Files
Learn_System/frontend/js/labs/stereo.js
T
Maxim Dolgolyov 6afe928c0d feat(labs): visual polish wave — LabFX foundation + 33 sims juiced up
ФУНДАМЕНТ (4 новых файла):
- _fx_core.js: LabFX namespace, glow.drawGlow, glow.pulse, haptic, shake
- _fx_particles.js: пул 1500 объектов, 6 shapes (dot/spark/ring/smoke/splash/dust)
- _fx_motion.js: tween + 12 easings + critically-damped spring
- _fx_sound.js: 9 procedural synth-звуков (click/tick/whoosh/chime/fizz/spark/bounce/pour/drone), Web Audio API
- Sound toggle в шапке lab.html с localStorage-persist

UX МИКРО (CSS + JS):
- Button states: hover scale+brightness, active scale-down, disabled grayscale
- Slider polish: custom thumb с тенью, filled-track gradient, hover/active
- Focus rings через :focus-visible
- Tooltip system .tt-host data-tt= с 400ms hover, fade-in
- Marching ants для selection
- Loading skeleton с shimmer
- Empty state .sim-empty-* паттерн
- Toast: progress bar внизу, icons по типу
- Cursor states utility classes
- View Transitions API для smooth sim-switch, fallback на CSS fade

PHASE 2 — визуальные эффекты для 33 симуляций:

Physics motion: projectile (launch whoosh + landing splash/shake/haptic + target chime), pendulum (max-extension tick + bob glow), collision (bounce + sparks + shake), angrybirds (whoosh/bounce/fizz/chime + confetti), newton (rocket flame trail + scene transitions), forcesandbox (spring glow + impact sparks)

Physics fields: emfield (field-lines glow + particle trail + lightning при high field + Gauss-drag haptic + rod motion sparks), circuit (energized-wire glow ∝ current + LED bloom + short-circuit shake/spark + heat shimmer smoke + place/erase/switch sounds), opticsbench (beam glow на всех режимах + caustics dust у фокуса + TIR/Brewster one-shot sounds)

Thermo+waves: waves (mode-switch whoosh + Mach-cone particles + spectrum harmonic chime + waveform glow), hydrostatics (pour sound + splash при погружении + valve click), isoprocess (PV-trail dust), heatengine (drone цикла + hot/cold reservoir smoke/dust + phase-change ticks), radioactive (Geiger tick throttle + decay sparks + half-life chime + α/β/γ glow)

Chemistry: chemsandbox (pour splash + fizz bubbles/dust/spark по типу реакции + shake при горении), equilibrium/electrolysis/reactions/titration/flask/ionexchange/redox (pour/fizz/spark/chime по событиям, частицы при ключевых моментах), stoichiometry (fizz bubbles + recipe-change click)

Math+geom: geometry (tool-click + object-create tick + locus glow + challenge confetti via LabFX + haptic), triangle (vertex-drop tick + special-point glow), stereo (figure-change whoosh + cross-section chime, no particles — Three.js), trigcircle (drag-haptic + pitch∝angle tick + sin/cos glow), graph/graphtransform/quadratic (slider-tick throttled + curve glow + discriminant-cross chime), probability (bounce + finish-chime burst), normaldist (pulsing shade + bell glow)

Bio+misc: celldivision (phase-change whoosh + prophase dust + anaphase poles spark + cytokinesis chime), photosynthesis (photon tick + ATP chime + glucose sparkle), bohratom (electron-jump chime ∝ levels + photon spark), orbitals/crystal (mode-change whoosh — Three.js, sound only), logic (gate-place + wire-connect + LED bloom + HIGH wire flowing dots + preset chime), gas/brownian/diffusion/states (preset whoosh, throttled tick по событиям)

Все LabFX вызовы обёрнуты в if (window.LabFX) guard — graceful degradation если фундамент не загружен. 47 JS файлов прошли syntax check.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-23 13:58:49 +03:00

3722 lines
138 KiB
JavaScript

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