'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; this._rafId = null; // active requestAnimationFrame id (null = loop asleep) this._needsRender = true; // render-on-demand dirty flag this._contextLost = 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, preserveDrawingBuffer: 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._panning = false; this._prevX = 0; this._prevY = 0; this._rotY = 0.6; this._rotX = 0.45; this._dist = 14; this._autoSpin = true; this._spinEnabled = true; // master switch for idle auto-rotation this._idleTime = 0; this._velX = 0; this._velY = 0; // orbit inertia (angular velocity) this._panOffset = new THREE.Vector3(0, 0, 0); // look-at target offset (panning) // home view for the reset button this._homeView = { rotY: 0.6, rotX: 0.45, dist: 14 }; const el = this.renderer.domElement; el.style.cursor = 'grab'; el.style.touchAction = 'none'; el.setAttribute('tabindex', '0'); el.setAttribute('role', 'img'); el.setAttribute('aria-label', '3D-модель стереометрической фигуры'); this._clickStart = null; // Listeners are scoped to the canvas (not window) and tracked for dispose(). // pointer capture keeps move/up flowing while dragging outside the canvas. this._listeners = []; const on = (target, type, fn, opts) => { target.addEventListener(type, fn, opts); this._listeners.push([target, type, fn, opts]); }; on(el, 'pointerdown', e => { this._clickStart = { x: e.clientX, y: e.clientY }; // Right / middle button or Shift = pan; left button = orbit. this._panning = (e.button === 1 || e.button === 2 || e.shiftKey); this._drag = true; this._prevX = e.clientX; this._prevY = e.clientY; this._autoSpin = false; this._idleTime = 0; this._velX = 0; this._velY = 0; try { el.setPointerCapture(e.pointerId); } catch (_) {} if (this._panning) el.style.cursor = 'move'; else if (!this._pointMode && !this._connectMode && !this._measureMode && !this._angleMode && !this._section3PMode) el.style.cursor = 'grabbing'; this._invalidate(); }); on(el, 'contextmenu', e => e.preventDefault()); // allow right-drag pan without menu on(el, '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; const wasPanning = this._panning; this._panning = false; try { el.releasePointerCapture(e.pointerId); } catch (_) {} if (wasPanning) { el.style.cursor = 'grab'; this._invalidate(); return; } 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'; this._invalidate(); }); on(el, 'pointermove', e => { this._onHoverMove(e); if (!this._drag) return; const dx = e.clientX - this._prevX, dy = e.clientY - this._prevY; if (this._panning) { this._pan(dx, dy); } else { const vy = dx * 0.007, vx = dy * 0.007; this._rotY += vy; this._rotX += vx; this._rotX = Math.max(-1.4, Math.min(1.4, this._rotX)); this._velY = vy; this._velX = vx; // remember last delta for inertia } this._prevX = e.clientX; this._prevY = e.clientY; this._idleTime = 0; this._invalidate(); }); on(el, 'wheel', e => { e.preventDefault(); this._dist = Math.max(4, Math.min(40, this._dist + e.deltaY * 0.02)); this._invalidate(); }, { passive: false }); // Keyboard navigation (a11y) — works when the canvas is focused. on(el, 'keydown', e => { const STEP = 0.12; let handled = true; switch (e.key) { case 'ArrowLeft': this._rotY -= STEP; this._autoSpin = false; break; case 'ArrowRight': this._rotY += STEP; this._autoSpin = false; break; case 'ArrowUp': this._rotX = Math.min(1.4, this._rotX + STEP); this._autoSpin = false; break; case 'ArrowDown': this._rotX = Math.max(-1.4, this._rotX - STEP); this._autoSpin = false; break; case '+': case '=': this._dist = Math.max(4, this._dist - 1); break; case '-': case '_': this._dist = Math.min(40, this._dist + 1); break; case 'r': case 'R': case 'Home': this.resetView(); break; default: handled = false; } if (handled) { e.preventDefault(); this._idleTime = 0; this._invalidate(); } }); // WebGL context loss / restore — keep the page alive if the GPU resets. on(el, 'webglcontextlost', e => { e.preventDefault(); this._contextLost = true; this.stop(); }, false); on(el, 'webglcontextrestored', () => { this._contextLost = false; this._buildGrid(); this._buildFigure(); if (this._running === false) this.play(); else this._invalidate(); }, false); /* touch — orbit (1 finger) + pinch-zoom & pan (2 fingers) */ this._touchDist = 0; this._touchMidX = 0; this._touchMidY = 0; on(el, 'touchstart', e => { if (e.touches.length === 1) { this._drag = true; this._panning = false; this._prevX = e.touches[0].clientX; this._prevY = e.touches[0].clientY; this._autoSpin = false; this._idleTime = 0; this._velX = 0; this._velY = 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); this._touchMidX = (e.touches[0].clientX + e.touches[1].clientX) / 2; this._touchMidY = (e.touches[0].clientY + e.touches[1].clientY) / 2; } this._invalidate(); }, { passive: true }); on(el, '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; // two-finger pan via midpoint movement const midX = (e.touches[0].clientX + e.touches[1].clientX) / 2; const midY = (e.touches[0].clientY + e.touches[1].clientY) / 2; this._pan(midX - this._touchMidX, midY - this._touchMidY); this._touchMidX = midX; this._touchMidY = midY; this._idleTime = 0; this._invalidate(); return; } if (!this._drag || e.touches.length !== 1) return; const t = e.touches[0]; const vy = (t.clientX - this._prevX) * 0.007, vx = (t.clientY - this._prevY) * 0.007; this._rotY += vy; this._rotX += vx; this._rotX = Math.max(-1.4, Math.min(1.4, this._rotX)); this._velY = vy; this._velX = vx; this._prevX = t.clientX; this._prevY = t.clientY; this._idleTime = 0; this._invalidate(); }, { passive: true }); on(el, 'touchend', () => { this._drag = false; this._panning = false; this._touchDist = 0; this._invalidate(); }, { passive: true }); /* 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._stepCaption = ''; // caption for the current trace-method step 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(); this._notify(); } clearMeasurements() { this._measurements = []; this._measurePicks = []; this._rebuildMeasureGroup(); this._clearGroup(this._measurePickGroup); this._notify(); } _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; // keep step ≥1 while in step mode, else a re-pick would hide the section this._section3PStep = this._section3PStepBy ? 1 : 0; this._stepCaption = ''; this._clearGroup(this._section3PGroup); this._notify(); } toggleSection3PStepBy(on) { this._section3PStepBy = on; // entering step mode: start at the first step so the build-up is visible if (on && this._section3PStep === 0) this._section3PStep = 1; if (!on) this._stepCaption = ''; if (this._section3PData || this._section3PPicks.length) 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); } _polygonPerimeter(pts) { let p = 0; for (let i = 0; i < pts.length; i++) p += pts[i].distanceTo(pts[(i + 1) % pts.length]); return p; } // Live, human-readable lines for the viewport readout panel. getReadout() { const lines = []; const r = (v) => Math.round(v * 100) / 100; const curved = ['cylinder', 'cone', 'trunccone', 'sphere'].includes(this.figureType); if (this._section3PData) { const d = this._section3PData; lines.push({ label: 'Сечение (3 точки)', value: d.typeName }); lines.push({ label: 'Площадь S', value: r(d.area) }); lines.push({ label: 'Периметр P', value: r(this._polygonPerimeter(d.polygon)) }); } else if (this.showSection && this._sectionPolygon && this._sectionPolygon.length >= 3) { const poly = this._sectionPolygon; const polyName = (curved && poly.length > 8) ? (this.figureType === 'sphere' || this.sectionType === 'horizontal' ? 'окружность' : 'эллипс') : ({ 3: 'треугольник', 4: 'четырёхугольник', 5: 'пятиугольник', 6: 'шестиугольник' }[poly.length] || `${poly.length}-угольник`); const kind = { horizontal: 'горизонтальное', diagonal: 'наклонное', custom: 'произвольное' }[this.sectionType] || ''; lines.push({ label: 'Сечение' + (kind ? ` (${kind})` : ''), value: polyName }); lines.push({ label: 'Площадь S', value: r(this.getSectionArea()) }); lines.push({ label: 'Периметр P', value: r(this._polygonPerimeter(poly)) }); } if (this._measurements.length) { const m = this._measurements[this._measurements.length - 1]; lines.push({ label: `Отрезок ${m.from}${m.to}`, value: m.dist }); } return lines; } 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, readout: this.getReadout(), }; } 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); this._invalidate(); } play() { if (!this._running) { this._running = true; this._invalidate(); } } stop() { this._running = false; if (this._rafId != null) { cancelAnimationFrame(this._rafId); this._rafId = null; } } pause() { this.stop(); } // Mark the scene dirty and wake the loop if it was asleep. _invalidate() { this._needsRender = true; if (this._running && this._rafId == null && !this._contextLost) { this._rafId = requestAnimationFrame(() => this._loop()); } } /* ════════════════ CAMERA CONTROLS ════════════════ */ // Look-at target = figure-centre + user pan offset. _camTarget() { return new THREE.Vector3(0, this._figureHeight() / 2, 0).add(this._panOffset); } // Pan the orbit centre in screen space (dx,dy in pixels). _pan(dx, dy) { const forward = new THREE.Vector3(); this.camera.getWorldDirection(forward); const right = new THREE.Vector3().crossVectors(forward, this.camera.up).normalize(); const up = new THREE.Vector3().crossVectors(right, forward).normalize(); const k = this._dist * 0.0016; // pan speed scales with zoom distance this._panOffset.addScaledVector(right, -dx * k); this._panOffset.addScaledVector(up, dy * k); } resetView() { const h = this._homeView; this._rotY = h.rotY; this._rotX = h.rotX; this._dist = h.dist; this._panOffset.set(0, 0, 0); this._velX = 0; this._velY = 0; // Reset = back to the initial state, which gently auto-rotates. this._spinEnabled = true; this._autoSpin = true; this._idleTime = 0; this._invalidate(); } // Snap to a named viewpoint. Disables auto-spin so the view holds still. setPreset(name) { const P = { iso: { rotY: 0.6, rotX: 0.45 }, front: { rotY: 0, rotX: 0.05 }, back: { rotY: Math.PI, rotX: 0.05 }, side: { rotY: Math.PI / 2, rotX: 0.05 }, top: { rotY: 0, rotX: 1.4 }, }; const v = P[name] || P.iso; this._rotY = v.rotY; this._rotX = v.rotX; this._panOffset.set(0, 0, 0); this._velX = 0; this._velY = 0; // Hold the chosen view: stop spinning and don't let it re-engage on idle. this._autoSpin = false; this._spinEnabled = false; this._idleTime = 0; this._invalidate(); } setAutoSpin(on) { this._spinEnabled = !!on; this._autoSpin = !!on; this._idleTime = 0; this._velX = 0; this._velY = 0; this._invalidate(); } // Render one frame synchronously and return a PNG data URL. screenshot() { this._needsRender = true; this._renderNow(); try { return this.renderer.domElement.toDataURL('image/png'); } catch (_) { return null; } } _renderNow() { const target = this._camTarget(); this.camera.position.set( target.x + this._dist * Math.sin(this._rotY) * Math.cos(this._rotX), target.y + this._dist * Math.sin(this._rotX), target.z + this._dist * Math.cos(this._rotY) * Math.cos(this._rotX) ); this.camera.lookAt(target); this.renderer.render(this.scene, this.camera); this._needsRender = false; } toggleFullscreen() { const box = this.container.closest('.graph-canvas-outer') || this.container; if (!document.fullscreenElement) { if (box.requestFullscreen) box.requestFullscreen(); } else if (document.exitFullscreen) { document.exitFullscreen(); } // fit() is driven by the ResizeObserver once the element resizes. } // Free GPU + DOM resources. Call when the sim is permanently torn down. dispose() { this.stop(); if (this._ro) { this._ro.disconnect(); this._ro = null; } if (this._listeners) { for (const [t, type, fn, opts] of this._listeners) t.removeEventListener(type, fn, opts); this._listeners = []; } [this._figGroup, this._labelGroup, this._sectionGroup, this._sphereGroup, this._measureGroup, this._measurePickGroup, this._gridGroup, this._markGroup, this._derivedGroup, this._section3PGroup, this._angleGroup, this._pointGroup] .forEach(g => g && this._clearGroup(g)); if (this._tooltipEl && this._tooltipEl.parentNode) this._tooltipEl.parentNode.removeChild(this._tooltipEl); if (this.renderer) { this.renderer.dispose(); const el = this.renderer.domElement; if (el && el.parentNode) el.parentNode.removeChild(el); } } /* ════════════════ 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); // axis letters, coloured to match AxesHelper (X red, Y green, Z blue) const axLabel = (txt, color, pos) => { const s = this._makeTextSprite(txt, color, 38); s.position.copy(pos); s.scale.multiplyScalar(0.85); this._gridGroup.add(s); }; axLabel('X', '#ff5b5b', new THREE.Vector3(6.5, 0.15, 0)); axLabel('Y', '#5bff8d', new THREE.Vector3(0, 6.6, 0)); axLabel('Z', '#5b9bff', new THREE.Vector3(0, 0.15, 6.5)); } this._invalidate(); } /* ════════════════ 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(); this._invalidate(); } /* ── 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.9) { 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 }); const line = new THREE.Line(geo, mat); line.renderOrder = 2; // draw over the translucent solid for crisp contrast this._figGroup.add(line); 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) { // soft additive glow halo (texture-free → safe with _clearGroup disposal) const gGeo = new THREE.SphereGeometry(0.17, 12, 12); const gMat = new THREE.MeshBasicMaterial({ color: 0x9B5DE5, transparent: true, opacity: 0.16, blending: THREE.AdditiveBlending, depthWrite: false, }); const glow = new THREE.Mesh(gGeo, gMat); glow.position.copy(v.pos); this._figGroup.add(glow); 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); sphere.renderOrder = 3; 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) { text = String(text); // High-res, aspect-correct backing canvas → crisp at any zoom / DPI. const dpr = Math.min(window.devicePixelRatio || 1, 2); const fontPx = 96; // backing resolution (independent of world size) const pad = 12; const meas = document.createElement('canvas').getContext('2d'); meas.font = `bold ${fontPx}px Manrope, sans-serif`; const wCss = Math.ceil(meas.measureText(text).width) + pad * 2; const hCss = fontPx + pad * 2; const canvas = document.createElement('canvas'); canvas.width = Math.max(2, Math.round(wCss * dpr)); canvas.height = Math.max(2, Math.round(hCss * dpr)); const ctx = canvas.getContext('2d'); ctx.scale(dpr, dpr); ctx.font = `bold ${fontPx}px Manrope, sans-serif`; ctx.textAlign = 'center'; ctx.textBaseline = 'middle'; // dark halo keeps labels legible over bright faces / grid ctx.lineWidth = fontPx * 0.14; ctx.lineJoin = 'round'; ctx.strokeStyle = 'rgba(0,0,0,0.55)'; ctx.strokeText(text, wCss / 2, hCss / 2); ctx.fillStyle = color; ctx.fillText(text, wCss / 2, hCss / 2); const tex = new THREE.CanvasTexture(canvas); tex.minFilter = THREE.LinearFilter; tex.magFilter = THREE.LinearFilter; if (this.renderer.capabilities) tex.anisotropy = this.renderer.capabilities.getMaxAnisotropy(); const mat = new THREE.SpriteMaterial({ map: tex, transparent: true, depthTest: false }); const sprite = new THREE.Sprite(mat); // world height scales with the requested `size`; width follows the text aspect const worldH = (size / 64) * 0.5; sprite.scale.set(worldH * (wCss / hCss), worldH, 1); return sprite; } /* ════════════════ SECTION PLANE ════════════════ */ _updateSection() { this._clearGroup(this._sectionGroup); this._sectionPolygon = null; this._invalidate(); 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); } } } this._notify(); } _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 curved = ['cylinder', 'cone', 'trunccone', 'sphere'].includes(this.figureType); // Curved solids: analytic (smooth) intersection where possible. if (curved) { const poly = this._sliceCurvedByNormal(normal, d); if (poly && poly.length >= 3) return poly; // else fall through to the generic sampler (e.g. near-vertical planes) } const points = []; // Intersect with all edges (polyhedra) 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)))); } } // Fallback sampler for curved solids when the analytic path bailed out. if (points.length < 3 && curved) { const fh = this._figureHeight(); const samples = 96; for (let i = 0; i < samples; i++) { const angle = (i / samples) * Math.PI * 2; 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); } // Analytic plane∩(solid of revolution) — returns an ordered, smooth polygon // (circle for a sphere, ellipse/conic arc for cylinder/cone), or null to defer. _sliceCurvedByNormal(normal, d) { const ft = this.figureType; const fh = this._figureHeight(); const TAU = Math.PI * 2; if (ft === 'sphere') { const R = this.params.r; const C = new THREE.Vector3(0, R, 0); // sphere centre const dist = normal.dot(C) + d; // signed distance centre→plane if (Math.abs(dist) >= R) return []; // plane misses the sphere const rho = Math.sqrt(Math.max(0, R * R - dist * dist)); const center = C.clone().addScaledVector(normal, -dist); // projection onto plane // orthonormal basis in the plane let u = Math.abs(normal.x) > 0.9 ? new THREE.Vector3(0, 1, 0) : new THREE.Vector3(1, 0, 0); u = new THREE.Vector3().crossVectors(normal, u).normalize(); const v = new THREE.Vector3().crossVectors(normal, u).normalize(); const out = []; const N = 72; for (let i = 0; i < N; i++) { const a = (i / N) * TAU; out.push(center.clone().addScaledVector(u, rho * Math.cos(a)).addScaledVector(v, rho * Math.sin(a))); } return out; // already ordered around the circle } // cylinder / cone / trunccone — lateral surface r(y) = r0 + k·y if (Math.abs(normal.y) < 0.05) return null; // near-vertical plane → defer const r0 = this._radiusAtHeight(0); const r1 = this._radiusAtHeight(fh); const k = (r1 - r0) / fh; const out = []; const N = 120; for (let i = 0; i < N; i++) { const a = (i / N) * TAU; const c = normal.x * Math.cos(a) + normal.z * Math.sin(a); // plane: c·r(y) + n_y·y + d = 0, with r(y) = r0 + k·y const denom = c * k + normal.y; if (Math.abs(denom) < 1e-9) continue; const y = -(c * r0 + d) / denom; if (y < -1e-6 || y > fh + 1e-6) continue; // generator meets plane outside the body const r = r0 + k * Math.max(0, Math.min(fh, y)); if (r < 1e-4) continue; out.push(new THREE.Vector3(r * Math.cos(a), Math.max(0, Math.min(fh, y)), r * Math.sin(a))); } return out.length >= 3 ? this._sortByAngle3D(out, normal) : null; } _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 + letter labels for genuine polygons; smooth conic // sections (many sampled points) would otherwise render dozens of spheres. if (pts.length <= 12) { const LETTERS = 'KLMNPQRSTUV'; pts.forEach((p, i) => { 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); // letter label pushed slightly outward from the section centroid const lbl = this._makeTextSprite(LETTERS[i] || `${i + 1}`, '#06D6E0', 34); const out = new THREE.Vector3().subVectors(p, c).normalize().multiplyScalar(0.32); lbl.position.copy(p).add(out).add(new THREE.Vector3(0, 0.18, 0)); this._sectionGroup.add(lbl); }); } } _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); this._invalidate(); 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 curved = ['cylinder', 'cone', 'trunccone', 'sphere'].includes(this.figureType); const typeNames = { 3: 'треугольник', 4: 'четырёхугольник', 5: 'пятиугольник', 6: 'шестиугольник' }; let typeName; if (curved && n > 8) { typeName = this.figureType === 'sphere' ? 'окружность' : (Math.abs(normal.y) > 0.98 ? 'окружность' : 'эллипс (коническое сечение)'); } else { 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); }); // Connector triangle between picks — shown live while picking and in normal // mode; hidden during the step build-up (step 2 draws same-face sides itself). const showPickLines = !this._section3PStepBy; if (showPickLines && 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 (showPickLines && 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; // In step mode the step counter must be ≥1 (any reset path may have zeroed it), // otherwise the section would be hidden and no step drawn. if (this._section3PStepBy && this._section3PStep < 1) this._section3PStep = 1; // In step-by-step mode the finished section is hidden until step ≥ 5 so the // trace construction builds up; otherwise (or at the end) show it in full. const showFull = !this._section3PStepBy || this._section3PStep >= 5; // 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)); if (showFull) { // 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 + letter labels — only for true polygons; smooth conic // sections skip them. if (polygon.length <= 12) { const LETTERS = 'KLMNPQRSTUV'; polygon.forEach((p, i) => { 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); const lbl = this._makeTextSprite(LETTERS[i] || `${i + 1}`, '#7BF5A4', 32); const out = new THREE.Vector3().subVectors(p, c).normalize().multiplyScalar(0.3); lbl.position.copy(p).add(out).add(new THREE.Vector3(0, 0.16, 0)); this._section3PGroup.add(lbl); }); } } // Step-by-step trace construction (метод следов) if (this._section3PStepBy && this._section3PStep > 0) { this._drawSection3PStep(data); } else { this._stepCaption = ''; } } // Solids that rest on a base plane (y=0) — the trace method applies to these. _hasBase() { return ['cube', 'parallelepiped', 'prism', 'pyramid', 'truncpyramid', 'tetrahedron'] .includes(this.figureType); } // Trace line of the cutting plane on the base plane y=0. // Plane: n·X + D = 0 → at y=0: n.x·x + n.z·z + D = 0. Returns {p0, dir} or null // when the cutting plane is (nearly) parallel to the base (trace at infinity). _traceLine(data) { const a = data.normal.x, b = data.normal.z, D = data.D; const denom = a * a + b * b; if (denom < 1e-12) return null; // plane parallel to base const dir = new THREE.Vector3(-b, 0, a).normalize(); // foot of perpendicular from origin → keeps the drawn trace near the figure const p0 = new THREE.Vector3(-D * a / denom, 0, -D * b / denom); return { p0, dir }; } // Auxiliary points: extend each lateral side of the section to the base plane. // Each extension meets the base on the trace line — the heart of the method. _auxiliaryPoints(polygon) { const out = []; const n = polygon.length; for (let i = 0; i < n; i++) { const A = polygon[i], B = polygon[(i + 1) % n]; if (A.y < 0.05 && B.y < 0.05) continue; // edge already on the base if (Math.abs(B.y - A.y) < 1e-3) continue; // horizontal → meets base at ∞ const t = -A.y / (B.y - A.y); const H = new THREE.Vector3().lerpVectors(A, B, t); if (Math.abs(H.x) > 40 || Math.abs(H.z) > 40) continue; // near-parallel, too far // prefer extensions that reach the base outside the segment (the classic case) const reach = (t < 0) ? -t : (t > 1 ? t - 1 : 0); out.push({ A, B, H, reach }); } out.sort((p, q) => p.reach - q.reach); // nearest extensions first return out; } _drawSection3PStep(data) { const step = this._section3PStep; const picks = this._section3PPicks; const grp = this._section3PGroup; const HILITE = 0xFFFFA0, TRACE = 0xEF476F, AUX = 0xFFD166; const dot = (pos, color, r = 0.12) => { const s = new THREE.Mesh(new THREE.SphereGeometry(r, 10, 10), new THREE.MeshBasicMaterial({ color })); s.position.copy(pos); grp.add(s); }; const solidLine = (a, b, color) => { grp.add(new THREE.Line(new THREE.BufferGeometry().setFromPoints([a, b]), new THREE.LineBasicMaterial({ color, transparent: true, opacity: 0.95 }))); }; const dashLine = (a, b, color) => { const l = new THREE.Line(new THREE.BufferGeometry().setFromPoints([a, b]), new THREE.LineDashedMaterial({ color, dashSize: 0.18, gapSize: 0.1, transparent: true, opacity: 0.9 })); l.computeLineDistances(); grp.add(l); }; const tag = (pos, text, color, off = new THREE.Vector3(0.25, 0.25, 0)) => { const s = this._makeTextSprite(text, '#' + new THREE.Color(color).getHexString(), 34); s.position.copy(pos).add(off); grp.add(s); }; const hasBase = this._hasBase(); const polygon = data.polygon; const trace = hasBase ? this._traceLine(data) : null; const aux = (hasBase && trace) ? this._auxiliaryPoints(polygon).slice(0, 2) : []; // Step 1 — the three given points if (step >= 1) picks.forEach((p, i) => { dot(p, HILITE); tag(p, 'P' + (i + 1), HILITE); }); // Step 2 — connect points lying in the same face → first sides of the section if (step >= 2) { for (let i = 0; i < picks.length; i++) { for (let j = i + 1; j < picks.length; j++) { if (this._sameFace(picks[i], picks[j])) solidLine(picks[i], picks[j], HILITE); } } } // Step 3 — build the trace of the cutting plane on the base if (step >= 3 && trace) { const L = 13; const a = trace.p0.clone().addScaledVector(trace.dir, -L); const b = trace.p0.clone().addScaledVector(trace.dir, L); dashLine(a, b, TRACE); tag(b, 'след', TRACE, new THREE.Vector3(0.3, 0.2, 0)); } // Step 4 — extend the section's sides to the trace → auxiliary points if (step >= 4) { aux.forEach((q, i) => { dashLine(q.A, q.H, AUX); dot(q.H, AUX, 0.1); tag(q.H, 'T' + (i + 1), AUX); }); } // Steps 5–6 — the finished section is drawn by _drawSection3P (showFull) const CAPS = hasBase ? { 1: 'Шаг 1. Отмечены 3 точки, задающие секущую плоскость.', 2: 'Шаг 2. Соединяем точки в одной грани — первые стороны сечения.', 3: 'Шаг 3. Строим след — линию пересечения плоскости с основанием.', 4: 'Шаг 4. Продлеваем стороны сечения до следа — вспомогательные точки.', 5: 'Шаг 5. Через след находим остальные вершины и замыкаем сечение.', 6: `Шаг 6. Сечение построено: ${data.typeName}` + (data.area > 0 ? `, S = ${Math.round(data.area * 100) / 100}.` : '.'), } : { 1: 'Шаг 1. Отмечены 3 точки, задающие секущую плоскость.', 2: 'Шаг 2. Соединяем точки, лежащие в одной грани.', 3: 'Для этого тела метод следов не применяется — показываем готовое сечение.', 4: 'Готовое сечение.', 5: 'Готовое сечение.', 6: 'Готовое сечение.', }; this._stepCaption = CAPS[Math.min(step, 6)] || ''; } // True if two points both lie in (the plane of) the same face of the solid. _sameFace(p, q) { for (const face of this._faces) { if (face.length < 3) continue; const nrm = this._faceNormal(face); const d = nrm.dot(face[0]); if (Math.abs(nrm.dot(p) - d) < 0.06 && Math.abs(nrm.dot(q) - d) < 0.06) return true; } return false; } /* ════════════════ 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(); this._notify(); } } /* ════════════════ 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, }; } // Distance (in NDC) from a screen point to the projected 3D segment a→b, // and the clamped parameter t. Used by every edge picker for consistency. _edgePickNDC(mx, my, a, b) { const p1 = a.clone().project(this.camera); const p2 = b.clone().project(this.camera); const dx = p2.x - p1.x, dy = p2.y - p1.y; const lenSq = dx * dx + dy * dy; let t = lenSq < 1e-9 ? 0 : ((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; return { dist: Math.hypot(mx - px, my - py), t }; } // Raycast against the figure's solid mesh → a point on a face interior. _raycastFace(mx, my) { if (!this._raycaster) this._raycaster = new THREE.Raycaster(); this._raycaster.setFromCamera({ x: mx, y: my }, this.camera); const hits = this._raycaster.intersectObjects(this._figGroup.children, true); for (const h of hits) { if (h.object && h.object.type === 'Mesh') return h.point.clone(); } return null; } _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; } } // Fall back to a point on a face interior (raycast) when not near edge/vertex. if (!bestPos) { const fp = this._raycastFace(mx, my); if (fp) { bestPos = fp; bestEdge = -3; bestT = 0; } } 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 { dist } = this._edgePickNDC(mx, my, edge.from, edge.to); if (dist < bestDist) { bestDist = dist; 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) { const disposeObj = (o) => { if (o.geometry) o.geometry.dispose(); if (o.material) { const mats = Array.isArray(o.material) ? o.material : [o.material]; for (const m of mats) { if (m.map) m.map.dispose(); m.dispose(); } } }; while (group.children.length) { const c = group.children[0]; c.traverse(disposeObj); // dispose c plus any nested descendants (avoids leaks on nested groups) group.remove(c); } this._invalidate(); } /* ════════════════ 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 { dist } = this._edgePickNDC(mx, my, edge.from, edge.to); if (dist < bestDist) { bestDist = dist; 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() { this._rafId = null; if (!this._running) return; // Orbit inertia (after release, decays to rest) let inertia = false; if (!this._drag && (this._velX !== 0 || this._velY !== 0)) { this._rotY += this._velY; this._rotX += this._velX; this._rotX = Math.max(-1.4, Math.min(1.4, this._rotX)); this._velY *= 0.92; this._velX *= 0.92; if (Math.abs(this._velX) < 1e-4) this._velX = 0; if (Math.abs(this._velY) < 1e-4) this._velY = 0; inertia = (this._velX !== 0 || this._velY !== 0); this._idleTime = 0; this._needsRender = true; } // Auto-spin after idle (only when enabled and the view has settled) if (!this._drag && !inertia) this._idleTime++; if (this._spinEnabled && this._idleTime > 300 && !this._drag && !inertia) this._autoSpin = true; if (this._autoSpin) { this._rotY += 0.002; this._needsRender = true; } // Unfold animation let unfolding = false; if (this._unfold && this._unfoldProgress < this._unfoldTarget) { this._unfoldProgress = Math.min(1, this._unfoldProgress + 0.015); this._applyUnfold(this._unfoldProgress); unfolding = true; this._needsRender = true; } 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; } unfolding = true; this._needsRender = true; } if (this._needsRender) { // Camera orbit around the (possibly panned) target const target = this._camTarget(); this.camera.position.set( target.x + this._dist * Math.sin(this._rotY) * Math.cos(this._rotX), target.y + this._dist * Math.sin(this._rotX), target.z + this._dist * Math.cos(this._rotY) * Math.cos(this._rotX) ); this.camera.lookAt(target); this.renderer.render(this.scene, this.camera); this._needsRender = false; } // Keep the loop alive while there is motion or we're still counting toward // auto-spin re-engagement; otherwise sleep until _invalidate() wakes us. // Guard on _rafId so a mid-loop _invalidate() can't schedule a second frame. const motion = this._autoSpin || this._drag || unfolding || inertia; const waitingToSpin = this._spinEnabled && !this._autoSpin && this._idleTime <= 300; if ((motion || waitingToSpin || this._needsRender) && this._rafId == null) { this._rafId = requestAnimationFrame(() => this._loop()); } } } /* ─── 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(figureType) { document.getElementById('sim-topbar-title').textContent = 'Стереометрия 3D'; _simShow('sim-stereo'); document.getElementById('stereo-stats').style.display = ''; // Deep-link from a textbook: openSim('stereo:pyramid') or /lab?stereofig=pyramid if (!figureType) { try { figureType = new URLSearchParams(location.search).get('stereofig') || null; } catch (_) {} } requestAnimationFrame(() => requestAnimationFrame(() => { if (!stereoSim) { stereoSim = new StereoSim(document.getElementById('stereo-container')); stereoSim.onUpdate = _stereoUpdateUI; } else { stereoSim.fit(); stereoSim.play(); } if (figureType && STEREO_PARAM_MAP[figureType]) { const btn = document.querySelector(`.stereo-fig-btn[onclick*="'${figureType}'"]`); setStereoFigure(figureType, btn); } _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); } /* ── camera / view controls (overlay toolbar) ── */ function stereoResetView() { if (stereoSim) stereoSim.resetView(); // restore UI to initial: Изо preset active, auto-spin on document.querySelectorAll('.st-view-preset').forEach((b, i) => b.classList.toggle('active', i === 0)); const sb = document.getElementById('st-spin-btn'); if (sb) { sb.classList.add('active'); sb.setAttribute('aria-pressed', 'true'); } } function stereoPreset(name, btn) { if (stereoSim) stereoSim.setPreset(name); document.querySelectorAll('.st-view-preset').forEach(b => b.classList.remove('active')); if (btn) btn.classList.add('active'); // a preset turns auto-spin off — reflect it on the spin button const sb = document.getElementById('st-spin-btn'); if (sb) { sb.classList.remove('active'); sb.setAttribute('aria-pressed', 'false'); } } function stereoToggleSpin(btn) { if (!stereoSim) return; const on = !btn.classList.contains('active'); btn.classList.toggle('active', on); btn.setAttribute('aria-pressed', on ? 'true' : 'false'); stereoSim.setAutoSpin(on); } function stereoFullscreen() { if (stereoSim) stereoSim.toggleFullscreen(); } function stereoScreenshot() { if (!stereoSim) return; const url = stereoSim.screenshot(); if (!url) return; const a = document.createElement('a'); a.href = url; a.download = 'stereo-' + (stereoSim.figureType || 'figure') + '.png'; document.body.appendChild(a); a.click(); document.body.removeChild(a); if (window.LabFX) LabFX.sound.play('tick', { pitch: 1.4, volume: 0.3 }); } // 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 _stereoStepHint() { const hint = document.getElementById('sect3p-hint'); if (hint && stereoSim) hint.textContent = stereoSim._stepCaption || ''; } function stereoSection3PStepBy(toggle) { const on = !toggle.classList.contains('on'); toggle.classList.toggle('on', on); if (stereoSim) stereoSim.toggleSection3PStepBy(on); _stereoStepHint(); } function stereoSection3PNextStep() { if (!stereoSim) return; if (!stereoSim._section3PStepBy) return; // steps only meaningful in step mode const max = stereoSim._section3PData ? 6 : stereoSim._section3PPicks.length; stereoSim._section3PStep = Math.min(stereoSim._section3PStep + 1, max); stereoSim._drawSection3P(); _stereoStepHint(); } function stereoSection3PPrevStep() { if (!stereoSim) return; if (!stereoSim._section3PStepBy) return; stereoSim._section3PStep = Math.max(1, stereoSim._section3PStep - 1); stereoSim._drawSection3P(); _stereoStepHint(); } 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(`
P${i+1} = ${fmtV(p)}
`)); if (data) { const { normal: n, D, typeName, area } = data; const A = r(n.x), B = r(n.y), C = r(n.z), Dv = r(D); const eq = `${A}x + ${B}y + ${C}z ${Dv >= 0 ? '+' : ''}${Dv} = 0`; lines.push(`
Плоскость: ${eq}
`); lines.push(`
Сечение: ${typeName}
`); if (area > 0) lines.push(`
S = ${r(area)}
`); } else if (picks.length < 3) { lines.push(`
Выбрано точек: ${picks.length}/3
`); } panel.innerHTML = lines.join(''); } function stereoInscribed(btn) { const on = !btn.classList.contains('active'); btn.classList.toggle('active', on); if (stereoSim) stereoSim.toggleInscribed(on); } function stereoCircumscribed(btn) { const on = !btn.classList.contains('active'); btn.classList.toggle('active', on); if (stereoSim) stereoSim.toggleCircumscribed(on); } function _stereoUpdateFormulas() { if (!stereoSim) return; const f = stereoSim.getFormulas(); const el = document.getElementById('stereo-formulas'); if (!f || !f.formulas) { el.innerHTML = ''; return; } const colors = ['#7BF5A4','#60a5fa','#c4b5fd','#fbbf24','#f9a8d4','#F59E0B','#EF476F']; el.innerHTML = f.formulas.map((s, i) => '
' + s + '
' ).join(''); } function _stereoUpdateUI(info) { if (!info) return; document.getElementById('stbar-vol').textContent = info.V !== undefined ? info.V.toFixed(2) : '—'; document.getElementById('stbar-area').textContent = info.S !== undefined ? info.S.toFixed(2) : '—'; document.getElementById('stbar-side').textContent = info.S_side !== undefined ? info.S_side.toFixed(2) : '—'; document.getElementById('stbar-h').textContent = info.h !== undefined ? info.h.toFixed(2) : '—'; document.getElementById('stbar-d').textContent = info.d !== undefined && info.d > 0 ? info.d.toFixed(2) : '—'; // Section area const sectEl = document.getElementById('sect-area-display'); if (info.sectionArea && info.sectionArea > 0) { sectEl.style.display = ''; sectEl.textContent = 'S сечения = ' + info.sectionArea.toFixed(2); } else { sectEl.style.display = 'none'; } // Inscribed / Circumscribed radius info const rInfo = document.getElementById('sphere-radius-info'); if (rInfo) { const parts = []; if (info.inscribedR != null) parts.push('r_вп = ' + info.inscribedR.toFixed(2)); if (info.circumscribedR != null) parts.push('R_оп = ' + info.circumscribedR.toFixed(2)); rInfo.textContent = parts.join(' · '); rInfo.style.display = parts.length ? '' : 'none'; } // Points info _stereoUpdatePointsInfo(info); // Section-3P panel _stereoUpdateSection3PPanel(); // Live readout overlay (section type/area/perimeter, last measurement) _stereoUpdateReadout(info); // Keep the trace-method caption in sync (e.g. right after the 3rd pick), // but only in step mode so the "Кликните 3 точки" instruction is preserved. if (stereoSim && stereoSim._section3PStepBy) _stereoStepHint(); } function _stereoUpdateReadout(info) { const el = document.getElementById('stereo-readout'); if (!el) return; const lines = (info && info.readout) || []; if (!lines.length) { el.style.display = 'none'; el.innerHTML = ''; return; } el.style.display = ''; el.innerHTML = lines.map(l => '
' + l.label + '' + '' + l.value + '
' ).join(''); } function _stereoUpdatePointsInfo(info) { const el = document.getElementById('points-info'); if (!el) return; if (!info) info = stereoSim?.info(); if (!info) { el.textContent = ''; return; } let txt = ''; if (info.customPoints > 0) txt += `Точек: ${info.customPoints}`; if (info.connections > 0) txt += ` · Линий: ${info.connections}`; el.textContent = txt; }