06455f7424
**Aircraft actually move now, and the reason they did not is the headline.**
`HttpFlights` holds a frozen snapshot between network refreshes and is
polled at 1 Hz, so a live feed handed the layer the same position five to
fifteen times and then jumped. `span` therefore measured the poll interval
rather than the gap between the two positions that differ, the teleport
test saw an airliner covering 36 units in a "second" against a ceiling of
8, and **every live track's history was wiped on every refresh** — so no
aircraft on the deployed site could ever grow a trail, however long
TRAIL_POINTS was set. Skipping the repeat fixes the motion and the trail
at once. Trails then go to 72 points / 240 s, which is about seventy
seconds of flying.
Three more defects in the same file, found while looking: trail
truncation dropped the segments nearest the aircraft (leaving a streak
with no aeroplane attached), MAX_TRACKS was declared and never enforced,
and one missing target deleted its whole trail. The buffer now uploads
only what it wrote, rather than 46 MB/s of untouched array.
**You can get above the constellation.** Dome to 1.05 board *radii* and
the orbit to 2.0 spans. Radii, not spans: scene space is centred on the
city and the Bay Area board runs forty kilometres down the peninsula, so
the furthest corner is 0.94 spans out where the half-diagonal is 0.65 —
sized off the half-diagonal the dome sat inside its own city. The far
plane goes to 4 spans to stop clipping the sky from off-centre chapters,
and `PointsMaterial` defaults `fog: true`, which was quietly dimming the
whole constellation with the city's haze.
**An office can say where it stands.** `Office.site` — lat, lng, height
above the ground outside, and the compass bearing the pack's −Z points
along — and with one it gets the same sun the city does, a sky, and a
horizon at `-elevation`. CONTRACT §4 reserved this as "a later
refinement"; it is taken up rather than overturned, and `daylight.ts`
computes no light of its own. It does the two things a room needs that a
map does not: turn the sun into the building's frame, and move the fog
outdoors before it greys out the far wall.
Two buildings now, and they are deliberately unalike: Lumbridge HQ 188 m
up a Transbay tower facing 205°, and **Frontier Valley**, a startup in a
hangar at Alameda Point — one room, 54 x 30 m, nine metres to the
trusses, four metres above reclaimed ground.
Floor-to-floor in the reference pack is now 16.8 m: the interstitial is
ten times a real one, so the space between the slabs is somewhere things
can hang. It is frankly not architecture, `PLENUM` is the one number to
change, and the file says so.
Also fixed, all found by review rather than by looking at the screen:
- `sun.shadow.camera.updateProjectionMatrix()` was never called, so
three's default ±5 unit box has been in force this whole time and
every `shadowExtent` this repo passes — including the city's ±752 —
has been silently ignored.
- A missing aircraft was kept alive by the new grace period and *drawn*,
so it froze in mid-air at full opacity for 32 s.
- Frontier Valley's mezzanine was a `Room`, which carries no height: its
slab lay on the concrete, its chairs floated 4.4 m over it, and its
balustrade fenced off a patch of ground floor. It is a `Level`.
- Overlapping floor slabs z-fought. The format permits overlap and
resolves later-first, so `shell.ts` now lifts a slab a hair per
earlier slab it overlaps — and by nothing at all in a pack, like the
reference office, whose rooms only ever abut.
- `switchOffice` bypassed the `entering` guard (leaking a whole scene
per double-click) and tore down the old room before knowing the new
one would load, with no way back.
Known and not fixed: raising MAX_SPAN to 30 s doubles the worst-case
re-base snap when a feed's gap shortens. It is bounded, pre-existing in
kind, and the fix wants carrying the live head into the next leg.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
519 lines
20 KiB
TypeScript
519 lines
20 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.
|
|
*/
|
|
|
|
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<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;
|
|
}
|