7c598d6430
- инерция орбиты с затуханием; панорамирование (ПКМ/СКМ/Shift+ЛКМ, 2 пальца) - орбита вокруг сдвигаемого таргета (_panOffset) - overlay-тулбар: сброс вида + пресеты ракурса (Изо/Спереди/Сбоку/Сверху) - тумблер авто-вращения с реальным засыпанием loop, fullscreen, снимок PNG - a11y-атрибуты на кнопках; bump stereo.js?v=4 Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
3977 lines
148 KiB
JavaScript
3977 lines
148 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 };
|
|
// 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) el.style.cursor = 'grabbing';
|
|
this._invalidate();
|
|
});
|
|
on(el, 'contextmenu', e => e.preventDefault()); // allow right-drag pan without menu
|
|
on(el, 'pointerup', e => {
|
|
const wasDrag = this._clickStart &&
|
|
(Math.abs(e.clientX - this._clickStart.x) > 4 || Math.abs(e.clientY - this._clickStart.y) > 4);
|
|
this._drag = false;
|
|
const wasPanning = this._panning; this._panning = false;
|
|
try { el.releasePointerCapture(e.pointerId); } catch (_) {}
|
|
if (wasPanning) { el.style.cursor = 'grab'; this._invalidate(); return; }
|
|
if (this._pointMode) { el.style.cursor = 'cell'; if (!wasDrag) this._onPointClick(e); }
|
|
else if (this._connectMode) { el.style.cursor = 'pointer'; if (!wasDrag) this._onConnectClick(e); }
|
|
else if (this._measureMode) { el.style.cursor = 'crosshair'; if (!wasDrag) this._onMeasureClick(e); }
|
|
else if (this._angleMode) { el.style.cursor = 'crosshair'; if (!wasDrag) this._onAngleClick(e); }
|
|
else if (this._markMode) { el.style.cursor = 'pointer'; if (!wasDrag) this._onMarkClick(e); }
|
|
else if (this._deriveMode) { el.style.cursor = 'crosshair'; if (!wasDrag) this._onDeriveClick(e); }
|
|
else if (this._section3PMode) { el.style.cursor = 'crosshair'; if (!wasDrag) this._onSection3PClick(e); }
|
|
else el.style.cursor = 'grab';
|
|
this._invalidate();
|
|
});
|
|
on(el, 'pointermove', e => {
|
|
this._onHoverMove(e);
|
|
if (!this._drag) return;
|
|
const dx = e.clientX - this._prevX, dy = e.clientY - this._prevY;
|
|
if (this._panning) {
|
|
this._pan(dx, dy);
|
|
} else {
|
|
const vy = dx * 0.007, vx = dy * 0.007;
|
|
this._rotY += vy; this._rotX += vx;
|
|
this._rotX = Math.max(-1.4, Math.min(1.4, this._rotX));
|
|
this._velY = vy; this._velX = vx; // remember last delta for inertia
|
|
}
|
|
this._prevX = e.clientX; this._prevY = e.clientY;
|
|
this._idleTime = 0;
|
|
this._invalidate();
|
|
});
|
|
on(el, 'wheel', e => {
|
|
e.preventDefault();
|
|
this._dist = Math.max(4, Math.min(40, this._dist + e.deltaY * 0.02));
|
|
this._invalidate();
|
|
}, { passive: false });
|
|
|
|
// 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.onUpdate = null;
|
|
|
|
this._buildGrid();
|
|
this._buildFigure();
|
|
this.fit();
|
|
this.play();
|
|
}
|
|
|
|
/* ════════════════ PUBLIC API ════════════════ */
|
|
|
|
setFigure(type) {
|
|
this.figureType = type;
|
|
this._unfold = false; this._unfoldProgress = 0; this._unfoldTarget = 0;
|
|
this.showSection = false;
|
|
this.showInscribed = false; this.showCircumscribed = false;
|
|
this.showHeight = false; this.showApothem = false;
|
|
this.showDiagonals = false; this.showMidpoints = false;
|
|
this._measurements = [];
|
|
this._measurePicks = [];
|
|
this._customPoints = [];
|
|
this._connections = [];
|
|
this._connectPicks = [];
|
|
this._anglePicks = [];
|
|
this._angleMode = null;
|
|
this._nextPointId = 1;
|
|
this._clearGroup(this._pointGroup);
|
|
this._clearGroup(this._angleGroup);
|
|
this._clearGroup(this._measureGroup);
|
|
this._edgeMarks = {};
|
|
this._markMode = null;
|
|
this._clearGroup(this._markGroup);
|
|
this._derived3D = [];
|
|
this._deriveMode = null;
|
|
this._derivePicks = [];
|
|
this._clearGroup(this._derivedGroup);
|
|
this._section3PPicks = [];
|
|
this._section3PData = null;
|
|
this._section3PMode = false;
|
|
this._section3PStep = 0;
|
|
this._clearGroup(this._section3PGroup);
|
|
this._buildFigure();
|
|
this._notify();
|
|
}
|
|
|
|
setParam(key, val) {
|
|
this.params[key] = val;
|
|
this._buildFigure();
|
|
this._notify();
|
|
}
|
|
|
|
setOpacity(v) {
|
|
this.opacity = v;
|
|
this._buildFigure();
|
|
}
|
|
|
|
toggleEdges(v) { this.showEdges = v; this._buildFigure(); }
|
|
toggleVertices(v) { this.showVertices = v; this._buildFigure(); }
|
|
toggleLabels(v) { this.showLabels = v; this._buildFigure(); }
|
|
toggleAxes(v) { this.showAxes = v; this._buildGrid(); }
|
|
toggleGrid(v) { this.showGrid = v; this._buildGrid(); }
|
|
|
|
toggleSection(on) {
|
|
this.showSection = on;
|
|
this._updateSection();
|
|
}
|
|
setSectionHeight(v) {
|
|
this.sectionHeight = v;
|
|
this._updateSection();
|
|
this._notify();
|
|
}
|
|
setSectionType(t) {
|
|
this.sectionType = t;
|
|
this._updateSection();
|
|
this._notify();
|
|
}
|
|
|
|
toggleUnfold(on) {
|
|
this._unfold = on;
|
|
this._unfoldTarget = on ? 1 : 0;
|
|
}
|
|
|
|
toggleInscribed(on) {
|
|
this.showInscribed = on;
|
|
this._updateSpheres();
|
|
this._notify();
|
|
}
|
|
toggleCircumscribed(on) {
|
|
this.showCircumscribed = on;
|
|
this._updateSpheres();
|
|
this._notify();
|
|
}
|
|
|
|
toggleHeight(on) {
|
|
this.showHeight = on;
|
|
this._buildFigure();
|
|
this._notify();
|
|
}
|
|
|
|
toggleApothem(on) {
|
|
this.showApothem = on;
|
|
this._buildFigure();
|
|
this._notify();
|
|
}
|
|
|
|
toggleDiagonals(on) {
|
|
this.showDiagonals = on;
|
|
this._buildFigure();
|
|
this._notify();
|
|
}
|
|
|
|
toggleMidpoints(on) {
|
|
this.showMidpoints = on;
|
|
this._buildFigure();
|
|
this._notify();
|
|
}
|
|
|
|
toggleMeasure(on) {
|
|
this._measureMode = on;
|
|
this._pointMode = false;
|
|
this._angleMode = null;
|
|
this._measurePicks = [];
|
|
this._clearGroup(this._measurePickGroup);
|
|
if (!on) { this._measurements = []; this._rebuildMeasureGroup(); }
|
|
this.renderer.domElement.style.cursor = on ? 'crosshair' : 'grab';
|
|
}
|
|
|
|
removeLastMeasurement() {
|
|
if (!this._measurements.length) return;
|
|
this._measurements.pop();
|
|
this._rebuildMeasureGroup();
|
|
}
|
|
|
|
clearMeasurements() {
|
|
this._measurements = [];
|
|
this._measurePicks = [];
|
|
this._rebuildMeasureGroup();
|
|
this._clearGroup(this._measurePickGroup);
|
|
}
|
|
|
|
_rebuildMeasureGroup() {
|
|
this._clearGroup(this._measureGroup);
|
|
for (const m of this._measurements) {
|
|
const g = new THREE.Group();
|
|
// spheres at endpoints
|
|
for (const pos of [m.posA, m.posB]) {
|
|
const sGeo = new THREE.SphereGeometry(0.14, 12, 12);
|
|
const sMat = new THREE.MeshBasicMaterial({ color: 0xFFD166 });
|
|
const s = new THREE.Mesh(sGeo, sMat);
|
|
s.position.copy(pos);
|
|
g.add(s);
|
|
}
|
|
// dashed line
|
|
const lineGeo = new THREE.BufferGeometry().setFromPoints([m.posA, m.posB]);
|
|
const lineMat = new THREE.LineDashedMaterial({ color: 0xFFD166, dashSize: 0.15, gapSize: 0.1, transparent: true, opacity: 0.9 });
|
|
const line = new THREE.Line(lineGeo, lineMat);
|
|
line.computeLineDistances();
|
|
g.add(line);
|
|
// label
|
|
const mid = new THREE.Vector3().addVectors(m.posA, m.posB).multiplyScalar(0.5);
|
|
const label = this._makeTextSprite(`${m.from}${m.to} = ${m.dist}`, '#FFD166', 40);
|
|
label.position.copy(mid).add(new THREE.Vector3(0, 0.3, 0));
|
|
label.scale.set(1.4, 0.5, 1);
|
|
g.add(label);
|
|
this._measureGroup.add(g);
|
|
}
|
|
}
|
|
|
|
setAngleMode(mode) {
|
|
// mode: 'edge' | 'linePlane' | 'dihedral' | null
|
|
this._angleMode = mode;
|
|
this._anglePicks = [];
|
|
this._measureMode = false;
|
|
this._pointMode = false;
|
|
this._connectMode = false;
|
|
if (!mode) { this._clearGroup(this._angleGroup); }
|
|
this.renderer.domElement.style.cursor = mode ? 'crosshair' : 'grab';
|
|
}
|
|
|
|
setSectionAngle(v) {
|
|
this.sectionAngle = v;
|
|
if (this.showSection && this.sectionType === 'diagonal') this._updateSection();
|
|
this._notify();
|
|
}
|
|
|
|
/* ── Point mode: place points on edges ── */
|
|
togglePointMode(on) {
|
|
this._pointMode = on;
|
|
this._measureMode = false;
|
|
this._connectPicks = [];
|
|
this.renderer.domElement.style.cursor = on ? 'cell' : 'grab';
|
|
}
|
|
|
|
toggleConnectMode(on) {
|
|
this._connectMode = on;
|
|
this._pointMode = false;
|
|
this._measureMode = false;
|
|
this._connectPicks = [];
|
|
this.renderer.domElement.style.cursor = on ? 'pointer' : 'grab';
|
|
}
|
|
|
|
clearCustomPoints() {
|
|
this._customPoints = [];
|
|
this._connections = [];
|
|
this._connectPicks = [];
|
|
this._nextPointId = 1;
|
|
this._clearGroup(this._pointGroup);
|
|
if (this.showSection && this.sectionType === 'custom') this._updateSection();
|
|
this._notify();
|
|
}
|
|
|
|
removeLastPoint() {
|
|
if (!this._customPoints.length) return;
|
|
// Remove connections referencing last point
|
|
const lastIdx = this._customPoints.length - 1;
|
|
this._connections = this._connections.filter(c => c.from !== lastIdx && c.to !== lastIdx);
|
|
this._customPoints.pop();
|
|
this._nextPointId = Math.max(1, this._nextPointId - 1);
|
|
this._rebuildPointVisuals();
|
|
if (this.showSection && this.sectionType === 'custom') this._updateSection();
|
|
this._notify();
|
|
}
|
|
|
|
getCustomPoints() { return this._customPoints; }
|
|
getConnections() { return this._connections; }
|
|
|
|
/* ── Edge mark mode ── */
|
|
setMarkMode(mode) {
|
|
// mode: 'ticks' | 'parallel' | null
|
|
this._markMode = mode;
|
|
this._deriveMode = null;
|
|
this._derivePicks = [];
|
|
this._measureMode = false;
|
|
this._pointMode = false;
|
|
this._connectMode = false;
|
|
this._angleMode = null;
|
|
this.renderer.domElement.style.cursor = mode ? 'pointer' : 'grab';
|
|
}
|
|
|
|
clearMarks() {
|
|
this._edgeMarks = {};
|
|
this._renderEdgeMarks();
|
|
}
|
|
|
|
/* ── Derived 3D constructions mode ── */
|
|
setDeriveMode(mode) {
|
|
// mode: 'midpoint' | 'face_centroid' | 'alt_foot' | 'solid_centroid' | null
|
|
this._deriveMode = mode;
|
|
this._derivePicks = [];
|
|
this._markMode = null;
|
|
this._measureMode = false;
|
|
this._pointMode = false;
|
|
this._connectMode = false;
|
|
this._angleMode = null;
|
|
this.renderer.domElement.style.cursor = mode ? 'crosshair' : 'grab';
|
|
if (mode === 'solid_centroid') this._addSolidCentroid();
|
|
}
|
|
|
|
clearDerived() {
|
|
this._derived3D = [];
|
|
this._derivePicks = [];
|
|
this._clearGroup(this._derivedGroup);
|
|
}
|
|
|
|
removeLastDerived() {
|
|
if (!this._derived3D.length) return;
|
|
this._derived3D.pop();
|
|
this._buildDerived3D();
|
|
}
|
|
|
|
/* ── Edge length labels ── */
|
|
toggleEdgeLengths(on) {
|
|
this.showEdgeLengths = on;
|
|
this._buildFigure();
|
|
}
|
|
|
|
/* ── Section by 3 arbitrary points ── */
|
|
toggleSection3P(on) {
|
|
this._section3PMode = on;
|
|
// turn off all other interactive modes
|
|
this._pointMode = false;
|
|
this._connectMode = false;
|
|
this._measureMode = false;
|
|
this._angleMode = null;
|
|
this._markMode = null;
|
|
this._deriveMode = null;
|
|
this._connectPicks = [];
|
|
this.renderer.domElement.style.cursor = on ? 'crosshair' : 'grab';
|
|
}
|
|
|
|
clearSection3P() {
|
|
this._section3PPicks = [];
|
|
this._section3PData = null;
|
|
this._section3PStep = 0;
|
|
this._clearGroup(this._section3PGroup);
|
|
this._notify();
|
|
}
|
|
|
|
toggleSection3PStepBy(on) {
|
|
this._section3PStepBy = on;
|
|
// re-render if data already exists
|
|
if (this._section3PData) this._drawSection3P();
|
|
}
|
|
|
|
getSection3PInfo() {
|
|
return this._section3PData;
|
|
}
|
|
|
|
getFormulas() {
|
|
const p = this.params;
|
|
const PI = Math.PI;
|
|
const r = (v) => Math.round(v * 100) / 100;
|
|
switch (this.figureType) {
|
|
case 'cube': {
|
|
const a = p.a;
|
|
const rIn = a / 2, rOut = r(a * Math.sqrt(3) / 2);
|
|
return { V: r(a**3), S: r(6*a**2), S_side: r(4*a**2), d: r(a*Math.sqrt(3)), h: a,
|
|
formulas: [`V = a³ = ${r(a**3)}`, `S = 6a² = ${r(6*a**2)}`, `d = a√3 = ${r(a*Math.sqrt(3))}`,
|
|
`r_вп = a/2 = ${r(rIn)}`, `R_оп = a√3/2 = ${rOut}`] };
|
|
}
|
|
case 'parallelepiped': {
|
|
const {a,b,c} = p;
|
|
return { V: r(a*b*c), S: r(2*(a*b+b*c+a*c)), S_side: r(2*c*(a+b)), d: r(Math.sqrt(a**2+b**2+c**2)), h: c,
|
|
formulas: [`V = abc = ${r(a*b*c)}`, `S = 2(ab+bc+ac) = ${r(2*(a*b+b*c+a*c))}`, `d = √(a²+b²+c²) = ${r(Math.sqrt(a**2+b**2+c**2))}`,
|
|
`r_вп = ${r(Math.min(a,b,c)/2)}`, `R_оп = ${r(Math.sqrt(a**2+b**2+c**2)/2)}`] };
|
|
}
|
|
case 'pyramid': {
|
|
const {a, h, n} = p;
|
|
const sBase = n * a**2 / (4 * Math.tan(PI/n));
|
|
const apothem = a / (2 * Math.tan(PI/n));
|
|
const slantH = Math.sqrt(h**2 + apothem**2);
|
|
const sLat = n * a * slantH / 2;
|
|
const Vp = sBase * h / 3;
|
|
const rIn = r(3 * Vp / (sBase + sLat));
|
|
const Rb = a / (2 * Math.sin(PI / n));
|
|
const rOut = r((Rb**2 + h**2) / (2 * h));
|
|
return { V: r(Vp), S: r(sBase+sLat), S_side: r(sLat), d: null, h,
|
|
formulas: [`V = ⅓·S_осн·h = ${r(Vp)}`, `S_осн = ${r(sBase)}`, `S_бок = ${r(sLat)}`, `S_полн = ${r(sBase+sLat)}`,
|
|
`a_осн = ${r(apothem)}`, `a_бок = ${r(slantH)}`, `r_вп = ${rIn}`, `R_оп = ${rOut}`] };
|
|
}
|
|
case 'tetrahedron': {
|
|
const a = p.a;
|
|
const rIn = r(a / (2 * Math.sqrt(6))), rOut = r(a * Math.sqrt(6) / 4);
|
|
return { V: r(a**3*Math.sqrt(2)/12), S: r(a**2*Math.sqrt(3)), S_side: r(a**2*Math.sqrt(3)*3/4), d: null, h: r(a*Math.sqrt(2/3)),
|
|
formulas: [`V = a³√2/12 = ${r(a**3*Math.sqrt(2)/12)}`, `S = a²√3 = ${r(a**2*Math.sqrt(3))}`, `h = a√(2/3) = ${r(a*Math.sqrt(2/3))}`,
|
|
`r_вп = ${rIn}`, `R_оп = ${rOut}`] };
|
|
}
|
|
case 'cylinder': {
|
|
const {r: rad, h} = p;
|
|
const rIn = r(Math.min(rad, h/2)), rOut = r(Math.sqrt(rad**2 + (h/2)**2));
|
|
return { V: r(PI*rad**2*h), S: r(2*PI*rad*(rad+h)), S_side: r(2*PI*rad*h), d: r(2*rad), h,
|
|
formulas: [`V = πr²h = ${r(PI*rad**2*h)}`, `S_бок = 2πrh = ${r(2*PI*rad*h)}`, `S_полн = 2πr(r+h) = ${r(2*PI*rad*(rad+h))}`,
|
|
`r_вп = ${rIn}`, `R_оп = ${rOut}`] };
|
|
}
|
|
case 'cone': {
|
|
const {r: rad, h} = p;
|
|
const l = Math.sqrt(rad**2+h**2);
|
|
const rIn = r(rad * h / (rad + l)), rOut = r((rad**2 + h**2) / (2 * h));
|
|
return { V: r(PI*rad**2*h/3), S: r(PI*rad*(rad+l)), S_side: r(PI*rad*l), d: r(2*rad), h,
|
|
formulas: [`V = ⅓πr²h = ${r(PI*rad**2*h/3)}`, `l = √(r²+h²) = ${r(l)}`, `S_бок = πrl = ${r(PI*rad*l)}`,
|
|
`r_вп = ${rIn}`, `R_оп = ${rOut}`] };
|
|
}
|
|
case 'trunccone': {
|
|
const {R: R1, r: r1, h} = p;
|
|
const l = Math.sqrt((R1-r1)**2+h**2);
|
|
const V = PI*h*(R1**2+R1*r1+r1**2)/3;
|
|
return { V: r(V), S: r(PI*(R1**2+r1**2+(R1+r1)*l)), S_side: r(PI*(R1+r1)*l), d: null, h,
|
|
formulas: [`V = ⅓πh(R²+Rr+r²) = ${r(V)}`, `l = ${r(l)}`, `S_бок = π(R+r)l = ${r(PI*(R1+r1)*l)}`] };
|
|
}
|
|
case 'sphere': {
|
|
const rad = p.r;
|
|
return { V: r(4*PI*rad**3/3), S: r(4*PI*rad**2), S_side: null, d: r(2*rad), h: r(2*rad),
|
|
formulas: [`V = ⁴⁄₃πr³ = ${r(4*PI*rad**3/3)}`, `S = 4πr² = ${r(4*PI*rad**2)}`, `d = 2r = ${r(2*rad)}`] };
|
|
}
|
|
case 'prism': {
|
|
const {a, h, n} = p;
|
|
const sBase = n * a**2 / (4 * Math.tan(PI/n));
|
|
const apothem = a / (2 * Math.tan(PI/n));
|
|
const Rb = a / (2 * Math.sin(PI/n));
|
|
const rIn = r(Math.min(apothem, h/2)), rOut = r(Math.sqrt(Rb**2 + (h/2)**2));
|
|
return { V: r(sBase*h), S: r(2*sBase + n*a*h), S_side: r(n*a*h), d: null, h,
|
|
formulas: [`V = S_осн·h = ${r(sBase*h)}`, `S_осн = ${r(sBase)}`, `S_бок = nah = ${r(n*a*h)}`,
|
|
`r_вп = ${rIn}`, `R_оп = ${rOut}`] };
|
|
}
|
|
case 'truncpyramid': {
|
|
const { a, b, h, n } = p;
|
|
const sLow = n * a**2 / (4 * Math.tan(PI/n));
|
|
const sUp = n * b**2 / (4 * Math.tan(PI/n));
|
|
const apLow = a / (2 * Math.tan(PI/n));
|
|
const apUp = b / (2 * Math.tan(PI/n));
|
|
const slant = Math.sqrt(h**2 + (apLow - apUp)**2);
|
|
const sLat = n * (a + b) * slant / 2;
|
|
const V = h * (sLow + sUp + Math.sqrt(sLow * sUp)) / 3;
|
|
return { V: r(V), S: r(sLow + sUp + sLat), S_side: r(sLat), d: null, h,
|
|
formulas: [
|
|
`V = h(S₁+S₂+√(S₁·S₂))/3 = ${r(V)}`,
|
|
`S₁ = ${r(sLow)}, S₂ = ${r(sUp)}`,
|
|
`l (апоф.) = ${r(slant)}`,
|
|
`S_бок = n(a+b)l/2 = ${r(sLat)}`,
|
|
`S_полн = ${r(sLow + sUp + sLat)}`,
|
|
]};
|
|
}
|
|
case 'octahedron': {
|
|
const a = p.a;
|
|
const V_val = r(a**3 * Math.SQRT2 / 3);
|
|
const S_val = r(2 * a**2 * Math.sqrt(3));
|
|
return { V: V_val, S: S_val, S_side: null, d: null, h: r(a * Math.SQRT2),
|
|
formulas: [
|
|
`V = a³√2/3 = ${V_val}`,
|
|
`S = 2a²√3 = ${S_val}`,
|
|
`h = a√2 = ${r(a * Math.SQRT2)}`,
|
|
`r_вп = a√6/6 = ${r(a * Math.sqrt(6) / 6)}`,
|
|
`R_оп = a√2/2 = ${r(a * Math.SQRT2 / 2)}`,
|
|
]};
|
|
}
|
|
case 'icosahedron': {
|
|
const a = p.a;
|
|
const phi = (1 + Math.sqrt(5)) / 2;
|
|
const V_val = r(5 * a**3 * (3 + Math.sqrt(5)) / 12);
|
|
const S_val = r(5 * a**2 * Math.sqrt(3));
|
|
const rIn = r(a * phi**2 / (2 * Math.sqrt(3)));
|
|
const rOut = r(a * Math.sqrt(10 + 2*Math.sqrt(5)) / 4);
|
|
return { V: V_val, S: S_val, S_side: null, d: null, h: r(a * Math.sqrt(10 + 2*Math.sqrt(5)) / 2),
|
|
formulas: [
|
|
`V = 5a³(3+√5)/12 = ${V_val}`,
|
|
`S = 5a²√3 = ${S_val}`,
|
|
`r_вп ≈ ${rIn}`,
|
|
`R_оп = a√(10+2√5)/4 ≈ ${rOut}`,
|
|
]};
|
|
}
|
|
case 'dodecahedron': {
|
|
const a = p.a;
|
|
const phi = (1 + Math.sqrt(5)) / 2;
|
|
const V_val = r(a**3 * (15 + 7*Math.sqrt(5)) / 4);
|
|
const S_val = r(3 * a**2 * Math.sqrt(25 + 10*Math.sqrt(5)));
|
|
const rIn = r(a / 2 * Math.sqrt((25 + 11*Math.sqrt(5)) / 10));
|
|
const rOut = r(a * Math.sqrt(3) * phi / 2);
|
|
return { V: V_val, S: S_val, S_side: null, d: null, h: r(a * Math.sqrt(3) * phi),
|
|
formulas: [
|
|
`V = a³(15+7√5)/4 = ${V_val}`,
|
|
`S = 3a²√(25+10√5) = ${S_val}`,
|
|
`r_вп ≈ ${rIn}`,
|
|
`R_оп = a√3·φ/2 ≈ ${rOut}`,
|
|
]};
|
|
}
|
|
default: return { V: 0, S: 0, S_side: 0, d: 0, h: 0, formulas: [] };
|
|
}
|
|
}
|
|
|
|
getSectionArea() {
|
|
if (!this.showSection || !this._sectionPolygon || !this._sectionPolygon.length) return 0;
|
|
return this._polygonArea(this._sectionPolygon);
|
|
}
|
|
|
|
info() {
|
|
const f = this.getFormulas();
|
|
return {
|
|
type: this.figureType,
|
|
V: f.V, S: f.S, S_side: f.S_side, d: f.d, h: f.h,
|
|
sectionArea: this.showSection ? this.getSectionArea() : null,
|
|
inscribedR: this.showInscribed ? this._inscribedRadius() : null,
|
|
circumscribedR: this.showCircumscribed ? this._circumscribedRadius() : null,
|
|
customPoints: this._customPoints.length,
|
|
connections: this._connections.length,
|
|
};
|
|
}
|
|
|
|
fit() {
|
|
const w = this.container.clientWidth || 600;
|
|
const h = this.container.clientHeight || 400;
|
|
this.camera.aspect = w / h;
|
|
this.camera.updateProjectionMatrix();
|
|
this.renderer.setSize(w, h);
|
|
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]
|
|
.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);
|
|
}
|
|
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.8) {
|
|
if (!this.showEdges) return;
|
|
for (const e of this._edges) {
|
|
const pts = [e.from, e.to];
|
|
const geo = new THREE.BufferGeometry().setFromPoints(pts);
|
|
const mat = new THREE.LineBasicMaterial({ color: 0xFFFFFF, transparent: true, opacity: opac, linewidth: 2 });
|
|
this._figGroup.add(new THREE.Line(geo, mat));
|
|
|
|
if (this.showEdgeLengths) {
|
|
const len = e.from.distanceTo(e.to);
|
|
const mid = new THREE.Vector3().addVectors(e.from, e.to).multiplyScalar(0.5);
|
|
const lbl = this._makeTextSprite(len.toFixed(2), '#A8E063', 44);
|
|
lbl.position.copy(mid).add(new THREE.Vector3(0.1, 0.12, 0.1));
|
|
lbl.scale.set(0.9, 0.4, 1);
|
|
this._labelGroup.add(lbl);
|
|
}
|
|
}
|
|
}
|
|
|
|
_addVerticesAndLabels() {
|
|
for (const v of this._vertices) {
|
|
if (this.showVertices) {
|
|
const sGeo = new THREE.SphereGeometry(0.08, 12, 12);
|
|
const sMat = new THREE.MeshBasicMaterial({ color: 0xFFFFFF });
|
|
const sphere = new THREE.Mesh(sGeo, sMat);
|
|
sphere.position.copy(v.pos);
|
|
this._figGroup.add(sphere);
|
|
}
|
|
|
|
if (this.showLabels) {
|
|
const sprite = this._makeTextSprite(v.label);
|
|
sprite.position.copy(v.pos).add(new THREE.Vector3(0.15, 0.25, 0));
|
|
this._labelGroup.add(sprite);
|
|
}
|
|
}
|
|
}
|
|
|
|
_makeTextSprite(text, color = '#ffffff', size = 64) {
|
|
const canvas = document.createElement('canvas');
|
|
canvas.width = 128; canvas.height = 64;
|
|
const ctx = canvas.getContext('2d');
|
|
ctx.font = `bold ${size}px Manrope, sans-serif`;
|
|
ctx.fillStyle = color;
|
|
ctx.textAlign = 'center';
|
|
ctx.textBaseline = 'middle';
|
|
ctx.fillText(text, 64, 32);
|
|
|
|
const tex = new THREE.CanvasTexture(canvas);
|
|
tex.minFilter = THREE.LinearFilter;
|
|
const mat = new THREE.SpriteMaterial({ map: tex, transparent: true, depthTest: false });
|
|
const sprite = new THREE.Sprite(mat);
|
|
sprite.scale.set(0.8, 0.4, 1);
|
|
return sprite;
|
|
}
|
|
|
|
/* ════════════════ SECTION PLANE ════════════════ */
|
|
|
|
_updateSection() {
|
|
this._clearGroup(this._sectionGroup);
|
|
this._sectionPolygon = null;
|
|
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);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
_figureHeight() {
|
|
const p = this.params;
|
|
switch (this.figureType) {
|
|
case 'cube': return p.a;
|
|
case 'parallelepiped': return p.c;
|
|
case 'pyramid': case 'prism': case 'truncpyramid': return p.h;
|
|
case 'tetrahedron': return p.a * Math.sqrt(2/3);
|
|
case 'cylinder': case 'cone': case 'trunccone': return p.h;
|
|
case 'sphere': return p.r * 2;
|
|
case 'octahedron': return p.a * Math.SQRT2;
|
|
case 'icosahedron': return p.a * Math.sqrt(10 + 2*Math.sqrt(5)) / 2;
|
|
case 'dodecahedron': { const phi = (1+Math.sqrt(5))/2; return p.a * Math.sqrt(3) * phi; }
|
|
default: return p.h || p.a || 4;
|
|
}
|
|
}
|
|
|
|
_sliceAtY(y) {
|
|
// Find intersections of all edges with horizontal plane y
|
|
const points = [];
|
|
for (const e of this._edges) {
|
|
const y1 = e.from.y, y2 = e.to.y;
|
|
if ((y1 <= y && y2 >= y) || (y2 <= y && y1 >= y)) {
|
|
if (Math.abs(y2 - y1) < 1e-9) continue;
|
|
const t = (y - y1) / (y2 - y1);
|
|
if (t < -0.001 || t > 1.001) continue;
|
|
const pt = new THREE.Vector3().lerpVectors(e.from, e.to, t);
|
|
points.push(pt);
|
|
}
|
|
}
|
|
// For cylinders/cones/spheres with no explicit edges, generate circle
|
|
if (points.length < 3) {
|
|
const circleR = this._radiusAtHeight(y);
|
|
if (circleR > 0.01) {
|
|
const n = 48;
|
|
for (let i = 0; i < n; i++) {
|
|
const a = (i/n)*Math.PI*2;
|
|
points.push(new THREE.Vector3(circleR*Math.cos(a), y, circleR*Math.sin(a)));
|
|
}
|
|
}
|
|
}
|
|
// Sort by angle from centroid
|
|
return this._sortByAngle(points);
|
|
}
|
|
|
|
_radiusAtHeight(y) {
|
|
const p = this.params;
|
|
const fh = this._figureHeight();
|
|
const t = Math.max(0, Math.min(1, y / fh));
|
|
switch (this.figureType) {
|
|
case 'cylinder': return p.r;
|
|
case 'cone': return p.r * (1 - t);
|
|
case 'trunccone': return p.R + (p.r - p.R) * t;
|
|
case 'sphere': {
|
|
const dy = y - p.r; // center at (0, r, 0)
|
|
const r2 = p.r**2 - dy**2;
|
|
return r2 > 0 ? Math.sqrt(r2) : 0;
|
|
}
|
|
default: return 0;
|
|
}
|
|
}
|
|
|
|
_sliceDiagonal() {
|
|
const fh = this._figureHeight();
|
|
const y = fh * this.sectionHeight;
|
|
// Tilt angle: sectionAngle 0=horizontal, 1=nearly vertical (~80°)
|
|
const tiltRad = this.sectionAngle * Math.PI * 0.44; // 0..~80°
|
|
// Normal rotated from vertical (0,1,0) toward X axis
|
|
const normal = new THREE.Vector3(Math.sin(tiltRad), Math.cos(tiltRad), 0).normalize();
|
|
const pointOnPlane = new THREE.Vector3(0, y, 0);
|
|
return this._sliceByNormal(normal, pointOnPlane);
|
|
}
|
|
|
|
_sliceByPlane(p1, p2, p3) {
|
|
// Plane through 3 points
|
|
const v1 = new THREE.Vector3().subVectors(p2, p1);
|
|
const v2 = new THREE.Vector3().subVectors(p3, p1);
|
|
const normal = new THREE.Vector3().crossVectors(v1, v2).normalize();
|
|
if (normal.length() < 1e-9) return [];
|
|
return this._sliceByNormal(normal, p1);
|
|
}
|
|
|
|
_sliceByNormal(normal, pointOnPlane) {
|
|
const d = -normal.dot(pointOnPlane);
|
|
const points = [];
|
|
|
|
// Intersect with all edges
|
|
for (const e of this._edges) {
|
|
const d1 = normal.dot(e.from) + d;
|
|
const d2 = normal.dot(e.to) + d;
|
|
if ((d1 <= 0 && d2 >= 0) || (d2 <= 0 && d1 >= 0)) {
|
|
if (Math.abs(d2 - d1) < 1e-9) continue;
|
|
const t = -d1 / (d2 - d1);
|
|
if (t < -0.001 || t > 1.001) continue;
|
|
points.push(new THREE.Vector3().lerpVectors(e.from, e.to, Math.max(0, Math.min(1, t))));
|
|
}
|
|
}
|
|
|
|
// For cylinders/cones/spheres — sample the intersection curve
|
|
if (points.length < 3 && ['cylinder','cone','trunccone','sphere'].includes(this.figureType)) {
|
|
const fh = this._figureHeight();
|
|
const samples = 64;
|
|
for (let i = 0; i < samples; i++) {
|
|
const angle = (i / samples) * Math.PI * 2;
|
|
// Walk along height, find where the plane intersects at this angle
|
|
for (let step = 0; step <= 100; step++) {
|
|
const y = (step / 100) * fh;
|
|
const r = this._radiusAtHeight(y);
|
|
if (r < 0.01) continue;
|
|
const pt = new THREE.Vector3(r * Math.cos(angle), y, r * Math.sin(angle));
|
|
const dist = normal.dot(pt) + d;
|
|
if (Math.abs(dist) < fh * 0.012) {
|
|
points.push(pt);
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// Sort into convex polygon order (project onto plane, sort by angle)
|
|
return this._sortByAngle3D(points, normal);
|
|
}
|
|
|
|
_sortByAngle(points) {
|
|
if (points.length < 3) return points;
|
|
const cx = points.reduce((s,p) => s+p.x, 0) / points.length;
|
|
const cz = points.reduce((s,p) => s+p.z, 0) / points.length;
|
|
points.sort((a,b) => Math.atan2(a.z-cz, a.x-cx) - Math.atan2(b.z-cz, b.x-cx));
|
|
return points;
|
|
}
|
|
|
|
_sortByAngle3D(points, normal) {
|
|
if (points.length < 3) return points;
|
|
// Remove near-duplicates
|
|
const unique = [points[0]];
|
|
for (let i = 1; i < points.length; i++) {
|
|
let dup = false;
|
|
for (const u of unique) {
|
|
if (points[i].distanceTo(u) < 0.01) { dup = true; break; }
|
|
}
|
|
if (!dup) unique.push(points[i]);
|
|
}
|
|
if (unique.length < 3) return unique;
|
|
|
|
// Centroid
|
|
const c = new THREE.Vector3();
|
|
unique.forEach(p => c.add(p));
|
|
c.divideScalar(unique.length);
|
|
|
|
// Build local 2D basis on the plane
|
|
const u = new THREE.Vector3().subVectors(unique[0], c).normalize();
|
|
const v = new THREE.Vector3().crossVectors(normal, u).normalize();
|
|
|
|
// Project and sort by angle
|
|
unique.sort((a, b) => {
|
|
const da = new THREE.Vector3().subVectors(a, c);
|
|
const db = new THREE.Vector3().subVectors(b, c);
|
|
return Math.atan2(da.dot(v), da.dot(u)) - Math.atan2(db.dot(v), db.dot(u));
|
|
});
|
|
return unique;
|
|
}
|
|
|
|
_drawSectionInfo(pts) {
|
|
if (pts.length < 3) return;
|
|
const area = this._polygonArea(pts);
|
|
let perimeter = 0;
|
|
for (let i = 0; i < pts.length; i++) {
|
|
perimeter += pts[i].distanceTo(pts[(i + 1) % pts.length]);
|
|
}
|
|
// Label at centroid
|
|
const c = new THREE.Vector3();
|
|
pts.forEach(p => c.add(p));
|
|
c.divideScalar(pts.length);
|
|
const label = this._makeTextSprite(`S=${area.toFixed(1)} P=${perimeter.toFixed(1)}`, '#06D6E0', 36);
|
|
label.position.copy(c).add(new THREE.Vector3(0, 0.4, 0));
|
|
label.scale.set(1.6, 0.5, 1);
|
|
this._sectionGroup.add(label);
|
|
|
|
// Vertex markers on section polygon
|
|
for (const p of pts) {
|
|
const sGeo = new THREE.SphereGeometry(0.06, 8, 8);
|
|
const sMat = new THREE.MeshBasicMaterial({ color: 0x06D6E0 });
|
|
const sm = new THREE.Mesh(sGeo, sMat);
|
|
sm.position.copy(p);
|
|
this._sectionGroup.add(sm);
|
|
}
|
|
}
|
|
|
|
_drawSectionPolygon(pts, y) {
|
|
if (pts.length < 3) return;
|
|
// Fill polygon
|
|
const shape = new THREE.Shape();
|
|
shape.moveTo(pts[0].x, pts[0].z);
|
|
for (let i = 1; i < pts.length; i++) shape.lineTo(pts[i].x, pts[i].z);
|
|
shape.closePath();
|
|
const geo = new THREE.ShapeGeometry(shape);
|
|
const mat = new THREE.MeshBasicMaterial({ color: 0x06D6E0, transparent: true, opacity: 0.35, side: THREE.DoubleSide });
|
|
const mesh = new THREE.Mesh(geo, mat);
|
|
mesh.rotation.x = -Math.PI/2;
|
|
mesh.position.y = y + 0.005;
|
|
this._sectionGroup.add(mesh);
|
|
|
|
// outline
|
|
const linePts = [...pts, pts[0]];
|
|
const lineGeo = new THREE.BufferGeometry().setFromPoints(linePts);
|
|
const lineMat = new THREE.LineBasicMaterial({ color: 0x06D6E0, linewidth: 2 });
|
|
this._sectionGroup.add(new THREE.Line(lineGeo, lineMat));
|
|
}
|
|
|
|
_drawSectionPolygon3D(pts) {
|
|
if (pts.length < 3) return;
|
|
const positions = [];
|
|
const indices = [];
|
|
pts.forEach(p => positions.push(p.x, p.y, p.z));
|
|
for (let i = 1; i < pts.length - 1; i++) indices.push(0, i, i+1);
|
|
const geo = new THREE.BufferGeometry();
|
|
geo.setAttribute('position', new THREE.Float32BufferAttribute(positions, 3));
|
|
geo.setIndex(indices);
|
|
geo.computeVertexNormals();
|
|
const mat = new THREE.MeshBasicMaterial({ color: 0x06D6E0, transparent: true, opacity: 0.35, side: THREE.DoubleSide });
|
|
this._sectionGroup.add(new THREE.Mesh(geo, mat));
|
|
|
|
const linePts = [...pts, pts[0]];
|
|
const lineGeo = new THREE.BufferGeometry().setFromPoints(linePts);
|
|
this._sectionGroup.add(new THREE.Line(lineGeo, new THREE.LineBasicMaterial({ color: 0x06D6E0 })));
|
|
}
|
|
|
|
_polygonArea(pts) {
|
|
// Shoelace in 3D — project onto best-fit plane
|
|
if (pts.length < 3) return 0;
|
|
let area = 0;
|
|
const n = pts.length;
|
|
const cx = pts.reduce((s,p)=>s+p.x,0)/n;
|
|
const cy = pts.reduce((s,p)=>s+p.y,0)/n;
|
|
const cz = pts.reduce((s,p)=>s+p.z,0)/n;
|
|
// Cross-product sum
|
|
const cross = new THREE.Vector3(0,0,0);
|
|
for (let i = 0; i < n; i++) {
|
|
const a = new THREE.Vector3().subVectors(pts[i], new THREE.Vector3(cx,cy,cz));
|
|
const b = new THREE.Vector3().subVectors(pts[(i+1)%n], new THREE.Vector3(cx,cy,cz));
|
|
cross.add(new THREE.Vector3().crossVectors(a, b));
|
|
}
|
|
return Math.round(cross.length() / 2 * 100) / 100;
|
|
}
|
|
|
|
/* ════════════════ INSCRIBED / CIRCUMSCRIBED ════════════════ */
|
|
|
|
_inscribedRadius() {
|
|
const p = this.params;
|
|
const PI = Math.PI;
|
|
switch (this.figureType) {
|
|
case 'cube': return p.a / 2;
|
|
case 'parallelepiped': return Math.min(p.a, p.b, p.c) / 2;
|
|
case 'tetrahedron': return p.a / (2 * Math.sqrt(6));
|
|
case 'sphere': return p.r;
|
|
case 'cylinder': return Math.min(p.r, p.h / 2);
|
|
case 'cone': {
|
|
// r_in = r * h / (r + sqrt(r²+h²))
|
|
const l = Math.sqrt(p.r ** 2 + p.h ** 2);
|
|
return p.r * p.h / (p.r + l);
|
|
}
|
|
case 'trunccone': {
|
|
// inscribed sphere radius = h * (R - r) / (2 * sqrt((R-r)² + h²))... approximate
|
|
// exact for truncated cone: r_in = h / (1 + sqrt(1 + ((R-r)/h)²)) * (not standard)
|
|
// simpler: r_in = h * min(R, r) / sqrt((R-r)² + h²) — approximate
|
|
// Actually: for truncated cone, inscribed sphere touches both bases and lateral surface
|
|
// r_in = h * (R + r - sqrt((R-r)² + h²)) / (2 * (R - r)) when R ≠ r
|
|
if (Math.abs(p.R - p.r) < 0.001) return Math.min(p.R, p.h / 2); // cylinder-like
|
|
const l = Math.sqrt((p.R - p.r) ** 2 + p.h ** 2);
|
|
return p.h * (p.R + p.r - l) / (2 * Math.abs(p.R - p.r));
|
|
}
|
|
case 'pyramid': {
|
|
// r_in = 3V / S_total
|
|
const { a, h, n } = p;
|
|
const sBase = n * a ** 2 / (4 * Math.tan(PI / n));
|
|
const apothem = a / (2 * Math.tan(PI / n));
|
|
const slantH = Math.sqrt(h ** 2 + apothem ** 2);
|
|
const sLat = n * a * slantH / 2;
|
|
const V = sBase * h / 3;
|
|
return 3 * V / (sBase + sLat);
|
|
}
|
|
case 'prism': {
|
|
const { a, h, n } = p;
|
|
const apothem = a / (2 * Math.tan(PI / n));
|
|
return Math.min(apothem, h / 2);
|
|
}
|
|
case 'octahedron': return p.a * Math.sqrt(6) / 6;
|
|
case 'icosahedron': { const phi = (1+Math.sqrt(5))/2; return p.a * phi**2 / (2*Math.sqrt(3)); }
|
|
case 'dodecahedron': return p.a / 2 * Math.sqrt((25 + 11*Math.sqrt(5)) / 10);
|
|
default: return null;
|
|
}
|
|
}
|
|
|
|
_circumscribedRadius() {
|
|
const p = this.params;
|
|
const PI = Math.PI;
|
|
switch (this.figureType) {
|
|
case 'cube': return p.a * Math.sqrt(3) / 2;
|
|
case 'parallelepiped': return Math.sqrt(p.a ** 2 + p.b ** 2 + p.c ** 2) / 2;
|
|
case 'tetrahedron': return p.a * Math.sqrt(6) / 4;
|
|
case 'sphere': return p.r;
|
|
case 'cylinder': return Math.sqrt(p.r ** 2 + (p.h / 2) ** 2);
|
|
case 'cone': {
|
|
// circumscribed sphere around cone: passes through apex and base circle
|
|
// R = (r² + h²) / (2h)
|
|
return (p.r ** 2 + p.h ** 2) / (2 * p.h);
|
|
}
|
|
case 'trunccone': {
|
|
// R = sqrt(R² + (h/2 + (R²-r²)/(2h))²) — approximate
|
|
const hc = p.h / 2 + (p.R ** 2 - p.r ** 2) / (2 * p.h);
|
|
return Math.sqrt(p.R ** 2 + hc ** 2);
|
|
}
|
|
case 'pyramid': {
|
|
// R = sqrt(R_base² + h²_offset) where R_base = circumradius of base polygon
|
|
// center of circumscribed sphere at height y: y = h - R, R² = R_base² + (h - y)²
|
|
// solving: R = (R_base² + h²) / (2h)
|
|
const { a, h, n } = p;
|
|
const Rb = a / (2 * Math.sin(PI / n));
|
|
return (Rb ** 2 + h ** 2) / (2 * h);
|
|
}
|
|
case 'prism': {
|
|
const { a, h, n } = p;
|
|
const Rb = a / (2 * Math.sin(PI / n));
|
|
return Math.sqrt(Rb ** 2 + (h / 2) ** 2);
|
|
}
|
|
case 'octahedron': return p.a * Math.SQRT2 / 2;
|
|
case 'icosahedron': return p.a * Math.sqrt(10 + 2*Math.sqrt(5)) / 4;
|
|
case 'dodecahedron': { const phi = (1+Math.sqrt(5))/2; return p.a * Math.sqrt(3) * phi / 2; }
|
|
default: return null;
|
|
}
|
|
}
|
|
|
|
_inscribedCenter() {
|
|
const p = this.params;
|
|
const PI = Math.PI;
|
|
switch (this.figureType) {
|
|
case 'cube': return p.a / 2;
|
|
case 'parallelepiped': return p.c / 2;
|
|
case 'tetrahedron': return p.a / (2 * Math.sqrt(6)); // r_in from base
|
|
case 'sphere': return p.r;
|
|
case 'cylinder': return p.h / 2;
|
|
case 'cone': {
|
|
const l = Math.sqrt(p.r ** 2 + p.h ** 2);
|
|
return p.r * p.h / (p.r + l); // r_in = height of center
|
|
}
|
|
case 'trunccone': return p.h / 2;
|
|
case 'pyramid': {
|
|
// inscribed sphere center at height r_in from base
|
|
const { a, h, n } = p;
|
|
const sBase = n * a ** 2 / (4 * Math.tan(PI / n));
|
|
const apothem = a / (2 * Math.tan(PI / n));
|
|
const slantH = Math.sqrt(h ** 2 + apothem ** 2);
|
|
const sLat = n * a * slantH / 2;
|
|
const V = sBase * h / 3;
|
|
return 3 * V / (sBase + sLat); // r_in
|
|
}
|
|
case 'prism': return p.h / 2;
|
|
case 'octahedron': return p.a * Math.SQRT2 / 2; // center of octahedron
|
|
case 'icosahedron': return p.a * Math.sqrt(10 + 2*Math.sqrt(5)) / 4; // circumR = half-height approx
|
|
case 'dodecahedron': { const phi = (1+Math.sqrt(5))/2; return p.a * Math.sqrt(3) * phi / 2; }
|
|
default: return this._figureHeight() / 2;
|
|
}
|
|
}
|
|
|
|
_circumscribedCenter() {
|
|
const p = this.params;
|
|
const PI = Math.PI;
|
|
switch (this.figureType) {
|
|
case 'cube': return p.a / 2;
|
|
case 'parallelepiped': return p.c / 2;
|
|
case 'tetrahedron': {
|
|
// center at h - R from base = h - a*sqrt(6)/4
|
|
const h = p.a * Math.sqrt(2 / 3);
|
|
return h - p.a * Math.sqrt(6) / 4;
|
|
}
|
|
case 'sphere': return p.r;
|
|
case 'cylinder': return p.h / 2;
|
|
case 'cone': {
|
|
// center at y = R_circ from apex? No. center at y = h - R + h_offset
|
|
// R = (r²+h²)/(2h), center at y = h - R = h - (r²+h²)/(2h) = (h²-r²)/(2h)
|
|
// but if r > h, center goes below base — clamp
|
|
const R = (p.r ** 2 + p.h ** 2) / (2 * p.h);
|
|
return p.h - R;
|
|
}
|
|
case 'trunccone': {
|
|
const hc = p.h / 2 + (p.R ** 2 - p.r ** 2) / (2 * p.h);
|
|
return hc; // height of center from base
|
|
}
|
|
case 'pyramid': {
|
|
const { a, h, n } = p;
|
|
const Rb = a / (2 * Math.sin(PI / n));
|
|
const R = (Rb ** 2 + h ** 2) / (2 * h);
|
|
return h - R; // from base
|
|
}
|
|
case 'prism': return p.h / 2;
|
|
case 'octahedron': return p.a * Math.SQRT2 / 2;
|
|
case 'icosahedron': return p.a * Math.sqrt(10 + 2*Math.sqrt(5)) / 4;
|
|
case 'dodecahedron': { const phi = (1+Math.sqrt(5))/2; return p.a * Math.sqrt(3) * phi / 2; }
|
|
default: return this._figureHeight() / 2;
|
|
}
|
|
}
|
|
|
|
_updateSpheres() {
|
|
this._clearGroup(this._sphereGroup);
|
|
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 typeNames = { 3: 'треугольник', 4: 'четырёхугольник', 5: 'пятиугольник', 6: 'шестиугольник' };
|
|
const typeName = typeNames[n] || `${n}-угольник`;
|
|
|
|
this._section3PData = { normal, D, polygon, area, typeName, P1, P2, P3 };
|
|
}
|
|
|
|
_drawSection3P() {
|
|
this._clearGroup(this._section3PGroup);
|
|
const picks = this._section3PPicks;
|
|
const data = this._section3PData;
|
|
|
|
// Draw picked points as spheres (yellow accent)
|
|
const PICK_COLOR = 0xFFD166;
|
|
const PLANE_COLOR = 0xEF476F;
|
|
const SECT_COLOR = 0x7BF5A4;
|
|
|
|
picks.forEach((p, i) => {
|
|
const sGeo = new THREE.SphereGeometry(0.13, 10, 10);
|
|
const sMat = new THREE.MeshBasicMaterial({ color: PICK_COLOR });
|
|
const s = new THREE.Mesh(sGeo, sMat);
|
|
s.position.copy(p);
|
|
this._section3PGroup.add(s);
|
|
|
|
// Number label
|
|
const lbl = this._makeTextSprite(String(i + 1), '#FFD166', 42);
|
|
lbl.position.copy(p).add(new THREE.Vector3(0.25, 0.25, 0));
|
|
lbl.scale.set(0.7, 0.28, 1);
|
|
this._section3PGroup.add(lbl);
|
|
});
|
|
|
|
// Draw line from P1 to P2 after 2nd pick
|
|
if (picks.length >= 2) {
|
|
const lg1 = new THREE.BufferGeometry().setFromPoints([picks[0], picks[1]]);
|
|
this._section3PGroup.add(new THREE.Line(lg1, new THREE.LineBasicMaterial({ color: PICK_COLOR, opacity: 0.7, transparent: true })));
|
|
}
|
|
if (picks.length >= 3) {
|
|
const lg2 = new THREE.BufferGeometry().setFromPoints([picks[1], picks[2]]);
|
|
this._section3PGroup.add(new THREE.Line(lg2, new THREE.LineBasicMaterial({ color: PICK_COLOR, opacity: 0.7, transparent: true })));
|
|
const lg3 = new THREE.BufferGeometry().setFromPoints([picks[2], picks[0]]);
|
|
this._section3PGroup.add(new THREE.Line(lg3, new THREE.LineBasicMaterial({ color: PICK_COLOR, opacity: 0.5, transparent: true })));
|
|
}
|
|
|
|
if (!data || picks.length < 3) return;
|
|
|
|
// Semi-transparent plane quad (large enough to show context)
|
|
const { normal, D, polygon } = data;
|
|
// Build a visible plane chip — use bounding box of polygon centroid + spread
|
|
const c = new THREE.Vector3();
|
|
polygon.forEach(p => c.add(p));
|
|
c.divideScalar(polygon.length);
|
|
// Local basis on plane
|
|
const u = new THREE.Vector3().subVectors(polygon[0], c).normalize();
|
|
const v = new THREE.Vector3().crossVectors(normal, u).normalize();
|
|
const spread = Math.max(...polygon.map(p => c.distanceTo(p))) * 1.5;
|
|
const planeVerts = [
|
|
c.clone().addScaledVector(u, -spread).addScaledVector(v, -spread),
|
|
c.clone().addScaledVector(u, spread).addScaledVector(v, -spread),
|
|
c.clone().addScaledVector(u, spread).addScaledVector(v, spread),
|
|
c.clone().addScaledVector(u, -spread).addScaledVector(v, spread),
|
|
];
|
|
const planePositions = [];
|
|
[[0,1,2],[0,2,3]].forEach(tri => tri.forEach(i => {
|
|
const pv = planeVerts[i];
|
|
planePositions.push(pv.x, pv.y, pv.z);
|
|
}));
|
|
const planeGeo = new THREE.BufferGeometry();
|
|
planeGeo.setAttribute('position', new THREE.Float32BufferAttribute(planePositions, 3));
|
|
const planeMat = new THREE.MeshBasicMaterial({ color: PLANE_COLOR, transparent: true, opacity: 0.08, side: THREE.DoubleSide });
|
|
this._section3PGroup.add(new THREE.Mesh(planeGeo, planeMat));
|
|
|
|
// Cross-section polygon fill
|
|
const sectPositions = [];
|
|
const sectIndices = [];
|
|
polygon.forEach(p => sectPositions.push(p.x, p.y, p.z));
|
|
for (let i = 1; i < polygon.length - 1; i++) sectIndices.push(0, i, i + 1);
|
|
const sectGeo = new THREE.BufferGeometry();
|
|
sectGeo.setAttribute('position', new THREE.Float32BufferAttribute(sectPositions, 3));
|
|
sectGeo.setIndex(sectIndices);
|
|
sectGeo.computeVertexNormals();
|
|
const sectMat = new THREE.MeshBasicMaterial({ color: SECT_COLOR, transparent: true, opacity: 0.45, side: THREE.DoubleSide });
|
|
this._section3PGroup.add(new THREE.Mesh(sectGeo, sectMat));
|
|
|
|
// Polygon outline (slightly offset along normal for visibility)
|
|
const outlinePts = [...polygon, polygon[0]].map(p =>
|
|
p.clone().addScaledVector(normal, 0.012)
|
|
);
|
|
const outlineGeo = new THREE.BufferGeometry().setFromPoints(outlinePts);
|
|
const outlineMat = new THREE.LineBasicMaterial({ color: SECT_COLOR, linewidth: 2 });
|
|
this._section3PGroup.add(new THREE.Line(outlineGeo, outlineMat));
|
|
|
|
// Vertex markers on section polygon
|
|
polygon.forEach(p => {
|
|
const sg = new THREE.SphereGeometry(0.07, 8, 8);
|
|
const sm = new THREE.MeshBasicMaterial({ color: SECT_COLOR });
|
|
const s = new THREE.Mesh(sg, sm);
|
|
s.position.copy(p);
|
|
this._section3PGroup.add(s);
|
|
});
|
|
|
|
// Step-by-step highlight (если включён пошаговый режим)
|
|
if (this._section3PStepBy && this._section3PStep > 0) {
|
|
this._drawSection3PStep(data);
|
|
}
|
|
}
|
|
|
|
_drawSection3PStep(data) {
|
|
// Extra step-by-step highlight objects added to _section3PGroup
|
|
const step = this._section3PStep;
|
|
const picks = this._section3PPicks;
|
|
const HILITE = 0xFFFFA0;
|
|
|
|
const flash = (pos) => {
|
|
const sg = new THREE.SphereGeometry(0.22, 10, 10);
|
|
const sm = new THREE.MeshBasicMaterial({ color: HILITE, transparent: true, opacity: 0.7 });
|
|
const s = new THREE.Mesh(sg, sm);
|
|
s.position.copy(pos);
|
|
this._section3PGroup.add(s);
|
|
};
|
|
const flashLine = (a, b) => {
|
|
const lg = new THREE.BufferGeometry().setFromPoints([a, b]);
|
|
this._section3PGroup.add(new THREE.Line(lg, new THREE.LineBasicMaterial({ color: HILITE, linewidth: 3 })));
|
|
};
|
|
|
|
if (step >= 1) flash(picks[0]);
|
|
if (step >= 2) { flash(picks[1]); flashLine(picks[0], picks[1]); }
|
|
if (step >= 3) { flash(picks[2]); flashLine(picks[1], picks[2]); flashLine(picks[2], picks[0]); }
|
|
// steps 4-6 handled by full plane + section already drawn above
|
|
}
|
|
|
|
/* ════════════════ MEASUREMENT MODE ════════════════ */
|
|
|
|
_onMeasureClick(e) {
|
|
if (!this._measureMode) return;
|
|
const { mx, my } = this._screenCoords(e);
|
|
|
|
// Find closest vertex OR custom point in screen space
|
|
let bestDist = 0.08; // threshold in NDC
|
|
let bestPick = null;
|
|
|
|
// Check vertices
|
|
for (const v of this._vertices) {
|
|
const projected = v.pos.clone().project(this.camera);
|
|
const d = Math.sqrt((projected.x - mx) ** 2 + (projected.y - my) ** 2);
|
|
if (d < bestDist) { bestDist = d; bestPick = { pos: v.pos, label: v.label }; }
|
|
}
|
|
|
|
// Check custom points
|
|
for (const cp of this._customPoints) {
|
|
const projected = cp.pos.clone().project(this.camera);
|
|
const d = Math.sqrt((projected.x - mx) ** 2 + (projected.y - my) ** 2);
|
|
if (d < bestDist) { bestDist = d; bestPick = { pos: cp.pos, label: cp.label }; }
|
|
}
|
|
|
|
if (!bestPick) return;
|
|
|
|
this._measurePicks.push(bestPick);
|
|
|
|
// Highlight first pick in temporary group
|
|
const sGeo = new THREE.SphereGeometry(0.14, 12, 12);
|
|
const sMat = new THREE.MeshBasicMaterial({ color: 0xFFD166 });
|
|
const s = new THREE.Mesh(sGeo, sMat);
|
|
s.position.copy(bestPick.pos);
|
|
this._measurePickGroup.add(s);
|
|
|
|
if (this._measurePicks.length === 2) {
|
|
const [a, b] = this._measurePicks;
|
|
const dist = a.pos.distanceTo(b.pos);
|
|
this._measurements.push({
|
|
from: a.label, to: b.label,
|
|
dist: Math.round(dist * 100) / 100,
|
|
posA: a.pos.clone(), posB: b.pos.clone(),
|
|
});
|
|
this._measurePicks = [];
|
|
this._clearGroup(this._measurePickGroup);
|
|
this._rebuildMeasureGroup();
|
|
}
|
|
}
|
|
|
|
/* ════════════════ POINT MODE — place points on edges ════════════════ */
|
|
|
|
_screenCoords(e) {
|
|
const rect = this.renderer.domElement.getBoundingClientRect();
|
|
return {
|
|
mx: ((e.clientX - rect.left) / rect.width) * 2 - 1,
|
|
my: -((e.clientY - rect.top) / rect.height) * 2 + 1,
|
|
};
|
|
}
|
|
|
|
_onPointClick(e) {
|
|
const { mx, my } = this._screenCoords(e);
|
|
|
|
// Find the nearest edge in screen space
|
|
let bestDist = 0.08; // threshold in NDC
|
|
let bestEdge = -1;
|
|
let bestT = 0;
|
|
let bestPos = null;
|
|
|
|
for (let ei = 0; ei < this._edges.length; ei++) {
|
|
const edge = this._edges[ei];
|
|
const p1 = edge.from.clone().project(this.camera);
|
|
const p2 = edge.to.clone().project(this.camera);
|
|
|
|
// Point-to-segment distance in 2D
|
|
const dx = p2.x - p1.x, dy = p2.y - p1.y;
|
|
const lenSq = dx * dx + dy * dy;
|
|
if (lenSq < 1e-9) continue;
|
|
let t = ((mx - p1.x) * dx + (my - p1.y) * dy) / lenSq;
|
|
t = Math.max(0.02, Math.min(0.98, t)); // clamp away from endpoints
|
|
const px = p1.x + t * dx, py = p1.y + t * dy;
|
|
const dist = Math.sqrt((mx - px) ** 2 + (my - py) ** 2);
|
|
|
|
if (dist < bestDist) {
|
|
bestDist = dist;
|
|
bestEdge = ei;
|
|
bestT = t;
|
|
bestPos = new THREE.Vector3().lerpVectors(edge.from, edge.to, t);
|
|
}
|
|
}
|
|
|
|
// Also check: click near a vertex <svg class="ic" viewBox="0 0 24 24"><line x1="5" y1="12" x2="19" y2="12"/><polyline points="12 5 19 12 12 19"/></svg> snap to vertex
|
|
for (const v of this._vertices) {
|
|
const proj = v.pos.clone().project(this.camera);
|
|
const dist = Math.sqrt((mx - proj.x) ** 2 + (my - proj.y) ** 2);
|
|
if (dist < 0.06) {
|
|
bestPos = v.pos.clone();
|
|
bestEdge = -2; // special: vertex
|
|
bestT = 0;
|
|
bestDist = dist;
|
|
}
|
|
}
|
|
|
|
if (!bestPos) return;
|
|
|
|
const label = String(this._nextPointId++);
|
|
this._customPoints.push({
|
|
pos: bestPos,
|
|
edgeIdx: bestEdge,
|
|
t: bestT,
|
|
label,
|
|
});
|
|
|
|
this._rebuildPointVisuals();
|
|
if (window.LabFX) LabFX.sound.play('tick', { pitch: 1.5, volume: 0.3 });
|
|
|
|
// If custom section mode and ≥3 points, update section
|
|
if (this.showSection && this.sectionType === 'custom') this._updateSection();
|
|
this._notify();
|
|
}
|
|
|
|
_onConnectClick(e) {
|
|
const { mx, my } = this._screenCoords(e);
|
|
|
|
// Find nearest custom point OR vertex
|
|
let bestDist = 0.08;
|
|
let bestIdx = -1;
|
|
|
|
// Check custom points
|
|
for (let i = 0; i < this._customPoints.length; i++) {
|
|
const proj = this._customPoints[i].pos.clone().project(this.camera);
|
|
const dist = Math.sqrt((mx - proj.x) ** 2 + (my - proj.y) ** 2);
|
|
if (dist < bestDist) { bestDist = dist; bestIdx = i; }
|
|
}
|
|
|
|
// Also check vertices (mapped as negative indices: -1 <svg class="ic" viewBox="0 0 24 24"><line x1="5" y1="12" x2="19" y2="12"/><polyline points="12 5 19 12 12 19"/></svg> vertex 0, -2 <svg class="ic" viewBox="0 0 24 24"><line x1="5" y1="12" x2="19" y2="12"/><polyline points="12 5 19 12 12 19"/></svg> vertex 1, etc.)
|
|
for (let i = 0; i < this._vertices.length; i++) {
|
|
const proj = this._vertices[i].pos.clone().project(this.camera);
|
|
const dist = Math.sqrt((mx - proj.x) ** 2 + (my - proj.y) ** 2);
|
|
if (dist < bestDist) { bestDist = dist; bestIdx = -(i + 100); } // encode vertex
|
|
}
|
|
|
|
if (bestIdx === -1 && bestIdx !== -(99 + this._vertices.length)) return; // nothing found
|
|
// Actually check: any valid pick
|
|
if (bestDist >= 0.08) return;
|
|
|
|
this._connectPicks.push(bestIdx);
|
|
|
|
// Highlight pick
|
|
const pos = bestIdx >= 0 ? this._customPoints[bestIdx].pos : this._vertices[-(bestIdx + 100)].pos;
|
|
const sGeo = new THREE.SphereGeometry(0.12, 10, 10);
|
|
const sMat = new THREE.MeshBasicMaterial({ color: 0xF59E0B });
|
|
const s = new THREE.Mesh(sGeo, sMat);
|
|
s.position.copy(pos);
|
|
this._pointGroup.add(s);
|
|
|
|
if (this._connectPicks.length === 2) {
|
|
const [idxA, idxB] = this._connectPicks;
|
|
if (idxA !== idxB) {
|
|
this._connections.push({ from: idxA, to: idxB });
|
|
}
|
|
this._connectPicks = [];
|
|
this._rebuildPointVisuals();
|
|
this._notify();
|
|
}
|
|
}
|
|
|
|
_getPointPos(idx) {
|
|
if (idx >= 0) return this._customPoints[idx]?.pos;
|
|
return this._vertices[-(idx + 100)]?.pos;
|
|
}
|
|
|
|
_getPointLabel(idx) {
|
|
if (idx >= 0) return this._customPoints[idx]?.label || '?';
|
|
return this._vertices[-(idx + 100)]?.label || '?';
|
|
}
|
|
|
|
_rebuildPointVisuals() {
|
|
this._clearGroup(this._pointGroup);
|
|
|
|
// Draw custom points
|
|
for (const pt of this._customPoints) {
|
|
// Sphere marker
|
|
const sGeo = new THREE.SphereGeometry(0.1, 10, 10);
|
|
const sMat = new THREE.MeshBasicMaterial({ color: 0xFFD166 });
|
|
const s = new THREE.Mesh(sGeo, sMat);
|
|
s.position.copy(pt.pos);
|
|
this._pointGroup.add(s);
|
|
|
|
// Label
|
|
const label = this._makeTextSprite(pt.label, '#FFD166', 40);
|
|
label.position.copy(pt.pos).add(new THREE.Vector3(0.2, 0.2, 0));
|
|
label.scale.set(0.6, 0.3, 1);
|
|
this._pointGroup.add(label);
|
|
}
|
|
|
|
// Draw connections
|
|
for (const conn of this._connections) {
|
|
const posA = this._getPointPos(conn.from);
|
|
const posB = this._getPointPos(conn.to);
|
|
if (!posA || !posB) continue;
|
|
|
|
const lineGeo = new THREE.BufferGeometry().setFromPoints([posA, posB]);
|
|
const lineMat = new THREE.LineDashedMaterial({
|
|
color: 0xF59E0B, dashSize: 0.12, gapSize: 0.06,
|
|
transparent: true, opacity: 0.9,
|
|
});
|
|
const line = new THREE.Line(lineGeo, lineMat);
|
|
line.computeLineDistances();
|
|
this._pointGroup.add(line);
|
|
|
|
// Distance label
|
|
const dist = posA.distanceTo(posB);
|
|
const mid = new THREE.Vector3().addVectors(posA, posB).multiplyScalar(0.5);
|
|
const lbl = this._makeTextSprite(dist.toFixed(2), '#F59E0B', 36);
|
|
lbl.position.copy(mid).add(new THREE.Vector3(0, 0.25, 0));
|
|
lbl.scale.set(0.8, 0.4, 1);
|
|
this._pointGroup.add(lbl);
|
|
}
|
|
}
|
|
|
|
/* ════════════════ HEIGHT LINE ════════════════ */
|
|
|
|
_drawHeightLine() {
|
|
if (!this.showHeight) return;
|
|
const p = this.params;
|
|
const fh = this._figureHeight();
|
|
let from, to, label;
|
|
|
|
switch (this.figureType) {
|
|
case 'cube':
|
|
case 'parallelepiped':
|
|
case 'prism':
|
|
case 'cylinder': {
|
|
// vertical height: center of bottom base <svg class="ic" viewBox="0 0 24 24"><line x1="5" y1="12" x2="19" y2="12"/><polyline points="12 5 19 12 12 19"/></svg> center of top base
|
|
from = new THREE.Vector3(0, 0, 0);
|
|
to = new THREE.Vector3(0, fh, 0);
|
|
label = `h = ${fh.toFixed(2)}`;
|
|
break;
|
|
}
|
|
case 'pyramid': {
|
|
// apex to base center
|
|
from = new THREE.Vector3(0, 0, 0);
|
|
to = new THREE.Vector3(0, p.h, 0);
|
|
label = `h = ${p.h.toFixed(2)}`;
|
|
break;
|
|
}
|
|
case 'tetrahedron': {
|
|
const h = p.a * Math.sqrt(2 / 3);
|
|
from = new THREE.Vector3(0, 0, 0);
|
|
to = new THREE.Vector3(0, h, 0);
|
|
label = `h = ${h.toFixed(2)}`;
|
|
break;
|
|
}
|
|
case 'cone': {
|
|
from = new THREE.Vector3(0, 0, 0);
|
|
to = new THREE.Vector3(0, p.h, 0);
|
|
label = `h = ${p.h.toFixed(2)}`;
|
|
break;
|
|
}
|
|
case 'trunccone': {
|
|
from = new THREE.Vector3(0, 0, 0);
|
|
to = new THREE.Vector3(0, p.h, 0);
|
|
label = `h = ${p.h.toFixed(2)}`;
|
|
break;
|
|
}
|
|
case 'sphere': {
|
|
from = new THREE.Vector3(0, 0, 0);
|
|
to = new THREE.Vector3(0, 2 * p.r, 0);
|
|
label = `d = ${(2 * p.r).toFixed(2)}`;
|
|
break;
|
|
}
|
|
default: return;
|
|
}
|
|
|
|
// dashed line
|
|
const lineGeo = new THREE.BufferGeometry().setFromPoints([from, to]);
|
|
const lineMat = new THREE.LineDashedMaterial({
|
|
color: 0xF9A8D4, dashSize: 0.15, gapSize: 0.08,
|
|
transparent: true, opacity: 0.85,
|
|
});
|
|
const line = new THREE.Line(lineGeo, lineMat);
|
|
line.computeLineDistances();
|
|
this._figGroup.add(line);
|
|
|
|
// small dots at endpoints
|
|
for (const pt of [from, to]) {
|
|
const sGeo = new THREE.SphereGeometry(0.06, 8, 8);
|
|
const sMat = new THREE.MeshBasicMaterial({ color: 0xF9A8D4 });
|
|
const s = new THREE.Mesh(sGeo, sMat);
|
|
s.position.copy(pt);
|
|
this._figGroup.add(s);
|
|
}
|
|
|
|
// label at midpoint
|
|
const mid = new THREE.Vector3().addVectors(from, to).multiplyScalar(0.5);
|
|
const lbl = this._makeTextSprite(label, '#F9A8D4', 36);
|
|
lbl.position.copy(mid).add(new THREE.Vector3(0.4, 0, 0));
|
|
lbl.scale.set(1.2, 0.4, 1);
|
|
this._labelGroup.add(lbl);
|
|
|
|
// right-angle marker at base (for pyramid/cone/tetrahedron)
|
|
if (['pyramid', 'tetrahedron', 'cone'].includes(this.figureType)) {
|
|
this._drawRightAngleMarker(from, new THREE.Vector3(0, 1, 0), new THREE.Vector3(1, 0, 0), 0.3);
|
|
}
|
|
}
|
|
|
|
_drawRightAngleMarker(origin, dir1, dir2, size) {
|
|
const p1 = origin.clone().add(dir1.clone().normalize().multiplyScalar(size));
|
|
const p2 = origin.clone().add(dir2.clone().normalize().multiplyScalar(size));
|
|
const p3 = p1.clone().add(dir2.clone().normalize().multiplyScalar(size));
|
|
const pts = [p1, p3, p2];
|
|
const geo = new THREE.BufferGeometry().setFromPoints(pts);
|
|
const mat = new THREE.LineBasicMaterial({ color: 0xF9A8D4, transparent: true, opacity: 0.6 });
|
|
this._figGroup.add(new THREE.Line(geo, mat));
|
|
}
|
|
|
|
/* ════════════════ APOTHEM LINE ════════════════ */
|
|
|
|
_drawApothemLine() {
|
|
if (!this.showApothem) return;
|
|
const p = this.params;
|
|
const PI = Math.PI;
|
|
|
|
if (this.figureType === 'pyramid') {
|
|
const { a, h, n } = p;
|
|
const apothem = a / (2 * Math.tan(PI / n)); // base apothem
|
|
const slantH = Math.sqrt(h ** 2 + apothem ** 2); // slant apothem
|
|
|
|
// Base apothem: center of base to midpoint of first base edge
|
|
const midEdge = this._getBaseMidpoint(n, a, 0);
|
|
this._drawDashedSegment(
|
|
new THREE.Vector3(0, 0, 0), midEdge,
|
|
`a_осн = ${apothem.toFixed(2)}`, '#7BF5A4'
|
|
);
|
|
|
|
// Slant apothem: apex to midpoint of first base edge
|
|
this._drawDashedSegment(
|
|
new THREE.Vector3(0, h, 0), midEdge,
|
|
`a_бок = ${slantH.toFixed(2)}`, '#60a5fa'
|
|
);
|
|
|
|
// Right angle at midEdge (between base apothem and base edge direction)
|
|
const edgeDir = this._getBaseEdgeDir(n, a, 0);
|
|
const toCenter = new THREE.Vector3().subVectors(new THREE.Vector3(0, 0, 0), midEdge).normalize();
|
|
this._drawRightAngleMarker(midEdge, toCenter, edgeDir, 0.25);
|
|
|
|
} else if (this.figureType === 'prism') {
|
|
const { a, h, n } = p;
|
|
const apothem = a / (2 * Math.tan(PI / n));
|
|
|
|
// Base apothem
|
|
const midEdge = this._getBaseMidpoint(n, a, 0);
|
|
this._drawDashedSegment(
|
|
new THREE.Vector3(0, 0, 0), midEdge,
|
|
`a = ${apothem.toFixed(2)}`, '#7BF5A4'
|
|
);
|
|
|
|
// Right angle marker
|
|
const edgeDir = this._getBaseEdgeDir(n, a, 0);
|
|
const toCenter = new THREE.Vector3().subVectors(new THREE.Vector3(0, 0, 0), midEdge).normalize();
|
|
this._drawRightAngleMarker(midEdge, toCenter, edgeDir, 0.25);
|
|
|
|
} else if (this.figureType === 'cone') {
|
|
// Slant height (образующая)
|
|
const l = Math.sqrt(p.r ** 2 + p.h ** 2);
|
|
this._drawDashedSegment(
|
|
new THREE.Vector3(0, p.h, 0),
|
|
new THREE.Vector3(p.r, 0, 0),
|
|
`l = ${l.toFixed(2)}`, '#60a5fa'
|
|
);
|
|
|
|
} else if (this.figureType === 'trunccone') {
|
|
const l = Math.sqrt((p.R - p.r) ** 2 + p.h ** 2);
|
|
this._drawDashedSegment(
|
|
new THREE.Vector3(p.r, p.h, 0),
|
|
new THREE.Vector3(p.R, 0, 0),
|
|
`l = ${l.toFixed(2)}`, '#60a5fa'
|
|
);
|
|
}
|
|
}
|
|
|
|
_getBaseMidpoint(n, a, edgeIndex) {
|
|
const r = a / (2 * Math.sin(Math.PI / n));
|
|
const a1 = (edgeIndex / n) * Math.PI * 2 - Math.PI / 2;
|
|
const a2 = ((edgeIndex + 1) / n) * Math.PI * 2 - Math.PI / 2;
|
|
return new THREE.Vector3(
|
|
(r * Math.cos(a1) + r * Math.cos(a2)) / 2,
|
|
0,
|
|
(r * Math.sin(a1) + r * Math.sin(a2)) / 2
|
|
);
|
|
}
|
|
|
|
_getBaseEdgeDir(n, a, edgeIndex) {
|
|
const r = a / (2 * Math.sin(Math.PI / n));
|
|
const a1 = (edgeIndex / n) * Math.PI * 2 - Math.PI / 2;
|
|
const a2 = ((edgeIndex + 1) / n) * Math.PI * 2 - Math.PI / 2;
|
|
const p1 = new THREE.Vector3(r * Math.cos(a1), 0, r * Math.sin(a1));
|
|
const p2 = new THREE.Vector3(r * Math.cos(a2), 0, r * Math.sin(a2));
|
|
return new THREE.Vector3().subVectors(p2, p1).normalize();
|
|
}
|
|
|
|
_drawDashedSegment(from, to, label, color) {
|
|
const lineGeo = new THREE.BufferGeometry().setFromPoints([from, to]);
|
|
const lineMat = new THREE.LineDashedMaterial({
|
|
color: new THREE.Color(color), dashSize: 0.12, gapSize: 0.06,
|
|
transparent: true, opacity: 0.85,
|
|
});
|
|
const line = new THREE.Line(lineGeo, lineMat);
|
|
line.computeLineDistances();
|
|
this._figGroup.add(line);
|
|
|
|
// dots
|
|
for (const pt of [from, to]) {
|
|
const sGeo = new THREE.SphereGeometry(0.05, 8, 8);
|
|
const sMat = new THREE.MeshBasicMaterial({ color: new THREE.Color(color) });
|
|
const s = new THREE.Mesh(sGeo, sMat);
|
|
s.position.copy(pt);
|
|
this._figGroup.add(s);
|
|
}
|
|
|
|
// label
|
|
const mid = new THREE.Vector3().addVectors(from, to).multiplyScalar(0.5);
|
|
const lbl = this._makeTextSprite(label, color, 34);
|
|
lbl.position.copy(mid).add(new THREE.Vector3(0.3, 0.2, 0));
|
|
lbl.scale.set(1.2, 0.4, 1);
|
|
this._labelGroup.add(lbl);
|
|
}
|
|
|
|
/* ════════════════ ANGLE MEASUREMENT MODES ════════════════ */
|
|
|
|
_pickNearestPoint(e) {
|
|
const { mx, my } = this._screenCoords(e);
|
|
let bestDist = 0.08;
|
|
let bestPick = null;
|
|
|
|
for (const v of this._vertices) {
|
|
const projected = v.pos.clone().project(this.camera);
|
|
const d = Math.sqrt((projected.x - mx) ** 2 + (projected.y - my) ** 2);
|
|
if (d < bestDist) { bestDist = d; bestPick = { pos: v.pos.clone(), label: v.label }; }
|
|
}
|
|
for (const cp of this._customPoints) {
|
|
const projected = cp.pos.clone().project(this.camera);
|
|
const d = Math.sqrt((projected.x - mx) ** 2 + (projected.y - my) ** 2);
|
|
if (d < bestDist) { bestDist = d; bestPick = { pos: cp.pos.clone(), label: cp.label }; }
|
|
}
|
|
return bestPick;
|
|
}
|
|
|
|
_pickNearestFace(e) {
|
|
// Pick the face whose projected centroid is closest to click
|
|
const { mx, my } = this._screenCoords(e);
|
|
let bestDist = 0.15;
|
|
let bestFace = null;
|
|
let bestIdx = -1;
|
|
|
|
for (let fi = 0; fi < this._faces.length; fi++) {
|
|
const face = this._faces[fi];
|
|
const c = new THREE.Vector3();
|
|
face.forEach(v => c.add(v));
|
|
c.divideScalar(face.length);
|
|
const proj = c.clone().project(this.camera);
|
|
const d = Math.sqrt((proj.x - mx) ** 2 + (proj.y - my) ** 2);
|
|
if (d < bestDist) { bestDist = d; bestFace = face; bestIdx = fi; }
|
|
}
|
|
return bestFace;
|
|
}
|
|
|
|
_pickNearestEdge(e) {
|
|
const { mx, my } = this._screenCoords(e);
|
|
let bestDist = 0.06;
|
|
let bestEdge = null;
|
|
|
|
for (const edge of this._edges) {
|
|
const p1 = edge.from.clone().project(this.camera);
|
|
const p2 = edge.to.clone().project(this.camera);
|
|
const mid = { x: (p1.x + p2.x) / 2, y: (p1.y + p2.y) / 2 };
|
|
const d = Math.sqrt((mid.x - mx) ** 2 + (mid.y - my) ** 2);
|
|
if (d < bestDist) { bestDist = d; bestEdge = edge; }
|
|
}
|
|
return bestEdge;
|
|
}
|
|
|
|
_highlightPick(pos, color = 0xFFD166) {
|
|
const sGeo = new THREE.SphereGeometry(0.12, 10, 10);
|
|
const sMat = new THREE.MeshBasicMaterial({ color });
|
|
const s = new THREE.Mesh(sGeo, sMat);
|
|
s.position.copy(pos);
|
|
this._angleGroup.add(s);
|
|
}
|
|
|
|
_onAngleClick(e) {
|
|
if (!this._angleMode) return;
|
|
|
|
if (this._angleMode === 'edge') {
|
|
this._onEdgeAngleClick(e);
|
|
} else if (this._angleMode === 'linePlane') {
|
|
this._onLinePlaneAngleClick(e);
|
|
} else if (this._angleMode === 'dihedral') {
|
|
this._onDihedralAngleClick(e);
|
|
} else if (this._angleMode === 'pointPlane') {
|
|
this._onPointPlaneClick(e);
|
|
} else if (this._angleMode === 'skewLines') {
|
|
this._onSkewLinesClick(e);
|
|
}
|
|
}
|
|
|
|
/* ── Skew lines: pick P1, P2 (line 1) then P3, P4 (line 2) ── */
|
|
_onSkewLinesClick(e) {
|
|
const pick = this._pickNearestPoint(e);
|
|
if (!pick) return;
|
|
|
|
const step = this._anglePicks.length;
|
|
this._anglePicks.push(pick);
|
|
|
|
const colors = [0xFFD166, 0xFFD166, 0x06D6E0, 0x06D6E0];
|
|
this._highlightPick(pick.pos, colors[step] || 0xffffff);
|
|
|
|
// After 2 picks: draw line 1
|
|
if (this._anglePicks.length === 2) {
|
|
const [P1, P2] = this._anglePicks;
|
|
this._drawAngleLine(P1.pos, P2.pos, '#FFD166');
|
|
}
|
|
|
|
if (this._anglePicks.length < 4) return;
|
|
|
|
const [P1, P2, P3, P4] = this._anglePicks;
|
|
const d1 = new THREE.Vector3().subVectors(P2.pos, P1.pos);
|
|
const d2 = new THREE.Vector3().subVectors(P4.pos, P3.pos);
|
|
|
|
// Draw line 2
|
|
this._drawAngleLine(P3.pos, P4.pos, '#06D6E0');
|
|
|
|
// Angle between lines (acute)
|
|
const cosA = Math.abs(d1.dot(d2)) / (d1.length() * d2.length());
|
|
const angleDeg = Math.acos(Math.min(1, cosA)) * 180 / Math.PI;
|
|
|
|
// Common perpendicular (distance between skew lines)
|
|
const normal = new THREE.Vector3().crossVectors(d1, d2);
|
|
let dist = 0;
|
|
if (normal.length() > 1e-9) {
|
|
// Lines are skew → project onto normal
|
|
dist = Math.abs(new THREE.Vector3().subVectors(P3.pos, P1.pos).dot(normal.clone().normalize()));
|
|
|
|
// Find foot points of common perpendicular
|
|
const w = new THREE.Vector3().subVectors(P1.pos, P3.pos);
|
|
const b1 = d1.dot(d1), b2 = d2.dot(d2), d12 = d1.dot(d2);
|
|
const det = b1 * b2 - d12 * d12;
|
|
if (Math.abs(det) > 1e-9) {
|
|
const t1 = (d12 * d2.dot(w) - b2 * d1.dot(w)) / det;
|
|
const t2 = (b1 * d2.dot(w) - d12 * d1.dot(w)) / det;
|
|
const Q1 = P1.pos.clone().addScaledVector(d1, t1);
|
|
const Q2 = P3.pos.clone().addScaledVector(d2, t2);
|
|
this._drawAngleLine(Q1, Q2, '#F9A8D4', true);
|
|
this._highlightPick(Q1, 0xF9A8D4);
|
|
this._highlightPick(Q2, 0xF9A8D4);
|
|
}
|
|
} else {
|
|
// Parallel lines
|
|
const cross = new THREE.Vector3().crossVectors(d1, new THREE.Vector3().subVectors(P3.pos, P1.pos));
|
|
dist = cross.length() / d1.length();
|
|
}
|
|
|
|
// Label near midpoint between the two lines
|
|
const mid = new THREE.Vector3().addVectors(P1.pos, P3.pos).multiplyScalar(0.5);
|
|
mid.y += 0.5;
|
|
const lbl = this._makeTextSprite(
|
|
`∠ = ${angleDeg.toFixed(1)}° d = ${dist.toFixed(2)}`, '#F9A8D4', 36
|
|
);
|
|
lbl.position.copy(mid);
|
|
lbl.scale.set(2.8, 0.55, 1);
|
|
this._angleGroup.add(lbl);
|
|
|
|
this._anglePicks = [];
|
|
}
|
|
|
|
/* ── Edge angle: pick 3 points (B is vertex, angle ∠ABC) ── */
|
|
_onEdgeAngleClick(e) {
|
|
const pick = this._pickNearestPoint(e);
|
|
if (!pick) return;
|
|
|
|
this._anglePicks.push(pick);
|
|
this._highlightPick(pick.pos, this._anglePicks.length === 2 ? 0xEF476F : 0xFFD166);
|
|
|
|
if (this._anglePicks.length === 3) {
|
|
const [A, B, C] = this._anglePicks;
|
|
const BA = new THREE.Vector3().subVectors(A.pos, B.pos);
|
|
const BC = new THREE.Vector3().subVectors(C.pos, B.pos);
|
|
const cosAngle = BA.dot(BC) / (BA.length() * BC.length());
|
|
const angle = Math.acos(Math.max(-1, Math.min(1, cosAngle))) * 180 / Math.PI;
|
|
|
|
// Draw the angle arc
|
|
this._drawAngleArc(B.pos, BA, BC, angle, 0.6, '#EF476F');
|
|
|
|
// Label
|
|
const bisect = new THREE.Vector3().addVectors(
|
|
BA.clone().normalize(), BC.clone().normalize()
|
|
).normalize().multiplyScalar(0.8);
|
|
const lblPos = B.pos.clone().add(bisect);
|
|
const lbl = this._makeTextSprite(
|
|
`∠${A.label}${B.label}${C.label} = ${angle.toFixed(1)}°`, '#EF476F', 36
|
|
);
|
|
lbl.position.copy(lblPos);
|
|
lbl.scale.set(2.0, 0.5, 1);
|
|
this._angleGroup.add(lbl);
|
|
|
|
// Lines
|
|
this._drawAngleLine(B.pos, A.pos, '#EF476F');
|
|
this._drawAngleLine(B.pos, C.pos, '#EF476F');
|
|
|
|
this._anglePicks = [];
|
|
}
|
|
}
|
|
|
|
/* ── Line-Plane angle: pick 2 points (line), then 1 face ── */
|
|
_onLinePlaneAngleClick(e) {
|
|
if (this._anglePicks.length < 2) {
|
|
// Picking line endpoints
|
|
const pick = this._pickNearestPoint(e);
|
|
if (!pick) return;
|
|
this._anglePicks.push(pick);
|
|
this._highlightPick(pick.pos);
|
|
|
|
if (this._anglePicks.length === 2) {
|
|
// Draw the line
|
|
this._drawAngleLine(this._anglePicks[0].pos, this._anglePicks[1].pos, '#60a5fa');
|
|
}
|
|
} else {
|
|
// Pick a face
|
|
const face = this._pickNearestFace(e);
|
|
if (!face || face.length < 3) return;
|
|
|
|
const [A, B] = this._anglePicks;
|
|
const lineDir = new THREE.Vector3().subVectors(B.pos, A.pos).normalize();
|
|
|
|
// Face normal
|
|
const v1 = new THREE.Vector3().subVectors(face[1], face[0]);
|
|
const v2 = new THREE.Vector3().subVectors(face[2], face[0]);
|
|
const normal = new THREE.Vector3().crossVectors(v1, v2).normalize();
|
|
|
|
// Angle between line and plane = 90° - angle between line and normal
|
|
const sinAngle = Math.abs(lineDir.dot(normal));
|
|
const angle = Math.asin(Math.max(0, Math.min(1, sinAngle))) * 180 / Math.PI;
|
|
|
|
// Highlight face
|
|
const positions = [];
|
|
const indices = [];
|
|
face.forEach(v => positions.push(v.x, v.y, v.z));
|
|
for (let i = 1; i < face.length - 1; i++) indices.push(0, i, i + 1);
|
|
const geo = new THREE.BufferGeometry();
|
|
geo.setAttribute('position', new THREE.Float32BufferAttribute(positions, 3));
|
|
geo.setIndex(indices);
|
|
const mat = new THREE.MeshBasicMaterial({ color: 0x60a5fa, transparent: true, opacity: 0.2, side: THREE.DoubleSide });
|
|
this._angleGroup.add(new THREE.Mesh(geo, mat));
|
|
|
|
// Label at face centroid
|
|
const c = new THREE.Vector3();
|
|
face.forEach(v => c.add(v));
|
|
c.divideScalar(face.length);
|
|
const lbl = this._makeTextSprite(
|
|
`∠(${A.label}${B.label}, пл) = ${angle.toFixed(1)}°`, '#60a5fa', 36
|
|
);
|
|
lbl.position.copy(c).add(new THREE.Vector3(0, 0.5, 0));
|
|
lbl.scale.set(2.4, 0.5, 1);
|
|
this._angleGroup.add(lbl);
|
|
|
|
// Draw projection of line onto plane
|
|
const proj = lineDir.clone().sub(normal.clone().multiplyScalar(lineDir.dot(normal))).normalize();
|
|
const projEnd = A.pos.clone().add(proj.multiplyScalar(A.pos.distanceTo(B.pos)));
|
|
this._drawAngleLine(A.pos, projEnd, '#60a5fa', true);
|
|
|
|
this._anglePicks = [];
|
|
}
|
|
}
|
|
|
|
/* ── Dihedral angle: pick 2 points of shared edge, then auto-find adjacent faces ── */
|
|
_onDihedralAngleClick(e) {
|
|
const pick = this._pickNearestPoint(e);
|
|
if (!pick) return;
|
|
|
|
this._anglePicks.push(pick);
|
|
this._highlightPick(pick.pos, 0xc4b5fd);
|
|
|
|
if (this._anglePicks.length === 2) {
|
|
const [P1, P2] = this._anglePicks;
|
|
const edgeDir = new THREE.Vector3().subVectors(P2.pos, P1.pos);
|
|
|
|
// Find two faces sharing this edge (both contain P1 and P2)
|
|
const adjFaces = [];
|
|
const eps = 0.1;
|
|
for (const face of this._faces) {
|
|
let hasP1 = false, hasP2 = false;
|
|
for (const v of face) {
|
|
if (v.distanceTo(P1.pos) < eps) hasP1 = true;
|
|
if (v.distanceTo(P2.pos) < eps) hasP2 = true;
|
|
}
|
|
if (hasP1 && hasP2) adjFaces.push(face);
|
|
}
|
|
|
|
if (adjFaces.length >= 2) {
|
|
// Compute normals of both faces
|
|
const normal1 = this._faceNormal(adjFaces[0]);
|
|
const normal2 = this._faceNormal(adjFaces[1]);
|
|
|
|
// Dihedral angle = π - angle between outward normals
|
|
const cosAngle = normal1.dot(normal2);
|
|
const rawAngle = Math.acos(Math.max(-1, Math.min(1, cosAngle))) * 180 / Math.PI;
|
|
// Dihedral is the interior angle
|
|
const dihedralAngle = 180 - rawAngle;
|
|
|
|
// Highlight both faces
|
|
for (const face of [adjFaces[0], adjFaces[1]]) {
|
|
const positions = [];
|
|
const indices = [];
|
|
face.forEach(v => positions.push(v.x, v.y, v.z));
|
|
for (let i = 1; i < face.length - 1; i++) indices.push(0, i, i + 1);
|
|
const geo = new THREE.BufferGeometry();
|
|
geo.setAttribute('position', new THREE.Float32BufferAttribute(positions, 3));
|
|
geo.setIndex(indices);
|
|
const mat = new THREE.MeshBasicMaterial({ color: 0xc4b5fd, transparent: true, opacity: 0.2, side: THREE.DoubleSide });
|
|
this._angleGroup.add(new THREE.Mesh(geo, mat));
|
|
}
|
|
|
|
// Label at edge midpoint
|
|
const mid = new THREE.Vector3().addVectors(P1.pos, P2.pos).multiplyScalar(0.5);
|
|
const lbl = this._makeTextSprite(
|
|
`∠дв(${P1.label}${P2.label}) = ${dihedralAngle.toFixed(1)}°`, '#c4b5fd', 36
|
|
);
|
|
lbl.position.copy(mid).add(new THREE.Vector3(0, 0.5, 0.3));
|
|
lbl.scale.set(2.4, 0.5, 1);
|
|
this._angleGroup.add(lbl);
|
|
|
|
// Draw the edge
|
|
this._drawAngleLine(P1.pos, P2.pos, '#c4b5fd');
|
|
} else {
|
|
// Not enough adjacent faces — show error sprite
|
|
const mid = new THREE.Vector3().addVectors(P1.pos, P2.pos).multiplyScalar(0.5);
|
|
const lbl = this._makeTextSprite('Нет общего ребра', '#ff6b6b', 36);
|
|
lbl.position.copy(mid).add(new THREE.Vector3(0, 0.5, 0));
|
|
lbl.scale.set(2.0, 0.5, 1);
|
|
this._angleGroup.add(lbl);
|
|
}
|
|
|
|
this._anglePicks = [];
|
|
}
|
|
}
|
|
|
|
_faceNormal(face) {
|
|
if (face.length < 3) return new THREE.Vector3(0, 1, 0);
|
|
const v1 = new THREE.Vector3().subVectors(face[1], face[0]);
|
|
const v2 = new THREE.Vector3().subVectors(face[2], face[0]);
|
|
return new THREE.Vector3().crossVectors(v1, v2).normalize();
|
|
}
|
|
|
|
_drawAngleArc(center, dir1, dir2, angleDeg, radius, color) {
|
|
const n1 = dir1.clone().normalize();
|
|
const n2 = dir2.clone().normalize();
|
|
const angleRad = angleDeg * Math.PI / 180;
|
|
const steps = Math.max(8, Math.round(angleDeg / 5));
|
|
const pts = [];
|
|
|
|
// Build rotation from n1 toward n2
|
|
const axis = new THREE.Vector3().crossVectors(n1, n2).normalize();
|
|
if (axis.length() < 0.001) return; // parallel vectors
|
|
|
|
for (let i = 0; i <= steps; i++) {
|
|
const t = i / steps;
|
|
const a = t * angleRad;
|
|
const rotated = n1.clone().applyAxisAngle(axis, a).multiplyScalar(radius);
|
|
pts.push(center.clone().add(rotated));
|
|
}
|
|
|
|
const geo = new THREE.BufferGeometry().setFromPoints(pts);
|
|
const mat = new THREE.LineBasicMaterial({ color: new THREE.Color(color), transparent: true, opacity: 0.8 });
|
|
this._angleGroup.add(new THREE.Line(geo, mat));
|
|
}
|
|
|
|
_drawAngleLine(from, to, color, dashed = false) {
|
|
const geo = new THREE.BufferGeometry().setFromPoints([from, to]);
|
|
let mat;
|
|
if (dashed) {
|
|
mat = new THREE.LineDashedMaterial({
|
|
color: new THREE.Color(color), dashSize: 0.12, gapSize: 0.06,
|
|
transparent: true, opacity: 0.7,
|
|
});
|
|
} else {
|
|
mat = new THREE.LineBasicMaterial({ color: new THREE.Color(color), transparent: true, opacity: 0.7 });
|
|
}
|
|
const line = new THREE.Line(geo, mat);
|
|
if (dashed) line.computeLineDistances();
|
|
this._angleGroup.add(line);
|
|
}
|
|
|
|
/* ════════════════ DIAGONALS ════════════════ */
|
|
|
|
_drawDiagonals() {
|
|
if (!this.showDiagonals) return;
|
|
const t = this.figureType;
|
|
|
|
if (t === 'cube' || t === 'parallelepiped') {
|
|
// 8 vertices: 0-3 bottom, 4-7 top (from _buildBox)
|
|
const v = this._vertices.map(vt => vt.pos);
|
|
if (v.length < 8) return;
|
|
|
|
// Space diagonals (4)
|
|
const spaceDiags = [[0,6],[1,7],[2,4],[3,5]];
|
|
for (const [a, b] of spaceDiags) {
|
|
this._drawDashedSegment(v[a], v[b], '', '#fbbf24');
|
|
}
|
|
|
|
// Face diagonals (12) — 2 per face
|
|
const faceDiags = [
|
|
[0,2],[1,3], // bottom
|
|
[4,6],[5,7], // top
|
|
[0,5],[1,4], // front
|
|
[2,7],[3,6], // back
|
|
[1,6],[2,5], // right
|
|
[0,7],[3,4], // left
|
|
];
|
|
for (const [a, b] of faceDiags) {
|
|
const lineGeo = new THREE.BufferGeometry().setFromPoints([v[a], v[b]]);
|
|
const lineMat = new THREE.LineDashedMaterial({
|
|
color: 0xF59E0B, dashSize: 0.1, gapSize: 0.06,
|
|
transparent: true, opacity: 0.35,
|
|
});
|
|
const line = new THREE.Line(lineGeo, lineMat);
|
|
line.computeLineDistances();
|
|
this._figGroup.add(line);
|
|
}
|
|
|
|
// Space diagonal label — only one, the longest
|
|
const d = v[0].distanceTo(v[6]);
|
|
const mid = new THREE.Vector3().addVectors(v[0], v[6]).multiplyScalar(0.5);
|
|
const lbl = this._makeTextSprite(`d = ${d.toFixed(2)}`, '#fbbf24', 34);
|
|
lbl.position.copy(mid).add(new THREE.Vector3(0.3, 0.2, 0));
|
|
lbl.scale.set(1.2, 0.4, 1);
|
|
this._labelGroup.add(lbl);
|
|
|
|
} else if (t === 'prism') {
|
|
const n = this.params.n;
|
|
const v = this._vertices.map(vt => vt.pos);
|
|
if (v.length < n * 2) return;
|
|
|
|
// Base diagonals (bottom)
|
|
for (let i = 0; i < n; i++) {
|
|
for (let j = i + 2; j < n; j++) {
|
|
if (j === (i + n - 1) % n + i) continue; // skip adjacent
|
|
if (i === 0 && j === n - 1) continue;
|
|
const lineGeo = new THREE.BufferGeometry().setFromPoints([v[i], v[j]]);
|
|
const lineMat = new THREE.LineDashedMaterial({
|
|
color: 0xF59E0B, dashSize: 0.1, gapSize: 0.06,
|
|
transparent: true, opacity: 0.35,
|
|
});
|
|
const line = new THREE.Line(lineGeo, lineMat);
|
|
line.computeLineDistances();
|
|
this._figGroup.add(line);
|
|
}
|
|
}
|
|
// Top diagonals
|
|
for (let i = n; i < 2 * n; i++) {
|
|
for (let j = i + 2; j < 2 * n; j++) {
|
|
if (i === n && j === 2 * n - 1) continue;
|
|
const lineGeo = new THREE.BufferGeometry().setFromPoints([v[i], v[j]]);
|
|
const lineMat = new THREE.LineDashedMaterial({
|
|
color: 0xF59E0B, dashSize: 0.1, gapSize: 0.06,
|
|
transparent: true, opacity: 0.35,
|
|
});
|
|
const line = new THREE.Line(lineGeo, lineMat);
|
|
line.computeLineDistances();
|
|
this._figGroup.add(line);
|
|
}
|
|
}
|
|
// Space diagonals: connect bottom[i] to top[(i+k)%n] for k≠0
|
|
for (let i = 0; i < n; i++) {
|
|
for (let k = 1; k < n; k++) {
|
|
const j = n + (i + k) % n;
|
|
this._drawDashedSegment(v[i], v[j], '', '#fbbf24');
|
|
}
|
|
}
|
|
|
|
} else if (t === 'pyramid') {
|
|
// Base diagonals only
|
|
const n = this.params.n;
|
|
const v = this._vertices.map(vt => vt.pos);
|
|
for (let i = 0; i < n; i++) {
|
|
for (let j = i + 2; j < n; j++) {
|
|
if (i === 0 && j === n - 1) continue;
|
|
const lineGeo = new THREE.BufferGeometry().setFromPoints([v[i], v[j]]);
|
|
const lineMat = new THREE.LineDashedMaterial({
|
|
color: 0xF59E0B, dashSize: 0.1, gapSize: 0.06,
|
|
transparent: true, opacity: 0.4,
|
|
});
|
|
const line = new THREE.Line(lineGeo, lineMat);
|
|
line.computeLineDistances();
|
|
this._figGroup.add(line);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
/* ════════════════ MIDPOINTS ════════════════ */
|
|
|
|
_drawMidpoints() {
|
|
if (!this.showMidpoints) return;
|
|
|
|
for (let i = 0; i < this._edges.length; i++) {
|
|
const e = this._edges[i];
|
|
const mid = new THREE.Vector3().addVectors(e.from, e.to).multiplyScalar(0.5);
|
|
|
|
// small cyan sphere
|
|
const sGeo = new THREE.SphereGeometry(0.06, 8, 8);
|
|
const sMat = new THREE.MeshBasicMaterial({ color: 0x06D6E0 });
|
|
const s = new THREE.Mesh(sGeo, sMat);
|
|
s.position.copy(mid);
|
|
this._figGroup.add(s);
|
|
|
|
// label "M" + index
|
|
const lbl = this._makeTextSprite(`M${i + 1}`, '#06D6E0', 28);
|
|
lbl.position.copy(mid).add(new THREE.Vector3(0.15, 0.15, 0));
|
|
lbl.scale.set(0.5, 0.25, 1);
|
|
this._labelGroup.add(lbl);
|
|
}
|
|
}
|
|
|
|
/* ════════════════ POINT-TO-PLANE DISTANCE ════════════════ */
|
|
|
|
_onPointPlaneClick(e) {
|
|
if (this._anglePicks.length < 1) {
|
|
// First pick: a vertex/point
|
|
const pick = this._pickNearestPoint(e);
|
|
if (!pick) return;
|
|
this._anglePicks.push(pick);
|
|
this._highlightPick(pick.pos, 0xF9A8D4);
|
|
} else {
|
|
// Second pick: a face
|
|
const face = this._pickNearestFace(e);
|
|
if (!face || face.length < 3) return;
|
|
|
|
const pt = this._anglePicks[0];
|
|
const normal = this._faceNormal(face);
|
|
const planePoint = face[0];
|
|
|
|
// Distance = |dot(pt - planePoint, normal)|
|
|
const diff = new THREE.Vector3().subVectors(pt.pos, planePoint);
|
|
const dist = Math.abs(diff.dot(normal));
|
|
|
|
// Projection of point onto plane
|
|
const proj = pt.pos.clone().sub(normal.clone().multiplyScalar(diff.dot(normal)));
|
|
|
|
// Draw perpendicular line
|
|
this._drawAngleLine(pt.pos, proj, '#F9A8D4', true);
|
|
|
|
// Highlight foot of perpendicular
|
|
this._highlightPick(proj, 0xF9A8D4);
|
|
|
|
// Right angle marker
|
|
const edgeOnPlane = new THREE.Vector3().subVectors(face[1], face[0]).normalize();
|
|
this._drawRightAngleMarkerAt(proj, normal, edgeOnPlane, 0.25, '#F9A8D4');
|
|
|
|
// Highlight face
|
|
const positions = [];
|
|
const indices = [];
|
|
face.forEach(v => positions.push(v.x, v.y, v.z));
|
|
for (let i = 1; i < face.length - 1; i++) indices.push(0, i, i + 1);
|
|
const geo = new THREE.BufferGeometry();
|
|
geo.setAttribute('position', new THREE.Float32BufferAttribute(positions, 3));
|
|
geo.setIndex(indices);
|
|
const mat = new THREE.MeshBasicMaterial({ color: 0xF9A8D4, transparent: true, opacity: 0.15, side: THREE.DoubleSide });
|
|
this._angleGroup.add(new THREE.Mesh(geo, mat));
|
|
|
|
// Label
|
|
const mid = new THREE.Vector3().addVectors(pt.pos, proj).multiplyScalar(0.5);
|
|
const lbl = this._makeTextSprite(`d(${pt.label}, пл) = ${dist.toFixed(2)}`, '#F9A8D4', 36);
|
|
lbl.position.copy(mid).add(new THREE.Vector3(0.4, 0.2, 0));
|
|
lbl.scale.set(2.2, 0.5, 1);
|
|
this._angleGroup.add(lbl);
|
|
|
|
this._anglePicks = [];
|
|
}
|
|
}
|
|
|
|
_drawRightAngleMarkerAt(origin, normalDir, tangentDir, size, color) {
|
|
const d1 = normalDir.clone().normalize().multiplyScalar(size);
|
|
const d2 = tangentDir.clone().normalize().multiplyScalar(size);
|
|
const p1 = origin.clone().add(d1);
|
|
const p2 = origin.clone().add(d2);
|
|
const p3 = p1.clone().add(d2);
|
|
const pts = [p1, p3, p2];
|
|
const geo = new THREE.BufferGeometry().setFromPoints(pts);
|
|
const mat = new THREE.LineBasicMaterial({ color: new THREE.Color(color), transparent: true, opacity: 0.6 });
|
|
this._angleGroup.add(new THREE.Line(geo, mat));
|
|
}
|
|
|
|
/* ════════════════ COORDINATE TOOLTIP ════════════════ */
|
|
|
|
_initTooltip() {
|
|
this._tooltipEl = document.createElement('div');
|
|
Object.assign(this._tooltipEl.style, {
|
|
position: 'absolute', pointerEvents: 'none',
|
|
background: 'rgba(13,13,26,0.85)', color: '#ccc',
|
|
fontSize: '11px', fontFamily: 'Manrope, monospace',
|
|
padding: '3px 7px', borderRadius: '4px',
|
|
border: '1px solid rgba(155,93,229,0.3)',
|
|
display: 'none', zIndex: '50', whiteSpace: 'nowrap',
|
|
});
|
|
this.container.style.position = 'relative';
|
|
this.container.appendChild(this._tooltipEl);
|
|
}
|
|
|
|
_onHoverMove(e) {
|
|
const rect = this.renderer.domElement.getBoundingClientRect();
|
|
if (e.clientX < rect.left || e.clientX > rect.right ||
|
|
e.clientY < rect.top || e.clientY > rect.bottom) {
|
|
if (this._tooltipEl) this._tooltipEl.style.display = 'none';
|
|
return;
|
|
}
|
|
|
|
const mx = ((e.clientX - rect.left) / rect.width) * 2 - 1;
|
|
const my = -((e.clientY - rect.top) / rect.height) * 2 + 1;
|
|
|
|
let bestDist = 0.06;
|
|
let bestV = null;
|
|
|
|
for (const v of this._vertices) {
|
|
const proj = v.pos.clone().project(this.camera);
|
|
const d = Math.sqrt((proj.x - mx) ** 2 + (proj.y - my) ** 2);
|
|
if (d < bestDist) { bestDist = d; bestV = v; }
|
|
}
|
|
for (const cp of this._customPoints) {
|
|
const proj = cp.pos.clone().project(this.camera);
|
|
const d = Math.sqrt((proj.x - mx) ** 2 + (proj.y - my) ** 2);
|
|
if (d < bestDist) { bestDist = d; bestV = cp; }
|
|
}
|
|
|
|
if (bestV && this._tooltipEl) {
|
|
const p = bestV.pos;
|
|
this._tooltipEl.textContent = `${bestV.label} (${p.x.toFixed(1)}, ${p.y.toFixed(1)}, ${p.z.toFixed(1)})`;
|
|
this._tooltipEl.style.display = 'block';
|
|
this._tooltipEl.style.left = (e.clientX - rect.left + 14) + 'px';
|
|
this._tooltipEl.style.top = (e.clientY - rect.top - 10) + 'px';
|
|
} else if (this._tooltipEl) {
|
|
this._tooltipEl.style.display = 'none';
|
|
}
|
|
}
|
|
|
|
/* ════════════════ UNFOLD ANIMATION ════════════════ */
|
|
// Simplified unfold: flatten figure by reducing Y coordinates
|
|
_applyUnfold(progress) {
|
|
// This is a visual-only effect — squash Y toward 0
|
|
if (progress < 0.01) return;
|
|
this._figGroup.children.forEach(child => {
|
|
if (child.geometry) {
|
|
// Already built, don't modify geometry — just scale Y
|
|
}
|
|
});
|
|
this._figGroup.scale.y = 1 - progress * 0.85;
|
|
this._figGroup.position.y = progress * 0.5;
|
|
}
|
|
|
|
/* ════════════════ UTILS ════════════════ */
|
|
|
|
_clearGroup(group) {
|
|
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 p1 = edge.from.clone().project(this.camera);
|
|
const p2 = edge.to.clone().project(this.camera);
|
|
// distance from click to edge midpoint in NDC
|
|
const mx2 = (p1.x + p2.x) / 2;
|
|
const my2 = (p1.y + p2.y) / 2;
|
|
const d = Math.sqrt((mx2 - mx) ** 2 + (my2 - my) ** 2);
|
|
if (d < bestDist) { bestDist = d; bestIdx = i; }
|
|
}
|
|
return bestIdx;
|
|
}
|
|
|
|
_onMarkClick(e) {
|
|
const idx = this._pickNearestEdgeIdx(e);
|
|
if (idx < 0) return;
|
|
if (!this._edgeMarks[idx]) this._edgeMarks[idx] = { ticks: 0, parallel: 0 };
|
|
const m = this._edgeMarks[idx];
|
|
if (this._markMode === 'ticks') m.ticks = (m.ticks + 1) % 4; // 0→1→2→3→0
|
|
if (this._markMode === 'parallel') m.parallel = (m.parallel + 1) % 4;
|
|
this._renderEdgeMarks();
|
|
}
|
|
|
|
_renderEdgeMarks() {
|
|
this._clearGroup(this._markGroup);
|
|
for (const [idxStr, mark] of Object.entries(this._edgeMarks)) {
|
|
const idx = parseInt(idxStr);
|
|
if (idx < 0 || idx >= this._edges.length) continue;
|
|
const { from, to } = this._edges[idx];
|
|
if (mark.ticks > 0) this._drawEdgeTick3D(from, to, mark.ticks, '#FFD166');
|
|
if (mark.parallel > 0) this._drawEdgeParallel3D(from, to, mark.parallel, '#06D6E0');
|
|
}
|
|
}
|
|
|
|
_drawEdgeTick3D(from, to, count, color) {
|
|
// Perpendicular ticks crossing the edge near its midpoint
|
|
const dir = new THREE.Vector3().subVectors(to, from).normalize();
|
|
const up = Math.abs(dir.y) < 0.9 ? new THREE.Vector3(0, 1, 0) : new THREE.Vector3(1, 0, 0);
|
|
const perp = new THREE.Vector3().crossVectors(dir, up).normalize();
|
|
const mid = new THREE.Vector3().addVectors(from, to).multiplyScalar(0.5);
|
|
const step = 0.22;
|
|
const half = (count - 1) / 2;
|
|
const mat = new THREE.LineBasicMaterial({ color: new THREE.Color(color), linewidth: 2 });
|
|
|
|
for (let i = 0; i < count; i++) {
|
|
const offset = (i - half) * step;
|
|
const center = mid.clone().add(dir.clone().multiplyScalar(offset));
|
|
const p1 = center.clone().sub(perp.clone().multiplyScalar(0.18));
|
|
const p2 = center.clone().add(perp.clone().multiplyScalar(0.18));
|
|
const geo = new THREE.BufferGeometry().setFromPoints([p1, p2]);
|
|
this._markGroup.add(new THREE.Line(geo, mat.clone()));
|
|
}
|
|
}
|
|
|
|
_drawEdgeParallel3D(from, to, count, color) {
|
|
// Chevron (arrow-head) marks indicating parallel edges
|
|
const dir = new THREE.Vector3().subVectors(to, from).normalize();
|
|
const up = Math.abs(dir.y) < 0.9 ? new THREE.Vector3(0, 1, 0) : new THREE.Vector3(1, 0, 0);
|
|
const perp = new THREE.Vector3().crossVectors(dir, up).normalize();
|
|
const mid = new THREE.Vector3().addVectors(from, to).multiplyScalar(0.5);
|
|
const step = 0.22;
|
|
const half = (count - 1) / 2;
|
|
const mat = new THREE.LineBasicMaterial({ color: new THREE.Color(color), linewidth: 2 });
|
|
|
|
for (let i = 0; i < count; i++) {
|
|
const offset = (i - half) * step;
|
|
const center = mid.clone().add(dir.clone().multiplyScalar(offset));
|
|
// chevron: two lines meeting at a tip (along perp), base spread along dir
|
|
const tip = center.clone().add(perp.clone().multiplyScalar( 0.18));
|
|
const base1 = center.clone().add(perp.clone().multiplyScalar(-0.10)).sub(dir.clone().multiplyScalar(0.14));
|
|
const base2 = center.clone().add(perp.clone().multiplyScalar(-0.10)).add(dir.clone().multiplyScalar(0.14));
|
|
const geo = new THREE.BufferGeometry().setFromPoints([base1, tip, base2]);
|
|
this._markGroup.add(new THREE.Line(geo, mat.clone()));
|
|
}
|
|
}
|
|
|
|
/* ════════════════ DERIVED 3D CONSTRUCTIONS ════════════════ */
|
|
|
|
_addSolidCentroid() {
|
|
if (!this._vertices.length) return;
|
|
const c = new THREE.Vector3();
|
|
this._vertices.forEach(v => c.add(v.pos));
|
|
c.divideScalar(this._vertices.length);
|
|
const n = this._derived3D.filter(d => d.type === 'point').length;
|
|
this._derived3D.push({ type: 'point', pos: c.clone(), label: n ? 'G' + (n + 1) : 'G', color: '#9B5DE5' });
|
|
this._buildDerived3D();
|
|
this._deriveMode = null;
|
|
this.renderer.domElement.style.cursor = 'grab';
|
|
}
|
|
|
|
_onDeriveClick(e) {
|
|
if (this._deriveMode === 'midpoint') {
|
|
const idx = this._pickNearestEdgeIdx(e);
|
|
if (idx < 0) return;
|
|
const { from, to } = this._edges[idx];
|
|
const mid = new THREE.Vector3().addVectors(from, to).multiplyScalar(0.5);
|
|
const n = this._derived3D.filter(d => d.type === 'point').length;
|
|
this._derived3D.push({ type: 'point', pos: mid.clone(), label: n ? 'M' + (n + 1) : 'M', color: '#FFD166' });
|
|
this._buildDerived3D();
|
|
|
|
} else if (this._deriveMode === 'face_centroid') {
|
|
const face = this._pickNearestFace(e);
|
|
if (!face) return;
|
|
const c = new THREE.Vector3();
|
|
face.forEach(v => c.add(v));
|
|
c.divideScalar(face.length);
|
|
const n = this._derived3D.filter(d => d.type === 'point').length;
|
|
this._derived3D.push({ type: 'point', pos: c.clone(), label: n ? 'O' + (n + 1) : 'O', color: '#A8E063' });
|
|
this._buildDerived3D();
|
|
|
|
} else if (this._deriveMode === 'alt_foot') {
|
|
const pick = this._pickNearestPoint(e);
|
|
if (!pick) return;
|
|
this._derivePicks.push(pick);
|
|
// Highlight first pick
|
|
if (this._derivePicks.length === 1) {
|
|
const sGeo = new THREE.SphereGeometry(0.14, 10, 10);
|
|
const sMat = new THREE.MeshBasicMaterial({ color: 0xF15BB5 });
|
|
const s = new THREE.Mesh(sGeo, sMat);
|
|
s.position.copy(pick.pos);
|
|
this._derivedGroup.add(s);
|
|
} else if (this._derivePicks.length === 2) {
|
|
// Vertex + base point: find foot of perpendicular from V onto the edge containing E
|
|
const V = this._derivePicks[0].pos;
|
|
const E = this._derivePicks[1].pos;
|
|
const eps = 0.12;
|
|
let foot = E.clone();
|
|
for (const edge of this._edges) {
|
|
if (edge.from.distanceTo(E) < eps || edge.to.distanceTo(E) < eps) {
|
|
const d = new THREE.Vector3().subVectors(edge.to, edge.from);
|
|
const t = new THREE.Vector3().subVectors(V, edge.from).dot(d) / d.lengthSq();
|
|
foot = edge.from.clone().add(d.clone().multiplyScalar(Math.max(0, Math.min(1, t))));
|
|
break;
|
|
}
|
|
}
|
|
const n = this._derived3D.filter(d => d.type === 'point').length;
|
|
this._derived3D.push({ type: 'point', pos: foot.clone(), label: n ? 'H' + (n + 1) : 'H', color: '#FF9F43' });
|
|
this._derived3D.push({ type: 'line', from: V.clone(), to: foot.clone(), color: '#FF9F43', dashed: true });
|
|
this._buildDerived3D();
|
|
this._derivePicks = [];
|
|
}
|
|
}
|
|
}
|
|
|
|
_buildDerived3D() {
|
|
this._clearGroup(this._derivedGroup);
|
|
for (const d of this._derived3D) {
|
|
if (d.type === 'point') {
|
|
const sGeo = new THREE.SphereGeometry(0.12, 12, 12);
|
|
const sMat = new THREE.MeshBasicMaterial({ color: new THREE.Color(d.color || '#FFD166') });
|
|
const s = new THREE.Mesh(sGeo, sMat);
|
|
s.position.copy(d.pos);
|
|
this._derivedGroup.add(s);
|
|
if (d.label) {
|
|
const sprite = this._makeTextSprite(d.label, d.color || '#FFD166', 52);
|
|
sprite.position.copy(d.pos).add(new THREE.Vector3(0.18, 0.25, 0));
|
|
sprite.scale.set(1.0, 0.45, 1);
|
|
this._derivedGroup.add(sprite);
|
|
}
|
|
} else if (d.type === 'line') {
|
|
const pts = [d.from, d.to];
|
|
const geo = new THREE.BufferGeometry().setFromPoints(pts);
|
|
let mat;
|
|
if (d.dashed) {
|
|
mat = new THREE.LineDashedMaterial({
|
|
color: new THREE.Color(d.color || '#FFD166'),
|
|
dashSize: 0.1, gapSize: 0.07, transparent: true, opacity: 0.85,
|
|
});
|
|
} else {
|
|
mat = new THREE.LineBasicMaterial({ color: new THREE.Color(d.color || '#FFD166'), transparent: true, opacity: 0.85 });
|
|
}
|
|
const line = new THREE.Line(geo, mat);
|
|
if (d.dashed) line.computeLineDistances();
|
|
this._derivedGroup.add(line);
|
|
}
|
|
}
|
|
}
|
|
|
|
_notify() {
|
|
if (this.onUpdate) this.onUpdate(this.info());
|
|
}
|
|
|
|
/* ════════════════ ANIMATION LOOP ════════════════ */
|
|
|
|
_loop() {
|
|
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() {
|
|
document.getElementById('sim-topbar-title').textContent = 'Стереометрия 3D';
|
|
_simShow('sim-stereo');
|
|
document.getElementById('stereo-stats').style.display = '';
|
|
requestAnimationFrame(() => requestAnimationFrame(() => {
|
|
if (!stereoSim) {
|
|
stereoSim = new StereoSim(document.getElementById('stereo-container'));
|
|
stereoSim.onUpdate = _stereoUpdateUI;
|
|
} else {
|
|
stereoSim.fit();
|
|
stereoSim.play();
|
|
}
|
|
_stereoShowParams(stereoSim.figureType || 'cube');
|
|
_stereoUpdateUI(stereoSim.info());
|
|
_stereoUpdateFormulas();
|
|
}));
|
|
}
|
|
|
|
function setStereoFigure(type, btn) {
|
|
document.querySelectorAll('.stereo-fig-btn').forEach(b => b.classList.remove('active'));
|
|
if (btn) btn.classList.add('active');
|
|
if (stereoSim) {
|
|
stereoSim.setFigure(type);
|
|
_stereoShowParams(type);
|
|
_stereoUpdateFormulas();
|
|
if (window.LabFX) LabFX.sound.play('whoosh', { pitch: 1.2, volume: 0.3 });
|
|
// reset toggles and tool buttons
|
|
document.getElementById('sect-toggle').classList.remove('active');
|
|
document.getElementById('stereo-unfold-btn').classList.remove('active');
|
|
document.getElementById('stereo-measure-btn').classList.remove('active');
|
|
// reset element toggles
|
|
['stg-height','stg-apothem','stg-diagonals','stg-midpoints','stg-inscribed','stg-circumscribed','stg-edgelengths'].forEach(id => {
|
|
document.getElementById(id)?.classList.remove('on');
|
|
});
|
|
_stereoDeactivateTools();
|
|
}
|
|
}
|
|
|
|
function _stereoShowParams(type) {
|
|
const show = STEREO_PARAM_MAP[type] || ['a'];
|
|
['a','b','c','h','r','R','n'].forEach(k => {
|
|
document.getElementById('sp-' + k + '-row').style.display = show.includes(k) ? '' : 'none';
|
|
});
|
|
}
|
|
|
|
function stereoParamChange(key, val) {
|
|
val = +val;
|
|
const label = document.getElementById('sp-' + key + '-val');
|
|
if (label) label.textContent = val;
|
|
if (stereoSim) {
|
|
stereoSim.setParam(key, val);
|
|
_stereoUpdateFormulas();
|
|
}
|
|
}
|
|
|
|
function stereoOpacityChange(val) {
|
|
val = +val;
|
|
document.getElementById('sp-opacity-val').textContent = val.toFixed(2);
|
|
if (stereoSim) stereoSim.setOpacity(val);
|
|
}
|
|
|
|
/* ── 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'].forEach(id => {
|
|
document.getElementById(id)?.classList.remove('active');
|
|
});
|
|
if (stereoSim) {
|
|
stereoSim.toggleMeasure(false);
|
|
stereoSim.togglePointMode(false);
|
|
stereoSim.toggleConnectMode(false);
|
|
stereoSim.setAngleMode(null);
|
|
stereoSim.setMarkMode(null);
|
|
stereoSim.setDeriveMode(null);
|
|
stereoSim.toggleSection3P(false);
|
|
}
|
|
const hint = document.getElementById('angle-hint');
|
|
if (hint) hint.textContent = '';
|
|
}
|
|
|
|
function stereoMeasure(btn) {
|
|
const on = !btn.classList.contains('active');
|
|
_stereoDeactivateTools();
|
|
btn.classList.toggle('active', on);
|
|
if (stereoSim) stereoSim.toggleMeasure(on);
|
|
}
|
|
|
|
function stereoMeasureUndo() {
|
|
if (stereoSim) stereoSim.removeLastMeasurement();
|
|
}
|
|
|
|
function stereoMeasureClear() {
|
|
if (stereoSim) stereoSim.clearMeasurements();
|
|
}
|
|
|
|
function stereoToggleHeight(btn) {
|
|
const on = !btn.classList.contains('active');
|
|
btn.classList.toggle('active', on);
|
|
if (stereoSim) stereoSim.toggleHeight(on);
|
|
}
|
|
|
|
function stereoToggleApothem(btn) {
|
|
const on = !btn.classList.contains('active');
|
|
btn.classList.toggle('active', on);
|
|
if (stereoSim) stereoSim.toggleApothem(on);
|
|
}
|
|
|
|
function stereoToggleDiag(btn) {
|
|
const on = !btn.classList.contains('active');
|
|
btn.classList.toggle('active', on);
|
|
if (stereoSim) stereoSim.toggleDiagonals(on);
|
|
}
|
|
|
|
function stereoToggleMid(btn) {
|
|
const on = !btn.classList.contains('active');
|
|
btn.classList.toggle('active', on);
|
|
if (stereoSim) stereoSim.toggleMidpoints(on);
|
|
}
|
|
|
|
const ANGLE_HINTS = {
|
|
edge: 'Кликните 3 точки: A, B (вершина угла), C',
|
|
linePlane: 'Кликните 2 точки (прямая), затем — грань',
|
|
dihedral: 'Кликните 2 точки общего ребра двух граней',
|
|
pointPlane: 'Кликните точку, затем — грань',
|
|
skewLines: 'P1, P2 (прямая 1) → P3, P4 (прямая 2): угол и расстояние',
|
|
};
|
|
|
|
function stereoAngleMode(mode, btn) {
|
|
const on = !btn.classList.contains('active');
|
|
_stereoDeactivateTools();
|
|
btn.classList.toggle('active', on);
|
|
if (stereoSim) stereoSim.setAngleMode(on ? mode : null);
|
|
const hint = document.getElementById('angle-hint');
|
|
if (hint) hint.textContent = on ? ANGLE_HINTS[mode] : '';
|
|
}
|
|
|
|
function stereoAngleClear() {
|
|
_stereoDeactivateTools();
|
|
if (stereoSim) {
|
|
stereoSim.setAngleMode(null);
|
|
stereoSim._clearGroup(stereoSim._angleGroup);
|
|
}
|
|
}
|
|
|
|
/* ── Edge marks ── */
|
|
function stereoMarkMode(mode, btn) {
|
|
const on = !btn.classList.contains('active');
|
|
_stereoDeactivateTools();
|
|
btn.classList.toggle('active', on);
|
|
if (stereoSim) stereoSim.setMarkMode(on ? mode : null);
|
|
}
|
|
|
|
function stereoMarkClear() {
|
|
_stereoDeactivateTools();
|
|
if (stereoSim) stereoSim.clearMarks();
|
|
}
|
|
|
|
function stereoToggleEdgeLengths(btn) {
|
|
const on = !btn.classList.contains('active');
|
|
btn.classList.toggle('active', on);
|
|
if (stereoSim) stereoSim.toggleEdgeLengths(on);
|
|
}
|
|
|
|
/* ── Derived points ── */
|
|
function stereoDerive(mode, btn) {
|
|
const on = !btn.classList.contains('active');
|
|
_stereoDeactivateTools();
|
|
btn.classList.toggle('active', on);
|
|
if (stereoSim) stereoSim.setDeriveMode(on ? mode : null);
|
|
}
|
|
|
|
function stereoDeriveUndo() {
|
|
if (stereoSim) stereoSim.removeLastDerived();
|
|
}
|
|
|
|
function stereoDeriveClear() {
|
|
_stereoDeactivateTools();
|
|
if (stereoSim) stereoSim.clearDerived();
|
|
}
|
|
|
|
function stereoPointMode(btn) {
|
|
const on = !btn.classList.contains('active');
|
|
_stereoDeactivateTools();
|
|
btn.classList.toggle('active', on);
|
|
if (stereoSim) stereoSim.togglePointMode(on);
|
|
}
|
|
|
|
function stereoConnectMode(btn) {
|
|
const on = !btn.classList.contains('active');
|
|
_stereoDeactivateTools();
|
|
btn.classList.toggle('active', on);
|
|
if (stereoSim) stereoSim.toggleConnectMode(on);
|
|
}
|
|
|
|
function stereoUndoPoint() {
|
|
if (stereoSim) stereoSim.removeLastPoint();
|
|
}
|
|
|
|
function stereoClearPoints() {
|
|
if (stereoSim) stereoSim.clearCustomPoints();
|
|
_stereoUpdatePointsInfo();
|
|
}
|
|
|
|
/* ── Section through 3 points UI ── */
|
|
function stereoSection3P(btn) {
|
|
const on = !btn.classList.contains('active');
|
|
_stereoDeactivateTools();
|
|
btn.classList.toggle('active', on);
|
|
if (stereoSim) stereoSim.toggleSection3P(on);
|
|
const hint = document.getElementById('sect3p-hint');
|
|
if (hint) hint.textContent = on ? 'Кликните 3 точки на рёбрах или вершинах' : '';
|
|
if (on) _stereoUpdateSection3PPanel();
|
|
}
|
|
|
|
function stereoSection3PClear() {
|
|
if (stereoSim) stereoSim.clearSection3P();
|
|
_stereoUpdateSection3PPanel();
|
|
}
|
|
|
|
function stereoSection3PStepBy(toggle) {
|
|
const on = !toggle.classList.contains('on');
|
|
toggle.classList.toggle('on', on);
|
|
if (stereoSim) stereoSim.toggleSection3PStepBy(on);
|
|
}
|
|
|
|
function stereoSection3PNextStep() {
|
|
if (!stereoSim) return;
|
|
const max = stereoSim._section3PData ? 6 : stereoSim._section3PPicks.length;
|
|
stereoSim._section3PStep = Math.min(stereoSim._section3PStep + 1, max);
|
|
stereoSim._drawSection3P();
|
|
}
|
|
|
|
function stereoSection3PPrevStep() {
|
|
if (!stereoSim) return;
|
|
stereoSim._section3PStep = Math.max(0, stereoSim._section3PStep - 1);
|
|
stereoSim._drawSection3P();
|
|
}
|
|
|
|
function _stereoUpdateSection3PPanel() {
|
|
const panel = document.getElementById('sect3p-info');
|
|
if (!panel) return;
|
|
if (!stereoSim) { panel.innerHTML = ''; return; }
|
|
const data = stereoSim.getSection3PInfo();
|
|
const picks = stereoSim._section3PPicks;
|
|
if (!data && picks.length === 0) { panel.innerHTML = ''; return; }
|
|
|
|
const r = v => Math.round(v * 100) / 100;
|
|
const fmtV = v => `(${r(v.x)}, ${r(v.y)}, ${r(v.z)})`;
|
|
const lines = [];
|
|
|
|
picks.forEach((p, i) => lines.push(`<div style="color:#FFD166">P${i+1} = ${fmtV(p)}</div>`));
|
|
|
|
if (data) {
|
|
const { normal: n, D, typeName, area } = data;
|
|
const A = r(n.x), B = r(n.y), C = r(n.z), Dv = r(D);
|
|
const eq = `${A}x + ${B}y + ${C}z ${Dv >= 0 ? '+' : ''}${Dv} = 0`;
|
|
lines.push(`<div style="color:#EF476F;margin-top:4px">Плоскость: ${eq}</div>`);
|
|
lines.push(`<div style="color:#7BF5A4">Сечение: <b>${typeName}</b></div>`);
|
|
if (area > 0) lines.push(`<div style="color:#7BF5A4">S = ${r(area)}</div>`);
|
|
} else if (picks.length < 3) {
|
|
lines.push(`<div style="color:rgba(255,255,255,0.35)">Выбрано точек: ${picks.length}/3</div>`);
|
|
}
|
|
|
|
panel.innerHTML = lines.join('');
|
|
}
|
|
|
|
function stereoInscribed(btn) {
|
|
const on = !btn.classList.contains('active');
|
|
btn.classList.toggle('active', on);
|
|
if (stereoSim) stereoSim.toggleInscribed(on);
|
|
}
|
|
|
|
function stereoCircumscribed(btn) {
|
|
const on = !btn.classList.contains('active');
|
|
btn.classList.toggle('active', on);
|
|
if (stereoSim) stereoSim.toggleCircumscribed(on);
|
|
}
|
|
|
|
function _stereoUpdateFormulas() {
|
|
if (!stereoSim) return;
|
|
const f = stereoSim.getFormulas();
|
|
const el = document.getElementById('stereo-formulas');
|
|
if (!f || !f.formulas) { el.innerHTML = ''; return; }
|
|
const colors = ['#7BF5A4','#60a5fa','#c4b5fd','#fbbf24','#f9a8d4','#F59E0B','#EF476F'];
|
|
el.innerHTML = f.formulas.map((s, i) =>
|
|
'<div style="color:' + (colors[i % colors.length]) + '">' + s + '</div>'
|
|
).join('');
|
|
}
|
|
|
|
function _stereoUpdateUI(info) {
|
|
if (!info) return;
|
|
document.getElementById('stbar-vol').textContent = info.V !== undefined ? info.V.toFixed(2) : '—';
|
|
document.getElementById('stbar-area').textContent = info.S !== undefined ? info.S.toFixed(2) : '—';
|
|
document.getElementById('stbar-side').textContent = info.S_side !== undefined ? info.S_side.toFixed(2) : '—';
|
|
document.getElementById('stbar-h').textContent = info.h !== undefined ? info.h.toFixed(2) : '—';
|
|
document.getElementById('stbar-d').textContent = info.d !== undefined && info.d > 0 ? info.d.toFixed(2) : '—';
|
|
|
|
// Section area
|
|
const sectEl = document.getElementById('sect-area-display');
|
|
if (info.sectionArea && info.sectionArea > 0) {
|
|
sectEl.style.display = '';
|
|
sectEl.textContent = 'S сечения = ' + info.sectionArea.toFixed(2);
|
|
} else {
|
|
sectEl.style.display = 'none';
|
|
}
|
|
|
|
// Inscribed / Circumscribed radius info
|
|
const rInfo = document.getElementById('sphere-radius-info');
|
|
if (rInfo) {
|
|
const parts = [];
|
|
if (info.inscribedR != null) parts.push('r_вп = ' + info.inscribedR.toFixed(2));
|
|
if (info.circumscribedR != null) parts.push('R_оп = ' + info.circumscribedR.toFixed(2));
|
|
rInfo.textContent = parts.join(' · ');
|
|
rInfo.style.display = parts.length ? '' : 'none';
|
|
}
|
|
|
|
// Points info
|
|
_stereoUpdatePointsInfo(info);
|
|
|
|
// Section-3P panel
|
|
_stereoUpdateSection3PPanel();
|
|
}
|
|
|
|
function _stereoUpdatePointsInfo(info) {
|
|
const el = document.getElementById('points-info');
|
|
if (!el) return;
|
|
if (!info) info = stereoSim?.info();
|
|
if (!info) { el.textContent = ''; return; }
|
|
let txt = '';
|
|
if (info.customPoints > 0) txt += `Точек: ${info.customPoints}`;
|
|
if (info.connections > 0) txt += ` · Линий: ${info.connections}`;
|
|
el.textContent = txt;
|
|
}
|
|
|