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..99cd80d9 --- /dev/null +++ b/apps/ui-xr/src/compiled-room-readiness.test.ts @@ -0,0 +1,259 @@ +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 { mountStationEnvironmentForRuntime } from "./compiled-room-runtime-mount.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; + }; +} + +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); + 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("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 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(hasParametricFloor(mounted)).toBe(true); + }); + + 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" }, + ], + }, + }); + 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("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-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-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 new file mode 100644 index 00000000..2d6870fb --- /dev/null +++ b/apps/ui-xr/src/compiled-room-runtime.ts @@ -0,0 +1,89 @@ +/** + * Readiness glue for the existing compiled-room runtime mount. + * + * 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 + */ + +import type { Group } from "three"; +import { + evaluateCompiledRoomReadiness, + stampCompiledRoomReadinessUserData, + type CompiledRoomReadinessDiagnostic, + type CompiledRoomReadinessResult, +} from "./compiled-room-readiness.js"; +import { resolveStationEnvironment } from "./station-environment.js"; + +export async function fallbackPrimitiveStationShell(input: { + environmentId: string; + diagnostics: CompiledRoomReadinessDiagnostic[]; + compileNodeIdAttempted?: string; + loadFailed?: boolean; +}): Promise { + const root = await resolveStationEnvironment({ environmentId: input.environmentId }); + const notReady: CompiledRoomReadinessResult = { + ready: false, + diagnostics: input.diagnostics, + resolvedAnchors: [], + collisionBounds: null, + 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; +} + +/** + * After a compiled GLB load, apply authored-metadata readiness or keep #789. + * Metadata comes from `compiled.userData.openClinXrCompiledRoomMetadata`. + */ +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 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, + }); +} + +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 }], + }); +}