diff options
Diffstat (limited to 'src/scene')
| -rw-r--r-- | src/scene/experiments.ts | 248 | ||||
| -rw-r--r-- | src/scene/lab.ts | 124 | ||||
| -rw-r--r-- | src/scene/primitives.ts | 107 | ||||
| -rw-r--r-- | src/scene/test4.ts | 204 |
4 files changed, 0 insertions, 683 deletions
diff --git a/src/scene/experiments.ts b/src/scene/experiments.ts deleted file mode 100644 index f42017d..0000000 --- a/src/scene/experiments.ts +++ /dev/null @@ -1,248 +0,0 @@ -import * as THREE from 'three'; -import { chargePair, carrierModel, number, plateModel, wireModel, type ExperimentId, type Parameters } from '../experiments'; -import { scene } from '../i18n'; -import { electricField, type Charge, type Point } from '../physics/electricField'; -import { forceOn, pairEnergy, potential } from '../physics/models'; -import { arrow, box, colours, grid, label, line, sphere } from './primitives'; -import { buildTestScene } from './test4'; - -export type Animate = (delta: number) => void; -const still: Animate = () => {}; -function signedCharge(group: THREE.Group, charge: Charge) { - const colour = charge.coulombs >= 0 ? colours.positive : colours.negative; - sphere(group, charge, 0.2, colour); - label(group, String.raw`$${charge.coulombs >= 0 ? '+' : '-'}${Math.abs(charge.coulombs * 1e9).toFixed(1)}\,\mathrm{nC}$`, { ...charge, y: charge.y + 0.55 }, colour, 1.25); -} -function compressedFieldArrow(group: THREE.Group, position: Point, direction: Point, colour = colours.field) { - const strength = Math.hypot(direction.x, direction.y, direction.z); - if (strength < 1e-8) return; - arrow(group, position, direction, 0.12 + 0.32 * strength / (strength + 4), colour); -} - -function fieldScene(group: THREE.Group, p: Parameters) { - grid(group); - const charges = chargePair(p); - charges.forEach(charge => signedCharge(group, charge)); - for (let x = -5; x <= 5; x += 0.6) for (let z = -5; z <= 5; z += 0.6) { - const point = { x, y: 0, z }; - if (charges.some(q => Math.hypot(q.x - x, q.z - z) < 0.42)) continue; - compressedFieldArrow(group, point, electricField(point, charges)); - } - const force = forceOn(charges[1], [charges[0]]); - arrow(group, { ...charges[1], y: 0.8 }, force, 1.3, colours.gold); - label(group, String.raw`$\vec F$ ${scene('onRightCharge')}`, { x: charges[1].x, y: 1.5, z: 0 }, colours.gold, 2); - sphere(group, { x: 0, y: 0, z: 0 }, 0.06, colours.gold); - label(group, scene('origin'), { x: 0, y: 0, z: 0.45 }, colours.muted, 0.9); -} - -function fluxScene(group: THREE.Group, p: Parameters) { - const radius = number(p, 'radius'); - const charge: Charge = { x: number(p, 'position'), y: 0, z: 0, coulombs: number(p, 'charge') * 1e-9 }; - grid(group, -3.4); - signedCharge(group, charge); - const surface = new THREE.Mesh(new THREE.SphereGeometry(radius, 40, 24), new THREE.MeshBasicMaterial({ color: colours.negative, wireframe: true, transparent: true, opacity: 0.11, depthWrite: false })); - group.add(surface); - for (let i = 0; i < 150; i++) { - const y = 1 - 2 * (i + 0.5) / 150; - const r = Math.sqrt(1 - y * y); - const theta = i * Math.PI * (3 - Math.sqrt(5)); - const normal = { x: r * Math.cos(theta), y, z: r * Math.sin(theta) }; - const point = { x: normal.x * radius, y: y * radius, z: normal.z * radius }; - if (Math.hypot(point.x - charge.x, point.y, point.z) < 0.3) continue; - const e = electricField(point, [charge]); - const dot = e.x * normal.x + e.y * normal.y + e.z * normal.z; - compressedFieldArrow(group, point, e, dot >= 0 ? colours.field : colours.positive); - } - label(group, scene('gaussian'), { x: 0, y: radius + 0.6, z: 0 }, colours.negative, 2.5); - line(group, [{ x: -4.7, y: 0, z: 0 }, { x: 4.7, y: 0, z: 0 }], colours.muted, 0.4); -} - -function energyScene(group: THREE.Group, p: Parameters) { - grid(group, -4.3); - const r = number(p, 'distance'); - const q = number(p, 'testCharge') * 1e-9; - const source: Charge = { x: -3, y: 0, z: 1.5, coulombs: 3e-9 }; - const moving: Charge = { x: -3 + r, y: 0, z: 1.5, coulombs: q }; - signedCharge(group, source); - signedCharge(group, moving); - line(group, [source, moving], colours.muted); - arrow(group, { ...moving, y: 0.7 }, forceOn(moving, [source]), 1.1, colours.gold); - const curve: Point[] = []; - for (let distance = 0.75; distance <= 6; distance += 0.05) curve.push({ x: -3 + distance, y: pairEnergy(3e-9, q, distance) * 1e9 / 20, z: -1.5 }); - line(group, curve, colours.field); - line(group, [{ x: -2.5, y: 0, z: -1.5 }, { x: 3.5, y: 0, z: -1.5 }], colours.muted); - line(group, [{ x: -3, y: -5.5, z: -1.5 }, { x: -3, y: 5.5, z: -1.5 }], colours.muted, 0.5); - sphere(group, { x: -3 + r, y: pairEnergy(3e-9, q, r) * 1e9 / 20, z: -1.5 }, 0.12, colours.gold); - label(group, String.raw`$U(r)$ · 20 nJ per height unit`, { x: 0, y: 5.7, z: -1.5 }, colours.field, 3.9); - label(group, '0 energy', { x: 4, y: 0, z: -1.5 }, colours.muted, 1.5); - label(group, String.raw`$r = ${r.toFixed(2)}\,\mathrm m$`, { x: 0, y: -0.65, z: 1.5 }, colours.muted, 2); -} - -function potentialScene(group: THREE.Group, p: Parameters) { - const charges = chargePair(p); - const geometry = new THREE.PlaneGeometry(10, 10, 64, 64); - geometry.rotateX(-Math.PI / 2); - const positions = geometry.getAttribute('position'); - const values: number[] = []; - const colourValues: number[] = []; - const negative = new THREE.Color(colours.negative); - const positive = new THREE.Color(colours.positive); - const neutral = new THREE.Color('#203847'); - for (let i = 0; i < positions.count; i++) { - const point = { x: positions.getX(i), y: 0, z: positions.getZ(i) }; - const v = potential(point, charges); - const height = Number.isFinite(v) ? Math.tanh(v / 15) : NaN; - values.push(height); - positions.setY(i, Number.isFinite(height) ? 2 * height : 0); - const colour = neutral.clone().lerp(height >= 0 ? positive : negative, Math.abs(Number.isFinite(height) ? height : 0)); - colourValues.push(colour.r, colour.g, colour.b); - } - const indices = geometry.getIndex()!; - const kept: number[] = []; - for (let i = 0; i < indices.count; i += 3) { - const a = indices.getX(i), b = indices.getX(i + 1), c = indices.getX(i + 2); - if ([a, b, c].every(index => Number.isFinite(values[index]))) kept.push(a, b, c); - } - geometry.setIndex(kept); - geometry.setAttribute('color', new THREE.Float32BufferAttribute(colourValues, 3)); - geometry.computeVertexNormals(); - group.add(new THREE.Mesh(geometry, new THREE.MeshStandardMaterial({ vertexColors: true, side: THREE.DoubleSide, roughness: 0.7, transparent: true, opacity: 0.88 }))); - for (let x = -4.5; x <= 4.5; x += 0.75) for (let z = -4.5; z <= 4.5; z += 0.75) { - const point = { x, y: 0, z }; - if (charges.some(q => Math.hypot(q.x - x, q.z - z) < 0.6)) continue; - const v = potential(point, charges); - if (!Number.isFinite(v)) continue; - compressedFieldArrow(group, { ...point, y: 2 * Math.tanh(v / 15) + 0.12 }, electricField(point, charges)); - } - const probe = { x: number(p, 'probe'), y: 0, z: 1 }; - const v = potential(probe, charges); - sphere(group, { ...probe, y: 2 * Math.tanh(v / 15) + 0.2 }, 0.13, colours.gold); - label(group, scene('probe'), { ...probe, y: 2 * Math.tanh(v / 15) + 0.8 }, colours.gold, 1.3); - charges.forEach(charge => signedCharge(group, { ...charge, y: Math.sign(charge.coulombs) * 2.2 })); - grid(group, -2.4); -} - -function plateScene(group: THREE.Group, p: Parameters, dielectric: boolean) { - const c = plateModel(p); - const halfGap = number(p, 'gap') * 0.75; - const side = Math.sqrt(number(p, 'area')) * 0.3; - // Uniform magnification maps lateral metres to 30 scene units/m and gap - // metres to 1500 units/m: gap is enlarged 50x relative to plate width. - for (const sign of [1, -1]) { - const x = -sign * halfGap; - const colour = sign === 1 ? colours.positive : colours.negative; - box(group, { x, y: 0, z: 0 }, { x: 0.05, y: side, z: side }, colour, 0.65); - label(group, sign === 1 ? '+ plate' : '− plate', { x, y: side / 2 + 0.45, z: 0 }, colour, 1.2); - for (let y = -0.35; y <= 0.35; y += 0.35) for (let z = -0.35; z <= 0.35; z += 0.35) { - sphere(group, { x: x + sign * 0.035, y: y * side, z: z * side }, 0.025, colour); - } - } - for (let y = -0.3; y <= 0.3; y += 0.3) for (let z = -0.3; z <= 0.3; z += 0.3) { - const length = Math.max(0.1, 2 * halfGap - 0.1); - // Repeated arrows are direction cues; readouts provide field magnitude. - arrow(group, { x: -halfGap + 0.05, y: y * side, z: z * side }, { x: 1, y: 0, z: 0 }, length, colours.field); - } - if (dielectric) { - box(group, { x: 0, y: 0, z: 0 }, { x: Math.max(0.04, 2 * halfGap - 0.08), y: side * 0.92, z: side * 0.92 }, colours.gold, 0.1); - const kappa = number(p, 'permittivity'); - if (kappa > 1) { - const boundOffset = Math.min(halfGap * 0.65, 0.12) * (kappa - 1) / kappa; - for (let y = -0.32; y <= 0.32; y += 0.32) for (let z = -0.32; z <= 0.32; z += 0.32) { - const center = { x: 0, y: y * side, z: z * side }; - sphere(group, { ...center, x: -boundOffset }, 0.035, colours.negative); - sphere(group, { ...center, x: boundOffset }, 0.035, colours.positive); - line(group, [{ ...center, x: -boundOffset }, { ...center, x: boundOffset }], colours.gold); - } - } - } - line(group, [{ x: -halfGap, y: -side, z: 0 }, { x: halfGap, y: -side, z: 0 }], colours.muted); - label(group, String.raw`$d = ${number(p, 'gap').toFixed(1)}\,\mathrm{mm}$ (${scene('gap')} $\times50$)`, { x: 0, y: -side - 0.45, z: 0 }, colours.muted, 2.5); - label(group, String.raw`$${c.voltage.toFixed(2)}\,\mathrm V$ · ${scene(p.connection === 'isolated' ? 'isolated' : 'connected')}`, { x: 0, y: side + 0.8, z: 0 }, colours.field, 3); - grid(group, -3.2); -} - -function currentScene(group: THREE.Group, p: Parameters): Animate { - grid(group, -2); - const model = wireModel(p); - const length = number(p, 'length') * 3; - const radius = Math.sqrt(number(p, 'area')) * 0.4; - const wire = new THREE.Mesh(new THREE.CylinderGeometry(radius, radius, length, 28, 1, true), new THREE.MeshStandardMaterial({ color: '#bf8158', transparent: true, opacity: 0.12, side: THREE.DoubleSide, depthWrite: false })); - wire.rotation.z = Math.PI / 2; - group.add(wire); - for (let i = 0; i < 6; i++) { - const x = length * ((i + 0.5) / 6 - 0.5); - for (let j = 0; j < 6; j++) { - const angle = j * Math.PI / 3; - sphere(group, { x, y: radius * 0.8 * Math.cos(angle), z: radius * 0.8 * Math.sin(angle) }, 0.055, colours.gold); - } - } - const carriers: THREE.Mesh[] = []; - for (let i = 0; i < 32; i++) { - const angle = i * 2.39996; - const r = radius * 0.65 * Math.sqrt((i % 7 + 1) / 8); - carriers.push(sphere(group, { x: length * (i / 32 - 0.5), y: r * Math.cos(angle), z: r * Math.sin(angle) }, 0.065, colours.negative)); - } - arrow(group, { x: -Math.sign(model.current) * 1.1, y: radius + 0.8, z: 0 }, { x: model.current, y: 0, z: 0 }, 2.2, colours.field); - label(group, scene(model.current === 0 ? 'noCurrent' : 'conventional'), { x: 0, y: radius + 1.5, z: 0 }, colours.field, 2.8); - arrow(group, { x: -Math.sign(model.electronDrift) * 1.1, y: -radius - 0.65, z: 0 }, { x: model.electronDrift, y: 0, z: 0 }, 2.2, colours.negative); - label(group, scene('drift'), { x: 0, y: -radius - 1.1, z: 0 }, colours.negative, 3.3); - const velocity = model.electronDrift * 600 * 3; - return delta => { - for (const electron of carriers) { - electron.position.x = ((electron.position.x + length / 2 + delta * velocity) % length + length) % length - length / 2; - } - }; -} - -function semiconductorScene(group: THREE.Group, p: Parameters): Animate { - grid(group, -3.5); - const s = carrierModel(p); - // 3 display units per eV; approximate intrinsic level at midgap. - const bandEdge = 1.12 / 2 * 3; - box(group, { x: 0, y: bandEdge + 0.4, z: 0 }, { x: 7, y: 0.8, z: 3 }, colours.negative, 0.12); - box(group, { x: 0, y: -bandEdge - 0.4, z: 0 }, { x: 7, y: 0.8, z: 3 }, colours.positive, 0.12); - line(group, [{ x: -3.5, y: 0, z: 1.6 }, { x: 3.5, y: 0, z: 1.6 }], colours.muted, 0.7); - line(group, [{ x: -3.6, y: s.fermiShift * 3, z: 1.65 }, { x: 3.6, y: s.fermiShift * 3, z: 1.65 }], colours.gold); - label(group, String.raw`$E_F$`, { x: 4, y: s.fermiShift * 3, z: 1.65 }, colours.gold, 0.7); - label(group, String.raw`$E_i\approx\text{${scene('midgap')}}$`, { x: -4.4, y: 0, z: 1.65 }, colours.muted, 1.7); - label(group, scene('conduction'), { x: 0, y: bandEdge + 1.1, z: 0 }, colours.negative, 2.8); - label(group, scene('valence'), { x: 0, y: -bandEdge - 1.1, z: 0 }, colours.positive, 2.8); - label(group, String.raw`$E_g=1.12\,\mathrm{eV}$`, { x: 4.1, y: 0, z: -1.3 }, colours.muted, 1.8); - line(group, [{ x: 3.8, y: -bandEdge, z: -1.3 }, { x: 3.8, y: bandEdge, z: -1.3 }], colours.muted); - const particles: { mesh: THREE.Mesh; base: THREE.Vector3; phase: number }[] = []; - for (const [density, sign] of [[s.electrons, 1], [s.holes, -1]]) { - const count = Math.round(3 + Math.max(0, Math.log10(density) - 8) * 2.5); - for (let i = 0; i < count; i++) { - const x = 6.4 * ((i * 0.618034) % 1 - 0.5); - const z = 2.5 * ((i * 0.414214) % 1 - 0.5); - const mesh = sphere(group, { x, y: sign * (bandEdge + 0.4), z }, 0.075, sign === 1 ? colours.negative : colours.positive); - if (sign === -1) { - (mesh.material as THREE.MeshStandardMaterial).wireframe = true; - } - particles.push({ mesh, base: mesh.position.clone(), phase: i * 2.4 + sign }); - } - } - let time = 0; - return delta => { - time += delta; - for (const particle of particles) { - particle.mesh.position.x = particle.base.x + 0.12 * Math.sin(time * 1.3 + particle.phase); - particle.mesh.position.z = particle.base.z + 0.12 * Math.cos(time * 1.7 + particle.phase); - } - }; -} - -export function buildExperiment(group: THREE.Group, id: ExperimentId, p: Parameters): Animate { - switch (id) { - case 'field': fieldScene(group, p); break; - case 'flux': fluxScene(group, p); break; - case 'energy': energyScene(group, p); break; - case 'potential': potentialScene(group, p); break; - case 'capacitance': plateScene(group, p, false); break; - case 'dielectric': plateScene(group, p, true); break; - case 'current': return currentScene(group, p); - case 'semiconductor': return semiconductorScene(group, p); - default: return buildTestScene(group, id, p); - } - return still; -} diff --git a/src/scene/lab.ts b/src/scene/lab.ts deleted file mode 100644 index e00c234..0000000 --- a/src/scene/lab.ts +++ /dev/null @@ -1,124 +0,0 @@ -import * as THREE from 'three'; -import { OrbitControls } from 'three/addons/controls/OrbitControls.js'; -import { CSS2DRenderer } from 'three/addons/renderers/CSS2DRenderer.js'; -import type { ExperimentId, Parameters } from '../experiments'; -import { onLanguageChange, t } from '../i18n'; -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 = t('webglStart'); - viewport.append(error); - const stop = onLanguageChange(() => { error.textContent = t('webglStart'); }); - return { update: () => {}, resetCamera: (_id?: ExperimentId) => {}, setPaused: () => {}, dispose: () => stop() }; - } - renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2)); - renderer.domElement.setAttribute('aria-label', t('canvas')); - renderer.domElement.setAttribute('tabindex', '0'); - viewport.append(renderer.domElement); - const labels = new CSS2DRenderer(); - labels.domElement.className = 'scene-labels'; - viewport.append(labels.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); - let currentId: ExperimentId | undefined; - let framingScale = 1; - const fitScale = (aspect: number) => currentId?.startsWith('test-') ? Math.max(1, Math.min(2, 1.1 / aspect)) : 1; - function resetCamera(id = currentId) { - currentId = id; - const { width, height } = viewport.getBoundingClientRect(); - framingScale = fitScale(width / Math.max(1, height)); - if (currentId?.startsWith('test-')) camera.position.set(3.7, 5, 14.5).multiplyScalar(framingScale); - else 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; - let lostWarning: HTMLElement | undefined; - const stopLanguage = onLanguageChange(() => { - renderer.domElement.setAttribute('aria-label', t('canvas')); - if (lostWarning) lostWarning.textContent = t('webglLost'); - }); - 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); - labels.setSize(width, height); - camera.aspect = width / height; - camera.updateProjectionMatrix(); - const nextScale = fitScale(camera.aspect); - camera.position.sub(controls.target).multiplyScalar(nextScale / framingScale).add(controls.target); - framingScale = nextScale; - controls.update(); - }); - 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 = t('webglLost'); - lostWarning = warning; - 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); - labels.render(scene, camera); - }); - return { - update, - resetCamera, - setPaused: (value: boolean) => { paused = value; }, - dispose: () => { - stopLanguage(); - renderer.setAnimationLoop(null); - observer.disconnect(); - controls.dispose(); - disposeGroup(group); - renderer.dispose(); - renderer.domElement.remove(); - labels.domElement.remove(); - }, - }; -} diff --git a/src/scene/primitives.ts b/src/scene/primitives.ts deleted file mode 100644 index 384ea1d..0000000 --- a/src/scene/primitives.ts +++ /dev/null @@ -1,107 +0,0 @@ -import * as THREE from 'three'; -import { CSS2DObject } from 'three/addons/renderers/CSS2DRenderer.js'; -import { renderProse } from '../math'; -import type { Point } from '../physics/electricField'; - -export const colours = { positive: '#ff967b', negative: '#73d7ff', field: '#91e2cb', gold: '#f5d58b', muted: '#8294a8' }; -export const vector = (p: Point) => new THREE.Vector3(p.x, p.y, p.z); -export function sphere(group: THREE.Group, position: Point, radius: number, colour: string): THREE.Mesh { - const mesh = new THREE.Mesh(new THREE.SphereGeometry(radius, 18, 12), new THREE.MeshStandardMaterial({ color: colour, roughness: 0.3, emissive: colour, emissiveIntensity: 0.12 })); - mesh.position.copy(vector(position)); - group.add(mesh); - return mesh; -} -export function box(group: THREE.Group, position: Point, dimensions: Point, colour: string, opacity = 1): THREE.Mesh { - const mesh = new THREE.Mesh(new THREE.BoxGeometry(dimensions.x, dimensions.y, dimensions.z), new THREE.MeshStandardMaterial({ color: colour, transparent: opacity < 1, opacity, depthWrite: opacity === 1, side: THREE.DoubleSide, roughness: 0.45 })); - mesh.position.copy(vector(position)); - group.add(mesh); - return mesh; -} -export function line(group: THREE.Group, points: Point[], colour: string, opacity = 1): THREE.Line { - const mesh = new THREE.Line(new THREE.BufferGeometry().setFromPoints(points.map(vector)), new THREE.LineBasicMaterial({ color: colour, transparent: opacity < 1, opacity })); - group.add(mesh); - return mesh; -} -export function arrow(group: THREE.Group, origin: Point, direction: Point, length: number, colour = colours.field): void { - const dir = vector(direction); - if (dir.lengthSq() < 1e-24) return; - group.add(new THREE.ArrowHelper(dir.normalize(), vector(origin), length, colour, Math.min(0.16, length * 0.3), Math.min(0.1, length * 0.2))); -} -export function label(group: THREE.Group, text: string, position: Point, colour: string = '#dbe7f0', width = 2, minViewportWidth = 0): THREE.Sprite | CSS2DObject { - if (text.includes('$')) { - const element = document.createElement('div'); - element.className = 'scene-math-label'; - element.style.color = colour; - renderProse(element, text); - const annotation = new CSS2DObject(element); - annotation.position.copy(vector(position)); - annotation.onBeforeRender = () => { - element.style.visibility = (element.parentElement?.clientWidth ?? Infinity) < minViewportWidth ? 'hidden' : 'visible'; - }; - group.add(annotation); - return annotation; - } - const canvas = document.createElement('canvas'); - const context = canvas.getContext('2d')!; - context.font = '500 44px system-ui, sans-serif'; - canvas.width = Math.ceil(context.measureText(text).width) + 36; - canvas.height = 80; - context.fillStyle = 'rgba(16, 24, 34, 0.85)'; - context.fillRect(0, 0, canvas.width, canvas.height); - context.font = '500 44px system-ui, sans-serif'; - context.fillStyle = colour; - context.textAlign = 'center'; - context.textBaseline = 'middle'; - context.fillText(text, canvas.width / 2, 40); - const texture = new THREE.CanvasTexture(canvas); - texture.colorSpace = THREE.SRGBColorSpace; - const sprite = new THREE.Sprite(new THREE.SpriteMaterial({ map: texture, depthTest: false })); - sprite.position.copy(vector(position)); - const aspect = canvas.width / canvas.height; - const screenHeight = 23 + Math.min(2, Math.max(-1, width - 2)) * 2; - const viewportSize = new THREE.Vector2(); - const cameraPosition = new THREE.Vector3(); - // Keep labels legible when the camera or viewport changes. Width hints - // affect text size slightly; the actual texture fits the text content. - sprite.onBeforeRender = (renderer, _scene, camera) => { - if (!(camera instanceof THREE.PerspectiveCamera)) return; - sprite.getWorldPosition(cameraPosition).applyMatrix4(camera.matrixWorldInverse); - renderer.getSize(viewportSize); - sprite.material.opacity = viewportSize.x < minViewportWidth ? 0 : 1; - const responsiveHeight = viewportSize.x < 600 ? screenHeight * 0.78 : screenHeight; - const worldHeight = 2 * Math.abs(cameraPosition.z) * Math.tan(THREE.MathUtils.degToRad(camera.getEffectiveFOV()) / 2) * responsiveHeight / Math.max(1, viewportSize.y); - sprite.scale.set(worldHeight * aspect, worldHeight, 1); - sprite.updateMatrixWorld(); - }; - group.add(sprite); - return sprite; -} -export function grid(group: THREE.Group, y = -0.25) { - const helper = new THREE.GridHelper(12, 12, 0x375066, 0x22313f); - helper.position.y = y; - group.add(helper); -} - -/** Dispose shared geometries/materials once per scene replacement, including - * textures owned by generated labels. No GPU resources survive a lesson. */ -export function disposeGroup(group: THREE.Group) { - const geometries = new Set<THREE.BufferGeometry>(); - const materials = new Set<THREE.Material>(); - const textures = new Set<THREE.Texture>(); - group.traverse(object => { - const mesh = object as THREE.Mesh; - if (mesh.geometry) geometries.add(mesh.geometry); - if (mesh.material) { - const list = Array.isArray(mesh.material) ? mesh.material : [mesh.material]; - for (const material of list) { - materials.add(material); - const map = (material as THREE.MeshBasicMaterial).map; - if (map) textures.add(map); - } - } - }); - geometries.forEach(geometry => geometry.dispose()); - materials.forEach(material => material.dispose()); - textures.forEach(texture => texture.dispose()); - group.clear(); -} diff --git a/src/scene/test4.ts b/src/scene/test4.ts deleted file mode 100644 index a3f6ccd..0000000 --- a/src/scene/test4.ts +++ /dev/null @@ -1,204 +0,0 @@ -import * as THREE from 'three'; -import { number, format as formatNumber, type Parameters, type TestId } from '../experiments'; -import { scene } from '../i18n'; -import { type Charge, type Point } from '../physics/electricField'; -import { pairEnergy } from '../physics/models'; -import { assemblyState, centralState, conservationState, motionState, movingCharge, progress, rotationState } from '../test4'; -import { arrow, box, colours, grid, label, line, sphere } from './primitives'; -import type { Animate } from './experiments'; - -const format = (value: number) => formatNumber(Math.abs(value) < 1e-10 ? 0 : value) - .replaceAll(',', '{,}').replace(/e([+-]?\d+)$/, String.raw`\times10^{$1}`); - -function charge(group: THREE.Group, position: Point, q: number, name: string, radius = 0.21) { - const colour = q === 0 ? colours.muted : q > 0 ? colours.positive : colours.negative; - const mesh = sphere(group, position, radius, colour); - label(group, String.raw`$${name} = ${q >= 0 ? '+' : '-'}${format(Math.abs(q) * 1e9)}\,\mathrm{nC}$`, { ...position, y: position.y + 0.6 }, colour, 1.2); - return mesh; -} -function pulse(mesh: THREE.Mesh): Animate { - let time = 0; - return delta => { time += delta; mesh.scale.setScalar(1 + 0.035 * Math.sin(time * 2)); }; -} -function field(group: THREE.Group, strength: number) { - if (strength === 0) return; - for (const x of [-4, -2, 0, 2, 4]) for (const y of [-1, 1, 3]) for (const z of [-2, 2]) { - arrow(group, { x, y, z }, { x: 1, y: 0, z: 0 }, 0.6, colours.field); - } - label(group, String.raw`$\vec E\to +x$`, { x: 3.8, y: 3.5, z: -2 }, colours.field, 1.6, 600); -} -function endpoint(group: THREE.Group, name: string, point: Point) { - const marker = sphere(group, point, 0.12, colours.gold); - (marker.material as THREE.MeshStandardMaterial).wireframe = true; - label(group, name, { ...point, y: point.y - 0.55 }, colours.gold, 1); -} - -function motion(group: THREE.Group, p: Parameters, angled: boolean): Animate { - grid(group, -3.5); - const m = motionState(p); - field(group, m.field); - const a = { x: -m.dx / 2, y: 0.6 - m.dy / 2, z: 0 }; - const b = { x: m.dx / 2, y: 0.6 + m.dy / 2, z: 0 }; - const curved = !angled && p.path === 'curved'; - const at = (t: number): Point => ({ x: a.x + m.dx * t, y: a.y + m.dy * t + (curved ? 2 * Math.sin(Math.PI * t) : 0), z: curved ? 1.3 * Math.sin(Math.PI * t) : 0 }); - line(group, Array.from({ length: 101 }, (_, i) => at(i / 100)), colours.gold); - if (curved) line(group, [a, b], colours.muted, 0.4); - endpoint(group, String.raw`$a:\; U=0$`, a); - endpoint(group, 'b', b); - const point = at(progress(p)); - const probe = charge(group, point, movingCharge(p), 'q_0'); - const f = movingCharge(p) * m.field; - arrow(group, { ...point, y: point.y + 0.1, z: point.z + 0.35 }, { x: f, y: 0, z: 0 }, 1, colours.gold); - if (f !== 0) label(group, String.raw`$\vec F_e$`, { x: point.x + Math.sign(f) * 0.6, y: point.y + 0.25, z: point.z + 0.65 }, colours.gold, 0.8); - const projection = { x: b.x, y: a.y, z: -0.18 }; - line(group, [{ ...a, z: -0.18 }, projection], colours.negative); - if (angled) { - line(group, [projection, { ...b, z: -0.18 }], colours.muted, 0.5); - arrow(group, { ...a, z: 0.2 }, { x: m.dx, y: m.dy, z: 0 }, m.s, colours.gold); - // The arc is measured from the +x direction even when cos alpha is negative. - const arc = Array.from({ length: 41 }, (_, i) => ({ x: a.x + 0.8 * Math.cos(m.angle * i / 40), y: a.y + 0.8 * Math.sin(m.angle * i / 40), z: 0.3 })); - line(group, arc, colours.field); - label(group, String.raw`$\alpha = ${number(p, 'angle')}^\circ$`, { x: a.x + 1, y: a.y + 0.5, z: 0.4 }, colours.field, 1.4); - } - label(group, String.raw`$\Delta x = ${format(m.dx)}\,\mathrm m$`, { x: 0, y: a.y - 1, z: 0 }, colours.negative, 2); - label(group, String.raw`$W_{ab} = ${format(m.work * 1e9)}\,\mathrm{nJ}$`, { x: 0, y: -2.7, z: 2 }, colours.gold, 2.8); - return pulse(probe); -} - -function conservation(group: THREE.Group, p: Parameters): Animate { - grid(group, -3.3); - const s = conservationState(p); - const source = { x: -3.5, y: 1.8, z: 0 }; - const point = { x: source.x + s.r, y: 1.8, z: 0 }; - charge(group, source, 3e-9, 'q'); - const probe = charge(group, point, movingCharge(p), 'q_0'); - line(group, [source, { x: 2, y: 1.8, z: 0 }], colours.muted, 0.5); - if (s.speed > 0) arrow(group, { ...point, y: 2.8 }, { x: 1, y: 0, z: 0 }, 0.8 + s.speed, colours.gold); - label(group, String.raw`$r = ${format(s.r)}\,\mathrm m,\quad v = ${format(s.speed)}\,\mathrm{m/s}$`, { x: 0, y: 3.8, z: 0 }, colours.gold, 3.5); - const baseline = -2.7; - for (const [i, [name, joules, colour]] of ([['U', s.u, colours.negative], ['K', s.kinetic, colours.gold], ['K + U', s.total, colours.field]] as [string, number, string][]).entries()) { - const x = -2.2 + i * 2.2, height = joules / s.total * 2.6; - if (height > 0.001) box(group, { x, y: baseline + height / 2, z: 1 }, { x: 0.85, y: height, z: 0.6 }, colour, 0.85); - line(group, [{ x: x - 0.55, y: baseline, z: 1 }, { x: x + 0.55, y: baseline, z: 1 }], colours.muted); - label(group, String.raw`$${name} = ${format(joules * 1e9)}\,\mathrm{nJ}$`, { x, y: baseline - 0.45, z: 1 }, colour, 1.3); - } - line(group, [{ x: -2.8, y: -0.1, z: 1 }, { x: 2.8, y: -0.1, z: 1 }], colours.field, 0.4); - label(group, scene('energyConstant'), { x: 0, y: -4.2, z: 1 }, colours.field, 3.5); - return pulse(probe); -} - -function central(group: THREE.Group, p: Parameters): Animate { - grid(group, -4.6); - const s = centralState(p); - const source = { x: -3.3, y: 1.4, z: 0 }; - charge(group, source, s.q, 'q'); - if (s.q !== 0) for (let i = 0; i < 24; i++) { - const angle = i * Math.PI / 12; - for (const radius of [1, 2.4, 3.6]) { - const dir = { x: Math.cos(angle), y: Math.sin(angle) * 0.6, z: Math.sin(angle) * 0.8 }; - arrow(group, { x: source.x + radius * dir.x, y: source.y + radius * dir.y, z: radius * dir.z }, { x: Math.sign(s.q) * dir.x, y: Math.sign(s.q) * dir.y, z: Math.sign(s.q) * dir.z }, 0.25, colours.field); - } - } - const visibleEnd = Number.isFinite(s.rb) ? s.rb : 6; - const at = (r: number) => ({ x: source.x + r, y: source.y, z: 0 }); - line(group, [at(s.ra), at(visibleEnd)], colours.gold); - endpoint(group, String.raw`$a:\;r_a=${s.ra}\,\mathrm m$`, at(s.ra)); - if (Number.isFinite(s.rb)) endpoint(group, String.raw`$b:\;r_b=${s.rb}\,\mathrm m$`, at(s.rb)); - else { - arrow(group, at(5.6), { x: 1, y: 0, z: 0 }, 1.3, colours.gold); - label(group, String.raw`$r_b\to\infty,\quad U_b=0$`, { x: 3.5, y: 2.8, z: 0 }, colours.gold, 2.3); - } - const radius = s.ra + progress(p) * (visibleEnd - s.ra); - const probe = charge(group, at(radius), s.q0, 'q_0'); - const forceSign = Math.sign(s.q * s.q0); - arrow(group, { ...at(radius), z: 0.4 }, { x: forceSign, y: 0, z: 0 }, 0.9, colours.gold); - const y0 = -2.4, z0 = 1.5; - const plot = (r: number): Point => ({ x: source.x + r, y: y0 + THREE.MathUtils.clamp(pairEnergy(s.q, s.q0, r) * 1e9 / 30, -2.5, 2.5), z: z0 }); - line(group, Array.from({ length: 108 }, (_, i) => plot(0.65 + i * 0.05)), colours.negative); - line(group, [{ x: -3, y: y0, z: z0 }, { x: 3.6, y: y0, z: z0 }], colours.muted, 0.6); - line(group, [{ x: -3, y: y0 - 1.6, z: z0 }, { x: -3, y: y0 + 2.3, z: z0 }], colours.muted, 0.6); - sphere(group, plot(radius), 0.12, colours.gold); - label(group, String.raw`$U(r)$ · 30 nJ / height unit`, { x: 0.8, y: -0.7, z: z0 }, colours.negative, 3); - label(group, String.raw`$U = 0\to$`, { x: 3.8, y: y0 - 0.3, z: z0 }, colours.muted, 1.4); - label(group, String.raw`$r\;(\mathrm m)\to$`, { x: 2, y: -4.4, z: z0 }, colours.muted, 1.4); - return pulse(probe); -} - -function assembly(group: THREE.Group, p: Parameters) { - grid(group, -1.4); - const s = assemblyState(p); - const names = ['q_1', 'q_2', 'q_3', 'q_0']; - s.charges.forEach((q, i) => { - if (i < s.stage) charge(group, q, q.coulombs, names[i]); - else { - const ghost = sphere(group, q, 0.19, colours.muted); - const material = ghost.material as THREE.MeshStandardMaterial; - material.wireframe = true; material.transparent = true; material.opacity = 0.35; - label(group, String.raw`$${names[i]}$ · not placed`, { ...q, y: q.y + 0.6 }, colours.muted, 1.2); - } - }); - for (let i = 0; i < s.stage; i++) for (let j = i + 1; j < s.stage; j++) { - const a: Charge = s.charges[i], b = s.charges[j]; - const colour = j === s.stage - 1 ? colours.gold : colours.field; - line(group, [a, b], colour, 0.8); - const r = Math.hypot(a.x - b.x, a.y - b.y, a.z - b.z); - const pair = `${i === 3 ? '0' : i + 1}${j === 3 ? '0' : j + 1}`; - label(group, String.raw`$U_{${pair}} = ${format(pairEnergy(a.coulombs, b.coulombs, r) * 1e9)}\,\mathrm{nJ}$`, { x: (a.x + b.x) / 2, y: (a.y + b.y) / 2 - 0.3, z: (a.z + b.z) / 2 }, colour, 1); - } - label(group, `${s.stage} charges · ${s.stage * (s.stage - 1) / 2} unique pairs`, { x: 0, y: 4.5, z: 0 }, colours.field, 3); - label(group, String.raw`$U = ${format(s.current * 1e9)}\,\mathrm{nJ}$`, { x: 0, y: -1.9, z: 1 }, colours.gold, 2.4); -} - -function dipole(group: THREE.Group, p: Parameters): Animate { - grid(group, -4); - const s = rotationState(p), d = number(p, 'length'), q = number(p, 'charge') * 1e-9; - field(group, s.field); - const centre = { x: 0, y: 1.5, z: 0 }; - const at = (theta: number, sign: number) => ({ x: sign * d / 2 * Math.cos(theta), y: centre.y + sign * d / 2 * Math.sin(theta), z: 0 }); - const minus = at(s.theta, -1), plus = at(s.theta, 1); - // Initial/final orientation marks help relate the scrubber to theta_a and theta_b. - for (const [theta, name] of [[s.a, 'a'], [s.b, 'b']] as [number, string][]) { - line(group, [at(theta, -1), at(theta, 1)], colours.muted, 0.25); - const end = at(theta, 1); - label(group, String.raw`$\theta_${name} = ${format(theta * 180 / Math.PI)}^\circ$`, { x: end.x * 1.6, y: centre.y + (end.y - centre.y) * 1.6 + 0.2, z: -0.6 }, colours.muted, 1.1, 600); - } - line(group, [minus, plus], colours.muted); - charge(group, minus, -q, 'q_-'); - const positive = charge(group, plus, q, 'q_+'); - arrow(group, minus, { x: plus.x - minus.x, y: plus.y - minus.y, z: 0 }, d, colours.field); - label(group, String.raw`$\vec p_e:\; -\to+$`, { x: -1.7, y: centre.y + 0.4, z: 0.65 }, colours.field, 1.5); - if (s.field !== 0) { - arrow(group, { ...plus, z: 0.2 }, { x: 1, y: 0, z: 0 }, 1.15, colours.gold); - arrow(group, { ...minus, z: 0.2 }, { x: -1, y: 0, z: 0 }, 1.15, colours.gold); - label(group, String.raw`$\vec F_+$`, { x: plus.x + 1.1, y: plus.y + 0.2, z: 0.2 }, colours.gold, 0.8); - label(group, String.raw`$\vec F_-$`, { x: minus.x - 1.1, y: minus.y - 0.3, z: 0.2 }, colours.gold, 0.8); - } - const torqueNormalized = s.field ? s.torqueZ / (s.moment * s.field) : 0; - if (Math.abs(torqueNormalized) > 1e-6) { - arrow(group, centre, { x: 0, y: 0, z: torqueNormalized }, Math.abs(torqueNormalized) * 1.8, colours.negative); - label(group, String.raw`$\vec\tau\to -z$`, { x: 2, y: 1.7, z: -1.9 }, colours.negative, 1.3); - } - const arc = Array.from({ length: 61 }, (_, i) => ({ x: 0.7 * Math.cos(s.theta * i / 60), y: centre.y + 0.7 * Math.sin(s.theta * i / 60), z: 0.25 })); - line(group, arc, colours.gold); - label(group, String.raw`$\theta = ${format(s.theta * 180 / Math.PI)}^\circ$`, { x: 2.5, y: 3.6, z: 0 }, colours.gold, 2); - const plot = (theta: number): Point => ({ x: -3 + theta / Math.PI * 6, y: -2 - (s.field === 0 ? 0 : Math.cos(theta)), z: 1.2 }); - line(group, Array.from({ length: 101 }, (_, i) => plot(i * Math.PI / 100)), colours.negative); - line(group, [{ x: -3.3, y: -2, z: 1.2 }, { x: 3.3, y: -2, z: 1.2 }], colours.muted, 0.5); - sphere(group, plot(s.theta), 0.13, colours.gold); - for (const [x, text] of [[-3, String.raw`$0^\circ$ · stable`], [0, String.raw`$90^\circ$ · $U=0$`], [3, String.raw`$180^\circ$ · unstable`]] as [number, string][]) { - label(group, s.field === 0 ? text.split(' · ')[0] : text, { x, y: -3.9, z: 1.2 }, colours.muted, 1.4); - } - label(group, s.field ? String.raw`$\frac{U}{p_eE} = -\cos\theta$` : String.raw`$E=0,\quad U=0$ at all angles`, { x: 0, y: -0.6, z: 1.2 }, colours.negative, 2.6); - return pulse(positive); -} - -export function buildTestScene(group: THREE.Group, id: TestId, p: Parameters): Animate { - switch (id) { - case 'test-work': return motion(group, p, false); - case 'test-conservation': return conservation(group, p); - case 'test-central': return central(group, p); - case 'test-assembly': assembly(group, p); return () => {}; - case 'test-uniform': return motion(group, p, true); - case 'test-dipole': return dipole(group, p); - } -} |