feat: стереометрия — усечённая пирамида, правильные многогранники, скрещивающиеся прямые

- Добавлены фигуры: усечённая пирамида, октаэдр, икосаэдр, додекаэдр
- Формулы V, S, r_вп, R_оп для всех новых фигур
- Инструмент '∠ скрещ. прям.' — угол и расстояние между скрещивающимися прямыми (4 клика)
- Для икосаэдра/додекаэдра — THREE.IcosahedronGeometry/DodecahedronGeometry с извлечением рёбер
- Вписанная/описанная сфера поддерживает октаэдр, икосаэдр, додекаэдр
- Параметр n добавлен для пирамиды

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Maxim Dolgolyov
2026-04-14 11:59:42 +03:00
parent b520f4b849
commit 481a9aeb02
2 changed files with 298 additions and 5 deletions
+285 -3
View File
@@ -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);
+13 -2
View File
@@ -3559,6 +3559,10 @@
<button class="gp-btn stereo-fig-btn" onclick="setStereoFigure('trunccone',this)">Усечённый конус</button>
<button class="gp-btn stereo-fig-btn" onclick="setStereoFigure('sphere',this)">Сфера</button>
<button class="gp-btn stereo-fig-btn" onclick="setStereoFigure('prism',this)">Призма</button>
<button class="gp-btn stereo-fig-btn" onclick="setStereoFigure('truncpyramid',this)">Усеч. пирамида</button>
<button class="gp-btn stereo-fig-btn" onclick="setStereoFigure('octahedron',this)">Октаэдр</button>
<button class="gp-btn stereo-fig-btn" onclick="setStereoFigure('icosahedron',this)">Икосаэдр</button>
<button class="gp-btn stereo-fig-btn" onclick="setStereoFigure('dodecahedron',this)">Додекаэдр</button>
</div>
<div class="gp-section-title">Параметры</div>
@@ -3656,6 +3660,7 @@
<button class="gp-btn" id="stereo-angle-lp-btn" onclick="stereoAngleMode('linePlane', this)">∠ прям.–пл.</button>
<button class="gp-btn" id="stereo-angle-dih-btn" onclick="stereoAngleMode('dihedral', this)">∠ двугранный</button>
<button class="gp-btn" id="stereo-angle-pp-btn" onclick="stereoAngleMode('pointPlane', this)">d(т<svg class="ic" viewBox="0 0 24 24"><line x1="5" y1="12" x2="19" y2="12"/><polyline points="12 5 19 12 12 19"/></svg>пл)</button>
<button class="gp-btn" id="stereo-angle-skew-btn" onclick="stereoAngleMode('skewLines', this)">∠ скрещ. прям.</button>
<button class="gp-btn" id="stereo-angle-clear-btn" onclick="stereoAngleClear()">Очистить</button>
</div>
<div id="angle-hint" style="font-size:0.65rem;color:rgba(255,255,255,0.4);margin-bottom:4px"></div>
@@ -3674,6 +3679,7 @@
∠ прям.–пл.: 2 точки (прямая), затем грань<br>
∠ двугранный: 2 точки общего ребра<br>
d(т<svg class="ic" viewBox="0 0 24 24"><line x1="5" y1="12" x2="19" y2="12"/><polyline points="12 5 19 12 12 19"/></svg>пл): точка, затем грань — перпендикуляр<br>
∠ скрещ.: 4 точки — P1,P2 (пр.1), P3,P4 (пр.2)<br>
Координаты: наведите на вершину
</div>
</div>
@@ -8053,13 +8059,17 @@
const STEREO_PARAM_MAP = {
cube: ['a'],
parallelepiped: ['a','b','c'],
pyramid: ['a','h'],
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() {
@@ -8167,7 +8177,7 @@
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'].forEach(id => {
'stereo-angle-edge-btn','stereo-angle-lp-btn','stereo-angle-dih-btn','stereo-angle-pp-btn','stereo-angle-skew-btn'].forEach(id => {
document.getElementById(id)?.classList.remove('active');
});
if (stereoSim) {
@@ -8216,6 +8226,7 @@
linePlane: 'Кликните 2 точки (прямая), затем — грань',
dihedral: 'Кликните 2 точки общего ребра двух граней',
pointPlane: 'Кликните точку, затем — грань',
skewLines: 'P1, P2 (прямая 1) → P3, P4 (прямая 2): угол и расстояние',
};
function stereoAngleMode(mode, btn) {