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(); }, }; }