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
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:
@@ -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];
|
||||
|
||||
@@ -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<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 ------------------------------------------------------------
|
||||
|
||||
export interface SatelliteLayer {
|
||||
|
||||
+2
-1
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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<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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -12,7 +12,7 @@ function options(overrides: Partial<SceneAircraftOptions> = {}): 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<SceneAircraftOptions> = {}): 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);
|
||||
|
||||
+6
-2
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user