/** * 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. */ 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, 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; 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; /** 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"; group.add(walls, floors, ceilings, openings); 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); } } 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); } } 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, 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; }