1
0

feat: Fly is KLAX to KSFO, the pack is a gauge, and Crow is not the outdoor door
gates / clean-clone (push) Successful in 3m10s
gates / zero-config-boot (push) Successful in 13s
gates / no-binary-art (push) Successful in 5s
gates / dependency-terms (push) Successful in 7s

The assisted corridor used downtown coordinates, so "fly to San Francisco"
arrived over City Hall. Waypoints are the field centres. The HUD already
had watt-hours; it now shows pack percent, which is the gauge. Anon
outdoors no longer gets Explore-as-crow — Fly is the body. The crow kit
stays in the repo for Arena.

Satellite fixes now carry the sub-satellite point. `nextOverCalifornia`
is the handoff the follow camera will use; it is not the camera yet.
This commit is contained in:
2026-08-24 23:58:10 -07:00
parent a5e04f0300
commit 8d6b1c2dab
7 changed files with 152 additions and 9 deletions
+9 -2
View File
@@ -21,10 +21,17 @@ import {
type AircraftWaypoint, type AircraftWaypoint,
} from "./controller.ts"; } from "./controller.ts";
/**
* Assisted corridor: KLAX to KSFO.
*
* Downtown coordinates used to stand in for the airports, so "fly to San
* Francisco" arrived over City Hall. These are the field centres. Climb is
* authored on the waypoints; the envelope still caps the ceiling.
*/
export const CALIFORNIA_AIR_ROUTE: readonly AircraftWaypoint[] = Object.freeze([ export const CALIFORNIA_AIR_ROUTE: readonly AircraftWaypoint[] = Object.freeze([
Object.freeze({ id: "los-angeles", lat: 34.0522, lng: -118.2437, altitudeM: 1_350 }), Object.freeze({ id: "klax", lat: 33.9416, lng: -118.4085, altitudeM: 400 }),
Object.freeze({ id: "central-valley", lat: 36.45, lng: -120.45, altitudeM: 1_700 }), Object.freeze({ id: "central-valley", lat: 36.45, lng: -120.45, altitudeM: 1_700 }),
Object.freeze({ id: "san-francisco", lat: 37.7749, lng: -122.4194, altitudeM: 1_250 }), Object.freeze({ id: "ksfo", lat: 37.6213, lng: -122.379, altitudeM: 400 }),
]); ]);
export type AircraftProjection = (lat: number, lng: number) => readonly [x: number, z: number]; export type AircraftProjection = (lat: number, lng: number) => readonly [x: number, z: number];
+73 -1
View File
@@ -84,6 +84,13 @@ export interface SatelliteFix {
elevation: number; elevation: number;
/** Observer to satellite, in kilometres. Straight-line, not ground track. */ /** Observer to satellite, in kilometres. Straight-line, not ground track. */
rangeKm: number; rangeKm: number;
/**
* Sub-satellite point, degrees. The ground under the spacecraft, not the
* look-angle on the dome. Follow cameras use this; `starlinkMesh.ts`'s
* `nadirOf` is attitude of a mesh on that dome and is the other question.
*/
lat: number;
lng: number;
/** /**
* How much of the sun's disc the earth is covering, from the satellite's point * How much of the sun's disc the earth is covering, from the satellite's point
* of view. `0` is full sunlight, `1` is umbra, and the values in between are * of view. `0` is full sunlight, `1` is umbra, and the values in between are
@@ -271,7 +278,8 @@ export class SatelliteCatalogue {
const eci = state?.position; const eci = state?.position;
if (eci === undefined || typeof eci === "boolean") return null; if (eci === undefined || typeof eci === "boolean") return null;
const look = ecfToLookAngles(this.observer, eciToEcf(eci, gmst)); const ecf = eciToEcf(eci, gmst);
const look = ecfToLookAngles(this.observer, ecf);
// Below the horizon is the common case by a wide margin — a few hundred of // Below the horizon is the common case by a wide margin — a few hundred of
// several thousand objects are up at any instant — so this returns before // several thousand objects are up at any instant — so this returns before
// the shadow calculation rather than after it. // the shadow calculation rather than after it.
@@ -282,6 +290,9 @@ export class SatelliteCatalogue {
// partway through a session, long after it was admitted. // partway through a session, long after it was admitted.
if (!(look.elevation >= 0)) return null; if (!(look.elevation >= 0)) return null;
const nadir = geodeticFromEcf(ecf);
if (nadir === null) return null;
return { return {
noradId: meta.noradId, noradId: meta.noradId,
name: meta.name, name: meta.name,
@@ -290,10 +301,71 @@ export class SatelliteCatalogue {
elevation: look.elevation, elevation: look.elevation,
rangeKm: look.rangeSat, rangeKm: look.rangeSat,
shadow: shadowFraction(sunEciAU, eci), shadow: shadowFraction(sunEciAU, eci),
lat: nadir.lat,
lng: nadir.lng,
}; };
} }
} }
/**
* Sub-satellite point from an ECEF position in kilometres.
*
* Spherical geodetic is enough: Starlink is 550 km up, and the ellipsoid vs a
* sphere moves the ground point by a few kilometres — smaller than a board
* cell on the state pack. Follow cameras need "over California", not a survey.
*/
export function geodeticFromEcf(ecf: { x: number; y: number; z: number }): { lat: number; lng: number } | null {
const r = Math.hypot(ecf.x, ecf.y, ecf.z);
if (!(r > 0) || !Number.isFinite(r)) return null;
const lat = (Math.asin(Math.min(1, Math.max(-1, ecf.z / r))) * 180) / Math.PI;
const lng = (Math.atan2(ecf.y, ecf.x) * 180) / Math.PI;
if (!Number.isFinite(lat) || !Number.isFinite(lng)) return null;
return { lat, lng };
}
export function nadirInBounds(
fix: Pick<SatelliteFix, "lat" | "lng">,
bounds: { minLat: number; maxLat: number; minLng: number; maxLng: number },
): boolean {
return (
fix.lat >= bounds.minLat &&
fix.lat <= bounds.maxLat &&
fix.lng >= bounds.minLng &&
fix.lng <= bounds.maxLng
);
}
/**
* The satellite to possess next: nadir inside `bounds`, nearest to `from`
* if given, else the lowest NORAD id. Deterministic — two frames with the
* same fix list agree.
*/
export function nextOverCalifornia(
fixes: readonly SatelliteFix[],
bounds: { minLat: number; maxLat: number; minLng: number; maxLng: number },
from?: { lat: number; lng: number },
): SatelliteFix | null {
const inside = fixes.filter((fix) => nadirInBounds(fix, bounds));
if (inside.length === 0) return null;
if (from === undefined) {
let best = inside[0]!;
for (const fix of inside) {
if (fix.noradId < best.noradId) best = fix;
}
return best;
}
let best = inside[0]!;
let bestD = (best.lat - from.lat) ** 2 + (best.lng - from.lng) ** 2;
for (const fix of inside) {
const d = (fix.lat - from.lat) ** 2 + (fix.lng - from.lng) ** 2;
if (d < bestD - 1e-12 || (Math.abs(d - bestD) <= 1e-12 && fix.noradId < best.noradId)) {
best = fix;
bestD = d;
}
}
return best;
}
// ---- Rendering ------------------------------------------------------------ // ---- Rendering ------------------------------------------------------------
export interface SatelliteLayer { export interface SatelliteLayer {
+2 -1
View File
@@ -4097,7 +4097,7 @@ function availableControlModes() {
insideOffice: inside, insideOffice: inside,
drive: !inside && city?.vehicleState() !== null && drive: !inside && city?.vehicleState() !== null &&
(route === "la-sf-us-101" || route === "la-sf-i-5"), (route === "la-sf-us-101" || route === "la-sf-i-5"),
actor: !inside && city?.actorState() !== null, actor: !inside && city?.actorState() !== null && access.subject !== null,
aircraft: !inside && city?.aircraftState() !== null, aircraft: !inside && city?.aircraftState() !== null,
officeWalk: inside && office?.walker !== null && office?.walker !== undefined, officeWalk: inside && office?.walker !== null && office?.walker !== undefined,
}; };
@@ -4336,6 +4336,7 @@ function playTelemetry(): PlayTelemetry | null {
altitudeM: state.altitudeM, altitudeM: state.altitudeM,
flightMode: state.mode, flightMode: state.mode,
batteryWh: state.batteryWh, batteryWh: state.batteryWh,
batteryCapacityWh: 54_000,
stalled: state.stalled, stalled: state.stalled,
hardLanding: state.hardLanding, hardLanding: state.hardLanding,
envelopeContact: state.envelopeContact, envelopeContact: state.envelopeContact,
+2
View File
@@ -502,6 +502,8 @@ function overheadFix() {
// Full sunlight, which is the brightest a dot ever gets and therefore the // Full sunlight, which is the brightest a dot ever gets and therefore the
// case that clipped. // case that clipped.
shadow: 0, shadow: 0,
lat: 37.77,
lng: -122.42,
}; };
} }
+49 -1
View File
@@ -23,7 +23,13 @@
import assert from "node:assert/strict"; import assert from "node:assert/strict";
import { describe, it } from "node:test"; import { describe, it } from "node:test";
import { dotPixels, SatelliteCatalogue, type SatelliteElements } from "../engine/satellites.ts"; import {
dotPixels,
nextOverCalifornia,
SatelliteCatalogue,
type SatelliteElements,
type SatelliteFix,
} from "../engine/satellites.ts";
/** San Francisco, which is `SAN_FRANCISCO.center` and is the default board. */ /** San Francisco, which is `SAN_FRANCISCO.center` and is the default board. */
const SF = { lat: 37.7749, lng: -122.4194 }; const SF = { lat: 37.7749, lng: -122.4194 };
@@ -264,3 +270,45 @@ describe("how large a satellite is drawn", () => {
} }
}); });
}); });
const CA = { minLat: 32.5, maxLat: 42.05, minLng: -124.5, maxLng: -114 };
function stubFix(over: Partial<SatelliteFix> & Pick<SatelliteFix, "noradId" | "lat" | "lng">): SatelliteFix {
return {
name: `SAT-${over.noradId}`,
group: "starlink",
azimuth: 0,
elevation: 1,
rangeKm: 600,
shadow: 0,
...over,
};
}
describe("nadir possession", () => {
it("reports a sub-satellite point when the ISS is above the horizon", () => {
const seen = (() => {
const catalogue = new SatelliteCatalogue([ISS], SF);
const out: SatelliteFix[] = [];
for (let minute = 0; minute < 24 * 60; minute += 5) {
const when = new Date(NEAR_EPOCH.getTime() + minute * 60_000);
for (const fix of sweep(catalogue, when)) out.push(fix);
}
return out;
})();
assert.ok(seen.length > 0);
for (const fix of seen) {
assert.ok(fix.lat >= -90 && fix.lat <= 90, `lat ${fix.lat}`);
assert.ok(fix.lng >= -180 && fix.lng <= 180, `lng ${fix.lng}`);
}
});
it("hands off to the nearest nadir still over California", () => {
const la = stubFix({ noradId: 20, lat: 34.05, lng: -118.25 });
const sf = stubFix({ noradId: 10, lat: 37.62, lng: -122.38 });
const utah = stubFix({ noradId: 5, lat: 40.76, lng: -111.89 });
assert.equal(nextOverCalifornia([la, sf, utah], CA)?.noradId, 10);
assert.equal(nextOverCalifornia([la, sf, utah], CA, { lat: 33.94, lng: -118.41 })?.noradId, 20);
assert.equal(nextOverCalifornia([utah], CA), null);
});
});
+11 -2
View File
@@ -12,7 +12,7 @@ function options(overrides: Partial<SceneAircraftOptions> = {}): SceneAircraftOp
project: (lat, lng) => [(lng + 121) * 20, -(lat - 36) * 20], project: (lat, lng) => [(lng + 121) * 20, -(lat - 36) * 20],
groundAt: () => 2, groundAt: () => 2,
route: CALIFORNIA_AIR_ROUTE, route: CALIFORNIA_AIR_ROUTE,
initialPosition: { lat: 34.0522, lng: -118.2437 }, initialPosition: { lat: 33.9416, lng: -118.4085 },
initialAltitudeM: 1_000, initialAltitudeM: 1_000,
initialHeadingDeg: 0, initialHeadingDeg: 0,
fixedStepSeconds: 0.1, fixedStepSeconds: 0.1,
@@ -23,12 +23,21 @@ function options(overrides: Partial<SceneAircraftOptions> = {}): SceneAircraftOp
} }
describe("playable scene aircraft", () => { describe("playable scene aircraft", () => {
it("authors KLAX to KSFO, not downtown stand-ins", () => {
assert.equal(CALIFORNIA_AIR_ROUTE[0]?.id, "klax");
assert.equal(CALIFORNIA_AIR_ROUTE.at(-1)?.id, "ksfo");
const start = CALIFORNIA_AIR_ROUTE[0]!;
const end = CALIFORNIA_AIR_ROUTE.at(-1)!;
assert.ok(Math.abs(start.lat - 33.9416) < 0.02 && Math.abs(start.lng + 118.4085) < 0.02);
assert.ok(Math.abs(end.lat - 37.6213) < 0.02 && Math.abs(end.lng + 122.379) < 0.02);
});
it("projects a stable root and stays inert until activated", () => { it("projects a stable root and stays inert until activated", () => {
const aircraft = createSceneAircraft(options()); const aircraft = createSceneAircraft(options());
const root = aircraft.root; const root = aircraft.root;
assert.equal(root.name, "playable-scene-aircraft"); assert.equal(root.name, "playable-scene-aircraft");
assert.ok(root.getObjectByName("electric-aircraft")); assert.ok(root.getObjectByName("electric-aircraft"));
assert.deepEqual(root.position.toArray(), [( -118.2437 + 121) * 20, 7, -(34.0522 - 36) * 20]); assert.deepEqual(root.position.toArray(), [(-118.4085 + 121) * 20, 7, -(33.9416 - 36) * 20]);
assert.equal(root.scale.x, 0.1); assert.equal(root.scale.x, 0.1);
aircraft.setActions({ throttle: 1, roll: 0.8 }); aircraft.setActions({ throttle: 1, roll: 0.8 });
aircraft.tick(0.2); aircraft.tick(0.2);
+6 -2
View File
@@ -91,6 +91,8 @@ export interface AircraftPlayTelemetry {
altitudeM: number; altitudeM: number;
flightMode: string; flightMode: string;
batteryWh: number; batteryWh: number;
/** Pack size, so the HUD can show a fraction rather than a raw watt-hour. */
batteryCapacityWh: number;
stalled: boolean; stalled: boolean;
hardLanding: boolean; hardLanding: boolean;
envelopeContact: boolean; envelopeContact: boolean;
@@ -157,11 +159,13 @@ export function formatPlayHud(telemetry: PlayTelemetry | null): PlayHudView | nu
}; };
} }
if (telemetry.kind === "aircraft") { if (telemetry.kind === "aircraft") {
const pack = Math.max(1, telemetry.batteryCapacityWh);
const pct = Math.round((100 * Math.max(0, telemetry.batteryWh)) / pack);
return { return {
mode: "Flight", mode: "Flight",
primary: `${Math.round(telemetry.speedMps * METRES_PER_SECOND_TO_KNOTS)} kt · ${Math.round(telemetry.altitudeM).toLocaleString()} m`, primary: `${Math.round(telemetry.speedMps * METRES_PER_SECOND_TO_KNOTS)} kt · ${Math.round(telemetry.altitudeM).toLocaleString()} m`,
status: `${telemetry.flightMode} · ${Math.round(telemetry.batteryWh)} Wh${telemetry.stalled ? " · STALL" : ""}`, status: `${telemetry.flightMode} · ${pct}% pack${telemetry.stalled ? " · STALL" : ""}`,
warning: telemetry.stalled || telemetry.hardLanding || telemetry.envelopeContact, warning: telemetry.stalled || telemetry.hardLanding || telemetry.envelopeContact || pct < 15,
}; };
} }
return { return {