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:
@@ -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();
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user