1
0

The office keeps its lights on, and something walks around under them

**Lights.** A sited office follows the real sun, and the real sun spends
half its time below the horizon — which was producing a technically
correct and completely useless picture: an unlit floor plate at midnight
in a building whose whole premise is that you can see who is at which
desk. `luminaires.ts` brings the diffusers up as the sun goes down and
reports one scalar for how much interior light there is; `withHouseLights`
adds it to the rig. CONTRACT §4's rule that a fitting emits no light is
kept in full — nothing here is a light source, and the rig still has one
owner.

**And they notice you.** A fitting within four metres of somebody walking
underneath brightens and fades back as they leave, which is what an
occupancy-sensed floor actually does at night. They are one `InstancedMesh`
sharing one material, so `emissiveIntensity` cannot vary between them —
`instanceColor` can, but three multiplies it into the diffuse term only, so
six lines of `onBeforeCompile` carry it into the emissive term as well. The
alternative was one mesh per fitting: forty draw calls of ceiling in a
building that spends about twenty on everything.

**Optimus.** A posable Gen-3 humanoid — eleven articulating joints, pale
shells over a dark frame, a black visor — with a walk cycle driven by
*distance travelled* rather than wall-clock, so the feet do not slide when
a robot slows down. Two per floor, derived from the pack's levels, so the
two-storey tower gets four and the hangar gets two without either pack
knowing robots exist. They wander between reachable points using
`Plan.blocked` — the collider the wall split already produces — and they
are deliberately **not** gated on `depth`: the build-time-exclusion rule is
about occupancy, and a robot is nobody.

**Starlinks stop being pixels.** The sixty-four nearest the centre of view
grow real geometry — a flat bus with ONE large solar array, which is the
actual signature and the thing everybody draws symmetrically and wrong —
fading in so there is no pop where a point becomes a mesh. Two draw calls.
The sun for their attitude comes from `solar.ts` and not from the rig,
because `atmosphere.ts` floors the light direction to keep the shadow
camera usable, and a sun ten degrees *down* is exactly the dusk geometry
that makes a pass visible.

**Aircraft** are airliners now — swept wings, nacelles, a fin — instead of
an arrowhead, still one shared geometry facing +Z as `flights.ts` requires.
**Clouds** drift over the board, driven by observed cover, lit by the rig
rather than by themselves.

Four modules were built by subagents and reviewed by another; every one
came back `needs-work` and the reviews were right. Fixed before wiring:

  - The walk cycle's arms were a quarter cycle out of step with its legs —
    the legs are cosine-shaped and the arms were on `sin`, so at the
    instant the left leg reached full forward the left shoulder was at dead
    neutral. Uncanny, and hard to name until it is pointed at.
  - Every Optimus shell used a `roundedBox` radius of 0.12–0.22, which that
    primitive turns into a near-circular cross-section — the figure was
    built out of lozenges, not panels. The rest of the library uses
    0.02–0.09.
  - The cloud material was `transparent` + `DoubleSide` without
    `forceSinglePass`, so three rendered it twice per frame *and* bumped
    `material.version` on each pass — rebuilding the program cache key
    forever, on the one layer that is fill-rate bound.
  - `starlinkMesh.dispose()` freed the geometries but not the
    `InstancedMesh`es, orphaning their instance buffers on every city
    switch.
  - The airliner's tailplane roots sat outside the tail cone and hung in
    free air over most of their chord.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-08-07 00:56:49 -07:00
parent 18dadda917
commit af0d4a7d57
13 changed files with 4917 additions and 42 deletions
+78
View File
@@ -82,6 +82,8 @@ import { createFurnishings, type Furnishings } from "./furnish.ts";
import { Plan, type Depth, type PlanOptions } from "./plan.ts";
import { createPresenceLayer, type PresenceLayer, type PresencePalette } from "./presence.ts";
import { createShell, type Shell, type WallInfo } from "./shell.ts";
import { createLuminaires, type Luminaires, type Walker } from "./luminaires.ts";
import { createRobotLayer, type RobotLayer, type RobotSpec } from "./robots.ts";
import type { Office, Point2, Presence, Viewpoint } from "./types.ts";
// Re-exported so a caller can name the tier it is asking for without importing
@@ -189,6 +191,17 @@ export interface OfficeSceneOptions {
* those are the three things you actually feel.
*/
horizon?: { drop: number };
/**
* Humanoids to walk about the floor, one entry per robot.
*
* **Not gated on `depth`, unlike `presence`, and that asymmetry is the point.**
* The build-time-exclusion rule in this file's header is about *occupancy* — a
* `Presence` names a person and comes from an authenticated API, so the public
* office must not construct one. A robot is nobody: it carries no id anybody
* issued, no seat binding, and no data from anywhere. There is nothing to
* withhold, so a stranger gets them too.
*/
robots?: readonly RobotSpec[];
/** Defaults to false — the lid comes off, because that is the whole view. */
showCeilings?: boolean;
/** Fade the walls you are looking through. Defaults to true. */
@@ -227,6 +240,26 @@ export interface OfficeScene extends StageScene {
anchors: Map<string, THREE.Vector3>;
setCeilingsVisible(visible: boolean): void;
setLighting(state: LightingState): void;
/**
* The sun's height, in degrees, from whatever clock the app is running.
*
* This is what turns the lights on. It is a separate call from `setLighting`
* and not a field on `LightingState` for the reason `scene.ts` gives for the
* city's identical pair: a `LightingState` is a rig, and how far below the
* horizon the sun is is a fact about the sky that the rig has already spent.
*/
setSolarElevation(degrees: number): void;
/**
* How much of the building's own light is on, 0..1, after the last
* `setSolarElevation`. The caller adds it to the rig — see `withHouseLights`
* in `daylight.ts`, and CONTRACT.md §4 on why this file does not.
*/
houseLevel(): number;
/**
* Who is moving about the floor, so the fittings above them can come up.
* Cheap; call it every frame. An empty list is the normal state.
*/
setWalkers(walkers: readonly Walker[]): void;
}
export function createOfficeScene(office: Office, options: OfficeSceneOptions): OfficeScene {
@@ -391,6 +424,38 @@ export function createOfficeScene(office: Office, options: OfficeSceneOptions):
...(options.registry ? { registry: options.registry } : {}),
...(options.colorFor ? { colorFor: options.colorFor } : {}),
});
/**
* The ceiling, made switchable.
*
* Built from the furnishings rather than from the pack, because what a
* fitting *is* has already been resolved by then: an id has been through the
* registry's override table, and a self-hoster who pointed
* `tera:light.troffer` at their own asset gets their fitting switched on
* rather than a fitting nobody placed.
*
* Harmless on a pack with no fittings — the list is empty, `tick` does
* nothing, and `houseLevel` still reports the hour so the caller's rig can
* make its own decision.
*/
const luminaires: Luminaires = createLuminaires(furnishings.luminaires);
const robots: RobotLayer | null =
options.robots && options.robots.length > 0
? createRobotLayer(plan, { materials, robots: options.robots })
: null;
if (robots) {
scene.add(robots.group);
/**
* Once, not per frame.
*
* `robots()` hands back a stable array of stable `Vector3`s that the layer
* mutates in place, so the luminaires are reading this frame's positions
* through a reference taken at setup. Calling it every frame would allocate
* nothing extra but would imply the array were a snapshot, which it is not.
*/
luminaires.setWalkers(robots.robots());
}
// A public office has no presence layer, rather than an empty one. The
// difference is not cosmetic: an empty `PresenceLayer` is a `THREE.Group`
// named "presence" hanging in the scene graph, a `setPresence` that works, and
@@ -676,6 +741,13 @@ export function createOfficeScene(office: Office, options: OfficeSceneOptions):
kit.applyLighting(state);
paintHorizon(state);
},
setSolarElevation(degrees) {
luminaires.setSolarElevation(degrees);
},
houseLevel: () => luminaires.houseLevel(),
setWalkers(walkers) {
luminaires.setWalkers(walkers);
},
// Stepping back out to the city should retire the hover with it, or the
// detail card for whoever the pointer was over survives the journey.
onExit: () => kit.resetPick(),
@@ -683,8 +755,14 @@ export function createOfficeScene(office: Office, options: OfficeSceneOptions):
if (disposed) return;
kit.tick(dt);
updateOcclusion();
// Robots first: the lights above them should respond to where they are
// *now*, not to where they were last frame.
robots?.tick(dt);
luminaires.tick(dt);
},
dispose() {
robots?.dispose();
luminaires.dispose();
if (horizonPlane) {
horizonPlane.geometry.dispose();
(horizonPlane.material as THREE.Material).dispose();