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: () => {