/** * 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({ 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({ 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({ 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({ 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({ 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({ 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({ 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({ 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({ 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({ 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({ 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({ 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({ 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;