/**
 * The studio kit: twelve props the LA floor needs and the office catalogue did
 * not have.
 *
 * ### Why twelve *kinds* and not more instances
 *
 * This file exists because of one property of the layer above it. `furnish.ts`
 * batches props by **(asset, colorKey)** and every instance in a batch is
 * geometrically identical — `ctx.rand` is drawn once per batch, not once per
 * prop. The consequence is blunt and it decides what an asset library is for:
 * **adding ten more shelves to a room adds nothing the eye can read**, because
 * they are the same shelf with the same books on it in ten places. Only a new
 * *kind* adds density.
 *
 * So the answer to "the LA studio looks empty and the SF one does not" is not
 * more props, it is more shapes. Twelve of them, chosen by walking the four
 * chapters `mateo-court` already declares and asking what is conspicuously
 * missing from each:
 *
 *  - **Courtyard** — `planter.trough`, `bench.slat`, `canopy.parasol`
 *  - **Robotics Lab** — `bench.lab`, `rack.equipment`, `cart.tool`, `dock.robot`
 *  - **Model Loft** — `light.softbox`, `camera.tripod`, `case.stack`
 *  - **everywhere** — `acoustic.baffle`, `divider.slat`, `shelf.wall`
 *
 * The last two are aimed squarely at the two defects the live site shows most
 * often: blank untextured wall planes with no trim, and an arrival viewpoint
 * looking at the flat side of a corridor. A wall of acoustic panels and a timber
 * slat screen are both real studio fittings *and* the cheapest honest way to put
 * something on a wall that is currently nothing.
 *
 * ### Conventions
 *
 * Everything in here follows `common.ts`: origin on the floor at the centre of
 * the footprint, facing −Z at yaw zero, used from +Z, metres. Two assets are
 * wall-hung and carry their own `mount` height for the same reason `whiteboard`
 * does — how high a panel goes is a property of the panel, not of the room.
 *
 * And the rule that bites hardest is the same one: every part under one material
 * must be all-indexed or all-non-indexed, or `mergeGeometries` silently drops
 * the material and you get a bench with no top. Where a rounded edge is worth
 * having, the whole material is `roundedBoxOf`; everywhere else the whole
 * material is `box`/`cylinder`/`rod`.
 */

import * as THREE from "three";
import { defineAsset, type AssetContext, type AssetId } from "../kit.ts";
import type { SurfaceMaterial } from "../materials.ts";
import { MeshBin } from "../parts.ts";
import { clamp, jitter, panelSlab, slab, tintable } from "./common.ts";
import { leafBlade } from "./greenery.ts";

/**
 * One leg of a tripod: a bar running from a foot on the floor up to a hub on
 * the axis.
 *
 * Worth a helper because the placement maths is the one thing in this file that
 * is genuinely easy to get wrong, and getting it wrong is not obvious — you get
 * a stand whose legs are the right length and the wrong angle, and the only
 * symptom is a footprint that does not match the geometry.
 *
 * `Placement` rotates a part about its own **base**, and a part built on
 * `box()` runs along +Y from there. Under the `YXZ` euler the pitch is applied
 * before the yaw, so +Y goes to `(sinψ·sinθ, cosθ, cosψ·sinθ)`. To send the tip
 * from a foot at `(sinψ·R, 0, cosψ·R)` to the hub at `(0, hubY, 0)` the
 * direction has to be `(−sinψ·R, hubY, −cosψ·R)` over its own length, so
 * `θ = atan2(R, hubY)` and the yaw is **ψ + π** — the leg leans back over the
 * axis rather than away from it. The half-angle version of this, with the base
 * at the middle of the leg, is what produced a light stand a third wider than it
 * said it was.
 */
function tripodLeg(
  bin: MeshBin,
  ctx: AssetContext,
  material: SurfaceMaterial,
  leg: { index: number; count: number; radius: number; hubY: number; thickness: number; phase?: number },
): number {
  const yaw = (leg.index / leg.count) * Math.PI * 2 + (leg.phase ?? 0);
  const length = Math.hypot(leg.radius, leg.hubY);
  bin.add(ctx.parts.box(), material, {
    x: Math.sin(yaw) * leg.radius,
    z: Math.cos(yaw) * leg.radius,
    size: [leg.thickness, length, leg.thickness],
    yaw: yaw + Math.PI,
    pitch: Math.atan2(leg.radius, leg.hubY),
  });
  return yaw;
}

/**
 * Every id this file registers.
 *
 * Exported as data rather than left implicit because a pack author has to be
 * able to see the list without reading twelve builders, and because the test
 * that asserts they all build walks this rather than a hand-copied array that
 * would drift the first time somebody added a thirteenth.
 */
export const STUDIO_ASSET_IDS: readonly AssetId[] = [
  "tera:planter.trough",
  "tera:bench.slat",
  "tera:canopy.parasol",
  "tera:bench.lab",
  "tera:rack.equipment",
  "tera:cart.tool",
  "tera:dock.robot",
  "tera:case.stack",
  "tera:light.softbox",
  "tera:camera.tripod",
  "tera:acoustic.baffle",
  "tera:divider.slat",
  "tera:shelf.wall",
];

// ---- Courtyard ------------------------------------------------------------

type TroughParams = {
  length: number;
  depth: number;
  height: number;
  /** Shrubs along the run. Each is a small fan of arching blades. */
  clumps: number;
};

/**
 * A long planted trough — the LA courtyard's edge, and the thing that turns a
 * paved rectangle into a garden.
 *
 * The planting reuses `leafBlade` from `greenery.ts` rather than drawing its own
 * cards, so a trough and a corner plant are made of the same leaf with the same
 * cutout. Two files inventing foliage separately is how a library stops looking
 * like one library.
 *
 * The vessel is the tintable part: a pack that wants a terracotta courtyard and
 * a concrete lobby says so with a `colorKey` and changes nothing else.
 */
export const planterTrough = defineAsset<TroughParams>({
  id: "tera:planter.trough",
  label: "Planted trough",
  defaults: { length: 1.8, depth: 0.44, height: 0.46, clumps: 4 },

  footprint(p) {
    // The planting overhangs the vessel on every side, and it is the planting
    // somebody brushes past — so the footprint is the trough plus a leaf, not
    // the trough.
    return {
      width: p.length + 0.24,
      depth: p.depth + 0.24,
      height: p.height + 0.62,
      clearance: 0.4,
    };
  },

  build(p, ctx) {
    const P = ctx.parts;
    const bin = new MeshBin();
    const vessel = tintable(ctx, "planter");
    const soil = ctx.materials.get("cabinet");
    const leaf = ctx.materials.get("foliage");

    // `planter` is all `roundedBoxOf` here: a 12 mm round on the top edge of a
    // cast trough is what catches the sun along its whole length, and a sharp
    // arris on a 1.8 m object reads as a cardboard box.
    const wall = 0.05;
    bin.add(P.roundedBoxOf(p.length, p.height, p.depth, 0.012), vessel, { size: 1 });
    // The inner void, sunk in from the top, so the walls have thickness.
    bin.add(P.box(), soil, {
      y: p.height - 0.1,
      size: [p.length - wall * 2, 0.1, p.depth - wall * 2],
    });

    const clumps = Math.max(1, Math.round(p.clumps));
    const soilY = p.height - 0.012;
    for (let c = 0; c < clumps; c++) {
      const x = p.length * ((c + 0.5) / clumps - 0.5);
      const blades = 5;
      for (let i = 0; i < blades; i++) {
        const yaw = (i / blades) * Math.PI * 2 + c * 1.1;
        const length = 0.34 + ctx.rand() * 0.3;
        leafBlade(bin, ctx, leaf, {
          x: x + jitter(ctx.rand, 0.05),
          y: soilY,
          z: jitter(ctx.rand, 0.05),
          length,
          width: length * 0.34,
          yaw,
          pitch: 0.3 + (i / blades) * 0.7,
          droop: 0.55,
          roll: jitter(ctx.rand, 0.2),
          segments: 2,
        });
      }
    }

    return bin.build("planter.trough");
  },
});

type BenchParams = {
  length: number;
  depth: number;
  seatHeight: number;
  /** Slats across the seat. Odd numbers centre a gap, which looks deliberate. */
  slats: number;
  back: boolean;
};

/**
 * A slatted timber bench. Courtyard seating, and the lobby bench nobody sits on.
 *
 * The slats are separate boards with gaps between them rather than one board
 * with lines drawn on it, which matters more here than it sounds: a bench is
 * almost always seen from above and slightly to one side, and the gaps are what
 * put shadow stripes on the ground under it.
 */
export const benchSlat = defineAsset<BenchParams>({
  id: "tera:bench.slat",
  label: "Slat bench",
  defaults: { length: 1.6, depth: 0.44, seatHeight: 0.44, slats: 5, back: false },

  footprint(p) {
    const height = p.back ? p.seatHeight + 0.42 : p.seatHeight;
    return { width: p.length, depth: p.depth, height, clearance: 0.5 };
  },

  build(p, ctx) {
    const P = ctx.parts;
    const bin = new MeshBin();
    const frame = ctx.materials.get("metalTrim");
    const board = tintable(ctx, "shelf");

    // Two sled frames. A bench on four separate legs wobbles visually; the
    // continuous foot is what makes it read as one object.
    const legX = p.length / 2 - 0.14;
    for (const sx of [-1, 1]) {
      const x = sx * legX;
      bin.add(P.box(), frame, { x, size: [0.05, 0.04, p.depth - 0.04] });
      bin.add(P.box(), frame, { x, y: p.seatHeight - 0.06, size: [0.05, 0.05, p.depth - 0.08] });
      for (const sz of [-1, 1]) {
        bin.add(P.box(), frame, {
          x,
          z: (sz * (p.depth - 0.1)) / 2,
          size: [0.04, p.seatHeight - 0.06, 0.04],
        });
      }
    }
    bin.add(P.box(), frame, { y: 0.04, size: [legX * 2, 0.04, 0.04] });

    const slats = Math.max(2, Math.round(p.slats));
    const pitch = (p.depth - 0.06) / slats;
    for (let i = 0; i < slats; i++) {
      bin.add(P.box(), board, {
        y: p.seatHeight - 0.035,
        z: -(p.depth - 0.06) / 2 + pitch * (i + 0.5),
        size: [p.length, 0.035, pitch * 0.78],
      });
    }

    if (p.back) {
      const backSlats = Math.max(2, slats - 2);
      for (let i = 0; i < backSlats; i++) {
        bin.add(P.box(), board, {
          y: p.seatHeight + 0.06 + i * 0.1,
          z: -(p.depth / 2 - 0.06),
          size: [p.length, 0.07, 0.032],
          pitch: 0.14,
        });
      }
      for (const sx of [-1, 1]) {
        bin.add(P.box(), frame, {
          x: sx * legX,
          y: p.seatHeight - 0.02,
          z: -(p.depth / 2 - 0.06),
          size: [0.04, 0.44, 0.04],
          pitch: 0.14,
        });
      }
    }

    return bin.build("bench.slat");
  },
});

type ParasolParams = {
  /** Diameter of the open canopy. */
  spread: number;
  /** Floor to the tip of the finial. */
  height: number;
  /** Sides of the canopy. Eight is the usual market parasol. */
  panels: number;
};

/**
 * A courtyard parasol.
 *
 * The one prop in this file whose job is mostly to be *tall and soft*: an
 * outdoor space with nothing above waist height reads as a car park, and a
 * canopy at 2.3 m gives the courtyard camera something to frame under. The
 * canopy is a faceted cone, which is what a panelled parasol actually is, and
 * the ribs run down the seams so the facets read as construction rather than as
 * a low-polygon budget.
 *
 * The fabric is the tintable part.
 */
export const canopyParasol = defineAsset<ParasolParams>({
  id: "tera:canopy.parasol",
  label: "Courtyard parasol",
  defaults: { spread: 2.4, height: 2.42, panels: 8 },

  footprint(p) {
    return { width: p.spread, depth: p.spread, height: p.height, clearance: 0.3 };
  },

  build(p, ctx) {
    const P = ctx.parts;
    const bin = new MeshBin();
    const metal = ctx.materials.get("metalTrim");
    const fabric = tintable(ctx, "partitionFabric");
    const panels = Math.max(4, Math.round(p.panels));

    // A cast base, which is the only thing stopping this from looking like a
    // parasol pushed into a floor.
    bin.add(P.cylinder(20), ctx.materials.get("chairBase"), { size: [0.46, 0.05, 0.46] });
    bin.add(P.cylinder(20), ctx.materials.get("chairBase"), {
      y: 0.05,
      size: [0.34, 0.06, 0.34],
    });

    const canopyDrop = p.spread * 0.24;
    const skirtY = p.height - canopyDrop - 0.06;
    bin.add(P.rod(), metal, { y: 0.09, size: [0.05, skirtY - 0.09 + 0.1, 0.05] });

    // The canopy: a cone with its base at the skirt and its apex at the top.
    bin.add(P.cone(panels), fabric, {
      y: skirtY,
      size: [p.spread, canopyDrop, p.spread],
    });
    // Ribs along the seams. Each lies in the slant plane, so it is placed at the
    // mid-radius and pitched by the canopy's own slope.
    const slope = Math.atan2(canopyDrop, p.spread / 2);
    const ribLength = Math.hypot(canopyDrop, p.spread / 2);
    for (let i = 0; i < panels; i++) {
      const yaw = ((i + 0.5) / panels) * Math.PI * 2;
      bin.add(P.box(), metal, {
        x: (Math.sin(yaw) * p.spread) / 4,
        z: (Math.cos(yaw) * p.spread) / 4,
        y: skirtY + canopyDrop / 2 - 0.008,
        size: [0.018, 0.018, ribLength],
        yaw,
        // A bar laid along local +Z tips its far end downward under a *positive*
        // pitch (rotation about +X sends +Z to −Y), so the sign here is what
        // decides whether the canopy has ribs or antennae.
        pitch: slope,
      });
    }
    // The finial, so the pole does not simply stop.
    bin.add(P.sphere(10), metal, { y: p.height - 0.07, size: [0.07, 0.07, 0.07] });

    return bin.build("canopy.parasol");
  },
});

// ---- Robotics lab ---------------------------------------------------------

type LabBenchParams = {
  width: number;
  depth: number;
  height: number;
  /** The perforated tool wall behind the bench. */
  pegboard: boolean;
  drawers: number;
};

/**
 * A robotics workbench: heavy top, boxed frame, drawer bank, and a pegboard
 * tool wall behind it.
 *
 * The tool wall is what makes this a *lab* bench rather than a wide desk. It is
 * modelled as a board with real hanging tools on it — six bars and hooks at
 * seeded positions — because the alternative, a flat panel, is exactly the blank
 * grey plane the live LA studio is already full of.
 *
 * The tools use `ctx.rand`, which is drawn once per batch: every lab bench in a
 * room therefore has the *same* tools in the same places. That is the batching
 * price `furnish.ts` documents, it is paid knowingly, and the alternative is one
 * geometry per bench.
 */
export const benchLab = defineAsset<LabBenchParams>({
  id: "tera:bench.lab",
  label: "Lab workbench",
  defaults: { width: 1.9, depth: 0.78, height: 0.92, pegboard: true, drawers: 3 },

  footprint(p) {
    const height = p.pegboard ? p.height + 0.72 : p.height;
    return { width: p.width, depth: p.depth, height, clearance: 1 };
  },

  build(p, ctx) {
    const P = ctx.parts;
    const bin = new MeshBin();
    const frame = ctx.materials.get("deskFrame");
    const carcass = ctx.materials.get("cabinet");
    const top = ctx.materials.get("polishedConcrete");
    const metal = ctx.materials.get("metalTrim");

    const deckY = p.height - 0.05;
    slab(bin, ctx, top, { y: deckY, width: p.width, depth: p.depth, thickness: 0.05 });

    // A boxed frame rather than four legs. A bench that carries a robot arm has
    // visible structure under it, and the diagonal is most of what says so.
    const legX = p.width / 2 - 0.07;
    const legZ = p.depth / 2 - 0.07;
    for (const sx of [-1, 1]) {
      for (const sz of [-1, 1]) {
        bin.add(P.box(), frame, {
          x: sx * legX,
          z: sz * legZ,
          size: [0.06, deckY, 0.06],
        });
      }
      bin.add(P.box(), frame, { x: sx * legX, y: 0.12, size: [0.05, 0.05, legZ * 2] });
      // The diagonal, from the bottom of the back leg to the top of the front
      // one. Placed at its own base and pitched, exactly as `tripodLeg`
      // explains: a bar along +Y under pitch θ lands at
      // `(0, L cos θ, L sin θ)`, so θ and L follow from the two ends.
      const rise = deckY - 0.14;
      const run = legZ * 2;
      bin.add(P.box(), frame, {
        x: sx * legX,
        y: 0.14,
        z: -legZ,
        size: [0.036, Math.hypot(rise, run), 0.036],
        pitch: Math.atan2(run, rise),
      });
    }
    bin.add(P.box(), frame, { y: 0.12, z: -legZ, size: [legX * 2, 0.05, 0.05] });

    // The drawer bank, on the left half, fronts at +Z toward the person.
    const drawers = Math.max(1, Math.round(p.drawers));
    const bankW = Math.min(0.5, p.width * 0.3);
    const bankX = -p.width / 2 + bankW / 2 + 0.1;
    const bankH = deckY - 0.16;
    bin.add(P.box(), carcass, {
      x: bankX,
      y: 0.14,
      size: [bankW, bankH, p.depth - 0.14],
    });
    const front = tintable(ctx, "cabinet");
    const pitch = (bankH - 0.02) / drawers;
    for (let i = 0; i < drawers; i++) {
      const y = 0.15 + i * pitch;
      bin.add(P.box(), front, {
        x: bankX,
        y,
        z: (p.depth - 0.14) / 2,
        size: [bankW - 0.02, pitch - 0.01, 0.02],
      });
      bin.add(P.box(), metal, {
        x: bankX,
        y: y + pitch - 0.045,
        z: (p.depth - 0.14) / 2 + 0.012,
        size: [bankW * 0.5, 0.012, 0.012],
      });
    }

    if (!p.pegboard) return bin.build("bench.lab");

    // The tool wall. Its face is at −Z, against the wall the bench backs onto,
    // so its tools hang toward the person at +Z.
    const boardY = p.height;
    const boardH = 0.68;
    const boardZ = -(p.depth / 2 - 0.03);
    panelSlab(bin, ctx, carcass, {
      y: boardY,
      z: boardZ,
      width: p.width - 0.1,
      height: boardH,
      thickness: 0.018,
      faces: "front",
    });
    for (const sx of [-1, 1]) {
      bin.add(P.box(), metal, {
        x: (sx * (p.width - 0.1)) / 2,
        y: boardY,
        z: boardZ,
        size: [0.03, boardH, 0.03],
      });
    }
    // Hanging tools: a rail, then bars of varying length dropped off it.
    bin.add(P.box(), metal, {
      y: boardY + boardH * 0.62,
      z: boardZ + 0.02,
      size: [p.width - 0.2, 0.014, 0.014],
    });
    for (let i = 0; i < 8; i++) {
      const x = (p.width - 0.28) * ((i + 0.5) / 8 - 0.5);
      const drop = 0.09 + ctx.rand() * 0.16;
      bin.add(P.box(), metal, {
        x,
        y: boardY + boardH * 0.62 - drop,
        z: boardZ + 0.028,
        size: [0.018 + ctx.rand() * 0.03, drop, 0.016],
        roll: jitter(ctx.rand, 0.06),
      });
    }
    // A task light under the top shelf of the board.
    bin.add(P.box(), ctx.materials.get("lightHousing"), {
      y: boardY + boardH - 0.06,
      z: boardZ + 0.06,
      size: [p.width - 0.3, 0.05, 0.07],
    });

    return bin.build("bench.lab");
  },
});

type RackParams = {
  width: number;
  depth: number;
  height: number;
  /** Rack units of equipment showing through the door. */
  units: number;
};

/**
 * A 19-inch equipment rack.
 *
 * The vented door is the whole asset. `deviceMesh` is double-sided so the gaps
 * between the louvres show the dark inside of the cabinet and the equipment
 * faces behind them, and that depth is what separates a rack from a fridge. The
 * status LEDs are `accent` rather than `deviceIndicator`: they are decoration
 * here, and `deviceIndicator` means "a reading the device layer drives" — using
 * it for scenery would make a rack light up when somebody muted a microphone.
 */
export const rackEquipment = defineAsset<RackParams>({
  id: "tera:rack.equipment",
  label: "Equipment rack",
  defaults: { width: 0.62, depth: 0.9, height: 1.9, units: 7 },

  footprint(p) {
    return { width: p.width, depth: p.depth, height: p.height, clearance: 1 };
  },

  build(p, ctx) {
    const P = ctx.parts;
    const bin = new MeshBin();
    const carcass = ctx.materials.get("cabinet");
    const metal = ctx.materials.get("metalTrim");
    const louvre = ctx.materials.get("deviceMesh");
    const led = ctx.materials.get("accent");

    // Castors and plinth.
    for (const sx of [-1, 1]) {
      for (const sz of [-1, 1]) {
        bin.add(P.cylinder(10), metal, {
          x: sx * (p.width / 2 - 0.07),
          z: sz * (p.depth / 2 - 0.09),
          size: [0.06, 0.055, 0.06],
        });
      }
    }
    const plinth = 0.055;
    const bodyH = p.height - plinth;

    // Sides, back and top. The front is left open for the door.
    for (const sx of [-1, 1]) {
      bin.add(P.box(), carcass, {
        x: (sx * (p.width - 0.02)) / 2,
        y: plinth,
        size: [0.02, bodyH, p.depth],
      });
    }
    bin.add(P.box(), carcass, {
      y: plinth,
      z: -(p.depth / 2 - 0.01),
      size: [p.width, bodyH, 0.02],
    });
    bin.add(P.box(), carcass, {
      y: p.height - 0.02,
      size: [p.width, 0.02, p.depth],
    });

    // The equipment, recessed behind the door plane: faceplates with a handle
    // and a row of lights each.
    const uH = (bodyH - 0.14) / Math.max(1, Math.round(p.units));
    const units = Math.max(1, Math.round(p.units));
    for (let i = 0; i < units; i++) {
      const y = plinth + 0.07 + i * uH;
      bin.add(P.box(), ctx.materials.get("screenBezel"), {
        y,
        z: p.depth / 2 - 0.12,
        size: [p.width - 0.06, uH - 0.012, 0.05],
      });
      for (const sx of [-1, 1]) {
        bin.add(P.box(), metal, {
          x: sx * (p.width / 2 - 0.07),
          y: y + uH * 0.4,
          z: p.depth / 2 - 0.09,
          size: [0.03, uH * 0.5, 0.014],
        });
      }
      for (let k = 0; k < 4; k++) {
        bin.add(P.box(), led, {
          x: -p.width * 0.18 + k * 0.03,
          y: y + uH * 0.3,
          z: p.depth / 2 - 0.088,
          size: [0.012, 0.008, 0.006],
        });
      }
    }

    // The louvred door. Horizontal blades with gaps, plus a frame and a handle.
    const blades = 22;
    const bladePitch = (bodyH - 0.06) / blades;
    for (let i = 0; i < blades; i++) {
      bin.add(P.box(), louvre, {
        y: plinth + 0.03 + i * bladePitch,
        z: p.depth / 2 - 0.012,
        size: [p.width - 0.05, bladePitch * 0.55, 0.008],
        pitch: 0.4,
      });
    }
    for (const sx of [-1, 1]) {
      bin.add(P.box(), metal, {
        x: (sx * (p.width - 0.03)) / 2,
        y: plinth,
        z: p.depth / 2 - 0.012,
        size: [0.03, bodyH, 0.02],
      });
    }
    bin.add(P.box(), metal, {
      x: p.width / 2 - 0.06,
      y: p.height * 0.5,
      z: p.depth / 2 + 0.004,
      size: [0.02, 0.16, 0.03],
    });

    return bin.build("rack.equipment");
  },
});

type CartParams = {
  width: number;
  depth: number;
  height: number;
  drawers: number;
};

/** A rolling tool trolley. The drawer fronts are the tintable part. */
export const cartTool = defineAsset<CartParams>({
  id: "tera:cart.tool",
  label: "Tool cart",
  defaults: { width: 0.68, depth: 0.44, height: 0.94, drawers: 5 },

  footprint(p) {
    // The push handle stands above the worktop and the castors stand it off the
    // floor, so the overall height is neither `p.height` nor the cabinet's.
    return { width: p.width + 0.06, depth: p.depth + 0.06, height: p.height + 0.14, clearance: 0.7 };
  },

  build(p, ctx) {
    const P = ctx.parts;
    const bin = new MeshBin();
    const carcass = ctx.materials.get("cabinet");
    const metal = ctx.materials.get("metalTrim");
    const front = tintable(ctx, "cabinet");

    const lift = 0.085;
    for (const sx of [-1, 1]) {
      for (const sz of [-1, 1]) {
        bin.add(P.cylinder(10), metal, {
          x: sx * (p.width / 2 - 0.08),
          z: sz * (p.depth / 2 - 0.08),
          size: [0.07, lift, 0.03],
          yaw: sz > 0 ? 0 : 0.3,
        });
      }
    }

    const bodyH = p.height - lift - 0.03;
    bin.add(P.box(), carcass, { y: lift, size: [p.width, bodyH, p.depth] });
    // A rubber-topped worktop with a lip, which is what a tool cart's top is.
    bin.add(P.box(), ctx.materials.get("upholstery"), {
      y: lift + bodyH,
      size: [p.width + 0.02, 0.028, p.depth + 0.02],
    });
    bin.add(P.box(), metal, {
      y: lift + bodyH + 0.028,
      z: -(p.depth / 2),
      size: [p.width + 0.02, 0.022, 0.018],
    });

    const drawers = Math.max(1, Math.round(p.drawers));
    const pitch = (bodyH - 0.03) / drawers;
    for (let i = 0; i < drawers; i++) {
      const y = lift + 0.015 + i * pitch;
      bin.add(P.box(), front, {
        y,
        z: p.depth / 2,
        size: [p.width - 0.026, pitch - 0.012, 0.02],
      });
      bin.add(P.box(), metal, {
        y: y + pitch - 0.05,
        z: p.depth / 2 + 0.014,
        size: [p.width * 0.62, 0.016, 0.014],
      });
    }

    // The push handle, at the −Z end where the person pushing from behind is.
    bin.add(P.box(), metal, {
      y: lift + bodyH + 0.12,
      z: -(p.depth / 2 + 0.02),
      size: [p.width * 0.7, 0.022, 0.022],
    });
    for (const sx of [-1, 1]) {
      bin.add(P.box(), metal, {
        x: (sx * p.width * 0.7) / 2,
        y: lift + bodyH + 0.03,
        z: -(p.depth / 2 + 0.02),
        size: [0.022, 0.1, 0.022],
      });
    }

    return bin.build("cart.tool");
  },
});

type DockParams = {
  width: number;
  depth: number;
  /** Height of the backboard the robot parks against. */
  height: number;
};

/**
 * A robot charging dock: a marked floor pad, a backboard and a contact plate.
 *
 * This is the prop `optimus.ts` needs somewhere to *be* when it is not walking.
 * A humanoid standing in the middle of an empty floor reads as a mistake; the
 * same humanoid standing on a marked pad reads as a charging robot, and the
 * difference is a rectangle of paint and a plate at shoulder height.
 *
 * The pad's marking is the tintable part, so a pack can colour-code a row of
 * docks without touching the hardware.
 */
export const dockRobot = defineAsset<DockParams>({
  id: "tera:dock.robot",
  label: "Robot dock",
  defaults: { width: 0.9, depth: 0.7, height: 1.9 },

  footprint(p) {
    return { width: p.width, depth: p.depth + 0.1, height: p.height, clearance: 1.2 };
  },

  build(p, ctx) {
    const P = ctx.parts;
    const bin = new MeshBin();
    const metal = ctx.materials.get("metalTrim");
    const carcass = ctx.materials.get("cabinet");
    const marking = tintable(ctx, "carpetAccent");

    // The pad: a shallow tray with a painted field inside it. Two quads a
    // millimetre apart, so neither z-fights the floor it is laid on.
    bin.add(P.box(), carcass, { size: [p.width, 0.012, p.depth] });
    bin.add(P.metricQuad(p.width - 0.06, p.depth - 0.06), marking, { y: 0.0135 });
    for (const sz of [-1, 1]) {
      bin.add(P.box(), metal, {
        z: (sz * (p.depth - 0.03)) / 2,
        size: [p.width, 0.02, 0.03],
      });
    }

    // The backboard, at −Z, with the robot facing out of the dock at +Z.
    const boardZ = -(p.depth / 2 - 0.04);
    panelSlab(bin, ctx, carcass, {
      y: 0.012,
      z: boardZ,
      width: p.width * 0.72,
      height: p.height - 0.012,
      thickness: 0.06,
      faces: "front",
    });
    for (const sx of [-1, 1]) {
      bin.add(P.box(), metal, {
        x: (sx * p.width * 0.72) / 2,
        y: 0.012,
        z: boardZ,
        size: [0.04, p.height - 0.012, 0.08],
      });
    }

    // The contact plate at shoulder height, and the cable duct down to the pad.
    bin.add(P.box(), metal, {
      y: p.height * 0.62,
      z: boardZ + 0.05,
      size: [p.width * 0.4, 0.16, 0.05],
    });
    for (let i = 0; i < 3; i++) {
      bin.add(P.box(), ctx.materials.get("accent"), {
        x: -p.width * 0.1 + i * 0.1,
        y: p.height * 0.62 + 0.05,
        z: boardZ + 0.078,
        size: [0.05, 0.03, 0.008],
      });
    }
    bin.add(P.box(), carcass, {
      y: 0.012,
      z: boardZ + 0.05,
      size: [0.1, p.height * 0.62, 0.05],
    });

    return bin.build("dock.robot");
  },
});

type CaseStackParams = {
  width: number;
  depth: number;
  /** Cases in the stack, bottom to top. Each is a little shorter than the last. */
  cases: number;
};

/**
 * A stack of flight cases.
 *
 * Studios are full of these and nothing else in the library looks like one. What
 * makes a flight case a flight case is the corner armour and the recessed
 * latches — eight small parts per case, all in `metalTrim`, against a plain
 * tinted body. Without them it is a stack of boxes, which is precisely what the
 * asset would otherwise be.
 */
export const caseStack = defineAsset<CaseStackParams>({
  id: "tera:case.stack",
  label: "Case stack",
  defaults: { width: 0.78, depth: 0.56, cases: 3 },

  footprint(p) {
    const cases = Math.max(1, Math.round(p.cases));
    let height = 0;
    for (let i = 0; i < cases; i++) height += 0.34 - i * 0.05;
    // Each case is rotated by a few degrees of seeded jitter, so the stack's
    // plan is larger than any one case in it. 60 mm covers ±0.05 rad on the
    // longest edge plus the latches standing off the front face.
    return { width: p.width + 0.06, depth: p.depth + 0.06, height, clearance: 0.5 };
  },

  build(p, ctx) {
    const P = ctx.parts;
    const bin = new MeshBin();
    const body = tintable(ctx, "cabinet");
    const metal = ctx.materials.get("metalTrim");
    const cases = Math.max(1, Math.round(p.cases));

    let y = 0;
    for (let i = 0; i < cases; i++) {
      const h = 0.34 - i * 0.05;
      // Each case is a touch smaller than the one below and rotated slightly,
      // because nobody stacks road cases square.
      const shrink = i * 0.02;
      const w = p.width - shrink;
      const d = p.depth - shrink;
      const yaw = jitter(ctx.rand, 0.05);

      bin.add(P.box(), body, { y, size: [w, h, d], yaw });
      // Corner armour: eight L-shaped blocks, one per vertex, faked as a single
      // cube each. At the size a case occupies, an actual L is four times the
      // geometry for a shape nobody resolves.
      for (const sx of [-1, 1]) {
        for (const sz of [-1, 1]) {
          for (const sy of [0, 1]) {
            bin.add(P.box(), metal, {
              x: (sx * (w - 0.05)) / 2,
              y: y + sy * (h - 0.05),
              z: (sz * (d - 0.05)) / 2,
              size: [0.05, 0.05, 0.05],
              yaw,
            });
          }
        }
      }
      // Latches and a lid seam, on the used face at +Z.
      bin.add(P.box(), metal, { y: y + h * 0.6, size: [w, 0.012, d], yaw });
      for (const sx of [-1, 1]) {
        bin.add(P.box(), metal, {
          x: sx * w * 0.28,
          y: y + h * 0.6 - 0.02,
          z: (Math.cos(yaw) * d) / 2,
          size: [0.07, 0.05, 0.014],
          yaw,
        });
      }
      y += h;
    }

    return bin.build("case.stack");
  },
});

// ---- Model loft -----------------------------------------------------------

type SoftboxParams = {
  /** Floor to the centre of the box. */
  height: number;
  width: number;
  boxHeight: number;
  /** Radians the box tilts down toward the subject at +Z. */
  tilt: number;
};

/**
 * A studio softbox on a stand.
 *
 * It emits no light. Like every other luminaire in the library, the office's
 * lighting is a fixed rig owned by the scene (CONTRACT.md §4) and what a fitting
 * contributes is a glowing `lightDiffuser` you can see. `furnish.ts` recognises
 * it as a fitting from the `:light.` in its id, exactly as it does the pendant
 * and the troffer, so it brightens with the rest of the room's fittings and
 * costs nothing extra to wire.
 *
 * Its datum is the **floor**, not the ceiling — it is a stand, and `light.floor`
 * in `habitat.ts` set that precedent. The two ceiling fittings are the
 * exceptions, and they say so.
 */
export const lightSoftbox = defineAsset<SoftboxParams>({
  id: "tera:light.softbox",
  label: "Studio softbox",
  defaults: { height: 1.85, width: 0.9, boxHeight: 0.68, tilt: 0.32 },

  footprint(p) {
    // The tripod's spread is what somebody trips over, and it is wider than the
    // box at the heights a softbox is actually set to. `0.21` is the leg radius
    // used in `build`, so the diameter is twice it — the two have to agree, and
    // this is the whole of what they have to agree about.
    const spread = Math.max(p.width + 0.06, p.height * 0.42);
    return { width: spread, depth: spread, height: p.height + p.boxHeight / 2 };
  },

  build(p, ctx) {
    const P = ctx.parts;
    const body = new MeshBin();
    const glow = new MeshBin();
    const metal = ctx.materials.get("metalTrim");
    const housing = ctx.materials.get("lightHousing");

    // Three legs on a hub, which is a light stand. Four would be a table.
    const spread = p.height * 0.21;
    const hubY = p.height * 0.34;
    for (let i = 0; i < 3; i++) {
      tripodLeg(body, ctx, metal, {
        index: i,
        count: 3,
        radius: spread,
        hubY,
        thickness: 0.026,
      });
    }
    body.add(P.cylinder(12), metal, { y: hubY, size: [0.07, 0.05, 0.07] });
    body.add(P.rod(), metal, { y: hubY, size: [0.032, p.height - hubY, 0.032] });
    body.add(P.cylinder(12), metal, { y: p.height - 0.06, size: [0.06, 0.06, 0.06] });

    // The box: a shallow reflector behind a diffusion panel. Both are pitched,
    // and the diffuser stands a centimetre proud of the housing.
    const tilt = p.tilt;
    body.add(P.box(), housing, {
      y: p.height - p.boxHeight / 2,
      z: -0.06,
      size: [p.width, p.boxHeight, 0.16],
      pitch: tilt,
    });
    for (const sx of [-1, 1]) {
      body.add(P.box(), housing, {
        x: (sx * p.width) / 2,
        y: p.height - p.boxHeight / 2,
        z: 0.02,
        size: [0.02, p.boxHeight, 0.2],
        pitch: tilt,
        yaw: sx * 0.06,
      });
    }
    glow.add(P.panel(), ctx.materials.get("lightDiffuser"), {
      y: p.height - p.boxHeight / 2,
      z: 0.03,
      size: [p.width - 0.03, p.boxHeight - 0.03, 1],
      pitch: tilt,
    });

    const group = new THREE.Group();
    group.name = "light.softbox";
    group.add(body.build("light.softbox:body"));
    group.add(glow.build("light.softbox:glow", { castShadow: false, receiveShadow: false }));
    return group;
  },
});

type TripodParams = {
  /** Floor to the centre of the lens. */
  height: number;
  /** Include the little on-board monitor. */
  monitor: boolean;
};

/**
 * A cinema camera on sticks.
 *
 * Pointed along −Z like everything else in the library, which for a camera means
 * the lens looks the way the prop faces — the same convention a desk and a chair
 * follow, so a pack aims a camera the way it aims a person.
 *
 * The little on-board monitor uses `screenContent` and therefore picks up the
 * same `screenUI` drawing every other display in the building does. That is the
 * point of a shared role: a monitor on a camera and a monitor on a desk are the
 * same kind of object and should not look like two different ideas.
 */
export const cameraTripod = defineAsset<TripodParams>({
  id: "tera:camera.tripod",
  label: "Camera on tripod",
  defaults: { height: 1.52, monitor: true },

  footprint(p) {
    // Twice the leg radius used in `build`, plus the pan bar sticking out behind.
    const spread = p.height * 0.48;
    return { width: spread, depth: spread + 0.2, height: p.height + 0.24, clearance: 0.8 };
  },

  build(p, ctx) {
    const P = ctx.parts;
    const bin = new MeshBin();
    const metal = ctx.materials.get("metalTrim");
    const bodyMat = ctx.materials.get("screenBezel");

    const headY = p.height - 0.14;
    const radius = p.height * 0.24;
    for (let i = 0; i < 3; i++) {
      const yaw = tripodLeg(bin, ctx, metal, {
        index: i,
        count: 3,
        radius,
        hubY: headY,
        thickness: 0.034,
        phase: Math.PI,
      });
      // The spreader, a third of the way up, which is what stops a tripod
      // reading as three sticks that happen to meet. It sits on the leg line, so
      // its own radius is a third of the leg's.
      const at = radius * 0.66;
      bin.add(P.box(), metal, {
        x: Math.sin(yaw) * at,
        z: Math.cos(yaw) * at,
        y: headY * 0.32,
        size: [0.018, 0.018, at],
        yaw: yaw + Math.PI / 2,
      });
    }
    bin.add(P.cylinder(12), metal, { y: headY, size: [0.09, 0.07, 0.09] });

    // Fluid head and plate.
    const plateY = headY + 0.07;
    bin.add(P.box(), metal, { y: plateY, size: [0.11, 0.045, 0.19] });
    // The pan bar, sticking out behind at +Z where the operator is.
    bin.add(P.rod(), metal, {
      y: plateY + 0.02,
      z: 0.12,
      size: [0.016, 0.34, 0.016],
      roll: 0,
      pitch: 1.15,
    });

    // The body: a boxy cine camera, not a DSLR. Its mass is behind the lens.
    const bodyY = plateY + 0.045;
    bin.add(P.box(), bodyMat, { y: bodyY, z: 0.03, size: [0.15, 0.16, 0.28] });
    bin.add(P.box(), bodyMat, { y: bodyY + 0.16, z: 0.06, size: [0.1, 0.05, 0.16] });
    // The lens, on the −Z face, in three stepped barrels.
    const lensY = bodyY + 0.08;
    bin.add(P.cylinder(18), metal, {
      y: lensY,
      z: -0.13,
      size: [0.11, 0.06, 0.11],
      pitch: Math.PI / 2,
    });
    bin.add(P.cylinder(18), metal, {
      y: lensY,
      z: -0.19,
      size: [0.095, 0.1, 0.095],
      pitch: Math.PI / 2,
    });
    bin.add(P.disc(18), ctx.materials.get("glazing"), {
      y: lensY,
      z: -0.242,
      size: [0.082, 1, 0.082],
      pitch: -Math.PI / 2,
    });
    // Tally light, on the front where the subject can see it.
    bin.add(P.box(), ctx.materials.get("accent"), {
      y: bodyY + 0.15,
      z: -0.108,
      size: [0.03, 0.014, 0.01],
    });

    if (p.monitor) {
      bin.add(P.box(), bodyMat, {
        x: 0.11,
        y: bodyY + 0.09,
        z: 0.02,
        size: [0.11, 0.08, 0.014],
        yaw: -0.5,
      });
      bin.add(P.panel(), ctx.materials.variant("screenContent", 3), {
        x: 0.113,
        y: bodyY + 0.095,
        z: 0.026,
        size: [0.092, 0.062, 1],
        yaw: -0.5 + Math.PI,
      });
    }

    return bin.build("camera.tripod");
  },
});

// ---- Wall fittings --------------------------------------------------------

type BaffleParams = {
  /** Total width of the array. */
  width: number;
  /** Total height of the array. */
  height: number;
  /** Floor to the bottom edge. */
  mount: number;
  /** Panels across the array. */
  columns: number;
  rows: number;
};

/**
 * A wall of acoustic panels.
 *
 * The most direct answer in this file to a defect on the live site: "blank
 * white/grey wall planes everywhere, no material variation, no trim". A grid of
 * fabric panels standing 45 mm off the wall gives a flat plane a shadow under
 * every panel, a material that is not plaster, and — because the panels are
 * offset in depth by a seeded amount — a surface that changes as you walk past
 * it rather than one that is uniformly grey from every angle.
 *
 * Authored on the floor with a `mount` height, like `whiteboard`, and its −Z
 * face is skipped because it is against a wall.
 *
 * The fabric is the tintable part, which is the one thing a pack will want to
 * change per room.
 */
export const acousticBaffle = defineAsset<BaffleParams>({
  id: "tera:acoustic.baffle",
  label: "Acoustic panels",
  defaults: { width: 2.4, height: 1.2, mount: 0.9, columns: 4, rows: 2 },

  footprint(p) {
    return { width: p.width, depth: 0.07, height: p.mount + p.height };
  },

  build(p, ctx) {
    const P = ctx.parts;
    const bin = new MeshBin();
    const fabric = tintable(ctx, "partitionFabric");
    const frame = ctx.materials.get("metalTrim");

    const columns = Math.max(1, Math.round(p.columns));
    const rows = Math.max(1, Math.round(p.rows));
    const cellW = p.width / columns;
    const cellH = p.height / rows;
    const gap = Math.min(0.03, cellW * 0.08);

    for (let c = 0; c < columns; c++) {
      for (let r = 0; r < rows; r++) {
        // Seeded depth. 20 to 55 mm is the range real absorbers come in, and it
        // is enough that the array has relief without looking damaged.
        const depth = clamp(0.02 + ctx.rand() * 0.035, 0.02, 0.055);
        panelSlab(bin, ctx, fabric, {
          x: -p.width / 2 + cellW * (c + 0.5),
          y: p.mount + cellH * r + gap / 2,
          z: depth / 2,
          width: cellW - gap,
          height: cellH - gap,
          thickness: depth,
          faces: "front",
        });
      }
    }
    // A rail top and bottom, so the array is a fitting rather than panels stuck
    // to a wall.
    for (const sy of [0, 1]) {
      bin.add(P.box(), frame, {
        y: p.mount + sy * p.height - (sy === 0 ? 0.016 : 0),
        z: 0.012,
        size: [p.width, 0.016, 0.024],
      });
    }

    return bin.build("acoustic.baffle");
  },
});

type DividerParams = {
  width: number;
  height: number;
  /** Vertical slats across the width. */
  slats: number;
  /** Depth of each slat. Deeper slats close the view off at a shallower angle. */
  slatDepth: number;
};

/**
 * A vertical timber slat screen.
 *
 * The one asset in the library that is *different depending on where you stand*.
 * Head-on you see through it; at a glancing angle the slats overlap and it is a
 * wall. That is why it is worth its own kind rather than being a partition with
 * a different colour: it does something no other prop in the office does, and it
 * is the standard way an open-plan studio divides a room without building one.
 *
 * It is also the direct answer to the LA arrival viewpoint looking at a bare
 * corridor wall — a slat screen at the end of a corridor gives that view depth
 * and a reason to walk toward it.
 *
 * The timber is the tintable part.
 */
export const dividerSlat = defineAsset<DividerParams>({
  id: "tera:divider.slat",
  label: "Slat divider",
  defaults: { width: 2.2, height: 2.1, slats: 22, slatDepth: 0.09 },

  footprint(p) {
    return { width: p.width, depth: p.slatDepth + 0.04, height: p.height, clearance: 0.5 };
  },

  build(p, ctx) {
    const P = ctx.parts;
    const bin = new MeshBin();
    const timber = tintable(ctx, "shelf");
    const frame = ctx.materials.get("metalTrim");

    const rail = 0.05;
    for (const sy of [0, 1]) {
      bin.add(P.box(), frame, {
        y: sy * (p.height - rail),
        size: [p.width, rail, p.slatDepth + 0.02],
      });
    }
    for (const sx of [-1, 1]) {
      bin.add(P.box(), frame, {
        x: (sx * (p.width - 0.04)) / 2,
        size: [0.04, p.height, p.slatDepth + 0.02],
      });
    }

    const slats = Math.max(2, Math.round(p.slats));
    const pitch = (p.width - 0.12) / slats;
    for (let i = 0; i < slats; i++) {
      bin.add(P.box(), timber, {
        x: -(p.width - 0.12) / 2 + pitch * (i + 0.5),
        y: rail,
        size: [pitch * 0.5, p.height - rail * 2, p.slatDepth],
      });
    }

    return bin.build("divider.slat");
  },
});

type WallShelfParams = {
  width: number;
  depth: number;
  /** Floor to the underside of the lowest board. */
  mount: number;
  boards: number;
  /** Vertical gap between boards. */
  pitch: number;
};

/**
 * A wall-hung shelf, on visible brackets.
 *
 * This is the direct answer to a defect on the live site: "a wall shelf floats
 * with no visible bracket". The floor-standing `storage.shelf` has uprights that
 * read as support when it is on the floor and read as nothing when a pack raises
 * it up a wall with `Prop.elevation` — and because `furnish.ts` builds every
 * asset from its **defaults** and never passes a pack's parameters through, a
 * `brackets: true` option on the existing shelf would have been unreachable from
 * a pack. A wall shelf has to be its own kind or it cannot exist at all.
 *
 * The bracket is the whole point, so it is a real L: an arm under the board and
 * a plate against the wall, with the plate taller than the arm is long, which is
 * what makes it look like it is carrying the load rather than resting beside it.
 *
 * Authored on the floor with a `mount` height, like `whiteboard` and
 * `acoustic.baffle`, and its −Z side is against the wall.
 *
 * The boards are the tintable part.
 */
export const shelfWall = defineAsset<WallShelfParams>({
  id: "tera:shelf.wall",
  label: "Wall shelf",
  defaults: { width: 1.2, depth: 0.26, mount: 1.05, boards: 2, pitch: 0.38 },

  footprint(p) {
    // The top board carries books, and books are what somebody's head hits: the
    // stated height is the tallest thing on the shelf, not the shelf.
    const boards = Math.max(1, Math.round(p.boards));
    return {
      width: p.width,
      depth: p.depth,
      height: p.mount + (boards - 1) * p.pitch + 0.032 + 0.26,
      clearance: 0.5,
    };
  },

  build(p, ctx) {
    const P = ctx.parts;
    const bin = new MeshBin();
    const board = tintable(ctx, "shelf");
    const bracket = ctx.materials.get("metalTrim");
    const boards = Math.max(1, Math.round(p.boards));
    const bracketX = Math.max(0.12, p.width / 2 - 0.16);

    for (let i = 0; i < boards; i++) {
      const y = p.mount + i * p.pitch;
      // `shelf` is all `roundedBoxOf` here for the same reason the desktop is:
      // a 3 mm round is what gives a board a bright line along its front edge
      // instead of a hard colour change against the wall behind it.
      bin.add(P.roundedBoxOf(p.width, 0.032, p.depth, 0.003), board, { y });

      for (const sx of [-1, 1]) {
        const x = sx * bracketX;
        // The arm, under the board and stopping 30 mm short of its front edge —
        // a bracket flush with the front reads as a second, thinner board.
        bin.add(P.box(), bracket, {
          x,
          y: y - 0.026,
          z: 0.015,
          size: [0.022, 0.026, p.depth - 0.03],
        });
        // The wall plate, taller than the arm is long.
        bin.add(P.box(), bracket, {
          x,
          y: y - 0.16,
          z: -(p.depth / 2 - 0.012),
          size: [0.026, 0.19, 0.022],
        });
        // The gusset between them, which is the part that actually reads as an
        // L-bracket from below rather than as two separate bars.
        bin.add(P.box(), bracket, {
          x,
          y: y - 0.14,
          z: -(p.depth / 2 - 0.05),
          size: [0.018, 0.16, 0.014],
          pitch: 0.62,
        });
      }
    }

    // A few things on the top board, so a shelf reads as storage rather than as
    // a ledge. Three materials that already exist in the library, and seeded, so
    // every wall shelf in one batch carries the same objects — the price
    // `furnish.ts` documents.
    const topY = p.mount + (boards - 1) * p.pitch + 0.032;
    const spines = [
      ctx.materials.get("paper"),
      ctx.materials.get("accent"),
      ctx.materials.get("cabinet"),
    ];
    let x = -p.width / 2 + 0.06;
    while (x < p.width / 2 - 0.1) {
      if (ctx.rand() < 0.22) {
        x += 0.05 + ctx.rand() * 0.1;
        continue;
      }
      const w = 0.02 + ctx.rand() * 0.036;
      const h = 0.16 + ctx.rand() * 0.09;
      const material = spines[Math.floor(ctx.rand() * spines.length)] ?? spines[0];
      if (!material) break;
      bin.add(P.box(), material, {
        x: x + w / 2,
        y: topY,
        z: 0.01 + jitter(ctx.rand, 0.012),
        size: [w, h, clamp(p.depth * 0.62, 0.1, 0.2)],
        roll: jitter(ctx.rand, 0.035),
      });
      x += w + 0.004;
    }

    return bin.build("shelf.wall");
  },
});

/** Every studio asset, in the order `STUDIO_ASSET_IDS` names them. */
export const STUDIO_ASSETS = [
  planterTrough,
  benchSlat,
  canopyParasol,
  benchLab,
  rackEquipment,
  cartTool,
  dockRobot,
  caseStack,
  lightSoftbox,
  cameraTripod,
  acousticBaffle,
  dividerSlat,
  shelfWall,
] as const;
