be4d43105e
Node.js/Express backend + vanilla JS frontend. Features: real-time collaborative whiteboard (SSE), multi-page support, LaTeX formulas, shapes/connectors, coordinate systems, number lines, compass, zoom/pan, Catmull-Rom pencil smoothing, ruler/protractor with rotation & resize controls, minimap navigation overlay, auto-measurements, multi-page thumbnails sidebar, PNG export, page templates. Student/teacher workflows: classes, assignments, library, dashboard. Mobile responsive. SQLite (better-sqlite3). Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2422 lines
87 KiB
JavaScript
2422 lines
87 KiB
JavaScript
'use strict';
|
|
|
|
/* ═══════════════════════════════════════════════════════════
|
|
StereoSim — 3D Stereometry (Three.js)
|
|
Cube, Parallelepiped, Pyramid, Tetrahedron, Cylinder,
|
|
Cone, Truncated Cone, Sphere, Prism + sections, unfold
|
|
═══════════════════════════════════════════════════════════ */
|
|
|
|
class StereoSim {
|
|
constructor(container) {
|
|
this.container = container;
|
|
this._running = false;
|
|
|
|
/* Three.js core */
|
|
this.scene = new THREE.Scene();
|
|
this.camera = new THREE.PerspectiveCamera(50, 1, 0.1, 500);
|
|
this.renderer = new THREE.WebGLRenderer({ antialias: true, alpha: true });
|
|
this.renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2));
|
|
this.renderer.setClearColor(0x0D0D1A, 1);
|
|
container.appendChild(this.renderer.domElement);
|
|
|
|
/* lighting */
|
|
this.scene.add(new THREE.AmbientLight(0xffffff, 0.55));
|
|
const dir = new THREE.DirectionalLight(0xffffff, 0.75);
|
|
dir.position.set(6, 10, 8);
|
|
this.scene.add(dir);
|
|
const fill = new THREE.DirectionalLight(0x9B5DE5, 0.2);
|
|
fill.position.set(-5, 3, -4);
|
|
this.scene.add(fill);
|
|
|
|
/* orbit camera */
|
|
this._drag = false;
|
|
this._prevX = 0; this._prevY = 0;
|
|
this._rotY = 0.6; this._rotX = 0.45;
|
|
this._dist = 14;
|
|
this._autoSpin = true;
|
|
this._idleTime = 0;
|
|
|
|
const el = this.renderer.domElement;
|
|
el.style.cursor = 'grab';
|
|
this._clickStart = null;
|
|
el.addEventListener('pointerdown', e => {
|
|
this._clickStart = { x: e.clientX, y: e.clientY };
|
|
this._drag = true; this._prevX = e.clientX; this._prevY = e.clientY;
|
|
this._autoSpin = false; this._idleTime = 0;
|
|
if (!this._pointMode && !this._connectMode && !this._measureMode && !this._angleMode) el.style.cursor = 'grabbing';
|
|
});
|
|
window.addEventListener('pointerup', e => {
|
|
const wasDrag = this._clickStart &&
|
|
(Math.abs(e.clientX - this._clickStart.x) > 4 || Math.abs(e.clientY - this._clickStart.y) > 4);
|
|
this._drag = false;
|
|
if (this._pointMode) { el.style.cursor = 'cell'; if (!wasDrag) this._onPointClick(e); }
|
|
else if (this._connectMode) { el.style.cursor = 'pointer'; if (!wasDrag) this._onConnectClick(e); }
|
|
else if (this._measureMode) { el.style.cursor = 'crosshair'; if (!wasDrag) this._onMeasureClick(e); }
|
|
else if (this._angleMode) { el.style.cursor = 'crosshair'; if (!wasDrag) this._onAngleClick(e); }
|
|
else el.style.cursor = 'grab';
|
|
});
|
|
window.addEventListener('pointermove', e => {
|
|
this._onHoverMove(e);
|
|
if (!this._drag) return;
|
|
this._rotY += (e.clientX - this._prevX) * 0.007;
|
|
this._rotX += (e.clientY - this._prevY) * 0.007;
|
|
this._rotX = Math.max(-1.4, Math.min(1.4, this._rotX));
|
|
this._prevX = e.clientX; this._prevY = e.clientY;
|
|
this._idleTime = 0;
|
|
});
|
|
el.addEventListener('wheel', e => {
|
|
e.preventDefault();
|
|
this._dist = Math.max(4, Math.min(40, this._dist + e.deltaY * 0.02));
|
|
}, { passive: false });
|
|
|
|
/* touch — orbit + pinch zoom */
|
|
this._touchDist = 0;
|
|
el.addEventListener('touchstart', e => {
|
|
if (e.touches.length === 1) {
|
|
this._drag = true; this._prevX = e.touches[0].clientX; this._prevY = e.touches[0].clientY;
|
|
this._autoSpin = false; this._idleTime = 0;
|
|
} else if (e.touches.length === 2) {
|
|
this._drag = false;
|
|
const dx = e.touches[0].clientX - e.touches[1].clientX;
|
|
const dy = e.touches[0].clientY - e.touches[1].clientY;
|
|
this._touchDist = Math.sqrt(dx * dx + dy * dy);
|
|
}
|
|
}, { passive: true });
|
|
el.addEventListener('touchmove', e => {
|
|
if (e.touches.length === 2) {
|
|
// pinch zoom
|
|
const dx = e.touches[0].clientX - e.touches[1].clientX;
|
|
const dy = e.touches[0].clientY - e.touches[1].clientY;
|
|
const newDist = Math.sqrt(dx * dx + dy * dy);
|
|
if (this._touchDist > 0) {
|
|
const scale = this._touchDist / newDist;
|
|
this._dist = Math.max(4, Math.min(40, this._dist * scale));
|
|
}
|
|
this._touchDist = newDist;
|
|
return;
|
|
}
|
|
if (!this._drag || e.touches.length !== 1) return;
|
|
const t = e.touches[0];
|
|
this._rotY += (t.clientX - this._prevX) * 0.007;
|
|
this._rotX += (t.clientY - this._prevY) * 0.007;
|
|
this._rotX = Math.max(-1.4, Math.min(1.4, this._rotX));
|
|
this._prevX = t.clientX; this._prevY = t.clientY;
|
|
}, { passive: true });
|
|
el.addEventListener('touchend', () => { this._drag = false; this._touchDist = 0; });
|
|
|
|
/* resize */
|
|
this._ro = new ResizeObserver(() => this.fit());
|
|
this._ro.observe(container);
|
|
|
|
/* groups */
|
|
this._figGroup = new THREE.Group();
|
|
this._labelGroup = new THREE.Group();
|
|
this._sectionGroup = new THREE.Group();
|
|
this._sphereGroup = new THREE.Group();
|
|
this._measureGroup = new THREE.Group();
|
|
this._gridGroup = 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._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, ...]]
|
|
|
|
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._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 = [];
|
|
if (!on) { this._clearGroup(this._measureGroup); this._measurements = []; }
|
|
this.renderer.domElement.style.cursor = on ? 'crosshair' : 'grab';
|
|
}
|
|
|
|
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; }
|
|
|
|
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}`] };
|
|
}
|
|
default: return { V: 0, S: 0, S_side: 0, d: 0, h: 0, formulas: [] };
|
|
}
|
|
}
|
|
|
|
getSectionArea() {
|
|
if (!this.showSection || !this._sectionPolygon || !this._sectionPolygon.length) return 0;
|
|
return this._polygonArea(this._sectionPolygon);
|
|
}
|
|
|
|
info() {
|
|
const f = this.getFormulas();
|
|
return {
|
|
type: this.figureType,
|
|
V: f.V, S: f.S, S_side: f.S_side, d: f.d, h: f.h,
|
|
sectionArea: this.showSection ? this.getSectionArea() : null,
|
|
inscribedR: this.showInscribed ? this._inscribedRadius() : null,
|
|
circumscribedR: this.showCircumscribed ? this._circumscribedRadius() : null,
|
|
customPoints: this._customPoints.length,
|
|
connections: this._connections.length,
|
|
};
|
|
}
|
|
|
|
fit() {
|
|
const w = this.container.clientWidth || 600;
|
|
const h = this.container.clientHeight || 400;
|
|
this.camera.aspect = w / h;
|
|
this.camera.updateProjectionMatrix();
|
|
this.renderer.setSize(w, h);
|
|
}
|
|
|
|
play() { if (!this._running) { this._running = true; this._loop(); } }
|
|
stop() { this._running = false; }
|
|
pause() { this._running = false; }
|
|
|
|
/* ════════════════ GRID + AXES ════════════════ */
|
|
|
|
_buildGrid() {
|
|
this._clearGroup(this._gridGroup);
|
|
if (this.showGrid) {
|
|
const grid = new THREE.GridHelper(20, 20, 0x222244, 0x151530);
|
|
grid.position.y = -0.01;
|
|
this._gridGroup.add(grid);
|
|
}
|
|
if (this.showAxes) {
|
|
const axes = new THREE.AxesHelper(6);
|
|
axes.material.transparent = true;
|
|
axes.material.opacity = 0.4;
|
|
this._gridGroup.add(axes);
|
|
}
|
|
}
|
|
|
|
/* ════════════════ FIGURE BUILDER ════════════════ */
|
|
|
|
_buildFigure() {
|
|
this._clearGroup(this._figGroup);
|
|
this._clearGroup(this._labelGroup);
|
|
this._vertices = [];
|
|
this._edges = [];
|
|
this._faces = [];
|
|
|
|
const builders = {
|
|
cube: () => this._buildCube(),
|
|
parallelepiped: () => this._buildParallelepiped(),
|
|
pyramid: () => this._buildPyramid(),
|
|
tetrahedron: () => this._buildTetrahedron(),
|
|
cylinder: () => this._buildCylinder(),
|
|
cone: () => this._buildCone(),
|
|
trunccone: () => this._buildTruncCone(),
|
|
sphere: () => this._buildSphere(),
|
|
prism: () => this._buildPrism(),
|
|
};
|
|
(builders[this.figureType] || builders.cube)();
|
|
|
|
this._updateSection();
|
|
this._updateSpheres();
|
|
this._drawHeightLine();
|
|
this._drawApothemLine();
|
|
this._drawDiagonals();
|
|
this._drawMidpoints();
|
|
}
|
|
|
|
/* ── 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: two vertical lines + center
|
|
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) });
|
|
|
|
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 });
|
|
|
|
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)),
|
|
});
|
|
}
|
|
|
|
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();
|
|
}
|
|
|
|
/* ════════════════ 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;
|
|
}
|
|
|
|
_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));
|
|
}
|
|
}
|
|
|
|
_addVerticesAndLabels() {
|
|
for (const v of this._vertices) {
|
|
if (this.showVertices) {
|
|
const sGeo = new THREE.SphereGeometry(0.08, 12, 12);
|
|
const sMat = new THREE.MeshBasicMaterial({ color: 0xFFFFFF });
|
|
const sphere = new THREE.Mesh(sGeo, sMat);
|
|
sphere.position.copy(v.pos);
|
|
this._figGroup.add(sphere);
|
|
}
|
|
|
|
if (this.showLabels) {
|
|
const sprite = this._makeTextSprite(v.label);
|
|
sprite.position.copy(v.pos).add(new THREE.Vector3(0.15, 0.25, 0));
|
|
this._labelGroup.add(sprite);
|
|
}
|
|
}
|
|
}
|
|
|
|
_makeTextSprite(text, color = '#ffffff', size = 64) {
|
|
const canvas = document.createElement('canvas');
|
|
canvas.width = 128; canvas.height = 64;
|
|
const ctx = canvas.getContext('2d');
|
|
ctx.font = `bold ${size}px Manrope, sans-serif`;
|
|
ctx.fillStyle = color;
|
|
ctx.textAlign = 'center';
|
|
ctx.textBaseline = 'middle';
|
|
ctx.fillText(text, 64, 32);
|
|
|
|
const tex = new THREE.CanvasTexture(canvas);
|
|
tex.minFilter = THREE.LinearFilter;
|
|
const mat = new THREE.SpriteMaterial({ map: tex, transparent: true, depthTest: false });
|
|
const sprite = new THREE.Sprite(mat);
|
|
sprite.scale.set(0.8, 0.4, 1);
|
|
return sprite;
|
|
}
|
|
|
|
/* ════════════════ SECTION PLANE ════════════════ */
|
|
|
|
_updateSection() {
|
|
this._clearGroup(this._sectionGroup);
|
|
this._sectionPolygon = null;
|
|
if (!this.showSection) return;
|
|
|
|
const figH = this._figureHeight();
|
|
const t = this.sectionType;
|
|
|
|
if (t === 'horizontal') {
|
|
const y = figH * this.sectionHeight;
|
|
this._sectionPolygon = this._sliceAtY(y);
|
|
if (this._sectionPolygon.length >= 3) {
|
|
this._drawSectionPolygon(this._sectionPolygon, y);
|
|
// translucent plane
|
|
const planeGeo = new THREE.PlaneGeometry(16, 16);
|
|
const planeMat = new THREE.MeshBasicMaterial({ color: 0x06D6E0, transparent: true, opacity: 0.08, side: THREE.DoubleSide });
|
|
const planeMesh = new THREE.Mesh(planeGeo, planeMat);
|
|
planeMesh.rotation.x = -Math.PI/2;
|
|
planeMesh.position.y = y;
|
|
this._sectionGroup.add(planeMesh);
|
|
}
|
|
} else if (t === 'diagonal') {
|
|
this._sectionPolygon = this._sliceDiagonal();
|
|
if (this._sectionPolygon.length >= 3) {
|
|
this._drawSectionPolygon3D(this._sectionPolygon);
|
|
this._drawSectionInfo(this._sectionPolygon);
|
|
}
|
|
} else if (t === 'custom') {
|
|
// Need ≥3 custom points to define a plane
|
|
if (this._customPoints.length >= 3) {
|
|
const pts3 = this._customPoints.slice(0, 3).map(p => p.pos);
|
|
this._sectionPolygon = this._sliceByPlane(pts3[0], pts3[1], pts3[2]);
|
|
if (this._sectionPolygon.length >= 3) {
|
|
this._drawSectionPolygon3D(this._sectionPolygon);
|
|
this._drawSectionInfo(this._sectionPolygon);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
_figureHeight() {
|
|
const p = this.params;
|
|
switch (this.figureType) {
|
|
case 'cube': return p.a;
|
|
case 'parallelepiped': return p.c;
|
|
case 'pyramid': case 'prism': 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;
|
|
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': {
|
|
// r_in = apothem of base (if h >= 2*apothem), else h/2
|
|
const { a, h, n } = p;
|
|
const apothem = a / (2 * Math.tan(PI / n));
|
|
return Math.min(apothem, h / 2);
|
|
}
|
|
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': {
|
|
// R = sqrt(R_base² + (h/2)²)
|
|
const { a, h, n } = p;
|
|
const Rb = a / (2 * Math.sin(PI / n));
|
|
return Math.sqrt(Rb ** 2 + (h / 2) ** 2);
|
|
}
|
|
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;
|
|
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;
|
|
default: return this._figureHeight() / 2;
|
|
}
|
|
}
|
|
|
|
_updateSpheres() {
|
|
this._clearGroup(this._sphereGroup);
|
|
|
|
if (this.showInscribed) {
|
|
const r = this._inscribedRadius();
|
|
const cy = this._inscribedCenter();
|
|
if (r && r > 0) {
|
|
const geo = new THREE.SphereGeometry(r, 32, 24);
|
|
const mat = new THREE.MeshPhysicalMaterial({
|
|
color: 0x06D6E0, transparent: true, opacity: 0.12,
|
|
side: THREE.DoubleSide, depthWrite: false,
|
|
});
|
|
const mesh = new THREE.Mesh(geo, mat);
|
|
mesh.position.y = cy;
|
|
this._sphereGroup.add(mesh);
|
|
const wf = new THREE.LineSegments(
|
|
new THREE.WireframeGeometry(geo),
|
|
new THREE.LineBasicMaterial({ color: 0x06D6E0, transparent: true, opacity: 0.2 })
|
|
);
|
|
wf.position.y = cy;
|
|
this._sphereGroup.add(wf);
|
|
// radius label
|
|
const lbl = this._makeTextSprite(`r=${r.toFixed(2)}`, '#06D6E0', 36);
|
|
lbl.position.set(r * 0.5, cy + r * 0.3, 0);
|
|
lbl.scale.set(1.0, 0.4, 1);
|
|
this._sphereGroup.add(lbl);
|
|
// radius line
|
|
const lineGeo = new THREE.BufferGeometry().setFromPoints([
|
|
new THREE.Vector3(0, cy, 0), new THREE.Vector3(r, cy, 0)
|
|
]);
|
|
const lineMat = new THREE.LineDashedMaterial({ color: 0x06D6E0, dashSize: 0.1, gapSize: 0.06, transparent: true, opacity: 0.7 });
|
|
const line = new THREE.Line(lineGeo, lineMat);
|
|
line.computeLineDistances();
|
|
this._sphereGroup.add(line);
|
|
}
|
|
}
|
|
|
|
if (this.showCircumscribed) {
|
|
const R = this._circumscribedRadius();
|
|
const cy = this._circumscribedCenter();
|
|
if (R && R > 0) {
|
|
const geo = new THREE.SphereGeometry(R, 32, 24);
|
|
const mat = new THREE.MeshPhysicalMaterial({
|
|
color: 0xF59E0B, transparent: true, opacity: 0.08,
|
|
side: THREE.DoubleSide, depthWrite: false,
|
|
});
|
|
const mesh = new THREE.Mesh(geo, mat);
|
|
mesh.position.y = cy;
|
|
this._sphereGroup.add(mesh);
|
|
const wf = new THREE.LineSegments(
|
|
new THREE.WireframeGeometry(geo),
|
|
new THREE.LineBasicMaterial({ color: 0xF59E0B, transparent: true, opacity: 0.15 })
|
|
);
|
|
wf.position.y = cy;
|
|
this._sphereGroup.add(wf);
|
|
// radius label
|
|
const lbl = this._makeTextSprite(`R=${R.toFixed(2)}`, '#F59E0B', 36);
|
|
lbl.position.set(R * 0.5, cy + R * 0.3, 0);
|
|
lbl.scale.set(1.0, 0.4, 1);
|
|
this._sphereGroup.add(lbl);
|
|
// radius line
|
|
const lineGeo = new THREE.BufferGeometry().setFromPoints([
|
|
new THREE.Vector3(0, cy, 0), new THREE.Vector3(R, cy, 0)
|
|
]);
|
|
const lineMat = new THREE.LineDashedMaterial({ color: 0xF59E0B, dashSize: 0.1, gapSize: 0.06, transparent: true, opacity: 0.7 });
|
|
const line = new THREE.Line(lineGeo, lineMat);
|
|
line.computeLineDistances();
|
|
this._sphereGroup.add(line);
|
|
}
|
|
}
|
|
}
|
|
|
|
/* ════════════════ 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 picked point
|
|
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._measureGroup.add(s);
|
|
|
|
if (this._measurePicks.length === 2) {
|
|
const [a, b] = this._measurePicks;
|
|
const dist = a.pos.distanceTo(b.pos);
|
|
const mid = new THREE.Vector3().addVectors(a.pos, b.pos).multiplyScalar(0.5);
|
|
|
|
// Dashed line
|
|
const pts = [a.pos, b.pos];
|
|
const lineGeo = new THREE.BufferGeometry().setFromPoints(pts);
|
|
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();
|
|
this._measureGroup.add(line);
|
|
|
|
// Label
|
|
const label = this._makeTextSprite(`${a.label}${b.label} = ${dist.toFixed(2)}`, '#FFD166', 40);
|
|
label.position.copy(mid).add(new THREE.Vector3(0, 0.3, 0));
|
|
label.scale.set(1.4, 0.5, 1);
|
|
this._measureGroup.add(label);
|
|
|
|
this._measurements.push({ from: a.label, to: b.label, dist: Math.round(dist * 100) / 100 });
|
|
this._measurePicks = [];
|
|
}
|
|
}
|
|
|
|
/* ════════════════ 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 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);
|
|
}
|
|
}
|
|
|
|
/* ── Edge angle: pick 3 points (B is vertex, angle ∠ABC) ── */
|
|
_onEdgeAngleClick(e) {
|
|
const pick = this._pickNearestPoint(e);
|
|
if (!pick) return;
|
|
|
|
this._anglePicks.push(pick);
|
|
this._highlightPick(pick.pos, this._anglePicks.length === 2 ? 0xEF476F : 0xFFD166);
|
|
|
|
if (this._anglePicks.length === 3) {
|
|
const [A, B, C] = this._anglePicks;
|
|
const BA = new THREE.Vector3().subVectors(A.pos, B.pos);
|
|
const BC = new THREE.Vector3().subVectors(C.pos, B.pos);
|
|
const cosAngle = BA.dot(BC) / (BA.length() * BC.length());
|
|
const angle = Math.acos(Math.max(-1, Math.min(1, cosAngle))) * 180 / Math.PI;
|
|
|
|
// Draw the angle arc
|
|
this._drawAngleArc(B.pos, BA, BC, angle, 0.6, '#EF476F');
|
|
|
|
// Label
|
|
const bisect = new THREE.Vector3().addVectors(
|
|
BA.clone().normalize(), BC.clone().normalize()
|
|
).normalize().multiplyScalar(0.8);
|
|
const lblPos = B.pos.clone().add(bisect);
|
|
const lbl = this._makeTextSprite(
|
|
`∠${A.label}${B.label}${C.label} = ${angle.toFixed(1)}°`, '#EF476F', 36
|
|
);
|
|
lbl.position.copy(lblPos);
|
|
lbl.scale.set(2.0, 0.5, 1);
|
|
this._angleGroup.add(lbl);
|
|
|
|
// Lines
|
|
this._drawAngleLine(B.pos, A.pos, '#EF476F');
|
|
this._drawAngleLine(B.pos, C.pos, '#EF476F');
|
|
|
|
this._anglePicks = [];
|
|
}
|
|
}
|
|
|
|
/* ── Line-Plane angle: pick 2 points (line), then 1 face ── */
|
|
_onLinePlaneAngleClick(e) {
|
|
if (this._anglePicks.length < 2) {
|
|
// Picking line endpoints
|
|
const pick = this._pickNearestPoint(e);
|
|
if (!pick) return;
|
|
this._anglePicks.push(pick);
|
|
this._highlightPick(pick.pos);
|
|
|
|
if (this._anglePicks.length === 2) {
|
|
// Draw the line
|
|
this._drawAngleLine(this._anglePicks[0].pos, this._anglePicks[1].pos, '#60a5fa');
|
|
}
|
|
} else {
|
|
// Pick a face
|
|
const face = this._pickNearestFace(e);
|
|
if (!face || face.length < 3) return;
|
|
|
|
const [A, B] = this._anglePicks;
|
|
const lineDir = new THREE.Vector3().subVectors(B.pos, A.pos).normalize();
|
|
|
|
// Face normal
|
|
const v1 = new THREE.Vector3().subVectors(face[1], face[0]);
|
|
const v2 = new THREE.Vector3().subVectors(face[2], face[0]);
|
|
const normal = new THREE.Vector3().crossVectors(v1, v2).normalize();
|
|
|
|
// Angle between line and plane = 90° - angle between line and normal
|
|
const sinAngle = Math.abs(lineDir.dot(normal));
|
|
const angle = Math.asin(Math.max(0, Math.min(1, sinAngle))) * 180 / Math.PI;
|
|
|
|
// Highlight face
|
|
const positions = [];
|
|
const indices = [];
|
|
face.forEach(v => positions.push(v.x, v.y, v.z));
|
|
for (let i = 1; i < face.length - 1; i++) indices.push(0, i, i + 1);
|
|
const geo = new THREE.BufferGeometry();
|
|
geo.setAttribute('position', new THREE.Float32BufferAttribute(positions, 3));
|
|
geo.setIndex(indices);
|
|
const mat = new THREE.MeshBasicMaterial({ color: 0x60a5fa, transparent: true, opacity: 0.2, side: THREE.DoubleSide });
|
|
this._angleGroup.add(new THREE.Mesh(geo, mat));
|
|
|
|
// Label at face centroid
|
|
const c = new THREE.Vector3();
|
|
face.forEach(v => c.add(v));
|
|
c.divideScalar(face.length);
|
|
const lbl = this._makeTextSprite(
|
|
`∠(${A.label}${B.label}, пл) = ${angle.toFixed(1)}°`, '#60a5fa', 36
|
|
);
|
|
lbl.position.copy(c).add(new THREE.Vector3(0, 0.5, 0));
|
|
lbl.scale.set(2.4, 0.5, 1);
|
|
this._angleGroup.add(lbl);
|
|
|
|
// Draw projection of line onto plane
|
|
const proj = lineDir.clone().sub(normal.clone().multiplyScalar(lineDir.dot(normal))).normalize();
|
|
const projEnd = A.pos.clone().add(proj.multiplyScalar(A.pos.distanceTo(B.pos)));
|
|
this._drawAngleLine(A.pos, projEnd, '#60a5fa', true);
|
|
|
|
this._anglePicks = [];
|
|
}
|
|
}
|
|
|
|
/* ── Dihedral angle: pick 2 points of shared edge, then auto-find adjacent faces ── */
|
|
_onDihedralAngleClick(e) {
|
|
const pick = this._pickNearestPoint(e);
|
|
if (!pick) return;
|
|
|
|
this._anglePicks.push(pick);
|
|
this._highlightPick(pick.pos, 0xc4b5fd);
|
|
|
|
if (this._anglePicks.length === 2) {
|
|
const [P1, P2] = this._anglePicks;
|
|
const edgeDir = new THREE.Vector3().subVectors(P2.pos, P1.pos);
|
|
|
|
// Find two faces sharing this edge (both contain P1 and P2)
|
|
const adjFaces = [];
|
|
const eps = 0.1;
|
|
for (const face of this._faces) {
|
|
let hasP1 = false, hasP2 = false;
|
|
for (const v of face) {
|
|
if (v.distanceTo(P1.pos) < eps) hasP1 = true;
|
|
if (v.distanceTo(P2.pos) < eps) hasP2 = true;
|
|
}
|
|
if (hasP1 && hasP2) adjFaces.push(face);
|
|
}
|
|
|
|
if (adjFaces.length >= 2) {
|
|
// Compute normals of both faces
|
|
const normal1 = this._faceNormal(adjFaces[0]);
|
|
const normal2 = this._faceNormal(adjFaces[1]);
|
|
|
|
// Dihedral angle = π - angle between outward normals
|
|
const cosAngle = normal1.dot(normal2);
|
|
const rawAngle = Math.acos(Math.max(-1, Math.min(1, cosAngle))) * 180 / Math.PI;
|
|
// Dihedral is the interior angle
|
|
const dihedralAngle = 180 - rawAngle;
|
|
|
|
// Highlight both faces
|
|
for (const face of [adjFaces[0], adjFaces[1]]) {
|
|
const positions = [];
|
|
const indices = [];
|
|
face.forEach(v => positions.push(v.x, v.y, v.z));
|
|
for (let i = 1; i < face.length - 1; i++) indices.push(0, i, i + 1);
|
|
const geo = new THREE.BufferGeometry();
|
|
geo.setAttribute('position', new THREE.Float32BufferAttribute(positions, 3));
|
|
geo.setIndex(indices);
|
|
const mat = new THREE.MeshBasicMaterial({ color: 0xc4b5fd, transparent: true, opacity: 0.2, side: THREE.DoubleSide });
|
|
this._angleGroup.add(new THREE.Mesh(geo, mat));
|
|
}
|
|
|
|
// Label at edge midpoint
|
|
const mid = new THREE.Vector3().addVectors(P1.pos, P2.pos).multiplyScalar(0.5);
|
|
const lbl = this._makeTextSprite(
|
|
`∠дв(${P1.label}${P2.label}) = ${dihedralAngle.toFixed(1)}°`, '#c4b5fd', 36
|
|
);
|
|
lbl.position.copy(mid).add(new THREE.Vector3(0, 0.5, 0.3));
|
|
lbl.scale.set(2.4, 0.5, 1);
|
|
this._angleGroup.add(lbl);
|
|
|
|
// Draw the edge
|
|
this._drawAngleLine(P1.pos, P2.pos, '#c4b5fd');
|
|
} else {
|
|
// Not enough adjacent faces — show error sprite
|
|
const mid = new THREE.Vector3().addVectors(P1.pos, P2.pos).multiplyScalar(0.5);
|
|
const lbl = this._makeTextSprite('Нет общего ребра', '#ff6b6b', 36);
|
|
lbl.position.copy(mid).add(new THREE.Vector3(0, 0.5, 0));
|
|
lbl.scale.set(2.0, 0.5, 1);
|
|
this._angleGroup.add(lbl);
|
|
}
|
|
|
|
this._anglePicks = [];
|
|
}
|
|
}
|
|
|
|
_faceNormal(face) {
|
|
if (face.length < 3) return new THREE.Vector3(0, 1, 0);
|
|
const v1 = new THREE.Vector3().subVectors(face[1], face[0]);
|
|
const v2 = new THREE.Vector3().subVectors(face[2], face[0]);
|
|
return new THREE.Vector3().crossVectors(v1, v2).normalize();
|
|
}
|
|
|
|
_drawAngleArc(center, dir1, dir2, angleDeg, radius, color) {
|
|
const n1 = dir1.clone().normalize();
|
|
const n2 = dir2.clone().normalize();
|
|
const angleRad = angleDeg * Math.PI / 180;
|
|
const steps = Math.max(8, Math.round(angleDeg / 5));
|
|
const pts = [];
|
|
|
|
// Build rotation from n1 toward n2
|
|
const axis = new THREE.Vector3().crossVectors(n1, n2).normalize();
|
|
if (axis.length() < 0.001) return; // parallel vectors
|
|
|
|
for (let i = 0; i <= steps; i++) {
|
|
const t = i / steps;
|
|
const a = t * angleRad;
|
|
const rotated = n1.clone().applyAxisAngle(axis, a).multiplyScalar(radius);
|
|
pts.push(center.clone().add(rotated));
|
|
}
|
|
|
|
const geo = new THREE.BufferGeometry().setFromPoints(pts);
|
|
const mat = new THREE.LineBasicMaterial({ color: new THREE.Color(color), transparent: true, opacity: 0.8 });
|
|
this._angleGroup.add(new THREE.Line(geo, mat));
|
|
}
|
|
|
|
_drawAngleLine(from, to, color, dashed = false) {
|
|
const geo = new THREE.BufferGeometry().setFromPoints([from, to]);
|
|
let mat;
|
|
if (dashed) {
|
|
mat = new THREE.LineDashedMaterial({
|
|
color: new THREE.Color(color), dashSize: 0.12, gapSize: 0.06,
|
|
transparent: true, opacity: 0.7,
|
|
});
|
|
} else {
|
|
mat = new THREE.LineBasicMaterial({ color: new THREE.Color(color), transparent: true, opacity: 0.7 });
|
|
}
|
|
const line = new THREE.Line(geo, mat);
|
|
if (dashed) line.computeLineDistances();
|
|
this._angleGroup.add(line);
|
|
}
|
|
|
|
/* ════════════════ DIAGONALS ════════════════ */
|
|
|
|
_drawDiagonals() {
|
|
if (!this.showDiagonals) return;
|
|
const t = this.figureType;
|
|
|
|
if (t === 'cube' || t === 'parallelepiped') {
|
|
// 8 vertices: 0-3 bottom, 4-7 top (from _buildBox)
|
|
const v = this._vertices.map(vt => vt.pos);
|
|
if (v.length < 8) return;
|
|
|
|
// Space diagonals (4)
|
|
const spaceDiags = [[0,6],[1,7],[2,4],[3,5]];
|
|
for (const [a, b] of spaceDiags) {
|
|
this._drawDashedSegment(v[a], v[b], '', '#fbbf24');
|
|
}
|
|
|
|
// Face diagonals (12) — 2 per face
|
|
const faceDiags = [
|
|
[0,2],[1,3], // bottom
|
|
[4,6],[5,7], // top
|
|
[0,5],[1,4], // front
|
|
[2,7],[3,6], // back
|
|
[1,6],[2,5], // right
|
|
[0,7],[3,4], // left
|
|
];
|
|
for (const [a, b] of faceDiags) {
|
|
const lineGeo = new THREE.BufferGeometry().setFromPoints([v[a], v[b]]);
|
|
const lineMat = new THREE.LineDashedMaterial({
|
|
color: 0xF59E0B, dashSize: 0.1, gapSize: 0.06,
|
|
transparent: true, opacity: 0.35,
|
|
});
|
|
const line = new THREE.Line(lineGeo, lineMat);
|
|
line.computeLineDistances();
|
|
this._figGroup.add(line);
|
|
}
|
|
|
|
// Space diagonal label — only one, the longest
|
|
const d = v[0].distanceTo(v[6]);
|
|
const mid = new THREE.Vector3().addVectors(v[0], v[6]).multiplyScalar(0.5);
|
|
const lbl = this._makeTextSprite(`d = ${d.toFixed(2)}`, '#fbbf24', 34);
|
|
lbl.position.copy(mid).add(new THREE.Vector3(0.3, 0.2, 0));
|
|
lbl.scale.set(1.2, 0.4, 1);
|
|
this._labelGroup.add(lbl);
|
|
|
|
} else if (t === 'prism') {
|
|
const n = this.params.n;
|
|
const v = this._vertices.map(vt => vt.pos);
|
|
if (v.length < n * 2) return;
|
|
|
|
// Base diagonals (bottom)
|
|
for (let i = 0; i < n; i++) {
|
|
for (let j = i + 2; j < n; j++) {
|
|
if (j === (i + n - 1) % n + i) continue; // skip adjacent
|
|
if (i === 0 && j === n - 1) continue;
|
|
const lineGeo = new THREE.BufferGeometry().setFromPoints([v[i], v[j]]);
|
|
const lineMat = new THREE.LineDashedMaterial({
|
|
color: 0xF59E0B, dashSize: 0.1, gapSize: 0.06,
|
|
transparent: true, opacity: 0.35,
|
|
});
|
|
const line = new THREE.Line(lineGeo, lineMat);
|
|
line.computeLineDistances();
|
|
this._figGroup.add(line);
|
|
}
|
|
}
|
|
// Top diagonals
|
|
for (let i = n; i < 2 * n; i++) {
|
|
for (let j = i + 2; j < 2 * n; j++) {
|
|
if (i === n && j === 2 * n - 1) continue;
|
|
const lineGeo = new THREE.BufferGeometry().setFromPoints([v[i], v[j]]);
|
|
const lineMat = new THREE.LineDashedMaterial({
|
|
color: 0xF59E0B, dashSize: 0.1, gapSize: 0.06,
|
|
transparent: true, opacity: 0.35,
|
|
});
|
|
const line = new THREE.Line(lineGeo, lineMat);
|
|
line.computeLineDistances();
|
|
this._figGroup.add(line);
|
|
}
|
|
}
|
|
// Space diagonals: connect bottom[i] to top[(i+k)%n] for k≠0
|
|
for (let i = 0; i < n; i++) {
|
|
for (let k = 1; k < n; k++) {
|
|
const j = n + (i + k) % n;
|
|
this._drawDashedSegment(v[i], v[j], '', '#fbbf24');
|
|
}
|
|
}
|
|
|
|
} else if (t === 'pyramid') {
|
|
// Base diagonals only
|
|
const n = this.params.n;
|
|
const v = this._vertices.map(vt => vt.pos);
|
|
for (let i = 0; i < n; i++) {
|
|
for (let j = i + 2; j < n; j++) {
|
|
if (i === 0 && j === n - 1) continue;
|
|
const lineGeo = new THREE.BufferGeometry().setFromPoints([v[i], v[j]]);
|
|
const lineMat = new THREE.LineDashedMaterial({
|
|
color: 0xF59E0B, dashSize: 0.1, gapSize: 0.06,
|
|
transparent: true, opacity: 0.4,
|
|
});
|
|
const line = new THREE.Line(lineGeo, lineMat);
|
|
line.computeLineDistances();
|
|
this._figGroup.add(line);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
/* ════════════════ MIDPOINTS ════════════════ */
|
|
|
|
_drawMidpoints() {
|
|
if (!this.showMidpoints) return;
|
|
|
|
for (let i = 0; i < this._edges.length; i++) {
|
|
const e = this._edges[i];
|
|
const mid = new THREE.Vector3().addVectors(e.from, e.to).multiplyScalar(0.5);
|
|
|
|
// small cyan sphere
|
|
const sGeo = new THREE.SphereGeometry(0.06, 8, 8);
|
|
const sMat = new THREE.MeshBasicMaterial({ color: 0x06D6E0 });
|
|
const s = new THREE.Mesh(sGeo, sMat);
|
|
s.position.copy(mid);
|
|
this._figGroup.add(s);
|
|
|
|
// label "M" + index
|
|
const lbl = this._makeTextSprite(`M${i + 1}`, '#06D6E0', 28);
|
|
lbl.position.copy(mid).add(new THREE.Vector3(0.15, 0.15, 0));
|
|
lbl.scale.set(0.5, 0.25, 1);
|
|
this._labelGroup.add(lbl);
|
|
}
|
|
}
|
|
|
|
/* ════════════════ POINT-TO-PLANE DISTANCE ════════════════ */
|
|
|
|
_onPointPlaneClick(e) {
|
|
if (this._anglePicks.length < 1) {
|
|
// First pick: a vertex/point
|
|
const pick = this._pickNearestPoint(e);
|
|
if (!pick) return;
|
|
this._anglePicks.push(pick);
|
|
this._highlightPick(pick.pos, 0xF9A8D4);
|
|
} else {
|
|
// Second pick: a face
|
|
const face = this._pickNearestFace(e);
|
|
if (!face || face.length < 3) return;
|
|
|
|
const pt = this._anglePicks[0];
|
|
const normal = this._faceNormal(face);
|
|
const planePoint = face[0];
|
|
|
|
// Distance = |dot(pt - planePoint, normal)|
|
|
const diff = new THREE.Vector3().subVectors(pt.pos, planePoint);
|
|
const dist = Math.abs(diff.dot(normal));
|
|
|
|
// Projection of point onto plane
|
|
const proj = pt.pos.clone().sub(normal.clone().multiplyScalar(diff.dot(normal)));
|
|
|
|
// Draw perpendicular line
|
|
this._drawAngleLine(pt.pos, proj, '#F9A8D4', true);
|
|
|
|
// Highlight foot of perpendicular
|
|
this._highlightPick(proj, 0xF9A8D4);
|
|
|
|
// Right angle marker
|
|
const edgeOnPlane = new THREE.Vector3().subVectors(face[1], face[0]).normalize();
|
|
this._drawRightAngleMarkerAt(proj, normal, edgeOnPlane, 0.25, '#F9A8D4');
|
|
|
|
// Highlight face
|
|
const positions = [];
|
|
const indices = [];
|
|
face.forEach(v => positions.push(v.x, v.y, v.z));
|
|
for (let i = 1; i < face.length - 1; i++) indices.push(0, i, i + 1);
|
|
const geo = new THREE.BufferGeometry();
|
|
geo.setAttribute('position', new THREE.Float32BufferAttribute(positions, 3));
|
|
geo.setIndex(indices);
|
|
const mat = new THREE.MeshBasicMaterial({ color: 0xF9A8D4, transparent: true, opacity: 0.15, side: THREE.DoubleSide });
|
|
this._angleGroup.add(new THREE.Mesh(geo, mat));
|
|
|
|
// Label
|
|
const mid = new THREE.Vector3().addVectors(pt.pos, proj).multiplyScalar(0.5);
|
|
const lbl = this._makeTextSprite(`d(${pt.label}, пл) = ${dist.toFixed(2)}`, '#F9A8D4', 36);
|
|
lbl.position.copy(mid).add(new THREE.Vector3(0.4, 0.2, 0));
|
|
lbl.scale.set(2.2, 0.5, 1);
|
|
this._angleGroup.add(lbl);
|
|
|
|
this._anglePicks = [];
|
|
}
|
|
}
|
|
|
|
_drawRightAngleMarkerAt(origin, normalDir, tangentDir, size, color) {
|
|
const d1 = normalDir.clone().normalize().multiplyScalar(size);
|
|
const d2 = tangentDir.clone().normalize().multiplyScalar(size);
|
|
const p1 = origin.clone().add(d1);
|
|
const p2 = origin.clone().add(d2);
|
|
const p3 = p1.clone().add(d2);
|
|
const pts = [p1, p3, p2];
|
|
const geo = new THREE.BufferGeometry().setFromPoints(pts);
|
|
const mat = new THREE.LineBasicMaterial({ color: new THREE.Color(color), transparent: true, opacity: 0.6 });
|
|
this._angleGroup.add(new THREE.Line(geo, mat));
|
|
}
|
|
|
|
/* ════════════════ COORDINATE TOOLTIP ════════════════ */
|
|
|
|
_initTooltip() {
|
|
this._tooltipEl = document.createElement('div');
|
|
Object.assign(this._tooltipEl.style, {
|
|
position: 'absolute', pointerEvents: 'none',
|
|
background: 'rgba(13,13,26,0.85)', color: '#ccc',
|
|
fontSize: '11px', fontFamily: 'Manrope, monospace',
|
|
padding: '3px 7px', borderRadius: '4px',
|
|
border: '1px solid rgba(155,93,229,0.3)',
|
|
display: 'none', zIndex: '50', whiteSpace: 'nowrap',
|
|
});
|
|
this.container.style.position = 'relative';
|
|
this.container.appendChild(this._tooltipEl);
|
|
}
|
|
|
|
_onHoverMove(e) {
|
|
const rect = this.renderer.domElement.getBoundingClientRect();
|
|
if (e.clientX < rect.left || e.clientX > rect.right ||
|
|
e.clientY < rect.top || e.clientY > rect.bottom) {
|
|
if (this._tooltipEl) this._tooltipEl.style.display = 'none';
|
|
return;
|
|
}
|
|
|
|
const mx = ((e.clientX - rect.left) / rect.width) * 2 - 1;
|
|
const my = -((e.clientY - rect.top) / rect.height) * 2 + 1;
|
|
|
|
let bestDist = 0.06;
|
|
let bestV = null;
|
|
|
|
for (const v of this._vertices) {
|
|
const proj = v.pos.clone().project(this.camera);
|
|
const d = Math.sqrt((proj.x - mx) ** 2 + (proj.y - my) ** 2);
|
|
if (d < bestDist) { bestDist = d; bestV = v; }
|
|
}
|
|
for (const cp of this._customPoints) {
|
|
const proj = cp.pos.clone().project(this.camera);
|
|
const d = Math.sqrt((proj.x - mx) ** 2 + (proj.y - my) ** 2);
|
|
if (d < bestDist) { bestDist = d; bestV = cp; }
|
|
}
|
|
|
|
if (bestV && this._tooltipEl) {
|
|
const p = bestV.pos;
|
|
this._tooltipEl.textContent = `${bestV.label} (${p.x.toFixed(1)}, ${p.y.toFixed(1)}, ${p.z.toFixed(1)})`;
|
|
this._tooltipEl.style.display = 'block';
|
|
this._tooltipEl.style.left = (e.clientX - rect.left + 14) + 'px';
|
|
this._tooltipEl.style.top = (e.clientY - rect.top - 10) + 'px';
|
|
} else if (this._tooltipEl) {
|
|
this._tooltipEl.style.display = 'none';
|
|
}
|
|
}
|
|
|
|
/* ════════════════ UNFOLD ANIMATION ════════════════ */
|
|
// Simplified unfold: flatten figure by reducing Y coordinates
|
|
_applyUnfold(progress) {
|
|
// This is a visual-only effect — squash Y toward 0
|
|
if (progress < 0.01) return;
|
|
this._figGroup.children.forEach(child => {
|
|
if (child.geometry) {
|
|
// Already built, don't modify geometry — just scale Y
|
|
}
|
|
});
|
|
this._figGroup.scale.y = 1 - progress * 0.85;
|
|
this._figGroup.position.y = progress * 0.5;
|
|
}
|
|
|
|
/* ════════════════ UTILS ════════════════ */
|
|
|
|
_clearGroup(group) {
|
|
while (group.children.length) {
|
|
const c = group.children[0];
|
|
if (c.geometry) c.geometry.dispose();
|
|
if (c.material) {
|
|
if (c.material.map) c.material.map.dispose();
|
|
c.material.dispose();
|
|
}
|
|
group.remove(c);
|
|
}
|
|
}
|
|
|
|
_notify() {
|
|
if (this.onUpdate) this.onUpdate(this.info());
|
|
}
|
|
|
|
/* ════════════════ ANIMATION LOOP ════════════════ */
|
|
|
|
_loop() {
|
|
if (!this._running) return;
|
|
requestAnimationFrame(() => this._loop());
|
|
|
|
// Auto-spin after idle
|
|
this._idleTime++;
|
|
if (this._idleTime > 300 && !this._drag) this._autoSpin = true;
|
|
if (this._autoSpin) this._rotY += 0.002;
|
|
|
|
// Unfold animation
|
|
if (this._unfold && this._unfoldProgress < this._unfoldTarget) {
|
|
this._unfoldProgress = Math.min(1, this._unfoldProgress + 0.015);
|
|
this._applyUnfold(this._unfoldProgress);
|
|
} else if (!this._unfold && this._unfoldProgress > 0) {
|
|
this._unfoldProgress = Math.max(0, this._unfoldProgress - 0.015);
|
|
this._applyUnfold(this._unfoldProgress);
|
|
if (this._unfoldProgress <= 0) {
|
|
this._figGroup.scale.y = 1;
|
|
this._figGroup.position.y = 0;
|
|
}
|
|
}
|
|
|
|
// Camera orbit
|
|
this.camera.position.set(
|
|
this._dist * Math.sin(this._rotY) * Math.cos(this._rotX),
|
|
this._dist * Math.sin(this._rotX),
|
|
this._dist * Math.cos(this._rotY) * Math.cos(this._rotX)
|
|
);
|
|
this.camera.lookAt(0, this._figureHeight() / 2, 0);
|
|
|
|
this.renderer.render(this.scene, this.camera);
|
|
}
|
|
}
|