1
0

feat: real fire on the boards, the LA office as a twin, and a night sky worth reading

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>
This commit is contained in:
2026-08-22 18:01:11 -07:00
parent b7f5c41da5
commit b25f217e3e
91 changed files with 15111 additions and 401 deletions
+108 -4
View File
@@ -26,6 +26,26 @@
* building's own scale, haze beyond it, saturated long before the horizon plane
* ends. That is what turns a flat backdrop into a view.
*
* ### Three: is anything burning near here
*
* A third question, added when the boards learned about real fire. `smokeLoad`
* is a single scalar — how much drawn fire this building is downwind of — and it
* moves exactly the three numbers a smoke plume actually moves: the haze goes
* brown, it starts closer, and the sun through it goes orange and dim.
*
* **It costs nothing.** No geometry, no draw call, no second pass, and no light:
* every one of those three is a parameter this function already returned, and
* `Atmosphere` still owns the rig (CONTRACT.md §4). A courtyard under smoke is
* the same scene with three different numbers in it.
*
* At zero it is bit-identical to what this file returned before any of it
* existed, which is asserted rather than assumed. That matters more than it
* sounds: a haze that creeps in at a "load" of nothing would put a brown sky
* over Los Angeles on a day when nothing is burning, which is the exact failure
* `src/server/fires.ts`'s promotion gate exists to prevent one layer up. The
* gate decides whether a fire is real; this file is only allowed to draw what
* the gate already agreed to.
*
* A pack with no `site` never reaches this file and keeps the fixed rig, which
* is the promise the format makes: you can author a floor plan without owning a
* coordinate.
@@ -58,6 +78,40 @@ const FOG_NEAR_M = 150;
*/
const FOG_FAR_M = 4200;
/**
* The colour smoke turns the air, and how far in it comes.
*
* A drab yellow-brown rather than a grey: wildfire smoke scatters the blue out
* of daylight long before it dims it, which is why a smoke day looks *wrong*
* from indoors rather than merely overcast. Grey reads as weather; this does
* not.
*
* `SMOKE_FOG_NEAR_M` is where the haze starts at full load — 45 m, which is
* inside the far wall of a 36 m courtyard block, so the far range of the yard
* goes soft while the room you are standing in does not. `FOG_NEAR_M`'s own note
* explains why 150 m was the floor for clear air; this is the one condition
* under which coming inside that is the right answer rather than a bug.
*/
const SMOKE_FOG_COLOR = 0x8a6a45;
const SMOKE_FOG_NEAR_M = 45;
/** The colour the sun goes through smoke, and how much of it survives. */
const SMOKE_SUN_COLOR = 0xd9762e;
const SMOKE_SUN_DIM = 0.45;
/**
* How much of the way to each of those a full load takes you.
*
* Less than all of it, on purpose. `smokeLoad` is a scalar somebody computed
* from acreage and distance, and a scalar that can saturate the frame is a
* scalar whose top end has to be exactly right. Capping the *effect* rather than
* the input means the difference between a bad fire and a catastrophic one is
* still visible, and that a bug in the number upstream cannot black out a
* courtyard.
*/
const SMOKE_FOG_MIX = 0.85;
const SMOKE_SUN_MIX = 0.8;
/**
* Turn a city-frame lighting state into an office-frame one.
*
@@ -67,13 +121,39 @@ const FOG_FAR_M = 4200;
* otherwise the same object's values, because everything else `Atmosphere`
* decided is as true inside a building as outside one.
*/
export function officeDaylight(state: LightingState, site: OfficeSite): LightingState {
const fog =
state.fog === null ? null : { color: state.fog.color, near: FOG_NEAR_M, far: FOG_FAR_M };
export function officeDaylight(
state: LightingState,
site: OfficeSite,
smokeLoad = 0,
): LightingState {
// Clamped rather than trusted. This number arrives from a fire layer reading a
// feed off somebody else's machine, and the one thing it must never do is
// reach through this function into the rig.
const smoke = Number.isFinite(smokeLoad) ? Math.min(1, Math.max(0, smokeLoad)) : 0;
const fog = state.fog === null
? null
: {
color: smoke === 0
? state.fog.color
: mixHex(state.fog.color, SMOKE_FOG_COLOR, smoke * SMOKE_FOG_MIX),
near: FOG_NEAR_M + (SMOKE_FOG_NEAR_M - FOG_NEAR_M) * smoke * SMOKE_FOG_MIX,
far: FOG_FAR_M,
};
return {
...state,
sun: { ...state.sun, direction: intoBuildingFrame(state.sun.direction, site.heading) },
sun: {
...state.sun,
direction: intoBuildingFrame(state.sun.direction, site.heading),
color: smoke === 0
? state.sun.color
: mixHex(state.sun.color, SMOKE_SUN_COLOR, smoke * SMOKE_SUN_MIX),
// Smoke does not switch the sun off, and a courtyard that went dark would
// read as dusk rather than as smoke. The colour is what says "fire"; the
// dimming only stops it looking like a bright orange afternoon.
intensity: state.sun.intensity * (1 - SMOKE_SUN_DIM * smoke * SMOKE_SUN_MIX),
},
fog,
/**
* The sky's horizon stop is pinned to the fog colour, which is what makes
@@ -95,6 +175,30 @@ export function officeDaylight(state: LightingState, site: OfficeSite): Lighting
};
}
/**
* The sentence that goes with a brown sky.
*
* `null` at zero, and a sentence at anything above it, because an unexplained
* brown sky is worse than a clear one. The whole argument for driving haze off
* real fire is that the room is telling you something true about outside; a
* viewer who cannot tell the smoke from a rendering bug has been told nothing.
*
* It says **where the number came from and what it is not**. It is not a
* measurement of the air in this courtyard — nobody has an air sensor there, and
* the one the upstream store carries is unusable for reasons recorded in
* `ARCHITECTURE.md`. It is the drawn fire set, at a distance, and the sentence
* says so rather than letting a viewer read a haze slider as an AQI.
*/
export function smokeCaption(load: number): string | null {
const smoke = Number.isFinite(load) ? Math.min(1, Math.max(0, load)) : 0;
if (smoke <= 0) return null;
const strength = smoke < 0.34 ? "Thin" : smoke < 0.67 ? "Hazy" : "Heavy";
return (
`${strength} smoke: the sky here is drawn from the fires currently on the board, ` +
"by size and distance. It is not a measurement of the air at this building."
);
}
/**
* The colour of the light a building makes for itself.
*
+305 -14
View File
@@ -6,6 +6,32 @@
* Callers choose humanoid or anonymous dog, translate their own input device to
* a normalized planar action, and decide when walk mode is active. A caller may
* attach an already-created face texture; ownership stays with that caller.
*
* ### The climb lives here, and it is presentation
*
* `WalkerState` has no `y` and is never getting one: height is fully determined
* by the storey you are on, `Plan` owns that number, and adding a vertical to
* the controller would ripple into every consumer of `floorY` for a value none
* of them may disagree about. So a crossing between two storeys is *this* file's
* job. It interpolates the actor's world height and the follow camera's along
* `ResolvedTransition.path` — the same list of points the drawn treads are built
* from — and hands the controller its new storey exactly once, at the midpoint,
* through `enterLevel`.
*
* Three rules the crossing keeps, and each of them is a defect avoided:
*
* - **Both levels resolve before it starts.** `sync` and `followPose` used to
* throw outright on a level they could not find, which for a cross-level
* handoff means killing the RAF loop mid-frame. They now fall back to the last
* height they knew, and a crossing refuses to begin at all unless both ends
* are already resolved — the failure is checked where it can be reported
* rather than where it would be fatal.
* - **It always lands on one storey.** Going inactive, resetting or disposing
* part-way through completes the handover immediately rather than abandoning
* the actor between two floors.
* - **You have to leave before you can come back.** Arriving puts the walker
* inside the far footprint, which is a way back down; without a latch a
* staircase would be an infinite loop and the actor would oscillate.
*/
import * as THREE from "three";
@@ -23,7 +49,7 @@ import {
type HumanoidRig,
} from "../assets/actors/humanoid.ts";
import type { Pose } from "../engine/scenekit.ts";
import type { Plan } from "./plan.ts";
import type { Plan, ResolvedTransition, TransitionSide } from "./plan.ts";
import {
createWalker,
normalizeWalkerAction,
@@ -39,6 +65,30 @@ const DOG_CAMERA = { distance: 2.6, height: 1.45, targetHeight: 0.48, lookAhead:
const STRIDE_METRES = 0.72;
const GAIT_EASE_SECONDS = 0.16;
/**
* How fast the actor moves along a transition path, as a multiple of its own
* walking speed.
*
* Faster than walking, because the alternative is worse. `mateo-court`'s stair
* is eleven and a half metres of path — two flights, a half landing and the
* approach — and traversing that at 1.6 m/s is seven seconds during which
* nothing the viewer does has any effect. A climb is an event, not a cutscene.
*/
const CLIMB_SPEED_FACTOR = 1.45;
/**
* Hard bounds on a crossing, in seconds.
*
* The floor stops a one-metre step between a mezzanine and its landing being an
* instantaneous jump; the ceiling stops a long flight taking the controls away
* for longer than anybody will sit still for.
*/
const MIN_CLIMB_SECONDS = 0.9;
const MAX_CLIMB_SECONDS = 3.6;
/** Below this, the actor is standing still and a crossing has no reason to start. */
const CLIMB_INPUT_EPSILON = 1e-3;
export type OfficeActorKind = "humanoid" | "anonymous-dog";
export interface OfficeActorAppearance {
@@ -75,6 +125,16 @@ export interface OfficeWalkerState extends WalkerState {
active: boolean;
actor: OfficeActorKind;
action: WalkerAction;
/**
* The transition the actor is part-way along, or `null`.
*
* `levelId` is still authoritative and still flips exactly once, halfway
* through — a consumer that only wants to know which storey to draw needs
* nothing from this field. It is here so that a caller which drives input can
* see that input is currently going nowhere, and so a test can assert the
* crossing rather than infer it from a height.
*/
crossing: string | null;
}
export interface OfficeWalker {
@@ -100,6 +160,23 @@ export interface OfficeWalker {
dispose(): void;
}
/** One crossing in flight. Presentation only: nothing here is walker state. */
interface Crossing {
transition: ResolvedTransition;
toLevelId: string;
toPosition: { x: number; z: number };
/** The direction of the last leg: which way the actor comes off the treads. */
toFacing: { x: number; z: number };
/** Foot-to-head or head-to-foot, in office-world metres, current pose first. */
path: readonly { x: number; y: number; z: number }[];
/** Cumulative 3-D length at each point. `spans[0]` is 0. */
spans: readonly number[];
length: number;
seconds: number;
elapsed: number;
handedOver: boolean;
}
type Actor =
| { kind: "humanoid"; rig: HumanoidRig }
| { kind: "anonymous-dog"; rig: DogRig };
@@ -123,6 +200,18 @@ export function createOfficeWalker(plan: Plan, options: OfficeWalkerOptions): Of
const previousFacing = new THREE.Vector2(0, -1);
let faceTexture: THREE.Texture | null = null;
let disposed = false;
let lastFloorY = plan.level(options.levelId)?.floorY ?? 0;
let warnedLostLevel = false;
let crossing: Crossing | null = null;
/**
* The transition whose footprint the actor is standing in and has not yet
* left.
*
* Arriving from a crossing puts you inside the *far* footprint, which is a way
* straight back. Without this latch a staircase is an infinite loop: up, down,
* up, for as long as the key is held.
*/
let latched: string | null = null;
const root = new THREE.Group();
root.name = "office-walker-actor";
root.userData.kind = "playable-actor";
@@ -131,10 +220,38 @@ export function createOfficeWalker(plan: Plan, options: OfficeWalkerOptions): Of
root.add(actor.rig.root);
const view = { position: new THREE.Vector3() };
function sync(state: WalkerState, elapsedSeconds = 0, travelled = 0): void {
const level = plan.level(state.levelId);
if (!level) throw new Error(`office walker lost level "${state.levelId}"`);
root.position.set(state.position.x, level.floorY, state.position.z);
/**
* This level's floor height, or the last one we knew.
*
* It used to throw. A `throw` from inside `tick` kills the RAF loop for the
* whole scene, and the case it fires on — a level that stops resolving — is
* precisely the one a cross-level handoff could produce. Everywhere else in
* the interiors stack the discipline is to report and carry on, so this
* carries on at the last known height and says so once. A crossing is refused
* outright if either end is unresolved, which is where that failure is
* actually catchable.
*/
function floorYOf(levelId: string): number {
const level = plan.level(levelId);
if (level) {
lastFloorY = level.floorY;
return level.floorY;
}
if (!warnedLostLevel) {
warnedLostLevel = true;
console.warn(`office walker lost level "${levelId}"; holding the last known floor`);
}
return lastFloorY;
}
function sync(state: WalkerState, elapsedSeconds = 0, travelled = 0, override?: {
x: number;
y: number;
z: number;
}): void {
const floorY = floorYOf(state.levelId);
if (override) root.position.set(override.x, override.y, override.z);
else root.position.set(state.position.x, floorY, state.position.z);
view.position.copy(root.position);
root.rotation.y = Math.atan2(-state.facing.x, -state.facing.z);
@@ -174,6 +291,123 @@ export function createOfficeWalker(plan: Plan, options: OfficeWalkerOptions): Of
previousFacing.set(state.facing.x, state.facing.z);
}
/**
* Start a crossing, or decline to.
*
* Declining is silent and is the ordinary answer: standing still on a stair
* is standing on a stair. It refuses outright — rather than beginning and
* failing — when either level is unresolved or the far landing is not a place
* the controller would accept, because a handover that throws does it inside
* the frame loop where nothing can recover.
*/
function beginCrossing(side: TransitionSide): boolean {
if (plan.level(side.from.levelId) === null || plan.level(side.to.levelId) === null) return false;
const authored = side.transition.path;
const ordered = side.ascending ? authored : [...authored].reverse();
// The actor triggers at the edge of the footprint, which is usually a metre
// or so from the landing the path starts at. Starting the path where the
// actor actually is turns that gap into the first stride of the climb
// rather than a jump.
const here = { x: root.position.x, y: root.position.y, z: root.position.z };
const head = ordered[0]!;
const path = Math.hypot(here.x - head.x, here.z - head.z) > 0.05
? [here, ...ordered]
: [...ordered];
const spans: number[] = [0];
let length = 0;
for (let index = 1; index < path.length; index += 1) {
const a = path[index - 1]!;
const b = path[index]!;
length += Math.hypot(b.x - a.x, b.y - a.y, b.z - a.z);
spans.push(length);
}
if (!(length > 1e-4)) return false;
const speed = (options.speed ?? 1.6) * CLIMB_SPEED_FACTOR;
const penultimate = path[path.length - 2]!;
const arrival = path[path.length - 1]!;
const lastX = arrival.x - penultimate.x;
const lastZ = arrival.z - penultimate.z;
const lastRun = Math.hypot(lastX, lastZ);
crossing = {
transition: side.transition,
toLevelId: side.to.levelId,
toPosition: { x: side.to.landing.x, z: side.to.landing.z },
// A vertical last leg — a lift — has no direction of its own, and the
// pack's authored `facing` is the answer there. Failing both, the actor
// keeps what it had.
toFacing: lastRun > 1e-6
? { x: lastX / lastRun, z: lastZ / lastRun }
: { x: Math.sin(side.to.facing), z: -Math.cos(side.to.facing) },
path,
spans,
length,
seconds: Math.min(MAX_CLIMB_SECONDS, Math.max(MIN_CLIMB_SECONDS, length / speed)),
elapsed: 0,
handedOver: false,
};
latched = side.transition.id;
return true;
}
/** The pose at a fraction of the way along a crossing, and the way it faces. */
function poseAlong(active: Crossing, t: number): {
position: { x: number; y: number; z: number };
facing: { x: number; z: number };
} {
const target = Math.min(active.length, Math.max(0, t * active.length));
let index = 1;
while (index < active.spans.length - 1 && active.spans[index]! < target) index += 1;
const a = active.path[index - 1]!;
const b = active.path[index]!;
const span = active.spans[index]! - active.spans[index - 1]!;
const local = span > 1e-9 ? (target - active.spans[index - 1]!) / span : 1;
const position = {
x: a.x + (b.x - a.x) * local,
y: a.y + (b.y - a.y) * local,
z: a.z + (b.z - a.z) * local,
};
const dx = b.x - a.x;
const dz = b.z - a.z;
const flat = Math.hypot(dx, dz);
// A lift's legs are vertical and have no heading of their own, so the actor
// keeps the one it arrived with rather than snapping to an arbitrary axis.
const facing = flat > 1e-6 ? { x: dx / flat, z: dz / flat } : null;
return { position, facing: facing ?? { x: 0, z: -1 } };
}
/**
* The one place `levelId` changes, and it happens exactly once per crossing.
*
* At the midpoint rather than at the end so that the storey the rest of the
* application is told about — the minimap, the presence pose, the occupancy
* lighting — changes while the actor is visibly between floors, which is the
* only moment at which either answer is defensible.
*/
function handOver(active: Crossing): void {
if (active.handedOver) return;
active.handedOver = true;
try {
controller.enterLevel(active.toLevelId, active.toPosition, active.toFacing);
} catch {
// `Plan` validated this landing when it resolved the transition, so this
// is unreachable short of a plan swapped underneath a live crossing. Not
// throwing is the point: the alternative is a dead frame loop.
crossing = null;
}
}
/** Finish a crossing now, wherever it had got to. Used by every interruption. */
function completeCrossing(): void {
const active = crossing;
if (!active) return;
handOver(active);
crossing = null;
sync(controller.state());
}
function snapshot(): OfficeWalkerState {
const state = controller.state();
return {
@@ -183,6 +417,7 @@ export function createOfficeWalker(plan: Plan, options: OfficeWalkerOptions): Of
active: enabled,
actor: actor.kind,
action: { ...desired },
crossing: crossing?.transition.id ?? null,
};
}
@@ -210,6 +445,10 @@ export function createOfficeWalker(plan: Plan, options: OfficeWalkerOptions): Of
setActive(active) {
if (active !== enabled) desired = { x: 0, z: 0 };
enabled = active;
// Never leave the actor between two floors. Going inactive part-way up a
// flight lands it at the top, which is the only place the rest of the
// application can describe.
if (!active) completeCrossing();
},
action: () => ({ ...desired }),
setAction(action) {
@@ -218,14 +457,56 @@ export function createOfficeWalker(plan: Plan, options: OfficeWalkerOptions): Of
},
tick(elapsedSeconds) {
if (disposed) return snapshot();
const dt = Number.isFinite(elapsedSeconds) ? Math.max(0, elapsedSeconds) : 0;
// A crossing owns the actor while it runs. The controller is deliberately
// not ticked: input during a climb goes nowhere, and letting it accumulate
// would land the walker somewhere it did not visibly walk to.
const active = crossing;
if (active) {
active.elapsed += dt;
const t = Math.min(1, active.seconds > 0 ? active.elapsed / active.seconds : 1);
if (t >= 0.5) handOver(active);
if (t >= 1) {
completeCrossing();
return snapshot();
}
const along = poseAlong(active, t);
const state = controller.state();
// The climb's own heading, not the one the controller is holding: the
// actor has to face up the flight it is on.
sync({ ...state, facing: along.facing }, dt, dt * (active.length / Math.max(active.seconds, 1e-6)), along.position);
return snapshot();
}
const before = controller.state();
const next = enabled
? controller.tick(elapsedSeconds, desired)
: controller.tick(elapsedSeconds, { x: 0, z: 0 });
sync(next, Number.isFinite(elapsedSeconds) ? Math.max(0, elapsedSeconds) : 0, next.distance - before.distance);
// Standing on a way up *is* the input. There is no key to press, which is
// why the footprints in a pack are the shape they are: the bottom of a
// flight rather than the whole stair, and the gap in a balustrade rather
// than the whole walkway.
const side = plan.transitionAt(next.levelId, next.position);
if (side === null) latched = null;
else if (
enabled && latched !== side.transition.id &&
Math.hypot(desired.x, desired.z) > CLIMB_INPUT_EPSILON &&
beginCrossing(side)
) {
return snapshot();
}
sync(next, dt, next.distance - before.distance);
return snapshot();
},
reset(spawn) {
// Abandoned rather than completed: `reset` is a teleport to a known place,
// so finishing the climb first would move the walker somewhere else and
// then move it again.
crossing = null;
latched = null;
const state = controller.reset(spawn);
desired = { x: 0, z: 0 };
gait = 0;
@@ -255,23 +536,28 @@ export function createOfficeWalker(plan: Plan, options: OfficeWalkerOptions): Of
},
followPose() {
const state = controller.state();
const level = plan.level(state.levelId);
if (!level) throw new Error(`office walker lost level "${state.levelId}"`);
// The actor's *drawn* position, so the camera rises with it across a
// crossing instead of cutting to the destination floor at the midpoint.
// On every other frame `root.position` is exactly the state's position at
// this level's floor, so this is the same answer it always gave.
const base = root.position;
const facing = crossing ? headingOf(root.rotation.y) : state.facing;
return {
position: new THREE.Vector3(
state.position.x - state.facing.x * camera.distance,
level.floorY + camera.height,
state.position.z - state.facing.z * camera.distance,
base.x - facing.x * camera.distance,
base.y + camera.height,
base.z - facing.z * camera.distance,
),
target: new THREE.Vector3(
state.position.x + state.facing.x * camera.lookAhead,
level.floorY + camera.targetHeight,
state.position.z + state.facing.z * camera.lookAhead,
base.x + facing.x * camera.lookAhead,
base.y + camera.targetHeight,
base.z + facing.z * camera.lookAhead,
),
};
},
dispose() {
if (disposed) return;
completeCrossing();
disposed = true;
root.removeFromParent();
if (actor.kind === "humanoid") applyOfficeFaceTexture(actor.rig, null);
@@ -282,6 +568,11 @@ export function createOfficeWalker(plan: Plan, options: OfficeWalkerOptions): Of
};
}
/** The inverse of the yaw `sync` writes: a rotation back to a planar heading. */
function headingOf(yaw: number): { x: number; z: number } {
return { x: -Math.sin(yaw), z: -Math.cos(yaw) };
}
function buildActor(appearance: OfficeActorAppearance): Actor {
if (appearance.kind === "anonymous-dog") {
return {
+417 -8
View File
@@ -43,16 +43,22 @@
* exception with no context in the middle of a 180-prop pack tells the author
* nothing and loses the other 179.
*
* ### Two things resolve late, and both are addresses
* ### Three things resolve late, and all of them are addresses
*
* Almost everything here is resolved level by level, in one pass, because a wall
* and the slab under it are facts about one storey. Two authored things are not:
* a **device** names a prop, and the prop may be anywhere in the building; the
* **exterior arrival stall** names a level and stands outside every one of them.
* Both are therefore resolved after the level loop has run, next to the
* prop-to-seat binding pass that runs late for exactly the same reason — a
* cross-level address checked against a half-built plan reports a problem that
* is not there.
* and the slab under it are facts about one storey. Three authored things are
* not: a **device** names a prop, and the prop may be anywhere in the building;
* the **exterior arrival stall** names a level and stands outside every one of
* them; and a **transition** names two levels at once and is a fact about
* neither on its own. All three are therefore resolved after the level loop has
* run, next to the prop-to-seat binding pass that runs late for exactly the same
* reason — a cross-level address checked against a half-built plan reports a
* problem that is not there.
*
* The transition is the one that drops *whole* rather than in pieces, and the
* reason is worth stating where a reader will find it: everywhere else a bad
* item costs a viewer that item, but half a staircase is a gap in a balustrade
* with nothing behind it. See `resolveTransition`.
*
* A device is the first authored record in this format that takes its
* *coordinate* from another record rather than restating one. `DeviceAnchor` in
@@ -97,6 +103,9 @@ import type {
Seat,
SeatPose,
SurfaceId,
Transition,
TransitionEnd,
TransitionKind,
Viewpoint,
Wall,
Yaw,
@@ -120,6 +129,25 @@ const DEFAULT_SEAT_OFFSET = 0.6;
*/
const DEFAULT_WALK_HEIGHT = 1.1;
/**
* A flight's width when the pack does not say, in metres.
*
* 1.2 m is two people passing, and it is the width of the balustrade gap
* `mateo-court` already authored for its stair — which is the number a pack
* author has usually already chosen by the time they write the transition.
*/
const DEFAULT_TRANSITION_WIDTH = 1.2;
/**
* The footprint a landing is checked against, in metres.
*
* `DEFAULT_WALKER_RADIUS` from `walker.ts`, restated rather than imported: this
* file is `walker.ts`'s dependency and not the other way round, and one number
* flowing uphill would make the dependency circular for no gain. The two are
* asserted equal in `src/test/plan.test.ts`.
*/
const TRANSITION_LANDING_RADIUS = 0.3;
/** Below this, two floats are the same number and a length is zero. */
const EPS = 1e-6;
@@ -317,6 +345,66 @@ export interface ResolvedZone {
centroid: Point2;
}
/**
* A `Transition`, resolved: two ends with floors under them, and a path with a
* height at every point on it.
*
* Both halves matter and they are used by different consumers. `lower`/`upper`
* are what the walk controller asks about — "am I standing on a way up, and
* where does it put me down" — and `path` is what the renderer builds treads
* along and what the crossing animation interpolates position and height over.
* They come from the same record, which is the point: a stair that is drawn
* somewhere a walker cannot climb is not expressible.
*/
export interface ResolvedTransition {
id: string;
kind: TransitionKind;
label: string | undefined;
width: number;
surface: SurfaceId | undefined;
lower: ResolvedTransitionEnd;
upper: ResolvedTransitionEnd;
/**
* Foot to head, in office-world metres, level elevation included.
*
* At least two points. The first is `lower.landing` at the lower floor and the
* last is `upper.landing` at the upper one, so a consumer can walk it without
* knowing anything about levels.
*/
path: readonly { x: number; y: number; z: number }[];
/** Office-world metres climbed. `upper.floorY - lower.floorY`, and positive. */
rise: number;
}
/** One end of a resolved transition. Its `floorY` is office-world metres. */
export interface ResolvedTransitionEnd {
levelId: string;
floorY: number;
/** Plan coordinates, cleaned and re-wound the way a room outline is. */
footprint: Outline;
bounds: Bounds;
landing: Point2;
facing: Yaw;
}
/**
* One end of a transition, from the point of view of somebody standing on it.
*
* What `transitionAt` answers with, and deliberately not just the transition:
* the question a walker asks is "where does this take me", and answering with a
* record that has a `lower` and an `upper` makes every caller work out which one
* it is standing on. This has already done that.
*/
export interface TransitionSide {
transition: ResolvedTransition;
/** The end the query point is standing on. */
from: ResolvedTransitionEnd;
/** The other end. Where the crossing lands. */
to: ResolvedTransitionEnd;
/** True when `to` is the upper end: this crossing goes up. */
ascending: boolean;
}
/** One storey, resolved. Everything in office-world metres. */
export interface LevelPlan {
id: string;
@@ -470,6 +558,13 @@ export class Plan {
* it can `structuredClone` it — the same treatment `office` gets.
*/
readonly exteriorArrival: ExteriorArrival | null;
/**
* Every way up and down that resolved, in declaration order.
*
* Empty for every pack that authors none, which is all of them but one — see
* the note on `Transition` in `types.ts`.
*/
readonly transitions: readonly ResolvedTransition[];
/** Everything the validation pass dropped or repaired, in build order. */
readonly problems: readonly PlanProblem[];
/** The whole office, every level unioned. */
@@ -481,6 +576,7 @@ export class Plan {
private readonly propsById = new Map<string, PropPlacement>();
private readonly viewpointsById = new Map<string, Viewpoint>();
private readonly devicesById = new Map<string, ResolvedDevice>();
private readonly transitionsById = new Map<string, ResolvedTransition>();
constructor(office: Office, options: PlanOptions = {}) {
this.office = office;
@@ -507,6 +603,7 @@ export class Plan {
zone: new Set<string>(),
viewpoint: new Set<string>(),
device: new Set<string>(),
transition: new Set<string>(),
};
// `levels` and `viewpoints` are required by the type, but a pack arriving as
@@ -551,6 +648,20 @@ export class Plan {
for (const item of pending) this.resolveDevice(item, seen.device, report);
// Transitions resolve here, next to devices, and for the same reason: a
// transition is a *cross-level* address and there is no point in the level
// loop at which both of its ends exist. Unlike a device it is not a fact
// about one storey at all, which is why it is authored on the office rather
// than on either floorplan.
const transitions: ResolvedTransition[] = [];
(office.transitions ?? []).forEach((transition, ti) => {
const resolved = this.resolveTransition(transition, `transitions[${ti}]`, seen.transition, report);
if (!resolved) return;
transitions.push(resolved);
this.transitionsById.set(resolved.id, resolved);
});
this.transitions = transitions;
const viewpoints: Viewpoint[] = [];
(office.viewpoints ?? []).forEach((viewpoint, vi) => {
const where = `viewpoints[${vi}]`;
@@ -604,6 +715,37 @@ export class Plan {
return this.devicesById.get(id) ?? null;
}
transition(id: string): ResolvedTransition | null {
return this.transitionsById.get(id) ?? null;
}
/**
* The way up under a point on a storey, if there is one.
*
* The whole of the walk controller's interface to this record: standing on the
* footprint is the input, so this is the question `officeWalker` asks every
* frame. It is a linear scan because a building has one or two of these and a
* spatial index for two entries is a slower way to be cleverer.
*/
transitionAt(levelId: string, point: Point2): TransitionSide | null {
for (const transition of this.transitions) {
for (const ascending of [true, false]) {
const from = ascending ? transition.lower : transition.upper;
if (from.levelId !== levelId) continue;
if (point.x < from.bounds.minX || point.x > from.bounds.maxX) continue;
if (point.z < from.bounds.minZ || point.z > from.bounds.maxZ) continue;
if (!pointInOutline(point, from.footprint)) continue;
return {
transition,
from,
to: ascending ? transition.upper : transition.lower,
ascending,
};
}
}
return null;
}
/** Every device in the building, in declaration order, levels in order. */
allDevices(): ResolvedDevice[] {
return [...this.devicesById.values()];
@@ -1255,6 +1397,221 @@ export class Plan {
this.devicesById.set(id, resolved);
}
/**
* One authored way up, resolved — or dropped whole.
*
* **Whole** is the operative word and it is the one rule this method exists to
* keep. Everywhere else in this file a bad item costs a viewer that item; here
* a half-resolved transition would cost them a floor they can walk off. A
* staircase whose upper end did not resolve is a hole in a collider with an
* invitation standing in front of it, so every check below drops the record
* entirely and says which end was wrong.
*
* What is checked, in the order a pack gets it wrong:
*
* - the id is unique across the office, like every other id here;
* - both levels resolve, and `upper` is genuinely above `lower` — a pack that
* has them the wrong way round would otherwise build a stair that descends
* into the slab;
* - each footprint is a polygon with area, inside its own level's bounds, and
* with no collision segment running through it. That last one is the "leaves
* the footprint clear" half of the contract: the wall resolver already
* leaves a gap in `collision` for a door, and a transition whose footprint
* is on the wrong side of a wall has no such gap and is not a way anywhere;
* - each landing is inside its own footprint and is a place a walker of the
* default radius could actually stand;
* - the legs form a finite path whose rise shares are not all zero.
*/
private resolveTransition(
transition: Transition,
where: string,
seen: Set<string>,
report: Report,
): ResolvedTransition | null {
const id = transition.id;
if (typeof id !== "string" || id === "") {
report(where, "transition has no id", "dropped");
return null;
}
if (seen.has(id)) {
report(where, `duplicate transition id "${id}"`, "dropped");
return null;
}
if (transition.kind !== "stair" && transition.kind !== "lift") {
report(where, `transition "${id}" has unknown kind "${String(transition.kind)}"`, "dropped");
return null;
}
// Not reported: a private transition at public depth is the depth doing its
// job, exactly as it is for a private room. A pack that wants a staff stair
// absent from the anonymous build says so and gets a building with no stair
// in it rather than a stair that goes nowhere.
if (!included(this.depth, transition.audience)) return null;
const lower = this.resolveTransitionEnd(transition.lower, `${where}.lower`, id, report);
const upper = this.resolveTransitionEnd(transition.upper, `${where}.upper`, id, report);
if (!lower || !upper) return null;
if (lower.levelId === upper.levelId) {
report(where, `transition "${id}" joins level "${lower.levelId}" to itself`, "dropped");
return null;
}
const rise = upper.floorY - lower.floorY;
if (!(rise > EPS)) {
report(
where,
`transition "${id}" does not rise: "${lower.levelId}" is at ${lower.floorY} and ` +
`"${upper.levelId}" is at ${upper.floorY}`,
"dropped",
);
return null;
}
const path = this.transitionPath(transition, lower, upper, rise, where, id, report);
if (!path) return null;
seen.add(id);
return {
id,
kind: transition.kind,
label: transition.label,
width: positiveOr(transition.width, DEFAULT_TRANSITION_WIDTH),
surface: transition.surface ?? this.levelsById.get(lower.levelId)?.wallSurface,
lower,
upper,
path,
rise,
};
}
/** One end: a level, a cleaned footprint on it, and a place to stand. */
private resolveTransitionEnd(
end: TransitionEnd | undefined,
where: string,
id: string,
report: Report,
): ResolvedTransitionEnd | null {
if (!end || typeof end.levelId !== "string") {
report(where, `transition "${id}" is missing an end`, "dropped");
return null;
}
const level = this.levelsById.get(end.levelId);
if (!level) {
report(where, `transition "${id}" names unknown level "${end.levelId}"`, "dropped");
return null;
}
const footprint = cleanOutline(end.footprint, where, report);
if (!footprint) return null;
const bounds = outlineBounds(footprint);
if (
bounds.minX < level.bounds.minX - EPS || bounds.maxX > level.bounds.maxX + EPS ||
bounds.minZ < level.bounds.minZ - EPS || bounds.maxZ > level.bounds.maxZ + EPS
) {
report(
where,
`transition "${id}" has a footprint outside level "${end.levelId}"`,
"dropped",
);
return null;
}
for (const segment of level.collision) {
if (!segmentCrossesOutline(segment.from, segment.to, footprint, bounds)) continue;
report(
where,
`transition "${id}" has wall "${segment.wallId}" running through its footprint on ` +
`level "${end.levelId}"`,
"dropped",
);
return null;
}
const landing = end.landing;
if (!landing || !Number.isFinite(landing.x) || !Number.isFinite(landing.z)) {
report(where, `transition "${id}" has a landing that is not a point`, "dropped");
return null;
}
if (!pointInOutline(landing, footprint)) {
report(where, `transition "${id}" has a landing outside its own footprint`, "dropped");
return null;
}
if (this.blocked(end.levelId, landing, landing, TRANSITION_LANDING_RADIUS)) {
report(
where,
`transition "${id}" has a landing a walker cannot stand on — it is inside a wall`,
"dropped",
);
return null;
}
return {
levelId: end.levelId,
floorY: level.floorY,
footprint,
bounds,
landing: { x: landing.x, z: landing.z },
facing: Number.isFinite(end.facing) ? (end.facing as Yaw) : 0,
};
}
/**
* The legs, turned into an absolute path with a height at every point.
*
* The shares are normalised here rather than trusted, because "the flights add
* up to the storey" is the sort of arithmetic a pack author does in their head
* and gets wrong by a twentieth. A path whose shares are all zero is refused
* rather than normalised: that is a lift authored as a stair, and silently
* turning it into one flat run would draw a floor where a flight should be.
*/
private transitionPath(
transition: Transition,
lower: ResolvedTransitionEnd,
upper: ResolvedTransitionEnd,
rise: number,
where: string,
id: string,
report: Report,
): { x: number; y: number; z: number }[] | null {
const authored = transition.legs ?? [];
const legs = authored.length > 0
? authored
// One straight flight, which is what a single run of stairs and a lift
// shaft both are. The share is the whole climb because there is nothing
// else to share it with.
: [{ to: upper.landing, rise: 1 }];
let total = 0;
for (const leg of legs) {
if (!leg || !leg.to || !Number.isFinite(leg.to.x) || !Number.isFinite(leg.to.z)) {
report(where, `transition "${id}" has a leg that is not a point`, "dropped");
return null;
}
if (!Number.isFinite(leg.rise) || leg.rise < 0) {
report(where, `transition "${id}" has a leg with a rise share of "${String(leg.rise)}"`, "dropped");
return null;
}
total += leg.rise;
}
if (!(total > EPS)) {
report(where, `transition "${id}" climbs nothing: every leg's rise share is zero`, "dropped");
return null;
}
const path = [{ x: lower.landing.x, y: lower.floorY, z: lower.landing.z }];
let climbed = 0;
for (const leg of legs) {
climbed += leg.rise / total;
path.push({ x: leg.to.x, y: lower.floorY + rise * climbed, z: leg.to.z });
}
// The last leg is *made* to arrive at the upper landing rather than checked
// against it. A pack that ends its final flight a few centimetres short has
// authored a stair that does not reach its own landing, and the alternative
// to snapping it is a walker who finishes a climb standing next to the floor.
const head = path[path.length - 1]!;
head.x = upper.landing.x;
head.z = upper.landing.z;
head.y = upper.floorY;
return path;
}
/**
* The exterior stall, checked for the three things that would make it
* unusable and deliberately not for the fourth.
@@ -1507,6 +1864,58 @@ function cleanOutline(
return points;
}
/**
* Does a wall's centreline run through a footprint?
*
* A crossing test rather than a distance one, deliberately. A footprint is a
* patch of floor and a wall along its edge is the ordinary case — the stair in
* `mateo-court` has brick on two sides of it — so inflating by anything at all
* would refuse every transition authored against a wall, which is where stairs
* go. What is not allowed is a wall *through* the middle of it, because then the
* two halves are not one place and the collider will not let a walker cross
* between them.
*
* The landing is checked separately, with the walker's radius, which is where
* "can somebody actually stand here" is answered.
*/
function segmentCrossesOutline(
from: Point2,
to: Point2,
outline: Outline,
bounds: Bounds,
): boolean {
const minX = Math.min(from.x, to.x);
const maxX = Math.max(from.x, to.x);
const minZ = Math.min(from.z, to.z);
const maxZ = Math.max(from.z, to.z);
if (maxX < bounds.minX || minX > bounds.maxX || maxZ < bounds.minZ || minZ > bounds.maxZ) {
return false;
}
for (let i = 0, j = outline.length - 1; i < outline.length; j = i++) {
const a = outline[j];
const b = outline[i];
if (!a || !b) continue;
if (properCrossing(from, to, a, b)) return true;
}
// A segment wholly inside the footprint crosses no edge, and is still a wall
// standing in the middle of it.
return pointInOutline(from, outline) || pointInOutline(to, outline);
}
/** Two segments crossing at an interior point of both. Collinear is not a crossing. */
function properCrossing(a1: Point2, a2: Point2, b1: Point2, b2: Point2): boolean {
const d1 = cross(a1, a2, b1);
const d2 = cross(a1, a2, b2);
const d3 = cross(b1, b2, a1);
const d4 = cross(b1, b2, a2);
return d1 * d2 < 0 && d3 * d4 < 0;
}
/** A positive finite override, or the default. Used for optional dimensions. */
function positiveOr(value: number | undefined, fallback: number): number {
return typeof value === "number" && Number.isFinite(value) && value > 0 ? value : fallback;
}
function shoelace(outline: Outline): number {
let sum = 0;
for (let i = 0, j = outline.length - 1; i < outline.length; j = i++) {
+151 -2
View File
@@ -29,13 +29,34 @@
* 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, WallRun } from "./plan.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. */
@@ -45,6 +66,27 @@ 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. */
@@ -81,6 +123,15 @@ export interface Shell {
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`. */
@@ -137,7 +188,9 @@ export function createShell(plan: Plan, options: ShellOptions): Shell {
ceilings.name = "ceilings";
const openings = new THREE.Group();
openings.name = "openings";
group.add(walls, floors, ceilings, 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
@@ -169,6 +222,15 @@ export function createShell(plan: Plan, options: ShellOptions): Shell {
}
}
// 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
@@ -187,6 +249,92 @@ export function createShell(plan: Plan, options: ShellOptions): Shell {
}
}
/**
* 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,
@@ -366,6 +514,7 @@ export function createShell(plan: Plan, options: ShellOptions): Shell {
walls,
floors,
ceilings,
stairs,
openings,
wallMeshes,
setGhosted(mesh, ghosted) {
+139
View File
@@ -185,6 +185,23 @@ export interface Office {
*/
viewpoints: Viewpoint[];
/**
* The ways up and down: where a walker may cross between two storeys.
*
* On the `Office` rather than on a `Floorplan` because a transition is the one
* authored record that is a fact about *two* levels at once, and putting it on
* either one would make the other one's copy a restatement — which is the
* failure `DeviceAnchor` already argues about at length. It resolves late in
* `Plan`, beside devices and the exterior arrival stall, for the same reason
* they do: it is a cross-level address, and checking one against a half-built
* plan reports a problem that is not there.
*
* Absent — and it is absent from most packs — means the storeys are places the
* camera flies to and nothing more, which is what every pack in this repo was
* until `src/interiors/walker.ts` learned to change levels.
*/
transitions?: Transition[];
/**
* Where on the earth this building stands, if it stands anywhere.
*
@@ -355,6 +372,128 @@ export interface Level {
floorplan: Floorplan;
}
// ---- Transitions ----------------------------------------------------------
/**
* What kind of way up this is.
*
* Two members, and the difference between them is presentation rather than
* mechanism: a `stair` is drawn as a flight of treads climbing its own path and
* a walker rises along it, a `lift` is a box that goes up. Both resolve
* identically and both hand the walker the same two footprints. A third kind
* would be a ladder or a ramp; neither is authored anywhere yet, and a union
* member nothing draws is a promise the renderer has not made.
*/
export type TransitionKind = "stair" | "lift";
/**
* A way between two storeys, authored once and read by both the walker and the
* renderer.
*
* ### Why the format needs this at all
*
* `Plan` carries no vertical beyond `Level.elevation`, and the collider is a
* flat list of 2-D segments. There is nothing in the build product that can tell
* a staircase from a rug — a `Room` with a `STEEL` floor the shape of a flight
* is exactly as walkable as the carpet next to it, and exactly as unable to take
* anybody upstairs. So the way up is stated rather than inferred, which is the
* same decision CONTRACT.md §2 already made for wall openings: one explicit
* list, resolved once, read by the thing that draws it and the thing that walks
* it, with no second list to keep in step.
*
* ### Both ends, in plan coordinates, like everything else
*
* Each end names a level and gives a **footprint**: the patch of that storey's
* floor a walker has to be standing on for this to be the way up. Standing in it
* *is* the input — there is no key to press — which is why the footprints are
* the shape they are in `mateo-court`: the lower one is the bottom of the flight
* and not the whole stair, and the upper one is the gap in the balustrade rather
* than the whole walkway.
*
* The upper footprint doubles as a guard rail in the literal sense. A gap in a
* balustrade is a hole a walker can otherwise stroll straight out of, five
* metres above a paved yard; a footprint that covers the gap catches them before
* the edge and sends them downstairs instead, which is what the gap is for.
*
* Coordinates are the pack's plan frame — the same one every level is authored
* in, with each slab at zero — and `Plan` adds the level's elevation exactly
* once, in one place, the way it does for everything else.
*/
export interface Transition {
/** Unique in the office, across every kind of record `Plan` checks ids for. */
id: string;
kind: TransitionKind;
/** Shown wherever a pack's own vocabulary is shown. "The Stair". */
label?: string;
/** The storey it starts on: the lower `elevation` of the two. */
lower: TransitionEnd;
/** The storey it arrives at. `Plan` drops the record if this is not higher. */
upper: TransitionEnd;
/**
* The route it takes between the two landings, in plan coordinates, foot
* first.
*
* Absent means one straight flight from `lower.landing` to `upper.landing`,
* which is what a single run of stairs or a lift shaft is. A dog-leg is three
* legs — a flight, a landing, a flight — and it has to be authored, because
* the shape of the flight is a fact about the building that nothing in a
* floorplan implies. It is also what the treads are built from: the drawn
* stair and the climb a walker takes read the same list, so a stair that is
* drawn somewhere the walker does not go is not expressible.
*/
legs?: TransitionLeg[];
/** The flight's width in metres. `Plan` uses 1.2 when this is absent. */
width?: number;
/** The finish of the treads. Falls back to the lower level's wall surface. */
surface?: SurfaceId;
audience?: Audience;
}
/** One end of a `Transition`: a storey, a patch of its floor, and where you stand. */
export interface TransitionEnd {
levelId: string;
/**
* The patch of floor that is this end of the way up, in plan coordinates.
*
* Wound either way and closed implicitly, exactly like a `Room.outline`.
* `Plan` drops the whole transition if this falls outside its level's bounds
* or if a collision segment runs through it — half a staircase is a hole in a
* collider, and dropping one end and keeping the other is how you get one.
*/
footprint: Outline;
/**
* Where a walker is set down arriving at this end, in plan coordinates.
*
* Must be inside `footprint` and clear of walls at the default walker radius,
* or the transition is dropped. It is authored rather than derived from the
* footprint's centroid because the centroid of an L-shaped landing is not
* necessarily on it, and because a pack author knows which way the last tread
* faces and a centroid does not.
*/
landing: Point2;
/** Which way a walker faces on arrival. Defaults to along the last leg. */
facing?: Yaw;
}
/**
* One straight leg of a transition: a flight, or a landing between two flights.
*
* `rise` is this leg's share of the total climb, as a fraction — so a dog-leg's
* two flights are `0.5` each and the half landing between them is `0`. It is a
* share rather than a height because the height is already known exactly (it is
* the difference between the two levels' elevations) and stating it twice is how
* a pack ends up with a staircase that does not reach its own landing.
*
* `Plan` normalises the shares to sum to one and reports a pack whose shares are
* all zero, which is a lift shaft authored as a stair.
*/
export interface TransitionLeg {
/** Where this leg ends, in plan coordinates. */
to: Point2;
/** This leg's share of the total rise, 0..1. A flat landing is 0. */
rise: number;
}
/**
* Everything on one storey.
*
+81 -5
View File
@@ -7,6 +7,27 @@
* projects blocked motion along a wall so diagonal input slides instead of
* stopping. Doors need no special case: the wall resolver has already left a
* gap in `LevelPlan.collision` for every passable opening.
*
* ### The level is state, and the state is still two-dimensional
*
* `levelId` used to be spawn *configuration* — `snapshot()` returned
* `spawn.levelId` and nothing in `tick()` could change it, so a snapshot taken
* on another storey was refused outright and every authored upper floor in every
* pack was unreachable on foot. It is now a mutable part of the finite state,
* changed by exactly one method, `enterLevel`.
*
* What deliberately did **not** change is the shape of `WalkerState`: it is two
* numbers and a level id, with no `y` anywhere. Height is fully determined by
* the level and belongs to whoever is drawing — `officeWalker` interpolates
* `floorY` across a crossing and `Plan` owns the number. Adding a vertical here
* would ripple into every consumer of `floorY` for a value none of them would be
* allowed to disagree about, and it would cost this file the property that makes
* it worth having: it is numeric, three-free, testable without a WebGL context
* and snapshotable by Arena.
*
* `enterLevel` is not `reset`. `reset` adopts a new *spawn*, zeroes the odometer
* and restores the authored facing — which is a teleport, and using it to climb
* a staircase would silently reset `distance` at the top of every flight.
*/
import type { Bounds, LevelPlan, Segment } from "./plan.ts";
@@ -69,6 +90,17 @@ export interface WalkerController {
reset(spawn?: WalkerSpawn): WalkerState;
/** Restore a trusted JSON snapshot without changing the configured spawn. */
restore(snapshot: WalkerState): WalkerState;
/**
* Cross to another storey at a position that is valid on it, keeping the
* odometer and the configured spawn. Throws if it is not.
*
* The one mutator that is not a teleport. See the header on why the climb
* itself is presentation and lives in `officeWalker`. `facing` is optional
* because a lift does not turn you round and a flight of stairs does: pass the
* direction of the last leg and the actor arrives looking the way it came off
* the treads, rather than snapping back to whatever it held at the bottom.
*/
enterLevel(levelId: string, position: Point2, facing?: Point2): WalkerState;
}
/**
@@ -89,6 +121,7 @@ export function createWalker(plan: WalkerPlan, options: WalkerOptions): WalkerCo
const maxCatchUpSteps = integer(options.maxCatchUpSteps ?? DEFAULT_MAX_CATCH_UP_STEPS);
let spawn = checkedSpawn(plan, options, radius);
let levelId = spawn.levelId;
let position = copy(spawn.position);
const initialFacing = normalizedFacing(options.facing);
let facing: Point2 = copy(initialFacing);
@@ -97,7 +130,7 @@ export function createWalker(plan: WalkerPlan, options: WalkerOptions): WalkerCo
function snapshot(): WalkerState {
return {
levelId: spawn.levelId,
levelId,
position: copy(position),
facing: copy(facing),
distance,
@@ -106,6 +139,7 @@ export function createWalker(plan: WalkerPlan, options: WalkerOptions): WalkerCo
function reset(next = spawn): WalkerState {
spawn = checkedSpawn(plan, next, radius);
levelId = spawn.levelId;
position = copy(spawn.position);
facing = copy(initialFacing);
distance = 0;
@@ -116,7 +150,7 @@ export function createWalker(plan: WalkerPlan, options: WalkerOptions): WalkerCo
function tick(elapsedSeconds: number, rawAction: WalkerAction): WalkerState {
// The internals are private, but this also makes the recovery policy clear
// if a future refactor exposes a mutable transport/state object.
if (!finitePoint(position) || !validPosition(plan, spawn.levelId, position, radius)) reset();
if (!finitePoint(position) || !validPosition(plan, levelId, position, radius)) reset();
if (!(elapsedSeconds > 0) || !Number.isFinite(elapsedSeconds)) return snapshot();
const action = normalizeWalkerAction(rawAction);
@@ -131,7 +165,7 @@ export function createWalker(plan: WalkerPlan, options: WalkerOptions): WalkerCo
steps += 1;
const amount = speed * fixedStep;
const before = position;
position = moveWithSliding(plan, spawn.levelId, position, {
position = moveWithSliding(plan, levelId, position, {
x: action.x * amount,
z: action.z * amount,
}, radius);
@@ -140,12 +174,29 @@ export function createWalker(plan: WalkerPlan, options: WalkerOptions): WalkerCo
return snapshot();
}
/**
* The rule a snapshot has to pass, and the reason it is written this way.
*
* It used to be "the level equals the spawn level", which made a snapshot from
* an upper storey illegal even when it described a place a walker could
* plainly stand. The rule is now the one it should always have been — **a
* level this plan resolves, at a position that is valid on it** — which is
* strictly the check the next line already performed, with the spawn
* comparison removed. Everything else a bad snapshot can be is still refused:
* a non-finite coordinate, a negative odometer, a position outside the level's
* bounds or inside a wall.
*
* This is an Arena contract rather than an internal: `src/arena/officeNav.ts`
* restores an episode through it, and what a snapshot may legally contain is
* defined here and nowhere else.
*/
function restore(next: WalkerState): WalkerState {
if (
next.levelId !== spawn.levelId || !finitePoint(next.position) || !finitePoint(next.facing) ||
typeof next.levelId !== "string" || !finitePoint(next.position) || !finitePoint(next.facing) ||
!Number.isFinite(next.distance) || next.distance < 0 ||
!validPosition(plan, next.levelId, next.position, radius)
) throw new RangeError("walker snapshot is incompatible or invalid");
levelId = next.levelId;
position = copy(next.position);
facing = normalizedFacing(next.facing);
distance = next.distance;
@@ -153,7 +204,32 @@ export function createWalker(plan: WalkerPlan, options: WalkerOptions): WalkerCo
return snapshot();
}
return { state: snapshot, tick, reset, restore };
/**
* Step onto another storey, keeping everything else.
*
* The whole of the cross-level mechanism, and deliberately the whole of it:
* the odometer keeps running, the facing is kept, the spawn is untouched, and
* the caller has already decided both that a transition exists and where on
* the far level it lands. `Plan.transitionAt` is the thing that decides;
* `officeWalker` is the thing that animates the climb. This only moves the
* two numbers that make it true.
*
* It throws rather than dropping, because unlike an authored pack this is
* called by engine code with a position the resolver has already validated —
* a failure here is a bug upstream, not a typo in somebody's floorplan.
*/
function enterLevel(nextLevelId: string, nextPosition: Point2, nextFacing?: Point2): WalkerState {
if (!nextLevelId || !finitePoint(nextPosition) || !validPosition(plan, nextLevelId, nextPosition, radius)) {
throw new RangeError(`walker cannot enter level "${nextLevelId}" at that position`);
}
levelId = nextLevelId;
position = copy(nextPosition);
if (nextFacing !== undefined) facing = normalizedFacing(nextFacing);
accumulator = 0;
return snapshot();
}
return { state: snapshot, tick, reset, restore, enterLevel };
}
function normalizedFacing(value: Point2 | undefined): Point2 {