Back to Mini Samurai
SOURCE / PINNED RELEASE

Made of little things.

Mini Samurai

Release
a7beb798adbc…
Author-recorded commit
4f97be1515b8…
License
LICENSE
Author’s source reference
nostr://npub12lm3rxsva8kfempyjkxupt09e02n3fezurkyadyqfejdc5e0r2kscqty6r/wss%3A%2F%2Fgit.napplet.soy%2F/n-3b28f0efc5b

Archive hash verified: 401ec9347ab8f8cd…. The source-to-build association is the author’s claim; it has not been independently rebuilt.

src/game/rooms.ts
import * as THREE from 'three';
import { buildTrack } from './train';
import type { Area, DripDef, MachineDef, PoolDef, RollerDef, RoomData, RoomId, Solid, SpawnPoint, TrackDef, WallPlane } from './types';

/**
 * The four arenas: giant rooms built from axis-aligned boxes at 1 unit = 10 cm.
 * Every big furniture top is reachable from the floor through a chain of
 * household "steps" 1.0–2.6 units apart (fighters jump ~1.4 and grab ledges
 * 0.8–1.75 above their feet). Gaps between solids are either 0 or >= 0.7.
 */

export const ROOMS: { id: RoomId; name: string; blurb: string }[] = [
  { id: 'living', name: 'Living Room', blurb: 'Sofa cliffs, a coffee-table plateau and a bookcase fort.' },
  { id: 'kitchen', name: 'Kitchen', blurb: 'Checker tiles, a cherry-red fridge and the great counter climb.' },
  { id: 'bedroom', name: 'Bedroom', blurb: 'Blanket hills, a crawl under the bed and a dresser of open drawers.' },
  { id: 'bathroom', name: 'Bathroom', blurb: 'Slick tiles, a bathtub pit and a toilet-paper tower.' },
  { id: 'garage', name: 'Garage', blurb: 'A stepladder, a lawn mower, tyres and a workbench of tools.' },
  { id: 'house', name: 'Whole House', blurb: 'Every room off one hallway, a dining room, and a toy train to ride.' },
];

const ROOM_NAMES: Partial<Record<RoomId, string>> = { dining: 'Dining Room' };

export function buildRoom(id: RoomId): RoomData {
  switch (id) {
    case 'living':
      return buildLiving();
    case 'kitchen':
      return buildKitchen();
    case 'bedroom':
      return buildBedroom();
    case 'bathroom':
      return buildBathroom();
    case 'dining':
      return buildDining();
    case 'garage':
      return buildGarage();
    case 'house':
      return buildHouse();
  }
}

// ---------------------------------------------------------------------------
// Shared resources (geometries are unit-sized and scaled per mesh; materials are
// cached by colour). They are shared between rooms, so callers must not dispose them.

type Shape = 'box' | 'cyl' | 'cyl6' | 'cone' | 'shade' | 'bowl' | 'ball';

const GEO: Record<Shape, THREE.BufferGeometry> = {
  box: new THREE.BoxGeometry(1, 1, 1),
  cyl: new THREE.CylinderGeometry(0.5, 0.5, 1, 10),
  cyl6: new THREE.CylinderGeometry(0.5, 0.5, 1, 6),
  cone: new THREE.CylinderGeometry(0.05, 0.5, 1, 7),
  shade: new THREE.CylinderGeometry(0.3, 0.5, 1, 8),
  bowl: new THREE.CylinderGeometry(0.5, 0.34, 1, 10),
  ball: new THREE.IcosahedronGeometry(0.5, 1),
};

const MATS = new Map<string, THREE.MeshStandardMaterial>();

function mat(color: number, emissive = 0): THREE.MeshStandardMaterial {
  const key = `${color}:${emissive}`;
  let m = MATS.get(key);
  if (!m) {
    m = new THREE.MeshStandardMaterial({
      color,
      emissive,
      emissiveIntensity: emissive ? 0.85 : 0,
      flatShading: true,
      roughness: 0.8,
      metalness: 0,
    });
    MATS.set(key, m);
  }
  return m;
}

function rng(seed: number): () => number {
  let s = seed;
  return () => (s = (s * 16807) % 2147483647) / 2147483647;
}

// ---------------------------------------------------------------------------
// Builder

interface Opts {
  shape?: Shape;
  name?: string;
  grab?: boolean;
  parent?: THREE.Object3D;
  rotY?: number;
  emissive?: number;
  slide?: boolean;
  cast?: boolean;
}

/** A doorway in one wall: from u0 to u1 along the wall, h high (20 by default). */
interface DoorSpec {
  u0: number;
  u1: number;
  h?: number;
}

type Doors = Partial<Record<WallSide, DoorSpec[]>>;

class RoomBuilder {
  readonly root = new THREE.Group();
  readonly solids: Solid[] = [];
  readonly cutaway: RoomData['cutaway'] = [];
  /** Walkable strips through the walls' doorways (part of a house). */
  readonly doorAreas: Area[] = [];

  /** `doors` is set when the room is part of the house: walls get openings and the floor stops at the walls. */
  constructor(
    readonly bounds: RoomData['bounds'],
    readonly doors?: Doors,
  ) {}

  /** Collider only. */
  solid(x0: number, y0: number, z0: number, x1: number, y1: number, z1: number, name?: string, grab = true, slide = false, round = false): void {
    this.solids.push({
      round: round || undefined,
      minX: Math.min(x0, x1),
      minY: Math.min(y0, y1),
      minZ: Math.min(z0, z1),
      maxX: Math.max(x0, x1),
      maxY: Math.max(y0, y1),
      maxZ: Math.max(z0, z1),
      grab,
      slide: slide || undefined,
      name,
    });
  }

  /** Mesh only, filling the given box. */
  deco(x0: number, y0: number, z0: number, x1: number, y1: number, z1: number, color: number, o: Opts = {}): THREE.Mesh {
    const m = new THREE.Mesh(GEO[o.shape ?? 'box'], mat(color, o.emissive ?? 0));
    m.position.set((x0 + x1) / 2, (y0 + y1) / 2, (z0 + z1) / 2);
    m.scale.set(Math.abs(x1 - x0), Math.abs(y1 - y0), Math.abs(z1 - z0));
    if (o.rotY) m.rotation.y = o.rotY;
    m.castShadow = o.cast ?? true;
    m.receiveShadow = true;
    (o.parent ?? this.root).add(m);
    return m;
  }

  /** Mesh plus matching collider (round when the shape is, and its footprint is square). */
  box(x0: number, y0: number, z0: number, x1: number, y1: number, z1: number, color: number, o: Opts = {}): THREE.Mesh {
    const roundShape = o.shape === 'cyl' || o.shape === 'cyl6' || o.shape === 'bowl' || o.shape === 'ball' || o.shape === 'shade';
    const square = Math.abs(Math.abs(x1 - x0) - Math.abs(z1 - z0)) < 0.05;
    this.solid(x0, y0, z0, x1, y1, z1, o.name, o.grab ?? true, o.slide ?? false, roundShape && square);
    return this.deco(x0, y0, z0, x1, y1, z1, color, o);
  }

  /** Round object (cylinder by default) centred on (cx, cz), with its AABB as collider. */
  round(cx: number, y0: number, cz: number, r: number, h: number, color: number, o: Opts = {}, solid = true): THREE.Mesh {
    const args = [cx - r, y0, cz - r, cx + r, y0 + h, cz + r] as const;
    const opts = { shape: 'cyl' as Shape, ...o };
    return solid ? this.box(...args, color, opts) : this.deco(...args, color, opts);
  }
}

// ---------------------------------------------------------------------------
// Room shell: floor collider, four cutaway walls with baseboards, windows

type WallSide = 'back' | 'front' | 'left' | 'right';

interface Wall {
  group: THREE.Group;
  along: 'x' | 'z';
  face: number;
  inward: 1 | -1;
}

/** Deco box on a wall: u runs along the wall, d is depth into the room from its face. */
function wallBox(
  b: RoomBuilder,
  w: Wall,
  u0: number,
  u1: number,
  y0: number,
  y1: number,
  d0: number,
  d1: number,
  color: number,
  o: Opts = {},
): THREE.Mesh {
  const a = w.face + w.inward * d0;
  const c = w.face + w.inward * d1;
  const opts = { parent: w.group, ...o };
  return w.along === 'x' ? b.deco(u0, y0, a, u1, y1, c, color, opts) : b.deco(a, y0, u0, c, y1, u1, color, opts);
}

/** Collider on a wall, in the same wall coordinates as wallBox. */
function wallSolid(b: RoomBuilder, w: Wall, u0: number, u1: number, y0: number, y1: number, d0: number, d1: number, name: string, grab = false): void {
  const a = w.face + w.inward * d0;
  const c = w.face + w.inward * d1;
  if (w.along === 'x') b.solid(u0, y0, a, u1, y1, c, name, grab);
  else b.solid(a, y0, u0, c, y1, u1, name, grab);
}

/** The parts of [lo, hi] not covered by doorways. */
function openSpans(lo: number, hi: number, doors: DoorSpec[]): [number, number][] {
  const out: [number, number][] = [];
  let u = lo;
  for (const d of [...doors].sort((p, q) => p.u0 - q.u0)) {
    if (d.u0 > u) out.push([u, d.u0]);
    u = Math.max(u, d.u1);
  }
  if (u < hi) out.push([u, hi]);
  return out;
}

const WALL_T = 1;
/** Height of the bottom strip of wall that stays solid when the rest fades away. */
const WALL_BASE = 1.5;
const WALL_H = 25;
const DOOR_H = 20;

function shell(
  b: RoomBuilder,
  wallColor: number,
  trim: number,
  wainscot?: { color: number; top: number; rail: number },
): Record<WallSide, Wall> {
  const { minX, maxX, minZ, maxZ } = b.bounds;
  const T = WALL_T;
  const H = WALL_H;
  // In the house the floor stops at the walls, so the AI only finds its way between rooms through doorways.
  if (b.doors) b.solid(minX, -1, minZ, maxX, 0, maxZ, 'floor', false);
  else b.solid(minX - 2, -1, minZ - 2, maxX + 2, 0, maxZ + 2, 'floor', false);

  const make = (
    side: WallSide,
    along: 'x' | 'z',
    face: number,
    inward: 1 | -1,
    normal: THREE.Vector3,
    w0: number,
    w1: number,
    u0: number,
    u1: number,
  ): Wall => {
    const group = new THREE.Group();
    group.name = `wall-${side}`;
    b.root.add(group);
    const w: Wall = { group, along, face, inward };
    const plane: WallPlane = { axis: along === 'x' ? 'z' : 'x', at: face - (inward * T) / 2, u0: w0, u1: w1 };
    b.cutaway.push({ mesh: group, normal, plane });
    const doors = b.doors?.[side] ?? [];
    for (const [s0, s1] of openSpans(w0, w1, doors)) {
      wallSolid(b, w, s0, s1, -1, H, -T, 0, 'wall');
      wallBox(b, w, s0, s1, -1, WALL_BASE, -T, 0, wallColor, { cast: false, parent: b.root });
      wallBox(b, w, s0, s1, WALL_BASE, H, -T, 0, wallColor, { cast: false });
    }
    const open = openSpans(u0, u1, doors);
    for (const [s0, s1] of open) wallBox(b, w, s0, s1, 0, 0.8, 0, 0.15, trim, { cast: false, parent: b.root });
    if (wainscot) {
      for (const [s0, s1] of open) {
        wallBox(b, w, s0, s1, 0.8, wainscot.top, 0, 0.06, wainscot.color, { cast: false });
        wallBox(b, w, s0, s1, wainscot.top, wainscot.top + 0.3, 0, 0.14, wainscot.rail, { cast: false });
      }
    }
    for (const d of doors) {
      const h = d.h ?? DOOR_H;
      // Lintel over the opening, a casing round it and a floor strip through the wall.
      wallSolid(b, w, d.u0, d.u1, h, H, -T, 0, 'wall');
      wallBox(b, w, d.u0, d.u1, h, H, -T, 0, wallColor, { cast: false });
      wallBox(b, w, d.u0 - 0.6, d.u0, 0, h + 0.6, 0, 0.18, trim);
      wallBox(b, w, d.u1, d.u1 + 0.6, 0, h + 0.6, 0, 0.18, trim);
      wallBox(b, w, d.u0 - 0.6, d.u1 + 0.6, h, h + 0.6, 0, 0.18, trim);
      wallBox(b, w, d.u0, d.u1, -0.3, 0.02, -T, 0, 0x8a6a48, { cast: false });
      wallSolid(b, w, d.u0, d.u1, -1, 0, -T, 0, 'doorway');
      const a = face - inward * T;
      if (along === 'x') b.doorAreas.push({ minX: d.u0, maxX: d.u1, minZ: Math.min(a, face), maxZ: Math.max(a, face), doorway: true });
      else b.doorAreas.push({ minX: Math.min(a, face), maxX: Math.max(a, face), minZ: d.u0, maxZ: d.u1, doorway: true });
    }
    return w;
  };

  return {
    back: make('back', 'x', minZ, 1, new THREE.Vector3(0, 0, 1), minX - T, maxX + T, minX, maxX),
    front: make('front', 'x', maxZ, -1, new THREE.Vector3(0, 0, -1), minX - T, maxX + T, minX, maxX),
    left: make('left', 'z', minX, 1, new THREE.Vector3(1, 0, 0), minZ, maxZ, minZ, maxZ),
    right: make('right', 'z', maxX, -1, new THREE.Vector3(-1, 0, 0), minZ, maxZ, minZ, maxZ),
  };
}

/** Glowing afternoon window with frame, mullions, sill and optional curtains. */
function windowOn(b: RoomBuilder, w: Wall, u0: number, u1: number, y0: number, y1: number, trim: number, curtain?: number): void {
  wallBox(b, w, u0 - 0.6, u1 + 0.6, y0 - 0.6, y1 + 0.6, 0, 0.2, trim);
  wallBox(b, w, u0, u1, y0, y1, 0, 0.26, 0xfff4d6, { emissive: 0xffd99a, cast: false });
  const um = (u0 + u1) / 2;
  const ym = (y0 + y1) / 2;
  wallBox(b, w, um - 0.15, um + 0.15, y0, y1, 0, 0.35, trim);
  wallBox(b, w, u0, u1, ym - 0.15, ym + 0.15, 0, 0.35, trim);
  // The sill is a real ledge: grab it, climb up and stand on it.
  wallBox(b, w, u0 - 1, u1 + 1, y0 - 1, y0 - 0.6, 0, 0.9, trim);
  const s0 = w.face;
  const s1 = w.face + w.inward * 0.9;
  if (w.along === 'x') b.solid(u0 - 1, y0 - 1, s0, u1 + 1, y0 - 0.6, s1, 'window-sill');
  else b.solid(s0, y0 - 1, u0 - 1, s1, y0 - 0.6, u1 + 1, 'window-sill');
  if (curtain !== undefined) {
    wallBox(b, w, u0 - 2.4, u0 - 0.8, y0 - 2.5, y1 + 1.2, 0.3, 0.8, curtain);
    wallBox(b, w, u1 + 0.8, u1 + 2.4, y0 - 2.5, y1 + 1.2, 0.3, 0.8, curtain);
    wallBox(b, w, u0 - 3, u1 + 3, y1 + 1.2, y1 + 1.5, 0.4, 0.7, 0x6b4428);
  }
}

/** Picture frame with a coloured canvas. */
function picture(b: RoomBuilder, w: Wall, u0: number, u1: number, y0: number, y1: number, frame: number, art: number): void {
  wallBox(b, w, u0, u1, y0, y1, 0, 0.2, frame);
  wallBox(b, w, u0 + 0.5, u1 - 0.5, y0 + 0.5, y1 - 0.5, 0, 0.25, art, { cast: false });
}

// ---------------------------------------------------------------------------
// Floors

function plankFloor(b: RoomBuilder, c1: number, c2: number, seam: number): void {
  const { minX, maxX, minZ, maxZ } = b.bounds;
  b.deco(minX, -0.4, minZ, maxX, -0.1, maxZ, seam, { cast: false });
  const rows = Math.round((maxZ - minZ) / 2);
  for (let i = 0; i < rows; i++) {
    const z0 = minZ + i * 2 + 0.04;
    const z1 = z0 + 1.92;
    const split = minX + 8 + ((i * 7) % 5) * 6.5;
    b.deco(minX, -0.3, z0, split - 0.05, 0, z1, i % 2 ? c1 : c2, { cast: false });
    b.deco(split + 0.05, -0.3, z0, maxX, 0, z1, i % 2 ? c2 : c1, { cast: false });
  }
}

/** Checkerboard tiles as two instanced meshes over a grout base. */
function tileFloor(b: RoomBuilder, size: number, c1: number, c2: number, grout: number): void {
  const { minX, maxX, minZ, maxZ } = b.bounds;
  b.deco(minX, -0.4, minZ, maxX, -0.08, maxZ, grout, { cast: false });
  const nx = Math.ceil((maxX - minX) / size);
  const nz = Math.ceil((maxZ - minZ) / size);
  const cells: number[][] = [[], []];
  for (let i = 0; i < nx; i++) for (let j = 0; j < nz; j++) cells[(i + j) % 2].push(i, j);
  const m = new THREE.Matrix4();
  const s = size - 0.08;
  [c1, c2].forEach((color, k) => {
    const list = cells[k];
    const inst = new THREE.InstancedMesh(GEO.box, mat(color), list.length / 2);
    for (let n = 0; n < list.length; n += 2) {
      m.makeScale(s, 0.3, s);
      m.setPosition(minX + (list[n] + 0.5) * size, -0.15, minZ + (list[n + 1] + 0.5) * size);
      inst.setMatrixAt(n / 2, m);
    }
    inst.instanceMatrix.needsUpdate = true;
    inst.receiveShadow = true;
    b.root.add(inst);
  });
}

// ---------------------------------------------------------------------------
// Furniture helpers

/** A solid stack of books (one collider, several coloured covers). */
function bookStack(
  b: RoomBuilder,
  x0: number,
  y0: number,
  z0: number,
  x1: number,
  z1: number,
  h: number,
  colors: number[],
  seed: number,
  name = 'books',
): void {
  b.solid(x0, y0, z0, x1, y0 + h, z1, name);
  const r = rng(seed);
  const n = Math.max(2, Math.round(h / 0.6));
  const t = h / n;
  for (let k = 0; k < n; k++) {
    const ix0 = r() * 0.25;
    const ix1 = r() * 0.25;
    const iz0 = r() * 0.25;
    const iz1 = r() * 0.25;
    const by = y0 + k * t;
    b.deco(x0 + ix0, by, z0 + iz0, x1 - ix1, by + t, z1 - iz1, colors[k % colors.length]);
    // page edge on the +z side
    b.deco(x0 + ix0 + 0.15, by + 0.07, z1 - iz1 - 0.1, x1 - ix1 - 0.05, by + t - 0.07, z1 - iz1 + 0.02, 0xf4ecd6, { cast: false });
  }
}

type Side = 'x-' | 'x+' | 'z-' | 'z+';

/** Four-legged seat (chair or stool) with an optional solid backrest. */
function chair(
  b: RoomBuilder,
  x0: number,
  z0: number,
  x1: number,
  z1: number,
  seatY: number,
  color: number,
  legColor: number,
  back?: { side: Side; top: number },
  name = 'chair',
): void {
  const leg = 0.5;
  const t = 0.5;
  b.box(x0, seatY - t, z0, x1, seatY, z1, color, { name: `${name}-seat` });
  const corners: [number, number][] = [
    [x0, z0],
    [x1 - leg, z0],
    [x0, z1 - leg],
    [x1 - leg, z1 - leg],
  ];
  for (const [lx, lz] of corners) b.box(lx, 0, lz, lx + leg, seatY - t, lz + leg, legColor, { name: `${name}-leg`, grab: false });
  if (!back) return;
  const s = back.side;
  const [bx0, bz0, bx1, bz1] =
    s === 'x-' ? [x0, z0, x0 + t, z1] : s === 'x+' ? [x1 - t, z0, x1, z1] : s === 'z-' ? [x0, z0, x1, z0 + t] : [x0, z1 - t, x1, z1];
  b.box(bx0, seatY, bz0, bx1, back.top, bz1, color, { name: `${name}-back` });
}

/** Table top on four inset legs. */
function table(
  b: RoomBuilder,
  x0: number,
  z0: number,
  x1: number,
  z1: number,
  top: number,
  thick: number,
  leg: number,
  color: number,
  legColor: number,
  name = 'table',
): void {
  b.box(x0, top - thick, z0, x1, top, z1, color, { name });
  const i = 0.2;
  for (const lx of [x0 + i, x1 - i - leg])
    for (const lz of [z0 + i, z1 - i - leg]) b.box(lx, 0, lz, lx + leg, top - thick, lz + leg, legColor, { name: `${name}-leg`, grab: false });
}

/** Rubber duck inside the AABB (x0..x0+s, y0..y0+s, z0..z0+s), facing +x. */
function duck(b: RoomBuilder, x0: number, y0: number, z0: number, s: number, solid = true): void {
  // Colliders follow the shape: a low round body, and a smaller head on top of it.
  if (solid) {
    b.solid(x0 + s * 0.06, y0, z0 + s * 0.06, x0 + s * 0.94, y0 + s * 0.6, z0 + s * 0.94, 'rubber-duck', false, true);
    b.solid(x0 + s * 0.45, y0 + s * 0.6, z0 + s * 0.27, x0 + s * 0.9, y0 + s * 0.97, z0 + s * 0.73, 'rubber-duck-head', false, true);
  }
  const Y = 0xf6cf3a;
  b.deco(x0, y0, z0, x0 + s, y0 + s * 0.62, z0 + s, Y, { shape: 'ball' });
  b.deco(x0 + s * 0.4, y0 + s * 0.45, z0 + s * 0.2, x0 + s * 0.95, y0 + s, z0 + s * 0.8, Y, { shape: 'ball' });
  b.deco(x0 + s * 0.88, y0 + s * 0.62, z0 + s * 0.38, x0 + s * 1.12, y0 + s * 0.74, z0 + s * 0.62, 0xf08a24);
  b.deco(x0 + s * 0.8, y0 + s * 0.82, z0 + s * 0.24, x0 + s * 0.88, y0 + s * 0.9, z0 + s * 0.32, 0x222222, { cast: false });
  b.deco(x0 + s * 0.8, y0 + s * 0.82, z0 + s * 0.68, x0 + s * 0.88, y0 + s * 0.9, z0 + s * 0.76, 0x222222, { cast: false });
}

/** Cardboard box with packing tape over the top seam and a shipping label. */
function cardboard(b: RoomBuilder, x0: number, y0: number, z0: number, x1: number, y1: number, z1: number, name: string): void {
  const CARD = 0xc59a66;
  const SEAM = 0x8d6438;
  const TAPE = 0xe6d2a6;
  b.box(x0, y0, z0, x1, y1, z1, CARD, { name });
  const xm = (x0 + x1) / 2;
  const zm = (z0 + z1) / 2;
  const drop = Math.min(0.5, (y1 - y0) * 0.25);
  if (x1 - x0 >= z1 - z0) {
    // Tape runs along X over the top and folds down both ends.
    b.deco(x0 - 0.012, y1 - drop, zm - 0.2, x1 + 0.012, y1 + 0.012, zm + 0.2, TAPE, { cast: false });
    b.deco(x0 + 0.02, y1 + 0.012, zm - 0.025, x1 - 0.02, y1 + 0.024, zm + 0.025, SEAM, { cast: false });
    // Flap creases along the long top edges.
    b.deco(x0, y1 - 0.06, z0 - 0.01, x1, y1 - 0.03, z0, SEAM, { cast: false });
    b.deco(x0, y1 - 0.06, z1, x1, y1 - 0.03, z1 + 0.01, SEAM, { cast: false });
  } else {
    b.deco(xm - 0.2, y1 - drop, z0 - 0.012, xm + 0.2, y1 + 0.012, z1 + 0.012, TAPE, { cast: false });
    b.deco(xm - 0.025, y1 + 0.012, z0 + 0.02, xm + 0.025, y1 + 0.024, z1 - 0.02, SEAM, { cast: false });
    b.deco(x0 - 0.01, y1 - 0.06, z0, x0, y1 - 0.03, z1, SEAM, { cast: false });
    b.deco(x1, y1 - 0.06, z0, x1 + 0.01, y1 - 0.03, z1, SEAM, { cast: false });
  }
  // Shipping label with a couple of printed lines and a this-way-up arrow on the +z face.
  const ly = y0 + (y1 - y0) * 0.55;
  const lw = Math.min(0.45, (x1 - x0) * 0.22);
  const lh = Math.min(0.32, (y1 - y0) * 0.18);
  b.deco(xm - lw, ly - lh, z1, xm + lw, ly + lh, z1 + 0.012, 0xf4efe2, { cast: false });
  b.deco(xm - lw * 0.75, ly + lh * 0.25, z1 + 0.012, xm + lw * 0.5, ly + lh * 0.45, z1 + 0.018, 0x3a3330, { cast: false });
  b.deco(xm - lw * 0.75, ly - lh * 0.3, z1 + 0.012, xm + lw * 0.2, ly - lh * 0.12, z1 + 0.018, 0x3a3330, { cast: false });
  b.deco(x0 + 0.15, y1 - drop - 0.45, z1, x0 + 0.22, y1 - drop - 0.15, z1 + 0.012, 0x6b4a2a, { cast: false });
  b.deco(x0 + 0.1, y1 - drop - 0.2, z1, x0 + 0.27, y1 - drop - 0.13, z1 + 0.012, 0x6b4a2a, { cast: false });
}

/** 2x2x2 pack of toilet rolls (2 x 2 x 2 units). */
function tpPack(b: RoomBuilder, x0: number, z0: number): void {
  b.solid(x0, 0, z0, x0 + 2, 2, z0 + 2, 'toilet-paper');
  for (let ly = 0; ly < 2; ly++)
    for (let ix = 0; ix < 2; ix++)
      for (let iz = 0; iz < 2; iz++) {
        const cx = x0 + 0.5 + ix;
        const cz = z0 + 0.5 + iz;
        b.round(cx, ly, cz, 0.5, 1, 0xfbfbf7, {}, false);
        if (ly === 1) b.round(cx, 1.99, cz, 0.16, 0.03, 0xb89a72, { cast: false }, false);
      }
}

function duelSpawns(ax: number, bx: number, z0: number, z1: number): SpawnPoint[] {
  const fa = Math.atan2(bx - ax, 0);
  const fb = Math.atan2(ax - bx, 0);
  return [
    { x: ax, y: 0, z: z0, facing: fa },
    { x: bx, y: 0, z: z0, facing: fb },
    { x: ax, y: 0, z: z1, facing: fa },
    { x: bx, y: 0, z: z1, facing: fb },
  ];
}

function finish(
  b: RoomBuilder,
  id: RoomId,
  d: Pick<RoomData, 'spawns' | 'itemSpawns' | 'itemKinds' | 'ceiling' | 'sky'> &
    Partial<Pick<RoomData, 'machines' | 'rollers' | 'pools' | 'drips' | 'crates'>> & { sun: [number, number, number] },
): RoomData {
  const meta = ROOMS.find((r) => r.id === id);
  b.root.name = `room-${id}`;
  return {
    id,
    name: meta ? meta.name : (ROOM_NAMES[id] ?? id),
    root: b.root,
    solids: b.solids,
    spawns: d.spawns,
    itemSpawns: d.itemSpawns,
    itemKinds: d.itemKinds,
    bounds: { ...b.bounds },
    ceiling: d.ceiling,
    cutaway: b.cutaway,
    sky: d.sky,
    sunDir: new THREE.Vector3(...d.sun).normalize(),
    machines: d.machines,
    rollers: d.rollers,
    crates: d.crates,
    pools: d.pools,
    drips: d.drips,
    // Part of the house: the room's floor plus its doorways.
    areas: b.doors ? [{ ...b.bounds, room: id }, ...b.doorAreas] : undefined,
  };
}

// ---------------------------------------------------------------------------
// Living room — walnut, mustard, teal
//
// Chains: floor → books 2.2 → sofa seat 4.5 → pillow 6.3 / arm 6.5 → back cushion 7.2 → sofa back 8.5
//         floor → magazines 2.3 → coffee table 4.5
//         floor → board games 2.2 → shoebox 4.6 → bookcase 7.0
//         floor → toy block 2.2 → TV stand 4.6

function buildLiving(doors?: Doors): RoomData {
  const b = new RoomBuilder({ minX: -24, maxX: 24, minZ: -16, maxZ: 16 }, doors);
  const WALNUT = 0x6b4428;
  const WALNUT_D = 0x4e301b;
  const MUSTARD = 0xd9a72b;
  const TEAL = 0x2f8f8a;
  const TEAL_D = 0x23706c;
  const BRASS = 0xc9a24a;
  const BOOKS = [0xc8473a, TEAL, MUSTARD, 0x3e5c8a, 0x7a9a4a, 0xe8dcc0];

  const walls = shell(b, 0xe9d5b5, 0xf7efe0);
  plankFloor(b, 0x8a5a36, 0x9c6a40, 0x3b2414);
  windowOn(b, walls.back, 12, 21, 10, 19, 0xf7efe0, MUSTARD);
  picture(b, walls.back, -6, 6, 12, 18, WALNUT_D, TEAL);
  b.round(0, 14, -15.7, 1.6, 0.3, MUSTARD, { parent: walls.back.group, cast: false }, false).rotation.x = Math.PI / 2;
  picture(b, walls.right, -12, -6, 9, 15, WALNUT_D, MUSTARD);

  // rug
  b.deco(-12.5, 0, -6.5, 12.5, 0.05, 5.5, TEAL, { cast: false });
  b.deco(-11.5, 0, -5.5, 11.5, 0.07, 4.5, MUSTARD, { cast: false });

  // sofa against the back wall
  b.solid(-8, 0, -14, 8, 4.5, -7, 'sofa-seat');
  b.deco(-8, 0, -13.8, 8, 0.6, -7.4, WALNUT_D);
  b.deco(-8, 0.6, -14, 8, 3.4, -7, TEAL_D);
  b.deco(-8, 3.4, -14, -0.05, 4.5, -7, TEAL);
  b.deco(0.05, 3.4, -14, 8, 4.5, -7, TEAL);
  b.box(-10, 0, -14, -8, 6.5, -7, TEAL, { name: 'sofa-arm' });
  b.box(8, 0, -14, 10, 6.5, -7, TEAL, { name: 'sofa-arm' });
  b.box(-10, 0, -16, 10, 8.5, -14, TEAL_D, { name: 'sofa-back' });
  b.solid(-8, 4.5, -14, 8, 7.2, -13, 'sofa-cushion');
  b.deco(-8, 4.5, -14, -0.05, 7.2, -13, TEAL);
  b.deco(0.05, 4.5, -14, 8, 7.2, -13, TEAL);
  b.box(-8, 4.5, -13, -5.3, 6.3, -11.3, MUSTARD, { name: 'pillow' });
  bookStack(b, -7, 0, -7, -4.5, -5, 2.2, BOOKS, 11);

  // coffee table with a mug and a magazine
  table(b, -5, -3.5, 5, 0, 4.5, 0.6, 0.6, WALNUT, WALNUT_D, 'coffee-table');
  b.round(3.6, 4.5, -2.4, 0.45, 0.9, 0xf2ece0, { name: 'mug' });
  b.deco(-3.5, 4.5, -2.8, -0.8, 4.58, -0.8, 0xc8473a, { cast: false });
  bookStack(b, 5, 0, -3.1, 6.9, -0.7, 2.3, BOOKS, 17, 'magazines');

  // low bookcase on the left wall (single collider, open-shelf visuals)
  b.solid(-24, 0, -2, -21, 7, 8, 'bookcase');
  b.deco(-24, 0, -2, -23.8, 7, 8, WALNUT_D);
  b.deco(-24, 0, -2, -21, 7, -1.6, WALNUT);
  b.deco(-24, 0, 7.6, -21, 7, 8, WALNUT);
  b.deco(-23.8, 0, -1.6, -21, 0.6, 7.6, WALNUT);
  b.deco(-23.8, 3.3, -1.6, -21, 3.6, 7.6, WALNUT);
  b.deco(-24, 6.6, -2, -21, 7, 8, WALNUT);
  const r = rng(7);
  for (const [base, space] of [
    [0.6, 2.7],
    [3.6, 3.0],
  ]) {
    let z = -1.6;
    while (z < 7.2) {
      const w = Math.min(0.35 + r() * 0.35, 7.6 - z);
      if (r() > 0.1) {
        const h = space * (0.7 + r() * 0.22);
        b.deco(-23.8, base, z, -21.25 - r() * 0.3, base + h, z + w - 0.03, BOOKS[Math.floor(r() * BOOKS.length)]);
      }
      z += w;
    }
  }

  // board games + shoebox: the stairs up to the bookcase
  b.solid(-21, 0, 4, -18, 2.2, 8, 'board-games');
  b.deco(-21, 0, 4, -18, 0.7, 8, 0xc8473a);
  b.deco(-20.9, 0.7, 4.1, -18.1, 1.5, 7.9, 0x3e5c8a);
  b.deco(-21, 1.5, 4, -18, 2.2, 8, 0x7a9a4a);
  cardboard(b, -21, 2.2, 5.5, -19.5, 4.6, 8, 'shoebox');

  // TV stand and TV on the right wall
  b.box(20, 0, -6, 24, 4.6, 6, WALNUT_D, { name: 'tv-stand' });
  b.deco(19.92, 0.6, -5.6, 20, 4.0, -0.2, WALNUT, { cast: false });
  b.deco(19.92, 0.6, 0.2, 20, 4.0, 5.6, WALNUT, { cast: false });
  b.solid(22, 4.6, -5, 24, 11, 5, 'tv');
  b.deco(22.2, 4.6, -1.5, 23.4, 5.2, 1.5, 0x1d1f24);
  b.deco(22.4, 5.2, -5, 23.2, 11, 5, 0x1d1f24);
  b.deco(22.34, 5.6, -4.6, 22.4, 10.6, 4.6, 0x2b3d4a, { emissive: 0x0c1b26, cast: false });
  b.box(20.2, 4.6, 2, 21.2, 4.9, 4, 0xeeeeee, { name: 'console' });
  cardboard(b, 17.8, 0, 3.6, 20, 2.2, 5.8, 'box');
  b.deco(17.74, 0.5, 4.1, 17.8, 1.7, 5.3, TEAL, { cast: false });

  // potted plant in the back-left corner
  b.box(-22, 0, -14, -19, 3.2, -11, 0xc0643a, { shape: 'bowl', name: 'plant-pot' });
  b.deco(-21.8, 3.0, -13.8, -19.2, 3.25, -11.2, 0x3b2a1e, { shape: 'cyl', cast: true });
  for (const [x, y, z, s] of [
    [-20.5, 4.0, -12.5, 3.2],
    [-21.4, 6.0, -13.2, 2.4],
    [-19.6, 6.4, -11.8, 2.6],
    [-20.4, 8.0, -12.6, 2.0],
  ])
    b.deco(x - s / 2, y, z - s / 2, x + s / 2, y + s * 1.2, z + s / 2, 0x4f8a3a, { shape: 'cone', rotY: x });

  // floor lamp
  b.round(17, 0, -12, 1.2, 0.3, BRASS, { name: 'lamp-base' });
  b.round(17, 0.3, -12, 0.2, 14.7, BRASS, { shape: 'cyl6', name: 'lamp-pole', grab: false });
  b.deco(15, 14, -14, 19, 17.5, -10, 0xfbe7b5, { shape: 'shade', emissive: 0xffc870 });

  // toys
  cardboard(b, -15, 0, 9, -13.8, 1.2, 10.2, 'box');

  // China hutch beside the sofa: sofa back 8.5 → shelf 10.6 → hutch top 12.8.
  b.box(-15, 0, -16, -10, 6, -12.5, WALNUT_D, { name: 'hutch' });
  b.deco(-14.8, 0.6, -12.48, -12.6, 5.4, -12.45, WALNUT, { cast: false });
  b.deco(-12.4, 0.6, -12.48, -10.2, 5.4, -12.45, WALNUT, { cast: false });
  for (const x of [-13.1, -11.9]) b.deco(x - 0.15, 3, -12.45, x + 0.15, 3.4, -12.3, BRASS);
  b.box(-15, 6, -16, -10, 12.8, -14.5, WALNUT_D, { name: 'hutch' });
  b.deco(-14.7, 6.4, -14.52, -10.3, 12.4, -14.48, 0x9fc3c8, { emissive: 0x0c1b26, cast: false });
  for (const [px, py, pc] of [
    [-13.6, 7.2, 0xf4ecd6],
    [-11.4, 7.2, 0x3e6ea8],
    [-13.6, 11, 0xd9a72b],
    [-11.4, 11, 0xf4ecd6],
  ]) {
    // Plates propped up on display along the front.
    const plate = b.deco(px - 0.9, 0, -0.9, px + 0.9, 0.1, 0.9, pc, { shape: 'cyl', cast: false });
    plate.position.set(px, py + 0.9, -14.42);
    plate.rotation.x = Math.PI / 2;
  }
  b.box(-15, 10.2, -14.5, -10, 10.6, -13.5, WALNUT, { name: 'hutch-shelf' });
  b.deco(-15.2, 12.8, -16, -9.8, 13.1, -14.3, WALNUT, { cast: false });

  // ceiling fan in the middle of the room, high up: reach it from the top of the cat tree
  b.deco(-0.12, 15.7, -4.12, 0.12, 25, -3.88, BRASS, { cast: false });
  b.box(-1, 14.7, -5, 1, 15.7, -3, BRASS, { shape: 'cyl', name: 'fan-motor', grab: false });

  // Cat tree by the coffee table: platforms spiral up its sisal post to a perch right
  // under the fan's blades: 2.4 → 4.8 → 7.2 → 9.6 → 12.0.
  const SISAL = 0xc9a874;
  const PAD = 0xb7a58f;
  b.box(6.6, 0, -5.6, 8.4, 0.4, -3.8, PAD, { name: 'cat-tree-base' });
  b.round(7.5, 0.4, -4.7, 0.4, 11.3, SISAL, { name: 'cat-tree-post', grab: false });
  for (let y = 1; y < 11.5; y += 0.7) b.round(7.5, y, -4.7, 0.42, 0.08, 0x9c7f52, { cast: false }, false);
  for (const [px, pz, top] of [
    [9.3, -4.7, 2.4],
    [7.5, -2.9, 4.8],
    [5.7, -4.7, 7.2],
    [7.5, -2.9, 9.6],
    [7.5, -4.7, 12.0],
  ] as [number, number, number][]) {
    b.round(px, top - 0.35, pz, 1.3, 0.35, PAD, { name: 'cat-tree-platform' });
    b.round(px, top - 0.4, pz, 1.34, 0.06, 0x8a7a66, { cast: false }, false);
  }
  // a toy mouse dangling from the top perch
  b.deco(8.65, 9.9, -4.72, 8.69, 11.65, -4.68, 0xe8e0cc, { cast: false });
  b.deco(8.4, 9.5, -4.95, 8.95, 9.9, -4.45, 0x9a9a9a, { shape: 'ball' });

  // record player on a low cabinet: floor → box of records 2.4 → cabinet 4.0 → deck 4.6 → platter
  b.box(12, 0, 10.5, 19, 4, 16, WALNUT, { name: 'record-cabinet' });
  b.deco(12.4, 0.6, 10.42, 18.6, 3.4, 10.5, 0x3b2a1e, { cast: false });
  for (let k = 0; k < 6; k++) b.deco(12.8 + k * 1, 0.9, 10.38, 13.2 + k * 1, 3.1, 10.42, 0x6b4428, { cast: false });
  b.box(12, 4, 10.5, 19, 4.6, 16, 0x2b2622, { name: 'record-deck' });
  b.deco(17.9, 4.6, 14.6, 18.3, 5.2, 15.0, 0xc9d1d6); // tone-arm post
  b.deco(15.9, 5.05, 14.75, 18.1, 5.15, 14.85, 0xc9d1d6, { cast: false }); // tone arm
  b.deco(12.9, 4.6, 15.0, 13.4, 4.75, 15.4, 0xc8473a, { cast: false }); // start button
  cardboard(b, 19, 0, 11, 21.5, 2.4, 14.5, 'record-crate');
  for (let k = 0; k < 4; k++) b.deco(19.2 + k * 0.55, 1.2, 11.4, 19.5 + k * 0.55, 3.0, 14.1, [0x1b1b1f, 0xd9a72b, 0x2f8f8a, 0xc8473a][k]!);

  return finish(b, 'living', {
    spawns: duelSpawns(-5, 5, 4, 12),
    machines: [
      { kind: 'fan', x: 0, y: 14.4, z: -4, r: 9, speed: 0.55 },
      { kind: 'turntable', x: 15.5, y: 4.85, z: 13.25, r: 2.0, speed: 1.1 },
    ],
    rollers: [{ kind: 'ball', x: 8, y: 0, z: 9 }],
    itemSpawns: [
      { x: 0, y: 0, z: 8 },
      { x: 13, y: 4.6, z: 11.4 },
      { x: -12.5, y: 12.8, z: -15.2 },
      { x: 7.5, y: 12, z: -4.2 },
      { x: -17, y: 0, z: 13 },
      { x: 2, y: 4.5, z: -10 },
      { x: 1, y: 4.5, z: -1.8 },
      { x: -22.5, y: 7, z: 2 },
      { x: 21, y: 4.6, z: -3 },
      { x: 4, y: 8.5, z: -15 },
      { x: 14, y: 0, z: -2 },
    ],
    itemKinds: ['screwdriver', 'wrench', 'pencil', 'chopstick', 'paintbrush', 'fork', 'clothespeg', 'nunchaku'],
    ceiling: 14.7,
    sky: 0xf0d2a8,
    sun: [-0.35, -0.75, 0.55],
  });
}

// ---------------------------------------------------------------------------
// Kitchen — cream, mint, cherry red
//
// Chains: floor → cookbooks 2.0 → open drawer 4.2 → upper drawer 6.6 → counter 9.0 → toaster / pot 11.0
//         floor → books 2.4 → chair seat 4.8 → dining table 7.4 → cereal box 10.0

function buildKitchen(doors?: Doors): RoomData {
  const b = new RoomBuilder({ minX: -23, maxX: 23, minZ: -16, maxZ: 16 }, doors);
  const CREAM = 0xf4ecd6;
  const MINT = 0x8fd4b6;
  const MINT_D = 0x6bb898;
  const CHERRY = 0xc8323c;
  const CHROME = 0xc9d1d6;
  const WOOD = 0xc99a5e;

  const walls = shell(b, 0xf6eedb, 0xfffaf0, { color: MINT, top: 7, rail: CHERRY });
  tileFloor(b, 2, CREAM, MINT, 0xc7c0aa);
  windowOn(b, walls.back, -21, -13, 12, 20, 0xfffaf0, CHERRY);
  wallBox(b, walls.back, -23, 5, 9, 14, 0, 0.08, 0xfbfaf3, { cast: false }); // backsplash
  wallBox(b, walls.back, -10, 5, 14, 21, 0, 3, MINT); // upper cabinets
  for (const u of [-10, -5, 0]) {
    wallBox(b, walls.back, u + 0.3, u + 4.7, 14.4, 20.6, 3, 3.08, MINT_D, { cast: false });
    wallBox(b, walls.back, u + 2.2, u + 2.8, 14.8, 15.2, 3, 3.3, CHROME);
  }
  picture(b, walls.left, 2, 10, 9, 15, CHERRY, CREAM);

  // counter run along the back wall
  b.solid(-23, 0, -16, 5, 9, -10, 'counter');
  b.deco(-23, 0, -16, 5, 0.8, -10.4, 0x5b5148);
  b.deco(-23, 0.8, -16, 5, 8.5, -10, MINT);
  b.deco(-23, 8.5, -16, 5, 9, -9.8, CREAM);
  for (const [u0, u1] of [
    [-23, -19],
    [-19, -14],
    [-10, -6],
    [1, 5],
  ]) {
    b.deco(u0 + 0.3, 1.2, -10, u1 - 0.3, 8.0, -9.92, MINT_D, { cast: false });
    b.deco((u0 + u1) / 2 - 0.3, 6.6, -9.92, (u0 + u1) / 2 + 0.3, 7.2, -9.7, CHERRY);
  }
  // pulled-out drawers (x -14..-10)
  b.deco(-13.8, 0.9, -10, -10.2, 2.7, -9.92, MINT_D, { cast: false });
  b.deco(-13.8, 3.0, -10, -10.2, 4.2, -9.96, 0x2c2a26, { cast: false });
  b.deco(-13.8, 5.6, -10, -10.2, 6.6, -9.96, 0x2c2a26, { cast: false });
  b.box(-14, 3.0, -10, -10, 4.2, -7, MINT, { name: 'drawer' });
  b.deco(-12.4, 3.4, -7, -11.6, 3.8, -6.8, CHERRY);
  b.box(-14, 5.6, -10, -10, 6.6, -8.5, MINT, { name: 'drawer' });
  b.deco(-12.4, 5.9, -8.5, -11.6, 6.3, -8.3, CHERRY);
  bookStack(b, -14, 0, -7, -11.5, -4.5, 2.0, [CHERRY, 0x3e5c8a, 0xf2c14e], 21, 'cookbooks');

  // stove section (x -6..1)
  b.deco(-5.5, 1.5, -10, 0.5, 6.4, -9.9, 0x333a40, { cast: false });
  b.deco(-5, 6.8, -9.9, 0, 7.1, -9.6, CHROME);
  for (const kx of [-4.6, -3.2, -1.8, -0.4]) b.deco(kx - 0.25, 7.6, -10, kx + 0.25, 8.1, -9.75, 0x26282b, { shape: 'cyl' });
  b.deco(-6, 9, -15.5, 1, 9.06, -10.5, 0x26282b, { cast: false });
  for (const [bx, bz] of [
    [-4.2, -14],
    [-1, -14],
    [-4.2, -12],
    [-1, -12],
  ])
    b.round(bx, 9.06, bz, 0.9, 0.06, 0x4a4e54, { cast: false }, false);
  b.round(-1.5, 9, -13, 1.5, 2, CHERRY, { name: 'pot' });
  b.deco(-1.6, 11, -13.1, -1.4, 11.3, -12.9, 0x26282b);
  b.deco(-0.2, 10, -13.25, 0.8, 10.3, -12.75, 0x26282b);

  // sink, faucet, toaster
  b.deco(-20, 9, -14.5, -15, 9.04, -11, 0xb8c2c8, { cast: false });
  b.deco(-19.6, 9.04, -14.1, -15.4, 9.06, -11.4, 0x8c979e, { cast: false });
  // the leak: a wet streak over the counter's lip
  b.deco(-17.7, 8.3, -9.82, -17.3, 9.02, -9.74, 0x8fd4f0, { cast: false });
  b.box(-17.8, 9, -16, -17.2, 11.6, -15.2, CHROME, { name: 'faucet' });
  b.deco(-17.75, 11, -15.2, -17.25, 11.4, -14, CHROME);
  b.box(-11, 9, -15, -8.5, 11, -12.5, CHERRY, { name: 'toaster' });
  b.deco(-10.6, 11, -14.3, -10.3, 11.03, -13.2, 0x26282b, { cast: false });
  b.deco(-9.2, 11, -14.3, -8.9, 11.03, -13.2, 0x26282b, { cast: false });
  b.deco(-8.5, 9.8, -13.9, -8.2, 10.1, -13.6, CHROME);

  // fridge + bin
  b.box(5, 0, -16, 12, 18, -9.5, CHERRY, { name: 'fridge' });
  b.deco(5, 11.9, -9.5, 12, 12.1, -9.42, 0x8a1f27, { cast: false });
  b.deco(10.7, 13, -9.5, 11.2, 16, -9.1, CHROME);
  b.deco(10.7, 7, -9.5, 11.2, 10.5, -9.1, CHROME);
  for (const [mx, my, mc] of [
    [6.5, 15.5, 0xf2c14e],
    [8, 14.4, MINT],
    [7, 9.5, 0x3e5c8a],
  ])
    b.deco(mx, my, -9.5, mx + 0.7, my + 0.7, -9.4, mc, { cast: false });
  b.round(14.5, 0, -13.5, 1.5, 5, 0xdedbd0, { name: 'bin' });
  b.round(14.5, 5, -13.5, 1.55, 0.25, CHROME, { cast: false }, false);

  // dining table, chairs, fruit bowl, cereal box
  table(b, 9, 3, 19, 11, 7.4, 0.6, 0.8, WOOD, 0xa77b45, 'dining-table');
  chair(b, 10.5, -1.4, 14.5, 2.3, 4.8, CHERRY, CREAM, { side: 'z-', top: 9 });
  chair(b, 19.7, 5, 23, 9, 4.8, CHERRY, CREAM, { side: 'x+', top: 9 });
  bookStack(b, 8, 0, -0.9, 10.5, 1.8, 2.4, [MINT_D, 0xf2c14e, 0x3e5c8a, CHERRY], 33);
  b.box(13.4, 7.4, 6.4, 16.6, 8.4, 9.6, 0xf7f3ea, { shape: 'bowl', name: 'fruit-bowl' });
  b.deco(13.9, 8.1, 7, 15.1, 9.3, 8.2, 0xd8343c, { shape: 'ball' });
  b.deco(15, 8.1, 7.6, 16.2, 9.3, 8.8, 0xf39a2b, { shape: 'ball' });
  b.deco(14.2, 8.2, 8.4, 15.6, 8.9, 9.2, 0x8fc04a, { shape: 'ball' });
  b.box(17, 7.4, 3.3, 18.6, 10, 5.6, 0xf2c14e, { name: 'cereal-box' });
  b.deco(16.94, 8.2, 3.7, 17, 9.4, 5.2, CHERRY, { cast: false });

  return finish(b, 'kitchen', {
    spawns: duelSpawns(-8, 2, 2, 10),
    itemSpawns: [
      { x: -3, y: 0, z: 6 },
      { x: -17, y: 0, z: 9 },
      { x: 7, y: 0, z: 14 },
      { x: 14, y: 0, z: 7 },
      { x: 11.5, y: 7.4, z: 6 },
      { x: -13.5, y: 9, z: -12 },
      { x: 3, y: 9, z: -13 },
      { x: 12.5, y: 4.8, z: 0.8 },
      { x: -12, y: 6.6, z: -9.2 },
    ],
    itemKinds: ['fork', 'spoon', 'butterknife', 'whisk', 'spatula', 'chopstick', 'ladle', 'pizzacutter', 'nunchaku'],
    machines: [{ kind: 'toaster', x: -9.75, y: 11, z: -13.75, yaw: 0, period: 7 }],
    // The sink tap leaks: water runs over the counter's edge and puddles on the floor.
    drips: [{ x: -17.5, y: 8.95, z: -9.75, r: 2.6 }],
    rollers: [{ kind: 'can', x: -4, y: 0, z: 12 }],
    ceiling: 15,
    sky: 0xf6e7c8,
    sun: [0.35, -0.75, 0.55],
  });
}

// ---------------------------------------------------------------------------
// Bedroom — lavender, soft blue, pine
//
// Chains: floor → foam block 2.0 → stacked block 4.0 → bed 5.0 → pillow 6.2 → headboard 8.6
//         bed 5.0 → nightstand 6.0
//         floor → books 2.4 → desk chair 4.8 → desk 7.4
//         floor → drawer 2.4 → drawer 5.0 → drawer 7.6 → dresser 10 → jewellery box 11.2

function buildBedroom(doors?: Doors): RoomData {
  const b = new RoomBuilder({ minX: -22, maxX: 22, minZ: -16, maxZ: 16 }, doors);
  const LAV = 0xb9a7d9;
  const LAV_D = 0x9887c0;
  const BLUE = 0x9cc3e6;
  const PINE = 0xc99a5e;
  const PINE_D = 0xa77b45;
  const WHITE = 0xf4f1ea;
  const YELLOW = 0xf2c14e;

  const walls = shell(b, 0xcfdff0, 0xf8f6f0);
  b.deco(-22, -0.3, -16, 22, 0, 16, 0xa79bc4, { cast: false }); // carpet
  b.deco(-7, 0, 2, 7, 0.06, 16, BLUE, { shape: 'cyl', cast: false });
  b.deco(-5, 0, 4, 5, 0.08, 14, 0xd8e6f4, { shape: 'cyl', cast: false });
  windowOn(b, walls.back, 2, 12, 9, 18, 0xf8f6f0, LAV);
  picture(b, walls.left, -6, 0, 9, 14, PINE_D, YELLOW);
  picture(b, walls.right, 8, 13, 10, 16, PINE_D, LAV);

  // bed: crawlspace 0.9 underneath
  b.box(-16, 0, -16, -2, 8.6, -15, PINE, { name: 'headboard' });
  b.solid(-16, 0.9, -15, -2, 5, 4, 'bed');
  b.deco(-16, 0.9, -15, -2, 2.4, 4, PINE);
  b.deco(-15.8, 2.4, -15, -2.2, 5, 3.8, WHITE);
  b.deco(-16.1, 2.6, -8, -1.9, 5.04, 4.1, BLUE);
  b.deco(-16.12, 4.3, -8.6, -1.88, 5.06, -7.6, WHITE);
  for (const lx of [-16, -3]) for (const lz of [-15, 3]) b.box(lx, 0, lz, lx + 1, 0.9, lz + 1, PINE_D, { name: 'bed-leg', grab: false });
  b.box(-15, 5, -15, -10, 6.2, -12, WHITE, { name: 'pillow' });
  b.box(-8, 5, -15, -3, 6.2, -12, WHITE, { name: 'pillow' });
  b.deco(-11, 0, -9, -9.6, 0.25, -7.8, LAV_D, { cast: false }); // lost sock

  // foam blocks forming a stair to the bed
  cardboard(b, -2, 0, -3, 0, 2, -1, 'box');
  cardboard(b, -2, 0, -1, 0, 2, 1, 'box');
  cardboard(b, -2, 2, -1, 0, 4, 1, 'box');
  b.deco(-1.6, 0.4, -1.06, -0.4, 1.6, -1, 0xc8473a, { cast: false });
  b.deco(-1.6, 2.4, 1, -0.4, 3.6, 1.06, YELLOW, { cast: false });

  // nightstand + lamp
  b.box(-22, 0, -16, -17.5, 6, -12, PINE, { name: 'nightstand' });
  b.deco(-21.6, 3.4, -12, -17.9, 5.4, -11.92, LAV, { cast: false });
  b.deco(-20, 4.2, -11.92, -19.5, 4.6, -11.7, PINE_D);
  b.round(-20, 6, -15.1, 0.9, 0.4, LAV_D, { name: 'lamp-base' });
  b.deco(-20.1, 6.4, -15.2, -19.9, 8.4, -15.0, PINE_D, { shape: 'cyl6' });
  b.deco(-21.4, 8.2, -16.5, -18.6, 10.4, -13.7, 0xfff0c8, { shape: 'shade', emissive: 0xffc870 });

  // desk + chair on the right wall
  const deskEnd = doors ? 4.4 : 6;
  b.box(16, 6.8, -6, 22, 7.4, deskEnd, PINE, { name: 'desk' });
  b.box(16.2, 0, -6, 22, 6.8, -5.4, PINE_D, { name: 'desk-side', grab: false });
  b.box(16.2, 0, deskEnd - 0.6, 22, 6.8, deskEnd, PINE_D, { name: 'desk-side', grab: false });
  chair(b, 11.5, -2, 15.3, 2, 4.8, BLUE, PINE_D, { side: 'x-', top: 9 }, 'desk-chair');
  // In the house the train passes close by: the books sit a little further under the chair.
  const booksZ = doors ? 1.6 : 2;
  bookStack(b, 12, 0, booksZ, 14.8, booksZ + 2.5, 2.4, [LAV_D, YELLOW, 0x5b7fb0, 0xc8473a], 41);
  bookStack(b, 19.5, 7.4, -5, 22, -2.5, 1.2, [BLUE, LAV, PINE_D], 42);
  const cupZ = doors ? 3.7 : 4;
  b.round(18.5, 7.4, cupZ, 0.5, 1.2, LAV_D, { name: 'pencil-cup' });
  for (const [px, pz, pc] of [
    [18.35, cupZ - 0.1, YELLOW],
    [18.6, cupZ + 0.15, 0xc8473a],
  ])
    b.deco(px - 0.08, 8.6, pz - 0.08, px + 0.08, 9.6, pz + 0.08, pc, { shape: 'cyl6' });
  b.round(21.3, 7.4, 3.7, 0.7, 0.3, 0x5b7fb0, { name: 'lamp-base' });
  b.deco(21.2, 7.7, 3.6, 21.4, 10.2, 3.8, 0x5b7fb0);
  b.deco(20.0, 9.6, 3.1, 21.5, 10.6, 4.3, 0x5b7fb0, { shape: 'shade', emissive: 0x3a3020 });
  b.deco(17, 7.4, -1.5, 19.2, 7.45, 1.5, WHITE, { cast: false }); // notebook

  // dresser on the left wall with drawers pulled out as steps
  b.solid(-22, 0, 7, -17, 10, 16, 'dresser');
  b.deco(-22, 0, 7, -17, 10, 16, PINE);
  const open = new Set(['0:0', '1:1', '0:2']);
  const cols: [number, number][] = [
    [7.4, 11.3],
    [11.7, 15.6],
  ];
  const rows: [number, number][] = [
    [0.8, 3.2],
    [3.6, 6.4],
    [6.8, 9.4],
  ];
  cols.forEach(([z0, z1], c) =>
    rows.forEach(([y0, y1], rr) => {
      const isOpen = open.has(`${c}:${rr}`);
      b.deco(-17, y0, z0, -16.92, y1, z1, isOpen ? 0x3b2b1e : LAV, { cast: false });
      if (!isOpen) b.deco(-16.92, (y0 + y1) / 2 - 0.2, (z0 + z1) / 2 - 0.2, -16.7, (y0 + y1) / 2 + 0.2, (z0 + z1) / 2 + 0.2, PINE_D);
    }),
  );
  const drawer = (x1: number, y0: number, y1: number, z0: number, z1: number) => {
    b.box(-17, y0, z0, x1, y1, z1, PINE_D, { name: 'drawer' });
    b.deco(x1, y0 - 0.1, z0 - 0.1, x1 + 0.1, y1 + 0.1, z1 + 0.1, LAV, { cast: false });
    b.deco(x1 + 0.1, (y0 + y1) / 2 - 0.2, (z0 + z1) / 2 - 0.2, x1 + 0.3, (y0 + y1) / 2 + 0.2, (z0 + z1) / 2 + 0.2, PINE_D);
  };
  drawer(-14.5, 1.0, 2.4, 8, 11);
  drawer(-15.5, 4.0, 5.0, 12, 15);
  drawer(-15.6, 7.0, 7.6, 8, 11);
  b.box(-21, 10, 12.5, -18.5, 11.2, 15, LAV_D, { name: 'jewellery-box' });
  b.deco(-21.05, 10.9, 12.45, -18.45, 11, 15.05, YELLOW, { cast: false });

  // floor clutter
  bookStack(b, 4, 0, -12, 6.5, -10, 1.2, [0x5b7fb0, YELLOW], 51);
  // toy shelf under the window: books 1.2 → toy box 3.4 → shelf 6.0 → window sill 8.4
  b.box(3, 0, -16, 9, 6, -13.6, PINE, { name: 'toy-shelf' });
  b.deco(3.2, 2.9, -13.62, 8.8, 3.1, -13.58, PINE_D, { cast: false });
  cardboard(b, 3, 0, -13.6, 5.4, 3.4, -12, 'toy-box');
  b.deco(-12, 0, 6, -11, 0.3, 8.2, LAV_D, { rotY: 0.2 });
  b.deco(-10.6, 0, 6.3, -9.6, 0.3, 8.5, LAV_D, { rotY: -0.15 });

  return finish(b, 'bedroom', {
    spawns: duelSpawns(-5, 5, 6, 14),
    rollers: [
      { kind: 'ball', x: 7.9, y: 0, z: -6.1 },
      { kind: 'reel', x: -7, y: 0, z: 9 },
    ],
    itemSpawns: [
      { x: 0, y: 0, z: 10 },
      { x: 10, y: 0, z: 12 },
      { x: -9, y: 0, z: -5 },
      { x: -9, y: 5, z: -4 },
      { x: -12, y: 8.6, z: -15.5 },
      { x: -19.5, y: 6, z: -12.8 },
      { x: 18, y: 7.4, z: 0.5 },
      { x: -19.5, y: 10, z: 9 },
      { x: 13.4, y: 4.8, z: 0 },
    ],
    itemKinds: ['pencil', 'ruler', 'paintbrush', 'knittingneedle', 'crayon', 'hairpin', 'toothpick'],
    ceiling: 11.2,
    sky: 0xd9d0ee,
    sun: [-0.3, -0.75, 0.6],
  });
}

// ---------------------------------------------------------------------------
// Bathroom — aqua, white, sunny yellow
//
// Chains: floor → toilet paper 2.0 → bath stool 3.4 → tub rim 5.5; inside: basin 1.0 → duck 3.0 → rim 5.5
//         floor → toilet paper 2.0 → toilet lid 4.2 → spare roll 5.3 → tank 7.9
//         floor → towels 2.0 → laundry basket 3.8 → vanity drawer 6.0 → vanity 8.5

function buildBathroom(doors?: Doors): RoomData {
  const b = new RoomBuilder({ minX: -20, maxX: 20, minZ: -15, maxZ: 15 }, doors);
  const AQUA = 0x5cc8c8;
  const AQUA_D = 0x3fa9ab;
  const WHITE = 0xf7f7f2;
  const YELLOW = 0xf6cf3a;
  const CHROME = 0xc9d1d6;

  const walls = shell(b, 0xf4f6f4, WHITE, { color: AQUA, top: 8, rail: YELLOW });
  tileFloor(b, 2, 0xf2f4f1, 0x9fdcd8, 0xb9c8c6);
  windowOn(b, walls.left, -1, 8, 10, 18, WHITE, YELLOW);
  wallBox(b, walls.right, -5.5, 3.5, 11, 19, 0, 0.15, YELLOW); // mirror
  wallBox(b, walls.right, -5, 3, 11.5, 18.5, 0, 0.2, 0xcfeef2, { emissive: 0x1d3a3c, cast: false });
  wallBox(b, walls.back, 0, 4, 10.6, 10.9, 0, 0.6, CHROME); // towel bar
  wallBox(b, walls.back, 0.5, 3.5, 6.5, 10.9, 0.2, 0.55, AQUA);
  wallBox(b, walls.back, 11.5, 12.7, 5.6, 6.8, 0.1, 1.2, WHITE); // paper holder

  // bathtub: four rims around a basin you can drop into
  b.box(-20, 0, -15, -3, 5.5, -14, WHITE, { name: 'tub-rim' });
  b.box(-20, 0, -9, -3, 5.5, -8, WHITE, { name: 'tub-rim' });
  b.box(-20, 0, -14, -19, 5.5, -9, WHITE, { name: 'tub-rim' });
  b.box(-4, 0, -14, -3, 5.5, -9, WHITE, { name: 'tub-rim' });
  b.box(-19, 0, -14, -4, 1, -9, 0xe4f0f0, { name: 'tub-basin' });
  b.round(-17.5, 1, -11.5, 0.4, 0.03, 0x8c979e, { cast: false }, false);
  b.box(-17, 5.5, -15, -16.2, 6.6, -14, CHROME, { name: 'tub-faucet' });
  b.deco(-16.9, 6.2, -14, -16.3, 6.6, -13.3, CHROME);
  cardboard(b, -10, 1, -11, -8, 3, -9, 'tub-box');
  b.box(-5.5, 1, -14, -4, 4.2, -12.5, 0xff8fa3, { name: 'shampoo' });
  b.deco(-5.1, 4.2, -13.6, -4.4, 4.7, -12.9, WHITE, { shape: 'cyl' });

  // outside steps up to the rim
  b.deco(-18, 0, -7.6, -13, 0.06, -3.4, YELLOW, { cast: false }); // bath mat
  chair(b, -12, -7.3, -8, -4.3, 3.4, AQUA, AQUA_D, undefined, 'bath-stool');
  tpPack(b, -7.3, -6.8);

  // toilet
  b.solid(6.3, 0, -15, 10.2, 7.9, -13, 'toilet-tank');
  b.deco(6.3, 0, -15, 10.2, 7.5, -13, WHITE);
  b.deco(6.2, 7.5, -15, 10.3, 7.9, -12.9, WHITE);
  b.deco(8.0, 7.9, -14.2, 8.5, 8.0, -13.7, CHROME, { shape: 'cyl', cast: false });
  b.box(7.2, 0, -13, 9.3, 3, -8.5, WHITE, { name: 'toilet-base' });
  b.solid(6.5, 3, -13, 10, 4.2, -7.5, 'toilet-lid');
  b.deco(6.5, 3, -13, 10, 3.9, -7.5, WHITE);
  b.deco(6.6, 3.9, -12.9, 9.9, 4.2, -7.6, YELLOW);
  b.box(8.6, 4.2, -13, 9.7, 5.3, -11.9, WHITE, { shape: 'cyl', name: 'toilet-roll' });
  tpPack(b, 10.7, -11);

  // vanity with an open drawer, laundry basket and towels as steps
  b.solid(15, 0, -6, 20, 8.5, 4, 'vanity');
  b.deco(15, 0, -6, 20, 8.1, 4, WHITE);
  b.deco(14.9, 8.1, -6.1, 20, 8.5, 4.1, AQUA);
  b.deco(14.92, 0.8, -5.6, 15, 4.5, -1.2, AQUA, { cast: false });
  b.deco(14.92, 0.8, -0.8, 15, 4.5, 3.6, AQUA, { cast: false });
  b.deco(14.94, 5.0, -5, 15, 6.0, -1, 0x2c3a3a, { cast: false });
  b.deco(14.92, 5.0, -0.6, 15, 7.6, 3.6, AQUA, { cast: false });
  b.box(13, 5, -5, 15, 6, -1, AQUA_D, { name: 'drawer' });
  b.deco(12.8, 5.3, -3.4, 13, 5.7, -2.6, YELLOW);
  b.deco(16, 8.5, -3, 18.6, 8.54, 1, 0xdfeff0, { shape: 'cyl', cast: false });
  b.box(19, 8.5, -1.4, 20, 10.3, -0.6, CHROME, { name: 'faucet' });
  b.deco(18.3, 9.9, -1.2, 19, 10.2, -0.8, CHROME);
  b.box(16, 8.5, 2, 17.4, 8.9, 3, 0xf7a1b5, { name: 'soap' });
  b.round(18.8, 8.5, 2.7, 0.5, 1.4, AQUA, { name: 'cup' });
  b.deco(18.6, 9.9, 2.5, 18.75, 11.2, 2.65, YELLOW);
  b.deco(18.85, 9.9, 2.75, 19.0, 11.0, 2.9, 0xe86f8a);

  b.solid(9, 0, -5, 12.3, 3.8, -1, 'laundry-basket');
  b.deco(9, 0, -5, 12.3, 3.6, -1, 0xd4b27a, { shape: 'bowl' });
  b.deco(9.4, 3.1, -4.6, 11.0, 3.9, -3.0, AQUA, { shape: 'ball' });
  b.deco(10.6, 3.2, -3.0, 11.9, 3.85, -1.5, YELLOW, { shape: 'ball' });
  b.solid(9, 0, 0, 11.5, 2, 2.5, 'towels');
  b.deco(9, 0, 0, 11.5, 0.65, 2.5, AQUA);
  b.deco(9.1, 0.65, 0.1, 11.4, 1.3, 2.4, YELLOW);
  b.deco(9, 1.3, 0, 11.5, 2, 2.5, WHITE);

  // floor bits
  b.box(-17, 0, 5, -13.5, 0.5, 8.5, 0xdfe6e8, { name: 'scale' });
  // towel cabinet under the window: bath toys 2.2 → towels 4.6 → cabinet 7.0 → window sill 9.4
  b.box(-20, 0, 0, -17.5, 7, 4, WHITE, { name: 'towel-cabinet' });
  b.deco(-17.52, 0.6, 0.3, -17.45, 6.4, 3.7, AQUA_D, { cast: false });
  b.box(-17.5, 0, 0.5, -15.5, 4.6, 2.5, AQUA, { name: 'towels' });
  cardboard(b, -15.5, 0, 0.5, -13.5, 2.2, 2.5, 'toy-crate');
  b.deco(-16, 0.5, 5.6, -14.5, 0.52, 6.6, 0x2c3a3a, { cast: false });
  duck(b, -16, 0, 11, 1.4);

  return finish(b, 'bathroom', {
    spawns: duelSpawns(-5, 5, 3, 11),
    // The bath taps run: it fills slowly, then drains and fills again.
    pools: [{ minX: -19, maxX: -4, minZ: -14, maxZ: -9, floor: 1, low: 1, high: 5, fill: 75, tap: { x: -16.6, y: 6.1, z: -13.55 } }],
    itemSpawns: [
      { x: 0, y: 0, z: 7 },
      { x: -8, y: 0, z: 12 },
      { x: 14, y: 0, z: 10 },
      { x: -15.2, y: 0.5, z: 6.7 },
      { x: -14, y: 1, z: -11.5 },
      { x: -12, y: 5.5, z: -14.5 },
      { x: 8.2, y: 4.2, z: -9 },
      { x: 17, y: 8.5, z: -3.5 },
      { x: 10.6, y: 3.8, z: -3 },
      { x: -10, y: 3.4, z: -5.8 },
    ],
    itemKinds: ['toothbrush', 'comb', 'razor', 'cottonswab', 'nailfile', 'tweezers', 'toothpick'],
    ceiling: 10.3,
    sky: 0xcdeef0,
    sun: [0.6, -0.75, 0.28],
  });
}

// ---------------------------------------------------------------------------
// Dining room — oak, sage, wine red (only in the house)
//
// Chains: floor → books 2.4 → chair seat 4.8 → dining table 7.4
//         floor → wine crate 2.2 → sideboard drawer 4.6 → upper drawer 6.8 → sideboard 8.5
// The train runs under the table and two of the chairs: legs straddle the track.

function buildDining(doors?: Doors): RoomData {
  const b = new RoomBuilder({ minX: -20, maxX: 20, minZ: -16, maxZ: 16 }, doors);
  const OAK = 0xb07a45;
  const OAK_D = 0x7d5230;
  const SAGE = 0x8fa889;
  const WINE = 0x8e2f3c;
  const CREAM = 0xf5ecd8;
  const BRASS = 0xc9a24a;
  const WHITE = 0xf8f6f0;

  const walls = shell(b, 0xf1e6cf, CREAM, { color: SAGE, top: 7, rail: OAK_D });
  plankFloor(b, 0xa8723f, 0xb98049, 0x4a2e18);
  windowOn(b, walls.back, -13, -4, 10, 19, CREAM, WINE);
  picture(b, walls.back, 4, 14, 11, 18, OAK_D, 0xd9a72b);
  picture(b, walls.right, 5, 13, 10, 16, BRASS, SAGE);
  // wall clock
  wallBox(b, walls.front, -11, -7, 13, 17, 0, 0.3, OAK_D);
  wallBox(b, walls.front, -10.6, -7.4, 13.4, 16.6, 0, 0.36, WHITE, { cast: false });
  wallBox(b, walls.front, -9.1, -8.9, 15, 16.2, 0, 0.42, 0x26282b, { cast: false });
  wallBox(b, walls.front, -9.1, -8.1, 14.9, 15.1, 0, 0.42, 0x26282b, { cast: false });

  // rug under the table
  b.deco(-6, 0, -15, 18, 0.04, 3, WINE, { cast: false });
  b.deco(-5, 0, -14, 17, 0.06, 2, 0xd9b77a, { cast: false });

  // dining table, its legs clear of the track (which turns the corner underneath it)
  table(b, 0, -13, 15, -1, 7.4, 0.6, 0.8, OAK, OAK_D, 'dining-table');
  b.deco(1, 7.4, -8, 14, 7.44, -6, CREAM, { cast: false });
  for (const [px, pz] of [
    [3.5, -11],
    [11.5, -11],
    [3.5, -3],
    [11.5, -3],
  ])
    b.round(px, 7.4, pz, 0.95, 0.08, WHITE, { cast: false }, false);
  for (const cx of [6, 9]) {
    b.round(cx, 7.4, -7, 0.35, 0.25, BRASS, { name: 'candlestick' });
    b.round(cx, 7.65, -7, 0.14, 1.4, BRASS, { name: 'candlestick', grab: false });
    b.deco(cx - 0.12, 9.05, -7.12, cx + 0.12, 9.9, -6.88, 0xfbf6e8, { shape: 'cyl' });
    b.deco(cx - 0.08, 9.9, -7.08, cx + 0.08, 10.2, -6.92, 0xffc870, { shape: 'cone', emissive: 0xffa040, cast: false });
  }
  b.round(3, 7.4, -7.2, 0.8, 1.3, WHITE, { name: 'teapot' });
  b.deco(2.6, 8.7, -7.6, 3.4, 9.0, -6.8, WHITE, { shape: 'ball' });
  b.deco(3.7, 7.9, -7.35, 4.4, 8.15, -7.05, WHITE);
  b.box(11.6, 7.4, -8.2, 13.8, 8.2, -6.2, 0xd4b27a, { shape: 'bowl', name: 'bread-basket' });
  b.deco(11.9, 8.0, -7.9, 13.0, 8.6, -6.9, 0xc98a3e, { shape: 'ball' });
  b.deco(12.6, 8.0, -7.4, 13.6, 8.5, -6.5, 0xd99a4a, { shape: 'ball' });

  // chairs: the train passes between the legs of the east and south ones
  chair(b, 15.3, -8.5, 19.3, -4.5, 4.8, OAK, OAK_D, { side: 'x+', top: 9 }, 'dining-chair');
  chair(b, 5, 0, 9, 4, 4.8, OAK, OAK_D, { side: 'z+', top: 9 }, 'dining-chair');
  chair(b, -4.5, -9, -0.5, -5, 4.8, OAK, OAK_D, { side: 'x-', top: 9 }, 'dining-chair');
  chair(b, 5, -16, 9, -12, 4.8, OAK, OAK_D, { side: 'z-', top: 9 }, 'dining-chair');
  for (const [x0, z0, x1, z1] of [
    [15.4, -8.4, 18.9, -4.6],
    [5.1, 0.1, 8.9, 3.6],
    [-4.0, -8.9, -0.6, -5.1],
    [5.1, -15.4, 8.9, -12.1],
  ])
    b.deco(x0, 4.8, z0, x1, 4.95, z1, WINE, { cast: false });
  bookStack(b, -4, 0, -5, -1.5, -2.5, 2.4, [WINE, SAGE, 0xd9a72b, 0x3e5c8a], 61);

  // sideboard on the left wall, with two drawers pulled out as steps
  b.solid(-20, 0, 3, -15, 8.5, 15, 'sideboard');
  b.deco(-20, 0, 3, -15.2, 8.1, 15, OAK_D);
  b.deco(-20, 8.1, 2.8, -14.9, 8.5, 15.2, OAK);
  for (const [z0, z1] of [
    [3.4, 8.8],
    [9.2, 14.6],
  ]) {
    b.deco(-15.2, 0.8, z0, -14.95, 3.3, z1, OAK, { cast: false });
    b.deco(-14.95, 1.9, (z0 + z1) / 2 - 0.2, -14.8, 2.3, (z0 + z1) / 2 + 0.2, BRASS);
  }
  for (const [y0, y1] of [
    [3.6, 4.8],
    [5.6, 6.8],
  ]) {
    for (const [z0, z1] of [
      [3.4, 7.6],
      [11.9, 14.6],
    ]) {
      b.deco(-15.2, y0, z0, -14.95, y1, z1, OAK, { cast: false });
      b.deco(-14.95, (y0 + y1) / 2 - 0.15, (z0 + z1) / 2 - 0.4, -14.85, (y0 + y1) / 2 + 0.15, (z0 + z1) / 2 + 0.4, BRASS);
    }
    b.deco(-15.2, y0, 8, -15.1, y1, 11.5, 0x2e1c10, { cast: false });
  }
  b.box(-15, 3.6, 8, -13, 4.6, 11.5, OAK, { name: 'drawer' });
  b.deco(-13, 3.9, 9.4, -12.8, 4.3, 10.1, BRASS);
  b.box(-15, 5.8, 8, -14, 6.8, 11.5, OAK, { name: 'drawer' });
  b.deco(-14, 6.1, 9.4, -13.8, 6.5, 10.1, BRASS);
  // wine crate step
  b.box(-15, 0, 5, -12.5, 2.2, 8, 0xc99a5e, { name: 'crate' });
  for (const y of [0.5, 1.3]) b.deco(-12.52, y, 5, -12.45, y + 0.5, 8, 0xa77b45, { cast: false });
  for (const z of [5.6, 6.5, 7.4]) b.deco(-14.4, 2.2, z - 0.15, -13.1, 2.5, z + 0.15, 0x5a1f2a, { shape: 'cyl', cast: false });
  // on top: a vase of flowers, a stack of plates and a lamp
  b.round(-17.5, 8.5, 4.8, 0.6, 2.2, 0x3e6ea8, { name: 'vase' });
  for (const [fx, fz, fc] of [
    [-17.8, 4.5, 0xe86f8a],
    [-17.2, 4.9, 0xf2c14e],
    [-17.5, 5.2, 0xd8443a],
  ])
    b.deco(fx - 0.35, 11.0, fz - 0.35, fx + 0.35, 11.7, fz + 0.35, fc, { shape: 'ball' });
  b.deco(-17.55, 10.7, 4.75, -17.45, 11.1, 4.85, 0x4f8a3a);
  b.round(-17.5, 8.5, 9, 1.0, 0.7, WHITE, { name: 'plates' });
  b.round(-17.5, 8.5, 13, 0.7, 0.3, BRASS, { name: 'lamp-base' });
  b.deco(-17.6, 8.8, 12.9, -17.4, 10.6, 13.1, BRASS, { shape: 'cyl6' });
  b.deco(-18.8, 10.4, 11.7, -16.2, 12.2, 14.3, 0xfbe7b5, { shape: 'shade', emissive: 0xffc870 });

  // potted plant in the back corner
  b.box(16, 0, -15, 19, 3, -12, 0xc0643a, { shape: 'bowl', name: 'plant-pot' });
  for (const [x, y, z, s] of [
    [17.5, 3.5, -13.5, 2.8],
    [16.8, 5.4, -14.1, 2.2],
    [18.2, 5.8, -12.9, 2.2],
  ])
    b.deco(x - s / 2, y, z - s / 2, x + s / 2, y + s * 1.2, z + s / 2, 0x4f8a3a, { shape: 'cone', rotY: x });

  // chandelier over the table
  b.deco(7.45, 15.6, -7.05, 7.55, 25, -6.95, 0x3a3330, { cast: false });
  b.deco(5.3, 15.2, -9.2, 9.7, 15.5, -4.8, BRASS, { shape: 'cyl', cast: false });
  for (const [lx, lz] of [
    [5.6, -7],
    [9.4, -7],
    [7.5, -8.9],
    [7.5, -5.1],
  ])
    b.deco(lx - 0.3, 15.5, lz - 0.3, lx + 0.3, 16.2, lz + 0.3, 0xfff0c8, { shape: 'ball', emissive: 0xffc870, cast: false });

  return finish(b, 'dining', {
    spawns: duelSpawns(-11, -1, 6, 12),
    itemSpawns: [
      { x: -8, y: 0, z: 8 },
      { x: 12, y: 0, z: 10 },
      { x: -12, y: 0, z: -10 },
      { x: 0, y: 0, z: 12 },
      { x: 4, y: 7.4, z: -4.5 },
      { x: 12.5, y: 7.4, z: -11.5 },
      { x: -17.5, y: 8.5, z: 6.8 },
      { x: -2.5, y: 4.8, z: -7 },
      { x: -13.7, y: 2.2, z: 6.5 },
    ],
    itemKinds: ['fork', 'spoon', 'butterknife', 'ladle', 'chopstick', 'pizzacutter'],
    ceiling: 11.2,
    sky: 0xf2dcc0,
    sun: [-0.35, -0.75, 0.55],
  });
}

// ---------------------------------------------------------------------------
// Garage — concrete, pale grey, safety yellow
//
// Chains: floor → stepladder 2.2 → 4.4 → 6.6 → top 8.8 → workbench 9.0 / cabinet 9.0
//         floor → tyre 1.8 → two tyres 3.6 → three tyres 5.4
//         floor → grass bag 2.2 → mower deck 3.4 → engine 5.2
//         floor → box 3.0 → box 5.2
// In the house the train comes in from the hallway, runs past the big roll-up door
// and leaves into the bedroom, so the east side (local -x) holds the furniture.

function buildGarage(doors?: Doors): RoomData {
  const b = new RoomBuilder({ minX: -22, maxX: 22, minZ: -16, maxZ: 16 }, doors);
  const CONCRETE = 0xb9bcb8;
  const GREY = 0x8d9296;
  const YELLOW = 0xf2c14e;
  const RED = 0xc8323c;
  const WOOD = 0xb88a55;
  const WOOD_D = 0x8a6238;
  const GREEN = 0x3f8a4a;
  const RUBBER = 0x26282b;

  const walls = shell(b, 0xd6d8d4, 0xbfc3c0);
  tileFloor(b, 4, CONCRETE, 0xadb0ac, 0x9a9d99);
  b.deco(6, 0, -6, 11, 0.02, -2, 0x55524d, { shape: 'cyl', cast: false }); // oil stain
  windowOn(b, walls.left, 9, 14, 11, 17, 0xbfc3c0);

  // The big roll-up garage door across the back wall, shut.
  wallBox(b, walls.back, -3.6, 19.6, 0, 18.6, 0, 0.2, 0x9a9d99);
  wallBox(b, walls.back, -3, 19, 0, 18, 0, 0.3, 0xece9df);
  for (let y = 3; y < 18; y += 3) wallBox(b, walls.back, -3, 19, y - 0.08, y + 0.08, 0, 0.36, 0xc9c5b8, { cast: false });
  for (let u = -2; u < 18; u += 4) wallBox(b, walls.back, u, u + 3, 15.3, 17.2, 0, 0.34, 0xfff4d6, { emissive: 0xffd99a, cast: false });
  wallBox(b, walls.back, 7, 9, 1.2, 1.7, 0, 0.6, GREY);

  // Workbench on the east wall, a pegboard of tools above it.
  table(b, -22, -8, -16, 6, 9, 0.8, 0.7, WOOD, WOOD_D, 'workbench');
  b.deco(-21.8, 3, -7.6, -16.2, 3.2, 5.6, WOOD_D); // lower shelf
  wallBox(b, walls.left, -8, 6, 10, 17, 0, 0.12, 0xd9b77a);
  for (let u = -7.4; u < 5.6; u += 1) for (let y = 10.6; y < 16.6; y += 1) wallBox(b, walls.left, u, u + 0.1, y, y + 0.1, 0.12, 0.13, 0x6b5a40, { cast: false });
  // hammer, saw, spanner, screwdriver hung on the board
  wallBox(b, walls.left, -6.5, -6.1, 11, 15.5, 0.12, 0.35, WOOD_D);
  wallBox(b, walls.left, -7.2, -5.4, 15, 15.9, 0.12, 0.45, GREY);
  wallBox(b, walls.left, -3.8, 0.8, 12.4, 14.6, 0.12, 0.18, 0xc9d1d6);
  wallBox(b, walls.left, -4.4, -3.6, 12.2, 15, 0.12, 0.4, RED);
  wallBox(b, walls.left, 2, 2.4, 11, 16, 0.12, 0.3, 0xa9b0b8);
  wallBox(b, walls.left, 4, 4.3, 11.5, 15.5, 0.12, 0.3, 0xa9b0b8);
  wallBox(b, walls.left, 3.85, 4.45, 15.2, 16.4, 0.12, 0.4, YELLOW);
  b.box(-17.6, 9, 3, -16.2, 10.2, 5, GREY, { name: 'vise' });
  b.box(-21, 9, -3, -18, 10.8, 0, RED, { name: 'toolbox' });
  b.deco(-20.6, 10.8, -1.75, -18.4, 11.1, -1.25, 0x26282b);
  b.round(-20, 9, -6, 0.6, 1.4, 0xc9d1d6, { name: 'screw-tin' });

  // Metal cabinet beside it, paint tins on top.
  b.box(-22, 0, -16, -16, 9, -8, GREY, { name: 'cabinet' });
  b.deco(-16.02, 0.6, -15.6, -15.95, 8.4, -12.1, 0x7a7f84, { cast: false });
  b.deco(-16.02, 0.6, -11.9, -15.95, 8.4, -8.4, 0x7a7f84, { cast: false });
  b.round(-19, 9, -12.5, 0.8, 1.6, 0x3e6ea8, { name: 'paint-tin' });
  b.round(-20.5, 9, -10, 0.7, 1.3, YELLOW, { name: 'paint-tin' });

  // Yellow stepladder up to the workbench.
  for (const [x0, x1, y] of [
    [-11.5, -10.6, 2.2],
    [-12.6, -11.7, 4.4],
    [-13.7, -12.8, 6.6],
    [-16, -13.8, 8.8],
  ])
    b.box(x0, y - 0.2, -3.5, x1, y, 0.5, YELLOW, { name: 'ladder-step' });
  // Side rails rise from the floor in front and lean back with the steps; the back legs
  // drop from the top cap to the floor under the bench.
  for (const z of [-3.7, 0.5]) {
    const rail = b.deco(-0.12, -5.2, z, 0.12, 5.2, z + 0.2, 0xd9a72b);
    rail.position.set(-12.6, 4.6, z + 0.1);
    rail.rotation.z = Math.atan2(4.4, 9.2);
    const leg = b.deco(-0.12, -4.7, z, 0.12, 4.7, z + 0.2, 0xd9a72b);
    leg.position.set(-17.2, 4.4, z + 0.1);
    leg.rotation.z = -Math.atan2(2.6, 8.8);
  }

  // Tyres: one, two and three high.
  const tyre = (cx: number, cz: number, n: number): void => {
    b.solid(cx - 2.6, 0, cz - 2.6, cx + 2.6, n * 1.8, cz + 2.6, 'tyres', true, false, true);
    for (let i = 0; i < n; i++) {
      b.round(cx, i * 1.8, cz, 2.6, 1.8, RUBBER, {}, false);
      b.round(cx, i * 1.8 + 1.79, cz, 1.4, 0.03, 0x111214, { cast: false }, false);
    }
  };
  tyre(-4.8, 12, 1);
  tyre(-10, 12, 2);
  tyre(-15.2, 12, 3);

  // Lawn mower: grass bag 2.2 → deck 3.4 → engine 5.2, handle reaching up behind.
  b.box(-8.5, 0.3, -13, -2.5, 3.4, -7, GREEN, { name: 'mower-deck' });
  b.box(-7, 3.4, -11.5, -4, 5.2, -8.5, 0x2b2d30, { name: 'mower-engine' });
  b.deco(-6.2, 5.2, -10.7, -4.8, 5.6, -9.3, RED);
  for (const [wx, wz] of [
    [-8.6, -13.4],
    [-8.6, -6.6],
    [-2.4, -13.4],
    [-2.4, -6.6],
  ]) {
    const w = b.round(wx, 0, wz, 1.1, 0.5, RUBBER, { cast: true }, false);
    w.rotation.x = Math.PI / 2;
    w.position.y = 1.1;
    b.solid(wx - 1.1, 0, wz - 0.25, wx + 1.1, 2.2, wz + 0.25, 'mower-wheel', false);
  }
  b.box(-2.5, 0, -12.5, 0.5, 2.2, -7.5, 0x6b8a4a, { name: 'grass-bag' });
  for (const z of [-12.6, -7.6]) {
    const bar = b.deco(-0.3, 0, z - 0.15, 0.3, 7.5, z + 0.15, 0x1d1f24);
    bar.position.set(-0.6, 5.6, z);
    bar.rotation.z = -0.45;
    // Colliders in short pieces up the slanting bar.
    for (const t of [0.15, 0.4, 0.65, 0.9]) {
      const px = -2.23 + 3.26 * t;
      const py = 2.22 + 6.76 * t;
      b.solid(px - 0.35, py - 0.9, z - 0.15, px + 0.35, py + 0.9, z + 0.15, 'mower-handle', false);
    }
  }
  b.box(0.9, 8.6, -13, 1.9, 9.1, -7.2, 0x1d1f24, { name: 'mower-handle' });

  // Paint tins and a bucket (inside the train's loop in the house).
  b.round(9, 0, -6, 1.1, 2.2, 0xe86f4a, { name: 'paint-tin' });
  b.round(9, 2.2, -6, 1.1, 2.2, 0x3e6ea8, { name: 'paint-tin' });
  b.round(11.2, 0, -6, 1.1, 2.2, YELLOW, { name: 'paint-tin' });
  b.box(12, 0, 0, 14.6, 2.6, 2.6, 0x3e86c8, { shape: 'bowl', name: 'bucket' });

  // Storage boxes by the hallway wall.
  cardboard(b, 9, 0, 11, 13, 3, 14.7, 'box');
  cardboard(b, 13, 0, 11.4, 16.5, 5.2, 14.7, 'box');

  // A strip light.
  b.deco(-6, 20, -1, 6, 20.4, 0, 0xf4f8ff, { emissive: 0xe8f0ff, cast: false });
  b.deco(-0.05, 20.4, -0.55, 0.05, 25, -0.45, GREY, { cast: false });

  return finish(b, 'garage', {
    spawns: duelSpawns(-6, 6, -2, 4),
    rollers: [{ kind: 'can', x: 9, y: 0, z: 1 }],
    // Four car boxes: break one open and drive the car inside.
    crates: [
      { x: -6, y: 0, z: 5 },
      { x: -1.5, y: 0, z: -3 },
      { x: 10, y: 0, z: -10.5 },
      { x: 19, y: 0, z: 10 },
    ],
    itemSpawns: [
      { x: 0, y: 0, z: 0 },
      { x: -6, y: 0, z: 6 },
      { x: 8, y: 0, z: 9 },
      { x: -19, y: 9, z: 2 },
      { x: -15.2, y: 5.4, z: 12 },
      { x: 14.8, y: 5.2, z: 13 },
      { x: -8, y: 3.4, z: -8 },
      { x: -15, y: 8.8, z: -1.5 },
    ],
    itemKinds: ['screwdriver', 'wrench', 'paintbrush', 'paperclip', 'clothespeg', 'nunchaku'],
    ceiling: 11,
    sky: 0xd9dde0,
    sun: [0.35, -0.75, 0.55],
  });
}

// ---------------------------------------------------------------------------
// The whole house: the four rooms and the dining room round one long hallway.
//
//   north:  Kitchen | Dining | Living          (their windows on the outside walls)
//           ========== Hallway ========== [front door]
//   south:  Bathroom | Bedroom | Garage        (turned to face the hallway)
//
// Rooms keep their own walls, so inner walls are two thick. Doorways stand open.
// A toy train loops Dining → Living → Hallway → Garage (past its roll-up door) →
// Bedroom → Hallway → Dining, under the dining table and chairs.

const HALL = { minX: -41, maxX: 70, minZ: -7, maxZ: 7 };
const TRACK: TrackDef = {
  points: [
    [6, -30.5],
    [34, -30.5],
    [34, 1.5],
    [66, 1.5],
    [66, 37.5],
    [52.5, 37.5],
    [52.5, 18.3],
    [6, 18.3],
  ],
  r: 4,
};

/** How a room is set into the house: as built, turned half round, or mirrored front to back. */
type Placing = 'as-is' | 'turned' | 'mirrored';

function buildHouse(): RoomData {
  const root = new THREE.Group();
  root.name = 'room-house';
  const solids: Solid[] = [];
  const cutaway: RoomData['cutaway'] = [];
  const areas: Area[] = [];
  const itemSpawns: RoomData['itemSpawns'] = [];
  const kinds = new Set<string>();
  const machines: MachineDef[] = [];
  const rollers: RollerDef[] = [];
  const crates: { x: number; y: number; z: number }[] = [];
  const pools: PoolDef[] = [];
  const drips: DripDef[] = [];

  const place = (part: RoomData, X: number, Z: number, how: Placing): void => {
    const kx = how === 'turned' ? -1 : 1;
    const kz = how === 'as-is' ? 1 : -1;
    const holder = new THREE.Group();
    holder.name = `holder-${part.id}`;
    holder.position.set(X, 0, Z);
    if (how === 'turned') holder.rotation.y = Math.PI;
    if (how === 'mirrored') holder.scale.z = -1;
    holder.add(part.root);
    root.add(holder);
    const rx = (a: number, c: number): [number, number] => {
      const p = X + kx * a;
      const q = X + kx * c;
      return [Math.min(p, q), Math.max(p, q)];
    };
    const rz = (a: number, c: number): [number, number] => {
      const p = Z + kz * a;
      const q = Z + kz * c;
      return [Math.min(p, q), Math.max(p, q)];
    };
    const rect = <T extends { minX: number; maxX: number; minZ: number; maxZ: number }>(r: T): T => {
      const [minX, maxX] = rx(r.minX, r.maxX);
      const [minZ, maxZ] = rz(r.minZ, r.maxZ);
      return { ...r, minX, maxX, minZ, maxZ };
    };
    for (const s of part.solids) solids.push(rect(s));
    for (const c of part.cutaway) {
      const normal = new THREE.Vector3(c.normal.x * kx, c.normal.y, c.normal.z * kz);
      let plane: WallPlane | undefined;
      if (c.plane) {
        const p = c.plane;
        const [u0, u1] = p.axis === 'z' ? rx(p.u0, p.u1) : rz(p.u0, p.u1);
        plane = { axis: p.axis, at: p.axis === 'z' ? Z + kz * p.at : X + kx * p.at, u0, u1 };
      }
      cutaway.push({ mesh: c.mesh, normal, plane });
    }
    for (const a of part.areas ?? []) areas.push(rect(a));
    const yawOf = (yaw: number) => (how === 'turned' ? yaw + Math.PI : how === 'mirrored' ? Math.PI - yaw : yaw);
    for (const m of part.machines ?? []) machines.push({ ...m, x: X + kx * m.x, z: Z + kz * m.z, ...('yaw' in m ? { yaw: yawOf(m.yaw) } : {}) } as MachineDef);
    for (const r of part.rollers ?? []) rollers.push({ ...r, x: X + kx * r.x, z: Z + kz * r.z });
    for (const c of part.crates ?? []) crates.push({ ...c, x: X + kx * c.x, z: Z + kz * c.z });
    for (const p of part.pools ?? []) pools.push({ ...rect(p), tap: { ...p.tap, x: X + kx * p.tap.x, z: Z + kz * p.tap.z } });
    for (const d of part.drips ?? []) drips.push({ ...d, x: X + kx * d.x, z: Z + kz * d.z });
    for (const p of part.itemSpawns) itemSpawns.push({ x: X + kx * p.x, y: p.y, z: Z + kz * p.z, kinds: part.itemKinds });
    for (const id of part.itemKinds) kinds.add(id);
  };

  // Rooms, with doorways where they meet the hallway and each other.
  place(buildKitchen({ front: [{ u0: 10, u1: 18 }], right: [{ u0: -7, u1: -1 }] }), -46, -24, 'as-is');
  place(buildDining({ left: [{ u0: -7, u1: -1 }], front: [{ u0: 3, u1: 11 }], right: [{ u0: -10, u1: -3 }] }), -1, -24, 'as-is');
  place(buildLiving({ left: [{ u0: -10, u1: -3 }], front: [{ u0: -15, u1: -7 }] }), 45, -24, 'as-is');
  place(buildBathroom({ front: [{ u0: -4, u1: 4 }] }), -20, 23, 'mirrored');
  place(buildBedroom({ front: [{ u0: -14, u1: -6 }, { u0: 14.5, u1: 21.5 }], left: [{ u0: 3, u1: 7 }] }), 24, 24, 'turned');
  place(buildGarage({ front: [{ u0: 0.5, u1: 7.5 }], right: [{ u0: 3, u1: 7 }] }), 70, 24, 'turned');

  // ---- Hallway (built straight in house coordinates) ----
  const hb = new RoomBuilder({ ...HALL }, {});
  const PAPER = 0xe6c9a0;
  const PANEL = 0xf3ead8;
  const RAIL = 0x8a5a36;
  const TRIM = 0xfaf3e6;
  const WALNUT = 0x6b4428;
  const WALNUT_D = 0x4e301b;
  const BRASS = 0xc9a24a;
  const DOOR = 0xf6f1e6;
  hb.solid(HALL.minX, -1, HALL.minZ, HALL.maxX, 0, HALL.maxZ, 'floor', false);
  plankFloor(hb, 0x9a6a42, 0xa8774a, 0x3b2414);
  // long runner rug
  hb.deco(-38, 0, -2.6, 64, 0.04, 2.6, 0x8e2f3c, { cast: false });
  hb.deco(-37.4, 0, -2.0, 63.4, 0.06, 2.0, 0xd9a72b, { cast: false });
  hb.deco(-36.8, 0, -1.6, 62.8, 0.065, 1.6, 0x8e2f3c, { cast: false });

  /** A hallway wall surface: wallpaper, panelling, rail and skirting, with casings round its doorways. */
  const dress = (w: Wall, lo: number, hi: number, doors: DoorSpec[]): void => {
    for (const [s0, s1] of openSpans(lo, hi, doors)) {
      wallBox(hb, w, s0, s1, WALL_BASE, WALL_H, 0, 0.04, PAPER, { cast: false });
      wallBox(hb, w, s0, s1, 0.8, WALL_BASE, 0, 0.08, PANEL, { cast: false, parent: hb.root });
      wallBox(hb, w, s0, s1, WALL_BASE, 6, 0, 0.08, PANEL, { cast: false });
      wallBox(hb, w, s0, s1, 6, 6.3, 0, 0.16, RAIL, { cast: false });
      wallBox(hb, w, s0, s1, 0, 0.8, 0, 0.16, TRIM, { cast: false, parent: hb.root });
    }
    for (const d of doors) {
      wallBox(hb, w, d.u0, d.u1, DOOR_H, WALL_H, 0, 0.04, PAPER, { cast: false });
      wallBox(hb, w, d.u0 - 0.6, d.u0, 0, DOOR_H + 0.6, 0, 0.18, TRIM);
      wallBox(hb, w, d.u1, d.u1 + 0.6, 0, DOOR_H + 0.6, 0, 0.18, TRIM);
      wallBox(hb, w, d.u0 - 0.6, d.u1 + 0.6, DOOR_H, DOOR_H + 0.6, 0, 0.18, TRIM);
    }
  };
  /** A group of wall dressing that hides together with the wall it covers. */
  const skin = (along: 'x' | 'z', face: number, inward: 1 | -1, plane: WallPlane): Wall => {
    const group = new THREE.Group();
    hb.root.add(group);
    hb.cutaway.push({ mesh: group, normal: along === 'x' ? new THREE.Vector3(0, 0, inward) : new THREE.Vector3(inward, 0, 0), plane });
    return { group, along, face, inward };
  };
  /** A wall of the hallway's own: collider, mesh and dressing. */
  const ownWall = (along: 'x' | 'z', face: number, inward: 1 | -1, lo: number, hi: number, dressLo: number, dressHi: number): Wall => {
    const w = skin(along, face, inward, { axis: along === 'x' ? 'z' : 'x', at: face - inward * 0.5, u0: lo, u1: hi });
    wallSolid(hb, w, lo, hi, -1, WALL_H, -WALL_T, 0, 'wall');
    wallBox(hb, w, lo, hi, -1, WALL_BASE, -WALL_T, 0, PAPER, { cast: false, parent: hb.root });
    wallBox(hb, w, lo, hi, WALL_BASE, WALL_H, -WALL_T, 0, PAPER, { cast: false });
    dress(w, dressLo, dressHi, []);
    return w;
  };

  // The rooms' walls seen from the hallway, dressed to match it.
  const north = (lo: number, hi: number, wallLo: number, wallHi: number, doors: DoorSpec[]): Wall => {
    const w = skin('x', HALL.minZ, 1, { axis: 'z', at: HALL.minZ - 0.5, u0: wallLo, u1: wallHi });
    dress(w, lo, hi, doors);
    return w;
  };
  const south = (lo: number, hi: number, wallLo: number, wallHi: number, doors: DoorSpec[]): Wall => {
    const w = skin('x', HALL.maxZ, -1, { axis: 'z', at: HALL.maxZ + 0.5, u0: wallLo, u1: wallHi });
    dress(w, lo, hi, doors);
    return w;
  };
  const nKitchen = north(-41, -22, -70, -22, [{ u0: -36, u1: -28 }]);
  const nDining = north(-22, 20, -22, 20, [{ u0: 2, u1: 10 }]);
  const nLiving = north(20, 70, 20, 70, [{ u0: 30, u1: 38 }]);
  const sBath = south(-41, 1, -41, 1, [{ u0: -24, u1: -16 }]);
  const sBed = south(1, 47, 1, 47, [
    { u0: 2.5, u1: 9.5 },
    { u0: 30, u1: 38 },
  ]);
  south(47, 70, 47, 93, [{ u0: 62.5, u1: 69.5 }]);
  const west = ownWall('z', HALL.minX, 1, -8, 8, -7, 7);
  const east = ownWall('z', HALL.maxX, -1, -8, 8, -7, 7);

  // Pictures along the hallway.
  picture(hb, nKitchen, -40, -37.2, 10, 15, WALNUT_D, 0x7a9a4a);

  picture(hb, nLiving, 39.5, 43, 11, 16, BRASS, 0xc8473a);
  picture(hb, sBath, -12, -4, 10, 15, WALNUT_D, 0xd9a72b);
  picture(hb, sBed, 18.5, 27.5, 10, 16, BRASS, 0x9cc3e6);
  picture(hb, west, -4, 4, 10, 17, BRASS, 0xd9c7a0);
  // mirror over the console table
  wallBox(hb, nDining, -18.9, -12.9, 10.5, 17.5, 0, 0.2, BRASS);
  wallBox(hb, nDining, -18.4, -13.4, 11, 17, 0, 0.26, 0xcfe6ec, { emissive: 0x1d3036, cast: false });

  // Coat hooks by the front door, with a coat and a scarf hanging.
  wallBox(hb, nLiving, 44, 56, 13, 13.6, 0, 0.3, WALNUT);
  for (const u of [45.5, 49, 52.5, 55]) wallBox(hb, nLiving, u - 0.15, u + 0.15, 12.6, 13.3, 0.3, 0.8, BRASS);
  wallBox(hb, nLiving, 47.6, 50.4, 6.5, 12.8, 0.4, 1.4, 0x3e5c8a);
  wallBox(hb, nLiving, 48.6, 49.4, 11.6, 12.9, 0.35, 1.5, 0x2c4466);
  wallBox(hb, nLiving, 54.5, 55.4, 7.5, 12.8, 0.35, 0.7, 0xd8443a);
  wallBox(hb, nLiving, 55.0, 55.9, 8.5, 12.8, 0.7, 1.05, 0xf2c14e);

  // The front door at the far end: shut, with a fanlight, letterbox and knob.
  wallBox(hb, east, -5.1, 5.1, 0, 20.6, 0, 0.2, TRIM);
  wallBox(hb, east, -4.5, 4.5, 0, 20, 0, 0.32, 0x2f6b4a);
  for (const [u0, u1, y0, y1] of [
    [-3.8, -0.5, 1.5, 8.5],
    [0.5, 3.8, 1.5, 8.5],
    [-3.8, -0.5, 11, 18.4],
    [0.5, 3.8, 11, 18.4],
  ])
    wallBox(hb, east, u0, u1, y0, y1, 0.32, 0.44, 0x27593d);
  wallBox(hb, east, -1.6, 1.6, 9.4, 10.1, 0.32, 0.46, BRASS);
  wallBox(hb, east, 2.9, 3.6, 9.4, 10.1, 0.32, 0.8, BRASS);
  wallBox(hb, east, -0.4, 0.4, 15, 15.6, 0.32, 0.5, BRASS);
  wallBox(hb, east, -4.5, 4.5, 20.8, 23.4, 0, 0.24, 0xfff4d6, { emissive: 0xffd99a, cast: false });
  wallBox(hb, east, -4.9, 4.9, 20.6, 20.8, 0, 0.3, TRIM);
  hb.deco(66.2, 0, -3.6, 69.8, 0.08, 3.6, 0x8a6a3c, { cast: false });
  hb.deco(66.6, 0, -3.2, 69.4, 0.1, 3.2, 0x6b4f2a, { cast: false });

  // Shoe rack, a pair of trainers and an umbrella stand by the door.
  hb.solid(58, 0, -7, 66, 3.2, -4.6, 'shoe-rack');
  for (const x of [58, 65.7]) hb.deco(x, 0, -7, x + 0.3, 3.2, -4.6, WALNUT);
  for (const y of [0.25, 1.6, 2.95]) hb.deco(58.3, y, -7, 65.7, y + 0.25, -4.6, WALNUT);
  for (const [sx, sy, sc] of [
    [58.8, 0.5, 0xd8443a],
    [61.6, 0.5, 0x3e5c8a],
    [59.2, 1.85, 0x2c2a26],
    [62.4, 1.85, 0xf2c14e],
  ]) {
    hb.deco(sx, sy, -6.6, sx + 2.4, sy + 0.25, -5.1, 0xf4f1ea, { cast: false });
    hb.deco(sx + 0.2, sy + 0.25, -6.5, sx + 1.6, sy + 0.9, -5.2, sc, { cast: false });
  }
  const trainer = (x0: number, z0: number, color: number): void => {
    hb.solid(x0, 0, z0, x0 + 3, 1.1, z0 + 1.2, 'trainer');
    hb.deco(x0, 0, z0, x0 + 3, 0.3, z0 + 1.2, 0xf4f1ea);
    hb.deco(x0 + 0.1, 0.3, z0 + 0.05, x0 + 2.0, 1.1, z0 + 1.15, color);
    hb.deco(x0 + 2.0, 0.3, z0 + 0.1, x0 + 2.95, 0.7, z0 + 1.1, color);
    for (const lx of [1.9, 2.2, 2.5]) hb.deco(x0 + lx, 0.7, z0 + 0.3, x0 + lx + 0.1, 0.78, z0 + 0.9, 0xf4f1ea, { cast: false });
  };
  trainer(52, -6.6, 0x2f8f8a);
  trainer(52.4, -4.7, 0x2f8f8a);
  hb.round(68, 0, -5.6, 0.9, 5, 0x3e5c8a, { name: 'umbrella-stand' });
  for (const [ux, uz, uc] of [
    [67.7, -5.8, 0xd8443a],
    [68.3, -5.4, 0xf2c14e],
  ]) {
    hb.deco(ux - 0.4, 5, uz - 0.4, ux + 0.4, 9.2, uz + 0.4, uc, { shape: 'cone' });
    hb.deco(ux - 0.05, 9.2, uz - 0.05, ux + 0.05, 10.4, uz + 0.05, WALNUT_D);
  }

  // Console table, a stool and a box: floor → box 2.4 → stool 4.8 → console 7.6
  table(hb, -19.4, -7, -12.4, -4, 7.6, 0.6, 0.6, WALNUT, WALNUT_D, 'console-table');
  hb.box(-18.8, 7.6, -6.4, -17, 8.1, -4.6, 0x2f8f8a, { shape: 'bowl', name: 'key-bowl' });
  hb.deco(51.8, 8.0, 5.4, 52.3, 8.1, 5.9, BRASS, { cast: false });
  hb.round(-13.3, 7.6, -6.1, 0.6, 0.3, BRASS, { name: 'lamp-base' });
  hb.deco(-13.4, 7.9, -6.2, -13.2, 9.8, -6.0, BRASS, { shape: 'cyl6' });
  hb.deco(-14.5, 9.6, -7.3, -12.1, 11.2, -4.9, 0xfbe7b5, { shape: 'shade', emissive: 0xffc870 });
  hb.deco(-16.4, 7.6, -6.2, -14.8, 7.66, -5.0, 0xf4ecd6, { cast: false });
  chair(hb, -12.4, -7, -8.8, -4, 4.8, WALNUT, WALNUT_D, undefined, 'stool');
  cardboard(hb, -8.8, 0, -6.6, -6.6, 2.4, -4.2, 'box');

  // Grandfather clock: its brass pendulum swings out in front of the case. Stand on the
  // plinth (3), jump and catch it, ride it, let go at the end of a swing to be flung.
  hb.box(13, 0, -7, 19, 3, -3, WALNUT_D, { name: 'clock-plinth' });
  hb.box(13.5, 3, -7, 18.5, 14, -5, WALNUT, { name: 'clock-case', grab: false });
  hb.deco(14.2, 4, -5.03, 17.8, 13.2, -4.98, 0x2b2420, { cast: false });
  hb.box(12.5, 14, -7, 19.5, 21, -3.5, WALNUT_D, { name: 'clock-hood' });
  hb.deco(12.2, 21, -7, 19.8, 21.8, -3.2, WALNUT, { cast: false });
  hb.deco(15.2, 21.8, -6.2, 16.8, 23, -4.6, 0xc9a24a, { shape: 'ball' });
  const dial = hb.round(16, 17.5, -3.5, 2.5, 0.12, 0xf4ecd6, { cast: false }, false);
  dial.rotation.x = Math.PI / 2;
  dial.position.set(16, 17.5, -3.42);
  const ring = hb.round(16, 17.5, -3.5, 2.75, 0.1, 0xc9a24a, { cast: false }, false);
  ring.rotation.x = Math.PI / 2;
  ring.position.set(16, 17.5, -3.46);
  for (let h = 0; h < 12; h++) {
    const a = (h / 12) * Math.PI * 2;
    hb.deco(16 + Math.sin(a) * 2.1 - 0.1, 17.5 + Math.cos(a) * 2.1 - 0.1, -3.38, 16 + Math.sin(a) * 2.1 + 0.1, 17.5 + Math.cos(a) * 2.1 + 0.1, -3.34, 0x2b2420, { cast: false });
  }
  const hand = (len: number, angle: number, w: number): void => {
    const m = hb.deco(-w / 2, 0, -3.33, w / 2, len, -3.3, 0x1d1f24, { cast: false });
    m.geometry = m.geometry.clone().translate(0, 0.5, 0);
    m.position.set(16, 17.5, -3.31);
    m.rotation.z = -angle;
  };
  hand(1.4, 2.0, 0.16);
  hand(2.0, 5.2, 0.1);

  // Radiator and a plant at the west end.
  hb.box(-41, 0, -3, -40.2, 5, 3, 0xf4f1ea, { name: 'radiator' });
  for (let z = -2.6; z < 3; z += 0.6) hb.deco(-40.2, 0.4, z, -40.05, 4.6, z + 0.3, 0xe2ddd2, { cast: false });
  hb.box(-40.5, 0, 3.8, -38, 2.8, 6.5, 0xc0643a, { shape: 'bowl', name: 'plant-pot' });
  for (const [x, y, z, s] of [
    [-39.2, 3.2, 5.1, 2.4],
    [-39.8, 4.8, 4.6, 1.8],
    [-38.7, 5.0, 5.6, 1.8],
  ])
    hb.deco(x - s / 2, y, z - s / 2, x + s / 2, y + s * 1.2, z + s / 2, 0x4f8a3a, { shape: 'cone', rotY: x });

  // ---- Open doors, swung right back against the wall beside each doorway ----
  /** A door leaf standing flat against wall `w` from u0 to u1, hinged at one end; it hides with that wall. */
  const leaf = (w: Wall, u0: number, u1: number, hingeAtU0: boolean, plane: WallPlane): void => {
    const group = new THREE.Group();
    hb.root.add(group);
    hb.cutaway.push({ mesh: group, normal: new THREE.Vector3(), plane });
    const g: Wall = { ...w, group };
    wallSolid(hb, g, u0, u1, 0.1, DOOR_H - 0.4, 0, 0.58, 'door');
    wallBox(hb, g, u0, u1, 0.1, DOOR_H - 0.4, 0.22, 0.58, DOOR);
    for (const [y0, y1] of [
      [2, 8.5],
      [10.5, 18],
    ])
      wallBox(hb, g, u0 + 0.8, u1 - 0.8, y0, y1, 0.58, 0.66, 0xe8e0cf, { cast: false });
    const free = hingeAtU0 ? u1 - 0.8 : u0 + 0.8;
    wallBox(hb, g, free - 0.3, free + 0.3, 9.3, 9.9, 0.58, 0.95, BRASS);
    const hinge = hingeAtU0 ? u0 : u1;
    for (const y of [3, 16]) wallBox(hb, g, hinge - 0.15, hinge + 0.15, y, y + 1, 0.15, 0.62, BRASS, { cast: false });
  };
  const N: WallPlane = { axis: 'z', at: HALL.minZ - 0.5, u0: 0, u1: 0 };
  const S: WallPlane = { axis: 'z', at: HALL.maxZ + 0.5, u0: 0, u1: 0 };
  const hallN: Wall = { group: hb.root, along: 'x', face: HALL.minZ, inward: 1 };
  const hallS: Wall = { group: hb.root, along: 'x', face: HALL.maxZ, inward: -1 };
  leaf(hallN, -27.4, -19.6, true, { ...N, u0: -70, u1: -22 }); // kitchen
  leaf(hallN, -5.8, 1.4, false, { ...N, u0: -22, u1: 20 }); // dining room
  leaf(hallN, 22.2, 29.4, false, { ...N, u0: 20, u1: 70 }); // living room
  leaf(hallS, -31.8, -24.6, false, { ...S, u0: -41, u1: 1 }); // bathroom
  leaf(hallS, 10.1, 16.9, true, { ...S, u0: 1, u1: 47 }); // bedroom (west)
  leaf(hallS, 38.6, 46.4, true, { ...S, u0: 1, u1: 47 }); // bedroom (east)
  // Between rooms: kitchen → dining opens against the dining room's west wall,
  // dining → living room against the dining room's east wall.
  leaf({ group: hb.root, along: 'z', face: -21, inward: 1 }, -36.8, -31.6, false, { axis: 'x', at: -21.5, u0: -40, u1: -8 });
  leaf({ group: hb.root, along: 'z', face: 19, inward: -1 }, -26.4, -19.6, true, { axis: 'x', at: 19.5, u0: -40, u1: -8 });
  // Hallway → garage opens against the garage's front wall; bedroom → garage against the garage's west wall.
  leaf({ group: hb.root, along: 'x', face: 8, inward: 1 }, 54.1, 61.9, false, { axis: 'z', at: 7.5, u0: 47, u1: 93 });
  leaf({ group: hb.root, along: 'z', face: 48, inward: 1 }, 12.6, 16.4, false, { axis: 'x', at: 47.5, u0: 8, u1: 40 });

  // Garden lawn round the house and a path to the front door.
  hb.deco(-170, -1.4, -140, 180, -0.5, 140, 0x86b35e, { cast: false });
  hb.deco(71, -0.5, -4.5, 100, -0.42, 4.5, 0xcdbb9c, { cast: false });

  // The train track.
  hb.root.add(buildTrack(TRACK));

  root.add(hb.root);
  solids.push(...hb.solids);
  cutaway.push(...hb.cutaway);
  areas.push({ ...HALL });
  for (const p of [
    { x: -30, y: 0, z: 3 },
    { x: 20, y: 0, z: -4 },
    { x: 44, y: 0, z: 3 },
    { x: -15.5, y: 7.6, z: -5.6 },
    { x: 62, y: 3.2, z: -5.8 },
    { x: -10.6, y: 4.8, z: -5.5 },
    { x: 17.8, y: 3, z: -3.4 },
  ])
    itemSpawns.push({ ...p, kinds: ['screwdriver', 'wrench', 'pencil', 'paintbrush'] });

  return {
    id: 'house',
    name: 'Whole House',
    root,
    solids,
    spawns: duelSpawns(-30, -18, -2, 3),
    itemSpawns,
    itemKinds: [...kinds],
    bounds: { minX: -70, maxX: 93, minZ: -41, maxZ: 41 },
    ceiling: 15,
    cutaway,
    sky: 0xeedcbc,
    sunDir: new THREE.Vector3(-0.35, -0.75, 0.55).normalize(),
    areas,
    track: { ...TRACK },
    machines: [...machines, { kind: 'pendulum', style: 'clock', x: 16, y: 13.8, z: -4.4, yaw: 0, length: 8.9, swing: 0.4, period: 2.6 }],
    rollers,
    crates,
    pools,
    drips,
  };
}