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:
+159
-13
@@ -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);
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user