From 389fb3a592832092835da0c2cc1608a57ba5f629 Mon Sep 17 00:00:00 2001
From: LalleSX
Date: Sat, 24 Oct 2026 14:18:00 +0200
Subject: 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.
---
.vscode/settings.json | 3 +
README.md | 30 +++
docs/Test_4.md | 63 +++++
docs/Test_Answers.md | 361 +++++++++++++++++++++++++++++
docs/implementation-progress.md | 52 -----
docs/superpowers/plans/2026-10-24-test4.md | 59 +++++
index.html | 3 +-
package-lock.json | 31 +++
package.json | 2 +
src/experiments.ts | 61 ++---
src/main.ts | 147 +++++++++---
src/math.ts | 42 ++++
src/physics/test4.test.ts | 42 ++++
src/physics/test4.ts | 36 +++
src/scene/experiments.ts | 22 +-
src/scene/lab.ts | 24 +-
src/scene/primitives.ts | 21 +-
src/scene/test4.ts | 203 ++++++++++++++++
src/style.css | 75 +++++-
src/test4.ts | 232 ++++++++++++++++++
tests/lab.spec.ts | 2 +-
tests/math.spec.ts | 65 ++++++
tests/test4.spec.ts | 118 ++++++++++
vite.config.ts | 2 +
24 files changed, 1570 insertions(+), 126 deletions(-)
create mode 100644 .vscode/settings.json
create mode 100644 docs/Test_4.md
create mode 100644 docs/Test_Answers.md
delete mode 100644 docs/implementation-progress.md
create mode 100644 docs/superpowers/plans/2026-10-24-test4.md
create mode 100644 src/math.ts
create mode 100644 src/physics/test4.test.ts
create mode 100644 src/physics/test4.ts
create mode 100644 src/scene/test4.ts
create mode 100644 src/test4.ts
create mode 100644 tests/math.spec.ts
create mode 100644 tests/test4.spec.ts
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 @@
-
Electrotechnique · Field Lab
+
+ Test 4 in 3D · Electrophysics Lab
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;
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('#app')!;
app.innerHTML = `
- E/LAB
+ E/LAB
Electrotechnique & Electrophysics
Explore the invisible. Understand the physical.
- INTERACTIVE LAB
+
-
+
FROM EQUATIONS TO INTUITION
Electric energy, made visible.
Explore the six questions from Test 4. Change the scene, make a prediction, then follow the derivation.