diff --git a/index.html b/index.html index d732b3f..7f324a6 100644 --- a/index.html +++ b/index.html @@ -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 @@

+ + diff --git a/src/aircraft/asset.ts b/src/aircraft/asset.ts new file mode 100644 index 0000000..47ca5ed --- /dev/null +++ b/src/aircraft/asset.ts @@ -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, +): 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(); + const materials = new Set(); + 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(); +} diff --git a/src/aircraft/controller.ts b/src/aircraft/controller.ts new file mode 100644 index 0000000..52d6eb0 --- /dev/null +++ b/src/aircraft/controller.ts @@ -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 = 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 = + Object.freeze({ + minLat: 32.4, + maxLat: 42.1, + minLng: -124.6, + maxLng: -114.0, + minAltitudeM: 75, + maxAltitudeM: 6_000, + }); + +export interface AircraftControllerOptions { + initialPosition?: Partial; + 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; +} + +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(); + 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 | 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 { + 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 = 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 = 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, + 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, + }; +} diff --git a/src/aircraft/index.ts b/src/aircraft/index.ts new file mode 100644 index 0000000..713f9cf --- /dev/null +++ b/src/aircraft/index.ts @@ -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"; diff --git a/src/engine/scene.ts b/src/engine/scene.ts index 6edc843..49d4890 100644 --- a/src/engine/scene.ts +++ b/src/engine/scene.ts @@ -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): void; actorState(): Readonly | 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); diff --git a/src/interiors/officeScene.ts b/src/interiors/officeScene.ts index 07b87f1..ebcbbeb 100644 --- a/src/interiors/officeScene.ts +++ b/src/interiors/officeScene.ts @@ -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(); diff --git a/src/main.ts b/src/main.ts index f79017e..853f6ee 100644 --- a/src/main.ts +++ b/src/main.ts @@ -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("#drive-hint"); const walkButton = document.querySelector("#walk"); const walkControls = document.querySelector("#walk-controls"); const walkHint = document.querySelector("#walk-hint"); +const profileOverlay = document.querySelector("#profile-overlay"); +let profileEditor: ProfileEditor | null = null; function showDetail(text: string | null) { const card = document.querySelector("#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 { + 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) ) { diff --git a/src/media/index.ts b/src/media/index.ts index b77274d..05f1485 100644 --- a/src/media/index.ts +++ b/src/media/index.ts @@ -11,3 +11,9 @@ export { type MediaSurfaceBinding, type MediaSurfaceLifecycle, } from "./lifecycle.ts"; +export { + createOfficeMediaPresentation, + type MediaSurfaceDescriptor, + type MediaSurfaceGrant, + type OfficeMediaPresentation, +} from "./presentation.ts"; diff --git a/src/media/presentation.ts b/src/media/presentation.ts new file mode 100644 index 0000000..57d629e --- /dev/null +++ b/src/media/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(); + 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, + }); +} + diff --git a/src/profile/editor.ts b/src/profile/editor.ts new file mode 100644 index 0000000..9d72589 --- /dev/null +++ b/src/profile/editor.ts @@ -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> = { + skinTone: SKIN_TONES, + outfit: OUTFITS, + accent: ACCENTS, + hair: HAIR_COLORS, + bodyShape: BODY_SHAPES, +}; + +const FIELD_LABELS: Readonly> = { + 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; + 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; +} diff --git a/src/profile/index.ts b/src/profile/index.ts index 45794d5..f7d176f 100644 --- a/src/profile/index.ts +++ b/src/profile/index.ts @@ -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"; diff --git a/src/profile/webcamFaceTexture.ts b/src/profile/webcamFaceTexture.ts new file mode 100644 index 0000000..0edec50 --- /dev/null +++ b/src/profile/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"); + } +} + diff --git a/src/test/aircraft.test.ts b/src/test/aircraft.test.ts new file mode 100644 index 0000000..0e10afa --- /dev/null +++ b/src/test/aircraft.test.ts @@ -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/); + }); +}); diff --git a/src/test/mediaPresentation.test.ts b/src/test/mediaPresentation.test.ts new file mode 100644 index 0000000..92ba2b6 --- /dev/null +++ b/src/test/mediaPresentation.test.ts @@ -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(); + }); +}); diff --git a/src/test/profileEditor.test.ts b/src/test/profileEditor.test.ts new file mode 100644 index 0000000..ae5fc00 --- /dev/null +++ b/src/test/profileEditor.test.ts @@ -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(); + readonly listeners = new Map(); + 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> = {}): 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); + }); +}); diff --git a/src/test/webcamFaceTexture.test.ts b/src/test/webcamFaceTexture.test.ts new file mode 100644 index 0000000..0e5eade --- /dev/null +++ b/src/test/webcamFaceTexture.test.ts @@ -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); + }); +}); +