feat(stereo): C1+C3 — плоскость как сечение + «натуральная величина»

Фаза C раунда «Конструктор» (C2 покрыта Фазой A, C4 отложена).

C1 — любую построенную плоскость можно показать сечением тела: клик по
плоскости в дереве (нормальный режим) → setSectionPlane: заливка
многоугольника + подписи вершин K,L,M… + площадь и периметр в readout-
панели. Удаление плоскости / очистка / смена фигуры сбрасывают сечение.

C3 — «Натуральная величина» сечения (getTrueShape): многоугольник сечения
разворачивается в свою плоскость (ортонормированный базис от нормали) с
сохранением истинных длин → 2D-SVG мини-панель со штриховкой (pattern),
подписями вершин, длинами сторон и S/P. Появляется автоматически при
активном сечении.

- StereoSim: _sectionPlaneId, setSectionPlane, _activeSectionPolygon,
  _sectionVertexLabel, getTrueShape; _drawPlaneObject заливает+подписывает
  активное сечение; getReadout добавляет S/P; getConstructions отдаёт
  sectionId + per-plane section; pickConstructObject в нормальном режиме
  тогглит сечение по плоскости.
- Панель: контейнер #construct-trueshape + подсказка; glue
  _stereoUpdateTrueShape (SVG-рендер) вызывается из _stereoUpdateUI; строки
  плоскостей в дереве всегда кликабельны, тег «(сечение)».

Верификация: node --check OK; headless-смоук 26/26 (квадрат y=2: S=16,P=16;
readout/дерево/тоггл; true-shape длины K,L,M,N=4, площадь=16; сохранение
длин и площади для прямого И наклонного сечения; 2D-shoelace=S; удаление/
очистка/setFigure сбрасывают сечение; dispose); эмодзи/eval/new Function — 0.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Maxim Dolgolyov
2026-06-17 17:15:22 +03:00
parent 9382b063aa
commit 24403718bf
3 changed files with 173 additions and 18 deletions
+159 -13
View File
@@ -316,6 +316,7 @@ class StereoSim {
this._nextCPointName = 0; // → M, N, K, …
this._constructSeq = 0; // monotonic insertion order (for "remove last")
this._relMode = null; // {op, refId} parallel/perpendicular through a point
this._sectionPlaneId = null; // id of the plane shown as a filled, measured section
this._lastConstructMsg = ''; // transient result text for the panel hint
this._undoStack = []; // construction-layer history (JSON snapshots)
this._redoStack = [];
@@ -364,7 +365,7 @@ class StereoSim {
this._cpoints = []; this._lines = []; this._planes = [];
this._lineMode = false; this._planeMode = false;
this._intersectMode = false; this._intersectSel = [];
this._relMode = null; this._lastConstructMsg = '';
this._relMode = null; this._sectionPlaneId = null; this._lastConstructMsg = '';
this._undoStack = []; this._redoStack = [];
this._constructPicks = [];
this._nextLineName = 0; this._nextPlaneName = 0; this._nextCPointName = 0; this._constructSeq = 0;
@@ -816,6 +817,17 @@ class StereoSim {
lines.push({ label: 'Периметр P', value: r(this._polygonPerimeter(poly)) });
}
if (this._sectionPlaneId) {
const pl = this._planes.find(p => p.id === this._sectionPlaneId);
const poly = this._activeSectionPolygon();
if (pl && poly) {
const nm = ({ 3: 'треугольник', 4: 'четырёхугольник', 5: 'пятиугольник', 6: 'шестиугольник' }[poly.length] || `${poly.length}-угольник`);
lines.push({ label: 'Сечение пл. ' + pl.name, value: nm });
lines.push({ label: 'Площадь S', value: r(this._polygonArea(poly)) });
lines.push({ label: 'Периметр P', value: r(this._polygonPerimeter(poly)) });
}
}
if (this._measurements.length) {
const m = this._measurements[this._measurements.length - 1];
lines.push({ label: `Отрезок ${m.from}${m.to}`, value: m.dist });
@@ -3644,6 +3656,7 @@ class StereoSim {
if (i >= 0) { a.splice(i, 1); break; }
}
this._intersectSel = this._intersectSel.filter(x => x !== id);
if (this._sectionPlaneId === id) this._sectionPlaneId = null;
this._rebuildConstructions();
this._notify();
}
@@ -3659,7 +3672,7 @@ class StereoSim {
this._cpoints = []; this._lines = []; this._planes = []; this._constructPicks = [];
this._lineMode = false; this._planeMode = false;
this._intersectMode = false; this._intersectSel = [];
this._relMode = null;
this._relMode = null; this._sectionPlaneId = null;
this._clearGroup(this._constructGroup);
this._notify();
}
@@ -3704,6 +3717,67 @@ class StereoSim {
this._restoreSnapshot(this._redoStack.pop());
}
/* ── Section from a plane object (Phase C1): fill + vertex labels + S/P ── */
setSectionPlane(id) {
this._sectionPlaneId = (this._sectionPlaneId === id) ? null : id;
this._rebuildConstructions();
this._notify();
}
// Ordered polygon (Vector3[]) where the active section plane cuts the solid, or null.
_activeSectionPolygon() {
if (!this._sectionPlaneId) return null;
const pl = this._planes.find(p => p.id === this._sectionPlaneId);
if (!pl || !pl.def) return null;
let poly = null;
try {
poly = this._sliceByPlane(
new THREE.Vector3(pl.def[0].x, pl.def[0].y, pl.def[0].z),
new THREE.Vector3(pl.def[1].x, pl.def[1].y, pl.def[1].z),
new THREE.Vector3(pl.def[2].x, pl.def[2].y, pl.def[2].z));
} catch (_) { poly = null; }
return (poly && poly.length >= 3) ? poly : null;
}
_sectionVertexLabel(i) {
// K, L, M, … wrapping after a dozen
return String.fromCharCode(75 + (i % 12));
}
// True shape (натуральная величина) of the active section: the polygon unfolded
// into its own plane → 2D points with REAL lengths preserved. Pure data (no DOM).
getTrueShape() {
const poly = this._activeSectionPolygon();
if (!poly || poly.length < 3) return null;
const pl = this._planes.find(p => p.id === this._sectionPlaneId);
if (!pl) return null;
const n = new THREE.Vector3(pl.normal.x, pl.normal.y, pl.normal.z).normalize();
let u = Math.abs(n.x) > 0.9 ? new THREE.Vector3(0, 1, 0) : new THREE.Vector3(1, 0, 0);
u = new THREE.Vector3().crossVectors(n, u).normalize();
const w = new THREE.Vector3().crossVectors(n, u).normalize();
const c = new THREE.Vector3();
poly.forEach(p => c.add(p));
c.divideScalar(poly.length);
const r = (v) => Math.round(v * 100) / 100;
const pts = poly.map(p => {
const d = p.clone().sub(c);
return { x: d.dot(u), y: d.dot(w) };
});
const edges = pts.map((p, i) => {
const q = pts[(i + 1) % pts.length];
return r(Math.hypot(q.x - p.x, q.y - p.y));
});
return {
name: pl.name,
pts,
edges,
labels: pts.map((_, i) => this._sectionVertexLabel(i)),
area: r(this._polygonArea(poly)),
perim: r(this._polygonPerimeter(poly)),
};
}
// Interactive tree summary for the panel (ids/types/visibility/selection).
getConstructions() {
const r = (v) => Math.round(v * 100) / 100;
@@ -3712,6 +3786,7 @@ class StereoSim {
return {
intersectMode: this._intersectMode,
relMode: !!this._relMode,
sectionId: this._sectionPlaneId,
points: this._cpoints.map(p => ({
id: p.id, name: p.name, hidden: !!p.hidden, selected: isSel(p.id),
info: `(${r(p.pos.x)}, ${r(p.pos.y)}, ${r(p.pos.z)})`,
@@ -3725,6 +3800,7 @@ class StereoSim {
const sign = (v) => (v >= 0 ? '+ ' : ' ') + Math.abs(r(v));
return {
id: p.id, name: p.name, hidden: !!p.hidden, selected: isSel(p.id),
section: (p.id === this._sectionPlaneId),
info: `${r(n.x)}x ${sign(n.y)}y ${sign(n.z)}z ${sign(D)} = 0`,
};
}),
@@ -3795,7 +3871,15 @@ class StereoSim {
// or to intersection (select 2 objects). Returns { msg } for the panel hint.
pickConstructObject(id) {
if (this._relMode) return this._pickRelRef(id);
if (!this._intersectMode) return { msg: '' };
if (!this._intersectMode) {
// Normal mode: click a plane in the tree to show/hide it as a filled, measured section.
const f = this._findObj(id);
if (f && f.type === 'plane') {
this.setSectionPlane(id);
return { msg: this._sectionPlaneId ? ('сечение по плоскости ' + f.obj.name) : 'сечение снято' };
}
return { msg: '' };
}
const found = this._findObj(id);
if (!found || found.type === 'point') return { msg: 'Для пересечения выберите прямую или плоскость' };
const i = this._intersectSel.indexOf(id);
@@ -4084,19 +4168,42 @@ class StereoSim {
this._constructGroup.add(bline);
// Cross-section of the solid by this plane — makes the plane immediately meaningful.
// When this plane is the *active section*, fill it + label its vertices K,L,M…
if (pl.def) {
const isSection = (pl.id === this._sectionPlaneId);
let poly = null;
try {
const poly = this._sliceByPlane(
poly = this._sliceByPlane(
new THREE.Vector3(pl.def[0].x, pl.def[0].y, pl.def[0].z),
new THREE.Vector3(pl.def[1].x, pl.def[1].y, pl.def[1].z),
new THREE.Vector3(pl.def[2].x, pl.def[2].y, pl.def[2].z));
if (poly && poly.length >= 3) {
const sline = new THREE.Line(new THREE.BufferGeometry().setFromPoints([...poly, poly[0]]),
new THREE.LineBasicMaterial({ color: 0x06D6E0, transparent: true, opacity: 0.95 }));
sline.renderOrder = 4;
this._constructGroup.add(sline);
} catch (_) { poly = null; }
if (poly && poly.length >= 3) {
if (isSection) {
// filled face (triangle fan)
const positions = [], indices = [];
poly.forEach(p => positions.push(p.x, p.y, p.z));
for (let i = 1; i < poly.length - 1; i++) indices.push(0, i, i + 1);
const fgeo = new THREE.BufferGeometry();
fgeo.setAttribute('position', new THREE.Float32BufferAttribute(positions, 3));
fgeo.setIndex(indices);
fgeo.computeVertexNormals();
const fill = new THREE.Mesh(fgeo, new THREE.MeshBasicMaterial({ color: 0x06D6E0, transparent: true, opacity: 0.32, side: THREE.DoubleSide, depthWrite: false }));
fill.renderOrder = 3;
this._constructGroup.add(fill);
}
} catch (_) {}
const sline = new THREE.Line(new THREE.BufferGeometry().setFromPoints([...poly, poly[0]]),
new THREE.LineBasicMaterial({ color: 0x06D6E0, transparent: true, opacity: isSection ? 1 : 0.85 }));
sline.renderOrder = 4;
this._constructGroup.add(sline);
if (isSection && this.showLabels) {
poly.forEach((p, i) => {
const lbl = this._makeTextSprite(this._sectionVertexLabel(i), '#67E8F9', 38);
lbl.position.copy(p).add(new THREE.Vector3(0.14, 0.2, 0));
this._constructGroup.add(lbl);
});
}
}
}
if (this.showLabels) {
@@ -4812,11 +4919,12 @@ class StereoSim {
'<button onclick="' + fn + "('" + id + '\')" title="' + title + '" style="background:none;border:none;color:rgba(255,255,255,0.55);cursor:pointer;padding:1px;display:flex;align-items:center">' + svg + '</button>';
const row = (o, kind, color, selectable) => {
const selBg = o.selected ? 'background:rgba(56,189,248,0.2);' : '';
const selBg = o.section ? 'background:rgba(6,214,224,0.18);' : (o.selected ? 'background:rgba(56,189,248,0.2);' : '');
const dim = o.hidden ? 'opacity:0.4;' : '';
const tag = o.section ? ' <span style="color:#06D6E0">(сечение)</span>' : '';
const main = '<span style="flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;' + dim +
(selectable ? 'cursor:pointer" onclick="stereoConstructSelect(\'' + o.id + '\')"' : '"') + '>' +
'<b style="color:' + color + '">' + kind + ' ' + o.name + '</b>' +
'<b style="color:' + color + '">' + kind + ' ' + o.name + '</b>' + tag +
(o.info ? ' <span style="color:rgba(255,255,255,0.4)">' + o.info + '</span>' : '') + '</span>';
return '<div style="display:flex;align-items:center;gap:3px;padding:1px 3px;border-radius:5px;' + selBg + '">' +
main +
@@ -4828,10 +4936,45 @@ class StereoSim {
const rows = [];
c.points.forEach(p => rows.push(row(p, 'точка', '#6EE7B7', false)));
c.lines.forEach(l => rows.push(row(l, 'прямая', '#7DD3FC', sel)));
c.planes.forEach(p => rows.push(row(p, 'плоскость', '#DDD6FE', sel)));
// planes are always clickable: in normal mode a click toggles "show as section"
c.planes.forEach(p => rows.push(row(p, 'плоскость', '#DDD6FE', true)));
el.innerHTML = rows.join('');
}
// True-shape (натуральная величина) mini-panel for the active section.
function _stereoUpdateTrueShape() {
const el = document.getElementById('construct-trueshape');
if (!el || !stereoSim) return;
const ts = stereoSim.getTrueShape();
if (!ts) { el.style.display = 'none'; el.innerHTML = ''; return; }
el.style.display = '';
const W = 150, H = 130, pad = 24;
let minX = Infinity, maxX = -Infinity, minY = Infinity, maxY = -Infinity;
ts.pts.forEach(p => { minX = Math.min(minX, p.x); maxX = Math.max(maxX, p.x); minY = Math.min(minY, p.y); maxY = Math.max(maxY, p.y); });
const s = Math.min((W - 2 * pad) / Math.max(1e-3, maxX - minX), (H - 2 * pad) / Math.max(1e-3, maxY - minY));
const cx = (minX + maxX) / 2, cy = (minY + maxY) / 2;
const scr = ts.pts.map(p => ({ x: W / 2 + (p.x - cx) * s, y: H / 2 - (p.y - cy) * s }));
const ptsAttr = scr.map(p => p.x.toFixed(1) + ',' + p.y.toFixed(1)).join(' ');
let svg = '<svg viewBox="0 0 ' + W + ' ' + H + '" style="width:100%;height:auto;display:block">';
svg += '<defs><pattern id="st-ts-hatch" width="6" height="6" patternUnits="userSpaceOnUse" patternTransform="rotate(45)">'
+ '<line x1="0" y1="0" x2="0" y2="6" stroke="#06D6E0" stroke-width="0.8" opacity="0.45"/></pattern></defs>';
svg += '<polygon points="' + ptsAttr + '" fill="url(#st-ts-hatch)" stroke="#06D6E0" stroke-width="1.6" stroke-linejoin="round"/>';
scr.forEach((p, i) => {
const q = scr[(i + 1) % scr.length];
svg += '<text x="' + ((p.x + q.x) / 2).toFixed(1) + '" y="' + ((p.y + q.y) / 2).toFixed(1)
+ '" font-size="6.5" fill="rgba(255,255,255,0.75)" text-anchor="middle" dominant-baseline="middle">' + ts.edges[i] + '</text>';
});
scr.forEach((p, i) => {
const dx = p.x - W / 2, dy = p.y - H / 2, L = Math.hypot(dx, dy) || 1;
svg += '<circle cx="' + p.x.toFixed(1) + '" cy="' + p.y.toFixed(1) + '" r="2" fill="#67E8F9"/>';
svg += '<text x="' + (p.x + dx / L * 9).toFixed(1) + '" y="' + (p.y + dy / L * 9).toFixed(1)
+ '" font-size="8" font-weight="700" fill="#67E8F9" text-anchor="middle" dominant-baseline="middle">' + ts.labels[i] + '</text>';
});
svg += '</svg>';
el.innerHTML = '<div style="font-size:0.63rem;color:rgba(255,255,255,0.55);margin:0 0 2px">Натуральная величина сечения '
+ ts.name + ' · S = ' + ts.area + ' · P = ' + ts.perim + '</div>' + svg;
}
/* ── Section through 3 points UI ── */
function stereoSection3P(btn) {
const on = !btn.classList.contains('active');
@@ -4971,6 +5114,9 @@ class StereoSim {
stereoSim._lastConstructMsg = '';
}
// True-shape mini-panel of the active section
_stereoUpdateTrueShape();
// Live readout overlay (section type/area/perimeter, last measurement)
_stereoUpdateReadout(info);
+2
View File
@@ -3708,7 +3708,9 @@
<button class="st-action-btn" onclick="stereoConstructClear()">Очистить</button>
</div>
<div id="construct-hint" style="font-size:0.63rem;color:rgba(255,255,255,0.38);margin-top:3px;line-height:1.4"></div>
<div style="font-size:0.6rem;color:rgba(255,255,255,0.3);margin-top:2px;line-height:1.35">Клик по плоскости в списке — показать её сечением (заливка, площадь, периметр, натуральная величина).</div>
<div id="construct-list" style="font-size:0.7rem;margin-top:4px;line-height:1.6"></div>
<div id="construct-trueshape" style="display:none;margin-top:6px;background:rgba(6,214,224,0.05);border:1px solid rgba(6,214,224,0.18);border-radius:8px;padding:6px"></div>
<!-- ── Метки рёбер ── -->
<div class="gp-section-title" style="margin-top:8px;margin-bottom:6px">Метки рёбер</div>
+12 -5
View File
@@ -94,11 +94,18 @@
### Фаза C — Сечения+
- [ ] C1 — Сечение **плоскостью-объектом** (из Фазы A): «показать как сечение» с площадью/периметром.
- [ ] C2 — Сечение, **параллельное прямой/плоскости**; сечение **через прямую и точку**.
- [ ] C3 — **«Натуральная величина» сечения** (разворот многоугольника сечения в плоскость экрана,
отдельная мини-панель) + **штриховка**.
- [ ] C4 — Честный конструктивный алгоритм следов с анимацией перехода между шагами (из бэклога Ф6).
- [x] C1 — Сечение **плоскостью-объектом** (из Фазы A): клик по плоскости в дереве (нормальный режим)
`setSectionPlane` показывает её заливкой + подписи вершин K,L,M… + площадь/периметр в readout
(`_activeSectionPolygon`, `getReadout`). Удаление/очистка/смена фигуры сбрасывают сечение.
- [x] C2 — **Покрыто Фазой A** (отдельный код не нужен): сечение через прямую+точку = плоскость по
3 точкам (2 с прямой + 1); сечение ∥ плоскости через точку = rel-операция `ppar` → затем клик
как сечение. Дополнительный UI признан избыточным.
- [x] C3 — **«Натуральная величина» сечения** (`getTrueShape`): разворот многоугольника в его
плоскость (ортонормированный базис от нормали) с сохранением истинных длин → 2D-мини-панель
(SVG, штриховка `<pattern>`, подписи вершин, длины сторон, S/P). Проверено сохранение длин и
площади для прямого и наклонного сечений.
- [ ] C4 — Честный конструктивный алгоритм следов с анимацией (из бэклога Ф6) — **отложено**
(крупная отдельная фича; текущий гибрид Ф6 + сечения-объекты Фазы A/C закрывают практику).
---