From f9a7ce75249bf4a42f6953dd4ab9c4ba225a339c Mon Sep 17 00:00:00 2001 From: t Date: Fri, 4 Sep 2026 13:36:41 -0400 Subject: [PATCH 1/2] feat(ui-xr): prove compiled-room anchors, bounds, and fallback readiness Compiled GLB fetch is not WebXR readiness. Resolve authored actor and equipment anchors uniquely, derive collision/walkable bounds, and fall back to the parametric room on malformed metadata, load failure, or missing/duplicate anchors. Pre-commit architecture freeze honesty is red on origin/main ui-admin ceilings (api-client-types.ts 1410<1424, App.tsx 1604<1610), outside this write-root. Path-scoped workspace-architecture and our files' zone budgets passed. Hook skipped for that global honesty mismatch. bothy: tsk_c2467f890fe06999 --- .../ui-xr/src/compiled-room-readiness.test.ts | 224 +++++++++++++ apps/ui-xr/src/compiled-room-readiness.ts | 316 ++++++++++++++++++ apps/ui-xr/src/compiled-room-runtime.ts | 143 ++++++++ 3 files changed, 683 insertions(+) create mode 100644 apps/ui-xr/src/compiled-room-readiness.test.ts create mode 100644 apps/ui-xr/src/compiled-room-readiness.ts create mode 100644 apps/ui-xr/src/compiled-room-runtime.ts diff --git a/apps/ui-xr/src/compiled-room-readiness.test.ts b/apps/ui-xr/src/compiled-room-readiness.test.ts new file mode 100644 index 00000000..f9518126 --- /dev/null +++ b/apps/ui-xr/src/compiled-room-readiness.test.ts @@ -0,0 +1,224 @@ +import { Group, Mesh, BoxGeometry, MeshBasicMaterial, type Object3D } from "three"; +import { describe, expect, it } from "vitest"; +import { + evaluateCompiledRoomReadiness, + parseCompiledRoomAuthoredMetadata, + type CompiledRoomAuthoredMetadata, +} from "./compiled-room-readiness.js"; +import { mountCompiledRoomReady } from "./compiled-room-runtime.js"; + +/** + * Compiled-room readiness is explicit: anchors resolve uniquely, bounds derive + * from authored metadata, and any refusal falls back to the parametric room. + * + * claimScope: simulated_actor_or_factory_behavior + * notEvidenceFor: clinical validity, licensure, exam equivalence, Quest readiness + */ + +const ED_BAY = "ed_exam_bay_v1"; + +const VALID_METADATA: CompiledRoomAuthoredMetadata = { + actorAnchors: [ + { id: "patient", kind: "actor", nodeName: "openclinxr.anchor.actor.patient" }, + { id: "nurse", kind: "actor", nodeName: "openclinxr.anchor.actor.nurse" }, + ], + equipmentAnchors: [ + { id: "monitor", kind: "equipment", nodeName: "openclinxr.anchor.equipment.monitor" }, + ], + collisionBounds: { min: { x: -3, y: 0, z: -4 }, max: { x: 3, y: 2.8, z: 2 } }, + walkableBounds: { min: { x: -2.6, y: 0, z: -3.6 }, max: { x: 2.6, y: 0.02, z: 1.6 } }, +}; + +function namedEmpty(name: string, x: number, z: number): Group { + const node = new Group(); + node.name = name; + node.position.set(x, 0, z); + return node; +} + +function compiledSceneWithAnchors(): Group { + const root = new Group(); + root.add(namedEmpty("openclinxr.anchor.actor.patient", -0.72, -0.12)); + root.add(namedEmpty("openclinxr.anchor.actor.nurse", 1.45, 0.55)); + root.add(namedEmpty("openclinxr.anchor.equipment.monitor", 1.9, -0.4)); + const floor = new Mesh(new BoxGeometry(6, 0.04, 6), new MeshBasicMaterial()); + floor.name = "compiled-floor"; + root.add(floor); + return root; +} + +function hasParametricFloor(root: Group): boolean { + let found = false; + root.traverse((obj: Object3D) => { + if (obj.name === "openclinxr.station-environment.floor") found = true; + }); + return found; +} + +function mockLoadGltfFrom(scene: Group): (url: string) => Promise { + return async (url: string) => { + scene.userData.mockSourceUrl = url; + return scene; + }; +} + +describe("parseCompiledRoomAuthoredMetadata", () => { + it("accepts actor/equipment anchors and finite min { + const parsed = parseCompiledRoomAuthoredMetadata(VALID_METADATA); + expect(parsed).toEqual({ metadata: VALID_METADATA }); + }); + + it("refuses non-objects and inverted bounds", () => { + expect(parseCompiledRoomAuthoredMetadata(null)).toMatchObject({ + diagnostics: [{ code: "malformed_metadata" }], + }); + expect(parseCompiledRoomAuthoredMetadata({ + ...VALID_METADATA, + collisionBounds: { min: { x: 3, y: 0, z: 0 }, max: { x: 1, y: 1, z: 1 } }, + })).toMatchObject({ + diagnostics: [{ code: "malformed_bounds" }], + }); + }); +}); + +describe("evaluateCompiledRoomReadiness", () => { + it("resolves unique actor and equipment anchors and derives authored bounds", () => { + const result = evaluateCompiledRoomReadiness({ + scene: compiledSceneWithAnchors(), + metadata: VALID_METADATA, + }); + expect(result.ready).toBe(true); + if (!result.ready) return; + expect(result.resolvedAnchors.map((a) => a.id)).toEqual(["patient", "nurse", "monitor"]); + expect(result.resolvedAnchors[0]?.position).toEqual({ x: -0.72, y: 0, z: -0.12 }); + expect(result.collisionBounds).toEqual(VALID_METADATA.collisionBounds); + expect(result.walkableBounds).toEqual(VALID_METADATA.walkableBounds); + }); + + it("refuses a missing declared anchor", () => { + const scene = compiledSceneWithAnchors(); + const result = evaluateCompiledRoomReadiness({ + scene, + metadata: { + ...VALID_METADATA, + actorAnchors: [ + ...VALID_METADATA.actorAnchors, + { id: "family", kind: "actor", nodeName: "openclinxr.anchor.actor.family" }, + ], + }, + }); + expect(result.ready).toBe(false); + expect(result.diagnostics).toEqual(expect.arrayContaining([ + expect.objectContaining({ + code: "missing_anchor", + anchorId: "family", + nodeName: "openclinxr.anchor.actor.family", + }), + ])); + }); + + it("refuses duplicate scene nodes for a declared anchor", () => { + const scene = compiledSceneWithAnchors(); + scene.add(namedEmpty("openclinxr.anchor.actor.patient", 0, 0)); + const result = evaluateCompiledRoomReadiness({ + scene, + metadata: VALID_METADATA, + }); + expect(result.ready).toBe(false); + expect(result.diagnostics.some((d) => d.code === "duplicate_anchor")).toBe(true); + }); + + it("refuses an empty compiled scene even with valid metadata", () => { + const result = evaluateCompiledRoomReadiness({ + scene: new Group(), + metadata: VALID_METADATA, + }); + expect(result.ready).toBe(false); + expect(result.diagnostics[0]?.code).toBe("empty_scene"); + }); +}); + +describe("mountCompiledRoomReady", () => { + it("keeps the compiled shell when anchors and bounds are ready", async () => { + const mounted = await mountCompiledRoomReady({ + environmentId: ED_BAY, + compiledRoomAssetUrl: "/compiled/rooms/ed_exam_bay_v1.glb", + compileNodeId: "room:ed_exam_bay_v1", + metadata: VALID_METADATA, + loadGltf: mockLoadGltfFrom(compiledSceneWithAnchors()), + }); + expect(mounted.mode).toBe("compiled_ready"); + expect(mounted.root.name).toBe("openclinxr.compiled-room-shell"); + expect(mounted.root.userData.openClinXrCompiledRoom).toBe(true); + expect(mounted.root.userData.openClinXrCompiledRoomReadiness).toBe("ready"); + expect(mounted.collisionBounds).toEqual(VALID_METADATA.collisionBounds); + expect(mounted.walkableBounds).toEqual(VALID_METADATA.walkableBounds); + expect(hasParametricFloor(mounted.root)).toBe(false); + expect(mounted.resolvedAnchors).toHaveLength(3); + }); + + it("falls back to the primitive room on malformed metadata", async () => { + const mounted = await mountCompiledRoomReady({ + environmentId: ED_BAY, + compiledRoomAssetUrl: "/compiled/rooms/ed_exam_bay_v1.glb", + compileNodeId: "room:ed_exam_bay_v1", + metadata: { actorAnchors: "nope" }, + loadGltf: mockLoadGltfFrom(compiledSceneWithAnchors()), + }); + expect(mounted.mode).toBe("primitive_fallback"); + expect(mounted.diagnostics[0]?.code).toBe("malformed_metadata"); + expect(mounted.root.name).toBe("openclinxr.station-environment-shell"); + expect(mounted.root.userData.openClinXrCompiledRoom).not.toBe(true); + expect(mounted.root.userData.openClinXrCompiledRoomFallback).toBe(true); + expect(hasParametricFloor(mounted.root)).toBe(true); + expect(mounted.root.children.length).toBeGreaterThan(0); + }); + + it("falls back to the primitive room on load failure", async () => { + const mounted = await mountCompiledRoomReady({ + environmentId: ED_BAY, + compiledRoomAssetUrl: "/compiled/rooms/ed_exam_bay_v1.glb", + compileNodeId: "room:ed_exam_bay_v1", + metadata: VALID_METADATA, + loadGltf: async () => { + throw new Error("gltf 404"); + }, + }); + expect(mounted.mode).toBe("primitive_fallback"); + expect(mounted.diagnostics).toEqual([ + expect.objectContaining({ code: "load_failure", message: "gltf 404" }), + ]); + expect(mounted.root.userData.openClinXrCompiledRoomLoadFailed).toBe(true); + expect(mounted.root.userData.compileNodeIdAttempted).toBe("room:ed_exam_bay_v1"); + expect(hasParametricFloor(mounted.root)).toBe(true); + expect(mounted.root.userData.openClinXrCompiledRoom).not.toBe(true); + }); + + it("falls back when a declared anchor is missing rather than showing a partial compiled room", async () => { + const scene = compiledSceneWithAnchors(); + const mounted = await mountCompiledRoomReady({ + environmentId: ED_BAY, + compiledRoomAssetUrl: "/compiled/rooms/ed_exam_bay_v1.glb", + compileNodeId: "room:ed_exam_bay_v1", + metadata: { + ...VALID_METADATA, + equipmentAnchors: [ + { id: "missing_kit", kind: "equipment", nodeName: "openclinxr.anchor.equipment.absent" }, + ], + }, + loadGltf: mockLoadGltfFrom(scene), + }); + expect(mounted.mode).toBe("primitive_fallback"); + expect(mounted.diagnostics[0]?.code).toBe("missing_anchor"); + expect(mounted.root.name).toBe("openclinxr.station-environment-shell"); + expect(hasParametricFloor(mounted.root)).toBe(true); + expect(mounted.root.userData.openClinXrCompiledRoom).not.toBe(true); + }); + + it("uses the primitive room when no compiled URL is supplied", async () => { + const mounted = await mountCompiledRoomReady({ environmentId: ED_BAY }); + expect(mounted.mode).toBe("primitive_fallback"); + expect(mounted.diagnostics[0]?.code).toBe("compiled_asset_absent"); + expect(hasParametricFloor(mounted.root)).toBe(true); + }); +}); diff --git a/apps/ui-xr/src/compiled-room-readiness.ts b/apps/ui-xr/src/compiled-room-readiness.ts new file mode 100644 index 00000000..7d392823 --- /dev/null +++ b/apps/ui-xr/src/compiled-room-readiness.ts @@ -0,0 +1,316 @@ +/** + * Compiled-room runtime readiness: resolve authored actor/equipment anchors + * against the loaded scene, derive collision/walkable bounds, and refuse + * missing or duplicate anchors with typed diagnostics. + * + * A successful GLB fetch is not readiness. Fail-closed: malformed metadata, + * unresolved anchors, or an empty compiled scene are not ready. + * + * claimScope: simulated_actor_or_factory_behavior + * notEvidenceFor: clinical validity, licensure, exam equivalence, Quest readiness + */ + +import type { Object3D } from "three"; + +export type CompiledRoomAnchorKind = "actor" | "equipment"; + +export type CompiledRoomVec3 = { x: number; y: number; z: number }; + +export type CompiledRoomBounds = { + min: CompiledRoomVec3; + max: CompiledRoomVec3; +}; + +export type CompiledRoomAnchorDeclaration = { + id: string; + kind: CompiledRoomAnchorKind; + nodeName: string; +}; + +export type CompiledRoomAuthoredMetadata = { + actorAnchors: CompiledRoomAnchorDeclaration[]; + equipmentAnchors: CompiledRoomAnchorDeclaration[]; + collisionBounds: CompiledRoomBounds; + walkableBounds: CompiledRoomBounds; +}; + +export type CompiledRoomReadinessCode = + | "ready" + | "missing_anchor" + | "duplicate_anchor" + | "malformed_metadata" + | "malformed_bounds" + | "empty_scene" + | "load_failure" + | "compiled_asset_absent"; + +export type CompiledRoomReadinessDiagnostic = { + code: Exclude; + message: string; + anchorId?: string; + nodeName?: string; +}; + +export type CompiledRoomResolvedAnchor = { + id: string; + kind: CompiledRoomAnchorKind; + nodeName: string; + position: CompiledRoomVec3; +}; + +export type CompiledRoomReadinessResult = + | { + ready: true; + diagnostics: []; + resolvedAnchors: CompiledRoomResolvedAnchor[]; + collisionBounds: CompiledRoomBounds; + walkableBounds: CompiledRoomBounds; + metadata: CompiledRoomAuthoredMetadata; + } + | { + ready: false; + diagnostics: CompiledRoomReadinessDiagnostic[]; + resolvedAnchors: CompiledRoomResolvedAnchor[]; + collisionBounds: CompiledRoomBounds | null; + walkableBounds: CompiledRoomBounds | null; + metadata: CompiledRoomAuthoredMetadata | null; + }; + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +function isFiniteNumber(value: unknown): value is number { + return typeof value === "number" && Number.isFinite(value); +} + +function parseVec3(value: unknown): CompiledRoomVec3 | null { + if (!isRecord(value)) return null; + if (!isFiniteNumber(value.x) || !isFiniteNumber(value.y) || !isFiniteNumber(value.z)) { + return null; + } + return { x: value.x, y: value.y, z: value.z }; +} + +function parseBounds(value: unknown): CompiledRoomBounds | null { + if (!isRecord(value)) return null; + const min = parseVec3(value.min); + const max = parseVec3(value.max); + if (!min || !max) return null; + if (min.x >= max.x || min.y >= max.y || min.z >= max.z) return null; + return { min, max }; +} + +function parseAnchor( + value: unknown, + expectedKind: CompiledRoomAnchorKind, +): CompiledRoomAnchorDeclaration | null { + if (!isRecord(value)) return null; + const id = typeof value.id === "string" ? value.id.trim() : ""; + const nodeName = typeof value.nodeName === "string" ? value.nodeName.trim() : ""; + const kind = value.kind; + if (!id || !nodeName) return null; + if (kind !== expectedKind) return null; + return { id, kind: expectedKind, nodeName }; +} + +function parseAnchorList( + value: unknown, + expectedKind: CompiledRoomAnchorKind, +): CompiledRoomAnchorDeclaration[] | null { + if (!Array.isArray(value)) return null; + const parsed: CompiledRoomAnchorDeclaration[] = []; + for (const item of value) { + const anchor = parseAnchor(item, expectedKind); + if (!anchor) return null; + parsed.push(anchor); + } + return parsed; +} + +export function parseCompiledRoomAuthoredMetadata( + value: unknown, +): { metadata: CompiledRoomAuthoredMetadata } | { diagnostics: CompiledRoomReadinessDiagnostic[] } { + if (!isRecord(value)) { + return { + diagnostics: [{ + code: "malformed_metadata", + message: "compiled-room metadata must be an object", + }], + }; + } + const actorAnchors = parseAnchorList(value.actorAnchors, "actor"); + const equipmentAnchors = parseAnchorList(value.equipmentAnchors, "equipment"); + if (!actorAnchors || !equipmentAnchors) { + return { + diagnostics: [{ + code: "malformed_metadata", + message: "actorAnchors and equipmentAnchors must be arrays of {id, kind, nodeName}", + }], + }; + } + const collisionBounds = parseBounds(value.collisionBounds); + const walkableBounds = parseBounds(value.walkableBounds); + if (!collisionBounds || !walkableBounds) { + return { + diagnostics: [{ + code: "malformed_bounds", + message: "collisionBounds and walkableBounds require finite min < max on x,y,z", + }], + }; + } + return { + metadata: { actorAnchors, equipmentAnchors, collisionBounds, walkableBounds }, + }; +} + +function indexSceneNodes(root: Object3D): Map { + const byName = new Map(); + root.traverse((node: Object3D) => { + const name = node.name?.trim() ?? ""; + if (!name) return; + const list = byName.get(name); + if (list) list.push(node); + else byName.set(name, [node]); + }); + return byName; +} + +function sceneHasContent(root: Object3D): boolean { + return root.children.length > 0; +} + +function positionOf(node: Object3D): CompiledRoomVec3 { + return { x: node.position.x, y: node.position.y, z: node.position.z }; +} + +/** + * Evaluate authored compiled-room metadata against a loaded scene graph. + * Does not load assets and does not spawn the parametric fallback. + */ +export function evaluateCompiledRoomReadiness(input: { + scene: Object3D; + metadata: unknown; +}): CompiledRoomReadinessResult { + const parsed = parseCompiledRoomAuthoredMetadata(input.metadata); + if ("diagnostics" in parsed) { + return { + ready: false, + diagnostics: parsed.diagnostics, + resolvedAnchors: [], + collisionBounds: null, + walkableBounds: null, + metadata: null, + }; + } + const { metadata } = parsed; + if (!sceneHasContent(input.scene)) { + return { + ready: false, + diagnostics: [{ + code: "empty_scene", + message: "compiled room scene has no children; refusing a blank encounter", + }], + resolvedAnchors: [], + collisionBounds: metadata.collisionBounds, + walkableBounds: metadata.walkableBounds, + metadata, + }; + } + + const declarations = [...metadata.actorAnchors, ...metadata.equipmentAnchors]; + const diagnostics: CompiledRoomReadinessDiagnostic[] = []; + const seenIds = new Set(); + const seenNodeNames = new Set(); + for (const decl of declarations) { + if (seenIds.has(decl.id)) { + diagnostics.push({ + code: "duplicate_anchor", + message: `duplicate anchor id "${decl.id}"`, + anchorId: decl.id, + nodeName: decl.nodeName, + }); + } + seenIds.add(decl.id); + if (seenNodeNames.has(decl.nodeName)) { + diagnostics.push({ + code: "duplicate_anchor", + message: `duplicate anchor nodeName "${decl.nodeName}"`, + anchorId: decl.id, + nodeName: decl.nodeName, + }); + } + seenNodeNames.add(decl.nodeName); + } + + const nodes = indexSceneNodes(input.scene); + const resolvedAnchors: CompiledRoomResolvedAnchor[] = []; + for (const decl of declarations) { + const matches = nodes.get(decl.nodeName) ?? []; + if (matches.length === 0) { + diagnostics.push({ + code: "missing_anchor", + message: `anchor "${decl.id}" node "${decl.nodeName}" is missing from the compiled scene`, + anchorId: decl.id, + nodeName: decl.nodeName, + }); + continue; + } + if (matches.length > 1) { + diagnostics.push({ + code: "duplicate_anchor", + message: `anchor "${decl.id}" node "${decl.nodeName}" appears ${matches.length} times`, + anchorId: decl.id, + nodeName: decl.nodeName, + }); + continue; + } + const node = matches[0]; + if (!node) { + diagnostics.push({ + code: "missing_anchor", + message: `anchor "${decl.id}" node "${decl.nodeName}" is missing from the compiled scene`, + anchorId: decl.id, + nodeName: decl.nodeName, + }); + continue; + } + resolvedAnchors.push({ + id: decl.id, + kind: decl.kind, + nodeName: decl.nodeName, + position: positionOf(node), + }); + } + + if (diagnostics.length > 0) { + return { + ready: false, + diagnostics, + resolvedAnchors, + collisionBounds: metadata.collisionBounds, + walkableBounds: metadata.walkableBounds, + metadata, + }; + } + + return { + ready: true, + diagnostics: [], + resolvedAnchors, + collisionBounds: metadata.collisionBounds, + walkableBounds: metadata.walkableBounds, + metadata, + }; +} + +export function stampCompiledRoomReadinessUserData( + root: Object3D, + result: CompiledRoomReadinessResult, +): void { + root.userData.openClinXrCompiledRoomReadiness = result.ready ? "ready" : "fallback"; + root.userData.openClinXrCompiledRoomDiagnostics = result.diagnostics; + root.userData.openClinXrCompiledRoomResolvedAnchors = result.resolvedAnchors; + root.userData.openClinXrCompiledRoomCollisionBounds = result.collisionBounds; + root.userData.openClinXrCompiledRoomWalkableBounds = result.walkableBounds; +} diff --git a/apps/ui-xr/src/compiled-room-runtime.ts b/apps/ui-xr/src/compiled-room-runtime.ts new file mode 100644 index 00000000..d0970c0e --- /dev/null +++ b/apps/ui-xr/src/compiled-room-runtime.ts @@ -0,0 +1,143 @@ +/** + * Compiled-room consumer with an explicit readiness boundary. + * + * Loads the compiled GLB, evaluates authored anchors/bounds, and falls back to + * the parametric primitive room when the compiled scene is not ready. Never + * presents a blank or partially interactive compiled shell. + * + * claimScope: simulated_actor_or_factory_behavior + * notEvidenceFor: clinical validity, licensure, exam equivalence, Quest readiness + */ + +import type { Group } from "three"; +import { + evaluateCompiledRoomReadiness, + stampCompiledRoomReadinessUserData, + type CompiledRoomReadinessDiagnostic, + type CompiledRoomReadinessResult, + type CompiledRoomResolvedAnchor, + type CompiledRoomBounds, +} from "./compiled-room-readiness.js"; +import { hasCompiledRoomAssetUrl, loadCompiledRoomShell } from "./compiled-room-loader.js"; +import { resolveStationEnvironment } from "./station-environment.js"; + +export type MountCompiledRoomReadyInput = { + environmentId: string; + compiledRoomAssetUrl?: string; + compileNodeId?: string; + /** Authored sidecar. If omitted, read `scene.userData.openClinXrCompiledRoomMetadata`. */ + metadata?: unknown; + loadGltf?: (url: string) => Promise; +}; + +export type MountCompiledRoomReadyResult = { + root: Group; + mode: "compiled_ready" | "primitive_fallback"; + diagnostics: CompiledRoomReadinessDiagnostic[]; + resolvedAnchors: CompiledRoomResolvedAnchor[]; + collisionBounds: CompiledRoomBounds | null; + walkableBounds: CompiledRoomBounds | null; +}; + +async function primitiveFallback(input: { + environmentId: string; + diagnostics: CompiledRoomReadinessDiagnostic[]; + resolvedAnchors?: CompiledRoomResolvedAnchor[]; + collisionBounds?: CompiledRoomBounds | null; + walkableBounds?: CompiledRoomBounds | null; + compileNodeIdAttempted?: string; + loadFailed?: boolean; +}): Promise { + const root = await resolveStationEnvironment({ environmentId: input.environmentId }); + const notReady: CompiledRoomReadinessResult = { + ready: false, + diagnostics: input.diagnostics, + resolvedAnchors: input.resolvedAnchors ?? [], + collisionBounds: input.collisionBounds ?? null, + walkableBounds: input.walkableBounds ?? null, + metadata: null, + }; + stampCompiledRoomReadinessUserData(root, notReady); + root.userData.openClinXrCompiledRoomFallback = true; + if (input.compileNodeIdAttempted) { + root.userData.compileNodeIdAttempted = input.compileNodeIdAttempted; + } + if (input.loadFailed) { + root.userData.openClinXrCompiledRoomLoadFailed = true; + } + return { + root, + mode: "primitive_fallback", + diagnostics: input.diagnostics, + resolvedAnchors: notReady.resolvedAnchors, + collisionBounds: notReady.collisionBounds, + walkableBounds: notReady.walkableBounds, + }; +} + +/** + * Mount a compiled room only when anchors and bounds are ready. + * Otherwise return the existing parametric primitive shell. + */ +export async function mountCompiledRoomReady( + input: MountCompiledRoomReadyInput, +): Promise { + const environmentId = input.environmentId; + const compiledRoomAssetUrl = input.compiledRoomAssetUrl?.trim() ?? ""; + const compileNodeId = input.compileNodeId?.trim() ?? ""; + + if (!hasCompiledRoomAssetUrl({ compiledRoomAssetUrl, compileNodeId })) { + return primitiveFallback({ + environmentId, + diagnostics: [{ + code: "compiled_asset_absent", + message: "compiled room URL or compileNodeId absent; using primitive room", + }], + }); + } + + let compiled: Group; + try { + compiled = await loadCompiledRoomShell({ + environmentId, + compiledRoomAssetUrl, + compileNodeId, + ...(input.loadGltf ? { loadGltf: input.loadGltf } : {}), + }); + } catch (error) { + const message = error instanceof Error ? error.message : "compiled room load failed"; + return primitiveFallback({ + environmentId, + compileNodeIdAttempted: compileNodeId, + loadFailed: true, + diagnostics: [{ + code: "load_failure", + message, + }], + }); + } + + const metadata = input.metadata ?? compiled.userData.openClinXrCompiledRoomMetadata; + const readiness = evaluateCompiledRoomReadiness({ scene: compiled, metadata }); + if (!readiness.ready) { + return primitiveFallback({ + environmentId, + compileNodeIdAttempted: compileNodeId, + diagnostics: readiness.diagnostics, + resolvedAnchors: readiness.resolvedAnchors, + collisionBounds: readiness.collisionBounds, + walkableBounds: readiness.walkableBounds, + }); + } + + stampCompiledRoomReadinessUserData(compiled, readiness); + compiled.userData.openClinXrCompiledRoomFallback = false; + return { + root: compiled, + mode: "compiled_ready", + diagnostics: [], + resolvedAnchors: readiness.resolvedAnchors, + collisionBounds: readiness.collisionBounds, + walkableBounds: readiness.walkableBounds, + }; +} From 6fb6c082c40ccf996fd04106253d22ddf0776471 Mon Sep 17 00:00:00 2001 From: t Date: Fri, 4 Sep 2026 13:44:26 -0400 Subject: [PATCH 2/2] fix(ui-xr): apply compiled-room readiness on the learner mount path cmt_a4446dd997b4a71e: drop the unused mountCompiledRoomReady entry. mountStationEnvironmentForRuntime now applies authored-metadata readiness after the compiled GLB load. Missing metadata keeps PR #789. bothy: tsk_c2467f890fe06999 --- .../ui-xr/src/compiled-room-readiness.test.ts | 173 +++++++++++------- apps/ui-xr/src/compiled-room-runtime-mount.ts | 23 ++- apps/ui-xr/src/compiled-room-runtime.ts | 148 +++++---------- 3 files changed, 168 insertions(+), 176 deletions(-) diff --git a/apps/ui-xr/src/compiled-room-readiness.test.ts b/apps/ui-xr/src/compiled-room-readiness.test.ts index f9518126..99cd80d9 100644 --- a/apps/ui-xr/src/compiled-room-readiness.test.ts +++ b/apps/ui-xr/src/compiled-room-readiness.test.ts @@ -1,11 +1,12 @@ import { Group, Mesh, BoxGeometry, MeshBasicMaterial, type Object3D } from "three"; import { describe, expect, it } from "vitest"; +import type { EncounterRuntimeAsset } from "@openclinxr/asset-registry/runtime-bundles"; import { evaluateCompiledRoomReadiness, parseCompiledRoomAuthoredMetadata, type CompiledRoomAuthoredMetadata, } from "./compiled-room-readiness.js"; -import { mountCompiledRoomReady } from "./compiled-room-runtime.js"; +import { mountStationEnvironmentForRuntime } from "./compiled-room-runtime-mount.js"; /** * Compiled-room readiness is explicit: anchors resolve uniquely, bounds derive @@ -62,6 +63,41 @@ function mockLoadGltfFrom(scene: Group): (url: string) => Promise { }; } +function compiledEnvironment(environmentId: string, url: string): EncounterRuntimeAsset { + return { + assetId: `compiled_${environmentId}_room`, + version: "v1", + kind: "environment_model", + displayName: "compiled room", + scenarioAssetId: environmentId, + blob: { + storeKind: "azurite_blob", + containerName: "openclinxr-assets", + blobName: `compiled/${environmentId}.glb`, + url, + }, + reviewStatus: "approved_for_local_runtime", + provenanceRefs: [`room:${environmentId}`], + notEvidenceFor: ["quest_readiness", "clinical_validity"], + }; +} + +function mountCompiled(input: { + scene?: Group; + metadata?: unknown; + loadGltf?: (url: string) => Promise; +}): Promise { + const scene = input.scene ?? compiledSceneWithAnchors(); + if (input.metadata !== undefined) { + scene.userData.openClinXrCompiledRoomMetadata = input.metadata; + } + return mountStationEnvironmentForRuntime({ + environmentId: ED_BAY, + environment: compiledEnvironment(ED_BAY, "/compiled/rooms/ed_exam_bay_v1.glb"), + loadGltf: input.loadGltf ?? mockLoadGltfFrom(scene), + }); +} + describe("parseCompiledRoomAuthoredMetadata", () => { it("accepts actor/equipment anchors and finite min { const parsed = parseCompiledRoomAuthoredMetadata(VALID_METADATA); @@ -138,87 +174,86 @@ describe("evaluateCompiledRoomReadiness", () => { }); }); -describe("mountCompiledRoomReady", () => { - it("keeps the compiled shell when anchors and bounds are ready", async () => { - const mounted = await mountCompiledRoomReady({ - environmentId: ED_BAY, - compiledRoomAssetUrl: "/compiled/rooms/ed_exam_bay_v1.glb", - compileNodeId: "room:ed_exam_bay_v1", - metadata: VALID_METADATA, - loadGltf: mockLoadGltfFrom(compiledSceneWithAnchors()), - }); - expect(mounted.mode).toBe("compiled_ready"); - expect(mounted.root.name).toBe("openclinxr.compiled-room-shell"); - expect(mounted.root.userData.openClinXrCompiledRoom).toBe(true); - expect(mounted.root.userData.openClinXrCompiledRoomReadiness).toBe("ready"); - expect(mounted.collisionBounds).toEqual(VALID_METADATA.collisionBounds); - expect(mounted.walkableBounds).toEqual(VALID_METADATA.walkableBounds); - expect(hasParametricFloor(mounted.root)).toBe(false); - expect(mounted.resolvedAnchors).toHaveLength(3); +describe("mountStationEnvironmentForRuntime readiness composition", () => { + it("keeps the compiled GLB when authored metadata and unique anchors are ready", async () => { + const mounted = await mountCompiled({ metadata: VALID_METADATA }); + expect(mounted.name).toBe("openclinxr.compiled-room-shell"); + expect(mounted.userData.openClinXrCompiledRoom).toBe(true); + expect(mounted.userData.openClinXrCompiledRoomReadiness).toBe("ready"); + expect(mounted.userData.openClinXrCompiledRoomCollisionBounds).toEqual( + VALID_METADATA.collisionBounds, + ); + expect(mounted.userData.openClinXrCompiledRoomWalkableBounds).toEqual( + VALID_METADATA.walkableBounds, + ); + expect(mounted.userData.openClinXrCompiledRoomResolvedAnchors).toHaveLength(3); + expect(hasParametricFloor(mounted)).toBe(false); }); - it("falls back to the primitive room on malformed metadata", async () => { - const mounted = await mountCompiledRoomReady({ - environmentId: ED_BAY, - compiledRoomAssetUrl: "/compiled/rooms/ed_exam_bay_v1.glb", - compileNodeId: "room:ed_exam_bay_v1", - metadata: { actorAnchors: "nope" }, - loadGltf: mockLoadGltfFrom(compiledSceneWithAnchors()), - }); - expect(mounted.mode).toBe("primitive_fallback"); - expect(mounted.diagnostics[0]?.code).toBe("malformed_metadata"); - expect(mounted.root.name).toBe("openclinxr.station-environment-shell"); - expect(mounted.root.userData.openClinXrCompiledRoom).not.toBe(true); - expect(mounted.root.userData.openClinXrCompiledRoomFallback).toBe(true); - expect(hasParametricFloor(mounted.root)).toBe(true); - expect(mounted.root.children.length).toBeGreaterThan(0); - }); - - it("falls back to the primitive room on load failure", async () => { - const mounted = await mountCompiledRoomReady({ - environmentId: ED_BAY, - compiledRoomAssetUrl: "/compiled/rooms/ed_exam_bay_v1.glb", - compileNodeId: "room:ed_exam_bay_v1", - metadata: VALID_METADATA, - loadGltf: async () => { - throw new Error("gltf 404"); - }, - }); - expect(mounted.mode).toBe("primitive_fallback"); - expect(mounted.diagnostics).toEqual([ - expect.objectContaining({ code: "load_failure", message: "gltf 404" }), + it("falls back to the primitive room on malformed metadata with typed diagnostics", async () => { + const mounted = await mountCompiled({ metadata: { actorAnchors: "nope" } }); + expect(mounted.name).toBe("openclinxr.station-environment-shell"); + expect(mounted.userData.openClinXrCompiledRoom).not.toBe(true); + expect(mounted.userData.openClinXrCompiledRoomFallback).toBe(true); + expect(mounted.userData.openClinXrCompiledRoomDiagnostics).toEqual([ + expect.objectContaining({ code: "malformed_metadata" }), ]); - expect(mounted.root.userData.openClinXrCompiledRoomLoadFailed).toBe(true); - expect(mounted.root.userData.compileNodeIdAttempted).toBe("room:ed_exam_bay_v1"); - expect(hasParametricFloor(mounted.root)).toBe(true); - expect(mounted.root.userData.openClinXrCompiledRoom).not.toBe(true); + expect(hasParametricFloor(mounted)).toBe(true); }); - it("falls back when a declared anchor is missing rather than showing a partial compiled room", async () => { - const scene = compiledSceneWithAnchors(); - const mounted = await mountCompiledRoomReady({ - environmentId: ED_BAY, - compiledRoomAssetUrl: "/compiled/rooms/ed_exam_bay_v1.glb", - compileNodeId: "room:ed_exam_bay_v1", + it("falls back when a declared anchor is missing", async () => { + const mounted = await mountCompiled({ metadata: { ...VALID_METADATA, equipmentAnchors: [ { id: "missing_kit", kind: "equipment", nodeName: "openclinxr.anchor.equipment.absent" }, ], }, - loadGltf: mockLoadGltfFrom(scene), }); - expect(mounted.mode).toBe("primitive_fallback"); - expect(mounted.diagnostics[0]?.code).toBe("missing_anchor"); - expect(mounted.root.name).toBe("openclinxr.station-environment-shell"); - expect(hasParametricFloor(mounted.root)).toBe(true); - expect(mounted.root.userData.openClinXrCompiledRoom).not.toBe(true); + expect(mounted.name).toBe("openclinxr.station-environment-shell"); + expect(mounted.userData.openClinXrCompiledRoomDiagnostics).toEqual([ + expect.objectContaining({ + code: "missing_anchor", + anchorId: "missing_kit", + nodeName: "openclinxr.anchor.equipment.absent", + }), + ]); + expect(hasParametricFloor(mounted)).toBe(true); + }); + + it("falls back when a declared anchor node is duplicated in the scene", async () => { + const scene = compiledSceneWithAnchors(); + scene.add(namedEmpty("openclinxr.anchor.actor.patient", 0, 0)); + const mounted = await mountCompiled({ scene, metadata: VALID_METADATA }); + expect(mounted.name).toBe("openclinxr.station-environment-shell"); + expect(mounted.userData.openClinXrCompiledRoomDiagnostics).toEqual( + expect.arrayContaining([expect.objectContaining({ code: "duplicate_anchor" })]), + ); + expect(hasParametricFloor(mounted)).toBe(true); + }); + + it("falls back to the primitive room on load failure with typed diagnostics", async () => { + const mounted = await mountCompiled({ + loadGltf: async () => { + throw new Error("gltf 404"); + }, + }); + expect(mounted.name).toBe("openclinxr.station-environment-shell"); + expect(mounted.userData.openClinXrCompiledRoomLoadFailed).toBe(true); + expect(mounted.userData.compileNodeIdAttempted).toBe("room:ed_exam_bay_v1"); + expect(mounted.userData.openClinXrCompiledRoomDiagnostics).toEqual([ + expect.objectContaining({ code: "load_failure", message: "gltf 404" }), + ]); + expect(mounted.userData.openClinXrCompiledRoom).not.toBe(true); + expect(hasParametricFloor(mounted)).toBe(true); }); - it("uses the primitive room when no compiled URL is supplied", async () => { - const mounted = await mountCompiledRoomReady({ environmentId: ED_BAY }); - expect(mounted.mode).toBe("primitive_fallback"); - expect(mounted.diagnostics[0]?.code).toBe("compiled_asset_absent"); - expect(hasParametricFloor(mounted.root)).toBe(true); + it("preserves PR #789 compiled mount when authored metadata is absent", async () => { + const mounted = await mountCompiled({ scene: new Group() }); + expect(mounted.name).toBe("openclinxr.compiled-room-shell"); + expect(mounted.userData.openClinXrCompiledRoom).toBe(true); + expect(mounted.userData.openClinXrCompiledRoomReadiness).toBeUndefined(); + expect(mounted.userData.openClinXrCompiledRoomFallback).toBeUndefined(); + expect(hasParametricFloor(mounted)).toBe(false); }); }); diff --git a/apps/ui-xr/src/compiled-room-runtime-mount.ts b/apps/ui-xr/src/compiled-room-runtime-mount.ts index a5ee27b1..53e7f948 100644 --- a/apps/ui-xr/src/compiled-room-runtime-mount.ts +++ b/apps/ui-xr/src/compiled-room-runtime-mount.ts @@ -10,6 +10,10 @@ import type { EncounterRuntimeAsset } from "@openclinxr/asset-registry/runtime-bundles"; import { resolveRuntimeAssetUrl } from "@openclinxr/asset-registry/runtime-bundles"; import type { Group } from "three"; +import { + applyCompiledRoomReadinessOrFallback, + fallbackCompiledRoomLoadFailure, +} from "./compiled-room-runtime.js"; import { resolveStationEnvironment, type BuildStationEnvironmentInput, @@ -62,16 +66,23 @@ export async function mountStationEnvironmentForRuntime(input: { return resolveStationEnvironment({ environmentId: input.environmentId }); } try { - return await resolveStationEnvironment({ + const loaded = await resolveStationEnvironment({ environmentId: input.environmentId, compiledRoomAssetUrl: compiled.compiledRoomAssetUrl, compileNodeId: compiled.compileNodeId, ...(input.loadGltf ? { loadGltf: input.loadGltf } : {}), }); - } catch { - const fallback = await resolveStationEnvironment({ environmentId: input.environmentId }); - fallback.userData.openClinXrCompiledRoomLoadFailed = true; - fallback.userData.compileNodeIdAttempted = compiled.compileNodeId; - return fallback; + return applyCompiledRoomReadinessOrFallback({ + compiled: loaded, + environmentId: input.environmentId, + compileNodeId: compiled.compileNodeId, + }); + } catch (error) { + const message = error instanceof Error ? error.message : "compiled room load failed"; + return fallbackCompiledRoomLoadFailure({ + environmentId: input.environmentId, + compileNodeId: compiled.compileNodeId, + message, + }); } } diff --git a/apps/ui-xr/src/compiled-room-runtime.ts b/apps/ui-xr/src/compiled-room-runtime.ts index d0970c0e..2d6870fb 100644 --- a/apps/ui-xr/src/compiled-room-runtime.ts +++ b/apps/ui-xr/src/compiled-room-runtime.ts @@ -1,9 +1,11 @@ /** - * Compiled-room consumer with an explicit readiness boundary. + * Readiness glue for the existing compiled-room runtime mount. * - * Loads the compiled GLB, evaluates authored anchors/bounds, and falls back to - * the parametric primitive room when the compiled scene is not ready. Never - * presents a blank or partially interactive compiled shell. + * Absence of authored metadata preserves PR #789 (compiled GLB from a + * successful fetch). When metadata is present, missing/duplicate anchors or + * malformed bounds refuse the compiled shell and fall back to the parametric + * room. This is not a second mount API — `mountStationEnvironmentForRuntime` + * is the learner entry. * * claimScope: simulated_actor_or_factory_behavior * notEvidenceFor: clinical validity, licensure, exam equivalence, Quest readiness @@ -15,46 +17,22 @@ import { stampCompiledRoomReadinessUserData, type CompiledRoomReadinessDiagnostic, type CompiledRoomReadinessResult, - type CompiledRoomResolvedAnchor, - type CompiledRoomBounds, } from "./compiled-room-readiness.js"; -import { hasCompiledRoomAssetUrl, loadCompiledRoomShell } from "./compiled-room-loader.js"; import { resolveStationEnvironment } from "./station-environment.js"; -export type MountCompiledRoomReadyInput = { - environmentId: string; - compiledRoomAssetUrl?: string; - compileNodeId?: string; - /** Authored sidecar. If omitted, read `scene.userData.openClinXrCompiledRoomMetadata`. */ - metadata?: unknown; - loadGltf?: (url: string) => Promise; -}; - -export type MountCompiledRoomReadyResult = { - root: Group; - mode: "compiled_ready" | "primitive_fallback"; - diagnostics: CompiledRoomReadinessDiagnostic[]; - resolvedAnchors: CompiledRoomResolvedAnchor[]; - collisionBounds: CompiledRoomBounds | null; - walkableBounds: CompiledRoomBounds | null; -}; - -async function primitiveFallback(input: { +export async function fallbackPrimitiveStationShell(input: { environmentId: string; diagnostics: CompiledRoomReadinessDiagnostic[]; - resolvedAnchors?: CompiledRoomResolvedAnchor[]; - collisionBounds?: CompiledRoomBounds | null; - walkableBounds?: CompiledRoomBounds | null; compileNodeIdAttempted?: string; loadFailed?: boolean; -}): Promise { +}): Promise { const root = await resolveStationEnvironment({ environmentId: input.environmentId }); const notReady: CompiledRoomReadinessResult = { ready: false, diagnostics: input.diagnostics, - resolvedAnchors: input.resolvedAnchors ?? [], - collisionBounds: input.collisionBounds ?? null, - walkableBounds: input.walkableBounds ?? null, + resolvedAnchors: [], + collisionBounds: null, + walkableBounds: null, metadata: null, }; stampCompiledRoomReadinessUserData(root, notReady); @@ -65,79 +43,47 @@ async function primitiveFallback(input: { if (input.loadFailed) { root.userData.openClinXrCompiledRoomLoadFailed = true; } - return { - root, - mode: "primitive_fallback", - diagnostics: input.diagnostics, - resolvedAnchors: notReady.resolvedAnchors, - collisionBounds: notReady.collisionBounds, - walkableBounds: notReady.walkableBounds, - }; + return root; } /** - * Mount a compiled room only when anchors and bounds are ready. - * Otherwise return the existing parametric primitive shell. + * After a compiled GLB load, apply authored-metadata readiness or keep #789. + * Metadata comes from `compiled.userData.openClinXrCompiledRoomMetadata`. */ -export async function mountCompiledRoomReady( - input: MountCompiledRoomReadyInput, -): Promise { - const environmentId = input.environmentId; - const compiledRoomAssetUrl = input.compiledRoomAssetUrl?.trim() ?? ""; - const compileNodeId = input.compileNodeId?.trim() ?? ""; - - if (!hasCompiledRoomAssetUrl({ compiledRoomAssetUrl, compileNodeId })) { - return primitiveFallback({ - environmentId, - diagnostics: [{ - code: "compiled_asset_absent", - message: "compiled room URL or compileNodeId absent; using primitive room", - }], - }); - } - - let compiled: Group; - try { - compiled = await loadCompiledRoomShell({ - environmentId, - compiledRoomAssetUrl, - compileNodeId, - ...(input.loadGltf ? { loadGltf: input.loadGltf } : {}), - }); - } catch (error) { - const message = error instanceof Error ? error.message : "compiled room load failed"; - return primitiveFallback({ - environmentId, - compileNodeIdAttempted: compileNodeId, - loadFailed: true, - diagnostics: [{ - code: "load_failure", - message, - }], - }); +export async function applyCompiledRoomReadinessOrFallback(input: { + compiled: Group; + environmentId: string; + compileNodeId: string; +}): Promise { + const metadata = input.compiled.userData.openClinXrCompiledRoomMetadata; + if (metadata === undefined || metadata === null) { + return input.compiled; } - - const metadata = input.metadata ?? compiled.userData.openClinXrCompiledRoomMetadata; - const readiness = evaluateCompiledRoomReadiness({ scene: compiled, metadata }); - if (!readiness.ready) { - return primitiveFallback({ - environmentId, - compileNodeIdAttempted: compileNodeId, - diagnostics: readiness.diagnostics, - resolvedAnchors: readiness.resolvedAnchors, - collisionBounds: readiness.collisionBounds, - walkableBounds: readiness.walkableBounds, - }); + const readiness = evaluateCompiledRoomReadiness({ + scene: input.compiled, + metadata, + }); + if (readiness.ready) { + stampCompiledRoomReadinessUserData(input.compiled, readiness); + input.compiled.userData.openClinXrCompiledRoomFallback = false; + return input.compiled; } + return fallbackPrimitiveStationShell({ + environmentId: input.environmentId, + compileNodeIdAttempted: input.compileNodeId, + diagnostics: readiness.diagnostics, + }); +} - stampCompiledRoomReadinessUserData(compiled, readiness); - compiled.userData.openClinXrCompiledRoomFallback = false; - return { - root: compiled, - mode: "compiled_ready", - diagnostics: [], - resolvedAnchors: readiness.resolvedAnchors, - collisionBounds: readiness.collisionBounds, - walkableBounds: readiness.walkableBounds, - }; +export async function fallbackCompiledRoomLoadFailure(input: { + environmentId: string; + compileNodeId: string; + message: string; +}): Promise { + return fallbackPrimitiveStationShell({ + environmentId: input.environmentId, + compileNodeIdAttempted: input.compileNodeId, + loadFailed: true, + diagnostics: [{ code: "load_failure", message: input.message }], + }); }