1
0

feat: add playable flight and office screen sharing

This commit is contained in:
2026-08-11 19:46:47 -07:00
parent c0c7fcf974
commit 16dc85a6f8
13 changed files with 1355 additions and 45 deletions
+9
View File
@@ -31,3 +31,12 @@ export {
type ElectricAircraftMaterials,
type ElectricAircraftRig,
} from "./asset.ts";
export {
CALIFORNIA_AIR_ROUTE,
createSceneAircraft,
type AircraftProjection,
type SceneAircraft,
type SceneAircraftCameraOptions,
type SceneAircraftOptions,
type SceneAircraftView,
} from "./sceneAircraft.ts";
+213
View File
@@ -0,0 +1,213 @@
/** Three.js presentation adapter for the deterministic electric aircraft. */
import * as THREE from "three";
import type { Pose } from "../engine/scenekit.ts";
import {
advanceAircraftFans,
buildElectricAircraft,
disposeElectricAircraft,
setAircraftControlSurfaces,
setAircraftFanRotation,
type ElectricAircraftBuildOptions,
type ElectricAircraftRig,
} from "./asset.ts";
import {
AircraftController,
NEUTRAL_AIRCRAFT_ACTIONS,
normalizeAircraftActions,
type AircraftActionSnapshot,
type AircraftControllerOptions,
type AircraftControllerSnapshot,
type AircraftWaypoint,
} from "./controller.ts";
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: "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 }),
]);
export type AircraftProjection = (lat: number, lng: number) => readonly [x: number, z: number];
export interface SceneAircraftCameraOptions {
distance?: number;
height?: number;
lookAhead?: number;
}
export interface SceneAircraftOptions extends AircraftControllerOptions {
project: AircraftProjection;
groundAt?: (lat: number, lng: number) => number;
/** Aircraft altitude metres to vertical scene units. */
altitudeSceneUnitsPerMetre?: number;
/** Aircraft model metres to scene units; useful as a state-board glyph. */
visualSceneUnitsPerMetre?: number;
camera?: SceneAircraftCameraOptions;
asset?: ElectricAircraftBuildOptions;
/** False by default, so construction never steals input or camera ownership. */
active?: boolean;
}
export interface SceneAircraftView {
/** Stable live scene position suitable for lights, labels, and camera consumers. */
position: THREE.Vector3;
}
export interface SceneAircraft {
root: THREE.Group;
view: SceneAircraftView;
state(): AircraftControllerSnapshot;
actions(): AircraftActionSnapshot;
setActions(actions: Partial<AircraftActionSnapshot>): AircraftActionSnapshot;
tick(elapsedSeconds: number): AircraftControllerSnapshot;
active(): boolean;
setActive(active: boolean): void;
followPose(): Pose;
reset(): AircraftControllerSnapshot;
dispose(): void;
}
function positive(value: number, name: string): number {
if (!(value > 0) || !Number.isFinite(value)) throw new RangeError(`${name} must be finite and positive`);
return value;
}
function nonNegative(value: number, name: string): number {
if (value < 0 || !Number.isFinite(value)) throw new RangeError(`${name} must be finite and non-negative`);
return value;
}
function checkedPoint(value: readonly [number, number]): readonly [number, number] {
if (value.length < 2 || !Number.isFinite(value[0]) || !Number.isFinite(value[1])) {
throw new RangeError("aircraft projection must return finite x/z coordinates");
}
return value;
}
export function createSceneAircraft(options: SceneAircraftOptions): SceneAircraft {
if (typeof options.project !== "function") throw new RangeError("aircraft project callback is required");
if (options.groundAt !== undefined && typeof options.groundAt !== "function") {
throw new RangeError("aircraft groundAt must be a function");
}
const altitudeScale = positive(
options.altitudeSceneUnitsPerMetre ?? 0.004,
"altitudeSceneUnitsPerMetre",
);
const visualScale = positive(options.visualSceneUnitsPerMetre ?? 0.12, "visualSceneUnitsPerMetre");
const camera = {
distance: options.camera?.distance ?? 20,
height: options.camera?.height ?? 7,
lookAhead: options.camera?.lookAhead ?? 32,
};
positive(camera.distance, "camera.distance");
nonNegative(camera.height, "camera.height");
nonNegative(camera.lookAhead, "camera.lookAhead");
const controller = new AircraftController(options);
const rig: ElectricAircraftRig = buildElectricAircraft(options.asset);
const root = new THREE.Group();
root.name = "playable-scene-aircraft";
root.userData.kind = "playable-aircraft";
root.userData.forwardAxis = "-Z";
root.scale.setScalar(visualScale);
root.add(rig.root);
const view = { position: new THREE.Vector3() };
let desired: AircraftActionSnapshot = { ...NEUTRAL_AIRCRAFT_ACTIONS };
let enabled = options.active ?? false;
let disposed = false;
function groundAt(lat: number, lng: number): number {
const value = options.groundAt?.(lat, lng) ?? 0;
if (!Number.isFinite(value)) throw new RangeError("aircraft groundAt must return a finite height");
return value;
}
function sync(previousFanRadians?: number): void {
const state = controller.state();
const [x, z] = checkedPoint(options.project(state.lat, state.lng));
root.position.set(x, groundAt(state.lat, state.lng) + state.altitudeM * altitudeScale, z);
root.rotation.order = "YXZ";
root.rotation.set(
state.pitchDeg * Math.PI / 180,
-state.headingDeg * Math.PI / 180,
-state.rollDeg * Math.PI / 180,
);
view.position.copy(root.position);
setAircraftControlSurfaces(rig, {
roll: state.rollInput,
pitch: state.pitchInput,
yaw: state.yawInput,
});
if (previousFanRadians === undefined) setAircraftFanRotation(rig, state.fanRadians);
else {
let delta = state.fanRadians - previousFanRadians;
if (delta < -Math.PI) delta += Math.PI * 2;
if (delta > Math.PI) delta -= Math.PI * 2;
advanceAircraftFans(rig, delta);
}
}
function clearEdges(): void {
desired.modeRequest = "none";
desired.reset = false;
}
sync();
return {
root,
view,
state: () => controller.snapshot(),
actions: () => ({ ...desired }),
setActions(actions) {
desired = normalizeAircraftActions(actions);
return { ...desired };
},
tick(elapsedSeconds) {
if (!disposed && enabled) {
const previousFanRadians = controller.state().fanRadians;
controller.tick(elapsedSeconds, desired);
sync(previousFanRadians);
clearEdges();
}
return controller.snapshot();
},
active: () => enabled,
setActive(active) {
if (active !== enabled) desired = { ...NEUTRAL_AIRCRAFT_ACTIONS };
enabled = active;
},
followPose() {
const state = controller.state();
const heading = state.headingDeg * Math.PI / 180;
const pitch = state.pitchDeg * Math.PI / 180;
// Compass heading zero follows projected north at -Z, matching Tera's map convention.
const forward = new THREE.Vector3(
Math.sin(heading) * Math.cos(pitch),
Math.sin(pitch),
-Math.cos(heading) * Math.cos(pitch),
);
return {
position: root.position.clone()
.addScaledVector(forward, -camera.distance * visualScale)
.add(new THREE.Vector3(0, camera.height * visualScale, 0)),
target: root.position.clone().addScaledVector(forward, camera.lookAhead * visualScale),
};
},
reset() {
if (!disposed) {
controller.reset();
desired = { ...NEUTRAL_AIRCRAFT_ACTIONS };
sync();
}
return controller.snapshot();
},
dispose() {
if (disposed) return;
disposed = true;
root.removeFromParent();
root.remove(rig.root);
disposeElectricAircraft(rig);
},
};
}