/** * The building itself: walls, floor slabs, ceilings, and the frames and glazing * that line the holes in the walls. * * Everything here comes out of a `Plan` and nothing here reads an `Office`. The * wall pass has already happened — a run is a solid piece of wall with its * openings taken out of it, and its numbers are already in office-world metres * with the level's elevation baked in — so this file is the arithmetic-free half * of the job: place a `wallRun` part per run, triangulate a polygon per room, * and merge. * * ### One mesh per wall, and why not fewer * * Merging every wall on a floor into one buffer would be one draw call instead * of forty, and it is the wrong trade. The occlusion fade — the walls between * the camera and what you are looking at going translucent, so the floorplan * stays readable from outside — swaps a *material* on a whole object, and an * object has to be one wall for that to mean anything. Forty extra draw calls is * a rounding error next to the ~1,200 objects `parts.ts` was written to * collapse; losing the ability to fade one wall is not. * * Each wall mesh therefore carries its 2-D segment and its top height in * `userData.wall`, which is everything the fade needs to decide without walking * geometry, and `setGhosted` is the swap. See CONTRACT.md §3, which is where * `ghostOf()` landed on the material registry for exactly this. * * ### Ceilings are a group, not a clip plane * * Orbit mode hides them wholesale (`shell.ceilings.visible = false`) and that is * the entire mechanism. No CSG, no clipping planes, no per-camera cutaway: a * dollhouse is a room with its lid off, and a lid is a thing you can take off. * * ### Stairs are built from the record a walker climbs * * `ASSET_RESEARCH.md` listed a stair as the catalogue's one missing piece, and * `mateo-court` shipped without one: a `STEEL` floor finish the shape of the * flight, no treads, with the comment "the kit has no stair asset". The reason * it belongs *here* rather than in `src/assets/office/` is the interesting part. * A prop is placed by a coordinate somebody typed; a flight of stairs has to * agree with the two footprints, the two floor heights and the dog-leg the walk * controller actually traverses, and every one of those is already in the * resolved `Transition`. Building the treads from that record rather than beside * it makes the failure `src/offices/README.md` used to warn about — * a staircase nobody can climb — structurally inexpressible: the drawn flight * and the walked one are the same list of points. */ import * as THREE from "three"; import type { MaterialRegistry, SurfaceRole } from "../assets/materials.ts"; import { MeshBin, parts as sharedParts, type PartBin } from "../assets/parts.ts"; import { TEXTURE_TILE_METRES } from "../assets/textures.ts"; import type { LevelPlan, Plan, ResolvedOpening, ResolvedRoom, ResolvedTransition, WallRun, } from "./plan.ts"; import type { Outline, Point2 } from "./types.ts"; /** Jamb and head width on an opening's lining, in metres. */ const FRAME_WIDTH = 0.045; /** How far a lining stands proud of its wall on each face, so it reads as a reveal. */ const FRAME_PROUD = 0.008; /** Depth of a window's sill board past the wall face, per side. */ const SILL_PROUD = 0.03; /** * The riser height a flight is divided into, in metres. * * 0.178 m is the middle of a commercial stair and is what makes the step count * come out right without a pack ever stating one: `mateo-court`'s 2.5 m flight * lands on fourteen risers, which is the number its own comment already claimed. * The flight is divided into a whole number of equal risers, never into * 0.178 m ones with a short step at the top — an uneven riser is the single most * reliable way to make a staircase read as wrong, and it is also how people fall * down real ones. */ const TARGET_RISER_M = 0.178; /** Bounds on the division, so an absurd `elevation` cannot emit ten thousand boxes. */ const MIN_FLIGHT_STEPS = 2; const MAX_FLIGHT_STEPS = 40; /** Tread slab and riser board thickness, in metres. */ const TREAD_THICKNESS = 0.055; const RISER_THICKNESS = 0.03; export interface ShellOptions { materials: MaterialRegistry; /** Defaults to the shared bin, which is what everything else uses. */ parts?: PartBin; /** Which levels to build. Defaults to every level in the plan. */ levelIds?: readonly string[]; /** Line the openings with frames and glaze the windows. Defaults to true. */ openings?: boolean; } /** * What a wall mesh knows about itself, stamped on `userData.wall`. * * The segment is the wall's centreline in plan, which is what an occlusion test * wants: a camera-to-target ray crossing this line is looking through this wall. * `top` is there so a knee-high partition is never faded — you can see over it, * so it is not in the way. */ export interface WallInfo { wallId: string; levelId: string; from: Point2; to: Point2; /** Office-world metres. */ bottom: number; top: number; role: SurfaceRole; } export interface Shell { /** Everything below, as one object to add to a scene. */ group: THREE.Group; walls: THREE.Group; floors: THREE.Group; /** Hide this to get the dollhouse. */ ceilings: THREE.Group; /** * Treads, risers and half landings, one merged mesh per surface. * * Separate from `floors` because a stair is not a slab and is not hidden with * a lid, and separate from `walls` because it must never be ghosted: fading * out the way upstairs when the camera happens to be behind it is worse than * seeing through it. */ stairs: THREE.Group; /** Frames and glazing. Separate because glass must not cast a shadow. */ openings: THREE.Group; /** Every wall mesh, each carrying a `WallInfo` on `userData.wall`. */ wallMeshes: readonly THREE.Mesh[]; /** Swap one wall between its own finish and the translucent copy of it. */ setGhosted(mesh: THREE.Mesh, ghosted: boolean): void; dispose(): void; } /** * How far a slab is lifted per earlier slab it overlaps. See `liftOf`. * * 4 mm. Big enough to beat the depth buffer's resolution at office range — the * near plane is 0.2 m and the camera orbits within about a hundred metres, so a * 24-bit buffer resolves far finer than this — and small enough that a step * between two floor finishes is not a step anybody can see or trip over. */ const SLAB_LIFT = 0.004; /** Do two outlines' axis-aligned bounding boxes intersect? See `liftOf`. */ function boxesOverlap(a: readonly Point2[], b: readonly Point2[]): boolean { const box = (points: readonly Point2[]) => { let minX = Infinity; let maxX = -Infinity; let minZ = Infinity; let maxZ = -Infinity; for (const p of points) { minX = Math.min(minX, p.x); maxX = Math.max(maxX, p.x); minZ = Math.min(minZ, p.z); maxZ = Math.max(maxZ, p.z); } return { minX, maxX, minZ, maxZ }; }; const one = box(a); const two = box(b); // Touching edge-to-edge is not overlapping: the reference office's rooms abut // along shared lines everywhere and must not all be lifted for it. return one.minX < two.maxX && two.minX < one.maxX && one.minZ < two.maxZ && two.minZ < one.maxZ; } export function createShell(plan: Plan, options: ShellOptions): Shell { const { materials } = options; const parts = options.parts ?? sharedParts; const drawOpenings = options.openings ?? true; const group = new THREE.Group(); group.name = "shell"; const walls = new THREE.Group(); walls.name = "walls"; const floors = new THREE.Group(); floors.name = "floors"; const ceilings = new THREE.Group(); ceilings.name = "ceilings"; const openings = new THREE.Group(); openings.name = "openings"; const stairs = new THREE.Group(); stairs.name = "stairs"; group.add(walls, floors, ceilings, openings, stairs); const wallMeshes: THREE.Mesh[] = []; // Every geometry this file makes is a merge or a triangulation it owns // outright, so disposal is a list rather than a traversal. The materials // belong to the registry and are emphatically not ours to dispose. const owned: THREE.BufferGeometry[] = []; const levels = options.levelIds ? options.levelIds.map((id) => plan.level(id)).filter((l): l is LevelPlan => l !== null) : plan.levels; // Frames and glazing are merged across the whole shell rather than per level: // nothing ever fades or hides one on its own, so there is no reason to pay for // the addressability. const frameBin = new MeshBin(); const glassBin = new MeshBin(); for (const level of levels) { const holesByWall = groupBy(level.openings, (o) => o.wallId); for (const [wallId, runs] of groupBy(level.runs, (r) => r.wallId)) { buildWall(level.id, wallId, runs, holesByWall.get(wallId) ?? []); } for (const room of level.rooms) { buildFloor(room, liftOf(room, level.rooms)); buildCeiling(room); } if (drawOpenings) { for (const opening of level.openings) lineOpening(opening); } } // A transition is built with its lower storey, so a shell restricted to one // level draws the flight rising out of it rather than nothing at all. const built = new Set(levels.map((level) => level.id)); for (const transition of plan.transitions) { if (transition.kind !== "stair") continue; if (!built.has(transition.lower.levelId)) continue; buildStair(transition); } if (drawOpenings) { for (const mesh of frameBin.build("openings").children) openings.add(mesh); // Glass casts no shadow and receives none. A shadow-casting pane makes a // window read as a solid panel, which is the one thing a window must not do. for (const mesh of glassBin .build("glazing", { castShadow: false, receiveShadow: false }) .children) { // Drawn after the opaque shell, since the material writes no depth and // cannot sort itself against the room behind it. mesh.renderOrder = 1; openings.add(mesh); } for (const mesh of openings.children) { const geo = (mesh as THREE.Mesh).geometry; if (geo) owned.push(geo); } } /** * One flight — or one dog-leg — as treads, risers and half landings. * * The path is the resolved transition's own: the same points the crossing in * `officeWalker` interpolates over, in office-world metres with both floor * heights already in them. A leg that climbs is a flight; a leg that does not * is a landing, and gets one slab. * * The **top tread of every flight is not drawn**, and that is the one detail * worth knowing. A flight always arrives at something that already has a * surface — a half landing, or the floor of the storey above — and drawing a * tread there puts two coplanar slabs at the same height, which is a z-fight * and which storey wins is the GPU's business. So a flight of fourteen risers * draws fourteen riser boards and thirteen treads, and the fourteenth surface * is the thing it lands on. That is also what a real stair is. */ function buildStair(transition: ResolvedTransition): void { const material = materials.forSurface(transition.surface, "plaster"); const bin = new MeshBin(); let drew = false; for (let index = 1; index < transition.path.length; index += 1) { const from = transition.path[index - 1]!; const to = transition.path[index]!; const dx = to.x - from.x; const dz = to.z - from.z; const run = Math.hypot(dx, dz); if (run < 1e-4) continue; const ux = dx / run; const uz = dz / run; // The same convention `splitWall` uses: a part's local +X at yaw φ points // along (cos φ, −sin φ). const yaw = Math.atan2(-uz, ux) + 0; const rise = to.y - from.y; if (rise <= 1e-4) { // A half landing. One slab, the width of the flight, spanning the leg. bin.box(material, { x: from.x + ux * (run / 2), y: from.y - TREAD_THICKNESS, z: from.z + uz * (run / 2), yaw, size: [run + transition.width, TREAD_THICKNESS, transition.width], }); drew = true; continue; } const steps = Math.min( MAX_FLIGHT_STEPS, Math.max(MIN_FLIGHT_STEPS, Math.round(rise / TARGET_RISER_M)), ); const riser = rise / steps; const going = run / steps; for (let step = 0; step < steps; step += 1) { const foot = step * going; // The vertical face, at the leading edge of the step it climbs to. bin.box(material, { x: from.x + ux * foot, y: from.y + riser * step, z: from.z + uz * foot, yaw, size: [RISER_THICKNESS, riser, transition.width], }); // The tread. The last one is the landing above, which already exists. if (step === steps - 1) continue; bin.box(material, { x: from.x + ux * (foot + going / 2), y: from.y + riser * (step + 1) - TREAD_THICKNESS, z: from.z + uz * (foot + going / 2), yaw, size: [going, TREAD_THICKNESS, transition.width], }); } drew = true; } if (!drew) return; for (const child of bin.build(`stair:${transition.id}`).children) { const mesh = child as THREE.Mesh; mesh.userData.transitionId = transition.id; owned.push(mesh.geometry); stairs.add(mesh); } } function buildWall( levelId: string, wallId: string, runs: WallRun[], holes: readonly ResolvedOpening[], ): void { const first = runs[0]; if (!first) return; // Every run of a wall carries the same surface — it is resolved from the // wall, or from the level, and never per run — so a wall is one material and // therefore one mesh. The loop below still handles a group of them, because // a `Shell` that silently drew three quarters of a wall would be worse than // one that drew an unexpected extra mesh. const role = materials.resolve(first.surface, "plaster"); const material = materials.get(role); const bin = new MeshBin(); let bottom = Infinity; let top = -Infinity; for (const run of runs) { const height = run.top - run.bottom; if (height <= 0) continue; bin.add(parts.wallRun(run.length, height, run.thickness), material, { x: run.center.x, y: run.bottom, z: run.center.z, yaw: run.yaw, }); bottom = Math.min(bottom, run.bottom); top = Math.max(top, run.top); } if (!Number.isFinite(top)) return; const info: WallInfo = { wallId, levelId, ...extentOf([...runs, ...holes]), bottom, top, role, }; for (const child of [...bin.build(`wall:${wallId}`).children]) { const mesh = child as THREE.Mesh; mesh.userData.wall = info; owned.push(mesh.geometry); wallMeshes.push(mesh); walls.add(mesh); } } /** * How far to lift a slab so it does not fight the ones it overlaps. * * The format explicitly permits overlapping rooms and resolves *later* ones * first (`types.ts` on `Room.outline`, `Plan.roomAt`), so "a slab on top of * another slab" is legal and is the natural way to author a hangar: one * concrete floor with a carpeted meeting box and a timber galley laid on it. * The reference office avoids it by notching every room around its neighbours, * which works when the rooms tile the plate and cannot work at all when they * are islands in the middle of it — a rectangle with holes in it is not a * simple polygon. * * Two coplanar slabs at the same `y` is a z-fight, and which one wins is the * GPU's business. So a room that overlaps earlier rooms is lifted by a hair * per earlier room it overlaps, which makes the depth test agree with the * ordering the format already documents. * * **A pack whose rooms do not overlap is lifted by nothing**, which is why * this counts overlaps rather than simply using the room's index: indexing * would raise the reference office's fifteenth room by a centimetre and a half * for no reason at all. * * Bounding boxes rather than true polygon intersection, deliberately. It is * conservative in the safe direction — two rooms whose boxes touch but whose * outlines do not get a lift they did not need, which is invisible — and it is * a handful of comparisons rather than a clipping library. */ function liftOf(room: ResolvedRoom, rooms: readonly ResolvedRoom[]): number { let overlaps = 0; for (const other of rooms) { if (other === room) break; if (Math.abs(other.y - room.y) > 1e-6) continue; if (boxesOverlap(other.outline, room.outline)) overlaps += 1; } return overlaps * SLAB_LIFT; } function buildFloor(room: ResolvedRoom, lift: number): void { const geometry = slabGeometry(room.outline, room.y + lift, true); if (!geometry) return; owned.push(geometry); const mesh = new THREE.Mesh(geometry, materials.forSurface(room.floor, "carpet")); mesh.name = `floor:${room.id}`; mesh.receiveShadow = true; // A floor slab casts nothing — there is nothing under it, and asking the // shadow camera to render the largest polygon in the office for no result is // a straight waste of its budget. mesh.castShadow = false; mesh.userData.roomId = room.id; floors.add(mesh); } function buildCeiling(room: ResolvedRoom): void { const ceiling = room.ceiling; if (!ceiling) return; const geometry = slabGeometry(room.outline, ceiling.height, false); if (!geometry) return; owned.push(geometry); const mesh = new THREE.Mesh(geometry, materials.forSurface(ceiling.surface, "ceilingTile")); mesh.name = `ceiling:${room.id}`; // A ceiling that casts a shadow puts the whole room in shade, because the // rig's sun is above it. The room is lit by the rig, not through the slab. mesh.castShadow = false; mesh.receiveShadow = true; mesh.userData.roomId = room.id; ceilings.add(mesh); } /** * The lining of one hole: two jambs and a head, a sill board under a window, * and a pane in it. * * A door gets a frame and no leaf. A leaf either stands open — and then it is * a prop in the way of the dollhouse view — or stands shut, and then the room * behind it is invisible from every angle. The collider already has the gap; * the eye should have it too. */ function lineOpening(opening: ResolvedOpening): void { const height = opening.head - opening.sill; if (height <= 0 || opening.width <= 0) return; // Windows are trimmed in the glazing frame's finish, doors and arches in the // door's. Same geometry, and the difference is the one a joiner would make. const trim = materials.get(opening.kind === "window" ? "glazingFrame" : "doorLeaf"); const depth = opening.thickness + FRAME_PROUD * 2; const half = opening.width / 2; for (const side of [-1, 1]) { const at = along(opening.center, opening.yaw, side * (half - FRAME_WIDTH / 2)); frameBin.add(parts.box(), trim, { x: at.x, y: opening.sill, z: at.z, size: [FRAME_WIDTH, height, depth], yaw: opening.yaw, }); } frameBin.add(parts.box(), trim, { x: opening.center.x, y: opening.head - FRAME_WIDTH, z: opening.center.z, size: [opening.width, FRAME_WIDTH, depth], yaw: opening.yaw, }); if (opening.kind !== "window") return; frameBin.add(parts.box(), trim, { x: opening.center.x, y: opening.sill - 0.03, z: opening.center.z, size: [opening.width + FRAME_WIDTH, 0.03, opening.thickness + SILL_PROUD * 2], yaw: opening.yaw, }); glassBin.add(parts.box(), materials.get("glazing"), { x: opening.center.x, y: opening.sill + 0.005, z: opening.center.z, size: [opening.width - FRAME_WIDTH, height - FRAME_WIDTH, 0.012], yaw: opening.yaw, }); } return { group, walls, floors, ceilings, stairs, openings, wallMeshes, setGhosted(mesh, ghosted) { if (Boolean(mesh.userData.ghosted) === ghosted) return; const info = mesh.userData.wall as WallInfo | undefined; if (!info) return; mesh.userData.ghosted = ghosted; mesh.material = ghosted ? materials.ghostOf(info.role) : materials.get(info.role); // A ghost that still casts a solid shadow gives itself away instantly. mesh.castShadow = !ghosted; }, dispose() { for (const geo of owned) geo.dispose(); owned.length = 0; wallMeshes.length = 0; group.clear(); walls.clear(); floors.clear(); ceilings.clear(); openings.clear(); }, }; } // ---- Geometry ------------------------------------------------------------- /** * A room's polygon as a flat slab at `y`, facing up for a floor and down for a * ceiling. * * It is a surface and not a box. Nothing is ever underneath a floor or above a * ceiling in an office, and the only place the missing thickness would show is * the outer edge of the building seen from below, which the orbit limits do not * let you get to. * * **UVs are the room's own world coordinates in metres**, not a 0..1 unwrap. * Carpet in one room therefore lines up with carpet in the room next door * exactly as laid carpet does, and a 3 m booth and a 30 m floor plate show the * same size of loop. `parts.metricQuad` does this for rectangles; a room is a * polygon, which is why this lives here. */ function slabGeometry(outline: Outline, y: number, up: boolean): THREE.BufferGeometry | null { const count = outline.length; if (count < 3) return null; const contour = outline.map((p) => new THREE.Vector2(p.x, p.z)); const faces = THREE.ShapeUtils.triangulateShape(contour, []); if (faces.length === 0) return null; const position = new Float32Array(count * 3); const normal = new Float32Array(count * 3); const uv = new Float32Array(count * 2); const ny = up ? 1 : -1; for (let i = 0; i < count; i++) { const p = outline[i]; if (!p) continue; position[i * 3] = p.x; position[i * 3 + 1] = y; position[i * 3 + 2] = p.z; normal[i * 3 + 1] = ny; uv[i * 2] = p.x / TEXTURE_TILE_METRES; uv[i * 2 + 1] = p.z / TEXTURE_TILE_METRES; } // `Plan` hands over a known winding, but the triangulator's output order is // its own business and a back-facing floor is invisible rather than wrong- // looking. Each triangle is oriented from its own cross product, which costs // three subtractions and cannot be got wrong by a later change of convention. const index: number[] = []; for (const face of faces) { const a = face[0]; const b = face[1]; const c = face[2]; if (a === undefined || b === undefined || c === undefined) continue; const pa = outline[a]; const pb = outline[b]; const pc = outline[c]; if (!pa || !pb || !pc) continue; const facing = (pb.z - pa.z) * (pc.x - pa.x) - (pb.x - pa.x) * (pc.z - pa.z); if (facing * ny > 0) index.push(a, b, c); else index.push(a, c, b); } if (index.length === 0) return null; const geometry = new THREE.BufferGeometry(); geometry.setAttribute("position", new THREE.BufferAttribute(position, 3)); geometry.setAttribute("normal", new THREE.BufferAttribute(normal, 3)); geometry.setAttribute("uv", new THREE.BufferAttribute(uv, 2)); geometry.setIndex(index); return geometry; } /** A point `d` metres along a wall of yaw `yaw` from its centre. */ function along(center: Point2, yaw: number, d: number): Point2 { // A run's mesh lies along its local +X, which for yaw φ points at // (cos φ, -sin φ) — the same derivation `Plan` uses to place its runs. The // `+ 0` normalises IEEE negative zero for the same reason `Plan` does it: a // north-south wall otherwise reports an x of `-0`, which renders identically // and looks like a bug in every diff. return { x: center.x + Math.cos(yaw) * d + 0, z: center.z - Math.sin(yaw) * d + 0 }; } /** Anything that knows where it sits along its wall. Runs and openings both do. */ interface Interval { center: Point2; yaw: number; start: number; end: number; } /** * The endpoints of the wall a set of runs and openings came from. * * A run knows where its own centre is and how far along the wall it starts and * ends, which is enough to recover the wall's origin and therefore both of its * ends. Doing it this way rather than reading `Wall.from`/`Wall.to` off the pack * means the segment stamped on the mesh is the segment that was actually drawn, * and a wall `Plan` repaired stays consistent with itself. * * The openings are in the list because a full-height door at the very end of a * wall leaves no run out there — no apron under it, no lintel over it — and the * segment would come up short by the width of the door. */ function extentOf(intervals: readonly Interval[]): { from: Point2; to: Point2 } { const first = intervals[0]; if (!first) return { from: { x: 0, z: 0 }, to: { x: 0, z: 0 } }; const mid = (first.start + first.end) / 2; const origin = along(first.center, first.yaw, -mid); let start = first.start; let end = first.end; for (const interval of intervals) { start = Math.min(start, interval.start); end = Math.max(end, interval.end); } return { from: along(origin, first.yaw, start), to: along(origin, first.yaw, end), }; } function groupBy(items: readonly T[], key: (item: T) => K): Map { const out = new Map(); for (const item of items) { const k = key(item); const list = out.get(k); if (list) list.push(item); else out.set(k, [item]); } return out; }