fuzzyworld

tower / commit

feat(exterior): try a rock hull, dressed as an asteroid (D-076)

908833aa4c2c9e3a3a596b3f761cdb3c3ef178fe · Pavel Safronov · 2026-09-27 21:15:32 -0700 · parents e331ec2af1ca

Claude Code usage: this commit ≈0 out / ≈0 cached · cost unknown (no per-model trailer) · session 3 tally 300 in / 170.0K out / 17.2M cached · usage by commit and session

feat(exterior): try a rock hull, dressed as an asteroid (D-076)

A third Hull reading beside True and X-ray, labelled an experiment:
a lumpy, cratered shell laid over the skin, windows left clear.

Usage: 0.3K in / 170.0K out / 17.2M cached (session tally)
rtk:   963.0K saved, 15.9% over 2824 cmds (global/lifetime)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
 README.md              |   6 +-
 decisions.md           |  23 +++++
 design.md              |  10 +-
 index.html             |   4 +-
 src/ui/app.ts          |   8 +-
 src/views/exterior.ts  |  34 +++++--
 src/views/rock-hull.ts | 241 +++++++++++++++++++++++++++++++++++++++++++++++++
 test/rock-hull.test.ts |  26 ++++++
 8 files changed, 338 insertions(+), 14 deletions(-)

diff --git a/README.md b/README.md
index ae731b8..49f1421 100644
--- a/README.md
+++ b/README.md
@@ -278,14 +278,16 @@ 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 two ways and the **Hull** switch picks one; it opens on True and
+The hull reads three 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. The active view is remembered and travels in the permalink, so
+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
 `#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 f78b673..974fd87 100644
--- a/decisions.md
+++ b/decisions.md
@@ -1432,6 +1432,8 @@ 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.*
+
 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
 structure, so terrain on the outside is not what Tower looks like — it is a diagram
@@ -2522,3 +2524,24 @@ vision.
 star at the middle" of the pinwheel. The real sky has no star at the antisolar point, and it
 moves a degree a day, so no real star could hold the place. The placed star at the centre
 stays for now and is on the backlog (design.md §5) to revisit; removing it is one line.
+
+## D-076 — Trying other outsides: rock
+
+**Asked for** (author, 2026-09-27): the outside is very uniform; try different approaches,
+starting with "a rocky surface, almost like someone tried to make it look like an asteroid".
+
+**An experiment, not canon.** It is a third reading in the Hull switch — True, **Rock**,
+X-ray — so it can be compared with the bare skin and kept, changed or dropped. Nothing in
+§1 changes. A shell kilometres thick round a 540 km cylinder would be over 10¹⁸ kg of rock
+on a spinning hull, which canon would have to answer for if it stays.
+
+**The shape.** A shell laid over the metal skin, never cut into it: at least 0.2 km proud of
+the wall's outer face everywhere, so nothing inside can show through, and the windows are
+left clear, with rock heaped round their rims. Lumps a few kilometres high and 1,400 craters
+from 0.8 to 16 km across, a power-law population with raised rims, in the geometry
+(`src/views/rock-hull.ts`, ~0.7 km between vertices); grit, pits and colour mottling below
+that in the shader, bump-mapped from noise in the hull's own frame and faded out as a pixel
+grows wider than the detail. Seeded, so it is the same rock on every load. Clicks still land
+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.
diff --git a/design.md b/design.md
index 5fb89f2..7ea90d5 100644
--- a/design.md
+++ b/design.md
@@ -838,6 +838,11 @@ ecliptic longitude, and turns once a year. Outside, ecliptic north is up (scene
 roll square to the axis on every day. Spin does not turn it — the camera is not riding the
 hull — and there is no daylight to adapt to, so it is drawn at full gain (D-060).
 
+**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.
+
 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
 is the least intuitive object in Tower: it is larger than the world it feeds.
@@ -1167,4 +1172,7 @@ Things decided to be worth coming back to, not scheduled.
   star at the centre of the turning sky, and the real sky (D-075) has none there — the
   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.
\ No newline at end of file
+  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
+  heavy one stays, answer for its mass.
diff --git a/index.html b/index.html
index 316a923..0d30543 100644
--- a/index.html
+++ b/index.html
@@ -54,12 +54,14 @@
         <label>Hull
           <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="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.</p>
+          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>
         <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 5380163..936e6c5 100644
--- a/src/ui/app.ts
+++ b/src/ui/app.ts
@@ -14,7 +14,7 @@ import { seaStateOf } from '../terrain/sea-state.js'
 import { hoursOf, occurrences, timeOf } from '../world/weather.js'
 import { KIND_COLOR } from '../views/palette.js'
 import { TowerMap } from '../views/map.js'
-import type { ExteriorView } from '../views/exterior.js'
+import type { ExteriorView, HullMode } from '../views/exterior.js'
 import type { InteriorView } from '../views/interior.js'
 import { clock } from '../world/clock.js'
 import { clockText, dateLabel, hoursAt, isDial, skyAt } from '../world/sky.js'
@@ -430,7 +430,7 @@ export class App {
       const speed = $<HTMLInputElement>('f-speed')
       this.exterior.spinning = spin.checked
       this.exterior.timeScale = SPEEDS[Number(speed.value)]!
-      this.exterior.showTerrain = $<HTMLSelectElement>('f-ext-view').value === 'xray'
+      this.exterior.hullMode = $<HTMLSelectElement>('f-ext-view').value as HullMode
     }
     this.exterior.resize()
     this.exterior.start()
@@ -661,10 +661,10 @@ 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 === 'xray') ext.value = stored
+      if (stored === 'true' || stored === 'rock' || stored === 'xray') ext.value = stored
     } catch { /* unreadable storage just means the default, true */ }
     ext.onchange = () => {
-      if (this.exterior) this.exterior.showTerrain = ext.value === 'xray'
+      if (this.exterior) this.exterior.hullMode = ext.value as HullMode
       try {
         localStorage.setItem(HULL_KEY, ext.value)
       } catch { /* not remembering the hull is survivable */ }
diff --git a/src/views/exterior.ts b/src/views/exterior.ts
index aab7aa4..d3233b3 100644
--- a/src/views/exterior.ts
+++ b/src/views/exterior.ts
@@ -27,6 +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'
 
 const KM = 1000
 const R = RADIUS_M / KM
@@ -107,6 +108,12 @@ 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.
+ */
+export type HullMode = 'true' | 'rock' | 'xray'
+
 export interface ExteriorCallbacks {
   onPickPoint: (p: SurfacePoint) => void
 }
@@ -131,7 +138,11 @@ export class ExteriorView {
   private eyeInTower = new Vector3()
   private terrainMap: Texture | null = null
   /** True mode by default (D-061); the app sets the remembered reading on construction. */
-  private _showTerrain = false
+  private _hullMode: HullMode = 'true'
+  /** The rock shell (D-076), built the first time it is asked for. */
+  private rock: Mesh | null = null
+  /** The metal skin past each end of the world, which the rock replaces. */
+  private metalEnds: Mesh[] = []
   private raycaster = new Raycaster()
   private running = false
   private lastFrame = 0
@@ -285,7 +296,7 @@ export class ExteriorView {
   }
 
   private applyTerrain(): void {
-    const on = this._showTerrain && this.terrainMap
+    const on = this._hullMode === 'xray' && this.terrainMap
     this.hullMat.map = on ? this.terrainMap : null
     /**
      * Self-illuminate the terrain. Sunlight runs along the axis, so most of the
@@ -303,8 +314,18 @@ export class ExteriorView {
     this.hullMat.needsUpdate = true
   }
 
-  set showTerrain(v: boolean) {
-    this._showTerrain = v
+  set hullMode(mode: HullMode) {
+    this._hullMode = mode
+    if (mode === 'rock' && !this.rock) {
+      this.rock = buildRockHull()
+      this.tower.add(this.rock)
+    }
+    const rock = mode === 'rock'
+    if (this.rock) this.rock.visible = rock
+    // 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
+    for (const m of this.metalEnds) m.visible = !rock
     this.applyTerrain()
   }
 
@@ -329,11 +350,12 @@ export class ExteriorView {
       const outer = new CylinderGeometry(R + HULL_D, R + HULL_D, HULL_D, 160, 1, true)
       outer.rotateX(Math.PI / 2)
       outer.translate(0, 0, z0 + HULL_D / 2)
-      this.tower.add(new Mesh(outer, metal(FrontSide)))
       // The end face of the wall: the kilometre between the window's edge and the skin.
       const face = new RingGeometry(R, R + HULL_D, 160)
       face.translate(0, 0, z0 < 0 ? z0 : L + HULL_D)
-      this.tower.add(new Mesh(face, metal(DoubleSide)))
+      const ends = [new Mesh(outer, metal(FrontSide)), new Mesh(face, metal(DoubleSide))]
+      this.metalEnds.push(...ends)
+      this.tower.add(...ends)
     }
     /**
      * The sun end's inner face has no bore behind it — the sun cap window sits at the
diff --git a/src/views/rock-hull.ts b/src/views/rock-hull.ts
new file mode 100644
index 0000000..1124470
--- /dev/null
+++ b/src/views/rock-hull.ts
@@ -0,0 +1,241 @@
+/**
+ * The rock skin: an exterior experiment, Tower dressed as an asteroid (D-076). Not canon.
+ *
+ * The author asked to try different outsides, starting with "a rocky surface, almost
+ * like someone tried to make it look like an asteroid". So this is a shell laid over the
+ * metal skin, never cut into it: every point is at least ROCK_MIN_KM out from the wall's
+ * outer face (D-044), so nothing inside Tower — sea floors, tile skirts — can show
+ * through, and the windows at the ends are left clear.
+ *
+ * The shape is in two scales, because the camera spans two:
+ * - **Geometry** carries what reads from a thousand kilometres off: lumps a few
+ *   kilometres high, and craters up to thirty across with raised rims.
+ * - **The shader** carries what reads from sixty: grit, small pits and colour mottling,
+ *   bump-mapped from noise in the hull's own frame, faded out as a pixel grows wider
+ *   than the detail so the far hull does not shimmer.
+ *
+ * Everything is seeded, so it is the same rock on every load.
+ */
+import {
+  BufferAttribute, BufferGeometry, Color, Mesh, MeshStandardMaterial,
+} from 'three'
+import { HULL_DEPTH_M, LENGTH_M, RADIUS_M } from '../canon/constants.js'
+import { mulberry32, simplex } from '../terrain/noise.js'
+
+const KM = 1000
+const R = RADIUS_M / KM
+const L = LENGTH_M / KM
+const HULL_D = HULL_DEPTH_M / KM
+/** The skin the rock sits on. */
+const SKIN = R + HULL_D
+/** The thinnest the rock ever gets, km: always a little proud of the metal. */
+export const ROCK_MIN_KM = 0.2
+
+const SEED = 0x70c4
+/** Vertices around, and rows along, the side. ~0.7 km apart around, ~0.8 km along. */
+const AROUND = 480
+const ALONG = 680
+/** Rings across each end face, from the window's edge out to the side. */
+const END_RINGS = 6
+
+interface Crater { theta: number; z: number; r: number }
+
+/**
+ * Rock thickness at a point on the side, km above the skin.
+ *
+ * Lumps: two octaves of broad noise, so the outline against the stars is irregular rather
+ * than a cylinder with texture. Craters: a power-law population — many small, few large,
+ * as on any real airless body — each a bowl a fifth as deep as it is wide with a raised
+ * rim that falls away outside it.
+ */
+export function rockShell(seed = SEED): (theta: number, z: number) => number {
+  const lumps = simplex(seed)
+  const detail = simplex(seed + 1)
+  const rand = mulberry32(seed + 2)
+  const craters: Crater[] = []
+  for (let i = 0; i < 1400; i++) {
+    // r ∝ u^-1.4 between 0.8 and 16 km: a steep cumulative size distribution.
+    const r = Math.min(16, 0.8 * rand() ** -0.7)
+    craters.push({ theta: rand() * 2 * Math.PI, z: -HULL_D + rand() * (L + 2 * HULL_D), r })
+  }
+  // Binned along z so a vertex only looks at craters that could reach it.
+  const BIN = 20
+  const bins: Crater[][] = Array.from({ length: Math.ceil((L + 40) / BIN) + 1 }, () => [])
+  const binOf = (z: number) => Math.max(0, Math.min(bins.length - 1, Math.floor((z + 20) / BIN)))
+  for (const c of craters) {
+    for (let b = binOf(c.z - 2 * c.r); b <= binOf(c.z + 2 * c.r); b++) bins[b]!.push(c)
+  }
+
+  return (theta, z) => {
+    const x = Math.cos(theta) * SKIN
+    const y = Math.sin(theta) * SKIN
+    let h = 1.6
+      + 1.7 * lumps(x / 70, y / 70, z / 70)
+      + 0.8 * lumps(x / 24 + 11, y / 24, z / 24)
+      + 0.3 * detail(x / 7, y / 7, z / 7)
+    for (const c of bins[binOf(z)]!) {
+      let dt = Math.abs(theta - c.theta)
+      if (dt > Math.PI) dt = 2 * Math.PI - dt
+      const dx = dt * SKIN
+      const dz = z - c.z
+      const d2 = dx * dx + dz * dz
+      if (d2 > 4 * c.r * c.r) continue
+      const q = Math.sqrt(d2) / c.r
+      const rim = 0.06 * c.r
+      h += q < 1 ? 0.2 * c.r * (q * q - 1) + rim : rim * Math.exp(-((q - 1) * (q - 1)) / 0.12)
+    }
+    return Math.max(ROCK_MIN_KM, h)
+  }
+}
+
+/**
+ * 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.
+ */
+export function buildRockHull(): Mesh {
+  const height = rockShell()
+  const rings: { r: (i: number) => number; z: (i: number) => number }[] = []
+  const thetaOf = (i: number) => (i / AROUND) * 2 * Math.PI
+  const z0 = -HULL_D
+  const z1 = L + HULL_D
+  const sideAt = (z: number) => {
+    const row = new Float32Array(AROUND)
+    for (let i = 0; i < AROUND; i++) row[i] = SKIN + height(thetaOf(i), z)
+    return row
+  }
+  const first = sideAt(z0)
+  const last = sideAt(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) => {
+    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),
+      })
+    }
+  }
+  endFace(first, 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 })
+  }
+  endFace(last, z1, 1, false)
+  rings.push({ r: () => R, z: () => z1 })
+
+  const pos = new Float32Array(rings.length * AROUND * 3)
+  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)
+    }
+  })
+  const idx = new Uint32Array((rings.length - 1) * AROUND * 6)
+  let n = 0
+  for (let j = 0; j + 1 < rings.length; j++) {
+    for (let i = 0; i < AROUND; i++) {
+      const a = j * AROUND + i
+      const b = j * AROUND + ((i + 1) % AROUND)
+      const c = a + AROUND
+      const d = b + AROUND
+      // Wound so the faces point outward, as the metal skin's do.
+      idx.set([a, b, c, b, d, c], n)
+      n += 6
+    }
+  }
+  const g = new BufferGeometry()
+  g.setAttribute('position', new BufferAttribute(pos, 3))
+  g.setIndex(new BufferAttribute(idx, 1))
+  g.computeVertexNormals()
+  g.computeBoundingSphere()
+  return new Mesh(g, rockMaterial())
+}
+
+/**
+ * Regolith: dark grey-brown, matte, not metal. Albedo is licensed upward the way the fill
+ * 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 {
+  const m = new MeshStandardMaterial({ color: new Color(0x80776d), roughness: 1, metalness: 0 })
+  m.onBeforeCompile = (shader) => {
+    shader.vertexShader = shader.vertexShader
+      .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 <map_fragment>', `#include <map_fragment>
+  float rockH = rockHeight(vRock);
+  // Mottling: lighter on the highs, darker in the pits, and patches of warmer and colder
+  // rock at a larger scale, as ejecta lies over whatever it landed on.
+  float ejecta = rockNoise(vRock / 9.0);
+  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);`)
+  }
+  return m
+}
+
+const ROCK_GLSL = /* glsl */`
+varying vec3 vRock;
+
+float rockHash(vec3 p) {
+  p = fract(p * 0.1031);
+  p += dot(p, p.zyx + 31.32);
+  return fract((p.x + p.y) * p.z);
+}
+// Value noise, −0.5..0.5, smooth.
+float rockNoise(vec3 p) {
+  vec3 i = floor(p);
+  vec3 f = fract(p);
+  f = f * f * (3.0 - 2.0 * f);
+  float a = mix(mix(rockHash(i), rockHash(i + vec3(1, 0, 0)), f.x),
+                mix(rockHash(i + vec3(0, 1, 0)), rockHash(i + vec3(1, 1, 0)), f.x), f.y);
+  float b = mix(mix(rockHash(i + vec3(0, 0, 1)), rockHash(i + vec3(1, 0, 1)), f.x),
+                mix(rockHash(i + vec3(0, 1, 1)), rockHash(i + vec3(1, 1, 1)), f.x), f.y);
+  return mix(a, b, f.z) - 0.5;
+}
+
+/**
+ * Fine relief in kilometres, from 3 km lumps down to 50 m grit. Each octave fades out
+ * once a pixel spans a good part of it: detail finer than a pixel is not detail, it is
+ * the sparkle that makes a far surface crawl as it turns.
+ */
+float rockHeight(vec3 p) {
+  float px = length(fwidth(p));
+  float h = 0.0;
+  float amp = 0.35;
+  float freq = 1.0 / 3.0;
+  for (int o = 0; o < 6; o++) {
+    float w = 1.0 - smoothstep(0.15, 0.5, px * freq);
+    // Ridged: sharp crests and soft troughs, like weathered rock rather than cloud.
+    float n = 0.5 - abs(rockNoise(p * freq + float(o) * 17.3));
+    h += amp * w * (n - 0.25);
+    amp *= 0.5;
+    freq *= 2.1;
+  }
+  return h;
+}
+
+/**
+ * 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 vSigmaX = dFdx(surf_pos.xyz);
+  vec3 vSigmaY = dFdy(surf_pos.xyz);
+  vec3 vN = surf_norm;
+  vec3 R1 = cross(vSigmaY, vN);
+  vec3 R2 = cross(vN, vSigmaX);
+  float fDet = dot(vSigmaX, R1) * faceDirection;
+  vec3 vGrad = sign(fDet) * (dHdxy.x * R1 + dHdxy.y * R2);
+  return normalize(abs(fDet) * surf_norm - vGrad);
+}
+`
diff --git a/test/rock-hull.test.ts b/test/rock-hull.test.ts
new file mode 100644
index 0000000..d366ea7
--- /dev/null
+++ b/test/rock-hull.test.ts
@@ -0,0 +1,26 @@
+import { describe, expect, it } from 'vitest'
+import { ROCK_MIN_KM, rockShell } from '../src/views/rock-hull.js'
+
+describe('the rock shell (D-076)', () => {
+  const h = rockShell()
+
+  it('never sinks into the metal skin', () => {
+    for (let i = 0; i < 4000; i++) {
+      const v = h((i * 2.399963) % (2 * Math.PI), (i * 0.1357) % 542 - 1)
+      expect(v).toBeGreaterThanOrEqual(ROCK_MIN_KM)
+    }
+  })
+
+  it('is one surface across the seam at theta = 0', () => {
+    for (const z of [3, 120, 333, 539]) expect(h(1e-9, z)).toBeCloseTo(h(2 * Math.PI - 1e-9, z), 5)
+  })
+
+  it('has relief worth the name: kilometres, not metres', () => {
+    const vals = Array.from({ length: 2000 }, (_, i) => h((i * 0.61) % (2 * Math.PI), (i * 0.27) % 540))
+    expect(Math.max(...vals) - Math.min(...vals)).toBeGreaterThan(3)
+  })
+
+  it('is the same rock on every load', () => {
+    expect(rockShell()(1, 100)).toBe(h(1, 100))
+  })
+})