'use strict'; /* ═══════════════════════════════════════════════════════════ StereoSim — 3D Stereometry (Three.js) Cube, Parallelepiped, Pyramid, Tetrahedron, Cylinder, Cone, Truncated Cone, Sphere, Prism + sections, unfold ═══════════════════════════════════════════════════════════ */ class StereoSim { constructor(container) { this.container = container; this._running = false; /* Three.js core */ this.scene = new THREE.Scene(); this.camera = new THREE.PerspectiveCamera(50, 1, 0.1, 500); this.renderer = new THREE.WebGLRenderer({ antialias: true, alpha: true }); this.renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2)); this.renderer.setClearColor(0x0D0D1A, 1); container.appendChild(this.renderer.domElement); /* lighting */ this.scene.add(new THREE.AmbientLight(0xffffff, 0.55)); const dir = new THREE.DirectionalLight(0xffffff, 0.75); dir.position.set(6, 10, 8); this.scene.add(dir); const fill = new THREE.DirectionalLight(0x9B5DE5, 0.2); fill.position.set(-5, 3, -4); this.scene.add(fill); /* orbit camera */ this._drag = false; this._prevX = 0; this._prevY = 0; this._rotY = 0.6; this._rotX = 0.45; this._dist = 14; this._autoSpin = true; this._idleTime = 0; const el = this.renderer.domElement; el.style.cursor = 'grab'; this._clickStart = null; el.addEventListener('pointerdown', e => { this._clickStart = { x: e.clientX, y: e.clientY }; this._drag = true; this._prevX = e.clientX; this._prevY = e.clientY; this._autoSpin = false; this._idleTime = 0; if (!this._pointMode && !this._connectMode && !this._measureMode && !this._angleMode && !this._section3PMode) el.style.cursor = 'grabbing'; }); window.addEventListener('pointerup', e => { const wasDrag = this._clickStart && (Math.abs(e.clientX - this._clickStart.x) > 4 || Math.abs(e.clientY - this._clickStart.y) > 4); this._drag = false; if (this._pointMode) { el.style.cursor = 'cell'; if (!wasDrag) this._onPointClick(e); } 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 if (this._section3PMode) { el.style.cursor = 'crosshair'; if (!wasDrag) this._onSection3PClick(e); } else el.style.cursor = 'grab'; }); window.addEventListener('pointermove', e => { this._onHoverMove(e); if (!this._drag) return; this._rotY += (e.clientX - this._prevX) * 0.007; this._rotX += (e.clientY - this._prevY) * 0.007; this._rotX = Math.max(-1.4, Math.min(1.4, this._rotX)); this._prevX = e.clientX; this._prevY = e.clientY; this._idleTime = 0; }); el.addEventListener('wheel', e => { e.preventDefault(); this._dist = Math.max(4, Math.min(40, this._dist + e.deltaY * 0.02)); }, { passive: false }); /* touch — orbit + pinch zoom */ this._touchDist = 0; el.addEventListener('touchstart', e => { if (e.touches.length === 1) { this._drag = true; this._prevX = e.touches[0].clientX; this._prevY = e.touches[0].clientY; this._autoSpin = false; this._idleTime = 0; } else if (e.touches.length === 2) { this._drag = false; const dx = e.touches[0].clientX - e.touches[1].clientX; const dy = e.touches[0].clientY - e.touches[1].clientY; this._touchDist = Math.sqrt(dx * dx + dy * dy); } }, { passive: true }); el.addEventListener('touchmove', e => { if (e.touches.length === 2) { // pinch zoom const dx = e.touches[0].clientX - e.touches[1].clientX; const dy = e.touches[0].clientY - e.touches[1].clientY; const newDist = Math.sqrt(dx * dx + dy * dy); if (this._touchDist > 0) { const scale = this._touchDist / newDist; this._dist = Math.max(4, Math.min(40, this._dist * scale)); } this._touchDist = newDist; return; } if (!this._drag || e.touches.length !== 1) return; const t = e.touches[0]; this._rotY += (t.clientX - this._prevX) * 0.007; this._rotX += (t.clientY - this._prevY) * 0.007; this._rotX = Math.max(-1.4, Math.min(1.4, this._rotX)); this._prevX = t.clientX; this._prevY = t.clientY; }, { passive: true }); el.addEventListener('touchend', () => { this._drag = false; this._touchDist = 0; }); /* resize */ this._ro = new ResizeObserver(() => this.fit()); this._ro.observe(container); /* groups */ this._figGroup = new THREE.Group(); this._labelGroup = new THREE.Group(); this._sectionGroup = new THREE.Group(); this._sphereGroup = new THREE.Group(); this._measureGroup = new THREE.Group(); this._measurePickGroup = new THREE.Group(); this._gridGroup = new THREE.Group(); this._markGroup = new THREE.Group(); this._derivedGroup = new THREE.Group(); this._section3PGroup = 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._measurePickGroup); this.scene.add(this._markGroup); this.scene.add(this._derivedGroup); this.scene.add(this._section3PGroup); this.scene.add(this._labelGroup); /* state */ this.figureType = 'cube'; this.params = { a: 4, b: 3, c: 5, h: 5, r: 2, R: 3, n: 4 }; this.showEdges = true; this.showVertices = true; this.showLabels = true; this.showAxes = true; this.showGrid = true; this.opacity = 0.3; this.showSection = false; this.sectionHeight = 0.5; // 0..1 this.sectionType = 'horizontal'; // horizontal | diagonal | custom this.sectionAngle = 0.5; // 0..1 for diagonal tilt this._unfold = false; this._unfoldProgress = 0; this._unfoldTarget = 0; this.showInscribed = false; this.showCircumscribed = false; this.showHeight = false; this.showApothem = false; this.showDiagonals = false; this.showMidpoints = false; this._measureMode = false; this._measurePicks = []; this._measurements = []; /* angle modes */ this._angleMode = null; // 'edge' | 'linePlane' | 'dihedral' | 'pointPlane' this._anglePicks = []; // picked vertices/points this._angleGroup = new THREE.Group(); this.scene.add(this._angleGroup); /* hover coordinate tooltip */ this._tooltipEl = null; this._initTooltip(); /* custom points & connections */ this._pointMode = false; // place points on edges this._connectMode = false; // connect two points with a line this._customPoints = []; // [{pos: Vector3, edgeIdx: number, t: number, label: string}] this._connections = []; // [{from: idx, to: idx}] this._connectPicks = []; // temp picks for connecting this._pointGroup = new THREE.Group(); this.scene.add(this._pointGroup); this._nextPointId = 1; this._vertices = []; // [{pos: Vector3, label: string}] 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; /* section by 3 arbitrary points */ this._section3PMode = false; // interactive picking active this._section3PPicks = []; // Vector3[] — up to 3 picked points this._section3PStepBy = false; // step-by-step visualisation toggle this._section3PStep = 0; // current step (0=idle, 1..6=sub-steps) this._section3PData = null; // computed result {normal,D,polygon,area,typeName} this.onUpdate = null; this._buildGrid(); this._buildFigure(); this.fit(); this.play(); } /* ════════════════ PUBLIC API ════════════════ */ setFigure(type) { this.figureType = type; this._unfold = false; this._unfoldProgress = 0; this._unfoldTarget = 0; this.showSection = false; this.showInscribed = false; this.showCircumscribed = false; this.showHeight = false; this.showApothem = false; this.showDiagonals = false; this.showMidpoints = false; this._measurements = []; this._measurePicks = []; this._customPoints = []; this._connections = []; this._connectPicks = []; this._anglePicks = []; this._angleMode = null; this._nextPointId = 1; 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._section3PPicks = []; this._section3PData = null; this._section3PMode = false; this._section3PStep = 0; this._clearGroup(this._section3PGroup); this._buildFigure(); this._notify(); } setParam(key, val) { this.params[key] = val; this._buildFigure(); this._notify(); } setOpacity(v) { this.opacity = v; this._buildFigure(); } toggleEdges(v) { this.showEdges = v; this._buildFigure(); } toggleVertices(v) { this.showVertices = v; this._buildFigure(); } toggleLabels(v) { this.showLabels = v; this._buildFigure(); } toggleAxes(v) { this.showAxes = v; this._buildGrid(); } toggleGrid(v) { this.showGrid = v; this._buildGrid(); } toggleSection(on) { this.showSection = on; this._updateSection(); } setSectionHeight(v) { this.sectionHeight = v; this._updateSection(); this._notify(); } setSectionType(t) { this.sectionType = t; this._updateSection(); this._notify(); } toggleUnfold(on) { this._unfold = on; this._unfoldTarget = on ? 1 : 0; } toggleInscribed(on) { this.showInscribed = on; this._updateSpheres(); this._notify(); } toggleCircumscribed(on) { this.showCircumscribed = on; this._updateSpheres(); this._notify(); } toggleHeight(on) { this.showHeight = on; this._buildFigure(); this._notify(); } toggleApothem(on) { this.showApothem = on; this._buildFigure(); this._notify(); } toggleDiagonals(on) { this.showDiagonals = on; this._buildFigure(); this._notify(); } toggleMidpoints(on) { this.showMidpoints = on; this._buildFigure(); this._notify(); } toggleMeasure(on) { this._measureMode = on; this._pointMode = false; this._angleMode = null; this._measurePicks = []; this._clearGroup(this._measurePickGroup); if (!on) { this._measurements = []; this._rebuildMeasureGroup(); } this.renderer.domElement.style.cursor = on ? 'crosshair' : 'grab'; } removeLastMeasurement() { if (!this._measurements.length) return; this._measurements.pop(); this._rebuildMeasureGroup(); } clearMeasurements() { this._measurements = []; this._measurePicks = []; this._rebuildMeasureGroup(); this._clearGroup(this._measurePickGroup); } _rebuildMeasureGroup() { this._clearGroup(this._measureGroup); for (const m of this._measurements) { const g = new THREE.Group(); // spheres at endpoints for (const pos of [m.posA, m.posB]) { const sGeo = new THREE.SphereGeometry(0.14, 12, 12); const sMat = new THREE.MeshBasicMaterial({ color: 0xFFD166 }); const s = new THREE.Mesh(sGeo, sMat); s.position.copy(pos); g.add(s); } // dashed line const lineGeo = new THREE.BufferGeometry().setFromPoints([m.posA, m.posB]); const lineMat = new THREE.LineDashedMaterial({ color: 0xFFD166, dashSize: 0.15, gapSize: 0.1, transparent: true, opacity: 0.9 }); const line = new THREE.Line(lineGeo, lineMat); line.computeLineDistances(); g.add(line); // label const mid = new THREE.Vector3().addVectors(m.posA, m.posB).multiplyScalar(0.5); const label = this._makeTextSprite(`${m.from}${m.to} = ${m.dist}`, '#FFD166', 40); label.position.copy(mid).add(new THREE.Vector3(0, 0.3, 0)); label.scale.set(1.4, 0.5, 1); g.add(label); this._measureGroup.add(g); } } setAngleMode(mode) { // mode: 'edge' | 'linePlane' | 'dihedral' | null this._angleMode = mode; this._anglePicks = []; this._measureMode = false; this._pointMode = false; this._connectMode = false; if (!mode) { this._clearGroup(this._angleGroup); } this.renderer.domElement.style.cursor = mode ? 'crosshair' : 'grab'; } setSectionAngle(v) { this.sectionAngle = v; if (this.showSection && this.sectionType === 'diagonal') this._updateSection(); this._notify(); } /* ── Point mode: place points on edges ── */ togglePointMode(on) { this._pointMode = on; this._measureMode = false; this._connectPicks = []; this.renderer.domElement.style.cursor = on ? 'cell' : 'grab'; } toggleConnectMode(on) { this._connectMode = on; this._pointMode = false; this._measureMode = false; this._connectPicks = []; this.renderer.domElement.style.cursor = on ? 'pointer' : 'grab'; } clearCustomPoints() { this._customPoints = []; this._connections = []; this._connectPicks = []; this._nextPointId = 1; this._clearGroup(this._pointGroup); if (this.showSection && this.sectionType === 'custom') this._updateSection(); this._notify(); } removeLastPoint() { if (!this._customPoints.length) return; // Remove connections referencing last point const lastIdx = this._customPoints.length - 1; this._connections = this._connections.filter(c => c.from !== lastIdx && c.to !== lastIdx); this._customPoints.pop(); this._nextPointId = Math.max(1, this._nextPointId - 1); this._rebuildPointVisuals(); if (this.showSection && this.sectionType === 'custom') this._updateSection(); this._notify(); } 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(); } /* ── Section by 3 arbitrary points ── */ toggleSection3P(on) { this._section3PMode = on; // turn off all other interactive modes this._pointMode = false; this._connectMode = false; this._measureMode = false; this._angleMode = null; this._markMode = null; this._deriveMode = null; this._connectPicks = []; this.renderer.domElement.style.cursor = on ? 'crosshair' : 'grab'; } clearSection3P() { this._section3PPicks = []; this._section3PData = null; this._section3PStep = 0; this._clearGroup(this._section3PGroup); this._notify(); } toggleSection3PStepBy(on) { this._section3PStepBy = on; // re-render if data already exists if (this._section3PData) this._drawSection3P(); } getSection3PInfo() { return this._section3PData; } getFormulas() { const p = this.params; const PI = Math.PI; const r = (v) => Math.round(v * 100) / 100; switch (this.figureType) { case 'cube': { const a = p.a; const rIn = a / 2, rOut = r(a * Math.sqrt(3) / 2); return { V: r(a**3), S: r(6*a**2), S_side: r(4*a**2), d: r(a*Math.sqrt(3)), h: a, formulas: [`V = a³ = ${r(a**3)}`, `S = 6a² = ${r(6*a**2)}`, `d = a√3 = ${r(a*Math.sqrt(3))}`, `r_вп = a/2 = ${r(rIn)}`, `R_оп = a√3/2 = ${rOut}`] }; } case 'parallelepiped': { const {a,b,c} = p; return { V: r(a*b*c), S: r(2*(a*b+b*c+a*c)), S_side: r(2*c*(a+b)), d: r(Math.sqrt(a**2+b**2+c**2)), h: c, formulas: [`V = abc = ${r(a*b*c)}`, `S = 2(ab+bc+ac) = ${r(2*(a*b+b*c+a*c))}`, `d = √(a²+b²+c²) = ${r(Math.sqrt(a**2+b**2+c**2))}`, `r_вп = ${r(Math.min(a,b,c)/2)}`, `R_оп = ${r(Math.sqrt(a**2+b**2+c**2)/2)}`] }; } case 'pyramid': { const {a, h, n} = p; const sBase = n * a**2 / (4 * Math.tan(PI/n)); const apothem = a / (2 * Math.tan(PI/n)); const slantH = Math.sqrt(h**2 + apothem**2); const sLat = n * a * slantH / 2; const Vp = sBase * h / 3; const rIn = r(3 * Vp / (sBase + sLat)); const Rb = a / (2 * Math.sin(PI / n)); const rOut = r((Rb**2 + h**2) / (2 * h)); return { V: r(Vp), S: r(sBase+sLat), S_side: r(sLat), d: null, h, formulas: [`V = ⅓·S_осн·h = ${r(Vp)}`, `S_осн = ${r(sBase)}`, `S_бок = ${r(sLat)}`, `S_полн = ${r(sBase+sLat)}`, `a_осн = ${r(apothem)}`, `a_бок = ${r(slantH)}`, `r_вп = ${rIn}`, `R_оп = ${rOut}`] }; } case 'tetrahedron': { const a = p.a; const rIn = r(a / (2 * Math.sqrt(6))), rOut = r(a * Math.sqrt(6) / 4); return { V: r(a**3*Math.sqrt(2)/12), S: r(a**2*Math.sqrt(3)), S_side: r(a**2*Math.sqrt(3)*3/4), d: null, h: r(a*Math.sqrt(2/3)), formulas: [`V = a³√2/12 = ${r(a**3*Math.sqrt(2)/12)}`, `S = a²√3 = ${r(a**2*Math.sqrt(3))}`, `h = a√(2/3) = ${r(a*Math.sqrt(2/3))}`, `r_вп = ${rIn}`, `R_оп = ${rOut}`] }; } case 'cylinder': { const {r: rad, h} = p; const rIn = r(Math.min(rad, h/2)), rOut = r(Math.sqrt(rad**2 + (h/2)**2)); return { V: r(PI*rad**2*h), S: r(2*PI*rad*(rad+h)), S_side: r(2*PI*rad*h), d: r(2*rad), h, formulas: [`V = πr²h = ${r(PI*rad**2*h)}`, `S_бок = 2πrh = ${r(2*PI*rad*h)}`, `S_полн = 2πr(r+h) = ${r(2*PI*rad*(rad+h))}`, `r_вп = ${rIn}`, `R_оп = ${rOut}`] }; } case 'cone': { const {r: rad, h} = p; const l = Math.sqrt(rad**2+h**2); const rIn = r(rad * h / (rad + l)), rOut = r((rad**2 + h**2) / (2 * h)); return { V: r(PI*rad**2*h/3), S: r(PI*rad*(rad+l)), S_side: r(PI*rad*l), d: r(2*rad), h, formulas: [`V = ⅓πr²h = ${r(PI*rad**2*h/3)}`, `l = √(r²+h²) = ${r(l)}`, `S_бок = πrl = ${r(PI*rad*l)}`, `r_вп = ${rIn}`, `R_оп = ${rOut}`] }; } case 'trunccone': { const {R: R1, r: r1, h} = p; const l = Math.sqrt((R1-r1)**2+h**2); const V = PI*h*(R1**2+R1*r1+r1**2)/3; return { V: r(V), S: r(PI*(R1**2+r1**2+(R1+r1)*l)), S_side: r(PI*(R1+r1)*l), d: null, h, formulas: [`V = ⅓πh(R²+Rr+r²) = ${r(V)}`, `l = ${r(l)}`, `S_бок = π(R+r)l = ${r(PI*(R1+r1)*l)}`] }; } case 'sphere': { const rad = p.r; return { V: r(4*PI*rad**3/3), S: r(4*PI*rad**2), S_side: null, d: r(2*rad), h: r(2*rad), formulas: [`V = ⁴⁄₃πr³ = ${r(4*PI*rad**3/3)}`, `S = 4πr² = ${r(4*PI*rad**2)}`, `d = 2r = ${r(2*rad)}`] }; } case 'prism': { const {a, h, n} = p; const sBase = n * a**2 / (4 * Math.tan(PI/n)); const apothem = a / (2 * Math.tan(PI/n)); const Rb = a / (2 * Math.sin(PI/n)); const rIn = r(Math.min(apothem, h/2)), rOut = r(Math.sqrt(Rb**2 + (h/2)**2)); return { V: r(sBase*h), S: r(2*sBase + n*a*h), S_side: r(n*a*h), d: null, h, formulas: [`V = S_осн·h = ${r(sBase*h)}`, `S_осн = ${r(sBase)}`, `S_бок = nah = ${r(n*a*h)}`, `r_вп = ${rIn}`, `R_оп = ${rOut}`] }; } case 'truncpyramid': { const { a, b, h, n } = p; const sLow = n * a**2 / (4 * Math.tan(PI/n)); const sUp = n * b**2 / (4 * Math.tan(PI/n)); const apLow = a / (2 * Math.tan(PI/n)); const apUp = b / (2 * Math.tan(PI/n)); const slant = Math.sqrt(h**2 + (apLow - apUp)**2); const sLat = n * (a + b) * slant / 2; const V = h * (sLow + sUp + Math.sqrt(sLow * sUp)) / 3; return { V: r(V), S: r(sLow + sUp + sLat), S_side: r(sLat), d: null, h, formulas: [ `V = h(S₁+S₂+√(S₁·S₂))/3 = ${r(V)}`, `S₁ = ${r(sLow)}, S₂ = ${r(sUp)}`, `l (апоф.) = ${r(slant)}`, `S_бок = n(a+b)l/2 = ${r(sLat)}`, `S_полн = ${r(sLow + sUp + sLat)}`, ]}; } case 'octahedron': { const a = p.a; const V_val = r(a**3 * Math.SQRT2 / 3); const S_val = r(2 * a**2 * Math.sqrt(3)); return { V: V_val, S: S_val, S_side: null, d: null, h: r(a * Math.SQRT2), formulas: [ `V = a³√2/3 = ${V_val}`, `S = 2a²√3 = ${S_val}`, `h = a√2 = ${r(a * Math.SQRT2)}`, `r_вп = a√6/6 = ${r(a * Math.sqrt(6) / 6)}`, `R_оп = a√2/2 = ${r(a * Math.SQRT2 / 2)}`, ]}; } case 'icosahedron': { const a = p.a; const phi = (1 + Math.sqrt(5)) / 2; const V_val = r(5 * a**3 * (3 + Math.sqrt(5)) / 12); const S_val = r(5 * a**2 * Math.sqrt(3)); const rIn = r(a * phi**2 / (2 * Math.sqrt(3))); const rOut = r(a * Math.sqrt(10 + 2*Math.sqrt(5)) / 4); return { V: V_val, S: S_val, S_side: null, d: null, h: r(a * Math.sqrt(10 + 2*Math.sqrt(5)) / 2), formulas: [ `V = 5a³(3+√5)/12 = ${V_val}`, `S = 5a²√3 = ${S_val}`, `r_вп ≈ ${rIn}`, `R_оп = a√(10+2√5)/4 ≈ ${rOut}`, ]}; } case 'dodecahedron': { const a = p.a; const phi = (1 + Math.sqrt(5)) / 2; const V_val = r(a**3 * (15 + 7*Math.sqrt(5)) / 4); const S_val = r(3 * a**2 * Math.sqrt(25 + 10*Math.sqrt(5))); const rIn = r(a / 2 * Math.sqrt((25 + 11*Math.sqrt(5)) / 10)); const rOut = r(a * Math.sqrt(3) * phi / 2); return { V: V_val, S: S_val, S_side: null, d: null, h: r(a * Math.sqrt(3) * phi), formulas: [ `V = a³(15+7√5)/4 = ${V_val}`, `S = 3a²√(25+10√5) = ${S_val}`, `r_вп ≈ ${rIn}`, `R_оп = a√3·φ/2 ≈ ${rOut}`, ]}; } default: return { V: 0, S: 0, S_side: 0, d: 0, h: 0, formulas: [] }; } } getSectionArea() { if (!this.showSection || !this._sectionPolygon || !this._sectionPolygon.length) return 0; return this._polygonArea(this._sectionPolygon); } info() { const f = this.getFormulas(); return { type: this.figureType, V: f.V, S: f.S, S_side: f.S_side, d: f.d, h: f.h, sectionArea: this.showSection ? this.getSectionArea() : null, inscribedR: this.showInscribed ? this._inscribedRadius() : null, circumscribedR: this.showCircumscribed ? this._circumscribedRadius() : null, customPoints: this._customPoints.length, connections: this._connections.length, }; } fit() { const w = this.container.clientWidth || 600; const h = this.container.clientHeight || 400; this.camera.aspect = w / h; this.camera.updateProjectionMatrix(); this.renderer.setSize(w, h); } play() { if (!this._running) { this._running = true; this._loop(); } } stop() { this._running = false; } pause() { this._running = false; } /* ════════════════ GRID + AXES ════════════════ */ _buildGrid() { this._clearGroup(this._gridGroup); if (this.showGrid) { const grid = new THREE.GridHelper(20, 20, 0x222244, 0x151530); grid.position.y = -0.01; this._gridGroup.add(grid); } if (this.showAxes) { const axes = new THREE.AxesHelper(6); axes.material.transparent = true; axes.material.opacity = 0.4; this._gridGroup.add(axes); } } /* ════════════════ FIGURE BUILDER ════════════════ */ _buildFigure() { this._clearGroup(this._figGroup); this._clearGroup(this._labelGroup); this._vertices = []; this._edges = []; this._faces = []; const builders = { cube: () => this._buildCube(), parallelepiped: () => this._buildParallelepiped(), pyramid: () => this._buildPyramid(), tetrahedron: () => this._buildTetrahedron(), cylinder: () => this._buildCylinder(), cone: () => this._buildCone(), trunccone: () => this._buildTruncCone(), sphere: () => this._buildSphere(), prism: () => this._buildPrism(), truncpyramid: () => this._buildTruncPyramid(), octahedron: () => this._buildOctahedron(), icosahedron: () => this._buildIcosahedron(), dodecahedron: () => this._buildDodecahedron(), }; (builders[this.figureType] || builders.cube)(); this._updateSection(); this._updateSpheres(); this._drawHeightLine(); this._drawApothemLine(); this._drawDiagonals(); this._drawMidpoints(); this._renderEdgeMarks(); this._buildDerived3D(); } /* ── BOX helpers ── */ _buildBox(sx, sy, sz) { const hx = sx/2, hy = sy/2, hz = sz/2; // 8 vertices const v = [ new THREE.Vector3(-hx, 0, hz), // A (0) new THREE.Vector3( hx, 0, hz), // B (1) new THREE.Vector3( hx, 0, -hz), // C (2) new THREE.Vector3(-hx, 0, -hz), // D (3) new THREE.Vector3(-hx, sy, hz), // E (4) new THREE.Vector3( hx, sy, hz), // F (5) new THREE.Vector3( hx, sy,-hz), // G (6) new THREE.Vector3(-hx, sy,-hz), // H (7) ]; const labels = ['A','B','C','D','E','F','G','H']; this._vertices = v.map((pos, i) => ({ pos, label: labels[i] })); const edgeIdx = [[0,1],[1,2],[2,3],[3,0],[4,5],[5,6],[6,7],[7,4],[0,4],[1,5],[2,6],[3,7]]; this._edges = edgeIdx.map(([a,b]) => ({ from: v[a], to: v[b] })); this._faces = [ [v[0],v[1],v[2],v[3]], // bottom [v[4],v[5],v[6],v[7]], // top [v[0],v[1],v[5],v[4]], // front [v[2],v[3],v[7],v[6]], // back [v[1],v[2],v[6],v[5]], // right [v[0],v[3],v[7],v[4]], // left ]; // transparent mesh const geo = new THREE.BoxGeometry(sx, sy, sz); geo.translate(0, sy/2, 0); const mat = new THREE.MeshPhysicalMaterial({ color: 0x9B5DE5, transparent: true, opacity: this.opacity, side: THREE.DoubleSide, metalness: 0.05, roughness: 0.4, clearcoat: 0.3, depthWrite: false, }); this._figGroup.add(new THREE.Mesh(geo, mat)); this._addEdges(); this._addVerticesAndLabels(); } _buildCube() { const a = this.params.a; this._buildBox(a, a, a); } _buildParallelepiped() { this._buildBox(this.params.a, this.params.c, this.params.b); } /* ── PYRAMID ── */ _buildPyramid() { const { a, h, n } = this.params; const baseVerts = this._regularPolygon(n, a); const apex = new THREE.Vector3(0, h, 0); const labels = 'ABCDEFGH'.split(''); this._vertices = baseVerts.map((pos, i) => ({ pos, label: labels[i] || `P${i}` })); this._vertices.push({ pos: apex, label: 'S' }); // edges: base ring + apex connections for (let i = 0; i < n; i++) { this._edges.push({ from: baseVerts[i], to: baseVerts[(i+1)%n] }); this._edges.push({ from: baseVerts[i], to: apex }); } // faces: base + lateral triangles this._faces.push([...baseVerts]); for (let i = 0; i < n; i++) { this._faces.push([baseVerts[i], baseVerts[(i+1)%n], apex]); } this._addMeshFromFaces(0x06D6A0); this._addEdges(); this._addVerticesAndLabels(); } /* ── TETRAHEDRON ── */ _buildTetrahedron() { const a = this.params.a; const h = a * Math.sqrt(2/3); const r = a / Math.sqrt(3); const v = [ new THREE.Vector3(0, 0, r), new THREE.Vector3(a/2, 0, -r/2), new THREE.Vector3(-a/2, 0, -r/2), new THREE.Vector3(0, h, 0), ]; const labels = ['A','B','C','D']; this._vertices = v.map((pos, i) => ({ pos, label: labels[i] })); const edgeIdx = [[0,1],[1,2],[2,0],[0,3],[1,3],[2,3]]; this._edges = edgeIdx.map(([a,b]) => ({ from: v[a], to: v[b] })); this._faces = [ [v[0],v[1],v[2]], [v[0],v[1],v[3]], [v[1],v[2],v[3]], [v[0],v[2],v[3]], ]; this._addMeshFromFaces(0x06D6E0); this._addEdges(); this._addVerticesAndLabels(); } /* ── CYLINDER ── */ _buildCylinder() { const { r, h } = this.params; const geo = new THREE.CylinderGeometry(r, r, h, 48, 1, false); geo.translate(0, h/2, 0); const mat = new THREE.MeshPhysicalMaterial({ color: 0xF59E0B, transparent: true, opacity: this.opacity, side: THREE.DoubleSide, metalness: 0.05, roughness: 0.4, depthWrite: false, }); this._figGroup.add(new THREE.Mesh(geo, mat)); // wireframe rings const ringGeo = new THREE.RingGeometry(r - 0.02, r + 0.02, 48); const ringMat = new THREE.MeshBasicMaterial({ color: 0xFFD166, side: THREE.DoubleSide }); const bottomRing = new THREE.Mesh(ringGeo, ringMat); bottomRing.rotation.x = -Math.PI/2; this._figGroup.add(bottomRing); const topRing = bottomRing.clone(); topRing.position.y = h; this._figGroup.add(topRing); // edges: vertical generators + center axis const n = 8; for (let i = 0; i < n; i++) { const angle = (i / n) * Math.PI * 2; const x = r * Math.cos(angle), z = r * Math.sin(angle); this._edges.push({ from: new THREE.Vector3(x, 0, z), to: new THREE.Vector3(x, h, z) }); } // center axis this._vertices.push({ pos: new THREE.Vector3(0, 0, 0), label: 'O₁' }); this._vertices.push({ pos: new THREE.Vector3(0, h, 0), label: 'O₂' }); this._edges.push({ from: new THREE.Vector3(0, 0, 0), to: new THREE.Vector3(0, h, 0) }); // base/top circle arc segments (for point-picking) const arcN = 32; for (let i = 0; i < arcN; i++) { const a1 = (i / arcN) * Math.PI * 2, a2 = ((i + 1) / arcN) * Math.PI * 2; this._edges.push({ from: new THREE.Vector3(r * Math.cos(a1), 0, r * Math.sin(a1)), to: new THREE.Vector3(r * Math.cos(a2), 0, r * Math.sin(a2)) }); this._edges.push({ from: new THREE.Vector3(r * Math.cos(a1), h, r * Math.sin(a1)), to: new THREE.Vector3(r * Math.cos(a2), h, r * Math.sin(a2)) }); } this._addEdges(0.4); this._addVerticesAndLabels(); } /* ── CONE ── */ _buildCone() { const { r, h } = this.params; const geo = new THREE.CylinderGeometry(0, r, h, 48, 1, false); geo.translate(0, h/2, 0); const mat = new THREE.MeshPhysicalMaterial({ color: 0xE0335E, transparent: true, opacity: this.opacity, side: THREE.DoubleSide, metalness: 0.05, roughness: 0.4, depthWrite: false, }); this._figGroup.add(new THREE.Mesh(geo, mat)); // bottom ring const ringGeo = new THREE.RingGeometry(r - 0.02, r + 0.02, 48); const ringMat = new THREE.MeshBasicMaterial({ color: 0xFF6B8A, side: THREE.DoubleSide }); const ring = new THREE.Mesh(ringGeo, ringMat); ring.rotation.x = -Math.PI/2; this._figGroup.add(ring); const apex = new THREE.Vector3(0, h, 0); this._vertices.push({ pos: new THREE.Vector3(0, 0, 0), label: 'O' }); this._vertices.push({ pos: apex, label: 'S' }); // slant lines const n = 8; for (let i = 0; i < n; i++) { const angle = (i / n) * Math.PI * 2; const x = r * Math.cos(angle), z = r * Math.sin(angle); this._edges.push({ from: new THREE.Vector3(x, 0, z), to: apex }); } this._edges.push({ from: new THREE.Vector3(0, 0, 0), to: apex }); // base circle as arc segments (for point-picking) const arcN = 32; for (let i = 0; i < arcN; i++) { const a1 = (i / arcN) * Math.PI * 2, a2 = ((i + 1) / arcN) * Math.PI * 2; this._edges.push({ from: new THREE.Vector3(r * Math.cos(a1), 0, r * Math.sin(a1)), to: new THREE.Vector3(r * Math.cos(a2), 0, r * Math.sin(a2)), }); } this._addEdges(0.4); this._addVerticesAndLabels(); } /* ── TRUNCATED CONE ── */ _buildTruncCone() { const { R, r, h } = this.params; const geo = new THREE.CylinderGeometry(r, R, h, 48, 1, false); geo.translate(0, h/2, 0); const mat = new THREE.MeshPhysicalMaterial({ color: 0x60A5FA, transparent: true, opacity: this.opacity, side: THREE.DoubleSide, metalness: 0.05, roughness: 0.4, depthWrite: false, }); this._figGroup.add(new THREE.Mesh(geo, mat)); // rings for (const [rad, y] of [[R, 0], [r, h]]) { const rg = new THREE.RingGeometry(rad - 0.02, rad + 0.02, 48); const rm = new THREE.MeshBasicMaterial({ color: 0x93C5FD, side: THREE.DoubleSide }); const mesh = new THREE.Mesh(rg, rm); mesh.rotation.x = -Math.PI/2; mesh.position.y = y; this._figGroup.add(mesh); } this._vertices.push({ pos: new THREE.Vector3(0, 0, 0), label: 'O₁' }); this._vertices.push({ pos: new THREE.Vector3(0, h, 0), label: 'O₂' }); this._edges.push({ from: new THREE.Vector3(0, 0, 0), to: new THREE.Vector3(0, h, 0) }); const n = 8; for (let i = 0; i < n; i++) { const angle = (i / n) * Math.PI * 2; this._edges.push({ from: new THREE.Vector3(R * Math.cos(angle), 0, R * Math.sin(angle)), to: new THREE.Vector3(r * Math.cos(angle), h, r * Math.sin(angle)), }); } // circle arc segments for point-picking on both bases const arcN = 32; for (let i = 0; i < arcN; i++) { const a1 = (i / arcN) * Math.PI * 2, a2 = ((i + 1) / arcN) * Math.PI * 2; this._edges.push({ from: new THREE.Vector3(R * Math.cos(a1), 0, R * Math.sin(a1)), to: new THREE.Vector3(R * Math.cos(a2), 0, R * Math.sin(a2)) }); this._edges.push({ from: new THREE.Vector3(r * Math.cos(a1), h, r * Math.sin(a1)), to: new THREE.Vector3(r * Math.cos(a2), h, r * Math.sin(a2)) }); } this._addEdges(0.4); this._addVerticesAndLabels(); } /* ── SPHERE ── */ _buildSphere() { const rad = this.params.r; const geo = new THREE.SphereGeometry(rad, 48, 32); geo.translate(0, rad, 0); const mat = new THREE.MeshPhysicalMaterial({ color: 0x9B5DE5, transparent: true, opacity: this.opacity, side: THREE.DoubleSide, metalness: 0.1, roughness: 0.3, clearcoat: 0.5, depthWrite: false, }); this._figGroup.add(new THREE.Mesh(geo, mat)); // equator + meridian wireframes const createCircle = (radius, y, rotX) => { const pts = []; for (let i = 0; i <= 64; i++) { const a = (i/64)*Math.PI*2; pts.push(new THREE.Vector3(radius*Math.cos(a), 0, radius*Math.sin(a))); } const lineGeo = new THREE.BufferGeometry().setFromPoints(pts); const lineMat = new THREE.LineBasicMaterial({ color: 0xCCCCFF, transparent: true, opacity: 0.5 }); const line = new THREE.Line(lineGeo, lineMat); line.position.y = y; if (rotX) line.rotation.x = rotX; return line; }; this._figGroup.add(createCircle(rad, rad, 0)); this._figGroup.add(createCircle(rad, rad, Math.PI/2)); this._vertices.push({ pos: new THREE.Vector3(0, rad, 0), label: 'O' }); this._vertices.push({ pos: new THREE.Vector3(0, 2*rad, 0), label: 'N' }); this._vertices.push({ pos: new THREE.Vector3(0, 0, 0), label: 'S' }); this._addVerticesAndLabels(); } /* ── PRISM ── */ _buildPrism() { const { a, h, n } = this.params; const baseVerts = this._regularPolygon(n, a); const topVerts = baseVerts.map(v => new THREE.Vector3(v.x, h, v.z)); const labels = 'ABCDEFGH'.split(''); this._vertices = baseVerts.map((pos, i) => ({ pos, label: labels[i] || `P${i}` })); topVerts.forEach((pos, i) => this._vertices.push({ pos, label: (labels[i] || `P${i}`) + '₁' })); // edges for (let i = 0; i < n; i++) { this._edges.push({ from: baseVerts[i], to: baseVerts[(i+1)%n] }); // base this._edges.push({ from: topVerts[i], to: topVerts[(i+1)%n] }); // top this._edges.push({ from: baseVerts[i], to: topVerts[i] }); // vertical } // faces this._faces.push([...baseVerts]); this._faces.push([...topVerts]); for (let i = 0; i < n; i++) { const j = (i+1) % n; this._faces.push([baseVerts[i], baseVerts[j], topVerts[j], topVerts[i]]); } this._addMeshFromFaces(0x06D6A0); this._addEdges(); this._addVerticesAndLabels(); } /* ── TRUNCATED PYRAMID ── */ _buildTruncPyramid() { const { a, b, h, n } = this.params; const baseVerts = this._regularPolygon(n, a); const topVerts = this._regularPolygon(n, b).map(v => new THREE.Vector3(v.x, h, v.z)); const labels = 'ABCDEFGH'.split(''); this._vertices = baseVerts.map((pos, i) => ({ pos, label: labels[i] || `P${i}` })); topVerts.forEach((pos, i) => this._vertices.push({ pos, label: (labels[i] || `P${i}`) + '₁' })); for (let i = 0; i < n; i++) { this._edges.push({ from: baseVerts[i], to: baseVerts[(i+1)%n] }); this._edges.push({ from: topVerts[i], to: topVerts[(i+1)%n] }); this._edges.push({ from: baseVerts[i], to: topVerts[i] }); } this._faces.push([...baseVerts]); this._faces.push([...topVerts]); for (let i = 0; i < n; i++) { const j = (i+1) % n; this._faces.push([baseVerts[i], baseVerts[j], topVerts[j], topVerts[i]]); } this._addMeshFromFaces(0xF59E0B); this._addEdges(); this._addVerticesAndLabels(); } /* ── OCTAHEDRON ── */ _buildOctahedron() { const a = this.params.a; const R = a / Math.SQRT2; // equatorial radius (dist from center to eq. vertex) const v = [ new THREE.Vector3(0, 0, 0), // 0 S₁ (bottom) new THREE.Vector3( R, R, 0), // 1 A new THREE.Vector3( 0, R, R), // 2 B new THREE.Vector3(-R, R, 0), // 3 C new THREE.Vector3( 0, R, -R), // 4 D new THREE.Vector3( 0, a * Math.SQRT2, 0), // 5 S₂ (top) ]; const labels = ['S₁','A','B','C','D','S₂']; this._vertices = v.map((pos, i) => ({ pos, label: labels[i] })); const edgeIdx = [[0,1],[0,2],[0,3],[0,4],[5,1],[5,2],[5,3],[5,4],[1,2],[2,3],[3,4],[4,1]]; this._edges = edgeIdx.map(([i, j]) => ({ from: v[i], to: v[j] })); this._faces = [ [v[0],v[1],v[2]], [v[0],v[2],v[3]], [v[0],v[3],v[4]], [v[0],v[4],v[1]], [v[5],v[2],v[1]], [v[5],v[3],v[2]], [v[5],v[4],v[3]], [v[5],v[1],v[4]], ]; this._addMeshFromFaces(0xF15BB5); this._addEdges(); this._addVerticesAndLabels(); } /* ── ICOSAHEDRON ── */ _buildIcosahedron() { const a = this.params.a; const circR = a * Math.sqrt(10 + 2 * Math.sqrt(5)) / 4; const geo = new THREE.IcosahedronGeometry(circR, 0); const posArr = geo.getAttribute('position').array; let minY = Infinity; for (let i = 1; i < posArr.length; i += 3) minY = Math.min(minY, posArr[i]); geo.translate(0, -minY, 0); const mat = new THREE.MeshPhysicalMaterial({ color: 0x06D6E0, transparent: true, opacity: this.opacity, side: THREE.DoubleSide, metalness: 0.05, roughness: 0.4, clearcoat: 0.2, depthWrite: false, }); this._figGroup.add(new THREE.Mesh(geo, mat)); const verts = this._extractUniqueVerts(geo); const labels = 'ABCDEFGHIJKL'.split(''); this._vertices = verts.map((pos, i) => ({ pos, label: labels[i] || `V${i+1}` })); for (let i = 0; i < verts.length; i++) for (let j = i + 1; j < verts.length; j++) if (verts[i].distanceTo(verts[j]) <= a * 1.06) this._edges.push({ from: verts[i], to: verts[j] }); this._addEdges(); this._addVerticesAndLabels(); } /* ── DODECAHEDRON ── */ _buildDodecahedron() { const a = this.params.a; const phi = (1 + Math.sqrt(5)) / 2; const circR = a * Math.sqrt(3) * phi / 2; const geo = new THREE.DodecahedronGeometry(circR, 0); const posArr = geo.getAttribute('position').array; let minY = Infinity; for (let i = 1; i < posArr.length; i += 3) minY = Math.min(minY, posArr[i]); geo.translate(0, -minY, 0); const mat = new THREE.MeshPhysicalMaterial({ color: 0x9B5DE5, transparent: true, opacity: this.opacity, side: THREE.DoubleSide, metalness: 0.05, roughness: 0.4, clearcoat: 0.2, depthWrite: false, }); this._figGroup.add(new THREE.Mesh(geo, mat)); const edgeLen = a * 2 / phi; // dodecahedron edge length relative to circumradius const verts = this._extractUniqueVerts(geo); const labels = 'ABCDEFGHIJKLMNOPQRST'.split(''); this._vertices = verts.map((pos, i) => ({ pos, label: labels[i] || `V${i+1}` })); for (let i = 0; i < verts.length; i++) for (let j = i + 1; j < verts.length; j++) if (verts[i].distanceTo(verts[j]) <= a * 1.06) this._edges.push({ from: verts[i], to: verts[j] }); this._addEdges(); this._addVerticesAndLabels(); } /* ════════════════ GEOMETRY HELPERS ════════════════ */ _regularPolygon(n, sideLength) { const r = sideLength / (2 * Math.sin(Math.PI / n)); const pts = []; for (let i = 0; i < n; i++) { const angle = (i / n) * Math.PI * 2 - Math.PI / 2; pts.push(new THREE.Vector3(r * Math.cos(angle), 0, r * Math.sin(angle))); } return pts; } /* Extract deduplicated vertex list from a THREE.BufferGeometry */ _extractUniqueVerts(geo) { const arr = geo.getAttribute('position').array; const map = new Map(); const verts = []; for (let i = 0; i < arr.length; i += 3) { const key = `${(arr[i]*1000)|0},${(arr[i+1]*1000)|0},${(arr[i+2]*1000)|0}`; if (!map.has(key)) { map.set(key, verts.length); verts.push(new THREE.Vector3(arr[i], arr[i+1], arr[i+2])); } } return verts; } _addMeshFromFaces(color) { // Build a single mesh from faces const positions = []; const indices = []; let vi = 0; for (const face of this._faces) { const base = vi; for (const v of face) { positions.push(v.x, v.y, v.z); vi++; } // triangulate fan for (let i = 1; i < face.length - 1; i++) { indices.push(base, base + i, base + i + 1); } } const geo = new THREE.BufferGeometry(); geo.setAttribute('position', new THREE.Float32BufferAttribute(positions, 3)); geo.setIndex(indices); geo.computeVertexNormals(); const mat = new THREE.MeshPhysicalMaterial({ color, transparent: true, opacity: this.opacity, side: THREE.DoubleSide, metalness: 0.05, roughness: 0.4, clearcoat: 0.2, depthWrite: false, }); this._figGroup.add(new THREE.Mesh(geo, mat)); } _addEdges(opac = 0.8) { if (!this.showEdges) return; for (const e of this._edges) { const pts = [e.from, e.to]; 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); } } } _addVerticesAndLabels() { for (const v of this._vertices) { if (this.showVertices) { const sGeo = new THREE.SphereGeometry(0.08, 12, 12); const sMat = new THREE.MeshBasicMaterial({ color: 0xFFFFFF }); const sphere = new THREE.Mesh(sGeo, sMat); sphere.position.copy(v.pos); this._figGroup.add(sphere); } if (this.showLabels) { const sprite = this._makeTextSprite(v.label); sprite.position.copy(v.pos).add(new THREE.Vector3(0.15, 0.25, 0)); this._labelGroup.add(sprite); } } } _makeTextSprite(text, color = '#ffffff', size = 64) { const canvas = document.createElement('canvas'); canvas.width = 128; canvas.height = 64; const ctx = canvas.getContext('2d'); ctx.font = `bold ${size}px Manrope, sans-serif`; ctx.fillStyle = color; ctx.textAlign = 'center'; ctx.textBaseline = 'middle'; ctx.fillText(text, 64, 32); const tex = new THREE.CanvasTexture(canvas); tex.minFilter = THREE.LinearFilter; const mat = new THREE.SpriteMaterial({ map: tex, transparent: true, depthTest: false }); const sprite = new THREE.Sprite(mat); sprite.scale.set(0.8, 0.4, 1); return sprite; } /* ════════════════ SECTION PLANE ════════════════ */ _updateSection() { this._clearGroup(this._sectionGroup); this._sectionPolygon = null; if (!this.showSection) return; const figH = this._figureHeight(); const t = this.sectionType; if (t === 'horizontal') { const y = figH * this.sectionHeight; this._sectionPolygon = this._sliceAtY(y); if (this._sectionPolygon.length >= 3) { this._drawSectionPolygon(this._sectionPolygon, y); // translucent plane const planeGeo = new THREE.PlaneGeometry(16, 16); const planeMat = new THREE.MeshBasicMaterial({ color: 0x06D6E0, transparent: true, opacity: 0.08, side: THREE.DoubleSide }); const planeMesh = new THREE.Mesh(planeGeo, planeMat); planeMesh.rotation.x = -Math.PI/2; planeMesh.position.y = y; this._sectionGroup.add(planeMesh); } } else if (t === 'diagonal') { this._sectionPolygon = this._sliceDiagonal(); if (this._sectionPolygon.length >= 3) { this._drawSectionPolygon3D(this._sectionPolygon); this._drawSectionInfo(this._sectionPolygon); } } else if (t === 'custom') { // Need ≥3 custom points to define a plane if (this._customPoints.length >= 3) { const pts3 = this._customPoints.slice(0, 3).map(p => p.pos); this._sectionPolygon = this._sliceByPlane(pts3[0], pts3[1], pts3[2]); if (this._sectionPolygon.length >= 3) { this._drawSectionPolygon3D(this._sectionPolygon); this._drawSectionInfo(this._sectionPolygon); } } } } _figureHeight() { const p = this.params; switch (this.figureType) { case 'cube': return p.a; case 'parallelepiped': return p.c; case 'pyramid': case 'prism': case 'truncpyramid': return p.h; case 'tetrahedron': return p.a * Math.sqrt(2/3); case 'cylinder': case 'cone': case 'trunccone': return p.h; case 'sphere': return p.r * 2; case 'octahedron': return p.a * Math.SQRT2; case 'icosahedron': return p.a * Math.sqrt(10 + 2*Math.sqrt(5)) / 2; case 'dodecahedron': { const phi = (1+Math.sqrt(5))/2; return p.a * Math.sqrt(3) * phi; } default: return p.h || p.a || 4; } } _sliceAtY(y) { // Find intersections of all edges with horizontal plane y const points = []; for (const e of this._edges) { const y1 = e.from.y, y2 = e.to.y; if ((y1 <= y && y2 >= y) || (y2 <= y && y1 >= y)) { if (Math.abs(y2 - y1) < 1e-9) continue; const t = (y - y1) / (y2 - y1); if (t < -0.001 || t > 1.001) continue; const pt = new THREE.Vector3().lerpVectors(e.from, e.to, t); points.push(pt); } } // For cylinders/cones/spheres with no explicit edges, generate circle if (points.length < 3) { const circleR = this._radiusAtHeight(y); if (circleR > 0.01) { const n = 48; for (let i = 0; i < n; i++) { const a = (i/n)*Math.PI*2; points.push(new THREE.Vector3(circleR*Math.cos(a), y, circleR*Math.sin(a))); } } } // Sort by angle from centroid return this._sortByAngle(points); } _radiusAtHeight(y) { const p = this.params; const fh = this._figureHeight(); const t = Math.max(0, Math.min(1, y / fh)); switch (this.figureType) { case 'cylinder': return p.r; case 'cone': return p.r * (1 - t); case 'trunccone': return p.R + (p.r - p.R) * t; case 'sphere': { const dy = y - p.r; // center at (0, r, 0) const r2 = p.r**2 - dy**2; return r2 > 0 ? Math.sqrt(r2) : 0; } default: return 0; } } _sliceDiagonal() { const fh = this._figureHeight(); const y = fh * this.sectionHeight; // Tilt angle: sectionAngle 0=horizontal, 1=nearly vertical (~80°) const tiltRad = this.sectionAngle * Math.PI * 0.44; // 0..~80° // Normal rotated from vertical (0,1,0) toward X axis const normal = new THREE.Vector3(Math.sin(tiltRad), Math.cos(tiltRad), 0).normalize(); const pointOnPlane = new THREE.Vector3(0, y, 0); return this._sliceByNormal(normal, pointOnPlane); } _sliceByPlane(p1, p2, p3) { // Plane through 3 points const v1 = new THREE.Vector3().subVectors(p2, p1); const v2 = new THREE.Vector3().subVectors(p3, p1); const normal = new THREE.Vector3().crossVectors(v1, v2).normalize(); if (normal.length() < 1e-9) return []; return this._sliceByNormal(normal, p1); } _sliceByNormal(normal, pointOnPlane) { const d = -normal.dot(pointOnPlane); const points = []; // Intersect with all edges for (const e of this._edges) { const d1 = normal.dot(e.from) + d; const d2 = normal.dot(e.to) + d; if ((d1 <= 0 && d2 >= 0) || (d2 <= 0 && d1 >= 0)) { if (Math.abs(d2 - d1) < 1e-9) continue; const t = -d1 / (d2 - d1); if (t < -0.001 || t > 1.001) continue; points.push(new THREE.Vector3().lerpVectors(e.from, e.to, Math.max(0, Math.min(1, t)))); } } // For cylinders/cones/spheres — sample the intersection curve if (points.length < 3 && ['cylinder','cone','trunccone','sphere'].includes(this.figureType)) { const fh = this._figureHeight(); const samples = 64; for (let i = 0; i < samples; i++) { const angle = (i / samples) * Math.PI * 2; // Walk along height, find where the plane intersects at this angle for (let step = 0; step <= 100; step++) { const y = (step / 100) * fh; const r = this._radiusAtHeight(y); if (r < 0.01) continue; const pt = new THREE.Vector3(r * Math.cos(angle), y, r * Math.sin(angle)); const dist = normal.dot(pt) + d; if (Math.abs(dist) < fh * 0.012) { points.push(pt); break; } } } } // Sort into convex polygon order (project onto plane, sort by angle) return this._sortByAngle3D(points, normal); } _sortByAngle(points) { if (points.length < 3) return points; const cx = points.reduce((s,p) => s+p.x, 0) / points.length; const cz = points.reduce((s,p) => s+p.z, 0) / points.length; points.sort((a,b) => Math.atan2(a.z-cz, a.x-cx) - Math.atan2(b.z-cz, b.x-cx)); return points; } _sortByAngle3D(points, normal) { if (points.length < 3) return points; // Remove near-duplicates const unique = [points[0]]; for (let i = 1; i < points.length; i++) { let dup = false; for (const u of unique) { if (points[i].distanceTo(u) < 0.01) { dup = true; break; } } if (!dup) unique.push(points[i]); } if (unique.length < 3) return unique; // Centroid const c = new THREE.Vector3(); unique.forEach(p => c.add(p)); c.divideScalar(unique.length); // Build local 2D basis on the plane const u = new THREE.Vector3().subVectors(unique[0], c).normalize(); const v = new THREE.Vector3().crossVectors(normal, u).normalize(); // Project and sort by angle unique.sort((a, b) => { const da = new THREE.Vector3().subVectors(a, c); const db = new THREE.Vector3().subVectors(b, c); return Math.atan2(da.dot(v), da.dot(u)) - Math.atan2(db.dot(v), db.dot(u)); }); return unique; } _drawSectionInfo(pts) { if (pts.length < 3) return; const area = this._polygonArea(pts); let perimeter = 0; for (let i = 0; i < pts.length; i++) { perimeter += pts[i].distanceTo(pts[(i + 1) % pts.length]); } // Label at centroid const c = new THREE.Vector3(); pts.forEach(p => c.add(p)); c.divideScalar(pts.length); const label = this._makeTextSprite(`S=${area.toFixed(1)} P=${perimeter.toFixed(1)}`, '#06D6E0', 36); label.position.copy(c).add(new THREE.Vector3(0, 0.4, 0)); label.scale.set(1.6, 0.5, 1); this._sectionGroup.add(label); // Vertex markers on section polygon for (const p of pts) { const sGeo = new THREE.SphereGeometry(0.06, 8, 8); const sMat = new THREE.MeshBasicMaterial({ color: 0x06D6E0 }); const sm = new THREE.Mesh(sGeo, sMat); sm.position.copy(p); this._sectionGroup.add(sm); } } _drawSectionPolygon(pts, y) { if (pts.length < 3) return; // Fill polygon const shape = new THREE.Shape(); shape.moveTo(pts[0].x, pts[0].z); for (let i = 1; i < pts.length; i++) shape.lineTo(pts[i].x, pts[i].z); shape.closePath(); const geo = new THREE.ShapeGeometry(shape); const mat = new THREE.MeshBasicMaterial({ color: 0x06D6E0, transparent: true, opacity: 0.35, side: THREE.DoubleSide }); const mesh = new THREE.Mesh(geo, mat); mesh.rotation.x = -Math.PI/2; mesh.position.y = y + 0.005; this._sectionGroup.add(mesh); // outline const linePts = [...pts, pts[0]]; const lineGeo = new THREE.BufferGeometry().setFromPoints(linePts); const lineMat = new THREE.LineBasicMaterial({ color: 0x06D6E0, linewidth: 2 }); this._sectionGroup.add(new THREE.Line(lineGeo, lineMat)); } _drawSectionPolygon3D(pts) { if (pts.length < 3) return; const positions = []; const indices = []; pts.forEach(p => positions.push(p.x, p.y, p.z)); for (let i = 1; i < pts.length - 1; i++) indices.push(0, i, i+1); const geo = new THREE.BufferGeometry(); geo.setAttribute('position', new THREE.Float32BufferAttribute(positions, 3)); geo.setIndex(indices); geo.computeVertexNormals(); const mat = new THREE.MeshBasicMaterial({ color: 0x06D6E0, transparent: true, opacity: 0.35, side: THREE.DoubleSide }); this._sectionGroup.add(new THREE.Mesh(geo, mat)); const linePts = [...pts, pts[0]]; const lineGeo = new THREE.BufferGeometry().setFromPoints(linePts); this._sectionGroup.add(new THREE.Line(lineGeo, new THREE.LineBasicMaterial({ color: 0x06D6E0 }))); } _polygonArea(pts) { // Shoelace in 3D — project onto best-fit plane if (pts.length < 3) return 0; let area = 0; const n = pts.length; const cx = pts.reduce((s,p)=>s+p.x,0)/n; const cy = pts.reduce((s,p)=>s+p.y,0)/n; const cz = pts.reduce((s,p)=>s+p.z,0)/n; // Cross-product sum const cross = new THREE.Vector3(0,0,0); for (let i = 0; i < n; i++) { const a = new THREE.Vector3().subVectors(pts[i], new THREE.Vector3(cx,cy,cz)); const b = new THREE.Vector3().subVectors(pts[(i+1)%n], new THREE.Vector3(cx,cy,cz)); cross.add(new THREE.Vector3().crossVectors(a, b)); } return Math.round(cross.length() / 2 * 100) / 100; } /* ════════════════ INSCRIBED / CIRCUMSCRIBED ════════════════ */ _inscribedRadius() { const p = this.params; const PI = Math.PI; switch (this.figureType) { case 'cube': return p.a / 2; case 'parallelepiped': return Math.min(p.a, p.b, p.c) / 2; case 'tetrahedron': return p.a / (2 * Math.sqrt(6)); case 'sphere': return p.r; case 'cylinder': return Math.min(p.r, p.h / 2); case 'cone': { // r_in = r * h / (r + sqrt(r²+h²)) const l = Math.sqrt(p.r ** 2 + p.h ** 2); return p.r * p.h / (p.r + l); } case 'trunccone': { // inscribed sphere radius = h * (R - r) / (2 * sqrt((R-r)² + h²))... approximate // exact for truncated cone: r_in = h / (1 + sqrt(1 + ((R-r)/h)²)) * (not standard) // simpler: r_in = h * min(R, r) / sqrt((R-r)² + h²) — approximate // Actually: for truncated cone, inscribed sphere touches both bases and lateral surface // r_in = h * (R + r - sqrt((R-r)² + h²)) / (2 * (R - r)) when R ≠ r if (Math.abs(p.R - p.r) < 0.001) return Math.min(p.R, p.h / 2); // cylinder-like const l = Math.sqrt((p.R - p.r) ** 2 + p.h ** 2); return p.h * (p.R + p.r - l) / (2 * Math.abs(p.R - p.r)); } case 'pyramid': { // r_in = 3V / S_total const { a, h, n } = p; const sBase = n * a ** 2 / (4 * Math.tan(PI / n)); const apothem = a / (2 * Math.tan(PI / n)); const slantH = Math.sqrt(h ** 2 + apothem ** 2); const sLat = n * a * slantH / 2; const V = sBase * h / 3; return 3 * V / (sBase + sLat); } case 'prism': { const { a, h, n } = p; const apothem = a / (2 * Math.tan(PI / n)); return Math.min(apothem, h / 2); } case 'octahedron': return p.a * Math.sqrt(6) / 6; case 'icosahedron': { const phi = (1+Math.sqrt(5))/2; return p.a * phi**2 / (2*Math.sqrt(3)); } case 'dodecahedron': return p.a / 2 * Math.sqrt((25 + 11*Math.sqrt(5)) / 10); default: return null; } } _circumscribedRadius() { const p = this.params; const PI = Math.PI; switch (this.figureType) { case 'cube': return p.a * Math.sqrt(3) / 2; case 'parallelepiped': return Math.sqrt(p.a ** 2 + p.b ** 2 + p.c ** 2) / 2; case 'tetrahedron': return p.a * Math.sqrt(6) / 4; case 'sphere': return p.r; case 'cylinder': return Math.sqrt(p.r ** 2 + (p.h / 2) ** 2); case 'cone': { // circumscribed sphere around cone: passes through apex and base circle // R = (r² + h²) / (2h) return (p.r ** 2 + p.h ** 2) / (2 * p.h); } case 'trunccone': { // R = sqrt(R² + (h/2 + (R²-r²)/(2h))²) — approximate const hc = p.h / 2 + (p.R ** 2 - p.r ** 2) / (2 * p.h); return Math.sqrt(p.R ** 2 + hc ** 2); } case 'pyramid': { // R = sqrt(R_base² + h²_offset) where R_base = circumradius of base polygon // center of circumscribed sphere at height y: y = h - R, R² = R_base² + (h - y)² // solving: R = (R_base² + h²) / (2h) const { a, h, n } = p; const Rb = a / (2 * Math.sin(PI / n)); return (Rb ** 2 + h ** 2) / (2 * h); } case 'prism': { const { a, h, n } = p; const Rb = a / (2 * Math.sin(PI / n)); return Math.sqrt(Rb ** 2 + (h / 2) ** 2); } case 'octahedron': return p.a * Math.SQRT2 / 2; case 'icosahedron': return p.a * Math.sqrt(10 + 2*Math.sqrt(5)) / 4; case 'dodecahedron': { const phi = (1+Math.sqrt(5))/2; return p.a * Math.sqrt(3) * phi / 2; } default: return null; } } _inscribedCenter() { const p = this.params; const PI = Math.PI; switch (this.figureType) { case 'cube': return p.a / 2; case 'parallelepiped': return p.c / 2; case 'tetrahedron': return p.a / (2 * Math.sqrt(6)); // r_in from base case 'sphere': return p.r; case 'cylinder': return p.h / 2; case 'cone': { const l = Math.sqrt(p.r ** 2 + p.h ** 2); return p.r * p.h / (p.r + l); // r_in = height of center } case 'trunccone': return p.h / 2; case 'pyramid': { // inscribed sphere center at height r_in from base const { a, h, n } = p; const sBase = n * a ** 2 / (4 * Math.tan(PI / n)); const apothem = a / (2 * Math.tan(PI / n)); const slantH = Math.sqrt(h ** 2 + apothem ** 2); const sLat = n * a * slantH / 2; const V = sBase * h / 3; return 3 * V / (sBase + sLat); // r_in } case 'prism': return p.h / 2; case 'octahedron': return p.a * Math.SQRT2 / 2; // center of octahedron case 'icosahedron': return p.a * Math.sqrt(10 + 2*Math.sqrt(5)) / 4; // circumR = half-height approx case 'dodecahedron': { const phi = (1+Math.sqrt(5))/2; return p.a * Math.sqrt(3) * phi / 2; } default: return this._figureHeight() / 2; } } _circumscribedCenter() { const p = this.params; const PI = Math.PI; switch (this.figureType) { case 'cube': return p.a / 2; case 'parallelepiped': return p.c / 2; case 'tetrahedron': { // center at h - R from base = h - a*sqrt(6)/4 const h = p.a * Math.sqrt(2 / 3); return h - p.a * Math.sqrt(6) / 4; } case 'sphere': return p.r; case 'cylinder': return p.h / 2; case 'cone': { // center at y = R_circ from apex? No. center at y = h - R + h_offset // R = (r²+h²)/(2h), center at y = h - R = h - (r²+h²)/(2h) = (h²-r²)/(2h) // but if r > h, center goes below base — clamp const R = (p.r ** 2 + p.h ** 2) / (2 * p.h); return p.h - R; } case 'trunccone': { const hc = p.h / 2 + (p.R ** 2 - p.r ** 2) / (2 * p.h); return hc; // height of center from base } case 'pyramid': { const { a, h, n } = p; const Rb = a / (2 * Math.sin(PI / n)); const R = (Rb ** 2 + h ** 2) / (2 * h); return h - R; // from base } case 'prism': return p.h / 2; case 'octahedron': return p.a * Math.SQRT2 / 2; case 'icosahedron': return p.a * Math.sqrt(10 + 2*Math.sqrt(5)) / 4; case 'dodecahedron': { const phi = (1+Math.sqrt(5))/2; return p.a * Math.sqrt(3) * phi / 2; } default: return this._figureHeight() / 2; } } _updateSpheres() { this._clearGroup(this._sphereGroup); if (this.showInscribed) { const r = this._inscribedRadius(); const cy = this._inscribedCenter(); if (r && r > 0) { const geo = new THREE.SphereGeometry(r, 32, 24); const mat = new THREE.MeshPhysicalMaterial({ color: 0x06D6E0, transparent: true, opacity: 0.12, side: THREE.DoubleSide, depthWrite: false, }); const mesh = new THREE.Mesh(geo, mat); mesh.position.y = cy; this._sphereGroup.add(mesh); const wf = new THREE.LineSegments( new THREE.WireframeGeometry(geo), new THREE.LineBasicMaterial({ color: 0x06D6E0, transparent: true, opacity: 0.2 }) ); wf.position.y = cy; this._sphereGroup.add(wf); // radius label const lbl = this._makeTextSprite(`r=${r.toFixed(2)}`, '#06D6E0', 36); lbl.position.set(r * 0.5, cy + r * 0.3, 0); lbl.scale.set(1.0, 0.4, 1); this._sphereGroup.add(lbl); // radius line const lineGeo = new THREE.BufferGeometry().setFromPoints([ new THREE.Vector3(0, cy, 0), new THREE.Vector3(r, cy, 0) ]); const lineMat = new THREE.LineDashedMaterial({ color: 0x06D6E0, dashSize: 0.1, gapSize: 0.06, transparent: true, opacity: 0.7 }); const line = new THREE.Line(lineGeo, lineMat); line.computeLineDistances(); this._sphereGroup.add(line); } } if (this.showCircumscribed) { const R = this._circumscribedRadius(); const cy = this._circumscribedCenter(); if (R && R > 0) { const geo = new THREE.SphereGeometry(R, 32, 24); const mat = new THREE.MeshPhysicalMaterial({ color: 0xF59E0B, transparent: true, opacity: 0.08, side: THREE.DoubleSide, depthWrite: false, }); const mesh = new THREE.Mesh(geo, mat); mesh.position.y = cy; this._sphereGroup.add(mesh); const wf = new THREE.LineSegments( new THREE.WireframeGeometry(geo), new THREE.LineBasicMaterial({ color: 0xF59E0B, transparent: true, opacity: 0.15 }) ); wf.position.y = cy; this._sphereGroup.add(wf); // radius label const lbl = this._makeTextSprite(`R=${R.toFixed(2)}`, '#F59E0B', 36); lbl.position.set(R * 0.5, cy + R * 0.3, 0); lbl.scale.set(1.0, 0.4, 1); this._sphereGroup.add(lbl); // radius line const lineGeo = new THREE.BufferGeometry().setFromPoints([ new THREE.Vector3(0, cy, 0), new THREE.Vector3(R, cy, 0) ]); const lineMat = new THREE.LineDashedMaterial({ color: 0xF59E0B, dashSize: 0.1, gapSize: 0.06, transparent: true, opacity: 0.7 }); const line = new THREE.Line(lineGeo, lineMat); line.computeLineDistances(); this._sphereGroup.add(line); } } } /* ════════════════ SECTION THROUGH 3 POINTS ════════════════ */ _onSection3PClick(e) { if (!this._section3PMode) return; if (this._section3PPicks.length >= 3) return; // already have 3 — need reset first const { mx, my } = this._screenCoords(e); // Pick nearest point: prefer vertex snap, then edge snap let bestDist = 0.09; let bestPos = null; for (const v of this._vertices) { const proj = v.pos.clone().project(this.camera); const d = Math.sqrt((mx - proj.x) ** 2 + (my - proj.y) ** 2); if (d < bestDist) { bestDist = d; bestPos = v.pos.clone(); } } // Also check custom points if placed for (const cp of this._customPoints) { const proj = cp.pos.clone().project(this.camera); const d = Math.sqrt((mx - proj.x) ** 2 + (my - proj.y) ** 2); if (d < bestDist) { bestDist = d; bestPos = cp.pos.clone(); } } // Edge snap (pick point on edge) for (const edge of this._edges) { const p1 = edge.from.clone().project(this.camera); const p2 = edge.to.clone().project(this.camera); const dx = p2.x - p1.x, dy = p2.y - p1.y; const lenSq = dx * dx + dy * dy; if (lenSq < 1e-9) continue; let t = ((mx - p1.x) * dx + (my - p1.y) * dy) / lenSq; t = Math.max(0, Math.min(1, t)); const px = p1.x + t * dx, py = p1.y + t * dy; const d = Math.sqrt((mx - px) ** 2 + (my - py) ** 2); if (d < bestDist) { bestDist = d; bestPos = new THREE.Vector3().lerpVectors(edge.from, edge.to, t); } } if (!bestPos) return; // Avoid duplicate picks (too close) for (const p of this._section3PPicks) { if (p.distanceTo(bestPos) < 0.08) return; } this._section3PPicks.push(bestPos); this._drawSection3P(); if (window.LabFX) LabFX.sound.play('tick', { pitch: 1.5, volume: 0.3 }); if (this._section3PPicks.length === 3) { this._computeSection3P(); this._drawSection3P(); this._notify(); if (window.LabFX) LabFX.sound.play('chime', { pitch: 1.1 }); } } _computeSection3P() { const pts = this._section3PPicks; if (pts.length < 3) { this._section3PData = null; return; } const [P1, P2, P3] = pts; const v1 = new THREE.Vector3().subVectors(P2, P1); const v2 = new THREE.Vector3().subVectors(P3, P1); const normal = new THREE.Vector3().crossVectors(v1, v2); if (normal.length() < 1e-9) { this._section3PData = null; return; } normal.normalize(); const D = -normal.dot(P1); // Intersect the plane with all edges of the solid const polygon = this._sliceByNormal(normal, P1); if (polygon.length < 3) { this._section3PData = null; return; } const area = this._polygonArea(polygon); const n = polygon.length; const typeNames = { 3: 'треугольник', 4: 'четырёхугольник', 5: 'пятиугольник', 6: 'шестиугольник' }; const typeName = typeNames[n] || `${n}-угольник`; this._section3PData = { normal, D, polygon, area, typeName, P1, P2, P3 }; } _drawSection3P() { this._clearGroup(this._section3PGroup); const picks = this._section3PPicks; const data = this._section3PData; // Draw picked points as spheres (yellow accent) const PICK_COLOR = 0xFFD166; const PLANE_COLOR = 0xEF476F; const SECT_COLOR = 0x7BF5A4; picks.forEach((p, i) => { const sGeo = new THREE.SphereGeometry(0.13, 10, 10); const sMat = new THREE.MeshBasicMaterial({ color: PICK_COLOR }); const s = new THREE.Mesh(sGeo, sMat); s.position.copy(p); this._section3PGroup.add(s); // Number label const lbl = this._makeTextSprite(String(i + 1), '#FFD166', 42); lbl.position.copy(p).add(new THREE.Vector3(0.25, 0.25, 0)); lbl.scale.set(0.7, 0.28, 1); this._section3PGroup.add(lbl); }); // Draw line from P1 to P2 after 2nd pick if (picks.length >= 2) { const lg1 = new THREE.BufferGeometry().setFromPoints([picks[0], picks[1]]); this._section3PGroup.add(new THREE.Line(lg1, new THREE.LineBasicMaterial({ color: PICK_COLOR, opacity: 0.7, transparent: true }))); } if (picks.length >= 3) { const lg2 = new THREE.BufferGeometry().setFromPoints([picks[1], picks[2]]); this._section3PGroup.add(new THREE.Line(lg2, new THREE.LineBasicMaterial({ color: PICK_COLOR, opacity: 0.7, transparent: true }))); const lg3 = new THREE.BufferGeometry().setFromPoints([picks[2], picks[0]]); this._section3PGroup.add(new THREE.Line(lg3, new THREE.LineBasicMaterial({ color: PICK_COLOR, opacity: 0.5, transparent: true }))); } if (!data || picks.length < 3) return; // Semi-transparent plane quad (large enough to show context) const { normal, D, polygon } = data; // Build a visible plane chip — use bounding box of polygon centroid + spread const c = new THREE.Vector3(); polygon.forEach(p => c.add(p)); c.divideScalar(polygon.length); // Local basis on plane const u = new THREE.Vector3().subVectors(polygon[0], c).normalize(); const v = new THREE.Vector3().crossVectors(normal, u).normalize(); const spread = Math.max(...polygon.map(p => c.distanceTo(p))) * 1.5; const planeVerts = [ c.clone().addScaledVector(u, -spread).addScaledVector(v, -spread), c.clone().addScaledVector(u, spread).addScaledVector(v, -spread), c.clone().addScaledVector(u, spread).addScaledVector(v, spread), c.clone().addScaledVector(u, -spread).addScaledVector(v, spread), ]; const planePositions = []; [[0,1,2],[0,2,3]].forEach(tri => tri.forEach(i => { const pv = planeVerts[i]; planePositions.push(pv.x, pv.y, pv.z); })); const planeGeo = new THREE.BufferGeometry(); planeGeo.setAttribute('position', new THREE.Float32BufferAttribute(planePositions, 3)); const planeMat = new THREE.MeshBasicMaterial({ color: PLANE_COLOR, transparent: true, opacity: 0.08, side: THREE.DoubleSide }); this._section3PGroup.add(new THREE.Mesh(planeGeo, planeMat)); // Cross-section polygon fill const sectPositions = []; const sectIndices = []; polygon.forEach(p => sectPositions.push(p.x, p.y, p.z)); for (let i = 1; i < polygon.length - 1; i++) sectIndices.push(0, i, i + 1); const sectGeo = new THREE.BufferGeometry(); sectGeo.setAttribute('position', new THREE.Float32BufferAttribute(sectPositions, 3)); sectGeo.setIndex(sectIndices); sectGeo.computeVertexNormals(); const sectMat = new THREE.MeshBasicMaterial({ color: SECT_COLOR, transparent: true, opacity: 0.45, side: THREE.DoubleSide }); this._section3PGroup.add(new THREE.Mesh(sectGeo, sectMat)); // Polygon outline (slightly offset along normal for visibility) const outlinePts = [...polygon, polygon[0]].map(p => p.clone().addScaledVector(normal, 0.012) ); const outlineGeo = new THREE.BufferGeometry().setFromPoints(outlinePts); const outlineMat = new THREE.LineBasicMaterial({ color: SECT_COLOR, linewidth: 2 }); this._section3PGroup.add(new THREE.Line(outlineGeo, outlineMat)); // Vertex markers on section polygon polygon.forEach(p => { const sg = new THREE.SphereGeometry(0.07, 8, 8); const sm = new THREE.MeshBasicMaterial({ color: SECT_COLOR }); const s = new THREE.Mesh(sg, sm); s.position.copy(p); this._section3PGroup.add(s); }); // Step-by-step highlight (если включён пошаговый режим) if (this._section3PStepBy && this._section3PStep > 0) { this._drawSection3PStep(data); } } _drawSection3PStep(data) { // Extra step-by-step highlight objects added to _section3PGroup const step = this._section3PStep; const picks = this._section3PPicks; const HILITE = 0xFFFFA0; const flash = (pos) => { const sg = new THREE.SphereGeometry(0.22, 10, 10); const sm = new THREE.MeshBasicMaterial({ color: HILITE, transparent: true, opacity: 0.7 }); const s = new THREE.Mesh(sg, sm); s.position.copy(pos); this._section3PGroup.add(s); }; const flashLine = (a, b) => { const lg = new THREE.BufferGeometry().setFromPoints([a, b]); this._section3PGroup.add(new THREE.Line(lg, new THREE.LineBasicMaterial({ color: HILITE, linewidth: 3 }))); }; if (step >= 1) flash(picks[0]); if (step >= 2) { flash(picks[1]); flashLine(picks[0], picks[1]); } if (step >= 3) { flash(picks[2]); flashLine(picks[1], picks[2]); flashLine(picks[2], picks[0]); } // steps 4-6 handled by full plane + section already drawn above } /* ════════════════ MEASUREMENT MODE ════════════════ */ _onMeasureClick(e) { if (!this._measureMode) return; const { mx, my } = this._screenCoords(e); // Find closest vertex OR custom point in screen space let bestDist = 0.08; // threshold in NDC let bestPick = null; // Check vertices for (const v of this._vertices) { const projected = v.pos.clone().project(this.camera); const d = Math.sqrt((projected.x - mx) ** 2 + (projected.y - my) ** 2); if (d < bestDist) { bestDist = d; bestPick = { pos: v.pos, label: v.label }; } } // Check custom points for (const cp of this._customPoints) { const projected = cp.pos.clone().project(this.camera); const d = Math.sqrt((projected.x - mx) ** 2 + (projected.y - my) ** 2); if (d < bestDist) { bestDist = d; bestPick = { pos: cp.pos, label: cp.label }; } } if (!bestPick) return; this._measurePicks.push(bestPick); // Highlight first pick in temporary group const sGeo = new THREE.SphereGeometry(0.14, 12, 12); const sMat = new THREE.MeshBasicMaterial({ color: 0xFFD166 }); const s = new THREE.Mesh(sGeo, sMat); s.position.copy(bestPick.pos); this._measurePickGroup.add(s); if (this._measurePicks.length === 2) { const [a, b] = this._measurePicks; const dist = a.pos.distanceTo(b.pos); this._measurements.push({ from: a.label, to: b.label, dist: Math.round(dist * 100) / 100, posA: a.pos.clone(), posB: b.pos.clone(), }); this._measurePicks = []; this._clearGroup(this._measurePickGroup); this._rebuildMeasureGroup(); } } /* ════════════════ POINT MODE — place points on edges ════════════════ */ _screenCoords(e) { const rect = this.renderer.domElement.getBoundingClientRect(); return { mx: ((e.clientX - rect.left) / rect.width) * 2 - 1, my: -((e.clientY - rect.top) / rect.height) * 2 + 1, }; } _onPointClick(e) { const { mx, my } = this._screenCoords(e); // Find the nearest edge in screen space let bestDist = 0.08; // threshold in NDC let bestEdge = -1; let bestT = 0; let bestPos = null; for (let ei = 0; ei < this._edges.length; ei++) { const edge = this._edges[ei]; const p1 = edge.from.clone().project(this.camera); const p2 = edge.to.clone().project(this.camera); // Point-to-segment distance in 2D const dx = p2.x - p1.x, dy = p2.y - p1.y; const lenSq = dx * dx + dy * dy; if (lenSq < 1e-9) continue; let t = ((mx - p1.x) * dx + (my - p1.y) * dy) / lenSq; t = Math.max(0.02, Math.min(0.98, t)); // clamp away from endpoints const px = p1.x + t * dx, py = p1.y + t * dy; const dist = Math.sqrt((mx - px) ** 2 + (my - py) ** 2); if (dist < bestDist) { bestDist = dist; bestEdge = ei; bestT = t; bestPos = new THREE.Vector3().lerpVectors(edge.from, edge.to, t); } } // Also check: click near a vertex snap to vertex for (const v of this._vertices) { const proj = v.pos.clone().project(this.camera); const dist = Math.sqrt((mx - proj.x) ** 2 + (my - proj.y) ** 2); if (dist < 0.06) { bestPos = v.pos.clone(); bestEdge = -2; // special: vertex bestT = 0; bestDist = dist; } } if (!bestPos) return; const label = String(this._nextPointId++); this._customPoints.push({ pos: bestPos, edgeIdx: bestEdge, t: bestT, label, }); this._rebuildPointVisuals(); if (window.LabFX) LabFX.sound.play('tick', { pitch: 1.5, volume: 0.3 }); // If custom section mode and ≥3 points, update section if (this.showSection && this.sectionType === 'custom') this._updateSection(); this._notify(); } _onConnectClick(e) { const { mx, my } = this._screenCoords(e); // Find nearest custom point OR vertex let bestDist = 0.08; let bestIdx = -1; // Check custom points for (let i = 0; i < this._customPoints.length; i++) { const proj = this._customPoints[i].pos.clone().project(this.camera); const dist = Math.sqrt((mx - proj.x) ** 2 + (my - proj.y) ** 2); if (dist < bestDist) { bestDist = dist; bestIdx = i; } } // Also check vertices (mapped as negative indices: -1 vertex 0, -2 vertex 1, etc.) for (let i = 0; i < this._vertices.length; i++) { const proj = this._vertices[i].pos.clone().project(this.camera); const dist = Math.sqrt((mx - proj.x) ** 2 + (my - proj.y) ** 2); if (dist < bestDist) { bestDist = dist; bestIdx = -(i + 100); } // encode vertex } if (bestIdx === -1 && bestIdx !== -(99 + this._vertices.length)) return; // nothing found // Actually check: any valid pick if (bestDist >= 0.08) return; this._connectPicks.push(bestIdx); // Highlight pick const pos = bestIdx >= 0 ? this._customPoints[bestIdx].pos : this._vertices[-(bestIdx + 100)].pos; const sGeo = new THREE.SphereGeometry(0.12, 10, 10); const sMat = new THREE.MeshBasicMaterial({ color: 0xF59E0B }); const s = new THREE.Mesh(sGeo, sMat); s.position.copy(pos); this._pointGroup.add(s); if (this._connectPicks.length === 2) { const [idxA, idxB] = this._connectPicks; if (idxA !== idxB) { this._connections.push({ from: idxA, to: idxB }); } this._connectPicks = []; this._rebuildPointVisuals(); this._notify(); } } _getPointPos(idx) { if (idx >= 0) return this._customPoints[idx]?.pos; return this._vertices[-(idx + 100)]?.pos; } _getPointLabel(idx) { if (idx >= 0) return this._customPoints[idx]?.label || '?'; return this._vertices[-(idx + 100)]?.label || '?'; } _rebuildPointVisuals() { this._clearGroup(this._pointGroup); // Draw custom points for (const pt of this._customPoints) { // Sphere marker const sGeo = new THREE.SphereGeometry(0.1, 10, 10); const sMat = new THREE.MeshBasicMaterial({ color: 0xFFD166 }); const s = new THREE.Mesh(sGeo, sMat); s.position.copy(pt.pos); this._pointGroup.add(s); // Label const label = this._makeTextSprite(pt.label, '#FFD166', 40); label.position.copy(pt.pos).add(new THREE.Vector3(0.2, 0.2, 0)); label.scale.set(0.6, 0.3, 1); this._pointGroup.add(label); } // Draw connections for (const conn of this._connections) { const posA = this._getPointPos(conn.from); const posB = this._getPointPos(conn.to); if (!posA || !posB) continue; const lineGeo = new THREE.BufferGeometry().setFromPoints([posA, posB]); const lineMat = new THREE.LineDashedMaterial({ color: 0xF59E0B, dashSize: 0.12, gapSize: 0.06, transparent: true, opacity: 0.9, }); const line = new THREE.Line(lineGeo, lineMat); line.computeLineDistances(); this._pointGroup.add(line); // Distance label const dist = posA.distanceTo(posB); const mid = new THREE.Vector3().addVectors(posA, posB).multiplyScalar(0.5); const lbl = this._makeTextSprite(dist.toFixed(2), '#F59E0B', 36); lbl.position.copy(mid).add(new THREE.Vector3(0, 0.25, 0)); lbl.scale.set(0.8, 0.4, 1); this._pointGroup.add(lbl); } } /* ════════════════ HEIGHT LINE ════════════════ */ _drawHeightLine() { if (!this.showHeight) return; const p = this.params; const fh = this._figureHeight(); let from, to, label; switch (this.figureType) { case 'cube': case 'parallelepiped': case 'prism': case 'cylinder': { // vertical height: center of bottom base center of top base from = new THREE.Vector3(0, 0, 0); to = new THREE.Vector3(0, fh, 0); label = `h = ${fh.toFixed(2)}`; break; } case 'pyramid': { // apex to base center from = new THREE.Vector3(0, 0, 0); to = new THREE.Vector3(0, p.h, 0); label = `h = ${p.h.toFixed(2)}`; break; } case 'tetrahedron': { const h = p.a * Math.sqrt(2 / 3); from = new THREE.Vector3(0, 0, 0); to = new THREE.Vector3(0, h, 0); label = `h = ${h.toFixed(2)}`; break; } case 'cone': { from = new THREE.Vector3(0, 0, 0); to = new THREE.Vector3(0, p.h, 0); label = `h = ${p.h.toFixed(2)}`; break; } case 'trunccone': { from = new THREE.Vector3(0, 0, 0); to = new THREE.Vector3(0, p.h, 0); label = `h = ${p.h.toFixed(2)}`; break; } case 'sphere': { from = new THREE.Vector3(0, 0, 0); to = new THREE.Vector3(0, 2 * p.r, 0); label = `d = ${(2 * p.r).toFixed(2)}`; break; } default: return; } // dashed line const lineGeo = new THREE.BufferGeometry().setFromPoints([from, to]); const lineMat = new THREE.LineDashedMaterial({ color: 0xF9A8D4, dashSize: 0.15, gapSize: 0.08, transparent: true, opacity: 0.85, }); const line = new THREE.Line(lineGeo, lineMat); line.computeLineDistances(); this._figGroup.add(line); // small dots at endpoints for (const pt of [from, to]) { const sGeo = new THREE.SphereGeometry(0.06, 8, 8); const sMat = new THREE.MeshBasicMaterial({ color: 0xF9A8D4 }); const s = new THREE.Mesh(sGeo, sMat); s.position.copy(pt); this._figGroup.add(s); } // label at midpoint const mid = new THREE.Vector3().addVectors(from, to).multiplyScalar(0.5); const lbl = this._makeTextSprite(label, '#F9A8D4', 36); lbl.position.copy(mid).add(new THREE.Vector3(0.4, 0, 0)); lbl.scale.set(1.2, 0.4, 1); this._labelGroup.add(lbl); // right-angle marker at base (for pyramid/cone/tetrahedron) if (['pyramid', 'tetrahedron', 'cone'].includes(this.figureType)) { this._drawRightAngleMarker(from, new THREE.Vector3(0, 1, 0), new THREE.Vector3(1, 0, 0), 0.3); } } _drawRightAngleMarker(origin, dir1, dir2, size) { const p1 = origin.clone().add(dir1.clone().normalize().multiplyScalar(size)); const p2 = origin.clone().add(dir2.clone().normalize().multiplyScalar(size)); const p3 = p1.clone().add(dir2.clone().normalize().multiplyScalar(size)); const pts = [p1, p3, p2]; const geo = new THREE.BufferGeometry().setFromPoints(pts); const mat = new THREE.LineBasicMaterial({ color: 0xF9A8D4, transparent: true, opacity: 0.6 }); this._figGroup.add(new THREE.Line(geo, mat)); } /* ════════════════ APOTHEM LINE ════════════════ */ _drawApothemLine() { if (!this.showApothem) return; const p = this.params; const PI = Math.PI; if (this.figureType === 'pyramid') { const { a, h, n } = p; const apothem = a / (2 * Math.tan(PI / n)); // base apothem const slantH = Math.sqrt(h ** 2 + apothem ** 2); // slant apothem // Base apothem: center of base to midpoint of first base edge const midEdge = this._getBaseMidpoint(n, a, 0); this._drawDashedSegment( new THREE.Vector3(0, 0, 0), midEdge, `a_осн = ${apothem.toFixed(2)}`, '#7BF5A4' ); // Slant apothem: apex to midpoint of first base edge this._drawDashedSegment( new THREE.Vector3(0, h, 0), midEdge, `a_бок = ${slantH.toFixed(2)}`, '#60a5fa' ); // Right angle at midEdge (between base apothem and base edge direction) const edgeDir = this._getBaseEdgeDir(n, a, 0); const toCenter = new THREE.Vector3().subVectors(new THREE.Vector3(0, 0, 0), midEdge).normalize(); this._drawRightAngleMarker(midEdge, toCenter, edgeDir, 0.25); } else if (this.figureType === 'prism') { const { a, h, n } = p; const apothem = a / (2 * Math.tan(PI / n)); // Base apothem const midEdge = this._getBaseMidpoint(n, a, 0); this._drawDashedSegment( new THREE.Vector3(0, 0, 0), midEdge, `a = ${apothem.toFixed(2)}`, '#7BF5A4' ); // Right angle marker const edgeDir = this._getBaseEdgeDir(n, a, 0); const toCenter = new THREE.Vector3().subVectors(new THREE.Vector3(0, 0, 0), midEdge).normalize(); this._drawRightAngleMarker(midEdge, toCenter, edgeDir, 0.25); } else if (this.figureType === 'cone') { // Slant height (образующая) const l = Math.sqrt(p.r ** 2 + p.h ** 2); this._drawDashedSegment( new THREE.Vector3(0, p.h, 0), new THREE.Vector3(p.r, 0, 0), `l = ${l.toFixed(2)}`, '#60a5fa' ); } else if (this.figureType === 'trunccone') { const l = Math.sqrt((p.R - p.r) ** 2 + p.h ** 2); this._drawDashedSegment( new THREE.Vector3(p.r, p.h, 0), new THREE.Vector3(p.R, 0, 0), `l = ${l.toFixed(2)}`, '#60a5fa' ); } } _getBaseMidpoint(n, a, edgeIndex) { const r = a / (2 * Math.sin(Math.PI / n)); const a1 = (edgeIndex / n) * Math.PI * 2 - Math.PI / 2; const a2 = ((edgeIndex + 1) / n) * Math.PI * 2 - Math.PI / 2; return new THREE.Vector3( (r * Math.cos(a1) + r * Math.cos(a2)) / 2, 0, (r * Math.sin(a1) + r * Math.sin(a2)) / 2 ); } _getBaseEdgeDir(n, a, edgeIndex) { const r = a / (2 * Math.sin(Math.PI / n)); const a1 = (edgeIndex / n) * Math.PI * 2 - Math.PI / 2; const a2 = ((edgeIndex + 1) / n) * Math.PI * 2 - Math.PI / 2; const p1 = new THREE.Vector3(r * Math.cos(a1), 0, r * Math.sin(a1)); const p2 = new THREE.Vector3(r * Math.cos(a2), 0, r * Math.sin(a2)); return new THREE.Vector3().subVectors(p2, p1).normalize(); } _drawDashedSegment(from, to, label, color) { const lineGeo = new THREE.BufferGeometry().setFromPoints([from, to]); const lineMat = new THREE.LineDashedMaterial({ color: new THREE.Color(color), dashSize: 0.12, gapSize: 0.06, transparent: true, opacity: 0.85, }); const line = new THREE.Line(lineGeo, lineMat); line.computeLineDistances(); this._figGroup.add(line); // dots for (const pt of [from, to]) { const sGeo = new THREE.SphereGeometry(0.05, 8, 8); const sMat = new THREE.MeshBasicMaterial({ color: new THREE.Color(color) }); const s = new THREE.Mesh(sGeo, sMat); s.position.copy(pt); this._figGroup.add(s); } // label const mid = new THREE.Vector3().addVectors(from, to).multiplyScalar(0.5); const lbl = this._makeTextSprite(label, color, 34); lbl.position.copy(mid).add(new THREE.Vector3(0.3, 0.2, 0)); lbl.scale.set(1.2, 0.4, 1); this._labelGroup.add(lbl); } /* ════════════════ ANGLE MEASUREMENT MODES ════════════════ */ _pickNearestPoint(e) { const { mx, my } = this._screenCoords(e); let bestDist = 0.08; let bestPick = null; for (const v of this._vertices) { const projected = v.pos.clone().project(this.camera); const d = Math.sqrt((projected.x - mx) ** 2 + (projected.y - my) ** 2); if (d < bestDist) { bestDist = d; bestPick = { pos: v.pos.clone(), label: v.label }; } } for (const cp of this._customPoints) { const projected = cp.pos.clone().project(this.camera); const d = Math.sqrt((projected.x - mx) ** 2 + (projected.y - my) ** 2); if (d < bestDist) { bestDist = d; bestPick = { pos: cp.pos.clone(), label: cp.label }; } } return bestPick; } _pickNearestFace(e) { // Pick the face whose projected centroid is closest to click const { mx, my } = this._screenCoords(e); let bestDist = 0.15; let bestFace = null; let bestIdx = -1; for (let fi = 0; fi < this._faces.length; fi++) { const face = this._faces[fi]; const c = new THREE.Vector3(); face.forEach(v => c.add(v)); c.divideScalar(face.length); const proj = c.clone().project(this.camera); const d = Math.sqrt((proj.x - mx) ** 2 + (proj.y - my) ** 2); if (d < bestDist) { bestDist = d; bestFace = face; bestIdx = fi; } } return bestFace; } _pickNearestEdge(e) { const { mx, my } = this._screenCoords(e); let bestDist = 0.06; let bestEdge = null; for (const edge of this._edges) { const p1 = edge.from.clone().project(this.camera); const p2 = edge.to.clone().project(this.camera); const mid = { x: (p1.x + p2.x) / 2, y: (p1.y + p2.y) / 2 }; const d = Math.sqrt((mid.x - mx) ** 2 + (mid.y - my) ** 2); if (d < bestDist) { bestDist = d; bestEdge = edge; } } return bestEdge; } _highlightPick(pos, color = 0xFFD166) { const sGeo = new THREE.SphereGeometry(0.12, 10, 10); const sMat = new THREE.MeshBasicMaterial({ color }); const s = new THREE.Mesh(sGeo, sMat); s.position.copy(pos); this._angleGroup.add(s); } _onAngleClick(e) { if (!this._angleMode) return; if (this._angleMode === 'edge') { this._onEdgeAngleClick(e); } else if (this._angleMode === 'linePlane') { this._onLinePlaneAngleClick(e); } else if (this._angleMode === 'dihedral') { this._onDihedralAngleClick(e); } else if (this._angleMode === 'pointPlane') { this._onPointPlaneClick(e); } else if (this._angleMode === 'skewLines') { this._onSkewLinesClick(e); } } /* ── Skew lines: pick P1, P2 (line 1) then P3, P4 (line 2) ── */ _onSkewLinesClick(e) { const pick = this._pickNearestPoint(e); if (!pick) return; const step = this._anglePicks.length; this._anglePicks.push(pick); const colors = [0xFFD166, 0xFFD166, 0x06D6E0, 0x06D6E0]; this._highlightPick(pick.pos, colors[step] || 0xffffff); // After 2 picks: draw line 1 if (this._anglePicks.length === 2) { const [P1, P2] = this._anglePicks; this._drawAngleLine(P1.pos, P2.pos, '#FFD166'); } if (this._anglePicks.length < 4) return; const [P1, P2, P3, P4] = this._anglePicks; const d1 = new THREE.Vector3().subVectors(P2.pos, P1.pos); const d2 = new THREE.Vector3().subVectors(P4.pos, P3.pos); // Draw line 2 this._drawAngleLine(P3.pos, P4.pos, '#06D6E0'); // Angle between lines (acute) const cosA = Math.abs(d1.dot(d2)) / (d1.length() * d2.length()); const angleDeg = Math.acos(Math.min(1, cosA)) * 180 / Math.PI; // Common perpendicular (distance between skew lines) const normal = new THREE.Vector3().crossVectors(d1, d2); let dist = 0; if (normal.length() > 1e-9) { // Lines are skew → project onto normal dist = Math.abs(new THREE.Vector3().subVectors(P3.pos, P1.pos).dot(normal.clone().normalize())); // Find foot points of common perpendicular const w = new THREE.Vector3().subVectors(P1.pos, P3.pos); const b1 = d1.dot(d1), b2 = d2.dot(d2), d12 = d1.dot(d2); const det = b1 * b2 - d12 * d12; if (Math.abs(det) > 1e-9) { const t1 = (d12 * d2.dot(w) - b2 * d1.dot(w)) / det; const t2 = (b1 * d2.dot(w) - d12 * d1.dot(w)) / det; const Q1 = P1.pos.clone().addScaledVector(d1, t1); const Q2 = P3.pos.clone().addScaledVector(d2, t2); this._drawAngleLine(Q1, Q2, '#F9A8D4', true); this._highlightPick(Q1, 0xF9A8D4); this._highlightPick(Q2, 0xF9A8D4); } } else { // Parallel lines const cross = new THREE.Vector3().crossVectors(d1, new THREE.Vector3().subVectors(P3.pos, P1.pos)); dist = cross.length() / d1.length(); } // Label near midpoint between the two lines const mid = new THREE.Vector3().addVectors(P1.pos, P3.pos).multiplyScalar(0.5); mid.y += 0.5; const lbl = this._makeTextSprite( `∠ = ${angleDeg.toFixed(1)}° d = ${dist.toFixed(2)}`, '#F9A8D4', 36 ); lbl.position.copy(mid); lbl.scale.set(2.8, 0.55, 1); this._angleGroup.add(lbl); this._anglePicks = []; } /* ── Edge angle: pick 3 points (B is vertex, angle ∠ABC) ── */ _onEdgeAngleClick(e) { const pick = this._pickNearestPoint(e); if (!pick) return; this._anglePicks.push(pick); this._highlightPick(pick.pos, this._anglePicks.length === 2 ? 0xEF476F : 0xFFD166); if (this._anglePicks.length === 3) { const [A, B, C] = this._anglePicks; const BA = new THREE.Vector3().subVectors(A.pos, B.pos); const BC = new THREE.Vector3().subVectors(C.pos, B.pos); const cosAngle = BA.dot(BC) / (BA.length() * BC.length()); const angle = Math.acos(Math.max(-1, Math.min(1, cosAngle))) * 180 / Math.PI; // Draw the angle arc this._drawAngleArc(B.pos, BA, BC, angle, 0.6, '#EF476F'); // Label const bisect = new THREE.Vector3().addVectors( BA.clone().normalize(), BC.clone().normalize() ).normalize().multiplyScalar(0.8); const lblPos = B.pos.clone().add(bisect); const lbl = this._makeTextSprite( `∠${A.label}${B.label}${C.label} = ${angle.toFixed(1)}°`, '#EF476F', 36 ); lbl.position.copy(lblPos); lbl.scale.set(2.0, 0.5, 1); this._angleGroup.add(lbl); // Lines this._drawAngleLine(B.pos, A.pos, '#EF476F'); this._drawAngleLine(B.pos, C.pos, '#EF476F'); this._anglePicks = []; } } /* ── Line-Plane angle: pick 2 points (line), then 1 face ── */ _onLinePlaneAngleClick(e) { if (this._anglePicks.length < 2) { // Picking line endpoints const pick = this._pickNearestPoint(e); if (!pick) return; this._anglePicks.push(pick); this._highlightPick(pick.pos); if (this._anglePicks.length === 2) { // Draw the line this._drawAngleLine(this._anglePicks[0].pos, this._anglePicks[1].pos, '#60a5fa'); } } else { // Pick a face const face = this._pickNearestFace(e); if (!face || face.length < 3) return; const [A, B] = this._anglePicks; const lineDir = new THREE.Vector3().subVectors(B.pos, A.pos).normalize(); // Face normal const v1 = new THREE.Vector3().subVectors(face[1], face[0]); const v2 = new THREE.Vector3().subVectors(face[2], face[0]); const normal = new THREE.Vector3().crossVectors(v1, v2).normalize(); // Angle between line and plane = 90° - angle between line and normal const sinAngle = Math.abs(lineDir.dot(normal)); const angle = Math.asin(Math.max(0, Math.min(1, sinAngle))) * 180 / Math.PI; // Highlight face const positions = []; const indices = []; face.forEach(v => positions.push(v.x, v.y, v.z)); for (let i = 1; i < face.length - 1; i++) indices.push(0, i, i + 1); const geo = new THREE.BufferGeometry(); geo.setAttribute('position', new THREE.Float32BufferAttribute(positions, 3)); geo.setIndex(indices); const mat = new THREE.MeshBasicMaterial({ color: 0x60a5fa, transparent: true, opacity: 0.2, side: THREE.DoubleSide }); this._angleGroup.add(new THREE.Mesh(geo, mat)); // Label at face centroid const c = new THREE.Vector3(); face.forEach(v => c.add(v)); c.divideScalar(face.length); const lbl = this._makeTextSprite( `∠(${A.label}${B.label}, пл) = ${angle.toFixed(1)}°`, '#60a5fa', 36 ); lbl.position.copy(c).add(new THREE.Vector3(0, 0.5, 0)); lbl.scale.set(2.4, 0.5, 1); this._angleGroup.add(lbl); // Draw projection of line onto plane const proj = lineDir.clone().sub(normal.clone().multiplyScalar(lineDir.dot(normal))).normalize(); const projEnd = A.pos.clone().add(proj.multiplyScalar(A.pos.distanceTo(B.pos))); this._drawAngleLine(A.pos, projEnd, '#60a5fa', true); this._anglePicks = []; } } /* ── Dihedral angle: pick 2 points of shared edge, then auto-find adjacent faces ── */ _onDihedralAngleClick(e) { const pick = this._pickNearestPoint(e); if (!pick) return; this._anglePicks.push(pick); this._highlightPick(pick.pos, 0xc4b5fd); if (this._anglePicks.length === 2) { const [P1, P2] = this._anglePicks; const edgeDir = new THREE.Vector3().subVectors(P2.pos, P1.pos); // Find two faces sharing this edge (both contain P1 and P2) const adjFaces = []; const eps = 0.1; for (const face of this._faces) { let hasP1 = false, hasP2 = false; for (const v of face) { if (v.distanceTo(P1.pos) < eps) hasP1 = true; if (v.distanceTo(P2.pos) < eps) hasP2 = true; } if (hasP1 && hasP2) adjFaces.push(face); } if (adjFaces.length >= 2) { // Compute normals of both faces const normal1 = this._faceNormal(adjFaces[0]); const normal2 = this._faceNormal(adjFaces[1]); // Dihedral angle = π - angle between outward normals const cosAngle = normal1.dot(normal2); const rawAngle = Math.acos(Math.max(-1, Math.min(1, cosAngle))) * 180 / Math.PI; // Dihedral is the interior angle const dihedralAngle = 180 - rawAngle; // Highlight both faces for (const face of [adjFaces[0], adjFaces[1]]) { const positions = []; const indices = []; face.forEach(v => positions.push(v.x, v.y, v.z)); for (let i = 1; i < face.length - 1; i++) indices.push(0, i, i + 1); const geo = new THREE.BufferGeometry(); geo.setAttribute('position', new THREE.Float32BufferAttribute(positions, 3)); geo.setIndex(indices); const mat = new THREE.MeshBasicMaterial({ color: 0xc4b5fd, transparent: true, opacity: 0.2, side: THREE.DoubleSide }); this._angleGroup.add(new THREE.Mesh(geo, mat)); } // Label at edge midpoint const mid = new THREE.Vector3().addVectors(P1.pos, P2.pos).multiplyScalar(0.5); const lbl = this._makeTextSprite( `∠дв(${P1.label}${P2.label}) = ${dihedralAngle.toFixed(1)}°`, '#c4b5fd', 36 ); lbl.position.copy(mid).add(new THREE.Vector3(0, 0.5, 0.3)); lbl.scale.set(2.4, 0.5, 1); this._angleGroup.add(lbl); // Draw the edge this._drawAngleLine(P1.pos, P2.pos, '#c4b5fd'); } else { // Not enough adjacent faces — show error sprite const mid = new THREE.Vector3().addVectors(P1.pos, P2.pos).multiplyScalar(0.5); const lbl = this._makeTextSprite('Нет общего ребра', '#ff6b6b', 36); lbl.position.copy(mid).add(new THREE.Vector3(0, 0.5, 0)); lbl.scale.set(2.0, 0.5, 1); this._angleGroup.add(lbl); } this._anglePicks = []; } } _faceNormal(face) { if (face.length < 3) return new THREE.Vector3(0, 1, 0); const v1 = new THREE.Vector3().subVectors(face[1], face[0]); const v2 = new THREE.Vector3().subVectors(face[2], face[0]); return new THREE.Vector3().crossVectors(v1, v2).normalize(); } _drawAngleArc(center, dir1, dir2, angleDeg, radius, color) { const n1 = dir1.clone().normalize(); const n2 = dir2.clone().normalize(); const angleRad = angleDeg * Math.PI / 180; const steps = Math.max(8, Math.round(angleDeg / 5)); const pts = []; // Build rotation from n1 toward n2 const axis = new THREE.Vector3().crossVectors(n1, n2).normalize(); if (axis.length() < 0.001) return; // parallel vectors for (let i = 0; i <= steps; i++) { const t = i / steps; const a = t * angleRad; const rotated = n1.clone().applyAxisAngle(axis, a).multiplyScalar(radius); pts.push(center.clone().add(rotated)); } const geo = new THREE.BufferGeometry().setFromPoints(pts); const mat = new THREE.LineBasicMaterial({ color: new THREE.Color(color), transparent: true, opacity: 0.8 }); this._angleGroup.add(new THREE.Line(geo, mat)); } _drawAngleLine(from, to, color, dashed = false) { const geo = new THREE.BufferGeometry().setFromPoints([from, to]); let mat; if (dashed) { mat = new THREE.LineDashedMaterial({ color: new THREE.Color(color), dashSize: 0.12, gapSize: 0.06, transparent: true, opacity: 0.7, }); } else { mat = new THREE.LineBasicMaterial({ color: new THREE.Color(color), transparent: true, opacity: 0.7 }); } const line = new THREE.Line(geo, mat); if (dashed) line.computeLineDistances(); this._angleGroup.add(line); } /* ════════════════ DIAGONALS ════════════════ */ _drawDiagonals() { if (!this.showDiagonals) return; const t = this.figureType; if (t === 'cube' || t === 'parallelepiped') { // 8 vertices: 0-3 bottom, 4-7 top (from _buildBox) const v = this._vertices.map(vt => vt.pos); if (v.length < 8) return; // Space diagonals (4) const spaceDiags = [[0,6],[1,7],[2,4],[3,5]]; for (const [a, b] of spaceDiags) { this._drawDashedSegment(v[a], v[b], '', '#fbbf24'); } // Face diagonals (12) — 2 per face const faceDiags = [ [0,2],[1,3], // bottom [4,6],[5,7], // top [0,5],[1,4], // front [2,7],[3,6], // back [1,6],[2,5], // right [0,7],[3,4], // left ]; for (const [a, b] of faceDiags) { const lineGeo = new THREE.BufferGeometry().setFromPoints([v[a], v[b]]); const lineMat = new THREE.LineDashedMaterial({ color: 0xF59E0B, dashSize: 0.1, gapSize: 0.06, transparent: true, opacity: 0.35, }); const line = new THREE.Line(lineGeo, lineMat); line.computeLineDistances(); this._figGroup.add(line); } // Space diagonal label — only one, the longest const d = v[0].distanceTo(v[6]); const mid = new THREE.Vector3().addVectors(v[0], v[6]).multiplyScalar(0.5); const lbl = this._makeTextSprite(`d = ${d.toFixed(2)}`, '#fbbf24', 34); lbl.position.copy(mid).add(new THREE.Vector3(0.3, 0.2, 0)); lbl.scale.set(1.2, 0.4, 1); this._labelGroup.add(lbl); } else if (t === 'prism') { const n = this.params.n; const v = this._vertices.map(vt => vt.pos); if (v.length < n * 2) return; // Base diagonals (bottom) for (let i = 0; i < n; i++) { for (let j = i + 2; j < n; j++) { if (j === (i + n - 1) % n + i) continue; // skip adjacent if (i === 0 && j === n - 1) continue; const lineGeo = new THREE.BufferGeometry().setFromPoints([v[i], v[j]]); const lineMat = new THREE.LineDashedMaterial({ color: 0xF59E0B, dashSize: 0.1, gapSize: 0.06, transparent: true, opacity: 0.35, }); const line = new THREE.Line(lineGeo, lineMat); line.computeLineDistances(); this._figGroup.add(line); } } // Top diagonals for (let i = n; i < 2 * n; i++) { for (let j = i + 2; j < 2 * n; j++) { if (i === n && j === 2 * n - 1) continue; const lineGeo = new THREE.BufferGeometry().setFromPoints([v[i], v[j]]); const lineMat = new THREE.LineDashedMaterial({ color: 0xF59E0B, dashSize: 0.1, gapSize: 0.06, transparent: true, opacity: 0.35, }); const line = new THREE.Line(lineGeo, lineMat); line.computeLineDistances(); this._figGroup.add(line); } } // Space diagonals: connect bottom[i] to top[(i+k)%n] for k≠0 for (let i = 0; i < n; i++) { for (let k = 1; k < n; k++) { const j = n + (i + k) % n; this._drawDashedSegment(v[i], v[j], '', '#fbbf24'); } } } else if (t === 'pyramid') { // Base diagonals only const n = this.params.n; const v = this._vertices.map(vt => vt.pos); for (let i = 0; i < n; i++) { for (let j = i + 2; j < n; j++) { if (i === 0 && j === n - 1) continue; const lineGeo = new THREE.BufferGeometry().setFromPoints([v[i], v[j]]); const lineMat = new THREE.LineDashedMaterial({ color: 0xF59E0B, dashSize: 0.1, gapSize: 0.06, transparent: true, opacity: 0.4, }); const line = new THREE.Line(lineGeo, lineMat); line.computeLineDistances(); this._figGroup.add(line); } } } } /* ════════════════ MIDPOINTS ════════════════ */ _drawMidpoints() { if (!this.showMidpoints) return; for (let i = 0; i < this._edges.length; i++) { const e = this._edges[i]; const mid = new THREE.Vector3().addVectors(e.from, e.to).multiplyScalar(0.5); // small cyan sphere const sGeo = new THREE.SphereGeometry(0.06, 8, 8); const sMat = new THREE.MeshBasicMaterial({ color: 0x06D6E0 }); const s = new THREE.Mesh(sGeo, sMat); s.position.copy(mid); this._figGroup.add(s); // label "M" + index const lbl = this._makeTextSprite(`M${i + 1}`, '#06D6E0', 28); lbl.position.copy(mid).add(new THREE.Vector3(0.15, 0.15, 0)); lbl.scale.set(0.5, 0.25, 1); this._labelGroup.add(lbl); } } /* ════════════════ POINT-TO-PLANE DISTANCE ════════════════ */ _onPointPlaneClick(e) { if (this._anglePicks.length < 1) { // First pick: a vertex/point const pick = this._pickNearestPoint(e); if (!pick) return; this._anglePicks.push(pick); this._highlightPick(pick.pos, 0xF9A8D4); } else { // Second pick: a face const face = this._pickNearestFace(e); if (!face || face.length < 3) return; const pt = this._anglePicks[0]; const normal = this._faceNormal(face); const planePoint = face[0]; // Distance = |dot(pt - planePoint, normal)| const diff = new THREE.Vector3().subVectors(pt.pos, planePoint); const dist = Math.abs(diff.dot(normal)); // Projection of point onto plane const proj = pt.pos.clone().sub(normal.clone().multiplyScalar(diff.dot(normal))); // Draw perpendicular line this._drawAngleLine(pt.pos, proj, '#F9A8D4', true); // Highlight foot of perpendicular this._highlightPick(proj, 0xF9A8D4); // Right angle marker const edgeOnPlane = new THREE.Vector3().subVectors(face[1], face[0]).normalize(); this._drawRightAngleMarkerAt(proj, normal, edgeOnPlane, 0.25, '#F9A8D4'); // Highlight face const positions = []; const indices = []; face.forEach(v => positions.push(v.x, v.y, v.z)); for (let i = 1; i < face.length - 1; i++) indices.push(0, i, i + 1); const geo = new THREE.BufferGeometry(); geo.setAttribute('position', new THREE.Float32BufferAttribute(positions, 3)); geo.setIndex(indices); const mat = new THREE.MeshBasicMaterial({ color: 0xF9A8D4, transparent: true, opacity: 0.15, side: THREE.DoubleSide }); this._angleGroup.add(new THREE.Mesh(geo, mat)); // Label const mid = new THREE.Vector3().addVectors(pt.pos, proj).multiplyScalar(0.5); const lbl = this._makeTextSprite(`d(${pt.label}, пл) = ${dist.toFixed(2)}`, '#F9A8D4', 36); lbl.position.copy(mid).add(new THREE.Vector3(0.4, 0.2, 0)); lbl.scale.set(2.2, 0.5, 1); this._angleGroup.add(lbl); this._anglePicks = []; } } _drawRightAngleMarkerAt(origin, normalDir, tangentDir, size, color) { const d1 = normalDir.clone().normalize().multiplyScalar(size); const d2 = tangentDir.clone().normalize().multiplyScalar(size); const p1 = origin.clone().add(d1); const p2 = origin.clone().add(d2); const p3 = p1.clone().add(d2); const pts = [p1, p3, p2]; const geo = new THREE.BufferGeometry().setFromPoints(pts); const mat = new THREE.LineBasicMaterial({ color: new THREE.Color(color), transparent: true, opacity: 0.6 }); this._angleGroup.add(new THREE.Line(geo, mat)); } /* ════════════════ COORDINATE TOOLTIP ════════════════ */ _initTooltip() { this._tooltipEl = document.createElement('div'); Object.assign(this._tooltipEl.style, { position: 'absolute', pointerEvents: 'none', background: 'rgba(13,13,26,0.85)', color: '#ccc', fontSize: '11px', fontFamily: 'Manrope, monospace', padding: '3px 7px', borderRadius: '4px', border: '1px solid rgba(155,93,229,0.3)', display: 'none', zIndex: '50', whiteSpace: 'nowrap', }); this.container.style.position = 'relative'; this.container.appendChild(this._tooltipEl); } _onHoverMove(e) { const rect = this.renderer.domElement.getBoundingClientRect(); if (e.clientX < rect.left || e.clientX > rect.right || e.clientY < rect.top || e.clientY > rect.bottom) { if (this._tooltipEl) this._tooltipEl.style.display = 'none'; return; } const mx = ((e.clientX - rect.left) / rect.width) * 2 - 1; const my = -((e.clientY - rect.top) / rect.height) * 2 + 1; let bestDist = 0.06; let bestV = null; for (const v of this._vertices) { const proj = v.pos.clone().project(this.camera); const d = Math.sqrt((proj.x - mx) ** 2 + (proj.y - my) ** 2); if (d < bestDist) { bestDist = d; bestV = v; } } for (const cp of this._customPoints) { const proj = cp.pos.clone().project(this.camera); const d = Math.sqrt((proj.x - mx) ** 2 + (proj.y - my) ** 2); if (d < bestDist) { bestDist = d; bestV = cp; } } if (bestV && this._tooltipEl) { const p = bestV.pos; this._tooltipEl.textContent = `${bestV.label} (${p.x.toFixed(1)}, ${p.y.toFixed(1)}, ${p.z.toFixed(1)})`; this._tooltipEl.style.display = 'block'; this._tooltipEl.style.left = (e.clientX - rect.left + 14) + 'px'; this._tooltipEl.style.top = (e.clientY - rect.top - 10) + 'px'; } else if (this._tooltipEl) { this._tooltipEl.style.display = 'none'; } } /* ════════════════ UNFOLD ANIMATION ════════════════ */ // Simplified unfold: flatten figure by reducing Y coordinates _applyUnfold(progress) { // This is a visual-only effect — squash Y toward 0 if (progress < 0.01) return; this._figGroup.children.forEach(child => { if (child.geometry) { // Already built, don't modify geometry — just scale Y } }); this._figGroup.scale.y = 1 - progress * 0.85; this._figGroup.position.y = progress * 0.5; } /* ════════════════ UTILS ════════════════ */ _clearGroup(group) { while (group.children.length) { const c = group.children[0]; if (c.geometry) c.geometry.dispose(); if (c.material) { if (c.material.map) c.material.map.dispose(); c.material.dispose(); } group.remove(c); } } /* ════════════════ 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()); } /* ════════════════ ANIMATION LOOP ════════════════ */ _loop() { if (!this._running) return; requestAnimationFrame(() => this._loop()); // Auto-spin after idle this._idleTime++; if (this._idleTime > 300 && !this._drag) this._autoSpin = true; if (this._autoSpin) this._rotY += 0.002; // Unfold animation if (this._unfold && this._unfoldProgress < this._unfoldTarget) { this._unfoldProgress = Math.min(1, this._unfoldProgress + 0.015); this._applyUnfold(this._unfoldProgress); } else if (!this._unfold && this._unfoldProgress > 0) { this._unfoldProgress = Math.max(0, this._unfoldProgress - 0.015); this._applyUnfold(this._unfoldProgress); if (this._unfoldProgress <= 0) { this._figGroup.scale.y = 1; this._figGroup.position.y = 0; } } // Camera orbit this.camera.position.set( this._dist * Math.sin(this._rotY) * Math.cos(this._rotX), this._dist * Math.sin(this._rotX), this._dist * Math.cos(this._rotY) * Math.cos(this._rotX) ); this.camera.lookAt(0, this._figureHeight() / 2, 0); this.renderer.render(this.scene, this.camera); } } /* ─── lab UI init ─────────────────────────────────── */ var stereoSim = null; // which params are relevant per figure type const STEREO_PARAM_MAP = { cube: ['a'], parallelepiped: ['a','b','c'], pyramid: ['a','n','h'], tetrahedron: ['a'], cylinder: ['r','h'], cone: ['r','h'], trunccone: ['R','r','h'], sphere: ['r'], prism: ['a','n','h'], truncpyramid: ['a','b','n','h'], octahedron: ['a'], icosahedron: ['a'], dodecahedron: ['a'], }; function _openStereo() { document.getElementById('sim-topbar-title').textContent = 'Стереометрия 3D'; _simShow('sim-stereo'); document.getElementById('stereo-stats').style.display = ''; requestAnimationFrame(() => requestAnimationFrame(() => { if (!stereoSim) { stereoSim = new StereoSim(document.getElementById('stereo-container')); stereoSim.onUpdate = _stereoUpdateUI; } else { stereoSim.fit(); stereoSim.play(); } _stereoShowParams(stereoSim.figureType || 'cube'); _stereoUpdateUI(stereoSim.info()); _stereoUpdateFormulas(); })); } function setStereoFigure(type, btn) { document.querySelectorAll('.stereo-fig-btn').forEach(b => b.classList.remove('active')); if (btn) btn.classList.add('active'); if (stereoSim) { stereoSim.setFigure(type); _stereoShowParams(type); _stereoUpdateFormulas(); if (window.LabFX) LabFX.sound.play('whoosh', { pitch: 1.2, volume: 0.3 }); // reset toggles and tool buttons document.getElementById('sect-toggle').classList.remove('active'); document.getElementById('stereo-unfold-btn').classList.remove('active'); document.getElementById('stereo-measure-btn').classList.remove('active'); // reset element toggles ['stg-height','stg-apothem','stg-diagonals','stg-midpoints','stg-inscribed','stg-circumscribed','stg-edgelengths'].forEach(id => { document.getElementById(id)?.classList.remove('on'); }); _stereoDeactivateTools(); } } function _stereoShowParams(type) { const show = STEREO_PARAM_MAP[type] || ['a']; ['a','b','c','h','r','R','n'].forEach(k => { document.getElementById('sp-' + k + '-row').style.display = show.includes(k) ? '' : 'none'; }); } function stereoParamChange(key, val) { val = +val; const label = document.getElementById('sp-' + key + '-val'); if (label) label.textContent = val; if (stereoSim) { stereoSim.setParam(key, val); _stereoUpdateFormulas(); } } function stereoOpacityChange(val) { val = +val; document.getElementById('sp-opacity-val').textContent = val.toFixed(2); if (stereoSim) stereoSim.setOpacity(val); } // legacy (used nowhere now but kept for safety) function stereoToggle(layer, btn) { const on = !btn.classList.contains('active'); btn.classList.toggle('active', on); if (!stereoSim) return; if (layer === 'edges') stereoSim.toggleEdges(on); if (layer === 'vertices') stereoSim.toggleVertices(on); if (layer === 'labels') stereoSim.toggleLabels(on); if (layer === 'axes') stereoSim.toggleAxes(on); if (layer === 'grid') stereoSim.toggleGrid(on); } // new toggle-row style function stereoToggleSt(layer, toggle) { const on = !toggle.classList.contains('on'); toggle.classList.toggle('on', on); if (!stereoSim) return; if (layer === 'edges') stereoSim.toggleEdges(on); if (layer === 'vertices') stereoSim.toggleVertices(on); if (layer === 'labels') stereoSim.toggleLabels(on); if (layer === 'axes') stereoSim.toggleAxes(on); if (layer === 'grid') stereoSim.toggleGrid(on); } function stereoToggleElem(layer, toggle) { const on = !toggle.classList.contains('on'); toggle.classList.toggle('on', on); if (!stereoSim) return; if (layer === 'height') stereoSim.toggleHeight(on); if (layer === 'apothem') stereoSim.toggleApothem(on); if (layer === 'diagonals') stereoSim.toggleDiagonals(on); if (layer === 'midpoints') stereoSim.toggleMidpoints(on); if (layer === 'inscribed') stereoSim.toggleInscribed(on); if (layer === 'circumscribed') stereoSim.toggleCircumscribed(on); if (layer === 'edgelengths') stereoSim.toggleEdgeLengths(on); } // n-stepper for prism/pyramid function stereoNChange(delta) { if (!stereoSim) return; const cur = stereoSim.params.n || 4; const nv = Math.max(3, Math.min(12, cur + delta)); document.getElementById('sp-n-val').textContent = nv; stereoSim.setParam('n', nv); _stereoUpdateFormulas(); } function stereoSectionToggle(btn) { const on = !btn.classList.contains('active'); btn.classList.toggle('active', on); if (stereoSim) stereoSim.toggleSection(on); } function stereoSectionType(t, btn) { document.querySelectorAll('.stereo-sect-type').forEach(b => b.classList.remove('active')); btn.classList.add('active'); // Show/hide angle slider for diagonal document.getElementById('sp-angle-row').style.display = t === 'diagonal' ? '' : 'none'; if (stereoSim) stereoSim.setSectionType(t); } function stereoSectionHeight(val) { document.getElementById('sp-sect-val').textContent = val + '%'; if (stereoSim) stereoSim.setSectionHeight(+val / 100); } function stereoSectionAngle(val) { document.getElementById('sp-angle-val').textContent = val + '%'; if (stereoSim) stereoSim.setSectionAngle(+val / 100); } function stereoUnfold(btn) { const on = !btn.classList.contains('active'); btn.classList.toggle('active', on); if (stereoSim) stereoSim.toggleUnfold(on); } function _stereoDeactivateTools() { ['stereo-measure-btn','stereo-point-btn','stereo-connect-btn', '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 => { document.getElementById(id)?.classList.remove('active'); }); if (stereoSim) { stereoSim.toggleMeasure(false); stereoSim.togglePointMode(false); stereoSim.toggleConnectMode(false); stereoSim.setAngleMode(null); stereoSim.setMarkMode(null); stereoSim.setDeriveMode(null); stereoSim.toggleSection3P(false); } const hint = document.getElementById('angle-hint'); if (hint) hint.textContent = ''; } function stereoMeasure(btn) { const on = !btn.classList.contains('active'); _stereoDeactivateTools(); btn.classList.toggle('active', on); if (stereoSim) stereoSim.toggleMeasure(on); } function stereoMeasureUndo() { if (stereoSim) stereoSim.removeLastMeasurement(); } function stereoMeasureClear() { if (stereoSim) stereoSim.clearMeasurements(); } function stereoToggleHeight(btn) { const on = !btn.classList.contains('active'); btn.classList.toggle('active', on); if (stereoSim) stereoSim.toggleHeight(on); } function stereoToggleApothem(btn) { const on = !btn.classList.contains('active'); btn.classList.toggle('active', on); if (stereoSim) stereoSim.toggleApothem(on); } function stereoToggleDiag(btn) { const on = !btn.classList.contains('active'); btn.classList.toggle('active', on); if (stereoSim) stereoSim.toggleDiagonals(on); } function stereoToggleMid(btn) { const on = !btn.classList.contains('active'); btn.classList.toggle('active', on); if (stereoSim) stereoSim.toggleMidpoints(on); } const ANGLE_HINTS = { edge: 'Кликните 3 точки: A, B (вершина угла), C', linePlane: 'Кликните 2 точки (прямая), затем — грань', dihedral: 'Кликните 2 точки общего ребра двух граней', pointPlane: 'Кликните точку, затем — грань', skewLines: 'P1, P2 (прямая 1) → P3, P4 (прямая 2): угол и расстояние', }; function stereoAngleMode(mode, btn) { const on = !btn.classList.contains('active'); _stereoDeactivateTools(); btn.classList.toggle('active', on); if (stereoSim) stereoSim.setAngleMode(on ? mode : null); const hint = document.getElementById('angle-hint'); if (hint) hint.textContent = on ? ANGLE_HINTS[mode] : ''; } function stereoAngleClear() { _stereoDeactivateTools(); if (stereoSim) { stereoSim.setAngleMode(null); stereoSim._clearGroup(stereoSim._angleGroup); } } /* ── Edge marks ── */ function stereoMarkMode(mode, btn) { const on = !btn.classList.contains('active'); _stereoDeactivateTools(); btn.classList.toggle('active', on); if (stereoSim) stereoSim.setMarkMode(on ? mode : null); } function stereoMarkClear() { _stereoDeactivateTools(); if (stereoSim) stereoSim.clearMarks(); } function stereoToggleEdgeLengths(btn) { const on = !btn.classList.contains('active'); btn.classList.toggle('active', on); if (stereoSim) stereoSim.toggleEdgeLengths(on); } /* ── Derived points ── */ function stereoDerive(mode, btn) { const on = !btn.classList.contains('active'); _stereoDeactivateTools(); btn.classList.toggle('active', on); if (stereoSim) stereoSim.setDeriveMode(on ? mode : null); } function stereoDeriveUndo() { if (stereoSim) stereoSim.removeLastDerived(); } function stereoDeriveClear() { _stereoDeactivateTools(); if (stereoSim) stereoSim.clearDerived(); } function stereoPointMode(btn) { const on = !btn.classList.contains('active'); _stereoDeactivateTools(); btn.classList.toggle('active', on); if (stereoSim) stereoSim.togglePointMode(on); } function stereoConnectMode(btn) { const on = !btn.classList.contains('active'); _stereoDeactivateTools(); btn.classList.toggle('active', on); if (stereoSim) stereoSim.toggleConnectMode(on); } function stereoUndoPoint() { if (stereoSim) stereoSim.removeLastPoint(); } function stereoClearPoints() { if (stereoSim) stereoSim.clearCustomPoints(); _stereoUpdatePointsInfo(); } /* ── Section through 3 points UI ── */ function stereoSection3P(btn) { const on = !btn.classList.contains('active'); _stereoDeactivateTools(); btn.classList.toggle('active', on); if (stereoSim) stereoSim.toggleSection3P(on); const hint = document.getElementById('sect3p-hint'); if (hint) hint.textContent = on ? 'Кликните 3 точки на рёбрах или вершинах' : ''; if (on) _stereoUpdateSection3PPanel(); } function stereoSection3PClear() { if (stereoSim) stereoSim.clearSection3P(); _stereoUpdateSection3PPanel(); } function stereoSection3PStepBy(toggle) { const on = !toggle.classList.contains('on'); toggle.classList.toggle('on', on); if (stereoSim) stereoSim.toggleSection3PStepBy(on); } function stereoSection3PNextStep() { if (!stereoSim) return; const max = stereoSim._section3PData ? 6 : stereoSim._section3PPicks.length; stereoSim._section3PStep = Math.min(stereoSim._section3PStep + 1, max); stereoSim._drawSection3P(); } function stereoSection3PPrevStep() { if (!stereoSim) return; stereoSim._section3PStep = Math.max(0, stereoSim._section3PStep - 1); stereoSim._drawSection3P(); } function _stereoUpdateSection3PPanel() { const panel = document.getElementById('sect3p-info'); if (!panel) return; if (!stereoSim) { panel.innerHTML = ''; return; } const data = stereoSim.getSection3PInfo(); const picks = stereoSim._section3PPicks; if (!data && picks.length === 0) { panel.innerHTML = ''; return; } const r = v => Math.round(v * 100) / 100; const fmtV = v => `(${r(v.x)}, ${r(v.y)}, ${r(v.z)})`; const lines = []; picks.forEach((p, i) => lines.push(`