diff --git a/src/aircraft/sceneAircraft.ts b/src/aircraft/sceneAircraft.ts index f10a8f4..ad082b5 100644 --- a/src/aircraft/sceneAircraft.ts +++ b/src/aircraft/sceneAircraft.ts @@ -21,10 +21,17 @@ import { type AircraftWaypoint, } 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([ - 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: "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]; diff --git a/src/engine/satellites.ts b/src/engine/satellites.ts index ff8ac9f..9f0186d 100644 --- a/src/engine/satellites.ts +++ b/src/engine/satellites.ts @@ -84,6 +84,13 @@ export interface SatelliteFix { elevation: number; /** Observer to satellite, in kilometres. Straight-line, not ground track. */ 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 * 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; 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 // several thousand objects are up at any instant — so this returns before // the shadow calculation rather than after it. @@ -282,6 +290,9 @@ export class SatelliteCatalogue { // partway through a session, long after it was admitted. if (!(look.elevation >= 0)) return null; + const nadir = geodeticFromEcf(ecf); + if (nadir === null) return null; + return { noradId: meta.noradId, name: meta.name, @@ -290,10 +301,71 @@ export class SatelliteCatalogue { elevation: look.elevation, rangeKm: look.rangeSat, 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, + 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 ------------------------------------------------------------ export interface SatelliteLayer { diff --git a/src/main.ts b/src/main.ts index de5d732..753c4d0 100644 --- a/src/main.ts +++ b/src/main.ts @@ -4097,7 +4097,7 @@ function availableControlModes() { insideOffice: inside, drive: !inside && city?.vehicleState() !== null && (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, officeWalk: inside && office?.walker !== null && office?.walker !== undefined, }; @@ -4336,6 +4336,7 @@ function playTelemetry(): PlayTelemetry | null { altitudeM: state.altitudeM, flightMode: state.mode, batteryWh: state.batteryWh, + batteryCapacityWh: 54_000, stalled: state.stalled, hardLanding: state.hardLanding, envelopeContact: state.envelopeContact, diff --git a/src/test/integration/sceneWiring.test.ts b/src/test/integration/sceneWiring.test.ts index 5407485..2f569f4 100644 --- a/src/test/integration/sceneWiring.test.ts +++ b/src/test/integration/sceneWiring.test.ts @@ -502,6 +502,8 @@ function overheadFix() { // Full sunlight, which is the brightest a dot ever gets and therefore the // case that clipped. shadow: 0, + lat: 37.77, + lng: -122.42, }; } diff --git a/src/test/satellites.test.ts b/src/test/satellites.test.ts index 7108f1f..2cde875 100644 --- a/src/test/satellites.test.ts +++ b/src/test/satellites.test.ts @@ -23,7 +23,13 @@ import assert from "node:assert/strict"; 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. */ 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 & Pick): 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); + }); +}); diff --git a/src/test/sceneAircraft.test.ts b/src/test/sceneAircraft.test.ts index daabbff..33a2d5e 100644 --- a/src/test/sceneAircraft.test.ts +++ b/src/test/sceneAircraft.test.ts @@ -12,7 +12,7 @@ function options(overrides: Partial = {}): SceneAircraftOp project: (lat, lng) => [(lng + 121) * 20, -(lat - 36) * 20], groundAt: () => 2, route: CALIFORNIA_AIR_ROUTE, - initialPosition: { lat: 34.0522, lng: -118.2437 }, + initialPosition: { lat: 33.9416, lng: -118.4085 }, initialAltitudeM: 1_000, initialHeadingDeg: 0, fixedStepSeconds: 0.1, @@ -23,12 +23,21 @@ function options(overrides: Partial = {}): SceneAircraftOp } 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", () => { const aircraft = createSceneAircraft(options()); const root = aircraft.root; assert.equal(root.name, "playable-scene-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); aircraft.setActions({ throttle: 1, roll: 0.8 }); aircraft.tick(0.2); diff --git a/src/ui/hud.ts b/src/ui/hud.ts index cc0f877..f8c2459 100644 --- a/src/ui/hud.ts +++ b/src/ui/hud.ts @@ -91,6 +91,8 @@ export interface AircraftPlayTelemetry { altitudeM: number; flightMode: string; batteryWh: number; + /** Pack size, so the HUD can show a fraction rather than a raw watt-hour. */ + batteryCapacityWh: number; stalled: boolean; hardLanding: boolean; envelopeContact: boolean; @@ -157,11 +159,13 @@ export function formatPlayHud(telemetry: PlayTelemetry | null): PlayHudView | nu }; } if (telemetry.kind === "aircraft") { + const pack = Math.max(1, telemetry.batteryCapacityWh); + const pct = Math.round((100 * Math.max(0, telemetry.batteryWh)) / pack); return { mode: "Flight", 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" : ""}`, - warning: telemetry.stalled || telemetry.hardLanding || telemetry.envelopeContact, + status: `${telemetry.flightMode} · ${pct}% pack${telemetry.stalled ? " · STALL" : ""}`, + warning: telemetry.stalled || telemetry.hardLanding || telemetry.envelopeContact || pct < 15, }; } return {