diff --git a/frontend/js/labs/stereo.js b/frontend/js/labs/stereo.js index 09192cb..eba8485 100644 --- a/frontend/js/labs/stereo.js +++ b/frontend/js/labs/stereo.js @@ -423,6 +423,67 @@ class StereoSim { 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: [] }; } } @@ -493,6 +554,10 @@ class StereoSim { 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)(); @@ -779,6 +844,124 @@ class StereoSim { 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) { @@ -791,6 +974,21 @@ class StereoSim { 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 = []; @@ -915,10 +1113,13 @@ class StereoSim { switch (this.figureType) { case 'cube': return p.a; case 'parallelepiped': return p.c; - case 'pyramid': case 'prism': return p.h; + 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; } } @@ -1188,11 +1389,13 @@ class StereoSim { 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); } + 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; } } @@ -1225,11 +1428,13 @@ class StereoSim { 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); } + 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; } } @@ -1259,6 +1464,9 @@ class StereoSim { 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; } } @@ -1294,6 +1502,9 @@ class StereoSim { 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; } } @@ -1880,9 +2091,80 @@ class StereoSim { 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); diff --git a/frontend/lab.html b/frontend/lab.html index b68256e..200c8ca 100644 --- a/frontend/lab.html +++ b/frontend/lab.html @@ -3559,6 +3559,10 @@ + + + +