/** * 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]; }