diff options
| author | LalleSX | 2026-10-24 04:35:56 +0200 |
|---|---|---|
| committer | LalleSX | 2026-10-24 04:35:56 +0200 |
| commit | c4c1c840f8761ea45a3d2dcc912a59d8ced78cb5 (patch) | |
| tree | 8fcbbfffb6a34af73697a703f77e6b82f191b91f /src/scene/lab.ts | |
| download | 3D-Electrophysics-c4c1c840f8761ea45a3d2dcc912a59d8ced78cb5.tar.gz 3D-Electrophysics-c4c1c840f8761ea45a3d2dcc912a59d8ced78cb5.zip | |
init
Diffstat (limited to '')
| -rw-r--r-- | src/scene/lab.ts | 97 |
1 files changed, 97 insertions, 0 deletions
diff --git a/src/scene/lab.ts b/src/scene/lab.ts new file mode 100644 index 0000000..5d2cadf --- /dev/null +++ b/src/scene/lab.ts @@ -0,0 +1,97 @@ +import * as THREE from 'three'; +import { OrbitControls } from 'three/addons/controls/OrbitControls.js'; +import type { ExperimentId, Parameters } from '../experiments'; +import { buildExperiment, type Animate } from './experiments'; +import { disposeGroup } from './primitives'; + +export function createLab(viewport: HTMLElement) { + let renderer: THREE.WebGLRenderer; + try { + renderer = new THREE.WebGLRenderer({ antialias: true }); + } catch { + const error = document.createElement('div'); + error.className = 'webgl-error'; + error.setAttribute('role', 'alert'); + error.textContent = 'WebGL could not start. Enable browser hardware acceleration and reload to see the 3D scenes. Lessons, controls and measurements still work.'; + viewport.append(error); + return { update: () => {}, resetCamera: () => {}, setPaused: () => {}, dispose: () => {} }; + } + renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2)); + renderer.domElement.setAttribute('aria-label', '3D experiment: drag to orbit, scroll to zoom, right-drag to pan'); + renderer.domElement.setAttribute('tabindex', '0'); + viewport.append(renderer.domElement); + const scene = new THREE.Scene(); + scene.background = new THREE.Color('#101822'); + scene.add(new THREE.HemisphereLight(0xe9f7ff, 0x263149, 3)); + const keyLight = new THREE.DirectionalLight(0xffffff, 2); + keyLight.position.set(3, 6, 5); + scene.add(keyLight); + const camera = new THREE.PerspectiveCamera(45, 1, 0.1, 100); + const controls = new OrbitControls(camera, renderer.domElement); + controls.enableDamping = true; + controls.minDistance = 3; + controls.maxDistance = 32; + controls.listenToKeyEvents(renderer.domElement); + function resetCamera() { + camera.position.set(8, 7.5, 11); + controls.target.set(0, 0, 0); + controls.update(); + } + resetCamera(); + let group = new THREE.Group(); + scene.add(group); + let animate: Animate = () => {}; + let paused = window.matchMedia('(prefers-reduced-motion: reduce)').matches; + let lastTime = 0; + let pending: { id: ExperimentId; parameters: Parameters } | undefined; + function update(id: ExperimentId, parameters: Parameters) { + // Coalesce range events to one rebuild per animation frame. + pending = { id, parameters: { ...parameters } }; + } + const observer = new ResizeObserver(() => { + const { width, height } = viewport.getBoundingClientRect(); + if (width <= 0 || height <= 0) return; + renderer.setSize(width, height); + camera.aspect = width / height; + camera.updateProjectionMatrix(); + }); + observer.observe(viewport); + renderer.domElement.addEventListener('webglcontextlost', event => { + event.preventDefault(); + if (!viewport.querySelector('[role="alert"]')) { + const warning = document.createElement('p'); + warning.setAttribute('role', 'alert'); + warning.className = 'webgl-error'; + warning.textContent = 'WebGL context lost. Reload to restore the 3D view. Numeric experiments still work.'; + viewport.append(warning); + } + }); + renderer.setAnimationLoop(time => { + const delta = lastTime ? Math.min((time - lastTime) / 1000, 0.05) : 0; + lastTime = time; + if (pending) { + disposeGroup(group); + scene.remove(group); + group = new THREE.Group(); + scene.add(group); + animate = buildExperiment(group, pending.id, pending.parameters); + pending = undefined; + } + if (!paused && !document.hidden) animate(delta); + controls.update(); + renderer.render(scene, camera); + }); + return { + update, + resetCamera, + setPaused: (value: boolean) => { paused = value; }, + dispose: () => { + renderer.setAnimationLoop(null); + observer.disconnect(); + controls.dispose(); + disposeGroup(group); + renderer.dispose(); + renderer.domElement.remove(); + }, + }; +} |