feat(stereo): A2 — пересечения построений + интерактивное дерево объектов

Фаза A2 раунда «Конструктор». Пересечения как list-based операция:
- прямая ∩ плоскость → точка (_cpoints, имена M,N,K…);
- плоскость ∩ плоскость → прямая;
- прямая ∩ прямая → точка либо «скрещиваются»/«параллельны».
Точки-пересечения пикабельны — по ним строятся новые прямые/плоскости.

- StereoSim: setIntersectMode/pickConstructObject (выбор 2 объектов),
  _computeIntersection + _intersectLinePlane/_intersectPlanePlane/
  _intersectLineLine, _createCPoint/_drawCPointObject/_cpointLabel,
  removeConstruction(id)/toggleConstructionVis(id), getConstructions
  переписан в дерево (id/type/hidden/selected/info), _pickConstructPoint
  теперь учитывает точки-пересечения. Сброс в setFigure, очистка/clear.
- Панель: кнопка «Пересечение»; список — интерактивные строки (выбор для
  пересечения, глаз=видимость, ×=удаление) через glue stereoIntersectMode/
  ConstructSelect/ConstructVis/ConstructDelete; интеграция в _stereoDeactivateTools.

Верификация: node --check OK; headless-смоук 34/34 (точная геометрия
line∩plane / plane∩plane / line∩line, параллельные/скрещ. без объекта,
list-pick поток, гард точки, дерево, видимость/удаление/remove-last,
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 16:59:20 +03:00
parent 53ac45bccd
commit abd1af2653
3 changed files with 267 additions and 25 deletions
+259 -22
View File
@@ -292,15 +292,19 @@ class StereoSim {
/* ── Construction layer (Phase A): lines & planes as named objects ──
Stored serialisably as plain {x,y,z}; rebuilt into _constructGroup. */
this._lines = []; // [{id,seq,name, a:{x,y,z}, b:{x,y,z}, color}]
this._planes = []; // [{id,seq,name, point:{x,y,z}, normal:{x,y,z}, def:[{x,y,z}×3], color}]
this._cpoints = []; // [{id,seq,name, pos:{x,y,z}, color, hidden}] — construction points (intersections)
this._lines = []; // [{id,seq,name, a:{x,y,z}, b:{x,y,z}, color, hidden}]
this._planes = []; // [{id,seq,name, point:{x,y,z}, normal:{x,y,z}, def:[{x,y,z}×3], color, hidden}]
this._constructGroup = new THREE.Group();
this.scene.add(this._constructGroup);
this._lineMode = false; // pick 2 points → infinite line
this._planeMode = false; // pick 3 points → plane (+ its cross-section of the solid)
this._intersectMode = false; // list-based: select 2 objects → intersection
this._intersectSel = []; // ids of objects selected for intersection
this._constructPicks = []; // temp Vector3 picks for the active construction tool
this._nextLineName = 0; // → a, b, c, …
this._nextPlaneName = 0; // → α, β, γ, …
this._nextCPointName = 0; // → M, N, K, …
this._constructSeq = 0; // monotonic insertion order (for "remove last")
this.onUpdate = null;
@@ -343,10 +347,11 @@ class StereoSim {
this._section3PMode = false;
this._section3PStep = 0;
this._clearGroup(this._section3PGroup);
this._lines = []; this._planes = [];
this._cpoints = []; this._lines = []; this._planes = [];
this._lineMode = false; this._planeMode = false;
this._intersectMode = false; this._intersectSel = [];
this._constructPicks = [];
this._nextLineName = 0; this._nextPlaneName = 0; this._constructSeq = 0;
this._nextLineName = 0; this._nextPlaneName = 0; this._nextCPointName = 0; this._constructSeq = 0;
this._clearGroup(this._constructGroup);
this._buildFigure();
this._notify();
@@ -3605,31 +3610,60 @@ class StereoSim {
removeLastConstruction() {
let best = -1, arr = null, idx = -1;
this._lines.forEach((l, i) => { if (l.seq > best) { best = l.seq; arr = this._lines; idx = i; } });
this._planes.forEach((p, i) => { if (p.seq > best) { best = p.seq; arr = this._planes; 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);
if (!arr) return;
arr.splice(idx, 1);
this._rebuildConstructions();
this._notify();
}
// Delete one object by id (point / line / plane).
removeConstruction(id) {
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; }
}
this._intersectSel = this._intersectSel.filter(x => x !== id);
this._rebuildConstructions();
this._notify();
}
// Toggle the visibility of one object by id (kept in the tree, hidden in 3D).
toggleConstructionVis(id) {
const o = this._findObj(id);
if (o) { o.obj.hidden = !o.obj.hidden; this._rebuildConstructions(); this._notify(); }
}
clearConstructions() {
this._lines = []; this._planes = []; this._constructPicks = [];
this._cpoints = []; this._lines = []; this._planes = []; this._constructPicks = [];
this._lineMode = false; this._planeMode = false;
this._intersectMode = false; this._intersectSel = [];
this._clearGroup(this._constructGroup);
this._notify();
}
// Human-readable summary for the panel "construction tree".
// Interactive tree summary for the panel (ids/types/visibility/selection).
getConstructions() {
const r = (v) => Math.round(v * 100) / 100;
const sel = this._intersectSel;
return {
lines: this._lines.map(l => ({ name: l.name })),
intersectMode: this._intersectMode,
points: this._cpoints.map(p => ({
id: p.id, name: p.name, hidden: !!p.hidden, selected: sel.includes(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),
})),
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 { name: p.name, eq: `${r(n.x)}x ${sign(n.y)}y ${sign(n.z)}z ${sign(D)} = 0` };
return {
id: p.id, name: p.name, hidden: !!p.hidden, selected: sel.includes(p.id),
info: `${r(n.x)}x ${sign(n.y)}y ${sign(n.z)}z ${sign(D)} = 0`,
};
}),
};
}
@@ -3655,10 +3689,153 @@ class StereoSim {
return r;
}
// Pick the nearest vertex / custom point under the cursor (Vector3 | null).
// Pick the nearest vertex / custom point / construction point (Vector3 | null).
_pickConstructPoint(e) {
const p = this._pickNearestPoint(e);
return p ? p.pos.clone() : null;
const { mx, my } = this._screenCoords(e);
let bestDist = 0.08, best = null;
const consider = (pos) => {
const p = pos.clone().project(this.camera);
const d = Math.hypot(p.x - mx, p.y - my);
if (d < bestDist) { bestDist = d; best = pos.clone(); }
};
for (const v of this._vertices) consider(v.pos);
for (const cp of this._customPoints) consider(cp.pos);
for (const cp of this._cpoints) consider(new THREE.Vector3(cp.pos.x, cp.pos.y, cp.pos.z));
return best;
}
_cpointLabel(i) {
const P = ['M', 'N', 'K', 'P', 'Q', 'S', 'T', 'U', 'V', 'W', 'F', 'G'];
const base = P[i % P.length];
const sub = Math.floor(i / P.length);
return sub > 0 ? base + '_' + sub : base;
}
/* ── Intersections (Phase A2): list-based — select 2 objects ── */
setIntersectMode(on) {
this._intersectMode = on;
this._intersectSel = [];
if (on) { this._lineMode = false; this._planeMode = false; this._constructPicks = []; }
this._rebuildConstructions();
this._notify();
}
_findObj(id) {
let o = this._cpoints.find(x => x.id === id); if (o) return { type: 'point', obj: o };
o = this._lines.find(x => x.id === id); if (o) return { type: 'line', obj: o };
o = this._planes.find(x => x.id === id); if (o) return { type: 'plane', obj: o };
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.
pickConstructObject(id) {
if (!this._intersectMode) return { msg: '' };
const found = this._findObj(id);
if (!found || found.type === 'point') return { msg: 'Для пересечения выберите прямую или плоскость' };
const i = this._intersectSel.indexOf(id);
if (i >= 0) { this._intersectSel.splice(i, 1); this._notify(); return { msg: '' }; }
this._intersectSel.push(id);
if (this._intersectSel.length < 2) { this._notify(); return { msg: 'Выберите второй объект' }; }
const [idA, idB] = this._intersectSel;
const res = this._computeIntersection(idA, idB);
this._intersectSel = [];
this._rebuildConstructions();
this._notify();
return { msg: res };
}
_lineFromObj(l) {
const p0 = new THREE.Vector3(l.a.x, l.a.y, l.a.z);
const d = new THREE.Vector3(l.b.x, l.b.y, l.b.z).sub(p0).normalize();
return { p0, d };
}
_planeFromObj(p) {
return {
n: new THREE.Vector3(p.normal.x, p.normal.y, p.normal.z).normalize(),
point: new THREE.Vector3(p.point.x, p.point.y, p.point.z),
};
}
_computeIntersection(idA, idB) {
const A = this._findObj(idA), B = this._findObj(idB);
if (!A || !B) return 'Объект не найден';
const types = [A.type, B.type].sort().join('+');
if (types === 'plane+plane') {
const r = this._intersectPlanePlane(A.obj, B.obj);
if (!r) return `${A.obj.name}${B.obj.name}: прямой пересечения нет`;
const ln = this._createLine(r.p0, r.p0.clone().add(r.dir));
return `прямая ${ln} = ${A.obj.name}${B.obj.name}`;
}
const line = A.type === 'line' ? A.obj : B.obj;
const plane = A.type === 'plane' ? A.obj : B.obj;
if (types === 'line+plane') {
const pt = this._intersectLinePlane(line, plane);
if (!pt) return `${line.name}${plane.name}: точки пересечения нет`;
const nm = this._createCPoint(pt);
return `точка ${nm} = ${line.name}${plane.name}`;
}
if (types === 'line+line') {
const r = this._intersectLineLine(A.obj, B.obj);
if (r === 'parallel') return `${A.obj.name}${B.obj.name}: точки нет`;
if (r === 'skew') return `${A.obj.name} и ${B.obj.name} скрещиваются: точки нет`;
const nm = this._createCPoint(r);
return `точка ${nm} = ${A.obj.name}${B.obj.name}`;
}
return 'Нельзя пересечь эти объекты';
}
_intersectLinePlane(l, pl) {
const { p0, d } = this._lineFromObj(l);
const { n, point } = this._planeFromObj(pl);
const denom = n.dot(d);
if (Math.abs(denom) < 1e-9) return null; // line ∥ plane
const t = n.dot(point.clone().sub(p0)) / denom;
return p0.clone().addScaledVector(d, t);
}
_intersectPlanePlane(pa, pb) {
const n1 = this._planeFromObj(pa).n, n2 = this._planeFromObj(pb).n;
const dir = new THREE.Vector3().crossVectors(n1, n2);
if (dir.length() < 1e-7) return null; // parallel planes
const c1 = n1.dot(this._planeFromObj(pa).point);
const c2 = n2.dot(this._planeFromObj(pb).point);
const term1 = new THREE.Vector3().crossVectors(n2, dir).multiplyScalar(c1);
const term2 = new THREE.Vector3().crossVectors(dir, n1).multiplyScalar(c2);
const p0 = term1.add(term2).divideScalar(dir.lengthSq());
return { p0, dir: dir.normalize() };
}
_intersectLineLine(la, lb) {
const { p0: P0, d: D0 } = this._lineFromObj(la);
const { p0: P1, d: D1 } = this._lineFromObj(lb);
const b = D0.dot(D1);
const denom = 1 - b * b; // D0,D1 are unit
if (Math.abs(denom) < 1e-9) return 'parallel';
const r = P0.clone().sub(P1);
const dd = D0.dot(r), e = D1.dot(r);
const s = (b * e - dd) / denom;
const tt = (e - b * dd) / denom;
const cp0 = P0.clone().addScaledVector(D0, s);
const cp1 = P1.clone().addScaledVector(D1, tt);
const tol = Math.max(0.12, this._sceneRadius() * 0.03);
if (cp0.distanceTo(cp1) > tol) return 'skew';
return cp0.add(cp1).multiplyScalar(0.5);
}
_createCPoint(pos) {
const name = this._cpointLabel(this._nextCPointName++);
this._cpoints.push({
id: 'C' + this._constructSeq, seq: this._constructSeq++,
name, pos: { x: pos.x, y: pos.y, z: pos.z }, color: 0x34D399, hidden: false,
});
return name;
}
_onConstructClick(e) {
@@ -3682,36 +3859,39 @@ class StereoSim {
}
_createLine(pA, pB) {
if (pA.distanceTo(pB) < 1e-6) return;
if (pA.distanceTo(pB) < 1e-6) return null;
const name = this._lineLabel(this._nextLineName++);
this._lines.push({
id: 'L' + this._constructSeq, seq: this._constructSeq++,
name, a: { x: pA.x, y: pA.y, z: pA.z }, b: { x: pB.x, y: pB.y, z: pB.z },
color: 0x38BDF8,
color: 0x38BDF8, hidden: false,
});
return name;
}
_createPlane(p1, p2, p3) {
const v1 = new THREE.Vector3().subVectors(p2, p1);
const v2 = new THREE.Vector3().subVectors(p3, p1);
const n = new THREE.Vector3().crossVectors(v1, v2);
if (n.length() < 1e-6) return; // 3 collinear points → no plane
if (n.length() < 1e-6) return null; // 3 collinear points → no plane
n.normalize();
const name = this._planeLabel(this._nextPlaneName++);
this._planes.push({
id: 'P' + this._constructSeq, seq: this._constructSeq++,
name, point: { x: p1.x, y: p1.y, z: p1.z }, normal: { x: n.x, y: n.y, z: n.z },
def: [p1, p2, p3].map(p => ({ x: p.x, y: p.y, z: p.z })),
color: 0xC4B5FD,
color: 0xC4B5FD, hidden: false,
});
return name;
}
_rebuildConstructions() {
if (!this._constructGroup) return;
this._clearGroup(this._constructGroup);
const ext = this._sceneRadius() * 1.6 + 2;
for (const pl of this._planes) this._drawPlaneObject(pl, ext);
for (const l of this._lines) this._drawLineObject(l, ext);
for (const pl of this._planes) if (!pl.hidden) this._drawPlaneObject(pl, ext);
for (const l of this._lines) if (!l.hidden) this._drawLineObject(l, ext);
for (const cp of this._cpoints) if (!cp.hidden) this._drawCPointObject(cp);
// in-progress picks (highlight spheres)
for (const p of this._constructPicks) {
const s = new THREE.Mesh(
@@ -3799,6 +3979,22 @@ class StereoSim {
}
}
_drawCPointObject(cp) {
const pos = new THREE.Vector3(cp.pos.x, cp.pos.y, cp.pos.z);
const glow = new THREE.Mesh(new THREE.SphereGeometry(0.18, 12, 12),
new THREE.MeshBasicMaterial({ color: cp.color, transparent: true, opacity: 0.18, blending: THREE.AdditiveBlending, depthWrite: false }));
glow.position.copy(pos);
this._constructGroup.add(glow);
const s = new THREE.Mesh(new THREE.SphereGeometry(0.11, 12, 12), new THREE.MeshBasicMaterial({ color: cp.color }));
s.position.copy(pos); s.renderOrder = 5;
this._constructGroup.add(s);
if (this.showLabels) {
const lbl = this._makeTextSprite(cp.name, '#6EE7B7', 44);
lbl.position.copy(pos).add(new THREE.Vector3(0.2, 0.28, 0));
this._constructGroup.add(lbl);
}
}
_clearGroup(group) {
const disposeObj = (o) => {
if (o.geometry) o.geometry.dispose();
@@ -4271,7 +4467,7 @@ 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'].forEach(id => {
'stereo-sect3p-btn','stereo-line-btn','stereo-plane-btn','stereo-intersect-btn'].forEach(id => {
document.getElementById(id)?.classList.remove('active');
});
if (stereoSim) {
@@ -4284,6 +4480,7 @@ class StereoSim {
stereoSim.toggleSection3P(false);
stereoSim.setLineMode(false);
stereoSim.setPlaneMode(false);
stereoSim.setIntersectMode(false);
}
const hint = document.getElementById('angle-hint');
if (hint) hint.textContent = '';
@@ -4444,13 +4641,53 @@ class StereoSim {
if (h) h.textContent = '';
}
function stereoIntersectMode(btn) {
const on = !btn.classList.contains('active');
_stereoDeactivateTools();
btn.classList.toggle('active', on);
if (stereoSim) stereoSim.setIntersectMode(on);
const h = document.getElementById('construct-hint');
if (h) h.textContent = on ? 'Выберите 2 объекта (прямые/плоскости) в списке ниже' : '';
}
function stereoConstructSelect(id) {
if (!stereoSim) return;
const res = stereoSim.pickConstructObject(id);
const h = document.getElementById('construct-hint');
if (h && res && res.msg) h.textContent = res.msg;
}
function stereoConstructDelete(id) { if (stereoSim) stereoSim.removeConstruction(id); }
function stereoConstructVis(id) { if (stereoSim) stereoSim.toggleConstructionVis(id); }
function _stereoUpdateConstructList() {
const el = document.getElementById('construct-list');
if (!el || !stereoSim) return;
const c = stereoSim.getConstructions();
const EYE_ON = '<svg viewBox="0 0 24 24" style="width:14px;height:14px"><path d="M1 12s4-7 11-7 11 7 11 7-4 7-11 7-11-7-11-7z" fill="none" stroke="currentColor" stroke-width="2"/><circle cx="12" cy="12" r="3" fill="currentColor"/></svg>';
const EYE_OFF = '<svg viewBox="0 0 24 24" style="width:14px;height:14px"><path d="M3 3l18 18M10.6 10.6a3 3 0 0 0 4.2 4.2M9.9 5.1A10.9 10.9 0 0 1 12 5c7 0 11 7 11 7a18 18 0 0 1-3.2 3.9M6.1 6.1A18 18 0 0 0 1 12s4 7 11 7c1.4 0 2.7-.2 3.9-.6" fill="none" stroke="currentColor" stroke-width="2"/></svg>';
const X_IC = '<svg viewBox="0 0 24 24" style="width:14px;height:14px"><line x1="6" y1="6" x2="18" y2="18" stroke="currentColor" stroke-width="2" stroke-linecap="round"/><line x1="18" y1="6" x2="6" y2="18" stroke="currentColor" stroke-width="2" stroke-linecap="round"/></svg>';
const icBtn = (id, fn, title, svg) =>
'<button onclick="' + fn + "('" + id + '\')" title="' + title + '" style="background:none;border:none;color:rgba(255,255,255,0.55);cursor:pointer;padding:1px;display:flex;align-items:center">' + svg + '</button>';
const row = (o, kind, color, selectable) => {
const selBg = o.selected ? 'background:rgba(56,189,248,0.2);' : '';
const dim = o.hidden ? 'opacity:0.4;' : '';
const main = '<span style="flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;' + dim +
(selectable ? 'cursor:pointer" onclick="stereoConstructSelect(\'' + o.id + '\')"' : '"') + '>' +
'<b style="color:' + color + '">' + kind + ' ' + o.name + '</b>' +
(o.info ? ' <span style="color:rgba(255,255,255,0.4)">' + o.info + '</span>' : '') + '</span>';
return '<div style="display:flex;align-items:center;gap:3px;padding:1px 3px;border-radius:5px;' + selBg + '">' +
main +
icBtn(o.id, 'stereoConstructVis', 'Скрыть/показать', o.hidden ? EYE_OFF : EYE_ON) +
icBtn(o.id, 'stereoConstructDelete', 'Удалить', X_IC) + '</div>';
};
const sel = c.intersectMode;
const rows = [];
c.lines.forEach(l => rows.push('<div style="color:#7DD3FC">прямая <b>' + l.name + '</b></div>'));
c.planes.forEach(p => rows.push('<div style="color:#DDD6FE">плоскость <b>' + p.name + '</b>: ' + p.eq + '</div>'));
c.points.forEach(p => rows.push(row(p, 'точка', '#6EE7B7', false)));
c.lines.forEach(l => rows.push(row(l, 'прямая', '#7DD3FC', sel)));
c.planes.forEach(p => rows.push(row(p, 'плоскость', '#DDD6FE', sel)));
el.innerHTML = rows.join('');
}
+3
View File
@@ -3681,6 +3681,9 @@
<button class="st-tool-btn" id="stereo-plane-btn" onclick="stereoPlaneMode(this)" title="Плоскость через 3 точки — кликните три вершины или точки">
<svg viewBox="0 0 24 24"><polygon points="3,16 13,20 21,8 11,4" fill="none"/><circle cx="3" cy="16" r="1.8" fill="currentColor"/><circle cx="21" cy="8" r="1.8" fill="currentColor"/><circle cx="11" cy="4" r="1.8" fill="currentColor"/></svg>Плоскость
</button>
<button class="st-tool-btn st-tool-btn-wide" id="stereo-intersect-btn" onclick="stereoIntersectMode(this)" title="Пересечение: выберите 2 объекта в списке (прямая∩плоскость → точка, плоскость∩плоскость → прямая)">
<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-action-grid" style="margin-top:3px">
<button class="st-action-btn" onclick="stereoConstructUndo()">Удалить последнее</button>