feat(stereo): выделение цветом — многоугольник по точкам (с палитрой)

Новый инструмент «Многоугольник по точкам» (секция «Выделение цветом»):
кликаешь точки/вершины по контуру → «Замкнуть» (или клик по первой точке)
→ область заливается полупрозрачным цветом + контур + вершины. Палитра из
6 цветов (свотчи), переключается. Можно выделить треугольник/грань/сечение
из выбранных точек, чтобы подсветить «фигуру по точкам».

- StereoSim: _polyMode/_polyPicks/_polyHighlights/_polyColor + _polyGroup;
  setPolyMode (взаимоисключение с другими инструментами), setPolyColor,
  closePoly (≥3 точек), removeLastPolyPick, clearPoly, _onPolyClick
  (авто-замыкание кликом по первой вершине), _rebuildPoly/_drawPolyHighlight/
  _drawPolyPreview (превью: пунктир + крупная 1-я точка-подсказка). Пикинг
  вершин/точек через _pickConstructPoint. Сброс в setFigure, очистка в dispose.
- Панель: секция «Выделение цветом» (кнопка, палитра .st-sw, Замкнуть/
  Отменить точку/Очистить, #poly-hint); glue stereoPolyMode/Color/Close/
  Undo/Clear; интеграция в _stereoDeactivateTools. CSS палитры в lab.css.

Верификация: node --check OK; headless-смоук 21/21 (режим+взаимоисключение,
пик→замыкание, дефолт/выбранный цвет, авто-замыкание по 1-й точке, требование
≥3, undo точки/выделения, clear, setFigure-сброс, dispose, счётчики
fill+контур+вершины); эмодзи/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 18:02:06 +03:00
parent 5e6effa8cd
commit 1f461e96fd
3 changed files with 192 additions and 3 deletions
+9
View File
@@ -413,6 +413,15 @@
.stereo-panel .st-acc-body { margin: 0 0 8px; padding: 0 1px; }
.stereo-panel .st-sublabel { opacity: .8; margin: 8px 0 6px; }
/* highlight-polygon colour palette */
.stereo-panel .st-poly-palette { display: flex; gap: 6px; margin: 4px 0 2px; flex-wrap: wrap; }
.stereo-panel .st-sw {
width: 20px; height: 20px; border-radius: 50%; cursor: pointer; padding: 0;
border: 2px solid rgba(255,255,255,.25); transition: transform .1s, border-color .12s, box-shadow .12s;
}
.stereo-panel .st-sw:hover { transform: scale(1.12); }
.stereo-panel .st-sw.active { border-color: #fff; box-shadow: 0 0 0 2px rgba(255,255,255,.25); }
.gp-preset-group { margin-bottom: 8px; }
.gp-preset-label {
font-size: 0.68rem; font-weight: 700; text-transform: uppercase;
+159 -3
View File
@@ -79,7 +79,7 @@ class StereoSim {
this._velX = 0; this._velY = 0;
try { el.setPointerCapture(e.pointerId); } catch (_) {}
if (this._panning) el.style.cursor = 'move';
else if (!this._pointMode && !this._connectMode && !this._measureMode && !this._angleMode && !this._section3PMode && !this._lineMode && !this._planeMode && !this._relMode && !this._divideMode && !this._dragPointMode) el.style.cursor = 'grabbing';
else if (!this._pointMode && !this._connectMode && !this._measureMode && !this._angleMode && !this._section3PMode && !this._lineMode && !this._planeMode && !this._relMode && !this._divideMode && !this._dragPointMode && !this._polyMode) el.style.cursor = 'grabbing';
this._invalidate();
});
on(el, 'contextmenu', e => e.preventDefault()); // allow right-drag pan without menu
@@ -106,6 +106,7 @@ class StereoSim {
else if (this._section3PMode) { el.style.cursor = 'crosshair'; if (!wasDrag) this._onSection3PClick(e); }
else if (this._lineMode || this._planeMode || this._divideMode) { el.style.cursor = 'crosshair'; if (!wasDrag) this._onConstructClick(e); }
else if (this._relMode) { el.style.cursor = 'crosshair'; if (!wasDrag) this._onRelClick(e); }
else if (this._polyMode) { el.style.cursor = 'crosshair'; if (!wasDrag) this._onPolyClick(e); }
else el.style.cursor = 'grab';
this._invalidate();
});
@@ -347,6 +348,15 @@ class StereoSim {
this._redoStack = [];
this._undoMax = 60;
/* ── Highlight polygons by points (colour fill) ── */
this._polyMode = false; // click points → close → filled coloured polygon
this._polyPicks = []; // Vector3[] in-progress vertices
this._polyHighlights = []; // [{id, pts:[{x,y,z}], color}]
this._polyColor = 0xF59E0B; // current highlight colour
this._polySeq = 0;
this._polyGroup = new THREE.Group();
this.scene.add(this._polyGroup);
this.onUpdate = null;
this._buildGrid();
@@ -393,6 +403,8 @@ class StereoSim {
this._relMode = null; this._sectionPlaneId = null; this._lastConstructMsg = '';
this._divideMode = false; this._dragPointMode = false; this._draggingCP = null; this._dragPlane = null;
this._undoStack = []; this._redoStack = [];
this._polyMode = false; this._polyPicks = []; this._polyHighlights = [];
this._clearGroup(this._polyGroup);
this._constructPicks = [];
this._nextLineName = 0; this._nextPlaneName = 0; this._nextCPointName = 0; this._constructSeq = 0;
this._clearGroup(this._constructGroup);
@@ -1001,7 +1013,7 @@ class StereoSim {
[this._figGroup, this._labelGroup, this._sectionGroup, this._sphereGroup,
this._measureGroup, this._measurePickGroup, this._gridGroup, this._markGroup,
this._derivedGroup, this._section3PGroup, this._angleGroup, this._pointGroup,
this._constructGroup]
this._constructGroup, this._polyGroup]
.forEach(g => g && this._clearGroup(g));
if (this._tooltipEl && this._tooltipEl.parentNode) this._tooltipEl.parentNode.removeChild(this._tooltipEl);
if (this.renderer) {
@@ -4208,6 +4220,118 @@ class StereoSim {
this._dragPlane = null;
}
/* ── Highlight polygon by points (colour fill) ── */
setPolyMode(on) {
this._polyMode = on;
if (on) {
this._lineMode = false; this._planeMode = false; this._divideMode = false;
this._dragPointMode = false; this._intersectMode = false; this._intersectSel = [];
this._relMode = null; this._constructPicks = [];
} else {
this._polyPicks = [];
}
this.renderer.domElement.style.cursor = on ? 'crosshair' : 'grab';
this._rebuildPoly();
this._notify();
}
setPolyColor(color) {
this._polyColor = color;
this._rebuildPoly();
}
// Close the in-progress polygon into a filled highlight. Returns true if closed.
closePoly() {
if (this._polyPicks.length < 3) return false;
this._polyHighlights.push({
id: 'H' + (this._polySeq++),
pts: this._polyPicks.map(p => ({ x: p.x, y: p.y, z: p.z })),
color: this._polyColor,
});
this._polyPicks = [];
this._rebuildPoly();
this._notify();
return true;
}
removeLastPolyPick() {
if (this._polyPicks.length) this._polyPicks.pop();
else if (this._polyHighlights.length) this._polyHighlights.pop();
this._rebuildPoly();
this._notify();
}
clearPoly() {
this._polyPicks = [];
this._polyHighlights = [];
this._rebuildPoly();
this._notify();
}
_onPolyClick(e) {
const pos = this._pickConstructPoint(e);
if (!pos) return;
// click the first vertex again → close the loop
if (this._polyPicks.length >= 3 && pos.distanceTo(this._polyPicks[0]) < 1e-3) { this.closePoly(); return; }
const last = this._polyPicks[this._polyPicks.length - 1];
if (last && last.distanceTo(pos) < 1e-4) return; // ignore duplicate click
this._polyPicks.push(pos);
if (window.LabFX) LabFX.sound.play('tick', { pitch: 1.5, volume: 0.25 });
this._rebuildPoly();
this._notify();
}
_rebuildPoly() {
if (!this._polyGroup) return;
this._clearGroup(this._polyGroup);
for (const h of this._polyHighlights) this._drawPolyHighlight(h);
if (this._polyPicks.length) this._drawPolyPreview();
this._invalidate();
}
_drawPolyHighlight(h) {
const pts = h.pts.map(p => new THREE.Vector3(p.x, p.y, p.z));
if (pts.length >= 3) {
const positions = [], indices = [];
pts.forEach(p => positions.push(p.x, p.y, p.z));
for (let i = 1; i < pts.length - 1; i++) indices.push(0, i, i + 1);
const geo = new THREE.BufferGeometry();
geo.setAttribute('position', new THREE.Float32BufferAttribute(positions, 3));
geo.setIndex(indices);
geo.computeVertexNormals();
const fill = new THREE.Mesh(geo, new THREE.MeshBasicMaterial({
color: h.color, transparent: true, opacity: 0.34, side: THREE.DoubleSide, depthWrite: false }));
fill.renderOrder = 2;
this._polyGroup.add(fill);
}
const outline = new THREE.Line(new THREE.BufferGeometry().setFromPoints([...pts, pts[0]]),
new THREE.LineBasicMaterial({ color: h.color, transparent: true, opacity: 0.95 }));
outline.renderOrder = 3;
this._polyGroup.add(outline);
for (const p of pts) {
const s = new THREE.Mesh(new THREE.SphereGeometry(0.08, 10, 10), new THREE.MeshBasicMaterial({ color: h.color }));
s.position.copy(p); s.renderOrder = 4;
this._polyGroup.add(s);
}
}
_drawPolyPreview() {
const pts = this._polyPicks;
if (pts.length >= 2) {
const ln = new THREE.Line(new THREE.BufferGeometry().setFromPoints(pts),
new THREE.LineDashedMaterial({ color: this._polyColor, dashSize: 0.14, gapSize: 0.08, transparent: true, opacity: 0.9 }));
ln.computeLineDistances();
this._polyGroup.add(ln);
}
pts.forEach((p, i) => {
const r = (i === 0 && pts.length >= 3) ? 0.15 : 0.09; // bigger 1st dot = "click to close"
const s = new THREE.Mesh(new THREE.SphereGeometry(r, 12, 12), new THREE.MeshBasicMaterial({ color: this._polyColor }));
s.position.copy(p); s.renderOrder = 5;
this._polyGroup.add(s);
});
}
_createLine(pA, pB) {
if (pA.distanceTo(pB) < 1e-6) return null;
this._pushHistory();
@@ -4921,7 +5045,7 @@ class StereoSim {
'stereo-derive-mid-btn','stereo-derive-fc-btn','stereo-derive-alt-btn','stereo-derive-cen-btn',
'stereo-sect3p-btn','stereo-line-btn','stereo-plane-btn','stereo-intersect-btn',
'stereo-rel-lpar-btn','stereo-rel-lperp-btn','stereo-rel-ppar-btn','stereo-rel-pperp-btn',
'stereo-divide-btn','stereo-dragpt-btn'].forEach(id => {
'stereo-divide-btn','stereo-dragpt-btn','stereo-poly-btn'].forEach(id => {
document.getElementById(id)?.classList.remove('active');
});
if (stereoSim) {
@@ -4938,6 +5062,7 @@ class StereoSim {
stereoSim.setRelMode(null);
stereoSim.setDivideMode(false);
stereoSim.setDragPointMode(false);
stereoSim.setPolyMode(false);
}
const hint = document.getElementById('angle-hint');
if (hint) hint.textContent = '';
@@ -5178,6 +5303,37 @@ class StereoSim {
if (h) h.textContent = on ? 'Тащите построенные точки (M, N…) мышью — двигаются в плоскости экрана' : '';
}
/* ── Highlight polygon by points (colour fill) ── */
function stereoPolyMode(btn) {
const on = !btn.classList.contains('active');
_stereoDeactivateTools();
btn.classList.toggle('active', on);
if (stereoSim) stereoSim.setPolyMode(on);
const h = document.getElementById('poly-hint');
if (h) h.textContent = on ? 'Кликайте точки/вершины по контуру; клик по первой или «Замкнуть» — заливка' : '';
}
function stereoPolyColor(hex, btn) {
document.querySelectorAll('#sim-stereo .st-sw').forEach(b => b.classList.remove('active'));
if (btn) btn.classList.add('active');
if (stereoSim) stereoSim.setPolyColor(parseInt(hex, 16));
}
function stereoPolyClose() {
if (!stereoSim) return;
const done = stereoSim.closePoly();
const h = document.getElementById('poly-hint');
if (h) h.textContent = done ? 'Многоугольник выделен. Можно начать новый контур.' : 'Нужно минимум 3 точки';
}
function stereoPolyUndo() { if (stereoSim) stereoSim.removeLastPolyPick(); }
function stereoPolyClear() {
if (stereoSim) stereoSim.clearPoly();
const h = document.getElementById('poly-hint');
if (h) h.textContent = '';
}
function _stereoUpdateConstructList() {
const el = document.getElementById('construct-list');
if (!el || !stereoSim) return;
+24
View File
@@ -3676,6 +3676,30 @@
</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 st-tool-btn-wide" id="stereo-poly-btn" onclick="stereoPolyMode(this)" title="Кликайте точки/вершины по контуру → область заливается выбранным цветом">
<svg viewBox="0 0 24 24"><polygon points="4,20 9,5 19,9 16,20" fill="currentColor" fill-opacity="0.25"/><circle cx="4" cy="20" r="1.8" fill="currentColor"/><circle cx="9" cy="5" r="1.8" fill="currentColor"/><circle cx="19" cy="9" r="1.8" fill="currentColor"/><circle cx="16" cy="20" r="1.8" fill="currentColor"/></svg>Многоугольник по точкам
</button>
</div>
<div class="st-poly-palette">
<button class="st-sw active" style="background:#F59E0B" onclick="stereoPolyColor('F59E0B',this)" title="Янтарный"></button>
<button class="st-sw" style="background:#06D6E0" onclick="stereoPolyColor('06D6E0',this)" title="Бирюзовый"></button>
<button class="st-sw" style="background:#EF476F" onclick="stereoPolyColor('EF476F',this)" title="Розовый"></button>
<button class="st-sw" style="background:#7BF5A4" onclick="stereoPolyColor('7BF5A4',this)" title="Зелёный"></button>
<button class="st-sw" style="background:#C4B5FD" onclick="stereoPolyColor('C4B5FD',this)" title="Фиолетовый"></button>
<button class="st-sw" style="background:#60A5FA" onclick="stereoPolyColor('60A5FA',this)" title="Синий"></button>
</div>
<div class="st-action-grid" style="margin-top:4px">
<button class="st-action-btn" onclick="stereoPolyClose()">Замкнуть</button>
<button class="st-action-btn" onclick="stereoPolyUndo()">Отменить точку</button>
</div>
<div class="st-action-grid" style="margin-top:3px">
<button class="st-action-btn st-tool-btn-wide" onclick="stereoPolyClear()" style="grid-column:span 2">Очистить выделения</button>
</div>
<div id="poly-hint" style="font-size:0.63rem;color:rgba(255,255,255,0.38);margin-top:3px;line-height:1.4"></div>
<!-- ── Построения (прямые / плоскости) ── -->
<div class="gp-section-title" style="margin-top:8px;margin-bottom:6px">Построения</div>
<div class="st-tool-grid">