feat: засечки рёбер, производные точки 3D, длины рёбер в стереометрии
This commit is contained in:
@@ -53,6 +53,8 @@ class StereoSim {
|
||||
else if (this._connectMode) { el.style.cursor = 'pointer'; if (!wasDrag) this._onConnectClick(e); }
|
||||
else if (this._measureMode) { el.style.cursor = 'crosshair'; if (!wasDrag) this._onMeasureClick(e); }
|
||||
else if (this._angleMode) { el.style.cursor = 'crosshair'; if (!wasDrag) this._onAngleClick(e); }
|
||||
else if (this._markMode) { el.style.cursor = 'pointer'; if (!wasDrag) this._onMarkClick(e); }
|
||||
else if (this._deriveMode) { el.style.cursor = 'crosshair'; if (!wasDrag) this._onDeriveClick(e); }
|
||||
else el.style.cursor = 'grab';
|
||||
});
|
||||
window.addEventListener('pointermove', e => {
|
||||
@@ -115,11 +117,15 @@ class StereoSim {
|
||||
this._sphereGroup = new THREE.Group();
|
||||
this._measureGroup = 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._figGroup);
|
||||
this.scene.add(this._sectionGroup);
|
||||
this.scene.add(this._sphereGroup);
|
||||
this.scene.add(this._measureGroup);
|
||||
this.scene.add(this._markGroup);
|
||||
this.scene.add(this._derivedGroup);
|
||||
this.scene.add(this._labelGroup);
|
||||
|
||||
/* state */
|
||||
@@ -176,6 +182,18 @@ class StereoSim {
|
||||
this._edges = []; // [{from: Vector3, to: Vector3}]
|
||||
this._faces = []; // [[Vector3, ...]]
|
||||
|
||||
/* edge marks (tick / parallel) — аналог _drawTickMark() из планиметрии */
|
||||
this._edgeMarks = {}; // { edgeIdx: { ticks: 0-3, parallel: 0-3 } }
|
||||
this._markMode = null; // 'ticks' | 'parallel' | null
|
||||
|
||||
/* derived 3D constructions — аналог midpoint/altitude_foot/centroid из планиметрии */
|
||||
this._derived3D = []; // [{type, ...args}]
|
||||
this._deriveMode = null; // 'midpoint'|'face_centroid'|'alt_foot'|'solid_centroid'|null
|
||||
this._derivePicks = [];
|
||||
|
||||
/* edge length labels */
|
||||
this.showEdgeLengths = false;
|
||||
|
||||
this.onUpdate = null;
|
||||
|
||||
this._buildGrid();
|
||||
@@ -204,6 +222,13 @@ class StereoSim {
|
||||
this._clearGroup(this._pointGroup);
|
||||
this._clearGroup(this._angleGroup);
|
||||
this._clearGroup(this._measureGroup);
|
||||
this._edgeMarks = {};
|
||||
this._markMode = null;
|
||||
this._clearGroup(this._markGroup);
|
||||
this._derived3D = [];
|
||||
this._deriveMode = null;
|
||||
this._derivePicks = [];
|
||||
this._clearGroup(this._derivedGroup);
|
||||
this._buildFigure();
|
||||
this._notify();
|
||||
}
|
||||
@@ -347,6 +372,56 @@ class StereoSim {
|
||||
getCustomPoints() { return this._customPoints; }
|
||||
getConnections() { return this._connections; }
|
||||
|
||||
/* ── Edge mark mode ── */
|
||||
setMarkMode(mode) {
|
||||
// mode: 'ticks' | 'parallel' | null
|
||||
this._markMode = mode;
|
||||
this._deriveMode = null;
|
||||
this._derivePicks = [];
|
||||
this._measureMode = false;
|
||||
this._pointMode = false;
|
||||
this._connectMode = false;
|
||||
this._angleMode = null;
|
||||
this.renderer.domElement.style.cursor = mode ? 'pointer' : 'grab';
|
||||
}
|
||||
|
||||
clearMarks() {
|
||||
this._edgeMarks = {};
|
||||
this._renderEdgeMarks();
|
||||
}
|
||||
|
||||
/* ── Derived 3D constructions mode ── */
|
||||
setDeriveMode(mode) {
|
||||
// mode: 'midpoint' | 'face_centroid' | 'alt_foot' | 'solid_centroid' | null
|
||||
this._deriveMode = mode;
|
||||
this._derivePicks = [];
|
||||
this._markMode = null;
|
||||
this._measureMode = false;
|
||||
this._pointMode = false;
|
||||
this._connectMode = false;
|
||||
this._angleMode = null;
|
||||
this.renderer.domElement.style.cursor = mode ? 'crosshair' : 'grab';
|
||||
if (mode === 'solid_centroid') this._addSolidCentroid();
|
||||
}
|
||||
|
||||
clearDerived() {
|
||||
this._derived3D = [];
|
||||
this._derivePicks = [];
|
||||
this._clearGroup(this._derivedGroup);
|
||||
}
|
||||
|
||||
removeLastDerived() {
|
||||
if (!this._derived3D.length) return;
|
||||
this._derived3D.pop();
|
||||
this._buildDerived3D();
|
||||
}
|
||||
|
||||
/* ── Edge length labels ── */
|
||||
toggleEdgeLengths(on) {
|
||||
this.showEdgeLengths = on;
|
||||
this._buildFigure();
|
||||
}
|
||||
|
||||
getFormulas() {
|
||||
const p = this.params;
|
||||
const PI = Math.PI;
|
||||
@@ -567,6 +642,8 @@ class StereoSim {
|
||||
this._drawApothemLine();
|
||||
this._drawDiagonals();
|
||||
this._drawMidpoints();
|
||||
this._renderEdgeMarks();
|
||||
this._buildDerived3D();
|
||||
}
|
||||
|
||||
/* ── BOX helpers ── */
|
||||
@@ -1027,6 +1104,15 @@ class StereoSim {
|
||||
const geo = new THREE.BufferGeometry().setFromPoints(pts);
|
||||
const mat = new THREE.LineBasicMaterial({ color: 0xFFFFFF, transparent: true, opacity: opac, linewidth: 2 });
|
||||
this._figGroup.add(new THREE.Line(geo, mat));
|
||||
|
||||
if (this.showEdgeLengths) {
|
||||
const len = e.from.distanceTo(e.to);
|
||||
const mid = new THREE.Vector3().addVectors(e.from, e.to).multiplyScalar(0.5);
|
||||
const lbl = this._makeTextSprite(len.toFixed(2), '#A8E063', 44);
|
||||
lbl.position.copy(mid).add(new THREE.Vector3(0.1, 0.12, 0.1));
|
||||
lbl.scale.set(0.9, 0.4, 1);
|
||||
this._labelGroup.add(lbl);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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() {
|
||||
if (this.onUpdate) this.onUpdate(this.info());
|
||||
}
|
||||
|
||||
+63
-1
@@ -3648,6 +3648,24 @@
|
||||
<button class="gp-btn" id="stereo-circumscribed-btn" onclick="stereoCircumscribed(this)">Описанная</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-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 style="display:flex;flex-wrap:wrap;gap:4px;margin-bottom:4px">
|
||||
<button class="gp-btn" id="stereo-unfold-btn" onclick="stereoUnfold(this)">Развёртка</button>
|
||||
@@ -3680,6 +3698,10 @@
|
||||
∠ двугранный: 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>
|
||||
∠ скрещ.: 4 точки — P1,P2 (пр.1), P3,P4 (пр.2)<br>
|
||||
Засечки/Парал.: кликайте рёбра (0→1→2→3→0)<br>
|
||||
Середина ребра: клик на ребро<br>
|
||||
Центр грани: клик на грань<br>
|
||||
Осн. высоты: вершина → точка/ребро<br>
|
||||
Координаты: наведите на вершину
|
||||
</div>
|
||||
</div>
|
||||
@@ -8177,7 +8199,9 @@
|
||||
|
||||
function _stereoDeactivateTools() {
|
||||
['stereo-measure-btn','stereo-point-btn','stereo-connect-btn',
|
||||
'stereo-angle-edge-btn','stereo-angle-lp-btn','stereo-angle-dih-btn','stereo-angle-pp-btn','stereo-angle-skew-btn'].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');
|
||||
});
|
||||
if (stereoSim) {
|
||||
@@ -8185,6 +8209,8 @@
|
||||
stereoSim.togglePointMode(false);
|
||||
stereoSim.toggleConnectMode(false);
|
||||
stereoSim.setAngleMode(null);
|
||||
stereoSim.setMarkMode(null);
|
||||
stereoSim.setDeriveMode(null);
|
||||
}
|
||||
const hint = document.getElementById('angle-hint');
|
||||
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) {
|
||||
const on = !btn.classList.contains('active');
|
||||
_stereoDeactivateTools();
|
||||
|
||||
Reference in New Issue
Block a user