aboutsummaryrefslogtreecommitdiff
path: root/src/scene/lab.ts
diff options
context:
space:
mode:
Diffstat (limited to '')
-rw-r--r--src/scene/lab.ts97
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();
+ },
+ };
+}