SoCal, the whole bay, a moon, and gates that actually run
Six agents in parallel, and the two city packs independently reported the same blocker: `focusRegions` and `coarseFactor` existed on the `City` type and nothing implemented them. Uniform lattices would have been 2.9M points for Southern California and 3.7M for the expanded bay. Both packs were unloadable as written. `buildAxis` is the answer, and it is honest about its limits: refinement is per axis, not per rectangle, so a focus region sharpens its whole row *and* its whole column. Two regions at opposite corners refine nearly everything between them. Measured, not guessed — the bay went 0.53M points with one region and 1.64M with three, for detail nobody is looking at from a board this wide. One region each, coarse factor ten, and the builds land at 3.8 s and 2.3 s. Then three things that were only ever right because San Francisco was the only city. `maxDistance: 340` and a 170-unit shadow box were constants tuned for a 230-unit board; the bay is 1003 units across and the camera physically could not retreat far enough to frame it. Fog distances were scene units pinned to the same assumption. And `minVisibilityM` defaulted to 4.5 km of honest weather, which over ninety-four kilometres of bay correctly hides three quarters of it — the night view was a black rectangle for a completely reasonable reason. All three now derive from the board. The moon is a real ephemeris and its light is a deliberate lie: 1.15, against a physical ratio of one to four hundred thousand. What is being reproduced is what a moonlit night looks like on a screen in a lit room. The CI gate caught itself, which is the part worth keeping. Port 8431 was already held by a server from an earlier session, so the boot check polled a healthy stranger while the process it started died on EADDRINUSE. It now refuses to run rather than pass. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
+439
-31
@@ -15,6 +15,7 @@
|
||||
*/
|
||||
|
||||
import * as THREE from "three";
|
||||
import { mergeGeometries } from "three/examples/jsm/utils/BufferGeometryUtils.js";
|
||||
import type { Aircraft, FlightSource } from "./types.ts";
|
||||
import { seededRandom, type World } from "./world.ts";
|
||||
|
||||
@@ -54,20 +55,33 @@ export class SimulatedFlights implements FlightSource {
|
||||
this.t += Math.min(now - this.last, 5);
|
||||
this.last = now;
|
||||
|
||||
return this.routes.map((route, i) => {
|
||||
const p = ((this.t / route.duration + (this.phase[i] ?? 0)) % 1 + 1) % 1;
|
||||
const lat = route.from[0] + (route.to[0] - route.from[0]) * p;
|
||||
const lng = route.from[1] + (route.to[1] - route.from[1]) * p;
|
||||
// Ease the altitude so departures climb steeply and level off.
|
||||
const ease = 1 - (1 - p) ** 2;
|
||||
const altitude = route.fromAlt + (route.toAlt - route.fromAlt) * ease;
|
||||
const heading =
|
||||
(Math.atan2(route.to[1] - route.from[1], route.to[0] - route.from[0]) * 180) / Math.PI;
|
||||
return { id: `sim-${route.callsign}`, callsign: route.callsign, lat, lng, altitude, heading };
|
||||
});
|
||||
return this.routes.map((route, i) => sampleRoute(route, this.t / route.duration + (this.phase[i] ?? 0)));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* One aircraft's state at a fraction of the way along its leg. `p` wraps, so
|
||||
* anything can be handed in and 1.4 means the same as 0.4.
|
||||
*
|
||||
* Split out of `SimulatedFlights.poll` because the HTTP adapter needs exactly
|
||||
* this and cannot reuse the class to get it: `SimulatedFlights` runs on a
|
||||
* monotonic clock that starts when it is constructed, whereas the wire's
|
||||
* `FlightsPlanBody` anchors every route to a fixed epoch so that two browsers
|
||||
* agree about where the aircraft are. Same arithmetic, different origin — and
|
||||
* two copies of the arithmetic would drift.
|
||||
*/
|
||||
export function sampleRoute(route: SimRoute, p: number): Aircraft {
|
||||
const t = ((p % 1) + 1) % 1;
|
||||
const lat = route.from[0] + (route.to[0] - route.from[0]) * t;
|
||||
const lng = route.from[1] + (route.to[1] - route.from[1]) * t;
|
||||
// Ease the altitude so departures climb steeply and level off.
|
||||
const ease = 1 - (1 - t) ** 2;
|
||||
const altitude = route.fromAlt + (route.toAlt - route.fromAlt) * ease;
|
||||
const heading =
|
||||
(Math.atan2(route.to[1] - route.from[1], route.to[0] - route.from[0]) * 180) / Math.PI;
|
||||
return { id: `sim-${route.callsign}`, callsign: route.callsign, lat, lng, altitude, heading };
|
||||
}
|
||||
|
||||
function nowSeconds(): number {
|
||||
return (typeof performance !== "undefined" ? performance.now() : 0) / 1000;
|
||||
}
|
||||
@@ -125,53 +139,447 @@ interface RawAircraft {
|
||||
|
||||
export interface FlightLayer {
|
||||
group: THREE.Group;
|
||||
/**
|
||||
* Hand over a fresh observation. Called on the source's own timer, which is
|
||||
* once a second for the simulator and once every several seconds for a real
|
||||
* feed; the motion in between is this layer's problem, not the caller's.
|
||||
*/
|
||||
update(aircraft: Aircraft[]): void;
|
||||
/**
|
||||
* Move everything to where it should be at this instant.
|
||||
*
|
||||
* A pure function of the wall clock and the last two observations, so calling
|
||||
* it twice in a frame does the same thing as calling it once. That matters:
|
||||
* the layer drives itself from the trail geometry's `onBeforeRender` — see
|
||||
* `createFlightLayer` — and a scene that also ticks it explicitly must not end
|
||||
* up advancing time twice as fast.
|
||||
*/
|
||||
tick(): void;
|
||||
dispose(): void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Aircraft as small darts with a shadow-less trail. Rendered at true altitude
|
||||
* through the world's vertical exaggeration, so a jet on approach sits visibly
|
||||
* below one at cruise.
|
||||
* How many observations a trail remembers, and how long it may hold one.
|
||||
*
|
||||
* Both limits are needed. The count keeps the shared vertex buffer bounded, and
|
||||
* the age keeps a slow feed from drawing a trail across the entire bay: at
|
||||
* `AdsbFlights`'s eight-second interval, twenty samples is nearly three minutes
|
||||
* of flying, which is most of a leg.
|
||||
*/
|
||||
const TRAIL_POINTS = 20;
|
||||
const TRAIL_SECONDS = 45;
|
||||
|
||||
/** Ceiling on tracks that get a trail, so the buffer can be allocated once. */
|
||||
const MAX_TRACKS = 192;
|
||||
|
||||
/**
|
||||
* Opacity at the head of a trail, fading to nothing at the tail. Well under 1
|
||||
* on purpose: the trail is context for the dart, not a second subject, and a
|
||||
* dozen opaque lines over a city read as a wiring diagram.
|
||||
*/
|
||||
const TRAIL_ALPHA = 0.55;
|
||||
|
||||
/**
|
||||
* Bounds on how long a leg between two observations may be taken to be.
|
||||
*
|
||||
* The span is measured rather than declared, because a `FlightSource` announces
|
||||
* an `interval` and then misses it — a tab in the background, a slow upstream,
|
||||
* a fetch that took two seconds. Interpolating over the announced interval when
|
||||
* the real gap was four times that gives an aircraft that darts and then waits.
|
||||
*/
|
||||
const MIN_SPAN = 0.2;
|
||||
const MAX_SPAN = 15;
|
||||
|
||||
/**
|
||||
* Above this, a step is a teleport rather than a flight.
|
||||
*
|
||||
* Scene units per second, and generous: a fast jet at this city's ~94 m per
|
||||
* unit covers about three. The case this exists for is the simulator's routes
|
||||
* looping — an aircraft reaching the end of its leg reappears at the start,
|
||||
* which is several hundred units in one poll — and without the check the trail
|
||||
* draws a bright line straight across San Francisco every time one wraps.
|
||||
*/
|
||||
const JUMP_UNITS_PER_SECOND = 8;
|
||||
|
||||
/**
|
||||
* Altitude, as colour.
|
||||
*
|
||||
* The obvious cue is a drop line to the ground, and it was tried first and
|
||||
* removed: this city renders at ~94 m per scene unit with a 3.6× vertical
|
||||
* exaggeration, so an aircraft at cruise sits about 230 units above a downtown
|
||||
* whose tallest tower is 10, and its drop line is a full-height wire through the
|
||||
* middle of the frame. Twelve of those is a birdcage. Colour costs nothing, is
|
||||
* readable at any camera distance, and — because the trail carries it too — a
|
||||
* climb shows up as a gradient along the ribbon rather than as a number nobody
|
||||
* reads.
|
||||
*/
|
||||
const LOW_COLOR = new THREE.Color(0xffb277);
|
||||
const HIGH_COLOR = new THREE.Color(0xdfeaf6);
|
||||
/** Metres at which the ramp reaches `HIGH_COLOR`. Roughly a cruising airliner. */
|
||||
const CRUISE_METRES = 9000;
|
||||
/** Distinct materials along the ramp. Enough to look continuous, few enough to cache. */
|
||||
const COLOR_BANDS = 12;
|
||||
|
||||
/** Steepest nose-up or nose-down attitude a dart is drawn at, in radians. */
|
||||
const MAX_PITCH = 0.42;
|
||||
|
||||
interface TrailSample {
|
||||
position: THREE.Vector3;
|
||||
altitude: number;
|
||||
/** Compass degrees, as reported. */
|
||||
heading: number;
|
||||
/** Seconds on `nowSeconds`'s monotonic clock. */
|
||||
at: number;
|
||||
}
|
||||
|
||||
interface Track {
|
||||
mesh: THREE.Mesh;
|
||||
/** Observations, oldest first. The last is where the aircraft is heading. */
|
||||
samples: TrailSample[];
|
||||
/** Seconds the current leg should take: the measured gap between the last two. */
|
||||
span: number;
|
||||
/** Climb angle of the current leg, radians, positive nose-up. */
|
||||
pitch: number;
|
||||
/** Which cached material is on the mesh, so a band change is the only write. */
|
||||
band: number;
|
||||
/** Interpolated position, reused rather than reallocated every frame. */
|
||||
head: THREE.Vector3;
|
||||
/** Altitude at `head`, which is what the dart's colour is chosen from. */
|
||||
headAltitude: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Aircraft as small darts, each dragging a fading trail of where it has been.
|
||||
*
|
||||
* Rendered at true altitude through the world's vertical exaggeration, so a jet
|
||||
* on approach sits visibly below one at cruise, and coloured by that altitude so
|
||||
* the difference survives a camera far enough away that the heights stop being
|
||||
* separable.
|
||||
*
|
||||
* The layer moves things every frame while being told where they are only every
|
||||
* poll. Positions are interpolated between the last two observations rather than
|
||||
* extrapolated past the newest one: that costs one interval of lag — a second
|
||||
* for the simulator — and in exchange an aircraft never overshoots and then
|
||||
* snaps back, which is what extrapolation does the moment a feed stutters.
|
||||
*/
|
||||
export function createFlightLayer(world: World): FlightLayer {
|
||||
const group = new THREE.Group();
|
||||
group.name = "flights";
|
||||
|
||||
const geo = new THREE.ConeGeometry(0.1, 0.42, 5);
|
||||
geo.rotateX(Math.PI / 2); // point along +z, so heading maps to a Y rotation
|
||||
const material = new THREE.MeshLambertMaterial({ color: 0xf2f5f8 });
|
||||
const meshes = new Map<string, THREE.Mesh>();
|
||||
const geo = dartGeometry();
|
||||
const materials = new Map<number, THREE.MeshLambertMaterial>();
|
||||
const tracks = new Map<string, Track>();
|
||||
|
||||
const scratch = new THREE.Color();
|
||||
|
||||
/**
|
||||
* One material per altitude band, built on demand.
|
||||
*
|
||||
* The emissive term is small and deliberate. Aircraft are lit by the same rig
|
||||
* as the city, and after sunset that rig is a tenth of an intensity — a dart
|
||||
* of pure diffuse white simply disappears at night, which is the one time of
|
||||
* day the sky is worth looking at.
|
||||
*/
|
||||
function materialFor(band: number): THREE.MeshLambertMaterial {
|
||||
const existing = materials.get(band);
|
||||
if (existing) return existing;
|
||||
const color = scratch.copy(LOW_COLOR).lerp(HIGH_COLOR, band / (COLOR_BANDS - 1)).getHex();
|
||||
const mat = new THREE.MeshLambertMaterial({
|
||||
color,
|
||||
emissive: color,
|
||||
emissiveIntensity: 0.35,
|
||||
});
|
||||
materials.set(band, mat);
|
||||
return mat;
|
||||
}
|
||||
|
||||
// ---- The trail ----------------------------------------------------------
|
||||
|
||||
// One `LineSegments` for every trail in the scene rather than one per
|
||||
// aircraft: the vertex count is trivial either way, and a single draw call
|
||||
// with a preallocated buffer avoids allocating and disposing geometry every
|
||||
// time traffic changes. Per-vertex alpha does the fade, which needs a
|
||||
// four-component colour attribute — three.js reads the item size and switches
|
||||
// the shader on it.
|
||||
const maxVertices = MAX_TRACKS * TRAIL_POINTS * 2;
|
||||
const trailPositions = new Float32Array(maxVertices * 3);
|
||||
const trailColors = new Float32Array(maxVertices * 4);
|
||||
const trailGeo = new THREE.BufferGeometry();
|
||||
trailGeo.setAttribute("position", new THREE.BufferAttribute(trailPositions, 3));
|
||||
trailGeo.setAttribute("color", new THREE.BufferAttribute(trailColors, 4));
|
||||
trailGeo.setDrawRange(0, 0);
|
||||
const trailMat = new THREE.LineBasicMaterial({
|
||||
vertexColors: true,
|
||||
transparent: true,
|
||||
// Trails cross each other constantly and are the faintest thing in the
|
||||
// scene; letting them write depth makes the one that happened to draw first
|
||||
// punch a hole in every one behind it.
|
||||
depthWrite: false,
|
||||
});
|
||||
const trailLine = new THREE.LineSegments(trailGeo, trailMat);
|
||||
trailLine.name = "flight-trails";
|
||||
// The buffer is rewritten from scene-space coordinates every frame, so its
|
||||
// bounding sphere is permanently wrong and culling it would be culling the
|
||||
// whole layer.
|
||||
trailLine.frustumCulled = false;
|
||||
// The layer is handed observations on the source's timer and is otherwise
|
||||
// never called, so the interpolation hangs off the one thing guaranteed to
|
||||
// happen every frame: this line being drawn. `tick` is idempotent, so a scene
|
||||
// that would rather drive the layer itself can call it and nothing here
|
||||
// double-counts.
|
||||
trailLine.onBeforeRender = () => tick();
|
||||
group.add(trailLine);
|
||||
|
||||
// ---- Observations -------------------------------------------------------
|
||||
|
||||
function update(aircraft: Aircraft[]) {
|
||||
const now = nowSeconds();
|
||||
const seen = new Set<string>();
|
||||
|
||||
for (const a of aircraft) {
|
||||
seen.add(a.id);
|
||||
let mesh = meshes.get(a.id);
|
||||
if (!mesh) {
|
||||
mesh = new THREE.Mesh(geo, material);
|
||||
meshes.set(a.id, mesh);
|
||||
group.add(mesh);
|
||||
}
|
||||
const [x, z] = world.project(a.lat, a.lng);
|
||||
mesh.position.set(x, world.metres(a.altitude), z);
|
||||
mesh.rotation.y = -(a.heading * Math.PI) / 180;
|
||||
const position = new THREE.Vector3(x, world.metres(a.altitude), z);
|
||||
const sample: TrailSample = { position, altitude: a.altitude, heading: a.heading, at: now };
|
||||
|
||||
let track = tracks.get(a.id);
|
||||
if (!track) {
|
||||
const mesh = new THREE.Mesh(geo, materialFor(0));
|
||||
// Yaw then pitch, because the heading is about the world's vertical and
|
||||
// the climb angle is about the aircraft's own wing.
|
||||
mesh.rotation.order = "YXZ";
|
||||
group.add(mesh);
|
||||
track = {
|
||||
mesh,
|
||||
samples: [],
|
||||
span: MIN_SPAN,
|
||||
pitch: 0,
|
||||
band: -1,
|
||||
head: position.clone(),
|
||||
headAltitude: a.altitude,
|
||||
};
|
||||
tracks.set(a.id, track);
|
||||
}
|
||||
|
||||
const previous = track.samples[track.samples.length - 1];
|
||||
if (previous) {
|
||||
// The clamp is load-bearing on both ends. Two polls arriving in the same
|
||||
// millisecond — a manual refresh, a tab waking up — divide by nearly
|
||||
// zero and make every aircraft look like it teleported; a source that
|
||||
// stalled for a minute makes the next honest step look like one too.
|
||||
const span = clamp(now - previous.at, MIN_SPAN, MAX_SPAN);
|
||||
// Ground distance only. Scene height is exaggerated 3.6× here, so a
|
||||
// healthy climb contributes more to a straight 3-D distance than the
|
||||
// aircraft's actual speed does, and a departure out of SFO would trip
|
||||
// the teleport test on every poll.
|
||||
const travelled = Math.hypot(
|
||||
position.x - previous.position.x,
|
||||
position.z - previous.position.z,
|
||||
);
|
||||
if (travelled / span > JUMP_UNITS_PER_SECOND) {
|
||||
// A source that has moved something further than anything flies has
|
||||
// either looped a simulated route or reused an id. Either way the
|
||||
// history is about a different flight; keeping it would draw a trail
|
||||
// across the map.
|
||||
track.samples.length = 0;
|
||||
track.head.copy(position);
|
||||
track.pitch = 0;
|
||||
} else {
|
||||
track.span = span;
|
||||
track.pitch = climbAngle(world, previous, sample);
|
||||
}
|
||||
}
|
||||
|
||||
track.samples.push(sample);
|
||||
trim(track, now);
|
||||
}
|
||||
for (const [id, mesh] of meshes) {
|
||||
|
||||
for (const [id, track] of tracks) {
|
||||
if (seen.has(id)) continue;
|
||||
group.remove(mesh);
|
||||
meshes.delete(id);
|
||||
group.remove(track.mesh);
|
||||
tracks.delete(id);
|
||||
}
|
||||
|
||||
tick();
|
||||
}
|
||||
|
||||
/** Forget history that is too old or too long to be worth drawing. */
|
||||
function trim(track: Track, now: number) {
|
||||
while (track.samples.length > TRAIL_POINTS) track.samples.shift();
|
||||
while (track.samples.length > 2) {
|
||||
const oldest = track.samples[0];
|
||||
if (!oldest || now - oldest.at <= TRAIL_SECONDS) break;
|
||||
track.samples.shift();
|
||||
}
|
||||
}
|
||||
|
||||
// ---- Per-frame ----------------------------------------------------------
|
||||
|
||||
function tick() {
|
||||
const now = nowSeconds();
|
||||
for (const track of tracks.values()) {
|
||||
const n = track.samples.length;
|
||||
const to = track.samples[n - 1];
|
||||
if (!to) continue;
|
||||
const from = track.samples[n - 2] ?? to;
|
||||
const alpha = from === to ? 1 : clamp((now - to.at) / track.span, 0, 1);
|
||||
|
||||
track.head.lerpVectors(from.position, to.position, alpha);
|
||||
track.headAltitude = from.altitude + (to.altitude - from.altitude) * alpha;
|
||||
|
||||
track.mesh.position.copy(track.head);
|
||||
// A heading of 0 is north, and north is -z, so a dart whose nose is
|
||||
// modelled along +z has to be turned all the way round before the compass
|
||||
// and the scene agree. The previous mapping was a bare negation of the
|
||||
// heading, which flew every aircraft tail-first and put an easterly
|
||||
// departure over the Pacific.
|
||||
track.mesh.rotation.y = Math.PI - (interpolateHeading(from.heading, to.heading, alpha) * Math.PI) / 180;
|
||||
// Negative, because rotating the nose (+z) about +x by a positive angle
|
||||
// pushes it down.
|
||||
track.mesh.rotation.x = -track.pitch;
|
||||
|
||||
const band = bandFor(track.headAltitude);
|
||||
if (band !== track.band) {
|
||||
track.band = band;
|
||||
track.mesh.material = materialFor(band);
|
||||
}
|
||||
}
|
||||
rebuildTrails();
|
||||
}
|
||||
|
||||
/**
|
||||
* Rewrite the shared trail buffer.
|
||||
*
|
||||
* The spine is every observation except the newest, followed by the
|
||||
* interpolated head — the newest observation is where the aircraft is *going*,
|
||||
* and drawing to it would put the trail in front of the dart.
|
||||
*/
|
||||
function rebuildTrails() {
|
||||
let vertex = 0;
|
||||
for (const track of tracks.values()) {
|
||||
const spine = track.samples.length - 1;
|
||||
if (spine < 1) continue;
|
||||
const points = spine + 1; // the spine, plus the head
|
||||
|
||||
for (let i = 1; i < points; i++) {
|
||||
if (vertex + 2 > maxVertices) break;
|
||||
const a = track.samples[i - 1];
|
||||
if (!a) continue;
|
||||
const bSample = i < spine ? track.samples[i] : null;
|
||||
const bPosition = bSample ? bSample.position : track.head;
|
||||
const bAltitude = bSample ? bSample.altitude : track.headAltitude;
|
||||
|
||||
// Alpha runs from nothing at the tail to `TRAIL_ALPHA` at the aircraft,
|
||||
// eased so that the fade happens mostly in the older half and the
|
||||
// segment behind the dart stays legible.
|
||||
writeTrailVertex(vertex++, a.position, a.altitude, ((i - 1) / spine) ** 1.7);
|
||||
writeTrailVertex(vertex++, bPosition, bAltitude, (i / spine) ** 1.7);
|
||||
}
|
||||
}
|
||||
trailGeo.setDrawRange(0, vertex);
|
||||
trailGeo.attributes.position!.needsUpdate = true;
|
||||
trailGeo.attributes.color!.needsUpdate = true;
|
||||
}
|
||||
|
||||
function writeTrailVertex(index: number, position: THREE.Vector3, altitude: number, fade: number) {
|
||||
const p = index * 3;
|
||||
trailPositions[p] = position.x;
|
||||
trailPositions[p + 1] = position.y;
|
||||
trailPositions[p + 2] = position.z;
|
||||
// `THREE.Color` holds working-space values, which is what a vertex colour
|
||||
// attribute is read as — so the ramp and the dart materials, which come from
|
||||
// the same two colours, agree.
|
||||
scratch.copy(LOW_COLOR).lerp(HIGH_COLOR, ramp(altitude));
|
||||
const c = index * 4;
|
||||
trailColors[c] = scratch.r;
|
||||
trailColors[c + 1] = scratch.g;
|
||||
trailColors[c + 2] = scratch.b;
|
||||
trailColors[c + 3] = fade * TRAIL_ALPHA;
|
||||
}
|
||||
|
||||
return {
|
||||
group,
|
||||
update,
|
||||
tick,
|
||||
dispose() {
|
||||
geo.dispose();
|
||||
material.dispose();
|
||||
meshes.clear();
|
||||
for (const m of materials.values()) m.dispose();
|
||||
materials.clear();
|
||||
trailGeo.dispose();
|
||||
trailMat.dispose();
|
||||
tracks.clear();
|
||||
group.clear();
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* A dart: a five-sided body with a wing and a tailplane, merged into one
|
||||
* geometry so an aircraft is one draw call.
|
||||
*
|
||||
* The wing is what earns its keep. A bare cone at this scale is a bright speck
|
||||
* with no orientation, and the whole reason to draw traffic on a city map is
|
||||
* that it is going somewhere — the crossbar is the only part of the silhouette
|
||||
* that says which way.
|
||||
*/
|
||||
function dartGeometry(): THREE.BufferGeometry {
|
||||
const body = new THREE.ConeGeometry(0.09, 0.42, 5);
|
||||
body.rotateX(Math.PI / 2); // nose along +z, so heading is a rotation about Y
|
||||
const wing = new THREE.BoxGeometry(0.44, 0.016, 0.085);
|
||||
wing.translate(0, -0.005, -0.02);
|
||||
const tail = new THREE.BoxGeometry(0.15, 0.014, 0.055);
|
||||
tail.translate(0, 0.02, -0.165);
|
||||
|
||||
const parts = [body, wing, tail];
|
||||
const merged = mergeGeometries(parts);
|
||||
for (const part of parts) part.dispose();
|
||||
if (merged) return merged;
|
||||
|
||||
// `mergeGeometries` returns null when the inputs disagree about their
|
||||
// attributes, which three primitives from the same library cannot — but the
|
||||
// signature allows it, and a missing aircraft is worse than a plain one.
|
||||
const fallback = new THREE.ConeGeometry(0.09, 0.42, 5);
|
||||
fallback.rotateX(Math.PI / 2);
|
||||
return fallback;
|
||||
}
|
||||
|
||||
/**
|
||||
* The climb angle of a leg, from the real numbers rather than the scene's.
|
||||
*
|
||||
* Scene height is exaggerated 3.6× here, so an angle measured off the rendered
|
||||
* positions would put a routine departure at forty degrees nose-up. Horizontal
|
||||
* distance in scene units *is* proportional to distance on the ground, so one
|
||||
* multiplication converts it and the altitudes are already metres.
|
||||
*/
|
||||
function climbAngle(world: World, from: TrailSample, to: TrailSample): number {
|
||||
const dx = to.position.x - from.position.x;
|
||||
const dz = to.position.z - from.position.z;
|
||||
const horizontal = Math.hypot(dx, dz) * world.metresPerUnit;
|
||||
if (horizontal < 1) return 0;
|
||||
return clamp(Math.atan2(to.altitude - from.altitude, horizontal), -MAX_PITCH, MAX_PITCH);
|
||||
}
|
||||
|
||||
/**
|
||||
* Blend two compass headings the short way round.
|
||||
*
|
||||
* A straight lerp from 350° to 10° spins the aircraft 340° through south over
|
||||
* the course of a second, which is the most conspicuous artefact this whole file
|
||||
* could have.
|
||||
*/
|
||||
function interpolateHeading(from: number, to: number, t: number): number {
|
||||
const delta = (((to - from) % 360) + 540) % 360 - 180;
|
||||
return from + delta * t;
|
||||
}
|
||||
|
||||
/** 0 on the deck, 1 at cruise. Curved, because the low end is where the eye is. */
|
||||
function ramp(altitude: number): number {
|
||||
return clamp(altitude / CRUISE_METRES, 0, 1) ** 0.6;
|
||||
}
|
||||
|
||||
function bandFor(altitude: number): number {
|
||||
return Math.round(ramp(altitude) * (COLOR_BANDS - 1));
|
||||
}
|
||||
|
||||
function clamp(x: number, lo: number, hi: number): number {
|
||||
return x < lo ? lo : x > hi ? hi : x;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user