9547a20875
Фаза B раунда «Конструктор» (умные точки для построений). B1 — деление отрезка m:n: задаёшь m,n, кликаешь 2 точки A,B → точка делит AB как AM:MB = m:n (t=m/(m+n)), создаётся как точка-построение M,N,K… B2 — точка по координатам: поля x/y/z + кнопка → addPointAt. B3 — перетаскивание построенных точек мышью: drag в плоскости, обращённой к камере (нормаль фиксируется на старте), приоритет над орбитой; снапшот истории на старте → undo откатывает весь drag. Непараметрично: downstream- объекты за перетаскиванием не следуют (параметрический граф — бэклог). - StereoSim: setDivideMode/setDivideRatio (+ ветка в _onConstructClick), addPointAt; setDragPointMode/_pickCPointAt/_beginCPointDrag/_rayPlaneHit/ _dragCPointWithRay/_dragCPointAt/_endCPointDrag; pointer-хендлеры (down=начать drag, move=тащить, up=завершить); сброс в setFigure; интеграция в _stereoDeactivateTools. - Панель: блок «Точки» (кнопки Деление/Тащить, поля m:n, поля x,y,z + «Точка (x,y,z)»); glue stereoDivideMode/DivideRatio/AddCoordPoint/ DragPointMode. Верификация: node --check OK; headless-смоук 25/25 (деление 1:1/1:2/3:1, координатная точка + отказ NaN, ray∩plane вкл. parallel/behind, drag begin→ move→end с проверкой позиции и снапшота истории + undo, взаимоисключение режимов, setFigure-сброс, dispose); эмодзи/eval/new Function — 0. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
5329 lines
210 KiB
JavaScript
5329 lines
210 KiB
JavaScript
'use strict';
|
||
|
||
/* ═══════════════════════════════════════════════════════════
|
||
StereoSim — 3D Stereometry (Three.js)
|
||
Cube, Parallelepiped, Pyramid, Tetrahedron, Cylinder,
|
||
Cone, Truncated Cone, Sphere, Prism + sections, unfold
|
||
═══════════════════════════════════════════════════════════ */
|
||
|
||
class StereoSim {
|
||
constructor(container) {
|
||
this.container = container;
|
||
this._running = false;
|
||
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 };
|
||
// Drag a construction point (left button) — takes priority over orbit.
|
||
if (this._dragPointMode && e.button === 0 && !e.shiftKey) {
|
||
const hit = this._pickCPointAt(e);
|
||
if (hit && this._beginCPointDrag(hit)) {
|
||
try { el.setPointerCapture(e.pointerId); } catch (_) {}
|
||
el.style.cursor = 'grabbing';
|
||
this._autoSpin = false; this._idleTime = 0;
|
||
return;
|
||
}
|
||
}
|
||
// 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 && !this._divideMode && !this._dragPointMode) el.style.cursor = 'grabbing';
|
||
this._invalidate();
|
||
});
|
||
on(el, 'contextmenu', e => e.preventDefault()); // allow right-drag pan without menu
|
||
on(el, 'pointerup', e => {
|
||
if (this._draggingCP) {
|
||
this._endCPointDrag();
|
||
try { el.releasePointerCapture(e.pointerId); } catch (_) {}
|
||
el.style.cursor = 'grab';
|
||
this._notify(); this._invalidate();
|
||
return;
|
||
}
|
||
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 || this._divideMode) { 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 => {
|
||
if (this._draggingCP) { this._dragCPointAt(e); this._idleTime = 0; this._invalidate(); return; }
|
||
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._sectionPlaneId = null; // id of the plane shown as a filled, measured section
|
||
this._divideMode = false; // pick 2 points → point dividing the segment m:n
|
||
this._divM = 1; // division ratio m
|
||
this._divN = 1; // division ratio n
|
||
this._dragPointMode = false; // drag construction points in the screen-facing plane
|
||
this._draggingCP = null; // id of the construction point being dragged
|
||
this._dragPlane = null; // {point, normal} drag plane fixed at grab time
|
||
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._sectionPlaneId = null; this._lastConstructMsg = '';
|
||
this._divideMode = false; this._dragPointMode = false; this._draggingCP = null; this._dragPlane = null;
|
||
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._sectionPlaneId) {
|
||
const pl = this._planes.find(p => p.id === this._sectionPlaneId);
|
||
const poly = this._activeSectionPolygon();
|
||
if (pl && poly) {
|
||
const nm = ({ 3: 'треугольник', 4: 'четырёхугольник', 5: 'пятиугольник', 6: 'шестиугольник' }[poly.length] || `${poly.length}-угольник`);
|
||
lines.push({ label: 'Сечение пл. ' + pl.name, value: nm });
|
||
lines.push({ label: 'Площадь S', value: r(this._polygonArea(poly)) });
|
||
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 5–6 — 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);
|
||
if (this._sectionPlaneId === id) this._sectionPlaneId = null;
|
||
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._sectionPlaneId = 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());
|
||
}
|
||
|
||
/* ── Section from a plane object (Phase C1): fill + vertex labels + S/P ── */
|
||
|
||
setSectionPlane(id) {
|
||
this._sectionPlaneId = (this._sectionPlaneId === id) ? null : id;
|
||
this._rebuildConstructions();
|
||
this._notify();
|
||
}
|
||
|
||
// Ordered polygon (Vector3[]) where the active section plane cuts the solid, or null.
|
||
_activeSectionPolygon() {
|
||
if (!this._sectionPlaneId) return null;
|
||
const pl = this._planes.find(p => p.id === this._sectionPlaneId);
|
||
if (!pl || !pl.def) return null;
|
||
let poly = null;
|
||
try {
|
||
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));
|
||
} catch (_) { poly = null; }
|
||
return (poly && poly.length >= 3) ? poly : null;
|
||
}
|
||
|
||
_sectionVertexLabel(i) {
|
||
// K, L, M, … wrapping after a dozen
|
||
return String.fromCharCode(75 + (i % 12));
|
||
}
|
||
|
||
// True shape (натуральная величина) of the active section: the polygon unfolded
|
||
// into its own plane → 2D points with REAL lengths preserved. Pure data (no DOM).
|
||
getTrueShape() {
|
||
const poly = this._activeSectionPolygon();
|
||
if (!poly || poly.length < 3) return null;
|
||
const pl = this._planes.find(p => p.id === this._sectionPlaneId);
|
||
if (!pl) return null;
|
||
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 c = new THREE.Vector3();
|
||
poly.forEach(p => c.add(p));
|
||
c.divideScalar(poly.length);
|
||
const r = (v) => Math.round(v * 100) / 100;
|
||
const pts = poly.map(p => {
|
||
const d = p.clone().sub(c);
|
||
return { x: d.dot(u), y: d.dot(w) };
|
||
});
|
||
const edges = pts.map((p, i) => {
|
||
const q = pts[(i + 1) % pts.length];
|
||
return r(Math.hypot(q.x - p.x, q.y - p.y));
|
||
});
|
||
return {
|
||
name: pl.name,
|
||
pts,
|
||
edges,
|
||
labels: pts.map((_, i) => this._sectionVertexLabel(i)),
|
||
area: r(this._polygonArea(poly)),
|
||
perim: r(this._polygonPerimeter(poly)),
|
||
};
|
||
}
|
||
|
||
// 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,
|
||
sectionId: this._sectionPlaneId,
|
||
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),
|
||
section: (p.id === this._sectionPlaneId),
|
||
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) {
|
||
// Normal mode: click a plane in the tree to show/hide it as a filled, measured section.
|
||
const f = this._findObj(id);
|
||
if (f && f.type === 'plane') {
|
||
this.setSectionPlane(id);
|
||
return { msg: this._sectionPlaneId ? ('сечение по плоскости ' + f.obj.name) : 'сечение снято' };
|
||
}
|
||
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 && !this._divideMode) 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._planeMode ? 3 : 2;
|
||
if (this._constructPicks.length >= need) {
|
||
if (this._lineMode) {
|
||
const nm = this._createLine(this._constructPicks[0], this._constructPicks[1]);
|
||
this._lastConstructMsg = nm ? ('прямая ' + nm) : '';
|
||
} else if (this._divideMode) {
|
||
const A = this._constructPicks[0], B = this._constructPicks[1];
|
||
const sum = this._divM + this._divN;
|
||
const t = sum > 0 ? this._divM / sum : 0.5;
|
||
const M = A.clone().addScaledVector(B.clone().sub(A), t);
|
||
const nm = this._createCPoint(M);
|
||
this._lastConstructMsg = nm ? ('точка ' + nm + ' делит AB как ' + this._divM + ':' + this._divN) : '';
|
||
} 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();
|
||
}
|
||
|
||
/* ── Smart points (Phase B): division m:n, coordinate input ── */
|
||
|
||
setDivideMode(on) {
|
||
this._divideMode = on;
|
||
if (on) {
|
||
this._lineMode = false; this._planeMode = false;
|
||
this._intersectMode = false; this._intersectSel = []; this._relMode = null;
|
||
this._constructPicks = [];
|
||
}
|
||
this.renderer.domElement.style.cursor = on ? 'crosshair' : 'grab';
|
||
this._rebuildConstructions();
|
||
this._notify();
|
||
}
|
||
|
||
setDivideRatio(m, n) {
|
||
m = +m; n = +n;
|
||
if (isFinite(m) && m >= 0) this._divM = m;
|
||
if (isFinite(n) && n >= 0) this._divN = n;
|
||
}
|
||
|
||
addPointAt(x, y, z) {
|
||
x = +x; y = +y; z = +z;
|
||
if (!isFinite(x) || !isFinite(y) || !isFinite(z)) return null;
|
||
const nm = this._createCPoint(new THREE.Vector3(x, y, z));
|
||
this._lastConstructMsg = nm ? ('точка ' + nm + ' (' + x + ', ' + y + ', ' + z + ')') : '';
|
||
this._rebuildConstructions();
|
||
this._notify();
|
||
return nm;
|
||
}
|
||
|
||
/* ── Drag construction points in the screen-facing plane (Phase B) ── */
|
||
|
||
setDragPointMode(on) {
|
||
this._dragPointMode = on;
|
||
if (on) {
|
||
this._lineMode = false; this._planeMode = false; this._divideMode = false;
|
||
this._intersectMode = false; this._intersectSel = []; this._relMode = null;
|
||
this._constructPicks = [];
|
||
}
|
||
this.renderer.domElement.style.cursor = 'grab';
|
||
this._rebuildConstructions();
|
||
this._notify();
|
||
}
|
||
|
||
_pickCPointAt(e) {
|
||
const { mx, my } = this._screenCoords(e);
|
||
let bestDist = 0.05, best = null;
|
||
for (const cp of this._cpoints) {
|
||
if (cp.hidden) continue;
|
||
const p = new THREE.Vector3(cp.pos.x, cp.pos.y, cp.pos.z).project(this.camera);
|
||
const d = Math.hypot(p.x - mx, p.y - my);
|
||
if (d < bestDist) { bestDist = d; best = cp.id; }
|
||
}
|
||
return best;
|
||
}
|
||
|
||
// Begin dragging a construction point; the drag plane faces the camera.
|
||
_beginCPointDrag(id) {
|
||
const cp = this._cpoints.find(p => p.id === id);
|
||
if (!cp) return false;
|
||
this._draggingCP = id;
|
||
const nrm = new THREE.Vector3();
|
||
if (this.camera.getWorldDirection) this.camera.getWorldDirection(nrm);
|
||
if (nrm.length() < 1e-6) nrm.set(0, 0, 1);
|
||
this._dragPlane = { point: new THREE.Vector3(cp.pos.x, cp.pos.y, cp.pos.z), normal: nrm.normalize() };
|
||
this._pushHistory();
|
||
return true;
|
||
}
|
||
|
||
_rayPlaneHit(ro, rd, planePoint, n) {
|
||
const denom = rd.dot(n);
|
||
if (Math.abs(denom) < 1e-9) return null;
|
||
const t = planePoint.clone().sub(ro).dot(n) / denom;
|
||
if (t < 0) return null;
|
||
return ro.clone().addScaledVector(rd, t);
|
||
}
|
||
|
||
// Move the dragged point to where the given ray meets the drag plane.
|
||
_dragCPointWithRay(ro, rd) {
|
||
if (!this._draggingCP || !this._dragPlane) return;
|
||
const cp = this._cpoints.find(p => p.id === this._draggingCP);
|
||
if (!cp) return;
|
||
const hit = this._rayPlaneHit(ro, rd, this._dragPlane.point, this._dragPlane.normal);
|
||
if (!hit) return;
|
||
cp.pos = { x: hit.x, y: hit.y, z: hit.z };
|
||
this._rebuildConstructions();
|
||
}
|
||
|
||
_dragCPointAt(e) {
|
||
const { mx, my } = this._screenCoords(e);
|
||
const ro = this.camera.position.clone();
|
||
const rd = new THREE.Vector3(mx, my, 0.5).unproject(this.camera).sub(ro).normalize();
|
||
this._dragCPointWithRay(ro, rd);
|
||
}
|
||
|
||
_endCPointDrag() {
|
||
this._draggingCP = null;
|
||
this._dragPlane = null;
|
||
}
|
||
|
||
_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.
|
||
// When this plane is the *active section*, fill it + label its vertices K,L,M…
|
||
if (pl.def) {
|
||
const isSection = (pl.id === this._sectionPlaneId);
|
||
let poly = null;
|
||
try {
|
||
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));
|
||
} catch (_) { poly = null; }
|
||
if (poly && poly.length >= 3) {
|
||
if (isSection) {
|
||
// filled face (triangle fan)
|
||
const positions = [], indices = [];
|
||
poly.forEach(p => positions.push(p.x, p.y, p.z));
|
||
for (let i = 1; i < poly.length - 1; i++) indices.push(0, i, i + 1);
|
||
const fgeo = new THREE.BufferGeometry();
|
||
fgeo.setAttribute('position', new THREE.Float32BufferAttribute(positions, 3));
|
||
fgeo.setIndex(indices);
|
||
fgeo.computeVertexNormals();
|
||
const fill = new THREE.Mesh(fgeo, new THREE.MeshBasicMaterial({ color: 0x06D6E0, transparent: true, opacity: 0.32, side: THREE.DoubleSide, depthWrite: false }));
|
||
fill.renderOrder = 3;
|
||
this._constructGroup.add(fill);
|
||
}
|
||
const sline = new THREE.Line(new THREE.BufferGeometry().setFromPoints([...poly, poly[0]]),
|
||
new THREE.LineBasicMaterial({ color: 0x06D6E0, transparent: true, opacity: isSection ? 1 : 0.85 }));
|
||
sline.renderOrder = 4;
|
||
this._constructGroup.add(sline);
|
||
if (isSection && this.showLabels) {
|
||
poly.forEach((p, i) => {
|
||
const lbl = this._makeTextSprite(this._sectionVertexLabel(i), '#67E8F9', 38);
|
||
lbl.position.copy(p).add(new THREE.Vector3(0.14, 0.2, 0));
|
||
this._constructGroup.add(lbl);
|
||
});
|
||
}
|
||
}
|
||
}
|
||
|
||
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',
|
||
'stereo-divide-btn','stereo-dragpt-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);
|
||
stereoSim.setDivideMode(false);
|
||
stereoSim.setDragPointMode(false);
|
||
}
|
||
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(); }
|
||
|
||
/* ── Smart points (Phase B): division m:n, coordinate input, drag ── */
|
||
function _stereoReadRatio() {
|
||
const m = parseFloat(document.getElementById('st-div-m')?.value);
|
||
const n = parseFloat(document.getElementById('st-div-n')?.value);
|
||
return { m: isFinite(m) && m >= 0 ? m : 1, n: isFinite(n) && n >= 0 ? n : 1 };
|
||
}
|
||
|
||
function stereoDivideMode(btn) {
|
||
const on = !btn.classList.contains('active');
|
||
_stereoDeactivateTools();
|
||
btn.classList.toggle('active', on);
|
||
if (stereoSim) {
|
||
const { m, n } = _stereoReadRatio();
|
||
stereoSim.setDivideRatio(m, n);
|
||
stereoSim.setDivideMode(on);
|
||
}
|
||
const h = document.getElementById('construct-hint');
|
||
if (h) h.textContent = on ? 'Кликните 2 точки A и B — точка разделит AB в отношении m:n' : '';
|
||
}
|
||
|
||
function stereoDivideRatio() {
|
||
if (!stereoSim) return;
|
||
const { m, n } = _stereoReadRatio();
|
||
stereoSim.setDivideRatio(m, n);
|
||
}
|
||
|
||
function stereoAddCoordPoint() {
|
||
if (!stereoSim) return;
|
||
const x = parseFloat(document.getElementById('st-pt-x')?.value) || 0;
|
||
const y = parseFloat(document.getElementById('st-pt-y')?.value) || 0;
|
||
const z = parseFloat(document.getElementById('st-pt-z')?.value) || 0;
|
||
stereoSim.addPointAt(x, y, z);
|
||
}
|
||
|
||
function stereoDragPointMode(btn) {
|
||
const on = !btn.classList.contains('active');
|
||
_stereoDeactivateTools();
|
||
btn.classList.toggle('active', on);
|
||
if (stereoSim) stereoSim.setDragPointMode(on);
|
||
const h = document.getElementById('construct-hint');
|
||
if (h) h.textContent = on ? 'Тащите построенные точки (M, N…) мышью — двигаются в плоскости экрана' : '';
|
||
}
|
||
|
||
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.section ? 'background:rgba(6,214,224,0.18);' : (o.selected ? 'background:rgba(56,189,248,0.2);' : '');
|
||
const dim = o.hidden ? 'opacity:0.4;' : '';
|
||
const tag = o.section ? ' <span style="color:#06D6E0">(сечение)</span>' : '';
|
||
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>' + tag +
|
||
(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)));
|
||
// planes are always clickable: in normal mode a click toggles "show as section"
|
||
c.planes.forEach(p => rows.push(row(p, 'плоскость', '#DDD6FE', true)));
|
||
el.innerHTML = rows.join('');
|
||
}
|
||
|
||
// True-shape (натуральная величина) mini-panel for the active section.
|
||
function _stereoUpdateTrueShape() {
|
||
const el = document.getElementById('construct-trueshape');
|
||
if (!el || !stereoSim) return;
|
||
const ts = stereoSim.getTrueShape();
|
||
if (!ts) { el.style.display = 'none'; el.innerHTML = ''; return; }
|
||
el.style.display = '';
|
||
const W = 150, H = 130, pad = 24;
|
||
let minX = Infinity, maxX = -Infinity, minY = Infinity, maxY = -Infinity;
|
||
ts.pts.forEach(p => { minX = Math.min(minX, p.x); maxX = Math.max(maxX, p.x); minY = Math.min(minY, p.y); maxY = Math.max(maxY, p.y); });
|
||
const s = Math.min((W - 2 * pad) / Math.max(1e-3, maxX - minX), (H - 2 * pad) / Math.max(1e-3, maxY - minY));
|
||
const cx = (minX + maxX) / 2, cy = (minY + maxY) / 2;
|
||
const scr = ts.pts.map(p => ({ x: W / 2 + (p.x - cx) * s, y: H / 2 - (p.y - cy) * s }));
|
||
const ptsAttr = scr.map(p => p.x.toFixed(1) + ',' + p.y.toFixed(1)).join(' ');
|
||
let svg = '<svg viewBox="0 0 ' + W + ' ' + H + '" style="width:100%;height:auto;display:block">';
|
||
svg += '<defs><pattern id="st-ts-hatch" width="6" height="6" patternUnits="userSpaceOnUse" patternTransform="rotate(45)">'
|
||
+ '<line x1="0" y1="0" x2="0" y2="6" stroke="#06D6E0" stroke-width="0.8" opacity="0.45"/></pattern></defs>';
|
||
svg += '<polygon points="' + ptsAttr + '" fill="url(#st-ts-hatch)" stroke="#06D6E0" stroke-width="1.6" stroke-linejoin="round"/>';
|
||
scr.forEach((p, i) => {
|
||
const q = scr[(i + 1) % scr.length];
|
||
svg += '<text x="' + ((p.x + q.x) / 2).toFixed(1) + '" y="' + ((p.y + q.y) / 2).toFixed(1)
|
||
+ '" font-size="6.5" fill="rgba(255,255,255,0.75)" text-anchor="middle" dominant-baseline="middle">' + ts.edges[i] + '</text>';
|
||
});
|
||
scr.forEach((p, i) => {
|
||
const dx = p.x - W / 2, dy = p.y - H / 2, L = Math.hypot(dx, dy) || 1;
|
||
svg += '<circle cx="' + p.x.toFixed(1) + '" cy="' + p.y.toFixed(1) + '" r="2" fill="#67E8F9"/>';
|
||
svg += '<text x="' + (p.x + dx / L * 9).toFixed(1) + '" y="' + (p.y + dy / L * 9).toFixed(1)
|
||
+ '" font-size="8" font-weight="700" fill="#67E8F9" text-anchor="middle" dominant-baseline="middle">' + ts.labels[i] + '</text>';
|
||
});
|
||
svg += '</svg>';
|
||
el.innerHTML = '<div style="font-size:0.63rem;color:rgba(255,255,255,0.55);margin:0 0 2px">Натуральная величина сечения '
|
||
+ ts.name + ' · S = ' + ts.area + ' · P = ' + ts.perim + '</div>' + svg;
|
||
}
|
||
|
||
/* ── 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 = '';
|
||
}
|
||
|
||
// True-shape mini-panel of the active section
|
||
_stereoUpdateTrueShape();
|
||
|
||
// 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;
|
||
}
|
||
|