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; this._velX = 0; this._velY = 0;
try { el.setPointerCapture(e.pointerId); } catch (_) {} try { el.setPointerCapture(e.pointerId); } catch (_) {}
if (this._panning) el.style.cursor = 'move'; 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(); this._invalidate();
}); });
on(el, 'contextmenu', e => e.preventDefault()); // allow right-drag pan without menu 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._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._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._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'; else el.style.cursor = 'grab';
this._invalidate(); this._invalidate();
}); });
@@ -115,6 +116,14 @@ class StereoSim {
// Keyboard navigation (a11y) — works when the canvas is focused. // Keyboard navigation (a11y) — works when the canvas is focused.
on(el, 'keydown', e => { 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; const STEP = 0.12;
let handled = true; let handled = true;
switch (e.key) { switch (e.key) {
@@ -306,6 +315,11 @@ class StereoSim {
this._nextPlaneName = 0; // → α, β, γ, … this._nextPlaneName = 0; // → α, β, γ, …
this._nextCPointName = 0; // → M, N, K, … this._nextCPointName = 0; // → M, N, K, …
this._constructSeq = 0; // monotonic insertion order (for "remove last") 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; this.onUpdate = null;
@@ -350,6 +364,8 @@ class StereoSim {
this._cpoints = []; this._lines = []; this._planes = []; this._cpoints = []; this._lines = []; this._planes = [];
this._lineMode = false; this._planeMode = false; this._lineMode = false; this._planeMode = false;
this._intersectMode = false; this._intersectSel = []; this._intersectMode = false; this._intersectSel = [];
this._relMode = null; this._lastConstructMsg = '';
this._undoStack = []; this._redoStack = [];
this._constructPicks = []; this._constructPicks = [];
this._nextLineName = 0; this._nextPlaneName = 0; this._nextCPointName = 0; this._constructSeq = 0; this._nextLineName = 0; this._nextPlaneName = 0; this._nextCPointName = 0; this._constructSeq = 0;
this._clearGroup(this._constructGroup); 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; } }); 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); scan(this._cpoints); scan(this._lines); scan(this._planes);
if (!arr) return; if (!arr) return;
this._pushHistory();
arr.splice(idx, 1); arr.splice(idx, 1);
this._rebuildConstructions(); this._rebuildConstructions();
this._notify(); this._notify();
@@ -3620,6 +3637,8 @@ class StereoSim {
// Delete one object by id (point / line / plane). // Delete one object by id (point / line / plane).
removeConstruction(id) { removeConstruction(id) {
if (!this._findObj(id)) return;
this._pushHistory();
for (const a of [this._cpoints, this._lines, this._planes]) { for (const a of [this._cpoints, this._lines, this._planes]) {
const i = a.findIndex(o => o.id === id); const i = a.findIndex(o => o.id === id);
if (i >= 0) { a.splice(i, 1); break; } if (i >= 0) { a.splice(i, 1); break; }
@@ -3636,32 +3655,76 @@ class StereoSim {
} }
clearConstructions() { clearConstructions() {
if (this._cpoints.length || this._lines.length || this._planes.length) this._pushHistory();
this._cpoints = []; this._lines = []; this._planes = []; this._constructPicks = []; this._cpoints = []; this._lines = []; this._planes = []; this._constructPicks = [];
this._lineMode = false; this._planeMode = false; this._lineMode = false; this._planeMode = false;
this._intersectMode = false; this._intersectSel = []; this._intersectMode = false; this._intersectSel = [];
this._relMode = null;
this._clearGroup(this._constructGroup); this._clearGroup(this._constructGroup);
this._notify(); 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). // Interactive tree summary for the panel (ids/types/visibility/selection).
getConstructions() { getConstructions() {
const r = (v) => Math.round(v * 100) / 100; 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 { return {
intersectMode: this._intersectMode, intersectMode: this._intersectMode,
relMode: !!this._relMode,
points: this._cpoints.map(p => ({ 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)})`, info: `(${r(p.pos.x)}, ${r(p.pos.y)}, ${r(p.pos.z)})`,
})), })),
lines: this._lines.map(l => ({ 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 => { planes: this._planes.map(p => {
const n = p.normal; const n = p.normal;
const D = -(n.x * p.point.x + n.y * p.point.y + n.z * p.point.z); 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)); const sign = (v) => (v >= 0 ? '+ ' : ' ') + Math.abs(r(v));
return { 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`, info: `${r(n.x)}x ${sign(n.y)}y ${sign(n.z)}z ${sign(D)} = 0`,
}; };
}), }),
@@ -3728,9 +3791,10 @@ class StereoSim {
return null; return null;
} }
// Select an object for intersection; once 2 (line/plane) are chosen, compute. // Select an object: dispatches to relative-construction reference picking,
// Returns { msg } describing the result (or the prompt), for the panel hint. // or to intersection (select 2 objects). Returns { msg } for the panel hint.
pickConstructObject(id) { pickConstructObject(id) {
if (this._relMode) return this._pickRelRef(id);
if (!this._intersectMode) return { msg: '' }; if (!this._intersectMode) return { msg: '' };
const found = this._findObj(id); const found = this._findObj(id);
if (!found || found.type === 'point') return { msg: 'Для пересечения выберите прямую или плоскость' }; if (!found || found.type === 'point') return { msg: 'Для пересечения выберите прямую или плоскость' };
@@ -3830,6 +3894,7 @@ class StereoSim {
} }
_createCPoint(pos) { _createCPoint(pos) {
this._pushHistory();
const name = this._cpointLabel(this._nextCPointName++); const name = this._cpointLabel(this._nextCPointName++);
this._cpoints.push({ this._cpoints.push({
id: 'C' + this._constructSeq, seq: this._constructSeq++, id: 'C' + this._constructSeq, seq: this._constructSeq++,
@@ -3838,6 +3903,61 @@ class StereoSim {
return name; 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) { _onConstructClick(e) {
if (!this._lineMode && !this._planeMode) return; if (!this._lineMode && !this._planeMode) return;
const pos = this._pickConstructPoint(e); const pos = this._pickConstructPoint(e);
@@ -3850,8 +3970,13 @@ class StereoSim {
const need = this._lineMode ? 2 : 3; const need = this._lineMode ? 2 : 3;
if (this._constructPicks.length >= need) { if (this._constructPicks.length >= need) {
if (this._lineMode) this._createLine(this._constructPicks[0], this._constructPicks[1]); if (this._lineMode) {
else this._createPlane(this._constructPicks[0], this._constructPicks[1], this._constructPicks[2]); 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._constructPicks = [];
} }
this._rebuildConstructions(); this._rebuildConstructions();
@@ -3860,6 +3985,7 @@ class StereoSim {
_createLine(pA, pB) { _createLine(pA, pB) {
if (pA.distanceTo(pB) < 1e-6) return null; if (pA.distanceTo(pB) < 1e-6) return null;
this._pushHistory();
const name = this._lineLabel(this._nextLineName++); const name = this._lineLabel(this._nextLineName++);
this._lines.push({ this._lines.push({
id: 'L' + this._constructSeq, seq: this._constructSeq++, id: 'L' + this._constructSeq, seq: this._constructSeq++,
@@ -3875,6 +4001,7 @@ class StereoSim {
const n = new THREE.Vector3().crossVectors(v1, v2); const n = new THREE.Vector3().crossVectors(v1, v2);
if (n.length() < 1e-6) return null; // 3 collinear points → no plane if (n.length() < 1e-6) return null; // 3 collinear points → no plane
n.normalize(); n.normalize();
this._pushHistory();
const name = this._planeLabel(this._nextPlaneName++); const name = this._planeLabel(this._nextPlaneName++);
this._planes.push({ this._planes.push({
id: 'P' + this._constructSeq, seq: this._constructSeq++, 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-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-mark-tick-btn','stereo-mark-par-btn',
'stereo-derive-mid-btn','stereo-derive-fc-btn','stereo-derive-alt-btn','stereo-derive-cen-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'); document.getElementById(id)?.classList.remove('active');
}); });
if (stereoSim) { if (stereoSim) {
@@ -4481,6 +4609,7 @@ class StereoSim {
stereoSim.setLineMode(false); stereoSim.setLineMode(false);
stereoSim.setPlaneMode(false); stereoSim.setPlaneMode(false);
stereoSim.setIntersectMode(false); stereoSim.setIntersectMode(false);
stereoSim.setRelMode(null);
} }
const hint = document.getElementById('angle-hint'); const hint = document.getElementById('angle-hint');
if (hint) hint.textContent = ''; if (hint) hint.textContent = '';
@@ -4660,6 +4789,18 @@ class StereoSim {
function stereoConstructDelete(id) { if (stereoSim) stereoSim.removeConstruction(id); } function stereoConstructDelete(id) { if (stereoSim) stereoSim.removeConstruction(id); }
function stereoConstructVis(id) { if (stereoSim) stereoSim.toggleConstructionVis(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() { function _stereoUpdateConstructList() {
const el = document.getElementById('construct-list'); const el = document.getElementById('construct-list');
if (!el || !stereoSim) return; if (!el || !stereoSim) return;
@@ -4683,7 +4824,7 @@ class StereoSim {
icBtn(o.id, 'stereoConstructDelete', 'Удалить', X_IC) + '</div>'; icBtn(o.id, 'stereoConstructDelete', 'Удалить', X_IC) + '</div>';
}; };
const sel = c.intersectMode; const sel = c.intersectMode || c.relMode;
const rows = []; const rows = [];
c.points.forEach(p => rows.push(row(p, 'точка', '#6EE7B7', false))); c.points.forEach(p => rows.push(row(p, 'точка', '#6EE7B7', false)));
c.lines.forEach(l => rows.push(row(l, 'прямая', '#7DD3FC', sel))); c.lines.forEach(l => rows.push(row(l, 'прямая', '#7DD3FC', sel)));
@@ -4820,9 +4961,16 @@ class StereoSim {
// Section-3P panel // Section-3P panel
_stereoUpdateSection3PPanel(); _stereoUpdateSection3PPanel();
// Construction tree (lines & planes) // Construction tree (points / lines / planes)
_stereoUpdateConstructList(); _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) // Live readout overlay (section type/area/perimeter, last measurement)
_stereoUpdateReadout(info); _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>Пересечение <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> </button>
</div> </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"> <div class="st-action-grid" style="margin-top:3px">
<button class="st-action-btn" onclick="stereoConstructUndo()">Удалить последнее</button> <button class="st-action-btn" onclick="stereoConstructUndo()">Удалить последнее</button>
<button class="st-action-btn" onclick="stereoConstructClear()">Очистить</button> <button class="st-action-btn" onclick="stereoConstructClear()">Очистить</button>
+6 -3
View File
@@ -85,9 +85,12 @@
→ прямая; прямая∩прямая → точка или «скрещиваются») — выбор 2 объектов в дереве (`setIntersectMode`/ → прямая; прямая∩прямая → точка или «скрещиваются») — выбор 2 объектов в дереве (`setIntersectMode`/
`pickConstructObject`). **Интерактивное дерево**: видимость (глаз)/удаление (×) по объекту, выбор `pickConstructObject`). **Интерактивное дерево**: видимость (глаз)/удаление (×) по объекту, выбор
для пересечения. Точки-пересечения пикабельны → по ним строятся новые прямые/плоскости. для пересечения. Точки-пересечения пикабельны → по ним строятся новые прямые/плоскости.
- [ ] A3 — **Параллели/перпендикуляры** (прямая ∥ прямой через точку; прямая ⟂ плоскости; - [x] A3 — **Параллели/перпендикуляры** через точку (`setRelMode`/`_onRelClick`): `lpar` прямая ∥
плоскость ∥ плоскости; плоскость прямой = «плоскость по точке и нормали» — мост к Фазе C) + прямой; `lperp` прямая ⟂ плоскости; `ppar` плоскость плоскости; `pperp` плоскость ⟂ прямой
**общий undo/redo** (снапшот всех пользовательских массивов построения, Ctrl+Z/Ctrl+Shift+Z). (= «плоскость по точке и нормали» через `_createPlaneFromPointNormal` — мост к Фазе C). Поток:
кнопка op → выбор опоры в дереве → клик точки. **Общий undo/redo** конструкторного слоя (JSON-
снапшот `_undoStack`/`_redoStack`, кап 60; хуки в create/remove/clear; Ctrl+Z / Ctrl+Shift+Z /
Ctrl+Y + кнопки «Отменить»/«Вернуть»). Видимость — не шаг истории (намеренно).
### Фаза C — Сечения+ ### Фаза C — Сечения+