aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorLalleSX2026-10-24 14:18:00 +0200
committerLalleSX2026-10-24 14:18:00 +0200
commit389fb3a592832092835da0c2cc1608a57ba5f629 (patch)
treead1f89333ec3c84b906fb6546cee7824f58236fa
parentinit (diff)
download3D-Electrophysics-389fb3a592832092835da0c2cc1608a57ba5f629.tar.gz
3D-Electrophysics-389fb3a592832092835da0c2cc1608a57ba5f629.zip
Add tests for math rendering and interactive scenes; update navigation and viewport handling
- Updated `lab.spec.ts` to navigate directly to the field lesson. - Created `math.spec.ts` with tests for math rendering, error checking, and responsive design. - Added `test4.spec.ts` for interactive scenes, ensuring all questions are functional and accessible. - Modified `vite.config.ts` to keep original question/answer links navigable in production.
-rw-r--r--.vscode/settings.json3
-rw-r--r--README.md30
-rw-r--r--docs/Test_4.md63
-rw-r--r--docs/Test_Answers.md361
-rw-r--r--docs/implementation-progress.md52
-rw-r--r--docs/superpowers/plans/2026-10-24-test4.md59
-rw-r--r--index.html3
-rw-r--r--package-lock.json31
-rw-r--r--package.json2
-rw-r--r--src/experiments.ts61
-rw-r--r--src/main.ts147
-rw-r--r--src/math.ts42
-rw-r--r--src/physics/test4.test.ts42
-rw-r--r--src/physics/test4.ts36
-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
-rw-r--r--src/style.css75
-rw-r--r--src/test4.ts232
-rw-r--r--tests/lab.spec.ts2
-rw-r--r--tests/math.spec.ts65
-rw-r--r--tests/test4.spec.ts118
-rw-r--r--vite.config.ts2
24 files changed, 1570 insertions, 126 deletions
diff --git a/.vscode/settings.json b/.vscode/settings.json
new file mode 100644
index 0000000..e48a724
--- /dev/null
+++ b/.vscode/settings.json
@@ -0,0 +1,3 @@
+{
+ "markdown.math.enabled": true
+}
diff --git a/README.md b/README.md
index 916cb69..c3eb7b9 100644
--- a/README.md
+++ b/README.md
@@ -30,6 +30,36 @@ For a fresh checkout with a lockfile, use `npm ci` for repeatable installs.
## Eight experiments from PLAN.md
+The app now opens with **Test 4 in 3D**, a guided study collection based on
+`docs/Test_4.md` and `docs/Test_Answers.md`. Select **All experiments** to
+return to the original eight subjects. All previous subject links work.
+
+| Test question | 3D learning activity | URL fragment |
+| --- | --- | --- |
+| 1 · Work by the electric force (30p) | Compare straight and curved paths with the same endpoints | `#test-work` |
+| 2 · Energy conservation (15p) | Exchange potential and kinetic energy while keeping the total constant | `#test-conservation` |
+| 3 · Central field (55p) | Radial motion, U(r), signed work and the exact reference at infinity | `#test-central` |
+| 4 · Charge assembly (25p) | Build a tetrahedral configuration, counting each pair once | `#test-assembly` |
+| 5 · Uniform field (25p) | Project an angled displacement along E | `#test-uniform` |
+| 6 · Dipole rotation (50p) | Inspect force couples, signed torque, rotation work and U(θ) | `#test-dipole` |
+
+Each question includes its original scope and subparts, adjustable numerical
+examples, live observations, expandable derivations and a prediction with a
+revealable explanation. Journey/rotation sliders select a position or angle,
+not elapsed time. The original Markdown questions and answers are available
+from the lesson links, including in the production build. Test 4A and 4B each
+carry 100 points. These are teaching examples rather than an automatic grader.
+
+Equations, derivations, inline lesson notation, control symbols and mathematical
+3D annotations use KaTeX with locally bundled fonts and accessible MathML.
+Long equations scroll within their own containers on narrow screens. Scene
+annotations follow the camera through a CSS2D overlay, leaving orbit controls
+usable.
+
+The added models are in `src/physics/test4.ts`, lesson content in
+`src/test4.ts`, scene builders in `src/scene/test4.ts`, and browser coverage
+in `tests/test4.spec.ts`.
+
Each lesson has an interactive 3D scene, adjustable parameters, live SI
measurements, governing equations, a short explanation, a challenge and a
model/scales disclosure. Use the subject tabs or these URL fragments:
diff --git a/docs/Test_4.md b/docs/Test_4.md
new file mode 100644
index 0000000..beb7160
--- /dev/null
+++ b/docs/Test_4.md
@@ -0,0 +1,63 @@
+# Test 4
+
+**Test 4A:** Questions 1–3.
+**Test 4B:** Questions 4–6.
+**Max point:** 100 + 100.
+
+**NB!** Small sketches may improve the answers in questions 1, 3, 4, 5, 6.
+
+## 1. (30p)
+
+What is the work done by the electric force, when $q_0$ is moved from $a$ to $b$?
+
+## 2. (15p)
+
+Please write the condition for conservation of the total energy.
+
+## 3. (55p)
+
+When $q_0$ moves from $a$ to $b$ in an E-field from a point charge $q$ (“Central field”).
+
+**a. (30p)** Please derive the work done by the electric force.
+
+**b. (25p)** The work in above is equal to the drop in the electric potential energy
+$U_{\mathrm{e\,pot}\,a} - U_{\mathrm{e\,pot}\,b}$.
+What is $U_{\mathrm{e\,pot}\,a}$ and $U_{\mathrm{e\,pot}\,b}$ if $b \to \infty$?
+
+## 4. (25p)
+
+Bringing charges together.
+
+**a. (15p)** What is the total electric potential energy if $q_1, q_2, q_3$ are brought together from infinite?
+
+**b. (10p)** What is the additional electric potential energy if $q_0$ is brought together with $q_1, q_2, q_3$?
+
+## 5. (25p)
+
+When $q_0$ moves from $a$ to $b$ in a uniform E-field.
+
+**a. (15p)** Please derive the work done by the electric force.
+
+**b. (10p)** What is $U_{\mathrm{pot}\,a}$ and $U_{\mathrm{pot}\,b}$?
+
+## 6. (50p)
+
+The electric dipole in a uniform E-field.
+
+**a. (10p)** Please derive the torque vector.
+
+Prove, that the magnitude is
+
+$$
+\tau = p_e E \sin(\theta),
+$$
+
+where $\theta = \angle(\vec{p}_e, \vec{E})$.
+
+**b. (30p)** Please derive the work $W_{ab}$ done by the electric force when the electric dipole rotates from $\theta_a$ to $\theta_b$ and what is $U_{\mathrm{e\,pot}\,a}$ and $U_{\mathrm{e\,pot}\,b}$?
+
+You don’t have to derive $dW = -\tau\,d\theta$.
+
+**c. (10p)** Explain why $\theta_a = 90^\circ$ can be used as a reference position for the electric potential energy and write the result for $U_{\mathrm{e\,pot}\,a}$.
+
+Now find the general expression of $U_{\mathrm{e\,pot}}$ for the electric dipole, also written as a scalar product.
diff --git a/docs/Test_Answers.md b/docs/Test_Answers.md
new file mode 100644
index 0000000..f08a9cc
--- /dev/null
+++ b/docs/Test_Answers.md
@@ -0,0 +1,361 @@
+# Test 4 – Answers
+
+We assume electrostatic fields in a vacuum and use:
+
+$$
+k=\frac{1}{4\pi\varepsilon_0}.
+$$
+
+Here, $U$ denotes electric potential energy, $V$ denotes electric potential, and $q_0$ is the charge being moved. 4Te_pw14 (1)(1)
+
+## 1. Work done by the electric force from $a$ to $b$
+
+The work is found by integrating the force along the path of the charge:
+
+$$
+W_{ab}=\int_a^b\vec F_e\cdot d\vec r.
+$$
+
+Since $\vec F_e=q_0\vec E$, we obtain:
+
+$$
+\boxed{W_{ab}=q_0\int_a^b\vec E\cdot d\vec r}.
+$$
+
+The electrostatic force is conservative. The work therefore equals the decrease in potential energy:
+
+$$
+\boxed{W_{ab}=U_a-U_b=q_0(V_a-V_b)}.
+$$
+
+Positive work means that the potential energy decreases.
+
+## 2. Condition for conservation of energy
+
+The total energy of an **isolated system** is constant:
+
+$$
+\boxed{\Delta E_{\mathrm{total}}=0}.
+$$
+
+If only the electric force does work, the sum of kinetic and electric potential energy is conserved:
+
+$$
+\boxed{K_a+U_a=K_b+U_b}.
+$$
+
+For a particle of mass $m$:
+
+$$
+\frac12mv_a^2+U_a=\frac12mv_b^2+U_b.
+$$
+
+This follows from the work–energy theorem:
+
+$$
+\Delta K=W_{ab}=-\Delta U.
+$$
+
+## 3. Motion in the field of a point charge
+
+### 3a. Derivation of the work
+
+A fixed point charge $q$ creates the field:
+
+$$
+\vec E(r)=k\frac{q}{r^2}\hat r.
+$$
+
+The force on $q_0$ is:
+
+$$
+\vec F_e=k\frac{qq_0}{r^2}\hat r.
+$$
+
+Let $r_a$ and $r_b$ be the distances from $q$ to $a$ and $b$, respectively. Since $\hat r\cdot d\vec r=dr$, we obtain:
+
+$$
+W_{ab}
+=kqq_0\int_{r_a}^{r_b}\frac{1}{r^2}\,dr.
+$$
+
+Thus:
+
+$$
+W_{ab}
+=kqq_0\left[-\frac1r\right]_{r_a}^{r_b}.
+$$
+
+Therefore:
+
+$$
+\boxed{W_{ab}=kqq_0\left(\frac1{r_a}-\frac1{r_b}\right)}.
+$$
+
+The result depends only on the initial and final distances, not on the path taken.
+
+### 3b. Potential energy with a reference at infinity
+
+We choose:
+
+$$
+U(\infty)=0.
+$$
+
+As $r_b\to\infty$, the work becomes:
+
+$$
+W_{a\infty}=\frac{kqq_0}{r_a}.
+$$
+
+Since $W_{a\infty}=U_a-U_\infty$, we obtain:
+
+$$
+\boxed{U_a=\frac{kqq_0}{r_a}},
+\qquad
+\boxed{U_b=U_\infty=0}.
+$$
+
+The general expression for the potential energy is therefore:
+
+$$
+\boxed{U(r)=\frac{kqq_0}{r}}.
+$$
+
+Charges with the same sign give positive potential energy; charges with opposite signs give negative potential energy.
+
+## 4. Assembling point charges
+
+Let $r_{ij}$ be the distance between charges $q_i$ and $q_j$. The zero of potential energy is chosen to be when all charges are infinitely far apart.
+
+### 4a. Total potential energy of $q_1,q_2,q_3$
+
+The charges can be assembled one at a time:
+
+- $q_1$ requires no work, since no other charges are present yet.
+- $q_2$ adds the energy $kq_1q_2/r_{12}$.
+- $q_3$ adds the energy $kq_1q_3/r_{13}+kq_2q_3/r_{23}$.
+
+The total potential energy is:
+
+$$
+\boxed{
+U_{123}
+=k\left(
+\frac{q_1q_2}{r_{12}}
++\frac{q_1q_3}{r_{13}}
++\frac{q_2q_3}{r_{23}}
+\right)
+}.
+$$
+
+Each pair of charges is counted once.
+
+### 4b. Additional potential energy when adding $q_0$
+
+With the three existing charges held in place, adding $q_0$ gives:
+
+$$
+\boxed{
+\Delta U
+=k\left(
+\frac{q_0q_1}{r_{01}}
++\frac{q_0q_2}{r_{02}}
++\frac{q_0q_3}{r_{03}}
+\right)
+}.
+$$
+
+This can also be written as:
+
+$$
+\boxed{\Delta U=q_0V_{123}},
+$$
+
+where $V_{123}$ is the potential due to $q_1,q_2,q_3$ at the position where $q_0$ is placed.
+
+## 5. Motion in a uniform electric field
+
+### 5a. Derivation of the work
+
+In a uniform field, $\vec E$ is constant:
+
+$$
+W_{ab}=q_0\int_a^b\vec E\cdot d\vec r
+=q_0\vec E\cdot(\vec r_b-\vec r_a).
+$$
+
+Thus:
+
+$$
+\boxed{W_{ab}=q_0\vec E\cdot(\vec r_b-\vec r_a)}.
+$$
+
+If the displacement has magnitude $s$ and the angle between the displacement and the field is $\alpha$:
+
+$$
+\boxed{W_{ab}=q_0Es\cos\alpha}.
+$$
+
+Choosing the $x$-axis along the direction of the field gives:
+
+$$
+\boxed{W_{ab}=q_0E(x_b-x_a)}.
+$$
+
+### 5b. Potential energy at $a$ and $b$
+
+Since $W_{ab}=U_a-U_b$:
+
+$$
+U_b-U_a=-q_0E(x_b-x_a).
+$$
+
+A potential energy that satisfies this is:
+
+$$
+U(x)=-q_0Ex+C.
+$$
+
+Therefore:
+
+$$
+\boxed{U_a=-q_0Ex_a+C},
+\qquad
+\boxed{U_b=-q_0Ex_b+C}.
+$$
+
+The constant $C$ is determined by the chosen zero of potential energy. Choosing $U_a=0$ gives:
+
+$$
+\boxed{U_a=0},
+\qquad
+\boxed{U_b=-q_0E(x_b-x_a)}.
+$$
+
+For a positive charge, the potential energy decreases when it moves in the direction of the field.
+
+## 6. Electric dipole in a uniform electric field
+
+A dipole consists of charges $+q$ and $-q$, separated by the vector $\vec d$, which points from the negative charge to the positive charge.
+
+The dipole moment is defined as:
+
+$$
+\boxed{\vec p_e=q\vec d}.
+$$
+
+The angle between $\vec p_e$ and $\vec E$ is denoted by $\theta$.
+
+### 6a. Derivation of the torque
+
+The forces on the charges are:
+
+$$
+\vec F_+=q\vec E,
+\qquad
+\vec F_-=-q\vec E.
+$$
+
+The net force is zero, but the forces form a couple.
+
+Taking the midpoint of the dipole as the origin, the position vectors are:
+
+$$
+\vec r_+=\frac{\vec d}{2},
+\qquad
+\vec r_-=-\frac{\vec d}{2}.
+$$
+
+The total torque is:
+
+$$
+\begin{aligned}
+\vec\tau
+&=\vec r_+\times\vec F_+
++\vec r_-\times\vec F_-\\
+&=\frac{\vec d}{2}\times q\vec E
++\left(-\frac{\vec d}{2}\right)\times(-q\vec E)\\
+&=q\vec d\times\vec E.
+\end{aligned}
+$$
+
+Thus:
+
+$$
+\boxed{\vec\tau=\vec p_e\times\vec E}.
+$$
+
+The magnitude of the cross product gives:
+
+$$
+\boxed{\tau=p_eE\sin\theta}.
+$$
+
+The torque tends to align the dipole moment with the field.
+
+### 6b. Work during rotation from $\theta_a$ to $\theta_b$
+
+The torque acts in the direction of decreasing $\theta$. With $\tau=p_eE\sin\theta$ as its magnitude:
+
+$$
+dW=-\tau\,d\theta.
+$$
+
+The work is therefore:
+
+$$
+W_{ab}=-p_eE\int_{\theta_a}^{\theta_b}\sin\theta\,d\theta.
+$$
+
+Integration gives:
+
+$$
+\boxed{
+W_{ab}=p_eE(\cos\theta_b-\cos\theta_a)
+}.
+$$
+
+Since $W_{ab}=U_a-U_b$, the potential energy can be written as:
+
+$$
+U(\theta)=-p_eE\cos\theta+C.
+$$
+
+Therefore:
+
+$$
+\boxed{U_a=-p_eE\cos\theta_a+C},
+$$
+
+$$
+\boxed{U_b=-p_eE\cos\theta_b+C}.
+$$
+
+### 6c. Reference at $90^\circ$ and the general expression
+
+The zero of potential energy can be chosen freely. We choose:
+
+$$
+\boxed{U_a=U(90^\circ)=0}.
+$$
+
+At $90^\circ$, the two charges are at the same potential in the uniform field, so their contributions to the total potential energy cancel each other.
+
+Since $\cos90^\circ=0$, this choice of reference gives $C=0$. Therefore:
+
+$$
+\boxed{U(\theta)=-p_eE\cos\theta}.
+$$
+
+Using the dot product, we obtain:
+
+$$
+\boxed{U=-\vec p_e\cdot\vec E}.
+$$
+
+| Angle | Orientation | Potential energy | Equilibrium |
+|---|---|---|---|
+| $0^\circ$ | Dipole moment aligned with the field | $-p_eE$, minimum | Stable |
+| $90^\circ$ | Dipole moment perpendicular to the field | $0$ | No equilibrium |
+| $180^\circ$ | Dipole moment opposite to the field | $+p_eE$, maximum | Unstable | \ No newline at end of file
diff --git a/docs/implementation-progress.md b/docs/implementation-progress.md
deleted file mode 100644
index f4a745a..0000000
--- a/docs/implementation-progress.md
+++ /dev/null
@@ -1,52 +0,0 @@
-# Implementation progress — PLAN.md
-
-The user asks for educational 3D graphics for eight subjects. PLAN.md is the
-subject specification; no separate design or implementation spec exists.
-
-Ruling: Implement the eight subjects as selectable experiments in the existing
-Three.js app, sharing a scene host and parameter/readout interface. This gives
-each topic an interactive visual immediately. The models are idealizations,
-explained in the UI, rather than general field/circuit/quantum solvers.
-
-Ruling: Continue in this directory because `.git` is an empty managed mount,
-not a usable Git repository; worktree/commit scripts cannot run here.
-
-Pre-flight: Potential and energy share Coulomb models; capacitance and
-permittivity share a parallel-plate model; current and semiconductors share
-carrier constants. All physics functions use SI units and rendering handles
-unit conversion and illustrative scales.
-
-- Baseline: existing 3 physics tests pass.
-- [x] Electrostatics: charge/force/field, flux, energy, potential/gradient.
-- [x] Matter/circuits: capacitance, dielectrics, current, semiconductors.
-- [x] Eight experiment definitions and educational UI.
-- [x] Eight 3D scenes, interaction, animation, cleanup.
-- [x] Tests, browser checks, build, README.
-
-Implementation:
-- Electrostatics complete: force/field, exact enclosed-charge flux (surface
- singularity disclosed), interaction energy/work, potential and probe gradient.
-- Matter/circuits complete: parallel plates, fixed-V/fixed-Q dielectrics,
- Ohmic wire with signed drift, intrinsic/n/p silicon equilibrium/bands.
-- UI and eight scenes complete: independent settings, deep links/history,
- reset, pause/reduced motion, responsive layout and WebGL fallback.
-- Renderer rebuilds are coalesced; obsolete GPU resources are disposed.
-- Visual inspection: field, potential, capacitance and semiconductor screenshots
- checked. Labels changed to stable screen sizes for readability.
-
-Final review: fresh read-only reviewer approved the current implementation;
-no Critical or Important findings.
-- Model direction-only capacitor-arrow disclosure was already added during
- implementation and resolves the reviewer's corresponding suggestion.
-- Final: minor (deferred): browser pause test verifies the state/button;
- pixel comparisons proving actual animation cessation are not yet included.
-- Final: minor (deferred): finite-difference potential-gradient unit test covers
- one charge along x; add multiple sources/off-axis components when expanding.
-- Build has a non-fatal Three.js bundle-size advisory (~598 kB minified,
- ~153 kB gzip). No deployment or code-splitting requirement in PLAN.md.
-
-Final verification: 20/20 unit tests, 10/10 Chromium browser tests, strict
-TypeScript and Vite production build pass after the final label adjustment.
-All eight scenes were checked for rendered geometry and browser exceptions.
-The app remains served on http://localhost:5173. No Git integration action
-was available or required in this non-repository workspace.
diff --git a/docs/superpowers/plans/2026-10-24-test4.md b/docs/superpowers/plans/2026-10-24-test4.md
new file mode 100644
index 0000000..278ab0b
--- /dev/null
+++ b/docs/superpowers/plans/2026-10-24-test4.md
@@ -0,0 +1,59 @@
+# Test 4 in 3D Implementation Plan
+
+> **For agentic workers:** Use superpowers:executing-plans to implement this plan task by task.
+
+**Goal:** Represent every question and answer in `docs/Test_4.md` and `docs/Test_Answers.md` through an educational, interactive 3D lesson.
+
+**Architecture:** Extend the existing TypeScript/Three.js lab with a separate Test 4 collection. Share the renderer and parameter UI; keep test physics, lesson content, and scene construction in focused modules. Native execution in the current checkout; the user delegated design and implementation choices.
+
+**Tech Stack:** Existing Three.js, TypeScript, Vite, Vitest, Playwright; no new dependencies.
+
+**Spec:** The two source documents above and the design below.
+
+## Design and constraints
+
+- Six lessons, with exact question scope, all subparts and original marks: 4A = 100 points, 4B = 100 points.
+- Guided lessons were selected as the implementation default after the user said “Choose the details and build it.” An exam-only view hides useful explanation; a disconnected presentation would duplicate the existing renderer.
+- Test 4 is the default entry. Existing experiment hash links and all eight experiments remain available through a collection switch.
+- Q1: uniform-field straight/curved paths with the same endpoints and work; Q2: repelling charge, energy bars and conserved K + U; Q3: radial motion, inverse-radius energy, reference at infinity; Q4: assemble three charges and add a fourth, showing pair energies once; Q5: angled displacement in a uniform field; Q6: dipole forces, torque, rotation work and energy versus angle.
+- Use adjustable progress for inspectable motion; decorative animation respects pause and reduced motion. Explicit physical units and visual scale explanations. Equations and derivations remain usable without WebGL.
+- Original questions, step-by-step answers, live numerical examples and a prediction/reveal check in each lesson. Source Markdown is linked from the built app.
+- Preserve existing unrelated work and source documents. No publication or repository integration requested.
+
+## Review focus
+
+Signed charges and work; arbitrary energy zeros; infinity represented analytically rather than as a finite point; each charge pair counted once; dipole torque sign versus magnitude and unstable equilibrium; mobile overflow; WebGL fallback; collection/history state.
+
+## Task 1: Physics models
+
+Files: `src/physics/test4.ts`, `src/physics/test4.test.ts`.
+
+- [x] Write independent numerical tests for uniform and central work, conservation, pair assembly, dipole torque/work and infinity.
+- [x] Run the tests and observe missing model failure.
+- [x] Implement SI functions with explicit energy references and domain validation.
+- [x] Run physics tests and confirm passing.
+
+## Task 2: Guided lesson content and scenes
+
+Files: `src/test4.ts`, `src/scene/test4.ts`, `src/experiments.ts`, `src/scene/experiments.ts`.
+
+- [x] Add browser checks for lesson selection, parameter changes, answers, graphics and fallback. Initial browser execution required enabling the local server and downloading Chromium into `/tmp`.
+- [x] Implement all six lesson definitions with original questions, subparts and derivations.
+- [x] Build parameter-linked 3D diagrams with endpoint labels, force/field arrows and energy plots or bars.
+
+## Task 3: Navigation, learning UI and verification
+
+Files: `src/main.ts`, `src/style.css`, `tests/test4.spec.ts`, `tests/lab.spec.ts`, `README.md`.
+
+- [x] Add Test 4/experiment collection navigation, question and solution panels, observation prompts, prediction reveal, previous/next links and retained parameter state.
+- [x] Verify direct links, collection/history navigation, mobile overflow, all scenes, resets and WebGL fallback.
+- [x] Run `npm test`, `npm run build`, `npm run test:e2e`; inspect desktop and mobile screenshots.
+- [x] Obtain an independent code review and address significant findings; document final outcome.
+
+## Execution record
+
+Baseline: 20 unit tests passed; production build passed (existing Three.js bundle-size advisory).
+
+Completed: all six questions and subparts are covered. Independent review confirmed physical and content consistency and found a mobile pagination issue. A new browser regression reproduced the issue, then passed after explicit pagination began focusing and scrolling the new heading. Review polish items were addressed with valid `aria-current="page"`, responsive framing and less crowded mobile labels. Both original Markdown links returned HTTP 200 with their original content from the production preview; assets are emitted as files rather than inlined data URLs.
+
+Final verification: 25 unit tests passed, 17 browser tests passed, strict TypeScript and production build passed, and `git diff --check` passed. No new dependencies. The existing Three.js bundle-size advisory remains. Work is left in the user's checkout without a commit or publication.
diff --git a/index.html b/index.html
index 2f4ad93..e4fac18 100644
--- a/index.html
+++ b/index.html
@@ -4,7 +4,8 @@
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="theme-color" content="#101822" />
- <title>Electrotechnique · Field Lab</title>
+ <meta name="description" content="Learn electric work, potential energy and dipole rotation through six interactive 3D lessons from Test 4." />
+ <title>Test 4 in 3D · Electrophysics Lab</title>
</head>
<body>
<div id="app"></div>
diff --git a/package-lock.json b/package-lock.json
index cf51837..833e2ae 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -8,10 +8,12 @@
"name": "electrophysics-sandbox",
"version": "0.1.0",
"dependencies": {
+ "katex": "^0.19.1",
"three": "^0.186.1"
},
"devDependencies": {
"@playwright/test": "^1.64.1",
+ "@types/katex": "^0.16.8",
"@types/three": "^0.186.0",
"typescript": "^7.0.2",
"vite": "^8.3.5",
@@ -350,6 +352,12 @@
"integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==",
"dev": true
},
+ "node_modules/@types/katex": {
+ "version": "0.16.8",
+ "resolved": "https://registry.npmjs.org/@types/katex/-/katex-0.16.8.tgz",
+ "integrity": "sha512-trgaNyfU+Xh2Tc+ABIb44a5AYUpicB3uwirOioeOkNPPbmgRNtcWyDeeFRzjPZENO9Vq8gvVqfhaaXWLlevVwg==",
+ "dev": true
+ },
"node_modules/@types/stats.js": {
"version": "0.17.4",
"resolved": "https://registry.npmjs.org/@types/stats.js/-/stats.js-0.17.4.tgz",
@@ -750,6 +758,14 @@
"node": ">=18"
}
},
+ "node_modules/commander": {
+ "version": "15.0.0",
+ "resolved": "https://registry.npmjs.org/commander/-/commander-15.0.0.tgz",
+ "integrity": "sha512-z67u4ZhzCL/Tydu1lJARtEZYWbWaN7oYLHbsuzocr6y4N6WZAagG3RQ4FW61V1/0+jImpj293XfrcYnd1qxtPg==",
+ "engines": {
+ "node": ">=22.12.0"
+ }
+ },
"node_modules/detect-libc": {
"version": "2.1.2",
"resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz",
@@ -820,6 +836,21 @@
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
}
},
+ "node_modules/katex": {
+ "version": "0.19.1",
+ "resolved": "https://registry.npmjs.org/katex/-/katex-0.19.1.tgz",
+ "integrity": "sha512-jG8uKntjSEcm2EpQOh0XturpPuuC9r3Sq5HLHdsFdWZT27E672SF19QEi+of0/YeOy15lDgRhKvr5614uRrEfA==",
+ "funding": [
+ "https://opencollective.com/katex",
+ "https://github.com/sponsors/katex"
+ ],
+ "dependencies": {
+ "commander": "^15.0.0"
+ },
+ "bin": {
+ "katex": "cli.js"
+ }
+ },
"node_modules/lightningcss": {
"version": "1.33.0",
"resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.33.0.tgz",
diff --git a/package.json b/package.json
index 127a763..77d3b97 100644
--- a/package.json
+++ b/package.json
@@ -15,10 +15,12 @@
"test:e2e": "playwright test"
},
"dependencies": {
+ "katex": "^0.19.1",
"three": "^0.186.1"
},
"devDependencies": {
"@playwright/test": "^1.64.1",
+ "@types/katex": "^0.16.8",
"@types/three": "^0.186.0",
"typescript": "^7.0.2",
"vite": "^8.3.5",
diff --git a/src/experiments.ts b/src/experiments.ts
index df6835e..6045a88 100644
--- a/src/experiments.ts
+++ b/src/experiments.ts
@@ -1,7 +1,8 @@
import { electricField, type Charge } from './physics/electricField';
import { capacitor, conductor, forceOn, gaussianFlux, pairEnergy, potential, semiconductor } from './physics/models';
-export type ExperimentId = 'field' | 'flux' | 'energy' | 'potential' | 'capacitance' | 'dielectric' | 'current' | 'semiconductor';
+export type TestId = 'test-work' | 'test-conservation' | 'test-central' | 'test-assembly' | 'test-uniform' | 'test-dipole';
+export type ExperimentId = 'field' | 'flux' | 'energy' | 'potential' | 'capacitance' | 'dielectric' | 'current' | 'semiconductor' | TestId;
export type Parameters = Record<string, number | string>;
export interface Slider { kind: 'range'; id: string; label: string; min: number; max: number; step: number; value: number; unit: string }
export interface Choice { kind: 'select'; id: string; label: string; value: string; options: { value: string; label: string }[] }
@@ -56,11 +57,11 @@ export const experiments: Experiment[] = [
id: 'field', title: 'Electric charge, force and field', shortTitle: 'Charge & field',
description: 'See how signed charges create a vector field and exert forces on one another.',
controls: pairControls,
- equations: ['E = k Σ qᵢ r̂ᵢ / rᵢ²', 'F = qE · |F₁₂| = k|q₁q₂| / r²'],
+ equations: [String.raw`\vec E = k\sum_i \frac{q_i}{r_i^2}\hat r_i`, String.raw`\vec F = q\vec E,\qquad |\vec F_{12}| = k\frac{|q_1q_2|}{r^2}`],
explanation: 'A positive test charge follows the electric field; a negative charge feels a force in the opposite direction. The large gold arrow shows the force on the right-hand source due only to the left-hand source.',
challenge: 'Double the separation. What happens to the force? Change to like charges and watch attraction become repulsion.',
- limits: 'Vacuum point charges. The x–z plane is sampled; points within 0.18 m of a source are excluded. Arrow lengths are compressed, and the force arrow is normalized.',
- sceneNote: 'Grid: 1 m major squares · small arrows: E · gold arrow: force on right charge',
+ limits: String.raw`Vacuum point charges. The x–z plane is sampled; points within $0.18\,\mathrm m$ of a source are excluded. Arrow lengths are compressed, and the force arrow is normalized.`,
+ sceneNote: String.raw`Grid: $1\,\mathrm m$ major squares · small arrows: $E$ · gold arrow: force on right charge`,
legend: [['#ff967b', 'Positive charge'], ['#73d7ff', 'Negative charge'], ['#91e2cb', 'Electric field'], ['#f5d58b', 'Force']],
source: textbook('5-3-coulombs-law'),
measure: p => {
@@ -74,11 +75,11 @@ export const experiments: Experiment[] = [
id: 'flux', title: 'Electric flux and charge', shortTitle: 'Flux & Gauss’s law',
description: 'Move a charge through a Gaussian surface and compare local field with net flux.',
controls: [slider('charge', 'Signed source charge', -5, 5, 0.1, 2, 'nC'), slider('radius', 'Gaussian sphere radius', 1, 3, 0.1, 2, 'm'), slider('position', 'Charge x position', -4, 4, 0.1, 0, 'm')],
- equations: ['ΦE = ∮ E · n̂ dA', 'ΦE = Qenclosed / ε₀'],
- explanation: 'The translucent sphere is an imaginary closed surface, not a conductor. Arrows show E at surface samples: mint means outward flux and coral means inward flux. An outside charge still creates a field, but its total flux through the closed surface is zero.',
+ equations: [String.raw`\Phi_E = \oint \vec E\cdot\hat n\,\mathrm dA`, String.raw`\Phi_E = \frac{Q_{\mathrm{enclosed}}}{\varepsilon_0}`],
+ explanation: String.raw`The translucent sphere is an imaginary closed surface, not a conductor. Arrows show $E$ at surface samples: mint means outward flux and coral means inward flux. An outside charge still creates a field, but its total flux through the closed surface is zero.`,
challenge: 'Keep the charge inside and enlarge the sphere. Then move the charge outside. Can there be a nonzero field with zero net flux?',
limits: 'The flux readout uses exact Gauss’s law; arrows are discrete field samples, not a numerical integral. A point charge exactly on the surface makes the flux undefined.',
- sceneNote: 'Imaginary Gaussian surface · mint: E·n > 0 · coral: E·n < 0',
+ sceneNote: String.raw`Imaginary Gaussian surface · mint: $\vec E\cdot\hat n>0$ · coral: $\vec E\cdot\hat n<0$`,
legend: [['#91e2cb', 'Outward flux'], ['#ff967b', 'Inward flux'], ['#73d7ff', 'Gaussian surface']],
source: textbook('6-2-explaining-gausss-law'),
measure: p => {
@@ -91,11 +92,11 @@ export const experiments: Experiment[] = [
id: 'energy', title: 'Electric potential energy', shortTitle: 'Potential energy',
description: 'Separate two charges and connect interaction energy with the work done by the field.',
controls: [slider('testCharge', 'Moving charge', -3, 3, 0.1, -2, 'nC'), slider('distance', 'Distance from +3 nC source', 0.75, 6, 0.05, 2, 'm')],
- equations: ['U(r) = kQq / r', 'Wfield, 6m→r = U(6 m) − U(r)'],
+ equations: [String.raw`U(r) = \frac{kQq}{r}`, String.raw`W_{\mathrm{field},\,6\,\mathrm m\to r} = U(6\,\mathrm m)-U(r)`],
explanation: 'The source stays on the left. The mint curve plots the interaction energy against separation; the gold marker follows the current setting. Opposite charges have negative energy relative to infinite separation, so pulling them apart requires external work.',
- challenge: 'Move an opposite charge closer. Does U rise or fall? Now make the moving charge positive and compare the sign of the field’s work.',
- limits: 'Two prescribed point charges, with U = 0 at infinity. The curve uses a fixed display scale of 20 nJ per vertical scene unit. The controls set positions; they do not integrate motion.',
- sceneNote: 'Curve: U versus separation · gold point: current U · horizontal zero-energy axis',
+ challenge: String.raw`Move an opposite charge closer. Does $U$ rise or fall? Now make the moving charge positive and compare the sign of the field’s work.`,
+ limits: String.raw`Two prescribed point charges, with $U=0$ at infinity. The curve uses a fixed display scale of 20 nJ per vertical scene unit. The controls set positions; they do not integrate motion.`,
+ sceneNote: String.raw`Curve: $U$ versus separation · gold point: current $U$ · horizontal zero-energy axis`,
legend: [['#ff967b', 'Fixed +3 nC'], ['#73d7ff', 'Negative charge'], ['#91e2cb', 'Energy curve'], ['#f5d58b', 'Current energy']],
source: textbook('7-1-electric-potential-energy'),
measure: p => {
@@ -109,11 +110,11 @@ export const experiments: Experiment[] = [
id: 'potential', title: 'Electric potential and gradient', shortTitle: 'Potential & gradient',
description: 'Read electric potential as a landscape and connect its downhill direction to the field.',
controls: [...pairControls, slider('probe', 'Probe x position (z = 1 m)', -4, 4, 0.1, 0, 'm')],
- equations: ['V = k Σ qᵢ / rᵢ', 'E = −∇V · Fx = −q ∂V/∂x'],
- explanation: 'Warm peaks are positive potential; blue valleys are negative. Mint arrows show the electric field projected in the x–z plane. The gold probe samples V and the x component of E. Field arrows point toward decreasing potential.',
- challenge: 'Use opposite charges: the midpoint potential can be zero while the field is nonzero. Compare V and Ex as you move the probe.',
- limits: 'Height = 2 tanh(V / 15 V), a compressed potential scale. Holes exclude source singularities. Arrow lengths are compressed. Probe is always at z = 1 m, away from sources; V = 0 at infinity.',
- sceneNote: 'Height and colour encode potential · gold probe at z = 1 m · arrows: −∇V',
+ equations: [String.raw`V = k\sum_i\frac{q_i}{r_i}`, String.raw`\vec E = -\nabla V,\qquad F_x = -q\frac{\partial V}{\partial x}`],
+ explanation: String.raw`Warm peaks are positive potential; blue valleys are negative. Mint arrows show the electric field projected in the x–z plane. The gold probe samples $V$ and the x component of $E$. Field arrows point toward decreasing potential.`,
+ challenge: String.raw`Use opposite charges: the midpoint potential can be zero while the field is nonzero. Compare $V$ and $E_x$ as you move the probe.`,
+ limits: String.raw`Height $=2\tanh\left(\frac{V}{15\,\mathrm V}\right)$, a compressed potential scale. Holes exclude source singularities. Arrow lengths are compressed. Probe is always at $z=1\,\mathrm m$, away from sources; $V=0$ at infinity.`,
+ sceneNote: String.raw`Height and colour encode potential · gold probe at $z=1\,\mathrm m$ · arrows: $-\nabla V$`,
legend: [['#ff967b', 'Positive potential'], ['#73d7ff', 'Negative potential'], ['#f5d58b', 'Probe']],
source: textbook('7-4-determining-field-from-potential'),
measure: p => {
@@ -127,7 +128,7 @@ export const experiments: Experiment[] = [
id: 'capacitance', title: 'Capacitance', shortTitle: 'Capacitance',
description: 'Build a parallel-plate capacitor and see how geometry controls charge storage.',
controls: plateControls,
- equations: ['C = ε₀A / d · Q = CV', 'E = V / d · U = ½CV²'],
+ equations: [String.raw`C = \frac{\varepsilon_0 A}{d},\qquad Q = CV`, String.raw`E = \frac{V}{d},\qquad U = \frac12 CV^2`],
explanation: 'The left plate is positive and the right plate is negative. At a fixed applied voltage, larger plates store more charge, while a wider gap reduces capacitance. The arrows show the uniform field approximation between the plates.',
challenge: 'Double the area, then double the gap. Which measurements return to their original values, and which do not?',
limits: 'Ideal parallel plates in vacuum, connected to a fixed-voltage supply. Fringing is ignored. Plate separation is enlarged 50× relative to lateral dimensions for visibility; charge markers are illustrative. Arrows span the gap to show direction, not magnitude.',
@@ -140,11 +141,11 @@ export const experiments: Experiment[] = [
id: 'dielectric', title: 'Dielectric materials', shortTitle: 'Dielectrics',
description: 'Polarize an insulator and compare insertion with a connected or disconnected supply.',
controls: [slider('permittivity', 'Relative permittivity κ', 1, 8, 0.1, 4, ''), choice('connection', 'Electrical condition', 'connected', [['connected', 'Connected · voltage held fixed'], ['isolated', 'Isolated · free charge held fixed']]), ...plateControls],
- equations: ['C = κε₀A / d · P = ε₀(κ − 1)E', 'Fixed V: Q ∝ κ · Fixed Q: V, E ∝ 1/κ'],
- explanation: 'Bound dipoles align with the field: their negative ends face the positive plate. The bound charges oppose the field of the free plate charges. With a battery connected, extra free charge enters and V stays fixed. When isolated, Q stays fixed and V falls.',
- challenge: 'Switch to isolated and compare κ = 1 with κ = 4. Then reconnect the supply and repeat: which quantities are held constant?',
- limits: 'A uniform, linear dielectric completely fills the gap. Isolated Q is defined by charging this geometry in vacuum at the selected charging voltage, then disconnecting and inserting the material. Each setting is a fresh preparation; breakdown and fringing are excluded. Gap enlarged 50×; field arrows show direction, not magnitude.',
- sceneNote: 'Gold volume: dielectric · blue/red pairs: bound dipoles · κ = 1: no polarization',
+ equations: [String.raw`C = \frac{\kappa\varepsilon_0 A}{d},\qquad \vec P = \varepsilon_0(\kappa-1)\vec E`, String.raw`\begin{aligned}\text{Fixed }V:&\quad Q\propto\kappa\\\text{Fixed }Q:&\quad V,E\propto\frac1\kappa\end{aligned}`],
+ explanation: String.raw`Bound dipoles align with the field: their negative ends face the positive plate. The bound charges oppose the field of the free plate charges. With a battery connected, extra free charge enters and $V$ stays fixed. When isolated, $Q$ stays fixed and $V$ falls.`,
+ challenge: String.raw`Switch to isolated and compare $\kappa$ = 1 with $\kappa$ = 4. Then reconnect the supply and repeat: which quantities are held constant?`,
+ limits: String.raw`A uniform, linear dielectric completely fills the gap. Isolated $Q$ is defined by charging this geometry in vacuum at the selected charging voltage, then disconnecting and inserting the material. Each setting is a fresh preparation; breakdown and fringing are excluded. Gap enlarged 50×; field arrows show direction, not magnitude.`,
+ sceneNote: String.raw`Gold volume: dielectric · blue/red pairs: bound dipoles · $\kappa$ = 1: no polarization`,
legend: [['#f5d58b', 'Dielectric'], ['#73d7ff', 'Bound −'], ['#ff967b', 'Bound +'], ['#91e2cb', 'Net field']],
source: textbook('8-4-capacitor-with-a-dielectric'),
measure: p => { const c = plateModel(p); return [metric('Capacitance', c.capacitance * 1e12, 'pF'), metric('Plate voltage', c.voltage, 'V'), metric('Free charge |Q|', c.charge * 1e9, 'nC'), metric('Net field', c.field / 1000, 'kV/m'), metric('Polarization |P|', c.polarization * 1e6, 'µC/m²')]; },
@@ -153,10 +154,10 @@ export const experiments: Experiment[] = [
id: 'current', title: 'Current', shortTitle: 'Current & drift',
description: 'Drive electrons through a copper wire and compare their drift with conventional current.',
controls: [slider('voltage', 'Voltage across wire', -0.1, 0.1, 0.005, 0.025, 'V'), slider('length', 'Wire length', 0.5, 3, 0.1, 1, 'm'), slider('area', 'Cross-sectional area', 0.5, 4, 0.1, 1, 'mm²')],
- equations: ['R = ρL / A · I = V / R', 'J = I / A · ve = −I / (neA)'],
+ equations: [String.raw`R = \frac{\rho L}{A},\qquad I = \frac{V}{R}`, String.raw`J = \frac{I}{A},\qquad v_e = -\frac{I}{neA}`],
explanation: 'Mint shows conventional current, defined as positive-charge flow. Blue electrons drift in the opposite direction. More voltage increases the drift; a longer wire adds resistance. A wider wire carries more current at the same voltage.',
challenge: 'Reverse the voltage. Then set it to zero. At fixed voltage, increase the wire area: does current increase, and does the electron drift speed change?',
- limits: 'Ohmic copper at fixed temperature: ρ = 1.68×10⁻⁸ Ω·m, n = 8.5×10²⁸ m⁻³. Drift is displayed 600× faster and the wire radius is exaggerated. Thermal scattering, heating feedback, and signal propagation are not simulated; P is dissipated power.',
+ limits: String.raw`Ohmic copper at fixed temperature: $\rho=1.68\times10^{-8}\,\Omega\,\mathrm m$, $n=8.5\times10^{28}\,\mathrm m^{-3}$. Drift is displayed 600× faster and the wire radius is exaggerated. Thermal scattering, heating feedback, and signal propagation are not simulated; $P$ is dissipated power.`,
sceneNote: 'Blue: electron drift · mint: conventional current · speed ×600 · radius exaggerated',
legend: [['#73d7ff', 'Electrons'], ['#91e2cb', 'Conventional current'], ['#f5d58b', 'Copper ions']],
source: textbook('9-2-model-of-conduction-in-metals'),
@@ -164,13 +165,13 @@ export const experiments: Experiment[] = [
},
{
id: 'semiconductor', title: 'Semiconducting materials', shortTitle: 'Semiconductors',
- description: 'Explore intrinsic, n-type and p-type silicon through carriers and an energy-band diagram.',
+ description: String.raw`Explore intrinsic, $n$-type and $p$-type silicon through carriers and an energy-band diagram.`,
controls: [choice('doping', 'Silicon sample at 300 K', 'intrinsic', [['intrinsic', 'Intrinsic · undoped'], ['n', 'n-type · donor dopants'], ['p', 'p-type · acceptor dopants']]), slider('concentration', 'Dopant density · log₁₀(N / m⁻³)', 14, 22, 0.25, 20, '')],
- equations: ['np = nᵢ² · n − p = ND − NA', 'σ = e(nμn + pμp)', 'EF − Ei = kBT ln(n / nᵢ)'],
- explanation: 'Electrons occupy the conduction band; holes are missing electrons in the valence band. Donors raise electron density and the Fermi level; acceptors raise hole density and lower it. The gold line marks EF. Carrier markers show populations on a compressed scale, not individual atoms.',
- challenge: 'Switch from intrinsic to n-type, then p-type at the same doping. Watch the majority carrier and Fermi shift change. Why are the conductivities different?',
- limits: 'Illustrative silicon reference values at 300 K: Eg = 1.12 eV, nᵢ ≈ 10¹⁶ m⁻³, μn = 0.135 and μp = 0.048 m²/(V·s). Fully ionized, non-degenerate equilibrium with fixed mobilities. Ei is drawn at approximate midgap. Motion is schematic thermal motion with no applied field or net drift; the density control is inactive for intrinsic silicon.',
- sceneNote: 'Upper blue band: conduction · lower coral band: valence · gold: EF · no net flow',
+ equations: [String.raw`np = n_i^2,\qquad n-p = N_D-N_A`, String.raw`\sigma = e(n\mu_n+p\mu_p)`, String.raw`E_F-E_i = k_B T\ln\left(\frac{n}{n_i}\right)`],
+ explanation: String.raw`Electrons occupy the conduction band; holes are missing electrons in the valence band. Donors raise electron density and the Fermi level; acceptors raise hole density and lower it. The gold line marks $E_F$. Carrier markers show populations on a compressed scale, not individual atoms.`,
+ challenge: String.raw`Switch from intrinsic to $n$-type, then $p$-type at the same doping. Watch the majority carrier and Fermi shift change. Why are the conductivities different?`,
+ limits: String.raw`Illustrative silicon reference values at $300\,\mathrm K$: $E_g=1.12\,\mathrm{eV}$, $n_i\approx10^{16}\,\mathrm m^{-3}$, $\mu_n=0.135\,\mathrm{m^2/(V\,s)}$ and $\mu_p=0.048\,\mathrm{m^2/(V\,s)}$. Fully ionized, non-degenerate equilibrium with fixed mobilities. $E_i$ is drawn at approximate midgap. Motion is schematic thermal motion with no applied field or net drift; the density control is inactive for intrinsic silicon.`,
+ sceneNote: String.raw`Upper blue band: conduction · lower coral band: valence · gold: $E_F$ · no net flow`,
legend: [['#73d7ff', 'Conduction electrons'], ['#ff967b', 'Valence holes'], ['#f5d58b', 'Fermi level']],
source: { title: 'OpenStax · Band theory of solids', url: 'https://openstax.org/books/university-physics-volume-3/pages/9-5-band-theory-of-solids' },
measure: p => { const s = carrierModel(p); return [metric('Electron density n', s.electrons, 'm⁻³'), metric('Hole density p', s.holes, 'm⁻³'), metric('Conductivity σ', s.conductivity, 'S/m'), metric('Fermi shift EF − Ei', s.fermiShift, 'eV')]; },
diff --git a/src/main.ts b/src/main.ts
index 172f316..7961a6f 100644
--- a/src/main.ts
+++ b/src/main.ts
@@ -1,19 +1,23 @@
import './style.css';
import { defaults, experiments, format, type Experiment, type Parameters } from './experiments';
import { createLab } from './scene/lab';
+import { answerUrl, getTestLesson, testLessons } from './test4';
+import { renderEquation, renderNotation, renderProse } from './math';
const app = document.querySelector<HTMLDivElement>('#app')!;
app.innerHTML = `
<header class="app-header">
- <a class="brand" href="#field" aria-label="Field Lab home">E<span>/</span>LAB</a>
+ <a class="brand" href="#test-work" aria-label="Field Lab home">E<span>/</span>LAB</a>
<div class="header-title"><h1>Electrotechnique & Electrophysics</h1><p>Explore the invisible. Understand the physical.</p></div>
- <span class="status"><i></i> INTERACTIVE LAB</span>
+ <nav class="collections" aria-label="Learning collection"><a href="#test-work" id="test-collection">Test 4 in 3D</a><a href="#field" id="lab-collection">All experiments</a></nav>
</header>
- <nav class="subjects" aria-label="Electrophysics subjects"><div role="tablist" aria-label="Experiments">${experiments.map((e, index) => `<button type="button" role="tab" id="tab-${e.id}" aria-controls="lesson" aria-selected="false" tabindex="-1" data-experiment="${e.id}"><span>${String(index + 1).padStart(2, '0')}</span>${e.shortTitle}</button>`).join('')}</div></nav>
+ <section class="test-overview" id="test-overview"><div><p class="eyebrow">FROM EQUATIONS TO INTUITION</p><h2>Electric energy, made visible.</h2><p>Explore the six questions from Test 4. Change the scene, make a prediction, then follow the derivation.</p></div><div class="test-score"><strong>4A <span>100p</span></strong><strong>4B <span>100p</span></strong><span>6 questions · 3D study guide</span></div></section>
+ <nav class="subjects" aria-label="Electrophysics subjects"><div role="tablist" aria-label="Lessons"></div></nav>
<main id="lesson" role="tabpanel" aria-labelledby="tab-field">
<aside class="controls-panel">
<p class="eyebrow" id="lesson-number"></p>
- <h2 id="lesson-title"></h2><p class="description" id="description"></p>
+ <h2 id="lesson-title" tabindex="-1"></h2><p class="description" id="description"></p>
+ <section id="test-question" class="test-question" aria-label="Original test question"></section>
<form id="parameters" aria-label="Experiment parameters" onsubmit="return false"></form>
<div class="panel-actions"><button type="button" id="reset">Reset experiment</button></div>
<section class="measurements" id="measurements" aria-label="Live measurements" aria-live="polite" aria-atomic="true"></section>
@@ -24,17 +28,27 @@ app.innerHTML = `
<div class="legend" id="legend"></div>
<div class="scene-footer"><p id="scene-note"></p><p class="hint">Drag to orbit · Scroll to zoom · Right-drag to pan · Arrow keys to pan when focused</p></div>
</section>
+ <section id="observation-panel" class="observation" aria-label="Connect the scene to the answer"><span class="eyebrow">NOTICE WHAT CHANGES</span><p id="observation" aria-live="polite"></p></section>
<section class="learning-panel" aria-label="Learn the physics">
<div><p class="eyebrow">THE PHYSICS</p><div id="equations" class="equations"></div><p id="explanation"></p><a id="source" target="_blank" rel="noopener noreferrer"></a></div>
<div class="challenge"><p class="eyebrow">TRY THIS</p><p id="challenge"></p><details><summary>Model & visual scales</summary><p id="limits"></p></details></div>
</section>
+ <section id="test-study" class="test-study" aria-label="Test answers and prediction">
+ <div class="solution-heading"><div><p class="eyebrow">BUILD YOUR ANSWER</p><h3>Follow the reasoning</h3></div><a href="${answerUrl}" target="_blank" rel="noopener noreferrer">Original answers ↗</a></div>
+ <div id="solution" class="solution"></div>
+ <section class="prediction"><p class="eyebrow">PAUSE & PREDICT</p><h3 id="prediction-question"></h3><button type="button" id="reveal-prediction" aria-expanded="false" aria-controls="prediction-answer">Reveal explanation</button><p id="prediction-answer" hidden></p></section>
+ <nav class="lesson-pagination" aria-label="Question navigation"><a id="previous-question"></a><span id="question-progress"></span><a id="next-question"></a></nav>
+ </section>
</div>
</main>`;
const lab = createLab(document.querySelector('#viewport')!);
-const settings = new Map(experiments.map(experiment => [experiment.id, defaults(experiment)]));
+const allLessons: Experiment[] = [...testLessons, ...experiments];
+const settings = new Map(allLessons.map(experiment => [experiment.id, defaults(experiment)]));
const byId = (id: string) => document.getElementById(id)!;
-let active = experiments[0];
+let active: Experiment = testLessons[0];
+let collection = '';
+const subjects = document.querySelector<HTMLElement>('[role="tablist"]')!;
let paused = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
function updatePause() {
byId('pause').textContent = paused ? 'Resume animation' : 'Pause animation';
@@ -53,22 +67,78 @@ function update() {
if (active.id === 'semiconductor') {
(byId('parameter-concentration') as HTMLInputElement).disabled = p.doping === 'intrinsic';
}
+ if (active.id === 'test-central') (byId('parameter-rb') as HTMLInputElement).disabled = p.destination === 'infinity';
+ const testLesson = getTestLesson(active);
+ if (testLesson) renderProse(byId('observation'), testLesson.observe(p));
byId('measurements').innerHTML = active.measure(p).map(m => `<div class="measurement"><span>${m.label}</span><div><strong aria-label="${m.label}">${m.value}</strong><small>${m.unit}</small></div></div>`).join('');
+ byId('measurements').querySelectorAll<HTMLElement>('.measurement > span').forEach(label => renderNotation(label, label.textContent!));
lab.update(active.id, p);
}
function show(experiment: Experiment) {
active = experiment;
- const index = experiments.indexOf(experiment);
- byId('lesson-number').textContent = `EXPERIMENT ${String(index + 1).padStart(2, '0')} / 08`;
+ const testLesson = getTestLesson(experiment);
+ const lessons: Experiment[] = testLesson ? testLessons : experiments;
+ const index = lessons.indexOf(experiment);
+ const nextCollection = testLesson ? 'test' : 'lab';
+ if (collection !== nextCollection) {
+ collection = nextCollection;
+ subjects.innerHTML = lessons.map((e, i) => `<button type="button" role="tab" id="tab-${e.id}" aria-controls="lesson" aria-selected="false" tabindex="-1" data-experiment="${e.id}"><span>${String(i + 1).padStart(2, '0')}</span>${e.shortTitle}${getTestLesson(e) ? `<small>${getTestLesson(e)!.points}p</small>` : ''}</button>`).join('');
+ }
+ for (const [id, selected] of [['test-collection', Boolean(testLesson)], ['lab-collection', !testLesson]] as const) {
+ if (selected) byId(id).setAttribute('aria-current', 'page');
+ else byId(id).removeAttribute('aria-current');
+ }
+ byId('test-overview').hidden = !testLesson;
+ byId('test-question').hidden = !testLesson;
+ byId('test-study').hidden = !testLesson;
+ byId('observation-panel').hidden = !testLesson;
+ byId('lesson-number').textContent = testLesson ? `TEST ${testLesson.section} · QUESTION ${index + 1} · ${testLesson.points} POINTS` : `EXPERIMENT ${String(index + 1).padStart(2, '0')} / 08`;
+ if (testLesson) {
+ byId('test-question').replaceChildren(...testLesson.questions.map((text, i) => {
+ const paragraph = document.createElement('p');
+ renderProse(paragraph, text);
+ if (i === 0) paragraph.className = 'question-lead';
+ return paragraph;
+ }));
+ byId('solution').replaceChildren(...testLesson.answers.map(part => {
+ const details = document.createElement('details');
+ const summary = document.createElement('summary');
+ renderNotation(summary, part.title);
+ const steps = document.createElement('ol');
+ steps.className = 'derivation';
+ for (const step of part.steps) {
+ const item = document.createElement('li');
+ const why = document.createElement('p'); renderProse(why, step.why); item.append(why);
+ if (step.math) { const math = document.createElement('p'); math.className = 'math-step'; renderEquation(math, step.math); item.append(math); }
+ steps.append(item);
+ }
+ details.append(summary, steps);
+ return details;
+ }));
+ renderProse(byId('prediction-question'), testLesson.prediction);
+ renderProse(byId('prediction-answer'), testLesson.predictionAnswer);
+ byId('prediction-answer').hidden = true;
+ byId('reveal-prediction').textContent = 'Reveal explanation';
+ byId('reveal-prediction').setAttribute('aria-expanded', 'false');
+ const previous = byId('previous-question') as HTMLAnchorElement;
+ const next = byId('next-question') as HTMLAnchorElement;
+ previous.hidden = index === 0;
+ next.hidden = index === testLessons.length - 1;
+ previous.textContent = '← Previous question';
+ next.textContent = 'Next question →';
+ if (index > 0) previous.href = `#${testLessons[index - 1].id}`;
+ if (index < testLessons.length - 1) next.href = `#${testLessons[index + 1].id}`;
+ byId('question-progress').textContent = `${index + 1} / 6`;
+ }
byId('lesson-title').textContent = experiment.title;
- byId('description').textContent = experiment.description;
- byId('scene-note').textContent = experiment.sceneNote;
- byId('explanation').textContent = experiment.explanation;
- byId('challenge').textContent = experiment.challenge;
- byId('limits').textContent = experiment.limits;
+ renderProse(byId('description'), experiment.description);
+ renderProse(byId('scene-note'), experiment.sceneNote);
+ renderProse(byId('explanation'), experiment.explanation);
+ renderProse(byId('challenge'), experiment.challenge);
+ renderProse(byId('limits'), experiment.limits);
byId('equations').replaceChildren(...experiment.equations.map(equation => {
const div = document.createElement('p');
- div.textContent = equation;
+ renderEquation(div, equation);
return div;
}));
const source = byId('source') as HTMLAnchorElement;
@@ -87,14 +157,31 @@ function show(experiment: Experiment) {
if (control.kind === 'select') return `<div class="parameter"><label for="${id}">${control.label}</label><select id="${id}" name="${control.id}">${control.options.map(option => `<option value="${option.value}" ${option.value === p[control.id] ? 'selected' : ''}>${option.label}</option>`).join('')}</select></div>`;
return `<div class="parameter"><div class="parameter-label"><label for="${id}">${control.label}</label><output for="${id}" id="output-${control.id}"></output></div><input id="${id}" name="${control.id}" type="range" min="${control.min}" max="${control.max}" step="${control.step}" value="${p[control.id]}" /></div>`;
}).join('');
- lab.resetCamera();
+ byId('parameters').querySelectorAll<HTMLLabelElement>('label').forEach(label => {
+ const input = document.getElementById(label.htmlFor)!;
+ input.setAttribute('aria-label', label.textContent!);
+ renderNotation(label, label.textContent!);
+ });
+ lab.resetCamera(experiment.id);
update();
}
function fromHash() {
const id = window.location.hash.slice(1);
- show(experiments.find(experiment => experiment.id === id) ?? experiments[0]);
+ show(allLessons.find(experiment => experiment.id === id) ?? testLessons[0]);
}
-const onHashChange = () => fromHash();
+let focusNextQuestion = false;
+document.querySelector('.lesson-pagination')!.addEventListener('click', event => {
+ const link = (event.target as HTMLElement).closest('a');
+ if (link && !((event as MouseEvent).ctrlKey || (event as MouseEvent).metaKey || (event as MouseEvent).shiftKey || (event as MouseEvent).altKey)) focusNextQuestion = true;
+});
+const onHashChange = () => {
+ fromHash();
+ if (focusNextQuestion) {
+ focusNextQuestion = false;
+ byId('lesson-title').focus({ preventScroll: true });
+ byId('lesson-title').scrollIntoView({ block: 'start' });
+ }
+};
window.addEventListener('hashchange', onHashChange);
fromHash();
byId('parameters').addEventListener('input', event => {
@@ -112,14 +199,23 @@ byId('pause').addEventListener('click', () => {
paused = !paused;
updatePause();
});
-const tabs = [...document.querySelectorAll<HTMLButtonElement>('[role="tab"]')];
-tabs.forEach((tab, index) => {
- tab.addEventListener('click', () => {
- const id = tab.dataset.experiment!;
- if (window.location.hash === `#${id}`) show(experiments[index]);
- else window.location.hash = id;
- });
- tab.addEventListener('keydown', event => {
+byId('reveal-prediction').addEventListener('click', () => {
+ const reveal = byId('prediction-answer').hidden;
+ byId('prediction-answer').hidden = !reveal;
+ byId('reveal-prediction').setAttribute('aria-expanded', String(reveal));
+ byId('reveal-prediction').textContent = reveal ? 'Hide explanation' : 'Reveal explanation';
+});
+subjects.addEventListener('click', event => {
+ const tab = (event.target as HTMLElement).closest<HTMLButtonElement>('[data-experiment]');
+ if (!tab) return;
+ const id = tab.dataset.experiment!;
+ if (window.location.hash === `#${id}`) show(allLessons.find(e => e.id === id)!);
+ else window.location.hash = id;
+});
+subjects.addEventListener('keydown', event => {
+ const tabs = [...subjects.querySelectorAll<HTMLButtonElement>('[role="tab"]')];
+ const index = tabs.indexOf(event.target as HTMLButtonElement);
+ if (index < 0) return;
let next: number;
if (event.key === 'ArrowRight') next = (index + 1) % tabs.length;
else if (event.key === 'ArrowLeft') next = (index - 1 + tabs.length) % tabs.length;
@@ -129,7 +225,6 @@ tabs.forEach((tab, index) => {
event.preventDefault();
tabs[next].focus();
tabs[next].click();
- });
});
if (import.meta.hot) import.meta.hot.dispose(() => {
window.removeEventListener('hashchange', onHashChange);
diff --git a/src/math.ts b/src/math.ts
new file mode 100644
index 0000000..71403f4
--- /dev/null
+++ b/src/math.ts
@@ -0,0 +1,42 @@
+import katex from 'katex';
+import 'katex/dist/katex.min.css';
+
+const options = { output: 'htmlAndMathml', throwOnError: false, strict: 'error', trust: false } as const;
+
+/** Render authored TeX with bundled fonts and a MathML equivalent for assistive technology. */
+export function renderEquation(element: HTMLElement, tex: string) {
+ element.classList.add('math-display');
+ element.tabIndex = 0; // Long equations can be scrolled with the keyboard on narrow screens.
+ katex.render(tex, element, { ...options, displayMode: true });
+}
+
+/** Plain prose with explicit $...$ inline math; everything outside delimiters stays text. */
+export function renderProse(element: HTMLElement, text: string) {
+ element.replaceChildren();
+ let offset = 0;
+ for (const match of text.matchAll(/\$([^$\n]+)\$/g)) {
+ element.append(document.createTextNode(text.slice(offset, match.index)));
+ const math = document.createElement('span');
+ katex.render(match[1], math, { ...options, displayMode: false });
+ element.append(math);
+ offset = match.index + match[0].length;
+ }
+ element.append(document.createTextNode(text.slice(offset)));
+}
+
+// Short UI labels retain their plain accessible names while sharing the lesson notation.
+const notation: Record<string, string> = {
+ 'log₁₀(N / m⁻³)': String.raw`\log_{10}(N/\mathrm m^{-3})`,
+ 'EF − Ei': 'E_F-E_i', 'U₁₂₃': 'U_{123}', 'ΔU₀': String.raw`\Delta U_0`,
+ 'K + U': 'K+U', 'q₀': 'q_0', 'q₁': 'q_1', 'q₂': 'q_2', 'q₃': 'q_3',
+ 'Uₐ': 'U_a', 'Uᵦ': 'U_b', 'Kₐ': 'K_a', 'rₐ': 'r_a', 'rᵦ': 'r_b',
+ 'θₐ': String.raw`\theta_a`, 'θᵦ': String.raw`\theta_b`, 'τz': String.raw`\tau_z`,
+ '|Q|': '|Q|', '|P|': '|P|', '|E|': String.raw`|\vec E|`, 'Ex': 'E_x',
+ 'Δx': String.raw`\Delta x`, 'θ': String.raw`\theta`, 'α': String.raw`\alpha`,
+ 'κ': String.raw`\kappa`, 'σ': String.raw`\sigma`, '90°': String.raw`90^\circ`,
+};
+const notationPattern = new RegExp(Object.keys(notation).sort((a, b) => b.length - a.length).map(key => key.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')).join('|') + '|\\b[EqdnpUVK]\\b', 'g');
+
+export function renderNotation(element: HTMLElement, text: string) {
+ renderProse(element, text.replace(notationPattern, token => `$${notation[token] ?? token}$`));
+}
diff --git a/src/physics/test4.test.ts b/src/physics/test4.test.ts
new file mode 100644
index 0000000..08dd868
--- /dev/null
+++ b/src/physics/test4.test.ts
@@ -0,0 +1,42 @@
+import { describe, expect, it } from 'vitest';
+import { assembledEnergy, centralWork, dipoleState, energyTransfer, uniformWork } from './test4';
+
+describe('Test 4 physical answers', () => {
+ it('uses only displacement along the uniform field and preserves the charge sign', () => {
+ expect(uniformWork(2e-9, 100, 3, Math.PI / 3)).toBeCloseTo(3e-7, 14);
+ expect(uniformWork(-2e-9, 100, 3, 0)).toBeCloseTo(-6e-7, 14);
+ expect(uniformWork(2e-9, 100, 3, Math.PI / 2)).toBeCloseTo(0, 14);
+ });
+ it('computes point-charge work including the analytic infinity reference', () => {
+ expect(centralWork(3e-9, 2e-9, 2, 4)).toBeCloseTo(1.348132768845e-8, 16);
+ expect(centralWork(3e-9, -2e-9, 2, Infinity)).toBeCloseTo(-2.69626553769e-8, 16);
+ expect(() => centralWork(1, 1, 0, 2)).toThrow(RangeError);
+ });
+ it('converts the potential drop to kinetic energy without changing total energy', () => {
+ const state = energyTransfer(3e-8, 1e-8, 5e-9, 1e-6);
+ expect(state.kinetic).toBeCloseTo(2.5e-8, 16);
+ expect(state.total).toBeCloseTo(3.5e-8, 16);
+ expect(state.speed).toBeCloseTo(0.22360679775, 10);
+ expect(() => energyTransfer(0, 1, 0, 1)).toThrow(RangeError);
+ });
+ it('counts each pair once when building a charge configuration', () => {
+ const charges = [
+ { x: 0, y: 0, z: 0, coulombs: 1e-9 },
+ { x: 1, y: 0, z: 0, coulombs: 1e-9 },
+ { x: 0, y: 0, z: 0.75, coulombs: -1e-9 },
+ ];
+ expect(assembledEnergy(charges.slice(0, 1))).toBe(0);
+ expect(assembledEnergy(charges)).toBeCloseTo(-1.0185892031273e-8, 16);
+ expect(() => assembledEnergy([charges[0], charges[0]])).toThrow(RangeError);
+ });
+ it('distinguishes signed restoring torque, rotation work and zero at 90 degrees', () => {
+ const state = dipoleState(2e-9, 100, Math.PI / 2, 0);
+ expect(state.energy).toBeCloseTo(0, 14);
+ expect(state.torqueZ).toBeCloseTo(-2e-7, 14);
+ expect(state.work).toBeCloseTo(-2e-7, 14);
+ const aligned = dipoleState(2e-9, 100, 0, Math.PI / 2);
+ expect(aligned.energy).toBeCloseTo(-2e-7, 14);
+ expect(aligned.work).toBeCloseTo(2e-7, 14);
+ expect(dipoleState(2e-9, 100, Math.PI, 0).energy).toBeCloseTo(2e-7, 14);
+ });
+});
diff --git a/src/physics/test4.ts b/src/physics/test4.ts
new file mode 100644
index 0000000..a4d4be5
--- /dev/null
+++ b/src/physics/test4.ts
@@ -0,0 +1,36 @@
+import { COULOMB_CONSTANT, type Charge } from './electricField';
+
+export function uniformWork(q: number, field: number, distance: number, angle: number): number {
+ return q * field * distance * Math.cos(angle);
+}
+
+export function centralWork(q: number, q0: number, ra: number, rb: number): number {
+ if (!Number.isFinite(ra) || ra <= 0 || rb <= 0 || Number.isNaN(rb)) throw new RangeError('Distances must be positive; only the final distance may be infinity.');
+ return COULOMB_CONSTANT * q * q0 * (1 / ra - 1 / rb);
+}
+
+export function energyTransfer(initialPotential: number, potential: number, initialKinetic: number, mass: number) {
+ const kinetic = initialKinetic + initialPotential - potential;
+ if (kinetic < -1e-20 || mass <= 0) throw new RangeError('This position is energetically inaccessible or the mass is invalid.');
+ return { kinetic: Math.max(0, kinetic), total: initialKinetic + initialPotential, speed: Math.sqrt(2 * Math.max(0, kinetic) / mass) };
+}
+
+export function assembledEnergy(charges: readonly Charge[]): number {
+ let energy = 0;
+ for (let i = 0; i < charges.length; i++) for (let j = i + 1; j < charges.length; j++) {
+ const a = charges[i], b = charges[j];
+ const distance = Math.hypot(a.x - b.x, a.y - b.y, a.z - b.z);
+ if (distance === 0) throw new RangeError('Point charges must have distinct positions.');
+ energy += COULOMB_CONSTANT * a.coulombs * b.coulombs / distance;
+ }
+ return energy;
+}
+
+/** Dipole in the xy plane, E along +x; positive theta is counterclockwise. */
+export function dipoleState(moment: number, field: number, theta: number, thetaA: number) {
+ return {
+ energy: -moment * field * Math.cos(theta),
+ torqueZ: -moment * field * Math.sin(theta),
+ work: moment * field * (Math.cos(theta) - Math.cos(thetaA)),
+ };
+}
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);
+ }
+}
diff --git a/src/style.css b/src/style.css
index a1d6ced..1e8542b 100644
--- a/src/style.css
+++ b/src/style.css
@@ -9,6 +9,7 @@
--border: #2a394a;
}
* { box-sizing: border-box; }
+[hidden] { display: none !important; }
body { margin: 0; }
button, select, input { font: inherit; }
button, select { color: inherit; }
@@ -55,6 +56,9 @@ button:hover { border-color: var(--mint); }
.workbench { min-width: 0; }
#viewport { position: relative; height: clamp(480px, 64dvh, 800px); min-width: 0; overflow: hidden; }
#viewport > canvas { display: block; position: absolute; inset: 0; width: 100%; height: 100%; touch-action: none; }
+.scene-labels { position: absolute; inset: 0; pointer-events: none; z-index: 0; overflow: hidden; container-type: inline-size; }
+.scene-math-label { pointer-events: none; white-space: nowrap; padding: 2px 5px; border-radius: 3px; background: #101822d9; font-size: 13px; line-height: 1.5; }
+@container (max-width: 599px) { .scene-math-label { font-size: 11px; } }
.scene-toolbar { position: absolute; z-index: 1; top: 20px; left: 24px; right: 24px; display: flex; justify-content: space-between; align-items: center; gap: 10px; pointer-events: none; }
.view-tag { font: 10px monospace; color: #71899d; letter-spacing: 2px; }
.scene-toolbar > div { display: flex; gap: 8px; }
@@ -68,12 +72,81 @@ button:hover { border-color: var(--mint); }
.learning-panel { display: grid; grid-template-columns: 1.3fr 1fr; gap: 36px; padding: 28px 32px; border-top: 1px solid var(--border); background: #121d29; }
.learning-panel p { font-size: 13px; color: var(--muted); margin: 12px 0; }
.learning-panel .eyebrow { color: var(--mint); font-size: 10px; margin: 0 0 14px; }
-.equations p { color: #e3ecf3; font: 16px ui-monospace, monospace; line-height: 1.8; margin: 4px 0; }
+.learning-panel > div { min-width: 0; }
+.equations p { color: #e3ecf3; font-size: 16px; margin: 8px 0; }
.learning-panel a { font-size: 11px; }
.challenge { border-left: 1px solid var(--border); padding-left: 30px; }
.challenge > p:not(.eyebrow) { color: #cad8e4; }
details { margin-top: 20px; }
summary { color: var(--muted); font-size: 11px; cursor: pointer; }
+.collections { display: flex; gap: 5px; margin-left: auto; padding: 4px; background: #0c151f; border: 1px solid var(--border); border-radius: 8px; flex-shrink: 0; }
+.collections a { color: var(--muted); text-decoration: none; font-size: 12px; padding: 10px 13px; border-radius: 5px; }
+.collections a[aria-current] { background: #234139; color: #b0f3dc; }
+.test-overview { display: flex; justify-content: space-between; align-items: center; gap: 32px; padding: 30px 34px; background: radial-gradient(ellipse at 80% 0%, #20413988, transparent 65%), #121d29; border-bottom: 1px solid var(--border); }
+.test-overview .eyebrow { font-size: 9px; margin-bottom: 10px; }
+.test-overview h2 { font-size: clamp(22px, 2.4vw, 34px); font-weight: 500; letter-spacing: -1px; margin-bottom: 8px; }
+.test-overview p:last-child { font-size: 12px; color: var(--muted); margin: 0; }
+.test-score { flex-shrink: 0; display: grid; grid-template-columns: 1fr 1fr; gap: 10px 24px; }
+.test-score strong { font: 17px ui-monospace, monospace; color: var(--mint); }
+.test-score strong span { font-size: 11px; color: var(--muted); }
+.test-score > span { grid-column: 1 / -1; font-size: 10px; color: var(--muted); }
+[role='tab'] small { font: 10px ui-monospace, monospace; color: #729989; padding: 3px 5px; border: 1px solid #294739; border-radius: 4px; }
+.test-question { padding: 14px 16px; margin: 0 0 24px; border-left: 2px solid #708b7e; background: #1b2d35; border-radius: 0 7px 7px 0; }
+.test-question p { font-size: 12px; line-height: 1.75; margin: 9px 0 0; color: #bdcbd5; }
+.test-question .question-lead { margin-top: 0; color: #eef3ed; }
+.observation { padding: 18px 30px; background: #192e31; border-top: 1px solid #2c4947; border-bottom: 1px solid #2c4947; }
+.observation .eyebrow { font-size: 9px; }
+.observation p { margin: 8px 0 0; font-size: 13px; line-height: 1.75; color: #d1e3df; }
+.test-study { padding: 30px 32px; background: #101b26; border-top: 1px solid var(--border); }
+.solution-heading { display: flex; align-items: center; justify-content: space-between; gap: 16px; margin-bottom: 22px; }
+.solution-heading .eyebrow { margin-bottom: 8px; }
+.solution-heading h3 { font-weight: 500; font-size: 23px; margin: 0; letter-spacing: -0.5px; }
+.solution-heading a { font-size: 11px; }
+.solution details { border: 1px solid #314555; border-radius: 8px; background: #152331; margin: 12px 0; overflow: hidden; }
+.solution summary { font-size: 13px; color: #d0e1e8; padding: 17px 20px; line-height: 1.6; }
+.solution details[open] summary { color: var(--mint); border-bottom: 1px solid #314555; }
+.derivation { margin: 0; padding: 8px 24px 20px 52px; counter-reset: step; }
+.derivation li { padding: 8px 0 0 6px; color: var(--mint); }
+.derivation li::marker { font: 13px ui-monospace, monospace; }
+.derivation p { font-size: 13px !important; color: #a9becb; line-height: 1.8; margin: 8px 0; }
+.derivation .math-step { font-size: 15px !important; color: #edf4ef; background: #0d1924; border-radius: 5px; padding: 12px 14px; }
+.prediction { padding: 24px; margin-top: 24px; border: 1px solid #5b5138; border-radius: 8px; background: #262a28; }
+.prediction .eyebrow { color: var(--gold, #f5d58b); }
+.prediction h3 { font-size: 16px; font-weight: 500; line-height: 1.65; margin: 0 0 16px; }
+.prediction button { background: transparent; border: 1px solid #8c805b; border-radius: 5px; color: #f5d58b; padding: 9px 13px; font-size: 12px; }
+.prediction > p:last-child { font-size: 13px; color: #d3d6c6; line-height: 1.8; margin: 18px 0 0; }
+.lesson-pagination { display: grid; grid-template-columns: 1fr auto 1fr; align-items: center; gap: 10px; margin-top: 30px; }
+.lesson-pagination a { font-size: 12px; text-decoration: none; }
+.lesson-pagination a:first-child { grid-column: 1; }
+.lesson-pagination span { grid-column: 2; font: 11px ui-monospace, monospace; color: var(--muted); }
+.lesson-pagination a:last-child { grid-column: 3; text-align: right; }
+.math-display { max-width: 100%; min-width: 0; overflow-x: auto; overflow-y: hidden; padding: 6px 2px; scrollbar-width: thin; scrollbar-color: #526e7c transparent; }
+.math-display .katex-display { text-align: left; margin: 0.5em 0; }
+.math-display .katex-display > .katex { text-align: left; }
+.katex { font-size: 1.08em; }
+.math-display:focus-visible { outline: 2px solid var(--mint); outline-offset: 3px; }
+@media (max-width: 850px) {
+ .app-header { flex-wrap: wrap; gap: 16px; }
+ .collections { margin-left: auto; }
+ .header-title { flex: 1; }
+ .test-overview { padding: 25px 22px; gap: 20px; }
+ .test-score { gap: 8px 14px; }
+}
+@media (max-width: 600px) {
+ .app-header { padding: 16px 20px; }
+ .collections { width: 100%; margin: 0; }
+ .collections a { flex: 1; text-align: center; }
+ .test-overview { align-items: flex-start; flex-direction: column; gap: 18px; padding: 24px 20px; }
+ .test-score { display: flex; align-items: center; flex-wrap: wrap; gap: 16px; }
+ .test-score > span { font-size: 9px; }
+ .observation { padding: 18px 20px; }
+ .test-study { padding: 26px 20px; }
+ .solution-heading { align-items: flex-start; flex-direction: column; }
+ .derivation { padding-left: 35px; padding-right: 14px; }
+ .derivation .math-step { font-size: 13px !important; padding: 10px; }
+ .prediction { padding: 19px; }
+ .lesson-pagination a { font-size: 11px; }
+}
details p { font-size: 11px !important; }
.webgl-error { position: absolute; z-index: 2; top: 140px; left: 12%; right: 12%; padding: 24px; background: #1e2937; border: 1px solid #ff967b; border-radius: 8px; color: #ffb79f; font-size: 14px; line-height: 1.7; }
@media (min-width: 1600px) { main { grid-template-columns: 340px minmax(0, 1fr); } .controls-panel { padding: 32px; } }
diff --git a/src/test4.ts b/src/test4.ts
new file mode 100644
index 0000000..9bd32d8
--- /dev/null
+++ b/src/test4.ts
@@ -0,0 +1,232 @@
+import { format, number, type Choice, type Experiment, type Measurement, type Parameters, type Slider, type TestId } from './experiments';
+import { type Charge } from './physics/electricField';
+import { pairEnergy } from './physics/models';
+import { assembledEnergy, centralWork, dipoleState, energyTransfer, uniformWork } from './physics/test4';
+import questionUrl from '../docs/Test_4.md?url';
+import answerUrl from '../docs/Test_Answers.md?url';
+
+export { answerUrl };
+export interface AnswerPart { title: string; steps: { why: string; math?: string }[] }
+export interface TestLesson extends Experiment {
+ id: TestId;
+ points: number;
+ section: '4A' | '4B';
+ questions: string[];
+ answers: AnswerPart[];
+ prediction: string;
+ predictionAnswer: string;
+ observe: (p: Parameters) => string;
+}
+const slider = (id: string, label: string, min: number, max: number, step: number, value: number, unit: string): Slider => ({ kind: 'range', id, label, min, max, step, value, unit });
+const choice = (id: string, label: string, value: string, options: [string, string][]): Choice => ({ kind: 'select', id, label, value, options: options.map(([value, label]) => ({ value, label })) });
+const metric = (label: string, value: number, unit = 'nJ'): Measurement => ({ label, value: format(Math.abs(value) < 1e-10 ? 0 : value), unit });
+const energy = (label: string, joules: number) => metric(label, joules * 1e9);
+const radians = (degrees: number) => degrees * Math.PI / 180;
+export const progress = (p: Parameters) => number(p, 'progress') / 100;
+export const movingCharge = (p: Parameters) => number(p, 'q0') * 1e-9;
+export function motionState(p: Parameters) {
+ const field = number(p, 'field');
+ const s = number(p, 'distance');
+ const angle = radians(number(p, 'angle') || 0);
+ const work = uniformWork(movingCharge(p), field, s, angle);
+ return { field, s, angle, work, dx: s * Math.cos(angle), dy: s * Math.sin(angle) };
+}
+export function centralState(p: Parameters) {
+ const q = number(p, 'source') * 1e-9, q0 = movingCharge(p);
+ const ra = number(p, 'ra');
+ const rb = p.destination === 'infinity' ? Infinity : number(p, 'rb');
+ return { q, q0, ra, rb, ua: pairEnergy(q, q0, ra), ub: rb === Infinity ? 0 : pairEnergy(q, q0, rb), work: centralWork(q, q0, ra, rb) };
+}
+export function conservationState(p: Parameters) {
+ const r = 1.2 + progress(p) * 4.3;
+ const q0 = movingCharge(p);
+ const ua = pairEnergy(3e-9, q0, 1.2), u = pairEnergy(3e-9, q0, r);
+ return { r, ua, u, ...energyTransfer(ua, u, number(p, 'kinetic') * 1e-9, 1e-6) };
+}
+export function assemblyCharges(p: Parameters): Charge[] {
+ const s = number(p, 'spacing');
+ return [
+ { x: -s / 2, y: 0, z: -s / 3, coulombs: 3e-9 },
+ { x: s / 2, y: 0, z: -s / 3, coulombs: -2e-9 },
+ { x: 0, y: 0, z: s * 0.6, coulombs: 2e-9 },
+ { x: 0, y: s * 0.8, z: 0, coulombs: movingCharge(p) },
+ ];
+}
+export function assemblyState(p: Parameters) {
+ const charges = assemblyCharges(p), stage = Number(p.stage);
+ const base = assembledEnergy(charges.slice(0, 3));
+ const total = assembledEnergy(charges);
+ return { charges, stage, base, extra: total - base, current: assembledEnergy(charges.slice(0, stage)) };
+}
+export function rotationState(p: Parameters) {
+ const a = radians(number(p, 'thetaA')), b = radians(number(p, 'thetaB'));
+ const theta = a + (b - a) * progress(p);
+ const moment = number(p, 'charge') * 1e-9 * number(p, 'length');
+ const field = number(p, 'field');
+ return { a, b, theta, moment, field, ...dipoleState(moment, field, theta, a), endpointWork: dipoleState(moment, field, b, a).work };
+}
+const common = {
+ source: { title: 'Original question · Test_4.md', url: questionUrl },
+ legend: [['#ff967b', 'Positive charge'], ['#73d7ff', 'Negative charge'], ['#91e2cb', 'Electric field E'], ['#f5d58b', 'Path / force / energy']] as [string, string][],
+};
+const motionControls = [slider('q0', 'Moving charge q₀', -4, 4, 0.5, 2, 'nC'), slider('field', 'Electric field E', 0, 80, 5, 30, 'V/m'), slider('distance', 'Displacement s', 1, 6, 0.25, 4, 'm')];
+const journey = () => slider('progress', 'Journey progress', 0, 100, 1, 35, '%');
+
+export const testLessons: TestLesson[] = [
+ {
+ ...common, id: 'test-work', title: 'Work along a path', shortTitle: 'Work & paths', points: 30, section: '4A',
+ description: 'Follow a charge from a to b. Only the component of force along each tiny step contributes to work.',
+ questions: [String.raw`What is the work done by the electric force, when $q_0$ is moved from a to b?`],
+ controls: [...motionControls, choice('path', 'Path shape', 'straight', [['straight', 'Straight path'], ['curved', 'Curved path · same endpoints']]), journey()],
+ equations: [String.raw`W_{ab} = \int_a^b \vec F_e\cdot\mathrm d\vec r = q_0\int_a^b \vec E\cdot\mathrm d\vec r`, String.raw`W_{ab} = U_a-U_b = q_0(V_a-V_b)`],
+ explanation: 'The dot product measures how much the electric force helps or opposes each step. Electrostatic work depends on the endpoints. Positive field work means a drop in electric potential energy.',
+ challenge: 'Switch between the two paths. Predict the final work before changing the moving charge from positive to negative.',
+ prediction: 'Does the longer, curved route require more work by the electric field?',
+ predictionAnswer: String.raw`No. In an electrostatic field, $W=U_a-U_b$. Both routes start and finish at the same positions. Sideways steps are perpendicular to $E$ and contribute no work. Any extra motion along $E$ cancels the motion back against $E$.`,
+ sceneNote: String.raw`Example: uniform $E$ along +x. Green arrows are $E$; the gold arrow is electric force. Both paths share a and b.`,
+ limits: String.raw`Electrostatic vacuum model; prescribed path, not a free-particle trajectory. Geometry is in metres; field and force arrows show direction with compressed lengths. The gold probe is scrubbed by journey progress; dotted endpoint markers stay fixed. Choose $U_a=0$.`,
+ measure: p => { const m = motionState(p); return [energy('Field work a → b', m.work), energy('Work to current position', m.work * progress(p)), energy('Potential energy Uᵦ', -m.work)]; },
+ observe: p => String.raw`${p.path === 'curved' ? 'The curved route adds sideways motion, but the final work stays the same.' : 'The straight route makes the force–displacement dot product easy to see.'} At b, the field does ${format(motionState(p).work * 1e9)} nJ of work; $U$ drops by that amount.`,
+ answers: [{ title: 'Question 1 · Work by the electric force · 30p', steps: [
+ { why: 'Start with the definition of work: add the component of force along every small displacement.', math: String.raw`W_{ab} = \int_a^b\vec F_e\cdot\mathrm d\vec r` },
+ { why: String.raw`Substitute the electric force on the moving charge, $\vec F_e=q_0\vec E$.`, math: String.raw`W_{ab} = q_0\int_a^b\vec E\cdot\mathrm d\vec r` },
+ { why: 'The electrostatic force is conservative, so field work is the decrease in potential energy.', math: String.raw`W_{ab} = U_a-U_b = q_0(V_a-V_b)` },
+ ] }],
+ },
+ {
+ ...common, id: 'test-conservation', title: 'Where does the energy go?', shortTitle: 'Conservation', points: 15, section: '4A',
+ description: 'A positive charge moves away from a fixed positive source. Watch potential energy become kinetic energy.',
+ questions: ['Please write the condition for conservation of the total energy.'],
+ controls: [slider('q0', 'Moving charge q₀', 0.5, 4, 0.5, 2, 'nC'), slider('kinetic', 'Initial kinetic energy Kₐ', 0, 40, 1, 0, 'nJ'), journey()],
+ equations: [String.raw`\Delta E_{\mathrm{total}} = 0\quad\text{(isolated system)}`, String.raw`K_a+U_a = K_b+U_b`, String.raw`\Delta K = W_{ab} = -\Delta U`],
+ explanation: 'Total energy is conserved for an isolated system. If only the electric force does work on the moving charge, kinetic plus electric potential energy stays constant. The source is fixed; its support does no work.',
+ challenge: String.raw`Move the journey slider from 0% to 100%. Check that the total bar stays the same height as $K$ rises and $U$ falls.`,
+ prediction: 'If the particle starts at rest, can it gain speed without an energy supply?',
+ predictionAnswer: String.raw`Yes. Its initial electric potential energy supplies the increase in kinetic energy. Energy changes form while $K+U$ stays constant. The particle and source together are the electrostatic system; the moving particle alone does not have constant kinetic energy.`,
+ sceneNote: String.raw`Free radial motion away from +3 nC. Bars show $U$, $K$ and their constant sum; mass = 1 mg.`,
+ limits: String.raw`Source fixed, vacuum, nonrelativistic point particle of mass 1 mg. $U(\infty)=0$. Journey progress selects a position, not elapsed time; the speed is computed from energy, not from slider speed. Bar heights are normalized to the total energy; readouts are in nJ.`,
+ measure: p => { const s = conservationState(p); return [energy('Potential energy U', s.u), energy('Kinetic energy K', s.kinetic), energy('Total K + U', s.total), metric('Particle speed', s.speed, 'm/s')]; },
+ observe: p => { const s = conservationState(p); return String.raw`At $r$ = ${format(s.r)} m, $U$ = ${format(s.u * 1e9)} nJ and $K$ = ${format(s.kinetic * 1e9)} nJ. Their sum stays ${format(s.total * 1e9)} nJ. The work done so far is the increase in $K$.`; },
+ answers: [{ title: 'Question 2 · Conservation condition · 15p', steps: [
+ { why: 'For an isolated system, no net energy crosses the system boundary.', math: String.raw`\Delta E_{\mathrm{total}} = 0` },
+ { why: 'If the electric force is the only force doing work, the mechanical energy is conserved.', math: String.raw`K_a+U_a = K_b+U_b` },
+ { why: String.raw`For a particle of mass $m$, substitute kinetic energy. The work–energy theorem connects the two changes.`, math: String.raw`\begin{aligned}\frac12 mv_a^2+U_a &= \frac12 mv_b^2+U_b\\\Delta K &= W_{ab} = -\Delta U\end{aligned}` },
+ ] }],
+ },
+ {
+ ...common, id: 'test-central', title: 'The field of a point charge', shortTitle: 'Central field', points: 55, section: '4A',
+ description: String.raw`Move $q_0$ radially through a central field. Connect the inverse-square force to an inverse-distance potential energy.`,
+ questions: [String.raw`When $q_0$ moves from a to b in an $E$-field from a point charge $q$ (“Central field”).`, 'a. (30p) Please derive the work done by the electric force.', String.raw`b. (25p) The work above is equal to the drop $U_a$ − $U_b$. What are $U_a$ and $U_b$ if $b\to\infty$?`],
+ controls: [slider('source', 'Source charge q', -4, 4, 0.5, 3, 'nC'), slider('q0', 'Moving charge q₀', -4, 4, 0.5, 2, 'nC'), slider('ra', 'Initial distance rₐ', 0.75, 4, 0.25, 1.5, 'm'), slider('rb', 'Final distance rᵦ', 0.75, 6, 0.25, 4.5, 'm'), choice('destination', 'Destination b', 'finite', [['finite', 'Finite final distance'], ['infinity', 'Infinity · final energy is zero']]), journey()],
+ equations: [String.raw`\vec E(r) = \frac{kq}{r^2}\hat r`, String.raw`W_{ab} = kqq_0\left(\frac1{r_a}-\frac1{r_b}\right)`, String.raw`U(r) = \frac{kqq_0}{r},\qquad U(\infty) = 0`],
+ explanation: String.raw`Only radial displacement contributes to work in a central field. Integrating $\frac1{r^2}$ gives $-\frac1r$. The zero at infinity fixes the energy reference; it is not an additional force law.`,
+ challenge: String.raw`Compare like and opposite charges. Send b to infinity and check that $W_{a\infty}=U_a$, including its sign.`,
+ prediction: String.raw`Is $U$ always positive when we choose $U(\infty)=0$?`,
+ predictionAnswer: String.raw`No. $U=\frac{kqq_0}{r}$ is positive for like charges and negative for opposite charges. A negative value means the configuration has less energy than the separated charges at infinity. Work by the field in separating opposite charges is negative.`,
+ sceneNote: String.raw`Radial path above; $U(r)$ curve below. Infinity mode shows an outward continuation, not a finite stand-in for infinity.`,
+ limits: String.raw`Fixed point source; vacuum Coulomb constant $k=\frac1{4\pi\varepsilon_0}$. Distances in metres. $U$ graph: 30 nJ per vertical scene unit, clipped outside ±2.5 units. Infinity has exactly $U_b=0$ analytically; the visible path stops at the edge of the diagram and does not locate b at a finite radius.`,
+ measure: p => { const s = centralState(p); return [energy('Potential energy Uₐ', s.ua), energy('Potential energy Uᵦ', s.ub), energy('Field work a → b', s.work)]; },
+ observe: p => { const s = centralState(p); return s.rb === Infinity ? String.raw`At infinity, $U_b=0$ exactly. $W_{a\infty}=U_a$ = ${format(s.work * 1e9)} nJ. The diagram’s edge is only a visual continuation toward infinity.` : String.raw`The field does ${format(s.work * 1e9)} nJ of work from $r_a$ = ${s.ra} m to $r_b$ = ${s.rb} m. Switching the sign of either charge reverses $U$ and $W$.`; },
+ answers: [
+ { title: '3a · Derive the radial work · 30p', steps: [
+ { why: 'Write the radial field and force of the fixed point charge.', math: String.raw`\vec E = \frac{kq}{r^2}\hat r,\qquad \vec F_e = \frac{kqq_0}{r^2}\hat r` },
+ { why: String.raw`The radial component of the displacement is $\hat r\cdot\mathrm d\vec r=\mathrm dr$. Tangential steps do no work.`, math: String.raw`W_{ab} = kqq_0\int_{r_a}^{r_b}\frac1{r^2}\,\mathrm dr` },
+ { why: 'Integrate and evaluate at the endpoints. The result depends only on their distances.', math: String.raw`W_{ab} = kqq_0\left[-\frac1r\right]_{r_a}^{r_b} = kqq_0\left(\frac1{r_a}-\frac1{r_b}\right)` },
+ ] },
+ { title: '3b · Set the zero at infinity · 25p', steps: [
+ { why: String.raw`Choose $U(\infty)=0$. As $r_b\to\infty$, the reciprocal distance goes to zero.`, math: String.raw`W_{a\infty} = \frac{kqq_0}{r_a}` },
+ { why: String.raw`Use $W_{a\infty}=U_a-U_\infty$ to identify the two endpoint energies.`, math: String.raw`U_a = \frac{kqq_0}{r_a},\qquad U_b = U_\infty = 0` },
+ { why: String.raw`This gives the general interaction energy. Its sign is the sign of $qq_0$.`, math: String.raw`U(r) = \frac{kqq_0}{r}` },
+ ] },
+ ],
+ },
+ {
+ ...common, id: 'test-assembly', title: 'Build a system of charges', shortTitle: 'Charge assembly', points: 25, section: '4B',
+ description: 'Bring charges in one at a time. Every connecting edge is one interaction energy, counted exactly once.',
+ questions: ['Bringing charges together.', String.raw`a. (15p) What is the total electric potential energy if $q_1$, $q_2$, $q_3$ are brought together from infinity?`, String.raw`b. (10p) What is the additional electric potential energy if $q_0$ is brought together with $q_1$, $q_2$, $q_3$?`],
+ controls: [choice('stage', 'Assembly stage', '3', [['1', '1 · Place first charge'], ['2', '2 · Add second charge'], ['3', '3 · Add third charge'], ['4', '4 · Add test charge']]), slider('spacing', 'Configuration size', 2, 5, 0.25, 3.5, 'm'), slider('q0', 'Added charge q₀', -4, 4, 0.5, 2, 'nC')],
+ equations: [String.raw`U_{123} = k\left(\frac{q_1q_2}{r_{12}}+\frac{q_1q_3}{r_{13}}+\frac{q_2q_3}{r_{23}}\right)`, String.raw`\Delta U_0 = k\left(\frac{q_0q_1}{r_{01}}+\frac{q_0q_2}{r_{02}}+\frac{q_0q_3}{r_{03}}\right)`, String.raw`\Delta U_0 = q_0V_{123}`],
+ explanation: String.raw`Place $q_1$ for free. Adding $q_2$ creates one pair; adding $q_3$ creates two more. The fourth charge creates three new pairs without changing the energy of the existing three. For quasistatic assembly, external work equals the increase in $U$.`,
+ challenge: String.raw`Step through all four assembly stages. Then reverse $q_0$ and watch the sign of its three new interactions reverse.`,
+ prediction: String.raw`Should the total energy contain both $\frac{q_1q_2}{r_{12}}$ and $\frac{q_2q_1}{r_{21}}$?`,
+ predictionAnswer: String.raw`No. Those describe the same pair. Sum only over $i<j$. Three charges have three distinct pairs; four charges have six. The additional energy from $q_0$ includes just its three new pairs, $\Delta U_0=q_0V_{123}$.`,
+ sceneNote: 'A tetrahedral arrangement. Mint edges: existing pairs. Gold edges: pairs introduced by the newest charge. Dashed markers: not yet assembled.',
+ limits: String.raw`Point charges fixed at distinct positions. $q_1$ = +3 nC, $q_2$ = −2 nC, $q_3$ = +2 nC. All coordinates scale with the configuration size in metres. Reference: all charges mutually separated at infinity. Self-energies are excluded. Later-stage totals are shown in readouts even before those charges are placed.`,
+ measure: p => { const s = assemblyState(p); return [energy('Energy of current assembly', s.current), energy('Three-charge energy U₁₂₃', s.base), energy('Additional energy ΔU₀', s.extra), metric('Pairs currently assembled', s.stage * (s.stage - 1) / 2, 'pairs')]; },
+ observe: p => { const s = assemblyState(p); return String.raw`Stage ${s.stage}: ${s.stage * (s.stage - 1) / 2} distinct pairs are present. $U_{123}$ = ${format(s.base * 1e9)} nJ; adding $q_0$ changes it by ${format(s.extra * 1e9)} nJ. Negative pair energy indicates opposite signs.`; },
+ answers: [
+ { title: '4a · Assemble q₁, q₂, q₃ · 15p', steps: [
+ { why: String.raw`Bring $q_1$ from infinity first. With no other charges present, its interaction energy is zero.`, math: String.raw`U_1 = 0` },
+ { why: String.raw`Bring in $q_2$ against the potential due to $q_1$; then bring $q_3$ against the potential due to $q_1$ and $q_2$.`, math: String.raw`\begin{aligned}\Delta U_2 &= \frac{kq_1q_2}{r_{12}}\\\Delta U_3 &= \frac{kq_1q_3}{r_{13}}+\frac{kq_2q_3}{r_{23}}\end{aligned}` },
+ { why: 'Add the contributions, counting each pair once.', math: String.raw`U_{123} = k\left(\frac{q_1q_2}{r_{12}}+\frac{q_1q_3}{r_{13}}+\frac{q_2q_3}{r_{23}}\right)` },
+ ] },
+ { title: '4b · Add q₀ to the fixed system · 10p', steps: [
+ { why: String.raw`The existing three charges are held fixed. Add only the three new interactions involving $q_0$.`, math: String.raw`\Delta U_0 = k\left(\frac{q_0q_1}{r_{01}}+\frac{q_0q_2}{r_{02}}+\frac{q_0q_3}{r_{03}}\right)` },
+ { why: String.raw`Factor out $q_0$. The remaining sum is the potential of the original three charges at the new position.`, math: String.raw`\begin{aligned}V_{123} &= k\left(\frac{q_1}{r_{01}}+\frac{q_2}{r_{02}}+\frac{q_3}{r_{03}}\right)\\\Delta U_0 &= q_0V_{123}\end{aligned}` },
+ ] },
+ ],
+ },
+ {
+ ...common, id: 'test-uniform', title: 'Work in a uniform field', shortTitle: 'Uniform field', points: 25, section: '4B',
+ description: String.raw`Rotate the displacement relative to the field. The projection along $E$ sets the work, not the full path length.`,
+ questions: [String.raw`When $q_0$ moves from a to b in a uniform $E$-field.`, 'a. (15p) Please derive the work done by the electric force.', String.raw`b. (10p) What are $U_{\mathrm{pot},a}$ and $U_{\mathrm{pot},b}$?`],
+ controls: [...motionControls, slider('angle', 'Displacement angle α', 0, 180, 5, 35, '°'), journey()],
+ equations: [String.raw`W_{ab} = q_0\vec E\cdot(\vec r_b-\vec r_a) = q_0Es\cos\alpha`, String.raw`W_{ab} = q_0E(x_b-x_a)`, String.raw`U(x) = -q_0Ex+C`],
+ explanation: String.raw`A constant field can be taken outside the work integral. With x along $E$, the potential energy changes only with x. The energy zero is arbitrary; here a is the reference, so $U_a=0$.`,
+ challenge: String.raw`Set $\alpha$ to $90^\circ$. The charge travels a nonzero distance, but $E$ is perpendicular to every step: work is zero.`,
+ prediction: String.raw`If $\alpha$ exceeds $90^\circ$ for a positive charge, does the field give energy to the charge?`,
+ predictionAnswer: String.raw`The field does negative work because $\cos\alpha<0$. $U$ increases as the charge moves against the field. An external agent would need to supply that energy to move it slowly, or a freely moving particle would lose kinetic energy.`,
+ sceneNote: String.raw`Displacement is in the xy plane; $E$ points along +x. The blue segment is $\Delta x=s\cos\alpha$. Choose $U_a=0$.`,
+ limits: String.raw`Ideal constant field with no edge effects. Geometry in metres; arrows show direction with compressed lengths. Prescribed motion, not a free-particle trajectory. $U_a=0$ fixes $C=q_0Ex_a$; $x_a$ is shown explicitly by the a marker.`,
+ measure: p => { const m = motionState(p); return [energy('Field work a → b', m.work), metric('Projection Δx', m.dx, 'm'), energy('Potential energy Uₐ', 0), energy('Potential energy Uᵦ', -m.work)]; },
+ observe: p => { const m = motionState(p); return String.raw`$\alpha = ${number(p, 'angle')}^\circ$ projects $s$ = ${m.s} m onto $\Delta x$ = ${format(m.dx)} m. $W$ = ${format(m.work * 1e9)} nJ and $U_b$ = ${format(-m.work * 1e9)} nJ with $U_a=0$.`; },
+ answers: [
+ { title: '5a · Derive uniform-field work · 15p', steps: [
+ { why: String.raw`Start with the work integral. The field is constant, so take $\vec E$ outside the integral.`, math: String.raw`W_{ab} = q_0\int_a^b\vec E\cdot\mathrm d\vec r = q_0\vec E\cdot(\vec r_b-\vec r_a)` },
+ { why: String.raw`Write the dot product using the displacement length and angle $\alpha$.`, math: String.raw`W_{ab} = q_0Es\cos\alpha` },
+ { why: 'Choose the x-axis along the field. Only the x-displacement contributes.', math: String.raw`W_{ab} = q_0E(x_b-x_a)` },
+ ] },
+ { title: '5b · Endpoint potential energies · 10p', steps: [
+ { why: 'The change in potential energy is the negative of the field work.', math: String.raw`U_b-U_a = -q_0E(x_b-x_a)` },
+ { why: String.raw`Integrating with respect to x leaves an arbitrary reference constant $C$.`, math: String.raw`U_a = -q_0Ex_a+C,\qquad U_b = -q_0Ex_b+C` },
+ { why: String.raw`A convenient choice is $U_a=0$. It changes no forces or energy differences.`, math: String.raw`U_a = 0,\qquad U_b = -q_0E(x_b-x_a)` },
+ ] },
+ ],
+ },
+ {
+ ...common, id: 'test-dipole', title: 'Turn a dipole in a field', shortTitle: 'Dipole rotation', points: 50, section: '4B',
+ description: 'Two equal and opposite forces cancel in translation, yet form a torque. Rotate the dipole to explore work and stability.',
+ questions: [String.raw`The electric dipole in a uniform $E$-field.`, String.raw`a. (10p) Derive the torque vector and prove $\tau=p_eE\sin\theta$, where $\theta=\angle(\vec p_e,\vec E)$.`, String.raw`b. (30p) Derive $W_{ab}$ as the dipole rotates from $\theta_a$ to $\theta_b$. What are $U_a$ and $U_b$? You do not have to derive $\mathrm dW=-\tau\,\mathrm d\theta$.`, String.raw`c. (10p) Explain why $\theta_a$ = $90^\circ$ can be a reference, give $U_a$, and find the general $U$ also as a scalar product.`],
+ controls: [slider('charge', 'Dipole charge magnitude q', 0.5, 4, 0.5, 2, 'nC'), slider('length', 'Charge separation d', 0.5, 3, 0.25, 2, 'm'), slider('field', 'Electric field E', 0, 80, 5, 30, 'V/m'), slider('thetaA', 'Initial angle θₐ', 0, 180, 5, 90, '°'), slider('thetaB', 'Final angle θᵦ', 0, 180, 5, 15, '°'), slider('progress', 'Rotation progress', 0, 100, 1, 50, '%')],
+ equations: [String.raw`\vec p_e = q\vec d,\qquad \vec\tau = \vec p_e\times\vec E,\qquad |\vec\tau| = p_e E\sin\theta`, String.raw`W_{ab} = p_e E(\cos\theta_b-\cos\theta_a)`, String.raw`U(\theta) = -p_e E\cos\theta = -\vec p_e\cdot\vec E`],
+ explanation: String.raw`The dipole moment points from $-q$ to $+q$. The equal and opposite electric forces create a couple that tends to align $\vec p_e$ with $\vec E$. We use $U(90^\circ)=0$; alignment is the minimum energy state.`,
+ challenge: String.raw`Set the final angle to $0^\circ$, then $180^\circ$, with rotation progress at 100%. Both have zero torque. Compare their energy and stability.`,
+ prediction: String.raw`If the torque is zero at both $0^\circ$ and $180^\circ$, are both orientations stable?`,
+ predictionAnswer: String.raw`No. At $0^\circ$, $U=-p_eE$ is a minimum: a small angular displacement produces a restoring torque. At $180^\circ$, $U=+p_eE$ is a maximum: a small displacement drives the dipole farther away. At $90^\circ$, $U=0$ by convention but torque is largest, so this is not equilibrium. Without damping, a freely rotating dipole can oscillate about alignment.`,
+ sceneNote: String.raw`$E$ along $+x$; $\vec p_e$ points from − to +. Gold arrows show forces; the blue z-arrow shows signed torque. $U(\theta)$ below.`,
+ limits: String.raw`Ideal rigid dipole with fixed centre in a uniform field. Geometry in metres. No net force. Angle measured counterclockwise in xy, so $\tau_z=-p_eE\sin\theta$. The slider prescribes orientation, not a damped dynamical trajectory. Torque arrow length and graph height are normalized; readouts give magnitudes in SI-derived units. $U(90^\circ)=0$.`,
+ measure: p => { const s = rotationState(p); return [metric('Current angle θ', s.theta * 180 / Math.PI, '°'), metric('Torque τz', s.torqueZ * 1e9, 'nN·m'), energy('Potential energy U', s.energy), energy('Field work a → b', s.endpointWork), energy('Work to current angle', s.work)]; },
+ observe: p => { const s = rotationState(p); const degrees = s.theta * 180 / Math.PI; return s.field === 0 ? String.raw`$E=0$: both forces, torque, rotation work and dipole interaction energy vanish. There is no preferred orientation.` : degrees < 0.01 ? String.raw`Aligned at $0^\circ$: $U$ is minimum and torque is zero. This is stable equilibrium; a small rotation produces a restoring torque.` : degrees > 179.99 ? String.raw`Opposed at $180^\circ$: $U$ is maximum and torque is zero. This is unstable equilibrium; a small rotation takes it away from this position.` : String.raw`At $\theta = ${format(degrees)}^\circ$, $\tau_z$ = ${format(s.torqueZ * 1e9)} nN·m. The negative z direction reduces $\theta$ toward alignment. ${Math.abs(degrees - 90) < 0.01 ? String.raw`At $90^\circ$, $U=0$ by choice, but the torque is maximum: this is not equilibrium.` : 'Follow the point on the energy curve as you rotate.'}`; },
+ answers: [
+ { title: '6a · Torque vector and magnitude · 10p', steps: [
+ { why: String.raw`Let $\vec d$ point from the negative charge to the positive charge. Define the dipole moment.`, math: String.raw`\begin{aligned}\vec p_e &= q\vec d\\\vec F_+ &= q\vec E,\quad \vec F_- = -q\vec E\\\vec F_{\mathrm{net}} &= 0\end{aligned}` },
+ { why: String.raw`Take moments about the midpoint. The two lever arms are $\pm\frac{\vec d}{2}$.`, math: String.raw`\vec\tau = \frac{\vec d}{2}\times q\vec E+\left(-\frac{\vec d}{2}\right)\times(-q\vec E) = q\vec d\times\vec E` },
+ { why: 'The cross product gives the vector direction and magnitude. The two forces form a couple.', math: String.raw`\vec\tau = \vec p_e\times\vec E,\qquad |\vec\tau| = p_e E\sin\theta` },
+ ] },
+ { title: '6b · Rotation work and endpoint energies · 30p', steps: [
+ { why: String.raw`The torque acts toward decreasing $\theta$. With $\tau$ as its positive magnitude, use the supplied relation.`, math: String.raw`\mathrm dW = -\tau\,\mathrm d\theta = -p_e E\sin\theta\,\mathrm d\theta` },
+ { why: 'Integrate from the initial angle to the final angle.', math: String.raw`W_{ab} = -p_e E\int_{\theta_a}^{\theta_b}\sin\theta\,\mathrm d\theta = p_e E(\cos\theta_b-\cos\theta_a)` },
+ { why: String.raw`Use $W_{ab}=U_a-U_b$. The reference constant is the same at both endpoints.`, math: String.raw`\begin{aligned}U_a &= -p_e E\cos\theta_a+C\\U_b &= -p_e E\cos\theta_b+C\end{aligned}` },
+ ] },
+ { title: '6c · Reference at 90° and scalar product · 10p', steps: [
+ { why: String.raw`The zero of potential energy is arbitrary. At $90^\circ$ the two charges are at the same electric potential, so $+qV$ and $-qV$ cancel.`, math: String.raw`U_a = U(90^\circ) = 0` },
+ { why: String.raw`Since $\cos90^\circ=0$, this reference sets $C=0$.`, math: String.raw`U(\theta) = -p_e E\cos\theta = -\vec p_e\cdot\vec E` },
+ { why: 'Aligned is a stable energy minimum; opposed is an unstable maximum. The perpendicular reference is not an equilibrium.', math: String.raw`\begin{aligned}U(0^\circ) &= -p_e E\\U(90^\circ) &= 0\\U(180^\circ) &= +p_e E\end{aligned}` },
+ ] },
+ ],
+ },
+];
+
+export function getTestLesson(experiment: Experiment): TestLesson | undefined {
+ return testLessons.find(lesson => lesson.id === experiment.id);
+}
diff --git a/tests/lab.spec.ts b/tests/lab.spec.ts
index 51645cd..e4bac8e 100644
--- a/tests/lab.spec.ts
+++ b/tests/lab.spec.ts
@@ -10,7 +10,7 @@ async function read(page: Page, label: string) {
test('all eight lessons render 3D content with measurements and explanations', async ({ page }) => {
const errors: string[] = [];
page.on('pageerror', e => errors.push(e.message));
- await page.goto('/');
+ await page.goto('/#field');
const subjects = ['Electric charge, force and field', 'Electric flux and charge', 'Electric potential energy', 'Electric potential and gradient', 'Capacitance', 'Dielectric materials', 'Current', 'Semiconducting materials'];
const tabs = page.getByRole('tab');
await expect(tabs).toHaveCount(8);
diff --git a/tests/math.spec.ts b/tests/math.spec.ts
new file mode 100644
index 0000000..71b586d
--- /dev/null
+++ b/tests/math.spec.ts
@@ -0,0 +1,65 @@
+import { test, expect } from '@playwright/test';
+
+test('work equations typeset vector arrows and integral limits with accessible math', async ({ page }) => {
+ await page.goto('/#test-work');
+ const equations = page.locator('#equations');
+ await expect(equations.locator('.katex')).toHaveCount(2);
+ await expect(equations.locator('math')).toHaveCount(2);
+ await expect(equations.locator('math msubsup').first()).toBeAttached();
+ await expect(equations.locator('math mover').first()).toBeAttached();
+ await expect(page.locator('label[for="parameter-q0"] .katex')).toBeVisible();
+ await expect(page.getByRole('slider', { name: 'Moving charge q₀', exact: true })).toBeVisible();
+ await page.locator('#solution summary').click();
+ await expect(page.locator('.math-step .katex')).toHaveCount(3);
+ await expect(page.locator('.katex-error')).toHaveCount(0);
+});
+
+test('every lesson summary and derivation renders without math parsing errors', async ({ page }) => {
+ const errors: string[] = [];
+ page.on('pageerror', error => errors.push(error.message));
+ for (const id of ['test-work', 'test-conservation', 'test-central', 'test-assembly', 'test-uniform', 'test-dipole', 'field', 'flux', 'energy', 'potential', 'capacitance', 'dielectric', 'current', 'semiconductor']) {
+ await page.goto(`/#${id}`);
+ await expect(page.locator('#equations .katex').first()).toBeVisible();
+ expect(await page.locator('#equations .katex').count()).toBe(await page.locator('#equations > p').count());
+ if (id.startsWith('test-')) {
+ await page.locator('#solution details').evaluateAll(details => details.forEach(detail => detail.setAttribute('open', '')));
+ expect(await page.locator('.math-step .katex').count()).toBe(await page.locator('.math-step').count());
+ }
+ await expect(page.locator('.katex-error')).toHaveCount(0);
+ }
+ await page.goto('/#test-dipole');
+ await expect(page.locator('#test-question .katex').first()).toBeVisible();
+ await expect(page.locator('#explanation .katex').first()).toBeVisible();
+ expect(errors).toEqual([]);
+});
+
+test('long formulas stay contained and usable on a narrow screen', async ({ page }) => {
+ await page.setViewportSize({ width: 390, height: 844 });
+ await page.goto('/#test-assembly');
+ await page.locator('#solution summary').first().click();
+ await expect(page.locator('#equations .katex').first()).toBeVisible();
+ expect(await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth)).toBe(true);
+ const equation = page.locator('#equations > p').first();
+ const layout = await equation.evaluate(element => {
+ const style = getComputedStyle(element);
+ return { overflow: style.overflowX, width: element.getBoundingClientRect().width, available: element.parentElement!.getBoundingClientRect().width };
+ });
+ expect(layout.width).toBeLessThanOrEqual(layout.available);
+ expect(layout.overflow).toBe('auto');
+});
+
+test('3D mathematical annotations track the scene and are removed when switching lessons', async ({ page }) => {
+ await page.goto('/#test-dipole');
+ const labels = page.locator('#viewport .scene-math-label');
+ await expect(labels.locator('math mfrac').first()).toBeAttached();
+ await expect(labels.filter({ hasText: 'cos' }).first()).toBeVisible();
+ const before = await labels.count();
+ await page.getByRole('slider', { name: 'Final angle θᵦ', exact: true }).fill('180');
+ await expect(labels).toHaveCount(before);
+ await page.getByRole('tab', { name: /Work & paths/ }).click();
+ await expect(labels.filter({ hasText: 'cos' })).toHaveCount(0);
+ await expect(labels.locator('.katex').first()).toBeVisible();
+ await expect(page.locator('#viewport .katex-error')).toHaveCount(0);
+ // Labels are an overlay; orbiting the underlying canvas remains possible.
+ expect(await page.locator('.scene-labels').evaluate(el => getComputedStyle(el).pointerEvents)).toBe('none');
+});
diff --git a/tests/test4.spec.ts b/tests/test4.spec.ts
new file mode 100644
index 0000000..3e6a3b5
--- /dev/null
+++ b/tests/test4.spec.ts
@@ -0,0 +1,118 @@
+import { test, expect, type Page } from '@playwright/test';
+
+async function read(page: Page, label: string) {
+ return Number((await page.getByLabel(label, { exact: true }).innerText()).replaceAll(',', ''));
+}
+
+test('six original test questions have interactive scenes and every answer part', async ({ page }) => {
+ const errors: string[] = [];
+ page.on('pageerror', e => errors.push(e.message));
+ await page.goto('/');
+ const ids = ['work', 'conservation', 'central', 'assembly', 'uniform', 'dipole'];
+ const parts = [1, 1, 2, 2, 2, 3];
+ await expect(page.getByRole('tab')).toHaveCount(6);
+ for (let i = 0; i < ids.length; i++) {
+ await page.getByRole('tab').nth(i).click();
+ await expect(page).toHaveURL(new RegExp(`#test-${ids[i]}$`));
+ await expect(page.locator('#test-question')).not.toBeEmpty();
+ await expect(page.locator('#solution details')).toHaveCount(parts[i]);
+ await page.locator('#solution summary').first().click();
+ await expect(page.locator('#solution details[open] .derivation')).toBeVisible();
+ await expect(page.locator('#measurements')).not.toContainText('Undefined');
+ await expect(page.locator('canvas').first()).toBeVisible();
+ // Check actual rendered pixels, including scenes constructed after hash navigation.
+ await expect.poll(() => page.locator('#viewport > canvas').evaluate(canvas => new Promise<number>(resolve => {
+ requestAnimationFrame(() => requestAnimationFrame(() => {
+ const small = document.createElement('canvas'); small.width = 80; small.height = 60;
+ const context = small.getContext('2d')!;
+ context.drawImage(canvas as HTMLCanvasElement, 0, 0, 80, 60);
+ const data = context.getImageData(0, 0, 80, 60).data;
+ const colours = new Set<number>();
+ for (let k = 0; k < data.length; k += 4) colours.add((data[k] << 16) | (data[k + 1] << 8) | data[k + 2]);
+ resolve(colours.size);
+ }));
+ }))).toBeGreaterThan(12);
+ }
+ expect(errors).toEqual([]);
+});
+
+test('path changes preserve work while signed charge and angle change it', async ({ page }) => {
+ await page.goto('/#test-work');
+ const initial = await read(page, 'Field work a → b');
+ await page.getByLabel('Path shape', { exact: true }).selectOption('curved');
+ expect(await read(page, 'Field work a → b')).toBe(initial);
+ await page.getByRole('slider', { name: 'Moving charge q₀', exact: true }).fill('-2');
+ expect(await read(page, 'Field work a → b')).toBe(-initial);
+ await page.goto('/#test-uniform');
+ await page.getByRole('slider', { name: 'Displacement angle α', exact: true }).fill('90');
+ expect(Math.abs(await read(page, 'Field work a → b'))).toBeLessThan(1e-10);
+});
+
+test('energy conservation, infinity and dipole equilibrium stay physically consistent', async ({ page }) => {
+ await page.goto('/#test-conservation');
+ const total = await read(page, 'Total K + U');
+ await page.getByRole('slider', { name: 'Journey progress', exact: true }).fill('100');
+ expect(await read(page, 'Total K + U')).toBe(total);
+ expect(await read(page, 'Kinetic energy K')).toBeGreaterThan(0);
+ await page.goto('/#test-central');
+ await page.getByLabel('Destination b', { exact: true }).selectOption('infinity');
+ expect(await read(page, 'Potential energy Uᵦ')).toBe(0);
+ await expect(page.locator('#observation')).toContainText('infinity');
+ await page.goto('/#test-dipole');
+ await page.getByRole('slider', { name: 'Final angle θᵦ', exact: true }).fill('0');
+ await page.getByRole('slider', { name: 'Rotation progress', exact: true }).fill('100');
+ expect(await read(page, 'Potential energy U')).toBeLessThan(0);
+ expect(await read(page, 'Torque τz')).toBe(0);
+ await expect(page.locator('#observation')).toContainText('stable');
+});
+
+test('assembly adds q₀ without double counting and the prediction can be revealed', async ({ page }) => {
+ await page.goto('/#test-assembly');
+ await page.getByLabel('Assembly stage', { exact: true }).selectOption('3');
+ const base = await read(page, 'Energy of current assembly');
+ await page.getByLabel('Assembly stage', { exact: true }).selectOption('4');
+ expect(await read(page, 'Energy of current assembly')).toBeCloseTo(base + await read(page, 'Additional energy ΔU₀'), 1);
+ await expect(page.locator('#prediction-answer')).toBeHidden();
+ await page.getByRole('button', { name: 'Reveal explanation' }).click();
+ await expect(page.locator('#prediction-answer')).toBeVisible();
+});
+
+test('collection navigation retains settings, works with history, and fits mobile', async ({ page }) => {
+ await page.goto('/#test-dipole');
+ await page.getByRole('slider', { name: 'Final angle θᵦ', exact: true }).fill('180');
+ await page.getByRole('link', { name: 'All experiments', exact: true }).click();
+ await expect(page.getByRole('tab')).toHaveCount(8);
+ await page.goBack();
+ await expect(page.getByRole('slider', { name: 'Final angle θᵦ', exact: true })).toHaveValue('180');
+ await page.getByRole('button', { name: 'Reset experiment' }).click();
+ await expect(page.getByRole('slider', { name: 'Final angle θᵦ', exact: true })).toHaveValue('15');
+ await page.setViewportSize({ width: 390, height: 844 });
+ await expect(page.locator('canvas')).toBeVisible();
+ expect(await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth)).toBe(true);
+});
+
+test('next question returns a mobile learner to the new heading', async ({ page }) => {
+ await page.setViewportSize({ width: 390, height: 844 });
+ await page.goto('/#test-work');
+ await page.getByRole('link', { name: 'Next question →', exact: true }).click();
+ await expect(page).toHaveURL(/#test-conservation$/);
+ await expect(page.getByRole('heading', { name: 'Where does the energy go?' })).toBeInViewport();
+ await expect(page.locator('#lesson-title')).toBeFocused();
+});
+
+test('test answers and controls remain accessible without WebGL', async ({ page }) => {
+ await page.addInitScript(() => {
+ const original = HTMLCanvasElement.prototype.getContext;
+ HTMLCanvasElement.prototype.getContext = function (type: string, ...args: unknown[]) {
+ if (type.startsWith('webgl')) return null;
+ return original.call(this, type as '2d', ...args as []) as never;
+ } as typeof original;
+ });
+ await page.goto('/#test-dipole');
+ await expect(page.getByRole('alert')).toContainText('WebGL');
+ await page.getByRole('slider', { name: 'Rotation progress', exact: true }).fill('100');
+ expect(await read(page, 'Potential energy U')).toBeLessThan(0);
+ await page.locator('#solution summary').last().click();
+ await expect(page.locator('#solution details[open] math').first()).toBeAttached();
+ await expect(page.locator('#solution details[open] .katex-error')).toHaveCount(0);
+});
diff --git a/vite.config.ts b/vite.config.ts
index a35e76e..935dce8 100644
--- a/vite.config.ts
+++ b/vite.config.ts
@@ -1,5 +1,7 @@
import { defineConfig } from 'vitest/config';
export default defineConfig({
+ // Keep original question/answer links navigable in production.
+ build: { assetsInlineLimit: 0 },
test: { include: ['src/**/*.test.ts'] },
});