aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
Diffstat (limited to '')
-rw-r--r--.gitignore6
-rw-r--r--.nvmrc1
-rw-r--r--PLAN.md10
-rw-r--r--README.md120
-rw-r--r--docs/implementation-progress.md52
-rw-r--r--index.html13
-rw-r--r--package-lock.json1484
-rw-r--r--package.json27
-rw-r--r--playwright.config.ts14
-rw-r--r--src/experiments.ts182
-rw-r--r--src/main.ts137
-rw-r--r--src/physics/electricField.test.ts21
-rw-r--r--src/physics/electricField.ts22
-rw-r--r--src/physics/models.test.ts108
-rw-r--r--src/physics/models.ts112
-rw-r--r--src/scene/experiments.ts245
-rw-r--r--src/scene/lab.ts97
-rw-r--r--src/scene/primitives.ts90
-rw-r--r--src/style.css104
-rw-r--r--tests/lab.spec.ts152
-rw-r--r--theory/1Introduction to electrophysics.pdfbin0 -> 1373195 bytes
-rw-r--r--theory/2Electric charge, force and field.pdfbin0 -> 1307864 bytes
-rw-r--r--theory/3Electric flux and charge.pdfbin0 -> 2051370 bytes
-rw-r--r--theory/4Electric potential energy.pdfbin0 -> 1922512 bytes
-rw-r--r--theory/5Electric potential and gradient.pdfbin0 -> 3009513 bytes
-rw-r--r--theory/6capacitance.pdfbin0 -> 1315542 bytes
-rw-r--r--theory/7dielectricmaterials.pdfbin0 -> 1657183 bytes
-rw-r--r--theory/8current.pdfbin0 -> 2134217 bytes
-rw-r--r--theory/9semiconductingmaterials.pdfbin0 -> 3016847 bytes
-rw-r--r--tsconfig.json13
-rw-r--r--vite.config.ts5
31 files changed, 3015 insertions, 0 deletions
diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..12c3132
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,6 @@
+node_modules/
+dist/
+.vite/
+*.local
+test-results/
+playwright-report/
diff --git a/.nvmrc b/.nvmrc
new file mode 100644
index 0000000..2bd5a0a
--- /dev/null
+++ b/.nvmrc
@@ -0,0 +1 @@
+22
diff --git a/PLAN.md b/PLAN.md
new file mode 100644
index 0000000..73f7f15
--- /dev/null
+++ b/PLAN.md
@@ -0,0 +1,10 @@
+# The plan of Electrophysics
+### It is the plan to have a visual and educating 3D graphics for the following subjects:
+Electric charge, force and field
+Electric flux and charge
+Electric potential energy
+Electric potential and gradient
+Capacitance
+Dielectric materials
+Current
+Semiconducting materials
diff --git a/README.md b/README.md
new file mode 100644
index 0000000..916cb69
--- /dev/null
+++ b/README.md
@@ -0,0 +1,120 @@
+# Electrotechnique & Electrophysics Sandbox
+
+Three.js + strict TypeScript + Vite, developed in Ubuntu on WSL2.
+
+## Develop
+
+Run in your Ubuntu terminal:
+
+```bash
+cd /home/victor/projects/geekedin
+npm install
+npm run dev
+```
+
+Open http://localhost:5173 in your Windows browser. The dev server binds to
+127.0.0.1 by default and Vite provides hot module replacement.
+Use a browser with WebGL and hardware acceleration enabled.
+
+Node.js 22.12+ is required; `.nvmrc` selects Node 22 if you use nvm.
+For a fresh checkout with a lockfile, use `npm ci` for repeatable installs.
+
+## Commands
+
+- `npm run dev`: development server, hot reload.
+- `npm run typecheck`: strict TypeScript check.
+- `npm test`: physics unit tests with Vitest.
+- `npm run test:e2e`: Chromium browser checks with Playwright.
+- `npm run build`: type check and production bundle in `dist/`.
+- `npm run preview`: preview the production build at http://localhost:4173.
+
+## Eight experiments from PLAN.md
+
+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:
+
+| Subject | Interactive view | URL fragment |
+| --- | --- | --- |
+| Electric charge, force and field | Dipole/like charges, field arrows and force on a source | `#field` |
+| Electric flux and charge | Move a signed charge through a spherical Gaussian surface | `#flux` |
+| Electric potential energy | Vary separation and charge, plot U(r) and field work | `#energy` |
+| Electric potential and gradient | Potential landscape with field arrows and a movable probe | `#potential` |
+| Capacitance | Vary plate area, gap and applied voltage | `#capacitance` |
+| Dielectric materials | Polarization at fixed voltage versus fixed free charge | `#dielectric` |
+| Current | Ohmic copper wire with electrons drifting against conventional current | `#current` |
+| Semiconducting materials | Intrinsic, n-type and p-type silicon, carrier densities and bands | `#semiconductor` |
+
+Drag to orbit, scroll to zoom, right-drag to pan. Focus the canvas to pan
+with arrow keys. Reset the camera independently or reset the current
+experiment. Settings survive switching subjects for the current page session.
+URL fragments support browser back/forward and direct links. Animations can
+be paused, and start paused when reduced motion is preferred. Without WebGL,
+lessons, controls and measurements remain usable.
+
+These are teaching models: electrostatics in vacuum, ideal parallel plates,
+linear fully filling dielectrics, an Ohmic copper wire at fixed temperature,
+and non-degenerate silicon in equilibrium at 300 K. The semiconductor lab
+uses approximate reference constants disclosed in the lesson. It does not
+model a p–n junction or compare real devices. Geometry, arrow lengths,
+carrier populations and animation speeds use explicit visual scales.
+
+In the isolated dielectric experiment, each parameter setting represents
+charging the selected geometry in vacuum at the charging voltage, then
+disconnecting and filling it with dielectric. Q is fixed while varying κ;
+changing geometry or charging voltage prepares a different capacitor state.
+
+## Verification
+
+```bash
+npm test
+npm run build
+# Once per development machine:
+npx playwright install chromium
+npm run test:e2e
+```
+
+The unit suite checks force direction, the potential gradient, energy,
+Gauss's law, capacitance, fixed-Q/fixed-V dielectric behavior, current,
+drift and semiconductor mass action/neutrality. Playwright checks rendered
+content in all eight scenes, parameter changes, reset, history, mobile layout,
+pausing and the WebGL fallback. The browser suite starts Vite if necessary.
+On a minimal Ubuntu installation, Playwright may require browser libraries;
+its installer can identify them (`npx playwright install-deps chromium`).
+
+## Extend
+
+- `src/main.ts`: subject navigation, parameter controls, lesson UI.
+- `src/experiments.ts`: lesson definitions, defaults, units and measurements.
+- `src/scene/lab.ts`: renderer, orbit controls, resizing and frame loop.
+- `src/scene/experiments.ts`: one scene builder per topic.
+- `src/scene/primitives.ts`: shared geometry/labels and GPU resource disposal.
+- `src/physics/`: framework-independent SI models and unit tests.
+- `tests/lab.spec.ts`: real-browser interaction and rendering checks.
+
+Add model calculations under `src/physics/`, then connect an experiment
+and a scene builder. Keep physical quantities in SI and perform display
+conversion in the lesson definitions.
+
+## WSL2 notes
+
+Keep the project in `/home/victor/projects` for Linux file watching and I/O.
+Run Node/npm in Ubuntu rather than mixing Windows and Linux installations.
+For VS Code, run `code .` in Ubuntu and use its WSL extension.
+
+If Windows cannot reach localhost, first check the URL printed by Vite (it
+may choose another port if 5173 is busy). If WSL localhost forwarding is
+unavailable, run `npm run dev -- --host 0.0.0.0` and access the WSL address
+shown by `hostname -I`. This exposes the development server on available
+interfaces; use it only on a trusted development network.
+
+If file changes do not reload on a mounted Windows filesystem, move the
+project into the Linux filesystem. Polling can be enabled in Vite config if
+needed, but is unnecessary for the default project location.
+
+## References
+
+- [Vite getting started](https://vite.dev/guide/)
+- [Three.js installation](https://threejs.org/manual/pages/installation.html)
+- [OpenStax University Physics Volume 2](https://openstax.org/details/books/university-physics-volume-2)
+- [OpenStax band theory of solids](https://openstax.org/books/university-physics-volume-3/pages/9-5-band-theory-of-solids)
diff --git a/docs/implementation-progress.md b/docs/implementation-progress.md
new file mode 100644
index 0000000..f4a745a
--- /dev/null
+++ b/docs/implementation-progress.md
@@ -0,0 +1,52 @@
+# 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/index.html b/index.html
new file mode 100644
index 0000000..2f4ad93
--- /dev/null
+++ b/index.html
@@ -0,0 +1,13 @@
+<!doctype html>
+<html lang="en">
+ <head>
+ <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>
+ </head>
+ <body>
+ <div id="app"></div>
+ <script type="module" src="/src/main.ts"></script>
+ </body>
+</html>
diff --git a/package-lock.json b/package-lock.json
new file mode 100644
index 0000000..cf51837
--- /dev/null
+++ b/package-lock.json
@@ -0,0 +1,1484 @@
+{
+ "name": "electrophysics-sandbox",
+ "version": "0.1.0",
+ "lockfileVersion": 3,
+ "requires": true,
+ "packages": {
+ "": {
+ "name": "electrophysics-sandbox",
+ "version": "0.1.0",
+ "dependencies": {
+ "three": "^0.186.1"
+ },
+ "devDependencies": {
+ "@playwright/test": "^1.64.1",
+ "@types/three": "^0.186.0",
+ "typescript": "^7.0.2",
+ "vite": "^8.3.5",
+ "vitest": "^5.0.5"
+ },
+ "engines": {
+ "node": ">=22.12.0"
+ }
+ },
+ "node_modules/@dimforge/rapier3d-compat": {
+ "version": "0.12.0",
+ "resolved": "https://registry.npmjs.org/@dimforge/rapier3d-compat/-/rapier3d-compat-0.12.0.tgz",
+ "integrity": "sha512-uekIGetywIgopfD97oDL5PfeezkFpNhwlzlaEYNOA0N6ghdsOvh/HYjSMek5Q2O1PYvRSDFcqFVJl4r4ZBwOow==",
+ "dev": true
+ },
+ "node_modules/@jridgewell/resolve-uri": {
+ "version": "3.1.2",
+ "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz",
+ "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==",
+ "dev": true,
+ "engines": {
+ "node": ">=6.0.0"
+ }
+ },
+ "node_modules/@jridgewell/sourcemap-codec": {
+ "version": "1.6.0",
+ "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.6.0.tgz",
+ "integrity": "sha512-T7jf+5zgsZHwNJ4lvQ7/aezbyk0nNX+zJVWpmHA7VYsEx7a7qr5Rg5IbtJFqkgze5Y2sruq1RUY8Q837Od7iFw==",
+ "dev": true
+ },
+ "node_modules/@jridgewell/trace-mapping": {
+ "version": "0.3.31",
+ "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz",
+ "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==",
+ "dev": true,
+ "dependencies": {
+ "@jridgewell/resolve-uri": "^3.1.0",
+ "@jridgewell/sourcemap-codec": "^1.4.14"
+ }
+ },
+ "node_modules/@oxc-project/types": {
+ "version": "0.155.0",
+ "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.155.0.tgz",
+ "integrity": "sha512-SeE8JPwW1xs1tHeNycjUtUBdhorRa8o4jlvjwk49R7/Z291i+4DQa7JJIgOm/TaYQZx+QrPQbVJ/9c8dMIZN8Q==",
+ "dev": true,
+ "funding": {
+ "url": "https://github.com/sponsors/oxc-project"
+ }
+ },
+ "node_modules/@playwright/test": {
+ "version": "1.64.1",
+ "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.64.1.tgz",
+ "integrity": "sha512-IaYGml/nSOiZWROfPtuIL7neuu9qDOZ3TNvNljUIrhDMxiGTgPjzj/g9p9RxpS2tSepPDRY+UwAaEeiKtUvIAg==",
+ "dev": true,
+ "dependencies": {
+ "playwright": "1.64.1"
+ },
+ "bin": {
+ "playwright": "cli.js"
+ },
+ "engines": {
+ "node": ">=20"
+ }
+ },
+ "node_modules/@rolldown/binding-android-arm-eabi": {
+ "version": "1.2.15",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm-eabi/-/binding-android-arm-eabi-1.2.15.tgz",
+ "integrity": "sha512-aLcgK9gIJ3YRvrrtfSBbRa27F5WrpiTaPulnFpsHPxpUHgFlSgnU4854MHwoRGEBhfRJDWCS2WQ7lIjskE9Olw==",
+ "cpu": [
+ "arm"
+ ],
+ "dev": true,
+ "optional": true,
+ "os": [
+ "android"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@rolldown/binding-android-arm64": {
+ "version": "1.2.15",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.2.15.tgz",
+ "integrity": "sha512-xD7DdZCrbThKuxNg7ooQmvp07ys4J/pnSEMTG3/OdOWl6daC67m/Q4fL78sRHMxTH/N4fGUbYhcPM0MF5WFHkg==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "optional": true,
+ "os": [
+ "android"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@rolldown/binding-darwin-arm64": {
+ "version": "1.2.15",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.2.15.tgz",
+ "integrity": "sha512-m2WuQ6r0B4OTYxyPpNLtXaMRbRDUbG8YQV2Q3KTdSSK8Th6KHCnC8jVCJt8bvYeSB5WivkkNbc85mKadaSdOxA==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@rolldown/binding-darwin-x64": {
+ "version": "1.2.15",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.2.15.tgz",
+ "integrity": "sha512-5RtnsHM36nodM9WbMy+zip0LGS/G+y5wro5RHrpOlr4ac24+1zSi9kiV2MP1myZeZ/ifEHfulQcWjnAXzc2C2A==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@rolldown/binding-freebsd-x64": {
+ "version": "1.2.15",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.2.15.tgz",
+ "integrity": "sha512-g3l09pyFqhu+135JKkbkwiKGQmwU5hUMUPJ4VdBADvuqR8d33L6H578rh8m7rtxbdp+VmX/+nzI7nvhSZlzpUQ==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "optional": true,
+ "os": [
+ "freebsd"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@rolldown/binding-linux-arm-gnueabihf": {
+ "version": "1.2.15",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.2.15.tgz",
+ "integrity": "sha512-nIe3VugI97nGQtspPxDqA4sXMUpeiocKIP/CqpY/u/vgbT+alX+cVxu1C/oVBPKg4feNTmYovE91v4DgXvXNwQ==",
+ "cpu": [
+ "arm"
+ ],
+ "dev": true,
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@rolldown/binding-linux-arm64-gnu": {
+ "version": "1.2.15",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.2.15.tgz",
+ "integrity": "sha512-iTBjXx+B5QSvgI89lJfmqh4wTh8NabR1YZIIN3hNx4zSG0EVfVqkOvLulNpG4251pbd6/IAmZ/LefVk1Y4rPhw==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@rolldown/binding-linux-arm64-musl": {
+ "version": "1.2.15",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.2.15.tgz",
+ "integrity": "sha512-uN26XOnVR7rqQupG3V9oAG2EGGatWjcJHOeDOHUFCh4TBWmP2tHTFoVN13aYbwfm0aHqJAEmcwhAu6sooIjfqQ==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@rolldown/binding-linux-ppc64-gnu": {
+ "version": "1.2.15",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.2.15.tgz",
+ "integrity": "sha512-9nWkcdd+H+XhaA3fQIwpHnKIrfx/PQ6rO0pivr78Og07tXlUlctBBJoEYLPfhTmCR/QxEVdn6uPjHV5SymPhdw==",
+ "cpu": [
+ "ppc64"
+ ],
+ "dev": true,
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@rolldown/binding-linux-s390x-gnu": {
+ "version": "1.2.15",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.2.15.tgz",
+ "integrity": "sha512-Ggh0ciFjLwIusLSLz7LZYEaGdxOAUJXI+Qu5JCbhPJJHEt8vP+IjRATTJheiJsdzzTYgUiHUiw3X1Ea3ZpMBdA==",
+ "cpu": [
+ "s390x"
+ ],
+ "dev": true,
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@rolldown/binding-linux-x64-gnu": {
+ "version": "1.2.15",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.2.15.tgz",
+ "integrity": "sha512-FHg60rS6DBePAYZ9X9F67x+JObFdmgn6tupA1C/a3B+CufFmTD4mX+e8M75tCiAO3foR/6xwVBI0KQiI7sn/rw==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@rolldown/binding-linux-x64-musl": {
+ "version": "1.2.15",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.2.15.tgz",
+ "integrity": "sha512-/cvV/7v5VGz/moiz0XKRj2dE8DzsKyhIu1KpOvJGPjRywg3yscON889UGvAe8zfiJLHup7VLjBduoGWi3HusWQ==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@rolldown/binding-openharmony-arm64": {
+ "version": "1.2.15",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.2.15.tgz",
+ "integrity": "sha512-MyzC5Hrv/sm+Z7TSGKP/j8001RO2xQm1zOpCNVL4j0cY/VhBZviludA2YFQ7i4lH6ZAXxQm0lcMdBzM+99mE7w==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "optional": true,
+ "os": [
+ "openharmony"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@rolldown/binding-win32-arm64-msvc": {
+ "version": "1.2.15",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.2.15.tgz",
+ "integrity": "sha512-+PgYiS+AH5omy6L45ZLxybasauJaj3M8buoT83D+nzPmCayXlxUV9hydhciCKVyIg0B/v0BtXPDE1uGqNwCPpg==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@rolldown/binding-win32-x64-msvc": {
+ "version": "1.2.15",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.2.15.tgz",
+ "integrity": "sha512-HFZd2QNQYn1JbIi+Hpd7H+r/lkWxcfIu28nirZ6eCn2LafuvmExMlb/P96Sfx5tf7pPArw39IdhmCF8vmV+NhQ==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@rolldown/pluginutils": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.1.tgz",
+ "integrity": "sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==",
+ "dev": true
+ },
+ "node_modules/@tweenjs/tween.js": {
+ "version": "23.1.3",
+ "resolved": "https://registry.npmjs.org/@tweenjs/tween.js/-/tween.js-23.1.3.tgz",
+ "integrity": "sha512-vJmvvwFxYuGnF2axRtPYocag6Clbb5YS7kLL+SO/TeVFzHqDIWrNKYtcsPMibjDx9O+bu+psAy9NKfWklassUA==",
+ "dev": true
+ },
+ "node_modules/@types/chai": {
+ "version": "5.2.3",
+ "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz",
+ "integrity": "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==",
+ "dev": true,
+ "dependencies": {
+ "@types/deep-eql": "*",
+ "assertion-error": "^2.0.1"
+ }
+ },
+ "node_modules/@types/deep-eql": {
+ "version": "4.0.2",
+ "resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz",
+ "integrity": "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==",
+ "dev": true
+ },
+ "node_modules/@types/estree": {
+ "version": "1.0.9",
+ "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz",
+ "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==",
+ "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",
+ "integrity": "sha512-jIBvWWShCvlBqBNIZt0KAshWpvSjhkwkEu4ZUcASoAvhmrgAUI2t1dXrjSL4xXVLB4FznPrIsX3nKXFl/Dt4vA==",
+ "dev": true
+ },
+ "node_modules/@types/three": {
+ "version": "0.186.0",
+ "resolved": "https://registry.npmjs.org/@types/three/-/three-0.186.0.tgz",
+ "integrity": "sha512-mxYSBpDC+D0pLfSP6sW4WZTcT+nrtmZcimMqnVmy36Hte3XpeYSrvgg4TRdaM1GemGog1AWzI5qL2VoIfMXbJQ==",
+ "dev": true,
+ "dependencies": {
+ "@dimforge/rapier3d-compat": "~0.12.0",
+ "@tweenjs/tween.js": "~23.1.3",
+ "@types/stats.js": "*",
+ "@types/webxr": ">=0.5.17",
+ "fflate": "~0.8.3",
+ "meshoptimizer": "~1.1.1"
+ }
+ },
+ "node_modules/@types/webxr": {
+ "version": "0.5.24",
+ "resolved": "https://registry.npmjs.org/@types/webxr/-/webxr-0.5.24.tgz",
+ "integrity": "sha512-h8fgEd/DpoS9CBrjEQXR+dIDraopAEfu4wYVNY2tEPwk60stPWhvZMf4Foo5FakuQ7HFZoa8WceaWFervK2Ovg==",
+ "dev": true
+ },
+ "node_modules/@typescript/typescript-aix-ppc64": {
+ "version": "7.0.2",
+ "resolved": "https://registry.npmjs.org/@typescript/typescript-aix-ppc64/-/typescript-aix-ppc64-7.0.2.tgz",
+ "integrity": "sha512-MTKKkWB7p/0E9xi1d1tHtZ5PiLkGEMIq88pK2CubZjOsLtYTLqhgIgi6zepFa+9GHZ6h05NMCkQxGKiPXMxXtQ==",
+ "cpu": [
+ "ppc64"
+ ],
+ "dev": true,
+ "optional": true,
+ "os": [
+ "aix"
+ ],
+ "engines": {
+ "node": ">=16.20.0"
+ }
+ },
+ "node_modules/@typescript/typescript-darwin-arm64": {
+ "version": "7.0.2",
+ "resolved": "https://registry.npmjs.org/@typescript/typescript-darwin-arm64/-/typescript-darwin-arm64-7.0.2.tgz",
+ "integrity": "sha512-gowzar9MwS/aRWp6f3a4KUqzRjAZjOsmGNCM6LcTgXum+dBfgsBVMN+AgvOCCbguXyick6LJhpBszxMebJ8syA==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": ">=16.20.0"
+ }
+ },
+ "node_modules/@typescript/typescript-darwin-x64": {
+ "version": "7.0.2",
+ "resolved": "https://registry.npmjs.org/@typescript/typescript-darwin-x64/-/typescript-darwin-x64-7.0.2.tgz",
+ "integrity": "sha512-SZ9xZInqApNlNGc9s0W1VSsktYSOe9cFqNOIqmN1Gs8SmkjKZYFt017G4VwPxASInODuAdbTW7sXiFUf893RgA==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": ">=16.20.0"
+ }
+ },
+ "node_modules/@typescript/typescript-freebsd-arm64": {
+ "version": "7.0.2",
+ "resolved": "https://registry.npmjs.org/@typescript/typescript-freebsd-arm64/-/typescript-freebsd-arm64-7.0.2.tgz",
+ "integrity": "sha512-W5NH4y/J0plIIS5b2xvTEkU7JFxyqdMAOgf+Ilhl0vHQXKO5dZoxd+C/jEtq56c4F3wk71RB4BMRQ2XdI+bwYQ==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "optional": true,
+ "os": [
+ "freebsd"
+ ],
+ "engines": {
+ "node": ">=16.20.0"
+ }
+ },
+ "node_modules/@typescript/typescript-freebsd-x64": {
+ "version": "7.0.2",
+ "resolved": "https://registry.npmjs.org/@typescript/typescript-freebsd-x64/-/typescript-freebsd-x64-7.0.2.tgz",
+ "integrity": "sha512-UMGDx5sTpzNw3WiPebH7l90IWfJggEd+egHt/q6p7/Cm3zqoV7VxkGXt+3DxPIw8CcmvAB0j3sVVfbhX+M4Tpw==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "optional": true,
+ "os": [
+ "freebsd"
+ ],
+ "engines": {
+ "node": ">=16.20.0"
+ }
+ },
+ "node_modules/@typescript/typescript-linux-arm": {
+ "version": "7.0.2",
+ "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-arm/-/typescript-linux-arm-7.0.2.tgz",
+ "integrity": "sha512-gffT3xPz9sR7j/YJExkyPntrI0P2EP9XbOyWzth2/Gs0RstK+90RBcO0ncXoXy/beYll1SXw846Nf2zdnEz0QQ==",
+ "cpu": [
+ "arm"
+ ],
+ "dev": true,
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=16.20.0"
+ }
+ },
+ "node_modules/@typescript/typescript-linux-arm64": {
+ "version": "7.0.2",
+ "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-arm64/-/typescript-linux-arm64-7.0.2.tgz",
+ "integrity": "sha512-Qh4eU4/y3yDjnfjjyPYihMj5/ODIlmt+Bzu17OI+fiSRDW57QmU5SiN63exPRNJPKUzcc1INa1NXdrJ+MqHjUQ==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=16.20.0"
+ }
+ },
+ "node_modules/@typescript/typescript-linux-loong64": {
+ "version": "7.0.2",
+ "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-loong64/-/typescript-linux-loong64-7.0.2.tgz",
+ "integrity": "sha512-uEHck9i8hoAzXPiYRib1O7miOnz23SxIeVl6F4LXox+qov1K35jHcEW6VHKvZI+pyvl7fZEP4MCU5LYvIq1GuQ==",
+ "cpu": [
+ "loong64"
+ ],
+ "dev": true,
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=16.20.0"
+ }
+ },
+ "node_modules/@typescript/typescript-linux-mips64el": {
+ "version": "7.0.2",
+ "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-mips64el/-/typescript-linux-mips64el-7.0.2.tgz",
+ "integrity": "sha512-R4KvAMnE43W5Qeqb0Ly56O3mWMWIAgsMyz36DCaycd5nbg/9kzm0liw3JocfRqyJY0KPmzFjbswozXyW0DnIYA==",
+ "cpu": [
+ "mips64el"
+ ],
+ "dev": true,
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=16.20.0"
+ }
+ },
+ "node_modules/@typescript/typescript-linux-ppc64": {
+ "version": "7.0.2",
+ "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-ppc64/-/typescript-linux-ppc64-7.0.2.tgz",
+ "integrity": "sha512-DORx5b3sd/4S7eayxm4FQv+A7CrkUIGRaHiwI8oiHTAI1fAPWhF4J0vAlkC8biAlHSVVwxMQ3tjZ2/DVbnQiiA==",
+ "cpu": [
+ "ppc64"
+ ],
+ "dev": true,
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=16.20.0"
+ }
+ },
+ "node_modules/@typescript/typescript-linux-riscv64": {
+ "version": "7.0.2",
+ "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-riscv64/-/typescript-linux-riscv64-7.0.2.tgz",
+ "integrity": "sha512-wf0jqEDOjrPRnKwYRyyJDRo11KMbvMFrU+q4zqKyChODBzvlkbhNQfKvLxQCcwTpdDaXSHZTVuh0JoCrKCUMHQ==",
+ "cpu": [
+ "riscv64"
+ ],
+ "dev": true,
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=16.20.0"
+ }
+ },
+ "node_modules/@typescript/typescript-linux-s390x": {
+ "version": "7.0.2",
+ "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-s390x/-/typescript-linux-s390x-7.0.2.tgz",
+ "integrity": "sha512-IkwJc3L7yhytWd/ewjyxNDfOmswCm9GWMJT/ue/dU4aZNbwZeYAetq42VyLmsmSjvoX7z74X6ZaYCtzAr0EuGw==",
+ "cpu": [
+ "s390x"
+ ],
+ "dev": true,
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=16.20.0"
+ }
+ },
+ "node_modules/@typescript/typescript-linux-x64": {
+ "version": "7.0.2",
+ "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-x64/-/typescript-linux-x64-7.0.2.tgz",
+ "integrity": "sha512-EYdf2cNg7rgCWJnxCdJ+F3V39O8ihb37eHAu1LK8oAFizgTQbPOK7zHHXbPt8rX24COqODXeI3sIf0fCXG7H/A==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=16.20.0"
+ }
+ },
+ "node_modules/@typescript/typescript-netbsd-arm64": {
+ "version": "7.0.2",
+ "resolved": "https://registry.npmjs.org/@typescript/typescript-netbsd-arm64/-/typescript-netbsd-arm64-7.0.2.tgz",
+ "integrity": "sha512-+polYF4MF04aPpO5FTkHran9yUQDSXqy5GiSDKpsll5jy3l3+g9QLhpf39T+ePtefhXLOGrLl0QIjkQP6VnelA==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "optional": true,
+ "os": [
+ "netbsd"
+ ],
+ "engines": {
+ "node": ">=16.20.0"
+ }
+ },
+ "node_modules/@typescript/typescript-netbsd-x64": {
+ "version": "7.0.2",
+ "resolved": "https://registry.npmjs.org/@typescript/typescript-netbsd-x64/-/typescript-netbsd-x64-7.0.2.tgz",
+ "integrity": "sha512-8YIT0EHM/3dq10ZOVF/A7pc/YSMtbcecct4rWtexrnSCHOPcpC2KTLXfTCR6vDpnSiY12heNb1GiN/wu+T/FyA==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "optional": true,
+ "os": [
+ "netbsd"
+ ],
+ "engines": {
+ "node": ">=16.20.0"
+ }
+ },
+ "node_modules/@typescript/typescript-openbsd-arm64": {
+ "version": "7.0.2",
+ "resolved": "https://registry.npmjs.org/@typescript/typescript-openbsd-arm64/-/typescript-openbsd-arm64-7.0.2.tgz",
+ "integrity": "sha512-APT8+ClYnuYm1u9+kgGXoMj2VzWzcymwh2gNSQVySHfkRDGOTVkoWLjCmOQSaO+PoqQ57B0flRp9SA+7GnnkzQ==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "optional": true,
+ "os": [
+ "openbsd"
+ ],
+ "engines": {
+ "node": ">=16.20.0"
+ }
+ },
+ "node_modules/@typescript/typescript-openbsd-x64": {
+ "version": "7.0.2",
+ "resolved": "https://registry.npmjs.org/@typescript/typescript-openbsd-x64/-/typescript-openbsd-x64-7.0.2.tgz",
+ "integrity": "sha512-yX7s+Q0Dln0Dt9tEzZsAjXXR/+ytBM7AlglaqyeMPxQszJ1JhlJdZ6jLA+IzldHtflX81em7lDao1xXu+aRRkg==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "optional": true,
+ "os": [
+ "openbsd"
+ ],
+ "engines": {
+ "node": ">=16.20.0"
+ }
+ },
+ "node_modules/@typescript/typescript-sunos-x64": {
+ "version": "7.0.2",
+ "resolved": "https://registry.npmjs.org/@typescript/typescript-sunos-x64/-/typescript-sunos-x64-7.0.2.tgz",
+ "integrity": "sha512-dLJDGaLZ1D4HPQn62u1n8mBDkJREwMsAkCdkwd4Ieqw+x3TUyTsqY0YiBCtE6H6OzzgGk3iuZ3vFWRS+E8/d1g==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "optional": true,
+ "os": [
+ "sunos"
+ ],
+ "engines": {
+ "node": ">=16.20.0"
+ }
+ },
+ "node_modules/@typescript/typescript-win32-arm64": {
+ "version": "7.0.2",
+ "resolved": "https://registry.npmjs.org/@typescript/typescript-win32-arm64/-/typescript-win32-arm64-7.0.2.tgz",
+ "integrity": "sha512-Gyl1Vy6OsWesLzmq+EP0Fb7b4Nid5232AvcA2SFcdYreldpNtYFFofPjnt62y9hQy7VTaZp65ICJjuAQRaVcIQ==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">=16.20.0"
+ }
+ },
+ "node_modules/@typescript/typescript-win32-x64": {
+ "version": "7.0.2",
+ "resolved": "https://registry.npmjs.org/@typescript/typescript-win32-x64/-/typescript-win32-x64-7.0.2.tgz",
+ "integrity": "sha512-0BQ3HkAHHlKLSp1qRvf3SUhGpGsDuhB/jgFw75guyqbxJqEaS0Cw/VFO8i2nHglJUzQCRtMMR/IBAKE3ETMC4g==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">=16.20.0"
+ }
+ },
+ "node_modules/@vitest/mocker": {
+ "version": "5.0.5",
+ "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-5.0.5.tgz",
+ "integrity": "sha512-xLL3lkDegJZw+MGoGaCCsFayDZthGgpAtr3Vziyfc4p7xH89K7KRPcRBBh/kEYm0Aqkt5DtTmkYpBtRV/FrHZg==",
+ "dev": true,
+ "dependencies": {
+ "@jridgewell/trace-mapping": "0.3.31",
+ "@vitest/spy": "5.0.5",
+ "estree-walker": "^3.0.3",
+ "magic-string": "^1.4.3"
+ },
+ "funding": {
+ "url": "https://opencollective.com/vitest"
+ },
+ "peerDependencies": {
+ "msw": "^2.4.9 || ^3.0.0",
+ "vite": "^6.0.0 || ^7.0.0 || ^8.0.0"
+ },
+ "peerDependenciesMeta": {
+ "msw": {
+ "optional": true
+ },
+ "vite": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@vitest/spy": {
+ "version": "5.0.5",
+ "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-5.0.5.tgz",
+ "integrity": "sha512-lwJRExuH417257Q5wv9QYugN9IMJkoxcrKrYxGPQkBzl7wlNpTUxD/5yNX4ESKVUIShKPgkGchYPFMWOOk0wzw==",
+ "dev": true,
+ "funding": {
+ "url": "https://opencollective.com/vitest"
+ }
+ },
+ "node_modules/assertion-error": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz",
+ "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==",
+ "dev": true,
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/chai": {
+ "version": "6.3.0",
+ "resolved": "https://registry.npmjs.org/chai/-/chai-6.3.0.tgz",
+ "integrity": "sha512-XWAtwJ6OHO+tj0EKCs0Y2UamnyOxseZWltU4x2U2wh8g4AigdjwvtUjvLP2tqkA/avxHEtzxNaqGq/YGNwckKg==",
+ "dev": true,
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/detect-libc": {
+ "version": "2.1.2",
+ "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz",
+ "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==",
+ "dev": true,
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/es-module-lexer": {
+ "version": "2.3.2",
+ "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.3.2.tgz",
+ "integrity": "sha512-poHGpORABojJJucnV9KbOavETW8lBVnphkW77ER5/BQ5Fz7oXSoCNek7IH3vR5nRjdsEz926ibFYX8KtLQmdyw==",
+ "dev": true
+ },
+ "node_modules/estree-walker": {
+ "version": "3.0.3",
+ "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz",
+ "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==",
+ "dev": true,
+ "dependencies": {
+ "@types/estree": "^1.0.0"
+ }
+ },
+ "node_modules/expect-type": {
+ "version": "1.4.0",
+ "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.4.0.tgz",
+ "integrity": "sha512-KfYbmpRm0VbLjEvVa9yGwCi9GI34xvi7A/HXYWQO65CSD2u3MczUJSuwXKFIxlGsgBQizV9q5J9NHj4VG0n+pA==",
+ "dev": true,
+ "engines": {
+ "node": ">=12.0.0"
+ }
+ },
+ "node_modules/fdir": {
+ "version": "6.5.0",
+ "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz",
+ "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==",
+ "dev": true,
+ "engines": {
+ "node": ">=12.0.0"
+ },
+ "peerDependencies": {
+ "picomatch": "^3 || ^4"
+ },
+ "peerDependenciesMeta": {
+ "picomatch": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/fflate": {
+ "version": "0.8.3",
+ "resolved": "https://registry.npmjs.org/fflate/-/fflate-0.8.3.tgz",
+ "integrity": "sha512-tbZNuJrLwGUp3zshBtdy4W+ORxZuIh8a5ilyIEQDC5rY1f3U20JMry0Ll3WBzU58EZKsEuJFXhb5gwv8CsPvgA==",
+ "dev": true
+ },
+ "node_modules/fsevents": {
+ "version": "2.3.3",
+ "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
+ "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==",
+ "dev": true,
+ "hasInstallScript": true,
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": "^8.16.0 || ^10.6.0 || >=11.0.0"
+ }
+ },
+ "node_modules/lightningcss": {
+ "version": "1.33.0",
+ "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.33.0.tgz",
+ "integrity": "sha512-WkUDrojuJs0xkgGf2udWxa3yGBRxPtxUkB79i6aCZLRgc7PM8fZe9TosfPDcvEpQZbuFASnHYmRLBLUbmLOIIA==",
+ "dev": true,
+ "dependencies": {
+ "detect-libc": "^2.0.3"
+ },
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ },
+ "optionalDependencies": {
+ "lightningcss-android-arm64": "1.33.0",
+ "lightningcss-darwin-arm64": "1.33.0",
+ "lightningcss-darwin-x64": "1.33.0",
+ "lightningcss-freebsd-x64": "1.33.0",
+ "lightningcss-linux-arm-gnueabihf": "1.33.0",
+ "lightningcss-linux-arm64-gnu": "1.33.0",
+ "lightningcss-linux-arm64-musl": "1.33.0",
+ "lightningcss-linux-x64-gnu": "1.33.0",
+ "lightningcss-linux-x64-musl": "1.33.0",
+ "lightningcss-win32-arm64-msvc": "1.33.0",
+ "lightningcss-win32-x64-msvc": "1.33.0"
+ }
+ },
+ "node_modules/lightningcss-android-arm64": {
+ "version": "1.33.0",
+ "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.33.0.tgz",
+ "integrity": "sha512-gEpRTalKdosp4Bb8qWtc2iOgE5SeIHlpS1up9bFq2wAyYhl1UdTObYiHe98zEM9SQvSoqQZ1IQD0JNpg3Ml5pg==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "optional": true,
+ "os": [
+ "android"
+ ],
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/lightningcss-darwin-arm64": {
+ "version": "1.33.0",
+ "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.33.0.tgz",
+ "integrity": "sha512-Sciaz8eenNTKn9b3t7+xr0ipTp9YxKQY4npwQ3mrRuL0BAVHBLyZxofhaKBAVtzmtRZ/zTyo0/to4B1uWG/Djg==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/lightningcss-darwin-x64": {
+ "version": "1.33.0",
+ "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.33.0.tgz",
+ "integrity": "sha512-Z5UPAxzrjlWNNyGy6i65cJzzvgJ5D3T6wMvs+gWpY9d7qRhANrxqAp6LhxIgZhWEw18RfJTGcRxjuLIBr+m8XQ==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/lightningcss-freebsd-x64": {
+ "version": "1.33.0",
+ "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.33.0.tgz",
+ "integrity": "sha512-QQM/Ti/hQajJwCY+RiWuCZ9sdtI/XQk7nDK5vC8kkdwixezOlDgvDx7+RT+QjK6FcFT4MpsuoBnHIo/O3StRRg==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "optional": true,
+ "os": [
+ "freebsd"
+ ],
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/lightningcss-linux-arm-gnueabihf": {
+ "version": "1.33.0",
+ "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.33.0.tgz",
+ "integrity": "sha512-N7FVBe6iS24MlM6R/4RBTxGhQheZGs7tiQ9U32UtF75NzP5Q7xWPRqLBCKxlRQRk3rY1jCIPLzx7WzOhuUIRLQ==",
+ "cpu": [
+ "arm"
+ ],
+ "dev": true,
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/lightningcss-linux-arm64-gnu": {
+ "version": "1.33.0",
+ "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.33.0.tgz",
+ "integrity": "sha512-j2v/itmy4HlNxlc6voKXYgBqNi0Ng2LShg4z7GufpEgs05P+2suBVyi9I6YHq5uoVFx9ETin3eCEhLVyXGQnKg==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/lightningcss-linux-arm64-musl": {
+ "version": "1.33.0",
+ "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.33.0.tgz",
+ "integrity": "sha512-yiO5ROMuYQgXbC60yjZU5CYSFZGKXL0HFATXt9mHJn1+zW55oCtMI9NfcVhYLMFDL7gV7oBPon/EmMMGg2OvtQ==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/lightningcss-linux-x64-gnu": {
+ "version": "1.33.0",
+ "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.33.0.tgz",
+ "integrity": "sha512-ar+Ju7LmcN0Jo4FpL4hpFybwNG9/3A/Br5KW2n2jyODg3MEZXaDYADdemoNS+BDNfMgKvylJLj4S5tyRActuAg==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/lightningcss-linux-x64-musl": {
+ "version": "1.33.0",
+ "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.33.0.tgz",
+ "integrity": "sha512-RYiYbkokw0trfKqqzfF55lginwEPrD3OJDfTuJzFs1MK6iFnDenaz1fqLLtX4ITG3OktJQXOeTaw1awrBAlZPw==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/lightningcss-win32-arm64-msvc": {
+ "version": "1.33.0",
+ "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.33.0.tgz",
+ "integrity": "sha512-1K+MPfLSFVpphzpdbfkhlWk6wBrTObBzS2T6db10PNOZgR9GoVsAWzwNyuhUYYbTp23j+4RrncfujZ4uAzXvwA==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/lightningcss-win32-x64-msvc": {
+ "version": "1.33.0",
+ "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.33.0.tgz",
+ "integrity": "sha512-OlEICDx/Xl0FqSp4bry8zFnCvGpig3Gl4gCquvYwHuqJKEC1+n9NgDniFvqHGmMv1ZkqDJrDqKKSykTDX+ehuA==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/magic-string": {
+ "version": "1.4.3",
+ "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-1.4.3.tgz",
+ "integrity": "sha512-z12OxmaPGE0F4xlpGdjuAUckipLpQlTAuAmyGhNoD7ODpYUzvDfvtxUbjM/jwznyP178oju/KDdyi220wNJ0RQ==",
+ "dev": true,
+ "dependencies": {
+ "@jridgewell/sourcemap-codec": "^1.6.0"
+ }
+ },
+ "node_modules/meshoptimizer": {
+ "version": "1.1.1",
+ "resolved": "https://registry.npmjs.org/meshoptimizer/-/meshoptimizer-1.1.1.tgz",
+ "integrity": "sha512-oRFNWJRDA/WTrVj7NWvqa5HqE1t9MYDj2VaWirQCzCCrAd2GHrqR/sQezCxiWATPNlKTcRaPRHPJwIRoPBAp5g==",
+ "dev": true
+ },
+ "node_modules/nanoid": {
+ "version": "3.3.20",
+ "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.20.tgz",
+ "integrity": "sha512-uKdg2G3GNCKQn9byYOpxbGqrT2fGO5KRt5J/8b3pok8rT6qxGWF6hxMyJiEYtAf+FVyYuD9hRaDqX5uPFYJ4ZQ==",
+ "dev": true,
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/ai"
+ }
+ ],
+ "bin": {
+ "nanoid": "bin/nanoid.cjs"
+ },
+ "engines": {
+ "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1"
+ }
+ },
+ "node_modules/obug": {
+ "version": "3.0.0",
+ "resolved": "https://registry.npmjs.org/obug/-/obug-3.0.0.tgz",
+ "integrity": "sha512-5vvB5+W7ePv+p3uqxi+RcW1XAzLW0/hxt3/4X4Lc4qHudzOhmBiBwOY6DRob4WnanAEGvNcLjF+KNOufrUoEQw==",
+ "dev": true,
+ "funding": [
+ "https://github.com/sponsors/sxzz",
+ "https://opencollective.com/debug"
+ ],
+ "engines": {
+ "node": ">=12.20.0"
+ }
+ },
+ "node_modules/picocolors": {
+ "version": "1.1.1",
+ "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
+ "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==",
+ "dev": true
+ },
+ "node_modules/picomatch": {
+ "version": "4.0.7",
+ "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.7.tgz",
+ "integrity": "sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA==",
+ "dev": true,
+ "engines": {
+ "node": ">=12"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/jonschlinkert"
+ }
+ },
+ "node_modules/playwright": {
+ "version": "1.64.1",
+ "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.64.1.tgz",
+ "integrity": "sha512-HCrVhHr5QQuwYoswEUGTb7gGp522yflbv/ST8nF4sFWGuLjQCt17Q/EubpGGSNKYn1tsTCPZxqHhHCfVURu7UA==",
+ "dev": true,
+ "dependencies": {
+ "playwright-core": "1.64.1"
+ },
+ "bin": {
+ "playwright": "cli.js"
+ },
+ "engines": {
+ "node": ">=20"
+ }
+ },
+ "node_modules/playwright-core": {
+ "version": "1.64.1",
+ "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.64.1.tgz",
+ "integrity": "sha512-BC4s8y6SEVA6Q1rSxZTto8hI1KUI3xXlHGZgvGqP4IqPdI/a6FX9H29IDNz7vKo9l21b6LfYosScPL6M3xxyDQ==",
+ "dev": true,
+ "bin": {
+ "playwright-core": "cli.js"
+ },
+ "engines": {
+ "node": ">=20"
+ }
+ },
+ "node_modules/postcss": {
+ "version": "8.5.31",
+ "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.31.tgz",
+ "integrity": "sha512-YUbWYCgErwnMAVkjUnYa+KlMBzGpHwiIt/DE7f0G33AuX2LH9UcTk6KgWxd3zRU+pN3u/C+Xl3EnMLM4Hea7Sg==",
+ "dev": true,
+ "funding": [
+ {
+ "type": "opencollective",
+ "url": "https://opencollective.com/postcss/"
+ },
+ {
+ "type": "tidelift",
+ "url": "https://tidelift.com/funding/github/npm/postcss"
+ },
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/ai"
+ }
+ ],
+ "dependencies": {
+ "nanoid": "^3.3.20",
+ "picocolors": "^1.1.1",
+ "source-map-js": "^1.2.2"
+ },
+ "engines": {
+ "node": "^10 || ^12 || >=14"
+ }
+ },
+ "node_modules/rolldown": {
+ "version": "1.2.15",
+ "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.2.15.tgz",
+ "integrity": "sha512-6odND+fel5zVw3I4Yc2e69gvguDnX0CAAGiNDZSq4ik5WUcaLrkD2GLuog0XcODbor31hZ5ayEMgVDno+KPV5g==",
+ "dev": true,
+ "dependencies": {
+ "@oxc-project/types": "=0.155.0",
+ "@rolldown/pluginutils": "^1.0.0"
+ },
+ "bin": {
+ "rolldown": "bin/cli.mjs"
+ },
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ },
+ "optionalDependencies": {
+ "@rolldown/binding-android-arm-eabi": "1.2.15",
+ "@rolldown/binding-android-arm64": "1.2.15",
+ "@rolldown/binding-darwin-arm64": "1.2.15",
+ "@rolldown/binding-darwin-x64": "1.2.15",
+ "@rolldown/binding-freebsd-x64": "1.2.15",
+ "@rolldown/binding-linux-arm-gnueabihf": "1.2.15",
+ "@rolldown/binding-linux-arm64-gnu": "1.2.15",
+ "@rolldown/binding-linux-arm64-musl": "1.2.15",
+ "@rolldown/binding-linux-ppc64-gnu": "1.2.15",
+ "@rolldown/binding-linux-s390x-gnu": "1.2.15",
+ "@rolldown/binding-linux-x64-gnu": "1.2.15",
+ "@rolldown/binding-linux-x64-musl": "1.2.15",
+ "@rolldown/binding-openharmony-arm64": "1.2.15",
+ "@rolldown/binding-win32-arm64-msvc": "1.2.15",
+ "@rolldown/binding-win32-x64-msvc": "1.2.15"
+ }
+ },
+ "node_modules/source-map-js": {
+ "version": "1.2.2",
+ "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.2.tgz",
+ "integrity": "sha512-KGj/8Y43x35aZVDtt+J4mK1hoLGHULMYfSkODJNQjNDC3oW1PqPoxMwo0pLUsWM/UEGzON/NxeHywEfNXNP3Vw==",
+ "dev": true,
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/std-env": {
+ "version": "4.3.0",
+ "resolved": "https://registry.npmjs.org/std-env/-/std-env-4.3.0.tgz",
+ "integrity": "sha512-OtU/EgQ1kIm5KwqQpBC6ZEMXrZRui11w8zgfTWp8cdO9B8OaPsbA8bTHO2P+HNo1VlUTGMVBwPhydu6poeXiag==",
+ "dev": true
+ },
+ "node_modules/three": {
+ "version": "0.186.1",
+ "resolved": "https://registry.npmjs.org/three/-/three-0.186.1.tgz",
+ "integrity": "sha512-blFeqb49wRCSGUGj7gtpfnSGHy2lwDk94RhUmS1c/hTby70kvChbWpkJ4Pm1390LqzzvTmzgXKHPEafJwCb8jA=="
+ },
+ "node_modules/tinybench": {
+ "version": "6.2.2",
+ "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-6.2.2.tgz",
+ "integrity": "sha512-nIZ2N2dFpjBsg2hgitKu7mA+FvsIJuGJOwysRngmVRRmWgR/UyPjwJj8c/fT/dhm8EkHnF5EOogRf+0Z2qrimA==",
+ "dev": true,
+ "engines": {
+ "node": ">=20.0.0"
+ }
+ },
+ "node_modules/tinyexec": {
+ "version": "1.3.1",
+ "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.3.1.tgz",
+ "integrity": "sha512-GCvB3aoys96IuDFBMcTB46JOR6mdMtAToqwiW8JlWhsoh1mhHi/xn9ss/Dg7N555GiJyEt2qzoG/NHCwM6h1EA==",
+ "dev": true,
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/tinyglobby": {
+ "version": "0.2.17",
+ "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz",
+ "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==",
+ "dev": true,
+ "dependencies": {
+ "fdir": "^6.5.0",
+ "picomatch": "^4.0.4"
+ },
+ "engines": {
+ "node": ">=12.0.0"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/SuperchupuDev"
+ }
+ },
+ "node_modules/typescript": {
+ "version": "7.0.2",
+ "resolved": "https://registry.npmjs.org/typescript/-/typescript-7.0.2.tgz",
+ "integrity": "sha512-8FYau96o3NKOhbjKi/qNvG/W5jhzxkbdm5sj9AbZ/5T5sWqn3hJgLfGx27sRKZWTvyzCP8dLRBTf5tBTSRVUNA==",
+ "dev": true,
+ "bin": {
+ "tsc": "bin/tsc"
+ },
+ "engines": {
+ "node": ">=16.20.0"
+ },
+ "optionalDependencies": {
+ "@typescript/typescript-aix-ppc64": "7.0.2",
+ "@typescript/typescript-darwin-arm64": "7.0.2",
+ "@typescript/typescript-darwin-x64": "7.0.2",
+ "@typescript/typescript-freebsd-arm64": "7.0.2",
+ "@typescript/typescript-freebsd-x64": "7.0.2",
+ "@typescript/typescript-linux-arm": "7.0.2",
+ "@typescript/typescript-linux-arm64": "7.0.2",
+ "@typescript/typescript-linux-loong64": "7.0.2",
+ "@typescript/typescript-linux-mips64el": "7.0.2",
+ "@typescript/typescript-linux-ppc64": "7.0.2",
+ "@typescript/typescript-linux-riscv64": "7.0.2",
+ "@typescript/typescript-linux-s390x": "7.0.2",
+ "@typescript/typescript-linux-x64": "7.0.2",
+ "@typescript/typescript-netbsd-arm64": "7.0.2",
+ "@typescript/typescript-netbsd-x64": "7.0.2",
+ "@typescript/typescript-openbsd-arm64": "7.0.2",
+ "@typescript/typescript-openbsd-x64": "7.0.2",
+ "@typescript/typescript-sunos-x64": "7.0.2",
+ "@typescript/typescript-win32-arm64": "7.0.2",
+ "@typescript/typescript-win32-x64": "7.0.2"
+ }
+ },
+ "node_modules/vite": {
+ "version": "8.3.5",
+ "resolved": "https://registry.npmjs.org/vite/-/vite-8.3.5.tgz",
+ "integrity": "sha512-z1YpjieJ9g0ebTmQpLBv/oq9E3F9EIlOe+7LahW8SrE3LTiG3udBRUXQOlYXP9b+IC2q2phHeVxj9xGlw34OEQ==",
+ "dev": true,
+ "dependencies": {
+ "lightningcss": "^1.33.0",
+ "picomatch": "^4.0.7",
+ "postcss": "^8.5.30",
+ "rolldown": "~1.2.14",
+ "tinyglobby": "^0.2.17"
+ },
+ "bin": {
+ "vite": "bin/vite.js"
+ },
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ },
+ "funding": {
+ "url": "https://github.com/vitejs/vite?sponsor=1"
+ },
+ "optionalDependencies": {
+ "fsevents": "~2.3.3"
+ },
+ "peerDependencies": {
+ "@types/node": "^20.19.0 || >=22.12.0",
+ "@vitejs/devtools": "^0.7.1 || ^0.8.0",
+ "esbuild": "^0.27.0 || ^0.28.0",
+ "jiti": ">=1.21.0",
+ "less": "^4.0.0",
+ "sass": "^1.70.0",
+ "sass-embedded": "^1.70.0",
+ "stylus": ">=0.54.8",
+ "sugarss": "^5.0.0",
+ "terser": "^5.16.0",
+ "tsx": "^4.8.1",
+ "yaml": "^2.4.2"
+ },
+ "peerDependenciesMeta": {
+ "@types/node": {
+ "optional": true
+ },
+ "@vitejs/devtools": {
+ "optional": true
+ },
+ "esbuild": {
+ "optional": true
+ },
+ "jiti": {
+ "optional": true
+ },
+ "less": {
+ "optional": true
+ },
+ "sass": {
+ "optional": true
+ },
+ "sass-embedded": {
+ "optional": true
+ },
+ "stylus": {
+ "optional": true
+ },
+ "sugarss": {
+ "optional": true
+ },
+ "terser": {
+ "optional": true
+ },
+ "tsx": {
+ "optional": true
+ },
+ "yaml": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/vitest": {
+ "version": "5.0.5",
+ "resolved": "https://registry.npmjs.org/vitest/-/vitest-5.0.5.tgz",
+ "integrity": "sha512-cPODOMF8YnkiiYtDdHo6n4wWmBrOBBbOIYH2d33M4l3CdNn72h+qMmO6uXffUN6A5AZFXNND/d+krS9mgkXRfg==",
+ "dev": true,
+ "dependencies": {
+ "@types/chai": "^5.2.2",
+ "@vitest/mocker": "5.0.5",
+ "chai": "^6.3.0",
+ "es-module-lexer": "^2.3.2",
+ "expect-type": "^1.4.0",
+ "magic-string": "^1.4.3",
+ "obug": "^3.0.0",
+ "picomatch": "^4.0.7",
+ "std-env": "^4.3.0",
+ "tinybench": "^6.2.2",
+ "tinyexec": "^1.3.1",
+ "tinyglobby": "^0.2.17",
+ "why-is-node-running": "3.2.1"
+ },
+ "bin": {
+ "vitest": "vitest.mjs"
+ },
+ "engines": {
+ "node": "^22.12.0 || ^24.0.0 || >=26.0.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/vitest"
+ },
+ "peerDependencies": {
+ "@edge-runtime/vm": "*",
+ "@opentelemetry/api": "^1.9.0",
+ "@types/node": "^22.0.0 || >=24.0.0",
+ "@vitest/browser-playwright": "5.0.5",
+ "@vitest/browser-preview": "5.0.5",
+ "@vitest/browser-webdriverio": "^5.0.0-beta.5 || >=5.0.0",
+ "@vitest/coverage-istanbul": "5.0.5",
+ "@vitest/coverage-v8": "5.0.5",
+ "@vitest/ui": "5.0.5",
+ "happy-dom": "*",
+ "jsdom": "*",
+ "vite": "^6.4.0 || ^7.0.0 || ^8.0.0"
+ },
+ "peerDependenciesMeta": {
+ "@edge-runtime/vm": {
+ "optional": true
+ },
+ "@opentelemetry/api": {
+ "optional": true
+ },
+ "@types/node": {
+ "optional": true
+ },
+ "@vitest/browser-playwright": {
+ "optional": true
+ },
+ "@vitest/browser-preview": {
+ "optional": true
+ },
+ "@vitest/browser-webdriverio": {
+ "optional": true
+ },
+ "@vitest/coverage-istanbul": {
+ "optional": true
+ },
+ "@vitest/coverage-v8": {
+ "optional": true
+ },
+ "@vitest/ui": {
+ "optional": true
+ },
+ "happy-dom": {
+ "optional": true
+ },
+ "jsdom": {
+ "optional": true
+ },
+ "vite": {
+ "optional": false
+ }
+ }
+ },
+ "node_modules/why-is-node-running": {
+ "version": "3.2.1",
+ "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-3.2.1.tgz",
+ "integrity": "sha512-Tb2FUhB4vUsGQlfSquQLYkApkuPAFQXGFzxWKHHumVz2dK+X1RUm/HnID4+TfIGYJ1kTcwOaCk/buYCEJr6YjQ==",
+ "dev": true,
+ "bin": {
+ "why-is-node-running": "cli.js"
+ },
+ "engines": {
+ "node": ">=20.11"
+ }
+ }
+ }
+}
diff --git a/package.json b/package.json
new file mode 100644
index 0000000..127a763
--- /dev/null
+++ b/package.json
@@ -0,0 +1,27 @@
+{
+ "name": "electrophysics-sandbox",
+ "private": true,
+ "version": "0.1.0",
+ "type": "module",
+ "engines": {
+ "node": ">=22.12.0"
+ },
+ "scripts": {
+ "dev": "vite --host 127.0.0.1",
+ "build": "tsc --noEmit && vite build",
+ "typecheck": "tsc --noEmit",
+ "preview": "vite preview --host 127.0.0.1",
+ "test": "vitest run",
+ "test:e2e": "playwright test"
+ },
+ "dependencies": {
+ "three": "^0.186.1"
+ },
+ "devDependencies": {
+ "@playwright/test": "^1.64.1",
+ "@types/three": "^0.186.0",
+ "typescript": "^7.0.2",
+ "vite": "^8.3.5",
+ "vitest": "^5.0.5"
+ }
+}
diff --git a/playwright.config.ts b/playwright.config.ts
new file mode 100644
index 0000000..d1cd1b3
--- /dev/null
+++ b/playwright.config.ts
@@ -0,0 +1,14 @@
+import { defineConfig } from '@playwright/test';
+
+export default defineConfig({
+ testDir: './tests',
+ fullyParallel: false,
+ workers: 1,
+ use: {
+ baseURL: 'http://127.0.0.1:5173',
+ viewport: { width: 1440, height: 1000 },
+ launchOptions: { args: ['--no-sandbox', '--use-angle=swiftshader', '--enable-unsafe-swiftshader'] },
+ screenshot: 'only-on-failure',
+ },
+ webServer: { command: 'npm run dev -- --port 5173 --strictPort', url: 'http://127.0.0.1:5173', reuseExistingServer: true },
+});
diff --git a/src/experiments.ts b/src/experiments.ts
new file mode 100644
index 0000000..df6835e
--- /dev/null
+++ b/src/experiments.ts
@@ -0,0 +1,182 @@
+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 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 }[] }
+export interface Measurement { label: string; value: string; unit: string }
+export interface Experiment {
+ id: ExperimentId;
+ title: string;
+ shortTitle: string;
+ description: string;
+ controls: (Slider | Choice)[];
+ equations: string[];
+ explanation: string;
+ challenge: string;
+ limits: string;
+ sceneNote: string;
+ legend: [string, string][];
+ source: { title: string; url: string };
+ measure: (p: Parameters) => Measurement[];
+}
+export const number = (p: Parameters, key: string): number => Number(p[key]);
+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 })) });
+export function format(value: number, digits = 2): string {
+ if (!Number.isFinite(value)) return 'Undefined';
+ if (value === 0) return '0';
+ if (Math.abs(value) < 0.01 || Math.abs(value) >= 1e5) return value.toExponential(2);
+ return value.toLocaleString('en', { maximumFractionDigits: digits });
+}
+const metric = (label: string, value: number, unit: string): Measurement => ({ label, value: format(value), unit });
+const textbook = (page: string) => ({ title: 'OpenStax · University Physics', url: `https://openstax.org/books/university-physics-volume-2/pages/${page}` });
+export function chargePair(p: Parameters): Charge[] {
+ const half = number(p, 'separation') / 2;
+ return [
+ { x: -half, y: 0, z: 0, coulombs: number(p, 'charge') * 1e-9 },
+ { x: half, y: 0, z: 0, coulombs: number(p, 'charge') * 1e-9 * (p.configuration === 'like' ? 1 : -1) },
+ ];
+}
+export function plateModel(p: Parameters) {
+ return capacitor({ area: number(p, 'area') * 1e-4, gap: number(p, 'gap') * 1e-3, voltage: number(p, 'voltage'), relativePermittivity: p.permittivity === undefined ? 1 : number(p, 'permittivity'), isolated: p.connection === 'isolated' });
+}
+export function wireModel(p: Parameters) {
+ return conductor({ voltage: number(p, 'voltage'), area: number(p, 'area') * 1e-6, length: number(p, 'length'), resistivity: 1.68e-8, carrierDensity: 8.5e28 });
+}
+export function carrierModel(p: Parameters) {
+ return semiconductor({ intrinsicDensity: 1e16, electronMobility: 0.135, holeMobility: 0.048, netDoping: p.doping === 'intrinsic' ? 0 : 10 ** number(p, 'concentration') * (p.doping === 'p' ? -1 : 1) });
+}
+const pairControls = [slider('charge', 'Source charge magnitude', 0.2, 5, 0.1, 2, 'nC'), slider('separation', 'Charge separation', 1, 6, 0.1, 3, 'm'), choice('configuration', 'Charge signs', 'opposite', [['opposite', 'Opposite (+ / −)'], ['like', 'Like (+ / +)']])];
+const plateControls = [slider('area', 'Plate area', 25, 225, 5, 100, 'cm²'), slider('gap', 'Plate separation', 0.5, 5, 0.1, 1, 'mm'), slider('voltage', 'Charging voltage', 1, 20, 1, 10, 'V')];
+
+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²'],
+ 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',
+ legend: [['#ff967b', 'Positive charge'], ['#73d7ff', 'Negative charge'], ['#91e2cb', 'Electric field'], ['#f5d58b', 'Force']],
+ source: textbook('5-3-coulombs-law'),
+ measure: p => {
+ const charges = chargePair(p);
+ const e = electricField({ x: 0, y: 0, z: 0 }, charges);
+ const f = forceOn(charges[1], [charges[0]]);
+ return [metric('|E| at origin', Math.hypot(e.x, e.y, e.z), 'N/C'), metric('|Force on right charge|', Math.hypot(f.x, f.y, f.z) * 1e9, 'nN'), { label: 'Interaction', value: p.configuration === 'like' ? 'Repulsive' : 'Attractive', unit: '' }];
+ },
+ },
+ {
+ 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.',
+ 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',
+ legend: [['#91e2cb', 'Outward flux'], ['#ff967b', 'Inward flux'], ['#73d7ff', 'Gaussian surface']],
+ source: textbook('6-2-explaining-gausss-law'),
+ measure: p => {
+ const q = { x: number(p, 'position'), y: 0, z: 0, coulombs: number(p, 'charge') * 1e-9 };
+ const flux = gaussianFlux([q], number(p, 'radius'));
+ return [metric('Net electric flux', flux, 'N·m²/C'), metric('Enclosed charge', Number.isNaN(flux) ? NaN : Math.abs(q.x) < number(p, 'radius') ? q.coulombs * 1e9 : 0, 'nC'), { label: 'Source location', value: Number.isNaN(flux) ? 'On surface' : Math.abs(q.x) < number(p, 'radius') ? 'Inside' : 'Outside', unit: '' }];
+ },
+ },
+ {
+ 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)'],
+ 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',
+ legend: [['#ff967b', 'Fixed +3 nC'], ['#73d7ff', 'Negative charge'], ['#91e2cb', 'Energy curve'], ['#f5d58b', 'Current energy']],
+ source: textbook('7-1-electric-potential-energy'),
+ measure: p => {
+ const q = number(p, 'testCharge') * 1e-9;
+ const r = number(p, 'distance');
+ const u = pairEnergy(3e-9, q, r);
+ return [metric('Interaction energy', u * 1e9, 'nJ'), metric('Work by field, from 6 m', (pairEnergy(3e-9, q, 6) - u) * 1e9, 'nJ'), metric('|Force on moving charge|', Math.abs(u / r) * 1e9, 'nN')];
+ },
+ },
+ {
+ 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',
+ legend: [['#ff967b', 'Positive potential'], ['#73d7ff', 'Negative potential'], ['#f5d58b', 'Probe']],
+ source: textbook('7-4-determining-field-from-potential'),
+ measure: p => {
+ const point = { x: number(p, 'probe'), y: 0, z: 1 };
+ const charges = chargePair(p);
+ const field = electricField(point, charges);
+ return [metric('Potential at probe', potential(point, charges), 'V'), metric('Ex at probe', field.x, 'V/m'), metric('|E| at probe', Math.hypot(field.x, field.y, field.z), 'N/C')];
+ },
+ },
+ {
+ 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²'],
+ 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.',
+ sceneNote: 'Positive plate → field → negative plate · gap enlarged 50×',
+ legend: [['#ff967b', 'Positive plate'], ['#73d7ff', 'Negative plate'], ['#91e2cb', 'Uniform E']],
+ source: textbook('8-1-capacitors-and-capacitance'),
+ measure: p => { const c = plateModel(p); return [metric('Capacitance', c.capacitance * 1e12, 'pF'), metric('Stored free charge |Q|', c.charge * 1e9, 'nC'), metric('Electric field', c.field / 1000, 'kV/m'), metric('Stored energy', c.energy * 1e9, 'nJ')]; },
+ },
+ {
+ 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',
+ 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²')]; },
+ },
+ {
+ 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)'],
+ 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.',
+ 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'),
+ measure: p => { const w = wireModel(p); return [metric('Resistance', w.resistance * 1000, 'mΩ'), metric('Conventional current', w.current, 'A'), metric('Electron drift, x', w.electronDrift * 1000, 'mm/s'), metric('Dissipated power', w.power, 'W')]; },
+ },
+ {
+ id: 'semiconductor', title: 'Semiconducting materials', shortTitle: 'Semiconductors',
+ description: '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',
+ 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')]; },
+ },
+];
+
+export function defaults(experiment: Experiment): Parameters {
+ return Object.fromEntries(experiment.controls.map(control => [control.id, control.value]));
+}
diff --git a/src/main.ts b/src/main.ts
new file mode 100644
index 0000000..172f316
--- /dev/null
+++ b/src/main.ts
@@ -0,0 +1,137 @@
+import './style.css';
+import { defaults, experiments, format, type Experiment, type Parameters } from './experiments';
+import { createLab } from './scene/lab';
+
+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>
+ <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>
+ </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>
+ <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>
+ <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>
+ </aside>
+ <div class="workbench">
+ <section id="viewport" aria-label="3D workbench">
+ <div class="scene-toolbar"><span class="view-tag">3D VIEW</span><div><button type="button" id="camera-reset">Reset camera</button><button type="button" id="pause" aria-pressed="false">Pause animation</button></div></div>
+ <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 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>
+ </div>
+ </main>`;
+
+const lab = createLab(document.querySelector('#viewport')!);
+const settings = new Map(experiments.map(experiment => [experiment.id, defaults(experiment)]));
+const byId = (id: string) => document.getElementById(id)!;
+let active = experiments[0];
+let paused = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
+function updatePause() {
+ byId('pause').textContent = paused ? 'Resume animation' : 'Pause animation';
+ byId('pause').setAttribute('aria-pressed', String(paused));
+ lab.setPaused(paused);
+}
+updatePause();
+
+function parameters(): Parameters { return settings.get(active.id)!; }
+function update() {
+ const p = parameters();
+ for (const control of active.controls) {
+ if (control.kind !== 'range') continue;
+ byId(`output-${control.id}`).textContent = `${format(Number(p[control.id]), 3)}${control.unit ? ` ${control.unit}` : ''}`;
+ }
+ if (active.id === 'semiconductor') {
+ (byId('parameter-concentration') as HTMLInputElement).disabled = p.doping === 'intrinsic';
+ }
+ 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('');
+ 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`;
+ 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;
+ byId('equations').replaceChildren(...experiment.equations.map(equation => {
+ const div = document.createElement('p');
+ div.textContent = equation;
+ return div;
+ }));
+ const source = byId('source') as HTMLAnchorElement;
+ source.textContent = `${experiment.source.title} ↗`;
+ source.href = experiment.source.url;
+ byId('legend').innerHTML = experiment.legend.map(([colour, text]) => `<span><i style="background:${colour}"></i>${text}</span>`).join('');
+ byId('lesson').setAttribute('aria-labelledby', `tab-${experiment.id}`);
+ document.querySelectorAll<HTMLButtonElement>('[role="tab"]').forEach(tab => {
+ const selected = tab.dataset.experiment === experiment.id;
+ tab.setAttribute('aria-selected', String(selected));
+ tab.tabIndex = selected ? 0 : -1;
+ });
+ const p = parameters();
+ byId('parameters').innerHTML = experiment.controls.map(control => {
+ const id = `parameter-${control.id}`;
+ 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();
+ update();
+}
+function fromHash() {
+ const id = window.location.hash.slice(1);
+ show(experiments.find(experiment => experiment.id === id) ?? experiments[0]);
+}
+const onHashChange = () => fromHash();
+window.addEventListener('hashchange', onHashChange);
+fromHash();
+byId('parameters').addEventListener('input', event => {
+ const input = event.target as HTMLInputElement | HTMLSelectElement;
+ if (!input.name) return;
+ parameters()[input.name] = input.type === 'range' ? Number(input.value) : input.value;
+ update();
+});
+byId('reset').addEventListener('click', () => {
+ settings.set(active.id, defaults(active));
+ show(active);
+});
+byId('camera-reset').addEventListener('click', () => lab.resetCamera());
+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 => {
+ let next: number;
+ if (event.key === 'ArrowRight') next = (index + 1) % tabs.length;
+ else if (event.key === 'ArrowLeft') next = (index - 1 + tabs.length) % tabs.length;
+ else if (event.key === 'Home') next = 0;
+ else if (event.key === 'End') next = tabs.length - 1;
+ else return;
+ event.preventDefault();
+ tabs[next].focus();
+ tabs[next].click();
+ });
+});
+if (import.meta.hot) import.meta.hot.dispose(() => {
+ window.removeEventListener('hashchange', onHashChange);
+ lab.dispose();
+});
diff --git a/src/physics/electricField.test.ts b/src/physics/electricField.test.ts
new file mode 100644
index 0000000..2e01309
--- /dev/null
+++ b/src/physics/electricField.test.ts
@@ -0,0 +1,21 @@
+import { describe, expect, it } from 'vitest';
+import { electricField, COULOMB_CONSTANT } from './electricField';
+
+describe('Coulomb electric field', () => {
+ it('points away from a positive charge with inverse-square magnitude', () => {
+ const field = electricField({ x: 2, y: 0, z: 0 }, [{ x: 0, y: 0, z: 0, coulombs: 1e-9 }]);
+ expect(field.x).toBeCloseTo(COULOMB_CONSTANT * 1e-9 / 4);
+ expect(field.y).toBe(0);
+ expect(field.z).toBe(0);
+ });
+ it('superposes opposite charges in the same direction at the midpoint', () => {
+ const field = electricField({ x: 0, y: 0, z: 0 }, [
+ { x: -1, y: 0, z: 0, coulombs: 1e-9 },
+ { x: 1, y: 0, z: 0, coulombs: -1e-9 },
+ ]);
+ expect(field.x).toBeCloseTo(2 * COULOMB_CONSTANT * 1e-9);
+ });
+ it('omits singular samples within the visualization exclusion radius', () => {
+ expect(electricField({ x: 0, y: 0, z: 0 }, [{ x: 0, y: 0, z: 0, coulombs: 1e-9 }])).toEqual({ x: 0, y: 0, z: 0 });
+ });
+});
diff --git a/src/physics/electricField.ts b/src/physics/electricField.ts
new file mode 100644
index 0000000..26975e3
--- /dev/null
+++ b/src/physics/electricField.ts
@@ -0,0 +1,22 @@
+export interface Point { x: number; y: number; z: number }
+export interface Charge extends Point { coulombs: number }
+export const COULOMB_CONSTANT = 8.9875517923e9;
+
+/** SI units: positions in metres, charges in coulombs, output in N/C.
+ * Samples inside 0.18 m of a charge are omitted to avoid visual singularities.
+ */
+export function electricField(point: Point, charges: readonly Charge[]): Point {
+ const field = { x: 0, y: 0, z: 0 };
+ for (const charge of charges) {
+ const dx = point.x - charge.x;
+ const dy = point.y - charge.y;
+ const dz = point.z - charge.z;
+ const r2 = dx * dx + dy * dy + dz * dz;
+ if (r2 < 0.18 ** 2) continue;
+ const factor = COULOMB_CONSTANT * charge.coulombs / (r2 * Math.sqrt(r2));
+ field.x += factor * dx;
+ field.y += factor * dy;
+ field.z += factor * dz;
+ }
+ return field;
+}
diff --git a/src/physics/models.test.ts b/src/physics/models.test.ts
new file mode 100644
index 0000000..3ff6487
--- /dev/null
+++ b/src/physics/models.test.ts
@@ -0,0 +1,108 @@
+import { describe, expect, it } from 'vitest';
+import { electricField } from './electricField';
+import { potential, forceOn, pairEnergy, gaussianFlux, capacitor, conductor, semiconductor } from './models';
+
+// Fixtures use hand-calculated SI results, independent of model implementations.
+describe('charge, potential and energy', () => {
+ const source = [{ x: 0, y: 0, z: 0, coulombs: 2e-9 }];
+ it('gives scalar potential tending to zero at infinity', () => {
+ expect(potential({ x: 2, y: 0, z: 0 }, source)).toBeCloseTo(8.98755, 4);
+ expect(potential({ x: 1e9, y: 0, z: 0 }, source)).toBeCloseTo(0, 6);
+ });
+ it('makes the field the negative spatial gradient of potential', () => {
+ const h = 1e-4;
+ const numerical = -(potential({ x: 2 + h, y: 0, z: 0 }, source) - potential({ x: 2 - h, y: 0, z: 0 }, source)) / (2 * h);
+ expect(electricField({ x: 2, y: 0, z: 0 }, source).x).toBeCloseTo(numerical, 5);
+ });
+ it('reverses force for a negative test charge', () => {
+ expect(forceOn({ x: 2, y: 0, z: 0, coulombs: -3e-9 }, source).x).toBeCloseTo(-13.48132768845e-9, 16);
+ });
+ it('gives negative interaction energy for opposite charges', () => {
+ expect(pairEnergy(2e-9, -3e-9, 2)).toBeCloseTo(-26.9626553769e-9, 16);
+ });
+ it('marks potential at a charge as undefined instead of inventing a finite value', () => {
+ expect(potential({ x: 0, y: 0, z: 0 }, source)).toBeNaN();
+ expect(() => pairEnergy(1, 1, 0)).toThrow(RangeError);
+ });
+});
+
+describe('Gauss law', () => {
+ it('counts only enclosed charge, including its sign', () => {
+ const charges = [{ x: 0, y: 0, z: 0, coulombs: -2e-9 }, { x: 3, y: 0, z: 0, coulombs: 5e-9 }];
+ expect(gaussianFlux(charges, 2)).toBeCloseTo(-225.8818, 3);
+ expect(gaussianFlux(charges, 4)).toBeCloseTo(338.8227, 3);
+ });
+ it('does not depend on surface size when enclosed charge is unchanged', () => {
+ const charges = [{ x: 0, y: 0, z: 0, coulombs: 1e-9 }];
+ expect(gaussianFlux(charges, 1)).toBe(gaussianFlux(charges, 2));
+ });
+ it('marks charge on the Gaussian surface as undefined', () => {
+ expect(gaussianFlux([{ x: 2, y: 0, z: 0, coulombs: 1e-9 }], 2)).toBeNaN();
+ });
+});
+
+describe('parallel plate capacitor', () => {
+ it('converts physical geometry into C, Q, E and stored energy', () => {
+ const c = capacitor({ area: 0.01, gap: 0.001, relativePermittivity: 1, voltage: 10 });
+ expect(c.capacitance).toBeCloseTo(8.8541878128e-11, 18);
+ expect(c.charge).toBeCloseTo(8.8541878128e-10, 18);
+ expect(c.field).toBe(10000);
+ expect(c.energy).toBeCloseTo(4.4270939064e-9, 17);
+ });
+ it('increases free charge at fixed voltage when dielectric is inserted', () => {
+ const c = capacitor({ area: 0.01, gap: 0.001, relativePermittivity: 4, voltage: 10 });
+ expect(c.field).toBe(10000);
+ expect(c.charge).toBeCloseTo(3.54167512512e-9, 17);
+ expect(c.polarization).toBeCloseTo(2.65625634384e-7, 15);
+ });
+ it('reduces voltage and energy at fixed free charge', () => {
+ const c = capacitor({ area: 0.01, gap: 0.001, relativePermittivity: 4, voltage: 10, isolated: true });
+ expect(c.charge).toBeCloseTo(8.8541878128e-10, 18);
+ expect(c.voltage).toBeCloseTo(2.5);
+ expect(c.field).toBeCloseTo(2500);
+ expect(c.energy).toBeCloseTo(1.1067734766e-9, 17);
+ });
+ it('rejects impossible geometry', () => {
+ expect(() => capacitor({ area: 0.01, gap: 0, relativePermittivity: 1, voltage: 10 })).toThrow(RangeError);
+ });
+});
+
+describe('current and drift', () => {
+ it('follows Ohm law and sends electrons opposite conventional current', () => {
+ const wire = conductor({ voltage: 2, resistivity: 1.68e-8, length: 1, area: 1e-6, carrierDensity: 8.5e28 });
+ expect(wire.resistance).toBeCloseTo(0.0168, 6);
+ expect(wire.current).toBeCloseTo(119.047619, 5);
+ expect(wire.electronDrift).toBeCloseTo(-0.00874160935, 10);
+ expect(wire.power).toBeCloseTo(238.095238, 5);
+ });
+ it('reverses current but keeps dissipated power positive with reversed voltage', () => {
+ const wire = conductor({ voltage: -2, resistivity: 1.68e-8, length: 1, area: 1e-6, carrierDensity: 8.5e28 });
+ expect(wire.current).toBeLessThan(0);
+ expect(wire.electronDrift).toBeGreaterThan(0);
+ expect(wire.power).toBeGreaterThan(0);
+ });
+});
+
+describe('semiconductor equilibrium at 300 K', () => {
+ it('has equal intrinsic electrons and holes', () => {
+ const s = semiconductor({ intrinsicDensity: 1e16, netDoping: 0, electronMobility: 0.135, holeMobility: 0.048 });
+ expect(s.electrons).toBe(1e16);
+ expect(s.holes).toBe(1e16);
+ expect(s.fermiShift).toBe(0);
+ expect(s.conductivity).toBeCloseTo(0.0002931983, 9);
+ });
+ it('conserves mass action and charge neutrality even at heavy donor doping', () => {
+ const s = semiconductor({ intrinsicDensity: 1e16, netDoping: 1e22, electronMobility: 0.135, holeMobility: 0.048 });
+ expect(s.electrons / 1e22).toBeCloseTo(1, 10);
+ expect(s.holes).toBeCloseTo(1e10, 0);
+ expect(s.electrons * s.holes / 1e32).toBeCloseTo(1, 12);
+ expect((s.electrons - s.holes) / 1e22).toBeCloseTo(1, 12);
+ expect(s.fermiShift).toBeCloseTo(0.3571586, 5);
+ });
+ it('makes holes majority carriers for acceptor doping', () => {
+ const s = semiconductor({ intrinsicDensity: 1e16, netDoping: -1e22, electronMobility: 0.135, holeMobility: 0.048 });
+ expect(s.holes / 1e22).toBeCloseTo(1, 10);
+ expect(s.electrons).toBeCloseTo(1e10, 0);
+ expect(s.fermiShift).toBeLessThan(0);
+ });
+});
diff --git a/src/physics/models.ts b/src/physics/models.ts
new file mode 100644
index 0000000..8fb121f
--- /dev/null
+++ b/src/physics/models.ts
@@ -0,0 +1,112 @@
+import { COULOMB_CONSTANT, electricField, type Charge, type Point } from './electricField';
+
+export const VACUUM_PERMITTIVITY = 8.8541878128e-12;
+export const ELEMENTARY_CHARGE = 1.602176634e-19;
+const THERMAL_ENERGY_EV = 8.617333262e-5 * 300;
+
+function positive(...values: number[]) {
+ if (values.some(value => !Number.isFinite(value) || value <= 0)) {
+ throw new RangeError('Physical dimensions and material constants must be finite and positive.');
+ }
+}
+
+/** Vacuum potential in volts, referenced to infinity. Undefined inside the
+ * same 0.18 m visualization exclusion used by electricField. */
+export function potential(point: Point, charges: readonly Charge[]): number {
+ let volts = 0;
+ for (const charge of charges) {
+ if (charge.coulombs === 0) continue;
+ const r = Math.hypot(point.x - charge.x, point.y - charge.y, point.z - charge.z);
+ if (r < 0.18) return NaN;
+ volts += COULOMB_CONSTANT * charge.coulombs / r;
+ }
+ return volts;
+}
+
+/** Force on a test charge, excluding that charge from the supplied sources. */
+export function forceOn(test: Charge, sources: readonly Charge[]): Point {
+ const field = electricField(test, sources);
+ return { x: field.x * test.coulombs, y: field.y * test.coulombs, z: field.z * test.coulombs };
+}
+
+export function pairEnergy(q1: number, q2: number, separation: number): number {
+ positive(separation);
+ return COULOMB_CONSTANT * q1 * q2 / separation;
+}
+
+/** Exact signed flux for an origin-centred spherical Gaussian surface.
+ * A point charge exactly on the surface makes the integral undefined. */
+export function gaussianFlux(charges: readonly Charge[], radius: number): number {
+ positive(radius);
+ let enclosed = 0;
+ for (const charge of charges) {
+ if (charge.coulombs === 0) continue;
+ const r = Math.hypot(charge.x, charge.y, charge.z);
+ if (Math.abs(r - radius) < 1e-9) return NaN;
+ if (r < radius) enclosed += charge.coulombs;
+ }
+ return enclosed / VACUUM_PERMITTIVITY;
+}
+
+export interface CapacitorParameters {
+ area: number;
+ gap: number;
+ relativePermittivity: number;
+ voltage: number;
+ /** If isolated, voltage means the initial vacuum charging voltage. */
+ isolated?: boolean;
+}
+export function capacitor(p: CapacitorParameters) {
+ positive(p.area, p.gap, p.relativePermittivity);
+ const vacuumCapacitance = VACUUM_PERMITTIVITY * p.area / p.gap;
+ const capacitance = vacuumCapacitance * p.relativePermittivity;
+ const voltage = p.isolated ? p.voltage / p.relativePermittivity : p.voltage;
+ const charge = capacitance * voltage;
+ const field = voltage / p.gap;
+ return {
+ capacitance, voltage, charge, field,
+ energy: 0.5 * capacitance * voltage ** 2,
+ polarization: VACUUM_PERMITTIVITY * (p.relativePermittivity - 1) * field,
+ };
+}
+
+export interface ConductorParameters {
+ voltage: number;
+ resistivity: number;
+ length: number;
+ area: number;
+ carrierDensity: number;
+}
+export function conductor(p: ConductorParameters) {
+ positive(p.resistivity, p.length, p.area, p.carrierDensity);
+ const resistance = p.resistivity * p.length / p.area;
+ const current = p.voltage / resistance;
+ return {
+ resistance, current,
+ power: p.voltage * current,
+ density: current / p.area,
+ electronDrift: -current / (p.carrierDensity * ELEMENTARY_CHARGE * p.area),
+ };
+}
+
+export interface SemiconductorParameters {
+ intrinsicDensity: number;
+ /** Fully ionized donors minus acceptors, in m^-3. */
+ netDoping: number;
+ electronMobility: number;
+ holeMobility: number;
+}
+/** Non-degenerate thermal equilibrium at 300 K. Solving for the minority
+ * through mass action avoids catastrophic subtraction at large doping. */
+export function semiconductor(p: SemiconductorParameters) {
+ positive(p.intrinsicDensity, p.electronMobility, p.holeMobility);
+ const majority = (Math.abs(p.netDoping) + Math.hypot(p.netDoping, 2 * p.intrinsicDensity)) / 2;
+ const minority = p.intrinsicDensity ** 2 / majority;
+ const electrons = p.netDoping >= 0 ? majority : minority;
+ const holes = p.netDoping >= 0 ? minority : majority;
+ return {
+ electrons, holes,
+ fermiShift: THERMAL_ENERGY_EV * Math.log(electrons / p.intrinsicDensity),
+ conductivity: ELEMENTARY_CHARGE * (electrons * p.electronMobility + holes * p.holeMobility),
+ };
+}
diff --git a/src/scene/experiments.ts b/src/scene/experiments.ts
new file mode 100644
index 0000000..67d0322
--- /dev/null
+++ b/src/scene/experiments.ts
@@ -0,0 +1,245 @@
+import * as THREE from 'three';
+import { chargePair, carrierModel, number, plateModel, wireModel, type ExperimentId, type Parameters } from '../experiments';
+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';
+
+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);
+}
+function compressedFieldArrow(group: THREE.Group, position: Point, direction: Point, colour = colours.field) {
+ const strength = Math.hypot(direction.x, direction.y, direction.z);
+ if (strength < 1e-8) return;
+ arrow(group, position, direction, 0.12 + 0.32 * strength / (strength + 4), colour);
+}
+
+function fieldScene(group: THREE.Group, p: Parameters) {
+ grid(group);
+ const charges = chargePair(p);
+ charges.forEach(charge => signedCharge(group, charge));
+ for (let x = -5; x <= 5; x += 0.6) for (let z = -5; z <= 5; z += 0.6) {
+ const point = { x, y: 0, z };
+ if (charges.some(q => Math.hypot(q.x - x, q.z - z) < 0.42)) continue;
+ compressedFieldArrow(group, point, electricField(point, charges));
+ }
+ const force = forceOn(charges[1], [charges[0]]);
+ arrow(group, { ...charges[1], y: 0.8 }, force, 1.3, colours.gold);
+ label(group, '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);
+}
+
+function fluxScene(group: THREE.Group, p: Parameters) {
+ const radius = number(p, 'radius');
+ const charge: Charge = { x: number(p, 'position'), y: 0, z: 0, coulombs: number(p, 'charge') * 1e-9 };
+ grid(group, -3.4);
+ signedCharge(group, charge);
+ const surface = new THREE.Mesh(new THREE.SphereGeometry(radius, 40, 24), new THREE.MeshBasicMaterial({ color: colours.negative, wireframe: true, transparent: true, opacity: 0.11, depthWrite: false }));
+ group.add(surface);
+ for (let i = 0; i < 150; i++) {
+ const y = 1 - 2 * (i + 0.5) / 150;
+ const r = Math.sqrt(1 - y * y);
+ const theta = i * Math.PI * (3 - Math.sqrt(5));
+ const normal = { x: r * Math.cos(theta), y, z: r * Math.sin(theta) };
+ const point = { x: normal.x * radius, y: y * radius, z: normal.z * radius };
+ if (Math.hypot(point.x - charge.x, point.y, point.z) < 0.3) continue;
+ const e = electricField(point, [charge]);
+ const dot = e.x * normal.x + e.y * normal.y + e.z * normal.z;
+ compressedFieldArrow(group, point, e, dot >= 0 ? colours.field : colours.positive);
+ }
+ label(group, 'Gaussian surface', { x: 0, y: radius + 0.6, z: 0 }, colours.negative, 2.5);
+ line(group, [{ x: -4.7, y: 0, z: 0 }, { x: 4.7, y: 0, z: 0 }], colours.muted, 0.4);
+}
+
+function energyScene(group: THREE.Group, p: Parameters) {
+ grid(group, -4.3);
+ const r = number(p, 'distance');
+ const q = number(p, 'testCharge') * 1e-9;
+ const source: Charge = { x: -3, y: 0, z: 1.5, coulombs: 3e-9 };
+ const moving: Charge = { x: -3 + r, y: 0, z: 1.5, coulombs: q };
+ signedCharge(group, source);
+ signedCharge(group, moving);
+ line(group, [source, moving], colours.muted);
+ arrow(group, { ...moving, y: 0.7 }, forceOn(moving, [source]), 1.1, colours.gold);
+ const curve: Point[] = [];
+ for (let distance = 0.75; distance <= 6; distance += 0.05) curve.push({ x: -3 + distance, y: pairEnergy(3e-9, q, distance) * 1e9 / 20, z: -1.5 });
+ line(group, curve, colours.field);
+ line(group, [{ x: -2.5, y: 0, z: -1.5 }, { x: 3.5, y: 0, z: -1.5 }], colours.muted);
+ line(group, [{ x: -3, y: -5.5, z: -1.5 }, { x: -3, y: 5.5, z: -1.5 }], colours.muted, 0.5);
+ sphere(group, { x: -3 + r, y: pairEnergy(3e-9, q, r) * 1e9 / 20, z: -1.5 }, 0.12, colours.gold);
+ label(group, '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);
+}
+
+function potentialScene(group: THREE.Group, p: Parameters) {
+ const charges = chargePair(p);
+ const geometry = new THREE.PlaneGeometry(10, 10, 64, 64);
+ geometry.rotateX(-Math.PI / 2);
+ const positions = geometry.getAttribute('position');
+ const values: number[] = [];
+ const colourValues: number[] = [];
+ const negative = new THREE.Color(colours.negative);
+ const positive = new THREE.Color(colours.positive);
+ const neutral = new THREE.Color('#203847');
+ for (let i = 0; i < positions.count; i++) {
+ const point = { x: positions.getX(i), y: 0, z: positions.getZ(i) };
+ const v = potential(point, charges);
+ const height = Number.isFinite(v) ? Math.tanh(v / 15) : NaN;
+ values.push(height);
+ positions.setY(i, Number.isFinite(height) ? 2 * height : 0);
+ const colour = neutral.clone().lerp(height >= 0 ? positive : negative, Math.abs(Number.isFinite(height) ? height : 0));
+ colourValues.push(colour.r, colour.g, colour.b);
+ }
+ const indices = geometry.getIndex()!;
+ const kept: number[] = [];
+ for (let i = 0; i < indices.count; i += 3) {
+ const a = indices.getX(i), b = indices.getX(i + 1), c = indices.getX(i + 2);
+ if ([a, b, c].every(index => Number.isFinite(values[index]))) kept.push(a, b, c);
+ }
+ geometry.setIndex(kept);
+ geometry.setAttribute('color', new THREE.Float32BufferAttribute(colourValues, 3));
+ geometry.computeVertexNormals();
+ group.add(new THREE.Mesh(geometry, new THREE.MeshStandardMaterial({ vertexColors: true, side: THREE.DoubleSide, roughness: 0.7, transparent: true, opacity: 0.88 })));
+ for (let x = -4.5; x <= 4.5; x += 0.75) for (let z = -4.5; z <= 4.5; z += 0.75) {
+ const point = { x, y: 0, z };
+ if (charges.some(q => Math.hypot(q.x - x, q.z - z) < 0.6)) continue;
+ const v = potential(point, charges);
+ if (!Number.isFinite(v)) continue;
+ compressedFieldArrow(group, { ...point, y: 2 * Math.tanh(v / 15) + 0.12 }, electricField(point, charges));
+ }
+ const probe = { x: number(p, 'probe'), y: 0, z: 1 };
+ const v = potential(probe, charges);
+ sphere(group, { ...probe, y: 2 * Math.tanh(v / 15) + 0.2 }, 0.13, colours.gold);
+ label(group, 'Probe', { ...probe, y: 2 * Math.tanh(v / 15) + 0.8 }, colours.gold, 1.3);
+ charges.forEach(charge => signedCharge(group, { ...charge, y: Math.sign(charge.coulombs) * 2.2 }));
+ grid(group, -2.4);
+}
+
+function plateScene(group: THREE.Group, p: Parameters, dielectric: boolean) {
+ const c = plateModel(p);
+ const halfGap = number(p, 'gap') * 0.75;
+ const side = Math.sqrt(number(p, 'area')) * 0.3;
+ // Uniform magnification maps lateral metres to 30 scene units/m and gap
+ // metres to 1500 units/m: gap is enlarged 50x relative to plate width.
+ for (const sign of [1, -1]) {
+ const x = -sign * halfGap;
+ const colour = sign === 1 ? colours.positive : colours.negative;
+ box(group, { x, y: 0, z: 0 }, { x: 0.05, y: side, z: side }, colour, 0.65);
+ label(group, sign === 1 ? '+ plate' : '− plate', { x, y: side / 2 + 0.45, z: 0 }, colour, 1.2);
+ for (let y = -0.35; y <= 0.35; y += 0.35) for (let z = -0.35; z <= 0.35; z += 0.35) {
+ sphere(group, { x: x + sign * 0.035, y: y * side, z: z * side }, 0.025, colour);
+ }
+ }
+ for (let y = -0.3; y <= 0.3; y += 0.3) for (let z = -0.3; z <= 0.3; z += 0.3) {
+ const length = Math.max(0.1, 2 * halfGap - 0.1);
+ // Repeated arrows are direction cues; readouts provide field magnitude.
+ arrow(group, { x: -halfGap + 0.05, y: y * side, z: z * side }, { x: 1, y: 0, z: 0 }, length, colours.field);
+ }
+ if (dielectric) {
+ box(group, { x: 0, y: 0, z: 0 }, { x: Math.max(0.04, 2 * halfGap - 0.08), y: side * 0.92, z: side * 0.92 }, colours.gold, 0.1);
+ const kappa = number(p, 'permittivity');
+ if (kappa > 1) {
+ const boundOffset = Math.min(halfGap * 0.65, 0.12) * (kappa - 1) / kappa;
+ for (let y = -0.32; y <= 0.32; y += 0.32) for (let z = -0.32; z <= 0.32; z += 0.32) {
+ const center = { x: 0, y: y * side, z: z * side };
+ sphere(group, { ...center, x: -boundOffset }, 0.035, colours.negative);
+ sphere(group, { ...center, x: boundOffset }, 0.035, colours.positive);
+ line(group, [{ ...center, x: -boundOffset }, { ...center, x: boundOffset }], colours.gold);
+ }
+ }
+ }
+ line(group, [{ x: -halfGap, y: -side, z: 0 }, { x: halfGap, y: -side, z: 0 }], colours.muted);
+ label(group, `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);
+ grid(group, -3.2);
+}
+
+function currentScene(group: THREE.Group, p: Parameters): Animate {
+ grid(group, -2);
+ const model = wireModel(p);
+ const length = number(p, 'length') * 3;
+ const radius = Math.sqrt(number(p, 'area')) * 0.4;
+ const wire = new THREE.Mesh(new THREE.CylinderGeometry(radius, radius, length, 28, 1, true), new THREE.MeshStandardMaterial({ color: '#bf8158', transparent: true, opacity: 0.12, side: THREE.DoubleSide, depthWrite: false }));
+ wire.rotation.z = Math.PI / 2;
+ group.add(wire);
+ for (let i = 0; i < 6; i++) {
+ const x = length * ((i + 0.5) / 6 - 0.5);
+ for (let j = 0; j < 6; j++) {
+ const angle = j * Math.PI / 3;
+ sphere(group, { x, y: radius * 0.8 * Math.cos(angle), z: radius * 0.8 * Math.sin(angle) }, 0.055, colours.gold);
+ }
+ }
+ const carriers: THREE.Mesh[] = [];
+ for (let i = 0; i < 32; i++) {
+ const angle = i * 2.39996;
+ const r = radius * 0.65 * Math.sqrt((i % 7 + 1) / 8);
+ carriers.push(sphere(group, { x: length * (i / 32 - 0.5), y: r * Math.cos(angle), z: r * Math.sin(angle) }, 0.065, colours.negative));
+ }
+ arrow(group, { x: -Math.sign(model.current) * 1.1, y: radius + 0.8, z: 0 }, { x: model.current, y: 0, z: 0 }, 2.2, colours.field);
+ label(group, 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);
+ const velocity = model.electronDrift * 600 * 3;
+ return delta => {
+ for (const electron of carriers) {
+ electron.position.x = ((electron.position.x + length / 2 + delta * velocity) % length + length) % length - length / 2;
+ }
+ };
+}
+
+function semiconductorScene(group: THREE.Group, p: Parameters): Animate {
+ grid(group, -3.5);
+ const s = carrierModel(p);
+ // 3 display units per eV; approximate intrinsic level at midgap.
+ const bandEdge = 1.12 / 2 * 3;
+ box(group, { x: 0, y: bandEdge + 0.4, z: 0 }, { x: 7, y: 0.8, z: 3 }, colours.negative, 0.12);
+ box(group, { x: 0, y: -bandEdge - 0.4, z: 0 }, { x: 7, y: 0.8, z: 3 }, colours.positive, 0.12);
+ line(group, [{ x: -3.5, y: 0, z: 1.6 }, { x: 3.5, y: 0, z: 1.6 }], colours.muted, 0.7);
+ line(group, [{ x: -3.6, y: s.fermiShift * 3, z: 1.65 }, { x: 3.6, y: s.fermiShift * 3, z: 1.65 }], colours.gold);
+ label(group, '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, '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);
+ line(group, [{ x: 3.8, y: -bandEdge, z: -1.3 }, { x: 3.8, y: bandEdge, z: -1.3 }], colours.muted);
+ const particles: { mesh: THREE.Mesh; base: THREE.Vector3; phase: number }[] = [];
+ for (const [density, sign] of [[s.electrons, 1], [s.holes, -1]]) {
+ const count = Math.round(3 + Math.max(0, Math.log10(density) - 8) * 2.5);
+ for (let i = 0; i < count; i++) {
+ const x = 6.4 * ((i * 0.618034) % 1 - 0.5);
+ const z = 2.5 * ((i * 0.414214) % 1 - 0.5);
+ const mesh = sphere(group, { x, y: sign * (bandEdge + 0.4), z }, 0.075, sign === 1 ? colours.negative : colours.positive);
+ if (sign === -1) {
+ (mesh.material as THREE.MeshStandardMaterial).wireframe = true;
+ }
+ particles.push({ mesh, base: mesh.position.clone(), phase: i * 2.4 + sign });
+ }
+ }
+ let time = 0;
+ return delta => {
+ time += delta;
+ for (const particle of particles) {
+ particle.mesh.position.x = particle.base.x + 0.12 * Math.sin(time * 1.3 + particle.phase);
+ particle.mesh.position.z = particle.base.z + 0.12 * Math.cos(time * 1.7 + particle.phase);
+ }
+ };
+}
+
+export function buildExperiment(group: THREE.Group, id: ExperimentId, p: Parameters): Animate {
+ switch (id) {
+ case 'field': fieldScene(group, p); break;
+ case 'flux': fluxScene(group, p); break;
+ case 'energy': energyScene(group, p); break;
+ case 'potential': potentialScene(group, p); break;
+ case 'capacitance': plateScene(group, p, false); break;
+ case 'dielectric': plateScene(group, p, true); break;
+ case 'current': return currentScene(group, p);
+ case 'semiconductor': return semiconductorScene(group, p);
+ }
+ return still;
+}
diff --git a/src/scene/lab.ts b/src/scene/lab.ts
new file mode 100644
index 0000000..5d2cadf
--- /dev/null
+++ b/src/scene/lab.ts
@@ -0,0 +1,97 @@
+import * as THREE from 'three';
+import { OrbitControls } from 'three/addons/controls/OrbitControls.js';
+import type { ExperimentId, Parameters } from '../experiments';
+import { buildExperiment, type Animate } from './experiments';
+import { disposeGroup } from './primitives';
+
+export function createLab(viewport: HTMLElement) {
+ let renderer: THREE.WebGLRenderer;
+ try {
+ renderer = new THREE.WebGLRenderer({ antialias: true });
+ } catch {
+ const error = document.createElement('div');
+ error.className = 'webgl-error';
+ error.setAttribute('role', 'alert');
+ error.textContent = 'WebGL could not start. Enable browser hardware acceleration and reload to see the 3D scenes. Lessons, controls and measurements still work.';
+ viewport.append(error);
+ return { update: () => {}, resetCamera: () => {}, setPaused: () => {}, dispose: () => {} };
+ }
+ renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2));
+ renderer.domElement.setAttribute('aria-label', '3D experiment: drag to orbit, scroll to zoom, right-drag to pan');
+ renderer.domElement.setAttribute('tabindex', '0');
+ viewport.append(renderer.domElement);
+ const scene = new THREE.Scene();
+ scene.background = new THREE.Color('#101822');
+ scene.add(new THREE.HemisphereLight(0xe9f7ff, 0x263149, 3));
+ const keyLight = new THREE.DirectionalLight(0xffffff, 2);
+ keyLight.position.set(3, 6, 5);
+ scene.add(keyLight);
+ const camera = new THREE.PerspectiveCamera(45, 1, 0.1, 100);
+ const controls = new OrbitControls(camera, renderer.domElement);
+ controls.enableDamping = true;
+ controls.minDistance = 3;
+ controls.maxDistance = 32;
+ controls.listenToKeyEvents(renderer.domElement);
+ function resetCamera() {
+ camera.position.set(8, 7.5, 11);
+ controls.target.set(0, 0, 0);
+ controls.update();
+ }
+ resetCamera();
+ let group = new THREE.Group();
+ scene.add(group);
+ let animate: Animate = () => {};
+ let paused = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
+ let lastTime = 0;
+ let pending: { id: ExperimentId; parameters: Parameters } | undefined;
+ function update(id: ExperimentId, parameters: Parameters) {
+ // Coalesce range events to one rebuild per animation frame.
+ pending = { id, parameters: { ...parameters } };
+ }
+ const observer = new ResizeObserver(() => {
+ const { width, height } = viewport.getBoundingClientRect();
+ if (width <= 0 || height <= 0) return;
+ renderer.setSize(width, height);
+ camera.aspect = width / height;
+ camera.updateProjectionMatrix();
+ });
+ observer.observe(viewport);
+ renderer.domElement.addEventListener('webglcontextlost', event => {
+ event.preventDefault();
+ if (!viewport.querySelector('[role="alert"]')) {
+ const warning = document.createElement('p');
+ warning.setAttribute('role', 'alert');
+ warning.className = 'webgl-error';
+ warning.textContent = 'WebGL context lost. Reload to restore the 3D view. Numeric experiments still work.';
+ viewport.append(warning);
+ }
+ });
+ renderer.setAnimationLoop(time => {
+ const delta = lastTime ? Math.min((time - lastTime) / 1000, 0.05) : 0;
+ lastTime = time;
+ if (pending) {
+ disposeGroup(group);
+ scene.remove(group);
+ group = new THREE.Group();
+ scene.add(group);
+ animate = buildExperiment(group, pending.id, pending.parameters);
+ pending = undefined;
+ }
+ if (!paused && !document.hidden) animate(delta);
+ controls.update();
+ renderer.render(scene, camera);
+ });
+ return {
+ update,
+ resetCamera,
+ setPaused: (value: boolean) => { paused = value; },
+ dispose: () => {
+ renderer.setAnimationLoop(null);
+ observer.disconnect();
+ controls.dispose();
+ disposeGroup(group);
+ renderer.dispose();
+ renderer.domElement.remove();
+ },
+ };
+}
diff --git a/src/scene/primitives.ts b/src/scene/primitives.ts
new file mode 100644
index 0000000..bce9113
--- /dev/null
+++ b/src/scene/primitives.ts
@@ -0,0 +1,90 @@
+import * as THREE from 'three';
+import type { Point } from '../physics/electricField';
+
+export const colours = { positive: '#ff967b', negative: '#73d7ff', field: '#91e2cb', gold: '#f5d58b', muted: '#8294a8' };
+export const vector = (p: Point) => new THREE.Vector3(p.x, p.y, p.z);
+export function sphere(group: THREE.Group, position: Point, radius: number, colour: string): THREE.Mesh {
+ const mesh = new THREE.Mesh(new THREE.SphereGeometry(radius, 18, 12), new THREE.MeshStandardMaterial({ color: colour, roughness: 0.3, emissive: colour, emissiveIntensity: 0.12 }));
+ mesh.position.copy(vector(position));
+ group.add(mesh);
+ return mesh;
+}
+export function box(group: THREE.Group, position: Point, dimensions: Point, colour: string, opacity = 1): THREE.Mesh {
+ const mesh = new THREE.Mesh(new THREE.BoxGeometry(dimensions.x, dimensions.y, dimensions.z), new THREE.MeshStandardMaterial({ color: colour, transparent: opacity < 1, opacity, depthWrite: opacity === 1, side: THREE.DoubleSide, roughness: 0.45 }));
+ mesh.position.copy(vector(position));
+ group.add(mesh);
+ return mesh;
+}
+export function line(group: THREE.Group, points: Point[], colour: string, opacity = 1): THREE.Line {
+ const mesh = new THREE.Line(new THREE.BufferGeometry().setFromPoints(points.map(vector)), new THREE.LineBasicMaterial({ color: colour, transparent: opacity < 1, opacity }));
+ group.add(mesh);
+ return mesh;
+}
+export function arrow(group: THREE.Group, origin: Point, direction: Point, length: number, colour = colours.field): void {
+ const dir = vector(direction);
+ if (dir.lengthSq() < 1e-24) return;
+ group.add(new THREE.ArrowHelper(dir.normalize(), vector(origin), length, colour, Math.min(0.16, length * 0.3), Math.min(0.1, length * 0.2)));
+}
+export function label(group: THREE.Group, text: string, position: Point, colour: string = '#dbe7f0', width = 2): THREE.Sprite {
+ const canvas = document.createElement('canvas');
+ const context = canvas.getContext('2d')!;
+ context.font = '500 44px system-ui, sans-serif';
+ canvas.width = Math.ceil(context.measureText(text).width) + 36;
+ canvas.height = 80;
+ context.fillStyle = 'rgba(16, 24, 34, 0.85)';
+ context.fillRect(0, 0, canvas.width, canvas.height);
+ context.font = '500 44px system-ui, sans-serif';
+ context.fillStyle = colour;
+ context.textAlign = 'center';
+ context.textBaseline = 'middle';
+ context.fillText(text, canvas.width / 2, 40);
+ const texture = new THREE.CanvasTexture(canvas);
+ texture.colorSpace = THREE.SRGBColorSpace;
+ const sprite = new THREE.Sprite(new THREE.SpriteMaterial({ map: texture, depthTest: false }));
+ sprite.position.copy(vector(position));
+ const aspect = canvas.width / canvas.height;
+ const screenHeight = 23 + Math.min(2, Math.max(-1, width - 2)) * 2;
+ const viewportSize = new THREE.Vector2();
+ const cameraPosition = new THREE.Vector3();
+ // Keep labels legible when the camera or viewport changes. Width hints
+ // affect text size slightly; the actual texture fits the text content.
+ sprite.onBeforeRender = (renderer, _scene, camera) => {
+ if (!(camera instanceof THREE.PerspectiveCamera)) return;
+ sprite.getWorldPosition(cameraPosition).applyMatrix4(camera.matrixWorldInverse);
+ renderer.getSize(viewportSize);
+ const worldHeight = 2 * Math.abs(cameraPosition.z) * Math.tan(THREE.MathUtils.degToRad(camera.getEffectiveFOV()) / 2) * screenHeight / Math.max(1, viewportSize.y);
+ sprite.scale.set(worldHeight * aspect, worldHeight, 1);
+ sprite.updateMatrixWorld();
+ };
+ group.add(sprite);
+ return sprite;
+}
+export function grid(group: THREE.Group, y = -0.25) {
+ const helper = new THREE.GridHelper(12, 12, 0x375066, 0x22313f);
+ helper.position.y = y;
+ group.add(helper);
+}
+
+/** Dispose shared geometries/materials once per scene replacement, including
+ * textures owned by generated labels. No GPU resources survive a lesson. */
+export function disposeGroup(group: THREE.Group) {
+ const geometries = new Set<THREE.BufferGeometry>();
+ const materials = new Set<THREE.Material>();
+ const textures = new Set<THREE.Texture>();
+ group.traverse(object => {
+ const mesh = object as THREE.Mesh;
+ if (mesh.geometry) geometries.add(mesh.geometry);
+ if (mesh.material) {
+ const list = Array.isArray(mesh.material) ? mesh.material : [mesh.material];
+ for (const material of list) {
+ materials.add(material);
+ const map = (material as THREE.MeshBasicMaterial).map;
+ if (map) textures.add(map);
+ }
+ }
+ });
+ geometries.forEach(geometry => geometry.dispose());
+ materials.forEach(material => material.dispose());
+ textures.forEach(texture => texture.dispose());
+ group.clear();
+}
diff --git a/src/style.css b/src/style.css
new file mode 100644
index 0000000..a1d6ced
--- /dev/null
+++ b/src/style.css
@@ -0,0 +1,104 @@
+:root {
+ font-family: Inter, 'Segoe UI', system-ui, sans-serif;
+ color: #dbe7f0;
+ background: #101822;
+ font-synthesis: none;
+ color-scheme: dark;
+ --mint: #91e2cb;
+ --muted: #98adbf;
+ --border: #2a394a;
+}
+* { box-sizing: border-box; }
+body { margin: 0; }
+button, select, input { font: inherit; }
+button, select { color: inherit; }
+button, a, select, input { -webkit-tap-highlight-color: transparent; }
+button { cursor: pointer; }
+a { color: var(--mint); text-underline-offset: 4px; }
+button:focus-visible, a:focus-visible, select:focus-visible, input:focus-visible, canvas:focus-visible, summary:focus-visible { outline: 2px solid var(--mint); outline-offset: 4px; }
+.app-header { min-height: 96px; display: flex; align-items: center; gap: 22px; padding: 20px 32px; border-bottom: 1px solid var(--border); background: #121d29; }
+.brand { flex-shrink: 0; font: 700 19px monospace; text-decoration: none; color: var(--mint); border: 1px solid #3c7069; border-radius: 9px; padding: 15px 12px; }
+.brand span { color: #516a79; padding: 0 3px; }
+h1 { font-size: 19px; font-weight: 600; margin: 0; }
+.header-title p { margin: 6px 0 0; color: var(--muted); font-size: 13px; }
+.status { margin-left: auto; color: var(--mint); font: 10px monospace; letter-spacing: 1px; white-space: nowrap; display: flex; align-items: center; gap: 8px; }
+.status i { background: var(--mint); width: 6px; height: 6px; border-radius: 50%; }
+.subjects { background: #121d29; border-bottom: 1px solid var(--border); overflow-x: auto; padding: 0 24px; }
+[role='tablist'] { display: flex; min-width: max-content; }
+[role='tab'] { display: flex; align-items: center; gap: 9px; background: transparent; border: 0; border-bottom: 2px solid transparent; padding: 20px 13px; font-size: 12px; color: var(--muted); white-space: nowrap; }
+[role='tab'] span { color: #71899d; font: 10px monospace; }
+[role='tab']:hover { background: #1b2b3a; color: #dbe7f0; }
+[role='tab'][aria-selected='true'] { color: var(--mint); border-bottom-color: var(--mint); background: #192b32; }
+[role='tab'][aria-selected='true'] span { color: var(--mint); }
+main { display: grid; grid-template-columns: 310px minmax(0, 1fr); min-height: calc(100dvh - 153px); }
+.controls-panel { padding: 28px 25px; border-right: 1px solid var(--border); background: #15212e; }
+.eyebrow { font: 10px monospace; color: var(--mint); letter-spacing: 2px; margin: 0 0 14px; }
+h2 { font-size: 26px; font-weight: 550; letter-spacing: -0.5px; line-height: 1.25; margin: 0 0 15px; }
+p { line-height: 1.7; }
+.description { color: var(--muted); font-size: 13px; margin: 0 0 30px; }
+.parameter { margin: 24px 0; }
+.parameter-label { display: flex; align-items: baseline; justify-content: space-between; gap: 8px; }
+label { font-size: 12px; color: #c2d0dd; }
+output { flex-shrink: 0; color: var(--mint); font: 12px monospace; }
+input[type='range'] { display: block; width: 100%; accent-color: var(--mint); margin: 14px 0 0; cursor: pointer; }
+input:disabled { opacity: 0.35; cursor: default; }
+select { display: block; width: 100%; padding: 10px 8px; border: 1px solid #3b4d60; border-radius: 6px; background: #1c2c3d; margin-top: 10px; font-size: 12px; }
+.panel-actions button { width: 100%; border: 1px solid #3b4d60; border-radius: 6px; background: #1c2c3d; padding: 11px; font-size: 12px; }
+button:hover { border-color: var(--mint); }
+.measurements { margin-top: 25px; border: 1px solid #2a4252; border-radius: 8px; padding: 4px 16px; background: #101c27; }
+.measurement { padding: 14px 0; }
+.measurement + .measurement { border-top: 1px solid #243342; }
+.measurement > span { display: block; font-size: 11px; color: var(--muted); }
+.measurement > div { display: flex; align-items: baseline; gap: 8px; margin-top: 6px; }
+.measurement strong { font: 23px monospace; color: var(--mint); }
+.measurement small { font-size: 11px; color: var(--muted); }
+.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-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; }
+.scene-toolbar button { pointer-events: auto; background: #15212ee8; border: 1px solid #34495c; border-radius: 5px; padding: 7px 10px; font-size: 11px; }
+.legend { position: absolute; z-index: 1; pointer-events: none; left: 24px; top: 64px; display: flex; flex-wrap: wrap; gap: 8px 18px; right: 24px; }
+.legend span { display: flex; align-items: center; gap: 6px; color: var(--muted); font-size: 10px; }
+.legend i { display: inline-block; width: 5px; height: 5px; border-radius: 50%; }
+.scene-footer { position: absolute; z-index: 1; pointer-events: none; bottom: 0; padding: 24px 24px 12px; width: 100%; background: linear-gradient(transparent, #101822ed); }
+.scene-footer p { margin: 3px 0; color: #b3c8d7; font-size: 11px; line-height: 1.6; }
+.scene-footer .hint { color: #7890a4; font-size: 10px; }
+.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 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; }
+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; } }
+@media (max-width: 1100px) { .app-header { padding: 16px 24px; } .subjects { padding: 0 12px; } [role='tab'] { padding: 18px 12px; } main { grid-template-columns: 280px minmax(0, 1fr); } .controls-panel { padding: 24px 20px; } .learning-panel { grid-template-columns: 1fr; gap: 24px; padding: 25px; } .challenge { border-left: 0; border-top: 1px solid var(--border); padding: 22px 0 0; } }
+@media (max-width: 760px) {
+ .app-header { padding: 16px; gap: 13px; min-height: 88px; }
+ .brand { font-size: 15px; padding: 12px 8px; }
+ h1 { font-size: 15px; }
+ .header-title p { font-size: 10px; }
+ .status { display: none; }
+ main { display: flex; flex-direction: column; }
+ .controls-panel { border-right: 0; border-bottom: 1px solid var(--border); padding: 24px; }
+ h2 { font-size: 25px; }
+ #parameters { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 0 24px; }
+ .parameter { min-width: 0; margin: 10px 0 15px; }
+ .parameter-label { flex-wrap: wrap; gap: 6px; }
+ .measurements { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 0 16px; margin-top: 18px; }
+ .measurement { min-width: 0; }
+ .measurement strong { font-size: 21px; overflow-wrap: anywhere; }
+ .measurement > div { flex-wrap: wrap; gap: 4px; }
+ .measurement:nth-child(2) { border-top: 0; }
+ #viewport { height: 520px; }
+ .scene-toolbar { top: 18px; left: 16px; right: 16px; }
+ .scene-toolbar button { font-size: 10px; padding: 7px; }
+ .legend { left: 16px; right: 16px; }
+ .scene-footer { padding: 20px 16px 12px; }
+ .learning-panel { padding: 24px; }
+}
diff --git a/tests/lab.spec.ts b/tests/lab.spec.ts
new file mode 100644
index 0000000..51645cd
--- /dev/null
+++ b/tests/lab.spec.ts
@@ -0,0 +1,152 @@
+import { test, expect, type Page } from '@playwright/test';
+
+async function setRange(page: Page, label: string, value: number) {
+ await page.getByRole('slider', { name: label, exact: true }).fill(String(value));
+}
+async function read(page: Page, label: string) {
+ return Number((await page.getByLabel(label, { exact: true }).innerText()).replaceAll(',', ''));
+}
+
+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('/');
+ 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);
+ for (let i = 0; i < subjects.length; i++) {
+ await tabs.nth(i).click();
+ await expect(page.getByRole('heading', { name: subjects[i], exact: true })).toBeVisible();
+ await expect(page.locator('canvas')).toBeVisible();
+ await expect(page.locator('#measurements')).not.toContainText('Undefined');
+ await expect(page.locator('#measurements .measurement')).toHaveCount(i === 5 ? 5 : i >= 4 ? 4 : 3);
+ await expect(page.locator('#challenge')).not.toBeEmpty();
+ // A rendered frame must contain geometry beyond the solid background.
+ const colours = await page.locator('canvas').evaluate(canvas => new Promise<number>(resolve => 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 unique = new Set<number>();
+ for (let k = 0; k < data.length; k += 4) unique.add((data[k] << 16) | (data[k + 1] << 8) | data[k + 2]);
+ resolve(unique.size);
+ })));
+ expect(colours).toBeGreaterThan(12);
+ }
+ expect(errors).toEqual([]);
+});
+
+test('charge separation changes force by inverse square and reset restores defaults', async ({ page }) => {
+ await page.goto('/#field');
+ const force = await read(page, '|Force on right charge|');
+ await setRange(page, 'Charge separation', 6);
+ expect(await read(page, '|Force on right charge|')).toBeCloseTo(force / 4, 1);
+ await page.getByRole('button', { name: 'Reset experiment' }).click();
+ await expect(page.getByRole('slider', { name: 'Charge separation', exact: true })).toHaveValue('3');
+ await page.getByLabel('Charge signs', { exact: true }).selectOption('like');
+ await expect(page.getByLabel('Interaction', { exact: true })).toHaveText('Repulsive');
+});
+
+test('Gaussian flux distinguishes enclosed, outside and surface charges', async ({ page }) => {
+ await page.goto('/#flux');
+ expect(await read(page, 'Net electric flux')).toBeGreaterThan(200);
+ await setRange(page, 'Charge x position', 3);
+ expect(await read(page, 'Net electric flux')).toBe(0);
+ await setRange(page, 'Charge x position', 2);
+ await expect(page.getByLabel('Net electric flux', { exact: true })).toHaveText('Undefined');
+ await expect(page.getByLabel('Source location', { exact: true })).toHaveText('On surface');
+});
+
+test('capacitance scales with area and dielectric conditions preserve the correct quantity', async ({ page }) => {
+ await page.goto('/#capacitance');
+ const initial = await read(page, 'Capacitance');
+ await setRange(page, 'Plate area', 200);
+ expect(await read(page, 'Capacitance')).toBeCloseTo(2 * initial, 1);
+ await page.getByRole('tab', { name: /Dielectrics/ }).click();
+ const q = await read(page, 'Free charge |Q|');
+ await setRange(page, 'Relative permittivity κ', 8);
+ expect(await read(page, 'Free charge |Q|')).toBeCloseTo(2 * q, 1);
+ expect(await read(page, 'Plate voltage')).toBe(10);
+ await page.getByLabel('Electrical condition', { exact: true }).selectOption('isolated');
+ const isolatedCharge = await read(page, 'Free charge |Q|');
+ expect(await read(page, 'Plate voltage')).toBe(1.25);
+ await setRange(page, 'Relative permittivity κ', 4);
+ expect(await read(page, 'Free charge |Q|')).toBe(isolatedCharge);
+ expect(await read(page, 'Plate voltage')).toBe(2.5);
+});
+
+test('reversed voltage reverses drift and animation can be paused', async ({ page }) => {
+ await page.goto('/#current');
+ expect(await read(page, 'Conventional current')).toBeGreaterThan(0);
+ expect(await read(page, 'Electron drift, x')).toBeLessThan(0);
+ await setRange(page, 'Voltage across wire', -0.025);
+ expect(await read(page, 'Conventional current')).toBeLessThan(0);
+ expect(await read(page, 'Electron drift, x')).toBeGreaterThan(0);
+ await page.getByRole('button', { name: 'Pause animation' }).click();
+ await expect(page.getByRole('button', { name: 'Resume animation' })).toBeVisible();
+ await setRange(page, 'Voltage across wire', 0);
+ expect(await read(page, 'Conventional current')).toBe(0);
+});
+
+test('doping selects majority carriers without modifying intrinsic silicon', async ({ page }) => {
+ await page.goto('/#semiconductor');
+ const doping = page.getByLabel('Silicon sample at 300 K', { exact: true });
+ const concentration = page.getByRole('slider', { name: /Dopant density/ });
+ await expect(concentration).toBeDisabled();
+ expect(await read(page, 'Electron density n')).toBe(await read(page, 'Hole density p'));
+ await doping.selectOption('n');
+ await expect(concentration).toBeEnabled();
+ expect(await read(page, 'Electron density n')).toBeGreaterThan(await read(page, 'Hole density p'));
+ expect(await read(page, 'Fermi shift EF − Ei')).toBeGreaterThan(0);
+ await doping.selectOption('p');
+ expect(await read(page, 'Hole density p')).toBeGreaterThan(await read(page, 'Electron density n'));
+ expect(await read(page, 'Fermi shift EF − Ei')).toBeLessThan(0);
+});
+
+test('lesson navigation preserves parameters, supports history, and fits a mobile viewport', async ({ page }) => {
+ await page.goto('/#energy');
+ await setRange(page, 'Moving charge', 3);
+ await page.getByRole('tab', { name: /Potential & gradient/ }).click();
+ await page.goBack();
+ await expect(page.getByRole('slider', { name: 'Moving charge', exact: true })).toHaveValue('3');
+ await page.setViewportSize({ width: 390, height: 844 });
+ await expect(page.locator('canvas')).toBeVisible();
+ expect(await page.evaluate(() => document.documentElement.scrollWidth <= window.innerWidth)).toBe(true);
+});
+
+test('WebGL failure leaves lessons and numeric experiments usable', 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('/#capacitance');
+ await expect(page.getByRole('alert')).toContainText('WebGL');
+ expect(await read(page, 'Capacitance')).toBeGreaterThan(80);
+ await setRange(page, 'Plate area', 200);
+ expect(await read(page, 'Capacitance')).toBeGreaterThan(170);
+});
+
+test('energy sign, distance and field work agree', async ({ page }) => {
+ await page.goto('/#energy');
+ expect(await read(page, 'Interaction energy')).toBeCloseTo(-26.96, 2);
+ expect(await read(page, 'Work by field, from 6 m')).toBeCloseTo(17.98, 2);
+ await setRange(page, 'Distance from +3 nC source', 4);
+ expect(await read(page, 'Interaction energy')).toBeCloseTo(-13.48, 2);
+ await setRange(page, 'Moving charge', 2);
+ expect(await read(page, 'Interaction energy')).toBeCloseTo(13.48, 2);
+ expect(await read(page, 'Work by field, from 6 m')).toBeLessThan(0);
+});
+
+test('zero dipole potential can coexist with nonzero field', async ({ page }) => {
+ await page.goto('/#potential');
+ expect(await read(page, 'Potential at probe')).toBe(0);
+ expect(await read(page, 'Ex at probe')).toBeGreaterThan(0);
+ await setRange(page, 'Probe x position (z = 1 m)', 2);
+ expect(await read(page, 'Potential at probe')).toBeLessThan(0);
+ await page.getByLabel('Charge signs', { exact: true }).selectOption('like');
+ expect(await read(page, 'Potential at probe')).toBeGreaterThan(0);
+});
diff --git a/theory/1Introduction to electrophysics.pdf b/theory/1Introduction to electrophysics.pdf
new file mode 100644
index 0000000..526298a
--- /dev/null
+++ b/theory/1Introduction to electrophysics.pdf
Binary files differ
diff --git a/theory/2Electric charge, force and field.pdf b/theory/2Electric charge, force and field.pdf
new file mode 100644
index 0000000..31ad03f
--- /dev/null
+++ b/theory/2Electric charge, force and field.pdf
Binary files differ
diff --git a/theory/3Electric flux and charge.pdf b/theory/3Electric flux and charge.pdf
new file mode 100644
index 0000000..39a10ec
--- /dev/null
+++ b/theory/3Electric flux and charge.pdf
Binary files differ
diff --git a/theory/4Electric potential energy.pdf b/theory/4Electric potential energy.pdf
new file mode 100644
index 0000000..fd62020
--- /dev/null
+++ b/theory/4Electric potential energy.pdf
Binary files differ
diff --git a/theory/5Electric potential and gradient.pdf b/theory/5Electric potential and gradient.pdf
new file mode 100644
index 0000000..87eee7f
--- /dev/null
+++ b/theory/5Electric potential and gradient.pdf
Binary files differ
diff --git a/theory/6capacitance.pdf b/theory/6capacitance.pdf
new file mode 100644
index 0000000..67e697e
--- /dev/null
+++ b/theory/6capacitance.pdf
Binary files differ
diff --git a/theory/7dielectricmaterials.pdf b/theory/7dielectricmaterials.pdf
new file mode 100644
index 0000000..f67dd91
--- /dev/null
+++ b/theory/7dielectricmaterials.pdf
Binary files differ
diff --git a/theory/8current.pdf b/theory/8current.pdf
new file mode 100644
index 0000000..a09d270
--- /dev/null
+++ b/theory/8current.pdf
Binary files differ
diff --git a/theory/9semiconductingmaterials.pdf b/theory/9semiconductingmaterials.pdf
new file mode 100644
index 0000000..14c5cae
--- /dev/null
+++ b/theory/9semiconductingmaterials.pdf
Binary files differ
diff --git a/tsconfig.json b/tsconfig.json
new file mode 100644
index 0000000..8c204c5
--- /dev/null
+++ b/tsconfig.json
@@ -0,0 +1,13 @@
+{
+ "compilerOptions": {
+ "target": "ES2022",
+ "lib": ["ES2022", "DOM", "DOM.Iterable"],
+ "module": "ESNext",
+ "moduleResolution": "Bundler",
+ "strict": true,
+ "noEmit": true,
+ "skipLibCheck": true,
+ "types": ["vite/client"]
+ },
+ "include": ["src"]
+}
diff --git a/vite.config.ts b/vite.config.ts
new file mode 100644
index 0000000..a35e76e
--- /dev/null
+++ b/vite.config.ts
@@ -0,0 +1,5 @@
+import { defineConfig } from 'vitest/config';
+
+export default defineConfig({
+ test: { include: ['src/**/*.test.ts'] },
+});