db074e9cf7
The build the studios needed, across eight workstreams and one strict file partition. **The render rig was the quality ceiling.** The renderer ran three's NoToneMapping default while atmosphere drove the sun to 2.35 and assets set emissives to 3.2, so every value above 1.0 hard-clipped to flat white — which is why walls blew out and every fitting looked like a white rectangle. ACES filmic tone mapping and an explicit output colour space land in `stage.ts`, and the atmosphere intensity table and palette headroom are re-tuned against the new curve rather than left tuned for the clipping we removed. `engine/environmentRig.ts` builds a PMREM environment at runtime, procedurally, so nothing binary is committed. There was no environment map anywhere before, so every `metalness > 0` role had nothing to reflect and rendered dull grey — a defect the code already documented against itself in `office/optimus.ts`, where a whole material role was abandoned over it, and worked around in `modelX.ts` with a fake emissive that this change deletes. Atmosphere remains the sole light owner; the rig derives from the `LightingState` it already produced. **Studio hardware exists.** There was no device concept anywhere in the product: no type, no route, no state. `devices/types.ts` fixes a declaration/state/ capability/command contract that a smart light, a thermostat, a door sensor and a charger all fit without a schema change, and both studios now carry a desk mic and a computer speaker with deterministic simulated behaviour behind an adapter seam a real API can occupy later. Reads are the demo and are open; commands are a signed-in action and are kept off the read body entirely, because a shared cache replaying a GET that turned a microphone on is exactly what the fail-closed cache default exists to prevent. **The ADS-B licence hole is closed.** `TERA_ADSB_ENDPOINT` accepted any URL, the response was served publicly cacheable, and the attribution hardcoded adsb.lol regardless of where the endpoint pointed — one env var away from republishing non-redistributable data under an open-terms credit. The host is now allowlisted, the credit is derived from the host actually configured, public cacheability is conditional on redistributability, and a refused endpoint demotes to simulated flights and says so in `degraded[]`. The gate is on the source, not the feature: live aircraft and their detail cards stay open to anonymous visitors. **The LA studio was never the smaller pack** — 16 rooms and 248 props against SF's 4 and 28. Its deficit was fidelity per square metre: 98 of those props were ceiling troffers, it bound no props to seats, placed none of the habitat kit, and 12 of its 16 rooms had no viewpoint. Density comes from new asset kinds rather than more instances, because `furnish.ts` draws once per kind and folds colour into the batch key, so repeat instances add nothing the eye can read. **The interface stops being forty imperative mutations.** Every visibility decision moves into a pure, tested `ui/chromeState.ts` and one applier, so the chrome has coverage for the first time. Deleted: ~100 lines of CSS and two bindings targeting elements that no longer exist, and a `body:has()` rule that shifted the desktop layout by 160px for touch controls hidden there. Fixed: the office picker tabs that drew their label and their badge on top of each other. Added: a first-run flow, because the product is two verbs and neither was ever stated on screen. Mobile is designed on its own terms instead of being the desktop with things hidden — the plan view comes back, and the keyboard-only shortcuts button is replaced by touch controls. `arena/studioOps.ts` frames the whole thing as the multi-variable environment it is, wrapping the same simulators the renderer drives rather than a headless copy. Also removed `input/vehicle.ts`, which nothing but its own test imported. Tests 385 -> 961, all passing. Typecheck, build, performance budgets across six matrix cells, no-binaries, provenance, dependency licences, zero-config boot and arena source hashes all green. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
1407 lines
46 KiB
TypeScript
1407 lines
46 KiB
TypeScript
/**
|
||
* 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;
|