/** * The conventions every asset in this directory agrees on, and the three * helpers that would otherwise be copied into seventeen files. * * ### Which way an asset faces * * An asset is built in its own frame with the **origin at the centre of its * footprint, on the floor**, and at yaw zero it faces **−Z** — the same sense as * `Yaw` in `interiors/types.ts`, which is `object.rotation.y` with no * conversion. "Faces −Z" here means what it means for a person: the direction * the thing is pointed, not the side you see. A desk, the chair pulled up to it * and the person in the chair therefore all carry **one rotation**, which is * what lets `DeskBank` hand the same `rotation` to its desk and its chair. * * The consequence, and it is worth stating because it is the opposite of what * you might guess: the *used* side of an asset is at **+Z**, because that is * where the user is. Drawer fronts, the open front of a shelf, the face of a * monitor and the writing side of a whiteboard all point +Z, and the back of a * thing that stands against a wall is at −Z. * * ### Two things that are not on the floor * * `light.pendant` and `light.troffer` hang from a ceiling, and a ceiling is * their datum the way the floor is everything else's. They are authored with * the origin at the **mounting plane** and their geometry below it, `y ≤ 0`, so * a pack writes `elevation: 2.9` and gets a lamp hanging at 2.9 m rather than a * lamp whose author had to know the ceiling height. `footprint().height` is the * total drop. Nothing else in the library does this. * * Anything that stands on a desk or hangs on a wall — a monitor, a wall display, * a whiteboard — is still authored on the floor. The monitor's foot sits at * `y = 0` and the pack raises it with `Prop.elevation`; the wall-mounted things * carry their own `mount` parameter, because the height of a whiteboard is a * property of the whiteboard and not of the room. * * ### One rule that will bite you * * Every part an asset puts under a given material must be **either all indexed * or all non-indexed**. `mergeGeometries` refuses a mixture, `MeshBin` treats * the refusal as "skip this material", and the result is not an error but a * chair with no shell on it — which is a lot harder to notice than a crash. * * In practice: `roundedBox` is an `ExtrudeGeometry` and carries no index, while * every other part in `parts.ts` does. So a material is a rounded material or a * boxy one, and where that forces a choice the honest fix is to move the part * to the material it belongs to anyway — a task chair's arm pads are upholstery * as readily as they are shell. * * ### Light fixtures emit no light * * A luminaire here is geometry with a glowing diffuser and nothing else. The * office's lighting is a fixed rig owned by the scene (CONTRACT.md §4); a * hundred props each adding a `PointLight` is both the wrong owner and, at four * shadow-casting lights, the end of the frame budget. */ import { tintFor, type AssetContext } from "../kit.ts"; import type { SurfaceMaterial, SurfaceRole } from "../materials.ts"; import type { MeshBin } from "../parts.ts"; /** * The material for the one part of an asset that answers to `Prop.colorKey` — * a chair's fabric, a locker's doors, a rug's pile. Every asset names its * tintable role in its own comment; there is at most one per asset, because * "the blue meeting room" wants one thing to be blue and not six. * * Falling back to the shared role material rather than `tinted(role, * palette[role])` is not a micro-optimisation: an identical-but-distinct * material is a second merge bucket and a second draw call on every instance, * for a colour nobody can tell apart from the one next to it. */ export function tintable(ctx: AssetContext, role: SurfaceRole): SurfaceMaterial { const color = tintFor(ctx, role); return color === ctx.palette[role] ? ctx.materials.get(role) : ctx.materials.tinted(role, color); } /** * A horizontal slab — a desktop, a tabletop, a shelf board — with the grain the * right size on the face you actually look at. * * The body is a scaled unit box, whose 0..1 UVs stretch; the top face is a * `metricQuad`, whose UVs are in metres. Without the second part a 1.6 m desk * and a 2.4 m table would each show exactly one repeat of the wood and read as * two different materials (see the UV note in `parts.ts`). The quad sits 0.6 mm * proud of the box so the two never z-fight. * * `y` is the underside of the slab. */ export function slab( bin: MeshBin, ctx: AssetContext, material: SurfaceMaterial, s: { x?: number; y: number; z?: number; width: number; depth: number; thickness: number }, ): void { const x = s.x ?? 0; const z = s.z ?? 0; bin.add(ctx.parts.box(), material, { x, y: s.y, z, size: [s.width, s.thickness, s.depth], }); bin.add(ctx.parts.metricQuad(s.width, s.depth), material, { x, y: s.y + s.thickness + 0.0006, z, }); } /** * A standing panel — a partition, a modesty panel, a board — with metric UVs on * the faces. * * The only trick is how a floor-plane `metricQuad` is stood up: pitching it by * +π/2 sends its up-normal to +Z and its depth extent to Y, which gives a * vertical rectangle whose UVs are still in metres. `panel()` would have been * shorter and would have smeared one tile of felt across a 1.4 m screen. * * `y` is the bottom edge; the panel is centred on `z`. */ export function panelSlab( bin: MeshBin, ctx: AssetContext, material: SurfaceMaterial, s: { x?: number; y: number; z?: number; width: number; height: number; thickness: number; /** `"front"` skips the −Z face, for a panel hung flat against a wall. */ faces?: "both" | "front"; }, ): void { const x = s.x ?? 0; const z = s.z ?? 0; bin.add(ctx.parts.box(), material, { x, y: s.y, z, size: [s.width, s.height, s.thickness] }); const face = ctx.parts.metricQuad(s.width, s.height); const yMid = s.y + s.height / 2; bin.add(face, material, { x, y: yMid, z: z + s.thickness / 2 + 0.0006, pitch: Math.PI / 2, }); if (s.faces !== "front") { bin.add(face, material, { x, y: yMid, z: z - s.thickness / 2 - 0.0006, pitch: -Math.PI / 2, }); } } /** * Where a point `d` in front of a pitched part ends up. * * `Placement` applies its offset in the parent frame and its rotation about the * part's own base, so gluing a screen to a tilted bezel by writing `z: 0.013` * leaves the screen poking through the top of the bezel by `height × sin(tilt)`. * Rotating the offset first is the fix, and it is small enough that doing it by * hand twice would have been two chances to get the sign wrong. */ export function alongFacing(pitch: number, d: number): { y: number; z: number } { return { y: -d * Math.sin(pitch), z: d * Math.cos(pitch) }; } /** Symmetric jitter of ±`amount`, for the small deliberate untidiness. */ export function jitter(rand: () => number, amount: number): number { return (rand() - 0.5) * 2 * amount; } export function clamp(v: number, lo: number, hi: number): number { return v < lo ? lo : v > hi ? hi : v; }