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:
@@ -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
@@ -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
@@ -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>
|
||||
|
||||
@@ -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.
|
||||
Reference in New Issue
Block a user