diff --git a/frontend/js/labs/stereo.js b/frontend/js/labs/stereo.js index 35c4aa7..9329b96 100644 --- a/frontend/js/labs/stereo.js +++ b/frontend/js/labs/stereo.js @@ -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 = ''; + const EYE_OFF = ''; + const X_IC = ''; + const icBtn = (id, fn, title, svg) => + ''; + + 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 = ''; + return '