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,
|
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];
|
||||||
|
|||||||
@@ -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
@@ -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,
|
||||||
|
|||||||
@@ -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,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -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
@@ -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 {
|
||||||
|
|||||||
Reference in New Issue
Block a user