diff --git a/frontend/js/labs/stereo.js b/frontend/js/labs/stereo.js index eba8485..20d9585 100644 --- a/frontend/js/labs/stereo.js +++ b/frontend/js/labs/stereo.js @@ -53,6 +53,8 @@ class StereoSim { else if (this._connectMode) { el.style.cursor = 'pointer'; if (!wasDrag) this._onConnectClick(e); } else if (this._measureMode) { el.style.cursor = 'crosshair'; if (!wasDrag) this._onMeasureClick(e); } else if (this._angleMode) { el.style.cursor = 'crosshair'; if (!wasDrag) this._onAngleClick(e); } + 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 el.style.cursor = 'grab'; }); window.addEventListener('pointermove', e => { @@ -115,11 +117,15 @@ class StereoSim { this._sphereGroup = new THREE.Group(); this._measureGroup = new THREE.Group(); this._gridGroup = new THREE.Group(); + this._markGroup = new THREE.Group(); + this._derivedGroup = new THREE.Group(); this.scene.add(this._gridGroup); this.scene.add(this._figGroup); this.scene.add(this._sectionGroup); this.scene.add(this._sphereGroup); this.scene.add(this._measureGroup); + this.scene.add(this._markGroup); + this.scene.add(this._derivedGroup); this.scene.add(this._labelGroup); /* state */ @@ -176,6 +182,18 @@ class StereoSim { this._edges = []; // [{from: Vector3, to: Vector3}] this._faces = []; // [[Vector3, ...]] + /* edge marks (tick / parallel) — аналог _drawTickMark() из планиметрии */ + this._edgeMarks = {}; // { edgeIdx: { ticks: 0-3, parallel: 0-3 } } + this._markMode = null; // 'ticks' | 'parallel' | null + + /* derived 3D constructions — аналог midpoint/altitude_foot/centroid из планиметрии */ + this._derived3D = []; // [{type, ...args}] + this._deriveMode = null; // 'midpoint'|'face_centroid'|'alt_foot'|'solid_centroid'|null + this._derivePicks = []; + + /* edge length labels */ + this.showEdgeLengths = false; + this.onUpdate = null; this._buildGrid(); @@ -204,6 +222,13 @@ class StereoSim { this._clearGroup(this._pointGroup); this._clearGroup(this._angleGroup); this._clearGroup(this._measureGroup); + this._edgeMarks = {}; + this._markMode = null; + this._clearGroup(this._markGroup); + this._derived3D = []; + this._deriveMode = null; + this._derivePicks = []; + this._clearGroup(this._derivedGroup); this._buildFigure(); this._notify(); } @@ -347,6 +372,56 @@ class StereoSim { getCustomPoints() { return this._customPoints; } getConnections() { return this._connections; } + /* ── Edge mark mode ── */ + setMarkMode(mode) { + // mode: 'ticks' | 'parallel' | null + this._markMode = mode; + this._deriveMode = null; + this._derivePicks = []; + this._measureMode = false; + this._pointMode = false; + this._connectMode = false; + this._angleMode = null; + this.renderer.domElement.style.cursor = mode ? 'pointer' : 'grab'; + } + + clearMarks() { + this._edgeMarks = {}; + this._renderEdgeMarks(); + } + + /* ── Derived 3D constructions mode ── */ + setDeriveMode(mode) { + // mode: 'midpoint' | 'face_centroid' | 'alt_foot' | 'solid_centroid' | null + this._deriveMode = mode; + this._derivePicks = []; + this._markMode = null; + this._measureMode = false; + this._pointMode = false; + this._connectMode = false; + this._angleMode = null; + this.renderer.domElement.style.cursor = mode ? 'crosshair' : 'grab'; + if (mode === 'solid_centroid') this._addSolidCentroid(); + } + + clearDerived() { + this._derived3D = []; + this._derivePicks = []; + this._clearGroup(this._derivedGroup); + } + + removeLastDerived() { + if (!this._derived3D.length) return; + this._derived3D.pop(); + this._buildDerived3D(); + } + + /* ── Edge length labels ── */ + toggleEdgeLengths(on) { + this.showEdgeLengths = on; + this._buildFigure(); + } + getFormulas() { const p = this.params; const PI = Math.PI; @@ -567,6 +642,8 @@ class StereoSim { this._drawApothemLine(); this._drawDiagonals(); this._drawMidpoints(); + this._renderEdgeMarks(); + this._buildDerived3D(); } /* ── BOX helpers ── */ @@ -1027,6 +1104,15 @@ class StereoSim { const geo = new THREE.BufferGeometry().setFromPoints(pts); const mat = new THREE.LineBasicMaterial({ color: 0xFFFFFF, transparent: true, opacity: opac, linewidth: 2 }); this._figGroup.add(new THREE.Line(geo, mat)); + + if (this.showEdgeLengths) { + const len = e.from.distanceTo(e.to); + const mid = new THREE.Vector3().addVectors(e.from, e.to).multiplyScalar(0.5); + const lbl = this._makeTextSprite(len.toFixed(2), '#A8E063', 44); + lbl.position.copy(mid).add(new THREE.Vector3(0.1, 0.12, 0.1)); + lbl.scale.set(0.9, 0.4, 1); + this._labelGroup.add(lbl); + } } } @@ -2662,6 +2748,190 @@ class StereoSim { } } + /* ════════════════ EDGE MARKS ════════════════ */ + + _pickNearestEdgeIdx(e) { + const { mx, my } = this._screenCoords(e); + let bestDist = 0.10; + let bestIdx = -1; + for (let i = 0; i < this._edges.length; i++) { + const edge = this._edges[i]; + const p1 = edge.from.clone().project(this.camera); + const p2 = edge.to.clone().project(this.camera); + // distance from click to edge midpoint in NDC + const mx2 = (p1.x + p2.x) / 2; + const my2 = (p1.y + p2.y) / 2; + const d = Math.sqrt((mx2 - mx) ** 2 + (my2 - my) ** 2); + if (d < bestDist) { bestDist = d; bestIdx = i; } + } + return bestIdx; + } + + _onMarkClick(e) { + const idx = this._pickNearestEdgeIdx(e); + if (idx < 0) return; + if (!this._edgeMarks[idx]) this._edgeMarks[idx] = { ticks: 0, parallel: 0 }; + const m = this._edgeMarks[idx]; + if (this._markMode === 'ticks') m.ticks = (m.ticks + 1) % 4; // 0→1→2→3→0 + if (this._markMode === 'parallel') m.parallel = (m.parallel + 1) % 4; + this._renderEdgeMarks(); + } + + _renderEdgeMarks() { + this._clearGroup(this._markGroup); + for (const [idxStr, mark] of Object.entries(this._edgeMarks)) { + const idx = parseInt(idxStr); + if (idx < 0 || idx >= this._edges.length) continue; + const { from, to } = this._edges[idx]; + if (mark.ticks > 0) this._drawEdgeTick3D(from, to, mark.ticks, '#FFD166'); + if (mark.parallel > 0) this._drawEdgeParallel3D(from, to, mark.parallel, '#06D6E0'); + } + } + + _drawEdgeTick3D(from, to, count, color) { + // Perpendicular ticks crossing the edge near its midpoint + const dir = new THREE.Vector3().subVectors(to, from).normalize(); + const up = Math.abs(dir.y) < 0.9 ? new THREE.Vector3(0, 1, 0) : new THREE.Vector3(1, 0, 0); + const perp = new THREE.Vector3().crossVectors(dir, up).normalize(); + const mid = new THREE.Vector3().addVectors(from, to).multiplyScalar(0.5); + const step = 0.22; + const half = (count - 1) / 2; + const mat = new THREE.LineBasicMaterial({ color: new THREE.Color(color), linewidth: 2 }); + + for (let i = 0; i < count; i++) { + const offset = (i - half) * step; + const center = mid.clone().add(dir.clone().multiplyScalar(offset)); + const p1 = center.clone().sub(perp.clone().multiplyScalar(0.18)); + const p2 = center.clone().add(perp.clone().multiplyScalar(0.18)); + const geo = new THREE.BufferGeometry().setFromPoints([p1, p2]); + this._markGroup.add(new THREE.Line(geo, mat.clone())); + } + } + + _drawEdgeParallel3D(from, to, count, color) { + // Chevron (arrow-head) marks indicating parallel edges + const dir = new THREE.Vector3().subVectors(to, from).normalize(); + const up = Math.abs(dir.y) < 0.9 ? new THREE.Vector3(0, 1, 0) : new THREE.Vector3(1, 0, 0); + const perp = new THREE.Vector3().crossVectors(dir, up).normalize(); + const mid = new THREE.Vector3().addVectors(from, to).multiplyScalar(0.5); + const step = 0.22; + const half = (count - 1) / 2; + const mat = new THREE.LineBasicMaterial({ color: new THREE.Color(color), linewidth: 2 }); + + for (let i = 0; i < count; i++) { + const offset = (i - half) * step; + const center = mid.clone().add(dir.clone().multiplyScalar(offset)); + // chevron: two lines meeting at a tip (along perp), base spread along dir + const tip = center.clone().add(perp.clone().multiplyScalar( 0.18)); + const base1 = center.clone().add(perp.clone().multiplyScalar(-0.10)).sub(dir.clone().multiplyScalar(0.14)); + const base2 = center.clone().add(perp.clone().multiplyScalar(-0.10)).add(dir.clone().multiplyScalar(0.14)); + const geo = new THREE.BufferGeometry().setFromPoints([base1, tip, base2]); + this._markGroup.add(new THREE.Line(geo, mat.clone())); + } + } + + /* ════════════════ DERIVED 3D CONSTRUCTIONS ════════════════ */ + + _addSolidCentroid() { + if (!this._vertices.length) return; + const c = new THREE.Vector3(); + this._vertices.forEach(v => c.add(v.pos)); + c.divideScalar(this._vertices.length); + const n = this._derived3D.filter(d => d.type === 'point').length; + this._derived3D.push({ type: 'point', pos: c.clone(), label: n ? 'G' + (n + 1) : 'G', color: '#9B5DE5' }); + this._buildDerived3D(); + this._deriveMode = null; + this.renderer.domElement.style.cursor = 'grab'; + } + + _onDeriveClick(e) { + if (this._deriveMode === 'midpoint') { + const idx = this._pickNearestEdgeIdx(e); + if (idx < 0) return; + const { from, to } = this._edges[idx]; + const mid = new THREE.Vector3().addVectors(from, to).multiplyScalar(0.5); + const n = this._derived3D.filter(d => d.type === 'point').length; + this._derived3D.push({ type: 'point', pos: mid.clone(), label: n ? 'M' + (n + 1) : 'M', color: '#FFD166' }); + this._buildDerived3D(); + + } else if (this._deriveMode === 'face_centroid') { + const face = this._pickNearestFace(e); + if (!face) return; + const c = new THREE.Vector3(); + face.forEach(v => c.add(v)); + c.divideScalar(face.length); + const n = this._derived3D.filter(d => d.type === 'point').length; + this._derived3D.push({ type: 'point', pos: c.clone(), label: n ? 'O' + (n + 1) : 'O', color: '#A8E063' }); + this._buildDerived3D(); + + } else if (this._deriveMode === 'alt_foot') { + const pick = this._pickNearestPoint(e); + if (!pick) return; + this._derivePicks.push(pick); + // Highlight first pick + if (this._derivePicks.length === 1) { + const sGeo = new THREE.SphereGeometry(0.14, 10, 10); + const sMat = new THREE.MeshBasicMaterial({ color: 0xF15BB5 }); + const s = new THREE.Mesh(sGeo, sMat); + s.position.copy(pick.pos); + this._derivedGroup.add(s); + } else if (this._derivePicks.length === 2) { + // Vertex + base point: find foot of perpendicular from V onto the edge containing E + const V = this._derivePicks[0].pos; + const E = this._derivePicks[1].pos; + const eps = 0.12; + let foot = E.clone(); + for (const edge of this._edges) { + if (edge.from.distanceTo(E) < eps || edge.to.distanceTo(E) < eps) { + const d = new THREE.Vector3().subVectors(edge.to, edge.from); + const t = new THREE.Vector3().subVectors(V, edge.from).dot(d) / d.lengthSq(); + foot = edge.from.clone().add(d.clone().multiplyScalar(Math.max(0, Math.min(1, t)))); + break; + } + } + const n = this._derived3D.filter(d => d.type === 'point').length; + this._derived3D.push({ type: 'point', pos: foot.clone(), label: n ? 'H' + (n + 1) : 'H', color: '#FF9F43' }); + this._derived3D.push({ type: 'line', from: V.clone(), to: foot.clone(), color: '#FF9F43', dashed: true }); + this._buildDerived3D(); + this._derivePicks = []; + } + } + } + + _buildDerived3D() { + this._clearGroup(this._derivedGroup); + for (const d of this._derived3D) { + if (d.type === 'point') { + const sGeo = new THREE.SphereGeometry(0.12, 12, 12); + const sMat = new THREE.MeshBasicMaterial({ color: new THREE.Color(d.color || '#FFD166') }); + const s = new THREE.Mesh(sGeo, sMat); + s.position.copy(d.pos); + this._derivedGroup.add(s); + if (d.label) { + const sprite = this._makeTextSprite(d.label, d.color || '#FFD166', 52); + sprite.position.copy(d.pos).add(new THREE.Vector3(0.18, 0.25, 0)); + sprite.scale.set(1.0, 0.45, 1); + this._derivedGroup.add(sprite); + } + } else if (d.type === 'line') { + const pts = [d.from, d.to]; + const geo = new THREE.BufferGeometry().setFromPoints(pts); + let mat; + if (d.dashed) { + mat = new THREE.LineDashedMaterial({ + color: new THREE.Color(d.color || '#FFD166'), + dashSize: 0.1, gapSize: 0.07, transparent: true, opacity: 0.85, + }); + } else { + mat = new THREE.LineBasicMaterial({ color: new THREE.Color(d.color || '#FFD166'), transparent: true, opacity: 0.85 }); + } + const line = new THREE.Line(geo, mat); + if (d.dashed) line.computeLineDistances(); + this._derivedGroup.add(line); + } + } + } + _notify() { if (this.onUpdate) this.onUpdate(this.info()); } diff --git a/frontend/lab.html b/frontend/lab.html index 200c8ca..1fb823b 100644 --- a/frontend/lab.html +++ b/frontend/lab.html @@ -3648,6 +3648,24 @@ +