From 9382b063aa18334d7d8a12761257cd2d2ced2ac0 Mon Sep 17 00:00:00 2001 From: Maxim Dolgolyov Date: Wed, 17 Jun 2026 17:07:43 +0300 Subject: [PATCH] =?UTF-8?q?feat(stereo):=20A3=20=E2=80=94=20=D0=BF=D0=B0?= =?UTF-8?q?=D1=80=D0=B0=D0=BB=D0=BB=D0=B5=D0=BB=D0=B8/=D0=BF=D0=B5=D1=80?= =?UTF-8?q?=D0=BF=D0=B5=D0=BD=D0=B4=D0=B8=D0=BA=D1=83=D0=BB=D1=8F=D1=80?= =?UTF-8?q?=D1=8B=20+=20=D0=BE=D0=B1=D1=89=D0=B8=D0=B9=20undo/redo=20?= =?UTF-8?q?=D0=BF=D0=BE=D1=81=D1=82=D1=80=D0=BE=D0=B5=D0=BD=D0=B8=D0=B9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Фаза A3 раунда «Конструктор». Построения через точку, опираясь на объект: - lpar: прямая ∥ выбранной прямой; - lperp: прямая ⟂ выбранной плоскости (вдоль нормали); - ppar: плоскость ∥ выбранной плоскости; - pperp: плоскость ⟂ выбранной прямой (= плоскость по точке+нормали, через _createPlaneFromPointNormal — мост к Фазе C). Поток: кнопка op → выбор опоры в дереве → клик точки. Общий undo/redo конструкторного слоя: JSON-снапшоты _undoStack/_redoStack (кап 60), хуки _pushHistory в create/remove/clear; Ctrl+Z / Ctrl+Shift+Z / Ctrl+Y + кнопки «Отменить»/«Вернуть». Видимость объекта — не шаг истории. - StereoSim: setRelMode/_pickRelRef/_onRelClick/_createPlaneFromPointNormal; _snapshot/_pushHistory/_restoreSnapshot/undo/redo/canUndo/canRedo; pickConstructObject диспатчит rel/intersect; getConstructions отдаёт relMode + selected по опоре; _lastConstructMsg → flash в подсказку. Сброс rel/истории в setFigure, очистка в clearConstructions. - Панель: 4 кнопки (∥/⟂ прямая/плоск.) + «Отменить»/«Вернуть»; интеграция в _stereoDeactivateTools; glue stereoRelMode/HistUndo/HistRedo; дерево — строки выбираемы и в rel-режиме. Верификация: node --check OK; headless-смоук 30/30 (4 rel-операции с проверкой параллельности направлений/нормалей, гард типа опоры, undo/redo одиночный/многошаговый/redo-сброс/clear-undoable/vis-не-шаг/кап, setFigure- сброс истории, dispose); эмодзи/eval/new Function — 0. Co-Authored-By: Claude Opus 4.8 (1M context) --- frontend/js/labs/stereo.js | 172 ++++++++++++++++++++++++++++++--- frontend/labs-bodies.html | 18 ++++ plans/STEREO_3D_IMPROVEMENT.md | 9 +- 3 files changed, 184 insertions(+), 15 deletions(-) diff --git a/frontend/js/labs/stereo.js b/frontend/js/labs/stereo.js index 9329b96..17a879f 100644 --- a/frontend/js/labs/stereo.js +++ b/frontend/js/labs/stereo.js @@ -69,7 +69,7 @@ class StereoSim { this._velX = 0; this._velY = 0; try { el.setPointerCapture(e.pointerId); } catch (_) {} if (this._panning) el.style.cursor = 'move'; - else if (!this._pointMode && !this._connectMode && !this._measureMode && !this._angleMode && !this._section3PMode && !this._lineMode && !this._planeMode) el.style.cursor = 'grabbing'; + else if (!this._pointMode && !this._connectMode && !this._measureMode && !this._angleMode && !this._section3PMode && !this._lineMode && !this._planeMode && !this._relMode) el.style.cursor = 'grabbing'; this._invalidate(); }); on(el, 'contextmenu', e => e.preventDefault()); // allow right-drag pan without menu @@ -88,6 +88,7 @@ class StereoSim { else if (this._deriveMode) { el.style.cursor = 'crosshair'; if (!wasDrag) this._onDeriveClick(e); } else if (this._section3PMode) { el.style.cursor = 'crosshair'; if (!wasDrag) this._onSection3PClick(e); } else if (this._lineMode || this._planeMode) { el.style.cursor = 'crosshair'; if (!wasDrag) this._onConstructClick(e); } + else if (this._relMode) { el.style.cursor = 'crosshair'; if (!wasDrag) this._onRelClick(e); } else el.style.cursor = 'grab'; this._invalidate(); }); @@ -115,6 +116,14 @@ class StereoSim { // Keyboard navigation (a11y) — works when the canvas is focused. on(el, 'keydown', e => { + // Undo / redo of constructions (Ctrl/Cmd+Z, Ctrl+Shift+Z, Ctrl+Y) + if ((e.ctrlKey || e.metaKey) && (e.key === 'z' || e.key === 'Z')) { + if (e.shiftKey) this.redo(); else this.undo(); + e.preventDefault(); return; + } + if ((e.ctrlKey || e.metaKey) && (e.key === 'y' || e.key === 'Y')) { + this.redo(); e.preventDefault(); return; + } const STEP = 0.12; let handled = true; switch (e.key) { @@ -306,6 +315,11 @@ class StereoSim { this._nextPlaneName = 0; // → α, β, γ, … this._nextCPointName = 0; // → M, N, K, … this._constructSeq = 0; // monotonic insertion order (for "remove last") + this._relMode = null; // {op, refId} parallel/perpendicular through a point + this._lastConstructMsg = ''; // transient result text for the panel hint + this._undoStack = []; // construction-layer history (JSON snapshots) + this._redoStack = []; + this._undoMax = 60; this.onUpdate = null; @@ -350,6 +364,8 @@ class StereoSim { this._cpoints = []; this._lines = []; this._planes = []; this._lineMode = false; this._planeMode = false; this._intersectMode = false; this._intersectSel = []; + this._relMode = null; this._lastConstructMsg = ''; + this._undoStack = []; this._redoStack = []; this._constructPicks = []; this._nextLineName = 0; this._nextPlaneName = 0; this._nextCPointName = 0; this._constructSeq = 0; this._clearGroup(this._constructGroup); @@ -3613,6 +3629,7 @@ class StereoSim { const scan = (a) => a.forEach((o, i) => { if (o.seq > best) { best = o.seq; arr = a; idx = i; } }); scan(this._cpoints); scan(this._lines); scan(this._planes); if (!arr) return; + this._pushHistory(); arr.splice(idx, 1); this._rebuildConstructions(); this._notify(); @@ -3620,6 +3637,8 @@ class StereoSim { // Delete one object by id (point / line / plane). removeConstruction(id) { + if (!this._findObj(id)) return; + this._pushHistory(); for (const a of [this._cpoints, this._lines, this._planes]) { const i = a.findIndex(o => o.id === id); if (i >= 0) { a.splice(i, 1); break; } @@ -3636,32 +3655,76 @@ class StereoSim { } clearConstructions() { + if (this._cpoints.length || this._lines.length || this._planes.length) this._pushHistory(); this._cpoints = []; this._lines = []; this._planes = []; this._constructPicks = []; this._lineMode = false; this._planeMode = false; this._intersectMode = false; this._intersectSel = []; + this._relMode = null; this._clearGroup(this._constructGroup); this._notify(); } + /* ── Undo / redo of the construction layer (JSON snapshots) ── */ + + _snapshot() { + return JSON.stringify({ + cpoints: this._cpoints, lines: this._lines, planes: this._planes, + nl: this._nextLineName, np: this._nextPlaneName, nc: this._nextCPointName, seq: this._constructSeq, + }); + } + + _pushHistory() { + this._undoStack.push(this._snapshot()); + if (this._undoStack.length > this._undoMax) this._undoStack.shift(); + this._redoStack = []; + } + + _restoreSnapshot(json) { + const s = JSON.parse(json); + this._cpoints = s.cpoints || []; this._lines = s.lines || []; this._planes = s.planes || []; + this._nextLineName = s.nl || 0; this._nextPlaneName = s.np || 0; this._nextCPointName = s.nc || 0; + this._constructSeq = s.seq || 0; + this._intersectSel = []; this._relMode = null; + this._rebuildConstructions(); + this._notify(); + } + + canUndo() { return this._undoStack.length > 0; } + canRedo() { return this._redoStack.length > 0; } + + undo() { + if (!this._undoStack.length) return; + this._redoStack.push(this._snapshot()); + this._restoreSnapshot(this._undoStack.pop()); + } + + redo() { + if (!this._redoStack.length) return; + this._undoStack.push(this._snapshot()); + this._restoreSnapshot(this._redoStack.pop()); + } + // Interactive tree summary for the panel (ids/types/visibility/selection). getConstructions() { const r = (v) => Math.round(v * 100) / 100; - const sel = this._intersectSel; + const relRef = this._relMode ? this._relMode.refId : null; + const isSel = (id) => this._intersectSel.includes(id) || id === relRef; return { intersectMode: this._intersectMode, + relMode: !!this._relMode, points: this._cpoints.map(p => ({ - id: p.id, name: p.name, hidden: !!p.hidden, selected: sel.includes(p.id), + id: p.id, name: p.name, hidden: !!p.hidden, selected: isSel(p.id), info: `(${r(p.pos.x)}, ${r(p.pos.y)}, ${r(p.pos.z)})`, })), lines: this._lines.map(l => ({ - id: l.id, name: l.name, hidden: !!l.hidden, selected: sel.includes(l.id), + id: l.id, name: l.name, hidden: !!l.hidden, selected: isSel(l.id), })), planes: this._planes.map(p => { const n = p.normal; const D = -(n.x * p.point.x + n.y * p.point.y + n.z * p.point.z); const sign = (v) => (v >= 0 ? '+ ' : '− ') + Math.abs(r(v)); return { - id: p.id, name: p.name, hidden: !!p.hidden, selected: sel.includes(p.id), + id: p.id, name: p.name, hidden: !!p.hidden, selected: isSel(p.id), info: `${r(n.x)}x ${sign(n.y)}y ${sign(n.z)}z ${sign(D)} = 0`, }; }), @@ -3728,9 +3791,10 @@ class StereoSim { return null; } - // Select an object for intersection; once 2 (line/plane) are chosen, compute. - // Returns { msg } describing the result (or the prompt), for the panel hint. + // Select an object: dispatches to relative-construction reference picking, + // or to intersection (select 2 objects). Returns { msg } for the panel hint. pickConstructObject(id) { + if (this._relMode) return this._pickRelRef(id); if (!this._intersectMode) return { msg: '' }; const found = this._findObj(id); if (!found || found.type === 'point') return { msg: 'Для пересечения выберите прямую или плоскость' }; @@ -3830,6 +3894,7 @@ class StereoSim { } _createCPoint(pos) { + this._pushHistory(); const name = this._cpointLabel(this._nextCPointName++); this._cpoints.push({ id: 'C' + this._constructSeq, seq: this._constructSeq++, @@ -3838,6 +3903,61 @@ class StereoSim { return name; } + /* ── Parallels / perpendiculars through a point (Phase A3) ── + op: lpar = прямая ∥ прямой · lperp = прямая ⟂ плоскости + ppar = плоскость ∥ плоскости · pperp = плоскость ⟂ прямой (точка+нормаль). + Flow: choose op → select reference object in the tree → click a point. */ + + setRelMode(op) { + this._relMode = op ? { op, refId: null } : null; + if (op) { + this._lineMode = false; this._planeMode = false; + this._intersectMode = false; this._intersectSel = []; this._constructPicks = []; + } + this._rebuildConstructions(); + this._notify(); + } + + _pickRelRef(id) { + const found = this._findObj(id); + if (!found || found.type === 'point') return { msg: 'Опора — прямая или плоскость' }; + const op = this._relMode.op; + const needLine = (op === 'lpar' || op === 'pperp'); + const needPlane = (op === 'lperp' || op === 'ppar'); + if (needLine && found.type !== 'line') return { msg: 'Опора этой операции — прямая' }; + if (needPlane && found.type !== 'plane') return { msg: 'Опора этой операции — плоскость' }; + this._relMode.refId = (this._relMode.refId === id) ? null : id; + this._notify(); + return { msg: this._relMode.refId ? 'Теперь кликните точку на сцене' : 'Опора снята' }; + } + + _onRelClick(e) { + if (!this._relMode || !this._relMode.refId) return; // need a reference first + const ref = this._findObj(this._relMode.refId); + if (!ref) { this._relMode.refId = null; return; } + const pt = this._pickConstructPoint(e); + if (!pt) return; + if (window.LabFX) LabFX.sound.play('tick', { pitch: 1.6, volume: 0.25 }); + const op = this._relMode.op; + let nm = null, kind = ''; + if (op === 'lpar') { const { d } = this._lineFromObj(ref.obj); nm = this._createLine(pt, pt.clone().add(d)); kind = 'прямая'; } + else if (op === 'lperp') { const { n } = this._planeFromObj(ref.obj); nm = this._createLine(pt, pt.clone().add(n)); kind = 'прямая'; } + else if (op === 'ppar') { const { n } = this._planeFromObj(ref.obj); nm = this._createPlaneFromPointNormal(pt, n); kind = 'плоскость'; } + else if (op === 'pperp') { const { d } = this._lineFromObj(ref.obj); nm = this._createPlaneFromPointNormal(pt, d); kind = 'плоскость'; } + this._lastConstructMsg = nm ? (kind + ' ' + nm) : ''; + this._rebuildConstructions(); + this._notify(); + } + + _createPlaneFromPointNormal(point, normal) { + const n = normal.clone().normalize(); + if (n.length() < 1e-6) return null; + let u = Math.abs(n.x) > 0.9 ? new THREE.Vector3(0, 1, 0) : new THREE.Vector3(1, 0, 0); + u = new THREE.Vector3().crossVectors(n, u).normalize(); + const w = new THREE.Vector3().crossVectors(n, u).normalize(); + return this._createPlane(point.clone(), point.clone().addScaledVector(u, 1), point.clone().addScaledVector(w, 1)); + } + _onConstructClick(e) { if (!this._lineMode && !this._planeMode) return; const pos = this._pickConstructPoint(e); @@ -3850,8 +3970,13 @@ class StereoSim { const need = this._lineMode ? 2 : 3; if (this._constructPicks.length >= need) { - if (this._lineMode) this._createLine(this._constructPicks[0], this._constructPicks[1]); - else this._createPlane(this._constructPicks[0], this._constructPicks[1], this._constructPicks[2]); + if (this._lineMode) { + const nm = this._createLine(this._constructPicks[0], this._constructPicks[1]); + this._lastConstructMsg = nm ? ('прямая ' + nm) : ''; + } else { + const nm = this._createPlane(this._constructPicks[0], this._constructPicks[1], this._constructPicks[2]); + this._lastConstructMsg = nm ? ('плоскость ' + nm) : 'не удалось: 3 точки на одной прямой'; + } this._constructPicks = []; } this._rebuildConstructions(); @@ -3860,6 +3985,7 @@ class StereoSim { _createLine(pA, pB) { if (pA.distanceTo(pB) < 1e-6) return null; + this._pushHistory(); const name = this._lineLabel(this._nextLineName++); this._lines.push({ id: 'L' + this._constructSeq, seq: this._constructSeq++, @@ -3875,6 +4001,7 @@ class StereoSim { const n = new THREE.Vector3().crossVectors(v1, v2); if (n.length() < 1e-6) return null; // 3 collinear points → no plane n.normalize(); + this._pushHistory(); const name = this._planeLabel(this._nextPlaneName++); this._planes.push({ id: 'P' + this._constructSeq, seq: this._constructSeq++, @@ -4467,7 +4594,8 @@ class StereoSim { 'stereo-angle-edge-btn','stereo-angle-lp-btn','stereo-angle-dih-btn','stereo-angle-pp-btn','stereo-angle-skew-btn', 'stereo-mark-tick-btn','stereo-mark-par-btn', 'stereo-derive-mid-btn','stereo-derive-fc-btn','stereo-derive-alt-btn','stereo-derive-cen-btn', - 'stereo-sect3p-btn','stereo-line-btn','stereo-plane-btn','stereo-intersect-btn'].forEach(id => { + 'stereo-sect3p-btn','stereo-line-btn','stereo-plane-btn','stereo-intersect-btn', + 'stereo-rel-lpar-btn','stereo-rel-lperp-btn','stereo-rel-ppar-btn','stereo-rel-pperp-btn'].forEach(id => { document.getElementById(id)?.classList.remove('active'); }); if (stereoSim) { @@ -4481,6 +4609,7 @@ class StereoSim { stereoSim.setLineMode(false); stereoSim.setPlaneMode(false); stereoSim.setIntersectMode(false); + stereoSim.setRelMode(null); } const hint = document.getElementById('angle-hint'); if (hint) hint.textContent = ''; @@ -4660,6 +4789,18 @@ class StereoSim { function stereoConstructDelete(id) { if (stereoSim) stereoSim.removeConstruction(id); } function stereoConstructVis(id) { if (stereoSim) stereoSim.toggleConstructionVis(id); } + function stereoRelMode(op, btn) { + const on = !btn.classList.contains('active'); + _stereoDeactivateTools(); + btn.classList.toggle('active', on); + if (stereoSim) stereoSim.setRelMode(on ? op : null); + const h = document.getElementById('construct-hint'); + if (h) h.textContent = on ? 'Выберите опорный объект в списке, затем кликните точку на сцене' : ''; + } + + function stereoConstructHistUndo() { if (stereoSim) stereoSim.undo(); } + function stereoConstructHistRedo() { if (stereoSim) stereoSim.redo(); } + function _stereoUpdateConstructList() { const el = document.getElementById('construct-list'); if (!el || !stereoSim) return; @@ -4683,7 +4824,7 @@ class StereoSim { icBtn(o.id, 'stereoConstructDelete', 'Удалить', X_IC) + ''; }; - const sel = c.intersectMode; + const sel = c.intersectMode || c.relMode; const rows = []; c.points.forEach(p => rows.push(row(p, 'точка', '#6EE7B7', false))); c.lines.forEach(l => rows.push(row(l, 'прямая', '#7DD3FC', sel))); @@ -4820,9 +4961,16 @@ class StereoSim { // Section-3P panel _stereoUpdateSection3PPanel(); - // Construction tree (lines & planes) + // Construction tree (points / lines / planes) _stereoUpdateConstructList(); + // Flash the result of the last canvas-driven construction into the hint. + if (stereoSim && stereoSim._lastConstructMsg) { + const ch = document.getElementById('construct-hint'); + if (ch) ch.textContent = stereoSim._lastConstructMsg; + stereoSim._lastConstructMsg = ''; + } + // Live readout overlay (section type/area/perimeter, last measurement) _stereoUpdateReadout(info); diff --git a/frontend/labs-bodies.html b/frontend/labs-bodies.html index 267d6f7..765480b 100644 --- a/frontend/labs-bodies.html +++ b/frontend/labs-bodies.html @@ -3685,6 +3685,24 @@ Пересечение +
+ + + + +
+
+ + +
diff --git a/plans/STEREO_3D_IMPROVEMENT.md b/plans/STEREO_3D_IMPROVEMENT.md index 28b56c4..834ca6c 100644 --- a/plans/STEREO_3D_IMPROVEMENT.md +++ b/plans/STEREO_3D_IMPROVEMENT.md @@ -85,9 +85,12 @@ → прямая; прямая∩прямая → точка или «скрещиваются») — выбор 2 объектов в дереве (`setIntersectMode`/ `pickConstructObject`). **Интерактивное дерево**: видимость (глаз)/удаление (×) по объекту, выбор для пересечения. Точки-пересечения пикабельны → по ним строятся новые прямые/плоскости. -- [ ] A3 — **Параллели/перпендикуляры** (прямая ∥ прямой через точку; прямая ⟂ плоскости; - плоскость ∥ плоскости; плоскость ⟂ прямой = «плоскость по точке и нормали» — мост к Фазе C) + - **общий undo/redo** (снапшот всех пользовательских массивов построения, Ctrl+Z/Ctrl+Shift+Z). +- [x] A3 — **Параллели/перпендикуляры** через точку (`setRelMode`/`_onRelClick`): `lpar` прямая ∥ + прямой; `lperp` прямая ⟂ плоскости; `ppar` плоскость ∥ плоскости; `pperp` плоскость ⟂ прямой + (= «плоскость по точке и нормали» через `_createPlaneFromPointNormal` — мост к Фазе C). Поток: + кнопка op → выбор опоры в дереве → клик точки. **Общий undo/redo** конструкторного слоя (JSON- + снапшот `_undoStack`/`_redoStack`, кап 60; хуки в create/remove/clear; Ctrl+Z / Ctrl+Shift+Z / + Ctrl+Y + кнопки «Отменить»/«Вернуть»). Видимость — не шаг истории (намеренно). ### Фаза C — Сечения+