feat(stereo): A3 — параллели/перпендикуляры + общий undo/redo построений

Фаза 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) <noreply@anthropic.com>
This commit is contained in:
Maxim Dolgolyov
2026-06-17 17:07:43 +03:00
parent abd1af2653
commit 9382b063aa
3 changed files with 184 additions and 15 deletions
+160 -12
View File
@@ -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) + '</div>';
};
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);
+18
View File
@@ -3685,6 +3685,24 @@
<svg viewBox="0 0 24 24"><line x1="3" y1="7" x2="21" y2="17"/><line x1="3" y1="17" x2="21" y2="7"/><circle cx="12" cy="12" r="2.4" fill="currentColor"/></svg>Пересечение
</button>
</div>
<div class="st-tool-grid" style="margin-top:3px">
<button class="st-tool-btn" id="stereo-rel-lpar-btn" onclick="stereoRelMode('lpar',this)" title="Прямая, параллельная выбранной прямой, через точку">
<svg viewBox="0 0 24 24"><line x1="4" y1="8" x2="20" y2="6"/><line x1="4" y1="18" x2="20" y2="16"/></svg>∥ прямая
</button>
<button class="st-tool-btn" id="stereo-rel-lperp-btn" onclick="stereoRelMode('lperp',this)" title="Прямая, перпендикулярная выбранной плоскости, через точку">
<svg viewBox="0 0 24 24"><ellipse cx="12" cy="17" rx="9" ry="3" fill="none"/><line x1="12" y1="3" x2="12" y2="17"/><path d="M12 14 L15 14 L15 17" fill="none"/></svg>⟂ прямая
</button>
<button class="st-tool-btn" id="stereo-rel-ppar-btn" onclick="stereoRelMode('ppar',this)" title="Плоскость, параллельная выбранной плоскости, через точку">
<svg viewBox="0 0 24 24"><polygon points="3,11 12,14 21,6 12,3" fill="none"/><polygon points="3,18 12,21 21,13 12,10" fill="none"/></svg>∥ плоск.
</button>
<button class="st-tool-btn" id="stereo-rel-pperp-btn" onclick="stereoRelMode('pperp',this)" title="Плоскость, перпендикулярная выбранной прямой, через точку">
<svg viewBox="0 0 24 24"><polygon points="3,14 12,17 21,9 12,6" fill="none"/><line x1="12" y1="2" x2="12" y2="21"/></svg>⟂ плоск.
</button>
</div>
<div class="st-action-grid" style="margin-top:3px">
<button class="st-action-btn" onclick="stereoConstructHistUndo()" title="Отменить (Ctrl+Z)">Отменить</button>
<button class="st-action-btn" onclick="stereoConstructHistRedo()" title="Вернуть (Ctrl+Shift+Z)">Вернуть</button>
</div>
<div class="st-action-grid" style="margin-top:3px">
<button class="st-action-btn" onclick="stereoConstructUndo()">Удалить последнее</button>
<button class="st-action-btn" onclick="stereoConstructClear()">Очистить</button>