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
+74
View File
@@ -95,6 +95,80 @@ export function officeDaylight(state: LightingState, site: OfficeSite): Lighting
};
}
/**
* The colour of the light a building makes for itself.
*
* Warm, and warmer than daylight on purpose: an office at night is lit at
* something like 3500 K against a 5500 K sun, and the shift is most of what
* makes an interior at night read as *interior* rather than as a badly exposed
* afternoon. It is also what stops the night rig looking like a dimmer switch
* on the day rig, which is what a neutral lift would give.
*/
const HOUSE_COLOR = 0xffe4bd;
/** Ambient and hemisphere added at full darkness. */
const HOUSE_AMBIENT = 0.5;
const HOUSE_HEMISPHERE = 0.85;
/**
* Add the building's own lights to a rig that has run out of sun.
*
* Kept here, next to the other adaptation of a `LightingState` for an interior,
* and kept **out** of `luminaires.ts` — that file drives the glowing panels and
* decides how much artificial light there is, and this one applies it, because
* CONTRACT.md §4 gives the rig one owner and two files writing lights is exactly
* what that rule exists to prevent.
*
* `level` is `Luminaires.houseLevel()`: 0 in daylight, 1 once the sun is down.
* At 0 this returns the state unchanged, so a daylit office pays nothing and
* looks identical to before any of this existed.
*
* Note what is **not** touched: `sun`. The sun is where the sun is, and at
* midnight it is below the floor contributing nothing. Interior light is
* ambient and hemispherical because that is what a ceiling of diffusers
* actually produces — a room lit from a hundred soft sources has almost no
* directional term, which is why offices at night have such flat shadows.
*/
export function withHouseLights(state: LightingState, level: number): LightingState {
const t = Math.min(1, Math.max(0, level));
if (t === 0) return state;
return {
...state,
ambient: {
// Blended toward the interior colour rather than replaced, so dusk — when
// both are running — does not jump between two different whites.
color: mixHex(state.ambient.color, HOUSE_COLOR, t),
intensity: state.ambient.intensity + HOUSE_AMBIENT * t,
},
hemisphere: {
sky: mixHex(state.hemisphere.sky, HOUSE_COLOR, t),
// The floor of a lit office bounces its own light back up, and leaving the
// ground term at the night sky's near-black is what makes a figure's legs
// vanish while their head is lit.
ground: mixHex(state.hemisphere.ground, HOUSE_COLOR, t * 0.6),
intensity: state.hemisphere.intensity + HOUSE_HEMISPHERE * t,
},
};
}
/**
* Blend two packed 0xRRGGBB colours.
*
* Per channel on the raw bytes, which is not a perceptual blend and does not
* need to be: both ends are near-white and the path between them stays there.
* Doing it by hand avoids constructing two `THREE.Color`s per frame in a module
* that deliberately imports no three.js.
*/
function mixHex(from: number, to: number, t: number): number {
const mix = (shift: number) => {
const a = (from >> shift) & 0xff;
const b = (to >> shift) & 0xff;
return Math.round(a + (b - a) * t) & 0xff;
};
return (mix(16) << 16) | (mix(8) << 8) | mix(0);
}
/**
* Rotate a world-frame direction into the building's frame.
*