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:
+181
-55
@@ -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 5–6 — 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
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user