From 1f461e96fd92422d576963dee908cb3d92695a35 Mon Sep 17 00:00:00 2001 From: Maxim Dolgolyov Date: Wed, 17 Jun 2026 18:02:06 +0300 Subject: [PATCH] =?UTF-8?q?feat(stereo):=20=D0=B2=D1=8B=D0=B4=D0=B5=D0=BB?= =?UTF-8?q?=D0=B5=D0=BD=D0=B8=D0=B5=20=D1=86=D0=B2=D0=B5=D1=82=D0=BE=D0=BC?= =?UTF-8?q?=20=E2=80=94=20=D0=BC=D0=BD=D0=BE=D0=B3=D0=BE=D1=83=D0=B3=D0=BE?= =?UTF-8?q?=D0=BB=D1=8C=D0=BD=D0=B8=D0=BA=20=D0=BF=D0=BE=20=D1=82=D0=BE?= =?UTF-8?q?=D1=87=D0=BA=D0=B0=D0=BC=20(=D1=81=20=D0=BF=D0=B0=D0=BB=D0=B8?= =?UTF-8?q?=D1=82=D1=80=D0=BE=D0=B9)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Новый инструмент «Многоугольник по точкам» (секция «Выделение цветом»): кликаешь точки/вершины по контуру → «Замкнуть» (или клик по первой точке) → область заливается полупрозрачным цветом + контур + вершины. Палитра из 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) --- frontend/css/lab.css | 9 +++ frontend/js/labs/stereo.js | 162 ++++++++++++++++++++++++++++++++++++- frontend/labs-bodies.html | 24 ++++++ 3 files changed, 192 insertions(+), 3 deletions(-) diff --git a/frontend/css/lab.css b/frontend/css/lab.css index 61aa02f..dfebcbb 100644 --- a/frontend/css/lab.css +++ b/frontend/css/lab.css @@ -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; diff --git a/frontend/js/labs/stereo.js b/frontend/js/labs/stereo.js index 01a3e2e..bca4249 100644 --- a/frontend/js/labs/stereo.js +++ b/frontend/js/labs/stereo.js @@ -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; diff --git a/frontend/labs-bodies.html b/frontend/labs-bodies.html index 2597d52..1a8de15 100644 --- a/frontend/labs-bodies.html +++ b/frontend/labs-bodies.html @@ -3676,6 +3676,30 @@
+ +
Выделение цветом
+
+ +
+
+ + + + + + +
+
+ + +
+
+ +
+
+
Построения