b25f217e3e
The world stops being a simulation of California and starts being California. **THE PROMOTION GATE WAS THE FIRST COMMIT, BEFORE ANY ORANGE PIXEL EXISTED.** On today's live store the SoCal board contains 22 incidents. Every one has NULL acreage and fifteen are nameless LA County dispatch numbers. Drawn naively that is 22 orange marks over Los Angeles on a day nothing is burning — in a frame that contains no other warm colour, so one glyph would be the most salient object on the board and twenty-two would spend its credibility permanently. `acres >= 10 AND contained < 80 AND type != 'RX' AND last_seen = max(last_seen)` returns 0 on SoCal, exactly 5 on California, 0 on the Bay — same body, same day, three correct answers. The empty board is a deliverable, not a fallback: it says "No active fire on this board — CAL FIRE and WFIGS, just now", states that 21 records were gated and why, lists the largest fires burning OUTSIDE the frame with distances, and counts the hot pixels it is deliberately not drawing. **The privacy leak is structurally impossible rather than carefully avoided.** cloud-1 serves a projection; the four home-relative columns never leave that box. `observations.threat` was the one that nearly got through — it is `(16/distance)^2 x log10(acres) x momentum x containment x wind-alignment`, so with acreage and containment public it inverts to a distance circle around a house and three fires give an intersection. A grep of the built bundle for distance_km, bearing_deg, threat, 7762 and the street name returns nothing. **Deliberately not used, and both would have produced a confident wrong answer:** the store's `air` table retains only the last parameter of each poll, so all 78 rows read "Good" while the live feed reports ozone 101 "Unhealthy for Sensitive Groups" — haze driven off it would clear the sky during a smoke event. And `weather` is written only inside the NWS alerts loop, so a quiet day stores no wind at all. Tera's own per-region NWS wind is already correct and already what the clouds drift on. Satellite detections are drawn as evidence and never as incidents. The permanent industrial heat source 4.7 km from the owner's house is flagged persistent and dropped, asserted by a test that first proves it is present in the fixture. MODIS integer confidence and VIIRS string confidence are branched on `sat`. **The LA office is a twin.** Its entire authored second storey — Model Loft, Model Bay, The Materials Room, 430 lines nobody had ever stood in — is reachable on foot: a walker crosses level-1 to level-2 in 73 fixed steps, floorY 0 to 5, verified against the real pack rather than a synthetic plan. Its two studio devices read real hardware through a field-allowlisted bridge: mute, volume and reachability only. Never level, because there is no passive level upstream and obtaining one would record a room with people in it. Never dB, because upstream is gainPct across four different native scales. The bridge refuses all writes. Fixed at its root: an anonymous visitor was getting permanently at-rest instruments backing off against a 401. The tier moves into `createDeviceSource`, so anon gets the living simulator three file headers already promised. **Item 8 is closed, not fixed, and the correction is the point.** The Bay Area "stutter" was GPU power management — the card sat at 500 MHz of 2725 through every run that reproduced it, 4096/2048/1024/256 shadow maps all render in 1.21-1.31 ms, and two consecutive runs over a byte-identical dist gave 33.4 then 16.7. The allowance is removed and the cell is back to 16.7. Geometry is the gate; frame time is advisory. Item 7 was re-scoped after measuring: 1,069,006 of the Bay Area's 2,265,056 triangles were the second submission of the same buildings into the shadow pass. Mobile now has its own triangle caps and bay-area mobile draws 1,266,096. Also: bridges and the freeway corridor light up at night as emission, not lights — 1,614 deck lamps and 18 tower heads on the Bay in two draw calls. The single change that made US-101 legible was moving its edge lines from the lit material to the unlit one: retroreflective paint, the argument the SFO night frame already makes. California went 21,991 lamps to 4,051, clustered at the 17 town districts, because a rural interurban corridor genuinely is unlit. Tests 1137 -> 1340, server 280. All ten budget cells pass on first attempt with no cap raised. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
668 lines
25 KiB
TypeScript
668 lines
25 KiB
TypeScript
/**
|
||
* 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<T, K>(items: readonly T[], key: (item: T) => K): Map<K, T[]> {
|
||
const out = new Map<K, T[]>();
|
||
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;
|
||
}
|