feat: give the boards a horizon, a sea that reflects, and a state worth flying over
The wide shot, which is what an anonymous visitor actually lands on. **The sea was `MeshLambertMaterial`** — a material with no specular term at all, by construction — on a board where water is half the frame. It is now a low-roughness dielectric that reads `scene.environment`, with a runtime-generated tiling swell normal map sampled twice per fragment at two scales and two headings, so the sun breaks into a moving glitter path instead of a mirror point. An `onBeforeCompile` patch takes the body colour toward the deep value looking straight down and leaves it to the reflection at grazing, and walks roughness up past 1.6 board spans so the far water cannot shimmer. The swell spectrum is 1/k^2 and not 1/k because the first attempt was photographed: at 1/k every component carries the same slope, the shortest wave wins, and the sea renders as hard diagonal corduroy. A test holds it now. **The board no longer ends in a diamond.** The sea plane went from 1.8 board spans to 18, past the fog's far plane from anywhere the orbit reaches, and the sky is a world-space dome rather than a screen-space gradient. That gradient was wrong in a way dusk made obvious: the sunset band was painted along the *bottom* of the picture, under the board, while the true horizon at the top of frame stayed zenith blue. `daylight.ts` pinning the horizon stop to the fog colour to hide the seam was a symptom of it. **Terrain casts shadows.** Left off before because double-sided terrain against a ~16 m-per-texel shadow map gives acne; `shadowSide = BackSide` is the cure, shot at four sun elevations down to +0.0 degrees to confirm no stippling. The caster is a stride-2 decimation appended to the same index buffer and swapped in by `onBeforeShadow`/`onAfterShadow` via `drawRange`: no extra draw call, a quarter of the depth cost, and indistinguishable from the full-resolution caster in a side-by-side crop. Stride 1 was measured at +65,566 triangles and would have missed the budget by ~47,000, so it was not shipped. **California reads as California.** It was a beige kite: the eastern edge one ruled line for five degrees of latitude, the south closing in a diagonal V, the whole south-east a featureless tan wedge. Now the coast runs to the Mexican border with San Diego on it, the eastern edge follows the Colorado and the Nevada diagonal, and the south-east is the Basin and Range — forty parallel desert ridges throwing shadows east, Death Valley as a white pan between the Panamints and the Black Mountains, the Salton Sea the one cool value for two hundred kilometres. The opening pose is retuned to the bigger board; the old 452/392 stand-off left a slab of empty ocean where the state should be. **The aircraft were six pixels.** Measured, by enlarging a screenshot 200% to find one at all — indistinguishable from a dead pixel, on a board whose entire claim is that the sky is live. They are airliners now, with planform and trail, and clicking one raises its card for a signed-out visitor. **The Model X is off the wall.** It stood at floor level outside a studio 188 m up a Transbay tower, reading as a car balanced on a parapet. The apron is now chosen from `site.elevation`, which the pack already carries — not from an office id, which is the bug class this repo already hit once when a door marker gated on `id === "sf"` and would have pinned the Los Angeles building to San Francisco. Also fixed, and nearly shipped: sea z-fighting dithered every flat piece of ground on the Bay Area and SoCal boards. And one test asserted an exact source line for the water material, so the better multi-line implementation failed it — it now asserts the property (dielectric, metalness 0, low roughness) rather than the author's first guess at formatting. Tests 964 -> 1015. California desktop 562/650 draw calls and 728,744/750,000 triangles — 2.8% of triangle headroom left, which is the number the next person should check first. No budget was raised. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -27,6 +27,6 @@ export const ARENA_SOURCE_HASHES: Readonly<Record<string, ArenaSourceHashes>> =
|
||||
},
|
||||
"studio-ops-v1": {
|
||||
environment: "sha256:18375ef89e9f890356428a7b62fc6b48b94fc019dd8ca1ac05eabede6d70e03f",
|
||||
simulator: "sha256:6884955c43d7bf6488769b1c38a94a87591042322ee01ffb5e0ef013976dff1f",
|
||||
simulator: "sha256:b8d351b8b9d90b84e52e2601e37fa8995395c145a5d2ec9b939e0c3cb9ec5edc",
|
||||
},
|
||||
});
|
||||
|
||||
+15
-1
@@ -64,10 +64,24 @@ import type { SurfaceRole } from "./materials.ts";
|
||||
*/
|
||||
export const LIGHTNESS_HEADROOM = 0.22;
|
||||
|
||||
/**
|
||||
* The entries of `ScenePalette` that every pack is guaranteed to carry.
|
||||
*
|
||||
* `ScenePalette` has one optional colour — `alpine`, the bare rock above a tree
|
||||
* line, which only a board measured in hundreds of kilometres has any use for —
|
||||
* and an interior cannot descend from a colour that might not be there. Stated
|
||||
* as a type rather than as a runtime fallback, because the alternative is a
|
||||
* silent black carpet in somebody's office the first time an optional colour is
|
||||
* added to the palette.
|
||||
*/
|
||||
type RequiredPaletteKey = {
|
||||
[K in keyof ScenePalette]-?: undefined extends ScenePalette[K] ? never : K;
|
||||
}[keyof ScenePalette];
|
||||
|
||||
/** One role's derivation: a city colour, and how far to move it. */
|
||||
export interface RoleShift {
|
||||
/** Which entry of the city palette this role descends from. */
|
||||
from: keyof ScenePalette;
|
||||
from: RequiredPaletteKey;
|
||||
/** Hue shift in degrees. Wraps. */
|
||||
dh: number;
|
||||
/** Saturation shift, absolute, in 0..1. */
|
||||
|
||||
+1422
-56
File diff suppressed because it is too large
Load Diff
@@ -149,6 +149,22 @@ const NOSE_BASE_Z = 0.152;
|
||||
const TAIL_JOINT_Z = -0.055;
|
||||
const TAIL_TIP_Z = -0.21;
|
||||
|
||||
/**
|
||||
* Nose to tail, in scene units, at `scale = 1`.
|
||||
*
|
||||
* Exported because it is the unit `flights.ts` measures its legibility floor
|
||||
* in: that layer asks "how many of these does the glyph have to be for a person
|
||||
* to see it from here", and the answer has to be phrased in the length this
|
||||
* file actually built rather than in a 0.42 copied into another module and left
|
||||
* behind the day somebody shortens the tail cone.
|
||||
*
|
||||
* It is also the closest thing this aircraft has to a radius. Every extremity —
|
||||
* the wingtips at 0.24 from the origin, the fin tip at 0.22, the nose at 0.21 —
|
||||
* is inside a sphere of this radius with room to spare, which is what makes it
|
||||
* the right number for a pick volume as well as for a size.
|
||||
*/
|
||||
export const AIRLINER_LENGTH = NOSE_TIP_Z - TAIL_TIP_Z;
|
||||
|
||||
/**
|
||||
* How steeply the tail cone sweeps up, as a gradient (rise per unit of z).
|
||||
*
|
||||
|
||||
+60
-3
@@ -25,6 +25,37 @@ import { seededRandom, type World } from "./world.ts";
|
||||
const LOT = 0.42; // ~40 m at SF's scale
|
||||
const BLOCK_LOTS = 4; // 3 made streets a third of the city's surface
|
||||
|
||||
/**
|
||||
* The ground size at which a lot stops being a city block.
|
||||
*
|
||||
* `LOT` is fixed in **scene units**, which is right — the three boards are 1003,
|
||||
* 308 and 284 units across and are looked at from comparable standoffs, so a
|
||||
* lot that is legible on one is legible on the others. But it means a lot is
|
||||
* 40 m in San Francisco, 164 m in Southern California and **806 m** on the
|
||||
* statewide California board, and two things that are correct for a city are
|
||||
* wrong at 806 m:
|
||||
*
|
||||
* - **The street lattice.** Skipping every fourth row and column leaves 44%
|
||||
* of a district unbuilt. At 40 m those gaps are streets. At 806 m they are
|
||||
* eight-hundred-metre voids, and Los Angeles came out as a chequerboard of
|
||||
* separate white squares rather than as a city — the one thing the state
|
||||
* board most needed it to be. Above the threshold the lots tile, and the
|
||||
* district's `coverage` roll does all the thinning, which reads as urban
|
||||
* fabric because its gaps are irregular.
|
||||
* - **Casting shadows.** A shadow caster pays for itself twice, once in the
|
||||
* shadow pass and once in the beauty pass. A 40 m building on a San
|
||||
* Francisco hillside throws a shadow you can see; a 60 m building on a
|
||||
* 806 m lot throws about one pixel, and paying a second pass over a hundred
|
||||
* thousand triangles for it — on the board with the tightest budget of the
|
||||
* three — is not a trade anyone would make on purpose. Lambert still shades
|
||||
* the four walls, which is all the state camera can resolve anyway.
|
||||
* - **The underside.** Same measurement, same argument; see the geometry.
|
||||
*
|
||||
* 260 m is comfortably above Southern California's 164 and far below
|
||||
* California's 806, so neither of the detailed boards changes at all.
|
||||
*/
|
||||
const NEIGHBOURHOOD_LOT_METRES = 260;
|
||||
|
||||
const PALETTES = {
|
||||
downtown: [0xb9c3cc, 0xa8b4c0, 0xc7cfd6, 0x9dabb8, 0xd2d8dd, 0x8f9eaa],
|
||||
residential: [0xe8e2d6, 0xdcd3c4, 0xefe9dd, 0xd6cdbc, 0xe3d9c8, 0xcfc4b2, 0xf0ece2],
|
||||
@@ -108,6 +139,12 @@ export function createBlocks(
|
||||
const boxes: Box[] = [];
|
||||
let seedBase = 1337;
|
||||
|
||||
// See `NEIGHBOURHOOD_LOT_METRES`. One measurement, two decisions, and both of
|
||||
// them are about how much ground a lot covers rather than about which board
|
||||
// this is — a self-hoster's pack gets the same treatment without naming it.
|
||||
const lotMetres = LOT * world.metresPerUnit;
|
||||
const lotIsABlock = lotMetres <= NEIGHBOURHOOD_LOT_METRES;
|
||||
|
||||
for (const district of world.city.districts) {
|
||||
const rand = seededRandom(seedBase);
|
||||
seedBase += 7919;
|
||||
@@ -137,9 +174,9 @@ export function createBlocks(
|
||||
const steps = Math.ceil(reach / LOT);
|
||||
|
||||
for (let iu = -steps; iu <= steps; iu++) {
|
||||
if (((iu % BLOCK_LOTS) + BLOCK_LOTS) % BLOCK_LOTS === 0) continue; // street
|
||||
if (lotIsABlock && ((iu % BLOCK_LOTS) + BLOCK_LOTS) % BLOCK_LOTS === 0) continue; // street
|
||||
for (let iv = -steps; iv <= steps; iv++) {
|
||||
if (((iv % BLOCK_LOTS) + BLOCK_LOTS) % BLOCK_LOTS === 0) continue; // street
|
||||
if (lotIsABlock && ((iv % BLOCK_LOTS) + BLOCK_LOTS) % BLOCK_LOTS === 0) continue; // street
|
||||
|
||||
const u = (iu + (rand() - 0.5) * 0.34) * LOT;
|
||||
const v = (iv + (rand() - 0.5) * 0.34) * LOT;
|
||||
@@ -225,6 +262,26 @@ export function createBlocks(
|
||||
|
||||
const geometry = new THREE.BoxGeometry(1, 1, 1);
|
||||
geometry.translate(0, 0.5, 0); // pivot at the base, so y is ground level
|
||||
if (!lotIsABlock) {
|
||||
/**
|
||||
* Drop the underside at neighbourhood scale.
|
||||
*
|
||||
* `BoxGeometry` lays its groups out px, nx, py, ny, pz, nz, two triangles
|
||||
* each, so the six indices from 18 are the floor. A building sits on the
|
||||
* ground and that face is never visible — except on San Francisco's
|
||||
* steepest blocks, where a 40 m lot spanning a 3.6×-exaggerated hillside can
|
||||
* leave a corner clear of the terrain and you would see straight through the
|
||||
* hole. So this is tied to the same measurement as the street lattice and
|
||||
* the shadow pass: at 806 m to the lot the ground under a building is flat
|
||||
* to within a hair and nothing can get beneath it, and one sixth of the
|
||||
* board's largest triangle consumer goes back to the budget.
|
||||
*/
|
||||
const index = geometry.getIndex();
|
||||
if (index) {
|
||||
const kept = Array.from(index.array).filter((_, at) => at < 18 || at >= 24);
|
||||
geometry.setIndex(kept);
|
||||
}
|
||||
}
|
||||
|
||||
// The per-instance facade data, drawn from a stream of its own.
|
||||
//
|
||||
@@ -244,7 +301,7 @@ export function createBlocks(
|
||||
|
||||
const mesh = new THREE.InstancedMesh(geometry, new THREE.MeshLambertMaterial(), boxes.length);
|
||||
mesh.name = "blocks";
|
||||
mesh.castShadow = true;
|
||||
mesh.castShadow = lotIsABlock;
|
||||
mesh.receiveShadow = true;
|
||||
|
||||
const matrix = new THREE.Matrix4();
|
||||
|
||||
+250
-7
@@ -17,7 +17,7 @@
|
||||
*/
|
||||
|
||||
import * as THREE from "three";
|
||||
import { airlinerGeometry } from "./aircraftGeometry.ts";
|
||||
import { AIRLINER_LENGTH, airlinerGeometry } from "./aircraftGeometry.ts";
|
||||
import type { Aircraft, City, FlightSource } from "./types.ts";
|
||||
import { seededRandom, type World } from "./world.ts";
|
||||
|
||||
@@ -618,16 +618,23 @@ export function aircraftDetail(
|
||||
export interface FlightLayer {
|
||||
group: THREE.Group;
|
||||
/**
|
||||
* The aircraft meshes currently in the sky, as a **live** array, each
|
||||
* carrying `userData.aircraftId`.
|
||||
* What a pointer can hit, as a **live** array, each entry carrying
|
||||
* `userData.aircraftId`.
|
||||
*
|
||||
* Here rather than on the caller because only this layer knows which mesh is
|
||||
* Here rather than on the caller because only this layer knows which object is
|
||||
* which track: the map from id to mesh is private and the group's child order
|
||||
* is an artefact of when each aircraft appeared. It is the same shape
|
||||
* `MarkerLayer.pickables` publishes and it exists for the same reason — a
|
||||
* pick is resolved from the object that was hit, and something has to say
|
||||
* what the object stands for.
|
||||
*
|
||||
* What a ray meets is a **sphere** around the aeroplane rather than its
|
||||
* triangles — see `raycastGlyph` — because a seventeen-pixel glyph with
|
||||
* two-pixel wings is a game of marksmanship rather than an interface, and
|
||||
* under a fingertip it is not even that. The array itself holds one object per
|
||||
* *drawn* aircraft, and only while it is drawn: a track kept through a dropped
|
||||
* refresh stops being clickable at the moment it stops being visible.
|
||||
*
|
||||
* `owner-decisions.md` is why this is not gated on anything: an ADS-B
|
||||
* position is broadcast unencrypted to anybody with a receiver, so the card
|
||||
* it opens is available to an anonymous visitor and the picking that reaches
|
||||
@@ -741,6 +748,69 @@ const MAX_SPAN = 30;
|
||||
*/
|
||||
const JUMP_UNITS_PER_SECOND = 8;
|
||||
|
||||
/**
|
||||
* How much of the screen an aeroplane is never allowed to fall below.
|
||||
*
|
||||
* A fraction of the viewport's **height**, and the single number that decides
|
||||
* whether the live thing in this sky is visible at all.
|
||||
*
|
||||
* The arithmetic is unforgiving and it is worth writing down, because every
|
||||
* board in this repo lost it. A glyph `AIRLINER_LENGTH` long at distance `d`
|
||||
* covers `length / (2·d·tan(fov/2))` of the frame; at the 42° field of view
|
||||
* `scene.ts` uses that is `length / (0.767·d)`. The camera sits at up to two
|
||||
* board spans out, and a span is 230 units over San Francisco, 393 over the
|
||||
* Southland and about 580 across California — so a 0.42-unit aeroplane on the
|
||||
* default board came to **0.0005 of the frame, which is two thirds of one
|
||||
* pixel**. That is not a small aeroplane, it is a dead pixel, and a person who
|
||||
* has just arrived reads it as one: the feed was live, the callsigns were real,
|
||||
* and the whole layer was indistinguishable from a smudge on the monitor.
|
||||
*
|
||||
* So the glyph is given a floor in *apparent* size and grows with distance to
|
||||
* hold it. 0.016 is about seventeen pixels of aeroplane on a 1080-tall window
|
||||
* and eleven on a phone — the size a flight-tracker icon is drawn at, which is
|
||||
* the reference this is aiming for and not an accident. Below about 0.012 the
|
||||
* wings stop resolving and it degenerates into the cross it used to be; much
|
||||
* above 0.02 and a dozen of them start to look like a squadron flying formation
|
||||
* over a state, which is the cartoon this is trying not to be.
|
||||
*
|
||||
* Two things this deliberately is *not*:
|
||||
*
|
||||
* - It is not a size in metres. `aircraftGeometry.ts` already argues that
|
||||
* traffic here is a map symbol drawn in 3-D — 40 m over San Francisco, 164 m
|
||||
* over the Southland, the same 0.42 units on both — and a floor in screen
|
||||
* space is the same claim taken to its conclusion. What a person needs from
|
||||
* an aeroplane on a map is its position and its heading, and neither of
|
||||
* those is legible at half a pixel however truthful the span is.
|
||||
* - It is not applied unconditionally. The scale is `max(1, …)`, so the
|
||||
* authored geometry wins whenever the camera is close enough for it to be
|
||||
* legible on its own — about 34 units, or a chapter's worth of standoff.
|
||||
* Zooming in therefore *shrinks* an aeroplane back to the size the file that
|
||||
* drew it intended, rather than leaving a state-sized airliner parked over a
|
||||
* downtown.
|
||||
*/
|
||||
const GLYPH_MIN_SCREEN_FRACTION = 0.016;
|
||||
|
||||
/**
|
||||
* The radius of the sphere a pointer actually has to hit, in glyph lengths.
|
||||
*
|
||||
* The aeroplane's *triangles* are not the hit target and must not be. Even at
|
||||
* the floor above it is seventeen pixels of aeroplane, which is four pixels of
|
||||
* fuselage and a pair of wings a couple of pixels thick — a raycast against
|
||||
* those is a test of mouse marksmanship, and on a touch screen, where the tap
|
||||
* lands under a fingertip eight millimetres across, it is not winnable at all.
|
||||
* The owner's ask was that clicking a plane works for a stranger, and a target
|
||||
* you have to aim at does not.
|
||||
*
|
||||
* One glyph length gives a sphere two aeroplane-lengths across — about
|
||||
* thirty-four pixels at the legibility floor, which is a comfortable tap and is
|
||||
* still small enough that the pointer has to be *on* the aeroplane rather than
|
||||
* merely in the same part of the sky. Two aircraft whose spheres overlap still
|
||||
* resolve to the nearer one: `Raycaster` sorts its hits by distance, so what
|
||||
* wins is the aeroplane in front rather than the one that happened to be created
|
||||
* first.
|
||||
*/
|
||||
const PICK_RADIUS_GLYPHS = 1;
|
||||
|
||||
/**
|
||||
* Altitude, as colour.
|
||||
*
|
||||
@@ -842,6 +912,17 @@ interface TrailSample {
|
||||
|
||||
interface Track {
|
||||
mesh: THREE.Mesh;
|
||||
/**
|
||||
* Whether the aeroplane is currently in `pickables`.
|
||||
*
|
||||
* Tracked rather than derived because the answer changes on a rule the array
|
||||
* cannot see — a track that has gone stale is still held and still has a mesh,
|
||||
* and must stop being clickable the moment it stops being drawn. `Raycaster`
|
||||
* has not consulted `visible` since r119, so an aeroplane hidden by `tick` is
|
||||
* still a hit until somebody takes it out of the list, and a card raised on an
|
||||
* aeroplane nobody can see is a card about an aeroplane that is not there.
|
||||
*/
|
||||
picking: boolean;
|
||||
/** Observations, oldest first. The last is where the aircraft is heading. */
|
||||
samples: TrailSample[];
|
||||
/** Seconds the current leg should take: the measured gap between the last two. */
|
||||
@@ -911,6 +992,22 @@ export function createFlightLayer(world: World): FlightLayer {
|
||||
|
||||
const scratch = new THREE.Color();
|
||||
|
||||
/**
|
||||
* The camera the layer was last drawn for, or `null` before the first frame.
|
||||
*
|
||||
* The layer has no camera of its own and CONTRACT §1 is why it must not
|
||||
* acquire one: the camera belongs to the scene's `SceneKit`, and a layer that
|
||||
* took a second reference would have to be told when the scene swapped. It is
|
||||
* read off `onBeforeRender` instead, which is handed the camera actually being
|
||||
* rendered for — so an office looking at the same layer through a different
|
||||
* camera would size the glyphs for *that* view without anything having to be
|
||||
* wired up.
|
||||
*
|
||||
* Before the first frame there is no answer and the glyphs stay at their
|
||||
* authored size, which is the size they were before any of this existed.
|
||||
*/
|
||||
let viewer: THREE.PerspectiveCamera | null = null;
|
||||
|
||||
/**
|
||||
* One material per altitude band, built on demand.
|
||||
*
|
||||
@@ -966,7 +1063,18 @@ export function createFlightLayer(world: World): FlightLayer {
|
||||
// happen every frame: this line being drawn. `tick` is idempotent, so a scene
|
||||
// that would rather drive the layer itself can call it and nothing here
|
||||
// double-counts.
|
||||
trailLine.onBeforeRender = () => tick();
|
||||
// It also carries the camera in, which is the only reason this layer knows how
|
||||
// far away it is being looked at from — see `viewer`. Like every other write
|
||||
// `tick` makes, the scale it computes here lands on the *next* frame: world
|
||||
// matrices were resolved before any `onBeforeRender` ran. That is how this
|
||||
// layer's position and attitude have always worked, it is one frame at 60 Hz,
|
||||
// and the alternative is a camera reference this file is not entitled to hold.
|
||||
trailLine.onBeforeRender = (_renderer, _scene, camera) => {
|
||||
if ((camera as THREE.PerspectiveCamera).isPerspectiveCamera) {
|
||||
viewer = camera as THREE.PerspectiveCamera;
|
||||
}
|
||||
tick();
|
||||
};
|
||||
group.add(trailLine);
|
||||
|
||||
// ---- Observations -------------------------------------------------------
|
||||
@@ -990,10 +1098,14 @@ export function createFlightLayer(world: World): FlightLayer {
|
||||
// The id, on the object, so a raycast hit resolves to an aeroplane
|
||||
// without this layer having to expose its private track table.
|
||||
mesh.userData.aircraftId = a.id;
|
||||
// The pointer aims at the aeroplane and hits a sphere around it. See
|
||||
// `raycastGlyph`.
|
||||
mesh.raycast = raycastGlyph;
|
||||
group.add(mesh);
|
||||
pickables.push(mesh);
|
||||
track = {
|
||||
mesh,
|
||||
picking: true,
|
||||
samples: [],
|
||||
span: MIN_SPAN,
|
||||
pitch: 0,
|
||||
@@ -1112,15 +1224,38 @@ export function createFlightLayer(world: World): FlightLayer {
|
||||
}
|
||||
if (track.missingSince === 0) track.missingSince = now;
|
||||
if (now - track.missingSince < TRACK_GRACE_SECONDS) continue;
|
||||
setPickable(track, false);
|
||||
// The geometry and the material are shared by every aircraft in the sky
|
||||
// and belong to the layer, which frees them once in `dispose`.
|
||||
group.remove(track.mesh);
|
||||
const at = pickables.indexOf(track.mesh);
|
||||
if (at >= 0) pickables.splice(at, 1);
|
||||
tracks.delete(id);
|
||||
}
|
||||
|
||||
tick();
|
||||
}
|
||||
|
||||
/**
|
||||
* Put a track into the pick list, or take it out.
|
||||
*
|
||||
* Idempotent, and the flag is what makes it cheap: a hundred aircraft holding
|
||||
* station would otherwise walk the array with `indexOf` on every frame to
|
||||
* discover that nothing had changed.
|
||||
*
|
||||
* `pickables` is spliced rather than rebuilt because `scene.ts` hands the
|
||||
* array itself to the picker as a live target list — see `FlightLayer` — so
|
||||
* the identity of the array has to survive.
|
||||
*/
|
||||
function setPickable(track: Track, on: boolean) {
|
||||
if (track.picking === on) return;
|
||||
track.picking = on;
|
||||
if (on) {
|
||||
pickables.push(track.mesh);
|
||||
return;
|
||||
}
|
||||
const at = pickables.indexOf(track.mesh);
|
||||
if (at >= 0) pickables.splice(at, 1);
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether a feed has handed back the same position it did last time.
|
||||
*
|
||||
@@ -1176,12 +1311,34 @@ export function createFlightLayer(world: World): FlightLayer {
|
||||
*/
|
||||
track.stale = track.missingSince !== 0 && now - to.at > track.span;
|
||||
track.mesh.visible = !track.stale;
|
||||
// An aeroplane that is no longer drawn must no longer be clickable, and
|
||||
// saying so is not optional: `Raycaster` does not consult `visible`, so a
|
||||
// hidden track left in the list goes on opening its card from a patch of
|
||||
// empty sky for the rest of the grace period.
|
||||
setPickable(track, !track.stale);
|
||||
if (track.stale) continue;
|
||||
|
||||
track.head.lerpVectors(from.position, to.position, alpha);
|
||||
track.headAltitude = from.altitude + (to.altitude - from.altitude) * alpha;
|
||||
|
||||
track.mesh.position.copy(track.head);
|
||||
/**
|
||||
* Big enough to be an aeroplane from wherever this is being watched.
|
||||
*
|
||||
* Per aircraft rather than once for the layer, because the board is deep:
|
||||
* on the California corridor an arrival over Los Angeles and one over the
|
||||
* Bay are hundreds of units apart along the view axis, and a single scale
|
||||
* taken from the camera's orbit distance would leave the far one half the
|
||||
* size of the near one — which reads as depth on a photograph and as an
|
||||
* inconsistency on a map, where two aeroplanes at the same altitude are
|
||||
* the same aeroplane.
|
||||
*
|
||||
* Uniform, so nothing about the shape changes: this is the glyph the
|
||||
* geometry file drew, held at a legible size, and not a stretched one.
|
||||
*/
|
||||
if (viewer !== null) {
|
||||
track.mesh.scale.setScalar(glyphScale(viewer.position.distanceTo(track.head), viewer.fov));
|
||||
}
|
||||
// A heading of 0 is north, and north is -z, so an aircraft whose nose is
|
||||
// modelled along +z has to be turned all the way round before the compass
|
||||
// and the scene agree. The previous mapping was a bare negation of the
|
||||
@@ -1351,6 +1508,92 @@ export function createFlightLayer(world: World): FlightLayer {
|
||||
|
||||
|
||||
|
||||
/**
|
||||
* How much to enlarge an aeroplane so that it is still an aeroplane from here.
|
||||
*
|
||||
* Pure arithmetic on two numbers the camera already knows, extracted so it can
|
||||
* be tested without a WebGL context — the defect it exists to fix is a *visual*
|
||||
* one and can only be confirmed with a picture, but the ratio it turns on is
|
||||
* exactly the kind of thing that regresses silently under a refactor.
|
||||
*
|
||||
* `2·distance·tan(fov/2)` is the world-space height of the frustum at that
|
||||
* distance — the ruler the frame is measured with — so the glyph's share of the
|
||||
* screen is its length over that. Solving for the length that hits
|
||||
* `GLYPH_MIN_SCREEN_FRACTION` and dividing by the length the geometry was
|
||||
* authored at gives the scale, and `Math.max(1, …)` is the floor rather than a
|
||||
* fit: an aeroplane close enough to read at its authored size keeps it.
|
||||
*
|
||||
* Degenerate inputs return 1 rather than throwing. A camera at zero distance
|
||||
* from an aircraft is the chase view, a camera with no field of view is a
|
||||
* caller in the middle of setting one up, and neither is a reason for the sky
|
||||
* to disappear.
|
||||
*/
|
||||
export function glyphScale(distance: number, fovDegrees: number): number {
|
||||
if (!Number.isFinite(distance) || !Number.isFinite(fovDegrees)) return 1;
|
||||
if (distance <= 0 || fovDegrees <= 0 || fovDegrees >= 180) return 1;
|
||||
const frustumHeight = 2 * distance * Math.tan((fovDegrees * Math.PI) / 360);
|
||||
return Math.max(1, (GLYPH_MIN_SCREEN_FRACTION * frustumHeight) / AIRLINER_LENGTH);
|
||||
}
|
||||
|
||||
/**
|
||||
* Scratch for `raycastGlyph`. Module-level and reused: a raycast runs once a
|
||||
* frame against every aeroplane in the sky, and four hundred of them allocating
|
||||
* a `Sphere` and a `Vector3` apiece is a garbage-collection pause a pointer can
|
||||
* feel.
|
||||
*/
|
||||
const pickSphere = new THREE.Sphere();
|
||||
const pickPoint = new THREE.Vector3();
|
||||
const pickScale = new THREE.Vector3();
|
||||
|
||||
/**
|
||||
* What a pointer hits when it aims at an aeroplane: a sphere, not the aeroplane.
|
||||
*
|
||||
* Assigned onto each aircraft mesh in place of `Mesh.raycast`, which is the
|
||||
* extension point three.js publishes for exactly this — `Object3D.raycast` is
|
||||
* documented as the method a subclass or an instance supplies to say how it
|
||||
* meets a ray, and `Points` and `Line` already answer it with a threshold
|
||||
* instead of with geometry for the same reason this does.
|
||||
*
|
||||
* The reason is `PICK_RADIUS_GLYPHS`: at any camera distance where this layer is
|
||||
* worth looking at, the aeroplane's own triangles are a few pixels of fuselage
|
||||
* and a wing two pixels thick, and a pointer test against those is a game of
|
||||
* marksmanship rather than an interface. A sphere at the glyph's own scale is
|
||||
* the target a person thinks they are aiming at.
|
||||
*
|
||||
* Doing it here rather than with a second invisible object in the scene is worth
|
||||
* the unusual assignment. A proxy mesh would be another `Object3D` per aircraft
|
||||
* — four hundred more nodes to walk and four hundred more world matrices to
|
||||
* compose every frame, for something that is never drawn — and it would have to
|
||||
* be kept in step with the aeroplane's position and scale by hand. This costs
|
||||
* one sphere test, allocates nothing, and cannot drift out of step because there
|
||||
* is nothing to keep in step with.
|
||||
*
|
||||
* The scale is read off `matrixWorld` rather than off `this.scale` on purpose:
|
||||
* the layer's group is at the identity today, and a raycast that silently starts
|
||||
* lying if somebody ever moves or scales it is precisely the class of bug this
|
||||
* file's comments keep recording.
|
||||
*/
|
||||
function raycastGlyph(
|
||||
this: THREE.Mesh,
|
||||
raycaster: THREE.Raycaster,
|
||||
intersects: THREE.Intersection[],
|
||||
): void {
|
||||
pickSphere.center.setFromMatrixPosition(this.matrixWorld);
|
||||
pickScale.setFromMatrixScale(this.matrixWorld);
|
||||
pickSphere.radius = PICK_RADIUS_GLYPHS * AIRLINER_LENGTH * pickScale.x;
|
||||
// `intersectSphere` answers with the near hit, or with the far one when the
|
||||
// ray starts inside — so a camera flying through the sphere still picks the
|
||||
// aeroplane it is inside rather than nothing at all.
|
||||
if (raycaster.ray.intersectSphere(pickSphere, pickPoint) === null) return;
|
||||
const distance = raycaster.ray.origin.distanceTo(pickPoint);
|
||||
// The near/far clamp is the caller's contract and `Mesh.raycast` honours it;
|
||||
// an aeroplane behind the camera must not be pickable through the back of it.
|
||||
if (distance < raycaster.near || distance > raycaster.far) return;
|
||||
// Cloned rather than shared: the caller keeps the intersection, and every hit
|
||||
// in a frame would otherwise be handed the same point object.
|
||||
intersects.push({ distance, point: pickPoint.clone(), object: this });
|
||||
}
|
||||
|
||||
/**
|
||||
* The climb angle of a leg, from the real numbers rather than the scene's.
|
||||
*
|
||||
|
||||
@@ -7,6 +7,18 @@
|
||||
* a charge post, and a Model X parked in it whose lamps and cabin reflect a live
|
||||
* {@link VehicleTelemetryState}.
|
||||
*
|
||||
* ### Not every anchor gets one
|
||||
*
|
||||
* An apron is ground, and one shipped site has none: `lumbridge-hq`'s studio
|
||||
* floor is 188 m above the pavement outside it. A pack cannot author a stall
|
||||
* anywhere but its own plan frame, so its anchor is a podium kerb described at
|
||||
* a storey that is nowhere near the podium — and drawn literally, that is a
|
||||
* paved pad and a car hanging in open sky beside a wall, which is what this
|
||||
* product shipped for one build. `createOfficeExterior` therefore returns an
|
||||
* inert, empty exterior for a site whose arrival storey is not on the ground.
|
||||
* The rule is `arrivalGroundFor(site.elevation)` and is discussed where it
|
||||
* lives, in `transport/exteriorVehicle.ts`.
|
||||
*
|
||||
* ### Why the car stands here rather than on the board
|
||||
*
|
||||
* There has been a Model X in this product since the freeway corridor shipped,
|
||||
@@ -53,8 +65,8 @@ import {
|
||||
} from "../assets/vehicles/index.ts";
|
||||
import type { ExteriorArrival, OfficeSite } from "../interiors/types.ts";
|
||||
import {
|
||||
apronKindFor,
|
||||
apronMetrics,
|
||||
arrivalGroundFor,
|
||||
exteriorVehicleAppearance,
|
||||
lampTint,
|
||||
parkPose,
|
||||
@@ -137,11 +149,42 @@ export function createOfficeExterior(options: OfficeExteriorOptions): OfficeExte
|
||||
if (arrival.label) root.userData.label = arrival.label;
|
||||
if (site.label) root.userData.siteLabel = site.label;
|
||||
|
||||
const kind = apronKindFor(site.elevation);
|
||||
const metrics = apronMetrics(
|
||||
{ length: MODEL_X_METRICS.length, width: MODEL_X_METRICS.width },
|
||||
kind,
|
||||
);
|
||||
// ---- Is there anywhere to put it? ---------------------------------------
|
||||
//
|
||||
// Everything below this line asserts ground: a paved pad, a 135 mm kerb, a
|
||||
// charge post bolted to it and a car standing on all three. `site.elevation`
|
||||
// is the one number that says whether that ground exists — it measures the
|
||||
// arrival storey's floor against **the ground outside**, not against sea level
|
||||
// — and above `GROUND_ARRIVAL_MAX_ELEVATION_M` it does not.
|
||||
//
|
||||
// The alternative shipped for one build and is the reason this check exists:
|
||||
// `lumbridge-hq` is 188 m up a Transbay tower, the apron was built at its
|
||||
// floor because that is the frame the stall is authored in, and the arrival
|
||||
// viewpoint showed a Model X standing on top of a wall with nothing under it.
|
||||
// See `ArrivalGround` for the two other repairs that were weighed and why the
|
||||
// rule is derived from the site rather than from an office id.
|
||||
//
|
||||
// An inert exterior rather than `null` on purpose. The caller adds this object
|
||||
// to the level group and calls `apply()` on every telemetry publish; making it
|
||||
// return an empty root keeps that caller free of a question about architecture
|
||||
// it should not have to ask, and an empty `Group` costs no draw call and no
|
||||
// triangle. `setVehicleTelemetry` already documents itself as a possible
|
||||
// no-op.
|
||||
if (arrivalGroundFor(site.elevation) === "air") {
|
||||
root.userData.suppressed = "no-ground";
|
||||
return {
|
||||
object: root,
|
||||
apply() {},
|
||||
dispose() {
|
||||
root.clear();
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const metrics = apronMetrics({
|
||||
length: MODEL_X_METRICS.length,
|
||||
width: MODEL_X_METRICS.width,
|
||||
});
|
||||
|
||||
// Geometries this layer minted and must free. Registry materials are not in
|
||||
// here and must not be: they belong to the office this apron stands outside.
|
||||
@@ -158,8 +201,7 @@ export function createOfficeExterior(options: OfficeExteriorOptions): OfficeExte
|
||||
apron.rotation.y = arrival.rotation;
|
||||
root.add(apron);
|
||||
|
||||
const pavingColor = kind === "street" ? 0x6f7370 : 0x8d908a;
|
||||
const paving = materials.tinted("polishedConcrete", pavingColor);
|
||||
const paving = materials.tinted("polishedConcrete", 0x6f7370);
|
||||
const kerbMaterial = materials.get("skirting");
|
||||
const lineMaterial = materials.tinted("polishedConcrete", 0xd9d8cd);
|
||||
const postShell = materials.get("deviceShell");
|
||||
|
||||
+89
-4
@@ -341,6 +341,15 @@ export async function createScene(
|
||||
);
|
||||
|
||||
const orbitMinDistance = Math.max(4, boardSpan * 0.02);
|
||||
/**
|
||||
* The far end of the orbit, held rather than written twice.
|
||||
*
|
||||
* `chapterPose` below has to know it: a pose beyond it is not obeyed, it is
|
||||
* silently clamped by `OrbitControls` on the next update, so a framing
|
||||
* correction that asks for more than this is a framing correction that does
|
||||
* nothing. See the note on the option itself for where 2.0 comes from.
|
||||
*/
|
||||
const orbitMaxDistance = boardSpan * 2.0;
|
||||
const kit = createSceneKit({
|
||||
scene,
|
||||
dom: stage.renderer.domElement,
|
||||
@@ -373,7 +382,7 @@ export async function createScene(
|
||||
* The far plane already covers it: the furthest thing from the camera is then
|
||||
* the back of the dome at 2.7 spans, against `far` at 3.0.
|
||||
*/
|
||||
maxDistance: boardSpan * 2.0,
|
||||
maxDistance: orbitMaxDistance,
|
||||
shadowExtent: boardSpan * 0.75,
|
||||
/**
|
||||
* The middle of the board, which is **not** the origin.
|
||||
@@ -566,12 +575,18 @@ export async function createScene(
|
||||
function chapterPose(ch: Chapter): Pose {
|
||||
const [x, z] = world.project(ch.focus.lat, ch.focus.lng);
|
||||
const groundY = world.groundAt(ch.focus.lat, ch.focus.lng);
|
||||
const scale = chapterFraming({
|
||||
aspect: kit.camera.aspect,
|
||||
reach: Math.hypot(ch.focus.distance, ch.focus.height),
|
||||
boardSpan,
|
||||
orbitMax: orbitMaxDistance,
|
||||
});
|
||||
return {
|
||||
target: new THREE.Vector3(x, groundY, z),
|
||||
position: new THREE.Vector3(
|
||||
x + Math.sin(ch.focus.rotation) * ch.focus.distance,
|
||||
groundY + ch.focus.height,
|
||||
z + Math.cos(ch.focus.rotation) * ch.focus.distance,
|
||||
x + Math.sin(ch.focus.rotation) * ch.focus.distance * scale,
|
||||
groundY + ch.focus.height * scale,
|
||||
z + Math.cos(ch.focus.rotation) * ch.focus.distance * scale,
|
||||
),
|
||||
};
|
||||
}
|
||||
@@ -862,6 +877,76 @@ export async function createScene(
|
||||
* It exists so the engine renders with no server, no clock and no config, which
|
||||
* is the acceptance test the whole repo is held to.
|
||||
*/
|
||||
/**
|
||||
* How wide a screen a `Chapter.focus` was written for.
|
||||
*
|
||||
* Every pose in every pack was authored on one, and `fov: 42` is a **vertical**
|
||||
* field of view: the horizontal half-angle is `atan(tan(21°) × aspect)`, so how
|
||||
* much of a board is in frame sideways is entirely a fact about the shape of the
|
||||
* window. At 16:10 that half-angle is 31.6°. On a phone held upright — 390 × 844,
|
||||
* an aspect of 0.46 — it is 10.1°, a third as wide, and the pose that framed a
|
||||
* whole state on a laptop frames the middle third of it and runs the rest off
|
||||
* both sides of the screen.
|
||||
*/
|
||||
const AUTHORED_ASPECT = 1.6;
|
||||
|
||||
/**
|
||||
* Where a stand-off stops being a whole-board shot and starts being a place on
|
||||
* it, in board spans. See `chapterFraming`.
|
||||
*/
|
||||
const WHOLE_BOARD_FROM = 0.8;
|
||||
const WHOLE_BOARD_TO = 1.2;
|
||||
|
||||
/**
|
||||
* How much further back to stand than the pose asked for, given the window it
|
||||
* is actually being looked at through.
|
||||
*
|
||||
* Returns a multiplier applied to `distance` **and** `height` together, so the
|
||||
* angle the board is seen from — which is most of the character of a pack's
|
||||
* opening shot — is exactly preserved and only the stand-off changes.
|
||||
*
|
||||
* Three things bound it, and the second and third were both learned from a
|
||||
* screenshot rather than from the arithmetic.
|
||||
*
|
||||
* - **The aspect.** The correction wanted is `AUTHORED_ASPECT / aspect`,
|
||||
* because the width in frame at a given distance is linear in the aspect.
|
||||
* A window at least as wide as the authored one gets nothing at all.
|
||||
* - **The orbit's own ceiling.** `setPose` hands the camera to
|
||||
* `OrbitControls`, which clamps to `maxDistance` on its next update, so a
|
||||
* correction that asks for more than that is not a correction — the pose
|
||||
* quietly becomes something nobody wrote.
|
||||
* - **Whether the pose was ever about the whole board.** Correcting every
|
||||
* pose was tried and it ruins the close-ups: Southern California's opening
|
||||
* shot stands off half a board span and is a dense city filling a tall
|
||||
* screen to all four edges, and pulling it back three times put it in the
|
||||
* top third of the frame over half a screen of empty ocean. Widening the
|
||||
* frame is a correction for a shot that was trying to hold something and
|
||||
* no longer can; on a shot that was deliberately *inside* its subject it is
|
||||
* a different photograph. Below `WHOLE_BOARD_FROM` spans of stand-off the
|
||||
* correction is off; above `WHOLE_BOARD_TO` it is fully on.
|
||||
*/
|
||||
export function chapterFraming(options: {
|
||||
aspect: number;
|
||||
reach: number;
|
||||
boardSpan: number;
|
||||
orbitMax: number;
|
||||
}): number {
|
||||
const { aspect, reach, boardSpan, orbitMax } = options;
|
||||
if (!Number.isFinite(reach) || reach <= 0) return 1;
|
||||
const wanted =
|
||||
Number.isFinite(aspect) && aspect > 0 && aspect < AUTHORED_ASPECT
|
||||
? AUTHORED_ASPECT / aspect
|
||||
: 1;
|
||||
const ceiling = Number.isFinite(orbitMax) && orbitMax > 0 ? orbitMax / reach : 1;
|
||||
const wide = Math.max(1, Math.min(wanted, ceiling));
|
||||
const spans = Number.isFinite(boardSpan) && boardSpan > 0 ? reach / boardSpan : 0;
|
||||
const share = Math.max(
|
||||
0,
|
||||
Math.min(1, (spans - WHOLE_BOARD_FROM) / (WHOLE_BOARD_TO - WHOLE_BOARD_FROM)),
|
||||
);
|
||||
return 1 + (wide - 1) * share;
|
||||
}
|
||||
|
||||
export function cityDaylight(palette: ScenePalette, boardSpan = 230): LightingState {
|
||||
return {
|
||||
sun: { direction: [-0.632, 0.717, 0.295], color: 0xfff3e0, intensity: 2.1 },
|
||||
|
||||
+225
-33
@@ -51,6 +51,20 @@ const TOUCH_ROTATE_SCALE = 0.7;
|
||||
const TAP_SLOP = 12;
|
||||
/** How long a finger may rest and still be a tap, in ms. */
|
||||
const TAP_MS = 400;
|
||||
/**
|
||||
* How long after a touch a `pointerType: "mouse"` event is assumed to be the
|
||||
* browser's compatibility replay of that touch rather than a real mouse.
|
||||
*
|
||||
* Chrome finishes a tap by re-dispatching it as mouse events for pages written
|
||||
* before pointer events existed, and the tail of that replay is a
|
||||
* `pointerout`/`pointerleave` pair whose `pointerType` is `"mouse"`. Measured on
|
||||
* the deployed build it lands about 32 ms after the tap; 800 ms is far enough
|
||||
* out to cover a loaded phone and still shorter than any deliberate reach for a
|
||||
* trackpad. Being wrong in this direction costs a hybrid laptop one stale card
|
||||
* until the mouse moves again; being wrong in the other direction means no
|
||||
* detail card can ever be read on a phone at all.
|
||||
*/
|
||||
const COMPAT_MOUSE_MS = 800;
|
||||
|
||||
/** Where the camera sits and what it looks at. Scene units, whatever they mean. */
|
||||
export interface Pose {
|
||||
@@ -319,9 +333,8 @@ export function createSceneKit(options: SceneKitOptions): SceneKit {
|
||||
sun.target.updateMatrixWorld();
|
||||
|
||||
const sunDirection = new THREE.Vector3();
|
||||
let sky: THREE.Texture | null = null;
|
||||
let skyTop = -1;
|
||||
let skyHorizon = -1;
|
||||
const dome = makeSkyDome();
|
||||
let domeAttached = false;
|
||||
|
||||
function applyLighting(state: LightingState) {
|
||||
const [dx, dy, dz] = state.sun.direction;
|
||||
@@ -358,14 +371,34 @@ export function createSceneKit(options: SceneKitOptions): SceneKit {
|
||||
ambient.color.setHex(state.ambient.color);
|
||||
ambient.intensity = state.ambient.intensity;
|
||||
|
||||
// A null sky leaves `scene.background` alone entirely, which is what an
|
||||
// office wants: it has walls, and whatever is behind them is not sky.
|
||||
if (state.sky && (state.sky.top !== skyTop || state.sky.horizon !== skyHorizon)) {
|
||||
sky?.dispose();
|
||||
sky = makeSkyTexture(state.sky.top, state.sky.horizon);
|
||||
skyTop = state.sky.top;
|
||||
skyHorizon = state.sky.horizon;
|
||||
scene.background = sky;
|
||||
// A null sky leaves the background alone entirely, which is what an office
|
||||
// wants: it has walls, and whatever is behind them is not sky.
|
||||
if (state.sky) {
|
||||
if (!domeAttached) {
|
||||
scene.add(dome);
|
||||
domeAttached = true;
|
||||
}
|
||||
const u = dome.material.uniforms;
|
||||
(u.uTop!.value as THREE.Color).setHex(state.sky.top, THREE.LinearSRGBColorSpace);
|
||||
(u.uHorizon!.value as THREE.Color).setHex(state.sky.horizon, THREE.LinearSRGBColorSpace);
|
||||
(u.uSunColor!.value as THREE.Color).setHex(state.sun.color, THREE.LinearSRGBColorSpace);
|
||||
(u.uSunDirection!.value as THREE.Vector3).set(dx, dy, dz).normalize();
|
||||
/**
|
||||
* The glow follows the *key*, not the direction, and that is what keeps
|
||||
* it off the night sky.
|
||||
*
|
||||
* `atmosphere.ts` floors the sun's direction at `shadowFloorDeg` — seven
|
||||
* degrees — so that the shadow camera stays usable, which means the
|
||||
* vector in a `LightingState` never actually sets. Taken literally it
|
||||
* would park a sunrise on the horizon all night, at the azimuth the sun
|
||||
* went down at. The intensity is the honest signal: it collapses through
|
||||
* dusk and what is left at 2 a.m. is the moon's, so scaling by it gives a
|
||||
* glow that fades out with the daylight it belongs to.
|
||||
*/
|
||||
u.uSunGlow!.value = Math.min(1, Math.max(0, state.sun.intensity / SUN_GLOW_FULL_INTENSITY));
|
||||
} else if (domeAttached) {
|
||||
scene.remove(dome);
|
||||
domeAttached = false;
|
||||
}
|
||||
|
||||
if (!state.fog) {
|
||||
@@ -481,10 +514,13 @@ export function createSceneKit(options: SceneKitOptions): SceneKit {
|
||||
let tapX = 0;
|
||||
let tapY = 0;
|
||||
let tapAt = 0;
|
||||
/** When the glass was last touched, in `event.timeStamp` units. */
|
||||
let lastTouchAt = Number.NEGATIVE_INFINITY;
|
||||
|
||||
function onPointerDown(event: PointerEvent) {
|
||||
applyPointerProfile(event.pointerType);
|
||||
if (event.pointerType !== "touch") return;
|
||||
lastTouchAt = event.timeStamp;
|
||||
resetPick();
|
||||
tapPointer = tapPointer === -1 ? event.pointerId : -2;
|
||||
tapX = event.clientX;
|
||||
@@ -495,6 +531,7 @@ export function createSceneKit(options: SceneKitOptions): SceneKit {
|
||||
|
||||
function onPointerUp(event: PointerEvent) {
|
||||
if (event.pointerType !== "touch") return;
|
||||
lastTouchAt = event.timeStamp;
|
||||
const wasTap =
|
||||
tapPointer === event.pointerId &&
|
||||
event.timeStamp - tapAt <= TAP_MS &&
|
||||
@@ -514,11 +551,28 @@ export function createSceneKit(options: SceneKitOptions): SceneKit {
|
||||
wasPicking?.onChange(null);
|
||||
}
|
||||
|
||||
// Not for touch. A finger lifting fires `pointerleave` immediately after
|
||||
// `pointerup`, so honouring it here would wipe the pick a tap had just made,
|
||||
// in the same frame, every time.
|
||||
/*
|
||||
* Not for touch, and not for the compatibility mouse either.
|
||||
*
|
||||
* A finger lifting fires `pointerleave` immediately after `pointerup`, so
|
||||
* honouring that would wipe the pick a tap had just made, in the same frame,
|
||||
* every time. That much was anticipated. What was not is the *second* leave:
|
||||
* Chrome replays a finished tap as legacy mouse events, and the recorded tail
|
||||
* of a real tap on the canvas is
|
||||
*
|
||||
* pointerdown/touch, pointerup/touch, pointerout/touch, pointerleave/touch,
|
||||
* mousemove, click/touch, pointerout/MOUSE, pointerleave/MOUSE
|
||||
*
|
||||
* — so the last event of a tap is a `pointerleave` claiming to be a mouse,
|
||||
* about 32 ms later. Filtering on `pointerType` alone let that one through,
|
||||
* which called `resetPick()` and fired `onChange(null)`: the card was written
|
||||
* to the page and blanked before a thumb had left the glass, and no detail
|
||||
* card of any kind could be read on a phone. It is a clock that tells these
|
||||
* apart, not a type.
|
||||
*/
|
||||
function onPointerLeave(event: PointerEvent) {
|
||||
if (event.pointerType === "touch") return;
|
||||
if (event.timeStamp - lastTouchAt < COMPAT_MOUSE_MS) return;
|
||||
resetPick();
|
||||
}
|
||||
dom.addEventListener("pointerleave", onPointerLeave);
|
||||
@@ -606,29 +660,167 @@ export function createSceneKit(options: SceneKitOptions): SceneKit {
|
||||
sun.dispose();
|
||||
hemisphere.dispose();
|
||||
ambient.dispose();
|
||||
sky?.dispose();
|
||||
if (scene.background === sky) scene.background = null;
|
||||
if (domeAttached) scene.remove(dome);
|
||||
domeAttached = false;
|
||||
dome.geometry.dispose();
|
||||
dome.material.dispose();
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* A two-pixel-wide vertical gradient. Cheap, and a `Scene.background` texture
|
||||
* is stretched to fill regardless, so the width buys nothing.
|
||||
* The sun intensity at which the sky's glow around it is at full strength.
|
||||
*
|
||||
* `atmosphere.ts`'s day stops sit at 2.6, so this is reached a little before
|
||||
* noon and held; everything below it — the whole of dusk and all of the
|
||||
* night — scales down from there. See the note at the call site for why the
|
||||
* intensity and not the direction is what the glow is allowed to read.
|
||||
*/
|
||||
function makeSkyTexture(top: number, horizon: number): THREE.Texture {
|
||||
const canvas = document.createElement("canvas");
|
||||
canvas.width = 2;
|
||||
canvas.height = 256;
|
||||
const ctx = canvas.getContext("2d");
|
||||
if (!ctx) throw new Error("2D canvas context unavailable");
|
||||
const grad = ctx.createLinearGradient(0, 0, 0, 256);
|
||||
grad.addColorStop(0, `#${top.toString(16).padStart(6, "0")}`);
|
||||
grad.addColorStop(1, `#${horizon.toString(16).padStart(6, "0")}`);
|
||||
ctx.fillStyle = grad;
|
||||
ctx.fillRect(0, 0, 2, 256);
|
||||
const tex = new THREE.CanvasTexture(canvas);
|
||||
tex.magFilter = THREE.LinearFilter;
|
||||
tex.colorSpace = THREE.SRGBColorSpace;
|
||||
return tex;
|
||||
const SUN_GLOW_FULL_INTENSITY = 1.9;
|
||||
|
||||
/**
|
||||
* The sky, as a mesh in the world rather than a gradient on the screen.
|
||||
*
|
||||
* ## What was wrong with the gradient
|
||||
*
|
||||
* `Scene.background` with a plain 2D texture is drawn by three onto a
|
||||
* screen-filling quad: the top of the *viewport* is `skyTop` and the bottom of
|
||||
* the viewport is `skyHorizon`, whatever the camera happens to be doing. That
|
||||
* is not a sky, it is a wash, and on a map board — where the camera is almost
|
||||
* always tilted down and the true horizon sits high in the frame — it fails in
|
||||
* a way you can name from a screenshot:
|
||||
*
|
||||
* - At dusk the warm band appeared along the **bottom** of the picture, under
|
||||
* the board, while the actual horizon at the top of the frame stayed the
|
||||
* deep blue of the zenith. The sunset was rendered upside down.
|
||||
* - The world's far edge is faded out by `THREE.Fog` into `fog.color`, which
|
||||
* `atmosphere.ts` makes the horizon colour exactly so the two meet. They
|
||||
* could not meet, because the horizon colour was not at the horizon, so
|
||||
* there was a visible seam wherever the ground ran out — and
|
||||
* `interiors/daylight.ts` documents pinning its own horizon stop to the fog
|
||||
* colour to hide it, which is the symptom stated in the source.
|
||||
*
|
||||
* ## Why a dome and not an equirectangular background
|
||||
*
|
||||
* three renders `Scene.background` in world space only for a `CubeTexture` or a
|
||||
* PMREM (`CubeUVReflectionMapping`); a 2D texture tagged
|
||||
* `EquirectangularReflectionMapping` still takes the screen-space plane path.
|
||||
* Getting a world-oriented sky out of the background slot therefore means
|
||||
* running a `PMREMGenerator` over a gradient on every colour change, which is
|
||||
* the sharpest thing in the frame put through a blur chain built to destroy
|
||||
* detail. A dome is one draw call, a thousand triangles, and it can also do the
|
||||
* two things a gradient texture cannot: put the glow **around the sun** rather
|
||||
* than uniformly around the compass, and keep the horizon band tight.
|
||||
*
|
||||
* ## How it sits in the scene
|
||||
*
|
||||
* `depthTest: false` with `renderOrder` far negative, which is exactly how
|
||||
* three's own background box works: it is drawn first, writes no depth, and
|
||||
* every other object in the scene paints over it. That makes the radius
|
||||
* irrelevant — nothing is ever compared against it — so the sphere is a unit
|
||||
* one, recentred on the camera in `onBeforeRender`, and can never be clipped by
|
||||
* a near or far plane however large the board is.
|
||||
*
|
||||
* ## Colour, and why nothing is converted
|
||||
*
|
||||
* The components are written straight out with no tone mapping and no output
|
||||
* transform, which reproduces exactly what the old texture path did: an
|
||||
* sRGB-tagged background is decoded on sample and re-encoded on write, and
|
||||
* three sets `toneMapped = false` for it. So `LightingState.sky` is displayed
|
||||
* as the number the atmosphere table wrote, which is what
|
||||
* `render/toneMapping.test.ts` asserts about those columns. Hence
|
||||
* `setHex(hex, LinearSRGBColorSpace)` at the call site: it loads the byte
|
||||
* values without a colour-space conversion, because the shader is not
|
||||
* performing one either.
|
||||
*/
|
||||
function makeSkyDome(): THREE.Mesh<THREE.SphereGeometry, THREE.ShaderMaterial> {
|
||||
const material = new THREE.ShaderMaterial({
|
||||
uniforms: {
|
||||
uTop: { value: new THREE.Color(0x8fb8d8) },
|
||||
uHorizon: { value: new THREE.Color(0xd9e6ee) },
|
||||
uSunDirection: { value: new THREE.Vector3(0, 1, 0) },
|
||||
uSunColor: { value: new THREE.Color(0xffffff) },
|
||||
uSunGlow: { value: 0 },
|
||||
},
|
||||
vertexShader: `
|
||||
varying vec3 vDirection;
|
||||
void main() {
|
||||
// The dome is only ever translated, never rotated or scaled, so a unit
|
||||
// sphere's own vertex position is already the world direction it stands for.
|
||||
vDirection = position;
|
||||
gl_Position = projectionMatrix * modelViewMatrix * vec4( position, 1.0 );
|
||||
}
|
||||
`,
|
||||
fragmentShader: `
|
||||
uniform vec3 uTop;
|
||||
uniform vec3 uHorizon;
|
||||
uniform vec3 uSunDirection;
|
||||
uniform vec3 uSunColor;
|
||||
uniform float uSunGlow;
|
||||
varying vec3 vDirection;
|
||||
|
||||
void main() {
|
||||
vec3 direction = normalize( vDirection );
|
||||
float height = direction.y;
|
||||
|
||||
/*
|
||||
* The exponent is what makes this read as air rather than as a ramp. A
|
||||
* linear zenith-to-horizon blend spends half its colour change in the top
|
||||
* forty-five degrees of sky, where there is nothing to see; a real sky does
|
||||
* almost all of it in the first fifteen degrees above the horizon, which is
|
||||
* also the only part of it a map board ever has in frame.
|
||||
*/
|
||||
vec3 color = mix( uHorizon, uTop, pow( clamp( height, 0.0, 1.0 ), 0.42 ) );
|
||||
|
||||
// Below the horizon there is no sky. The ocean covers this in the city and
|
||||
// the ground plane covers it in a sited office, so what it has to be is
|
||||
// simply *not brighter* than the horizon it sits under.
|
||||
color = mix( color, uHorizon * 0.82, clamp( - height * 5.0, 0.0, 1.0 ) );
|
||||
|
||||
/*
|
||||
* Two glows, one warm quarter of sky.
|
||||
*
|
||||
* The first is round and centred on the sun: the aureole, tight enough to
|
||||
* say where the sun is without drawing a disc — a hard white disc at map
|
||||
* scale reads as a rendering artefact, and the environment map already
|
||||
* carries a real sun lobe for anything reflective to catch.
|
||||
*
|
||||
* The second hugs the horizon and falls off with the *azimuth* to the sun,
|
||||
* which is the half of a sunset the atmosphere's colour table cannot
|
||||
* express: its keyframes are one horizon colour for the whole compass, so
|
||||
* without this the sky behind the viewer is as orange as the sky the sun is
|
||||
* setting into.
|
||||
*/
|
||||
float toSun = max( dot( direction, uSunDirection ), 0.0 );
|
||||
float aureole = pow( toSun, 5.0 ) * 0.22 + pow( toSun, 90.0 ) * 0.30;
|
||||
|
||||
vec2 flat0 = normalize( vec2( direction.x, direction.z ) + 1e-5 );
|
||||
vec2 flatSun = normalize( vec2( uSunDirection.x, uSunDirection.z ) + 1e-5 );
|
||||
float azimuth = max( dot( flat0, flatSun ), 0.0 );
|
||||
float band = exp( - abs( height ) * 6.0 ) * pow( azimuth, 2.5 ) * 0.18;
|
||||
|
||||
color += uSunColor * uSunGlow * ( aureole + band );
|
||||
gl_FragColor = vec4( color, 1.0 );
|
||||
}
|
||||
`,
|
||||
side: THREE.BackSide,
|
||||
depthTest: false,
|
||||
depthWrite: false,
|
||||
fog: false,
|
||||
toneMapped: false,
|
||||
});
|
||||
|
||||
const mesh = new THREE.Mesh(new THREE.SphereGeometry(1, 32, 16), material);
|
||||
mesh.name = "sky";
|
||||
// First in the opaque list, before anything that could occlude it.
|
||||
mesh.renderOrder = -1000;
|
||||
// The pose is written below, after culling would have run, so culling must
|
||||
// not be allowed to run: the bounding sphere three would test is the one at
|
||||
// the origin, which is nowhere near where this is drawn.
|
||||
mesh.frustumCulled = false;
|
||||
mesh.matrixAutoUpdate = false;
|
||||
mesh.onBeforeRender = (_renderer, _scene, camera) => {
|
||||
mesh.matrixWorld.copyPosition(camera.matrixWorld);
|
||||
};
|
||||
return mesh;
|
||||
}
|
||||
|
||||
+9
-7
@@ -176,13 +176,15 @@ export function deviceProfile(): DeviceProfile {
|
||||
* Halved on a phone, and the city barely knows.
|
||||
*
|
||||
* Check what is actually in that map before defending its size. On the city
|
||||
* board the only casters are the buildings, the landmarks and the bridges —
|
||||
* `terrain.ts` sets `receiveShadow` and never `castShadow`, so the hills'
|
||||
* relief is the Lambert term and not a shadow at all. And `scene.ts` hands
|
||||
* the kit a shadow extent of 0.75 board spans, which for the Bay Area's
|
||||
* 1003 units is a 1504-unit box: at 2048 texels that is 0.73 units, about
|
||||
* 69 m at this city's scale, and a building footprint is one texel or less.
|
||||
* The map is already quantising past the things in it.
|
||||
* board the casters are the buildings, the landmarks, the bridges and —
|
||||
* since the relief started shadowing itself — a decimated copy of the
|
||||
* terrain, a quarter of its triangles, appended to its own index buffer and
|
||||
* swung in by `drawRange` for the depth pass alone (see `terrain.ts`,
|
||||
* `SHADOW_CASTER_STRIDE`). And `scene.ts` hands the kit a shadow extent of
|
||||
* 0.75 board spans, which for the Bay Area's 1003 units is a 1504-unit box:
|
||||
* at 2048 texels that is 0.73 units, about 69 m at this city's scale, and a
|
||||
* building footprint is one texel or less. The map is already quantising
|
||||
* past the things in it.
|
||||
*
|
||||
* So 1024 on a phone costs the map a resolution it was not using. An office
|
||||
* passes its own 2048 and keeps it, because at 1 unit = 1 m the same map is
|
||||
|
||||
+151
-27
@@ -172,24 +172,51 @@ function tubeGeometry(points: THREE.Vector3[], width: number, radial = 4): THREE
|
||||
}
|
||||
|
||||
/**
|
||||
* A draped, flat road deck. A tube turns a freeway into a raised pipeline.
|
||||
* A draped strip running between two parallel offsets from a path, each at its
|
||||
* own lateral distance and its own height.
|
||||
*
|
||||
* The UVs run 0..1 across the carriageway and in **metres** along it, which is
|
||||
* the sane convention if anyone ever puts a surface texture on a road. Right
|
||||
* now nothing does, and they are here for a duller reason: `mergeGeometries`
|
||||
* only merges geometries whose attribute sets match exactly, so a strip without
|
||||
* UVs cannot share a bucket with the tube barriers beside it.
|
||||
* The flat symmetric case is a road deck; the asymmetric case is an embankment
|
||||
* batter, and it is the reason this generalised. A ribbon whose two rails sit
|
||||
* at different heights has a **tilted normal**, which is the entire mechanism
|
||||
* by which a freeway stops reading as a line drawn on the ground: the crown
|
||||
* catches the sun and the two flanks do not, so the corridor has a lit edge and
|
||||
* a shaded one at every hour instead of being one flat value.
|
||||
*
|
||||
* The UVs run 0..1 across the strip and in **metres** along it, which is the
|
||||
* sane convention if anyone ever puts a surface texture on a road. Right now
|
||||
* nothing does, and they are here for a duller reason: `mergeGeometries` only
|
||||
* merges geometries whose attribute sets match exactly, so a strip without UVs
|
||||
* cannot share a bucket with the tube barriers beside it.
|
||||
*/
|
||||
function roadRibbonGeometry(
|
||||
function bandGeometry(
|
||||
points: readonly THREE.Vector3[],
|
||||
width: number,
|
||||
lift = 0,
|
||||
offsetA: number,
|
||||
liftA: number,
|
||||
offsetB: number,
|
||||
liftB: number,
|
||||
): THREE.BufferGeometry {
|
||||
/**
|
||||
* The rail at the larger offset is always emitted first, whichever order the
|
||||
* caller wrote them in.
|
||||
*
|
||||
* This is not tidiness. These strips are `deck` material, which is
|
||||
* `DoubleSide`, and three.js negates the shading normal on a back face — so a
|
||||
* strip whose two rails arrive in the opposite order to its neighbours has
|
||||
* reversed winding, gets its up-pointing normal turned to face the ground,
|
||||
* and renders as an unlit black band. That is exactly what the right-hand
|
||||
* embankment did the first time it was built from `side * 1.75` and
|
||||
* `side * 2.3`: on the `-1` side those two offsets are in decreasing order,
|
||||
* and a black stripe ran the length of US-101.
|
||||
*/
|
||||
const ordered = offsetA >= offsetB;
|
||||
const leftOffset = ordered ? offsetA : offsetB;
|
||||
const leftLift = ordered ? liftA : liftB;
|
||||
const rightOffset = ordered ? offsetB : offsetA;
|
||||
const rightLift = ordered ? liftB : liftA;
|
||||
const positions: number[] = [];
|
||||
const normals: number[] = [];
|
||||
const uvs: number[] = [];
|
||||
const indices: number[] = [];
|
||||
const half = width / 2;
|
||||
let along = 0;
|
||||
|
||||
for (let index = 0; index < points.length; index += 1) {
|
||||
@@ -200,14 +227,38 @@ function roadRibbonGeometry(
|
||||
const dx = next.x - previous.x;
|
||||
const dz = next.z - previous.z;
|
||||
const length = Math.hypot(dx, dz) || 1;
|
||||
const nx = -dz / length;
|
||||
const nz = dx / length;
|
||||
const tx = dx / length;
|
||||
const tz = dz / length;
|
||||
// Left of travel, in the ground plane.
|
||||
const nx = -tz;
|
||||
const nz = tx;
|
||||
if (index > 0) along += point.distanceTo(previous);
|
||||
|
||||
// The across-vector from the right rail to the left one, in three
|
||||
// dimensions. Crossed with the tangent it gives the strip's true normal;
|
||||
// the sign flip keeps that normal pointing at the sky whichever way round
|
||||
// the two offsets were handed in.
|
||||
const ax = nx * (leftOffset - rightOffset);
|
||||
const ay = leftLift - rightLift;
|
||||
const az = nz * (leftOffset - rightOffset);
|
||||
let mx = ay * tz - az * 0;
|
||||
let my = az * tx - ax * tz;
|
||||
let mz = ax * 0 - ay * tx;
|
||||
const mLength = Math.hypot(mx, my, mz) || 1;
|
||||
mx /= mLength;
|
||||
my /= mLength;
|
||||
mz /= mLength;
|
||||
if (my < 0) {
|
||||
mx = -mx;
|
||||
my = -my;
|
||||
mz = -mz;
|
||||
}
|
||||
|
||||
positions.push(
|
||||
point.x + nx * half, point.y + lift, point.z + nz * half,
|
||||
point.x - nx * half, point.y + lift, point.z - nz * half,
|
||||
point.x + nx * leftOffset, point.y + leftLift, point.z + nz * leftOffset,
|
||||
point.x + nx * rightOffset, point.y + rightLift, point.z + nz * rightOffset,
|
||||
);
|
||||
normals.push(0, 1, 0, 0, 1, 0);
|
||||
normals.push(mx, my, mz, mx, my, mz);
|
||||
uvs.push(0, along, 1, along);
|
||||
if (index < points.length - 1) {
|
||||
const a = index * 2;
|
||||
@@ -224,6 +275,15 @@ function roadRibbonGeometry(
|
||||
return geometry;
|
||||
}
|
||||
|
||||
/** A draped, flat road deck. A tube turns a freeway into a raised pipeline. */
|
||||
function roadRibbonGeometry(
|
||||
points: readonly THREE.Vector3[],
|
||||
width: number,
|
||||
lift = 0,
|
||||
): THREE.BufferGeometry {
|
||||
return bandGeometry(points, width / 2, lift, -width / 2, lift);
|
||||
}
|
||||
|
||||
function offsetPath(points: readonly THREE.Vector3[], offset: number): THREE.Vector3[] {
|
||||
return points.map((point, index) => {
|
||||
const previous = points[Math.max(0, index - 1)] ?? point;
|
||||
@@ -313,7 +373,12 @@ export function createFreewayWorld(world: World, pack: TransportPack): THREE.Gro
|
||||
const batch = new Batch();
|
||||
const asphalt = [0x353a3d, 0x303538];
|
||||
const shoulder = [0x555759, 0x4e5153];
|
||||
const berm = [0x64705c, 0x74674c];
|
||||
const berm = [0x8d8a66, 0x9a8c62];
|
||||
// One shadow colour for both corridors' batters. Two would be one more
|
||||
// material and one more draw call for a difference nobody can see on a
|
||||
// surface that is, by construction, the part of the corridor facing away
|
||||
// from the sun.
|
||||
const batter = 0x5f5740;
|
||||
const barrierMaterial = new THREE.MeshLambertMaterial({ color: 0xb6b4aa });
|
||||
const guardMaterial = new THREE.MeshStandardMaterial({ color: 0x9fa8aa, metalness: 0.64, roughness: 0.42 });
|
||||
const reflectorMaterial = new THREE.MeshBasicMaterial({ color: 0xf7e3a0, toneMapped: false });
|
||||
@@ -352,12 +417,43 @@ export function createFreewayWorld(world: World, pack: TransportPack): THREE.Gro
|
||||
const route = plan.routes[roadIndex];
|
||||
const identityIndex = route?.identity === "interstate" ? 1 : 0;
|
||||
const routePath = route ? buildRoutePath(pack, route.routeId) : null;
|
||||
// Broad earthwork under separate decks makes grade and curve changes read.
|
||||
batch.add(
|
||||
"freeway:berm",
|
||||
roadRibbonGeometry(path, 2.75, -0.09),
|
||||
batch.material("deck", berm[identityIndex] ?? berm[0]!),
|
||||
);
|
||||
/**
|
||||
* The earthwork, as a crown and two batters rather than one flat ribbon.
|
||||
*
|
||||
* This is the fix for the defect that mattered most on the California
|
||||
* board: at 1,919 m to the scene unit the whole corridor is about eleven
|
||||
* pixels wide from the default camera, and eleven pixels of flat mid-grey
|
||||
* lying exactly on the ground reads as a line somebody drew on the map, not
|
||||
* as a road. Three things change that, and none of them is width for its
|
||||
* own sake:
|
||||
*
|
||||
* - **A graded right-of-way that is not the colour of the asphalt.** The
|
||||
* crown runs out to ±1.75 in dry cut earth, so the corridor arrives as
|
||||
* pale / dark / pale instead of as one dark stroke, and the eye reads
|
||||
* three bands where it used to read one line.
|
||||
* - **Batters with a real normal.** The flanks fall 0.09 units over 0.55,
|
||||
* which is about nine degrees — enough that Lambert separates them from
|
||||
* the crown at every sun angle, and enough that at dusk the corridor has
|
||||
* a lit side and a shaded side.
|
||||
* - **Sitting slightly proud of the ground.** The crown is at -0.02
|
||||
* rather than -0.09, so the earthwork is a causeway across the flats
|
||||
* rather than a trench cut into them.
|
||||
*
|
||||
* All three survive the drive chapters, where the same geometry is two
|
||||
* hundred pixels of verge and a shallow embankment falling away to the
|
||||
* fields — which is what US-101 through the Salinas Valley actually looks
|
||||
* like out of a car window.
|
||||
*/
|
||||
const bermMaterial = batch.material("deck", berm[identityIndex] ?? berm[0]!);
|
||||
batch.add("freeway:berm", roadRibbonGeometry(path, 3.5, -0.02), bermMaterial);
|
||||
const batterMaterial = batch.material("deck", batter);
|
||||
for (const side of [-1, 1] as const) {
|
||||
batch.add(
|
||||
"freeway:embankment",
|
||||
bandGeometry(path, side * 1.75, -0.02, side * 2.3, -0.11),
|
||||
batterMaterial,
|
||||
);
|
||||
}
|
||||
for (const side of [-1, 1] as const) {
|
||||
batch.add(
|
||||
"freeway:shoulder",
|
||||
@@ -384,11 +480,32 @@ export function createFreewayWorld(world: World, pack: TransportPack): THREE.Gro
|
||||
batch.add("freeway:lane-dashes", dashedRibbonGeometry(path, side * 0.47, 0.022), dashes);
|
||||
batch.add("freeway:lane-dashes", dashedRibbonGeometry(path, side * 0.81, 0.022), dashes);
|
||||
const guardPath = offsetPath(path, side * 1.27);
|
||||
/**
|
||||
* One tubular segment per draped sample, and three sides, not five.
|
||||
*
|
||||
* `drapePath` already samples every leg 52 times — roughly a point per
|
||||
* kilometre along a 700 km corridor — so a tube at `length * 2` was
|
||||
* subdividing an interval nothing curves inside. Between the four
|
||||
* guardrails and the four median walls that was 82,000 triangles, an
|
||||
* eighth of the whole board's budget, spent on two objects that are a
|
||||
* hairline from the state camera and a thin grey rail from the chase
|
||||
* camera. Halving the segments and dropping two radial sides gives back
|
||||
* 55,000 of them, which is what pays for the state's relief and its
|
||||
* cities; a five-sided 25 mm-radius tube and a three-sided one are the
|
||||
* same handful of pixels at both distances this corridor is ever seen
|
||||
* from.
|
||||
*
|
||||
* It also stopped casting. A shadow caster is drawn twice, and what this
|
||||
* one casts is the shadow of a fifty-metre pipe standing in for a
|
||||
* half-metre rail — a fiction lying a few centimetres from the object
|
||||
* that threw it, at both distances this corridor is seen from. The sign
|
||||
* posts still cast, because a sign standing clear of the road is the one
|
||||
* roadside object whose shadow tells you where the ground is.
|
||||
*/
|
||||
batch.add(
|
||||
"freeway:outer-guardrail",
|
||||
new THREE.TubeGeometry(new THREE.CatmullRomCurve3(guardPath), Math.max(24, guardPath.length * 2), 0.025, 5, false),
|
||||
new THREE.TubeGeometry(new THREE.CatmullRomCurve3(guardPath), Math.max(24, guardPath.length), 0.025, 3, false),
|
||||
guardMaterial,
|
||||
{ cast: true },
|
||||
);
|
||||
}
|
||||
// Low concrete median walls keep both carriageways visually independent.
|
||||
@@ -396,7 +513,7 @@ export function createFreewayWorld(world: World, pack: TransportPack): THREE.Gro
|
||||
const medianPath = offsetPath(path, side * 0.075).map((point) => point.clone().setY(point.y + 0.065));
|
||||
batch.add(
|
||||
"freeway:median-barrier",
|
||||
new THREE.TubeGeometry(new THREE.CatmullRomCurve3(medianPath), Math.max(24, medianPath.length * 2), 0.055, 4, false),
|
||||
new THREE.TubeGeometry(new THREE.CatmullRomCurve3(medianPath), Math.max(24, medianPath.length), 0.055, 3, false),
|
||||
barrierMaterial,
|
||||
);
|
||||
}
|
||||
@@ -404,12 +521,19 @@ export function createFreewayWorld(world: World, pack: TransportPack): THREE.Gro
|
||||
// The matrices are collected across every corridor and committed to one
|
||||
// `InstancedMesh` after the loop, because two corridors' worth of the same
|
||||
// 0.018 m box is two draw calls for something nobody can resolve.
|
||||
const reflectorPoints = path.filter((_, index) => index % 2 === 0);
|
||||
//
|
||||
// Every sixth sample rather than every second: 2,296 boxes were 27,500
|
||||
// triangles for studs the chase camera sees a dozen of at a time and the
|
||||
// state camera cannot resolve at all. At this stride they are still about
|
||||
// one every seven kilometres of a road whose lanes are two kilometres wide,
|
||||
// and 20,000 triangles come back to the relief and the cities.
|
||||
const reflectorStride = 6;
|
||||
const reflectorPoints = path.filter((_, index) => index % reflectorStride === 0);
|
||||
for (const pointIndex of reflectorPoints.keys()) {
|
||||
const point = reflectorPoints[pointIndex];
|
||||
if (!point) continue;
|
||||
for (const offset of [-0.81, -0.47, 0.47, 0.81]) {
|
||||
const shifted = offsetPath(path, offset)[pointIndex * 2] ?? point;
|
||||
const shifted = offsetPath(path, offset)[pointIndex * reflectorStride] ?? point;
|
||||
dummy.position.set(shifted.x, shifted.y + 0.055, shifted.z);
|
||||
dummy.rotation.set(0, 0, 0);
|
||||
dummy.scale.setScalar(1);
|
||||
|
||||
+566
-14
@@ -36,6 +36,46 @@ export function paletteFor(world: World): ScenePalette {
|
||||
return { ...DEFAULT_PALETTE, ...(world.city.palette ?? {}) };
|
||||
}
|
||||
|
||||
/**
|
||||
* Where `palette.alpine` starts and where it wins, in metres.
|
||||
*
|
||||
* Only consulted for a pack that declares the colour; see `ScenePalette`.
|
||||
*
|
||||
* These are a *snow* line, not a tree line, and the difference is the whole
|
||||
* tuning. The first pair was 900 and 2,300 — a tree line — and it was tried and
|
||||
* photographed: at 900 every range in the Mojave has its top third above the
|
||||
* threshold, so a desert two hundred kilometres wide came out with white caps on
|
||||
* it and the Panamints, the Providences and the New York Mountains all read as
|
||||
* small Sierras. The Mojave ranges are dark rock and the Sierra crest is bare
|
||||
* granite and old snow, and only one of the two is pale.
|
||||
*
|
||||
* At 1,900 the desert keeps `upland` almost everywhere — the tallest thing in
|
||||
* the eastern Mojave is 2,300 m and lands a fifth of the way along — while the
|
||||
* Sierra crest at 3,150 to 4,300, the White Mountains, Telescope Peak and San
|
||||
* Gorgonio, which are the four places in California that hold snow into the
|
||||
* summer, are most of the way to it. That is the correct list.
|
||||
*
|
||||
* Wildland is unaffected either way: `groundColor` answers `inPark` first, so a
|
||||
* forest belt is green to whatever height its envelope reaches and this ramp
|
||||
* only ever paints ground that no park covers.
|
||||
*/
|
||||
const ALPINE_FROM = 1_900;
|
||||
const ALPINE_TO = 3_300;
|
||||
|
||||
/**
|
||||
* Ramp endpoints, held rather than allocated.
|
||||
*
|
||||
* `groundColor` runs once per emitted lattice vertex — 85,000 times on the
|
||||
* California board and 294,000 on the Bay Area — and the two `new THREE.Color`
|
||||
* calls it used to make per vertex were three quarters of a million short-lived
|
||||
* objects on one board build, all of them the same three values. Module scope
|
||||
* is safe here for the reason the `scratch` argument already is: this is
|
||||
* single-threaded, synchronous, and the result is read into a flat array before
|
||||
* the next call.
|
||||
*/
|
||||
const RAMP_TO = new THREE.Color();
|
||||
const RAMP_TOP = new THREE.Color();
|
||||
|
||||
/**
|
||||
* Ground colour is about land *use*, not altitude.
|
||||
*
|
||||
@@ -61,16 +101,19 @@ function groundColor(
|
||||
elevation: number,
|
||||
): THREE.Color {
|
||||
if (inPark) {
|
||||
return scratch
|
||||
.setHex(pal.park)
|
||||
.lerp(new THREE.Color(pal.parkHigh), Math.min(1, elevation / 180));
|
||||
return scratch.setHex(pal.park).lerp(RAMP_TO.setHex(pal.parkHigh), Math.min(1, elevation / 180));
|
||||
}
|
||||
if (elevation < 3) {
|
||||
return scratch.setHex(pal.sand).lerp(new THREE.Color(pal.flats), elevation / 3);
|
||||
return scratch.setHex(pal.sand).lerp(RAMP_TO.setHex(pal.flats), elevation / 3);
|
||||
}
|
||||
return scratch
|
||||
const ground = scratch
|
||||
.setHex(pal.flats)
|
||||
.lerp(new THREE.Color(pal.upland), Math.min(1, (elevation - 3) / 150));
|
||||
.lerp(RAMP_TO.setHex(pal.upland), Math.min(1, (elevation - 3) / 150));
|
||||
if (pal.alpine === undefined || elevation <= ALPINE_FROM) return ground;
|
||||
return ground.lerp(
|
||||
RAMP_TOP.setHex(pal.alpine),
|
||||
Math.min(1, (elevation - ALPINE_FROM) / (ALPINE_TO - ALPINE_FROM)),
|
||||
);
|
||||
}
|
||||
|
||||
/** The smooth flat polygon under each landmass — the crisp coastline. */
|
||||
@@ -104,6 +147,18 @@ export function createShorePlates(world: World): THREE.Mesh {
|
||||
new THREE.MeshLambertMaterial({ color: pal.shore, side: THREE.DoubleSide }),
|
||||
);
|
||||
mesh.receiveShadow = true;
|
||||
/**
|
||||
* Receives, and deliberately does not cast.
|
||||
*
|
||||
* The plate is the landmass polygon lying flat at y=0 with the sea six
|
||||
* hundredths of a unit beneath it — at California's 1.9 km per unit, a step
|
||||
* of about a hundred metres. A caster that thin is a caster with no volume:
|
||||
* at a low sun it would throw the whole coastline's silhouette out across the
|
||||
* water as a hard-edged slab shadow, which is a rendering of the step and not
|
||||
* of anything in the world. `createTerrain` below is the mesh with relief in
|
||||
* it, and relief is the only thing here worth a shadow.
|
||||
*/
|
||||
mesh.castShadow = false;
|
||||
mesh.name = "shorePlates";
|
||||
return mesh;
|
||||
}
|
||||
@@ -157,18 +212,338 @@ export function createTerrain(world: World): THREE.Mesh {
|
||||
const geo = new THREE.BufferGeometry();
|
||||
geo.setAttribute("position", new THREE.Float32BufferAttribute(positions, 3));
|
||||
geo.setAttribute("color", new THREE.Float32BufferAttribute(colors, 3));
|
||||
// Normals first, and from the visible triangles alone. The caster's coarse
|
||||
// triangles are about to join the same index buffer, and a vertex normal
|
||||
// averaged over both would be a normal for neither.
|
||||
geo.setIndex(indices);
|
||||
geo.computeVertexNormals();
|
||||
|
||||
const mesh = new THREE.Mesh(
|
||||
geo,
|
||||
new THREE.MeshLambertMaterial({ vertexColors: true, side: THREE.DoubleSide }),
|
||||
);
|
||||
/**
|
||||
* The same relief again at a quarter of the triangles, appended to the same
|
||||
* index buffer, and drawn only by the depth pass.
|
||||
*
|
||||
* The visible triangles occupy `[0, seen)` of the index and the caster's
|
||||
* occupy the tail; `Mesh.onBeforeShadow` swings `drawRange` onto the tail and
|
||||
* `onAfterShadow` swings it back. Both hooks exist for exactly this and fire
|
||||
* either side of the one `renderBufferDirect` the shadow pass makes for this
|
||||
* mesh, and the shadow pass runs before the colour pass, so the range is
|
||||
* always right for whoever is reading it.
|
||||
*
|
||||
* **The obvious alternatives were tried and neither works.** A second mesh on
|
||||
* a rendering layer only the shadow camera can see: `WebGLShadowMap.
|
||||
* renderObject` tests `object.layers.test( camera.layers )` against the
|
||||
* **scene** camera it was handed, not against `shadow.camera`, so a mesh
|
||||
* hidden from the viewer is hidden from the depth pass by the same line — this
|
||||
* was written, measured, and cast nothing at all. A second mesh with
|
||||
* `colorWrite` off: `visible`, `material.visible` and `frustumCulled` are each
|
||||
* one flag consulted identically by both passes, so it stays in the colour
|
||||
* pass and `renderer.info` counts its triangles twice, which is the whole
|
||||
* cost this exists to avoid. One geometry with two ranges is what is left, and
|
||||
* it is also the cheapest: no second draw call, no second vertex buffer.
|
||||
*/
|
||||
const seen = indices.length;
|
||||
for (let i = 0; i + SHADOW_CASTER_STRIDE <= latSteps; i += SHADOW_CASTER_STRIDE) {
|
||||
for (let j = 0; j + SHADOW_CASTER_STRIDE <= lngSteps; j += SHADOW_CASTER_STRIDE) {
|
||||
const s = SHADOW_CASTER_STRIDE;
|
||||
const a = i * (lngSteps + 1) + j;
|
||||
// All four corners on land, the same test the visible surface uses. A
|
||||
// coarse cell that straddles the coast sits on the world's falloff at
|
||||
// y≈0 and would cast nothing anyway.
|
||||
if (
|
||||
!land[a] ||
|
||||
!land[a + s] ||
|
||||
!land[a + s * (lngSteps + 1)] ||
|
||||
!land[a + s * (lngSteps + 1) + s]
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
indices.push(vertex(i, j), vertex(i + s, j), vertex(i, j + s));
|
||||
indices.push(vertex(i, j + s), vertex(i + s, j), vertex(i + s, j + s));
|
||||
}
|
||||
}
|
||||
const cast = indices.length - seen;
|
||||
// `vertex()` may have emitted a few lattice corners the visible surface never
|
||||
// needed, so the position and colour attributes are rebuilt alongside the
|
||||
// index rather than reused from above.
|
||||
geo.setAttribute("position", new THREE.Float32BufferAttribute(positions, 3));
|
||||
geo.setAttribute("color", new THREE.Float32BufferAttribute(colors, 3));
|
||||
const normal = geo.getAttribute("normal") as THREE.BufferAttribute;
|
||||
if (normal.count < positions.length / 3) {
|
||||
const grown = new Float32Array(positions.length);
|
||||
grown.set(normal.array as Float32Array);
|
||||
// A corner only the caster uses is never shaded, so any unit normal will
|
||||
// do; up is the one that cannot be mistaken for a bug.
|
||||
for (let k = normal.count * 3; k < grown.length; k += 3) grown[k + 1] = 1;
|
||||
geo.setAttribute("normal", new THREE.BufferAttribute(grown, 3));
|
||||
}
|
||||
geo.setIndex(indices);
|
||||
geo.setDrawRange(0, seen);
|
||||
|
||||
const material = new THREE.MeshLambertMaterial({ vertexColors: true, side: THREE.DoubleSide });
|
||||
/**
|
||||
* `shadowSide = BackSide` is the acne cure, and it is exactly right for a
|
||||
* heightfield.
|
||||
*
|
||||
* The depth pass then culls every face turned *toward* the sun — precisely
|
||||
* the set of faces that were shadowing themselves — and keeps the faces
|
||||
* turned away from it. The boundary between the two is the terminator, so the
|
||||
* depth recorded along a ridge starts at the crest and runs down its far
|
||||
* slope, and the valley floor beyond, which is still front-facing and
|
||||
* therefore writes nothing, tests against it and lands in shadow. A lit slope
|
||||
* has nothing in the map above it and cannot stipple.
|
||||
*
|
||||
* Without it, a constant `shadow.bias` has to cover a depth-per-texel that
|
||||
* grows as 1/tan(elevation) — one texel is 0.21 scene units on California and
|
||||
* 0.73 on the Bay Area — and there is no single value that is free of acne at
|
||||
* 40° and free of peter-panning at 8°. That is why this was left off through
|
||||
* the previous round, and it is checked here by photographing the boards at
|
||||
* a sun of 12° and of 2°.
|
||||
*/
|
||||
material.shadowSide = THREE.BackSide;
|
||||
|
||||
const mesh = new THREE.Mesh(geo, material);
|
||||
mesh.receiveShadow = true;
|
||||
/**
|
||||
* The relief casts at last.
|
||||
*
|
||||
* Until it did, a ridge shaded its own back slope through the Lambert N·L
|
||||
* term and then darkened nothing beside it: the valley next to a mountain
|
||||
* range stayed fully lit at every hour of the day, and a range read as a bump
|
||||
* map rather than as geography. `stage.ts` states the omission and this is
|
||||
* the line it was waiting on.
|
||||
*/
|
||||
mesh.castShadow = true;
|
||||
mesh.onBeforeShadow = () => geo.setDrawRange(seen, cast);
|
||||
mesh.onAfterShadow = () => geo.setDrawRange(0, seen);
|
||||
mesh.name = "terrain";
|
||||
return mesh;
|
||||
}
|
||||
|
||||
/**
|
||||
* How many lattice cells of the visible terrain go into one cell of the caster
|
||||
* appended to it, per axis.
|
||||
*
|
||||
* Bounded below by the shadow map's own resolution and above by the board's
|
||||
* triangle budget, and the budget is much the tighter of the two.
|
||||
*
|
||||
* *Below*: a caster finer than a shadow texel is detail the map cannot record.
|
||||
* The city hands `SceneKit` a box of 1.5 board spans over 2048 texels — 0.21
|
||||
* scene units on California — against a terrain lattice of about 1.2 units, so
|
||||
* a stride of 2 still writes a facet several texels across.
|
||||
*
|
||||
* *Above*: triangles, and the number is unforgiving. California's terrain is
|
||||
* 65,566 triangles; with a stride of 2 the whole board measures 732,206 against
|
||||
* a budget of 750,000, so the depth pass has about 18,000 in hand. A stride of
|
||||
* 1 would spend 65,566 and miss the budget by 47,000; a stride of 3 would spend
|
||||
* 7,285 and buy back nothing worth having. Both were measured.
|
||||
*
|
||||
* If the board's triangle count ever comes back down, this is the first number
|
||||
* worth spending it on — a stride of 1 puts a shadow edge on every ridge the
|
||||
* map can resolve and costs nothing else.
|
||||
*/
|
||||
const SHADOW_CASTER_STRIDE = 2;
|
||||
|
||||
// ---- The sea --------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* How far the sea reaches, in board spans, measured from the board's centre.
|
||||
*
|
||||
* This number is the fix for the hard diamond the world used to end in. The
|
||||
* plane was 1.8 times the board across — 0.9 spans from the middle — while
|
||||
* `main.ts` gives the atmosphere a clear-day fog that closes at 3.9 spans and
|
||||
* `scene.ts` gives the camera a far plane at 4. The sea therefore ran out
|
||||
* roughly a fifth of the way to the fog, in full contrast, and what you saw was
|
||||
* its silhouette: a blue quad with two straight edges meeting in a point, and
|
||||
* the state of California apparently floating on it.
|
||||
*
|
||||
* The fade already existed and nothing was reaching it. This is the plane's
|
||||
* *width*, so 18 puts its rim 9 spans from the middle, and the worst case it
|
||||
* has to beat is about 6.4: the camera can retreat 2 spans from the scene
|
||||
* origin, the origin can itself be half a span off the middle of the bounds,
|
||||
* and the fog closes 3.9 spans beyond wherever the camera is. Past the camera's
|
||||
* own far plane the water is clipped rather than drawn, and by the far plane it
|
||||
* is 100% fog, and
|
||||
* `atmosphere.ts` makes the fog colour the sky's own horizon colour on a clear
|
||||
* day. So the horizon is where the sea's colour and the sky's colour have
|
||||
* already become the same number: there is nothing there to see an edge in.
|
||||
*
|
||||
* It costs one quad. The plane is two triangles at any size.
|
||||
*/
|
||||
const SEA_SPAN_MULTIPLE = 18;
|
||||
|
||||
/**
|
||||
* The swell's wavelength, as a fraction of the board.
|
||||
*
|
||||
* Not a physical number and it should not be read as one. Real ocean swell is
|
||||
* 100–200 m crest to crest, which at California's 1.9 km per scene unit is a
|
||||
* tenth of a unit — below one screen pixel from any pose that has the board in
|
||||
* frame, so a physically-scaled sea is a sea with no visible surface at all,
|
||||
* which is where this started.
|
||||
*
|
||||
* What the number actually has to serve is the *glitter*: the eye reads water
|
||||
* as water because the sun's reflection is broken into a shifting path rather
|
||||
* than a mirror disc, and that path needs its texture to land at a handful of
|
||||
* pixels per tile at board distance. It was tuned by photographing it. A board
|
||||
* span over 20 puts a tile at some ninety screen pixels from a whole-board pose,
|
||||
* which is large enough that the eye finds the repeat and the sea reads as
|
||||
* woven fabric; over 45 the tile lands at forty and the mip chain has eaten it
|
||||
* before it says anything. 34 is the value in between, where the swell is
|
||||
* legible as swell when you look at it and never resolves into a pattern.
|
||||
*/
|
||||
const SWELL_TILE_SPANS = 1 / 34;
|
||||
|
||||
/** Tiles per second each swell layer drifts. Slow: this is weather, not surf. */
|
||||
const SWELL_DRIFT_A = new THREE.Vector2(0.031, 0.017);
|
||||
const SWELL_DRIFT_B = new THREE.Vector2(-0.019, 0.024);
|
||||
|
||||
/**
|
||||
* How much smaller the second swell layer's tile is than the first, and how far
|
||||
* round it is turned.
|
||||
*
|
||||
* Two samples of one map at two scales, two headings and two drift directions,
|
||||
* for the price of one texture. A single scrolling normal map reads as a
|
||||
* conveyor belt — the pattern is recognisable and it translates rigidly — and
|
||||
* the cheapest cure is a second copy that is not in step with it, so that no
|
||||
* crest keeps its shape for longer than the two layers stay aligned. 2.6 rather than a round
|
||||
* number because an integer ratio re-aligns on a short period and you can see
|
||||
* it happen, and 63° rather than nothing because the two copies otherwise share a
|
||||
* dominant swell direction and read as one sea seen twice.
|
||||
*/
|
||||
const SWELL_LAYER_B_SCALE = 2.6;
|
||||
const SWELL_LAYER_B_TURN = (63 * Math.PI) / 180;
|
||||
|
||||
/**
|
||||
* The Fresnel term, as the fraction of the body colour left when you look
|
||||
* straight down into the water.
|
||||
*
|
||||
* `MeshStandardMaterial` already has the *reflective* half of Fresnel: its
|
||||
* environment term brightens toward grazing, so with `environmentRig.ts` on the
|
||||
* scene the far water already picks up the horizon sky. What it has no notion
|
||||
* of is the other half — that the light coming *out* of the water is a body
|
||||
* colour seen through a surface that reflects less and less of the sky the more
|
||||
* squarely you look at it, so the sea directly below the camera should be the
|
||||
* deep colour and the sea at the horizon should be mostly sky.
|
||||
*
|
||||
* Multiplying the diffuse by 0.6 looking straight down and by 1 at grazing is
|
||||
* that, cheaply and in the right direction. Without it the ocean is one value
|
||||
* across the whole frame, which is the single loudest way water reads as a
|
||||
* painted card.
|
||||
*/
|
||||
const SEA_DEEP_FACTOR = 0.6;
|
||||
|
||||
/**
|
||||
* Where the far ocean gives up its specular, in board spans of view distance.
|
||||
*
|
||||
* The glitter is a per-pixel normal against a narrow specular lobe, and at some
|
||||
* distance one pixel covers more swell than the mip chain can average without
|
||||
* flickering as the camera moves. Rather than let that flicker happen, the
|
||||
* roughness is walked up toward `SEA_FAR_ROUGHNESS` past `SEA_CALM_NEAR`, which
|
||||
* is beyond the board — the water that matters keeps every bit of its response
|
||||
* and the water at the horizon becomes the flat hazy sheet it looks like from
|
||||
* an aeroplane anyway.
|
||||
*/
|
||||
const SEA_CALM_NEAR = 1.6;
|
||||
const SEA_CALM_FAR = 5;
|
||||
const SEA_FAR_ROUGHNESS = 0.42;
|
||||
|
||||
/**
|
||||
* A tiling tangent-space normal map for the swell, as raw RGBA bytes.
|
||||
*
|
||||
* Returned as data rather than drawn on a canvas, and this is the one texture
|
||||
* in the repo that is not a `TextureBin` drawing. A 2D canvas is the right tool
|
||||
* for anything with *shapes* in it — a screen, a leaf, a carpet — and the wrong
|
||||
* one here: the value being encoded is a surface derivative, `ctx` has no way
|
||||
* to express one, and going through a canvas would mean rasterising a height
|
||||
* field only to read it straight back out with `getImageData`. It also keeps
|
||||
* this module free of the DOM, so a test can call it directly.
|
||||
*
|
||||
* The height field is a sum of sine waves whose wave numbers are **integers**,
|
||||
* which is what makes the result tile: every component completes a whole number
|
||||
* of cycles across the map, so the left edge and the right edge are the same
|
||||
* sample. Phases are fixed constants rather than `Math.random`, so a board looks
|
||||
* the same on every reload and two people looking at it see one sea.
|
||||
*
|
||||
* **Amplitude falls as 1/k², and the exponent is the whole difference between
|
||||
* water and corduroy.** What a normal map encodes is not height but *slope*,
|
||||
* and slope is amplitude times wave number — so the obvious 1/k spectrum gives
|
||||
* every component in the sum exactly the same slope, the shortest one wins on
|
||||
* sheer count of edges, and the sea comes out as one hard diagonal rib. This
|
||||
* was tried and photographed. At 1/k² the slope falls as 1/k instead: the two
|
||||
* long components carry the shape, the short ones sit on top as sparkle, and
|
||||
* the eight directions stay spread far enough apart that no single one is
|
||||
* legible as a stripe.
|
||||
*/
|
||||
export function swellNormalData(size = 128, strength = 6): Uint8Array {
|
||||
/** `[wave number x, wave number y, phase in turns]`, amplitude is 1/|k|². */
|
||||
const waves: readonly (readonly [number, number, number])[] = [
|
||||
[2, 1, 0.13],
|
||||
[-1, 2, 0.61],
|
||||
[3, -2, 0.29],
|
||||
[2, 4, 0.87],
|
||||
[-5, 2, 0.44],
|
||||
[4, 5, 0.07],
|
||||
[-3, -7, 0.72],
|
||||
[8, -2, 0.35],
|
||||
];
|
||||
|
||||
const height = new Float32Array(size * size);
|
||||
for (const [kx, ky, phase] of waves) {
|
||||
const amplitude = 1 / (kx * kx + ky * ky);
|
||||
const p = phase * Math.PI * 2;
|
||||
for (let y = 0; y < size; y++) {
|
||||
const v = y / size;
|
||||
for (let x = 0; x < size; x++) {
|
||||
const u = x / size;
|
||||
height[y * size + x] =
|
||||
(height[y * size + x] as number) +
|
||||
amplitude * Math.sin(2 * Math.PI * (kx * u + ky * v) + p);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Central differences, wrapped, so the derivative tiles as cleanly as the
|
||||
// height does. `size` scales the difference back into per-tile slope, which
|
||||
// is what keeps `strength` independent of the resolution.
|
||||
const data = new Uint8Array(size * size * 4);
|
||||
const at = (x: number, y: number): number =>
|
||||
height[((y + size) % size) * size + ((x + size) % size)] as number;
|
||||
|
||||
for (let y = 0; y < size; y++) {
|
||||
for (let x = 0; x < size; x++) {
|
||||
const dx = (at(x + 1, y) - at(x - 1, y)) * 0.5 * strength;
|
||||
const dy = (at(x, y + 1) - at(x, y - 1)) * 0.5 * strength;
|
||||
// OpenGL convention: +Y in the map is +V in the texture, which is how
|
||||
// three's `getTangentFrame` builds the bitangent.
|
||||
const length = Math.hypot(-dx, -dy, 1);
|
||||
const i = (y * size + x) * 4;
|
||||
data[i] = Math.round(((-dx / length) * 0.5 + 0.5) * 255);
|
||||
data[i + 1] = Math.round(((-dy / length) * 0.5 + 0.5) * 255);
|
||||
data[i + 2] = Math.round((1 / length) * 0.5 * 255 + 127.5);
|
||||
data[i + 3] = 255;
|
||||
}
|
||||
}
|
||||
return data;
|
||||
}
|
||||
|
||||
function swellNormalTexture(): THREE.DataTexture {
|
||||
const size = 128;
|
||||
const texture = new THREE.DataTexture(swellNormalData(size), size, size);
|
||||
texture.wrapS = THREE.RepeatWrapping;
|
||||
texture.wrapT = THREE.RepeatWrapping;
|
||||
texture.magFilter = THREE.LinearFilter;
|
||||
// Mipmapped, and it is doing real work: the mip chain of a normal map
|
||||
// converges on "flat", so the sea a long way off stops perturbing its normal
|
||||
// without anything having to decide when. Anisotropy is what keeps the water
|
||||
// at a grazing angle — which is most of the frame from a map pose — from
|
||||
// blurring to that flat mip several times too early.
|
||||
texture.minFilter = THREE.LinearMipmapLinearFilter;
|
||||
texture.generateMipmaps = true;
|
||||
texture.anisotropy = 8;
|
||||
texture.needsUpdate = true;
|
||||
texture.name = "swellNormal";
|
||||
return texture;
|
||||
}
|
||||
|
||||
/** Ocean and bay: one plane under everything, plus any inland water. */
|
||||
export function createWater(world: World): THREE.Group {
|
||||
const pal = paletteFor(world);
|
||||
@@ -178,6 +553,8 @@ export function createWater(world: World): THREE.Group {
|
||||
const { bounds } = world.city;
|
||||
const [x0, z0] = world.project(bounds.minLat, bounds.minLng);
|
||||
const [x1, z1] = world.project(bounds.maxLat, bounds.maxLng);
|
||||
const span = Math.max(Math.abs(x1 - x0), Math.abs(z1 - z0));
|
||||
const size = span * SEA_SPAN_MULTIPLE;
|
||||
|
||||
/**
|
||||
* Standard rather than Lambert, and it is the whole difference between an
|
||||
@@ -196,14 +573,166 @@ export function createWater(world: World): THREE.Group {
|
||||
* dielectric: its reflection is a Fresnel term over a coloured body, which is
|
||||
* exactly what metalness 0 with low roughness produces, and a metallic water
|
||||
* would lose `pal.sea` entirely.
|
||||
*
|
||||
* The material on its own is still not a sea, and the three things below are
|
||||
* why: a mirror-flat plane has one specular *point* rather than a glitter
|
||||
* path, nothing about it moves, and its body colour does not change with the
|
||||
* angle you look into it. `seaSwell` supplies all three.
|
||||
*/
|
||||
const sea = new THREE.Mesh(
|
||||
new THREE.PlaneGeometry(Math.abs(x1 - x0) * 1.8, Math.abs(z1 - z0) * 1.8),
|
||||
new THREE.MeshStandardMaterial({ color: pal.sea, roughness: 0.14, metalness: 0 }),
|
||||
const material = new THREE.MeshStandardMaterial({
|
||||
color: pal.sea,
|
||||
/**
|
||||
* Rougher than a mirror, and the number is what sets the width of the sun's
|
||||
* path across the water.
|
||||
*
|
||||
* At 0.14 the specular lobe is about as tight as a smooth plane can make
|
||||
* it, so a flat sea returns the sun as a small hard disc that is either in
|
||||
* frame or not. The swell normals below spread it, and 0.2 spreads it
|
||||
* further: together they give the long shivering streak toward the sun that
|
||||
* is the single most recognisable thing about looking at the sea.
|
||||
*/
|
||||
roughness: 0.2,
|
||||
metalness: 0,
|
||||
normalMap: swellNormalTexture(),
|
||||
/**
|
||||
* Pushed to the back of every depth argument it is in, and this is not
|
||||
* cosmetic — without it the Bay Area and Southern California come out with
|
||||
* the sea dithered across their flats.
|
||||
*
|
||||
* The sea sits six hundredths of a unit below y=0 and the terrain's coastal
|
||||
* rim sits twelve thousandths above it, so seventy-two thousandths of a unit
|
||||
* is the whole separation between an ocean and the ground it is supposed to
|
||||
* be under. Ask what that is worth in the depth buffer at board distance:
|
||||
* with `near` at 0.1 and the camera 800 units out — an ordinary whole-board
|
||||
* pose on the Bay Area, which is 1,003 units across — one unit of world
|
||||
* depth is about two and a half of the twenty-four-bit buffer's steps, so
|
||||
* the entire gap is **a fifth of one step**. The two surfaces are the same
|
||||
* number.
|
||||
*
|
||||
* Which of them wins is then decided by float error, and the plane is
|
||||
* `SEA_SPAN_MULTIPLE` — eighteen board spans, two triangles, corners nine
|
||||
* thousand units from the middle. Interpolating a depth near the board from
|
||||
* vertex values of that magnitude leaves a few steps of noise in it, and a
|
||||
* few steps of noise on top of a fifth of a step of signal is the dapple of
|
||||
* blue over every flat piece of ground on the board. It arrived with the
|
||||
* bigger plane, it is invisible on California — whose valley floor stands at
|
||||
* 28 m, three and a half times the gap — and it is the reason to state the
|
||||
* numbers rather than to nudge the `y` until a screenshot looks right.
|
||||
*
|
||||
* Polygon offset is the fix that does not need a per-board number: it is
|
||||
* expressed in units of whatever the depth buffer can resolve *there*, so
|
||||
* thirty-two of them is thirty-two steps on any board at any zoom, and the
|
||||
* slope term covers the grazing angles where the noise is worst. It moves
|
||||
* no pixel and changes no colour. It only ever loses the sea an argument it
|
||||
* should never have been winning.
|
||||
*/
|
||||
polygonOffset: true,
|
||||
polygonOffsetFactor: 2,
|
||||
polygonOffsetUnits: 32,
|
||||
});
|
||||
material.normalScale.set(0.95, 0.95);
|
||||
|
||||
const swellA = new THREE.Vector3(0, 0, size / (span * SWELL_TILE_SPANS));
|
||||
const swellB = new THREE.Vector3(0, 0, swellA.z * SWELL_LAYER_B_SCALE);
|
||||
const turn = new THREE.Matrix3().set(
|
||||
Math.cos(SWELL_LAYER_B_TURN), -Math.sin(SWELL_LAYER_B_TURN), 0,
|
||||
Math.sin(SWELL_LAYER_B_TURN), Math.cos(SWELL_LAYER_B_TURN), 0,
|
||||
0, 0, 1,
|
||||
);
|
||||
const uniforms = {
|
||||
uSwellA: { value: swellA },
|
||||
uSwellB: { value: swellB },
|
||||
uSwellTurn: { value: turn },
|
||||
uSeaDeep: { value: SEA_DEEP_FACTOR },
|
||||
uSeaCalm: { value: new THREE.Vector2(span * SEA_CALM_NEAR, span * SEA_CALM_FAR) },
|
||||
uSeaFarRough: { value: SEA_FAR_ROUGHNESS },
|
||||
};
|
||||
|
||||
/**
|
||||
* Three edits to the standard shader, each of which the material has no dial
|
||||
* for and each of which the water needs.
|
||||
*
|
||||
* A patch rather than a `ShaderMaterial` written from scratch, because the
|
||||
* lighting this surface has to obey — a sun, a hemisphere, an ambient, a
|
||||
* shadow, a PMREM environment and a fog, all of them owned elsewhere and all
|
||||
* of them changing with the hour — is exactly what `MeshStandardMaterial`
|
||||
* already implements correctly. Reimplementing it to add a second texture
|
||||
* lookup would mean owning a second copy of the light rig, which CONTRACT.md
|
||||
* §4 spends its whole length arguing against.
|
||||
*/
|
||||
material.onBeforeCompile = (shader) => {
|
||||
Object.assign(shader.uniforms, uniforms);
|
||||
shader.fragmentShader = shader.fragmentShader
|
||||
.replace(
|
||||
"#include <common>",
|
||||
`#include <common>
|
||||
uniform vec3 uSwellA;
|
||||
uniform vec3 uSwellB;
|
||||
uniform mat3 uSwellTurn;
|
||||
uniform float uSeaDeep;
|
||||
uniform vec2 uSeaCalm;
|
||||
uniform float uSeaFarRough;`,
|
||||
)
|
||||
// 1. The horizon gives up its specular before it can alias. See
|
||||
// `SEA_CALM_NEAR`.
|
||||
.replace(
|
||||
"#include <roughnessmap_fragment>",
|
||||
`#include <roughnessmap_fragment>
|
||||
float seaFar = smoothstep( uSeaCalm.x, uSeaCalm.y, length( vViewPosition ) );
|
||||
roughnessFactor = mix( roughnessFactor, uSeaFarRough, seaFar );`,
|
||||
)
|
||||
// 2. Two drifting samples of the one swell map instead of the single
|
||||
// static one the material would take, and 3. the Fresnel term on the
|
||||
// body colour. Both live here because both need `tbn` and the
|
||||
// unperturbed normal, which exist only between these two chunks.
|
||||
.replace(
|
||||
"#include <normal_fragment_maps>",
|
||||
`vec3 seaFlatNormal = normal;
|
||||
mat2 seaTurn = mat2( uSwellTurn[0].xy, uSwellTurn[1].xy );
|
||||
vec3 seaNormalA = texture2D( normalMap, vNormalMapUv * uSwellA.z + uSwellA.xy ).xyz * 2.0 - 1.0;
|
||||
vec3 seaNormalB = texture2D( normalMap, seaTurn * ( vNormalMapUv * uSwellB.z ) + uSwellB.xy ).xyz * 2.0 - 1.0;
|
||||
// The second layer was sampled through a rotation, so its slope arrived in the
|
||||
// rotated frame; \`v * M\` is \`transpose(M) * v\`, which is the inverse of a
|
||||
// rotation and turns that slope back into this surface's own frame.
|
||||
seaNormalB.xy = seaNormalB.xy * seaTurn;
|
||||
vec3 mapN = normalize( seaNormalA + seaNormalB );
|
||||
mapN.xy *= normalScale * ( 1.0 - 0.9 * seaFar );
|
||||
normal = normalize( tbn * mapN );
|
||||
float seaFacing = saturate( dot( normalize( vViewPosition ), seaFlatNormal ) );
|
||||
diffuseColor.rgb *= mix( 1.0, uSeaDeep, seaFacing );`,
|
||||
);
|
||||
};
|
||||
// Two materials that compile to different programs must not share a cache
|
||||
// key, and `onBeforeCompile` is invisible to three's default key.
|
||||
material.customProgramCacheKey = () => "tera:sea";
|
||||
|
||||
const sea = new THREE.Mesh(new THREE.PlaneGeometry(size, size), material);
|
||||
sea.rotation.x = -Math.PI / 2;
|
||||
sea.position.set((x0 + x1) / 2, -0.06, (z0 + z1) / 2);
|
||||
sea.receiveShadow = true;
|
||||
sea.name = "sea";
|
||||
/**
|
||||
* The swell drifts from the wall clock, on the mesh's own render hook.
|
||||
*
|
||||
* `createWater` returns a `Group` and its caller adds it to a scene; there is
|
||||
* no `tick` on the way in and adding one would mean a new seam through
|
||||
* `scene.ts` for two uniform writes. `Object3D.onBeforeRender` is the hook
|
||||
* three already runs immediately before this mesh is drawn, which is the only
|
||||
* moment these two values are read.
|
||||
*
|
||||
* `performance.now()` rather than a delta accumulated per frame, because a
|
||||
* phase that is a pure function of the clock cannot drift, cannot double up
|
||||
* if the mesh is ever drawn twice in a frame, and resumes correctly after a
|
||||
* paused tab — a scene that has been backgrounded for a minute wakes up with
|
||||
* the sea where it should be rather than a minute behind.
|
||||
*/
|
||||
sea.onBeforeRender = () => {
|
||||
const t = performance.now() / 1000;
|
||||
swellA.x = SWELL_DRIFT_A.x * t;
|
||||
swellA.y = SWELL_DRIFT_A.y * t;
|
||||
swellB.x = SWELL_DRIFT_B.x * t;
|
||||
swellB.y = SWELL_DRIFT_B.y * t;
|
||||
};
|
||||
group.add(sea);
|
||||
|
||||
for (const poly of world.city.inlandWater) {
|
||||
@@ -217,9 +746,31 @@ export function createWater(world: World): THREE.Group {
|
||||
geo,
|
||||
new THREE.MeshStandardMaterial({
|
||||
color: pal.lake,
|
||||
roughness: 0.2,
|
||||
roughness: 0.24,
|
||||
metalness: 0,
|
||||
side: THREE.DoubleSide,
|
||||
/**
|
||||
* The five-hundredths below is not a separation, at board scale.
|
||||
*
|
||||
* A lake floats over the shore plate, which is the same landmass
|
||||
* polygon lying flat at y=0, and 0.05 units is all there is between
|
||||
* them. On San Francisco — 230 units across, camera a couple of hundred
|
||||
* out — that is comfortably more than one step of the depth buffer and
|
||||
* the two never argue. On the California board the camera stands 570
|
||||
* units off and the depth buffer's resolution *there* is about 0.15
|
||||
* units, three times the gap: the Salton Sea came out banded in
|
||||
* alternating stripes of lake and shore, which reads as a rendering
|
||||
* artefact because it is one.
|
||||
*
|
||||
* Polygon offset is the fix rather than a bigger `y`, because it is
|
||||
* expressed in units of whatever the depth buffer can currently resolve
|
||||
* — it scales itself with distance, where a hard-coded lift would have
|
||||
* to be tuned per board and would leave the lake visibly hovering on the
|
||||
* two boards that never needed it.
|
||||
*/
|
||||
polygonOffset: true,
|
||||
polygonOffsetFactor: -2,
|
||||
polygonOffsetUnits: -4,
|
||||
}),
|
||||
);
|
||||
lake.position.y = 0.05;
|
||||
@@ -228,3 +779,4 @@ export function createWater(world: World): THREE.Group {
|
||||
|
||||
return group;
|
||||
}
|
||||
|
||||
|
||||
@@ -193,6 +193,23 @@ export interface ScenePalette {
|
||||
upland: number;
|
||||
park: number;
|
||||
parkHigh: number;
|
||||
/**
|
||||
* Bare high ground, and **optional on purpose**.
|
||||
*
|
||||
* The unpainted ramp is `flats → upland` over the first 150 m and then flat
|
||||
* forever, which is right for a city — a hill in San Francisco is built to its
|
||||
* summit and the buildings do the talking. On a board measured in hundreds of
|
||||
* kilometres it is not: the Sierra crest at 4,000 m and the Mojave floor at
|
||||
* 600 come out the same number, so a granite skyline and a creosote flat are
|
||||
* one colour and the only thing separating them is the shading.
|
||||
*
|
||||
* A pack that declares this gets a second stop above `upland` — see
|
||||
* `groundColor` in `terrain.ts` for the two elevations it ramps between. A
|
||||
* pack that does not is rendered exactly as before, which is why this is
|
||||
* optional rather than a tenth required colour that every existing pack would
|
||||
* have to answer for.
|
||||
*/
|
||||
alpine?: number;
|
||||
}
|
||||
|
||||
// ---- Lighting -------------------------------------------------------------
|
||||
|
||||
@@ -711,8 +711,9 @@ export function createOfficeScene(office: Office, options: OfficeSceneOptions):
|
||||
// `ExteriorArrival` is optional and a floor plate with no outdoors has nowhere
|
||||
// to put one. The exterior positions everything in the pack's own metres from
|
||||
// the plan origin, so the only transform it needs is the storey its stall is
|
||||
// measured from — a podium deck at level 1 is 188 m off the street, and the
|
||||
// apron stands on the floor of `arrival.levelId` by the exterior's own wording.
|
||||
// measured from — a stall on an upper storey is measured from that storey's
|
||||
// floor, and the apron stands on it. A site with no ground outside gets an
|
||||
// inert exterior instead; see `ArrivalGround` in transport/exteriorVehicle.ts.
|
||||
const arrivalStall = office.site?.arrival;
|
||||
let exterior: OfficeExterior | null = null;
|
||||
if (options.exteriorVehicle && office.site && arrivalStall) {
|
||||
|
||||
+26
-10
@@ -29,12 +29,21 @@
|
||||
*
|
||||
* Two of the three are honest ground. `mateo-court` sits 1.2 m above its street
|
||||
* and `frontier-valley` 4 m above an airfield, so a stall a few metres outside
|
||||
* the façade is a stall on the pavement. **`lumbridge-hq` is 188 m up a tower**
|
||||
* and there is no pavement outside its west wall at all — its anchor is the
|
||||
* kerb of the podium, authored beside the front door because the pack frame is
|
||||
* the only place it can be authored, and what "outside" means vertically for a
|
||||
* tower is the exterior layer's decision and not this file's. It is called out
|
||||
* here rather than left for somebody to discover from a car parked in the sky.
|
||||
* the façade is a stall on the pavement, and both get a kerb, a bay and a car.
|
||||
*
|
||||
* **`lumbridge-hq` is 188 m up a tower** and there is no pavement outside its
|
||||
* west wall at all. Its anchor is still true — a podium kerb does exist at the
|
||||
* foot of that tower — but the only frame a pack can say it in is a storey 188 m
|
||||
* above it, and an earlier build took that literally and parked a Model X in
|
||||
* open sky beside the studio wall. The anchor stays; the exterior layer now
|
||||
* declines to draw an apron for a site whose arrival storey is not on the
|
||||
* ground, on the strength of `elevation` alone. See `ArrivalGround` in
|
||||
* `src/transport/exteriorVehicle.ts` for the rule and for the two other repairs
|
||||
* that were weighed against it.
|
||||
*
|
||||
* This file states the coordinate and never the decision. What is "outside" for
|
||||
* a tower is architecture, and architecture belongs to the layer that renders
|
||||
* it — which is also why the rule reads `site.elevation` and not `id`.
|
||||
*/
|
||||
|
||||
import type { OfficeSite } from "../interiors/types.ts";
|
||||
@@ -60,10 +69,17 @@ export const LUMBRIDGE_HQ_SITE: OfficeSite = {
|
||||
seed: 115,
|
||||
bodyColor: 0x8799a8,
|
||||
},
|
||||
// West of the studio's own front door, which is the doorway 6.8 m along
|
||||
// `ext-west`. Parallel to the façade and nosed north, the way a kerbside bay
|
||||
// on a one-way downtown street runs. See the note at the top of this file
|
||||
// about what 188 m of elevation does to the word "outside".
|
||||
// The podium kerb at the foot of the tower: parallel to the façade and nosed
|
||||
// north, the way a kerbside bay on a one-way downtown street runs, and placed
|
||||
// west of the studio's own front door (the doorway 6.8 m along `ext-west`)
|
||||
// because a pack has no frame but its own to say it in.
|
||||
//
|
||||
// **Nothing renders here.** `elevation: 188` above puts this storey's floor
|
||||
// that far over the pavement, and `officeExterior.ts` builds no apron for a
|
||||
// site off the ground — so this is the address of a kerb rather than the
|
||||
// position of a car, and the field is kept because the telemetry layer and
|
||||
// `Plan.exteriorArrival` both read it and because the kerb is real. See the
|
||||
// note at the top of this file.
|
||||
arrival: {
|
||||
levelId: "level-1",
|
||||
position: { x: -4.0, z: 7.4 },
|
||||
|
||||
@@ -28,6 +28,11 @@ describe("California corridor city", () => {
|
||||
it("uses a state-scale field rather than city-scale cells", () => {
|
||||
assert.ok(CALIFORNIA_CITY.cellLat >= 0.01);
|
||||
assert.ok(CALIFORNIA_CITY.cellLng >= 0.01);
|
||||
assert.equal(CALIFORNIA_CITY.districts.length, 0);
|
||||
// This used to assert zero districts, which was the pack's old promise that
|
||||
// the state board carried no cities at all — and that emptiness was the
|
||||
// defect, not the design. The board now declares its metros on purpose; a
|
||||
// coarse *field* is what makes it state-scale, not an absence of built
|
||||
// things. `packs/californiaBoard.test.ts` holds the districts to account.
|
||||
assert.ok(CALIFORNIA_CITY.districts.length >= 12);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,247 @@
|
||||
/**
|
||||
* The two properties that decide whether the live thing in this sky exists for
|
||||
* a visitor: **can they see it**, and **can they hit it**.
|
||||
*
|
||||
* Both used to be false on the deployed build, and neither failure could be
|
||||
* caught by any test in the repo, because both are about *apparent* size. The
|
||||
* aircraft were positioned correctly, oriented correctly, coloured correctly and
|
||||
* drawn at two thirds of a pixel — 0.42 scene units on a board the camera stands
|
||||
* eleven hundred units back from. Every existing assertion about the flight
|
||||
* layer passed on that build. A person looking at it saw an empty sky with a
|
||||
* couple of smudges on the monitor.
|
||||
*
|
||||
* So what is asserted here is the *arithmetic that survives* the picture:
|
||||
*
|
||||
* - `glyphScale` is what turns a camera distance into a legible size, and the
|
||||
* thing worth pinning is not the constant but the **identity**: whatever
|
||||
* scale comes back, the glyph's share of the frame is the floor. A future
|
||||
* refactor that halves the constant and doubles the divisor would leave
|
||||
* every number in the function looking sensible and every aeroplane at half
|
||||
* the size, and that is exactly what this catches.
|
||||
* - `AIRLINER_LENGTH` is the unit that identity is measured in, so it is held
|
||||
* against the geometry's own bounding box rather than against itself. The
|
||||
* constant and the mesh drifting apart is a one-character mistake in
|
||||
* `aircraftGeometry.ts` that nothing else would notice.
|
||||
* - The pick target is a **sphere around the glyph** rather than the glyph's
|
||||
* triangles, which is the whole of "clicking a plane works for a stranger".
|
||||
* It is asserted through `Raycaster` — the same class `scenekit.ts` uses —
|
||||
* rather than by reaching into `Mesh.raycast`, because the question is
|
||||
* whether a ray *near* an aeroplane resolves to it.
|
||||
* - A track that is being held through a dropped refresh but is no longer
|
||||
* drawn is no longer clickable. `Raycaster` does not consult `visible`, so
|
||||
* this is the one place in the layer where "not drawn" and "not there" have
|
||||
* to be said twice.
|
||||
*
|
||||
* The world here is the flat two-line stand-in the integration suite already
|
||||
* uses: this layer's whole contact with a `World` is `project` and `metres`, and
|
||||
* a real heightfield is half a million samples of nothing to do with any of it.
|
||||
*/
|
||||
|
||||
import assert from "node:assert/strict";
|
||||
import { describe, it } from "node:test";
|
||||
import * as THREE from "three";
|
||||
import { AIRLINER_LENGTH, airlinerGeometry } from "../../engine/aircraftGeometry.ts";
|
||||
import { createFlightLayer, glyphScale } from "../../engine/flights.ts";
|
||||
import type { Aircraft } from "../../engine/types.ts";
|
||||
|
||||
/** Enough of a `World` for the flight layer: a projection and a vertical scale. */
|
||||
const flatWorld = {
|
||||
project: (lat: number, lng: number) => [lng * 100, -lat * 100],
|
||||
metres: (m: number) => m / 100,
|
||||
metresPerUnit: 100,
|
||||
} as unknown as Parameters<typeof createFlightLayer>[0];
|
||||
|
||||
/**
|
||||
* The floor `flights.ts` states in its own comments, written out again.
|
||||
*
|
||||
* Restated rather than exported for the same reason `flights.test.ts` restates
|
||||
* `TRAIL_POINTS`: a test that imports the constant it is checking asserts only
|
||||
* that the code agrees with itself, and would follow a typo into production.
|
||||
*/
|
||||
const MIN_SCREEN_FRACTION = 0.016;
|
||||
|
||||
/** A camera's vertical view extent at a distance, in world units. */
|
||||
function frustumHeight(distance: number, fovDegrees: number): number {
|
||||
return 2 * distance * Math.tan((fovDegrees * Math.PI) / 360);
|
||||
}
|
||||
|
||||
function jet(id: string, lat: number, lng: number, altitude = 9_000): Aircraft {
|
||||
return { id, callsign: id.toUpperCase(), lat, lng, altitude, heading: 90 };
|
||||
}
|
||||
|
||||
describe("the legibility floor", () => {
|
||||
it("holds an aeroplane at the same share of the frame however far away it is", () => {
|
||||
// A chapter's standoff, a board span, and the far end of the orbit over the
|
||||
// California corridor — the three distances a visitor actually looks from.
|
||||
for (const distance of [120, 580, 1_160]) {
|
||||
for (const fov of [42, 60]) {
|
||||
const share = (glyphScale(distance, fov) * AIRLINER_LENGTH) / frustumHeight(distance, fov);
|
||||
assert.ok(
|
||||
Math.abs(share - MIN_SCREEN_FRACTION) < 1e-9,
|
||||
`at ${distance} units and ${fov}° the glyph is ${share} of the frame, not ${MIN_SCREEN_FRACTION}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it("is a floor and not a fit: close up, the authored geometry wins", () => {
|
||||
// Inside about 34 units the aeroplane is already legible at the size
|
||||
// `aircraftGeometry.ts` drew it, and enlarging it there would park a
|
||||
// state-sized airliner over a downtown.
|
||||
assert.equal(glyphScale(1, 42), 1);
|
||||
assert.equal(glyphScale(20, 42), 1);
|
||||
assert.ok(glyphScale(60, 42) > 1, "past a chapter's standoff it has to grow");
|
||||
// Monotonic, so the scale never jumps as the camera pulls back.
|
||||
let previous = 0;
|
||||
for (let d = 1; d < 1_200; d += 37) {
|
||||
const scale = glyphScale(d, 42);
|
||||
assert.ok(scale >= previous, `scale went backwards at ${d}`);
|
||||
previous = scale;
|
||||
}
|
||||
});
|
||||
|
||||
it("survives a camera that is not ready to be asked", () => {
|
||||
// A chase camera sitting on the aircraft, and a caller mid-setup. Neither is
|
||||
// a reason for the sky to vanish or to fill with NaN matrices.
|
||||
for (const bad of [0, -1, Number.NaN, Number.POSITIVE_INFINITY]) {
|
||||
assert.equal(glyphScale(bad, 42), 1, `distance ${bad}`);
|
||||
assert.equal(glyphScale(500, bad), 1, `fov ${bad}`);
|
||||
}
|
||||
assert.equal(glyphScale(500, 180), 1, "a degenerate field of view is not a size");
|
||||
});
|
||||
|
||||
it("measures the aeroplane the geometry actually built", () => {
|
||||
const geo = airlinerGeometry();
|
||||
geo.computeBoundingBox();
|
||||
const box = geo.boundingBox;
|
||||
assert.ok(box, "the airliner has no bounding box");
|
||||
const length = box.max.z - box.min.z;
|
||||
assert.ok(
|
||||
Math.abs(length - AIRLINER_LENGTH) < 1e-6,
|
||||
`the constant says ${AIRLINER_LENGTH} and the mesh is ${length} long`,
|
||||
);
|
||||
// Every extremity inside one glyph length of the origin, which is what makes
|
||||
// that length usable as the pick radius as well as as the size.
|
||||
const reach = Math.max(
|
||||
box.max.length(),
|
||||
box.min.length(),
|
||||
new THREE.Vector3(box.max.x, box.max.y, box.min.z).length(),
|
||||
);
|
||||
assert.ok(reach < AIRLINER_LENGTH, `an extremity reaches ${reach}, outside the pick sphere`);
|
||||
geo.dispose();
|
||||
});
|
||||
});
|
||||
|
||||
describe("pointing at an aeroplane", () => {
|
||||
/**
|
||||
* A camera looking down the −Z axis from above, and a ray through a point
|
||||
* offset from the aeroplane by `offsetUnits` in world X.
|
||||
*
|
||||
* Built with `Raycaster.set` rather than `setFromCamera` so the offset is in
|
||||
* world units and the assertion is about the *size of the target* rather than
|
||||
* about a projection matrix.
|
||||
*/
|
||||
function rayAt(x: number, y: number, z: number): THREE.Raycaster {
|
||||
return new THREE.Raycaster(new THREE.Vector3(x, y, z + 50), new THREE.Vector3(0, 0, -1));
|
||||
}
|
||||
|
||||
it("hits a sphere around the glyph rather than its wings", () => {
|
||||
const layer = createFlightLayer(flatWorld);
|
||||
try {
|
||||
layer.update([jet("a1b2c3", 0, 0)]);
|
||||
const mesh = layer.pickables[0];
|
||||
assert.ok(mesh, "no pick target for an aircraft that is in the sky");
|
||||
mesh.updateMatrixWorld(true);
|
||||
const at = mesh.position;
|
||||
|
||||
// Dead centre, and then off to one side by two thirds of a glyph length —
|
||||
// which is past the wingtips and past the nose, and is empty space as far
|
||||
// as the aeroplane's triangles are concerned.
|
||||
for (const offset of [0, 0.66 * AIRLINER_LENGTH]) {
|
||||
const hits = rayAt(at.x + offset, at.y, at.z).intersectObjects(layer.pickables, false);
|
||||
assert.equal(hits.length, 1, `nothing under a pointer ${offset} units from the aeroplane`);
|
||||
assert.equal(hits[0]?.object.userData.aircraftId, "a1b2c3");
|
||||
assert.ok(Number.isFinite(hits[0]?.distance), "a hit with no distance cannot be sorted");
|
||||
}
|
||||
|
||||
// Well outside it, though, is still empty sky. The target is generous, not
|
||||
// unbounded: a card that opens when the pointer is nowhere near an
|
||||
// aeroplane is a different bug with the same cause.
|
||||
const miss = rayAt(at.x + 3 * AIRLINER_LENGTH, at.y, at.z).intersectObjects(
|
||||
layer.pickables,
|
||||
false,
|
||||
);
|
||||
assert.equal(miss.length, 0, "the whole sky is not an aeroplane");
|
||||
} finally {
|
||||
layer.dispose();
|
||||
}
|
||||
});
|
||||
|
||||
it("grows the target with the glyph, so a distant aeroplane is no harder to hit", () => {
|
||||
const layer = createFlightLayer(flatWorld);
|
||||
try {
|
||||
layer.update([jet("a1b2c3", 0, 0)]);
|
||||
const mesh = layer.pickables[0] as THREE.Mesh;
|
||||
assert.ok(mesh);
|
||||
// What `tick` does when the camera is a long way off. The scale is the one
|
||||
// channel the pick radius reads, so setting it is the whole of the test.
|
||||
mesh.scale.setScalar(12);
|
||||
mesh.updateMatrixWorld(true);
|
||||
const at = mesh.position;
|
||||
const hits = rayAt(at.x + 8 * AIRLINER_LENGTH, at.y, at.z).intersectObjects(
|
||||
layer.pickables,
|
||||
false,
|
||||
);
|
||||
assert.equal(hits.length, 1, "a scaled-up glyph must scale its target with it");
|
||||
} finally {
|
||||
layer.dispose();
|
||||
}
|
||||
});
|
||||
|
||||
it("stops being clickable at the moment it stops being drawn", () => {
|
||||
/**
|
||||
* A track missing from a snapshot is held for `TRACK_GRACE_SECONDS` so a
|
||||
* dropped ADS-B refresh does not throw away a minute of trail. `tick` stops
|
||||
* *drawing* it once it has finished arriving where it was last seen — and
|
||||
* `Raycaster` never looks at `visible`, so without an explicit removal the
|
||||
* card goes on opening from a patch of empty sky for half a minute.
|
||||
*/
|
||||
const realNow = performance.now;
|
||||
let clockMs = 0;
|
||||
performance.now = () => clockMs;
|
||||
const layer = createFlightLayer(flatWorld);
|
||||
try {
|
||||
layer.update([jet("a1b2c3", 0, 0), jet("ddeeff", 0.2, 0.2)]);
|
||||
clockMs = 10_000;
|
||||
layer.update([jet("a1b2c3", 0.1, 0.1), jet("ddeeff", 0.3, 0.3)]);
|
||||
assert.equal(layer.pickables.length, 2);
|
||||
|
||||
// One target goes quiet. It keeps flying to where it was last seen…
|
||||
clockMs = 20_000;
|
||||
layer.update([jet("a1b2c3", 0.2, 0.2)]);
|
||||
assert.equal(layer.pickables.length, 2, "a target absent for one refresh is still in the sky");
|
||||
|
||||
// …and then it is not drawn any more, so it is not a pick target either.
|
||||
clockMs = 45_000;
|
||||
layer.update([jet("a1b2c3", 0.3, 0.3)]);
|
||||
assert.deepEqual(
|
||||
layer.pickables.map((object) => object.userData.aircraftId),
|
||||
["a1b2c3"],
|
||||
"an aeroplane nobody can see must not open a card",
|
||||
);
|
||||
|
||||
// And it comes back, rather than being permanently unclickable.
|
||||
clockMs = 55_000;
|
||||
layer.update([jet("a1b2c3", 0.4, 0.4), jet("ddeeff", 0.4, 0.4)]);
|
||||
assert.equal(layer.pickables.length, 2);
|
||||
assert.equal(
|
||||
new Set(layer.pickables).size,
|
||||
2,
|
||||
"a returning aeroplane must not be listed twice",
|
||||
);
|
||||
} finally {
|
||||
layer.dispose();
|
||||
performance.now = realNow;
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,316 @@
|
||||
/**
|
||||
* The default board's shape, its dry corner, and the three defects around them
|
||||
* that only a photograph ever caught.
|
||||
*
|
||||
* This file exists beside `packs/californiaBoard.test.ts` rather than inside it
|
||||
* because everything here crosses a seam: the pack decides where the state
|
||||
* stops, `terrain.ts` decides what colour the ground at that elevation is, and
|
||||
* `scene.ts` decides how far back to stand. Each of the three below typechecked,
|
||||
* rendered without a console error and met every performance budget.
|
||||
*
|
||||
* 1. **A state that was not the shape of the state.** The land polygon closed
|
||||
* with three ruled segments — one running due north along -117.45 for five
|
||||
* degrees of latitude — so San Diego, the Peninsular Ranges, the Colorado
|
||||
* Desert, the Salton basin and half the Mojave were all rendered as open
|
||||
* ocean, and California sat on the water as a paper dart.
|
||||
* 2. **A desert with no second value in it.** `groundColor` saturates at
|
||||
* 150 m, so the Mojave floor at 620 m and the Sierra crest at 4,300 were
|
||||
* painted exactly the same number and the only thing separating a granite
|
||||
* skyline from a creosote flat was the Lambert term.
|
||||
* 3. **An opening pose that framed a third of the board.** `fov` is vertical,
|
||||
* so the horizontal half-angle is a fact about the shape of the window: the
|
||||
* pose that held the whole state on a laptop held its middle third on a
|
||||
* phone, with the Sierra and the Colorado off both sides of the screen.
|
||||
*/
|
||||
|
||||
import assert from "node:assert/strict";
|
||||
import { describe, it } from "node:test";
|
||||
import * as THREE from "three";
|
||||
|
||||
import CALIFORNIA_CITY from "../../cities/california.ts";
|
||||
import SOCAL_CITY from "../../cities/socal.ts";
|
||||
import { chapterFraming } from "../../engine/scene.ts";
|
||||
import { createTerrain, createWater } from "../../engine/terrain.ts";
|
||||
import type { City, LatLng } from "../../engine/types.ts";
|
||||
import { World } from "../../engine/world.ts";
|
||||
|
||||
/**
|
||||
* A `World` whose heightfield is already up.
|
||||
*
|
||||
* `ready()` waits on a paint that never comes under the Node test runner, so
|
||||
* this takes the documented synchronous path: `lattice()` builds the field on
|
||||
* the calling thread when nobody awaited `ready()`.
|
||||
*/
|
||||
function builtWorld(city: City): World {
|
||||
const world = new World(city);
|
||||
world.lattice();
|
||||
return world;
|
||||
}
|
||||
|
||||
/**
|
||||
* The Nevada line, as the pack draws it: one ruled segment from the corner at
|
||||
* Lake Tahoe to the Arizona/Nevada/California tri-point on the Colorado.
|
||||
*/
|
||||
function nevadaLineLng(lat: number): number {
|
||||
return -114.6339 - 1.3421 * (lat - 35.0016);
|
||||
}
|
||||
|
||||
describe("the California silhouette", () => {
|
||||
const world = new World(CALIFORNIA_CITY);
|
||||
|
||||
it("puts the state's own edges on three sides of the board", () => {
|
||||
// Inside, and every one of them was ocean on the board that shipped before.
|
||||
for (const [name, lat, lng] of [
|
||||
["San Diego", 32.72, -117.16],
|
||||
["the Laguna Mountains", 32.78, -116.4],
|
||||
["the Imperial Valley", 32.85, -115.5],
|
||||
["Palm Springs", 33.82, -116.54],
|
||||
["Joshua Tree", 33.98, -116.1],
|
||||
["the eastern Mojave", 34.9, -115.6],
|
||||
["Death Valley", 36.25, -116.83],
|
||||
["Needles", 34.8, -114.7],
|
||||
] as [string, number, number][]) {
|
||||
assert.equal(world.isLand(lat, lng), true, `${name} should be on the board`);
|
||||
}
|
||||
|
||||
// Outside, and each one is a different edge doing its job.
|
||||
for (const [name, lat, lng] of [
|
||||
["Baja California, below the border", 32.3, -116.6],
|
||||
["Arizona, across the Colorado at Yuma", 32.75, -114.3],
|
||||
["Arizona, across the river at Parker", 34.2, -114.05],
|
||||
["Nevada, east of the line at Las Vegas' latitude", 36.1, -115.2],
|
||||
["Nevada, east of the line above Bishop", 37.4, -117.2],
|
||||
["the Pacific, west of Point Conception", 34.3, -120.8],
|
||||
] as [string, number, number][]) {
|
||||
assert.equal(world.isLand(lat, lng), false, `${name} should be off the board`);
|
||||
}
|
||||
});
|
||||
|
||||
it("follows the Nevada line rather than a meridian", () => {
|
||||
// A degree of latitude apart, and the boundary moves 1.34° of longitude with
|
||||
// it. A vertical closure — which is what this used to be — passes the first
|
||||
// of these and fails the third.
|
||||
for (const lat of [35.4, 36.4, 37.4, 38.0]) {
|
||||
const edge = nevadaLineLng(lat);
|
||||
assert.equal(world.isLand(lat, edge - 0.25), true, `${lat}N inside the line`);
|
||||
assert.equal(world.isLand(lat, edge + 0.25), false, `${lat}N outside the line`);
|
||||
}
|
||||
});
|
||||
|
||||
it("keeps the Salton Sea as inland water inside a land polygon", () => {
|
||||
assert.equal(CALIFORNIA_CITY.inlandWater.length, 1);
|
||||
// Water at the middle, land on both shores. A lake that has swallowed its
|
||||
// own basin and a lake that is not there at all both pass a length check.
|
||||
assert.equal(world.isLand(33.31, -115.84), false, "the middle of the sea");
|
||||
assert.equal(world.isLand(33.31, -116.15), true, "the western shore");
|
||||
assert.equal(world.isLand(33.31, -115.55), true, "the eastern shore");
|
||||
});
|
||||
|
||||
it("stands real relief in the corner that used to be a blank flat", () => {
|
||||
const built = builtWorld(CALIFORNIA_CITY);
|
||||
// The named ranges, each of which is a chain and not a single bell.
|
||||
for (const [name, lat, lng, floor] of [
|
||||
["San Gorgonio", 34.12, -116.86, 2_600],
|
||||
["San Jacinto", 33.81, -116.67, 2_400],
|
||||
["Telescope Peak", 36.2, -117.22, 2_400],
|
||||
["the Providence Mountains", 34.9, -115.62, 1_400],
|
||||
["the New York Mountains", 35.23, -115.32, 1_400],
|
||||
["the Kingston Range", 35.72, -115.91, 1_400],
|
||||
] as [string, number, number, number][]) {
|
||||
const metres = built.elevationAt(lat, lng);
|
||||
assert.ok(metres > floor, `${name} is only ${Math.round(metres)} m`);
|
||||
}
|
||||
});
|
||||
|
||||
it("draws those ranges as ridges rather than as a field of domes", () => {
|
||||
const built = builtWorld(CALIFORNIA_CITY);
|
||||
/**
|
||||
* The test for a ridge is anisotropy: walking *along* a range front stays
|
||||
* high and walking *across* it falls away, and the ratio between the two is
|
||||
* the whole difference between the board that reads as basin and range and
|
||||
* the board that read as bubble wrap. A single radial bell — which is what
|
||||
* each of these used to be — scores exactly 1 here by construction, because
|
||||
* `(1 - d²)²` has no direction in it.
|
||||
*
|
||||
* The two step vectors are derived from the range's own end points rather
|
||||
* than written by hand, and longitude is squashed by `cos(centre latitude)`
|
||||
* on the way in and out, because that is what `elevationAt` does when it
|
||||
* measures a distance and a step that ignores it is 22% short east-west.
|
||||
*/
|
||||
const SQUASH = Math.cos((CALIFORNIA_CITY.center.lat * Math.PI) / 180);
|
||||
const ranges: Array<{ name: string; from: LatLng; to: LatLng; step: number }> = [
|
||||
{ name: "the Providence Mountains", from: [34.72, -115.78], to: [35.08, -115.46], step: 0.15 },
|
||||
{ name: "the Old Woman Mountains", from: [34.3, -115.38], to: [34.68, -115.12], step: 0.15 },
|
||||
{ name: "the Panamint Range", from: [35.92, -117.22], to: [36.56, -117.24], step: 0.18 },
|
||||
];
|
||||
|
||||
for (const { name, from, to, step } of ranges) {
|
||||
const mid: LatLng = [(from[0] + to[0]) / 2, (from[1] + to[1]) / 2];
|
||||
const dLat = to[0] - from[0];
|
||||
const dLng = (to[1] - from[1]) * SQUASH;
|
||||
const length = Math.hypot(dLat, dLng);
|
||||
const along: LatLng = [(dLat / length) * step, ((dLng / length) * step) / SQUASH];
|
||||
const across: LatLng = [(-dLng / length) * step, ((dLat / length) * step) / SQUASH];
|
||||
|
||||
const at = (offset: LatLng, sign: number): number =>
|
||||
built.elevationAt(mid[0] + offset[0] * sign, mid[1] + offset[1] * sign);
|
||||
const crest = built.elevationAt(mid[0], mid[1]);
|
||||
const alongLow = Math.min(at(along, 1), at(along, -1));
|
||||
const acrossLow = Math.min(at(across, 1), at(across, -1));
|
||||
|
||||
assert.ok(crest > 800, `${name} is only ${Math.round(crest)} m at its middle`);
|
||||
assert.ok(
|
||||
alongLow > acrossLow * 1.6,
|
||||
`${name} falls to ${Math.round(alongLow)} m along the range and ` +
|
||||
`${Math.round(acrossLow)} m across it, which is a dome and not a ridge`,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
it("leaves Badwater a salt pan rather than lifting it onto farmland", () => {
|
||||
const built = builtWorld(CALIFORNIA_CITY);
|
||||
// Under 3 m is `palette.sand`, which is the beach colour and is exactly what
|
||||
// the floor of Death Valley should be. The Black Mountains' radius is the
|
||||
// only thing holding this: at 0.18 the chain reached across the valley and
|
||||
// put the pan at 52 m, which is `flats` gold.
|
||||
assert.ok(built.elevationAt(36.25, -116.83) < 3, "Badwater has been filled in");
|
||||
// And it is a pan between two walls, not a plain.
|
||||
assert.ok(built.elevationAt(36.2, -117.22) > 2_400, "the Panamints");
|
||||
assert.ok(built.elevationAt(36.15, -116.66) > 900, "the Black Mountains");
|
||||
});
|
||||
});
|
||||
|
||||
describe("the third colour stop", () => {
|
||||
/** Every emitted vertex colour, and the lattice index it came from. */
|
||||
function terrainColours(city: City): { world: World; colourAt: (lat: number, lng: number) => THREE.Color } {
|
||||
const world = builtWorld(city);
|
||||
const mesh = createTerrain(world);
|
||||
const position = mesh.geometry.getAttribute("position");
|
||||
const colour = mesh.geometry.getAttribute("color");
|
||||
return {
|
||||
world,
|
||||
colourAt: (lat, lng) => {
|
||||
const [x, z] = world.project(lat, lng);
|
||||
let best = -1;
|
||||
let bestDistance = Infinity;
|
||||
for (let i = 0; i < position.count; i += 1) {
|
||||
const d = Math.hypot(position.getX(i) - x, position.getZ(i) - z);
|
||||
if (d < bestDistance) {
|
||||
bestDistance = d;
|
||||
best = i;
|
||||
}
|
||||
}
|
||||
return new THREE.Color(colour.getX(best), colour.getY(best), colour.getZ(best));
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
it("separates the Sierra crest from the Mojave floor, which used to be one value", () => {
|
||||
const { colourAt } = terrainColours(CALIFORNIA_CITY);
|
||||
// Both are unpainted ground — no park envelope reaches either — so before
|
||||
// `alpine` existed these two returned the identical `upland` hex however far
|
||||
// apart they are in altitude.
|
||||
const crest = colourAt(36.62, -118.29); // Mount Whitney, 4,300 m
|
||||
const desert = colourAt(34.9, -117.0); // the Mojave floor, about 600 m
|
||||
const brightness = (c: THREE.Color): number => (c.r + c.g + c.b) / 3;
|
||||
assert.ok(
|
||||
brightness(crest) > brightness(desert) * 1.15,
|
||||
`the crest (${brightness(crest).toFixed(3)}) is no lighter than the desert ` +
|
||||
`(${brightness(desert).toFixed(3)})`,
|
||||
);
|
||||
// Lighter *and* less saturated: bare granite is grey and a desert fan is
|
||||
// brown, and a ramp that only raised the value would wash the desert out
|
||||
// rather than change what it is.
|
||||
const chroma = (c: THREE.Color): number =>
|
||||
Math.max(c.r, c.g, c.b) - Math.min(c.r, c.g, c.b);
|
||||
assert.ok(chroma(crest) < chroma(desert), "the crest is as warm as the desert");
|
||||
});
|
||||
|
||||
it("leaves a pack that declares no `alpine` exactly as it was", () => {
|
||||
// The colour is optional for this reason: Southern California is 308 units
|
||||
// across with a 3,000 m wall on it and has never wanted a snow line, and a
|
||||
// tenth required palette entry would have made every existing pack answer
|
||||
// for one.
|
||||
assert.equal(SOCAL_CITY.palette?.alpine, undefined);
|
||||
const { colourAt } = terrainColours(SOCAL_CITY);
|
||||
const high = colourAt(34.29, -117.65); // Mount San Antonio, the board's roof
|
||||
const low = colourAt(34.05, -117.9); // the basin floor below it
|
||||
const brightness = (c: THREE.Color): number => (c.r + c.g + c.b) / 3;
|
||||
// Not identical — one is in a forest envelope and one is not — but the high
|
||||
// ground must not have been *lightened*, which is what a stop applied to a
|
||||
// pack that never asked for one would do.
|
||||
assert.ok(
|
||||
brightness(high) <= brightness(low) + 0.02,
|
||||
"Southern California's high ground picked up a snow line it never declared",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("the sea loses every depth argument it should never have won", () => {
|
||||
it("pushes the ocean behind the ground it is under", () => {
|
||||
/*
|
||||
* The sea sits 0.06 units below y=0 and the terrain's coastal rim 0.012
|
||||
* above it, and at a whole-board pose on the Bay Area that whole gap is a
|
||||
* fifth of one step of the depth buffer. Which surface wins was decided by
|
||||
* float error in a two-triangle plane eighteen board spans across, and what
|
||||
* it looked like was the ocean dithered over every flat piece of ground on
|
||||
* the board — on the two detailed boards, not on the state one, whose valley
|
||||
* floor stands at 28 m.
|
||||
*
|
||||
* Asserted on the material rather than on a picture because a picture is
|
||||
* what it took to find, and the whole point of the assertion is that the
|
||||
* next person does not need one.
|
||||
*/
|
||||
const water = createWater(builtWorld(SOCAL_CITY));
|
||||
const sea = water.getObjectByName("sea");
|
||||
assert.ok(sea instanceof THREE.Mesh, "no sea in the water group");
|
||||
const material = (sea as THREE.Mesh).material as THREE.Material;
|
||||
assert.equal(material.polygonOffset, true, "the sea does not yield in depth");
|
||||
assert.ok(
|
||||
material.polygonOffsetUnits > 0 && material.polygonOffsetFactor > 0,
|
||||
"the sea's polygon offset pulls it toward the camera rather than away",
|
||||
);
|
||||
water.traverse((object) => {
|
||||
if (object instanceof THREE.Mesh) object.geometry.dispose();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("chapter framing on a window the pose was not written for", () => {
|
||||
// The California board: 428 units across, an opening pose reaching about 600,
|
||||
// an orbit ceiling at two board spans.
|
||||
const board = { boardSpan: 428, orbitMax: 856 };
|
||||
|
||||
it("leaves a wide window alone", () => {
|
||||
for (const aspect of [1.6, 1.78, 2.1]) {
|
||||
assert.equal(chapterFraming({ aspect, reach: 600, ...board }), 1);
|
||||
}
|
||||
});
|
||||
|
||||
it("stands back on a phone held upright", () => {
|
||||
const scale = chapterFraming({ aspect: 390 / 844, reach: 600, ...board });
|
||||
assert.ok(scale > 1.2, `only ${scale.toFixed(2)}× on a portrait screen`);
|
||||
// And never past the orbit's ceiling, because `OrbitControls` clamps to it
|
||||
// on the next update and a pose beyond it is a pose nobody wrote.
|
||||
assert.ok(600 * scale <= board.orbitMax + 1e-6, "the pose is outside maxDistance");
|
||||
});
|
||||
|
||||
it("leaves a close-up alone on any window", () => {
|
||||
// Southern California's opening shot stands off about half a board span and
|
||||
// is deliberately inside its subject. Pulling it back is a different
|
||||
// photograph, not a correction.
|
||||
assert.equal(chapterFraming({ aspect: 390 / 844, reach: 150, boardSpan: 308, orbitMax: 616 }), 1);
|
||||
// As is a driving chapter on the state board.
|
||||
assert.equal(chapterFraming({ aspect: 390 / 844, reach: 133, ...board }), 1);
|
||||
});
|
||||
|
||||
it("never returns something a camera cannot use", () => {
|
||||
for (const reach of [0, -1, Number.NaN, Number.POSITIVE_INFINITY]) {
|
||||
assert.equal(chapterFraming({ aspect: 0.46, reach, ...board }), 1);
|
||||
}
|
||||
for (const aspect of [0, -1, Number.NaN]) {
|
||||
const scale = chapterFraming({ aspect, reach: 600, ...board });
|
||||
assert.ok(Number.isFinite(scale) && scale >= 1, `aspect ${aspect} gave ${scale}`);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -50,6 +50,7 @@ const LUMBRIDGE_HQ = (await import("../../offices/lumbridge-hq.ts")).default;
|
||||
const MATEO_COURT = (await import("../../offices/mateo-court.ts")).default;
|
||||
const { initialDeviceState } = await import("../../devices/types.ts");
|
||||
const { createSimulatedVehicleTelemetry } = await import("../../transport/vehicleTelemetry.ts");
|
||||
const { arrivalGroundFor } = await import("../../transport/exteriorVehicle.ts");
|
||||
|
||||
type Office = typeof LUMBRIDGE_HQ;
|
||||
|
||||
@@ -277,7 +278,7 @@ test("a device reading reaches the indicator on the hardware", () => {
|
||||
// ---- The exterior ----------------------------------------------------------
|
||||
|
||||
for (const [id, pack] of PACKS) {
|
||||
test(`${id}: a Model X is parked on the pack's arrival stall`, () => {
|
||||
test(`${id}: the exterior matches the ground its site actually has`, () => {
|
||||
const arrival = pack.site?.arrival;
|
||||
assert.ok(arrival, `${id} authors no arrival anchor`);
|
||||
const { scene, rig } = build(pack);
|
||||
@@ -291,8 +292,19 @@ for (const [id, pack] of PACKS) {
|
||||
exterior.traverse((object) => {
|
||||
if (object.userData.vehicleModel === "model-x") car = object;
|
||||
});
|
||||
assert.ok(car !== null, "the apron was built without a car on it");
|
||||
|
||||
// An apron is ground, and `lumbridge-hq`'s studio floor is 188 m above the
|
||||
// pavement outside it. Built literally at the stall's own storey it is a
|
||||
// paved pad and a car in open sky beside the west wall, which is what this
|
||||
// shipped for one build. Read off the site rather than the id, because
|
||||
// reading off the site is exactly what the layer under test must do.
|
||||
if (arrivalGroundFor(pack.site?.elevation ?? 0) === "air") {
|
||||
assert.equal(car, null, `${id} parked a car in the air`);
|
||||
assert.equal(exterior.children.length, 0, "an off-ground site draws nothing");
|
||||
return;
|
||||
}
|
||||
|
||||
assert.ok(car !== null, "the apron was built without a car on it");
|
||||
const at = (car as THREE.Object3D).getWorldPosition(new THREE.Vector3());
|
||||
const floorY = scene.plan.level(arrival.levelId)?.floorY ?? 0;
|
||||
assert.ok(
|
||||
@@ -302,8 +314,8 @@ for (const [id, pack] of PACKS) {
|
||||
);
|
||||
assert.ok(
|
||||
Math.abs(at.y - floorY) <= 1.5,
|
||||
`the apron must stand on the floor of ${arrival.levelId} (${floorY} m), not at the ` +
|
||||
`plan origin — a podium deck is the whole reason the stall names a storey`,
|
||||
`the apron must stand on the floor of ${arrival.levelId} (${floorY} m), not at ` +
|
||||
`the plan origin — the stall names a storey for a reason`,
|
||||
);
|
||||
} finally {
|
||||
scene.dispose();
|
||||
@@ -557,10 +569,31 @@ test("the water reflects rather than absorbing, now that there is a sky to refle
|
||||
"Lambert has no specular term at all, which is why half the California board " +
|
||||
"rendered as one flat blue value at every hour and from every angle",
|
||||
);
|
||||
// Assert the PROPERTY, not the formatting. The first draft of this test
|
||||
// matched one exact source line — `MeshStandardMaterial({ color: pal.sea,
|
||||
// roughness: 0.14, metalness: 0 })` — and the sea that shipped is a
|
||||
// multi-line construction at roughness 0.2 with an `onBeforeCompile` patch
|
||||
// that flattens it toward the horizon. The implementation was better than the
|
||||
// literal and the test failed anyway, which is the failure mode of asserting
|
||||
// on source text: it pins the author's first guess rather than the behaviour.
|
||||
const build = water.slice(0, water.indexOf("\n}"));
|
||||
assert.match(
|
||||
build,
|
||||
/MeshStandardMaterial\(/,
|
||||
"the sea must be a dielectric that reads `scene.environment`",
|
||||
);
|
||||
|
||||
const roughness = Number(/roughness:\s*([\d.]+)/.exec(build)?.[1]);
|
||||
assert.ok(
|
||||
/MeshStandardMaterial\(\{ color: pal\.sea, roughness: 0\.14, metalness: 0 \}\)/.test(source),
|
||||
"the sea must be a low-roughness dielectric, so it takes both the sun's glint " +
|
||||
"and `scene.environment`",
|
||||
Number.isFinite(roughness) && roughness > 0 && roughness <= 0.35,
|
||||
`the sea's roughness is ${roughness}; it must be low enough to return a sun ` +
|
||||
`glint and a sky reflection, and above zero so it is water and not a mirror`,
|
||||
);
|
||||
assert.match(
|
||||
build,
|
||||
/metalness:\s*0\b/,
|
||||
"water is a dielectric: metalness 0 is what makes the reflection white " +
|
||||
"rather than tinted by the sea colour",
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
@@ -0,0 +1,228 @@
|
||||
/**
|
||||
* Can a stranger on a phone read a detail card at all?
|
||||
*
|
||||
* The answer on the deployed build was **no**, for every card the product has —
|
||||
* an aeroplane, a marker, a landmark — and nothing in the suite could see it.
|
||||
* The pick was correct, `mount.ts` wrote the card's text into `#detail`, and
|
||||
* about 32 ms later the card was blanked again. `#detail.textContent` still
|
||||
* held `SIM 1 / Simulated track — no receiver involved / …` while
|
||||
* `#detail.hidden` was `true`, continuously, for as long as you cared to watch.
|
||||
*
|
||||
* The cause is the one part of a tap that is not a touch. Chrome finishes a tap
|
||||
* by replaying it as the legacy mouse events pages were written against before
|
||||
* pointer events existed, and the tail of that replay — recorded off a real tap
|
||||
* on the canvas — is:
|
||||
*
|
||||
* pointerdown/touch, pointerup/touch, pointerout/touch, pointerleave/touch,
|
||||
* mousemove, click/touch, pointerout/MOUSE, pointerleave/MOUSE
|
||||
*
|
||||
* `onPointerLeave` filtered on `pointerType === "touch"`, which correctly
|
||||
* ignored the fourth event and then honoured the eighth. A `pointerleave`
|
||||
* calling itself a mouse is indistinguishable from a real one by type; the only
|
||||
* thing that tells them apart is the clock.
|
||||
*
|
||||
* So this file drives that exact sequence through a real `SceneKit` and asserts
|
||||
* the pick is still there afterwards — and, in the other direction, that a
|
||||
* genuine mouse leaving the canvas long after any touch still clears it, which
|
||||
* is the behaviour the desktop has always had and must keep.
|
||||
*
|
||||
* It is an integration test rather than a unit one because there is no seam to
|
||||
* unit-test: the defect lives in the wiring between three listeners and a piece
|
||||
* of module state, and the listeners are only reachable through the constructor.
|
||||
* The fake DOM here therefore *records* its listeners, which is the one thing
|
||||
* `sceneWiring.test.ts`'s fake deliberately does not do.
|
||||
*/
|
||||
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
import * as THREE from "three";
|
||||
|
||||
// `scenekit.ts` reads `window.matchMedia` while constructing, so the stub has to
|
||||
// be installed before the module is imported. Hence the dynamic import below.
|
||||
(globalThis as unknown as { window: unknown }).window = {
|
||||
matchMedia: () => ({ matches: false, addEventListener() {}, removeEventListener() {} }),
|
||||
innerWidth: 390,
|
||||
innerHeight: 844,
|
||||
devicePixelRatio: 2,
|
||||
addEventListener() {},
|
||||
removeEventListener() {},
|
||||
};
|
||||
|
||||
const { createSceneKit } = await import("../../engine/scenekit.ts");
|
||||
|
||||
/** The canvas, plus a record of what was bound to it so a test can fire it. */
|
||||
interface RecordingDom {
|
||||
el: HTMLElement;
|
||||
fire(type: string, event: Record<string, unknown>): void;
|
||||
}
|
||||
|
||||
function recordingDom(): RecordingDom {
|
||||
const listeners = new Map<string, Set<(event: unknown) => void>>();
|
||||
const el = {
|
||||
style: {} as Record<string, string>,
|
||||
clientWidth: 390,
|
||||
clientHeight: 844,
|
||||
addEventListener(type: string, fn: (event: unknown) => void) {
|
||||
let set = listeners.get(type);
|
||||
if (!set) listeners.set(type, (set = new Set()));
|
||||
set.add(fn);
|
||||
},
|
||||
removeEventListener(type: string, fn: (event: unknown) => void) {
|
||||
listeners.get(type)?.delete(fn);
|
||||
},
|
||||
setPointerCapture() {},
|
||||
releasePointerCapture() {},
|
||||
getBoundingClientRect: () => ({
|
||||
left: 0, top: 0, width: 390, height: 844, right: 390, bottom: 844, x: 0, y: 0,
|
||||
}),
|
||||
getRootNode: () => ({ addEventListener() {}, removeEventListener() {} }),
|
||||
ownerDocument: { addEventListener() {}, removeEventListener() {} },
|
||||
};
|
||||
return {
|
||||
el: el as unknown as HTMLElement,
|
||||
fire(type, event) {
|
||||
for (const fn of listeners.get(type) ?? []) fn(event);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* A kit looking straight down the −Z axis at one box, with that box picked.
|
||||
*
|
||||
* The centre of the viewport is therefore a hit and anywhere else is a miss,
|
||||
* which is all the geometry any test here needs.
|
||||
*/
|
||||
function board() {
|
||||
const scene = new THREE.Scene();
|
||||
const dom = recordingDom();
|
||||
const target = new THREE.Mesh(new THREE.BoxGeometry(4, 4, 4), new THREE.MeshBasicMaterial());
|
||||
target.userData.id = "sim-1";
|
||||
scene.add(target);
|
||||
|
||||
const kit = createSceneKit({ scene, dom: dom.el });
|
||||
kit.setPose({ position: new THREE.Vector3(0, 0, 40), target: new THREE.Vector3(0, 0, 0) });
|
||||
kit.camera.updateMatrixWorld(true);
|
||||
|
||||
const changes: (string | null)[] = [];
|
||||
kit.setPicking<string>({
|
||||
targets: [target],
|
||||
resolve: (hit) => (hit.object.userData.id as string) ?? null,
|
||||
onChange: (picked) => changes.push(picked),
|
||||
});
|
||||
|
||||
return {
|
||||
kit,
|
||||
dom,
|
||||
changes,
|
||||
dispose() {
|
||||
kit.dispose();
|
||||
target.geometry.dispose();
|
||||
target.material.dispose();
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/** The four touch events a tap on the middle of the glass produces, in order. */
|
||||
function tapCentre(dom: RecordingDom, at: number) {
|
||||
dom.fire("pointerdown", { pointerType: "touch", pointerId: 1, clientX: 195, clientY: 422, timeStamp: at });
|
||||
dom.fire("pointerup", { pointerType: "touch", pointerId: 1, clientX: 195, clientY: 422, timeStamp: at + 60 });
|
||||
dom.fire("pointerout", { pointerType: "touch", pointerId: 1, timeStamp: at + 61 });
|
||||
dom.fire("pointerleave", { pointerType: "touch", pointerId: 1, timeStamp: at + 61 });
|
||||
}
|
||||
|
||||
test("a tap raises a card and the compatibility mouse leave does not take it away", () => {
|
||||
const { kit, dom, changes, dispose } = board();
|
||||
try {
|
||||
tapCentre(dom, 1_000);
|
||||
kit.tick(1 / 60);
|
||||
assert.deepEqual(changes, ["sim-1"], "the tap did not pick the thing under the finger");
|
||||
|
||||
// The replay. `pointerType` says mouse and it is not one — it is Chrome
|
||||
// finishing the tap, 32 ms after the finger left.
|
||||
dom.fire("pointerout", { pointerType: "mouse", pointerId: 1, timeStamp: 1_092 });
|
||||
dom.fire("pointerleave", { pointerType: "mouse", pointerId: 1, timeStamp: 1_092 });
|
||||
kit.tick(1 / 60);
|
||||
|
||||
assert.deepEqual(
|
||||
changes,
|
||||
["sim-1"],
|
||||
"the card was cleared by the browser's own replay of the tap that raised it",
|
||||
);
|
||||
} finally {
|
||||
dispose();
|
||||
}
|
||||
});
|
||||
|
||||
test("the card survives long enough to be read, over many frames", () => {
|
||||
// The failure this is really about is not one event, it is a card that is
|
||||
// never on the page long enough to look at. Two seconds of frames, with the
|
||||
// replay in the middle of them, is the shape a person experiences.
|
||||
const { kit, dom, changes, dispose } = board();
|
||||
try {
|
||||
tapCentre(dom, 0);
|
||||
for (let frame = 0; frame < 120; frame += 1) {
|
||||
if (frame === 2) {
|
||||
dom.fire("pointerleave", { pointerType: "mouse", pointerId: 1, timeStamp: 92 });
|
||||
}
|
||||
kit.tick(1 / 60);
|
||||
}
|
||||
assert.deepEqual(changes, ["sim-1"], "the card did not survive two seconds of frames");
|
||||
} finally {
|
||||
dispose();
|
||||
}
|
||||
});
|
||||
|
||||
test("a real mouse leaving the canvas still clears the pick", () => {
|
||||
const { kit, dom, changes, dispose } = board();
|
||||
try {
|
||||
dom.fire("pointermove", { pointerType: "mouse", clientX: 195, clientY: 422, timeStamp: 500 });
|
||||
kit.tick(1 / 60);
|
||||
assert.deepEqual(changes, ["sim-1"], "hover did not pick on the desktop");
|
||||
|
||||
dom.fire("pointerleave", { pointerType: "mouse", pointerId: 1, timeStamp: 900 });
|
||||
assert.deepEqual(changes, ["sim-1", null], "the pointer left the canvas and the card stayed");
|
||||
} finally {
|
||||
dispose();
|
||||
}
|
||||
});
|
||||
|
||||
test("a hybrid laptop's mouse is only deferred to briefly, not disabled", () => {
|
||||
// The cost of the fix, stated: a device with both a touchscreen and a mouse
|
||||
// ignores a genuine mouse-leave for `COMPAT_MOUSE_MS` after a tap. Past that
|
||||
// window it behaves exactly like a desktop again, and this pins the recovery
|
||||
// rather than leaving it to be discovered.
|
||||
const { kit, dom, changes, dispose } = board();
|
||||
try {
|
||||
tapCentre(dom, 0);
|
||||
kit.tick(1 / 60);
|
||||
assert.deepEqual(changes, ["sim-1"]);
|
||||
|
||||
// Inside the window: held.
|
||||
dom.fire("pointerleave", { pointerType: "mouse", pointerId: 2, timeStamp: 700 });
|
||||
assert.deepEqual(changes, ["sim-1"]);
|
||||
|
||||
// Past it: a mouse is a mouse again.
|
||||
dom.fire("pointerleave", { pointerType: "mouse", pointerId: 2, timeStamp: 1_500 });
|
||||
assert.deepEqual(changes, ["sim-1", null], "the mouse never got the canvas back");
|
||||
} finally {
|
||||
dispose();
|
||||
}
|
||||
});
|
||||
|
||||
test("tapping empty water dismisses the card", () => {
|
||||
// The other half of the touch contract, asserted here because the fix above
|
||||
// works by ignoring a clear and this is the clear that must still happen.
|
||||
const { kit, dom, changes, dispose } = board();
|
||||
try {
|
||||
tapCentre(dom, 0);
|
||||
kit.tick(1 / 60);
|
||||
assert.deepEqual(changes, ["sim-1"]);
|
||||
|
||||
dom.fire("pointerdown", {
|
||||
pointerType: "touch", pointerId: 3, clientX: 20, clientY: 60, timeStamp: 2_000,
|
||||
});
|
||||
assert.deepEqual(changes, ["sim-1", null], "a tap on nothing left the old card up");
|
||||
} finally {
|
||||
dispose();
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,270 @@
|
||||
/**
|
||||
* The statewide California board — the default one, the first frame an
|
||||
* anonymous visitor sees.
|
||||
*
|
||||
* Every defect this file guards against **typechecked, rendered without a
|
||||
* console error, and met every performance budget.** They were only ever
|
||||
* visible by looking at the board, which is why they survived for so long and
|
||||
* why the assertions below are shaped the way they are: each one is the
|
||||
* cheapest arithmetic statement of something a person found in a screenshot.
|
||||
*
|
||||
* 1. **A self-intersecting coastline.** San Francisco Bay was traced as a
|
||||
* concavity in the landmass, and the polygon's closure edge ran across its
|
||||
* head. `isLand` still answered correctly and the terrain grid still left
|
||||
* the hole, but `ShapeGeometry` triangulated the slit shut and the shore
|
||||
* plate paved the entire bay. Nothing threw. The bay was simply not there.
|
||||
* 2. **A board with no relief.** At an exaggeration of 2.25 a 3,000 m range
|
||||
* stood 1.6 units off a board 284 units tall — five tenths of one percent.
|
||||
* Every hill was in the pack, every hill was in the heightfield, and the
|
||||
* state looked like a beach.
|
||||
* 3. **Cities that produce no buildings.** A district drawn inside a park
|
||||
* envelope emits zero lots, because `createBlocks` skips every lot in a
|
||||
* park. One did, silently, and read as an empty valley.
|
||||
* 4. **A corridor that climbs a mountain nobody meant to put there.** Hill
|
||||
* radii are in degrees and the routes are hand-traced; a range centred
|
||||
* half a degree from US-101 puts a kilometre of climb into the Salinas
|
||||
* Valley and neither the pack nor the renderer has an opinion about it.
|
||||
*
|
||||
* The blocks-scale assertions at the end are here for a different reason: they
|
||||
* are the promise that making the state board legible did not disturb the two
|
||||
* boards that already looked right.
|
||||
*/
|
||||
|
||||
import assert from "node:assert/strict";
|
||||
import { describe, it } from "node:test";
|
||||
|
||||
import CALIFORNIA_CITY, { CALIFORNIA_I_5, CALIFORNIA_US_101 } from "../../cities/california.ts";
|
||||
import SF_CITY from "../../cities/sf.ts";
|
||||
import SOCAL_CITY from "../../cities/socal.ts";
|
||||
import { createBlocks } from "../../engine/blocks.ts";
|
||||
import type { City, LatLng } from "../../engine/types.ts";
|
||||
import { World } from "../../engine/world.ts";
|
||||
|
||||
/** Do two closed segments cross, endpoints excluded? */
|
||||
function crosses(a: LatLng, b: LatLng, c: LatLng, d: LatLng): boolean {
|
||||
const side = (p: LatLng, q: LatLng, r: LatLng): number =>
|
||||
Math.sign((q[0] - p[0]) * (r[1] - q[1]) - (q[1] - p[1]) * (r[0] - q[0]));
|
||||
return side(a, b, c) !== side(a, b, d) && side(c, d, a) !== side(c, d, b);
|
||||
}
|
||||
|
||||
/**
|
||||
* Every pair of non-adjacent edges in a ring, which is quadratic and does not
|
||||
* matter: the biggest ring in the repo is a few hundred vertices and this runs
|
||||
* in single-digit milliseconds.
|
||||
*/
|
||||
function selfIntersections(ring: readonly LatLng[]): Array<[number, number]> {
|
||||
const hits: Array<[number, number]> = [];
|
||||
const n = ring.length;
|
||||
for (let i = 0; i < n; i += 1) {
|
||||
for (let j = i + 2; j < n; j += 1) {
|
||||
if (i === 0 && j === n - 1) continue; // the closing edge touches the first
|
||||
const a = ring[i];
|
||||
const b = ring[(i + 1) % n];
|
||||
const c = ring[j];
|
||||
const d = ring[(j + 1) % n];
|
||||
if (!a || !b || !c || !d) continue;
|
||||
if (crosses(a, b, c, d)) hits.push([i, j]);
|
||||
}
|
||||
}
|
||||
return hits;
|
||||
}
|
||||
|
||||
/**
|
||||
* A `World` whose heightfield is already built.
|
||||
*
|
||||
* `ready()` waits on a paint that never comes under the Node test runner, so
|
||||
* this takes the documented synchronous path instead: `lattice()` builds the
|
||||
* field on the calling thread when nobody awaited `ready()`, which is the same
|
||||
* fallback a browser with Workers blocked takes.
|
||||
*/
|
||||
function builtWorld(city: City): World {
|
||||
const world = new World(city);
|
||||
world.lattice();
|
||||
return world;
|
||||
}
|
||||
|
||||
describe("California board — geometry that only a picture used to catch", () => {
|
||||
it("traces every coastline as a simple polygon", () => {
|
||||
for (const city of [CALIFORNIA_CITY, SF_CITY, SOCAL_CITY]) {
|
||||
for (const [index, ring] of city.landmasses.entries()) {
|
||||
const hits = selfIntersections(ring);
|
||||
assert.deepEqual(
|
||||
hits,
|
||||
[],
|
||||
`${city.id} landmass ${index} crosses itself at ${JSON.stringify(hits)}; ` +
|
||||
"ShapeGeometry will quietly triangulate the slit shut and pave whatever is inside it",
|
||||
);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it("keeps San Francisco Bay as water joined to the Pacific", () => {
|
||||
const world = new World(CALIFORNIA_CITY);
|
||||
// Down the middle of the bay, from San Pablo to the south bay, plus the
|
||||
// Golden Gate itself. Every one of these was dry land when the contour
|
||||
// self-intersected.
|
||||
for (const [lat, lng] of [
|
||||
[38.0, -122.35],
|
||||
[37.9, -122.35],
|
||||
[37.8, -122.33],
|
||||
[37.7, -122.25],
|
||||
[37.6, -122.16],
|
||||
[37.5, -122.05],
|
||||
[37.83, -122.5],
|
||||
] as LatLng[]) {
|
||||
assert.equal(world.isLand(lat, lng), false, `${lat},${lng} should be bay`);
|
||||
}
|
||||
// And the two shores are still land, so the bay is a strait and not a hole
|
||||
// punched through the peninsula.
|
||||
assert.equal(world.isLand(37.76, -122.44), true, "San Francisco");
|
||||
assert.equal(world.isLand(37.8, -122.15), true, "the East Bay");
|
||||
});
|
||||
|
||||
it("stands the ranges up far enough to be seen from the state camera", () => {
|
||||
const world = builtWorld(CALIFORNIA_CITY);
|
||||
const { bounds } = CALIFORNIA_CITY;
|
||||
const boardUnits = (bounds.maxLat - bounds.minLat) * CALIFORNIA_CITY.latScale;
|
||||
|
||||
let peak = 0;
|
||||
for (const metres of world.lattice().height) if (metres > peak) peak = metres;
|
||||
const peakUnits = world.metres(peak);
|
||||
|
||||
assert.ok(peak > 4_000, `the highest ground is only ${Math.round(peak)} m`);
|
||||
// 8% of the board's own height. Southern California's San Gabriels clear
|
||||
// this comfortably; the old 2.25 exaggeration put this board at 0.6%.
|
||||
assert.ok(
|
||||
peakUnits / boardUnits > 0.08,
|
||||
`relief is ${((peakUnits / boardUnits) * 100).toFixed(1)}% of the board — flat`,
|
||||
);
|
||||
});
|
||||
|
||||
it("leaves the Central Valley a genuine flat between two ranges", () => {
|
||||
const world = builtWorld(CALIFORNIA_CITY);
|
||||
// A line up the middle of the valley floor, and a matching line along the
|
||||
// Sierra crest. Both axes lean west as they run north, which is why they are
|
||||
// interpolated rather than held at one longitude: the valley at Bakersfield
|
||||
// is at -119.2 and at Stockton it is at -121.4, and a straight line down one
|
||||
// meridian walks out of the valley and up into the foothills.
|
||||
for (let lat = 35.6; lat <= 37.8; lat += 0.2) {
|
||||
const lng = -119.2 - (lat - 35.4) * 0.88;
|
||||
const floor = world.elevationAt(lat, lng);
|
||||
assert.ok(floor < 260, `the valley floor at ${lat.toFixed(1)}N is ${Math.round(floor)} m`);
|
||||
}
|
||||
// The crest is sampled at its own longitudes rather than off a straight
|
||||
// line: the Sierra swings from -118.3 at Whitney to -119.6 at Sonora, and a
|
||||
// meridian drawn through both ends misses the range in the middle.
|
||||
for (const [lat, lng] of [
|
||||
[36.2, -118.28],
|
||||
[36.45, -118.28],
|
||||
[36.62, -118.29],
|
||||
[36.85, -118.38],
|
||||
[37.05, -118.52],
|
||||
[37.25, -118.72],
|
||||
[37.45, -118.92],
|
||||
[37.65, -119.12],
|
||||
] as LatLng[]) {
|
||||
const crest = world.elevationAt(lat, lng);
|
||||
assert.ok(crest > 2_000, `the Sierra at ${lat.toFixed(2)}N is only ${Math.round(crest)} m`);
|
||||
}
|
||||
});
|
||||
|
||||
it("keeps the two corridors in the valleys, and climbs only where a driver climbs", () => {
|
||||
const world = builtWorld(CALIFORNIA_CITY);
|
||||
/**
|
||||
* The named passes, and nothing else, each with its own reach.
|
||||
*
|
||||
* Newhall is a single notch behind Santa Clarita and the Cuesta Grade is one
|
||||
* climb out of San Luis Obispo, so both are tight. The Grapevine is not a
|
||||
* pass in that sense at all: I-5 leaves the Los Angeles basin at Castaic and
|
||||
* does not come down again until Wheeler Ridge forty kilometres later, over
|
||||
* Gorman and Tejon, and a small circle round the summit would call most of
|
||||
* that ascent an error.
|
||||
*/
|
||||
const passes: Array<{ at: LatLng; reach: number }> = [
|
||||
{ at: [34.3917, -118.5426], reach: 0.16 }, // Newhall
|
||||
{ at: [34.75, -118.8], reach: 0.4 }, // the Grapevine: Castaic to Wheeler Ridge
|
||||
{ at: [35.2828, -120.6596], reach: 0.2 }, // the Cuesta Grade
|
||||
];
|
||||
const nearAPass = (lat: number, lng: number): boolean =>
|
||||
passes.some(({ at, reach }) => Math.hypot(lat - at[0], (lng - at[1]) * 0.81) < reach);
|
||||
|
||||
for (const [name, path] of [["US-101", CALIFORNIA_US_101], ["I-5", CALIFORNIA_I_5]] as const) {
|
||||
for (let index = 0; index < path.length - 1; index += 1) {
|
||||
const from = path[index];
|
||||
const to = path[index + 1];
|
||||
if (!from || !to) continue;
|
||||
for (let step = 0; step <= 20; step += 1) {
|
||||
const t = step / 20;
|
||||
const lat = from[0] + (to[0] - from[0]) * t;
|
||||
const lng = from[1] + (to[1] - from[1]) * t;
|
||||
const metres = world.elevationAt(lat, lng);
|
||||
const cap = nearAPass(lat, lng) ? 1_500 : 700;
|
||||
assert.ok(
|
||||
metres < cap,
|
||||
`${name} climbs to ${Math.round(metres)} m at ${lat.toFixed(2)},${lng.toFixed(2)}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("California board — the built state", () => {
|
||||
const world = builtWorld(CALIFORNIA_CITY);
|
||||
const blocks = createBlocks(world);
|
||||
|
||||
it("builds every district it declares", () => {
|
||||
// A district drawn inside a park envelope, or out in the bay, emits nothing
|
||||
// at all and there is no warning anywhere. Each one is rebuilt alone so the
|
||||
// empty one is named rather than hidden in the total.
|
||||
for (const district of CALIFORNIA_CITY.districts) {
|
||||
const alone = new World({ ...CALIFORNIA_CITY, districts: [district] });
|
||||
alone.lattice();
|
||||
const count = createBlocks(alone).count;
|
||||
assert.ok(count > 20, `district "${district.id}" produced ${count} lots`);
|
||||
}
|
||||
});
|
||||
|
||||
it("makes Los Angeles and the Bay Area read as settlements, not as specks", () => {
|
||||
const within = (minLat: number, maxLat: number, minLng: number, maxLng: number): number => {
|
||||
let n = 0;
|
||||
for (const district of CALIFORNIA_CITY.districts) {
|
||||
const inside = district.polygon.every(
|
||||
([lat, lng]) => lat >= minLat && lat <= maxLat && lng >= minLng && lng <= maxLng,
|
||||
);
|
||||
if (!inside) continue;
|
||||
const alone = new World({ ...CALIFORNIA_CITY, districts: [district] });
|
||||
alone.lattice();
|
||||
n += createBlocks(alone).count;
|
||||
}
|
||||
return n;
|
||||
};
|
||||
assert.ok(within(33.4, 34.4, -118.7, -117.6) > 3_000, "the Los Angeles basin is thin");
|
||||
assert.ok(within(37.1, 38.1, -122.6, -121.7) > 1_500, "the Bay Area is thin");
|
||||
});
|
||||
|
||||
it("stays inside the triangle budget it was sized against", () => {
|
||||
// The California board is the tight one: 650 draw calls and 750,000
|
||||
// triangles, shared with the aircraft layer. One instanced box is twelve
|
||||
// triangles, so this ceiling is about 110k of them — roughly a seventh of
|
||||
// the whole board. A district enlarged without checking is the easy way to
|
||||
// blow the budget, and `scripts/performance-budget.mjs` needs a browser and
|
||||
// a minute to say so.
|
||||
assert.ok(blocks.count < 9_200, `${blocks.count} lots is over what the budget was sized for`);
|
||||
assert.ok(blocks.count > 7_000, `${blocks.count} lots is thinner than the board was tuned to`);
|
||||
});
|
||||
|
||||
it("drops the street lattice and the shadow pass only where a lot is a neighbourhood", () => {
|
||||
// The rule in `blocks.ts` is about how much ground a lot covers, not about
|
||||
// which board it is. Stated here as the fact it is derived from, so the two
|
||||
// detailed boards are provably untouched by it.
|
||||
const lotMetres = (city: City): number => 0.42 * (111_320 / city.latScale);
|
||||
assert.ok(lotMetres(SF_CITY) < 260, "San Francisco must keep its street grid");
|
||||
assert.ok(lotMetres(SOCAL_CITY) < 260, "Southern California must keep its street grid");
|
||||
assert.ok(lotMetres(CALIFORNIA_CITY) > 260, "the state board must not draw 800 m streets");
|
||||
|
||||
assert.equal(blocks.castShadow, false, "state-scale lots cost a second pass for one pixel");
|
||||
const socal = builtWorld(SOCAL_CITY);
|
||||
assert.equal(createBlocks(socal).castShadow, true, "Southern California lost its shadows");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,102 @@
|
||||
/**
|
||||
* The freeway corridor on the statewide board, which had two problems and only
|
||||
* one of them was visible.
|
||||
*
|
||||
* **It read as a wireframe.** At 1,919 m to the scene unit the whole corridor is
|
||||
* about eleven pixels wide from the default camera, and eleven pixels of flat
|
||||
* mid-grey lying exactly on the ground is a line somebody drew on a map. It is
|
||||
* now a graded crown with two batters, which gives it three value bands and a
|
||||
* normal that is not straight up — see `createFreewayWorld`.
|
||||
*
|
||||
* **It cost a fifth of the board's triangle budget on things nobody can see.**
|
||||
* Guardrails and median walls were tubes at two segments per draped sample on a
|
||||
* corridor already sampled every kilometre, and the reflectors were 2,296 boxes
|
||||
* eighteen millimetres across. Between them: 110,000 triangles on a board with
|
||||
* 75,000 to spare, which is why the state had no mountains and no cities on it.
|
||||
*
|
||||
* The two assertions below are the ones that would have caught the two defects
|
||||
* this cost a rebuild to find:
|
||||
*
|
||||
* - **Every batter faces the sky.** `deck` materials are `DoubleSide` and
|
||||
* three.js negates the shading normal on a back face, so a strip whose two
|
||||
* rails were emitted in the opposite order to its neighbours renders as an
|
||||
* unlit black band. One did, the length of US-101, and it typechecked.
|
||||
* - **The corridor stays under its triangle ceiling.** The real gate is
|
||||
* `scripts/performance-budget.mjs`, which needs a browser and a minute; this
|
||||
* runs in milliseconds and fails on the line that caused the regression.
|
||||
*/
|
||||
|
||||
import assert from "node:assert/strict";
|
||||
import { describe, it } from "node:test";
|
||||
import * as THREE from "three";
|
||||
|
||||
import CALIFORNIA_CITY from "../../cities/california.ts";
|
||||
import { createFreewayWorld } from "../../engine/structures.ts";
|
||||
import type { World } from "../../engine/world.ts";
|
||||
import CALIFORNIA_TRANSPORT from "../../transport/california.ts";
|
||||
|
||||
/** Flat ground and a linear projection: the corridor's own shape, nothing else. */
|
||||
const flatWorld = {
|
||||
city: CALIFORNIA_CITY,
|
||||
project(lat: number, lng: number): [number, number] {
|
||||
return [(lng + 121) * 47, -(lat - 36) * 58];
|
||||
},
|
||||
groundAt(): number {
|
||||
return 0;
|
||||
},
|
||||
} as unknown as World;
|
||||
|
||||
function meshesIn(group: THREE.Object3D): THREE.Mesh[] {
|
||||
const found: THREE.Mesh[] = [];
|
||||
group.traverse((object) => {
|
||||
if (object instanceof THREE.Mesh) found.push(object);
|
||||
});
|
||||
return found;
|
||||
}
|
||||
|
||||
function triangles(mesh: THREE.Mesh): number {
|
||||
const geometry = mesh.geometry;
|
||||
const index = geometry.getIndex();
|
||||
const per = index ? index.count / 3 : geometry.getAttribute("position").count / 3;
|
||||
return per * (mesh instanceof THREE.InstancedMesh ? mesh.count : 1);
|
||||
}
|
||||
|
||||
describe("California corridor", () => {
|
||||
const group = createFreewayWorld(flatWorld, CALIFORNIA_TRANSPORT);
|
||||
const all = meshesIn(group);
|
||||
|
||||
it("gives the earthwork a crown and two batters that both face the sky", () => {
|
||||
const embankment = all.find((mesh) => mesh.name === "freeway:embankment");
|
||||
assert.ok(embankment, "the corridor has no embankment; it is a flat ribbon again");
|
||||
|
||||
const normals = embankment.geometry.getAttribute("normal");
|
||||
assert.ok(normals, "the embankment lost the normals mergeGeometries matches on");
|
||||
let tilted = 0;
|
||||
for (let index = 0; index < normals.count; index += 1) {
|
||||
const y = normals.getY(index);
|
||||
assert.ok(y > 0, `embankment normal ${index} points into the ground (y=${y.toFixed(3)})`);
|
||||
if (y < 0.999) tilted += 1;
|
||||
}
|
||||
// And it is a batter, not another flat deck: an untilted strip would pass
|
||||
// the test above and still be the thing this replaced.
|
||||
assert.ok(tilted > normals.count * 0.9, "the embankment is flat; it will not catch the sun");
|
||||
|
||||
// All four spans — two carriageside batters on each of two corridors —
|
||||
// merged into the one mesh. A dropped bucket looks like an efficient one.
|
||||
assert.ok(
|
||||
normals.count > 2_000,
|
||||
`the embankment merged to only ${normals.count} vertices`,
|
||||
);
|
||||
});
|
||||
|
||||
it("keeps the whole corridor inside the triangle share it was budgeted", () => {
|
||||
const total = all.reduce((sum, mesh) => sum + triangles(mesh), 0);
|
||||
// 142,000 before the reclaim, on a board with a 750,000 cap that was already
|
||||
// measuring 675,000. 95,000 is comfortably above what it emits and low
|
||||
// enough to fail if anyone doubles a tube's tessellation again.
|
||||
assert.ok(total < 95_000, `the corridor is ${Math.round(total)} triangles`);
|
||||
// A floor as well, because the cheapest way to pass the line above is to
|
||||
// stop drawing the corridor.
|
||||
assert.ok(total > 50_000, `the corridor is only ${Math.round(total)} triangles`);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,262 @@
|
||||
/**
|
||||
* The two things `engine/terrain.ts` now does that a picture found and a test
|
||||
* can keep: the sea has a surface, and the relief casts a shadow it can afford.
|
||||
*
|
||||
* Everything in this round was invisible to the type checker and to every
|
||||
* existing test. What a test *can* hold is the handful of facts underneath the
|
||||
* picture — that the sea reaches past the fog rather than stopping in a hard
|
||||
* diamond, that its swell map tiles and is not one hard diagonal rib, that the
|
||||
* terrain casts from a decimated copy of itself rather than from the mesh you
|
||||
* are looking at. Each of those is a number, each was got wrong at least once
|
||||
* on the way here, and each would go back to being wrong silently.
|
||||
*
|
||||
* The board below is synthetic and tiny — twenty cells a side — because none of
|
||||
* these facts are about California. A real pack would make the file slow and
|
||||
* would couple a render test to a city's coastline.
|
||||
*/
|
||||
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
import * as THREE from "three";
|
||||
|
||||
import { createShorePlates, createTerrain, createWater, swellNormalData } from "../../engine/terrain.ts";
|
||||
import type { City } from "../../engine/types.ts";
|
||||
import { World } from "../../engine/world.ts";
|
||||
|
||||
const BOARD: City = {
|
||||
id: "test-board",
|
||||
name: "Test Board",
|
||||
center: { lat: 37, lng: -122 },
|
||||
bounds: { minLat: 36.5, maxLat: 37.5, minLng: -122.5, maxLng: -121.5 },
|
||||
latScale: 100,
|
||||
verticalExaggeration: 2,
|
||||
cellLat: 0.05,
|
||||
cellLng: 0.05,
|
||||
coastFalloff: 0.02,
|
||||
// One square island with a hill on it: enough land for a terrain grid, and
|
||||
// enough water around it for the sea to be the thing under everything.
|
||||
landmasses: [
|
||||
[
|
||||
[36.7, -122.3],
|
||||
[37.3, -122.3],
|
||||
[37.3, -121.7],
|
||||
[36.7, -121.7],
|
||||
],
|
||||
],
|
||||
parks: [],
|
||||
inlandWater: [],
|
||||
hills: [{ name: "Test Hill", lat: 37, lng: -122, elevation: 400, radius: 0.15 }],
|
||||
districts: [],
|
||||
landmarks: [],
|
||||
bridges: [],
|
||||
roads: [],
|
||||
chapters: [],
|
||||
};
|
||||
|
||||
async function board(): Promise<World> {
|
||||
const world = new World(BOARD);
|
||||
assert.equal(await world.ready(), true, "the synthetic board failed to build a heightfield");
|
||||
return world;
|
||||
}
|
||||
|
||||
function boardSpan(world: World): number {
|
||||
const [westX, northZ] = world.project(BOARD.bounds.maxLat, BOARD.bounds.minLng);
|
||||
const [eastX, southZ] = world.project(BOARD.bounds.minLat, BOARD.bounds.maxLng);
|
||||
return Math.max(Math.abs(eastX - westX), Math.abs(southZ - northZ));
|
||||
}
|
||||
|
||||
// ---- The swell map ---------------------------------------------------------
|
||||
|
||||
/** Decode one texel back to the tangent-space normal it stands for. */
|
||||
function normalAt(data: Uint8Array, size: number, x: number, y: number): THREE.Vector3 {
|
||||
const i = (((y + size) % size) * size + ((x + size) % size)) * 4;
|
||||
return new THREE.Vector3(
|
||||
((data[i] as number) / 255) * 2 - 1,
|
||||
((data[i + 1] as number) / 255) * 2 - 1,
|
||||
((data[i + 2] as number) / 255) * 2 - 1,
|
||||
);
|
||||
}
|
||||
|
||||
test("the swell map is the same sea on every reload", () => {
|
||||
const a = swellNormalData(64);
|
||||
const b = swellNormalData(64);
|
||||
assert.deepEqual(a, b, "two boards would show two different oceans");
|
||||
// And it is a sea rather than a flat card: `Math.random` removed would pass
|
||||
// the equality above just as happily as a seeded field does.
|
||||
const flat = [...a].every((_, i) => i % 4 === 2 || i % 4 === 3 || a[i] === 128);
|
||||
assert.equal(flat, false, "the swell map has no swell in it");
|
||||
});
|
||||
|
||||
test("every texel of the swell map decodes to a unit normal facing up", () => {
|
||||
const size = 64;
|
||||
const data = swellNormalData(size);
|
||||
let worst = 0;
|
||||
let lowest = 1;
|
||||
for (let y = 0; y < size; y++) {
|
||||
for (let x = 0; x < size; x++) {
|
||||
const n = normalAt(data, size, x, y);
|
||||
worst = Math.max(worst, Math.abs(n.length() - 1));
|
||||
lowest = Math.min(lowest, n.z);
|
||||
}
|
||||
}
|
||||
// One byte of quantisation is 1/255 per channel, so a little over that is the
|
||||
// whole tolerance a correctly encoded map needs.
|
||||
assert.ok(worst < 0.02, `a texel decoded to a normal of length ${1 + worst}`);
|
||||
// A tangent-space normal map for a surface, not for an overhang: z is the
|
||||
// surface's own axis and nothing may lean past horizontal.
|
||||
assert.ok(lowest > 0.5, `a texel leaned to z=${lowest}, which is a cliff, not a wave`);
|
||||
});
|
||||
|
||||
test("the swell map tiles: the wrap is no sharper than the interior", () => {
|
||||
const size = 64;
|
||||
const data = swellNormalData(size);
|
||||
let interior = 0;
|
||||
let seam = 0;
|
||||
for (let y = 0; y < size; y++) {
|
||||
for (let x = 1; x < size - 1; x++) {
|
||||
interior = Math.max(interior, normalAt(data, size, x, y).distanceTo(normalAt(data, size, x + 1, y)));
|
||||
}
|
||||
seam = Math.max(seam, normalAt(data, size, size - 1, y).distanceTo(normalAt(data, size, 0, y)));
|
||||
}
|
||||
// The sea is drawn as hundreds of copies of this map side by side, so a
|
||||
// derivative that does not wrap paints a visible grid across the whole ocean.
|
||||
assert.ok(
|
||||
seam <= interior * 1.5,
|
||||
`the wrap steps by ${seam} against an interior maximum of ${interior}`,
|
||||
);
|
||||
});
|
||||
|
||||
test("the swell runs in every direction rather than one", () => {
|
||||
/*
|
||||
* The failure this holds is a photographed one. With amplitude falling as
|
||||
* 1/k every component of the sum carries the *same* slope — slope is
|
||||
* amplitude times wave number — the shortest wave wins on sheer count of
|
||||
* edges, and the ocean renders as one hard diagonal rib that reads as
|
||||
* corduroy rather than water. At 1/k² the slope falls as 1/k and the eight
|
||||
* headings stay spread.
|
||||
*/
|
||||
const size = 64;
|
||||
const data = swellNormalData(size);
|
||||
const buckets = new Array(12).fill(0) as number[];
|
||||
let total = 0;
|
||||
for (let y = 0; y < size; y++) {
|
||||
for (let x = 0; x < size; x++) {
|
||||
const n = normalAt(data, size, x, y);
|
||||
const slope = Math.hypot(n.x, n.y);
|
||||
if (slope < 1e-4) continue;
|
||||
// Folded to a half turn: a crest and its trough are one direction.
|
||||
const angle = (Math.atan2(n.y, n.x) + Math.PI * 2) % Math.PI;
|
||||
const at = Math.min(11, Math.floor((angle / Math.PI) * 12));
|
||||
buckets[at] = (buckets[at] as number) + slope;
|
||||
total += slope;
|
||||
}
|
||||
}
|
||||
const dominant = Math.max(...buckets) / total;
|
||||
assert.ok(dominant < 0.25, `${Math.round(dominant * 100)}% of the swell runs one way`);
|
||||
});
|
||||
|
||||
// ---- The sea ---------------------------------------------------------------
|
||||
|
||||
test("the sea reaches far enough out to fade instead of ending", () => {
|
||||
return board().then((world) => {
|
||||
const sea = createWater(world).children.find((child) => child.name === "sea") as
|
||||
| THREE.Mesh<THREE.PlaneGeometry>
|
||||
| undefined;
|
||||
assert.ok(sea, "there is no sea in the water group");
|
||||
/*
|
||||
* Where the number comes from: `main.ts` gives the atmosphere a clear-day
|
||||
* fog closing at 3.9 board spans, and `scene.ts` lets the orbit retreat to
|
||||
* 2.0 spans from the middle of the board. So the furthest a fully-fogged
|
||||
* horizon can be from the origin is about 5.9 spans, and a sea that stops
|
||||
* anywhere nearer than that shows the viewer its own edge — which is
|
||||
* exactly what the 1.8-span plane this replaced did, as a hard diamond with
|
||||
* the state floating on it.
|
||||
*/
|
||||
assert.ok(
|
||||
sea.geometry.parameters.width / boardSpan(world) >= 12,
|
||||
`the sea is only ${sea.geometry.parameters.width / boardSpan(world)} board spans across`,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
test("the sea has a specular response, which a Lambert card cannot", () => {
|
||||
return board().then((world) => {
|
||||
const sea = createWater(world).children.find((child) => child.name === "sea") as THREE.Mesh;
|
||||
const material = sea.material as THREE.MeshStandardMaterial;
|
||||
// `MeshLambertMaterial` has no specular term at all, by construction, which
|
||||
// is the whole reason the Pacific used to render as one flat blue value at
|
||||
// every hour and from every angle.
|
||||
assert.ok(material.isMeshStandardMaterial, "the sea went back to being unlit paint");
|
||||
assert.equal(material.metalness, 0, "water is a dielectric");
|
||||
assert.ok(material.roughness > 0 && material.roughness < 0.5, "the sun would have no path");
|
||||
assert.ok(material.normalMap, "a mirror-flat plane has a specular point, not a glitter path");
|
||||
assert.ok(material.normalScale.x > 0, "the swell is switched off");
|
||||
});
|
||||
});
|
||||
|
||||
// ---- The terrain's shadow --------------------------------------------------
|
||||
|
||||
test("the relief casts, and from a decimated copy of itself", async () => {
|
||||
const world = await board();
|
||||
const terrain = createTerrain(world);
|
||||
assert.equal(terrain.castShadow, true, "the hills shadow nothing again");
|
||||
assert.equal(terrain.receiveShadow, true);
|
||||
const material = terrain.material as THREE.MeshLambertMaterial;
|
||||
// The acne cure. Without it a constant bias has to cover a depth-per-texel
|
||||
// that grows as 1/tan(sun elevation), and no single value is free of acne at
|
||||
// a high sun and free of peter-panning at a low one.
|
||||
assert.equal(material.shadowSide, THREE.BackSide);
|
||||
|
||||
const index = terrain.geometry.getIndex();
|
||||
assert.ok(index, "the terrain lost its index");
|
||||
const seen = terrain.geometry.drawRange.count;
|
||||
const cast = index.count - seen;
|
||||
assert.ok(seen > 0 && cast > 0, `nothing to draw: ${seen} seen, ${cast} cast`);
|
||||
assert.equal(terrain.geometry.drawRange.start, 0, "the colour pass would skip the near edge");
|
||||
|
||||
/*
|
||||
* A quarter, give or take the edge cells a stride of 2 cannot cover. The
|
||||
* ratio is the whole reason this exists: submitting the visible surface to
|
||||
* the depth pass draws every triangle on the board a second time, and
|
||||
* `renderer.info` counts it — 65,566 triangles on California against about
|
||||
* 28,000 of headroom in the board's budget.
|
||||
*/
|
||||
assert.ok(cast / seen > 0.1 && cast / seen < 0.45, `the caster is ${cast / seen} of the surface`);
|
||||
});
|
||||
|
||||
test("the shadow draw range swings onto the caster and back", async () => {
|
||||
const world = await board();
|
||||
const terrain = createTerrain(world);
|
||||
const index = terrain.geometry.getIndex();
|
||||
assert.ok(index);
|
||||
const seen = terrain.geometry.drawRange.count;
|
||||
|
||||
// three fires these either side of the one `renderBufferDirect` the depth
|
||||
// pass makes for this mesh, and the depth pass runs before the colour pass —
|
||||
// so this pair is the whole mechanism that keeps the caster out of the
|
||||
// picture without keeping it out of the shadow map.
|
||||
const nothing = null as never;
|
||||
terrain.onBeforeShadow(
|
||||
nothing, nothing, nothing, nothing,
|
||||
terrain.geometry, terrain.material as THREE.Material, nothing,
|
||||
);
|
||||
assert.equal(terrain.geometry.drawRange.start, seen, "the depth pass is still drawing the surface");
|
||||
assert.equal(terrain.geometry.drawRange.count, index.count - seen);
|
||||
|
||||
terrain.onAfterShadow(
|
||||
nothing, nothing, nothing, nothing,
|
||||
terrain.geometry, terrain.material as THREE.Material, nothing,
|
||||
);
|
||||
assert.equal(terrain.geometry.drawRange.start, 0, "the caster leaked into the colour pass");
|
||||
assert.equal(terrain.geometry.drawRange.count, seen);
|
||||
});
|
||||
|
||||
test("the shore plate receives and does not cast", async () => {
|
||||
const world = await board();
|
||||
const plate = createShorePlates(world);
|
||||
assert.equal(plate.receiveShadow, true);
|
||||
// It is the landmass polygon lying flat six hundredths of a unit above the
|
||||
// sea. A caster that thin has no volume: at a low sun it would throw the
|
||||
// whole coastline out across the water as a hard slab.
|
||||
assert.equal(plate.castShadow, false);
|
||||
});
|
||||
@@ -203,8 +203,9 @@ test("the sun brightens monotonically as it rises", () => {
|
||||
});
|
||||
|
||||
test("the sky colours were left alone, because they are not tone mapped", () => {
|
||||
// Three marks the background mesh `toneMapped = false` for an sRGB-transfer
|
||||
// texture and mixes fog after the tone map from an already-encoded uniform.
|
||||
// The sky is a world-space `ShaderMaterial` dome marked `toneMapped = false`,
|
||||
// so it writes its components straight out, and fog mixes after the tone map
|
||||
// from an already-encoded uniform.
|
||||
// So the one thing the re-tune must NOT have touched is the sky, and the noon
|
||||
// stop still reproduces the city's own declared daylight colours.
|
||||
const noon = rig(25);
|
||||
|
||||
@@ -0,0 +1,122 @@
|
||||
/**
|
||||
* The apron is built from the site's elevation and from nothing else.
|
||||
*
|
||||
* `officeExterior.test.ts` asserts the outcome for the three shipped sites.
|
||||
* This file asserts the *reason*, which is the part that rots: a rule that
|
||||
* happens to produce the right answer for `lumbridge-hq` because it names
|
||||
* `lumbridge-hq` passes every test in that file and is wrong the moment a
|
||||
* self-hoster hands the same tower a different id, or an office moves.
|
||||
*
|
||||
* This repo has been here before. A door marker was once gated on
|
||||
* `id === "sf"`, and had it shipped it would have pinned the Los Angeles
|
||||
* building to San Francisco. So the sites below are the shipped ones with one
|
||||
* number swapped and their identities kept: a `lumbridge-hq` that has come down
|
||||
* to street level must get its car, and a `mateo-court` that has gone up a
|
||||
* tower must lose it. Nothing but `elevation` moves between the two halves of
|
||||
* each pair.
|
||||
*/
|
||||
|
||||
import assert from "node:assert/strict";
|
||||
import { describe, it } from "node:test";
|
||||
import * as THREE from "three";
|
||||
import { MaterialRegistry } from "../../assets/materials.ts";
|
||||
import { kit } from "../../assets/kit.ts";
|
||||
import "../../assets/office/index.ts";
|
||||
import { createOfficeExterior } from "../../engine/officeExterior.ts";
|
||||
import { LUMBRIDGE_HQ_SITE, MATEO_COURT_SITE } from "../../offices/sites.ts";
|
||||
import type { OfficeSite } from "../../interiors/types.ts";
|
||||
import {
|
||||
GROUND_ARRIVAL_MAX_ELEVATION_M,
|
||||
arrivalGroundFor,
|
||||
} from "../../transport/exteriorVehicle.ts";
|
||||
|
||||
/** A deterministic generator, so a failure is reproducible from the seed alone. */
|
||||
function seeded(seed: number): () => number {
|
||||
let a = seed >>> 0;
|
||||
return () => {
|
||||
a = (a + 0x6d2b79f5) >>> 0;
|
||||
let t = Math.imul(a ^ (a >>> 15), 1 | a);
|
||||
t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
|
||||
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
|
||||
};
|
||||
}
|
||||
|
||||
/** How many cars a given site gets outside it. */
|
||||
function carsOutside(site: OfficeSite): number {
|
||||
assert.ok(site.arrival, "the site under test authors an arrival anchor");
|
||||
const materials = new MaterialRegistry({ quality: "low" });
|
||||
const exterior = createOfficeExterior({
|
||||
site,
|
||||
arrival: site.arrival,
|
||||
assets: kit,
|
||||
materials,
|
||||
rand: seeded(4242),
|
||||
detail: "corridor",
|
||||
});
|
||||
let cars = 0;
|
||||
exterior.object.traverse((object: THREE.Object3D) => {
|
||||
if (object.userData.vehicleModel === "model-x") cars += 1;
|
||||
});
|
||||
exterior.dispose();
|
||||
return cars;
|
||||
}
|
||||
|
||||
/** The same site, moved vertically and changed in no other way. */
|
||||
function at(site: OfficeSite, elevation: number): OfficeSite {
|
||||
return { ...site, elevation };
|
||||
}
|
||||
|
||||
describe("the apron follows the ground, not the office id", () => {
|
||||
it("gives the tower's own site a car once it is standing on a street", () => {
|
||||
// Same id, same lat/lng, same heading, same anchor — down at a kerb.
|
||||
assert.equal(carsOutside(at(LUMBRIDGE_HQ_SITE, 1.2)), 1);
|
||||
});
|
||||
|
||||
it("takes the street office's car away once its site is up a tower", () => {
|
||||
assert.equal(carsOutside(at(MATEO_COURT_SITE, 188)), 0);
|
||||
});
|
||||
|
||||
it("switches at the documented threshold and nowhere else", () => {
|
||||
// Both sides of the constant, from both packs, so neither the number nor
|
||||
// the comparison can drift without this failing.
|
||||
for (const site of [LUMBRIDGE_HQ_SITE, MATEO_COURT_SITE]) {
|
||||
assert.equal(carsOutside(at(site, GROUND_ARRIVAL_MAX_ELEVATION_M)), 1);
|
||||
assert.equal(carsOutside(at(site, GROUND_ARRIVAL_MAX_ELEVATION_M + 0.01)), 0);
|
||||
}
|
||||
});
|
||||
|
||||
it("agrees with the pure rule at every elevation it is asked about", () => {
|
||||
// `arrivalGroundFor` is what the UI, a verifier or a future pack tool would
|
||||
// read to predict this without building a scene, so the two must not be
|
||||
// able to disagree.
|
||||
for (const elevation of [-3, 0, 1.2, 4, 12, 29.9, 30, 30.5, 61, 188, 400]) {
|
||||
const expected = arrivalGroundFor(elevation) === "ground" ? 1 : 0;
|
||||
assert.equal(
|
||||
carsOutside(at(MATEO_COURT_SITE, elevation)),
|
||||
expected,
|
||||
`elevation ${elevation}`,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
it("keeps the anchor readable even where nothing is drawn", () => {
|
||||
// The suppressed exterior is still labelled with the stall it declined to
|
||||
// build, because a caller that wants to caption "Podium kerb" should not
|
||||
// have to reach back into the pack for it — and because a silently empty
|
||||
// Group is indistinguishable from a bug.
|
||||
const materials = new MaterialRegistry({ quality: "low" });
|
||||
assert.ok(LUMBRIDGE_HQ_SITE.arrival);
|
||||
const exterior = createOfficeExterior({
|
||||
site: LUMBRIDGE_HQ_SITE,
|
||||
arrival: LUMBRIDGE_HQ_SITE.arrival,
|
||||
assets: kit,
|
||||
materials,
|
||||
rand: seeded(1),
|
||||
detail: "corridor",
|
||||
});
|
||||
assert.equal(exterior.object.userData.suppressed, "no-ground");
|
||||
assert.equal(exterior.object.userData.label, "Podium kerb");
|
||||
assert.equal(exterior.object.userData.arrivalKind, "vehicle-stall");
|
||||
exterior.dispose();
|
||||
});
|
||||
});
|
||||
@@ -13,8 +13,9 @@ import {
|
||||
import { CALIFORNIA_TRANSPORT } from "../../transport/california.ts";
|
||||
import {
|
||||
METRE_SCALE_VEHICLE_OPTIONS,
|
||||
apronKindFor,
|
||||
apronMetrics,
|
||||
arrivalGroundFor,
|
||||
GROUND_ARRIVAL_MAX_ELEVATION_M,
|
||||
metreScaleVehicleOptions,
|
||||
PARK_JITTER,
|
||||
parkPose,
|
||||
@@ -157,7 +158,7 @@ describe("metre-scale controller options", () => {
|
||||
|
||||
describe("the bay is sized from the car", () => {
|
||||
it("fits the vehicle with room to open a door", () => {
|
||||
const metrics = apronMetrics(MODEL_X_METRICS, "street");
|
||||
const metrics = apronMetrics(MODEL_X_METRICS);
|
||||
assert.ok(metrics.stallWidth > MODEL_X_METRICS.width);
|
||||
assert.ok(metrics.stallLength > MODEL_X_METRICS.length);
|
||||
// Enough clearance each side to actually get out, which is the number a
|
||||
@@ -172,21 +173,25 @@ describe("the bay is sized from the car", () => {
|
||||
});
|
||||
|
||||
it("grows with the vehicle rather than being authored twice", () => {
|
||||
const small = apronMetrics({ length: 3.6, width: 1.6 }, "street");
|
||||
const large = apronMetrics({ length: 5.6, width: 2.4 }, "street");
|
||||
const small = apronMetrics({ length: 3.6, width: 1.6 });
|
||||
const large = apronMetrics({ length: 5.6, width: 2.4 });
|
||||
assert.ok(large.stallWidth > small.stallWidth);
|
||||
assert.ok(large.stallLength > small.stallLength);
|
||||
assert.ok(large.padWidth > small.padWidth);
|
||||
});
|
||||
|
||||
it("reads a tower as a deck and everything else as a street", () => {
|
||||
assert.equal(apronKindFor(188), "deck");
|
||||
assert.equal(apronKindFor(4), "street");
|
||||
assert.equal(apronKindFor(1.2), "street");
|
||||
assert.equal(apronKindFor(0), "street");
|
||||
assert.equal(apronKindFor(Number.NaN), "street");
|
||||
assert.ok(apronMetrics(MODEL_X_METRICS, "deck").kerbHeight <
|
||||
apronMetrics(MODEL_X_METRICS, "street").kerbHeight);
|
||||
it("reads a tower as air and everything with a kerb as ground", () => {
|
||||
// The shipped elevations, and both edges of the threshold. See
|
||||
// `ArrivalGround`: this decides *whether* there is an apron, not which kind.
|
||||
assert.equal(arrivalGroundFor(188), "air");
|
||||
assert.equal(arrivalGroundFor(4), "ground");
|
||||
assert.equal(arrivalGroundFor(1.2), "ground");
|
||||
assert.equal(arrivalGroundFor(0), "ground");
|
||||
assert.equal(arrivalGroundFor(GROUND_ARRIVAL_MAX_ELEVATION_M), "ground");
|
||||
assert.equal(arrivalGroundFor(GROUND_ARRIVAL_MAX_ELEVATION_M + 0.01), "air");
|
||||
// A pack that failed to state its elevation gets a kerb, because a bay
|
||||
// nobody walks past is a cheaper mistake than a car in mid-air.
|
||||
assert.equal(arrivalGroundFor(Number.NaN), "ground");
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -11,7 +11,7 @@ import {
|
||||
MATEO_COURT_SITE,
|
||||
} from "../../offices/sites.ts";
|
||||
import type { ExteriorArrival, OfficeSite } from "../../interiors/types.ts";
|
||||
import { PARK_JITTER } from "../../transport/exteriorVehicle.ts";
|
||||
import { PARK_JITTER, arrivalGroundFor } from "../../transport/exteriorVehicle.ts";
|
||||
import {
|
||||
createSimulatedVehicleTelemetry,
|
||||
type VehicleTelemetryState,
|
||||
@@ -23,6 +23,17 @@ const SITES: readonly (readonly [string, OfficeSite])[] = [
|
||||
["mateo-court", MATEO_COURT_SITE],
|
||||
];
|
||||
|
||||
/**
|
||||
* The sites whose arrival storey is actually on the ground, and so get an apron.
|
||||
*
|
||||
* Selected by the rule rather than listed by id, on purpose: an id list here
|
||||
* would pass just as happily against a `createOfficeExterior` that hard-coded
|
||||
* `lumbridge-hq`, which is the bug class this repo already hit once when a door
|
||||
* marker was gated on `id === "sf"`.
|
||||
*/
|
||||
const GROUNDED = SITES.filter(([, site]) => arrivalGroundFor(site.elevation) === "ground");
|
||||
const ELEVATED = SITES.filter(([, site]) => arrivalGroundFor(site.elevation) === "air");
|
||||
|
||||
/** A deterministic generator, so a failure is reproducible from the seed alone. */
|
||||
function seeded(seed: number): () => number {
|
||||
let a = seed >>> 0;
|
||||
@@ -85,7 +96,7 @@ function angleDelta(a: number, b: number): number {
|
||||
}
|
||||
|
||||
describe("office exterior placement", () => {
|
||||
for (const [id, site] of SITES) {
|
||||
for (const [id, site] of GROUNDED) {
|
||||
it(`stands one Model X on ${id}'s authored arrival anchor`, () => {
|
||||
const { exterior } = build(site);
|
||||
const arrival = arrivalOf(site);
|
||||
@@ -133,22 +144,42 @@ describe("office exterior placement", () => {
|
||||
}
|
||||
});
|
||||
|
||||
it("gives a tower a podium deck and a street-level site a kerb", () => {
|
||||
// 188 m up a Transbay tower there is no pavement outside the west wall, and
|
||||
// `offices/sites.ts` explicitly left the question of what that means to this
|
||||
// layer. See `apronKindFor`.
|
||||
const tower = build(LUMBRIDGE_HQ_SITE);
|
||||
it("builds nothing at all for a site whose arrival storey is not on ground", () => {
|
||||
// The defect this is here for renders, typechecks and passes a budget: an
|
||||
// apron built at `lumbridge-hq`'s floor is a paved pad and a Model X 188 m
|
||||
// up, in open sky beside the studio's west wall, and from the arrival
|
||||
// viewpoint it reads as a car standing on top of a wall. See
|
||||
// `ArrivalGround` for why the answer is no apron rather than a lower kerb.
|
||||
assert.ok(ELEVATED.length > 0, "a shipped site is off the ground; keep testing it");
|
||||
for (const [id, site] of ELEVATED) {
|
||||
const { exterior } = build(site);
|
||||
assert.equal(meshes(exterior.object).length, 0, `${id} drew geometry in mid-air`);
|
||||
assert.equal(vehicleRoots(exterior.object).length, 0, `${id} parked a car in the sky`);
|
||||
exterior.dispose();
|
||||
}
|
||||
});
|
||||
|
||||
it("stays inert rather than throwing when telemetry arrives anyway", () => {
|
||||
// The caller does not know about architecture and should not have to: it
|
||||
// adds the object to the level group and publishes telemetry into it every
|
||||
// time a feed ticks. See the note on the early return in `officeExterior.ts`.
|
||||
const { exterior } = build(LUMBRIDGE_HQ_SITE);
|
||||
exterior.apply(stateOf({ pluggedIn: true, socPct: 40, locked: false }));
|
||||
exterior.apply(stateOf({ pluggedIn: false, socPct: 100, locked: true }));
|
||||
assert.equal(exterior.object.children.length, 0);
|
||||
exterior.dispose();
|
||||
});
|
||||
|
||||
it("still gives a street-level site its kerb", () => {
|
||||
const street = build(MATEO_COURT_SITE);
|
||||
const kerbHeight = (exterior: OfficeExterior): number => {
|
||||
const kerb = meshes(exterior.object).find((mesh) => mesh.name.includes("skirting"));
|
||||
assert.ok(kerb, "the apron has a kerb");
|
||||
kerb.geometry.computeBoundingBox();
|
||||
const box = kerb.geometry.boundingBox;
|
||||
assert.ok(box);
|
||||
return box.max.y - box.min.y;
|
||||
};
|
||||
assert.ok(kerbHeight(tower.exterior) < kerbHeight(street.exterior));
|
||||
tower.exterior.dispose();
|
||||
const kerb = meshes(street.exterior.object).find((mesh) => mesh.name.includes("skirting"));
|
||||
assert.ok(kerb, "the apron has a kerb");
|
||||
kerb.geometry.computeBoundingBox();
|
||||
const box = kerb.geometry.boundingBox;
|
||||
assert.ok(box);
|
||||
// A kerb you could trip over, not a painted line: this is the height that
|
||||
// makes the pad read as pavement rather than as a decal on the ground.
|
||||
assert.ok(box.max.y - box.min.y > 0.1, `kerb ${box.max.y - box.min.y} m high`);
|
||||
street.exterior.dispose();
|
||||
});
|
||||
|
||||
|
||||
@@ -79,45 +79,78 @@ export function metreScaleVehicleOptions(
|
||||
return { routeId, ...METRE_SCALE_VEHICLE_OPTIONS, ...overrides };
|
||||
}
|
||||
|
||||
// ---- The apron ------------------------------------------------------------
|
||||
// ---- Is there ground outside the door? ------------------------------------
|
||||
|
||||
/**
|
||||
* What kind of ground the building's front door opens onto.
|
||||
* What the arrival storey's floor actually stands on.
|
||||
*
|
||||
* `street` is a kerb, a marked bay and a strip of carriageway: the normal case,
|
||||
* and what `mateo-court` (1.2 m above Mateo Street) and `frontier-valley` (4 m
|
||||
* above an airfield apron) both have.
|
||||
* `ground` is the normal case and the only one an apron can be built on:
|
||||
* `mateo-court`'s floor is 1.2 m above Mateo Street and `frontier-valley`'s is
|
||||
* 4 m above an airfield, and both of those are a kerb and a ramp — a car can be
|
||||
* driven to them, so a marked bay a few metres outside the façade is a bay on
|
||||
* the pavement.
|
||||
*
|
||||
* `deck` is the answer to the question `offices/sites.ts` deliberately left
|
||||
* open. `lumbridge-hq` is authored 188 m up a Transbay tower and its arrival
|
||||
* anchor is "the kerb of the podium", because the pack frame is the only frame
|
||||
* a pack has. Drawing a public street there would be a lie about a building
|
||||
* that has none at that height, so an elevated site gets a podium deck instead:
|
||||
* the same marked bay and the same charge post, standing on a paved deck with a
|
||||
* low upstand and no carriageway running off it.
|
||||
* `air` is `lumbridge-hq`. Its floor is 188 m above the ground outside, which is
|
||||
* what `OfficeSite.elevation` measures and says so in as many words: "how far
|
||||
* this pack's level-0 floor sits above **the ground outside**". There is no
|
||||
* pavement out there to park on, and there is no version of a paved pad, a kerb,
|
||||
* a charge post and a two-and-a-half-tonne car at that height that is not a
|
||||
* claim about ground that is not there.
|
||||
*
|
||||
* The vertical question that note deferred is settled the same way, and by
|
||||
* `ExteriorArrival`'s own wording rather than by a new rule: `levelId` names
|
||||
* "the storey whose floor this stall is measured from", so the apron stands on
|
||||
* that floor. The tower's car is on the podium at level 1, not on Folsom Street
|
||||
* 188 m below it.
|
||||
* ### Why this is a *whether*, not a *which*
|
||||
*
|
||||
* An earlier version of this file made the same distinction and spent it on
|
||||
* style: an elevated site got a "podium deck", the same bay and the same car on
|
||||
* a slab with a lower upstand. That reasoning went wrong at the vertical.
|
||||
* `ExteriorArrival.levelId` names "the storey whose floor this stall is measured
|
||||
* from", so the deck was built at the *studio's* floor — and a studio 188 m up a
|
||||
* Transbay tower put a paved pad and a Model X in open sky beside a wall. From
|
||||
* the arrival viewpoint it read as a car standing on top of a wall, which is
|
||||
* exactly what it was. Nothing about the kerb height fixed that, because the
|
||||
* defect was never the kerb.
|
||||
*
|
||||
* The two repairs that are not this one, and why:
|
||||
*
|
||||
* - **Drop the apron to the ground plane** (a sited office gets one at
|
||||
* `-site.elevation`, CONTRACT §4) and park the car on the real podium. But the
|
||||
* anchor's XZ is authored against the *studio's* walls — 12 × 9 m of floor
|
||||
* plate — and the tower's footprint at street level is 48 × 42 m, so the two
|
||||
* frames have no relationship at all below the storey the stall was measured
|
||||
* from. It would also be thirty-odd draw calls of geometry four pixels tall.
|
||||
* - **Delete the anchor from the pack.** The anchor is not wrong; a podium kerb
|
||||
* does exist at the foot of that tower. What is wrong is rendering it 188 m
|
||||
* above itself. The pack keeps saying the true thing and this layer stops
|
||||
* drawing the false one.
|
||||
*
|
||||
* Derived from `site.elevation` and nothing else, deliberately. The last time a
|
||||
* rule like this was written against an office id — a door marker gated on
|
||||
* `id === "sf"` — it would have pinned the Los Angeles building to San
|
||||
* Francisco. A pack that arrives over HTTP from a self-hoster gets the same
|
||||
* answer as a shipped one, for the same reason.
|
||||
*/
|
||||
export type ApronKind = "street" | "deck";
|
||||
export type ArrivalGround = "ground" | "air";
|
||||
|
||||
/**
|
||||
* Above this site elevation there is no street outside the door.
|
||||
* Above this floor elevation there is nothing outside the door to park on.
|
||||
*
|
||||
* Thirty metres is about ten storeys — comfortably above anything with a kerb
|
||||
* and comfortably below anything that could be mistaken for one. Both numbers
|
||||
* either side of it in the shipped packs (4 m and 188 m) are nowhere near it,
|
||||
* which is the property a threshold like this wants.
|
||||
* Thirty metres is about ten storeys — comfortably above anything a vehicle can
|
||||
* be driven to and comfortably below anything that could be mistaken for it.
|
||||
* Both numbers either side of it in the shipped packs (4 m and 188 m) are
|
||||
* nowhere near it, which is the property a threshold like this wants: it is not
|
||||
* doing fine discrimination, it is separating "there is a kerb here" from "this
|
||||
* is the sky".
|
||||
*
|
||||
* A non-finite elevation reads as `ground`, because a pack that failed to say
|
||||
* how high it is is far more likely to be at street level than up a tower, and
|
||||
* the failure mode of guessing wrong that way is a bay nobody walks past rather
|
||||
* than a car in mid-air.
|
||||
*/
|
||||
export const APRON_STREET_MAX_ELEVATION_M = 30;
|
||||
export const GROUND_ARRIVAL_MAX_ELEVATION_M = 30;
|
||||
|
||||
export function apronKindFor(siteElevationM: number): ApronKind {
|
||||
return Number.isFinite(siteElevationM) && siteElevationM > APRON_STREET_MAX_ELEVATION_M
|
||||
? "deck"
|
||||
: "street";
|
||||
export function arrivalGroundFor(siteElevationM: number): ArrivalGround {
|
||||
return Number.isFinite(siteElevationM) && siteElevationM > GROUND_ARRIVAL_MAX_ELEVATION_M
|
||||
? "air"
|
||||
: "ground";
|
||||
}
|
||||
|
||||
/** Just enough of a vehicle to size a bay for it. */
|
||||
@@ -128,9 +161,15 @@ export interface VehicleFootprint {
|
||||
width: number;
|
||||
}
|
||||
|
||||
/** Every dimension `officeExterior.ts` needs, in metres, all derived. */
|
||||
/**
|
||||
* Every dimension `officeExterior.ts` needs, in metres, all derived.
|
||||
*
|
||||
* There is one apron and it is a kerbside one, because the only sites that get
|
||||
* an apron at all are the ones with a kerb — see `ArrivalGround`. The second
|
||||
* variant this interface used to carry was a podium deck for an elevated site,
|
||||
* and it was answering the wrong question.
|
||||
*/
|
||||
export interface ApronMetrics {
|
||||
kind: ApronKind;
|
||||
/** Thickness of the paved slab. The vehicle stands on top of it. */
|
||||
padThickness: number;
|
||||
padWidth: number;
|
||||
@@ -162,24 +201,24 @@ export interface ApronMetrics {
|
||||
* measured off those two numbers, so there is exactly one place to change if
|
||||
* the vehicle changes.
|
||||
*/
|
||||
export function apronMetrics(vehicle: VehicleFootprint, kind: ApronKind): ApronMetrics {
|
||||
export function apronMetrics(vehicle: VehicleFootprint): ApronMetrics {
|
||||
const width = Math.max(1.2, vehicle.width);
|
||||
const length = Math.max(2.4, vehicle.length);
|
||||
const stallWidth = width + 0.9;
|
||||
const stallLength = length + 1.0;
|
||||
return {
|
||||
kind,
|
||||
padThickness: 0.06,
|
||||
// A shoulder wide enough to walk round the car on, and deep enough that the
|
||||
// bay is not floating in the middle of nothing at an oblique camera.
|
||||
padWidth: stallWidth + 1.8,
|
||||
padDepth: stallLength + (kind === "street" ? 3.4 : 1.6),
|
||||
// The extra 3.4 m at the open end is the strip of carriageway the car drove
|
||||
// in over, which is what makes the bay read as being *on* a street.
|
||||
padDepth: stallLength + 3.4,
|
||||
stallWidth,
|
||||
stallLength,
|
||||
lineWidth: 0.1,
|
||||
// A street kerb is a full 135 mm step; a podium deck gets a low upstand,
|
||||
// because nothing is going to drive up onto a deck 188 m in the air.
|
||||
kerbHeight: kind === "street" ? 0.135 : 0.09,
|
||||
// A full 135 mm step, which is what a kerb is.
|
||||
kerbHeight: 0.135,
|
||||
kerbDepth: 0.3,
|
||||
postWidth: 0.3,
|
||||
postDepth: 0.2,
|
||||
|
||||
Reference in New Issue
Block a user