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.