From 9547a208751bf40ddbd6f6ab22ee1630784248a2 Mon Sep 17 00:00:00 2001 From: Maxim Dolgolyov Date: Wed, 17 Jun 2026 17:28:22 +0300 Subject: [PATCH] =?UTF-8?q?feat(stereo):=20B=20=E2=80=94=20=D1=83=D0=BC?= =?UTF-8?q?=D0=BD=D1=8B=D0=B5=20=D1=82=D0=BE=D1=87=D0=BA=D0=B8=20(=D0=B4?= =?UTF-8?q?=D0=B5=D0=BB=D0=B5=D0=BD=D0=B8=D0=B5=20m:n,=20=D0=BA=D0=BE?= =?UTF-8?q?=D0=BE=D1=80=D0=B4=D0=B8=D0=BD=D0=B0=D1=82=D1=8B,=20=D0=BF?= =?UTF-8?q?=D0=B5=D1=80=D0=B5=D1=82=D0=B0=D1=81=D0=BA=D0=B8=D0=B2=D0=B0?= =?UTF-8?q?=D0=BD=D0=B8=D0=B5)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Фаза B раунда «Конструктор» (умные точки для построений). B1 — деление отрезка m:n: задаёшь m,n, кликаешь 2 точки A,B → точка делит AB как AM:MB = m:n (t=m/(m+n)), создаётся как точка-построение M,N,K… B2 — точка по координатам: поля x/y/z + кнопка → addPointAt. B3 — перетаскивание построенных точек мышью: drag в плоскости, обращённой к камере (нормаль фиксируется на старте), приоритет над орбитой; снапшот истории на старте → undo откатывает весь drag. Непараметрично: downstream- объекты за перетаскиванием не следуют (параметрический граф — бэклог). - StereoSim: setDivideMode/setDivideRatio (+ ветка в _onConstructClick), addPointAt; setDragPointMode/_pickCPointAt/_beginCPointDrag/_rayPlaneHit/ _dragCPointWithRay/_dragCPointAt/_endCPointDrag; pointer-хендлеры (down=начать drag, move=тащить, up=завершить); сброс в setFigure; интеграция в _stereoDeactivateTools. - Панель: блок «Точки» (кнопки Деление/Тащить, поля m:n, поля x,y,z + «Точка (x,y,z)»); glue stereoDivideMode/DivideRatio/AddCoordPoint/ DragPointMode. Верификация: node --check OK; headless-смоук 25/25 (деление 1:1/1:2/3:1, координатная точка + отказ NaN, ray∩plane вкл. parallel/behind, drag begin→ move→end с проверкой позиции и снапшота истории + undo, взаимоисключение режимов, setFigure-сброс, dispose); эмодзи/eval/new Function — 0. Co-Authored-By: Claude Opus 4.8 (1M context) --- frontend/js/labs/stereo.js | 188 ++++++++++++++++++++++++++++++++- frontend/labs-bodies.html | 21 ++++ plans/STEREO_3D_IMPROVEMENT.md | 11 ++ 3 files changed, 215 insertions(+), 5 deletions(-) diff --git a/frontend/js/labs/stereo.js b/frontend/js/labs/stereo.js index ae82d40..19095f9 100644 --- a/frontend/js/labs/stereo.js +++ b/frontend/js/labs/stereo.js @@ -62,6 +62,16 @@ class StereoSim { on(el, 'pointerdown', e => { this._clickStart = { x: e.clientX, y: e.clientY }; + // Drag a construction point (left button) — takes priority over orbit. + if (this._dragPointMode && e.button === 0 && !e.shiftKey) { + const hit = this._pickCPointAt(e); + if (hit && this._beginCPointDrag(hit)) { + try { el.setPointerCapture(e.pointerId); } catch (_) {} + el.style.cursor = 'grabbing'; + this._autoSpin = false; this._idleTime = 0; + return; + } + } // Right / middle button or Shift = pan; left button = orbit. this._panning = (e.button === 1 || e.button === 2 || e.shiftKey); this._drag = true; this._prevX = e.clientX; this._prevY = e.clientY; @@ -69,11 +79,18 @@ 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 && !this._relMode) el.style.cursor = 'grabbing'; + else if (!this._pointMode && !this._connectMode && !this._measureMode && !this._angleMode && !this._section3PMode && !this._lineMode && !this._planeMode && !this._relMode && !this._divideMode && !this._dragPointMode) el.style.cursor = 'grabbing'; this._invalidate(); }); on(el, 'contextmenu', e => e.preventDefault()); // allow right-drag pan without menu on(el, 'pointerup', e => { + if (this._draggingCP) { + this._endCPointDrag(); + try { el.releasePointerCapture(e.pointerId); } catch (_) {} + el.style.cursor = 'grab'; + this._notify(); this._invalidate(); + return; + } const wasDrag = this._clickStart && (Math.abs(e.clientX - this._clickStart.x) > 4 || Math.abs(e.clientY - this._clickStart.y) > 4); this._drag = false; @@ -87,12 +104,13 @@ class StereoSim { else if (this._markMode) { el.style.cursor = 'pointer'; if (!wasDrag) this._onMarkClick(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._lineMode || this._planeMode) { el.style.cursor = 'crosshair'; if (!wasDrag) this._onConstructClick(e); } + else if (this._lineMode || this._planeMode || this._divideMode) { 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(); }); on(el, 'pointermove', e => { + if (this._draggingCP) { this._dragCPointAt(e); this._idleTime = 0; this._invalidate(); return; } this._onHoverMove(e); if (!this._drag) return; const dx = e.clientX - this._prevX, dy = e.clientY - this._prevY; @@ -317,6 +335,12 @@ class StereoSim { this._constructSeq = 0; // monotonic insertion order (for "remove last") this._relMode = null; // {op, refId} parallel/perpendicular through a point this._sectionPlaneId = null; // id of the plane shown as a filled, measured section + this._divideMode = false; // pick 2 points → point dividing the segment m:n + this._divM = 1; // division ratio m + this._divN = 1; // division ratio n + this._dragPointMode = false; // drag construction points in the screen-facing plane + this._draggingCP = null; // id of the construction point being dragged + this._dragPlane = null; // {point, normal} drag plane fixed at grab time this._lastConstructMsg = ''; // transient result text for the panel hint this._undoStack = []; // construction-layer history (JSON snapshots) this._redoStack = []; @@ -366,6 +390,7 @@ class StereoSim { this._lineMode = false; this._planeMode = false; this._intersectMode = false; this._intersectSel = []; this._relMode = null; this._sectionPlaneId = null; this._lastConstructMsg = ''; + this._divideMode = false; this._dragPointMode = false; this._draggingCP = null; this._dragPlane = null; this._undoStack = []; this._redoStack = []; this._constructPicks = []; this._nextLineName = 0; this._nextPlaneName = 0; this._nextCPointName = 0; this._constructSeq = 0; @@ -4043,7 +4068,7 @@ class StereoSim { } _onConstructClick(e) { - if (!this._lineMode && !this._planeMode) return; + if (!this._lineMode && !this._planeMode && !this._divideMode) return; const pos = this._pickConstructPoint(e); if (!pos) return; // ignore a second click on (almost) the same point @@ -4052,11 +4077,18 @@ class StereoSim { this._constructPicks.push(pos); if (window.LabFX) LabFX.sound.play('tick', { pitch: 1.6, volume: 0.25 }); - const need = this._lineMode ? 2 : 3; + const need = this._planeMode ? 3 : 2; if (this._constructPicks.length >= need) { if (this._lineMode) { const nm = this._createLine(this._constructPicks[0], this._constructPicks[1]); this._lastConstructMsg = nm ? ('прямая ' + nm) : ''; + } else if (this._divideMode) { + const A = this._constructPicks[0], B = this._constructPicks[1]; + const sum = this._divM + this._divN; + const t = sum > 0 ? this._divM / sum : 0.5; + const M = A.clone().addScaledVector(B.clone().sub(A), t); + const nm = this._createCPoint(M); + this._lastConstructMsg = nm ? ('точка ' + nm + ' делит AB как ' + this._divM + ':' + this._divN) : ''; } else { const nm = this._createPlane(this._constructPicks[0], this._constructPicks[1], this._constructPicks[2]); this._lastConstructMsg = nm ? ('плоскость ' + nm) : 'не удалось: 3 точки на одной прямой'; @@ -4067,6 +4099,106 @@ class StereoSim { this._notify(); } + /* ── Smart points (Phase B): division m:n, coordinate input ── */ + + setDivideMode(on) { + this._divideMode = on; + if (on) { + this._lineMode = false; this._planeMode = false; + this._intersectMode = false; this._intersectSel = []; this._relMode = null; + this._constructPicks = []; + } + this.renderer.domElement.style.cursor = on ? 'crosshair' : 'grab'; + this._rebuildConstructions(); + this._notify(); + } + + setDivideRatio(m, n) { + m = +m; n = +n; + if (isFinite(m) && m >= 0) this._divM = m; + if (isFinite(n) && n >= 0) this._divN = n; + } + + addPointAt(x, y, z) { + x = +x; y = +y; z = +z; + if (!isFinite(x) || !isFinite(y) || !isFinite(z)) return null; + const nm = this._createCPoint(new THREE.Vector3(x, y, z)); + this._lastConstructMsg = nm ? ('точка ' + nm + ' (' + x + ', ' + y + ', ' + z + ')') : ''; + this._rebuildConstructions(); + this._notify(); + return nm; + } + + /* ── Drag construction points in the screen-facing plane (Phase B) ── */ + + setDragPointMode(on) { + this._dragPointMode = on; + if (on) { + this._lineMode = false; this._planeMode = false; this._divideMode = false; + this._intersectMode = false; this._intersectSel = []; this._relMode = null; + this._constructPicks = []; + } + this.renderer.domElement.style.cursor = 'grab'; + this._rebuildConstructions(); + this._notify(); + } + + _pickCPointAt(e) { + const { mx, my } = this._screenCoords(e); + let bestDist = 0.05, best = null; + for (const cp of this._cpoints) { + if (cp.hidden) continue; + const p = new THREE.Vector3(cp.pos.x, cp.pos.y, cp.pos.z).project(this.camera); + const d = Math.hypot(p.x - mx, p.y - my); + if (d < bestDist) { bestDist = d; best = cp.id; } + } + return best; + } + + // Begin dragging a construction point; the drag plane faces the camera. + _beginCPointDrag(id) { + const cp = this._cpoints.find(p => p.id === id); + if (!cp) return false; + this._draggingCP = id; + const nrm = new THREE.Vector3(); + if (this.camera.getWorldDirection) this.camera.getWorldDirection(nrm); + if (nrm.length() < 1e-6) nrm.set(0, 0, 1); + this._dragPlane = { point: new THREE.Vector3(cp.pos.x, cp.pos.y, cp.pos.z), normal: nrm.normalize() }; + this._pushHistory(); + return true; + } + + _rayPlaneHit(ro, rd, planePoint, n) { + const denom = rd.dot(n); + if (Math.abs(denom) < 1e-9) return null; + const t = planePoint.clone().sub(ro).dot(n) / denom; + if (t < 0) return null; + return ro.clone().addScaledVector(rd, t); + } + + // Move the dragged point to where the given ray meets the drag plane. + _dragCPointWithRay(ro, rd) { + if (!this._draggingCP || !this._dragPlane) return; + const cp = this._cpoints.find(p => p.id === this._draggingCP); + if (!cp) return; + const hit = this._rayPlaneHit(ro, rd, this._dragPlane.point, this._dragPlane.normal); + if (!hit) return; + cp.pos = { x: hit.x, y: hit.y, z: hit.z }; + this._rebuildConstructions(); + } + + _dragCPointAt(e) { + const { mx, my } = this._screenCoords(e); + const ro = this.camera.position.clone(); + const rd = new THREE.Vector3(mx, my, 0.5).unproject(this.camera).sub(ro).normalize(); + this._dragCPointWithRay(ro, rd); + } + + _endCPointDrag() { + this._draggingCP = null; + this._dragPlane = null; + } + _createLine(pA, pB) { if (pA.distanceTo(pB) < 1e-6) return null; this._pushHistory(); @@ -4702,7 +4834,8 @@ class StereoSim { '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', - 'stereo-rel-lpar-btn','stereo-rel-lperp-btn','stereo-rel-ppar-btn','stereo-rel-pperp-btn'].forEach(id => { + 'stereo-rel-lpar-btn','stereo-rel-lperp-btn','stereo-rel-ppar-btn','stereo-rel-pperp-btn', + 'stereo-divide-btn','stereo-dragpt-btn'].forEach(id => { document.getElementById(id)?.classList.remove('active'); }); if (stereoSim) { @@ -4717,6 +4850,8 @@ class StereoSim { stereoSim.setPlaneMode(false); stereoSim.setIntersectMode(false); stereoSim.setRelMode(null); + stereoSim.setDivideMode(false); + stereoSim.setDragPointMode(false); } const hint = document.getElementById('angle-hint'); if (hint) hint.textContent = ''; @@ -4908,6 +5043,49 @@ class StereoSim { function stereoConstructHistUndo() { if (stereoSim) stereoSim.undo(); } function stereoConstructHistRedo() { if (stereoSim) stereoSim.redo(); } + /* ── Smart points (Phase B): division m:n, coordinate input, drag ── */ + function _stereoReadRatio() { + const m = parseFloat(document.getElementById('st-div-m')?.value); + const n = parseFloat(document.getElementById('st-div-n')?.value); + return { m: isFinite(m) && m >= 0 ? m : 1, n: isFinite(n) && n >= 0 ? n : 1 }; + } + + function stereoDivideMode(btn) { + const on = !btn.classList.contains('active'); + _stereoDeactivateTools(); + btn.classList.toggle('active', on); + if (stereoSim) { + const { m, n } = _stereoReadRatio(); + stereoSim.setDivideRatio(m, n); + stereoSim.setDivideMode(on); + } + const h = document.getElementById('construct-hint'); + if (h) h.textContent = on ? 'Кликните 2 точки A и B — точка разделит AB в отношении m:n' : ''; + } + + function stereoDivideRatio() { + if (!stereoSim) return; + const { m, n } = _stereoReadRatio(); + stereoSim.setDivideRatio(m, n); + } + + function stereoAddCoordPoint() { + if (!stereoSim) return; + const x = parseFloat(document.getElementById('st-pt-x')?.value) || 0; + const y = parseFloat(document.getElementById('st-pt-y')?.value) || 0; + const z = parseFloat(document.getElementById('st-pt-z')?.value) || 0; + stereoSim.addPointAt(x, y, z); + } + + function stereoDragPointMode(btn) { + const on = !btn.classList.contains('active'); + _stereoDeactivateTools(); + btn.classList.toggle('active', on); + if (stereoSim) stereoSim.setDragPointMode(on); + const h = document.getElementById('construct-hint'); + if (h) h.textContent = on ? 'Тащите построенные точки (M, N…) мышью — двигаются в плоскости экрана' : ''; + } + function _stereoUpdateConstructList() { const el = document.getElementById('construct-list'); if (!el || !stereoSim) return; diff --git a/frontend/labs-bodies.html b/frontend/labs-bodies.html index 695243b..530044e 100644 --- a/frontend/labs-bodies.html +++ b/frontend/labs-bodies.html @@ -3699,6 +3699,27 @@ ⟂ плоск. +
+ + +
+
+ m + + : + + n(AM:MB) +
+
+ + + + +
diff --git a/plans/STEREO_3D_IMPROVEMENT.md b/plans/STEREO_3D_IMPROVEMENT.md index cbb49ff..e93a325 100644 --- a/plans/STEREO_3D_IMPROVEMENT.md +++ b/plans/STEREO_3D_IMPROVEMENT.md @@ -92,6 +92,17 @@ снапшот `_undoStack`/`_redoStack`, кап 60; хуки в create/remove/clear; Ctrl+Z / Ctrl+Shift+Z / Ctrl+Y + кнопки «Отменить»/«Вернуть»). Видимость — не шаг истории (намеренно). +### Фаза B — Умные точки + +- [x] B1 — **Деление отрезка m:n** (`setDivideMode`/`setDivideRatio`): задаёшь m,n → кликаешь 2 точки A,B + → точка делит AB как AM:MB = m:n (`t = m/(m+n)`), создаётся как `_cpoints` (M,N,K…). +- [x] B2 — **Точка по координатам** (`addPointAt(x,y,z)`): поля x/y/z + кнопка → точка-построение. +- [x] B3 — **Перетаскивание точек** (`setDragPointMode`): pointerdown по точке-построению (приоритет над + орбитой) → drag в плоскости, обращённой к камере (нормаль = направление камеры, фикс. на старте); + `_beginCPointDrag`/`_dragCPointWithRay`/`_rayPlaneHit`/`_endCPointDrag`; снапшот истории на старте + (undo откатывает весь drag). Точки-пересечения/деления непараметрические (downstream-объекты + копируют позицию при создании и за перетаскиванием НЕ следуют — параметрический граф = бэклог). + ### Фаза C — Сечения+ - [x] C1 — Сечение **плоскостью-объектом** (из Фазы A): клик по плоскости в дереве (нормальный режим)