feat(stereo3d): Фаза 6 — построение сечения «по следам» (метод следов)

Путь (b): надёжный полигон (есть) + аналитический след и вспом. точки.
- _traceLine(): след = π ∩ плоскость основания y=0 (проверено численно)
- _auxiliaryPoints(): продление сторон сечения до следа (dist=0 на следе)
- _hasBase()/_sameFace(): топология тел с основанием
- настоящий пошаговый _drawSection3PStep: 6 подписанных шагов, финал скрыт
  до шага 5 (showFull); подписи в #sect3p-hint через _stepCaption
- scope: куб, параллелепипед, призма, пирамида, усеч. пирамида, тетраэдр
- bump stereo.js?v=9

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Maxim Dolgolyov
2026-05-30 11:49:16 +03:00
parent f471463911
commit 3801d0cfa8
3 changed files with 194 additions and 57 deletions
+181 -55
View File
@@ -287,6 +287,7 @@ class StereoSim {
this._section3PStepBy = false; // step-by-step visualisation toggle
this._section3PStep = 0; // current step (0=idle, 1..6=sub-steps)
this._section3PData = null; // computed result {normal,D,polygon,area,typeName}
this._stepCaption = ''; // caption for the current trace-method step
this.onUpdate = null;
@@ -589,8 +590,10 @@ class StereoSim {
toggleSection3PStepBy(on) {
this._section3PStepBy = on;
// re-render if data already exists
if (this._section3PData) this._drawSection3P();
// entering step mode: start at the first step so the build-up is visible
if (on && this._section3PStep === 0) this._section3PStep = 1;
if (!on) this._stepCaption = '';
if (this._section3PData || this._section3PPicks.length) this._drawSection3P();
}
getSection3PInfo() {
@@ -2246,12 +2249,14 @@ class StereoSim {
this._section3PGroup.add(lbl);
});
// Draw line from P1 to P2 after 2nd pick
if (picks.length >= 2) {
// Connector triangle between picks — shown live while picking and in normal
// mode; hidden during the step build-up (step 2 draws same-face sides itself).
const showPickLines = !this._section3PStepBy;
if (showPickLines && picks.length >= 2) {
const lg1 = new THREE.BufferGeometry().setFromPoints([picks[0], picks[1]]);
this._section3PGroup.add(new THREE.Line(lg1, new THREE.LineBasicMaterial({ color: PICK_COLOR, opacity: 0.7, transparent: true })));
}
if (picks.length >= 3) {
if (showPickLines && picks.length >= 3) {
const lg2 = new THREE.BufferGeometry().setFromPoints([picks[1], picks[2]]);
this._section3PGroup.add(new THREE.Line(lg2, new THREE.LineBasicMaterial({ color: PICK_COLOR, opacity: 0.7, transparent: true })));
const lg3 = new THREE.BufferGeometry().setFromPoints([picks[2], picks[0]]);
@@ -2260,6 +2265,10 @@ class StereoSim {
if (!data || picks.length < 3) return;
// In step-by-step mode the finished section is hidden until step ≥ 5 so the
// trace construction builds up; otherwise (or at the end) show it in full.
const showFull = !this._section3PStepBy || this._section3PStep >= 5;
// Semi-transparent plane quad (large enough to show context)
const { normal, D, polygon } = data;
// Build a visible plane chip — use bounding box of polygon centroid + spread
@@ -2286,71 +2295,178 @@ class StereoSim {
const planeMat = new THREE.MeshBasicMaterial({ color: PLANE_COLOR, transparent: true, opacity: 0.08, side: THREE.DoubleSide });
this._section3PGroup.add(new THREE.Mesh(planeGeo, planeMat));
// Cross-section polygon fill
const sectPositions = [];
const sectIndices = [];
polygon.forEach(p => sectPositions.push(p.x, p.y, p.z));
for (let i = 1; i < polygon.length - 1; i++) sectIndices.push(0, i, i + 1);
const sectGeo = new THREE.BufferGeometry();
sectGeo.setAttribute('position', new THREE.Float32BufferAttribute(sectPositions, 3));
sectGeo.setIndex(sectIndices);
sectGeo.computeVertexNormals();
const sectMat = new THREE.MeshBasicMaterial({ color: SECT_COLOR, transparent: true, opacity: 0.45, side: THREE.DoubleSide });
this._section3PGroup.add(new THREE.Mesh(sectGeo, sectMat));
if (showFull) {
// Cross-section polygon fill
const sectPositions = [];
const sectIndices = [];
polygon.forEach(p => sectPositions.push(p.x, p.y, p.z));
for (let i = 1; i < polygon.length - 1; i++) sectIndices.push(0, i, i + 1);
const sectGeo = new THREE.BufferGeometry();
sectGeo.setAttribute('position', new THREE.Float32BufferAttribute(sectPositions, 3));
sectGeo.setIndex(sectIndices);
sectGeo.computeVertexNormals();
const sectMat = new THREE.MeshBasicMaterial({ color: SECT_COLOR, transparent: true, opacity: 0.45, side: THREE.DoubleSide });
this._section3PGroup.add(new THREE.Mesh(sectGeo, sectMat));
// Polygon outline (slightly offset along normal for visibility)
const outlinePts = [...polygon, polygon[0]].map(p =>
p.clone().addScaledVector(normal, 0.012)
);
const outlineGeo = new THREE.BufferGeometry().setFromPoints(outlinePts);
const outlineMat = new THREE.LineBasicMaterial({ color: SECT_COLOR, linewidth: 2 });
this._section3PGroup.add(new THREE.Line(outlineGeo, outlineMat));
// Polygon outline (slightly offset along normal for visibility)
const outlinePts = [...polygon, polygon[0]].map(p =>
p.clone().addScaledVector(normal, 0.012)
);
const outlineGeo = new THREE.BufferGeometry().setFromPoints(outlinePts);
const outlineMat = new THREE.LineBasicMaterial({ color: SECT_COLOR, linewidth: 2 });
this._section3PGroup.add(new THREE.Line(outlineGeo, outlineMat));
// Vertex markers + letter labels — only for true polygons; smooth conic
// sections skip them.
if (polygon.length <= 12) {
const LETTERS = 'KLMNPQRSTUV';
polygon.forEach((p, i) => {
const sg = new THREE.SphereGeometry(0.07, 8, 8);
const sm = new THREE.MeshBasicMaterial({ color: SECT_COLOR });
const s = new THREE.Mesh(sg, sm);
s.position.copy(p);
this._section3PGroup.add(s);
const lbl = this._makeTextSprite(LETTERS[i] || `${i + 1}`, '#7BF5A4', 32);
const out = new THREE.Vector3().subVectors(p, c).normalize().multiplyScalar(0.3);
lbl.position.copy(p).add(out).add(new THREE.Vector3(0, 0.16, 0));
this._section3PGroup.add(lbl);
});
// Vertex markers + letter labels — only for true polygons; smooth conic
// sections skip them.
if (polygon.length <= 12) {
const LETTERS = 'KLMNPQRSTUV';
polygon.forEach((p, i) => {
const sg = new THREE.SphereGeometry(0.07, 8, 8);
const sm = new THREE.MeshBasicMaterial({ color: SECT_COLOR });
const s = new THREE.Mesh(sg, sm);
s.position.copy(p);
this._section3PGroup.add(s);
const lbl = this._makeTextSprite(LETTERS[i] || `${i + 1}`, '#7BF5A4', 32);
const out = new THREE.Vector3().subVectors(p, c).normalize().multiplyScalar(0.3);
lbl.position.copy(p).add(out).add(new THREE.Vector3(0, 0.16, 0));
this._section3PGroup.add(lbl);
});
}
}
// Step-by-step highlight (если включён пошаговый режим)
// Step-by-step trace construction (метод следов)
if (this._section3PStepBy && this._section3PStep > 0) {
this._drawSection3PStep(data);
} else {
this._stepCaption = '';
}
}
// Solids that rest on a base plane (y=0) — the trace method applies to these.
_hasBase() {
return ['cube', 'parallelepiped', 'prism', 'pyramid', 'truncpyramid', 'tetrahedron']
.includes(this.figureType);
}
// Trace line of the cutting plane on the base plane y=0.
// Plane: n·X + D = 0 → at y=0: n.x·x + n.z·z + D = 0. Returns {p0, dir} or null
// when the cutting plane is (nearly) parallel to the base (trace at infinity).
_traceLine(data) {
const a = data.normal.x, b = data.normal.z, D = data.D;
if (Math.abs(a) < 1e-6 && Math.abs(b) < 1e-6) return null; // parallel to base
const dir = new THREE.Vector3(-b, 0, a).normalize();
let p0;
if (Math.abs(a) >= Math.abs(b)) p0 = new THREE.Vector3(-D / a, 0, 0);
else p0 = new THREE.Vector3(0, 0, -D / b);
return { p0, dir };
}
// Auxiliary points: extend each lateral side of the section to the base plane.
// Each extension meets the base on the trace line — the heart of the method.
_auxiliaryPoints(polygon) {
const out = [];
const n = polygon.length;
for (let i = 0; i < n; i++) {
const A = polygon[i], B = polygon[(i + 1) % n];
if (A.y < 0.05 && B.y < 0.05) continue; // edge already on the base
if (Math.abs(B.y - A.y) < 1e-3) continue; // horizontal → meets base at ∞
const t = -A.y / (B.y - A.y);
const H = new THREE.Vector3().lerpVectors(A, B, t);
if (Math.abs(H.x) > 40 || Math.abs(H.z) > 40) continue; // near-parallel, too far
// prefer extensions that reach the base outside the segment (the classic case)
const reach = (t < 0) ? -t : (t > 1 ? t - 1 : 0);
out.push({ A, B, H, reach });
}
out.sort((p, q) => p.reach - q.reach); // nearest extensions first
return out;
}
_drawSection3PStep(data) {
// Extra step-by-step highlight objects added to _section3PGroup
const step = this._section3PStep;
const picks = this._section3PPicks;
const HILITE = 0xFFFFA0;
const grp = this._section3PGroup;
const HILITE = 0xFFFFA0, TRACE = 0xEF476F, AUX = 0xFFD166;
const flash = (pos) => {
const sg = new THREE.SphereGeometry(0.22, 10, 10);
const sm = new THREE.MeshBasicMaterial({ color: HILITE, transparent: true, opacity: 0.7 });
const s = new THREE.Mesh(sg, sm);
s.position.copy(pos);
this._section3PGroup.add(s);
const dot = (pos, color, r = 0.12) => {
const s = new THREE.Mesh(new THREE.SphereGeometry(r, 10, 10),
new THREE.MeshBasicMaterial({ color }));
s.position.copy(pos); grp.add(s);
};
const flashLine = (a, b) => {
const lg = new THREE.BufferGeometry().setFromPoints([a, b]);
this._section3PGroup.add(new THREE.Line(lg, new THREE.LineBasicMaterial({ color: HILITE, linewidth: 3 })));
const solidLine = (a, b, color) => {
grp.add(new THREE.Line(new THREE.BufferGeometry().setFromPoints([a, b]),
new THREE.LineBasicMaterial({ color, transparent: true, opacity: 0.95 })));
};
const dashLine = (a, b, color) => {
const l = new THREE.Line(new THREE.BufferGeometry().setFromPoints([a, b]),
new THREE.LineDashedMaterial({ color, dashSize: 0.18, gapSize: 0.1, transparent: true, opacity: 0.9 }));
l.computeLineDistances(); grp.add(l);
};
const tag = (pos, text, color, off = new THREE.Vector3(0.25, 0.25, 0)) => {
const s = this._makeTextSprite(text, '#' + new THREE.Color(color).getHexString(), 34);
s.position.copy(pos).add(off); grp.add(s);
};
if (step >= 1) flash(picks[0]);
if (step >= 2) { flash(picks[1]); flashLine(picks[0], picks[1]); }
if (step >= 3) { flash(picks[2]); flashLine(picks[1], picks[2]); flashLine(picks[2], picks[0]); }
// steps 4-6 handled by full plane + section already drawn above
const hasBase = this._hasBase();
const polygon = data.polygon;
const trace = hasBase ? this._traceLine(data) : null;
const aux = (hasBase && trace) ? this._auxiliaryPoints(polygon).slice(0, 2) : [];
// Step 1 — the three given points
if (step >= 1) picks.forEach((p, i) => { dot(p, HILITE); tag(p, 'P' + (i + 1), HILITE); });
// Step 2 — connect points lying in the same face → first sides of the section
if (step >= 2) {
for (let i = 0; i < picks.length; i++) {
for (let j = i + 1; j < picks.length; j++) {
if (this._sameFace(picks[i], picks[j])) solidLine(picks[i], picks[j], HILITE);
}
}
}
// Step 3 — build the trace of the cutting plane on the base
if (step >= 3 && trace) {
const L = 13;
const a = trace.p0.clone().addScaledVector(trace.dir, -L);
const b = trace.p0.clone().addScaledVector(trace.dir, L);
dashLine(a, b, TRACE);
tag(b, 'след', TRACE, new THREE.Vector3(0.3, 0.2, 0));
}
// Step 4 — extend the section's sides to the trace → auxiliary points
if (step >= 4) {
aux.forEach((q, i) => {
dashLine(q.A, q.H, AUX);
dot(q.H, AUX, 0.1);
tag(q.H, 'T' + (i + 1), AUX);
});
}
// Steps 56 — the finished section is drawn by _drawSection3P (showFull)
const CAPS = hasBase ? {
1: 'Шаг 1. Отмечены 3 точки, задающие секущую плоскость.',
2: 'Шаг 2. Соединяем точки в одной грани — первые стороны сечения.',
3: 'Шаг 3. Строим след — линию пересечения плоскости с основанием.',
4: 'Шаг 4. Продлеваем стороны сечения до следа — вспомогательные точки.',
5: 'Шаг 5. Через след находим остальные вершины и замыкаем сечение.',
6: `Шаг 6. Сечение построено: ${data.typeName}` + (data.area > 0 ? `, S = ${Math.round(data.area * 100) / 100}.` : '.'),
} : {
1: 'Шаг 1. Отмечены 3 точки, задающие секущую плоскость.',
2: 'Шаг 2. Соединяем точки, лежащие в одной грани.',
3: 'Для этого тела метод следов не применяется — показываем готовое сечение.',
4: 'Готовое сечение.', 5: 'Готовое сечение.', 6: 'Готовое сечение.',
};
this._stepCaption = CAPS[Math.min(step, 6)] || '';
}
// True if two points both lie in (the plane of) the same face of the solid.
_sameFace(p, q) {
for (const face of this._faces) {
if (face.length < 3) continue;
const nrm = this._faceNormal(face);
const d = nrm.dot(face[0]);
if (Math.abs(nrm.dot(p) - d) < 0.06 && Math.abs(nrm.dot(q) - d) < 0.06) return true;
}
return false;
}
/* ════════════════ MEASUREMENT MODE ════════════════ */
@@ -4068,23 +4184,33 @@ class StereoSim {
_stereoUpdateSection3PPanel();
}
function _stereoStepHint() {
const hint = document.getElementById('sect3p-hint');
if (hint && stereoSim) hint.textContent = stereoSim._stepCaption || '';
}
function stereoSection3PStepBy(toggle) {
const on = !toggle.classList.contains('on');
toggle.classList.toggle('on', on);
if (stereoSim) stereoSim.toggleSection3PStepBy(on);
_stereoStepHint();
}
function stereoSection3PNextStep() {
if (!stereoSim) return;
if (!stereoSim._section3PStepBy) return; // steps only meaningful in step mode
const max = stereoSim._section3PData ? 6 : stereoSim._section3PPicks.length;
stereoSim._section3PStep = Math.min(stereoSim._section3PStep + 1, max);
stereoSim._drawSection3P();
_stereoStepHint();
}
function stereoSection3PPrevStep() {
if (!stereoSim) return;
stereoSim._section3PStep = Math.max(0, stereoSim._section3PStep - 1);
if (!stereoSim._section3PStepBy) return;
stereoSim._section3PStep = Math.max(1, stereoSim._section3PStep - 1);
stereoSim._drawSection3P();
_stereoStepHint();
}
function _stereoUpdateSection3PPanel() {
+1 -1
View File
@@ -4819,7 +4819,7 @@
<script src="/js/labs/flask.js"></script>
<script src="/js/labs/redox.js"></script>
<script src="/js/labs/ionexchange.js"></script>
<script src="/js/labs/stereo.js?v=8"></script>
<script src="/js/labs/stereo.js?v=9"></script>
<script src="/js/notifications.js"></script>
<script src="/js/search.js"></script>
<script src="/js/mobile.js"></script>