diff --git a/frontend/js/labs/stereo.js b/frontend/js/labs/stereo.js index 97abfe0..35c4aa7 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) el.style.cursor = 'grabbing'; + else if (!this._pointMode && !this._connectMode && !this._measureMode && !this._angleMode && !this._section3PMode && !this._lineMode && !this._planeMode) el.style.cursor = 'grabbing'; this._invalidate(); }); on(el, 'contextmenu', e => e.preventDefault()); // allow right-drag pan without menu @@ -87,6 +87,7 @@ 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 el.style.cursor = 'grab'; this._invalidate(); }); @@ -289,6 +290,19 @@ class StereoSim { this._section3PData = null; // computed result {normal,D,polygon,area,typeName} this._stepCaption = ''; // caption for the current trace-method step + /* ── 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._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._constructPicks = []; // temp Vector3 picks for the active construction tool + this._nextLineName = 0; // → a, b, c, … + this._nextPlaneName = 0; // → α, β, γ, … + this._constructSeq = 0; // monotonic insertion order (for "remove last") + this.onUpdate = null; this._buildGrid(); @@ -329,6 +343,11 @@ class StereoSim { this._section3PMode = false; this._section3PStep = 0; this._clearGroup(this._section3PGroup); + this._lines = []; this._planes = []; + this._lineMode = false; this._planeMode = false; + this._constructPicks = []; + this._nextLineName = 0; this._nextPlaneName = 0; this._constructSeq = 0; + this._clearGroup(this._constructGroup); this._buildFigure(); this._notify(); } @@ -346,7 +365,7 @@ class StereoSim { toggleEdges(v) { this.showEdges = v; this._buildFigure(); } toggleVertices(v) { this.showVertices = v; this._buildFigure(); } - toggleLabels(v) { this.showLabels = v; this._buildFigure(); } + toggleLabels(v) { this.showLabels = v; this._buildFigure(); this._rebuildConstructions(); } toggleAxes(v) { this.showAxes = v; this._buildGrid(); } toggleGrid(v) { this.showGrid = v; this._buildGrid(); } @@ -793,6 +812,7 @@ class StereoSim { circumscribedR: this.showCircumscribed ? this._circumscribedRadius() : null, customPoints: this._customPoints.length, connections: this._connections.length, + constructions: this._lines.length + this._planes.length, readout: this.getReadout(), }; } @@ -915,7 +935,8 @@ class StereoSim { } [this._figGroup, this._labelGroup, this._sectionGroup, this._sphereGroup, this._measureGroup, this._measurePickGroup, this._gridGroup, this._markGroup, - this._derivedGroup, this._section3PGroup, this._angleGroup, this._pointGroup] + this._derivedGroup, this._section3PGroup, this._angleGroup, this._pointGroup, + this._constructGroup] .forEach(g => g && this._clearGroup(g)); if (this._tooltipEl && this._tooltipEl.parentNode) this._tooltipEl.parentNode.removeChild(this._tooltipEl); if (this.renderer) { @@ -3562,6 +3583,222 @@ class StereoSim { /* ════════════════ UTILS ════════════════ */ + /* ════════════════ CONSTRUCTION LAYER (Phase A) ════════════════ */ + /* Lines (a,b,c…) & planes (α,β,γ…) built by picking points. Everything is + rebuilt from the serialisable _lines / _planes arrays into _constructGroup. */ + + setLineMode(on) { + this._lineMode = on; + if (on) this._planeMode = false; + this._constructPicks = []; + this.renderer.domElement.style.cursor = on ? 'crosshair' : 'grab'; + this._rebuildConstructions(); + } + + setPlaneMode(on) { + this._planeMode = on; + if (on) this._lineMode = false; + this._constructPicks = []; + this.renderer.domElement.style.cursor = on ? 'crosshair' : 'grab'; + this._rebuildConstructions(); + } + + 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; } }); + if (!arr) return; + arr.splice(idx, 1); + this._rebuildConstructions(); + this._notify(); + } + + clearConstructions() { + this._lines = []; this._planes = []; this._constructPicks = []; + this._lineMode = false; this._planeMode = false; + this._clearGroup(this._constructGroup); + this._notify(); + } + + // Human-readable summary for the panel "construction tree". + getConstructions() { + const r = (v) => Math.round(v * 100) / 100; + return { + lines: this._lines.map(l => ({ name: l.name })), + 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` }; + }), + }; + } + + _lineLabel(i) { + const base = String.fromCharCode(97 + (i % 26)); // a..z + const sub = Math.floor(i / 26); + return sub > 0 ? base + '_' + sub : base; + } + + _planeLabel(i) { + const G = ['α','β','γ','δ','ε','ζ','η','θ','λ','μ','π','ρ','σ','τ','φ','ψ','ω']; + const base = G[i % G.length]; + const sub = Math.floor(i / G.length); + return sub > 0 ? base + '_' + sub : base; + } + + // World-space radius enclosing the figure (for sizing infinite lines / planes). + _sceneRadius() { + let r = 0; + for (const v of this._vertices) r = Math.max(r, v.pos.length()); + if (r < 1e-3) r = this._figureHeight() || 4; + return r; + } + + // Pick the nearest vertex / custom point under the cursor (Vector3 | null). + _pickConstructPoint(e) { + const p = this._pickNearestPoint(e); + return p ? p.pos.clone() : null; + } + + _onConstructClick(e) { + if (!this._lineMode && !this._planeMode) return; + const pos = this._pickConstructPoint(e); + if (!pos) return; + // ignore a second click on (almost) the same point + const last = this._constructPicks[this._constructPicks.length - 1]; + if (last && last.distanceTo(pos) < 1e-4) return; + this._constructPicks.push(pos); + if (window.LabFX) LabFX.sound.play('tick', { pitch: 1.6, volume: 0.25 }); + + 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]); + this._constructPicks = []; + } + this._rebuildConstructions(); + this._notify(); + } + + _createLine(pA, pB) { + if (pA.distanceTo(pB) < 1e-6) return; + 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, + }); + } + + _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 + 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, + }); + } + + _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); + // in-progress picks (highlight spheres) + for (const p of this._constructPicks) { + const s = new THREE.Mesh( + new THREE.SphereGeometry(0.13, 12, 12), + new THREE.MeshBasicMaterial({ color: 0xFB7185 })); + s.position.set(p.x, p.y, p.z); + s.renderOrder = 6; + this._constructGroup.add(s); + } + this._invalidate(); + } + + _drawLineObject(l, ext) { + const A = new THREE.Vector3(l.a.x, l.a.y, l.a.z); + const B = new THREE.Vector3(l.b.x, l.b.y, l.b.z); + const dir = new THREE.Vector3().subVectors(B, A).normalize(); + const p1 = A.clone().addScaledVector(dir, -ext); + const p2 = B.clone().addScaledVector(dir, ext); + const geo = new THREE.BufferGeometry().setFromPoints([p1, p2]); + const line = new THREE.Line(geo, new THREE.LineBasicMaterial({ color: l.color, transparent: true, opacity: 0.95 })); + line.renderOrder = 4; + this._constructGroup.add(line); + for (const P of [A, B]) { + const s = new THREE.Mesh(new THREE.SphereGeometry(0.09, 10, 10), new THREE.MeshBasicMaterial({ color: l.color })); + s.position.copy(P); s.renderOrder = 5; + this._constructGroup.add(s); + } + if (this.showLabels) { + const lbl = this._makeTextSprite(l.name, '#7DD3FC', 44); + lbl.position.copy(p2).addScaledVector(dir, -0.5).add(new THREE.Vector3(0.15, 0.25, 0)); + this._constructGroup.add(lbl); + } + } + + _drawPlaneObject(pl, ext) { + const point = new THREE.Vector3(pl.point.x, pl.point.y, pl.point.z); + const n = new THREE.Vector3(pl.normal.x, pl.normal.y, pl.normal.z).normalize(); + 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(); + const S = ext; + const corners = [ + point.clone().addScaledVector(u, S).addScaledVector(w, S), + point.clone().addScaledVector(u, S).addScaledVector(w, -S), + point.clone().addScaledVector(u, -S).addScaledVector(w, -S), + point.clone().addScaledVector(u, -S).addScaledVector(w, S), + ]; + const positions = []; + corners.forEach(p => positions.push(p.x, p.y, p.z)); + const geo = new THREE.BufferGeometry(); + geo.setAttribute('position', new THREE.Float32BufferAttribute(positions, 3)); + geo.setIndex([0, 1, 2, 0, 2, 3]); + const mesh = new THREE.Mesh(geo, new THREE.MeshBasicMaterial({ + color: pl.color, transparent: true, opacity: 0.12, side: THREE.DoubleSide, depthWrite: false })); + mesh.renderOrder = 1; + this._constructGroup.add(mesh); + + const border = [...corners, corners[0]]; + const bline = new THREE.Line(new THREE.BufferGeometry().setFromPoints(border), + new THREE.LineDashedMaterial({ color: pl.color, dashSize: 0.22, gapSize: 0.14, transparent: true, opacity: 0.7 })); + bline.computeLineDistances(); + bline.renderOrder = 2; + this._constructGroup.add(bline); + + // Cross-section of the solid by this plane — makes the plane immediately meaningful. + if (pl.def) { + try { + const poly = this._sliceByPlane( + new THREE.Vector3(pl.def[0].x, pl.def[0].y, pl.def[0].z), + new THREE.Vector3(pl.def[1].x, pl.def[1].y, pl.def[1].z), + new THREE.Vector3(pl.def[2].x, pl.def[2].y, pl.def[2].z)); + if (poly && poly.length >= 3) { + const sline = new THREE.Line(new THREE.BufferGeometry().setFromPoints([...poly, poly[0]]), + new THREE.LineBasicMaterial({ color: 0x06D6E0, transparent: true, opacity: 0.95 })); + sline.renderOrder = 4; + this._constructGroup.add(sline); + } + } catch (_) {} + } + + if (this.showLabels) { + const lbl = this._makeTextSprite(pl.name, '#DDD6FE', 48); + lbl.position.copy(point).add(new THREE.Vector3(0.2, 0.3, 0)); + this._constructGroup.add(lbl); + } + } + _clearGroup(group) { const disposeObj = (o) => { if (o.geometry) o.geometry.dispose(); @@ -4034,7 +4271,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'].forEach(id => { + 'stereo-sect3p-btn','stereo-line-btn','stereo-plane-btn'].forEach(id => { document.getElementById(id)?.classList.remove('active'); }); if (stereoSim) { @@ -4045,9 +4282,13 @@ class StereoSim { stereoSim.setMarkMode(null); stereoSim.setDeriveMode(null); stereoSim.toggleSection3P(false); + stereoSim.setLineMode(false); + stereoSim.setPlaneMode(false); } const hint = document.getElementById('angle-hint'); if (hint) hint.textContent = ''; + const chint = document.getElementById('construct-hint'); + if (chint) chint.textContent = ''; } function stereoMeasure(btn) { @@ -4173,6 +4414,46 @@ class StereoSim { _stereoUpdatePointsInfo(); } + /* ── Constructions: lines & planes (Phase A) ── */ + function stereoLineMode(btn) { + const on = !btn.classList.contains('active'); + _stereoDeactivateTools(); + btn.classList.toggle('active', on); + if (stereoSim) stereoSim.setLineMode(on); + const h = document.getElementById('construct-hint'); + if (h) h.textContent = on ? 'Кликните 2 точки или вершины — построится прямая' : ''; + } + + function stereoPlaneMode(btn) { + const on = !btn.classList.contains('active'); + _stereoDeactivateTools(); + btn.classList.toggle('active', on); + if (stereoSim) stereoSim.setPlaneMode(on); + const h = document.getElementById('construct-hint'); + if (h) h.textContent = on ? 'Кликните 3 точки или вершины — построится плоскость и её сечение тела' : ''; + } + + function stereoConstructUndo() { + if (stereoSim) stereoSim.removeLastConstruction(); + } + + function stereoConstructClear() { + _stereoDeactivateTools(); + if (stereoSim) stereoSim.clearConstructions(); + const h = document.getElementById('construct-hint'); + if (h) h.textContent = ''; + } + + function _stereoUpdateConstructList() { + const el = document.getElementById('construct-list'); + if (!el || !stereoSim) return; + const c = stereoSim.getConstructions(); + const rows = []; + c.lines.forEach(l => rows.push('
прямая ' + l.name + '
')); + c.planes.forEach(p => rows.push('
плоскость ' + p.name + ': ' + p.eq + '
')); + el.innerHTML = rows.join(''); + } + /* ── Section through 3 points UI ── */ function stereoSection3P(btn) { const on = !btn.classList.contains('active'); @@ -4302,6 +4583,9 @@ class StereoSim { // Section-3P panel _stereoUpdateSection3PPanel(); + // Construction tree (lines & planes) + _stereoUpdateConstructList(); + // Live readout overlay (section type/area/perimeter, last measurement) _stereoUpdateReadout(info); diff --git a/frontend/labs-bodies.html b/frontend/labs-bodies.html index 0381c1b..4595fe6 100644 --- a/frontend/labs-bodies.html +++ b/frontend/labs-bodies.html @@ -3672,6 +3672,23 @@
+ +
Построения
+
+ + +
+
+ + +
+
+
+
Метки рёбер
diff --git a/plans/STEREO_3D_IMPROVEMENT.md b/plans/STEREO_3D_IMPROVEMENT.md index 9cfd4c1..aa8a28d 100644 --- a/plans/STEREO_3D_IMPROVEMENT.md +++ b/plans/STEREO_3D_IMPROVEMENT.md @@ -64,4 +64,37 @@ --- -История: создан 2026-05-30. Фаза 6 добавлена 2026-05-30. +## Раунд «Конструктор» (2026-06-17) — упор на ученика-самоучку (песочница) + +Цель: превратить отличный **визуализатор** в полноценный **конструктор** для самостоятельных +построений. Приоритеты, выбранные пользователем: **Фаза A (конструкторное ядро)** и +**Фаза C (сечения+)**. A — фундамент C (сечение через прямую+точку, параллельно прямой/плоскости +опираются на объекты-прямые/плоскости). + +### Фаза A — Конструкторное ядро + +Прямые и плоскости как объекты первого класса + пересечения + параллели/перпендикуляры + +общий undo/redo + дерево именованных объектов. + +- [~] A1 — **Объектная модель + базовые построения.** `_lines[]` (имена a,b,c…), `_planes[]` + (имена α,β,γ…), группа `_constructGroup`, сериализуемое хранение `{x,y,z}`. Инструменты + «Прямая по 2 точкам» и «Плоскость по 3 точкам» (пикинг вершин/точек). Плоскость рисует + полупрозрачный квад + пунктирную рамку + **сечение тела этой плоскостью** (через `_sliceByPlane`, + делает плоскость осмысленной сразу). Панель «Построения», список объектов с уравнением плоскости. +- [ ] A2 — **Пересечения** (прямая∩плоскость → точка; плоскость∩плоскость → прямая; прямая∩прямая + → точка) + **именованное дерево** с удалением/цветом/видимостью отдельных объектов. +- [ ] A3 — **Параллели/перпендикуляры** (прямая ∥ прямой через точку; прямая ⟂ плоскости; + плоскость ∥ плоскости; плоскость ⟂ прямой = «плоскость по точке и нормали» — мост к Фазе C) + + **общий undo/redo** (снапшот всех пользовательских массивов построения, Ctrl+Z/Ctrl+Shift+Z). + +### Фаза C — Сечения+ + +- [ ] C1 — Сечение **плоскостью-объектом** (из Фазы A): «показать как сечение» с площадью/периметром. +- [ ] C2 — Сечение, **параллельное прямой/плоскости**; сечение **через прямую и точку**. +- [ ] C3 — **«Натуральная величина» сечения** (разворот многоугольника сечения в плоскость экрана, + отдельная мини-панель) + **штриховка**. +- [ ] C4 — Честный конструктивный алгоритм следов с анимацией перехода между шагами (из бэклога Ф6). + +--- + +История: создан 2026-05-30. Фаза 6 добавлена 2026-05-30. Раунд «Конструктор» (Фазы A,C) — 2026-06-17.