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 @@ Пересечение +