feat: add character studio and aircraft foundation
This commit is contained in:
@@ -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();
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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
@@ -65,7 +65,11 @@ import type {
|
||||
} from "./types.ts";
|
||||
import { World, type FieldProgress } from "./world.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 {
|
||||
city: City;
|
||||
@@ -183,6 +187,8 @@ export interface SceneHandle {
|
||||
vehicleCamera(): VehicleCameraMode | null;
|
||||
setActorActions(actions: Partial<ActorActionSnapshot>): void;
|
||||
actorState(): Readonly<ActorControllerSnapshot> | null;
|
||||
/** Replace the visible local actor profile without disturbing its position or controls. */
|
||||
setActorIdentity(identity: ActorIdentity): void;
|
||||
setActorActive(active: boolean): void;
|
||||
actorActive(): boolean;
|
||||
setMarkers(markers: Marker[]): void;
|
||||
@@ -580,6 +586,7 @@ export async function createScene(
|
||||
vehicleCamera: () => roadTraffic?.cameraMode() ?? null,
|
||||
setActorActions: (actions) => { sceneActor?.setActions(actions); },
|
||||
actorState: () => sceneActor?.state() ?? null,
|
||||
setActorIdentity: (identity) => { sceneActor?.setIdentity(identity); },
|
||||
setActorActive(active) {
|
||||
sceneActor?.setActive(active);
|
||||
if (active) roadTraffic?.setFollowing(false);
|
||||
|
||||
@@ -87,6 +87,12 @@ import {
|
||||
type OfficeWalker,
|
||||
type OfficeWalkerOptions,
|
||||
} from "./officeWalker.ts";
|
||||
import {
|
||||
createOfficeMediaPresentation,
|
||||
type MediaSurfaceDescriptor,
|
||||
type MediaSurfaceGrant,
|
||||
type OfficeMediaPresentation,
|
||||
} from "../media/presentation.ts";
|
||||
import {
|
||||
createRobotLayer,
|
||||
type RobotLayer,
|
||||
@@ -238,6 +244,11 @@ export interface OfficeScene extends StageScene {
|
||||
depth: Depth;
|
||||
/** Local walk-mode actor, or null when this scene was built as dollhouse-only. */
|
||||
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. */
|
||||
views: View[];
|
||||
flyTo(viewId: string): void;
|
||||
@@ -474,6 +485,8 @@ export function createOfficeScene(office: Office, options: OfficeSceneOptions):
|
||||
...(options.registry ? { registry: options.registry } : {}),
|
||||
...(options.colorFor ? { colorFor: options.colorFor } : {}),
|
||||
});
|
||||
const mediaSurfaces: OfficeMediaPresentation = createOfficeMediaPresentation(plan, furnishings.group);
|
||||
scene.add(mediaSurfaces.group);
|
||||
|
||||
/**
|
||||
* The ceiling, made switchable.
|
||||
@@ -765,6 +778,9 @@ export function createOfficeScene(office: Office, options: OfficeSceneOptions):
|
||||
plan,
|
||||
depth,
|
||||
walker: officeWalker,
|
||||
listMediaSurfaces: () => mediaSurfaces.list(),
|
||||
bindMediaSurface: (screenId, grant, texture) => mediaSurfaces.bind(screenId, grant, texture),
|
||||
clearMediaSurface: (screenId) => mediaSurfaces.clear(screenId),
|
||||
views,
|
||||
// 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
|
||||
@@ -831,6 +847,7 @@ export function createOfficeScene(office: Office, options: OfficeSceneOptions):
|
||||
luminaires.tick(dt);
|
||||
},
|
||||
dispose() {
|
||||
mediaSurfaces.dispose();
|
||||
officeWalker?.dispose();
|
||||
robots?.dispose();
|
||||
luminaires.dispose();
|
||||
|
||||
+115
-24
@@ -71,12 +71,15 @@ import {
|
||||
} from "./journey/index.ts";
|
||||
import {
|
||||
actorKindForPresence,
|
||||
createProfileEditor,
|
||||
createDefaultLocalProfile,
|
||||
loadLocalProfile,
|
||||
resolveHumanoidAppearance,
|
||||
saveLocalProfile,
|
||||
type LocalProfile,
|
||||
type ProfileEditor,
|
||||
} 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
|
||||
* office and the instruments out of the entry chunk.
|
||||
@@ -133,6 +136,26 @@ function humanoidAppearance() {
|
||||
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 {
|
||||
const next = journeyReducer(journey, event);
|
||||
if (next === journey) return false;
|
||||
@@ -816,27 +839,14 @@ async function mountCity(id: string) {
|
||||
city: entry.city,
|
||||
actor: {
|
||||
kind: actorKindForPresence(access.subject !== null, "outdoors"),
|
||||
identity: {
|
||||
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,
|
||||
primaryColor: appearance.outfitColor,
|
||||
accentColor: appearance.accentColor,
|
||||
hairColor: appearance.hairColor,
|
||||
bodyShape: appearance.bodyShape,
|
||||
}
|
||||
: { primaryColor: "#151a20", accentColor: "#f2b134" };
|
||||
})(),
|
||||
},
|
||||
},
|
||||
identity: access.subject === null
|
||||
? {
|
||||
id: "anonymous",
|
||||
displayName: "Guest",
|
||||
authenticated: false,
|
||||
profile: { appearance: { primaryColor: "#11151a", accentColor: "#f2b134" } },
|
||||
}
|
||||
: signedInActorIdentity(),
|
||||
mode: access.subject === null ? "flight" : "ground",
|
||||
position: access.subject === null ? { y: 1_200 } : { y: 0 },
|
||||
minFlightAltitude: 20,
|
||||
@@ -1483,6 +1493,8 @@ const driveHint = document.querySelector<HTMLElement>("#drive-hint");
|
||||
const walkButton = document.querySelector<HTMLButtonElement>("#walk");
|
||||
const walkControls = document.querySelector<HTMLElement>("#walk-controls");
|
||||
const walkHint = document.querySelector<HTMLElement>("#walk-hint");
|
||||
const profileOverlay = document.querySelector<HTMLElement>("#profile-overlay");
|
||||
let profileEditor: ProfileEditor | null = null;
|
||||
|
||||
function showDetail(text: string | null) {
|
||||
const card = document.querySelector<HTMLElement>("#detail");
|
||||
@@ -1777,12 +1789,20 @@ function renderCredits() {
|
||||
function renderOfficeBadge() {
|
||||
if (!officeBadge) return;
|
||||
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
|
||||
// `#source` — see `renderSource`. This badge keeps the message that is a call
|
||||
// to action rather than a disclosure, because that one belongs beside the
|
||||
// office controls and survives being missed; the other one does not.
|
||||
officeBadge.hidden = !publicOffice;
|
||||
if (!publicOffice) return;
|
||||
officeBadge.hidden = !publicOffice && mediaSurfaces.length === 0;
|
||||
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(
|
||||
document.createTextNode("Public view — the building, not the people. "),
|
||||
);
|
||||
@@ -1823,8 +1843,16 @@ function renderTierBadge() {
|
||||
if (access.subject !== null) {
|
||||
const who = document.createElement("span");
|
||||
who.className = "who";
|
||||
who.textContent = access.subject;
|
||||
who.textContent = localProfile?.displayName ?? access.subject;
|
||||
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) {
|
||||
const link = document.createElement("a");
|
||||
link.href = access.signInUrl;
|
||||
@@ -1834,6 +1862,68 @@ function renderTierBadge() {
|
||||
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 -------------------------------------------------------------
|
||||
|
||||
/** The views on offer right now — city chapters, or office viewpoints inside. */
|
||||
@@ -2257,6 +2347,7 @@ window.addEventListener("keydown", (event) => {
|
||||
const target = event.target;
|
||||
if (
|
||||
target instanceof HTMLInputElement ||
|
||||
target instanceof HTMLSelectElement ||
|
||||
target instanceof HTMLTextAreaElement ||
|
||||
(target instanceof HTMLElement && target.isContentEditable)
|
||||
) {
|
||||
|
||||
@@ -11,3 +11,9 @@ export {
|
||||
type MediaSurfaceBinding,
|
||||
type MediaSurfaceLifecycle,
|
||||
} from "./lifecycle.ts";
|
||||
export {
|
||||
createOfficeMediaPresentation,
|
||||
type MediaSurfaceDescriptor,
|
||||
type MediaSurfaceGrant,
|
||||
type OfficeMediaPresentation,
|
||||
} from "./presentation.ts";
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -49,3 +49,19 @@ export {
|
||||
type WebcamFaceConsentStatus,
|
||||
type WebcamFaceConsentTransition,
|
||||
} 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";
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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/);
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user