perf(stereo3d): Фаза 0 — render-on-demand, остановка фонового рендера, dispose

- lab-init: _pauseAllSims() паузит активный rAF-сим при переключении (раньше стерео рендерило невидимый canvas вечно)
- stereo: render-on-demand через _invalidate()/_needsRender, loop засыпает и просыпается по взаимодействию
- pointer/touch-слушатели перенесены с window на canvas (pointer-capture), трекаются и снимаются в dispose()
- обработка webglcontextlost/restored + метод dispose()
- _clearGroup стал рекурсивным (устранена утечка вложенных групп), a11y-атрибуты на canvas
- bump stereo.js?v=3

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Maxim Dolgolyov
2026-05-30 11:05:35 +03:00
parent ed97b6d90b
commit 8af85961b5
4 changed files with 206 additions and 27 deletions
+44
View File
@@ -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');
+113 -26
View File
@@ -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());
}
}
}
+1 -1
View File
@@ -4794,7 +4794,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=2"></script>
<script src="/js/labs/stereo.js?v=3"></script>
<script src="/js/notifications.js"></script>
<script src="/js/search.js"></script>
<script src="/js/mobile.js"></script>
+48
View File
@@ -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.