feat(stereo3d): Фаза 1 — камера и навигация (инерция, pan, пресеты, скриншот)

- инерция орбиты с затуханием; панорамирование (ПКМ/СКМ/Shift+ЛКМ, 2 пальца)
- орбита вокруг сдвигаемого таргета (_panOffset)
- overlay-тулбар: сброс вида + пресеты ракурса (Изо/Спереди/Сбоку/Сверху)
- тумблер авто-вращения с реальным засыпанием loop, fullscreen, снимок PNG
- a11y-атрибуты на кнопках; bump stereo.js?v=4

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Maxim Dolgolyov
2026-05-30 11:13:04 +03:00
parent 96a2097e70
commit 7c598d6430
4 changed files with 253 additions and 26 deletions
+36
View File
@@ -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;
+188 -20
View File
@@ -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');
+25 -2
View File
@@ -4119,8 +4119,31 @@
<div id="stereo-formulas" style="font-size:0.72rem;color:rgba(255,255,255,0.7);line-height:1.5;margin-bottom:6px"></div>
</div>
<div class="graph-canvas-outer">
<div class="graph-canvas-outer" style="position:relative">
<div class="graph-canvas-wrap" id="stereo-container"></div>
<!-- ── view controls overlay ── -->
<div class="st-view-toolbar">
<div class="st-view-group" role="group" aria-label="Ракурс">
<button class="st-view-preset active" onclick="stereoPreset('iso',this)" title="Изометрия">Изо</button>
<button class="st-view-preset" onclick="stereoPreset('front',this)" title="Вид спереди">Спер.</button>
<button class="st-view-preset" onclick="stereoPreset('side',this)" title="Вид сбоку">Сбоку</button>
<button class="st-view-preset" onclick="stereoPreset('top',this)" title="Вид сверху">Сверху</button>
</div>
<div class="st-view-group" role="group" aria-label="Действия с видом">
<button class="st-view-btn" onclick="stereoResetView()" title="Сбросить вид" aria-label="Сбросить вид">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M3 12a9 9 0 1 0 3-6.7L3 8"/><path d="M3 3v5h5"/></svg>
</button>
<button class="st-view-btn active" id="st-spin-btn" onclick="stereoToggleSpin(this)" title="Авто-вращение" aria-label="Авто-вращение" aria-pressed="true">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 12a9 9 0 1 1-2.6-6.3"/><path d="M21 4v5h-5"/></svg>
</button>
<button class="st-view-btn" onclick="stereoFullscreen()" title="Во весь экран" aria-label="Во весь экран">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M8 3H5a2 2 0 0 0-2 2v3M21 8V5a2 2 0 0 0-2-2h-3M16 21h3a2 2 0 0 0 2-2v-3M3 16v3a2 2 0 0 0 2 2h3"/></svg>
</button>
<button class="st-view-btn" onclick="stereoScreenshot()" title="Снимок PNG" aria-label="Снимок PNG">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M23 19a2 2 0 0 1-2 2H3a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h4l2-3h6l2 3h4a2 2 0 0 1 2 2z"/><circle cx="12" cy="13" r="4"/></svg>
</button>
</div>
</div>
</div>
</div>
@@ -4794,7 +4817,7 @@
<script src="/js/labs/flask.js"></script>
<script src="/js/labs/redox.js"></script>
<script src="/js/labs/ionexchange.js"></script>
<script src="/js/labs/stereo.js?v=3"></script>
<script src="/js/labs/stereo.js?v=4"></script>
<script src="/js/notifications.js"></script>
<script src="/js/search.js"></script>
<script src="/js/mobile.js"></script>
+4 -4
View File
@@ -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 — Геометрия и пикинг