diff --git a/frontend/js/labs/_measure.js b/frontend/js/labs/_measure.js
new file mode 100644
index 0000000..d3df3c6
--- /dev/null
+++ b/frontend/js/labs/_measure.js
@@ -0,0 +1,125 @@
+'use strict';
+/* LabMeasure — измерительные инструменты поверх любой симуляции (Фаза 2).
+ * Линейка (длина в px + ≈ метрах при PX_PER_M) и угломер (угол при вершине).
+ * SVG-оверлей на весь экран с pointer-events:none, чтобы симуляция оставалась
+ * интерактивной; перехватывают события только перетаскиваемые ручки.
+ * Самодостаточно: свой DOM/CSS, не трогает симуляции. Точка входа — LabMeasure.toggle(). */
+(function (global) {
+ var NS = 'http://www.w3.org/2000/svg';
+ var PXM = (global.LabPalette && LabPalette.PX_PER_M) || 100;
+ var svg = null, bar = null, mode = null, drag = null;
+ var ruler = null, angle = null;
+
+ function el(tag, attrs) { var e = document.createElementNS(NS, tag); for (var k in attrs) e.setAttribute(k, attrs[k]); return e; }
+ function center() { return { x: global.innerWidth / 2, y: global.innerHeight / 2 }; }
+
+ function ensureStyle() {
+ if (document.getElementById('lm-style')) return;
+ var s = document.createElement('style'); s.id = 'lm-style';
+ s.textContent = [
+ '#lm-svg{position:fixed;inset:0;z-index:60;pointer-events:none;display:none;}',
+ '#lm-svg.on{display:block;}',
+ '#lm-svg .lm-hit{pointer-events:auto;cursor:grab;}',
+ '#lm-svg .lm-hit:active{cursor:grabbing;}',
+ '#lm-bar{position:fixed;top:64px;left:50%;transform:translateX(-50%);z-index:61;display:none;gap:6px;padding:6px;border-radius:12px;',
+ 'background:var(--surface,rgba(255,255,255,.9));backdrop-filter:var(--blur,blur(20px));border:1px solid var(--border,rgba(15,23,42,.1));box-shadow:0 8px 28px rgba(15,23,42,.18);}',
+ '#lm-bar.on{display:flex;}',
+ '.lm-tb{display:inline-flex;align-items:center;gap:6px;padding:7px 13px;border:none;border-radius:8px;background:transparent;',
+ 'font:700 .8rem Manrope,sans-serif;color:var(--text-2,#475569);cursor:pointer;}',
+ '.lm-tb:hover{background:rgba(155,93,229,.08);color:var(--violet,#9B5DE5);}',
+ '.lm-tb.on{background:var(--violet,#9B5DE5);color:#fff;}',
+ '.lm-tb .ic{width:15px;height:15px;}',
+ ].join('');
+ document.head.appendChild(s);
+ }
+
+ function ensure() {
+ if (svg) return;
+ ensureStyle();
+ svg = el('svg', { id: 'lm-svg' });
+ document.body.appendChild(svg);
+ bar = document.createElement('div'); bar.id = 'lm-bar';
+ bar.innerHTML =
+ '' +
+ '' +
+ '';
+ document.body.appendChild(bar);
+ bar.addEventListener('click', function (e) { var b = e.target.closest('.lm-tb'); if (b) setTool(b.dataset.t); });
+
+ // drag delegation
+ svg.addEventListener('pointerdown', function (e) {
+ var h = e.target.getAttribute && e.target.getAttribute('data-h');
+ if (!h) return;
+ drag = h; e.target.setPointerCapture && e.target.setPointerCapture(e.pointerId); e.preventDefault();
+ });
+ svg.addEventListener('pointermove', function (e) {
+ if (!drag) return;
+ var p = (mode === 'ruler') ? ruler : angle;
+ p[drag + 'x'] = e.clientX; p[drag + 'y'] = e.clientY;
+ render();
+ });
+ function end() { drag = null; }
+ svg.addEventListener('pointerup', end);
+ svg.addEventListener('pointercancel', end);
+ global.addEventListener('resize', function () { if (mode) render(); });
+ }
+
+ function setTool(t) {
+ ensure();
+ if (t === 'off') { mode = null; svg.classList.remove('on'); paintBar(); render(); return; }
+ mode = t;
+ var c = center();
+ if (t === 'ruler' && !ruler) ruler = { ax: c.x - 110, ay: c.y, bx: c.x + 110, by: c.y };
+ if (t === 'angle' && !angle) angle = { vx: c.x, vy: c.y + 70, ax: c.x - 120, ay: c.y - 40, bx: c.x + 120, by: c.y - 40 };
+ svg.classList.add('on');
+ paintBar(); render();
+ }
+ function paintBar() { if (!bar) return; bar.querySelectorAll('.lm-tb').forEach(function (b) { b.classList.toggle('on', b.dataset.t === mode); }); }
+
+ function lineLabel(x, y, text) {
+ var g = el('g', {});
+ var pad = 5, w = text.length * 7.2 + pad * 2, h = 20;
+ g.appendChild(el('rect', { x: x - w / 2, y: y - h / 2, width: w, height: h, rx: 6, fill: 'rgba(13,13,26,.85)' }));
+ var t = el('text', { x: x, y: y + 4, 'text-anchor': 'middle', fill: '#fff', 'font-size': 12, 'font-family': 'Manrope,sans-serif', 'font-weight': 700 });
+ t.textContent = text; g.appendChild(t);
+ return g;
+ }
+ function handle(hx, hy, name) { return el('circle', { cx: hx, cy: hy, r: 9, class: 'lm-hit', fill: '#9B5DE5', stroke: '#fff', 'stroke-width': 2, 'data-h': name }); }
+
+ function render() {
+ if (!svg) return;
+ while (svg.firstChild) svg.removeChild(svg.firstChild);
+ if (!mode) return;
+ if (mode === 'ruler') {
+ var r = ruler, dx = r.bx - r.ax, dy = r.by - r.ay;
+ var dpx = Math.hypot(dx, dy), deg = Math.abs(Math.atan2(dy, dx) * 180 / Math.PI);
+ if (deg > 90) deg = 180 - deg;
+ svg.appendChild(el('line', { x1: r.ax, y1: r.ay, x2: r.bx, y2: r.by, stroke: '#9B5DE5', 'stroke-width': 2.5 }));
+ svg.appendChild(handle(r.ax, r.ay, 'a'));
+ svg.appendChild(handle(r.bx, r.by, 'b'));
+ var lab = Math.round(dpx) + ' px · ' + (dpx / PXM).toFixed(2) + ' м · ' + deg.toFixed(1) + '°';
+ svg.appendChild(lineLabel((r.ax + r.bx) / 2, (r.ay + r.by) / 2 - 18, lab));
+ } else if (mode === 'angle') {
+ var a = angle;
+ var a1 = Math.atan2(a.ay - a.vy, a.ax - a.vx), a2 = Math.atan2(a.by - a.vy, a.bx - a.vx);
+ var deg2 = Math.abs((a1 - a2) * 180 / Math.PI); if (deg2 > 180) deg2 = 360 - deg2;
+ svg.appendChild(el('line', { x1: a.vx, y1: a.vy, x2: a.ax, y2: a.ay, stroke: '#06D6E0', 'stroke-width': 2.5 }));
+ svg.appendChild(el('line', { x1: a.vx, y1: a.vy, x2: a.bx, y2: a.by, stroke: '#06D6E0', 'stroke-width': 2.5 }));
+ // дуга угла
+ var rr = 36, s = el('path', { d: 'M ' + (a.vx + rr * Math.cos(a1)) + ' ' + (a.vy + rr * Math.sin(a1)) +
+ ' A ' + rr + ' ' + rr + ' 0 ' + (deg2 > 180 ? 1 : 0) + ' ' + ((a2 - a1 + 2 * Math.PI) % (2 * Math.PI) < Math.PI ? 1 : 0) + ' ' +
+ (a.vx + rr * Math.cos(a2)) + ' ' + (a.vy + rr * Math.sin(a2)), fill: 'none', stroke: '#06D6E0', 'stroke-width': 2, opacity: .6 });
+ svg.appendChild(s);
+ svg.appendChild(handle(a.ax, a.ay, 'a'));
+ svg.appendChild(handle(a.bx, a.by, 'b'));
+ svg.appendChild(handle(a.vx, a.vy, 'v'));
+ svg.appendChild(lineLabel(a.vx, a.vy + 26, deg2.toFixed(1) + '°'));
+ }
+ }
+
+ global.LabMeasure = {
+ toggle: function () { ensure(); if (bar.classList.contains('on')) { this.hide(); } else { bar.classList.add('on'); if (!mode) setTool('ruler'); } },
+ hide: function () { if (bar) bar.classList.remove('on'); if (svg) svg.classList.remove('on'); mode = null; paintBar(); render(); },
+ setTool: setTool,
+ };
+})(window);
diff --git a/frontend/lab.html b/frontend/lab.html
index fdc44c7..69b08c3 100644
--- a/frontend/lab.html
+++ b/frontend/lab.html
@@ -357,6 +357,11 @@
+
+
+