From 872da6341bdd55698f9e13cbd3fa7dfbfd07795e Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 4 Aug 2026 20:33:38 +0000 Subject: [PATCH] feat: add a Length Contraction screen (ladder and barn) Length contraction was the one standard topic the sim deliberately left out, and the repo's own docs flagged a ladder-and-barn treatment as the natural next screen. This adds it, third of five, after the Spacetime Diagram screen that introduces the simultaneity it is resolved by. A ladder of proper length L0 flies at beta through a barn whose two doors are wired to one switch. The two slams are events at ( -B/2, 0 ) and ( +B/2, 0 ) in the barn frame: spacelike separated for every non-zero barn, so no frame's answer to "which shut first?" is the wrong one. The barn frame sees the ladder contracted to L0/gamma and briefly wholly inside; the ladder frame sees the barn contracted to B/gamma, the exit door shutting and reopening while the nose is still short of it, and the entrance door shutting behind the tail gamma*beta*B later. No door ever touches the ladder in either account. The screen's central control is the frame selector. Flipping it rearranges the stage completely while changing exactly one thing on the spacetime diagram below - the tilt of the simultaneity slice the measurement is taken along - because the diagram is always drawn in barn-frame coordinates and the two slam markers do not move. Each object's ends sweep out a world sheet, so "does it fit?" becomes visibly a question about which slice of the overlap you take. Also here: - both frames' clocks are zeroed on one shared event (the centres crossing), which is what makes a single scene-time number meaningful across the toggle; - no scrubber - at high beta the window is set by the slams rather than the fly-past, so a fixed-range slider would have had a few percent of useful travel. Two "go to slam" buttons replace it, and teach better: in the barn frame they land on the same instant; - createPushButton takes an optional enabledProperty, and controlHelpers gains createRadioButtonGroup, so the new controls are the shared ones; - 22 new tests, including each frame's fitting verdict re-derived by sweeping the snapshots the view actually draws, and the check that no door shuts through the ladder; - en/es/fr strings, a11y summary, keyboard help, and screen icon; - model.md, implementation-notes.md, CLAUDE.md and README updated. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01D861mRUkK82cR8wuSLwtij --- CLAUDE.md | 43 +- README.md | 9 +- doc/implementation-notes.md | 40 +- doc/model.md | 83 ++- package.json | 2 +- src/SpecialRelativityColors.ts | 32 +- src/SpecialRelativityConstants.ts | 59 ++ src/common/SpecialRelativityScreenIcons.ts | 46 ++ src/common/view/controlHelpers.ts | 61 +- src/i18n/StringManager.ts | 12 + src/i18n/strings_en.json | 51 ++ src/i18n/strings_es.json | 51 ++ src/i18n/strings_fr.json | 51 ++ .../LengthContractionScreen.ts | 49 ++ .../model/LengthContractionModel.ts | 278 +++++++++ .../model/ladderBarnGeometry.ts | 282 +++++++++ .../view/LadderBarnStageNode.ts | 227 +++++++ .../LengthContractionKeyboardHelpContent.ts | 24 + .../LengthContractionScreenSummaryContent.ts | 75 +++ .../view/LengthContractionScreenView.ts | 568 ++++++++++++++++++ src/main.ts | 8 + tests/ladderBarnGeometry.test.ts | 311 ++++++++++ tests/memory-leak.test.ts | 10 + 23 files changed, 2344 insertions(+), 28 deletions(-) create mode 100644 src/length-contraction/LengthContractionScreen.ts create mode 100644 src/length-contraction/model/LengthContractionModel.ts create mode 100644 src/length-contraction/model/ladderBarnGeometry.ts create mode 100644 src/length-contraction/view/LadderBarnStageNode.ts create mode 100644 src/length-contraction/view/LengthContractionKeyboardHelpContent.ts create mode 100644 src/length-contraction/view/LengthContractionScreenSummaryContent.ts create mode 100644 src/length-contraction/view/LengthContractionScreenView.ts create mode 100644 tests/ladderBarnGeometry.test.ts diff --git a/CLAUDE.md b/CLAUDE.md index c76e8d4..b62f914 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -4,9 +4,9 @@ Sim-specific context for AI assistants. General SceneryStack guidance: [OpenPhys ## Project -A four-screen simulation of flat-spacetime special relativity: **Light Clock**, **Spacetime -Diagram**, **Twin Paradox**, **Relativistic Doppler**. Original work, not a PhET or NAAP port. -Forked from `SceneryStackTemplate` on 31 Jul 2026. +A five-screen simulation of flat-spacetime special relativity: **Light Clock**, **Spacetime +Diagram**, **Length Contraction**, **Twin Paradox**, **Relativistic Doppler**. Original work, not a +PhET or NAAP port. Forked from `SceneryStackTemplate` on 31 Jul 2026. Read [`doc/model.md`](doc/model.md) before changing anything physical, and [`doc/implementation-notes.md`](doc/implementation-notes.md) before changing anything structural. @@ -33,8 +33,9 @@ Read [`doc/model.md`](doc/model.md) before changing anything physical, and | `src/common/view/controlHelpers.ts` | `createNumberControl` / `createCheckbox` / `createReadoutRow` — the controls every screen shares | | `src/common/TimeModel.ts` | Composable clock: play/pause, speed, `scaledDt`, step forward/back | | `src/SpecialRelativityColors.ts` | `ProfileColorProperty` table **and the sim's colour language** — read its header before adding a colour | -| `src/SpecialRelativityConstants.ts` | Grouped `as const` blocks (`DIAGRAM`, `EVENT`, `LIGHT_CLOCK`, `TWIN`, `DOPPLER`, `FONTS`) | +| `src/SpecialRelativityConstants.ts` | Grouped `as const` blocks (`DIAGRAM`, `EVENT`, `LIGHT_CLOCK`, `LADDER_BARN`, `TWIN`, `DOPPLER`, `FONTS`) | | `src/light-clock/model/lightClockGeometry.ts` | Photon height, tick counts, the zigzag trail, the light-travel triangle | +| `src/length-contraction/model/ladderBarnGeometry.ts` | Contracted lengths, both frames' snapshots, the two door-slam events, the fitting verdicts, world sheets, and the simultaneity slices in lab coordinates | | `src/twin-paradox/model/twinJourney.ts` | Both worldlines, proper times, the simultaneity jump, the pulses the twins exchange | | `src/relativistic-doppler/model/dopplerGeometry.ts` | Retarded emission solve (for an arbitrary observer position), received signal, wavefronts, beaming lobe | @@ -72,6 +73,20 @@ Read [`doc/model.md`](doc/model.md) before changing anything physical, and exactly that instant, and `β·t_wrap` rounds onto either side of the modulo — the answer can flip to the far end of the rail. Use `traverseStartPosition()`, which returns the rail end exactly. This was a real bug in `photonTrail`, caught by the light-clock triangle's structural test. +- **The Length Contraction screen's clock is one number read by two frames.** `sceneTimeProperty` is + barn time `ct` or ladder time `ct′` depending on the toggle. This is legitimate only because both + frames' clocks are zeroed on the same event — the ladder's centre passing the barn's centre — which + is the one instant they can agree to label. Do not add a second clock. +- **The Length Contraction diagram is always in barn-frame coordinates**, and the frame toggle changes + exactly one thing on it: the tilt of the simultaneity slice. Its `betaProperty` is therefore a + derived 0-or-β, not the model's β. Switching the diagram's coordinates with the toggle would destroy + the screen's point, which is that both frames are describing the same picture. +- **That screen has no scrubber, on purpose.** At high β in the ladder frame the window is set by the + slams (γβB apart), not by the fly-past, so a fixed-range slider would have had a few percent of + useful travel. The two "go to slam" buttons replace it and teach better: in the barn frame they land + on the same instant. +- **Its β is capped at 0.95, not the sim-wide 0.99**, and floored at 0.1. Documented in + `LADDER_BARN`; both bounds are about the animation window, not the arithmetic. - **The Doppler screen uses the retarded emission event**, not the source's current position. That is what makes the transverse redshift come out at exactly γ — from wherever the observer is standing. - **Beaming is D⁴** (bolometric flux). D³ and D² are also correct, for other measured quantities; the @@ -96,12 +111,13 @@ Full convention: [Baton/ACCESSIBILITY.md](https://github.com/OpenPhysics/Baton/b ## Testing -`npm test` — Vitest, `happy-dom`, `--expose-gc`. 129 tests across six files. +`npm test` — Vitest, `happy-dom`, `--expose-gc`. 151 tests across seven files. | Path | Purpose | |---|---| | `tests/lorentz.test.ts` | Kinematics: γ, boosts, invariance, causal structure, velocity addition, Doppler, aberration, beaming | | `tests/lightClockGeometry.test.ts` | Photon path, tick counts, and the independent "the photon travels at c" check | +| `tests/ladderBarnGeometry.test.ts` | Contraction by γ, both frames' fitting verdicts re-derived by sweeping the drawn snapshots, the slams' spacelike separation and invariant interval, and the check that no door shuts through the ladder | | `tests/twinJourney.test.ts` | Proper times and the Earth-time accounting identity at the turn | | `tests/dopplerGeometry.test.ts` | The retarded solve, the three Doppler limits, wavefronts, the beaming lobe | | `tests/TimeModel.test.ts` | The shared clock | @@ -125,8 +141,17 @@ Query parameters: `?initialBeta=0.8`, `?showRapidity=true`, `?shadeLightCone=tru After `npm run build`, the sim is installable offline via Workbox (`dist/manifest.webmanifest`). -## Ideas for a fifth screen +## Ideas for a sixth screen -Length contraction is the one standard topic this sim deliberately avoids — every screen is arranged -so that it does not enter. A "ladder and barn" screen would reuse `MinkowskiDiagramNode` and -`lorentz.ts` almost unchanged. +The four topics still untouched, roughly in order of how much they would reuse: + +- **Velocity addition.** `velocityAddition()` already exists in `lorentz.ts` and is unit-tested but is + not on screen anywhere. A rocket firing a probe forward, with the two boosts composing on a rapidity + scale that *does* add, would need little more than the existing diagram. +- **Relativity of simultaneity as a train-and-lightning screen.** Cheaper than it sounds — it is the + Length Contraction screen's machinery with the two events on a moving object rather than a fixed one. +- **Momentum and energy.** The one genuinely new module: E = γm, p = γmβ, and E² − p² = m² as a fourth + invariant hyperbola to sit beside the one the Spacetime Diagram screen already draws. +- **Reciprocity of time dilation.** The classic misconception the sim does not yet address head-on: a + frame toggle on the Light Clock screen, so that each clock in turn is the one at rest and the *other* + is the one running slow. The Length Contraction screen's frame selector is the pattern to copy. diff --git a/README.md b/README.md index 77fa9f8..4bb5bbc 100644 --- a/README.md +++ b/README.md @@ -1,7 +1,8 @@ # Special Relativity An interactive simulation of flat-spacetime relativity — a moving light clock, a live Minkowski -diagram, the twin paradox, and the relativistic Doppler effect — built with +diagram, the ladder-and-barn paradox, the twin paradox, and the relativistic Doppler effect — built +with [SceneryStack](https://scenerystack.org/), Vite 8, TypeScript 6, and Biome 2. ## Features @@ -11,6 +12,10 @@ diagram, the twin paradox, and the relativistic Doppler effect — built with - **Spacetime Diagram** — drag two events on a Minkowski diagram while a velocity slider shears the primed axes live; light cone, lines of simultaneity, invariant hyperbolas, and a readout that contrasts the invariant interval with the frame-dependent order of events +- **Length Contraction** — a ladder flying through a barn whose two doors slam together, watched + from the barn's frame and then from the ladder's; the same two slams, one pair of events, and two + frames that answer "did it fit?" differently because they slice spacetime into "nows" at + different angles - **Twin Paradox** — place the turnaround event and watch both worldlines, both clocks, and the jump in the traveller's "now" that resolves the paradox - **Relativistic Doppler** — a source flying past an observer, with wavefronts, the received colour @@ -19,7 +24,7 @@ diagram, the twin paradox, and the relativistic Doppler effect — built with - English, Spanish, and French localization via `StringManager` - Full keyboard access, screen-reader summaries, and default/projector colour profiles - Progressive Web App (installable, offline-capable) -- 100 unit tests over the pure physics modules, plus a memory-leak suite +- 151 unit tests over the pure physics modules, plus a memory-leak suite ## Quick Start diff --git a/doc/implementation-notes.md b/doc/implementation-notes.md index c7f9da5..d5c7594 100644 --- a/doc/implementation-notes.md +++ b/doc/implementation-notes.md @@ -27,6 +27,7 @@ src/ controlHelpers.ts, chartUtils.ts light-clock/ model/{LightClockModel,lightClockGeometry} view/… spacetime/ model/SpacetimeDiagramModel view/… + length-contraction/ model/{LengthContractionModel,ladderBarnGeometry} view/… twin-paradox/ model/{TwinParadoxModel,twinJourney} view/… relativistic-doppler/ model/{RelativisticDopplerModel,dopplerGeometry} view/… tests/ one file per pure module, plus memory-leak @@ -36,11 +37,12 @@ tests/ one file per pure module, plus memory-leak ### Pure functional physics, Property layers on top -`lorentz.ts`, `lightClockGeometry.ts`, `twinJourney.ts` and `dopplerGeometry.ts` are plain functions +`lorentz.ts`, `lightClockGeometry.ts`, `ladderBarnGeometry.ts`, `twinJourney.ts` and +`dopplerGeometry.ts` are plain functions of plain numbers and `Vector2`s. They import from `scenerystack/dot` and nothing else — no axon, no scenery. Everything reactive lives in the model classes that wrap them. -This is what makes the physics testable without SceneryStack, and it is where all 100 unit tests +This is what makes the physics testable without SceneryStack, and it is where all 151 unit tests point. It follows `CarnotHeatEngine/src/common/model/carnotCycleGeometry.ts`. ### Everything animated is a closed form of elapsed time @@ -138,6 +140,38 @@ mouse would never see them: Both default to **zero** in the pure functions, so the tests check exact behaviour; only the model layer passes a positive value. Do not push the tolerance into `lorentz.ts`. +### The Length Contraction screen has one clock, read by two frames + +`LengthContractionModel.sceneTimeProperty` is a single number, interpreted as barn time `ct` or as +ladder time `ct′` depending on which frame is selected. That is not a shortcut. Both frames' clocks +are zeroed on one event — the ladder's centre passing the barn's centre — so there is exactly one +instant they can label the same, and it is the one the clock is zeroed on. Flipping the frame toggle +without touching the clock is then a meaningful operation, and it is the operation the screen exists +to offer: the number stays put and the scene rearranges itself around it. + +The clock wraps rather than scrubs. Its window, `sceneHalfWindow()`, is derived from the setup and +the frame, and at high β in the ladder frame it is set by the *slams* (γβB apart) rather than by the +fly-past — so a fixed-range slider would have had a useful travel of a few percent of its track. Two +push buttons take the clock straight to each slam instead, which is also the better teaching control: +in the barn frame both buttons land on the same instant, and that is the whole content of "the doors +are on one switch". + +`advance()` uses a modulo rather than a comparison against the ends, so a large `dt` after a +background tab regains focus lands in the right place instead of skipping the window. + +### The Length Contraction diagram never changes frames + +The stage is drawn in the selected frame; the spacetime diagram is always in barn-frame coordinates, +and the toggle changes exactly one thing on it — the tilt of the simultaneity slice. `betaProperty` +for the shear is therefore *not* the model's β but a DerivedProperty that is β in the ladder frame and +0 in the barn frame, because in the barn frame the primed mesh would be the unprimed mesh already +drawn. + +That split is the argument the screen makes, and it is why `ladderSliceInLab()` returns **barn-frame** +events for a measurement taken in either frame: a length is two ends at one instant, and the ladder +frame's pair of ends lands on the diagram as a tilted segment. Its being shorter against the barn's +upright strip is the disagreement drawn rather than asserted. + ### The Twin Paradox screen has no `SpecialRelativityModel` Its β is *derived* from the turn's position, not chosen. A trip is specified by where and when you @@ -202,7 +236,7 @@ is more than one flat list keeps legible. This is a documented variation on the ## Testing `npm test` runs Vitest over `tests/`, environment `happy-dom`, with `--expose-gc` for the memory-leak -suite. 129 tests across six files. +suite. 151 tests across seven files. The house style is three layers per physics module: diff --git a/doc/model.md b/doc/model.md index bc2d704..f01cdc1 100644 --- a/doc/model.md +++ b/doc/model.md @@ -6,12 +6,13 @@ terms appropriate for an educator. It is the companion to ## Overview -Special Relativity has four screens, each built on one idea: +Special Relativity has five screens, each built on one idea: | Screen | The idea | |---|---| | **Light Clock** | If light travels at c for everyone, a moving clock must tick slower — by γ. | | **Spacetime Diagram** | The interval between two events is the same for everyone; their *order* need not be. | +| **Length Contraction** | A length is two ends measured at one instant — and frames disagree about which instants those are. | | **Twin Paradox** | Elapsed time depends on the path, not just on its endpoints. | | **Relativistic Doppler** | The colour and brightness of a moving source depend on how it moves — including sideways. | @@ -42,6 +43,7 @@ slope ±1 on a spacetime diagram. Nothing in the code multiplies or divides by c | Rapidity | η = artanh β | dimensionless | Optional readout; adds under successive boosts, where β does not | | Invariant interval | s² = x² − (ct)² | ls² | Violet on the diagrams | | Proper time | τ | seconds | Time on a clock carried along a given worldline; green throughout the sim | +| Proper length | L₀ | light-seconds | A rod's length in its own rest frame — the longest any frame measures it | | Wavelength | λ | nanometres | Relativistic Doppler screen only | Two sign conventions are worth stating because both appear in textbooks: @@ -144,6 +146,68 @@ axis, because "same x′" is a line parallel to ct′ and not a line at right an frames' projections can be drawn at once from the selected event, and at β = 0 the primed pair collapses onto the lab pair, so the familiar recipe appears as the special case it is. +### Length Contraction + +A ladder of proper length L₀ flies at β through a barn of proper length B whose two doors are wired +to one switch. This is the ladder-and-barn paradox, and it is the one screen where the same question +gets two different answers and both of them are right. + +**A length is not a property of an object alone.** To measure a moving rod you must mark where both +ends are *at the same moment*, and "the same moment" is exactly what frames disagree about. Everything +below follows from that one sentence. + +``` +L_measured = L₀ / γ +``` + +Take the origin of the barn frame to be the event *the ladder's centre passes the barn's centre*. The +doors then sit at x = ∓B/2 for all time, and the switch fires them both at ct = 0, so the two slams +are the events + +``` +entrance slam ( −B/2, 0 ) exit slam ( +B/2, 0 ) +``` + +Δx = B and Δ(ct) = 0, so the two slams are **spacelike separated for every barn of non-zero length**. +No signal can pass between them, so no frame's opinion about their order is the wrong one — which is +the licence the whole screen runs on, and the same fact the Spacetime Diagram screen establishes in +the abstract. + +| | Barn frame | Ladder frame | +|---|---|---| +| Ladder measures | L₀/γ | L₀ | +| Barn measures | B | B/γ | +| The two slams | together, at ct = 0 | γβB apart — **exit door first** | +| Ladder ever wholly inside? | yes, when L₀/γ < B | no, when L₀ > B/γ | + +Both columns can hold at once, and they do whenever + +``` +B/γ² < L₀/γ < B +``` + +which is a non-empty range for every γ > 1. The screen's default configuration (B = 4 ls, L₀ = 5 ls, +β = 0.8, so γ = 5/3) sits inside it in round numbers: the ladder is measured at exactly 3 ls in the +barn frame and the barn at exactly 2.4 ls in the ladder frame. + +**The resolution is not that one frame is mistaken.** Follow the ladder frame's own account: at +ct′ = −γβB/2 the exit door slams and reopens while the ladder's nose is still short of it; the barn +keeps sweeping past; at ct′ = +γβB/2 the entrance door slams behind the ladder's tail, which is by +then well inside. No door ever touches the ladder, and the ladder is never wholly inside. The barn +frame's account has both doors shut at once with the ladder wholly between them. Every *event* in +those two stories is the same event; only the pairing into simultaneous moments differs. The tests +check both accounts for consistency, including that no door ever shuts through the ladder. + +**Two pictures, one spacetime.** The stage is drawn with the selected frame's rulers and clock, so +the toggle rearranges it completely. The spacetime diagram below is always in **barn-frame** +coordinates, and the toggle changes exactly one thing on it: the tilt of the line of simultaneity the +measurement is taken along. Each object's two ends sweep out a band — an upright strip for the barn, +a strip leaning by β for the ladder — and the question "does it fit?" becomes plainly a question about +*which slice of the overlap you take*. The two slam markers do not move when the toggle does. + +The doors are drawn shut for a short window either side of each slam. That is the only display +convention on the screen: a slam is an instant, and an instant occupies one frame of animation. + ### Twin Paradox One twin stays put; the other flies out at β, turns, and comes back. Both worldlines run between the @@ -258,6 +322,9 @@ draws. | β | −0.99 … 0.99 | 0.6 | γ = 1.25 at the default — visibly relativistic while the primed axes stay clearly off the light cone. At the cap γ ≈ 7.09; pushing closer to 1 collapses the axes onto the cone, and the geometry becomes unreadable long before the arithmetic becomes inaccurate. | | Diagram extent | ±5 ls | — | Light rays run corner to corner | | Mirror separation L | 0.5 … 1.6 ls | 1 ls | At the default one tick = 2 s, an easy number to hold onto while γ stretches it. The range is kept modest so the taller clock still fits between the readouts and the rail | +| Barn length B | fixed | 4 ls | Only the *ratio* of the two lengths matters, so a second length slider would only reach states the ladder slider already reaches. The barn is the one held still because the diagram is drawn in its frame | +| Ladder proper length L₀ | 2 … 8 ls | 5 ls | Spans "fits in both frames" through "fits in neither". At the default β the paradox regime is 2.56 … 4 ls of *contracted* length, and 5 ls lands in it at exactly 3 | +| Ladder speed β | 0.1 … 0.95 | 0.8 | γ = 5/3, so 5 ls contracts to exactly 3 and 4 ls to exactly 2.4. Strictly positive because at β = 0 nothing passes anything; capped below the sim-wide 0.99 because in the ladder's frame the slams are γβB apart, and at 0.99 that window is four times longer than the fly-past it brackets | | Turn position | \|x\| ≤ 4.2 ls, 0.6 ≤ ct ≤ 4.4 ls | (3, 4) | The 3-4-5 case: γ = 1.512, Earth 8 s against the traveller's 2√7 ≈ 5.29 s | | Journey time | 0 … 8.8 s | 0 | The latest reunion any allowed turn can produce; the reachable end moves with the trip | | Signal interval | fixed | 1 s of the sender's own time | A handful of pulses per leg on the default trip — enough to see the spacing stretch and crowd, few enough that the diagram does not turn into hatching | @@ -272,9 +339,14 @@ draws. time dilation, and no accelerated frames beyond the instantaneous turn on the Twin Paradox screen. - **The turn is instantaneous.** A real turnaround takes time and involves proper acceleration; here it is a corner. Smoothing it would change the numbers slightly but not the argument. -- **Length contraction is never shown.** Every screen is arranged so it does not enter: the light - clock's mirrors are transverse to the motion, and the diagrams show coordinates rather than rulers. - A "ladder and barn" treatment would be a natural fifth screen. +- **The doors slam and reopen instantaneously**, and the barn and ladder are perfectly rigid. Both are + the standard idealizations of the ladder-and-barn puzzle. A real ladder is not rigid — relativity + forbids it, because a rigid rod would carry a signal along its length instantly — but nothing on the + screen turns on the difference: no door ever touches the ladder in either frame. +- **Length contraction appears on exactly one screen.** The other four are arranged so it does not + enter — the light clock's mirrors are transverse to the motion, and the diagrams show coordinates + rather than rulers — so that time dilation can be established without it, and it can then be + introduced on its own terms rather than as a second effect tangled into the first. - **Light propagation is not raytraced.** The Doppler screen draws wavefronts and computes what one observer receives; it does not render the visual distortion (Terrell rotation) of an extended object seen at relativistic speed. @@ -286,5 +358,6 @@ draws. ## References - Taylor & Wheeler, *Spacetime Physics*, 2nd ed. — the invariant interval and the light clock. -- Rindler, *Relativity: Special, General and Cosmological*, 2nd ed. — Doppler shift and aberration. +- Rindler, *Relativity: Special, General and Cosmological*, 2nd ed. — Doppler shift and aberration; + §3.5 for the pole-and-barn (here ladder-and-barn) paradox and its resolution by simultaneity. - Rybicki & Lightman, *Radiative Processes in Astrophysics*, §4.8 — beaming and the D⁴ convention. diff --git a/package.json b/package.json index 0bd65ef..910b3a0 100644 --- a/package.json +++ b/package.json @@ -3,7 +3,7 @@ "private": true, "version": "0.0.0", "type": "module", - "description": "Explore special relativity across four screens: a moving light clock, an interactive Minkowski spacetime diagram, the twin paradox, and the relativistic Doppler effect.", + "description": "Explore special relativity across five screens: a moving light clock, an interactive Minkowski spacetime diagram, the ladder-and-barn length contraction paradox, the twin paradox, and the relativistic Doppler effect.", "license": "AGPL-3.0-or-later", "repository": { "type": "git", diff --git a/src/SpecialRelativityColors.ts b/src/SpecialRelativityColors.ts index 2e8414b..d379741 100644 --- a/src/SpecialRelativityColors.ts +++ b/src/SpecialRelativityColors.ts @@ -17,7 +17,9 @@ * yellow light itself — the light cone, photons, wavefronts * violet invariant hyperbolas: the structure every frame agrees on * green proper time — the travelling twin's clock, the moving clock - * orange / red the draggable events A and B, and the turnaround + * orange / red the draggable events A and B, the turnaround, the door slams + * slate apparatus at rest in the lab — mirrors, the barn + * amber the ladder: the object that is moving, and being contracted * * A student who learns "cyan means the other observer" on the Spacetime Diagram * screen should not have to relearn it on the Twin Paradox screen. @@ -199,12 +201,38 @@ const SpecialRelativityColors = { projector: "#bdbdbd", }), - /** Apparatus: light-clock mirrors and their supporting frame. */ + /** Apparatus: light-clock mirrors and their supporting frame, and the barn. */ apparatusColorProperty: new ProfileColorProperty(SpecialRelativityNamespace, "apparatus", { default: "#90a4ae", projector: "#546e7a", }), + /** + * The ladder on the Length Contraction screen — the object that is moving, and + * the one whose measured length is in dispute. Amber rather than one of the + * event colours because it is a thing rather than an event, and distinct from + * the barn's slate so "which of these two is contracted?" is answered by hue. + */ + ladderColorProperty: new ProfileColorProperty(SpecialRelativityNamespace, "ladder", { + default: "#ffb74d", + projector: "#e65100", + }), + + /** + * Fills of the two world-sheets on the Length Contraction diagram: the band each + * object's ends sweep out through spacetime. Deliberately faint — they are the + * region the slices are read against, not figures in their own right. + */ + ladderSheetFillColorProperty: new ProfileColorProperty(SpecialRelativityNamespace, "ladderSheetFill", { + default: "rgba(255,183,77,0.16)", + projector: "rgba(230,81,0,0.13)", + }), + + barnSheetFillColorProperty: new ProfileColorProperty(SpecialRelativityNamespace, "barnSheetFill", { + default: "rgba(144,164,174,0.15)", + projector: "rgba(84,110,122,0.13)", + }), + /** * Stand-in colours for light that has been shifted out of the visible band. * Deliberately dark and desaturated rather than an arbitrary visible hue: the diff --git a/src/SpecialRelativityConstants.ts b/src/SpecialRelativityConstants.ts index b9e411b..0980b6d 100644 --- a/src/SpecialRelativityConstants.ts +++ b/src/SpecialRelativityConstants.ts @@ -138,6 +138,64 @@ export const LIGHT_CLOCK = { TRACK_HALF_LENGTH: 2.1, } as const; +// ── Length Contraction screen ───────────────────────────────────────────────── + +export const LADDER_BARN = { + /** + * Proper length of the barn, in light-seconds. Fixed rather than adjustable: + * only the *ratio* of the two lengths matters, so a second length slider would + * offer a second way to reach states the ladder slider already reaches, and the + * barn is the better one to hold still because it is the frame the diagram is + * drawn in. + */ + BARN_LENGTH: 4, + + /** + * Proper length of the ladder, in light-seconds. The default pairs with + * {@link LADDER_BARN.DEFAULT_BETA} to put the paradox on screen in round + * numbers: at β = 0.8, γ = 5/3, so the 5 ls ladder is measured at exactly 3 ls + * in the barn frame and the 4 ls barn at exactly 2.4 ls in the ladder frame. + */ + LADDER_LENGTH: 5, + MIN_LADDER_LENGTH: 2, + MAX_LADDER_LENGTH: 8, + LADDER_LENGTH_DELTA: 0.5, + + /** + * Speed of the ladder through the barn. Kept strictly positive — at β = 0 the + * ladder never reaches the barn and the pass window is infinite — and capped a + * little below the sim-wide 0.99, because in the ladder's frame the two door + * slams are γβB apart: at 0.99 that window is four times longer than the fly-past + * it brackets, and the animation becomes mostly waiting. + */ + DEFAULT_BETA: 0.8, + MIN_BETA: 0.1, + MAX_BETA: 0.95, + + /** + * How long a door is *drawn* shut either side of its slam, in seconds of the + * frame being watched. A slam is an instant and has no duration; this is a + * display convention, and the only one on the screen. Without it the moment the + * whole experiment turns on would occupy a single frame of animation and nobody + * would ever see it. + */ + DOOR_FLASH_HALF_WIDTH: 0.35, + + /** Pixels per light-second in the stage above the diagram. */ + STAGE_VIEW_SCALE: 46, + + /** Half-width of the stage's window on space, in light-seconds. */ + STAGE_HALF_EXTENT: 7, + + /** Barn wall/door thickness and height in the stage, in pixels. */ + DOOR_WIDTH: 7, + BARN_HEIGHT: 74, + + /** Ladder bar thickness in the stage, in pixels, and how many rungs it carries. */ + LADDER_HEIGHT: 16, + LADDER_RUNGS: 7, +} as const; + // ── Twin Paradox screen ─────────────────────────────────────────────────────── export const TWIN = { @@ -232,6 +290,7 @@ SpecialRelativityNamespace.register("SpecialRelativityConstants", { LIGHTLIKE_TOLERANCE, EVENT, LIGHT_CLOCK, + LADDER_BARN, TWIN, MAX_REUNION_TIME, DOPPLER, diff --git a/src/common/SpecialRelativityScreenIcons.ts b/src/common/SpecialRelativityScreenIcons.ts index dc216a2..1300763 100644 --- a/src/common/SpecialRelativityScreenIcons.ts +++ b/src/common/SpecialRelativityScreenIcons.ts @@ -104,6 +104,52 @@ export function createSpacetimeDiagramIcon(): ScreenIcon { ); } +/** + * A barn with a door at each end and a ladder inside it, drawn against the ghost + * of the same ladder at its uncontracted length — the whole screen in one picture: + * the ladder that fits is the same ladder as the one that does not. + */ +export function createLengthContractionIcon(): ScreenIcon { + const baseline = 268; + const barnTop = 118; + const barnLeft = 158; + const barnRight = 390; + const ladderY = (baseline + barnTop) / 2; + return iconFrom( + new Node({ + children: [ + background(), + // The ladder at rest — longer than the barn, and shown only as an outline + // because it is the length nobody in this picture measures. + new Rectangle(120, ladderY - 17, 340, 34, { + stroke: SpecialRelativityColors.secondaryTextColorProperty, + lineWidth: 4, + lineDash: [14, 10], + }), + new Path( + polyline([ + [barnLeft, baseline], + [barnLeft, barnTop], + [barnRight, barnTop], + [barnRight, baseline], + ]), + { stroke: SpecialRelativityColors.apparatusColorProperty, lineWidth: 11 }, + ), + new Rectangle(barnLeft + 22, ladderY - 15, barnRight - barnLeft - 44, 30, { + fill: SpecialRelativityColors.ladderColorProperty, + }), + // Both doors shut at once: the barn frame's version of the story. + new Rectangle(barnLeft - 9, barnTop, 18, baseline - barnTop, { + fill: SpecialRelativityColors.eventBColorProperty, + }), + new Rectangle(barnRight - 9, barnTop, 18, baseline - barnTop, { + fill: SpecialRelativityColors.eventBColorProperty, + }), + ], + }), + ); +} + /** One straight worldline and one with a corner, from the same start to the same end. */ export function createTwinParadoxIcon(): ScreenIcon { const startY = 320; diff --git a/src/common/view/controlHelpers.ts b/src/common/view/controlHelpers.ts index 151e342..6727cfe 100644 --- a/src/common/view/controlHelpers.ts +++ b/src/common/view/controlHelpers.ts @@ -10,7 +10,7 @@ import type { PhetioProperty, TReadOnlyProperty } from "scenerystack/axon"; import { Dimension2, type Range } from "scenerystack/dot"; import { HBox, type Node, Text, type TPaint } from "scenerystack/scenery"; import { NumberControl } from "scenerystack/scenery-phet"; -import { Checkbox, RectangularPushButton } from "scenerystack/sun"; +import { Checkbox, RectangularPushButton, VerticalAquaRadioButtonGroup } from "scenerystack/sun"; import SpecialRelativityColors from "../../SpecialRelativityColors.js"; import { FONTS } from "../../SpecialRelativityConstants.js"; import { FLAT_RECTANGULAR_BUTTON_OPTIONS, LIGHT_SURFACE_TEXT_FILL } from "../SpecialRelativityButtonOptions.js"; @@ -96,16 +96,21 @@ export const createCheckbox = ( ); /** - * A themed flat push button carrying a text label. The two "boost to …" buttons - * on the Spacetime Diagram screen are the only ones in the sim, and they are a - * matched pair, so they are built by one factory rather than configured twice. + * A themed flat push button carrying a text label. The "boost to …" pair on the + * Spacetime Diagram screen and the "go to this slam" pair on the Length + * Contraction screen are built by this one factory rather than configured four + * times. + * + * `enabledProperty` is optional: the boost buttons grey each other out because + * exactly one of them is reachable at a time, but a button that is always + * available should not have to invent an always-true Property to say so. */ export const createPushButton = ( labelProperty: TReadOnlyProperty, config: { accessibleName: TReadOnlyProperty; accessibleHelpText: TReadOnlyProperty; - enabledProperty: TReadOnlyProperty; + enabledProperty?: TReadOnlyProperty; listener: () => void; maxTextWidth?: number; }, @@ -118,12 +123,56 @@ export const createPushButton = ( maxWidth: config.maxTextWidth ?? CONTROL_WIDTH - 24, }), baseColor: SpecialRelativityColors.controlSurfaceColorProperty, - enabledProperty: config.enabledProperty, + // Spread rather than assign: `exactOptionalPropertyTypes` rejects an explicit + // undefined, and "always enabled" must mean "absent". + ...(config.enabledProperty ? { enabledProperty: config.enabledProperty } : {}), listener: config.listener, accessibleName: config.accessibleName, accessibleHelpText: config.accessibleHelpText, }); +/** + * A themed vertical group of radio buttons — the control for a choice between + * named alternatives rather than a value on a scale. + * + * The Length Contraction screen's frame selector is the sim's only one, and it is + * a radio group rather than a checkbox or a switch on purpose: "barn frame" and + * "ladder frame" are two peers, and neither is the off state of the other. + */ +export const createRadioButtonGroup = ( + property: PhetioProperty, + items: readonly { value: T; labelProperty: TReadOnlyProperty; accessibleName: TReadOnlyProperty }[], + config: { + accessibleName: TReadOnlyProperty; + accessibleHelpText: TReadOnlyProperty; + width?: number; + }, +): VerticalAquaRadioButtonGroup => + new VerticalAquaRadioButtonGroup( + property, + items.map((item) => ({ + value: item.value, + createNode: () => + new Text(item.labelProperty, { + font: FONTS.READOUT, + fill: SpecialRelativityColors.textColorProperty, + maxWidth: (config.width ?? CONTROL_WIDTH) - 40, + }), + options: { accessibleName: item.accessibleName }, + })), + { + spacing: 7, + align: "left", + radioButtonOptions: { + selectedColor: SpecialRelativityColors.accentColorProperty, + deselectedColor: SpecialRelativityColors.controlSurfaceColorProperty, + stroke: SpecialRelativityColors.diagramAxisColorProperty, + }, + accessibleName: config.accessibleName, + accessibleHelpText: config.accessibleHelpText, + }, + ); + /** * A "label ......... value" row. The label and value are pushed to opposite ends * of a fixed width so a column of these reads as a table rather than as ragged diff --git a/src/i18n/StringManager.ts b/src/i18n/StringManager.ts index 62d3ef1..23a114a 100644 --- a/src/i18n/StringManager.ts +++ b/src/i18n/StringManager.ts @@ -79,12 +79,14 @@ export class StringManager { public getScreenNames(): { readonly lightClockStringProperty: ReadOnlyProperty; readonly spacetimeStringProperty: ReadOnlyProperty; + readonly lengthContractionStringProperty: ReadOnlyProperty; readonly twinParadoxStringProperty: ReadOnlyProperty; readonly relativisticDopplerStringProperty: ReadOnlyProperty; } { return { lightClockStringProperty: stringProperties.screens.lightClockStringProperty, spacetimeStringProperty: stringProperties.screens.spacetimeStringProperty, + lengthContractionStringProperty: stringProperties.screens.lengthContractionStringProperty, twinParadoxStringProperty: stringProperties.screens.twinParadoxStringProperty, relativisticDopplerStringProperty: stringProperties.screens.relativisticDopplerStringProperty, }; @@ -110,6 +112,11 @@ export class StringManager { return stringProperties.spacetime; } + /** Visible labels for the Length Contraction screen. */ + public getLengthContractionStrings() { + return stringProperties.lengthContraction; + } + /** Visible labels for the Twin Paradox screen. */ public getTwinParadoxStrings() { return stringProperties.twinParadox; @@ -130,6 +137,11 @@ export class StringManager { return stringProperties.a11y.spacetime; } + /** Accessibility strings for the Length Contraction screen. */ + public getLengthContractionA11yStrings() { + return stringProperties.a11y.lengthContraction; + } + /** Accessibility strings for the Twin Paradox screen. */ public getTwinParadoxA11yStrings() { return stringProperties.a11y.twinParadox; diff --git a/src/i18n/strings_en.json b/src/i18n/strings_en.json index 9417cf1..b6b9e70 100644 --- a/src/i18n/strings_en.json +++ b/src/i18n/strings_en.json @@ -3,6 +3,7 @@ "screens": { "lightClock": "Light Clock", "spacetime": "Spacetime Diagram", + "lengthContraction": "Length Contraction", "twinParadox": "Twin Paradox", "relativisticDoppler": "Relativistic Doppler" }, @@ -68,6 +69,30 @@ "properTime": "Proper time", "properDistance": "Proper distance" }, + "lengthContraction": { + "barnFrame": "Barn frame", + "ladderFrame": "Ladder frame", + "ladderSpeed": "Ladder speed", + "ladderProperLength": "Ladder, at rest", + "ladderLength": "Ladder measures", + "barnLength": "Barn measures", + "fitsQuestion": "Fits between the doors?", + "fits": "Yes", + "doesNotFit": "No", + "sceneTime": "Clock in this frame", + "slamGap": "Between the slams", + "slamsTogether": "Both doors slam at the same moment.", + "exitSlamsFirst": "The exit door slams first.", + "entranceSlamsFirst": "The entrance door slams first.", + "goToEntranceSlam": "Entrance slam", + "goToExitSlam": "Exit slam", + "showWorldSheets": "World sheets", + "showSlice": "This frame's \"now\"", + "barnMeasure": "Barn {{value}} ls", + "ladderMeasure": "Ladder {{value}} ls", + "verdict": "The two slams are spacelike separated, so no frame's answer is the wrong one.", + "takeaway": "One ladder, one barn, two frames — and the only thing they disagree about is which events happen at the same time." + }, "twinParadox": { "turnaround": "Turn", "earthTwin": "Stay-at-home twin", @@ -149,6 +174,32 @@ "showProjections": "Coordinate projections" } }, + "lengthContraction": { + "screenSummary": { + "playArea": "The play area shows a barn with a door at each end and a ladder flying through it, drawn with the rulers and clock of whichever frame is selected. Below it a spacetime diagram, always in the barn's coordinates, carries the band each object sweeps out and the two door slams.", + "controlArea": "The control area has a frame selector, a ladder speed slider, a ladder length slider, buttons that jump the clock to each door slam, world sheet and now-slice checkboxes, time controls, and a Reset All button.", + "interactionHint": "Watch the ladder fit inside with both doors shut, then switch to the ladder frame and watch the same two slams come apart." + }, + "currentDetails": "In the {{frame}}, with the ladder at {{beta}} times the speed of light and gamma {{gamma}}, the ladder measures {{ladder}} light-seconds and the barn {{barn}}. Fits between the doors: {{verdict}}. The two door slams are {{gap}} seconds apart in this frame.", + "controls": { + "frame": "Observation frame", + "frameHelp": "Choose whose rulers and clock the scene is drawn with. The spacetime diagram below stays in the barn's coordinates either way.", + "barnFrame": "Barn frame", + "ladderFrame": "Ladder frame", + "velocity": "Ladder speed", + "velocityHelp": "Speed of the ladder through the barn, as a fraction of the speed of light.", + "ladderLength": "Ladder length at rest", + "ladderLengthHelp": "Proper length of the ladder, in light-seconds — its length measured in its own frame.", + "goToEntranceSlam": "Go to the entrance door slam", + "goToEntranceSlamHelp": "Take the clock to the instant the entrance door slams, as this frame times it.", + "goToExitSlam": "Go to the exit door slam", + "goToExitSlamHelp": "Take the clock to the instant the exit door slams, as this frame times it.", + "showWorldSheets": "World sheets", + "showWorldSheetsHelp": "Show the band each object's two ends sweep out through spacetime.", + "showSlice": "This frame's now", + "showSliceHelp": "Show the line of events this frame calls simultaneous, and the two lengths measured along it." + } + }, "twinParadox": { "screenSummary": { "playArea": "The play area shows a spacetime diagram carrying two worldlines from the same start to the same finish: the stay-at-home twin's straight vertical line, and the travelling twin's out-and-back path through a draggable turn event.", diff --git a/src/i18n/strings_es.json b/src/i18n/strings_es.json index 67d066b..54bc09d 100644 --- a/src/i18n/strings_es.json +++ b/src/i18n/strings_es.json @@ -3,6 +3,7 @@ "screens": { "lightClock": "Reloj de luz", "spacetime": "Diagrama de espacio-tiempo", + "lengthContraction": "Contracción de longitudes", "twinParadox": "Paradoja de los gemelos", "relativisticDoppler": "Doppler relativista" }, @@ -68,6 +69,30 @@ "properTime": "Tiempo propio", "properDistance": "Distancia propia" }, + "lengthContraction": { + "barnFrame": "Sistema del granero", + "ladderFrame": "Sistema de la escalera", + "ladderSpeed": "Velocidad de la escalera", + "ladderProperLength": "Escalera, en reposo", + "ladderLength": "La escalera mide", + "barnLength": "El granero mide", + "fitsQuestion": "¿Cabe entre las puertas?", + "fits": "Sí", + "doesNotFit": "No", + "sceneTime": "Reloj de este sistema", + "slamGap": "Entre los cierres", + "slamsTogether": "Las dos puertas se cierran en el mismo instante.", + "exitSlamsFirst": "La puerta de salida se cierra primero.", + "entranceSlamsFirst": "La puerta de entrada se cierra primero.", + "goToEntranceSlam": "Cierre de entrada", + "goToExitSlam": "Cierre de salida", + "showWorldSheets": "Hojas de universo", + "showSlice": "El «ahora» de este sistema", + "barnMeasure": "Granero {{value}} sl", + "ladderMeasure": "Escalera {{value}} sl", + "verdict": "Los dos cierres tienen separación de tipo espacio: ninguna de las dos respuestas es la equivocada.", + "takeaway": "Una escalera, un granero, dos sistemas — y lo único en lo que discrepan es en qué sucesos ocurren a la vez." + }, "twinParadox": { "turnaround": "Giro", "earthTwin": "Gemelo que se queda", @@ -149,6 +174,32 @@ "showProjections": "Proyecciones de coordenadas" } }, + "lengthContraction": { + "screenSummary": { + "playArea": "El área de juego muestra un granero con una puerta en cada extremo y una escalera que lo atraviesa, dibujada con las reglas y el reloj del sistema seleccionado. Debajo, un diagrama de espacio-tiempo, siempre en coordenadas del granero, lleva la banda que barre cada objeto y los dos cierres de puertas.", + "controlArea": "El área de control tiene un selector de sistema, un deslizador de velocidad de la escalera, un deslizador de longitud de la escalera, botones que llevan el reloj a cada cierre de puerta, casillas para las hojas de universo y la rebanada del «ahora», controles de tiempo y un botón Reiniciar todo.", + "interactionHint": "Observa cómo la escalera cabe dentro con las dos puertas cerradas y luego cambia al sistema de la escalera y observa cómo esos mismos dos cierres se separan." + }, + "currentDetails": "En el {{frame}}, con la escalera a {{beta}} veces la velocidad de la luz y gamma {{gamma}}, la escalera mide {{ladder}} segundos-luz y el granero {{barn}}. Cabe entre las puertas: {{verdict}}. Los dos cierres de puertas están separados {{gap}} segundos en este sistema.", + "controls": { + "frame": "Sistema de observación", + "frameHelp": "Elige con qué reglas y qué reloj se dibuja la escena. El diagrama de espacio-tiempo de abajo sigue en coordenadas del granero en ambos casos.", + "barnFrame": "Sistema del granero", + "ladderFrame": "Sistema de la escalera", + "velocity": "Velocidad de la escalera", + "velocityHelp": "Velocidad de la escalera a través del granero, como fracción de la velocidad de la luz.", + "ladderLength": "Longitud de la escalera en reposo", + "ladderLengthHelp": "Longitud propia de la escalera, en segundos-luz: su longitud medida en su propio sistema.", + "goToEntranceSlam": "Ir al cierre de la puerta de entrada", + "goToEntranceSlamHelp": "Llevar el reloj al instante en que se cierra la puerta de entrada, según lo fecha este sistema.", + "goToExitSlam": "Ir al cierre de la puerta de salida", + "goToExitSlamHelp": "Llevar el reloj al instante en que se cierra la puerta de salida, según lo fecha este sistema.", + "showWorldSheets": "Hojas de universo", + "showWorldSheetsHelp": "Mostrar la banda que barren los dos extremos de cada objeto por el espacio-tiempo.", + "showSlice": "El ahora de este sistema", + "showSliceHelp": "Mostrar la línea de sucesos que este sistema llama simultáneos, y las dos longitudes medidas a lo largo de ella." + } + }, "twinParadox": { "screenSummary": { "playArea": "El área de juego muestra un diagrama de espacio-tiempo con dos líneas de universo que van del mismo inicio al mismo final: la línea vertical recta del gemelo que se queda y el camino de ida y vuelta del gemelo viajero a través de un suceso de giro arrastrable.", diff --git a/src/i18n/strings_fr.json b/src/i18n/strings_fr.json index 4dc38e7..429100c 100644 --- a/src/i18n/strings_fr.json +++ b/src/i18n/strings_fr.json @@ -3,6 +3,7 @@ "screens": { "lightClock": "Horloge à lumière", "spacetime": "Diagramme d'espace-temps", + "lengthContraction": "Contraction des longueurs", "twinParadox": "Paradoxe des jumeaux", "relativisticDoppler": "Doppler relativiste" }, @@ -68,6 +69,30 @@ "properTime": "Temps propre", "properDistance": "Distance propre" }, + "lengthContraction": { + "barnFrame": "Référentiel de la grange", + "ladderFrame": "Référentiel de l'échelle", + "ladderSpeed": "Vitesse de l'échelle", + "ladderProperLength": "Échelle, au repos", + "ladderLength": "L'échelle mesure", + "barnLength": "La grange mesure", + "fitsQuestion": "Tient entre les portes ?", + "fits": "Oui", + "doesNotFit": "Non", + "sceneTime": "Horloge de ce référentiel", + "slamGap": "Entre les fermetures", + "slamsTogether": "Les deux portes claquent au même instant.", + "exitSlamsFirst": "La porte de sortie claque en premier.", + "entranceSlamsFirst": "La porte d'entrée claque en premier.", + "goToEntranceSlam": "Fermeture entrée", + "goToExitSlam": "Fermeture sortie", + "showWorldSheets": "Nappes d'univers", + "showSlice": "Le « maintenant » de ce référentiel", + "barnMeasure": "Grange {{value}} sl", + "ladderMeasure": "Échelle {{value}} sl", + "verdict": "Les deux fermetures sont séparées par un intervalle du genre espace : aucune des deux réponses n'est fausse.", + "takeaway": "Une échelle, une grange, deux référentiels — et leur seul désaccord porte sur les événements qui ont lieu en même temps." + }, "twinParadox": { "turnaround": "Demi-tour", "earthTwin": "Jumeau sédentaire", @@ -149,6 +174,32 @@ "showProjections": "Projections des coordonnées" } }, + "lengthContraction": { + "screenSummary": { + "playArea": "La zone de jeu montre une grange munie d'une porte à chaque extrémité et une échelle qui la traverse, tracée avec les règles et l'horloge du référentiel choisi. En dessous, un diagramme d'espace-temps, toujours dans les coordonnées de la grange, porte la bande balayée par chaque objet et les deux fermetures de portes.", + "controlArea": "La zone de commande comporte un sélecteur de référentiel, un curseur de vitesse de l'échelle, un curseur de longueur de l'échelle, des boutons qui amènent l'horloge à chaque fermeture de porte, des cases pour les nappes d'univers et la tranche du « maintenant », des commandes de temps et un bouton Tout réinitialiser.", + "interactionHint": "Regardez l'échelle tenir à l'intérieur avec les deux portes fermées, puis passez au référentiel de l'échelle et regardez ces deux mêmes fermetures se séparer." + }, + "currentDetails": "Dans le {{frame}}, avec l'échelle à {{beta}} fois la vitesse de la lumière et gamma {{gamma}}, l'échelle mesure {{ladder}} secondes-lumière et la grange {{barn}}. Tient entre les portes : {{verdict}}. Les deux fermetures sont séparées de {{gap}} secondes dans ce référentiel.", + "controls": { + "frame": "Référentiel d'observation", + "frameHelp": "Choisissez les règles et l'horloge avec lesquelles la scène est tracée. Le diagramme d'espace-temps ci-dessous reste dans les coordonnées de la grange dans les deux cas.", + "barnFrame": "Référentiel de la grange", + "ladderFrame": "Référentiel de l'échelle", + "velocity": "Vitesse de l'échelle", + "velocityHelp": "Vitesse de l'échelle à travers la grange, en fraction de la vitesse de la lumière.", + "ladderLength": "Longueur de l'échelle au repos", + "ladderLengthHelp": "Longueur propre de l'échelle, en secondes-lumière — sa longueur mesurée dans son propre référentiel.", + "goToEntranceSlam": "Aller à la fermeture de la porte d'entrée", + "goToEntranceSlamHelp": "Amener l'horloge à l'instant où la porte d'entrée claque, tel que ce référentiel le date.", + "goToExitSlam": "Aller à la fermeture de la porte de sortie", + "goToExitSlamHelp": "Amener l'horloge à l'instant où la porte de sortie claque, tel que ce référentiel le date.", + "showWorldSheets": "Nappes d'univers", + "showWorldSheetsHelp": "Montrer la bande que les deux extrémités de chaque objet balaient dans l'espace-temps.", + "showSlice": "Le maintenant de ce référentiel", + "showSliceHelp": "Montrer la ligne des événements que ce référentiel dit simultanés, et les deux longueurs mesurées le long d'elle." + } + }, "twinParadox": { "screenSummary": { "playArea": "La zone de jeu montre un diagramme d'espace-temps portant deux lignes d'univers qui vont du même départ à la même arrivée : la verticale du jumeau sédentaire et le trajet aller-retour du jumeau voyageur passant par un événement de demi-tour déplaçable.", diff --git a/src/length-contraction/LengthContractionScreen.ts b/src/length-contraction/LengthContractionScreen.ts new file mode 100644 index 0000000..e64a721 --- /dev/null +++ b/src/length-contraction/LengthContractionScreen.ts @@ -0,0 +1,49 @@ +/** + * LengthContractionScreen.ts + * + * The top-level Screen component. It wires together the model and view + * factories and passes screen-level options (name, background color, tandem) + * to the parent Screen class. + * + * Registered in the screens array in src/main.ts, third of five: the resolution of + * the ladder-and-barn puzzle is relativity of simultaneity, so this screen comes + * after the Spacetime Diagram screen that introduces it. Its home-screen and + * navigation-bar icons come from createLengthContractionIcon() in + * src/common/SpecialRelativityScreenIcons.ts. + */ +import { type EmptySelfOptions, optionize } from "scenerystack/phet-core"; +import type { ScreenOptions } from "scenerystack/sim"; +import { Screen } from "scenerystack/sim"; +import type { Tandem } from "scenerystack/tandem"; +import { createLengthContractionIcon } from "../common/SpecialRelativityScreenIcons.js"; +import type { SpecialRelativityPreferencesModel } from "../preferences/SpecialRelativityPreferencesModel.js"; +import SpecialRelativityColors from "../SpecialRelativityColors.js"; +import { LengthContractionModel } from "./model/LengthContractionModel.js"; +import { LengthContractionKeyboardHelpContent } from "./view/LengthContractionKeyboardHelpContent.js"; +import { LengthContractionScreenView } from "./view/LengthContractionScreenView.js"; + +// Require tandem to be explicit — accidental omission would break PhET-iO. +type LengthContractionScreenOptions = ScreenOptions & { tandem: Tandem }; + +export class LengthContractionScreen extends Screen { + public constructor(preferences: SpecialRelativityPreferencesModel, options: LengthContractionScreenOptions) { + super( + // Model factory — called once when the screen is first shown + () => new LengthContractionModel(), + // View factory — receives the model instance + (model) => + new LengthContractionScreenView(model, preferences, { + tandem: options.tandem.createTandem("view"), + }), + optionize()( + { + backgroundColorProperty: SpecialRelativityColors.backgroundColorProperty, + createKeyboardHelpNode: () => new LengthContractionKeyboardHelpContent(), + homeScreenIcon: createLengthContractionIcon(), + navigationBarIcon: createLengthContractionIcon(), + }, + options, + ), + ); + } +} diff --git a/src/length-contraction/model/LengthContractionModel.ts b/src/length-contraction/model/LengthContractionModel.ts new file mode 100644 index 0000000..bae591d --- /dev/null +++ b/src/length-contraction/model/LengthContractionModel.ts @@ -0,0 +1,278 @@ +/** + * LengthContractionModel.ts + * + * The ladder-and-barn experiment, watched from whichever of the two frames the + * user has selected. + * + * ── One clock, re-read ──────────────────────────────────────────────────────── + * There is a single scene clock, {@link sceneTimeProperty}, and it is read as + * **whichever frame is selected** — barn time ct, or ladder time ct′. That is not + * a shortcut: the two frames' clocks are both zeroed on the event "the ladder's + * centre passes the barn's centre", which is one event and so is something they + * can agree about. Every other instant they label differently, and flipping the + * frame toggle without touching the clock is precisely the experience the screen + * is for — the number stays put and the scene rearranges itself around it. + * + * Everything geometric is a closed form of that one number, as everywhere else in + * this sim: no integration, no per-frame state, and step-backward for free. + */ + +import { BooleanProperty, DerivedProperty, NumberProperty, Property, type TReadOnlyProperty } from "scenerystack/axon"; +import { Range, type Vector2 } from "scenerystack/dot"; +import type { TModel } from "scenerystack/joist"; +import { gammaOf } from "../../common/model/lorentz.js"; +import { TimeModel } from "../../common/TimeModel.js"; +import { LADDER_BARN } from "../../SpecialRelativityConstants.js"; +import { + contractedLength, + entranceSlamEvent, + exitSlamEvent, + fitsIn, + isEntirelyInside, + type LadderBarnSetup, + ObservationFrame, + type SlamTimes, + type Snapshot, + sceneHalfWindow, + slamTimes, + snapshotAt, +} from "./ladderBarnGeometry.js"; + +/** Speeds the ladder may be sent through the barn at. See {@link LADDER_BARN}. */ +export const LADDER_BETA_RANGE = new Range(LADDER_BARN.MIN_BETA, LADDER_BARN.MAX_BETA); + +/** Proper lengths the ladder may be built at, in light-seconds. */ +export const LADDER_LENGTH_RANGE = new Range(LADDER_BARN.MIN_LADDER_LENGTH, LADDER_BARN.MAX_LADDER_LENGTH); + +/** Which door is drawn shut right now. Both, in the barn frame, at t = 0. */ +export type DoorStates = { + readonly entranceClosed: boolean; + readonly exitClosed: boolean; +}; + +export class LengthContractionModel implements TModel { + /** Play/pause and speed. The elapsed time itself is {@link sceneTimeProperty}. */ + public readonly timer = new TimeModel(true); + + /** + * Speed of the ladder through the barn, as a fraction of c. Positive: the + * direction adds nothing here, and fixing it lets "entrance" and "exit" name the + * two doors rather than having to be worked out each time. + */ + public readonly betaProperty = new NumberProperty(LADDER_BARN.DEFAULT_BETA, { range: LADDER_BETA_RANGE }); + + /** Proper length of the ladder, in light-seconds. */ + public readonly ladderLengthProperty = new NumberProperty(LADDER_BARN.LADDER_LENGTH, { range: LADDER_LENGTH_RANGE }); + + /** Whose rulers and clocks the scene is described with. The screen's main control. */ + public readonly frameProperty = new Property(ObservationFrame.BARN); + + /** + * The current instant, in seconds on the **selected frame's** clock. Runs from + * −sceneHalfWindow to +sceneHalfWindow and wraps, so the pass repeats without + * anyone having to press anything. + */ + public readonly sceneTimeProperty = new NumberProperty(0, { units: "s" }); + + /** γ for the current β — the factor both contractions are by. */ + public readonly gammaProperty: TReadOnlyProperty; + + /** The experiment as configured, bundled for the pure geometry functions. */ + public readonly setupProperty: TReadOnlyProperty; + + /** Half-width of the time window the scene loops over, on the selected frame's clock. */ + public readonly sceneHalfWindowProperty: TReadOnlyProperty; + + /** Where both objects are right now, as the selected frame measures them. */ + public readonly snapshotProperty: TReadOnlyProperty; + + /** Length of the ladder as the selected frame measures it: L₀ or L₀/γ. */ + public readonly measuredLadderLengthProperty: TReadOnlyProperty; + + /** Length of the barn as the selected frame measures it: B or B/γ. */ + public readonly measuredBarnLengthProperty: TReadOnlyProperty; + + /** Whether the ladder is wholly between the doors at this very instant. */ + public readonly isEntirelyInsideProperty: TReadOnlyProperty; + + /** Whether the ladder ever fits, as the selected frame measures things. */ + public readonly fitsProperty: TReadOnlyProperty; + + /** Whether the ladder fits in the barn frame — the answer that does not depend on the toggle. */ + public readonly fitsInBarnFrameProperty: TReadOnlyProperty; + + /** Whether the ladder fits in the ladder frame. */ + public readonly fitsInLadderFrameProperty: TReadOnlyProperty; + + /** When each door slams, on the selected frame's clock. */ + public readonly slamTimesProperty: TReadOnlyProperty; + + /** + * How long the selected frame says passed between the two slams: exit first is + * positive. Zero in the barn frame by construction; γβB in the ladder frame. + */ + public readonly slamGapProperty: TReadOnlyProperty; + + /** Which doors are drawn shut right now — see {@link LADDER_BARN.DOOR_FLASH_HALF_WIDTH}. */ + public readonly doorStatesProperty: TReadOnlyProperty; + + /** Whether the two world-sheets are drawn on the diagram. */ + public readonly showWorldSheetsProperty = new BooleanProperty(true); + + /** + * Whether the selected frame's line of simultaneity — the slice the current + * measurement is taken on — is drawn across the diagram. On by default: it is + * the one line that explains the whole disagreement. + */ + public readonly showSliceProperty = new BooleanProperty(true); + + /** Held so dispose() can unlink the same function object that was linked. */ + private readonly clampSceneTimeListener: (half: number) => void; + + public constructor() { + this.gammaProperty = new DerivedProperty([this.betaProperty], (beta) => gammaOf(beta)); + + this.setupProperty = new DerivedProperty([this.betaProperty, this.ladderLengthProperty], (beta, ladderLength) => ({ + barnLength: LADDER_BARN.BARN_LENGTH, + ladderLength, + beta, + })); + + this.sceneHalfWindowProperty = new DerivedProperty([this.setupProperty, this.frameProperty], (setup, frame) => + sceneHalfWindow(setup, frame), + ); + + this.snapshotProperty = new DerivedProperty( + [this.setupProperty, this.frameProperty, this.sceneTimeProperty], + (setup, frame, time) => snapshotAt(setup, frame, time), + ); + + this.measuredLadderLengthProperty = new DerivedProperty([this.setupProperty, this.frameProperty], (setup, frame) => + frame === ObservationFrame.BARN ? contractedLength(setup.ladderLength, setup.beta) : setup.ladderLength, + ); + + this.measuredBarnLengthProperty = new DerivedProperty([this.setupProperty, this.frameProperty], (setup, frame) => + frame === ObservationFrame.BARN ? setup.barnLength : contractedLength(setup.barnLength, setup.beta), + ); + + this.isEntirelyInsideProperty = new DerivedProperty([this.snapshotProperty], (snapshot) => + isEntirelyInside(snapshot), + ); + + this.fitsInBarnFrameProperty = new DerivedProperty([this.setupProperty], (setup) => + fitsIn(setup, ObservationFrame.BARN), + ); + this.fitsInLadderFrameProperty = new DerivedProperty([this.setupProperty], (setup) => + fitsIn(setup, ObservationFrame.LADDER), + ); + this.fitsProperty = new DerivedProperty( + [this.fitsInBarnFrameProperty, this.fitsInLadderFrameProperty, this.frameProperty], + (inBarn, inLadder, frame) => (frame === ObservationFrame.BARN ? inBarn : inLadder), + ); + + this.slamTimesProperty = new DerivedProperty([this.setupProperty, this.frameProperty], (setup, frame) => + slamTimes(setup, frame), + ); + + this.slamGapProperty = new DerivedProperty([this.slamTimesProperty], (times) => times.entrance - times.exit); + + this.doorStatesProperty = new DerivedProperty([this.slamTimesProperty, this.sceneTimeProperty], (times, time) => ({ + entranceClosed: Math.abs(time - times.entrance) <= LADDER_BARN.DOOR_FLASH_HALF_WIDTH, + exitClosed: Math.abs(time - times.exit) <= LADDER_BARN.DOOR_FLASH_HALF_WIDTH, + })); + + // Changing the speed, the ladder, or the frame moves the ends of the window + // the clock runs in; the clock is pulled back inside rather than left to sit + // outside it, which would strand the scene off screen until the next wrap. + this.clampSceneTimeListener = (half: number): void => this.clampSceneTime(half); + this.sceneHalfWindowProperty.link(this.clampSceneTimeListener); + } + + /** The two slam events in barn-frame coordinates, for the diagram. */ + public slamEvents(): { readonly entrance: Vector2; readonly exit: Vector2 } { + const setup = this.setupProperty.value; + return { entrance: entranceSlamEvent(setup), exit: exitSlamEvent(setup) }; + } + + /** + * Take the scene clock to the instant a door slams, on the selected frame's + * clock. In the barn frame both buttons land on the same instant — which is not + * a bug to hide but the shortest statement of what "the doors are on one switch" + * means, and what the ladder frame then disagrees with. + */ + public goToSlam(door: "entrance" | "exit"): void { + this.timer.isPlayingProperty.value = false; + this.sceneTimeProperty.value = this.slamTimesProperty.value[door]; + this.clampSceneTime(this.sceneHalfWindowProperty.value); + } + + public step(dt: number): void { + this.advance(this.timer.scaledDt(dt)); + } + + public stepForward(dt: number): void { + this.advance(dt); + } + + public stepBackward(dt: number): void { + this.advance(-dt); + } + + /** Move the scene clock, wrapping round the ends of the pass so it repeats. */ + private advance(seconds: number): void { + if (seconds === 0) { + return; + } + const half = this.sceneHalfWindowProperty.value; + const span = 2 * half; + if (!Number.isFinite(span) || span <= 0) { + return; + } + // Modulo rather than a comparison, so a large dt after a background tab + // regains focus lands in the right place instead of skipping the window. + const offset = (((this.sceneTimeProperty.value + seconds + half) % span) + span) % span; + this.sceneTimeProperty.value = offset - half; + } + + /** Keep the clock inside the current window without wrapping it to the far end. */ + private clampSceneTime(half: number): void { + if (!Number.isFinite(half)) { + return; + } + this.sceneTimeProperty.value = Math.max(-half, Math.min(half, this.sceneTimeProperty.value)); + } + + public reset(): void { + this.timer.reset(); + this.betaProperty.reset(); + this.ladderLengthProperty.reset(); + this.frameProperty.reset(); + this.sceneTimeProperty.reset(); + this.showWorldSheetsProperty.reset(); + this.showSliceProperty.reset(); + } + + public dispose(): void { + this.sceneHalfWindowProperty.unlink(this.clampSceneTimeListener); + this.doorStatesProperty.dispose(); + this.slamGapProperty.dispose(); + this.slamTimesProperty.dispose(); + this.fitsProperty.dispose(); + this.fitsInLadderFrameProperty.dispose(); + this.fitsInBarnFrameProperty.dispose(); + this.isEntirelyInsideProperty.dispose(); + this.measuredBarnLengthProperty.dispose(); + this.measuredLadderLengthProperty.dispose(); + this.snapshotProperty.dispose(); + this.sceneHalfWindowProperty.dispose(); + this.setupProperty.dispose(); + this.gammaProperty.dispose(); + this.showWorldSheetsProperty.dispose(); + this.showSliceProperty.dispose(); + this.sceneTimeProperty.dispose(); + this.frameProperty.dispose(); + this.ladderLengthProperty.dispose(); + this.betaProperty.dispose(); + this.timer.dispose(); + } +} diff --git a/src/length-contraction/model/ladderBarnGeometry.ts b/src/length-contraction/model/ladderBarnGeometry.ts new file mode 100644 index 0000000..75b0f30 --- /dev/null +++ b/src/length-contraction/model/ladderBarnGeometry.ts @@ -0,0 +1,282 @@ +/** + * ladderBarnGeometry.ts + * + * Pure geometry for the ladder-and-barn experiment: a ladder of proper length L₀ + * flying at β through a barn of proper length B, whose two doors are wired to one + * switch and slam shut **together in the barn's frame**. + * + * ── The one arrangement the whole screen rests on ───────────────────────────── + * Take the origin of the barn frame to be the event "the ladder's centre passes + * the barn's centre". Then the barn's doors sit at x = ∓B/2 for all time, and the + * two slams are the events + * + * entrance slam ( −B/2, 0 ) exit slam ( +B/2, 0 ) + * + * Their separation is Δx = B, Δct = 0. That is **spacelike for every non-zero + * barn**, which is the fact the screen exists to make concrete: no frame's answer + * to "which door shut first?" is the wrong one, because no signal could have + * travelled from one slam to the other. + * + * Everything else is bookkeeping around those two events: + * + * - in the **barn** frame the ladder is contracted to L₀/γ, so it is briefly + * inside with both doors shut, provided L₀/γ < B; + * - in the **ladder** frame the *barn* is contracted to B/γ, so the ladder never + * fits, and the slams are 2·γβB/2 = γβB apart — the exit door shutting and + * reopening while the tail is still outside, the entrance door long after the + * nose is out. + * + * Both frames are right. They disagree about which events are simultaneous, and + * about nothing else. + * + * ── Conventions ─────────────────────────────────────────────────────────────── + * Natural units, c = 1: lengths in light-seconds, times in seconds. An event is a + * `Vector2( x, ct )` in **barn-frame** coordinates, matching the rest of the sim — + * the screen's spacetime diagram is always drawn in the barn frame, and the + * ladder frame appears on it as a sheared mesh rather than as a second diagram. + */ + +import { Vector2 } from "scenerystack/dot"; +import { boostEvent, gammaOf, sanitizeBeta } from "../../common/model/lorentz.js"; + +/** Which observer's rulers and clocks the scene is being described with. */ +export const ObservationFrame = { + /** At rest with the barn. The ladder moves at +β and is contracted. */ + BARN: "barn", + /** At rest with the ladder. The barn moves at −β and is contracted. */ + LADDER: "ladder", +} as const; + +export type ObservationFrame = (typeof ObservationFrame)[keyof typeof ObservationFrame]; + +/** The experiment as configured: two proper lengths and a speed. */ +export type LadderBarnSetup = { + /** Proper length of the barn, in light-seconds. At rest in the barn frame. */ + readonly barnLength: number; + /** Proper length of the ladder, in light-seconds. At rest in the ladder frame. */ + readonly ladderLength: number; + /** Speed of the ladder through the barn, as a fraction of c. Positive, to the right. */ + readonly beta: number; +}; + +/** One object's extent along x at one instant, in the frame doing the measuring. */ +export type Span = { + readonly left: number; + readonly right: number; +}; + +/** Where both objects are at one instant of some frame's time. */ +export type Snapshot = { + readonly barn: Span; + readonly ladder: Span; +}; + +/** Length of a span. Always non-negative for the spans this module produces. */ +export const spanLength = (span: Span): number => span.right - span.left; + +/** + * Length of a rod of proper length `properLength` as measured in a frame it moves + * through at β: L₀/γ, the one formula on this screen. + * + * Note which way round it goes. A rod is *shortest* in the frames it moves fastest + * through and longest in its own, so "the ladder is 3 ls long" and "the ladder is + * 5 ls long" are both true statements about the same ladder, made by observers who + * measured it differently — not a disagreement about the ladder. + */ +export const contractedLength = (properLength: number, beta: number): number => properLength / gammaOf(beta); + +/** + * Where the barn and the ladder are at time `time` on `frame`'s clock, with x + * measured by that same frame. + * + * The two frames' clocks are set so that t = t′ = 0 is the single event "the + * ladder's centre passes the barn's centre" — the one instant both frames can + * point at without argument, which is what makes a single scene-time number + * meaningful when the frame toggle is flipped. + */ +export const snapshotAt = (setup: LadderBarnSetup, frame: ObservationFrame, time: number): Snapshot => { + const beta = sanitizeBeta(setup.beta); + const gamma = gammaOf(beta); + + if (frame === ObservationFrame.BARN) { + const barnHalf = setup.barnLength / 2; + const ladderHalf = setup.ladderLength / (2 * gamma); + const ladderCentre = beta * time; + return { + barn: { left: -barnHalf, right: barnHalf }, + ladder: { left: ladderCentre - ladderHalf, right: ladderCentre + ladderHalf }, + }; + } + + // In the ladder's frame the ladder stands still at its full proper length and + // the barn sweeps past to the left, contracted. + const barnHalf = setup.barnLength / (2 * gamma); + const ladderHalf = setup.ladderLength / 2; + const barnCentre = -beta * time; + return { + barn: { left: barnCentre - barnHalf, right: barnCentre + barnHalf }, + ladder: { left: -ladderHalf, right: ladderHalf }, + }; +}; + +/** The entrance door's slam, in barn-frame ( x, ct ). The ladder arrives from the left. */ +export const entranceSlamEvent = (setup: LadderBarnSetup): Vector2 => new Vector2(-setup.barnLength / 2, 0); + +/** The exit door's slam, in barn-frame ( x, ct ). */ +export const exitSlamEvent = (setup: LadderBarnSetup): Vector2 => new Vector2(setup.barnLength / 2, 0); + +/** When each door slams, on `frame`'s clock. */ +export type SlamTimes = { + readonly entrance: number; + readonly exit: number; +}; + +/** + * The two slam times as `frame` reads them. + * + * In the barn frame they are both zero — the doors are on one switch, and that is + * what the switch means. In the ladder frame they are ±γβB/2: the exit door slams + * **first**, by γβB, and there is no instant on the ladder's clock at which both + * doors are shut. + * + * Computed by boosting the two events rather than by writing ±γβB/2 out, so this + * cannot drift away from {@link boostEvent}. + */ +export const slamTimes = (setup: LadderBarnSetup, frame: ObservationFrame): SlamTimes => { + if (frame === ObservationFrame.BARN) { + return { entrance: entranceSlamEvent(setup).y, exit: exitSlamEvent(setup).y }; + } + const beta = sanitizeBeta(setup.beta); + return { + entrance: boostEvent(entranceSlamEvent(setup), beta).y, + exit: boostEvent(exitSlamEvent(setup), beta).y, + }; +}; + +/** True when the ladder lies wholly between the two doors at that instant. */ +export const isEntirelyInside = (snapshot: Snapshot): boolean => + snapshot.ladder.left >= snapshot.barn.left && snapshot.ladder.right <= snapshot.barn.right; + +/** + * Whether the ladder ever fits between the doors, as `frame` measures things. + * + * The barn frame compares L₀/γ with B; the ladder frame compares L₀ with B/γ. Both + * comparisons are of two lengths measured at one instant of the frame making them, + * which is the only way a length can be measured — and the reason the two frames + * can answer differently without either being wrong. + */ +export const fitsIn = (setup: LadderBarnSetup, frame: ObservationFrame): boolean => { + const gamma = gammaOf(setup.beta); + return frame === ObservationFrame.BARN + ? setup.ladderLength / gamma <= setup.barnLength + : setup.ladderLength <= setup.barnLength / gamma; +}; + +/** + * Half-width of the time window, on `frame`'s clock, over which the ladder and the + * barn overlap at all — from "the near end of one reaches the far end of the + * other" to the mirror-image moment on the way out. Symmetric about t = 0 because + * the origin was chosen at the centres' crossing. + * + * Returns Infinity at β = 0, where nothing passes anything; callers bound β away + * from zero rather than special-casing the result. + */ +export const passHalfWindow = (setup: LadderBarnSetup, frame: ObservationFrame): number => { + const beta = Math.abs(sanitizeBeta(setup.beta)); + const gamma = gammaOf(beta); + const combined = + frame === ObservationFrame.BARN + ? setup.barnLength + setup.ladderLength / gamma + : setup.ladderLength + setup.barnLength / gamma; + return combined / (2 * beta); +}; + +/** Fraction of extra time left either side of the pass, so it does not start mid-air. */ +const SCENE_WINDOW_MARGIN = 1.08; + +/** + * Half-width of the time window the screen animates over, on `frame`'s clock: + * wide enough for the whole pass *and* for both door slams, whichever reaches + * further, plus a little air. + * + * The two are not the same bound. In the ladder's frame the slams are γβB apart + * while the barn goes by in (L₀ + B/γ)/β, and past β ≈ 0.9 the slams are the wider + * of the two — which is itself worth seeing, since it says the entrance door shuts + * long after the barn has left the ladder behind. + */ +export const sceneHalfWindow = (setup: LadderBarnSetup, frame: ObservationFrame): number => { + const { entrance, exit } = slamTimes(setup, frame); + const reach = Math.max(passHalfWindow(setup, frame), Math.abs(entrance), Math.abs(exit)); + return reach * SCENE_WINDOW_MARGIN; +}; + +/** + * The two ends of an object, at one instant of `frame`, expressed as **barn-frame** + * events so they can be drawn on the diagram. + * + * This is the step the diagram is for. A "length" is two ends taken at one instant, + * and which pairs of ends count as simultaneous is exactly what the two frames + * disagree about — so the ladder frame's measuring stick lands on the diagram as a + * *tilted* segment, and its shorter appearance against the barn's vertical strip is + * the disagreement drawn rather than asserted. + */ +const sliceInLab = ( + setup: LadderBarnSetup, + frame: ObservationFrame, + time: number, + span: (snapshot: Snapshot) => Span, +): [Vector2, Vector2] => { + const { left, right } = span(snapshotAt(setup, frame, time)); + if (frame === ObservationFrame.BARN) { + return [new Vector2(left, time), new Vector2(right, time)]; + } + // Ladder-frame coordinates back into the barn frame: the inverse boost is the + // boost by −β. + const beta = sanitizeBeta(setup.beta); + return [boostEvent(new Vector2(left, time), -beta), boostEvent(new Vector2(right, time), -beta)]; +}; + +/** The ladder's two ends at one instant of `frame`, in barn-frame coordinates. */ +export const ladderSliceInLab = (setup: LadderBarnSetup, frame: ObservationFrame, time: number): [Vector2, Vector2] => + sliceInLab(setup, frame, time, (snapshot) => snapshot.ladder); + +/** The barn's two doors at one instant of `frame`, in barn-frame coordinates. */ +export const barnSliceInLab = (setup: LadderBarnSetup, frame: ObservationFrame, time: number): [Vector2, Vector2] => + sliceInLab(setup, frame, time, (snapshot) => snapshot.barn); + +/** + * The four corners of the band an object's two ends sweep out over + * ct ∈ [−extent, +extent], as a closed polygon in barn-frame coordinates. + * + * The barn's band is an upright strip and the ladder's is a leaning one; where they + * cross is every event at which some part of the ladder is inside the barn. The + * containment question is then plainly a question about which *slice* of that + * crossing you take, which is the whole resolution in one picture. + */ +const sheetCorners = (leftAt: (ct: number) => number, rightAt: (ct: number) => number, extent: number): Vector2[] => [ + new Vector2(leftAt(-extent), -extent), + new Vector2(rightAt(-extent), -extent), + new Vector2(rightAt(extent), extent), + new Vector2(leftAt(extent), extent), +]; + +/** The barn's world-sheet: an upright strip of width B centred on x = 0. */ +export const barnSheet = (setup: LadderBarnSetup, ctExtent: number): Vector2[] => { + const half = setup.barnLength / 2; + return sheetCorners( + () => -half, + () => half, + ctExtent, + ); +}; + +/** The ladder's world-sheet: a strip of barn-frame width L₀/γ leaning over by β. */ +export const ladderSheet = (setup: LadderBarnSetup, ctExtent: number): Vector2[] => { + const beta = sanitizeBeta(setup.beta); + const half = contractedLength(setup.ladderLength, beta) / 2; + return sheetCorners( + (ct) => beta * ct - half, + (ct) => beta * ct + half, + ctExtent, + ); +}; diff --git a/src/length-contraction/view/LadderBarnStageNode.ts b/src/length-contraction/view/LadderBarnStageNode.ts new file mode 100644 index 0000000..8f0c65d --- /dev/null +++ b/src/length-contraction/view/LadderBarnStageNode.ts @@ -0,0 +1,227 @@ +/** + * LadderBarnStageNode.ts + * + * The scene itself: a barn with a door at each end, and a ladder going through it, + * drawn with the rulers and the clock of whichever frame is selected. + * + * ── What is a display convention here, and what is not ──────────────────────── + * The positions and the lengths are the physics, straight out of + * {@link ladderBarnGeometry}. Exactly one thing on this node is a convention: a + * slam is an *instant*, and an instant occupies one frame of animation, so each + * door is drawn shut for a short window either side of its slam. Widening a point + * into a window is the only way the moment the whole experiment turns on can be + * seen at all — but it is worth knowing that the window is drawing, not doors. + * + * The measure lines under the barn and over the ladder are drawn from the same + * spans as the objects, so the numbers cannot say one thing while the picture + * shows another. + */ + +import { Multilink, type TReadOnlyProperty } from "scenerystack/axon"; +import { Bounds2 } from "scenerystack/dot"; +import { Shape } from "scenerystack/kite"; +import { Line, Node, type NodeOptions, Path, Rectangle, Text } from "scenerystack/scenery"; +import SpecialRelativityColors from "../../SpecialRelativityColors.js"; +import { FONTS, LADDER_BARN } from "../../SpecialRelativityConstants.js"; +import type { DoorStates } from "../model/LengthContractionModel.js"; +import { type Snapshot, type Span, spanLength } from "../model/ladderBarnGeometry.js"; + +/** Fraction of the barn's height a *open* door stub occupies at top and bottom. */ +const OPEN_DOOR_STUB = 0.22; + +/** Gap in pixels between an object and the measure line that reports its length. */ +const MEASURE_OFFSET = 16; + +/** Half-length in pixels of the end ticks on a measure line. */ +const MEASURE_TICK = 5; + +export type LadderBarnStageNodeOptions = { + /** Where both objects are, in the selected frame, right now. */ + snapshotProperty: TReadOnlyProperty; + /** Which doors are drawn shut right now. */ + doorStatesProperty: TReadOnlyProperty; + /** Whether the ladder is wholly between the doors at this instant. */ + isEntirelyInsideProperty: TReadOnlyProperty; + /** "Barn: 4.0 ls", already localized and formatted. */ + barnLabelProperty: TReadOnlyProperty; + /** "Ladder: 3.0 ls", already localized and formatted. */ + ladderLabelProperty: TReadOnlyProperty; + /** Extra Node options (position, …). */ + nodeOptions?: NodeOptions; +}; + +export class LadderBarnStageNode extends Node { + public constructor(providedOptions: LadderBarnStageNodeOptions) { + super(); + + const scale = LADDER_BARN.STAGE_VIEW_SCALE; + const halfWidth = LADDER_BARN.STAGE_HALF_EXTENT * scale; + const height = LADDER_BARN.BARN_HEIGHT; + + /** Model x in light-seconds to view x in pixels, with 0 at the stage's centre. */ + const viewX = (x: number): number => x * scale; + + // ── The ground the barn stands on ───────────────────────────────────────── + const ground = new Line(-halfWidth, 0, halfWidth, 0, { + stroke: SpecialRelativityColors.trackColorProperty, + lineWidth: 2, + }); + + // ── The barn: floor, roof, and a door at each end ───────────────────────── + // The roof and floor are redrawn on every frame rather than positioned once, + // because in the ladder's frame the barn is the thing that moves. + const barnShell = new Path(null, { + stroke: SpecialRelativityColors.apparatusColorProperty, + lineWidth: 2.5, + }); + + /** + * One door. The open state is two stubs with a gap between them — something a + * ladder can pass through — and the closed state is a solid panel across the + * whole opening, in the events colour the rest of the sim uses for the moments + * that matter. + */ + const createDoor = (): { node: Node; setState: (x: number, closed: boolean) => void } => { + const stubHeight = height * OPEN_DOOR_STUB; + const topStub = new Rectangle(0, 0, LADDER_BARN.DOOR_WIDTH, stubHeight, { + fill: SpecialRelativityColors.apparatusColorProperty, + }); + const bottomStub = new Rectangle(0, 0, LADDER_BARN.DOOR_WIDTH, stubHeight, { + fill: SpecialRelativityColors.apparatusColorProperty, + }); + const panel = new Rectangle(0, 0, LADDER_BARN.DOOR_WIDTH, height, { + fill: SpecialRelativityColors.eventBColorProperty, + }); + const node = new Node({ children: [topStub, bottomStub, panel] }); + + const setState = (x: number, closed: boolean): void => { + const left = viewX(x) - LADDER_BARN.DOOR_WIDTH / 2; + topStub.setRect(left, -height, LADDER_BARN.DOOR_WIDTH, stubHeight); + bottomStub.setRect(left, -stubHeight, LADDER_BARN.DOOR_WIDTH, stubHeight); + panel.setRect(left, -height, LADDER_BARN.DOOR_WIDTH, height); + panel.visible = closed; + topStub.visible = !closed; + bottomStub.visible = !closed; + }; + + return { node, setState }; + }; + + const entranceDoor = createDoor(); + const exitDoor = createDoor(); + + // ── The ladder: two rails and a set of rungs ────────────────────────────── + const ladderPath = new Path(null, { + stroke: SpecialRelativityColors.ladderColorProperty, + lineWidth: 3, + }); + + // A wash across the barn's opening while the ladder is wholly inside it. In + // the barn frame this comes on for a stretch around t = 0; in the ladder frame + // it never comes on at all, and its never coming on is the answer to the + // screen's question. + const insideHighlight = new Rectangle(0, 0, 1, 1, { + fill: SpecialRelativityColors.beamingFillColorProperty, + }); + + // ── Measure lines ───────────────────────────────────────────────────────── + const barnMeasure = new Path(null, { + stroke: SpecialRelativityColors.apparatusColorProperty, + lineWidth: 1.5, + }); + const ladderMeasure = new Path(null, { + stroke: SpecialRelativityColors.ladderColorProperty, + lineWidth: 1.5, + }); + const barnLabel = new Text(providedOptions.barnLabelProperty, { + font: FONTS.READOUT, + fill: SpecialRelativityColors.apparatusColorProperty, + maxWidth: 220, + }); + const ladderLabel = new Text(providedOptions.ladderLabelProperty, { + font: FONTS.READOUT, + fill: SpecialRelativityColors.ladderColorProperty, + maxWidth: 220, + }); + + /** A ⊢——⊣ bracket spanning `span` at view height `y`. */ + const measureShape = (span: Span, y: number): Shape => + new Shape() + .moveTo(viewX(span.left), y - MEASURE_TICK) + .lineTo(viewX(span.left), y + MEASURE_TICK) + .moveTo(viewX(span.left), y) + .lineTo(viewX(span.right), y) + .moveTo(viewX(span.right), y - MEASURE_TICK) + .lineTo(viewX(span.right), y + MEASURE_TICK); + + const stage = new Node({ + children: [ + ground, + insideHighlight, + barnShell, + entranceDoor.node, + exitDoor.node, + ladderPath, + barnMeasure, + ladderMeasure, + barnLabel, + ladderLabel, + ], + // Clipped so an object leaving the window is cut at the edge of the stage + // rather than sprawling across the control panel. + clipArea: Shape.bounds(new Bounds2(-halfWidth, -height - 46, halfWidth, MEASURE_OFFSET + 24)), + }); + this.addChild(stage); + + const update = (): void => { + const { barn, ladder } = providedOptions.snapshotProperty.value; + const doors = providedOptions.doorStatesProperty.value; + + barnShell.shape = new Shape() + .moveTo(viewX(barn.left), 0) + .lineTo(viewX(barn.left), -height) + .lineTo(viewX(barn.right), -height) + .lineTo(viewX(barn.right), 0); + + entranceDoor.setState(barn.left, doors.entranceClosed); + exitDoor.setState(barn.right, doors.exitClosed); + + const rungTop = -height / 2 - LADDER_BARN.LADDER_HEIGHT / 2; + const rungBottom = -height / 2 + LADDER_BARN.LADDER_HEIGHT / 2; + const shape = new Shape() + .moveTo(viewX(ladder.left), rungTop) + .lineTo(viewX(ladder.right), rungTop) + .moveTo(viewX(ladder.left), rungBottom) + .lineTo(viewX(ladder.right), rungBottom); + for (let index = 0; index <= LADDER_BARN.LADDER_RUNGS; index++) { + const x = ladder.left + (spanLength(ladder) * index) / LADDER_BARN.LADDER_RUNGS; + shape.moveTo(viewX(x), rungTop).lineTo(viewX(x), rungBottom); + } + ladderPath.shape = shape; + + insideHighlight.visible = providedOptions.isEntirelyInsideProperty.value; + insideHighlight.setRect(viewX(barn.left), -height, spanLength(barn) * scale, height); + + barnMeasure.shape = measureShape(barn, MEASURE_OFFSET); + barnLabel.centerX = viewX((barn.left + barn.right) / 2); + barnLabel.top = MEASURE_OFFSET + MEASURE_TICK + 3; + + ladderMeasure.shape = measureShape(ladder, -height - MEASURE_OFFSET); + ladderLabel.centerX = viewX((ladder.left + ladder.right) / 2); + ladderLabel.bottom = -height - MEASURE_OFFSET - MEASURE_TICK - 3; + }; + + const updateMultilink = Multilink.multilink( + [providedOptions.snapshotProperty, providedOptions.doorStatesProperty, providedOptions.isEntirelyInsideProperty], + update, + ); + + this.disposeEmitter.addListener(() => { + updateMultilink.dispose(); + barnLabel.dispose(); + ladderLabel.dispose(); + }); + + this.mutate(providedOptions.nodeOptions); + } +} diff --git a/src/length-contraction/view/LengthContractionKeyboardHelpContent.ts b/src/length-contraction/view/LengthContractionKeyboardHelpContent.ts new file mode 100644 index 0000000..06b6ea1 --- /dev/null +++ b/src/length-contraction/view/LengthContractionKeyboardHelpContent.ts @@ -0,0 +1,24 @@ +/** + * LengthContractionKeyboardHelpContent.ts + * + * Content for the keyboard-help dialog (the "?" button in the navigation bar). + * Nothing on this screen is draggable — the scene is driven entirely by the frame + * radio buttons, two sliders, two push buttons and the time controls — so the + * draggable-items section is deliberately absent. + */ + +import { + BasicActionsKeyboardHelpSection, + SliderControlsKeyboardHelpSection, + TimeControlsKeyboardHelpSection, + TwoColumnKeyboardHelpContent, +} from "scenerystack/scenery-phet"; + +export class LengthContractionKeyboardHelpContent extends TwoColumnKeyboardHelpContent { + public constructor() { + super( + [new SliderControlsKeyboardHelpSection(), new TimeControlsKeyboardHelpSection()], + [new BasicActionsKeyboardHelpSection({ withCheckboxContent: true })], + ); + } +} diff --git a/src/length-contraction/view/LengthContractionScreenSummaryContent.ts b/src/length-contraction/view/LengthContractionScreenSummaryContent.ts new file mode 100644 index 0000000..3e436b5 --- /dev/null +++ b/src/length-contraction/view/LengthContractionScreenSummaryContent.ts @@ -0,0 +1,75 @@ +/** + * LengthContractionScreenSummaryContent.ts + * + * The accessible screen summary for the Length Contraction screen. + * + * The live paragraph reports the frame in force, the two lengths that frame + * measures, and its verdict on whether the ladder fits — all of which change only + * when a control is touched, never while the scene animates. The scene clock is + * deliberately left out: it changes every frame, and a paragraph that changes + * every frame cannot be read. + */ +import { DerivedProperty } from "scenerystack/axon"; +import { toFixed } from "scenerystack/dot"; +import { StringUtils } from "scenerystack/phetcommon"; +import { ScreenSummaryContent } from "scenerystack/sim"; +import { StringManager } from "../../i18n/StringManager.js"; +import type { LengthContractionModel } from "../model/LengthContractionModel.js"; +import { ObservationFrame } from "../model/ladderBarnGeometry.js"; + +export class LengthContractionScreenSummaryContent extends ScreenSummaryContent { + public constructor(model: LengthContractionModel) { + const strings = StringManager.getInstance(); + const a11y = strings.getLengthContractionA11yStrings(); + const contractionStrings = strings.getLengthContractionStrings(); + + const currentDetails = new DerivedProperty( + [ + a11y.currentDetailsStringProperty, + contractionStrings.barnFrameStringProperty, + contractionStrings.ladderFrameStringProperty, + contractionStrings.fitsStringProperty, + contractionStrings.doesNotFitStringProperty, + model.frameProperty, + model.betaProperty, + model.gammaProperty, + model.measuredLadderLengthProperty, + model.measuredBarnLengthProperty, + model.fitsProperty, + model.slamGapProperty, + ], + ( + pattern, + barnFrame, + ladderFrame, + fitsPhrase, + doesNotFitPhrase, + frame, + beta, + gamma, + ladderLength, + barnLength, + fits, + slamGap, + ) => + StringUtils.fillIn(pattern, { + frame: frame === ObservationFrame.BARN ? barnFrame : ladderFrame, + beta: toFixed(beta, 2), + gamma: toFixed(gamma, 2), + ladder: toFixed(ladderLength, 2), + barn: toFixed(barnLength, 2), + verdict: fits ? fitsPhrase : doesNotFitPhrase, + gap: toFixed(Math.abs(slamGap), 2), + }), + ); + + super({ + playAreaContent: a11y.screenSummary.playAreaStringProperty, + controlAreaContent: a11y.screenSummary.controlAreaStringProperty, + currentDetailsContent: currentDetails, + interactionHintContent: a11y.screenSummary.interactionHintStringProperty, + }); + + this.disposeEmitter.addListener(() => currentDetails.dispose()); + } +} diff --git a/src/length-contraction/view/LengthContractionScreenView.ts b/src/length-contraction/view/LengthContractionScreenView.ts new file mode 100644 index 0000000..dda60f7 --- /dev/null +++ b/src/length-contraction/view/LengthContractionScreenView.ts @@ -0,0 +1,568 @@ +/** + * LengthContractionScreenView.ts + * + * The ladder and the barn, told twice: once as a scene you watch, and once as a + * spacetime diagram you read. + * + * ── Why the diagram does not change frames when the toggle does ─────────────── + * The stage is drawn with the selected frame's rulers and clock, so flipping the + * toggle rearranges it completely. The diagram is always drawn in **barn-frame** + * coordinates, and flipping the toggle changes exactly one thing on it: the tilt + * of the slice the measurement is taken on. + * + * That split is the argument. The events — the door slams, the ends of the ladder + * crossing the doorways — are the same points of the same picture whichever + * toggle position you are in; the two frames are not looking at different + * spacetimes. All they do differently is cut it into "nows" at a different angle, + * and the stage above is what that one difference *looks like* from inside. + */ + +import { DerivedProperty, Multilink, PatternStringProperty } from "scenerystack/axon"; +import { LinePlot } from "scenerystack/bamboo"; +import { Range, Vector2 } from "scenerystack/dot"; +import { Shape } from "scenerystack/kite"; +import { type EmptySelfOptions, optionize } from "scenerystack/phet-core"; +import { Circle, HBox, HSeparator, Node, Path, RichText, Text, VBox } from "scenerystack/scenery"; +import { ResetAllButton, TimeControlNode } from "scenerystack/scenery-phet"; +import { ScreenView, type ScreenViewOptions } from "scenerystack/sim"; +import { + FLAT_PLAY_PAUSE_STEP_BUTTON_OPTIONS, + FLAT_RESET_ALL_BUTTON_OPTIONS, + TIME_CONTROL_SPEED_RADIO_OPTIONS, +} from "../../common/SpecialRelativityButtonOptions.js"; +import { SpecialRelativityPanel } from "../../common/SpecialRelativityPanel.js"; +import { DEFAULT_TIME_SPEEDS } from "../../common/TimeModel.js"; +import { formatSignificant } from "../../common/view/chartUtils.js"; +import { + createCheckbox, + createNumberControl, + createPushButton, + createRadioButtonGroup, + createReadoutRow, + createSectionHeader, +} from "../../common/view/controlHelpers.js"; +import { MinkowskiDiagramNode } from "../../common/view/MinkowskiDiagramNode.js"; +import { StringManager } from "../../i18n/StringManager.js"; +import type { SpecialRelativityPreferencesModel } from "../../preferences/SpecialRelativityPreferencesModel.js"; +import SpecialRelativityColors from "../../SpecialRelativityColors.js"; +import { FONTS, LADDER_BARN, SCREEN_VIEW_MARGIN } from "../../SpecialRelativityConstants.js"; +import { + LADDER_BETA_RANGE, + LADDER_LENGTH_RANGE, + type LengthContractionModel, +} from "../model/LengthContractionModel.js"; +import { + barnSheet, + barnSliceInLab, + ladderSheet, + ladderSliceInLab, + ObservationFrame, +} from "../model/ladderBarnGeometry.js"; +import { LadderBarnStageNode } from "./LadderBarnStageNode.js"; +import { LengthContractionScreenSummaryContent } from "./LengthContractionScreenSummaryContent.js"; + +/** The stage sits above the diagram; this is where its ground line lands. */ +const STAGE_CENTER_X = 372; +const STAGE_BASELINE_Y = 142; + +/** Where the spacetime diagram's plotting area starts. */ +const DIAGRAM_LEFT = 155; +const DIAGRAM_TOP = 188; + +/** + * The diagram's window on the barn frame. Wider in x than in ct because the two + * slams sit on the x axis a whole barn apart, and the ladder's world-sheet leans + * out past them; the ct range only has to reach the moments the slices are taken + * at. Light rays still run at 45° — {@link MinkowskiDiagramNode} derives its + * height from these two ranges rather than taking one. + */ +const DIAGRAM_X_RANGE = new Range(-5.5, 5.5); +const DIAGRAM_CT_RANGE = new Range(-4.5, 4.5); +const DIAGRAM_VIEW_WIDTH = 370; + +const PANEL_WIDTH = 252; + +/** + * Gap between the two slams, in seconds, below which the status line reads "at + * the same moment". The barn frame produces exactly zero, so this band exists for + * the ladder frame at very small β, where the two slams are apart by less than the + * readout's own two decimal places and saying which came first would be reporting + * a difference the panel does not show. + */ +const SLAM_ORDER_TOLERANCE = 0.005; + +export type LengthContractionScreenViewOptions = ScreenViewOptions; + +export class LengthContractionScreenView extends ScreenView { + public constructor( + model: LengthContractionModel, + preferences: SpecialRelativityPreferencesModel, + providedOptions?: LengthContractionScreenViewOptions, + ) { + const options = optionize()( + { + screenSummaryContent: new LengthContractionScreenSummaryContent(model), + }, + providedOptions, + ); + super(options); + + const strings = StringManager.getInstance(); + const contractionStrings = strings.getLengthContractionStrings(); + const commonStrings = strings.getCommon(); + const units = strings.getUnits(); + const a11y = strings.getLengthContractionA11yStrings(); + + // ── The stage ───────────────────────────────────────────────────────────── + const barnStageLabel = new PatternStringProperty( + contractionStrings.barnMeasureStringProperty, + { value: model.measuredBarnLengthProperty }, + { decimalPlaces: 2 }, + ); + const ladderStageLabel = new PatternStringProperty( + contractionStrings.ladderMeasureStringProperty, + { value: model.measuredLadderLengthProperty }, + { decimalPlaces: 2 }, + ); + + const stage = new LadderBarnStageNode({ + snapshotProperty: model.snapshotProperty, + doorStatesProperty: model.doorStatesProperty, + isEntirelyInsideProperty: model.isEntirelyInsideProperty, + barnLabelProperty: barnStageLabel, + ladderLabelProperty: ladderStageLabel, + nodeOptions: { x: STAGE_CENTER_X, y: STAGE_BASELINE_Y }, + }); + this.addChild(stage); + + // ── The diagram, always in barn-frame coordinates ───────────────────────── + // β for the shear is the ladder's speed only while the ladder frame is + // selected; in the barn frame the primed mesh would be the barn's own axes, + // which is the unprimed mesh already drawn. + const diagramBetaProperty = new DerivedProperty([model.betaProperty, model.frameProperty], (beta, frame) => + frame === ObservationFrame.LADDER ? beta : 0, + ); + const showPrimedFrameProperty = new DerivedProperty( + [model.frameProperty], + (frame) => frame === ObservationFrame.LADDER, + ); + + const diagram = new MinkowskiDiagramNode({ + betaProperty: diagramBetaProperty, + xAxisLabelProperty: commonStrings.xAxisStringProperty, + ctAxisLabelProperty: commonStrings.ctAxisStringProperty, + primedXAxisLabelProperty: commonStrings.primedXAxisStringProperty, + primedCtAxisLabelProperty: commonStrings.primedCtAxisStringProperty, + xRange: DIAGRAM_X_RANGE, + ctRange: DIAGRAM_CT_RANGE, + viewWidth: DIAGRAM_VIEW_WIDTH, + shadeLightConeProperty: preferences.shadeLightConeProperty, + showPrimedFrameProperty: showPrimedFrameProperty, + nodeOptions: { left: DIAGRAM_LEFT, top: DIAGRAM_TOP }, + }); + this.addChild(diagram); + + // ── The two world-sheets ────────────────────────────────────────────────── + // The band each object's ends sweep out. Where they overlap is every event at + // which some of the ladder is inside the barn, and the containment question + // reduces to which slice of that overlap you take — which is the resolution, + // drawn rather than argued. + const barnSheetPath = new Path(null, { + fill: SpecialRelativityColors.barnSheetFillColorProperty, + stroke: SpecialRelativityColors.apparatusColorProperty, + lineWidth: 1.5, + }); + const ladderSheetPath = new Path(null, { + fill: SpecialRelativityColors.ladderSheetFillColorProperty, + stroke: SpecialRelativityColors.ladderColorProperty, + lineWidth: 1.5, + }); + const sheetLayer = new Node({ + children: [barnSheetPath, ladderSheetPath], + visibleProperty: model.showWorldSheetsProperty, + }); + diagram.plotLayer.addChild(sheetLayer); + + // Twice the visible ct range, so a sheet's leaning edges leave the frame + // instead of stopping just inside it. + const sheetExtent = 2 * DIAGRAM_CT_RANGE.max; + + /** A closed polygon through model-space corners, in the diagram's view space. */ + const polygonShape = (corners: readonly Vector2[]): Shape => { + const shape = new Shape(); + corners.forEach((corner, index) => { + const point = diagram.chartTransform.modelToViewPosition(corner); + if (index === 0) { + shape.moveToPoint(point); + } else { + shape.lineToPoint(point); + } + }); + return shape.close(); + }; + + const updateSheets = (): void => { + const setup = model.setupProperty.value; + barnSheetPath.shape = polygonShape(barnSheet(setup, sheetExtent)); + ladderSheetPath.shape = polygonShape(ladderSheet(setup, sheetExtent)); + }; + const sheetMultilink = Multilink.multilink([model.setupProperty], updateSheets); + + // ── The slice the current measurement is taken on ───────────────────────── + // Horizontal in the barn frame, tilted by β in the ladder frame. The two + // heavy segments on it are the ladder and the barn as that frame measures + // them at this instant — the same two lengths the stage above reports. + const slicePlot = new LinePlot(diagram.chartTransform, [], { + stroke: SpecialRelativityColors.simultaneityColorProperty, + lineWidth: 2, + lineDash: [7, 4], + }); + // The barn's segment is drawn wider and underneath the ladder's, so that when + // one measurement lies inside the other — which is the interesting case, and + // the one the barn frame produces at t = 0 — the shorter is not simply hidden + // by the longer. The wider stroke shows through as a border along its extent. + const barnSlicePlot = new LinePlot(diagram.chartTransform, [], { + stroke: SpecialRelativityColors.apparatusColorProperty, + lineWidth: 9, + }); + const ladderSlicePlot = new LinePlot(diagram.chartTransform, [], { + stroke: SpecialRelativityColors.ladderColorProperty, + lineWidth: 5, + }); + const sliceLayer = new Node({ + children: [slicePlot, barnSlicePlot, ladderSlicePlot], + visibleProperty: model.showSliceProperty, + }); + diagram.plotLayer.addChild(sliceLayer); + + const updateSlice = (): void => { + const setup = model.setupProperty.value; + const frame = model.frameProperty.value; + const time = model.sceneTimeProperty.value; + + const barnEnds = barnSliceInLab(setup, frame, time); + const ladderEnds = ladderSliceInLab(setup, frame, time); + barnSlicePlot.setDataSet(barnEnds); + ladderSlicePlot.setDataSet(ladderEnds); + + // Extend the dashed slice right across the frame through the same two + // points, so the tilt reads as a whole line of "now" and not as a bar. + const [start, end] = ladderEnds; + const direction = end.minus(start); + const unit = direction.magnitude === 0 ? new Vector2(1, 0) : direction.normalized(); + const reach = 4 * DIAGRAM_X_RANGE.max; + const middle = start.plus(end).timesScalar(0.5); + slicePlot.setDataSet([middle.plus(unit.timesScalar(-reach)), middle.plus(unit.timesScalar(reach))]); + }; + const sliceMultilink = Multilink.multilink( + [model.setupProperty, model.frameProperty, model.sceneTimeProperty], + updateSlice, + ); + + // ── The two door slams ──────────────────────────────────────────────────── + // Fixed points of the diagram: they do not move when the frame toggle does, + // which is the shortest possible statement that the two frames are describing + // the same pair of events. + const slamMarkers = [ + new Circle(6, { + fill: SpecialRelativityColors.eventBColorProperty, + stroke: SpecialRelativityColors.backgroundColorProperty, + lineWidth: 1.5, + }), + new Circle(6, { + fill: SpecialRelativityColors.eventBColorProperty, + stroke: SpecialRelativityColors.backgroundColorProperty, + lineWidth: 1.5, + }), + ]; + for (const marker of slamMarkers) { + diagram.overlayLayer.addChild(marker); + } + + const updateSlams = (): void => { + const { entrance, exit } = model.slamEvents(); + [entrance, exit].forEach((event, index) => { + const marker = slamMarkers[index]; + if (marker) { + marker.center = diagram.chartTransform.modelToViewPosition(event); + } + }); + }; + const slamMultilink = Multilink.multilink([model.setupProperty], updateSlams); + + // ── Readouts ────────────────────────────────────────────────────────────── + const gammaText = new DerivedProperty([model.gammaProperty], (gamma) => formatSignificant(gamma, 3)); + const ladderLengthText = new PatternStringProperty( + units.lightSecondsStringProperty, + { value: model.measuredLadderLengthProperty }, + { decimalPlaces: 2 }, + ); + const barnLengthText = new PatternStringProperty( + units.lightSecondsStringProperty, + { value: model.measuredBarnLengthProperty }, + { decimalPlaces: 2 }, + ); + const sceneTimeText = new PatternStringProperty( + units.secondsStringProperty, + { value: model.sceneTimeProperty }, + { decimalPlaces: 2 }, + ); + const slamGapText = new PatternStringProperty( + units.secondsStringProperty, + { value: model.slamGapProperty }, + { decimalPlaces: 2 }, + ); + + // "Does it fit?" is answered for the frame the toggle is on, in that frame's + // own words, because the whole point is that both answers are correct + // statements about the same ladder and the same barn. + const fitsText = new DerivedProperty( + [model.fitsProperty, contractionStrings.fitsStringProperty, contractionStrings.doesNotFitStringProperty], + (fits, yes, no) => (fits ? yes : no), + ); + + const slamOrderText = new DerivedProperty( + [ + model.slamGapProperty, + contractionStrings.slamsTogetherStringProperty, + contractionStrings.exitSlamsFirstStringProperty, + contractionStrings.entranceSlamsFirstStringProperty, + ], + (gap, together, exitFirst, entranceFirst) => { + if (Math.abs(gap) < SLAM_ORDER_TOLERANCE) { + return together; + } + return gap > 0 ? exitFirst : entranceFirst; + }, + ); + + // RichText rather than Text: this sentence is longer than the panel is wide, + // and a Text would answer maxWidth by shrinking itself to unreadable rather + // than by wrapping. + const verdict = new RichText(contractionStrings.verdictStringProperty, { + font: FONTS.READOUT, + fill: SpecialRelativityColors.secondaryTextColorProperty, + lineWrap: PANEL_WIDTH, + }); + + const frameHeadingText = new DerivedProperty( + [model.frameProperty, contractionStrings.barnFrameStringProperty, contractionStrings.ladderFrameStringProperty], + (frame, barn, ladder) => (frame === ObservationFrame.BARN ? barn : ladder), + ); + + const readoutPanel = new SpecialRelativityPanel( + new VBox({ + align: "left", + spacing: 4, + children: [ + createSectionHeader(frameHeadingText), + createReadoutRow( + contractionStrings.ladderLengthStringProperty, + ladderLengthText, + SpecialRelativityColors.ladderColorProperty, + PANEL_WIDTH, + ), + createReadoutRow( + contractionStrings.barnLengthStringProperty, + barnLengthText, + SpecialRelativityColors.apparatusColorProperty, + PANEL_WIDTH, + ), + createReadoutRow( + contractionStrings.fitsQuestionStringProperty, + fitsText, + SpecialRelativityColors.accentColorProperty, + PANEL_WIDTH, + ), + new HSeparator({ stroke: SpecialRelativityColors.panelBorderColorProperty }), + createReadoutRow(commonStrings.gammaStringProperty, gammaText, undefined, PANEL_WIDTH), + createReadoutRow(contractionStrings.sceneTimeStringProperty, sceneTimeText, undefined, PANEL_WIDTH), + createReadoutRow( + contractionStrings.slamGapStringProperty, + slamGapText, + SpecialRelativityColors.eventBColorProperty, + PANEL_WIDTH, + ), + new RichText(slamOrderText, { + font: FONTS.READOUT, + fill: SpecialRelativityColors.eventBColorProperty, + lineWrap: PANEL_WIDTH, + }), + verdict, + ], + }), + ); + + // ── Controls ────────────────────────────────────────────────────────────── + const frameControl = createRadioButtonGroup( + model.frameProperty, + [ + { + value: ObservationFrame.BARN, + labelProperty: contractionStrings.barnFrameStringProperty, + accessibleName: a11y.controls.barnFrameStringProperty, + }, + { + value: ObservationFrame.LADDER, + labelProperty: contractionStrings.ladderFrameStringProperty, + accessibleName: a11y.controls.ladderFrameStringProperty, + }, + ], + { + accessibleName: a11y.controls.frameStringProperty, + accessibleHelpText: a11y.controls.frameHelpStringProperty, + width: PANEL_WIDTH, + }, + ); + + const betaControl = createNumberControl(model.betaProperty, LADDER_BETA_RANGE, { + titleProperty: contractionStrings.ladderSpeedStringProperty, + valuePatternProperty: units.betaStringProperty, + accessibleName: a11y.controls.velocityStringProperty, + accessibleHelpText: a11y.controls.velocityHelpStringProperty, + decimalPlaces: 2, + delta: 0.01, + }); + + const lengthControl = createNumberControl(model.ladderLengthProperty, LADDER_LENGTH_RANGE, { + titleProperty: contractionStrings.ladderProperLengthStringProperty, + valuePatternProperty: units.lightSecondsStringProperty, + accessibleName: a11y.controls.ladderLengthStringProperty, + accessibleHelpText: a11y.controls.ladderLengthHelpStringProperty, + decimalPlaces: 1, + delta: LADDER_BARN.LADDER_LENGTH_DELTA, + }); + + // Two buttons that take the clock to a door slam. In the barn frame they land + // on the same instant and nothing moves between them — which is what "both + // doors are on one switch" means, and exactly what the ladder frame denies. + const buttonTextWidth = PANEL_WIDTH / 2 - 26; + const entranceSlamButton = createPushButton(contractionStrings.goToEntranceSlamStringProperty, { + accessibleName: a11y.controls.goToEntranceSlamStringProperty, + accessibleHelpText: a11y.controls.goToEntranceSlamHelpStringProperty, + listener: () => model.goToSlam("entrance"), + maxTextWidth: buttonTextWidth, + }); + const exitSlamButton = createPushButton(contractionStrings.goToExitSlamStringProperty, { + accessibleName: a11y.controls.goToExitSlamStringProperty, + accessibleHelpText: a11y.controls.goToExitSlamHelpStringProperty, + listener: () => model.goToSlam("exit"), + maxTextWidth: buttonTextWidth, + }); + const slamButtonRow = new HBox({ + children: [entranceSlamButton, exitSlamButton], + spacing: 8, + stretch: true, + }); + + const sheetsCheckbox = createCheckbox( + model.showWorldSheetsProperty, + contractionStrings.showWorldSheetsStringProperty, + a11y.controls.showWorldSheetsStringProperty, + PANEL_WIDTH, + ); + sheetsCheckbox.accessibleHelpText = a11y.controls.showWorldSheetsHelpStringProperty; + + const sliceCheckbox = createCheckbox( + model.showSliceProperty, + contractionStrings.showSliceStringProperty, + a11y.controls.showSliceStringProperty, + PANEL_WIDTH, + ); + sliceCheckbox.accessibleHelpText = a11y.controls.showSliceHelpStringProperty; + + const controlPanel = new SpecialRelativityPanel( + new VBox({ + align: "left", + spacing: 9, + children: [frameControl, betaControl, lengthControl, slamButtonRow, sheetsCheckbox, sliceCheckbox], + }), + ); + + const controlColumn = new VBox({ + align: "right", + spacing: 8, + children: [readoutPanel, controlPanel], + right: this.layoutBounds.maxX - SCREEN_VIEW_MARGIN, + top: this.layoutBounds.minY + 10, + }); + this.addChild(controlColumn); + + const takeaway = new Text(contractionStrings.takeawayStringProperty, { + font: FONTS.READOUT, + fill: SpecialRelativityColors.secondaryTextColorProperty, + maxWidth: 680, + left: this.layoutBounds.minX + SCREEN_VIEW_MARGIN, + top: this.layoutBounds.minY + 8, + }); + this.addChild(takeaway); + + const timeControlNode = new TimeControlNode(model.timer.isPlayingProperty, { + timeSpeedProperty: model.timer.timeSpeedProperty, + timeSpeeds: DEFAULT_TIME_SPEEDS, + ...TIME_CONTROL_SPEED_RADIO_OPTIONS, + playPauseStepButtonOptions: { + ...FLAT_PLAY_PAUSE_STEP_BUTTON_OPTIONS, + includeStepBackwardButton: true, + stepForwardButtonOptions: { listener: () => model.stepForward(1 / 30) }, + stepBackwardButtonOptions: { listener: () => model.stepBackward(1 / 30) }, + }, + centerX: diagram.centerX, + bottom: this.layoutBounds.maxY - SCREEN_VIEW_MARGIN, + }); + this.addChild(timeControlNode); + + const resetAllButton = new ResetAllButton({ + ...FLAT_RESET_ALL_BUTTON_OPTIONS, + listener: () => { + this.interruptSubtreeInput(); + model.reset(); + this.reset(); + }, + right: this.layoutBounds.maxX - SCREEN_VIEW_MARGIN, + bottom: this.layoutBounds.maxY - SCREEN_VIEW_MARGIN, + }); + this.addChild(resetAllButton); + + // The frame selector first: it is what this screen is about, and everything + // else is a way of varying what it selects between. Reset All last. + this.addChild( + new Node({ + pdomOrder: [ + frameControl, + betaControl, + lengthControl, + entranceSlamButton, + exitSlamButton, + sheetsCheckbox, + sliceCheckbox, + timeControlNode, + resetAllButton, + ], + }), + ); + + this.disposeEmitter.addListener(() => { + sheetMultilink.dispose(); + sliceMultilink.dispose(); + slamMultilink.dispose(); + diagramBetaProperty.dispose(); + showPrimedFrameProperty.dispose(); + barnStageLabel.dispose(); + ladderStageLabel.dispose(); + gammaText.dispose(); + ladderLengthText.dispose(); + barnLengthText.dispose(); + sceneTimeText.dispose(); + slamGapText.dispose(); + fitsText.dispose(); + slamOrderText.dispose(); + frameHeadingText.dispose(); + }); + } + + /** All resettable state lives in the model; there is nothing view-side to restore. */ + public reset(): void { + // Intentionally empty. + } +} diff --git a/src/main.ts b/src/main.ts index 8250b53..2f42528 100644 --- a/src/main.ts +++ b/src/main.ts @@ -22,6 +22,7 @@ import "./brand.js"; import { onReadyToLaunch, PreferencesModel, Sim } from "scenerystack/sim"; import { Tandem } from "scenerystack/tandem"; import { StringManager } from "./i18n/StringManager.js"; +import { LengthContractionScreen } from "./length-contraction/LengthContractionScreen.js"; import { LightClockScreen } from "./light-clock/LightClockScreen.js"; import { SpecialRelativityPreferencesModel } from "./preferences/SpecialRelativityPreferencesModel.js"; import { SpecialRelativityPreferencesNode } from "./preferences/SpecialRelativityPreferencesNode.js"; @@ -47,6 +48,13 @@ onReadyToLaunch(() => { tandem: Tandem.ROOT.createTandem("spacetimeScreen"), backgroundColorProperty: SpecialRelativityColors.backgroundColorProperty, }), + // Third of five: the ladder-and-barn puzzle is resolved by relativity of + // simultaneity, so it follows the screen that introduces it. + new LengthContractionScreen(simPreferences, { + name: stringManager.getScreenNames().lengthContractionStringProperty, + tandem: Tandem.ROOT.createTandem("lengthContractionScreen"), + backgroundColorProperty: SpecialRelativityColors.backgroundColorProperty, + }), new TwinParadoxScreen(simPreferences, { name: stringManager.getScreenNames().twinParadoxStringProperty, tandem: Tandem.ROOT.createTandem("twinParadoxScreen"), diff --git a/tests/ladderBarnGeometry.test.ts b/tests/ladderBarnGeometry.test.ts new file mode 100644 index 0000000..d507b81 --- /dev/null +++ b/tests/ladderBarnGeometry.test.ts @@ -0,0 +1,311 @@ +/** + * ladderBarnGeometry.test.ts + * + * The Length Contraction screen makes three claims, and each gets its own layer + * of checks: + * + * 1. each object is measured shorter by exactly γ in the frame it moves through; + * 2. the two frames disagree about whether the ladder fits, and the region of + * the parameter space where they disagree is exactly B/γ² < L₀/γ < B; + * 3. the disagreement is *only* about simultaneity — the two door slams are one + * pair of events, spacelike separated, and every frame agrees on the events + * themselves and on the interval between them. + * + * Layer 2 is the one that earns its keep: the fitting verdicts are re-derived here + * from the snapshots the view actually draws, rather than by restating + * {@link fitsIn}'s own comparison, and the frames' door-slam times are checked + * against an independent Lorentz transform of the slam events. + */ + +import type { Vector2 } from "scenerystack/dot"; +import { describe, expect, it } from "vitest"; +import { boostEvent, gammaOf, intervalSquared, Separation, separationOf } from "../src/common/model/lorentz.js"; +import { + barnSheet, + barnSliceInLab, + contractedLength, + entranceSlamEvent, + exitSlamEvent, + fitsIn, + isEntirelyInside, + type LadderBarnSetup, + ladderSheet, + ladderSliceInLab, + ObservationFrame, + passHalfWindow, + sceneHalfWindow, + slamTimes, + snapshotAt, + spanLength, +} from "../src/length-contraction/model/ladderBarnGeometry.js"; + +/** The screen's default configuration: γ = 5/3, so 5 ls contracts to exactly 3. */ +const DEFAULT_SETUP: LadderBarnSetup = { barnLength: 4, ladderLength: 5, beta: 0.8 }; + +/** A sweep across the reachable parameter space, including both fitting regimes. */ +const SETUPS: LadderBarnSetup[] = [ + DEFAULT_SETUP, + { barnLength: 4, ladderLength: 2, beta: 0.1 }, + { barnLength: 4, ladderLength: 2, beta: 0.95 }, + { barnLength: 4, ladderLength: 8, beta: 0.95 }, + { barnLength: 4, ladderLength: 8, beta: 0.5 }, + { barnLength: 4, ladderLength: 5, beta: 0.6 }, +]; + +const FRAMES = [ObservationFrame.BARN, ObservationFrame.LADDER] as const; + +describe("contraction", () => { + it("matches the hand-computed default case", () => { + // γ( 0.8 ) = 5/3, so the 5 ls ladder is measured at 3 ls and the 4 ls barn at 2.4. + expect(contractedLength(5, 0.8)).toBeCloseTo(3, 12); + expect(contractedLength(4, 0.8)).toBeCloseTo(2.4, 12); + }); + + it("leaves a rest length alone and never lengthens one", () => { + for (const setup of SETUPS) { + expect(contractedLength(setup.ladderLength, 0)).toBeCloseTo(setup.ladderLength, 12); + expect(contractedLength(setup.ladderLength, setup.beta)).toBeLessThanOrEqual(setup.ladderLength); + } + }); + + it("gives each frame the other object's length divided by γ", () => { + for (const setup of SETUPS) { + const gamma = gammaOf(setup.beta); + const inBarn = snapshotAt(setup, ObservationFrame.BARN, 0); + const inLadder = snapshotAt(setup, ObservationFrame.LADDER, 0); + + expect(spanLength(inBarn.barn)).toBeCloseTo(setup.barnLength, 12); + expect(spanLength(inBarn.ladder)).toBeCloseTo(setup.ladderLength / gamma, 12); + expect(spanLength(inLadder.ladder)).toBeCloseTo(setup.ladderLength, 12); + expect(spanLength(inLadder.barn)).toBeCloseTo(setup.barnLength / gamma, 12); + } + }); + + it("holds each object's measured length fixed as the scene runs", () => { + for (const setup of SETUPS) { + for (const frame of FRAMES) { + const half = sceneHalfWindow(setup, frame); + const first = snapshotAt(setup, frame, -half); + for (const time of [-half / 2, 0, half / 3, half]) { + const later = snapshotAt(setup, frame, time); + expect(spanLength(later.barn)).toBeCloseTo(spanLength(first.barn), 10); + expect(spanLength(later.ladder)).toBeCloseTo(spanLength(first.ladder), 10); + } + } + } + }); +}); + +describe("the two frames' verdicts", () => { + /** + * Independent check on {@link fitsIn}: walk the frame's own snapshots across the + * whole pass and ask whether the ladder is ever wholly between the doors. This + * cannot pass by restating fitsIn's comparison — it only knows where the four + * ends are. + */ + const everInsideBySampling = (setup: LadderBarnSetup, frame: ObservationFrame): boolean => { + const half = passHalfWindow(setup, frame); + const samples = 4001; + for (let index = 0; index < samples; index++) { + const time = -half + (2 * half * index) / (samples - 1); + if (isEntirelyInside(snapshotAt(setup, frame, time))) { + return true; + } + } + return false; + }; + + it("agrees with a sweep of the actual snapshots", () => { + for (const setup of SETUPS) { + for (const frame of FRAMES) { + expect(everInsideBySampling(setup, frame)).toBe(fitsIn(setup, frame)); + } + } + }); + + it("puts the paradox exactly where the algebra says it is", () => { + // Both frames' verdicts differ precisely when B/γ² < L₀/γ < B — the barn frame + // sees the ladder fit and the ladder frame does not. + for (const setup of SETUPS) { + const gamma = gammaOf(setup.beta); + const contracted = setup.ladderLength / gamma; + const disagree = + contracted <= setup.barnLength && contracted > setup.barnLength / (gamma * gamma) + Number.EPSILON; + expect(fitsIn(setup, ObservationFrame.BARN) && !fitsIn(setup, ObservationFrame.LADDER)).toBe(disagree); + } + }); + + it("has the default configuration land in the paradox regime", () => { + expect(fitsIn(DEFAULT_SETUP, ObservationFrame.BARN)).toBe(true); + expect(fitsIn(DEFAULT_SETUP, ObservationFrame.LADDER)).toBe(false); + }); + + it("never lets the ladder frame say yes while the barn frame says no", () => { + // The ladder is at its longest in its own frame and the barn at its shortest, + // so if it fits for the ladder it fits for everybody. + for (const setup of SETUPS) { + if (fitsIn(setup, ObservationFrame.LADDER)) { + expect(fitsIn(setup, ObservationFrame.BARN)).toBe(true); + } + } + }); +}); + +describe("the door slams", () => { + it("places them a barn apart at the same barn-frame instant", () => { + for (const setup of SETUPS) { + const entrance = entranceSlamEvent(setup); + const exit = exitSlamEvent(setup); + expect(exit.x - entrance.x).toBeCloseTo(setup.barnLength, 12); + expect(entrance.y).toBe(0); + expect(exit.y).toBe(0); + } + }); + + it("separates them spacelike, whatever the setup", () => { + for (const setup of SETUPS) { + expect(separationOf(entranceSlamEvent(setup), exitSlamEvent(setup))).toBe(Separation.SPACELIKE); + } + }); + + it("gives every frame the same interval between them", () => { + for (const setup of SETUPS) { + const displacement = exitSlamEvent(setup).minus(entranceSlamEvent(setup)); + const boosted = boostEvent(exitSlamEvent(setup), setup.beta).minus( + boostEvent(entranceSlamEvent(setup), setup.beta), + ); + expect(intervalSquared(boosted)).toBeCloseTo(intervalSquared(displacement), 10); + expect(intervalSquared(displacement)).toBeCloseTo(setup.barnLength ** 2, 12); + } + }); + + it("times them together in the barn frame and γβB apart in the ladder frame", () => { + for (const setup of SETUPS) { + const inBarn = slamTimes(setup, ObservationFrame.BARN); + expect(inBarn.entrance).toBe(0); + expect(inBarn.exit).toBe(0); + + // The closed form, checked against the boost the module actually applies. + const inLadder = slamTimes(setup, ObservationFrame.LADDER); + const expectedGap = gammaOf(setup.beta) * setup.beta * setup.barnLength; + expect(inLadder.entrance - inLadder.exit).toBeCloseTo(expectedGap, 10); + expect(inLadder.exit).toBeLessThan(inLadder.entrance); + } + }); + + it("has the exit door shut while the nose is still inside, whenever the barn frame says it fits", () => { + // The ladder frame's account has to be consistent too: no door may ever shut + // through the ladder. This is the check that the resolution is a resolution + // and not just two incompatible stories. + for (const setup of SETUPS.filter((candidate) => fitsIn(candidate, ObservationFrame.BARN))) { + const times = slamTimes(setup, ObservationFrame.LADDER); + const atExitSlam = snapshotAt(setup, ObservationFrame.LADDER, times.exit); + const atEntranceSlam = snapshotAt(setup, ObservationFrame.LADDER, times.entrance); + // The exit door is at the barn's right edge; the ladder's nose must not be + // past it. Likewise the tail must already be past the entrance door. + expect(atExitSlam.ladder.right).toBeLessThanOrEqual(atExitSlam.barn.right + 1e-9); + expect(atEntranceSlam.ladder.left).toBeGreaterThanOrEqual(atEntranceSlam.barn.left - 1e-9); + } + }); +}); + +describe("slices in lab coordinates", () => { + it("reproduces the barn frame's own snapshot unchanged", () => { + for (const setup of SETUPS) { + for (const time of [-1.3, 0, 2.4]) { + const [left, right] = ladderSliceInLab(setup, ObservationFrame.BARN, time); + const snapshot = snapshotAt(setup, ObservationFrame.BARN, time); + expect(left.x).toBeCloseTo(snapshot.ladder.left, 12); + expect(right.x).toBeCloseTo(snapshot.ladder.right, 12); + expect(left.y).toBe(time); + expect(right.y).toBe(time); + } + } + }); + + it("puts a ladder-frame slice on a line of slope β, not a horizontal one", () => { + for (const setup of SETUPS) { + for (const time of [-1.3, 0, 2.4]) { + const [left, right] = ladderSliceInLab(setup, ObservationFrame.LADDER, time); + // Constant t′ means ct = βx: the slice tilts by exactly β on the diagram. + expect((right.y - left.y) / (right.x - left.x)).toBeCloseTo(setup.beta, 10); + } + } + }); + + it("boosts back to the length the measuring frame reported", () => { + // Independent of snapshotAt's own arithmetic: take the two lab-frame ends of + // the slice, transform them into the ladder frame, and measure there. + for (const setup of SETUPS) { + const [left, right] = ladderSliceInLab(setup, ObservationFrame.LADDER, 1.1); + const leftPrimed = boostEvent(left, setup.beta); + const rightPrimed = boostEvent(right, setup.beta); + expect(rightPrimed.y - leftPrimed.y).toBeCloseTo(0, 10); + expect(rightPrimed.x - leftPrimed.x).toBeCloseTo(setup.ladderLength, 10); + } + }); + + it("keeps the barn's doors on their worldlines whichever frame slices them", () => { + for (const setup of SETUPS) { + for (const frame of FRAMES) { + const [left, right] = barnSliceInLab(setup, frame, 0.7); + expect(left.x).toBeCloseTo(-setup.barnLength / 2, 10); + expect(right.x).toBeCloseTo(setup.barnLength / 2, 10); + } + } + }); +}); + +describe("world sheets", () => { + it("gives the barn an upright strip of its own proper width", () => { + for (const setup of SETUPS) { + const corners = barnSheet(setup, 5); + expect(corners).toHaveLength(4); + for (const corner of corners) { + expect(Math.abs(corner.x)).toBeCloseTo(setup.barnLength / 2, 12); + } + } + }); + + it("leans the ladder's strip over by exactly β and narrows it by γ", () => { + for (const setup of SETUPS) { + const [bottomLeft, bottomRight, topRight, topLeft] = ladderSheet(setup, 5) as [ + Vector2, + Vector2, + Vector2, + Vector2, + ]; + expect(bottomRight.x - bottomLeft.x).toBeCloseTo(setup.ladderLength / gammaOf(setup.beta), 10); + expect(topRight.x - topLeft.x).toBeCloseTo(setup.ladderLength / gammaOf(setup.beta), 10); + expect((topLeft.x - bottomLeft.x) / (topLeft.y - bottomLeft.y)).toBeCloseTo(setup.beta, 10); + } + }); +}); + +describe("the scene window", () => { + it("brackets the whole pass and both slams", () => { + for (const setup of SETUPS) { + for (const frame of FRAMES) { + const half = sceneHalfWindow(setup, frame); + const times = slamTimes(setup, frame); + expect(half).toBeGreaterThanOrEqual(passHalfWindow(setup, frame)); + expect(half).toBeGreaterThanOrEqual(Math.abs(times.entrance)); + expect(half).toBeGreaterThanOrEqual(Math.abs(times.exit)); + expect(Number.isFinite(half)).toBe(true); + } + } + }); + + it("has the objects clear of each other at both ends of the pass", () => { + for (const setup of SETUPS) { + for (const frame of FRAMES) { + const half = passHalfWindow(setup, frame); + for (const time of [-half, half]) { + const { barn, ladder } = snapshotAt(setup, frame, time); + const overlap = Math.min(barn.right, ladder.right) - Math.max(barn.left, ladder.left); + expect(overlap).toBeCloseTo(0, 9); + } + } + } + }); +}); diff --git a/tests/memory-leak.test.ts b/tests/memory-leak.test.ts index 1870363..e96f60c 100644 --- a/tests/memory-leak.test.ts +++ b/tests/memory-leak.test.ts @@ -10,6 +10,7 @@ import { describe, expect, it } from "vitest"; import { SpecialRelativityModel } from "../src/common/model/SpecialRelativityModel.js"; import { TimeModel } from "../src/common/TimeModel.js"; +import { LengthContractionModel } from "../src/length-contraction/model/LengthContractionModel.js"; import { LightClockModel } from "../src/light-clock/model/LightClockModel.js"; import { RelativisticDopplerModel } from "../src/relativistic-doppler/model/RelativisticDopplerModel.js"; import { SpacetimeDiagramModel } from "../src/spacetime/model/SpacetimeDiagramModel.js"; @@ -76,6 +77,15 @@ const DISPOSABLE_MODELS: { readonly name: string; readonly createAndDispose: () return ref; }, }, + { + name: "LengthContractionModel", + createAndDispose: () => { + const model = new LengthContractionModel(); + const ref = new WeakRef(model); + model.dispose(); + return ref; + }, + }, { name: "TwinParadoxModel", createAndDispose: () => {