'use strict'; /* ═══════════════════════════════════════════════════════════ StereoSim — 3D Stereometry (Three.js) Cube, Parallelepiped, Pyramid, Tetrahedron, Cylinder, Cone, Truncated Cone, Sphere, Prism + sections, unfold ═══════════════════════════════════════════════════════════ */ class StereoSim { constructor(container) { this.container = container; this._running = false; /* Three.js core */ this.scene = new THREE.Scene(); this.camera = new THREE.PerspectiveCamera(50, 1, 0.1, 500); this.renderer = new THREE.WebGLRenderer({ antialias: true, alpha: true }); this.renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2)); this.renderer.setClearColor(0x0D0D1A, 1); container.appendChild(this.renderer.domElement); /* lighting */ this.scene.add(new THREE.AmbientLight(0xffffff, 0.55)); const dir = new THREE.DirectionalLight(0xffffff, 0.75); dir.position.set(6, 10, 8); this.scene.add(dir); const fill = new THREE.DirectionalLight(0x9B5DE5, 0.2); fill.position.set(-5, 3, -4); this.scene.add(fill); /* orbit camera */ this._drag = false; this._prevX = 0; this._prevY = 0; this._rotY = 0.6; this._rotX = 0.45; this._dist = 14; this._autoSpin = true; this._idleTime = 0; const el = this.renderer.domElement; el.style.cursor = 'grab'; this._clickStart = null; el.addEventListener('pointerdown', e => { this._clickStart = { x: e.clientX, y: e.clientY }; this._drag = true; this._prevX = e.clientX; this._prevY = e.clientY; this._autoSpin = false; this._idleTime = 0; if (!this._pointMode && !this._connectMode && !this._measureMode && !this._angleMode) el.style.cursor = 'grabbing'; }); window.addEventListener('pointerup', e => { const wasDrag = this._clickStart && (Math.abs(e.clientX - this._clickStart.x) > 4 || Math.abs(e.clientY - this._clickStart.y) > 4); this._drag = false; if (this._pointMode) { el.style.cursor = 'cell'; if (!wasDrag) this._onPointClick(e); } else if (this._connectMode) { el.style.cursor = 'pointer'; if (!wasDrag) this._onConnectClick(e); } else if (this._measureMode) { el.style.cursor = 'crosshair'; if (!wasDrag) this._onMeasureClick(e); } else if (this._angleMode) { el.style.cursor = 'crosshair'; if (!wasDrag) this._onAngleClick(e); } else el.style.cursor = 'grab'; }); window.addEventListener('pointermove', e => { this._onHoverMove(e); if (!this._drag) return; this._rotY += (e.clientX - this._prevX) * 0.007; this._rotX += (e.clientY - this._prevY) * 0.007; this._rotX = Math.max(-1.4, Math.min(1.4, this._rotX)); this._prevX = e.clientX; this._prevY = e.clientY; this._idleTime = 0; }); el.addEventListener('wheel', e => { e.preventDefault(); this._dist = Math.max(4, Math.min(40, this._dist + e.deltaY * 0.02)); }, { passive: false }); /* touch — orbit + pinch zoom */ this._touchDist = 0; el.addEventListener('touchstart', e => { if (e.touches.length === 1) { this._drag = true; this._prevX = e.touches[0].clientX; this._prevY = e.touches[0].clientY; this._autoSpin = false; this._idleTime = 0; } else if (e.touches.length === 2) { this._drag = false; const dx = e.touches[0].clientX - e.touches[1].clientX; const dy = e.touches[0].clientY - e.touches[1].clientY; this._touchDist = Math.sqrt(dx * dx + dy * dy); } }, { passive: true }); el.addEventListener('touchmove', e => { if (e.touches.length === 2) { // pinch zoom const dx = e.touches[0].clientX - e.touches[1].clientX; const dy = e.touches[0].clientY - e.touches[1].clientY; const newDist = Math.sqrt(dx * dx + dy * dy); if (this._touchDist > 0) { const scale = this._touchDist / newDist; this._dist = Math.max(4, Math.min(40, this._dist * scale)); } this._touchDist = newDist; return; } if (!this._drag || e.touches.length !== 1) return; const t = e.touches[0]; this._rotY += (t.clientX - this._prevX) * 0.007; this._rotX += (t.clientY - this._prevY) * 0.007; this._rotX = Math.max(-1.4, Math.min(1.4, this._rotX)); this._prevX = t.clientX; this._prevY = t.clientY; }, { passive: true }); el.addEventListener('touchend', () => { this._drag = false; this._touchDist = 0; }); /* resize */ this._ro = new ResizeObserver(() => this.fit()); this._ro.observe(container); /* groups */ this._figGroup = new THREE.Group(); this._labelGroup = new THREE.Group(); this._sectionGroup = new THREE.Group(); this._sphereGroup = new THREE.Group(); this._measureGroup = new THREE.Group(); this._gridGroup = 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._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, ...]] 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._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 = []; if (!on) { this._clearGroup(this._measureGroup); this._measurements = []; } this.renderer.domElement.style.cursor = on ? 'crosshair' : 'grab'; } 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; } 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}`] }; } default: return { V: 0, S: 0, S_side: 0, d: 0, h: 0, formulas: [] }; } } getSectionArea() { if (!this.showSection || !this._sectionPolygon || !this._sectionPolygon.length) return 0; return this._polygonArea(this._sectionPolygon); } info() { const f = this.getFormulas(); return { type: this.figureType, V: f.V, S: f.S, S_side: f.S_side, d: f.d, h: f.h, sectionArea: this.showSection ? this.getSectionArea() : null, inscribedR: this.showInscribed ? this._inscribedRadius() : null, circumscribedR: this.showCircumscribed ? this._circumscribedRadius() : null, customPoints: this._customPoints.length, connections: this._connections.length, }; } fit() { const w = this.container.clientWidth || 600; const h = this.container.clientHeight || 400; this.camera.aspect = w / h; this.camera.updateProjectionMatrix(); this.renderer.setSize(w, h); } play() { if (!this._running) { this._running = true; this._loop(); } } stop() { this._running = false; } pause() { this._running = false; } /* ════════════════ GRID + AXES ════════════════ */ _buildGrid() { this._clearGroup(this._gridGroup); if (this.showGrid) { const grid = new THREE.GridHelper(20, 20, 0x222244, 0x151530); grid.position.y = -0.01; this._gridGroup.add(grid); } if (this.showAxes) { const axes = new THREE.AxesHelper(6); axes.material.transparent = true; axes.material.opacity = 0.4; this._gridGroup.add(axes); } } /* ════════════════ FIGURE BUILDER ════════════════ */ _buildFigure() { this._clearGroup(this._figGroup); this._clearGroup(this._labelGroup); this._vertices = []; this._edges = []; this._faces = []; const builders = { cube: () => this._buildCube(), parallelepiped: () => this._buildParallelepiped(), pyramid: () => this._buildPyramid(), tetrahedron: () => this._buildTetrahedron(), cylinder: () => this._buildCylinder(), cone: () => this._buildCone(), trunccone: () => this._buildTruncCone(), sphere: () => this._buildSphere(), prism: () => this._buildPrism(), }; (builders[this.figureType] || builders.cube)(); this._updateSection(); this._updateSpheres(); this._drawHeightLine(); this._drawApothemLine(); this._drawDiagonals(); this._drawMidpoints(); } /* ── 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: two vertical lines + center 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) }); 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 }); 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)), }); } 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(); } /* ════════════════ 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; } _addMeshFromFaces(color) { // Build a single mesh from faces const positions = []; const indices = []; let vi = 0; for (const face of this._faces) { const base = vi; for (const v of face) { positions.push(v.x, v.y, v.z); vi++; } // triangulate fan for (let i = 1; i < face.length - 1; i++) { indices.push(base, base + i, base + i + 1); } } const geo = new THREE.BufferGeometry(); geo.setAttribute('position', new THREE.Float32BufferAttribute(positions, 3)); geo.setIndex(indices); geo.computeVertexNormals(); const mat = new THREE.MeshPhysicalMaterial({ color, transparent: true, opacity: this.opacity, side: THREE.DoubleSide, metalness: 0.05, roughness: 0.4, clearcoat: 0.2, depthWrite: false, }); this._figGroup.add(new THREE.Mesh(geo, mat)); } _addEdges(opac = 0.8) { if (!this.showEdges) return; for (const e of this._edges) { const pts = [e.from, e.to]; const geo = new THREE.BufferGeometry().setFromPoints(pts); const mat = new THREE.LineBasicMaterial({ color: 0xFFFFFF, transparent: true, opacity: opac, linewidth: 2 }); this._figGroup.add(new THREE.Line(geo, mat)); } } _addVerticesAndLabels() { for (const v of this._vertices) { if (this.showVertices) { const sGeo = new THREE.SphereGeometry(0.08, 12, 12); const sMat = new THREE.MeshBasicMaterial({ color: 0xFFFFFF }); const sphere = new THREE.Mesh(sGeo, sMat); sphere.position.copy(v.pos); this._figGroup.add(sphere); } if (this.showLabels) { const sprite = this._makeTextSprite(v.label); sprite.position.copy(v.pos).add(new THREE.Vector3(0.15, 0.25, 0)); this._labelGroup.add(sprite); } } } _makeTextSprite(text, color = '#ffffff', size = 64) { const canvas = document.createElement('canvas'); canvas.width = 128; canvas.height = 64; const ctx = canvas.getContext('2d'); ctx.font = `bold ${size}px Manrope, sans-serif`; ctx.fillStyle = color; ctx.textAlign = 'center'; ctx.textBaseline = 'middle'; ctx.fillText(text, 64, 32); const tex = new THREE.CanvasTexture(canvas); tex.minFilter = THREE.LinearFilter; const mat = new THREE.SpriteMaterial({ map: tex, transparent: true, depthTest: false }); const sprite = new THREE.Sprite(mat); sprite.scale.set(0.8, 0.4, 1); return sprite; } /* ════════════════ SECTION PLANE ════════════════ */ _updateSection() { this._clearGroup(this._sectionGroup); this._sectionPolygon = null; if (!this.showSection) return; const figH = this._figureHeight(); const t = this.sectionType; if (t === 'horizontal') { const y = figH * this.sectionHeight; this._sectionPolygon = this._sliceAtY(y); if (this._sectionPolygon.length >= 3) { this._drawSectionPolygon(this._sectionPolygon, y); // translucent plane const planeGeo = new THREE.PlaneGeometry(16, 16); const planeMat = new THREE.MeshBasicMaterial({ color: 0x06D6E0, transparent: true, opacity: 0.08, side: THREE.DoubleSide }); const planeMesh = new THREE.Mesh(planeGeo, planeMat); planeMesh.rotation.x = -Math.PI/2; planeMesh.position.y = y; this._sectionGroup.add(planeMesh); } } else if (t === 'diagonal') { this._sectionPolygon = this._sliceDiagonal(); if (this._sectionPolygon.length >= 3) { this._drawSectionPolygon3D(this._sectionPolygon); this._drawSectionInfo(this._sectionPolygon); } } else if (t === 'custom') { // Need ≥3 custom points to define a plane if (this._customPoints.length >= 3) { const pts3 = this._customPoints.slice(0, 3).map(p => p.pos); this._sectionPolygon = this._sliceByPlane(pts3[0], pts3[1], pts3[2]); if (this._sectionPolygon.length >= 3) { this._drawSectionPolygon3D(this._sectionPolygon); this._drawSectionInfo(this._sectionPolygon); } } } } _figureHeight() { const p = this.params; switch (this.figureType) { case 'cube': return p.a; case 'parallelepiped': return p.c; case 'pyramid': case 'prism': 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; default: return p.h || p.a || 4; } } _sliceAtY(y) { // Find intersections of all edges with horizontal plane y const points = []; for (const e of this._edges) { const y1 = e.from.y, y2 = e.to.y; if ((y1 <= y && y2 >= y) || (y2 <= y && y1 >= y)) { if (Math.abs(y2 - y1) < 1e-9) continue; const t = (y - y1) / (y2 - y1); if (t < -0.001 || t > 1.001) continue; const pt = new THREE.Vector3().lerpVectors(e.from, e.to, t); points.push(pt); } } // For cylinders/cones/spheres with no explicit edges, generate circle if (points.length < 3) { const circleR = this._radiusAtHeight(y); if (circleR > 0.01) { const n = 48; for (let i = 0; i < n; i++) { const a = (i/n)*Math.PI*2; points.push(new THREE.Vector3(circleR*Math.cos(a), y, circleR*Math.sin(a))); } } } // Sort by angle from centroid return this._sortByAngle(points); } _radiusAtHeight(y) { const p = this.params; const fh = this._figureHeight(); const t = Math.max(0, Math.min(1, y / fh)); switch (this.figureType) { case 'cylinder': return p.r; case 'cone': return p.r * (1 - t); case 'trunccone': return p.R + (p.r - p.R) * t; case 'sphere': { const dy = y - p.r; // center at (0, r, 0) const r2 = p.r**2 - dy**2; return r2 > 0 ? Math.sqrt(r2) : 0; } default: return 0; } } _sliceDiagonal() { const fh = this._figureHeight(); const y = fh * this.sectionHeight; // Tilt angle: sectionAngle 0=horizontal, 1=nearly vertical (~80°) const tiltRad = this.sectionAngle * Math.PI * 0.44; // 0..~80° // Normal rotated from vertical (0,1,0) toward X axis const normal = new THREE.Vector3(Math.sin(tiltRad), Math.cos(tiltRad), 0).normalize(); const pointOnPlane = new THREE.Vector3(0, y, 0); return this._sliceByNormal(normal, pointOnPlane); } _sliceByPlane(p1, p2, p3) { // Plane through 3 points const v1 = new THREE.Vector3().subVectors(p2, p1); const v2 = new THREE.Vector3().subVectors(p3, p1); const normal = new THREE.Vector3().crossVectors(v1, v2).normalize(); if (normal.length() < 1e-9) return []; return this._sliceByNormal(normal, p1); } _sliceByNormal(normal, pointOnPlane) { const d = -normal.dot(pointOnPlane); const points = []; // Intersect with all edges for (const e of this._edges) { const d1 = normal.dot(e.from) + d; const d2 = normal.dot(e.to) + d; if ((d1 <= 0 && d2 >= 0) || (d2 <= 0 && d1 >= 0)) { if (Math.abs(d2 - d1) < 1e-9) continue; const t = -d1 / (d2 - d1); if (t < -0.001 || t > 1.001) continue; points.push(new THREE.Vector3().lerpVectors(e.from, e.to, Math.max(0, Math.min(1, t)))); } } // For cylinders/cones/spheres — sample the intersection curve if (points.length < 3 && ['cylinder','cone','trunccone','sphere'].includes(this.figureType)) { const fh = this._figureHeight(); const samples = 64; for (let i = 0; i < samples; i++) { const angle = (i / samples) * Math.PI * 2; // Walk along height, find where the plane intersects at this angle for (let step = 0; step <= 100; step++) { const y = (step / 100) * fh; const r = this._radiusAtHeight(y); if (r < 0.01) continue; const pt = new THREE.Vector3(r * Math.cos(angle), y, r * Math.sin(angle)); const dist = normal.dot(pt) + d; if (Math.abs(dist) < fh * 0.012) { points.push(pt); break; } } } } // Sort into convex polygon order (project onto plane, sort by angle) return this._sortByAngle3D(points, normal); } _sortByAngle(points) { if (points.length < 3) return points; const cx = points.reduce((s,p) => s+p.x, 0) / points.length; const cz = points.reduce((s,p) => s+p.z, 0) / points.length; points.sort((a,b) => Math.atan2(a.z-cz, a.x-cx) - Math.atan2(b.z-cz, b.x-cx)); return points; } _sortByAngle3D(points, normal) { if (points.length < 3) return points; // Remove near-duplicates const unique = [points[0]]; for (let i = 1; i < points.length; i++) { let dup = false; for (const u of unique) { if (points[i].distanceTo(u) < 0.01) { dup = true; break; } } if (!dup) unique.push(points[i]); } if (unique.length < 3) return unique; // Centroid const c = new THREE.Vector3(); unique.forEach(p => c.add(p)); c.divideScalar(unique.length); // Build local 2D basis on the plane const u = new THREE.Vector3().subVectors(unique[0], c).normalize(); const v = new THREE.Vector3().crossVectors(normal, u).normalize(); // Project and sort by angle unique.sort((a, b) => { const da = new THREE.Vector3().subVectors(a, c); const db = new THREE.Vector3().subVectors(b, c); return Math.atan2(da.dot(v), da.dot(u)) - Math.atan2(db.dot(v), db.dot(u)); }); return unique; } _drawSectionInfo(pts) { if (pts.length < 3) return; const area = this._polygonArea(pts); let perimeter = 0; for (let i = 0; i < pts.length; i++) { perimeter += pts[i].distanceTo(pts[(i + 1) % pts.length]); } // Label at centroid const c = new THREE.Vector3(); pts.forEach(p => c.add(p)); c.divideScalar(pts.length); const label = this._makeTextSprite(`S=${area.toFixed(1)} P=${perimeter.toFixed(1)}`, '#06D6E0', 36); label.position.copy(c).add(new THREE.Vector3(0, 0.4, 0)); label.scale.set(1.6, 0.5, 1); this._sectionGroup.add(label); // Vertex markers on section polygon for (const p of pts) { const sGeo = new THREE.SphereGeometry(0.06, 8, 8); const sMat = new THREE.MeshBasicMaterial({ color: 0x06D6E0 }); const sm = new THREE.Mesh(sGeo, sMat); sm.position.copy(p); this._sectionGroup.add(sm); } } _drawSectionPolygon(pts, y) { if (pts.length < 3) return; // Fill polygon const shape = new THREE.Shape(); shape.moveTo(pts[0].x, pts[0].z); for (let i = 1; i < pts.length; i++) shape.lineTo(pts[i].x, pts[i].z); shape.closePath(); const geo = new THREE.ShapeGeometry(shape); const mat = new THREE.MeshBasicMaterial({ color: 0x06D6E0, transparent: true, opacity: 0.35, side: THREE.DoubleSide }); const mesh = new THREE.Mesh(geo, mat); mesh.rotation.x = -Math.PI/2; mesh.position.y = y + 0.005; this._sectionGroup.add(mesh); // outline const linePts = [...pts, pts[0]]; const lineGeo = new THREE.BufferGeometry().setFromPoints(linePts); const lineMat = new THREE.LineBasicMaterial({ color: 0x06D6E0, linewidth: 2 }); this._sectionGroup.add(new THREE.Line(lineGeo, lineMat)); } _drawSectionPolygon3D(pts) { if (pts.length < 3) return; const positions = []; const indices = []; pts.forEach(p => positions.push(p.x, p.y, p.z)); for (let i = 1; i < pts.length - 1; i++) indices.push(0, i, i+1); const geo = new THREE.BufferGeometry(); geo.setAttribute('position', new THREE.Float32BufferAttribute(positions, 3)); geo.setIndex(indices); geo.computeVertexNormals(); const mat = new THREE.MeshBasicMaterial({ color: 0x06D6E0, transparent: true, opacity: 0.35, side: THREE.DoubleSide }); this._sectionGroup.add(new THREE.Mesh(geo, mat)); const linePts = [...pts, pts[0]]; const lineGeo = new THREE.BufferGeometry().setFromPoints(linePts); this._sectionGroup.add(new THREE.Line(lineGeo, new THREE.LineBasicMaterial({ color: 0x06D6E0 }))); } _polygonArea(pts) { // Shoelace in 3D — project onto best-fit plane if (pts.length < 3) return 0; let area = 0; const n = pts.length; const cx = pts.reduce((s,p)=>s+p.x,0)/n; const cy = pts.reduce((s,p)=>s+p.y,0)/n; const cz = pts.reduce((s,p)=>s+p.z,0)/n; // Cross-product sum const cross = new THREE.Vector3(0,0,0); for (let i = 0; i < n; i++) { const a = new THREE.Vector3().subVectors(pts[i], new THREE.Vector3(cx,cy,cz)); const b = new THREE.Vector3().subVectors(pts[(i+1)%n], new THREE.Vector3(cx,cy,cz)); cross.add(new THREE.Vector3().crossVectors(a, b)); } return Math.round(cross.length() / 2 * 100) / 100; } /* ════════════════ INSCRIBED / CIRCUMSCRIBED ════════════════ */ _inscribedRadius() { const p = this.params; const PI = Math.PI; switch (this.figureType) { case 'cube': return p.a / 2; case 'parallelepiped': return Math.min(p.a, p.b, p.c) / 2; case 'tetrahedron': return p.a / (2 * Math.sqrt(6)); case 'sphere': return p.r; case 'cylinder': return Math.min(p.r, p.h / 2); case 'cone': { // r_in = r * h / (r + sqrt(r²+h²)) const l = Math.sqrt(p.r ** 2 + p.h ** 2); return p.r * p.h / (p.r + l); } case 'trunccone': { // inscribed sphere radius = h * (R - r) / (2 * sqrt((R-r)² + h²))... approximate // exact for truncated cone: r_in = h / (1 + sqrt(1 + ((R-r)/h)²)) * (not standard) // simpler: r_in = h * min(R, r) / sqrt((R-r)² + h²) — approximate // Actually: for truncated cone, inscribed sphere touches both bases and lateral surface // r_in = h * (R + r - sqrt((R-r)² + h²)) / (2 * (R - r)) when R ≠ r if (Math.abs(p.R - p.r) < 0.001) return Math.min(p.R, p.h / 2); // cylinder-like const l = Math.sqrt((p.R - p.r) ** 2 + p.h ** 2); return p.h * (p.R + p.r - l) / (2 * Math.abs(p.R - p.r)); } case 'pyramid': { // r_in = 3V / S_total const { a, h, n } = p; const sBase = n * a ** 2 / (4 * Math.tan(PI / n)); const apothem = a / (2 * Math.tan(PI / n)); const slantH = Math.sqrt(h ** 2 + apothem ** 2); const sLat = n * a * slantH / 2; const V = sBase * h / 3; return 3 * V / (sBase + sLat); } case 'prism': { // r_in = apothem of base (if h >= 2*apothem), else h/2 const { a, h, n } = p; const apothem = a / (2 * Math.tan(PI / n)); return Math.min(apothem, h / 2); } 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': { // R = sqrt(R_base² + (h/2)²) const { a, h, n } = p; const Rb = a / (2 * Math.sin(PI / n)); return Math.sqrt(Rb ** 2 + (h / 2) ** 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; 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; default: return this._figureHeight() / 2; } } _updateSpheres() { this._clearGroup(this._sphereGroup); if (this.showInscribed) { const r = this._inscribedRadius(); const cy = this._inscribedCenter(); if (r && r > 0) { const geo = new THREE.SphereGeometry(r, 32, 24); const mat = new THREE.MeshPhysicalMaterial({ color: 0x06D6E0, transparent: true, opacity: 0.12, side: THREE.DoubleSide, depthWrite: false, }); const mesh = new THREE.Mesh(geo, mat); mesh.position.y = cy; this._sphereGroup.add(mesh); const wf = new THREE.LineSegments( new THREE.WireframeGeometry(geo), new THREE.LineBasicMaterial({ color: 0x06D6E0, transparent: true, opacity: 0.2 }) ); wf.position.y = cy; this._sphereGroup.add(wf); // radius label const lbl = this._makeTextSprite(`r=${r.toFixed(2)}`, '#06D6E0', 36); lbl.position.set(r * 0.5, cy + r * 0.3, 0); lbl.scale.set(1.0, 0.4, 1); this._sphereGroup.add(lbl); // radius line const lineGeo = new THREE.BufferGeometry().setFromPoints([ new THREE.Vector3(0, cy, 0), new THREE.Vector3(r, cy, 0) ]); const lineMat = new THREE.LineDashedMaterial({ color: 0x06D6E0, dashSize: 0.1, gapSize: 0.06, transparent: true, opacity: 0.7 }); const line = new THREE.Line(lineGeo, lineMat); line.computeLineDistances(); this._sphereGroup.add(line); } } if (this.showCircumscribed) { const R = this._circumscribedRadius(); const cy = this._circumscribedCenter(); if (R && R > 0) { const geo = new THREE.SphereGeometry(R, 32, 24); const mat = new THREE.MeshPhysicalMaterial({ color: 0xF59E0B, transparent: true, opacity: 0.08, side: THREE.DoubleSide, depthWrite: false, }); const mesh = new THREE.Mesh(geo, mat); mesh.position.y = cy; this._sphereGroup.add(mesh); const wf = new THREE.LineSegments( new THREE.WireframeGeometry(geo), new THREE.LineBasicMaterial({ color: 0xF59E0B, transparent: true, opacity: 0.15 }) ); wf.position.y = cy; this._sphereGroup.add(wf); // radius label const lbl = this._makeTextSprite(`R=${R.toFixed(2)}`, '#F59E0B', 36); lbl.position.set(R * 0.5, cy + R * 0.3, 0); lbl.scale.set(1.0, 0.4, 1); this._sphereGroup.add(lbl); // radius line const lineGeo = new THREE.BufferGeometry().setFromPoints([ new THREE.Vector3(0, cy, 0), new THREE.Vector3(R, cy, 0) ]); const lineMat = new THREE.LineDashedMaterial({ color: 0xF59E0B, dashSize: 0.1, gapSize: 0.06, transparent: true, opacity: 0.7 }); const line = new THREE.Line(lineGeo, lineMat); line.computeLineDistances(); this._sphereGroup.add(line); } } } /* ════════════════ 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 picked point 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._measureGroup.add(s); if (this._measurePicks.length === 2) { const [a, b] = this._measurePicks; const dist = a.pos.distanceTo(b.pos); const mid = new THREE.Vector3().addVectors(a.pos, b.pos).multiplyScalar(0.5); // Dashed line const pts = [a.pos, b.pos]; const lineGeo = new THREE.BufferGeometry().setFromPoints(pts); 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(); this._measureGroup.add(line); // Label const label = this._makeTextSprite(`${a.label}${b.label} = ${dist.toFixed(2)}`, '#FFD166', 40); label.position.copy(mid).add(new THREE.Vector3(0, 0.3, 0)); label.scale.set(1.4, 0.5, 1); this._measureGroup.add(label); this._measurements.push({ from: a.label, to: b.label, dist: Math.round(dist * 100) / 100 }); this._measurePicks = []; } } /* ════════════════ POINT MODE — place points on edges ════════════════ */ _screenCoords(e) { const rect = this.renderer.domElement.getBoundingClientRect(); return { mx: ((e.clientX - rect.left) / rect.width) * 2 - 1, my: -((e.clientY - rect.top) / rect.height) * 2 + 1, }; } _onPointClick(e) { const { mx, my } = this._screenCoords(e); // Find the nearest edge in screen space let bestDist = 0.08; // threshold in NDC let bestEdge = -1; let bestT = 0; let bestPos = null; for (let ei = 0; ei < this._edges.length; ei++) { const edge = this._edges[ei]; const p1 = edge.from.clone().project(this.camera); const p2 = edge.to.clone().project(this.camera); // Point-to-segment distance in 2D const dx = p2.x - p1.x, dy = p2.y - p1.y; const lenSq = dx * dx + dy * dy; if (lenSq < 1e-9) continue; let t = ((mx - p1.x) * dx + (my - p1.y) * dy) / lenSq; t = Math.max(0.02, Math.min(0.98, t)); // clamp away from endpoints const px = p1.x + t * dx, py = p1.y + t * dy; const dist = Math.sqrt((mx - px) ** 2 + (my - py) ** 2); if (dist < bestDist) { bestDist = dist; bestEdge = ei; bestT = t; bestPos = new THREE.Vector3().lerpVectors(edge.from, edge.to, t); } } // Also check: click near a vertex snap to vertex for (const v of this._vertices) { const proj = v.pos.clone().project(this.camera); const dist = Math.sqrt((mx - proj.x) ** 2 + (my - proj.y) ** 2); if (dist < 0.06) { bestPos = v.pos.clone(); bestEdge = -2; // special: vertex bestT = 0; bestDist = dist; } } if (!bestPos) return; const label = String(this._nextPointId++); this._customPoints.push({ pos: bestPos, edgeIdx: bestEdge, t: bestT, label, }); this._rebuildPointVisuals(); // If custom section mode and ≥3 points, update section if (this.showSection && this.sectionType === 'custom') this._updateSection(); this._notify(); } _onConnectClick(e) { const { mx, my } = this._screenCoords(e); // Find nearest custom point OR vertex let bestDist = 0.08; let bestIdx = -1; // Check custom points for (let i = 0; i < this._customPoints.length; i++) { const proj = this._customPoints[i].pos.clone().project(this.camera); const dist = Math.sqrt((mx - proj.x) ** 2 + (my - proj.y) ** 2); if (dist < bestDist) { bestDist = dist; bestIdx = i; } } // Also check vertices (mapped as negative indices: -1 vertex 0, -2 vertex 1, etc.) for (let i = 0; i < this._vertices.length; i++) { const proj = this._vertices[i].pos.clone().project(this.camera); const dist = Math.sqrt((mx - proj.x) ** 2 + (my - proj.y) ** 2); if (dist < bestDist) { bestDist = dist; bestIdx = -(i + 100); } // encode vertex } if (bestIdx === -1 && bestIdx !== -(99 + this._vertices.length)) return; // nothing found // Actually check: any valid pick if (bestDist >= 0.08) return; this._connectPicks.push(bestIdx); // Highlight pick const pos = bestIdx >= 0 ? this._customPoints[bestIdx].pos : this._vertices[-(bestIdx + 100)].pos; const sGeo = new THREE.SphereGeometry(0.12, 10, 10); const sMat = new THREE.MeshBasicMaterial({ color: 0xF59E0B }); const s = new THREE.Mesh(sGeo, sMat); s.position.copy(pos); this._pointGroup.add(s); if (this._connectPicks.length === 2) { const [idxA, idxB] = this._connectPicks; if (idxA !== idxB) { this._connections.push({ from: idxA, to: idxB }); } this._connectPicks = []; this._rebuildPointVisuals(); this._notify(); } } _getPointPos(idx) { if (idx >= 0) return this._customPoints[idx]?.pos; return this._vertices[-(idx + 100)]?.pos; } _getPointLabel(idx) { if (idx >= 0) return this._customPoints[idx]?.label || '?'; return this._vertices[-(idx + 100)]?.label || '?'; } _rebuildPointVisuals() { this._clearGroup(this._pointGroup); // Draw custom points for (const pt of this._customPoints) { // Sphere marker const sGeo = new THREE.SphereGeometry(0.1, 10, 10); const sMat = new THREE.MeshBasicMaterial({ color: 0xFFD166 }); const s = new THREE.Mesh(sGeo, sMat); s.position.copy(pt.pos); this._pointGroup.add(s); // Label const label = this._makeTextSprite(pt.label, '#FFD166', 40); label.position.copy(pt.pos).add(new THREE.Vector3(0.2, 0.2, 0)); label.scale.set(0.6, 0.3, 1); this._pointGroup.add(label); } // Draw connections for (const conn of this._connections) { const posA = this._getPointPos(conn.from); const posB = this._getPointPos(conn.to); if (!posA || !posB) continue; const lineGeo = new THREE.BufferGeometry().setFromPoints([posA, posB]); const lineMat = new THREE.LineDashedMaterial({ color: 0xF59E0B, dashSize: 0.12, gapSize: 0.06, transparent: true, opacity: 0.9, }); const line = new THREE.Line(lineGeo, lineMat); line.computeLineDistances(); this._pointGroup.add(line); // Distance label const dist = posA.distanceTo(posB); const mid = new THREE.Vector3().addVectors(posA, posB).multiplyScalar(0.5); const lbl = this._makeTextSprite(dist.toFixed(2), '#F59E0B', 36); lbl.position.copy(mid).add(new THREE.Vector3(0, 0.25, 0)); lbl.scale.set(0.8, 0.4, 1); this._pointGroup.add(lbl); } } /* ════════════════ HEIGHT LINE ════════════════ */ _drawHeightLine() { if (!this.showHeight) return; const p = this.params; const fh = this._figureHeight(); let from, to, label; switch (this.figureType) { case 'cube': case 'parallelepiped': case 'prism': case 'cylinder': { // vertical height: center of bottom base center of top base from = new THREE.Vector3(0, 0, 0); to = new THREE.Vector3(0, fh, 0); label = `h = ${fh.toFixed(2)}`; break; } case 'pyramid': { // apex to base center from = new THREE.Vector3(0, 0, 0); to = new THREE.Vector3(0, p.h, 0); label = `h = ${p.h.toFixed(2)}`; break; } case 'tetrahedron': { const h = p.a * Math.sqrt(2 / 3); from = new THREE.Vector3(0, 0, 0); to = new THREE.Vector3(0, h, 0); label = `h = ${h.toFixed(2)}`; break; } case 'cone': { from = new THREE.Vector3(0, 0, 0); to = new THREE.Vector3(0, p.h, 0); label = `h = ${p.h.toFixed(2)}`; break; } case 'trunccone': { from = new THREE.Vector3(0, 0, 0); to = new THREE.Vector3(0, p.h, 0); label = `h = ${p.h.toFixed(2)}`; break; } case 'sphere': { from = new THREE.Vector3(0, 0, 0); to = new THREE.Vector3(0, 2 * p.r, 0); label = `d = ${(2 * p.r).toFixed(2)}`; break; } default: return; } // dashed line const lineGeo = new THREE.BufferGeometry().setFromPoints([from, to]); const lineMat = new THREE.LineDashedMaterial({ color: 0xF9A8D4, dashSize: 0.15, gapSize: 0.08, transparent: true, opacity: 0.85, }); const line = new THREE.Line(lineGeo, lineMat); line.computeLineDistances(); this._figGroup.add(line); // small dots at endpoints for (const pt of [from, to]) { const sGeo = new THREE.SphereGeometry(0.06, 8, 8); const sMat = new THREE.MeshBasicMaterial({ color: 0xF9A8D4 }); const s = new THREE.Mesh(sGeo, sMat); s.position.copy(pt); this._figGroup.add(s); } // label at midpoint const mid = new THREE.Vector3().addVectors(from, to).multiplyScalar(0.5); const lbl = this._makeTextSprite(label, '#F9A8D4', 36); lbl.position.copy(mid).add(new THREE.Vector3(0.4, 0, 0)); lbl.scale.set(1.2, 0.4, 1); this._labelGroup.add(lbl); // right-angle marker at base (for pyramid/cone/tetrahedron) if (['pyramid', 'tetrahedron', 'cone'].includes(this.figureType)) { this._drawRightAngleMarker(from, new THREE.Vector3(0, 1, 0), new THREE.Vector3(1, 0, 0), 0.3); } } _drawRightAngleMarker(origin, dir1, dir2, size) { const p1 = origin.clone().add(dir1.clone().normalize().multiplyScalar(size)); const p2 = origin.clone().add(dir2.clone().normalize().multiplyScalar(size)); const p3 = p1.clone().add(dir2.clone().normalize().multiplyScalar(size)); const pts = [p1, p3, p2]; const geo = new THREE.BufferGeometry().setFromPoints(pts); const mat = new THREE.LineBasicMaterial({ color: 0xF9A8D4, transparent: true, opacity: 0.6 }); this._figGroup.add(new THREE.Line(geo, mat)); } /* ════════════════ APOTHEM LINE ════════════════ */ _drawApothemLine() { if (!this.showApothem) return; const p = this.params; const PI = Math.PI; if (this.figureType === 'pyramid') { const { a, h, n } = p; const apothem = a / (2 * Math.tan(PI / n)); // base apothem const slantH = Math.sqrt(h ** 2 + apothem ** 2); // slant apothem // Base apothem: center of base to midpoint of first base edge const midEdge = this._getBaseMidpoint(n, a, 0); this._drawDashedSegment( new THREE.Vector3(0, 0, 0), midEdge, `a_осн = ${apothem.toFixed(2)}`, '#7BF5A4' ); // Slant apothem: apex to midpoint of first base edge this._drawDashedSegment( new THREE.Vector3(0, h, 0), midEdge, `a_бок = ${slantH.toFixed(2)}`, '#60a5fa' ); // Right angle at midEdge (between base apothem and base edge direction) const edgeDir = this._getBaseEdgeDir(n, a, 0); const toCenter = new THREE.Vector3().subVectors(new THREE.Vector3(0, 0, 0), midEdge).normalize(); this._drawRightAngleMarker(midEdge, toCenter, edgeDir, 0.25); } else if (this.figureType === 'prism') { const { a, h, n } = p; const apothem = a / (2 * Math.tan(PI / n)); // Base apothem const midEdge = this._getBaseMidpoint(n, a, 0); this._drawDashedSegment( new THREE.Vector3(0, 0, 0), midEdge, `a = ${apothem.toFixed(2)}`, '#7BF5A4' ); // Right angle marker const edgeDir = this._getBaseEdgeDir(n, a, 0); const toCenter = new THREE.Vector3().subVectors(new THREE.Vector3(0, 0, 0), midEdge).normalize(); this._drawRightAngleMarker(midEdge, toCenter, edgeDir, 0.25); } else if (this.figureType === 'cone') { // Slant height (образующая) const l = Math.sqrt(p.r ** 2 + p.h ** 2); this._drawDashedSegment( new THREE.Vector3(0, p.h, 0), new THREE.Vector3(p.r, 0, 0), `l = ${l.toFixed(2)}`, '#60a5fa' ); } else if (this.figureType === 'trunccone') { const l = Math.sqrt((p.R - p.r) ** 2 + p.h ** 2); this._drawDashedSegment( new THREE.Vector3(p.r, p.h, 0), new THREE.Vector3(p.R, 0, 0), `l = ${l.toFixed(2)}`, '#60a5fa' ); } } _getBaseMidpoint(n, a, edgeIndex) { const r = a / (2 * Math.sin(Math.PI / n)); const a1 = (edgeIndex / n) * Math.PI * 2 - Math.PI / 2; const a2 = ((edgeIndex + 1) / n) * Math.PI * 2 - Math.PI / 2; return new THREE.Vector3( (r * Math.cos(a1) + r * Math.cos(a2)) / 2, 0, (r * Math.sin(a1) + r * Math.sin(a2)) / 2 ); } _getBaseEdgeDir(n, a, edgeIndex) { const r = a / (2 * Math.sin(Math.PI / n)); const a1 = (edgeIndex / n) * Math.PI * 2 - Math.PI / 2; const a2 = ((edgeIndex + 1) / n) * Math.PI * 2 - Math.PI / 2; const p1 = new THREE.Vector3(r * Math.cos(a1), 0, r * Math.sin(a1)); const p2 = new THREE.Vector3(r * Math.cos(a2), 0, r * Math.sin(a2)); return new THREE.Vector3().subVectors(p2, p1).normalize(); } _drawDashedSegment(from, to, label, color) { const lineGeo = new THREE.BufferGeometry().setFromPoints([from, to]); const lineMat = new THREE.LineDashedMaterial({ color: new THREE.Color(color), dashSize: 0.12, gapSize: 0.06, transparent: true, opacity: 0.85, }); const line = new THREE.Line(lineGeo, lineMat); line.computeLineDistances(); this._figGroup.add(line); // dots for (const pt of [from, to]) { const sGeo = new THREE.SphereGeometry(0.05, 8, 8); const sMat = new THREE.MeshBasicMaterial({ color: new THREE.Color(color) }); const s = new THREE.Mesh(sGeo, sMat); s.position.copy(pt); this._figGroup.add(s); } // label const mid = new THREE.Vector3().addVectors(from, to).multiplyScalar(0.5); const lbl = this._makeTextSprite(label, color, 34); lbl.position.copy(mid).add(new THREE.Vector3(0.3, 0.2, 0)); lbl.scale.set(1.2, 0.4, 1); this._labelGroup.add(lbl); } /* ════════════════ ANGLE MEASUREMENT MODES ════════════════ */ _pickNearestPoint(e) { const { mx, my } = this._screenCoords(e); let bestDist = 0.08; let bestPick = null; for (const v of this._vertices) { const projected = v.pos.clone().project(this.camera); const d = Math.sqrt((projected.x - mx) ** 2 + (projected.y - my) ** 2); if (d < bestDist) { bestDist = d; bestPick = { pos: v.pos.clone(), label: v.label }; } } for (const cp of this._customPoints) { const projected = cp.pos.clone().project(this.camera); const d = Math.sqrt((projected.x - mx) ** 2 + (projected.y - my) ** 2); if (d < bestDist) { bestDist = d; bestPick = { pos: cp.pos.clone(), label: cp.label }; } } return bestPick; } _pickNearestFace(e) { // Pick the face whose projected centroid is closest to click const { mx, my } = this._screenCoords(e); let bestDist = 0.15; let bestFace = null; let bestIdx = -1; for (let fi = 0; fi < this._faces.length; fi++) { const face = this._faces[fi]; const c = new THREE.Vector3(); face.forEach(v => c.add(v)); c.divideScalar(face.length); const proj = c.clone().project(this.camera); const d = Math.sqrt((proj.x - mx) ** 2 + (proj.y - my) ** 2); if (d < bestDist) { bestDist = d; bestFace = face; bestIdx = fi; } } return bestFace; } _pickNearestEdge(e) { const { mx, my } = this._screenCoords(e); let bestDist = 0.06; let bestEdge = null; for (const edge of this._edges) { const p1 = edge.from.clone().project(this.camera); const p2 = edge.to.clone().project(this.camera); const mid = { x: (p1.x + p2.x) / 2, y: (p1.y + p2.y) / 2 }; const d = Math.sqrt((mid.x - mx) ** 2 + (mid.y - my) ** 2); if (d < bestDist) { bestDist = d; bestEdge = edge; } } return bestEdge; } _highlightPick(pos, color = 0xFFD166) { const sGeo = new THREE.SphereGeometry(0.12, 10, 10); const sMat = new THREE.MeshBasicMaterial({ color }); const s = new THREE.Mesh(sGeo, sMat); s.position.copy(pos); this._angleGroup.add(s); } _onAngleClick(e) { if (!this._angleMode) return; if (this._angleMode === 'edge') { this._onEdgeAngleClick(e); } else if (this._angleMode === 'linePlane') { this._onLinePlaneAngleClick(e); } else if (this._angleMode === 'dihedral') { this._onDihedralAngleClick(e); } else if (this._angleMode === 'pointPlane') { this._onPointPlaneClick(e); } } /* ── Edge angle: pick 3 points (B is vertex, angle ∠ABC) ── */ _onEdgeAngleClick(e) { const pick = this._pickNearestPoint(e); if (!pick) return; this._anglePicks.push(pick); this._highlightPick(pick.pos, this._anglePicks.length === 2 ? 0xEF476F : 0xFFD166); if (this._anglePicks.length === 3) { const [A, B, C] = this._anglePicks; const BA = new THREE.Vector3().subVectors(A.pos, B.pos); const BC = new THREE.Vector3().subVectors(C.pos, B.pos); const cosAngle = BA.dot(BC) / (BA.length() * BC.length()); const angle = Math.acos(Math.max(-1, Math.min(1, cosAngle))) * 180 / Math.PI; // Draw the angle arc this._drawAngleArc(B.pos, BA, BC, angle, 0.6, '#EF476F'); // Label const bisect = new THREE.Vector3().addVectors( BA.clone().normalize(), BC.clone().normalize() ).normalize().multiplyScalar(0.8); const lblPos = B.pos.clone().add(bisect); const lbl = this._makeTextSprite( `∠${A.label}${B.label}${C.label} = ${angle.toFixed(1)}°`, '#EF476F', 36 ); lbl.position.copy(lblPos); lbl.scale.set(2.0, 0.5, 1); this._angleGroup.add(lbl); // Lines this._drawAngleLine(B.pos, A.pos, '#EF476F'); this._drawAngleLine(B.pos, C.pos, '#EF476F'); this._anglePicks = []; } } /* ── Line-Plane angle: pick 2 points (line), then 1 face ── */ _onLinePlaneAngleClick(e) { if (this._anglePicks.length < 2) { // Picking line endpoints const pick = this._pickNearestPoint(e); if (!pick) return; this._anglePicks.push(pick); this._highlightPick(pick.pos); if (this._anglePicks.length === 2) { // Draw the line this._drawAngleLine(this._anglePicks[0].pos, this._anglePicks[1].pos, '#60a5fa'); } } else { // Pick a face const face = this._pickNearestFace(e); if (!face || face.length < 3) return; const [A, B] = this._anglePicks; const lineDir = new THREE.Vector3().subVectors(B.pos, A.pos).normalize(); // Face normal const v1 = new THREE.Vector3().subVectors(face[1], face[0]); const v2 = new THREE.Vector3().subVectors(face[2], face[0]); const normal = new THREE.Vector3().crossVectors(v1, v2).normalize(); // Angle between line and plane = 90° - angle between line and normal const sinAngle = Math.abs(lineDir.dot(normal)); const angle = Math.asin(Math.max(0, Math.min(1, sinAngle))) * 180 / Math.PI; // Highlight face const positions = []; const indices = []; face.forEach(v => positions.push(v.x, v.y, v.z)); for (let i = 1; i < face.length - 1; i++) indices.push(0, i, i + 1); const geo = new THREE.BufferGeometry(); geo.setAttribute('position', new THREE.Float32BufferAttribute(positions, 3)); geo.setIndex(indices); const mat = new THREE.MeshBasicMaterial({ color: 0x60a5fa, transparent: true, opacity: 0.2, side: THREE.DoubleSide }); this._angleGroup.add(new THREE.Mesh(geo, mat)); // Label at face centroid const c = new THREE.Vector3(); face.forEach(v => c.add(v)); c.divideScalar(face.length); const lbl = this._makeTextSprite( `∠(${A.label}${B.label}, пл) = ${angle.toFixed(1)}°`, '#60a5fa', 36 ); lbl.position.copy(c).add(new THREE.Vector3(0, 0.5, 0)); lbl.scale.set(2.4, 0.5, 1); this._angleGroup.add(lbl); // Draw projection of line onto plane const proj = lineDir.clone().sub(normal.clone().multiplyScalar(lineDir.dot(normal))).normalize(); const projEnd = A.pos.clone().add(proj.multiplyScalar(A.pos.distanceTo(B.pos))); this._drawAngleLine(A.pos, projEnd, '#60a5fa', true); this._anglePicks = []; } } /* ── Dihedral angle: pick 2 points of shared edge, then auto-find adjacent faces ── */ _onDihedralAngleClick(e) { const pick = this._pickNearestPoint(e); if (!pick) return; this._anglePicks.push(pick); this._highlightPick(pick.pos, 0xc4b5fd); if (this._anglePicks.length === 2) { const [P1, P2] = this._anglePicks; const edgeDir = new THREE.Vector3().subVectors(P2.pos, P1.pos); // Find two faces sharing this edge (both contain P1 and P2) const adjFaces = []; const eps = 0.1; for (const face of this._faces) { let hasP1 = false, hasP2 = false; for (const v of face) { if (v.distanceTo(P1.pos) < eps) hasP1 = true; if (v.distanceTo(P2.pos) < eps) hasP2 = true; } if (hasP1 && hasP2) adjFaces.push(face); } if (adjFaces.length >= 2) { // Compute normals of both faces const normal1 = this._faceNormal(adjFaces[0]); const normal2 = this._faceNormal(adjFaces[1]); // Dihedral angle = π - angle between outward normals const cosAngle = normal1.dot(normal2); const rawAngle = Math.acos(Math.max(-1, Math.min(1, cosAngle))) * 180 / Math.PI; // Dihedral is the interior angle const dihedralAngle = 180 - rawAngle; // Highlight both faces for (const face of [adjFaces[0], adjFaces[1]]) { const positions = []; const indices = []; face.forEach(v => positions.push(v.x, v.y, v.z)); for (let i = 1; i < face.length - 1; i++) indices.push(0, i, i + 1); const geo = new THREE.BufferGeometry(); geo.setAttribute('position', new THREE.Float32BufferAttribute(positions, 3)); geo.setIndex(indices); const mat = new THREE.MeshBasicMaterial({ color: 0xc4b5fd, transparent: true, opacity: 0.2, side: THREE.DoubleSide }); this._angleGroup.add(new THREE.Mesh(geo, mat)); } // Label at edge midpoint const mid = new THREE.Vector3().addVectors(P1.pos, P2.pos).multiplyScalar(0.5); const lbl = this._makeTextSprite( `∠дв(${P1.label}${P2.label}) = ${dihedralAngle.toFixed(1)}°`, '#c4b5fd', 36 ); lbl.position.copy(mid).add(new THREE.Vector3(0, 0.5, 0.3)); lbl.scale.set(2.4, 0.5, 1); this._angleGroup.add(lbl); // Draw the edge this._drawAngleLine(P1.pos, P2.pos, '#c4b5fd'); } else { // Not enough adjacent faces — show error sprite const mid = new THREE.Vector3().addVectors(P1.pos, P2.pos).multiplyScalar(0.5); const lbl = this._makeTextSprite('Нет общего ребра', '#ff6b6b', 36); lbl.position.copy(mid).add(new THREE.Vector3(0, 0.5, 0)); lbl.scale.set(2.0, 0.5, 1); this._angleGroup.add(lbl); } this._anglePicks = []; } } _faceNormal(face) { if (face.length < 3) return new THREE.Vector3(0, 1, 0); const v1 = new THREE.Vector3().subVectors(face[1], face[0]); const v2 = new THREE.Vector3().subVectors(face[2], face[0]); return new THREE.Vector3().crossVectors(v1, v2).normalize(); } _drawAngleArc(center, dir1, dir2, angleDeg, radius, color) { const n1 = dir1.clone().normalize(); const n2 = dir2.clone().normalize(); const angleRad = angleDeg * Math.PI / 180; const steps = Math.max(8, Math.round(angleDeg / 5)); const pts = []; // Build rotation from n1 toward n2 const axis = new THREE.Vector3().crossVectors(n1, n2).normalize(); if (axis.length() < 0.001) return; // parallel vectors for (let i = 0; i <= steps; i++) { const t = i / steps; const a = t * angleRad; const rotated = n1.clone().applyAxisAngle(axis, a).multiplyScalar(radius); pts.push(center.clone().add(rotated)); } const geo = new THREE.BufferGeometry().setFromPoints(pts); const mat = new THREE.LineBasicMaterial({ color: new THREE.Color(color), transparent: true, opacity: 0.8 }); this._angleGroup.add(new THREE.Line(geo, mat)); } _drawAngleLine(from, to, color, dashed = false) { const geo = new THREE.BufferGeometry().setFromPoints([from, to]); let mat; if (dashed) { mat = new THREE.LineDashedMaterial({ color: new THREE.Color(color), dashSize: 0.12, gapSize: 0.06, transparent: true, opacity: 0.7, }); } else { mat = new THREE.LineBasicMaterial({ color: new THREE.Color(color), transparent: true, opacity: 0.7 }); } const line = new THREE.Line(geo, mat); if (dashed) line.computeLineDistances(); this._angleGroup.add(line); } /* ════════════════ DIAGONALS ════════════════ */ _drawDiagonals() { if (!this.showDiagonals) return; const t = this.figureType; if (t === 'cube' || t === 'parallelepiped') { // 8 vertices: 0-3 bottom, 4-7 top (from _buildBox) const v = this._vertices.map(vt => vt.pos); if (v.length < 8) return; // Space diagonals (4) const spaceDiags = [[0,6],[1,7],[2,4],[3,5]]; for (const [a, b] of spaceDiags) { this._drawDashedSegment(v[a], v[b], '', '#fbbf24'); } // Face diagonals (12) — 2 per face const faceDiags = [ [0,2],[1,3], // bottom [4,6],[5,7], // top [0,5],[1,4], // front [2,7],[3,6], // back [1,6],[2,5], // right [0,7],[3,4], // left ]; for (const [a, b] of faceDiags) { const lineGeo = new THREE.BufferGeometry().setFromPoints([v[a], v[b]]); const lineMat = new THREE.LineDashedMaterial({ color: 0xF59E0B, dashSize: 0.1, gapSize: 0.06, transparent: true, opacity: 0.35, }); const line = new THREE.Line(lineGeo, lineMat); line.computeLineDistances(); this._figGroup.add(line); } // Space diagonal label — only one, the longest const d = v[0].distanceTo(v[6]); const mid = new THREE.Vector3().addVectors(v[0], v[6]).multiplyScalar(0.5); const lbl = this._makeTextSprite(`d = ${d.toFixed(2)}`, '#fbbf24', 34); lbl.position.copy(mid).add(new THREE.Vector3(0.3, 0.2, 0)); lbl.scale.set(1.2, 0.4, 1); this._labelGroup.add(lbl); } else if (t === 'prism') { const n = this.params.n; const v = this._vertices.map(vt => vt.pos); if (v.length < n * 2) return; // Base diagonals (bottom) for (let i = 0; i < n; i++) { for (let j = i + 2; j < n; j++) { if (j === (i + n - 1) % n + i) continue; // skip adjacent if (i === 0 && j === n - 1) continue; const lineGeo = new THREE.BufferGeometry().setFromPoints([v[i], v[j]]); const lineMat = new THREE.LineDashedMaterial({ color: 0xF59E0B, dashSize: 0.1, gapSize: 0.06, transparent: true, opacity: 0.35, }); const line = new THREE.Line(lineGeo, lineMat); line.computeLineDistances(); this._figGroup.add(line); } } // Top diagonals for (let i = n; i < 2 * n; i++) { for (let j = i + 2; j < 2 * n; j++) { if (i === n && j === 2 * n - 1) continue; const lineGeo = new THREE.BufferGeometry().setFromPoints([v[i], v[j]]); const lineMat = new THREE.LineDashedMaterial({ color: 0xF59E0B, dashSize: 0.1, gapSize: 0.06, transparent: true, opacity: 0.35, }); const line = new THREE.Line(lineGeo, lineMat); line.computeLineDistances(); this._figGroup.add(line); } } // Space diagonals: connect bottom[i] to top[(i+k)%n] for k≠0 for (let i = 0; i < n; i++) { for (let k = 1; k < n; k++) { const j = n + (i + k) % n; this._drawDashedSegment(v[i], v[j], '', '#fbbf24'); } } } else if (t === 'pyramid') { // Base diagonals only const n = this.params.n; const v = this._vertices.map(vt => vt.pos); for (let i = 0; i < n; i++) { for (let j = i + 2; j < n; j++) { if (i === 0 && j === n - 1) continue; const lineGeo = new THREE.BufferGeometry().setFromPoints([v[i], v[j]]); const lineMat = new THREE.LineDashedMaterial({ color: 0xF59E0B, dashSize: 0.1, gapSize: 0.06, transparent: true, opacity: 0.4, }); const line = new THREE.Line(lineGeo, lineMat); line.computeLineDistances(); this._figGroup.add(line); } } } } /* ════════════════ MIDPOINTS ════════════════ */ _drawMidpoints() { if (!this.showMidpoints) return; for (let i = 0; i < this._edges.length; i++) { const e = this._edges[i]; const mid = new THREE.Vector3().addVectors(e.from, e.to).multiplyScalar(0.5); // small cyan sphere const sGeo = new THREE.SphereGeometry(0.06, 8, 8); const sMat = new THREE.MeshBasicMaterial({ color: 0x06D6E0 }); const s = new THREE.Mesh(sGeo, sMat); s.position.copy(mid); this._figGroup.add(s); // label "M" + index const lbl = this._makeTextSprite(`M${i + 1}`, '#06D6E0', 28); lbl.position.copy(mid).add(new THREE.Vector3(0.15, 0.15, 0)); lbl.scale.set(0.5, 0.25, 1); this._labelGroup.add(lbl); } } /* ════════════════ POINT-TO-PLANE DISTANCE ════════════════ */ _onPointPlaneClick(e) { if (this._anglePicks.length < 1) { // First pick: a vertex/point const pick = this._pickNearestPoint(e); if (!pick) return; this._anglePicks.push(pick); this._highlightPick(pick.pos, 0xF9A8D4); } else { // Second pick: a face const face = this._pickNearestFace(e); if (!face || face.length < 3) return; const pt = this._anglePicks[0]; const normal = this._faceNormal(face); const planePoint = face[0]; // Distance = |dot(pt - planePoint, normal)| const diff = new THREE.Vector3().subVectors(pt.pos, planePoint); const dist = Math.abs(diff.dot(normal)); // Projection of point onto plane const proj = pt.pos.clone().sub(normal.clone().multiplyScalar(diff.dot(normal))); // Draw perpendicular line this._drawAngleLine(pt.pos, proj, '#F9A8D4', true); // Highlight foot of perpendicular this._highlightPick(proj, 0xF9A8D4); // Right angle marker const edgeOnPlane = new THREE.Vector3().subVectors(face[1], face[0]).normalize(); this._drawRightAngleMarkerAt(proj, normal, edgeOnPlane, 0.25, '#F9A8D4'); // Highlight face const positions = []; const indices = []; face.forEach(v => positions.push(v.x, v.y, v.z)); for (let i = 1; i < face.length - 1; i++) indices.push(0, i, i + 1); const geo = new THREE.BufferGeometry(); geo.setAttribute('position', new THREE.Float32BufferAttribute(positions, 3)); geo.setIndex(indices); const mat = new THREE.MeshBasicMaterial({ color: 0xF9A8D4, transparent: true, opacity: 0.15, side: THREE.DoubleSide }); this._angleGroup.add(new THREE.Mesh(geo, mat)); // Label const mid = new THREE.Vector3().addVectors(pt.pos, proj).multiplyScalar(0.5); const lbl = this._makeTextSprite(`d(${pt.label}, пл) = ${dist.toFixed(2)}`, '#F9A8D4', 36); lbl.position.copy(mid).add(new THREE.Vector3(0.4, 0.2, 0)); lbl.scale.set(2.2, 0.5, 1); this._angleGroup.add(lbl); this._anglePicks = []; } } _drawRightAngleMarkerAt(origin, normalDir, tangentDir, size, color) { const d1 = normalDir.clone().normalize().multiplyScalar(size); const d2 = tangentDir.clone().normalize().multiplyScalar(size); const p1 = origin.clone().add(d1); const p2 = origin.clone().add(d2); const p3 = p1.clone().add(d2); const pts = [p1, p3, p2]; const geo = new THREE.BufferGeometry().setFromPoints(pts); const mat = new THREE.LineBasicMaterial({ color: new THREE.Color(color), transparent: true, opacity: 0.6 }); this._angleGroup.add(new THREE.Line(geo, mat)); } /* ════════════════ COORDINATE TOOLTIP ════════════════ */ _initTooltip() { this._tooltipEl = document.createElement('div'); Object.assign(this._tooltipEl.style, { position: 'absolute', pointerEvents: 'none', background: 'rgba(13,13,26,0.85)', color: '#ccc', fontSize: '11px', fontFamily: 'Manrope, monospace', padding: '3px 7px', borderRadius: '4px', border: '1px solid rgba(155,93,229,0.3)', display: 'none', zIndex: '50', whiteSpace: 'nowrap', }); this.container.style.position = 'relative'; this.container.appendChild(this._tooltipEl); } _onHoverMove(e) { const rect = this.renderer.domElement.getBoundingClientRect(); if (e.clientX < rect.left || e.clientX > rect.right || e.clientY < rect.top || e.clientY > rect.bottom) { if (this._tooltipEl) this._tooltipEl.style.display = 'none'; return; } const mx = ((e.clientX - rect.left) / rect.width) * 2 - 1; const my = -((e.clientY - rect.top) / rect.height) * 2 + 1; let bestDist = 0.06; let bestV = null; for (const v of this._vertices) { const proj = v.pos.clone().project(this.camera); const d = Math.sqrt((proj.x - mx) ** 2 + (proj.y - my) ** 2); if (d < bestDist) { bestDist = d; bestV = v; } } for (const cp of this._customPoints) { const proj = cp.pos.clone().project(this.camera); const d = Math.sqrt((proj.x - mx) ** 2 + (proj.y - my) ** 2); if (d < bestDist) { bestDist = d; bestV = cp; } } if (bestV && this._tooltipEl) { const p = bestV.pos; this._tooltipEl.textContent = `${bestV.label} (${p.x.toFixed(1)}, ${p.y.toFixed(1)}, ${p.z.toFixed(1)})`; this._tooltipEl.style.display = 'block'; this._tooltipEl.style.left = (e.clientX - rect.left + 14) + 'px'; this._tooltipEl.style.top = (e.clientY - rect.top - 10) + 'px'; } else if (this._tooltipEl) { this._tooltipEl.style.display = 'none'; } } /* ════════════════ UNFOLD ANIMATION ════════════════ */ // Simplified unfold: flatten figure by reducing Y coordinates _applyUnfold(progress) { // This is a visual-only effect — squash Y toward 0 if (progress < 0.01) return; this._figGroup.children.forEach(child => { if (child.geometry) { // Already built, don't modify geometry — just scale Y } }); this._figGroup.scale.y = 1 - progress * 0.85; this._figGroup.position.y = progress * 0.5; } /* ════════════════ UTILS ════════════════ */ _clearGroup(group) { while (group.children.length) { const c = group.children[0]; if (c.geometry) c.geometry.dispose(); if (c.material) { if (c.material.map) c.material.map.dispose(); c.material.dispose(); } group.remove(c); } } _notify() { if (this.onUpdate) this.onUpdate(this.info()); } /* ════════════════ ANIMATION LOOP ════════════════ */ _loop() { if (!this._running) return; requestAnimationFrame(() => this._loop()); // Auto-spin after idle this._idleTime++; if (this._idleTime > 300 && !this._drag) this._autoSpin = true; if (this._autoSpin) this._rotY += 0.002; // Unfold animation if (this._unfold && this._unfoldProgress < this._unfoldTarget) { this._unfoldProgress = Math.min(1, this._unfoldProgress + 0.015); this._applyUnfold(this._unfoldProgress); } else if (!this._unfold && this._unfoldProgress > 0) { this._unfoldProgress = Math.max(0, this._unfoldProgress - 0.015); this._applyUnfold(this._unfoldProgress); if (this._unfoldProgress <= 0) { this._figGroup.scale.y = 1; this._figGroup.position.y = 0; } } // Camera orbit this.camera.position.set( this._dist * Math.sin(this._rotY) * Math.cos(this._rotX), this._dist * Math.sin(this._rotX), this._dist * Math.cos(this._rotY) * Math.cos(this._rotX) ); this.camera.lookAt(0, this._figureHeight() / 2, 0); this.renderer.render(this.scene, this.camera); } }