Files
Learn_System/frontend/js/labs/stereo.js
T
Maxim Dolgolyov 9382b063aa feat(stereo): A3 — параллели/перпендикуляры + общий undo/redo построений
Фаза A3 раунда «Конструктор». Построения через точку, опираясь на объект:
- lpar: прямая ∥ выбранной прямой;
- lperp: прямая ⟂ выбранной плоскости (вдоль нормали);
- ppar: плоскость ∥ выбранной плоскости;
- pperp: плоскость ⟂ выбранной прямой (= плоскость по точке+нормали,
  через _createPlaneFromPointNormal — мост к Фазе C).
Поток: кнопка op → выбор опоры в дереве → клик точки.

Общий undo/redo конструкторного слоя: JSON-снапшоты _undoStack/_redoStack
(кап 60), хуки _pushHistory в create/remove/clear; Ctrl+Z / Ctrl+Shift+Z /
Ctrl+Y + кнопки «Отменить»/«Вернуть». Видимость объекта — не шаг истории.

- StereoSim: setRelMode/_pickRelRef/_onRelClick/_createPlaneFromPointNormal;
  _snapshot/_pushHistory/_restoreSnapshot/undo/redo/canUndo/canRedo;
  pickConstructObject диспатчит rel/intersect; getConstructions отдаёт
  relMode + selected по опоре; _lastConstructMsg → flash в подсказку.
  Сброс rel/истории в setFigure, очистка в clearConstructions.
- Панель: 4 кнопки (∥/⟂ прямая/плоск.) + «Отменить»/«Вернуть»; интеграция в
  _stereoDeactivateTools; glue stereoRelMode/HistUndo/HistRedo; дерево —
  строки выбираемы и в rel-режиме.

Верификация: node --check OK; headless-смоук 30/30 (4 rel-операции с
проверкой параллельности направлений/нормалей, гард типа опоры, undo/redo
одиночный/многошаговый/redo-сброс/clear-undoable/vis-не-шаг/кап, setFigure-
сброс истории, dispose); эмодзи/eval/new Function — 0.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-17 17:07:43 +03:00

5005 lines
195 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
'use strict';
/* ═══════════════════════════════════════════════════════════
StereoSim — 3D Stereometry (Three.js)
Cube, Parallelepiped, Pyramid, Tetrahedron, Cylinder,
Cone, Truncated Cone, Sphere, Prism + sections, unfold
═══════════════════════════════════════════════════════════ */
class StereoSim {
constructor(container) {
this.container = container;
this._running = false;
this._rafId = null; // active requestAnimationFrame id (null = loop asleep)
this._needsRender = true; // render-on-demand dirty flag
this._contextLost = false;
/* Three.js core */
this.scene = new THREE.Scene();
this.camera = new THREE.PerspectiveCamera(50, 1, 0.1, 500);
this.renderer = new THREE.WebGLRenderer({ antialias: true, alpha: true, preserveDrawingBuffer: true });
this.renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2));
this.renderer.setClearColor(0x0D0D1A, 1);
container.appendChild(this.renderer.domElement);
/* lighting */
this.scene.add(new THREE.AmbientLight(0xffffff, 0.55));
const dir = new THREE.DirectionalLight(0xffffff, 0.75);
dir.position.set(6, 10, 8);
this.scene.add(dir);
const fill = new THREE.DirectionalLight(0x9B5DE5, 0.2);
fill.position.set(-5, 3, -4);
this.scene.add(fill);
/* orbit camera */
this._drag = false;
this._panning = false;
this._prevX = 0; this._prevY = 0;
this._rotY = 0.6; this._rotX = 0.45;
this._dist = 14;
this._autoSpin = true;
this._spinEnabled = true; // master switch for idle auto-rotation
this._idleTime = 0;
this._velX = 0; this._velY = 0; // orbit inertia (angular velocity)
this._panOffset = new THREE.Vector3(0, 0, 0); // look-at target offset (panning)
// home view for the reset button
this._homeView = { rotY: 0.6, rotX: 0.45, dist: 14 };
const el = this.renderer.domElement;
el.style.cursor = 'grab';
el.style.touchAction = 'none';
el.setAttribute('tabindex', '0');
el.setAttribute('role', 'img');
el.setAttribute('aria-label', '3D-модель стереометрической фигуры');
this._clickStart = null;
// Listeners are scoped to the canvas (not window) and tracked for dispose().
// pointer capture keeps move/up flowing while dragging outside the canvas.
this._listeners = [];
const on = (target, type, fn, opts) => {
target.addEventListener(type, fn, opts);
this._listeners.push([target, type, fn, opts]);
};
on(el, 'pointerdown', e => {
this._clickStart = { x: e.clientX, y: e.clientY };
// Right / middle button or Shift = pan; left button = orbit.
this._panning = (e.button === 1 || e.button === 2 || e.shiftKey);
this._drag = true; this._prevX = e.clientX; this._prevY = e.clientY;
this._autoSpin = false; this._idleTime = 0;
this._velX = 0; this._velY = 0;
try { el.setPointerCapture(e.pointerId); } catch (_) {}
if (this._panning) el.style.cursor = 'move';
else if (!this._pointMode && !this._connectMode && !this._measureMode && !this._angleMode && !this._section3PMode && !this._lineMode && !this._planeMode && !this._relMode) el.style.cursor = 'grabbing';
this._invalidate();
});
on(el, 'contextmenu', e => e.preventDefault()); // allow right-drag pan without menu
on(el, 'pointerup', e => {
const wasDrag = this._clickStart &&
(Math.abs(e.clientX - this._clickStart.x) > 4 || Math.abs(e.clientY - this._clickStart.y) > 4);
this._drag = false;
const wasPanning = this._panning; this._panning = false;
try { el.releasePointerCapture(e.pointerId); } catch (_) {}
if (wasPanning) { el.style.cursor = 'grab'; this._invalidate(); return; }
if (this._pointMode) { el.style.cursor = 'cell'; if (!wasDrag) this._onPointClick(e); }
else if (this._connectMode) { el.style.cursor = 'pointer'; if (!wasDrag) this._onConnectClick(e); }
else if (this._measureMode) { el.style.cursor = 'crosshair'; if (!wasDrag) this._onMeasureClick(e); }
else if (this._angleMode) { el.style.cursor = 'crosshair'; if (!wasDrag) this._onAngleClick(e); }
else if (this._markMode) { el.style.cursor = 'pointer'; if (!wasDrag) this._onMarkClick(e); }
else if (this._deriveMode) { el.style.cursor = 'crosshair'; if (!wasDrag) this._onDeriveClick(e); }
else if (this._section3PMode) { el.style.cursor = 'crosshair'; if (!wasDrag) this._onSection3PClick(e); }
else if (this._lineMode || this._planeMode) { el.style.cursor = 'crosshair'; if (!wasDrag) this._onConstructClick(e); }
else if (this._relMode) { el.style.cursor = 'crosshair'; if (!wasDrag) this._onRelClick(e); }
else el.style.cursor = 'grab';
this._invalidate();
});
on(el, 'pointermove', e => {
this._onHoverMove(e);
if (!this._drag) return;
const dx = e.clientX - this._prevX, dy = e.clientY - this._prevY;
if (this._panning) {
this._pan(dx, dy);
} else {
const vy = dx * 0.007, vx = dy * 0.007;
this._rotY += vy; this._rotX += vx;
this._rotX = Math.max(-1.4, Math.min(1.4, this._rotX));
this._velY = vy; this._velX = vx; // remember last delta for inertia
}
this._prevX = e.clientX; this._prevY = e.clientY;
this._idleTime = 0;
this._invalidate();
});
on(el, 'wheel', e => {
e.preventDefault();
this._dist = Math.max(4, Math.min(40, this._dist + e.deltaY * 0.02));
this._invalidate();
}, { passive: false });
// Keyboard navigation (a11y) — works when the canvas is focused.
on(el, 'keydown', e => {
// Undo / redo of constructions (Ctrl/Cmd+Z, Ctrl+Shift+Z, Ctrl+Y)
if ((e.ctrlKey || e.metaKey) && (e.key === 'z' || e.key === 'Z')) {
if (e.shiftKey) this.redo(); else this.undo();
e.preventDefault(); return;
}
if ((e.ctrlKey || e.metaKey) && (e.key === 'y' || e.key === 'Y')) {
this.redo(); e.preventDefault(); return;
}
const STEP = 0.12;
let handled = true;
switch (e.key) {
case 'ArrowLeft': this._rotY -= STEP; this._autoSpin = false; break;
case 'ArrowRight': this._rotY += STEP; this._autoSpin = false; break;
case 'ArrowUp': this._rotX = Math.min(1.4, this._rotX + STEP); this._autoSpin = false; break;
case 'ArrowDown': this._rotX = Math.max(-1.4, this._rotX - STEP); this._autoSpin = false; break;
case '+': case '=': this._dist = Math.max(4, this._dist - 1); break;
case '-': case '_': this._dist = Math.min(40, this._dist + 1); break;
case 'r': case 'R': case 'Home': this.resetView(); break;
default: handled = false;
}
if (handled) { e.preventDefault(); this._idleTime = 0; this._invalidate(); }
});
// WebGL context loss / restore — keep the page alive if the GPU resets.
on(el, 'webglcontextlost', e => { e.preventDefault(); this._contextLost = true; this.stop(); }, false);
on(el, 'webglcontextrestored', () => {
this._contextLost = false;
this._buildGrid();
this._buildFigure();
if (this._running === false) this.play(); else this._invalidate();
}, false);
/* touch — orbit (1 finger) + pinch-zoom & pan (2 fingers) */
this._touchDist = 0;
this._touchMidX = 0; this._touchMidY = 0;
on(el, 'touchstart', e => {
if (e.touches.length === 1) {
this._drag = true; this._panning = false;
this._prevX = e.touches[0].clientX; this._prevY = e.touches[0].clientY;
this._autoSpin = false; this._idleTime = 0;
this._velX = 0; this._velY = 0;
} else if (e.touches.length === 2) {
this._drag = false;
const dx = e.touches[0].clientX - e.touches[1].clientX;
const dy = e.touches[0].clientY - e.touches[1].clientY;
this._touchDist = Math.sqrt(dx * dx + dy * dy);
this._touchMidX = (e.touches[0].clientX + e.touches[1].clientX) / 2;
this._touchMidY = (e.touches[0].clientY + e.touches[1].clientY) / 2;
}
this._invalidate();
}, { passive: true });
on(el, 'touchmove', e => {
if (e.touches.length === 2) {
// pinch zoom
const dx = e.touches[0].clientX - e.touches[1].clientX;
const dy = e.touches[0].clientY - e.touches[1].clientY;
const newDist = Math.sqrt(dx * dx + dy * dy);
if (this._touchDist > 0) {
const scale = this._touchDist / newDist;
this._dist = Math.max(4, Math.min(40, this._dist * scale));
}
this._touchDist = newDist;
// two-finger pan via midpoint movement
const midX = (e.touches[0].clientX + e.touches[1].clientX) / 2;
const midY = (e.touches[0].clientY + e.touches[1].clientY) / 2;
this._pan(midX - this._touchMidX, midY - this._touchMidY);
this._touchMidX = midX; this._touchMidY = midY;
this._idleTime = 0;
this._invalidate();
return;
}
if (!this._drag || e.touches.length !== 1) return;
const t = e.touches[0];
const vy = (t.clientX - this._prevX) * 0.007, vx = (t.clientY - this._prevY) * 0.007;
this._rotY += vy; this._rotX += vx;
this._rotX = Math.max(-1.4, Math.min(1.4, this._rotX));
this._velY = vy; this._velX = vx;
this._prevX = t.clientX; this._prevY = t.clientY;
this._idleTime = 0;
this._invalidate();
}, { passive: true });
on(el, 'touchend', () => { this._drag = false; this._panning = false; this._touchDist = 0; this._invalidate(); }, { passive: true });
/* resize */
this._ro = new ResizeObserver(() => this.fit());
this._ro.observe(container);
/* groups */
this._figGroup = new THREE.Group();
this._labelGroup = new THREE.Group();
this._sectionGroup = new THREE.Group();
this._sphereGroup = new THREE.Group();
this._measureGroup = new THREE.Group();
this._measurePickGroup = new THREE.Group();
this._gridGroup = new THREE.Group();
this._markGroup = new THREE.Group();
this._derivedGroup = new THREE.Group();
this._section3PGroup = new THREE.Group();
this.scene.add(this._gridGroup);
this.scene.add(this._figGroup);
this.scene.add(this._sectionGroup);
this.scene.add(this._sphereGroup);
this.scene.add(this._measureGroup);
this.scene.add(this._measurePickGroup);
this.scene.add(this._markGroup);
this.scene.add(this._derivedGroup);
this.scene.add(this._section3PGroup);
this.scene.add(this._labelGroup);
/* state */
this.figureType = 'cube';
this.params = { a: 4, b: 3, c: 5, h: 5, r: 2, R: 3, n: 4 };
this.showEdges = true;
this.showVertices = true;
this.showLabels = true;
this.showAxes = true;
this.showGrid = true;
this.opacity = 0.3;
this.showSection = false;
this.sectionHeight = 0.5; // 0..1
this.sectionType = 'horizontal'; // horizontal | diagonal | custom
this.sectionAngle = 0.5; // 0..1 for diagonal tilt
this._unfold = false;
this._unfoldProgress = 0;
this._unfoldTarget = 0;
this.showInscribed = false;
this.showCircumscribed = false;
this.showHeight = false;
this.showApothem = false;
this.showDiagonals = false;
this.showMidpoints = false;
this._measureMode = false;
this._measurePicks = [];
this._measurements = [];
/* angle modes */
this._angleMode = null; // 'edge' | 'linePlane' | 'dihedral' | 'pointPlane'
this._anglePicks = []; // picked vertices/points
this._angleGroup = new THREE.Group();
this.scene.add(this._angleGroup);
/* hover coordinate tooltip */
this._tooltipEl = null;
this._initTooltip();
/* custom points & connections */
this._pointMode = false; // place points on edges
this._connectMode = false; // connect two points with a line
this._customPoints = []; // [{pos: Vector3, edgeIdx: number, t: number, label: string}]
this._connections = []; // [{from: idx, to: idx}]
this._connectPicks = []; // temp picks for connecting
this._pointGroup = new THREE.Group();
this.scene.add(this._pointGroup);
this._nextPointId = 1;
this._vertices = []; // [{pos: Vector3, label: string}]
this._edges = []; // [{from: Vector3, to: Vector3}]
this._faces = []; // [[Vector3, ...]]
/* edge marks (tick / parallel) — аналог _drawTickMark() из планиметрии */
this._edgeMarks = {}; // { edgeIdx: { ticks: 0-3, parallel: 0-3 } }
this._markMode = null; // 'ticks' | 'parallel' | null
/* derived 3D constructions — аналог midpoint/altitude_foot/centroid из планиметрии */
this._derived3D = []; // [{type, ...args}]
this._deriveMode = null; // 'midpoint'|'face_centroid'|'alt_foot'|'solid_centroid'|null
this._derivePicks = [];
/* edge length labels */
this.showEdgeLengths = false;
/* section by 3 arbitrary points */
this._section3PMode = false; // interactive picking active
this._section3PPicks = []; // Vector3[] — up to 3 picked points
this._section3PStepBy = false; // step-by-step visualisation toggle
this._section3PStep = 0; // current step (0=idle, 1..6=sub-steps)
this._section3PData = null; // computed result {normal,D,polygon,area,typeName}
this._stepCaption = ''; // caption for the current trace-method step
/* ── Construction layer (Phase A): lines & planes as named objects ──
Stored serialisably as plain {x,y,z}; rebuilt into _constructGroup. */
this._cpoints = []; // [{id,seq,name, pos:{x,y,z}, color, hidden}] — construction points (intersections)
this._lines = []; // [{id,seq,name, a:{x,y,z}, b:{x,y,z}, color, hidden}]
this._planes = []; // [{id,seq,name, point:{x,y,z}, normal:{x,y,z}, def:[{x,y,z}×3], color, hidden}]
this._constructGroup = new THREE.Group();
this.scene.add(this._constructGroup);
this._lineMode = false; // pick 2 points → infinite line
this._planeMode = false; // pick 3 points → plane (+ its cross-section of the solid)
this._intersectMode = false; // list-based: select 2 objects → intersection
this._intersectSel = []; // ids of objects selected for intersection
this._constructPicks = []; // temp Vector3 picks for the active construction tool
this._nextLineName = 0; // → a, b, c, …
this._nextPlaneName = 0; // → α, β, γ, …
this._nextCPointName = 0; // → M, N, K, …
this._constructSeq = 0; // monotonic insertion order (for "remove last")
this._relMode = null; // {op, refId} parallel/perpendicular through a point
this._lastConstructMsg = ''; // transient result text for the panel hint
this._undoStack = []; // construction-layer history (JSON snapshots)
this._redoStack = [];
this._undoMax = 60;
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._cpoints = []; this._lines = []; this._planes = [];
this._lineMode = false; this._planeMode = false;
this._intersectMode = false; this._intersectSel = [];
this._relMode = null; this._lastConstructMsg = '';
this._undoStack = []; this._redoStack = [];
this._constructPicks = [];
this._nextLineName = 0; this._nextPlaneName = 0; this._nextCPointName = 0; this._constructSeq = 0;
this._clearGroup(this._constructGroup);
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(); this._rebuildConstructions(); }
toggleAxes(v) { this.showAxes = v; this._buildGrid(); }
toggleGrid(v) { this.showGrid = v; this._buildGrid(); }
toggleSection(on) {
this.showSection = on;
this._updateSection();
}
setSectionHeight(v) {
this.sectionHeight = v;
this._updateSection();
this._notify();
}
setSectionType(t) {
this.sectionType = t;
this._updateSection();
this._notify();
}
toggleUnfold(on) {
this._unfold = on;
this._unfoldTarget = on ? 1 : 0;
}
toggleInscribed(on) {
this.showInscribed = on;
this._updateSpheres();
this._notify();
}
toggleCircumscribed(on) {
this.showCircumscribed = on;
this._updateSpheres();
this._notify();
}
toggleHeight(on) {
this.showHeight = on;
this._buildFigure();
this._notify();
}
toggleApothem(on) {
this.showApothem = on;
this._buildFigure();
this._notify();
}
toggleDiagonals(on) {
this.showDiagonals = on;
this._buildFigure();
this._notify();
}
toggleMidpoints(on) {
this.showMidpoints = on;
this._buildFigure();
this._notify();
}
toggleMeasure(on) {
this._measureMode = on;
this._pointMode = false;
this._angleMode = null;
this._measurePicks = [];
this._clearGroup(this._measurePickGroup);
if (!on) { this._measurements = []; this._rebuildMeasureGroup(); }
this.renderer.domElement.style.cursor = on ? 'crosshair' : 'grab';
}
removeLastMeasurement() {
if (!this._measurements.length) return;
this._measurements.pop();
this._rebuildMeasureGroup();
this._notify();
}
clearMeasurements() {
this._measurements = [];
this._measurePicks = [];
this._rebuildMeasureGroup();
this._clearGroup(this._measurePickGroup);
this._notify();
}
_rebuildMeasureGroup() {
this._clearGroup(this._measureGroup);
for (const m of this._measurements) {
const g = new THREE.Group();
// spheres at endpoints
for (const pos of [m.posA, m.posB]) {
const sGeo = new THREE.SphereGeometry(0.14, 12, 12);
const sMat = new THREE.MeshBasicMaterial({ color: 0xFFD166 });
const s = new THREE.Mesh(sGeo, sMat);
s.position.copy(pos);
g.add(s);
}
// dashed line
const lineGeo = new THREE.BufferGeometry().setFromPoints([m.posA, m.posB]);
const lineMat = new THREE.LineDashedMaterial({ color: 0xFFD166, dashSize: 0.15, gapSize: 0.1, transparent: true, opacity: 0.9 });
const line = new THREE.Line(lineGeo, lineMat);
line.computeLineDistances();
g.add(line);
// label
const mid = new THREE.Vector3().addVectors(m.posA, m.posB).multiplyScalar(0.5);
const label = this._makeTextSprite(`${m.from}${m.to} = ${m.dist}`, '#FFD166', 40);
label.position.copy(mid).add(new THREE.Vector3(0, 0.3, 0));
label.scale.set(1.4, 0.5, 1);
g.add(label);
this._measureGroup.add(g);
}
}
setAngleMode(mode) {
// mode: 'edge' | 'linePlane' | 'dihedral' | null
this._angleMode = mode;
this._anglePicks = [];
this._measureMode = false;
this._pointMode = false;
this._connectMode = false;
if (!mode) { this._clearGroup(this._angleGroup); }
this.renderer.domElement.style.cursor = mode ? 'crosshair' : 'grab';
}
setSectionAngle(v) {
this.sectionAngle = v;
if (this.showSection && this.sectionType === 'diagonal') this._updateSection();
this._notify();
}
/* ── Point mode: place points on edges ── */
togglePointMode(on) {
this._pointMode = on;
this._measureMode = false;
this._connectPicks = [];
this.renderer.domElement.style.cursor = on ? 'cell' : 'grab';
}
toggleConnectMode(on) {
this._connectMode = on;
this._pointMode = false;
this._measureMode = false;
this._connectPicks = [];
this.renderer.domElement.style.cursor = on ? 'pointer' : 'grab';
}
clearCustomPoints() {
this._customPoints = [];
this._connections = [];
this._connectPicks = [];
this._nextPointId = 1;
this._clearGroup(this._pointGroup);
if (this.showSection && this.sectionType === 'custom') this._updateSection();
this._notify();
}
removeLastPoint() {
if (!this._customPoints.length) return;
// Remove connections referencing last point
const lastIdx = this._customPoints.length - 1;
this._connections = this._connections.filter(c => c.from !== lastIdx && c.to !== lastIdx);
this._customPoints.pop();
this._nextPointId = Math.max(1, this._nextPointId - 1);
this._rebuildPointVisuals();
if (this.showSection && this.sectionType === 'custom') this._updateSection();
this._notify();
}
getCustomPoints() { return this._customPoints; }
getConnections() { return this._connections; }
/* ── Edge mark mode ── */
setMarkMode(mode) {
// mode: 'ticks' | 'parallel' | null
this._markMode = mode;
this._deriveMode = null;
this._derivePicks = [];
this._measureMode = false;
this._pointMode = false;
this._connectMode = false;
this._angleMode = null;
this.renderer.domElement.style.cursor = mode ? 'pointer' : 'grab';
}
clearMarks() {
this._edgeMarks = {};
this._renderEdgeMarks();
}
/* ── Derived 3D constructions mode ── */
setDeriveMode(mode) {
// mode: 'midpoint' | 'face_centroid' | 'alt_foot' | 'solid_centroid' | null
this._deriveMode = mode;
this._derivePicks = [];
this._markMode = null;
this._measureMode = false;
this._pointMode = false;
this._connectMode = false;
this._angleMode = null;
this.renderer.domElement.style.cursor = mode ? 'crosshair' : 'grab';
if (mode === 'solid_centroid') this._addSolidCentroid();
}
clearDerived() {
this._derived3D = [];
this._derivePicks = [];
this._clearGroup(this._derivedGroup);
}
removeLastDerived() {
if (!this._derived3D.length) return;
this._derived3D.pop();
this._buildDerived3D();
}
/* ── Edge length labels ── */
toggleEdgeLengths(on) {
this.showEdgeLengths = on;
this._buildFigure();
}
/* ── Section by 3 arbitrary points ── */
toggleSection3P(on) {
this._section3PMode = on;
// turn off all other interactive modes
this._pointMode = false;
this._connectMode = false;
this._measureMode = false;
this._angleMode = null;
this._markMode = null;
this._deriveMode = null;
this._connectPicks = [];
this.renderer.domElement.style.cursor = on ? 'crosshair' : 'grab';
}
clearSection3P() {
this._section3PPicks = [];
this._section3PData = null;
// keep step ≥1 while in step mode, else a re-pick would hide the section
this._section3PStep = this._section3PStepBy ? 1 : 0;
this._stepCaption = '';
this._clearGroup(this._section3PGroup);
this._notify();
}
toggleSection3PStepBy(on) {
this._section3PStepBy = on;
// entering step mode: start at the first step so the build-up is visible
if (on && this._section3PStep === 0) this._section3PStep = 1;
if (!on) this._stepCaption = '';
if (this._section3PData || this._section3PPicks.length) this._drawSection3P();
}
getSection3PInfo() {
return this._section3PData;
}
getFormulas() {
const p = this.params;
const PI = Math.PI;
const r = (v) => Math.round(v * 100) / 100;
switch (this.figureType) {
case 'cube': {
const a = p.a;
const rIn = a / 2, rOut = r(a * Math.sqrt(3) / 2);
return { V: r(a**3), S: r(6*a**2), S_side: r(4*a**2), d: r(a*Math.sqrt(3)), h: a,
formulas: [`V = a³ = ${r(a**3)}`, `S = 6a² = ${r(6*a**2)}`, `d = a√3 = ${r(a*Math.sqrt(3))}`,
`r_вп = a/2 = ${r(rIn)}`, `R_оп = a√3/2 = ${rOut}`] };
}
case 'parallelepiped': {
const {a,b,c} = p;
return { V: r(a*b*c), S: r(2*(a*b+b*c+a*c)), S_side: r(2*c*(a+b)), d: r(Math.sqrt(a**2+b**2+c**2)), h: c,
formulas: [`V = abc = ${r(a*b*c)}`, `S = 2(ab+bc+ac) = ${r(2*(a*b+b*c+a*c))}`, `d = √(a²+b²+c²) = ${r(Math.sqrt(a**2+b**2+c**2))}`,
`r_вп = ${r(Math.min(a,b,c)/2)}`, `R_оп = ${r(Math.sqrt(a**2+b**2+c**2)/2)}`] };
}
case 'pyramid': {
const {a, h, n} = p;
const sBase = n * a**2 / (4 * Math.tan(PI/n));
const apothem = a / (2 * Math.tan(PI/n));
const slantH = Math.sqrt(h**2 + apothem**2);
const sLat = n * a * slantH / 2;
const Vp = sBase * h / 3;
const rIn = r(3 * Vp / (sBase + sLat));
const Rb = a / (2 * Math.sin(PI / n));
const rOut = r((Rb**2 + h**2) / (2 * h));
return { V: r(Vp), S: r(sBase+sLat), S_side: r(sLat), d: null, h,
formulas: [`V = ⅓·S_осн·h = ${r(Vp)}`, `S_осн = ${r(sBase)}`, `S_бок = ${r(sLat)}`, `S_полн = ${r(sBase+sLat)}`,
`a_осн = ${r(apothem)}`, `a_бок = ${r(slantH)}`, `r_вп = ${rIn}`, `R_оп = ${rOut}`] };
}
case 'tetrahedron': {
const a = p.a;
const rIn = r(a / (2 * Math.sqrt(6))), rOut = r(a * Math.sqrt(6) / 4);
return { V: r(a**3*Math.sqrt(2)/12), S: r(a**2*Math.sqrt(3)), S_side: r(a**2*Math.sqrt(3)*3/4), d: null, h: r(a*Math.sqrt(2/3)),
formulas: [`V = a³√2/12 = ${r(a**3*Math.sqrt(2)/12)}`, `S = a²√3 = ${r(a**2*Math.sqrt(3))}`, `h = a√(2/3) = ${r(a*Math.sqrt(2/3))}`,
`r_вп = ${rIn}`, `R_оп = ${rOut}`] };
}
case 'cylinder': {
const {r: rad, h} = p;
const rIn = r(Math.min(rad, h/2)), rOut = r(Math.sqrt(rad**2 + (h/2)**2));
return { V: r(PI*rad**2*h), S: r(2*PI*rad*(rad+h)), S_side: r(2*PI*rad*h), d: r(2*rad), h,
formulas: [`V = πr²h = ${r(PI*rad**2*h)}`, `S_бок = 2πrh = ${r(2*PI*rad*h)}`, `S_полн = 2πr(r+h) = ${r(2*PI*rad*(rad+h))}`,
`r_вп = ${rIn}`, `R_оп = ${rOut}`] };
}
case 'cone': {
const {r: rad, h} = p;
const l = Math.sqrt(rad**2+h**2);
const rIn = r(rad * h / (rad + l)), rOut = r((rad**2 + h**2) / (2 * h));
return { V: r(PI*rad**2*h/3), S: r(PI*rad*(rad+l)), S_side: r(PI*rad*l), d: r(2*rad), h,
formulas: [`V = ⅓πr²h = ${r(PI*rad**2*h/3)}`, `l = √(r²+h²) = ${r(l)}`, `S_бок = πrl = ${r(PI*rad*l)}`,
`r_вп = ${rIn}`, `R_оп = ${rOut}`] };
}
case 'trunccone': {
const {R: R1, r: r1, h} = p;
const l = Math.sqrt((R1-r1)**2+h**2);
const V = PI*h*(R1**2+R1*r1+r1**2)/3;
return { V: r(V), S: r(PI*(R1**2+r1**2+(R1+r1)*l)), S_side: r(PI*(R1+r1)*l), d: null, h,
formulas: [`V = ⅓πh(R²+Rr+r²) = ${r(V)}`, `l = ${r(l)}`, `S_бок = π(R+r)l = ${r(PI*(R1+r1)*l)}`] };
}
case 'sphere': {
const rad = p.r;
return { V: r(4*PI*rad**3/3), S: r(4*PI*rad**2), S_side: null, d: r(2*rad), h: r(2*rad),
formulas: [`V = ⁴⁄₃πr³ = ${r(4*PI*rad**3/3)}`, `S = 4πr² = ${r(4*PI*rad**2)}`, `d = 2r = ${r(2*rad)}`] };
}
case 'prism': {
const {a, h, n} = p;
const sBase = n * a**2 / (4 * Math.tan(PI/n));
const apothem = a / (2 * Math.tan(PI/n));
const Rb = a / (2 * Math.sin(PI/n));
const rIn = r(Math.min(apothem, h/2)), rOut = r(Math.sqrt(Rb**2 + (h/2)**2));
return { V: r(sBase*h), S: r(2*sBase + n*a*h), S_side: r(n*a*h), d: null, h,
formulas: [`V = S_осн·h = ${r(sBase*h)}`, `S_осн = ${r(sBase)}`, `S_бок = nah = ${r(n*a*h)}`,
`r_вп = ${rIn}`, `R_оп = ${rOut}`] };
}
case 'truncpyramid': {
const { a, b, h, n } = p;
const sLow = n * a**2 / (4 * Math.tan(PI/n));
const sUp = n * b**2 / (4 * Math.tan(PI/n));
const apLow = a / (2 * Math.tan(PI/n));
const apUp = b / (2 * Math.tan(PI/n));
const slant = Math.sqrt(h**2 + (apLow - apUp)**2);
const sLat = n * (a + b) * slant / 2;
const V = h * (sLow + sUp + Math.sqrt(sLow * sUp)) / 3;
return { V: r(V), S: r(sLow + sUp + sLat), S_side: r(sLat), d: null, h,
formulas: [
`V = h(S₁+S₂+√(S₁·S₂))/3 = ${r(V)}`,
`S₁ = ${r(sLow)}, S₂ = ${r(sUp)}`,
`l (апоф.) = ${r(slant)}`,
`S_бок = n(a+b)l/2 = ${r(sLat)}`,
`S_полн = ${r(sLow + sUp + sLat)}`,
]};
}
case 'octahedron': {
const a = p.a;
const V_val = r(a**3 * Math.SQRT2 / 3);
const S_val = r(2 * a**2 * Math.sqrt(3));
return { V: V_val, S: S_val, S_side: null, d: null, h: r(a * Math.SQRT2),
formulas: [
`V = a³√2/3 = ${V_val}`,
`S = 2a²√3 = ${S_val}`,
`h = a√2 = ${r(a * Math.SQRT2)}`,
`r_вп = a√6/6 = ${r(a * Math.sqrt(6) / 6)}`,
`R_оп = a√2/2 = ${r(a * Math.SQRT2 / 2)}`,
]};
}
case 'icosahedron': {
const a = p.a;
const phi = (1 + Math.sqrt(5)) / 2;
const V_val = r(5 * a**3 * (3 + Math.sqrt(5)) / 12);
const S_val = r(5 * a**2 * Math.sqrt(3));
const rIn = r(a * phi**2 / (2 * Math.sqrt(3)));
const rOut = r(a * Math.sqrt(10 + 2*Math.sqrt(5)) / 4);
return { V: V_val, S: S_val, S_side: null, d: null, h: r(a * Math.sqrt(10 + 2*Math.sqrt(5)) / 2),
formulas: [
`V = 5a³(3+√5)/12 = ${V_val}`,
`S = 5a²√3 = ${S_val}`,
`r_вп ≈ ${rIn}`,
`R_оп = a√(10+2√5)/4 ≈ ${rOut}`,
]};
}
case 'dodecahedron': {
const a = p.a;
const phi = (1 + Math.sqrt(5)) / 2;
const V_val = r(a**3 * (15 + 7*Math.sqrt(5)) / 4);
const S_val = r(3 * a**2 * Math.sqrt(25 + 10*Math.sqrt(5)));
const rIn = r(a / 2 * Math.sqrt((25 + 11*Math.sqrt(5)) / 10));
const rOut = r(a * Math.sqrt(3) * phi / 2);
return { V: V_val, S: S_val, S_side: null, d: null, h: r(a * Math.sqrt(3) * phi),
formulas: [
`V = a³(15+7√5)/4 = ${V_val}`,
`S = 3a²√(25+10√5) = ${S_val}`,
`r_вп ≈ ${rIn}`,
`R_оп = a√3·φ/2 ≈ ${rOut}`,
]};
}
default: return { V: 0, S: 0, S_side: 0, d: 0, h: 0, formulas: [] };
}
}
getSectionArea() {
if (!this.showSection || !this._sectionPolygon || !this._sectionPolygon.length) return 0;
return this._polygonArea(this._sectionPolygon);
}
_polygonPerimeter(pts) {
let p = 0;
for (let i = 0; i < pts.length; i++) p += pts[i].distanceTo(pts[(i + 1) % pts.length]);
return p;
}
// Live, human-readable lines for the viewport readout panel.
getReadout() {
const lines = [];
const r = (v) => Math.round(v * 100) / 100;
const curved = ['cylinder', 'cone', 'trunccone', 'sphere'].includes(this.figureType);
if (this._section3PData) {
const d = this._section3PData;
lines.push({ label: 'Сечение (3 точки)', value: d.typeName });
lines.push({ label: 'Площадь S', value: r(d.area) });
lines.push({ label: 'Периметр P', value: r(this._polygonPerimeter(d.polygon)) });
} else if (this.showSection && this._sectionPolygon && this._sectionPolygon.length >= 3) {
const poly = this._sectionPolygon;
const polyName = (curved && poly.length > 8)
? (this.figureType === 'sphere' || this.sectionType === 'horizontal' ? 'окружность' : 'эллипс')
: ({ 3: 'треугольник', 4: 'четырёхугольник', 5: 'пятиугольник', 6: 'шестиугольник' }[poly.length] || `${poly.length}-угольник`);
const kind = { horizontal: 'горизонтальное', diagonal: 'наклонное', custom: 'произвольное' }[this.sectionType] || '';
lines.push({ label: 'Сечение' + (kind ? ` (${kind})` : ''), value: polyName });
lines.push({ label: 'Площадь S', value: r(this.getSectionArea()) });
lines.push({ label: 'Периметр P', value: r(this._polygonPerimeter(poly)) });
}
if (this._measurements.length) {
const m = this._measurements[this._measurements.length - 1];
lines.push({ label: `Отрезок ${m.from}${m.to}`, value: m.dist });
}
return lines;
}
info() {
const f = this.getFormulas();
return {
type: this.figureType,
V: f.V, S: f.S, S_side: f.S_side, d: f.d, h: f.h,
sectionArea: this.showSection ? this.getSectionArea() : null,
inscribedR: this.showInscribed ? this._inscribedRadius() : null,
circumscribedR: this.showCircumscribed ? this._circumscribedRadius() : null,
customPoints: this._customPoints.length,
connections: this._connections.length,
constructions: this._lines.length + this._planes.length,
readout: this.getReadout(),
};
}
fit() {
const w = this.container.clientWidth || 600;
const h = this.container.clientHeight || 400;
this.camera.aspect = w / h;
this.camera.updateProjectionMatrix();
this.renderer.setSize(w, h);
this._invalidate();
}
play() { if (!this._running) { this._running = true; this._invalidate(); } }
stop() {
this._running = false;
if (this._rafId != null) { cancelAnimationFrame(this._rafId); this._rafId = null; }
}
pause() { this.stop(); }
// Mark the scene dirty and wake the loop if it was asleep.
_invalidate() {
this._needsRender = true;
if (this._running && this._rafId == null && !this._contextLost) {
this._rafId = requestAnimationFrame(() => this._loop());
}
}
/* ════════════════ CAMERA CONTROLS ════════════════ */
// Look-at target = figure-centre + user pan offset.
_camTarget() {
return new THREE.Vector3(0, this._figureHeight() / 2, 0).add(this._panOffset);
}
// Pan the orbit centre in screen space (dx,dy in pixels).
_pan(dx, dy) {
const forward = new THREE.Vector3();
this.camera.getWorldDirection(forward);
const right = new THREE.Vector3().crossVectors(forward, this.camera.up).normalize();
const up = new THREE.Vector3().crossVectors(right, forward).normalize();
const k = this._dist * 0.0016; // pan speed scales with zoom distance
this._panOffset.addScaledVector(right, -dx * k);
this._panOffset.addScaledVector(up, dy * k);
}
resetView() {
const h = this._homeView;
this._rotY = h.rotY; this._rotX = h.rotX; this._dist = h.dist;
this._panOffset.set(0, 0, 0);
this._velX = 0; this._velY = 0;
// Reset = back to the initial state, which gently auto-rotates.
this._spinEnabled = true; this._autoSpin = true; this._idleTime = 0;
this._invalidate();
}
// Snap to a named viewpoint. Disables auto-spin so the view holds still.
setPreset(name) {
const P = {
iso: { rotY: 0.6, rotX: 0.45 },
front: { rotY: 0, rotX: 0.05 },
back: { rotY: Math.PI, rotX: 0.05 },
side: { rotY: Math.PI / 2, rotX: 0.05 },
top: { rotY: 0, rotX: 1.4 },
};
const v = P[name] || P.iso;
this._rotY = v.rotY; this._rotX = v.rotX;
this._panOffset.set(0, 0, 0);
this._velX = 0; this._velY = 0;
// Hold the chosen view: stop spinning and don't let it re-engage on idle.
this._autoSpin = false; this._spinEnabled = false; this._idleTime = 0;
this._invalidate();
}
setAutoSpin(on) {
this._spinEnabled = !!on;
this._autoSpin = !!on;
this._idleTime = 0;
this._velX = 0; this._velY = 0;
this._invalidate();
}
// Render one frame synchronously and return a PNG data URL.
screenshot() {
this._needsRender = true;
this._renderNow();
try { return this.renderer.domElement.toDataURL('image/png'); }
catch (_) { return null; }
}
_renderNow() {
const target = this._camTarget();
this.camera.position.set(
target.x + this._dist * Math.sin(this._rotY) * Math.cos(this._rotX),
target.y + this._dist * Math.sin(this._rotX),
target.z + this._dist * Math.cos(this._rotY) * Math.cos(this._rotX)
);
this.camera.lookAt(target);
this.renderer.render(this.scene, this.camera);
this._needsRender = false;
}
toggleFullscreen() {
const box = this.container.closest('.graph-canvas-outer') || this.container;
if (!document.fullscreenElement) {
if (box.requestFullscreen) box.requestFullscreen();
} else if (document.exitFullscreen) {
document.exitFullscreen();
}
// fit() is driven by the ResizeObserver once the element resizes.
}
// Free GPU + DOM resources. Call when the sim is permanently torn down.
dispose() {
this.stop();
if (this._ro) { this._ro.disconnect(); this._ro = null; }
if (this._listeners) {
for (const [t, type, fn, opts] of this._listeners) t.removeEventListener(type, fn, opts);
this._listeners = [];
}
[this._figGroup, this._labelGroup, this._sectionGroup, this._sphereGroup,
this._measureGroup, this._measurePickGroup, this._gridGroup, this._markGroup,
this._derivedGroup, this._section3PGroup, this._angleGroup, this._pointGroup,
this._constructGroup]
.forEach(g => g && this._clearGroup(g));
if (this._tooltipEl && this._tooltipEl.parentNode) this._tooltipEl.parentNode.removeChild(this._tooltipEl);
if (this.renderer) {
this.renderer.dispose();
const el = this.renderer.domElement;
if (el && el.parentNode) el.parentNode.removeChild(el);
}
}
/* ════════════════ GRID + AXES ════════════════ */
_buildGrid() {
this._clearGroup(this._gridGroup);
if (this.showGrid) {
const grid = new THREE.GridHelper(20, 20, 0x222244, 0x151530);
grid.position.y = -0.01;
this._gridGroup.add(grid);
}
if (this.showAxes) {
const axes = new THREE.AxesHelper(6);
axes.material.transparent = true;
axes.material.opacity = 0.4;
this._gridGroup.add(axes);
// axis letters, coloured to match AxesHelper (X red, Y green, Z blue)
const axLabel = (txt, color, pos) => {
const s = this._makeTextSprite(txt, color, 38);
s.position.copy(pos);
s.scale.multiplyScalar(0.85);
this._gridGroup.add(s);
};
axLabel('X', '#ff5b5b', new THREE.Vector3(6.5, 0.15, 0));
axLabel('Y', '#5bff8d', new THREE.Vector3(0, 6.6, 0));
axLabel('Z', '#5b9bff', new THREE.Vector3(0, 0.15, 6.5));
}
this._invalidate();
}
/* ════════════════ FIGURE BUILDER ════════════════ */
_buildFigure() {
this._clearGroup(this._figGroup);
this._clearGroup(this._labelGroup);
this._vertices = [];
this._edges = [];
this._faces = [];
const builders = {
cube: () => this._buildCube(),
parallelepiped: () => this._buildParallelepiped(),
pyramid: () => this._buildPyramid(),
tetrahedron: () => this._buildTetrahedron(),
cylinder: () => this._buildCylinder(),
cone: () => this._buildCone(),
trunccone: () => this._buildTruncCone(),
sphere: () => this._buildSphere(),
prism: () => this._buildPrism(),
truncpyramid: () => this._buildTruncPyramid(),
octahedron: () => this._buildOctahedron(),
icosahedron: () => this._buildIcosahedron(),
dodecahedron: () => this._buildDodecahedron(),
};
(builders[this.figureType] || builders.cube)();
this._updateSection();
this._updateSpheres();
this._drawHeightLine();
this._drawApothemLine();
this._drawDiagonals();
this._drawMidpoints();
this._renderEdgeMarks();
this._buildDerived3D();
this._invalidate();
}
/* ── BOX helpers ── */
_buildBox(sx, sy, sz) {
const hx = sx/2, hy = sy/2, hz = sz/2;
// 8 vertices
const v = [
new THREE.Vector3(-hx, 0, hz), // A (0)
new THREE.Vector3( hx, 0, hz), // B (1)
new THREE.Vector3( hx, 0, -hz), // C (2)
new THREE.Vector3(-hx, 0, -hz), // D (3)
new THREE.Vector3(-hx, sy, hz), // E (4)
new THREE.Vector3( hx, sy, hz), // F (5)
new THREE.Vector3( hx, sy,-hz), // G (6)
new THREE.Vector3(-hx, sy,-hz), // H (7)
];
const labels = ['A','B','C','D','E','F','G','H'];
this._vertices = v.map((pos, i) => ({ pos, label: labels[i] }));
const edgeIdx = [[0,1],[1,2],[2,3],[3,0],[4,5],[5,6],[6,7],[7,4],[0,4],[1,5],[2,6],[3,7]];
this._edges = edgeIdx.map(([a,b]) => ({ from: v[a], to: v[b] }));
this._faces = [
[v[0],v[1],v[2],v[3]], // bottom
[v[4],v[5],v[6],v[7]], // top
[v[0],v[1],v[5],v[4]], // front
[v[2],v[3],v[7],v[6]], // back
[v[1],v[2],v[6],v[5]], // right
[v[0],v[3],v[7],v[4]], // left
];
// transparent mesh
const geo = new THREE.BoxGeometry(sx, sy, sz);
geo.translate(0, sy/2, 0);
const mat = new THREE.MeshPhysicalMaterial({
color: 0x9B5DE5, transparent: true, opacity: this.opacity,
side: THREE.DoubleSide, metalness: 0.05, roughness: 0.4,
clearcoat: 0.3, depthWrite: false,
});
this._figGroup.add(new THREE.Mesh(geo, mat));
this._addEdges();
this._addVerticesAndLabels();
}
_buildCube() { const a = this.params.a; this._buildBox(a, a, a); }
_buildParallelepiped() { this._buildBox(this.params.a, this.params.c, this.params.b); }
/* ── PYRAMID ── */
_buildPyramid() {
const { a, h, n } = this.params;
const baseVerts = this._regularPolygon(n, a);
const apex = new THREE.Vector3(0, h, 0);
const labels = 'ABCDEFGH'.split('');
this._vertices = baseVerts.map((pos, i) => ({ pos, label: labels[i] || `P${i}` }));
this._vertices.push({ pos: apex, label: 'S' });
// edges: base ring + apex connections
for (let i = 0; i < n; i++) {
this._edges.push({ from: baseVerts[i], to: baseVerts[(i+1)%n] });
this._edges.push({ from: baseVerts[i], to: apex });
}
// faces: base + lateral triangles
this._faces.push([...baseVerts]);
for (let i = 0; i < n; i++) {
this._faces.push([baseVerts[i], baseVerts[(i+1)%n], apex]);
}
this._addMeshFromFaces(0x06D6A0);
this._addEdges();
this._addVerticesAndLabels();
}
/* ── TETRAHEDRON ── */
_buildTetrahedron() {
const a = this.params.a;
const h = a * Math.sqrt(2/3);
const r = a / Math.sqrt(3);
const v = [
new THREE.Vector3(0, 0, r),
new THREE.Vector3(a/2, 0, -r/2),
new THREE.Vector3(-a/2, 0, -r/2),
new THREE.Vector3(0, h, 0),
];
const labels = ['A','B','C','D'];
this._vertices = v.map((pos, i) => ({ pos, label: labels[i] }));
const edgeIdx = [[0,1],[1,2],[2,0],[0,3],[1,3],[2,3]];
this._edges = edgeIdx.map(([a,b]) => ({ from: v[a], to: v[b] }));
this._faces = [
[v[0],v[1],v[2]],
[v[0],v[1],v[3]],
[v[1],v[2],v[3]],
[v[0],v[2],v[3]],
];
this._addMeshFromFaces(0x06D6E0);
this._addEdges();
this._addVerticesAndLabels();
}
/* ── CYLINDER ── */
_buildCylinder() {
const { r, h } = this.params;
const geo = new THREE.CylinderGeometry(r, r, h, 48, 1, false);
geo.translate(0, h/2, 0);
const mat = new THREE.MeshPhysicalMaterial({
color: 0xF59E0B, transparent: true, opacity: this.opacity,
side: THREE.DoubleSide, metalness: 0.05, roughness: 0.4, depthWrite: false,
});
this._figGroup.add(new THREE.Mesh(geo, mat));
// wireframe rings
const ringGeo = new THREE.RingGeometry(r - 0.02, r + 0.02, 48);
const ringMat = new THREE.MeshBasicMaterial({ color: 0xFFD166, side: THREE.DoubleSide });
const bottomRing = new THREE.Mesh(ringGeo, ringMat);
bottomRing.rotation.x = -Math.PI/2;
this._figGroup.add(bottomRing);
const topRing = bottomRing.clone();
topRing.position.y = h;
this._figGroup.add(topRing);
// edges: vertical generators + center axis
const n = 8;
for (let i = 0; i < n; i++) {
const angle = (i / n) * Math.PI * 2;
const x = r * Math.cos(angle), z = r * Math.sin(angle);
this._edges.push({ from: new THREE.Vector3(x, 0, z), to: new THREE.Vector3(x, h, z) });
}
// center axis
this._vertices.push({ pos: new THREE.Vector3(0, 0, 0), label: 'O₁' });
this._vertices.push({ pos: new THREE.Vector3(0, h, 0), label: 'O₂' });
this._edges.push({ from: new THREE.Vector3(0, 0, 0), to: new THREE.Vector3(0, h, 0) });
// base/top circle arc segments (for point-picking)
const arcN = 32;
for (let i = 0; i < arcN; i++) {
const a1 = (i / arcN) * Math.PI * 2, a2 = ((i + 1) / arcN) * Math.PI * 2;
this._edges.push({ from: new THREE.Vector3(r * Math.cos(a1), 0, r * Math.sin(a1)), to: new THREE.Vector3(r * Math.cos(a2), 0, r * Math.sin(a2)) });
this._edges.push({ from: new THREE.Vector3(r * Math.cos(a1), h, r * Math.sin(a1)), to: new THREE.Vector3(r * Math.cos(a2), h, r * Math.sin(a2)) });
}
this._addEdges(0.4);
this._addVerticesAndLabels();
}
/* ── CONE ── */
_buildCone() {
const { r, h } = this.params;
const geo = new THREE.CylinderGeometry(0, r, h, 48, 1, false);
geo.translate(0, h/2, 0);
const mat = new THREE.MeshPhysicalMaterial({
color: 0xE0335E, transparent: true, opacity: this.opacity,
side: THREE.DoubleSide, metalness: 0.05, roughness: 0.4, depthWrite: false,
});
this._figGroup.add(new THREE.Mesh(geo, mat));
// bottom ring
const ringGeo = new THREE.RingGeometry(r - 0.02, r + 0.02, 48);
const ringMat = new THREE.MeshBasicMaterial({ color: 0xFF6B8A, side: THREE.DoubleSide });
const ring = new THREE.Mesh(ringGeo, ringMat);
ring.rotation.x = -Math.PI/2;
this._figGroup.add(ring);
const apex = new THREE.Vector3(0, h, 0);
this._vertices.push({ pos: new THREE.Vector3(0, 0, 0), label: 'O' });
this._vertices.push({ pos: apex, label: 'S' });
// slant lines
const n = 8;
for (let i = 0; i < n; i++) {
const angle = (i / n) * Math.PI * 2;
const x = r * Math.cos(angle), z = r * Math.sin(angle);
this._edges.push({ from: new THREE.Vector3(x, 0, z), to: apex });
}
this._edges.push({ from: new THREE.Vector3(0, 0, 0), to: apex });
// base circle as arc segments (for point-picking)
const arcN = 32;
for (let i = 0; i < arcN; i++) {
const a1 = (i / arcN) * Math.PI * 2, a2 = ((i + 1) / arcN) * Math.PI * 2;
this._edges.push({
from: new THREE.Vector3(r * Math.cos(a1), 0, r * Math.sin(a1)),
to: new THREE.Vector3(r * Math.cos(a2), 0, r * Math.sin(a2)),
});
}
this._addEdges(0.4);
this._addVerticesAndLabels();
}
/* ── TRUNCATED CONE ── */
_buildTruncCone() {
const { R, r, h } = this.params;
const geo = new THREE.CylinderGeometry(r, R, h, 48, 1, false);
geo.translate(0, h/2, 0);
const mat = new THREE.MeshPhysicalMaterial({
color: 0x60A5FA, transparent: true, opacity: this.opacity,
side: THREE.DoubleSide, metalness: 0.05, roughness: 0.4, depthWrite: false,
});
this._figGroup.add(new THREE.Mesh(geo, mat));
// rings
for (const [rad, y] of [[R, 0], [r, h]]) {
const rg = new THREE.RingGeometry(rad - 0.02, rad + 0.02, 48);
const rm = new THREE.MeshBasicMaterial({ color: 0x93C5FD, side: THREE.DoubleSide });
const mesh = new THREE.Mesh(rg, rm);
mesh.rotation.x = -Math.PI/2; mesh.position.y = y;
this._figGroup.add(mesh);
}
this._vertices.push({ pos: new THREE.Vector3(0, 0, 0), label: 'O₁' });
this._vertices.push({ pos: new THREE.Vector3(0, h, 0), label: 'O₂' });
this._edges.push({ from: new THREE.Vector3(0, 0, 0), to: new THREE.Vector3(0, h, 0) });
const n = 8;
for (let i = 0; i < n; i++) {
const angle = (i / n) * Math.PI * 2;
this._edges.push({
from: new THREE.Vector3(R * Math.cos(angle), 0, R * Math.sin(angle)),
to: new THREE.Vector3(r * Math.cos(angle), h, r * Math.sin(angle)),
});
}
// circle arc segments for point-picking on both bases
const arcN = 32;
for (let i = 0; i < arcN; i++) {
const a1 = (i / arcN) * Math.PI * 2, a2 = ((i + 1) / arcN) * Math.PI * 2;
this._edges.push({ from: new THREE.Vector3(R * Math.cos(a1), 0, R * Math.sin(a1)), to: new THREE.Vector3(R * Math.cos(a2), 0, R * Math.sin(a2)) });
this._edges.push({ from: new THREE.Vector3(r * Math.cos(a1), h, r * Math.sin(a1)), to: new THREE.Vector3(r * Math.cos(a2), h, r * Math.sin(a2)) });
}
this._addEdges(0.4);
this._addVerticesAndLabels();
}
/* ── SPHERE ── */
_buildSphere() {
const rad = this.params.r;
const geo = new THREE.SphereGeometry(rad, 48, 32);
geo.translate(0, rad, 0);
const mat = new THREE.MeshPhysicalMaterial({
color: 0x9B5DE5, transparent: true, opacity: this.opacity,
side: THREE.DoubleSide, metalness: 0.1, roughness: 0.3,
clearcoat: 0.5, depthWrite: false,
});
this._figGroup.add(new THREE.Mesh(geo, mat));
// equator + meridian wireframes
const createCircle = (radius, y, rotX) => {
const pts = [];
for (let i = 0; i <= 64; i++) {
const a = (i/64)*Math.PI*2;
pts.push(new THREE.Vector3(radius*Math.cos(a), 0, radius*Math.sin(a)));
}
const lineGeo = new THREE.BufferGeometry().setFromPoints(pts);
const lineMat = new THREE.LineBasicMaterial({ color: 0xCCCCFF, transparent: true, opacity: 0.5 });
const line = new THREE.Line(lineGeo, lineMat);
line.position.y = y;
if (rotX) line.rotation.x = rotX;
return line;
};
this._figGroup.add(createCircle(rad, rad, 0));
this._figGroup.add(createCircle(rad, rad, Math.PI/2));
this._vertices.push({ pos: new THREE.Vector3(0, rad, 0), label: 'O' });
this._vertices.push({ pos: new THREE.Vector3(0, 2*rad, 0), label: 'N' });
this._vertices.push({ pos: new THREE.Vector3(0, 0, 0), label: 'S' });
this._addVerticesAndLabels();
}
/* ── PRISM ── */
_buildPrism() {
const { a, h, n } = this.params;
const baseVerts = this._regularPolygon(n, a);
const topVerts = baseVerts.map(v => new THREE.Vector3(v.x, h, v.z));
const labels = 'ABCDEFGH'.split('');
this._vertices = baseVerts.map((pos, i) => ({ pos, label: labels[i] || `P${i}` }));
topVerts.forEach((pos, i) => this._vertices.push({ pos, label: (labels[i] || `P${i}`) + '₁' }));
// edges
for (let i = 0; i < n; i++) {
this._edges.push({ from: baseVerts[i], to: baseVerts[(i+1)%n] }); // base
this._edges.push({ from: topVerts[i], to: topVerts[(i+1)%n] }); // top
this._edges.push({ from: baseVerts[i], to: topVerts[i] }); // vertical
}
// faces
this._faces.push([...baseVerts]);
this._faces.push([...topVerts]);
for (let i = 0; i < n; i++) {
const j = (i+1) % n;
this._faces.push([baseVerts[i], baseVerts[j], topVerts[j], topVerts[i]]);
}
this._addMeshFromFaces(0x06D6A0);
this._addEdges();
this._addVerticesAndLabels();
}
/* ── TRUNCATED PYRAMID ── */
_buildTruncPyramid() {
const { a, b, h, n } = this.params;
const baseVerts = this._regularPolygon(n, a);
const topVerts = this._regularPolygon(n, b).map(v => new THREE.Vector3(v.x, h, v.z));
const labels = 'ABCDEFGH'.split('');
this._vertices = baseVerts.map((pos, i) => ({ pos, label: labels[i] || `P${i}` }));
topVerts.forEach((pos, i) => this._vertices.push({ pos, label: (labels[i] || `P${i}`) + '₁' }));
for (let i = 0; i < n; i++) {
this._edges.push({ from: baseVerts[i], to: baseVerts[(i+1)%n] });
this._edges.push({ from: topVerts[i], to: topVerts[(i+1)%n] });
this._edges.push({ from: baseVerts[i], to: topVerts[i] });
}
this._faces.push([...baseVerts]);
this._faces.push([...topVerts]);
for (let i = 0; i < n; i++) {
const j = (i+1) % n;
this._faces.push([baseVerts[i], baseVerts[j], topVerts[j], topVerts[i]]);
}
this._addMeshFromFaces(0xF59E0B);
this._addEdges();
this._addVerticesAndLabels();
}
/* ── OCTAHEDRON ── */
_buildOctahedron() {
const a = this.params.a;
const R = a / Math.SQRT2; // equatorial radius (dist from center to eq. vertex)
const v = [
new THREE.Vector3(0, 0, 0), // 0 S₁ (bottom)
new THREE.Vector3( R, R, 0), // 1 A
new THREE.Vector3( 0, R, R), // 2 B
new THREE.Vector3(-R, R, 0), // 3 C
new THREE.Vector3( 0, R, -R), // 4 D
new THREE.Vector3( 0, a * Math.SQRT2, 0), // 5 S₂ (top)
];
const labels = ['S₁','A','B','C','D','S₂'];
this._vertices = v.map((pos, i) => ({ pos, label: labels[i] }));
const edgeIdx = [[0,1],[0,2],[0,3],[0,4],[5,1],[5,2],[5,3],[5,4],[1,2],[2,3],[3,4],[4,1]];
this._edges = edgeIdx.map(([i, j]) => ({ from: v[i], to: v[j] }));
this._faces = [
[v[0],v[1],v[2]], [v[0],v[2],v[3]], [v[0],v[3],v[4]], [v[0],v[4],v[1]],
[v[5],v[2],v[1]], [v[5],v[3],v[2]], [v[5],v[4],v[3]], [v[5],v[1],v[4]],
];
this._addMeshFromFaces(0xF15BB5);
this._addEdges();
this._addVerticesAndLabels();
}
/* ── ICOSAHEDRON ── */
_buildIcosahedron() {
const a = this.params.a;
const circR = a * Math.sqrt(10 + 2 * Math.sqrt(5)) / 4;
const geo = new THREE.IcosahedronGeometry(circR, 0);
const posArr = geo.getAttribute('position').array;
let minY = Infinity;
for (let i = 1; i < posArr.length; i += 3) minY = Math.min(minY, posArr[i]);
geo.translate(0, -minY, 0);
const mat = new THREE.MeshPhysicalMaterial({
color: 0x06D6E0, transparent: true, opacity: this.opacity,
side: THREE.DoubleSide, metalness: 0.05, roughness: 0.4, clearcoat: 0.2, depthWrite: false,
});
this._figGroup.add(new THREE.Mesh(geo, mat));
const verts = this._extractUniqueVerts(geo);
const labels = 'ABCDEFGHIJKL'.split('');
this._vertices = verts.map((pos, i) => ({ pos, label: labels[i] || `V${i+1}` }));
for (let i = 0; i < verts.length; i++)
for (let j = i + 1; j < verts.length; j++)
if (verts[i].distanceTo(verts[j]) <= a * 1.06)
this._edges.push({ from: verts[i], to: verts[j] });
this._addEdges();
this._addVerticesAndLabels();
}
/* ── DODECAHEDRON ── */
_buildDodecahedron() {
const a = this.params.a;
const phi = (1 + Math.sqrt(5)) / 2;
const circR = a * Math.sqrt(3) * phi / 2;
const geo = new THREE.DodecahedronGeometry(circR, 0);
const posArr = geo.getAttribute('position').array;
let minY = Infinity;
for (let i = 1; i < posArr.length; i += 3) minY = Math.min(minY, posArr[i]);
geo.translate(0, -minY, 0);
const mat = new THREE.MeshPhysicalMaterial({
color: 0x9B5DE5, transparent: true, opacity: this.opacity,
side: THREE.DoubleSide, metalness: 0.05, roughness: 0.4, clearcoat: 0.2, depthWrite: false,
});
this._figGroup.add(new THREE.Mesh(geo, mat));
const edgeLen = a * 2 / phi; // dodecahedron edge length relative to circumradius
const verts = this._extractUniqueVerts(geo);
const labels = 'ABCDEFGHIJKLMNOPQRST'.split('');
this._vertices = verts.map((pos, i) => ({ pos, label: labels[i] || `V${i+1}` }));
for (let i = 0; i < verts.length; i++)
for (let j = i + 1; j < verts.length; j++)
if (verts[i].distanceTo(verts[j]) <= a * 1.06)
this._edges.push({ from: verts[i], to: verts[j] });
this._addEdges();
this._addVerticesAndLabels();
}
/* ════════════════ GEOMETRY HELPERS ════════════════ */
_regularPolygon(n, sideLength) {
const r = sideLength / (2 * Math.sin(Math.PI / n));
const pts = [];
for (let i = 0; i < n; i++) {
const angle = (i / n) * Math.PI * 2 - Math.PI / 2;
pts.push(new THREE.Vector3(r * Math.cos(angle), 0, r * Math.sin(angle)));
}
return pts;
}
/* Extract deduplicated vertex list from a THREE.BufferGeometry */
_extractUniqueVerts(geo) {
const arr = geo.getAttribute('position').array;
const map = new Map();
const verts = [];
for (let i = 0; i < arr.length; i += 3) {
const key = `${(arr[i]*1000)|0},${(arr[i+1]*1000)|0},${(arr[i+2]*1000)|0}`;
if (!map.has(key)) {
map.set(key, verts.length);
verts.push(new THREE.Vector3(arr[i], arr[i+1], arr[i+2]));
}
}
return verts;
}
_addMeshFromFaces(color) {
// Build a single mesh from faces
const positions = [];
const indices = [];
let vi = 0;
for (const face of this._faces) {
const base = vi;
for (const v of face) {
positions.push(v.x, v.y, v.z);
vi++;
}
// triangulate fan
for (let i = 1; i < face.length - 1; i++) {
indices.push(base, base + i, base + i + 1);
}
}
const geo = new THREE.BufferGeometry();
geo.setAttribute('position', new THREE.Float32BufferAttribute(positions, 3));
geo.setIndex(indices);
geo.computeVertexNormals();
const mat = new THREE.MeshPhysicalMaterial({
color, transparent: true, opacity: this.opacity,
side: THREE.DoubleSide, metalness: 0.05, roughness: 0.4,
clearcoat: 0.2, depthWrite: false,
});
this._figGroup.add(new THREE.Mesh(geo, mat));
}
_addEdges(opac = 0.9) {
if (!this.showEdges) return;
for (const e of this._edges) {
const pts = [e.from, e.to];
const geo = new THREE.BufferGeometry().setFromPoints(pts);
const mat = new THREE.LineBasicMaterial({ color: 0xFFFFFF, transparent: true, opacity: opac, linewidth: 2 });
const line = new THREE.Line(geo, mat);
line.renderOrder = 2; // draw over the translucent solid for crisp contrast
this._figGroup.add(line);
if (this.showEdgeLengths) {
const len = e.from.distanceTo(e.to);
const mid = new THREE.Vector3().addVectors(e.from, e.to).multiplyScalar(0.5);
const lbl = this._makeTextSprite(len.toFixed(2), '#A8E063', 44);
lbl.position.copy(mid).add(new THREE.Vector3(0.1, 0.12, 0.1));
lbl.scale.set(0.9, 0.4, 1);
this._labelGroup.add(lbl);
}
}
}
_addVerticesAndLabels() {
for (const v of this._vertices) {
if (this.showVertices) {
// soft additive glow halo (texture-free → safe with _clearGroup disposal)
const gGeo = new THREE.SphereGeometry(0.17, 12, 12);
const gMat = new THREE.MeshBasicMaterial({
color: 0x9B5DE5, transparent: true, opacity: 0.16,
blending: THREE.AdditiveBlending, depthWrite: false,
});
const glow = new THREE.Mesh(gGeo, gMat);
glow.position.copy(v.pos);
this._figGroup.add(glow);
const sGeo = new THREE.SphereGeometry(0.08, 12, 12);
const sMat = new THREE.MeshBasicMaterial({ color: 0xFFFFFF });
const sphere = new THREE.Mesh(sGeo, sMat);
sphere.position.copy(v.pos);
sphere.renderOrder = 3;
this._figGroup.add(sphere);
}
if (this.showLabels) {
const sprite = this._makeTextSprite(v.label);
sprite.position.copy(v.pos).add(new THREE.Vector3(0.15, 0.25, 0));
this._labelGroup.add(sprite);
}
}
}
_makeTextSprite(text, color = '#ffffff', size = 64) {
text = String(text);
// High-res, aspect-correct backing canvas → crisp at any zoom / DPI.
const dpr = Math.min(window.devicePixelRatio || 1, 2);
const fontPx = 96; // backing resolution (independent of world size)
const pad = 12;
const meas = document.createElement('canvas').getContext('2d');
meas.font = `bold ${fontPx}px Manrope, sans-serif`;
const wCss = Math.ceil(meas.measureText(text).width) + pad * 2;
const hCss = fontPx + pad * 2;
const canvas = document.createElement('canvas');
canvas.width = Math.max(2, Math.round(wCss * dpr));
canvas.height = Math.max(2, Math.round(hCss * dpr));
const ctx = canvas.getContext('2d');
ctx.scale(dpr, dpr);
ctx.font = `bold ${fontPx}px Manrope, sans-serif`;
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
// dark halo keeps labels legible over bright faces / grid
ctx.lineWidth = fontPx * 0.14;
ctx.lineJoin = 'round';
ctx.strokeStyle = 'rgba(0,0,0,0.55)';
ctx.strokeText(text, wCss / 2, hCss / 2);
ctx.fillStyle = color;
ctx.fillText(text, wCss / 2, hCss / 2);
const tex = new THREE.CanvasTexture(canvas);
tex.minFilter = THREE.LinearFilter;
tex.magFilter = THREE.LinearFilter;
if (this.renderer.capabilities) tex.anisotropy = this.renderer.capabilities.getMaxAnisotropy();
const mat = new THREE.SpriteMaterial({ map: tex, transparent: true, depthTest: false });
const sprite = new THREE.Sprite(mat);
// world height scales with the requested `size`; width follows the text aspect
const worldH = (size / 64) * 0.5;
sprite.scale.set(worldH * (wCss / hCss), worldH, 1);
return sprite;
}
/* ════════════════ SECTION PLANE ════════════════ */
_updateSection() {
this._clearGroup(this._sectionGroup);
this._sectionPolygon = null;
this._invalidate();
if (!this.showSection) return;
const figH = this._figureHeight();
const t = this.sectionType;
if (t === 'horizontal') {
const y = figH * this.sectionHeight;
this._sectionPolygon = this._sliceAtY(y);
if (this._sectionPolygon.length >= 3) {
this._drawSectionPolygon(this._sectionPolygon, y);
// translucent plane
const planeGeo = new THREE.PlaneGeometry(16, 16);
const planeMat = new THREE.MeshBasicMaterial({ color: 0x06D6E0, transparent: true, opacity: 0.08, side: THREE.DoubleSide });
const planeMesh = new THREE.Mesh(planeGeo, planeMat);
planeMesh.rotation.x = -Math.PI/2;
planeMesh.position.y = y;
this._sectionGroup.add(planeMesh);
}
} else if (t === 'diagonal') {
this._sectionPolygon = this._sliceDiagonal();
if (this._sectionPolygon.length >= 3) {
this._drawSectionPolygon3D(this._sectionPolygon);
this._drawSectionInfo(this._sectionPolygon);
}
} else if (t === 'custom') {
// Need ≥3 custom points to define a plane
if (this._customPoints.length >= 3) {
const pts3 = this._customPoints.slice(0, 3).map(p => p.pos);
this._sectionPolygon = this._sliceByPlane(pts3[0], pts3[1], pts3[2]);
if (this._sectionPolygon.length >= 3) {
this._drawSectionPolygon3D(this._sectionPolygon);
this._drawSectionInfo(this._sectionPolygon);
}
}
}
this._notify();
}
_figureHeight() {
const p = this.params;
switch (this.figureType) {
case 'cube': return p.a;
case 'parallelepiped': return p.c;
case 'pyramid': case 'prism': case 'truncpyramid': return p.h;
case 'tetrahedron': return p.a * Math.sqrt(2/3);
case 'cylinder': case 'cone': case 'trunccone': return p.h;
case 'sphere': return p.r * 2;
case 'octahedron': return p.a * Math.SQRT2;
case 'icosahedron': return p.a * Math.sqrt(10 + 2*Math.sqrt(5)) / 2;
case 'dodecahedron': { const phi = (1+Math.sqrt(5))/2; return p.a * Math.sqrt(3) * phi; }
default: return p.h || p.a || 4;
}
}
_sliceAtY(y) {
// Find intersections of all edges with horizontal plane y
const points = [];
for (const e of this._edges) {
const y1 = e.from.y, y2 = e.to.y;
if ((y1 <= y && y2 >= y) || (y2 <= y && y1 >= y)) {
if (Math.abs(y2 - y1) < 1e-9) continue;
const t = (y - y1) / (y2 - y1);
if (t < -0.001 || t > 1.001) continue;
const pt = new THREE.Vector3().lerpVectors(e.from, e.to, t);
points.push(pt);
}
}
// For cylinders/cones/spheres with no explicit edges, generate circle
if (points.length < 3) {
const circleR = this._radiusAtHeight(y);
if (circleR > 0.01) {
const n = 48;
for (let i = 0; i < n; i++) {
const a = (i/n)*Math.PI*2;
points.push(new THREE.Vector3(circleR*Math.cos(a), y, circleR*Math.sin(a)));
}
}
}
// Sort by angle from centroid
return this._sortByAngle(points);
}
_radiusAtHeight(y) {
const p = this.params;
const fh = this._figureHeight();
const t = Math.max(0, Math.min(1, y / fh));
switch (this.figureType) {
case 'cylinder': return p.r;
case 'cone': return p.r * (1 - t);
case 'trunccone': return p.R + (p.r - p.R) * t;
case 'sphere': {
const dy = y - p.r; // center at (0, r, 0)
const r2 = p.r**2 - dy**2;
return r2 > 0 ? Math.sqrt(r2) : 0;
}
default: return 0;
}
}
_sliceDiagonal() {
const fh = this._figureHeight();
const y = fh * this.sectionHeight;
// Tilt angle: sectionAngle 0=horizontal, 1=nearly vertical (~80°)
const tiltRad = this.sectionAngle * Math.PI * 0.44; // 0..~80°
// Normal rotated from vertical (0,1,0) toward X axis
const normal = new THREE.Vector3(Math.sin(tiltRad), Math.cos(tiltRad), 0).normalize();
const pointOnPlane = new THREE.Vector3(0, y, 0);
return this._sliceByNormal(normal, pointOnPlane);
}
_sliceByPlane(p1, p2, p3) {
// Plane through 3 points
const v1 = new THREE.Vector3().subVectors(p2, p1);
const v2 = new THREE.Vector3().subVectors(p3, p1);
const normal = new THREE.Vector3().crossVectors(v1, v2).normalize();
if (normal.length() < 1e-9) return [];
return this._sliceByNormal(normal, p1);
}
_sliceByNormal(normal, pointOnPlane) {
const d = -normal.dot(pointOnPlane);
const curved = ['cylinder', 'cone', 'trunccone', 'sphere'].includes(this.figureType);
// Curved solids: analytic (smooth) intersection where possible.
if (curved) {
const poly = this._sliceCurvedByNormal(normal, d);
if (poly && poly.length >= 3) return poly;
// else fall through to the generic sampler (e.g. near-vertical planes)
}
const points = [];
// Intersect with all edges (polyhedra)
for (const e of this._edges) {
const d1 = normal.dot(e.from) + d;
const d2 = normal.dot(e.to) + d;
if ((d1 <= 0 && d2 >= 0) || (d2 <= 0 && d1 >= 0)) {
if (Math.abs(d2 - d1) < 1e-9) continue;
const t = -d1 / (d2 - d1);
if (t < -0.001 || t > 1.001) continue;
points.push(new THREE.Vector3().lerpVectors(e.from, e.to, Math.max(0, Math.min(1, t))));
}
}
// Fallback sampler for curved solids when the analytic path bailed out.
if (points.length < 3 && curved) {
const fh = this._figureHeight();
const samples = 96;
for (let i = 0; i < samples; i++) {
const angle = (i / samples) * Math.PI * 2;
for (let step = 0; step <= 100; step++) {
const y = (step / 100) * fh;
const r = this._radiusAtHeight(y);
if (r < 0.01) continue;
const pt = new THREE.Vector3(r * Math.cos(angle), y, r * Math.sin(angle));
const dist = normal.dot(pt) + d;
if (Math.abs(dist) < fh * 0.012) { points.push(pt); break; }
}
}
}
// Sort into convex polygon order (project onto plane, sort by angle)
return this._sortByAngle3D(points, normal);
}
// Analytic plane∩(solid of revolution) — returns an ordered, smooth polygon
// (circle for a sphere, ellipse/conic arc for cylinder/cone), or null to defer.
_sliceCurvedByNormal(normal, d) {
const ft = this.figureType;
const fh = this._figureHeight();
const TAU = Math.PI * 2;
if (ft === 'sphere') {
const R = this.params.r;
const C = new THREE.Vector3(0, R, 0); // sphere centre
const dist = normal.dot(C) + d; // signed distance centre→plane
if (Math.abs(dist) >= R) return []; // plane misses the sphere
const rho = Math.sqrt(Math.max(0, R * R - dist * dist));
const center = C.clone().addScaledVector(normal, -dist); // projection onto plane
// orthonormal basis in the plane
let u = Math.abs(normal.x) > 0.9 ? new THREE.Vector3(0, 1, 0) : new THREE.Vector3(1, 0, 0);
u = new THREE.Vector3().crossVectors(normal, u).normalize();
const v = new THREE.Vector3().crossVectors(normal, u).normalize();
const out = [];
const N = 72;
for (let i = 0; i < N; i++) {
const a = (i / N) * TAU;
out.push(center.clone().addScaledVector(u, rho * Math.cos(a)).addScaledVector(v, rho * Math.sin(a)));
}
return out; // already ordered around the circle
}
// cylinder / cone / trunccone — lateral surface r(y) = r0 + k·y
if (Math.abs(normal.y) < 0.05) return null; // near-vertical plane → defer
const r0 = this._radiusAtHeight(0);
const r1 = this._radiusAtHeight(fh);
const k = (r1 - r0) / fh;
const out = [];
const N = 120;
for (let i = 0; i < N; i++) {
const a = (i / N) * TAU;
const c = normal.x * Math.cos(a) + normal.z * Math.sin(a);
// plane: c·r(y) + n_y·y + d = 0, with r(y) = r0 + k·y
const denom = c * k + normal.y;
if (Math.abs(denom) < 1e-9) continue;
const y = -(c * r0 + d) / denom;
if (y < -1e-6 || y > fh + 1e-6) continue; // generator meets plane outside the body
const r = r0 + k * Math.max(0, Math.min(fh, y));
if (r < 1e-4) continue;
out.push(new THREE.Vector3(r * Math.cos(a), Math.max(0, Math.min(fh, y)), r * Math.sin(a)));
}
return out.length >= 3 ? this._sortByAngle3D(out, normal) : null;
}
_sortByAngle(points) {
if (points.length < 3) return points;
const cx = points.reduce((s,p) => s+p.x, 0) / points.length;
const cz = points.reduce((s,p) => s+p.z, 0) / points.length;
points.sort((a,b) => Math.atan2(a.z-cz, a.x-cx) - Math.atan2(b.z-cz, b.x-cx));
return points;
}
_sortByAngle3D(points, normal) {
if (points.length < 3) return points;
// Remove near-duplicates
const unique = [points[0]];
for (let i = 1; i < points.length; i++) {
let dup = false;
for (const u of unique) {
if (points[i].distanceTo(u) < 0.01) { dup = true; break; }
}
if (!dup) unique.push(points[i]);
}
if (unique.length < 3) return unique;
// Centroid
const c = new THREE.Vector3();
unique.forEach(p => c.add(p));
c.divideScalar(unique.length);
// Build local 2D basis on the plane
const u = new THREE.Vector3().subVectors(unique[0], c).normalize();
const v = new THREE.Vector3().crossVectors(normal, u).normalize();
// Project and sort by angle
unique.sort((a, b) => {
const da = new THREE.Vector3().subVectors(a, c);
const db = new THREE.Vector3().subVectors(b, c);
return Math.atan2(da.dot(v), da.dot(u)) - Math.atan2(db.dot(v), db.dot(u));
});
return unique;
}
_drawSectionInfo(pts) {
if (pts.length < 3) return;
const area = this._polygonArea(pts);
let perimeter = 0;
for (let i = 0; i < pts.length; i++) {
perimeter += pts[i].distanceTo(pts[(i + 1) % pts.length]);
}
// Label at centroid
const c = new THREE.Vector3();
pts.forEach(p => c.add(p));
c.divideScalar(pts.length);
const label = this._makeTextSprite(`S=${area.toFixed(1)} P=${perimeter.toFixed(1)}`, '#06D6E0', 36);
label.position.copy(c).add(new THREE.Vector3(0, 0.4, 0));
label.scale.set(1.6, 0.5, 1);
this._sectionGroup.add(label);
// Vertex markers + letter labels for genuine polygons; smooth conic
// sections (many sampled points) would otherwise render dozens of spheres.
if (pts.length <= 12) {
const LETTERS = 'KLMNPQRSTUV';
pts.forEach((p, i) => {
const sGeo = new THREE.SphereGeometry(0.06, 8, 8);
const sMat = new THREE.MeshBasicMaterial({ color: 0x06D6E0 });
const sm = new THREE.Mesh(sGeo, sMat);
sm.position.copy(p);
this._sectionGroup.add(sm);
// letter label pushed slightly outward from the section centroid
const lbl = this._makeTextSprite(LETTERS[i] || `${i + 1}`, '#06D6E0', 34);
const out = new THREE.Vector3().subVectors(p, c).normalize().multiplyScalar(0.32);
lbl.position.copy(p).add(out).add(new THREE.Vector3(0, 0.18, 0));
this._sectionGroup.add(lbl);
});
}
}
_drawSectionPolygon(pts, y) {
if (pts.length < 3) return;
// Fill polygon
const shape = new THREE.Shape();
shape.moveTo(pts[0].x, pts[0].z);
for (let i = 1; i < pts.length; i++) shape.lineTo(pts[i].x, pts[i].z);
shape.closePath();
const geo = new THREE.ShapeGeometry(shape);
const mat = new THREE.MeshBasicMaterial({ color: 0x06D6E0, transparent: true, opacity: 0.35, side: THREE.DoubleSide });
const mesh = new THREE.Mesh(geo, mat);
mesh.rotation.x = -Math.PI/2;
mesh.position.y = y + 0.005;
this._sectionGroup.add(mesh);
// outline
const linePts = [...pts, pts[0]];
const lineGeo = new THREE.BufferGeometry().setFromPoints(linePts);
const lineMat = new THREE.LineBasicMaterial({ color: 0x06D6E0, linewidth: 2 });
this._sectionGroup.add(new THREE.Line(lineGeo, lineMat));
}
_drawSectionPolygon3D(pts) {
if (pts.length < 3) return;
const positions = [];
const indices = [];
pts.forEach(p => positions.push(p.x, p.y, p.z));
for (let i = 1; i < pts.length - 1; i++) indices.push(0, i, i+1);
const geo = new THREE.BufferGeometry();
geo.setAttribute('position', new THREE.Float32BufferAttribute(positions, 3));
geo.setIndex(indices);
geo.computeVertexNormals();
const mat = new THREE.MeshBasicMaterial({ color: 0x06D6E0, transparent: true, opacity: 0.35, side: THREE.DoubleSide });
this._sectionGroup.add(new THREE.Mesh(geo, mat));
const linePts = [...pts, pts[0]];
const lineGeo = new THREE.BufferGeometry().setFromPoints(linePts);
this._sectionGroup.add(new THREE.Line(lineGeo, new THREE.LineBasicMaterial({ color: 0x06D6E0 })));
}
_polygonArea(pts) {
// Shoelace in 3D — project onto best-fit plane
if (pts.length < 3) return 0;
let area = 0;
const n = pts.length;
const cx = pts.reduce((s,p)=>s+p.x,0)/n;
const cy = pts.reduce((s,p)=>s+p.y,0)/n;
const cz = pts.reduce((s,p)=>s+p.z,0)/n;
// Cross-product sum
const cross = new THREE.Vector3(0,0,0);
for (let i = 0; i < n; i++) {
const a = new THREE.Vector3().subVectors(pts[i], new THREE.Vector3(cx,cy,cz));
const b = new THREE.Vector3().subVectors(pts[(i+1)%n], new THREE.Vector3(cx,cy,cz));
cross.add(new THREE.Vector3().crossVectors(a, b));
}
return Math.round(cross.length() / 2 * 100) / 100;
}
/* ════════════════ INSCRIBED / CIRCUMSCRIBED ════════════════ */
_inscribedRadius() {
const p = this.params;
const PI = Math.PI;
switch (this.figureType) {
case 'cube': return p.a / 2;
case 'parallelepiped': return Math.min(p.a, p.b, p.c) / 2;
case 'tetrahedron': return p.a / (2 * Math.sqrt(6));
case 'sphere': return p.r;
case 'cylinder': return Math.min(p.r, p.h / 2);
case 'cone': {
// r_in = r * h / (r + sqrt(r²+h²))
const l = Math.sqrt(p.r ** 2 + p.h ** 2);
return p.r * p.h / (p.r + l);
}
case 'trunccone': {
// inscribed sphere radius = h * (R - r) / (2 * sqrt((R-r)² + h²))... approximate
// exact for truncated cone: r_in = h / (1 + sqrt(1 + ((R-r)/h)²)) * (not standard)
// simpler: r_in = h * min(R, r) / sqrt((R-r)² + h²) — approximate
// Actually: for truncated cone, inscribed sphere touches both bases and lateral surface
// r_in = h * (R + r - sqrt((R-r)² + h²)) / (2 * (R - r)) when R ≠ r
if (Math.abs(p.R - p.r) < 0.001) return Math.min(p.R, p.h / 2); // cylinder-like
const l = Math.sqrt((p.R - p.r) ** 2 + p.h ** 2);
return p.h * (p.R + p.r - l) / (2 * Math.abs(p.R - p.r));
}
case 'pyramid': {
// r_in = 3V / S_total
const { a, h, n } = p;
const sBase = n * a ** 2 / (4 * Math.tan(PI / n));
const apothem = a / (2 * Math.tan(PI / n));
const slantH = Math.sqrt(h ** 2 + apothem ** 2);
const sLat = n * a * slantH / 2;
const V = sBase * h / 3;
return 3 * V / (sBase + sLat);
}
case 'prism': {
const { a, h, n } = p;
const apothem = a / (2 * Math.tan(PI / n));
return Math.min(apothem, h / 2);
}
case 'octahedron': return p.a * Math.sqrt(6) / 6;
case 'icosahedron': { const phi = (1+Math.sqrt(5))/2; return p.a * phi**2 / (2*Math.sqrt(3)); }
case 'dodecahedron': return p.a / 2 * Math.sqrt((25 + 11*Math.sqrt(5)) / 10);
default: return null;
}
}
_circumscribedRadius() {
const p = this.params;
const PI = Math.PI;
switch (this.figureType) {
case 'cube': return p.a * Math.sqrt(3) / 2;
case 'parallelepiped': return Math.sqrt(p.a ** 2 + p.b ** 2 + p.c ** 2) / 2;
case 'tetrahedron': return p.a * Math.sqrt(6) / 4;
case 'sphere': return p.r;
case 'cylinder': return Math.sqrt(p.r ** 2 + (p.h / 2) ** 2);
case 'cone': {
// circumscribed sphere around cone: passes through apex and base circle
// R = (r² + h²) / (2h)
return (p.r ** 2 + p.h ** 2) / (2 * p.h);
}
case 'trunccone': {
// R = sqrt(R² + (h/2 + (R²-r²)/(2h))²) — approximate
const hc = p.h / 2 + (p.R ** 2 - p.r ** 2) / (2 * p.h);
return Math.sqrt(p.R ** 2 + hc ** 2);
}
case 'pyramid': {
// R = sqrt(R_base² + h²_offset) where R_base = circumradius of base polygon
// center of circumscribed sphere at height y: y = h - R, R² = R_base² + (h - y)²
// solving: R = (R_base² + h²) / (2h)
const { a, h, n } = p;
const Rb = a / (2 * Math.sin(PI / n));
return (Rb ** 2 + h ** 2) / (2 * h);
}
case 'prism': {
const { a, h, n } = p;
const Rb = a / (2 * Math.sin(PI / n));
return Math.sqrt(Rb ** 2 + (h / 2) ** 2);
}
case 'octahedron': return p.a * Math.SQRT2 / 2;
case 'icosahedron': return p.a * Math.sqrt(10 + 2*Math.sqrt(5)) / 4;
case 'dodecahedron': { const phi = (1+Math.sqrt(5))/2; return p.a * Math.sqrt(3) * phi / 2; }
default: return null;
}
}
_inscribedCenter() {
const p = this.params;
const PI = Math.PI;
switch (this.figureType) {
case 'cube': return p.a / 2;
case 'parallelepiped': return p.c / 2;
case 'tetrahedron': return p.a / (2 * Math.sqrt(6)); // r_in from base
case 'sphere': return p.r;
case 'cylinder': return p.h / 2;
case 'cone': {
const l = Math.sqrt(p.r ** 2 + p.h ** 2);
return p.r * p.h / (p.r + l); // r_in = height of center
}
case 'trunccone': return p.h / 2;
case 'pyramid': {
// inscribed sphere center at height r_in from base
const { a, h, n } = p;
const sBase = n * a ** 2 / (4 * Math.tan(PI / n));
const apothem = a / (2 * Math.tan(PI / n));
const slantH = Math.sqrt(h ** 2 + apothem ** 2);
const sLat = n * a * slantH / 2;
const V = sBase * h / 3;
return 3 * V / (sBase + sLat); // r_in
}
case 'prism': return p.h / 2;
case 'octahedron': return p.a * Math.SQRT2 / 2; // center of octahedron
case 'icosahedron': return p.a * Math.sqrt(10 + 2*Math.sqrt(5)) / 4; // circumR = half-height approx
case 'dodecahedron': { const phi = (1+Math.sqrt(5))/2; return p.a * Math.sqrt(3) * phi / 2; }
default: return this._figureHeight() / 2;
}
}
_circumscribedCenter() {
const p = this.params;
const PI = Math.PI;
switch (this.figureType) {
case 'cube': return p.a / 2;
case 'parallelepiped': return p.c / 2;
case 'tetrahedron': {
// center at h - R from base = h - a*sqrt(6)/4
const h = p.a * Math.sqrt(2 / 3);
return h - p.a * Math.sqrt(6) / 4;
}
case 'sphere': return p.r;
case 'cylinder': return p.h / 2;
case 'cone': {
// center at y = R_circ from apex? No. center at y = h - R + h_offset
// R = (r²+h²)/(2h), center at y = h - R = h - (r²+h²)/(2h) = (h²-r²)/(2h)
// but if r > h, center goes below base — clamp
const R = (p.r ** 2 + p.h ** 2) / (2 * p.h);
return p.h - R;
}
case 'trunccone': {
const hc = p.h / 2 + (p.R ** 2 - p.r ** 2) / (2 * p.h);
return hc; // height of center from base
}
case 'pyramid': {
const { a, h, n } = p;
const Rb = a / (2 * Math.sin(PI / n));
const R = (Rb ** 2 + h ** 2) / (2 * h);
return h - R; // from base
}
case 'prism': return p.h / 2;
case 'octahedron': return p.a * Math.SQRT2 / 2;
case 'icosahedron': return p.a * Math.sqrt(10 + 2*Math.sqrt(5)) / 4;
case 'dodecahedron': { const phi = (1+Math.sqrt(5))/2; return p.a * Math.sqrt(3) * phi / 2; }
default: return this._figureHeight() / 2;
}
}
_updateSpheres() {
this._clearGroup(this._sphereGroup);
this._invalidate();
if (this.showInscribed) {
const r = this._inscribedRadius();
const cy = this._inscribedCenter();
if (r && r > 0) {
const geo = new THREE.SphereGeometry(r, 32, 24);
const mat = new THREE.MeshPhysicalMaterial({
color: 0x06D6E0, transparent: true, opacity: 0.12,
side: THREE.DoubleSide, depthWrite: false,
});
const mesh = new THREE.Mesh(geo, mat);
mesh.position.y = cy;
this._sphereGroup.add(mesh);
const wf = new THREE.LineSegments(
new THREE.WireframeGeometry(geo),
new THREE.LineBasicMaterial({ color: 0x06D6E0, transparent: true, opacity: 0.2 })
);
wf.position.y = cy;
this._sphereGroup.add(wf);
// radius label
const lbl = this._makeTextSprite(`r=${r.toFixed(2)}`, '#06D6E0', 36);
lbl.position.set(r * 0.5, cy + r * 0.3, 0);
lbl.scale.set(1.0, 0.4, 1);
this._sphereGroup.add(lbl);
// radius line
const lineGeo = new THREE.BufferGeometry().setFromPoints([
new THREE.Vector3(0, cy, 0), new THREE.Vector3(r, cy, 0)
]);
const lineMat = new THREE.LineDashedMaterial({ color: 0x06D6E0, dashSize: 0.1, gapSize: 0.06, transparent: true, opacity: 0.7 });
const line = new THREE.Line(lineGeo, lineMat);
line.computeLineDistances();
this._sphereGroup.add(line);
}
}
if (this.showCircumscribed) {
const R = this._circumscribedRadius();
const cy = this._circumscribedCenter();
if (R && R > 0) {
const geo = new THREE.SphereGeometry(R, 32, 24);
const mat = new THREE.MeshPhysicalMaterial({
color: 0xF59E0B, transparent: true, opacity: 0.08,
side: THREE.DoubleSide, depthWrite: false,
});
const mesh = new THREE.Mesh(geo, mat);
mesh.position.y = cy;
this._sphereGroup.add(mesh);
const wf = new THREE.LineSegments(
new THREE.WireframeGeometry(geo),
new THREE.LineBasicMaterial({ color: 0xF59E0B, transparent: true, opacity: 0.15 })
);
wf.position.y = cy;
this._sphereGroup.add(wf);
// radius label
const lbl = this._makeTextSprite(`R=${R.toFixed(2)}`, '#F59E0B', 36);
lbl.position.set(R * 0.5, cy + R * 0.3, 0);
lbl.scale.set(1.0, 0.4, 1);
this._sphereGroup.add(lbl);
// radius line
const lineGeo = new THREE.BufferGeometry().setFromPoints([
new THREE.Vector3(0, cy, 0), new THREE.Vector3(R, cy, 0)
]);
const lineMat = new THREE.LineDashedMaterial({ color: 0xF59E0B, dashSize: 0.1, gapSize: 0.06, transparent: true, opacity: 0.7 });
const line = new THREE.Line(lineGeo, lineMat);
line.computeLineDistances();
this._sphereGroup.add(line);
}
}
}
/* ════════════════ SECTION THROUGH 3 POINTS ════════════════ */
_onSection3PClick(e) {
if (!this._section3PMode) return;
if (this._section3PPicks.length >= 3) return; // already have 3 — need reset first
const { mx, my } = this._screenCoords(e);
// Pick nearest point: prefer vertex snap, then edge snap
let bestDist = 0.09;
let bestPos = null;
for (const v of this._vertices) {
const proj = v.pos.clone().project(this.camera);
const d = Math.sqrt((mx - proj.x) ** 2 + (my - proj.y) ** 2);
if (d < bestDist) { bestDist = d; bestPos = v.pos.clone(); }
}
// Also check custom points if placed
for (const cp of this._customPoints) {
const proj = cp.pos.clone().project(this.camera);
const d = Math.sqrt((mx - proj.x) ** 2 + (my - proj.y) ** 2);
if (d < bestDist) { bestDist = d; bestPos = cp.pos.clone(); }
}
// Edge snap (pick point on edge)
for (const edge of this._edges) {
const p1 = edge.from.clone().project(this.camera);
const p2 = edge.to.clone().project(this.camera);
const dx = p2.x - p1.x, dy = p2.y - p1.y;
const lenSq = dx * dx + dy * dy;
if (lenSq < 1e-9) continue;
let t = ((mx - p1.x) * dx + (my - p1.y) * dy) / lenSq;
t = Math.max(0, Math.min(1, t));
const px = p1.x + t * dx, py = p1.y + t * dy;
const d = Math.sqrt((mx - px) ** 2 + (my - py) ** 2);
if (d < bestDist) {
bestDist = d;
bestPos = new THREE.Vector3().lerpVectors(edge.from, edge.to, t);
}
}
if (!bestPos) return;
// Avoid duplicate picks (too close)
for (const p of this._section3PPicks) {
if (p.distanceTo(bestPos) < 0.08) return;
}
this._section3PPicks.push(bestPos);
this._drawSection3P();
if (window.LabFX) LabFX.sound.play('tick', { pitch: 1.5, volume: 0.3 });
if (this._section3PPicks.length === 3) {
this._computeSection3P();
this._drawSection3P();
this._notify();
if (window.LabFX) LabFX.sound.play('chime', { pitch: 1.1 });
}
}
_computeSection3P() {
const pts = this._section3PPicks;
if (pts.length < 3) { this._section3PData = null; return; }
const [P1, P2, P3] = pts;
const v1 = new THREE.Vector3().subVectors(P2, P1);
const v2 = new THREE.Vector3().subVectors(P3, P1);
const normal = new THREE.Vector3().crossVectors(v1, v2);
if (normal.length() < 1e-9) { this._section3PData = null; return; }
normal.normalize();
const D = -normal.dot(P1);
// Intersect the plane with all edges of the solid
const polygon = this._sliceByNormal(normal, P1);
if (polygon.length < 3) { this._section3PData = null; return; }
const area = this._polygonArea(polygon);
const n = polygon.length;
const curved = ['cylinder', 'cone', 'trunccone', 'sphere'].includes(this.figureType);
const typeNames = { 3: 'треугольник', 4: 'четырёхугольник', 5: 'пятиугольник', 6: 'шестиугольник' };
let typeName;
if (curved && n > 8) {
typeName = this.figureType === 'sphere' ? 'окружность'
: (Math.abs(normal.y) > 0.98 ? 'окружность' : 'эллипс (коническое сечение)');
} else {
typeName = typeNames[n] || `${n}-угольник`;
}
this._section3PData = { normal, D, polygon, area, typeName, P1, P2, P3 };
}
_drawSection3P() {
this._clearGroup(this._section3PGroup);
const picks = this._section3PPicks;
const data = this._section3PData;
// Draw picked points as spheres (yellow accent)
const PICK_COLOR = 0xFFD166;
const PLANE_COLOR = 0xEF476F;
const SECT_COLOR = 0x7BF5A4;
picks.forEach((p, i) => {
const sGeo = new THREE.SphereGeometry(0.13, 10, 10);
const sMat = new THREE.MeshBasicMaterial({ color: PICK_COLOR });
const s = new THREE.Mesh(sGeo, sMat);
s.position.copy(p);
this._section3PGroup.add(s);
// Number label
const lbl = this._makeTextSprite(String(i + 1), '#FFD166', 42);
lbl.position.copy(p).add(new THREE.Vector3(0.25, 0.25, 0));
lbl.scale.set(0.7, 0.28, 1);
this._section3PGroup.add(lbl);
});
// Connector triangle between picks — shown live while picking and in normal
// mode; hidden during the step build-up (step 2 draws same-face sides itself).
const showPickLines = !this._section3PStepBy;
if (showPickLines && picks.length >= 2) {
const lg1 = new THREE.BufferGeometry().setFromPoints([picks[0], picks[1]]);
this._section3PGroup.add(new THREE.Line(lg1, new THREE.LineBasicMaterial({ color: PICK_COLOR, opacity: 0.7, transparent: true })));
}
if (showPickLines && picks.length >= 3) {
const lg2 = new THREE.BufferGeometry().setFromPoints([picks[1], picks[2]]);
this._section3PGroup.add(new THREE.Line(lg2, new THREE.LineBasicMaterial({ color: PICK_COLOR, opacity: 0.7, transparent: true })));
const lg3 = new THREE.BufferGeometry().setFromPoints([picks[2], picks[0]]);
this._section3PGroup.add(new THREE.Line(lg3, new THREE.LineBasicMaterial({ color: PICK_COLOR, opacity: 0.5, transparent: true })));
}
if (!data || picks.length < 3) return;
// In step mode the step counter must be ≥1 (any reset path may have zeroed it),
// otherwise the section would be hidden and no step drawn.
if (this._section3PStepBy && this._section3PStep < 1) this._section3PStep = 1;
// In step-by-step mode the finished section is hidden until step ≥ 5 so the
// trace construction builds up; otherwise (or at the end) show it in full.
const showFull = !this._section3PStepBy || this._section3PStep >= 5;
// Semi-transparent plane quad (large enough to show context)
const { normal, D, polygon } = data;
// Build a visible plane chip — use bounding box of polygon centroid + spread
const c = new THREE.Vector3();
polygon.forEach(p => c.add(p));
c.divideScalar(polygon.length);
// Local basis on plane
const u = new THREE.Vector3().subVectors(polygon[0], c).normalize();
const v = new THREE.Vector3().crossVectors(normal, u).normalize();
const spread = Math.max(...polygon.map(p => c.distanceTo(p))) * 1.5;
const planeVerts = [
c.clone().addScaledVector(u, -spread).addScaledVector(v, -spread),
c.clone().addScaledVector(u, spread).addScaledVector(v, -spread),
c.clone().addScaledVector(u, spread).addScaledVector(v, spread),
c.clone().addScaledVector(u, -spread).addScaledVector(v, spread),
];
const planePositions = [];
[[0,1,2],[0,2,3]].forEach(tri => tri.forEach(i => {
const pv = planeVerts[i];
planePositions.push(pv.x, pv.y, pv.z);
}));
const planeGeo = new THREE.BufferGeometry();
planeGeo.setAttribute('position', new THREE.Float32BufferAttribute(planePositions, 3));
const planeMat = new THREE.MeshBasicMaterial({ color: PLANE_COLOR, transparent: true, opacity: 0.08, side: THREE.DoubleSide });
this._section3PGroup.add(new THREE.Mesh(planeGeo, planeMat));
if (showFull) {
// Cross-section polygon fill
const sectPositions = [];
const sectIndices = [];
polygon.forEach(p => sectPositions.push(p.x, p.y, p.z));
for (let i = 1; i < polygon.length - 1; i++) sectIndices.push(0, i, i + 1);
const sectGeo = new THREE.BufferGeometry();
sectGeo.setAttribute('position', new THREE.Float32BufferAttribute(sectPositions, 3));
sectGeo.setIndex(sectIndices);
sectGeo.computeVertexNormals();
const sectMat = new THREE.MeshBasicMaterial({ color: SECT_COLOR, transparent: true, opacity: 0.45, side: THREE.DoubleSide });
this._section3PGroup.add(new THREE.Mesh(sectGeo, sectMat));
// Polygon outline (slightly offset along normal for visibility)
const outlinePts = [...polygon, polygon[0]].map(p =>
p.clone().addScaledVector(normal, 0.012)
);
const outlineGeo = new THREE.BufferGeometry().setFromPoints(outlinePts);
const outlineMat = new THREE.LineBasicMaterial({ color: SECT_COLOR, linewidth: 2 });
this._section3PGroup.add(new THREE.Line(outlineGeo, outlineMat));
// Vertex markers + letter labels — only for true polygons; smooth conic
// sections skip them.
if (polygon.length <= 12) {
const LETTERS = 'KLMNPQRSTUV';
polygon.forEach((p, i) => {
const sg = new THREE.SphereGeometry(0.07, 8, 8);
const sm = new THREE.MeshBasicMaterial({ color: SECT_COLOR });
const s = new THREE.Mesh(sg, sm);
s.position.copy(p);
this._section3PGroup.add(s);
const lbl = this._makeTextSprite(LETTERS[i] || `${i + 1}`, '#7BF5A4', 32);
const out = new THREE.Vector3().subVectors(p, c).normalize().multiplyScalar(0.3);
lbl.position.copy(p).add(out).add(new THREE.Vector3(0, 0.16, 0));
this._section3PGroup.add(lbl);
});
}
}
// Step-by-step trace construction (метод следов)
if (this._section3PStepBy && this._section3PStep > 0) {
this._drawSection3PStep(data);
} else {
this._stepCaption = '';
}
}
// Solids that rest on a base plane (y=0) — the trace method applies to these.
_hasBase() {
return ['cube', 'parallelepiped', 'prism', 'pyramid', 'truncpyramid', 'tetrahedron']
.includes(this.figureType);
}
// Trace line of the cutting plane on the base plane y=0.
// Plane: n·X + D = 0 → at y=0: n.x·x + n.z·z + D = 0. Returns {p0, dir} or null
// when the cutting plane is (nearly) parallel to the base (trace at infinity).
_traceLine(data) {
const a = data.normal.x, b = data.normal.z, D = data.D;
const denom = a * a + b * b;
if (denom < 1e-12) return null; // plane parallel to base
const dir = new THREE.Vector3(-b, 0, a).normalize();
// foot of perpendicular from origin → keeps the drawn trace near the figure
const p0 = new THREE.Vector3(-D * a / denom, 0, -D * b / denom);
return { p0, dir };
}
// Auxiliary points: extend each lateral side of the section to the base plane.
// Each extension meets the base on the trace line — the heart of the method.
_auxiliaryPoints(polygon) {
const out = [];
const n = polygon.length;
for (let i = 0; i < n; i++) {
const A = polygon[i], B = polygon[(i + 1) % n];
if (A.y < 0.05 && B.y < 0.05) continue; // edge already on the base
if (Math.abs(B.y - A.y) < 1e-3) continue; // horizontal → meets base at ∞
const t = -A.y / (B.y - A.y);
const H = new THREE.Vector3().lerpVectors(A, B, t);
if (Math.abs(H.x) > 40 || Math.abs(H.z) > 40) continue; // near-parallel, too far
// prefer extensions that reach the base outside the segment (the classic case)
const reach = (t < 0) ? -t : (t > 1 ? t - 1 : 0);
out.push({ A, B, H, reach });
}
out.sort((p, q) => p.reach - q.reach); // nearest extensions first
return out;
}
_drawSection3PStep(data) {
const step = this._section3PStep;
const picks = this._section3PPicks;
const grp = this._section3PGroup;
const HILITE = 0xFFFFA0, TRACE = 0xEF476F, AUX = 0xFFD166;
const dot = (pos, color, r = 0.12) => {
const s = new THREE.Mesh(new THREE.SphereGeometry(r, 10, 10),
new THREE.MeshBasicMaterial({ color }));
s.position.copy(pos); grp.add(s);
};
const solidLine = (a, b, color) => {
grp.add(new THREE.Line(new THREE.BufferGeometry().setFromPoints([a, b]),
new THREE.LineBasicMaterial({ color, transparent: true, opacity: 0.95 })));
};
const dashLine = (a, b, color) => {
const l = new THREE.Line(new THREE.BufferGeometry().setFromPoints([a, b]),
new THREE.LineDashedMaterial({ color, dashSize: 0.18, gapSize: 0.1, transparent: true, opacity: 0.9 }));
l.computeLineDistances(); grp.add(l);
};
const tag = (pos, text, color, off = new THREE.Vector3(0.25, 0.25, 0)) => {
const s = this._makeTextSprite(text, '#' + new THREE.Color(color).getHexString(), 34);
s.position.copy(pos).add(off); grp.add(s);
};
const hasBase = this._hasBase();
const polygon = data.polygon;
const trace = hasBase ? this._traceLine(data) : null;
const aux = (hasBase && trace) ? this._auxiliaryPoints(polygon).slice(0, 2) : [];
// Step 1 — the three given points
if (step >= 1) picks.forEach((p, i) => { dot(p, HILITE); tag(p, 'P' + (i + 1), HILITE); });
// Step 2 — connect points lying in the same face → first sides of the section
if (step >= 2) {
for (let i = 0; i < picks.length; i++) {
for (let j = i + 1; j < picks.length; j++) {
if (this._sameFace(picks[i], picks[j])) solidLine(picks[i], picks[j], HILITE);
}
}
}
// Step 3 — build the trace of the cutting plane on the base
if (step >= 3 && trace) {
const L = 13;
const a = trace.p0.clone().addScaledVector(trace.dir, -L);
const b = trace.p0.clone().addScaledVector(trace.dir, L);
dashLine(a, b, TRACE);
tag(b, 'след', TRACE, new THREE.Vector3(0.3, 0.2, 0));
}
// Step 4 — extend the section's sides to the trace → auxiliary points
if (step >= 4) {
aux.forEach((q, i) => {
dashLine(q.A, q.H, AUX);
dot(q.H, AUX, 0.1);
tag(q.H, 'T' + (i + 1), AUX);
});
}
// Steps 56 — the finished section is drawn by _drawSection3P (showFull)
const CAPS = hasBase ? {
1: 'Шаг 1. Отмечены 3 точки, задающие секущую плоскость.',
2: 'Шаг 2. Соединяем точки в одной грани — первые стороны сечения.',
3: 'Шаг 3. Строим след — линию пересечения плоскости с основанием.',
4: 'Шаг 4. Продлеваем стороны сечения до следа — вспомогательные точки.',
5: 'Шаг 5. Через след находим остальные вершины и замыкаем сечение.',
6: `Шаг 6. Сечение построено: ${data.typeName}` + (data.area > 0 ? `, S = ${Math.round(data.area * 100) / 100}.` : '.'),
} : {
1: 'Шаг 1. Отмечены 3 точки, задающие секущую плоскость.',
2: 'Шаг 2. Соединяем точки, лежащие в одной грани.',
3: 'Для этого тела метод следов не применяется — показываем готовое сечение.',
4: 'Готовое сечение.', 5: 'Готовое сечение.', 6: 'Готовое сечение.',
};
this._stepCaption = CAPS[Math.min(step, 6)] || '';
}
// True if two points both lie in (the plane of) the same face of the solid.
_sameFace(p, q) {
for (const face of this._faces) {
if (face.length < 3) continue;
const nrm = this._faceNormal(face);
const d = nrm.dot(face[0]);
if (Math.abs(nrm.dot(p) - d) < 0.06 && Math.abs(nrm.dot(q) - d) < 0.06) return true;
}
return false;
}
/* ════════════════ MEASUREMENT MODE ════════════════ */
_onMeasureClick(e) {
if (!this._measureMode) return;
const { mx, my } = this._screenCoords(e);
// Find closest vertex OR custom point in screen space
let bestDist = 0.08; // threshold in NDC
let bestPick = null;
// Check vertices
for (const v of this._vertices) {
const projected = v.pos.clone().project(this.camera);
const d = Math.sqrt((projected.x - mx) ** 2 + (projected.y - my) ** 2);
if (d < bestDist) { bestDist = d; bestPick = { pos: v.pos, label: v.label }; }
}
// Check custom points
for (const cp of this._customPoints) {
const projected = cp.pos.clone().project(this.camera);
const d = Math.sqrt((projected.x - mx) ** 2 + (projected.y - my) ** 2);
if (d < bestDist) { bestDist = d; bestPick = { pos: cp.pos, label: cp.label }; }
}
if (!bestPick) return;
this._measurePicks.push(bestPick);
// Highlight first pick in temporary group
const sGeo = new THREE.SphereGeometry(0.14, 12, 12);
const sMat = new THREE.MeshBasicMaterial({ color: 0xFFD166 });
const s = new THREE.Mesh(sGeo, sMat);
s.position.copy(bestPick.pos);
this._measurePickGroup.add(s);
if (this._measurePicks.length === 2) {
const [a, b] = this._measurePicks;
const dist = a.pos.distanceTo(b.pos);
this._measurements.push({
from: a.label, to: b.label,
dist: Math.round(dist * 100) / 100,
posA: a.pos.clone(), posB: b.pos.clone(),
});
this._measurePicks = [];
this._clearGroup(this._measurePickGroup);
this._rebuildMeasureGroup();
this._notify();
}
}
/* ════════════════ POINT MODE — place points on edges ════════════════ */
_screenCoords(e) {
const rect = this.renderer.domElement.getBoundingClientRect();
return {
mx: ((e.clientX - rect.left) / rect.width) * 2 - 1,
my: -((e.clientY - rect.top) / rect.height) * 2 + 1,
};
}
// Distance (in NDC) from a screen point to the projected 3D segment a→b,
// and the clamped parameter t. Used by every edge picker for consistency.
_edgePickNDC(mx, my, a, b) {
const p1 = a.clone().project(this.camera);
const p2 = b.clone().project(this.camera);
const dx = p2.x - p1.x, dy = p2.y - p1.y;
const lenSq = dx * dx + dy * dy;
let t = lenSq < 1e-9 ? 0 : ((mx - p1.x) * dx + (my - p1.y) * dy) / lenSq;
t = Math.max(0, Math.min(1, t));
const px = p1.x + t * dx, py = p1.y + t * dy;
return { dist: Math.hypot(mx - px, my - py), t };
}
// Raycast against the figure's solid mesh → a point on a face interior.
_raycastFace(mx, my) {
if (!this._raycaster) this._raycaster = new THREE.Raycaster();
this._raycaster.setFromCamera({ x: mx, y: my }, this.camera);
const hits = this._raycaster.intersectObjects(this._figGroup.children, true);
for (const h of hits) {
if (h.object && h.object.type === 'Mesh') return h.point.clone();
}
return null;
}
_onPointClick(e) {
const { mx, my } = this._screenCoords(e);
// Find the nearest edge in screen space
let bestDist = 0.08; // threshold in NDC
let bestEdge = -1;
let bestT = 0;
let bestPos = null;
for (let ei = 0; ei < this._edges.length; ei++) {
const edge = this._edges[ei];
const p1 = edge.from.clone().project(this.camera);
const p2 = edge.to.clone().project(this.camera);
// Point-to-segment distance in 2D
const dx = p2.x - p1.x, dy = p2.y - p1.y;
const lenSq = dx * dx + dy * dy;
if (lenSq < 1e-9) continue;
let t = ((mx - p1.x) * dx + (my - p1.y) * dy) / lenSq;
t = Math.max(0.02, Math.min(0.98, t)); // clamp away from endpoints
const px = p1.x + t * dx, py = p1.y + t * dy;
const dist = Math.sqrt((mx - px) ** 2 + (my - py) ** 2);
if (dist < bestDist) {
bestDist = dist;
bestEdge = ei;
bestT = t;
bestPos = new THREE.Vector3().lerpVectors(edge.from, edge.to, t);
}
}
// Also check: click near a vertex → snap to vertex
for (const v of this._vertices) {
const proj = v.pos.clone().project(this.camera);
const dist = Math.sqrt((mx - proj.x) ** 2 + (my - proj.y) ** 2);
if (dist < 0.06) {
bestPos = v.pos.clone();
bestEdge = -2; // special: vertex
bestT = 0;
bestDist = dist;
}
}
// Fall back to a point on a face interior (raycast) when not near edge/vertex.
if (!bestPos) {
const fp = this._raycastFace(mx, my);
if (fp) { bestPos = fp; bestEdge = -3; bestT = 0; }
}
if (!bestPos) return;
const label = String(this._nextPointId++);
this._customPoints.push({
pos: bestPos,
edgeIdx: bestEdge,
t: bestT,
label,
});
this._rebuildPointVisuals();
if (window.LabFX) LabFX.sound.play('tick', { pitch: 1.5, volume: 0.3 });
// If custom section mode and ≥3 points, update section
if (this.showSection && this.sectionType === 'custom') this._updateSection();
this._notify();
}
_onConnectClick(e) {
const { mx, my } = this._screenCoords(e);
// Find nearest custom point OR vertex
let bestDist = 0.08;
let bestIdx = -1;
// Check custom points
for (let i = 0; i < this._customPoints.length; i++) {
const proj = this._customPoints[i].pos.clone().project(this.camera);
const dist = Math.sqrt((mx - proj.x) ** 2 + (my - proj.y) ** 2);
if (dist < bestDist) { bestDist = dist; bestIdx = i; }
}
// Also check vertices (mapped as negative indices: -1 <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 { dist } = this._edgePickNDC(mx, my, edge.from, edge.to);
if (dist < bestDist) { bestDist = dist; bestEdge = edge; }
}
return bestEdge;
}
_highlightPick(pos, color = 0xFFD166) {
const sGeo = new THREE.SphereGeometry(0.12, 10, 10);
const sMat = new THREE.MeshBasicMaterial({ color });
const s = new THREE.Mesh(sGeo, sMat);
s.position.copy(pos);
this._angleGroup.add(s);
}
_onAngleClick(e) {
if (!this._angleMode) return;
if (this._angleMode === 'edge') {
this._onEdgeAngleClick(e);
} else if (this._angleMode === 'linePlane') {
this._onLinePlaneAngleClick(e);
} else if (this._angleMode === 'dihedral') {
this._onDihedralAngleClick(e);
} else if (this._angleMode === 'pointPlane') {
this._onPointPlaneClick(e);
} else if (this._angleMode === 'skewLines') {
this._onSkewLinesClick(e);
}
}
/* ── Skew lines: pick P1, P2 (line 1) then P3, P4 (line 2) ── */
_onSkewLinesClick(e) {
const pick = this._pickNearestPoint(e);
if (!pick) return;
const step = this._anglePicks.length;
this._anglePicks.push(pick);
const colors = [0xFFD166, 0xFFD166, 0x06D6E0, 0x06D6E0];
this._highlightPick(pick.pos, colors[step] || 0xffffff);
// After 2 picks: draw line 1
if (this._anglePicks.length === 2) {
const [P1, P2] = this._anglePicks;
this._drawAngleLine(P1.pos, P2.pos, '#FFD166');
}
if (this._anglePicks.length < 4) return;
const [P1, P2, P3, P4] = this._anglePicks;
const d1 = new THREE.Vector3().subVectors(P2.pos, P1.pos);
const d2 = new THREE.Vector3().subVectors(P4.pos, P3.pos);
// Draw line 2
this._drawAngleLine(P3.pos, P4.pos, '#06D6E0');
// Angle between lines (acute)
const cosA = Math.abs(d1.dot(d2)) / (d1.length() * d2.length());
const angleDeg = Math.acos(Math.min(1, cosA)) * 180 / Math.PI;
// Common perpendicular (distance between skew lines)
const normal = new THREE.Vector3().crossVectors(d1, d2);
let dist = 0;
if (normal.length() > 1e-9) {
// Lines are skew → project onto normal
dist = Math.abs(new THREE.Vector3().subVectors(P3.pos, P1.pos).dot(normal.clone().normalize()));
// Find foot points of common perpendicular
const w = new THREE.Vector3().subVectors(P1.pos, P3.pos);
const b1 = d1.dot(d1), b2 = d2.dot(d2), d12 = d1.dot(d2);
const det = b1 * b2 - d12 * d12;
if (Math.abs(det) > 1e-9) {
const t1 = (d12 * d2.dot(w) - b2 * d1.dot(w)) / det;
const t2 = (b1 * d2.dot(w) - d12 * d1.dot(w)) / det;
const Q1 = P1.pos.clone().addScaledVector(d1, t1);
const Q2 = P3.pos.clone().addScaledVector(d2, t2);
this._drawAngleLine(Q1, Q2, '#F9A8D4', true);
this._highlightPick(Q1, 0xF9A8D4);
this._highlightPick(Q2, 0xF9A8D4);
}
} else {
// Parallel lines
const cross = new THREE.Vector3().crossVectors(d1, new THREE.Vector3().subVectors(P3.pos, P1.pos));
dist = cross.length() / d1.length();
}
// Label near midpoint between the two lines
const mid = new THREE.Vector3().addVectors(P1.pos, P3.pos).multiplyScalar(0.5);
mid.y += 0.5;
const lbl = this._makeTextSprite(
`∠ = ${angleDeg.toFixed(1)}° d = ${dist.toFixed(2)}`, '#F9A8D4', 36
);
lbl.position.copy(mid);
lbl.scale.set(2.8, 0.55, 1);
this._angleGroup.add(lbl);
this._anglePicks = [];
}
/* ── Edge angle: pick 3 points (B is vertex, angle ∠ABC) ── */
_onEdgeAngleClick(e) {
const pick = this._pickNearestPoint(e);
if (!pick) return;
this._anglePicks.push(pick);
this._highlightPick(pick.pos, this._anglePicks.length === 2 ? 0xEF476F : 0xFFD166);
if (this._anglePicks.length === 3) {
const [A, B, C] = this._anglePicks;
const BA = new THREE.Vector3().subVectors(A.pos, B.pos);
const BC = new THREE.Vector3().subVectors(C.pos, B.pos);
const cosAngle = BA.dot(BC) / (BA.length() * BC.length());
const angle = Math.acos(Math.max(-1, Math.min(1, cosAngle))) * 180 / Math.PI;
// Draw the angle arc
this._drawAngleArc(B.pos, BA, BC, angle, 0.6, '#EF476F');
// Label
const bisect = new THREE.Vector3().addVectors(
BA.clone().normalize(), BC.clone().normalize()
).normalize().multiplyScalar(0.8);
const lblPos = B.pos.clone().add(bisect);
const lbl = this._makeTextSprite(
`∠${A.label}${B.label}${C.label} = ${angle.toFixed(1)}°`, '#EF476F', 36
);
lbl.position.copy(lblPos);
lbl.scale.set(2.0, 0.5, 1);
this._angleGroup.add(lbl);
// Lines
this._drawAngleLine(B.pos, A.pos, '#EF476F');
this._drawAngleLine(B.pos, C.pos, '#EF476F');
this._anglePicks = [];
}
}
/* ── Line-Plane angle: pick 2 points (line), then 1 face ── */
_onLinePlaneAngleClick(e) {
if (this._anglePicks.length < 2) {
// Picking line endpoints
const pick = this._pickNearestPoint(e);
if (!pick) return;
this._anglePicks.push(pick);
this._highlightPick(pick.pos);
if (this._anglePicks.length === 2) {
// Draw the line
this._drawAngleLine(this._anglePicks[0].pos, this._anglePicks[1].pos, '#60a5fa');
}
} else {
// Pick a face
const face = this._pickNearestFace(e);
if (!face || face.length < 3) return;
const [A, B] = this._anglePicks;
const lineDir = new THREE.Vector3().subVectors(B.pos, A.pos).normalize();
// Face normal
const v1 = new THREE.Vector3().subVectors(face[1], face[0]);
const v2 = new THREE.Vector3().subVectors(face[2], face[0]);
const normal = new THREE.Vector3().crossVectors(v1, v2).normalize();
// Angle between line and plane = 90° - angle between line and normal
const sinAngle = Math.abs(lineDir.dot(normal));
const angle = Math.asin(Math.max(0, Math.min(1, sinAngle))) * 180 / Math.PI;
// Highlight face
const positions = [];
const indices = [];
face.forEach(v => positions.push(v.x, v.y, v.z));
for (let i = 1; i < face.length - 1; i++) indices.push(0, i, i + 1);
const geo = new THREE.BufferGeometry();
geo.setAttribute('position', new THREE.Float32BufferAttribute(positions, 3));
geo.setIndex(indices);
const mat = new THREE.MeshBasicMaterial({ color: 0x60a5fa, transparent: true, opacity: 0.2, side: THREE.DoubleSide });
this._angleGroup.add(new THREE.Mesh(geo, mat));
// Label at face centroid
const c = new THREE.Vector3();
face.forEach(v => c.add(v));
c.divideScalar(face.length);
const lbl = this._makeTextSprite(
`∠(${A.label}${B.label}, пл) = ${angle.toFixed(1)}°`, '#60a5fa', 36
);
lbl.position.copy(c).add(new THREE.Vector3(0, 0.5, 0));
lbl.scale.set(2.4, 0.5, 1);
this._angleGroup.add(lbl);
// Draw projection of line onto plane
const proj = lineDir.clone().sub(normal.clone().multiplyScalar(lineDir.dot(normal))).normalize();
const projEnd = A.pos.clone().add(proj.multiplyScalar(A.pos.distanceTo(B.pos)));
this._drawAngleLine(A.pos, projEnd, '#60a5fa', true);
this._anglePicks = [];
}
}
/* ── Dihedral angle: pick 2 points of shared edge, then auto-find adjacent faces ── */
_onDihedralAngleClick(e) {
const pick = this._pickNearestPoint(e);
if (!pick) return;
this._anglePicks.push(pick);
this._highlightPick(pick.pos, 0xc4b5fd);
if (this._anglePicks.length === 2) {
const [P1, P2] = this._anglePicks;
const edgeDir = new THREE.Vector3().subVectors(P2.pos, P1.pos);
// Find two faces sharing this edge (both contain P1 and P2)
const adjFaces = [];
const eps = 0.1;
for (const face of this._faces) {
let hasP1 = false, hasP2 = false;
for (const v of face) {
if (v.distanceTo(P1.pos) < eps) hasP1 = true;
if (v.distanceTo(P2.pos) < eps) hasP2 = true;
}
if (hasP1 && hasP2) adjFaces.push(face);
}
if (adjFaces.length >= 2) {
// Compute normals of both faces
const normal1 = this._faceNormal(adjFaces[0]);
const normal2 = this._faceNormal(adjFaces[1]);
// Dihedral angle = π - angle between outward normals
const cosAngle = normal1.dot(normal2);
const rawAngle = Math.acos(Math.max(-1, Math.min(1, cosAngle))) * 180 / Math.PI;
// Dihedral is the interior angle
const dihedralAngle = 180 - rawAngle;
// Highlight both faces
for (const face of [adjFaces[0], adjFaces[1]]) {
const positions = [];
const indices = [];
face.forEach(v => positions.push(v.x, v.y, v.z));
for (let i = 1; i < face.length - 1; i++) indices.push(0, i, i + 1);
const geo = new THREE.BufferGeometry();
geo.setAttribute('position', new THREE.Float32BufferAttribute(positions, 3));
geo.setIndex(indices);
const mat = new THREE.MeshBasicMaterial({ color: 0xc4b5fd, transparent: true, opacity: 0.2, side: THREE.DoubleSide });
this._angleGroup.add(new THREE.Mesh(geo, mat));
}
// Label at edge midpoint
const mid = new THREE.Vector3().addVectors(P1.pos, P2.pos).multiplyScalar(0.5);
const lbl = this._makeTextSprite(
`∠дв(${P1.label}${P2.label}) = ${dihedralAngle.toFixed(1)}°`, '#c4b5fd', 36
);
lbl.position.copy(mid).add(new THREE.Vector3(0, 0.5, 0.3));
lbl.scale.set(2.4, 0.5, 1);
this._angleGroup.add(lbl);
// Draw the edge
this._drawAngleLine(P1.pos, P2.pos, '#c4b5fd');
} else {
// Not enough adjacent faces — show error sprite
const mid = new THREE.Vector3().addVectors(P1.pos, P2.pos).multiplyScalar(0.5);
const lbl = this._makeTextSprite('Нет общего ребра', '#ff6b6b', 36);
lbl.position.copy(mid).add(new THREE.Vector3(0, 0.5, 0));
lbl.scale.set(2.0, 0.5, 1);
this._angleGroup.add(lbl);
}
this._anglePicks = [];
}
}
_faceNormal(face) {
if (face.length < 3) return new THREE.Vector3(0, 1, 0);
const v1 = new THREE.Vector3().subVectors(face[1], face[0]);
const v2 = new THREE.Vector3().subVectors(face[2], face[0]);
return new THREE.Vector3().crossVectors(v1, v2).normalize();
}
_drawAngleArc(center, dir1, dir2, angleDeg, radius, color) {
const n1 = dir1.clone().normalize();
const n2 = dir2.clone().normalize();
const angleRad = angleDeg * Math.PI / 180;
const steps = Math.max(8, Math.round(angleDeg / 5));
const pts = [];
// Build rotation from n1 toward n2
const axis = new THREE.Vector3().crossVectors(n1, n2).normalize();
if (axis.length() < 0.001) return; // parallel vectors
for (let i = 0; i <= steps; i++) {
const t = i / steps;
const a = t * angleRad;
const rotated = n1.clone().applyAxisAngle(axis, a).multiplyScalar(radius);
pts.push(center.clone().add(rotated));
}
const geo = new THREE.BufferGeometry().setFromPoints(pts);
const mat = new THREE.LineBasicMaterial({ color: new THREE.Color(color), transparent: true, opacity: 0.8 });
this._angleGroup.add(new THREE.Line(geo, mat));
}
_drawAngleLine(from, to, color, dashed = false) {
const geo = new THREE.BufferGeometry().setFromPoints([from, to]);
let mat;
if (dashed) {
mat = new THREE.LineDashedMaterial({
color: new THREE.Color(color), dashSize: 0.12, gapSize: 0.06,
transparent: true, opacity: 0.7,
});
} else {
mat = new THREE.LineBasicMaterial({ color: new THREE.Color(color), transparent: true, opacity: 0.7 });
}
const line = new THREE.Line(geo, mat);
if (dashed) line.computeLineDistances();
this._angleGroup.add(line);
}
/* ════════════════ DIAGONALS ════════════════ */
_drawDiagonals() {
if (!this.showDiagonals) return;
const t = this.figureType;
if (t === 'cube' || t === 'parallelepiped') {
// 8 vertices: 0-3 bottom, 4-7 top (from _buildBox)
const v = this._vertices.map(vt => vt.pos);
if (v.length < 8) return;
// Space diagonals (4)
const spaceDiags = [[0,6],[1,7],[2,4],[3,5]];
for (const [a, b] of spaceDiags) {
this._drawDashedSegment(v[a], v[b], '', '#fbbf24');
}
// Face diagonals (12) — 2 per face
const faceDiags = [
[0,2],[1,3], // bottom
[4,6],[5,7], // top
[0,5],[1,4], // front
[2,7],[3,6], // back
[1,6],[2,5], // right
[0,7],[3,4], // left
];
for (const [a, b] of faceDiags) {
const lineGeo = new THREE.BufferGeometry().setFromPoints([v[a], v[b]]);
const lineMat = new THREE.LineDashedMaterial({
color: 0xF59E0B, dashSize: 0.1, gapSize: 0.06,
transparent: true, opacity: 0.35,
});
const line = new THREE.Line(lineGeo, lineMat);
line.computeLineDistances();
this._figGroup.add(line);
}
// Space diagonal label — only one, the longest
const d = v[0].distanceTo(v[6]);
const mid = new THREE.Vector3().addVectors(v[0], v[6]).multiplyScalar(0.5);
const lbl = this._makeTextSprite(`d = ${d.toFixed(2)}`, '#fbbf24', 34);
lbl.position.copy(mid).add(new THREE.Vector3(0.3, 0.2, 0));
lbl.scale.set(1.2, 0.4, 1);
this._labelGroup.add(lbl);
} else if (t === 'prism') {
const n = this.params.n;
const v = this._vertices.map(vt => vt.pos);
if (v.length < n * 2) return;
// Base diagonals (bottom)
for (let i = 0; i < n; i++) {
for (let j = i + 2; j < n; j++) {
if (j === (i + n - 1) % n + i) continue; // skip adjacent
if (i === 0 && j === n - 1) continue;
const lineGeo = new THREE.BufferGeometry().setFromPoints([v[i], v[j]]);
const lineMat = new THREE.LineDashedMaterial({
color: 0xF59E0B, dashSize: 0.1, gapSize: 0.06,
transparent: true, opacity: 0.35,
});
const line = new THREE.Line(lineGeo, lineMat);
line.computeLineDistances();
this._figGroup.add(line);
}
}
// Top diagonals
for (let i = n; i < 2 * n; i++) {
for (let j = i + 2; j < 2 * n; j++) {
if (i === n && j === 2 * n - 1) continue;
const lineGeo = new THREE.BufferGeometry().setFromPoints([v[i], v[j]]);
const lineMat = new THREE.LineDashedMaterial({
color: 0xF59E0B, dashSize: 0.1, gapSize: 0.06,
transparent: true, opacity: 0.35,
});
const line = new THREE.Line(lineGeo, lineMat);
line.computeLineDistances();
this._figGroup.add(line);
}
}
// Space diagonals: connect bottom[i] to top[(i+k)%n] for k≠0
for (let i = 0; i < n; i++) {
for (let k = 1; k < n; k++) {
const j = n + (i + k) % n;
this._drawDashedSegment(v[i], v[j], '', '#fbbf24');
}
}
} else if (t === 'pyramid') {
// Base diagonals only
const n = this.params.n;
const v = this._vertices.map(vt => vt.pos);
for (let i = 0; i < n; i++) {
for (let j = i + 2; j < n; j++) {
if (i === 0 && j === n - 1) continue;
const lineGeo = new THREE.BufferGeometry().setFromPoints([v[i], v[j]]);
const lineMat = new THREE.LineDashedMaterial({
color: 0xF59E0B, dashSize: 0.1, gapSize: 0.06,
transparent: true, opacity: 0.4,
});
const line = new THREE.Line(lineGeo, lineMat);
line.computeLineDistances();
this._figGroup.add(line);
}
}
}
}
/* ════════════════ MIDPOINTS ════════════════ */
_drawMidpoints() {
if (!this.showMidpoints) return;
for (let i = 0; i < this._edges.length; i++) {
const e = this._edges[i];
const mid = new THREE.Vector3().addVectors(e.from, e.to).multiplyScalar(0.5);
// small cyan sphere
const sGeo = new THREE.SphereGeometry(0.06, 8, 8);
const sMat = new THREE.MeshBasicMaterial({ color: 0x06D6E0 });
const s = new THREE.Mesh(sGeo, sMat);
s.position.copy(mid);
this._figGroup.add(s);
// label "M" + index
const lbl = this._makeTextSprite(`M${i + 1}`, '#06D6E0', 28);
lbl.position.copy(mid).add(new THREE.Vector3(0.15, 0.15, 0));
lbl.scale.set(0.5, 0.25, 1);
this._labelGroup.add(lbl);
}
}
/* ════════════════ POINT-TO-PLANE DISTANCE ════════════════ */
_onPointPlaneClick(e) {
if (this._anglePicks.length < 1) {
// First pick: a vertex/point
const pick = this._pickNearestPoint(e);
if (!pick) return;
this._anglePicks.push(pick);
this._highlightPick(pick.pos, 0xF9A8D4);
} else {
// Second pick: a face
const face = this._pickNearestFace(e);
if (!face || face.length < 3) return;
const pt = this._anglePicks[0];
const normal = this._faceNormal(face);
const planePoint = face[0];
// Distance = |dot(pt - planePoint, normal)|
const diff = new THREE.Vector3().subVectors(pt.pos, planePoint);
const dist = Math.abs(diff.dot(normal));
// Projection of point onto plane
const proj = pt.pos.clone().sub(normal.clone().multiplyScalar(diff.dot(normal)));
// Draw perpendicular line
this._drawAngleLine(pt.pos, proj, '#F9A8D4', true);
// Highlight foot of perpendicular
this._highlightPick(proj, 0xF9A8D4);
// Right angle marker
const edgeOnPlane = new THREE.Vector3().subVectors(face[1], face[0]).normalize();
this._drawRightAngleMarkerAt(proj, normal, edgeOnPlane, 0.25, '#F9A8D4');
// Highlight face
const positions = [];
const indices = [];
face.forEach(v => positions.push(v.x, v.y, v.z));
for (let i = 1; i < face.length - 1; i++) indices.push(0, i, i + 1);
const geo = new THREE.BufferGeometry();
geo.setAttribute('position', new THREE.Float32BufferAttribute(positions, 3));
geo.setIndex(indices);
const mat = new THREE.MeshBasicMaterial({ color: 0xF9A8D4, transparent: true, opacity: 0.15, side: THREE.DoubleSide });
this._angleGroup.add(new THREE.Mesh(geo, mat));
// Label
const mid = new THREE.Vector3().addVectors(pt.pos, proj).multiplyScalar(0.5);
const lbl = this._makeTextSprite(`d(${pt.label}, пл) = ${dist.toFixed(2)}`, '#F9A8D4', 36);
lbl.position.copy(mid).add(new THREE.Vector3(0.4, 0.2, 0));
lbl.scale.set(2.2, 0.5, 1);
this._angleGroup.add(lbl);
this._anglePicks = [];
}
}
_drawRightAngleMarkerAt(origin, normalDir, tangentDir, size, color) {
const d1 = normalDir.clone().normalize().multiplyScalar(size);
const d2 = tangentDir.clone().normalize().multiplyScalar(size);
const p1 = origin.clone().add(d1);
const p2 = origin.clone().add(d2);
const p3 = p1.clone().add(d2);
const pts = [p1, p3, p2];
const geo = new THREE.BufferGeometry().setFromPoints(pts);
const mat = new THREE.LineBasicMaterial({ color: new THREE.Color(color), transparent: true, opacity: 0.6 });
this._angleGroup.add(new THREE.Line(geo, mat));
}
/* ════════════════ COORDINATE TOOLTIP ════════════════ */
_initTooltip() {
this._tooltipEl = document.createElement('div');
Object.assign(this._tooltipEl.style, {
position: 'absolute', pointerEvents: 'none',
background: 'rgba(13,13,26,0.85)', color: '#ccc',
fontSize: '11px', fontFamily: 'Manrope, monospace',
padding: '3px 7px', borderRadius: '4px',
border: '1px solid rgba(155,93,229,0.3)',
display: 'none', zIndex: '50', whiteSpace: 'nowrap',
});
this.container.style.position = 'relative';
this.container.appendChild(this._tooltipEl);
}
_onHoverMove(e) {
const rect = this.renderer.domElement.getBoundingClientRect();
if (e.clientX < rect.left || e.clientX > rect.right ||
e.clientY < rect.top || e.clientY > rect.bottom) {
if (this._tooltipEl) this._tooltipEl.style.display = 'none';
return;
}
const mx = ((e.clientX - rect.left) / rect.width) * 2 - 1;
const my = -((e.clientY - rect.top) / rect.height) * 2 + 1;
let bestDist = 0.06;
let bestV = null;
for (const v of this._vertices) {
const proj = v.pos.clone().project(this.camera);
const d = Math.sqrt((proj.x - mx) ** 2 + (proj.y - my) ** 2);
if (d < bestDist) { bestDist = d; bestV = v; }
}
for (const cp of this._customPoints) {
const proj = cp.pos.clone().project(this.camera);
const d = Math.sqrt((proj.x - mx) ** 2 + (proj.y - my) ** 2);
if (d < bestDist) { bestDist = d; bestV = cp; }
}
if (bestV && this._tooltipEl) {
const p = bestV.pos;
this._tooltipEl.textContent = `${bestV.label} (${p.x.toFixed(1)}, ${p.y.toFixed(1)}, ${p.z.toFixed(1)})`;
this._tooltipEl.style.display = 'block';
this._tooltipEl.style.left = (e.clientX - rect.left + 14) + 'px';
this._tooltipEl.style.top = (e.clientY - rect.top - 10) + 'px';
} else if (this._tooltipEl) {
this._tooltipEl.style.display = 'none';
}
}
/* ════════════════ UNFOLD ANIMATION ════════════════ */
// Simplified unfold: flatten figure by reducing Y coordinates
_applyUnfold(progress) {
// This is a visual-only effect — squash Y toward 0
if (progress < 0.01) return;
this._figGroup.children.forEach(child => {
if (child.geometry) {
// Already built, don't modify geometry — just scale Y
}
});
this._figGroup.scale.y = 1 - progress * 0.85;
this._figGroup.position.y = progress * 0.5;
}
/* ════════════════ UTILS ════════════════ */
/* ════════════════ CONSTRUCTION LAYER (Phase A) ════════════════ */
/* Lines (a,b,c…) & planes (α,β,γ…) built by picking points. Everything is
rebuilt from the serialisable _lines / _planes arrays into _constructGroup. */
setLineMode(on) {
this._lineMode = on;
if (on) this._planeMode = false;
this._constructPicks = [];
this.renderer.domElement.style.cursor = on ? 'crosshair' : 'grab';
this._rebuildConstructions();
}
setPlaneMode(on) {
this._planeMode = on;
if (on) this._lineMode = false;
this._constructPicks = [];
this.renderer.domElement.style.cursor = on ? 'crosshair' : 'grab';
this._rebuildConstructions();
}
removeLastConstruction() {
let best = -1, arr = null, idx = -1;
const scan = (a) => a.forEach((o, i) => { if (o.seq > best) { best = o.seq; arr = a; idx = i; } });
scan(this._cpoints); scan(this._lines); scan(this._planes);
if (!arr) return;
this._pushHistory();
arr.splice(idx, 1);
this._rebuildConstructions();
this._notify();
}
// Delete one object by id (point / line / plane).
removeConstruction(id) {
if (!this._findObj(id)) return;
this._pushHistory();
for (const a of [this._cpoints, this._lines, this._planes]) {
const i = a.findIndex(o => o.id === id);
if (i >= 0) { a.splice(i, 1); break; }
}
this._intersectSel = this._intersectSel.filter(x => x !== id);
this._rebuildConstructions();
this._notify();
}
// Toggle the visibility of one object by id (kept in the tree, hidden in 3D).
toggleConstructionVis(id) {
const o = this._findObj(id);
if (o) { o.obj.hidden = !o.obj.hidden; this._rebuildConstructions(); this._notify(); }
}
clearConstructions() {
if (this._cpoints.length || this._lines.length || this._planes.length) this._pushHistory();
this._cpoints = []; this._lines = []; this._planes = []; this._constructPicks = [];
this._lineMode = false; this._planeMode = false;
this._intersectMode = false; this._intersectSel = [];
this._relMode = null;
this._clearGroup(this._constructGroup);
this._notify();
}
/* ── Undo / redo of the construction layer (JSON snapshots) ── */
_snapshot() {
return JSON.stringify({
cpoints: this._cpoints, lines: this._lines, planes: this._planes,
nl: this._nextLineName, np: this._nextPlaneName, nc: this._nextCPointName, seq: this._constructSeq,
});
}
_pushHistory() {
this._undoStack.push(this._snapshot());
if (this._undoStack.length > this._undoMax) this._undoStack.shift();
this._redoStack = [];
}
_restoreSnapshot(json) {
const s = JSON.parse(json);
this._cpoints = s.cpoints || []; this._lines = s.lines || []; this._planes = s.planes || [];
this._nextLineName = s.nl || 0; this._nextPlaneName = s.np || 0; this._nextCPointName = s.nc || 0;
this._constructSeq = s.seq || 0;
this._intersectSel = []; this._relMode = null;
this._rebuildConstructions();
this._notify();
}
canUndo() { return this._undoStack.length > 0; }
canRedo() { return this._redoStack.length > 0; }
undo() {
if (!this._undoStack.length) return;
this._redoStack.push(this._snapshot());
this._restoreSnapshot(this._undoStack.pop());
}
redo() {
if (!this._redoStack.length) return;
this._undoStack.push(this._snapshot());
this._restoreSnapshot(this._redoStack.pop());
}
// Interactive tree summary for the panel (ids/types/visibility/selection).
getConstructions() {
const r = (v) => Math.round(v * 100) / 100;
const relRef = this._relMode ? this._relMode.refId : null;
const isSel = (id) => this._intersectSel.includes(id) || id === relRef;
return {
intersectMode: this._intersectMode,
relMode: !!this._relMode,
points: this._cpoints.map(p => ({
id: p.id, name: p.name, hidden: !!p.hidden, selected: isSel(p.id),
info: `(${r(p.pos.x)}, ${r(p.pos.y)}, ${r(p.pos.z)})`,
})),
lines: this._lines.map(l => ({
id: l.id, name: l.name, hidden: !!l.hidden, selected: isSel(l.id),
})),
planes: this._planes.map(p => {
const n = p.normal;
const D = -(n.x * p.point.x + n.y * p.point.y + n.z * p.point.z);
const sign = (v) => (v >= 0 ? '+ ' : ' ') + Math.abs(r(v));
return {
id: p.id, name: p.name, hidden: !!p.hidden, selected: isSel(p.id),
info: `${r(n.x)}x ${sign(n.y)}y ${sign(n.z)}z ${sign(D)} = 0`,
};
}),
};
}
_lineLabel(i) {
const base = String.fromCharCode(97 + (i % 26)); // a..z
const sub = Math.floor(i / 26);
return sub > 0 ? base + '_' + sub : base;
}
_planeLabel(i) {
const G = ['α','β','γ','δ','ε','ζ','η','θ','λ','μ','π','ρ','σ','τ','φ','ψ','ω'];
const base = G[i % G.length];
const sub = Math.floor(i / G.length);
return sub > 0 ? base + '_' + sub : base;
}
// World-space radius enclosing the figure (for sizing infinite lines / planes).
_sceneRadius() {
let r = 0;
for (const v of this._vertices) r = Math.max(r, v.pos.length());
if (r < 1e-3) r = this._figureHeight() || 4;
return r;
}
// Pick the nearest vertex / custom point / construction point (Vector3 | null).
_pickConstructPoint(e) {
const { mx, my } = this._screenCoords(e);
let bestDist = 0.08, best = null;
const consider = (pos) => {
const p = pos.clone().project(this.camera);
const d = Math.hypot(p.x - mx, p.y - my);
if (d < bestDist) { bestDist = d; best = pos.clone(); }
};
for (const v of this._vertices) consider(v.pos);
for (const cp of this._customPoints) consider(cp.pos);
for (const cp of this._cpoints) consider(new THREE.Vector3(cp.pos.x, cp.pos.y, cp.pos.z));
return best;
}
_cpointLabel(i) {
const P = ['M', 'N', 'K', 'P', 'Q', 'S', 'T', 'U', 'V', 'W', 'F', 'G'];
const base = P[i % P.length];
const sub = Math.floor(i / P.length);
return sub > 0 ? base + '_' + sub : base;
}
/* ── Intersections (Phase A2): list-based — select 2 objects ── */
setIntersectMode(on) {
this._intersectMode = on;
this._intersectSel = [];
if (on) { this._lineMode = false; this._planeMode = false; this._constructPicks = []; }
this._rebuildConstructions();
this._notify();
}
_findObj(id) {
let o = this._cpoints.find(x => x.id === id); if (o) return { type: 'point', obj: o };
o = this._lines.find(x => x.id === id); if (o) return { type: 'line', obj: o };
o = this._planes.find(x => x.id === id); if (o) return { type: 'plane', obj: o };
return null;
}
// Select an object: dispatches to relative-construction reference picking,
// or to intersection (select 2 objects). Returns { msg } for the panel hint.
pickConstructObject(id) {
if (this._relMode) return this._pickRelRef(id);
if (!this._intersectMode) return { msg: '' };
const found = this._findObj(id);
if (!found || found.type === 'point') return { msg: 'Для пересечения выберите прямую или плоскость' };
const i = this._intersectSel.indexOf(id);
if (i >= 0) { this._intersectSel.splice(i, 1); this._notify(); return { msg: '' }; }
this._intersectSel.push(id);
if (this._intersectSel.length < 2) { this._notify(); return { msg: 'Выберите второй объект' }; }
const [idA, idB] = this._intersectSel;
const res = this._computeIntersection(idA, idB);
this._intersectSel = [];
this._rebuildConstructions();
this._notify();
return { msg: res };
}
_lineFromObj(l) {
const p0 = new THREE.Vector3(l.a.x, l.a.y, l.a.z);
const d = new THREE.Vector3(l.b.x, l.b.y, l.b.z).sub(p0).normalize();
return { p0, d };
}
_planeFromObj(p) {
return {
n: new THREE.Vector3(p.normal.x, p.normal.y, p.normal.z).normalize(),
point: new THREE.Vector3(p.point.x, p.point.y, p.point.z),
};
}
_computeIntersection(idA, idB) {
const A = this._findObj(idA), B = this._findObj(idB);
if (!A || !B) return 'Объект не найден';
const types = [A.type, B.type].sort().join('+');
if (types === 'plane+plane') {
const r = this._intersectPlanePlane(A.obj, B.obj);
if (!r) return `${A.obj.name}${B.obj.name}: прямой пересечения нет`;
const ln = this._createLine(r.p0, r.p0.clone().add(r.dir));
return `прямая ${ln} = ${A.obj.name}${B.obj.name}`;
}
const line = A.type === 'line' ? A.obj : B.obj;
const plane = A.type === 'plane' ? A.obj : B.obj;
if (types === 'line+plane') {
const pt = this._intersectLinePlane(line, plane);
if (!pt) return `${line.name}${plane.name}: точки пересечения нет`;
const nm = this._createCPoint(pt);
return `точка ${nm} = ${line.name}${plane.name}`;
}
if (types === 'line+line') {
const r = this._intersectLineLine(A.obj, B.obj);
if (r === 'parallel') return `${A.obj.name}${B.obj.name}: точки нет`;
if (r === 'skew') return `${A.obj.name} и ${B.obj.name} скрещиваются: точки нет`;
const nm = this._createCPoint(r);
return `точка ${nm} = ${A.obj.name}${B.obj.name}`;
}
return 'Нельзя пересечь эти объекты';
}
_intersectLinePlane(l, pl) {
const { p0, d } = this._lineFromObj(l);
const { n, point } = this._planeFromObj(pl);
const denom = n.dot(d);
if (Math.abs(denom) < 1e-9) return null; // line ∥ plane
const t = n.dot(point.clone().sub(p0)) / denom;
return p0.clone().addScaledVector(d, t);
}
_intersectPlanePlane(pa, pb) {
const n1 = this._planeFromObj(pa).n, n2 = this._planeFromObj(pb).n;
const dir = new THREE.Vector3().crossVectors(n1, n2);
if (dir.length() < 1e-7) return null; // parallel planes
const c1 = n1.dot(this._planeFromObj(pa).point);
const c2 = n2.dot(this._planeFromObj(pb).point);
const term1 = new THREE.Vector3().crossVectors(n2, dir).multiplyScalar(c1);
const term2 = new THREE.Vector3().crossVectors(dir, n1).multiplyScalar(c2);
const p0 = term1.add(term2).divideScalar(dir.lengthSq());
return { p0, dir: dir.normalize() };
}
_intersectLineLine(la, lb) {
const { p0: P0, d: D0 } = this._lineFromObj(la);
const { p0: P1, d: D1 } = this._lineFromObj(lb);
const b = D0.dot(D1);
const denom = 1 - b * b; // D0,D1 are unit
if (Math.abs(denom) < 1e-9) return 'parallel';
const r = P0.clone().sub(P1);
const dd = D0.dot(r), e = D1.dot(r);
const s = (b * e - dd) / denom;
const tt = (e - b * dd) / denom;
const cp0 = P0.clone().addScaledVector(D0, s);
const cp1 = P1.clone().addScaledVector(D1, tt);
const tol = Math.max(0.12, this._sceneRadius() * 0.03);
if (cp0.distanceTo(cp1) > tol) return 'skew';
return cp0.add(cp1).multiplyScalar(0.5);
}
_createCPoint(pos) {
this._pushHistory();
const name = this._cpointLabel(this._nextCPointName++);
this._cpoints.push({
id: 'C' + this._constructSeq, seq: this._constructSeq++,
name, pos: { x: pos.x, y: pos.y, z: pos.z }, color: 0x34D399, hidden: false,
});
return name;
}
/* ── Parallels / perpendiculars through a point (Phase A3) ──
op: lpar = прямая ∥ прямой · lperp = прямая ⟂ плоскости
ppar = плоскость ∥ плоскости · pperp = плоскость ⟂ прямой (точка+нормаль).
Flow: choose op → select reference object in the tree → click a point. */
setRelMode(op) {
this._relMode = op ? { op, refId: null } : null;
if (op) {
this._lineMode = false; this._planeMode = false;
this._intersectMode = false; this._intersectSel = []; this._constructPicks = [];
}
this._rebuildConstructions();
this._notify();
}
_pickRelRef(id) {
const found = this._findObj(id);
if (!found || found.type === 'point') return { msg: 'Опора — прямая или плоскость' };
const op = this._relMode.op;
const needLine = (op === 'lpar' || op === 'pperp');
const needPlane = (op === 'lperp' || op === 'ppar');
if (needLine && found.type !== 'line') return { msg: 'Опора этой операции — прямая' };
if (needPlane && found.type !== 'plane') return { msg: 'Опора этой операции — плоскость' };
this._relMode.refId = (this._relMode.refId === id) ? null : id;
this._notify();
return { msg: this._relMode.refId ? 'Теперь кликните точку на сцене' : 'Опора снята' };
}
_onRelClick(e) {
if (!this._relMode || !this._relMode.refId) return; // need a reference first
const ref = this._findObj(this._relMode.refId);
if (!ref) { this._relMode.refId = null; return; }
const pt = this._pickConstructPoint(e);
if (!pt) return;
if (window.LabFX) LabFX.sound.play('tick', { pitch: 1.6, volume: 0.25 });
const op = this._relMode.op;
let nm = null, kind = '';
if (op === 'lpar') { const { d } = this._lineFromObj(ref.obj); nm = this._createLine(pt, pt.clone().add(d)); kind = 'прямая'; }
else if (op === 'lperp') { const { n } = this._planeFromObj(ref.obj); nm = this._createLine(pt, pt.clone().add(n)); kind = 'прямая'; }
else if (op === 'ppar') { const { n } = this._planeFromObj(ref.obj); nm = this._createPlaneFromPointNormal(pt, n); kind = 'плоскость'; }
else if (op === 'pperp') { const { d } = this._lineFromObj(ref.obj); nm = this._createPlaneFromPointNormal(pt, d); kind = 'плоскость'; }
this._lastConstructMsg = nm ? (kind + ' ' + nm) : '';
this._rebuildConstructions();
this._notify();
}
_createPlaneFromPointNormal(point, normal) {
const n = normal.clone().normalize();
if (n.length() < 1e-6) return null;
let u = Math.abs(n.x) > 0.9 ? new THREE.Vector3(0, 1, 0) : new THREE.Vector3(1, 0, 0);
u = new THREE.Vector3().crossVectors(n, u).normalize();
const w = new THREE.Vector3().crossVectors(n, u).normalize();
return this._createPlane(point.clone(), point.clone().addScaledVector(u, 1), point.clone().addScaledVector(w, 1));
}
_onConstructClick(e) {
if (!this._lineMode && !this._planeMode) return;
const pos = this._pickConstructPoint(e);
if (!pos) return;
// ignore a second click on (almost) the same point
const last = this._constructPicks[this._constructPicks.length - 1];
if (last && last.distanceTo(pos) < 1e-4) return;
this._constructPicks.push(pos);
if (window.LabFX) LabFX.sound.play('tick', { pitch: 1.6, volume: 0.25 });
const need = this._lineMode ? 2 : 3;
if (this._constructPicks.length >= need) {
if (this._lineMode) {
const nm = this._createLine(this._constructPicks[0], this._constructPicks[1]);
this._lastConstructMsg = nm ? ('прямая ' + nm) : '';
} else {
const nm = this._createPlane(this._constructPicks[0], this._constructPicks[1], this._constructPicks[2]);
this._lastConstructMsg = nm ? ('плоскость ' + nm) : 'не удалось: 3 точки на одной прямой';
}
this._constructPicks = [];
}
this._rebuildConstructions();
this._notify();
}
_createLine(pA, pB) {
if (pA.distanceTo(pB) < 1e-6) return null;
this._pushHistory();
const name = this._lineLabel(this._nextLineName++);
this._lines.push({
id: 'L' + this._constructSeq, seq: this._constructSeq++,
name, a: { x: pA.x, y: pA.y, z: pA.z }, b: { x: pB.x, y: pB.y, z: pB.z },
color: 0x38BDF8, hidden: false,
});
return name;
}
_createPlane(p1, p2, p3) {
const v1 = new THREE.Vector3().subVectors(p2, p1);
const v2 = new THREE.Vector3().subVectors(p3, p1);
const n = new THREE.Vector3().crossVectors(v1, v2);
if (n.length() < 1e-6) return null; // 3 collinear points → no plane
n.normalize();
this._pushHistory();
const name = this._planeLabel(this._nextPlaneName++);
this._planes.push({
id: 'P' + this._constructSeq, seq: this._constructSeq++,
name, point: { x: p1.x, y: p1.y, z: p1.z }, normal: { x: n.x, y: n.y, z: n.z },
def: [p1, p2, p3].map(p => ({ x: p.x, y: p.y, z: p.z })),
color: 0xC4B5FD, hidden: false,
});
return name;
}
_rebuildConstructions() {
if (!this._constructGroup) return;
this._clearGroup(this._constructGroup);
const ext = this._sceneRadius() * 1.6 + 2;
for (const pl of this._planes) if (!pl.hidden) this._drawPlaneObject(pl, ext);
for (const l of this._lines) if (!l.hidden) this._drawLineObject(l, ext);
for (const cp of this._cpoints) if (!cp.hidden) this._drawCPointObject(cp);
// in-progress picks (highlight spheres)
for (const p of this._constructPicks) {
const s = new THREE.Mesh(
new THREE.SphereGeometry(0.13, 12, 12),
new THREE.MeshBasicMaterial({ color: 0xFB7185 }));
s.position.set(p.x, p.y, p.z);
s.renderOrder = 6;
this._constructGroup.add(s);
}
this._invalidate();
}
_drawLineObject(l, ext) {
const A = new THREE.Vector3(l.a.x, l.a.y, l.a.z);
const B = new THREE.Vector3(l.b.x, l.b.y, l.b.z);
const dir = new THREE.Vector3().subVectors(B, A).normalize();
const p1 = A.clone().addScaledVector(dir, -ext);
const p2 = B.clone().addScaledVector(dir, ext);
const geo = new THREE.BufferGeometry().setFromPoints([p1, p2]);
const line = new THREE.Line(geo, new THREE.LineBasicMaterial({ color: l.color, transparent: true, opacity: 0.95 }));
line.renderOrder = 4;
this._constructGroup.add(line);
for (const P of [A, B]) {
const s = new THREE.Mesh(new THREE.SphereGeometry(0.09, 10, 10), new THREE.MeshBasicMaterial({ color: l.color }));
s.position.copy(P); s.renderOrder = 5;
this._constructGroup.add(s);
}
if (this.showLabels) {
const lbl = this._makeTextSprite(l.name, '#7DD3FC', 44);
lbl.position.copy(p2).addScaledVector(dir, -0.5).add(new THREE.Vector3(0.15, 0.25, 0));
this._constructGroup.add(lbl);
}
}
_drawPlaneObject(pl, ext) {
const point = new THREE.Vector3(pl.point.x, pl.point.y, pl.point.z);
const n = new THREE.Vector3(pl.normal.x, pl.normal.y, pl.normal.z).normalize();
let u = Math.abs(n.x) > 0.9 ? new THREE.Vector3(0, 1, 0) : new THREE.Vector3(1, 0, 0);
u = new THREE.Vector3().crossVectors(n, u).normalize();
const w = new THREE.Vector3().crossVectors(n, u).normalize();
const S = ext;
const corners = [
point.clone().addScaledVector(u, S).addScaledVector(w, S),
point.clone().addScaledVector(u, S).addScaledVector(w, -S),
point.clone().addScaledVector(u, -S).addScaledVector(w, -S),
point.clone().addScaledVector(u, -S).addScaledVector(w, S),
];
const positions = [];
corners.forEach(p => positions.push(p.x, p.y, p.z));
const geo = new THREE.BufferGeometry();
geo.setAttribute('position', new THREE.Float32BufferAttribute(positions, 3));
geo.setIndex([0, 1, 2, 0, 2, 3]);
const mesh = new THREE.Mesh(geo, new THREE.MeshBasicMaterial({
color: pl.color, transparent: true, opacity: 0.12, side: THREE.DoubleSide, depthWrite: false }));
mesh.renderOrder = 1;
this._constructGroup.add(mesh);
const border = [...corners, corners[0]];
const bline = new THREE.Line(new THREE.BufferGeometry().setFromPoints(border),
new THREE.LineDashedMaterial({ color: pl.color, dashSize: 0.22, gapSize: 0.14, transparent: true, opacity: 0.7 }));
bline.computeLineDistances();
bline.renderOrder = 2;
this._constructGroup.add(bline);
// Cross-section of the solid by this plane — makes the plane immediately meaningful.
if (pl.def) {
try {
const poly = this._sliceByPlane(
new THREE.Vector3(pl.def[0].x, pl.def[0].y, pl.def[0].z),
new THREE.Vector3(pl.def[1].x, pl.def[1].y, pl.def[1].z),
new THREE.Vector3(pl.def[2].x, pl.def[2].y, pl.def[2].z));
if (poly && poly.length >= 3) {
const sline = new THREE.Line(new THREE.BufferGeometry().setFromPoints([...poly, poly[0]]),
new THREE.LineBasicMaterial({ color: 0x06D6E0, transparent: true, opacity: 0.95 }));
sline.renderOrder = 4;
this._constructGroup.add(sline);
}
} catch (_) {}
}
if (this.showLabels) {
const lbl = this._makeTextSprite(pl.name, '#DDD6FE', 48);
lbl.position.copy(point).add(new THREE.Vector3(0.2, 0.3, 0));
this._constructGroup.add(lbl);
}
}
_drawCPointObject(cp) {
const pos = new THREE.Vector3(cp.pos.x, cp.pos.y, cp.pos.z);
const glow = new THREE.Mesh(new THREE.SphereGeometry(0.18, 12, 12),
new THREE.MeshBasicMaterial({ color: cp.color, transparent: true, opacity: 0.18, blending: THREE.AdditiveBlending, depthWrite: false }));
glow.position.copy(pos);
this._constructGroup.add(glow);
const s = new THREE.Mesh(new THREE.SphereGeometry(0.11, 12, 12), new THREE.MeshBasicMaterial({ color: cp.color }));
s.position.copy(pos); s.renderOrder = 5;
this._constructGroup.add(s);
if (this.showLabels) {
const lbl = this._makeTextSprite(cp.name, '#6EE7B7', 44);
lbl.position.copy(pos).add(new THREE.Vector3(0.2, 0.28, 0));
this._constructGroup.add(lbl);
}
}
_clearGroup(group) {
const disposeObj = (o) => {
if (o.geometry) o.geometry.dispose();
if (o.material) {
const mats = Array.isArray(o.material) ? o.material : [o.material];
for (const m of mats) { if (m.map) m.map.dispose(); m.dispose(); }
}
};
while (group.children.length) {
const c = group.children[0];
c.traverse(disposeObj); // dispose c plus any nested descendants (avoids leaks on nested groups)
group.remove(c);
}
this._invalidate();
}
/* ════════════════ EDGE MARKS ════════════════ */
_pickNearestEdgeIdx(e) {
const { mx, my } = this._screenCoords(e);
let bestDist = 0.10;
let bestIdx = -1;
for (let i = 0; i < this._edges.length; i++) {
const edge = this._edges[i];
const { dist } = this._edgePickNDC(mx, my, edge.from, edge.to);
if (dist < bestDist) { bestDist = dist; bestIdx = i; }
}
return bestIdx;
}
_onMarkClick(e) {
const idx = this._pickNearestEdgeIdx(e);
if (idx < 0) return;
if (!this._edgeMarks[idx]) this._edgeMarks[idx] = { ticks: 0, parallel: 0 };
const m = this._edgeMarks[idx];
if (this._markMode === 'ticks') m.ticks = (m.ticks + 1) % 4; // 0→1→2→3→0
if (this._markMode === 'parallel') m.parallel = (m.parallel + 1) % 4;
this._renderEdgeMarks();
}
_renderEdgeMarks() {
this._clearGroup(this._markGroup);
for (const [idxStr, mark] of Object.entries(this._edgeMarks)) {
const idx = parseInt(idxStr);
if (idx < 0 || idx >= this._edges.length) continue;
const { from, to } = this._edges[idx];
if (mark.ticks > 0) this._drawEdgeTick3D(from, to, mark.ticks, '#FFD166');
if (mark.parallel > 0) this._drawEdgeParallel3D(from, to, mark.parallel, '#06D6E0');
}
}
_drawEdgeTick3D(from, to, count, color) {
// Perpendicular ticks crossing the edge near its midpoint
const dir = new THREE.Vector3().subVectors(to, from).normalize();
const up = Math.abs(dir.y) < 0.9 ? new THREE.Vector3(0, 1, 0) : new THREE.Vector3(1, 0, 0);
const perp = new THREE.Vector3().crossVectors(dir, up).normalize();
const mid = new THREE.Vector3().addVectors(from, to).multiplyScalar(0.5);
const step = 0.22;
const half = (count - 1) / 2;
const mat = new THREE.LineBasicMaterial({ color: new THREE.Color(color), linewidth: 2 });
for (let i = 0; i < count; i++) {
const offset = (i - half) * step;
const center = mid.clone().add(dir.clone().multiplyScalar(offset));
const p1 = center.clone().sub(perp.clone().multiplyScalar(0.18));
const p2 = center.clone().add(perp.clone().multiplyScalar(0.18));
const geo = new THREE.BufferGeometry().setFromPoints([p1, p2]);
this._markGroup.add(new THREE.Line(geo, mat.clone()));
}
}
_drawEdgeParallel3D(from, to, count, color) {
// Chevron (arrow-head) marks indicating parallel edges
const dir = new THREE.Vector3().subVectors(to, from).normalize();
const up = Math.abs(dir.y) < 0.9 ? new THREE.Vector3(0, 1, 0) : new THREE.Vector3(1, 0, 0);
const perp = new THREE.Vector3().crossVectors(dir, up).normalize();
const mid = new THREE.Vector3().addVectors(from, to).multiplyScalar(0.5);
const step = 0.22;
const half = (count - 1) / 2;
const mat = new THREE.LineBasicMaterial({ color: new THREE.Color(color), linewidth: 2 });
for (let i = 0; i < count; i++) {
const offset = (i - half) * step;
const center = mid.clone().add(dir.clone().multiplyScalar(offset));
// chevron: two lines meeting at a tip (along perp), base spread along dir
const tip = center.clone().add(perp.clone().multiplyScalar( 0.18));
const base1 = center.clone().add(perp.clone().multiplyScalar(-0.10)).sub(dir.clone().multiplyScalar(0.14));
const base2 = center.clone().add(perp.clone().multiplyScalar(-0.10)).add(dir.clone().multiplyScalar(0.14));
const geo = new THREE.BufferGeometry().setFromPoints([base1, tip, base2]);
this._markGroup.add(new THREE.Line(geo, mat.clone()));
}
}
/* ════════════════ DERIVED 3D CONSTRUCTIONS ════════════════ */
_addSolidCentroid() {
if (!this._vertices.length) return;
const c = new THREE.Vector3();
this._vertices.forEach(v => c.add(v.pos));
c.divideScalar(this._vertices.length);
const n = this._derived3D.filter(d => d.type === 'point').length;
this._derived3D.push({ type: 'point', pos: c.clone(), label: n ? 'G' + (n + 1) : 'G', color: '#9B5DE5' });
this._buildDerived3D();
this._deriveMode = null;
this.renderer.domElement.style.cursor = 'grab';
}
_onDeriveClick(e) {
if (this._deriveMode === 'midpoint') {
const idx = this._pickNearestEdgeIdx(e);
if (idx < 0) return;
const { from, to } = this._edges[idx];
const mid = new THREE.Vector3().addVectors(from, to).multiplyScalar(0.5);
const n = this._derived3D.filter(d => d.type === 'point').length;
this._derived3D.push({ type: 'point', pos: mid.clone(), label: n ? 'M' + (n + 1) : 'M', color: '#FFD166' });
this._buildDerived3D();
} else if (this._deriveMode === 'face_centroid') {
const face = this._pickNearestFace(e);
if (!face) return;
const c = new THREE.Vector3();
face.forEach(v => c.add(v));
c.divideScalar(face.length);
const n = this._derived3D.filter(d => d.type === 'point').length;
this._derived3D.push({ type: 'point', pos: c.clone(), label: n ? 'O' + (n + 1) : 'O', color: '#A8E063' });
this._buildDerived3D();
} else if (this._deriveMode === 'alt_foot') {
const pick = this._pickNearestPoint(e);
if (!pick) return;
this._derivePicks.push(pick);
// Highlight first pick
if (this._derivePicks.length === 1) {
const sGeo = new THREE.SphereGeometry(0.14, 10, 10);
const sMat = new THREE.MeshBasicMaterial({ color: 0xF15BB5 });
const s = new THREE.Mesh(sGeo, sMat);
s.position.copy(pick.pos);
this._derivedGroup.add(s);
} else if (this._derivePicks.length === 2) {
// Vertex + base point: find foot of perpendicular from V onto the edge containing E
const V = this._derivePicks[0].pos;
const E = this._derivePicks[1].pos;
const eps = 0.12;
let foot = E.clone();
for (const edge of this._edges) {
if (edge.from.distanceTo(E) < eps || edge.to.distanceTo(E) < eps) {
const d = new THREE.Vector3().subVectors(edge.to, edge.from);
const t = new THREE.Vector3().subVectors(V, edge.from).dot(d) / d.lengthSq();
foot = edge.from.clone().add(d.clone().multiplyScalar(Math.max(0, Math.min(1, t))));
break;
}
}
const n = this._derived3D.filter(d => d.type === 'point').length;
this._derived3D.push({ type: 'point', pos: foot.clone(), label: n ? 'H' + (n + 1) : 'H', color: '#FF9F43' });
this._derived3D.push({ type: 'line', from: V.clone(), to: foot.clone(), color: '#FF9F43', dashed: true });
this._buildDerived3D();
this._derivePicks = [];
}
}
}
_buildDerived3D() {
this._clearGroup(this._derivedGroup);
for (const d of this._derived3D) {
if (d.type === 'point') {
const sGeo = new THREE.SphereGeometry(0.12, 12, 12);
const sMat = new THREE.MeshBasicMaterial({ color: new THREE.Color(d.color || '#FFD166') });
const s = new THREE.Mesh(sGeo, sMat);
s.position.copy(d.pos);
this._derivedGroup.add(s);
if (d.label) {
const sprite = this._makeTextSprite(d.label, d.color || '#FFD166', 52);
sprite.position.copy(d.pos).add(new THREE.Vector3(0.18, 0.25, 0));
sprite.scale.set(1.0, 0.45, 1);
this._derivedGroup.add(sprite);
}
} else if (d.type === 'line') {
const pts = [d.from, d.to];
const geo = new THREE.BufferGeometry().setFromPoints(pts);
let mat;
if (d.dashed) {
mat = new THREE.LineDashedMaterial({
color: new THREE.Color(d.color || '#FFD166'),
dashSize: 0.1, gapSize: 0.07, transparent: true, opacity: 0.85,
});
} else {
mat = new THREE.LineBasicMaterial({ color: new THREE.Color(d.color || '#FFD166'), transparent: true, opacity: 0.85 });
}
const line = new THREE.Line(geo, mat);
if (d.dashed) line.computeLineDistances();
this._derivedGroup.add(line);
}
}
}
_notify() {
if (this.onUpdate) this.onUpdate(this.info());
}
/* ════════════════ ANIMATION LOOP ════════════════ */
_loop() {
this._rafId = null;
if (!this._running) return;
// Orbit inertia (after release, decays to rest)
let inertia = false;
if (!this._drag && (this._velX !== 0 || this._velY !== 0)) {
this._rotY += this._velY; this._rotX += this._velX;
this._rotX = Math.max(-1.4, Math.min(1.4, this._rotX));
this._velY *= 0.92; this._velX *= 0.92;
if (Math.abs(this._velX) < 1e-4) this._velX = 0;
if (Math.abs(this._velY) < 1e-4) this._velY = 0;
inertia = (this._velX !== 0 || this._velY !== 0);
this._idleTime = 0; this._needsRender = true;
}
// Auto-spin after idle (only when enabled and the view has settled)
if (!this._drag && !inertia) this._idleTime++;
if (this._spinEnabled && this._idleTime > 300 && !this._drag && !inertia) this._autoSpin = true;
if (this._autoSpin) { this._rotY += 0.002; this._needsRender = true; }
// Unfold animation
let unfolding = false;
if (this._unfold && this._unfoldProgress < this._unfoldTarget) {
this._unfoldProgress = Math.min(1, this._unfoldProgress + 0.015);
this._applyUnfold(this._unfoldProgress);
unfolding = true; this._needsRender = true;
} else if (!this._unfold && this._unfoldProgress > 0) {
this._unfoldProgress = Math.max(0, this._unfoldProgress - 0.015);
this._applyUnfold(this._unfoldProgress);
if (this._unfoldProgress <= 0) {
this._figGroup.scale.y = 1;
this._figGroup.position.y = 0;
}
unfolding = true; this._needsRender = true;
}
if (this._needsRender) {
// Camera orbit around the (possibly panned) target
const target = this._camTarget();
this.camera.position.set(
target.x + this._dist * Math.sin(this._rotY) * Math.cos(this._rotX),
target.y + this._dist * Math.sin(this._rotX),
target.z + this._dist * Math.cos(this._rotY) * Math.cos(this._rotX)
);
this.camera.lookAt(target);
this.renderer.render(this.scene, this.camera);
this._needsRender = false;
}
// Keep the loop alive while there is motion or we're still counting toward
// auto-spin re-engagement; otherwise sleep until _invalidate() wakes us.
// Guard on _rafId so a mid-loop _invalidate() can't schedule a second frame.
const motion = this._autoSpin || this._drag || unfolding || inertia;
const waitingToSpin = this._spinEnabled && !this._autoSpin && this._idleTime <= 300;
if ((motion || waitingToSpin || this._needsRender) && this._rafId == null) {
this._rafId = requestAnimationFrame(() => this._loop());
}
}
}
/* ─── lab UI init ─────────────────────────────────── */
var stereoSim = null;
// which params are relevant per figure type
const STEREO_PARAM_MAP = {
cube: ['a'],
parallelepiped: ['a','b','c'],
pyramid: ['a','n','h'],
tetrahedron: ['a'],
cylinder: ['r','h'],
cone: ['r','h'],
trunccone: ['R','r','h'],
sphere: ['r'],
prism: ['a','n','h'],
truncpyramid: ['a','b','n','h'],
octahedron: ['a'],
icosahedron: ['a'],
dodecahedron: ['a'],
};
function _openStereo(figureType) {
document.getElementById('sim-topbar-title').textContent = 'Стереометрия 3D';
_simShow('sim-stereo');
document.getElementById('stereo-stats').style.display = '';
// Deep-link from a textbook: openSim('stereo:pyramid') or /lab?stereofig=pyramid
if (!figureType) {
try { figureType = new URLSearchParams(location.search).get('stereofig') || null; } catch (_) {}
}
requestAnimationFrame(() => requestAnimationFrame(() => {
if (!stereoSim) {
stereoSim = new StereoSim(document.getElementById('stereo-container'));
stereoSim.onUpdate = _stereoUpdateUI;
} else {
stereoSim.fit();
stereoSim.play();
}
if (figureType && STEREO_PARAM_MAP[figureType]) {
const btn = document.querySelector(`.stereo-fig-btn[onclick*="'${figureType}'"]`);
setStereoFigure(figureType, btn);
}
_stereoShowParams(stereoSim.figureType || 'cube');
_stereoUpdateUI(stereoSim.info());
_stereoUpdateFormulas();
}));
}
function setStereoFigure(type, btn) {
document.querySelectorAll('.stereo-fig-btn').forEach(b => b.classList.remove('active'));
if (btn) btn.classList.add('active');
if (stereoSim) {
stereoSim.setFigure(type);
_stereoShowParams(type);
_stereoUpdateFormulas();
if (window.LabFX) LabFX.sound.play('whoosh', { pitch: 1.2, volume: 0.3 });
// reset toggles and tool buttons
document.getElementById('sect-toggle').classList.remove('active');
document.getElementById('stereo-unfold-btn').classList.remove('active');
document.getElementById('stereo-measure-btn').classList.remove('active');
// reset element toggles
['stg-height','stg-apothem','stg-diagonals','stg-midpoints','stg-inscribed','stg-circumscribed','stg-edgelengths'].forEach(id => {
document.getElementById(id)?.classList.remove('on');
});
_stereoDeactivateTools();
}
}
function _stereoShowParams(type) {
const show = STEREO_PARAM_MAP[type] || ['a'];
['a','b','c','h','r','R','n'].forEach(k => {
document.getElementById('sp-' + k + '-row').style.display = show.includes(k) ? '' : 'none';
});
}
function stereoParamChange(key, val) {
val = +val;
const label = document.getElementById('sp-' + key + '-val');
if (label) label.textContent = val;
if (stereoSim) {
stereoSim.setParam(key, val);
_stereoUpdateFormulas();
}
}
function stereoOpacityChange(val) {
val = +val;
document.getElementById('sp-opacity-val').textContent = val.toFixed(2);
if (stereoSim) stereoSim.setOpacity(val);
}
/* ── camera / view controls (overlay toolbar) ── */
function stereoResetView() {
if (stereoSim) stereoSim.resetView();
// restore UI to initial: Изо preset active, auto-spin on
document.querySelectorAll('.st-view-preset').forEach((b, i) => b.classList.toggle('active', i === 0));
const sb = document.getElementById('st-spin-btn');
if (sb) { sb.classList.add('active'); sb.setAttribute('aria-pressed', 'true'); }
}
function stereoPreset(name, btn) {
if (stereoSim) stereoSim.setPreset(name);
document.querySelectorAll('.st-view-preset').forEach(b => b.classList.remove('active'));
if (btn) btn.classList.add('active');
// a preset turns auto-spin off — reflect it on the spin button
const sb = document.getElementById('st-spin-btn');
if (sb) { sb.classList.remove('active'); sb.setAttribute('aria-pressed', 'false'); }
}
function stereoToggleSpin(btn) {
if (!stereoSim) return;
const on = !btn.classList.contains('active');
btn.classList.toggle('active', on);
btn.setAttribute('aria-pressed', on ? 'true' : 'false');
stereoSim.setAutoSpin(on);
}
function stereoFullscreen() {
if (stereoSim) stereoSim.toggleFullscreen();
}
function stereoScreenshot() {
if (!stereoSim) return;
const url = stereoSim.screenshot();
if (!url) return;
const a = document.createElement('a');
a.href = url;
a.download = 'stereo-' + (stereoSim.figureType || 'figure') + '.png';
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
if (window.LabFX) LabFX.sound.play('tick', { pitch: 1.4, volume: 0.3 });
}
// legacy (used nowhere now but kept for safety)
function stereoToggle(layer, btn) {
const on = !btn.classList.contains('active');
btn.classList.toggle('active', on);
if (!stereoSim) return;
if (layer === 'edges') stereoSim.toggleEdges(on);
if (layer === 'vertices') stereoSim.toggleVertices(on);
if (layer === 'labels') stereoSim.toggleLabels(on);
if (layer === 'axes') stereoSim.toggleAxes(on);
if (layer === 'grid') stereoSim.toggleGrid(on);
}
// new toggle-row style
function stereoToggleSt(layer, toggle) {
const on = !toggle.classList.contains('on');
toggle.classList.toggle('on', on);
if (!stereoSim) return;
if (layer === 'edges') stereoSim.toggleEdges(on);
if (layer === 'vertices') stereoSim.toggleVertices(on);
if (layer === 'labels') stereoSim.toggleLabels(on);
if (layer === 'axes') stereoSim.toggleAxes(on);
if (layer === 'grid') stereoSim.toggleGrid(on);
}
function stereoToggleElem(layer, toggle) {
const on = !toggle.classList.contains('on');
toggle.classList.toggle('on', on);
if (!stereoSim) return;
if (layer === 'height') stereoSim.toggleHeight(on);
if (layer === 'apothem') stereoSim.toggleApothem(on);
if (layer === 'diagonals') stereoSim.toggleDiagonals(on);
if (layer === 'midpoints') stereoSim.toggleMidpoints(on);
if (layer === 'inscribed') stereoSim.toggleInscribed(on);
if (layer === 'circumscribed') stereoSim.toggleCircumscribed(on);
if (layer === 'edgelengths') stereoSim.toggleEdgeLengths(on);
}
// n-stepper for prism/pyramid
function stereoNChange(delta) {
if (!stereoSim) return;
const cur = stereoSim.params.n || 4;
const nv = Math.max(3, Math.min(12, cur + delta));
document.getElementById('sp-n-val').textContent = nv;
stereoSim.setParam('n', nv);
_stereoUpdateFormulas();
}
function stereoSectionToggle(btn) {
const on = !btn.classList.contains('active');
btn.classList.toggle('active', on);
if (stereoSim) stereoSim.toggleSection(on);
}
function stereoSectionType(t, btn) {
document.querySelectorAll('.stereo-sect-type').forEach(b => b.classList.remove('active'));
btn.classList.add('active');
// Show/hide angle slider for diagonal
document.getElementById('sp-angle-row').style.display = t === 'diagonal' ? '' : 'none';
if (stereoSim) stereoSim.setSectionType(t);
}
function stereoSectionHeight(val) {
document.getElementById('sp-sect-val').textContent = val + '%';
if (stereoSim) stereoSim.setSectionHeight(+val / 100);
}
function stereoSectionAngle(val) {
document.getElementById('sp-angle-val').textContent = val + '%';
if (stereoSim) stereoSim.setSectionAngle(+val / 100);
}
function stereoUnfold(btn) {
const on = !btn.classList.contains('active');
btn.classList.toggle('active', on);
if (stereoSim) stereoSim.toggleUnfold(on);
}
function _stereoDeactivateTools() {
['stereo-measure-btn','stereo-point-btn','stereo-connect-btn',
'stereo-angle-edge-btn','stereo-angle-lp-btn','stereo-angle-dih-btn','stereo-angle-pp-btn','stereo-angle-skew-btn',
'stereo-mark-tick-btn','stereo-mark-par-btn',
'stereo-derive-mid-btn','stereo-derive-fc-btn','stereo-derive-alt-btn','stereo-derive-cen-btn',
'stereo-sect3p-btn','stereo-line-btn','stereo-plane-btn','stereo-intersect-btn',
'stereo-rel-lpar-btn','stereo-rel-lperp-btn','stereo-rel-ppar-btn','stereo-rel-pperp-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);
stereoSim.setLineMode(false);
stereoSim.setPlaneMode(false);
stereoSim.setIntersectMode(false);
stereoSim.setRelMode(null);
}
const hint = document.getElementById('angle-hint');
if (hint) hint.textContent = '';
const chint = document.getElementById('construct-hint');
if (chint) chint.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();
}
/* ── Constructions: lines & planes (Phase A) ── */
function stereoLineMode(btn) {
const on = !btn.classList.contains('active');
_stereoDeactivateTools();
btn.classList.toggle('active', on);
if (stereoSim) stereoSim.setLineMode(on);
const h = document.getElementById('construct-hint');
if (h) h.textContent = on ? 'Кликните 2 точки или вершины — построится прямая' : '';
}
function stereoPlaneMode(btn) {
const on = !btn.classList.contains('active');
_stereoDeactivateTools();
btn.classList.toggle('active', on);
if (stereoSim) stereoSim.setPlaneMode(on);
const h = document.getElementById('construct-hint');
if (h) h.textContent = on ? 'Кликните 3 точки или вершины — построится плоскость и её сечение тела' : '';
}
function stereoConstructUndo() {
if (stereoSim) stereoSim.removeLastConstruction();
}
function stereoConstructClear() {
_stereoDeactivateTools();
if (stereoSim) stereoSim.clearConstructions();
const h = document.getElementById('construct-hint');
if (h) h.textContent = '';
}
function stereoIntersectMode(btn) {
const on = !btn.classList.contains('active');
_stereoDeactivateTools();
btn.classList.toggle('active', on);
if (stereoSim) stereoSim.setIntersectMode(on);
const h = document.getElementById('construct-hint');
if (h) h.textContent = on ? 'Выберите 2 объекта (прямые/плоскости) в списке ниже' : '';
}
function stereoConstructSelect(id) {
if (!stereoSim) return;
const res = stereoSim.pickConstructObject(id);
const h = document.getElementById('construct-hint');
if (h && res && res.msg) h.textContent = res.msg;
}
function stereoConstructDelete(id) { if (stereoSim) stereoSim.removeConstruction(id); }
function stereoConstructVis(id) { if (stereoSim) stereoSim.toggleConstructionVis(id); }
function stereoRelMode(op, btn) {
const on = !btn.classList.contains('active');
_stereoDeactivateTools();
btn.classList.toggle('active', on);
if (stereoSim) stereoSim.setRelMode(on ? op : null);
const h = document.getElementById('construct-hint');
if (h) h.textContent = on ? 'Выберите опорный объект в списке, затем кликните точку на сцене' : '';
}
function stereoConstructHistUndo() { if (stereoSim) stereoSim.undo(); }
function stereoConstructHistRedo() { if (stereoSim) stereoSim.redo(); }
function _stereoUpdateConstructList() {
const el = document.getElementById('construct-list');
if (!el || !stereoSim) return;
const c = stereoSim.getConstructions();
const EYE_ON = '<svg viewBox="0 0 24 24" style="width:14px;height:14px"><path d="M1 12s4-7 11-7 11 7 11 7-4 7-11 7-11-7-11-7z" fill="none" stroke="currentColor" stroke-width="2"/><circle cx="12" cy="12" r="3" fill="currentColor"/></svg>';
const EYE_OFF = '<svg viewBox="0 0 24 24" style="width:14px;height:14px"><path d="M3 3l18 18M10.6 10.6a3 3 0 0 0 4.2 4.2M9.9 5.1A10.9 10.9 0 0 1 12 5c7 0 11 7 11 7a18 18 0 0 1-3.2 3.9M6.1 6.1A18 18 0 0 0 1 12s4 7 11 7c1.4 0 2.7-.2 3.9-.6" fill="none" stroke="currentColor" stroke-width="2"/></svg>';
const X_IC = '<svg viewBox="0 0 24 24" style="width:14px;height:14px"><line x1="6" y1="6" x2="18" y2="18" stroke="currentColor" stroke-width="2" stroke-linecap="round"/><line x1="18" y1="6" x2="6" y2="18" stroke="currentColor" stroke-width="2" stroke-linecap="round"/></svg>';
const icBtn = (id, fn, title, svg) =>
'<button onclick="' + fn + "('" + id + '\')" title="' + title + '" style="background:none;border:none;color:rgba(255,255,255,0.55);cursor:pointer;padding:1px;display:flex;align-items:center">' + svg + '</button>';
const row = (o, kind, color, selectable) => {
const selBg = o.selected ? 'background:rgba(56,189,248,0.2);' : '';
const dim = o.hidden ? 'opacity:0.4;' : '';
const main = '<span style="flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;' + dim +
(selectable ? 'cursor:pointer" onclick="stereoConstructSelect(\'' + o.id + '\')"' : '"') + '>' +
'<b style="color:' + color + '">' + kind + ' ' + o.name + '</b>' +
(o.info ? ' <span style="color:rgba(255,255,255,0.4)">' + o.info + '</span>' : '') + '</span>';
return '<div style="display:flex;align-items:center;gap:3px;padding:1px 3px;border-radius:5px;' + selBg + '">' +
main +
icBtn(o.id, 'stereoConstructVis', 'Скрыть/показать', o.hidden ? EYE_OFF : EYE_ON) +
icBtn(o.id, 'stereoConstructDelete', 'Удалить', X_IC) + '</div>';
};
const sel = c.intersectMode || c.relMode;
const rows = [];
c.points.forEach(p => rows.push(row(p, 'точка', '#6EE7B7', false)));
c.lines.forEach(l => rows.push(row(l, 'прямая', '#7DD3FC', sel)));
c.planes.forEach(p => rows.push(row(p, 'плоскость', '#DDD6FE', sel)));
el.innerHTML = rows.join('');
}
/* ── Section through 3 points UI ── */
function stereoSection3P(btn) {
const on = !btn.classList.contains('active');
_stereoDeactivateTools();
btn.classList.toggle('active', on);
if (stereoSim) stereoSim.toggleSection3P(on);
const hint = document.getElementById('sect3p-hint');
if (hint) hint.textContent = on ? 'Кликните 3 точки на рёбрах или вершинах' : '';
if (on) _stereoUpdateSection3PPanel();
}
function stereoSection3PClear() {
if (stereoSim) stereoSim.clearSection3P();
_stereoUpdateSection3PPanel();
}
function _stereoStepHint() {
const hint = document.getElementById('sect3p-hint');
if (hint && stereoSim) hint.textContent = stereoSim._stepCaption || '';
}
function stereoSection3PStepBy(toggle) {
const on = !toggle.classList.contains('on');
toggle.classList.toggle('on', on);
if (stereoSim) stereoSim.toggleSection3PStepBy(on);
_stereoStepHint();
}
function stereoSection3PNextStep() {
if (!stereoSim) return;
if (!stereoSim._section3PStepBy) return; // steps only meaningful in step mode
const max = stereoSim._section3PData ? 6 : stereoSim._section3PPicks.length;
stereoSim._section3PStep = Math.min(stereoSim._section3PStep + 1, max);
stereoSim._drawSection3P();
_stereoStepHint();
}
function stereoSection3PPrevStep() {
if (!stereoSim) return;
if (!stereoSim._section3PStepBy) return;
stereoSim._section3PStep = Math.max(1, stereoSim._section3PStep - 1);
stereoSim._drawSection3P();
_stereoStepHint();
}
function _stereoUpdateSection3PPanel() {
const panel = document.getElementById('sect3p-info');
if (!panel) return;
if (!stereoSim) { panel.innerHTML = ''; return; }
const data = stereoSim.getSection3PInfo();
const picks = stereoSim._section3PPicks;
if (!data && picks.length === 0) { panel.innerHTML = ''; return; }
const r = v => Math.round(v * 100) / 100;
const fmtV = v => `(${r(v.x)}, ${r(v.y)}, ${r(v.z)})`;
const lines = [];
picks.forEach((p, i) => lines.push(`<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();
// Construction tree (points / lines / planes)
_stereoUpdateConstructList();
// Flash the result of the last canvas-driven construction into the hint.
if (stereoSim && stereoSim._lastConstructMsg) {
const ch = document.getElementById('construct-hint');
if (ch) ch.textContent = stereoSim._lastConstructMsg;
stereoSim._lastConstructMsg = '';
}
// Live readout overlay (section type/area/perimeter, last measurement)
_stereoUpdateReadout(info);
// Keep the trace-method caption in sync (e.g. right after the 3rd pick),
// but only in step mode so the "Кликните 3 точки" instruction is preserved.
if (stereoSim && stereoSim._section3PStepBy) _stereoStepHint();
}
function _stereoUpdateReadout(info) {
const el = document.getElementById('stereo-readout');
if (!el) return;
const lines = (info && info.readout) || [];
if (!lines.length) { el.style.display = 'none'; el.innerHTML = ''; return; }
el.style.display = '';
el.innerHTML = lines.map(l =>
'<div class="st-ro-row"><span class="st-ro-k">' + l.label + '</span>' +
'<span class="st-ro-v">' + l.value + '</span></div>'
).join('');
}
function _stereoUpdatePointsInfo(info) {
const el = document.getElementById('points-info');
if (!el) return;
if (!info) info = stereoSim?.info();
if (!info) { el.textContent = ''; return; }
let txt = '';
if (info.customPoints > 0) txt += `Точек: ${info.customPoints}`;
if (info.connections > 0) txt += ` · Линий: ${info.connections}`;
el.textContent = txt;
}