fuzzyworld

tower / commit

feat(tour): automatic flight, inside and out, on the world's flyers (D-085..D-087)

99a2821b9e355852e6678e01fc9a60f033cdbcac · Pavel Safronov · 2026-09-28 18:14:28 -0700 · parents 3e898b5156e7

Claude Code usage: this commit ~351.0K out / ~65.1M cached · cost unknown (no per-model trailer) · session 4 tally 700 in / 571.0K out / 107.2M cached · usage by commit and session

feat(tour): automatic flight, inside and out, on the world's flyers (D-085..D-087)

The scenic tour now hands the view from one of the world's flyers to the next every
12-18 s, dealt at random with a fade between: paragliders, gliders, light aircraft and
seaplanes, helicopters, an airliner, airships and giant airships near the axis, trains
and ships inside; spacecraft outside. A director points the camera at whatever can be
seen from the craft, scored through the same haze integral the renderer uses. No craft
enters the beam.

Adds a railway kind and two lines, the Axial Line and the Uplands Ring, laid on a grade
of their own over the ground. Records as canon that spacecraft dock anywhere on the
shell and that aircraft may fly round the beam but not into it.

Usage: 0.7K in / 571.0K out / 107.2M cached (session tally)
rtk:   1.1M saved, 17.6% over 3084 cmds (global/lifetime)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
 README.md                                       |  61 +-
 content/world.json                              |  84 +++
 decisions.md                                    | 102 +++
 design.md                                       |  55 +-
 docs/screenshots/desktop-detail.png             | Bin 1287548 -> 1290048 bytes
 docs/screenshots/desktop-exterior-true.png      | Bin 500422 -> 549207 bytes
 docs/screenshots/desktop-exterior.png           | Bin 566286 -> 564157 bytes
 docs/screenshots/desktop-interior-morning.png   | Bin 625335 -> 681845 bytes
 docs/screenshots/desktop-interior-night.png     | Bin 405112 -> 447830 bytes
 docs/screenshots/desktop-interior-overhead.png  | Bin 777984 -> 822672 bytes
 docs/screenshots/desktop-interior-suncap.png    | Bin 409280 -> 462620 bytes
 docs/screenshots/desktop-interior.png           | Bin 991436 -> 1053645 bytes
 docs/screenshots/desktop-map-sightline.png      | Bin 1104075 -> 1106067 bytes
 docs/screenshots/desktop-map.png                | Bin 1265774 -> 1265897 bytes
 docs/screenshots/desktop-tour-canopy.png        | Bin 923797 -> 910146 bytes
 docs/screenshots/desktop-tour-climb.png         | Bin 0 -> 790888 bytes
 docs/screenshots/desktop-tour-deep.png          | Bin 581412 -> 641161 bytes
 docs/screenshots/desktop-tour-docking.png       | Bin 0 -> 148229 bytes
 docs/screenshots/desktop-tour-falls-glide.png   | Bin 0 -> 993069 bytes
 docs/screenshots/desktop-tour-falls.png         | Bin 963669 -> 1004114 bytes
 docs/screenshots/desktop-tour-far-cap-peek.png  | Bin 0 -> 883591 bytes
 docs/screenshots/desktop-tour-far-cap.png       | Bin 0 -> 453086 bytes
 docs/screenshots/desktop-tour-giants.png        | Bin 187082 -> 314944 bytes
 docs/screenshots/desktop-tour-girdle.png        | Bin 846731 -> 1043188 bytes
 docs/screenshots/desktop-tour-grand.png         | Bin 0 -> 265291 bytes
 docs/screenshots/desktop-tour-hull-skim.png     | Bin 0 -> 142029 bytes
 docs/screenshots/desktop-tour-mesas.png         | Bin 839485 -> 945619 bytes
 docs/screenshots/desktop-tour-narrows.png       | Bin 855575 -> 0 bytes
 docs/screenshots/desktop-tour-pan.png           | Bin 804898 -> 0 bytes
 docs/screenshots/desktop-tour-ridge.png         | Bin 0 -> 1087176 bytes
 docs/screenshots/desktop-tour-river.png         | Bin 975937 -> 0 bytes
 docs/screenshots/desktop-tour-ship.png          | Bin 420149 -> 665661 bytes
 docs/screenshots/desktop-tour-squall.png        | Bin 408055 -> 876900 bytes
 docs/screenshots/desktop-tour-storm.png         | Bin 452358 -> 0 bytes
 docs/screenshots/desktop-tour-summit.png        | Bin 1054421 -> 1093972 bytes
 docs/screenshots/desktop-tour-sun-circuit.png   | Bin 0 -> 617454 bytes
 docs/screenshots/desktop-tour-thermal.png       | Bin 0 -> 890883 bytes
 docs/screenshots/desktop-tour-train-narrows.png | Bin 0 -> 902075 bytes
 docs/screenshots/desktop-tour-train-ring.png    | Bin 0 -> 845135 bytes
 docs/screenshots/mobile-exterior.png            | Bin 119960 -> 123340 bytes
 docs/screenshots/mobile-interior.png            | Bin 286596 -> 314026 bytes
 docs/screenshots/mobile-map.png                 | Bin 346189 -> 346514 bytes
 docs/screenshots/mobile-panel.png               | Bin 98089 -> 98093 bytes
 docs/screenshots/overview.png                   | Bin 3118980 -> 4169483 bytes
 index.html                                      |  14 +-
 src/canon/motion.ts                             |  14 +
 src/terrain/rails.ts                            | 125 ++++
 src/tour/director.ts                            | 326 +++++++++
 src/tour/exterior-legs.ts                       | 288 ++++++++
 src/tour/geo.ts                                 | 127 ++++
 src/tour/interior-legs.ts                       | 909 ++++++++++++++++++++++++
 src/tour/plan.ts                                |  52 ++
 src/ui/app.ts                                   |  23 +-
 src/views/craft.ts                              | 187 +++++
 src/views/exterior.ts                           | 157 +++-
 src/views/interior-model.ts                     | 136 +++-
 src/views/interior.ts                           | 197 +++--
 src/views/map.ts                                |   2 +-
 src/views/palette.ts                            |   1 +
 src/views/scenic.ts                             | 514 --------------
 src/views/tour-player.ts                        | 162 +++++
 src/world/schema.ts                             |  22 +-
 test/scenic.test.ts                             |  83 ---
 test/terrain.test.ts                            |   5 +-
 test/tour.test.ts                               | 245 +++++++
 tools/screenshots.mjs                           | 189 +----
 world-generation.md                             |  89 ++-
 67 files changed, 3235 insertions(+), 934 deletions(-)

diff --git a/README.md b/README.md
index 59f5d34..9d5fdc4 100644
--- a/README.md
+++ b/README.md
@@ -32,7 +32,9 @@ nothing passive would stay in the beam past mid-afternoon. It is a person.
 
 Built: the unrolled surface map, Tower from outside, standing inside it and flying
 over it, a detail card for every place, Tower's published weather, a sun that crosses
-the world each day on Sydney's clock, permalinks, and the sightline probe.
+the world each day on Sydney's clock, two railways, a scenic tour that flies the world's
+paragliders, aircraft, airships, trains and spacecraft inside and out, permalinks, and the
+sightline probe.
 
 **The app is for looking; the world is changed in the repository.** The author writes
 alongside Tower rather than in it, and changes the world by talking it through with
@@ -96,18 +98,26 @@ another machine will show a large one-off divergence rather than a regression.
 | [exterior-true](docs/screenshots/desktop-exterior-true.png) | true hull — bare skin, and the land visible through the far cap window |
 | [interior](docs/screenshots/desktop-interior.png) | standing at The Narrows, the ground curving up on both sides |
 | [tour-falls](docs/screenshots/desktop-tour-falls.png) | scenic tour: a helicopter circling the Long Water's 680 m fall |
+| [tour-falls-glide](docs/screenshots/desktop-tour-falls-glide.png) | scenic tour: a paraglider past the same fall |
 | [tour-ship](docs/screenshots/desktop-tour-ship.png) | scenic tour: following a ship round the Sunward Sea |
-| [tour-summit](docs/screenshots/desktop-tour-summit.png) | scenic tour: round Snowcrown's summit, 4,300 m |
-| [tour-river](docs/screenshots/desktop-tour-river.png) | scenic tour: down the Long Water to its 167 m fall into The Deep |
 | [tour-squall](docs/screenshots/desktop-tour-squall.png) | scenic tour: a ship caught out by a gale Tower gave no warning of |
+| [tour-summit](docs/screenshots/desktop-tour-summit.png) | scenic tour: an airship off Snowcrown, 4,300 m |
+| [tour-ridge](docs/screenshots/desktop-tour-ridge.png) | scenic tour: a paraglider ridge-soaring on a summit's flank |
+| [tour-thermal](docs/screenshots/desktop-tour-thermal.png) | scenic tour: a glider climbing in a thermal over the Dry Reach |
+| [tour-mesas](docs/screenshots/desktop-tour-mesas.png) | scenic tour: past the mesas of the Dry Reach, strata lining up across all of them |
 | [tour-giants](docs/screenshots/desktop-tour-giants.png) | scenic tour: under the giants of the Tall Wood, 115 m of them |
 | [tour-canopy](docs/screenshots/desktop-tour-canopy.png) | scenic tour: low over Greatwood, closed canopy and the meadows left open in it |
-| [tour-mesas](docs/screenshots/desktop-tour-mesas.png) | scenic tour: past the mesas of the Dry Reach, strata lining up across all of them |
-| [tour-pan](docs/screenshots/desktop-tour-pan.png) | scenic tour: over a salt pan, ringed by the shorelines of a lake that never stood |
-| [tour-narrows](docs/screenshots/desktop-tour-narrows.png) | scenic tour: through The Narrows, the one gap in all three ridges |
-| [tour-deep](docs/screenshots/desktop-tour-deep.png) | scenic tour: out from the Capital over The Deep |
-| [tour-storm](docs/screenshots/desktop-tour-storm.png) | scenic tour: the published storm on the Sunward Sea, every ship in harbour |
-| [tour-girdle](docs/screenshots/desktop-tour-girdle.png) | scenic tour: nine kilometres over the Girdle |
+| [tour-deep](docs/screenshots/desktop-tour-deep.png) | scenic tour: a seaplane out from the Capital over The Deep |
+| [tour-girdle](docs/screenshots/desktop-tour-girdle.png) | scenic tour: an airliner over the Girdle, antispinward and lighter for it |
+| [tour-climb](docs/screenshots/desktop-tour-climb.png) | scenic tour: a giant airship climbing out of the Capital, the world closing overhead |
+| [tour-sun-circuit](docs/screenshots/desktop-tour-sun-circuit.png) | scenic tour: a giant airship round the sun, outside the beam |
+| [tour-far-cap](docs/screenshots/desktop-tour-far-cap.png) | scenic tour: toward the far cap by night |
+| [tour-train-narrows](docs/screenshots/desktop-tour-train-narrows.png) | scenic tour: a train on the Axial Line through The Narrows |
+| [tour-train-ring](docs/screenshots/desktop-tour-train-ring.png) | scenic tour: a train on the Uplands Ring |
+| [tour-grand](docs/screenshots/desktop-tour-grand.png) | scenic tour: outside: Tower, its collector and the sun in one frame |
+| [tour-docking](docs/screenshots/desktop-tour-docking.png) | scenic tour: outside: a spacecraft docking on the shell |
+| [tour-far-cap-peek](docs/screenshots/desktop-tour-far-cap-peek.png) | scenic tour: outside: looking in through the far cap |
+| [tour-hull-skim](docs/screenshots/desktop-tour-hull-skim.png) | scenic tour: outside: skimming the hull as it turns |
 | [interior-overhead](docs/screenshots/desktop-interior-overhead.png) | looking up: the far side 100 km overhead is clearer than ground 100 km away |
 | [interior-morning](docs/screenshots/desktop-interior-morning.png) | 06:30 — the sun just out from the sun cap and coming, the rod lit only behind it |
 | [detail](docs/screenshots/desktop-detail.png) | a place selected: its detail card, with what the world made of it |
@@ -121,7 +131,7 @@ another machine will show a large one-off divergence rather than a regression.
 
 ### Looking before committing
 
-[`overview.png`](docs/screenshots/overview.png) is a contact sheet of all ten. It
+[`overview.png`](docs/screenshots/overview.png) is a contact sheet of every shot. It
 exists to be **looked at, once, before committing** — one image that answers "is the
 application still there?". Regenerate, glance at it, then commit.
 
@@ -155,7 +165,7 @@ npm run dev      # http://localhost:5173, plus a LAN URL and a QR code for it
 npm test         # 175 tests; canon figures are pinned to design.md
 npm run build
 npm run screenshots  # drive the app and rephotograph docs/screenshots/
-npm run screenshots -- --only tour-storm   # just those shots, for working on one view
+npm run screenshots -- --only tour-grand   # just those shots, for working on one view
 ```
 
 `npm run dev` binds to every interface (`vite --host`) and prints a scannable QR
@@ -297,7 +307,11 @@ wears a layer of dust, thickest on whatever faces outward. **Rock and piping** p
 two together: the plant half buried under banks of rock heaped on as shielding, bare
 plating and plant rooms in the gaps, the bands and pipe runs going under the rock and out
 again, and the rock dug clear round each thruster pod. The active view is remembered and travels in the permalink, so
-`#view=exterior&place=the-narrows` opens where you left off. Drag to orbit, scroll to zoom, click the hull to drop a point; the spin runs at
+`#view=exterior&place=the-narrows` opens where you left off. Drag to orbit, scroll to zoom, click the hull to drop a point.
+**Scenic tour** in the exterior's panel flies spacecraft instead: skimming the hull while it
+turns under them at 700 m/s, docking on it — ships dock anywhere on the shell, and hang from
+it at 1.02 g — looking in through the far cap, over the collector's rim, down the outside of
+the beam, and Tower, the collector and the sun in one frame; the spin runs at
 1x to 1200x real time, and a point picked off the turning hull still comes back in
 canon coordinates.
 
@@ -352,11 +366,17 @@ since called it off, because people act on what they were told — and a squall
 too late catches them at sea. Set the **Day** under **Time**, or run the clock, to move
 through the plan.
 
-**Or take the scenic tour.** *Scenic tour* in the view panel flies you round the sights,
-a new one every thirty seconds: circling the great waterfall, following a ship, round
-Snowcrown, down the Long Water into The Deep, a ship caught in a squall, through The
-Narrows, over The Deep, a storm at sea, and high over the Girdle. **N** skips ahead;
-**Esc**, a drag or a flight key stops it and leaves you where it was.
+**Or take the scenic tour.** *Scenic tour* in the view panel hands you from one of the
+world's flyers to the next, every twelve to eighteen seconds, in an order dealt at random:
+a paraglider on Snowcrown's flank, a glider circling up in a thermal over the Dry Reach, a
+helicopter round the great waterfall, a seaplane across The Deep, an airliner round the
+Girdle — flying antispinward, and so weighing a third of what it would at rest — a giant
+airship climbing out of the Capital until the world closes overhead, another circling the
+sun, outside the beam as every aircraft must be, trains on the two railways, and ships in
+fair weather and in a squall. The camera rides in the craft or chases it, banks with it,
+and turns to whatever can be seen from there — never at something the haze has eaten. The
+slow legs are time-lapses and the caption says so. **N** skips ahead; **Esc**, a drag or a
+flight key stops it and leaves you where it was.
 
 **Or fly.** W A S D or the arrow keys move you, E climbs and Q descends, and shift
 goes five times faster. Speed follows your height above the ground: a brisk walk
@@ -477,6 +497,13 @@ water Tower quotes there — dry washes, how many salt pans, and whether they ar
 the shorelines of lakes that never stood. The detail card says what was actually made:
 "24% standing rock, 37% dune field, 35% cut by washes, 4 salt pans."
 
+A **railway** is a line with a line speed. Its track keeps a grade of its own over the
+ground rather than cutting it — the highest ground within a kilometre either side, eased —
+so it stands on piers much of the way and crosses the gorges on viaducts. There are two:
+the **Axial Line**, from Sisters Station through The Narrows and past the Capital to the
+Deepward Sea, and the **Uplands Ring**, round the world at 140 km, whose trains weigh 31%
+more running spinward and 27% less running antispinward.
+
 A **region** is a label, and it can also be ground Tower raised: give it an uplift and
 everything inside it is lifted to that height above the open ground, wherever that is
 higher than what is there. The Uplands are one.
diff --git a/content/world.json b/content/world.json
index 0dcdc5b..25836fd 100644
--- a/content/world.json
+++ b/content/world.json
@@ -930,6 +930,90 @@
       },
       "size": "station"
     },
+    {
+      "id": "the-axial-line",
+      "name": "The Axial Line",
+      "blurb": "The railway down the length of the world: from Sisters Station across the Uplands, through The Narrows beside The Long Water, down the shore of The Deep past the Capital, and through Greatwood to the Deepward Sea.",
+      "notes": "D-086. Laid by Claude at the author's request (2026-09-28), as a line that seemed reasonable: the only way along the axis is through The Narrows, so the railway goes that way too. Runs on the floor where it is level and on viaducts where it is not; Tower does not cut the ground for it.",
+      "public": true,
+      "seed": 1476168498,
+      "kind": "railway",
+      "geometry": {
+        "type": "polyline",
+        "points": [
+          [-0.47, 110500],
+          [-0.43, 114000],
+          [-0.3, 122000],
+          [-0.16, 131000],
+          [-0.06, 140000],
+          [0.0, 149000],
+          [0.025, 158000],
+          [0.032, 165000],
+          [0.032, 170000],
+          [-0.008, 178000],
+          [0.017, 186000],
+          [-0.018, 194000],
+          [0.007, 201000],
+          [-0.013, 208000],
+          [0.005, 216000],
+          [0.0, 226000],
+          [-0.005, 240000],
+          [0.0, 255000],
+          [0.03, 270000],
+          [0.0, 285000],
+          [-0.005, 310000],
+          [0.0, 340000],
+          [-0.005, 370000],
+          [0.0, 400000],
+          [0.0, 430000],
+          [0.005, 455000],
+          [0.0, 480000]
+        ]
+      },
+      "speedMS": 70
+    },
+    {
+      "id": "the-uplands-ring",
+      "name": "The Uplands Ring",
+      "blurb": "The one railway that goes round the world rather than along it, across the Uplands at 140 km. A train on it running spinward weighs a third more; running antispinward, a quarter less.",
+      "notes": "D-086. Laid by Claude at the author's request (2026-09-28). The Uplands are level at about 1,000 m all the way round, which is why the ring is here. At 100 m/s a passenger weighs 1.31 times what they weigh standing beside the line going spinward, and 0.73 times going antispinward (canon/motion.ts apparentGravity). It crosses the four rivers off the range, and meets The Axial Line near θ -0.06.",
+      "public": true,
+      "seed": 1180007981,
+      "kind": "railway",
+      "geometry": {
+        "type": "polyline",
+        "points": [
+          [0.0, 140000],
+          [0.241661, 140000],
+          [0.483322, 140000],
+          [0.724983, 140000],
+          [0.966644, 140000],
+          [1.208305, 140000],
+          [1.449966, 140000],
+          [1.691627, 140000],
+          [1.933288, 140000],
+          [2.174949, 140000],
+          [2.41661, 140000],
+          [2.658271, 140000],
+          [2.899932, 140000],
+          [3.141593, 140000],
+          [3.383254, 140000],
+          [3.624915, 140000],
+          [3.866576, 140000],
+          [4.108237, 140000],
+          [4.349898, 140000],
+          [4.591558, 140000],
+          [4.833219, 140000],
+          [5.07488, 140000],
+          [5.316541, 140000],
+          [5.558202, 140000],
+          [5.799863, 140000],
+          [6.041524, 140000],
+          [6.283185307179586, 140000]
+        ]
+      },
+      "speedMS": 100
+    },
     {
       "id": "the-tall-wood",
       "name": "The Tall Wood",
diff --git a/decisions.md b/decisions.md
index 7892a8e..162bf04 100644
--- a/decisions.md
+++ b/decisions.md
@@ -2826,3 +2826,105 @@ is white — a brilliantly sunlit cloud, 300 m off, as §1.3 describes it.
 the old slider's label measured from the ground up and read 0.044 g at the top where the
 readout said 0.038 g.
 
+
+## D-085 — The scenic tour flies the world's flyers, inside and out
+
+**Asked for** (author, 2026-09-28): automatic flight, to show Tower off inside and out —
+a flight plan put together at random from components, a camera that points at interesting
+things as it goes, about fifteen seconds before changing from one flyer to another, and
+every point of view belonging to something in the world: paragliders, planes, ships,
+spacecraft, trains. Planned before it was built, and the plan was agreed with these answers:
+
+- **A craft exists for its leg.** Nothing is simulated as traffic when nobody is watching;
+  a paraglider is there for the fifteen seconds the camera rides it. The ships, which were
+  already in the world (D-067), are followed as they are.
+- **Legs last 12 to 18 seconds**, each its own length, and **fade through black** between.
+- **Airships, yes**: a few ordinary ones low down, and a few slow giants near the axis.
+- **It replaces D-068's tour**, whose nine thirty-second scenes are now legs among the rest.
+
+**Built.** `world-generation.md` §19 has the detail.
+
+**A leg is a craft on a path, and a camera on the craft.** Twenty-six inside — a helicopter
+round the Long Water's fall and a paraglider across its face, ships in fair weather and in
+a squall, a paraglider on a summit's flank, a glider in a thermal over the Dry Reach, light
+aircraft down rivers and through The Narrows, a seaplane across The Deep, an airliner round
+the Girdle, a giant airship climbing out of the Capital and another circling the sun, a
+night flight toward the far cap, trains on both railways (D-086) — and nine outside, all
+spacecraft: skimming the hull, circling it against its spin, docking on it, looking in
+through the far cap, crossing the sun cap, over the collector's rim, down the beam, pulling
+away, and Tower, the collector and the sun in one frame. The camera rides in the craft, or
+behind it on the far side from what it is looking at, so the craft and its subject share
+the frame.
+
+**The camera is pointed by a director**, not by hand. Everything worth a look — summits,
+falls, lakes, the Capital, the Tall Wood, the sun, both caps, ships — is scored from the
+craft by how big it looks, how clearly it can be seen through the air (the same integral of
+the density profile the renderer hazes with), whether the ground hides it, and whether a
+pilot could look that way. The leg's own subject wins most arguments; anything looked at for
+long starts to bore it; turns are eased over three seconds and never swing faster than
+0.7 rad/s. So the tour does not stare at haze, and the far side of the world gets looked at,
+because §1.8 says it is the clearest thing in the sky.
+
+**The deal is a deck.** Every leg is flown once before any is flown twice, no two in a row
+are the same kind of craft, two legs about the same subject are kept apart where they can
+be, and a new deck never opens on the craft the last one closed on.
+
+**Some legs are time-lapses, and say so.** A giant airship takes minutes to climb ten
+kilometres and circles the sun at 28 m/s, so those legs run the world's clock ×20 to ×150,
+and the caption says "time ×150". Everything else is real time.
+
+**What the numbers say, the captions say.** Moving round the world changes what you weigh
+(`canon/motion.ts` `apparentGravity`): the Girdle's airliner, flying antispinward at 230 m/s
+nine kilometres up, weighs about a third of what it would at rest, and a train on the Uplands
+Ring is 31% heavier one way round and 27% lighter the other. Every circumferential journey in
+Tower has a handedness, as every slope does for a skier (§1.11).
+
+**Outside, the hull turns at the leg's rate** — real time — whatever the Speed slider says,
+so a craft skimming it sees 700 m/s of hull go by and not a blur. A leg flies in one of two
+frames: space, where the hull wheels past, or the hull's own, where it holds still and the
+stars wheel, which is what docking looks like. The near plane follows the nearest thing, so
+a craft 400 m off is not clipped.
+
+## D-086 — Two railways: the Axial Line and the Uplands Ring
+
+**Asked for** (author, 2026-09-28): trains in the tour, with the lines "the ones you find
+reasonable". There were no railways in the world, so there is now a `railway` kind in
+world.json — a line, with a line speed — and two of them, chosen and laid by Claude:
+
+- **The Axial Line**, 378 km, 70 m/s: from Sisters Station across the Uplands, through **The
+  Narrows** beside The Long Water — the only way along the world goes through the only gap
+  in the ridges, for rails as for rivers — down The Deep's shore past the Capital, and through
+  Greatwood to the shore of the Deepward Sea.
+- **The Uplands Ring**, 314 km, 100 m/s: the one line round the world rather than along it,
+  at z 140 km, where the Uplands are level at about 1,000 m the whole way round and no water
+  stands. It crosses the four rivers off the range and meets the Axial Line near θ −0.06.
+
+**The track keeps its own grade, over the ground.** `terrain/rails.ts` lays the deck at the
+highest ground within a kilometre either side along the line, eased over 900 m, so it is never
+below the ground and a train never runs through a hill. Tower does not cut the ground for it.
+The cost of that is honesty about viaducts: the deck stands 10–20 m up on piers over most of
+both lines, and up to 260 m where the Ring crosses a gorge. The steepest grade is 7%, where
+the Axial Line comes down off the plateau into Greatwood; all but 23 km of it is under 3%,
+and the Ring never passes 3.7%. A kilometre window was measured against 400 m and 1.5 km:
+shorter made grades of 17–28%, longer made the whole line a viaduct.
+
+The map draws them as lines in their own colour, and the interior draws the deck, its
+girders and a pier every 100 m wherever it stands clear of the ground.
+
+## D-087 — Where spacecraft dock, and where aircraft may not go
+
+**Canon, from the author** (2026-09-28), answering questions the tour raised:
+
+- **Spacecraft can dock anywhere on the outer shell.** A docked ship turns with the hull at
+  700 m/s, and the hull holds it on that circle: it hangs from its clamps, pressed outward at
+  1.02 g (ω² × 51 km), its feet toward the hull and its floor toward space. The tour's docking
+  leg matches the spin and sets a ship down that way.
+- **Aircraft are not allowed into the beam, but they can fly around it** — and round the sun,
+  which is in the beam (§1.3.1). Every craft in the tour keeps at least 3 km clear of it,
+  inside and out, and the tests check that.
+
+**Consequences worked out with it, not canon until the author says so:** the giant airships
+live near the axis because only there do they have to. At 44 km up the air is about a
+twentieth of the floor's density and weighs about a tenth, and what a gasbag lifts per cubic
+metre depends on the density of the air it displaces, not on gravity — so an airship there
+needs some twenty times the volume for the same load. Hence giants, 420 m long, and slow.
diff --git a/design.md b/design.md
index 4144937..f8cbe6a 100644
--- a/design.md
+++ b/design.md
@@ -349,6 +349,11 @@ puts Earth roughly 90° off the axis — out the *side* of the cylinder, which i
 solid hull. A habitat in Earth orbit whose inhabitants have never seen the planet
 they orbit.
 
+**Spacecraft dock anywhere on the outer shell** (author, 2026-09-28; D-087). There is no
+port: a ship matches the hull's spin and sets down where it likes. Docked, it turns with the
+hull at 700 m/s and hangs from its clamps, pressed outward at 1.02 g — its feet toward the
+hull and its floor toward space.
+
 The caps are therefore the only places in the world with a horizon that is not
 Tower. Whatever grows up around them, it will not be ordinary.
 
@@ -591,6 +596,14 @@ along the axis**, which is the natural grain of an engineered climate (§1.9.4):
 | 248–402 km | **The Deep** | 154 × 32 km sea, long down the axis and narrow around |
 | 270 km | **The Capital / The Spindle** | axial midpoint; 20 km across, its spinward edge on The Deep's shore (D-070) |
 | 496–540 km | **The Deepward Sea** | ring ocean at the foot of the far cap |
+| 110–480 km | **The Axial Line** | railway down the length of the world, through The Narrows (D-086) |
+| 140 km | **The Uplands Ring** | railway round the world across the Uplands (D-086) |
+
+The railways keep a grade of their own over the ground rather than cutting it, so they run
+on piers much of the way: 10–20 m up over ordinary country, and on long viaducts over the
+rivers' gorges. **A train going round the world has a handedness**, like a ski run: the
+Ring's trains, at 100 m/s, are 31% heavier running spinward and 27% lighter running
+antispinward (`canon/motion.ts`); a train along the axis feels nothing.
 
 Both ring seas carry ninety sailing ships each, and Tower's published weather plan
 (D-067) runs a 28-day cycle of storms, gales and a calm over them and The Deep.
@@ -741,6 +754,7 @@ src/
     water.ts        wind seas, fetch, river speed and foam: the water's physics
     sea-state.ts    every lake and sea's waves and whitecaps now, from the weather
     ships.ts        ship lanes, positions from the clock, the wave sum they ride
+    rails.ts        railway lines, and the grade their track keeps over the ground
     falls.ts        waterfall sheets, leaning antispinward as they fall
     cache.ts        the compiled world kept in IndexedDB between visits
     field.ts        the elevation function: one answer to "how high is it here"
@@ -754,8 +768,15 @@ src/
   views/
     palette.ts      one colour per feature kind, shared by the map and the list
     terrain-lod.ts  the ground as a quadtree of tiles, refined around the eye
-    scenic.ts       the scenic tour's camera moves, worked out from the world
+    craft.ts        the tour's flyers as meshes: paragliders to giant airships, trains, spacecraft
+    tour-player.ts  plays the tour's legs: the deal, the fades, the caption
     forest-view.ts  the trees near the eye: meshes, then cards, then the canopy skin
+  tour/             the scenic tour, pure: no Three.js, flown by the views and the tests
+    geo.ts          the floor's coordinates and the tower's frame, and the sums between them
+    director.ts     a craft's track and bank, what is worth looking at, where the camera rides
+    interior-legs.ts  the legs inside, found in the compiled world
+    exterior-legs.ts  the legs outside, in kilometres, clear of the beam
+    plan.ts         the deal: every leg once, no two of a kind in a row, 12–18 s each
   world/
     schema.ts       world.json v2 types + validation: features, not places
     instruments.ts  sightline, viewshed, lighting probe, travel (§3.3)
@@ -901,6 +922,12 @@ triangles across the whole image.
 **The camera position persists** across reloads, alongside the active view, so
 returning to the page returns to the vantage you left.
 
+**The scenic tour runs outside too** (D-085): spacecraft, a new one every 12–18 seconds —
+skimming the hull, circling it against its spin, docking on it, looking in through the far
+cap, over the collector's rim, down the beam, and Tower, the collector and the sun framed
+together. A leg flies either in space, where the hull turns past at real speed, or in the
+hull's own frame, where it holds still and the stars wheel. No craft enters the beam's cone.
+
 **There are no place markers** (D-060). Selecting a place still swings the camera
 round to it, and a cap is framed from about 40° off the axis so its window faces the
 camera; the sidebar and the map carry the places themselves.
@@ -1002,10 +1029,16 @@ drawn under it with the mainsail set to the wind. While a gale or storm is publi
 their sea in time to get in, they are in harbour — off the water, since harbours are not
 drawn yet; a squall announced too late catches them out.
 
-**The scenic tour** (D-068) flies the view itself: a scene every thirty seconds — circling
-a waterfall or a summit, flying down a river or over The Deep, a chase camera on a ship in
-calm or in a squall — fading through black between them. The scenes are worked out from
-the world, so they follow its edits, and the screenshot tour photographs them.
+**The scenic tour** (D-085, replacing D-068's) flies the view itself, on the world's flyers:
+a new one every 12–18 seconds, dealt at random, fading through black between them — a
+paraglider on a summit's flank, a glider in a thermal, light aircraft and seaplanes, an
+airliner round the Girdle, airships low down and giants near the axis, one of them circling
+the sun outside the beam, trains on both railways, ships in fair weather and foul. Each
+craft exists for its leg. The camera rides in it or chases it, banks with it, and is
+pointed by a director at whatever is worth seeing from there — scored by the same haze
+integral the view is drawn with, so it never turns to stare at something the air has eaten.
+The legs are worked out from the world, so they follow its edits, and the screenshot tour
+photographs them.
 
 **Waterfalls lean.** A river's profile steps where it cannot slope — off a range, into
 The Deep — and a step is a fall with a gorge below it. The falling sheet leaves the lip at
@@ -1060,7 +1093,7 @@ reader mode (`?reader=1`); the query parameter now does nothing.
   "features": [{
     "id": "the-deep",                   // permalink target; see below
     "name": "The Deep",
-    "kind": "mountains|lake|sea|river|desert|forest|settlement|road|path|embassy|tower|landmark|note|region",
+    "kind": "mountains|lake|sea|river|desert|forest|settlement|road|path|railway|embassy|tower|landmark|note|region",
     "geometry": { "type": "polygon", "points": [[theta, z], ...] },
     //        or point, polyline, or band { "zFrom", "zTo" } for anything ringing the world
     "blurb": "public description, shown to readers",
@@ -1194,3 +1227,13 @@ Things decided to be worth coming back to, not scheduled.
   if a heavy one stays, answer for its mass. The combined dress gives the rock a reason
   (shielding heaped on the plant), but taller and over three fifths of the hull it weighs
   about what D-076's shell does, so the mass question stands.
+- **Traffic** (D-085). The tour's craft exist only for their legs: nothing flies, sails the
+  sky or runs the railways when the tour is not watching, except the ships. A world with
+  timetables — trains on the lines, airliners between the stations, giants on their slow
+  circuits — would be the honest next step, and would let a reader standing anywhere see
+  them go by.
+- **The Axial Line's 7% grade** (D-086), where it comes down off the plateau into Greatwood,
+  is a rack railway's grade, not an adhesion one. Regrade it round the slope, or say it is a
+  rack section.
+- **The giant airships** (D-087) are a consequence worked out from the thin air near the
+  axis, not canon. Whether Tower's sky has them is the author's call.
diff --git a/docs/screenshots/desktop-detail.png b/docs/screenshots/desktop-detail.png
index 7327688..fded338 100644
Binary files a/docs/screenshots/desktop-detail.png and b/docs/screenshots/desktop-detail.png differ
diff --git a/docs/screenshots/desktop-exterior-true.png b/docs/screenshots/desktop-exterior-true.png
index 1f04b64..2b1f072 100644
Binary files a/docs/screenshots/desktop-exterior-true.png and b/docs/screenshots/desktop-exterior-true.png differ
diff --git a/docs/screenshots/desktop-exterior.png b/docs/screenshots/desktop-exterior.png
index ec22ff8..d677705 100644
Binary files a/docs/screenshots/desktop-exterior.png and b/docs/screenshots/desktop-exterior.png differ
diff --git a/docs/screenshots/desktop-interior-morning.png b/docs/screenshots/desktop-interior-morning.png
index 3cbb2e1..b9cdcee 100644
Binary files a/docs/screenshots/desktop-interior-morning.png and b/docs/screenshots/desktop-interior-morning.png differ
diff --git a/docs/screenshots/desktop-interior-night.png b/docs/screenshots/desktop-interior-night.png
index 92adc1f..af438e4 100644
Binary files a/docs/screenshots/desktop-interior-night.png and b/docs/screenshots/desktop-interior-night.png differ
diff --git a/docs/screenshots/desktop-interior-overhead.png b/docs/screenshots/desktop-interior-overhead.png
index 9e5c485..3ba21bb 100644
Binary files a/docs/screenshots/desktop-interior-overhead.png and b/docs/screenshots/desktop-interior-overhead.png differ
diff --git a/docs/screenshots/desktop-interior-suncap.png b/docs/screenshots/desktop-interior-suncap.png
index b1dd0a0..ade0fbf 100644
Binary files a/docs/screenshots/desktop-interior-suncap.png and b/docs/screenshots/desktop-interior-suncap.png differ
diff --git a/docs/screenshots/desktop-interior.png b/docs/screenshots/desktop-interior.png
index 8840367..5c21ce4 100644
Binary files a/docs/screenshots/desktop-interior.png and b/docs/screenshots/desktop-interior.png differ
diff --git a/docs/screenshots/desktop-map-sightline.png b/docs/screenshots/desktop-map-sightline.png
index 6078db5..ae17180 100644
Binary files a/docs/screenshots/desktop-map-sightline.png and b/docs/screenshots/desktop-map-sightline.png differ
diff --git a/docs/screenshots/desktop-map.png b/docs/screenshots/desktop-map.png
index cb7555b..c5eaa7d 100644
Binary files a/docs/screenshots/desktop-map.png and b/docs/screenshots/desktop-map.png differ
diff --git a/docs/screenshots/desktop-tour-canopy.png b/docs/screenshots/desktop-tour-canopy.png
index 2e9b6d6..a720a53 100644
Binary files a/docs/screenshots/desktop-tour-canopy.png and b/docs/screenshots/desktop-tour-canopy.png differ
diff --git a/docs/screenshots/desktop-tour-climb.png b/docs/screenshots/desktop-tour-climb.png
new file mode 100644
index 0000000..c087bfa
Binary files /dev/null and b/docs/screenshots/desktop-tour-climb.png differ
diff --git a/docs/screenshots/desktop-tour-deep.png b/docs/screenshots/desktop-tour-deep.png
index 0cfb424..667d9c4 100644
Binary files a/docs/screenshots/desktop-tour-deep.png and b/docs/screenshots/desktop-tour-deep.png differ
diff --git a/docs/screenshots/desktop-tour-docking.png b/docs/screenshots/desktop-tour-docking.png
new file mode 100644
index 0000000..6a98485
Binary files /dev/null and b/docs/screenshots/desktop-tour-docking.png differ
diff --git a/docs/screenshots/desktop-tour-falls-glide.png b/docs/screenshots/desktop-tour-falls-glide.png
new file mode 100644
index 0000000..ea101db
Binary files /dev/null and b/docs/screenshots/desktop-tour-falls-glide.png differ
diff --git a/docs/screenshots/desktop-tour-falls.png b/docs/screenshots/desktop-tour-falls.png
index 6d8c460..8d3e7c4 100644
Binary files a/docs/screenshots/desktop-tour-falls.png and b/docs/screenshots/desktop-tour-falls.png differ
diff --git a/docs/screenshots/desktop-tour-far-cap-peek.png b/docs/screenshots/desktop-tour-far-cap-peek.png
new file mode 100644
index 0000000..dbcf3b4
Binary files /dev/null and b/docs/screenshots/desktop-tour-far-cap-peek.png differ
diff --git a/docs/screenshots/desktop-tour-far-cap.png b/docs/screenshots/desktop-tour-far-cap.png
new file mode 100644
index 0000000..2a3a723
Binary files /dev/null and b/docs/screenshots/desktop-tour-far-cap.png differ
diff --git a/docs/screenshots/desktop-tour-giants.png b/docs/screenshots/desktop-tour-giants.png
index 6002d77..9f213bb 100644
Binary files a/docs/screenshots/desktop-tour-giants.png and b/docs/screenshots/desktop-tour-giants.png differ
diff --git a/docs/screenshots/desktop-tour-girdle.png b/docs/screenshots/desktop-tour-girdle.png
index 346036d..78444e5 100644
Binary files a/docs/screenshots/desktop-tour-girdle.png and b/docs/screenshots/desktop-tour-girdle.png differ
diff --git a/docs/screenshots/desktop-tour-grand.png b/docs/screenshots/desktop-tour-grand.png
new file mode 100644
index 0000000..58ec769
Binary files /dev/null and b/docs/screenshots/desktop-tour-grand.png differ
diff --git a/docs/screenshots/desktop-tour-hull-skim.png b/docs/screenshots/desktop-tour-hull-skim.png
new file mode 100644
index 0000000..69e65cf
Binary files /dev/null and b/docs/screenshots/desktop-tour-hull-skim.png differ
diff --git a/docs/screenshots/desktop-tour-mesas.png b/docs/screenshots/desktop-tour-mesas.png
index 2bfd0e3..8967fb7 100644
Binary files a/docs/screenshots/desktop-tour-mesas.png and b/docs/screenshots/desktop-tour-mesas.png differ
diff --git a/docs/screenshots/desktop-tour-narrows.png b/docs/screenshots/desktop-tour-narrows.png
deleted file mode 100644
index 36d2464..0000000
Binary files a/docs/screenshots/desktop-tour-narrows.png and /dev/null differ
diff --git a/docs/screenshots/desktop-tour-pan.png b/docs/screenshots/desktop-tour-pan.png
deleted file mode 100644
index 04b545c..0000000
Binary files a/docs/screenshots/desktop-tour-pan.png and /dev/null differ
diff --git a/docs/screenshots/desktop-tour-ridge.png b/docs/screenshots/desktop-tour-ridge.png
new file mode 100644
index 0000000..6de2231
Binary files /dev/null and b/docs/screenshots/desktop-tour-ridge.png differ
diff --git a/docs/screenshots/desktop-tour-river.png b/docs/screenshots/desktop-tour-river.png
deleted file mode 100644
index ec72fb8..0000000
Binary files a/docs/screenshots/desktop-tour-river.png and /dev/null differ
diff --git a/docs/screenshots/desktop-tour-ship.png b/docs/screenshots/desktop-tour-ship.png
index cce9bfb..60d1182 100644
Binary files a/docs/screenshots/desktop-tour-ship.png and b/docs/screenshots/desktop-tour-ship.png differ
diff --git a/docs/screenshots/desktop-tour-squall.png b/docs/screenshots/desktop-tour-squall.png
index cb77510..809b890 100644
Binary files a/docs/screenshots/desktop-tour-squall.png and b/docs/screenshots/desktop-tour-squall.png differ
diff --git a/docs/screenshots/desktop-tour-storm.png b/docs/screenshots/desktop-tour-storm.png
deleted file mode 100644
index 94d972a..0000000
Binary files a/docs/screenshots/desktop-tour-storm.png and /dev/null differ
diff --git a/docs/screenshots/desktop-tour-summit.png b/docs/screenshots/desktop-tour-summit.png
index 82123a8..425c8b8 100644
Binary files a/docs/screenshots/desktop-tour-summit.png and b/docs/screenshots/desktop-tour-summit.png differ
diff --git a/docs/screenshots/desktop-tour-sun-circuit.png b/docs/screenshots/desktop-tour-sun-circuit.png
new file mode 100644
index 0000000..a47143d
Binary files /dev/null and b/docs/screenshots/desktop-tour-sun-circuit.png differ
diff --git a/docs/screenshots/desktop-tour-thermal.png b/docs/screenshots/desktop-tour-thermal.png
new file mode 100644
index 0000000..f770b85
Binary files /dev/null and b/docs/screenshots/desktop-tour-thermal.png differ
diff --git a/docs/screenshots/desktop-tour-train-narrows.png b/docs/screenshots/desktop-tour-train-narrows.png
new file mode 100644
index 0000000..5ad486f
Binary files /dev/null and b/docs/screenshots/desktop-tour-train-narrows.png differ
diff --git a/docs/screenshots/desktop-tour-train-ring.png b/docs/screenshots/desktop-tour-train-ring.png
new file mode 100644
index 0000000..65c145d
Binary files /dev/null and b/docs/screenshots/desktop-tour-train-ring.png differ
diff --git a/docs/screenshots/mobile-exterior.png b/docs/screenshots/mobile-exterior.png
index 1cfa5b6..f3ce31f 100644
Binary files a/docs/screenshots/mobile-exterior.png and b/docs/screenshots/mobile-exterior.png differ
diff --git a/docs/screenshots/mobile-interior.png b/docs/screenshots/mobile-interior.png
index 631ac70..411a5c9 100644
Binary files a/docs/screenshots/mobile-interior.png and b/docs/screenshots/mobile-interior.png differ
diff --git a/docs/screenshots/mobile-map.png b/docs/screenshots/mobile-map.png
index 8ed8efd..19eb113 100644
Binary files a/docs/screenshots/mobile-map.png and b/docs/screenshots/mobile-map.png differ
diff --git a/docs/screenshots/mobile-panel.png b/docs/screenshots/mobile-panel.png
index 0e525b6..fac8fcc 100644
Binary files a/docs/screenshots/mobile-panel.png and b/docs/screenshots/mobile-panel.png differ
diff --git a/docs/screenshots/overview.png b/docs/screenshots/overview.png
index 18aee12..cc00d81 100644
Binary files a/docs/screenshots/overview.png and b/docs/screenshots/overview.png differ
diff --git a/index.html b/index.html
index dce8eee..0ef87d5 100644
--- a/index.html
+++ b/index.html
@@ -76,14 +76,22 @@
           <input id="f-speed" type="range" min="0" max="4" step="1" value="2" /></label>
         <p class="hint">Tower turns once every 7 min 29 s. Drag to orbit, scroll to
           zoom, click the hull to drop a point.</p>
+        <button id="f-ext-tour" type="button">Scenic tour</button>
+        <p class="hint">Spacecraft, a new one every twelve to eighteen seconds: skimming the
+          hull, docking on it, looking in through the far cap, over the collector's rim, and
+          Tower, the collector and the sun in one frame. Every flight keeps out of the beam.
+          <b>N</b> skips ahead; <b>Esc</b> or a drag stops it.</p>
       </div>
       <div id="interior-options" class="hidden">
         <p class="hint">Eye height is the gauge up the right-hand side of the view: drag it,
           or click it and use the arrow keys. The marks are things of known height.</p>
         <button id="f-tour" type="button">Scenic tour</button>
-        <p class="hint">A new sight every thirty seconds — flying down a river, circling a
-          waterfall or a summit, following a ship through weather. <b>N</b> skips ahead;
-          <b>Esc</b>, a drag or a flight key stops it where it is.</p>
+        <p class="hint">A new flyer every twelve to eighteen seconds, dealt at random:
+          paragliders on the ridges, gliders in thermals, light aircraft and seaplanes, an
+          airliner round the Girdle, airships — and the giants that fly near the axis, round
+          the sun — trains on the two railways, and ships in weather. The camera turns to
+          whatever can be seen from there. <b>N</b> skips ahead; <b>Esc</b>, a drag or a
+          flight key stops it where it is.</p>
         <p class="hint"><b>One finger looks. Two fingers move</b> — drag with two
           and the ground follows them. On a trackpad, a two-finger swipe moves you the
           same way; so does a mouse wheel. With a mouse: drag to look, shift-drag or
diff --git a/src/canon/motion.ts b/src/canon/motion.ts
index 65b8d02..30afab8 100644
--- a/src/canon/motion.ts
+++ b/src/canon/motion.ts
@@ -59,3 +59,17 @@ export const freeFallPathLeanRad = (heightM: number): number =>
  */
 export const freeFallLeanRad = (heightM: number): number =>
   Math.atan(OMEGA_RAD_S * freeFallTimeS(heightM))
+
+/**
+ * What something moving round the world weighs, m/s², at `radiusM` from the axis and
+ * `aroundMS` along the floor — positive spinward. Its true speed round the axis is the
+ * floor's ωr plus its own, and the floor has to hold it on that circle: (ωr + v)²/r.
+ *
+ * So going spinward adds weight and going antispinward takes it away, and it is not
+ * small: a train at 100 m/s is 31% heavier one way and 27% lighter the other, and
+ * something doing ωr antispinward — 700 m/s at the floor — weighs nothing at all,
+ * because it is standing still while the world turns under it. Moving along the axis
+ * changes nothing. Every circumferential journey in Tower has a handedness (D-085).
+ */
+export const apparentGravity = (radiusM: number, aroundMS: number): number =>
+  (OMEGA_RAD_S * radiusM + aroundMS) ** 2 / radiusM
diff --git a/src/terrain/rails.ts b/src/terrain/rails.ts
new file mode 100644
index 0000000..dbdb63a
--- /dev/null
+++ b/src/terrain/rails.ts
@@ -0,0 +1,125 @@
+/**
+ * Railway lines: where the track runs, and how high. See design.md §1.11, D-086.
+ *
+ * A line is authored as a handful of waypoints (world.json, kind `railway`) and smoothed
+ * like a river. Its **grade** is its own: Tower does not cut the ground for it, so the deck
+ * rides over the land at the height of the highest ground within a kilometre either side,
+ * eased along the line — which puts it on the ground across level country and on a viaduct
+ * over anything it would otherwise have to dip into. The deck is never below the ground, so
+ * a train never runs through a hill.
+ *
+ * A line whose last waypoint comes back round to its first, a whole circumference on, is a
+ * loop, and its windows wrap.
+ *
+ * Pure functions over a ground function, like the scenic tour (tour/), so the interior view,
+ * the tour and the tests all lay the same track.
+ */
+import { CIRCUMFERENCE_M, RADIUS_M } from '../canon/constants.js'
+import type { Railway, World } from '../world/schema.js'
+import { type Line, smoothLine } from './shapes.js'
+
+/** Metres between samples of the deck. */
+export const RAIL_STEP_M = 50
+/** How far either side the deck looks for the highest ground, metres. */
+const REACH_M = 1000
+/** How far either side the deck is eased over, metres. No more than REACH_M, or it can dip. */
+const EASE_M = 900
+/** The deck stands this far above the highest ground it clears, metres: ballast and sleepers. */
+export const DECK_CLEAR_M = 1.2
+
+export interface RailLine {
+  id: string
+  name: string
+  speedMS: number
+  loop: boolean
+  line: Line
+  lengthM: number
+  /** Height of the rails above the datum at each sample, metres. */
+  deckM: Float64Array
+  /** Height of the ground or water under each sample, metres. */
+  groundM: Float64Array
+}
+
+export interface RailFix {
+  z: number
+  /** Around, metres, unwrapped along the line. */
+  a: number
+  altM: number
+  /** Radians from deepward toward spinward. */
+  heading: number
+  /** Rise over run, along the direction of travel. */
+  grade: number
+}
+
+/** True when the waypoints go the whole way round and meet themselves. */
+function isLoop(points: readonly [number, number][]): boolean {
+  if (points.length < 3) return false
+  const [t0, z0] = points[0]!, [t1, z1] = points[points.length - 1]!
+  return Math.abs(z1 - z0) < 1 && Math.abs(Math.abs(t1 - t0) * RADIUS_M - CIRCUMFERENCE_M) < 10
+}
+
+/** Every railway in the world, with its deck laid over `ground` (metres above the datum). */
+export function railLines(world: World, ground: (theta: number, z: number) => number): RailLine[] {
+  return world.features
+    .filter((f): f is Railway => f.kind === 'railway' && f.geometry.type === 'polyline')
+    .map((f) => layRail(f, ground))
+}
+
+export function layRail(f: Railway, ground: (theta: number, z: number) => number): RailLine {
+  const points = f.geometry.type === 'polyline' ? f.geometry.points : []
+  const loop = isLoop(points)
+  const line = smoothLine(points, RAIL_STEP_M)
+  const n = line.s.length
+  const g = new Float64Array(n)
+  for (let i = 0; i < n; i++) g[i] = ground(line.pts[2 * i + 1]! / RADIUS_M, line.pts[2 * i]!)
+  // A loop's last sample is its first again; windows wrap over the rest.
+  const m = loop ? n - 1 : n
+  const idx = (i: number): number => (loop ? ((i % m) + m) % m : Math.max(0, Math.min(n - 1, i)))
+  const window = (src: Float64Array, r: number, pick: 'max' | 'mean'): Float64Array => {
+    const out = new Float64Array(n)
+    for (let i = 0; i < n; i++) {
+      let v = pick === 'max' ? -Infinity : 0
+      for (let k = -r; k <= r; k++) {
+        const x = src[idx(i + k)]!
+        v = pick === 'max' ? Math.max(v, x) : v + x
+      }
+      out[i] = pick === 'max' ? v : v / (2 * r + 1)
+    }
+    return out
+  }
+  const high = window(g, Math.round(REACH_M / RAIL_STEP_M), 'max')
+  const deck = window(high, Math.round(EASE_M / RAIL_STEP_M), 'mean')
+  for (let i = 0; i < n; i++) deck[i] = deck[i]! + DECK_CLEAR_M
+  return {
+    id: f.id, name: f.name, speedMS: f.speedMS, loop, line,
+    lengthM: line.s[n - 1]!, deckM: deck, groundM: g,
+  }
+}
+
+/** Where the track is `s` metres along the line. A loop's `s` wraps; a line's is clamped. */
+export function railAt(r: RailLine, s: number): RailFix {
+  const { line } = r
+  const n = line.s.length
+  const len = r.lengthM
+  const u = r.loop ? ((s % len) + len) % len : Math.max(0, Math.min(len, s))
+  const i = Math.min(n - 2, Math.floor(u / RAIL_STEP_M))
+  const t = Math.max(0, Math.min(1, (u - line.s[i]!) / (line.s[i + 1]! - line.s[i]! || 1)))
+  const z0 = line.pts[2 * i]!, a0 = line.pts[2 * i + 1]!
+  const z1 = line.pts[2 * i + 2]!, a1 = line.pts[2 * i + 3]!
+  const h0 = r.deckM[i]!, h1 = r.deckM[i + 1]!
+  const run = Math.hypot(z1 - z0, a1 - a0) || 1
+  return {
+    z: z0 + (z1 - z0) * t,
+    a: a0 + (a1 - a0) * t,
+    altM: h0 + (h1 - h0) * t,
+    heading: Math.atan2(a1 - a0, z1 - z0),
+    grade: (h1 - h0) / run,
+  }
+}
+
+/** The steepest grade anywhere on a line, rise over run. */
+export function maxGrade(r: RailLine): number {
+  let g = 0
+  for (let i = 1; i < r.deckM.length; i++) g = Math.max(g, Math.abs(r.deckM[i]! - r.deckM[i - 1]!) / RAIL_STEP_M)
+  return g
+}
diff --git a/src/tour/director.ts b/src/tour/director.ts
new file mode 100644
index 0000000..9590b6f
--- /dev/null
+++ b/src/tour/director.ts
@@ -0,0 +1,326 @@
+/**
+ * Where the camera looks, while a flyer carries it. See world-generation.md §19.
+ *
+ * A leg of the tour is a craft on a path; the director decides, moment by moment, what is
+ * worth pointing the camera at from there. Everything worth a look is a point of interest
+ * with a weight and a size, and each is scored by how well it can be seen right now:
+ *
+ *   - **through the air**, by the same integral of the rotational density profile the
+ *     renderer hazes with (canon/atmosphere.ts), so the camera is never turned to stare at
+ *     something the haze has eaten;
+ *   - **past the ground**, sampled along the sightline, so a summit behind a ridge is not
+ *     a subject until the ridge is passed;
+ *   - **as big as it looks**, since a tarn is worth a look from 3 km and not from 60;
+ *   - and **where a pilot could look**: within so many degrees of the nose.
+ *
+ * The director holds a subject for a while, lets the leg's own subject win most arguments,
+ * tires of anything it has looked at for long, and eases from one aim into the next over a
+ * few seconds. All of it is worked out once, when a leg is first played, so a pose is a pure
+ * function of time: the screenshot tour can hold any moment, and a test can fly every leg.
+ */
+import { LENGTH_M, OMEGA_RAD_S, RADIUS_M } from '../canon/constants.js'
+import { extinctionAtRadius } from '../canon/atmosphere.js'
+import {
+  type At, type V3, add, basisAt, cross, dot, ease, fromV, len, mix, norm, radial, scale, sub, toV,
+} from './geo.js'
+
+export interface Poi {
+  id: string
+  name: string
+  /** How much it is worth looking at, before distance and haze. About 1 for a named summit. */
+  weight: number
+  /** How big it is, metres: what makes it worth a look from further off. */
+  sizeM: number
+  /** Where it is at leg time `t`, or null while it is not there (the sun at night). */
+  at: (t: number) => V3 | null
+  /** Things on the ground can be hidden by it. The sun and the caps cannot. */
+  grounded: boolean
+}
+
+export interface CraftSample {
+  pos: V3
+  /** The way the craft is pointed: along its motion, or its own heading when it hovers. */
+  fwd: V3
+  /** The craft's own up, banked into its turns. */
+  up: V3
+}
+
+export interface Track {
+  durationS: number
+  sample: (t: number) => CraftSample
+}
+
+const DT = 0.25
+
+/**
+ * A craft's track from the path it flies: where it is, which way it points, and how far it
+ * banks. The bank is what a real wing does — lift tilted so that, with the local gravity,
+ * it gives the sideways acceleration the turn needs — so a tight turn near the axis, where
+ * gravity is a tenth of the floor's, banks hard, and the same turn at the floor barely does.
+ * `heading`, radians from deepward toward spinward, is for a craft that points somewhere
+ * other than where it drifts, or hovers.
+ */
+export function trackOf(path: (t: number) => At, durationS: number,
+  opts: { heading?: (t: number) => number; bank?: boolean; level?: boolean } = {}): Track {
+  const n = Math.ceil(durationS / DT) + 1
+  const pos: V3[] = []
+  for (let i = -2; i <= n + 2; i++) pos.push(toV(path(i * DT)))
+  const P = (i: number): V3 => pos[i + 2]!
+  const out: CraftSample[] = []
+  let lastFwd: V3 | null = null
+  for (let i = 0; i <= n; i++) {
+    const p = P(i)
+    const { up, spin, deep } = basisAt(p)
+    const vel = scale(sub(P(i + 1), P(i - 1)), 1 / (2 * DT))
+    let fwd: V3
+    if (opts.heading) {
+      const h = opts.heading(i * DT)
+      fwd = add(scale(deep, Math.cos(h)), scale(spin, Math.sin(h)))
+      // Pitched with the climb, when it is moving — unless it keeps level, as an airship does.
+      if (len(vel) > 0.5 && !opts.level) fwd = norm(add(fwd, scale(up, dot(norm(vel), up))))
+    } else if (len(vel) > 0.2) fwd = norm(vel)
+    else fwd = lastFwd ?? deep
+    lastFwd = fwd
+    let craftUp = norm(sub(up, scale(fwd, dot(up, fwd))))
+    if (opts.level) fwd = norm(sub(fwd, scale(up, dot(fwd, up))))
+    if (opts.bank !== false && !opts.level && len(vel) > 0.5) {
+      const acc = scale(add(sub(P(i + 2), scale(p, 2)), P(i - 2)), 1 / (4 * DT * DT))
+      const side = sub(sub(acc, scale(fwd, dot(acc, fwd))), scale(craftUp, dot(acc, craftUp)))
+      const g = OMEGA_RAD_S ** 2 * Math.max(1000, radial(p))
+      let lift = add(scale(craftUp, g), side)
+      // No steeper than 50°: past that the craft is not turning, it is falling.
+      const tilt = Math.acos(Math.max(-1, Math.min(1, dot(norm(lift), craftUp))))
+      if (tilt > 0.87) lift = mix(craftUp, norm(lift), 0.87 / tilt)
+      craftUp = norm(sub(lift, scale(fwd, dot(lift, fwd))))
+    }
+    out.push({ pos: p, fwd, up: craftUp })
+  }
+  return {
+    durationS,
+    sample: (t) => {
+      const x = Math.max(0, Math.min(n, t / DT))
+      const i = Math.min(n - 1, Math.floor(x)), u = x - i
+      const a = out[i]!, b = out[i + 1]!
+      return { pos: mix(a.pos, b.pos, u), fwd: norm(mix(a.fwd, b.fwd, u)), up: norm(mix(a.up, b.up, u)) }
+    },
+  }
+}
+
+// ------------------------------------------------------------------- seeing
+
+/** Transmission of green light from p to q, through the air between the caps. */
+export function transmission(p: V3, q: V3): number {
+  const N = 16
+  const d = sub(q, p)
+  const step = len(d) / N
+  let tau = 0
+  for (let i = 0; i < N; i++) {
+    const x = add(p, scale(d, (i + 0.5) / N))
+    if (x.z < 0 || x.z > LENGTH_M) continue
+    tau += extinctionAtRadius(Math.min(RADIUS_M, radial(x))) * step
+  }
+  return Math.exp(-tau)
+}
+
+/**
+ * True when nothing on the floor stands between p and q. The last `shortM` of the sightline
+ * is not tested: a summit's point of interest is inside the mountain it names.
+ */
+export function clearOfGround(p: V3, q: V3, ground: (theta: number, z: number) => number, shortM = 0): boolean {
+  const d = sub(q, p)
+  const upTo = Math.max(0, 1 - shortM / (len(d) || 1))
+  for (let i = 1; i < 16; i++) {
+    if (i / 16 > upTo) break
+    const x = add(p, scale(d, i / 16))
+    if (x.z < 0 || x.z > LENGTH_M) continue
+    const at = fromV(x)
+    if (at.altM < ground(at.a / RADIUS_M, at.z) + 3) return false
+  }
+  return true
+}
+
+// -------------------------------------------------------------------- gazing
+
+export interface GazeOpts {
+  pois: Poi[]
+  /** The leg's own subject: preferred, by `subjectBoost`, whenever it can be seen. */
+  subject?: Poi | null
+  subjectBoost?: number
+  /** How far off the nose the camera may turn, radians. */
+  limitRad: number
+  /** How far above the horizon it may look, radians. */
+  maxPitch?: number
+  ground: (theta: number, z: number) => number
+}
+
+export interface Gaze {
+  /** The direction to look at leg time `t`, from the craft. */
+  aim: (t: number) => V3
+  /** What it looked at, and from when. */
+  targets: { t: number; id: string }[]
+}
+
+/** Seconds a new subject holds the camera at least; seconds turning from one to the next. */
+export const MIN_DWELL_S = 4.5
+export const TURN_S = 3
+/** The fastest the aim swings, radians a second, and the step it is worked out in. */
+const MAX_SLEW = 0.7
+const SLEW_DT = 0.1
+/** Seconds after which a subject starts to bore the director. */
+const TIRES_S = 7
+
+/**
+ * Something to fall back on: the way ahead, a little below the horizon, where a pilot's
+ * eye goes when nothing calls it away.
+ */
+const AHEAD: Poi = { id: 'ahead', name: 'the way ahead', weight: 0.12, sizeM: 1, at: () => null, grounded: false }
+
+export function gaze(track: Track, opts: GazeOpts): Gaze {
+  const pois = opts.subject ? [opts.subject, ...opts.pois.filter((p) => p.id !== opts.subject!.id)] : opts.pois
+  const boost = opts.subjectBoost ?? 4
+  const maxPitch = opts.maxPitch ?? 1.2
+
+  const dirTo = (poi: Poi, t: number): V3 => {
+    const c = track.sample(t)
+    if (poi === AHEAD) {
+      const { up } = basisAt(c.pos)
+      return norm(sub(c.fwd, scale(up, 0.2)))
+    }
+    const q = poi.at(t)
+    return q ? norm(sub(q, c.pos)) : c.fwd
+  }
+
+  /** How good a look at `poi` there is from the craft at `t`, before any ground is checked. */
+  const score = (poi: Poi, t: number): number => {
+    if (poi === AHEAD) return AHEAD.weight
+    const q = poi.at(t)
+    if (!q) return 0
+    const c = track.sample(t)
+    const d = sub(q, c.pos)
+    const dist = len(d)
+    if (dist < Math.max(40, poi.sizeM * 0.2) || poi.sizeM / dist < 0.004) return 0
+    const dir = scale(d, 1 / dist)
+    const off = Math.acos(Math.max(-1, Math.min(1, dot(dir, c.fwd))))
+    if (off > opts.limitRad) return 0
+    const { up } = basisAt(c.pos)
+    const pitch = Math.asin(Math.max(-1, Math.min(1, dot(dir, up))))
+    if (pitch > maxPitch || pitch < -1.25) return 0
+    const T = transmission(c.pos, q)
+    if (T < 0.15) return 0
+    const size = Math.sqrt(Math.min(1, poi.sizeM / dist / 0.08))
+    const nose = 1 - 0.5 * (off / opts.limitRad) ** 2
+    return poi.weight * T * T * size * nose * (poi === opts.subject ? boost : 1)
+  }
+
+  const visible = (poi: Poi, t: number): boolean => {
+    if (poi === AHEAD || !poi.grounded) return true
+    const q = poi.at(t)
+    if (!q) return false
+    const from = track.sample(t).pos
+    return clearOfGround(from, q, opts.ground, Math.min(poi.sizeM * 0.3, len(sub(q, from)) * 0.5))
+  }
+
+  /** The best few by score, the first of them that the ground does not hide. */
+  const best = (t: number, weariness: (p: Poi) => number): { poi: Poi; s: number } => {
+    const ranked = pois.map((poi) => ({ poi, s: score(poi, t) * weariness(poi) }))
+      .filter((x) => x.s > 0).sort((a, b) => b.s - a.s)
+    for (const x of ranked.slice(0, 4)) if (visible(x.poi, t)) return x
+    return { poi: AHEAD, s: AHEAD.weight }
+  }
+
+  const targets: { t: number; poi: Poi }[] = []
+  let cur = best(0, () => 1)
+  targets.push({ t: 0, poi: cur.poi })
+  for (let t = 0.5; t < track.durationS - 1; t += 0.5) {
+    const since = t - targets[targets.length - 1]!.t
+    if (since < MIN_DWELL_S) continue
+    const tired = since > TIRES_S ? 0.5 : 1
+    const now = score(cur.poi, t) * tired
+    const stillThere = now > 0 && visible(cur.poi, t)
+    const next = best(t, (p) => (p === cur.poi ? tired : 1))
+    if (next.poi !== cur.poi && (!stillThere || next.s > now * 1.35)) {
+      // Only toward something the turn can reach without swinging the camera right round.
+      if (stillThere && dot(dirTo(next.poi, t), dirTo(cur.poi, t)) < -0.2) continue
+      cur = next
+      targets.push({ t, poi: next.poi })
+    }
+  }
+
+  const raw = (t: number): V3 => {
+    let k = 0
+    while (k + 1 < targets.length && targets[k + 1]!.t <= t) k++
+    const now = dirTo(targets[k]!.poi, t)
+    if (k === 0 || t - targets[k]!.t >= TURN_S) return now
+    const was = dirTo(targets[k - 1]!.poi, t)
+    return norm(mix(was, now, ease((t - targets[k]!.t) / TURN_S)))
+  }
+  /**
+   * And no faster than a camera operator would swing it: something close going by, or a
+   * time-lapse, can sweep the raw aim round faster than anyone could follow.
+   */
+  const n = Math.ceil(track.durationS / SLEW_DT) + 1
+  const aims: V3[] = [raw(0)]
+  for (let i = 1; i <= n; i++) {
+    const want = raw(i * SLEW_DT), had = aims[i - 1]!
+    const ang = Math.acos(Math.max(-1, Math.min(1, dot(want, had))))
+    const most = MAX_SLEW * SLEW_DT
+    aims.push(ang <= most ? want : norm(mix(had, want, most / ang)))
+  }
+  return {
+    targets: targets.map((x) => ({ t: x.t, id: x.poi.id })),
+    aim: (t) => {
+      const x = Math.max(0, Math.min(n, t / SLEW_DT))
+      const i = Math.min(n - 1, Math.floor(x))
+      return norm(mix(aims[i]!, aims[i + 1]!, x - i))
+    },
+  }
+}
+
+// ---------------------------------------------------------------------- rigs
+
+/** Where the camera rides on the craft. */
+export type Rig =
+  /** In the craft, looking where the director says, the horizon tilting with the bank. */
+  | { kind: 'cockpit' }
+  /**
+   * Behind it, on the far side from what the director is looking at, so the craft and its
+   * subject share the frame. `swingRad` walks the camera round the craft over the leg.
+   */
+  | { kind: 'chase'; back: number; raise: number; swingRad?: number }
+
+export interface Eye {
+  eye: V3
+  dir: V3
+  /** The camera's up. */
+  up: V3
+}
+
+/** The camera for a rig, at leg time `t`. Chase cameras are kept `clearM` over the ground. */
+export function rigEye(rig: Rig, track: Track, aim: (t: number) => V3, t: number,
+  ground: (theta: number, z: number) => number, clearM = 15): Eye {
+  const c = track.sample(t)
+  const a = aim(t)
+  const { up } = basisAt(c.pos)
+  if (rig.kind === 'cockpit') {
+    const eye = add(c.pos, scale(c.up, 2))
+    // The horizon tilts with the bank when looking out ahead, and less when looking aside.
+    const lean = 0.8 * Math.max(0, dot(a, c.fwd))
+    return { eye, dir: a, up: norm(mix(up, c.up, lean)) }
+  }
+  // The camera's bearing from the craft: opposite the aim, walked round by the swing.
+  const swing = (rig.swingRad ?? 0) * (t / track.durationS - 0.5)
+  let back = scale(a, -1)
+  if (swing) {
+    const side = cross(up, back)
+    const along = scale(up, dot(back, up))
+    const flat = sub(back, along)
+    back = add(along, add(scale(flat, Math.cos(swing)), scale(side, Math.sin(swing))))
+  }
+  let eye = add(add(c.pos, scale(norm(back), rig.back)), scale(up, rig.raise))
+  const at = fromV(eye)
+  const floor = ground(at.a / RADIUS_M, at.z) + clearM
+  if (at.altM < floor) eye = toV({ ...at, altM: floor })
+  const look = add(c.pos, scale(a, rig.back * 0.8))
+  return { eye, dir: norm(sub(look, eye)), up: norm(mix(up, c.up, 0.3)) }
+}
+
diff --git a/src/tour/exterior-legs.ts b/src/tour/exterior-legs.ts
new file mode 100644
index 0000000..bec65a3
--- /dev/null
+++ b/src/tour/exterior-legs.ts
@@ -0,0 +1,288 @@
+/**
+ * The tour outside Tower: legs flown by spacecraft. See world-generation.md §19, D-085.
+ *
+ * Scene units are kilometres, as in the exterior view: Tower's axis along z, the sun cap at
+ * z = 0, the collector 320 km sunward of it. A leg is flown in one of two frames:
+ *
+ *   - **space**, which does not turn: the hull wheels past a craft in it at 700 m/s at the
+ *     skin, once every 7 min 29 s, because the craft is not going round with it;
+ *   - **tower**, which turns with the hull: a craft matching its spin to dock sees the hull
+ *     hold still and the stars wheel instead. Spacecraft dock anywhere on the outer shell
+ *     (author, 2026-09-28), and a docked ship hangs from it, pressed outward at 1.02 g.
+ *
+ * No craft goes into the beam. Its cone runs from the collector's rim to the 3.2 km aperture
+ * in the sun cap, and every path keeps outside it by a margin the tests check.
+ *
+ * Pure functions: the exterior view plays these, and the tests fly them.
+ */
+import {
+  BEAM_RADIUS_M, COLLECTOR_DIAMETER_M, COLLECTOR_STANDOFF_M, HULL_DEPTH_M, LENGTH_M, RADIUS_M,
+} from '../canon/constants.js'
+import { hourWhenSunAt } from '../world/sky.js'
+import type { World } from '../world/schema.js'
+import { type V3, add, cross, dot, ease, len, mix, norm, radial, scale, sub, v3 } from './geo.js'
+
+const KM = 1000
+const R = RADIUS_M / KM
+const L = LENGTH_M / KM
+/** The outer skin: a kilometre out from the floor (D-044). */
+export const HULL_R = R + HULL_DEPTH_M / KM
+export const COLLECTOR_R = COLLECTOR_DIAMETER_M / KM / 2
+export const COLLECTOR_Z = -COLLECTOR_STANDOFF_M / KM
+const APERTURE_R = BEAM_RADIUS_M / KM
+/**
+ * Where the exterior draws the sun, km: on the axis, far beyond the collector. Not the real
+ * 150 million km — a scene cannot hold that — but far enough that it sits where the sun
+ * would against everything nearer.
+ */
+export const SUN_Z = -14_000
+export const SUN_DRAWN_R = 300
+
+/** The beam's radius at `z`: a cone from the collector's rim down to the aperture. */
+export function beamRadius(z: number): number {
+  if (z < COLLECTOR_Z || z > 0) return 0
+  return APERTURE_R + (COLLECTOR_R - APERTURE_R) * (z / COLLECTOR_Z)
+}
+
+export type Frame = 'space' | 'tower'
+
+/** A camera, in its leg's frame, km. */
+export interface ExtPose { eye: V3; look: V3; up: V3; fovDeg: number }
+
+/** A spacecraft to draw, in its leg's frame, km: +x of the model is `fwd`, +y is `up`. */
+export interface ExtCraft { pos: V3; fwd: V3; up: V3 }
+
+export interface ExtLeg {
+  id: string
+  title: string
+  vehicle: 'spacecraft'
+  /** The clock at the start of the leg, hours since day 0: what lights the inside. */
+  hours: number
+  /** World seconds per second of the leg. The hull turns at this rate too. */
+  rate: number
+  durationS: number
+  frame: Frame
+  pose: (t: number) => ExtPose
+  craft: (t: number) => ExtCraft[]
+}
+
+export interface ExtWorld { world: World }
+
+interface ExtOpts { durationS: number; rng: () => number }
+
+const outward = (p: V3): V3 => norm(v3(p.x, p.y, 0))
+
+/** Behind a craft, from what it is looking at, and a little above: the craft and the view in one frame. */
+function chase(craft: ExtCraft, target: V3, back: number, raise: number, fovDeg: number): ExtPose {
+  const aim = norm(sub(target, craft.pos))
+  const eye = add(sub(craft.pos, scale(aim, back)), scale(craft.up, raise))
+  return { eye, look: add(craft.pos, scale(aim, back * 3)), up: craft.up, fovDeg }
+}
+
+/** A craft `t` into a leg, moving along `path` and pointed along its motion. */
+function flying(path: (t: number) => V3, t: number, up: (p: V3) => V3): ExtCraft {
+  const pos = path(t)
+  const d = sub(path(t + 0.05), path(t - 0.05))
+  const fwd = len(d) > 1e-6 ? norm(d) : v3(0, 0, 1)
+  const u = up(pos)
+  return { pos, fwd, up: norm(sub(u, scale(fwd, dot(u, fwd)))) }
+}
+
+/** An hour on day 0 with the sun well up, so the inside is lit when looked into. */
+const daytime = (w: ExtWorld): number => hourWhenSunAt(w.world.calendar, 0, 330_000)
+
+export interface ExtLegSpec { id: string; prepare: (w: ExtWorld, o: ExtOpts) => ExtLeg }
+
+export const EXTERIOR_LEGS: ExtLegSpec[] = [
+  {
+    id: 'hull-skim',
+    prepare: (w, o) => {
+      const th = o.rng() * Math.PI * 2, r = HULL_R + 5
+      const z0 = 40 + o.rng() * 300
+      const path = (t: number) => v3(r * Math.cos(th), r * Math.sin(th), z0 + 3 * t)
+      return {
+        id: 'hull-skim', vehicle: 'spacecraft', hours: daytime(w), rate: 1, durationS: o.durationS, frame: 'space',
+        title: 'Skimming the hull, 5 km off, while Tower turns under the ship at 700 m/s',
+        craft: (t) => [flying(path, t, outward)],
+        pose: (t) => {
+          const c = flying(path, t, outward)
+          return chase(c, add(add(c.pos, v3(0, 0, 40)), scale(c.up, -9)), 0.35, 0.09, 60)
+        },
+      }
+    },
+  },
+  {
+    id: 'hull-orbit',
+    prepare: (w, o) => {
+      const th0 = o.rng() * Math.PI * 2, r = HULL_R + 11
+      const z = 60 + o.rng() * 420
+      // Clockwise seen from the far cap: against the spin, so the hull comes the other way.
+      const path = (t: number) => { const th = th0 - (4 * t) / r; return v3(r * Math.cos(th), r * Math.sin(th), z) }
+      return {
+        id: 'hull-orbit', vehicle: 'spacecraft', hours: daytime(w), rate: 1, durationS: o.durationS, frame: 'space',
+        title: 'Round the hull against its spin: 700 m/s of Tower coming the other way',
+        craft: (t) => [flying(path, t, outward)],
+        pose: (t) => {
+          const c = flying(path, t, outward)
+          return chase(c, add(add(c.pos, scale(c.fwd, 40)), scale(c.up, -14)), 0.4, 0.1, 62)
+        },
+      }
+    },
+  },
+  {
+    id: 'docking',
+    prepare: (w, o) => {
+      const th = o.rng() * Math.PI * 2, z = 60 + o.rng() * 420
+      const out = v3(Math.cos(th), Math.sin(th), 0)
+      const pad = scale(out, HULL_R)
+      const D = o.durationS
+      // Down onto the shell, slowing to a stop 70 m off it, as the clamps take it.
+      const pos = (t: number) => add(add(pad, v3(0, 0, z)), scale(out, 0.07 + 3.2 * (1 - ease(t / D))))
+      // Its feet are toward the hull: once docked, the spin presses it outward, away from it.
+      const craft = (t: number): ExtCraft => ({ pos: pos(t), fwd: v3(0, 0, 1), up: scale(out, -1) })
+      const side = norm(cross(v3(0, 0, 1), out))
+      const eye = add(add(add(pad, v3(0, 0, z - 0.32)), scale(out, 0.34)), scale(side, 0.3))
+      return {
+        id: 'docking', vehicle: 'spacecraft', hours: daytime(w), rate: 1, durationS: D, frame: 'tower',
+        title: 'Docking: matching the spin to set down on the shell, where a ship hangs from its clamps at 1.02 g — the stars wheel, and the hull holds still',
+        craft: (t) => [craft(t)],
+        pose: (t) => ({ eye, look: mix(pos(t), add(pad, v3(0, 0, z)), 0.3), up: out, fovDeg: 55 }),
+      }
+    },
+  },
+  {
+    id: 'far-cap-peek',
+    prepare: (w, o) => {
+      const phi = o.rng() * Math.PI * 2
+      const off = (k: number) => v3(k * Math.cos(phi), k * Math.sin(phi), 0)
+      const D = o.durationS
+      const path = (t: number) => add(off(40 - 22 * ease(t / D)), v3(0, 0, L + 200 - 140 * ease(t / D)))
+      const target = v3(0, 0, L - 80)
+      const up = (p: V3) => norm(cross(norm(sub(target, p)), norm(cross(v3(0, 0, 1), outward(p)))))
+      return {
+        id: 'far-cap-peek', vehicle: 'spacecraft', hours: daytime(w), rate: 1, durationS: D, frame: 'space',
+        title: 'Looking in through the far cap: the whole world inside, 540 km deep, lit by its own sun',
+        craft: (t) => [flying(path, t, up)],
+        pose: (t) => chase(flying(path, t, up), target, 0.5, 0.12, 50),
+      }
+    },
+  },
+  {
+    id: 'sun-cap-pass',
+    prepare: (w, o) => {
+      const phi = o.rng() * Math.PI * 2
+      const u = v3(Math.cos(phi), Math.sin(phi), 0), s = v3(-Math.sin(phi), Math.cos(phi), 0)
+      const D = o.durationS
+      const path = (t: number) => add(add(scale(s, -70 + (140 * t) / D), scale(u, 48)), v3(0, 0, -45))
+      return {
+        id: 'sun-cap-pass', vehicle: 'spacecraft', hours: daytime(w), rate: 1, durationS: D, frame: 'space',
+        title: 'Across the sun cap, clear of the beam: 54 TW going in through a hole 3.2 km wide',
+        craft: (t) => [flying(path, t, () => u)],
+        pose: (t) => chase(flying(path, t, () => u), v3(0, 0, 10), 0.4, 0.1, 58),
+      }
+    },
+  },
+  {
+    id: 'collector-rim',
+    prepare: (w, o) => {
+      const psi = o.rng() * Math.PI * 2
+      const out = v3(Math.cos(psi), Math.sin(psi), 0)
+      const pivot = add(scale(out, COLLECTOR_R), v3(0, 0, COLLECTOR_Z))
+      const D = o.durationS
+      // Over the rim from Tower's side to the sun's, 30 km out from its edge.
+      const path = (t: number) => {
+        const a = 1.35 - 2.7 * ease(t / D)
+        return add(pivot, add(scale(out, 30 * Math.cos(a)), v3(0, 0, 30 * Math.sin(a))))
+      }
+      const centre = v3(0, 0, COLLECTOR_Z)
+      return {
+        id: 'collector-rim', vehicle: 'spacecraft', hours: daytime(w), rate: 1, durationS: D, frame: 'space',
+        title: 'Over the rim of the collector, 244 km across, onto its mirrored face',
+        craft: (t) => [flying(path, t, (p) => norm(sub(p, pivot)))],
+        pose: (t) => chase(flying(path, t, (p) => norm(sub(p, pivot))), centre, 0.6, 0.15, 64),
+      }
+    },
+  },
+  {
+    id: 'grand',
+    prepare: (w, o) => {
+      const phi0 = o.rng() * Math.PI * 2
+      const eyeAt = (t: number) => {
+        const phi = phi0 + 0.004 * t
+        return v3(240 * Math.cos(phi), 240 * Math.sin(phi), 980 + 3 * t)
+      }
+      const frame = (t: number): ExtPose => {
+        const eye = eyeAt(t)
+        const marks = [v3(0, 0, L), v3(0, 0, 0), v3(0, 0, COLLECTOR_Z), v3(0, 0, SUN_Z)]
+        const dirs = marks.map((m) => norm(sub(m, eye)))
+        const look = add(eye, norm(dirs.reduce((a, b) => add(a, b), v3(0, 0, 0))))
+        const centre = norm(sub(look, eye))
+        // Wide enough for all of it, with the collector's whole disc and a margin.
+        const widest = Math.max(...dirs.map((d) => Math.acos(Math.min(1, dot(d, centre)))))
+          + Math.atan(COLLECTOR_R / len(sub(v3(0, 0, COLLECTOR_Z), eye)))
+        return { eye, look, up: v3(0, 1, 0), fovDeg: Math.min(70, Math.max(24, (widest * 2.3 * 180) / Math.PI)) }
+      }
+      return {
+        id: 'grand', vehicle: 'spacecraft', hours: daytime(w), rate: 1, durationS: o.durationS, frame: 'space',
+        title: 'Tower, its collector and the sun in one frame — from inside, the collector hides the sun from everyone',
+        craft: (t) => {
+          const p = frame(t)
+          const f = norm(sub(p.look, p.eye))
+          const right = norm(cross(f, p.up))
+          return [{ pos: add(add(add(p.eye, scale(f, 1.3)), scale(right, 0.32)), scale(p.up, -0.2)), fwd: f, up: p.up }]
+        },
+        pose: frame,
+      }
+    },
+  },
+  {
+    id: 'beam-run',
+    prepare: (w, o) => {
+      const psi = o.rng() * Math.PI * 2
+      const out = v3(Math.cos(psi), Math.sin(psi), 0)
+      const D = o.durationS
+      const path = (t: number) => {
+        const z = COLLECTOR_Z + 25 + (-COLLECTOR_Z - 60) * ease(t / D)
+        return add(scale(out, beamRadius(z) + 14), v3(0, 0, z))
+      }
+      return {
+        id: 'beam-run', vehicle: 'spacecraft', hours: daytime(w), rate: 1, durationS: D, frame: 'space',
+        title: 'Down the beam from the collector to the sun cap, just outside it',
+        craft: (t) => [flying(path, t, () => out)],
+        pose: (t) => {
+          const c = flying(path, t, () => out)
+          return chase(c, v3(0, 0, 30), 0.6, 0.15, 62)
+        },
+      }
+    },
+  },
+  {
+    id: 'pull-back',
+    prepare: (w, o) => {
+      const th = o.rng() * Math.PI * 2
+      const out = v3(Math.cos(th), Math.sin(th), 0)
+      const D = o.durationS
+      const dist = (t: number) => 15 * 80 ** ease(t / D)
+      return {
+        id: 'pull-back', vehicle: 'spacecraft', hours: daytime(w), rate: 1, durationS: D, frame: 'space',
+        title: 'Pulling away: Tower is 540 km long, and the collector is bigger still',
+        craft: () => [],
+        pose: (t) => {
+          const u = ease(t / D)
+          const eye = add(scale(out, HULL_R + dist(t)), v3(0, 0, 270 - 120 * u))
+          return { eye, look: v3(0, 0, 270 - 250 * u), up: v3(0, 0, 1), fovDeg: 55 }
+        },
+      }
+    },
+  },
+]
+
+export function planExterior(w: ExtWorld, rng: () => number, durationS: (id: string) => number): ExtLeg[] {
+  return EXTERIOR_LEGS.map((spec) => spec.prepare(w, { durationS: durationS(spec.id), rng }))
+}
+
+/** True when a point is clear of the beam's cone by `marginKm`. */
+export function clearOfBeam(p: V3, marginKm: number): boolean {
+  const r = beamRadius(p.z)
+  return r === 0 || radial(p) > r + marginKm
+}
diff --git a/src/tour/geo.ts b/src/tour/geo.ts
new file mode 100644
index 0000000..36ee36e
--- /dev/null
+++ b/src/tour/geo.ts
@@ -0,0 +1,127 @@
+/**
+ * Geometry for the scenic tour, inside Tower. See world-generation.md §19.
+ *
+ * Two ways of saying where something is. On the floor, (z, a, altM): metres along the
+ * axis, metres around it, and height above the datum — what the world's features are
+ * written in. And in the tower's own frame, a vector in metres with the axis along z —
+ * what aiming, distances and the camera need, since the floor curves up all round and a
+ * straight line through the air is not a line on the map.
+ */
+import { CIRCUMFERENCE_M, RADIUS_M } from '../canon/constants.js'
+import { type Line, nearestA } from '../terrain/shapes.js'
+
+export interface At { z: number; a: number; altM: number }
+
+/** A vector in the tower's frame, metres: x and y across the axis, z along it. */
+export interface V3 { x: number; y: number; z: number }
+
+export const v3 = (x: number, y: number, z: number): V3 => ({ x, y, z })
+export const add = (p: V3, q: V3): V3 => v3(p.x + q.x, p.y + q.y, p.z + q.z)
+export const sub = (p: V3, q: V3): V3 => v3(p.x - q.x, p.y - q.y, p.z - q.z)
+export const scale = (p: V3, k: number): V3 => v3(p.x * k, p.y * k, p.z * k)
+export const dot = (p: V3, q: V3): number => p.x * q.x + p.y * q.y + p.z * q.z
+export const len = (p: V3): number => Math.hypot(p.x, p.y, p.z)
+export const cross = (p: V3, q: V3): V3 =>
+  v3(p.y * q.z - p.z * q.y, p.z * q.x - p.x * q.z, p.x * q.y - p.y * q.x)
+export const norm = (p: V3): V3 => { const l = len(p) || 1; return scale(p, 1 / l) }
+/** p + (q − p)·t. */
+export const mix = (p: V3, q: V3, t: number): V3 => add(p, scale(sub(q, p), t))
+/** Distance from the axis. */
+export const radial = (p: V3): number => Math.hypot(p.x, p.y)
+
+export const wrapA = (a: number): number => ((a % CIRCUMFERENCE_M) + CIRCUMFERENCE_M) % CIRCUMFERENCE_M
+
+/** A point over the floor, in the tower's frame. */
+export function toV(at: At): V3 {
+  const th = at.a / RADIUS_M, r = RADIUS_M - at.altM
+  return v3(r * Math.cos(th), r * Math.sin(th), at.z)
+}
+
+/** And back. `a` comes out in [0, circumference). */
+export function fromV(p: V3): At {
+  return { z: p.z, a: wrapA(Math.atan2(p.y, p.x) * RADIUS_M), altM: RADIUS_M - radial(p) }
+}
+
+/** Up (toward the axis), spinward and deepward, at a point. */
+export function basisAt(p: V3): { up: V3; spin: V3; deep: V3 } {
+  const th = Math.atan2(p.y, p.x)
+  return { up: v3(-Math.cos(th), -Math.sin(th), 0), spin: v3(-Math.sin(th), Math.cos(th), 0), deep: v3(0, 0, 1) }
+}
+
+/** A direction as the interior view's heading and pitch, at the point it is seen from. */
+export function yawPitch(from: V3, dir: V3): { yaw: number; pitch: number } {
+  const { up, spin, deep } = basisAt(from)
+  const d = norm(dir)
+  return { yaw: Math.atan2(dot(d, spin), dot(d, deep)), pitch: Math.asin(Math.max(-1, Math.min(1, dot(d, up)))) }
+}
+
+/** The direction a heading and pitch point, from a point. */
+export function dirOf(from: V3, yaw: number, pitch: number): V3 {
+  const { up, spin, deep } = basisAt(from)
+  return add(add(scale(deep, Math.cos(pitch) * Math.cos(yaw)), scale(spin, Math.cos(pitch) * Math.sin(yaw))),
+    scale(up, Math.sin(pitch)))
+}
+
+/**
+ * The roll that turns a camera looking along `dir` with the floor's up as its up into one
+ * whose up is `want`: the angle for three's rotateZ, about the camera's own view axis.
+ */
+export function rollTo(from: V3, dir: V3, want: V3): number {
+  const { up } = basisAt(from)
+  const f = norm(dir)
+  const right = norm(cross(f, up))
+  const camUp = cross(right, f)
+  return Math.atan2(-dot(want, right), dot(want, camUp))
+}
+
+/**
+ * Heading and pitch from one point to another, on the curved floor. The floor curves up
+ * around the axis, so a summit 6 km spinward stands about 360 m higher in the eye's frame
+ * than its elevation says, and a pitch taken on a flat floor would miss it.
+ */
+export function lookAt(from: At, to: At): { yaw: number; pitch: number } {
+  const p = toV(from)
+  const q = toV({ ...to, a: nearestA(to.a, from.a) })
+  return yawPitch(p, sub(q, p))
+}
+
+/** A point `s` metres along a line, clamped to its ends. */
+export function along(line: Line, s: number): { z: number; a: number } {
+  const n = line.s.length
+  if (s <= 0) return { z: line.pts[0]!, a: line.pts[1]! }
+  if (s >= line.s[n - 1]!) return { z: line.pts[2 * n - 2]!, a: line.pts[2 * n - 1]! }
+  let lo = 0, hi = n - 1
+  while (hi - lo > 1) {
+    const mid = (lo + hi) >> 1
+    if (line.s[mid]! <= s) lo = mid
+    else hi = mid
+  }
+  const t = (s - line.s[lo]!) / (line.s[hi]! - line.s[lo]! || 1)
+  return {
+    z: line.pts[2 * lo]! + (line.pts[2 * hi]! - line.pts[2 * lo]!) * t,
+    a: line.pts[2 * lo + 1]! + (line.pts[2 * hi + 1]! - line.pts[2 * lo + 1]!) * t,
+  }
+}
+
+/** A straight line from (z, a), heading `dirRad` (deepward toward spinward), `length` metres. */
+export function straight(z: number, a: number, dirRad: number, length: number): Line {
+  const pts: number[] = [], s: number[] = []
+  for (let d = 0; d <= length; d += 50) {
+    pts.push(z + Math.cos(dirRad) * d, a + Math.sin(dirRad) * d)
+    s.push(d)
+  }
+  return { pts, s, box: { z0: 0, z1: 0, a0: 0, a1: 0 } }
+}
+
+/** How far along a line its nearest point to (z, a) is. */
+export function sAlong(line: Line, z: number, a: number): number {
+  let best = 0, bestD = Infinity
+  for (let i = 0; i < line.s.length; i++) {
+    const d = Math.hypot(line.pts[2 * i]! - z, nearestA(line.pts[2 * i + 1]!, a) - a)
+    if (d < bestD) { bestD = d; best = i }
+  }
+  return line.s[best]!
+}
+
+/** Smoothstep, for easing one aim into the next. */
+export const ease = (t: number): number => { const u = Math.max(0, Math.min(1, t)); return u * u * (3 - 2 * u) }
diff --git a/src/tour/interior-legs.ts b/src/tour/interior-legs.ts
new file mode 100644
index 0000000..6f277f8
--- /dev/null
+++ b/src/tour/interior-legs.ts
@@ -0,0 +1,909 @@
+/**
+ * The tour inside Tower: legs flown by the world's flyers. See world-generation.md §19.
+ *
+ * Each leg is a craft on a path for twelve to eighteen seconds — a paraglider along a
+ * ridge, a giant airship round the sun, a train through The Narrows, a ship in a squall —
+ * with the camera on it, and the director (director.ts) pointing it at whatever is worth
+ * seeing from there. The legs find their subjects in the compiled world, so they follow it
+ * as it is edited, and a leg whose subject is gone drops out of the tour.
+ *
+ * A craft exists for its leg (D-085): nothing here is traffic that runs when nobody is
+ * watching, except the ships, which were already in the world and are followed as they are.
+ *
+ * Pure functions, no Three.js: the interior view plays the legs, the screenshot tour holds
+ * them at a moment, and the tests fly every one.
+ */
+import { BEAM_RADIUS_M, LENGTH_M, RADIUS_M } from '../canon/constants.js'
+import { apparentGravity } from '../canon/motion.js'
+import type { CFall, CompiledWorld } from '../terrain/compile.js'
+import type { CDesert } from '../terrain/desert.js'
+import { forEachTree, type Tree } from '../terrain/forest.js'
+import { type RailLine, railAt } from '../terrain/rails.js'
+import { type Line, smoothLine } from '../terrain/shapes.js'
+import { lanesOf, shipAt, shipsAt, type ShipLane } from '../terrain/ships.js'
+import { type Feature, type Sea, type World, anchorOf } from '../world/schema.js'
+import { HARBOUR_LEAD_H, occurrences } from '../world/weather.js'
+import { hourWhenSunAt, skyAt } from '../world/sky.js'
+import {
+  type CraftSample, type Gaze, type Poi, type Rig, type Track, gaze, rigEye, trackOf,
+} from './director.js'
+import {
+  type At, type V3, add, along, basisAt, dot, fromV, norm, rollTo, sAlong, scale, straight, sub, toV, v3,
+  wrapA, yawPitch,
+} from './geo.js'
+
+// --------------------------------------------------------------------- types
+
+export type Vehicle =
+  | 'paraglider' | 'glider' | 'light-plane' | 'seaplane' | 'helicopter' | 'airliner'
+  | 'airship' | 'giant-airship' | 'ship' | 'train' | 'spacecraft'
+
+export const VEHICLE_NAME: Record<Vehicle, string> = {
+  'paraglider': 'Paraglider', 'glider': 'Glider', 'light-plane': 'Light aircraft', 'seaplane': 'Seaplane',
+  'helicopter': 'Helicopter', 'airliner': 'Airliner', 'airship': 'Airship', 'giant-airship': 'Giant airship',
+  'ship': 'Sailing ship', 'train': 'Train', 'spacecraft': 'Spacecraft',
+}
+
+/** What a view draws: a vehicle, or one vehicle of a train. */
+export type CraftKind = Vehicle | 'locomotive' | 'carriage'
+
+/** A craft to draw, in the tower's frame, metres. +x of its model is `fwd`, +y is `up`. */
+export interface Craft { kind: CraftKind; pos: V3; fwd: V3; up: V3 }
+
+/** Where the camera is and where it looks. `altM` is the eye's elevation above the datum. */
+export interface Pose {
+  theta: number
+  z: number
+  altM: number
+  /** Radians from deepward toward spinward, as the interior view's heading. */
+  yaw: number
+  pitch: number
+  /** About the view axis, radians: the bank. */
+  roll: number
+  fovDeg: number
+}
+
+export interface TourWorld {
+  world: World
+  compiled: CompiledWorld
+  /** Height of the ground or the water standing on it, metres. */
+  ground: (theta: number, z: number) => number
+  rails: RailLine[]
+}
+
+export interface Leg {
+  id: string
+  title: string
+  vehicle: Vehicle
+  /** The clock at the start of the leg, hours since day 0. */
+  hours: number
+  /** World seconds per second of the leg: 1 is real time, more is a time-lapse, and says so. */
+  rate: number
+  durationS: number
+  pose: (t: number) => Pose
+  /** The craft to draw at `t` — none when the camera is inside it, or it is drawn already. */
+  craft: (t: number) => Craft[]
+  /** Where the craft is at `t`, for the tests. */
+  at: (t: number) => CraftSample
+  /** What the camera is turned toward at `t`, by name. */
+  lookingAt: (t: number) => string
+  subject: Poi | null
+  /** Seen from the craft's own seat. */
+  rig: Rig['kind']
+}
+
+export interface LegOptions {
+  durationS: number
+  rng: () => number
+}
+
+export interface LegSpec {
+  id: string
+  vehicle: Vehicle
+  prepare: (w: TourWorld, o: LegOptions) => Leg | null
+}
+
+// ------------------------------------------------------------------- the sky
+
+const sunZ = (w: TourWorld, hours: number): number | null => skyAt(w.world.calendar, hours, 'full').sunZ
+
+/**
+ * The hour on day 0 when the sun is 60 km sunward of `z` and coming: about 40° up, which is
+ * high enough to be full day and low enough for the ground to have relief (D-074). Kept
+ * clear of the caps, where the sun is still lighting or already going out.
+ */
+function morningOver(w: TourWorld, z: number): number {
+  return hourWhenSunAt(w.world.calendar, 0, Math.min(LENGTH_M - 25_000, Math.max(25_000, z - 60_000)))
+}
+
+/** Late on day 0: the beam banked, and the stars out through the far cap. */
+const NIGHT = 23.2
+
+// -------------------------------------------------------------- what to see
+
+const feature = (w: World, id: string): Feature | undefined => w.features.find((f) => f.id === id)
+
+/**
+ * A point of interest that stays where it is. `grounded` is false for a field of things — a
+ * stand of mesas — where the point is in the middle of it and the nearest of them hides it,
+ * but seeing any of them is seeing it.
+ */
+const fixed = (id: string, name: string, at: At, weight: number, sizeM: number, grounded = true): Poi => {
+  const p = toV(at)
+  return { id, name, weight, sizeM, grounded, at: () => p }
+}
+
+/** The far cap's window, and the sun cap's: centred on the axis at each end. */
+const farCap = (): Poi => ({
+  id: 'far-cap', name: 'the far cap', weight: 0.7, sizeM: 40_000, grounded: false, at: () => v3(0, 0, LENGTH_M),
+})
+const sunCap = (): Poi => ({
+  id: 'sun-cap', name: 'the sun cap', weight: 0.45, sizeM: 40_000, grounded: false, at: () => v3(0, 0, 0),
+})
+const theSun = (w: TourWorld, hours: number, rate: number): Poi => ({
+  id: 'sun', name: 'the sun', weight: 1.4, sizeM: 15_000, grounded: false,
+  at: (t) => {
+    const z = sunZ(w, hours + (t * rate) / 3600)
+    return z === null ? null : v3(0, 0, z)
+  },
+})
+
+/** Everything in the world worth turning the camera toward, from `hours` on. */
+export function worldPois(w: TourWorld, hours: number, rate: number): Poi[] {
+  const out: Poi[] = [farCap(), sunCap(), theSun(w, hours, rate)]
+  const R = RADIUS_M
+  for (const f of w.world.features) {
+    if (f.kind === 'mountains') {
+      for (const pk of f.peaks) {
+        out.push(fixed(`peak-${pk.name || `${pk.theta}-${pk.z}`}`, pk.name || 'a summit',
+          { z: pk.z, a: pk.theta * R, altM: pk.elevationM - 200 }, pk.name ? 0.6 + pk.elevationM / 8000 : 0.35, 2500))
+      }
+    } else if (f.kind === 'lake') {
+      const b = w.compiled.basins.find((x) => x.id === f.id)
+      const p = anchorOf(f)
+      if (!b || !p) continue
+      const big = f.id === 'the-deep'
+      out.push(fixed(f.id, f.name, { z: p.z, a: p.theta * R, altM: b.levelM + 20 }, big ? 0.8 : 0.55, big ? 18_000 : 2500))
+    } else if (f.kind === 'settlement' || (f.kind === 'forest' && f.giants) || f.id === 'the-narrows') {
+      const p = anchorOf(f)
+      if (!p) continue
+      const city = f.kind === 'settlement' && f.size === 'city'
+      out.push(fixed(f.id, f.name, { z: p.z, a: p.theta * R, altM: w.ground(p.theta, p.z) + 120 },
+        city ? 0.9 : f.kind === 'settlement' ? 0.5 : 0.65, city ? 10_000 : f.kind === 'settlement' ? 1500 : 4000))
+    }
+  }
+  for (const f of w.compiled.falls) {
+    const drop = f.topM - f.bottomM
+    if (drop < 60) continue
+    const river = feature(w.world, f.river)?.name ?? 'a river'
+    out.push(fixed(`fall-${f.river}-${Math.round(f.z)}`, `${river}'s ${Math.round(drop)} m fall`,
+      { z: f.z, a: f.a, altM: f.bottomM + drop / 2 }, 0.6 + Math.min(0.6, drop / 1000), Math.max(300, drop * 2)))
+  }
+  for (const { lane } of shipsAt(w.world, hours)) {
+    const level = w.compiled.basins.find((b) => b.id === lane.sea)?.levelM ?? 0
+    out.push({
+      id: `ship-${lane.sea}-${Math.round(lane.a0)}`, name: 'a ship', weight: 0.45, sizeM: lane.lengthM * 8,
+      grounded: true,
+      at: (t) => { const s = shipAt(lane, hours + (t * rate) / 3600); return toV({ z: s.z, a: s.a, altM: level + 6 }) },
+    })
+  }
+  return out
+}
+
+// ----------------------------------------------------------------- building
+
+interface LegDef {
+  id: string
+  title: string
+  vehicle: Vehicle
+  hours: number
+  rate?: number
+  durationS: number
+  /** The craft's path, in leg seconds. */
+  path: (t: number) => At
+  /** Which way the craft points, when that is not the way it moves. */
+  heading?: (t: number) => number
+  rig: Rig
+  fovDeg: number
+  subject?: Poi | null
+  /** How strongly the subject is preferred over everything else. */
+  subjectBoost?: number
+  /** What the director may look at: the world, only the sky, or a list of its own. */
+  pois?: 'world' | 'sky' | Poi[]
+  /** How far off the nose the camera may turn. */
+  limitRad?: number
+  maxPitch?: number
+  /** False when the craft is drawn already (a ship) or would fill the lens (a cockpit). */
+  draw?: boolean
+  /** Craft to draw besides the one carrying the camera: a train's carriages. */
+  extra?: (t: number) => Craft[]
+}
+
+/**
+ * A leg from its path and rig. The track, the director's choices and everything else that
+ * costs anything are worked out the first time the leg is asked for a pose, so planning a
+ * tour of two dozen legs is cheap and playing one costs one leg's worth.
+ */
+function makeLeg(w: TourWorld, d: LegDef): Leg {
+  const rate = d.rate ?? 1
+  let built: { track: Track; gaze: Gaze; names: Map<string, string> } | null = null
+  const build = () => {
+    if (built) return built
+    // Airships and ships keep level; everything with wings banks into its turns.
+    const level = d.vehicle === 'airship' || d.vehicle === 'giant-airship' || d.vehicle === 'ship' || d.vehicle === 'train'
+    const track = trackOf(d.path, d.durationS, { heading: d.heading, level })
+    const pois = d.pois === 'sky'
+      ? [farCap(), sunCap(), theSun(w, d.hours, rate)]
+      : Array.isArray(d.pois) ? d.pois : worldPois(w, d.hours, rate)
+    const g = gaze(track, {
+      pois, subject: d.subject ?? null, subjectBoost: d.subjectBoost, ground: w.ground,
+      limitRad: d.limitRad ?? (d.rig.kind === 'cockpit' ? 1.9 : 2.4), maxPitch: d.maxPitch,
+    })
+    const names = new Map(pois.map((p) => [p.id, p.name]))
+    if (d.subject) names.set(d.subject.id, d.subject.name)
+    names.set('ahead', 'the way ahead')
+    built = { track, gaze: g, names }
+    return built
+  }
+  return {
+    id: d.id, title: d.title, vehicle: d.vehicle, hours: d.hours, rate, durationS: d.durationS,
+    subject: d.subject ?? null, rig: d.rig.kind,
+    at: (t) => build().track.sample(t),
+    pose: (t) => {
+      const b = build()
+      const e = rigEye(d.rig, b.track, b.gaze.aim, t, w.ground)
+      const at = fromV(e.eye)
+      const { yaw, pitch } = yawPitch(e.eye, e.dir)
+      return { theta: at.a / RADIUS_M, z: at.z, altM: at.altM, yaw, pitch, roll: rollTo(e.eye, e.dir, e.up), fovDeg: d.fovDeg }
+    },
+    craft: (t) => {
+      const extra = d.extra?.(t) ?? []
+      if (d.draw === false || d.rig.kind === 'cockpit') return extra
+      const c = build().track.sample(t)
+      return [{ kind: d.vehicle, pos: c.pos, fwd: c.fwd, up: c.up }, ...extra]
+    },
+    lookingAt: (t) => {
+      const b = build()
+      let id = b.gaze.targets[0]!.id
+      for (const x of b.gaze.targets) if (x.t <= t) id = x.id
+      return b.names.get(id) ?? id
+    },
+  }
+}
+
+// ------------------------------------------------------------------- paths
+
+/**
+ * Flying along a line at a steady speed, with a clearance over the ground that does not
+ * bob with every hill: the highest ground within 700 m either side, smoothed.
+ */
+function lineFlight(w: TourWorld, line: Line, from: number, speed: number, clearanceM: number, D: number): (t: number) => At {
+  const STEP = 50
+  const n = Math.ceil((speed * (D + 2) + 2000) / STEP) + 1
+  const raw = new Float64Array(n)
+  for (let i = 0; i < n; i++) {
+    const p = along(line, from - 1000 + i * STEP)
+    raw[i] = w.ground(wrapA(p.a) / RADIUS_M, p.z)
+  }
+  const reach = (arr: Float64Array, r: number, pick: (xs: number[]) => number) =>
+    Float64Array.from(arr, (_, i) => pick(Array.from(arr.subarray(Math.max(0, i - r), Math.min(n, i + r + 1)))))
+  const smooth = reach(reach(raw, 14, (xs) => Math.max(...xs)), 8, (xs) => xs.reduce((x, y) => x + y, 0) / xs.length)
+  return (t) => {
+    const s = from + speed * t
+    const x = Math.min(n - 1, Math.max(0, (s - from + 1000) / STEP))
+    const i = Math.min(n - 2, Math.floor(x))
+    const h = smooth[i]! + (smooth[i + 1]! - smooth[i]!) * (x - i)
+    return { ...along(line, s), altM: h + clearanceM }
+  }
+}
+
+/** A circle round a point, `radius` out, from `startRad`, at `speed`; never under `minClearM`. */
+function circle(w: TourWorld, centre: { z: number; a: number }, radius: number, altM: number, startRad: number,
+  speed: number, minClearM: number, climbMS = 0): (t: number) => At {
+  return (t) => {
+    const ang = startRad + (speed * t) / radius
+    const z = centre.z + Math.cos(ang) * radius, a = centre.a + Math.sin(ang) * radius
+    return { z, a, altM: Math.max(altM + climbMS * t, w.ground(wrapA(a) / RADIUS_M, z) + minClearM) }
+  }
+}
+
+// ---------------------------------------------------------------- finding
+
+/** The tallest fall in the world, or on one river. */
+function tallestFall(c: CompiledWorld, river?: string): CFall | null {
+  let best: CFall | null = null
+  for (const f of c.falls) {
+    if (river && f.river !== river) continue
+    if (!best || f.topM - f.bottomM > best.topM - best.bottomM) best = f
+  }
+  return best
+}
+
+const fallPoi = (w: TourWorld, f: CFall): Poi => {
+  const drop = f.topM - f.bottomM
+  const river = feature(w.world, f.river)?.name ?? 'a river'
+  return fixed(`fall-${f.river}-${Math.round(f.z)}`, `${river}'s ${Math.round(drop)} m fall`,
+    { z: f.z, a: f.a, altM: f.bottomM + drop / 2 }, 1, Math.max(300, drop * 2))
+}
+
+/** The longest ship at sea on `sea` at `hours`, if any is out. */
+function biggestShip(w: World, sea: string, hours: number): ShipLane | null {
+  const out = shipsAt(w, hours).filter((s) => s.lane.sea === sea)
+  return out.reduce<ShipLane | null>((best, s) => (!best || s.lane.lengthM > best.lengthM ? s.lane : best), null)
+}
+
+/**
+ * The broadest stand of rock in the deserts, not merely the first tall cell: scored by how
+ * much plateau there is within a couple of kilometres. And somewhere on the open plain a few
+ * kilometres off to watch it from, so the camera is over sand with the rock standing over it.
+ */
+function mesaField(w: TourWorld): { z: number; a: number; top: number; stand: { z: number; a: number }; d: CDesert } | null {
+  let best: { z: number; a: number; d: CDesert; score: number } | null = null
+  for (const d of w.compiled.deserts ?? []) {
+    const { grid } = d
+    const reach = Math.round(2000 / grid.cell)
+    for (let i = reach; i < grid.nz - reach; i += 4) {
+      for (let j = 0; j < grid.na; j += 4) {
+        if (d.plateau[i * grid.na + j]! < 0.9) continue
+        let score = 0
+        for (let di = -reach; di <= reach; di += 2) {
+          for (let dj = -reach; dj <= reach; dj += 2) {
+            const jj = grid.ring ? ((j + dj) % grid.na + grid.na) % grid.na : j + dj
+            if (jj < 0 || jj >= grid.na) continue
+            score += d.plateau[(i + di) * grid.na + jj]!
+          }
+        }
+        if (!best || score > best.score) best = { z: grid.z0 + i * grid.cell, a: grid.a0 + j * grid.cell, d, score }
+      }
+    }
+  }
+  if (!best) return null
+  const { grid } = best.d
+  let stand = { z: best.z, a: best.a + 4000 }
+  search: for (let r = 3500; r <= 7000; r += 500) {
+    for (let k = 0; k < 16; k++) {
+      const ang = (k / 16) * Math.PI * 2
+      const z = best.z + Math.cos(ang) * r, a = best.a + Math.sin(ang) * r
+      const i = Math.round((z - grid.z0) / grid.cell)
+      const j = ((Math.round((a - grid.a0) / grid.cell) % grid.na) + grid.na) % grid.na
+      if (i < 0 || i >= grid.nz) continue
+      if (best.d.plateau[i * grid.na + j]! < 0.03) { stand = { z, a }; break search }
+    }
+  }
+  return { z: best.z, a: best.a, top: w.ground(wrapA(best.a) / RADIUS_M, best.z), stand, d: best.d }
+}
+
+const pick = <T>(xs: readonly T[], rng: () => number): T => xs[Math.floor(rng() * xs.length) % xs.length]!
+
+/** How much a craft moving round the world weighs against standing still there, as a percentage. */
+const weightPct = (altM: number, aroundMS: number): number => {
+  const r = RADIUS_M - altM
+  return Math.round((100 * apparentGravity(r, aroundMS)) / apparentGravity(r, 0))
+}
+
+// ------------------------------------------------------------------- trains
+
+/** A train of a locomotive and `cars` carriages, `s` metres along a line, heading `dir`. */
+function trainAt(r: RailLine, s: number, dir: 1 | -1, cars: number): Craft[] {
+  const out: Craft[] = []
+  let back = 0
+  for (let k = 0; k <= cars; k++) {
+    const lenM = k === 0 ? 22 : 25
+    const mid = s - dir * (back + lenM / 2)
+    const f0 = railAt(r, mid - dir * lenM / 2), f1 = railAt(r, mid + dir * lenM / 2)
+    const p0 = toV({ z: f0.z, a: f0.a, altM: f0.altM }), p1 = toV({ z: f1.z, a: f1.a, altM: f1.altM })
+    const pos = scale(add(p0, p1), 0.5)
+    const fwd = norm(sub(p1, p0))
+    const { up } = basisAt(pos)
+    out.push({ kind: k === 0 ? 'locomotive' : 'carriage', pos, fwd, up: norm(sub(up, scale(fwd, dot(fwd, up)))) })
+    back += lenM + 1.5
+  }
+  return out
+}
+
+/** A train leg: the camera chasing a train `s0` along `r`, and the train drawn car by car. */
+function trainLeg(w: TourWorld, r: RailLine, o: LegOptions, s0: number, dir: 1 | -1, id: string, title: string,
+  hours: number, subject: Poi | null): Leg {
+  const cars = 8
+  const at = (t: number) => {
+    const f = railAt(r, s0 + dir * r.speedMS * t)
+    return { z: f.z, a: f.a, altM: f.altM + 3 }
+  }
+  return makeLeg(w, {
+    id, title, vehicle: 'train', hours, durationS: o.durationS, fovDeg: 60, path: at, subject,
+    rig: { kind: 'chase', back: 110, raise: 28, swingRad: 1.1 }, draw: false,
+    extra: (t) => trainAt(r, s0 + dir * r.speedMS * t, dir, cars),
+  })
+}
+
+/** Where along a line the track first passes `z`, from `from` on. */
+function sAtZ(r: RailLine, z: number): number {
+  let best = 0, bestD = Infinity
+  for (let i = 0; i < r.line.s.length; i++) {
+    const d = Math.abs(r.line.pts[2 * i]! - z)
+    if (d < bestD) { bestD = d; best = i }
+  }
+  return r.line.s[best]!
+}
+
+// ----------------------------------------------------------------------- legs
+
+export const INTERIOR_LEGS: LegSpec[] = [
+  {
+    id: 'falls', vehicle: 'helicopter',
+    prepare: (w, o) => {
+      const f = tallestFall(w.compiled)
+      if (!f) return null
+      const drop = f.topM - f.bottomM
+      const subject = fallPoi(w, f)
+      // Out in front of the fall and below its lip, swinging slowly across its face.
+      const centre = { z: f.z + f.dz * 60, a: f.a + f.da * 60 }
+      return makeLeg(w, {
+        id: 'falls', vehicle: 'helicopter', hours: morningOver(w, f.z), durationS: o.durationS, fovDeg: 55,
+        title: `Circling ${subject.name} — it leans antispinward as it drops`,
+        path: circle(w, centre, 1500, f.bottomM + drop * 0.7, Math.atan2(f.da, f.dz) - 0.3, 40, 60),
+        rig: { kind: 'cockpit' }, subject, limitRad: 2.2,
+      })
+    },
+  },
+  {
+    id: 'falls-glide', vehicle: 'paraglider',
+    prepare: (w, o) => {
+      const f = tallestFall(w.compiled)
+      if (!f) return null
+      const drop = f.topM - f.bottomM
+      const subject = fallPoi(w, f)
+      // Across the fall's face, well out from it, sinking gently.
+      const cz = f.z + f.dz * 1300, ca = f.a + f.da * 1300
+      const pz = -f.da, pa = f.dz
+      const alt0 = f.bottomM + drop * 0.8
+      return makeLeg(w, {
+        id: 'falls-glide', vehicle: 'paraglider', hours: morningOver(w, f.z), durationS: o.durationS, fovDeg: 58,
+        title: `A paraglider past ${subject.name}`,
+        path: (t) => {
+          const d = -80 + 11 * t
+          const z = cz + pz * d, a = ca + pa * d
+          return { z, a, altM: Math.max(alt0 - 1.2 * t, w.ground(wrapA(a) / RADIUS_M, z) + 80) }
+        },
+        rig: { kind: 'chase', back: 45, raise: 8, swingRad: 0.9 }, subject,
+      })
+    },
+  },
+  {
+    id: 'ship', vehicle: 'ship',
+    prepare: (w, o) => {
+      const sea = w.world.features.find((f): f is Sea => f.kind === 'sea' && lanesOf(f, w.world.seed).length > 0)
+      const hours = sea ? morningOver(w, sea.geometry.type === 'band'
+        ? (sea.geometry.zFrom + sea.geometry.zTo) / 2 : anchorOf(sea).z) : 12
+      const lane = sea && biggestShip(w.world, sea.id, hours)
+      if (!sea || !lane) return null
+      return shipLeg(w, o, lane, hours, 'ship', `Following a ${Math.round(lane.lengthM)} m ship round ${sea.name}`)
+    },
+  },
+  {
+    id: 'squall', vehicle: 'ship',
+    prepare: (w, o) => {
+      // A blow Tower gave too little warning of for the fleet to get in (weather.ts).
+      for (const oc of occurrences(w.world.weather, null, 0, 24 * Math.max(28, w.world.weather.cycleDays))) {
+        if (oc.event.kind !== 'gale' && oc.event.kind !== 'storm') continue
+        if (oc.publishedH <= oc.startH - HARBOUR_LEAD_H) continue
+        const sea = feature(w.world, oc.event.target)
+        const hours = oc.startH + Math.min(2, oc.endH - oc.startH - 0.5)
+        const lane = sea?.kind === 'sea' ? biggestShip(w.world, sea.id, hours) : null
+        if (!sea || !lane) continue
+        return shipLeg(w, o, lane, hours, 'squall',
+          `Caught out: a ship in a ${oc.event.kind} on ${sea.name} that Tower gave no warning of`)
+      }
+      return null
+    },
+  },
+  {
+    id: 'storm', vehicle: 'light-plane',
+    prepare: (w, o) => {
+      for (const oc of occurrences(w.world.weather, null, 0, 24 * Math.max(28, w.world.weather.cycleDays))) {
+        const sea = feature(w.world, oc.event.target)
+        if (oc.event.kind !== 'storm' || sea?.kind !== 'sea' || sea.geometry.type !== 'band') continue
+        if (oc.revisions.length) continue
+        const hours = oc.startH + Math.min(8, (oc.endH - oc.startH) / 2)
+        const dir = (oc.event.wind.directionDeg * Math.PI) / 180
+        const level = w.compiled.basins.find((b) => b.id === sea.id)?.levelM ?? 0
+        const z = (sea.geometry.zFrom + sea.geometry.zTo) / 2
+        // Running before the storm, low, over a sea with nobody on it.
+        return makeLeg(w, {
+          id: 'storm', vehicle: 'light-plane', hours, durationS: o.durationS, fovDeg: 62,
+          title: `The storm Tower published for ${sea.name}: ${oc.event.wind.speedMS} m/s, and every ship in harbour`,
+          path: (t) => ({ z: z + Math.cos(dir) * 55 * t, a: Math.sin(dir) * 55 * t, altM: level + 90 }),
+          rig: { kind: 'cockpit' }, pois: [], limitRad: 0.8,
+        })
+      }
+      return null
+    },
+  },
+  {
+    id: 'summit', vehicle: 'airship',
+    prepare: (w, o) => {
+      const peaks = w.world.features.flatMap((f) => (f.kind === 'mountains' ? f.peaks : []))
+      const top = peaks.reduce<(typeof peaks)[number] | null>((b, p) => (!b || p.elevationM > b.elevationM ? p : b), null)
+      if (!top) return null
+      const subject = fixed(`peak-${top.name}`, top.name, { z: top.z, a: top.theta * RADIUS_M, altM: top.elevationM - 250 }, 1.3, 2500)
+      const a = top.theta * RADIUS_M
+      return makeLeg(w, {
+        id: 'summit', vehicle: 'airship', hours: morningOver(w, top.z), durationS: o.durationS, fovDeg: 60,
+        title: `An airship off ${top.name}, ${top.elevationM.toLocaleString('en')} m, the highest summit in Tower`,
+        path: circle(w, { z: top.z, a }, 4200, top.elevationM + 250, 0.3, 22, 300),
+        rig: { kind: 'chase', back: 330, raise: 60, swingRad: 1.0 }, subject,
+      })
+    },
+  },
+  {
+    id: 'ridge', vehicle: 'paraglider',
+    prepare: (w, o) => {
+      // Along a summit's flank, under two kilometres out from its top, in the lift the slope
+      // throws up: a paraglider working the mountainside with the summit above it.
+      const peaks = w.world.features.flatMap((f) => (f.kind === 'mountains' ? f.peaks.filter((p) => p.name) : []))
+      if (!peaks.length) return null
+      const p = pick(peaks, o.rng)
+      const a = p.theta * RADIUS_M
+      const bearing = o.rng() * Math.PI * 2
+      const foot = { z: p.z + Math.cos(bearing) * 1700, a: a + Math.sin(bearing) * 1700 }
+      const line = straight(foot.z - Math.sin(bearing) * 300, foot.a + Math.cos(bearing) * 300, bearing - Math.PI / 2, 1500)
+      const subject = fixed(`peak-${p.name}`, p.name, { z: p.z, a, altM: p.elevationM + 30 }, 1.1, 2500)
+      const flank = lineFlight(w, line, 0, 11, 200, o.durationS)
+      return makeLeg(w, {
+        id: 'ridge', vehicle: 'paraglider', hours: morningOver(w, p.z), durationS: o.durationS, fovDeg: 60,
+        title: `Ridge-soaring on the flank of ${p.name}, ${p.elevationM.toLocaleString('en')} m`,
+        // Above the summit's height: its top is a dome, and from lower on the flank the brow
+        // of it hides the summit itself.
+        path: (t) => { const q = flank(t); return { ...q, altM: Math.max(q.altM, p.elevationM + 140) } },
+        rig: { kind: 'chase', back: 40, raise: 7, swingRad: 1.2 }, subject,
+      })
+    },
+  },
+  {
+    id: 'river', vehicle: 'light-plane',
+    prepare: (w, o) => {
+      // A river's last big fall, at its mouth: flown down to, and over.
+      let best: { r: CompiledWorld['rivers'][number]; f: CFall; s: number } | null = null
+      for (const r of w.compiled.rivers) {
+        for (const f of w.compiled.falls) {
+          if (f.river !== r.id || f.topM - f.bottomM < 50) continue
+          const s = sAlong(r.line, f.z, f.a)
+          const frac = s / r.line.s[r.line.s.length - 1]!
+          if (!best || frac > best.s / best.r.line.s[best.r.line.s.length - 1]!) best = { r, f, s }
+        }
+      }
+      if (!best) return null
+      const name = feature(w.world, best.r.id)?.name ?? 'the river'
+      const outlet = best.r.outlet ? feature(w.world, best.r.outlet)?.name : null
+      const drop = Math.round(best.f.topM - best.f.bottomM)
+      return makeLeg(w, {
+        id: 'river', vehicle: 'light-plane', hours: morningOver(w, best.f.z), durationS: o.durationS, fovDeg: 65,
+        title: `Down ${name} to its ${drop} m fall${outlet ? ` into ${outlet}` : ''}`,
+        path: lineFlight(w, best.r.line, best.s - 65 * o.durationS * 0.85, 65, 220, o.durationS),
+        rig: { kind: 'cockpit' }, subject: fallPoi(w, best.f), limitRad: 1.1,
+      })
+    },
+  },
+  {
+    id: 'narrows', vehicle: 'light-plane',
+    prepare: (w, o) => {
+      const f = feature(w.world, 'the-narrows')
+      if (!f || f.geometry.type !== 'polyline') return null
+      const line = smoothLine(f.geometry.points, 50)
+      const length = line.s[line.s.length - 1]!
+      const from = o.rng() * Math.max(0, length - 65 * o.durationS - 1000)
+      return makeLeg(w, {
+        id: 'narrows', vehicle: 'light-plane', hours: morningOver(w, along(line, from).z), durationS: o.durationS, fovDeg: 65,
+        title: `Through ${f.name}, the one gap in all three ridges`,
+        path: lineFlight(w, line, from, 65, 300, o.durationS),
+        rig: { kind: 'chase', back: 35, raise: 7, swingRad: 0.5 },
+      })
+    },
+  },
+  {
+    id: 'mesas', vehicle: 'helicopter',
+    prepare: (w, o) => {
+      const m = mesaField(w)
+      if (!m) return null
+      const name = feature(w.world, m.d.id)?.name ?? 'the desert'
+      const standGround = w.ground(wrapA(m.stand.a) / RADIUS_M, m.stand.z)
+      const subject = fixed('mesas', `the mesas of ${name}`, { z: m.z, a: m.a, altM: m.top - m.d.plateauHeightM * 0.45 }, 1.2, 4000, false)
+      return makeLeg(w, {
+        id: 'mesas', vehicle: 'helicopter', hours: morningOver(w, m.z), durationS: o.durationS, fovDeg: 55,
+        title: `Past the mesas of ${name}: ${m.d.terraces} tiers of rock, the strata lining up across all of them`,
+        path: circle(w, m.stand, 1500, standGround + 320, 0.4, 45, 220),
+        rig: { kind: 'cockpit' }, subject, limitRad: 2.4,
+      })
+    },
+  },
+  {
+    id: 'thermal', vehicle: 'glider',
+    prepare: (w, o) => {
+      const m = mesaField(w)
+      if (!m) return null
+      const name = feature(w.world, m.d.id)?.name ?? 'the desert'
+      const standGround = w.ground(wrapA(m.stand.a) / RADIUS_M, m.stand.z)
+      const subject = fixed('mesas', `the mesas of ${name}`, { z: m.z, a: m.a, altM: m.top - m.d.plateauHeightM * 0.3 }, 1.0, 4000, false)
+      return makeLeg(w, {
+        id: 'thermal', vehicle: 'glider', hours: morningOver(w, m.z) + 2, durationS: o.durationS, fovDeg: 60,
+        title: `A glider climbing in a thermal over ${name}, the hot ground Tower lights hardest`,
+        path: circle(w, m.stand, 220, Math.max(m.top, standGround) + 250, o.rng() * 6, 30, 200, 2.5),
+        rig: { kind: 'chase', back: 45, raise: 10 }, subject,
+      })
+    },
+  },
+  {
+    id: 'pan', vehicle: 'light-plane',
+    prepare: (w, o) => {
+      const d = (w.compiled.deserts ?? []).find((x) => x.pans.length > 0)
+      const pan = d?.pans[0]
+      if (!d || !pan) return null
+      const name = feature(w.world, d.id)?.name ?? 'the desert'
+      // Across the pan, low, so the benches around it stand against the sky.
+      const span = Math.max(pan.radiusM * 3, 60 * o.durationS + 500)
+      const line = straight(pan.z - span / 2, pan.a, 0, span)
+      return makeLeg(w, {
+        id: 'pan', vehicle: 'light-plane', hours: morningOver(w, pan.z), durationS: o.durationS, fovDeg: 60,
+        title: `Over a salt pan in ${name}, ringed by the shorelines of a lake that never stood`,
+        path: lineFlight(w, line, 0, 60, 70, o.durationS),
+        rig: { kind: 'cockpit' }, limitRad: 1.3,
+      })
+    },
+  },
+  {
+    id: 'giants', vehicle: 'helicopter',
+    prepare: (w, o) => {
+      const tall = (w.compiled.forests ?? []).find((f) => f.giants)
+      if (!tall) return null
+      const name = feature(w.world, tall.id)?.name ?? 'the tall wood'
+      const b = tall.box
+      const centre = { z: (b.z0 + b.z1) / 2, a: (b.a0 + b.a1) / 2 }
+      // One particular giant, found the way the view finds them (forest.ts), circled from
+      // far enough out to see it whole: flying through a grove put the camera in a trunk.
+      let best: Tree | null = null
+      forEachTree(tall, { z0: centre.z - 300, z1: centre.z + 300, a0: centre.a - 300, a1: centre.a + 300 },
+        (z, a) => ({ stems: tall.densityPerHa, h: w.ground(wrapA(a) / RADIUS_M, z) }),
+        (tree) => { if (!best || tree.heightM > best.heightM) best = tree })
+      if (!best) return null
+      const tree = best as Tree
+      const foot = w.ground(wrapA(tree.a) / RADIUS_M, tree.z)
+      const subject = fixed('giant', `a ${Math.round(tree.heightM)} m giant`, { z: tree.z, a: tree.a, altM: foot + tree.heightM * 0.45 }, 1.5, 200)
+      return makeLeg(w, {
+        id: 'giants', vehicle: 'helicopter', hours: morningOver(w, tree.z), durationS: o.durationS, fovDeg: 70,
+        title: `Under the giants of ${name}: ${Math.round(tree.heightM)} m, near the tallest a tree can be at 1 g`,
+        path: circle(w, tree, 75, foot + 14, 0.6, 7, 12),
+        rig: { kind: 'cockpit' }, subject, pois: [subject], limitRad: 2.6, maxPitch: 1.4,
+      })
+    },
+  },
+  {
+    id: 'canopy', vehicle: 'light-plane',
+    prepare: (w, o) => {
+      const wood = (w.compiled.forests ?? []).find((f) => !f.giants && f.densityPerHa > 100)
+      if (!wood) return null
+      const name = feature(w.world, wood.id)?.name ?? 'the forest'
+      const b = wood.box
+      const z = b.z0 + (b.z1 - b.z0) * (0.3 + 0.4 * o.rng())
+      const line = straight(z - 3000, o.rng() * 30_000, 0.35, 12_000)
+      return makeLeg(w, {
+        id: 'canopy', vehicle: 'light-plane', hours: morningOver(w, z), durationS: o.durationS, fovDeg: 65,
+        title: `Low over ${name}: closed canopy, and the meadows Tower left open in it`,
+        path: lineFlight(w, line, 0, 62, 150, o.durationS),
+        rig: { kind: 'chase', back: 30, raise: 6, swingRad: 0.7 },
+      })
+    },
+  },
+  {
+    id: 'deep', vehicle: 'seaplane',
+    prepare: (w, o) => {
+      const capital = feature(w.world, 'the-capital')
+      const deep = feature(w.world, 'the-deep')
+      if (!capital || capital.geometry.type !== 'point' || !deep) return null
+      const { theta, z } = capital.geometry
+      // Spinward, across the lake the short way, low over the water, toward the far shore —
+      // which the floor's curve lifts well above the eye.
+      const line = straight(z, theta * RADIUS_M, Math.PI / 2, 45_000)
+      const subject = fixed('far-shore', `the far shore of ${deep.name}`, { z, a: theta * RADIUS_M + 24_000, altM: 400 }, 1.0, 12_000)
+      return makeLeg(w, {
+        id: 'deep', vehicle: 'seaplane', hours: morningOver(w, z), durationS: o.durationS, fovDeg: 62,
+        title: `Out from ${capital.name} over ${deep.name} — the far shore climbs the sky ahead`,
+        path: lineFlight(w, line, 9000, 55, 120, o.durationS),
+        rig: { kind: 'chase', back: 32, raise: 6, swingRad: 0.6 }, subject,
+      })
+    },
+  },
+  {
+    id: 'girdle', vehicle: 'airliner',
+    prepare: (w, o) => {
+      const g = feature(w.world, 'the-girdle')
+      if (!g || g.geometry.type !== 'band') return null
+      const z = (g.geometry.zFrom + g.geometry.zTo) / 2
+      const alt = 9000, v = 230
+      const a0 = o.rng() * 300_000
+      return makeLeg(w, {
+        id: 'girdle', vehicle: 'airliner', hours: morningOver(w, z), durationS: o.durationS, fovDeg: 72,
+        title: `Nine kilometres over ${g.name}, flying antispinward: the airliner weighs ${weightPct(alt, -v)}% of what it would at rest`,
+        path: (t) => ({ z, a: a0 - v * t, altM: alt }),
+        rig: { kind: 'cockpit' }, limitRad: 1.4,
+      })
+    },
+  },
+  {
+    id: 'climb', vehicle: 'giant-airship',
+    prepare: (w, o) => {
+      const capital = feature(w.world, 'the-capital')
+      if (!capital || capital.geometry.type !== 'point') return null
+      const { theta, z } = capital.geometry
+      const rate = 150
+      const a0 = theta * RADIUS_M - 4000
+      const h0 = o.rng() * Math.PI * 2
+      // Straight up, as only a lighter-than-air craft can, turning slowly as it goes.
+      return makeLeg(w, {
+        id: 'climb', vehicle: 'giant-airship', hours: morningOver(w, z) + 1, rate, durationS: o.durationS, fovDeg: 72,
+        title: `Up from ${capital.name} in a giant airship: the world curls up and closes overhead`,
+        path: (t) => ({ z: z + 6 * rate * t, a: a0, altM: 2500 + 5 * rate * t }),
+        heading: (t) => h0 + 0.07 * t,
+        rig: { kind: 'cockpit' }, limitRad: 1.6, maxPitch: 1.3,
+      })
+    },
+  },
+  {
+    id: 'look-up', vehicle: 'glider',
+    prepare: (w, o) => {
+      const z = 290_000 + o.rng() * 100_000
+      const a0 = (o.rng() * 0.5 + 0.9) * RADIUS_M
+      const th0 = a0 / RADIUS_M
+      // Only the far side of the world: what hangs overhead, 100 km up and clear.
+      const pois = worldPois(w, morningOver(w, z), 1).filter((p) => {
+        const q = p.at(0)
+        if (!q || !p.grounded) return false
+        const d = Math.abs(Math.atan2(Math.sin(Math.atan2(q.y, q.x) - th0), Math.cos(Math.atan2(q.y, q.x) - th0)))
+        return d > 1.3
+      }).map((p) => ({ ...p, weight: p.weight * 2, sizeM: p.sizeM * 4 }))
+      return makeLeg(w, {
+        id: 'look-up', vehicle: 'glider', hours: morningOver(w, z), durationS: o.durationS, fovDeg: 70,
+        title: 'Looking up from a glider: the far side of the world overhead is clearer than the ground ahead',
+        path: (t) => ({ z: z + 32 * t, a: a0, altM: 2500 }),
+        rig: { kind: 'cockpit' }, pois, limitRad: 1.65, maxPitch: 1.5,
+      })
+    },
+  },
+  {
+    id: 'sun-circuit', vehicle: 'giant-airship',
+    prepare: (w, o) => {
+      const rate = 20
+      const hours = hourWhenSunAt(w.world.calendar, 0, 200_000 + o.rng() * 140_000)
+      if (sunZ(w, hours) === null) return null
+      const out = BEAM_RADIUS_M + 5400
+      const phi0 = o.rng() * Math.PI * 2
+      const subject = theSun(w, hours, rate)
+      return makeLeg(w, {
+        id: 'sun-circuit', vehicle: 'giant-airship', hours, rate, durationS: o.durationS, fovDeg: 68,
+        title: `Round the sun in a giant airship, ${Math.round((RADIUS_M - out) / 1000)} km up, where the air is thin and weighs a tenth — outside the beam, as every aircraft must stay`,
+        path: (t) => ({
+          z: (sunZ(w, hours + (t * rate) / 3600) ?? 0) - 2500,
+          a: (phi0 + (28 * rate * t) / out) * RADIUS_M,
+          altM: RADIUS_M - out,
+        }),
+        rig: { kind: 'chase', back: 1400, raise: 250 }, subject, subjectBoost: 3, limitRad: 2.6, maxPitch: 1.5,
+      })
+    },
+  },
+  {
+    id: 'sun-chase', vehicle: 'giant-airship',
+    prepare: (w, o) => {
+      const rate = 40
+      const hours = hourWhenSunAt(w.world.calendar, 0, 120_000 + o.rng() * 200_000)
+      if (sunZ(w, hours) === null) return null
+      const out = BEAM_RADIUS_M + 4400
+      const a = o.rng() * RADIUS_M * 6
+      return makeLeg(w, {
+        id: 'sun-chase', vehicle: 'giant-airship', hours, rate, durationS: o.durationS, fovDeg: 66,
+        title: 'Keeping pace with the sun down the length of the world, beside the beam',
+        path: (t) => ({ z: (sunZ(w, hours + (t * rate) / 3600) ?? 0) - 9000, a, altM: RADIUS_M - out }),
+        heading: () => 0,
+        rig: { kind: 'chase', back: 1300, raise: 200, swingRad: 1.2 }, subject: theSun(w, hours, rate), limitRad: 2.6, maxPitch: 1.5,
+      })
+    },
+  },
+  {
+    id: 'far-cap', vehicle: 'airliner',
+    prepare: (w, o) => {
+      const a = o.rng() * RADIUS_M * 6
+      return makeLeg(w, {
+        id: 'far-cap', vehicle: 'airliner', hours: NIGHT, durationS: o.durationS, fovDeg: 66,
+        title: 'Toward the far cap by night: the one sky in Tower that moves, turning once every 7 min 29 s',
+        path: (t) => ({ z: 460_000 + 230 * t, a, altM: 3500 }),
+        rig: { kind: 'cockpit' }, pois: 'sky', subject: farCap(), limitRad: 1.2,
+      })
+    },
+  },
+  {
+    id: 'sun-cap', vehicle: 'seaplane',
+    prepare: (w, o) => {
+      const sea = feature(w.world, 'sunward-sea')
+      const level = w.compiled.basins.find((b) => b.id === 'sunward-sea')?.levelM ?? 90
+      const a = o.rng() * RADIUS_M * 6
+      return makeLeg(w, {
+        id: 'sun-cap', vehicle: 'seaplane', hours: NIGHT - 0.4, durationS: o.durationS, fovDeg: 66,
+        title: `Over ${sea?.name ?? 'the Sunward Sea'} by night, toward the sun cap: the collector's black disc, ringed by its lit rim`,
+        path: (t) => ({ z: 34_000 - 55 * t, a, altM: level + 350 }),
+        rig: { kind: 'cockpit' }, pois: 'sky', subject: sunCap(), limitRad: 1.2,
+      })
+    },
+  },
+  {
+    id: 'sea-skim', vehicle: 'seaplane',
+    prepare: (w, o) => {
+      const sea = feature(w.world, 'deepward-sea')
+      if (!sea) return null
+      const level = w.compiled.basins.find((b) => b.id === sea.id)?.levelM ?? 90
+      const z0 = sea.geometry.type === 'band' ? sea.geometry.zFrom + 6000 : 500_000
+      const a = o.rng() * RADIUS_M * 6
+      return makeLeg(w, {
+        id: 'sea-skim', vehicle: 'seaplane', hours: morningOver(w, z0), durationS: o.durationS, fovDeg: 62,
+        title: `Low over ${sea.name} toward the far cap, a cliff 50 km high that is all window`,
+        path: (t) => ({ z: z0 + 55 * t, a, altM: level + 22 }),
+        rig: { kind: 'chase', back: 30, raise: 5, swingRad: 0.8 }, subject: farCap(),
+      })
+    },
+  },
+  {
+    id: 'train-narrows', vehicle: 'train',
+    prepare: (w, o) => {
+      const r = w.rails.find((x) => !x.loop)
+      const narrows = feature(w.world, 'the-narrows')
+      if (!r || !narrows) return null
+      const n = anchorOf(narrows)
+      const s0 = sAtZ(r, n.z - 8000 + o.rng() * 16_000)
+      const subject = fixed('the-narrows', narrows.name, { z: n.z, a: n.theta * RADIUS_M, altM: w.ground(n.theta, n.z) + 300 }, 0.8, 8000)
+      return trainLeg(w, r, o, s0, 1, 'train-narrows',
+        `${r.name} through ${narrows.name}: the one way along the world, by rail as by river`, morningOver(w, n.z), subject)
+    },
+  },
+  {
+    id: 'train-deep', vehicle: 'train',
+    prepare: (w, o) => {
+      const r = w.rails.find((x) => !x.loop)
+      const deep = feature(w.world, 'the-deep')
+      if (!r || !deep) return null
+      const s0 = sAtZ(r, 262_000 + o.rng() * 20_000)
+      return trainLeg(w, r, o, s0, -1, 'train-deep', `${r.name} along the shore of ${deep.name}, past the Capital`,
+        morningOver(w, 270_000), null)
+    },
+  },
+  {
+    id: 'train-ring', vehicle: 'train',
+    prepare: (w, o) => {
+      const r = w.rails.find((x) => x.loop)
+      if (!r) return null
+      const s0 = o.rng() * r.lengthM
+      const dir: 1 | -1 = o.rng() < 0.5 ? 1 : -1
+      const f = railAt(r, s0)
+      // Round the world: spinward is +a, which is the way a lap with increasing s goes when
+      // the line was authored with θ rising.
+      const around = Math.sin(f.heading) * dir * r.speedMS
+      const pct = weightPct(f.altM, around)
+      return trainLeg(w, r, o, s0, dir, 'train-ring',
+        `${r.name}, running ${around > 0 ? 'spinward' : 'antispinward'} at ${Math.round(r.speedMS * 3.6)} km/h: everyone aboard weighs ${pct}% of what they did on the platform`,
+        morningOver(w, f.z), null)
+    },
+  },
+]
+
+/** A chase camera on a ship, which the fleet draws itself. */
+function shipLeg(w: TourWorld, o: LegOptions, lane: ShipLane, hours: number, id: string, title: string): Leg {
+  const level = w.compiled.basins.find((b) => b.id === lane.sea)?.levelM ?? 0
+  const scaleM = Math.max(1, lane.lengthM / 20)
+  return makeLeg(w, {
+    id, vehicle: 'ship', hours, durationS: o.durationS, fovDeg: 55, title,
+    path: (t) => { const f = shipAt(lane, hours + t / 3600); return { z: f.z, a: f.a, altM: level + lane.lengthM * 0.25 } },
+    heading: (t) => shipAt(lane, hours + t / 3600).headingRad,
+    rig: { kind: 'chase', back: 60 * scaleM, raise: 12 * scaleM, swingRad: 0.9 }, draw: false,
+  })
+}
+
+/** Every leg the world has, prepared with its own length and luck. */
+export function planInterior(w: TourWorld, rng: () => number, durationS: (id: string) => number): Leg[] {
+  return INTERIOR_LEGS.flatMap((spec) => spec.prepare(w, { durationS: durationS(spec.id), rng }) ?? [])
+}
diff --git a/src/tour/plan.ts b/src/tour/plan.ts
new file mode 100644
index 0000000..4c165f0
--- /dev/null
+++ b/src/tour/plan.ts
@@ -0,0 +1,52 @@
+/**
+ * The order the tour flies its legs in, and how long each lasts. See world-generation.md §19.
+ *
+ * Variety is the point (D-085), so the tour is drawn like cards from a shuffled deck: every
+ * leg is flown once before any is flown twice, no two in a row are the same kind of craft,
+ * and each lasts its own twelve to eighteen seconds. When the deck runs out it is shuffled
+ * again, and the new deck never opens with the craft the old one closed on.
+ */
+import { mulberry32 } from '../terrain/noise.js'
+
+export const LEG_MIN_S = 12
+export const LEG_MAX_S = 18
+
+/** A random number source: a fresh one per tour, or a fixed one for the screenshots. */
+export const tourRng = (seed: number): (() => number) => mulberry32(seed >>> 0)
+
+/** How long a leg lasts, from a draw in [0, 1). */
+export const legSeconds = (u: number): number => LEG_MIN_S + (LEG_MAX_S - LEG_MIN_S) * u
+
+/**
+ * A shuffled order of `items`, with no two neighbours of the same kind where that can be
+ * helped — nor of the same `theme`, so two legs about one waterfall are not flown back to
+ * back — and the first not of kind `after`. At each step the next card is the first of the
+ * shuffled deck that differs from the last one laid, unless one kind has so many cards left
+ * that it must be laid now or end up laid twice running, and then it is.
+ */
+export function deal<T>(items: readonly T[], kind: (x: T) => string, rng: () => number, after?: string,
+  theme: (x: T) => string = () => ''): T[] {
+  const deck = [...items]
+  for (let i = deck.length - 1; i > 0; i--) {
+    const j = Math.floor(rng() * (i + 1))
+    ;[deck[i], deck[j]] = [deck[j]!, deck[i]!]
+  }
+  const out: T[] = []
+  let last = after
+  let lastTheme: string | null = null
+  while (deck.length) {
+    const counts = new Map<string, number>()
+    for (const x of deck) counts.set(kind(x), (counts.get(kind(x)) ?? 0) + 1)
+    let must: string | null = null
+    for (const [k, c] of counts) if (k !== last && c > (deck.length - 1) / 2) must = k
+    const fits = (x: T) => (must !== null ? kind(x) === must : kind(x) !== last)
+    let i = deck.findIndex((x) => fits(x) && (!theme(x) || theme(x) !== lastTheme))
+    if (i < 0) i = deck.findIndex(fits)
+    if (i < 0) i = 0
+    const [x] = deck.splice(i, 1)
+    out.push(x!)
+    last = kind(x!)
+    lastTheme = theme(x!)
+  }
+  return out
+}
diff --git a/src/ui/app.ts b/src/ui/app.ts
index 01f531c..97a7a8f 100644
--- a/src/ui/app.ts
+++ b/src/ui/app.ts
@@ -260,6 +260,18 @@ export class App {
     spin.onchange = apply
     speed.oninput = apply
     apply()
+    $('f-ext-tour').onclick = () => {
+      const v = this.exterior
+      if (!v) return
+      if (v.touring) v.stopTour()
+      else {
+        v.startTour()
+        if (this.narrow.matches) this.setPanel(false)
+      }
+      this.syncTour()
+    }
+    // A tour also stops from inside its view — a drag, Esc — so the labels are kept in step.
+    setInterval(() => this.syncTour(), 500)
     this.buildInteriorControls()
     this.buildTimeControls()
   }
@@ -950,12 +962,13 @@ export class App {
     this.eye.set(v.eyeM)
   }
 
-  /** The tour button says what pressing it will do. */
+  /** The tour buttons say what pressing them will do. */
   private syncTour(): void {
-    const v = this.interior
-    if (!v) return
-    const tour = v.touring ? 'Stop the tour' : 'Scenic tour'
-    if ($('f-tour').textContent !== tour) $('f-tour').textContent = tour
+    for (const [id, v] of [['f-tour', this.interior], ['f-ext-tour', this.exterior]] as const) {
+      if (!v) continue
+      const tour = v.touring ? 'Stop the tour' : 'Scenic tour'
+      if ($(id).textContent !== tour) $(id).textContent = tour
+    }
   }
 
   /**
diff --git a/src/views/craft.ts b/src/views/craft.ts
new file mode 100644
index 0000000..bd6c7c7
--- /dev/null
+++ b/src/views/craft.ts
@@ -0,0 +1,187 @@
+/**
+ * The flyers of the scenic tour, drawn: paragliders, gliders, light aircraft and seaplanes,
+ * airliners, airships and the giants that live near the axis, trains, and spacecraft. See
+ * world-generation.md §19, D-085.
+ *
+ * Each is a handful of plain shapes, modelled in metres with its nose along +x and its top
+ * along +y, which is how the tour hands them over (tour/interior-legs.ts `Craft`). They are
+ * seen for fifteen seconds at a time, mostly from a chase camera tens of metres back, so the
+ * silhouette is what matters: the paraglider's arched wing, the airliner's swept one, the
+ * airship's envelope and fins.
+ *
+ * The material is the caller's: the interior hazes and lights them like everything else in
+ * there, and the exterior lights its spacecraft with its own lights.
+ */
+import {
+  BoxGeometry, type BufferGeometry, ConeGeometry, CylinderGeometry, Group, type Material, Matrix4, Mesh,
+  SphereGeometry, Vector3,
+} from 'three'
+import type { CraftKind } from '../tour/interior-legs.js'
+
+export type MaterialFor = (colour: number) => Material
+
+interface Part { g: BufferGeometry; colour: number }
+
+const box = (w: number, h: number, d: number, x: number, y: number, z: number, colour: number): Part =>
+  ({ g: new BoxGeometry(w, h, d).translate(x, y, z), colour })
+
+/** A cylinder along x. */
+const tube = (r: number, length: number, x: number, y: number, z: number, colour: number, r2 = r): Part =>
+  ({ g: new CylinderGeometry(r2, r, length, 16).rotateZ(-Math.PI / 2).translate(x, y, z), colour })
+
+/** A cone along +x, its point forward. */
+const nose = (r: number, length: number, x: number, y: number, z: number, colour: number): Part =>
+  ({ g: new ConeGeometry(r, length, 16).rotateZ(-Math.PI / 2).translate(x, y, z), colour })
+
+const blob = (rx: number, ry: number, rz: number, x: number, y: number, z: number, colour: number): Part =>
+  ({ g: new SphereGeometry(1, 24, 16).scale(rx, ry, rz).translate(x, y, z), colour })
+
+/** A wing: a flat box `span` across (z), `chord` deep, swept back by `sweep` metres at the tips. */
+function wing(span: number, chord: number, thick: number, x: number, y: number, sweep: number, colour: number): Part {
+  const g = new BoxGeometry(chord, thick, span).translate(x, y, 0)
+  const p = g.attributes['position']!
+  for (let i = 0; i < p.count; i++) p.setX(i, p.getX(i) - (Math.abs(p.getZ(i)) / (span / 2)) * sweep)
+  g.computeVertexNormals()
+  return { g, colour }
+}
+
+const WHITE = 0xe9e9ec, GREY = 0x8b9099, DARK = 0x2a2d33
+
+function parts(kind: CraftKind): Part[] {
+  switch (kind) {
+    case 'paraglider': {
+      // An arched wing overhead on its lines, and the pilot hanging under it.
+      const out: Part[] = [box(0.6, 1.1, 0.5, 0, 0, 0, DARK)]
+      const cells = 9, R = 6.5, arc = 1.6
+      for (let i = 0; i < cells; i++) {
+        const a = -arc / 2 + (arc * (i + 0.5)) / cells
+        const g = new BoxGeometry(2.6, 0.3, (R * arc) / cells + 0.05)
+          .rotateX(-a).translate(0, 7 + R * (Math.cos(a) - 1) + 0.5, R * Math.sin(a))
+        out.push({ g, colour: i === 0 || i === cells - 1 ? 0xf2c14e : 0xe0662a })
+      }
+      for (const s of [-1, 1]) out.push(box(0.05, 6.3, 0.05, 0, 3.4, s * 2.2, GREY))
+      return out
+    }
+    case 'glider':
+      return [
+        tube(0.45, 7, 0, 0, 0, WHITE, 0.25), nose(0.45, 1.2, 4.1, 0, 0, WHITE),
+        blob(0.8, 0.4, 0.4, 2.2, 0.35, 0, 0x5e7690),
+        wing(17, 0.95, 0.12, 0.6, 0.25, 0.3, WHITE),
+        box(0.9, 1.4, 0.1, -3.4, 0.7, 0, WHITE), wing(2.8, 0.6, 0.08, -3.6, 1.4, 0.1, WHITE),
+      ]
+    case 'light-plane':
+    case 'seaplane': {
+      const body = kind === 'seaplane' ? 0xd9a32e : 0xb8322e
+      const out = [
+        tube(0.65, 7.5, 0, 0, 0, body, 0.35), nose(0.65, 1, 4.2, 0, 0, WHITE),
+        blob(1.1, 0.55, 0.6, 1.2, 0.55, 0, 0x5e7690),
+        wing(11, 1.5, 0.18, 0.8, 0.85, 0, WHITE),
+        box(1.3, 1.6, 0.12, -3.3, 0.8, 0, body), wing(3.4, 0.9, 0.1, -3.4, 0.2, 0.1, WHITE),
+      ]
+      if (kind === 'seaplane') for (const s of [-1, 1]) out.push(tube(0.3, 5.5, 0.4, -1.6, s * 1.3, WHITE), box(0.1, 1.2, 0.1, 0.9, -0.9, s * 1.3, GREY))
+      return out
+    }
+    case 'helicopter':
+      return [
+        blob(2.6, 1.4, 1.3, 0, 0, 0, 0x2f5f9e), tube(0.25, 6, -4.2, 0.4, 0, 0x2f5f9e, 0.15),
+        box(0.1, 0.08, 11, 0, 1.6, 0, DARK), box(11, 0.08, 0.1, 0, 1.6, 0, DARK),
+      ]
+    case 'airliner':
+      return [
+        tube(2, 38, 0, 0, 0, WHITE, 1.4), nose(2, 4, 21, 0, 0, WHITE),
+        wing(36, 6, 0.4, -1, -0.8, 7, WHITE),
+        tube(1.1, 4, 2, -2, 6.5, GREY), tube(1.1, 4, 2, -2, -6.5, GREY),
+        box(4.5, 7, 0.4, -17.5, 4, 0, 0x2f5f9e), wing(12, 3, 0.3, -18, 1.2, 2, WHITE),
+      ]
+    case 'airship':
+    case 'giant-airship': {
+      // The giants are the same shape, three and a half times over: 420 m, for air a
+      // twentieth as dense near the axis (D-085).
+      const k = kind === 'giant-airship' ? 3.5 : 1
+      const hull = kind === 'giant-airship' ? 0xb9b3a4 : 0xc9ccd2
+      const out: Part[] = [blob(60 * k, 14 * k, 14 * k, 0, 0, 0, hull), box(16 * k, 4 * k, 5 * k, 6 * k, -15 * k, 0, GREY)]
+      for (const r of [0, Math.PI / 2, Math.PI, (3 * Math.PI) / 2]) {
+        const g = new BoxGeometry(14 * k, 0.6 * k, 12 * k).translate(-48 * k, 0, 16 * k).rotateX(r)
+        out.push({ g, colour: 0x7a1f1f })
+      }
+      return out
+    }
+    case 'locomotive':
+      return [box(22, 3.9, 3.1, 0, 2.3, 0, 0x7a2320), box(3, 1, 2.6, 9, 4.5, 0, DARK)]
+    case 'carriage':
+      return [box(25, 3.8, 3, 0, 2.3, 0, 0x2e4a6b), box(24.6, 0.35, 3.1, 0, 4.3, 0, GREY)]
+    case 'train':
+    case 'ship':
+      // Drawn car by car (above), and by the fleet: never asked for whole.
+      return []
+    case 'spacecraft':
+      return [
+        tube(7, 60, 0, 0, 0, 0xb3b8c2), nose(7, 12, 36, 0, 0, 0xd6d8dc),
+        nose(9, 10, -34, 0, 0, 0x3a3d44), blob(4, 4, 4, 30, 5, 0, 0x5e7690),
+        box(28, 0.6, 46, -8, 0, 0, 0x6b5a2e), box(12, 2.5, 2.5, 10, 7, 0, GREY),
+      ]
+  }
+}
+
+/** A craft, built fresh: a group of meshes in metres. */
+export function buildCraft(kind: CraftKind, material: MaterialFor): Group {
+  const group = new Group()
+  group.name = kind
+  const mats = new Map<number, Material>()
+  for (const p of parts(kind)) {
+    let m = mats.get(p.colour)
+    if (!m) mats.set(p.colour, (m = material(p.colour)))
+    const mesh = new Mesh(p.g, m)
+    mesh.frustumCulled = false
+    group.add(mesh)
+  }
+  return group
+}
+
+/**
+ * A pool of craft, placed each frame from the tour's list. Positions arrive in metres in
+ * the tower's frame and are drawn in the view's kilometres, `metresPerUnit` to the unit.
+ */
+export class CraftView {
+  readonly group = new Group()
+  private pool = new Map<CraftKind, Group[]>()
+  private basis = new Matrix4()
+  private fwd = new Vector3()
+  private up = new Vector3()
+  private side = new Vector3()
+
+  constructor(private material: MaterialFor, private metresPerUnit = 1000) {
+    this.group.name = 'craft'
+  }
+
+  show(crafts: readonly { kind: CraftKind; pos: { x: number; y: number; z: number }; fwd: { x: number; y: number; z: number }; up: { x: number; y: number; z: number } }[]): void {
+    for (const list of this.pool.values()) for (const g of list) g.visible = false
+    const used = new Map<CraftKind, number>()
+    for (const c of crafts) {
+      const n = used.get(c.kind) ?? 0
+      used.set(c.kind, n + 1)
+      let list = this.pool.get(c.kind)
+      if (!list) this.pool.set(c.kind, (list = []))
+      let g = list[n]
+      if (!g) {
+        g = buildCraft(c.kind, this.material)
+        list.push(g)
+        this.group.add(g)
+      }
+      const k = this.metresPerUnit
+      this.fwd.set(c.fwd.x, c.fwd.y, c.fwd.z).normalize()
+      this.up.set(c.up.x, c.up.y, c.up.z).normalize()
+      this.side.crossVectors(this.fwd, this.up).normalize()
+      this.up.crossVectors(this.side, this.fwd)
+      this.basis.makeBasis(this.fwd, this.up, this.side)
+      g.quaternion.setFromRotationMatrix(this.basis)
+      g.position.set(c.pos.x / k, c.pos.y / k, c.pos.z / k)
+      g.scale.setScalar(1 / k)
+      g.visible = true
+    }
+  }
+
+  clear(): void {
+    for (const list of this.pool.values()) for (const g of list) g.visible = false
+  }
+}
diff --git a/src/views/exterior.ts b/src/views/exterior.ts
index 2c0042b..b5f3fcc 100644
--- a/src/views/exterior.ts
+++ b/src/views/exterior.ts
@@ -29,6 +29,10 @@ import { skyAt } from '../world/sky.js'
 import { skyMatrix, starTexture } from './stars.js'
 import { ROCK_COVER, buildRockHull, rockCover } from './rock-hull.js'
 import { type PipeHull, buildPipeHull } from './pipe-hull.js'
+import { CraftView } from './craft.js'
+import { TourPlayer } from './tour-player.js'
+import { type ExtLeg, HULL_R, SUN_DRAWN_R, SUN_Z, planExterior } from '../tour/exterior-legs.js'
+import { legSeconds, tourRng } from '../tour/plan.js'
 
 const KM = 1000
 const R = RADIUS_M / KM
@@ -167,6 +171,14 @@ export class ExteriorView {
   /** Screen-space captions, so nothing in the scene is left unexplained. */
   private labels: { at: Vector3; el: HTMLSpanElement }[] = []
 
+  /** The scenic tour outside (tour/exterior-legs.ts), while one is playing. */
+  private tour: TourPlayer<ExtLeg> | null = null
+  private player: TourPlayer<ExtLeg>
+  /** Where the camera was, and the clock, when the tour took them: given back when it stops. */
+  private tourSaved: { pos: Vector3; target: Vector3; hours: number; running: boolean } | null = null
+  /** The tour's spacecraft. */
+  private crafts = new CraftView((c) => new MeshStandardMaterial({ color: c, roughness: 0.55, metalness: 0.35 }))
+
   constructor(
     private canvas: HTMLCanvasElement,
     private cb: ExteriorCallbacks,
@@ -206,6 +218,8 @@ export class ExteriorView {
     this.buildLights()
     this.restoreCamera()
     this.buildLabels()
+    this.scene.add(this.crafts.group)
+    this.player = new TourPlayer<ExtLeg>(canvas.parentElement)
     this.attach()
     this.resize()
     /**
@@ -217,6 +231,8 @@ export class ExteriorView {
      * why this only appeared once the view began persisting across reloads.
      */
     new ResizeObserver(() => this.resize()).observe(canvas)
+    // The screenshot tour's handle on the tour outside, beside the interior's `tower`.
+    ;(window as unknown as Record<string, unknown>)['towerOutside'] = this
   }
 
   // ------------------------------------------------------------------ geometry
@@ -548,7 +564,8 @@ export class ExteriorView {
     const sun = new Mesh(
       new SphereGeometry(300, 24, 24),
       new MeshBasicMaterial({ color: 0xfff7e6, side: BackSide }))
-    sun.position.z = -14_000
+    sun.position.z = SUN_Z
+    sun.scale.setScalar(SUN_DRAWN_R / 300)
     this.scene.add(sun)
     const c = document.createElement('canvas')
     c.width = c.height = 128
@@ -564,7 +581,7 @@ export class ExteriorView {
       map: new CanvasTexture(c), blending: AdditiveBlending, depthWrite: false,
     }))
     halo.scale.setScalar(2400)
-    halo.position.z = -14_000
+    halo.position.z = SUN_Z
     this.scene.add(halo)
   }
 
@@ -690,7 +707,18 @@ export class ExteriorView {
     })
     window.addEventListener('pagehide', () => this.saveCamera())
 
-    this.canvas.addEventListener('pointerdown', () => { this.moved = false })
+    this.canvas.addEventListener('pointerdown', () => {
+      this.moved = false
+      // Taking hold of the view takes it back from the tour.
+      if (this.tour && !this.tour.frozen) this.stopTour()
+    })
+    this.canvas.addEventListener('wheel', () => { if (this.tour && !this.tour.frozen) this.stopTour() }, { passive: true })
+    window.addEventListener('keydown', (e) => {
+      if (!this.running || !this.tour || this.tour.frozen || e.metaKey || e.ctrlKey || e.altKey) return
+      if (e.target instanceof HTMLInputElement || e.target instanceof HTMLTextAreaElement || e.target instanceof HTMLSelectElement) return
+      if (e.code === 'Escape') { this.stopTour(); e.preventDefault() }
+      if (e.code === 'KeyN') { this.tour.next(); e.preventDefault() }
+    })
     this.canvas.addEventListener('pointermove', () => { this.moved = true })
     this.canvas.addEventListener('pointerup', (e) => {
       if (this.moved) return
@@ -758,11 +786,14 @@ export class ExteriorView {
       if (!this.running) return
       const dt = Math.min(0.1, (now - this.lastFrame) / 1000)
       this.lastFrame = now
-      if (this.spinning) this.phase = (this.phase + OMEGA_RAD_S * dt * this.timeScale) % (2 * Math.PI)
+      // On the tour the hull turns at the leg's own rate — real time, unless it says otherwise.
+      const spin = this.tour ? (this.tour.frozen ? 0 : this.tour.current?.rate ?? 1) : this.spinning ? this.timeScale : 0
+      this.phase = (this.phase + OMEGA_RAD_S * dt * spin) % (2 * Math.PI)
       this.tower.rotation.z = this.phase
       if (this.pipes?.group.visible) this.pipes.update(now / 1000)
       if (this.mixed?.pipes.group.visible) this.mixed.pipes.update(now / 1000)
-      this.controls.update()
+      if (this.tour) this.stepTour(dt)
+      else this.controls.update()
       /**
        * The interior model's shaders integrate haze in the tower's own frame, and
        * the tower is turning. So they are handed the world-to-tower transform and
@@ -785,6 +816,122 @@ export class ExteriorView {
     this.running = false
   }
 
+  // --------------------------------------------------------------------- tour
+
+  private planTour(seed: number): ExtLeg[] {
+    const world = terrain().world
+    if (!world) return []
+    const rng = tourRng(seed)
+    return planExterior({ world }, rng, () => legSeconds(rng()))
+  }
+
+  get touring(): boolean {
+    return this.tour !== null && !this.tour.frozen
+  }
+
+  get sceneIds(): string[] {
+    return this.planTour(1).map((l) => l.id)
+  }
+
+  /** Start the tour outside: spacecraft, dealt at random, every twelve to eighteen seconds. */
+  startTour(fromId?: string): boolean {
+    const seed = Math.floor(Math.random() * 2 ** 32)
+    const legs = this.planTour(seed)
+    if (!legs.length) return false
+    this.takeCamera()
+    this.player.start(legs, tourRng(seed ^ 0x5eed), fromId)
+    this.tour = this.player
+    return true
+  }
+
+  stopTour(): void {
+    const tour = this.tour
+    if (!tour) return
+    this.tour = null
+    tour.stop()
+    this.crafts.clear()
+    const saved = this.tourSaved
+    this.tourSaved = null
+    this.camera.near = 2
+    this.camera.fov = 42
+    this.camera.up.set(0, 1, 0)
+    if (saved) {
+      this.camera.position.copy(saved.pos)
+      this.controls.target.copy(saved.target)
+      clock.set(saved.hours)
+      clock.setRunning(saved.running)
+    }
+    this.camera.updateProjectionMatrix()
+    this.controls.enabled = true
+    this.controls.update()
+  }
+
+  /** Hold one leg at one moment, for the screenshot tour. */
+  showScene(id: string, t: number): string | null {
+    const legs = this.planTour(1)
+    const leg = legs.find((l) => l.id === id)
+    if (!leg) return null
+    this.takeCamera()
+    this.player.hold(legs, leg, t)
+    this.tour = this.player
+    this.applyLeg(leg, this.player.t)
+    return leg.title
+  }
+
+  private takeCamera(): void {
+    this.tourSaved ??= {
+      pos: this.camera.position.clone(), target: this.controls.target.clone(),
+      hours: clock.hours, running: clock.running,
+    }
+    clock.setRunning(false)
+    this.controls.enabled = false
+  }
+
+  private stepTour(dt: number): void {
+    const tour = this.tour!
+    const show = tour.step(dt, true)
+    const leg = tour.current
+    if (!leg) { this.stopTour(); return }
+    if (show) this.applyLeg(leg, Math.min(tour.t, leg.durationS))
+  }
+
+  /**
+   * Put the camera and the leg's craft where the leg has them at `t`. A leg in the tower's
+   * frame turns with the hull, so its points are carried through the hull's transform.
+   */
+  private applyLeg(leg: ExtLeg, t: number): void {
+    clock.set(leg.hours + (t * leg.rate) / 3600)
+    this.tower.updateMatrixWorld()
+    const m = leg.frame === 'tower' ? this.tower.matrixWorld : new Matrix4()
+    const rot = new Matrix3().setFromMatrix4(m)
+    const p = leg.pose(t)
+    const eye = new Vector3(p.eye.x, p.eye.y, p.eye.z).applyMatrix4(m)
+    const look = new Vector3(p.look.x, p.look.y, p.look.z).applyMatrix4(m)
+    this.camera.position.copy(eye)
+    this.camera.up.set(p.up.x, p.up.y, p.up.z).applyMatrix3(rot).normalize()
+    this.camera.lookAt(look)
+    this.camera.fov = p.fovDeg
+    const crafts = leg.craft(t).map((c) => {
+      const pos = new Vector3(c.pos.x, c.pos.y, c.pos.z).applyMatrix4(m).multiplyScalar(KM)
+      const fwd = new Vector3(c.fwd.x, c.fwd.y, c.fwd.z).applyMatrix3(rot)
+      const up = new Vector3(c.up.x, c.up.y, c.up.z).applyMatrix3(rot)
+      return { kind: 'spacecraft' as const, pos, fwd, up }
+    })
+    this.crafts.show(crafts)
+    /**
+     * The near plane follows whatever is closest — the hull, or the craft being chased — so a
+     * ship 400 m off is not clipped, and the depth buffer is not spent on nothing when the
+     * camera is a thousand kilometres out.
+     */
+    const local = eye.clone().applyMatrix4(this.tower.matrixWorld.clone().invert())
+    const rad = Math.hypot(local.x, local.y)
+    let nearest = local.z >= 0 && local.z <= L ? Math.abs(rad - HULL_R)
+      : Math.hypot(Math.max(0, rad - HULL_R), local.z < 0 ? -local.z : local.z - L)
+    for (const c of crafts) nearest = Math.min(nearest, c.pos.clone().divideScalar(KM).distanceTo(eye) - 0.05)
+    this.camera.near = Math.min(2, Math.max(0.01, nearest * 0.3))
+    this.camera.updateProjectionMatrix()
+  }
+
   /**
    * The same sky the interior lights itself by (sky.ts, D-074): the sun where the clock
    * has it, seen in through the windows, and the rod lit from the sun cap to it. The rays
diff --git a/src/views/interior-model.ts b/src/views/interior-model.ts
index 79048dd..0c469ee 100644
--- a/src/views/interior-model.ts
+++ b/src/views/interior-model.ts
@@ -32,6 +32,7 @@ import { ForestView } from './forest-view.js'
 import { BODY_STRIDE, MAX_BODIES, type SeaState, seaStateTable } from '../terrain/sea-state.js'
 import { WAVE_AMP, WAVE_LEN, WAVE_PHASE, WAVE_TURN, boomAngle, shipsAt, waveAt } from '../terrain/ships.js'
 import { terrain } from '../terrain/service.js'
+import type { RailLine } from '../terrain/rails.js'
 import { MIRROR_UV_RADIUS, capGlazingTexture, sunCapGlazingTexture } from './cap-pattern.js'
 import { TerrainLOD, type TerrainLODOptions } from './terrain-lod.js'
 import { skyMatrix, starTexture } from './stars.js'
@@ -842,6 +843,29 @@ void main() {
 }
 `
 
+/**
+ * The ships' shader for things that are not instanced: the tour's craft (craft.ts) and the
+ * railways. Lit from the sun and the rod, and hazed, like everything else in here.
+ */
+const PLAIN_VERT = /* glsl */`
+#include <common>
+#include <logdepthbuf_pars_vertex>
+uniform mat4 uFrame;
+varying vec3 vWorld;
+varying vec3 vN;
+void main() {
+  vWorld = (uFrame * modelMatrix * vec4(position, 1.0)).xyz;
+  vN = mat3(uFrame * modelMatrix) * normal;
+  gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0);
+  #include <logdepthbuf_vertex>
+}
+`
+
+/** Metres between a railway's piers, where its deck stands clear of the ground. */
+const PIER_EVERY_M = 100
+/** Samples of a railway per mesh: meshes with their own origins keep float32 precise. */
+const RAIL_CHUNK = 100
+
 /**
  * A hull one unit long, bow toward +x, deck up +y: pointed at the bow, a transom at the
  * stern, sides tumbling in to a narrow keel, a sheer rising to the bow, and a deckhouse
@@ -1340,6 +1364,8 @@ export class InteriorModel {
   readonly hulls: InstancedMesh
   readonly sails: InstancedMesh
   readonly jibs: InstancedMesh
+  /** The railways (terrain/rails.ts), set by the interior view: the exterior does not draw them. */
+  readonly rails = new Group()
 
   constructor(private opts: InteriorModelOptions) {
     this.groundMat = this.material(GROUND_VERT, GROUND_FRAG, { side: FrontSide, uniforms: {} })
@@ -1376,7 +1402,8 @@ export class InteriorModel {
     this.sails = fleet(sailGeometry(), 0xe6ddc8, 'sails')
     this.jibs = fleet(jibGeometry(), 0xe6ddc8, 'jibs')
     this.sun = this.buildSun()
-    this.meshes = [this.ground, this.falls, this.forest.group, this.hulls, this.sails, this.jibs, ...this.buildCaps(), this.buildBeam(), this.sun]
+    this.rails.name = 'rails'
+    this.meshes = [this.ground, this.falls, this.forest.group, this.hulls, this.sails, this.jibs, this.rails, ...this.buildCaps(), this.buildBeam(), this.sun]
     terrain().onWorld(() => this.buildFalls())
     if (terrain().compiled) this.buildFalls()
   }
@@ -1460,6 +1487,10 @@ export class InteriorModel {
     }
   }
 
+  private railAt = new Vector3()
+  /** How far out railways are drawn, km. */
+  private static readonly RAIL_RANGE_KM = 45
+
   /** How far out ships are drawn, km. Past this a hull is under a pixel and all haze. */
   private static readonly SHIP_RANGE_KM = 40
 
@@ -1535,6 +1566,109 @@ export class InteriorModel {
     // The trees around the eye, rebuilt only when it has moved far enough to matter.
     this.forest.update(eye)
     this.lod.update(eye)
+    // A railway 7 m wide is under a pixel and all haze past a few tens of kilometres: those
+    // chunks are not drawn at all, which keeps some 280 draw calls off every frame.
+    for (const c of this.rails.children) {
+      const m = c as Mesh
+      const b = m.geometry.boundingSphere
+      if (!b) continue
+      this.railAt.copy(b.center).add(m.position)
+      m.visible = this.railAt.distanceTo(eye) - b.radius < InteriorModel.RAIL_RANGE_KM
+    }
+  }
+
+  /**
+   * A lit, hazed material of one colour, for meshes that are neither ground nor water nor
+   * instanced: the scenic tour's craft, and the railways.
+   */
+  plainMaterial(colour: number): ShaderMaterial {
+    const m = this.material(PLAIN_VERT, SHIP_FRAG, { side: DoubleSide, uniforms: { uColour: { value: new Color(colour) } } })
+    // Built mid-session, so take the light of the moment rather than the defaults.
+    const donor = this.groundMat.uniforms
+    for (const key of ['uExposure', 'uStarGain', 'uSpin', 'uTime']) m.uniforms[key]!.value = donor[key]!.value
+    ;(m.uniforms['uHaze']!.value as Color).copy(donor['uHaze']!.value as Color)
+    ;(m.uniforms['uCam']!.value as Vector3).copy(donor['uCam']!.value as Vector3)
+    ;(m.uniforms['uSun']!.value as Vector3).copy(donor['uSun']!.value as Vector3)
+    ;(m.uniforms['uFrame']!.value as Matrix4).copy(donor['uFrame']!.value as Matrix4)
+    return m
+  }
+
+  private railMats: { deck: ShaderMaterial; girder: ShaderMaterial } | null = null
+
+  /**
+   * Lay the railways (D-086): a deck on a girder along each line at the height rails.ts gave
+   * it, and a pier down to the ground every 100 m wherever it stands clear. Rebuilt whole when
+   * the world changes; a few hundred kilometres of box is cheap.
+   */
+  setRails(lines: readonly RailLine[]): void {
+    for (const child of [...this.rails.children]) {
+      ;(child as Mesh).geometry.dispose()
+      this.rails.remove(child)
+    }
+    this.railMats ??= { deck: this.plainMaterial(0x57514a), girder: this.plainMaterial(0x9a958c) }
+    const HALF = 3.5, GIRDER = 1.8, PIER = 1.6
+    for (const r of lines) {
+      const n = r.line.s.length
+      const P = (i: number) => {
+        const k = r.loop ? ((i % (n - 1)) + (n - 1)) % (n - 1) : Math.max(0, Math.min(n - 1, i))
+        const th = r.line.pts[2 * k + 1]! / RADIUS_M, rad = RADIUS_M - r.deckM[k]!
+        return { v: new Vector3(rad * Math.cos(th), rad * Math.sin(th), r.line.pts[2 * k]!), k }
+      }
+      for (let c0 = 0; c0 < n - 1; c0 += RAIL_CHUNK) {
+        const c1 = Math.min(n - 1, c0 + RAIL_CHUNK)
+        const origin = P(c0).v.clone().divideScalar(1000)
+        const deck: number[] = [], girder: number[] = []
+        const at = (v: Vector3) => [v.x / 1000 - origin.x, v.y / 1000 - origin.y, v.z / 1000 - origin.z]
+        const quad = (out: number[], a: Vector3, b: Vector3, c: Vector3, d: Vector3) =>
+          out.push(...at(a), ...at(b), ...at(c), ...at(a), ...at(c), ...at(d))
+        const edge = (i: number) => {
+          const p = P(i).v, fwd = P(i + 1).v.clone().sub(P(i - 1).v).normalize()
+          const up = new Vector3(-p.x, -p.y, 0).normalize()
+          const side = new Vector3().crossVectors(fwd, up).normalize().multiplyScalar(HALF)
+          const down = up.clone().multiplyScalar(-GIRDER)
+          return {
+            tl: p.clone().sub(side), tr: p.clone().add(side),
+            bl: p.clone().sub(side).add(down), br: p.clone().add(side).add(down), p, up,
+          }
+        }
+        let prev = edge(c0)
+        for (let i = c0 + 1; i <= c1; i++) {
+          const e = edge(i)
+          quad(deck, prev.tl, prev.tr, e.tr, e.tl)
+          quad(girder, prev.tl, e.tl, e.bl, prev.bl)
+          quad(girder, prev.tr, prev.br, e.br, e.tr)
+          quad(girder, prev.bl, e.bl, e.br, prev.br)
+          prev = e
+        }
+        // Piers, where the deck stands clear of the ground.
+        const every = Math.round(PIER_EVERY_M / (r.line.s[1]! - r.line.s[0]!)) || 2
+        for (let i = c0 - (c0 % every) + every; i < c1; i += every) {
+          const { k } = P(i)
+          const clear = r.deckM[k]! - r.groundM[k]!
+          if (clear < GIRDER + 2) continue
+          const e = edge(i)
+          const fwd = P(i + 1).v.clone().sub(P(i - 1).v).normalize().multiplyScalar(PIER / 2)
+          const side = new Vector3().crossVectors(fwd, e.up).normalize().multiplyScalar(PIER / 2)
+          const top = e.p.clone().addScaledVector(e.up, -GIRDER)
+          const foot = e.p.clone().addScaledVector(e.up, -(clear + 3))
+          const corners = [fwd.clone().add(side), fwd.clone().sub(side), fwd.clone().negate().sub(side), fwd.clone().negate().add(side)]
+          for (let q = 0; q < 4; q++) {
+            const a = corners[q]!, b = corners[(q + 1) % 4]!
+            quad(girder, top.clone().add(a), top.clone().add(b), foot.clone().add(b), foot.clone().add(a))
+          }
+        }
+        for (const [pos, mat] of [[deck, this.railMats.deck], [girder, this.railMats.girder]] as const) {
+          const g = new BufferGeometry()
+          g.setAttribute('position', new BufferAttribute(new Float32Array(pos), 3))
+          g.computeVertexNormals()
+          g.computeBoundingSphere()
+          const mesh = new Mesh(g, mat)
+          mesh.position.copy(origin)
+          mesh.name = `rail ${r.id}`
+          this.rails.add(mesh)
+        }
+      }
+    }
   }
 
   /** World to tower coordinates. Only the exterior, which turns the hull, needs it. */
diff --git a/src/views/interior.ts b/src/views/interior.ts
index be2e3a5..8bd628c 100644
--- a/src/views/interior.ts
+++ b/src/views/interior.ts
@@ -31,7 +31,12 @@ import { terrain } from '../terrain/service.js'
 import { EYE_MAX_M, EYE_MIN_M } from '../ui/scales.js'
 import { HAZE_DAY, InteriorModel, K_GREEN } from './interior-model.js'
 import { DESKTOP_LOD, PHONE_LOD } from './terrain-lod.js'
-import { type Pose, SCENE_S, type ScenePlan, planTour } from './scenic.js'
+import { type RailLine, railLines } from '../terrain/rails.js'
+import { type Leg, type Pose, planInterior } from '../tour/interior-legs.js'
+import { legSeconds, tourRng } from '../tour/plan.js'
+import type { World } from '../world/schema.js'
+import { CraftView } from './craft.js'
+import { TourPlayer } from './tour-player.js'
 
 const KM = 1000
 const R = RADIUS_M / KM
@@ -111,25 +116,19 @@ interface Probe {
  */
 const FROZEN_TIME_S = 12
 
-/** Seconds a scene change takes to fade out, and in again once the ground has arrived. */
-const TOUR_FADE_S = 0.6
-/** Longest the tour holds black waiting for the ground; past this it shows what there is. */
-const TOUR_WAIT_S = 4
+/** The seed the screenshot tour's held legs are planned with, so the same leg is the same leg. */
+const HELD_SEED = 1
 
-interface TourState {
-  plans: ScenePlan[]
-  index: number
-  /** Seconds into the current scene. */
-  t: number
-  phase: 'out' | 'wait' | 'in' | 'play'
-  /** 0 clear, 1 black. */
-  fade: number
-  waited: number
-  /** Held at one moment, for the screenshot tour: no fades, no time passing. */
-  frozen: boolean
-  /** The clock as it was, given back when the tour stops. */
-  /** The clock as the tour found it, to hand back when it stops. */
-  saved: { hours: number; running: boolean }
+/**
+ * The railways of a world, laid once per world (terrain/rails.ts): some fourteen thousand
+ * samples of the ground, which is a noticeable moment on the main thread and needs doing
+ * only when the world changes.
+ */
+const laid = new WeakMap<World, RailLine[]>()
+function railsFor(world: World): RailLine[] {
+  let r = laid.get(world)
+  if (!r) laid.set(world, (r = railLines(world, (theta, z) => terrain().surfaceM(theta, z))))
+  return r
 }
 
 /**
@@ -222,10 +221,15 @@ export class InteriorView {
   /** Phase of the star pinwheel, radians. The only thing in the view that turns. */
   private spin = 0
   private labels: { place: Feature; el: HTMLSpanElement }[] = []
-  /** The scenic tour, while one is playing (scenic.ts). */
-  private tour: TourState | null = null
-  private tourFade = document.createElement('div')
-  private tourCaption = document.createElement('div')
+  /** The bank, about the view axis: only a flyer on the scenic tour tilts the horizon. */
+  roll = 0
+  /** The scenic tour, while one is playing (tour/). */
+  private tour: TourPlayer<Leg> | null = null
+  private player: TourPlayer<Leg>
+  /** The clock as the tour found it, to hand back when it stops. */
+  private tourSaved: { hours: number; running: boolean } | null = null
+  /** The tour's flyers, drawn while their leg is on (craft.ts). */
+  private crafts: CraftView
 
   constructor(
     private canvas: HTMLCanvasElement,
@@ -248,28 +252,26 @@ export class InteriorView {
     this.shared = this.model.materials
     this.groundMat = this.model.groundMat
     this.scene.add(...this.model.meshes)
+    this.crafts = new CraftView((c) => this.model.plainMaterial(c))
+    this.scene.add(this.crafts.group)
+    this.player = new TourPlayer<Leg>(canvas.parentElement)
+    const layRails = () => { const w = terrain().world; if (w && terrain().compiled) this.model.setRails(railsFor(w)) }
+    layRails()
     // A new world moves the ground under the observer: stand on the new ground, and
     // redraw the elevation readout that was drawn against the old one.
     terrain().onWorld(() => {
-      // The scenes are worked out from the world, so an edit re-plans them.
-      if (this.tour) {
-        const id = this.tour.plans[this.tour.index]?.id
-        this.tour.plans = this.planTour()
-        const i = this.tour.plans.findIndex((p) => p.id === id)
-        this.tour.index = Math.max(0, i)
-        if (!this.tour.plans.length) this.stopTour()
+      layRails()
+      // The legs are worked out from the world, so an edit re-plans them.
+      if (this.tour && !this.tour.frozen) {
+        const legs = this.planTour(Math.random() * 2 ** 32)
+        if (legs.length) this.tour.replan(legs)
+        else this.stopTour()
       }
       this.model.setWeather(this.worldHours)
       this.place()
       this.cb.onLook()
     })
 
-    this.tourFade.className = 'tour-fade'
-    this.tourFade.hidden = true
-    this.tourCaption.className = 'tour-caption'
-    this.tourCaption.hidden = true
-    canvas.parentElement?.append(this.tourFade, this.tourCaption)
-
     this.restoreState()
     this.applyLighting()
     this.attach()
@@ -363,6 +365,7 @@ export class InteriorView {
     this.camera.position.copy(eye)
     this.camera.up.copy(up)
     this.camera.lookAt(eye.clone().add(dir))
+    if (this.roll) this.camera.rotateZ(this.roll)
     this.camera.fov = this.fovDeg
     this.camera.updateProjectionMatrix()
 
@@ -959,6 +962,7 @@ export class InteriorView {
       }
       if (this.tour) this.stepTour(dt)
       else this.fly(dt)
+      if (!this.tour) this.crafts.clear()
       this.model.setShips(this.worldHours, this.time, this.camera.position)
       // Also what refines the ground around the eye, so it runs whether or not the
       // observer moved: tiles keep arriving while they stand still.
@@ -1006,56 +1010,61 @@ export class InteriorView {
 
   // --------------------------------------------------------------------- tour
 
-  private planTour(): ScenePlan[] {
+  /** Every leg the world has, planned from `seed`: each its own length, and its own luck. */
+  private planTour(seed: number): Leg[] {
     const t = terrain()
     if (!t.world || !t.compiled) return []
-    return planTour({ world: t.world, compiled: t.compiled, ground: (theta, z) => t.surfaceM(theta, z) })
+    const rng = tourRng(seed)
+    return planInterior({
+      world: t.world, compiled: t.compiled, ground: (theta, z) => t.surfaceM(theta, z), rails: railsFor(t.world),
+    }, rng, () => legSeconds(rng()))
   }
 
   get touring(): boolean {
     return this.tour !== null && !this.tour.frozen
   }
 
-  /** The scenes the world has, in order: what the screenshot tour and the panel can ask for. */
+  /** The legs the world has: what the screenshot tour and the panel can ask for. */
   get sceneIds(): string[] {
-    return this.planTour().map((p) => p.id)
+    return this.planTour(HELD_SEED).map((l) => l.id)
   }
 
   /**
-   * Start the scenic tour: a scene every thirty seconds, fading through black between
-   * them, and round again. Any look, drag, wheel or flight key hands the view back.
+   * Start the scenic tour: a new flyer every twelve to eighteen seconds, dealt at random,
+   * fading through black between them. Any look, drag, wheel or flight key hands the view
+   * back where the tour left it.
    */
   startTour(fromId?: string): boolean {
-    const plans = this.planTour()
-    if (!plans.length) return false
-    const saved = this.tour?.saved ?? { hours: clock.hours, running: clock.running }
-    this.tour = {
-      plans, index: Math.max(0, plans.findIndex((p) => p.id === fromId)), t: 0,
-      phase: 'out', fade: 0, waited: 0, frozen: false, saved,
-    }
-    // Each scene sets its own time; a running clock would fight it.
+    const seed = Math.floor(Math.random() * 2 ** 32)
+    const legs = this.planTour(seed)
+    if (!legs.length) return false
+    this.tourSaved ??= { hours: clock.hours, running: clock.running }
+    this.player.start(legs, tourRng(seed ^ 0x5eed), fromId)
+    this.tour = this.player
+    // Each leg sets its own time; a running clock would fight it.
     clock.setRunning(false)
     this.cb.onLook()
     return true
   }
 
   nextScene(): void {
-    const tour = this.tour
-    if (!tour || tour.frozen) return
-    if (tour.phase === 'play' || tour.phase === 'in') tour.phase = 'out'
+    this.tour?.next()
   }
 
   stopTour(): void {
     const tour = this.tour
     if (!tour) return
     this.tour = null
-    this.tourFade.hidden = true
-    this.tourCaption.hidden = true
-    // The tour frames its own shots; the reader's field is the fixed one.
+    tour.stop()
+    this.crafts.clear()
+    // The tour frames its own shots; the reader's field is the fixed one, and level.
     this.fovDeg = FIELD_DEG
+    this.roll = 0
     // The clock goes back to where it was; the observer stays where the tour left them.
-    clock.set(tour.saved.hours)
-    clock.setRunning(tour.saved.running)
+    const saved = this.tourSaved ?? { hours: clock.hours, running: clock.running }
+    this.tourSaved = null
+    clock.set(saved.hours)
+    clock.setRunning(saved.running)
     this.model.setWeather(this.worldHours)
     this.applyLighting()
     this.place()
@@ -1064,69 +1073,42 @@ export class InteriorView {
   }
 
   /**
-   * Hold one scene at one moment: the screenshot tour's way in. Returns the scene's
-   * title, or null if the world has no such scene.
+   * Hold one leg at one moment: the screenshot tour's way in. Returns the leg's title, or
+   * null if the world has no such leg. Held legs are planned from a fixed seed, so a leg is
+   * the same flight every time it is photographed.
    */
   showScene(id: string, t: number): string | null {
-    const plans = this.planTour()
-    const index = plans.findIndex((p) => p.id === id)
-    if (index < 0) return null
-    const saved = this.tour?.saved ?? { hours: clock.hours, running: clock.running }
-    this.tour = { plans, index, t, phase: 'play', fade: 0, waited: 0, frozen: true, saved }
+    const legs = this.planTour(HELD_SEED)
+    const leg = legs.find((l) => l.id === id)
+    if (!leg) return null
+    this.tourSaved ??= { hours: clock.hours, running: clock.running }
+    this.player.hold(legs, leg, t)
+    this.tour = this.player
     clock.setRunning(false)
-    this.applyScene(plans[index]!, t)
-    this.tourFade.hidden = true
-    this.tourCaption.hidden = true
+    this.applyScene(leg, this.player.t)
     this.cb.onLook()
-    return plans[index]!.title
+    return leg.title
   }
 
   private stepTour(dt: number): void {
     const tour = this.tour!
-    const plan = tour.plans[tour.index]
-    if (!plan) { this.stopTour(); return }
-    if (tour.frozen) { this.applyScene(plan, tour.t); return }
-
-    if (tour.phase === 'out') {
-      tour.fade = Math.min(1, tour.fade + dt / TOUR_FADE_S)
-      if (tour.t > 0) tour.t += dt
-      if (tour.fade >= 1) {
-        // A fresh start fades out of the observer's own view into the first scene.
-        if (tour.t > 0) tour.index = (tour.index + 1) % tour.plans.length
-        tour.t = 0
-        tour.waited = 0
-        tour.phase = 'wait'
-      }
-    } else if (tour.phase === 'wait') {
-      tour.waited += dt
-      if ((this.settled && tour.waited > 0.3) || tour.waited > TOUR_WAIT_S) tour.phase = 'in'
-    } else {
-      tour.t += dt
-      if (tour.phase === 'in') {
-        tour.fade = Math.max(0, tour.fade - dt / TOUR_FADE_S)
-        if (tour.fade <= 0) tour.phase = 'play'
-      }
-      if (tour.t >= SCENE_S) tour.phase = 'out'
-    }
-
-    const current = tour.plans[tour.index]!
-    if (tour.phase !== 'out' || tour.t > 0) this.applyScene(current, tour.t)
-    this.tourFade.hidden = tour.fade <= 0
-    this.tourFade.style.opacity = String(tour.fade)
-    this.tourCaption.hidden = false
+    const show = tour.step(dt, this.settled, (l, t) => l.lookingAt(t))
+    const leg = tour.current
+    if (!leg) { this.stopTour(); return }
+    if (show) this.applyScene(leg, Math.min(tour.t, leg.durationS))
     // The readout follows the observer, a few times a second.
     if (this.frames % 15 === 0) this.cb.onLook()
-    const caption = `${current.title}\n${tour.index + 1} of ${tour.plans.length} · N next · Esc stops`
-    if (this.tourCaption.textContent !== caption) this.tourCaption.textContent = caption
   }
 
-  /** Put the observer and the clock where a scene has them at `t` seconds. */
-  private applyScene(plan: ScenePlan, t: number): void {
-    const hours = plan.hours + t / 3600
+  /** Put the observer, the clock and the leg's craft where the leg has them at `t` seconds. */
+  private applyScene(leg: Leg, t: number): void {
+    const hours = leg.hours + (t * leg.rate) / 3600
     clock.set(hours)
-    this.model.setWeather(hours)
+    // The sea state changes over hours, so a few times a second is plenty, as outside the tour.
+    if (this.tour?.frozen || this.frames % 10 === 0) this.model.setWeather(hours)
     this.applyLighting()
-    this.applyPose(plan.pose(t))
+    this.applyPose(leg.pose(t))
+    this.crafts.show(leg.craft(t))
   }
 
   private applyPose(p: Pose): void {
@@ -1134,6 +1116,7 @@ export class InteriorView {
     this.eyeM = Math.max(EYE_MIN_M, Math.min(EYE_MAX_M, p.altM - this.elevationM))
     this.yaw = normalizeTheta(p.yaw)
     this.pitch = Math.max(-1.55, Math.min(1.55, p.pitch))
+    this.roll = p.roll
     this.fovDeg = p.fovDeg
     this.place()
   }
diff --git a/src/views/map.ts b/src/views/map.ts
index d6ac0bf..7d352f7 100644
--- a/src/views/map.ts
+++ b/src/views/map.ts
@@ -455,7 +455,7 @@ export class TowerMap {
   /** Rank for label priority: settlements and landmarks beat broad regions. */
   private static readonly LABEL_RANK: Record<string, number> = {
     settlement: 0, tower: 1, embassy: 3, landmark: 4, mountains: 5,
-    river: 7, lake: 8, sea: 8, note: 9, road: 10, path: 10, forest: 10, desert: 11, region: 12,
+    river: 7, lake: 8, sea: 8, note: 9, road: 10, path: 10, railway: 10, forest: 10, desert: 11, region: 12,
   }
 
   private drawPlaces(): void {
diff --git a/src/views/palette.ts b/src/views/palette.ts
index 14f0df2..890a84d 100644
--- a/src/views/palette.ts
+++ b/src/views/palette.ts
@@ -5,6 +5,7 @@ export const KIND_COLOR: Record<FeatureKind, string> = {
   settlement: '#e8c46a',
   road: '#d8b25e',
   path: '#b89a6a',
+  railway: '#c7c3d6',
   embassy: '#d98cc8',
   tower: '#f0a0a0',
   landmark: '#e8815a',
diff --git a/src/views/scenic.ts b/src/views/scenic.ts
deleted file mode 100644
index 4376f96..0000000
--- a/src/views/scenic.ts
+++ /dev/null
@@ -1,514 +0,0 @@
-/**
- * The scenic tour: camera moves over the places worth seeing. See world-generation.md §19.
- *
- * Each scene is thirty seconds of one move — a bird flying down a river, a helicopter
- * circling a waterfall or a summit, a chase camera on a ship — worked out from the
- * compiled world, so it follows the world as it is edited rather than a list of
- * coordinates. A scene also sets the clock, because what is there depends on it: the fleet
- * is in harbour for a published storm, and at sea for one Tower gave no warning of.
- *
- * Pure functions, no Three.js: the interior view plays the scenes, the screenshot tour
- * photographs them at a fixed moment, and the tests fly every one over the real world.
- */
-import { CIRCUMFERENCE_M, LENGTH_M, RADIUS_M } from '../canon/constants.js'
-import type { CFall, CompiledWorld } from '../terrain/compile.js'
-import type { CDesert } from '../terrain/desert.js'
-import { type Line, nearestA, smoothLine } from '../terrain/shapes.js'
-import { forEachTree, type Tree } from '../terrain/forest.js'
-import { lanesOf, shipAt, shipsAt, type ShipLane } from '../terrain/ships.js'
-import { type Feature, type Sea, type World, anchorOf } from '../world/schema.js'
-import { HARBOUR_LEAD_H, occurrences } from '../world/weather.js'
-import { hourWhenSunAt } from '../world/sky.js'
-
-/** How long each scene runs, seconds. */
-export const SCENE_S = 30
-
-/** Where the camera is and where it looks. `altM` is the eye's elevation above the datum. */
-export interface Pose {
-  theta: number
-  z: number
-  altM: number
-  /** Radians from deepward toward spinward, as the interior view's heading. */
-  yaw: number
-  pitch: number
-  fovDeg: number
-}
-
-export interface SceneWorld {
-  world: World
-  compiled: CompiledWorld
-  /** Height of the ground or the water standing on it, metres. */
-  ground: (theta: number, z: number) => number
-}
-
-export interface ScenePlan {
-  id: string
-  title: string
-  /** The clock at the start of the scene, hours since day 0. It runs at real time from there. */
-  hours: number
-  pose: (t: number) => Pose
-}
-
-export interface Scene {
-  id: string
-  prepare: (w: SceneWorld) => ScenePlan | null
-}
-
-/**
- * Noon on day 0: a calm, published-nothing day, for scenes with no reason to pick another.
- * Such a scene is not shown at noon, though, but in the morning where it is (planTour).
- */
-const NOON = 12
-
-/**
- * The hour on day 0 when the sun is 60 km sunward of `z` and coming: about 40° up, which
- * is high enough to be full day and low enough for the ground to have relief (D-074).
- * Kept clear of the caps, where the sun is still lighting or already going out.
- */
-function morningOver(w: SceneWorld, z: number): number {
-  const sunZ = Math.min(LENGTH_M - 25_000, Math.max(25_000, z - 60_000))
-  return hourWhenSunAt(w.world.calendar, 0, sunZ)
-}
-
-// ------------------------------------------------------------------ geometry
-
-interface At { z: number; a: number; altM: number }
-
-/**
- * Heading and pitch from one point to another, on the curved floor. The floor curves up
- * around the axis, so a summit 6 km spinward stands about 360 m higher in the eye's frame
- * than its elevation says, and a pitch taken on a flat floor would miss it.
- */
-export function lookAt(from: At, to: At): { yaw: number; pitch: number } {
-  const phi = (nearestA(to.a, from.a) - from.a) / RADIUS_M
-  const rc = RADIUS_M - from.altM, rt = RADIUS_M - to.altM
-  const across = rt * Math.sin(phi)
-  const up = rc - rt * Math.cos(phi)
-  const along = to.z - from.z
-  return { yaw: Math.atan2(across, along), pitch: Math.atan2(up, Math.hypot(along, across)) }
-}
-
-const wrapA = (a: number): number => ((a % CIRCUMFERENCE_M) + CIRCUMFERENCE_M) % CIRCUMFERENCE_M
-const pose = (at: At, look: { yaw: number; pitch: number }, fovDeg: number): Pose => ({
-  theta: wrapA(at.a) / RADIUS_M, z: at.z, altM: at.altM, yaw: look.yaw, pitch: look.pitch, fovDeg,
-})
-
-/** A point `s` metres along a line, clamped to its ends. */
-function along(line: Line, s: number): { z: number; a: number } {
-  const n = line.s.length
-  if (s <= 0) return { z: line.pts[0]!, a: line.pts[1]! }
-  if (s >= line.s[n - 1]!) return { z: line.pts[2 * n - 2]!, a: line.pts[2 * n - 1]! }
-  let lo = 0, hi = n - 1
-  while (hi - lo > 1) {
-    const mid = (lo + hi) >> 1
-    if (line.s[mid]! <= s) lo = mid
-    else hi = mid
-  }
-  const t = (s - line.s[lo]!) / (line.s[hi]! - line.s[lo]! || 1)
-  return {
-    z: line.pts[2 * lo]! + (line.pts[2 * hi]! - line.pts[2 * lo]!) * t,
-    a: line.pts[2 * lo + 1]! + (line.pts[2 * hi + 1]! - line.pts[2 * lo + 1]!) * t,
-  }
-}
-
-/** A straight line from (z, a), heading `dirRad` (deepward toward spinward), `length` metres. */
-function straight(z: number, a: number, dirRad: number, length: number): Line {
-  const pts: number[] = [], s: number[] = []
-  for (let d = 0; d <= length; d += 50) {
-    pts.push(z + Math.cos(dirRad) * d, a + Math.sin(dirRad) * d)
-    s.push(d)
-  }
-  return { pts, s, box: { z0: 0, z1: 0, a0: 0, a1: 0 } }
-}
-
-/**
- * Flying along a line: steady speed, a clearance over the ground that does not bob with
- * every hill — the highest ground within 700 m either side, smoothed — and the eye on a
- * point further along, at the ground there, so the view dips as the land falls away.
- */
-function flight(w: SceneWorld, line: Line, from: number, speed: number, clearanceM: number,
-  lookAheadM: number, fovDeg: number): (t: number) => Pose {
-  const to = from + speed * SCENE_S + lookAheadM
-  const STEP = 50
-  const n = Math.ceil((to - from + 2000) / STEP) + 1
-  const raw = new Float64Array(n)
-  for (let i = 0; i < n; i++) {
-    const p = along(line, from - 1000 + i * STEP)
-    raw[i] = w.ground(wrapA(p.a) / RADIUS_M, p.z)
-  }
-  const reach = (arr: Float64Array, r: number, pick: (xs: number[]) => number) =>
-    Float64Array.from(arr, (_, i) => pick(Array.from(arr.subarray(Math.max(0, i - r), Math.min(n, i + r + 1)))))
-  const high = reach(raw, 14, (xs) => Math.max(...xs))
-  const smooth = reach(high, 8, (xs) => xs.reduce((x, y) => x + y, 0) / xs.length)
-  const at = (arr: Float64Array, s: number): number => {
-    const x = Math.min(n - 1, Math.max(0, (s - from + 1000) / STEP))
-    const i = Math.min(n - 2, Math.floor(x))
-    return arr[i]! + (arr[i + 1]! - arr[i]!) * (x - i)
-  }
-  return (t) => {
-    const s = from + speed * t
-    const p = along(line, s)
-    const cam = { ...p, altM: at(smooth, s) + clearanceM }
-    const q = along(line, s + lookAheadM)
-    return pose(cam, lookAt(cam, { ...q, altM: at(raw, s + lookAheadM) }), fovDeg)
-  }
-}
-
-/**
- * A helicopter's arc round a point: `radius` out, from `startRad` (the direction from the
- * target to the camera) sweeping `sweepRad`, never lower than `minClearM` over the ground.
- */
-function orbit(w: SceneWorld, target: At, radius: number, altM: number, startRad: number,
-  sweepRad: number, minClearM: number, fovDeg: number): (t: number) => Pose {
-  return orbitLooking(w, target, target, radius, altM, startRad, sweepRad, minClearM, fovDeg)
-}
-
-/**
- * The same arc, but circling one point and looking at another: for keeping the camera over
- * open ground while it watches something that has no room to be circled — a mesa field the
- * camera would otherwise be flying *over*.
- */
-function orbitLooking(w: SceneWorld, centre: At, target: At, radius: number, altM: number,
-  startRad: number, sweepRad: number, minClearM: number, fovDeg: number): (t: number) => Pose {
-  return (t) => {
-    const ang = startRad + (sweepRad * t) / SCENE_S
-    const z = centre.z + Math.cos(ang) * radius, a = centre.a + Math.sin(ang) * radius
-    const cam = { z, a, altM: Math.max(altM, w.ground(wrapA(a) / RADIUS_M, z) + minClearM) }
-    return pose(cam, lookAt(cam, target), fovDeg)
-  }
-}
-
-/**
- * A chase camera on a ship: behind it and a little to one side, above the water, drifting
- * slowly round so the ship is not a still picture. The ship heaves; the camera does not.
- */
-function chase(w: SceneWorld, lane: ShipLane, h0: number, fovDeg: number): (t: number) => Pose {
-  const level = w.compiled.basins.find((b) => b.id === lane.sea)?.levelM ?? 0
-  const scale = Math.max(1, lane.lengthM / 20)
-  return (t) => {
-    const fix = shipAt(lane, h0 + t / 3600)
-    const back = (70 + 20 * Math.sin(t / 9)) * scale
-    const side = (25 + 35 * t / SCENE_S) * scale
-    const hz = Math.cos(fix.headingRad), ha = Math.sin(fix.headingRad)
-    const cam = { z: fix.z - hz * back - ha * side, a: fix.a - ha * back + hz * side, altM: level + 14 * scale }
-    return pose(cam, lookAt(cam, { z: fix.z, a: fix.a, altM: level + lane.lengthM * 0.25 }), fovDeg)
-  }
-}
-
-// -------------------------------------------------------------------- finding
-
-/** How far along a line its nearest point to (z, a) is. */
-function sAlong(line: Line, z: number, a: number): number {
-  let best = 0, bestD = Infinity
-  for (let i = 0; i < line.s.length; i++) {
-    const d = Math.hypot(line.pts[2 * i]! - z, nearestA(line.pts[2 * i + 1]!, a) - a)
-    if (d < bestD) { bestD = d; best = i }
-  }
-  return line.s[best]!
-}
-
-const feature = (w: World, id: string): Feature | undefined => w.features.find((f) => f.id === id)
-
-/** The tallest fall in the world, or on one river. */
-function tallestFall(c: CompiledWorld, river?: string): CFall | null {
-  let best: CFall | null = null
-  for (const f of c.falls) {
-    if (river && f.river !== river) continue
-    if (!best || f.topM - f.bottomM > best.topM - best.bottomM) best = f
-  }
-  return best
-}
-
-/** The longest ship at sea on `sea` at `hours`, if any is out. */
-function biggestShip(w: World, sea: string, hours: number): ShipLane | null {
-  const out = shipsAt(w, hours).filter((s) => s.lane.sea === sea)
-  return out.reduce<ShipLane | null>((best, s) => (!best || s.lane.lengthM > best.lengthM ? s.lane : best), null)
-}
-
-// --------------------------------------------------------------------- scenes
-
-export const SCENES: Scene[] = [
-  {
-    id: 'falls',
-    prepare: (w) => {
-      const f = tallestFall(w.compiled)
-      if (!f) return null
-      const river = feature(w.world, f.river)?.name ?? 'a river'
-      const drop = f.topM - f.bottomM
-      const target = { z: f.z + f.dz * 60, a: f.a + f.da * 60, altM: f.bottomM + drop * 0.5 }
-      return {
-        id: 'falls', hours: NOON,
-        title: `Circling ${river}'s ${Math.round(drop)} m fall — it leans antispinward as it drops`,
-        // Out in front of the fall and below its lip, looking back at its face, swinging
-        // slowly across it. From above, the gorge hides the water.
-        pose: orbit(w, target, 1700, f.bottomM + drop * 0.7, Math.atan2(f.da, f.dz) - 0.35, 0.7, 60, 55),
-      }
-    },
-  },
-  {
-    id: 'ship',
-    prepare: (w) => {
-      const sea = w.world.features.find((f): f is Sea => f.kind === 'sea' && lanesOf(f, w.world.seed).length > 0)
-      // Chosen before the ship, since the ship is wherever it is at that hour.
-      const hours = sea ? morningOver(w, sea.geometry.type === 'band'
-        ? (sea.geometry.zFrom + sea.geometry.zTo) / 2 : anchorOf(sea).z) : NOON
-      const lane = sea && biggestShip(w.world, sea.id, hours)
-      if (!sea || !lane) return null
-      return {
-        id: 'ship', hours, pose: chase(w, lane, hours, 50),
-        title: `Following a ${Math.round(lane.lengthM)} m ship round ${sea.name}`,
-      }
-    },
-  },
-  {
-    id: 'summit',
-    prepare: (w) => {
-      const peaks = w.world.features.flatMap((f) => (f.kind === 'mountains' ? f.peaks : []))
-      const top = peaks.reduce<(typeof peaks)[number] | null>((b, p) => (!b || p.elevationM > b.elevationM ? p : b), null)
-      if (!top) return null
-      const target = { z: top.z, a: top.theta * RADIUS_M, altM: top.elevationM - 250 }
-      return {
-        id: 'summit', hours: NOON,
-        title: `Round ${top.name}, ${top.elevationM.toLocaleString('en')} m, the highest summit in Tower`,
-        pose: orbit(w, target, 5500, top.elevationM + 450, 0.3, 1.1, 300, 60),
-      }
-    },
-  },
-  {
-    id: 'river',
-    prepare: (w) => {
-      // A river's last big fall, at its mouth: flown down to, and over.
-      let pick: { r: CompiledWorld['rivers'][number]; f: CFall; s: number } | null = null
-      for (const r of w.compiled.rivers) {
-        for (const f of w.compiled.falls) {
-          if (f.river !== r.id || f.topM - f.bottomM < 50) continue
-          const s = sAlong(r.line, f.z, f.a)
-          if (!pick || s / r.line.s[r.line.s.length - 1]! > pick.s / pick.r.line.s[pick.r.line.s.length - 1]!) pick = { r, f, s }
-        }
-      }
-      if (!pick) return null
-      const name = feature(w.world, pick.r.id)?.name ?? 'the river'
-      const outlet = pick.r.outlet ? feature(w.world, pick.r.outlet)?.name : null
-      const drop = Math.round(pick.f.topM - pick.f.bottomM)
-      return {
-        id: 'river', hours: NOON,
-        title: `Down ${name} to its ${drop} m fall${outlet ? ` into ${outlet}` : ''}`,
-        pose: flight(w, pick.r.line, pick.s - 110 * 20, 110, 220, 3500, 65),
-      }
-    },
-  },
-  {
-    id: 'squall',
-    prepare: (w) => {
-      // A blow Tower gave too little warning of for the fleet to get in (weather.ts).
-      for (const o of occurrences(w.world.weather, null, 0, 24 * Math.max(28, w.world.weather.cycleDays))) {
-        if (o.event.kind !== 'gale' && o.event.kind !== 'storm') continue
-        if (o.publishedH <= o.startH - HARBOUR_LEAD_H) continue
-        const sea = feature(w.world, o.event.target)
-        const hours = o.startH + Math.min(2, o.endH - o.startH - 0.5)
-        const lane = sea?.kind === 'sea' ? biggestShip(w.world, sea.id, hours) : null
-        if (!sea || !lane) continue
-        return {
-          id: 'squall', hours, pose: chase(w, lane, hours, 55),
-          title: `Caught out: a ship in a ${o.event.kind} on ${sea.name} that Tower gave no warning of`,
-        }
-      }
-      return null
-    },
-  },
-  {
-    id: 'mesas',
-    prepare: (w) => {
-      /**
-       * The broadest stand of rock, not merely the first tall cell: scored by how much
-       * plateau there is within a couple of kilometres, so the camera is put in front of a
-       * mesa field rather than a lone butte.
-       */
-      let best: { z: number; a: number; d: CDesert; score: number } | null = null
-      for (const d of w.compiled.deserts ?? []) {
-        const { grid } = d
-        const reach = Math.round(2000 / grid.cell)
-        for (let i = reach; i < grid.nz - reach; i += 4) {
-          for (let j = 0; j < grid.na; j += 4) {
-            if (d.plateau[i * grid.na + j]! < 0.9) continue
-            let score = 0
-            for (let di = -reach; di <= reach; di += 2) {
-              for (let dj = -reach; dj <= reach; dj += 2) {
-                const jj = grid.ring ? ((j + dj) % grid.na + grid.na) % grid.na : j + dj
-                if (jj < 0 || jj >= grid.na) continue
-                score += d.plateau[(i + di) * grid.na + jj]!
-              }
-            }
-            if (!best || score > best.score) {
-              best = { z: grid.z0 + i * grid.cell, a: grid.a0 + j * grid.cell, d, score }
-            }
-          }
-        }
-      }
-      if (!best) return null
-      const name = feature(w.world, best.d.id)?.name ?? 'the desert'
-      const top = w.ground(wrapA(best.a) / RADIUS_M, best.z)
-      /**
-       * Somewhere on the open plain to watch them from: the nearest low ground a few
-       * kilometres off, so the camera circles over sand and the rock stands over it.
-       */
-      const { grid } = best.d
-      let stand = { z: best.z, a: best.a + 4000 }
-      for (let r = 3500; r <= 7000; r += 500) {
-        for (let k = 0; k < 16; k++) {
-          const ang = (k / 16) * Math.PI * 2
-          const z = best.z + Math.cos(ang) * r, a = best.a + Math.sin(ang) * r
-          const i = Math.round((z - grid.z0) / grid.cell)
-          const j = ((Math.round((a - grid.a0) / grid.cell) % grid.na) + grid.na) % grid.na
-          if (i < 0 || i >= grid.nz) continue
-          if (best.d.plateau[i * grid.na + j]! < 0.03) { stand = { z, a }; r = 1e9; break }
-        }
-      }
-      const standGround = w.ground(wrapA(stand.a) / RADIUS_M, stand.z)
-      return {
-        id: 'mesas', hours: NOON,
-        title: `Past the mesas of ${name}: ${best.d.terraces} tiers of rock, with the strata lining up across all of them`,
-        pose: orbitLooking(w,
-          { z: stand.z, a: stand.a, altM: standGround },
-          { z: best.z, a: best.a, altM: top - best.d.plateauHeightM * 0.45 },
-          1500, standGround + 320, 0.4, 1.3, 220, 55),
-      }
-    },
-  },
-  {
-    id: 'pan',
-    prepare: (w) => {
-      const d = (w.compiled.deserts ?? []).find((x) => x.pans.length > 0)
-      const pan = d?.pans[0]
-      if (!d || !pan) return null
-      const name = feature(w.world, d.id)?.name ?? 'the desert'
-      // Across the pan, low, so the benches around it stand against the sky.
-      const line = straight(pan.z - pan.radiusM * 2.2, pan.a, 0, pan.radiusM * 4.4)
-      return {
-        id: 'pan', hours: NOON,
-        title: `Over a salt pan in ${name}, ringed by the shorelines of a lake that never stood`,
-        pose: flight(w, line, 0, (pan.radiusM * 4.4 - 2500) / SCENE_S, 70, 2500, 60),
-      }
-    },
-  },
-  {
-    id: 'giants',
-    prepare: (w) => {
-      const tall = (w.compiled.forests ?? []).find((f) => f.giants)
-      if (!tall) return null
-      const name = feature(w.world, tall.id)?.name ?? 'the tall wood'
-      const b = tall.box
-      const centre = { z: (b.z0 + b.z1) / 2, a: (b.a0 + b.a1) / 2 }
-      /**
-       * One particular giant, found the same way the view finds them (forest.ts), and
-       * circled from far enough out to see it whole. Flying *through* a grove at 13 m
-       * spacing put the camera inside a trunk about every other second.
-       */
-      let best: Tree | null = null
-      forEachTree(tall, { z0: centre.z - 300, z1: centre.z + 300, a0: centre.a - 300, a1: centre.a + 300 },
-        (z, a) => ({ stems: tall.densityPerHa, h: w.ground(wrapA(a) / RADIUS_M, z) }),
-        (tree) => { if (!best || tree.heightM > best.heightM) best = tree })
-      if (!best) return null
-      const tree = best as Tree
-      const foot = w.ground(wrapA(tree.a) / RADIUS_M, tree.z)
-      return {
-        id: 'giants', hours: NOON,
-        title: `Under the giants of ${name}: ${Math.round(tree.heightM)} m, near the tallest a tree can be at 1 g`,
-        pose: orbit(w, { z: tree.z, a: tree.a, altM: foot + tree.heightM * 0.45 },
-          70, foot + 14, 0.6, 1.5, 12, 70),
-      }
-    },
-  },
-  {
-    id: 'canopy',
-    prepare: (w) => {
-      const wood = (w.compiled.forests ?? []).find((f) => !f.giants && f.densityPerHa > 100)
-      if (!wood) return null
-      const name = feature(w.world, wood.id)?.name ?? 'the forest'
-      const b = wood.box
-      const z = (b.z0 + b.z1) / 2
-      const line = straight(z - 3000, 0, 0.35, 12_000)
-      return {
-        id: 'canopy', hours: NOON,
-        title: `Low over ${name}: closed canopy, and the meadows Tower left open in it`,
-        pose: flight(w, line, 0, 300, 260, 5000, 65),
-      }
-    },
-  },
-  {
-    id: 'narrows',
-    prepare: (w) => {
-      const f = feature(w.world, 'the-narrows')
-      if (!f || f.geometry.type !== 'polyline') return null
-      const line = smoothLine(f.geometry.points, 50)
-      const length = line.s[line.s.length - 1]!
-      return {
-        id: 'narrows', hours: NOON,
-        title: `Through ${f.name}, the one gap in all three ridges`,
-        pose: flight(w, line, 0, Math.max(40, Math.min(200, (length - 1500) / SCENE_S)), 350, 1500, 65),
-      }
-    },
-  },
-  {
-    id: 'deep',
-    prepare: (w) => {
-      const capital = feature(w.world, 'the-capital')
-      const deep = feature(w.world, 'the-deep')
-      if (!capital || capital.geometry.type !== 'point' || !deep) return null
-      const { theta, z } = capital.geometry
-      // Spinward, across the lake the short way, low over the water, looking at the far
-      // shore — which the floor's curve lifts well above the eye.
-      const line = straight(z, theta * RADIUS_M, Math.PI / 2, 45_000)
-      return {
-        id: 'deep', hours: NOON,
-        title: `Out from ${capital.name} over ${deep.name} — the far shore climbs the sky ahead`,
-        pose: flight(w, line, 7000, 220, 150, 20_000, 60),
-      }
-    },
-  },
-  {
-    id: 'storm',
-    prepare: (w) => {
-      for (const o of occurrences(w.world.weather, null, 0, 24 * Math.max(28, w.world.weather.cycleDays))) {
-        const sea = feature(w.world, o.event.target)
-        if (o.event.kind !== 'storm' || sea?.kind !== 'sea' || sea.geometry.type !== 'band') continue
-        if (o.revisions.length) continue
-        const hours = o.startH + Math.min(8, (o.endH - o.startH) / 2)
-        const dir = (o.event.wind.directionDeg * Math.PI) / 180
-        const level = w.compiled.basins.find((b) => b.id === sea.id)?.levelM ?? 0
-        const z = (sea.geometry.zFrom + sea.geometry.zTo) / 2
-        // Running before the storm, low, over a sea with nobody on it.
-        const move = { z: Math.cos(dir), a: Math.sin(dir) }
-        return {
-          id: 'storm', hours,
-          title: `The storm Tower published for ${sea.name}: ${o.event.wind.speedMS} m/s, and every ship in harbour`,
-          pose: (t) => {
-            const d = 25 * t
-            const cam = { z: z + move.z * d, a: move.a * d, altM: level + 30 }
-            return pose(cam, lookAt(cam, { z: cam.z + move.z * 600, a: cam.a + move.a * 600, altM: level }), 60)
-          },
-        }
-      }
-      return null
-    },
-  },
-  {
-    id: 'girdle',
-    prepare: (w) => {
-      const g = feature(w.world, 'the-girdle')
-      if (!g || g.geometry.type !== 'band') return null
-      const z = (g.geometry.zFrom + g.geometry.zTo) / 2
-      return {
-        id: 'girdle', hours: NOON,
-        title: `Nine kilometres over ${g.name}, the range climbing round the world ahead`,
-        pose: (t) => ({ theta: (400 * t) / RADIUS_M, z, altM: 9000, yaw: Math.PI / 2, pitch: 0.55, fovDeg: 75 }),
-      }
-    },
-  },
-]
-
-/** Every scene the world can show, in tour order. */
-export function planTour(w: SceneWorld): ScenePlan[] {
-  return SCENES.flatMap((s) => s.prepare(w) ?? [])
-    .map((p) => (p.hours === NOON ? { ...p, hours: morningOver(w, p.pose(0).z) } : p))
-}
diff --git a/src/views/tour-player.ts b/src/views/tour-player.ts
new file mode 100644
index 0000000..97c7878
--- /dev/null
+++ b/src/views/tour-player.ts
@@ -0,0 +1,162 @@
+/**
+ * Plays the scenic tour's legs, inside or out: which leg, how far into it, and the fade
+ * through black between one and the next. See world-generation.md §19, D-085.
+ *
+ * The legs come from tour/; this only keeps time. It deals them like a deck (tour/plan.ts)
+ * and deals again when the deck runs out, fades out of one leg, holds black until the view
+ * says its ground has arrived — or four seconds, whichever is first — and fades into the
+ * next. A held leg, for the screenshot tour, is one moment with no fades and no time.
+ */
+import { VEHICLE_NAME, type Vehicle } from '../tour/interior-legs.js'
+import { deal } from '../tour/plan.js'
+
+export interface PlayableLeg {
+  id: string
+  title: string
+  vehicle: Vehicle
+  durationS: number
+  rate: number
+  /** What the leg is about, so two legs about the same thing are not dealt back to back. */
+  subject?: { id: string } | null
+}
+
+const theme = (l: PlayableLeg): string => l.subject?.id ?? l.id
+
+/** Seconds a change of leg takes to fade out, and in again once the ground has arrived. */
+const FADE_S = 0.6
+/** Longest the tour holds black waiting for the ground; past this it shows what there is. */
+const WAIT_S = 4
+
+export class TourPlayer<L extends PlayableLeg> {
+  legs: L[] = []
+  private order: L[] = []
+  private index = 0
+  /** Legs flown so far, for the caption's count. */
+  private flown = 0
+  /** Seconds into the current leg. */
+  t = 0
+  private phase: 'out' | 'wait' | 'in' | 'play' = 'out'
+  /** 0 clear, 1 black. */
+  private fade = 0
+  private waited = 0
+  frozen = false
+  private rng: () => number = Math.random
+  private fadeEl = document.createElement('div')
+  private captionEl = document.createElement('div')
+
+  constructor(host: HTMLElement | null | undefined) {
+    this.fadeEl.className = 'tour-fade'
+    this.fadeEl.hidden = true
+    this.captionEl.className = 'tour-caption'
+    this.captionEl.hidden = true
+    host?.append(this.fadeEl, this.captionEl)
+  }
+
+  get current(): L | null {
+    return this.order[this.index] ?? null
+  }
+
+  /** Start dealing `legs`, from the one named `fromId` if it is there. */
+  start(legs: L[], rng: () => number, fromId?: string): void {
+    this.legs = legs
+    this.rng = rng
+    this.order = deal(legs, (l) => l.vehicle, rng, undefined, theme)
+    const i = this.order.findIndex((l) => l.id === fromId)
+    if (i > 0) this.order.unshift(...this.order.splice(i, 1))
+    this.index = 0
+    this.flown = 0
+    this.t = 0
+    this.phase = 'out'
+    this.fade = 0
+    this.waited = 0
+    this.frozen = false
+  }
+
+  /** Hold one leg at one moment. */
+  hold(legs: L[], leg: L, t: number): void {
+    this.legs = legs
+    this.order = [leg]
+    this.index = 0
+    this.t = Math.min(t, leg.durationS)
+    this.phase = 'play'
+    this.fade = 0
+    this.frozen = true
+    this.fadeEl.hidden = true
+    this.captionEl.hidden = true
+  }
+
+  /** The world changed under the tour: carry on with the new legs, from the same one if it is still there. */
+  replan(legs: L[]): void {
+    const id = this.current?.id
+    this.legs = legs
+    const rest = deal(legs, (l) => l.vehicle, this.rng, undefined, theme)
+    const same = rest.findIndex((l) => l.id === id)
+    if (same > 0) rest.unshift(...rest.splice(same, 1))
+    this.order = rest
+    this.index = 0
+  }
+
+  next(): void {
+    if (this.frozen) return
+    if (this.phase === 'play' || this.phase === 'in') this.phase = 'out'
+  }
+
+  stop(): void {
+    this.fadeEl.hidden = true
+    this.captionEl.hidden = true
+  }
+
+  /**
+   * Advance by `dt` seconds. `settled` is whether the view's ground has arrived. Returns
+   * true when the current leg should be shown at `t` — false while fading out of the
+   * observer's own view at the very start.
+   */
+  step(dt: number, settled: boolean, lookingAt?: (l: L, t: number) => string): boolean {
+    const leg = this.current
+    if (!leg) return false
+    if (this.frozen) return true
+    if (this.phase === 'out') {
+      this.fade = Math.min(1, this.fade + dt / FADE_S)
+      if (this.t > 0) this.t += dt
+      if (this.fade >= 1) {
+        // A fresh start fades out of the observer's own view into the first leg.
+        if (this.t > 0) this.advance()
+        this.t = 0
+        this.waited = 0
+        this.phase = 'wait'
+      }
+    } else if (this.phase === 'wait') {
+      this.waited += dt
+      if ((settled && this.waited > 0.3) || this.waited > WAIT_S) this.phase = 'in'
+    } else {
+      this.t += dt
+      if (this.phase === 'in') {
+        this.fade = Math.max(0, this.fade - dt / FADE_S)
+        if (this.fade <= 0) this.phase = 'play'
+      }
+      if (this.t >= (this.current?.durationS ?? 0) - FADE_S) this.phase = 'out'
+    }
+    const now = this.current!
+    this.fadeEl.hidden = this.fade <= 0
+    this.fadeEl.style.opacity = String(this.fade)
+    this.captionEl.hidden = false
+    const t = Math.min(this.t, now.durationS)
+    const looking = lookingAt ? ` · looking at ${lookingAt(now, t)}` : ''
+    const lapse = now.rate > 1 ? ` · time ×${now.rate}` : ''
+    const caption = `${now.title}\n${VEHICLE_NAME[now.vehicle]}${lapse}${looking}\n`
+      + `leg ${this.flown + 1} · N next · Esc stops`
+    if (this.captionEl.textContent !== caption) this.captionEl.textContent = caption
+    return this.phase !== 'out' || this.t > 0
+  }
+
+  private advance(): void {
+    this.flown++
+    this.index++
+    if (this.index >= this.order.length) {
+      // A new deck, which never opens with the craft the last one closed on.
+      const last = this.order[this.order.length - 1]?.vehicle
+      this.order = deal(this.legs, (l) => l.vehicle, this.rng, last, theme)
+      this.index = 0
+    }
+  }
+}
diff --git a/src/world/schema.ts b/src/world/schema.ts
index 087bd6c..08ca8a2 100644
--- a/src/world/schema.ts
+++ b/src/world/schema.ts
@@ -26,7 +26,7 @@ export const WORLD_VERSION = 2
 /** Features that shape the ground. A region can too, when it is given an uplift. */
 export const TERRAIN_KINDS = ['mountains', 'lake', 'sea', 'river', 'desert', 'forest'] as const
 /** Built things. Generated from in later phases (world-generation.md §4.7, §4.8). */
-export const BUILT_KINDS = ['settlement', 'road', 'path'] as const
+export const BUILT_KINDS = ['settlement', 'road', 'path', 'railway'] as const
 /** Labels on the world. They generate nothing, except a region with an uplift. */
 export const MARKER_KINDS = ['embassy', 'tower', 'landmark', 'note', 'region'] as const
 
@@ -37,7 +37,7 @@ export type FeatureKind = (typeof FEATURE_KINDS)[number]
 export const AREA_KINDS: readonly FeatureKind[] =
   ['mountains', 'lake', 'sea', 'desert', 'forest', 'region']
 /** Kinds drawn as an open line. */
-export const LINE_KINDS: readonly FeatureKind[] = ['river', 'road', 'path']
+export const LINE_KINDS: readonly FeatureKind[] = ['river', 'road', 'path', 'railway']
 
 export const isAreaKind = (k: FeatureKind): boolean => AREA_KINDS.includes(k)
 export const isLineKind = (k: FeatureKind): boolean => LINE_KINDS.includes(k)
@@ -215,6 +215,16 @@ export interface Route extends Base<'road' | 'path'> {
   widthM: number
 }
 
+/**
+ * A railway line (D-086). Its track is laid on a grade of its own, over the ground or on a
+ * viaduct above it (terrain/rails.ts), and trains run on it in the scenic tour. A line whose
+ * last point comes back round to its first, a whole circumference on, is a loop.
+ */
+export interface Railway extends Base<'railway'> {
+  /** Line speed, metres a second: what a train runs at along it. */
+  speedMS: number
+}
+
 export interface Marker extends Base<'embassy' | 'tower' | 'landmark' | 'note'> {
   /** Height of a tower, for when towers are drawn. */
   heightM?: number
@@ -231,7 +241,7 @@ export interface Region extends Base<'region'> {
 }
 
 export type Feature =
-  | Mountains | Lake | Sea | River | Desert | Forest | Settlement | Route | Marker | Region
+  | Mountains | Lake | Sea | River | Desert | Forest | Settlement | Route | Railway | Marker | Region
 
 /** The gentle relief everywhere nothing else is (today's "open ground"). */
 export interface Ground {
@@ -341,7 +351,7 @@ export const emptyWorld = (): World => ({
 
 /** Which shapes a kind may take, the usual one first. */
 export function geometryTypesFor(kind: FeatureKind): readonly GeometryType[] {
-  if (kind === 'river' || kind === 'road' || kind === 'path') return ['polyline']
+  if (kind === 'river' || kind === 'road' || kind === 'path' || kind === 'railway') return ['polyline']
   if (kind === 'lake') return ['polygon']
   if (kind === 'mountains' || kind === 'sea' || kind === 'desert' || kind === 'forest') {
     return ['polygon', 'band']
@@ -396,6 +406,8 @@ export function withKind(common: Common, kind: FeatureKind): Feature {
     case 'road':
     case 'path':
       return { ...c, kind, widthM: kind === 'road' ? 8 : 2 }
+    case 'railway':
+      return { ...c, kind, speedMS: 70 }
     case 'region':
       return { ...c, kind, upliftM: 0, edgeM: 4000 }
     default:
@@ -609,6 +621,8 @@ function fillParams(d: Feature, f: Record<string, unknown>): Feature {
     case 'road':
     case 'path':
       return { ...d, widthM: Math.max(0.5, num(f['widthM'], d.widthM)) }
+    case 'railway':
+      return { ...d, speedMS: Math.max(5, Math.min(200, num(f['speedMS'], d.speedMS))) }
     default:
       return Number.isFinite(f['heightM']) ? { ...d, heightM: f['heightM'] as number } : d
   }
diff --git a/test/scenic.test.ts b/test/scenic.test.ts
deleted file mode 100644
index eb28f48..0000000
--- a/test/scenic.test.ts
+++ /dev/null
@@ -1,83 +0,0 @@
-import { readFileSync } from 'node:fs'
-import { describe, expect, it } from 'vitest'
-import { LENGTH_M, RADIUS_M } from '../src/canon/constants.js'
-import { compileWorld } from '../src/terrain/compile.js'
-import { prepare } from '../src/terrain/field.js'
-import { nearestA } from '../src/terrain/shapes.js'
-import { shipAt, shipsAt } from '../src/terrain/ships.js'
-import { SCENES, SCENE_S, lookAt, planTour } from '../src/views/scenic.js'
-import { parseWorld } from '../src/world/schema.js'
-import { actualAt } from '../src/world/weather.js'
-
-const world = parseWorld(JSON.parse(readFileSync('content/world.json', 'utf8')))
-const compiled = compileWorld(world)
-const rt = prepare(compiled)
-const ground = (theta: number, z: number): number => {
-  const s = rt.sample(theta, z, 1)
-  return Math.max(s.h, s.water)
-}
-const plans = planTour({ world, compiled, ground })
-const plan = (id: string) => plans.find((p) => p.id === id)!
-
-describe('the scenic tour', () => {
-  it('finds every scene in the world as authored', () => {
-    expect(plans.map((p) => p.id)).toEqual(SCENES.map((s) => s.id))
-  })
-
-  it('keeps every camera in the world, above the ground, and moving smoothly', () => {
-    for (const p of plans) {
-      let prev: ReturnType<typeof p.pose> | null = null
-      for (let t = 0; t <= SCENE_S; t += 0.5) {
-        const q = p.pose(t)
-        for (const v of Object.values(q)) expect(Number.isFinite(v), `${p.id} at ${t}s`).toBe(true)
-        expect(q.z).toBeGreaterThanOrEqual(0)
-        expect(q.z).toBeLessThanOrEqual(LENGTH_M)
-        expect(q.altM - ground(q.theta, q.z), `${p.id} clearance at ${t}s`).toBeGreaterThan(8)
-        if (prev) {
-          const moved = Math.hypot(q.z - prev.z, nearestA(q.theta * RADIUS_M, prev.theta * RADIUS_M) - prev.theta * RADIUS_M)
-          expect(moved, `${p.id} speed at ${t}s`).toBeLessThan(250)
-          const turn = Math.abs(Math.atan2(Math.sin(q.yaw - prev.yaw), Math.cos(q.yaw - prev.yaw)))
-          expect(turn, `${p.id} turn at ${t}s`).toBeLessThan(0.2)
-        }
-        prev = q
-      }
-    }
-  })
-
-  it('keeps a followed ship in the middle of the frame', () => {
-    for (const id of ['ship', 'squall']) {
-      const p = plan(id)
-      const fleet = shipsAt(world, p.hours)
-      for (const t of [0, 15, 30]) {
-        const q = p.pose(t)
-        const cam = { z: q.z, a: q.theta * RADIUS_M, altM: q.altM }
-        // The ship being followed is the one nearest the camera.
-        const near = fleet.map((s) => shipAt(s.lane, p.hours + t / 3600))
-          .reduce((b, f) => (Math.hypot(f.z - cam.z, nearestA(f.a, cam.a) - cam.a)
-            < Math.hypot(b.z - cam.z, nearestA(b.a, cam.a) - cam.a) ? f : b))
-        const look = lookAt(cam, { z: near.z, a: near.a, altM: q.altM - 10 })
-        expect(Math.abs(Math.atan2(Math.sin(look.yaw - q.yaw), Math.cos(look.yaw - q.yaw)))).toBeLessThan(0.1)
-      }
-    }
-  })
-
-  it('catches a ship out in weather Tower gave no warning of', () => {
-    const p = plan('squall')
-    const sea = world.weather.events.find((e) => p.title.includes(e.kind) && e.id.includes('squall'))!
-    const wind = actualAt(world.weather, sea.target, { directionDeg: 0, speedMS: 0 }, p.hours).wind.speedMS
-    expect(wind).toBeGreaterThanOrEqual(17)
-    expect(shipsAt(world, p.hours).some((s) => s.lane.sea === sea.target)).toBe(true)
-  })
-
-  it('shows a published storm with the sea empty of ships', () => {
-    const p = plan('storm')
-    expect(shipsAt(world, p.hours).filter((s) => p.title.includes('Sunward') && s.lane.sea === 'sunward-sea')).toHaveLength(0)
-  })
-
-  it('photographs only scenes that exist', () => {
-    const tool = readFileSync('tools/screenshots.mjs', 'utf8')
-    const ids = [...tool.matchAll(/scene: '([a-z-]+)'/g)].map((m) => m[1])
-    expect(ids.length).toBeGreaterThan(0)
-    for (const id of ids) expect(SCENES.map((s) => s.id)).toContain(id)
-  })
-})
diff --git a/test/terrain.test.ts b/test/terrain.test.ts
index e92b4e4..4b6b7a5 100644
--- a/test/terrain.test.ts
+++ b/test/terrain.test.ts
@@ -16,8 +16,9 @@ const rt = prepare(cw)
 
 describe('the world compiles', () => {
   it('ports every v1 feature, plus the ground raised under the rivers', () => {
-    // Twenty-nine ported from v1, plus The Tall Wood, which Tower placed from the seed (D-071).
-    expect(world.features).toHaveLength(30)
+    // Twenty-nine ported from v1, plus The Tall Wood, which Tower placed from the seed (D-071),
+    // and the two railways (D-086).
+    expect(world.features).toHaveLength(32)
     // Eight ranges in two groups: the Girdle with the four massifs on it, and the
     // three desert ridges, which overlap each other.
     expect(cw.mountains).toHaveLength(2)
diff --git a/test/tour.test.ts b/test/tour.test.ts
new file mode 100644
index 0000000..356d2f4
--- /dev/null
+++ b/test/tour.test.ts
@@ -0,0 +1,245 @@
+import { readFileSync } from 'node:fs'
+import { describe, expect, it } from 'vitest'
+import { BEAM_RADIUS_M, G0, LENGTH_M, RADIUS_M } from '../src/canon/constants.js'
+import { apparentGravity } from '../src/canon/motion.js'
+import { compileWorld } from '../src/terrain/compile.js'
+import { prepare } from '../src/terrain/field.js'
+import { maxGrade, railLines } from '../src/terrain/rails.js'
+import { shipsAt } from '../src/terrain/ships.js'
+import { transmission } from '../src/tour/director.js'
+import {
+  COLLECTOR_R, COLLECTOR_Z, EXTERIOR_LEGS, HULL_R, SUN_Z, clearOfBeam, planExterior,
+} from '../src/tour/exterior-legs.js'
+import { type V3, dirOf, dot, len, norm, radial, sub, toV } from '../src/tour/geo.js'
+import { INTERIOR_LEGS, planInterior } from '../src/tour/interior-legs.js'
+import { LEG_MAX_S, LEG_MIN_S, deal, legSeconds, tourRng } from '../src/tour/plan.js'
+import { parseWorld } from '../src/world/schema.js'
+import { actualAt } from '../src/world/weather.js'
+
+const world = parseWorld(JSON.parse(readFileSync('content/world.json', 'utf8')))
+const compiled = compileWorld(world)
+const rt = prepare(compiled)
+const ground = (theta: number, z: number): number => {
+  const s = rt.sample(theta, z, 1)
+  return Math.max(s.h, s.water)
+}
+const rails = railLines(world, ground)
+const rng = tourRng(Number(process.env["TOUR_SEED"] ?? 7))
+const legs = planInterior({ world, compiled, ground, rails }, rng, () => legSeconds(rng()))
+const leg = (id: string) => legs.find((l) => l.id === id)!
+const outside = planExterior({ world }, tourRng(Number(process.env["TOUR_SEED"] ?? 3)), () => 15)
+
+const eyeOf = (p: ReturnType<(typeof legs)[number]['pose']>): V3 =>
+  toV({ z: p.z, a: p.theta * RADIUS_M, altM: p.altM })
+const viewOf = (p: ReturnType<(typeof legs)[number]['pose']>): V3 => dirOf(eyeOf(p), p.yaw, p.pitch)
+/** Angle off the middle of the frame, radians. */
+const offCentre = (eye: V3, dir: V3, target: V3): number =>
+  Math.acos(Math.max(-1, Math.min(1, dot(norm(sub(target, eye)), dir))))
+
+describe('the railways', () => {
+  it('lays both lines, one of them a loop round the world', () => {
+    expect(rails.map((r) => r.id).sort()).toEqual(['the-axial-line', 'the-uplands-ring'])
+    expect(rails.find((r) => r.id === 'the-uplands-ring')!.loop).toBe(true)
+    expect(rails.find((r) => r.id === 'the-axial-line')!.loop).toBe(false)
+  })
+
+  it('never runs the deck below the ground, and never steeper than a mountain railway', () => {
+    for (const r of rails) {
+      for (let i = 0; i < r.deckM.length; i++) expect(r.deckM[i]!).toBeGreaterThanOrEqual(r.groundM[i]!)
+      expect(maxGrade(r), r.id).toBeLessThan(0.08)
+    }
+  })
+})
+
+describe('weight on the move', () => {
+  it('is the floor\'s 1 g standing still, and nothing at all doing the rim speed antispinward', () => {
+    expect(apparentGravity(RADIUS_M, 0) / G0).toBeCloseTo(1, 6)
+    expect(apparentGravity(RADIUS_M, -Math.sqrt(G0 * RADIUS_M))).toBeCloseTo(0, 6)
+  })
+
+  it('puts a 100 m/s train 31% up one way and 27% down the other', () => {
+    expect(apparentGravity(RADIUS_M, 100) / G0).toBeCloseTo(1.306, 2)
+    expect(apparentGravity(RADIUS_M, -100) / G0).toBeCloseTo(0.735, 2)
+  })
+})
+
+describe('the order of the legs', () => {
+  it('deals every leg once, and never the same craft twice running', () => {
+    const items = legs.map((l) => ({ id: l.id, kind: l.vehicle }))
+    for (let seed = 1; seed < 40; seed++) {
+      const order = deal(items, (x) => x.kind, tourRng(seed), 'train')
+      expect(order.map((x) => x.id).sort()).toEqual(items.map((x) => x.id).sort())
+      expect(order[0]!.kind).not.toBe('train')
+      for (let i = 1; i < order.length; i++) expect(order[i]!.kind, `seed ${seed}`).not.toBe(order[i - 1]!.kind)
+    }
+  })
+
+  it('keeps two legs about the same thing apart, nearly always', () => {
+    // Where a craft has to be laid to keep two of a kind apart, the subject can lose out.
+    let repeats = 0
+    for (let seed = 1; seed <= 100; seed++) {
+      const order = deal(legs, (l) => l.vehicle, tourRng(seed), undefined, (l) => l.subject?.id ?? l.id)
+      for (let i = 1; i < order.length; i++) {
+        const a = order[i]!.subject?.id, b = order[i - 1]!.subject?.id
+        if (a && a === b) repeats++
+      }
+    }
+    expect(repeats).toBeLessThan(10)
+  })
+
+  it('gives each leg twelve to eighteen seconds', () => {
+    for (const l of legs) {
+      expect(l.durationS).toBeGreaterThanOrEqual(LEG_MIN_S)
+      expect(l.durationS).toBeLessThanOrEqual(LEG_MAX_S)
+    }
+  })
+})
+
+describe('the tour inside', () => {
+  it('finds every leg in the world as authored', () => {
+    expect(legs.map((l) => l.id)).toEqual(INTERIOR_LEGS.map((s) => s.id))
+  })
+
+  it('keeps every camera in the world, above the ground, out of the beam, and moving smoothly', () => {
+    for (const l of legs) {
+      let prev: ReturnType<typeof l.pose> | null = null
+      for (let t = 0; t <= l.durationS; t += 0.5) {
+        const q = l.pose(t)
+        for (const v of Object.values(q)) expect(Number.isFinite(v), `${l.id} at ${t}s`).toBe(true)
+        expect(q.z).toBeGreaterThanOrEqual(0)
+        expect(q.z).toBeLessThanOrEqual(LENGTH_M)
+        expect(q.altM - ground(q.theta, q.z), `${l.id} clearance at ${t}s`).toBeGreaterThan(8)
+        expect(RADIUS_M - q.altM, `${l.id} beam at ${t}s`).toBeGreaterThan(BEAM_RADIUS_M + 2000)
+        expect(Math.abs(q.roll), `${l.id} roll at ${t}s`).toBeLessThan(1)
+        if (prev) {
+          const moved = len(sub(eyeOf(q), eyeOf(prev)))
+          // Real time, a train or a ship is tens of metres a second; a time-lapse is more.
+          expect(moved, `${l.id} speed at ${t}s`).toBeLessThan(l.rate > 1 ? 3000 : 400)
+          const turn = Math.acos(Math.min(1, dot(viewOf(q), viewOf(prev))))
+          expect(turn, `${l.id} turn at ${t}s`).toBeLessThan(0.45)
+        }
+        prev = q
+      }
+    }
+  })
+
+  it('keeps every craft out of the beam and off the ground', () => {
+    for (const l of legs) {
+      for (let t = 0; t <= l.durationS; t += 0.5) {
+        const c = l.at(t)
+        expect(radial(c.pos), `${l.id} at ${t}s`).toBeGreaterThan(BEAM_RADIUS_M + 3000)
+        const at = { theta: Math.atan2(c.pos.y, c.pos.x), z: c.pos.z }
+        expect(RADIUS_M - radial(c.pos) - ground(at.theta, at.z), `${l.id} at ${t}s`).toBeGreaterThan(1)
+      }
+    }
+  })
+
+  it('keeps a chased craft in the frame', () => {
+    for (const l of legs.filter((x) => x.rig === 'chase')) {
+      for (let t = 0; t <= l.durationS; t += 1) {
+        const q = l.pose(t)
+        const off = offCentre(eyeOf(q), viewOf(q), l.at(t).pos)
+        expect(off, `${l.id} at ${t}s`).toBeLessThan((q.fovDeg / 2) * (Math.PI / 180))
+      }
+    }
+  })
+
+  it('shows every leg its subject', () => {
+    for (const l of legs.filter((x) => x.subject)) {
+      let best = Infinity
+      for (let t = 0; t <= l.durationS; t += 0.5) {
+        const q = l.pose(t)
+        const s = l.subject!.at(t)
+        if (s) best = Math.min(best, offCentre(eyeOf(q), viewOf(q), s))
+      }
+      expect(best, l.id).toBeLessThan(0.35)
+    }
+  })
+
+  it('never turns the camera toward something the haze has eaten', () => {
+    for (const l of legs) {
+      for (let t = 0; t <= l.durationS; t += 1) {
+        const name = l.lookingAt(t)
+        expect(name, l.id).toBeTruthy()
+      }
+    }
+    // The haze model itself: the far side overhead is clear, a hundred kilometres along the floor is not.
+    const here = toV({ z: 270_000, a: 0, altM: 2 })
+    expect(transmission(here, toV({ z: 270_000, a: Math.PI * RADIUS_M, altM: 2 }))).toBeGreaterThan(0.7)
+    expect(transmission(here, toV({ z: 370_000, a: 0, altM: 2 }))).toBeLessThan(0.4)
+  })
+
+  it('catches a ship out in weather Tower gave no warning of', () => {
+    const l = leg('squall')
+    const sea = world.weather.events.find((e) => l.title.includes(e.kind) && e.id.includes('squall'))!
+    const wind = actualAt(world.weather, sea.target, { directionDeg: 0, speedMS: 0 }, l.hours).wind.speedMS
+    expect(wind).toBeGreaterThanOrEqual(17)
+    expect(shipsAt(world, l.hours).some((s) => s.lane.sea === sea.target)).toBe(true)
+  })
+
+  it('shows a published storm with the sea empty of ships', () => {
+    const l = leg('storm')
+    expect(shipsAt(world, l.hours).filter((s) => l.title.includes('Sunward') && s.lane.sea === 'sunward-sea')).toHaveLength(0)
+  })
+
+  it('flies round the sun by day, and to the caps by night', () => {
+    expect(leg('sun-circuit').subject!.at(0)).not.toBeNull()
+    expect(leg('far-cap').subject!.id).toBe('far-cap')
+  })
+
+  it('draws the whole train, every carriage on the track', () => {
+    const t = leg('train-ring').craft(5)
+    expect(t[0]!.kind).toBe('locomotive')
+    expect(t.filter((c) => c.kind === 'carriage')).toHaveLength(8)
+    for (let i = 1; i < t.length; i++) expect(len(sub(t[i]!.pos, t[i - 1]!.pos))).toBeLessThan(30)
+  })
+})
+
+describe('the tour outside', () => {
+  it('flies every leg', () => {
+    expect(outside.map((l) => l.id)).toEqual(EXTERIOR_LEGS.map((s) => s.id))
+  })
+
+  it('keeps every camera and craft out of the beam, off the hull, and moving smoothly', () => {
+    for (const l of outside) {
+      let prev: ReturnType<typeof l.pose> | null = null
+      for (let t = 0; t <= l.durationS; t += 0.5) {
+        const p = l.pose(t)
+        for (const v of [p.eye, p.look, p.up]) {
+          for (const x of [v.x, v.y, v.z]) expect(Number.isFinite(x), `${l.id} at ${t}s`).toBe(true)
+        }
+        const things = [p.eye, ...l.craft(t).map((c) => c.pos)]
+        for (const q of things) {
+          expect(clearOfBeam(q, 3), `${l.id} beam at ${t}s`).toBe(true)
+          if (q.z > -1 && q.z < 541) expect(radial(q), `${l.id} hull at ${t}s`).toBeGreaterThan(HULL_R + 0.05)
+        }
+        if (prev) {
+          const d0 = norm(sub(prev.look, prev.eye)), d1 = norm(sub(p.look, p.eye))
+          expect(Math.acos(Math.min(1, dot(d0, d1))), `${l.id} turn at ${t}s`).toBeLessThan(0.3)
+        }
+        prev = p
+      }
+    }
+  })
+
+  it('frames the sun clear of the collector in the grand shot', () => {
+    const l = outside.find((x) => x.id === 'grand')!
+    for (const t of [0, 7, 14]) {
+      const { eye, look, fovDeg } = l.pose(t)
+      const toSun = norm(sub({ x: 0, y: 0, z: SUN_Z }, eye))
+      // Where the line to the sun crosses the collector's plane, it is outside the disc.
+      const k = (COLLECTOR_Z - eye.z) / toSun.z
+      expect(Math.hypot(eye.x + toSun.x * k, eye.y + toSun.y * k)).toBeGreaterThan(COLLECTOR_R + 10)
+      expect(offCentre(eye, norm(sub(look, eye)), { x: 0, y: 0, z: SUN_Z })).toBeLessThan((fovDeg / 2) * (Math.PI / 180))
+      expect(offCentre(eye, norm(sub(look, eye)), { x: 0, y: 0, z: 540 })).toBeLessThan((fovDeg / 2) * (Math.PI / 180))
+    }
+  })
+})
+
+it('photographs only legs that exist', () => {
+  const tool = readFileSync('tools/screenshots.mjs', 'utf8')
+  const ids = [...tool.matchAll(/\['([a-z-]+)', \d+, '/g)].map((m) => m[1])
+  expect(ids.length).toBeGreaterThan(15)
+  for (const id of ids) expect([...INTERIOR_LEGS, ...EXTERIOR_LEGS].map((s) => s.id)).toContain(id)
+})
+
diff --git a/tools/screenshots.mjs b/tools/screenshots.mjs
index 301d11f..a9c7473 100644
--- a/tools/screenshots.mjs
+++ b/tools/screenshots.mjs
@@ -272,164 +272,51 @@ const SHOTS = [
       await settle(page, 1400)
     },
   },
-  // The scenic tour's scenes (src/views/scenic.ts), each held at one moment. The scenes are
-  // worked out from the world, so these follow it as it is edited; a test checks every id.
-  {
-    id: 'tour-falls',
-    caption: 'Scenic tour — a helicopter circling the Long Water\'s 680 m fall, leaning antispinward as it drops',
-    on: ['desktop'],
-    scene: 'falls',
-    run: async (page) => {
-      await openView(page, 'interior')
-      const title = await page.evaluate(() => window.tower.showScene('falls', 10))
-      if (!title) throw new Error('no scene falls in this world')
-      await settle(page, 1400)
-    },
-  },
-  {
-    id: 'tour-ship',
-    caption: 'Scenic tour — following a ship round the Sunward Sea',
-    on: ['desktop'],
-    scene: 'ship',
-    run: async (page) => {
-      await openView(page, 'interior')
-      const title = await page.evaluate(() => window.tower.showScene('ship', 12))
-      if (!title) throw new Error('no scene ship in this world')
-      await settle(page, 1400)
-    },
-  },
-  {
-    id: 'tour-summit',
-    caption: 'Scenic tour — round Snowcrown\'s summit, 4,300 m',
-    on: ['desktop'],
-    scene: 'summit',
+  // The scenic tour's legs (src/tour/), each held at one moment. The legs are worked out
+  // from the world, so these follow it as it is edited; a test checks every id. A held leg is
+  // planned from a fixed seed, so it is the same flight on every run. Each is at least twelve
+  // seconds long, so every moment here is inside it.
+  ...[
+    ['falls', 8, 'a helicopter circling the Long Water\'s 680 m fall, leaning antispinward as it drops'],
+    ['falls-glide', 7, 'a paraglider past the same fall'],
+    ['ship', 9, 'following a ship round the Sunward Sea'],
+    ['squall', 9, 'a ship caught out in a squall Tower gave no warning of'],
+    ['summit', 8, 'an airship off Snowcrown, the highest summit in Tower'],
+    ['ridge', 7, 'a paraglider ridge-soaring on a summit\'s flank'],
+    ['thermal', 9, 'a glider climbing in a thermal over the Dry Reach'],
+    ['mesas', 10, 'a helicopter past the mesas of the Dry Reach'],
+    ['giants', 10, 'a helicopter under the giants of the Tall Wood'],
+    ['canopy', 8, 'a light aircraft low over Greatwood'],
+    ['deep', 9, 'a seaplane out from the Capital over The Deep, the far shore climbing the sky'],
+    ['girdle', 8, 'an airliner over the Girdle, flying antispinward, and lighter for it'],
+    ['climb', 11, 'a giant airship climbing out of the Capital: the world curls up overhead'],
+    ['sun-circuit', 8, 'a giant airship round the sun, outside the beam'],
+    ['far-cap', 9, 'toward the far cap by night, the one sky that turns'],
+    ['train-narrows', 6, 'a train on the Axial Line through The Narrows, on its viaduct'],
+    ['train-ring', 6, 'a train on the Uplands Ring, round the world'],
+  ].map(([scene, t, caption]) => ({
+    id: `tour-${scene}`, caption: `Scenic tour — ${caption}`, on: ['desktop'], scene,
     run: async (page) => {
       await openView(page, 'interior')
-      const title = await page.evaluate(() => window.tower.showScene('summit', 8))
-      if (!title) throw new Error('no scene summit in this world')
+      const title = await page.evaluate(([id, at]) => window.tower.showScene(id, at), [scene, t])
+      if (!title) throw new Error(`no leg ${scene} in this world`)
       await settle(page, 1400)
     },
-  },
-  {
-    id: 'tour-river',
-    caption: 'Scenic tour — down the Long Water to its 167 m fall into The Deep',
-    on: ['desktop'],
-    scene: 'river',
+  })),
+  ...[
+    ['grand', 5, 'Tower, its collector and the sun in one frame'],
+    ['docking', 12, 'a spacecraft docking on the shell, the stars wheeling past'],
+    ['far-cap-peek', 12, 'looking in through the far cap at the world inside'],
+    ['hull-skim', 7, 'skimming the hull while Tower turns under the ship'],
+  ].map(([scene, t, caption]) => ({
+    id: `tour-${scene}`, caption: `Scenic tour, outside — ${caption}`, on: ['desktop'], scene,
     run: async (page) => {
-      await openView(page, 'interior')
-      const title = await page.evaluate(() => window.tower.showScene('river', 16))
-      if (!title) throw new Error('no scene river in this world')
-      await settle(page, 1400)
-    },
-  },
-  {
-    id: 'tour-squall',
-    caption: 'Scenic tour — a ship caught out by a gale Tower gave no warning of',
-    on: ['desktop'],
-    scene: 'squall',
-    run: async (page) => {
-      await openView(page, 'interior')
-      const title = await page.evaluate(() => window.tower.showScene('squall', 12))
-      if (!title) throw new Error('no scene squall in this world')
-      await settle(page, 1400)
-    },
-  },
-  {
-    id: 'tour-mesas',
-    caption: 'Scenic tour — past the mesas of the Dry Reach, strata lining up across all of them',
-    on: ['desktop'],
-    scene: 'mesas',
-    run: async (page) => {
-      await openView(page, 'interior')
-      const title = await page.evaluate(() => window.tower.showScene('mesas', 16))
-      if (!title) throw new Error('no scene mesas in this world')
-      await settle(page, 1400)
-    },
-  },
-  {
-    id: 'tour-pan',
-    caption: 'Scenic tour — over a salt pan, ringed by the shorelines of a lake that never stood',
-    on: ['desktop'],
-    scene: 'pan',
-    run: async (page) => {
-      await openView(page, 'interior')
-      const title = await page.evaluate(() => window.tower.showScene('pan', 26))
-      if (!title) throw new Error('no scene pan in this world')
-      await settle(page, 1400)
-    },
-  },
-  {
-    id: 'tour-giants',
-    caption: 'Scenic tour — under the giants of the Tall Wood, 100 m of them',
-    on: ['desktop'],
-    scene: 'giants',
-    run: async (page) => {
-      await openView(page, 'interior')
-      const title = await page.evaluate(() => window.tower.showScene('giants', 14))
-      if (!title) throw new Error('no scene giants in this world')
-      await settle(page, 1400)
-    },
-  },
-  {
-    id: 'tour-canopy',
-    caption: 'Scenic tour — low over Greatwood, closed canopy and the meadows left open in it',
-    on: ['desktop'],
-    scene: 'canopy',
-    run: async (page) => {
-      await openView(page, 'interior')
-      const title = await page.evaluate(() => window.tower.showScene('canopy', 18))
-      if (!title) throw new Error('no scene canopy in this world')
-      await settle(page, 1400)
-    },
-  },
-  {
-    id: 'tour-narrows',
-    caption: 'Scenic tour — through The Narrows, the one gap in all three ridges',
-    on: ['desktop'],
-    scene: 'narrows',
-    run: async (page) => {
-      await openView(page, 'interior')
-      const title = await page.evaluate(() => window.tower.showScene('narrows', 15))
-      if (!title) throw new Error('no scene narrows in this world')
-      await settle(page, 1400)
-    },
-  },
-  {
-    id: 'tour-deep',
-    caption: 'Scenic tour — out from the Capital over The Deep, the far shore climbing the sky',
-    on: ['desktop'],
-    scene: 'deep',
-    run: async (page) => {
-      await openView(page, 'interior')
-      const title = await page.evaluate(() => window.tower.showScene('deep', 14))
-      if (!title) throw new Error('no scene deep in this world')
-      await settle(page, 1400)
-    },
-  },
-  {
-    id: 'tour-storm',
-    caption: 'Scenic tour — the storm Tower published for the Sunward Sea, and every ship in harbour',
-    on: ['desktop'],
-    scene: 'storm',
-    run: async (page) => {
-      await openView(page, 'interior')
-      const title = await page.evaluate(() => window.tower.showScene('storm', 10))
-      if (!title) throw new Error('no scene storm in this world')
-      await settle(page, 1400)
-    },
-  },
-  {
-    id: 'tour-girdle',
-    caption: 'Scenic tour — nine kilometres over the Girdle, the range climbing round the world ahead',
-    on: ['desktop'],
-    scene: 'girdle',
-    run: async (page) => {
-      await openView(page, 'interior')
-      const title = await page.evaluate(() => window.tower.showScene('girdle', 10))
-      if (!title) throw new Error('no scene girdle in this world')
+      await openView(page, 'exterior')
+      const title = await page.evaluate(([id, at]) => window.towerOutside.showScene(id, at), [scene, t])
+      if (!title) throw new Error(`no leg ${scene} outside`)
       await settle(page, 1400)
     },
-  },
+  })),
   {
     id: 'interior-overhead',
     caption: 'Interior — looking up: the far side 100 km overhead is clearer than ground 100 km away',
diff --git a/world-generation.md b/world-generation.md
index 236c212..e78696f 100644
--- a/world-generation.md
+++ b/world-generation.md
@@ -1243,41 +1243,60 @@ draws in about 1.2 s.
 
 ---
 
-## 19. The scenic tour, as built (D-068)
-
-Added between phases 4 and 5, at the author's request: once the world had particular
-things in it — a fall, a ship, a storm — standing still and dragging was a poor way to
-find them, and a screenshot at a fixed place was a poor way to show them.
-
-- **A scene is thirty seconds of one camera move**, worked out from the compiled world
-  (`views/scenic.ts`): a helicopter's arc, a bird's flight along a line, or a chase camera
-  on a ship. Scenes find their subjects — the tallest fall, the highest summit, the biggest
-  ship at sea, a published storm, a squall Tower gave no warning of — so they follow the
-  world as it is edited, and a scene whose subject is gone drops out of the tour.
-- **The scenes**, in order: circling the Long Water's 678 m fall from below its lip;
-  following a ship round the Sunward Sea; round Snowcrown; down the Long Water to its
-  167 m fall into The Deep; a ship caught out in a squall; through The Narrows; out from
-  the Capital over The Deep; the published storm on the Sunward Sea, with nobody out; and
-  nine kilometres over the Girdle.
-- **Flights hold a steady clearance** over the highest ground within 700 m, smoothed, so
-  the camera does not bob with every hill, and look at the ground a set distance ahead, so
-  the view tips down as land falls away. Orbits never drop below a clearance over the
-  ground. Aim is taken on the curved floor (`lookAt`): 6 km around, the floor has risen
-  360 m in the eye's frame.
-- **Each scene sets the clock** and runs it at real time: ships and weather are functions
-  of the hour, and the storm scenes need theirs. The observer's own clock comes back when
-  the tour stops; where they are does not, so stopping leaves them at the view they liked.
-- **Between scenes the view fades to black and waits for the ground** — up to four
-  seconds — so a scene opens on its terrain rather than on the coarse tiles of the moment.
-- **Controls**: *Scenic tour* in the interior's panel starts and stops it; **N** skips to
-  the next scene; **Esc**, a drag, the wheel or a flight key stops it where it is.
-- **The screenshot tour photographs the scenes**, each held at one moment
-  (`tower.showScene(id, t)`), in place of four hand-placed shots. A held scene stops the
-  water's clock as well as the world's, or waves and foam made every run diverge; and
-  `--only <ids>` retakes a few shots rather than all twenty-three. A test checks that every
-  scene the screenshot tool names exists, and flies every scene over the real world:
-  cameras stay in the world and above the ground, move and turn smoothly, and keep a
-  followed ship in the middle of the frame.
+## 19. The scenic tour, as built (D-068, rebuilt by D-085)
+
+D-068 added a tour of nine thirty-second scenes, once the world had particular things in it
+worth finding. D-085 replaced it, at the author's request, with the world's own flyers:
+automatic flight, inside and out, a new craft every twelve to eighteen seconds, in an order
+dealt at random, with the camera pointed at whatever is worth seeing.
+
+- **A leg is a craft on a path, and a camera on the craft** (`tour/interior-legs.ts`,
+  `tour/exterior-legs.ts`). Twenty-six legs inside and nine outside, each finding its
+  subject in the compiled world — the tallest fall, the highest summit, the biggest ship at
+  sea, a squall Tower gave no warning of, the broadest stand of mesas, the tallest giant, the
+  sun where the clock has it — so they follow the world as it is edited, and a leg whose
+  subject is gone drops out. The craft exists for its leg (D-085); only the ships are the
+  world's own, followed where they are.
+- **The craft**: paragliders, gliders, light aircraft and seaplanes, helicopters, airliners,
+  airships, giant airships near the axis, trains, and outside, spacecraft (`views/craft.ts`).
+  A winged craft banks into its turns by the lift the turn needs against the local gravity,
+  so the same turn banks hard near the axis and barely at the floor; airships, ships and
+  trains keep level.
+- **Two rigs.** In the craft, looking where the director says, the horizon tilting with the
+  bank when looking ahead; or chasing it, on the far side of the craft from what the
+  director is looking at, so the two share the frame, walked slowly round it over the leg.
+  A chase camera is never under 15 m over the ground.
+- **The director** (`tour/director.ts`) scores every point of interest from the craft,
+  twice a second: weight × transmission² × apparent size × how far off the nose, with the
+  transmission integrated through the density profile as the renderer's haze is, and the
+  sightline sampled against the ground for anything a hill could hide. The leg's subject is
+  boosted fourfold. A subject holds the camera at least 4.5 s, bores it after 7, and a turn
+  to the next is eased over 3 s and never swings faster than 0.7 rad/s. It is all worked out
+  when a leg is first played, so a pose is a pure function of time.
+- **Flights hold a steady clearance** over the highest ground within 700 m, smoothed, as
+  D-068's did. Aim is taken in the tower's frame, where a straight line is straight: 6 km
+  around, the floor has risen 360 m in the eye's frame.
+- **Each leg sets the clock** and runs it at its own rate — real time, or a labelled
+  time-lapse for the slow giants. The observer's own clock comes back when the tour stops;
+  where they are does not, so stopping leaves them at the view they liked. Outside, the hull
+  turns at the leg's rate whatever the Speed slider says.
+- **Between legs the view fades to black and waits for the ground** — up to four seconds —
+  so a leg opens on its terrain rather than on the coarse tiles of the moment
+  (`views/tour-player.ts`).
+- **The deal** (`tour/plan.ts`): every leg once before any twice, no two of a kind of craft
+  in a row, two legs about the same subject kept apart where they can be, and a new deck never
+  opening on the craft the last one closed on.
+- **Controls**: *Scenic tour* in the interior's panel, and in the exterior's, starts and stops
+  it; **N** skips to the next leg; **Esc**, a drag, the wheel or a flight key stops it where
+  it is.
+- **The screenshot tour photographs the legs**, each held at one moment
+  (`tower.showScene(id, t)` inside, `towerOutside.showScene(id, t)` outside) and planned
+  from a fixed seed, so a leg is the same flight every run. A test checks that every leg the
+  screenshot tool names exists, and flies every leg over the real world, across twenty
+  seeds while it was built: cameras stay in the world, above the ground and out of the beam,
+  move and turn smoothly, keep a chased craft in the frame and bring each leg's subject into
+  the middle of it; outside, nothing touches the hull or the beam's cone, and the grand shot
+  keeps the sun clear of the collector.
 
 ---