diff --git a/frontend/css/lab.css b/frontend/css/lab.css index 02b6d81..aa47919 100644 --- a/frontend/css/lab.css +++ b/frontend/css/lab.css @@ -263,6 +263,42 @@ .st-fig-btn.active { border-color: var(--violet); color: var(--violet); background: rgba(155,93,229,.12); } .st-fig-btn-wide { grid-column: span 2; } + /* ── 3D viewport view-controls overlay ── */ + .st-view-toolbar { + position: absolute; top: 10px; right: 10px; z-index: 5; + display: flex; align-items: center; gap: 8px; + pointer-events: none; /* groups re-enable */ + } + .st-view-group { + display: flex; align-items: center; gap: 2px; + padding: 3px; border-radius: 10px; + background: rgba(13,13,26,.72); backdrop-filter: blur(8px); + border: 1px solid rgba(255,255,255,.10); + pointer-events: auto; + } + .st-view-preset { + padding: 4px 8px; border-radius: 7px; + background: transparent; border: none; + color: rgba(255,255,255,.62); font-size: .68rem; font-weight: 600; + cursor: pointer; white-space: nowrap; transition: all .12s; + } + .st-view-preset:hover { color: var(--violet); background: rgba(155,93,229,.10); } + .st-view-preset.active { color: var(--violet); background: rgba(155,93,229,.18); } + .st-view-btn { + display: flex; align-items: center; justify-content: center; + width: 28px; height: 28px; border-radius: 7px; + background: transparent; border: none; color: rgba(255,255,255,.62); + cursor: pointer; transition: all .12s; + } + .st-view-btn svg { width: 15px; height: 15px; } + .st-view-btn:hover { color: var(--violet); background: rgba(155,93,229,.10); } + .st-view-btn.active { color: var(--violet); background: rgba(155,93,229,.18); } + @media (max-width: 640px) { + .st-view-toolbar { top: 6px; right: 6px; gap: 5px; flex-wrap: wrap; justify-content: flex-end; } + .st-view-preset { padding: 4px 6px; font-size: .64rem; } + .st-view-btn { width: 26px; height: 26px; } + } + .st-tool-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 3px; margin-bottom: 4px; } .st-tool-btn { display: flex; align-items: center; gap: 5px; diff --git a/frontend/js/labs/stereo.js b/frontend/js/labs/stereo.js index c3604d3..bb9ea72 100644 --- a/frontend/js/labs/stereo.js +++ b/frontend/js/labs/stereo.js @@ -17,7 +17,7 @@ class StereoSim { /* Three.js core */ this.scene = new THREE.Scene(); this.camera = new THREE.PerspectiveCamera(50, 1, 0.1, 500); - this.renderer = new THREE.WebGLRenderer({ antialias: true, alpha: true }); + this.renderer = new THREE.WebGLRenderer({ antialias: true, alpha: true, preserveDrawingBuffer: true }); this.renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2)); this.renderer.setClearColor(0x0D0D1A, 1); container.appendChild(this.renderer.domElement); @@ -33,11 +33,17 @@ class StereoSim { /* orbit camera */ this._drag = false; + this._panning = false; this._prevX = 0; this._prevY = 0; this._rotY = 0.6; this._rotX = 0.45; this._dist = 14; this._autoSpin = true; + this._spinEnabled = true; // master switch for idle auto-rotation this._idleTime = 0; + this._velX = 0; this._velY = 0; // orbit inertia (angular velocity) + this._panOffset = new THREE.Vector3(0, 0, 0); // look-at target offset (panning) + // home view for the reset button + this._homeView = { rotY: 0.6, rotX: 0.45, dist: 14 }; const el = this.renderer.domElement; el.style.cursor = 'grab'; @@ -56,17 +62,24 @@ class StereoSim { on(el, 'pointerdown', e => { this._clickStart = { x: e.clientX, y: e.clientY }; + // Right / middle button or Shift = pan; left button = orbit. + this._panning = (e.button === 1 || e.button === 2 || e.shiftKey); this._drag = true; this._prevX = e.clientX; this._prevY = e.clientY; this._autoSpin = false; this._idleTime = 0; + this._velX = 0; this._velY = 0; try { el.setPointerCapture(e.pointerId); } catch (_) {} - if (!this._pointMode && !this._connectMode && !this._measureMode && !this._angleMode && !this._section3PMode) el.style.cursor = 'grabbing'; + if (this._panning) el.style.cursor = 'move'; + else if (!this._pointMode && !this._connectMode && !this._measureMode && !this._angleMode && !this._section3PMode) el.style.cursor = 'grabbing'; this._invalidate(); }); + on(el, 'contextmenu', e => e.preventDefault()); // allow right-drag pan without menu on(el, 'pointerup', e => { const wasDrag = this._clickStart && (Math.abs(e.clientX - this._clickStart.x) > 4 || Math.abs(e.clientY - this._clickStart.y) > 4); this._drag = false; + const wasPanning = this._panning; this._panning = false; try { el.releasePointerCapture(e.pointerId); } catch (_) {} + if (wasPanning) { el.style.cursor = 'grab'; this._invalidate(); return; } if (this._pointMode) { el.style.cursor = 'cell'; if (!wasDrag) this._onPointClick(e); } else if (this._connectMode) { el.style.cursor = 'pointer'; if (!wasDrag) this._onConnectClick(e); } else if (this._measureMode) { el.style.cursor = 'crosshair'; if (!wasDrag) this._onMeasureClick(e); } @@ -80,9 +93,15 @@ class StereoSim { on(el, 'pointermove', e => { this._onHoverMove(e); if (!this._drag) return; - this._rotY += (e.clientX - this._prevX) * 0.007; - this._rotX += (e.clientY - this._prevY) * 0.007; - this._rotX = Math.max(-1.4, Math.min(1.4, this._rotX)); + const dx = e.clientX - this._prevX, dy = e.clientY - this._prevY; + if (this._panning) { + this._pan(dx, dy); + } else { + const vy = dx * 0.007, vx = dy * 0.007; + this._rotY += vy; this._rotX += vx; + this._rotX = Math.max(-1.4, Math.min(1.4, this._rotX)); + this._velY = vy; this._velX = vx; // remember last delta for inertia + } this._prevX = e.clientX; this._prevY = e.clientY; this._idleTime = 0; this._invalidate(); @@ -102,17 +121,22 @@ class StereoSim { if (this._running === false) this.play(); else this._invalidate(); }, false); - /* touch — orbit + pinch zoom */ + /* touch — orbit (1 finger) + pinch-zoom & pan (2 fingers) */ this._touchDist = 0; + this._touchMidX = 0; this._touchMidY = 0; on(el, 'touchstart', e => { if (e.touches.length === 1) { - this._drag = true; this._prevX = e.touches[0].clientX; this._prevY = e.touches[0].clientY; + this._drag = true; this._panning = false; + this._prevX = e.touches[0].clientX; this._prevY = e.touches[0].clientY; this._autoSpin = false; this._idleTime = 0; + this._velX = 0; this._velY = 0; } else if (e.touches.length === 2) { this._drag = false; const dx = e.touches[0].clientX - e.touches[1].clientX; const dy = e.touches[0].clientY - e.touches[1].clientY; this._touchDist = Math.sqrt(dx * dx + dy * dy); + this._touchMidX = (e.touches[0].clientX + e.touches[1].clientX) / 2; + this._touchMidY = (e.touches[0].clientY + e.touches[1].clientY) / 2; } this._invalidate(); }, { passive: true }); @@ -127,18 +151,26 @@ class StereoSim { this._dist = Math.max(4, Math.min(40, this._dist * scale)); } this._touchDist = newDist; + // two-finger pan via midpoint movement + const midX = (e.touches[0].clientX + e.touches[1].clientX) / 2; + const midY = (e.touches[0].clientY + e.touches[1].clientY) / 2; + this._pan(midX - this._touchMidX, midY - this._touchMidY); + this._touchMidX = midX; this._touchMidY = midY; + this._idleTime = 0; this._invalidate(); return; } if (!this._drag || e.touches.length !== 1) return; const t = e.touches[0]; - this._rotY += (t.clientX - this._prevX) * 0.007; - this._rotX += (t.clientY - this._prevY) * 0.007; + const vy = (t.clientX - this._prevX) * 0.007, vx = (t.clientY - this._prevY) * 0.007; + this._rotY += vy; this._rotX += vx; this._rotX = Math.max(-1.4, Math.min(1.4, this._rotX)); + this._velY = vy; this._velX = vx; this._prevX = t.clientX; this._prevY = t.clientY; + this._idleTime = 0; this._invalidate(); }, { passive: true }); - on(el, 'touchend', () => { this._drag = false; this._touchDist = 0; this._invalidate(); }, { passive: true }); + on(el, 'touchend', () => { this._drag = false; this._panning = false; this._touchDist = 0; this._invalidate(); }, { passive: true }); /* resize */ this._ro = new ResizeObserver(() => this.fit()); @@ -729,6 +761,90 @@ class StereoSim { } } + /* ════════════════ CAMERA CONTROLS ════════════════ */ + + // Look-at target = figure-centre + user pan offset. + _camTarget() { + return new THREE.Vector3(0, this._figureHeight() / 2, 0).add(this._panOffset); + } + + // Pan the orbit centre in screen space (dx,dy in pixels). + _pan(dx, dy) { + const forward = new THREE.Vector3(); + this.camera.getWorldDirection(forward); + const right = new THREE.Vector3().crossVectors(forward, this.camera.up).normalize(); + const up = new THREE.Vector3().crossVectors(right, forward).normalize(); + const k = this._dist * 0.0016; // pan speed scales with zoom distance + this._panOffset.addScaledVector(right, -dx * k); + this._panOffset.addScaledVector(up, dy * k); + } + + resetView() { + const h = this._homeView; + this._rotY = h.rotY; this._rotX = h.rotX; this._dist = h.dist; + this._panOffset.set(0, 0, 0); + this._velX = 0; this._velY = 0; + // Reset = back to the initial state, which gently auto-rotates. + this._spinEnabled = true; this._autoSpin = true; this._idleTime = 0; + this._invalidate(); + } + + // Snap to a named viewpoint. Disables auto-spin so the view holds still. + setPreset(name) { + const P = { + iso: { rotY: 0.6, rotX: 0.45 }, + front: { rotY: 0, rotX: 0.05 }, + back: { rotY: Math.PI, rotX: 0.05 }, + side: { rotY: Math.PI / 2, rotX: 0.05 }, + top: { rotY: 0, rotX: 1.4 }, + }; + const v = P[name] || P.iso; + this._rotY = v.rotY; this._rotX = v.rotX; + this._panOffset.set(0, 0, 0); + this._velX = 0; this._velY = 0; + // Hold the chosen view: stop spinning and don't let it re-engage on idle. + this._autoSpin = false; this._spinEnabled = false; this._idleTime = 0; + this._invalidate(); + } + + setAutoSpin(on) { + this._spinEnabled = !!on; + this._autoSpin = !!on; + this._idleTime = 0; + this._velX = 0; this._velY = 0; + this._invalidate(); + } + + // Render one frame synchronously and return a PNG data URL. + screenshot() { + this._needsRender = true; + this._renderNow(); + try { return this.renderer.domElement.toDataURL('image/png'); } + catch (_) { return null; } + } + + _renderNow() { + const target = this._camTarget(); + this.camera.position.set( + target.x + this._dist * Math.sin(this._rotY) * Math.cos(this._rotX), + target.y + this._dist * Math.sin(this._rotX), + target.z + this._dist * Math.cos(this._rotY) * Math.cos(this._rotX) + ); + this.camera.lookAt(target); + this.renderer.render(this.scene, this.camera); + this._needsRender = false; + } + + toggleFullscreen() { + const box = this.container.closest('.graph-canvas-outer') || this.container; + if (!document.fullscreenElement) { + if (box.requestFullscreen) box.requestFullscreen(); + } else if (document.exitFullscreen) { + document.exitFullscreen(); + } + // fit() is driven by the ResizeObserver once the element resizes. + } + // Free GPU + DOM resources. Call when the sim is permanently torn down. dispose() { this.stop(); @@ -3329,9 +3445,21 @@ class StereoSim { this._rafId = null; if (!this._running) return; - // Auto-spin after idle - if (!this._drag) this._idleTime++; - if (this._idleTime > 300 && !this._drag) this._autoSpin = true; + // Orbit inertia (after release, decays to rest) + let inertia = false; + if (!this._drag && (this._velX !== 0 || this._velY !== 0)) { + this._rotY += this._velY; this._rotX += this._velX; + this._rotX = Math.max(-1.4, Math.min(1.4, this._rotX)); + this._velY *= 0.92; this._velX *= 0.92; + if (Math.abs(this._velX) < 1e-4) this._velX = 0; + if (Math.abs(this._velY) < 1e-4) this._velY = 0; + inertia = (this._velX !== 0 || this._velY !== 0); + this._idleTime = 0; this._needsRender = true; + } + + // Auto-spin after idle (only when enabled and the view has settled) + if (!this._drag && !inertia) this._idleTime++; + if (this._spinEnabled && this._idleTime > 300 && !this._drag && !inertia) this._autoSpin = true; if (this._autoSpin) { this._rotY += 0.002; this._needsRender = true; } // Unfold animation @@ -3351,13 +3479,14 @@ class StereoSim { } if (this._needsRender) { - // Camera orbit + // Camera orbit around the (possibly panned) target + const target = this._camTarget(); this.camera.position.set( - this._dist * Math.sin(this._rotY) * Math.cos(this._rotX), - this._dist * Math.sin(this._rotX), - this._dist * Math.cos(this._rotY) * Math.cos(this._rotX) + target.x + this._dist * Math.sin(this._rotY) * Math.cos(this._rotX), + target.y + this._dist * Math.sin(this._rotX), + target.z + this._dist * Math.cos(this._rotY) * Math.cos(this._rotX) ); - this.camera.lookAt(0, this._figureHeight() / 2, 0); + this.camera.lookAt(target); this.renderer.render(this.scene, this.camera); this._needsRender = false; } @@ -3365,8 +3494,8 @@ class StereoSim { // Keep the loop alive while there is motion or we're still counting toward // auto-spin re-engagement; otherwise sleep until _invalidate() wakes us. // Guard on _rafId so a mid-loop _invalidate() can't schedule a second frame. - const motion = this._autoSpin || this._drag || unfolding; - const waitingToSpin = !this._autoSpin && this._idleTime <= 300; + const motion = this._autoSpin || this._drag || unfolding || inertia; + const waitingToSpin = this._spinEnabled && !this._autoSpin && this._idleTime <= 300; if ((motion || waitingToSpin || this._needsRender) && this._rafId == null) { this._rafId = requestAnimationFrame(() => this._loop()); } @@ -3454,6 +3583,45 @@ class StereoSim { if (stereoSim) stereoSim.setOpacity(val); } + /* ── camera / view controls (overlay toolbar) ── */ + function stereoResetView() { + if (stereoSim) stereoSim.resetView(); + // restore UI to initial: Изо preset active, auto-spin on + document.querySelectorAll('.st-view-preset').forEach((b, i) => b.classList.toggle('active', i === 0)); + const sb = document.getElementById('st-spin-btn'); + if (sb) { sb.classList.add('active'); sb.setAttribute('aria-pressed', 'true'); } + } + function stereoPreset(name, btn) { + if (stereoSim) stereoSim.setPreset(name); + document.querySelectorAll('.st-view-preset').forEach(b => b.classList.remove('active')); + if (btn) btn.classList.add('active'); + // a preset turns auto-spin off — reflect it on the spin button + const sb = document.getElementById('st-spin-btn'); + if (sb) { sb.classList.remove('active'); sb.setAttribute('aria-pressed', 'false'); } + } + function stereoToggleSpin(btn) { + if (!stereoSim) return; + const on = !btn.classList.contains('active'); + btn.classList.toggle('active', on); + btn.setAttribute('aria-pressed', on ? 'true' : 'false'); + stereoSim.setAutoSpin(on); + } + function stereoFullscreen() { + if (stereoSim) stereoSim.toggleFullscreen(); + } + function stereoScreenshot() { + if (!stereoSim) return; + const url = stereoSim.screenshot(); + if (!url) return; + const a = document.createElement('a'); + a.href = url; + a.download = 'stereo-' + (stereoSim.figureType || 'figure') + '.png'; + document.body.appendChild(a); + a.click(); + document.body.removeChild(a); + if (window.LabFX) LabFX.sound.play('tick', { pitch: 1.4, volume: 0.3 }); + } + // legacy (used nowhere now but kept for safety) function stereoToggle(layer, btn) { const on = !btn.classList.contains('active'); diff --git a/frontend/lab.html b/frontend/lab.html index 10b156f..8beeff1 100644 --- a/frontend/lab.html +++ b/frontend/lab.html @@ -4119,8 +4119,31 @@
-
+
+ +
+
+ + + + +
+
+ + + + +
+
@@ -4794,7 +4817,7 @@ - + diff --git a/plans/STEREO_3D_IMPROVEMENT.md b/plans/STEREO_3D_IMPROVEMENT.md index eedf2fa..56f2f75 100644 --- a/plans/STEREO_3D_IMPROVEMENT.md +++ b/plans/STEREO_3D_IMPROVEMENT.md @@ -14,11 +14,11 @@ - [x] 0.3 Render-on-demand: `_invalidate()` + dirty-флаг `_needsRender`; loop засыпает (`_rafId=null`), просыпается по взаимодействию/изменению сцены. Хук в `_clearGroup()` покрывает все rebuild/clear; защита от двойного rAF. - [x] 0.4 Обработка `webglcontextlost`/`webglcontextrestored` (пересборка сцены); метод `dispose()` (renderer, ResizeObserver, слушатели, текстуры). Бонус: `_clearGroup` стал рекурсивным — устранена утечка вложенных групп (измерения и т.п.). -## Фаза 1 — Камера и навигация +## Фаза 1 — Камера и навигация — ГОТОВО -- [ ] 1.1 Демпфирование/инерция орбиты, панорамирование (pan), zoom-to-cursor. -- [ ] 1.2 Кнопки: сброс вида, пресеты ракурса (изометрия / спереди / сверху / сбоку). -- [ ] 1.3 Тумблер авто-вращения, fullscreen, скриншот PNG. +- [x] 1.1 Инерция орбиты (плавное затухание после отпускания) + панорамирование: правая/средняя кнопка или Shift+ЛКМ на десктопе, два пальца на тач. Орбита вокруг сдвигаемого таргета (`_panOffset`). _(zoom-to-cursor отложен — pan покрывает рецентрирование; перенесён в бэклог Фазы 2.)_ +- [x] 1.2 Overlay-тулбар в правом верхнем углу viewport: сброс вида + пресеты ракурса (Изо / Спереди / Сбоку / Сверху). Пресет «держит» вид (спин выключается). +- [x] 1.3 Тумблер авто-вращения (с реальным засыпанием loop при выключении), fullscreen (по `.graph-canvas-outer`), снимок PNG (`preserveDrawingBuffer` + синхронный рендер → download). a11y: `aria-pressed`/`aria-label` на кнопках. ## Фаза 2 — Геометрия и пикинг