feat: add playable flight and office screen sharing
This commit is contained in:
@@ -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";
|
||||
|
||||
@@ -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);
|
||||
},
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user