1
0

feat: add character studio and aircraft foundation

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