1
0

feat: add character studio and aircraft foundation

This commit is contained in:
2026-08-11 19:32:32 -07:00
parent 1c37ef8f3e
commit c0c7fcf974
16 changed files with 2250 additions and 25 deletions
+26
View File
@@ -446,6 +446,30 @@
} }
.tier.god .who { color: var(--amber-ink); } .tier.god .who { color: var(--amber-ink); }
.tier a { color: var(--amber-ink); text-transform: none; letter-spacing: 0.04em; } .tier a { color: var(--amber-ink); text-transform: none; letter-spacing: 0.04em; }
.profile-trigger {
min-height: 28px;
padding: 4px 8px;
color: var(--amber-ink);
background: rgba(242, 177, 52, 0.1);
border: 1px solid rgba(242, 177, 52, 0.3);
border-radius: var(--r-sm);
font: inherit;
letter-spacing: 0.06em;
text-transform: uppercase;
cursor: pointer;
}
.profile-trigger:hover { background: rgba(242, 177, 52, 0.2); }
.profile-overlay {
position: fixed;
inset: 0;
z-index: 20;
display: grid;
place-items: center;
padding: var(--s4);
background: rgba(5, 8, 11, 0.68);
backdrop-filter: blur(6px);
-webkit-backdrop-filter: blur(6px);
}
/* ---- Bottom right: what you picked, and how to drive ------------------ */ /* ---- Bottom right: what you picked, and how to drive ------------------ */
.rail { .rail {
@@ -978,6 +1002,8 @@
<p id="source" class="source"></p> <p id="source" class="source"></p>
<div id="profile-overlay" class="profile-overlay" hidden></div>
<!-- Behind the panel sheet on a phone, and nowhere else. A sheet with no <!-- Behind the panel sheet on a phone, and nowhere else. A sheet with no
scrim leaves no way to dismiss it but the same button that opened it, scrim leaves no way to dismiss it but the same button that opened it,
which is the wrong half of the screen for a thumb. --> which is the wrong half of the screen for a thumb. -->
+214
View File
@@ -0,0 +1,214 @@
/** Original procedural electric V-tail aircraft, authored in metres and facing -Z. */
import * as THREE from "three";
export const ELECTRIC_AIRCRAFT_METRICS = Object.freeze({
length: 8.4,
wingspan: 11.8,
height: 2.45,
});
export interface ElectricAircraftMaterials {
body: THREE.Material;
accent: THREE.Material;
glass: THREE.Material;
dark: THREE.Material;
}
export interface ElectricAircraftBuildOptions {
materials?: ElectricAircraftMaterials;
bodyColor?: THREE.ColorRepresentation;
}
export interface ElectricAircraftRig {
root: THREE.Group;
leftAileron: THREE.Group;
rightAileron: THREE.Group;
leftVTail: THREE.Group;
rightVTail: THREE.Group;
fans: readonly THREE.Group[];
ownsMaterials: boolean;
}
export interface AircraftSurfacePose {
roll: number;
pitch: number;
yaw: number;
}
export function createElectricAircraftMaterials(
bodyColor: THREE.ColorRepresentation = 0xe9edf0,
): ElectricAircraftMaterials {
return {
body: new THREE.MeshPhysicalMaterial({
name: "electric-aircraft.body",
color: bodyColor,
metalness: 0.35,
roughness: 0.3,
clearcoat: 0.8,
}),
accent: new THREE.MeshStandardMaterial({
name: "electric-aircraft.accent",
color: 0x178f83,
metalness: 0.45,
roughness: 0.32,
}),
glass: new THREE.MeshPhysicalMaterial({
name: "electric-aircraft.glass",
color: 0x19323c,
roughness: 0.12,
transparent: true,
opacity: 0.82,
}),
dark: new THREE.MeshStandardMaterial({
name: "electric-aircraft.dark",
color: 0x1a2022,
metalness: 0.7,
roughness: 0.34,
}),
};
}
function mesh(
geometry: THREE.BufferGeometry,
material: THREE.Material,
name: string,
): THREE.Mesh {
const value = new THREE.Mesh(geometry, material);
value.name = name;
value.castShadow = true;
value.receiveShadow = true;
return value;
}
function wingGeometry(span: number, rootChord: number, tipChord: number): THREE.BufferGeometry {
const half = span / 2;
const vertices = new Float32Array([
0, 0, -rootChord / 2, half, 0, -tipChord / 2, half, 0, tipChord / 2,
0, 0, rootChord / 2, -half, 0, tipChord / 2, -half, 0, -tipChord / 2,
]);
const geometry = new THREE.BufferGeometry();
geometry.setAttribute("position", new THREE.BufferAttribute(vertices, 3));
geometry.setIndex([0, 1, 2, 0, 2, 3, 0, 3, 4, 0, 4, 5]);
geometry.computeVertexNormals();
return geometry;
}
function addFan(root: THREE.Group, x: number, materials: ElectricAircraftMaterials): THREE.Group {
const fan = new THREE.Group();
fan.name = x < 0 ? "electric-aircraft.fan-left" : "electric-aircraft.fan-right";
fan.position.set(x, 0.02, -0.48);
const nacelle = mesh(new THREE.CapsuleGeometry(0.25, 0.72, 5, 10), materials.accent, `${fan.name}:nacelle`);
nacelle.rotation.x = Math.PI / 2;
nacelle.position.set(x, 0.02, -0.12);
root.add(nacelle);
const hub = mesh(new THREE.CylinderGeometry(0.11, 0.11, 0.14, 12), materials.dark, `${fan.name}:hub`);
hub.rotation.x = Math.PI / 2;
fan.add(hub);
for (let index = 0; index < 5; index += 1) {
const blade = mesh(new THREE.BoxGeometry(0.07, 0.68, 0.025), materials.dark, `${fan.name}:blade-${index}`);
blade.position.y = 0.3;
blade.rotation.z = index * TWO_PI / 5;
fan.add(blade);
}
root.add(fan);
return fan;
}
const TWO_PI = Math.PI * 2;
export function buildElectricAircraft(options: ElectricAircraftBuildOptions = {}): ElectricAircraftRig {
const ownsMaterials = options.materials === undefined;
const materials = options.materials ?? createElectricAircraftMaterials(options.bodyColor);
const root = new THREE.Group();
root.name = "electric-aircraft";
const fuselage = mesh(new THREE.CapsuleGeometry(0.66, 5.9, 8, 16), materials.body, "electric-aircraft:fuselage");
fuselage.rotation.x = Math.PI / 2;
fuselage.position.y = 0.42;
root.add(fuselage);
const nose = mesh(new THREE.ConeGeometry(0.64, 1.7, 18), materials.accent, "electric-aircraft:nose");
nose.rotation.x = -Math.PI / 2;
nose.position.set(0, 0.42, -3.8);
root.add(nose);
const canopy = mesh(new THREE.SphereGeometry(0.68, 16, 8), materials.glass, "electric-aircraft:canopy");
canopy.scale.set(0.8, 0.52, 1.55);
canopy.position.set(0, 1.02, -0.9);
root.add(canopy);
const wing = mesh(wingGeometry(11.8, 2.25, 0.72), materials.body, "electric-aircraft:wing");
wing.position.set(0, 0.46, 0.05);
root.add(wing);
const leftAileron = new THREE.Group();
const rightAileron = new THREE.Group();
leftAileron.name = "electric-aircraft.aileron-left";
rightAileron.name = "electric-aircraft.aileron-right";
for (const [joint, x] of [[leftAileron, -4.2], [rightAileron, 4.2]] as const) {
joint.position.set(x, 0.48, 0.72);
joint.add(mesh(new THREE.BoxGeometry(2.15, 0.08, 0.45), materials.accent, `${joint.name}:surface`));
root.add(joint);
}
const leftVTail = new THREE.Group();
const rightVTail = new THREE.Group();
leftVTail.name = "electric-aircraft.v-tail-left";
rightVTail.name = "electric-aircraft.v-tail-right";
for (const [joint, x, tilt] of [
[leftVTail, -0.32, -0.72],
[rightVTail, 0.32, 0.72],
] as const) {
joint.position.set(x, 0.63, 3.2);
joint.rotation.z = tilt;
const surface = mesh(new THREE.BoxGeometry(0.1, 1.85, 1.15), materials.body, `${joint.name}:surface`);
surface.position.y = 0.72;
joint.add(surface);
root.add(joint);
}
const fans = [addFan(root, -2.1, materials), addFan(root, 2.1, materials)];
root.traverse((item) => {
if (item instanceof THREE.Mesh) {
item.geometry.computeBoundingBox();
item.geometry.computeBoundingSphere();
}
});
return { root, leftAileron, rightAileron, leftVTail, rightVTail, fans, ownsMaterials };
}
export function setAircraftControlSurfaces(
rig: ElectricAircraftRig,
pose: Partial<AircraftSurfacePose>,
): void {
const roll = THREE.MathUtils.clamp(Number.isFinite(pose.roll) ? pose.roll ?? 0 : 0, -1, 1);
const pitch = THREE.MathUtils.clamp(Number.isFinite(pose.pitch) ? pose.pitch ?? 0 : 0, -1, 1);
const yaw = THREE.MathUtils.clamp(Number.isFinite(pose.yaw) ? pose.yaw ?? 0 : 0, -1, 1);
rig.leftAileron.rotation.x = roll * 0.45;
rig.rightAileron.rotation.x = -roll * 0.45;
rig.leftVTail.rotation.x = pitch * 0.34 + yaw * 0.22;
rig.rightVTail.rotation.x = pitch * 0.34 - yaw * 0.22;
}
export function setAircraftFanRotation(rig: ElectricAircraftRig, radians: number): void {
const safe = Number.isFinite(radians) ? radians : 0;
for (const fan of rig.fans) fan.rotation.z = safe;
}
export function advanceAircraftFans(rig: ElectricAircraftRig, radians: number): void {
const delta = Number.isFinite(radians) ? radians : 0;
for (const fan of rig.fans) fan.rotation.z = (fan.rotation.z + delta) % TWO_PI;
}
export function disposeElectricAircraft(rig: ElectricAircraftRig): void {
const geometries = new Set<THREE.BufferGeometry>();
const materials = new Set<THREE.Material>();
rig.root.traverse((item) => {
if (!(item instanceof THREE.Mesh)) return;
geometries.add(item.geometry);
const values = Array.isArray(item.material) ? item.material : [item.material];
for (const material of values) materials.add(material);
});
for (const geometry of geometries) geometry.dispose();
if (rig.ownsMaterials) for (const material of materials) material.dispose();
rig.root.removeFromParent();
rig.root.clear();
}
+433
View File
@@ -0,0 +1,433 @@
/** Deterministic, renderer-independent fixed-wing flight over California. */
const EARTH_RADIUS_M = 6_371_000;
const TWO_PI = Math.PI * 2;
export type AircraftControlMode = "assisted" | "manual";
export type AircraftModeRequest = "none" | AircraftControlMode;
export interface AircraftGeographicPoint {
lat: number;
lng: number;
}
export interface AircraftWaypoint extends AircraftGeographicPoint {
id: string;
altitudeM: number;
}
export interface AircraftActionSnapshot {
throttle: number;
yaw: number;
pitch: number;
roll: number;
modeRequest: AircraftModeRequest;
reset: boolean;
}
export const NEUTRAL_AIRCRAFT_ACTIONS: Readonly<AircraftActionSnapshot> = Object.freeze({
throttle: 0,
yaw: 0,
pitch: 0,
roll: 0,
modeRequest: "none",
reset: false,
});
export interface CaliforniaFlightEnvelope {
minLat: number;
maxLat: number;
minLng: number;
maxLng: number;
minAltitudeM: number;
maxAltitudeM: number;
}
export const DEFAULT_CALIFORNIA_FLIGHT_ENVELOPE: Readonly<CaliforniaFlightEnvelope> =
Object.freeze({
minLat: 32.4,
maxLat: 42.1,
minLng: -124.6,
maxLng: -114.0,
minAltitudeM: 75,
maxAltitudeM: 6_000,
});
export interface AircraftControllerOptions {
initialPosition?: Partial<AircraftGeographicPoint>;
initialAltitudeM?: number;
initialHeadingDeg?: number;
initialSpeedMps?: number;
mode?: AircraftControlMode;
route?: readonly AircraftWaypoint[];
envelope?: CaliforniaFlightEnvelope;
minimumSpeedMps?: number;
maximumSpeedMps?: number;
assistedCruiseMps?: number;
assistedAltitudeM?: number;
fixedStepSeconds?: number;
maxFrameDeltaSeconds?: number;
}
export interface AircraftControllerState extends AircraftGeographicPoint {
altitudeM: number;
headingDeg: number;
pitchDeg: number;
rollDeg: number;
speedMps: number;
verticalSpeedMps: number;
mode: AircraftControlMode;
routeWaypointIndex: number;
routeWaypointId: string | null;
throttle: number;
yawInput: number;
pitchInput: number;
rollInput: number;
fanRadians: number;
envelopeContact: boolean;
elapsedSteps: number;
}
export interface AircraftControllerSnapshot extends AircraftControllerState {}
export interface TimedAircraftInputFrame {
steps: number;
actions?: Partial<AircraftActionSnapshot>;
}
export interface AircraftReplayResult {
trajectory: readonly AircraftControllerSnapshot[];
final: AircraftControllerSnapshot;
}
export interface AircraftCameraPose {
position: AircraftGeographicPoint & { altitudeM: number };
target: AircraftGeographicPoint & { altitudeM: number };
rollDeg: number;
}
interface ResolvedOptions {
initialPosition: AircraftGeographicPoint;
initialAltitudeM: number;
initialHeadingDeg: number;
initialSpeedMps: number;
mode: AircraftControlMode;
route: readonly AircraftWaypoint[];
envelope: CaliforniaFlightEnvelope;
minimumSpeedMps: number;
maximumSpeedMps: number;
assistedCruiseMps: number;
assistedAltitudeM: number;
fixedStepSeconds: number;
maxFrameDeltaSeconds: number;
}
function clamp(value: number, min: number, max: number): number {
return Math.max(min, Math.min(max, value));
}
function finiteOr(value: number | undefined, fallback: number): number {
return typeof value === "number" && Number.isFinite(value) ? value : fallback;
}
function moveToward(value: number, target: number, maximumDelta: number): number {
if (value < target) return Math.min(value + maximumDelta, target);
if (value > target) return Math.max(value - maximumDelta, target);
return value;
}
function wrapDegrees(value: number): number {
return ((value % 360) + 360) % 360;
}
function signedAngleDegrees(from: number, to: number): number {
return ((to - from + 540) % 360) - 180;
}
function distanceM(a: AircraftGeographicPoint, b: AircraftGeographicPoint): number {
const mean = ((a.lat + b.lat) / 2) * Math.PI / 180;
const north = (b.lat - a.lat) * Math.PI / 180 * EARTH_RADIUS_M;
const east = (b.lng - a.lng) * Math.PI / 180 * Math.cos(mean) * EARTH_RADIUS_M;
return Math.hypot(north, east);
}
function bearingDeg(a: AircraftGeographicPoint, b: AircraftGeographicPoint): number {
const mean = ((a.lat + b.lat) / 2) * Math.PI / 180;
return wrapDegrees(Math.atan2((b.lng - a.lng) * Math.cos(mean), b.lat - a.lat) * 180 / Math.PI);
}
function checkedEnvelope(value: CaliforniaFlightEnvelope | undefined): CaliforniaFlightEnvelope {
const envelope = { ...(value ?? DEFAULT_CALIFORNIA_FLIGHT_ENVELOPE) };
if (
!Number.isFinite(envelope.minLat) || !Number.isFinite(envelope.maxLat) ||
!Number.isFinite(envelope.minLng) || !Number.isFinite(envelope.maxLng) ||
!Number.isFinite(envelope.minAltitudeM) || !Number.isFinite(envelope.maxAltitudeM) ||
envelope.minLat >= envelope.maxLat || envelope.minLng >= envelope.maxLng ||
envelope.minAltitudeM >= envelope.maxAltitudeM
) throw new RangeError("aircraft flight envelope must be finite and ordered");
return envelope;
}
function checkedRoute(route: readonly AircraftWaypoint[] | undefined): readonly AircraftWaypoint[] {
if (!route) return [];
const ids = new Set<string>();
return route.map((point) => {
if (
typeof point.id !== "string" || point.id.length === 0 || ids.has(point.id) ||
!Number.isFinite(point.lat) || !Number.isFinite(point.lng) ||
!Number.isFinite(point.altitudeM)
) throw new RangeError("aircraft route waypoints must be finite with unique ids");
ids.add(point.id);
return { ...point };
});
}
function resolveOptions(value: AircraftControllerOptions): ResolvedOptions {
const envelope = checkedEnvelope(value.envelope);
const minimumSpeedMps = clamp(finiteOr(value.minimumSpeedMps, 20), 5, 100);
const maximumSpeedMps = clamp(finiteOr(value.maximumSpeedMps, 95), minimumSpeedMps, 250);
const assistedAltitudeM = clamp(
finiteOr(value.assistedAltitudeM, 1_500),
envelope.minAltitudeM,
envelope.maxAltitudeM,
);
return {
initialPosition: {
lat: clamp(finiteOr(value.initialPosition?.lat, 34.0522), envelope.minLat, envelope.maxLat),
lng: clamp(finiteOr(value.initialPosition?.lng, -118.2437), envelope.minLng, envelope.maxLng),
},
initialAltitudeM: clamp(finiteOr(value.initialAltitudeM, assistedAltitudeM), envelope.minAltitudeM, envelope.maxAltitudeM),
initialHeadingDeg: wrapDegrees(finiteOr(value.initialHeadingDeg, 320)),
initialSpeedMps: clamp(finiteOr(value.initialSpeedMps, 55), minimumSpeedMps, maximumSpeedMps),
mode: value.mode === "manual" ? "manual" : "assisted",
route: checkedRoute(value.route),
envelope,
minimumSpeedMps,
maximumSpeedMps,
assistedCruiseMps: clamp(finiteOr(value.assistedCruiseMps, 62), minimumSpeedMps, maximumSpeedMps),
assistedAltitudeM,
fixedStepSeconds: clamp(finiteOr(value.fixedStepSeconds, 1 / 60), 1 / 240, 0.1),
maxFrameDeltaSeconds: clamp(finiteOr(value.maxFrameDeltaSeconds, 0.25), 0.05, 1),
};
}
export function normalizeAircraftActions(
value: Partial<AircraftActionSnapshot> | undefined,
): AircraftActionSnapshot {
return {
throttle: clamp(finiteOr(value?.throttle, 0), 0, 1),
yaw: clamp(finiteOr(value?.yaw, 0), -1, 1),
pitch: clamp(finiteOr(value?.pitch, 0), -1, 1),
roll: clamp(finiteOr(value?.roll, 0), -1, 1),
modeRequest: value?.modeRequest === "manual" || value?.modeRequest === "assisted"
? value.modeRequest
: "none",
reset: value?.reset === true,
};
}
function hasManualIntent(actions: AircraftActionSnapshot): boolean {
return (
actions.modeRequest === "manual" || actions.throttle > 0.02 ||
Math.abs(actions.yaw) > 0.06 || Math.abs(actions.pitch) > 0.06 || Math.abs(actions.roll) > 0.06
);
}
export class AircraftController {
private readonly options: ResolvedOptions;
private accumulator = 0;
private readonly current: AircraftControllerState;
constructor(options: AircraftControllerOptions = {}) {
this.options = resolveOptions(options);
this.current = {
...this.options.initialPosition,
altitudeM: this.options.initialAltitudeM,
headingDeg: this.options.initialHeadingDeg,
pitchDeg: 0,
rollDeg: 0,
speedMps: this.options.initialSpeedMps,
verticalSpeedMps: 0,
mode: this.options.mode,
routeWaypointIndex: 0,
routeWaypointId: null,
throttle: 0,
yawInput: 0,
pitchInput: 0,
rollInput: 0,
fanRadians: 0,
envelopeContact: false,
elapsedSteps: 0,
};
this.reset();
}
fixedStepSeconds(): number {
return this.options.fixedStepSeconds;
}
state(): Readonly<AircraftControllerState> {
return this.current;
}
snapshot(): AircraftControllerSnapshot {
return { ...this.current };
}
reset(): void {
this.accumulator = 0;
Object.assign(this.current, this.options.initialPosition, {
altitudeM: this.options.initialAltitudeM,
headingDeg: this.options.initialHeadingDeg,
pitchDeg: 0,
rollDeg: 0,
speedMps: this.options.initialSpeedMps,
verticalSpeedMps: 0,
mode: this.options.mode,
routeWaypointIndex: 0,
routeWaypointId: this.options.route[0]?.id ?? null,
throttle: 0,
yawInput: 0,
pitchInput: 0,
rollInput: 0,
fanRadians: 0,
envelopeContact: false,
elapsedSteps: 0,
});
}
tick(deltaSeconds: number, actions: Partial<AircraftActionSnapshot> = NEUTRAL_AIRCRAFT_ACTIONS): number {
if (!Number.isFinite(deltaSeconds) || deltaSeconds <= 0) return 0;
const normalized = normalizeAircraftActions(actions);
if (normalized.reset) {
this.reset();
return 0;
}
this.accumulator += Math.min(deltaSeconds, this.options.maxFrameDeltaSeconds);
let steps = 0;
while (this.accumulator + Number.EPSILON >= this.options.fixedStepSeconds) {
this.stepNormalized(normalized);
this.accumulator -= this.options.fixedStepSeconds;
steps += 1;
}
return steps;
}
stepFixed(actions: Partial<AircraftActionSnapshot> = NEUTRAL_AIRCRAFT_ACTIONS): void {
const normalized = normalizeAircraftActions(actions);
if (normalized.reset) this.reset();
else this.stepNormalized(normalized);
}
private stepNormalized(actions: AircraftActionSnapshot): void {
const dt = this.options.fixedStepSeconds;
const manual = hasManualIntent(actions);
if (manual) this.current.mode = "manual";
else if (actions.modeRequest === "assisted") this.current.mode = "assisted";
let throttle = actions.throttle;
let yaw = actions.yaw;
let pitch = actions.pitch;
let roll = actions.roll;
if (this.current.mode === "assisted") {
throttle = clamp(0.5 + (this.options.assistedCruiseMps - this.current.speedMps) / 18, 0, 1);
const target = this.options.route[this.current.routeWaypointIndex];
if (target && distanceM(this.current, target) < 3_000 && this.options.route.length > 1) {
this.current.routeWaypointIndex = (this.current.routeWaypointIndex + 1) % this.options.route.length;
}
const waypoint = this.options.route[this.current.routeWaypointIndex];
this.current.routeWaypointId = waypoint?.id ?? null;
const desiredHeading = waypoint ? bearingDeg(this.current, waypoint) : this.options.initialHeadingDeg;
const headingError = signedAngleDegrees(this.current.headingDeg, desiredHeading);
roll = clamp(headingError / 38, -1, 1);
yaw = clamp(headingError / 90, -0.45, 0.45);
const altitudeTarget = waypoint?.altitudeM ?? this.options.assistedAltitudeM;
pitch = clamp((altitudeTarget - this.current.altitudeM) / 350, -0.65, 0.65);
}
this.current.throttle = moveToward(this.current.throttle, throttle, 0.8 * dt);
this.current.yawInput = moveToward(this.current.yawInput, yaw, 2.5 * dt);
this.current.pitchInput = moveToward(this.current.pitchInput, pitch, 2.2 * dt);
this.current.rollInput = moveToward(this.current.rollInput, roll, 2.8 * dt);
const targetRoll = this.current.rollInput * 58;
const targetPitch = this.current.pitchInput * 22;
this.current.rollDeg = moveToward(this.current.rollDeg, targetRoll, 55 * dt);
this.current.pitchDeg = moveToward(this.current.pitchDeg, targetPitch, 28 * dt);
const thrust = this.current.throttle * 8.5;
const drag = 1.2 + this.current.speedMps * this.current.speedMps * 0.00075;
this.current.speedMps = clamp(
this.current.speedMps + (thrust - drag) * dt,
this.options.minimumSpeedMps,
this.options.maximumSpeedMps,
);
const bankTurn = Math.sin(this.current.rollDeg * Math.PI / 180) * 24;
this.current.headingDeg = wrapDegrees(
this.current.headingDeg + (bankTurn + this.current.yawInput * 20) * dt,
);
this.current.verticalSpeedMps = Math.sin(this.current.pitchDeg * Math.PI / 180) * this.current.speedMps;
const heading = this.current.headingDeg * Math.PI / 180;
const horizontalSpeed = Math.cos(this.current.pitchDeg * Math.PI / 180) * this.current.speedMps;
const northM = Math.cos(heading) * horizontalSpeed * dt;
const eastM = Math.sin(heading) * horizontalSpeed * dt;
const nextLat = this.current.lat + northM / EARTH_RADIUS_M * 180 / Math.PI;
const nextLng = this.current.lng + eastM /
(EARTH_RADIUS_M * Math.max(0.01, Math.cos(this.current.lat * Math.PI / 180))) * 180 / Math.PI;
const nextAltitude = this.current.altitudeM + this.current.verticalSpeedMps * dt;
const envelope = this.options.envelope;
this.current.envelopeContact =
nextLat < envelope.minLat || nextLat > envelope.maxLat ||
nextLng < envelope.minLng || nextLng > envelope.maxLng ||
nextAltitude < envelope.minAltitudeM || nextAltitude > envelope.maxAltitudeM;
this.current.lat = clamp(nextLat, envelope.minLat, envelope.maxLat);
this.current.lng = clamp(nextLng, envelope.minLng, envelope.maxLng);
this.current.altitudeM = clamp(nextAltitude, envelope.minAltitudeM, envelope.maxAltitudeM);
if (this.current.envelopeContact) {
this.current.speedMps = Math.max(this.options.minimumSpeedMps, this.current.speedMps * 0.92);
this.current.pitchDeg = moveToward(this.current.pitchDeg, 0, 90 * dt);
this.current.rollDeg = moveToward(this.current.rollDeg, 0, 90 * dt);
}
this.current.fanRadians = (this.current.fanRadians + (30 + this.current.throttle * 180) * dt) % TWO_PI;
this.current.elapsedSteps += 1;
}
}
export function replayAircraftInputs(
options: AircraftControllerOptions,
frames: readonly TimedAircraftInputFrame[],
): AircraftReplayResult {
const controller = new AircraftController(options);
const trajectory: AircraftControllerSnapshot[] = [controller.snapshot()];
for (const frame of frames) {
const steps = Number.isFinite(frame.steps) ? Math.max(0, Math.floor(frame.steps)) : 0;
const held = normalizeAircraftActions(frame.actions);
for (let index = 0; index < steps; index += 1) {
controller.stepFixed(index === 0 ? held : { ...held, modeRequest: "none", reset: false });
trajectory.push(controller.snapshot());
}
}
return { trajectory, final: trajectory.at(-1) ?? controller.snapshot() };
}
/** Renderer-neutral geographic chase camera derived from an authoritative pose. */
export function aircraftChaseCameraPose(
state: Readonly<AircraftControllerState>,
distanceBehindM = 24,
heightAboveM = 8,
lookAheadM = 35,
): AircraftCameraPose {
const heading = state.headingDeg * Math.PI / 180;
const geographicOffset = (northM: number, eastM: number): AircraftGeographicPoint => ({
lat: state.lat + northM / EARTH_RADIUS_M * 180 / Math.PI,
lng: state.lng + eastM /
(EARTH_RADIUS_M * Math.max(0.01, Math.cos(state.lat * Math.PI / 180))) * 180 / Math.PI,
});
const behind = geographicOffset(-Math.cos(heading) * distanceBehindM, -Math.sin(heading) * distanceBehindM);
const ahead = geographicOffset(Math.cos(heading) * lookAheadM, Math.sin(heading) * lookAheadM);
return {
position: { ...behind, altitudeM: state.altitudeM + heightAboveM },
target: { ...ahead, altitudeM: state.altitudeM + state.verticalSpeedMps * 0.4 },
rollDeg: state.rollDeg * 0.2,
};
}
+33
View File
@@ -0,0 +1,33 @@
export {
AircraftController,
DEFAULT_CALIFORNIA_FLIGHT_ENVELOPE,
NEUTRAL_AIRCRAFT_ACTIONS,
aircraftChaseCameraPose,
normalizeAircraftActions,
replayAircraftInputs,
type AircraftActionSnapshot,
type AircraftCameraPose,
type AircraftControlMode,
type AircraftControllerOptions,
type AircraftControllerSnapshot,
type AircraftControllerState,
type AircraftGeographicPoint,
type AircraftModeRequest,
type AircraftReplayResult,
type AircraftWaypoint,
type CaliforniaFlightEnvelope,
type TimedAircraftInputFrame,
} from "./controller.ts";
export {
ELECTRIC_AIRCRAFT_METRICS,
advanceAircraftFans,
buildElectricAircraft,
createElectricAircraftMaterials,
disposeElectricAircraft,
setAircraftControlSurfaces,
setAircraftFanRotation,
type AircraftSurfacePose,
type ElectricAircraftBuildOptions,
type ElectricAircraftMaterials,
type ElectricAircraftRig,
} from "./asset.ts";
+8 -1
View File
@@ -65,7 +65,11 @@ import type {
} from "./types.ts"; } from "./types.ts";
import { World, type FieldProgress } from "./world.ts"; import { World, type FieldProgress } from "./world.ts";
import { createSceneActor, type SceneActorOptions } from "../actors/sceneActor.ts"; import { createSceneActor, type SceneActorOptions } from "../actors/sceneActor.ts";
import type { ActorActionSnapshot, ActorControllerSnapshot } from "../actors/controller.ts"; import type {
ActorActionSnapshot,
ActorControllerSnapshot,
ActorIdentity,
} from "../actors/controller.ts";
export interface SceneOptions { export interface SceneOptions {
city: City; city: City;
@@ -183,6 +187,8 @@ export interface SceneHandle {
vehicleCamera(): VehicleCameraMode | null; vehicleCamera(): VehicleCameraMode | null;
setActorActions(actions: Partial<ActorActionSnapshot>): void; setActorActions(actions: Partial<ActorActionSnapshot>): void;
actorState(): Readonly<ActorControllerSnapshot> | null; actorState(): Readonly<ActorControllerSnapshot> | null;
/** Replace the visible local actor profile without disturbing its position or controls. */
setActorIdentity(identity: ActorIdentity): void;
setActorActive(active: boolean): void; setActorActive(active: boolean): void;
actorActive(): boolean; actorActive(): boolean;
setMarkers(markers: Marker[]): void; setMarkers(markers: Marker[]): void;
@@ -580,6 +586,7 @@ export async function createScene(
vehicleCamera: () => roadTraffic?.cameraMode() ?? null, vehicleCamera: () => roadTraffic?.cameraMode() ?? null,
setActorActions: (actions) => { sceneActor?.setActions(actions); }, setActorActions: (actions) => { sceneActor?.setActions(actions); },
actorState: () => sceneActor?.state() ?? null, actorState: () => sceneActor?.state() ?? null,
setActorIdentity: (identity) => { sceneActor?.setIdentity(identity); },
setActorActive(active) { setActorActive(active) {
sceneActor?.setActive(active); sceneActor?.setActive(active);
if (active) roadTraffic?.setFollowing(false); if (active) roadTraffic?.setFollowing(false);
+17
View File
@@ -87,6 +87,12 @@ import {
type OfficeWalker, type OfficeWalker,
type OfficeWalkerOptions, type OfficeWalkerOptions,
} from "./officeWalker.ts"; } from "./officeWalker.ts";
import {
createOfficeMediaPresentation,
type MediaSurfaceDescriptor,
type MediaSurfaceGrant,
type OfficeMediaPresentation,
} from "../media/presentation.ts";
import { import {
createRobotLayer, createRobotLayer,
type RobotLayer, type RobotLayer,
@@ -238,6 +244,11 @@ export interface OfficeScene extends StageScene {
depth: Depth; depth: Depth;
/** Local walk-mode actor, or null when this scene was built as dollhouse-only. */ /** Local walk-mode actor, or null when this scene was built as dollhouse-only. */
walker: OfficeWalker | null; walker: OfficeWalker | null;
/** Authored monitor/display props; contains no source locators. */
listMediaSurfaces(): MediaSurfaceDescriptor[];
/** Bind only a caller-owned texture plus the result of server authorization and opt-in. */
bindMediaSurface(screenId: string, grant: MediaSurfaceGrant, texture: THREE.VideoTexture): boolean;
clearMediaSurface(screenId: string): boolean;
/** The pack's viewpoints, as the thing a legend prints and `flyTo` is keyed on. */ /** The pack's viewpoints, as the thing a legend prints and `flyTo` is keyed on. */
views: View[]; views: View[];
flyTo(viewId: string): void; flyTo(viewId: string): void;
@@ -474,6 +485,8 @@ export function createOfficeScene(office: Office, options: OfficeSceneOptions):
...(options.registry ? { registry: options.registry } : {}), ...(options.registry ? { registry: options.registry } : {}),
...(options.colorFor ? { colorFor: options.colorFor } : {}), ...(options.colorFor ? { colorFor: options.colorFor } : {}),
}); });
const mediaSurfaces: OfficeMediaPresentation = createOfficeMediaPresentation(plan, furnishings.group);
scene.add(mediaSurfaces.group);
/** /**
* The ceiling, made switchable. * The ceiling, made switchable.
@@ -765,6 +778,9 @@ export function createOfficeScene(office: Office, options: OfficeSceneOptions):
plan, plan,
depth, depth,
walker: officeWalker, walker: officeWalker,
listMediaSurfaces: () => mediaSurfaces.list(),
bindMediaSurface: (screenId, grant, texture) => mediaSurfaces.bind(screenId, grant, texture),
clearMediaSurface: (screenId) => mediaSurfaces.clear(screenId),
views, views,
// A public office anchors nothing, because it has nobody to anchor. The // A public office anchors nothing, because it has nobody to anchor. The
// empty map is this scene's own rather than a shared module-level one: an // empty map is this scene's own rather than a shared module-level one: an
@@ -831,6 +847,7 @@ export function createOfficeScene(office: Office, options: OfficeSceneOptions):
luminaires.tick(dt); luminaires.tick(dt);
}, },
dispose() { dispose() {
mediaSurfaces.dispose();
officeWalker?.dispose(); officeWalker?.dispose();
robots?.dispose(); robots?.dispose();
luminaires.dispose(); luminaires.dispose();
+113 -22
View File
@@ -71,12 +71,15 @@ import {
} from "./journey/index.ts"; } from "./journey/index.ts";
import { import {
actorKindForPresence, actorKindForPresence,
createProfileEditor,
createDefaultLocalProfile, createDefaultLocalProfile,
loadLocalProfile, loadLocalProfile,
resolveHumanoidAppearance, resolveHumanoidAppearance,
saveLocalProfile, saveLocalProfile,
type LocalProfile, type LocalProfile,
type ProfileEditor,
} from "./profile/index.ts"; } from "./profile/index.ts";
import type { ActorIdentity } from "./actors/controller.ts";
/** /**
* Three type-only imports and not one value among them, which is what keeps the * Three type-only imports and not one value among them, which is what keeps the
* office and the instruments out of the entry chunk. * office and the instruments out of the entry chunk.
@@ -133,6 +136,26 @@ function humanoidAppearance() {
return localProfile ? resolveHumanoidAppearance(localProfile.appearance) : null; return localProfile ? resolveHumanoidAppearance(localProfile.appearance) : null;
} }
function signedInActorIdentity(profile = localProfile): ActorIdentity {
const appearance = profile ? resolveHumanoidAppearance(profile.appearance) : null;
return {
id: access.subject ?? "anonymous",
displayName: profile?.displayName ?? access.subject ?? "Guest",
authenticated: access.subject !== null,
profile: {
appearance: appearance
? {
skinTone: appearance.skinTone,
primaryColor: appearance.outfitColor,
accentColor: appearance.accentColor,
hairColor: appearance.hairColor,
bodyShape: appearance.bodyShape,
}
: { primaryColor: "#151a20", accentColor: "#f2b134" },
},
};
}
function dispatchJourney(event: JourneyEvent): boolean { function dispatchJourney(event: JourneyEvent): boolean {
const next = journeyReducer(journey, event); const next = journeyReducer(journey, event);
if (next === journey) return false; if (next === journey) return false;
@@ -816,27 +839,14 @@ async function mountCity(id: string) {
city: entry.city, city: entry.city,
actor: { actor: {
kind: actorKindForPresence(access.subject !== null, "outdoors"), kind: actorKindForPresence(access.subject !== null, "outdoors"),
identity: { identity: access.subject === null
id: access.subject ?? "anonymous",
displayName: access.subject ?? "Guest",
authenticated: access.subject !== null,
profile: {
appearance: access.subject === null
? { primaryColor: "#11151a", accentColor: "#f2b134" }
: (() => {
const appearance = humanoidAppearance();
return appearance
? { ? {
skinTone: appearance.skinTone, id: "anonymous",
primaryColor: appearance.outfitColor, displayName: "Guest",
accentColor: appearance.accentColor, authenticated: false,
hairColor: appearance.hairColor, profile: { appearance: { primaryColor: "#11151a", accentColor: "#f2b134" } },
bodyShape: appearance.bodyShape,
} }
: { primaryColor: "#151a20", accentColor: "#f2b134" }; : signedInActorIdentity(),
})(),
},
},
mode: access.subject === null ? "flight" : "ground", mode: access.subject === null ? "flight" : "ground",
position: access.subject === null ? { y: 1_200 } : { y: 0 }, position: access.subject === null ? { y: 1_200 } : { y: 0 },
minFlightAltitude: 20, minFlightAltitude: 20,
@@ -1483,6 +1493,8 @@ const driveHint = document.querySelector<HTMLElement>("#drive-hint");
const walkButton = document.querySelector<HTMLButtonElement>("#walk"); const walkButton = document.querySelector<HTMLButtonElement>("#walk");
const walkControls = document.querySelector<HTMLElement>("#walk-controls"); const walkControls = document.querySelector<HTMLElement>("#walk-controls");
const walkHint = document.querySelector<HTMLElement>("#walk-hint"); const walkHint = document.querySelector<HTMLElement>("#walk-hint");
const profileOverlay = document.querySelector<HTMLElement>("#profile-overlay");
let profileEditor: ProfileEditor | null = null;
function showDetail(text: string | null) { function showDetail(text: string | null) {
const card = document.querySelector<HTMLElement>("#detail"); const card = document.querySelector<HTMLElement>("#detail");
@@ -1777,12 +1789,20 @@ function renderCredits() {
function renderOfficeBadge() { function renderOfficeBadge() {
if (!officeBadge) return; if (!officeBadge) return;
const publicOffice = inside && office !== null && office.depth === "public"; const publicOffice = inside && office !== null && office.depth === "public";
const mediaSurfaces = inside && office !== null && office.depth === "full"
? office.listMediaSurfaces()
: [];
// The fabricated-occupancy caption used to be here too and is now on // The fabricated-occupancy caption used to be here too and is now on
// `#source` — see `renderSource`. This badge keeps the message that is a call // `#source` — see `renderSource`. This badge keeps the message that is a call
// to action rather than a disclosure, because that one belongs beside the // to action rather than a disclosure, because that one belongs beside the
// office controls and survives being missed; the other one does not. // office controls and survives being missed; the other one does not.
officeBadge.hidden = !publicOffice; officeBadge.hidden = !publicOffice && mediaSurfaces.length === 0;
if (!publicOffice) return; if (!publicOffice) {
if (mediaSurfaces.length === 0) return;
const noun = mediaSurfaces.length === 1 ? "screen" : "screens";
officeBadge.textContent = `${mediaSurfaces.length} ${noun} ready · media stays off until you opt in.`;
return;
}
officeBadge.replaceChildren( officeBadge.replaceChildren(
document.createTextNode("Public view — the building, not the people. "), document.createTextNode("Public view — the building, not the people. "),
); );
@@ -1823,8 +1843,16 @@ function renderTierBadge() {
if (access.subject !== null) { if (access.subject !== null) {
const who = document.createElement("span"); const who = document.createElement("span");
who.className = "who"; who.className = "who";
who.textContent = access.subject; who.textContent = localProfile?.displayName ?? access.subject;
tierBadge.append(who); tierBadge.append(who);
if (localProfile) {
const customize = document.createElement("button");
customize.type = "button";
customize.className = "profile-trigger";
customize.textContent = "Character";
customize.addEventListener("click", openProfileEditor);
tierBadge.append(customize);
}
} else if (access.signInUrl !== null) { } else if (access.signInUrl !== null) {
const link = document.createElement("a"); const link = document.createElement("a");
link.href = access.signInUrl; link.href = access.signInUrl;
@@ -1834,6 +1862,68 @@ function renderTierBadge() {
tierBadge.hidden = false; tierBadge.hidden = false;
} }
function applyProfilePreview(profile: LocalProfile): void {
if (access.subject === null || inside) return;
city?.setActorIdentity(signedInActorIdentity(profile));
}
async function rebuildOfficeActor(): Promise<void> {
if (!inside || !office) return;
const wasWalking = office.walker?.active() ?? false;
stopWatchingOccupancy();
officePlan?.dispose();
officePlan = null;
office.dispose();
office = null;
officeAtmosphere = null;
await building("Updating your character…", () => enterOffice());
const rebuilt = office as OfficeScene | null;
if (wasWalking) rebuilt?.walker?.setActive(true);
renderLegend();
}
function ensureProfileEditor(): ProfileEditor | null {
if (profileEditor) return profileEditor;
if (!profileOverlay || !localProfile || access.subject === null) return null;
profileEditor = createProfileEditor({
container: profileOverlay,
profile: localProfile,
identityId: access.subject,
onPreview: applyProfilePreview,
onSave(profile) {
localProfile = profile;
saveLocalProfile(sessionStorage, LOCAL_PROFILE_KEY, profile);
dispatchJourney({
type: "sign-in-actor-swap",
actor: {
id: access.subject ?? "anonymous",
kind: "humanoid",
signedIn: true,
profile: { displayName: profile.displayName, color: humanoidAppearance()?.accentColor },
},
});
city?.setActorIdentity(signedInActorIdentity(profile));
renderTierBadge();
profileOverlay.hidden = true;
if (inside) void rebuildOfficeActor();
},
onCancel() {
profileOverlay.hidden = true;
},
});
profileEditor.root.addEventListener("keydown", (event) => event.stopPropagation());
return profileEditor;
}
function openProfileEditor(): void {
if (!localProfile || !profileOverlay) return;
const editor = ensureProfileEditor();
if (!editor) return;
editor.update(localProfile);
profileOverlay.hidden = false;
editor.open();
}
// ---- Navigation ------------------------------------------------------------- // ---- Navigation -------------------------------------------------------------
/** The views on offer right now — city chapters, or office viewpoints inside. */ /** The views on offer right now — city chapters, or office viewpoints inside. */
@@ -2257,6 +2347,7 @@ window.addEventListener("keydown", (event) => {
const target = event.target; const target = event.target;
if ( if (
target instanceof HTMLInputElement || target instanceof HTMLInputElement ||
target instanceof HTMLSelectElement ||
target instanceof HTMLTextAreaElement || target instanceof HTMLTextAreaElement ||
(target instanceof HTMLElement && target.isContentEditable) (target instanceof HTMLElement && target.isContentEditable)
) { ) {
+6
View File
@@ -11,3 +11,9 @@ export {
type MediaSurfaceBinding, type MediaSurfaceBinding,
type MediaSurfaceLifecycle, type MediaSurfaceLifecycle,
} from "./lifecycle.ts"; } from "./lifecycle.ts";
export {
createOfficeMediaPresentation,
type MediaSurfaceDescriptor,
type MediaSurfaceGrant,
type OfficeMediaPresentation,
} from "./presentation.ts";
+163
View File
@@ -0,0 +1,163 @@
/**
* Render hooks for authored office screens.
*
* Screen faces remain batched in `Furnishings`; this layer adds one thin overlay
* only for each interactive monitor/display. Each overlay has an independent
* material, so replacing one screen never splits every other furniture batch.
* It consumes an already-authorized caller-owned `VideoTexture`, never a source
* locator, URL, stream, or identity record.
*/
import * as THREE from "three";
import type { Plan } from "../interiors/plan.ts";
const SCREEN_KINDS = new Set(["tera:screen.monitor", "tera:screen.wall-display"]);
const PLACEHOLDER_COLOR = 0x101820;
export interface MediaSurfaceDescriptor {
screenId: string;
officeId: string;
levelId: string;
roomId: string | null;
kind: string;
bound: boolean;
}
/** Minimal output of server authorization; source locators never enter this layer. */
export interface MediaSurfaceGrant {
canView: boolean;
optedIn: boolean;
}
export interface OfficeMediaPresentation {
group: THREE.Group;
list(): MediaSurfaceDescriptor[];
bind(screenId: string, grant: MediaSurfaceGrant, texture: THREE.VideoTexture): boolean;
clear(screenId: string): boolean;
dispose(): void;
}
interface Surface {
descriptor: MediaSurfaceDescriptor;
mesh: THREE.Mesh;
material: THREE.MeshBasicMaterial;
/** Caller-owned. Kept only while bound and never disposed here. */
texture: THREE.VideoTexture | null;
}
export function createOfficeMediaPresentation(
plan: Plan,
furnishings: THREE.Object3D,
): OfficeMediaPresentation {
const group = new THREE.Group();
group.name = "office-media-surfaces";
const surfaces = new Map<string, Surface>();
let disposed = false;
furnishings.traverse((object) => {
if (!(object instanceof THREE.InstancedMesh)) return;
const separator = object.name.indexOf(":screenDisplay");
if (separator < 0) return;
const kind = object.name.slice(0, separator);
if (!SCREEN_KINDS.has(kind)) return;
const ids = object.userData.props as string[] | undefined;
if (!ids || ids.length !== object.count) return;
for (let index = 0; index < object.count; index += 1) {
const screenId = ids[index];
if (!screenId || surfaces.has(screenId)) continue;
const prop = plan.prop(screenId);
if (!prop) continue;
const level = plan.level(prop.levelId);
if (!level) continue;
const material = placeholderMaterial(screenId);
const mesh = new THREE.Mesh(object.geometry, material);
mesh.name = `media-surface:${screenId}`;
mesh.matrixAutoUpdate = false;
object.getMatrixAt(index, mesh.matrix);
mesh.matrixWorldNeedsUpdate = true;
mesh.castShadow = false;
mesh.receiveShadow = false;
mesh.renderOrder = 1;
const roomId = plan.roomAt(prop.levelId, prop.position)?.id ?? null;
const descriptor: MediaSurfaceDescriptor = {
screenId,
officeId: plan.office.id,
levelId: prop.levelId,
roomId,
kind,
bound: false,
};
mesh.userData.mediaSurface = { ...descriptor };
group.add(mesh);
surfaces.set(screenId, { descriptor, mesh, material, texture: null });
}
});
function clearSurface(surface: Surface): void {
if (surface.material.map !== null) {
surface.material.map = null;
surface.material.color.setHex(PLACEHOLDER_COLOR);
surface.material.needsUpdate = true;
}
surface.texture = null;
surface.descriptor.bound = false;
surface.mesh.userData.mediaSurface = { ...surface.descriptor };
}
return {
group,
list() {
return [...surfaces.values()].map(({ descriptor }) => ({ ...descriptor }));
},
bind(screenId, grant, texture) {
if (disposed) return false;
const surface = surfaces.get(screenId);
if (!surface) return false;
if (!grant.canView || !grant.optedIn) {
clearSurface(surface);
return false;
}
surface.texture = texture;
surface.material.map = texture;
surface.material.color.setHex(0xffffff);
surface.material.needsUpdate = true;
surface.descriptor.bound = true;
surface.mesh.userData.mediaSurface = { ...surface.descriptor };
return true;
},
clear(screenId) {
if (disposed) return false;
const surface = surfaces.get(screenId);
if (!surface) return false;
clearSurface(surface);
return true;
},
dispose() {
if (disposed) return;
disposed = true;
for (const surface of surfaces.values()) {
clearSurface(surface);
// Geometry belongs to the original furniture batch; the small overlay
// owns only its cloned material.
surface.material.dispose();
surface.mesh.removeFromParent();
}
surfaces.clear();
group.clear();
},
};
}
function placeholderMaterial(screenId: string): THREE.MeshBasicMaterial {
return new THREE.MeshBasicMaterial({
name: `media-placeholder:${screenId}`,
color: PLACEHOLDER_COLOR,
toneMapped: false,
polygonOffset: true,
polygonOffsetFactor: -1,
polygonOffsetUnits: -1,
});
}
+427
View File
@@ -0,0 +1,427 @@
/**
* Accessible, dependency-free profile editor mounted into a caller-owned node.
*
* It emits JSON profile data for a separate preview renderer. It creates no
* canvas, Three.js object, webcam, storage, URL, or network request. User text
* is assigned only through input `.value` and `textContent`; never `innerHTML`.
*/
import {
ACCENTS,
BODY_SHAPES,
HAIR_COLORS,
OUTFITS,
SKIN_TONES,
createDefaultLocalProfile,
isLocalProfile,
type HumanoidAppearanceChoices,
type LocalProfile,
} from "./model.ts";
export interface ProfileEditorOptions {
container: HTMLElement;
profile: LocalProfile;
/** Used only to regenerate deterministic appearance defaults; never rendered or emitted. */
identityId: string;
onPreview?: (profile: LocalProfile) => void;
onSave?: (profile: LocalProfile) => void;
onCancel?: () => void;
}
export interface ProfileEditorState {
open: boolean;
dirty: boolean;
valid: boolean;
/** Last saved/caller-supplied profile. */
profile: LocalProfile;
/** Current form values. May have an invalid display name while `valid` is false. */
draft: LocalProfile;
}
export interface ProfileEditor {
/** Stable dialog node for host layout or integration tests. */
root: HTMLElement;
state(): ProfileEditorState;
/** Replace both committed and draft data through strict profile validation. */
update(profile: LocalProfile): ProfileEditorState;
open(): ProfileEditorState;
/** Hide without committing or discarding the draft. */
close(): ProfileEditorState;
dispose(): void;
}
type AppearanceField = keyof HumanoidAppearanceChoices;
const PROFILE_EDITOR_STYLES = `
.tera-profile-editor {
width: min(420px, calc(100vw - 32px));
max-height: min(720px, calc(100dvh - 32px));
overflow: auto;
color: var(--ink, rgba(255,255,255,.82));
background: var(--glass-strong, rgba(9,13,18,.9));
border: 1px solid var(--hairline, rgba(255,255,255,.13));
border-radius: var(--r, 8px);
box-shadow: var(--shadow, 0 8px 28px rgba(0,0,0,.45));
backdrop-filter: var(--blur, blur(14px));
padding: var(--s5, 24px);
font: 12px/1.5 ui-monospace, "SF Mono", Menlo, monospace;
}
.tera-profile-editor__title { margin: 0 0 4px; color: white; font: 600 16px/1.3 inherit; }
.tera-profile-editor__intro { margin: 0 0 20px; color: var(--ink-2, rgba(255,255,255,.62)); }
.tera-profile-editor__grid { display: grid; grid-template-columns: 1fr 1fr; gap: 12px; }
.tera-profile-editor__field { display: grid; gap: 6px; min-width: 0; }
.tera-profile-editor__field--wide { grid-column: 1 / -1; }
.tera-profile-editor__label { color: var(--ink, rgba(255,255,255,.82)); }
.tera-profile-editor__input {
width: 100%; min-height: 40px; padding: 8px 10px; color: white;
background: rgba(255,255,255,.07); border: 1px solid var(--hairline, rgba(255,255,255,.15));
border-radius: var(--r-sm, 5px); font: inherit;
}
.tera-profile-editor__input:hover { border-color: rgba(255,255,255,.3); }
.tera-profile-editor__input:focus-visible, .tera-profile-editor__button:focus-visible {
outline: 2px solid var(--amber, #f2b134); outline-offset: 2px;
}
.tera-profile-editor__input[aria-invalid="true"] { border-color: #ff8b80; }
.tera-profile-editor__status { min-height: 18px; margin: 12px 0 0; color: #ffb2aa; }
.tera-profile-editor__actions { display: flex; flex-wrap: wrap; justify-content: flex-end; gap: 8px; margin-top: 16px; }
.tera-profile-editor__button {
min-height: 40px; padding: 8px 14px; color: var(--ink, white); cursor: pointer;
background: rgba(255,255,255,.07); border: 1px solid var(--hairline, rgba(255,255,255,.15));
border-radius: var(--r-sm, 5px); font: inherit;
}
.tera-profile-editor__button:hover:not(:disabled) { background: rgba(255,255,255,.12); }
.tera-profile-editor__button--primary { color: #171109; background: var(--amber, #f2b134); border-color: transparent; font-weight: 700; }
.tera-profile-editor__button--primary:hover:not(:disabled) { background: var(--amber-lit, #ffc555); }
.tera-profile-editor__button:disabled { cursor: not-allowed; opacity: .42; }
@media (max-width: 460px) { .tera-profile-editor__grid { grid-template-columns: 1fr; } .tera-profile-editor__field--wide { grid-column: auto; } }
@media (prefers-reduced-motion: reduce) { .tera-profile-editor * { scroll-behavior: auto !important; } }
`;
const FIELD_OPTIONS: Readonly<Record<AppearanceField, readonly string[]>> = {
skinTone: SKIN_TONES,
outfit: OUTFITS,
accent: ACCENTS,
hair: HAIR_COLORS,
bodyShape: BODY_SHAPES,
};
const FIELD_LABELS: Readonly<Record<AppearanceField, string>> = {
skinTone: "Skin tone",
outfit: "Outfit",
accent: "Accent",
hair: "Hair",
bodyShape: "Body shape",
};
let editorSequence = 0;
function clone(profile: LocalProfile): LocalProfile {
return { ...profile, appearance: { ...profile.appearance } };
}
function presentChoice(value: string): string {
return value.replace(/(^|-)([a-z])/g, (_match, separator: string, letter: string) => `${separator}${letter.toUpperCase()}`);
}
function validDisplayName(value: string): boolean {
return (
value === value.trim() &&
value.length >= 1 &&
value.length <= 64 &&
!/[\u0000-\u001f\u007f]/u.test(value)
);
}
function sameProfile(a: LocalProfile, b: LocalProfile): boolean {
return (
a.version === b.version &&
a.displayName === b.displayName &&
a.appearance.skinTone === b.appearance.skinTone &&
a.appearance.outfit === b.appearance.outfit &&
a.appearance.accent === b.appearance.accent &&
a.appearance.hair === b.appearance.hair &&
a.appearance.bodyShape === b.appearance.bodyShape
);
}
function focusable(value: Element | null): value is HTMLElement {
return value !== null && typeof (value as HTMLElement).focus === "function";
}
export function createProfileEditor(options: ProfileEditorOptions): ProfileEditor {
if (!options.container || typeof options.container.append !== "function") {
throw new RangeError("profile editor: container must be an HTMLElement");
}
if (!isLocalProfile(options.profile)) throw new RangeError("profile editor: invalid profile");
if (typeof options.identityId !== "string" || options.identityId.length === 0) {
throw new RangeError("profile editor: identity id is required");
}
const doc = options.container.ownerDocument;
const number = ++editorSequence;
const titleId = `tera-profile-title-${number}`;
const descriptionId = `tera-profile-description-${number}`;
const statusId = `tera-profile-status-${number}`;
const root = doc.createElement("section");
root.className = "tera-profile-editor";
root.setAttribute("role", "dialog");
root.setAttribute("aria-modal", "true");
root.setAttribute("aria-labelledby", titleId);
root.setAttribute("aria-describedby", `${descriptionId} ${statusId}`);
root.setAttribute("aria-hidden", "true");
root.hidden = true;
const style = doc.createElement("style");
style.setAttribute("data-tera-profile-editor-style", "");
style.textContent = PROFILE_EDITOR_STYLES;
root.append(style);
const title = doc.createElement("h2");
title.id = titleId;
title.className = "tera-profile-editor__title";
title.textContent = "Your Tera character";
root.append(title);
const intro = doc.createElement("p");
intro.id = descriptionId;
intro.className = "tera-profile-editor__intro";
intro.textContent = "Choose how your procedural humanoid appears. Preview stays local to this page.";
root.append(intro);
const form = doc.createElement("form");
form.setAttribute("novalidate", "");
const grid = doc.createElement("div");
grid.className = "tera-profile-editor__grid";
form.append(grid);
const nameField = doc.createElement("div");
nameField.className = "tera-profile-editor__field tera-profile-editor__field--wide";
const nameLabel = doc.createElement("label");
const nameId = `tera-profile-name-${number}`;
nameLabel.className = "tera-profile-editor__label";
nameLabel.setAttribute("for", nameId);
nameLabel.textContent = "Display name";
const nameInput = doc.createElement("input");
nameInput.id = nameId;
nameInput.className = "tera-profile-editor__input";
nameInput.setAttribute("data-field", "displayName");
nameInput.setAttribute("aria-describedby", statusId);
nameInput.type = "text";
nameInput.maxLength = 64;
nameInput.autocomplete = "name";
nameField.append(nameLabel, nameInput);
grid.append(nameField);
const selects = {} as Record<AppearanceField, HTMLSelectElement>;
const appearanceFields = Object.keys(FIELD_OPTIONS) as AppearanceField[];
for (const field of appearanceFields) {
const wrapper = doc.createElement("div");
wrapper.className = "tera-profile-editor__field";
const label = doc.createElement("label");
const id = `tera-profile-${field}-${number}`;
label.className = "tera-profile-editor__label";
label.setAttribute("for", id);
label.textContent = FIELD_LABELS[field];
const select = doc.createElement("select");
select.id = id;
select.className = "tera-profile-editor__input";
select.setAttribute("data-field", field);
for (const choice of FIELD_OPTIONS[field]) {
const option = doc.createElement("option");
option.value = choice;
option.textContent = presentChoice(choice);
select.append(option);
}
selects[field] = select;
wrapper.append(label, select);
grid.append(wrapper);
}
const status = doc.createElement("p");
status.id = statusId;
status.className = "tera-profile-editor__status";
status.setAttribute("role", "status");
status.setAttribute("aria-live", "polite");
form.append(status);
const actions = doc.createElement("div");
actions.className = "tera-profile-editor__actions";
const resetButton = button(doc, "Reset appearance", "reset");
const cancelButton = button(doc, "Cancel", "cancel");
const saveButton = button(doc, "Save profile", "save", true);
actions.append(resetButton, cancelButton, saveButton);
form.append(actions);
root.append(form);
options.container.append(root);
const focusOrder: HTMLElement[] = [nameInput, ...appearanceFields.map((field) => selects[field]), resetButton, cancelButton, saveButton];
let committed = clone(options.profile);
let draft = clone(options.profile);
let isOpen = false;
let disposed = false;
let returnFocus: HTMLElement | null = null;
function valid(): boolean {
return validDisplayName(draft.displayName) && isLocalProfile(draft);
}
function snapshot(): ProfileEditorState {
return {
open: isOpen,
dirty: !sameProfile(committed, draft),
valid: valid(),
profile: clone(committed),
draft: clone(draft),
};
}
function render(): void {
nameInput.value = draft.displayName;
for (const field of appearanceFields) selects[field].value = draft.appearance[field];
const isValid = valid();
nameInput.setAttribute("aria-invalid", isValid ? "false" : "true");
saveButton.disabled = !isValid;
status.textContent = isValid ? "" : "Enter a name from 1 to 64 characters without leading or trailing spaces.";
}
function emitPreview(): void {
if (valid()) options.onPreview?.(clone(draft));
}
function assignField(field: AppearanceField, value: string): void {
if (!(FIELD_OPTIONS[field] as readonly string[]).includes(value)) return;
draft = { ...draft, appearance: { ...draft.appearance, [field]: value } } as LocalProfile;
render();
emitPreview();
}
function save(): void {
if (!valid()) {
render();
nameInput.focus();
return;
}
committed = clone(draft);
options.onSave?.(clone(committed));
close();
}
function cancel(): void {
draft = clone(committed);
render();
emitPreview();
options.onCancel?.();
close();
}
function close(): ProfileEditorState {
if (!isOpen || disposed) return snapshot();
isOpen = false;
root.hidden = true;
root.setAttribute("aria-hidden", "true");
const target = returnFocus;
returnFocus = null;
target?.focus();
return snapshot();
}
nameInput.addEventListener("input", () => {
if (disposed) return;
draft = { ...draft, displayName: nameInput.value };
render();
emitPreview();
});
for (const field of appearanceFields) {
selects[field].addEventListener("change", () => {
if (!disposed) assignField(field, selects[field].value);
});
}
resetButton.addEventListener("click", () => {
if (disposed) return;
const defaults = createDefaultLocalProfile(options.identityId, committed.displayName);
draft = { ...defaults, displayName: draft.displayName };
render();
emitPreview();
});
cancelButton.addEventListener("click", () => {
if (!disposed) cancel();
});
saveButton.addEventListener("click", () => {
if (!disposed) save();
});
form.addEventListener("submit", (event) => {
event.preventDefault();
if (!disposed) save();
});
root.addEventListener("keydown", (event) => {
if (disposed || !isOpen) return;
if (event.key === "Escape") {
event.preventDefault();
cancel();
return;
}
if (event.key === "Enter" && (event.ctrlKey || event.metaKey)) {
event.preventDefault();
save();
return;
}
if (event.key !== "Tab") return;
const enabled = focusOrder.filter((element) => !((element as HTMLButtonElement).disabled));
const first = enabled[0];
const last = enabled[enabled.length - 1];
if (!first || !last) return;
if (event.shiftKey && doc.activeElement === first) {
event.preventDefault();
last.focus();
} else if (!event.shiftKey && doc.activeElement === last) {
event.preventDefault();
first.focus();
}
});
render();
return {
root,
state: snapshot,
update(profile) {
if (disposed) return snapshot();
if (!isLocalProfile(profile)) throw new RangeError("profile editor: invalid profile");
committed = clone(profile);
draft = clone(profile);
render();
emitPreview();
return snapshot();
},
open() {
if (disposed || isOpen) return snapshot();
isOpen = true;
root.hidden = false;
root.setAttribute("aria-hidden", "false");
returnFocus = focusable(doc.activeElement) ? doc.activeElement : null;
nameInput.focus();
emitPreview();
return snapshot();
},
close,
dispose() {
if (disposed) return;
if (isOpen) close();
disposed = true;
root.remove();
},
};
}
function button(
doc: Document,
label: string,
action: "reset" | "cancel" | "save",
primary = false,
): HTMLButtonElement {
const element = doc.createElement("button");
element.type = "button";
element.className = `tera-profile-editor__button${primary ? " tera-profile-editor__button--primary" : ""}`;
element.setAttribute("data-action", action);
element.textContent = label;
return element;
}
+16
View File
@@ -49,3 +49,19 @@ export {
type WebcamFaceConsentStatus, type WebcamFaceConsentStatus,
type WebcamFaceConsentTransition, type WebcamFaceConsentTransition,
} from "./webcamConsent.ts"; } from "./webcamConsent.ts";
export {
createProfileEditor,
type ProfileEditor,
type ProfileEditorOptions,
type ProfileEditorState,
} from "./editor.ts";
export {
createWebcamFaceTexture,
type WebcamFaceTextureAdapter,
type WebcamFaceTextureBinding,
type WebcamFaceTextureOptions,
type WebcamFaceTextureState,
type WebcamFaceTextureStatus,
} from "./webcamFaceTexture.ts";
+153
View File
@@ -0,0 +1,153 @@
/**
* Ephemeral webcam-face texture binding behind explicit consent.
*
* This adapter has no acquisition path: no `getUserMedia`, URL, fetch, canvas,
* recorder, upload, or storage API. A caller that already owns a stream and a
* video element may bind them only while the existing consent controller says
* active. The adapter owns the resulting Three.js texture and nothing else.
*/
import * as THREE from "three";
import type { WebcamFaceConsentController } from "./webcamConsent.ts";
export type WebcamFaceTextureStatus = "off" | "active" | "disposed";
export interface WebcamFaceTextureState {
status: WebcamFaceTextureStatus;
active: boolean;
/** UI must show this whenever live webcam pixels are available to the scene. */
indicatorVisible: boolean;
consentRevision: number;
readonly ephemeral: true;
readonly persistence: "none";
readonly recording: false;
readonly uploading: false;
}
export interface WebcamFaceTextureBinding {
/** Caller-owned and never stopped. */
stream: MediaStream;
/** Caller-owned and never removed, played, paused, or disposed. */
video: HTMLVideoElement;
}
export interface WebcamFaceTextureAdapter {
state(): WebcamFaceTextureState;
/**
* Creates one adapter-owned `VideoTexture`. The caller must already have
* attached the stream to the video and started playback after user consent.
*/
bind(binding: WebcamFaceTextureBinding): THREE.VideoTexture;
/** Returns null and clears immediately when consent is no longer active. */
texture(): THREE.VideoTexture | null;
/** Explicitly reconcile a consent transition and return the new state. */
sync(): WebcamFaceTextureState;
clear(): WebcamFaceTextureState;
dispose(): void;
}
export interface WebcamFaceTextureOptions {
consent: WebcamFaceConsentController;
/** Active-indicator hook; fires only when the visible state changes. */
onIndicatorChange?: (state: WebcamFaceTextureState) => void;
}
export function createWebcamFaceTexture(options: WebcamFaceTextureOptions): WebcamFaceTextureAdapter {
if (!options.consent || typeof options.consent.state !== "function") {
throw new RangeError("webcam face texture: consent controller is required");
}
let texture: THREE.VideoTexture | null = null;
let disposed = false;
let lastIndicator = false;
function snapshot(): WebcamFaceTextureState {
const consent = options.consent.state();
const active = !disposed && texture !== null && consent.status === "active" && consent.consentGranted;
return {
status: disposed ? "disposed" : active ? "active" : "off",
active,
indicatorVisible: active,
consentRevision: consent.revision,
ephemeral: true,
persistence: "none",
recording: false,
uploading: false,
};
}
function announce(): WebcamFaceTextureState {
const state = snapshot();
if (state.indicatorVisible !== lastIndicator) {
lastIndicator = state.indicatorVisible;
options.onIndicatorChange?.({ ...state });
}
return state;
}
function release(): void {
if (!texture) return;
// `VideoTexture.dispose()` releases only renderer resources. It does not
// stop tracks or dispose/mutate the caller's video element.
texture.dispose();
texture = null;
}
function reconcile(): WebcamFaceTextureState {
const consent = options.consent.state();
if (texture && (consent.status !== "active" || !consent.consentGranted)) release();
return announce();
}
return {
state: reconcile,
bind(binding) {
if (disposed) throw new Error("webcam face texture: adapter is disposed");
const consent = options.consent.state();
if (consent.status !== "active" || !consent.consentGranted) {
throw new Error("webcam face texture: explicit active consent is required");
}
validateBinding(binding);
release();
texture = new THREE.VideoTexture(binding.video);
texture.name = "ephemeral-webcam-face";
texture.colorSpace = THREE.SRGBColorSpace;
texture.generateMipmaps = false;
announce();
return texture;
},
texture() {
reconcile();
return texture;
},
sync: reconcile,
clear() {
if (!disposed) release();
return announce();
},
dispose() {
if (disposed) return;
release();
disposed = true;
announce();
},
};
}
function validateBinding(binding: WebcamFaceTextureBinding): void {
if (!binding || typeof binding !== "object") throw new TypeError("webcam face texture: binding is required");
const stream = binding.stream as MediaStream | undefined;
const video = binding.video as HTMLVideoElement | undefined;
if (!stream || typeof stream.getVideoTracks !== "function") {
throw new TypeError("webcam face texture: caller-owned MediaStream is required");
}
const tracks = stream.getVideoTracks();
if (tracks.length === 0) throw new TypeError("webcam face texture: stream has no video track");
if (!video || typeof video !== "object" || !("srcObject" in video)) {
throw new TypeError("webcam face texture: caller-owned HTMLVideoElement is required");
}
if (video.srcObject !== stream) {
throw new TypeError("webcam face texture: caller must attach the supplied stream to the video first");
}
}
+188
View File
@@ -0,0 +1,188 @@
import assert from "node:assert/strict";
import { describe, it } from "node:test";
import * as THREE from "three";
import {
AircraftController,
ELECTRIC_AIRCRAFT_METRICS,
aircraftChaseCameraPose,
advanceAircraftFans,
buildElectricAircraft,
createElectricAircraftMaterials,
disposeElectricAircraft,
normalizeAircraftActions,
replayAircraftInputs,
setAircraftControlSurfaces,
setAircraftFanRotation,
} from "../aircraft/index.ts";
const ROUTE = [
{ id: "los-angeles", lat: 34.0522, lng: -118.2437, altitudeM: 1_300 },
{ id: "san-francisco", lat: 37.7749, lng: -122.4194, altitudeM: 1_700 },
] as const;
describe("procedural electric aircraft", () => {
it("builds an original metre-scale fixed wing facing -Z with articulated parts", () => {
const rig = buildElectricAircraft();
assert.equal(rig.root.name, "electric-aircraft");
assert.equal(rig.fans.length, 2);
assert.equal(rig.ownsMaterials, true);
assert.ok(ELECTRIC_AIRCRAFT_METRICS.wingspan > ELECTRIC_AIRCRAFT_METRICS.length);
const bounds = new THREE.Box3().setFromObject(rig.root);
const size = bounds.getSize(new THREE.Vector3());
assert.ok(size.x > 10);
assert.ok(size.z > 7);
assert.ok(size.y > 1.5);
disposeElectricAircraft(rig);
assert.equal(rig.root.children.length, 0);
});
it("animates opposing ailerons, V-tail surfaces, and electric fans", () => {
const rig = buildElectricAircraft();
setAircraftControlSurfaces(rig, { roll: 0.8, pitch: 0.5, yaw: -0.4 });
assert.ok(rig.leftAileron.rotation.x > 0);
assert.ok(rig.rightAileron.rotation.x < 0);
assert.notEqual(rig.leftVTail.rotation.x, rig.rightVTail.rotation.x);
setAircraftFanRotation(rig, 1);
advanceAircraftFans(rig, 0.5);
assert.equal(rig.fans[0]?.rotation.z, 1.5);
assert.equal(rig.fans[1]?.rotation.z, 1.5);
disposeElectricAircraft(rig);
});
it("does not dispose caller-owned materials", () => {
const materials = createElectricAircraftMaterials();
let disposals = 0;
for (const material of Object.values(materials)) {
material.addEventListener("dispose", () => { disposals += 1; });
}
const rig = buildElectricAircraft({ materials });
assert.equal(rig.ownsMaterials, false);
disposeElectricAircraft(rig);
assert.equal(disposals, 0);
for (const material of Object.values(materials)) material.dispose();
});
});
describe("aircraft controller", () => {
it("normalizes device-neutral flight axes", () => {
assert.deepEqual(normalizeAircraftActions({
throttle: 5,
yaw: -4,
pitch: Number.NaN,
roll: 2,
modeRequest: "manual",
reset: true,
}), {
throttle: 1,
yaw: -1,
pitch: 0,
roll: 1,
modeRequest: "manual",
reset: true,
});
});
it("follows route and altitude deterministically in assisted mode", () => {
const options = {
route: ROUTE,
initialPosition: ROUTE[0],
initialHeadingDeg: 0,
initialAltitudeM: 900,
assistedAltitudeM: 1_300,
};
const a = new AircraftController(options);
const b = new AircraftController(options);
for (let index = 0; index < 600; index += 1) {
a.stepFixed();
b.stepFixed();
}
assert.deepEqual(a.snapshot(), b.snapshot());
assert.equal(a.state().mode, "assisted");
assert.equal(a.state().routeWaypointId, "san-francisco");
assert.ok(a.state().altitudeM > 900);
assert.ok(a.state().headingDeg > 180, "aircraft turns northwest toward San Francisco");
});
it("takes manual input immediately and resumes assistance without a state jump", () => {
const controller = new AircraftController({ route: ROUTE });
for (let index = 0; index < 120; index += 1) {
controller.stepFixed({ throttle: 0.8, roll: 0.7, pitch: -0.3 });
}
assert.equal(controller.state().mode, "manual");
const before = controller.snapshot();
controller.stepFixed({ modeRequest: "assisted", roll: 0.8 });
assert.equal(controller.state().mode, "manual", "manual controls beat simultaneous resume");
controller.stepFixed({ modeRequest: "assisted" });
assert.equal(controller.state().mode, "assisted");
assert.ok(Math.abs(controller.state().rollDeg - before.rollDeg) < 3);
assert.ok(Math.abs(controller.state().pitchDeg - before.pitchDeg) < 3);
});
it("enforces geographic, altitude, and speed boundaries", () => {
const controller = new AircraftController({
mode: "manual",
initialPosition: { lat: 100, lng: -200 },
initialAltitudeM: 100_000,
initialSpeedMps: 1_000,
maximumSpeedMps: 80,
});
assert.equal(controller.state().lat, 42.1);
assert.equal(controller.state().lng, -124.6);
assert.equal(controller.state().altitudeM, 6_000);
assert.equal(controller.state().speedMps, 80);
for (let index = 0; index < 180; index += 1) {
controller.stepFixed({ throttle: 1, pitch: 1, roll: 1 });
}
assert.ok(controller.state().lat >= 32.4 && controller.state().lat <= 42.1);
assert.ok(controller.state().lng >= -124.6 && controller.state().lng <= -114);
assert.ok(controller.state().altitudeM >= 75 && controller.state().altitudeM <= 6_000);
assert.ok(controller.state().speedMps <= 80);
assert.equal(controller.state().envelopeContact, true);
});
it("resets exactly, caps sleeping-tab time, and replays bit-for-bit", () => {
const options = { route: ROUTE, initialAltitudeM: 1_200, initialSpeedMps: 48 } as const;
const controller = new AircraftController(options);
const spawn = controller.snapshot();
assert.ok(controller.tick(600) <= 15);
controller.stepFixed({ throttle: 1, roll: -1 });
controller.stepFixed({ reset: true });
assert.deepEqual(controller.snapshot(), spawn);
const frames = [
{ steps: 90, actions: { throttle: 0.9, roll: 0.4 } },
{ steps: 1, actions: { modeRequest: "assisted" as const } },
{ steps: 180 },
];
assert.deepEqual(
replayAircraftInputs(options, frames),
replayAircraftInputs(options, frames),
);
});
it("derives a finite geographic chase camera behind the aircraft", () => {
const controller = new AircraftController({ initialHeadingDeg: 0 });
const camera = aircraftChaseCameraPose(controller.state());
assert.ok(camera.position.lat < controller.state().lat);
assert.ok(camera.target.lat > controller.state().lat);
assert.ok(camera.position.altitudeM > controller.state().altitudeM);
assert.ok(Object.values(camera.position).every(Number.isFinite));
assert.ok(Object.values(camera.target).every(Number.isFinite));
});
it("rejects malformed route and envelope configuration", () => {
assert.throws(() => new AircraftController({
route: [{ id: "bad", lat: Number.NaN, lng: 0, altitudeM: 1_000 }],
}), /waypoints/);
assert.throws(() => new AircraftController({
envelope: {
minLat: 40,
maxLat: 30,
minLng: -124,
maxLng: -114,
minAltitudeM: 10,
maxAltitudeM: 100,
},
}), /envelope/);
});
});
+102
View File
@@ -0,0 +1,102 @@
import assert from "node:assert/strict";
import { describe, it } from "node:test";
import * as THREE from "three";
import { MaterialRegistry } from "../assets/materials.ts";
import "../assets/office/index.ts";
import { createFurnishings } from "../interiors/furnish.ts";
import { Plan } from "../interiors/plan.ts";
import type { Level, Office, Room } from "../interiors/types.ts";
import { createOfficeMediaPresentation } from "../media/presentation.ts";
const ROOM: Room = {
id: "room",
name: "Room",
floor: "floor" as never,
outline: [{ x: 0, z: 0 }, { x: 8, z: 0 }, { x: 8, z: 6 }, { x: 0, z: 6 }],
};
function fixture(depth: "full" | "public" = "full") {
const level: Level = {
id: "ground",
name: "Ground",
elevation: 0,
wallHeight: 3,
wallThickness: 0.1,
floorplan: { rooms: [ROOM], walls: [], props: [
{ id: "wall-display", kind: "tera:screen.wall-display", position: { x: 2, z: 2 }, rotation: 0 },
{ id: "desk-monitor", kind: "tera:screen.monitor", position: { x: 4, z: 2 }, rotation: 0, elevation: 0.73 },
{ id: "ordinary-desk", kind: "tera:desk.workstation", position: { x: 6, z: 2 }, rotation: 0 },
{
id: "private-display",
kind: "tera:screen.wall-display",
position: { x: 2, z: 4 },
rotation: 0,
audience: "private",
},
] },
};
const office: Office = { id: "media-office", name: "Media Office", levels: [level], viewpoints: [] };
const plan = new Plan(office, { depth, warn: false });
const materials = new MaterialRegistry({ quality: "low" });
const furnishings = createFurnishings(plan, { materials });
const presentation = createOfficeMediaPresentation(plan, furnishings.group);
return { plan, materials, furnishings, presentation };
}
describe("procedural office media presentation", () => {
it("discovers only authored screen props and keeps exact prop ids and rooms", () => {
const f = fixture();
const listed = f.presentation.list();
assert.deepEqual(listed.map((item) => item.screenId).sort(), ["desk-monitor", "private-display", "wall-display"]);
assert.ok(listed.every((item) => item.officeId === "media-office" && item.roomId === "room"));
assert.ok(!listed.some((item) => item.screenId === "ordinary-desk"));
assert.equal(f.presentation.group.children.length, 3);
f.presentation.dispose();
f.furnishings.dispose();
f.materials.dispose();
});
it("keeps public depth private-prop-free by discovering only the resolved plan", () => {
const f = fixture("public");
assert.deepEqual(f.presentation.list().map((item) => item.screenId).sort(), ["desk-monitor", "wall-display"]);
f.presentation.dispose();
f.furnishings.dispose();
f.materials.dispose();
});
it("requires authorization plus opt-in and returns to a safe placeholder", () => {
const f = fixture();
let textureDisposals = 0;
const texture = new THREE.VideoTexture({} as HTMLVideoElement);
texture.dispose = () => { textureDisposals += 1; };
assert.equal(f.presentation.bind("wall-display", { canView: true, optedIn: false }, texture), false);
assert.equal(f.presentation.list().find((item) => item.screenId === "wall-display")?.bound, false);
assert.equal(f.presentation.bind("wall-display", { canView: true, optedIn: true }, texture), true);
const mesh = f.presentation.group.getObjectByName("media-surface:wall-display") as THREE.Mesh;
assert.equal((mesh.material as THREE.MeshBasicMaterial).map, texture);
assert.equal(f.presentation.clear("wall-display"), true);
assert.equal((mesh.material as THREE.MeshBasicMaterial).map, null);
assert.equal(textureDisposals, 0);
f.presentation.dispose();
assert.equal(textureDisposals, 0, "caller-owned texture was disposed");
f.furnishings.dispose();
f.materials.dispose();
});
it("returns defensive descriptors and contains unknown/disposed operations", () => {
const f = fixture();
const leaked = f.presentation.list()[0];
assert.ok(leaked);
leaked.screenId = "changed";
assert.notEqual(f.presentation.list()[0]?.screenId, "changed");
const texture = new THREE.VideoTexture({} as HTMLVideoElement);
assert.equal(f.presentation.bind("missing", { canView: true, optedIn: true }, texture), false);
f.presentation.dispose();
f.presentation.dispose();
assert.equal(f.presentation.bind("wall-display", { canView: true, optedIn: true }, texture), false);
assert.equal(f.presentation.clear("wall-display"), false);
f.furnishings.dispose();
f.materials.dispose();
texture.dispose();
});
});
+241
View File
@@ -0,0 +1,241 @@
import assert from "node:assert/strict";
import { describe, it } from "node:test";
import {
ACCENTS,
BODY_SHAPES,
HAIR_COLORS,
OUTFITS,
SKIN_TONES,
createDefaultLocalProfile,
createProfileEditor,
type LocalProfile,
} from "../profile/index.ts";
type Listener = (event: FakeEvent) => void;
class FakeEvent {
defaultPrevented = false;
readonly type: string;
readonly target: FakeElement;
readonly key: string;
readonly ctrlKey: boolean;
readonly metaKey: boolean;
readonly shiftKey: boolean;
constructor(
type: string,
target: FakeElement,
key = "",
ctrlKey = false,
metaKey = false,
shiftKey = false,
) {
this.type = type;
this.target = target;
this.key = key;
this.ctrlKey = ctrlKey;
this.metaKey = metaKey;
this.shiftKey = shiftKey;
}
preventDefault(): void { this.defaultPrevented = true; }
}
class FakeElement {
readonly children: FakeElement[] = [];
readonly attributes = new Map<string, string>();
readonly listeners = new Map<string, Listener[]>();
parentElement: FakeElement | null = null;
className = "";
textContent = "";
hidden = false;
value = "";
id = "";
type = "";
maxLength = 0;
autocomplete = "";
disabled = false;
readonly ownerDocument: FakeDocument;
readonly tagName: string;
constructor(ownerDocument: FakeDocument, tagName: string) {
this.ownerDocument = ownerDocument;
this.tagName = tagName;
}
append(...nodes: FakeElement[]): void {
for (const node of nodes) {
node.parentElement = this;
this.children.push(node);
}
}
setAttribute(name: string, value: string): void { this.attributes.set(name, value); }
getAttribute(name: string): string | null { return this.attributes.get(name) ?? null; }
addEventListener(type: string, listener: Listener): void {
const found = this.listeners.get(type);
if (found) found.push(listener);
else this.listeners.set(type, [listener]);
}
dispatch(type: string, init: Partial<Pick<FakeEvent, "key" | "ctrlKey" | "metaKey" | "shiftKey">> = {}): FakeEvent {
const event = new FakeEvent(type, this, init.key, init.ctrlKey, init.metaKey, init.shiftKey);
for (const listener of this.listeners.get(type) ?? []) listener(event);
return event;
}
focus(): void { this.ownerDocument.activeElement = this; }
remove(): void {
if (!this.parentElement) return;
const index = this.parentElement.children.indexOf(this);
if (index >= 0) this.parentElement.children.splice(index, 1);
this.parentElement = null;
}
find(attribute: string, value: string): FakeElement {
if (this.attributes.get(attribute) === value) return this;
for (const child of this.children) {
try { return child.find(attribute, value); } catch { /* continue */ }
}
throw new Error(`missing [${attribute}=${value}]`);
}
descendants(tagName: string): FakeElement[] {
const result: FakeElement[] = [];
for (const child of this.children) {
if (child.tagName === tagName.toUpperCase()) result.push(child);
result.push(...child.descendants(tagName));
}
return result;
}
}
class FakeDocument {
activeElement: FakeElement | null = null;
createElement(tagName: string): FakeElement { return new FakeElement(this, tagName.toUpperCase()); }
}
function setup(profile = createDefaultLocalProfile("member-22", "Avery")) {
const document = new FakeDocument();
const container = document.createElement("div");
const previews: LocalProfile[] = [];
const saves: LocalProfile[] = [];
let cancels = 0;
const editor = createProfileEditor({
container: container as unknown as HTMLElement,
profile,
identityId: "member-22",
onPreview: (next) => previews.push(next),
onSave: (next) => saves.push(next),
onCancel: () => cancels++,
});
return { document, container, editor, previews, saves, cancels: () => cancels };
}
describe("profile editor DOM adapter", () => {
it("builds a closed labelled dialog with every enumerated appearance choice", () => {
const { container, editor } = setup();
const root = editor.root as unknown as FakeElement;
assert.equal(container.children.length, 1);
assert.equal(root.hidden, true);
assert.equal(root.getAttribute("role"), "dialog");
assert.equal(root.getAttribute("aria-modal"), "true");
assert.ok(root.getAttribute("aria-labelledby"));
assert.equal(root.find("data-field", "displayName").getAttribute("aria-describedby"), root.descendants("p")[1]?.id);
assert.equal(root.find("data-field", "skinTone").children.length, SKIN_TONES.length);
assert.equal(root.find("data-field", "outfit").children.length, OUTFITS.length);
assert.equal(root.find("data-field", "accent").children.length, ACCENTS.length);
assert.equal(root.find("data-field", "hair").children.length, HAIR_COLORS.length);
assert.equal(root.find("data-field", "bodyShape").children.length, BODY_SHAPES.length);
assert.equal(editor.state().open, false);
});
it("opens with focus, edits preview data, saves, and restores invoker focus", () => {
const { document, editor, previews, saves } = setup();
const trigger = document.createElement("button");
trigger.focus();
editor.open();
const root = editor.root as unknown as FakeElement;
const name = root.find("data-field", "displayName");
assert.equal(document.activeElement, name);
assert.equal(root.getAttribute("aria-hidden"), "false");
name.value = "Avery Chen";
name.dispatch("input");
const outfit = root.find("data-field", "outfit");
outfit.value = "sage";
outfit.dispatch("change");
assert.equal(editor.state().draft.displayName, "Avery Chen");
assert.equal(editor.state().draft.appearance.outfit, "sage");
assert.equal(editor.state().dirty, true);
assert.equal(previews.at(-1)?.appearance.outfit, "sage");
root.find("data-action", "save").dispatch("click");
assert.equal(saves.length, 1);
assert.equal(saves[0]?.displayName, "Avery Chen");
assert.equal(editor.state().open, false);
assert.equal(document.activeElement, trigger);
});
it("blocks invalid names and exposes accessible validation", () => {
const { document, editor, saves } = setup();
editor.open();
const root = editor.root as unknown as FakeElement;
const name = root.find("data-field", "displayName");
const save = root.find("data-action", "save");
name.value = " ";
name.dispatch("input");
assert.equal(editor.state().valid, false);
assert.equal(name.getAttribute("aria-invalid"), "true");
assert.equal(save.disabled, true);
// Form submit covers Enter/assistive submit even though a disabled click cannot fire in a browser.
root.descendants("form")[0]?.dispatch("submit");
assert.equal(saves.length, 0);
assert.equal(document.activeElement, name);
});
it("resets appearance deterministically and Escape cancels the draft", () => {
const profile = createDefaultLocalProfile("some-other-id", "River");
const { editor, previews, cancels } = setup(profile);
editor.open();
const root = editor.root as unknown as FakeElement;
const outfit = root.find("data-field", "outfit");
outfit.value = outfit.value === "ink" ? "clay" : "ink";
outfit.dispatch("change");
const name = root.find("data-field", "displayName");
name.value = "River Two";
name.dispatch("input");
root.find("data-action", "reset").dispatch("click");
assert.deepEqual(editor.state().draft.appearance, createDefaultLocalProfile("member-22", "River").appearance);
assert.equal(editor.state().draft.displayName, "River Two");
const event = root.dispatch("keydown", { key: "Escape" });
assert.equal(event.defaultPrevented, true);
assert.equal(cancels(), 1);
assert.deepEqual(editor.state().draft, profile);
assert.deepEqual(previews.at(-1), profile);
});
it("traps keyboard focus and supports command-enter save", () => {
const { document, editor, saves } = setup();
editor.open();
const root = editor.root as unknown as FakeElement;
const first = root.find("data-field", "displayName");
const last = root.find("data-action", "save");
last.focus();
assert.equal(root.dispatch("keydown", { key: "Tab" }).defaultPrevented, true);
assert.equal(document.activeElement, first);
first.focus();
assert.equal(root.dispatch("keydown", { key: "Tab", shiftKey: true }).defaultPrevented, true);
assert.equal(document.activeElement, last);
assert.equal(root.dispatch("keydown", { key: "Enter", ctrlKey: true }).defaultPrevented, true);
assert.equal(saves.length, 1);
});
it("strictly updates caller data, then disposes without leaving DOM", () => {
const { container, editor, previews } = setup();
const next = createDefaultLocalProfile("new-person", "Morgan");
const updated = editor.update(next);
assert.deepEqual(updated.profile, next);
assert.deepEqual(updated.draft, next);
assert.deepEqual(previews.at(-1), next);
assert.throws(() => editor.update({ ...next, displayName: "" }), RangeError);
editor.open();
editor.dispose();
editor.dispose();
assert.equal(container.children.length, 0);
});
});
+108
View File
@@ -0,0 +1,108 @@
import assert from "node:assert/strict";
import { describe, it } from "node:test";
import {
createWebcamFaceConsent,
createWebcamFaceTexture,
type WebcamFaceTextureState,
} from "../profile/index.ts";
function media() {
let stops = 0;
let pauses = 0;
const track = {
kind: "video",
stop: () => { stops += 1; },
} as unknown as MediaStreamTrack;
const stream = {
getVideoTracks: () => [track],
getTracks: () => [track],
} as unknown as MediaStream;
const video = {
srcObject: stream,
pause: () => { pauses += 1; },
} as unknown as HTMLVideoElement;
return { stream, video, stops: () => stops, pauses: () => pauses };
}
describe("ephemeral webcam face texture", () => {
it("is off by default and refuses media before explicit active consent", () => {
const consent = createWebcamFaceConsent();
const adapter = createWebcamFaceTexture({ consent });
const input = media();
assert.deepEqual(adapter.state(), {
status: "off",
active: false,
indicatorVisible: false,
consentRevision: 0,
ephemeral: true,
persistence: "none",
recording: false,
uploading: false,
});
assert.throws(() => adapter.bind(input), /active consent/);
assert.equal(adapter.texture(), null);
adapter.dispose();
});
it("accepts only caller-attached video after consent and owns only its texture", () => {
const consent = createWebcamFaceConsent();
consent.requestStart();
consent.start(true);
const input = media();
const wrongVideo = { srcObject: null } as HTMLVideoElement;
const adapter = createWebcamFaceTexture({ consent });
assert.throws(() => adapter.bind({ stream: input.stream, video: wrongVideo }), /attach/);
const texture = adapter.bind(input);
let textureDisposals = 0;
const dispose = texture.dispose.bind(texture);
texture.dispose = () => { textureDisposals += 1; dispose(); };
assert.equal(adapter.texture(), texture);
assert.equal(adapter.state().active, true);
adapter.clear();
assert.equal(textureDisposals, 1);
assert.equal(adapter.texture(), null);
assert.equal(input.stops(), 0, "caller-owned track was stopped");
assert.equal(input.pauses(), 0, "caller-owned video was paused");
adapter.dispose();
});
it("clears on stop/revoke and publishes defensive active-indicator transitions", () => {
const consent = createWebcamFaceConsent();
consent.requestStart();
consent.start(true);
const input = media();
const indicators: WebcamFaceTextureState[] = [];
const adapter = createWebcamFaceTexture({
consent,
onIndicatorChange: (state) => {
state.status = "disposed";
indicators.push({ ...state });
},
});
adapter.bind(input);
assert.equal(adapter.state().status, "active", "callback mutation escaped into adapter");
consent.stop();
assert.equal(adapter.sync().active, false);
assert.equal(adapter.texture(), null);
assert.equal(input.stops(), 0);
assert.deepEqual(indicators.map((state) => state.indicatorVisible), [true, false]);
adapter.dispose();
});
it("is idempotent after disposal and rejects reuse", () => {
const consent = createWebcamFaceConsent();
consent.requestStart();
consent.start(true);
const input = media();
const adapter = createWebcamFaceTexture({ consent });
adapter.bind(input);
adapter.dispose();
adapter.dispose();
assert.equal(adapter.state().status, "disposed");
assert.equal(adapter.texture(), null);
assert.throws(() => adapter.bind(input), /disposed/);
assert.equal(input.stops(), 0);
assert.equal(input.pauses(), 0);
});
});