feat(stereo): B — умные точки (деление m:n, координаты, перетаскивание)

Фаза 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) <noreply@anthropic.com>
This commit is contained in:
Maxim Dolgolyov
2026-06-17 17:28:22 +03:00
parent 24403718bf
commit 9547a20875
3 changed files with 215 additions and 5 deletions
+183 -5
View File
@@ -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;
+21
View File
@@ -3699,6 +3699,27 @@
<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-tool-grid" style="margin-top:3px">
<button class="st-tool-btn" id="stereo-divide-btn" onclick="stereoDivideMode(this)" title="Точка, делящая отрезок AB в отношении m:n — задайте m,n и кликните 2 точки">
<svg viewBox="0 0 24 24"><line x1="3" y1="12" x2="21" y2="12"/><circle cx="3" cy="12" r="1.8" fill="currentColor"/><circle cx="21" cy="12" r="1.8" fill="currentColor"/><circle cx="11" cy="12" r="2.6" fill="currentColor"/></svg>Деление
</button>
<button class="st-tool-btn" id="stereo-dragpt-btn" onclick="stereoDragPointMode(this)" title="Перетаскивать построенные точки мышью (в плоскости экрана)">
<svg viewBox="0 0 24 24"><circle cx="12" cy="12" r="3" fill="currentColor"/><path d="M12 3v3M12 18v3M3 12h3M18 12h3"/></svg>Тащить
</button>
</div>
<div style="display:flex;align-items:center;gap:5px;margin-top:4px;font-size:.72rem;color:rgba(255,255,255,.55)">
<span>m</span>
<input id="st-div-m" type="number" min="0" step="1" value="1" oninput="stereoDivideRatio()" style="width:40px;background:#1a1030;color:#f0e8ff;border:1px solid rgba(255,255,255,.12);border-radius:6px;padding:3px 4px;font-size:.72rem">
<span>:</span>
<input id="st-div-n" type="number" min="0" step="1" value="1" oninput="stereoDivideRatio()" style="width:40px;background:#1a1030;color:#f0e8ff;border:1px solid rgba(255,255,255,.12);border-radius:6px;padding:3px 4px;font-size:.72rem">
<span>n</span><span style="opacity:.55">(AM:MB)</span>
</div>
<div style="display:flex;align-items:center;gap:4px;margin-top:4px">
<input id="st-pt-x" type="number" step="0.5" value="0" title="x" style="width:42px;background:#1a1030;color:#f0e8ff;border:1px solid rgba(255,255,255,.12);border-radius:6px;padding:3px 4px;font-size:.72rem">
<input id="st-pt-y" type="number" step="0.5" value="0" title="y" style="width:42px;background:#1a1030;color:#f0e8ff;border:1px solid rgba(255,255,255,.12);border-radius:6px;padding:3px 4px;font-size:.72rem">
<input id="st-pt-z" type="number" step="0.5" value="0" title="z" style="width:42px;background:#1a1030;color:#f0e8ff;border:1px solid rgba(255,255,255,.12);border-radius:6px;padding:3px 4px;font-size:.72rem">
<button class="st-action-btn" onclick="stereoAddCoordPoint()" style="flex:1" title="Поставить точку по координатам">Точка (x,y,z)</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>