fuzzyworld

tower / commit

feat(exterior): try piping, Tower as industrial plant (D-077)

6368edcf8e99c8c1f89e9f692bf9642a6173fbec · Pavel Safronov · 2026-09-27 22:05:58 -0700 · parents 908833aa4c2c

Claude Code usage: this commit ~183.6K out / ~29.0M cached · cost unknown (no per-model trailer) · session 3 tally 500 in / 353.6K out / 46.2M cached · usage by commit and session

feat(exterior): try piping, Tower as industrial plant (D-077)

A fourth Hull reading, labelled an experiment: dusty panelled plating,
a band with hoop pipes every ~36 km, pipe runs between them, blocks of
plant, vents whose grilles and plumes fire at random, and thruster
pods at each end that burn rarely, with long flaring flames that light
the hull. Nothing sits inside the skin, so the windows still see only
Tower's inside.

Usage: 0.5K in / 353.6K out / 46.2M cached (session tally)
rtk:   963.1K saved, 15.9% over 2845 cmds (global/lifetime)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
 README.md              |  14 +-
 decisions.md           |  54 +++-
 design.md              |  12 +-
 index.html             |   6 +-
 src/ui/app.ts          |   2 +-
 src/views/exterior.ts  |  17 +-
 src/views/pipe-hull.ts | 656 +++++++++++++++++++++++++++++++++++++++++++++++++
 src/views/rock-hull.ts |   9 +-
 test/pipe-hull.test.ts |  99 ++++++++
 9 files changed, 852 insertions(+), 17 deletions(-)

diff --git a/README.md b/README.md
index 49f1421..3dfddc0 100644
--- a/README.md
+++ b/README.md
@@ -278,16 +278,22 @@ cap looks at the point opposite it, and the sky behind Tower turns once a year w
 clock's date; from outside, ecliptic north is up. `node tools/bright-stars.mjs` rebuilds
 the catalogue file (`src/data/bright-stars.json`) from CDS.
 
-The hull reads three ways and the **Hull** switch picks one; it opens on True and
+The hull reads four ways and the **Hull** switch picks one; it opens on True and
 remembers whichever you last chose. **True** is the outer
 skin: bare structure, and both caps real windows — look in through either and the
 land is actually there, hazed exactly as the interior view hazes it and receding
 540 km down the tube. It is the only line
 of sight into Tower that is not a diagram. **X-ray** wraps the terrain onto the
 outside instead, which is a diagram of what is inside rather than a view of it, and
-useful precisely because the wall is opaque. **Rock** is an experiment, not canon: the
-hull dressed as an asteroid, a cratered, lumpy shell of rock a few kilometres thick laid
-over the skin, with the windows left clear. The active view is remembered and travels in the permalink, so
+useful precisely because the wall is opaque. **Rock** and **Piping** are experiments, not
+canon. Rock dresses the hull as an asteroid: a cratered, lumpy shell of rock a few
+kilometres thick laid over the skin, with the windows left clear. Piping dresses it as
+industrial plant: panelled plating, a band with hoop pipes every ~36 km, bundles of pipes
+0.6–2.2 km across running between them, and blocks of plant. Vents open at random, each
+on its own clock: grilles flare hot and fall back to an ember, and exhaust shoots out and
+fades. Near each end are thruster pods — what holds Tower's attitude — and every so often
+one fires a long flame that fans out into space and lights the plating round it. All of it
+wears a layer of dust, thickest on whatever faces outward. 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
 1x to 1200x real time, and a point picked off the turning hull still comes back in
 canon coordinates.
diff --git a/decisions.md b/decisions.md
index 974fd87..b0f9dd2 100644
--- a/decisions.md
+++ b/decisions.md
@@ -1432,7 +1432,7 @@ its own; neither setting needed the beam shader touched.
 
 ## D-047 — The exterior hull reads two ways, and they are different claims
 
-*Amended by D-076: a third reading, Rock, is on trial beside True and X-ray.*
+*Amended by D-076 and D-077: Rock and Piping are on trial beside True and X-ray.*
 
 The exterior had a "Show terrain" checkbox that wrapped the raster onto the hull.
 It was doing something stronger than a checkbox admits: from outside Tower is
@@ -2545,3 +2545,55 @@ grows wider than the detail. Seeded, so it is the same rock on every load. Click
 on the smooth skin, hidden, so a picked point is the canon point beneath the rock. The rock
 is lighter than a real carbonaceous asteroid's charcoal, under the same licence as the fill
 lights.
+
+## D-077 — Trying other outsides: piping
+
+**Asked for** (author, 2026-09-27): "something industrial like the Alien movies, with
+panels and pipes and exhaust vents, only on a huge scale." The second experiment after
+D-076's rock, and like it a Hull reading labelled as an experiment, not canon.
+
+**What is on the skin** (`src/views/pipe-hull.ts`):
+- **Plating**, in the shader of the hull cylinder itself: 40 panels round by 8 km along,
+  some split in two or four, each its own shade, some ribbed, with grime streaked along the
+  axis. The seams are bump-mapped grooves that widen with the pixel, so they fade with
+  distance instead of aliasing. 40 round, so the plating closes on itself exactly.
+- **Bands** every 30–42 km: a collar 2.6 km wide and 1.1 km proud, with a hoop pipe either
+  side. They divide the 540 km into stations, as a ship's frames do.
+- **Pipe runs**: fourteen lanes of two to five pipes, 0.6–2.2 km across, running from band
+  to band on brackets, and skipping some stations so runs start and stop.
+- **Plant**: 170 clusters of blocks, the odd one a tower up to 6 km, kept off the pipe
+  lanes and the bands.
+- **Vents**: 64 housings, each with a grille and a plume that fire **at random**, on
+  separate clocks (author: "the plumes are constant, let's make them fire at random", and
+  the same for the lights). A grille is lit 1–7 s at a time and an ember between; a plume
+  shoots out for 1.5–4 s, then is gone for 2–12. There is no air to hold a plume out here;
+  it is the look asked for, under the same licence as the beam's rays.
+- **Thrusters** ("larger fires, light them less often"): six pods at each end, in 12 km
+  kept clear for them, each with four bells — out, spinward, antispinward, and along the
+  axis away from the world. They are what §1.6's never-ending reorientation would need. A pod
+  burns one bell at a time for 2–5 s, then waits 25–80 s. The flame is 28–42 km long and fans
+  out as it goes, since exhaust in vacuum has nothing to hold it together; the spread grows as
+  the square of the distance, so a sideways flame stays clear of the plating it runs along.
+  Two point lights follow the two brightest burns and light the hull round them — one per
+  bell would recompile every material for 48 lights, and more than two at once is rare.
+
+**Dust** (author: "it's too shiny, let's add a nice layer of dust"). Every piping material —
+plating, bands, pipes, blocks, pods, bells — goes through `dusty()`, which lays a dust
+colour over the surface and makes it fully matte and non-metallic where it lies. Bare
+metal under the fill lights read as chrome. Dust is thickest on faces turned outward, the
+way everything on a spun hull is thrown, thinner on the sides and nearly absent
+underneath, and broken into drifts by noise. It is worked out in the hull's frame from the
+vertex, so instanced parts get it too.
+
+**Timing is on the CPU.** A small scheduler, `Bursts`, holds each emitter's on/off clock and
+a level that rises over an attack and falls over a release, so nothing snaps on or off. The
+shaders only read that level per instance. The lights need to know which bells are burning,
+and keeping one schedule in JavaScript is simpler than mirroring a hash in GLSL. It is
+tested in test/pipe-hull.test.ts, including a long pause such as a hidden tab.
+
+**What it must not do.** Nothing is inside the skin (test/pipe-hull.test.ts checks every
+vertex), and the bands are open drums with ring faces, never closed cylinders, whose
+discs would stand inside Tower and be seen through the windows. The plating is two-sided
+so that, from inside the tube, it hides the far side's parts wherever the ground has not
+been generated yet. All the parts are instanced: five draw calls for a few thousand pieces.
+Seeded, so it is the same plant on every load, and built only when first chosen.
diff --git a/design.md b/design.md
index 7ea90d5..954554e 100644
--- a/design.md
+++ b/design.md
@@ -841,7 +841,13 @@ hull — and there is no daylight to adapt to, so it is drawn at full gain (D-06
 **The hull can be tried in other dress** (D-076). **Rock** is the first: a cratered, lumpy
 shell a few kilometres thick laid over the metal skin, never into it, with the windows
 left clear. It is an experiment in how Tower might look, not canon — the mass alone would
-be a question for §1.2 — and it sits in the Hull switch beside True and X-ray.
+be a question for §1.2 — and it sits in the Hull switch beside True and X-ray. **Piping**
+is the second (D-077): the skin plated in panels, banded every ~36 km with hoop pipes either
+side, pipe bundles running between the bands on brackets, clustered blocks of plant, vents
+whose grilles and plumes switch on and off at random, and thruster pods at each end that
+fire rarely, with long flaring flames and real light on the hull. Everything is dusted,
+matte where the dust lies, so the metal does not read as chrome. Every part is outside the skin, so the windows
+still see only Tower's inside.
 
 Everything in the scene is captioned — collector, beam, both caps — with screen-space
 labels. A dark disc and a cone of light are not self-explanatory, and the collector
@@ -1173,6 +1179,6 @@ Things decided to be worth coming back to, not scheduled.
   antisolar point walks a degree a day. The placed star in `STAR_GLSL` is **not canon**
   and stays only until this is revisited: a motionless point, a beacon Tower hangs there,
   or nothing.
-- **The exterior's dress** (D-076). Rock is an experiment, labelled as such in the Hull
-  switch, and more are to come. Pick one, combine them, or keep the bare skin — and if a
+- **The exterior's dress** (D-076, D-077). Rock and Piping are experiments, labelled as
+  such in the Hull switch. Pick one, combine them, or keep the bare skin — and if a
   heavy one stays, answer for its mass.
diff --git a/index.html b/index.html
index 0d30543..f837bd9 100644
--- a/index.html
+++ b/index.html
@@ -55,13 +55,15 @@
           <select id="f-ext-view">
             <option value="true" selected>True — what Tower looks like</option>
             <option value="rock">Rock — an asteroid shell (experiment)</option>
+            <option value="pipes">Piping — plating, pipes and vents (experiment)</option>
             <option value="xray">X-ray — the inner surface projected out</option>
           </select></label>
         <p class="hint">True shows the outer skin: bare hull, and a window at each
           cap — look in through either and the land is really there, hazed as it is
           from inside, 540 km deep. X-ray wraps the terrain onto the outside instead,
-          which is a diagram of what is inside rather than a view of it. Rock is a trial
-          outside, not canon: the hull dressed as an asteroid, cratered and lumpy.</p>
+          which is a diagram of what is inside rather than a view of it. Rock and Piping
+          are trial outsides, not canon: the hull dressed as an asteroid, or as industrial
+          plant — plating, pipe runs, venting exhausts and thrusters that fire now and then.</p>
         <label class="row"><input id="f-spin" type="checkbox" checked /> Spin</label>
         <label>Speed <span id="speed-value" class="dim"></span>
           <input id="f-speed" type="range" min="0" max="4" step="1" value="2" /></label>
diff --git a/src/ui/app.ts b/src/ui/app.ts
index 936e6c5..fb2b473 100644
--- a/src/ui/app.ts
+++ b/src/ui/app.ts
@@ -661,7 +661,7 @@ export class App {
     // first built — so a view opened on load starts in the remembered reading.
     try {
       const stored = localStorage.getItem(HULL_KEY)
-      if (stored === 'true' || stored === 'rock' || stored === 'xray') ext.value = stored
+      if (stored === 'true' || stored === 'rock' || stored === 'pipes' || stored === 'xray') ext.value = stored
     } catch { /* unreadable storage just means the default, true */ }
     ext.onchange = () => {
       if (this.exterior) this.exterior.hullMode = ext.value as HullMode
diff --git a/src/views/exterior.ts b/src/views/exterior.ts
index d3233b3..f7a928c 100644
--- a/src/views/exterior.ts
+++ b/src/views/exterior.ts
@@ -28,6 +28,7 @@ import { clock } from '../world/clock.js'
 import { skyAt } from '../world/sky.js'
 import { skyMatrix, starTexture } from './stars.js'
 import { buildRockHull } from './rock-hull.js'
+import { type PipeHull, buildPipeHull } from './pipe-hull.js'
 
 const KM = 1000
 const R = RADIUS_M / KM
@@ -109,10 +110,10 @@ void main() {
 `
 
 /**
- * How the hull is drawn (D-047, D-076): bare metal, the rock experiment, or the x-ray
- * of the inside wrapped onto the smooth skin.
+ * How the hull is drawn (D-047, D-076, D-077): bare metal, the rock or piping
+ * experiments, or the x-ray of the inside wrapped onto the smooth skin.
  */
-export type HullMode = 'true' | 'rock' | 'xray'
+export type HullMode = 'true' | 'rock' | 'pipes' | 'xray'
 
 export interface ExteriorCallbacks {
   onPickPoint: (p: SurfacePoint) => void
@@ -141,6 +142,8 @@ export class ExteriorView {
   private _hullMode: HullMode = 'true'
   /** The rock shell (D-076), built the first time it is asked for. */
   private rock: Mesh | null = null
+  /** The piping (D-077), built the first time it is asked for, like the rock. */
+  private pipes: PipeHull | null = null
   /** The metal skin past each end of the world, which the rock replaces. */
   private metalEnds: Mesh[] = []
   private raycaster = new Raycaster()
@@ -320,8 +323,15 @@ export class ExteriorView {
       this.rock = buildRockHull()
       this.tower.add(this.rock)
     }
+    if (mode === 'pipes' && !this.pipes) {
+      this.pipes = buildPipeHull()
+      this.tower.add(this.pipes.group)
+    }
     const rock = mode === 'rock'
     if (this.rock) this.rock.visible = rock
+    if (this.pipes) this.pipes.group.visible = mode === 'pipes'
+    // Piping keeps the cylinder and plates it; every other reading draws it as hullMat.
+    this.hull.material = mode === 'pipes' && this.pipes ? this.pipes.plating : this.hullMat
     // Hidden, the smooth skin still takes the clicks: raycasting ignores visibility, and a
     // point on the cylinder is the canon point whatever is heaped on top of it.
     this.hull.visible = !rock
@@ -734,6 +744,7 @@ export class ExteriorView {
       this.lastFrame = now
       if (this.spinning) this.phase = (this.phase + OMEGA_RAD_S * dt * this.timeScale) % (2 * Math.PI)
       this.tower.rotation.z = this.phase
+      this.pipes?.update(now / 1000)
       this.controls.update()
       /**
        * The interior model's shaders integrate haze in the tower's own frame, and
diff --git a/src/views/pipe-hull.ts b/src/views/pipe-hull.ts
new file mode 100644
index 0000000..ab5de9e
--- /dev/null
+++ b/src/views/pipe-hull.ts
@@ -0,0 +1,656 @@
+/**
+ * The piping skin: an exterior experiment, Tower as industrial plant (D-077). Not canon.
+ *
+ * The author asked for "something industrial like the Alien movies, with panels and pipes
+ * and exhaust vents, only on a huge scale". So the bare skin becomes plating, and on it:
+ *
+ * - **Bands**: collars of structure round the whole hull every ~36 km, each flanked by a
+ *   pair of hoop pipes. They break the 540 km into stations, as a ship's frames do.
+ * - **Pipe runs**: bundles of two to five pipes along the axis between bands, a few hundred
+ *   metres to a kilometre and a half across, held off the plating on brackets.
+ * - **Greebles**: blocks, clustered like plant rooms, now and then a tower.
+ * - **Vents**: housings with a grille that glows hot, and a plume of exhaust above — each
+ *   switching on and off at random, the grille and the plume independently.
+ * - **Thrusters**: pods of four bells near each end, pointing out, spinward, antispinward
+ *   and along the axis — what holds Tower's attitude (§1.6). They fire rarely, one bell of a
+ *   pod at a time, with a long flame and real light on the plating round them.
+ *
+ * All of it wears a layer of dust, thickest on what faces outward (`dusty`).
+ *
+ * Everything sits outside the skin, R + HULL_D, and nothing is drawn inside it, so the
+ * windows still look into Tower and not into scaffolding. It is all instanced, so the few
+ * thousand parts cost a handful of draw calls. Seeded: the same plant on every load.
+ */
+import {
+  AdditiveBlending, BoxGeometry, type BufferGeometry, Color, ConeGeometry, CylinderGeometry,
+  DoubleSide, Group, InstancedBufferAttribute, InstancedMesh, Matrix4, Mesh, MeshStandardMaterial,
+  PlaneGeometry, PointLight, RingGeometry, ShaderMaterial, TorusGeometry, Vector3,
+} from 'three'
+import { HULL_DEPTH_M, LENGTH_M, RADIUS_M } from '../canon/constants.js'
+import { mulberry32 } from '../terrain/noise.js'
+import { BUMP_GLSL } from './rock-hull.js'
+
+const KM = 1000
+const R = RADIUS_M / KM
+const L = LENGTH_M / KM
+const HULL_D = HULL_DEPTH_M / KM
+/** The skin everything is bolted to. */
+const SKIN = R + HULL_D
+const SEED = 0x9177
+
+/** Panels round the circumference: a whole number, so the plating closes on itself. */
+const PANELS_AROUND = 40
+/** Panel length along the axis, km. */
+const PANEL_ALONG = 8
+
+const ZAXIS = new Vector3(0, 0, 1)
+/** Kilometres at each end kept for the thrusters: no bands, plant or vents. */
+const THRUSTER_ZONE = 12
+
+export interface PipeHull {
+  /** The parts on the skin; the plating itself is `plating`, a material for the hull. */
+  group: Group
+  plating: MeshStandardMaterial
+  /** Real seconds: steps the vents, the grilles and the thrusters. */
+  update(t: number): void
+}
+
+/** How a set of emitters switches: on for a while, off for a while, both at random. */
+export interface BurstTiming {
+  /** Seconds on, min and max. */
+  on: [number, number]
+  /** Seconds off, min and max. */
+  off: [number, number]
+  /** Seconds to come up to full, and to die away after. */
+  attack: number
+  release: number
+}
+
+/**
+ * Emitters firing at random, each on its own clock. `level[i]` is 0..1: rising over
+ * `attack` when it lights, falling over `release` when it goes out, so nothing snaps. The
+ * first gap is drawn from the whole off range, so they do not all start in step.
+ */
+export class Bursts {
+  readonly level: Float32Array
+  private lit: Uint8Array
+  private changedAt: Float64Array
+  private next: Float64Array
+  private from: Float32Array
+
+  constructor(readonly count: number, private timing: BurstTiming, private rand: () => number) {
+    this.level = new Float32Array(count)
+    this.lit = new Uint8Array(count)
+    this.changedAt = new Float64Array(count)
+    this.next = new Float64Array(count).fill(NaN)
+    this.from = new Float32Array(count)
+  }
+
+  step(t: number): void {
+    const { on, off, attack, release } = this.timing
+    const pick = ([a, b]: [number, number]) => a + (b - a) * this.rand()
+    for (let i = 0; i < this.count; i++) {
+      if (Number.isNaN(this.next[i]!)) this.next[i] = t + off[1] * this.rand()
+      // A long pause (a hidden tab) can pass several changes; take them in turn.
+      while (t >= this.next[i]!) {
+        this.from[i] = this.level[i]!
+        this.changedAt[i] = this.next[i]!
+        this.lit[i] = this.lit[i] ? 0 : 1
+        this.next[i] = this.next[i]! + pick(this.lit[i] ? on : off)
+      }
+      const since = t - this.changedAt[i]!
+      this.level[i] = this.lit[i]
+        ? Math.min(1, this.from[i]! + since / attack)
+        : Math.max(0, this.from[i]! - since / release)
+    }
+  }
+}
+
+/** A per-instance level the emitter shaders read, updated from a `Bursts` every frame. */
+function levels(geometry: BufferGeometry, count: number): InstancedBufferAttribute {
+  const a = new InstancedBufferAttribute(new Float32Array(count), 1)
+  geometry.setAttribute('aLevel', a)
+  return a
+}
+
+/** Outward normal and spinward tangent at angle theta. */
+const frame = (theta: number) => ({
+  n: new Vector3(Math.cos(theta), Math.sin(theta), 0),
+  t: new Vector3(-Math.sin(theta), Math.cos(theta), 0),
+})
+
+export function buildPipeHull(): PipeHull {
+  const rand = mulberry32(SEED)
+  const range = (a: number, b: number) => a + (b - a) * rand()
+  const group = new Group()
+  const m = new Matrix4()
+  const s = new Vector3()
+
+  // ------------------------------------------------------------------ bands
+  const stations: number[] = []
+  // Clear of the last 16 km at each end, which is the thrusters'.
+  for (let z = 14; z < L - 16; z += range(30, 42)) stations.push(z)
+  const bandMat = metal(0x5d605c, 0.55, 0.5)
+  const hoopMat = metal(0x4c4f4d, 0.4, 0.65)
+  const BAND_W = 2.6
+  const BAND_H = 1.1
+  for (const z of stations) {
+    // An open drum and its two end faces: never a closed cylinder, whose discs would
+    // stand inside Tower and be seen through the windows.
+    const drum = new CylinderGeometry(SKIN + BAND_H, SKIN + BAND_H, BAND_W, 256, 1, true)
+    drum.rotateX(Math.PI / 2)
+    drum.translate(0, 0, z)
+    group.add(new Mesh(drum, bandMat))
+    for (const side of [-1, 1]) {
+      const face = new RingGeometry(SKIN, SKIN + BAND_H, 256)
+      face.translate(0, 0, z + (side * BAND_W) / 2)
+      group.add(new Mesh(face, bandMat))
+      const tube = 0.42
+      const hoop = new TorusGeometry(SKIN + tube + 0.05, tube, 10, 320)
+      hoop.translate(0, 0, z + side * (BAND_W / 2 + 1.4))
+      group.add(new Mesh(hoop, hoopMat))
+    }
+  }
+
+  // ------------------------------------------------------------- pipe runs
+  interface Lane { theta: number; halfWidth: number }
+  const lanes: Lane[] = []
+  const pipes: { theta: number; z0: number; z1: number; r: number; lift: number }[] = []
+  for (let i = 0; i < 14; i++) {
+    const theta = range(0, 2 * Math.PI)
+    const count = 2 + Math.floor(rand() * 4)
+    const radii = Array.from({ length: count }, () => range(0.3, 1.1))
+    const width = radii.reduce((a, r) => a + 2 * r + 0.25, 0)
+    lanes.push({ theta, halfWidth: width / 2 + 0.6 })
+    // Each lane runs through some stations and not others, so the runs start and stop.
+    for (let k = 0; k + 1 < stations.length; k++) {
+      if (rand() < 0.3) continue
+      const z0 = stations[k]! + BAND_W / 2 + 0.3
+      const z1 = stations[k + 1]! - BAND_W / 2 - 0.3
+      let across = -width / 2
+      for (const r of radii) {
+        across += r + 0.125
+        pipes.push({ theta: theta + across / SKIN, z0, z1, r, lift: 0.35 })
+        across += r + 0.125
+      }
+    }
+  }
+  const pipeMesh = new InstancedMesh(new CylinderGeometry(1, 1, 1, 14, 1), metal(0xffffff, 0.5, 0.6), pipes.length)
+  const tint = new Color()
+  pipes.forEach((p, i) => {
+    const { n, t } = frame(p.theta)
+    // Cylinder y along the axis; (n, z, −t) keeps the basis right-handed.
+    m.makeBasis(n, ZAXIS, t.clone().negate())
+    m.scale(s.set(p.r, p.z1 - p.z0, p.r))
+    m.setPosition(n.multiplyScalar(SKIN + p.lift + p.r).addScaledVector(ZAXIS, (p.z0 + p.z1) / 2))
+    pipeMesh.setMatrixAt(i, m)
+    pipeMesh.setColorAt(i, tint.setHSL(range(0.08, 0.14), range(0.02, 0.12), range(0.3, 0.45)))
+  })
+  group.add(pipeMesh)
+
+  // ------------------------------------------------------ brackets & greebles
+  // Boxes: x radial (height), y spinward (width), z along the axis (length).
+  const boxes: { theta: number; z: number; h: number; w: number; l: number; shade: number }[] = []
+  for (const p of pipes) {
+    for (let z = p.z0 + 2; z < p.z1 - 1; z += range(4, 7)) {
+      boxes.push({ theta: p.theta, z, h: p.lift + p.r, w: 2.6 * p.r + 0.3, l: 0.35, shade: 0.28 })
+    }
+  }
+  const inLane = (theta: number, halfWidthKm: number) => lanes.some((ln) => {
+    let d = Math.abs(theta - ln.theta) % (2 * Math.PI)
+    if (d > Math.PI) d = 2 * Math.PI - d
+    return d * SKIN < ln.halfWidth + halfWidthKm
+  })
+  const inBand = (z: number, halfLen: number) =>
+    stations.some((st) => Math.abs(z - st) < BAND_W / 2 + 2.3 + halfLen)
+  const place = (theta: number, z: number, w: number, l: number) =>
+    z - l / 2 > THRUSTER_ZONE && z + l / 2 < L - THRUSTER_ZONE
+    && !inLane(theta, w / 2) && !inBand(z, l / 2)
+  // Plant rooms: clusters of blocks, big ones among small ones, and the odd tower.
+  for (let c = 0; c < 170; c++) {
+    const theta0 = range(0, 2 * Math.PI)
+    const z0 = range(2, L - 2)
+    const n = 6 + Math.floor(rand() * 16)
+    for (let j = 0; j < n; j++) {
+      const w = range(0.6, 4.5)
+      const l = range(0.8, 7)
+      const tall = rand() < 0.06
+      const h = tall ? range(3, 6) : range(0.3, 1.8)
+      const theta = theta0 + range(-6, 6) / SKIN
+      const z = z0 + range(-7, 7)
+      if (place(theta, z, w, l)) boxes.push({ theta, z, h, w, l, shade: range(0.25, 0.5) })
+    }
+  }
+  const boxMesh = new InstancedMesh(new BoxGeometry(1, 1, 1), metal(0xffffff, 0.35, 0.7), boxes.length)
+  boxes.forEach((b, i) => {
+    const { n, t } = frame(b.theta)
+    m.makeBasis(n, t, ZAXIS)
+    m.scale(s.set(b.h, b.w, b.l))
+    m.setPosition(n.multiplyScalar(SKIN + b.h / 2).addScaledVector(ZAXIS, b.z))
+    boxMesh.setMatrixAt(i, m)
+    boxMesh.setColorAt(i, tint.setHSL(range(0.07, 0.13), range(0.03, 0.1), b.shade))
+  })
+  group.add(boxMesh)
+
+  // ------------------------------------------------------------------ vents
+  const vents: { theta: number; z: number; size: number }[] = []
+  while (vents.length < 64) {
+    const theta = range(0, 2 * Math.PI)
+    const z = range(6, L - 6)
+    const size = range(1.8, 3.6)
+    if (place(theta, z, size + 1, size + 1)) vents.push({ theta, z, size })
+  }
+  const VENT_H = 0.9
+  const housing = new InstancedMesh(new BoxGeometry(1, 1, 1), metal(0x3a3c3b, 0.3, 0.75), vents.length)
+  const grilleGeometry = new PlaneGeometry(1, 1)
+  const grilleLevel = levels(grilleGeometry, vents.length)
+  const grille = new InstancedMesh(grilleGeometry, grilleMaterial(), vents.length)
+  const plumeGeometry = new ConeGeometry(1, 1, 20, 1, true)
+  const plumeLevel = levels(plumeGeometry, vents.length)
+  const plume = new InstancedMesh(plumeGeometry, plumeMaterial(), vents.length)
+  plume.renderOrder = 5
+  vents.forEach((v, i) => {
+    const { n, t } = frame(v.theta)
+    m.makeBasis(n, t, ZAXIS).scale(s.set(VENT_H, v.size + 0.8, v.size + 0.8))
+    m.setPosition(n.clone().multiplyScalar(SKIN + VENT_H / 2).addScaledVector(ZAXIS, v.z))
+    housing.setMatrixAt(i, m)
+    // The grille: a plane facing out, just proud of the housing's top. (t, z, n) is right-handed.
+    m.makeBasis(t, ZAXIS, n).scale(s.set(v.size, v.size, 1))
+    m.setPosition(n.clone().multiplyScalar(SKIN + VENT_H + 0.02).addScaledVector(ZAXIS, v.z))
+    grille.setMatrixAt(i, m)
+    // The plume: a cone with its point at the grille, widening outward. (t, −n, z).
+    const len = v.size * range(3, 5)
+    m.makeBasis(t, n.clone().negate(), ZAXIS).scale(s.set(v.size * 1.6, len, v.size * 1.6))
+    m.setPosition(n.clone().multiplyScalar(SKIN + VENT_H + len / 2).addScaledVector(ZAXIS, v.z))
+    plume.setMatrixAt(i, m)
+  })
+  group.add(housing, grille, plume)
+
+  // -------------------------------------------------------------- thrusters
+  // Pods round each end, in the zone kept clear for them. Each has four bells: out,
+  // spinward, antispinward, and along the axis away from the world.
+  const PODS_PER_END = 6
+  const POD = { h: 3.2, w: 6, l: 7 }
+  const BELL = { len: 2.6, throat: 0.5, exit: 1.4 }
+  const bells: { at: Vector3; dir: Vector3 }[] = []
+  const pods: { theta: number; z: number }[] = []
+  for (const [zPod, axial] of [[THRUSTER_ZONE / 2, -1], [L - THRUSTER_ZONE / 2, 1]] as const) {
+    const offset = range(0, 2 * Math.PI)
+    for (let k = 0; k < PODS_PER_END; k++) {
+      const theta = offset + (k / PODS_PER_END) * 2 * Math.PI
+      pods.push({ theta, z: zPod })
+      const { n, t } = frame(theta)
+      const centre = n.clone().multiplyScalar(SKIN + POD.h / 2).addScaledVector(ZAXIS, zPod)
+      for (const [dir, half] of [
+        [n, POD.h / 2], [t, POD.w / 2], [t.clone().negate(), POD.w / 2],
+        [ZAXIS.clone().multiplyScalar(axial), POD.l / 2],
+      ] as const) {
+        bells.push({ at: centre.clone().addScaledVector(dir, half), dir: dir.clone() })
+      }
+    }
+  }
+  const podMesh = new InstancedMesh(new BoxGeometry(1, 1, 1), metal(0x4a4c4a, 0.5, 0.55), pods.length)
+  pods.forEach((p, i) => {
+    const { n, t } = frame(p.theta)
+    m.makeBasis(n, t, ZAXIS).scale(s.set(POD.h, POD.w, POD.l))
+    m.setPosition(n.multiplyScalar(SKIN + POD.h / 2).addScaledVector(ZAXIS, p.z))
+    podMesh.setMatrixAt(i, m)
+  })
+  // A bell: open, narrow at the pod and wide at its mouth (Three's cylinder is wide at +y).
+  const bellMesh = new InstancedMesh(
+    new CylinderGeometry(BELL.exit, BELL.throat, BELL.len, 20, 1, true),
+    dusty(new MeshStandardMaterial({ color: 0x6b645c, metalness: 0.7, roughness: 0.4, side: DoubleSide })),
+    bells.length)
+  const flameGeometry = flare(3.2, 24)
+  const coreGeometry = flare(1.2, 16)
+  const flameLevel = levels(flameGeometry, bells.length)
+  const coreLevel = levels(coreGeometry, bells.length)
+  const flame = new InstancedMesh(flameGeometry, flameMaterial(new Color(1.0, 0.55, 0.2), 0.55), bells.length)
+  const core = new InstancedMesh(coreGeometry, flameMaterial(new Color(0.75, 0.85, 1.0), 1.1), bells.length)
+  flame.renderOrder = core.renderOrder = 6
+  const basis = (dir: Vector3) => {
+    // Any unit a square to dir, then b = a × dir: (a, dir, b) is right-handed, y along dir.
+    const a = new Vector3().crossVectors(dir, Math.abs(dir.z) < 0.9 ? ZAXIS : new Vector3(1, 0, 0)).normalize()
+    return m.makeBasis(a, dir, new Vector3().crossVectors(a, dir))
+  }
+  bells.forEach((b, i) => {
+    basis(b.dir).setPosition(b.at.clone().addScaledVector(b.dir, BELL.len / 2))
+    bellMesh.setMatrixAt(i, m)
+    // Flames: the bell's width at its mouth, fanning out as they go. The outer one long,
+    // the core a third as long and hardly spreading.
+    const mouth = b.at.clone().addScaledVector(b.dir, BELL.len)
+    const len = range(28, 42)
+    basis(b.dir).scale(s.set(BELL.exit, len, BELL.exit))
+    flame.setMatrixAt(i, m.setPosition(mouth.clone().addScaledVector(b.dir, len / 2)))
+    basis(b.dir).scale(s.set(BELL.exit * 0.8, len * 0.35, BELL.exit * 0.8))
+    core.setMatrixAt(i, m.setPosition(mouth.clone().addScaledVector(b.dir, len * 0.175)))
+  })
+  group.add(podMesh, bellMesh, flame, core)
+
+  /**
+   * Real light from the burns, on the plating and the pods round them. Two lights, handed
+   * to whichever bells are burning brightest: a light per bell would recompile every
+   * standard material in the scene for 48 lights, and more than two at once is rare.
+   */
+  const burnLights = [0, 1].map(() => {
+    const light = new PointLight(0xffa860, 0, 80, 1)
+    group.add(light)
+    return light
+  })
+
+  for (const mesh of [pipeMesh, boxMesh, housing, grille, plume, podMesh, bellMesh, flame, core]) {
+    mesh.computeBoundingSphere()
+  }
+
+  // The timings, the heart of the change: vents often, grilles on their own clock, and
+  // thrusters rarely — one bell of a pod at a time.
+  const live = mulberry32(SEED + 7)
+  const plumes = new Bursts(vents.length, { on: [1.5, 4], off: [2, 12], attack: 0.35, release: 1.2 }, live)
+  const grilles = new Bursts(vents.length, { on: [1, 7], off: [0.5, 5], attack: 0.15, release: 0.6 }, live)
+  const burns = new Bursts(pods.length, { on: [2, 5], off: [25, 80], attack: 0.12, release: 0.5 }, live)
+  const burnBell = new Int8Array(pods.length)
+  const wasBurning = new Uint8Array(pods.length)
+  const timed = [grille.material, plume.material, flame.material, core.material] as ShaderMaterial[]
+  return {
+    group,
+    plating: platingMaterial(),
+    update(t) {
+      for (const mat of timed) mat.uniforms['uTime']!.value = t
+      plumes.step(t)
+      grilles.step(t)
+      burns.step(t)
+      plumeLevel.set(plumes.level)
+      grilleLevel.set(grilles.level)
+      // A pod fires one bell per burn, chosen when it lights.
+      flameLevel.array.fill(0)
+      for (let p = 0; p < pods.length; p++) {
+        const burning = burns.level[p]! > 0
+        if (burning && !wasBurning[p]) burnBell[p] = Math.floor(live() * 4)
+        wasBurning[p] = burning ? 1 : 0
+        if (burning) flameLevel.array[p * 4 + burnBell[p]!] = burns.level[p]!
+      }
+      coreLevel.set(flameLevel.array)
+      for (const a of [plumeLevel, grilleLevel, flameLevel, coreLevel]) a.needsUpdate = true
+      // The two brightest burns get the lights, at the bell's mouth.
+      const order = [...flameLevel.array.keys()].sort((a, b) => flameLevel.array[b]! - flameLevel.array[a]!)
+      burnLights.forEach((light, k) => {
+        const i = order[k]!
+        const level = flameLevel.array[i]!
+        light.intensity = level * 60
+        if (level > 0) light.position.copy(bells[i]!.at).addScaledVector(bells[i]!.dir, BELL.len + 2)
+      })
+    },
+  }
+}
+
+/**
+ * A flame's shape: an open tube of radius 1 at its base (y = −0.5, the bell's mouth) that
+ * flares to `spread` at its far end — exhaust in vacuum has nothing to hold it together.
+ * Growing as the square of the distance keeps it slim near the hull, where a sideways
+ * flame runs closest to the plating, and lets it bloom once it is clear.
+ */
+function flare(spread: number, around: number): BufferGeometry {
+  const g = new CylinderGeometry(1, 1, 1, around, 16, true)
+  const pos = g.attributes['position']!
+  for (let i = 0; i < pos.count; i++) {
+    const along = pos.getY(i) + 0.5
+    const k = 1 + (spread - 1) * along * along
+    pos.setXYZ(i, pos.getX(i) * k, pos.getY(i), pos.getZ(i) * k)
+  }
+  g.computeVertexNormals()
+  return g
+}
+
+function metal(color: number, metalness: number, roughness: number): MeshStandardMaterial {
+  return dusty(new MeshStandardMaterial({ color, metalness, roughness }))
+}
+
+/**
+ * A layer of dust over a material (author: "too shiny, let's add a nice layer of dust").
+ *
+ * Where it lies, the surface takes the dust's colour and goes fully matte and non-metallic,
+ * which is what takes the shine off: bare metal under the fill lights reads as chrome. It
+ * lies thickest on faces turned outward — the tops of pipes, blocks and plating — thinner on
+ * the sides, and hardly at all underneath, broken into drifts by noise so it is not a
+ * uniform wash. Out here "up" is outward: it is the way everything on the hull is thrown.
+ *
+ * Worked out in the hull's own frame, from the vertex, so it follows instanced parts too,
+ * and chained after any shader change the material already has (the plating's panels).
+ */
+export function dusty<M extends MeshStandardMaterial>(mat: M): M {
+  const before = mat.onBeforeCompile.bind(mat)
+  const key = mat.customProgramCacheKey.bind(mat)
+  mat.onBeforeCompile = (shader, renderer) => {
+    before(shader, renderer)
+    shader.vertexShader = shader.vertexShader
+      .replace('#include <common>', '#include <common>\nvarying vec3 vDustPos;\nvarying vec3 vDustN;')
+      .replace('#include <project_vertex>', `#include <project_vertex>
+  vec4 dustPos = vec4(transformed, 1.0);
+  vec3 dustN = objectNormal;
+  #ifdef USE_INSTANCING
+    dustPos = instanceMatrix * dustPos;
+    dustN = mat3(instanceMatrix) * dustN;
+  #endif
+  vDustPos = dustPos.xyz;
+  vDustN = dustN;`)
+    shader.fragmentShader = shader.fragmentShader
+      .replace('#include <common>', `#include <common>\n${DUST_GLSL}`)
+      .replace('#include <metalnessmap_fragment>', `#include <metalnessmap_fragment>
+  float dust = dustCover(vDustPos, normalize(vDustN) * (gl_FrontFacing ? 1.0 : -1.0));
+  diffuseColor.rgb = mix(diffuseColor.rgb, DUST_COLOUR * (0.85 + 0.3 * dustGrain(vDustPos)), dust);
+  roughnessFactor = mix(roughnessFactor, 1.0, dust);
+  metalnessFactor = mix(metalnessFactor, 0.0, dust);`)
+  }
+  // Three caches programs by onBeforeCompile's source, and every dusty material shares
+  // this wrapper's; the plating's panels must not be mistaken for a plain one.
+  mat.customProgramCacheKey = () => `dust|${key()}|${before.toString()}`
+  return mat
+}
+
+const DUST_GLSL = /* glsl */`
+varying vec3 vDustPos;
+varying vec3 vDustN;
+const vec3 DUST_COLOUR = vec3(0.56, 0.52, 0.46);
+
+float dustHash(vec3 p) {
+  p = fract(p * 0.1031);
+  p += dot(p, p.zyx + 31.32);
+  return fract((p.x + p.y) * p.z);
+}
+float dustNoise(vec3 p) {
+  vec3 i = floor(p);
+  vec3 f = fract(p);
+  f = f * f * (3.0 - 2.0 * f);
+  float a = mix(mix(dustHash(i), dustHash(i + vec3(1, 0, 0)), f.x),
+                mix(dustHash(i + vec3(0, 1, 0)), dustHash(i + vec3(1, 1, 0)), f.x), f.y);
+  float b = mix(mix(dustHash(i + vec3(0, 0, 1)), dustHash(i + vec3(1, 0, 1)), f.x),
+                mix(dustHash(i + vec3(0, 1, 1)), dustHash(i + vec3(1, 1, 1)), f.x), f.y);
+  return mix(a, b, f.z);
+}
+// Fine variation within the dust, so a dusted face is not a flat colour.
+float dustGrain(vec3 p) {
+  return dustNoise(p * 3.0) * 0.6 + dustNoise(p * 11.0) * 0.4 - 0.5;
+}
+/** How much dust lies here, 0..1: by how far the face turns outward, broken into drifts. */
+float dustCover(vec3 p, vec3 n) {
+  float out_ = dot(n, normalize(vec3(p.xy, 0.0)));
+  float drift = dustNoise(p / 6.0) * 0.65 + dustNoise(p / 1.7) * 0.35;
+  float lie = 0.3 + 0.55 * smoothstep(-0.2, 0.9, out_);
+  return clamp(smoothstep(0.25, 0.75, drift * 0.55 + lie * 0.75) * lie * 1.25, 0.0, 0.92);
+}
+`
+
+// ------------------------------------------------------------------ shaders
+
+const INSTANCED_VERT = /* glsl */`
+attribute float aLevel;
+varying vec2 vUv;
+varying float vId;
+varying float vLevel;
+void main() {
+  vUv = uv;
+  vId = float(gl_InstanceID);
+  vLevel = aLevel;
+  gl_Position = projectionMatrix * modelViewMatrix * instanceMatrix * vec4(position, 1.0);
+}
+`
+
+/**
+ * Slats glowing orange-hot between dark bars when the vent's grille is lit, and a dull
+ * ember between times, so an unlit vent still reads as one.
+ */
+function grilleMaterial(): ShaderMaterial {
+  return new ShaderMaterial({
+    uniforms: { uTime: { value: 0 } },
+    vertexShader: INSTANCED_VERT,
+    fragmentShader: /* glsl */`
+      uniform float uTime;
+      varying vec2 vUv;
+      varying float vId;
+      varying float vLevel;
+      void main() {
+        vec2 p = abs(vUv - 0.5);
+        float rim = step(0.42, max(p.x, p.y));
+        float slat = smoothstep(0.25, 0.45, abs(fract(vUv.y * 9.0) - 0.5));
+        float flicker = 0.9 + 0.1 * sin(uTime * 9.0 + vId * 2.1);
+        float heat = 0.06 + 1.6 * vLevel * flicker;
+        vec3 hot = vec3(1.0, 0.45, 0.12) * (1.0 - slat) * heat;
+        gl_FragColor = vec4(mix(hot, vec3(0.05), rim), 1.0);
+        #include <colorspace_fragment>
+      }
+    `,
+  })
+}
+
+/**
+ * Exhaust: a pale cone, brightest at the grille and gone by its mouth, streaked and
+ * drifting. It shoots out as the vent opens — only as much of the cone as the level has
+ * reached is drawn — and fades when it shuts. There is no air out here to hold a plume; it
+ * is the look that was asked for, under the same licence as the beam's rays.
+ */
+function plumeMaterial(): ShaderMaterial {
+  return new ShaderMaterial({
+    uniforms: { uTime: { value: 0 } },
+    vertexShader: INSTANCED_VERT,
+    fragmentShader: /* glsl */`
+      uniform float uTime;
+      varying vec2 vUv;
+      varying float vId;
+      varying float vLevel;
+      void main() {
+        // uv.y is 1 at the cone's point, which sits on the grille.
+        float along = vUv.y;
+        float reach = smoothstep(1.0 - vLevel * 1.1, 1.05 - vLevel * 1.1, along);
+        float streak = 0.6 + 0.4 * sin(vUv.x * 40.0 + vId * 3.0 + uTime * 0.7)
+                           * sin(along * 12.0 - uTime * (1.5 + fract(vId * 0.61)));
+        float a = along * along * streak * 0.2 * vLevel * reach;
+        gl_FragColor = vec4(vec3(0.85, 0.84, 0.8) * a, 1.0);
+      }
+    `,
+    transparent: true, blending: AdditiveBlending, depthWrite: false, side: DoubleSide,
+  })
+}
+
+/**
+ * A thruster's flame: brightest at the bell's mouth and gone by its far end, flickering
+ * fast, and reaching out as it lights. uv.y runs 0 at the mouth to 1 at the far end.
+ */
+function flameMaterial(color: Color, gain: number): ShaderMaterial {
+  return new ShaderMaterial({
+    uniforms: { uTime: { value: 0 }, uColor: { value: color }, uGain: { value: gain } },
+    vertexShader: INSTANCED_VERT,
+    fragmentShader: /* glsl */`
+      uniform float uTime;
+      uniform vec3 uColor;
+      uniform float uGain;
+      varying vec2 vUv;
+      varying float vId;
+      varying float vLevel;
+      void main() {
+        if (vLevel <= 0.0) discard;
+        float along = vUv.y;
+        float reach = 1.0 - smoothstep(vLevel - 0.1, vLevel, along);
+        float flicker = 0.8 + 0.2 * sin(uTime * 37.0 + vId * 5.0) * sin(uTime * 23.0 + along * 20.0);
+        float a = pow(1.0 - along, 1.6) * reach * flicker * vLevel * uGain;
+        gl_FragColor = vec4(uColor * a, 1.0);
+      }
+    `,
+    transparent: true, blending: AdditiveBlending, depthWrite: false, side: DoubleSide,
+  })
+}
+
+/**
+ * The plating, for the hull cylinder itself. Panels on a grid of PANELS_AROUND by
+ * PANEL_ALONG km, some split in two or four; each panel its own shade, some ribbed, and
+ * grime streaked along the axis. Seams are grooves, bump-mapped, drawn a fixed fraction
+ * of a pixel wider as the camera backs off, so they fade rather than alias.
+ */
+function platingMaterial(): MeshStandardMaterial {
+  /**
+   * Two-sided, unlike the bare skin: the parts bolted on are solid from every side, and
+   * from inside the tube — in through a window — the plating's inner face is what keeps
+   * them hidden wherever the ground has not been drawn yet. The ground is nearer, so once
+   * it is there it covers this.
+   */
+  const mat = new MeshStandardMaterial({
+    color: 0x7a7c77, metalness: 0.4, roughness: 0.65, side: DoubleSide,
+  })
+  mat.onBeforeCompile = (shader) => {
+    shader.vertexShader = shader.vertexShader
+      .replace('#include <common>', '#include <common>\nvarying vec3 vPlate;')
+      .replace('#include <begin_vertex>', '#include <begin_vertex>\nvPlate = position;')
+    shader.fragmentShader = shader.fragmentShader
+      .replace('#include <common>', `#include <common>\n${PLATE_GLSL}\n${BUMP_GLSL}`)
+      .replace('#include <map_fragment>', `#include <map_fragment>
+  vec3 plateTint;
+  float plateH = plating(vPlate, plateTint);
+  diffuseColor.rgb *= plateTint;`)
+      .replace('#include <normal_fragment_maps>', `#include <normal_fragment_maps>
+  normal = bumpNormal(-vViewPosition, normal, vec2(dFdx(plateH), dFdy(plateH)), faceDirection);`)
+  }
+  return dusty(mat)
+}
+
+const PLATE_GLSL = /* glsl */`
+varying vec3 vPlate;
+float plateHash(vec2 p) {
+  vec3 q = fract(vec3(p.xyx) * 0.1031);
+  q += dot(q, q.yzx + 33.33);
+  return fract((q.x + q.y) * q.z);
+}
+
+/**
+ * Relief in km (grooves negative) and a colour multiplier, at a point on the hull.
+ * u runs round in panel units and closes exactly on itself; the pixel's footprint is
+ * taken from the position, which has no seam, rather than from u, which jumps at ±π.
+ */
+float plating(vec3 p, out vec3 tint) {
+  float px = length(fwidth(p));
+  float around = (atan(p.y, p.x) / 6.2831853 + 0.5) * ${PANELS_AROUND.toFixed(1)};
+  float cellKm = ${(2 * Math.PI * SKIN / PANELS_AROUND).toFixed(4)};
+  vec2 g = vec2(around, p.z / ${PANEL_ALONG.toFixed(1)});
+  vec2 cell = floor(g);
+  vec2 f = fract(g);
+  float h = plateHash(cell);
+  // Split some panels: in two along the axis, in two round, or in four.
+  vec2 split = vec2(h < 0.3 || (h > 0.55 && h < 0.75) ? 2.0 : 1.0, h > 0.3 && h < 0.75 ? 2.0 : 1.0);
+  vec2 sub = floor(f * split);
+  vec2 sf = fract(f * split);
+  float id = plateHash(cell * 7.3 + sub + 1.7);
+  // Distance to the panel's edge, km, and the groove it makes.
+  vec2 edgeKm = min(sf, 1.0 - sf) * vec2(cellKm, ${PANEL_ALONG.toFixed(1)}) / split;
+  float e = min(edgeKm.x, edgeKm.y);
+  float groove = 1.0 - smoothstep(0.05, 0.05 + max(0.06, px * 1.2), e);
+  float height = -0.08 * groove;
+  // Ribbed panels: raised ribs across the axis.
+  float ribbed = step(0.82, id);
+  float rib = ribbed * smoothstep(0.3, 0.5, abs(fract(p.z / 0.9) - 0.5)) * 0.05
+            * (1.0 - smoothstep(0.3, 0.9, px / 0.9));
+  height += rib;
+  // Grime: long streaks along the axis, as if something had run down the hull in spin.
+  float grime = plateHash(vec2(floor(around * 6.0), floor(p.z / 23.0)));
+  float shade = mix(0.72, 1.12, id) * mix(0.8, 1.0, grime) * (1.0 - 0.55 * groove);
+  tint = vec3(shade) * mix(vec3(1.0), vec3(1.05, 1.0, 0.9), step(0.93, id));
+  return height;
+}
+`
diff --git a/src/views/rock-hull.ts b/src/views/rock-hull.ts
index 1124470..94f0529 100644
--- a/src/views/rock-hull.ts
+++ b/src/views/rock-hull.ts
@@ -167,7 +167,7 @@ function rockMaterial(): MeshStandardMaterial {
       .replace('#include <common>', '#include <common>\nvarying vec3 vRock;')
       .replace('#include <begin_vertex>', '#include <begin_vertex>\nvRock = position;')
     shader.fragmentShader = shader.fragmentShader
-      .replace('#include <common>', `#include <common>\n${ROCK_GLSL}`)
+      .replace('#include <common>', `#include <common>\n${ROCK_GLSL}\n${BUMP_GLSL}`)
       .replace('#include <map_fragment>', `#include <map_fragment>
   float rockH = rockHeight(vRock);
   // Mottling: lighter on the highs, darker in the pits, and patches of warmer and colder
@@ -176,7 +176,7 @@ function rockMaterial(): MeshStandardMaterial {
   diffuseColor.rgb *= mix(0.78, 1.12, clamp(0.5 + rockH * 0.9, 0.0, 1.0));
   diffuseColor.rgb *= mix(vec3(0.9, 0.95, 1.02), vec3(1.08, 1.0, 0.9), clamp(0.5 + ejecta, 0.0, 1.0));`)
       .replace('#include <normal_fragment_maps>', `#include <normal_fragment_maps>
-  normal = rockPerturb(-vViewPosition, normal, vec2(dFdx(rockH), dFdy(rockH)), faceDirection);`)
+  normal = bumpNormal(-vViewPosition, normal, vec2(dFdx(rockH), dFdy(rockH)), faceDirection);`)
   }
   return m
 }
@@ -222,13 +222,16 @@ float rockHeight(vec3 p) {
   return h;
 }
 
+`
+
+export const BUMP_GLSL = /* glsl */`
 /**
  * Bump mapping from screen-space derivatives (Mikkelsen 2010), as Three's own
  * bumpmap_pars_fragment does it — except the screen-space tangents are not normalised,
  * so the tilt is the relief's true slope in kilometres per kilometre, the same at every
  * distance, rather than a slope per pixel that steepens as the camera backs away.
  */
-vec3 rockPerturb(vec3 surf_pos, vec3 surf_norm, vec2 dHdxy, float faceDirection) {
+vec3 bumpNormal(vec3 surf_pos, vec3 surf_norm, vec2 dHdxy, float faceDirection) {
   vec3 vSigmaX = dFdx(surf_pos.xyz);
   vec3 vSigmaY = dFdy(surf_pos.xyz);
   vec3 vN = surf_norm;
diff --git a/test/pipe-hull.test.ts b/test/pipe-hull.test.ts
new file mode 100644
index 0000000..9c69ac6
--- /dev/null
+++ b/test/pipe-hull.test.ts
@@ -0,0 +1,99 @@
+import { describe, expect, it } from 'vitest'
+import { BufferGeometry, InstancedMesh, Matrix4, Mesh, Vector3 } from 'three'
+import { HULL_DEPTH_M, LENGTH_M, RADIUS_M } from '../src/canon/constants.js'
+import { Bursts, buildPipeHull } from '../src/views/pipe-hull.js'
+import { mulberry32 } from '../src/terrain/noise.js'
+
+const SKIN = (RADIUS_M + HULL_DEPTH_M) / 1000
+/** The hull's ends, collars included: past them there is no tube to see into. */
+const Z0 = -HULL_DEPTH_M / 1000
+const Z1 = (LENGTH_M + HULL_DEPTH_M) / 1000
+
+/**
+ * The smallest distance from the axis of any vertex the group draws alongside the hull.
+ * A thruster's flame fanning out past an end is not beside the tube, so it is not counted.
+ */
+function innermost(mesh: Mesh): number {
+  const pos = (mesh.geometry as BufferGeometry).attributes['position']!
+  const count = mesh instanceof InstancedMesh ? mesh.count : 1
+  const m = new Matrix4()
+  const v = new Vector3()
+  let min = Infinity
+  for (let k = 0; k < count; k++) {
+    if (mesh instanceof InstancedMesh) mesh.getMatrixAt(k, m)
+    else m.identity()
+    for (let i = 0; i < pos.count; i++) {
+      v.fromBufferAttribute(pos, i).applyMatrix4(m)
+      if (v.z >= Z0 && v.z <= Z1) min = Math.min(min, Math.hypot(v.x, v.y))
+    }
+  }
+  return min
+}
+
+describe('the piping skin (D-077)', () => {
+  const { group } = buildPipeHull()
+  const meshes = group.children.filter((c): c is Mesh => c instanceof Mesh)
+
+  it('puts nothing inside the skin, where the windows would see it', () => {
+    for (const mesh of meshes.filter((m) => m instanceof Mesh)) {
+      expect(innermost(mesh)).toBeGreaterThan(SKIN - 1e-3)
+    }
+  })
+
+  it('has every kind of part: bands, pipes, blocks, vents, thrusters', () => {
+    const instanced = meshes.filter((m): m is InstancedMesh => m instanceof InstancedMesh)
+    expect(instanced.length).toBe(9)
+    expect(instanced.every((m) => m.count > 0)).toBe(true)
+    expect(meshes.length - instanced.length).toBeGreaterThan(20)
+  })
+
+  it('is the same plant on every load', () => {
+    const again = buildPipeHull().group.children.filter((c): c is Mesh => c instanceof Mesh)
+    const a = new Matrix4()
+    const b = new Matrix4()
+    ;(meshes.find((m) => m instanceof InstancedMesh) as InstancedMesh).getMatrixAt(7, a)
+    ;(again.find((m) => m instanceof InstancedMesh) as InstancedMesh).getMatrixAt(7, b)
+    expect(a.equals(b)).toBe(true)
+  })
+})
+
+describe('emitters firing at random', () => {
+  const timing = { on: [1, 2] as [number, number], off: [3, 6] as [number, number], attack: 0.5, release: 1 }
+
+  it('goes on and off, rising and falling rather than snapping', () => {
+    const b = new Bursts(1, timing, mulberry32(3))
+    let wasOn = false
+    let ons = 0
+    let prev = 0
+    for (let t = 0; t < 120; t += 0.05) {
+      b.step(t)
+      const l = b.level[0]!
+      expect(l).toBeGreaterThanOrEqual(0)
+      expect(l).toBeLessThanOrEqual(1)
+      // At 0.05 s a frame, the level moves by at most 0.05 / attack (levels are float32).
+      expect(Math.abs(l - prev)).toBeLessThanOrEqual(0.1 + 1e-6)
+      prev = l
+      if (l > 0.99 && !wasOn) ons++
+      wasOn = l > 0.99 ? true : l === 0 ? false : wasOn
+    }
+    // One cycle is 4 to 8 s, so two minutes holds 15 to 30 of them.
+    expect(ons).toBeGreaterThan(12)
+    expect(ons).toBeLessThan(32)
+  })
+
+  it('keeps each emitter on its own clock', () => {
+    const b = new Bursts(20, timing, mulberry32(5))
+    b.step(0)
+    b.step(4)
+    const lit = Array.from(b.level).filter((l) => l > 0).length
+    expect(lit).toBeGreaterThan(0)
+    expect(lit).toBeLessThan(20)
+  })
+
+  it('survives a long pause, as when the tab was hidden', () => {
+    const b = new Bursts(3, timing, mulberry32(9))
+    b.step(0)
+    b.step(3600)
+    for (const l of b.level) expect(l >= 0 && l <= 1).toBe(true)
+  })
+})