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:
+160
-12
@@ -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);
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 — Сечения+
|
||||
|
||||
|
||||
Reference in New Issue
Block a user