diff --git a/frontend/js/labs/lab-init.js b/frontend/js/labs/lab-init.js index 53c2e0c..f47c4d2 100644 --- a/frontend/js/labs/lab-init.js +++ b/frontend/js/labs/lab-init.js @@ -49,11 +49,55 @@ /* ── sim routing ── */ + // Pause all animation-loop sims (non-destructive). Called when switching + // between sims so a previously opened sim doesn't keep rendering offscreen. + function _pauseAllSims() { + if (pSim) pSim.pause(); + if (cSim) cSim.pause(); + if (gasSim) gasSim.stop(); + if (brownSim) brownSim.stop(); + if (statesSim) statesSim.stop(); + if (diffSim) diffSim.stop(); + if (cirSim) cirSim.stop(); + if (reacSim) reacSim.stop(); + if (flaskSim) flaskSim.stop(); + if (rdxSim) rdxSim.stop(); + if (ioxSim) ioxSim.stop(); + if (newtonSim) newtonSim.stop(); + if (sandboxSim && sandboxSim.stop) sandboxSim.stop(); + if (crystalSim) crystalSim.stop(); + if (orbitalsSim) orbitalsSim.stop(); + if (stereoSim) stereoSim.stop(); + if (chemSandSim) chemSandSim.stop(); + if (cellDivSim) cellDivSim.stop(); + if (photosynSim) photosynSim.stop(); + if (angryBirdsSim) angryBirdsSim.stop(); + if (trigSim) trigSim.stop(); + if (pendSim) pendSim.stop(); + if (eqSim) eqSim.stop(); + if (titrSim) titrSim.stop(); + if (probSim) probSim.stop(); + if (bohrSim) bohrSim.stop(); + if (elecSim) elecSim.stop(); + if (wavesSim) wavesSim.stop(); + if (radioactiveSim) radioactiveSim.stop(); + if (heSim) heSim.stop(); + if (qualSim) qualSim.stop(); + if (periodicSim) periodicSim.stop(); + if (organicSim) organicSim.stop(); + if (_solutionsSim) _solutionsSim.stop(); + if (mirrorSim && mirrorSim._playing) mirrorSim._stopAnim(); + if (mirrorSim && mirrorSim._photonRaf) mirrorSim._stopPhotons(); + } + function openSim(id) { if (_disabledSimIds.has(id.split(':')[0])) return; document.getElementById('lab-home').style.display = 'none'; document.getElementById('lab-sim').classList.add('open'); + // pause whatever sim was running before we switch to the new one + _pauseAllSims(); + // hide all inner bodies + controls ALL_SIM_BODIES.forEach(bid => document.getElementById(bid).style.display = 'none'); ALL_CTRL_BARS.forEach(bid => document.getElementById(bid).style.display = 'none'); diff --git a/frontend/js/labs/stereo.js b/frontend/js/labs/stereo.js index 390f301..c3604d3 100644 --- a/frontend/js/labs/stereo.js +++ b/frontend/js/labs/stereo.js @@ -10,6 +10,9 @@ class StereoSim { constructor(container) { this.container = container; this._running = false; + this._rafId = null; // active requestAnimationFrame id (null = loop asleep) + this._needsRender = true; // render-on-demand dirty flag + this._contextLost = false; /* Three.js core */ this.scene = new THREE.Scene(); @@ -38,17 +41,32 @@ class StereoSim { const el = this.renderer.domElement; el.style.cursor = 'grab'; + el.style.touchAction = 'none'; + el.setAttribute('tabindex', '0'); + el.setAttribute('role', 'img'); + el.setAttribute('aria-label', '3D-модель стереометрической фигуры'); this._clickStart = null; - el.addEventListener('pointerdown', e => { + // Listeners are scoped to the canvas (not window) and tracked for dispose(). + // pointer capture keeps move/up flowing while dragging outside the canvas. + this._listeners = []; + const on = (target, type, fn, opts) => { + target.addEventListener(type, fn, opts); + this._listeners.push([target, type, fn, opts]); + }; + + on(el, 'pointerdown', e => { this._clickStart = { x: e.clientX, y: e.clientY }; this._drag = true; this._prevX = e.clientX; this._prevY = e.clientY; this._autoSpin = false; this._idleTime = 0; + try { el.setPointerCapture(e.pointerId); } catch (_) {} if (!this._pointMode && !this._connectMode && !this._measureMode && !this._angleMode && !this._section3PMode) el.style.cursor = 'grabbing'; + this._invalidate(); }); - window.addEventListener('pointerup', e => { + 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; + try { el.releasePointerCapture(e.pointerId); } catch (_) {} 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); } @@ -57,8 +75,9 @@ class StereoSim { 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 el.style.cursor = 'grab'; + this._invalidate(); }); - window.addEventListener('pointermove', e => { + on(el, 'pointermove', e => { this._onHoverMove(e); if (!this._drag) return; this._rotY += (e.clientX - this._prevX) * 0.007; @@ -66,15 +85,26 @@ class StereoSim { this._rotX = Math.max(-1.4, Math.min(1.4, this._rotX)); this._prevX = e.clientX; this._prevY = e.clientY; this._idleTime = 0; + this._invalidate(); }); - el.addEventListener('wheel', e => { + on(el, 'wheel', e => { e.preventDefault(); this._dist = Math.max(4, Math.min(40, this._dist + e.deltaY * 0.02)); + this._invalidate(); }, { passive: false }); + // WebGL context loss / restore — keep the page alive if the GPU resets. + on(el, 'webglcontextlost', e => { e.preventDefault(); this._contextLost = true; this.stop(); }, false); + on(el, 'webglcontextrestored', () => { + this._contextLost = false; + this._buildGrid(); + this._buildFigure(); + if (this._running === false) this.play(); else this._invalidate(); + }, false); + /* touch — orbit + pinch zoom */ this._touchDist = 0; - el.addEventListener('touchstart', e => { + 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._autoSpin = false; this._idleTime = 0; @@ -84,8 +114,9 @@ class StereoSim { const dy = e.touches[0].clientY - e.touches[1].clientY; this._touchDist = Math.sqrt(dx * dx + dy * dy); } + this._invalidate(); }, { passive: true }); - el.addEventListener('touchmove', e => { + on(el, 'touchmove', e => { if (e.touches.length === 2) { // pinch zoom const dx = e.touches[0].clientX - e.touches[1].clientX; @@ -96,6 +127,7 @@ class StereoSim { this._dist = Math.max(4, Math.min(40, this._dist * scale)); } this._touchDist = newDist; + this._invalidate(); return; } if (!this._drag || e.touches.length !== 1) return; @@ -104,8 +136,9 @@ class StereoSim { this._rotX += (t.clientY - this._prevY) * 0.007; this._rotX = Math.max(-1.4, Math.min(1.4, this._rotX)); this._prevX = t.clientX; this._prevY = t.clientY; + this._invalidate(); }, { passive: true }); - el.addEventListener('touchend', () => { this._drag = false; this._touchDist = 0; }); + on(el, 'touchend', () => { this._drag = false; this._touchDist = 0; this._invalidate(); }, { passive: true }); /* resize */ this._ro = new ResizeObserver(() => this.fit()); @@ -678,11 +711,43 @@ class StereoSim { this.camera.aspect = w / h; this.camera.updateProjectionMatrix(); this.renderer.setSize(w, h); + this._invalidate(); } - play() { if (!this._running) { this._running = true; this._loop(); } } - stop() { this._running = false; } - pause() { this._running = false; } + play() { if (!this._running) { this._running = true; this._invalidate(); } } + stop() { + this._running = false; + if (this._rafId != null) { cancelAnimationFrame(this._rafId); this._rafId = null; } + } + pause() { this.stop(); } + + // Mark the scene dirty and wake the loop if it was asleep. + _invalidate() { + this._needsRender = true; + if (this._running && this._rafId == null && !this._contextLost) { + this._rafId = requestAnimationFrame(() => this._loop()); + } + } + + // Free GPU + DOM resources. Call when the sim is permanently torn down. + dispose() { + this.stop(); + if (this._ro) { this._ro.disconnect(); this._ro = null; } + if (this._listeners) { + for (const [t, type, fn, opts] of this._listeners) t.removeEventListener(type, fn, opts); + this._listeners = []; + } + [this._figGroup, this._labelGroup, this._sectionGroup, this._sphereGroup, + this._measureGroup, this._measurePickGroup, this._gridGroup, this._markGroup, + this._derivedGroup, this._section3PGroup, this._angleGroup, this._pointGroup] + .forEach(g => g && this._clearGroup(g)); + if (this._tooltipEl && this._tooltipEl.parentNode) this._tooltipEl.parentNode.removeChild(this._tooltipEl); + if (this.renderer) { + this.renderer.dispose(); + const el = this.renderer.domElement; + if (el && el.parentNode) el.parentNode.removeChild(el); + } + } /* ════════════════ GRID + AXES ════════════════ */ @@ -699,6 +764,7 @@ class StereoSim { axes.material.opacity = 0.4; this._gridGroup.add(axes); } + this._invalidate(); } /* ════════════════ FIGURE BUILDER ════════════════ */ @@ -735,6 +801,7 @@ class StereoSim { this._drawMidpoints(); this._renderEdgeMarks(); this._buildDerived3D(); + this._invalidate(); } /* ── BOX helpers ── */ @@ -1271,6 +1338,7 @@ class StereoSim { _updateSection() { this._clearGroup(this._sectionGroup); this._sectionPolygon = null; + this._invalidate(); if (!this.showSection) return; const figH = this._figureHeight(); @@ -1711,6 +1779,7 @@ class StereoSim { _updateSpheres() { this._clearGroup(this._sphereGroup); + this._invalidate(); if (this.showInscribed) { const r = this._inscribedRadius(); @@ -3051,15 +3120,19 @@ class StereoSim { /* ════════════════ UTILS ════════════════ */ _clearGroup(group) { + const disposeObj = (o) => { + if (o.geometry) o.geometry.dispose(); + if (o.material) { + const mats = Array.isArray(o.material) ? o.material : [o.material]; + for (const m of mats) { if (m.map) m.map.dispose(); m.dispose(); } + } + }; while (group.children.length) { const c = group.children[0]; - if (c.geometry) c.geometry.dispose(); - if (c.material) { - if (c.material.map) c.material.map.dispose(); - c.material.dispose(); - } + c.traverse(disposeObj); // dispose c plus any nested descendants (avoids leaks on nested groups) group.remove(c); } + this._invalidate(); } /* ════════════════ EDGE MARKS ════════════════ */ @@ -3253,18 +3326,20 @@ class StereoSim { /* ════════════════ ANIMATION LOOP ════════════════ */ _loop() { + this._rafId = null; if (!this._running) return; - requestAnimationFrame(() => this._loop()); // Auto-spin after idle - this._idleTime++; + if (!this._drag) this._idleTime++; if (this._idleTime > 300 && !this._drag) this._autoSpin = true; - if (this._autoSpin) this._rotY += 0.002; + if (this._autoSpin) { this._rotY += 0.002; this._needsRender = true; } // Unfold animation + let unfolding = false; if (this._unfold && this._unfoldProgress < this._unfoldTarget) { this._unfoldProgress = Math.min(1, this._unfoldProgress + 0.015); this._applyUnfold(this._unfoldProgress); + unfolding = true; this._needsRender = true; } else if (!this._unfold && this._unfoldProgress > 0) { this._unfoldProgress = Math.max(0, this._unfoldProgress - 0.015); this._applyUnfold(this._unfoldProgress); @@ -3272,17 +3347,29 @@ class StereoSim { this._figGroup.scale.y = 1; this._figGroup.position.y = 0; } + unfolding = true; this._needsRender = true; } - // Camera orbit - 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) - ); - this.camera.lookAt(0, this._figureHeight() / 2, 0); + if (this._needsRender) { + // Camera orbit + 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) + ); + this.camera.lookAt(0, this._figureHeight() / 2, 0); + this.renderer.render(this.scene, this.camera); + this._needsRender = false; + } - this.renderer.render(this.scene, this.camera); + // 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; + if ((motion || waitingToSpin || this._needsRender) && this._rafId == null) { + this._rafId = requestAnimationFrame(() => this._loop()); + } } } diff --git a/frontend/lab.html b/frontend/lab.html index 4ec31e3..10b156f 100644 --- a/frontend/lab.html +++ b/frontend/lab.html @@ -4794,7 +4794,7 @@ - + diff --git a/plans/STEREO_3D_IMPROVEMENT.md b/plans/STEREO_3D_IMPROVEMENT.md new file mode 100644 index 0000000..eedf2fa --- /dev/null +++ b/plans/STEREO_3D_IMPROVEMENT.md @@ -0,0 +1,48 @@ +# План улучшения симуляции «Стереометрия 3D» + +Файлы: `frontend/js/labs/stereo.js` (StereoSim, ~3720 строк), панель в `frontend/lab.html` (`#sim-stereo`), роутинг `frontend/js/labs/lab-init.js`. + +Статус-легенда: [ ] не начато · [~] в работе · [x] готово + +--- + +## Фаза 0 — Производительность и гигиена (быстрый эффект, низкий риск) — ГОТОВО + +- [x] 0.1 Останавливать loop при переключении симуляций. + Добавлен `_pauseAllSims()` в `lab-init.js`, вызывается в начале `openSim()` — все rAF-симы (включая стерео) паузятся при переходе. Раньше предыдущий сим рендерил невидимый canvas вечно. +- [x] 0.2 Pointer/touch-слушатели перенесены с `window` на `renderer.domElement`, с pointer-capture для драга вне холста; все слушатели трекаются и снимаются в `dispose()`. Canvas получил `tabindex`/`role`/`aria-label`. +- [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 Демпфирование/инерция орбиты, панорамирование (pan), zoom-to-cursor. +- [ ] 1.2 Кнопки: сброс вида, пресеты ракурса (изометрия / спереди / сверху / сбоку). +- [ ] 1.3 Тумблер авто-вращения, fullscreen, скриншот PNG. + +## Фаза 2 — Геометрия и пикинг + +- [ ] 2.1 Точные сечения кривых тел (окружность/эллипс для шара/цилиндра/конуса) вместо сэмплинга порогом. +- [ ] 2.2 Унифицировать пикинг (расстояние точка-отрезок везде, в т.ч. `_pickNearestEdgeIdx`). +- [ ] 2.3 HiDPI-метки (резкие спрайты/SDF), пул текстур вместо пересоздания. + +## Фаза 3 — Педагогика сечений + +- [ ] 3.1 Построение сечения «по следам» с пошаговой анимацией и подписью вершин. +- [ ] 3.2 Точки сечения в произвольной точке грани. +- [ ] 3.3 Постоянная панель readout: длины/углы/площадь сечения с пояснением. + +## Фаза 4 — Визуал + +- [ ] 4.1 Материалы: рёбра с anti-alias, подсветка активной грани, свечение вершин. +- [ ] 4.2 Аккуратные оси X/Y/Z с подписями, опциональный фон (тёмный/бумага). + +## Фаза 5 — Интеграция и архитектура + +- [ ] 5.1 Разбить `stereo.js` на модули (builders / sections / tools / ui), вынести константы и цвета. +- [ ] 5.2 Связать симуляцию с учебниками 10–11 (открывать тело/сечение из §-канвы и задач). +- [ ] 5.3 Доступность: клавиатура, `aria`, фокус. + +--- + +История: создан 2026-05-30.