feat: засечки рёбер, производные точки 3D, длины рёбер в стереометрии

This commit is contained in:
Maxim Dolgolyov
2026-04-14 13:59:32 +03:00
parent 481a9aeb02
commit fff22f7331
2 changed files with 333 additions and 1 deletions
+270
View File
@@ -53,6 +53,8 @@ class StereoSim {
else if (this._connectMode) { el.style.cursor = 'pointer'; if (!wasDrag) this._onConnectClick(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._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._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 el.style.cursor = 'grab'; else el.style.cursor = 'grab';
}); });
window.addEventListener('pointermove', e => { window.addEventListener('pointermove', e => {
@@ -115,11 +117,15 @@ class StereoSim {
this._sphereGroup = new THREE.Group(); this._sphereGroup = new THREE.Group();
this._measureGroup = new THREE.Group(); this._measureGroup = new THREE.Group();
this._gridGroup = new THREE.Group(); this._gridGroup = new THREE.Group();
this._markGroup = new THREE.Group();
this._derivedGroup = new THREE.Group();
this.scene.add(this._gridGroup); this.scene.add(this._gridGroup);
this.scene.add(this._figGroup); this.scene.add(this._figGroup);
this.scene.add(this._sectionGroup); this.scene.add(this._sectionGroup);
this.scene.add(this._sphereGroup); this.scene.add(this._sphereGroup);
this.scene.add(this._measureGroup); this.scene.add(this._measureGroup);
this.scene.add(this._markGroup);
this.scene.add(this._derivedGroup);
this.scene.add(this._labelGroup); this.scene.add(this._labelGroup);
/* state */ /* state */
@@ -176,6 +182,18 @@ class StereoSim {
this._edges = []; // [{from: Vector3, to: Vector3}] this._edges = []; // [{from: Vector3, to: Vector3}]
this._faces = []; // [[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;
this.onUpdate = null; this.onUpdate = null;
this._buildGrid(); this._buildGrid();
@@ -204,6 +222,13 @@ class StereoSim {
this._clearGroup(this._pointGroup); this._clearGroup(this._pointGroup);
this._clearGroup(this._angleGroup); this._clearGroup(this._angleGroup);
this._clearGroup(this._measureGroup); 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._buildFigure(); this._buildFigure();
this._notify(); this._notify();
} }
@@ -347,6 +372,56 @@ class StereoSim {
getCustomPoints() { return this._customPoints; } getCustomPoints() { return this._customPoints; }
getConnections() { return this._connections; } 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();
}
getFormulas() { getFormulas() {
const p = this.params; const p = this.params;
const PI = Math.PI; const PI = Math.PI;
@@ -567,6 +642,8 @@ class StereoSim {
this._drawApothemLine(); this._drawApothemLine();
this._drawDiagonals(); this._drawDiagonals();
this._drawMidpoints(); this._drawMidpoints();
this._renderEdgeMarks();
this._buildDerived3D();
} }
/* ── BOX helpers ── */ /* ── BOX helpers ── */
@@ -1027,6 +1104,15 @@ class StereoSim {
const geo = new THREE.BufferGeometry().setFromPoints(pts); const geo = new THREE.BufferGeometry().setFromPoints(pts);
const mat = new THREE.LineBasicMaterial({ color: 0xFFFFFF, transparent: true, opacity: opac, linewidth: 2 }); const mat = new THREE.LineBasicMaterial({ color: 0xFFFFFF, transparent: true, opacity: opac, linewidth: 2 });
this._figGroup.add(new THREE.Line(geo, mat)); 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);
}
} }
} }
@@ -2662,6 +2748,190 @@ class StereoSim {
} }
} }
/* ════════════════ 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() { _notify() {
if (this.onUpdate) this.onUpdate(this.info()); if (this.onUpdate) this.onUpdate(this.info());
} }
+63 -1
View File
@@ -3648,6 +3648,24 @@
<button class="gp-btn" id="stereo-circumscribed-btn" onclick="stereoCircumscribed(this)">Описанная</button> <button class="gp-btn" id="stereo-circumscribed-btn" onclick="stereoCircumscribed(this)">Описанная</button>
</div> </div>
<div class="gp-section-title" style="margin-top:8px">Метки рёбер</div>
<div style="display:flex;flex-wrap:wrap;gap:4px;margin-bottom:4px">
<button class="gp-btn" id="stereo-mark-tick-btn" onclick="stereoMarkMode('ticks',this)" title="Засечки равных рёбер — кликните на ребро (до 3 штрихов)">Засечки</button>
<button class="gp-btn" id="stereo-mark-par-btn" onclick="stereoMarkMode('parallel',this)" title="Метки параллельных рёбер — кликните на ребро (до 3 стрелок)">Параллельные</button>
<button class="gp-btn" id="stereo-edge-len-btn" onclick="stereoToggleEdgeLengths(this)" title="Показать длины всех рёбер">Длины рёбер</button>
<button class="gp-btn" onclick="stereoMarkClear()">Очистить</button>
</div>
<div class="gp-section-title" style="margin-top:8px">Производные точки</div>
<div style="display:flex;flex-wrap:wrap;gap:4px;margin-bottom:4px">
<button class="gp-btn" id="stereo-derive-mid-btn" onclick="stereoDerive('midpoint',this)" title="Середина ребра — кликните на ребро">Середина ребра</button>
<button class="gp-btn" id="stereo-derive-fc-btn" onclick="stereoDerive('face_centroid',this)" title="Центр грани — кликните на грань">Центр грани</button>
<button class="gp-btn" id="stereo-derive-alt-btn" onclick="stereoDerive('alt_foot',this)" title="Основание высоты — кликните вершину, затем точку ребра">Осн. высоты</button>
<button class="gp-btn" id="stereo-derive-cen-btn" onclick="stereoDerive('solid_centroid',this)" title="Центроид тела — вставляется автоматически">Центроид тела</button>
<button class="gp-btn" onclick="stereoDeriveUndo()">Удалить послед.</button>
<button class="gp-btn" onclick="stereoDeriveClear()">Очистить</button>
</div>
<div class="gp-section-title" style="margin-top:8px">Инструменты</div> <div class="gp-section-title" style="margin-top:8px">Инструменты</div>
<div style="display:flex;flex-wrap:wrap;gap:4px;margin-bottom:4px"> <div style="display:flex;flex-wrap:wrap;gap:4px;margin-bottom:4px">
<button class="gp-btn" id="stereo-unfold-btn" onclick="stereoUnfold(this)">Развёртка</button> <button class="gp-btn" id="stereo-unfold-btn" onclick="stereoUnfold(this)">Развёртка</button>
@@ -3680,6 +3698,10 @@
∠ двугранный: 2 точки общего ребра<br> ∠ двугранный: 2 точки общего ребра<br>
d(т<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>пл): точка, затем грань — перпендикуляр<br> d(т<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>пл): точка, затем грань — перпендикуляр<br>
∠ скрещ.: 4 точки — P1,P2 (пр.1), P3,P4 (пр.2)<br> ∠ скрещ.: 4 точки — P1,P2 (пр.1), P3,P4 (пр.2)<br>
Засечки/Парал.: кликайте рёбра (0→1→2→3→0)<br>
Середина ребра: клик на ребро<br>
Центр грани: клик на грань<br>
Осн. высоты: вершина → точка/ребро<br>
Координаты: наведите на вершину Координаты: наведите на вершину
</div> </div>
</div> </div>
@@ -8177,7 +8199,9 @@
function _stereoDeactivateTools() { function _stereoDeactivateTools() {
['stereo-measure-btn','stereo-point-btn','stereo-connect-btn', ['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'].forEach(id => { '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'].forEach(id => {
document.getElementById(id)?.classList.remove('active'); document.getElementById(id)?.classList.remove('active');
}); });
if (stereoSim) { if (stereoSim) {
@@ -8185,6 +8209,8 @@
stereoSim.togglePointMode(false); stereoSim.togglePointMode(false);
stereoSim.toggleConnectMode(false); stereoSim.toggleConnectMode(false);
stereoSim.setAngleMode(null); stereoSim.setAngleMode(null);
stereoSim.setMarkMode(null);
stereoSim.setDeriveMode(null);
} }
const hint = document.getElementById('angle-hint'); const hint = document.getElementById('angle-hint');
if (hint) hint.textContent = ''; if (hint) hint.textContent = '';
@@ -8246,6 +8272,42 @@
} }
} }
/* ── 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) { function stereoPointMode(btn) {
const on = !btn.classList.contains('active'); const on = !btn.classList.contains('active');
_stereoDeactivateTools(); _stereoDeactivateTools();