aboutsummaryrefslogtreecommitdiff
path: root/src/scene
diff options
context:
space:
mode:
Diffstat (limited to 'src/scene')
-rw-r--r--src/scene/experiments.ts22
-rw-r--r--src/scene/lab.ts24
-rw-r--r--src/scene/primitives.ts21
-rw-r--r--src/scene/test4.ts203
4 files changed, 255 insertions, 15 deletions
diff --git a/src/scene/experiments.ts b/src/scene/experiments.ts
index 67d0322..f48c984 100644
--- a/src/scene/experiments.ts
+++ b/src/scene/experiments.ts
@@ -3,13 +3,14 @@ import { chargePair, carrierModel, number, plateModel, wireModel, type Experimen
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, `${charge.coulombs >= 0 ? '+' : '−'}${Math.abs(charge.coulombs * 1e9).toFixed(1)} nC`, { ...charge, y: charge.y + 0.55 }, colour, 1.25);
+ 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);
@@ -28,7 +29,7 @@ function fieldScene(group: THREE.Group, p: Parameters) {
}
const force = forceOn(charges[1], [charges[0]]);
arrow(group, { ...charges[1], y: 0.8 }, force, 1.3, colours.gold);
- label(group, 'F on right charge', { x: charges[1].x, y: 1.5, z: 0 }, colours.gold, 2);
+ label(group, String.raw`$\vec F$ on right charge`, { 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, 'Origin', { x: 0, y: 0, z: 0.45 }, colours.muted, 0.9);
}
@@ -71,9 +72,9 @@ function energyScene(group: THREE.Group, p: Parameters) {
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, 'U(r) · 20 nJ per height unit', { x: 0, y: 5.7, z: -1.5 }, colours.field, 3.9);
+ 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, `r = ${r.toFixed(2)} m`, { x: 0, y: -0.65, z: 1.5 }, colours.muted, 2);
+ 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) {
@@ -154,8 +155,8 @@ function plateScene(group: THREE.Group, p: Parameters, dielectric: boolean) {
}
}
line(group, [{ x: -halfGap, y: -side, z: 0 }, { x: halfGap, y: -side, z: 0 }], colours.muted);
- label(group, `d = ${number(p, 'gap').toFixed(1)} mm (gap ×50)`, { x: 0, y: -side - 0.45, z: 0 }, colours.muted, 2.5);
- label(group, `${c.voltage.toFixed(2)} V · ${p.connection === 'isolated' ? 'isolated' : 'supply connected'}`, { x: 0, y: side + 0.8, z: 0 }, colours.field, 3);
+ label(group, String.raw`$d = ${number(p, 'gap').toFixed(1)}\,\mathrm{mm}$ (gap $\times50$)`, { x: 0, y: -side - 0.45, z: 0 }, colours.muted, 2.5);
+ label(group, String.raw`$${c.voltage.toFixed(2)}\,\mathrm V$ · ${p.connection === 'isolated' ? 'isolated' : 'supply connected'}`, { x: 0, y: side + 0.8, z: 0 }, colours.field, 3);
grid(group, -3.2);
}
@@ -183,7 +184,7 @@ function currentScene(group: THREE.Group, p: Parameters): Animate {
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, model.current === 0 ? 'No net current' : 'Conventional current', { 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, 'Electron drift · displayed ×600', { x: 0, y: -radius - 1.1, z: 0 }, colours.negative, 3.3);
+ label(group, String.raw`Electron drift · displayed $\times600$`, { 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) {
@@ -201,11 +202,11 @@ function semiconductorScene(group: THREE.Group, p: Parameters): Animate {
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, 'EF', { x: 4, y: s.fermiShift * 3, z: 1.65 }, colours.gold, 0.7);
- label(group, 'Ei ≈ midgap', { x: -4.4, y: 0, z: 1.65 }, colours.muted, 1.7);
+ 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{midgap}$`, { x: -4.4, y: 0, z: 1.65 }, colours.muted, 1.7);
label(group, 'Conduction band', { x: 0, y: bandEdge + 1.1, z: 0 }, colours.negative, 2.8);
label(group, 'Valence band', { x: 0, y: -bandEdge - 1.1, z: 0 }, colours.positive, 2.8);
- label(group, 'Eg = 1.12 eV', { x: 4.1, y: 0, z: -1.3 }, colours.muted, 1.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]]) {
@@ -240,6 +241,7 @@ export function buildExperiment(group: THREE.Group, id: ExperimentId, p: Paramet
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
index 5d2cadf..782f300 100644
--- a/src/scene/lab.ts
+++ b/src/scene/lab.ts
@@ -1,5 +1,6 @@
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 { buildExperiment, type Animate } from './experiments';
import { disposeGroup } from './primitives';
@@ -14,12 +15,15 @@ export function createLab(viewport: HTMLElement) {
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: () => {} };
+ return { update: () => {}, resetCamera: (_id?: ExperimentId) => {}, 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 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));
@@ -32,8 +36,15 @@ export function createLab(viewport: HTMLElement) {
controls.minDistance = 3;
controls.maxDistance = 32;
controls.listenToKeyEvents(renderer.domElement);
- function resetCamera() {
- camera.position.set(8, 7.5, 11);
+ 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();
}
@@ -52,8 +63,13 @@ export function createLab(viewport: HTMLElement) {
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 => {
@@ -80,6 +96,7 @@ export function createLab(viewport: HTMLElement) {
if (!paused && !document.hidden) animate(delta);
controls.update();
renderer.render(scene, camera);
+ labels.render(scene, camera);
});
return {
update,
@@ -92,6 +109,7 @@ export function createLab(viewport: HTMLElement) {
disposeGroup(group);
renderer.dispose();
renderer.domElement.remove();
+ labels.domElement.remove();
},
};
}
diff --git a/src/scene/primitives.ts b/src/scene/primitives.ts
index bce9113..384ea1d 100644
--- a/src/scene/primitives.ts
+++ b/src/scene/primitives.ts
@@ -1,4 +1,6 @@
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' };
@@ -25,7 +27,20 @@ export function arrow(group: THREE.Group, origin: Point, direction: Point, lengt
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): THREE.Sprite {
+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';
@@ -52,7 +67,9 @@ export function label(group: THREE.Group, text: string, position: Point, colour:
if (!(camera instanceof THREE.PerspectiveCamera)) return;
sprite.getWorldPosition(cameraPosition).applyMatrix4(camera.matrixWorldInverse);
renderer.getSize(viewportSize);
- const worldHeight = 2 * Math.abs(cameraPosition.z) * Math.tan(THREE.MathUtils.degToRad(camera.getEffectiveFOV()) / 2) * screenHeight / Math.max(1, viewportSize.y);
+ 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();
};
diff --git a/src/scene/test4.ts b/src/scene/test4.ts
new file mode 100644
index 0000000..97e53ee
--- /dev/null
+++ b/src/scene/test4.ts
@@ -0,0 +1,203 @@
+import * as THREE from 'three';
+import { number, format as formatNumber, type Parameters, type TestId } from '../experiments';
+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, 'Energy changes form · total stays constant', { 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);
+ }
+}