af0d4a7d57
**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>
198 lines
8.7 KiB
TypeScript
198 lines
8.7 KiB
TypeScript
/**
|
||
* Real daylight for a building that knows where it stands.
|
||
*
|
||
* CONTRACT.md §4 gives lighting one owner — `Atmosphere` — and says an office
|
||
* gets a fixed rig instead, with daylight through the windows named as "a later
|
||
* refinement, not a v1 coupling". This is that refinement, and it is written to
|
||
* keep the rule it is extending: **nothing here computes light.** `Atmosphere`
|
||
* still owns that. This takes the `LightingState` it produced for a place on the
|
||
* earth and answers the two questions an interior asks that a city never does.
|
||
*
|
||
* ### One: which way is the building pointing
|
||
*
|
||
* `Atmosphere` works in the city's frame, where −Z is true north because a city
|
||
* pack is a map. An office is a *building*, and buildings are rotated to face
|
||
* streets. `OfficeSite.heading` is the compass bearing the pack's −Z actually
|
||
* points along, and until the sun is turned by it, "the daylight side" in a
|
||
* pack's comments is a label rather than a fact — the light would come through
|
||
* whichever wall the author happened to draw at the top of the page.
|
||
*
|
||
* ### Two: where does the weather start
|
||
*
|
||
* A city's fog begins a kilometre away and that is fine, because a city is
|
||
* ninety kilometres across. An office is fifty metres across, and the same fog
|
||
* would sit *inside the room*, greying out the far wall and the people at it.
|
||
* So the colour is kept and the distances are replaced: clear air out to the
|
||
* building's own scale, haze beyond it, saturated long before the horizon plane
|
||
* ends. That is what turns a flat backdrop into a view.
|
||
*
|
||
* 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.
|
||
*/
|
||
|
||
import type { LightingState } from "../engine/types.ts";
|
||
import type { OfficeSite } from "./types.ts";
|
||
|
||
/**
|
||
* Where the clear air ends and the haze begins, in metres from the camera.
|
||
*
|
||
* It has to clear the whole *camera orbit*, and the orbit is centred on an
|
||
* authored viewpoint's target rather than on the middle of the building. The
|
||
* camera pulls back to 1.8 spans — 97 m for the 54 m hangar — and from an
|
||
* off-centre target the far roof corner is another fifty or so beyond that, so
|
||
* the real worst case is about 149 m rather than the 110 m the building's width
|
||
* alone suggests. 150 m clears it, and clears the tower pack's 137 m with room
|
||
* to spare.
|
||
*
|
||
* Panning moves the target, so no finite number is a guarantee. It does not need
|
||
* to be: the fog ramps at 0.025 % per metre, so being a few metres inside it is
|
||
* imperceptible rather than a visible grey wall.
|
||
*/
|
||
const FOG_NEAR_M = 150;
|
||
|
||
/**
|
||
* Where haze becomes total. Well inside `HORIZON_EXTENT` in `officeScene.ts`, on
|
||
* purpose: the ground plane has to reach full fog colour before its own edge, or
|
||
* the horizon ends in a visible seam rather than in distance.
|
||
*/
|
||
const FOG_FAR_M = 4200;
|
||
|
||
/**
|
||
* Turn a city-frame lighting state into an office-frame one.
|
||
*
|
||
* The input is whatever `Atmosphere.apply()` produced for `site.lat/lng` at the
|
||
* instant being rendered. The output differs in exactly two ways — the sun is
|
||
* rotated into the building's frame, and the fog is moved outdoors — and is
|
||
* 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 };
|
||
|
||
return {
|
||
...state,
|
||
sun: { ...state.sun, direction: intoBuildingFrame(state.sun.direction, site.heading) },
|
||
fog,
|
||
/**
|
||
* The sky's horizon stop is pinned to the fog colour, which is what makes
|
||
* the horizon a horizon instead of a seam.
|
||
*
|
||
* The sky is a **screen-space** gradient: `applyLighting` paints it top to
|
||
* bottom of the viewport, and it does not tilt with the camera. The ground
|
||
* plane, meanwhile, converges on the fog colour at the distance the fog
|
||
* saturates. So the two meet at whatever screen row the world horizon
|
||
* happens to fall on — which moves every time you orbit — and unless the
|
||
* colours they meet with are the same, that line is a visible step.
|
||
*
|
||
* Matching them makes the join invisible wherever it lands, with no
|
||
* per-frame work and no second piece of geometry. It costs the sky a little
|
||
* of the atmosphere's chosen horizon tint; a step across the middle of the
|
||
* frame costs more.
|
||
*/
|
||
sky: state.sky === null || fog === null ? state.sky : { ...state.sky, horizon: fog.color },
|
||
};
|
||
}
|
||
|
||
/**
|
||
* 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.
|
||
*
|
||
* The world frame is the city's: **−Z is true north, +X is east**. The building
|
||
* frame is the pack's, whose −Z points along the compass bearing `heading`.
|
||
*
|
||
* Both frames measure a bearing as `atan2(x, −z)`, so a direction at world
|
||
* bearing `B` is at building bearing `B − heading`, and expanding
|
||
* `sin(B − h)` and `−cos(B − h)` gives the two lines below. It is a rotation of
|
||
* `−heading` about +Y, written out rather than delegated to a `Vector3` because
|
||
* this module deliberately imports no three.js — the same reason `plan.ts`
|
||
* imports none, and what keeps it testable without a GL context.
|
||
*
|
||
* `y` is untouched: rotating about the vertical cannot change how high the sun
|
||
* is, only where on the compass it sits.
|
||
*/
|
||
export function intoBuildingFrame(
|
||
direction: [number, number, number],
|
||
heading: number,
|
||
): [number, number, number] {
|
||
const [x, y, z] = direction;
|
||
const h = (heading * Math.PI) / 180;
|
||
const cos = Math.cos(h);
|
||
const sin = Math.sin(h);
|
||
return [x * cos + z * sin, y, -x * sin + z * cos];
|
||
}
|