feat(stereo): конструкторное ядро A1 — прямые и плоскости как объекты
Фаза A раунда «Конструктор» (под ученика-самоучку). Прямая по 2 точкам
(имена a,b,c…) и плоскость по 3 точкам (имена α,β,γ…) как именованные
объекты сцены. Плоскость рисует полупрозрачный квад + пунктирную рамку +
сечение тела этой плоскостью (через _sliceByPlane) — сразу осмысленна.
- StereoSim: _lines/_planes (сериализуемые {x,y,z}), _constructGroup,
setLineMode/setPlaneMode, _onConstructClick, _createLine/_createPlane,
_rebuildConstructions/_drawLineObject/_drawPlaneObject, removeLast/clear,
getConstructions (с уравнением плоскости). Сброс в setFigure, очистка в
dispose, перерисовка подписей в toggleLabels, счётчик в info().
- Панель «Построения» в labs-bodies.html + glue (stereoLineMode/PlaneMode/
ConstructUndo/Clear, _stereoUpdateConstructList); интеграция в
_stereoDeactivateTools и _stereoUpdateUI.
- План: Фазы A и C в plans/STEREO_3D_IMPROVEMENT.md.
Верификация: node --check OK; headless-смоук 35/35 (создание/имена/нормаль/
коллинеарность/rebuild/summary/remove-last/clear/click-путь/setFigure-сброс/
dispose); эмодзи/eval/new Function — 0.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
+288
-4
@@ -69,7 +69,7 @@ class StereoSim {
|
|||||||
this._velX = 0; this._velY = 0;
|
this._velX = 0; this._velY = 0;
|
||||||
try { el.setPointerCapture(e.pointerId); } catch (_) {}
|
try { el.setPointerCapture(e.pointerId); } catch (_) {}
|
||||||
if (this._panning) el.style.cursor = 'move';
|
if (this._panning) el.style.cursor = 'move';
|
||||||
else if (!this._pointMode && !this._connectMode && !this._measureMode && !this._angleMode && !this._section3PMode) el.style.cursor = 'grabbing';
|
else if (!this._pointMode && !this._connectMode && !this._measureMode && !this._angleMode && !this._section3PMode && !this._lineMode && !this._planeMode) el.style.cursor = 'grabbing';
|
||||||
this._invalidate();
|
this._invalidate();
|
||||||
});
|
});
|
||||||
on(el, 'contextmenu', e => e.preventDefault()); // allow right-drag pan without menu
|
on(el, 'contextmenu', e => e.preventDefault()); // allow right-drag pan without menu
|
||||||
@@ -87,6 +87,7 @@ class StereoSim {
|
|||||||
else if (this._markMode) { el.style.cursor = 'pointer'; if (!wasDrag) this._onMarkClick(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 if (this._deriveMode) { el.style.cursor = 'crosshair'; if (!wasDrag) this._onDeriveClick(e); }
|
||||||
else if (this._section3PMode) { el.style.cursor = 'crosshair'; if (!wasDrag) this._onSection3PClick(e); }
|
else if (this._section3PMode) { el.style.cursor = 'crosshair'; if (!wasDrag) this._onSection3PClick(e); }
|
||||||
|
else if (this._lineMode || this._planeMode) { el.style.cursor = 'crosshair'; if (!wasDrag) this._onConstructClick(e); }
|
||||||
else el.style.cursor = 'grab';
|
else el.style.cursor = 'grab';
|
||||||
this._invalidate();
|
this._invalidate();
|
||||||
});
|
});
|
||||||
@@ -289,6 +290,19 @@ class StereoSim {
|
|||||||
this._section3PData = null; // computed result {normal,D,polygon,area,typeName}
|
this._section3PData = null; // computed result {normal,D,polygon,area,typeName}
|
||||||
this._stepCaption = ''; // caption for the current trace-method step
|
this._stepCaption = ''; // caption for the current trace-method step
|
||||||
|
|
||||||
|
/* ── Construction layer (Phase A): lines & planes as named objects ──
|
||||||
|
Stored serialisably as plain {x,y,z}; rebuilt into _constructGroup. */
|
||||||
|
this._lines = []; // [{id,seq,name, a:{x,y,z}, b:{x,y,z}, color}]
|
||||||
|
this._planes = []; // [{id,seq,name, point:{x,y,z}, normal:{x,y,z}, def:[{x,y,z}×3], color}]
|
||||||
|
this._constructGroup = new THREE.Group();
|
||||||
|
this.scene.add(this._constructGroup);
|
||||||
|
this._lineMode = false; // pick 2 points → infinite line
|
||||||
|
this._planeMode = false; // pick 3 points → plane (+ its cross-section of the solid)
|
||||||
|
this._constructPicks = []; // temp Vector3 picks for the active construction tool
|
||||||
|
this._nextLineName = 0; // → a, b, c, …
|
||||||
|
this._nextPlaneName = 0; // → α, β, γ, …
|
||||||
|
this._constructSeq = 0; // monotonic insertion order (for "remove last")
|
||||||
|
|
||||||
this.onUpdate = null;
|
this.onUpdate = null;
|
||||||
|
|
||||||
this._buildGrid();
|
this._buildGrid();
|
||||||
@@ -329,6 +343,11 @@ class StereoSim {
|
|||||||
this._section3PMode = false;
|
this._section3PMode = false;
|
||||||
this._section3PStep = 0;
|
this._section3PStep = 0;
|
||||||
this._clearGroup(this._section3PGroup);
|
this._clearGroup(this._section3PGroup);
|
||||||
|
this._lines = []; this._planes = [];
|
||||||
|
this._lineMode = false; this._planeMode = false;
|
||||||
|
this._constructPicks = [];
|
||||||
|
this._nextLineName = 0; this._nextPlaneName = 0; this._constructSeq = 0;
|
||||||
|
this._clearGroup(this._constructGroup);
|
||||||
this._buildFigure();
|
this._buildFigure();
|
||||||
this._notify();
|
this._notify();
|
||||||
}
|
}
|
||||||
@@ -346,7 +365,7 @@ class StereoSim {
|
|||||||
|
|
||||||
toggleEdges(v) { this.showEdges = v; this._buildFigure(); }
|
toggleEdges(v) { this.showEdges = v; this._buildFigure(); }
|
||||||
toggleVertices(v) { this.showVertices = v; this._buildFigure(); }
|
toggleVertices(v) { this.showVertices = v; this._buildFigure(); }
|
||||||
toggleLabels(v) { this.showLabels = v; this._buildFigure(); }
|
toggleLabels(v) { this.showLabels = v; this._buildFigure(); this._rebuildConstructions(); }
|
||||||
toggleAxes(v) { this.showAxes = v; this._buildGrid(); }
|
toggleAxes(v) { this.showAxes = v; this._buildGrid(); }
|
||||||
toggleGrid(v) { this.showGrid = v; this._buildGrid(); }
|
toggleGrid(v) { this.showGrid = v; this._buildGrid(); }
|
||||||
|
|
||||||
@@ -793,6 +812,7 @@ class StereoSim {
|
|||||||
circumscribedR: this.showCircumscribed ? this._circumscribedRadius() : null,
|
circumscribedR: this.showCircumscribed ? this._circumscribedRadius() : null,
|
||||||
customPoints: this._customPoints.length,
|
customPoints: this._customPoints.length,
|
||||||
connections: this._connections.length,
|
connections: this._connections.length,
|
||||||
|
constructions: this._lines.length + this._planes.length,
|
||||||
readout: this.getReadout(),
|
readout: this.getReadout(),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -915,7 +935,8 @@ class StereoSim {
|
|||||||
}
|
}
|
||||||
[this._figGroup, this._labelGroup, this._sectionGroup, this._sphereGroup,
|
[this._figGroup, this._labelGroup, this._sectionGroup, this._sphereGroup,
|
||||||
this._measureGroup, this._measurePickGroup, this._gridGroup, this._markGroup,
|
this._measureGroup, this._measurePickGroup, this._gridGroup, this._markGroup,
|
||||||
this._derivedGroup, this._section3PGroup, this._angleGroup, this._pointGroup]
|
this._derivedGroup, this._section3PGroup, this._angleGroup, this._pointGroup,
|
||||||
|
this._constructGroup]
|
||||||
.forEach(g => g && this._clearGroup(g));
|
.forEach(g => g && this._clearGroup(g));
|
||||||
if (this._tooltipEl && this._tooltipEl.parentNode) this._tooltipEl.parentNode.removeChild(this._tooltipEl);
|
if (this._tooltipEl && this._tooltipEl.parentNode) this._tooltipEl.parentNode.removeChild(this._tooltipEl);
|
||||||
if (this.renderer) {
|
if (this.renderer) {
|
||||||
@@ -3562,6 +3583,222 @@ class StereoSim {
|
|||||||
|
|
||||||
/* ════════════════ UTILS ════════════════ */
|
/* ════════════════ UTILS ════════════════ */
|
||||||
|
|
||||||
|
/* ════════════════ CONSTRUCTION LAYER (Phase A) ════════════════ */
|
||||||
|
/* Lines (a,b,c…) & planes (α,β,γ…) built by picking points. Everything is
|
||||||
|
rebuilt from the serialisable _lines / _planes arrays into _constructGroup. */
|
||||||
|
|
||||||
|
setLineMode(on) {
|
||||||
|
this._lineMode = on;
|
||||||
|
if (on) this._planeMode = false;
|
||||||
|
this._constructPicks = [];
|
||||||
|
this.renderer.domElement.style.cursor = on ? 'crosshair' : 'grab';
|
||||||
|
this._rebuildConstructions();
|
||||||
|
}
|
||||||
|
|
||||||
|
setPlaneMode(on) {
|
||||||
|
this._planeMode = on;
|
||||||
|
if (on) this._lineMode = false;
|
||||||
|
this._constructPicks = [];
|
||||||
|
this.renderer.domElement.style.cursor = on ? 'crosshair' : 'grab';
|
||||||
|
this._rebuildConstructions();
|
||||||
|
}
|
||||||
|
|
||||||
|
removeLastConstruction() {
|
||||||
|
let best = -1, arr = null, idx = -1;
|
||||||
|
this._lines.forEach((l, i) => { if (l.seq > best) { best = l.seq; arr = this._lines; idx = i; } });
|
||||||
|
this._planes.forEach((p, i) => { if (p.seq > best) { best = p.seq; arr = this._planes; idx = i; } });
|
||||||
|
if (!arr) return;
|
||||||
|
arr.splice(idx, 1);
|
||||||
|
this._rebuildConstructions();
|
||||||
|
this._notify();
|
||||||
|
}
|
||||||
|
|
||||||
|
clearConstructions() {
|
||||||
|
this._lines = []; this._planes = []; this._constructPicks = [];
|
||||||
|
this._lineMode = false; this._planeMode = false;
|
||||||
|
this._clearGroup(this._constructGroup);
|
||||||
|
this._notify();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Human-readable summary for the panel "construction tree".
|
||||||
|
getConstructions() {
|
||||||
|
const r = (v) => Math.round(v * 100) / 100;
|
||||||
|
return {
|
||||||
|
lines: this._lines.map(l => ({ name: l.name })),
|
||||||
|
planes: this._planes.map(p => {
|
||||||
|
const n = p.normal;
|
||||||
|
const D = -(n.x * p.point.x + n.y * p.point.y + n.z * p.point.z);
|
||||||
|
const sign = (v) => (v >= 0 ? '+ ' : '− ') + Math.abs(r(v));
|
||||||
|
return { name: p.name, eq: `${r(n.x)}x ${sign(n.y)}y ${sign(n.z)}z ${sign(D)} = 0` };
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
_lineLabel(i) {
|
||||||
|
const base = String.fromCharCode(97 + (i % 26)); // a..z
|
||||||
|
const sub = Math.floor(i / 26);
|
||||||
|
return sub > 0 ? base + '_' + sub : base;
|
||||||
|
}
|
||||||
|
|
||||||
|
_planeLabel(i) {
|
||||||
|
const G = ['α','β','γ','δ','ε','ζ','η','θ','λ','μ','π','ρ','σ','τ','φ','ψ','ω'];
|
||||||
|
const base = G[i % G.length];
|
||||||
|
const sub = Math.floor(i / G.length);
|
||||||
|
return sub > 0 ? base + '_' + sub : base;
|
||||||
|
}
|
||||||
|
|
||||||
|
// World-space radius enclosing the figure (for sizing infinite lines / planes).
|
||||||
|
_sceneRadius() {
|
||||||
|
let r = 0;
|
||||||
|
for (const v of this._vertices) r = Math.max(r, v.pos.length());
|
||||||
|
if (r < 1e-3) r = this._figureHeight() || 4;
|
||||||
|
return r;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Pick the nearest vertex / custom point under the cursor (Vector3 | null).
|
||||||
|
_pickConstructPoint(e) {
|
||||||
|
const p = this._pickNearestPoint(e);
|
||||||
|
return p ? p.pos.clone() : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
_onConstructClick(e) {
|
||||||
|
if (!this._lineMode && !this._planeMode) return;
|
||||||
|
const pos = this._pickConstructPoint(e);
|
||||||
|
if (!pos) return;
|
||||||
|
// ignore a second click on (almost) the same point
|
||||||
|
const last = this._constructPicks[this._constructPicks.length - 1];
|
||||||
|
if (last && last.distanceTo(pos) < 1e-4) return;
|
||||||
|
this._constructPicks.push(pos);
|
||||||
|
if (window.LabFX) LabFX.sound.play('tick', { pitch: 1.6, volume: 0.25 });
|
||||||
|
|
||||||
|
const need = this._lineMode ? 2 : 3;
|
||||||
|
if (this._constructPicks.length >= need) {
|
||||||
|
if (this._lineMode) this._createLine(this._constructPicks[0], this._constructPicks[1]);
|
||||||
|
else this._createPlane(this._constructPicks[0], this._constructPicks[1], this._constructPicks[2]);
|
||||||
|
this._constructPicks = [];
|
||||||
|
}
|
||||||
|
this._rebuildConstructions();
|
||||||
|
this._notify();
|
||||||
|
}
|
||||||
|
|
||||||
|
_createLine(pA, pB) {
|
||||||
|
if (pA.distanceTo(pB) < 1e-6) return;
|
||||||
|
const name = this._lineLabel(this._nextLineName++);
|
||||||
|
this._lines.push({
|
||||||
|
id: 'L' + this._constructSeq, seq: this._constructSeq++,
|
||||||
|
name, a: { x: pA.x, y: pA.y, z: pA.z }, b: { x: pB.x, y: pB.y, z: pB.z },
|
||||||
|
color: 0x38BDF8,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
_createPlane(p1, p2, p3) {
|
||||||
|
const v1 = new THREE.Vector3().subVectors(p2, p1);
|
||||||
|
const v2 = new THREE.Vector3().subVectors(p3, p1);
|
||||||
|
const n = new THREE.Vector3().crossVectors(v1, v2);
|
||||||
|
if (n.length() < 1e-6) return; // 3 collinear points → no plane
|
||||||
|
n.normalize();
|
||||||
|
const name = this._planeLabel(this._nextPlaneName++);
|
||||||
|
this._planes.push({
|
||||||
|
id: 'P' + this._constructSeq, seq: this._constructSeq++,
|
||||||
|
name, point: { x: p1.x, y: p1.y, z: p1.z }, normal: { x: n.x, y: n.y, z: n.z },
|
||||||
|
def: [p1, p2, p3].map(p => ({ x: p.x, y: p.y, z: p.z })),
|
||||||
|
color: 0xC4B5FD,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
_rebuildConstructions() {
|
||||||
|
if (!this._constructGroup) return;
|
||||||
|
this._clearGroup(this._constructGroup);
|
||||||
|
const ext = this._sceneRadius() * 1.6 + 2;
|
||||||
|
for (const pl of this._planes) this._drawPlaneObject(pl, ext);
|
||||||
|
for (const l of this._lines) this._drawLineObject(l, ext);
|
||||||
|
// in-progress picks (highlight spheres)
|
||||||
|
for (const p of this._constructPicks) {
|
||||||
|
const s = new THREE.Mesh(
|
||||||
|
new THREE.SphereGeometry(0.13, 12, 12),
|
||||||
|
new THREE.MeshBasicMaterial({ color: 0xFB7185 }));
|
||||||
|
s.position.set(p.x, p.y, p.z);
|
||||||
|
s.renderOrder = 6;
|
||||||
|
this._constructGroup.add(s);
|
||||||
|
}
|
||||||
|
this._invalidate();
|
||||||
|
}
|
||||||
|
|
||||||
|
_drawLineObject(l, ext) {
|
||||||
|
const A = new THREE.Vector3(l.a.x, l.a.y, l.a.z);
|
||||||
|
const B = new THREE.Vector3(l.b.x, l.b.y, l.b.z);
|
||||||
|
const dir = new THREE.Vector3().subVectors(B, A).normalize();
|
||||||
|
const p1 = A.clone().addScaledVector(dir, -ext);
|
||||||
|
const p2 = B.clone().addScaledVector(dir, ext);
|
||||||
|
const geo = new THREE.BufferGeometry().setFromPoints([p1, p2]);
|
||||||
|
const line = new THREE.Line(geo, new THREE.LineBasicMaterial({ color: l.color, transparent: true, opacity: 0.95 }));
|
||||||
|
line.renderOrder = 4;
|
||||||
|
this._constructGroup.add(line);
|
||||||
|
for (const P of [A, B]) {
|
||||||
|
const s = new THREE.Mesh(new THREE.SphereGeometry(0.09, 10, 10), new THREE.MeshBasicMaterial({ color: l.color }));
|
||||||
|
s.position.copy(P); s.renderOrder = 5;
|
||||||
|
this._constructGroup.add(s);
|
||||||
|
}
|
||||||
|
if (this.showLabels) {
|
||||||
|
const lbl = this._makeTextSprite(l.name, '#7DD3FC', 44);
|
||||||
|
lbl.position.copy(p2).addScaledVector(dir, -0.5).add(new THREE.Vector3(0.15, 0.25, 0));
|
||||||
|
this._constructGroup.add(lbl);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
_drawPlaneObject(pl, ext) {
|
||||||
|
const point = new THREE.Vector3(pl.point.x, pl.point.y, pl.point.z);
|
||||||
|
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 S = ext;
|
||||||
|
const corners = [
|
||||||
|
point.clone().addScaledVector(u, S).addScaledVector(w, S),
|
||||||
|
point.clone().addScaledVector(u, S).addScaledVector(w, -S),
|
||||||
|
point.clone().addScaledVector(u, -S).addScaledVector(w, -S),
|
||||||
|
point.clone().addScaledVector(u, -S).addScaledVector(w, S),
|
||||||
|
];
|
||||||
|
const positions = [];
|
||||||
|
corners.forEach(p => positions.push(p.x, p.y, p.z));
|
||||||
|
const geo = new THREE.BufferGeometry();
|
||||||
|
geo.setAttribute('position', new THREE.Float32BufferAttribute(positions, 3));
|
||||||
|
geo.setIndex([0, 1, 2, 0, 2, 3]);
|
||||||
|
const mesh = new THREE.Mesh(geo, new THREE.MeshBasicMaterial({
|
||||||
|
color: pl.color, transparent: true, opacity: 0.12, side: THREE.DoubleSide, depthWrite: false }));
|
||||||
|
mesh.renderOrder = 1;
|
||||||
|
this._constructGroup.add(mesh);
|
||||||
|
|
||||||
|
const border = [...corners, corners[0]];
|
||||||
|
const bline = new THREE.Line(new THREE.BufferGeometry().setFromPoints(border),
|
||||||
|
new THREE.LineDashedMaterial({ color: pl.color, dashSize: 0.22, gapSize: 0.14, transparent: true, opacity: 0.7 }));
|
||||||
|
bline.computeLineDistances();
|
||||||
|
bline.renderOrder = 2;
|
||||||
|
this._constructGroup.add(bline);
|
||||||
|
|
||||||
|
// Cross-section of the solid by this plane — makes the plane immediately meaningful.
|
||||||
|
if (pl.def) {
|
||||||
|
try {
|
||||||
|
const 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 (_) {}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.showLabels) {
|
||||||
|
const lbl = this._makeTextSprite(pl.name, '#DDD6FE', 48);
|
||||||
|
lbl.position.copy(point).add(new THREE.Vector3(0.2, 0.3, 0));
|
||||||
|
this._constructGroup.add(lbl);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
_clearGroup(group) {
|
_clearGroup(group) {
|
||||||
const disposeObj = (o) => {
|
const disposeObj = (o) => {
|
||||||
if (o.geometry) o.geometry.dispose();
|
if (o.geometry) o.geometry.dispose();
|
||||||
@@ -4034,7 +4271,7 @@ class StereoSim {
|
|||||||
'stereo-angle-edge-btn','stereo-angle-lp-btn','stereo-angle-dih-btn','stereo-angle-pp-btn','stereo-angle-skew-btn',
|
'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-mark-tick-btn','stereo-mark-par-btn',
|
||||||
'stereo-derive-mid-btn','stereo-derive-fc-btn','stereo-derive-alt-btn','stereo-derive-cen-btn',
|
'stereo-derive-mid-btn','stereo-derive-fc-btn','stereo-derive-alt-btn','stereo-derive-cen-btn',
|
||||||
'stereo-sect3p-btn'].forEach(id => {
|
'stereo-sect3p-btn','stereo-line-btn','stereo-plane-btn'].forEach(id => {
|
||||||
document.getElementById(id)?.classList.remove('active');
|
document.getElementById(id)?.classList.remove('active');
|
||||||
});
|
});
|
||||||
if (stereoSim) {
|
if (stereoSim) {
|
||||||
@@ -4045,9 +4282,13 @@ class StereoSim {
|
|||||||
stereoSim.setMarkMode(null);
|
stereoSim.setMarkMode(null);
|
||||||
stereoSim.setDeriveMode(null);
|
stereoSim.setDeriveMode(null);
|
||||||
stereoSim.toggleSection3P(false);
|
stereoSim.toggleSection3P(false);
|
||||||
|
stereoSim.setLineMode(false);
|
||||||
|
stereoSim.setPlaneMode(false);
|
||||||
}
|
}
|
||||||
const hint = document.getElementById('angle-hint');
|
const hint = document.getElementById('angle-hint');
|
||||||
if (hint) hint.textContent = '';
|
if (hint) hint.textContent = '';
|
||||||
|
const chint = document.getElementById('construct-hint');
|
||||||
|
if (chint) chint.textContent = '';
|
||||||
}
|
}
|
||||||
|
|
||||||
function stereoMeasure(btn) {
|
function stereoMeasure(btn) {
|
||||||
@@ -4173,6 +4414,46 @@ class StereoSim {
|
|||||||
_stereoUpdatePointsInfo();
|
_stereoUpdatePointsInfo();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ── Constructions: lines & planes (Phase A) ── */
|
||||||
|
function stereoLineMode(btn) {
|
||||||
|
const on = !btn.classList.contains('active');
|
||||||
|
_stereoDeactivateTools();
|
||||||
|
btn.classList.toggle('active', on);
|
||||||
|
if (stereoSim) stereoSim.setLineMode(on);
|
||||||
|
const h = document.getElementById('construct-hint');
|
||||||
|
if (h) h.textContent = on ? 'Кликните 2 точки или вершины — построится прямая' : '';
|
||||||
|
}
|
||||||
|
|
||||||
|
function stereoPlaneMode(btn) {
|
||||||
|
const on = !btn.classList.contains('active');
|
||||||
|
_stereoDeactivateTools();
|
||||||
|
btn.classList.toggle('active', on);
|
||||||
|
if (stereoSim) stereoSim.setPlaneMode(on);
|
||||||
|
const h = document.getElementById('construct-hint');
|
||||||
|
if (h) h.textContent = on ? 'Кликните 3 точки или вершины — построится плоскость и её сечение тела' : '';
|
||||||
|
}
|
||||||
|
|
||||||
|
function stereoConstructUndo() {
|
||||||
|
if (stereoSim) stereoSim.removeLastConstruction();
|
||||||
|
}
|
||||||
|
|
||||||
|
function stereoConstructClear() {
|
||||||
|
_stereoDeactivateTools();
|
||||||
|
if (stereoSim) stereoSim.clearConstructions();
|
||||||
|
const h = document.getElementById('construct-hint');
|
||||||
|
if (h) h.textContent = '';
|
||||||
|
}
|
||||||
|
|
||||||
|
function _stereoUpdateConstructList() {
|
||||||
|
const el = document.getElementById('construct-list');
|
||||||
|
if (!el || !stereoSim) return;
|
||||||
|
const c = stereoSim.getConstructions();
|
||||||
|
const rows = [];
|
||||||
|
c.lines.forEach(l => rows.push('<div style="color:#7DD3FC">прямая <b>' + l.name + '</b></div>'));
|
||||||
|
c.planes.forEach(p => rows.push('<div style="color:#DDD6FE">плоскость <b>' + p.name + '</b>: ' + p.eq + '</div>'));
|
||||||
|
el.innerHTML = rows.join('');
|
||||||
|
}
|
||||||
|
|
||||||
/* ── Section through 3 points UI ── */
|
/* ── Section through 3 points UI ── */
|
||||||
function stereoSection3P(btn) {
|
function stereoSection3P(btn) {
|
||||||
const on = !btn.classList.contains('active');
|
const on = !btn.classList.contains('active');
|
||||||
@@ -4302,6 +4583,9 @@ class StereoSim {
|
|||||||
// Section-3P panel
|
// Section-3P panel
|
||||||
_stereoUpdateSection3PPanel();
|
_stereoUpdateSection3PPanel();
|
||||||
|
|
||||||
|
// Construction tree (lines & planes)
|
||||||
|
_stereoUpdateConstructList();
|
||||||
|
|
||||||
// Live readout overlay (section type/area/perimeter, last measurement)
|
// Live readout overlay (section type/area/perimeter, last measurement)
|
||||||
_stereoUpdateReadout(info);
|
_stereoUpdateReadout(info);
|
||||||
|
|
||||||
|
|||||||
@@ -3672,6 +3672,23 @@
|
|||||||
</div>
|
</div>
|
||||||
<div id="points-info" style="font-size:0.65rem;color:rgba(255,255,255,0.4);margin-top:2px"></div>
|
<div id="points-info" style="font-size:0.65rem;color:rgba(255,255,255,0.4);margin-top:2px"></div>
|
||||||
|
|
||||||
|
<!-- ── Построения (прямые / плоскости) ── -->
|
||||||
|
<div class="gp-section-title" style="margin-top:8px;margin-bottom:6px">Построения</div>
|
||||||
|
<div class="st-tool-grid">
|
||||||
|
<button class="st-tool-btn" id="stereo-line-btn" onclick="stereoLineMode(this)" title="Прямая через 2 точки — кликните две вершины или точки">
|
||||||
|
<svg viewBox="0 0 24 24"><line x1="3" y1="21" x2="21" y2="3"/><circle cx="6" cy="18" r="2" fill="currentColor"/><circle cx="18" cy="6" r="2" fill="currentColor"/></svg>Прямая
|
||||||
|
</button>
|
||||||
|
<button class="st-tool-btn" id="stereo-plane-btn" onclick="stereoPlaneMode(this)" title="Плоскость через 3 точки — кликните три вершины или точки">
|
||||||
|
<svg viewBox="0 0 24 24"><polygon points="3,16 13,20 21,8 11,4" fill="none"/><circle cx="3" cy="16" r="1.8" fill="currentColor"/><circle cx="21" cy="8" r="1.8" fill="currentColor"/><circle cx="11" cy="4" r="1.8" fill="currentColor"/></svg>Плоскость
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div class="st-action-grid" style="margin-top:3px">
|
||||||
|
<button class="st-action-btn" onclick="stereoConstructUndo()">Удалить последнее</button>
|
||||||
|
<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 id="construct-list" style="font-size:0.7rem;margin-top:4px;line-height:1.6"></div>
|
||||||
|
|
||||||
<!-- ── Метки рёбер ── -->
|
<!-- ── Метки рёбер ── -->
|
||||||
<div class="gp-section-title" style="margin-top:8px;margin-bottom:6px">Метки рёбер</div>
|
<div class="gp-section-title" style="margin-top:8px;margin-bottom:6px">Метки рёбер</div>
|
||||||
<div class="st-tool-grid">
|
<div class="st-tool-grid">
|
||||||
|
|||||||
@@ -64,4 +64,37 @@
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
История: создан 2026-05-30. Фаза 6 добавлена 2026-05-30.
|
## Раунд «Конструктор» (2026-06-17) — упор на ученика-самоучку (песочница)
|
||||||
|
|
||||||
|
Цель: превратить отличный **визуализатор** в полноценный **конструктор** для самостоятельных
|
||||||
|
построений. Приоритеты, выбранные пользователем: **Фаза A (конструкторное ядро)** и
|
||||||
|
**Фаза C (сечения+)**. A — фундамент C (сечение через прямую+точку, параллельно прямой/плоскости
|
||||||
|
опираются на объекты-прямые/плоскости).
|
||||||
|
|
||||||
|
### Фаза A — Конструкторное ядро
|
||||||
|
|
||||||
|
Прямые и плоскости как объекты первого класса + пересечения + параллели/перпендикуляры +
|
||||||
|
общий undo/redo + дерево именованных объектов.
|
||||||
|
|
||||||
|
- [~] A1 — **Объектная модель + базовые построения.** `_lines[]` (имена a,b,c…), `_planes[]`
|
||||||
|
(имена α,β,γ…), группа `_constructGroup`, сериализуемое хранение `{x,y,z}`. Инструменты
|
||||||
|
«Прямая по 2 точкам» и «Плоскость по 3 точкам» (пикинг вершин/точек). Плоскость рисует
|
||||||
|
полупрозрачный квад + пунктирную рамку + **сечение тела этой плоскостью** (через `_sliceByPlane`,
|
||||||
|
делает плоскость осмысленной сразу). Панель «Построения», список объектов с уравнением плоскости.
|
||||||
|
- [ ] A2 — **Пересечения** (прямая∩плоскость → точка; плоскость∩плоскость → прямая; прямая∩прямая
|
||||||
|
→ точка) + **именованное дерево** с удалением/цветом/видимостью отдельных объектов.
|
||||||
|
- [ ] A3 — **Параллели/перпендикуляры** (прямая ∥ прямой через точку; прямая ⟂ плоскости;
|
||||||
|
плоскость ∥ плоскости; плоскость ⟂ прямой = «плоскость по точке и нормали» — мост к Фазе C) +
|
||||||
|
**общий undo/redo** (снапшот всех пользовательских массивов построения, Ctrl+Z/Ctrl+Shift+Z).
|
||||||
|
|
||||||
|
### Фаза C — Сечения+
|
||||||
|
|
||||||
|
- [ ] C1 — Сечение **плоскостью-объектом** (из Фазы A): «показать как сечение» с площадью/периметром.
|
||||||
|
- [ ] C2 — Сечение, **параллельное прямой/плоскости**; сечение **через прямую и точку**.
|
||||||
|
- [ ] C3 — **«Натуральная величина» сечения** (разворот многоугольника сечения в плоскость экрана,
|
||||||
|
отдельная мини-панель) + **штриховка**.
|
||||||
|
- [ ] C4 — Честный конструктивный алгоритм следов с анимацией перехода между шагами (из бэклога Ф6).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
История: создан 2026-05-30. Фаза 6 добавлена 2026-05-30. Раунд «Конструктор» (Фазы A,C) — 2026-06-17.
|
||||||
|
|||||||
Reference in New Issue
Block a user