fuzzyworld

tower / commit

feat(exterior): rock and piping together, plant under shielding (D-081)

ab6ae94f1a9813406c4bb2093abc70562eb2c434 · Pavel Safronov · 2026-09-28 15:06:51 -0700 · parents 7df218ae0a5d

Claude Code usage: this commit ~220.0K out / ~42.1M cached (first commit of its session: everything since it began) · cost unknown (no per-model trailer) · session 4 tally 500 in / 220.0K out / 42.1M cached · usage by commit and session

feat(exterior): rock and piping together, plant under shielding (D-081)

A fifth Hull reading, labelled an experiment: rock heaped on the plant
as shielding. One seeded cover field lays banks of rock over about
three fifths of the hull, 1.6x as tall as the Rock reading, ending in
scarps; plant rooms and vents crowd into the bare ground, bands and
pipe runs go under the rock, and each thruster pod is dug clear.

Also fixes moire on the plating: ribs, seams and grime stripes fade
out before they are a few pixels apart, which fixes Piping too.

Usage: 0.5K in / 220.0K out / 42.1M cached (session tally)
rtk:   1.0M saved, 16.4% over 2947 cmds (global/lifetime)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
 README.md              |   7 +++-
 decisions.md           |  62 +++++++++++++++++++++++++++++-
 design.md              |  15 ++++++--
 index.html             |   4 +-
 src/ui/app.ts          |   3 +-
 src/views/exterior.ts  |  29 +++++++++++---
 src/views/pipe-hull.ts |  41 +++++++++++++++-----
 src/views/rock-hull.ts | 100 ++++++++++++++++++++++++++++++++++++++++++-------
 test/pipe-hull.test.ts |  45 ++++++++++++++++++++++
 test/rock-hull.test.ts |  25 ++++++++++++-
 10 files changed, 293 insertions(+), 38 deletions(-)

diff --git a/README.md b/README.md
index 91d254f..fbb6347 100644
--- a/README.md
+++ b/README.md
@@ -278,7 +278,7 @@ 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 four ways and the **Hull** switch picks one; it opens on True and
+The hull reads five 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
@@ -293,7 +293,10 @@ industrial plant: panelled plating, a band with hoop pipes every ~36 km, bundles
 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
+wears a layer of dust, thickest on whatever faces outward. **Rock and piping** puts the
+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
 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 b961066..af9e3b5 100644
--- a/decisions.md
+++ b/decisions.md
@@ -1436,7 +1436,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 and D-077: Rock and Piping are on trial beside True and X-ray.*
+*Amended by D-076, D-077 and D-081: Rock, Piping, and the two together 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
@@ -2672,3 +2672,63 @@ next time this happens it is visible. Only an empty pool is fatal, and it says s
 Checked in Firefox with the same sabotage: one silent worker now costs about 15 s and a
 console error; a silent worker with a silent replacement costs about 30 s and loads with five;
 a normal load is unchanged.
+
+## D-081 — Trying other outsides: rock and piping together
+
+**Asked for** (author, 2026-09-28): "i like the outside rock and pipes views, do you think
+we can combine them? have rock show through, and have pipes overlaying in other spots."
+Offered three readings: rock heaped on the plant as shielding, the plant built on top of
+the rock, or Tower half dug out of an asteroid. **Chosen by the author: shielding** — "it
+seems like a reasonable approach to creating Tower". A fifth Hull reading, **Rock and
+piping**, an experiment like D-076 and D-077, not canon.
+
+**Why the two together, and not just either.** The rock alone has nothing in it to give
+scale — a crater could be sixteen metres or sixteen kilometres. The plating alone is
+regular, which was D-076's complaint in the first place. Each fixes the other: a pipe
+run that goes into a bank of rock and comes out ten kilometres on says how big both are.
+
+**One cover field decides where the rock is** (`rockCover` in `src/views/rock-hull.ts`):
+seeded noise over the hull, 0..1, rock wherever it is at least 0.5. Two scales, 80 km and
+22 km, so it lies in banks and drifts tens of kilometres across with bare ground between.
+It first covered about 43% of the hull; the author asked for "a slightly higher
+concentration", and it is now about 60%.
+- **The rock** is D-076's shell scaled by the cover, rising from 0.2 km to full height
+  over a narrow band of it so each bank ends in a scarp. It is **1.6 times D-076's height**
+  here (author: "the rock feels like it can be taller, at least another 50%") — banks
+  with bare plating beside them need the height to read as heaps. The Rock reading on its
+  own is unchanged. A first try that ramped up slowly
+  left the edges a thin feathered skin that read as smoke, not rock. The cut is made per
+  pixel from the cover carried on each vertex, broken up by noise finer than the mesh, so
+  the shore of each bank is ragged rather than the mesh's 0.7 km grid.
+- **The plant** — blocks and vents — goes only on bare ground, a little inside the edge.
+  Each plant room is centred on bare ground, so they crowd into the gaps rather than
+  thinning out. **The bands and pipe runs are laid as before** and go under the rock: that
+  is what makes the rock read as heaped on rather than the plant as built round it.
+- **The thruster pods are dug clear**, 14 km round each, with a sloped edge: a sideways
+  flame runs along the hull, and would otherwise burn into the rock.
+
+**Built separately from the other two.** The plant crowds differently and the rock is
+dug out, so neither half is the same as the dress on its own; the combined one is its own
+pair of meshes, built the first time it is chosen. With nothing under rock, the piping
+builder makes the same draws from its seed as before, so the Piping reading is unchanged
+(tested).
+
+**Canon.** Nothing in §1 changes. The shielding reading gives D-076's rock a reason, and
+rock over three fifths of the hull at 1.6 times the height is about the same mass as
+D-076's shell — still over 10¹⁸ kg, so still a question for
+§1.2 if this stays (design.md §5 Backlog). Real habitat studies do bury the hull under
+regolith for shielding, but metres of it, not kilometres.
+
+**The plating's moiré** (author, 2026-09-28: "the repetitive pattern in the pipes creates
+moiree lines — circle patterns"). Seen from a glancing angle near the far cap on a
+high-density screen: rings of interference on the plating. The ribbed panels were the
+cause. Their ribs are rings round the hull 0.9 km apart, and they faded out only once a
+pixel was most of a rib wide — so they were still drawn at full strength while one to
+three pixels apart, which is exactly the band where a regular pattern beats against the
+pixel grid. They were also faded by the pixel's overall size, when what matters for rings
+is how fast z runs across a pixel, which at a glancing angle is several times more. The
+ribs now fade by that rate, and are gone before they are three pixels apart. The same
+reasoning is applied to the two other regular patterns in D-077's plating: the panel seams
+fade out once a panel is a few pixels across, and the hard-edged grime stripes are
+averaged away as they near a pixel's width. Close up, nothing changes. This is D-077's
+plating, so the Piping reading gets the fix too.
diff --git a/design.md b/design.md
index 524548f..6ac3322 100644
--- a/design.md
+++ b/design.md
@@ -850,7 +850,12 @@ side, pipe bundles running between the bands on brackets, clustered blocks of pl
 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.
+still see only Tower's inside. **Rock and piping** is the third (D-081), the two at once:
+the reading is that rock was heaped over the plant as shielding, so it lies in banks tens
+of kilometres across over about three fifths of the hull, half as tall again as the Rock
+reading, with scarps at their edges; the plant
+rooms and vents crowd into the bare ground between, the bands and pipe runs are laid
+regardless and go under the rock, and the rock is dug clear round each thruster pod.
 
 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
@@ -1184,6 +1189,8 @@ Things decided to be worth coming back to, not scheduled.
 - **The sun's share of the beam** (D-079). The rod now runs on past the sun at 70% of
   itself, which says the shoal takes only 30% — but the ground is still lit as if the sun
   took the whole beam. Canon has to settle the split, and the light budget with it.
-- **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.
+- **The exterior's dress** (D-076, D-077, D-081). Rock, Piping, and Rock and piping are
+  experiments, labelled as such in the Hull switch. Pick one or keep the bare skin — and
+  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.
diff --git a/index.html b/index.html
index f837bd9..ed807ef 100644
--- a/index.html
+++ b/index.html
@@ -56,6 +56,7 @@
             <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="rockpipes">Rock and piping — plant under shielding (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
@@ -63,7 +64,8 @@
           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 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>
+          plant — plating, pipe runs, venting exhausts and thrusters that fire now and then —
+          or both at once, the plant half buried under rock heaped on as shielding.</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 fb2b473..838b745 100644
--- a/src/ui/app.ts
+++ b/src/ui/app.ts
@@ -661,7 +661,8 @@ 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 === 'pipes' || stored === 'xray') ext.value = stored
+      if (stored === 'true' || stored === 'rock' || stored === 'pipes' || stored === 'rockpipes'
+        || 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 f7a928c..2c0042b 100644
--- a/src/views/exterior.ts
+++ b/src/views/exterior.ts
@@ -27,7 +27,7 @@ import { defaultCalendar } from '../world/schema.js'
 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 { ROCK_COVER, buildRockHull, rockCover } from './rock-hull.js'
 import { type PipeHull, buildPipeHull } from './pipe-hull.js'
 
 const KM = 1000
@@ -110,10 +110,10 @@ void main() {
 `
 
 /**
- * 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.
+ * How the hull is drawn (D-047, D-076, D-077, D-081): bare metal, the rock or piping
+ * experiments or both together, or the x-ray of the inside wrapped onto the smooth skin.
  */
-export type HullMode = 'true' | 'rock' | 'pipes' | 'xray'
+export type HullMode = 'true' | 'rock' | 'pipes' | 'rockpipes' | 'xray'
 
 export interface ExteriorCallbacks {
   onPickPoint: (p: SurfacePoint) => void
@@ -144,6 +144,12 @@ export class ExteriorView {
   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
+  /**
+   * Both at once (D-081): rock heaped on the plant as shielding, cut away where the plant
+   * is bare. Its own pair, since the plant crowds into the bare ground and the rock is dug
+   * clear of the thrusters, so neither half is the same as the dress on its own.
+   */
+  private mixed: { rock: Mesh; pipes: PipeHull } | null = null
   /** The metal skin past each end of the world, which the rock replaces. */
   private metalEnds: Mesh[] = []
   private raycaster = new Raycaster()
@@ -327,11 +333,21 @@ export class ExteriorView {
       this.pipes = buildPipeHull()
       this.tower.add(this.pipes.group)
     }
+    if (mode === 'rockpipes' && !this.mixed) {
+      const cover = rockCover()
+      const pipes = buildPipeHull((theta, z) => cover(theta, z) < ROCK_COVER - 0.06)
+      // Dug clear round each pod: a sideways flame runs close along the hull.
+      const rock = buildRockHull(rockCover(pipes.pods.map((p) => ({ ...p, r: 14 }))))
+      this.mixed = { rock, pipes }
+      this.tower.add(rock, pipes.group)
+    }
     const rock = mode === 'rock'
     if (this.rock) this.rock.visible = rock
     if (this.pipes) this.pipes.group.visible = mode === 'pipes'
+    if (this.mixed) this.mixed.rock.visible = this.mixed.pipes.group.visible = mode === 'rockpipes'
     // 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
+    const plant = mode === 'pipes' ? this.pipes : mode === 'rockpipes' ? this.mixed?.pipes : null
+    this.hull.material = plant ? plant.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
@@ -744,7 +760,8 @@ 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)
+      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()
       /**
        * 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
index ab5de9e..066a0fd 100644
--- a/src/views/pipe-hull.ts
+++ b/src/views/pipe-hull.ts
@@ -53,6 +53,8 @@ export interface PipeHull {
   plating: MeshStandardMaterial
   /** Real seconds: steps the vents, the grilles and the thrusters. */
   update(t: number): void
+  /** Where the thruster pods stand, so rock laid over the plant can be dug clear of them. */
+  pods: { theta: number; z: number }[]
 }
 
 /** How a set of emitters switches: on for a while, off for a while, both at random. */
@@ -119,7 +121,13 @@ const frame = (theta: number) => ({
   t: new Vector3(-Math.sin(theta), Math.cos(theta), 0),
 })
 
-export function buildPipeHull(): PipeHull {
+/**
+ * `bare` says where the plating is not under rock (D-081). The plant rooms and vents go
+ * only there; the bands and pipe runs are laid regardless and run under the rock. Without
+ * it, everywhere is bare — and the draws from the seed are the same, so it is the same
+ * plant as ever.
+ */
+export function buildPipeHull(bare: (theta: number, z: number) => boolean = () => true): PipeHull {
   const rand = mulberry32(SEED)
   const range = (a: number, b: number) => a + (b - a) * rand()
   const group = new Group()
@@ -205,11 +213,16 @@ export function buildPipeHull(): PipeHull {
     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.
+    && !inLane(theta, w / 2) && !inBand(z, l / 2) && bare(theta, z)
+  // Plant rooms: clusters of blocks, big ones among small ones, and the odd tower. Each
+  // is centred on bare ground, so with rock over half the hull they crowd into the rest.
   for (let c = 0; c < 170; c++) {
-    const theta0 = range(0, 2 * Math.PI)
-    const z0 = range(2, L - 2)
+    let theta0: number
+    let z0: number
+    do {
+      theta0 = range(0, 2 * Math.PI)
+      z0 = range(2, L - 2)
+    } while (!bare(theta0, z0))
     const n = 6 + Math.floor(rand() * 16)
     for (let j = 0; j < n; j++) {
       const w = range(0.6, 4.5)
@@ -353,6 +366,7 @@ export function buildPipeHull(): PipeHull {
   const timed = [grille.material, plume.material, flame.material, core.material] as ShaderMaterial[]
   return {
     group,
+    pods,
     plating: platingMaterial(),
     update(t) {
       for (const mat of timed) mat.uniforms['uTime']!.value = t
@@ -640,15 +654,24 @@ float plating(vec3 p, out vec3 tint) {
   // 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);
+  // Seams fade out once a sub-panel is only a few pixels across: a grid of lines each a
+  // pixel wide, a few pixels apart, beats against the pixel grid (moiré).
+  float seamFade = 1.0 - smoothstep(0.08, 0.2, px / min(cellKm / split.x, ${PANEL_ALONG.toFixed(1)} / split.y));
+  float groove = (1.0 - smoothstep(0.05, 0.05 + max(0.06, px * 1.2), e)) * seamFade;
   float height = -0.08 * groove;
-  // Ribbed panels: raised ribs across the axis.
+  // Ribbed panels: raised ribs across the axis. They are rings round the hull, so what
+  // matters is how fast z runs across a pixel, which at a glancing angle is far more than
+  // the pixel's overall size suggests. Gone before the ribs are three pixels apart: any
+  // closer and they beat against the pixel grid in rings of moiré (author, 2026-09-28).
+  float zPx = fwidth(p.z);
   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));
+            * (1.0 - smoothstep(0.12, 0.3, max(px, zPx) / 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)));
+  // Hard-edged stripes ~1.3 km wide; averaged away as they near a pixel, for the same reason.
+  float grime = mix(0.5, plateHash(vec2(floor(around * 6.0), floor(p.z / 23.0))),
+                    1.0 - smoothstep(0.15, 0.4, px / 1.3));
   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 94f0529..e673c15 100644
--- a/src/views/rock-hull.ts
+++ b/src/views/rock-hull.ts
@@ -40,6 +40,49 @@ const END_RINGS = 6
 
 interface Crater { theta: number; z: number; r: number }
 
+/**
+ * Where the rock lies, for the combined dress (D-081): 0..1 at a point on the hull, rock
+ * wherever it is at least ROCK_COVER. The reading is that the rock was heaped on as
+ * shielding, and the plant is bare where it was never covered or has been dug out.
+ */
+export type RockCover = (theta: number, z: number) => number
+export const ROCK_COVER = 0.5
+/**
+ * How much taller the rock is laid over the plant than on its own (author: "the rock feels
+ * like it can be taller, at least another 50%"). Banks with bare plating beside them need
+ * the height to read as heaps; the rock alone has no bare ground to be measured against.
+ */
+const COVER_LIFT = 1.6
+
+/** A patch dug clear of rock: round a thruster pod, whose flames would otherwise hit it. */
+export interface Clearing { theta: number; z: number; r: number }
+
+/**
+ * The cover field: broad banks and drifts of rock, tens of kilometres across, with bare
+ * ground between; about three fifths rock (author: "a slightly higher concentration"). `clearings` are dug out, with a sloped edge.
+ */
+export function rockCover(clearings: Clearing[] = [], seed = SEED + 3): RockCover {
+  const broad = simplex(seed)
+  const fine = simplex(seed + 1)
+  return (theta, z) => {
+    const x = Math.cos(theta) * SKIN
+    const y = Math.sin(theta) * SKIN
+    let c = 0.6 + 0.42 * broad(x / 80, y / 80, z / 80) + 0.18 * fine(x / 22, y / 22, z / 22)
+    for (const k of clearings) {
+      let dt = Math.abs(theta - k.theta)
+      if (dt > Math.PI) dt = 2 * Math.PI - dt
+      const d = Math.hypot(dt * SKIN, z - k.z)
+      c *= smoothstep(k.r, k.r * 1.5, d)
+    }
+    return Math.max(0, Math.min(1, c))
+  }
+}
+
+const smoothstep = (a: number, b: number, x: number) => {
+  const t = Math.max(0, Math.min(1, (x - a) / (b - a)))
+  return t * t * (3 - 2 * t)
+}
+
 /**
  * Rock thickness at a point on the side, km above the skin.
  *
@@ -92,10 +135,18 @@ export function rockShell(seed = SEED): (theta: number, z: number) => number {
  * The shell as one mesh: the sun end's face, the side, the far end's face, as rings of
  * AROUND vertices joined in order. Rings share their seam — index i wraps to 0 — so the
  * normals are smooth all the way round.
+ *
+ * Given a `cover` (D-081), the rock thins toward the edge of each bank and is cut away
+ * where the cover falls below ROCK_COVER, so the plating shows through. The cut is made
+ * per pixel, from the cover carried on each vertex, so its edge is not the mesh's grid.
  */
-export function buildRockHull(): Mesh {
-  const height = rockShell()
-  const rings: { r: (i: number) => number; z: (i: number) => number }[] = []
+export function buildRockHull(cover?: RockCover): Mesh {
+  const shell = rockShell()
+  const height = cover
+    ? (theta: number, z: number) =>
+      Math.max(ROCK_MIN_KM, COVER_LIFT * shell(theta, z) * smoothstep(ROCK_COVER, ROCK_COVER + 0.05, cover(theta, z)))
+    : shell
+  const rings: { r: (i: number) => number; z: (i: number) => number; c: Float32Array }[] = []
   const thetaOf = (i: number) => (i / AROUND) * 2 * Math.PI
   const z0 = -HULL_D
   const z1 = L + HULL_D
@@ -104,34 +155,44 @@ export function buildRockHull(): Mesh {
     for (let i = 0; i < AROUND; i++) row[i] = SKIN + height(thetaOf(i), z)
     return row
   }
+  const coverAt = (z: number) => {
+    const row = new Float32Array(AROUND).fill(1)
+    if (cover) for (let i = 0; i < AROUND; i++) row[i] = cover(thetaOf(i), z)
+    return row
+  }
   const first = sideAt(z0)
   const last = sideAt(z1)
+  const firstC = coverAt(z0)
+  const lastC = coverAt(z1)
   // An end face: from the window's edge out to the side, bulging a little outward so the
   // rim reads as rock heaped round the glass rather than a lathe-turned ring.
-  const endFace = (edge: Float32Array, zEnd: number, dir: number, outward: boolean) => {
+  const endFace = (edge: Float32Array, c: Float32Array, zEnd: number, dir: number, outward: boolean) => {
     for (let k = 0; k < END_RINGS; k++) {
       const t = outward ? k / END_RINGS : 1 - k / END_RINGS
       rings.push({
         r: (i) => R + (edge[i]! - R) * t,
         z: (i) => zEnd + dir * Math.sin(Math.PI * t) * 0.35 * (edge[i]! - SKIN),
+        c,
       })
     }
   }
-  endFace(first, z0, -1, true)
+  endFace(first, firstC, z0, -1, true)
   for (let j = 0; j <= ALONG; j++) {
     const z = z0 + ((z1 - z0) * j) / ALONG
     const row = j === 0 ? first : j === ALONG ? last : sideAt(z)
-    rings.push({ r: (i) => row[i]!, z: () => z })
+    rings.push({ r: (i) => row[i]!, z: () => z, c: j === 0 ? firstC : j === ALONG ? lastC : coverAt(z) })
   }
-  endFace(last, z1, 1, false)
-  rings.push({ r: () => R, z: () => z1 })
+  endFace(last, lastC, z1, 1, false)
+  rings.push({ r: () => R, z: () => z1, c: lastC })
 
   const pos = new Float32Array(rings.length * AROUND * 3)
+  const cov = new Float32Array(rings.length * AROUND)
   rings.forEach((ring, j) => {
     for (let i = 0; i < AROUND; i++) {
       const a = thetaOf(i)
       const r = ring.r(i)
       pos.set([r * Math.cos(a), r * Math.sin(a), ring.z(i)], (j * AROUND + i) * 3)
+      cov[j * AROUND + i] = ring.c[i]!
     }
   })
   const idx = new Uint32Array((rings.length - 1) * AROUND * 6)
@@ -149,10 +210,11 @@ export function buildRockHull(): Mesh {
   }
   const g = new BufferGeometry()
   g.setAttribute('position', new BufferAttribute(pos, 3))
+  if (cover) g.setAttribute('aCover', new BufferAttribute(cov, 1))
   g.setIndex(new BufferAttribute(idx, 1))
   g.computeVertexNormals()
   g.computeBoundingSphere()
-  return new Mesh(g, rockMaterial())
+  return new Mesh(g, rockMaterial(!!cover))
 }
 
 /**
@@ -160,14 +222,24 @@ export function buildRockHull(): Mesh {
  * lights are (design.md §2.3) — a real C-type asteroid is as dark as charcoal, and would
  * be a silhouette here.
  */
-function rockMaterial(): MeshStandardMaterial {
+function rockMaterial(covered: boolean): MeshStandardMaterial {
   const m = new MeshStandardMaterial({ color: new Color(0x80776d), roughness: 1, metalness: 0 })
   m.onBeforeCompile = (shader) => {
+    if (covered) {
+      shader.vertexShader = `#define ROCK_COVERED\n${shader.vertexShader}`
+      shader.fragmentShader = `#define ROCK_COVERED\n${shader.fragmentShader}`
+    }
     shader.vertexShader = shader.vertexShader
-      .replace('#include <common>', '#include <common>\nvarying vec3 vRock;')
-      .replace('#include <begin_vertex>', '#include <begin_vertex>\nvRock = position;')
+      .replace('#include <common>', '#include <common>\nvarying vec3 vRock;\n#ifdef ROCK_COVERED\nattribute float aCover;\nvarying float vCover;\n#endif')
+      .replace('#include <begin_vertex>', '#include <begin_vertex>\nvRock = position;\n#ifdef ROCK_COVERED\nvCover = aCover;\n#endif')
     shader.fragmentShader = shader.fragmentShader
-      .replace('#include <common>', `#include <common>\n${ROCK_GLSL}\n${BUMP_GLSL}`)
+      .replace('#include <common>', `#include <common>\n${ROCK_GLSL}\n${BUMP_GLSL}\n#ifdef ROCK_COVERED\nvarying float vCover;\n#endif`)
+      .replace('#include <clipping_planes_fragment>', `#include <clipping_planes_fragment>
+  #ifdef ROCK_COVERED
+  // The edge of a bank, broken up below the mesh's spacing so it reads as a ragged
+  // shore of rock on the plating rather than a line joining vertices.
+  if (vCover < ${ROCK_COVER.toFixed(2)} + 0.05 * rockNoise(vRock / 1.3) + 0.03 * rockNoise(vRock / 0.4)) discard;
+  #endif`)
       .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
@@ -178,6 +250,8 @@ function rockMaterial(): MeshStandardMaterial {
       .replace('#include <normal_fragment_maps>', `#include <normal_fragment_maps>
   normal = bumpNormal(-vViewPosition, normal, vec2(dFdx(rockH), dFdy(rockH)), faceDirection);`)
   }
+  // Three caches programs by onBeforeCompile's source, which is the same for both.
+  m.customProgramCacheKey = () => `rock|${covered}`
   return m
 }
 
diff --git a/test/pipe-hull.test.ts b/test/pipe-hull.test.ts
index 9c69ac6..ca39fc2 100644
--- a/test/pipe-hull.test.ts
+++ b/test/pipe-hull.test.ts
@@ -57,6 +57,51 @@ describe('the piping skin (D-077)', () => {
   })
 })
 
+describe('the plant under rock (D-081)', () => {
+  // Bare only on one half of the hull, by angle: easy to check a part against.
+  const bare = (theta: number) => Math.cos(theta) > 0
+  const hull = buildPipeHull((theta) => bare(theta))
+  const instanced = hull.group.children.filter((c): c is InstancedMesh => c instanceof InstancedMesh)
+
+  it('puts the plant rooms and vents only on bare ground', () => {
+    // Blocks and vent housings. The blocks include the pipes' brackets, which follow the
+    // pipes under the rock and are told apart by their length along the axis, 0.35 km.
+    const [, boxes, housings] = instanced
+    const m = new Matrix4()
+    const v = new Vector3()
+    const scale = new Vector3()
+    let rooms = 0
+    for (let i = 0; i < boxes!.count; i++) {
+      boxes!.getMatrixAt(i, m)
+      v.setFromMatrixPosition(m)
+      scale.setFromMatrixScale(m)
+      if (Math.abs(scale.z - 0.35) < 1e-6) continue
+      rooms++
+      expect(bare(Math.atan2(v.y, v.x))).toBe(true)
+    }
+    expect(rooms).toBeGreaterThan(500)
+    for (let i = 0; i < housings!.count; i++) {
+      housings!.getMatrixAt(i, m)
+      v.setFromMatrixPosition(m)
+      expect(bare(Math.atan2(v.y, v.x))).toBe(true)
+    }
+  })
+
+  it('says where its thruster pods stand, so the rock can be dug clear of them', () => {
+    expect(hull.pods.length).toBe(12)
+  })
+
+  it('is the same plant as ever when nothing is under rock', () => {
+    const a = new Matrix4()
+    const b = new Matrix4()
+    const plain = buildPipeHull().group.children.filter((c): c is InstancedMesh => c instanceof InstancedMesh)
+    ;(plain[1]!).getMatrixAt(plain[1]!.count - 1, a)
+    ;(buildPipeHull(() => true).group.children.filter((c): c is InstancedMesh => c instanceof InstancedMesh)[1]!)
+      .getMatrixAt(plain[1]!.count - 1, 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 }
 
diff --git a/test/rock-hull.test.ts b/test/rock-hull.test.ts
index d366ea7..bce584d 100644
--- a/test/rock-hull.test.ts
+++ b/test/rock-hull.test.ts
@@ -1,5 +1,5 @@
 import { describe, expect, it } from 'vitest'
-import { ROCK_MIN_KM, rockShell } from '../src/views/rock-hull.js'
+import { ROCK_COVER, ROCK_MIN_KM, rockCover, rockShell } from '../src/views/rock-hull.js'
 
 describe('the rock shell (D-076)', () => {
   const h = rockShell()
@@ -24,3 +24,26 @@ describe('the rock shell (D-076)', () => {
     expect(rockShell()(1, 100)).toBe(h(1, 100))
   })
 })
+
+describe('the rock laid over the plant (D-081)', () => {
+  const cover = rockCover()
+  const samples = Array.from({ length: 4000 }, (_, i) => cover((i * 2.399963) % (2 * Math.PI), (i * 0.1357) % 540))
+
+  it('covers most of the hull and leaves some bare', () => {
+    const covered = samples.filter((c) => c >= ROCK_COVER).length / samples.length
+    expect(covered).toBeGreaterThan(0.5)
+    expect(covered).toBeLessThan(0.75)
+  })
+
+  it('is one field across the seam at theta = 0', () => {
+    for (const z of [3, 120, 333, 539]) expect(cover(1e-9, z)).toBeCloseTo(cover(2 * Math.PI - 1e-9, z), 5)
+  })
+
+  it('is dug clear round a clearing', () => {
+    const dug = rockCover([{ theta: 1, z: 200, r: 14 }])
+    for (let a = 0; a < 2 * Math.PI; a += 0.3) {
+      const d = 13 * Math.random()
+      expect(dug(1 + (d * Math.cos(a)) / 51, 200 + d * Math.sin(a))).toBe(0)
+    }
+  })
+})