diff --git a/.github/scripts/bump-versions.mjs b/.github/scripts/bump-versions.mjs index 64ea28f25..5c04079f5 100644 --- a/.github/scripts/bump-versions.mjs +++ b/.github/scripts/bump-versions.mjs @@ -11,15 +11,15 @@ * untouched), and prints the new version to stdout (also exported as * NEXT_VERSION when running inside GitHub Actions). */ -import { readFileSync, writeFileSync, appendFileSync } from "node:fs"; +import { existsSync, readFileSync, readdirSync, writeFileSync, appendFileSync } from "node:fs"; import { resolve, dirname } from "node:path"; import { fileURLToPath } from "node:url"; const __dirname = dirname(fileURLToPath(import.meta.url)); const root = resolve(__dirname, "..", ".."); -const packages = ["core", "polycss", "react", "vue"].map((d) => - resolve(root, "packages", d, "package.json"), -); +const packagesRoot = resolve(root, "packages"); +const sourcePackage = resolve(packagesRoot, "core", "package.json"); +const packages = discoverPackageJsons(packagesRoot); const bump = process.argv[2]; if (!["patch", "minor", "major"].includes(bump)) { @@ -29,10 +29,15 @@ if (!["patch", "minor", "major"].includes(bump)) { const versionRe = /^(\s*"version":\s*")(\d+)\.(\d+)\.(\d+)(")/m; -const first = readFileSync(packages[0], "utf8"); +if (!packages.includes(sourcePackage)) { + console.error(`could not find ${sourcePackage}`); + process.exit(1); +} + +const first = readFileSync(sourcePackage, "utf8"); const m = first.match(versionRe); if (!m) { - console.error(`could not find a "version" field in ${packages[0]}`); + console.error(`could not find a "version" field in ${sourcePackage}`); process.exit(1); } const [maj, min, pat] = [Number(m[2]), Number(m[3]), Number(m[4])]; @@ -59,3 +64,11 @@ if (process.env.GITHUB_ENV) { if (process.env.GITHUB_OUTPUT) { appendFileSync(process.env.GITHUB_OUTPUT, `version=${next}\n`); } + +function discoverPackageJsons(packagesRoot) { + return readdirSync(packagesRoot, { withFileTypes: true }) + .filter((entry) => entry.isDirectory()) + .map((entry) => resolve(packagesRoot, entry.name, "package.json")) + .filter((file) => existsSync(file)) + .sort(); +} diff --git a/.github/scripts/sync-package-readmes.mjs b/.github/scripts/sync-package-readmes.mjs index f1ffe2aac..94266a1b3 100644 --- a/.github/scripts/sync-package-readmes.mjs +++ b/.github/scripts/sync-package-readmes.mjs @@ -1,5 +1,5 @@ import { copyFileSync } from "node:fs"; -import { dirname, resolve } from "node:path"; +import { dirname, relative, resolve } from "node:path"; import { fileURLToPath } from "node:url"; const repoRoot = resolve(dirname(fileURLToPath(import.meta.url)), "..", ".."); @@ -11,6 +11,16 @@ const targets = [ "packages/vue/README.md", ]; +const invokedFrom = relative(repoRoot, process.cwd()); +const invokedFromPackageReadme = invokedFrom.startsWith("packages/") + ? `${invokedFrom}/README.md` + : undefined; + +if (invokedFromPackageReadme !== undefined && !targets.includes(invokedFromPackageReadme)) { + console.log(`[sync-package-readmes] skipped for ${invokedFromPackageReadme}`); + process.exit(0); +} + for (const target of targets) { copyFileSync(source, resolve(repoRoot, target)); } diff --git a/AGENTS.md b/AGENTS.md index 8ff5d619d..222c57bbe 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -15,8 +15,9 @@ Monorepo layout (pnpm workspaces): | `packages/react` | `@layoutit/polycss-react` | React components + hooks. Owns its own copy of atlas rasterisation. Depends on `core` only — **NOT on `polycss`.** | | `packages/vue` | `@layoutit/polycss-vue` | Vue 3 mirror of the React package. Owns its own copy of atlas rasterisation. Depends on `core` only. | | `packages/fonts` | `@layoutit/polycss-fonts` | Fonts + text → extruded 3D `Polygon[]`. Hand-written TrueType (`glyf`) reader + extruder (flat/round/bevel profiles) + Google Fonts loader. Framework-agnostic (returns `Polygon[]`, no React/Vue mirror needed). Depends on `core` + `earcut`. | +| `packages/world` | `@layoutit/polycss-world` | Framework-agnostic topology, BSP/PVS compilation, state, planning, and DOM-apply helpers for authored worlds: regions, links, elements, selectors, resolution, state diffs, layer plans, caller-provided DOM-like records, stable-order apply results, and debug snapshots. Depends on `core` only. | | `website` | `@layoutit/polycss-website` | Astro + Starlight docs site. Not published. | -| `examples/{html,vanilla,react,vue,fontcss}` | private | Per-framework Vite apps demonstrating the minimal usage for each renderer (`fontcss` demos `@layoutit/polycss-fonts`). Workspace members so they resolve to local `workspace:^` packages. Not published. | +| `examples/{html,vanilla,react,vue,world}` | private | Per-framework Vite apps demonstrating the minimal usage for each renderer, plus `world` for `@layoutit/polycss-world` dogfooding. Workspace members so they resolve to local `workspace:^` packages. Not published. | Public API is **mirrored** across React and Vue. Adding a hook on one side without adding the matching composable on the other is not acceptable (see "Cross-package discipline" below). diff --git a/README.md b/README.md index 9daf6e254..a29607b8b 100644 --- a/README.md +++ b/README.md @@ -233,6 +233,8 @@ Each visible polygon is emitted as one leaf element; the renderer chooses the le | `@layoutit/polycss` | Vanilla custom elements and imperative `createPolyScene` API. | | `@layoutit/polycss-react` | React components, hooks, controls, and core re-exports. | | `@layoutit/polycss-vue` | Vue 3 components, composables, controls, and core re-exports. | +| `@layoutit/polycss-fonts` | Font parsing, Google font loading, and text-to-polygon mesh generation. | +| `@layoutit/polycss-world` | Framework-agnostic topology, BSP/PVS compilation, state, planning, and DOM-apply helpers for authored PolyCSS worlds. | ## Made with PolyCSS diff --git a/examples/world/browser-regression.mjs b/examples/world/browser-regression.mjs new file mode 100644 index 000000000..7e8469086 --- /dev/null +++ b/examples/world/browser-regression.mjs @@ -0,0 +1,489 @@ +import { chromium } from "playwright"; +import { createServer } from "vite"; + +const root = new URL(".", import.meta.url).pathname; +const server = await createServer({ + root, + logLevel: "error", + server: { + host: "127.0.0.1", + port: 0, + }, +}); + +let browser; + +try { + await server.listen(); + const address = server.httpServer?.address(); + if (typeof address !== "object" || address === null) { + throw new Error("Vite did not expose a local server address."); + } + + browser = await chromium.launch({ headless: true }); + const page = await browser.newPage({ viewport: { width: 1280, height: 820 } }); + await page.goto(`http://127.0.0.1:${address.port}/`, { waitUntil: "networkidle" }); + await page.waitForFunction(() => window.__polycssWorldDebug?.portal !== undefined); + await page.waitForFunction(() => window.__polycssWorldDebug?.chunk !== undefined); + + const west = await setPortalView(page, "gallery", 0); + assertNoWholeRoomElements(west); + assertPortalStructuralShell(west, ["studio", "gallery"], ["vault"]); + assertMountedPrefix(west, "studio-"); + assertMountedPrefix(west, "gallery-"); + assertMountedPrefix(west, "studio-detail-door-east-"); + assertMountedPrefix(west, "gallery-detail-door-west-"); + assertUnmountedPrefix(west, "vault-"); + assertDebugListContains(west, ["bspDebug", "current", "viewPvs", "regionIds", "values"], "studio"); + assertBspProof(west); + assertDebugListContains(west, ["viewSurfaceRegions"], "studio"); + assertDebugRoleCount(west, "shell"); + assertDebugRoleCount(west, "opening"); + assertDebugRoleCount(west, "prop"); + assertBspSurfaceSets(west); + assertPortalReadiness(west); + assertDebugStatusCount(west, "visible"); + assertDebugListContains(west, ["portalDebug", "regions", "selectedRegionIds", "values"], "studio"); + assertDebugListContains(west, ["portalDebug", "regions", "hiddenRegionIds", "values"], "vault"); + assertPortalMiniMap(west, { + activeRegionId: "gallery", + visibleRegionIds: ["studio", "gallery"], + hiddenRegionIds: ["vault"], + visibleLinkIds: ["studio-gallery"], + hiddenLinkIds: ["gallery-vault", "gallery-observatory"], + }); + + const east = await setPortalView(page, "gallery", 180); + assertNoWholeRoomElements(east); + assertPortalStructuralShell(east, ["gallery", "vault"], ["studio"]); + assertMountedPrefix(east, "gallery-"); + assertMountedPrefix(east, "vault-"); + assertMountedPrefix(east, "gallery-detail-door-east-"); + assertMountedPrefix(east, "vault-detail-door-west-"); + assertUnmountedPrefix(east, "studio-"); + assertDebugListContains(east, ["bspDebug", "current", "viewPvs", "regionIds", "values"], "vault"); + assertBspProof(east); + assertDebugListContains(east, ["viewSurfaceRegions"], "vault"); + assertDebugRoleCount(east, "shell"); + assertDebugRoleCount(east, "opening"); + assertDebugRoleCount(east, "prop"); + assertBspSurfaceSets(east); + assertPortalReadiness(east); + assertDebugStatusCount(east, "visible"); + assertDebugBroadPhaseCoversView(east); + assertDebugListContains(east, ["portalDebug", "regions", "selectedRegionIds", "values"], "vault"); + assertDebugListContains(east, ["portalDebug", "regions", "hiddenRegionIds", "values"], "studio"); + assertPortalMiniMap(east, { + activeRegionId: "gallery", + visibleRegionIds: ["gallery", "vault"], + hiddenRegionIds: ["studio"], + visibleLinkIds: ["gallery-vault"], + hiddenLinkIds: ["studio-gallery", "gallery-observatory"], + }); + + const engine = await setPortalView(page, "engine", -90); + assertNoWholeRoomElements(engine); + assertPortalStructuralShell(engine, ["engine", "vault", "archive"], ["gallery"]); + assertMountedPrefix(engine, "engine-"); + assertMountedPrefix(engine, "vault-"); + assertMountedPrefix(engine, "archive-"); + assertMountedPrefix(engine, "engine-detail-door-north-"); + assertMountedPrefix(engine, "vault-detail-door-south-"); + assertMountedPrefix(engine, "vault-detail-door-north-"); + assertMountedPrefix(engine, "archive-detail-door-south-"); + assertUnmountedPrefix(engine, "gallery-"); + assertDebugListContains(engine, ["viewSurfaceRegions"], "engine"); + assertBspProof(engine); + assertDebugListContains(engine, ["viewSurfaceRegions"], "vault"); + assertDebugRoleCount(engine, "shell"); + assertDebugRoleCount(engine, "opening"); + assertDebugRoleCount(engine, "prop"); + assertBspSurfaceSets(engine); + assertPortalReadiness(engine); + assertDebugBroadPhaseCoversView(engine); + assertDebugListContains(engine, ["portalDebug", "regions", "selectedRegionIds", "values"], "engine"); + assertDebugListContains(engine, ["portalDebug", "regions", "hiddenRegionIds", "values"], "gallery"); + assertPortalMiniMap(engine, { + activeRegionId: "engine", + visibleRegionIds: ["engine", "vault", "archive"], + hiddenRegionIds: ["gallery"], + visibleLinkIds: ["vault-engine", "vault-archive"], + hiddenLinkIds: ["gallery-vault", "gallery-observatory"], + }); + + const chunk = await page.evaluate(async () => { + const chunkDebug = window.__polycssWorldDebug?.chunk; + if (chunkDebug === undefined) throw new Error("Missing chunk debug API."); + return chunkDebug.setChunk(8); + }); + assertMountedPrefix(chunk, "chunk-5-"); + assertMountedPrefix(chunk, "chunk-12-"); + assertUnmountedPrefix(chunk, "chunk-14-"); + assertDebugListContains(chunk, ["chunkDebug", "streaming", "loadedRegionIds", "values"], "chunk-14"); + assertDebugListContains(chunk, ["chunkDebug", "streaming", "renderedRegionIds", "values"], "chunk-12"); + assertDebugListDoesNotContain(chunk, ["chunkDebug", "streaming", "renderedRegionIds", "values"], "chunk-14"); + assertDebugGreaterThan(chunk, ["chunkDebug", "streaming", "loadedRegionIds", "count"], ["chunkDebug", "streaming", "renderedRegionIds", "count"]); + assertDebugEquals(chunk, ["chunkDebug", "streaming", "chunkTree", "chunkCount"], 17); + assertDebugEquals(chunk, ["chunkDebug", "streaming", "chunkTree", "maxDepth"], 16); + assertDebugListContains(chunk, ["chunkDebug", "streaming", "chunkTree", "rootChunkIds", "values"], "chunk-0"); + assertDebugListContains(chunk, ["chunkDebug", "streaming", "chunkTree", "contentChunkIds", "values"], "chunk-0"); + assertDebugListContains(chunk, ["chunkDebug", "proof", "guarantees"], "screen-space-error-traversal"); + assertDebugEquals(chunk, ["chunkDebug", "streaming", "chunkTraversal", "currentChunkId"], "chunk-8"); + assertDebugEquals(chunk, ["chunkDebug", "streaming", "chunkTraversal", "budget", "maxRenderedChunks"], 10); + assertDebugEquals(chunk, ["chunkDebug", "streaming", "chunkTraversal", "budget", "maxScreenSpaceError"], 16); + assertDebugEquals(chunk, ["chunkDebug", "streaming", "chunkTraversal", "screenSpaceError", "maxError"], 16); + assertDebugPositive(chunk, ["chunkDebug", "streaming", "chunkTraversal", "screenSpaceError", "viewportHeight"]); + assertDebugPositive(chunk, ["chunkDebug", "streaming", "chunkTraversal", "entries", 0, "distanceToCamera"]); + assertDebugPositive(chunk, ["chunkDebug", "streaming", "chunkTraversal", "entries", 0, "screenSpaceError"]); + assertDebugEquals(chunk, ["chunkDebug", "streaming", "chunkTraversal", "renderedChunkIds", "count"], 10); + assertDebugListContains(chunk, ["chunkDebug", "streaming", "chunkTraversal", "renderedChunkIds", "values"], "chunk-8"); + assertDebugListContains(chunk, ["chunkDebug", "streaming", "chunkTraversal", "renderedChunkIds", "values"], "chunk-9"); + assertDebugListContains(chunk, ["chunkDebug", "streaming", "chunkTraversal", "heldChunkIds", "values"], "chunk-10"); + assertDebugListContains(chunk, ["chunkDebug", "streaming", "chunkTraversal", "budgetClippedChunkIds", "values"], "chunk-10"); + assertDebugListContains(chunk, ["chunkDebug", "streaming", "chunkTraversal", "viewCulledChunkIds", "values"], "chunk-13"); + assertDebugListContains(chunk, ["chunkDebug", "streaming", "chunkTraversal", "viewCulledChunkIds", "values"], "chunk-14"); + assertDebugListContains(chunk, ["chunkDebug", "streaming", "chunkTraversal", "skippedChunkIds", "values"], "chunk-13"); + assertDebugGreaterThan(chunk, ["chunkDebug", "streaming", "chunkTraversal", "entryCount"], ["chunkDebug", "streaming", "chunkTraversal", "entries", "length"]); + assertDebugEquals(chunk, ["streamingSets", "currentChunkId"], "chunk-8"); + assertDebugListContains(chunk, ["streamingSets", "loadedChunkIds"], "chunk-8"); + assertDebugListContains(chunk, ["streamingSets", "renderedChunkIds"], "chunk-8"); + assertDebugListContains(chunk, ["streamingSets", "heldChunkIds"], "chunk-10"); + assertDebugListContains(chunk, ["streamingSets", "budgetClippedChunkIds"], "chunk-10"); + assertDebugListContains(chunk, ["streamingSets", "viewCulledChunkIds"], "chunk-14"); + assertDebugListContains(chunk, ["streamingSets", "skippedChunkIds"], "chunk-13"); + assertDebugListContains(chunk, ["streamingSets", "plannedElementIds"], "chunk-5-road"); + assertDebugEquals(chunk, ["frameSummary", "profile"], "chunk-traversal"); + assertDebugEquals(chunk, ["frameSummary", "current", "chunkIds", 0], "chunk-8"); + assertDebugListContains(chunk, ["frameSummary", "candidate", "chunkIds"], "chunk-8"); + assertDebugListContains(chunk, ["frameSummary", "view", "chunkIds"], "chunk-8"); + assertDebugListContains(chunk, ["frameSummary", "retained", "chunkIds"], "chunk-10"); + assertDebugListContains(chunk, ["frameSummary", "rejected", "chunkIds"], "chunk-14"); + assertDebugEquals(chunk, ["frameSummary", "rejected", "reasonCounts", "view-culled"], 4); + assertDebugListContains(chunk, ["frameSummary", "plan", "plannedElementIds"], "chunk-5-road"); + + console.log(JSON.stringify({ + west: { + mountedCount: west.mountedElementIds.length, + unmountedCount: west.unmountedElementIds.length, + mountedPrefixes: countPrefixes(west.mountedElementIds), + traceStatusCounts: west.debug?.bspDebug?.trace?.statusCounts, + viewSurfaceRegions: west.debug?.viewSurfaceRegions, + viewSurfaceRoles: summarizeRoles(west.debug?.viewSurfaceRoles), + viewSurfaceElementCount: west.debug?.viewSurfaceElementCount, + structuralSurfaceCount: west.debug?.structuralSurfaceIds?.length, + detailSurfaceCount: west.debug?.detailSurfaceIds?.length, + blockedResourceCount: west.debug?.readiness?.blockedResourceIds?.length, + }, + east: { + mountedCount: east.mountedElementIds.length, + unmountedCount: east.unmountedElementIds.length, + mountedPrefixes: countPrefixes(east.mountedElementIds), + traceStatusCounts: east.debug?.bspDebug?.trace?.statusCounts, + viewSurfaceRegions: east.debug?.viewSurfaceRegions, + viewSurfaceRoles: summarizeRoles(east.debug?.viewSurfaceRoles), + viewSurfaceElementCount: east.debug?.viewSurfaceElementCount, + structuralSurfaceCount: east.debug?.structuralSurfaceIds?.length, + detailSurfaceCount: east.debug?.detailSurfaceIds?.length, + blockedResourceCount: east.debug?.readiness?.blockedResourceIds?.length, + }, + engine: { + mountedCount: engine.mountedElementIds.length, + unmountedCount: engine.unmountedElementIds.length, + mountedPrefixes: countPrefixes(engine.mountedElementIds), + traceStatusCounts: engine.debug?.bspDebug?.trace?.statusCounts, + viewSurfaceRegions: engine.debug?.viewSurfaceRegions, + viewSurfaceRoles: summarizeRoles(engine.debug?.viewSurfaceRoles), + viewSurfaceElementCount: engine.debug?.viewSurfaceElementCount, + structuralSurfaceCount: engine.debug?.structuralSurfaceIds?.length, + detailSurfaceCount: engine.debug?.detailSurfaceIds?.length, + blockedResourceCount: engine.debug?.readiness?.blockedResourceIds?.length, + }, + chunk: { + mountedCount: chunk.mountedElementIds.length, + unmountedCount: chunk.unmountedElementIds.length, + loadedCount: chunk.debug?.chunkDebug?.streaming?.loadedRegionIds?.count, + renderedCount: chunk.debug?.chunkDebug?.streaming?.renderedRegionIds?.count, + activeCount: chunk.debug?.chunkDebug?.streaming?.activeRegionIds?.count, + chunkTree: chunk.debug?.chunkDebug?.streaming?.chunkTree, + chunkTraversal: { + currentChunkId: chunk.debug?.chunkDebug?.streaming?.chunkTraversal?.currentChunkId, + renderedCount: chunk.debug?.chunkDebug?.streaming?.chunkTraversal?.renderedChunkIds?.count, + viewCulledCount: chunk.debug?.chunkDebug?.streaming?.chunkTraversal?.viewCulledChunkIds?.count, + budgetClippedCount: chunk.debug?.chunkDebug?.streaming?.chunkTraversal?.budgetClippedChunkIds?.count, + skippedCount: chunk.debug?.chunkDebug?.streaming?.chunkTraversal?.skippedChunkIds?.count, + }, + streamingSets: { + plannedElementCount: chunk.debug?.streamingSets?.plannedElementIds?.length, + renderedChunkCount: chunk.debug?.streamingSets?.renderedChunkIds?.length, + heldChunkCount: chunk.debug?.streamingSets?.heldChunkIds?.length, + viewCulledChunkCount: chunk.debug?.streamingSets?.viewCulledChunkIds?.length, + budgetClippedChunkCount: chunk.debug?.streamingSets?.budgetClippedChunkIds?.length, + }, + }, + }, null, 2)); +} finally { + await browser?.close(); + await server.close(); +} + +async function setPortalView(page, regionId, rotY) { + return page.evaluate(async ({ regionId, rotY }) => { + const portal = window.__polycssWorldDebug?.portal; + if (portal === undefined) throw new Error("Missing portal debug API."); + await portal.placeCamera(regionId); + const snapshot = await portal.setCameraRotation(88, rotY); + const readPortalMiniMapState = () => ({ + rooms: [...document.querySelectorAll(".portal-minimap-room")].map((room) => ({ + regionId: room.getAttribute("data-region-id"), + visible: room.classList.contains("is-visible"), + active: room.classList.contains("is-active"), + })), + links: [...document.querySelectorAll(".portal-minimap-link")].map((link) => ({ + linkId: link.getAttribute("data-link-id"), + visible: link.classList.contains("is-visible"), + })), + cameraTransform: document.querySelector(".portal-minimap-camera")?.getAttribute("transform") ?? "", + }); + return { + ...snapshot, + miniMap: readPortalMiniMapState(), + }; + }, { regionId, rotY }); +} + +function assertMounted(snapshot, elementId) { + if (!snapshot.mountedElementIds.includes(elementId)) { + throw new Error(`Expected ${elementId} to be mounted. Mounted: ${snapshot.mountedElementIds.join(", ")}`); + } +} + +function assertMountedPrefix(snapshot, prefix) { + if (!snapshot.mountedElementIds.some((elementId) => elementId.startsWith(prefix))) { + throw new Error(`Expected a mounted element with prefix ${prefix}. Mounted: ${snapshot.mountedElementIds.join(", ")}`); + } +} + +function assertMountedMatch(snapshot, pattern, label) { + if (!snapshot.mountedElementIds.some((elementId) => pattern.test(elementId))) { + throw new Error(`Expected mounted ${label}. Mounted: ${snapshot.mountedElementIds.join(", ")}`); + } +} + +function assertNoMountedPrefix(snapshot, prefix) { + const mounted = snapshot.mountedElementIds.filter((elementId) => elementId.startsWith(prefix)); + if (mounted.length > 0) { + throw new Error(`Expected no mounted elements with prefix ${prefix}. Mounted matches: ${mounted.join(", ")}`); + } +} + +function assertUnmounted(snapshot, elementId) { + if (snapshot.mountedElementIds.includes(elementId)) { + throw new Error(`Expected ${elementId} to be unmounted. Mounted: ${snapshot.mountedElementIds.join(", ")}`); + } + if (!snapshot.unmountedElementIds.includes(elementId)) { + throw new Error(`Expected ${elementId} in unmounted ids. Unmounted: ${snapshot.unmountedElementIds.join(", ")}`); + } +} + +function assertUnmountedPrefix(snapshot, prefix) { + const mounted = snapshot.mountedElementIds.filter((elementId) => elementId.startsWith(prefix)); + if (mounted.length > 0) { + throw new Error(`Expected no mounted elements with prefix ${prefix}. Mounted matches: ${mounted.join(", ")}`); + } + if (!snapshot.unmountedElementIds.some((elementId) => elementId.startsWith(prefix))) { + throw new Error(`Expected unmounted elements with prefix ${prefix}. Unmounted: ${snapshot.unmountedElementIds.join(", ")}`); + } +} + +function assertNoWholeRoomElements(snapshot) { + const roomElements = snapshot.mountedElementIds.filter((elementId) => elementId.endsWith("-room")); + if (roomElements.length > 0) { + throw new Error(`Expected surface-level BSP elements, not whole rooms. Mounted rooms: ${roomElements.join(", ")}`); + } +} + +function assertPortalStructuralShell(snapshot, visibleRegionIds, hiddenRegionIds) { + for (const regionId of visibleRegionIds) { + assertMountedMatch(snapshot, new RegExp(`^${regionId}-leaf-.+-top$`), `${regionId} ceiling shell`); + assertMountedMatch(snapshot, new RegExp(`^${regionId}-leaf-.+-bottom$`), `${regionId} floor shell`); + } + for (const regionId of hiddenRegionIds) { + assertNoMountedPrefix(snapshot, `${regionId}-leaf-`); + assertNoMountedPrefix(snapshot, `${regionId}-detail-`); + } +} + +function assertPortalMiniMap(snapshot, expected) { + const miniMap = snapshot.miniMap; + if (miniMap === undefined) throw new Error("Expected portal minimap debug state."); + const rooms = new Map(miniMap.rooms.map((room) => [room.regionId, room])); + const links = new Map(miniMap.links.map((link) => [link.linkId, link])); + const cameraRegion = readPath(snapshot.debug, ["cameraRegion"]); + if (cameraRegion !== expected.activeRegionId) { + throw new Error(`Expected debug cameraRegion ${expected.activeRegionId}, got ${JSON.stringify(cameraRegion)}.`); + } + for (const regionId of expected.visibleRegionIds) { + const room = rooms.get(regionId); + if (room?.visible !== true) throw new Error(`Expected minimap room ${regionId} to be visible. Rooms: ${JSON.stringify(miniMap.rooms)}`); + } + for (const regionId of expected.hiddenRegionIds) { + const room = rooms.get(regionId); + if (room?.visible !== false) throw new Error(`Expected minimap room ${regionId} to be hidden. Rooms: ${JSON.stringify(miniMap.rooms)}`); + } + const active = rooms.get(expected.activeRegionId); + if (active?.active !== true) throw new Error(`Expected minimap room ${expected.activeRegionId} to be active. Rooms: ${JSON.stringify(miniMap.rooms)}`); + for (const linkId of expected.visibleLinkIds) { + const link = links.get(linkId); + if (link?.visible !== true) throw new Error(`Expected minimap link ${linkId} to be visible. Links: ${JSON.stringify(miniMap.links)}`); + } + for (const linkId of expected.hiddenLinkIds) { + const link = links.get(linkId); + if (link?.visible !== false) throw new Error(`Expected minimap link ${linkId} to be hidden. Links: ${JSON.stringify(miniMap.links)}`); + } +} + +function assertDebugListContains(snapshot, path, value) { + const list = readPath(snapshot.debug, path); + if (!Array.isArray(list) || !list.includes(value)) { + throw new Error(`Expected debug path ${path.join(".")} to include ${value}. Value: ${JSON.stringify(list)}`); + } +} + +function assertDebugListDoesNotContain(snapshot, path, value) { + const list = readPath(snapshot.debug, path); + if (!Array.isArray(list) || list.includes(value)) { + throw new Error(`Expected debug path ${path.join(".")} not to include ${value}. Value: ${JSON.stringify(list)}`); + } +} + +function assertDebugGreaterThan(snapshot, leftPath, rightPath) { + const left = readPath(snapshot.debug, leftPath); + const right = readPath(snapshot.debug, rightPath); + if (typeof left !== "number" || typeof right !== "number" || left <= right) { + throw new Error(`Expected ${leftPath.join(".")} > ${rightPath.join(".")}. Values: ${JSON.stringify({ left, right })}`); + } +} + +function assertDebugEquals(snapshot, path, expected) { + const actual = readPath(snapshot.debug, path); + if (actual !== expected) { + throw new Error(`Expected debug path ${path.join(".")} to equal ${JSON.stringify(expected)}. Value: ${JSON.stringify(actual)}`); + } +} + +function assertDebugStatusCount(snapshot, status) { + const count = readPath(snapshot.debug, ["bspDebug", "trace", "statusCounts", status]); + if (typeof count !== "number" || count <= 0) { + throw new Error(`Expected BSP trace status ${status} to be counted. Value: ${JSON.stringify(count)}`); + } +} + +function assertBspProof(snapshot) { + assertDebugEquals(snapshot, ["bspProof", "profile"], "bsp-pvs"); + assertDebugEquals(snapshot, ["bspProof", "compiler", "id"], "brush-bsp"); + assertDebugEquals(snapshot, ["bspProof", "compiler", "compiled"], true); + assertDebugEquals(snapshot, ["bspProof", "compiler", "partition"], "recursive-plane"); + assertDebugEquals(snapshot, ["bspProof", "compiler", "leafBuilder"], "recursive-convex-halfspace"); + assertDebugEquals(snapshot, ["bspProof", "compiler", "portalBuilder"], "leaf-face-overlap"); + assertDebugEquals(snapshot, ["bspProof", "tree", "referencesEveryLeafOnce"], true); + assertDebugEquals(snapshot, ["bspProof", "pvs", "level"], "portal-clipped-baked-pvs"); + assertDebugEquals(snapshot, ["bspProof", "pvs", "method"], "portal-clipped-baked"); + assertDebugEquals(snapshot, ["bspProof", "pvs", "source"], "polycss-world"); + assertDebugEquals(snapshot, ["bspProof", "pvs", "completeness"], "complete"); + assertDebugEquals(snapshot, ["bspProof", "pvs", "indexed"], true); + assertDebugEquals(snapshot, ["bspProof", "pvs", "complete"], true); + assertDebugPositive(snapshot, ["bspProof", "leaves", "renderableCount"]); + assertDebugPositive(snapshot, ["bspProof", "leaves", "solidCount"]); + assertDebugListContains(snapshot, ["bspProof", "evidence", "guarantees"], "validated-pvs-metadata"); + assertDebugListContains(snapshot, ["bspProof", "artifact", "guarantees"], "portal-clipped-baked-pvs"); +} + +function assertDebugPositive(snapshot, path) { + const value = readPath(snapshot.debug, path); + if (typeof value !== "number" || value <= 0) { + throw new Error(`Expected debug path ${path.join(".")} to be positive. Value: ${JSON.stringify(value)}`); + } +} + +function assertDebugRoleCount(snapshot, role) { + const roles = readPath(snapshot.debug, ["viewSurfaceRoles"]); + if (!Array.isArray(roles)) { + throw new Error(`Expected viewSurfaceRoles debug array. Value: ${JSON.stringify(roles)}`); + } + const summary = roles.find((entry) => entry?.role === role); + if (typeof summary?.count !== "number" || summary.count <= 0) { + throw new Error(`Expected BSP surface role ${role} to be counted. Roles: ${JSON.stringify(roles)}`); + } +} + +function assertBspSurfaceSets(snapshot) { + assertDebugArrayNotEmpty(snapshot, ["structuralSurfaceIds"]); + assertDebugArrayNotEmpty(snapshot, ["structuralElementIds"]); + assertDebugArrayNotEmpty(snapshot, ["detailSurfaceIds"]); + assertDebugArrayNotEmpty(snapshot, ["detailElementIds"]); + assertDebugArrayNotEmpty(snapshot, ["visibilitySets", "structuralSurfaceIds"]); + assertDebugArrayNotEmpty(snapshot, ["visibilitySets", "structuralElementIds"]); + assertDebugArrayNotEmpty(snapshot, ["visibilitySets", "detailSurfaceIds"]); + assertDebugArrayNotEmpty(snapshot, ["visibilitySets", "detailElementIds"]); + assertDebugArrayNotEmpty(snapshot, ["visibilitySets", "plannedElementIds"]); + assertDebugEquals(snapshot, ["frameSummary", "profile"], "bsp-pvs"); + assertDebugArrayNotEmpty(snapshot, ["frameSummary", "broad", "leafIds"]); + assertDebugArrayNotEmpty(snapshot, ["frameSummary", "view", "leafIds"]); + assertDebugArrayNotEmpty(snapshot, ["frameSummary", "view", "surfaceIds"]); + assertDebugArrayNotEmpty(snapshot, ["frameSummary", "retained", "surfaceIds"]); + assertDebugArrayNotEmpty(snapshot, ["frameSummary", "planning", "elementIds"]); +} + +function assertPortalReadiness(snapshot) { + assertDebugArrayNotEmpty(snapshot, ["readiness", "resourceIds"]); + assertDebugArrayNotEmpty(snapshot, ["readiness", "readyResourceIds"]); + assertDebugArrayNotEmpty(snapshot, ["readiness", "staleResourceIds"]); + assertDebugArrayNotEmpty(snapshot, ["readiness", "blockedResourceIds"]); + assertDebugArrayNotEmpty(snapshot, ["readiness", "blockedElementIds"]); + const missing = readPath(snapshot.debug, ["readiness", "missingResourceIds"]); + if (!Array.isArray(missing) || missing.length !== 0) { + throw new Error(`Expected no missing portal resources. Value: ${JSON.stringify(missing)}`); + } + assertDebugArrayNotEmpty(snapshot, ["frameSummary", "readiness", "resourceIds"]); + assertDebugArrayNotEmpty(snapshot, ["frameSummary", "readiness", "blockedResourceIds"]); + assertDebugArrayNotEmpty(snapshot, ["frameSummary", "readiness", "blockedElementIds"]); +} + +function assertDebugArrayNotEmpty(snapshot, path) { + const value = readPath(snapshot.debug, path); + if (!Array.isArray(value) || value.length === 0) { + throw new Error(`Expected debug path ${path.join(".")} to be a non-empty array. Value: ${JSON.stringify(value)}`); + } +} + +function assertDebugBroadPhaseCoversView(snapshot) { + const broadCount = readPath(snapshot.debug, ["bspDebug", "current", "viewPvs", "broadPhaseLeafIds", "count"]); + const viewCount = readPath(snapshot.debug, ["bspDebug", "current", "viewPvs", "leafIds", "count"]); + if (typeof broadCount !== "number" || typeof viewCount !== "number" || broadCount < viewCount) { + throw new Error(`Expected broad PVS to cover view PVS. broad=${JSON.stringify(broadCount)} view=${JSON.stringify(viewCount)}`); + } +} + +function readPath(value, path) { + return path.reduce((current, key) => current?.[key], value); +} + +function countPrefixes(elementIds) { + const counts = {}; + for (const elementId of elementIds) { + const prefix = elementId + .replace(/-leaf-.+$/, "-leaf-*") + .replace(/-detail-.+$/, "-detail-*") + .replace(/surface-.+$/, "surface-*"); + counts[prefix] = (counts[prefix] ?? 0) + 1; + } + return counts; +} + +function summarizeRoles(roles) { + if (!Array.isArray(roles)) return []; + return roles.map((role) => ({ + role: role.role, + count: role.count, + })); +} diff --git a/examples/world/index.html b/examples/world/index.html new file mode 100644 index 000000000..d83c274ed --- /dev/null +++ b/examples/world/index.html @@ -0,0 +1,69 @@ + + + + + + PolyCSS World examples + + + +
+
+

PolyCSS World

+

Camera-driven authored worlds using real PolyCSS mesh DOM.

+
+ +
+
+
+
+

Portal FPV

+

First-person camera position chooses the active portal region.

+
+
+
+
+
+
+
Visible
+
+
+
+
Hidden
+
+
+
+
+ Debug snapshot +

+        
+
+ +
+
+
+

Chunk Follow

+

A third-person camera follows the player through streamed chunks.

+
+
+
+
+
+
+
Visible
+
+
+
+
Hidden
+
+
+
+
+ Debug snapshot +

+        
+
+
+
+ + diff --git a/examples/world/main.ts b/examples/world/main.ts new file mode 100644 index 000000000..380c55bda --- /dev/null +++ b/examples/world/main.ts @@ -0,0 +1,1684 @@ +import { + boxPolygons, + createPolyBox, + createPolyFirstPersonControls, + createPolyPerspectiveCamera, + createPolyScene, + type BoxFace, + type Polygon, + type Vec3, +} from "@layoutit/polycss"; +import { + applyPolyWorldDomPlan, + compilePolyWorldBrushBsp, + compilePolyWorldPolygonBsp, + createPolyWorldDomRegistry, + createPolyWorldChunkTree, + createPolyWorldPortalDebugSnapshot, + createPolyWorldState, + createPolyWorldTopology, + planPolyWorldBspVisibilityFrame, + planPolyWorldChunkStreamingFrame, + resolvePolyWorldBspLeaf, + resolvePolyWorldRegionByPoint, + type PolyWorldDomRegistry, + type PolyWorldElement, + type PolyWorldBspBrush, + type PolyWorldBounds, + type PolyWorldBspLeaf, + type PolyWorldBspViewSurfaceElement, + type PolyWorldBspViewSurfaceRole, + type PolyWorldBspViewSurfaceVisibility, + type PolyWorldResourceReadinessMap, + type PolyWorldState, + type PolyWorldTopology, + type PolyWorldTransition, +} from "@layoutit/polycss-world"; +import "./styles.css"; + +type SceneRuntime = { + registry: PolyWorldDomRegistry; + state: PolyWorldState; + topology: PolyWorldTopology; + debugEl: HTMLElement; + visibleEl: HTMLElement; + hiddenEl: HTMLElement; +}; + +type MeshEntry = { + id: string; + shape: ReturnType; + position: Vec3; + rotation?: Vec3; + scale?: Vec3; +}; + +type PortalSurfaceEntry = MeshEntry & PolyWorldBspViewSurfaceElement & { + regionId: RegionId; +}; + +type RegionId = string; + +type PortalDebugSnapshot = { + mountedElementIds: readonly string[]; + hiddenElementIds: readonly string[]; + unmountedElementIds: readonly string[]; + visibleText: string; + hiddenText: string; + debug: unknown; +}; + +type WorldDebugSnapshot = PortalDebugSnapshot; + +type PolycssWorldDebugApi = { + portal?: { + placeCamera: (regionId: RegionId) => Promise; + setCameraRotation: (rotX: number, rotY: number) => Promise; + snapshot: () => PortalDebugSnapshot; + }; + chunk?: { + setChunk: (index: number) => Promise; + snapshot: () => WorldDebugSnapshot; + }; +}; + +declare global { + interface Window { + __polycssWorldDebug?: PolycssWorldDebugApi; + } +} + +type PortalSide = "west" | "east" | "north" | "south"; + +type PortalRoomSpec = { + id: RegionId; + label: string; + center: Vec3; + yaw: number; + floor: string; + ceiling: string; + wall: string; + doorways: readonly PortalSide[]; +}; + +type PortalLinkSpec = { + id: string; + from: RegionId; + to: RegionId; + fromSide: PortalSide; + toSide: PortalSide; + color: string; +}; + +type PortalMiniMap = { + rooms: Map; + labels: Map; + links: Map; + camera: SVGGElement; + project: (point: Vec3) => [number, number]; +}; + +const portalRoomWidth = 8; +const portalRoomDepth = 8; +const portalWallHeight = 2.54; +const portalWallCenterZ = 1.2; +const portalWallThickness = 0.18; +const portalDoorWidth = 2.35; +const portalDoorHalf = portalDoorWidth / 2; +const portalDoorHeight = 2; +const portalFrameBaseInset = 0.05; +const portalFramePostHeight = portalDoorHeight - portalFrameBaseInset; +const portalFramePostCenterZ = portalFrameBaseInset + portalFramePostHeight / 2; +const portalLintelHeight = portalWallHeight - portalDoorHeight; +const portalEyeHeight = 1.2; +const portalPerspective = 900; +const portalLookSensitivity = 0.16; +const portalMinPitch = 62; +const portalMaxPitch = 116; +const portalLeafAdjacencySampleInset = 0.05; +const boxFaceOrder: readonly BoxFace[] = ["right", "left", "front", "back", "top", "bottom"]; + +const portalRooms: readonly PortalRoomSpec[] = [ + { + id: "studio", + label: "Studio", + center: [-8, 0, 0], + yaw: 180, + floor: "#5c5248", + ceiling: "#403a36", + wall: "#7b7265", + doorways: ["east"], + }, + { + id: "gallery", + label: "Gallery", + center: [0, 0, 0], + yaw: 145, + floor: "#46535d", + ceiling: "#353946", + wall: "#6f7f83", + doorways: ["west", "east", "north"], + }, + { + id: "vault", + label: "Vault", + center: [8, 0, 0], + yaw: -45, + floor: "#4d5b48", + ceiling: "#2f3540", + wall: "#77835f", + doorways: ["west", "north", "south"], + }, + { + id: "observatory", + label: "Observatory", + center: [0, 8, 0], + yaw: 90, + floor: "#46575d", + ceiling: "#303947", + wall: "#6f8589", + doorways: ["south"], + }, + { + id: "engine", + label: "Engine", + center: [8, -8, 0], + yaw: -90, + floor: "#574940", + ceiling: "#372f31", + wall: "#80685c", + doorways: ["north"], + }, + { + id: "archive", + label: "Archive", + center: [8, 8, 0], + yaw: 90, + floor: "#4d4f66", + ceiling: "#30303f", + wall: "#74708a", + doorways: ["south"], + }, +]; + +const portalLinks: readonly PortalLinkSpec[] = [ + { id: "studio-gallery", from: "studio", to: "gallery", fromSide: "east", toSide: "west", color: "#c59a55" }, + { id: "gallery-vault", from: "gallery", to: "vault", fromSide: "east", toSide: "west", color: "#d56a4f" }, + { id: "gallery-observatory", from: "gallery", to: "observatory", fromSide: "north", toSide: "south", color: "#7fb2a8" }, + { id: "vault-archive", from: "vault", to: "archive", fromSide: "north", toSide: "south", color: "#a58ac0" }, + { id: "vault-engine", from: "vault", to: "engine", fromSide: "south", toSide: "north", color: "#b37b5d" }, +]; + +const roomCenters: Record = Object.fromEntries( + portalRooms.map((room) => [room.id, room.center]), +) as Record; +const portalRoomsById = new Map(portalRooms.map((room) => [room.id, room])); +const portalRegionIds = portalRooms.map((room) => room.id); + +const portalBspBrushes = createPortalBspBrushes(); +const portalBspResult = compilePolyWorldBrushBsp({ + worldBounds: createPortalWorldBounds(portalBspBrushes), + brushes: portalBspBrushes, + regions: portalRooms.map((room) => ({ + id: room.id, + regionId: room.id, + bounds: roomBounds(room), + })), + outside: "flood-fill", + pvs: { projection: "xy", sampleInset: 0.35 }, + splitIdPrefix: "portal-brush", +}); +const portalBspTree = portalBspResult.tree; +const portalBrushBspSolidLeaves = portalBspResult.solidLeafIds.length; +const portalBrushBspEmptyLeaves = portalBspResult.emptyLeafIds.length; +const portalBrushBspOutsideLeaves = portalBspResult.outsideLeafIds.length; +const portalSurfaceEntries = createPortalSurfaceEntries(); +const portalResourceReadiness = Object.fromEntries( + portalSurfaceEntries.map((entry) => [ + `mesh:${entry.id}`, + entry.role === "prop" ? "stale" : "ready", + ]), +) as PolyWorldResourceReadinessMap; +const portalGeometryBsp = compilePolyWorldPolygonBsp({ + surfaces: createPortalGeometrySurfaces(), + splitIdPrefix: "portal-geometry", + maxDepth: 72, +}); +const chunkCount = 17; +const chunkStep = 5; +const chunkVisualWidth = 5.1; +const chunkRoadWidth = 3.8; +const chunkRailY = 2.06; +const chunkRunnerSpeed = 8.4; +const chunkCameraRotX = 58; +const chunkCameraRotY = -35; +const chunkCameraDistance = 440; +const chunkCameraZoom = 26; +const chunkWindowBefore = 3; +const chunkWindowAfter = 4; + +function mountPortalDemo() { + const article = document.querySelector('[data-demo="portal"]'); + if (!article) return; + + const host = article.querySelector(".scene-host"); + const visibleEl = article.querySelector("[data-visible]"); + const hiddenEl = article.querySelector("[data-hidden]"); + const debugEl = article.querySelector("[data-debug]"); + const controlsEl = article.querySelector("[data-controls]"); + if (!host || !visibleEl || !hiddenEl || !debugEl || !controlsEl) return; + + const topology = createPolyWorldTopology({ + regions: portalRooms.map((room) => ({ + id: room.id, + bounds: roomBounds(room), + center: room.center, + })), + links: portalLinks.map((link) => ({ + id: link.id, + fromRegionId: link.from, + toRegionId: link.to, + selectionKeys: [`portal:${link.id}`], + })), + elements: [ + ...portalSurfaceEntries.map((entry) => portalSurfaceElement(entry.id)), + ], + }); + + const camera = createPolyPerspectiveCamera({ + zoom: 26, + rotX: 88, + rotY: 0, + distance: 0, + perspective: portalPerspective, + }); + const scene = createPolyScene(host, { + camera, + directionalLight: { direction: [0, 0, 1], intensity: 0 }, + ambientLight: { color: "#ffffff", intensity: Math.PI }, + }); + host.tabIndex = 0; + const fpv = createPolyFirstPersonControls(scene, { + eyeHeight: portalEyeHeight, + groundZ: 0, + jumpEnabled: false, + crouchEnabled: false, + lookSensitivity: portalLookSensitivity, + moveSpeed: 4.8, + minPitch: portalMinPitch, + maxPitch: portalMaxPitch, + }); + + const registry = createPolyWorldDomRegistry(); + const runtime: SceneRuntime = { + registry, + state: createMountedWorldState(topology), + topology, + debugEl, + visibleEl, + hiddenEl, + }; + const miniMap = createPortalMiniMap(host); + + for (const entry of portalSurfaceEntries) { + const mesh = scene.add(entry.shape, { + id: entry.id, + merge: false, + meshResolution: "lossless", + excludeFromAutoCenter: true, + position: entry.position, + rotation: entry.rotation, + scale: entry.scale, + }); + registry.register({ elementId: entry.id, element: mesh.element, layers: ["render"], tags: ["world"] }); + } + + let activeRegionId: RegionId = "gallery"; + let syncing = false; + let correctingOrigin = false; + let softMouseLook = false; + let softMousePoint: { x: number; y: number } | undefined; + let lastValidOrigin: Vec3 = [roomCenters.gallery[0], roomCenters.gallery[1], portalEyeHeight]; + + const setRoomButtons = (regionId: RegionId) => { + for (const button of controlsEl.querySelectorAll("[data-room]")) { + button.classList.toggle("is-active", button.dataset.room === regionId); + } + }; + + const syncFromCamera = () => { + if (syncing) return; + syncing = true; + requestAnimationFrame(() => { + syncing = false; + let origin = fpv.getOrigin(); + const constrained = constrainPortalOrigin(origin, lastValidOrigin); + if (!sameVec3(origin, constrained)) { + correctingOrigin = true; + fpv.setOrigin(constrained); + correctingOrigin = false; + origin = constrained; + } else { + lastValidOrigin = constrained; + } + const resolved = resolvePolyWorldRegionByPoint(topology, origin, { nearest: true }); + const cameraRotX = scene.camera.state.rotX ?? 0; + const cameraRotY = scene.camera.state.rotY ?? 0; + const viewForward = portalCameraForward(cameraRotX, cameraRotY); + const viewFovDegrees = resolvePortalViewFovDegrees(host); + const viewAspect = resolvePortalViewAspect(host); + const fallbackRegionId = resolved?.regionId ?? activeRegionId; + const frame = planPolyWorldBspVisibilityFrame(topology, portalBspTree, { + previousState: runtime.state, + policies: [{ id: "render", layer: "render", elementLayers: ["render"] }], + point: origin, + forward: viewForward, + fovDegrees: viewFovDegrees, + aspect: viewAspect, + projection: "xy", + regionIds: [fallbackRegionId], + surfaces: portalSurfaceEntries, + includeTrace: true, + debug: { listLimit: 24 }, + planDebug: { includeEntries: false, listLimit: 8 }, + readiness: { resources: portalResourceReadiness }, + }); + const visibility = frame.visibility; + const bspLeaf = visibility.leaf; + const nextRegionId = bspLeaf?.leaf.regionId ?? fallbackRegionId; + activeRegionId = nextRegionId; + const bspDebug = visibility.debug; + const portalDebug = createPolyWorldPortalDebugSnapshot(topology, visibility.selection, { + currentRegionId: nextRegionId, + listLimit: 24, + }); + updatePortalMiniMap( + miniMap, + nextRegionId, + visibility.selection.regionIds ?? [], + origin, + cameraRotY, + ); + applyWorldTransition(runtime, frame, { + cameraOrigin: formatVec(origin), + cameraRegion: nextRegionId, + cameraRotation: formatVec([cameraRotX, cameraRotY, 0]).slice(0, 2), + mouseLookLocked: fpv.isLocked(), + mouseLookStatus: article.dataset.mouseLook ?? "unlocked", + bspLeaf: bspDebug?.current.leafId ?? null, + bspPath: bspLeaf?.path ?? [], + bspCompiler: bspDebug?.tree.compiler ?? null, + bspPartition: bspDebug?.tree.partition ?? null, + bspLeafBuilder: bspDebug?.tree.leafBuilder ?? null, + bspPortalBuilder: bspDebug?.tree.portalBuilder ?? null, + bspCompiled: portalBspTree.data?.compiled === true, + bspLeaves: bspDebug?.tree.leafCount ?? portalBspTree.leaves.length, + bspEmptyLeaves: bspDebug?.leaves.emptyCount ?? portalBrushBspEmptyLeaves, + bspSolidLeaves: bspDebug?.leaves.solidCount ?? portalBrushBspSolidLeaves, + bspOutsideLeaves: bspDebug?.leaves.outsideCount ?? portalBrushBspOutsideLeaves, + bspGeneratedPortals: bspDebug?.portals.generatedCount ?? portalBspTree.portals.length, + geometryBspCompiler: portalGeometryBsp.tree.data?.compiler ?? null, + geometryBspSourceSurfaces: portalGeometryBsp.tree.data?.sourceSurfaceCount ?? null, + geometryBspFragments: portalGeometryBsp.fragments.length, + geometryBspLeaves: portalGeometryBsp.tree.leaves.length, + pvsRegions: visibility.broadPvs?.regionIds ?? [], + viewRegions: visibility.selection.regionIds ?? [], + viewSurfaceCount: frame.surfaceElements?.surfaceIds.length ?? 0, + viewSurfaceElementCount: frame.surfaceElements?.elementIds.length ?? 0, + structuralSurfaceIds: frame.surfaceElements?.structuralSurfaceIds ?? [], + structuralElementIds: frame.surfaceElements?.structuralElementIds ?? [], + detailSurfaceIds: frame.surfaceElements?.detailSurfaceIds ?? [], + detailElementIds: frame.surfaceElements?.detailElementIds ?? [], + viewSurfaceRegions: frame.surfaceElements?.regionIds ?? [], + viewSurfaceRoles: frame.surfaceElements?.roles ?? [], + visibilitySets: frame.visibilitySets, + frameSummary: frame.frameSummary, + viewForward: formatVec(viewForward), + viewFovDegrees: Math.round(viewFovDegrees * 100) / 100, + pvsLeafCount: bspDebug?.current.broadPvs?.leafIds.count ?? visibility.broadPvs?.leafIds.length ?? 0, + pvsPortalCount: bspDebug?.current.broadPvs?.portalIds.count ?? visibility.broadPvs?.portalIds.length ?? 0, + pvsLeaves: bspDebug?.current.broadPvs?.leafIds.values ?? visibility.broadPvs?.leafIds.slice(0, 24) ?? [], + pvsPortals: bspDebug?.current.broadPvs?.portalIds.values ?? visibility.broadPvs?.portalIds.slice(0, 24) ?? [], + traceStatusCounts: bspDebug?.trace?.statusCounts ?? {}, + bspProof: bspDebug?.proof ?? null, + bspDebug, + portalDebug, + }); + setRoomButtons(nextRegionId); + }); + }; + + const placeCamera = (regionId: RegionId) => { + const room = portalRoomsById.get(regionId) ?? portalRoomsById.get("gallery"); + const origin = room?.center ?? roomCenters.gallery; + activeRegionId = regionId; + lastValidOrigin = [origin[0], origin[1], portalEyeHeight]; + scene.camera.update({ + rotX: 88, + rotY: room?.yaw ?? 145, + distance: 0, + }); + scene.applyCamera(); + fpv.setOrigin(lastValidOrigin); + syncLockState(); + syncFromCamera(); + }; + + const syncLockState = () => { + const locked = fpv.isLocked(); + article.dataset.mouseLook = locked ? "locked" : softMouseLook ? "fallback" : "unlocked"; + host.ownerDocument.documentElement.classList.toggle("polycss-world-mouselook", locked || softMouseLook); + lookButton.setAttribute("aria-pressed", String(locked || softMouseLook)); + lookButton.textContent = locked || softMouseLook ? "Unlock Look" : "Mouse Look"; + }; + + const enableSoftMouseLook = () => { + if (fpv.isLocked()) return; + softMouseLook = true; + softMousePoint = undefined; + syncLockState(); + syncFromCamera(); + }; + + const disableSoftMouseLook = () => { + softMouseLook = false; + softMousePoint = undefined; + syncLockState(); + syncFromCamera(); + }; + + const requestMouseLook = () => { + softMouseLook = false; + host.focus({ preventScroll: true }); + fpv.lock(); + window.setTimeout(() => { + if (!fpv.isLocked()) enableSoftMouseLook(); + else syncLockState(); + }, 120); + }; + + const applySoftMouseLook = (event: MouseEvent) => { + if (!softMouseLook || fpv.isLocked()) return; + const nextPoint = { x: event.clientX, y: event.clientY }; + const dx = event.movementX || (softMousePoint === undefined ? 0 : nextPoint.x - softMousePoint.x); + const dy = event.movementY || (softMousePoint === undefined ? 0 : nextPoint.y - softMousePoint.y); + softMousePoint = nextPoint; + if (dx === 0 && dy === 0) return; + const rotY = ((((scene.camera.state.rotY ?? 0) - dx * portalLookSensitivity) % 360) + 360) % 360; + const nextRotX = (scene.camera.state.rotX ?? 90) - dy * portalLookSensitivity; + const rotX = Math.max(portalMinPitch, Math.min(portalMaxPitch, nextRotX)); + scene.camera.update({ rotX, rotY }); + fpv.setOrigin(fpv.getOrigin()); + }; + + const lookButton = makeButton("Mouse Look", () => { + if (fpv.isLocked()) { + fpv.unlock(); + } else if (softMouseLook) { + disableSoftMouseLook(); + } else { + requestMouseLook(); + } + syncLockState(); + }); + lookButton.setAttribute("aria-pressed", "false"); + + controlsEl.append( + ...portalRooms.map((room) => makeButton(room.label, () => placeCamera(room.id), room.id)), + lookButton, + ); + + const waitForPortalSync = () => + new Promise((resolve) => { + requestAnimationFrame(() => { + requestAnimationFrame(() => resolve(portalDebugSnapshot(runtime))); + }); + }); + + window.__polycssWorldDebug = { + ...window.__polycssWorldDebug, + portal: { + placeCamera: async (regionId) => { + placeCamera(regionId); + return waitForPortalSync(); + }, + setCameraRotation: async (rotX, rotY) => { + scene.camera.update({ rotX, rotY }); + scene.applyCamera(); + fpv.setOrigin(fpv.getOrigin()); + syncFromCamera(); + return waitForPortalSync(); + }, + snapshot: () => portalDebugSnapshot(runtime), + }, + }; + + host.addEventListener("click", () => { + softMouseLook = false; + host.focus({ preventScroll: true }); + window.setTimeout(() => { + if (!fpv.isLocked()) enableSoftMouseLook(); + else syncLockState(); + }, 120); + }); + host.addEventListener("mousemove", (event) => { + applySoftMouseLook(event); + }); + host.ownerDocument.addEventListener("pointerlockerror", () => { + if (host.ownerDocument.pointerLockElement === host) return; + enableSoftMouseLook(); + }); + host.ownerDocument.addEventListener("keydown", (event) => { + if (event.code === "Escape" && softMouseLook) disableSoftMouseLook(); + }); + fpv.addEventListener("change", () => { + if (!correctingOrigin) syncFromCamera(); + }); + fpv.addEventListener("start", () => { + softMouseLook = false; + syncLockState(); + }); + fpv.addEventListener("end", () => syncLockState()); + placeCamera("engine"); +} + +function mountChunkDemo() { + const article = document.querySelector('[data-demo="chunk"]'); + if (!article) return; + + const host = article.querySelector(".scene-host"); + const visibleEl = article.querySelector("[data-visible]"); + const hiddenEl = article.querySelector("[data-hidden]"); + const debugEl = article.querySelector("[data-debug]"); + const controlsEl = article.querySelector("[data-controls]"); + if (!host || !visibleEl || !hiddenEl || !debugEl || !controlsEl) return; + + const regions = Array.from({ length: chunkCount }, (_, index) => { + const x = chunkX(index); + return { + id: `chunk-${index}`, + bounds: { + min: [x - chunkStep / 2, -chunkRailY - 0.35, -0.2] as Vec3, + max: [x + chunkStep / 2, chunkRailY + 0.35, 2.1] as Vec3, + }, + }; + }); + + const topology = createPolyWorldTopology({ + regions, + links: Array.from({ length: chunkCount - 1 }, (_, index) => ({ + id: `chunk-link-${index}`, + fromRegionId: `chunk-${index}`, + toRegionId: `chunk-${index + 1}`, + })), + elements: Array.from({ length: chunkCount }, (_, index) => { + const regionId = `chunk-${index}`; + const rootId = `${regionId}-root`; + return [ + { + id: rootId, + regionIds: [regionId], + layers: ["resident"], + tags: ["chunk-root"], + }, + worldElement(`${regionId}-road`, [regionId], [], rootId), + worldElement(`${regionId}-number`, [regionId], [], rootId), + worldElement(`${regionId}-left`, [regionId], [], rootId), + worldElement(`${regionId}-right`, [regionId], [], rootId), + worldElement(`${regionId}-gate`, [regionId], [], rootId), + ]; + }).flat(), + }); + const chunkTree = createPolyWorldChunkTree({ + chunks: regions.map((region, index) => ({ + id: region.id, + regionId: region.id, + ...(index === 0 ? {} : { parentId: `chunk-${index - 1}` }), + ...(index === chunkCount - 1 ? {} : { childIds: [`chunk-${index + 1}`] }), + bounds: region.bounds, + available: true, + contentAvailable: true, + resourceIds: [`mesh:${region.id}`], + refinement: "add", + geometricError: Math.max(0, chunkCount - index - 1), + priority: chunkCount - index, + tags: ["track"], + })), + }, { topology }); + + const camera = createPolyPerspectiveCamera({ + zoom: chunkCameraZoom, + rotX: chunkCameraRotX, + rotY: chunkCameraRotY, + distance: chunkCameraDistance, + perspective: 1350, + target: [chunkX(2), 0, 0.55], + }); + const scene = createPolyScene(host, { + camera, + directionalLight: { direction: [0, 0, 1], intensity: 0 }, + ambientLight: { color: "#ffffff", intensity: Math.PI }, + }); + + const registry = createPolyWorldDomRegistry(); + const runtime: SceneRuntime = { + registry, + state: createMountedWorldState(topology), + topology, + debugEl, + visibleEl, + hiddenEl, + }; + + for (const entry of createChunkMeshes()) { + const mesh = scene.add(entry.shape, { + id: entry.id, + position: entry.position, + rotation: entry.rotation, + scale: entry.scale, + }); + registry.register({ elementId: entry.id, element: mesh.element, layers: ["render"], tags: ["world"] }); + } + + const runner = scene.add(createRunnerMesh(), { + id: "third-person-runner", + position: [chunkX(0), 0, 0.34], + rotation: [0, 0, 0], + }); + + const trackMin = chunkX(0); + const trackMax = chunkX(chunkCount - 1); + let playerX = trackMin; + let trackDirection = 1; + let currentIndex = -1; + let residentRegionIds: readonly string[] = []; + let autoRunning = true; + let frameId = 0; + let lastFrameAt = 0; + const readout = createReadout(`chunk 1/${chunkCount}`); + + const setRunnerPosition = (nextX: number, forceSelection = false) => { + playerX = Math.max(trackMin, Math.min(trackMax, nextX)); + const bodyWobble = Math.sin(playerX * 1.15) * 1.6; + runner.setTransform({ + position: [playerX, 0, 0.34], + rotation: [0, 0, trackDirection > 0 ? bodyWobble : 180 - bodyWobble], + }); + scene.camera.update({ + target: [playerX, 0, 0.55], + rotX: chunkCameraRotX, + rotY: chunkCameraRotY, + distance: chunkCameraDistance, + zoom: chunkCameraZoom, + }); + scene.applyCamera(); + + const resolved = resolvePolyWorldRegionByPoint(topology, [playerX, 0, 0.45], { nearest: true }); + const currentRegionId = resolved?.regionId ?? `chunk-${nearestChunkIndex(playerX)}`; + const nextIndex = chunkIndexFromRegionId(currentRegionId); + if (!forceSelection && nextIndex === currentIndex) return; + + currentIndex = nextIndex; + const frame = planPolyWorldChunkStreamingFrame(topology, { + previousState: runtime.state, + orderedRegionIds: regions.map((region) => region.id), + chunkTree, + currentRegionId, + loadedRegionIds: residentRegionIds, + residentRegionIds, + chunkTraversal: { + point: [playerX, 0, 0.45], + forward: [trackDirection, 0, 0], + up: [0, 0, 1], + fovDegrees: 62, + aspect: 1.55, + viewportHeight: Math.max(1, host.clientHeight || 420), + far: 22, + budget: { + maxRenderedChunks: 10, + maxLoadedChunks: 14, + maxScreenSpaceError: 16, + }, + }, + sources: [ + { + id: "car", + regionId: currentRegionId, + before: chunkWindowBefore, + after: chunkWindowAfter, + targetState: "rendered", + priority: 10, + label: "car-stream", + }, + { + id: "lookahead", + regionId: `chunk-${Math.min(chunkCount - 1, nextIndex + chunkWindowAfter + 2)}`, + before: 0, + after: 1, + targetState: "loaded", + priority: 1, + label: "lookahead-load", + }, + ], + renderSelection: { + reasonLabel: "rendered-chunks", + }, + state: { resolutionOptions: { layers: ["render"] } }, + policies: [{ id: "render", layer: "render", elementLayers: ["render"] }], + planDebug: { includeEntries: false, listLimit: 8 }, + debug: { + includeSources: true, + includeTraversalEntries: true, + sourceLimit: 4, + traversalEntryLimit: 10, + listLimit: 12, + }, + }); + const streamingSelection = frame.streamingSelection; + applyWorldTransition(runtime, frame, { + playerPosition: formatVec([playerX, 0, 0.45]), + cameraTarget: formatVec([playerX, 0, 0.55]), + playerRegion: currentRegionId, + loadedRegionIds: streamingSelection.streaming.loadedRegionIds, + loadingRegionIds: streamingSelection.streaming.loadingRegionIds, + residentRegionIds: streamingSelection.streaming.residentRegionIds, + activeRegionIds: streamingSelection.streaming.activeRegionIds, + renderedRegionIds: streamingSelection.streaming.renderedRegionIds, + preloadedRegionIds: streamingSelection.streaming.preloadedRegionIds, + streamingSets: frame.streamingSets, + frameSummary: frame.frameSummary, + chunkDebug: frame.chunkDebug, + }); + residentRegionIds = streamingSelection.streaming.residentRegionIds; + + readout.textContent = `chunk ${currentIndex + 1}/${chunkCount} - active ${streamingSelection.streaming.renderedRegionIds.length} - loaded ${streamingSelection.streaming.loadedRegionIds.length}`; + }; + + const scheduleRunner = () => { + if (!autoRunning || frameId) return; + frameId = window.requestAnimationFrame(runRunnerFrame); + }; + + const setAutoRunning = (nextRunning: boolean) => { + autoRunning = nextRunning; + toggleButton.textContent = autoRunning ? "Pause" : "Run"; + toggleButton.setAttribute("aria-pressed", String(autoRunning)); + if (autoRunning) { + lastFrameAt = performance.now(); + scheduleRunner(); + } + }; + + const jumpRunner = (delta: number) => { + setAutoRunning(false); + const nextIndex = Math.max(0, Math.min(chunkCount - 1, currentIndex + delta)); + trackDirection = delta >= 0 ? 1 : -1; + setRunnerPosition(chunkX(nextIndex), true); + }; + + const runRunnerFrame = (now: number) => { + frameId = 0; + const dt = Math.min(0.05, Math.max(0, (now - lastFrameAt) / 1000 || 0)); + lastFrameAt = now; + let nextX = playerX + trackDirection * chunkRunnerSpeed * dt; + if (nextX >= trackMax) { + nextX = trackMax; + trackDirection = -1; + } else if (nextX <= trackMin) { + nextX = trackMin; + trackDirection = 1; + } + setRunnerPosition(nextX); + scheduleRunner(); + }; + + const toggleButton = makeButton("Pause", () => setAutoRunning(!autoRunning)); + toggleButton.setAttribute("aria-pressed", "true"); + controlsEl.append( + toggleButton, + makeButton("Back", () => jumpRunner(-1)), + readout, + makeButton("Forward", () => jumpRunner(1)), + ); + + const waitForChunkSync = () => + new Promise((resolve) => { + requestAnimationFrame(() => { + requestAnimationFrame(() => resolve(worldDebugSnapshot(runtime))); + }); + }); + + window.__polycssWorldDebug = { + ...window.__polycssWorldDebug, + chunk: { + setChunk: async (index) => { + setAutoRunning(false); + const nextIndex = Math.max(0, Math.min(chunkCount - 1, index)); + trackDirection = nextIndex >= currentIndex ? 1 : -1; + setRunnerPosition(chunkX(nextIndex), true); + return waitForChunkSync(); + }, + snapshot: () => worldDebugSnapshot(runtime), + }, + }; + + setRunnerPosition(playerX, true); + lastFrameAt = performance.now(); + scheduleRunner(); +} + +function applyWorldTransition( + runtime: SceneRuntime, + transition: PolyWorldTransition, + extraDebug: Record, +) { + const dom = applyPolyWorldDomPlan(runtime.registry, transition.plan, { hideMode: "remove" }); + runtime.state = transition.nextState; + const visible = transition.nextState.resolvedElementIds.slice().sort(); + const hidden = unmountedWorldElementIds(runtime).slice().sort(); + runtime.visibleEl.textContent = visible.join(", "); + runtime.hiddenEl.textContent = hidden.length ? hidden.join(", ") : "none"; + runtime.debugEl.textContent = JSON.stringify( + { + ...extraDebug, + readiness: transition.readiness, + added: transition.diff.resolvedElements.added, + removed: transition.diff.resolvedElements.removed, + plan: transition.plan.actionCounts, + dom: dom.counts, + snapshot: transition.debug, + }, + null, + 2, + ); +} + +function portalDebugSnapshot(runtime: SceneRuntime): PortalDebugSnapshot { + return worldDebugSnapshot(runtime); +} + +function worldDebugSnapshot(runtime: SceneRuntime): WorldDebugSnapshot { + let debug: unknown; + try { + debug = JSON.parse(runtime.debugEl.textContent || "{}"); + } catch { + debug = {}; + } + return { + mountedElementIds: runtime.registry.mountedElementIds().slice().sort(), + hiddenElementIds: runtime.registry.hiddenElementIds().slice().sort(), + unmountedElementIds: unmountedWorldElementIds(runtime).slice().sort(), + visibleText: runtime.visibleEl.textContent ?? "", + hiddenText: runtime.hiddenEl.textContent ?? "", + debug, + }; +} + +function unmountedWorldElementIds(runtime: SceneRuntime): string[] { + return runtime.registry.records + .filter((record) => !record.mounted) + .map((record) => record.elementId); +} + +function createMountedWorldState(topology: PolyWorldTopology): PolyWorldState { + return createPolyWorldState(topology, { + selection: { elementIds: topology.elements.map((element) => element.id) }, + }); +} + +function worldElement( + id: string, + regionIds: readonly string[], + selectionKeys: readonly string[] = [], + parentId?: string, +): PolyWorldElement { + return { + id, + ...(parentId === undefined ? {} : { parentId, containerId: parentId }), + regionIds, + ...(regionIds.length > 1 ? { regionMatch: "any" as const } : {}), + ...(selectionKeys.length > 0 ? { selectionKeys } : {}), + layers: ["render"], + tags: ["world"], + }; +} + +function portalSurfaceElement(id: string): PolyWorldElement { + return { + id, + selectionKeys: [`surface:${id}`], + resourceIds: [`mesh:${id}`], + layers: ["render"], + tags: ["world", "surface"], + }; +} + +function createPortalSurfaceEntries(): PortalSurfaceEntry[] { + return [ + ...createPortalLeafShellEntries(), + ...createPortalDetailSurfaceEntries(), + ]; +} + +function createPortalDetailSurfaceEntries(): PortalSurfaceEntry[] { + return portalRooms.flatMap((room) => { + let boxIndex = 0; + const regionId = room.id; + return roomDetailBoxes(room).flatMap((box) => + boxPolygons({ + size: box.size, + center: box.center, + color: box.color, + }).map((polygon, faceIndex) => { + const face = boxFaceOrder[faceIndex] ?? `face-${faceIndex}`; + const boxId = box.id ?? `prop-${boxIndex}`; + const id = `${regionId}-detail-${boxId}-${face}`; + if (faceIndex === boxFaceOrder.length - 1) boxIndex += 1; + const vertices = polygon.vertices.map((vertex) => [...vertex] as Vec3); + const role: PolyWorldBspViewSurfaceRole = boxId.startsWith("door-") ? "opening" : "prop"; + return portalSurfaceEntry(id, regionId, { ...polygon, vertices }, undefined, role); + }) + ); + }); +} + +function createPortalLeafShellEntries(): PortalSurfaceEntry[] { + const entries: PortalSurfaceEntry[] = []; + for (const leaf of portalBspTree.leaves) { + if (!isRenderablePortalLeaf(leaf)) continue; + const room = portalRoomsById.get(leaf.regionId); + if (room === undefined) continue; + const polygons = boxPolygons({ + min: leaf.bounds.min, + max: leaf.bounds.max, + color: room.wall, + }); + for (let faceIndex = 0; faceIndex < boxFaceOrder.length; faceIndex += 1) { + const face = boxFaceOrder[faceIndex]; + const polygon = polygons[faceIndex]; + if (face === undefined || polygon === undefined) continue; + if (!shouldRenderPortalLeafFace(leaf, face)) continue; + const color = face === "bottom" ? room.floor : face === "top" ? room.ceiling : room.wall; + entries.push(portalSurfaceEntry( + `${leaf.regionId}-leaf-${leaf.id}-${face}`, + leaf.regionId, + { ...polygon, color }, + leaf.id, + "shell", + )); + } + } + return entries; +} + +function isRenderablePortalLeaf(leaf: PolyWorldBspLeaf): leaf is PolyWorldBspLeaf & { + regionId: RegionId; + bounds: PolyWorldBounds; +} { + return leaf.regionId !== undefined && + leaf.bounds !== undefined && + leaf.data?.solid !== true && + leaf.data?.outside !== true; +} + +function shouldRenderPortalLeafFace(leaf: PolyWorldBspLeaf & { bounds: PolyWorldBounds }, face: BoxFace): boolean { + const sample = centerOfBounds(leaf.bounds); + const { axis, side } = portalLeafFaceDirection(face); + sample[axis] = side > 0 + ? leaf.bounds.max[axis] + portalLeafAdjacencySampleInset + : leaf.bounds.min[axis] - portalLeafAdjacencySampleInset; + const neighbor = resolvePolyWorldBspLeaf(portalBspTree, sample)?.leaf; + if (neighbor === undefined) return true; + if (neighbor.id === leaf.id) return false; + if (neighbor.data?.solid === true || neighbor.data?.outside === true) return true; + return false; +} + +function portalLeafFaceDirection(face: BoxFace): { axis: 0 | 1 | 2; side: -1 | 1 } { + switch (face) { + case "right": + return { axis: 0, side: 1 }; + case "left": + return { axis: 0, side: -1 }; + case "front": + return { axis: 1, side: 1 }; + case "back": + return { axis: 1, side: -1 }; + case "top": + return { axis: 2, side: 1 }; + case "bottom": + return { axis: 2, side: -1 }; + } +} + +function centerOfBounds(bounds: PolyWorldBounds): Vec3 { + return [ + (bounds.min[0] + bounds.max[0]) / 2, + (bounds.min[1] + bounds.max[1]) / 2, + (bounds.min[2] + bounds.max[2]) / 2, + ]; +} + +function portalSurfaceEntry( + id: string, + regionId: RegionId, + polygon: Polygon, + leafId?: string, + role?: PolyWorldBspViewSurfaceRole, + visibility?: PolyWorldBspViewSurfaceVisibility, +): PortalSurfaceEntry { + const vertices = polygon.vertices.map((vertex) => [...vertex] as Vec3); + return { + id, + elementId: id, + regionId, + vertices, + ...(role === undefined ? {} : { role }), + ...(visibility === undefined ? {} : { visibility }), + ...(leafId === undefined ? {} : { leafId }), + shape: shapeFromPolygons([{ ...polygon, vertices }]), + position: [0, 0, 0] as Vec3, + rotation: [0, 0, 0] as Vec3, + }; +} + +function createPortalGeometrySurfaces(): PolyWorldBspViewSurfaceElement[] { + return portalSurfaceEntries.map((entry) => ({ + id: entry.id, + vertices: entry.vertices.map((vertex) => [...vertex] as Vec3), + ...(entry.role === undefined ? {} : { role: entry.role }), + ...(entry.visibility === undefined ? {} : { visibility: entry.visibility }), + regionId: entry.regionId, + elementId: entry.elementId, + })); +} + +function createPortalMiniMap(host: HTMLElement): PortalMiniMap { + const mapWidth = 196; + const mapHeight = 164; + const svgNs = "http://www.w3.org/2000/svg"; + const bounds = unionBounds(portalRooms.map(roomBounds)); + const padding = 12; + const spanX = bounds.max[0] - bounds.min[0]; + const spanY = bounds.max[1] - bounds.min[1]; + const scale = Math.min((mapWidth - padding * 2) / spanX, (mapHeight - padding * 2) / spanY); + const offsetX = (mapWidth - spanX * scale) / 2; + const offsetY = (mapHeight - spanY * scale) / 2; + const project = (point: Vec3): [number, number] => [ + offsetX + (point[0] - bounds.min[0]) * scale, + offsetY + (bounds.max[1] - point[1]) * scale, + ]; + const wrapper = document.createElement("div"); + wrapper.className = "portal-minimap"; + wrapper.setAttribute("aria-label", "Portal room visibility map"); + + const svg = document.createElementNS(svgNs, "svg"); + svg.setAttribute("viewBox", `0 0 ${mapWidth} ${mapHeight}`); + svg.setAttribute("role", "img"); + svg.setAttribute("aria-hidden", "true"); + wrapper.append(svg); + + const linkGroup = document.createElementNS(svgNs, "g"); + const roomGroup = document.createElementNS(svgNs, "g"); + const labelGroup = document.createElementNS(svgNs, "g"); + const camera = document.createElementNS(svgNs, "g"); + camera.classList.add("portal-minimap-camera"); + const cameraArrow = document.createElementNS(svgNs, "path"); + cameraArrow.setAttribute("d", "M0 -5 L4 5 L0 2.6 L-4 5 Z"); + camera.append(cameraArrow); + svg.append(linkGroup, roomGroup, labelGroup, camera); + + const links = new Map(); + for (const link of portalLinks) { + const fromRoom = portalRoomsById.get(link.from); + const toRoom = portalRoomsById.get(link.to); + if (fromRoom === undefined || toRoom === undefined) continue; + const [x1, y1] = project(portalCenterForRoomSide(fromRoom, link.fromSide)); + const [x2, y2] = project(portalCenterForRoomSide(toRoom, link.toSide)); + const line = document.createElementNS(svgNs, "line"); + line.classList.add("portal-minimap-link"); + line.setAttribute("data-link-id", link.id); + line.style.setProperty("--link-color", link.color); + line.setAttribute("x1", formatMiniMapNumber(x1)); + line.setAttribute("y1", formatMiniMapNumber(y1)); + line.setAttribute("x2", formatMiniMapNumber(x2)); + line.setAttribute("y2", formatMiniMapNumber(y2)); + linkGroup.append(line); + links.set(link.id, line); + } + + const rooms = new Map(); + const labels = new Map(); + for (const room of portalRooms) { + const box = roomBounds(room); + const [x, y] = project([box.min[0], box.max[1], 0]); + const width = (box.max[0] - box.min[0]) * scale; + const height = (box.max[1] - box.min[1]) * scale; + const rect = document.createElementNS(svgNs, "rect"); + rect.classList.add("portal-minimap-room"); + rect.setAttribute("data-region-id", room.id); + rect.style.setProperty("--room-color", room.wall); + rect.setAttribute("x", formatMiniMapNumber(x)); + rect.setAttribute("y", formatMiniMapNumber(y)); + rect.setAttribute("width", formatMiniMapNumber(width)); + rect.setAttribute("height", formatMiniMapNumber(height)); + rect.setAttribute("rx", "3"); + rect.setAttribute("ry", "3"); + const title = document.createElementNS(svgNs, "title"); + title.textContent = room.label; + rect.append(title); + roomGroup.append(rect); + rooms.set(room.id, rect); + + const [labelX, labelY] = project(room.center); + const label = document.createElementNS(svgNs, "text"); + label.classList.add("portal-minimap-label"); + label.setAttribute("data-region-id", room.id); + label.setAttribute("x", formatMiniMapNumber(labelX)); + label.setAttribute("y", formatMiniMapNumber(labelY)); + label.textContent = room.label; + labelGroup.append(label); + labels.set(room.id, label); + } + + host.append(wrapper); + return { rooms, labels, links, camera, project }; +} + +function updatePortalMiniMap( + miniMap: PortalMiniMap, + activeRegionId: RegionId, + visibleRegionIds: readonly RegionId[], + origin: Vec3, + yaw: number, +): void { + const visible = new Set(visibleRegionIds); + for (const [regionId, rect] of miniMap.rooms) { + const isActive = regionId === activeRegionId; + const isVisible = visible.has(regionId); + rect.classList.toggle("is-visible", isVisible); + rect.classList.toggle("is-active", isActive); + miniMap.labels.get(regionId)?.classList.toggle("is-visible", isVisible); + miniMap.labels.get(regionId)?.classList.toggle("is-active", isActive); + } + for (const link of portalLinks) { + const line = miniMap.links.get(link.id); + if (line === undefined) continue; + line.classList.toggle("is-visible", visible.has(link.from) && visible.has(link.to)); + } + const [x, y] = miniMap.project(origin); + miniMap.camera.setAttribute( + "transform", + `translate(${formatMiniMapNumber(x)} ${formatMiniMapNumber(y)}) rotate(${formatMiniMapNumber(270 - yaw)})`, + ); +} + +function formatMiniMapNumber(value: number): string { + return String(Math.round(value * 100) / 100); +} + +function roomShellBoxes(room: PortalRoomSpec): BoxSpec[] { + const [x, y] = room.center; + const boxes: BoxSpec[] = [ + { size: [portalRoomWidth, portalRoomDepth, 0.14], center: [x, y, -0.07], color: room.floor }, + { size: [portalRoomWidth, portalRoomDepth, 0.14], center: [x, y, 2.52], color: room.ceiling }, + ]; + for (const side of ["west", "east", "north", "south"] as const) { + pushRoomWall(boxes, room, side, room.doorways.includes(side)); + } + return boxes; +} + +type BoxSpec = { + id?: string; + size: Vec3; + center: Vec3; + color: string; +}; + +function pushRoomWall( + boxes: BoxSpec[], + room: PortalRoomSpec, + side: PortalSide, + hasDoorway: boolean, +): void { + const [x, y] = room.center; + const halfWidth = portalRoomWidth / 2; + const halfDepth = portalRoomDepth / 2; + const sideLength = side === "west" || side === "east" ? portalRoomDepth : portalRoomWidth; + const sideSpan = (sideLength - portalDoorWidth) / 2; + const wallZ = portalWallCenterZ; + const lintelZ = portalDoorHeight + portalLintelHeight / 2; + const sign = side === "east" || side === "north" ? 1 : -1; + + if (!hasDoorway) { + boxes.push( + side === "west" || side === "east" + ? { size: [portalWallThickness, portalRoomDepth, portalWallHeight], center: [x + sign * halfWidth, y, wallZ], color: room.wall } + : { size: [portalRoomWidth, portalWallThickness, portalWallHeight], center: [x, y + sign * halfDepth, wallZ], color: room.wall }, + ); + return; + } + + const negativeOffset = -(portalDoorHalf + sideSpan / 2); + const positiveOffset = portalDoorHalf + sideSpan / 2; + if (side === "west" || side === "east") { + const wallX = x + sign * halfWidth; + boxes.push( + { size: [portalWallThickness, sideSpan, portalWallHeight], center: [wallX, y + negativeOffset, wallZ], color: room.wall }, + { size: [portalWallThickness, sideSpan, portalWallHeight], center: [wallX, y + positiveOffset, wallZ], color: room.wall }, + { size: [portalWallThickness, portalDoorWidth, portalLintelHeight], center: [wallX, y, lintelZ], color: room.wall }, + ); + } else { + const wallY = y + sign * halfDepth; + boxes.push( + { size: [sideSpan, portalWallThickness, portalWallHeight], center: [x + negativeOffset, wallY, wallZ], color: room.wall }, + { size: [sideSpan, portalWallThickness, portalWallHeight], center: [x + positiveOffset, wallY, wallZ], color: room.wall }, + { size: [portalDoorWidth, portalWallThickness, portalLintelHeight], center: [x, wallY, lintelZ], color: room.wall }, + ); + } +} + +function pushDoorwayFrameX(boxes: BoxSpec[], x: number, y: number, color: string, idPrefix: string): void { + boxes.push( + { id: `${idPrefix}-post-a`, size: [0.44, 0.26, portalFramePostHeight], center: [x, y - portalDoorHalf, portalFramePostCenterZ], color }, + { id: `${idPrefix}-post-b`, size: [0.44, 0.26, portalFramePostHeight], center: [x, y + portalDoorHalf, portalFramePostCenterZ], color }, + { id: `${idPrefix}-lintel`, size: [0.44, portalDoorWidth + 0.28, 0.24], center: [x, y, 2.12], color }, + ); +} + +function pushDoorwayFrameY(boxes: BoxSpec[], x: number, y: number, color: string, idPrefix: string): void { + boxes.push( + { id: `${idPrefix}-post-a`, size: [0.26, 0.44, portalFramePostHeight], center: [x - portalDoorHalf, y, portalFramePostCenterZ], color }, + { id: `${idPrefix}-post-b`, size: [0.26, 0.44, portalFramePostHeight], center: [x + portalDoorHalf, y, portalFramePostCenterZ], color }, + { id: `${idPrefix}-lintel`, size: [portalDoorWidth + 0.28, 0.44, 0.24], center: [x, y, 2.12], color }, + ); +} + +function pushRoomProps(boxes: BoxSpec[], room: PortalRoomSpec): void { + const [x, y] = room.center; + switch (room.id) { + case "studio": + boxes.push( + { size: [1.8, 0.42, 0.52], center: [x - 2.1, y - 2.65, 0.26], color: "#c89b5f" }, + { size: [0.42, 0.42, 1.28], center: [x - 3.05, y - 2.65, 0.64], color: "#8e6440" }, + { size: [0.42, 0.42, 1.28], center: [x - 1.15, y - 2.65, 0.64], color: "#8e6440" }, + { size: [0.7, 0.7, 0.7], center: [x - 2.7, y + 2.45, 0.35], color: "#b9864e" }, + { size: [0.58, 0.58, 1.02], center: [x - 1.75, y + 2.55, 0.51], color: "#6d5749" }, + ); + return; + case "gallery": + boxes.push( + { size: [0.95, 0.95, 0.72], center: [x - 2.45, y - 2.45, 0.36], color: "#83bfb6" }, + { size: [0.62, 0.62, 1.18], center: [x - 2.45, y - 2.45, 0.95], color: "#d0b46d" }, + { size: [0.95, 0.95, 0.72], center: [x + 2.45, y - 2.45, 0.36], color: "#5e91a8" }, + { size: [0.62, 0.62, 1.18], center: [x + 2.45, y - 2.45, 0.95], color: "#c98774" }, + { size: [1.7, 0.22, 1.4], center: [x, y - 3.0, 0.7], color: "#91b7ba" }, + ); + return; + case "vault": + boxes.push( + { size: [0.82, 0.82, 1.6], center: [x + 2.7, y - 2.2, 0.8], color: "#b99b4f" }, + { size: [0.82, 0.82, 1.6], center: [x + 2.7, y + 2.2, 0.8], color: "#b99b4f" }, + { size: [1.15, 1.15, 1.02], center: [x + 2.45, y, 0.51], color: "#d0b863" }, + { size: [0.76, 0.76, 0.54], center: [x + 2.45, y, 1.3], color: "#807c4d" }, + { size: [0.48, 2.8, 0.34], center: [x + 3.15, y, 0.17], color: "#6c7651" }, + ); + return; + case "observatory": + boxes.push( + { size: [1.05, 1.05, 0.38], center: [x - 2.1, y + 2.25, 0.19], color: "#5aa7b5" }, + { size: [0.36, 0.36, 1.45], center: [x - 2.1, y + 2.25, 0.94], color: "#b7d0ce" }, + { size: [1.9, 0.34, 0.34], center: [x - 1.35, y + 2.25, 1.58], color: "#d6c48b" }, + { size: [0.62, 0.62, 0.62], center: [x + 2.55, y + 2.35, 0.31], color: "#77b5bf" }, + { size: [0.9, 0.28, 1.12], center: [x + 2.55, y + 1.75, 0.56], color: "#4e6d7b" }, + ); + return; + case "engine": + boxes.push( + { size: [1.3, 0.9, 0.96], center: [x - 2.35, y - 2.35, 0.48], color: "#b45f49" }, + { size: [0.76, 0.76, 1.46], center: [x - 3.05, y - 1.55, 0.73], color: "#7f5148" }, + { size: [1.9, 0.28, 0.28], center: [x - 2.3, y + 2.65, 1.52], color: "#d08b55" }, + { size: [0.34, 1.9, 0.34], center: [x + 2.55, y - 2.2, 1.12], color: "#c46c4f" }, + { size: [1.1, 0.78, 0.72], center: [x + 2.4, y - 2.45, 0.36], color: "#6b5551" }, + ); + return; + case "archive": + boxes.push( + { size: [0.44, 2.2, 1.85], center: [x - 3.0, y + 1.45, 0.93], color: "#6d6385" }, + { size: [0.5, 0.36, 1.32], center: [x - 2.4, y + 2.18, 0.66], color: "#c5a66b" }, + { size: [0.5, 0.36, 1.06], center: [x - 2.4, y + 1.42, 0.53], color: "#8e89b8" }, + { size: [0.5, 0.36, 1.52], center: [x - 2.4, y + 0.66, 0.76], color: "#a07092" }, + { size: [1.35, 0.82, 0.58], center: [x + 2.45, y + 2.45, 0.29], color: "#9075a8" }, + ); + return; + } +} + +function roomDetailBoxes(room: PortalRoomSpec): BoxSpec[] { + const boxes: BoxSpec[] = []; + for (const side of room.doorways) { + pushRoomDoorFrame(boxes, room, side); + } + pushRoomProps(boxes, room); + return boxes; +} + +function pushRoomDoorFrame(boxes: BoxSpec[], room: PortalRoomSpec, side: PortalSide): void { + const center = portalCenterForRoomSide(room, side); + const link = portalLinkForRoomSide(room.id, side); + const color = link?.color ?? "#c59a55"; + const idPrefix = `door-${side}`; + if (side === "west" || side === "east") { + pushDoorwayFrameX(boxes, center[0], center[1], color, idPrefix); + } else { + pushDoorwayFrameY(boxes, center[0], center[1], color, idPrefix); + } +} + +function portalLinkForRoomSide(regionId: RegionId, side: PortalSide): PortalLinkSpec | undefined { + return portalLinks.find((link) => + (link.from === regionId && link.fromSide === side) || + (link.to === regionId && link.toSide === side) + ); +} + +function roomBounds(room: PortalRoomSpec) { + const halfWidth = portalRoomWidth / 2; + const halfDepth = portalRoomDepth / 2; + return { + min: [room.center[0] - halfWidth, room.center[1] - halfDepth, -0.2] as Vec3, + max: [room.center[0] + halfWidth, room.center[1] + halfDepth, 2.8] as Vec3, + }; +} + +function portalCenterForLink(link: PortalLinkSpec): Vec3 { + const from = portalRoomsById.get(link.from); + if (!from) return [0, 0, portalEyeHeight]; + return portalCenterForRoomSide(from, link.fromSide); +} + +function portalCenterForRoomSide(room: PortalRoomSpec, side: PortalSide): Vec3 { + const halfWidth = portalRoomWidth / 2; + const halfDepth = portalRoomDepth / 2; + switch (side) { + case "west": + return [room.center[0] - halfWidth, room.center[1], portalEyeHeight]; + case "east": + return [room.center[0] + halfWidth, room.center[1], portalEyeHeight]; + case "north": + return [room.center[0], room.center[1] + halfDepth, portalEyeHeight]; + case "south": + return [room.center[0], room.center[1] - halfDepth, portalEyeHeight]; + } +} + +function createPortalBspBrushes(): PolyWorldBspBrush[] { + const brushes: PolyWorldBspBrush[] = []; + for (const room of portalRooms) { + const boxes = roomShellBoxes(room); + for (let index = 0; index < boxes.length; index += 1) { + const box = boxes[index]; + if (box === undefined) continue; + brushes.push({ + id: `${room.id}-wall-brush-${index}`, + bounds: boundsFromBox(box), + }); + } + } + return brushes; +} + +function createPortalWorldBounds(brushes: readonly PolyWorldBspBrush[]): PolyWorldBounds { + return unionBounds([ + ...portalRooms.map(roomBounds), + ...brushes.map((brush) => brush.bounds), + ]); +} + +function boundsFromBox(box: BoxSpec): PolyWorldBounds { + return { + min: [ + box.center[0] - box.size[0] / 2, + box.center[1] - box.size[1] / 2, + box.center[2] - box.size[2] / 2, + ] as Vec3, + max: [ + box.center[0] + box.size[0] / 2, + box.center[1] + box.size[1] / 2, + box.center[2] + box.size[2] / 2, + ] as Vec3, + }; +} + +function unionBounds(boundsList: readonly PolyWorldBounds[]): PolyWorldBounds { + const min: Vec3 = [Infinity, Infinity, Infinity]; + const max: Vec3 = [-Infinity, -Infinity, -Infinity]; + for (const bounds of boundsList) { + for (const axis of [0, 1, 2] as const) { + min[axis] = Math.min(min[axis], bounds.min[axis]); + max[axis] = Math.max(max[axis], bounds.max[axis]); + } + } + return { min, max }; +} + +function createChunkMeshes(): MeshEntry[] { + const entries: MeshEntry[] = []; + for (let index = 0; index < chunkCount; index += 1) { + const x = chunkX(index); + const roadColors = ["#454d55", "#5d5146", "#485f58", "#5a495e", "#60613f", "#3f5a65", "#654642"]; + const leftColors = ["#b0834e", "#6f8456", "#9a674c", "#5f7b8c", "#8d6b9b", "#8e8c5a", "#b0675a"]; + const rightColors = ["#587999", "#9c6a5c", "#6f8d71", "#9f8253", "#66879c", "#966e8f", "#6b8c72"]; + const leftHeight = 0.85 + (index % 3) * 0.18; + const rightHeight = 1.05 + ((index + 1) % 3) * 0.2; + entries.push( + boxEntry(`chunk-${index}-road`, [chunkVisualWidth, chunkRoadWidth, 0.12], roadColors[index % roadColors.length], [x, 0, -0.06]), + chunkNumberEntry(index, x), + boxEntry(`chunk-${index}-left`, [chunkVisualWidth, 0.18, leftHeight], leftColors[index % leftColors.length], [x, -chunkRailY, leftHeight / 2 - 0.06]), + boxEntry(`chunk-${index}-right`, [chunkVisualWidth, 0.18, rightHeight], rightColors[index % rightColors.length], [x, chunkRailY, rightHeight / 2 - 0.06]), + gateEntry( + `chunk-${index}-gate`, + x + chunkStep / 2 - 0.16, + index === 0 || index === chunkCount - 1 ? "#d05648" : "#caa45d", + ), + ); + } + return entries; +} + +type DigitSegment = "a" | "b" | "c" | "d" | "e" | "f" | "g"; + +const digitSegments: Record = { + "0": ["a", "b", "c", "d", "e", "f"], + "1": ["b", "c"], + "2": ["a", "b", "g", "e", "d"], + "3": ["a", "b", "g", "c", "d"], + "4": ["f", "g", "b", "c"], + "5": ["a", "f", "g", "c", "d"], + "6": ["a", "f", "g", "e", "c", "d"], + "7": ["a", "b", "c"], + "8": ["a", "b", "c", "d", "e", "f", "g"], + "9": ["a", "b", "c", "d", "f", "g"], +}; + +function chunkNumberEntry(index: number, x: number): MeshEntry { + const label = String(index + 1).padStart(2, "0"); + const boxes: BoxSpec[] = []; + const digitAdvance = 0.72; + const start = -((label.length - 1) * digitAdvance) / 2; + for (let digitIndex = 0; digitIndex < label.length; digitIndex += 1) { + const digit = label[digitIndex] ?? "0"; + const digitX = x + start + digitIndex * digitAdvance; + for (const segment of digitSegments[digit] ?? []) { + boxes.push(numberSegmentBox(segment, digitX)); + } + } + return meshEntry(`chunk-${index}-number`, shapeFromBoxes(boxes), [0, 0, 0]); +} + +function numberSegmentBox(segment: DigitSegment, x: number): BoxSpec { + const z = 0.018; + const color = "#c7c0a6"; + const horizontal: Vec3 = [0.48, 0.075, 0.022]; + const vertical: Vec3 = [0.075, 0.38, 0.022]; + switch (segment) { + case "a": + return { size: horizontal, center: [x, 0.52, z], color }; + case "b": + return { size: vertical, center: [x + 0.28, 0.27, z], color }; + case "c": + return { size: vertical, center: [x + 0.28, -0.27, z], color }; + case "d": + return { size: horizontal, center: [x, -0.52, z], color }; + case "e": + return { size: vertical, center: [x - 0.28, -0.27, z], color }; + case "f": + return { size: vertical, center: [x - 0.28, 0.27, z], color }; + case "g": + return { size: horizontal, center: [x, 0, z], color }; + } +} + +function gateEntry(id: string, x: number, color: string): MeshEntry { + return meshEntry( + id, + shapeFromBoxes([ + { size: [0.18, 0.24, 1.32], center: [x, -1.7, 0.6], color }, + { size: [0.18, 0.24, 1.32], center: [x, 1.7, 0.6], color }, + { size: [0.18, 3.76, 0.2], center: [x, 0, 1.24], color }, + ]), + [0, 0, 0], + ); +} + +function createRunnerMesh(): ReturnType { + return shapeFromBoxes([ + { size: [1.45, 0.88, 0.3], center: [0, 0, 0.18], color: "#d65a4d" }, + { size: [0.62, 0.7, 0.34], center: [-0.16, 0, 0.5], color: "#f0d98a" }, + { size: [0.44, 0.68, 0.14], center: [0.58, 0, 0.36], color: "#efcf67" }, + { size: [0.26, 0.16, 0.24], center: [0.48, -0.52, 0.04], color: "#20242a" }, + { size: [0.26, 0.16, 0.24], center: [0.48, 0.52, 0.04], color: "#20242a" }, + { size: [0.26, 0.16, 0.24], center: [-0.48, -0.52, 0.04], color: "#20242a" }, + { size: [0.26, 0.16, 0.24], center: [-0.48, 0.52, 0.04], color: "#20242a" }, + { size: [0.1, 0.56, 0.08], center: [0.77, 0, 0.22], color: "#f4efe0" }, + ]); +} + +function shapeFromBoxes(boxes: readonly BoxSpec[]): ReturnType { + const polygons = boxes.flatMap((box) => + boxPolygons({ + size: box.size, + center: box.center, + color: box.color, + }), + ); + return shapeFromPolygons(polygons); +} + +function shapeFromPolygons(polygons: readonly Polygon[]): ReturnType { + return { + polygons: polygons.map((polygon) => ({ ...polygon, vertices: polygon.vertices.map((vertex) => [...vertex] as Vec3) })), + elementUrls: [], + warnings: [], + dispose: () => {}, + }; +} + +function meshEntry(id: string, shape: ReturnType, position: Vec3, rotation: Vec3 = [0, 0, 0]): MeshEntry { + return { id, shape, position, rotation }; +} + +function boxEntry(id: string, size: Vec3, color: string, position: Vec3, rotation: Vec3 = [0, 0, 0]): MeshEntry { + return meshEntry(id, createPolyBox({ size, color }), position, rotation); +} + +function makeButton(label: string, onClick: () => void, roomId?: string) { + const button = document.createElement("button"); + button.type = "button"; + button.textContent = label; + if (roomId) button.dataset.room = roomId; + button.addEventListener("click", onClick); + return button; +} + +function createReadout(text: string) { + const readout = document.createElement("span"); + readout.className = "control-readout"; + readout.dataset.current = ""; + readout.textContent = text; + return readout; +} + +function constrainPortalOrigin(origin: Vec3, previous: Vec3): Vec3 { + const candidate: Vec3 = [origin[0], origin[1], portalEyeHeight]; + if (isWalkableBspPoint(candidate) && isWalkableBspPath(previous, candidate)) { + return candidate; + } + return [...previous] as Vec3; +} + +function isWalkableBspPath(from: Vec3, to: Vec3): boolean { + const dx = to[0] - from[0]; + const dy = to[1] - from[1]; + const dz = to[2] - from[2]; + const distance = Math.hypot(dx, dy, dz); + const steps = Math.max(1, Math.ceil(distance / 0.18)); + for (let step = 1; step <= steps; step += 1) { + const t = step / steps; + if (!isWalkableBspPoint([from[0] + dx * t, from[1] + dy * t, from[2] + dz * t])) return false; + } + return true; +} + +function isWalkableBspPoint(point: Vec3): boolean { + const leaf = resolvePolyWorldBspLeaf(portalBspTree, point)?.leaf; + return leaf !== undefined && leaf.data?.solid !== true && leaf.regionId !== undefined; +} + +function portalCameraForward(rotX: number, rotY: number): Vec3 { + const rx = rotX * Math.PI / 180; + const ry = rotY * Math.PI / 180; + return [ + -Math.sin(rx) * Math.cos(ry), + -Math.sin(rx) * Math.sin(ry), + -Math.cos(rx), + ]; +} + +function resolvePortalViewFovDegrees(host: HTMLElement): number { + const width = host.getBoundingClientRect().width || 960; + const fov = 2 * Math.atan(width / (2 * portalPerspective)) * 180 / Math.PI; + return Math.max(58, Math.min(96, fov)); +} + +function resolvePortalViewAspect(host: HTMLElement): number { + const rect = host.getBoundingClientRect(); + const width = rect.width || 960; + const height = rect.height || 540; + return Math.max(0.25, Math.min(4, width / height)); +} + +function sameVec3(a: Vec3, b: Vec3) { + return Math.abs(a[0] - b[0]) < 0.001 && Math.abs(a[1] - b[1]) < 0.001 && Math.abs(a[2] - b[2]) < 0.001; +} + +function nearestChunkIndex(x: number) { + return Math.max(0, Math.min(chunkCount - 1, Math.round(x / chunkStep + Math.floor(chunkCount / 2)))); +} + +function chunkIndexFromRegionId(regionId: string) { + const rawIndex = Number(regionId.replace("chunk-", "")); + return Number.isFinite(rawIndex) ? Math.max(0, Math.min(chunkCount - 1, rawIndex)) : 0; +} + +function chunkX(index: number) { + return (index - Math.floor(chunkCount / 2)) * chunkStep; +} + +function formatVec(value: Vec3) { + return value.map((component) => Math.round(component * 10) / 10); +} + +mountPortalDemo(); +mountChunkDemo(); diff --git a/examples/world/package.json b/examples/world/package.json new file mode 100644 index 000000000..491f865f5 --- /dev/null +++ b/examples/world/package.json @@ -0,0 +1,20 @@ +{ + "name": "@layoutit/polycss-examples-world", + "private": true, + "version": "0.0.0", + "type": "module", + "scripts": { + "dev": "vite", + "build": "vite build", + "preview": "vite preview", + "test:browser": "node browser-regression.mjs" + }, + "dependencies": { + "@layoutit/polycss": "workspace:^", + "@layoutit/polycss-world": "workspace:^" + }, + "devDependencies": { + "typescript": "^5.3.3", + "vite": "^6.0.0" + } +} diff --git a/examples/world/styles.css b/examples/world/styles.css new file mode 100644 index 000000000..139194516 --- /dev/null +++ b/examples/world/styles.css @@ -0,0 +1,338 @@ +:root { + color: #ede7dc; + background: #171717; + font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; + font-synthesis: none; + text-rendering: geometricPrecision; +} + +* { + box-sizing: border-box; +} + +body { + min-width: 320px; + min-height: 100vh; + margin: 0; + background: + linear-gradient(135deg, rgba(184, 95, 77, 0.14), transparent 34%), + linear-gradient(215deg, rgba(78, 157, 125, 0.15), transparent 38%), + #171717; +} + +button { + min-width: 68px; + min-height: 34px; + border: 1px solid rgba(237, 231, 220, 0.22); + border-radius: 7px; + background: #262626; + color: #f4eee5; + font: inherit; + cursor: pointer; +} + +button:hover, +button[aria-pressed="true"], +button.is-active { + border-color: #d7a64e; + background: #3a2c1b; +} + +html.polycss-world-mouselook, +html.polycss-world-mouselook * { + cursor: none !important; +} + +.app-shell { + width: min(1480px, 100%); + margin: 0 auto; + padding: 24px; +} + +.app-header { + display: flex; + align-items: baseline; + justify-content: space-between; + gap: 20px; + padding: 0 0 18px; +} + +.app-header h1, +.demo-panel h2, +.app-header p, +.demo-panel p { + margin: 0; +} + +.app-header h1 { + font-size: 28px; + line-height: 1.1; +} + +.app-header p, +.demo-panel p { + color: #b8ada0; +} + +.demo-grid { + display: grid; + grid-template-columns: minmax(0, 1fr); + gap: 18px; +} + +.demo-panel { + min-width: 0; + border: 1px solid rgba(237, 231, 220, 0.16); + border-radius: 8px; + background: rgba(28, 28, 27, 0.86); + overflow: hidden; +} + +.panel-head { + display: flex; + align-items: center; + justify-content: space-between; + gap: 16px; + min-height: 78px; + padding: 16px; + border-bottom: 1px solid rgba(237, 231, 220, 0.12); +} + +.demo-panel h2 { + font-size: 18px; + line-height: 1.2; +} + +.controls { + display: flex; + flex-wrap: wrap; + justify-content: flex-end; + gap: 8px; +} + +.control-readout { + min-width: 96px; + min-height: 34px; + display: inline-flex; + align-items: center; + justify-content: center; + color: #f0d58c; + white-space: nowrap; +} + +.scene-host { + position: relative; + height: clamp(420px, 46vw, 680px); + overflow: hidden; + background: + linear-gradient(180deg, rgba(255, 255, 255, 0.04), transparent), + #101010; +} + +[data-demo="portal"] .scene-host::after { + position: absolute; + inset: 50% auto auto 50%; + width: 18px; + height: 18px; + border-top: 2px solid rgba(245, 239, 229, 0.82); + border-left: 2px solid rgba(245, 239, 229, 0.82); + content: ""; + pointer-events: none; + transform: translate(-50%, -50%) rotate(45deg); +} + +[data-demo="portal"] .scene-host { + cursor: crosshair; + touch-action: none; + user-select: none; +} + +[data-demo="portal"] .scene-host:focus-visible { + outline: 2px solid #d7a64e; + outline-offset: -2px; +} + +[data-demo="portal"][data-mouse-look="locked"] .scene-host, +[data-demo="portal"][data-mouse-look="fallback"] .scene-host { + cursor: none; +} + +.portal-minimap { + position: absolute; + top: 12px; + right: 12px; + z-index: 5; + width: min(210px, 34vw); + border: 1px solid rgba(237, 231, 220, 0.26); + border-radius: 8px; + background: rgba(18, 18, 17, 0.82); + box-shadow: 0 10px 26px rgba(0, 0, 0, 0.28); + pointer-events: none; +} + +.portal-minimap svg { + display: block; + width: 100%; + height: auto; +} + +.portal-minimap-link { + stroke: var(--link-color); + stroke-linecap: round; + stroke-width: 2; + opacity: 0.24; +} + +.portal-minimap-link.is-visible { + opacity: 0.86; +} + +.portal-minimap-room { + fill: var(--room-color); + stroke: rgba(237, 231, 220, 0.3); + stroke-width: 1; + opacity: 0.22; +} + +.portal-minimap-room.is-visible { + stroke: rgba(245, 239, 229, 0.72); + opacity: 0.7; +} + +.portal-minimap-room.is-active { + stroke: #f0bd54; + stroke-width: 2.2; + opacity: 1; +} + +.portal-minimap-label { + fill: rgba(245, 239, 229, 0.58); + font-size: 6px; + font-weight: 800; + letter-spacing: 0; + dominant-baseline: middle; + text-anchor: middle; +} + +.portal-minimap-label.is-visible { + fill: rgba(255, 248, 232, 0.88); +} + +.portal-minimap-label.is-active { + fill: #fff4bc; +} + +.portal-minimap-camera path { + fill: #f4ead6; + stroke: #161616; + stroke-width: 1; +} + +[data-demo="portal"] .polycss-mesh > b, +[data-demo="portal"] .polycss-mesh > i, +[data-demo="portal"] .polycss-mesh > s, +[data-demo="portal"] .polycss-mesh > u { + backface-visibility: visible !important; +} + +.world-readout { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 1px; + margin: 0; + border-top: 1px solid rgba(237, 231, 220, 0.12); + background: rgba(237, 231, 220, 0.1); +} + +.world-readout > div { + min-width: 0; + padding: 10px 12px; + background: #171717; +} + +.world-readout dt { + margin: 0 0 5px; + color: #9fa694; + font-size: 11px; + font-weight: 700; + letter-spacing: 0; + text-transform: uppercase; +} + +.world-readout dd { + min-height: 32px; + margin: 0; + overflow: hidden; + color: #e7dccb; + font: 11px/1.35 ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; + text-overflow: ellipsis; +} + +.debug-panel { + min-height: 224px; + max-height: 300px; + margin: 0; + padding: 14px 16px; + overflow: auto; + border-top: 1px solid rgba(237, 231, 220, 0.12); + background: #121212; + color: #c9e8db; + font: 12px/1.45 ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; +} + +.debug-details { + border-top: 1px solid rgba(237, 231, 220, 0.12); + background: #121212; +} + +.debug-details summary { + min-height: 38px; + display: flex; + align-items: center; + padding: 0 16px; + color: #b8ada0; + cursor: pointer; + font-size: 12px; +} + +.debug-details .debug-panel { + border-top: 1px solid rgba(237, 231, 220, 0.12); +} + +[data-world-element] { + transition: opacity 160ms ease; +} + +[data-world-element][hidden] { + display: none; +} + +@media (max-width: 940px) { + .app-header, + .panel-head { + align-items: flex-start; + flex-direction: column; + } + + .demo-grid { + grid-template-columns: 1fr; + } + + .controls { + justify-content: flex-start; + } + + .world-readout { + grid-template-columns: 1fr; + } + + .scene-host { + height: clamp(320px, 62vw, 520px); + } + + .portal-minimap { + top: 10px; + right: 10px; + width: min(178px, 44vw); + } +} diff --git a/examples/world/tsconfig.json b/examples/world/tsconfig.json new file mode 100644 index 000000000..6cb110a61 --- /dev/null +++ b/examples/world/tsconfig.json @@ -0,0 +1,19 @@ +{ + "compilerOptions": { + "target": "ES2020", + "useDefineForClassFields": true, + "module": "ESNext", + "lib": ["ES2020", "DOM", "DOM.Iterable"], + "allowJs": false, + "skipLibCheck": true, + "esModuleInterop": true, + "allowSyntheticDefaultImports": true, + "strict": true, + "forceConsistentCasingInFileNames": true, + "moduleResolution": "Node", + "resolveJsonModule": true, + "isolatedModules": true, + "noEmit": true + }, + "include": ["*.ts"] +} diff --git a/examples/world/vite.config.ts b/examples/world/vite.config.ts new file mode 100644 index 000000000..6150f9a69 --- /dev/null +++ b/examples/world/vite.config.ts @@ -0,0 +1,3 @@ +import { defineConfig } from "vite"; + +export default defineConfig({}); diff --git a/packages/core/src/cull/cameraVisibility.test.ts b/packages/core/src/cull/cameraVisibility.test.ts new file mode 100644 index 000000000..45cd61903 --- /dev/null +++ b/packages/core/src/cull/cameraVisibility.test.ts @@ -0,0 +1,94 @@ +import { describe, it, expect } from "vitest"; +import { + computeCameraVisibility, + createCameraVisibilityContext, +} from "./cameraVisibility"; +import type { Polygon, Vec3 } from "../types"; + +// Axis-aligned quad facing +Z (top), CCW from +Z so its normal points +Z. +function topQuad(cx: number, cy: number, cz: number, size = 2): Polygon { + const h = size / 2; + return { + vertices: [ + [cx - h, cy - h, cz], + [cx + h, cy - h, cz], + [cx + h, cy + h, cz], + [cx - h, cy + h, cz], + ], + }; +} + +// Quad facing +X (a wall in the y-z plane), CCW when viewed from +X. +function wallQuadX(cx: number, cy: number, cz: number, size = 10): Polygon { + const h = size / 2; + return { + vertices: [ + [cx, cy - h, cz - h], + [cx, cy + h, cz - h], + [cx, cy + h, cz + h], + [cx, cy - h, cz + h], + ], + }; +} + +describe("computeCameraVisibility", () => { + it("a lone front-facing quad is visible; from behind it is not", () => { + const polys: Polygon[] = [topQuad(0, 0, 0)]; + const above = computeCameraVisibility(polys, [0, 0, 10]); + expect(above.has(0)).toBe(true); + const below = computeCameraVisibility(polys, [0, 0, -10]); + expect(below.has(0)).toBe(false); // back-facing to the eye + }); + + it("an opaque wall between eye and target occludes the target", () => { + // Two +X-facing walls; eye far on +X. Near wall (x=5) occludes the far + // wall (x=0) which sits directly behind it along the view ray. + const polys: Polygon[] = [ + wallQuadX(0, 0, 0), // index 0 — far, hidden behind index 1 + wallQuadX(5, 0, 0), // index 1 — near, visible + ]; + const eye: Vec3 = [100, 0, 0]; + const visible = computeCameraVisibility(polys, eye); + expect(visible.has(1)).toBe(true); + expect(visible.has(0)).toBe(false); + }); + + it("target is visible when the eye can see around the occluder", () => { + // Move the eye off-axis in +Y so the far wall is no longer behind the + // small near wall — the ray to the far centroid is now unblocked. + const polys: Polygon[] = [ + wallQuadX(0, 0, 0, 40), // far, large + wallQuadX(5, 0, 0, 4), // near, small — only blocks a sliver + ]; + // Steep off-axis eye: the far centroid→eye ray crosses the x=5 plane at + // y≈15, well clear of the small near wall's y∈[-2,2] span. + const eye: Vec3 = [10, 30, 0]; + const visible = computeCameraVisibility(polys, eye); + expect(visible.has(0)).toBe(true); + }); + + it("frustum culls faces outside the view cone", () => { + const polys: Polygon[] = [topQuad(0, 0, 0)]; + // Eye above looking DOWN (-Z) sees it; looking UP (+Z) does not. + const looking = computeCameraVisibility(polys, [0, 0, 10], { + frustum: { forward: [0, 0, -1], fovRadians: Math.PI / 3 }, + }); + expect(looking.has(0)).toBe(true); + const away = computeCameraVisibility(polys, [0, 0, 10], { + frustum: { forward: [0, 0, 1], fovRadians: Math.PI / 3 }, + }); + expect(away.has(0)).toBe(false); + }); + + it("context reuse: anyVisible answers per-group queries without rebuilding", () => { + const polys: Polygon[] = [ + wallQuadX(0, 0, 0), // group A (far) + wallQuadX(5, 0, 0), // group B (near, occludes A) + ]; + const ctx = createCameraVisibilityContext(polys); + const eye: Vec3 = [100, 0, 0]; + expect(ctx.anyVisible(eye, [1])).toBe(true); + expect(ctx.anyVisible(eye, [0])).toBe(false); + expect(ctx.query(eye).has(1)).toBe(true); + }); +}); diff --git a/packages/core/src/cull/cameraVisibility.ts b/packages/core/src/cull/cameraVisibility.ts new file mode 100644 index 000000000..e738333e1 --- /dev/null +++ b/packages/core/src/cull/cameraVisibility.ts @@ -0,0 +1,444 @@ +/** + * computeCameraVisibility — the CAMERA twin of computeLightVisibility. + * + * Where `computeLightVisibility` ray-casts each polygon centroid toward a + * distant LIGHT direction (parallel rays to infinity) to decide "does light + * reach this face", this decides "can the CAMERA at `eye` see this face": is a + * ray from `eye` to the face centroid unblocked by other opaque geometry of + * the same mesh, is the face front-facing to the eye, and (optionally) is it + * inside the view frustum. It is the CPU equivalent of one visibility sample + * per polygon from the camera's POV — exactly the shadow machinery re-keyed on + * the camera position instead of the light vector. + * + * The one geometric difference from the light case: the camera is a POINT, so + * the occlusion ray has a FINITE length (the eye→centroid distance). A hit + * BEYOND the eye is behind the camera and does not occlude; the traversal + * clamps `tMax` to that distance. + * + * Cost: O(F log F) to build the flat-array SAH BVH once, then O(log F) per + * polygon per camera position. Callers that sample MANY camera positions over + * one static mesh (per-cell PVS bake) build the context once with + * `createCameraVisibilityContext` and query it per cell — the BVH is not + * rebuilt per query, unlike the one-shot `computeLightVisibility`. + */ +import type { Polygon, Vec3 } from "../types"; + +const PARALLEL_EPS = 1e-9; +const MIN_HIT_T = 1e-3; +const RAY_ORIGIN_OFFSET = 1e-3; + +interface PolyMeta { + triFlat: Float64Array; + centroid: Vec3; + normal: Vec3; + bcx: number; bcy: number; bcz: number; br2: number; + minX: number; minY: number; minZ: number; + maxX: number; maxY: number; maxZ: number; +} + +function precompute(p: Polygon): PolyMeta | null { + const verts = p.vertices; + if (!verts || verts.length < 3) return null; + let cx = 0, cy = 0, cz = 0; + for (const [x, y, z] of verts) { cx += x; cy += y; cz += z; } + const inv = 1 / verts.length; + cx *= inv; cy *= inv; cz *= inv; + const v0 = verts[0], v1 = verts[1], v2 = verts[2]; + const e1x = v1[0] - v0[0], e1y = v1[1] - v0[1], e1z = v1[2] - v0[2]; + const e2x = v2[0] - v0[0], e2y = v2[1] - v0[1], e2z = v2[2] - v0[2]; + let nx = e1y * e2z - e1z * e2y; + let ny = e1z * e2x - e1x * e2z; + let nz = e1x * e2y - e1y * e2x; + const nLen = Math.hypot(nx, ny, nz); + if (nLen < PARALLEL_EPS) return null; + nx /= nLen; ny /= nLen; nz /= nLen; + const nTri = verts.length - 2; + const triFlat = new Float64Array(nTri * 9); + let ti = 0; + for (let i = 1; i < verts.length - 1; i++) { + const a = verts[0], b = verts[i], c = verts[i + 1]; + triFlat[ti++] = a[0]; triFlat[ti++] = a[1]; triFlat[ti++] = a[2]; + triFlat[ti++] = b[0]; triFlat[ti++] = b[1]; triFlat[ti++] = b[2]; + triFlat[ti++] = c[0]; triFlat[ti++] = c[1]; triFlat[ti++] = c[2]; + } + let br2 = 0; + let minX = Infinity, minY = Infinity, minZ = Infinity; + let maxX = -Infinity, maxY = -Infinity, maxZ = -Infinity; + for (const [x, y, z] of verts) { + const ddx = x - cx, ddy = y - cy, ddz = z - cz; + const d2 = ddx * ddx + ddy * ddy + ddz * ddz; + if (d2 > br2) br2 = d2; + if (x < minX) minX = x; if (x > maxX) maxX = x; + if (y < minY) minY = y; if (y > maxY) maxY = y; + if (z < minZ) minZ = z; if (z > maxZ) maxZ = z; + } + return { + centroid: [cx, cy, cz], normal: [nx, ny, nz], + triFlat, bcx: cx, bcy: cy, bcz: cz, br2, + minX, minY, minZ, maxX, maxY, maxZ, + }; +} + +function rayTriFlat( + ox: number, oy: number, oz: number, + dx: number, dy: number, dz: number, + tf: Float64Array, base: number, tMax: number, +): boolean { + const ax = tf[base], ay = tf[base + 1], az = tf[base + 2]; + const e1x = tf[base + 3] - ax, e1y = tf[base + 4] - ay, e1z = tf[base + 5] - az; + const e2x = tf[base + 6] - ax, e2y = tf[base + 7] - ay, e2z = tf[base + 8] - az; + const hx = dy * e2z - dz * e2y; + const hy = dz * e2x - dx * e2z; + const hz = dx * e2y - dy * e2x; + const det = e1x * hx + e1y * hy + e1z * hz; + if (det > -PARALLEL_EPS && det < PARALLEL_EPS) return false; + const invDet = 1 / det; + const sx = ox - ax, sy = oy - ay, sz = oz - az; + const u = invDet * (sx * hx + sy * hy + sz * hz); + if (u < 0 || u > 1) return false; + const qx = sy * e1z - sz * e1y; + const qy = sz * e1x - sx * e1z; + const qz = sx * e1y - sy * e1x; + const v = invDet * (dx * qx + dy * qy + dz * qz); + if (v < 0 || u + v > 1) return false; + const t = invDet * (e2x * qx + e2y * qy + e2z * qz); + return t > MIN_HIT_T && t < tMax; +} + +function rayHitsPolygon( + ox: number, oy: number, oz: number, + dx: number, dy: number, dz: number, + tMax: number, + q: PolyMeta, +): boolean { + const vx = q.bcx - ox, vy = q.bcy - oy, vz = q.bcz - oz; + const proj = vx * dx + vy * dy + vz * dz; + const perpX = vx - proj * dx; + const perpY = vy - proj * dy; + const perpZ = vz - proj * dz; + if (perpX * perpX + perpY * perpY + perpZ * perpZ > q.br2) return false; + const tf = q.triFlat; + const n = tf.length; + for (let b = 0; b < n; b += 9) { + if (rayTriFlat(ox, oy, oz, dx, dy, dz, tf, b, tMax)) return true; + } + return false; +} + +const BVH_STRIDE = 9; +const BVH_LEAF_SIZE = 6; +const SAH_BUCKETS = 12; + +interface BVH { + data: Float64Array; + nodeCount: number; + polyIndices: Int32Array; + meta: Array; +} + +function aabbSA(minX: number, minY: number, minZ: number, maxX: number, maxY: number, maxZ: number): number { + const dx = maxX - minX, dy = maxY - minY, dz = maxZ - minZ; + return dx * dy + dy * dz + dz * dx; +} + +function buildBVH(meta: Array): BVH { + const valid: number[] = []; + for (let i = 0; i < meta.length; i++) { if (meta[i]) valid.push(i); } + const n = valid.length; + const polyIndices = new Int32Array(n); + for (let i = 0; i < n; i++) polyIndices[i] = valid[i]; + const centX = new Float64Array(n); + const centY = new Float64Array(n); + const centZ = new Float64Array(n); + for (let i = 0; i < n; i++) { + const m = meta[polyIndices[i]]!; + centX[i] = (m.minX + m.maxX) * 0.5; + centY[i] = (m.minY + m.maxY) * 0.5; + centZ[i] = (m.minZ + m.maxZ) * 0.5; + } + const maxNodes = 2 * Math.max(1, n) + 1; + const data = new Float64Array(maxNodes * BVH_STRIDE); + let nodeCount = 0; + const bMinX = new Float64Array(SAH_BUCKETS); + const bMinY = new Float64Array(SAH_BUCKETS); + const bMinZ = new Float64Array(SAH_BUCKETS); + const bMaxX = new Float64Array(SAH_BUCKETS); + const bMaxY = new Float64Array(SAH_BUCKETS); + const bMaxZ = new Float64Array(SAH_BUCKETS); + const bCnt = new Int32Array(SAH_BUCKETS); + const lSA = new Float64Array(SAH_BUCKETS - 1); + const lCnt = new Int32Array(SAH_BUCKETS - 1); + const rSA = new Float64Array(SAH_BUCKETS - 1); + const rCnt = new Int32Array(SAH_BUCKETS - 1); + function buildNode(start: number, end: number): number { + const ni = nodeCount++; + const base = ni * BVH_STRIDE; + const count = end - start; + let minX = Infinity, minY = Infinity, minZ = Infinity; + let maxX = -Infinity, maxY = -Infinity, maxZ = -Infinity; + for (let i = start; i < end; i++) { + const m = meta[polyIndices[i]]!; + if (m.minX < minX) minX = m.minX; if (m.maxX > maxX) maxX = m.maxX; + if (m.minY < minY) minY = m.minY; if (m.maxY > maxY) maxY = m.maxY; + if (m.minZ < minZ) minZ = m.minZ; if (m.maxZ > maxZ) maxZ = m.maxZ; + } + data[base] = minX; data[base + 1] = minY; data[base + 2] = minZ; + data[base + 3] = maxX; data[base + 4] = maxY; data[base + 5] = maxZ; + if (count <= BVH_LEAF_SIZE) { + data[base + 6] = 1; data[base + 7] = start; data[base + 8] = end; + return ni; + } + let cxMin = Infinity, cyMin = Infinity, czMin = Infinity; + let cxMax = -Infinity, cyMax = -Infinity, czMax = -Infinity; + for (let i = start; i < end; i++) { + if (centX[i] < cxMin) cxMin = centX[i]; if (centX[i] > cxMax) cxMax = centX[i]; + if (centY[i] < cyMin) cyMin = centY[i]; if (centY[i] > cyMax) cyMax = centY[i]; + if (centZ[i] < czMin) czMin = centZ[i]; if (centZ[i] > czMax) czMax = centZ[i]; + } + const extX = cxMax - cxMin, extY = cyMax - cyMin, extZ = czMax - czMin; + if (extX === 0 && extY === 0 && extZ === 0) { + data[base + 6] = 1; data[base + 7] = start; data[base + 8] = end; + return ni; + } + const nodeSA = aabbSA(minX, minY, minZ, maxX, maxY, maxZ); + const invSA = nodeSA > 0 ? 1 / nodeSA : 0; + let bestCost = count + 1; + let bestAxis = 0, bestSplitVal = 0; + for (let axis = 0; axis < 3; axis++) { + const cMin = axis === 0 ? cxMin : (axis === 1 ? cyMin : czMin); + const ext = axis === 0 ? extX : (axis === 1 ? extY : extZ); + if (ext === 0) continue; + const centArr = axis === 0 ? centX : (axis === 1 ? centY : centZ); + const scale = SAH_BUCKETS / ext; + bMinX.fill(Infinity); bMinY.fill(Infinity); bMinZ.fill(Infinity); + bMaxX.fill(-Infinity); bMaxY.fill(-Infinity); bMaxZ.fill(-Infinity); + bCnt.fill(0); + for (let i = start; i < end; i++) { + let b = (centArr[i] - cMin) * scale | 0; + if (b >= SAH_BUCKETS) b = SAH_BUCKETS - 1; + const m = meta[polyIndices[i]]!; + if (m.minX < bMinX[b]) bMinX[b] = m.minX; if (m.maxX > bMaxX[b]) bMaxX[b] = m.maxX; + if (m.minY < bMinY[b]) bMinY[b] = m.minY; if (m.maxY > bMaxY[b]) bMaxY[b] = m.maxY; + if (m.minZ < bMinZ[b]) bMinZ[b] = m.minZ; if (m.maxZ > bMaxZ[b]) bMaxZ[b] = m.maxZ; + bCnt[b]++; + } + let lx0 = Infinity, ly0 = Infinity, lz0 = Infinity; + let lx1 = -Infinity, ly1 = -Infinity, lz1 = -Infinity; + let lc = 0; + for (let k = 0; k < SAH_BUCKETS - 1; k++) { + if (bMinX[k] < lx0) lx0 = bMinX[k]; if (bMaxX[k] > lx1) lx1 = bMaxX[k]; + if (bMinY[k] < ly0) ly0 = bMinY[k]; if (bMaxY[k] > ly1) ly1 = bMaxY[k]; + if (bMinZ[k] < lz0) lz0 = bMinZ[k]; if (bMaxZ[k] > lz1) lz1 = bMaxZ[k]; + lc += bCnt[k]; + lSA[k] = aabbSA(lx0, ly0, lz0, lx1, ly1, lz1); + lCnt[k] = lc; + } + let rx0 = Infinity, ry0 = Infinity, rz0 = Infinity; + let rx1 = -Infinity, ry1 = -Infinity, rz1 = -Infinity; + let rc = 0; + for (let k = SAH_BUCKETS - 2; k >= 0; k--) { + const kb = k + 1; + if (bMinX[kb] < rx0) rx0 = bMinX[kb]; if (bMaxX[kb] > rx1) rx1 = bMaxX[kb]; + if (bMinY[kb] < ry0) ry0 = bMinY[kb]; if (bMaxY[kb] > ry1) ry1 = bMaxY[kb]; + if (bMinZ[kb] < rz0) rz0 = bMinZ[kb]; if (bMaxZ[kb] > rz1) rz1 = bMaxZ[kb]; + rc += bCnt[kb]; + rSA[k] = aabbSA(rx0, ry0, rz0, rx1, ry1, rz1); + rCnt[k] = rc; + } + for (let k = 0; k < SAH_BUCKETS - 1; k++) { + if (lCnt[k] === 0 || rCnt[k] === 0) continue; + const cost = 0.125 + (lSA[k] * lCnt[k] + rSA[k] * rCnt[k]) * invSA; + if (cost < bestCost) { + bestCost = cost; + bestAxis = axis; + bestSplitVal = cMin + (k + 1) / scale; + } + } + } + const centArr2 = bestAxis === 0 ? centX : (bestAxis === 1 ? centY : centZ); + let lo = start, hi = end - 1; + while (lo <= hi) { + if (centArr2[lo] < bestSplitVal) { + lo++; + } else { + const tmp = polyIndices[lo]; polyIndices[lo] = polyIndices[hi]; polyIndices[hi] = tmp; + const t0 = centX[lo]; centX[lo] = centX[hi]; centX[hi] = t0; + const t1 = centY[lo]; centY[lo] = centY[hi]; centY[hi] = t1; + const t2 = centZ[lo]; centZ[lo] = centZ[hi]; centZ[hi] = t2; + hi--; + } + } + let mid = lo; + if (mid === start || mid === end) mid = (start + end) >> 1; + data[base + 6] = 0; + const left = buildNode(start, mid); + const right = buildNode(mid, end); + data[ni * BVH_STRIDE + 7] = left; + data[ni * BVH_STRIDE + 8] = right; + return ni; + } + if (n > 0) buildNode(0, n); + return { data, nodeCount, polyIndices, meta }; +} + +function rayHitsAnyInBVH( + ox: number, oy: number, oz: number, + dx: number, dy: number, dz: number, + tMax: number, + selfIdx: number, + bvh: BVH, + stack: Int32Array, +): boolean { + if (bvh.nodeCount === 0) return false; + const { data, polyIndices, meta } = bvh; + const invDx = dx !== 0 ? 1 / dx : (dx >= 0 ? Infinity : -Infinity); + const invDy = dy !== 0 ? 1 / dy : (dy >= 0 ? Infinity : -Infinity); + const invDz = dz !== 0 ? 1 / dz : (dz >= 0 ? Infinity : -Infinity); + let top = 0; + stack[top++] = 0; + while (top > 0) { + const ni = stack[--top]; + const base = ni * BVH_STRIDE; + const tx1 = (data[base] - ox) * invDx; + const tx2 = (data[base + 3] - ox) * invDx; + let tMin = tx1 < tx2 ? tx1 : tx2; + let tBoxMax = tx1 < tx2 ? tx2 : tx1; + const ty1 = (data[base + 1] - oy) * invDy; + const ty2 = (data[base + 4] - oy) * invDy; + const tyMin = ty1 < ty2 ? ty1 : ty2; + const tyMax = ty1 < ty2 ? ty2 : ty1; + if (tMin > tyMax || tyMin > tBoxMax) continue; + if (tyMin > tMin) tMin = tyMin; + if (tyMax < tBoxMax) tBoxMax = tyMax; + const tz1 = (data[base + 2] - oz) * invDz; + const tz2 = (data[base + 5] - oz) * invDz; + const tzMin = tz1 < tz2 ? tz1 : tz2; + const tzMax = tz1 < tz2 ? tz2 : tz1; + if (tMin > tzMax || tzMin > tBoxMax) continue; + if (tzMax < tBoxMax) tBoxMax = tzMax; + // The occlusion ray is a SEGMENT eye→centroid: reject boxes entirely + // before MIN_HIT_T or entirely beyond the target distance tMax. + if (tBoxMax < MIN_HIT_T || tMin > tMax) continue; + if (data[base + 6] === 1) { + const start = data[base + 7] | 0; + const end = data[base + 8] | 0; + for (let k = start; k < end; k++) { + const j = polyIndices[k]; + if (j === selfIdx) continue; + const q = meta[j]; + if (q && rayHitsPolygon(ox, oy, oz, dx, dy, dz, tMax, q)) return true; + } + } else { + stack[top++] = data[base + 7] | 0; + stack[top++] = data[base + 8] | 0; + } + } + return false; +} + +export interface CameraFrustum { + /** Unit (or any-length) forward direction the camera looks along. */ + forward: Vec3; + /** Full horizontal field of view in radians. */ + fovRadians: number; +} + +export interface CameraVisibilityQueryOptions { + /** Optional view frustum; when omitted the query is omni-directional + * (occlusion + front-facing only) — the correct mode for a per-cell PVS + * bake that must cover every look direction from the cell. */ + frustum?: CameraFrustum; + /** Treat faces within this distance of the eye as always visible (skip the + * occlusion ray). Guards against a face the eye is sitting flush against. */ + nearVisibleDistance?: number; +} + +/** + * A reusable camera-visibility context: precomputes per-polygon metadata and + * the BVH ONCE so a caller can query many camera positions (per-cell PVS bake) + * without rebuilding. `skipIndices` drops overlapping/duplicate polygons (e.g. + * coincident two-sided wall twins) from being occlusion candidates. + */ +export interface CameraVisibilityContext { + readonly polygonCount: number; + query(eye: Vec3, options?: CameraVisibilityQueryOptions): Set; + /** Convenience: is at least one polygon in `indices` visible from `eye`? */ + anyVisible(eye: Vec3, indices: Iterable, options?: CameraVisibilityQueryOptions): boolean; +} + +export function createCameraVisibilityContext( + polygons: readonly Polygon[], + skipIndices?: ReadonlySet, +): CameraVisibilityContext { + const meta: Array = polygons.map(precompute); + if (skipIndices && skipIndices.size > 0) { + for (const i of skipIndices) meta[i] = null; + } + const bvh = buildBVH(meta); + const stack = new Int32Array(Math.max(64, bvh.nodeCount * 2)); + + const isVisible = (i: number, ex: number, ey: number, ez: number, options?: CameraVisibilityQueryOptions): boolean => { + const p = meta[i]; + if (!p) return false; + const toEyeX = ex - p.centroid[0]; + const toEyeY = ey - p.centroid[1]; + const toEyeZ = ez - p.centroid[2]; + const dist = Math.hypot(toEyeX, toEyeY, toEyeZ); + if (dist < PARALLEL_EPS) return true; + // Front-facing: the outward normal must point toward the eye. Faces whose + // lit side faces away are back-faces the compositor hides — never visible. + const ndotV = p.normal[0] * toEyeX + p.normal[1] * toEyeY + p.normal[2] * toEyeZ; + if (ndotV <= 0) return false; + const invDist = 1 / dist; + const dirX = toEyeX * invDist, dirY = toEyeY * invDist, dirZ = toEyeZ * invDist; + if (options?.frustum) { + const f = options.frustum; + const fLen = Math.hypot(f.forward[0], f.forward[1], f.forward[2]) || 1; + const fx = f.forward[0] / fLen, fy = f.forward[1] / fLen, fz = f.forward[2] / fLen; + // Angle between the view axis and the eye→centroid direction (note dir + // points centroid→eye, so the camera→centroid direction is its negation). + const cosToFace = -(dirX * fx + dirY * fy + dirZ * fz); + if (cosToFace < Math.cos(f.fovRadians * 0.5)) return false; + } + if (options?.nearVisibleDistance !== undefined && dist <= options.nearVisibleDistance) return true; + // Occlusion segment: origin just off the face along its outward normal, + // toward the eye, length = dist. A hit before the eye blocks the view. + const ox = p.centroid[0] + p.normal[0] * RAY_ORIGIN_OFFSET; + const oy = p.centroid[1] + p.normal[1] * RAY_ORIGIN_OFFSET; + const oz = p.centroid[2] + p.normal[2] * RAY_ORIGIN_OFFSET; + return !rayHitsAnyInBVH(ox, oy, oz, dirX, dirY, dirZ, dist - RAY_ORIGIN_OFFSET, i, bvh, stack); + }; + + return { + polygonCount: polygons.length, + query(eye, options) { + const visible = new Set(); + for (let i = 0; i < polygons.length; i++) { + if (isVisible(i, eye[0], eye[1], eye[2], options)) visible.add(i); + } + return visible; + }, + anyVisible(eye, indices, options) { + for (const i of indices) { + if (isVisible(i, eye[0], eye[1], eye[2], options)) return true; + } + return false; + }, + }; +} + +/** + * One-shot camera visibility (the direct twin of `computeLightVisibility`): + * returns the set of polygon indices POTENTIALLY VISIBLE from `eye` — those + * that are front-facing, inside the optional frustum, and not fully occluded + * by other polygons of the same mesh. Builds a throwaway BVH; callers sampling + * many positions should use `createCameraVisibilityContext` instead. + */ +export function computeCameraVisibility( + polygons: readonly Polygon[], + eye: Vec3, + options?: CameraVisibilityQueryOptions, + skipIndices?: ReadonlySet, +): Set { + return createCameraVisibilityContext(polygons, skipIndices).query(eye, options); +} diff --git a/packages/core/src/cull/cullInteriorPolygons.test.ts b/packages/core/src/cull/cullInteriorPolygons.test.ts index c0771f965..4a7cec923 100644 --- a/packages/core/src/cull/cullInteriorPolygons.test.ts +++ b/packages/core/src/cull/cullInteriorPolygons.test.ts @@ -88,6 +88,35 @@ describe("cullInteriorPolygons", () => { expect(out).toBe(tiny); // referential equality — short-circuit path }); + it("still culls a lone enclosed quad when preserveCoincidentTwins is on", () => { + // The opt-in must not weaken culling of genuinely-interior lone faces: a + // single quad with no coincident twin is still dropped. + const outer = cubeOutward(0, 0, 0, 10); + const interior = axisQuad(0, 0, 0, "z", 1, 0.1); + const out = cullInteriorPolygons([...outer, interior], { preserveCoincidentTwins: true }); + expect(out.length).toBe(outer.length); + }); + + it("keeps an enclosed two-sided twin only when preserveCoincidentTwins is on", () => { + // A coincident front/back pair (authored two-sided surface) enclosed by an + // outer shell. Both twins face enclosed geometry from their own normal, so + // the default any-escape test strips BOTH. The opt-in keeps both because a + // two-sided surface is visible from at least one reachable side. + const outer = cubeOutward(0, 0, 0, 10); + const front = axisQuad(0, 0, 0, "z", 1, 0.4); + const back = axisQuad(0, 0, 0, "z", -1, 0.4); + + const culled = cullInteriorPolygons([...outer, front, back]); + expect(culled.length).toBe(outer.length); // both twins dropped by default + + const kept = cullInteriorPolygons([...outer, front, back], { preserveCoincidentTwins: true }); + expect(kept.length).toBe(outer.length + 2); // both twins preserved + const frontKey = JSON.stringify(front.vertices); + const backKey = JSON.stringify(back.vertices); + expect(kept.some((p) => JSON.stringify(p.vertices) === frontKey)).toBe(true); + expect(kept.some((p) => JSON.stringify(p.vertices) === backKey)).toBe(true); + }); + it("does not ray-cull large open topology", () => { const openPanels: Polygon[] = []; for (let i = 0; i < 130; i += 1) { diff --git a/packages/core/src/cull/cullInteriorPolygons.ts b/packages/core/src/cull/cullInteriorPolygons.ts index 9267e3392..60960e148 100644 --- a/packages/core/src/cull/cullInteriorPolygons.ts +++ b/packages/core/src/cull/cullInteriorPolygons.ts @@ -93,6 +93,58 @@ function tangentBasis(nx: number, ny: number, nz: number): { }; } +const TWIN_QUANTIZE = 1e3; + +/** Unordered, quantized vertex signature. Two polygons that occupy the exact + * same footprint — including an authored front/back winding flip that carries + * the identical vertex ring in reverse order — hash to the same key. */ +function footprintKey(p: Polygon): string { + const q = (v: number): number => Math.round(v * TWIN_QUANTIZE); + return p.vertices + .map((v) => `${q(v[0])},${q(v[1])},${q(v[2])}`) + .sort() + .join("|"); +} + +/** + * Flag polygons that have a COINCIDENT OPPOSITE-FACING twin in the set — an + * authored two-sided surface (front + reverse-wound back on the same + * footprint). Such a surface is deliberately visible from at least one side, + * so neither twin may be interior-culled: the ray test only samples the + * hemisphere above ONE normal and will happily classify the side that faces + * enclosed geometry as "interior", silently deleting a panel the camera sees + * from the OTHER side (walkable-interior worlds put the camera on both sides of + * a wall). Coincident twins are exempted before the ray test runs. + */ +function coincidentTwinFlags( + polygons: Polygon[], + meta: Array, +): boolean[] { + const flags = new Array(polygons.length).fill(false); + const buckets = new Map(); + for (let i = 0; i < polygons.length; i++) { + if (!meta[i]) continue; + const key = footprintKey(polygons[i]); + let list = buckets.get(key); + if (!list) buckets.set(key, (list = [])); + list.push(i); + } + for (const list of buckets.values()) { + if (list.length < 2) continue; + for (let a = 0; a < list.length; a++) { + const na = meta[list[a]]!.normal; + for (let b = a + 1; b < list.length; b++) { + const nb = meta[list[b]]!.normal; + if (na[0] * nb[0] + na[1] * nb[1] + na[2] * nb[2] < -0.9) { + flags[list[a]] = true; + flags[list[b]] = true; + } + } + } + } + return flags; +} + function precompute(p: Polygon): PolyMeta | null { const verts = p.vertices; if (!verts || verts.length < 3) return null; @@ -505,6 +557,16 @@ export interface CullInteriorOptions { * porch trim, back-of-wall door frames) that have a sliver of clear * sky through windows/openings but are otherwise enclosed. */ minEscapeRatio?: number; + /** Never cull a polygon that has a COINCIDENT OPPOSITE-FACING twin — an + * authored two-sided surface (front + reverse-wound back on the same + * footprint). The ray test only samples the hemisphere above ONE normal, so + * it classifies the side that faces enclosed geometry as "interior" and + * silently deletes a panel the camera sees from the OTHER side. In a + * walkable-interior world the camera stands on both sides of a wall, so both + * twins are visible. Off by default: closed-mesh CAD imports carry coincident + * double-sided junk that SHOULD still be culled. Callers that author + * deliberate two-sided walls (worlds) opt in. Default false. */ + preserveCoincidentTwins?: boolean; } export function cullInteriorPolygons( @@ -522,6 +584,9 @@ export function cullInteriorPolygons( const minEscapingSamples = Math.max(1, Math.ceil(k * (options?.minEscapeRatio ?? (1 / k)))); const meta: Array = polygons.map(precompute); + const twinFlags = options?.preserveCoincidentTwins + ? coincidentTwinFlags(polygons, meta) + : null; const samplesFlat = hemisphereSamplesFlat(k); const kept: Polygon[] = []; const bvh = buildBVH(meta); @@ -536,6 +601,8 @@ export function cullInteriorPolygons( for (let i = 0; i < polygons.length; i++) { const p = meta[i]; if (!p) { kept.push(polygons[i]); continue; } + // Authored two-sided surface — visible from at least one side. Never cull. + if (twinFlags?.[i]) { kept.push(polygons[i]); continue; } const nx = p.normal[0], ny = p.normal[1], nz = p.normal[2]; const offX = RAY_ORIGIN_OFFSET * nx; diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 446ab55bd..5c8ca4921 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -147,6 +147,15 @@ export type { export { cullInteriorPolygons } from "./cull/cullInteriorPolygons"; export type { CullInteriorOptions } from "./cull/cullInteriorPolygons"; export { computeLightVisibility } from "./cull/lightVisibility"; +export { + computeCameraVisibility, + createCameraVisibilityContext, +} from "./cull/cameraVisibility"; +export type { + CameraFrustum, + CameraVisibilityContext, + CameraVisibilityQueryOptions, +} from "./cull/cameraVisibility"; export { CAMERA_BACKFACE_CULL_EPS, VOXEL_CAMERA_CULL_AXIS_EPS, diff --git a/packages/core/src/merge/optimizePolygons.ts b/packages/core/src/merge/optimizePolygons.ts index c5cf0637e..2e2a1178c 100644 --- a/packages/core/src/merge/optimizePolygons.ts +++ b/packages/core/src/merge/optimizePolygons.ts @@ -1,4 +1,4 @@ -import { cullInteriorPolygons } from "../cull/cullInteriorPolygons"; +import { cullInteriorPolygons, type CullInteriorOptions } from "../cull/cullInteriorPolygons"; import { findOverlappingPolygonDuplicates } from "./dedupeOverlappingPolygons"; import type { MeshResolution, Polygon, TextureTriangle, Vec2, Vec3 } from "../types"; import { coverPlanarPolygons, type CoverPlanarPolygonsOptions } from "./coverPlanarPolygons"; @@ -34,6 +34,15 @@ export interface OptimizeMeshPolygonsOptions { * regions. Defaults to true. */ rectCover?: boolean | CoverPlanarPolygonsOptions; + /** + * Never interior-cull a polygon that has a coincident opposite-facing twin + * (an authored two-sided surface). Off by default (closed-mesh imports keep + * their coincident-junk culling). Walkable-interior worlds — where the camera + * stands on both sides of a wall — opt in so two-sided walls/floors are not + * silently stripped from the side facing enclosed geometry. See + * `CullInteriorOptions.preserveCoincidentTwins`. + */ + preserveCoincidentTwins?: boolean; } interface ResolvedGeometryNormalizeOptions { @@ -152,6 +161,7 @@ interface BestSafetyDiagnostics { interface PreprocessCache { skipInteriorCull?: boolean; + preserveCoincidentTwins?: boolean; reuseSnappedInteriorCull?: boolean; baseline?: Polygon[]; deduped?: Polygon[]; @@ -170,6 +180,7 @@ interface PreprocessCache { interface OptimizeMeshPolygonsRunOptions { requiredMaxPolygonCount?: number; skipInteriorCull?: boolean; + preserveCoincidentTwins?: boolean; skipExactRectCover?: boolean; simplifiedCandidate?: boolean; captureVisiblePolygons?: boolean; @@ -329,7 +340,9 @@ export function optimizeMeshPolygons( polygons: Polygon[], options: OptimizeMeshPolygonsOptions = {}, ): Polygon[] { - return optimizeMeshPolygonsInternal(polygons, options).polygons; + return optimizeMeshPolygonsInternal(polygons, options, { + preserveCoincidentTwins: options.preserveCoincidentTwins === true, + }).polygons; } export function optimizeParseMeshPolygons( @@ -388,6 +401,7 @@ class MeshOptimizationArtifactGraph { function workspaceCacheKey(runOptions: OptimizeMeshPolygonsRunOptions): string { return [ runOptions.skipInteriorCull === true ? "skip-cull" : "cull", + runOptions.preserveCoincidentTwins === true ? "keep-twins" : "cull-twins", runOptions.simplifiedCandidate === true ? "simplified" : "source", ].join(":"); } @@ -406,6 +420,7 @@ class MeshOptimizationWorkspace { this.graph = graph; this.preprocessCache = { skipInteriorCull: runOptions.skipInteriorCull === true, + preserveCoincidentTwins: runOptions.preserveCoincidentTwins === true, }; } @@ -1447,12 +1462,16 @@ function dedupedPolygonsForMerge(polygons: Polygon[], cache?: PreprocessCache): return deduped; } +function cullOptionsFor(cache?: PreprocessCache): CullInteriorOptions | undefined { + return cache?.preserveCoincidentTwins ? { preserveCoincidentTwins: true } : undefined; +} + function interiorPolygonsForMerge(polygons: Polygon[], cache?: PreprocessCache): Polygon[] { if (cache?.skipInteriorCull) return polygons; if (cache?.interior) return cache.interior; let filter = cache?.interiorIndices; if (filter === undefined) { - const kept = cullInteriorPolygons(polygons); + const kept = cullInteriorPolygons(polygons, cullOptionsFor(cache)); filter = keptIndexFilter(polygons, kept); if (cache) cache.interiorIndices = filter; } @@ -1497,7 +1516,7 @@ function preprocessModelPolygons( return mergedPaired.length < baseline.length ? mergedPaired : baseline; } const normalizedGeometry = normalizeGeometryForMerge(deduped, options, cache); - const normalizedInterior = cullInteriorPolygons(normalizedGeometry); + const normalizedInterior = cullInteriorPolygons(normalizedGeometry, cullOptionsFor(cache)); const normalized = mergePolygons(normalizedInterior); return normalized.length < baseline.length ? normalized : baseline; } @@ -1514,6 +1533,7 @@ function snappedInteriorPolygonsForMerge( exactCull = false, ): Polygon[] { if (!cache) return cullInteriorPolygons(snapGeometryForMerge(polygons)); + const cullOptions = cullOptionsFor(cache); if (exactCull && cache.snappedInteriorExact) return cache.snappedInteriorExact; if (!exactCull && cache.snappedInterior) return cache.snappedInterior; @@ -1524,7 +1544,7 @@ function snappedInteriorPolygonsForMerge( cache.snappedInteriorExact = cache.interior; return cache.snappedInteriorExact; } - const kept = cullInteriorPolygons(snapped); + const kept = cullInteriorPolygons(snapped, cullOptions); cache.snappedInteriorExactIndices = keptIndexFilter(snapped, kept); cache.snappedInteriorExact = kept; return cache.snappedInteriorExact; @@ -1544,7 +1564,7 @@ function snappedInteriorPolygonsForMerge( return cache.snappedInterior; } if (cache.snappedInteriorIndices === undefined) { - const kept = cullInteriorPolygons(snapped); + const kept = cullInteriorPolygons(snapped, cullOptions); cache.snappedInteriorIndices = keptIndexFilter(snapped, kept); cache.snappedInterior = kept; cache.snappedInteriorExactIndices = cache.snappedInteriorIndices; diff --git a/packages/polycss/src/styles/styles.ts b/packages/polycss/src/styles/styles.ts index ee6facf93..e09377eda 100644 --- a/packages/polycss/src/styles/styles.ts +++ b/packages/polycss/src/styles/styles.ts @@ -114,6 +114,14 @@ const CORE_BASE_STYLES = ` line-height: 0; text-decoration: none; backface-visibility: hidden; + /* Isolate each leaf's layout + style scope so a high-leaf-count scene + (10k+ mounted polygons) does not fold every leaf into one global + layout/style-recalc pass. Softens the periodic multi-hundred-ms + compositor/recalc stalls that appear when the DOM is churned (LOD + mount/unmount) at high leaf counts. Deliberately excludes paint (a + per-leaf paint boundary regressed) and size (leaf primitives are + CSS-sized). */ + contain: layout style; background-repeat: no-repeat; } @@ -384,11 +392,6 @@ const CORE_BASE_STYLES = ` own) and the scene-level light vars. Splitting this from the lambert calc above lets bucketed polys skip the dot-product entirely. */ .polycss-scene[data-polycss-lighting="dynamic"] s { - /* Isolate each leaf's layout/style/paint walks from siblings. Works - because the leaf transform-style:preserve-3d was dropped above — - the 3D context lives on .polycss-scene / .polycss-mesh, not the - leaves, so there's nothing inside a leaf that needs to participate - in 3D compositing across the contain boundary. */ contain: strict; /* * Three.js MeshLambertMaterial parity for textured surfaces: diff --git a/packages/react/src/styles/styles.ts b/packages/react/src/styles/styles.ts index cac4afb1c..ae3e424b6 100644 --- a/packages/react/src/styles/styles.ts +++ b/packages/react/src/styles/styles.ts @@ -109,6 +109,7 @@ const CORE_BASE_STYLES = ` quotes: none; text-decoration: none; backface-visibility: hidden; + contain: layout style; background-repeat: no-repeat; } diff --git a/packages/vue/src/styles/styles.ts b/packages/vue/src/styles/styles.ts index 2a2f0e150..0c49502e3 100644 --- a/packages/vue/src/styles/styles.ts +++ b/packages/vue/src/styles/styles.ts @@ -103,6 +103,7 @@ const CORE_BASE_STYLES = ` quotes: none; text-decoration: none; backface-visibility: hidden; + contain: layout style; background-repeat: no-repeat; } diff --git a/packages/world/README.md b/packages/world/README.md new file mode 100644 index 000000000..88054ef6f --- /dev/null +++ b/packages/world/README.md @@ -0,0 +1,1102 @@ +# @layoutit/polycss-world + +Topology, state, planning, and DOM-apply helpers for authored PolyCSS worlds. Framework-agnostic: no renderer imports, no React/Vue wrappers, no browser globals. + +```bash +pnpm add @layoutit/polycss-world @layoutit/polycss +``` + +## Capability Contract + +Use `createPolyWorldTopologyCapabilityContract` when docs, debug UIs, examples, or tests need to show what PolyCSS World owns. The contract is data-only: it maps reference lessons to package-owned topology behavior and app-owned runtime behavior without importing those formats or engines. + +```ts +import { createPolyWorldTopologyCapabilityContract } from "@layoutit/polycss-world"; + +const contract = createPolyWorldTopologyCapabilityContract(); + +console.log(contract.capabilities.map((capability) => capability.id)); +// [ +// "world-ir", +// "compiled-bsp-pvs", +// "area-portals", +// "chunk-hierarchy", +// "resource-readiness", +// "dom-planning", +// "debug-proof", +// ] +console.log(contract.references.find((reference) => reference.id === "quake-bsp-pvs")?.compatibilityClaim); +// Implements Quake-like BSP/PVS topology concepts, not Quake BSP format compatibility. +console.log(contract.references.find((reference) => reference.id === "quake-bsp-pvs")?.claimLevel); +// "topology-proof" +``` + +Each reference also carries `claimLevel` and `sourceUrls` so debug panels and docs can show provenance without upgrading the claim. The reference split is explicit: + +| Reference | Claim level | PolyCSS World uses it for | Still app-owned or out of scope | +|---|---|---|---| +| X3D | `structure-reference` | Authored grouping, bounds, transform/collision/inline boundaries, and LOD as scene-structure concepts. | X3D parsing, X3D runtime, browser plugin semantics, visibility proof from grouping alone. | +| OpenUSD | `structure-reference` | Stable element identity, payload/readiness separation, and purpose-like traversal gates. | USD composition, layering, payload loading, scenegraph runtime. | +| Quake BSP/PVS | `topology-proof` | Camera leaf lookup, baked broad PVS, view-clipped PVS, solid/outside/detail separation, and topology proof. | Quake BSP loading, full `qbsp`/`vis` parity, player controls. | +| Quake QBSP/VIS | `compiler-boundary` | Offline compiler/proof separation and provenance vocabulary. | Full compiler or VIS solver parity. | +| 3D Tiles | `working-set-reference` | Chunk hierarchy, availability, content availability, refinement, geometric error, and traversal budgets. | Fetch scheduling, cache eviction, mesh replacement, renderer LOD swaps. | +| glTF LOD | `asset-boundary` | A cautionary asset-level LOD reference for what not to make chunk traversal depend on. | glTF loading or vendor extension behavior. | + +Profile debug snapshots expose a shared proof envelope so apps can show what a frame actually proves without overclaiming: + +```ts +console.log(bspDebug.artifact.profile); // "bsp-pvs" +console.log(portalDebug.proof.profile); // "area-portals" +console.log(portalFlowDebug.proof.profile); // "portal-flow" +console.log(chunkDebug.proof.knownWeaknesses); // no fetch/cache/render ownership +``` + +BSP/PVS proof is the strongest profile and includes BSP-specific tree, leaf, portal, and PVS fields. Authored area portals, portal flow, and chunk traversal intentionally expose weaker proof envelopes: they are useful topology/planning evidence, not compiled occlusion or loader proof. Area portals prove authored region/link selection and link-state reporting; portal flow adds camera-frustum portal clipping. + +Frame helpers expose the same proof boundary as `frame.artifact` even when verbose debug snapshots are disabled. Use debug snapshots for capped lists and panels; use frame artifacts for stable assertions and logs that only need the profile, guarantees, weaknesses, counts, and coverage. + +Use `auditPolyWorldProfileArtifactProof` when imported, serialized, or debug-forwarded proof envelopes need to be checked before a panel or test trusts them. The audit validates the final proof fields, so a canonicalized proof can carry diagnostics about stripped input claims and still be valid, while a forged portal/chunk proof that currently claims BSP/PVS guarantees fails: + +```ts +import { + auditPolyWorldProfileArtifactProof, + createPolyWorldProfileArtifactBundle, +} from "@layoutit/polycss-world"; + +const audit = auditPolyWorldProfileArtifactProof(frame.artifact); + +if (!audit.valid) { + console.warn(audit.diagnostics); +} + +const artifactRef = document.profileArtifactsById.get("gallery-bsp"); + +if (artifactRef) { + const bundle = createPolyWorldProfileArtifactBundle({ + entries: [{ ref: artifactRef, proof: frame.artifact }], + }); + + console.log(bundle.entriesById.get("gallery-bsp")?.valid); +} +``` + +Use artifact bundles when a frame proof needs to be tied back to a document artifact ref before a debug panel, regression test, or app-level frame runner trusts it. Bundle audits catch profile, artifact kind, source kind, producer, duplicate-id, and proof-audit mismatches without loading files or touching renderer DOM. + +## World Document + +Use `createPolyWorldDocument` when an authored world needs one validated data boundary for topology plus profile refs, resource declarations, and named plan policies. The document layer is still data-only: it normalizes `createPolyWorldTopology`, indexes references, and exposes summary counts without loading formats, creating BSPs, fetching resources, creating DOM, or choosing renderer meshes. + +```ts +import { createPolyWorldDocument } from "@layoutit/polycss-world"; + +const document = createPolyWorldDocument({ + id: "gallery-world", + topology: { + regions: [ + { id: "gallery", bounds: { min: [0, 0, 0], max: [8, 8, 3] } }, + ], + elements: [ + { + id: "gallery-shell", + path: "/World/Gallery/Shell", + regionIds: ["gallery"], + resourceIds: ["mesh:gallery-shell"], + layers: ["world"], + }, + ], + }, + capabilityIds: ["world-ir", "compiled-bsp-pvs", "resource-readiness"], + profileArtifacts: [ + { + id: "gallery-bsp", + profile: "bsp-pvs", + artifactKind: "compiled-bsp-pvs", + sourceKind: "compiled", + producedBy: "brush-bsp", + elementIds: ["gallery-shell"], + }, + ], + resources: [ + { id: "mesh:gallery-shell", state: "ready", elementIds: ["gallery-shell"] }, + ], + planPolicies: [ + { id: "render-world", layer: "world", elementLayers: ["world"] }, + ], +}); + +console.log(document.topology.elementsByPath.get("/World/Gallery/Shell")?.id); +console.log(document.profileArtifactsById.get("gallery-bsp")?.profile); +console.log(document.summary.capabilityIds); +``` + +Invalid profile refs, disabled profile capabilities, duplicate resource ids, unknown element/spatial-element references, invalid capability ids, and malformed plan policies fail at document creation. If `capabilityIds` is narrowed, profile artifact refs must match the enabled capability: `bsp-pvs` requires `compiled-bsp-pvs`, portal profiles require `area-portals`, and chunk traversal requires `chunk-hierarchy`. Use the lower-level topology/profile/planner APIs when the app already has those records split across its own authoring pipeline. + +Topology validation has a baseline mode and an opt-in authored-world strict mode. Baseline validation catches broken ids, missing endpoints, malformed bounds, invalid element references, and parent/container cycles. Strict mode adds authoring checks such as spatially referenced regions, connected region graphs, and element layers, while still allowing individual checks to be disabled for imported or partial data: + +```ts +const world = createPolyWorldTopology({ + validation: { + strict: true, + requireRegionBounds: true, + }, + regions: [ + { id: "studio", bounds: { min: [0, 0, 0], max: [8, 8, 3] } }, + { id: "gallery", bounds: { min: [8, 0, 0], max: [16, 8, 3] } }, + ], + links: [ + { id: "studio-gallery", fromRegionId: "studio", toRegionId: "gallery" }, + ], + elements: [ + { id: "studio-shell", regionIds: ["studio"], layers: ["world"] }, + { id: "gallery-shell", regionIds: ["gallery"], layers: ["world"] }, + ], +}); +``` + +Elements can also describe authored-world graph metadata without becoming renderer nodes. Use `path` for a stable scene path, `parentId` / `containerId` for graph relationships, `bounds` and `transform` for app-authored spatial metadata, `purposes` for traversal gates, and `resourceIds` for app-owned readiness checks. PolyCSS World validates and indexes these fields, but it does not compute renderer transforms or create DOM: + +```ts +const world = createPolyWorldTopology({ + regions: [{ id: "gallery", bounds: { min: [0, 0, 0], max: [8, 8, 3] } }], + elements: [ + { + id: "gallery-root", + path: "/World/Gallery", + selectionKeys: ["root:gallery"], + purposes: ["render"], + layers: ["resident"], + }, + { + id: "gallery-wall", + path: "/World/Gallery/Wall", + parentId: "gallery-root", + containerId: "gallery-root", + regionIds: ["gallery"], + bounds: { min: [0, 0, 0], max: [8, 0.2, 3] }, + transform: { position: [0, 0, 0], rotation: [0, 0, 0], scale: [1, 1, 1] }, + purposes: ["render", "occluder"], + resourceIds: ["mesh:gallery-wall"], + layers: ["world"], + }, + ], +}); +``` + +Use `resolvePolyWorldElementSubtree` for X3D/OpenUSD-style graph traversal over prepared identities, and `selectPolyWorldElementsByPurpose` when a planner needs a purpose-gated selection: + +```ts +const subtree = resolvePolyWorldElementSubtree(world, ["gallery-root"], { + purposes: ["render"], +}); + +const renderSelection = selectPolyWorldElementsByPurpose(world, ["render"], { + includeDescendants: true, + reasonLabel: "render-purpose", +}); +``` + +The graph layer is still data-only. It does not load resources, choose LOD meshes, evaluate animation, create elements, or inspect the browser DOM. + +Spatial element catalogs let authors describe topology-relevant surfaces and roots without turning PolyCSS World into a renderer. A spatial element can point at an app element, region, BSP leaf, bounds, polygon vertices, resource ids, and a role such as `root`, `shell`, `opening`, `detail`, or `prop`. The package validates those records and indexes them for portal/BSP/chunk profiles and debug panels: + +```ts +const world = createPolyWorldTopology({ + regions: [{ id: "gallery", bounds: { min: [0, 0, 0], max: [8, 8, 3] } }], + elements: [{ id: "gallery-shell", regionIds: ["gallery"], layers: ["world"] }], + spatialElements: [ + { + id: "gallery-floor", + elementId: "gallery-shell", + regionId: "gallery", + role: "shell", + visibility: "structural", + resourceIds: ["texture:gallery-floor"], + vertices: [ + [0, 0, 0], + [8, 0, 0], + [8, 8, 0], + [0, 8, 0], + ], + }, + ], +}); + +console.log(world.spatialElementsByRole.get("shell")); +``` + +## Portal Example + +Use connected regions when the world is made of rooms, areas, or zones linked by passages. + +```ts +import { + createPolyWorldTopology, + planPolyWorldPortalFrame, + resolvePolyWorldElements, + resolvePolyWorldPortalFlow, + selectPolyWorldPortalRegions, +} from "@layoutit/polycss-world"; + +const world = createPolyWorldTopology({ + regions: [ + { id: "studio", selectionKeys: ["faces:studio"] }, + { id: "gallery", selectionKeys: ["faces:gallery"] }, + ], + links: [ + { id: "studio-gallery", fromRegionId: "studio", toRegionId: "gallery" }, + ], + elements: [ + { id: "studio-shell", regionIds: ["studio"], layers: ["world"] }, + { id: "gallery-shell", regionIds: ["gallery"], layers: ["world"] }, + { + id: "shared-door", + regionIds: ["studio", "gallery"], + regionMatch: "all", + layers: ["world"], + }, + ], +}); + +const selection = selectPolyWorldPortalRegions(world, { + currentRegionId: "studio", +}); + +const elements = resolvePolyWorldElements(world, selection); +``` + +Portal selections can also carry door/area-portal state. Closed or blocked links are not traversed by linked-room selection unless the caller opts into `includeClosedLinks`: + +```ts +const selection = selectPolyWorldPortalRegions(world, { + currentRegionId: "studio", + linkedDepth: 2, + linkState: { + "studio-gallery": "open", + "gallery-vault": "closed", + }, +}); +``` + +Closed and blocked links are reported as selection reasons so debug panels can explain why a room is not linked without rendering that room by accident. + +Use `planPolyWorldPortalFrame` when authored region/link selection should directly produce state, diff, layer plan, readiness, and debug output: + +```ts +const frame = planPolyWorldPortalFrame(world, { + previousState, + currentRegionId: "studio", + linkedDepth: 2, + activity: { + selectedTargetState: "resident", + renderedRegionIds: ["studio", "gallery"], + }, + planRegionState: "rendered", + policies: [{ id: "render", layer: "render", elementLayers: ["world"] }], + debug: { listLimit: 20 }, +}); + +console.log(frame.portalSets.selectedRegionIds); +console.log(frame.portalSets.plannedElementIds); +console.log(frame.artifact.profile); // "area-portals" +``` + +Portal frames expose `portalSets`, a compact truth-ladder summary of current region, selected regions/links/elements/selection keys, visible/external/closed/blocked/facing link buckets, optional activity buckets, and planned element ids. Use authored portal frames for explicit region/link topology; use portal flow when camera-clipped authored portal polygons matter, and use BSP/PVS when the world has a compiled partition and baked broad visibility. + +When authored portals have bounds or polygon vertices, `resolvePolyWorldPortalFlow` can evaluate camera-clipped area visibility without requiring a compiled BSP/PVS artifact. It starts from the current region, clips portal polygons through the camera frustum and through prior visible portals, reports trace statuses, and returns an ordinary topology selection: + +```ts +const flow = resolvePolyWorldPortalFlow(world, { + point: cameraPosition, + currentRegionId: "studio", + forward: cameraForward, + up: [0, 0, 1], + fovDegrees: 80, + portals: [ + { + id: "studio-gallery-opening", + linkId: "studio-gallery", + bounds: { min: [8, 2, 0], max: [8, 4, 2] }, + selectionKeys: ["portal:studio-gallery"], + }, + ], + includeTrace: true, +}); + +const visibleElements = resolvePolyWorldElements(world, flow.selection); +``` + +Portal flow is authored-area visibility, not BSP. Use BSP/PVS when the world has a compiled partition and baked broad visibility; use portal flow when the author already knows the room links/openings and wants a data-only visible-area selection. + +Use `planPolyWorldPortalFlowFrame` when authored area-portal visibility should directly drive world state, layer planning, plan debug, and portal-flow debug. The frame can keep the full flow selection for visibility/debug while narrowing the rendered plan through portal activity: + +```ts +const frame = planPolyWorldPortalFlowFrame(world, { + previousState, + point: cameraPosition, + currentRegionId: "studio", + forward: cameraForward, + up: [0, 0, 1], + fovDegrees: 80, + portals, + activity: { + selectedTargetState: "resident", + renderedRegionIds: ["studio", "gallery"], + }, + planRegionState: "rendered", + policies: [{ id: "render", layer: "render", elementLayers: ["world"] }], + debug: { includeTraceEntries: true, entryLimit: 20 }, + planDebug: { includeEntries: false }, +}); + +console.log(frame.flow.regionIds); +console.log(frame.flowSets.visiblePortalIds); +console.log(frame.artifact.profile); // "portal-flow" +console.log(frame.portalFlowDebug?.topology.profile); // "portal-flow" +``` + +Portal-flow frames expose `flowSets`, a compact truth-ladder summary of current region, selected regions/links/portals, traced/rejected/visible/clipped portals, trace status counts, optional activity buckets, and planned element ids. Use it for debug panels and assertions that need the authored-area visibility result without parsing trace entries or DOM records. + +`createPolyWorldPortalFlowDebugSnapshot` summarizes selected and hidden regions, selected and rejected portals, trace status counts, and optional capped trace entries. Its debug profile is `portal-flow` on purpose: it is useful authored area-portal evidence, not compiled BSP/PVS proof. + +Portal selection can also consume a package-owned or app-owned visibility selection. This is the bridge for BSP/PVS-driven room visibility: pass the BSP/PVS selection as `visibilitySelection`, and the portal profile preserves its region ids, element ids, selection keys, and source reasons while still adding authored current/link diagnostics: + +```ts +const bspFrame = planPolyWorldBspVisibilityFrame(world, bsp, { + previousState, + policies, + point: cameraPosition, + forward: cameraForward, + fovDegrees: 90, +}); + +const portalFrame = planPolyWorldPortalFrame(world, { + previousState, + currentRegionId: bspFrame.visibility.leaf?.leaf.regionId, + visibilitySelection: bspFrame.visibility.selection, + activity: { + selectedTargetState: "resident", + renderedRegionIds: bspFrame.visibility.selection.regionIds, + }, + planRegionState: "rendered", + policies, +}); +``` + +When `planRegionState` narrows a portal frame to `rendered`, `resident`, or another activity state, direct element/source/alias selectors from the broader visibility selection are not allowed to leak into the narrowed plan. Use `planRegionState: "selected"` when the whole visibility selection should drive element resolution directly. + +Use `resolvePolyWorldPortalActivity` when a portal selection should drive room lifecycle state without making visibility and activity the same thing. Selected rooms can be treated as loaded or resident while only caller-chosen rooms are active or rendered: + +```ts +const activity = resolvePolyWorldPortalActivity(world, selection, { + selectedTargetState: "resident", + activeRegionIds: ["studio"], + renderedRegionIds: ["gallery"], + preloadedRegionIds: ["vault"], +}); + +const portalDebug = createPolyWorldPortalDebugSnapshot(world, selection, { + currentRegionId: "studio", + activity, +}); +``` + +Use `planPolyWorldPortalFrame` when an authored room/portal update should produce selection, optional activity, next state, layer plan, plan debug, and portal debug in one package-owned step: + +```ts +const frame = planPolyWorldPortalFrame(world, { + previousState, + currentRegionId: "studio", + linkedDepth: 2, + activity: { + selectedTargetState: "resident", + renderedRegionIds: ["studio", "gallery"], + }, + planRegionState: "rendered", + policies: [{ id: "render", layer: "render", elementLayers: ["world"] }], +}); +``` + +`planRegionState` lets activity drive planning without making selected, resident, active, and rendered rooms the same thing. + +## BSP/PVS Example + +Use BSP leaves when a portal world has spatial partitions and portal faces. The package validates the BSP graph, bakes broad portal PVS into indexed leaf/portal bitsets, and can either select that broad set from the camera point or further clip it through a 3D camera/portal frustum before rendering. + +```ts +import { + compilePolyWorldBsp, + createPolyWorldState, + createPolyWorldTopology, + planPolyWorldBspVisibilityFrame, +} from "@layoutit/polycss-world"; + +const world = createPolyWorldTopology({ + regions: [{ id: "studio" }, { id: "gallery" }], + links: [ + { id: "studio-gallery", fromRegionId: "studio", toRegionId: "gallery" }, + ], + elements: [ + { id: "studio-shell", regionIds: ["studio"], layers: ["world"] }, + { id: "gallery-shell", regionIds: ["gallery"], layers: ["world"] }, + ], +}); + +const bsp = compilePolyWorldBsp({ + regions: [ + { + id: "studio", + regionId: "studio", + bounds: { min: [-4, -4, 0], max: [0, 4, 3] }, + }, + { + id: "gallery", + regionId: "gallery", + bounds: { min: [0, -4, 0], max: [4, 4, 3] }, + }, + ], + portals: [ + { + id: "studio-gallery-portal", + fromRegionId: "studio", + toRegionId: "gallery", + linkId: "studio-gallery", + bounds: { min: [0, -1, 0], max: [0, 1, 2] }, + }, + ], + pvs: { projection: "xy", sampleInset: 1 }, +}); + +const previousState = createPolyWorldState(world, { + selection: { regionIds: ["studio"] }, +}); + +const frame = planPolyWorldBspVisibilityFrame(world, bsp, { + previousState, + policies: [{ id: "render", layer: "render", elementLayers: ["world"] }], + point: cameraPosition, + forward: cameraForward, + up: [0, 0, 1], + aspect: viewportWidth / viewportHeight, + fovDegrees: 90, + includeTrace: true, + debug: { listLimit: 20 }, + planDebug: { includeEntries: false, listLimit: 20 }, + surfaces: [ + { + id: "studio-wall-0", + elementId: "studio-wall-0", + regionId: "studio", + vertices: [[-4, -4, 0], [-4, 4, 0], [-4, 4, 3], [-4, -4, 3]], + }, + ], +}); + +const visibleElementIds = frame.nextState.resolvedElementIds; +const plan = frame.plan; +const bspDebug = frame.visibility.debug; +const planDebug = frame.debug; +``` + +`compilePolyWorldBsp` generates the split tree and portal vertices from region bounds and portal-opening bounds. `createPolyWorldBspTree` and `bakePolyWorldBspPvs` remain available when an app already has compiled BSP nodes and portal polygons from another pipeline. Manual portal vertices are canonicalized into a stable coplanar convex winding before traversal. Baked visibility is stored on `leaf.pvs`, while authored leaf contents such as `elementIds` stay on the leaf itself. The tree-level `pvsIndex` maps leaf and portal ids to the typed bitsets in each baked PVS record. Baked PVS metadata (`regionIds`, `linkIds`, `selectionKeys`, and `elementIds`) is validated against the decoded leaf and portal bits so stale summaries fail at tree creation. Baked broad PVS must also include directly adjacent portal leaves and portal bits, so an imported artifact cannot hide a neighboring open room while still claiming BSP/PVS proof. Use `resolvePolyWorldBspBakedPvs`, `decodePolyWorldBspPvsLeafIds`, or `decodePolyWorldBspPvsPortalIds` instead of reading the bitsets directly. + +BSP leaves can also carry optional `clusterId` metadata. PVS and view-PVS results expose `clusterIds` alongside `leafIds`, so apps can debug Quake-style leaf-versus-cluster visibility without asking PolyCSS World to parse Quake BSP files. + +Use `planPolyWorldBspVisibilityFrame` for ordinary camera updates that should produce a next world state, state diff, layer plan, optional plan debug snapshot, and BSP visibility data in one call. Use `resolvePolyWorldBspVisibility` when an app only needs the current BSP leaf, broad baked PVS, camera-clipped view PVS, topology selection, optional portal trace, and optional BSP debug snapshot. + +Use `summarizePolyWorldBspTopologyProof` when a debug panel, test, or example needs to prove which BSP/PVS path is active. The proof element has `profile: "bsp-pvs"`, compiler metadata (`authored`, `bounds-bsp`, `brush-bsp`, or `polygon-bsp`), root/leaf reference counts, solid/empty/outside leaf counts when known, generated/candidate/rejected portal counts, PVS index coverage, baked-PVS coverage, density, and validation guarantees. Check `pvs.level` first for the coarse proof tier: `certified-tree-only`, `portal-clipped-baked-pvs`, `exact-baked-pvs`, `authored-baked-pvs`, `partial-baked-pvs`, `authored-loose-pvs`, `debug-loose-pvs`, or `uncertified`. The proof also reports `pvs.method`, `pvs.source`, and `pvs.completeness` so callers can distinguish exact baked PVS, PolyCSS World's package-generated portal-clipped baked PVS, authored baked PVS, authored loose PVS, debug loose PVS, and unavailable PVS. Generated portal-clipped PVS is labeled with `portal-clipped-baked-pvs`; it is useful for broad visibility but is still not presented as Quake BSP format support or full `qbsp`/`vis` parity. `createPolyWorldBspDebugSnapshot` includes this same proof as `snapshot.proof`. + +Pass `surfaces` when renderable elements are surface-level instead of region-level. These records use the same spatial-element role and visibility vocabulary as the topology catalog. The frame tests each surface polygon against the same portal-clipped BSP view traversal and adds only surviving `elementId`s to the transition selection. This avoids the common mistake of mounting a whole room element just because one BSP leaf or region is visible. + +BSP view surfaces can declare both a semantic `role` and an optional `visibility` override. Use `role: "root"` for resident containers, `role: "shell"` for floors, ceilings, and walls, `role: "opening"` for portal or door frames that should stay stable with the visible leaf/region, and `role: "detail"` or `role: "prop"` for incidental geometry that should be clipped by the portal/view footprint. If `visibility` is omitted, `root`, `shell`, and `opening` resolve to structural visibility, while `detail` and `prop` resolve to clipped detail visibility. Existing callers can still pass `visibility: "structural"` or `visibility: "detail"` directly. Resolved surface elements include role summaries plus explicit `structuralSurfaceIds`, `structuralElementIds`, `detailSurfaceIds`, and `detailElementIds`, so debug panels can prove shell/opening surfaces stayed mounted while props were clipped. + +BSP visibility frames also expose `visibilitySets`, a compact truth-ladder summary for the current frame: current leaf, broad-PVS leaves, view-PVS leaves, structural surface ids, detail surface ids, and planned element ids. It intentionally does not include DOM-mounted ids; those remain in the caller-owned DOM/apply debug layer. + +Use `tracePolyWorldBspViewPvs` when a debug panel or minimap needs to explain why a portal was accepted or rejected. Trace entries report the source leaf, target leaf, portal id, depth, status (`visible`, `clipped`, `closed`, `blocked`, `depth-capped`, `outside-broad-phase`, `missing-target-leaf`, or `degenerate-clip`), vertex counts, and clip-plane count for visible portals. + +Use `createPolyWorldBspDebugSnapshot` when an app needs compact BSP inspection data for a debug panel or minimap. It summarizes tree shape, compiler metadata, solid/empty/outside leaf counts when available, baked-PVS density, current broad/view PVS lists, trace status counts, and the topology proof. Detailed trace entries are opt-in and can be capped with `entryLimit`. + +Detailed BSP trace entries preserve portal ids, leaf ids, status, depth, vertex counts, clip-plane counts, and link/selection-key metadata when present, so a debug UI can explain culling decisions without reading private traversal state. + +Pass `portalState` to `selectPolyWorldBspPvs`, `selectPolyWorldBspViewPvs`, `resolvePolyWorldBspPvs`, or `resolvePolyWorldBspViewPvs` when doors or area portals can close at runtime: + +```ts +const selection = selectPolyWorldBspViewPvs(world, bsp, { + point: cameraPosition, + forward: cameraForward, + fovDegrees: 90, + portalState: { + "studio-gallery": "closed", + }, +}); + +console.log(frame.artifact.profile); // "bsp-pvs" +``` + +`portalState` records are resolved by portal id first, then by `linkId`, so generated brush portals can still be controlled by authored topology link ids. + +Use `compilePolyWorldBrushBsp` when the source is authored solid space. Bounds brushes are converted into six halfspace planes, explicit plane brushes are normalized, and the compiler builds a recursive BSP tree by splitting the active convex cell with brush and region planes. Leaves are emitted from that recursion, then marked solid or empty; face-overlap portals are generated only between adjacent empty leaves, and outside space can be classified: + +```ts +import { compilePolyWorldBrushBsp } from "@layoutit/polycss-world"; + +const brushBsp = compilePolyWorldBrushBsp({ + worldBounds: { min: [-8, -8, 0], max: [8, 8, 3] }, + brushes: [ + { id: "wall", bounds: { min: [0, -8, 0], max: [0.25, 8, 3] } }, + ], + regions: [ + { id: "left", bounds: { min: [-8, -8, 0], max: [0, 8, 3] } }, + { id: "right", bounds: { min: [0.25, -8, 0], max: [8, 8, 3] } }, + ], + outside: "solid", + pvs: { projection: "xy" }, +}); +``` + +`outside: "solid"` treats leaves without an authored region as solid. `outside: "flood-fill"` instead starts from empty leaves touching `worldBounds`, walks the generated empty-leaf portal graph, and marks every reachable leaf as outside solid space. A sealed room stays empty; a leaked room becomes outside. + +Brushes can also provide explicit BSP planes. Plane normals point to the front side; brush solid space defaults to the back side of each plane unless `side: "front"` is set. When `bounds` and `planes` are both provided, the bounds clip the plane brush: + +```ts +const slopedBrushBsp = compilePolyWorldBrushBsp({ + worldBounds: { min: [0, 0, 0], max: [2, 2, 1] }, + brushes: [ + { + id: "diagonal-solid", + bounds: { min: [0, 0, 0], max: [2, 2, 1] }, + planes: [{ normal: [-1, -1, 0], distance: -2 }], + }, + ], + regions: [ + { id: "walkable", bounds: { min: [0, 0, 0], max: [2, 2, 1] } }, + ], +}); +``` + +Use `compilePolyWorldPolygonBsp` when the source is actual surface geometry and you need plane-based BSP splitting: + +```ts +import { compilePolyWorldPolygonBsp } from "@layoutit/polycss-world"; + +const polygonBsp = compilePolyWorldPolygonBsp({ + surfaces: [ + { + id: "divider", + vertices: [[0, -1, 0], [0, 1, 0], [0, 1, 2], [0, -1, 2]], + }, + { + id: "floor", + vertices: [[-2, -1, 0], [2, -1, 0], [2, 1, 0], [-2, 1, 0]], + }, + ], +}); + +console.log(polygonBsp.tree, polygonBsp.fragments); +``` + +## State And Planning Example + +Turn selections into explicit state, diff states, and produce caller-defined layer intent. + +```ts +import { + applyPolyWorldDomPlan, + createPolyWorldDomApplyDebugSnapshot, + createPolyWorldDomRegistry, + createPolyWorldPlanDebugSnapshot, + createPolyWorldResourceReadinessGuards, + createPolyWorldState, + diffPolyWorldState, + planPolyWorldElementSet, + planPolyWorldLayers, + planPolyWorldTransition, + summarizePolyWorldResourceReadiness, +} from "@layoutit/polycss-world"; + +const previous = createPolyWorldState(world, { + selection: { regionIds: ["studio"] }, +}); +const next = createPolyWorldState(world, { + selection: { regionIds: ["studio", "gallery"] }, +}); +const diff = diffPolyWorldState(previous, next); + +const plan = planPolyWorldLayers(world, diff, [ + { id: "render", layer: "render", elementLayers: ["world"] }, + { + id: "preload", + layer: "preload", + elementLayers: ["world"], + actions: { added: "preload", retained: "noop" }, + }, +]); + +const debug = createPolyWorldPlanDebugSnapshot(diff, plan); +``` + +Each plan entry keeps the action vocabulary small, but also carries explicit target state. For example, `show` defaults to `{ visible: true, rendered: true }`, `hide` defaults to `{ visible: false, rendered: false }`, and `preload` defaults to `{ preloaded: true }`. Policies can override `targetStates` and `phase` when an app needs loaded/resident/active/rendered planning without inventing new DOM actions. + +When an app already has a resolved element set from an external visibility system, use `planPolyWorldElementSet` instead of constructing a topology state just to diff ids. This is the intended bridge for source engines that already compile BSP/PVS, portal sets, or chunk working sets and only need PolyCSS World to produce stable plan entries: + +```ts +const plan = planPolyWorldElementSet({ + previousElementIds: previousVisibleFaceLeafIds, + nextElementIds: nextVisibleFaceLeafIds, + layer: "render", + policyId: "source-pvs", + reasonLabels: ["source-pvs:leaf-42"], +}); +``` + +`planPolyWorldElementSet` still uses the same `show`/`hide`/`retain`/`preload` vocabulary and `targetStates` overrides as topology-backed plans, so a caller can plan residency or preloading from the same external set diff without making visibility and residency the same thing. + +Policies may also attach app-owned `guards` and `dependencies`. These are plain check results, or callbacks that return check results for each entry. PolyCSS World does not evaluate resources or schedule jobs; it only carries the results into plan/debug output. `applyPolyWorldDomPlan` blocks entries with failed checks and reports `guardFailureElementIds` and `dependencyFailureElementIds`; DOM mount failures such as a missing parent are reported separately as `mountBlockedElementIds`: + +```ts +const plan = planPolyWorldLayers(world, diff, [ + { + id: "render", + layer: "render", + elementLayers: ["world"], + guards: ({ elementId }) => [ + { id: "resource-ready", ok: readyElementIds.has(elementId ?? "") }, + ], + dependencies: [{ id: "root-mounted", ok: rootMounted }], + }, +]); +``` + +When topology elements, spatial elements, or document resource declarations name resources, `summarizePolyWorldResourceReadiness` can turn an app-owned resource map into a readiness summary, and `createPolyWorldResourceReadinessGuards` can turn that same data into ordinary plan guards. Readiness states are `missing`, `requested`, `loading`, `ready`, `failed`, and `stale`. Non-ready resources are render-blocking by default. Use `renderBlocking: false` for nonblocking previews or hints, and `preloadOnly: true` for resources that should be reported/requested without blocking rendered DOM. This is still data-only: PolyCSS World does not fetch, retry, cache, decode, evict, or schedule resources. + +```ts +const readiness = summarizePolyWorldResourceReadiness( + world, + ["studio-shell", "gallery-door-frame"], + { + "texture:gallery-floor": "ready", + "mesh:door-frame": "stale", + }, + { + resourceDeclarations: document.resources, + }, +); + +console.log(readiness.blockedResourceIds, readiness.preloadOnlyResourceIds); +``` + +Use `createPolyWorldResourceLoadSet` when a frame also needs to explain what changed between previous and next visibility: + +```ts +const loadSet = createPolyWorldResourceLoadSet(world, { + previousElementIds: previousState.resolvedElementIds, + nextElementIds: nextState.resolvedElementIds, + resources: appResourceStates, + readyStates: ["ready", "stale"], + resourceDeclarations: document.resources, +}); + +console.log(loadSet.requestResourceIds); +console.log(loadSet.retainResourceIds); +console.log(loadSet.releaseCandidateResourceIds); +console.log(loadSet.readyButNotVisibleResourceIds); +``` + +```ts +const plan = planPolyWorldLayers(world, diff, [ + { + id: "render", + layer: "render", + elementLayers: ["world"], + guards: createPolyWorldResourceReadinessGuards(world, { + "texture:gallery-floor": "ready", + "mesh:door-frame": "loading", + }, { + resourceDeclarations: document.resources, + }), + }, +]); +``` + +For the common case where an app already has the previous state and a next selection, use `planPolyWorldTransition` to create the next state, diff, layer plan, and optional plan debug snapshot together: + +```ts +const transition = planPolyWorldTransition(world, { + previousState: previous, + selection: { regionIds: ["studio", "gallery"] }, + relations: { reasonLabel: "resident-roots" }, + readiness: { + resources: { + "texture:gallery-floor": "ready", + "mesh:door-frame": "stale", + }, + }, + policies: [ + { id: "render", layer: "render", elementLayers: ["world"] }, + ], + debug: { includeEntries: false }, +}); +``` + +`transition.readiness` is the same readiness summary returned by `summarizePolyWorldResourceReadiness`, scoped to the transition's next resolved elements unless `readiness.elementIds` is supplied. `transition.loadSet` adds previous/next resource ids, request ids, retained ids, release candidates, preload-only ids, stale-allowed ids, nonblocking ids, and blocked ids. Plan debug snapshots expose capped `readiness` and `loadSet` sections with counts. BSP, portal, portal-flow, and chunk frame helpers pass this through the same transition path, so profile results can report visibility/working-set data separately from resource blockers. + +Transition and profile-frame results expose `planningSelection`, the exact pre-normalization selection used to create `nextState`, before the diff and layer plan are produced. Plan debug snapshots also summarize that same selection when they are produced from a transition or profile frame. This keeps authored room frames, BSP visibility frames, and chunk streaming frames inspectable without inferring the planned region/element set from resolved state. + +Profile-frame helpers also expose `frameSummary`, a shared truth-ladder object for tests and debug panels. It preserves profile-specific details in `visibilitySets`, `portalSets`, `flowSets`, or `streamingSets`, but gives every frame the same high-level order: `current`, `candidate`, `broad`, `view`, `retained`, `rejected`, optional `readiness`, optional `loadSet`, `planning`, `state`, `diff`, and `plan`. Use it when a UI needs one compact summary across BSP/PVS, authored portals, portal flow, and chunk traversal without treating broad visibility, view visibility, readiness, state, and DOM planning as the same thing. + +Pass `relations` when the next selection resolves detail elements but the transition should also include their `parentId` or `containerId` roots before diffing and planning. This is useful for prepared DOM worlds where a resident root stays mounted while child surfaces render or hide independently. If a caller supplies a precomputed `resolution`, relation expansion is skipped; expand the selection first or provide the already-expanded resolution. + +Layer plans are intent until the caller applies them. `preload` remains non-mutating: `applyPolyWorldDomPlan` reports it as unsupported instead of fetching resources. + +## DOM Apply Example + +Register app-owned DOM-like elements, then apply `show`, `hide`, `retain`, and `noop` plan entries while preserving prepared order. + +```ts +const registry = createPolyWorldDomRegistry([ + { + elementId: "studio-shell", + element: studioElement, + parent: sceneRoot, + mounted: true, + nextElementId: "gallery-shell", + layers: ["world"], + }, + { + elementId: "gallery-shell", + element: galleryElement, + parent: sceneRoot, + mounted: false, + previousElementId: "studio-shell", + layers: ["world"], + }, +]); + +const apply = applyPolyWorldDomPlan(registry, plan); +const applyDebug = createPolyWorldDomApplyDebugSnapshot(apply); +``` + +By default, `hide` removes a mounted element and `show` reinserts it at its prepared order hint. Use `applyPolyWorldDomPlan(registry, plan, { hideMode: "hidden" })` when prepared elements must stay mounted and only toggle the standard `hidden` attribute. + +Apply results expose both `mountedElementIds` and `hiddenElementIds`, so callers can distinguish mounted visible elements from mounted hidden elements after hidden-only apply. + +The DOM layer only toggles, reinserts, and removes registered elements. It does not create elements, inspect `document`, parse renderer styles, import PolyCSS renderers, or run a scheduler. + +## Debug Detail + +Debug snapshots are deterministic and adapter-friendly. By default they keep full id lists for small authored worlds. Large worlds can cap list and entry detail while preserving full counts: + +```ts +const compactPlanDebug = createPolyWorldPlanDebugSnapshot(diff, plan, { + entryLimit: 20, + listLimit: 20, +}); + +const compactApplyDebug = createPolyWorldDomApplyDebugSnapshot(apply, { + entryLimit: 20, + listLimit: 20, +}); + +const compactBspDebug = createPolyWorldBspDebugSnapshot(bsp, { + trace, + includeTraceEntries: true, + entryLimit: 20, + listLimit: 20, +}); +``` + +Use `includeEntries: false` when an app debug API only needs counts and omitted totals. + +## Chunk Example + +Use ordered chunk windows when the world streams through sections, blocks, tiles, or authored terrain strips. + +```ts +import { + createPolyWorldTopology, + resolvePolyWorldElements, + selectPolyWorldChunkWindow, +} from "@layoutit/polycss-world"; + +const world = createPolyWorldTopology({ + regions: [ + { id: "chunk-0" }, + { id: "chunk-1" }, + { id: "chunk-2" }, + { id: "chunk-3" }, + ], + elements: [ + { id: "road-1", regionIds: ["chunk-1"], layers: ["world"] }, + { id: "road-2", regionIds: ["chunk-2"], layers: ["world"] }, + { id: "track-3", selectionKeys: ["track:chunk-3"], layers: ["track"] }, + ], +}); + +const selection = selectPolyWorldChunkWindow(world, { + currentRegionId: "chunk-2", + before: 1, + after: 1, + taggedRegionSelections: [ + { + kind: "preload", + label: "next-section", + regionIds: ["chunk-3"], + selectionKeys: ["track:chunk-3"], + }, + ], +}); + +const elements = resolvePolyWorldElements(world, selection); +``` + +Use streaming sources when loaded, resident, active, and rendered chunks should be tracked separately. A streaming source returns a topology selection plus `selection.streaming`, but callers should derive the render selection from the state they actually want to display: + +```ts +import { + createPolyWorldChunkStreamingDebugSnapshot, + selectPolyWorldChunkStreaming, + selectPolyWorldChunkStreamingState, +} from "@layoutit/polycss-world"; + +const streaming = selectPolyWorldChunkStreaming(world, { + orderedRegionIds: ["chunk-0", "chunk-1", "chunk-2", "chunk-3"], + loadedRegionIds: ["chunk-1"], + residentRegionIds: ["chunk-1"], + sources: [ + { + id: "player", + regionId: "chunk-2", + before: 1, + after: 1, + targetState: "rendered", + priority: 10, + label: "player-stream", + }, + { + id: "lookahead", + regionId: "chunk-3", + targetState: "loaded", + label: "lookahead-load", + }, + ], +}); + +const renderSelection = selectPolyWorldChunkStreamingState(world, streaming, "rendered", { + reasonLabel: "rendered-chunks", +}); + +const renderElements = resolvePolyWorldElements(world, renderSelection); +const chunkDebug = createPolyWorldChunkStreamingDebugSnapshot(streaming, { + includeSources: true, + listLimit: 20, +}); +``` + +This keeps residency and visibility separate: a chunk may be loaded or resident without being rendered. + +Streaming sources are processed by descending `priority` and then by source id, so requested-region and reason order stays deterministic even when callers provide sources out of order. A source `loadingRange` is measured from `point`, `position`, the current region `center`, or the current region bounds center, in that order. + +Chunk streams can also expand through a caller-provided chunk graph. This is still selection only: PolyCSS World does not fetch resources, choose LOD meshes, or run a scheduler. + +```ts +const streaming = selectPolyWorldChunkStreaming(world, { + chunkGraph: { + parentRegionIds: { + "sector-a": "world", + "tile-a": "sector-a", + }, + childRegionIds: { + "sector-a": ["tile-a", "tile-b"], + }, + relatedRegionIds: { + "tile-a": ["tile-c"], + }, + }, + sources: [ + { + id: "camera", + regionId: "tile-a", + targetState: "rendered", + chunkGraphExpansion: { + includeParents: true, + includeRelated: true, + recursive: true, + targetState: "resident", + }, + }, + { + id: "sector-render", + regionId: "sector-a", + targetState: "rendered", + chunkGraphExpansion: { includeChildren: true }, + }, + ], +}); +``` + +Graph expansion lets parent or related chunks stay loaded/resident while a narrower child set is rendered. Put `targetState` on `chunkGraphExpansion` when graph-expanded regions should use a different lifecycle state from the source chunk. Source debug summaries expose `graphRegionIds`, `graphTargetState`, and `missingRegionIds` when graph expansion adds regions or references missing ones. + +For spatially organized worlds, use a chunk tree instead of hand-authored graph maps. The tree is still data-only: it validates hierarchy, availability, content presence, bounds, resource ids, and refinement metadata, then derives the graph used by streaming selection. PolyCSS World does not fetch content, choose concrete mesh LODs, or enforce screen-space error: + +```ts +import { + createPolyWorldChunkTree, + selectPolyWorldChunkStreaming, +} from "@layoutit/polycss-world"; + +const chunkTree = createPolyWorldChunkTree({ + chunks: [ + { + id: "world", + regionId: "world", + childIds: ["sector-a"], + available: true, + contentAvailable: true, + refinement: "add", + }, + { + id: "sector-a", + regionId: "sector-a", + parentId: "world", + childIds: ["tile-a", "tile-b"], + available: true, + contentAvailable: true, + geometricError: 2, + }, + { + id: "tile-a", + regionId: "tile-a", + parentId: "sector-a", + available: true, + contentAvailable: true, + resourceIds: ["mesh:tile-a"], + }, + ], +}, { topology: world }); + +const streaming = selectPolyWorldChunkStreaming(world, { + chunkTree, + sources: [ + { + id: "camera", + regionId: "tile-a", + targetState: "rendered", + chunkGraphExpansion: { + includeParents: true, + recursive: true, + targetState: "resident", + }, + }, + ], +}); + +console.log(streaming.streaming.chunkTree); +``` + +When the tree should drive residency directly, opt into budgeted traversal. `resolvePolyWorldChunkTreeTraversal` walks from roots/current chunk, applies availability, content presence, refinement, geometric-error, camera/frustum, viewer request bounds, and budget inputs, and returns per-chunk reasons such as `refined`, `rendered`, `held`, `requested`, `unavailable`, `outside-request-volume`, `view-culled`, `skipped`, and `budget-clipped`: + +```ts +const traversal = resolvePolyWorldChunkTreeTraversal(chunkTree, { + currentRegionId: "tile-a", + point: cameraPosition, + forward: cameraForward, + up: [0, 0, 1], + fovDegrees: 80, + aspect: viewportWidth / viewportHeight, + viewportHeight, + near: 0.1, + far: 200, + budget: { + maxScreenSpaceError: 16, + maxRenderedChunks: 4, + maxRenderCost: 8, + }, +}); + +console.log(traversal.renderedRegionIds); +console.log(traversal.viewCulledChunkIds); +console.log(traversal.budgetClippedChunkIds); +console.log(traversal.entries[0]?.screenSpaceError); +``` + +When `viewportHeight`, camera/FOV data, and `budget.maxScreenSpaceError` are present, traversal computes per-entry `distanceToCamera` and `screenSpaceError` from each chunk's `geometricError` and uses that screen-space error to decide refinement. If those inputs are absent, traversal falls back to `budget.targetGeometricError`. This mirrors the useful 3D Tiles idea without adopting 3D Tiles loading: the result is still a data-only working-set plan. + +Callers that already have culling planes can pass `frustum` instead of camera vectors. Plane normals use the same convention as the rest of the package: a chunk is inside when at least part of its bounds is on or in front of every plane. `contentBounds` can narrow view culling and screen-space distance to actual chunk content while `bounds` remains available for chunk lookup and hierarchy. `viewerRequestBounds` is a data-only eligibility gate: if the traversal point is outside that volume, a non-active chunk is skipped with `outside-request-volume`. Chunks without bounds remain eligible, and the active current/ancestor path is retained even when a sibling is view-culled or outside its request volume. + +Pass `chunkTraversal` to `selectPolyWorldChunkStreaming` when that traversal should populate requested, loaded, resident, and rendered region buckets. This remains declarative: unavailable or missing content can be requested or held in debug state, but the app still owns fetch timing, cache eviction, and actual mesh replacement. + +```ts +const streaming = selectPolyWorldChunkStreaming(world, { + chunkTree, + currentRegionId: "tile-a", + chunkTraversal: { + budget: { + maxRenderedChunks: 4, + maxLoadedChunks: 8, + }, + }, +}); + +console.log(streaming.streaming.chunkTraversal?.entries); +``` + +Use `planPolyWorldChunkStreamingFrame` for the common streaming update path. It creates the streaming selection, derives the render selection from a streaming state such as `rendered`, then produces next state, diff, layer plan, plan debug, and chunk debug: + +```ts +const frame = planPolyWorldChunkStreamingFrame(world, { + previousState, + orderedRegionIds: ["chunk-0", "chunk-1", "chunk-2"], + sources: [ + { id: "player", regionId: "chunk-1", before: 1, after: 1, targetState: "rendered" }, + { id: "lookahead", regionId: "chunk-2", targetState: "loaded" }, + ], + renderSelection: { reasonLabel: "rendered-chunks" }, + policies: [{ id: "render", layer: "render", elementLayers: ["world"] }], + debug: { includeSources: true }, +}); + +console.log(frame.artifact.profile); // "chunk-traversal" +``` + +Chunk frames expose `streamingSets`, a compact truth-ladder summary of the selected/rendered/loaded/resident/requested chunk and region ids, held/unavailable/view-culled/outside-request-volume/skipped/budget-clipped chunk ids, and planned element ids. Use it for debug panels and tests that need to prove working-set state without parsing traversal entries or DOM records. + +## Scope + +- Regions, links, elements, selections, and selection reasons. +- Element resolution by region, selection key, element id, source id, and alias. +- Portal, portal-flow, bounds-BSP/PVS, brush-BSP/PVS, polygon-BSP, chunk-window, chunk-streaming, and chunk-tree selector helpers. +- Spatial element catalogs for topology-relevant roots, shells, openings, details, props, resource refs, bounds, and polygon surfaces. +- Structural versus detail surface selection for BSP view-surface elements using the shared spatial-element role vocabulary. +- Bounds-first region lookup with optional nearest-center fallback. +- Stable world state snapshots and state diffs. +- Caller-defined layer plans with `show`, `hide`, `retain`, `preload`, and `noop` intent plus explicit target-state metadata. +- DOM-like record registries with element/source/alias/layer/tag lookups. +- Stable-order DOM apply for registered elements, including hidden-only apply for prepared elements. +- Stable debug snapshots, capped BSP/plan/chunk/apply detail, and app-owned debug adapters. + +## Selection Keys + +`selectionKeys` are opaque topology keys. Regions and links can expose keys, selectors can carry them forward, and elements can opt into them. Element resolution returns only elements that match the selected regions, ids, source ids, aliases, or element-owned selection keys. Debug `unresolved.selectionKeys` reports keys unknown to the topology, not known keys that simply produce no element action. + +## Element Relationships + +Elements can declare `parentId` and `containerId` when a prepared world has resident roots and independently visible children. The topology indexes these relationships through `elementsByParentId` and `elementsByContainerId`, but the package does not turn them into a scene graph or renderer-owned hierarchy. Planners and DOM apply helpers still operate on caller-registered element records. + +Generic debug snapshots include relation summaries for resolved elements, so debug UIs can see which parent/container roots are involved without walking app DOM records. + +Use `resolvePolyWorldElementRelations` when a visibility selection resolves detail elements but the app also needs their resident roots or containers: + +```ts +const relationExpansion = resolvePolyWorldElementRelations(world, ["gallery-wall"]); + +console.log(relationExpansion.relatedElementIds); +``` + +Use `expandPolyWorldSelectionElementRelations` when a region, BSP, portal, or chunk selection should keep those roots selectable by element id while preserving the original visibility selection: + +```ts +const visibleSelection = { regionIds: ["gallery"] }; +const renderSelection = expandPolyWorldSelectionElementRelations(world, visibleSelection); +const renderState = createPolyWorldState(world, { selection: renderSelection }); +``` + +The expansion is data-only. It does not mount parents, create DOM nodes, or make parent/child visibility the same thing. Cyclic `parentId` or `containerId` chains are rejected at topology creation because they cannot produce stable resident-root planning. diff --git a/packages/world/package.json b/packages/world/package.json new file mode 100644 index 000000000..ef180e078 --- /dev/null +++ b/packages/world/package.json @@ -0,0 +1,49 @@ +{ + "name": "@layoutit/polycss-world", + "version": "0.0.0", + "description": "Topology, state, planning, and DOM helpers for authored PolyCSS worlds.", + "type": "module", + "main": "dist/index.cjs", + "module": "dist/index.js", + "types": "dist/index.d.ts", + "keywords": ["polycss", "world", "topology", "3d", "css", "dom"], + "license": "MIT", + "repository": { + "type": "git", + "url": "https://github.com/LayoutitStudio/polycss.git", + "directory": "packages/world" + }, + "bugs": { + "url": "https://github.com/LayoutitStudio/polycss/issues" + }, + "homepage": "https://github.com/LayoutitStudio/polycss#readme", + "files": [ + "dist" + ], + "exports": { + ".": { + "types": "./dist/index.d.ts", + "import": "./dist/index.js", + "require": "./dist/index.cjs" + } + }, + "scripts": { + "build": "tsup", + "test": "vitest run --passWithNoTests", + "test:coverage": "vitest run --coverage --passWithNoTests", + "prepack": "node ../../.github/scripts/sync-package-readmes.mjs", + "prepublishOnly": "npm run build" + }, + "publishConfig": { + "access": "public" + }, + "dependencies": { + "@layoutit/polycss-core": "workspace:^" + }, + "devDependencies": { + "@vitest/coverage-v8": "^3.1.1", + "tsup": "^8.0.1", + "typescript": "^5.3.3", + "vitest": "^3.1.1" + } +} diff --git a/packages/world/src/debug/bspSnapshot.ts b/packages/world/src/debug/bspSnapshot.ts new file mode 100644 index 000000000..206652e2b --- /dev/null +++ b/packages/world/src/debug/bspSnapshot.ts @@ -0,0 +1,240 @@ +import type { + PolyWorldBspResolvedPvs, + PolyWorldBspResolvedViewPvs, + PolyWorldBspTree, + PolyWorldBspViewPvsTrace, + PolyWorldBspViewPvsTraceEntry, + PolyWorldBspViewPvsTraceStatus, +} from "../profiles/bsp"; +import { + summarizePolyWorldBspTopologyProof, + type PolyWorldBspTopologyProof, +} from "../profiles/bspProof"; +import type { PolyWorldProfileArtifactProof } from "../profiles/artifact"; +import { limitPolyWorldDebugList } from "./limits"; + +export interface PolyWorldBspDebugSnapshotOptions { + leafId?: string; + broadPvs?: PolyWorldBspResolvedPvs; + viewPvs?: PolyWorldBspResolvedViewPvs; + trace?: PolyWorldBspViewPvsTrace; + listLimit?: number; + entryLimit?: number; + includeTraceEntries?: boolean; + metadata?: Record; +} + +export interface PolyWorldBspDebugListSummary { + values: readonly string[]; + count: number; + omitted: number; +} + +export interface PolyWorldBspDebugResolvedPvsSummary { + leafIds: PolyWorldBspDebugListSummary; + clusterIds: PolyWorldBspDebugListSummary; + regionIds: PolyWorldBspDebugListSummary; + linkIds: PolyWorldBspDebugListSummary; + portalIds: PolyWorldBspDebugListSummary; + selectionKeys: PolyWorldBspDebugListSummary; + elementIds: PolyWorldBspDebugListSummary; +} + +export interface PolyWorldBspDebugViewPvsSummary extends PolyWorldBspDebugResolvedPvsSummary { + broadPhaseLeafIds: PolyWorldBspDebugListSummary; + broadPhasePortalIds: PolyWorldBspDebugListSummary; + fovDegrees: number; +} + +export interface PolyWorldBspDebugTraceEntry { + portalId: string; + fromLeafId: string; + toLeafId: string; + depth: number; + status: PolyWorldBspViewPvsTraceStatus; + inputVertexCount: number; + clippedVertexCount?: number; + clipPlaneCount?: number; + linkId?: string; + selectionKeys?: readonly string[]; +} + +export interface PolyWorldBspDebugSnapshot { + schemaVersion: 1; + artifact: PolyWorldProfileArtifactProof; + proof: PolyWorldBspTopologyProof; + tree: { + leafCount: number; + portalCount: number; + nodeCount: number; + maxDepth: number; + compiler?: string; + partition?: string; + leafBuilder?: string; + portalBuilder?: string; + }; + leaves: { + clusterCount: number; + solidCount?: number; + emptyCount?: number; + outsideCount?: number; + bakedPvsCount: number; + pvsDensity?: number; + }; + portals: { + generatedCount: number; + candidateCount?: number; + rejectedCandidateCount?: number; + }; + current: { + leafId?: string; + broadPvs?: PolyWorldBspDebugResolvedPvsSummary; + viewPvs?: PolyWorldBspDebugViewPvsSummary; + }; + trace?: { + entryCount: number; + statusCounts: Partial>; + entries?: readonly PolyWorldBspDebugTraceEntry[]; + omittedEntries?: number; + }; + metadata?: Record; +} + +export function createPolyWorldBspDebugSnapshot( + tree: PolyWorldBspTree, + options: PolyWorldBspDebugSnapshotOptions = {}, +): PolyWorldBspDebugSnapshot { + const proof = summarizePolyWorldBspTopologyProof(tree); + const broadPvs = options.broadPvs; + const viewPvs = options.viewPvs ?? options.trace; + const snapshot: PolyWorldBspDebugSnapshot = { + schemaVersion: 1, + artifact: proof.artifact, + proof, + tree: { + leafCount: proof.tree.leafCount, + portalCount: proof.tree.portalCount, + nodeCount: proof.tree.nodeCount, + maxDepth: proof.tree.maxDepth, + ...(proof.compiler.id === "authored" ? {} : { compiler: proof.compiler.id }), + ...(proof.compiler.partition === undefined ? {} : { partition: proof.compiler.partition }), + ...(proof.compiler.leafBuilder === undefined ? {} : { leafBuilder: proof.compiler.leafBuilder }), + ...(proof.compiler.portalBuilder === undefined ? {} : { portalBuilder: proof.compiler.portalBuilder }), + }, + leaves: { + clusterCount: uniqueStrings(tree.leaves.flatMap((leaf) => leaf.clusterId === undefined ? [] : [leaf.clusterId])).length, + ...optionalCount("solidCount", proof.leaves.solidCount), + ...optionalCount("emptyCount", proof.leaves.emptyCount), + ...optionalCount("outsideCount", proof.leaves.outsideCount), + bakedPvsCount: proof.leaves.bakedPvsCount, + ...optionalCount("pvsDensity", proof.pvs.pvsDensity), + }, + portals: { + generatedCount: proof.portals.generatedCount, + ...optionalCount("candidateCount", proof.portals.candidateCount), + ...optionalCount("rejectedCandidateCount", proof.portals.rejectedCandidateCount), + }, + current: { + ...stringValue("leafId", options.leafId ?? viewPvs?.leafId ?? broadPvs?.leafId), + ...(broadPvs === undefined ? {} : { broadPvs: summarizeResolvedPvs(broadPvs, options.listLimit) }), + ...(viewPvs === undefined ? {} : { viewPvs: summarizeViewPvs(viewPvs, options.listLimit) }), + }, + ...(options.trace === undefined ? {} : { trace: summarizeTrace(options.trace, options) }), + ...(options.metadata === undefined ? {} : { metadata: options.metadata }), + }; + return snapshot; +} + +export function adaptPolyWorldBspDebugSnapshot( + snapshot: PolyWorldBspDebugSnapshot, + adapter: (snapshot: PolyWorldBspDebugSnapshot) => T, +): T { + return adapter(snapshot); +} + +function summarizeResolvedPvs( + pvs: PolyWorldBspResolvedPvs, + listLimit: number | undefined, +): PolyWorldBspDebugResolvedPvsSummary { + return { + leafIds: summarizeList(pvs.leafIds, listLimit), + clusterIds: summarizeList(pvs.clusterIds, listLimit), + regionIds: summarizeList(pvs.regionIds, listLimit), + linkIds: summarizeList(pvs.linkIds, listLimit), + portalIds: summarizeList(pvs.portalIds, listLimit), + selectionKeys: summarizeList(pvs.selectionKeys, listLimit), + elementIds: summarizeList(pvs.elementIds, listLimit), + }; +} + +function summarizeViewPvs( + pvs: PolyWorldBspResolvedViewPvs, + listLimit: number | undefined, +): PolyWorldBspDebugViewPvsSummary { + return { + ...summarizeResolvedPvs(pvs, listLimit), + broadPhaseLeafIds: summarizeList(pvs.broadPhaseLeafIds, listLimit), + broadPhasePortalIds: summarizeList(pvs.broadPhasePortalIds, listLimit), + fovDegrees: pvs.fovDegrees, + }; +} + +function summarizeTrace( + trace: PolyWorldBspViewPvsTrace, + options: PolyWorldBspDebugSnapshotOptions, +): PolyWorldBspDebugSnapshot["trace"] { + const statusCounts: Partial> = {}; + for (const entry of trace.entries) { + statusCounts[entry.status] = (statusCounts[entry.status] ?? 0) + 1; + } + if (options.includeTraceEntries !== true) { + return { + entryCount: trace.entries.length, + statusCounts, + }; + } + const limitedEntries = limitPolyWorldDebugList(trace.entries, options.entryLimit); + return { + entryCount: trace.entries.length, + statusCounts, + entries: limitedEntries.values.map(summarizeTraceEntry), + omittedEntries: limitedEntries.omitted, + }; +} + +function summarizeTraceEntry(entry: PolyWorldBspViewPvsTraceEntry): PolyWorldBspDebugTraceEntry { + return { + portalId: entry.portalId, + fromLeafId: entry.fromLeafId, + toLeafId: entry.toLeafId, + depth: entry.depth, + status: entry.status, + inputVertexCount: entry.inputVertexCount, + ...(entry.clippedVertexCount === undefined ? {} : { clippedVertexCount: entry.clippedVertexCount }), + ...(entry.clipPlaneCount === undefined ? {} : { clipPlaneCount: entry.clipPlaneCount }), + ...(entry.linkId === undefined ? {} : { linkId: entry.linkId }), + ...(entry.selectionKeys === undefined ? {} : { selectionKeys: [...entry.selectionKeys] }), + }; +} + +function summarizeList(values: readonly string[], limit: number | undefined): PolyWorldBspDebugListSummary { + const limited = limitPolyWorldDebugList(values, limit); + return { + values: limited.values, + count: values.length, + omitted: limited.omitted, + }; +} + + +function stringValue(key: string, value: string | undefined): Record { + return value === undefined ? {} : { [key]: value }; +} + +function optionalCount(key: string, value: number | undefined): Record { + return value === undefined ? {} : { [key]: value }; +} + +function uniqueStrings(values: readonly string[]): string[] { + return [...new Set(values)]; +} diff --git a/packages/world/src/debug/chunkSnapshot.test.ts b/packages/world/src/debug/chunkSnapshot.test.ts new file mode 100644 index 000000000..55a6dd446 --- /dev/null +++ b/packages/world/src/debug/chunkSnapshot.test.ts @@ -0,0 +1,352 @@ +import { describe, expect, it } from "vitest"; +import { selectPolyWorldChunkStreaming } from "../profiles"; +import { createPolyWorldTopology } from "../topology"; +import { + adaptPolyWorldChunkStreamingDebugSnapshot, + createPolyWorldChunkStreamingDebugSnapshot, +} from "./index"; + +describe("createPolyWorldChunkStreamingDebugSnapshot", () => { + it("summarizes streaming chunks, source decisions, and omitted detail", () => { + const topology = createPolyWorldTopology({ + regions: [ + { id: "chunk-0", bounds: { min: [0, 0, 0], max: [1, 1, 1] }, selectionKeys: ["chunk-key:0"] }, + { id: "chunk-1", bounds: { min: [1, 0, 0], max: [2, 1, 1] }, selectionKeys: ["chunk-key:1"] }, + { id: "chunk-2", bounds: { min: [2, 0, 0], max: [3, 1, 1] }, selectionKeys: ["chunk-key:2"] }, + { id: "chunk-3", bounds: { min: [3, 0, 0], max: [4, 1, 1] }, selectionKeys: ["chunk-key:3"] }, + ], + elements: [ + { id: "chunk-1-world", regionIds: ["chunk-1"] }, + { id: "chunk-2-world", regionIds: ["chunk-2"] }, + { id: "chunk-3-world", regionIds: ["chunk-3"] }, + ], + }); + const selection = selectPolyWorldChunkStreaming(topology, { + orderedRegionIds: ["chunk-0", "chunk-1", "chunk-2", "chunk-3"], + chunkTree: { + chunks: [ + { id: "root", regionId: "chunk-0", childIds: ["chunk-1"], available: true, contentAvailable: true }, + { id: "chunk-1", regionId: "chunk-1", parentId: "root", available: true, contentAvailable: true }, + { id: "chunk-2", regionId: "chunk-2", available: true, contentAvailable: false }, + { id: "chunk-3", regionId: "chunk-3", available: false, contentAvailable: false }, + ], + }, + loadedRegionIds: ["chunk-1"], + residentRegionIds: ["chunk-1"], + preloadedRegionIds: ["chunk-0"], + sources: [ + { + id: "car", + regionId: "chunk-2", + before: 1, + after: 1, + targetState: "rendered", + priority: 10, + label: "car-stream", + }, + { + id: "preload", + regionId: "chunk-3", + targetState: "preloaded", + label: "preload-next", + }, + ], + }); + const snapshot = createPolyWorldChunkStreamingDebugSnapshot(selection, { + listLimit: 2, + sourceLimit: 1, + metadata: { example: "chunk-follow" }, + }); + + expect(snapshot.selection.regionIds).toEqual({ + values: ["chunk-1", "chunk-2"], + count: 3, + omitted: 1, + }); + expect(snapshot.proof).toMatchObject({ + profile: "chunk-traversal", + artifactKind: "chunk-working-set", + sourceKind: "authored-runtime-selection", + producedBy: "selectPolyWorldChunkStreaming", + counts: { + selectedRegionCount: 3, + sourceCount: 2, + chunkCount: 4, + availableChunkCount: 3, + contentChunkCount: 2, + }, + coverage: { + availableChunkCoverage: 0.75, + contentChunkCoverage: 0.5, + }, + }); + expect(snapshot.proof.guarantees).toContain("availability-state-reporting"); + expect(snapshot.proof.knownWeaknesses).toContain("not-fetch-scheduler"); + expect(snapshot.selection.selectionKeys.count).toBe(3); + expect(snapshot.streaming.loadedRegionIds).toEqual({ + values: ["chunk-1", "chunk-2"], + count: 3, + omitted: 1, + }); + expect(snapshot.streaming.renderedRegionIds.count).toBe(3); + expect(snapshot.streaming.preloadedRegionIds).toEqual({ + values: ["chunk-0", "chunk-3"], + count: 2, + omitted: 0, + }); + expect(snapshot.streaming.sourceCount).toBe(2); + expect(snapshot.streaming.chunkTree).toEqual({ + chunkCount: 4, + maxDepth: 1, + rootChunkIds: { values: ["root", "chunk-2"], count: 3, omitted: 1 }, + availableChunkIds: { values: ["root", "chunk-1"], count: 3, omitted: 1 }, + contentChunkIds: { values: ["root", "chunk-1"], count: 2, omitted: 0 }, + }); + expect(snapshot.streaming.sources).toEqual([ + { + sourceId: "car", + currentRegionId: "chunk-2", + selectedRegionIds: { values: ["chunk-1", "chunk-2"], count: 3, omitted: 1 }, + targetState: "rendered", + priority: 10, + label: "car-stream", + tags: undefined, + missingRegionId: undefined, + }, + ]); + expect(snapshot.streaming.omittedSources).toBe(1); + expect(snapshot.metadata).toEqual({ example: "chunk-follow" }); + expect( + adaptPolyWorldChunkStreamingDebugSnapshot(snapshot, (value) => ({ + activeChunks: value.streaming.activeRegionIds.count, + residentChunks: value.streaming.residentRegionIds.count, + renderedChunks: value.streaming.renderedRegionIds.count, + parityClaim: false, + })), + ).toEqual({ + activeChunks: 3, + residentChunks: 3, + renderedChunks: 3, + parityClaim: false, + }); + }); + + it("summarizes budgeted chunk tree traversal decisions and capped entries", () => { + const topology = createPolyWorldTopology({ + regions: [ + { id: "world", selectionKeys: ["chunk:world"] }, + { id: "tile-a", selectionKeys: ["chunk:tile-a"] }, + { id: "tile-b", selectionKeys: ["chunk:tile-b"] }, + { id: "tile-c", selectionKeys: ["chunk:tile-c"] }, + ], + elements: [ + { id: "world-root", regionIds: ["world"] }, + { id: "tile-a-world", regionIds: ["tile-a"] }, + { id: "tile-b-world", regionIds: ["tile-b"] }, + ], + }); + const selection = selectPolyWorldChunkStreaming(topology, { + chunkTree: { + chunks: [ + { id: "world", regionId: "world", childIds: ["tile-a", "tile-b", "tile-c"], available: true, contentAvailable: true, refinement: "add", cost: 1 }, + { id: "tile-a", regionId: "tile-a", parentId: "world", available: true, contentAvailable: true, priority: 10, cost: 2 }, + { id: "tile-b", regionId: "tile-b", parentId: "world", available: true, contentAvailable: false, priority: 5, cost: 1 }, + { id: "tile-c", regionId: "tile-c", parentId: "world", available: false, contentAvailable: false, priority: 1 }, + ], + }, + currentRegionId: "tile-a", + chunkTraversal: { + budget: { + maxRenderedChunks: 1, + maxLoadedChunks: 2, + }, + }, + }); + const snapshot = createPolyWorldChunkStreamingDebugSnapshot(selection, { + listLimit: 2, + includeTraversalEntries: true, + traversalEntryLimit: 2, + }); + + expect(snapshot.proof).toMatchObject({ + profile: "chunk-traversal", + artifactKind: "chunk-working-set", + sourceKind: "authored-runtime-selection", + producedBy: "resolvePolyWorldChunkTreeTraversal", + counts: { + selectedChunkCount: 3, + renderedChunkCount: 1, + requestedChunkCount: 1, + heldChunkCount: 1, + unavailableChunkCount: 1, + viewCulledChunkCount: 0, + outsideRequestVolumeChunkCount: 0, + budgetClippedChunkCount: 1, + traversalEntryCount: 4, + }, + }); + expect(snapshot.proof.guarantees).toContain("budgeted-traversal"); + expect(snapshot.proof.knownWeaknesses).toContain("not-renderer-lod-swap"); + expect(snapshot.streaming.chunkTraversal?.currentChunkId).toBe("tile-a"); + expect(snapshot.streaming.chunkTraversal?.selectedChunkIds).toEqual({ + values: ["world", "tile-a"], + count: 3, + omitted: 1, + }); + expect(snapshot.streaming.chunkTraversal?.renderedChunkIds).toEqual({ + values: ["world"], + count: 1, + omitted: 0, + }); + expect(snapshot.streaming.chunkTraversal?.requestedChunkIds.values).toEqual(["tile-b"]); + expect(snapshot.streaming.chunkTraversal?.unavailableChunkIds.values).toEqual(["tile-c"]); + expect(snapshot.streaming.chunkTraversal?.viewCulledChunkIds.values).toEqual([]); + expect(snapshot.streaming.chunkTraversal?.outsideRequestVolumeChunkIds.values).toEqual([]); + expect(snapshot.streaming.chunkTraversal?.budgetClippedChunkIds.values).toEqual(["tile-a"]); + expect(snapshot.streaming.chunkTraversal?.budget).toEqual({ + maxRenderedChunks: 1, + maxLoadedChunks: 2, + }); + expect(snapshot.streaming.chunkTraversal?.entryCount).toBe(4); + expect(snapshot.streaming.chunkTraversal?.entries?.map((entry) => [entry.chunkId, entry.reasons])).toEqual([ + ["world", ["root", "ancestor", "refined", "loaded", "rendered"]], + ["tile-a", ["current", "loaded", "budget-clipped", "held"]], + ]); + expect(snapshot.streaming.chunkTraversal?.omittedEntries).toBe(2); + }); + + it("reports viewer request volume filtering separately from generic skipped chunks", () => { + const topology = createPolyWorldTopology({ + regions: [ + { id: "world", selectionKeys: ["chunk:world"] }, + { id: "front", selectionKeys: ["chunk:front"] }, + { id: "gated", selectionKeys: ["chunk:gated"] }, + ], + }); + const selection = selectPolyWorldChunkStreaming(topology, { + chunkTree: { + chunks: [ + { + id: "world", + regionId: "world", + childIds: ["front", "gated"], + bounds: { min: [-1, -1, -1], max: [6, 1, 1] }, + available: true, + contentAvailable: true, + refinement: "add", + }, + { + id: "front", + regionId: "front", + parentId: "world", + bounds: { min: [2, -0.5, -0.5], max: [3, 0.5, 0.5] }, + available: true, + contentAvailable: true, + priority: 2, + }, + { + id: "gated", + regionId: "gated", + parentId: "world", + bounds: { min: [3, -0.5, -0.5], max: [4, 0.5, 0.5] }, + viewerRequestBounds: { min: [20, -1, -1], max: [22, 1, 1] }, + available: true, + contentAvailable: true, + priority: 1, + }, + ], + }, + chunkTraversal: { + currentRegionId: "front", + point: [0, 0, 0], + forward: [1, 0, 0], + fovDegrees: 60, + aspect: 1, + }, + }); + const snapshot = createPolyWorldChunkStreamingDebugSnapshot(selection, { + includeTraversalEntries: true, + }); + + expect(snapshot.proof.guarantees).toContain("viewer-request-volume-filtering"); + expect(snapshot.proof.counts).toMatchObject({ + selectedChunkCount: 2, + outsideRequestVolumeChunkCount: 1, + skippedChunkCount: 1, + }); + expect(snapshot.streaming.chunkTraversal?.outsideRequestVolumeChunkIds.values).toEqual(["gated"]); + expect(snapshot.streaming.chunkTraversal?.skippedChunkIds.values).toEqual(["gated"]); + expect(snapshot.streaming.chunkTraversal?.entries?.find((entry) => entry.chunkId === "gated")?.reasons).toEqual([ + "outside-request-volume", + "skipped", + ]); + }); + + it("reports screen-space-error traversal proof and entry metrics", () => { + const topology = createPolyWorldTopology({ + regions: [ + { id: "sector", selectionKeys: ["chunk:sector"] }, + { id: "tile", selectionKeys: ["chunk:tile"] }, + ], + elements: [ + { id: "sector-world", regionIds: ["sector"] }, + { id: "tile-world", regionIds: ["tile"] }, + ], + }); + const selection = selectPolyWorldChunkStreaming(topology, { + chunkTree: { + chunks: [ + { + id: "sector", + regionId: "sector", + childIds: ["tile"], + bounds: { min: [10, -1, -1], max: [30, 1, 1] }, + available: true, + contentAvailable: true, + refinement: "add", + geometricError: 10, + }, + { + id: "tile", + regionId: "tile", + parentId: "sector", + bounds: { min: [12, -1, -1], max: [14, 1, 1] }, + available: true, + contentAvailable: true, + geometricError: 0.5, + }, + ], + }, + chunkTraversal: { + point: [0, 0, 0], + forward: [1, 0, 0], + fovDegrees: 90, + aspect: 1, + viewportHeight: 100, + budget: { + maxScreenSpaceError: 40, + }, + }, + }); + const snapshot = createPolyWorldChunkStreamingDebugSnapshot(selection, { + includeTraversalEntries: true, + }); + + expect(snapshot.proof.guarantees).toContain("screen-space-error-traversal"); + expect(snapshot.streaming.chunkTraversal?.screenSpaceError).toEqual({ + viewportHeight: 100, + fovDegrees: 90, + maxError: 40, + distanceFloor: 0.0001, + }); + expect(snapshot.streaming.chunkTraversal?.budget).toEqual({ + maxScreenSpaceError: 40, + }); + expect(snapshot.streaming.chunkTraversal?.refinedChunkIds.values).toEqual(["sector"]); + expect(snapshot.streaming.chunkTraversal?.renderedChunkIds.values).toEqual(["sector", "tile"]); + const sectorEntry = snapshot.streaming.chunkTraversal?.entries?.[0]; + expect(sectorEntry?.chunkId).toBe("sector"); + expect(sectorEntry?.distanceToCamera).toBe(10); + expect(sectorEntry?.screenSpaceError).toBeCloseTo(50); + }); +}); diff --git a/packages/world/src/debug/chunkSnapshot.ts b/packages/world/src/debug/chunkSnapshot.ts new file mode 100644 index 000000000..e704b278e --- /dev/null +++ b/packages/world/src/debug/chunkSnapshot.ts @@ -0,0 +1,342 @@ +import type { + PolyWorldChunkStreamingSelection, + PolyWorldChunkStreamingSourceSummary, + PolyWorldChunkTreeTraversal, + PolyWorldChunkTreeTraversalBudget, + PolyWorldChunkTreeTraversalEntry, + PolyWorldChunkTreeTraversalScreenSpaceError, + PolyWorldChunkTreeSummary, +} from "../profiles/chunk"; +import { + createPolyWorldProfileArtifactProof, + type PolyWorldProfileArtifactProof, +} from "../profiles/artifact"; +import { limitPolyWorldDebugList } from "./limits"; + +export interface PolyWorldChunkStreamingDebugSnapshotOptions { + includeSources?: boolean; + includeTraversalEntries?: boolean; + sourceLimit?: number; + traversalEntryLimit?: number; + listLimit?: number; + metadata?: Record; +} + +export interface PolyWorldChunkStreamingDebugListSummary { + values: readonly string[]; + count: number; + omitted: number; +} + +export interface PolyWorldChunkStreamingDebugSourceSummary { + sourceId: string; + currentRegionId?: string; + selectedRegionIds: PolyWorldChunkStreamingDebugListSummary; + graphRegionIds?: PolyWorldChunkStreamingDebugListSummary; + graphTargetState?: string; + targetState: string; + priority: number; + label: string; + tags?: readonly string[]; + missingRegionId?: string; + missingRegionIds?: PolyWorldChunkStreamingDebugListSummary; +} + +export interface PolyWorldChunkTreeDebugSummary { + chunkCount: number; + maxDepth: number; + rootChunkIds: PolyWorldChunkStreamingDebugListSummary; + availableChunkIds: PolyWorldChunkStreamingDebugListSummary; + contentChunkIds: PolyWorldChunkStreamingDebugListSummary; +} + +export interface PolyWorldChunkTreeTraversalDebugEntry { + chunkId: string; + regionId?: string; + parentId?: string; + depth: number; + reasons: readonly string[]; + cost: number; + priority: number; + available: boolean; + contentAvailable: boolean; + geometricError?: number; + distanceToCamera?: number; + screenSpaceError?: number; +} + +export interface PolyWorldChunkTreeTraversalDebugSummary { + currentChunkId?: string; + rootChunkIds: PolyWorldChunkStreamingDebugListSummary; + selectedChunkIds: PolyWorldChunkStreamingDebugListSummary; + refinedChunkIds: PolyWorldChunkStreamingDebugListSummary; + renderedChunkIds: PolyWorldChunkStreamingDebugListSummary; + loadedChunkIds: PolyWorldChunkStreamingDebugListSummary; + residentChunkIds: PolyWorldChunkStreamingDebugListSummary; + requestedChunkIds: PolyWorldChunkStreamingDebugListSummary; + heldChunkIds: PolyWorldChunkStreamingDebugListSummary; + unavailableChunkIds: PolyWorldChunkStreamingDebugListSummary; + viewCulledChunkIds: PolyWorldChunkStreamingDebugListSummary; + outsideRequestVolumeChunkIds: PolyWorldChunkStreamingDebugListSummary; + skippedChunkIds: PolyWorldChunkStreamingDebugListSummary; + budgetClippedChunkIds: PolyWorldChunkStreamingDebugListSummary; + selectedRegionIds: PolyWorldChunkStreamingDebugListSummary; + renderedRegionIds: PolyWorldChunkStreamingDebugListSummary; + requestedRegionIds: PolyWorldChunkStreamingDebugListSummary; + totalRenderCost: number; + totalLoadCost: number; + budget: PolyWorldChunkTreeTraversalBudget; + screenSpaceError?: PolyWorldChunkTreeTraversalScreenSpaceError; + entryCount: number; + entries?: readonly PolyWorldChunkTreeTraversalDebugEntry[]; + omittedEntries?: number; +} + +export interface PolyWorldChunkStreamingDebugSnapshot { + schemaVersion: 1; + proof: PolyWorldProfileArtifactProof; + selection: { + regionIds: PolyWorldChunkStreamingDebugListSummary; + selectionKeys: PolyWorldChunkStreamingDebugListSummary; + reasonLabels: PolyWorldChunkStreamingDebugListSummary; + }; + streaming: { + requestedRegionIds: PolyWorldChunkStreamingDebugListSummary; + loadingRegionIds: PolyWorldChunkStreamingDebugListSummary; + loadedRegionIds: PolyWorldChunkStreamingDebugListSummary; + residentRegionIds: PolyWorldChunkStreamingDebugListSummary; + activeRegionIds: PolyWorldChunkStreamingDebugListSummary; + renderedRegionIds: PolyWorldChunkStreamingDebugListSummary; + preloadedRegionIds: PolyWorldChunkStreamingDebugListSummary; + missingRegionIds: PolyWorldChunkStreamingDebugListSummary; + sourceCount: number; + sources?: readonly PolyWorldChunkStreamingDebugSourceSummary[]; + omittedSources?: number; + chunkTree?: PolyWorldChunkTreeDebugSummary; + chunkTraversal?: PolyWorldChunkTreeTraversalDebugSummary; + }; + metadata?: Record; +} + +export function createPolyWorldChunkStreamingDebugSnapshot( + selection: PolyWorldChunkStreamingSelection, + options: PolyWorldChunkStreamingDebugSnapshotOptions = {}, +): PolyWorldChunkStreamingDebugSnapshot { + const sources = options.includeSources === false + ? undefined + : limitPolyWorldDebugList(selection.streaming.sources, options.sourceLimit); + + return { + schemaVersion: 1, + proof: createPolyWorldChunkStreamingArtifactProof(selection), + selection: { + regionIds: summarizeList(selection.regionIds ?? [], options.listLimit), + selectionKeys: summarizeList(selection.selectionKeys ?? [], options.listLimit), + reasonLabels: summarizeList(selection.reasons?.map((reason) => reason.label) ?? [], options.listLimit), + }, + streaming: { + requestedRegionIds: summarizeList(selection.streaming.requestedRegionIds, options.listLimit), + loadingRegionIds: summarizeList(selection.streaming.loadingRegionIds, options.listLimit), + loadedRegionIds: summarizeList(selection.streaming.loadedRegionIds, options.listLimit), + residentRegionIds: summarizeList(selection.streaming.residentRegionIds, options.listLimit), + activeRegionIds: summarizeList(selection.streaming.activeRegionIds, options.listLimit), + renderedRegionIds: summarizeList(selection.streaming.renderedRegionIds, options.listLimit), + preloadedRegionIds: summarizeList(selection.streaming.preloadedRegionIds, options.listLimit), + missingRegionIds: summarizeList(selection.streaming.missingRegionIds, options.listLimit), + sourceCount: selection.streaming.sources.length, + sources: sources?.values.map((source) => summarizeSource(source, options.listLimit)), + omittedSources: sources?.omitted, + chunkTree: selection.streaming.chunkTree === undefined + ? undefined + : summarizeChunkTree(selection.streaming.chunkTree, options.listLimit), + chunkTraversal: selection.streaming.chunkTraversal === undefined + ? undefined + : summarizeChunkTreeTraversal(selection.streaming.chunkTraversal, options), + }, + metadata: options.metadata, + }; +} + +export function createPolyWorldChunkStreamingArtifactProof( + selection: PolyWorldChunkStreamingSelection, +): PolyWorldProfileArtifactProof { + const chunkTree = selection.streaming.chunkTree; + const traversal = selection.streaming.chunkTraversal; + return createPolyWorldProfileArtifactProof({ + profile: "chunk-traversal", + artifactKind: "chunk-working-set", + sourceKind: "authored-runtime-selection", + producedBy: traversal === undefined + ? "selectPolyWorldChunkStreaming" + : "resolvePolyWorldChunkTreeTraversal", + guarantees: [ + "streaming-state-separation", + "deterministic-selection-order", + ...(chunkTree === undefined ? [] : [ + "chunk-tree-summary", + "availability-state-reporting", + "content-availability-state-reporting", + ]), + ...(traversal === undefined ? [] : [ + "budgeted-traversal", + "working-set-state-reporting", + "budget-clipping-reasons", + ...(traversal.outsideRequestVolumeChunkIds.length === 0 ? [] : ["viewer-request-volume-filtering"]), + ...(traversal.screenSpaceError === undefined ? [] : ["screen-space-error-traversal"]), + ...(traversal.viewCulledChunkIds.length === 0 ? [] : ["view-frustum-culling"]), + ]), + ], + knownWeaknesses: [ + "not-visibility-occlusion", + "not-fetch-scheduler", + "not-cache-eviction", + "not-renderer-lod-swap", + ], + counts: { + selectedRegionCount: selection.regionIds?.length ?? 0, + requestedRegionCount: selection.streaming.requestedRegionIds.length, + loadingRegionCount: selection.streaming.loadingRegionIds.length, + loadedRegionCount: selection.streaming.loadedRegionIds.length, + residentRegionCount: selection.streaming.residentRegionIds.length, + activeRegionCount: selection.streaming.activeRegionIds.length, + renderedRegionCount: selection.streaming.renderedRegionIds.length, + preloadedRegionCount: selection.streaming.preloadedRegionIds.length, + missingRegionCount: selection.streaming.missingRegionIds.length, + sourceCount: selection.streaming.sources.length, + chunkCount: chunkTree?.chunkCount, + rootChunkCount: chunkTree?.rootChunkIds.length, + availableChunkCount: chunkTree?.availableChunkIds.length, + contentChunkCount: chunkTree?.contentChunkIds.length, + selectedChunkCount: traversal?.selectedChunkIds.length, + renderedChunkCount: traversal?.renderedChunkIds.length, + loadedChunkCount: traversal?.loadedChunkIds.length, + residentChunkCount: traversal?.residentChunkIds.length, + requestedChunkCount: traversal?.requestedChunkIds.length, + heldChunkCount: traversal?.heldChunkIds.length, + unavailableChunkCount: traversal?.unavailableChunkIds.length, + viewCulledChunkCount: traversal?.viewCulledChunkIds.length, + outsideRequestVolumeChunkCount: traversal?.outsideRequestVolumeChunkIds.length, + skippedChunkCount: traversal?.skippedChunkIds.length, + budgetClippedChunkCount: traversal?.budgetClippedChunkIds.length, + traversalEntryCount: traversal?.entries.length, + }, + coverage: { + renderedRegionCoverage: coverage(selection.streaming.renderedRegionIds.length, selection.streaming.loadedRegionIds.length), + residentRegionCoverage: coverage(selection.streaming.residentRegionIds.length, selection.streaming.loadedRegionIds.length), + availableChunkCoverage: coverage(chunkTree?.availableChunkIds.length ?? 0, chunkTree?.chunkCount ?? 0), + contentChunkCoverage: coverage(chunkTree?.contentChunkIds.length ?? 0, chunkTree?.chunkCount ?? 0), + renderedChunkCoverage: coverage(traversal?.renderedChunkIds.length ?? 0, traversal?.selectedChunkIds.length ?? 0), + loadedChunkCoverage: coverage(traversal?.loadedChunkIds.length ?? 0, traversal?.selectedChunkIds.length ?? 0), + }, + }); +} + +function summarizeChunkTree( + chunkTree: PolyWorldChunkTreeSummary, + listLimit: number | undefined, +): PolyWorldChunkTreeDebugSummary { + return { + chunkCount: chunkTree.chunkCount, + maxDepth: chunkTree.maxDepth, + rootChunkIds: summarizeList(chunkTree.rootChunkIds, listLimit), + availableChunkIds: summarizeList(chunkTree.availableChunkIds, listLimit), + contentChunkIds: summarizeList(chunkTree.contentChunkIds, listLimit), + }; +} + +function summarizeChunkTreeTraversal( + traversal: PolyWorldChunkTreeTraversal, + options: PolyWorldChunkStreamingDebugSnapshotOptions, +): PolyWorldChunkTreeTraversalDebugSummary { + const entries = options.includeTraversalEntries === true + ? limitPolyWorldDebugList(traversal.entries, options.traversalEntryLimit) + : undefined; + return { + currentChunkId: traversal.currentChunkId, + rootChunkIds: summarizeList(traversal.rootChunkIds, options.listLimit), + selectedChunkIds: summarizeList(traversal.selectedChunkIds, options.listLimit), + refinedChunkIds: summarizeList(traversal.refinedChunkIds, options.listLimit), + renderedChunkIds: summarizeList(traversal.renderedChunkIds, options.listLimit), + loadedChunkIds: summarizeList(traversal.loadedChunkIds, options.listLimit), + residentChunkIds: summarizeList(traversal.residentChunkIds, options.listLimit), + requestedChunkIds: summarizeList(traversal.requestedChunkIds, options.listLimit), + heldChunkIds: summarizeList(traversal.heldChunkIds, options.listLimit), + unavailableChunkIds: summarizeList(traversal.unavailableChunkIds, options.listLimit), + viewCulledChunkIds: summarizeList(traversal.viewCulledChunkIds, options.listLimit), + outsideRequestVolumeChunkIds: summarizeList(traversal.outsideRequestVolumeChunkIds, options.listLimit), + skippedChunkIds: summarizeList(traversal.skippedChunkIds, options.listLimit), + budgetClippedChunkIds: summarizeList(traversal.budgetClippedChunkIds, options.listLimit), + selectedRegionIds: summarizeList(traversal.selectedRegionIds, options.listLimit), + renderedRegionIds: summarizeList(traversal.renderedRegionIds, options.listLimit), + requestedRegionIds: summarizeList(traversal.requestedRegionIds, options.listLimit), + totalRenderCost: traversal.totalRenderCost, + totalLoadCost: traversal.totalLoadCost, + budget: traversal.budget, + screenSpaceError: traversal.screenSpaceError, + entryCount: traversal.entries.length, + entries: entries?.values.map(summarizeTraversalEntry), + omittedEntries: entries?.omitted, + }; +} + +function summarizeTraversalEntry( + entry: PolyWorldChunkTreeTraversalEntry, +): PolyWorldChunkTreeTraversalDebugEntry { + return { + chunkId: entry.chunkId, + regionId: entry.regionId, + parentId: entry.parentId, + depth: entry.depth, + reasons: entry.reasons, + cost: entry.cost, + priority: entry.priority, + available: entry.available, + contentAvailable: entry.contentAvailable, + geometricError: entry.geometricError, + distanceToCamera: entry.distanceToCamera, + screenSpaceError: entry.screenSpaceError, + }; +} + +export function adaptPolyWorldChunkStreamingDebugSnapshot( + snapshot: PolyWorldChunkStreamingDebugSnapshot, + adapter: (snapshot: PolyWorldChunkStreamingDebugSnapshot) => T, +): T { + return adapter(snapshot); +} + +function summarizeSource( + source: PolyWorldChunkStreamingSourceSummary, + listLimit: number | undefined, +): PolyWorldChunkStreamingDebugSourceSummary { + return { + sourceId: source.sourceId, + currentRegionId: source.currentRegionId, + selectedRegionIds: summarizeList(source.selectedRegionIds, listLimit), + graphRegionIds: source.graphRegionIds === undefined ? undefined : summarizeList(source.graphRegionIds, listLimit), + graphTargetState: source.graphTargetState, + targetState: source.targetState, + priority: source.priority, + label: source.label, + tags: source.tags, + missingRegionId: source.missingRegionId, + missingRegionIds: source.missingRegionIds === undefined ? undefined : summarizeList(source.missingRegionIds, listLimit), + }; +} + +function summarizeList( + values: readonly string[], + limit: number | undefined, +): PolyWorldChunkStreamingDebugListSummary { + const limited = limitPolyWorldDebugList(values, limit); + return { + values: limited.values, + count: values.length, + omitted: limited.omitted, + }; +} + +function coverage(count: number, total: number): number { + if (total === 0) return 0; + return count / total; +} diff --git a/packages/world/src/debug/debug.test.ts b/packages/world/src/debug/debug.test.ts new file mode 100644 index 000000000..83c41a169 --- /dev/null +++ b/packages/world/src/debug/debug.test.ts @@ -0,0 +1,292 @@ +import { describe, expect, it } from "vitest"; +import { createPolyWorldTopology, resolvePolyWorldElements } from "../topology"; +import { + compilePolyWorldBsp, + resolvePolyWorldBspPvs, + tracePolyWorldBspViewPvs, +} from "../profiles"; +import { + adaptPolyWorldBspDebugSnapshot, + adaptPolyWorldDebugSnapshot, + createPolyWorldBspDebugSnapshot, + createPolyWorldDebugSnapshot, +} from "./index"; + +describe("createPolyWorldDebugSnapshot", () => { + it("summarizes prepared-only selections without DOM or apply state", () => { + const topology = createPolyWorldTopology({ + regions: [ + { id: "room-a" }, + { id: "room-b" }, + ], + links: [ + { id: "door-a-b", fromRegionId: "room-a", toRegionId: "room-b" }, + ], + elements: [ + { id: "room-a-shell", regionIds: ["room-a"], kind: "mesh", layers: ["world"], tags: ["solid"] }, + { + id: "native-visible-faces", + selectionKeys: ["visible:room-b"], + parentId: "room-a-shell", + containerId: "room-a-shell", + kind: "mesh", + layers: ["world"], + }, + { id: "source-note", sourceIds: ["source:room-b"], kind: "metadata", layers: ["debug"], tags: ["source"] }, + ], + }); + const selection = { + regionIds: ["room-a"], + linkIds: ["door-a-b"], + selectionKeys: ["visible:room-b", "missing:key"], + sourceIds: ["source:room-b"], + reasons: [ + { + label: "prepared-room", + kind: "prepared", + regionIds: ["room-a"], + }, + { + label: "native-visible-room", + kind: "source-visible", + selectionKeys: ["visible:room-b"], + }, + ], + }; + const resolution = resolvePolyWorldElements(topology, selection); + const snapshot = createPolyWorldDebugSnapshot(topology, selection, { + preparedOnly: true, + resolution, + metadata: { app: "fixture" }, + }); + + expect(snapshot.preparedOnly).toBe(true); + expect(snapshot.topology).toEqual({ + regionCount: 2, + linkCount: 1, + elementCount: 3, + }); + expect(snapshot.selection.reasonLabels).toEqual(["prepared-room", "native-visible-room"]); + expect(snapshot.selection.counts.reasonLabels).toBe(2); + expect(snapshot.selection.omitted).toEqual({ + regionIds: 0, + linkIds: 0, + selectionKeys: 0, + elementIds: 0, + sourceIds: 0, + aliases: 0, + reasonLabels: 0, + }); + expect(snapshot.elements).toEqual({ + elementIds: ["room-a-shell", "native-visible-faces", "source-note"], + count: 3, + omittedElementIds: 0, + byKind: { mesh: 2, metadata: 1 }, + byLayer: { debug: 1, world: 2 }, + byTag: { solid: 1, source: 1 }, + relations: { + parentElementIds: ["room-a-shell"], + containerElementIds: ["room-a-shell"], + parentCount: 1, + containerCount: 1, + omittedParentElementIds: 0, + omittedContainerElementIds: 0, + }, + }); + expect(snapshot.unresolved.selectionKeys).toEqual(["missing:key"]); + expect(snapshot.metadata).toEqual({ app: "fixture" }); + }); + + it("lets apps adapt debug snapshots without adding compatibility fields to generic types", () => { + const topology = createPolyWorldTopology({ + regions: [{ id: "zone-a" }], + elements: [{ id: "zone-a-shell", regionIds: ["zone-a"], sourceIds: ["src:a"] }], + }); + const snapshot = createPolyWorldDebugSnapshot(topology, { regionIds: ["zone-a"] }); + + expect( + adaptPolyWorldDebugSnapshot(snapshot, (value) => ({ + selectedZone: value.selection.regionIds[0], + sourceBacked: value.elements.elementIds.length > 0, + parityClaim: false, + })), + ).toEqual({ + selectedZone: "zone-a", + sourceBacked: true, + parityClaim: false, + }); + }); + + it("can cap debug lists while preserving full counts", () => { + const topology = createPolyWorldTopology({ + regions: [{ id: "zone-a" }, { id: "zone-b" }], + elements: [ + { id: "zone-a-shell", regionIds: ["zone-a"] }, + { id: "zone-b-shell", regionIds: ["zone-b"] }, + ], + }); + const snapshot = createPolyWorldDebugSnapshot( + topology, + { + regionIds: ["zone-a", "zone-b"], + reasons: [{ label: "current" }, { label: "linked" }], + }, + { listLimit: 1 }, + ); + + expect(snapshot.selection.regionIds).toEqual(["zone-a"]); + expect(snapshot.selection.counts.regions).toBe(2); + expect(snapshot.selection.omitted.regionIds).toBe(1); + expect(snapshot.selection.reasonLabels).toEqual(["current"]); + expect(snapshot.selection.omitted.reasonLabels).toBe(1); + expect(snapshot.elements.elementIds).toEqual(["zone-a-shell"]); + expect(snapshot.elements.count).toBe(2); + expect(snapshot.elements.omittedElementIds).toBe(1); + }); +}); + +describe("createPolyWorldBspDebugSnapshot", () => { + it("summarizes BSP tree shape, PVS density, current visibility, and trace statuses", () => { + const tree = compilePolyWorldBsp({ + regions: [ + { id: "left", bounds: { min: [-8, -2, 0], max: [-4, 2, 2] } }, + { id: "middle", bounds: { min: [-4, -2, 0], max: [4, 2, 2] } }, + { id: "right", bounds: { min: [4, -2, 0], max: [8, 2, 2] } }, + ], + portals: [ + { + id: "left-middle", + fromRegionId: "left", + toRegionId: "middle", + linkId: "left-middle", + bounds: { min: [-4, -1, 0], max: [-4, 1, 2] }, + }, + { + id: "middle-right", + fromRegionId: "middle", + toRegionId: "right", + linkId: "middle-right", + bounds: { min: [4, -1, 0], max: [4, 1, 2] }, + }, + ], + pvs: { projection: "xy" }, + }); + const broadPvs = resolvePolyWorldBspPvs(tree, "middle", { projection: "xy" }); + const trace = tracePolyWorldBspViewPvs(tree, { + leafId: "middle", + point: [0, 0, 1], + forward: [-1, 0, 0], + fovDegrees: 90, + projection: "xy", + }); + const snapshot = createPolyWorldBspDebugSnapshot(tree, { + leafId: "middle", + broadPvs, + trace, + includeTraceEntries: true, + listLimit: 1, + entryLimit: 1, + metadata: { view: "west" }, + }); + + expect(snapshot.tree).toMatchObject({ + leafCount: 3, + portalCount: 2, + compiler: "bounds-bsp", + }); + expect(snapshot.tree.nodeCount).toBeGreaterThan(0); + expect(snapshot.proof).toMatchObject({ + profile: "bsp-pvs", + compiler: { + id: "bounds-bsp", + compiled: true, + }, + tree: { + leafCount: 3, + portalCount: 2, + rootLeafRefCount: 3, + uniqueRootLeafRefCount: 3, + referencesEveryLeafOnce: true, + }, + leaves: { + bakedPvsCount: 3, + bakedPvsCoverage: 1, + renderableCount: 3, + }, + pvs: { + method: "portal-clipped-baked", + source: "polycss-world", + completeness: "complete", + indexed: true, + indexLeafCount: 3, + indexPortalCount: 2, + indexLeafCoverage: 1, + indexPortalCoverage: 1, + bakedLeafCount: 3, + bakedLeafCoverage: 1, + complete: true, + }, + evidence: { + validatedBy: "createPolyWorldBspTree", + }, + }); + expect(snapshot.proof.evidence.guarantees).toContain("validated-pvs-metadata"); + expect(snapshot.proof.artifact.guarantees).toContain("portal-clipped-baked-pvs"); + expect(snapshot.proof.artifact.guarantees).not.toContain("baked-pvs-bitsets"); + expect(snapshot.artifact).toMatchObject({ + profile: "bsp-pvs", + artifactKind: "compiled-bsp-pvs", + sourceKind: "compiled", + producedBy: "bounds-bsp", + counts: { + leafCount: 3, + portalCount: 2, + bakedPvsCount: 3, + indexLeafCount: 3, + indexPortalCount: 2, + }, + coverage: { + bakedPvsCoverage: 1, + indexLeafCoverage: 1, + indexPortalCoverage: 1, + }, + }); + expect(snapshot.artifact.guarantees).toContain("pvs-metadata-decode-audit"); + expect(snapshot.artifact.knownWeaknesses).toContain("not-full-qbsp-vis-parity"); + expect(snapshot.proof.artifact).toEqual(snapshot.artifact); + expect(snapshot.leaves.bakedPvsCount).toBe(3); + expect(snapshot.leaves.pvsDensity).toBeGreaterThan(0); + expect(snapshot.current.leafId).toBe("middle"); + expect(snapshot.current.broadPvs?.leafIds).toEqual({ + values: ["left"], + count: 3, + omitted: 2, + }); + expect(snapshot.current.viewPvs?.leafIds).toEqual({ + values: ["left"], + count: 2, + omitted: 1, + }); + expect(snapshot.current.viewPvs?.broadPhaseLeafIds.count).toBe(3); + expect(snapshot.trace?.statusCounts).toEqual({ + visible: 1, + clipped: 1, + }); + expect(snapshot.trace?.entries).toEqual([ + { + portalId: "left-middle", + fromLeafId: "middle", + toLeafId: "left", + depth: 0, + status: "visible", + inputVertexCount: 4, + clippedVertexCount: 4, + clipPlaneCount: 9, + linkId: "left-middle", + }, + ]); + expect(snapshot.trace?.omittedEntries).toBe(1); + expect(snapshot.metadata).toEqual({ view: "west" }); + expect(adaptPolyWorldBspDebugSnapshot(snapshot, (value) => value.current.viewPvs?.portalIds.count)).toBe(1); + }); +}); diff --git a/packages/world/src/debug/domApplySnapshot.test.ts b/packages/world/src/debug/domApplySnapshot.test.ts new file mode 100644 index 000000000..be0ba62da --- /dev/null +++ b/packages/world/src/debug/domApplySnapshot.test.ts @@ -0,0 +1,228 @@ +import { describe, expect, it } from "vitest"; +import { applyPolyWorldDomPlan, createPolyWorldDomRegistry } from "../dom"; +import { + adaptPolyWorldDomApplyDebugSnapshot, + createPolyWorldDomApplyDebugSnapshot, +} from "./index"; +import type { PolyWorldDomElementLike, PolyWorldDomParentLike } from "../dom"; + +class FakeElement implements PolyWorldDomElementLike { + parentNode: FakeParent | null = null; + + constructor(readonly id: string) {} + + remove(): void { + this.parentNode?.removeChild(this); + } +} + +class FakeParent implements PolyWorldDomParentLike { + readonly children: FakeElement[] = []; + + insertBefore(element: FakeElement, before: FakeElement | null): void { + this.removeChild(element); + const index = before === null ? -1 : this.children.indexOf(before); + if (index === -1) this.children.push(element); + else this.children.splice(index, 0, element); + element.parentNode = this; + } + + removeChild(element: FakeElement): void { + const index = this.children.indexOf(element); + if (index !== -1) this.children.splice(index, 1); + if (element.parentNode === this) element.parentNode = null; + } +} + +describe("createPolyWorldDomApplyDebugSnapshot", () => { + it("summarizes apply status counts and app-owned debug adapters without DOM reads", () => { + const parent = new FakeParent(); + const group45 = new FakeElement("group-45"); + parent.insertBefore(group45, null); + const registry = createPolyWorldDomRegistry([ + { elementId: "group-14", element: new FakeElement("group-14"), parent, nextElementId: "group-45" }, + { elementId: "group-45", element: group45, parent, mounted: true }, + { elementId: "blocked", element: new FakeElement("blocked") }, + { elementId: "guarded", element: new FakeElement("guarded"), parent }, + ]); + const result = applyPolyWorldDomPlan(registry, [ + planEntry("render:group-14", "group-14", "show"), + planEntry("render:group-45", "group-45", "retain"), + planEntry("render:missing", "missing", "show"), + planEntry("render:blocked", "blocked", "show"), + { + ...planEntry("render:guarded", "guarded", "show"), + guards: [{ id: "ready", ok: false, message: "Resource is not ready." }], + }, + planEntry("preload:group-45", "group-45", "preload"), + ]); + const snapshot = createPolyWorldDomApplyDebugSnapshot(result, { + metadata: { view: "product" }, + }); + + expect(snapshot.apply.counts).toEqual({ + added: 1, + hidden: 0, + removed: 0, + retained: 1, + noop: 0, + missing: 1, + blocked: 2, + unsupported: 1, + changed: 1, + mounted: 2, + }); + expect(snapshot.plan.actionCounts).toEqual({ + show: 4, + hide: 0, + retain: 1, + preload: 1, + noop: 0, + }); + expect(snapshot.apply.missingElementIds).toEqual(["missing"]); + expect(snapshot.apply.plannedElementIds).toEqual(["blocked", "group-14", "group-45", "guarded", "missing"]); + expect(snapshot.apply.addedElementIds).toEqual(["group-14"]); + expect(snapshot.apply.retainedElementIds).toEqual(["group-45"]); + expect(snapshot.apply.changedElementIds).toEqual(["group-14"]); + expect(snapshot.apply.blockedElementIds).toEqual(["blocked", "guarded"]); + expect(snapshot.apply.mountBlockedElementIds).toEqual(["blocked"]); + expect(snapshot.apply.guardFailureElementIds).toEqual(["guarded"]); + expect(snapshot.apply.dependencyFailureElementIds).toEqual([]); + expect(snapshot.apply.unsupportedElementIds).toEqual(["group-45"]); + expect(snapshot.apply.mountedElementIds).toEqual(["group-14", "group-45"]); + expect(snapshot.entries?.map((entry) => [entry.elementId, entry.status])).toEqual([ + ["group-14", "added"], + ["group-45", "retained"], + ["missing", "missing"], + ["blocked", "blocked"], + ["guarded", "blocked"], + ["group-45", "unsupported"], + ]); + expect(snapshot.entries?.map((entry) => [entry.elementId, entry.phase, entry.targetState])).toEqual([ + ["group-14", "render", { visible: true, rendered: true }], + ["group-45", "render", { visible: true, rendered: true }], + ["missing", "render", { visible: true, rendered: true }], + ["blocked", "render", { visible: true, rendered: true }], + ["guarded", "render", { visible: true, rendered: true }], + ["group-45", "preload", { preloaded: true }], + ]); + expect(snapshot.entries?.find((entry) => entry.elementId === "guarded")?.failedGuards).toEqual([ + { id: "ready", ok: false, message: "Resource is not ready." }, + ]); + expect(snapshot.metadata).toEqual({ view: "product" }); + + expect( + adaptPolyWorldDomApplyDebugSnapshot(snapshot, (value) => ({ + visibleGroups: value.apply.mountedElementIds, + mountedLeafCount: value.apply.counts.mounted, + hiddenLeafCount: value.apply.counts.removed + value.apply.counts.missing, + blockedLeafCount: value.apply.blockedElementIds.length, + mountBlockedLeafCount: value.apply.mountBlockedElementIds.length, + guardBlockedCount: value.apply.guardFailureElementIds.length, + })), + ).toEqual({ + visibleGroups: ["group-14", "group-45"], + mountedLeafCount: 2, + hiddenLeafCount: 1, + blockedLeafCount: 2, + mountBlockedLeafCount: 1, + guardBlockedCount: 1, + }); + }); + + it("can omit per-entry detail for compact render-visibility debug", () => { + const parent = new FakeParent(); + const track = new FakeElement("track-1"); + parent.insertBefore(track, null); + const registry = createPolyWorldDomRegistry([ + { elementId: "track-1", element: track, parent, mounted: true, sourceIds: ["track:1"] }, + ]); + const result = applyPolyWorldDomPlan(registry, [ + planEntry("track:track-1", "track-1", "retain"), + ]); + const snapshot = createPolyWorldDomApplyDebugSnapshot(result, { + includeEntries: false, + metadata: { policy: "source-track-window" }, + }); + + expect(snapshot.entries).toBeUndefined(); + expect(snapshot.apply.counts.retained).toBe(1); + expect(snapshot.metadata).toEqual({ policy: "source-track-window" }); + }); + + it("can cap entries and element id lists while preserving counts", () => { + const parent = new FakeParent(); + const leafA = new FakeElement("leaf-a"); + const leafB = new FakeElement("leaf-b"); + const leafC = new FakeElement("leaf-c"); + parent.insertBefore(leafA, null); + parent.insertBefore(leafB, null); + parent.insertBefore(leafC, null); + const registry = createPolyWorldDomRegistry([ + { elementId: "leaf-a", element: leafA, parent, mounted: true }, + { elementId: "leaf-b", element: leafB, parent, mounted: true }, + { elementId: "leaf-c", element: leafC, parent, mounted: true }, + ]); + const result = applyPolyWorldDomPlan( + registry, + [ + planEntry("render:leaf-b", "leaf-b", "hide"), + planEntry("render:leaf-c", "leaf-c", "hide"), + planEntry("render:leaf-a", "leaf-a", "retain"), + planEntry("render:missing", "missing", "show"), + ], + { hideMode: "hidden" }, + ); + const snapshot = createPolyWorldDomApplyDebugSnapshot(result, { + entryLimit: 2, + listLimit: 1, + }); + + expect(snapshot.plan.entryCount).toBe(4); + expect(snapshot.plan.includedEntryCount).toBe(2); + expect(snapshot.plan.omittedEntryCount).toBe(2); + expect(snapshot.entries?.map((entry) => entry.elementId)).toEqual(["leaf-b", "leaf-c"]); + expect(snapshot.apply.counts.hidden).toBe(2); + expect(snapshot.apply.plannedElementIds).toEqual(["leaf-a"]); + expect(snapshot.apply.hiddenAppliedElementIds).toEqual(["leaf-b"]); + expect(snapshot.apply.retainedElementIds).toEqual(["leaf-a"]); + expect(snapshot.apply.changedElementIds).toEqual(["leaf-b"]); + expect(snapshot.apply.mountedElementIds).toEqual(["leaf-a"]); + expect(snapshot.apply.hiddenElementIds).toEqual(["leaf-b"]); + expect(snapshot.apply.omitted.mountedElementIds).toBe(2); + expect(snapshot.apply.omitted.hiddenElementIds).toBe(1); + expect(snapshot.apply.omitted.plannedElementIds).toBe(3); + expect(snapshot.apply.omitted.hiddenAppliedElementIds).toBe(1); + expect(snapshot.apply.omitted.changedElementIds).toBe(1); + expect(snapshot.apply.missingElementIds).toEqual(["missing"]); + expect(snapshot.apply.mountBlockedElementIds).toEqual([]); + }); +}); + +function planEntry(key: string, elementId: string, action: "show" | "hide" | "retain" | "preload" | "noop") { + const targetState = action === "show" || action === "retain" + ? { visible: true, rendered: true } + : action === "hide" + ? { visible: false, rendered: false } + : action === "preload" + ? { preloaded: true } + : {}; + const phase = action === "show" || action === "retain" + ? "render" + : action === "hide" + ? "cleanup" + : action === "preload" + ? "preload" + : undefined; + return { + key, + policyId: "render", + layer: "render", + elementId, + action, + reason: action === "hide" ? "removed" : "added", + ...(phase === undefined ? {} : { phase }), + targetState, + reasonLabels: [], + } as const; +} diff --git a/packages/world/src/debug/domApplySnapshot.ts b/packages/world/src/debug/domApplySnapshot.ts new file mode 100644 index 000000000..8d1405b89 --- /dev/null +++ b/packages/world/src/debug/domApplySnapshot.ts @@ -0,0 +1,193 @@ +import type { + PolyWorldDomApplyCounts, + PolyWorldDomApplyEntry, + PolyWorldDomApplyResult, + PolyWorldDomElementLike, +} from "../dom"; +import type { + PolyWorldPlanActionCounts, + PolyWorldPlanCheckResult, + PolyWorldPlanPhase, + PolyWorldPlanTargetState, +} from "../planner"; +import { limitPolyWorldDebugList } from "./limits"; + +export interface PolyWorldDomApplyDebugSnapshotOptions { + includeEntries?: boolean; + entryLimit?: number; + listLimit?: number; + metadata?: Record; +} + +export interface PolyWorldDomApplyDebugEntry { + key: string; + layer: string; + action: string; + status: string; + elementId?: string; + policyId?: string; + phase?: PolyWorldPlanPhase; + targetState: PolyWorldPlanTargetState; + guards: readonly PolyWorldPlanCheckResult[]; + dependencies: readonly PolyWorldPlanCheckResult[]; + failedGuards: readonly PolyWorldPlanCheckResult[]; + failedDependencies: readonly PolyWorldPlanCheckResult[]; + mounted: boolean; + changed: boolean; + reasonLabels: readonly string[]; + message?: string; +} + +export interface PolyWorldDomApplyDebugSnapshot { + schemaVersion: 1; + plan: { + previousSignature?: string; + nextSignature?: string; + changed?: boolean; + entryCount: number; + includedEntryCount: number; + omittedEntryCount: number; + actionCounts: PolyWorldPlanActionCounts; + }; + apply: { + counts: PolyWorldDomApplyCounts; + plannedElementIds: readonly string[]; + addedElementIds: readonly string[]; + hiddenAppliedElementIds: readonly string[]; + removedElementIds: readonly string[]; + retainedElementIds: readonly string[]; + noopElementIds: readonly string[]; + changedElementIds: readonly string[]; + missingElementIds: readonly string[]; + blockedElementIds: readonly string[]; + mountBlockedElementIds: readonly string[]; + guardFailureElementIds: readonly string[]; + dependencyFailureElementIds: readonly string[]; + unsupportedElementIds: readonly string[]; + mountedElementIds: readonly string[]; + hiddenElementIds: readonly string[]; + omitted: { + plannedElementIds: number; + addedElementIds: number; + hiddenAppliedElementIds: number; + removedElementIds: number; + retainedElementIds: number; + noopElementIds: number; + changedElementIds: number; + missingElementIds: number; + blockedElementIds: number; + mountBlockedElementIds: number; + guardFailureElementIds: number; + dependencyFailureElementIds: number; + unsupportedElementIds: number; + mountedElementIds: number; + hiddenElementIds: number; + }; + }; + entries?: readonly PolyWorldDomApplyDebugEntry[]; + metadata?: Record; +} + +export function createPolyWorldDomApplyDebugSnapshot< + TElement extends PolyWorldDomElementLike = PolyWorldDomElementLike, +>( + result: PolyWorldDomApplyResult, + options: PolyWorldDomApplyDebugSnapshotOptions = {}, +): PolyWorldDomApplyDebugSnapshot { + const limitedEntries = options.includeEntries === false + ? undefined + : limitPolyWorldDebugList(result.entries.map(summarizeEntry), options.entryLimit); + const plannedElementIds = limitPolyWorldDebugList(result.plannedElementIds, options.listLimit); + const addedElementIds = limitPolyWorldDebugList(result.addedElementIds, options.listLimit); + const hiddenAppliedElementIds = limitPolyWorldDebugList(result.hiddenAppliedElementIds, options.listLimit); + const removedElementIds = limitPolyWorldDebugList(result.removedElementIds, options.listLimit); + const retainedElementIds = limitPolyWorldDebugList(result.retainedElementIds, options.listLimit); + const noopElementIds = limitPolyWorldDebugList(result.noopElementIds, options.listLimit); + const changedElementIds = limitPolyWorldDebugList(result.changedElementIds, options.listLimit); + const missingElementIds = limitPolyWorldDebugList(result.missingElementIds, options.listLimit); + const blockedElementIds = limitPolyWorldDebugList(result.blockedElementIds, options.listLimit); + const mountBlockedElementIds = limitPolyWorldDebugList(result.mountBlockedElementIds, options.listLimit); + const guardFailureElementIds = limitPolyWorldDebugList(result.guardFailureElementIds, options.listLimit); + const dependencyFailureElementIds = limitPolyWorldDebugList(result.dependencyFailureElementIds, options.listLimit); + const unsupportedElementIds = limitPolyWorldDebugList(result.unsupportedElementIds, options.listLimit); + const mountedElementIds = limitPolyWorldDebugList(result.mountedElementIds, options.listLimit); + const hiddenElementIds = limitPolyWorldDebugList(result.hiddenElementIds, options.listLimit); + + return { + schemaVersion: 1, + plan: { + previousSignature: result.previousSignature, + nextSignature: result.nextSignature, + changed: result.planChanged, + entryCount: result.entries.length, + includedEntryCount: limitedEntries?.values.length ?? 0, + omittedEntryCount: limitedEntries?.omitted ?? result.entries.length, + actionCounts: result.actionCounts, + }, + apply: { + counts: result.counts, + plannedElementIds: plannedElementIds.values, + addedElementIds: addedElementIds.values, + hiddenAppliedElementIds: hiddenAppliedElementIds.values, + removedElementIds: removedElementIds.values, + retainedElementIds: retainedElementIds.values, + noopElementIds: noopElementIds.values, + changedElementIds: changedElementIds.values, + missingElementIds: missingElementIds.values, + blockedElementIds: blockedElementIds.values, + mountBlockedElementIds: mountBlockedElementIds.values, + guardFailureElementIds: guardFailureElementIds.values, + dependencyFailureElementIds: dependencyFailureElementIds.values, + unsupportedElementIds: unsupportedElementIds.values, + mountedElementIds: mountedElementIds.values, + hiddenElementIds: hiddenElementIds.values, + omitted: { + plannedElementIds: plannedElementIds.omitted, + addedElementIds: addedElementIds.omitted, + hiddenAppliedElementIds: hiddenAppliedElementIds.omitted, + removedElementIds: removedElementIds.omitted, + retainedElementIds: retainedElementIds.omitted, + noopElementIds: noopElementIds.omitted, + changedElementIds: changedElementIds.omitted, + missingElementIds: missingElementIds.omitted, + blockedElementIds: blockedElementIds.omitted, + mountBlockedElementIds: mountBlockedElementIds.omitted, + guardFailureElementIds: guardFailureElementIds.omitted, + dependencyFailureElementIds: dependencyFailureElementIds.omitted, + unsupportedElementIds: unsupportedElementIds.omitted, + mountedElementIds: mountedElementIds.omitted, + hiddenElementIds: hiddenElementIds.omitted, + }, + }, + entries: limitedEntries?.values, + metadata: options.metadata, + }; +} + +export function adaptPolyWorldDomApplyDebugSnapshot( + snapshot: PolyWorldDomApplyDebugSnapshot, + adapter: (snapshot: PolyWorldDomApplyDebugSnapshot) => T, +): T { + return adapter(snapshot); +} + +function summarizeEntry(entry: PolyWorldDomApplyEntry): PolyWorldDomApplyDebugEntry { + return { + key: entry.key, + layer: entry.layer, + action: entry.action, + status: entry.status, + elementId: entry.elementId, + policyId: entry.policyId, + phase: entry.planEntry.phase, + targetState: entry.planEntry.targetState ?? {}, + guards: entry.guards, + dependencies: entry.dependencies, + failedGuards: entry.failedGuards, + failedDependencies: entry.failedDependencies, + mounted: entry.mounted, + changed: entry.changed, + reasonLabels: entry.reasonLabels, + message: entry.message, + }; +} diff --git a/packages/world/src/debug/index.ts b/packages/world/src/debug/index.ts new file mode 100644 index 000000000..6ab1ab094 --- /dev/null +++ b/packages/world/src/debug/index.ts @@ -0,0 +1,74 @@ +export { + adaptPolyWorldDebugSnapshot, + createPolyWorldDebugSnapshot, +} from "./snapshot"; +export { + adaptPolyWorldBspDebugSnapshot, + createPolyWorldBspDebugSnapshot, +} from "./bspSnapshot"; +export { + adaptPolyWorldPlanDebugSnapshot, + createPolyWorldPlanDebugSnapshot, +} from "./planSnapshot"; +export { + adaptPolyWorldDomApplyDebugSnapshot, + createPolyWorldDomApplyDebugSnapshot, +} from "./domApplySnapshot"; +export { + adaptPolyWorldChunkStreamingDebugSnapshot, + createPolyWorldChunkStreamingDebugSnapshot, +} from "./chunkSnapshot"; +export { + adaptPolyWorldPortalDebugSnapshot, + createPolyWorldPortalDebugSnapshot, +} from "./portalSnapshot"; +export { + adaptPolyWorldPortalFlowDebugSnapshot, + createPolyWorldPortalFlowDebugSnapshot, +} from "./portalFlowSnapshot"; +export type { + PolyWorldDebugSnapshot, + PolyWorldDebugSnapshotOptions, +} from "./snapshot"; +export type { + PolyWorldBspDebugListSummary, + PolyWorldBspDebugResolvedPvsSummary, + PolyWorldBspDebugSnapshot, + PolyWorldBspDebugSnapshotOptions, + PolyWorldBspDebugTraceEntry, + PolyWorldBspDebugViewPvsSummary, +} from "./bspSnapshot"; +export type { + PolyWorldPlanDebugAppliedComparison, + PolyWorldPlanDebugIdDiff, + PolyWorldPlanDebugResourceLoadSetSummary, + PolyWorldPlanDebugResourceReadinessSummary, + PolyWorldPlanDebugSelectionSummary, + PolyWorldPlanDebugSnapshot, + PolyWorldPlanDebugSnapshotOptions, + PolyWorldPlanDebugStateSummary, +} from "./planSnapshot"; +export type { + PolyWorldDomApplyDebugEntry, + PolyWorldDomApplyDebugSnapshot, + PolyWorldDomApplyDebugSnapshotOptions, +} from "./domApplySnapshot"; +export type { + PolyWorldChunkStreamingDebugListSummary, + PolyWorldChunkStreamingDebugSnapshot, + PolyWorldChunkStreamingDebugSnapshotOptions, + PolyWorldChunkStreamingDebugSourceSummary, + PolyWorldChunkTreeTraversalDebugEntry, + PolyWorldChunkTreeTraversalDebugSummary, +} from "./chunkSnapshot"; +export type { + PolyWorldPortalDebugListSummary, + PolyWorldPortalDebugSnapshot, + PolyWorldPortalDebugSnapshotOptions, +} from "./portalSnapshot"; +export type { + PolyWorldPortalFlowDebugListSummary, + PolyWorldPortalFlowDebugSnapshot, + PolyWorldPortalFlowDebugSnapshotOptions, + PolyWorldPortalFlowDebugTraceEntry, +} from "./portalFlowSnapshot"; diff --git a/packages/world/src/debug/limits.ts b/packages/world/src/debug/limits.ts new file mode 100644 index 000000000..2bc4ab648 --- /dev/null +++ b/packages/world/src/debug/limits.ts @@ -0,0 +1,31 @@ +export interface PolyWorldDebugListLimitOptions { + listLimit?: number; +} + +export interface PolyWorldDebugEntryLimitOptions { + entryLimit?: number; +} + +export interface PolyWorldDebugLimitedList { + values: readonly T[]; + omitted: number; +} + +export function limitPolyWorldDebugList( + values: readonly T[], + limit: number | undefined, +): PolyWorldDebugLimitedList { + const normalizedLimit = normalizeLimit(limit); + if (normalizedLimit === undefined) return { values, omitted: 0 }; + + return { + values: values.slice(0, normalizedLimit), + omitted: Math.max(0, values.length - normalizedLimit), + }; +} + +function normalizeLimit(limit: number | undefined): number | undefined { + if (limit === undefined) return undefined; + if (!Number.isFinite(limit)) return undefined; + return Math.max(0, Math.floor(limit)); +} diff --git a/packages/world/src/debug/planSnapshot.test.ts b/packages/world/src/debug/planSnapshot.test.ts new file mode 100644 index 000000000..9f3e3192b --- /dev/null +++ b/packages/world/src/debug/planSnapshot.test.ts @@ -0,0 +1,292 @@ +import { describe, expect, it } from "vitest"; +import { planPolyWorldLayers, summarizePolyWorldResourceReadiness } from "../planner"; +import { createPolyWorldState, diffPolyWorldState } from "../state"; +import { createPolyWorldTopology } from "../topology"; +import { + adaptPolyWorldPlanDebugSnapshot, + createPolyWorldPlanDebugSnapshot, +} from "./index"; + +function topologyFixture() { + return createPolyWorldTopology({ + regions: [ + { id: "group-45" }, + { id: "group-14" }, + { id: "group-15" }, + ], + links: [ + { + id: "portal-45-14", + fromRegionId: "group-45", + toRegionId: "group-14", + selectionKeys: ["portal:45:14"], + }, + ], + elements: [ + { id: "shell-45", regionIds: ["group-45"], kind: "mesh", layers: ["render"], tags: ["solid"] }, + { + id: "shell-14", + regionIds: ["group-14"], + kind: "mesh", + layers: ["render"], + tags: ["solid"], + resourceIds: ["mesh:shell-14", "texture:shell-14"], + }, + { + id: "shell-15", + regionIds: ["group-15"], + kind: "mesh", + layers: ["render"], + tags: ["solid"], + resourceIds: ["mesh:shell-15"], + }, + { + id: "portal-marker", + selectionKeys: ["portal:45:14"], + kind: "marker", + layers: ["debug"], + tags: ["portal"], + }, + ], + }); +} + +describe("createPolyWorldPlanDebugSnapshot", () => { + it("summarizes selected, resolved, planned, and applied state without DOM reads", () => { + const topology = topologyFixture(); + const previous = createPolyWorldState(topology, { + id: "previous", + selection: { regionIds: ["group-45"], reasons: [{ label: "current-group" }] }, + }); + const next = createPolyWorldState(topology, { + id: "next", + selection: { + regionIds: ["group-45", "group-14", "group-15"], + linkIds: ["portal-45-14"], + selectionKeys: ["portal:45:14", "missing:key"], + reasons: [{ label: "visible-through-portal" }], + }, + }); + const applied = createPolyWorldState(topology, { + id: "applied", + selection: { + regionIds: ["group-45", "group-14"], + linkIds: ["portal-45-14"], + selectionKeys: ["portal:45:14"], + }, + }); + const diff = diffPolyWorldState(previous, next); + const plan = planPolyWorldLayers(topology, diff, [ + { + id: "render", + layer: "render", + elementLayers: ["render"], + tags: ["solid"], + guards: ({ elementId }) => [{ id: "ready", ok: elementId !== "shell-15" }], + }, + { id: "debug", layer: "debug", tags: ["portal"], actions: { retained: "noop" } }, + ]); + const snapshot = createPolyWorldPlanDebugSnapshot(diff, plan, { + planningSelection: { + regionIds: ["group-45", "group-14", "group-15"], + linkIds: ["portal-45-14"], + selectionKeys: ["portal:45:14", "missing:key"], + reasons: [{ label: "visibility-source" }], + }, + appliedState: applied, + metadata: { view: "product" }, + }); + + expect(snapshot.changed).toBe(true); + expect(snapshot.planningSelection?.regionIds).toEqual(["group-45", "group-14", "group-15"]); + expect(snapshot.planningSelection?.linkIds).toEqual(["portal-45-14"]); + expect(snapshot.planningSelection?.selectionKeys).toEqual(["portal:45:14", "missing:key"]); + expect(snapshot.planningSelection?.reasonLabels).toEqual(["visibility-source"]); + expect(snapshot.previous.counts.resolvedElements).toBe(1); + expect(snapshot.next.selectedRegionIds).toEqual(["group-14", "group-15", "group-45"]); + expect(snapshot.next.unresolved.selectionKeys).toEqual(["missing:key"]); + expect(snapshot.diff.resolvedElements.counts).toEqual({ + added: 3, + removed: 0, + retained: 1, + }); + expect(snapshot.plan.actionCounts).toEqual({ + show: 3, + hide: 0, + retain: 1, + preload: 0, + noop: 0, + }); + expect(snapshot.plan.blockedEntryCount).toBe(1); + expect(snapshot.plan.guardFailureEntryCount).toBe(1); + expect(snapshot.plan.dependencyFailureEntryCount).toBe(0); + expect(snapshot.plan.entries?.map((entry) => [entry.elementId, entry.action, entry.reason])).toEqual([ + ["shell-14", "show", "added"], + ["shell-15", "show", "added"], + ["shell-45", "retain", "retained"], + ["portal-marker", "show", "added"], + ]); + expect(snapshot.applied?.matchesNext).toBe(false); + expect(snapshot.applied?.missingElementIds).toEqual(["shell-15"]); + expect(snapshot.applied?.extraElementIds).toEqual([]); + expect(snapshot.metadata).toEqual({ view: "product" }); + }); + + it("lets apps expose culling-shaped debug without adding game-specific exported fields", () => { + const topology = topologyFixture(); + const previous = createPolyWorldState(topology, { + selection: { regionIds: ["group-45"] }, + }); + const next = createPolyWorldState(topology, { + selection: { + regionIds: ["group-45", "group-14"], + linkIds: ["portal-45-14"], + selectionKeys: ["portal:45:14"], + }, + }); + const diff = diffPolyWorldState(previous, next); + const plan = planPolyWorldLayers(topology, diff, [ + { id: "render", layer: "render", elementLayers: ["render"] }, + { id: "debug", layer: "debug", tags: ["portal"] }, + ]); + const snapshot = createPolyWorldPlanDebugSnapshot(diff, plan, { + appliedState: previous, + includeEntries: false, + }); + + expect( + adaptPolyWorldPlanDebugSnapshot(snapshot, (value) => ({ + visibleGroups: value.next.selectedRegionIds, + selectedPortalKeys: value.next.selectedSelectionKeys, + mountedElementCount: value.applied?.state.counts.resolvedElements ?? 0, + hiddenElementCount: value.applied?.missingElementIds.length ?? 0, + parityClaim: false, + })), + ).toEqual({ + visibleGroups: ["group-14", "group-45"], + selectedPortalKeys: ["portal:45:14"], + mountedElementCount: 1, + hiddenElementCount: 2, + parityClaim: false, + }); + expect(snapshot.plan.entries).toBeUndefined(); + }); + + it("can cap plan entries and id lists while preserving full counts", () => { + const topology = topologyFixture(); + const previous = createPolyWorldState(topology, { + selection: { regionIds: ["group-45"] }, + }); + const next = createPolyWorldState(topology, { + selection: { + regionIds: ["group-45", "group-14", "group-15"], + linkIds: ["portal-45-14"], + selectionKeys: ["portal:45:14"], + reasons: [{ label: "current" }, { label: "linked" }], + }, + }); + const diff = diffPolyWorldState(previous, next); + const plan = planPolyWorldLayers(topology, diff, [ + { id: "render", layer: "render", elementLayers: ["render"] }, + { id: "debug", layer: "debug", tags: ["portal"] }, + ]); + const snapshot = createPolyWorldPlanDebugSnapshot(diff, plan, { + planningSelection: { + regionIds: ["group-45", "group-14", "group-15"], + linkIds: ["portal-45-14"], + selectionKeys: ["portal:45:14"], + elementIds: ["shell-45", "shell-14"], + reasons: [{ label: "current" }, { label: "linked" }], + }, + entryLimit: 2, + listLimit: 1, + appliedState: previous, + }); + + expect(snapshot.planningSelection?.regionIds).toEqual(["group-45"]); + expect(snapshot.planningSelection?.counts.regions).toBe(3); + expect(snapshot.planningSelection?.omitted.regionIds).toBe(2); + expect(snapshot.planningSelection?.elementIds).toEqual(["shell-45"]); + expect(snapshot.planningSelection?.counts.elements).toBe(2); + expect(snapshot.planningSelection?.omitted.elementIds).toBe(1); + expect(snapshot.next.selectedRegionIds).toEqual(["group-14"]); + expect(snapshot.next.counts.regions).toBe(3); + expect(snapshot.next.omitted.selectedRegionIds).toBe(2); + expect(snapshot.next.reasonLabels).toEqual(["current"]); + expect(snapshot.next.omitted.reasonLabels).toBe(1); + expect(snapshot.diff.resolvedElements.added).toEqual(["portal-marker"]); + expect(snapshot.diff.resolvedElements.counts.added).toBe(3); + expect(snapshot.diff.resolvedElements.omitted.added).toBe(2); + expect(snapshot.plan.entryCount).toBe(4); + expect(snapshot.plan.includedEntryCount).toBe(2); + expect(snapshot.plan.omittedEntryCount).toBe(2); + expect(snapshot.applied?.missingElementIds).toEqual(["portal-marker"]); + expect(snapshot.applied?.counts.missingElementIds).toBe(3); + expect(snapshot.applied?.omitted.missingElementIds).toBe(2); + }); + + it("summarizes resource readiness in plan debug snapshots", () => { + const topology = topologyFixture(); + const previous = createPolyWorldState(topology, { + selection: { regionIds: ["group-45"] }, + }); + const next = createPolyWorldState(topology, { + selection: { regionIds: ["group-14", "group-15"] }, + }); + const diff = diffPolyWorldState(previous, next); + const plan = planPolyWorldLayers(topology, diff, [ + { id: "render", layer: "render", elementLayers: ["render"] }, + ]); + const readiness = summarizePolyWorldResourceReadiness( + topology, + next.resolvedElementIds, + { + "mesh:shell-14": "ready", + "texture:shell-14": "stale", + "mesh:shell-15": "loading", + }, + ); + const snapshot = createPolyWorldPlanDebugSnapshot(diff, plan, { + readiness, + listLimit: 1, + includeEntries: false, + }); + + expect(snapshot.readiness?.resourceIds).toEqual(["mesh:shell-14"]); + expect(snapshot.readiness?.readyResourceIds).toEqual(["mesh:shell-14"]); + expect(snapshot.readiness?.staleResourceIds).toEqual(["texture:shell-14"]); + expect(snapshot.readiness?.loadingResourceIds).toEqual(["mesh:shell-15"]); + expect(snapshot.readiness?.renderBlockingResourceIds).toEqual(["mesh:shell-14"]); + expect(snapshot.readiness?.preloadOnlyResourceIds).toEqual([]); + expect(snapshot.readiness?.nonBlockingResourceIds).toEqual([]); + expect(snapshot.readiness?.blockedResourceIds).toEqual(["texture:shell-14"]); + expect(snapshot.readiness?.blockedElementIds).toEqual(["shell-14"]); + expect(snapshot.readiness?.counts).toEqual({ + resources: 3, + readyResources: 1, + missingResources: 0, + requestedResources: 0, + loadingResources: 1, + failedResources: 0, + staleResources: 1, + renderBlockingResources: 3, + preloadOnlyResources: 0, + nonBlockingResources: 0, + blockedResources: 2, + blockedElements: 2, + }); + expect(snapshot.readiness?.omitted.renderBlockingResourceIds).toBe(2); + expect(snapshot.readiness?.omitted.preloadOnlyResourceIds).toBe(0); + expect(snapshot.readiness?.omitted.nonBlockingResourceIds).toBe(0); + expect(snapshot.readiness?.omitted.blockedResourceIds).toBe(1); + expect(snapshot.readiness?.omitted.blockedElementIds).toBe(1); + expect(snapshot.readiness?.stateCounts).toEqual({ + ready: 1, + missing: 0, + requested: 0, + loading: 1, + failed: 0, + stale: 1, + }); + }); +}); diff --git a/packages/world/src/debug/planSnapshot.ts b/packages/world/src/debug/planSnapshot.ts new file mode 100644 index 000000000..14d5d24d8 --- /dev/null +++ b/packages/world/src/debug/planSnapshot.ts @@ -0,0 +1,605 @@ +import type { PolyWorldLayerPlan, PolyWorldPlanActionCounts, PolyWorldPlanEntry } from "../planner"; +import type { + PolyWorldResourceLoadSetSummary, + PolyWorldResourceReadinessState, + PolyWorldResourceReadinessSummary, +} from "../planner/resources"; +import { diffPolyWorldIds } from "../state"; +import type { PolyWorldIdDiff, PolyWorldState, PolyWorldStateDiff } from "../state"; +import type { PolyWorldSelection, PolyWorldUnresolvedSelection } from "../topology"; +import { limitPolyWorldDebugList } from "./limits"; + +export interface PolyWorldPlanDebugSnapshotOptions { + appliedState?: PolyWorldState; + planningSelection?: PolyWorldSelection; + includeEntries?: boolean; + entryLimit?: number; + listLimit?: number; + readiness?: PolyWorldResourceReadinessSummary; + loadSet?: PolyWorldResourceLoadSetSummary; + metadata?: Record; +} + +export interface PolyWorldPlanDebugIdDiff { + added: readonly string[]; + removed: readonly string[]; + retained: readonly string[]; + counts: { + added: number; + removed: number; + retained: number; + }; + omitted: { + added: number; + removed: number; + retained: number; + }; +} + +export interface PolyWorldPlanDebugStateSummary { + id?: string; + signature: string; + selectionSignature: string; + elementSignature: string; + layerSignature: string; + selectedRegionIds: readonly string[]; + selectedLinkIds: readonly string[]; + selectedSelectionKeys: readonly string[]; + selectedElementIds: readonly string[]; + selectedSourceIds: readonly string[]; + selectedAliases: readonly string[]; + resolvedElementIds: readonly string[]; + layers: readonly string[]; + reasonLabels: readonly string[]; + unresolved: PolyWorldUnresolvedSelection; + counts: { + regions: number; + links: number; + selectionKeys: number; + selectedElements: number; + sourceIds: number; + aliases: number; + resolvedElements: number; + layers: number; + reasonLabels: number; + }; + omitted: { + selectedRegionIds: number; + selectedLinkIds: number; + selectedSelectionKeys: number; + selectedElementIds: number; + selectedSourceIds: number; + selectedAliases: number; + resolvedElementIds: number; + layers: number; + reasonLabels: number; + }; +} + +export interface PolyWorldPlanDebugSelectionSummary { + regionIds: readonly string[]; + linkIds: readonly string[]; + selectionKeys: readonly string[]; + elementIds: readonly string[]; + sourceIds: readonly string[]; + aliases: readonly string[]; + reasonLabels: readonly string[]; + counts: { + regions: number; + links: number; + selectionKeys: number; + elements: number; + sourceIds: number; + aliases: number; + reasonLabels: number; + }; + omitted: { + regionIds: number; + linkIds: number; + selectionKeys: number; + elementIds: number; + sourceIds: number; + aliases: number; + reasonLabels: number; + }; +} + +export interface PolyWorldPlanDebugAppliedComparison { + state: PolyWorldPlanDebugStateSummary; + matchesNext: boolean; + matchesResolvedElements: boolean; + matchesLayers: boolean; + missingElementIds: readonly string[]; + extraElementIds: readonly string[]; + missingLayers: readonly string[]; + extraLayers: readonly string[]; + counts: { + missingElementIds: number; + extraElementIds: number; + missingLayers: number; + extraLayers: number; + }; + omitted: { + missingElementIds: number; + extraElementIds: number; + missingLayers: number; + extraLayers: number; + }; +} + +export interface PolyWorldPlanDebugResourceReadinessSummary { + resourceIds: readonly string[]; + readyResourceIds: readonly string[]; + missingResourceIds: readonly string[]; + requestedResourceIds: readonly string[]; + loadingResourceIds: readonly string[]; + failedResourceIds: readonly string[]; + staleResourceIds: readonly string[]; + renderBlockingResourceIds: readonly string[]; + preloadOnlyResourceIds: readonly string[]; + nonBlockingResourceIds: readonly string[]; + blockedResourceIds: readonly string[]; + blockedElementIds: readonly string[]; + counts: { + resources: number; + readyResources: number; + missingResources: number; + requestedResources: number; + loadingResources: number; + failedResources: number; + staleResources: number; + renderBlockingResources: number; + preloadOnlyResources: number; + nonBlockingResources: number; + blockedResources: number; + blockedElements: number; + }; + omitted: { + resourceIds: number; + readyResourceIds: number; + missingResourceIds: number; + requestedResourceIds: number; + loadingResourceIds: number; + failedResourceIds: number; + staleResourceIds: number; + renderBlockingResourceIds: number; + preloadOnlyResourceIds: number; + nonBlockingResourceIds: number; + blockedResourceIds: number; + blockedElementIds: number; + }; + stateCounts: Readonly>; +} + +export interface PolyWorldPlanDebugResourceLoadSetSummary { + previousResourceIds: readonly string[]; + nextResourceIds: readonly string[]; + requestResourceIds: readonly string[]; + retainResourceIds: readonly string[]; + releaseCandidateResourceIds: readonly string[]; + readyButNotVisibleResourceIds: readonly string[]; + preloadOnlyResourceIds: readonly string[]; + renderBlockingResourceIds: readonly string[]; + staleAllowedResourceIds: readonly string[]; + nonBlockingResourceIds: readonly string[]; + blockedResourceIds: readonly string[]; + blockedElementIds: readonly string[]; + counts: { + previousResources: number; + nextResources: number; + requestResources: number; + retainResources: number; + releaseCandidateResources: number; + readyButNotVisibleResources: number; + preloadOnlyResources: number; + renderBlockingResources: number; + staleAllowedResources: number; + nonBlockingResources: number; + blockedResources: number; + blockedElements: number; + }; + omitted: { + previousResourceIds: number; + nextResourceIds: number; + requestResourceIds: number; + retainResourceIds: number; + releaseCandidateResourceIds: number; + readyButNotVisibleResourceIds: number; + preloadOnlyResourceIds: number; + renderBlockingResourceIds: number; + staleAllowedResourceIds: number; + nonBlockingResourceIds: number; + blockedResourceIds: number; + blockedElementIds: number; + }; +} + +export interface PolyWorldPlanDebugSnapshot { + schemaVersion: 1; + changed: boolean; + planningSelection?: PolyWorldPlanDebugSelectionSummary; + previous: PolyWorldPlanDebugStateSummary; + next: PolyWorldPlanDebugStateSummary; + diff: { + regions: PolyWorldPlanDebugIdDiff; + links: PolyWorldPlanDebugIdDiff; + selectionKeys: PolyWorldPlanDebugIdDiff; + selectedElements: PolyWorldPlanDebugIdDiff; + sourceIds: PolyWorldPlanDebugIdDiff; + aliases: PolyWorldPlanDebugIdDiff; + resolvedElements: PolyWorldPlanDebugIdDiff; + layers: PolyWorldPlanDebugIdDiff; + }; + plan: { + previousSignature: string; + nextSignature: string; + changed: boolean; + entryCount: number; + includedEntryCount: number; + omittedEntryCount: number; + blockedEntryCount: number; + guardFailureEntryCount: number; + dependencyFailureEntryCount: number; + actionCounts: PolyWorldPlanActionCounts; + layerCounts: Readonly>; + entries?: readonly PolyWorldPlanEntry[]; + }; + readiness?: PolyWorldPlanDebugResourceReadinessSummary; + loadSet?: PolyWorldPlanDebugResourceLoadSetSummary; + applied?: PolyWorldPlanDebugAppliedComparison; + metadata?: Record; +} + +export function createPolyWorldPlanDebugSnapshot( + diff: PolyWorldStateDiff, + plan: PolyWorldLayerPlan, + options: PolyWorldPlanDebugSnapshotOptions = {}, +): PolyWorldPlanDebugSnapshot { + const limitedEntries = options.includeEntries === false + ? undefined + : limitPolyWorldDebugList(plan.entries, options.entryLimit); + + return { + schemaVersion: 1, + changed: diff.changed, + planningSelection: options.planningSelection === undefined + ? undefined + : summarizeSelection(options.planningSelection, options.listLimit), + previous: summarizeState(diff.previous, options.listLimit), + next: summarizeState(diff.next, options.listLimit), + diff: { + regions: summarizeDiff(diff.regions, options.listLimit), + links: summarizeDiff(diff.links, options.listLimit), + selectionKeys: summarizeDiff(diff.selectionKeys, options.listLimit), + selectedElements: summarizeDiff(diff.selectedElements, options.listLimit), + sourceIds: summarizeDiff(diff.sourceIds, options.listLimit), + aliases: summarizeDiff(diff.aliases, options.listLimit), + resolvedElements: summarizeDiff(diff.resolvedElements, options.listLimit), + layers: summarizeDiff(diff.layers, options.listLimit), + }, + plan: { + previousSignature: plan.previousSignature, + nextSignature: plan.nextSignature, + changed: plan.changed, + entryCount: plan.entries.length, + includedEntryCount: limitedEntries?.values.length ?? 0, + omittedEntryCount: limitedEntries?.omitted ?? plan.entries.length, + blockedEntryCount: plan.entries.filter((entry) => entry.blocked === true).length, + guardFailureEntryCount: plan.entries.filter((entry) => hasFailedCheck(entry.guards)).length, + dependencyFailureEntryCount: plan.entries.filter((entry) => hasFailedCheck(entry.dependencies)).length, + actionCounts: plan.actionCounts, + layerCounts: plan.layerCounts, + entries: limitedEntries?.values, + }, + readiness: options.readiness === undefined + ? undefined + : summarizeReadiness(options.readiness, options.listLimit), + loadSet: options.loadSet === undefined + ? undefined + : summarizeLoadSet(options.loadSet, options.listLimit), + applied: options.appliedState === undefined + ? undefined + : compareAppliedState(diff.next, options.appliedState, options.listLimit), + metadata: options.metadata, + }; +} + +function summarizeReadiness( + readiness: PolyWorldResourceReadinessSummary, + listLimit: number | undefined, +): PolyWorldPlanDebugResourceReadinessSummary { + const resourceIds = limitPolyWorldDebugList(readiness.resourceIds, listLimit); + const readyResourceIds = limitPolyWorldDebugList(readiness.readyResourceIds, listLimit); + const missingResourceIds = limitPolyWorldDebugList(readiness.missingResourceIds, listLimit); + const requestedResourceIds = limitPolyWorldDebugList(readiness.requestedResourceIds, listLimit); + const loadingResourceIds = limitPolyWorldDebugList(readiness.loadingResourceIds, listLimit); + const failedResourceIds = limitPolyWorldDebugList(readiness.failedResourceIds, listLimit); + const staleResourceIds = limitPolyWorldDebugList(readiness.staleResourceIds, listLimit); + const renderBlockingResourceIds = limitPolyWorldDebugList(readiness.renderBlockingResourceIds, listLimit); + const preloadOnlyResourceIds = limitPolyWorldDebugList(readiness.preloadOnlyResourceIds, listLimit); + const nonBlockingResourceIds = limitPolyWorldDebugList(readiness.nonBlockingResourceIds, listLimit); + const blockedResourceIds = limitPolyWorldDebugList(readiness.blockedResourceIds, listLimit); + const blockedElementIds = limitPolyWorldDebugList(readiness.blockedElementIds, listLimit); + return { + resourceIds: resourceIds.values, + readyResourceIds: readyResourceIds.values, + missingResourceIds: missingResourceIds.values, + requestedResourceIds: requestedResourceIds.values, + loadingResourceIds: loadingResourceIds.values, + failedResourceIds: failedResourceIds.values, + staleResourceIds: staleResourceIds.values, + renderBlockingResourceIds: renderBlockingResourceIds.values, + preloadOnlyResourceIds: preloadOnlyResourceIds.values, + nonBlockingResourceIds: nonBlockingResourceIds.values, + blockedResourceIds: blockedResourceIds.values, + blockedElementIds: blockedElementIds.values, + counts: { + resources: readiness.resourceIds.length, + readyResources: readiness.readyResourceIds.length, + missingResources: readiness.missingResourceIds.length, + requestedResources: readiness.requestedResourceIds.length, + loadingResources: readiness.loadingResourceIds.length, + failedResources: readiness.failedResourceIds.length, + staleResources: readiness.staleResourceIds.length, + renderBlockingResources: readiness.renderBlockingResourceIds.length, + preloadOnlyResources: readiness.preloadOnlyResourceIds.length, + nonBlockingResources: readiness.nonBlockingResourceIds.length, + blockedResources: readiness.blockedResourceIds.length, + blockedElements: readiness.blockedElementIds.length, + }, + omitted: { + resourceIds: resourceIds.omitted, + readyResourceIds: readyResourceIds.omitted, + missingResourceIds: missingResourceIds.omitted, + requestedResourceIds: requestedResourceIds.omitted, + loadingResourceIds: loadingResourceIds.omitted, + failedResourceIds: failedResourceIds.omitted, + staleResourceIds: staleResourceIds.omitted, + renderBlockingResourceIds: renderBlockingResourceIds.omitted, + preloadOnlyResourceIds: preloadOnlyResourceIds.omitted, + nonBlockingResourceIds: nonBlockingResourceIds.omitted, + blockedResourceIds: blockedResourceIds.omitted, + blockedElementIds: blockedElementIds.omitted, + }, + stateCounts: { + ready: readiness.readyResourceIds.length, + missing: readiness.missingResourceIds.length, + requested: readiness.requestedResourceIds.length, + loading: readiness.loadingResourceIds.length, + failed: readiness.failedResourceIds.length, + stale: readiness.staleResourceIds.length, + }, + }; +} + +function summarizeLoadSet( + loadSet: PolyWorldResourceLoadSetSummary, + listLimit: number | undefined, +): PolyWorldPlanDebugResourceLoadSetSummary { + const previousResourceIds = limitPolyWorldDebugList(loadSet.previousResourceIds, listLimit); + const nextResourceIds = limitPolyWorldDebugList(loadSet.nextResourceIds, listLimit); + const requestResourceIds = limitPolyWorldDebugList(loadSet.requestResourceIds, listLimit); + const retainResourceIds = limitPolyWorldDebugList(loadSet.retainResourceIds, listLimit); + const releaseCandidateResourceIds = limitPolyWorldDebugList(loadSet.releaseCandidateResourceIds, listLimit); + const readyButNotVisibleResourceIds = limitPolyWorldDebugList(loadSet.readyButNotVisibleResourceIds, listLimit); + const preloadOnlyResourceIds = limitPolyWorldDebugList(loadSet.preloadOnlyResourceIds, listLimit); + const renderBlockingResourceIds = limitPolyWorldDebugList(loadSet.renderBlockingResourceIds, listLimit); + const staleAllowedResourceIds = limitPolyWorldDebugList(loadSet.staleAllowedResourceIds, listLimit); + const nonBlockingResourceIds = limitPolyWorldDebugList(loadSet.nonBlockingResourceIds, listLimit); + const blockedResourceIds = limitPolyWorldDebugList(loadSet.blockedResourceIds, listLimit); + const blockedElementIds = limitPolyWorldDebugList(loadSet.blockedElementIds, listLimit); + return { + previousResourceIds: previousResourceIds.values, + nextResourceIds: nextResourceIds.values, + requestResourceIds: requestResourceIds.values, + retainResourceIds: retainResourceIds.values, + releaseCandidateResourceIds: releaseCandidateResourceIds.values, + readyButNotVisibleResourceIds: readyButNotVisibleResourceIds.values, + preloadOnlyResourceIds: preloadOnlyResourceIds.values, + renderBlockingResourceIds: renderBlockingResourceIds.values, + staleAllowedResourceIds: staleAllowedResourceIds.values, + nonBlockingResourceIds: nonBlockingResourceIds.values, + blockedResourceIds: blockedResourceIds.values, + blockedElementIds: blockedElementIds.values, + counts: { + previousResources: loadSet.previousResourceIds.length, + nextResources: loadSet.nextResourceIds.length, + requestResources: loadSet.requestResourceIds.length, + retainResources: loadSet.retainResourceIds.length, + releaseCandidateResources: loadSet.releaseCandidateResourceIds.length, + readyButNotVisibleResources: loadSet.readyButNotVisibleResourceIds.length, + preloadOnlyResources: loadSet.preloadOnlyResourceIds.length, + renderBlockingResources: loadSet.renderBlockingResourceIds.length, + staleAllowedResources: loadSet.staleAllowedResourceIds.length, + nonBlockingResources: loadSet.nonBlockingResourceIds.length, + blockedResources: loadSet.blockedResourceIds.length, + blockedElements: loadSet.blockedElementIds.length, + }, + omitted: { + previousResourceIds: previousResourceIds.omitted, + nextResourceIds: nextResourceIds.omitted, + requestResourceIds: requestResourceIds.omitted, + retainResourceIds: retainResourceIds.omitted, + releaseCandidateResourceIds: releaseCandidateResourceIds.omitted, + readyButNotVisibleResourceIds: readyButNotVisibleResourceIds.omitted, + preloadOnlyResourceIds: preloadOnlyResourceIds.omitted, + renderBlockingResourceIds: renderBlockingResourceIds.omitted, + staleAllowedResourceIds: staleAllowedResourceIds.omitted, + nonBlockingResourceIds: nonBlockingResourceIds.omitted, + blockedResourceIds: blockedResourceIds.omitted, + blockedElementIds: blockedElementIds.omitted, + }, + }; +} + +function hasFailedCheck(checks: readonly { ok: boolean }[] | undefined): boolean { + return checks?.some((check) => check.ok === false) ?? false; +} + +export function adaptPolyWorldPlanDebugSnapshot( + snapshot: PolyWorldPlanDebugSnapshot, + adapter: (snapshot: PolyWorldPlanDebugSnapshot) => T, +): T { + return adapter(snapshot); +} + +function summarizeSelection( + selection: PolyWorldSelection, + listLimit: number | undefined, +): PolyWorldPlanDebugSelectionSummary { + const regionIds = limitPolyWorldDebugList(selection.regionIds ?? [], listLimit); + const linkIds = limitPolyWorldDebugList(selection.linkIds ?? [], listLimit); + const selectionKeys = limitPolyWorldDebugList(selection.selectionKeys ?? [], listLimit); + const elementIds = limitPolyWorldDebugList(selection.elementIds ?? [], listLimit); + const sourceIds = limitPolyWorldDebugList(selection.sourceIds ?? [], listLimit); + const aliases = limitPolyWorldDebugList(selection.aliases ?? [], listLimit); + const reasonLabels = limitPolyWorldDebugList(selection.reasons?.map((reason) => reason.label) ?? [], listLimit); + + return { + regionIds: regionIds.values, + linkIds: linkIds.values, + selectionKeys: selectionKeys.values, + elementIds: elementIds.values, + sourceIds: sourceIds.values, + aliases: aliases.values, + reasonLabels: reasonLabels.values, + counts: { + regions: selection.regionIds?.length ?? 0, + links: selection.linkIds?.length ?? 0, + selectionKeys: selection.selectionKeys?.length ?? 0, + elements: selection.elementIds?.length ?? 0, + sourceIds: selection.sourceIds?.length ?? 0, + aliases: selection.aliases?.length ?? 0, + reasonLabels: selection.reasons?.length ?? 0, + }, + omitted: { + regionIds: regionIds.omitted, + linkIds: linkIds.omitted, + selectionKeys: selectionKeys.omitted, + elementIds: elementIds.omitted, + sourceIds: sourceIds.omitted, + aliases: aliases.omitted, + reasonLabels: reasonLabels.omitted, + }, + }; +} + +function compareAppliedState( + next: PolyWorldState, + applied: PolyWorldState, + listLimit: number | undefined, +): PolyWorldPlanDebugAppliedComparison { + const elementDiff = diffPolyWorldIds(applied.resolvedElementIds, next.resolvedElementIds); + const layerDiff = diffPolyWorldIds(applied.layers, next.layers); + const missingElementIds = elementDiff.added; + const extraElementIds = elementDiff.removed; + const missingLayers = layerDiff.added; + const extraLayers = layerDiff.removed; + const limitedMissingElementIds = limitPolyWorldDebugList(missingElementIds, listLimit); + const limitedExtraElementIds = limitPolyWorldDebugList(extraElementIds, listLimit); + const limitedMissingLayers = limitPolyWorldDebugList(missingLayers, listLimit); + const limitedExtraLayers = limitPolyWorldDebugList(extraLayers, listLimit); + + return { + state: summarizeState(applied, listLimit), + matchesNext: applied.signature === next.signature, + matchesResolvedElements: missingElementIds.length === 0 && extraElementIds.length === 0, + matchesLayers: missingLayers.length === 0 && extraLayers.length === 0, + missingElementIds: limitedMissingElementIds.values, + extraElementIds: limitedExtraElementIds.values, + missingLayers: limitedMissingLayers.values, + extraLayers: limitedExtraLayers.values, + counts: { + missingElementIds: missingElementIds.length, + extraElementIds: extraElementIds.length, + missingLayers: missingLayers.length, + extraLayers: extraLayers.length, + }, + omitted: { + missingElementIds: limitedMissingElementIds.omitted, + extraElementIds: limitedExtraElementIds.omitted, + missingLayers: limitedMissingLayers.omitted, + extraLayers: limitedExtraLayers.omitted, + }, + }; +} + +function summarizeState( + state: PolyWorldState, + listLimit: number | undefined, +): PolyWorldPlanDebugStateSummary { + const selectedRegionIds = limitPolyWorldDebugList(state.selectedRegionIds, listLimit); + const selectedLinkIds = limitPolyWorldDebugList(state.selectedLinkIds, listLimit); + const selectedSelectionKeys = limitPolyWorldDebugList(state.selectedSelectionKeys, listLimit); + const selectedElementIds = limitPolyWorldDebugList(state.selectedElementIds, listLimit); + const selectedSourceIds = limitPolyWorldDebugList(state.selectedSourceIds, listLimit); + const selectedAliases = limitPolyWorldDebugList(state.selectedAliases, listLimit); + const resolvedElementIds = limitPolyWorldDebugList(state.resolvedElementIds, listLimit); + const layers = limitPolyWorldDebugList(state.layers, listLimit); + const reasonLabels = limitPolyWorldDebugList(state.reasonLabels, listLimit); + + return { + id: state.id, + signature: state.signature, + selectionSignature: state.selectionSignature, + elementSignature: state.elementSignature, + layerSignature: state.layerSignature, + selectedRegionIds: selectedRegionIds.values, + selectedLinkIds: selectedLinkIds.values, + selectedSelectionKeys: selectedSelectionKeys.values, + selectedElementIds: selectedElementIds.values, + selectedSourceIds: selectedSourceIds.values, + selectedAliases: selectedAliases.values, + resolvedElementIds: resolvedElementIds.values, + layers: layers.values, + reasonLabels: reasonLabels.values, + unresolved: state.unresolved, + counts: { + regions: state.selectedRegionIds.length, + links: state.selectedLinkIds.length, + selectionKeys: state.selectedSelectionKeys.length, + selectedElements: state.selectedElementIds.length, + sourceIds: state.selectedSourceIds.length, + aliases: state.selectedAliases.length, + resolvedElements: state.resolvedElementIds.length, + layers: state.layers.length, + reasonLabels: state.reasonLabels.length, + }, + omitted: { + selectedRegionIds: selectedRegionIds.omitted, + selectedLinkIds: selectedLinkIds.omitted, + selectedSelectionKeys: selectedSelectionKeys.omitted, + selectedElementIds: selectedElementIds.omitted, + selectedSourceIds: selectedSourceIds.omitted, + selectedAliases: selectedAliases.omitted, + resolvedElementIds: resolvedElementIds.omitted, + layers: layers.omitted, + reasonLabels: reasonLabels.omitted, + }, + }; +} + +function summarizeDiff( + diff: PolyWorldIdDiff, + listLimit: number | undefined, +): PolyWorldPlanDebugIdDiff { + const added = limitPolyWorldDebugList(diff.added, listLimit); + const removed = limitPolyWorldDebugList(diff.removed, listLimit); + const retained = limitPolyWorldDebugList(diff.retained, listLimit); + + return { + added: added.values, + removed: removed.values, + retained: retained.values, + counts: { + added: diff.added.length, + removed: diff.removed.length, + retained: diff.retained.length, + }, + omitted: { + added: added.omitted, + removed: removed.omitted, + retained: retained.omitted, + }, + }; +} diff --git a/packages/world/src/debug/portalFlowSnapshot.ts b/packages/world/src/debug/portalFlowSnapshot.ts new file mode 100644 index 000000000..69859e7e4 --- /dev/null +++ b/packages/world/src/debug/portalFlowSnapshot.ts @@ -0,0 +1,258 @@ +import type { + PolyWorldPortalFlow, + PolyWorldPortalFlowTraceEntry, + PolyWorldPortalFlowTraceStatus, +} from "../profiles/portalFlow"; +import { + createPolyWorldProfileArtifactProof, + type PolyWorldProfileArtifactProof, +} from "../profiles/artifact"; +import type { PolyWorldTopology } from "../topology"; +import { limitPolyWorldDebugList } from "./limits"; + +export interface PolyWorldPortalFlowDebugSnapshotOptions { + listLimit?: number; + entryLimit?: number; + includeTraceEntries?: boolean; + metadata?: Record; +} + +export interface PolyWorldPortalFlowDebugListSummary { + values: readonly string[]; + count: number; + omitted: number; +} + +export interface PolyWorldPortalFlowDebugTraceEntry { + portalId: string; + linkId: string; + fromRegionId: string; + toRegionId: string; + depth: number; + status: PolyWorldPortalFlowTraceStatus; + inputVertexCount: number; + clippedVertexCount?: number; + clipPlaneCount?: number; + selectionKeys?: readonly string[]; +} + +export interface PolyWorldPortalFlowDebugSnapshot { + schemaVersion: 1; + proof: PolyWorldProfileArtifactProof; + topology: { + regionCount: number; + linkCount: number; + profile: "portal-flow"; + }; + current: { + regionId?: string; + }; + regions: { + selectedRegionIds: PolyWorldPortalFlowDebugListSummary; + hiddenRegionIds: PolyWorldPortalFlowDebugListSummary; + }; + links: { + selectedLinkIds: PolyWorldPortalFlowDebugListSummary; + hiddenLinkIds: PolyWorldPortalFlowDebugListSummary; + }; + portals: { + selectedPortalIds: PolyWorldPortalFlowDebugListSummary; + tracedPortalIds: PolyWorldPortalFlowDebugListSummary; + rejectedPortalIds: PolyWorldPortalFlowDebugListSummary; + }; + selection: { + selectionKeys: PolyWorldPortalFlowDebugListSummary; + reasonLabels: PolyWorldPortalFlowDebugListSummary; + reasonKinds: Readonly>; + }; + trace?: { + entryCount: number; + statusCounts: Partial>; + entries?: readonly PolyWorldPortalFlowDebugTraceEntry[]; + omittedEntries?: number; + }; + metadata?: Record; +} + +export function createPolyWorldPortalFlowDebugSnapshot( + topology: PolyWorldTopology, + flow: PolyWorldPortalFlow, + options: PolyWorldPortalFlowDebugSnapshotOptions = {}, +): PolyWorldPortalFlowDebugSnapshot { + const selectedRegionIds = unique(flow.regionIds); + const selectedRegionSet = new Set(selectedRegionIds); + const selectedLinkIds = unique(flow.linkIds); + const selectedLinkSet = new Set(selectedLinkIds); + const selectedPortalIds = unique(flow.portalIds); + const selectedPortalSet = new Set(selectedPortalIds); + const tracedPortalIds = unique(flow.trace?.map((entry) => entry.portalId)); + const rejectedPortalIds = tracedPortalIds.filter((portalId) => !selectedPortalSet.has(portalId)); + const reasons = flow.selection.reasons ?? []; + + return { + schemaVersion: 1, + proof: createPolyWorldPortalFlowArtifactProof(topology, flow), + topology: { + regionCount: topology.regions.length, + linkCount: topology.links.length, + profile: "portal-flow", + }, + current: { + ...(flow.currentRegionId === undefined ? {} : { regionId: flow.currentRegionId }), + }, + regions: { + selectedRegionIds: summarizeList(selectedRegionIds, options.listLimit), + hiddenRegionIds: summarizeList( + topology.regions.map((region) => region.id).filter((regionId) => !selectedRegionSet.has(regionId)), + options.listLimit, + ), + }, + links: { + selectedLinkIds: summarizeList(selectedLinkIds, options.listLimit), + hiddenLinkIds: summarizeList( + topology.links.map((link) => link.id).filter((linkId) => !selectedLinkSet.has(linkId)), + options.listLimit, + ), + }, + portals: { + selectedPortalIds: summarizeList(selectedPortalIds, options.listLimit), + tracedPortalIds: summarizeList(tracedPortalIds, options.listLimit), + rejectedPortalIds: summarizeList(rejectedPortalIds, options.listLimit), + }, + selection: { + selectionKeys: summarizeList(unique(flow.selectionKeys), options.listLimit), + reasonLabels: summarizeList(unique(reasons.map((reason) => reason.label)), options.listLimit), + reasonKinds: countReasonKinds(reasons), + }, + ...(flow.trace === undefined ? {} : { trace: summarizeTrace(flow.trace, options) }), + ...(options.metadata === undefined ? {} : { metadata: options.metadata }), + }; +} + +export function createPolyWorldPortalFlowArtifactProof( + topology: PolyWorldTopology, + flow: PolyWorldPortalFlow, +): PolyWorldProfileArtifactProof { + const selectedRegionIds = unique(flow.regionIds); + const selectedLinkIds = unique(flow.linkIds); + const selectedPortalIds = unique(flow.portalIds); + const selectedPortalSet = new Set(selectedPortalIds); + const tracedPortalIds = unique(flow.trace?.map((entry) => entry.portalId)); + const rejectedPortalIds = tracedPortalIds.filter((portalId) => !selectedPortalSet.has(portalId)); + return createPolyWorldProfileArtifactProof({ + profile: "portal-flow", + artifactKind: "authored-area-portal-flow", + sourceKind: "authored-runtime-selection", + producedBy: "resolvePolyWorldPortalFlow", + guarantees: [ + "authored-region-link-traversal", + "camera-frustum-portal-clipping", + "closed-blocked-link-state", + "trace-status-counts", + ], + knownWeaknesses: [ + "not-compiled-bsp-pvs", + "not-occlusion-proof", + "not-resource-loader", + "not-a-renderer", + ], + counts: { + regionCount: topology.regions.length, + linkCount: topology.links.length, + selectedRegionCount: selectedRegionIds.length, + hiddenRegionCount: topology.regions.length - selectedRegionIds.length, + selectedLinkCount: selectedLinkIds.length, + hiddenLinkCount: topology.links.length - selectedLinkIds.length, + selectedPortalCount: selectedPortalIds.length, + tracedPortalCount: tracedPortalIds.length, + rejectedPortalCount: rejectedPortalIds.length, + traceEntryCount: flow.trace?.length ?? 0, + }, + coverage: { + selectedRegionCoverage: coverage(selectedRegionIds.length, topology.regions.length), + selectedLinkCoverage: coverage(selectedLinkIds.length, topology.links.length), + selectedPortalCoverage: coverage(selectedPortalIds.length, tracedPortalIds.length), + }, + }); +} + +export function adaptPolyWorldPortalFlowDebugSnapshot( + snapshot: PolyWorldPortalFlowDebugSnapshot, + adapter: (snapshot: PolyWorldPortalFlowDebugSnapshot) => T, +): T { + return adapter(snapshot); +} + +function summarizeTrace( + entries: readonly PolyWorldPortalFlowTraceEntry[], + options: PolyWorldPortalFlowDebugSnapshotOptions, +): NonNullable { + const statusCounts: Partial> = {}; + for (const entry of entries) { + statusCounts[entry.status] = (statusCounts[entry.status] ?? 0) + 1; + } + if (options.includeTraceEntries !== true) { + return { + entryCount: entries.length, + statusCounts, + }; + } + const limitedEntries = limitPolyWorldDebugList(entries, options.entryLimit); + return { + entryCount: entries.length, + statusCounts, + entries: limitedEntries.values.map(summarizeTraceEntry), + omittedEntries: limitedEntries.omitted, + }; +} + +function summarizeTraceEntry(entry: PolyWorldPortalFlowTraceEntry): PolyWorldPortalFlowDebugTraceEntry { + return { + portalId: entry.portalId, + linkId: entry.linkId, + fromRegionId: entry.fromRegionId, + toRegionId: entry.toRegionId, + depth: entry.depth, + status: entry.status, + inputVertexCount: entry.inputVertexCount, + ...(entry.clippedVertexCount === undefined ? {} : { clippedVertexCount: entry.clippedVertexCount }), + ...(entry.clipPlaneCount === undefined ? {} : { clipPlaneCount: entry.clipPlaneCount }), + ...(entry.selectionKeys === undefined ? {} : { selectionKeys: [...entry.selectionKeys] }), + }; +} + +function summarizeList( + values: readonly string[], + limit: number | undefined, +): PolyWorldPortalFlowDebugListSummary { + const limited = limitPolyWorldDebugList(values, limit); + return { + values: limited.values, + count: values.length, + omitted: limited.omitted, + }; +} + +function countReasonKinds(reasons: readonly { kind?: string }[]): Record { + const counts: Record = {}; + for (const reason of reasons) { + const kind = reason.kind ?? "unknown"; + counts[kind] = (counts[kind] ?? 0) + 1; + } + return Object.fromEntries(Object.entries(counts).sort(([a], [b]) => compareStrings(a, b))); +} + +function unique(values: readonly string[] | undefined): string[] { + return [...new Set(values ?? [])]; +} + +function compareStrings(a: string, b: string): number { + if (a < b) return -1; + if (a > b) return 1; + return 0; +} + +function coverage(count: number, total: number): number { + if (total === 0) return 0; + return count / total; +} diff --git a/packages/world/src/debug/portalSnapshot.test.ts b/packages/world/src/debug/portalSnapshot.test.ts new file mode 100644 index 000000000..cacd29285 --- /dev/null +++ b/packages/world/src/debug/portalSnapshot.test.ts @@ -0,0 +1,161 @@ +import { describe, expect, it } from "vitest"; +import { resolvePolyWorldPortalActivity, selectPolyWorldPortalRegions } from "../profiles"; +import { createPolyWorldTopology } from "../topology"; +import { + adaptPolyWorldPortalDebugSnapshot, + createPolyWorldPortalDebugSnapshot, +} from "./index"; + +describe("createPolyWorldPortalDebugSnapshot", () => { + it("summarizes selected, hidden, closed, and blocked portal rooms", () => { + const topology = createPolyWorldTopology({ + regions: [ + { id: "studio" }, + { id: "gallery" }, + { id: "vault" }, + { id: "engine" }, + ], + links: [ + { id: "studio-gallery", fromRegionId: "studio", toRegionId: "gallery", selectionKeys: ["portal:studio-gallery"] }, + { id: "gallery-vault", fromRegionId: "gallery", toRegionId: "vault", selectionKeys: ["portal:gallery-vault"] }, + { id: "gallery-engine", fromRegionId: "gallery", toRegionId: "engine", selectionKeys: ["portal:gallery-engine"] }, + ], + elements: [ + { id: "studio-shell", regionIds: ["studio"] }, + { id: "gallery-shell", regionIds: ["gallery"] }, + { id: "vault-shell", regionIds: ["vault"] }, + { id: "engine-shell", regionIds: ["engine"] }, + ], + }); + const selection = selectPolyWorldPortalRegions(topology, { + currentRegionId: "studio", + linkedDepth: 2, + facingLinkIds: ["gallery-vault"], + visibleRegionIds: ["vault"], + linkState: { + "gallery-vault": "closed", + "gallery-engine": "blocked", + }, + }); + const activity = resolvePolyWorldPortalActivity(topology, selection, { + selectedTargetState: "resident", + activeRegionIds: ["studio"], + renderedRegionIds: ["gallery"], + preloadedRegionIds: ["engine"], + }); + const snapshot = createPolyWorldPortalDebugSnapshot(topology, selection, { + currentRegionId: "studio", + activity, + listLimit: 2, + metadata: { view: "portal-fpv" }, + }); + + expect(snapshot.proof).toMatchObject({ + profile: "area-portals", + artifactKind: "authored-area-portals", + sourceKind: "authored-runtime-selection", + producedBy: "selectPolyWorldPortalRegions", + counts: { + regionCount: 4, + linkCount: 3, + selectedRegionCount: 3, + hiddenRegionCount: 1, + selectedLinkCount: 2, + hiddenLinkCount: 1, + closedLinkCount: 1, + blockedLinkCount: 1, + facingLinkCount: 1, + }, + }); + expect(snapshot.proof.guarantees).toContain("authored-region-link-traversal"); + expect(snapshot.proof.guarantees).toContain("closed-blocked-link-state"); + expect(snapshot.proof.knownWeaknesses).toContain("not-compiled-bsp-pvs"); + expect(snapshot.proof.knownWeaknesses).toContain("not-camera-frustum-portal-clipping"); + expect(snapshot.topology).toEqual({ regionCount: 4, linkCount: 3, profile: "area-portals" }); + expect(snapshot.current.regionId).toBe("studio"); + expect(snapshot.regions.selectedRegionIds).toEqual({ + values: ["studio", "gallery"], + count: 3, + omitted: 1, + }); + expect(snapshot.regions.hiddenRegionIds).toEqual({ + values: ["engine"], + count: 1, + omitted: 0, + }); + expect(activity).toEqual({ + selectedRegionIds: ["studio", "gallery", "vault"], + hiddenRegionIds: ["engine"], + loadedRegionIds: ["studio", "gallery", "vault"], + residentRegionIds: ["studio", "gallery", "vault"], + activeRegionIds: ["studio", "gallery"], + renderedRegionIds: ["gallery"], + preloadedRegionIds: ["engine"], + inactiveRegionIds: ["vault", "engine"], + }); + expect(snapshot.activity?.residentRegionIds).toEqual({ + values: ["studio", "gallery"], + count: 3, + omitted: 1, + }); + expect(snapshot.activity?.activeRegionIds).toEqual({ + values: ["studio", "gallery"], + count: 2, + omitted: 0, + }); + expect(snapshot.activity?.renderedRegionIds).toEqual({ + values: ["gallery"], + count: 1, + omitted: 0, + }); + expect(snapshot.activity?.preloadedRegionIds).toEqual({ + values: ["engine"], + count: 1, + omitted: 0, + }); + expect(snapshot.activity?.inactiveRegionIds).toEqual({ + values: ["vault", "engine"], + count: 2, + omitted: 0, + }); + expect(snapshot.links.selectedLinkIds).toEqual({ + values: ["studio-gallery", "gallery-vault"], + count: 2, + omitted: 0, + }); + expect(snapshot.links.closedLinkIds).toEqual({ + values: ["gallery-vault"], + count: 1, + omitted: 0, + }); + expect(snapshot.links.blockedLinkIds).toEqual({ + values: ["gallery-engine"], + count: 1, + omitted: 0, + }); + expect(snapshot.links.facingLinkIds.values).toEqual(["gallery-vault"]); + expect(snapshot.selection.reasonKinds).toEqual({ + blocked: 1, + closed: 1, + current: 1, + facing: 1, + linked: 1, + selectionKey: 1, + visible: 1, + }); + expect(snapshot.metadata).toEqual({ view: "portal-fpv" }); + expect( + adaptPolyWorldPortalDebugSnapshot(snapshot, (value) => ({ + visibleRooms: value.regions.selectedRegionIds.count, + hiddenRooms: value.regions.hiddenRegionIds.count, + closedLinks: value.links.closedLinkIds.count, + blockedLinks: value.links.blockedLinkIds.count, + })), + ).toEqual({ + visibleRooms: 3, + hiddenRooms: 1, + closedLinks: 1, + blockedLinks: 1, + }); + }); +}); diff --git a/packages/world/src/debug/portalSnapshot.ts b/packages/world/src/debug/portalSnapshot.ts new file mode 100644 index 000000000..b03c96a67 --- /dev/null +++ b/packages/world/src/debug/portalSnapshot.ts @@ -0,0 +1,228 @@ +import type { PolyWorldSelection, PolyWorldSelectionReason, PolyWorldTopology } from "../topology"; +import { + createPolyWorldProfileArtifactProof, + type PolyWorldProfileArtifactProof, +} from "../profiles/artifact"; +import type { PolyWorldPortalActivityState } from "../profiles/portal"; +import { limitPolyWorldDebugList } from "./limits"; + +export interface PolyWorldPortalDebugSnapshotOptions { + currentRegionId?: string; + activity?: PolyWorldPortalActivityState; + listLimit?: number; + metadata?: Record; +} + +export interface PolyWorldPortalDebugListSummary { + values: readonly string[]; + count: number; + omitted: number; +} + +export interface PolyWorldPortalDebugSnapshot { + schemaVersion: 1; + proof: PolyWorldProfileArtifactProof; + topology: { + regionCount: number; + linkCount: number; + profile: "area-portals"; + }; + current: { + regionId?: string; + }; + regions: { + selectedRegionIds: PolyWorldPortalDebugListSummary; + hiddenRegionIds: PolyWorldPortalDebugListSummary; + }; + activity?: { + loadedRegionIds: PolyWorldPortalDebugListSummary; + residentRegionIds: PolyWorldPortalDebugListSummary; + activeRegionIds: PolyWorldPortalDebugListSummary; + renderedRegionIds: PolyWorldPortalDebugListSummary; + preloadedRegionIds: PolyWorldPortalDebugListSummary; + inactiveRegionIds: PolyWorldPortalDebugListSummary; + }; + links: { + selectedLinkIds: PolyWorldPortalDebugListSummary; + hiddenLinkIds: PolyWorldPortalDebugListSummary; + closedLinkIds: PolyWorldPortalDebugListSummary; + blockedLinkIds: PolyWorldPortalDebugListSummary; + facingLinkIds: PolyWorldPortalDebugListSummary; + }; + selection: { + selectionKeys: PolyWorldPortalDebugListSummary; + reasonLabels: PolyWorldPortalDebugListSummary; + reasonKinds: Readonly>; + }; + metadata?: Record; +} + +export function createPolyWorldPortalDebugSnapshot( + topology: PolyWorldTopology, + selection: PolyWorldSelection, + options: PolyWorldPortalDebugSnapshotOptions = {}, +): PolyWorldPortalDebugSnapshot { + const selectedRegionIds = unique(selection.regionIds); + const selectedLinkIds = unique(selection.linkIds); + const selectedRegionSet = new Set(selectedRegionIds); + const selectedLinkSet = new Set(selectedLinkIds); + const hiddenRegionIds = topology.regions + .map((region) => region.id) + .filter((regionId) => !selectedRegionSet.has(regionId)); + const hiddenLinkIds = topology.links + .map((link) => link.id) + .filter((linkId) => !selectedLinkSet.has(linkId)); + const reasons = selection.reasons ?? []; + + return { + schemaVersion: 1, + proof: createPolyWorldPortalArtifactProof(topology, selection), + topology: { + regionCount: topology.regions.length, + linkCount: topology.links.length, + profile: "area-portals", + }, + current: { + ...(options.currentRegionId === undefined ? {} : { regionId: options.currentRegionId }), + }, + regions: { + selectedRegionIds: summarizeList(selectedRegionIds, options.listLimit), + hiddenRegionIds: summarizeList(hiddenRegionIds, options.listLimit), + }, + ...(options.activity === undefined ? {} : { + activity: summarizeActivity(options.activity, options.listLimit), + }), + links: { + selectedLinkIds: summarizeList(selectedLinkIds, options.listLimit), + hiddenLinkIds: summarizeList(hiddenLinkIds, options.listLimit), + closedLinkIds: summarizeList(reasonLinkIds(reasons, "closed"), options.listLimit), + blockedLinkIds: summarizeList(reasonLinkIds(reasons, "blocked"), options.listLimit), + facingLinkIds: summarizeList(reasonLinkIds(reasons, "facing"), options.listLimit), + }, + selection: { + selectionKeys: summarizeList(unique(selection.selectionKeys), options.listLimit), + reasonLabels: summarizeList(unique(reasons.map((reason) => reason.label)), options.listLimit), + reasonKinds: countReasonKinds(reasons), + }, + metadata: options.metadata, + }; +} + +export function createPolyWorldPortalArtifactProof( + topology: PolyWorldTopology, + selection: PolyWorldSelection, +): PolyWorldProfileArtifactProof { + const selectedRegionIds = unique(selection.regionIds); + const selectedRegionSet = new Set(selectedRegionIds); + const selectedLinkIds = unique(selection.linkIds); + const selectedLinkSet = new Set(selectedLinkIds); + const reasons = selection.reasons ?? []; + const hiddenRegionIds = topology.regions + .map((region) => region.id) + .filter((regionId) => !selectedRegionSet.has(regionId)); + const hiddenLinkIds = topology.links + .map((link) => link.id) + .filter((linkId) => !selectedLinkSet.has(linkId)); + const closedLinkIds = reasonLinkIds(reasons, "closed"); + const blockedLinkIds = reasonLinkIds(reasons, "blocked"); + const facingLinkIds = reasonLinkIds(reasons, "facing"); + return createPolyWorldProfileArtifactProof({ + profile: "area-portals", + artifactKind: "authored-area-portals", + sourceKind: "authored-runtime-selection", + producedBy: "selectPolyWorldPortalRegions", + guarantees: [ + "authored-region-link-traversal", + "closed-blocked-link-state", + "activity-state-reporting", + "selection-reason-counts", + ], + knownWeaknesses: [ + "not-compiled-bsp-pvs", + "not-camera-frustum-portal-clipping", + "not-occlusion-proof", + "not-resource-loader", + "not-a-renderer", + ], + counts: { + regionCount: topology.regions.length, + linkCount: topology.links.length, + selectedRegionCount: selectedRegionIds.length, + hiddenRegionCount: hiddenRegionIds.length, + selectedLinkCount: selectedLinkIds.length, + hiddenLinkCount: hiddenLinkIds.length, + closedLinkCount: closedLinkIds.length, + blockedLinkCount: blockedLinkIds.length, + facingLinkCount: facingLinkIds.length, + selectionKeyCount: unique(selection.selectionKeys).length, + }, + coverage: { + selectedRegionCoverage: coverage(selectedRegionIds.length, topology.regions.length), + selectedLinkCoverage: coverage(selectedLinkIds.length, topology.links.length), + }, + }); +} + +export function adaptPolyWorldPortalDebugSnapshot( + snapshot: PolyWorldPortalDebugSnapshot, + adapter: (snapshot: PolyWorldPortalDebugSnapshot) => T, +): T { + return adapter(snapshot); +} + +function summarizeActivity( + activity: PolyWorldPortalActivityState, + listLimit: number | undefined, +): NonNullable { + return { + loadedRegionIds: summarizeList(activity.loadedRegionIds, listLimit), + residentRegionIds: summarizeList(activity.residentRegionIds, listLimit), + activeRegionIds: summarizeList(activity.activeRegionIds, listLimit), + renderedRegionIds: summarizeList(activity.renderedRegionIds, listLimit), + preloadedRegionIds: summarizeList(activity.preloadedRegionIds, listLimit), + inactiveRegionIds: summarizeList(activity.inactiveRegionIds, listLimit), + }; +} + +function reasonLinkIds( + reasons: readonly PolyWorldSelectionReason[], + kind: string, +): string[] { + return unique(reasons.flatMap((reason) => reason.kind === kind ? [...(reason.linkIds ?? [])] : [])); +} + +function countReasonKinds(reasons: readonly PolyWorldSelectionReason[]): Record { + const counts: Record = {}; + for (const reason of reasons) { + const kind = reason.kind ?? "unknown"; + counts[kind] = (counts[kind] ?? 0) + 1; + } + return Object.fromEntries(Object.entries(counts).sort(([a], [b]) => compareStrings(a, b))); +} + +function summarizeList( + values: readonly string[], + limit: number | undefined, +): PolyWorldPortalDebugListSummary { + const limited = limitPolyWorldDebugList(values, limit); + return { + values: limited.values, + count: values.length, + omitted: limited.omitted, + }; +} + +function unique(values: readonly string[] | undefined): string[] { + return [...new Set(values ?? [])]; +} + +function coverage(value: number, total: number): number { + if (total <= 0) return 1; + return value / total; +} + +function compareStrings(a: string, b: string): number { + if (a < b) return -1; + if (a > b) return 1; + return 0; +} diff --git a/packages/world/src/debug/snapshot.ts b/packages/world/src/debug/snapshot.ts new file mode 100644 index 000000000..071a95b56 --- /dev/null +++ b/packages/world/src/debug/snapshot.ts @@ -0,0 +1,206 @@ +import type { + PolyWorldElement, + PolyWorldElementResolution, + PolyWorldElementResolutionOptions, + PolyWorldSelection, + PolyWorldTopology, + PolyWorldUnresolvedSelection, + PolyWorldValidationDiagnostic, +} from "../topology"; +import { resolvePolyWorldElements } from "../topology"; +import { limitPolyWorldDebugList } from "./limits"; + +export interface PolyWorldDebugSnapshotOptions { + preparedOnly?: boolean; + listLimit?: number; + metadata?: Record; + resolution?: PolyWorldElementResolution; + resolutionOptions?: PolyWorldElementResolutionOptions; + validationDiagnostics?: readonly PolyWorldValidationDiagnostic[]; +} + +export interface PolyWorldDebugSnapshot { + schemaVersion: 1; + preparedOnly: boolean; + topology: { + regionCount: number; + linkCount: number; + elementCount: number; + }; + selection: { + regionIds: readonly string[]; + linkIds: readonly string[]; + selectionKeys: readonly string[]; + elementIds: readonly string[]; + sourceIds: readonly string[]; + aliases: readonly string[]; + reasonLabels: readonly string[]; + counts: { + regions: number; + links: number; + selectionKeys: number; + elementIds: number; + sourceIds: number; + aliases: number; + reasonLabels: number; + }; + omitted: { + regionIds: number; + linkIds: number; + selectionKeys: number; + elementIds: number; + sourceIds: number; + aliases: number; + reasonLabels: number; + }; + }; + elements: { + elementIds: readonly string[]; + count: number; + omittedElementIds: number; + byKind: Record; + byLayer: Record; + byTag: Record; + relations: { + parentElementIds: readonly string[]; + containerElementIds: readonly string[]; + parentCount: number; + containerCount: number; + omittedParentElementIds: number; + omittedContainerElementIds: number; + }; + }; + unresolved: PolyWorldUnresolvedSelection; + validationDiagnostics: readonly PolyWorldValidationDiagnostic[]; + metadata?: Record; +} + +export function createPolyWorldDebugSnapshot( + topology: PolyWorldTopology, + selection: PolyWorldSelection, + options: PolyWorldDebugSnapshotOptions = {}, +): PolyWorldDebugSnapshot { + const resolution = options.resolution ?? resolvePolyWorldElements(topology, selection, options.resolutionOptions); + const selectedRegionIds = unique(selection.regionIds); + const selectedLinkIds = unique(selection.linkIds); + const selectedSelectionKeys = unique(selection.selectionKeys); + const selectedElementIds = unique(selection.elementIds); + const selectedSourceIds = unique(selection.sourceIds); + const selectedAliases = unique(selection.aliases); + const reasonLabels = unique(selection.reasons?.map((reason) => reason.label)); + const limitedRegionIds = limitPolyWorldDebugList(selectedRegionIds, options.listLimit); + const limitedLinkIds = limitPolyWorldDebugList(selectedLinkIds, options.listLimit); + const limitedSelectionKeys = limitPolyWorldDebugList(selectedSelectionKeys, options.listLimit); + const limitedElementIds = limitPolyWorldDebugList(selectedElementIds, options.listLimit); + const limitedSourceIds = limitPolyWorldDebugList(selectedSourceIds, options.listLimit); + const limitedAliases = limitPolyWorldDebugList(selectedAliases, options.listLimit); + const limitedReasonLabels = limitPolyWorldDebugList(reasonLabels, options.listLimit); + const limitedResolutionElementIds = limitPolyWorldDebugList(resolution.elementIds, options.listLimit); + const parentElementIds = unique(resolution.elements.flatMap((element) => element.parentId === undefined ? [] : [element.parentId])); + const containerElementIds = unique(resolution.elements.flatMap((element) => element.containerId === undefined ? [] : [element.containerId])); + const limitedParentElementIds = limitPolyWorldDebugList(parentElementIds, options.listLimit); + const limitedContainerElementIds = limitPolyWorldDebugList(containerElementIds, options.listLimit); + + return { + schemaVersion: 1, + preparedOnly: options.preparedOnly ?? false, + topology: { + regionCount: topology.regions.length, + linkCount: topology.links.length, + elementCount: topology.elements.length, + }, + selection: { + regionIds: limitedRegionIds.values, + linkIds: limitedLinkIds.values, + selectionKeys: limitedSelectionKeys.values, + elementIds: limitedElementIds.values, + sourceIds: limitedSourceIds.values, + aliases: limitedAliases.values, + reasonLabels: limitedReasonLabels.values, + counts: { + regions: selectedRegionIds.length, + links: selectedLinkIds.length, + selectionKeys: selectedSelectionKeys.length, + elementIds: selectedElementIds.length, + sourceIds: selectedSourceIds.length, + aliases: selectedAliases.length, + reasonLabels: reasonLabels.length, + }, + omitted: { + regionIds: limitedRegionIds.omitted, + linkIds: limitedLinkIds.omitted, + selectionKeys: limitedSelectionKeys.omitted, + elementIds: limitedElementIds.omitted, + sourceIds: limitedSourceIds.omitted, + aliases: limitedAliases.omitted, + reasonLabels: limitedReasonLabels.omitted, + }, + }, + elements: { + elementIds: limitedResolutionElementIds.values, + count: resolution.elements.length, + omittedElementIds: limitedResolutionElementIds.omitted, + byKind: countElements(resolution.elements, (element) => element.kind), + byLayer: countElementValues(resolution.elements, (element) => element.layers), + byTag: countElementValues(resolution.elements, (element) => element.tags), + relations: { + parentElementIds: limitedParentElementIds.values, + containerElementIds: limitedContainerElementIds.values, + parentCount: parentElementIds.length, + containerCount: containerElementIds.length, + omittedParentElementIds: limitedParentElementIds.omitted, + omittedContainerElementIds: limitedContainerElementIds.omitted, + }, + }, + unresolved: resolution.unresolved, + validationDiagnostics: [...(options.validationDiagnostics ?? [])], + metadata: options.metadata, + }; +} + +export function adaptPolyWorldDebugSnapshot( + snapshot: PolyWorldDebugSnapshot, + adapter: (snapshot: PolyWorldDebugSnapshot) => T, +): T { + return adapter(snapshot); +} + +function countElements( + elements: readonly PolyWorldElement[], + resolveKey: (element: PolyWorldElement) => string | undefined, +): Record { + const counts: Record = {}; + for (const element of elements) { + const key = resolveKey(element); + if (key === undefined) continue; + counts[key] = (counts[key] ?? 0) + 1; + } + return sortRecord(counts); +} + +function countElementValues( + elements: readonly PolyWorldElement[], + resolveValues: (element: PolyWorldElement) => readonly string[] | undefined, +): Record { + const counts: Record = {}; + for (const element of elements) { + for (const value of resolveValues(element) ?? []) { + counts[value] = (counts[value] ?? 0) + 1; + } + } + return sortRecord(counts); +} + +function sortRecord(values: Record): Record { + return Object.fromEntries(Object.entries(values).sort(([a], [b]) => compareStrings(a, b))); +} + +function unique(values: readonly string[] | undefined): string[] { + return [...new Set(values ?? [])]; +} + +function compareStrings(a: string, b: string): number { + if (a < b) return -1; + if (a > b) return 1; + return 0; +} diff --git a/packages/world/src/dom/apply.ts b/packages/world/src/dom/apply.ts new file mode 100644 index 000000000..642a5ea6c --- /dev/null +++ b/packages/world/src/dom/apply.ts @@ -0,0 +1,315 @@ +import type { PolyWorldLayerPlan, PolyWorldPlanEntry } from "../planner"; +import type { PolyWorldPlanAction, PolyWorldPlanActionCounts, PolyWorldPlanCheckResult } from "../planner"; +import type { + PolyWorldDomApplyCounts, + PolyWorldDomApplyEntry, + PolyWorldDomApplyOptions, + PolyWorldDomApplyResult, + PolyWorldDomApplyStatus, + PolyWorldDomElementLike, + PolyWorldDomPlanInput, + PolyWorldDomRecord, +} from "./types"; +import type { PolyWorldDomRegistry } from "./registry"; + +const defaultApplyOptions: Required = { + hideMode: "remove", + syncHidden: true, +}; +const planActions: readonly PolyWorldPlanAction[] = ["show", "hide", "retain", "preload", "noop"]; + +export function applyPolyWorldDomPlan< + TElement extends PolyWorldDomElementLike = PolyWorldDomElementLike, +>( + registry: PolyWorldDomRegistry, + plan: PolyWorldDomPlanInput, + options: PolyWorldDomApplyOptions = {}, +): PolyWorldDomApplyResult { + const resolvedOptions = { ...defaultApplyOptions, ...options }; + const entries = planEntries(plan).map((entry) => applyEntry(registry, entry, resolvedOptions)); + const counts = countApplyEntries(entries, registry.mountedElementIds().length); + + return { + previousSignature: isLayerPlan(plan) ? plan.previousSignature : undefined, + nextSignature: isLayerPlan(plan) ? plan.nextSignature : undefined, + planChanged: isLayerPlan(plan) ? plan.changed : undefined, + entries, + actionCounts: countPlanActions(entries), + counts, + plannedElementIds: uniqueSorted(entries.map((entry) => entry.elementId).filter(isString)), + addedElementIds: statusElementIds(entries, "added"), + hiddenAppliedElementIds: statusElementIds(entries, "hidden"), + removedElementIds: statusElementIds(entries, "removed"), + retainedElementIds: statusElementIds(entries, "retained"), + noopElementIds: statusElementIds(entries, "noop"), + changedElementIds: uniqueSorted( + entries.filter((entry) => entry.changed).map((entry) => entry.elementId).filter(isString), + ), + missingElementIds: uniqueSorted( + entries.filter((entry) => entry.status === "missing").map((entry) => entry.elementId).filter(isString), + ), + blockedElementIds: uniqueSorted( + entries.filter((entry) => entry.status === "blocked").map((entry) => entry.elementId).filter(isString), + ), + mountBlockedElementIds: uniqueSorted( + entries + .filter((entry) => + entry.status === "blocked" && + entry.failedGuards.length === 0 && + entry.failedDependencies.length === 0 + ) + .map((entry) => entry.elementId) + .filter(isString), + ), + guardFailureElementIds: uniqueSorted( + entries.filter((entry) => entry.failedGuards.length > 0).map((entry) => entry.elementId).filter(isString), + ), + dependencyFailureElementIds: uniqueSorted( + entries.filter((entry) => entry.failedDependencies.length > 0).map((entry) => entry.elementId).filter(isString), + ), + unsupportedElementIds: uniqueSorted( + entries.filter((entry) => entry.status === "unsupported").map((entry) => entry.elementId).filter(isString), + ), + mountedElementIds: registry.mountedElementIds(), + hiddenElementIds: registry.hiddenElementIds(), + }; +} + +function applyEntry( + registry: PolyWorldDomRegistry, + planEntry: PolyWorldPlanEntry, + options: Required, +): PolyWorldDomApplyEntry { + const elementId = planEntry.elementId; + const record = elementId === undefined ? undefined : registry.getByElementId(elementId); + const failedGuards = failedChecks(planEntry.guards); + const failedDependencies = failedChecks(planEntry.dependencies); + + if (failedGuards.length > 0 || failedDependencies.length > 0) { + return entryResult( + planEntry, + record, + "blocked", + false, + record?.mounted ?? false, + failedGuards.length > 0 + ? "Plan entry guard failed." + : "Plan entry dependency failed.", + ); + } + + if (planEntry.action === "noop") { + if (elementId !== undefined && record === undefined) { + return entryResult(planEntry, undefined, "missing", false, false, "No DOM record is registered for this element."); + } + return entryResult(planEntry, record, "noop", false, record?.mounted ?? false); + } + + if (planEntry.action === "preload") { + return entryResult( + planEntry, + record, + "unsupported", + false, + record?.mounted ?? false, + "preload is not applied by the DOM layer.", + ); + } + + if (elementId === undefined) { + return entryResult(planEntry, undefined, "missing", false, false, "Plan entry has no elementId."); + } + if (record === undefined) { + return entryResult(planEntry, undefined, "missing", false, false, "No DOM record is registered for this element."); + } + + if (planEntry.action === "show") return showRecord(registry, planEntry, record, options); + if (planEntry.action === "hide") return hideRecord(planEntry, record, options); + if (planEntry.action === "retain") { + if (options.syncHidden && record.mounted) setHidden(record.element, false); + return entryResult(planEntry, record, "retained", false, record.mounted); + } + + return entryResult(planEntry, record, "noop", false, record.mounted); +} + +function showRecord( + registry: PolyWorldDomRegistry, + planEntry: PolyWorldPlanEntry, + record: PolyWorldDomRecord, + options: Required, +): PolyWorldDomApplyEntry { + if (record.mounted) { + const changed = options.syncHidden && isHidden(record.element); + if (options.syncHidden) setHidden(record.element, false); + return entryResult(planEntry, record, "retained", changed, true); + } + + if (record.parent === null) { + return entryResult(planEntry, record, "blocked", false, false, "Cannot mount record without a parent."); + } + + const before = findNextMountedElement(registry, record); + record.parent.insertBefore(record.element, before); + record.mounted = true; + if (options.syncHidden) setHidden(record.element, false); + + return entryResult(planEntry, record, "added", true, true); +} + +function hideRecord( + planEntry: PolyWorldPlanEntry, + record: PolyWorldDomRecord, + options: Required, +): PolyWorldDomApplyEntry { + if (!record.mounted) { + if (options.syncHidden) setHidden(record.element, true); + return entryResult(planEntry, record, "noop", false, false); + } + + if (options.hideMode === "hidden") { + const changed = options.syncHidden && !isHidden(record.element); + if (options.syncHidden) setHidden(record.element, true); + return entryResult(planEntry, record, "hidden", changed, true); + } + + if (options.syncHidden) setHidden(record.element, true); + record.element.remove(); + record.mounted = false; + + return entryResult(planEntry, record, "removed", true, false); +} + +function findNextMountedElement( + registry: PolyWorldDomRegistry, + record: PolyWorldDomRecord, +): TElement | null { + const visited = new Set(); + let nextElementId = record.nextElementId; + + while (nextElementId !== undefined && !visited.has(nextElementId)) { + visited.add(nextElementId); + const nextRecord = registry.getByElementId(nextElementId); + if (nextRecord === undefined) return null; + if (nextRecord.mounted && nextRecord.parent === record.parent) return nextRecord.element; + nextElementId = nextRecord.nextElementId; + } + + return null; +} + +function setHidden(element: PolyWorldDomElementLike, hidden: boolean): void { + element.hidden = hidden; + if (hidden) { + element.setAttribute?.("hidden", ""); + return; + } + element.removeAttribute?.("hidden"); +} + +function isHidden(element: PolyWorldDomElementLike): boolean { + return element.hidden === true; +} + +function entryResult( + planEntry: PolyWorldPlanEntry, + record: PolyWorldDomRecord | undefined, + status: PolyWorldDomApplyStatus, + changed: boolean, + mounted: boolean, + message?: string, +): PolyWorldDomApplyEntry { + return { + key: planEntry.key, + action: planEntry.action, + status, + layer: planEntry.layer, + elementId: planEntry.elementId, + policyId: planEntry.policyId, + mounted, + changed, + guards: [...(planEntry.guards ?? [])], + dependencies: [...(planEntry.dependencies ?? [])], + failedGuards: failedChecks(planEntry.guards), + failedDependencies: failedChecks(planEntry.dependencies), + reasonLabels: planEntry.reasonLabels, + message, + planEntry, + record, + }; +} + +function countApplyEntries( + entries: readonly PolyWorldDomApplyEntry[], + mountedCount: number, +): PolyWorldDomApplyCounts { + const counts = emptyCounts(); + for (const entry of entries) { + counts[entry.status] += 1; + if (entry.changed) counts.changed += 1; + } + counts.mounted = mountedCount; + return counts; +} + +function statusElementIds( + entries: readonly PolyWorldDomApplyEntry[], + status: PolyWorldDomApplyStatus, +): readonly string[] { + return uniqueSorted( + entries.filter((entry) => entry.status === status).map((entry) => entry.elementId).filter(isString), + ); +} + +function failedChecks( + checks: readonly PolyWorldPlanCheckResult[] | undefined, +): readonly PolyWorldPlanCheckResult[] { + return checks?.filter((check) => check.ok === false) ?? []; +} + +function countPlanActions(entries: readonly PolyWorldDomApplyEntry[]): PolyWorldPlanActionCounts { + const counts = emptyActionCounts(); + for (const entry of entries) counts[entry.action] += 1; + return counts; +} + +function emptyCounts(): PolyWorldDomApplyCounts { + return { + added: 0, + hidden: 0, + removed: 0, + retained: 0, + noop: 0, + missing: 0, + blocked: 0, + unsupported: 0, + changed: 0, + mounted: 0, + }; +} + +function emptyActionCounts(): PolyWorldPlanActionCounts { + return Object.fromEntries(planActions.map((action) => [action, 0])) as PolyWorldPlanActionCounts; +} + +function planEntries(plan: PolyWorldDomPlanInput): readonly PolyWorldPlanEntry[] { + return isLayerPlan(plan) ? plan.entries : plan; +} + +function isLayerPlan(plan: PolyWorldDomPlanInput): plan is PolyWorldLayerPlan { + return !Array.isArray(plan) && "entries" in plan; +} + +function uniqueSorted(values: readonly string[]): readonly string[] { + return [...new Set(values)].sort(compareStrings); +} + +function compareStrings(a: string, b: string): number { + if (a < b) return -1; + if (a > b) return 1; + return 0; +} + +function isString(value: string | undefined): value is string { + return value !== undefined; +} diff --git a/packages/world/src/dom/dom.test.ts b/packages/world/src/dom/dom.test.ts new file mode 100644 index 000000000..16e880346 --- /dev/null +++ b/packages/world/src/dom/dom.test.ts @@ -0,0 +1,686 @@ +import { describe, expect, it } from "vitest"; +import { planPolyWorldBspVisibilityFrame } from "../profiles"; +import { planPolyWorldElementSet, planPolyWorldLayers } from "../planner"; +import { createPolyWorldState, diffPolyWorldState } from "../state"; +import { createPolyWorldPartitionGalleryFixture } from "../testing/fixtures"; +import { createPolyWorldTopology } from "../topology"; +import { + PolyWorldDomRegistryError, + applyPolyWorldDomPlan, + createPolyWorldDomRegistry, +} from "./index"; +import type { PolyWorldDomElementLike, PolyWorldDomParentLike } from "./index"; + +class FakeElement implements PolyWorldDomElementLike { + parentNode: FakeParent | null = null; + hidden = false; + readonly attributes = new Map(); + + constructor(readonly id: string) {} + + remove(): void { + this.parentNode?.removeChild(this); + } + + setAttribute(name: string, value: string): void { + this.attributes.set(name, value); + } + + removeAttribute(name: string): void { + this.attributes.delete(name); + } +} + +class FakeParent implements PolyWorldDomParentLike { + readonly children: FakeElement[] = []; + + insertBefore(element: FakeElement, before: FakeElement | null): void { + this.removeChild(element); + const index = before === null ? -1 : this.children.indexOf(before); + if (index === -1) { + this.children.push(element); + } else { + this.children.splice(index, 0, element); + } + element.parentNode = this; + } + + removeChild(element: FakeElement): void { + const index = this.children.indexOf(element); + if (index !== -1) this.children.splice(index, 1); + if (element.parentNode === this) element.parentNode = null; + } + + ids(): string[] { + return this.children.map((element) => element.id); + } +} + +describe("createPolyWorldDomRegistry", () => { + it("registers DOM-like records and indexes element, source, alias, layer, and tag lookups", () => { + const parent = new FakeParent(); + const shell45 = new FakeElement("shell-45"); + parent.insertBefore(shell45, null); + + const registry = createPolyWorldDomRegistry([ + { + elementId: "shell-45", + element: shell45, + parent, + sourceIds: ["face:45", "face:45"], + aliases: ["group:45"], + layers: ["render"], + tags: ["solid", "visible"], + }, + { + elementId: "portal-debug", + element: new FakeElement("portal-debug"), + sourceIds: ["portal:45:14"], + aliases: ["portal:45:14"], + layers: ["debug"], + tags: ["portal"], + }, + ]); + + expect(registry.getByElementId("shell-45")?.mounted).toBe(true); + expect(registry.getByElementId("shell-45")?.sourceIds).toEqual(["face:45"]); + expect(registry.getBySourceId("face:45").map((record) => record.elementId)).toEqual(["shell-45"]); + expect(registry.getByAlias("group:45").map((record) => record.elementId)).toEqual(["shell-45"]); + expect(registry.getByLayer("render").map((record) => record.elementId)).toEqual(["shell-45"]); + expect(registry.getByTag("portal").map((record) => record.elementId)).toEqual(["portal-debug"]); + expect(registry.mountedElementIds()).toEqual(["shell-45"]); + }); + + it("rejects duplicate element ids but lets update replace a record and rebuild indexes", () => { + const registry = createPolyWorldDomRegistry([ + { + elementId: "track-1", + element: new FakeElement("track-1"), + sourceIds: ["source:old"], + layers: ["old"], + }, + ]); + + expect(() => + registry.register({ + elementId: "track-1", + element: new FakeElement("track-1-copy"), + }), + ).toThrow(PolyWorldDomRegistryError); + + registry.update({ + elementId: "track-1", + element: new FakeElement("track-1-next"), + sourceIds: ["source:new"], + layers: ["render"], + }); + + expect(registry.getBySourceId("source:old")).toEqual([]); + expect(registry.getBySourceId("source:new").map((record) => record.elementId)).toEqual(["track-1"]); + expect(registry.getByLayer("old")).toEqual([]); + expect(registry.getByLayer("render").map((record) => record.elementId)).toEqual(["track-1"]); + }); +}); + +describe("applyPolyWorldDomPlan", () => { + it("applies external BSP/PVS face visibility without requiring a topology graph", () => { + const parent = new FakeParent(); + const face0 = new FakeElement("face-0"); + const face1 = new FakeElement("face-1"); + const face2 = new FakeElement("face-2"); + const face3 = new FakeElement("face-3"); + const face4 = new FakeElement("face-4"); + parent.insertBefore(face0, null); + parent.insertBefore(face2, null); + parent.insertBefore(face4, null); + const registry = createPolyWorldDomRegistry([ + { + elementId: "face-0", + element: face0, + parent, + mounted: true, + nextElementId: "face-1", + sourceIds: ["quake-face:0"], + layers: ["render"], + tags: ["quake-leaf", "world"], + }, + { + elementId: "face-1", + element: face1, + parent, + previousElementId: "face-0", + nextElementId: "face-2", + sourceIds: ["quake-face:1"], + layers: ["render"], + tags: ["quake-leaf", "world"], + }, + { + elementId: "face-2", + element: face2, + parent, + mounted: true, + previousElementId: "face-1", + nextElementId: "face-3", + sourceIds: ["quake-face:2"], + layers: ["render"], + tags: ["quake-leaf", "world"], + }, + { + elementId: "face-3", + element: face3, + parent, + previousElementId: "face-2", + nextElementId: "face-4", + sourceIds: ["quake-face:3"], + layers: ["render"], + tags: ["quake-leaf", "world"], + }, + { + elementId: "face-4", + element: face4, + parent, + mounted: true, + previousElementId: "face-3", + sourceIds: ["quake-face:4"], + layers: ["render"], + tags: ["quake-leaf", "world"], + }, + ]); + const plan = planPolyWorldElementSet({ + previousElementIds: ["face-0", "face-2", "face-4"], + nextElementIds: ["face-1", "face-2", "face-3"], + policyId: "quake-pvs", + reasonLabels: ["quake-pvs:e1m1:leaf-42"], + }); + + const result = applyPolyWorldDomPlan(registry, plan); + + expect(parent.ids()).toEqual(["face-1", "face-2", "face-3"]); + expect(result.entries.map((entry) => [entry.elementId, entry.action, entry.status, entry.reasonLabels])).toEqual([ + ["face-0", "hide", "removed", ["quake-pvs:e1m1:leaf-42"]], + ["face-4", "hide", "removed", ["quake-pvs:e1m1:leaf-42"]], + ["face-1", "show", "added", ["quake-pvs:e1m1:leaf-42"]], + ["face-3", "show", "added", ["quake-pvs:e1m1:leaf-42"]], + ["face-2", "retain", "retained", ["quake-pvs:e1m1:leaf-42"]], + ]); + expect(result.removedElementIds).toEqual(["face-0", "face-4"]); + expect(result.addedElementIds).toEqual(["face-1", "face-3"]); + expect(result.retainedElementIds).toEqual(["face-2"]); + expect(result.mountedElementIds).toEqual(["face-1", "face-2", "face-3"]); + }); + + it("applies portal-room visibility with stable prepared order", () => { + const topology = createPolyWorldTopology({ + regions: [ + { id: "group-45" }, + { id: "group-14" }, + { id: "group-15" }, + ], + elements: [ + { id: "shell-14", regionIds: ["group-14"], layers: ["render"], tags: ["solid"] }, + { id: "shell-15", regionIds: ["group-15"], layers: ["render"], tags: ["solid"] }, + { id: "shell-45", regionIds: ["group-45"], layers: ["render"], tags: ["solid"] }, + ], + }); + const previous = createPolyWorldState(topology, { selection: { regionIds: ["group-45"] } }); + const next = createPolyWorldState(topology, { + selection: { regionIds: ["group-45", "group-14", "group-15"] }, + }); + const plan = planPolyWorldLayers(topology, diffPolyWorldState(previous, next), [ + { id: "render", layer: "render", elementLayers: ["render"] }, + ]); + + const parent = new FakeParent(); + const shell14 = new FakeElement("shell-14"); + const shell15 = new FakeElement("shell-15"); + const shell45 = new FakeElement("shell-45"); + parent.insertBefore(shell45, null); + const registry = createPolyWorldDomRegistry([ + { + elementId: "shell-14", + element: shell14, + parent, + previousElementId: undefined, + nextElementId: "shell-15", + sourceIds: ["group:14"], + layers: ["render"], + tags: ["solid"], + }, + { + elementId: "shell-15", + element: shell15, + parent, + previousElementId: "shell-14", + nextElementId: "shell-45", + sourceIds: ["group:15"], + layers: ["render"], + tags: ["solid"], + }, + { + elementId: "shell-45", + element: shell45, + parent, + mounted: true, + previousElementId: "shell-15", + sourceIds: ["group:45"], + layers: ["render"], + tags: ["solid"], + }, + ]); + + const result = applyPolyWorldDomPlan(registry, plan); + + expect(parent.ids()).toEqual(["shell-14", "shell-15", "shell-45"]); + expect(result.entries.map((entry) => [entry.elementId, entry.action, entry.status])).toEqual([ + ["shell-14", "show", "added"], + ["shell-15", "show", "added"], + ["shell-45", "retain", "retained"], + ]); + expect(result.counts).toEqual({ + added: 2, + hidden: 0, + removed: 0, + retained: 1, + noop: 0, + missing: 0, + blocked: 0, + unsupported: 0, + changed: 2, + mounted: 3, + }); + expect(result.actionCounts).toEqual({ + show: 2, + hide: 0, + retain: 1, + preload: 0, + noop: 0, + }); + expect(result.plannedElementIds).toEqual(["shell-14", "shell-15", "shell-45"]); + expect(result.addedElementIds).toEqual(["shell-14", "shell-15"]); + expect(result.retainedElementIds).toEqual(["shell-45"]); + expect(result.changedElementIds).toEqual(["shell-14", "shell-15"]); + expect(result.mountedElementIds).toEqual(["shell-14", "shell-15", "shell-45"]); + expect(shell14.hidden).toBe(false); + expect(shell15.hidden).toBe(false); + }); + + it("removes hidden elements and keeps retained elements mounted", () => { + const topology = createPolyWorldTopology({ + regions: [ + { id: "group-45" }, + { id: "group-14" }, + { id: "group-15" }, + ], + elements: [ + { id: "shell-14", regionIds: ["group-14"], layers: ["render"] }, + { id: "shell-15", regionIds: ["group-15"], layers: ["render"] }, + { id: "shell-45", regionIds: ["group-45"], layers: ["render"] }, + ], + }); + const previous = createPolyWorldState(topology, { + selection: { regionIds: ["group-45", "group-14", "group-15"] }, + }); + const next = createPolyWorldState(topology, { selection: { regionIds: ["group-45"] } }); + const plan = planPolyWorldLayers(topology, diffPolyWorldState(previous, next), [ + { id: "render", layer: "render", elementLayers: ["render"] }, + ]); + const parent = new FakeParent(); + const shell14 = new FakeElement("shell-14"); + const shell15 = new FakeElement("shell-15"); + const shell45 = new FakeElement("shell-45"); + parent.insertBefore(shell14, null); + parent.insertBefore(shell15, null); + parent.insertBefore(shell45, null); + const registry = createPolyWorldDomRegistry([ + { elementId: "shell-14", element: shell14, parent, mounted: true, layers: ["render"] }, + { elementId: "shell-15", element: shell15, parent, mounted: true, layers: ["render"] }, + { elementId: "shell-45", element: shell45, parent, mounted: true, layers: ["render"] }, + ]); + + const result = applyPolyWorldDomPlan(registry, plan); + + expect(parent.ids()).toEqual(["shell-45"]); + expect(result.entries.map((entry) => [entry.elementId, entry.action, entry.status, entry.mounted])).toEqual([ + ["shell-14", "hide", "removed", false], + ["shell-15", "hide", "removed", false], + ["shell-45", "retain", "retained", true], + ]); + expect(result.counts.removed).toBe(2); + expect(result.counts.mounted).toBe(1); + expect(result.removedElementIds).toEqual(["shell-14", "shell-15"]); + expect(result.retainedElementIds).toEqual(["shell-45"]); + expect(result.changedElementIds).toEqual(["shell-14", "shell-15"]); + expect(shell14.hidden).toBe(true); + expect(shell14.attributes.get("hidden")).toBe(""); + expect(shell15.hidden).toBe(true); + }); + + it("can hide elements without detaching prepared DOM elements", () => { + const topology = createPolyWorldTopology({ + regions: [ + { id: "region-road" }, + { id: "region-overlay" }, + ], + elements: [ + { id: "road-leaf", regionIds: ["region-road"], layers: ["render"], tags: ["prepared-leaf"] }, + { id: "overlay-leaf", regionIds: ["region-overlay"], layers: ["render"], tags: ["prepared-leaf"] }, + ], + }); + const previous = createPolyWorldState(topology, { + selection: { regionIds: ["region-road", "region-overlay"] }, + }); + const next = createPolyWorldState(topology, { selection: { regionIds: ["region-road"] } }); + const plan = planPolyWorldLayers(topology, diffPolyWorldState(previous, next), [ + { id: "prepared-leaves", layer: "render", elementLayers: ["render"], tags: ["prepared-leaf"] }, + ]); + const parent = new FakeParent(); + const road = new FakeElement("road-leaf"); + const overlay = new FakeElement("overlay-leaf"); + parent.insertBefore(road, null); + parent.insertBefore(overlay, null); + const registry = createPolyWorldDomRegistry([ + { elementId: "road-leaf", element: road, parent, mounted: true, layers: ["render"], tags: ["prepared-leaf"] }, + { elementId: "overlay-leaf", element: overlay, parent, mounted: true, layers: ["render"], tags: ["prepared-leaf"] }, + ]); + + const hiddenResult = applyPolyWorldDomPlan(registry, plan, { hideMode: "hidden" }); + + expect(parent.ids()).toEqual(["road-leaf", "overlay-leaf"]); + expect(hiddenResult.entries.map((entry) => [entry.elementId, entry.action, entry.status, entry.mounted])).toEqual([ + ["overlay-leaf", "hide", "hidden", true], + ["road-leaf", "retain", "retained", true], + ]); + expect(hiddenResult.counts.hidden).toBe(1); + expect(hiddenResult.counts.removed).toBe(0); + expect(hiddenResult.counts.mounted).toBe(2); + expect(hiddenResult.counts.changed).toBe(1); + expect(hiddenResult.mountedElementIds).toEqual(["road-leaf", "overlay-leaf"]); + expect(hiddenResult.hiddenElementIds).toEqual(["overlay-leaf"]); + expect(overlay.hidden).toBe(true); + expect(overlay.attributes.get("hidden")).toBe(""); + + const restoredPlan = planPolyWorldLayers(topology, diffPolyWorldState(next, previous), [ + { id: "prepared-leaves", layer: "render", elementLayers: ["render"], tags: ["prepared-leaf"] }, + ]); + const restoredResult = applyPolyWorldDomPlan(registry, restoredPlan, { hideMode: "hidden" }); + + expect(parent.ids()).toEqual(["road-leaf", "overlay-leaf"]); + expect(restoredResult.entries.map((entry) => [entry.elementId, entry.action, entry.status, entry.mounted])).toEqual([ + ["overlay-leaf", "show", "retained", true], + ["road-leaf", "retain", "retained", true], + ]); + expect(restoredResult.counts.changed).toBe(1); + expect(restoredResult.hiddenElementIds).toEqual([]); + expect(overlay.hidden).toBe(false); + expect(overlay.attributes.has("hidden")).toBe(false); + }); + + it("applies chunk-window visibility while app-owned dynamic records stay out of plan policy", () => { + const topology = createPolyWorldTopology({ + regions: [ + { id: "chunk-1" }, + { id: "chunk-2" }, + { id: "chunk-3" }, + ], + elements: [ + { id: "track-1", regionIds: ["chunk-1"], layers: ["render"], tags: ["source-track"], sourceIds: ["track:1"] }, + { id: "track-2", regionIds: ["chunk-2"], layers: ["render"], tags: ["source-track"], sourceIds: ["track:2"] }, + { id: "track-3", regionIds: ["chunk-3"], layers: ["render"], tags: ["source-track"], sourceIds: ["track:3"] }, + { id: "car", regionIds: ["chunk-2"], layers: ["dynamic"], tags: ["dynamic"] }, + { id: "sky", regionIds: ["chunk-2"], layers: ["persistent"], tags: ["persistent"] }, + ], + }); + const previous = createPolyWorldState(topology, { selection: { regionIds: ["chunk-1"] } }); + const next = createPolyWorldState(topology, { + selection: { regionIds: ["chunk-1", "chunk-2", "chunk-3"] }, + }); + const plan = planPolyWorldLayers(topology, diffPolyWorldState(previous, next), [ + { id: "track-window", layer: "render", elementLayers: ["render"], tags: ["source-track"] }, + ]); + const parent = new FakeParent(); + const track1 = new FakeElement("track-1"); + const track2 = new FakeElement("track-2"); + const track3 = new FakeElement("track-3"); + const car = new FakeElement("car"); + parent.insertBefore(track1, null); + parent.insertBefore(car, null); + const registry = createPolyWorldDomRegistry([ + { + elementId: "track-1", + element: track1, + parent, + mounted: true, + nextElementId: "track-2", + sourceIds: ["track:1"], + layers: ["render"], + tags: ["source-track"], + }, + { + elementId: "track-2", + element: track2, + parent, + previousElementId: "track-1", + nextElementId: "track-3", + sourceIds: ["track:2"], + layers: ["render"], + tags: ["source-track"], + }, + { + elementId: "track-3", + element: track3, + parent, + previousElementId: "track-2", + sourceIds: ["track:3"], + layers: ["render"], + tags: ["source-track"], + }, + { elementId: "car", element: car, parent, mounted: true, layers: ["dynamic"], tags: ["dynamic"] }, + { elementId: "sky", element: new FakeElement("sky"), layers: ["persistent"], tags: ["persistent"] }, + ]); + + const result = applyPolyWorldDomPlan(registry, plan); + + expect(result.entries.map((entry) => [entry.elementId, entry.action, entry.status])).toEqual([ + ["track-2", "show", "added"], + ["track-3", "show", "added"], + ["track-1", "retain", "retained"], + ]); + expect(parent.ids()).toEqual(["track-1", "car", "track-2", "track-3"]); + expect(registry.getBySourceId("track:2").map((record) => record.elementId)).toEqual(["track-2"]); + expect(result.mountedElementIds).toEqual(["track-1", "track-2", "track-3", "car"]); + expect(result.entries.some((entry) => entry.elementId === "car")).toBe(false); + expect(result.entries.some((entry) => entry.elementId === "sky")).toBe(false); + }); + + it("reports missing, blocked, and unsupported preload entries without mutation", () => { + const blocked = new FakeElement("blocked"); + const mounted = new FakeElement("mounted"); + const parent = new FakeParent(); + parent.insertBefore(mounted, null); + const registry = createPolyWorldDomRegistry([ + { elementId: "blocked", element: blocked }, + { elementId: "mounted", element: mounted, parent, mounted: true }, + ]); + + const result = applyPolyWorldDomPlan(registry, [ + planEntry("missing", "show"), + planEntry("missing-noop", "noop"), + planEntry("blocked", "show"), + planEntry("mounted", "preload"), + planEntry("mounted", "noop"), + { + key: "summary:noop", + policyId: "summary", + layer: "summary", + action: "noop", + reason: "no-match", + reasonLabels: [], + }, + ]); + + expect(parent.ids()).toEqual(["mounted"]); + expect(result.entries.map((entry) => [ + entry.elementId, + entry.action, + entry.status, + entry.mounted, + entry.message, + ])).toEqual([ + ["missing", "show", "missing", false, "No DOM record is registered for this element."], + ["missing-noop", "noop", "missing", false, "No DOM record is registered for this element."], + ["blocked", "show", "blocked", false, "Cannot mount record without a parent."], + ["mounted", "preload", "unsupported", true, "preload is not applied by the DOM layer."], + ["mounted", "noop", "noop", true, undefined], + [undefined, "noop", "noop", false, undefined], + ]); + expect(result.counts).toEqual({ + added: 0, + hidden: 0, + removed: 0, + retained: 0, + noop: 2, + missing: 2, + blocked: 1, + unsupported: 1, + changed: 0, + mounted: 1, + }); + expect(result.actionCounts).toEqual({ + show: 2, + hide: 0, + retain: 0, + preload: 1, + noop: 3, + }); + expect(result.missingElementIds).toEqual(["missing", "missing-noop"]); + expect(result.blockedElementIds).toEqual(["blocked"]); + expect(result.mountBlockedElementIds).toEqual(["blocked"]); + expect(result.unsupportedElementIds).toEqual(["mounted"]); + }); + + it("blocks guarded and dependency-gated entries without mutating DOM records", () => { + const guarded = new FakeElement("guarded"); + const dependent = new FakeElement("dependent"); + const parent = new FakeParent(); + const registry = createPolyWorldDomRegistry([ + { elementId: "guarded", element: guarded, parent }, + { elementId: "dependent", element: dependent, parent }, + ]); + + const result = applyPolyWorldDomPlan(registry, [ + { + ...planEntry("guarded", "show"), + guards: [ + { id: "asset-ready", ok: false, message: "Texture group is not ready." }, + ], + }, + { + ...planEntry("dependent", "show"), + dependencies: [ + { id: "parent-mounted", ok: false, message: "Chunk root is not mounted." }, + ], + }, + ]); + + expect(parent.ids()).toEqual([]); + expect(result.entries.map((entry) => [ + entry.elementId, + entry.status, + entry.message, + entry.failedGuards.map((guard) => guard.id), + entry.failedDependencies.map((dependency) => dependency.id), + ])).toEqual([ + ["guarded", "blocked", "Plan entry guard failed.", ["asset-ready"], []], + ["dependent", "blocked", "Plan entry dependency failed.", [], ["parent-mounted"]], + ]); + expect(result.blockedElementIds).toEqual(["dependent", "guarded"]); + expect(result.mountBlockedElementIds).toEqual([]); + expect(result.guardFailureElementIds).toEqual(["guarded"]); + expect(result.dependencyFailureElementIds).toEqual(["dependent"]); + expect(result.mountedElementIds).toEqual([]); + }); + + it("applies partition-gallery BSP frames while retaining shared structure and hiding detail", () => { + const fixture = createPolyWorldPartitionGalleryFixture(); + const parent = new FakeParent(); + const registry = createPolyWorldDomRegistry(fixture.topology.elements.map((element) => ({ + elementId: element.id, + element: new FakeElement(element.id), + parent, + layers: element.layers, + tags: element.tags, + }))); + const previousState = createPolyWorldState(fixture.topology); + const policies = [{ id: "render-world", layer: "world", elementLayers: ["world"] }]; + + const westFrame = planPolyWorldBspVisibilityFrame(fixture.topology, fixture.tree, { + previousState, + policies, + leafId: "gallery", + point: fixture.points.gallery, + forward: fixture.points.westView, + up: [0, 0, 1], + aspect: 1, + fovDegrees: 68, + projection: "xy", + includeTrace: true, + surfaces: fixture.surfaces, + }); + const westApply = applyPolyWorldDomPlan(registry, westFrame.plan, { hideMode: "hidden" }); + + expect(westApply.addedElementIds).toEqual(expect.arrayContaining([ + "gallery-floor-element", + "studio-floor-element", + "studio-prop-element", + ])); + expect(westApply.removedElementIds).toEqual([]); + + const eastFrame = planPolyWorldBspVisibilityFrame(fixture.topology, fixture.tree, { + previousState: westFrame.nextState, + policies, + leafId: "gallery", + point: fixture.points.gallery, + forward: fixture.points.eastView, + up: [0, 0, 1], + aspect: 1, + fovDegrees: 68, + projection: "xy", + includeTrace: true, + surfaces: fixture.surfaces, + }); + const eastApply = applyPolyWorldDomPlan(registry, eastFrame.plan, { hideMode: "hidden" }); + + expect(eastApply.retainedElementIds).toEqual(expect.arrayContaining([ + "gallery-floor-element", + "gallery-ceiling-element", + ])); + expect(eastApply.addedElementIds).toEqual(expect.arrayContaining([ + "vault-floor-element", + "vault-ceiling-element", + ])); + expect(eastApply.hiddenAppliedElementIds).toEqual(expect.arrayContaining([ + "studio-prop-element", + ])); + expect(eastApply.removedElementIds).toEqual([]); + expect(eastApply.hiddenElementIds).toEqual(expect.arrayContaining([ + "studio-prop-element", + ])); + expect(eastApply.mountedElementIds).toEqual(expect.arrayContaining([ + "gallery-floor-element", + "studio-prop-element", + "vault-floor-element", + ])); + }); +}); + +function planEntry(elementId: string, action: "show" | "hide" | "retain" | "preload" | "noop") { + return { + key: `render:${elementId}:${action}`, + policyId: "render", + layer: "render", + elementId, + action, + reason: action === "hide" ? "removed" : "added", + reasonLabels: [], + } as const; +} diff --git a/packages/world/src/dom/index.ts b/packages/world/src/dom/index.ts new file mode 100644 index 000000000..54ae827e2 --- /dev/null +++ b/packages/world/src/dom/index.ts @@ -0,0 +1,20 @@ +export { applyPolyWorldDomPlan } from "./apply"; +export { + PolyWorldDomRegistry, + PolyWorldDomRegistryError, + createPolyWorldDomRegistry, + validatePolyWorldDomRecord, +} from "./registry"; +export type { + PolyWorldDomApplyCounts, + PolyWorldDomApplyEntry, + PolyWorldDomApplyOptions, + PolyWorldDomApplyResult, + PolyWorldDomApplyStatus, + PolyWorldDomElementLike, + PolyWorldDomParentLike, + PolyWorldDomPlanInput, + PolyWorldDomRecord, + PolyWorldDomRecordInput, + PolyWorldDomValidationDiagnostic, +} from "./types"; diff --git a/packages/world/src/dom/registry.ts b/packages/world/src/dom/registry.ts new file mode 100644 index 000000000..11da6a1b1 --- /dev/null +++ b/packages/world/src/dom/registry.ts @@ -0,0 +1,293 @@ +import type { + PolyWorldDomElementLike, + PolyWorldDomParentLike, + PolyWorldDomRecord, + PolyWorldDomRecordInput, + PolyWorldDomValidationDiagnostic, +} from "./types"; + +export class PolyWorldDomRegistryError extends Error { + readonly diagnostics: readonly PolyWorldDomValidationDiagnostic[]; + + constructor(diagnostics: readonly PolyWorldDomValidationDiagnostic[]) { + super(diagnostics.map((diagnostic) => diagnostic.message).join("\n")); + this.name = "PolyWorldDomRegistryError"; + this.diagnostics = diagnostics; + } +} + +export class PolyWorldDomRegistry< + TElement extends PolyWorldDomElementLike = PolyWorldDomElementLike, +> { + #recordsByElementId = new Map>(); + #recordsBySourceId = new Map[]>(); + #recordsByAlias = new Map[]>(); + #recordsByLayer = new Map[]>(); + #recordsByTag = new Map[]>(); + + constructor(records: readonly PolyWorldDomRecordInput[] = []) { + for (const record of records) this.register(record); + } + + get records(): readonly PolyWorldDomRecord[] { + return [...this.#recordsByElementId.values()]; + } + + get recordsByElementId(): ReadonlyMap> { + return this.#recordsByElementId; + } + + get recordsBySourceId(): ReadonlyMap[]> { + return this.#recordsBySourceId; + } + + get recordsByAlias(): ReadonlyMap[]> { + return this.#recordsByAlias; + } + + get recordsByLayer(): ReadonlyMap[]> { + return this.#recordsByLayer; + } + + get recordsByTag(): ReadonlyMap[]> { + return this.#recordsByTag; + } + + register(input: PolyWorldDomRecordInput): PolyWorldDomRecord { + const diagnostics = validatePolyWorldDomRecord(input); + if (this.#recordsByElementId.has(input.elementId)) { + diagnostics.push({ + code: "poly-world-dom-duplicate-element-id", + message: `Duplicate PolyWorld DOM record element id "${input.elementId}".`, + elementId: input.elementId, + field: "elementId", + }); + } + if (diagnostics.length > 0) throw new PolyWorldDomRegistryError(diagnostics); + + const record = normalizeRecord(input); + this.#recordsByElementId.set(record.elementId, record); + this.#indexRecord(record); + return record; + } + + update(input: PolyWorldDomRecordInput): PolyWorldDomRecord { + const diagnostics = validatePolyWorldDomRecord(input); + if (diagnostics.length > 0) throw new PolyWorldDomRegistryError(diagnostics); + + const existing = this.#recordsByElementId.get(input.elementId); + if (existing !== undefined) this.#unindexRecord(existing); + + const record = normalizeRecord(input); + this.#recordsByElementId.set(record.elementId, record); + this.#indexRecord(record); + return record; + } + + getByElementId(elementId: string): PolyWorldDomRecord | undefined { + return this.#recordsByElementId.get(elementId); + } + + getBySourceId(sourceId: string): readonly PolyWorldDomRecord[] { + return this.#recordsBySourceId.get(sourceId) ?? []; + } + + getByAlias(alias: string): readonly PolyWorldDomRecord[] { + return this.#recordsByAlias.get(alias) ?? []; + } + + getByLayer(layer: string): readonly PolyWorldDomRecord[] { + return this.#recordsByLayer.get(layer) ?? []; + } + + getByTag(tag: string): readonly PolyWorldDomRecord[] { + return this.#recordsByTag.get(tag) ?? []; + } + + mountedRecords(): readonly PolyWorldDomRecord[] { + return this.records.filter((record) => record.mounted); + } + + mountedElementIds(): readonly string[] { + return this.mountedRecords().map((record) => record.elementId); + } + + hiddenElementIds(): readonly string[] { + return this.records + .filter((record) => record.mounted && record.element.hidden === true) + .map((record) => record.elementId); + } + + setMounted(elementId: string, mounted: boolean): void { + const record = this.#recordsByElementId.get(elementId); + if (record !== undefined) record.mounted = mounted; + } + + #indexRecord(record: PolyWorldDomRecord): void { + for (const sourceId of record.sourceIds) pushMap(this.#recordsBySourceId, sourceId, record); + for (const alias of record.aliases) pushMap(this.#recordsByAlias, alias, record); + for (const layer of record.layers) pushMap(this.#recordsByLayer, layer, record); + for (const tag of record.tags) pushMap(this.#recordsByTag, tag, record); + } + + #unindexRecord(record: PolyWorldDomRecord): void { + for (const sourceId of record.sourceIds) removeMapValue(this.#recordsBySourceId, sourceId, record); + for (const alias of record.aliases) removeMapValue(this.#recordsByAlias, alias, record); + for (const layer of record.layers) removeMapValue(this.#recordsByLayer, layer, record); + for (const tag of record.tags) removeMapValue(this.#recordsByTag, tag, record); + } +} + +export function createPolyWorldDomRegistry< + TElement extends PolyWorldDomElementLike = PolyWorldDomElementLike, +>( + records: readonly PolyWorldDomRecordInput[] = [], +): PolyWorldDomRegistry { + return new PolyWorldDomRegistry(records); +} + +export function validatePolyWorldDomRecord( + input: PolyWorldDomRecordInput, +): PolyWorldDomValidationDiagnostic[] { + const diagnostics: PolyWorldDomValidationDiagnostic[] = []; + validateId(input.elementId, "elementId", diagnostics); + validateElement(input.element, input.elementId, diagnostics); + validateStringArray(input.elementId, "sourceIds", input.sourceIds, diagnostics); + validateStringArray(input.elementId, "aliases", input.aliases, diagnostics); + validateStringArray(input.elementId, "layers", input.layers, diagnostics); + validateStringArray(input.elementId, "tags", input.tags, diagnostics); + validateOptionalId(input.elementId, "previousElementId", input.previousElementId, diagnostics); + validateOptionalId(input.elementId, "nextElementId", input.nextElementId, diagnostics); + return diagnostics; +} + +function normalizeRecord< + TElement extends PolyWorldDomElementLike = PolyWorldDomElementLike, +>(input: PolyWorldDomRecordInput): PolyWorldDomRecord { + const parent = input.parent ?? parentFromElement(input.element); + + return { + elementId: input.elementId, + element: input.element, + parent, + mounted: input.mounted ?? isMounted(input.element, parent), + previousElementId: input.previousElementId, + nextElementId: input.nextElementId, + sourceIds: uniqueSorted(input.sourceIds), + aliases: uniqueSorted(input.aliases), + layers: uniqueSorted(input.layers), + tags: uniqueSorted(input.tags), + data: input.data, + }; +} + +function isMounted( + element: TElement, + parent: PolyWorldDomParentLike | null, +): boolean { + return parent !== null && element.parentNode === parent; +} + +function parentFromElement( + element: TElement, +): PolyWorldDomParentLike | null { + const parent = element.parentNode; + if (parent === undefined || parent === null || typeof parent !== "object") return null; + if (!("insertBefore" in parent) || typeof parent.insertBefore !== "function") return null; + return parent as PolyWorldDomParentLike; +} + +function validateId( + value: string, + field: string, + diagnostics: PolyWorldDomValidationDiagnostic[], +): void { + if (typeof value !== "string" || value.length === 0) { + diagnostics.push({ + code: "poly-world-dom-empty-element-id", + message: "PolyWorld DOM record requires a non-empty elementId.", + field, + }); + } +} + +function validateOptionalId( + elementId: string, + field: string, + value: string | undefined, + diagnostics: PolyWorldDomValidationDiagnostic[], +): void { + if (value === undefined) return; + if (typeof value !== "string" || value.length === 0) { + diagnostics.push({ + code: "poly-world-dom-empty-reference-id", + message: `PolyWorld DOM record "${elementId}" has an empty ${field}.`, + elementId, + field, + }); + } +} + +function validateElement( + value: PolyWorldDomElementLike, + elementId: string, + diagnostics: PolyWorldDomValidationDiagnostic[], +): void { + if (value === undefined || value === null || typeof value.remove !== "function") { + diagnostics.push({ + code: "poly-world-dom-invalid-element", + message: `PolyWorld DOM record "${elementId}" requires an element with remove().`, + elementId, + field: "element", + }); + } +} + +function validateStringArray( + elementId: string, + field: string, + values: readonly string[] | undefined, + diagnostics: PolyWorldDomValidationDiagnostic[], +): void { + if (values === undefined) return; + for (const value of values) { + if (typeof value !== "string" || value.length === 0) { + diagnostics.push({ + code: "poly-world-dom-empty-array-value", + message: `PolyWorld DOM record "${elementId}" has an empty value in ${field}.`, + elementId, + field, + }); + } + } +} + +function pushMap(map: Map, key: K, value: V): void { + const values = map.get(key); + if (values === undefined) { + map.set(key, [value]); + return; + } + values.push(value); +} + +function removeMapValue(map: Map, key: K, value: V): void { + const values = map.get(key); + if (values === undefined) return; + const next = values.filter((item) => item !== value); + if (next.length === 0) { + map.delete(key); + return; + } + map.set(key, next); +} + +function uniqueSorted(values: readonly string[] | undefined): readonly string[] { + return [...new Set(values ?? [])].sort(compareStrings); +} + +function compareStrings(a: string, b: string): number { + if (a < b) return -1; + if (a > b) return 1; + return 0; +} diff --git a/packages/world/src/dom/types.ts b/packages/world/src/dom/types.ts new file mode 100644 index 000000000..902209fb4 --- /dev/null +++ b/packages/world/src/dom/types.ts @@ -0,0 +1,136 @@ +import type { PolyWorldData } from "../topology"; +import type { + PolyWorldLayerPlan, + PolyWorldPlanAction, + PolyWorldPlanActionCounts, + PolyWorldPlanCheckResult, + PolyWorldPlanEntry, +} from "../planner"; + +export interface PolyWorldDomParentLike { + insertBefore(element: any, before: any): unknown; +} + +export interface PolyWorldDomElementLike { + parentNode?: unknown | null; + hidden?: boolean; + remove(): unknown; + setAttribute?(name: string, value: string): unknown; + removeAttribute?(name: string): unknown; +} + +export interface PolyWorldDomRecordInput< + TElement extends PolyWorldDomElementLike = PolyWorldDomElementLike, +> { + elementId: string; + element: TElement; + parent?: PolyWorldDomParentLike | null; + mounted?: boolean; + previousElementId?: string; + nextElementId?: string; + sourceIds?: readonly string[]; + aliases?: readonly string[]; + layers?: readonly string[]; + tags?: readonly string[]; + data?: PolyWorldData; +} + +export interface PolyWorldDomRecord< + TElement extends PolyWorldDomElementLike = PolyWorldDomElementLike, +> { + elementId: string; + element: TElement; + parent: PolyWorldDomParentLike | null; + mounted: boolean; + previousElementId?: string; + nextElementId?: string; + sourceIds: readonly string[]; + aliases: readonly string[]; + layers: readonly string[]; + tags: readonly string[]; + data?: PolyWorldData; +} + +export interface PolyWorldDomValidationDiagnostic { + code: string; + message: string; + elementId?: string; + field?: string; +} + +export type PolyWorldDomApplyStatus = + | "added" + | "hidden" + | "removed" + | "retained" + | "noop" + | "missing" + | "blocked" + | "unsupported"; + +export interface PolyWorldDomApplyCounts { + added: number; + hidden: number; + removed: number; + retained: number; + noop: number; + missing: number; + blocked: number; + unsupported: number; + changed: number; + mounted: number; +} + +export interface PolyWorldDomApplyEntry< + TElement extends PolyWorldDomElementLike = PolyWorldDomElementLike, +> { + key: string; + action: PolyWorldPlanAction; + status: PolyWorldDomApplyStatus; + layer: string; + elementId?: string; + policyId?: string; + mounted: boolean; + changed: boolean; + guards: readonly PolyWorldPlanCheckResult[]; + dependencies: readonly PolyWorldPlanCheckResult[]; + failedGuards: readonly PolyWorldPlanCheckResult[]; + failedDependencies: readonly PolyWorldPlanCheckResult[]; + reasonLabels: readonly string[]; + message?: string; + planEntry: PolyWorldPlanEntry; + record?: PolyWorldDomRecord; +} + +export interface PolyWorldDomApplyResult< + TElement extends PolyWorldDomElementLike = PolyWorldDomElementLike, +> { + previousSignature?: string; + nextSignature?: string; + planChanged?: boolean; + entries: readonly PolyWorldDomApplyEntry[]; + actionCounts: PolyWorldPlanActionCounts; + counts: PolyWorldDomApplyCounts; + plannedElementIds: readonly string[]; + addedElementIds: readonly string[]; + hiddenAppliedElementIds: readonly string[]; + removedElementIds: readonly string[]; + retainedElementIds: readonly string[]; + noopElementIds: readonly string[]; + changedElementIds: readonly string[]; + missingElementIds: readonly string[]; + blockedElementIds: readonly string[]; + mountBlockedElementIds: readonly string[]; + guardFailureElementIds: readonly string[]; + dependencyFailureElementIds: readonly string[]; + unsupportedElementIds: readonly string[]; + mountedElementIds: readonly string[]; + hiddenElementIds: readonly string[]; +} + +export interface PolyWorldDomApplyOptions { + hideMode?: "remove" | "hidden"; + syncHidden?: boolean; +} + +export type PolyWorldDomPlanInput = PolyWorldLayerPlan | readonly PolyWorldPlanEntry[]; diff --git a/packages/world/src/index.ts b/packages/world/src/index.ts new file mode 100644 index 000000000..efdc87f8b --- /dev/null +++ b/packages/world/src/index.ts @@ -0,0 +1,6 @@ +export * from "./topology"; +export * from "./profiles"; +export * from "./state"; +export * from "./planner"; +export * from "./dom"; +export * from "./debug"; diff --git a/packages/world/src/planner/elementSetPlan.ts b/packages/world/src/planner/elementSetPlan.ts new file mode 100644 index 000000000..a87369556 --- /dev/null +++ b/packages/world/src/planner/elementSetPlan.ts @@ -0,0 +1,169 @@ +import type { PolyWorldData } from "../topology"; +import type { + PolyWorldLayerPlan, + PolyWorldPlanAction, + PolyWorldPlanActionCounts, + PolyWorldPlanCheckResult, + PolyWorldPlanEntry, + PolyWorldPlanPhase, + PolyWorldPlanReason, + PolyWorldPlanReasonActions, + PolyWorldPlanReasonTargetStates, + PolyWorldPlanTargetState, +} from "./types"; + +const planActions: readonly PolyWorldPlanAction[] = ["show", "hide", "retain", "preload", "noop"]; +type PolyWorldElementSetPlanReason = Exclude; + +const reasonOrder: readonly PolyWorldElementSetPlanReason[] = ["removed", "added", "retained"]; +const defaultActions: Record = { + added: "show", + removed: "hide", + retained: "retain", + "no-match": "noop", +}; + +export interface PolyWorldElementSetPlanOptions { + previousElementIds?: Iterable; + nextElementIds: Iterable; + layer?: string; + policyId?: string; + keyPrefix?: string; + phase?: PolyWorldPlanPhase; + reasonLabels?: readonly string[]; + actions?: PolyWorldPlanReasonActions; + targetStates?: PolyWorldPlanReasonTargetStates; + guards?: readonly PolyWorldPlanCheckResult[]; + dependencies?: readonly PolyWorldPlanCheckResult[]; + data?: PolyWorldData; +} + +export function planPolyWorldElementSet(options: PolyWorldElementSetPlanOptions): PolyWorldLayerPlan { + const previousElementIds = uniqueSorted(options.previousElementIds ?? []); + const nextElementIds = uniqueSorted(options.nextElementIds); + const previousSet = new Set(previousElementIds); + const nextSet = new Set(nextElementIds); + const byReason: Record = { + removed: previousElementIds.filter((elementId) => !nextSet.has(elementId)), + added: nextElementIds.filter((elementId) => !previousSet.has(elementId)), + retained: nextElementIds.filter((elementId) => previousSet.has(elementId)), + }; + const layer = options.layer ?? "render"; + const policyKey = options.policyId ?? options.keyPrefix ?? layer; + const entries: PolyWorldPlanEntry[] = []; + + for (const reason of reasonOrder) { + for (const elementId of byReason[reason]) { + const action = actionForReason(options.actions, reason); + entries.push({ + key: `${policyKey}:${elementId}`, + policyId: options.policyId, + layer, + elementId, + action, + reason, + ...phaseField(options.phase, action), + targetState: targetStateForReason(options.targetStates, reason, action), + guards: options.guards?.map((guard) => ({ ...guard })) ?? [], + dependencies: options.dependencies?.map((dependency) => ({ ...dependency })) ?? [], + blocked: hasFailedCheck(options.guards) || hasFailedCheck(options.dependencies), + reasonLabels: [...(options.reasonLabels ?? [])], + data: options.data, + }); + } + } + + return { + previousSignature: elementSetSignature(previousElementIds), + nextSignature: elementSetSignature(nextElementIds), + changed: !sameOrdered(previousElementIds, nextElementIds), + entries, + actionCounts: countActions(entries), + layerCounts: countLayerActions(entries), + }; +} + +function actionForReason( + actions: PolyWorldPlanReasonActions | undefined, + reason: PolyWorldPlanReason, +): PolyWorldPlanAction { + return actions?.[reason] ?? defaultActions[reason]; +} + +function targetStateForReason( + targetStates: PolyWorldPlanReasonTargetStates | undefined, + reason: PolyWorldPlanReason, + action: PolyWorldPlanAction, +): PolyWorldPlanTargetState { + return { + ...targetStateForAction(action), + ...targetStates?.[reason], + }; +} + +function targetStateForAction(action: PolyWorldPlanAction): PolyWorldPlanTargetState { + if (action === "show") return { visible: true, rendered: true }; + if (action === "hide") return { visible: false, rendered: false }; + if (action === "retain") return { visible: true, rendered: true }; + if (action === "preload") return { preloaded: true }; + return {}; +} + +function phaseField( + phase: PolyWorldPlanPhase | undefined, + action: PolyWorldPlanAction, +): { phase?: PolyWorldPlanPhase } { + const resolved = phase ?? phaseForAction(action); + return resolved === undefined ? {} : { phase: resolved }; +} + +function phaseForAction(action: PolyWorldPlanAction): PolyWorldPlanPhase | undefined { + if (action === "preload") return "preload"; + if (action === "show") return "render"; + if (action === "retain") return "render"; + if (action === "hide") return "cleanup"; + return undefined; +} + +function hasFailedCheck(checks: readonly PolyWorldPlanCheckResult[] | undefined): boolean { + return checks?.some((check) => check.ok === false) ?? false; +} + +function countActions(entries: readonly PolyWorldPlanEntry[]): PolyWorldPlanActionCounts { + const counts = emptyActionCounts(); + for (const entry of entries) counts[entry.action] += 1; + return counts; +} + +function countLayerActions( + entries: readonly PolyWorldPlanEntry[], +): Record { + const counts: Record = {}; + for (const entry of entries) { + counts[entry.layer] ??= emptyActionCounts(); + counts[entry.layer][entry.action] += 1; + } + return Object.fromEntries(Object.entries(counts).sort(([a], [b]) => compareStrings(a, b))); +} + +function emptyActionCounts(): PolyWorldPlanActionCounts { + return Object.fromEntries(planActions.map((action) => [action, 0])) as PolyWorldPlanActionCounts; +} + +function uniqueSorted(values: Iterable): readonly string[] { + return [...new Set(values)].sort(compareStrings); +} + +function elementSetSignature(elementIds: readonly string[]): string { + return `elements:${elementIds.join("|")}`; +} + +function sameOrdered(a: readonly string[], b: readonly string[]): boolean { + return a.length === b.length && a.every((value, index) => value === b[index]); +} + +function compareStrings(a: string, b: string): number { + if (a < b) return -1; + if (a > b) return 1; + return 0; +} diff --git a/packages/world/src/planner/index.ts b/packages/world/src/planner/index.ts new file mode 100644 index 000000000..f5853f9a8 --- /dev/null +++ b/packages/world/src/planner/index.ts @@ -0,0 +1,46 @@ +export { planPolyWorldElementSet } from "./elementSetPlan"; +export { planPolyWorldLayers } from "./plan"; +export { + createPolyWorldResourceLoadSet, + createPolyWorldResourceReadinessGuards, + summarizePolyWorldResourceReadiness, +} from "./resources"; +export { planPolyWorldTransition } from "./transition"; +export type { + PolyWorldElementSetPlanOptions, +} from "./elementSetPlan"; +export type { + PolyWorldLayerPlan, + PolyWorldLayerPlanPolicy, + PolyWorldPlanAction, + PolyWorldPlanActionCounts, + PolyWorldPlanCheckInput, + PolyWorldPlanCheckResult, + PolyWorldPlanEntry, + PolyWorldPlanEntryCheckContext, + PolyWorldPlanPhase, + PolyWorldPlanReason, + PolyWorldPlanReasonActions, + PolyWorldPlanReasonTargetStates, + PolyWorldPlanStateName, + PolyWorldPlanTargetState, +} from "./types"; +export type { + PolyWorldResourceReadinessGuardOptions, + PolyWorldResourceReadinessDeclaration, + PolyWorldResourceLoadSetOptions, + PolyWorldResourceLoadSetSummary, + PolyWorldResourceReadinessMap, + PolyWorldResourceReadinessRecord, + PolyWorldResourceReadinessSummary, + PolyWorldResourceReadinessSummaryOptions, + PolyWorldResourceReadinessSummaryRecord, + PolyWorldResourceReadinessState, +} from "./resources"; +export type { + PolyWorldTransition, + PolyWorldTransitionDebugOptions, + PolyWorldTransitionOptions, + PolyWorldTransitionReadinessOptions, + PolyWorldTransitionStateOptions, +} from "./transition"; diff --git a/packages/world/src/planner/plan.ts b/packages/world/src/planner/plan.ts new file mode 100644 index 000000000..3a591c605 --- /dev/null +++ b/packages/world/src/planner/plan.ts @@ -0,0 +1,222 @@ +import type { PolyWorldStateDiff } from "../state"; +import type { PolyWorldElement, PolyWorldTopology } from "../topology"; +import type { + PolyWorldLayerPlan, + PolyWorldLayerPlanPolicy, + PolyWorldPlanAction, + PolyWorldPlanActionCounts, + PolyWorldPlanCheckResult, + PolyWorldPlanEntry, + PolyWorldPlanEntryCheckContext, + PolyWorldPlanPhase, + PolyWorldPlanReason, + PolyWorldPlanTargetState, +} from "./types"; + +const planActions: readonly PolyWorldPlanAction[] = ["show", "hide", "retain", "preload", "noop"]; +const reasonOrder: readonly PolyWorldPlanReason[] = ["removed", "added", "retained"]; +const defaultActions: Record = { + added: "show", + removed: "hide", + retained: "retain", + "no-match": "noop", +}; + +export function planPolyWorldLayers( + topology: PolyWorldTopology, + diff: PolyWorldStateDiff, + policies: readonly PolyWorldLayerPlanPolicy[], +): PolyWorldLayerPlan { + const entries: PolyWorldPlanEntry[] = []; + + policies.forEach((policy, policyIndex) => { + const policyEntries: PolyWorldPlanEntry[] = []; + const policyKey = resolvePolicyKey(policy, policyIndex); + + for (const reason of reasonOrder) { + for (const elementId of idsForReason(diff, reason)) { + const element = topology.elementsById.get(elementId); + if (element === undefined || !matchesPolicy(element, policy)) continue; + const action = actionForReason(policy, reason); + const checkContext: PolyWorldPlanEntryCheckContext = { + policy, + layer: policy.layer, + action, + reason, + element, + elementId, + }; + const guards = checksForPolicy(policy.guards, checkContext); + const dependencies = checksForPolicy(policy.dependencies, checkContext); + + policyEntries.push({ + key: `${policyKey}:${elementId}`, + policyId: policy.id, + layer: policy.layer, + elementId, + action, + reason, + ...phaseField(policy, action), + targetState: targetStateForReason(policy, reason, action), + guards, + dependencies, + blocked: hasFailedCheck(guards) || hasFailedCheck(dependencies), + reasonLabels: reasonLabelsForReason(diff, reason), + data: policy.data, + }); + } + } + + if (policyEntries.length === 0 && policy.emitNoop === true) { + const action = actionForReason(policy, "no-match"); + const checkContext: PolyWorldPlanEntryCheckContext = { + policy, + layer: policy.layer, + action, + reason: "no-match", + }; + const guards = checksForPolicy(policy.guards, checkContext); + const dependencies = checksForPolicy(policy.dependencies, checkContext); + policyEntries.push({ + key: `${policyKey}:noop`, + policyId: policy.id, + layer: policy.layer, + action, + reason: "no-match", + ...phaseField(policy, action), + targetState: targetStateForReason(policy, "no-match", action), + guards, + dependencies, + blocked: hasFailedCheck(guards) || hasFailedCheck(dependencies), + reasonLabels: diff.next.reasonLabels, + data: policy.data, + }); + } + + entries.push(...policyEntries); + }); + + return { + previousSignature: diff.previousSignature, + nextSignature: diff.nextSignature, + changed: diff.changed, + entries, + actionCounts: countActions(entries), + layerCounts: countLayerActions(entries), + }; +} + +function idsForReason(diff: PolyWorldStateDiff, reason: PolyWorldPlanReason): readonly string[] { + if (reason === "added") return diff.resolvedElements.added; + if (reason === "removed") return diff.resolvedElements.removed; + if (reason === "retained") return diff.resolvedElements.retained; + return []; +} + +function actionForReason( + policy: PolyWorldLayerPlanPolicy, + reason: PolyWorldPlanReason, +): PolyWorldPlanAction { + return policy.actions?.[reason] ?? defaultActions[reason]; +} + +function targetStateForReason( + policy: PolyWorldLayerPlanPolicy, + reason: PolyWorldPlanReason, + action: PolyWorldPlanAction, +): PolyWorldPlanTargetState { + return { + ...targetStateForAction(action), + ...policy.targetStates?.[reason], + }; +} + +function targetStateForAction(action: PolyWorldPlanAction): PolyWorldPlanTargetState { + if (action === "show") return { visible: true, rendered: true }; + if (action === "hide") return { visible: false, rendered: false }; + if (action === "retain") return { visible: true, rendered: true }; + if (action === "preload") return { preloaded: true }; + return {}; +} + +function checksForPolicy( + checks: PolyWorldLayerPlanPolicy["guards"], + context: PolyWorldPlanEntryCheckContext, +): readonly PolyWorldPlanCheckResult[] { + const resolved = typeof checks === "function" ? checks(context) : checks; + return resolved?.map((check) => ({ ...check })) ?? []; +} + +function hasFailedCheck(checks: readonly PolyWorldPlanCheckResult[]): boolean { + return checks.some((check) => check.ok === false); +} + +function phaseField( + policy: PolyWorldLayerPlanPolicy, + action: PolyWorldPlanAction, +): { phase?: PolyWorldPlanPhase } { + const phase = policy.phase ?? phaseForAction(action); + return phase === undefined ? {} : { phase }; +} + +function phaseForAction(action: PolyWorldPlanAction): PolyWorldPlanPhase | undefined { + if (action === "preload") return "preload"; + if (action === "show") return "render"; + if (action === "retain") return "render"; + if (action === "hide") return "cleanup"; + return undefined; +} + +function reasonLabelsForReason( + diff: PolyWorldStateDiff, + reason: PolyWorldPlanReason, +): readonly string[] { + if (reason === "removed") return diff.previous.reasonLabels; + return diff.next.reasonLabels; +} + +function matchesPolicy(element: PolyWorldElement, policy: PolyWorldLayerPlanPolicy): boolean { + if (!matchesOne(element.layers, policy.elementLayers)) return false; + if (!matchesOne(element.tags, policy.tags)) return false; + if (!matchesOne(element.kind === undefined ? undefined : [element.kind], policy.elementKinds)) return false; + if (!matchesOne([element.id], policy.elementIds)) return false; + return true; +} + +function matchesOne(values: readonly string[] | undefined, filter: readonly string[] | undefined): boolean { + if (filter === undefined) return true; + if (filter.length === 0) return false; + if (values === undefined) return false; + return values.some((value) => filter.includes(value)); +} + +function resolvePolicyKey(policy: PolyWorldLayerPlanPolicy, policyIndex: number): string { + return policy.id ?? `${policy.layer}:${policyIndex}`; +} + +function countActions(entries: readonly PolyWorldPlanEntry[]): PolyWorldPlanActionCounts { + const counts = emptyActionCounts(); + for (const entry of entries) counts[entry.action] += 1; + return counts; +} + +function countLayerActions( + entries: readonly PolyWorldPlanEntry[], +): Record { + const counts: Record = {}; + for (const entry of entries) { + counts[entry.layer] ??= emptyActionCounts(); + counts[entry.layer][entry.action] += 1; + } + return Object.fromEntries(Object.entries(counts).sort(([a], [b]) => compareStrings(a, b))); +} + +function emptyActionCounts(): PolyWorldPlanActionCounts { + return Object.fromEntries(planActions.map((action) => [action, 0])) as PolyWorldPlanActionCounts; +} + +function compareStrings(a: string, b: string): number { + if (a < b) return -1; + if (a > b) return 1; + return 0; +} diff --git a/packages/world/src/planner/planner.test.ts b/packages/world/src/planner/planner.test.ts new file mode 100644 index 000000000..697259c6f --- /dev/null +++ b/packages/world/src/planner/planner.test.ts @@ -0,0 +1,944 @@ +import { describe, expect, it } from "vitest"; +import { createPolyWorldState, diffPolyWorldState } from "../state"; +import { createPolyWorldTopology } from "../topology"; +import { + createPolyWorldResourceLoadSet, + createPolyWorldResourceReadinessGuards, + planPolyWorldElementSet, + planPolyWorldLayers, + planPolyWorldTransition, + summarizePolyWorldResourceReadiness, +} from "./index"; + +function topologyFixture() { + return createPolyWorldTopology({ + regions: [ + { id: "group-45" }, + { id: "group-14" }, + { id: "group-15", selectionKeys: ["known-empty"] }, + ], + elements: [ + { id: "shell-45", regionIds: ["group-45"], kind: "mesh", layers: ["render"], tags: ["solid"] }, + { id: "shell-14", regionIds: ["group-14"], kind: "mesh", layers: ["render"], tags: ["solid"] }, + { id: "shell-15", regionIds: ["group-15"], kind: "mesh", layers: ["render"], tags: ["solid"] }, + { + id: "door-volume", + regionIds: ["group-45", "group-14"], + regionMatch: "all", + kind: "volume", + layers: ["collision"], + tags: ["connector"], + }, + { + id: "portal-debug", + selectionKeys: ["portal:45:14"], + kind: "marker", + layers: ["debug"], + tags: ["debug", "portal"], + }, + ], + }); +} + +describe("planPolyWorldLayers", () => { + it("plans directly from an external visible element set", () => { + const plan = planPolyWorldElementSet({ + previousElementIds: ["face-4", "face-2", "face-0", "face-2"], + nextElementIds: ["face-2", "face-1", "face-3"], + layer: "render", + policyId: "quake-pvs", + reasonLabels: ["quake-pvs:e1m1:leaf-42"], + }); + + expect(plan.previousSignature).toBe("elements:face-0|face-2|face-4"); + expect(plan.nextSignature).toBe("elements:face-1|face-2|face-3"); + expect(plan.changed).toBe(true); + expect(plan.entries.map((entry) => [ + entry.key, + entry.elementId, + entry.action, + entry.reason, + entry.phase, + entry.targetState, + entry.reasonLabels, + ])).toEqual([ + [ + "quake-pvs:face-0", + "face-0", + "hide", + "removed", + "cleanup", + { visible: false, rendered: false }, + ["quake-pvs:e1m1:leaf-42"], + ], + [ + "quake-pvs:face-4", + "face-4", + "hide", + "removed", + "cleanup", + { visible: false, rendered: false }, + ["quake-pvs:e1m1:leaf-42"], + ], + [ + "quake-pvs:face-1", + "face-1", + "show", + "added", + "render", + { visible: true, rendered: true }, + ["quake-pvs:e1m1:leaf-42"], + ], + [ + "quake-pvs:face-3", + "face-3", + "show", + "added", + "render", + { visible: true, rendered: true }, + ["quake-pvs:e1m1:leaf-42"], + ], + [ + "quake-pvs:face-2", + "face-2", + "retain", + "retained", + "render", + { visible: true, rendered: true }, + ["quake-pvs:e1m1:leaf-42"], + ], + ]); + expect(plan.actionCounts).toEqual({ + show: 2, + hide: 2, + retain: 1, + preload: 0, + noop: 0, + }); + expect(plan.layerCounts.render).toEqual(plan.actionCounts); + }); + + it("can plan resident/preload policy from the same external element set vocabulary", () => { + const plan = planPolyWorldElementSet({ + previousElementIds: ["chunk-1"], + nextElementIds: ["chunk-1", "chunk-2"], + layer: "resident", + policyId: "stream-resident", + phase: "mount", + actions: { added: "preload", retained: "retain" }, + targetStates: { + added: { loaded: true, resident: true, visible: false, rendered: false }, + retained: { loaded: true, resident: true, visible: true, rendered: true }, + }, + }); + + expect(plan.entries.map((entry) => [entry.elementId, entry.action, entry.phase, entry.targetState])).toEqual([ + [ + "chunk-2", + "preload", + "mount", + { preloaded: true, loaded: true, resident: true, visible: false, rendered: false }, + ], + [ + "chunk-1", + "retain", + "mount", + { visible: true, rendered: true, loaded: true, resident: true }, + ], + ]); + }); + + it("plans different actions for render, collision, and preload layers from one state diff", () => { + const topology = topologyFixture(); + const previous = createPolyWorldState(topology, { + selection: { regionIds: ["group-45"], reasons: [{ label: "current-group" }] }, + }); + const next = createPolyWorldState(topology, { + selection: { + regionIds: ["group-45", "group-14", "group-15"], + selectionKeys: ["portal:45:14"], + reasons: [{ label: "visible-through-portal" }], + }, + }); + const plan = planPolyWorldLayers(topology, diffPolyWorldState(previous, next), [ + { id: "render", layer: "render", elementLayers: ["render"], tags: ["solid"] }, + { id: "collision", layer: "collision", elementLayers: ["collision"], elementKinds: ["volume"] }, + { + id: "preload", + layer: "preload", + elementLayers: ["render"], + actions: { added: "preload", retained: "noop" }, + }, + ]); + + expect(plan.entries.map((entry) => [entry.layer, entry.elementId, entry.action, entry.reason])).toEqual([ + ["render", "shell-14", "show", "added"], + ["render", "shell-15", "show", "added"], + ["render", "shell-45", "retain", "retained"], + ["collision", "door-volume", "show", "added"], + ["preload", "shell-14", "preload", "added"], + ["preload", "shell-15", "preload", "added"], + ["preload", "shell-45", "noop", "retained"], + ]); + expect(plan.entries[0]?.key).toBe("render:shell-14"); + expect(plan.entries.map((entry) => [entry.elementId, entry.phase, entry.targetState])).toEqual([ + ["shell-14", "render", { visible: true, rendered: true }], + ["shell-15", "render", { visible: true, rendered: true }], + ["shell-45", "render", { visible: true, rendered: true }], + ["door-volume", "render", { visible: true, rendered: true }], + ["shell-14", "preload", { preloaded: true }], + ["shell-15", "preload", { preloaded: true }], + ["shell-45", undefined, {}], + ]); + expect(plan.actionCounts).toEqual({ + show: 3, + hide: 0, + retain: 1, + preload: 2, + noop: 1, + }); + expect(plan.layerCounts.render.show).toBe(2); + expect(plan.layerCounts.preload.preload).toBe(2); + }); + + it("keeps debug and persistent elements out of plans unless policies target them", () => { + const topology = topologyFixture(); + const previous = createPolyWorldState(topology, { + selection: { regionIds: ["group-45"] }, + }); + const next = createPolyWorldState(topology, { + selection: { regionIds: ["group-45"], selectionKeys: ["portal:45:14"] }, + }); + + expect( + planPolyWorldLayers(topology, diffPolyWorldState(previous, next), [ + { id: "render", layer: "render", elementLayers: ["render"], tags: ["solid"] }, + ]).entries.map((entry) => entry.elementId), + ).toEqual(["shell-45"]); + + expect( + planPolyWorldLayers(topology, diffPolyWorldState(previous, next), [ + { id: "debug", layer: "debug", tags: ["portal"] }, + ]).entries.map((entry) => [entry.elementId, entry.action]), + ).toEqual([["portal-debug", "show"]]); + }); + + it("can emit summary-only noop plans when no element matches a policy", () => { + const topology = topologyFixture(); + const previous = createPolyWorldState(topology, { + selection: { selectionKeys: ["known-empty"], reasons: [{ label: "known-empty-key" }] }, + }); + const next = createPolyWorldState(topology, { + selection: { selectionKeys: ["known-empty"], reasons: [{ label: "known-empty-key" }] }, + }); + const plan = planPolyWorldLayers(topology, diffPolyWorldState(previous, next), [ + { id: "render", layer: "render", tags: ["missing-tag"], emitNoop: true }, + ]); + + expect(plan.entries).toEqual([ + { + key: "render:noop", + policyId: "render", + layer: "render", + action: "noop", + reason: "no-match", + targetState: {}, + guards: [], + dependencies: [], + blocked: false, + reasonLabels: ["known-empty-key"], + data: undefined, + }, + ]); + expect(plan.actionCounts.noop).toBe(1); + }); + + it("lets policies override target states and phase without changing the action vocabulary", () => { + const topology = topologyFixture(); + const previous = createPolyWorldState(topology, { + selection: { regionIds: ["group-45"] }, + }); + const next = createPolyWorldState(topology, { + selection: { regionIds: ["group-45", "group-14"] }, + }); + const plan = planPolyWorldLayers(topology, diffPolyWorldState(previous, next), [ + { + id: "resident", + layer: "resident", + phase: "mount", + elementLayers: ["render"], + actions: { added: "show", retained: "retain", removed: "hide" }, + targetStates: { + added: { loaded: true, resident: true, visible: false, rendered: false }, + retained: { loaded: true, resident: true, visible: true, rendered: true }, + removed: { resident: false, visible: false, rendered: false }, + }, + }, + ]); + + expect(plan.entries.map((entry) => [entry.elementId, entry.action, entry.phase, entry.targetState])).toEqual([ + [ + "shell-14", + "show", + "mount", + { visible: false, rendered: false, loaded: true, resident: true }, + ], + [ + "shell-45", + "retain", + "mount", + { visible: true, rendered: true, loaded: true, resident: true }, + ], + ]); + }); + + it("resolves policy guards and dependencies onto plan entries", () => { + const topology = topologyFixture(); + const previous = createPolyWorldState(topology, { + selection: { regionIds: ["group-45"] }, + }); + const next = createPolyWorldState(topology, { + selection: { regionIds: ["group-45", "group-14"] }, + }); + const plan = planPolyWorldLayers(topology, diffPolyWorldState(previous, next), [ + { + id: "render", + layer: "render", + elementLayers: ["render"], + guards: ({ elementId }) => [ + { + id: "asset-ready", + ok: elementId !== "shell-14", + message: elementId === "shell-14" ? "Waiting for shell-14 resources." : undefined, + }, + ], + dependencies: [{ id: "chunk-root", ok: true }], + }, + ]); + + expect(plan.entries.map((entry) => [ + entry.elementId, + entry.blocked, + entry.guards?.map((guard) => [guard.id, guard.ok, guard.message]), + entry.dependencies?.map((dependency) => [dependency.id, dependency.ok]), + ])).toEqual([ + [ + "shell-14", + true, + [["asset-ready", false, "Waiting for shell-14 resources."]], + [["chunk-root", true]], + ], + [ + "shell-45", + false, + [["asset-ready", true, undefined]], + [["chunk-root", true]], + ], + ]); + }); + + it("plans a state transition from a next selection", () => { + const topology = topologyFixture(); + const previous = createPolyWorldState(topology, { + selection: { regionIds: ["group-45"], reasons: [{ label: "current-group" }] }, + }); + const transition = planPolyWorldTransition(topology, { + previousState: previous, + selection: { regionIds: ["group-14"], reasons: [{ label: "camera-region" }] }, + policies: [ + { id: "render", layer: "render", elementLayers: ["render"], tags: ["solid"] }, + ], + debug: { includeEntries: false, listLimit: 2 }, + }); + + expect(transition.nextState.selectedRegionIds).toEqual(["group-14"]); + expect(transition.planningSelection?.regionIds).toEqual(["group-14"]); + expect(transition.planningSelection?.reasons?.map((reason) => reason.label)).toEqual(["camera-region"]); + expect(transition.nextState.resolvedElementIds).toEqual(["shell-14"]); + expect(transition.diff.resolvedElements.added).toEqual(["shell-14"]); + expect(transition.diff.resolvedElements.removed).toEqual(["shell-45"]); + expect(transition.plan.actionCounts).toEqual({ + show: 1, + hide: 1, + retain: 0, + preload: 0, + noop: 0, + }); + expect(transition.debug?.next.reasonLabels).toEqual(["camera-region"]); + expect(transition.debug?.planningSelection?.regionIds).toEqual(["group-14"]); + expect(transition.debug?.planningSelection?.reasonLabels).toEqual(["camera-region"]); + expect(transition.debug?.plan.entryCount).toBe(2); + expect(transition.debug?.plan.includedEntryCount).toBe(0); + }); + + it("summarizes resource readiness for transition next-state elements", () => { + const topology = createPolyWorldTopology({ + regions: [{ id: "old-room" }, { id: "new-room" }], + elements: [ + { id: "old-shell", regionIds: ["old-room"], layers: ["render"], resourceIds: ["mesh:old"] }, + { id: "new-shell", regionIds: ["new-room"], layers: ["render"], resourceIds: ["mesh:new", "texture:new"] }, + ], + spatialElements: [ + { + id: "new-shell-surface", + elementId: "new-shell", + regionId: "new-room", + role: "shell", + resourceIds: ["lightmap:new"], + vertices: [ + [0, 0, 0], + [1, 0, 0], + [1, 1, 0], + [0, 1, 0], + ], + }, + ], + }); + const previous = createPolyWorldState(topology, { selection: { regionIds: ["old-room"] } }); + const transition = planPolyWorldTransition(topology, { + previousState: previous, + selection: { regionIds: ["new-room"] }, + policies: [{ id: "render", layer: "render", elementLayers: ["render"] }], + readiness: { + resources: { + "mesh:old": "failed", + "mesh:new": "ready", + "texture:new": "stale", + "lightmap:new": "loading", + }, + }, + debug: { includeEntries: false }, + }); + + expect(transition.nextState.resolvedElementIds).toEqual(["new-shell"]); + expect(transition.readiness?.resourceIds).toEqual(["mesh:new", "texture:new", "lightmap:new"]); + expect(transition.readiness?.readyResourceIds).toEqual(["mesh:new"]); + expect(transition.readiness?.staleResourceIds).toEqual(["texture:new"]); + expect(transition.readiness?.loadingResourceIds).toEqual(["lightmap:new"]); + expect(transition.readiness?.failedResourceIds).toEqual([]); + expect(transition.readiness?.blockedResourceIds).toEqual(["texture:new", "lightmap:new"]); + expect(transition.readiness?.blockedElementIds).toEqual(["new-shell"]); + expect(transition.debug?.readiness?.blockedResourceIds).toEqual(["texture:new", "lightmap:new"]); + expect(transition.debug?.readiness?.counts.blockedElements).toBe(1); + }); + + it("can expand transition selections with parent and container roots before planning", () => { + const topology = createPolyWorldTopology({ + regions: [{ id: "gallery" }], + elements: [ + { id: "scene-root", selectionKeys: ["root:scene"], layers: ["resident"] }, + { + id: "gallery-root", + selectionKeys: ["root:gallery"], + containerId: "scene-root", + layers: ["resident"], + }, + { + id: "gallery-wall", + regionIds: ["gallery"], + parentId: "gallery-root", + containerId: "gallery-root", + layers: ["render"], + }, + ], + }); + const previous = createPolyWorldState(topology); + + const transition = planPolyWorldTransition(topology, { + previousState: previous, + selection: { regionIds: ["gallery"], reasons: [{ label: "view-pvs" }] }, + relations: { reasonLabel: "resident-roots" }, + policies: [ + { + id: "resident", + layer: "resident", + elementLayers: ["resident"], + phase: "mount", + targetStates: { + added: { loaded: true, resident: true, visible: false, rendered: false }, + }, + }, + { id: "render", layer: "render", elementLayers: ["render"] }, + ], + debug: { includeEntries: true }, + }); + + expect(transition.nextState.selectedRegionIds).toEqual(["gallery"]); + expect(transition.planningSelection?.regionIds).toEqual(["gallery"]); + expect(transition.planningSelection?.elementIds).toEqual(["scene-root", "gallery-root"]); + expect(transition.nextState.selectedElementIds).toEqual(["gallery-root", "scene-root"]); + expect(transition.nextState.reasonLabels).toEqual(["resident-roots", "view-pvs"]); + expect(transition.debug?.planningSelection?.regionIds).toEqual(["gallery"]); + expect(transition.debug?.planningSelection?.elementIds).toEqual(["scene-root", "gallery-root"]); + expect(transition.debug?.planningSelection?.reasonLabels).toEqual(["view-pvs", "resident-roots"]); + expect(transition.nextState.resolvedElementIds).toEqual(["gallery-root", "gallery-wall", "scene-root"]); + expect(transition.plan.entries.map((entry) => [ + entry.layer, + entry.elementId, + entry.action, + entry.phase, + entry.targetState, + ])).toEqual([ + [ + "resident", + "gallery-root", + "show", + "mount", + { visible: false, rendered: false, loaded: true, resident: true }, + ], + [ + "resident", + "scene-root", + "show", + "mount", + { visible: false, rendered: false, loaded: true, resident: true }, + ], + ["render", "gallery-wall", "show", "render", { visible: true, rendered: true }], + ]); + expect(transition.debug?.next.reasonLabels).toEqual(["resident-roots", "view-pvs"]); + }); + + it("creates resource readiness guards from spatial element resource refs without loading resources", () => { + const topology = createPolyWorldTopology({ + regions: [{ id: "room" }], + elements: [ + { id: "room-shell", regionIds: ["room"], layers: ["render"] }, + { id: "room-prop", regionIds: ["room"], layers: ["render"] }, + ], + spatialElements: [ + { + id: "room-shell-surface", + elementId: "room-shell", + regionId: "room", + role: "shell", + resourceIds: ["texture:wall", "mesh:room"], + vertices: [ + [0, 0, 0], + [1, 0, 0], + [1, 1, 0], + [0, 1, 0], + ], + }, + { + id: "room-prop-surface", + elementId: "room-prop", + regionId: "room", + role: "prop", + resourceIds: ["mesh:prop"], + vertices: [ + [0, 0, 1], + [1, 0, 1], + [1, 1, 1], + [0, 1, 1], + ], + }, + ], + }); + const previous = createPolyWorldState(topology, { selection: {} }); + const next = createPolyWorldState(topology, { selection: { regionIds: ["room"] } }); + const plan = planPolyWorldLayers(topology, diffPolyWorldState(previous, next), [ + { + id: "render", + layer: "render", + elementLayers: ["render"], + guards: createPolyWorldResourceReadinessGuards(topology, { + "texture:wall": "ready", + "mesh:room": "loading", + "mesh:prop": { state: "failed", message: "missing prepared mesh" }, + }), + }, + { + id: "preload", + layer: "preload", + elementLayers: ["render"], + actions: { added: "preload" }, + guards: createPolyWorldResourceReadinessGuards(topology, { + "texture:wall": "loading", + "mesh:room": "loading", + "mesh:prop": "requested", + }), + }, + ]); + + expect(plan.entries.map((entry) => [entry.layer, entry.elementId, entry.action, entry.blocked])).toEqual([ + ["render", "room-prop", "show", true], + ["render", "room-shell", "show", true], + ["preload", "room-prop", "preload", false], + ["preload", "room-shell", "preload", false], + ]); + expect(plan.entries.find((entry) => entry.elementId === "room-shell" && entry.layer === "render")?.guards).toEqual([ + expect.objectContaining({ + id: "resource-ready:texture:wall", + ok: true, + data: expect.objectContaining({ + resourceId: "texture:wall", + state: "ready", + spatialElementIds: ["room-shell-surface"], + }), + }), + expect.objectContaining({ + id: "resource-ready:mesh:room", + ok: false, + message: 'Resource "mesh:room" is loading.', + data: expect.objectContaining({ + resourceId: "mesh:room", + state: "loading", + elementId: "room-shell", + }), + }), + ]); + expect(plan.entries.find((entry) => entry.elementId === "room-prop" && entry.layer === "render")?.guards).toEqual([ + expect.objectContaining({ + id: "resource-ready:mesh:prop", + ok: false, + message: "missing prepared mesh", + }), + ]); + expect(plan.entries.find((entry) => entry.elementId === "room-shell" && entry.layer === "preload")?.guards).toEqual([]); + }); + + it("summarizes direct and spatial resource readiness without loading resources", () => { + const topology = createPolyWorldTopology({ + regions: [{ id: "room" }], + elements: [ + { + id: "room-shell", + regionIds: ["room"], + layers: ["render"], + resourceIds: ["mesh:shell", "texture:shared"], + }, + { + id: "room-prop", + regionIds: ["room"], + layers: ["render"], + resourceIds: ["mesh:prop", "sound:ambience"], + }, + ], + spatialElements: [ + { + id: "room-shell-surface", + elementId: "room-shell", + regionId: "room", + role: "shell", + resourceIds: ["texture:shared", "lightmap:shell"], + vertices: [ + [0, 0, 0], + [1, 0, 0], + [1, 1, 0], + [0, 1, 0], + ], + }, + { + id: "room-prop-surface", + elementId: "room-prop", + regionId: "room", + role: "prop", + resourceIds: ["texture:prop"], + vertices: [ + [0, 0, 1], + [1, 0, 1], + [1, 1, 1], + [0, 1, 1], + ], + }, + ], + }); + const resources = { + "mesh:shell": "ready", + "texture:shared": "stale", + "lightmap:shell": "loading", + "mesh:prop": { state: "failed", message: "mesh prepare failed" }, + "texture:prop": "requested", + } as const; + const summary = summarizePolyWorldResourceReadiness(topology, ["room-shell", "room-prop"], resources); + + expect(summary.resourceIds).toEqual([ + "mesh:shell", + "texture:shared", + "lightmap:shell", + "mesh:prop", + "sound:ambience", + "texture:prop", + ]); + expect(summary.readyResourceIds).toEqual(["mesh:shell"]); + expect(summary.staleResourceIds).toEqual(["texture:shared"]); + expect(summary.loadingResourceIds).toEqual(["lightmap:shell"]); + expect(summary.failedResourceIds).toEqual(["mesh:prop"]); + expect(summary.missingResourceIds).toEqual(["sound:ambience"]); + expect(summary.requestedResourceIds).toEqual(["texture:prop"]); + expect(summary.blockedResourceIds).toEqual([ + "texture:shared", + "lightmap:shell", + "mesh:prop", + "sound:ambience", + "texture:prop", + ]); + expect(summary.renderBlockingResourceIds).toEqual([ + "mesh:shell", + "texture:shared", + "lightmap:shell", + "mesh:prop", + "sound:ambience", + "texture:prop", + ]); + expect(summary.preloadOnlyResourceIds).toEqual([]); + expect(summary.nonBlockingResourceIds).toEqual([]); + expect(summary.blockedElementIds).toEqual(["room-shell", "room-prop"]); + expect(summary.elementIdsByResourceState).toEqual({ + ready: ["room-shell"], + stale: ["room-shell"], + loading: ["room-shell"], + failed: ["room-prop"], + missing: ["room-prop"], + requested: ["room-prop"], + }); + expect(summary.records.find((record) => record.resourceId === "texture:shared")).toEqual({ + resourceId: "texture:shared", + state: "stale", + elementIds: ["room-shell"], + spatialElementIds: ["room-shell-surface"], + renderBlocking: true, + preloadOnly: false, + }); + + const previous = createPolyWorldState(topology, { selection: {} }); + const next = createPolyWorldState(topology, { selection: { regionIds: ["room"] } }); + const plan = planPolyWorldLayers(topology, diffPolyWorldState(previous, next), [ + { + id: "render", + layer: "render", + elementLayers: ["render"], + guards: createPolyWorldResourceReadinessGuards(topology, resources), + }, + ]); + + expect(plan.entries.map((entry) => [entry.elementId, entry.blocked])).toEqual([ + ["room-prop", true], + ["room-shell", true], + ]); + expect(plan.entries.find((entry) => entry.elementId === "room-shell")?.guards).toEqual([ + expect.objectContaining({ + id: "resource-ready:mesh:shell", + ok: true, + data: expect.objectContaining({ + elementIds: ["room-shell"], + spatialElementIds: [], + }), + }), + expect.objectContaining({ + id: "resource-ready:texture:shared", + ok: false, + message: 'Resource "texture:shared" is stale.', + data: expect.objectContaining({ + state: "stale", + elementIds: ["room-shell"], + spatialElementIds: ["room-shell-surface"], + }), + }), + expect.objectContaining({ + id: "resource-ready:lightmap:shell", + ok: false, + message: 'Resource "lightmap:shell" is loading.', + }), + ]); + }); + + it("uses declaration metadata for render-blocking and preload-only resource readiness", () => { + const topology = createPolyWorldTopology({ + regions: [{ id: "room" }], + elements: [ + { id: "room-shell", regionIds: ["room"], layers: ["render"] }, + { id: "room-prop", regionIds: ["room"], layers: ["render"] }, + ], + spatialElements: [ + { + id: "room-prop-surface", + elementId: "room-prop", + regionId: "room", + role: "prop", + vertices: [ + [0, 0, 0], + [1, 0, 0], + [1, 1, 0], + ], + }, + ], + }); + const resourceDeclarations = [ + { + id: "mesh:shell", + state: "loading", + renderBlocking: true, + elementIds: ["room-shell"], + }, + { + id: "texture:prop-preview", + state: "requested", + renderBlocking: false, + spatialElementIds: ["room-prop-surface"], + }, + { + id: "audio:ambience", + state: "missing", + preloadOnly: true, + elementIds: ["room-shell", "room-prop"], + }, + ] as const; + const summary = summarizePolyWorldResourceReadiness(topology, ["room-shell", "room-prop"], {}, { + resourceDeclarations, + }); + + expect(summary.resourceIds).toEqual(["mesh:shell", "texture:prop-preview", "audio:ambience"]); + expect(summary.loadingResourceIds).toEqual(["mesh:shell"]); + expect(summary.requestedResourceIds).toEqual(["texture:prop-preview"]); + expect(summary.missingResourceIds).toEqual(["audio:ambience"]); + expect(summary.renderBlockingResourceIds).toEqual(["mesh:shell"]); + expect(summary.preloadOnlyResourceIds).toEqual(["audio:ambience"]); + expect(summary.nonBlockingResourceIds).toEqual(["texture:prop-preview", "audio:ambience"]); + expect(summary.blockedResourceIds).toEqual(["mesh:shell"]); + expect(summary.blockedElementIds).toEqual(["room-shell"]); + expect(summary.records.find((record) => record.resourceId === "texture:prop-preview")).toEqual({ + resourceId: "texture:prop-preview", + state: "requested", + elementIds: ["room-prop"], + spatialElementIds: ["room-prop-surface"], + renderBlocking: false, + preloadOnly: false, + }); + + const previous = createPolyWorldState(topology, { selection: {} }); + const next = createPolyWorldState(topology, { selection: { regionIds: ["room"] } }); + const plan = planPolyWorldLayers(topology, diffPolyWorldState(previous, next), [ + { + id: "render", + layer: "render", + elementLayers: ["render"], + guards: createPolyWorldResourceReadinessGuards(topology, {}, { resourceDeclarations }), + }, + ]); + + expect(plan.entries.map((entry) => [entry.elementId, entry.blocked])).toEqual([ + ["room-prop", false], + ["room-shell", true], + ]); + expect(plan.entries.find((entry) => entry.elementId === "room-prop")?.guards).toEqual([ + expect.objectContaining({ + id: "resource-ready:texture:prop-preview", + ok: true, + data: expect.objectContaining({ + renderBlocking: false, + preloadOnly: false, + }), + }), + expect.objectContaining({ + id: "resource-ready:audio:ambience", + ok: true, + data: expect.objectContaining({ + renderBlocking: false, + preloadOnly: true, + }), + }), + ]); + expect(plan.entries.find((entry) => entry.elementId === "room-shell")?.guards).toEqual([ + expect.objectContaining({ + id: "resource-ready:mesh:shell", + ok: false, + }), + expect.objectContaining({ + id: "resource-ready:audio:ambience", + ok: true, + }), + ]); + }); + + it("summarizes resource load sets without taking ownership of loading", () => { + const topology = createPolyWorldTopology({ + regions: [{ id: "old-room" }, { id: "new-room" }], + elements: [ + { id: "old-shell", regionIds: ["old-room"], layers: ["render"], resourceIds: ["mesh:old", "texture:shared"] }, + { + id: "new-shell", + regionIds: ["new-room"], + layers: ["render"], + resourceIds: ["mesh:new", "texture:shared", "texture:new", "mesh:missing"], + }, + ], + }); + const previous = createPolyWorldState(topology, { selection: { regionIds: ["old-room"] } }); + const next = createPolyWorldState(topology, { selection: { regionIds: ["new-room"] } }); + const resources = { + "mesh:old": "ready", + "texture:shared": "ready", + "mesh:new": "ready", + "texture:new": "stale", + } as const; + const resourceDeclarations = [ + { + id: "texture:preview", + state: "requested", + renderBlocking: false, + elementIds: ["new-shell"], + }, + { + id: "audio:ambience", + state: "requested", + preloadOnly: true, + elementIds: ["new-shell"], + }, + ] as const; + + const loadSet = createPolyWorldResourceLoadSet(topology, { + previousElementIds: previous.resolvedElementIds, + nextElementIds: next.resolvedElementIds, + resources, + readyStates: ["ready", "stale"], + resourceDeclarations, + }); + + expect(loadSet.previousResourceIds).toEqual(["mesh:old", "texture:shared"]); + expect(loadSet.nextResourceIds).toEqual([ + "mesh:new", + "texture:shared", + "texture:new", + "mesh:missing", + "texture:preview", + "audio:ambience", + ]); + expect(loadSet.requestResourceIds).toEqual(["mesh:missing", "texture:preview", "audio:ambience"]); + expect(loadSet.retainResourceIds).toEqual(["texture:shared"]); + expect(loadSet.releaseCandidateResourceIds).toEqual(["mesh:old"]); + expect(loadSet.readyButNotVisibleResourceIds).toEqual(["mesh:old"]); + expect(loadSet.preloadOnlyResourceIds).toEqual(["audio:ambience"]); + expect(loadSet.renderBlockingResourceIds).toEqual(["mesh:new", "texture:shared", "texture:new", "mesh:missing"]); + expect(loadSet.staleAllowedResourceIds).toEqual(["texture:new"]); + expect(loadSet.nonBlockingResourceIds).toEqual(["texture:preview", "audio:ambience"]); + expect(loadSet.blockedResourceIds).toEqual(["mesh:missing"]); + expect(loadSet.blockedElementIds).toEqual(["new-shell"]); + + const transition = planPolyWorldTransition(topology, { + previousState: previous, + selection: { regionIds: ["new-room"] }, + policies: [{ id: "render", layer: "render", elementLayers: ["render"] }], + readiness: { + resources, + readyStates: ["ready", "stale"], + resourceDeclarations, + }, + debug: { includeEntries: false, listLimit: 3 }, + }); + + expect(transition.readiness?.resourceIds).toEqual(loadSet.nextResourceIds); + expect(transition.readiness?.staleResourceIds).toEqual(["texture:new"]); + expect(transition.readiness?.blockedResourceIds).toEqual(["mesh:missing"]); + expect(transition.loadSet?.requestResourceIds).toEqual(loadSet.requestResourceIds); + expect(transition.loadSet?.releaseCandidateResourceIds).toEqual(["mesh:old"]); + expect(transition.debug?.loadSet?.requestResourceIds).toEqual(["mesh:missing", "texture:preview", "audio:ambience"]); + expect(transition.debug?.loadSet?.counts).toMatchObject({ + requestResources: 3, + retainResources: 1, + releaseCandidateResources: 1, + staleAllowedResources: 1, + blockedElements: 1, + }); + }); +}); diff --git a/packages/world/src/planner/resources.ts b/packages/world/src/planner/resources.ts new file mode 100644 index 000000000..08323f39c --- /dev/null +++ b/packages/world/src/planner/resources.ts @@ -0,0 +1,352 @@ +import type { PolyWorldData, PolyWorldTopology } from "../topology"; +import type { + PolyWorldPlanAction, + PolyWorldPlanCheckInput, + PolyWorldPlanCheckResult, + PolyWorldPlanEntryCheckContext, +} from "./types"; + +export type PolyWorldResourceReadinessState = "missing" | "requested" | "loading" | "ready" | "failed" | "stale"; + +export interface PolyWorldResourceReadinessRecord { + state: PolyWorldResourceReadinessState; + renderBlocking?: boolean; + preloadOnly?: boolean; + label?: string; + message?: string; + data?: PolyWorldData; +} + +export type PolyWorldResourceReadinessMap = Readonly>; + +export interface PolyWorldResourceReadinessGuardOptions { + id?: string; + label?: string; + actions?: readonly PolyWorldPlanAction[]; + readyStates?: readonly PolyWorldResourceReadinessState[]; + resourceDeclarations?: readonly PolyWorldResourceReadinessDeclaration[]; +} + +export interface PolyWorldResourceReadinessSummaryOptions { + readyStates?: readonly PolyWorldResourceReadinessState[]; + resourceDeclarations?: readonly PolyWorldResourceReadinessDeclaration[]; +} + +export interface PolyWorldResourceLoadSetOptions extends PolyWorldResourceReadinessSummaryOptions { + previousElementIds?: readonly string[]; + nextElementIds: readonly string[]; + resources: PolyWorldResourceReadinessMap; +} + +export interface PolyWorldResourceReadinessDeclaration { + id: string; + state?: PolyWorldResourceReadinessState; + renderBlocking?: boolean; + preloadOnly?: boolean; + elementIds?: readonly string[]; + spatialElementIds?: readonly string[]; + label?: string; + message?: string; + data?: PolyWorldData; +} + +export interface PolyWorldResourceReadinessSummaryRecord { + resourceId: string; + state: PolyWorldResourceReadinessState; + elementIds: readonly string[]; + spatialElementIds: readonly string[]; + renderBlocking: boolean; + preloadOnly: boolean; + label?: string; + message?: string; + data?: PolyWorldData; +} + +export interface PolyWorldResourceReadinessSummary { + records: readonly PolyWorldResourceReadinessSummaryRecord[]; + resourceIds: readonly string[]; + readyResourceIds: readonly string[]; + missingResourceIds: readonly string[]; + requestedResourceIds: readonly string[]; + loadingResourceIds: readonly string[]; + failedResourceIds: readonly string[]; + staleResourceIds: readonly string[]; + renderBlockingResourceIds: readonly string[]; + preloadOnlyResourceIds: readonly string[]; + nonBlockingResourceIds: readonly string[]; + blockedResourceIds: readonly string[]; + blockedElementIds: readonly string[]; + elementIdsByResourceState: Readonly>; +} + +export interface PolyWorldResourceLoadSetSummary { + previousReadiness: PolyWorldResourceReadinessSummary; + nextReadiness: PolyWorldResourceReadinessSummary; + previousResourceIds: readonly string[]; + nextResourceIds: readonly string[]; + requestResourceIds: readonly string[]; + retainResourceIds: readonly string[]; + releaseCandidateResourceIds: readonly string[]; + readyButNotVisibleResourceIds: readonly string[]; + preloadOnlyResourceIds: readonly string[]; + renderBlockingResourceIds: readonly string[]; + staleAllowedResourceIds: readonly string[]; + nonBlockingResourceIds: readonly string[]; + blockedResourceIds: readonly string[]; + blockedElementIds: readonly string[]; +} + +export function createPolyWorldResourceReadinessGuards( + topology: PolyWorldTopology, + resources: PolyWorldResourceReadinessMap, + options: PolyWorldResourceReadinessGuardOptions = {}, +): PolyWorldPlanCheckInput { + const id = options.id ?? "resource-ready"; + const label = options.label ?? "resource-ready"; + const actions = new Set(options.actions ?? ["show", "retain"]); + const readyStates = new Set(options.readyStates ?? ["ready"]); + + return (context: PolyWorldPlanEntryCheckContext): readonly PolyWorldPlanCheckResult[] => { + if (!actions.has(context.action) || context.elementId === undefined) return []; + const elementId = context.elementId; + const summary = summarizePolyWorldResourceReadiness(topology, [elementId], resources, { + readyStates: options.readyStates, + resourceDeclarations: options.resourceDeclarations, + }); + if (summary.records.length === 0) return []; + + return summary.records.map((record) => { + const ok = readyStates.has(record.state) || !isResourceBlocking(record); + return { + id: `${id}:${record.resourceId}`, + ok, + label, + message: ok ? record.message : record.message ?? `Resource "${record.resourceId}" is ${record.state}.`, + data: { + resourceId: record.resourceId, + state: record.state, + renderBlocking: record.renderBlocking, + preloadOnly: record.preloadOnly, + elementId, + elementIds: record.elementIds, + spatialElementIds: record.spatialElementIds, + ...(record.data ?? {}), + }, + }; + }); + }; +} + +export function summarizePolyWorldResourceReadiness( + topology: PolyWorldTopology, + elementIds: readonly string[], + resources: PolyWorldResourceReadinessMap, + options: PolyWorldResourceReadinessSummaryOptions = {}, +): PolyWorldResourceReadinessSummary { + const readyStates = new Set(options.readyStates ?? ["ready"]); + const ownershipByResourceId = new Map; spatialElementIds: Set }>(); + const selectedElementIds = new Set(elementIds); + const declarationsByResourceId = new Map( + (options.resourceDeclarations ?? []).map((resource) => [resource.id, resource]), + ); + + for (const elementId of elementIds) { + const element = topology.elementsById.get(elementId); + if (element === undefined) continue; + + for (const resourceId of element.resourceIds ?? []) { + const ownership = resolveResourceOwnership(ownershipByResourceId, resourceId); + ownership.elementIds.add(elementId); + } + + for (const spatialElement of topology.spatialElementsByElementId.get(elementId) ?? []) { + for (const resourceId of spatialElement.resourceIds ?? []) { + const ownership = resolveResourceOwnership(ownershipByResourceId, resourceId); + ownership.elementIds.add(elementId); + ownership.spatialElementIds.add(spatialElement.id); + } + } + } + for (const declaration of options.resourceDeclarations ?? []) { + for (const elementId of declaration.elementIds ?? []) { + if (!selectedElementIds.has(elementId)) continue; + const ownership = resolveResourceOwnership(ownershipByResourceId, declaration.id); + ownership.elementIds.add(elementId); + } + for (const spatialElementId of declaration.spatialElementIds ?? []) { + const spatialElement = topology.spatialElementsById.get(spatialElementId); + const ownerElementId = spatialElement?.elementId; + if (ownerElementId === undefined || !selectedElementIds.has(ownerElementId)) continue; + const ownership = resolveResourceOwnership(ownershipByResourceId, declaration.id); + ownership.elementIds.add(ownerElementId); + ownership.spatialElementIds.add(spatialElementId); + } + } + + const records: PolyWorldResourceReadinessSummaryRecord[] = []; + const resourceIdsByState: Record = { + missing: [], + requested: [], + loading: [], + ready: [], + failed: [], + stale: [], + }; + const elementIdsByResourceState: Record = { + missing: [], + requested: [], + loading: [], + ready: [], + failed: [], + stale: [], + }; + const renderBlockingResourceIds: string[] = []; + const preloadOnlyResourceIds: string[] = []; + const nonBlockingResourceIds: string[] = []; + const blockedResourceIds: string[] = []; + const blockedElementIds: string[] = []; + + for (const [resourceId, ownership] of ownershipByResourceId) { + const resource = normalizeResourceRecord(resources[resourceId], declarationsByResourceId.get(resourceId)); + const elementOwnerIds = [...ownership.elementIds]; + const spatialElementIds = [...ownership.spatialElementIds]; + const preloadOnly = resource.preloadOnly === true; + const renderBlocking = isResourceBlocking(resource); + if (renderBlocking) renderBlockingResourceIds.push(resourceId); + if (preloadOnly) preloadOnlyResourceIds.push(resourceId); + if (!renderBlocking) nonBlockingResourceIds.push(resourceId); + const record: PolyWorldResourceReadinessSummaryRecord = { + resourceId, + state: resource.state, + elementIds: elementOwnerIds, + spatialElementIds, + renderBlocking, + preloadOnly, + ...(resource.label === undefined ? {} : { label: resource.label }), + ...(resource.message === undefined ? {} : { message: resource.message }), + ...(resource.data === undefined ? {} : { data: resource.data }), + }; + records.push(record); + resourceIdsByState[resource.state].push(resourceId); + for (const elementId of elementOwnerIds) addUnique(elementIdsByResourceState[resource.state], elementId); + if (!readyStates.has(resource.state) && isResourceBlocking(resource)) { + blockedResourceIds.push(resourceId); + for (const elementId of elementOwnerIds) addUnique(blockedElementIds, elementId); + } + } + + return { + records, + resourceIds: records.map((record) => record.resourceId), + readyResourceIds: resourceIdsByState.ready, + missingResourceIds: resourceIdsByState.missing, + requestedResourceIds: resourceIdsByState.requested, + loadingResourceIds: resourceIdsByState.loading, + failedResourceIds: resourceIdsByState.failed, + staleResourceIds: resourceIdsByState.stale, + renderBlockingResourceIds, + preloadOnlyResourceIds, + nonBlockingResourceIds, + blockedResourceIds, + blockedElementIds, + elementIdsByResourceState, + }; +} + +export function createPolyWorldResourceLoadSet( + topology: PolyWorldTopology, + options: PolyWorldResourceLoadSetOptions, +): PolyWorldResourceLoadSetSummary { + const readyStates = new Set(options.readyStates ?? ["ready"]); + const previousReadiness = summarizePolyWorldResourceReadiness( + topology, + options.previousElementIds ?? [], + options.resources, + { + readyStates: options.readyStates, + resourceDeclarations: options.resourceDeclarations, + }, + ); + const nextReadiness = summarizePolyWorldResourceReadiness( + topology, + options.nextElementIds, + options.resources, + { + readyStates: options.readyStates, + resourceDeclarations: options.resourceDeclarations, + }, + ); + const nextResourceIds = new Set(nextReadiness.resourceIds); + const previousResourceIds = new Set(previousReadiness.resourceIds); + const previousRecordsById = new Map(previousReadiness.records.map((record) => [record.resourceId, record])); + + return { + previousReadiness, + nextReadiness, + previousResourceIds: previousReadiness.resourceIds, + nextResourceIds: nextReadiness.resourceIds, + requestResourceIds: nextReadiness.records + .filter((record) => !readyStates.has(record.state)) + .map((record) => record.resourceId), + retainResourceIds: nextReadiness.resourceIds.filter((resourceId) => previousResourceIds.has(resourceId)), + releaseCandidateResourceIds: previousReadiness.resourceIds.filter((resourceId) => !nextResourceIds.has(resourceId)), + readyButNotVisibleResourceIds: previousReadiness.resourceIds.filter((resourceId) => { + if (nextResourceIds.has(resourceId)) return false; + const record = previousRecordsById.get(resourceId); + return record !== undefined && readyStates.has(record.state); + }), + preloadOnlyResourceIds: nextReadiness.preloadOnlyResourceIds, + renderBlockingResourceIds: nextReadiness.renderBlockingResourceIds, + staleAllowedResourceIds: nextReadiness.records + .filter((record) => record.state === "stale" && readyStates.has(record.state)) + .map((record) => record.resourceId), + nonBlockingResourceIds: nextReadiness.nonBlockingResourceIds, + blockedResourceIds: nextReadiness.blockedResourceIds, + blockedElementIds: nextReadiness.blockedElementIds, + }; +} + +function resolveResourceOwnership( + ownershipByResourceId: Map; spatialElementIds: Set }>, + resourceId: string, +): { elementIds: Set; spatialElementIds: Set } { + let ownership = ownershipByResourceId.get(resourceId); + if (ownership === undefined) { + ownership = { elementIds: new Set(), spatialElementIds: new Set() }; + ownershipByResourceId.set(resourceId, ownership); + } + return ownership; +} + +function addUnique(values: string[], value: string): void { + if (!values.includes(value)) values.push(value); +} + +function normalizeResourceRecord( + value: PolyWorldResourceReadinessState | PolyWorldResourceReadinessRecord | undefined, + declaration?: PolyWorldResourceReadinessDeclaration, +): PolyWorldResourceReadinessRecord { + const fallback: PolyWorldResourceReadinessRecord = { + state: declaration?.state ?? "missing", + renderBlocking: declaration?.renderBlocking ?? (declaration?.preloadOnly === true ? false : true), + preloadOnly: declaration?.preloadOnly ?? false, + ...(declaration?.label === undefined ? {} : { label: declaration.label }), + ...(declaration?.message === undefined ? {} : { message: declaration.message }), + ...(declaration?.data === undefined ? {} : { data: declaration.data }), + }; + if (value === undefined) return fallback; + if (typeof value === "string") return { ...fallback, state: value }; + return { + ...fallback, + ...value, + renderBlocking: value.renderBlocking ?? fallback.renderBlocking, + preloadOnly: value.preloadOnly ?? fallback.preloadOnly, + }; +} + +function isResourceBlocking(resource: Pick): boolean { + return resource.renderBlocking !== false && resource.preloadOnly !== true; +} diff --git a/packages/world/src/planner/transition.ts b/packages/world/src/planner/transition.ts new file mode 100644 index 000000000..b6c61b50b --- /dev/null +++ b/packages/world/src/planner/transition.ts @@ -0,0 +1,106 @@ +import type { PolyWorldPlanDebugSnapshot, PolyWorldPlanDebugSnapshotOptions } from "../debug/planSnapshot"; +import { createPolyWorldPlanDebugSnapshot } from "../debug/planSnapshot"; +import type { PolyWorldState, PolyWorldStateDiff, PolyWorldStateInput } from "../state"; +import { createPolyWorldState, diffPolyWorldState } from "../state"; +import type { + PolyWorldElementResolution, + PolyWorldSelection, + PolyWorldSelectionElementRelationExpansionOptions, + PolyWorldTopology, +} from "../topology"; +import { expandPolyWorldSelectionElementRelations } from "../topology"; +import { planPolyWorldLayers } from "./plan"; +import { createPolyWorldResourceLoadSet } from "./resources"; +import type { + PolyWorldResourceLoadSetSummary, + PolyWorldResourceReadinessMap, + PolyWorldResourceReadinessSummary, + PolyWorldResourceReadinessSummaryOptions, +} from "./resources"; +import type { PolyWorldLayerPlan, PolyWorldLayerPlanPolicy } from "./types"; + +export type PolyWorldTransitionStateOptions = Omit; + +export type PolyWorldTransitionDebugOptions = Omit< + PolyWorldPlanDebugSnapshotOptions, + "appliedState" | "planningSelection" +>; + +export interface PolyWorldTransitionReadinessOptions extends PolyWorldResourceReadinessSummaryOptions { + resources: PolyWorldResourceReadinessMap; + elementIds?: readonly string[]; +} + +export interface PolyWorldTransitionOptions { + previousState: PolyWorldState; + policies: readonly PolyWorldLayerPlanPolicy[]; + selection?: PolyWorldSelection; + resolution?: PolyWorldElementResolution; + relations?: false | PolyWorldSelectionElementRelationExpansionOptions; + readiness?: PolyWorldTransitionReadinessOptions; + state?: PolyWorldTransitionStateOptions; + debug?: false | PolyWorldTransitionDebugOptions; +} + +export interface PolyWorldTransition { + planningSelection?: PolyWorldSelection; + readiness?: PolyWorldResourceReadinessSummary; + loadSet?: PolyWorldResourceLoadSetSummary; + nextState: PolyWorldState; + diff: PolyWorldStateDiff; + plan: PolyWorldLayerPlan; + debug?: PolyWorldPlanDebugSnapshot; +} + +export function planPolyWorldTransition( + topology: PolyWorldTopology, + options: PolyWorldTransitionOptions, +): PolyWorldTransition { + const selection = resolveTransitionSelection(topology, options); + const nextState = createPolyWorldState(topology, { + ...options.state, + selection, + resolution: options.resolution, + }); + const diff = diffPolyWorldState(options.previousState, nextState); + const plan = planPolyWorldLayers(topology, diff, options.policies); + const loadSet = options.readiness === undefined + ? undefined + : createPolyWorldResourceLoadSet( + topology, + { + previousElementIds: options.previousState.resolvedElementIds, + nextElementIds: options.readiness.elementIds ?? nextState.resolvedElementIds, + resources: options.readiness.resources, + readyStates: options.readiness.readyStates, + resourceDeclarations: options.readiness.resourceDeclarations, + }, + ); + const readiness = loadSet?.nextReadiness; + return { + ...(selection === undefined ? {} : { planningSelection: selection }), + ...(readiness === undefined ? {} : { readiness }), + ...(loadSet === undefined ? {} : { loadSet }), + nextState, + diff, + plan, + ...(options.debug === false ? {} : { + debug: createPolyWorldPlanDebugSnapshot(diff, plan, { + ...options.debug, + ...(readiness === undefined ? {} : { readiness }), + ...(loadSet === undefined ? {} : { loadSet }), + ...(selection === undefined ? {} : { planningSelection: selection }), + }), + }), + }; +} + +function resolveTransitionSelection( + topology: PolyWorldTopology, + options: PolyWorldTransitionOptions, +): PolyWorldSelection | undefined { + if (options.selection === undefined) return undefined; + if (options.relations === undefined || options.relations === false) return options.selection; + if (options.resolution !== undefined) return options.selection; + return expandPolyWorldSelectionElementRelations(topology, options.selection, options.relations); +} diff --git a/packages/world/src/planner/types.ts b/packages/world/src/planner/types.ts new file mode 100644 index 000000000..b5ac4d797 --- /dev/null +++ b/packages/world/src/planner/types.ts @@ -0,0 +1,79 @@ +import type { PolyWorldData, PolyWorldElement } from "../topology"; + +export type PolyWorldPlanAction = "show" | "hide" | "retain" | "preload" | "noop"; +export type PolyWorldPlanReason = "added" | "removed" | "retained" | "no-match"; +export type PolyWorldPlanPhase = "preload" | "mount" | "activate" | "render" | "order" | "cleanup"; +export type PolyWorldPlanStateName = + | "visible" + | "loaded" + | "resident" + | "active" + | "rendered" + | "preloaded"; + +export type PolyWorldPlanReasonActions = Partial>; +export type PolyWorldPlanActionCounts = Record; +export type PolyWorldPlanTargetState = Partial>; +export type PolyWorldPlanReasonTargetStates = Partial>; + +export interface PolyWorldPlanEntryCheckContext { + policy: PolyWorldLayerPlanPolicy; + layer: string; + action: PolyWorldPlanAction; + reason: PolyWorldPlanReason; + element?: PolyWorldElement; + elementId?: string; +} + +export interface PolyWorldPlanCheckResult { + id: string; + ok: boolean; + label?: string; + message?: string; + data?: PolyWorldData; +} + +export type PolyWorldPlanCheckInput = + | readonly PolyWorldPlanCheckResult[] + | ((context: PolyWorldPlanEntryCheckContext) => readonly PolyWorldPlanCheckResult[] | undefined); + +export interface PolyWorldLayerPlanPolicy { + id?: string; + layer: string; + phase?: PolyWorldPlanPhase; + elementLayers?: readonly string[]; + tags?: readonly string[]; + elementKinds?: readonly string[]; + elementIds?: readonly string[]; + actions?: PolyWorldPlanReasonActions; + targetStates?: PolyWorldPlanReasonTargetStates; + guards?: PolyWorldPlanCheckInput; + dependencies?: PolyWorldPlanCheckInput; + emitNoop?: boolean; + data?: PolyWorldData; +} + +export interface PolyWorldPlanEntry { + key: string; + policyId?: string; + layer: string; + elementId?: string; + action: PolyWorldPlanAction; + reason: PolyWorldPlanReason; + phase?: PolyWorldPlanPhase; + targetState: PolyWorldPlanTargetState; + guards?: readonly PolyWorldPlanCheckResult[]; + dependencies?: readonly PolyWorldPlanCheckResult[]; + blocked?: boolean; + reasonLabels: readonly string[]; + data?: PolyWorldData; +} + +export interface PolyWorldLayerPlan { + previousSignature: string; + nextSignature: string; + changed: boolean; + entries: readonly PolyWorldPlanEntry[]; + actionCounts: PolyWorldPlanActionCounts; + layerCounts: Readonly>; +} diff --git a/packages/world/src/profiles/artifact.ts b/packages/world/src/profiles/artifact.ts new file mode 100644 index 000000000..c0bfb51b5 --- /dev/null +++ b/packages/world/src/profiles/artifact.ts @@ -0,0 +1,492 @@ +export type PolyWorldProfileArtifactProfile = "bsp-pvs" | "area-portals" | "portal-flow" | "chunk-traversal"; + +export type PolyWorldProfileArtifactKind = + | "compiled-bsp-pvs" + | "authored-area-portals" + | "authored-area-portal-flow" + | "chunk-working-set"; + +export type PolyWorldProfileArtifactSourceKind = + | "compiled" + | "authored" + | "authored-runtime-selection"; + +export interface PolyWorldProfileArtifactDiagnostic { + code: string; + message: string; + id?: string; + field?: string; + kind?: string; +} + +export interface PolyWorldProfileArtifactProof { + schemaVersion: 1; + profile: PolyWorldProfileArtifactProfile; + artifactKind: PolyWorldProfileArtifactKind; + sourceKind: PolyWorldProfileArtifactSourceKind; + producedBy: string; + guarantees: readonly string[]; + knownWeaknesses: readonly string[]; + counts: Readonly>; + coverage: Readonly>; + diagnostics: readonly PolyWorldProfileArtifactDiagnostic[]; +} + +export interface PolyWorldProfileArtifactProofInput { + profile: PolyWorldProfileArtifactProfile; + artifactKind: PolyWorldProfileArtifactKind; + sourceKind: PolyWorldProfileArtifactSourceKind; + producedBy: string; + guarantees?: readonly string[]; + knownWeaknesses?: readonly string[]; + counts?: Readonly>; + coverage?: Readonly>; + diagnostics?: readonly PolyWorldProfileArtifactDiagnostic[]; +} + +export interface PolyWorldProfileArtifactProofAudit { + schemaVersion: 1; + profile: PolyWorldProfileArtifactProfile; + valid: boolean; + diagnostics: readonly PolyWorldProfileArtifactDiagnostic[]; +} + +export interface PolyWorldProfileArtifactBundleRef { + id: string; + profile: PolyWorldProfileArtifactProfile; + artifactKind?: PolyWorldProfileArtifactKind; + sourceKind?: PolyWorldProfileArtifactSourceKind; + producedBy?: string; + elementIds?: readonly string[]; + spatialElementIds?: readonly string[]; + resourceIds?: readonly string[]; +} + +export interface PolyWorldProfileArtifactBundleEntryInput { + id?: string; + ref: PolyWorldProfileArtifactBundleRef; + proof: PolyWorldProfileArtifactProof; +} + +export interface PolyWorldProfileArtifactBundleEntry { + schemaVersion: 1; + id: string; + ref: PolyWorldProfileArtifactBundleRef; + proof: PolyWorldProfileArtifactProof; + audit: PolyWorldProfileArtifactProofAudit; + valid: boolean; + diagnostics: readonly PolyWorldProfileArtifactDiagnostic[]; +} + +export interface PolyWorldProfileArtifactBundleInput { + entries: readonly PolyWorldProfileArtifactBundleEntryInput[]; +} + +export interface PolyWorldProfileArtifactBundle { + schemaVersion: 1; + entries: readonly PolyWorldProfileArtifactBundleEntry[]; + entriesById: ReadonlyMap; + entryIdsByProfile: ReadonlyMap; + valid: boolean; + diagnostics: readonly PolyWorldProfileArtifactDiagnostic[]; +} + +interface PolyWorldProfileArtifactRule { + artifactKind: PolyWorldProfileArtifactKind; + sourceKinds: readonly PolyWorldProfileArtifactSourceKind[]; + fallbackSourceKind: PolyWorldProfileArtifactSourceKind; + forbiddenGuarantees: readonly string[]; +} + +const bspPvsGuarantees = [ + "tree-root-leaf-reference-audit", + "portal-endpoint-audit", + "portal-leaf-adjacency-audit", + "pvs-bitset-width-audit", + "pvs-direct-adjacency-audit", + "pvs-metadata-decode-audit", + "compiled-bsp-pvs", + "baked-pvs-bitsets", + "portal-flood-broad-visibility", + "portal-clipped-baked-pvs", + "view-clipped-pvs-traversal", +] as const; + +const portalFlowOnlyGuarantees = [ + "camera-frustum-portal-clipping", + "trace-status-counts", +] as const; + +const artifactRules: Readonly> = { + "bsp-pvs": { + artifactKind: "compiled-bsp-pvs", + sourceKinds: ["compiled", "authored"], + fallbackSourceKind: "authored", + forbiddenGuarantees: [], + }, + "area-portals": { + artifactKind: "authored-area-portals", + sourceKinds: ["authored-runtime-selection"], + fallbackSourceKind: "authored-runtime-selection", + forbiddenGuarantees: [...bspPvsGuarantees, ...portalFlowOnlyGuarantees], + }, + "portal-flow": { + artifactKind: "authored-area-portal-flow", + sourceKinds: ["authored-runtime-selection"], + fallbackSourceKind: "authored-runtime-selection", + forbiddenGuarantees: bspPvsGuarantees, + }, + "chunk-traversal": { + artifactKind: "chunk-working-set", + sourceKinds: ["authored-runtime-selection"], + fallbackSourceKind: "authored-runtime-selection", + forbiddenGuarantees: bspPvsGuarantees, + }, +}; + +export function createPolyWorldProfileArtifactProof( + input: PolyWorldProfileArtifactProofInput, +): PolyWorldProfileArtifactProof { + const rule = artifactRules[input.profile]; + const artifactKind = input.artifactKind === rule.artifactKind + ? input.artifactKind + : rule.artifactKind; + const sourceKind = rule.sourceKinds.includes(input.sourceKind) + ? input.sourceKind + : rule.fallbackSourceKind; + const guaranteeResult = resolveArtifactGuarantees(input.guarantees ?? [], rule.forbiddenGuarantees); + const diagnostics = [ + ...(input.artifactKind === artifactKind ? [] : [{ + code: "poly-world-profile-artifact-kind-mismatch", + message: `PolyWorld profile artifact "${input.profile}" cannot use artifact kind "${input.artifactKind}".`, + field: "artifactKind", + kind: input.artifactKind, + }]), + ...(input.sourceKind === sourceKind ? [] : [{ + code: "poly-world-profile-artifact-source-kind-mismatch", + message: `PolyWorld profile artifact "${input.profile}" cannot use source kind "${input.sourceKind}".`, + field: "sourceKind", + kind: input.sourceKind, + }]), + ...guaranteeResult.diagnostics, + ...(input.diagnostics?.map((diagnostic) => ({ ...diagnostic })) ?? []), + ]; + return { + schemaVersion: 1, + profile: input.profile, + artifactKind, + sourceKind, + producedBy: input.producedBy, + guarantees: guaranteeResult.guarantees, + knownWeaknesses: unique(input.knownWeaknesses ?? []), + counts: finiteRecord(input.counts ?? {}), + coverage: finiteRecord(input.coverage ?? {}), + diagnostics, + }; +} + +export function auditPolyWorldProfileArtifactProof( + proof: PolyWorldProfileArtifactProof, +): PolyWorldProfileArtifactProofAudit { + const diagnostics: PolyWorldProfileArtifactDiagnostic[] = []; + const rule = artifactRules[proof.profile]; + + if (proof.schemaVersion !== 1) { + diagnostics.push({ + code: "poly-world-profile-artifact-invalid-schema-version", + message: `PolyWorld profile artifact "${proof.profile}" has invalid schemaVersion "${String(proof.schemaVersion)}".`, + field: "schemaVersion", + kind: String(proof.schemaVersion), + }); + } + + if (rule === undefined) { + diagnostics.push({ + code: "poly-world-profile-artifact-invalid-profile", + message: `PolyWorld profile artifact has invalid profile "${String(proof.profile)}".`, + field: "profile", + kind: String(proof.profile), + }); + } else { + if (proof.artifactKind !== rule.artifactKind) { + diagnostics.push({ + code: "poly-world-profile-artifact-kind-mismatch", + message: `PolyWorld profile artifact "${proof.profile}" cannot use artifact kind "${proof.artifactKind}".`, + field: "artifactKind", + kind: proof.artifactKind, + }); + } + if (!rule.sourceKinds.includes(proof.sourceKind)) { + diagnostics.push({ + code: "poly-world-profile-artifact-source-kind-mismatch", + message: `PolyWorld profile artifact "${proof.profile}" cannot use source kind "${proof.sourceKind}".`, + field: "sourceKind", + kind: proof.sourceKind, + }); + } + for (const guarantee of proof.guarantees) { + if (!rule.forbiddenGuarantees.includes(guarantee)) continue; + diagnostics.push({ + code: "poly-world-profile-artifact-forbidden-guarantee", + message: `PolyWorld profile artifact cannot claim guarantee "${guarantee}".`, + id: guarantee, + field: "guarantees", + }); + } + } + + if (typeof proof.producedBy !== "string" || proof.producedBy.length === 0) { + diagnostics.push({ + code: "poly-world-profile-artifact-empty-produced-by", + message: `PolyWorld profile artifact "${proof.profile}" requires a non-empty producedBy value.`, + field: "producedBy", + }); + } + + if ( + proof.profile === "bsp-pvs" && + proof.knownWeaknesses.includes("bsp-certification-failed") && + proof.guarantees.length > 0 + ) { + diagnostics.push({ + code: "poly-world-profile-artifact-uncertified-bsp-guarantees", + message: "PolyWorld BSP/PVS artifact cannot claim guarantees when BSP certification failed.", + field: "guarantees", + kind: "bsp-pvs", + }); + } + + validateFiniteRecord(proof.profile, "counts", proof.counts, diagnostics); + validateFiniteRecord(proof.profile, "coverage", proof.coverage, diagnostics); + + return { + schemaVersion: 1, + profile: proof.profile, + valid: diagnostics.length === 0, + diagnostics, + }; +} + +export function createPolyWorldProfileArtifactBundle( + input: PolyWorldProfileArtifactBundleInput, +): PolyWorldProfileArtifactBundle { + const entries: PolyWorldProfileArtifactBundleEntry[] = []; + const entriesById = new Map(); + const entryIdsByProfile = new Map(); + const diagnostics: PolyWorldProfileArtifactDiagnostic[] = []; + + for (const entryInput of input.entries) { + const entry = createPolyWorldProfileArtifactBundleEntry(entryInput); + entries.push(entry); + if (entriesById.has(entry.id)) { + diagnostics.push({ + code: "poly-world-profile-artifact-bundle-duplicate-id", + message: `Duplicate PolyWorld profile artifact bundle id "${entry.id}".`, + id: entry.id, + field: "entries.id", + }); + } else if (entry.id.length > 0) { + entriesById.set(entry.id, entry); + } + pushMap(entryIdsByProfile, entry.proof.profile, entry.id); + diagnostics.push(...entry.diagnostics); + } + + return { + schemaVersion: 1, + entries, + entriesById, + entryIdsByProfile, + valid: diagnostics.length === 0 && entries.every((entry) => entry.valid), + diagnostics, + }; +} + +export function createPolyWorldProfileArtifactBundleEntry( + input: PolyWorldProfileArtifactBundleEntryInput, +): PolyWorldProfileArtifactBundleEntry { + const id = input.id ?? input.ref.id; + const audit = auditPolyWorldProfileArtifactProof(input.proof); + const diagnostics = [ + ...validateProfileArtifactBundleRef(id, input), + ...validateProfileArtifactBundleProofRef(input.ref, input.proof), + ...audit.diagnostics, + ]; + return { + schemaVersion: 1, + id, + ref: cloneProfileArtifactBundleRef(input.ref), + proof: cloneProfileArtifactProof(input.proof), + audit, + valid: diagnostics.length === 0, + diagnostics, + }; +} + +function resolveArtifactGuarantees( + guarantees: readonly string[], + forbiddenGuarantees: readonly string[], +): { + guarantees: readonly string[]; + diagnostics: readonly PolyWorldProfileArtifactDiagnostic[]; +} { + const forbidden = new Set(forbiddenGuarantees); + const accepted: string[] = []; + const diagnostics: PolyWorldProfileArtifactDiagnostic[] = []; + for (const guarantee of unique(guarantees)) { + if (!forbidden.has(guarantee)) { + accepted.push(guarantee); + continue; + } + diagnostics.push({ + code: "poly-world-profile-artifact-forbidden-guarantee", + message: `PolyWorld profile artifact cannot claim guarantee "${guarantee}".`, + id: guarantee, + field: "guarantees", + }); + } + return { guarantees: accepted, diagnostics }; +} + +function validateProfileArtifactBundleRef( + id: string, + input: PolyWorldProfileArtifactBundleEntryInput, +): PolyWorldProfileArtifactDiagnostic[] { + const diagnostics: PolyWorldProfileArtifactDiagnostic[] = []; + if (typeof id !== "string" || id.length === 0) { + diagnostics.push({ + code: "poly-world-profile-artifact-bundle-empty-id", + message: "PolyWorld profile artifact bundle entries require a non-empty id.", + field: "entries.id", + }); + } + if (input.id !== undefined && input.ref.id !== input.id) { + diagnostics.push({ + code: "poly-world-profile-artifact-bundle-id-mismatch", + message: `PolyWorld profile artifact bundle id "${input.id}" does not match ref id "${input.ref.id}".`, + id: input.id, + field: "entries.ref.id", + }); + } + return diagnostics; +} + +function validateProfileArtifactBundleProofRef( + ref: PolyWorldProfileArtifactBundleRef, + proof: PolyWorldProfileArtifactProof, +): PolyWorldProfileArtifactDiagnostic[] { + const diagnostics: PolyWorldProfileArtifactDiagnostic[] = []; + if (ref.profile !== proof.profile) { + diagnostics.push({ + code: "poly-world-profile-artifact-bundle-profile-mismatch", + message: `PolyWorld profile artifact ref "${ref.id}" uses profile "${ref.profile}" but proof uses "${proof.profile}".`, + id: ref.id, + field: "entries.proof.profile", + kind: proof.profile, + }); + } + if (ref.artifactKind !== undefined && ref.artifactKind !== proof.artifactKind) { + diagnostics.push({ + code: "poly-world-profile-artifact-bundle-kind-mismatch", + message: `PolyWorld profile artifact ref "${ref.id}" uses kind "${ref.artifactKind}" but proof uses "${proof.artifactKind}".`, + id: ref.id, + field: "entries.proof.artifactKind", + kind: proof.artifactKind, + }); + } + if (ref.sourceKind !== undefined && ref.sourceKind !== proof.sourceKind) { + diagnostics.push({ + code: "poly-world-profile-artifact-bundle-source-kind-mismatch", + message: `PolyWorld profile artifact ref "${ref.id}" uses source kind "${ref.sourceKind}" but proof uses "${proof.sourceKind}".`, + id: ref.id, + field: "entries.proof.sourceKind", + kind: proof.sourceKind, + }); + } + if (ref.producedBy !== undefined && ref.producedBy !== proof.producedBy) { + diagnostics.push({ + code: "poly-world-profile-artifact-bundle-producer-mismatch", + message: `PolyWorld profile artifact ref "${ref.id}" was produced by "${ref.producedBy}" but proof was produced by "${proof.producedBy}".`, + id: ref.id, + field: "entries.proof.producedBy", + kind: proof.producedBy, + }); + } + return diagnostics; +} + +function cloneProfileArtifactBundleRef( + ref: PolyWorldProfileArtifactBundleRef, +): PolyWorldProfileArtifactBundleRef { + return { + id: ref.id, + profile: ref.profile, + ...(ref.artifactKind === undefined ? {} : { artifactKind: ref.artifactKind }), + ...(ref.sourceKind === undefined ? {} : { sourceKind: ref.sourceKind }), + ...(ref.producedBy === undefined ? {} : { producedBy: ref.producedBy }), + ...(ref.elementIds === undefined ? {} : { elementIds: [...ref.elementIds] }), + ...(ref.spatialElementIds === undefined ? {} : { spatialElementIds: [...ref.spatialElementIds] }), + ...(ref.resourceIds === undefined ? {} : { resourceIds: [...ref.resourceIds] }), + }; +} + +function cloneProfileArtifactProof( + proof: PolyWorldProfileArtifactProof, +): PolyWorldProfileArtifactProof { + return { + schemaVersion: proof.schemaVersion, + profile: proof.profile, + artifactKind: proof.artifactKind, + sourceKind: proof.sourceKind, + producedBy: proof.producedBy, + guarantees: [...proof.guarantees], + knownWeaknesses: [...proof.knownWeaknesses], + counts: { ...proof.counts }, + coverage: { ...proof.coverage }, + diagnostics: proof.diagnostics.map((diagnostic) => ({ ...diagnostic })), + }; +} + +function pushMap( + map: Map, + key: TKey, + value: string, +): void { + const existing = map.get(key); + if (existing === undefined) map.set(key, [value]); + else existing.push(value); +} + +function finiteRecord(input: Readonly>): Record { + const entries = Object.entries(input) + .filter((entry): entry is [string, number] => Number.isFinite(entry[1])); + entries.sort(([a], [b]) => compareStrings(a, b)); + return Object.fromEntries(entries); +} + +function validateFiniteRecord( + profile: string, + field: "counts" | "coverage", + input: Readonly>, + diagnostics: PolyWorldProfileArtifactDiagnostic[], +): void { + for (const [key, value] of Object.entries(input)) { + if (Number.isFinite(value)) continue; + diagnostics.push({ + code: `poly-world-profile-artifact-nonfinite-${field}`, + message: `PolyWorld profile artifact "${profile}" has non-finite ${field}.${key}.`, + id: key, + field: `${field}.${key}`, + }); + } +} + +function unique(values: readonly string[]): string[] { + return [...new Set(values)]; +} + +function compareStrings(a: string, b: string): number { + if (a < b) return -1; + if (a > b) return 1; + return 0; +} diff --git a/packages/world/src/profiles/brushBsp.ts b/packages/world/src/profiles/brushBsp.ts new file mode 100644 index 000000000..977ea1986 --- /dev/null +++ b/packages/world/src/profiles/brushBsp.ts @@ -0,0 +1,1148 @@ +import type { Vec3 } from "@layoutit/polycss-core"; +import type { PolyWorldBounds, PolyWorldData } from "../topology"; +import { + bakePolyWorldBspPvs, + createPolyWorldBspTree, + PolyWorldBspError, + type PolyWorldBspChild, + type PolyWorldBspDiagnostic, + type PolyWorldBspPlane, + type PolyWorldBspPortal, + type PolyWorldBspPvsBakeOptions, + type PolyWorldBspTree, +} from "./bsp"; + +const epsilon = 0.0001; + +type BrushBspHalfspaceSide = "front" | "back"; + +interface BrushBspHalfspace { + plane: PolyWorldBspPlane; + side: BrushBspHalfspaceSide; +} + +interface BrushBspCell { + id: string; + bounds: PolyWorldBounds; + center: Vec3; + vertices?: readonly Vec3[]; + halfspaces?: readonly BrushBspHalfspace[]; + solid: boolean; + brushIds: readonly string[]; + regionId?: string; + elementIds: readonly string[]; + data?: PolyWorldData; +} + +interface BrushBspFace { + cell: BrushBspCell; + bounds: PolyWorldBounds; + vertices: readonly Vec3[]; + plane: PolyWorldBspPlane; + side: BrushBspHalfspaceSide; + sideSign: -1 | 1; +} + +interface BrushBspPortalBuildResult { + portals: readonly PolyWorldBspPortal[]; + candidateCount: number; + rejectedCandidateCount: number; +} + +interface BrushBspNodeState { + nextNodeId: number; + nextLeafId: number; + cells: BrushBspCell[]; +} + +interface PlaneBrushBspCellInput { + halfspaces: readonly BrushBspHalfspace[]; + vertices: readonly Vec3[]; + bounds: PolyWorldBounds; + center: Vec3; +} + +interface CompiledBrushBspBrush { + id: string; + halfspaces: readonly BrushBspHalfspace[]; +} + +interface BrushBspSplitPlane { + plane: PolyWorldBspPlane; + source: "brush" | "region"; + sourceId: string; + order: number; +} + +export type PolyWorldBrushBspOutsideMode = "empty" | "solid" | "flood-fill"; + +export interface PolyWorldBspBrushPlane extends PolyWorldBspPlane { + side?: BrushBspHalfspaceSide; +} + +export interface PolyWorldBspBrush { + id: string; + bounds?: PolyWorldBounds; + planes?: readonly PolyWorldBspBrushPlane[]; + data?: PolyWorldData; +} + +export interface PolyWorldBrushBspRegion { + id: string; + regionId?: string; + bounds: PolyWorldBounds; + elementIds?: readonly string[]; + data?: PolyWorldData; +} + +export interface PolyWorldBrushBspCompileInput { + worldBounds: PolyWorldBounds; + brushes: readonly PolyWorldBspBrush[]; + regions?: readonly PolyWorldBrushBspRegion[]; + outside?: PolyWorldBrushBspOutsideMode; + splitIdPrefix?: string; + bakePvs?: boolean; + pvs?: PolyWorldBspPvsBakeOptions; + data?: PolyWorldData; +} + +export interface PolyWorldBrushBspCompileResult { + tree: PolyWorldBspTree; + solidLeafIds: readonly string[]; + emptyLeafIds: readonly string[]; + outsideLeafIds: readonly string[]; + portals: readonly PolyWorldBspPortal[]; +} + +export function compilePolyWorldBrushBsp( + input: PolyWorldBrushBspCompileInput, +): PolyWorldBrushBspCompileResult { + const diagnostics = validatePolyWorldBrushBspInput(input); + if (diagnostics.length > 0) throw new PolyWorldBspError(diagnostics); + + const prefix = input.splitIdPrefix ?? "brush-bsp"; + const regions = input.regions ?? []; + const outsideMode = input.outside ?? "empty"; + const compiledBrushes = input.brushes.map((brush) => ({ + id: brush.id, + halfspaces: brushHalfspaces(brush), + })); + const compiled = compileRecursiveBrushBsp( + input.worldBounds, + compiledBrushes, + regions, + outsideMode, + prefix, + ); + const cells = compiled.cells; + const initialPortalBuild = createPlaneBrushBspPortalBuild(cells, prefix); + const outsideLeafIds = outsideMode === "flood-fill" + ? applyBrushBspOutsideFloodFill( + cells, + input.worldBounds, + initialPortalBuild.portals, + ) + : cells.filter((cell) => cell.data?.outside === true).map((cell) => cell.id); + const leaves = cells.map((cell) => ({ + id: cell.id, + ...(cell.regionId === undefined ? {} : { regionId: cell.regionId }), + bounds: cloneBounds(cell.bounds), + center: [...cell.center] as Vec3, + pvsSamplePoints: [[...cell.center] as Vec3], + ...(cell.elementIds.length === 0 ? {} : { elementIds: [...cell.elementIds] }), + data: { + ...cell.data, + compiled: true, + compiler: "brush-bsp", + solid: cell.solid, + brushIds: [...cell.brushIds], + }, + })); + const portalBuild = createPlaneBrushBspPortalBuild(cells, prefix); + const portals = portalBuild.portals; + const tree = createPolyWorldBspTree({ + root: compiled.root, + leaves, + portals, + data: { + ...input.data, + compiled: true, + compiler: "brush-bsp", + partition: "recursive-plane", + leafBuilder: "recursive-convex-halfspace", + portalBuilder: "leaf-face-overlap", + solidLeafIds: cells.filter((cell) => cell.solid).map((cell) => cell.id), + emptyLeafIds: cells.filter((cell) => !cell.solid).map((cell) => cell.id), + outsideLeafIds, + portalCandidateCount: portalBuild.candidateCount, + rejectedPortalCandidateCount: portalBuild.rejectedCandidateCount, + }, + }); + const finalTree = input.bakePvs === false ? tree : bakePolyWorldBspPvs(tree, input.pvs); + return { + tree: finalTree, + solidLeafIds: cells.filter((cell) => cell.solid).map((cell) => cell.id), + emptyLeafIds: cells.filter((cell) => !cell.solid).map((cell) => cell.id), + outsideLeafIds, + portals: finalTree.portals, + }; +} + +export function validatePolyWorldBrushBspInput( + input: PolyWorldBrushBspCompileInput, +): PolyWorldBspDiagnostic[] { + const diagnostics: PolyWorldBspDiagnostic[] = []; + const brushIds = new Set(); + const regionIds = new Set(); + validateBounds("world", "world", input.worldBounds, diagnostics); + if ( + input.outside !== undefined && + input.outside !== "empty" && + input.outside !== "solid" && + input.outside !== "flood-fill" + ) { + diagnostics.push({ + code: "poly-world-invalid-brush-bsp-outside-mode", + message: 'PolyWorld brush BSP outside must be "empty", "solid", or "flood-fill".', + field: "outside", + kind: "compile", + }); + } + + for (const brush of input.brushes) { + if (typeof brush.id !== "string" || brush.id.length === 0) { + diagnostics.push({ + code: "poly-world-empty-brush-bsp-brush-id", + message: "PolyWorld brush BSP brush requires a non-empty id.", + field: "id", + kind: "brush", + }); + } else if (brushIds.has(brush.id)) { + diagnostics.push({ + code: "poly-world-duplicate-brush-bsp-brush-id", + message: `Duplicate PolyWorld brush BSP brush id "${brush.id}".`, + id: brush.id, + field: "id", + kind: "brush", + }); + } + if (brush.id) brushIds.add(brush.id); + if (brush.bounds === undefined && (brush.planes?.length ?? 0) === 0) { + diagnostics.push({ + code: "poly-world-empty-brush-bsp-brush-shape", + message: `PolyWorld brush BSP brush "${brush.id}" requires bounds, planes, or both.`, + id: brush.id, + field: "bounds", + kind: "brush", + }); + } + if (brush.bounds !== undefined) validateBounds("brush", brush.id, brush.bounds, diagnostics); + validateBrushPlanes(brush, diagnostics); + if (brush.bounds !== undefined && !boundsContainsBounds(input.worldBounds, brush.bounds)) { + diagnostics.push({ + code: "poly-world-brush-bsp-brush-outside-world", + message: `PolyWorld brush BSP brush "${brush.id}" must be inside worldBounds.`, + id: brush.id, + field: "bounds", + kind: "brush", + }); + } + } + + for (const region of input.regions ?? []) { + if (typeof region.id !== "string" || region.id.length === 0) { + diagnostics.push({ + code: "poly-world-empty-brush-bsp-region-id", + message: "PolyWorld brush BSP region requires a non-empty id.", + field: "id", + kind: "region", + }); + } else if (regionIds.has(region.id)) { + diagnostics.push({ + code: "poly-world-duplicate-brush-bsp-region-id", + message: `Duplicate PolyWorld brush BSP region id "${region.id}".`, + id: region.id, + field: "id", + kind: "region", + }); + } + if (region.id) regionIds.add(region.id); + validateBounds("region", region.id, region.bounds, diagnostics); + if (!boundsContainsBounds(input.worldBounds, region.bounds)) { + diagnostics.push({ + code: "poly-world-brush-bsp-region-outside-world", + message: `PolyWorld brush BSP region "${region.id}" must be inside worldBounds.`, + id: region.id, + field: "bounds", + kind: "region", + }); + } + if (region.elementIds !== undefined && !isStringArray(region.elementIds)) { + diagnostics.push({ + code: "poly-world-invalid-brush-bsp-region-element-ids", + message: `PolyWorld brush BSP region "${region.id}" elementIds must contain only non-empty strings.`, + id: region.id, + field: "elementIds", + kind: "region", + }); + } + } + + return diagnostics; +} + +function compileRecursiveBrushBsp( + worldBounds: PolyWorldBounds, + compiledBrushes: readonly CompiledBrushBspBrush[], + regions: readonly PolyWorldBrushBspRegion[], + outside: PolyWorldBrushBspOutsideMode, + prefix: string, +): { root: PolyWorldBspChild; cells: BrushBspCell[] } { + const splitPlanes = collectBrushBspSplitPlanes(compiledBrushes, regions); + const rootCell = buildPlaneBrushBspCell(boundsHalfspaces(worldBounds)); + if (rootCell === undefined) { + throw new PolyWorldBspError([{ + code: "poly-world-brush-bsp-invalid-world-cell", + message: "PolyWorld brush BSP compiler could not create a valid world cell.", + kind: "compile", + }]); + } + const state: BrushBspNodeState = { nextNodeId: 0, nextLeafId: 0, cells: [] }; + return { + root: compileRecursiveBrushBspChild( + rootCell, + splitPlanes, + compiledBrushes, + regions, + outside, + prefix, + state, + 0, + ), + cells: state.cells, + }; +} + +function applyBrushBspOutsideFloodFill( + cells: BrushBspCell[], + worldBounds: PolyWorldBounds, + portals: readonly PolyWorldBspPortal[], +): string[] { + const cellsById = new Map(cells.map((cell) => [cell.id, cell])); + const portalLeafIdsByLeafId = new Map(); + for (const portal of portals) { + pushMap(portalLeafIdsByLeafId, portal.fromLeafId, portal.toLeafId); + pushMap(portalLeafIdsByLeafId, portal.toLeafId, portal.fromLeafId); + } + + const outsideLeafIds: string[] = []; + const visitedOutsideLeafIds = new Set(); + const queue = cells + .filter((cell) => !cell.solid && boundsTouchesBoundsBoundary(cell.bounds, worldBounds)) + .map((cell) => cell.id); + + while (queue.length > 0) { + const leafId = queue.shift(); + if (leafId === undefined || visitedOutsideLeafIds.has(leafId)) continue; + const cell = cellsById.get(leafId); + if (cell === undefined || cell.solid) continue; + visitedOutsideLeafIds.add(leafId); + outsideLeafIds.push(leafId); + for (const linkedLeafId of portalLeafIdsByLeafId.get(leafId) ?? []) { + if (!visitedOutsideLeafIds.has(linkedLeafId)) queue.push(linkedLeafId); + } + } + + const outsideLeafIdSet = new Set(outsideLeafIds); + for (const cell of cells) { + if (!outsideLeafIdSet.has(cell.id)) continue; + cell.solid = true; + delete cell.regionId; + cell.elementIds = []; + cell.data = { + ...cell.data, + outside: true, + outsideFill: true, + }; + } + + return outsideLeafIds; +} + +function collectBrushBspSplitPlanes( + brushes: readonly CompiledBrushBspBrush[], + regions: readonly PolyWorldBrushBspRegion[], +): BrushBspSplitPlane[] { + const planes: BrushBspSplitPlane[] = []; + let order = 0; + for (const brush of brushes) { + for (const halfspace of brush.halfspaces) { + addUniqueBrushBspSplitPlane(planes, { + plane: halfspace.plane, + source: "brush", + sourceId: brush.id, + order, + }); + order += 1; + } + } + for (const region of regions) { + for (const halfspace of boundsHalfspaces(region.bounds)) { + addUniqueBrushBspSplitPlane(planes, { + plane: halfspace.plane, + source: "region", + sourceId: region.id, + order, + }); + order += 1; + } + } + return planes; +} + +function addUniqueBrushBspSplitPlane( + planes: BrushBspSplitPlane[], + candidate: BrushBspSplitPlane, +): void { + const normalized = { + ...candidate, + plane: normalizePlane(candidate.plane), + }; + if (!planes.some((existing) => samePlane(existing.plane, normalized.plane))) planes.push(normalized); +} + +function compileRecursiveBrushBspChild( + cell: PlaneBrushBspCellInput, + splitPlanes: readonly BrushBspSplitPlane[], + brushes: readonly CompiledBrushBspBrush[], + regions: readonly PolyWorldBrushBspRegion[], + outside: PolyWorldBrushBspOutsideMode, + prefix: string, + state: BrushBspNodeState, + depth: number, +): PolyWorldBspChild { + if (depth > splitPlanes.length + 6) { + throw new PolyWorldBspError([{ + code: "poly-world-brush-bsp-recursive-depth-exceeded", + message: "PolyWorld brush BSP compiler exceeded the recursive split depth budget.", + kind: "compile", + }]); + } + const split = chooseRecursiveBrushBspSplit(cell, splitPlanes); + if (split === undefined) { + return createRecursiveBrushBspLeaf(cell, brushes, regions, outside, prefix, state); + } + const nodeId = state.nextNodeId; + state.nextNodeId += 1; + return { + id: `${prefix}-node-${nodeId}-plane-${formatNumber(split.plane.distance)}`, + plane: clonePlane(split.plane), + back: compileRecursiveBrushBspChild( + split.back, + splitPlanes, + brushes, + regions, + outside, + prefix, + state, + depth + 1, + ), + front: compileRecursiveBrushBspChild( + split.front, + splitPlanes, + brushes, + regions, + outside, + prefix, + state, + depth + 1, + ), + data: { + compiled: true, + compiler: "brush-bsp", + partition: "recursive-plane", + splitterSource: split.source, + splitterSourceId: split.sourceId, + }, + }; +} + +function chooseRecursiveBrushBspSplit( + cell: PlaneBrushBspCellInput, + splitPlanes: readonly BrushBspSplitPlane[], +): (BrushBspSplitPlane & { back: PlaneBrushBspCellInput; front: PlaneBrushBspCellInput }) | undefined { + let best: + | (BrushBspSplitPlane & { + back: PlaneBrushBspCellInput; + front: PlaneBrushBspCellInput; + score: number; + }) + | undefined; + + for (const splitPlane of splitPlanes) { + if (classifyVerticesAgainstPlane(cell.vertices, splitPlane.plane) !== "spanning") continue; + const back = buildPlaneBrushBspCell([...cell.halfspaces, { plane: splitPlane.plane, side: "back" }]); + const front = buildPlaneBrushBspCell([...cell.halfspaces, { plane: splitPlane.plane, side: "front" }]); + if (back === undefined || front === undefined) continue; + const backVolume = boundsVolume(back.bounds); + const frontVolume = boundsVolume(front.bounds); + const totalVolume = Math.max(epsilon, backVolume + frontVolume); + const balancePenalty = Math.abs(backVolume - frontVolume) / totalVolume; + const sourcePenalty = splitPlane.source === "brush" ? 0 : 4; + const score = sourcePenalty + balancePenalty + splitPlane.order * 0.0001; + if (best === undefined || score < best.score) { + best = { ...splitPlane, back, front, score }; + } + } + + return best; +} + +function createRecursiveBrushBspLeaf( + cell: PlaneBrushBspCellInput, + brushes: readonly CompiledBrushBspBrush[], + regions: readonly PolyWorldBrushBspRegion[], + outside: PolyWorldBrushBspOutsideMode, + prefix: string, + state: BrushBspNodeState, +): PolyWorldBspChild { + const brushIds = brushes + .filter((brush) => cellInsideHalfspaces(cell.vertices, brush.halfspaces)) + .map((brush) => brush.id); + const region = resolveBrushBspRegion(cell.center, regions); + const outsideSolid = outside === "solid" && region === undefined; + const solid = brushIds.length > 0 || outsideSolid; + const emptyRegion = solid ? undefined : region; + const id = `${prefix}-leaf-${state.nextLeafId}`; + state.nextLeafId += 1; + state.cells.push({ + id, + bounds: cloneBounds(cell.bounds), + center: [...cell.center] as Vec3, + vertices: cell.vertices.map((vertex) => [...vertex] as Vec3), + halfspaces: cell.halfspaces.map((halfspace) => ({ + plane: clonePlane(halfspace.plane), + side: halfspace.side, + })), + solid, + brushIds, + ...(emptyRegion === undefined ? {} : { regionId: emptyRegion.regionId ?? emptyRegion.id }), + elementIds: emptyRegion?.elementIds === undefined ? [] : [...emptyRegion.elementIds], + data: { + ...emptyRegion?.data, + ...(outsideSolid ? { outside: true } : {}), + }, + }); + return { leafId: id }; +} + +function buildPlaneBrushBspCell( + halfspaces: readonly BrushBspHalfspace[], +): PlaneBrushBspCellInput | undefined { + const vertices: Vec3[] = []; + for (let a = 0; a < halfspaces.length - 2; a += 1) { + for (let b = a + 1; b < halfspaces.length - 1; b += 1) { + for (let c = b + 1; c < halfspaces.length; c += 1) { + const point = intersectPlanes( + halfspaces[a]?.plane, + halfspaces[b]?.plane, + halfspaces[c]?.plane, + ); + if (point !== undefined && pointInsideHalfspaces(point, halfspaces)) { + pushUniqueVec3(vertices, point); + } + } + } + } + if (vertices.length < 4 || !hasNonZeroVolume(vertices)) return undefined; + const bounds = boundsFromPoints(vertices); + return { + halfspaces: halfspaces.map((halfspace) => ({ + plane: clonePlane(halfspace.plane), + side: halfspace.side, + })), + vertices, + bounds, + center: averageVec3(vertices), + }; +} + +function createPlaneBrushBspPortalBuild( + cells: readonly BrushBspCell[], + prefix: string, +): BrushBspPortalBuildResult { + const portals: PolyWorldBspPortal[] = []; + const facesByPlane = new Map(); + const seenPortals = new Set(); + let index = 0; + let candidateCount = 0; + let rejectedCandidateCount = 0; + + for (const cell of cells) { + if (cell.solid || cell.vertices === undefined || cell.halfspaces === undefined) continue; + for (const halfspace of cell.halfspaces) { + const vertices = faceVerticesFromCell(cell.vertices, halfspace.plane); + if (vertices.length < 3) continue; + const canonical = canonicalPlane(halfspace.plane); + pushMap(facesByPlane, canonicalPlaneKey(halfspace.plane), { + cell, + bounds: boundsFromPoints(vertices), + vertices, + plane: halfspace.plane, + side: halfspace.side, + sideSign: brushBspFaceSideSign(halfspace, canonical.normal), + }); + } + } + + for (const faces of facesByPlane.values()) { + for (let aIndex = 0; aIndex < faces.length; aIndex += 1) { + const a = faces[aIndex]; + if (a === undefined) continue; + for (let bIndex = aIndex + 1; bIndex < faces.length; bIndex += 1) { + const b = faces[bIndex]; + if (b === undefined || a.cell.id === b.cell.id || a.sideSign === b.sideSign) continue; + candidateCount += 1; + const vertices = overlapBrushBspFaces(a, b); + if (vertices.length < 3) { + rejectedCandidateCount += 1; + continue; + } + const dedupeKey = portalOverlapKey(a, b, vertices); + if (seenPortals.has(dedupeKey)) { + rejectedCandidateCount += 1; + continue; + } + seenPortals.add(dedupeKey); + const bounds = boundsFromPoints(vertices); + portals.push({ + id: `${prefix}-portal-${index}`, + fromLeafId: a.cell.id, + toLeafId: b.cell.id, + vertices, + data: { + compiled: true, + compiler: "brush-bsp", + partition: "recursive-plane", + portalBuilder: "leaf-face-overlap", + bounds: cloneBounds(bounds), + fromFaceBounds: cloneBounds(a.bounds), + toFaceBounds: cloneBounds(b.bounds), + }, + }); + index += 1; + } + } + } + + return { portals, candidateCount, rejectedCandidateCount }; +} + +function pushMap(map: Map, key: Key, value: Value): void { + const existing = map.get(key); + if (existing === undefined) map.set(key, [value]); + else existing.push(value); +} + +function validateBrushPlanes( + brush: PolyWorldBspBrush, + diagnostics: PolyWorldBspDiagnostic[], +): void { + if (brush.planes === undefined) return; + if (brush.planes.length === 0) { + diagnostics.push({ + code: "poly-world-empty-brush-bsp-brush-planes", + message: `PolyWorld brush BSP brush "${brush.id}" planes must not be empty when provided.`, + id: brush.id, + field: "planes", + kind: "brush", + }); + return; + } + if (brush.bounds === undefined && brush.planes.length < 4) { + diagnostics.push({ + code: "poly-world-open-brush-bsp-brush-planes", + message: `PolyWorld brush BSP brush "${brush.id}" requires at least four planes without bounds.`, + id: brush.id, + field: "planes", + kind: "brush", + }); + } + brush.planes.forEach((plane, index) => { + if (!isVec3(plane.normal) || !isFiniteNumber(plane.distance)) { + diagnostics.push({ + code: "poly-world-invalid-brush-bsp-plane", + message: `PolyWorld brush BSP brush "${brush.id}" plane ${index} must have a finite normal and distance.`, + id: brush.id, + field: "planes", + kind: "brush", + }); + return; + } + if (vecLength(plane.normal) <= epsilon) { + diagnostics.push({ + code: "poly-world-zero-brush-bsp-plane-normal", + message: `PolyWorld brush BSP brush "${brush.id}" plane ${index} normal cannot be zero.`, + id: brush.id, + field: "planes", + kind: "brush", + }); + } + if (plane.epsilon !== undefined && (!isFiniteNumber(plane.epsilon) || plane.epsilon < 0)) { + diagnostics.push({ + code: "poly-world-invalid-brush-bsp-plane-epsilon", + message: `PolyWorld brush BSP brush "${brush.id}" plane ${index} epsilon must be finite and non-negative.`, + id: brush.id, + field: "planes", + kind: "brush", + }); + } + if (plane.side !== undefined && plane.side !== "front" && plane.side !== "back") { + diagnostics.push({ + code: "poly-world-invalid-brush-bsp-plane-side", + message: `PolyWorld brush BSP brush "${brush.id}" plane ${index} side must be "front" or "back".`, + id: brush.id, + field: "planes", + kind: "brush", + }); + } + }); +} + +function brushHalfspaces(brush: PolyWorldBspBrush): BrushBspHalfspace[] { + return [ + ...(brush.bounds === undefined ? [] : boundsHalfspaces(brush.bounds)), + ...(brush.planes ?? []).map((plane) => ({ + plane: normalizePlane(plane), + side: plane.side ?? "back", + })), + ]; +} + +function boundsHalfspaces(bounds: PolyWorldBounds): BrushBspHalfspace[] { + return [ + { plane: { normal: [1, 0, 0], distance: bounds.min[0] }, side: "front" }, + { plane: { normal: [1, 0, 0], distance: bounds.max[0] }, side: "back" }, + { plane: { normal: [0, 1, 0], distance: bounds.min[1] }, side: "front" }, + { plane: { normal: [0, 1, 0], distance: bounds.max[1] }, side: "back" }, + { plane: { normal: [0, 0, 1], distance: bounds.min[2] }, side: "front" }, + { plane: { normal: [0, 0, 1], distance: bounds.max[2] }, side: "back" }, + ]; +} + +function normalizePlane(plane: PolyWorldBspPlane): PolyWorldBspPlane { + const length = vecLength(plane.normal); + if (length <= epsilon) return clonePlane(plane); + return { + normal: [ + plane.normal[0] / length, + plane.normal[1] / length, + plane.normal[2] / length, + ], + distance: plane.distance / length, + ...(plane.epsilon === undefined ? {} : { epsilon: plane.epsilon / length }), + }; +} + +function clonePlane(plane: PolyWorldBspPlane): PolyWorldBspPlane { + return { + normal: [...plane.normal] as Vec3, + distance: plane.distance, + ...(plane.epsilon === undefined ? {} : { epsilon: plane.epsilon }), + }; +} + +function pointInsideHalfspaces(point: Vec3, halfspaces: readonly BrushBspHalfspace[]): boolean { + return halfspaces.every((halfspace) => { + const signed = signedDistance(halfspace.plane, point); + const planeEpsilon = halfspace.plane.epsilon ?? epsilon; + return halfspace.side === "front" ? signed >= -planeEpsilon : signed <= planeEpsilon; + }); +} + +function cellInsideHalfspaces(vertices: readonly Vec3[], halfspaces: readonly BrushBspHalfspace[]): boolean { + return vertices.every((vertex) => pointInsideHalfspaces(vertex, halfspaces)); +} + +function classifyVerticesAgainstPlane( + vertices: readonly Vec3[], + plane: PolyWorldBspPlane, +): "front" | "back" | "spanning" | "coplanar" { + let hasFront = false; + let hasBack = false; + const planeEpsilon = plane.epsilon ?? epsilon; + for (const vertex of vertices) { + const signed = signedDistance(plane, vertex); + if (signed > planeEpsilon) hasFront = true; + if (signed < -planeEpsilon) hasBack = true; + if (hasFront && hasBack) return "spanning"; + } + if (hasFront) return "front"; + if (hasBack) return "back"; + return "coplanar"; +} + +function samePlane(a: PolyWorldBspPlane, b: PolyWorldBspPlane): boolean { + const an = normalizePlane(a); + const bn = normalizePlane(b); + return ( + Math.abs(an.normal[0] - bn.normal[0]) <= epsilon && + Math.abs(an.normal[1] - bn.normal[1]) <= epsilon && + Math.abs(an.normal[2] - bn.normal[2]) <= epsilon && + Math.abs(an.distance - bn.distance) <= epsilon + ) || ( + Math.abs(an.normal[0] + bn.normal[0]) <= epsilon && + Math.abs(an.normal[1] + bn.normal[1]) <= epsilon && + Math.abs(an.normal[2] + bn.normal[2]) <= epsilon && + Math.abs(an.distance + bn.distance) <= epsilon + ); +} + +function intersectPlanes( + a: PolyWorldBspPlane | undefined, + b: PolyWorldBspPlane | undefined, + c: PolyWorldBspPlane | undefined, +): Vec3 | undefined { + if (a === undefined || b === undefined || c === undefined) return undefined; + const ab = normalizePlane(a); + const bb = normalizePlane(b); + const cb = normalizePlane(c); + const bc = cross(bb.normal, cb.normal); + const ca = cross(cb.normal, ab.normal); + const abCross = cross(ab.normal, bb.normal); + const denominator = dot(ab.normal, bc); + if (Math.abs(denominator) <= epsilon) return undefined; + return [ + (ab.distance * bc[0] + bb.distance * ca[0] + cb.distance * abCross[0]) / denominator, + (ab.distance * bc[1] + bb.distance * ca[1] + cb.distance * abCross[1]) / denominator, + (ab.distance * bc[2] + bb.distance * ca[2] + cb.distance * abCross[2]) / denominator, + ]; +} + +function faceVerticesFromCell(vertices: readonly Vec3[], plane: PolyWorldBspPlane): Vec3[] { + return orderFaceVertices( + uniqueVec3(vertices.filter((vertex) => Math.abs(signedDistance(plane, vertex)) <= (plane.epsilon ?? epsilon))), + plane.normal, + ); +} + +function orderFaceVertices(vertices: readonly Vec3[], normal: Vec3): Vec3[] { + if (vertices.length < 3) return vertices.map((vertex) => [...vertex] as Vec3); + const center = averageVec3(vertices); + const unitNormal = normalizeVec3(normal); + const seed: Vec3 = Math.abs(unitNormal[2]) < 0.9 ? [0, 0, 1] : [0, 1, 0]; + const u = normalizeVec3(cross(seed, unitNormal)); + const v = cross(unitNormal, u); + return [...vertices] + .sort((a, b) => + Math.atan2(dot(subtract(a, center), v), dot(subtract(a, center), u)) - + Math.atan2(dot(subtract(b, center), v), dot(subtract(b, center), u)), + ) + .map((vertex) => [...vertex] as Vec3); +} + +function overlapBrushBspFaces(a: BrushBspFace, b: BrushBspFace): Vec3[] { + const normal = normalizeVec3(canonicalPlane(a.plane).normal); + if (vecLength(normal) <= epsilon) return []; + const subject = orderFaceVertices(a.vertices, normal); + const clip = orderFaceVertices(b.vertices, normal); + const clipped = clipConvexPolygonByConvexPolygon(subject, clip, normal); + const vertices = orderFaceVertices(uniqueVec3(clipped), normal); + if (vertices.length < 3) return []; + const area = Math.abs(dot(polygonAreaNormal(vertices), normal)); + return area <= epsilon ? [] : vertices; +} + +function clipConvexPolygonByConvexPolygon( + subject: readonly Vec3[], + clip: readonly Vec3[], + normal: Vec3, +): Vec3[] { + let output = subject.map((vertex) => [...vertex] as Vec3); + for (let index = 0; index < clip.length; index += 1) { + const edgeStart = clip[index] ?? [0, 0, 0]; + const edgeEnd = clip[(index + 1) % clip.length] ?? edgeStart; + output = clipConvexPolygonByEdge(output, edgeStart, edgeEnd, normal); + if (output.length < 3) return []; + } + return output; +} + +function clipConvexPolygonByEdge( + vertices: readonly Vec3[], + edgeStart: Vec3, + edgeEnd: Vec3, + normal: Vec3, +): Vec3[] { + const clipped: Vec3[] = []; + for (let index = 0; index < vertices.length; index += 1) { + const previous = vertices[(index + vertices.length - 1) % vertices.length] ?? vertices[index] ?? [0, 0, 0]; + const current = vertices[index] ?? previous; + const previousDistance = edgeSignedDistance(edgeStart, edgeEnd, previous, normal); + const currentDistance = edgeSignedDistance(edgeStart, edgeEnd, current, normal); + const previousInside = previousDistance >= -epsilon; + const currentInside = currentDistance >= -epsilon; + if (currentInside) { + if (!previousInside) { + clipped.push(intersectEdgeClipSegment(previous, current, previousDistance, currentDistance)); + } + clipped.push([...current] as Vec3); + } else if (previousInside) { + clipped.push(intersectEdgeClipSegment(previous, current, previousDistance, currentDistance)); + } + } + return uniqueVec3(clipped); +} + +function edgeSignedDistance(edgeStart: Vec3, edgeEnd: Vec3, point: Vec3, normal: Vec3): number { + return dot(cross(subtract(edgeEnd, edgeStart), subtract(point, edgeStart)), normal); +} + +function intersectEdgeClipSegment( + a: Vec3, + b: Vec3, + aDistance: number, + bDistance: number, +): Vec3 { + const denominator = aDistance - bDistance; + const t = Math.abs(denominator) <= epsilon ? 0 : aDistance / denominator; + return [ + a[0] + (b[0] - a[0]) * t, + a[1] + (b[1] - a[1]) * t, + a[2] + (b[2] - a[2]) * t, + ]; +} + +function brushBspFaceSideSign(halfspace: BrushBspHalfspace, canonicalNormal: Vec3): -1 | 1 { + const normalized = normalizePlane(halfspace.plane); + const normalSign = dot(normalized.normal, canonicalNormal) >= 0 ? 1 : -1; + const sideSign = halfspace.side === "front" ? normalSign : -normalSign; + return sideSign >= 0 ? 1 : -1; +} + +function portalOverlapKey(a: BrushBspFace, b: BrushBspFace, vertices: readonly Vec3[]): string { + const leafKey = [a.cell.id, b.cell.id].sort().join("<>"); + const vertexKey = [...vertices] + .map((vertex) => vertex.map(formatNumber).join(",")) + .sort() + .join("|"); + return `${leafKey}:${vertexKey}`; +} + +function canonicalPlaneKey(plane: PolyWorldBspPlane): string { + const canonical = canonicalPlane(plane); + return `${canonical.normal.map(formatNumber).join(",")}:${formatNumber(canonical.distance)}`; +} + +function canonicalPlane(plane: PolyWorldBspPlane): PolyWorldBspPlane { + const normalized = normalizePlane(plane); + let normal = normalized.normal; + let distance = normalized.distance; + const flip = normal.find((component) => Math.abs(component) > epsilon) ?? 0; + if (flip < 0) { + normal = [-normal[0], -normal[1], -normal[2]]; + distance = -distance; + } + return { + normal: [...normal] as Vec3, + distance, + ...(normalized.epsilon === undefined ? {} : { epsilon: normalized.epsilon }), + }; +} + +function signedDistance(plane: PolyWorldBspPlane, point: Vec3): number { + return dot(plane.normal, point) - plane.distance; +} + +function pushUniqueVec3(points: Vec3[], point: Vec3): void { + if (!points.some((existing) => distanceSq(existing, point) <= epsilon * epsilon)) { + points.push([point[0], point[1], point[2]]); + } +} + +function uniqueVec3(points: readonly Vec3[]): Vec3[] { + const result: Vec3[] = []; + for (const point of points) pushUniqueVec3(result, point); + return result; +} + +function boundsFromPoints(points: readonly Vec3[]): PolyWorldBounds { + const min: Vec3 = [Infinity, Infinity, Infinity]; + const max: Vec3 = [-Infinity, -Infinity, -Infinity]; + for (const point of points) { + for (const axis of [0, 1, 2] as const) { + min[axis] = Math.min(min[axis], point[axis]); + max[axis] = Math.max(max[axis], point[axis]); + } + } + return { min, max }; +} + +function averageVec3(points: readonly Vec3[]): Vec3 { + const total = points.reduce((acc, point) => [ + acc[0] + point[0], + acc[1] + point[1], + acc[2] + point[2], + ] as Vec3, [0, 0, 0] as Vec3); + return [total[0] / points.length, total[1] / points.length, total[2] / points.length]; +} + +function hasNonZeroVolume(points: readonly Vec3[]): boolean { + for (let a = 0; a < points.length - 3; a += 1) { + for (let b = a + 1; b < points.length - 2; b += 1) { + for (let c = b + 1; c < points.length - 1; c += 1) { + for (let d = c + 1; d < points.length; d += 1) { + const pa = points[a]; + const pb = points[b]; + const pc = points[c]; + const pd = points[d]; + if (pa === undefined || pb === undefined || pc === undefined || pd === undefined) continue; + const volume = Math.abs(dot(cross(subtract(pb, pa), subtract(pc, pa)), subtract(pd, pa))); + if (volume > epsilon) return true; + } + } + } + } + return false; +} + +function polygonAreaNormal(vertices: readonly Vec3[]): Vec3 { + return vertices.reduce((acc, vertex, index) => { + const next = vertices[(index + 1) % vertices.length] ?? vertex; + return [ + acc[0] + vertex[1] * next[2] - vertex[2] * next[1], + acc[1] + vertex[2] * next[0] - vertex[0] * next[2], + acc[2] + vertex[0] * next[1] - vertex[1] * next[0], + ]; + }, [0, 0, 0]); +} + +function normalizeVec3(value: Vec3): Vec3 { + const length = vecLength(value); + if (length <= epsilon) return [0, 0, 0]; + return [value[0] / length, value[1] / length, value[2] / length]; +} + +function vecLength(value: Vec3): number { + return Math.hypot(value[0], value[1], value[2]); +} + +function distanceSq(a: Vec3, b: Vec3): number { + const dx = a[0] - b[0]; + const dy = a[1] - b[1]; + const dz = a[2] - b[2]; + return dx * dx + dy * dy + dz * dz; +} + +function subtract(a: Vec3, b: Vec3): Vec3 { + return [a[0] - b[0], a[1] - b[1], a[2] - b[2]]; +} + +function dot(a: Vec3, b: Vec3): number { + return a[0] * b[0] + a[1] * b[1] + a[2] * b[2]; +} + +function cross(a: Vec3, b: Vec3): Vec3 { + return [ + a[1] * b[2] - a[2] * b[1], + a[2] * b[0] - a[0] * b[2], + a[0] * b[1] - a[1] * b[0], + ]; +} + +function validateBounds( + kind: "world" | "brush" | "region", + id: string, + bounds: PolyWorldBounds, + diagnostics: PolyWorldBspDiagnostic[], +): void { + if (!isVec3(bounds.min) || !isVec3(bounds.max)) { + diagnostics.push({ + code: "poly-world-invalid-brush-bsp-bounds", + message: `PolyWorld brush BSP ${kind} "${id}" bounds must be finite Vec3 min/max.`, + id, + field: "bounds", + kind: kind === "world" ? "compile" : kind, + }); + return; + } + for (const axis of [0, 1, 2] as const) { + if (bounds.min[axis] >= bounds.max[axis]) { + diagnostics.push({ + code: "poly-world-invalid-brush-bsp-bounds-order", + message: `PolyWorld brush BSP ${kind} "${id}" bounds.min must be < bounds.max on every axis.`, + id, + field: "bounds", + kind: kind === "world" ? "compile" : kind, + }); + return; + } + } +} + +function resolveBrushBspRegion( + point: Vec3, + regions: readonly PolyWorldBrushBspRegion[], +): PolyWorldBrushBspRegion | undefined { + return regions + .filter((region) => boundsContainsPoint(region.bounds, point)) + .sort((a, b) => boundsVolume(a.bounds) - boundsVolume(b.bounds) || a.id.localeCompare(b.id))[0]; +} + +function boundsVolume(bounds: PolyWorldBounds): number { + return Math.max(0, bounds.max[0] - bounds.min[0]) * + Math.max(0, bounds.max[1] - bounds.min[1]) * + Math.max(0, bounds.max[2] - bounds.min[2]); +} + +function boundsContainsBounds(container: PolyWorldBounds, bounds: PolyWorldBounds): boolean { + return ([0, 1, 2] as const).every((axis) => + bounds.min[axis] >= container.min[axis] - 0.0001 && + bounds.max[axis] <= container.max[axis] + 0.0001 + ); +} + +function boundsContainsPoint(bounds: PolyWorldBounds, point: Vec3): boolean { + return ([0, 1, 2] as const).every((axis) => + point[axis] >= bounds.min[axis] - 0.0001 && + point[axis] <= bounds.max[axis] + 0.0001 + ); +} + +function boundsTouchesBoundsBoundary(bounds: PolyWorldBounds, container: PolyWorldBounds): boolean { + return ([0, 1, 2] as const).some((axis) => + Math.abs(bounds.min[axis] - container.min[axis]) <= 0.0001 || + Math.abs(bounds.max[axis] - container.max[axis]) <= 0.0001 + ); +} + +function cloneBounds(bounds: PolyWorldBounds): PolyWorldBounds { + return { + min: [...bounds.min] as Vec3, + max: [...bounds.max] as Vec3, + }; +} + +function boundsCenter(bounds: PolyWorldBounds): Vec3 { + return [ + (bounds.min[0] + bounds.max[0]) / 2, + (bounds.min[1] + bounds.max[1]) / 2, + (bounds.min[2] + bounds.max[2]) / 2, + ]; +} + +function formatNumber(value: number): string { + return String(Math.round(value * 1000) / 1000).replace("-", "neg-").replace(".", "p"); +} + +function isVec3(value: unknown): value is Vec3 { + return Array.isArray(value) && value.length === 3 && value.every(isFiniteNumber); +} + +function isStringArray(value: unknown): value is readonly string[] { + return Array.isArray(value) && value.every((item) => typeof item === "string" && item.length > 0); +} + +function isFiniteNumber(value: unknown): value is number { + return typeof value === "number" && Number.isFinite(value); +} diff --git a/packages/world/src/profiles/bsp.ts b/packages/world/src/profiles/bsp.ts new file mode 100644 index 000000000..bedf94f75 --- /dev/null +++ b/packages/world/src/profiles/bsp.ts @@ -0,0 +1,2913 @@ +import type { Vec3 } from "@layoutit/polycss-core"; +import type { + PolyWorldBounds, + PolyWorldData, + PolyWorldSelection, + PolyWorldSelectionReason, + PolyWorldSpatialElement, + PolyWorldSpatialElementRole, + PolyWorldSpatialElementVisibility, + PolyWorldTopology, +} from "../topology"; +import { + resolvePolyWorldSpatialElementRole, + resolvePolyWorldSpatialElementVisibility, +} from "../topology"; +import { + addVec3, + averageVec3, + crossVec3 as cross, + dotVec3 as dot, + normalizeVec3OrUndefined as normalizeBspVector, + polygonAreaNormal, + scaleVec3, + subtractVec3, + uniqueVec3, +} from "./bspGeometry"; + +export type PolyWorldBspPvsProjection = "xy" | "xz" | "yz"; + +interface PolyWorldBspClipPlane { + normal: Vec3; + distance: number; +} + +interface PolyWorldBspPortalClip { + planes: readonly PolyWorldBspClipPlane[]; + origin: Vec3; + rays: readonly Vec3[]; +} + +export interface PolyWorldBspPlane { + normal: Vec3; + distance: number; + epsilon?: number; +} + +export interface PolyWorldBspLeafRef { + leafId: string; +} + +export interface PolyWorldBspNode { + id: string; + plane: PolyWorldBspPlane; + front: PolyWorldBspChild; + back: PolyWorldBspChild; + onPlane?: "front" | "back"; + data?: PolyWorldData; +} + +export type PolyWorldBspChild = PolyWorldBspNode | PolyWorldBspLeafRef; + +export interface PolyWorldBspLeaf { + id: string; + regionId?: string; + clusterId?: string; + bounds?: PolyWorldBounds; + center?: Vec3; + pvsSamplePoints?: readonly Vec3[]; + pvs?: PolyWorldBspBakedPvs; + elementIds?: readonly string[]; + data?: PolyWorldData; +} + +export interface PolyWorldBspPvsIndex { + leafIds: readonly string[]; + portalIds: readonly string[]; + leafIndexById: ReadonlyMap; + portalIndexById: ReadonlyMap; +} + +export interface PolyWorldBspBakedPvs { + leafBits: Uint32Array; + portalBits: Uint32Array; + regionIds: readonly string[]; + linkIds: readonly string[]; + selectionKeys: readonly string[]; + elementIds: readonly string[]; +} + +export interface PolyWorldBspPortal { + id: string; + fromLeafId: string; + toLeafId: string; + linkId?: string; + vertices: readonly Vec3[]; + selectionKeys?: readonly string[]; + data?: PolyWorldData; +} + +export interface PolyWorldBspCompileRegion { + id: string; + regionId?: string; + clusterId?: string; + bounds: PolyWorldBounds; + center?: Vec3; + pvsSamplePoints?: readonly Vec3[]; + elementIds?: readonly string[]; + data?: PolyWorldData; +} + +export interface PolyWorldBspCompilePortal { + id: string; + fromRegionId: string; + toRegionId: string; + linkId?: string; + bounds?: PolyWorldBounds; + vertices?: readonly Vec3[]; + selectionKeys?: readonly string[]; + data?: PolyWorldData; +} + +export interface PolyWorldBspCompileOptions { + pvs?: PolyWorldBspPvsBakeOptions; + bakePvs?: boolean; + splitIdPrefix?: string; +} + +export interface PolyWorldBspCompileInput extends PolyWorldBspCompileOptions { + regions: readonly PolyWorldBspCompileRegion[]; + portals?: readonly PolyWorldBspCompilePortal[]; + data?: PolyWorldData; +} + +export interface PolyWorldBspTreeInput { + root: PolyWorldBspChild; + leaves: readonly PolyWorldBspLeaf[]; + portals?: readonly PolyWorldBspPortal[]; + pvsIndex?: PolyWorldBspPvsIndex; + data?: PolyWorldData; +} + +export interface PolyWorldBspTree { + root: PolyWorldBspChild; + leaves: readonly PolyWorldBspLeaf[]; + portals: readonly PolyWorldBspPortal[]; + leavesById: ReadonlyMap; + portalsById: ReadonlyMap; + portalsByLeafId: ReadonlyMap; + pvsIndex?: PolyWorldBspPvsIndex; + data?: PolyWorldData; +} + +export interface PolyWorldBspLeafResolution { + leaf: PolyWorldBspLeaf; + leafId: string; + path: readonly string[]; +} + +export interface PolyWorldBspPvsReasonLabels { + leaf?: string; + pvs?: string; + selectionKey?: string; +} + +export interface PolyWorldBspPvsSelectionOptions extends PolyWorldBspPvsBakeOptions { + point?: Vec3; + leafId?: string; + includeLeafRegion?: boolean; + includePvs?: boolean; + regionIds?: readonly string[]; + linkIds?: readonly string[]; + selectionKeys?: readonly string[]; + elementIds?: readonly string[]; + reasonLabels?: PolyWorldBspPvsReasonLabels; + reasons?: readonly PolyWorldSelectionReason[]; + data?: PolyWorldData; +} + +export interface PolyWorldBspViewPvsReasonLabels extends PolyWorldBspPvsReasonLabels { + view?: string; +} + +export interface PolyWorldBspViewPvsOptions extends PolyWorldBspPvsBakeOptions { + point: Vec3; + leafId?: string; + forward: Vec3; + up?: Vec3; + aspect?: number; + fovDegrees?: number; + near?: number; + far?: number; +} + +export interface PolyWorldBspViewPvsSelectionOptions extends PolyWorldBspViewPvsOptions { + includeLeafRegion?: boolean; + includePvs?: boolean; + regionIds?: readonly string[]; + linkIds?: readonly string[]; + selectionKeys?: readonly string[]; + elementIds?: readonly string[]; + reasonLabels?: PolyWorldBspViewPvsReasonLabels; + reasons?: readonly PolyWorldSelectionReason[]; + data?: PolyWorldData; +} + +export interface PolyWorldBspPvsBakeOptions { + projection?: PolyWorldBspPvsProjection; + sampleInset?: number; + maxDepth?: number; + includePortalSelectionKeys?: boolean; + portalState?: PolyWorldBspPortalState; +} + +export interface PolyWorldBspPortalStateContext { + fromLeafId: string; + toLeafId: string; + depth: number; +} + +export type PolyWorldBspPortalStateValue = boolean | "open" | "closed" | "blocked"; + +export type PolyWorldBspPortalStateResolver = ( + portal: PolyWorldBspPortal, + context: PolyWorldBspPortalStateContext, +) => boolean; + +export type PolyWorldBspPortalState = + | Readonly> + | PolyWorldBspPortalStateResolver; + +export interface PolyWorldBspResolvedPvs { + leafId: string; + leafIds: readonly string[]; + clusterIds: readonly string[]; + regionIds: readonly string[]; + linkIds: readonly string[]; + portalIds: readonly string[]; + selectionKeys: readonly string[]; + elementIds: readonly string[]; +} + +export interface PolyWorldBspResolvedViewPvs extends PolyWorldBspResolvedPvs { + broadPhaseLeafIds: readonly string[]; + broadPhasePortalIds: readonly string[]; + fovDegrees: number; +} + +export interface PolyWorldBspViewSurfaceElement extends PolyWorldSpatialElement { + vertices: readonly Vec3[]; + role?: PolyWorldBspViewSurfaceRole; + visibility?: PolyWorldBspViewSurfaceVisibility; +} + +export type PolyWorldBspViewSurfaceRole = PolyWorldSpatialElementRole; + +export type PolyWorldBspViewSurfaceVisibility = PolyWorldSpatialElementVisibility; + +export interface PolyWorldBspViewSurfaceElementOptions extends PolyWorldBspViewPvsOptions { + surfaces: readonly PolyWorldBspViewSurfaceElement[]; +} + +export interface PolyWorldBspResolvedViewSurfaceElements { + surfaceIds: readonly string[]; + elementIds: readonly string[]; + structuralSurfaceIds: readonly string[]; + structuralElementIds: readonly string[]; + detailSurfaceIds: readonly string[]; + detailElementIds: readonly string[]; + leafIds: readonly string[]; + regionIds: readonly string[]; + roles: readonly PolyWorldBspResolvedViewSurfaceRoleSummary[]; +} + +export interface PolyWorldBspResolvedViewSurfaceRoleSummary { + role: PolyWorldBspViewSurfaceRole; + count: number; + surfaceIds: readonly string[]; + elementIds: readonly string[]; +} + +export type PolyWorldBspViewPvsTraceStatus = + | "visible" + | "outside-broad-phase" + | "closed" + | "blocked" + | "depth-capped" + | "missing-target-leaf" + | "clipped" + | "degenerate-clip"; + +export interface PolyWorldBspViewPvsTraceEntry { + portalId: string; + fromLeafId: string; + toLeafId: string; + depth: number; + status: PolyWorldBspViewPvsTraceStatus; + inputVertexCount: number; + clippedVertexCount?: number; + clipPlaneCount?: number; + linkId?: string; + selectionKeys?: readonly string[]; +} + +export interface PolyWorldBspViewPvsTrace extends PolyWorldBspResolvedViewPvs { + entries: readonly PolyWorldBspViewPvsTraceEntry[]; +} + +export interface PolyWorldBspDiagnostic { + code: string; + message: string; + id?: string; + field?: string; + kind?: "brush" | "compile" | "tree" | "node" | "leaf" | "portal" | "region" | "surface"; +} + +export class PolyWorldBspError extends Error { + readonly diagnostics: readonly PolyWorldBspDiagnostic[]; + + constructor(diagnostics: readonly PolyWorldBspDiagnostic[]) { + super(diagnostics.map((diagnostic) => diagnostic.message).join("\n")); + this.name = "PolyWorldBspError"; + this.diagnostics = diagnostics; + } +} + +export function compilePolyWorldBsp(input: PolyWorldBspCompileInput): PolyWorldBspTree { + const diagnostics = validatePolyWorldBspCompileInput(input); + if (diagnostics.length > 0) throw new PolyWorldBspError(diagnostics); + + const leaves = input.regions.map((region) => ({ + id: region.id, + regionId: region.regionId ?? region.id, + ...(region.clusterId === undefined ? {} : { clusterId: region.clusterId }), + bounds: cloneBounds(region.bounds), + center: region.center === undefined ? boundsCenter(region.bounds) : [...region.center] as Vec3, + ...(region.pvsSamplePoints === undefined ? {} : { + pvsSamplePoints: region.pvsSamplePoints.map((point) => [...point] as Vec3), + }), + ...(region.elementIds === undefined ? {} : { elementIds: [...region.elementIds] }), + data: { + ...region.data, + compiled: true, + }, + })); + const regionsById = new Map(input.regions.map((region) => [region.id, region])); + const portals = (input.portals ?? []).map((portal) => compileBspPortal(portal, regionsById)); + const root = compileBspChild(leaves, input.splitIdPrefix ?? "bsp", 0); + const tree = createPolyWorldBspTree({ + root, + leaves, + portals, + data: { + ...input.data, + compiled: true, + compiler: "bounds-bsp", + }, + }); + if (input.bakePvs === false) return tree; + return bakePolyWorldBspPvs(tree, input.pvs); +} + +export function validatePolyWorldBspCompileInput( + input: PolyWorldBspCompileInput, +): PolyWorldBspDiagnostic[] { + const diagnostics: PolyWorldBspDiagnostic[] = []; + const regionIds = new Set(); + const portalIds = new Set(); + + if (input.regions.length === 0) { + diagnostics.push({ + code: "poly-world-empty-bsp-compile-regions", + message: "PolyWorld BSP compiler requires at least one region.", + field: "regions", + kind: "compile", + }); + } + + for (const region of input.regions) { + validateId("region", region.id, diagnostics); + if (region.id && regionIds.has(region.id)) { + diagnostics.push({ + code: "poly-world-duplicate-bsp-compile-region-id", + message: `Duplicate PolyWorld BSP compile region id "${region.id}".`, + id: region.id, + field: "id", + kind: "region", + }); + } + if (region.id) regionIds.add(region.id); + validateOptionalString("region", region.id, "clusterId", region.clusterId, diagnostics); + validateBounds("region", region.id, region.bounds, diagnostics); + validateVec3("region", region.id, "center", region.center, diagnostics); + validateVec3Array("region", region.id, "pvsSamplePoints", region.pvsSamplePoints, diagnostics); + validateStringArray("region", region.id, "elementIds", region.elementIds, diagnostics); + } + + for (const portal of input.portals ?? []) { + validateId("portal", portal.id, diagnostics); + if (portal.id && portalIds.has(portal.id)) { + diagnostics.push({ + code: "poly-world-duplicate-bsp-compile-portal-id", + message: `Duplicate PolyWorld BSP compile portal id "${portal.id}".`, + id: portal.id, + field: "id", + kind: "portal", + }); + } + if (portal.id) portalIds.add(portal.id); + validateCompilePortalRegion(portal, "fromRegionId", regionIds, diagnostics); + validateCompilePortalRegion(portal, "toRegionId", regionIds, diagnostics); + if (portal.fromRegionId === portal.toRegionId) { + diagnostics.push({ + code: "poly-world-bsp-compile-portal-self-link", + message: `PolyWorld BSP compile portal "${portal.id}" must connect two different regions.`, + id: portal.id, + field: "toRegionId", + kind: "portal", + }); + } + validateBounds("portal", portal.id, portal.bounds, diagnostics); + validateVec3Array("portal", portal.id, "vertices", portal.vertices, diagnostics); + validateStringArray("portal", portal.id, "selectionKeys", portal.selectionKeys, diagnostics); + } + + return diagnostics; +} + +export function createPolyWorldBspTree(input: PolyWorldBspTreeInput): PolyWorldBspTree { + const diagnostics = validatePolyWorldBspTree(input); + if (diagnostics.length > 0) throw new PolyWorldBspError(diagnostics); + + const leaves = input.leaves.map((leaf) => cloneBspLeaf(leaf)); + const portals = (input.portals ?? []).map((portal) => cloneBspPortal(portal)); + const pvsIndex = input.pvsIndex === undefined ? undefined : cloneBspPvsIndex(input.pvsIndex); + const portalsByLeafId = new Map(); + for (const portal of portals) { + pushMap(portalsByLeafId, portal.fromLeafId, portal); + pushMap(portalsByLeafId, portal.toLeafId, portal); + } + + return { + root: cloneBspChild(input.root), + leaves, + portals, + leavesById: new Map(leaves.map((leaf) => [leaf.id, leaf])), + portalsById: new Map(portals.map((portal) => [portal.id, portal])), + portalsByLeafId, + ...(pvsIndex === undefined ? {} : { pvsIndex }), + data: input.data, + }; +} + +export function createPolyWorldBspPvsIndex(tree: Pick): PolyWorldBspPvsIndex { + const leafIds = tree.leaves.map((leaf) => leaf.id); + const portalIds = tree.portals.map((portal) => portal.id); + return { + leafIds, + portalIds, + leafIndexById: new Map(leafIds.map((leafId, index) => [leafId, index])), + portalIndexById: new Map(portalIds.map((portalId, index) => [portalId, index])), + }; +} + +export function decodePolyWorldBspPvsLeafIds( + index: PolyWorldBspPvsIndex, + pvs: PolyWorldBspBakedPvs, +): string[] { + return bitsetIds(index.leafIds, pvs.leafBits); +} + +export function decodePolyWorldBspPvsPortalIds( + index: PolyWorldBspPvsIndex, + pvs: PolyWorldBspBakedPvs, +): string[] { + return bitsetIds(index.portalIds, pvs.portalBits); +} + +export function resolvePolyWorldBspBakedPvs( + tree: PolyWorldBspTree, + leafId: string, +): PolyWorldBspResolvedPvs | undefined { + const leaf = tree.leavesById.get(leafId); + if (leaf === undefined) { + throw new PolyWorldBspError([{ + code: "poly-world-missing-bsp-baked-pvs-leaf", + message: `PolyWorld BSP baked PVS cannot resolve missing leaf "${leafId}".`, + id: leafId, + kind: "leaf", + }]); + } + if (leaf.pvs === undefined) return undefined; + if (tree.pvsIndex === undefined) { + throw new PolyWorldBspError([{ + code: "poly-world-bsp-baked-pvs-missing-index", + message: `PolyWorld BSP baked PVS for leaf "${leafId}" requires a tree pvsIndex.`, + id: leafId, + field: "pvsIndex", + kind: "tree", + }]); + } + const leafIds = decodePolyWorldBspPvsLeafIds(tree.pvsIndex, leaf.pvs); + return { + leafId: leaf.id, + leafIds, + clusterIds: clusterIdsForLeafIds(tree, leafIds), + regionIds: [...leaf.pvs.regionIds], + linkIds: [...leaf.pvs.linkIds], + portalIds: decodePolyWorldBspPvsPortalIds(tree.pvsIndex, leaf.pvs), + selectionKeys: [...leaf.pvs.selectionKeys], + elementIds: [...leaf.pvs.elementIds], + }; +} + +export function validatePolyWorldBspTree(input: PolyWorldBspTreeInput): PolyWorldBspDiagnostic[] { + const diagnostics: PolyWorldBspDiagnostic[] = []; + const leafIds = new Set(); + const leavesById = new Map(); + const nodeIds = new Set(); + const portalIds = new Set(); + const rootLeafIds = new Set(); + const rootLeafRefCounts = new Map(); + + if (input.leaves.length === 0) { + diagnostics.push({ + code: "poly-world-empty-bsp-leaves", + message: "PolyWorld BSP tree requires at least one leaf.", + field: "leaves", + kind: "tree", + }); + } + + for (const leaf of input.leaves) { + validateId("leaf", leaf.id, diagnostics); + if (leaf.id && leafIds.has(leaf.id)) { + diagnostics.push({ + code: "poly-world-duplicate-bsp-leaf-id", + message: `Duplicate PolyWorld BSP leaf id "${leaf.id}".`, + id: leaf.id, + field: "id", + kind: "leaf", + }); + } + if (leaf.id) leafIds.add(leaf.id); + if (leaf.id) leavesById.set(leaf.id, leaf); + validateOptionalString("leaf", leaf.id, "clusterId", leaf.clusterId, diagnostics); + validateVec3("leaf", leaf.id, "center", leaf.center, diagnostics); + validateVec3Array("leaf", leaf.id, "pvsSamplePoints", leaf.pvsSamplePoints, diagnostics); + validateBounds("leaf", leaf.id, leaf.bounds, diagnostics); + validateBspBakedPvs(leaf.id, leaf.pvs, diagnostics); + validateStringArray("leaf", leaf.id, "elementIds", leaf.elementIds, diagnostics); + } + + if (input.pvsIndex === undefined && input.leaves.some((leaf) => leaf.pvs !== undefined)) { + diagnostics.push({ + code: "poly-world-bsp-pvs-missing-index", + message: "PolyWorld BSP leaves with baked PVS require a tree pvsIndex.", + field: "pvsIndex", + kind: "tree", + }); + } + + validateBspChild(input.root, leafIds, nodeIds, diagnostics, rootLeafIds, rootLeafRefCounts); + validateBspRootLeafReferences(leafIds, rootLeafIds, rootLeafRefCounts, diagnostics); + + for (const portal of input.portals ?? []) { + validateId("portal", portal.id, diagnostics); + if (portal.id && portalIds.has(portal.id)) { + diagnostics.push({ + code: "poly-world-duplicate-bsp-portal-id", + message: `Duplicate PolyWorld BSP portal id "${portal.id}".`, + id: portal.id, + field: "id", + kind: "portal", + }); + } + if (portal.id) portalIds.add(portal.id); + validatePortalLeaf(portal, "fromLeafId", leafIds, diagnostics); + validatePortalLeaf(portal, "toLeafId", leafIds, diagnostics); + if (portal.fromLeafId === portal.toLeafId) { + diagnostics.push({ + code: "poly-world-bsp-portal-self-link", + message: `PolyWorld BSP portal "${portal.id}" must connect two different leaves.`, + id: portal.id, + field: "toLeafId", + kind: "portal", + }); + } + validateVec3Array("portal", portal.id, "vertices", portal.vertices, diagnostics); + if (portal.vertices.length < 3) { + diagnostics.push({ + code: "poly-world-bsp-portal-too-few-vertices", + message: `PolyWorld BSP portal "${portal.id}" requires at least three vertices.`, + id: portal.id, + field: "vertices", + kind: "portal", + }); + } else if (isValidVec3Array(portal.vertices)) { + try { + normalizeBspPortalVertices(portal.id, portal.vertices); + } catch (error) { + if (error instanceof PolyWorldBspError) diagnostics.push(...error.diagnostics); + else throw error; + } + validateBspPortalAdjacency(portal, leavesById, diagnostics); + } + validateStringArray("portal", portal.id, "selectionKeys", portal.selectionKeys, diagnostics); + } + + const reachableLeafIds = collectReachableBspLeafIds(rootLeafIds, input.portals ?? []); + for (const leafId of leafIds) { + if (reachableLeafIds.has(leafId)) continue; + diagnostics.push({ + code: "poly-world-unreachable-bsp-leaf", + message: `PolyWorld BSP leaf "${leafId}" is not reachable from the root leaf set or portal graph.`, + id: leafId, + kind: "leaf", + }); + } + + if (input.pvsIndex !== undefined) { + validateBspPvsIndex(input.pvsIndex, leafIds, portalIds, diagnostics); + for (const leaf of input.leaves) { + validateBspBakedPvsIndex(leaf.id, leaf.pvs, input.pvsIndex, input.leaves, input.portals ?? [], diagnostics); + } + } + + return diagnostics; +} + +export function bakePolyWorldBspPvs( + tree: PolyWorldBspTree, + options: PolyWorldBspPvsBakeOptions = {}, +): PolyWorldBspTree { + const pvsIndex = createPolyWorldBspPvsIndex(tree); + const leaves = tree.leaves.map((leaf) => { + const pvs = resolvePolyWorldBspPvs(tree, leaf.id, options); + return { + ...leaf, + pvs: encodeBspPvs(pvs, pvsIndex), + }; + }); + return createPolyWorldBspTree({ + root: tree.root, + leaves, + portals: tree.portals, + pvsIndex, + data: { + ...tree.data, + pvsGenerated: true, + pvsMethod: "portal-clipped-baked", + pvsProjection: options.projection ?? "xy", + pvsSource: "polycss-world", + }, + }); +} + +export function resolvePolyWorldBspPvs( + tree: PolyWorldBspTree, + leafId: string, + options: PolyWorldBspPvsBakeOptions = {}, +): PolyWorldBspResolvedPvs { + const sourceLeaf = tree.leavesById.get(leafId); + if (sourceLeaf === undefined) { + throw new PolyWorldBspError([{ + code: "poly-world-missing-bsp-pvs-leaf", + message: `PolyWorld BSP PVS cannot resolve missing leaf "${leafId}".`, + id: leafId, + kind: "leaf", + }]); + } + + const projection = options.projection ?? "xy"; + const maxDepth = options.maxDepth ?? tree.leaves.length; + const leafIds = new Set(); + const portalIds = new Set(); + const linkIds = new Set(); + const selectionKeys = new Set(); + const elementIds = new Set(); + const regionIds = new Set(); + addVisibleLeaf(sourceLeaf, leafIds, regionIds, elementIds); + + for (const samplePoint of resolveBspLeafSamplePoints(sourceLeaf, projection, options)) { + traceBspPortalFrustumPvs( + tree, + sourceLeaf.id, + samplePoint, + { planes: [], origin: [...samplePoint] as Vec3, rays: [] }, + new Set(), + { + leafIds, + portalIds, + linkIds, + selectionKeys, + elementIds, + regionIds, + }, + { ...options, projection, maxDepth }, + 0, + ); + } + + return { + leafId, + leafIds: tree.leaves.filter((leaf) => leafIds.has(leaf.id)).map((leaf) => leaf.id), + clusterIds: unique(tree.leaves.flatMap((leaf) => leafIds.has(leaf.id) && leaf.clusterId ? [leaf.clusterId] : [])), + regionIds: unique(tree.leaves.flatMap((leaf) => leafIds.has(leaf.id) && leaf.regionId ? [leaf.regionId] : [])), + linkIds: unique(tree.portals.flatMap((portal) => portalIds.has(portal.id) && portal.linkId ? [portal.linkId] : [])), + portalIds: tree.portals.filter((portal) => portalIds.has(portal.id)).map((portal) => portal.id), + selectionKeys: unique([ + ...tree.portals.flatMap((portal) => portalIds.has(portal.id) ? [...(portal.selectionKeys ?? [])] : []), + ...Array.from(selectionKeys), + ]), + elementIds: unique(tree.leaves.flatMap((leaf) => leafIds.has(leaf.id) ? [...(leaf.elementIds ?? [])] : [])), + }; +} + +export function resolvePolyWorldBspViewPvs( + tree: PolyWorldBspTree, + options: PolyWorldBspViewPvsOptions, +): PolyWorldBspResolvedViewPvs { + return resolveBspViewPvs(tree, options); +} + +export function tracePolyWorldBspViewPvs( + tree: PolyWorldBspTree, + options: PolyWorldBspViewPvsOptions, +): PolyWorldBspViewPvsTrace { + const entries: PolyWorldBspViewPvsTraceEntry[] = []; + return { + ...resolveBspViewPvs(tree, options, entries), + entries, + }; +} + +export function resolvePolyWorldBspViewSurfaceElements( + tree: PolyWorldBspTree, + options: PolyWorldBspViewSurfaceElementOptions, +): PolyWorldBspResolvedViewSurfaceElements { + const leafClips = new Map(); + resolveBspViewPvs(tree, options, undefined, leafClips); + const surfaceIds = new Set(); + const elementIds = new Set(); + const structuralSurfaceIds = new Set(); + const structuralElementIds = new Set(); + const detailSurfaceIds = new Set(); + const detailElementIds = new Set(); + const leafIds = new Set(); + const regionIds = new Set(); + + for (const surface of options.surfaces) { + for (const [leafId, clips] of leafClips) { + const leaf = tree.leavesById.get(leafId); + if (leaf === undefined || !surfaceMatchesBspLeaf(surface, leaf)) continue; + if (resolveBspViewSurfaceVisibility(surface) === "structural") { + addBspSelectedViewSurface( + surface, + leafId, + surfaceIds, + elementIds, + structuralSurfaceIds, + structuralElementIds, + leafIds, + regionIds, + ); + break; + } + if (!clips.some((clip) => surfaceIntersectsBspClip(surface.vertices, clip))) continue; + addBspSelectedViewSurface( + surface, + leafId, + surfaceIds, + elementIds, + detailSurfaceIds, + detailElementIds, + leafIds, + regionIds, + ); + break; + } + } + + return { + surfaceIds: options.surfaces.filter((surface) => surfaceIds.has(surface.id)).map((surface) => surface.id), + elementIds: unique(options.surfaces.flatMap((surface) => + surfaceIds.has(surface.id) ? [surface.elementId ?? surface.id] : [] + )), + structuralSurfaceIds: options.surfaces + .filter((surface) => structuralSurfaceIds.has(surface.id)) + .map((surface) => surface.id), + structuralElementIds: unique(options.surfaces.flatMap((surface) => + structuralSurfaceIds.has(surface.id) ? [surface.elementId ?? surface.id] : [] + )), + detailSurfaceIds: options.surfaces + .filter((surface) => detailSurfaceIds.has(surface.id)) + .map((surface) => surface.id), + detailElementIds: unique(options.surfaces.flatMap((surface) => + detailSurfaceIds.has(surface.id) ? [surface.elementId ?? surface.id] : [] + )), + leafIds: tree.leaves.filter((leaf) => leafIds.has(leaf.id)).map((leaf) => leaf.id), + regionIds: unique(tree.leaves.flatMap((leaf) => + leafIds.has(leaf.id) && leaf.regionId !== undefined ? [leaf.regionId] : [] + )), + roles: summarizeBspViewSurfaceRoles(options.surfaces.filter((surface) => surfaceIds.has(surface.id))), + }; +} + +function addBspSelectedViewSurface( + surface: PolyWorldBspViewSurfaceElement, + leafId: string, + surfaceIds: Set, + elementIds: Set, + visibilitySurfaceIds: Set, + visibilityElementIds: Set, + leafIds: Set, + regionIds: Set, +): void { + const elementId = surface.elementId ?? surface.id; + surfaceIds.add(surface.id); + elementIds.add(elementId); + visibilitySurfaceIds.add(surface.id); + visibilityElementIds.add(elementId); + leafIds.add(leafId); + if (surface.regionId !== undefined) regionIds.add(surface.regionId); +} + +function resolveBspViewSurfaceVisibility( + surface: PolyWorldBspViewSurfaceElement, +): PolyWorldBspViewSurfaceVisibility { + return resolvePolyWorldSpatialElementVisibility(surface); +} + +function resolveBspViewSurfaceRole( + surface: PolyWorldBspViewSurfaceElement, +): PolyWorldBspViewSurfaceRole { + return resolvePolyWorldSpatialElementRole(surface); +} + +function summarizeBspViewSurfaceRoles( + surfaces: readonly PolyWorldBspViewSurfaceElement[], +): PolyWorldBspResolvedViewSurfaceRoleSummary[] { + const summaries = new Map(); + for (const surface of surfaces) { + const role = resolveBspViewSurfaceRole(surface); + const summary = summaries.get(role); + if (summary === undefined) { + summaries.set(role, { + surfaceIds: [surface.id], + elementIds: [surface.elementId ?? surface.id], + }); + continue; + } + summary.surfaceIds.push(surface.id); + add(summary.elementIds, surface.elementId ?? surface.id); + } + return bspViewSurfaceRoleOrder.flatMap((role) => { + const summary = summaries.get(role); + if (summary === undefined) return []; + return [{ + role, + count: summary.surfaceIds.length, + surfaceIds: summary.surfaceIds, + elementIds: summary.elementIds, + }]; + }); +} + +const bspViewSurfaceRoleOrder: readonly PolyWorldBspViewSurfaceRole[] = [ + "root", + "shell", + "opening", + "detail", + "prop", +]; + +function resolveBspViewPvs( + tree: PolyWorldBspTree, + options: PolyWorldBspViewPvsOptions, + traceEntries?: PolyWorldBspViewPvsTraceEntry[], + leafClips?: Map, +): PolyWorldBspResolvedViewPvs { + const resolution = options.leafId === undefined + ? resolvePolyWorldBspLeaf(tree, options.point) + : resolveBspLeafById(tree, options.leafId); + if (resolution === undefined) { + throw new PolyWorldBspError([{ + code: "poly-world-missing-bsp-view-pvs-leaf", + message: "PolyWorld BSP view PVS requires a point inside the BSP tree or an existing leafId.", + id: options.leafId, + kind: "leaf", + }]); + } + + const projection = options.projection ?? "xy"; + const maxDepth = options.maxDepth ?? tree.leaves.length; + const fovDegrees = resolveBspViewFovDegrees(options.fovDegrees); + const broadPhase = resolveBspBroadPhasePvs(tree, resolution.leaf, options); + const broadPhaseLeafIds = new Set(broadPhase.leafIds); + const leafIds = new Set(); + const portalIds = new Set(); + const linkIds = new Set(); + const selectionKeys = new Set(); + const elementIds = new Set(); + const regionIds = new Set(); + addVisibleLeaf(resolution.leaf, leafIds, regionIds, elementIds); + const viewClip = createBspViewClip(options.point, options.forward, { + up: options.up, + aspect: options.aspect, + fovDegrees, + near: options.near, + far: options.far, + }); + addBspLeafClip(leafClips, resolution.leaf.id, viewClip); + + traceBspPortalFrustumPvs( + tree, + resolution.leaf.id, + options.point, + viewClip, + new Set(), + { + leafIds, + portalIds, + linkIds, + selectionKeys, + elementIds, + regionIds, + }, + { ...options, projection, maxDepth }, + 0, + { leafIds: broadPhaseLeafIds }, + traceEntries, + leafClips, + ); + + return { + leafId: resolution.leaf.id, + leafIds: tree.leaves.filter((leaf) => leafIds.has(leaf.id)).map((leaf) => leaf.id), + clusterIds: unique(tree.leaves.flatMap((leaf) => leafIds.has(leaf.id) && leaf.clusterId ? [leaf.clusterId] : [])), + regionIds: unique(tree.leaves.flatMap((leaf) => leafIds.has(leaf.id) && leaf.regionId ? [leaf.regionId] : [])), + linkIds: unique(tree.portals.flatMap((portal) => portalIds.has(portal.id) && portal.linkId ? [portal.linkId] : [])), + portalIds: tree.portals.filter((portal) => portalIds.has(portal.id)).map((portal) => portal.id), + selectionKeys: unique([ + ...tree.portals.flatMap((portal) => portalIds.has(portal.id) ? [...(portal.selectionKeys ?? [])] : []), + ...Array.from(selectionKeys), + ]), + elementIds: unique(Array.from(elementIds)), + broadPhaseLeafIds: broadPhase.leafIds, + broadPhasePortalIds: broadPhase.portalIds, + fovDegrees, + }; +} + +function surfaceMatchesBspLeaf( + surface: PolyWorldBspViewSurfaceElement, + leaf: PolyWorldBspLeaf, +): boolean { + if (surface.leafId !== undefined) return surface.leafId === leaf.id; + if (surface.regionId !== undefined) return surface.regionId === leaf.regionId; + const center = averageVec3(surface.vertices); + return boundsContainsBspPoint(leaf.bounds, center); +} + +function boundsContainsBspPoint(bounds: PolyWorldBounds | undefined, point: Vec3): boolean { + if (bounds === undefined) return false; + return point.every((value, axis) => + value >= bounds.min[axis] - 0.0001 && value <= bounds.max[axis] + 0.0001 + ); +} + +export function resolvePolyWorldBspLeaf( + tree: PolyWorldBspTree, + point: Vec3, +): PolyWorldBspLeafResolution | undefined { + const path: string[] = []; + let child: PolyWorldBspChild = tree.root; + + while (!isBspLeafRef(child)) { + path.push(child.id); + child = selectBspChild(child, point); + } + + const leaf = tree.leavesById.get(child.leafId); + if (leaf === undefined) return undefined; + return { leaf, leafId: leaf.id, path }; +} + +export function selectPolyWorldBspPvs( + topology: PolyWorldTopology, + tree: PolyWorldBspTree, + options: PolyWorldBspPvsSelectionOptions, +): PolyWorldSelection { + const labels = { + leaf: "bsp-leaf", + pvs: "pvs", + selectionKey: "selection-key", + ...options.reasonLabels, + }; + const regionIds: string[] = []; + const linkIds: string[] = []; + const selectionKeys: string[] = []; + const elementIds: string[] = []; + const reasons: PolyWorldSelectionReason[] = [...(options.reasons ?? [])]; + const resolution = resolveSelectionLeaf(tree, options); + const leaf = resolution?.leaf; + + if (leaf !== undefined && options.includeLeafRegion !== false && leaf.regionId !== undefined) { + add(regionIds, leaf.regionId); + reasons.push({ + id: "poly-world-bsp-leaf", + kind: "bspLeaf", + label: labels.leaf, + regionIds: [leaf.regionId], + data: { + leafId: leaf.id, + path: resolution?.path ?? [], + }, + }); + } + + if (leaf !== undefined && options.includePvs !== false) { + const pvs = resolveBspBroadPhasePvs(tree, leaf, options); + for (const regionId of pvs.regionIds) add(regionIds, regionId); + for (const linkId of pvs.linkIds) add(linkIds, linkId); + for (const selectionKey of pvs.selectionKeys) add(selectionKeys, selectionKey); + for (const elementId of pvs.elementIds) add(elementIds, elementId); + addLinkSelectionKeys(topology, linkIds, selectionKeys); + if ( + pvs.regionIds.length > 0 || + pvs.linkIds.length > 0 || + pvs.selectionKeys.length > 0 || + pvs.elementIds.length > 0 + ) { + reasons.push({ + id: "poly-world-bsp-pvs", + kind: "pvs", + label: labels.pvs, + regionIds: pvs.regionIds, + linkIds: pvs.linkIds, + selectionKeys: pvs.selectionKeys, + data: { + leafId: leaf.id, + portalIds: pvs.portalIds, + leafIds: pvs.leafIds, + clusterIds: pvs.clusterIds, + }, + }); + } + } + + for (const regionId of options.regionIds ?? []) add(regionIds, regionId); + for (const linkId of options.linkIds ?? []) add(linkIds, linkId); + for (const selectionKey of options.selectionKeys ?? []) add(selectionKeys, selectionKey); + for (const elementId of options.elementIds ?? []) add(elementIds, elementId); + addLinkSelectionKeys(topology, linkIds, selectionKeys); + + if (selectionKeys.length > 0) { + reasons.push({ + id: "poly-world-bsp-selection-key", + kind: "selectionKey", + label: labels.selectionKey, + selectionKeys, + }); + } + + return { + regionIds, + linkIds, + selectionKeys, + elementIds, + reasons, + data: { + ...options.data, + ...(leaf === undefined ? {} : { leafId: leaf.id }), + }, + }; +} + +export function selectPolyWorldBspViewPvs( + topology: PolyWorldTopology, + tree: PolyWorldBspTree, + options: PolyWorldBspViewPvsSelectionOptions, +): PolyWorldSelection { + const labels = { + leaf: "bsp-leaf", + view: "view-pvs", + selectionKey: "selection-key", + ...options.reasonLabels, + }; + const regionIds: string[] = []; + const linkIds: string[] = []; + const selectionKeys: string[] = []; + const elementIds: string[] = []; + const reasons: PolyWorldSelectionReason[] = [...(options.reasons ?? [])]; + const resolution = resolveSelectionLeaf(tree, options); + const leaf = resolution?.leaf; + + if (leaf !== undefined && options.includeLeafRegion !== false && leaf.regionId !== undefined) { + add(regionIds, leaf.regionId); + reasons.push({ + id: "poly-world-bsp-leaf", + kind: "bspLeaf", + label: labels.leaf, + regionIds: [leaf.regionId], + data: { + leafId: leaf.id, + path: resolution?.path ?? [], + }, + }); + } + + if (leaf !== undefined && options.includePvs !== false) { + const view = resolvePolyWorldBspViewPvs(tree, { + ...options, + leafId: leaf.id, + }); + for (const regionId of view.regionIds) add(regionIds, regionId); + for (const linkId of view.linkIds) add(linkIds, linkId); + for (const selectionKey of view.selectionKeys) add(selectionKeys, selectionKey); + for (const elementId of view.elementIds) add(elementIds, elementId); + addLinkSelectionKeys(topology, linkIds, selectionKeys); + reasons.push({ + id: "poly-world-bsp-view-pvs", + kind: "viewPvs", + label: labels.view, + regionIds: view.regionIds, + linkIds: view.linkIds, + selectionKeys: view.selectionKeys, + data: { + leafId: view.leafId, + portalIds: view.portalIds, + leafIds: view.leafIds, + clusterIds: view.clusterIds, + broadPhaseLeafIds: view.broadPhaseLeafIds, + broadPhasePortalIds: view.broadPhasePortalIds, + fovDegrees: view.fovDegrees, + }, + }); + } + + for (const regionId of options.regionIds ?? []) add(regionIds, regionId); + for (const linkId of options.linkIds ?? []) add(linkIds, linkId); + for (const selectionKey of options.selectionKeys ?? []) add(selectionKeys, selectionKey); + for (const elementId of options.elementIds ?? []) add(elementIds, elementId); + addLinkSelectionKeys(topology, linkIds, selectionKeys); + + if (selectionKeys.length > 0) { + reasons.push({ + id: "poly-world-bsp-selection-key", + kind: "selectionKey", + label: labels.selectionKey, + selectionKeys, + }); + } + + return { + regionIds, + linkIds, + selectionKeys, + elementIds, + reasons, + data: { + ...options.data, + ...(leaf === undefined ? {} : { leafId: leaf.id }), + }, + }; +} + +function resolveSelectionLeaf( + tree: PolyWorldBspTree, + options: PolyWorldBspPvsSelectionOptions, +): PolyWorldBspLeafResolution | undefined { + if (options.leafId !== undefined) { + const leaf = tree.leavesById.get(options.leafId); + return leaf === undefined ? undefined : { leaf, leafId: leaf.id, path: [] }; + } + return options.point === undefined ? undefined : resolvePolyWorldBspLeaf(tree, options.point); +} + +function selectBspChild(node: PolyWorldBspNode, point: Vec3): PolyWorldBspChild { + const signedDistance = dot(node.plane.normal, point) - node.plane.distance; + const epsilon = node.plane.epsilon ?? 0; + if (Math.abs(signedDistance) <= epsilon) return node.onPlane === "back" ? node.back : node.front; + return signedDistance >= 0 ? node.front : node.back; +} + +function compileBspChild( + leaves: readonly PolyWorldBspLeaf[], + idPrefix: string, + depth: number, +): PolyWorldBspChild { + if (leaves.length === 1) return { leafId: leaves[0]?.id ?? "" }; + const split = chooseBspSplit(leaves); + const normal: Vec3 = [0, 0, 0]; + normal[split.axis] = 1; + return { + id: `${idPrefix}-split-${depth}-${axisName(split.axis)}-${formatSplitDistance(split.distance)}-${formatLeafGroupId(leaves)}`, + plane: { normal, distance: split.distance }, + back: compileBspChild(split.back, idPrefix, depth + 1), + front: compileBspChild(split.front, idPrefix, depth + 1), + data: { + compiled: true, + axis: axisName(split.axis), + backLeafIds: split.back.map((leaf) => leaf.id), + frontLeafIds: split.front.map((leaf) => leaf.id), + }, + }; +} + +function chooseBspSplit(leaves: readonly PolyWorldBspLeaf[]): { + axis: 0 | 1 | 2; + distance: number; + back: readonly PolyWorldBspLeaf[]; + front: readonly PolyWorldBspLeaf[]; +} { + let best: + | { + axis: 0 | 1 | 2; + distance: number; + back: readonly PolyWorldBspLeaf[]; + front: readonly PolyWorldBspLeaf[]; + score: number; + } + | undefined; + + for (const axis of [0, 1, 2] as const) { + const sorted = [...leaves].sort((a, b) => + leafCenter(a)[axis] - leafCenter(b)[axis] || a.id.localeCompare(b.id), + ); + for (let cut = 1; cut < sorted.length; cut += 1) { + const back = sorted.slice(0, cut); + const front = sorted.slice(cut); + const backMax = Math.max(...back.map((leaf) => leafMax(leaf, axis))); + const frontMin = Math.min(...front.map((leaf) => leafMin(leaf, axis))); + const backCenter = Math.max(...back.map((leaf) => leafCenter(leaf)[axis])); + const frontCenter = Math.min(...front.map((leaf) => leafCenter(leaf)[axis])); + const hasGap = backMax <= frontMin; + const distance = hasGap ? (backMax + frontMin) / 2 : (backCenter + frontCenter) / 2; + const overlapPenalty = Math.max(0, backMax - frontMin); + const balancePenalty = Math.abs(front.length - back.length); + const extent = Math.max(...sorted.map((leaf) => leafMax(leaf, axis))) - + Math.min(...sorted.map((leaf) => leafMin(leaf, axis))); + const score = overlapPenalty * 1000 + balancePenalty * 10 - extent * 0.001 + axis * 0.0001; + if (best === undefined || score < best.score) { + best = { axis, distance, back, front, score }; + } + } + } + + if (best !== undefined) return best; + throw new PolyWorldBspError([{ + code: "poly-world-bsp-compile-no-split", + message: "PolyWorld BSP compiler could not split the supplied regions.", + kind: "compile", + }]); +} + +function compileBspPortal( + portal: PolyWorldBspCompilePortal, + regionsById: ReadonlyMap, +): PolyWorldBspPortal { + const from = regionsById.get(portal.fromRegionId); + const to = regionsById.get(portal.toRegionId); + if (from === undefined || to === undefined) { + throw new PolyWorldBspError([{ + code: "poly-world-bsp-compile-missing-portal-region", + message: `PolyWorld BSP compile portal "${portal.id}" references a missing region.`, + id: portal.id, + kind: "portal", + }]); + } + const bounds = portal.bounds ?? derivePortalBounds(portal.id, from.bounds, to.bounds); + const vertices = portal.vertices === undefined + ? verticesFromPortalBounds(portal.id, bounds) + : portal.vertices.map((vertex) => [...vertex] as Vec3); + return { + id: portal.id, + fromLeafId: portal.fromRegionId, + toLeafId: portal.toRegionId, + linkId: portal.linkId, + vertices, + ...(portal.selectionKeys === undefined ? {} : { selectionKeys: [...portal.selectionKeys] }), + data: { + ...portal.data, + compiled: true, + bounds, + }, + }; +} + +function derivePortalBounds( + portalId: string, + a: PolyWorldBounds, + b: PolyWorldBounds, +): PolyWorldBounds { + const epsilon = 0.0001; + for (const axis of [0, 1, 2] as const) { + const aTouchesB = Math.abs(a.max[axis] - b.min[axis]) <= epsilon; + const bTouchesA = Math.abs(b.max[axis] - a.min[axis]) <= epsilon; + if (!aTouchesB && !bTouchesA) continue; + const bounds = cloneBounds({ + min: [0, 0, 0], + max: [0, 0, 0], + }); + const plane = aTouchesB ? a.max[axis] : b.max[axis]; + bounds.min[axis] = plane; + bounds.max[axis] = plane; + let valid = true; + for (const otherAxis of [0, 1, 2] as const) { + if (otherAxis === axis) continue; + bounds.min[otherAxis] = Math.max(a.min[otherAxis], b.min[otherAxis]); + bounds.max[otherAxis] = Math.min(a.max[otherAxis], b.max[otherAxis]); + if (bounds.max[otherAxis] - bounds.min[otherAxis] <= epsilon) valid = false; + } + if (valid) return bounds; + } + throw new PolyWorldBspError([{ + code: "poly-world-bsp-compile-no-shared-portal-face", + message: `PolyWorld BSP compile portal "${portalId}" requires bounds or two regions with a shared face.`, + id: portalId, + kind: "portal", + }]); +} + +function verticesFromPortalBounds(portalId: string, bounds: PolyWorldBounds): Vec3[] { + const epsilon = 0.0001; + const zeroAxes = ([0, 1, 2] as const).filter((axis) => Math.abs(bounds.max[axis] - bounds.min[axis]) <= epsilon); + if (zeroAxes.length !== 1) { + throw new PolyWorldBspError([{ + code: "poly-world-bsp-compile-invalid-portal-bounds", + message: `PolyWorld BSP compile portal "${portalId}" bounds must describe one planar rectangle.`, + id: portalId, + field: "bounds", + kind: "portal", + }]); + } + const planeAxis = zeroAxes[0] ?? 0; + const axes = ([0, 1, 2] as const).filter((axis) => axis !== planeAxis); + const a = axes[0] ?? 0; + const b = axes[1] ?? 1; + const makePoint = (aa: number, bb: number): Vec3 => { + const point = [0, 0, 0] as Vec3; + point[planeAxis] = bounds.min[planeAxis]; + point[a] = aa; + point[b] = bb; + return point; + }; + return [ + makePoint(bounds.min[a], bounds.min[b]), + makePoint(bounds.max[a], bounds.min[b]), + makePoint(bounds.max[a], bounds.max[b]), + makePoint(bounds.min[a], bounds.max[b]), + ]; +} + +function normalizeBspPortalVertices(portalId: string, vertices: readonly Vec3[]): Vec3[] { + const epsilon = 0.0001; + const uniqueVertices = uniqueVec3(vertices.map((vertex) => [...vertex] as Vec3)); + if (uniqueVertices.length < 3) { + throw new PolyWorldBspError([{ + code: "poly-world-bsp-portal-too-few-unique-vertices", + message: `PolyWorld BSP portal "${portalId}" requires at least three unique vertices.`, + id: portalId, + field: "vertices", + kind: "portal", + }]); + } + + const plane = resolveBspPortalPlane(portalId, uniqueVertices); + for (const vertex of uniqueVertices) { + if (Math.abs(dot(plane.normal, vertex) - plane.distance) > epsilon) { + throw new PolyWorldBspError([{ + code: "poly-world-bsp-portal-non-coplanar", + message: `PolyWorld BSP portal "${portalId}" vertices must be coplanar.`, + id: portalId, + field: "vertices", + kind: "portal", + }]); + } + } + + const center = averageVec3(uniqueVertices); + const tangent = resolveBspPortalTangent(plane.normal, uniqueVertices, center); + const bitangent = normalizeBspVector(cross(plane.normal, tangent)) ?? [0, 1, 0]; + const sorted = [...uniqueVertices].sort((a, b) => { + const da = subtractVec3(a, center); + const db = subtractVec3(b, center); + return Math.atan2(dot(da, bitangent), dot(da, tangent)) - + Math.atan2(dot(db, bitangent), dot(db, tangent)); + }); + const areaNormal = polygonAreaNormal(sorted); + if (dot(areaNormal, plane.normal) < 0) sorted.reverse(); + validateBspPortalConvexity(portalId, sorted, plane.normal); + return sorted.map((vertex) => [...vertex] as Vec3); +} + +function resolveBspPortalPlane( + portalId: string, + vertices: readonly Vec3[], +): { normal: Vec3; distance: number } { + const origin = vertices[0] ?? [0, 0, 0]; + for (let aIndex = 1; aIndex < vertices.length - 1; aIndex += 1) { + for (let bIndex = aIndex + 1; bIndex < vertices.length; bIndex += 1) { + const a = subtractVec3(vertices[aIndex] ?? origin, origin); + const b = subtractVec3(vertices[bIndex] ?? origin, origin); + const normal = normalizeBspVector(cross(a, b)); + if (normal !== undefined) { + return { + normal, + distance: dot(normal, origin), + }; + } + } + } + throw new PolyWorldBspError([{ + code: "poly-world-bsp-portal-degenerate-plane", + message: `PolyWorld BSP portal "${portalId}" vertices must define a non-degenerate plane.`, + id: portalId, + field: "vertices", + kind: "portal", + }]); +} + +function resolveBspPortalTangent( + normal: Vec3, + vertices: readonly Vec3[], + center: Vec3, +): Vec3 { + for (const vertex of vertices) { + const direction = subtractVec3(vertex, center); + const projected = subtractVec3(direction, scaleVec3(normal, dot(direction, normal))); + const tangent = normalizeBspVector(projected); + if (tangent !== undefined) return tangent; + } + return Math.abs(normal[2]) < 0.9 + ? normalizeBspVector(cross(normal, [0, 0, 1])) ?? [1, 0, 0] + : normalizeBspVector(cross(normal, [0, 1, 0])) ?? [1, 0, 0]; +} + +function validateBspPortalConvexity( + portalId: string, + vertices: readonly Vec3[], + normal: Vec3, +): void { + const epsilon = 0.0001; + const area = Math.hypot(...polygonAreaNormal(vertices)); + if (area <= epsilon) { + throw new PolyWorldBspError([{ + code: "poly-world-bsp-portal-degenerate-area", + message: `PolyWorld BSP portal "${portalId}" vertices must enclose non-zero area.`, + id: portalId, + field: "vertices", + kind: "portal", + }]); + } + for (let index = 0; index < vertices.length; index += 1) { + const previous = vertices[(index + vertices.length - 1) % vertices.length] ?? vertices[index] ?? [0, 0, 0]; + const current = vertices[index] ?? previous; + const next = vertices[(index + 1) % vertices.length] ?? current; + const a = subtractVec3(current, previous); + const b = subtractVec3(next, current); + const turn = dot(cross(a, b), normal); + if (turn < -epsilon) { + throw new PolyWorldBspError([{ + code: "poly-world-bsp-portal-concave", + message: `PolyWorld BSP portal "${portalId}" vertices must describe a convex polygon.`, + id: portalId, + field: "vertices", + kind: "portal", + }]); + } + } +} + +function traceBspPortalFrustumPvs( + tree: PolyWorldBspTree, + currentLeafId: string, + sourcePoint: Vec3, + clip: PolyWorldBspPortalClip, + pathPortalIds: Set, + visible: { + leafIds: Set; + portalIds: Set; + linkIds: Set; + selectionKeys: Set; + elementIds: Set; + regionIds: Set; + }, + options: Required> & PolyWorldBspPvsBakeOptions, + depth: number, + broadPhase?: { leafIds: ReadonlySet }, + traceEntries?: PolyWorldBspViewPvsTraceEntry[], + leafClips?: Map, +): void { + if (depth >= options.maxDepth) { + for (const portal of tree.portalsByLeafId.get(currentLeafId) ?? []) { + const nextLeafId = otherPortalLeafId(portal, currentLeafId); + if (nextLeafId === undefined || pathPortalIds.has(portal.id)) continue; + addBspViewPvsTraceEntry(traceEntries, portal, currentLeafId, nextLeafId, depth, "depth-capped"); + } + return; + } + + for (const portal of tree.portalsByLeafId.get(currentLeafId) ?? []) { + if (pathPortalIds.has(portal.id)) continue; + const nextLeafId = otherPortalLeafId(portal, currentLeafId); + if (nextLeafId === undefined) continue; + const portalState = resolveBspPortalTraversalState(portal, currentLeafId, nextLeafId, depth, options.portalState); + if (portalState !== "open") { + addBspViewPvsTraceEntry(traceEntries, portal, currentLeafId, nextLeafId, depth, portalState); + continue; + } + if (broadPhase !== undefined && !broadPhase.leafIds.has(nextLeafId)) { + addBspViewPvsTraceEntry(traceEntries, portal, currentLeafId, nextLeafId, depth, "outside-broad-phase"); + continue; + } + const nextLeaf = tree.leavesById.get(nextLeafId); + if (nextLeaf === undefined) { + addBspViewPvsTraceEntry(traceEntries, portal, currentLeafId, nextLeafId, depth, "missing-target-leaf"); + continue; + } + const clippedPortal = clipPolygonByBspPlanes(portal.vertices, clip.planes); + if (clippedPortal.length < 3) { + addBspViewPvsTraceEntry(traceEntries, portal, currentLeafId, nextLeafId, depth, "clipped", { + clippedVertexCount: clippedPortal.length, + }); + continue; + } + const portalPlanes = createBspPortalClipPlanes(sourcePoint, clippedPortal); + if (portalPlanes.length === 0) { + addBspViewPvsTraceEntry(traceEntries, portal, currentLeafId, nextLeafId, depth, "degenerate-clip", { + clippedVertexCount: clippedPortal.length, + }); + continue; + } + const nextClip: PolyWorldBspPortalClip = { + planes: [...clip.planes, ...portalPlanes], + origin: clip.origin, + rays: clippedPortal + .map((vertex) => normalizeBspVector(subtractVec3(vertex, clip.origin))) + .filter((ray): ray is Vec3 => ray !== undefined), + }; + addBspLeafClip(leafClips, nextLeafId, nextClip); + addBspViewPvsTraceEntry(traceEntries, portal, currentLeafId, nextLeafId, depth, "visible", { + clippedVertexCount: clippedPortal.length, + clipPlaneCount: nextClip.planes.length, + }); + + addVisibleLeaf(nextLeaf, visible.leafIds, visible.regionIds, visible.elementIds); + visible.portalIds.add(portal.id); + if (portal.linkId !== undefined) visible.linkIds.add(portal.linkId); + if (options.includePortalSelectionKeys !== false) { + for (const key of portal.selectionKeys ?? []) visible.selectionKeys.add(key); + } + + traceBspPortalFrustumPvs( + tree, + nextLeafId, + sourcePoint, + nextClip, + new Set([...pathPortalIds, portal.id]), + visible, + options, + depth + 1, + broadPhase, + traceEntries, + leafClips, + ); + } +} + +function addBspLeafClip( + leafClips: Map | undefined, + leafId: string, + clip: PolyWorldBspPortalClip, +): void { + if (leafClips === undefined) return; + const clips = leafClips.get(leafId) ?? []; + clips.push(clip); + leafClips.set(leafId, clips); +} + +function addBspViewPvsTraceEntry( + entries: PolyWorldBspViewPvsTraceEntry[] | undefined, + portal: PolyWorldBspPortal, + fromLeafId: string, + toLeafId: string, + depth: number, + status: PolyWorldBspViewPvsTraceStatus, + extra: Pick = {}, +): void { + if (entries === undefined) return; + entries.push({ + portalId: portal.id, + fromLeafId, + toLeafId, + depth, + status, + inputVertexCount: portal.vertices.length, + ...extra, + ...(portal.linkId === undefined ? {} : { linkId: portal.linkId }), + ...(portal.selectionKeys === undefined ? {} : { selectionKeys: [...portal.selectionKeys] }), + }); +} + +function addVisibleLeaf( + leaf: PolyWorldBspLeaf, + leafIds: Set, + regionIds: Set, + elementIds: Set, + collectElementIds = true, +): void { + leafIds.add(leaf.id); + if (leaf.regionId !== undefined) regionIds.add(leaf.regionId); + if (collectElementIds) { + for (const elementId of leaf.elementIds ?? []) elementIds.add(elementId); + } +} + +function resolveBspLeafById( + tree: PolyWorldBspTree, + leafId: string, +): PolyWorldBspLeafResolution | undefined { + const leaf = tree.leavesById.get(leafId); + return leaf === undefined ? undefined : { leaf, leafId: leaf.id, path: [] }; +} + +function resolveBspBroadPhasePvs( + tree: PolyWorldBspTree, + leaf: PolyWorldBspLeaf, + options: PolyWorldBspPvsBakeOptions, +): PolyWorldBspResolvedPvs { + if (leaf.pvs !== undefined && options.portalState === undefined) { + const baked = resolvePolyWorldBspBakedPvs(tree, leaf.id); + if (baked !== undefined) return baked; + } + return resolvePolyWorldBspPvs(tree, leaf.id, options); +} + +function resolveBspLeafSamplePoints( + leaf: PolyWorldBspLeaf, + projection: PolyWorldBspPvsProjection, + options: PolyWorldBspPvsBakeOptions, +): Vec3[] { + if (leaf.pvsSamplePoints !== undefined && leaf.pvsSamplePoints.length > 0) { + return leaf.pvsSamplePoints.map((point) => [...point] as Vec3); + } + if (leaf.bounds !== undefined) return sampleBounds(leaf.bounds, projection, options.sampleInset ?? 0); + if (leaf.center !== undefined) return [[...leaf.center] as Vec3]; + throw new PolyWorldBspError([{ + code: "poly-world-bsp-leaf-missing-pvs-samples", + message: `PolyWorld BSP leaf "${leaf.id}" requires bounds, center, or pvsSamplePoints before baking PVS.`, + id: leaf.id, + kind: "leaf", + }]); +} + +function sampleBounds( + bounds: PolyWorldBounds, + projection: PolyWorldBspPvsProjection, + inset: number, +): Vec3[] { + const center: Vec3 = [ + (bounds.min[0] + bounds.max[0]) / 2, + (bounds.min[1] + bounds.max[1]) / 2, + (bounds.min[2] + bounds.max[2]) / 2, + ]; + const [a, b] = projectionAxes(projection); + const insetA = Math.min(Math.max(0, inset), (bounds.max[a] - bounds.min[a]) / 2); + const insetB = Math.min(Math.max(0, inset), (bounds.max[b] - bounds.min[b]) / 2); + const minA = bounds.min[a] + insetA; + const maxA = bounds.max[a] - insetA; + const minB = bounds.min[b] + insetB; + const maxB = bounds.max[b] - insetB; + const points = [center]; + for (const aa of [minA, maxA]) { + for (const bb of [minB, maxB]) { + const point = [...center] as Vec3; + point[a] = aa; + point[b] = bb; + points.push(point); + } + } + return uniqueVec3(points); +} + +function resolveBspViewFovDegrees(value: number | undefined): number { + const fovDegrees = value ?? 90; + if (!Number.isFinite(fovDegrees) || fovDegrees <= 0) { + throw new PolyWorldBspError([{ + code: "poly-world-invalid-bsp-view-pvs-fov", + message: "PolyWorld BSP view PVS fovDegrees must be a finite number greater than zero.", + field: "fovDegrees", + kind: "compile", + }]); + } + return Math.min(fovDegrees, 360); +} + +function createBspViewClip( + origin: Vec3, + forward: Vec3, + options: { + up?: Vec3; + aspect?: number; + fovDegrees: number; + near?: number; + far?: number; + }, +): PolyWorldBspPortalClip { + const forwardDirection = normalizeBspVector(forward); + if (forwardDirection === undefined) { + throw new PolyWorldBspError([{ + code: "poly-world-invalid-bsp-view-pvs-forward", + message: "PolyWorld BSP view PVS forward vector must have non-zero length.", + field: "forward", + kind: "compile", + }]); + } + + const aspect = resolveBspViewAspect(options.aspect); + const planes: PolyWorldBspClipPlane[] = []; + const rays: Vec3[] = [forwardDirection]; + const near = options.near ?? 0.001; + if (near > 0) { + planes.push({ + normal: forwardDirection, + distance: dot(forwardDirection, origin) + near, + }); + } + if (options.far !== undefined && Number.isFinite(options.far) && options.far > near) { + const farNormal = scaleVec3(forwardDirection, -1); + planes.push({ + normal: farNormal, + distance: dot(farNormal, addVec3(origin, scaleVec3(forwardDirection, options.far))), + }); + } + if (options.fovDegrees >= 359.999) { + return { planes, origin: [...origin] as Vec3, rays }; + } + + const basis = createBspViewBasis(forwardDirection, options.up); + const halfHorizontal = options.fovDegrees * Math.PI / 360; + const halfVertical = Math.atan(Math.tan(halfHorizontal) / aspect); + const horizontal = Math.tan(halfHorizontal); + const vertical = Math.tan(halfVertical); + const topLeft = normalizeBspVector(addVec3(addVec3(forwardDirection, scaleVec3(basis.up, vertical)), scaleVec3(basis.right, -horizontal))); + const topRight = normalizeBspVector(addVec3(addVec3(forwardDirection, scaleVec3(basis.up, vertical)), scaleVec3(basis.right, horizontal))); + const bottomRight = normalizeBspVector(addVec3(addVec3(forwardDirection, scaleVec3(basis.up, -vertical)), scaleVec3(basis.right, horizontal))); + const bottomLeft = normalizeBspVector(addVec3(addVec3(forwardDirection, scaleVec3(basis.up, -vertical)), scaleVec3(basis.right, -horizontal))); + for (const ray of [topLeft, topRight, bottomRight, bottomLeft]) { + if (ray !== undefined) rays.push(ray); + } + for (const plane of [ + createBspRayPlane(origin, topLeft, topRight, forwardDirection), + createBspRayPlane(origin, topRight, bottomRight, forwardDirection), + createBspRayPlane(origin, bottomRight, bottomLeft, forwardDirection), + createBspRayPlane(origin, bottomLeft, topLeft, forwardDirection), + ]) { + if (plane !== undefined) planes.push(plane); + } + return { planes, origin: [...origin] as Vec3, rays }; +} + +function surfaceIntersectsBspClip( + vertices: readonly Vec3[], + clip: PolyWorldBspPortalClip, +): boolean { + if (clipPolygonByBspPlanes(vertices, clip.planes).length >= 3) return true; + return clip.rays.some((ray) => rayIntersectsBspSurfacePolygon(clip.origin, ray, vertices, clip.planes)); +} + +function rayIntersectsBspSurfacePolygon( + origin: Vec3, + ray: Vec3, + vertices: readonly Vec3[], + planes: readonly PolyWorldBspClipPlane[], +): boolean { + if (vertices.length < 3) return false; + const normal = normalizeBspVector(polygonAreaNormal(vertices)); + if (normal === undefined) return false; + const denominator = dot(normal, ray); + if (Math.abs(denominator) <= 0.000001) return false; + const distance = dot(normal, vertices[0] ?? [0, 0, 0]); + const t = (distance - dot(normal, origin)) / denominator; + if (t <= 0.0001) return false; + const point = addVec3(origin, scaleVec3(ray, t)); + if (!planes.every((plane) => signedBspPlaneDistance(plane, point) >= -0.0001)) return false; + return pointInConvexBspPolygon(point, vertices, normal); +} + +function pointInConvexBspPolygon(point: Vec3, vertices: readonly Vec3[], normal: Vec3): boolean { + let hasPositive = false; + let hasNegative = false; + for (let index = 0; index < vertices.length; index += 1) { + const a = vertices[index] ?? [0, 0, 0]; + const b = vertices[(index + 1) % vertices.length] ?? a; + const side = dot(cross(subtractVec3(b, a), subtractVec3(point, a)), normal); + if (side > 0.0001) hasPositive = true; + if (side < -0.0001) hasNegative = true; + if (hasPositive && hasNegative) return false; + } + return true; +} + +function resolveBspViewAspect(value: number | undefined): number { + if (value === undefined) return 1; + return Number.isFinite(value) && value > 0 ? value : 1; +} + +function createBspViewBasis(forward: Vec3, up: Vec3 | undefined): { right: Vec3; up: Vec3 } { + const worldUp = normalizeBspVector(up ?? [0, 0, 1]) ?? [0, 0, 1]; + let right = normalizeBspVector(cross(forward, worldUp)); + if (right === undefined) right = normalizeBspVector(cross(forward, [0, 1, 0])) ?? [1, 0, 0]; + const viewUp = normalizeBspVector(cross(right, forward)) ?? worldUp; + return { right, up: viewUp }; +} + +function createBspRayPlane( + origin: Vec3, + a: Vec3 | undefined, + b: Vec3 | undefined, + insideDirection: Vec3, +): PolyWorldBspClipPlane | undefined { + if (a === undefined || b === undefined) return undefined; + let normal = normalizeBspVector(cross(a, b)); + if (normal === undefined) return undefined; + if (dot(normal, insideDirection) < 0) normal = scaleVec3(normal, -1); + return { + normal, + distance: dot(normal, origin), + }; +} + +function clipPolygonByBspPlanes( + vertices: readonly Vec3[], + planes: readonly PolyWorldBspClipPlane[], +): Vec3[] { + let clipped = vertices.map((vertex) => [...vertex] as Vec3); + for (const plane of planes) { + clipped = clipPolygonByBspPlane(clipped, plane); + if (clipped.length < 3) return []; + } + return uniqueVec3(clipped); +} + +function clipPolygonByBspPlane( + vertices: readonly Vec3[], + plane: PolyWorldBspClipPlane, +): Vec3[] { + if (vertices.length === 0) return []; + const clipped: Vec3[] = []; + for (let index = 0; index < vertices.length; index += 1) { + const current = vertices[index] ?? [0, 0, 0]; + const next = vertices[(index + 1) % vertices.length] ?? current; + const currentDistance = signedBspPlaneDistance(plane, current); + const nextDistance = signedBspPlaneDistance(plane, next); + const currentInside = currentDistance >= -0.0001; + const nextInside = nextDistance >= -0.0001; + if (currentInside && nextInside) { + clipped.push([...next] as Vec3); + } else if (currentInside && !nextInside) { + clipped.push(intersectBspPlaneSegment(current, next, currentDistance, nextDistance)); + } else if (!currentInside && nextInside) { + clipped.push(intersectBspPlaneSegment(current, next, currentDistance, nextDistance), [...next] as Vec3); + } + } + return clipped; +} + +function intersectBspPlaneSegment(a: Vec3, b: Vec3, aDistance: number, bDistance: number): Vec3 { + const denominator = aDistance - bDistance; + const t = Math.abs(denominator) <= 0.000001 ? 0 : aDistance / denominator; + return [ + a[0] + (b[0] - a[0]) * t, + a[1] + (b[1] - a[1]) * t, + a[2] + (b[2] - a[2]) * t, + ]; +} + +function signedBspPlaneDistance(plane: PolyWorldBspClipPlane, point: Vec3): number { + return dot(plane.normal, point) - plane.distance; +} + +function createBspPortalClipPlanes(origin: Vec3, vertices: readonly Vec3[]): PolyWorldBspClipPlane[] { + const center = averageVec3(vertices); + const insideDirection = subtractVec3(center, origin); + const planes: PolyWorldBspClipPlane[] = []; + for (let index = 0; index < vertices.length; index += 1) { + const a = subtractVec3(vertices[index] ?? center, origin); + const b = subtractVec3(vertices[(index + 1) % vertices.length] ?? center, origin); + let normal = normalizeBspVector(cross(a, b)); + if (normal === undefined) continue; + if (dot(normal, insideDirection) < 0) normal = scaleVec3(normal, -1); + planes.push({ + normal, + distance: dot(normal, origin), + }); + } + return planes; +} + +function resolveBspPortalTraversalState( + portal: PolyWorldBspPortal, + fromLeafId: string, + toLeafId: string, + depth: number, + state: PolyWorldBspPortalState | undefined, +): "open" | "closed" | "blocked" { + if (state === undefined) return "open"; + if (typeof state === "function") { + return state(portal, { fromLeafId, toLeafId, depth }) ? "open" : "closed"; + } + const value = state[portal.id] ?? (portal.linkId === undefined ? undefined : state[portal.linkId]); + if (value === "blocked") return "blocked"; + return value === undefined || value === true || value === "open" ? "open" : "closed"; +} + +function encodeBspPvs( + pvs: PolyWorldBspResolvedPvs, + index: PolyWorldBspPvsIndex, +): PolyWorldBspBakedPvs { + return { + leafBits: bitsetFromIds(pvs.leafIds, index.leafIndexById, index.leafIds.length), + portalBits: bitsetFromIds(pvs.portalIds, index.portalIndexById, index.portalIds.length), + regionIds: [...pvs.regionIds], + linkIds: [...pvs.linkIds], + selectionKeys: [...pvs.selectionKeys], + elementIds: [...pvs.elementIds], + }; +} + +function bitsetFromIds( + ids: readonly string[], + indexById: ReadonlyMap, + size: number, +): Uint32Array { + const bits = new Uint32Array(Math.ceil(size / 32)); + for (const id of ids) { + const index = indexById.get(id); + if (index !== undefined) setBit(bits, index); + } + return bits; +} + +function bitsetIds(ids: readonly string[], bits: Uint32Array): string[] { + const result: string[] = []; + for (let index = 0; index < ids.length; index += 1) { + if (hasBit(bits, index)) result.push(ids[index] ?? ""); + } + return result.filter((id) => id.length > 0); +} + +function clusterIdsForLeafIds(tree: PolyWorldBspTree, leafIds: readonly string[]): string[] { + const leafIdSet = new Set(leafIds); + return unique(tree.leaves.flatMap((leaf) => + leafIdSet.has(leaf.id) && leaf.clusterId !== undefined ? [leaf.clusterId] : [] + )); +} + +function setBit(bits: Uint32Array, index: number): void { + bits[index >> 5] |= 1 << (index & 31); +} + +function hasBit(bits: Uint32Array, index: number): boolean { + return (bits[index >> 5] & (1 << (index & 31))) !== 0; +} + +function projectionAxes(projection: PolyWorldBspPvsProjection): readonly [0 | 1 | 2, 0 | 1 | 2] { + switch (projection) { + case "xy": + return [0, 1]; + case "xz": + return [0, 2]; + case "yz": + return [1, 2]; + } +} + +function otherPortalLeafId(portal: PolyWorldBspPortal, leafId: string): string | undefined { + if (portal.fromLeafId === leafId) return portal.toLeafId; + if (portal.toLeafId === leafId) return portal.fromLeafId; + return undefined; +} + +function cloneBspChild(child: PolyWorldBspChild): PolyWorldBspChild { + if (isBspLeafRef(child)) return { ...child }; + return { + ...child, + plane: { + ...child.plane, + normal: [...child.plane.normal] as Vec3, + }, + front: cloneBspChild(child.front), + back: cloneBspChild(child.back), + }; +} + +function cloneBspLeaf(leaf: PolyWorldBspLeaf): PolyWorldBspLeaf { + return { + ...leaf, + ...(leaf.bounds === undefined ? {} : { + bounds: { + min: [...leaf.bounds.min] as Vec3, + max: [...leaf.bounds.max] as Vec3, + }, + }), + ...(leaf.center === undefined ? {} : { center: [...leaf.center] as Vec3 }), + ...(leaf.pvsSamplePoints === undefined ? {} : { + pvsSamplePoints: leaf.pvsSamplePoints.map((point) => [...point] as Vec3), + }), + ...(leaf.pvs === undefined ? {} : { pvs: cloneBspBakedPvs(leaf.pvs) }), + ...(leaf.elementIds === undefined ? {} : { elementIds: [...leaf.elementIds] }), + }; +} + +function cloneBspBakedPvs(pvs: PolyWorldBspBakedPvs): PolyWorldBspBakedPvs { + return { + leafBits: new Uint32Array(pvs.leafBits), + portalBits: new Uint32Array(pvs.portalBits), + regionIds: [...pvs.regionIds], + linkIds: [...pvs.linkIds], + selectionKeys: [...pvs.selectionKeys], + elementIds: [...pvs.elementIds], + }; +} + +function cloneBspPvsIndex(index: PolyWorldBspPvsIndex): PolyWorldBspPvsIndex { + return { + leafIds: [...index.leafIds], + portalIds: [...index.portalIds], + leafIndexById: new Map(index.leafIndexById), + portalIndexById: new Map(index.portalIndexById), + }; +} + +function cloneBspPortal(portal: PolyWorldBspPortal): PolyWorldBspPortal { + return { + ...portal, + vertices: normalizeBspPortalVertices(portal.id, portal.vertices), + ...(portal.selectionKeys === undefined ? {} : { selectionKeys: [...portal.selectionKeys] }), + }; +} + +function cloneBounds(bounds: PolyWorldBounds): PolyWorldBounds { + return { + min: [...bounds.min] as Vec3, + max: [...bounds.max] as Vec3, + }; +} + +function isBspLeafRef(child: PolyWorldBspChild): child is PolyWorldBspLeafRef { + return "leafId" in child; +} + +function addLinkSelectionKeys( + topology: PolyWorldTopology, + linkIds: readonly string[], + selectionKeys: string[], +): void { + for (const linkId of linkIds) { + const link = topology.linksById.get(linkId); + for (const selectionKey of link?.selectionKeys ?? []) add(selectionKeys, selectionKey); + } +} + +function validateBspChild( + child: PolyWorldBspChild, + leafIds: ReadonlySet, + nodeIds: Set, + diagnostics: PolyWorldBspDiagnostic[], + rootLeafIds: Set, + rootLeafRefCounts: Map, +): void { + if (isBspLeafRef(child)) { + if (typeof child.leafId !== "string" || child.leafId.length === 0) { + diagnostics.push({ + code: "poly-world-empty-bsp-leaf-ref", + message: "PolyWorld BSP leaf reference requires a non-empty leafId.", + field: "leafId", + kind: "leaf", + }); + return; + } + rootLeafIds.add(child.leafId); + rootLeafRefCounts.set(child.leafId, (rootLeafRefCounts.get(child.leafId) ?? 0) + 1); + if (!leafIds.has(child.leafId)) { + diagnostics.push({ + code: "poly-world-missing-bsp-leaf-ref", + message: `PolyWorld BSP leaf reference points to missing leaf "${child.leafId}".`, + id: child.leafId, + field: "leafId", + kind: "leaf", + }); + } + return; + } + + validateId("node", child.id, diagnostics); + if (child.id && nodeIds.has(child.id)) { + diagnostics.push({ + code: "poly-world-duplicate-bsp-node-id", + message: `Duplicate PolyWorld BSP node id "${child.id}".`, + id: child.id, + field: "id", + kind: "node", + }); + } + if (child.id) nodeIds.add(child.id); + validatePlane(child.id, child.plane, diagnostics); + if (child.onPlane !== undefined && child.onPlane !== "front" && child.onPlane !== "back") { + diagnostics.push({ + code: "poly-world-invalid-bsp-node-on-plane", + message: `PolyWorld BSP node "${child.id}" has invalid onPlane value "${String(child.onPlane)}".`, + id: child.id, + field: "onPlane", + kind: "node", + }); + } + validateBspChild(child.front, leafIds, nodeIds, diagnostics, rootLeafIds, rootLeafRefCounts); + validateBspChild(child.back, leafIds, nodeIds, diagnostics, rootLeafIds, rootLeafRefCounts); +} + +function validateBspRootLeafReferences( + leafIds: ReadonlySet, + rootLeafIds: ReadonlySet, + rootLeafRefCounts: ReadonlyMap, + diagnostics: PolyWorldBspDiagnostic[], +): void { + for (const leafId of leafIds) { + if (rootLeafIds.has(leafId)) continue; + diagnostics.push({ + code: "poly-world-unreferenced-bsp-leaf", + message: `PolyWorld BSP leaf "${leafId}" is not referenced by the BSP root tree.`, + id: leafId, + field: "root", + kind: "leaf", + }); + } + + for (const [leafId, count] of rootLeafRefCounts) { + if (count <= 1 || !leafIds.has(leafId)) continue; + diagnostics.push({ + code: "poly-world-duplicate-bsp-leaf-ref", + message: `PolyWorld BSP leaf "${leafId}" is referenced ${count} times by the BSP root tree.`, + id: leafId, + field: "root", + kind: "leaf", + }); + } +} + +function collectReachableBspLeafIds( + rootLeafIds: ReadonlySet, + portals: readonly PolyWorldBspPortal[], +): Set { + const reachable = new Set(rootLeafIds); + const portalLeafIds = new Map(); + for (const portal of portals) { + pushMap(portalLeafIds, portal.fromLeafId, portal.toLeafId); + pushMap(portalLeafIds, portal.toLeafId, portal.fromLeafId); + } + + const queue = [...rootLeafIds]; + while (queue.length > 0) { + const leafId = queue.shift(); + if (leafId === undefined) continue; + for (const nextLeafId of portalLeafIds.get(leafId) ?? []) { + if (reachable.has(nextLeafId)) continue; + reachable.add(nextLeafId); + queue.push(nextLeafId); + } + } + return reachable; +} + +function validateBspPvsIndex( + index: PolyWorldBspPvsIndex, + leafIds: ReadonlySet, + portalIds: ReadonlySet, + diagnostics: PolyWorldBspDiagnostic[], +): void { + const indexedLeafIds = new Set(); + for (let position = 0; position < index.leafIds.length; position += 1) { + const leafId = index.leafIds[position] ?? ""; + if (typeof leafId !== "string" || leafId.length === 0) { + diagnostics.push({ + code: "poly-world-empty-bsp-pvs-index-leaf-id", + message: "PolyWorld BSP pvsIndex leafIds must contain only non-empty strings.", + field: "pvsIndex.leafIds", + kind: "tree", + }); + continue; + } + if (indexedLeafIds.has(leafId)) { + diagnostics.push({ + code: "poly-world-duplicate-bsp-pvs-index-leaf-id", + message: `Duplicate PolyWorld BSP pvsIndex leaf id "${leafId}".`, + id: leafId, + field: "pvsIndex.leafIds", + kind: "leaf", + }); + } + indexedLeafIds.add(leafId); + if (!leafIds.has(leafId)) { + diagnostics.push({ + code: "poly-world-missing-bsp-pvs-index-leaf", + message: `PolyWorld BSP pvsIndex references missing leaf "${leafId}".`, + id: leafId, + field: "pvsIndex.leafIds", + kind: "leaf", + }); + } + if (index.leafIndexById.get(leafId) !== position) { + diagnostics.push({ + code: "poly-world-invalid-bsp-pvs-index-leaf-map", + message: `PolyWorld BSP pvsIndex leafIndexById must map "${leafId}" to index ${position}.`, + id: leafId, + field: "pvsIndex.leafIndexById", + kind: "leaf", + }); + } + } + + for (const leafId of leafIds) { + if (indexedLeafIds.has(leafId)) continue; + diagnostics.push({ + code: "poly-world-missing-bsp-pvs-index-leaf", + message: `PolyWorld BSP pvsIndex is missing leaf "${leafId}".`, + id: leafId, + field: "pvsIndex.leafIds", + kind: "leaf", + }); + } + + for (const [leafId, position] of index.leafIndexById) { + if (indexedLeafIds.has(leafId) && index.leafIds[position] === leafId) continue; + diagnostics.push({ + code: "poly-world-invalid-bsp-pvs-index-leaf-map", + message: `PolyWorld BSP pvsIndex leafIndexById has a stale entry for "${leafId}".`, + id: leafId, + field: "pvsIndex.leafIndexById", + kind: "leaf", + }); + } + + const indexedPortalIds = new Set(); + for (let position = 0; position < index.portalIds.length; position += 1) { + const portalId = index.portalIds[position] ?? ""; + if (typeof portalId !== "string" || portalId.length === 0) { + diagnostics.push({ + code: "poly-world-empty-bsp-pvs-index-portal-id", + message: "PolyWorld BSP pvsIndex portalIds must contain only non-empty strings.", + field: "pvsIndex.portalIds", + kind: "tree", + }); + continue; + } + if (indexedPortalIds.has(portalId)) { + diagnostics.push({ + code: "poly-world-duplicate-bsp-pvs-index-portal-id", + message: `Duplicate PolyWorld BSP pvsIndex portal id "${portalId}".`, + id: portalId, + field: "pvsIndex.portalIds", + kind: "portal", + }); + } + indexedPortalIds.add(portalId); + if (!portalIds.has(portalId)) { + diagnostics.push({ + code: "poly-world-missing-bsp-pvs-index-portal", + message: `PolyWorld BSP pvsIndex references missing portal "${portalId}".`, + id: portalId, + field: "pvsIndex.portalIds", + kind: "portal", + }); + } + if (index.portalIndexById.get(portalId) !== position) { + diagnostics.push({ + code: "poly-world-invalid-bsp-pvs-index-portal-map", + message: `PolyWorld BSP pvsIndex portalIndexById must map "${portalId}" to index ${position}.`, + id: portalId, + field: "pvsIndex.portalIndexById", + kind: "portal", + }); + } + } + + for (const portalId of portalIds) { + if (indexedPortalIds.has(portalId)) continue; + diagnostics.push({ + code: "poly-world-missing-bsp-pvs-index-portal", + message: `PolyWorld BSP pvsIndex is missing portal "${portalId}".`, + id: portalId, + field: "pvsIndex.portalIds", + kind: "portal", + }); + } + + for (const [portalId, position] of index.portalIndexById) { + if (indexedPortalIds.has(portalId) && index.portalIds[position] === portalId) continue; + diagnostics.push({ + code: "poly-world-invalid-bsp-pvs-index-portal-map", + message: `PolyWorld BSP pvsIndex portalIndexById has a stale entry for "${portalId}".`, + id: portalId, + field: "pvsIndex.portalIndexById", + kind: "portal", + }); + } +} + +function validateBspBakedPvsIndex( + leafId: string, + pvs: PolyWorldBspBakedPvs | undefined, + index: PolyWorldBspPvsIndex, + leaves: readonly PolyWorldBspLeaf[], + portals: readonly PolyWorldBspPortal[], + diagnostics: PolyWorldBspDiagnostic[], +): void { + if (pvs === undefined) return; + if (pvs.leafBits instanceof Uint32Array && pvs.leafBits.length !== bitsetLength(index.leafIds.length)) { + diagnostics.push({ + code: "poly-world-invalid-bsp-pvs-leaf-bits-length", + message: `PolyWorld BSP leaf "${leafId}" pvs.leafBits length must match pvsIndex.leafIds.`, + id: leafId, + field: "pvs.leafBits", + kind: "leaf", + }); + } + if (pvs.portalBits instanceof Uint32Array && pvs.portalBits.length !== bitsetLength(index.portalIds.length)) { + diagnostics.push({ + code: "poly-world-invalid-bsp-pvs-portal-bits-length", + message: `PolyWorld BSP leaf "${leafId}" pvs.portalBits length must match pvsIndex.portalIds.`, + id: leafId, + field: "pvs.portalBits", + kind: "leaf", + }); + } + if (!(pvs.leafBits instanceof Uint32Array) || !(pvs.portalBits instanceof Uint32Array)) return; + if (pvs.leafBits.length !== bitsetLength(index.leafIds.length)) return; + if (pvs.portalBits.length !== bitsetLength(index.portalIds.length)) return; + + const decodedLeafIds = bitsetIds(index.leafIds, pvs.leafBits); + const decodedPortalIds = bitsetIds(index.portalIds, pvs.portalBits); + const decodedLeafIdSet = new Set(decodedLeafIds); + const decodedPortalIdSet = new Set(decodedPortalIds); + validateBspBakedPvsReachability(leafId, decodedLeafIdSet, decodedPortalIdSet, portals, diagnostics); + validateBspPvsMetadataList( + leafId, + "regionIds", + pvs.regionIds, + unique(leaves.flatMap((leaf) => + decodedLeafIdSet.has(leaf.id) && leaf.regionId !== undefined ? [leaf.regionId] : [] + )), + diagnostics, + ); + validateBspPvsMetadataList( + leafId, + "linkIds", + pvs.linkIds, + unique(portals.flatMap((portal) => + decodedPortalIdSet.has(portal.id) && portal.linkId !== undefined ? [portal.linkId] : [] + )), + diagnostics, + ); + validateBspPvsMetadataList( + leafId, + "selectionKeys", + pvs.selectionKeys, + unique(portals.flatMap((portal) => + decodedPortalIdSet.has(portal.id) ? [...(portal.selectionKeys ?? [])] : [] + )), + diagnostics, + ); + validateBspPvsMetadataList( + leafId, + "elementIds", + pvs.elementIds, + unique(leaves.flatMap((leaf) => + decodedLeafIdSet.has(leaf.id) ? [...(leaf.elementIds ?? [])] : [] + )), + diagnostics, + ); +} + +function validateBspBakedPvsReachability( + leafId: string, + decodedLeafIds: ReadonlySet, + decodedPortalIds: ReadonlySet, + portals: readonly PolyWorldBspPortal[], + diagnostics: PolyWorldBspDiagnostic[], +): void { + if (!decodedLeafIds.has(leafId)) { + diagnostics.push({ + code: "poly-world-bsp-pvs-missing-source-leaf", + message: `PolyWorld BSP leaf "${leafId}" pvs.leafBits must include its source leaf.`, + id: leafId, + field: "pvs.leafBits", + kind: "leaf", + }); + } + + for (const portal of portals) { + const adjacentLeafId = portal.fromLeafId === leafId + ? portal.toLeafId + : portal.toLeafId === leafId + ? portal.fromLeafId + : undefined; + if (adjacentLeafId === undefined) continue; + if (!decodedPortalIds.has(portal.id)) { + diagnostics.push({ + code: "poly-world-bsp-pvs-missing-adjacent-portal", + message: `PolyWorld BSP leaf "${leafId}" pvs.portalBits must include directly adjacent portal "${portal.id}".`, + id: leafId, + field: "pvs.portalBits", + kind: "leaf", + }); + } + if (!decodedLeafIds.has(adjacentLeafId)) { + diagnostics.push({ + code: "poly-world-bsp-pvs-missing-adjacent-leaf", + message: `PolyWorld BSP leaf "${leafId}" pvs.leafBits must include directly adjacent leaf "${adjacentLeafId}".`, + id: leafId, + field: "pvs.leafBits", + kind: "leaf", + }); + } + } + + const decodedPortals = portals.filter((portal) => decodedPortalIds.has(portal.id)); + for (const portal of decodedPortals) { + if (decodedLeafIds.has(portal.fromLeafId) && decodedLeafIds.has(portal.toLeafId)) continue; + diagnostics.push({ + code: "poly-world-bsp-pvs-portal-outside-leaf-set", + message: `PolyWorld BSP leaf "${leafId}" pvs.portalBits include portal "${portal.id}" outside pvs.leafBits.`, + id: leafId, + field: "pvs.portalBits", + kind: "leaf", + }); + } + + const reachable = collectReachableBspLeafIds(new Set([leafId]), decodedPortals); + for (const decodedLeafId of decodedLeafIds) { + if (reachable.has(decodedLeafId)) continue; + diagnostics.push({ + code: "poly-world-bsp-pvs-unreachable-leaf", + message: `PolyWorld BSP leaf "${leafId}" pvs.leafBits include unreachable leaf "${decodedLeafId}".`, + id: leafId, + field: "pvs.leafBits", + kind: "leaf", + }); + } +} + +function bitsetLength(size: number): number { + return Math.ceil(size / 32); +} + +function validateBspPvsMetadataList( + leafId: string, + field: "regionIds" | "linkIds" | "selectionKeys" | "elementIds", + actual: readonly string[], + expected: readonly string[], + diagnostics: PolyWorldBspDiagnostic[], +): void { + if (sameStringList(actual, expected)) return; + diagnostics.push({ + code: `poly-world-bsp-pvs-${bspPvsMetadataCodeField(field)}-metadata-mismatch`, + message: `PolyWorld BSP leaf "${leafId}" pvs.${field} must match decoded PVS bitsets.`, + id: leafId, + field: `pvs.${field}`, + kind: "leaf", + }); +} + +function bspPvsMetadataCodeField(field: "regionIds" | "linkIds" | "selectionKeys" | "elementIds"): string { + switch (field) { + case "regionIds": + return "region-ids"; + case "linkIds": + return "link-ids"; + case "selectionKeys": + return "selection-keys"; + case "elementIds": + return "element-ids"; + } +} + +function sameStringList(a: readonly string[], b: readonly string[]): boolean { + return a.length === b.length && a.every((value, index) => value === b[index]); +} + +function validatePlane( + nodeId: string, + plane: PolyWorldBspPlane, + diagnostics: PolyWorldBspDiagnostic[], +): void { + validateVec3("node", nodeId, "plane.normal", plane.normal, diagnostics); + if (!isFiniteNumber(plane.distance)) { + diagnostics.push({ + code: "poly-world-invalid-bsp-plane-distance", + message: `PolyWorld BSP node "${nodeId}" requires a finite plane distance.`, + id: nodeId, + field: "plane.distance", + kind: "node", + }); + } + if (plane.epsilon !== undefined && (!isFiniteNumber(plane.epsilon) || plane.epsilon < 0)) { + diagnostics.push({ + code: "poly-world-invalid-bsp-plane-epsilon", + message: `PolyWorld BSP node "${nodeId}" requires a finite non-negative plane epsilon.`, + id: nodeId, + field: "plane.epsilon", + kind: "node", + }); + } + if (Array.isArray(plane.normal) && plane.normal.length === 3 && plane.normal.every(isFiniteNumber)) { + const lengthSq = dot(plane.normal, plane.normal); + if (lengthSq <= 0) { + diagnostics.push({ + code: "poly-world-zero-bsp-plane-normal", + message: `PolyWorld BSP node "${nodeId}" plane normal cannot be zero.`, + id: nodeId, + field: "plane.normal", + kind: "node", + }); + } + } +} + +function validatePortalLeaf( + portal: PolyWorldBspPortal, + field: "fromLeafId" | "toLeafId", + leafIds: ReadonlySet, + diagnostics: PolyWorldBspDiagnostic[], +): void { + const leafId = portal[field]; + if (typeof leafId !== "string" || leafId.length === 0) { + diagnostics.push({ + code: "poly-world-empty-bsp-portal-leaf", + message: `PolyWorld BSP portal "${portal.id}" requires a non-empty ${field}.`, + id: portal.id, + field, + kind: "portal", + }); + return; + } + if (!leafIds.has(leafId)) { + diagnostics.push({ + code: "poly-world-missing-bsp-portal-leaf", + message: `PolyWorld BSP portal "${portal.id}" references missing leaf "${leafId}".`, + id: portal.id, + field, + kind: "portal", + }); + } +} + +function validateBspPortalAdjacency( + portal: PolyWorldBspPortal, + leavesById: ReadonlyMap, + diagnostics: PolyWorldBspDiagnostic[], +): void { + const fromLeaf = leavesById.get(portal.fromLeafId); + const toLeaf = leavesById.get(portal.toLeafId); + if (fromLeaf === undefined || toLeaf === undefined) return; + + validateBspPortalTargetLeaf(portal, fromLeaf, "fromLeafId", diagnostics); + validateBspPortalTargetLeaf(portal, toLeaf, "toLeafId", diagnostics); + if (fromLeaf.bounds !== undefined) { + validateBspPortalVerticesInsideLeafBounds(portal, fromLeaf, "fromLeafId", diagnostics); + } + if (toLeaf.bounds !== undefined) { + validateBspPortalVerticesInsideLeafBounds(portal, toLeaf, "toLeafId", diagnostics); + } + if (fromLeaf.bounds !== undefined && toLeaf.bounds !== undefined) { + validateBspPortalSharedBoundsFace(portal, fromLeaf, toLeaf, diagnostics); + } +} + +function validateBspPortalTargetLeaf( + portal: PolyWorldBspPortal, + leaf: PolyWorldBspLeaf, + field: "fromLeafId" | "toLeafId", + diagnostics: PolyWorldBspDiagnostic[], +): void { + if (leaf.data?.solid === true) { + diagnostics.push({ + code: "poly-world-bsp-portal-solid-leaf", + message: `PolyWorld BSP portal "${portal.id}" cannot connect to solid leaf "${leaf.id}".`, + id: portal.id, + field, + kind: "portal", + }); + } + if (leaf.data?.outside === true) { + diagnostics.push({ + code: "poly-world-bsp-portal-outside-leaf", + message: `PolyWorld BSP portal "${portal.id}" cannot connect to outside leaf "${leaf.id}".`, + id: portal.id, + field, + kind: "portal", + }); + } +} + +function validateBspPortalVerticesInsideLeafBounds( + portal: PolyWorldBspPortal, + leaf: PolyWorldBspLeaf, + field: "fromLeafId" | "toLeafId", + diagnostics: PolyWorldBspDiagnostic[], +): void { + const bounds = leaf.bounds; + if (bounds === undefined) return; + if (portal.vertices.every((vertex) => boundsContainsPoint(bounds, vertex))) return; + diagnostics.push({ + code: "poly-world-bsp-portal-vertices-outside-leaf-bounds", + message: `PolyWorld BSP portal "${portal.id}" vertices must fit inside leaf "${leaf.id}" bounds.`, + id: portal.id, + field, + kind: "portal", + }); +} + +function validateBspPortalSharedBoundsFace( + portal: PolyWorldBspPortal, + fromLeaf: PolyWorldBspLeaf, + toLeaf: PolyWorldBspLeaf, + diagnostics: PolyWorldBspDiagnostic[], +): void { + if (fromLeaf.bounds === undefined || toLeaf.bounds === undefined) return; + const sharedFace = sharedBoundsFace(fromLeaf.bounds, toLeaf.bounds); + if (sharedFace === undefined) return; + if (portal.vertices.every((vertex) => Math.abs(vertex[sharedFace.axis] - sharedFace.distance) <= 0.0001)) return; + diagnostics.push({ + code: "poly-world-bsp-portal-not-on-shared-bounds-face", + message: `PolyWorld BSP portal "${portal.id}" vertices must lie on the shared bounds face between leaves "${fromLeaf.id}" and "${toLeaf.id}".`, + id: portal.id, + field: "vertices", + kind: "portal", + }); +} + +function sharedBoundsFace( + a: PolyWorldBounds, + b: PolyWorldBounds, +): { axis: 0 | 1 | 2; distance: number } | undefined { + for (const axis of [0, 1, 2] as const) { + if (Math.abs(a.max[axis] - b.min[axis]) <= 0.0001) { + return { axis, distance: (a.max[axis] + b.min[axis]) / 2 }; + } + if (Math.abs(b.max[axis] - a.min[axis]) <= 0.0001) { + return { axis, distance: (b.max[axis] + a.min[axis]) / 2 }; + } + } + return undefined; +} + +function boundsContainsPoint(bounds: PolyWorldBounds, point: Vec3): boolean { + return point.every((value, axis) => + value >= bounds.min[axis] - 0.0001 && value <= bounds.max[axis] + 0.0001 + ); +} + +function validateCompilePortalRegion( + portal: PolyWorldBspCompilePortal, + field: "fromRegionId" | "toRegionId", + regionIds: ReadonlySet, + diagnostics: PolyWorldBspDiagnostic[], +): void { + const regionId = portal[field]; + if (typeof regionId !== "string" || regionId.length === 0) { + diagnostics.push({ + code: "poly-world-empty-bsp-compile-portal-region", + message: `PolyWorld BSP compile portal "${portal.id}" requires a non-empty ${field}.`, + id: portal.id, + field, + kind: "portal", + }); + return; + } + if (!regionIds.has(regionId)) { + diagnostics.push({ + code: "poly-world-missing-bsp-compile-portal-region", + message: `PolyWorld BSP compile portal "${portal.id}" references missing region "${regionId}".`, + id: portal.id, + field, + kind: "portal", + }); + } +} + +function validateId( + kind: "node" | "leaf" | "portal" | "region", + id: string, + diagnostics: PolyWorldBspDiagnostic[], +): void { + if (typeof id !== "string" || id.length === 0) { + diagnostics.push({ + code: `poly-world-empty-bsp-${kind}-id`, + message: `PolyWorld BSP ${kind} requires a non-empty id.`, + field: "id", + kind, + }); + } +} + +function validateBounds( + kind: "leaf" | "portal" | "region", + id: string, + bounds: PolyWorldBounds | undefined, + diagnostics: PolyWorldBspDiagnostic[], +): void { + if (bounds === undefined) return; + validateVec3(kind, id, "bounds.min", bounds.min, diagnostics); + validateVec3(kind, id, "bounds.max", bounds.max, diagnostics); + if ( + Array.isArray(bounds.min) && + Array.isArray(bounds.max) && + bounds.min.length === 3 && + bounds.max.length === 3 && + bounds.min.every(isFiniteNumber) && + bounds.max.every(isFiniteNumber) + ) { + for (let axis = 0; axis < 3; axis += 1) { + if ((bounds.min[axis] ?? 0) > (bounds.max[axis] ?? 0)) { + diagnostics.push({ + code: "poly-world-invalid-bsp-leaf-bounds", + message: `PolyWorld BSP leaf "${id}" bounds.min must be <= bounds.max on every axis.`, + id, + field: "bounds", + kind, + }); + return; + } + } + } +} + +function validateVec3Array( + kind: "leaf" | "portal" | "region", + id: string, + field: string, + values: readonly Vec3[] | undefined, + diagnostics: PolyWorldBspDiagnostic[], +): void { + if (values === undefined) return; + if (!Array.isArray(values)) { + diagnostics.push({ + code: "poly-world-invalid-bsp-vec3-array", + message: `PolyWorld BSP ${kind} "${id}" ${field} must be an array.`, + id, + field, + kind, + }); + return; + } + values.forEach((value, index) => validateVec3(kind, id, `${field}.${index}`, value, diagnostics)); +} + +function isValidVec3Array(values: readonly Vec3[]): boolean { + return Array.isArray(values) && values.every((value) => + Array.isArray(value) && value.length === 3 && value.every(isFiniteNumber) + ); +} + +function validateVec3( + kind: "node" | "leaf" | "portal" | "region", + id: string, + field: string, + value: Vec3 | undefined, + diagnostics: PolyWorldBspDiagnostic[], +): void { + if (value === undefined) return; + if (!Array.isArray(value) || value.length !== 3 || !value.every(isFiniteNumber)) { + diagnostics.push({ + code: "poly-world-invalid-bsp-vec3", + message: `PolyWorld BSP ${kind} "${id}" ${field} must be a finite Vec3.`, + id, + field, + kind, + }); + } +} + +function validateStringArray( + kind: "leaf" | "portal" | "region", + id: string, + field: string, + values: readonly string[] | undefined, + diagnostics: PolyWorldBspDiagnostic[], +): void { + if (values === undefined) return; + if (!Array.isArray(values) || values.some((value) => typeof value !== "string" || value.length === 0)) { + diagnostics.push({ + code: "poly-world-invalid-bsp-string-array", + message: `PolyWorld BSP ${kind} "${id}" ${field} must contain only non-empty strings.`, + id, + field, + kind, + }); + } +} + +function validateOptionalString( + kind: "leaf" | "region", + id: string, + field: string, + value: string | undefined, + diagnostics: PolyWorldBspDiagnostic[], +): void { + if (value === undefined) return; + if (typeof value !== "string" || value.length === 0) { + diagnostics.push({ + code: "poly-world-invalid-bsp-string", + message: `PolyWorld BSP ${kind} "${id}" ${field} must be a non-empty string.`, + id, + field, + kind, + }); + } +} + +function validateBspBakedPvs( + leafId: string, + pvs: PolyWorldBspBakedPvs | undefined, + diagnostics: PolyWorldBspDiagnostic[], +): void { + if (pvs === undefined) return; + if (!(pvs.leafBits instanceof Uint32Array)) { + diagnostics.push({ + code: "poly-world-invalid-bsp-pvs-leaf-bits", + message: `PolyWorld BSP leaf "${leafId}" pvs.leafBits must be a Uint32Array.`, + id: leafId, + field: "pvs.leafBits", + kind: "leaf", + }); + } + if (!(pvs.portalBits instanceof Uint32Array)) { + diagnostics.push({ + code: "poly-world-invalid-bsp-pvs-portal-bits", + message: `PolyWorld BSP leaf "${leafId}" pvs.portalBits must be a Uint32Array.`, + id: leafId, + field: "pvs.portalBits", + kind: "leaf", + }); + } + validateStringArray("leaf", leafId, "pvs.regionIds", pvs.regionIds, diagnostics); + validateStringArray("leaf", leafId, "pvs.linkIds", pvs.linkIds, diagnostics); + validateStringArray("leaf", leafId, "pvs.selectionKeys", pvs.selectionKeys, diagnostics); + validateStringArray("leaf", leafId, "pvs.elementIds", pvs.elementIds, diagnostics); +} + +function pushMap(map: Map, key: TKey, value: TValue): void { + const values = map.get(key); + if (values === undefined) { + map.set(key, [value]); + return; + } + values.push(value); +} + +function add(values: string[], value: string): void { + if (!values.includes(value)) values.push(value); +} + +function unique(values: readonly string[] | undefined): string[] { + return [...new Set(values ?? [])]; +} + +function leafCenter(leaf: PolyWorldBspLeaf): Vec3 { + if (leaf.center !== undefined) return leaf.center; + if (leaf.bounds !== undefined) return boundsCenter(leaf.bounds); + return [0, 0, 0]; +} + +function boundsCenter(bounds: PolyWorldBounds): Vec3 { + return [ + (bounds.min[0] + bounds.max[0]) / 2, + (bounds.min[1] + bounds.max[1]) / 2, + (bounds.min[2] + bounds.max[2]) / 2, + ]; +} + +function leafMin(leaf: PolyWorldBspLeaf, axis: 0 | 1 | 2): number { + return leaf.bounds?.min[axis] ?? leafCenter(leaf)[axis]; +} + +function leafMax(leaf: PolyWorldBspLeaf, axis: 0 | 1 | 2): number { + return leaf.bounds?.max[axis] ?? leafCenter(leaf)[axis]; +} + +function axisName(axis: 0 | 1 | 2): "x" | "y" | "z" { + return axis === 0 ? "x" : axis === 1 ? "y" : "z"; +} + +function formatSplitDistance(distance: number): string { + return String(Math.round(distance * 1000) / 1000).replace("-", "neg-").replace(".", "p"); +} + +function formatLeafGroupId(leaves: readonly PolyWorldBspLeaf[]): string { + return leaves + .map((leaf) => leaf.id) + .sort() + .join("-") + .replace(/[^a-zA-Z0-9_-]/g, "_"); +} + +function isFiniteNumber(value: unknown): value is number { + return typeof value === "number" && Number.isFinite(value); +} diff --git a/packages/world/src/profiles/bspGeometry.ts b/packages/world/src/profiles/bspGeometry.ts new file mode 100644 index 000000000..9e576d7aa --- /dev/null +++ b/packages/world/src/profiles/bspGeometry.ts @@ -0,0 +1,95 @@ +import type { Vec3 } from "@layoutit/polycss-core"; + +export const polyWorldBspEpsilon = 0.0001; + +export interface PolyWorldBspGeometryPlane { + normal: Vec3; + distance: number; +} + +export function signedPlaneDistance(plane: PolyWorldBspGeometryPlane, point: Vec3): number { + return dotVec3(plane.normal, point) - plane.distance; +} + +export function dotVec3(a: Vec3, b: Vec3): number { + return a[0] * b[0] + a[1] * b[1] + a[2] * b[2]; +} + +export function crossVec3(a: Vec3, b: Vec3): Vec3 { + return [ + a[1] * b[2] - a[2] * b[1], + a[2] * b[0] - a[0] * b[2], + a[0] * b[1] - a[1] * b[0], + ]; +} + +export function addVec3(a: Vec3, b: Vec3): Vec3 { + return [a[0] + b[0], a[1] + b[1], a[2] + b[2]]; +} + +export function subtractVec3(a: Vec3, b: Vec3): Vec3 { + return [a[0] - b[0], a[1] - b[1], a[2] - b[2]]; +} + +export function scaleVec3(value: Vec3, scale: number): Vec3 { + return [value[0] * scale, value[1] * scale, value[2] * scale]; +} + +export function lerpVec3(a: Vec3, b: Vec3, t: number): Vec3 { + return [ + a[0] + (b[0] - a[0]) * t, + a[1] + (b[1] - a[1]) * t, + a[2] + (b[2] - a[2]) * t, + ]; +} + +export function lengthSqVec3(value: Vec3): number { + return dotVec3(value, value); +} + +export function normalizeVec3OrZero(value: Vec3, epsilon = 0): Vec3 { + const length = Math.sqrt(lengthSqVec3(value)); + if (length <= epsilon) return [0, 0, 0]; + return [value[0] / length, value[1] / length, value[2] / length]; +} + +export function normalizeVec3OrUndefined(value: Vec3, epsilon = 0.000001): Vec3 | undefined { + const length = Math.hypot(value[0], value[1], value[2]); + if (length <= epsilon) return undefined; + return [value[0] / length, value[1] / length, value[2] / length]; +} + +export function averageVec3(values: readonly Vec3[]): Vec3 { + if (values.length === 0) return [0, 0, 0]; + const sum = values.reduce((acc, value) => [ + acc[0] + value[0], + acc[1] + value[1], + acc[2] + value[2], + ], [0, 0, 0]); + return [sum[0] / values.length, sum[1] / values.length, sum[2] / values.length]; +} + +export function polygonAreaNormal(vertices: readonly Vec3[]): Vec3 { + return vertices.reduce((acc, vertex, index) => { + const next = vertices[(index + 1) % vertices.length] ?? vertex; + return addVec3(acc, crossVec3(vertex, next)); + }, [0, 0, 0]); +} + +export function uniqueVec3(values: readonly Vec3[], epsilon = polyWorldBspEpsilon): Vec3[] { + const result: Vec3[] = []; + for (const value of values) { + if (!result.some((existing) => sameVec3(existing, value, epsilon))) { + result.push([...value] as Vec3); + } + } + return result; +} + +export function sameVec3(a: Vec3, b: Vec3, epsilon = polyWorldBspEpsilon): boolean { + return ( + Math.abs(a[0] - b[0]) <= epsilon && + Math.abs(a[1] - b[1]) <= epsilon && + Math.abs(a[2] - b[2]) <= epsilon + ); +} diff --git a/packages/world/src/profiles/bspProof.ts b/packages/world/src/profiles/bspProof.ts new file mode 100644 index 000000000..9d4d6ec63 --- /dev/null +++ b/packages/world/src/profiles/bspProof.ts @@ -0,0 +1,454 @@ +import type { + PolyWorldBspChild, + PolyWorldBspDiagnostic, + PolyWorldBspTree, + PolyWorldBspTreeInput, +} from "./bsp"; +import { decodePolyWorldBspPvsLeafIds } from "./bsp"; +import { validatePolyWorldBspTree } from "./bsp"; +import { + createPolyWorldProfileArtifactProof, + type PolyWorldProfileArtifactProof, +} from "./artifact"; + +export type PolyWorldBspTopologyProofProfile = "bsp-pvs"; + +export type PolyWorldBspPvsMethod = + | "none" + | "exact-baked" + | "portal-clipped-baked" + | "authored-baked" + | "authored-loose" + | "debug-loose"; + +export type PolyWorldBspPvsCompleteness = "none" | "partial" | "complete"; + +export type PolyWorldBspPvsProofLevel = + | "uncertified" + | "certified-tree-only" + | "portal-clipped-baked-pvs" + | "exact-baked-pvs" + | "authored-baked-pvs" + | "partial-baked-pvs" + | "authored-loose-pvs" + | "debug-loose-pvs"; + +export type PolyWorldBspTopologyProofGuarantee = + | "validated-tree-root-references" + | "validated-portal-endpoints" + | "validated-portal-leaf-adjacency" + | "validated-pvs-bitset-widths" + | "validated-pvs-direct-adjacency" + | "validated-pvs-metadata"; + +export interface PolyWorldBspTopologyProof { + schemaVersion: 1; + profile: PolyWorldBspTopologyProofProfile; + artifact: PolyWorldProfileArtifactProof; + compiler: { + id: string; + compiled: boolean; + partition?: string; + leafBuilder?: string; + portalBuilder?: string; + }; + tree: { + leafCount: number; + portalCount: number; + nodeCount: number; + maxDepth: number; + rootLeafRefCount: number; + uniqueRootLeafRefCount: number; + referencesEveryLeafOnce: boolean; + }; + leaves: { + solidCount?: number; + emptyCount?: number; + outsideCount?: number; + renderableCount: number; + bakedPvsCount: number; + bakedPvsCoverage: number; + }; + portals: { + generatedCount: number; + candidateCount?: number; + rejectedCandidateCount?: number; + }; + pvs: { + level: PolyWorldBspPvsProofLevel; + method: PolyWorldBspPvsMethod; + source: string; + completeness: PolyWorldBspPvsCompleteness; + indexed: boolean; + indexLeafCount: number; + indexPortalCount: number; + indexLeafCoverage: number; + indexPortalCoverage: number; + bakedLeafCount: number; + bakedLeafCoverage: number; + pvsDensity?: number; + complete: boolean; + }; + evidence: { + validatedBy: "createPolyWorldBspTree"; + guarantees: readonly PolyWorldBspTopologyProofGuarantee[]; + }; +} + +export interface PolyWorldBspTopologyCertification { + schemaVersion: 1; + profile: PolyWorldBspTopologyProofProfile; + certified: boolean; + proof: PolyWorldBspTopologyProof; + diagnostics: readonly PolyWorldBspDiagnostic[]; +} + +export function certifyPolyWorldBspTopology( + tree: PolyWorldBspTree, +): PolyWorldBspTopologyCertification { + const diagnostics = validatePolyWorldBspTree(treeInputFromTree(tree)); + return { + schemaVersion: 1, + profile: "bsp-pvs", + certified: diagnostics.length === 0, + proof: summarizePolyWorldBspTopologyProof(tree), + diagnostics, + }; +} + +export function summarizePolyWorldBspTopologyProof( + tree: PolyWorldBspTree, +): PolyWorldBspTopologyProof { + const diagnostics = validatePolyWorldBspTree(treeInputFromTree(tree)); + const certified = diagnostics.length === 0; + const shape = summarizeBspTreeShape(tree.root); + const rootLeafIds = collectRootLeafIds(tree.root); + const uniqueRootLeafIds = uniqueStrings(rootLeafIds); + const solidLeafIdsFromData = leafIdsFromData(tree, "solidLeafIds"); + const outsideLeafIdsFromData = leafIdsFromData(tree, "outsideLeafIds"); + const emptyLeafIdsFromData = leafIdsFromData(tree, "emptyLeafIds"); + const hasSolidLeafFlags = tree.leaves.some((leaf) => typeof leaf.data?.solid === "boolean"); + const hasOutsideLeafFlags = tree.leaves.some((leaf) => leaf.data?.outside === true); + const solidLeafIds = solidLeafIdsFromData + ?? tree.leaves.filter((leaf) => leaf.data?.solid === true).map((leaf) => leaf.id); + const outsideLeafIds = outsideLeafIdsFromData + ?? tree.leaves.filter((leaf) => leaf.data?.outside === true).map((leaf) => leaf.id); + const emptyLeafIds = emptyLeafIdsFromData + ?? (tree.leaves.some((leaf) => typeof leaf.data?.solid === "boolean") + ? tree.leaves.filter((leaf) => leaf.data?.solid === false).map((leaf) => leaf.id) + : undefined); + const solidLeafIdSet = new Set(solidLeafIds); + const outsideLeafIdSet = new Set(outsideLeafIds); + const bakedPvsCount = tree.leaves.filter((leaf) => leaf.pvs !== undefined).length; + const pvsDensity = calculateBspPvsDensity(tree); + const indexLeafCount = tree.pvsIndex?.leafIds.length ?? 0; + const indexPortalCount = tree.pvsIndex?.portalIds.length ?? 0; + const compilerId = stringData(tree.data, "compiler") ?? "authored"; + const compiled = tree.data?.compiled === true; + const referencesEveryLeafOnce = rootLeafIds.length === tree.leaves.length && uniqueRootLeafIds.length === tree.leaves.length; + const bakedPvsCoverage = coverage(bakedPvsCount, tree.leaves.length); + const indexLeafCoverage = coverage(indexLeafCount, tree.leaves.length); + const indexPortalCoverage = coverage(indexPortalCount, tree.portals.length); + const pvsComplete = tree.pvsIndex !== undefined && bakedPvsCount === tree.leaves.length; + const pvsCompleteness = resolveBspPvsCompleteness(tree, bakedPvsCount, pvsComplete); + const pvsMethod = resolveBspPvsMethod(tree, bakedPvsCount, pvsComplete); + const pvsSource = resolveBspPvsSource(tree, compilerId, pvsMethod); + const pvsLevel = resolveBspPvsProofLevel(certified, pvsMethod, pvsCompleteness); + const hasPvsData = tree.pvsIndex !== undefined && bakedPvsCount > 0; + const artifactGuarantees = certified + ? artifactGuaranteesForBspProof({ compiled, hasPvsData, pvsComplete, pvsMethod }) + : []; + const evidenceGuarantees = certified + ? evidenceGuaranteesForBspProof(hasPvsData) + : []; + + return { + schemaVersion: 1, + profile: "bsp-pvs", + artifact: createPolyWorldProfileArtifactProof({ + profile: "bsp-pvs", + artifactKind: "compiled-bsp-pvs", + sourceKind: compiled ? "compiled" : "authored", + producedBy: compilerId, + guarantees: artifactGuarantees, + knownWeaknesses: [ + ...(certified ? [] : ["bsp-certification-failed"]), + ...(pvsMethod === "none" ? ["pvs-unavailable"] : []), + ...(pvsMethod === "authored-loose" || pvsMethod === "debug-loose" ? ["loose-pvs-not-full-vis"] : []), + ...(pvsCompleteness === "partial" ? ["partial-pvs-coverage"] : []), + "not-quake-bsp-format", + "not-full-qbsp-vis-parity", + "not-a-renderer", + "not-gameplay-collision", + ], + counts: { + leafCount: tree.leaves.length, + portalCount: tree.portals.length, + nodeCount: shape.nodeCount, + rootLeafRefCount: rootLeafIds.length, + uniqueRootLeafRefCount: uniqueRootLeafIds.length, + bakedPvsCount, + indexLeafCount, + indexPortalCount, + pvsCompleteCount: pvsComplete ? bakedPvsCount : 0, + renderableLeafCount: tree.leaves.filter((leaf) => !solidLeafIdSet.has(leaf.id) && !outsideLeafIdSet.has(leaf.id)).length, + }, + coverage: { + rootLeafReferenceCoverage: coverage(uniqueRootLeafIds.length, tree.leaves.length), + bakedPvsCoverage, + indexLeafCoverage, + indexPortalCoverage, + ...(pvsDensity === undefined ? {} : { pvsDensity }), + }, + diagnostics, + }), + compiler: { + id: compilerId, + compiled, + ...optionalString("partition", stringData(tree.data, "partition")), + ...optionalString("leafBuilder", stringData(tree.data, "leafBuilder")), + ...optionalString("portalBuilder", stringData(tree.data, "portalBuilder")), + }, + tree: { + leafCount: tree.leaves.length, + portalCount: tree.portals.length, + nodeCount: shape.nodeCount, + maxDepth: shape.maxDepth, + rootLeafRefCount: rootLeafIds.length, + uniqueRootLeafRefCount: uniqueRootLeafIds.length, + referencesEveryLeafOnce, + }, + leaves: { + ...optionalCount("solidCount", solidLeafIdsFromData !== undefined || hasSolidLeafFlags ? solidLeafIds.length : undefined), + ...optionalCount("emptyCount", emptyLeafIds?.length), + ...optionalCount("outsideCount", outsideLeafIdsFromData !== undefined || hasOutsideLeafFlags ? outsideLeafIds.length : undefined), + renderableCount: tree.leaves.filter((leaf) => !solidLeafIdSet.has(leaf.id) && !outsideLeafIdSet.has(leaf.id)).length, + bakedPvsCount, + bakedPvsCoverage, + }, + portals: { + generatedCount: tree.portals.length, + ...optionalCount("candidateCount", numberData(tree.data, "portalCandidateCount")), + ...optionalCount("rejectedCandidateCount", numberData(tree.data, "rejectedPortalCandidateCount")), + }, + pvs: { + level: pvsLevel, + method: pvsMethod, + source: pvsSource, + completeness: pvsCompleteness, + indexed: tree.pvsIndex !== undefined, + indexLeafCount, + indexPortalCount, + indexLeafCoverage, + indexPortalCoverage, + bakedLeafCount: bakedPvsCount, + bakedLeafCoverage: bakedPvsCoverage, + ...optionalCount("pvsDensity", pvsDensity), + complete: pvsComplete, + }, + evidence: { + validatedBy: "createPolyWorldBspTree", + guarantees: evidenceGuarantees, + }, + }; +} + +function treeInputFromTree(tree: PolyWorldBspTree): PolyWorldBspTreeInput { + return { + root: tree.root, + leaves: tree.leaves, + portals: tree.portals, + ...(tree.pvsIndex === undefined ? {} : { pvsIndex: tree.pvsIndex }), + ...(tree.data === undefined ? {} : { data: tree.data }), + }; +} + +function summarizeBspTreeShape(child: PolyWorldBspChild, depth = 0): { nodeCount: number; maxDepth: number } { + if ("leafId" in child) return { nodeCount: 0, maxDepth: depth }; + const front = summarizeBspTreeShape(child.front, depth + 1); + const back = summarizeBspTreeShape(child.back, depth + 1); + return { + nodeCount: 1 + front.nodeCount + back.nodeCount, + maxDepth: Math.max(front.maxDepth, back.maxDepth), + }; +} + +function collectRootLeafIds(child: PolyWorldBspChild): string[] { + if ("leafId" in child) return [child.leafId]; + return [...collectRootLeafIds(child.back), ...collectRootLeafIds(child.front)]; +} + +function calculateBspPvsDensity(tree: PolyWorldBspTree): number | undefined { + const leavesWithPvs = tree.leaves.filter((leaf) => leaf.pvs !== undefined); + if (leavesWithPvs.length === 0 || tree.leaves.length === 0) return undefined; + let visibleLeafCount = 0; + for (const leaf of leavesWithPvs) { + if (leaf.pvs === undefined) continue; + visibleLeafCount += tree.pvsIndex === undefined + ? countBits(leaf.pvs.leafBits) + : decodePolyWorldBspPvsLeafIds(tree.pvsIndex, leaf.pvs).length; + } + return visibleLeafCount / (leavesWithPvs.length * tree.leaves.length); +} + +function leafIdsFromData(tree: PolyWorldBspTree, key: string): string[] | undefined { + const value = tree.data?.[key]; + return Array.isArray(value) && value.every((entry) => typeof entry === "string") + ? uniqueStrings(value) + : undefined; +} + +function stringData(data: Record | undefined, key: string): string | undefined { + const value = data?.[key]; + return typeof value === "string" ? value : undefined; +} + +function numberData(data: Record | undefined, key: string): number | undefined { + const value = data?.[key]; + return typeof value === "number" && Number.isFinite(value) ? value : undefined; +} + +function booleanData(data: Record | undefined, key: string): boolean | undefined { + const value = data?.[key]; + return typeof value === "boolean" ? value : undefined; +} + +function resolveBspPvsCompleteness( + tree: PolyWorldBspTree, + bakedPvsCount: number, + pvsComplete: boolean, +): PolyWorldBspPvsCompleteness { + if (tree.pvsIndex === undefined || bakedPvsCount === 0) return "none"; + return pvsComplete ? "complete" : "partial"; +} + +function resolveBspPvsMethod( + tree: PolyWorldBspTree, + bakedPvsCount: number, + pvsComplete: boolean, +): PolyWorldBspPvsMethod { + if (tree.pvsIndex === undefined || bakedPvsCount === 0) return "none"; + const authoredMethod = stringData(tree.data, "pvsMethod"); + if (isBspPvsMethod(authoredMethod)) return authoredMethod; + if (booleanData(tree.data, "pvsGenerated") === true || stringData(tree.data, "pvsSource") === "polycss-world") { + return "portal-clipped-baked"; + } + return pvsComplete ? "authored-baked" : "authored-loose"; +} + +function resolveBspPvsSource( + tree: PolyWorldBspTree, + compilerId: string, + pvsMethod: PolyWorldBspPvsMethod, +): string { + const source = stringData(tree.data, "pvsSource"); + if (source !== undefined) return source; + if (pvsMethod === "none") return "none"; + if (pvsMethod === "portal-clipped-baked") return "polycss-world"; + if (pvsMethod === "exact-baked") return compilerId; + if (pvsMethod === "debug-loose") return "debug"; + return compilerId === "authored" ? "authored" : compilerId; +} + +function resolveBspPvsProofLevel( + certified: boolean, + method: PolyWorldBspPvsMethod, + completeness: PolyWorldBspPvsCompleteness, +): PolyWorldBspPvsProofLevel { + if (!certified) return "uncertified"; + if (method === "none") return "certified-tree-only"; + if (method === "portal-clipped-baked") return "portal-clipped-baked-pvs"; + if (method === "debug-loose") return "debug-loose-pvs"; + if (method === "authored-loose") return "authored-loose-pvs"; + if (completeness !== "complete") return "partial-baked-pvs"; + if (method === "exact-baked") return "exact-baked-pvs"; + return "authored-baked-pvs"; +} + +function isBspPvsMethod(value: string | undefined): value is PolyWorldBspPvsMethod { + return value === "none" || + value === "exact-baked" || + value === "portal-clipped-baked" || + value === "authored-baked" || + value === "authored-loose" || + value === "debug-loose"; +} + +function artifactGuaranteesForBspProof(options: { + compiled: boolean; + hasPvsData: boolean; + pvsComplete: boolean; + pvsMethod: PolyWorldBspPvsMethod; +}): string[] { + const guarantees = [ + "tree-root-leaf-reference-audit", + "portal-endpoint-audit", + "portal-leaf-adjacency-audit", + ]; + if (options.compiled && options.hasPvsData && options.pvsMethod !== "authored-loose" && options.pvsMethod !== "debug-loose") { + guarantees.push("compiled-bsp-pvs"); + } + if (!options.hasPvsData) return guarantees; + guarantees.push( + "pvs-bitset-width-audit", + "pvs-direct-adjacency-audit", + "pvs-metadata-decode-audit", + ); + if (options.pvsMethod === "portal-clipped-baked") guarantees.push("portal-clipped-baked-pvs"); + if ( + options.pvsComplete && + (options.pvsMethod === "exact-baked" || options.pvsMethod === "authored-baked") + ) { + guarantees.push("baked-pvs-bitsets"); + } + return guarantees; +} + +function evidenceGuaranteesForBspProof(hasPvsData: boolean): PolyWorldBspTopologyProofGuarantee[] { + const guarantees: PolyWorldBspTopologyProofGuarantee[] = [ + "validated-tree-root-references", + "validated-portal-endpoints", + "validated-portal-leaf-adjacency", + ]; + if (hasPvsData) { + guarantees.push( + "validated-pvs-bitset-widths", + "validated-pvs-direct-adjacency", + "validated-pvs-metadata", + ); + } + return guarantees; +} + +function optionalString(key: string, value: string | undefined): Record { + return value === undefined ? {} : { [key]: value }; +} + +function optionalCount(key: string, value: number | undefined): Record { + return value === undefined ? {} : { [key]: value }; +} + +function coverage(count: number, total: number): number { + if (total === 0) return 0; + return count / total; +} + +function countBits(bits: Uint32Array): number { + let count = 0; + for (const value of bits) count += countBits32(value); + return count; +} + +function countBits32(value: number): number { + let remaining = value >>> 0; + let count = 0; + while (remaining !== 0) { + remaining &= remaining - 1; + count += 1; + } + return count; +} + +function uniqueStrings(values: readonly string[]): string[] { + return [...new Set(values)]; +} diff --git a/packages/world/src/profiles/bspVisibility.ts b/packages/world/src/profiles/bspVisibility.ts new file mode 100644 index 000000000..2824a7847 --- /dev/null +++ b/packages/world/src/profiles/bspVisibility.ts @@ -0,0 +1,332 @@ +import type { + PolyWorldSelection, + PolyWorldSelectionElementRelationExpansionOptions, + PolyWorldTopology, +} from "../topology"; +import type { + PolyWorldLayerPlanPolicy, + PolyWorldTransition, + PolyWorldTransitionDebugOptions, + PolyWorldTransitionReadinessOptions, + PolyWorldTransitionStateOptions, +} from "../planner"; +import { planPolyWorldTransition } from "../planner"; +import type { PolyWorldState } from "../state"; +import { + createPolyWorldBspDebugSnapshot, + type PolyWorldBspDebugSnapshot, + type PolyWorldBspDebugSnapshotOptions, +} from "../debug/bspSnapshot"; +import type { PolyWorldProfileArtifactProof } from "./artifact"; +import { + resolvePolyWorldBspBakedPvs, + resolvePolyWorldBspLeaf, + resolvePolyWorldBspPvs, + resolvePolyWorldBspViewSurfaceElements, + resolvePolyWorldBspViewPvs, + selectPolyWorldBspPvs, + selectPolyWorldBspViewPvs, + tracePolyWorldBspViewPvs, + type PolyWorldBspLeafResolution, + type PolyWorldBspResolvedPvs, + type PolyWorldBspResolvedViewPvs, + type PolyWorldBspResolvedViewSurfaceElements, + type PolyWorldBspTree, + type PolyWorldBspViewPvsSelectionOptions, + type PolyWorldBspViewPvsTrace, + type PolyWorldBspViewSurfaceElement, +} from "./bsp"; +import { summarizePolyWorldBspTopologyProof } from "./bspProof"; +import type { Vec3 } from "@layoutit/polycss-core"; +import { + createPolyWorldProfileFrameSummary, + type PolyWorldProfileFrameSummary, +} from "./frameSummary"; + +export type PolyWorldBspVisibilityDebugOptions = Omit< + PolyWorldBspDebugSnapshotOptions, + "leafId" | "broadPvs" | "viewPvs" | "trace" +>; + +export interface PolyWorldBspVisibilityOptions extends PolyWorldBspViewPvsSelectionOptions { + includeTrace?: boolean; + debug?: false | PolyWorldBspVisibilityDebugOptions; +} + +export interface PolyWorldBspVisibility { + leaf?: PolyWorldBspLeafResolution; + broadPvs?: PolyWorldBspResolvedPvs; + viewPvs?: PolyWorldBspResolvedViewPvs; + trace?: PolyWorldBspViewPvsTrace; + selection: PolyWorldSelection; + debug?: PolyWorldBspDebugSnapshot; +} + +export type PolyWorldBspVisibilityFrameStateOptions = PolyWorldTransitionStateOptions; + +export type PolyWorldBspVisibilityFrameDebugOptions = PolyWorldTransitionDebugOptions; + +export interface PolyWorldBspVisibilityFrameOptions extends PolyWorldBspVisibilityOptions { + previousState: PolyWorldState; + policies: readonly PolyWorldLayerPlanPolicy[]; + surfaces?: readonly PolyWorldBspViewSurfaceElement[]; + relations?: false | PolyWorldSelectionElementRelationExpansionOptions; + readiness?: PolyWorldTransitionReadinessOptions; + state?: PolyWorldBspVisibilityFrameStateOptions; + planDebug?: false | PolyWorldBspVisibilityFrameDebugOptions; +} + +export interface PolyWorldBspVisibilityFrameSets { + currentLeafId?: string; + broadPvsLeafIds: readonly string[]; + viewPvsLeafIds: readonly string[]; + structuralSurfaceIds: readonly string[]; + structuralElementIds: readonly string[]; + detailSurfaceIds: readonly string[]; + detailElementIds: readonly string[]; + plannedElementIds: readonly string[]; +} + +export interface PolyWorldBspVisibilityFrame extends PolyWorldTransition { + artifact: PolyWorldProfileArtifactProof; + visibility: PolyWorldBspVisibility; + surfaceElements?: PolyWorldBspResolvedViewSurfaceElements; + visibilitySets: PolyWorldBspVisibilityFrameSets; + frameSummary: PolyWorldProfileFrameSummary; +} + +export function resolvePolyWorldBspVisibility( + topology: PolyWorldTopology, + tree: PolyWorldBspTree, + options: PolyWorldBspVisibilityOptions, +): PolyWorldBspVisibility { + const leaf = resolveBspVisibilityLeaf(tree, options); + if (leaf === undefined) { + const selection = selectPolyWorldBspPvs(topology, tree, fallbackBspVisibilitySelectionOptions(options)); + return { + selection, + ...(options.debug === false ? {} : { + debug: createPolyWorldBspDebugSnapshot(tree, options.debug), + }), + }; + } + + const broadPvs = resolveBspVisibilityBroadPvs(tree, leaf.leafId, options); + const includeTrace = options.includeTrace === true; + const trace = includeTrace + ? tracePolyWorldBspViewPvs(tree, { ...options, leafId: leaf.leafId }) + : undefined; + const viewPvs = trace ?? resolvePolyWorldBspViewPvs(tree, { ...options, leafId: leaf.leafId }); + const selection = selectPolyWorldBspViewPvs(topology, tree, { ...options, leafId: leaf.leafId }); + + return { + leaf, + broadPvs, + viewPvs, + ...(trace === undefined ? {} : { trace }), + selection, + ...(options.debug === false ? {} : { + debug: createPolyWorldBspDebugSnapshot(tree, { + ...options.debug, + leafId: leaf.leafId, + broadPvs, + viewPvs, + ...(trace === undefined ? {} : { trace }), + }), + }), + }; +} + +export function planPolyWorldBspVisibilityFrame( + topology: PolyWorldTopology, + tree: PolyWorldBspTree, + options: PolyWorldBspVisibilityFrameOptions, +): PolyWorldBspVisibilityFrame { + const visibility = resolvePolyWorldBspVisibility(topology, tree, options); + const surfaceElements = visibility.leaf === undefined || options.surfaces === undefined + ? undefined + : resolvePolyWorldBspViewSurfaceElements(tree, { + ...options, + leafId: visibility.leaf.leafId, + surfaces: options.surfaces, + }); + const transition = planPolyWorldTransition(topology, { + previousState: options.previousState, + policies: options.policies, + state: options.state, + relations: options.relations, + readiness: options.readiness, + selection: surfaceElements === undefined + ? visibility.selection + : selectionWithBspSurfaceElements(visibility.selection, surfaceElements), + debug: options.planDebug, + }); + const artifact = summarizePolyWorldBspTopologyProof(tree).artifact; + const visibilitySets = createBspVisibilityFrameSets(visibility, surfaceElements, transition); + return { + artifact, + visibility, + ...(surfaceElements === undefined ? {} : { surfaceElements }), + visibilitySets, + frameSummary: createPolyWorldProfileFrameSummary({ + artifact, + transition, + current: { + leafIds: visibility.leaf?.leafId === undefined ? [] : [visibility.leaf.leafId], + regionIds: visibility.leaf?.leaf.regionId === undefined ? [] : [visibility.leaf.leaf.regionId], + }, + candidate: { + leafIds: visibility.broadPvs?.leafIds, + regionIds: visibility.selection.regionIds, + linkIds: visibility.selection.linkIds, + portalIds: visibility.broadPvs?.portalIds, + elementIds: visibility.selection.elementIds, + selectionKeys: visibility.selection.selectionKeys, + }, + broad: { + leafIds: visibility.broadPvs?.leafIds, + regionIds: visibility.broadPvs?.regionIds, + linkIds: visibility.broadPvs?.linkIds, + portalIds: visibility.broadPvs?.portalIds, + elementIds: visibility.broadPvs?.elementIds, + selectionKeys: visibility.broadPvs?.selectionKeys, + }, + view: { + leafIds: visibility.viewPvs?.leafIds, + regionIds: visibility.viewPvs?.regionIds, + linkIds: visibility.viewPvs?.linkIds, + portalIds: visibility.viewPvs?.portalIds, + surfaceIds: surfaceElements?.surfaceIds, + elementIds: surfaceElements?.elementIds ?? visibility.viewPvs?.elementIds, + selectionKeys: visibility.viewPvs?.selectionKeys, + }, + retained: { + surfaceIds: visibilitySets.structuralSurfaceIds, + elementIds: visibilitySets.structuralElementIds, + }, + rejected: { + portalIds: visibility.trace?.entries.flatMap((entry) => entry.status === "visible" ? [] : [entry.portalId]), + reasonCounts: visibility.trace === undefined ? undefined : countBspTraceStatuses(visibility.trace.entries), + }, + }), + ...transition, + }; +} + +function createBspVisibilityFrameSets( + visibility: PolyWorldBspVisibility, + surfaceElements: PolyWorldBspResolvedViewSurfaceElements | undefined, + transition: PolyWorldTransition, +): PolyWorldBspVisibilityFrameSets { + return { + ...(visibility.leaf?.leafId === undefined ? {} : { currentLeafId: visibility.leaf.leafId }), + broadPvsLeafIds: [...(visibility.broadPvs?.leafIds ?? [])], + viewPvsLeafIds: [...(visibility.viewPvs?.leafIds ?? [])], + structuralSurfaceIds: [...(surfaceElements?.structuralSurfaceIds ?? [])], + structuralElementIds: [...(surfaceElements?.structuralElementIds ?? [])], + detailSurfaceIds: [...(surfaceElements?.detailSurfaceIds ?? [])], + detailElementIds: [...(surfaceElements?.detailElementIds ?? [])], + plannedElementIds: uniqueStrings(transition.plan.entries.flatMap((entry) => + entry.elementId === undefined ? [] : [entry.elementId] + )), + }; +} + +function selectionWithBspSurfaceElements( + selection: PolyWorldSelection, + surfaceElements: PolyWorldBspResolvedViewSurfaceElements, +): PolyWorldSelection { + const elementIds = uniqueStrings([ + ...(selection.elementIds ?? []), + ...surfaceElements.elementIds, + ]); + return { + ...selection, + elementIds, + reasons: [ + ...(selection.reasons ?? []), + { + id: "poly-world-bsp-view-surfaces", + kind: "viewSurfaceElements", + label: "view-surfaces", + data: { + elementIds, + surfaceIds: surfaceElements.surfaceIds, + leafIds: surfaceElements.leafIds, + regionIds: surfaceElements.regionIds, + roles: surfaceElements.roles, + }, + }, + ], + }; +} + +function resolveBspVisibilityLeaf( + tree: PolyWorldBspTree, + options: PolyWorldBspVisibilityOptions, +): PolyWorldBspLeafResolution | undefined { + if (options.leafId !== undefined) { + const leaf = tree.leavesById.get(options.leafId); + return leaf === undefined ? undefined : { leaf, leafId: leaf.id, path: [] }; + } + const leaf = resolvePolyWorldBspLeaf(tree, options.point); + if (leaf?.leaf.bounds !== undefined && !boundsContainsPoint(leaf.leaf.bounds, options.point)) return undefined; + return leaf; +} + +function resolveBspVisibilityBroadPvs( + tree: PolyWorldBspTree, + leafId: string, + options: PolyWorldBspVisibilityOptions, +): PolyWorldBspResolvedPvs { + if (options.portalState === undefined) { + const baked = resolvePolyWorldBspBakedPvs(tree, leafId); + if (baked !== undefined) return baked; + } + return resolvePolyWorldBspPvs(tree, leafId, options); +} + +function fallbackBspVisibilitySelectionOptions( + options: PolyWorldBspVisibilityOptions, +): Parameters[2] { + return { + projection: options.projection, + sampleInset: options.sampleInset, + maxDepth: options.maxDepth, + includePortalSelectionKeys: options.includePortalSelectionKeys, + portalState: options.portalState, + includeLeafRegion: options.includeLeafRegion, + includePvs: options.includePvs, + regionIds: options.regionIds, + linkIds: options.linkIds, + selectionKeys: options.selectionKeys, + elementIds: options.elementIds, + reasonLabels: options.reasonLabels, + reasons: options.reasons, + data: options.data, + }; +} + +function boundsContainsPoint(bounds: { min: Vec3; max: Vec3 }, point: Vec3): boolean { + return point.every((value, axis) => + value >= bounds.min[axis] - 0.0001 && value <= bounds.max[axis] + 0.0001 + ); +} + +function uniqueStrings(values: readonly string[]): string[] { + return [...new Set(values)].sort(compareStrings); +} + +function countBspTraceStatuses( + entries: readonly { status: string }[], +): Readonly> { + const counts: Record = {}; + for (const entry of entries) counts[entry.status] = (counts[entry.status] ?? 0) + 1; + return counts; +} + +function compareStrings(a: string, b: string): number { + if (a < b) return -1; + if (a > b) return 1; + return 0; +} diff --git a/packages/world/src/profiles/chunk.ts b/packages/world/src/profiles/chunk.ts new file mode 100644 index 000000000..6b31fc8ab --- /dev/null +++ b/packages/world/src/profiles/chunk.ts @@ -0,0 +1,2089 @@ +import type { + PolyWorldBounds, + PolyWorldData, + PolyWorldSelection, + PolyWorldSelectionReason, + PolyWorldTopology, +} from "../topology"; +import { resolvePolyWorldRegionByPoint } from "../topology"; +import type { PolyWorldRegionSelectionKeys } from "./portal"; +import { resolvePolyWorldRegionSelectionKeys } from "./portal"; +import type { Vec3 } from "@layoutit/polycss-core"; + +export interface PolyWorldTaggedRegionSelection { + regionIds: readonly string[]; + label: string; + kind?: string; + tags?: readonly string[]; + selectionKeys?: readonly string[]; + data?: Record; +} + +export interface PolyWorldChunkReasonLabels { + current?: string; + active?: string; + window?: string; + tagged?: string; + selectionKey?: string; +} + +export type PolyWorldChunkTargetState = "preloaded" | "loaded" | "resident" | "active" | "rendered"; +export type PolyWorldChunkStreamingStateName = + | "requested" + | "loading" + | "loaded" + | "resident" + | "active" + | "rendered" + | "preloaded"; +export type PolyWorldChunkRefinement = "replace" | "add"; + +export interface PolyWorldChunkTreeNode { + id: string; + regionId?: string; + parentId?: string; + childIds?: readonly string[]; + bounds?: PolyWorldBounds; + contentBounds?: PolyWorldBounds; + viewerRequestBounds?: PolyWorldBounds; + available?: boolean; + contentAvailable?: boolean; + resourceIds?: readonly string[]; + refinement?: PolyWorldChunkRefinement; + geometricError?: number; + cost?: number; + priority?: number; + tags?: readonly string[]; + data?: PolyWorldData; +} + +export interface PolyWorldChunkTreeInput { + chunks: readonly PolyWorldChunkTreeNode[]; + rootChunkIds?: readonly string[]; + data?: PolyWorldData; +} + +export interface PolyWorldChunkTreeOptions { + topology?: PolyWorldTopology; +} + +export interface PolyWorldChunkTree { + chunks: readonly PolyWorldChunkTreeNode[]; + rootChunkIds: readonly string[]; + data?: PolyWorldData; + chunksById: ReadonlyMap; + chunksByRegionId: ReadonlyMap; + childIdsById: ReadonlyMap; + parentIdById: ReadonlyMap; + availableChunkIds: readonly string[]; + contentChunkIds: readonly string[]; +} + +export interface PolyWorldChunkTreeDiagnostic { + code: string; + message: string; + id?: string; + field?: string; +} + +export interface PolyWorldChunkTreeSummary { + chunkCount: number; + rootChunkIds: readonly string[]; + availableChunkIds: readonly string[]; + contentChunkIds: readonly string[]; + maxDepth: number; +} + +export type PolyWorldChunkTreeTraversalReason = + | "root" + | "current" + | "ancestor" + | "refined" + | "rendered" + | "loaded" + | "resident" + | "requested" + | "held" + | "unavailable" + | "outside-request-volume" + | "view-culled" + | "budget-clipped" + | "skipped"; + +export interface PolyWorldChunkTreeTraversalBudget { + maxRenderedChunks?: number; + maxLoadedChunks?: number; + maxRenderCost?: number; + maxLoadCost?: number; + targetGeometricError?: number; + maxScreenSpaceError?: number; + maxDepth?: number; +} + +export interface PolyWorldChunkTreeTraversalScreenSpaceError { + viewportHeight: number; + fovDegrees: number; + maxError?: number; + distanceFloor: number; +} + +export interface PolyWorldChunkTreeTraversalPlane { + normal: Vec3; + distance: number; +} + +export interface PolyWorldChunkTreeTraversalOptions { + currentChunkId?: string; + currentRegionId?: string; + point?: Vec3; + forward?: Vec3; + up?: Vec3; + fovDegrees?: number; + aspect?: number; + near?: number; + far?: number; + viewportHeight?: number; + screenSpaceErrorDistanceFloor?: number; + frustum?: readonly PolyWorldChunkTreeTraversalPlane[]; + nearest?: boolean; + rootChunkIds?: readonly string[]; + budget?: PolyWorldChunkTreeTraversalBudget; +} + +export interface PolyWorldChunkTreeTraversalEntry { + chunkId: string; + regionId?: string; + parentId?: string; + depth: number; + available: boolean; + contentAvailable: boolean; + refinement?: PolyWorldChunkRefinement; + geometricError?: number; + distanceToCamera?: number; + screenSpaceError?: number; + cost: number; + priority: number; + reasons: readonly PolyWorldChunkTreeTraversalReason[]; +} + +export interface PolyWorldChunkTreeTraversal { + currentChunkId?: string; + rootChunkIds: readonly string[]; + selectedChunkIds: readonly string[]; + refinedChunkIds: readonly string[]; + renderedChunkIds: readonly string[]; + loadedChunkIds: readonly string[]; + residentChunkIds: readonly string[]; + requestedChunkIds: readonly string[]; + heldChunkIds: readonly string[]; + unavailableChunkIds: readonly string[]; + viewCulledChunkIds: readonly string[]; + outsideRequestVolumeChunkIds: readonly string[]; + skippedChunkIds: readonly string[]; + budgetClippedChunkIds: readonly string[]; + selectedRegionIds: readonly string[]; + renderedRegionIds: readonly string[]; + loadedRegionIds: readonly string[]; + residentRegionIds: readonly string[]; + requestedRegionIds: readonly string[]; + totalRenderCost: number; + totalLoadCost: number; + budget: PolyWorldChunkTreeTraversalBudget; + screenSpaceError?: PolyWorldChunkTreeTraversalScreenSpaceError; + entries: readonly PolyWorldChunkTreeTraversalEntry[]; +} + +export class PolyWorldChunkTreeError extends Error { + readonly diagnostics: readonly PolyWorldChunkTreeDiagnostic[]; + + constructor(diagnostics: readonly PolyWorldChunkTreeDiagnostic[]) { + super(diagnostics.map((diagnostic) => diagnostic.message).join("\n")); + this.name = "PolyWorldChunkTreeError"; + this.diagnostics = diagnostics; + } +} + +export interface PolyWorldChunkGraph { + parentRegionIds?: Readonly>; + childRegionIds?: Readonly>; + relatedRegionIds?: Readonly>; +} + +export interface PolyWorldChunkGraphExpansionOptions { + includeParents?: boolean; + includeChildren?: boolean; + includeRelated?: boolean; + recursive?: boolean; + targetState?: PolyWorldChunkTargetState; +} + +export interface PolyWorldChunkStreamingSource { + id: string; + regionId?: string; + point?: Vec3; + position?: Vec3; + orderedRegionIds?: readonly string[]; + before?: number; + after?: number; + windowRadius?: number; + loadingRange?: number; + nearest?: boolean; + targetState?: PolyWorldChunkTargetState; + priority?: number; + chunkGraphExpansion?: false | PolyWorldChunkGraphExpansionOptions; + label?: string; + tags?: readonly string[]; + selectionKeys?: readonly string[]; + data?: Record; +} + +export interface PolyWorldChunkStreamingReasonLabels extends PolyWorldChunkReasonLabels { + streamingSource?: string; + chunkTreeTraversal?: string; +} + +export interface PolyWorldChunkStreamingSelectionOptions { + orderedRegionIds?: readonly string[]; + chunkTree?: PolyWorldChunkTree | PolyWorldChunkTreeInput; + currentRegionId?: string; + before?: number; + after?: number; + windowRadius?: number; + loadingRange?: number; + nearest?: boolean; + targetState?: PolyWorldChunkTargetState; + activeRegionIds?: readonly string[]; + loadedRegionIds?: readonly string[]; + residentRegionIds?: readonly string[]; + renderedRegionIds?: readonly string[]; + preloadedRegionIds?: readonly string[]; + selectionKeys?: readonly string[]; + sources?: readonly PolyWorldChunkStreamingSource[]; + chunkGraph?: PolyWorldChunkGraph; + chunkGraphExpansion?: false | PolyWorldChunkGraphExpansionOptions; + chunkTraversal?: false | PolyWorldChunkTreeTraversalOptions; + taggedRegionSelections?: readonly PolyWorldTaggedRegionSelection[]; + regionSelectionKeys?: PolyWorldRegionSelectionKeys; + reasonLabels?: PolyWorldChunkStreamingReasonLabels; + reasons?: readonly PolyWorldSelectionReason[]; + data?: Record; +} + +export interface PolyWorldChunkStreamingSourceSummary { + sourceId: string; + currentRegionId?: string; + selectedRegionIds: readonly string[]; + graphRegionIds?: readonly string[]; + graphTargetState?: PolyWorldChunkTargetState; + targetState: PolyWorldChunkTargetState; + priority: number; + label: string; + tags?: readonly string[]; + missingRegionId?: string; + missingRegionIds?: readonly string[]; + data?: Record; +} + +export interface PolyWorldChunkStreamingState { + requestedRegionIds: readonly string[]; + loadingRegionIds: readonly string[]; + loadedRegionIds: readonly string[]; + residentRegionIds: readonly string[]; + activeRegionIds: readonly string[]; + renderedRegionIds: readonly string[]; + preloadedRegionIds: readonly string[]; + missingRegionIds: readonly string[]; + sources: readonly PolyWorldChunkStreamingSourceSummary[]; + chunkTree?: PolyWorldChunkTreeSummary; + chunkTraversal?: PolyWorldChunkTreeTraversal; +} + +export interface PolyWorldChunkStreamingSelection extends PolyWorldSelection { + streaming: PolyWorldChunkStreamingState; +} + +export interface PolyWorldChunkStreamingStateSelectionOptions { + regionSelectionKeys?: PolyWorldRegionSelectionKeys; + selectionKeys?: readonly string[]; + reasonLabel?: string; + reasons?: readonly PolyWorldSelectionReason[]; + data?: Record; +} + +export interface PolyWorldChunkWindowSelectionOptions { + orderedRegionIds?: readonly string[]; + currentRegionId?: string; + before?: number; + after?: number; + windowRadius?: number; + activeRegionIds?: readonly string[]; + selectionKeys?: readonly string[]; + taggedRegionSelections?: readonly PolyWorldTaggedRegionSelection[]; + regionSelectionKeys?: PolyWorldRegionSelectionKeys; + reasonLabels?: PolyWorldChunkReasonLabels; + reasons?: readonly PolyWorldSelectionReason[]; + data?: Record; +} + +export function validatePolyWorldChunkTree( + input: PolyWorldChunkTreeInput, + options: PolyWorldChunkTreeOptions = {}, +): PolyWorldChunkTreeDiagnostic[] { + const diagnostics: PolyWorldChunkTreeDiagnostic[] = []; + const chunks = input.chunks ?? []; + const chunkIds = new Set(); + + if (chunks.length === 0) { + diagnostics.push({ + code: "poly-world-empty-chunk-tree", + message: "PolyWorld chunk tree requires at least one chunk.", + field: "chunks", + }); + } + + for (const chunk of chunks) { + validateChunkTreeId("chunk", chunk.id, "id", diagnostics); + if (chunk.id && chunkIds.has(chunk.id)) { + diagnostics.push({ + code: "poly-world-duplicate-chunk-id", + message: `Duplicate PolyWorld chunk id "${chunk.id}".`, + id: chunk.id, + field: "id", + }); + } + if (chunk.id) chunkIds.add(chunk.id); + } + + for (const chunk of chunks) { + validateChunkTreeNode(chunk, chunkIds, options.topology, diagnostics); + } + for (const rootChunkId of input.rootChunkIds ?? []) { + validateChunkTreeId("root chunk", rootChunkId, "rootChunkIds", diagnostics); + if (!chunkIds.has(rootChunkId)) { + diagnostics.push({ + code: "poly-world-missing-root-chunk", + message: `PolyWorld chunk tree rootChunkIds references missing chunk "${rootChunkId}".`, + id: rootChunkId, + field: "rootChunkIds", + }); + } + } + validateChunkTreeAvailability(chunks, diagnostics); + validateChunkTreeCycles(chunks, diagnostics); + return diagnostics; +} + +export function createPolyWorldChunkTree( + input: PolyWorldChunkTreeInput, + options: PolyWorldChunkTreeOptions = {}, +): PolyWorldChunkTree { + const diagnostics = validatePolyWorldChunkTree(input, options); + if (diagnostics.length > 0) throw new PolyWorldChunkTreeError(diagnostics); + + const chunks = input.chunks.map((chunk) => normalizeChunkTreeNode(chunk)); + const chunksById = new Map(); + const chunksByRegionId = new Map(); + const childIdsById = new Map(); + const parentIdById = new Map(); + + for (const chunk of chunks) { + chunksById.set(chunk.id, chunk); + if (chunk.regionId !== undefined) chunksByRegionId.set(chunk.regionId, chunk); + } + + for (const chunk of chunks) { + if (chunk.parentId !== undefined) { + parentIdById.set(chunk.id, chunk.parentId); + pushUniqueMap(childIdsById, chunk.parentId, chunk.id); + } + for (const childId of chunk.childIds ?? []) { + pushUniqueMap(childIdsById, chunk.id, childId); + if (!parentIdById.has(childId)) parentIdById.set(childId, chunk.id); + } + } + + const rootChunkIds = input.rootChunkIds === undefined + ? chunks.filter((chunk) => !parentIdById.has(chunk.id)).map((chunk) => chunk.id) + : unique(input.rootChunkIds); + + return { + chunks, + rootChunkIds, + data: input.data, + chunksById, + chunksByRegionId, + childIdsById, + parentIdById, + availableChunkIds: chunks.filter((chunk) => chunk.available !== false).map((chunk) => chunk.id), + contentChunkIds: chunks.filter((chunk) => chunk.contentAvailable === true).map((chunk) => chunk.id), + }; +} + +export function createPolyWorldChunkGraphFromTree(tree: PolyWorldChunkTree): PolyWorldChunkGraph { + const parentRegionIds: Record = {}; + const childRegionIds: Record = {}; + + for (const chunk of tree.chunks) { + if (chunk.regionId === undefined) continue; + const parentId = tree.parentIdById.get(chunk.id); + const parentRegionId = parentId === undefined ? undefined : tree.chunksById.get(parentId)?.regionId; + if (parentRegionId !== undefined) parentRegionIds[chunk.regionId] = parentRegionId; + + const childRegionIdsForChunk = (tree.childIdsById.get(chunk.id) ?? []) + .flatMap((childId) => { + const childRegionId = tree.chunksById.get(childId)?.regionId; + return childRegionId === undefined ? [] : [childRegionId]; + }); + if (childRegionIdsForChunk.length > 0) childRegionIds[chunk.regionId] = unique(childRegionIdsForChunk); + } + + return { parentRegionIds, childRegionIds }; +} + +export function summarizePolyWorldChunkTree(tree: PolyWorldChunkTree): PolyWorldChunkTreeSummary { + return { + chunkCount: tree.chunks.length, + rootChunkIds: tree.rootChunkIds, + availableChunkIds: tree.availableChunkIds, + contentChunkIds: tree.contentChunkIds, + maxDepth: resolveChunkTreeMaxDepth(tree), + }; +} + +export function resolvePolyWorldChunkTreeTraversal( + tree: PolyWorldChunkTree, + options: PolyWorldChunkTreeTraversalOptions = {}, +): PolyWorldChunkTreeTraversal { + const rootChunkIds = unique(options.rootChunkIds ?? tree.rootChunkIds) + .filter((chunkId) => tree.chunksById.has(chunkId)); + const currentChunkId = resolveChunkTraversalCurrentChunkId(tree, options); + const activePath = new Set(currentChunkId === undefined ? [] : resolveChunkAncestorIds(tree, currentChunkId)); + if (currentChunkId !== undefined) activePath.add(currentChunkId); + + const state: ChunkTreeTraversalState = { + tree, + budget: normalizeChunkTraversalBudget(options.budget), + viewPlanes: resolveChunkTraversalViewPlanes(options), + ...optionalChunkTraversalScreenSpaceError(options), + ...(options.point === undefined ? {} : { point: options.point }), + currentChunkId, + activePath, + entries: [], + selectedChunkIds: [], + refinedChunkIds: [], + renderedChunkIds: [], + loadedChunkIds: [], + residentChunkIds: [], + requestedChunkIds: [], + heldChunkIds: [], + unavailableChunkIds: [], + viewCulledChunkIds: [], + outsideRequestVolumeChunkIds: [], + skippedChunkIds: [], + budgetClippedChunkIds: [], + selectedRegionIds: [], + renderedRegionIds: [], + loadedRegionIds: [], + residentRegionIds: [], + requestedRegionIds: [], + totalRenderCost: 0, + totalLoadCost: 0, + renderedCount: 0, + loadedCount: 0, + visited: new Set(), + }; + + for (const rootChunkId of rootChunkIds) visitChunkTreeTraversal(rootChunkId, 0, ["root"], state); + + return { + ...(currentChunkId === undefined ? {} : { currentChunkId }), + rootChunkIds, + selectedChunkIds: state.selectedChunkIds, + refinedChunkIds: state.refinedChunkIds, + renderedChunkIds: state.renderedChunkIds, + loadedChunkIds: state.loadedChunkIds, + residentChunkIds: state.residentChunkIds, + requestedChunkIds: state.requestedChunkIds, + heldChunkIds: state.heldChunkIds, + unavailableChunkIds: state.unavailableChunkIds, + viewCulledChunkIds: state.viewCulledChunkIds, + outsideRequestVolumeChunkIds: state.outsideRequestVolumeChunkIds, + skippedChunkIds: state.skippedChunkIds, + budgetClippedChunkIds: state.budgetClippedChunkIds, + selectedRegionIds: state.selectedRegionIds, + renderedRegionIds: state.renderedRegionIds, + loadedRegionIds: state.loadedRegionIds, + residentRegionIds: state.residentRegionIds, + requestedRegionIds: state.requestedRegionIds, + totalRenderCost: state.totalRenderCost, + totalLoadCost: state.totalLoadCost, + budget: state.budget.publicBudget, + ...(state.screenSpaceError === undefined ? {} : { screenSpaceError: state.screenSpaceError }), + entries: state.entries, + }; +} + +export function selectPolyWorldChunkWindow( + topology: PolyWorldTopology, + options: PolyWorldChunkWindowSelectionOptions = {}, +): PolyWorldSelection { + const labels = { + current: "current", + active: "active", + window: "window", + tagged: "tagged", + selectionKey: "selection-key", + ...options.reasonLabels, + }; + const orderedRegionIds = options.orderedRegionIds ?? topology.regions.map((region) => region.id); + const before = options.windowRadius ?? options.before ?? 0; + const after = options.windowRadius ?? options.after ?? 0; + const regionIds: string[] = []; + const selectionKeys: string[] = []; + const reasons: PolyWorldSelectionReason[] = [...(options.reasons ?? [])]; + + for (const activeRegionId of options.activeRegionIds ?? []) add(regionIds, activeRegionId); + if ((options.activeRegionIds?.length ?? 0) > 0) { + reasons.push({ + id: "poly-world-chunk-active", + kind: "active", + label: labels.active, + regionIds: unique(options.activeRegionIds), + }); + } + + if (options.currentRegionId !== undefined) { + add(regionIds, options.currentRegionId); + reasons.push({ + id: "poly-world-chunk-current", + kind: "current", + label: labels.current, + regionIds: [options.currentRegionId], + }); + const windowRegionIds = windowAround(orderedRegionIds, options.currentRegionId, before, after); + for (const windowRegionId of windowRegionIds) add(regionIds, windowRegionId); + if (windowRegionIds.length > 0) { + reasons.push({ + id: "poly-world-chunk-window", + kind: "window", + label: labels.window, + regionIds: windowRegionIds, + }); + } + } + + for (const tagged of options.taggedRegionSelections ?? []) { + for (const regionId of tagged.regionIds) add(regionIds, regionId); + for (const selectionKey of tagged.selectionKeys ?? []) add(selectionKeys, selectionKey); + reasons.push({ + id: tagged.kind === undefined ? undefined : `poly-world-chunk-${tagged.kind}`, + kind: tagged.kind ?? "tagged", + label: tagged.label || labels.tagged, + regionIds: unique(tagged.regionIds), + selectionKeys: unique(tagged.selectionKeys), + tags: tagged.tags, + data: tagged.data, + }); + } + + for (const regionId of regionIds) { + const region = topology.regionsById.get(regionId); + for (const selectionKey of region?.selectionKeys ?? []) add(selectionKeys, selectionKey); + for (const selectionKey of resolvePolyWorldRegionSelectionKeys(options.regionSelectionKeys, regionId, topology)) { + add(selectionKeys, selectionKey); + } + } + + for (const selectionKey of options.selectionKeys ?? []) add(selectionKeys, selectionKey); + if (selectionKeys.length > 0) { + reasons.push({ + id: "poly-world-chunk-selection-key", + kind: "selectionKey", + label: labels.selectionKey, + selectionKeys, + }); + } + + return { + regionIds, + selectionKeys, + reasons, + data: options.data, + }; +} + +export function selectPolyWorldChunkStreaming( + topology: PolyWorldTopology, + options: PolyWorldChunkStreamingSelectionOptions = {}, +): PolyWorldChunkStreamingSelection { + const labels = { + current: "current", + active: "active", + window: "window", + tagged: "tagged", + selectionKey: "selection-key", + streamingSource: "streaming-source", + chunkTreeTraversal: "chunk-tree-traversal", + ...options.reasonLabels, + }; + const chunkTree = resolveChunkTreeOption(options.chunkTree, topology); + const chunkGraph = options.chunkGraph ?? (chunkTree === undefined ? undefined : createPolyWorldChunkGraphFromTree(chunkTree)); + const orderedRegionIds = options.orderedRegionIds ?? topology.regions.map((region) => region.id); + const before = options.windowRadius ?? options.before ?? 0; + const after = options.windowRadius ?? options.after ?? 0; + const targetState = options.targetState ?? "active"; + const regionIds: string[] = []; + const selectionKeys: string[] = []; + const requestedRegionIds: string[] = []; + const missingRegionIds: string[] = []; + const reasons: PolyWorldSelectionReason[] = [...(options.reasons ?? [])]; + const sourceSummaries: PolyWorldChunkStreamingSourceSummary[] = []; + const initiallyLoaded = new Set(options.loadedRegionIds ?? []); + const loadedRegionIds = new Set(options.loadedRegionIds ?? []); + const residentRegionIds = new Set(options.residentRegionIds ?? []); + const activeRegionIds = new Set(options.activeRegionIds ?? []); + const renderedRegionIds = new Set(options.renderedRegionIds ?? []); + const preloadedRegionIds = new Set(options.preloadedRegionIds ?? []); + const chunkTraversal = chunkTree === undefined || options.chunkTraversal === undefined || options.chunkTraversal === false + ? undefined + : resolvePolyWorldChunkTreeTraversal(chunkTree, { + currentRegionId: options.currentRegionId, + nearest: options.nearest, + ...options.chunkTraversal, + }); + + for (const activeRegionId of options.activeRegionIds ?? []) add(regionIds, activeRegionId); + if ((options.activeRegionIds?.length ?? 0) > 0) { + reasons.push({ + id: "poly-world-chunk-active", + kind: "active", + label: labels.active, + regionIds: unique(options.activeRegionIds), + }); + } + + const sources = [...normalizeStreamingSources(options)].sort(compareStreamingSources); + for (const source of sources) { + const sourceRegionId = resolveStreamingSourceRegionId(topology, source, options); + const sourceTargetState = source.targetState ?? targetState; + const priority = source.priority ?? 0; + const label = source.label ?? labels.streamingSource; + + if (sourceRegionId === undefined) { + const missingRegionId = source.regionId; + if (missingRegionId !== undefined) add(missingRegionIds, missingRegionId); + sourceSummaries.push({ + sourceId: source.id, + selectedRegionIds: [], + targetState: sourceTargetState, + priority, + label, + tags: source.tags, + missingRegionId, + data: source.data, + }); + continue; + } + + const sourceSelection = selectedRegionsForStreamingSource(topology, source, sourceRegionId, { + orderedRegionIds, + before, + after, + loadingRange: options.loadingRange, + chunkGraph, + chunkGraphExpansion: options.chunkGraphExpansion, + }); + const selectedRegionIds = sourceSelection.regionIds; + for (const missingRegionId of sourceSelection.missingRegionIds) add(missingRegionIds, missingRegionId); + const graphRegionIds = new Set(sourceSelection.graphRegionIds); + for (const selectedRegionId of selectedRegionIds) { + add(regionIds, selectedRegionId); + add(requestedRegionIds, selectedRegionId); + const selectedTargetState = sourceSelection.graphTargetState !== undefined && graphRegionIds.has(selectedRegionId) + ? sourceSelection.graphTargetState + : sourceTargetState; + addTargetState(selectedRegionId, selectedTargetState, { + loadedRegionIds, + residentRegionIds, + activeRegionIds, + renderedRegionIds, + preloadedRegionIds, + }); + } + for (const selectionKey of source.selectionKeys ?? []) add(selectionKeys, selectionKey); + + sourceSummaries.push({ + sourceId: source.id, + currentRegionId: sourceRegionId, + selectedRegionIds, + ...(sourceSelection.graphRegionIds.length === 0 ? {} : { graphRegionIds: sourceSelection.graphRegionIds }), + ...(sourceSelection.graphTargetState === undefined ? {} : { graphTargetState: sourceSelection.graphTargetState }), + targetState: sourceTargetState, + priority, + label, + tags: source.tags, + ...(sourceSelection.missingRegionIds.length === 0 ? {} : { missingRegionIds: sourceSelection.missingRegionIds }), + data: source.data, + }); + reasons.push({ + id: `poly-world-chunk-source-${source.id}`, + kind: "streamingSource", + label, + regionIds: selectedRegionIds, + selectionKeys: unique(source.selectionKeys), + tags: source.tags, + data: { + sourceId: source.id, + currentRegionId: sourceRegionId, + targetState: sourceTargetState, + priority, + ...(source.data ?? {}), + }, + }); + } + + for (const tagged of options.taggedRegionSelections ?? []) { + for (const regionId of tagged.regionIds) { + add(regionIds, regionId); + add(requestedRegionIds, regionId); + } + for (const selectionKey of tagged.selectionKeys ?? []) add(selectionKeys, selectionKey); + reasons.push({ + id: tagged.kind === undefined ? undefined : `poly-world-chunk-${tagged.kind}`, + kind: tagged.kind ?? "tagged", + label: tagged.label || labels.tagged, + regionIds: unique(tagged.regionIds), + selectionKeys: unique(tagged.selectionKeys), + tags: tagged.tags, + data: tagged.data, + }); + } + + if (chunkTraversal !== undefined) { + for (const regionId of chunkTraversal.selectedRegionIds) { + add(regionIds, regionId); + add(requestedRegionIds, regionId); + } + for (const regionId of chunkTraversal.requestedRegionIds) add(requestedRegionIds, regionId); + for (const regionId of chunkTraversal.loadedRegionIds) { + addTargetState(regionId, "loaded", { + loadedRegionIds, + residentRegionIds, + activeRegionIds, + renderedRegionIds, + preloadedRegionIds, + }); + } + for (const regionId of chunkTraversal.residentRegionIds) { + addTargetState(regionId, "resident", { + loadedRegionIds, + residentRegionIds, + activeRegionIds, + renderedRegionIds, + preloadedRegionIds, + }); + } + for (const regionId of chunkTraversal.renderedRegionIds) { + addTargetState(regionId, "rendered", { + loadedRegionIds, + residentRegionIds, + activeRegionIds, + renderedRegionIds, + preloadedRegionIds, + }); + } + reasons.push({ + id: "poly-world-chunk-tree-traversal", + kind: "chunkTreeTraversal", + label: labels.chunkTreeTraversal, + regionIds: chunkTraversal.selectedRegionIds, + data: { + currentChunkId: chunkTraversal.currentChunkId, + selectedChunkIds: chunkTraversal.selectedChunkIds, + renderedChunkIds: chunkTraversal.renderedChunkIds, + requestedChunkIds: chunkTraversal.requestedChunkIds, + budgetClippedChunkIds: chunkTraversal.budgetClippedChunkIds, + totalRenderCost: chunkTraversal.totalRenderCost, + totalLoadCost: chunkTraversal.totalLoadCost, + }, + }); + } + + for (const regionId of regionIds) { + const region = topology.regionsById.get(regionId); + for (const selectionKey of region?.selectionKeys ?? []) add(selectionKeys, selectionKey); + for (const selectionKey of resolvePolyWorldRegionSelectionKeys(options.regionSelectionKeys, regionId, topology)) { + add(selectionKeys, selectionKey); + } + } + + for (const selectionKey of options.selectionKeys ?? []) add(selectionKeys, selectionKey); + if (selectionKeys.length > 0) { + reasons.push({ + id: "poly-world-chunk-selection-key", + kind: "selectionKey", + label: labels.selectionKey, + selectionKeys, + }); + } + + const loadedIds = uniqueSorted([...loadedRegionIds]); + + return { + regionIds, + selectionKeys, + reasons, + data: options.data, + streaming: { + requestedRegionIds, + loadingRegionIds: requestedRegionIds.filter((regionId) => !initiallyLoaded.has(regionId)), + loadedRegionIds: loadedIds, + residentRegionIds: uniqueSorted([...residentRegionIds]), + activeRegionIds: uniqueSorted([...activeRegionIds]), + renderedRegionIds: uniqueSorted([...renderedRegionIds]), + preloadedRegionIds: uniqueSorted([...preloadedRegionIds]), + missingRegionIds, + sources: sourceSummaries.sort((a, b) => compareSourceSummaries(a, b)), + ...(chunkTree === undefined ? {} : { chunkTree: summarizePolyWorldChunkTree(chunkTree) }), + ...(chunkTraversal === undefined ? {} : { chunkTraversal }), + }, + }; +} + +export function selectPolyWorldChunkStreamingState( + topology: PolyWorldTopology, + selection: PolyWorldChunkStreamingSelection, + state: PolyWorldChunkStreamingStateName, + options: PolyWorldChunkStreamingStateSelectionOptions = {}, +): PolyWorldSelection { + const regionIds = regionIdsForStreamingState(selection.streaming, state); + const selectionKeys: string[] = []; + for (const regionId of regionIds) { + const region = topology.regionsById.get(regionId); + for (const selectionKey of region?.selectionKeys ?? []) add(selectionKeys, selectionKey); + for (const selectionKey of resolvePolyWorldRegionSelectionKeys(options.regionSelectionKeys, regionId, topology)) { + add(selectionKeys, selectionKey); + } + } + for (const selectionKey of options.selectionKeys ?? []) add(selectionKeys, selectionKey); + return { + regionIds, + selectionKeys, + reasons: [ + ...(options.reasons ?? []), + { + id: `poly-world-chunk-streaming-${state}`, + kind: "streamingState", + label: options.reasonLabel ?? state, + regionIds, + selectionKeys, + data: { state }, + }, + ], + data: options.data, + }; +} + +function regionIdsForStreamingState( + state: PolyWorldChunkStreamingState, + stateName: PolyWorldChunkStreamingStateName, +): readonly string[] { + if (stateName === "requested") return state.requestedRegionIds; + if (stateName === "loading") return state.loadingRegionIds; + if (stateName === "loaded") return state.loadedRegionIds; + if (stateName === "resident") return state.residentRegionIds; + if (stateName === "active") return state.activeRegionIds; + if (stateName === "rendered") return state.renderedRegionIds; + return state.preloadedRegionIds; +} + +interface NormalizedChunkTreeTraversalBudget { + publicBudget: PolyWorldChunkTreeTraversalBudget; + maxRenderedChunks?: number; + maxLoadedChunks?: number; + maxRenderCost?: number; + maxLoadCost?: number; + targetGeometricError?: number; + maxScreenSpaceError?: number; + maxDepth?: number; +} + +interface ChunkTreeTraversalState { + tree: PolyWorldChunkTree; + budget: NormalizedChunkTreeTraversalBudget; + viewPlanes: readonly PolyWorldChunkTreeTraversalPlane[]; + screenSpaceError?: PolyWorldChunkTreeTraversalScreenSpaceError; + point?: Vec3; + currentChunkId?: string; + activePath: ReadonlySet; + entries: PolyWorldChunkTreeTraversalEntry[]; + selectedChunkIds: string[]; + refinedChunkIds: string[]; + renderedChunkIds: string[]; + loadedChunkIds: string[]; + residentChunkIds: string[]; + requestedChunkIds: string[]; + heldChunkIds: string[]; + unavailableChunkIds: string[]; + viewCulledChunkIds: string[]; + outsideRequestVolumeChunkIds: string[]; + skippedChunkIds: string[]; + budgetClippedChunkIds: string[]; + selectedRegionIds: string[]; + renderedRegionIds: string[]; + loadedRegionIds: string[]; + residentRegionIds: string[]; + requestedRegionIds: string[]; + totalRenderCost: number; + totalLoadCost: number; + renderedCount: number; + loadedCount: number; + visited: Set; +} + +function visitChunkTreeTraversal( + chunkId: string, + depth: number, + baseReasons: readonly PolyWorldChunkTreeTraversalReason[], + state: ChunkTreeTraversalState, +): void { + const chunk = state.tree.chunksById.get(chunkId); + if (chunk === undefined || state.visited.has(chunkId)) return; + state.visited.add(chunkId); + + const reasons: PolyWorldChunkTreeTraversalReason[] = [...baseReasons]; + if (chunk.id === state.currentChunkId) addReason(reasons, "current"); + else if (state.activePath.has(chunk.id)) addReason(reasons, "ancestor"); + + const available = chunk.available !== false; + const contentAvailable = chunk.contentAvailable === true; + const cost = chunk.cost ?? 1; + const priority = chunk.priority ?? 0; + const childIds = state.tree.childIdsById.get(chunk.id) ?? []; + + if (!state.activePath.has(chunk.id) && isChunkOutsideViewerRequestBounds(chunk, state.point)) { + addReason(reasons, "outside-request-volume"); + addReason(reasons, "skipped"); + add(state.outsideRequestVolumeChunkIds, chunk.id); + add(state.skippedChunkIds, chunk.id); + addTraversalEntry(state, chunk, depth, available, contentAvailable, cost, priority, reasons); + markSkippedChunkSubtree(state, childIds, depth + 1, ["outside-request-volume"]); + return; + } + + if (!state.activePath.has(chunk.id) && isChunkOutsideTraversalView(chunk, state.viewPlanes)) { + addReason(reasons, "view-culled"); + addReason(reasons, "skipped"); + add(state.viewCulledChunkIds, chunk.id); + add(state.skippedChunkIds, chunk.id); + addTraversalEntry(state, chunk, depth, available, contentAvailable, cost, priority, reasons); + markSkippedChunkSubtree(state, childIds, depth + 1, ["view-culled"]); + return; + } + + if (!available) { + addReason(reasons, "unavailable"); + add(state.unavailableChunkIds, chunk.id); + addTraversalEntry(state, chunk, depth, available, contentAvailable, cost, priority, reasons); + markSkippedChunkSubtree(state, childIds, depth + 1); + return; + } + + const depthCapped = state.budget.maxDepth !== undefined && depth >= state.budget.maxDepth; + const shouldRefine = childIds.length > 0 && !depthCapped && shouldRefineChunk(chunk, state); + + if (shouldRefine) { + addReason(reasons, "refined"); + add(state.refinedChunkIds, chunk.id); + if (contentAvailable) { + if (chunk.refinement === "add") renderChunkTraversalEntry(state, chunk, cost, reasons); + else holdChunkTraversalEntry(state, chunk, cost, reasons); + } else { + requestChunkTraversalEntry(state, chunk, reasons); + } + addTraversalEntry(state, chunk, depth, available, contentAvailable, cost, priority, reasons); + for (const childId of sortChunkTraversalChildIds(state.tree, childIds)) { + visitChunkTreeTraversal(childId, depth + 1, [], state); + } + return; + } + + if (contentAvailable) renderChunkTraversalEntry(state, chunk, cost, reasons); + else requestChunkTraversalEntry(state, chunk, reasons); + addTraversalEntry(state, chunk, depth, available, contentAvailable, cost, priority, reasons); + if (childIds.length > 0) markSkippedChunkSubtree(state, childIds, depth + 1); +} + +function shouldRefineChunk( + chunk: PolyWorldChunkTreeNode, + state: ChunkTreeTraversalState, +): boolean { + if (state.activePath.has(chunk.id)) return true; + const screenSpaceError = resolveChunkTraversalScreenSpaceError(state, chunk); + if (screenSpaceError !== undefined && state.budget.maxScreenSpaceError !== undefined) { + return screenSpaceError > state.budget.maxScreenSpaceError; + } + const targetGeometricError = state.budget.targetGeometricError; + return targetGeometricError !== undefined && (chunk.geometricError ?? 0) > targetGeometricError; +} + +function renderChunkTraversalEntry( + state: ChunkTreeTraversalState, + chunk: PolyWorldChunkTreeNode, + cost: number, + reasons: PolyWorldChunkTreeTraversalReason[], +): void { + if (!loadChunkTraversalEntry(state, chunk, cost, reasons)) return; + if ( + exceedsBudget(state.renderedCount + 1, state.budget.maxRenderedChunks) || + exceedsBudget(state.totalRenderCost + cost, state.budget.maxRenderCost) + ) { + addReason(reasons, "budget-clipped"); + addReason(reasons, "held"); + add(state.budgetClippedChunkIds, chunk.id); + add(state.heldChunkIds, chunk.id); + add(state.residentChunkIds, chunk.id); + addRegion(state.residentRegionIds, chunk); + return; + } + addReason(reasons, "rendered"); + state.renderedCount += 1; + state.totalRenderCost += cost; + add(state.renderedChunkIds, chunk.id); + add(state.residentChunkIds, chunk.id); + addRegion(state.renderedRegionIds, chunk); + addRegion(state.residentRegionIds, chunk); +} + +function holdChunkTraversalEntry( + state: ChunkTreeTraversalState, + chunk: PolyWorldChunkTreeNode, + cost: number, + reasons: PolyWorldChunkTreeTraversalReason[], +): void { + if (!loadChunkTraversalEntry(state, chunk, cost, reasons)) return; + addReason(reasons, "held"); + addReason(reasons, "resident"); + add(state.heldChunkIds, chunk.id); + add(state.residentChunkIds, chunk.id); + addRegion(state.residentRegionIds, chunk); +} + +function loadChunkTraversalEntry( + state: ChunkTreeTraversalState, + chunk: PolyWorldChunkTreeNode, + cost: number, + reasons: PolyWorldChunkTreeTraversalReason[], +): boolean { + if ( + exceedsBudget(state.loadedCount + 1, state.budget.maxLoadedChunks) || + exceedsBudget(state.totalLoadCost + cost, state.budget.maxLoadCost) + ) { + addReason(reasons, "budget-clipped"); + add(state.budgetClippedChunkIds, chunk.id); + return false; + } + addReason(reasons, "loaded"); + state.loadedCount += 1; + state.totalLoadCost += cost; + add(state.loadedChunkIds, chunk.id); + add(state.selectedChunkIds, chunk.id); + addRegion(state.loadedRegionIds, chunk); + addRegion(state.selectedRegionIds, chunk); + return true; +} + +function requestChunkTraversalEntry( + state: ChunkTreeTraversalState, + chunk: PolyWorldChunkTreeNode, + reasons: PolyWorldChunkTreeTraversalReason[], +): void { + addReason(reasons, "requested"); + add(state.requestedChunkIds, chunk.id); + add(state.selectedChunkIds, chunk.id); + addRegion(state.requestedRegionIds, chunk); + addRegion(state.selectedRegionIds, chunk); +} + +function addTraversalEntry( + state: ChunkTreeTraversalState, + chunk: PolyWorldChunkTreeNode, + depth: number, + available: boolean, + contentAvailable: boolean, + cost: number, + priority: number, + reasons: readonly PolyWorldChunkTreeTraversalReason[], +): void { + const distanceToCamera = resolveChunkTraversalDistanceToCamera(state, chunk); + const screenSpaceError = resolveChunkTraversalScreenSpaceError(state, chunk, distanceToCamera); + state.entries.push({ + chunkId: chunk.id, + ...(chunk.regionId === undefined ? {} : { regionId: chunk.regionId }), + ...(chunk.parentId === undefined ? {} : { parentId: chunk.parentId }), + depth, + available, + contentAvailable, + ...(chunk.refinement === undefined ? {} : { refinement: chunk.refinement }), + ...(chunk.geometricError === undefined ? {} : { geometricError: chunk.geometricError }), + ...(distanceToCamera === undefined ? {} : { distanceToCamera }), + ...(screenSpaceError === undefined ? {} : { screenSpaceError }), + cost, + priority, + reasons: uniqueReasons(reasons), + }); +} + +function markSkippedChunkSubtree( + state: ChunkTreeTraversalState, + chunkIds: readonly string[], + depth: number, + extraReasons: readonly PolyWorldChunkTreeTraversalReason[] = [], +): void { + for (const chunkId of sortChunkTraversalChildIds(state.tree, chunkIds)) { + const chunk = state.tree.chunksById.get(chunkId); + if (chunk === undefined || state.visited.has(chunk.id)) continue; + state.visited.add(chunk.id); + const reasons: PolyWorldChunkTreeTraversalReason[] = [...extraReasons, "skipped"]; + if (extraReasons.includes("view-culled")) add(state.viewCulledChunkIds, chunk.id); + if (extraReasons.includes("outside-request-volume")) add(state.outsideRequestVolumeChunkIds, chunk.id); + add(state.skippedChunkIds, chunk.id); + addTraversalEntry( + state, + chunk, + depth, + chunk.available !== false, + chunk.contentAvailable === true, + chunk.cost ?? 1, + chunk.priority ?? 0, + reasons, + ); + markSkippedChunkSubtree(state, state.tree.childIdsById.get(chunk.id) ?? [], depth + 1, extraReasons); + } +} + +function normalizeChunkTraversalBudget( + budget: PolyWorldChunkTreeTraversalBudget | undefined, +): NormalizedChunkTreeTraversalBudget { + return { + publicBudget: { + ...optionalNonNegativeInteger("maxRenderedChunks", budget?.maxRenderedChunks), + ...optionalNonNegativeInteger("maxLoadedChunks", budget?.maxLoadedChunks), + ...optionalNonNegativeNumber("maxRenderCost", budget?.maxRenderCost), + ...optionalNonNegativeNumber("maxLoadCost", budget?.maxLoadCost), + ...optionalNonNegativeNumber("targetGeometricError", budget?.targetGeometricError), + ...optionalNonNegativeNumber("maxScreenSpaceError", budget?.maxScreenSpaceError), + ...optionalNonNegativeInteger("maxDepth", budget?.maxDepth), + }, + maxRenderedChunks: normalizeNonNegativeInteger(budget?.maxRenderedChunks), + maxLoadedChunks: normalizeNonNegativeInteger(budget?.maxLoadedChunks), + maxRenderCost: normalizeNonNegativeNumber(budget?.maxRenderCost), + maxLoadCost: normalizeNonNegativeNumber(budget?.maxLoadCost), + targetGeometricError: normalizeNonNegativeNumber(budget?.targetGeometricError), + maxScreenSpaceError: normalizeNonNegativeNumber(budget?.maxScreenSpaceError), + maxDepth: normalizeNonNegativeInteger(budget?.maxDepth), + }; +} + +function optionalChunkTraversalScreenSpaceError( + options: PolyWorldChunkTreeTraversalOptions, +): { screenSpaceError?: PolyWorldChunkTreeTraversalScreenSpaceError } { + const viewportHeight = normalizePositiveNumber(options.viewportHeight); + if (viewportHeight === undefined || options.point === undefined) return {}; + const fovDegrees = normalizePositiveNumber(options.fovDegrees ?? 90); + if (fovDegrees === undefined) return {}; + const aspect = normalizePositiveNumber(options.aspect); + const verticalFovDegrees = aspect === undefined + ? fovDegrees + : horizontalFovToVerticalFovDegrees(fovDegrees, aspect); + const distanceFloor = normalizePositiveNumber(options.screenSpaceErrorDistanceFloor) + ?? Math.max(normalizeNonNegativeNumber(options.near) ?? 0, 0.0001); + const maxError = normalizeNonNegativeNumber(options.budget?.maxScreenSpaceError); + return { + screenSpaceError: { + viewportHeight, + fovDegrees: verticalFovDegrees, + ...(maxError === undefined ? {} : { maxError }), + distanceFloor, + }, + }; +} + +function resolveChunkTraversalScreenSpaceError( + state: ChunkTreeTraversalState, + chunk: PolyWorldChunkTreeNode, + distanceToCamera = resolveChunkTraversalDistanceToCamera(state, chunk), +): number | undefined { + if ( + state.screenSpaceError === undefined || + distanceToCamera === undefined || + chunk.geometricError === undefined + ) { + return undefined; + } + const distance = Math.max(distanceToCamera, state.screenSpaceError.distanceFloor); + const verticalFovRadians = state.screenSpaceError.fovDegrees * Math.PI / 180; + const denominator = 2 * distance * Math.tan(verticalFovRadians / 2); + if (!Number.isFinite(denominator) || denominator <= 0) return undefined; + return chunk.geometricError * state.screenSpaceError.viewportHeight / denominator; +} + +function resolveChunkTraversalDistanceToCamera( + state: ChunkTreeTraversalState, + chunk: PolyWorldChunkTreeNode, +): number | undefined { + if (state.point === undefined) return undefined; + const bounds = chunk.contentBounds ?? chunk.bounds; + if (bounds === undefined) return undefined; + return Math.sqrt(distanceSqToBounds(state.point, bounds)); +} + +function distanceSqToBounds(point: Vec3, bounds: PolyWorldBounds): number { + let total = 0; + for (let axis = 0; axis < 3; axis += 1) { + const min = bounds.min[axis] ?? 0; + const max = bounds.max[axis] ?? 0; + const coordinate = point[axis] ?? 0; + const delta = coordinate < min ? min - coordinate : coordinate > max ? coordinate - max : 0; + total += delta * delta; + } + return total; +} + +function horizontalFovToVerticalFovDegrees(horizontalFovDegrees: number, aspect: number): number { + const horizontal = horizontalFovDegrees * Math.PI / 180; + const vertical = 2 * Math.atan(Math.tan(horizontal / 2) / aspect); + return vertical * 180 / Math.PI; +} + +function resolveChunkTraversalViewPlanes( + options: PolyWorldChunkTreeTraversalOptions, +): readonly PolyWorldChunkTreeTraversalPlane[] { + if (options.frustum !== undefined) { + return options.frustum + .map((plane) => normalizeChunkTraversalPlane(plane)) + .filter((plane): plane is PolyWorldChunkTreeTraversalPlane => plane !== undefined); + } + if (options.point === undefined || options.forward === undefined) return []; + return createChunkTraversalViewPlanes(options.point, options.forward, { + up: options.up, + fovDegrees: options.fovDegrees ?? 90, + aspect: options.aspect, + near: options.near, + far: options.far, + }); +} + +function normalizeChunkTraversalPlane( + plane: PolyWorldChunkTreeTraversalPlane, +): PolyWorldChunkTreeTraversalPlane | undefined { + const normal = normalizeVec3(plane.normal); + if (normal === undefined || !Number.isFinite(plane.distance)) return undefined; + return { normal, distance: plane.distance }; +} + +function createChunkTraversalViewPlanes( + origin: Vec3, + forward: Vec3, + options: { + up?: Vec3; + fovDegrees: number; + aspect?: number; + near?: number; + far?: number; + }, +): readonly PolyWorldChunkTreeTraversalPlane[] { + if (!isFiniteChunkTreeVec3(origin)) return []; + const forwardDirection = normalizeVec3(forward); + if (forwardDirection === undefined) return []; + const aspect = Number.isFinite(options.aspect) && (options.aspect ?? 0) > 0 ? options.aspect as number : 1; + const near = Number.isFinite(options.near) ? Math.max(0, options.near as number) : 0; + const planes: PolyWorldChunkTreeTraversalPlane[] = []; + if (near > 0) { + planes.push({ + normal: forwardDirection, + distance: dotVec3(forwardDirection, origin) + near, + }); + } + if (options.far !== undefined && Number.isFinite(options.far) && options.far > near) { + const farNormal = scaleVec3(forwardDirection, -1); + planes.push({ + normal: farNormal, + distance: dotVec3(farNormal, addVec3(origin, scaleVec3(forwardDirection, options.far))), + }); + } + const fovDegrees = Number.isFinite(options.fovDegrees) && options.fovDegrees > 0 + ? Math.min(options.fovDegrees, 360) + : 90; + if (fovDegrees >= 359.999) return planes; + + const basis = createChunkTraversalViewBasis(forwardDirection, options.up); + const halfHorizontal = fovDegrees * Math.PI / 360; + const halfVertical = Math.atan(Math.tan(halfHorizontal) / aspect); + const horizontal = Math.tan(halfHorizontal); + const vertical = Math.tan(halfVertical); + const topLeft = normalizeVec3(addVec3(addVec3(forwardDirection, scaleVec3(basis.up, vertical)), scaleVec3(basis.right, -horizontal))); + const topRight = normalizeVec3(addVec3(addVec3(forwardDirection, scaleVec3(basis.up, vertical)), scaleVec3(basis.right, horizontal))); + const bottomRight = normalizeVec3(addVec3(addVec3(forwardDirection, scaleVec3(basis.up, -vertical)), scaleVec3(basis.right, horizontal))); + const bottomLeft = normalizeVec3(addVec3(addVec3(forwardDirection, scaleVec3(basis.up, -vertical)), scaleVec3(basis.right, -horizontal))); + for (const plane of [ + createChunkTraversalRayPlane(origin, topLeft, topRight, forwardDirection), + createChunkTraversalRayPlane(origin, topRight, bottomRight, forwardDirection), + createChunkTraversalRayPlane(origin, bottomRight, bottomLeft, forwardDirection), + createChunkTraversalRayPlane(origin, bottomLeft, topLeft, forwardDirection), + ]) { + if (plane !== undefined) planes.push(plane); + } + return planes; +} + +function createChunkTraversalViewBasis(forward: Vec3, up: Vec3 | undefined): { right: Vec3; up: Vec3 } { + const worldUp = normalizeVec3(up ?? [0, 0, 1]) ?? [0, 0, 1]; + let right = normalizeVec3(crossVec3(forward, worldUp)); + if (right === undefined) right = normalizeVec3(crossVec3(forward, [0, 1, 0])) ?? [1, 0, 0]; + const viewUp = normalizeVec3(crossVec3(right, forward)) ?? worldUp; + return { right, up: viewUp }; +} + +function createChunkTraversalRayPlane( + origin: Vec3, + a: Vec3 | undefined, + b: Vec3 | undefined, + forward: Vec3, +): PolyWorldChunkTreeTraversalPlane | undefined { + if (a === undefined || b === undefined) return undefined; + let normal = normalizeVec3(crossVec3(a, b)); + if (normal === undefined) return undefined; + if (dotVec3(normal, forward) < 0) normal = scaleVec3(normal, -1); + return { + normal, + distance: dotVec3(normal, origin), + }; +} + +function isChunkOutsideTraversalView( + chunk: PolyWorldChunkTreeNode, + planes: readonly PolyWorldChunkTreeTraversalPlane[], +): boolean { + const bounds = chunk.contentBounds ?? chunk.bounds; + if (planes.length === 0 || bounds === undefined) return false; + const corners = boundsCorners(bounds); + return planes.some((plane) => + corners.every((corner) => signedChunkTraversalPlaneDistance(plane, corner) < -0.0001) + ); +} + +function isChunkOutsideViewerRequestBounds( + chunk: PolyWorldChunkTreeNode, + point: Vec3 | undefined, +): boolean { + if (point === undefined || chunk.viewerRequestBounds === undefined) return false; + return !boundsContainsPoint(chunk.viewerRequestBounds, point); +} + +function signedChunkTraversalPlaneDistance( + plane: PolyWorldChunkTreeTraversalPlane, + point: Vec3, +): number { + return dotVec3(plane.normal, point) - plane.distance; +} + +function resolveChunkTraversalCurrentChunkId( + tree: PolyWorldChunkTree, + options: PolyWorldChunkTreeTraversalOptions, +): string | undefined { + if (options.currentChunkId !== undefined && tree.chunksById.has(options.currentChunkId)) return options.currentChunkId; + if (options.currentRegionId !== undefined) return tree.chunksByRegionId.get(options.currentRegionId)?.id; + if (options.point === undefined) return undefined; + const containing = tree.chunks + .filter((chunk) => chunk.bounds !== undefined && boundsContainsPoint(chunk.bounds, options.point as Vec3)) + .sort((a, b) => boundsVolume(a.bounds) - boundsVolume(b.bounds) || compareChunkTraversalNodes(a, b)); + if (containing.length > 0) return containing[0]?.id; + if (options.nearest !== true) return undefined; + return tree.chunks + .filter((chunk) => chunk.bounds !== undefined) + .sort((a, b) => + distanceSq(centerFromBounds(a.bounds) as Vec3, options.point as Vec3) - + distanceSq(centerFromBounds(b.bounds) as Vec3, options.point as Vec3) || + compareChunkTraversalNodes(a, b) + )[0]?.id; +} + +function resolveChunkAncestorIds(tree: PolyWorldChunkTree, chunkId: string): string[] { + const ancestorIds: string[] = []; + let currentId = tree.parentIdById.get(chunkId); + while (currentId !== undefined) { + ancestorIds.unshift(currentId); + currentId = tree.parentIdById.get(currentId); + } + return ancestorIds; +} + +function sortChunkTraversalChildIds( + tree: PolyWorldChunkTree, + childIds: readonly string[], +): string[] { + return [...childIds].sort((a, b) => { + const chunkA = tree.chunksById.get(a); + const chunkB = tree.chunksById.get(b); + if (chunkA === undefined || chunkB === undefined) return compareStrings(a, b); + return compareChunkTraversalNodes(chunkA, chunkB); + }); +} + +function compareChunkTraversalNodes( + a: PolyWorldChunkTreeNode, + b: PolyWorldChunkTreeNode, +): number { + return (b.priority ?? 0) - (a.priority ?? 0) || + (b.geometricError ?? 0) - (a.geometricError ?? 0) || + compareStrings(a.id, b.id); +} + +function addRegion(values: string[], chunk: PolyWorldChunkTreeNode): void { + if (chunk.regionId !== undefined) add(values, chunk.regionId); +} + +function addReason(values: PolyWorldChunkTreeTraversalReason[], value: PolyWorldChunkTreeTraversalReason): void { + if (!values.includes(value)) values.push(value); +} + +function uniqueReasons(values: readonly PolyWorldChunkTreeTraversalReason[]): PolyWorldChunkTreeTraversalReason[] { + return [...new Set(values)]; +} + +function exceedsBudget(value: number, budget: number | undefined): boolean { + return budget !== undefined && value > budget; +} + +function resolveChunkTreeOption( + input: PolyWorldChunkTree | PolyWorldChunkTreeInput | undefined, + topology: PolyWorldTopology, +): PolyWorldChunkTree | undefined { + if (input === undefined) return undefined; + if ("chunksById" in input) return input; + return createPolyWorldChunkTree(input, { topology }); +} + +function normalizeStreamingSources( + options: PolyWorldChunkStreamingSelectionOptions, +): readonly PolyWorldChunkStreamingSource[] { + if (options.sources !== undefined) return options.sources; + if (options.chunkTraversal !== undefined && options.chunkTraversal !== false) return []; + if (options.currentRegionId === undefined) return []; + return [{ + id: "current", + regionId: options.currentRegionId, + before: options.before, + after: options.after, + windowRadius: options.windowRadius, + loadingRange: options.loadingRange, + nearest: options.nearest, + targetState: options.targetState, + label: options.reasonLabels?.current ?? "current", + }]; +} + +function validateChunkTreeNode( + chunk: PolyWorldChunkTreeNode, + chunkIds: ReadonlySet, + topology: PolyWorldTopology | undefined, + diagnostics: PolyWorldChunkTreeDiagnostic[], +): void { + if (chunk.regionId !== undefined) { + validateChunkTreeId("chunk", chunk.regionId, "regionId", diagnostics, chunk.id); + if (topology !== undefined && !topology.regionsById.has(chunk.regionId)) { + diagnostics.push({ + code: "poly-world-missing-chunk-region", + message: `PolyWorld chunk "${chunk.id}" references missing region "${chunk.regionId}".`, + id: chunk.id, + field: "regionId", + }); + } + } + if (chunk.parentId !== undefined) { + validateChunkTreeId("chunk", chunk.parentId, "parentId", diagnostics, chunk.id); + if (chunk.parentId === chunk.id) { + diagnostics.push({ + code: "poly-world-self-chunk-parent", + message: `PolyWorld chunk "${chunk.id}" cannot be its own parent.`, + id: chunk.id, + field: "parentId", + }); + } else if (!chunkIds.has(chunk.parentId)) { + diagnostics.push({ + code: "poly-world-missing-chunk-parent", + message: `PolyWorld chunk "${chunk.id}" references missing parent chunk "${chunk.parentId}".`, + id: chunk.id, + field: "parentId", + }); + } + } + for (const childId of chunk.childIds ?? []) { + validateChunkTreeId("chunk", childId, "childIds", diagnostics, chunk.id); + if (childId === chunk.id) { + diagnostics.push({ + code: "poly-world-self-chunk-child", + message: `PolyWorld chunk "${chunk.id}" cannot be its own child.`, + id: chunk.id, + field: "childIds", + }); + } else if (!chunkIds.has(childId)) { + diagnostics.push({ + code: "poly-world-missing-chunk-child", + message: `PolyWorld chunk "${chunk.id}" references missing child chunk "${childId}".`, + id: chunk.id, + field: "childIds", + }); + } + } + validateChunkTreeBounds(chunk, "bounds", chunk.bounds, diagnostics); + validateChunkTreeBounds(chunk, "contentBounds", chunk.contentBounds, diagnostics); + validateChunkTreeBounds(chunk, "viewerRequestBounds", chunk.viewerRequestBounds, diagnostics); + validateChunkTreeStringArray(chunk, "resourceIds", chunk.resourceIds, diagnostics); + validateChunkTreeStringArray(chunk, "tags", chunk.tags, diagnostics); + if (chunk.refinement !== undefined && chunk.refinement !== "replace" && chunk.refinement !== "add") { + diagnostics.push({ + code: "poly-world-invalid-chunk-refinement", + message: `PolyWorld chunk "${chunk.id}" has invalid refinement "${String(chunk.refinement)}".`, + id: chunk.id, + field: "refinement", + }); + } + validateFiniteNonNegative(chunk, "geometricError", chunk.geometricError, diagnostics); + validateFiniteNonNegative(chunk, "cost", chunk.cost, diagnostics); + validateFiniteNumber(chunk, "priority", chunk.priority, diagnostics); +} + +function validateChunkTreeAvailability( + chunks: readonly PolyWorldChunkTreeNode[], + diagnostics: PolyWorldChunkTreeDiagnostic[], +): void { + const chunksById = new Map(chunks.map((chunk) => [chunk.id, chunk])); + for (const chunk of chunks) { + if (chunk.available === false && chunk.contentAvailable === true) { + diagnostics.push({ + code: "poly-world-unavailable-chunk-content", + message: `PolyWorld chunk "${chunk.id}" cannot have content when it is unavailable.`, + id: chunk.id, + field: "contentAvailable", + }); + } + const parent = chunk.parentId === undefined ? undefined : chunksById.get(chunk.parentId); + if (chunk.available !== false && parent?.available === false) { + diagnostics.push({ + code: "poly-world-unavailable-chunk-parent", + message: `PolyWorld chunk "${chunk.id}" cannot be available when parent chunk "${parent.id}" is unavailable.`, + id: chunk.id, + field: "available", + }); + } + for (const childId of chunk.childIds ?? []) { + const child = chunksById.get(childId); + if (chunk.available === false && child !== undefined && child.available !== false) { + diagnostics.push({ + code: "poly-world-unavailable-chunk-parent", + message: `PolyWorld chunk "${child.id}" cannot be available when parent chunk "${chunk.id}" is unavailable.`, + id: child.id, + field: "available", + }); + } + } + } +} + +function validateChunkTreeCycles( + chunks: readonly PolyWorldChunkTreeNode[], + diagnostics: PolyWorldChunkTreeDiagnostic[], +): void { + const chunksById = new Map(chunks.map((chunk) => [chunk.id, chunk])); + for (const chunk of chunks) { + const path: string[] = []; + let current: PolyWorldChunkTreeNode | undefined = chunk; + while (current !== undefined) { + if (path.includes(current.id)) { + const cycle = [...path.slice(path.indexOf(current.id)), current.id]; + diagnostics.push({ + code: "poly-world-chunk-tree-cycle", + message: `PolyWorld chunk "${chunk.id}" has a parent cycle: ${cycle.join(" -> ")}.`, + id: chunk.id, + field: "parentId", + }); + break; + } + path.push(current.id); + current = current.parentId === undefined ? undefined : chunksById.get(current.parentId); + } + } +} + +function validateChunkTreeId( + kind: string, + value: string, + field: string, + diagnostics: PolyWorldChunkTreeDiagnostic[], + id?: string, +): void { + if (typeof value !== "string" || value.length === 0) { + diagnostics.push({ + code: "poly-world-empty-chunk-id", + message: `PolyWorld ${kind} requires a non-empty ${field}.`, + id, + field, + }); + } +} + +function validateChunkTreeBounds( + chunk: PolyWorldChunkTreeNode, + field: "bounds" | "contentBounds" | "viewerRequestBounds", + bounds: PolyWorldBounds | undefined, + diagnostics: PolyWorldChunkTreeDiagnostic[], +): void { + if (bounds === undefined) return; + validateChunkTreeVec3(chunk, `${field}.min`, bounds.min, diagnostics); + validateChunkTreeVec3(chunk, `${field}.max`, bounds.max, diagnostics); + if (!isFiniteChunkTreeVec3(bounds.min) || !isFiniteChunkTreeVec3(bounds.max)) return; + for (let axis = 0; axis < 3; axis += 1) { + if (bounds.min[axis] <= bounds.max[axis]) continue; + diagnostics.push({ + code: "poly-world-invalid-chunk-bounds", + message: `PolyWorld chunk "${chunk.id}" has ${field}.min greater than ${field}.max.`, + id: chunk.id, + field, + }); + break; + } +} + +function validateChunkTreeVec3( + chunk: PolyWorldChunkTreeNode, + field: string, + value: readonly number[], + diagnostics: PolyWorldChunkTreeDiagnostic[], +): void { + if (!isFiniteChunkTreeVec3(value)) { + diagnostics.push({ + code: "poly-world-invalid-chunk-vec3", + message: `PolyWorld chunk "${chunk.id}" has invalid ${field}.`, + id: chunk.id, + field, + }); + } +} + +function validateChunkTreeStringArray( + chunk: PolyWorldChunkTreeNode, + field: string, + values: readonly string[] | undefined, + diagnostics: PolyWorldChunkTreeDiagnostic[], +): void { + if (values === undefined) return; + if (values.length === 0) { + diagnostics.push({ + code: "poly-world-empty-chunk-array", + message: `PolyWorld chunk "${chunk.id}" has empty ${field}.`, + id: chunk.id, + field, + }); + return; + } + for (const value of values) { + if (typeof value === "string" && value.length > 0) continue; + diagnostics.push({ + code: "poly-world-empty-chunk-array-value", + message: `PolyWorld chunk "${chunk.id}" has an empty value in ${field}.`, + id: chunk.id, + field, + }); + } +} + +function validateFiniteNonNegative( + chunk: PolyWorldChunkTreeNode, + field: "geometricError" | "cost", + value: number | undefined, + diagnostics: PolyWorldChunkTreeDiagnostic[], +): void { + if (value === undefined) return; + if (Number.isFinite(value) && value >= 0) return; + diagnostics.push({ + code: "poly-world-invalid-chunk-number", + message: `PolyWorld chunk "${chunk.id}" ${field} must be a finite non-negative number.`, + id: chunk.id, + field, + }); +} + +function validateFiniteNumber( + chunk: PolyWorldChunkTreeNode, + field: "priority", + value: number | undefined, + diagnostics: PolyWorldChunkTreeDiagnostic[], +): void { + if (value === undefined || Number.isFinite(value)) return; + diagnostics.push({ + code: "poly-world-invalid-chunk-number", + message: `PolyWorld chunk "${chunk.id}" ${field} must be finite.`, + id: chunk.id, + field, + }); +} + +function normalizeChunkTreeNode(chunk: PolyWorldChunkTreeNode): PolyWorldChunkTreeNode { + return { + ...chunk, + bounds: chunk.bounds === undefined ? undefined : { + min: [...chunk.bounds.min] as Vec3, + max: [...chunk.bounds.max] as Vec3, + }, + contentBounds: chunk.contentBounds === undefined ? undefined : { + min: [...chunk.contentBounds.min] as Vec3, + max: [...chunk.contentBounds.max] as Vec3, + }, + viewerRequestBounds: chunk.viewerRequestBounds === undefined ? undefined : { + min: [...chunk.viewerRequestBounds.min] as Vec3, + max: [...chunk.viewerRequestBounds.max] as Vec3, + }, + childIds: chunk.childIds === undefined ? undefined : unique(chunk.childIds), + resourceIds: chunk.resourceIds === undefined ? undefined : unique(chunk.resourceIds), + tags: chunk.tags === undefined ? undefined : unique(chunk.tags), + }; +} + +function resolveChunkTreeMaxDepth(tree: PolyWorldChunkTree): number { + let maxDepth = 0; + const queue = tree.rootChunkIds.map((chunkId) => ({ chunkId, depth: 0 })); + const visited = new Set(); + while (queue.length > 0) { + const item = queue.shift(); + if (item === undefined || visited.has(item.chunkId)) continue; + visited.add(item.chunkId); + maxDepth = Math.max(maxDepth, item.depth); + for (const childId of tree.childIdsById.get(item.chunkId) ?? []) { + queue.push({ chunkId: childId, depth: item.depth + 1 }); + } + } + return maxDepth; +} + +function isFiniteChunkTreeVec3(value: readonly number[]): boolean { + return value.length === 3 && value.every((coordinate) => Number.isFinite(coordinate)); +} + +function pushUniqueMap(map: Map, key: string, value: T): void { + const values = map.get(key); + if (values === undefined) { + map.set(key, [value]); + return; + } + if (!values.includes(value)) values.push(value); +} + +function resolveStreamingSourceRegionId( + topology: PolyWorldTopology, + source: PolyWorldChunkStreamingSource, + options: PolyWorldChunkStreamingSelectionOptions, +): string | undefined { + if (source.regionId !== undefined) { + return topology.regionsById.has(source.regionId) ? source.regionId : undefined; + } + const point = source.point ?? source.position; + if (point === undefined) return undefined; + return resolvePolyWorldRegionByPoint(topology, point, { + nearest: source.nearest ?? options.nearest, + })?.regionId; +} + +function selectedRegionsForStreamingSource( + topology: PolyWorldTopology, + source: PolyWorldChunkStreamingSource, + currentRegionId: string, + defaults: { + orderedRegionIds: readonly string[]; + before: number; + after: number; + loadingRange?: number; + chunkGraph?: PolyWorldChunkGraph; + chunkGraphExpansion?: false | PolyWorldChunkGraphExpansionOptions; + }, +): { + regionIds: string[]; + graphRegionIds: string[]; + graphTargetState?: PolyWorldChunkTargetState; + missingRegionIds: string[]; +} { + const orderedRegionIds = source.orderedRegionIds ?? defaults.orderedRegionIds; + const before = source.windowRadius ?? source.before ?? defaults.before; + const after = source.windowRadius ?? source.after ?? defaults.after; + const selectedRegionIds = windowAround(orderedRegionIds, currentRegionId, before, after); + if (selectedRegionIds.length === 0) add(selectedRegionIds, currentRegionId); + + const currentRegion = topology.regionsById.get(currentRegionId); + const point = source.point ?? source.position ?? currentRegion?.center ?? centerFromBounds(currentRegion?.bounds); + const loadingRange = source.loadingRange ?? defaults.loadingRange; + if (point !== undefined && loadingRange !== undefined) { + for (const regionId of regionsWithinRange(topology, point, loadingRange)) add(selectedRegionIds, regionId); + } + + const graphExpansion = source.chunkGraphExpansion ?? defaults.chunkGraphExpansion; + const graph = graphExpansion === false ? undefined : defaults.chunkGraph; + if (graph === undefined || graphExpansion === undefined || graphExpansion === false) { + return { regionIds: selectedRegionIds, graphRegionIds: [], missingRegionIds: [] }; + } + + const graphSelection = expandChunkGraphRegionIds(topology, selectedRegionIds, graph, graphExpansion); + for (const regionId of graphSelection.regionIds) add(selectedRegionIds, regionId); + return { + regionIds: selectedRegionIds, + graphRegionIds: graphSelection.regionIds, + ...(graphExpansion.targetState === undefined ? {} : { graphTargetState: graphExpansion.targetState }), + missingRegionIds: graphSelection.missingRegionIds, + }; +} + +function expandChunkGraphRegionIds( + topology: PolyWorldTopology, + seedRegionIds: readonly string[], + graph: PolyWorldChunkGraph, + options: PolyWorldChunkGraphExpansionOptions, +): { regionIds: string[]; missingRegionIds: string[] } { + const regionIds: string[] = []; + const missingRegionIds: string[] = []; + const visited = new Set(seedRegionIds); + const queue = seedRegionIds.map((regionId) => ({ regionId, depth: 0 })); + + while (queue.length > 0) { + const item = queue.shift(); + if (item === undefined) break; + if (!options.recursive && item.depth > 0) continue; + + const nextRegionIds: string[] = []; + if (options.includeParents === true) { + for (const regionId of resolveChunkParentRegionIds(graph, item.regionId)) add(nextRegionIds, regionId); + } + if (options.includeChildren === true) { + for (const regionId of resolveChunkChildRegionIds(graph, item.regionId)) add(nextRegionIds, regionId); + } + if (options.includeRelated === true) { + for (const regionId of graph.relatedRegionIds?.[item.regionId] ?? []) add(nextRegionIds, regionId); + } + + for (const regionId of nextRegionIds) { + if (!topology.regionsById.has(regionId)) { + add(missingRegionIds, regionId); + continue; + } + if (visited.has(regionId)) continue; + visited.add(regionId); + add(regionIds, regionId); + if (options.recursive === true) queue.push({ regionId, depth: item.depth + 1 }); + } + } + + return { regionIds, missingRegionIds }; +} + +function resolveChunkParentRegionIds(graph: PolyWorldChunkGraph, regionId: string): string[] { + const regionIds: string[] = []; + for (const parentRegionId of normalizeChunkGraphIds(graph.parentRegionIds?.[regionId])) add(regionIds, parentRegionId); + for (const [parentRegionId, childRegionIds] of Object.entries(graph.childRegionIds ?? {})) { + if (childRegionIds.includes(regionId)) add(regionIds, parentRegionId); + } + return regionIds; +} + +function resolveChunkChildRegionIds(graph: PolyWorldChunkGraph, regionId: string): string[] { + const regionIds: string[] = []; + for (const childRegionId of graph.childRegionIds?.[regionId] ?? []) add(regionIds, childRegionId); + for (const [childRegionId, parentRegionIds] of Object.entries(graph.parentRegionIds ?? {})) { + if (normalizeChunkGraphIds(parentRegionIds).includes(regionId)) add(regionIds, childRegionId); + } + return regionIds; +} + +function normalizeChunkGraphIds(value: string | readonly string[] | undefined): readonly string[] { + if (value === undefined) return []; + return typeof value === "string" ? [value] : value; +} + +function regionsWithinRange( + topology: PolyWorldTopology, + point: Vec3, + range: number, +): string[] { + const maxDistanceSq = Math.max(0, range) * Math.max(0, range); + const regionIds: string[] = []; + for (const region of topology.regions) { + const center = region.center ?? centerFromBounds(region.bounds); + if (center === undefined) continue; + if (distanceSq(point, center) <= maxDistanceSq) regionIds.push(region.id); + } + return regionIds; +} + +function addTargetState( + regionId: string, + targetState: PolyWorldChunkTargetState, + sets: { + loadedRegionIds: Set; + residentRegionIds: Set; + activeRegionIds: Set; + renderedRegionIds: Set; + preloadedRegionIds: Set; + }, +): void { + if (targetState === "preloaded") { + sets.preloadedRegionIds.add(regionId); + return; + } + sets.loadedRegionIds.add(regionId); + if (targetState === "resident" || targetState === "active" || targetState === "rendered") { + sets.residentRegionIds.add(regionId); + } + if (targetState === "active" || targetState === "rendered") { + sets.activeRegionIds.add(regionId); + } + if (targetState === "rendered") { + sets.renderedRegionIds.add(regionId); + } +} + +function windowAround( + orderedRegionIds: readonly string[], + currentRegionId: string, + before: number, + after: number, +): string[] { + const currentIndex = orderedRegionIds.indexOf(currentRegionId); + if (currentIndex === -1) return []; + const start = Math.max(0, currentIndex - Math.max(0, before)); + const end = Math.min(orderedRegionIds.length - 1, currentIndex + Math.max(0, after)); + return orderedRegionIds.slice(start, end + 1); +} + +function add(values: string[], value: string): void { + if (!values.includes(value)) values.push(value); +} + +function unique(values: readonly string[] | undefined): string[] { + return [...new Set(values ?? [])]; +} + +function uniqueSorted(values: readonly string[]): string[] { + return [...new Set(values)].sort(compareStrings); +} + +function compareSourceSummaries( + a: PolyWorldChunkStreamingSourceSummary, + b: PolyWorldChunkStreamingSourceSummary, +): number { + return b.priority - a.priority || compareStrings(a.sourceId, b.sourceId); +} + +function compareStreamingSources( + a: PolyWorldChunkStreamingSource, + b: PolyWorldChunkStreamingSource, +): number { + return (b.priority ?? 0) - (a.priority ?? 0) || compareStrings(a.id, b.id); +} + +function compareStrings(a: string, b: string): number { + if (a < b) return -1; + if (a > b) return 1; + return 0; +} + +function distanceSq(a: Vec3, b: Vec3): number { + const dx = a[0] - b[0]; + const dy = a[1] - b[1]; + const dz = a[2] - b[2]; + return dx * dx + dy * dy + dz * dz; +} + +function boundsContainsPoint(bounds: PolyWorldBounds, point: Vec3): boolean { + return point.every((coordinate, axis) => + coordinate >= bounds.min[axis] && coordinate <= bounds.max[axis] + ); +} + +function boundsVolume(bounds: PolyWorldBounds | undefined): number { + if (bounds === undefined) return Number.POSITIVE_INFINITY; + return Math.max(0, bounds.max[0] - bounds.min[0]) * + Math.max(0, bounds.max[1] - bounds.min[1]) * + Math.max(0, bounds.max[2] - bounds.min[2]); +} + +function centerFromBounds(bounds: PolyWorldBounds | undefined): Vec3 | undefined { + if (bounds === undefined) return undefined; + return [ + (bounds.min[0] + bounds.max[0]) / 2, + (bounds.min[1] + bounds.max[1]) / 2, + (bounds.min[2] + bounds.max[2]) / 2, + ]; +} + +function boundsCorners(bounds: PolyWorldBounds): Vec3[] { + return [ + [bounds.min[0], bounds.min[1], bounds.min[2]], + [bounds.max[0], bounds.min[1], bounds.min[2]], + [bounds.min[0], bounds.max[1], bounds.min[2]], + [bounds.max[0], bounds.max[1], bounds.min[2]], + [bounds.min[0], bounds.min[1], bounds.max[2]], + [bounds.max[0], bounds.min[1], bounds.max[2]], + [bounds.min[0], bounds.max[1], bounds.max[2]], + [bounds.max[0], bounds.max[1], bounds.max[2]], + ]; +} + +function addVec3(a: Vec3, b: Vec3): Vec3 { + return [a[0] + b[0], a[1] + b[1], a[2] + b[2]]; +} + +function scaleVec3(value: Vec3, scale: number): Vec3 { + return [value[0] * scale, value[1] * scale, value[2] * scale]; +} + +function dotVec3(a: Vec3, b: Vec3): number { + return a[0] * b[0] + a[1] * b[1] + a[2] * b[2]; +} + +function crossVec3(a: Vec3, b: Vec3): Vec3 { + return [ + a[1] * b[2] - a[2] * b[1], + a[2] * b[0] - a[0] * b[2], + a[0] * b[1] - a[1] * b[0], + ]; +} + +function normalizeVec3(value: readonly number[] | undefined): Vec3 | undefined { + if (value === undefined || !isFiniteChunkTreeVec3(value)) return undefined; + const length = Math.hypot(value[0], value[1], value[2]); + if (length <= 0.000001) return undefined; + return [value[0] / length, value[1] / length, value[2] / length]; +} + +function normalizeNonNegativeInteger(value: number | undefined): number | undefined { + if (value === undefined || !Number.isFinite(value)) return undefined; + return Math.max(0, Math.floor(value)); +} + +function normalizeNonNegativeNumber(value: number | undefined): number | undefined { + if (value === undefined || !Number.isFinite(value)) return undefined; + return Math.max(0, value); +} + +function normalizePositiveNumber(value: number | undefined): number | undefined { + if (value === undefined || !Number.isFinite(value) || value <= 0) return undefined; + return value; +} + +function optionalNonNegativeInteger(key: string, value: number | undefined): Record { + const normalized = normalizeNonNegativeInteger(value); + return normalized === undefined ? {} : { [key]: normalized }; +} + +function optionalNonNegativeNumber(key: string, value: number | undefined): Record { + const normalized = normalizeNonNegativeNumber(value); + return normalized === undefined ? {} : { [key]: normalized }; +} diff --git a/packages/world/src/profiles/chunkFrame.ts b/packages/world/src/profiles/chunkFrame.ts new file mode 100644 index 000000000..b480402bd --- /dev/null +++ b/packages/world/src/profiles/chunkFrame.ts @@ -0,0 +1,183 @@ +import type { PolyWorldChunkStreamingDebugSnapshot, PolyWorldChunkStreamingDebugSnapshotOptions } from "../debug"; +import { + createPolyWorldChunkStreamingArtifactProof, + createPolyWorldChunkStreamingDebugSnapshot, +} from "../debug/chunkSnapshot"; +import type { + PolyWorldLayerPlanPolicy, + PolyWorldTransition, + PolyWorldTransitionDebugOptions, + PolyWorldTransitionReadinessOptions, + PolyWorldTransitionStateOptions, +} from "../planner"; +import { planPolyWorldTransition } from "../planner"; +import type { PolyWorldState } from "../state"; +import type { PolyWorldSelectionElementRelationExpansionOptions, PolyWorldTopology } from "../topology"; +import type { PolyWorldProfileArtifactProof } from "./artifact"; +import { + createPolyWorldProfileFrameSummary, + type PolyWorldProfileFrameSummary, +} from "./frameSummary"; +import type { + PolyWorldChunkStreamingSelection, + PolyWorldChunkStreamingSelectionOptions, + PolyWorldChunkStreamingStateName, + PolyWorldChunkStreamingStateSelectionOptions, +} from "./chunk"; +import { + selectPolyWorldChunkStreaming, + selectPolyWorldChunkStreamingState, +} from "./chunk"; + +export type PolyWorldChunkStreamingFrameStateOptions = PolyWorldTransitionStateOptions; + +export type PolyWorldChunkStreamingFramePlanDebugOptions = PolyWorldTransitionDebugOptions; + +export type PolyWorldChunkStreamingFrameDebugOptions = PolyWorldChunkStreamingDebugSnapshotOptions; + +export interface PolyWorldChunkStreamingFrameOptions extends PolyWorldChunkStreamingSelectionOptions { + previousState: PolyWorldState; + policies: readonly PolyWorldLayerPlanPolicy[]; + renderState?: PolyWorldChunkStreamingStateName; + renderSelection?: PolyWorldChunkStreamingStateSelectionOptions; + relations?: false | PolyWorldSelectionElementRelationExpansionOptions; + readiness?: PolyWorldTransitionReadinessOptions; + state?: PolyWorldChunkStreamingFrameStateOptions; + planDebug?: false | PolyWorldChunkStreamingFramePlanDebugOptions; + debug?: false | PolyWorldChunkStreamingFrameDebugOptions; +} + +export interface PolyWorldChunkStreamingFrameSets { + currentChunkId?: string; + selectedChunkIds: readonly string[]; + renderedChunkIds: readonly string[]; + loadedChunkIds: readonly string[]; + residentChunkIds: readonly string[]; + requestedChunkIds: readonly string[]; + heldChunkIds: readonly string[]; + unavailableChunkIds: readonly string[]; + viewCulledChunkIds: readonly string[]; + outsideRequestVolumeChunkIds: readonly string[]; + skippedChunkIds: readonly string[]; + budgetClippedChunkIds: readonly string[]; + selectedRegionIds: readonly string[]; + renderedRegionIds: readonly string[]; + loadedRegionIds: readonly string[]; + residentRegionIds: readonly string[]; + requestedRegionIds: readonly string[]; + plannedElementIds: readonly string[]; +} + +export interface PolyWorldChunkStreamingFrame extends PolyWorldTransition { + artifact: PolyWorldProfileArtifactProof; + streamingSelection: PolyWorldChunkStreamingSelection; + streamingSets: PolyWorldChunkStreamingFrameSets; + frameSummary: PolyWorldProfileFrameSummary; + chunkDebug?: PolyWorldChunkStreamingDebugSnapshot; +} + +export function planPolyWorldChunkStreamingFrame( + topology: PolyWorldTopology, + options: PolyWorldChunkStreamingFrameOptions, +): PolyWorldChunkStreamingFrame { + const streamingSelection = selectPolyWorldChunkStreaming(topology, options); + const selection = selectPolyWorldChunkStreamingState( + topology, + streamingSelection, + options.renderState ?? "rendered", + options.renderSelection, + ); + const transition = planPolyWorldTransition(topology, { + previousState: options.previousState, + policies: options.policies, + selection, + relations: options.relations, + readiness: options.readiness, + state: options.state, + debug: options.planDebug, + }); + const artifact = createPolyWorldChunkStreamingArtifactProof(streamingSelection); + const streamingSets = createChunkStreamingFrameSets(streamingSelection, transition); + + return { + artifact, + streamingSelection, + streamingSets, + frameSummary: createPolyWorldProfileFrameSummary({ + artifact, + transition, + current: { + chunkIds: streamingSets.currentChunkId === undefined ? [] : [streamingSets.currentChunkId], + }, + candidate: { + chunkIds: streamingSets.selectedChunkIds, + regionIds: streamingSets.selectedRegionIds, + }, + broad: { + chunkIds: streamingSets.loadedChunkIds, + regionIds: streamingSets.loadedRegionIds, + }, + view: { + chunkIds: streamingSets.renderedChunkIds, + regionIds: streamingSets.renderedRegionIds, + }, + retained: { + chunkIds: streamingSets.heldChunkIds, + regionIds: streamingSets.residentRegionIds, + }, + rejected: { + chunkIds: uniqueStrings([ + ...streamingSets.unavailableChunkIds, + ...streamingSets.viewCulledChunkIds, + ...streamingSets.outsideRequestVolumeChunkIds, + ...streamingSets.skippedChunkIds, + ...streamingSets.budgetClippedChunkIds, + ]), + reasonCounts: { + unavailable: streamingSets.unavailableChunkIds.length, + "view-culled": streamingSets.viewCulledChunkIds.length, + "outside-request-volume": streamingSets.outsideRequestVolumeChunkIds.length, + skipped: streamingSets.skippedChunkIds.length, + "budget-clipped": streamingSets.budgetClippedChunkIds.length, + }, + }, + }), + ...(options.debug === false ? {} : { + chunkDebug: createPolyWorldChunkStreamingDebugSnapshot(streamingSelection, options.debug), + }), + ...transition, + }; +} + +function createChunkStreamingFrameSets( + streamingSelection: PolyWorldChunkStreamingSelection, + transition: PolyWorldTransition, +): PolyWorldChunkStreamingFrameSets { + const traversal = streamingSelection.streaming.chunkTraversal; + return { + ...(traversal?.currentChunkId === undefined ? {} : { currentChunkId: traversal.currentChunkId }), + selectedChunkIds: [...(traversal?.selectedChunkIds ?? [])], + renderedChunkIds: [...(traversal?.renderedChunkIds ?? [])], + loadedChunkIds: [...(traversal?.loadedChunkIds ?? [])], + residentChunkIds: [...(traversal?.residentChunkIds ?? [])], + requestedChunkIds: [...(traversal?.requestedChunkIds ?? [])], + heldChunkIds: [...(traversal?.heldChunkIds ?? [])], + unavailableChunkIds: [...(traversal?.unavailableChunkIds ?? [])], + viewCulledChunkIds: [...(traversal?.viewCulledChunkIds ?? [])], + outsideRequestVolumeChunkIds: [...(traversal?.outsideRequestVolumeChunkIds ?? [])], + skippedChunkIds: [...(traversal?.skippedChunkIds ?? [])], + budgetClippedChunkIds: [...(traversal?.budgetClippedChunkIds ?? [])], + selectedRegionIds: [...(traversal?.selectedRegionIds ?? streamingSelection.regionIds ?? [])], + renderedRegionIds: [...(traversal?.renderedRegionIds ?? streamingSelection.streaming.renderedRegionIds)], + loadedRegionIds: [...(traversal?.loadedRegionIds ?? streamingSelection.streaming.loadedRegionIds)], + residentRegionIds: [...(traversal?.residentRegionIds ?? streamingSelection.streaming.residentRegionIds)], + requestedRegionIds: [...(traversal?.requestedRegionIds ?? streamingSelection.streaming.requestedRegionIds)], + plannedElementIds: uniqueStrings(transition.plan.entries.flatMap((entry) => + entry.elementId === undefined ? [] : [entry.elementId] + )), + }; +} + +function uniqueStrings(values: readonly string[]): string[] { + return [...new Set(values)]; +} diff --git a/packages/world/src/profiles/frameSummary.ts b/packages/world/src/profiles/frameSummary.ts new file mode 100644 index 000000000..a4c1fe177 --- /dev/null +++ b/packages/world/src/profiles/frameSummary.ts @@ -0,0 +1,284 @@ +import type { + PolyWorldLayerPlan, + PolyWorldPlanActionCounts, + PolyWorldTransition, +} from "../planner"; +import type { + PolyWorldResourceLoadSetSummary, + PolyWorldResourceReadinessSummary, +} from "../planner/resources"; +import type { + PolyWorldState, + PolyWorldStateDiff, +} from "../state"; +import type { PolyWorldSelection } from "../topology"; +import type { + PolyWorldProfileArtifactKind, + PolyWorldProfileArtifactProfile, + PolyWorldProfileArtifactProof, +} from "./artifact"; + +export type PolyWorldProfileFrameSummaryProfile = PolyWorldProfileArtifactProfile; + +export interface PolyWorldProfileFrameSummarySet { + leafIds: readonly string[]; + regionIds: readonly string[]; + linkIds: readonly string[]; + portalIds: readonly string[]; + chunkIds: readonly string[]; + surfaceIds: readonly string[]; + elementIds: readonly string[]; + selectionKeys: readonly string[]; + reasonCounts?: Readonly>; +} + +export interface PolyWorldProfileFrameSummarySetInput { + leafIds?: readonly string[]; + regionIds?: readonly string[]; + linkIds?: readonly string[]; + portalIds?: readonly string[]; + chunkIds?: readonly string[]; + surfaceIds?: readonly string[]; + elementIds?: readonly string[]; + selectionKeys?: readonly string[]; + reasonCounts?: Readonly>; +} + +export interface PolyWorldProfileFrameSummaryReadiness { + resourceIds: readonly string[]; + readyResourceIds: readonly string[]; + missingResourceIds: readonly string[]; + requestedResourceIds: readonly string[]; + loadingResourceIds: readonly string[]; + failedResourceIds: readonly string[]; + staleResourceIds: readonly string[]; + renderBlockingResourceIds: readonly string[]; + preloadOnlyResourceIds: readonly string[]; + nonBlockingResourceIds: readonly string[]; + blockedResourceIds: readonly string[]; + blockedElementIds: readonly string[]; +} + +export interface PolyWorldProfileFrameSummaryLoadSet { + previousResourceIds: readonly string[]; + nextResourceIds: readonly string[]; + requestResourceIds: readonly string[]; + retainResourceIds: readonly string[]; + releaseCandidateResourceIds: readonly string[]; + readyButNotVisibleResourceIds: readonly string[]; + preloadOnlyResourceIds: readonly string[]; + renderBlockingResourceIds: readonly string[]; + staleAllowedResourceIds: readonly string[]; + nonBlockingResourceIds: readonly string[]; + blockedResourceIds: readonly string[]; + blockedElementIds: readonly string[]; +} + +export interface PolyWorldProfileFrameSummaryState { + selectedRegionIds: readonly string[]; + selectedLinkIds: readonly string[]; + selectedSelectionKeys: readonly string[]; + selectedElementIds: readonly string[]; + resolvedElementIds: readonly string[]; + layers: readonly string[]; + reasonLabels: readonly string[]; +} + +export interface PolyWorldProfileFrameSummaryDiff { + changed: boolean; + addedRegionIds: readonly string[]; + removedRegionIds: readonly string[]; + retainedRegionIds: readonly string[]; + addedElementIds: readonly string[]; + removedElementIds: readonly string[]; + retainedElementIds: readonly string[]; +} + +export interface PolyWorldProfileFrameSummaryPlan { + changed: boolean; + entryCount: number; + plannedElementIds: readonly string[]; + blockedElementIds: readonly string[]; + actionCounts: PolyWorldPlanActionCounts; +} + +export interface PolyWorldProfileFrameSummary { + schemaVersion: 1; + profile: PolyWorldProfileFrameSummaryProfile; + artifactKind: PolyWorldProfileArtifactKind; + producedBy: string; + current: PolyWorldProfileFrameSummarySet; + candidate: PolyWorldProfileFrameSummarySet; + broad: PolyWorldProfileFrameSummarySet; + view: PolyWorldProfileFrameSummarySet; + retained: PolyWorldProfileFrameSummarySet; + rejected: PolyWorldProfileFrameSummarySet; + readiness?: PolyWorldProfileFrameSummaryReadiness; + loadSet?: PolyWorldProfileFrameSummaryLoadSet; + planning: PolyWorldProfileFrameSummarySet; + state: PolyWorldProfileFrameSummaryState; + diff: PolyWorldProfileFrameSummaryDiff; + plan: PolyWorldProfileFrameSummaryPlan; +} + +export interface PolyWorldProfileFrameSummaryInput { + artifact: PolyWorldProfileArtifactProof; + transition: PolyWorldTransition; + current?: PolyWorldProfileFrameSummarySetInput; + candidate?: PolyWorldProfileFrameSummarySetInput; + broad?: PolyWorldProfileFrameSummarySetInput; + view?: PolyWorldProfileFrameSummarySetInput; + retained?: PolyWorldProfileFrameSummarySetInput; + rejected?: PolyWorldProfileFrameSummarySetInput; +} + +export function createPolyWorldProfileFrameSummary( + input: PolyWorldProfileFrameSummaryInput, +): PolyWorldProfileFrameSummary { + return { + schemaVersion: 1, + profile: input.artifact.profile, + artifactKind: input.artifact.artifactKind, + producedBy: input.artifact.producedBy, + current: createSummarySet(input.current), + candidate: createSummarySet(input.candidate), + broad: createSummarySet(input.broad), + view: createSummarySet(input.view), + retained: createSummarySet(input.retained), + rejected: createSummarySet(input.rejected), + ...(input.transition.readiness === undefined ? {} : { + readiness: summarizeReadiness(input.transition.readiness), + }), + ...(input.transition.loadSet === undefined ? {} : { + loadSet: summarizeLoadSet(input.transition.loadSet), + }), + planning: createSummarySet(setFromSelection(input.transition.planningSelection)), + state: summarizeState(input.transition.nextState), + diff: summarizeDiff(input.transition.diff), + plan: summarizePlan(input.transition.plan), + }; +} + +function createSummarySet( + input: PolyWorldProfileFrameSummarySetInput | undefined, +): PolyWorldProfileFrameSummarySet { + return { + leafIds: uniqueStrings(input?.leafIds ?? []), + regionIds: uniqueStrings(input?.regionIds ?? []), + linkIds: uniqueStrings(input?.linkIds ?? []), + portalIds: uniqueStrings(input?.portalIds ?? []), + chunkIds: uniqueStrings(input?.chunkIds ?? []), + surfaceIds: uniqueStrings(input?.surfaceIds ?? []), + elementIds: uniqueStrings(input?.elementIds ?? []), + selectionKeys: uniqueStrings(input?.selectionKeys ?? []), + ...finiteReasonCounts(input?.reasonCounts), + }; +} + +function setFromSelection( + selection: PolyWorldSelection | undefined, +): PolyWorldProfileFrameSummarySetInput { + return { + regionIds: selection?.regionIds, + linkIds: selection?.linkIds, + elementIds: selection?.elementIds, + selectionKeys: selection?.selectionKeys, + }; +} + +function summarizeReadiness( + readiness: PolyWorldResourceReadinessSummary, +): PolyWorldProfileFrameSummaryReadiness { + return { + resourceIds: [...readiness.resourceIds], + readyResourceIds: [...readiness.readyResourceIds], + missingResourceIds: [...readiness.missingResourceIds], + requestedResourceIds: [...readiness.requestedResourceIds], + loadingResourceIds: [...readiness.loadingResourceIds], + failedResourceIds: [...readiness.failedResourceIds], + staleResourceIds: [...readiness.staleResourceIds], + renderBlockingResourceIds: [...readiness.renderBlockingResourceIds], + preloadOnlyResourceIds: [...readiness.preloadOnlyResourceIds], + nonBlockingResourceIds: [...readiness.nonBlockingResourceIds], + blockedResourceIds: [...readiness.blockedResourceIds], + blockedElementIds: [...readiness.blockedElementIds], + }; +} + +function summarizeLoadSet( + loadSet: PolyWorldResourceLoadSetSummary, +): PolyWorldProfileFrameSummaryLoadSet { + return { + previousResourceIds: [...loadSet.previousResourceIds], + nextResourceIds: [...loadSet.nextResourceIds], + requestResourceIds: [...loadSet.requestResourceIds], + retainResourceIds: [...loadSet.retainResourceIds], + releaseCandidateResourceIds: [...loadSet.releaseCandidateResourceIds], + readyButNotVisibleResourceIds: [...loadSet.readyButNotVisibleResourceIds], + preloadOnlyResourceIds: [...loadSet.preloadOnlyResourceIds], + renderBlockingResourceIds: [...loadSet.renderBlockingResourceIds], + staleAllowedResourceIds: [...loadSet.staleAllowedResourceIds], + nonBlockingResourceIds: [...loadSet.nonBlockingResourceIds], + blockedResourceIds: [...loadSet.blockedResourceIds], + blockedElementIds: [...loadSet.blockedElementIds], + }; +} + +function summarizeState(state: PolyWorldState): PolyWorldProfileFrameSummaryState { + return { + selectedRegionIds: [...state.selectedRegionIds], + selectedLinkIds: [...state.selectedLinkIds], + selectedSelectionKeys: [...state.selectedSelectionKeys], + selectedElementIds: [...state.selectedElementIds], + resolvedElementIds: [...state.resolvedElementIds], + layers: [...state.layers], + reasonLabels: [...state.reasonLabels], + }; +} + +function summarizeDiff(diff: PolyWorldStateDiff): PolyWorldProfileFrameSummaryDiff { + return { + changed: diff.changed, + addedRegionIds: [...diff.regions.added], + removedRegionIds: [...diff.regions.removed], + retainedRegionIds: [...diff.regions.retained], + addedElementIds: [...diff.resolvedElements.added], + removedElementIds: [...diff.resolvedElements.removed], + retainedElementIds: [...diff.resolvedElements.retained], + }; +} + +function summarizePlan(plan: PolyWorldLayerPlan): PolyWorldProfileFrameSummaryPlan { + return { + changed: plan.changed, + entryCount: plan.entries.length, + plannedElementIds: uniqueStrings(plan.entries.flatMap((entry) => + entry.elementId === undefined ? [] : [entry.elementId] + )), + blockedElementIds: uniqueStrings(plan.entries.flatMap((entry) => + entry.blocked === true && entry.elementId !== undefined ? [entry.elementId] : [] + )), + actionCounts: { ...plan.actionCounts }, + }; +} + +function finiteReasonCounts( + reasonCounts: Readonly> | undefined, +): { reasonCounts?: Readonly> } { + if (reasonCounts === undefined) return {}; + const entries = Object.entries(reasonCounts) + .filter((entry): entry is [string, number] => Number.isFinite(entry[1])); + if (entries.length === 0) return {}; + entries.sort(([a], [b]) => compareStrings(a, b)); + return { reasonCounts: Object.fromEntries(entries) }; +} + +function uniqueStrings(values: readonly string[]): string[] { + return [...new Set(values)].sort(compareStrings); +} + +function compareStrings(a: string, b: string): number { + if (a < b) return -1; + if (a > b) return 1; + return 0; +} diff --git a/packages/world/src/profiles/index.ts b/packages/world/src/profiles/index.ts new file mode 100644 index 000000000..a59421039 --- /dev/null +++ b/packages/world/src/profiles/index.ts @@ -0,0 +1,245 @@ +export { + auditPolyWorldProfileArtifactProof, + createPolyWorldProfileArtifactBundle, + createPolyWorldProfileArtifactBundleEntry, + createPolyWorldProfileArtifactProof, +} from "./artifact"; +export { + resolvePolyWorldPortalActivity, + resolvePolyWorldRegionSelectionKeys, + selectPolyWorldPortalRegions, +} from "./portal"; +export { + resolvePolyWorldPortalFlow, +} from "./portalFlow"; +export { + planPolyWorldPortalFlowFrame, +} from "./portalFlowFrame"; +export { + planPolyWorldPortalFrame, +} from "./portalFrame"; +export { + bakePolyWorldBspPvs, + compilePolyWorldBsp, + createPolyWorldBspPvsIndex, + createPolyWorldBspTree, + decodePolyWorldBspPvsLeafIds, + decodePolyWorldBspPvsPortalIds, + PolyWorldBspError, + resolvePolyWorldBspBakedPvs, + resolvePolyWorldBspPvs, + resolvePolyWorldBspViewSurfaceElements, + resolvePolyWorldBspViewPvs, + resolvePolyWorldBspLeaf, + selectPolyWorldBspPvs, + selectPolyWorldBspViewPvs, + tracePolyWorldBspViewPvs, + validatePolyWorldBspTree, + validatePolyWorldBspCompileInput, +} from "./bsp"; +export { + planPolyWorldBspVisibilityFrame, + resolvePolyWorldBspVisibility, +} from "./bspVisibility"; +export { + certifyPolyWorldBspTopology, + summarizePolyWorldBspTopologyProof, +} from "./bspProof"; +export { + compilePolyWorldPolygonBsp, + validatePolyWorldPolygonBspInput, +} from "./polygonBsp"; +export { + compilePolyWorldBrushBsp, + validatePolyWorldBrushBspInput, +} from "./brushBsp"; +export { + createPolyWorldProfileFrameSummary, +} from "./frameSummary"; +export { + planPolyWorldChunkStreamingFrame, +} from "./chunkFrame"; +export type { + PolyWorldProfileArtifactBundle, + PolyWorldProfileArtifactBundleEntry, + PolyWorldProfileArtifactBundleEntryInput, + PolyWorldProfileArtifactBundleInput, + PolyWorldProfileArtifactBundleRef, + PolyWorldProfileArtifactDiagnostic, + PolyWorldProfileArtifactKind, + PolyWorldProfileArtifactProfile, + PolyWorldProfileArtifactProofAudit, + PolyWorldProfileArtifactProof, + PolyWorldProfileArtifactProofInput, + PolyWorldProfileArtifactSourceKind, +} from "./artifact"; +export type { + PolyWorldPortalActivityOptions, + PolyWorldPortalActivityState, + PolyWorldPortalActivityTargetState, + PolyWorldPortalReasonLabels, + PolyWorldPortalLinkState, + PolyWorldPortalLinkStateContext, + PolyWorldPortalLinkStateValue, + PolyWorldPortalSelectionOptions, + PolyWorldRegionSelectionKeys, + PolyWorldRegionSelectionKeysContext, +} from "./portal"; +export type { + PolyWorldPortalFlow, + PolyWorldPortalFlowOptions, + PolyWorldPortalFlowPortal, + PolyWorldPortalFlowTraceEntry, + PolyWorldPortalFlowTraceStatus, +} from "./portalFlow"; +export type { + PolyWorldPortalFlowFrame, + PolyWorldPortalFlowFrameDebugOptions, + PolyWorldPortalFlowFrameOptions, + PolyWorldPortalFlowFramePlanDebugOptions, + PolyWorldPortalFlowFramePlanRegionState, + PolyWorldPortalFlowFrameSets, + PolyWorldPortalFlowFrameStateOptions, +} from "./portalFlowFrame"; +export type { + PolyWorldPortalFrame, + PolyWorldPortalFrameDebugOptions, + PolyWorldPortalFrameOptions, + PolyWorldPortalFramePlanDebugOptions, + PolyWorldPortalFramePlanRegionState, + PolyWorldPortalFrameSets, + PolyWorldPortalFrameStateOptions, +} from "./portalFrame"; +export type { + PolyWorldBspChild, + PolyWorldBspCompileInput, + PolyWorldBspCompileOptions, + PolyWorldBspCompilePortal, + PolyWorldBspCompileRegion, + PolyWorldBspDiagnostic, + PolyWorldBspLeaf, + PolyWorldBspLeafRef, + PolyWorldBspLeafResolution, + PolyWorldBspNode, + PolyWorldBspPlane, + PolyWorldBspPortal, + PolyWorldBspPortalState, + PolyWorldBspPortalStateContext, + PolyWorldBspPortalStateResolver, + PolyWorldBspPortalStateValue, + PolyWorldBspBakedPvs, + PolyWorldBspPvsIndex, + PolyWorldBspPvsReasonLabels, + PolyWorldBspPvsBakeOptions, + PolyWorldBspPvsProjection, + PolyWorldBspPvsSelectionOptions, + PolyWorldBspResolvedPvs, + PolyWorldBspResolvedViewPvs, + PolyWorldBspResolvedViewSurfaceElements, + PolyWorldBspResolvedViewSurfaceRoleSummary, + PolyWorldBspViewSurfaceRole, + PolyWorldBspViewSurfaceVisibility, + PolyWorldBspViewPvsTrace, + PolyWorldBspViewPvsTraceEntry, + PolyWorldBspViewPvsTraceStatus, + PolyWorldBspViewPvsOptions, + PolyWorldBspViewPvsReasonLabels, + PolyWorldBspViewPvsSelectionOptions, + PolyWorldBspViewSurfaceElement, + PolyWorldBspViewSurfaceElementOptions, + PolyWorldBspTree, + PolyWorldBspTreeInput, +} from "./bsp"; +export type { + PolyWorldBspVisibility, + PolyWorldBspVisibilityFrame, + PolyWorldBspVisibilityFrameDebugOptions, + PolyWorldBspVisibilityFrameOptions, + PolyWorldBspVisibilityFrameSets, + PolyWorldBspVisibilityFrameStateOptions, + PolyWorldBspVisibilityDebugOptions, + PolyWorldBspVisibilityOptions, +} from "./bspVisibility"; +export type { + PolyWorldBspPvsCompleteness, + PolyWorldBspPvsMethod, + PolyWorldBspPvsProofLevel, + PolyWorldBspTopologyCertification, + PolyWorldBspTopologyProof, + PolyWorldBspTopologyProofGuarantee, + PolyWorldBspTopologyProofProfile, +} from "./bspProof"; +export type { + PolyWorldProfileFrameSummary, + PolyWorldProfileFrameSummaryDiff, + PolyWorldProfileFrameSummaryInput, + PolyWorldProfileFrameSummaryLoadSet, + PolyWorldProfileFrameSummaryPlan, + PolyWorldProfileFrameSummaryProfile, + PolyWorldProfileFrameSummaryReadiness, + PolyWorldProfileFrameSummarySet, + PolyWorldProfileFrameSummarySetInput, + PolyWorldProfileFrameSummaryState, +} from "./frameSummary"; +export type { + PolyWorldBspSurface, + PolyWorldBspSurfaceFragment, + PolyWorldPolygonBspCompileInput, + PolyWorldPolygonBspCompileResult, +} from "./polygonBsp"; +export type { + PolyWorldBspBrush, + PolyWorldBspBrushPlane, + PolyWorldBrushBspCompileInput, + PolyWorldBrushBspCompileResult, + PolyWorldBrushBspOutsideMode, + PolyWorldBrushBspRegion, +} from "./brushBsp"; +export { + createPolyWorldChunkGraphFromTree, + createPolyWorldChunkTree, + PolyWorldChunkTreeError, + selectPolyWorldChunkStreaming, + selectPolyWorldChunkStreamingState, + selectPolyWorldChunkWindow, + resolvePolyWorldChunkTreeTraversal, + summarizePolyWorldChunkTree, + validatePolyWorldChunkTree, +} from "./chunk"; +export type { + PolyWorldChunkStreamingFrame, + PolyWorldChunkStreamingFrameDebugOptions, + PolyWorldChunkStreamingFrameOptions, + PolyWorldChunkStreamingFramePlanDebugOptions, + PolyWorldChunkStreamingFrameSets, + PolyWorldChunkStreamingFrameStateOptions, +} from "./chunkFrame"; +export type { + PolyWorldChunkGraph, + PolyWorldChunkGraphExpansionOptions, + PolyWorldChunkRefinement, + PolyWorldChunkReasonLabels, + PolyWorldChunkStreamingStateName, + PolyWorldChunkStreamingStateSelectionOptions, + PolyWorldChunkStreamingReasonLabels, + PolyWorldChunkStreamingSelection, + PolyWorldChunkStreamingSelectionOptions, + PolyWorldChunkStreamingSource, + PolyWorldChunkStreamingSourceSummary, + PolyWorldChunkStreamingState, + PolyWorldChunkTargetState, + PolyWorldChunkTree, + PolyWorldChunkTreeDiagnostic, + PolyWorldChunkTreeInput, + PolyWorldChunkTreeNode, + PolyWorldChunkTreeOptions, + PolyWorldChunkTreeSummary, + PolyWorldChunkTreeTraversal, + PolyWorldChunkTreeTraversalBudget, + PolyWorldChunkTreeTraversalEntry, + PolyWorldChunkTreeTraversalOptions, + PolyWorldChunkTreeTraversalPlane, + PolyWorldChunkTreeTraversalReason, + PolyWorldChunkWindowSelectionOptions, + PolyWorldTaggedRegionSelection, +} from "./chunk"; diff --git a/packages/world/src/profiles/polygonBsp.ts b/packages/world/src/profiles/polygonBsp.ts new file mode 100644 index 000000000..402e7a58e --- /dev/null +++ b/packages/world/src/profiles/polygonBsp.ts @@ -0,0 +1,433 @@ +import type { Vec3 } from "@layoutit/polycss-core"; +import type { PolyWorldData } from "../topology"; +import { + createPolyWorldBspTree, + PolyWorldBspError, + type PolyWorldBspChild, + type PolyWorldBspDiagnostic, + type PolyWorldBspPlane, + type PolyWorldBspTree, +} from "./bsp"; +import { + crossVec3 as cross, + dotVec3 as dot, + lengthSqVec3 as lengthSq, + lerpVec3, + normalizeVec3OrZero as normalizeVec3, + sameVec3, + signedPlaneDistance, + subtractVec3, +} from "./bspGeometry"; + +type PolygonBspSide = "front" | "back" | "coplanar" | "spanning"; + +interface PolygonBspFragment { + id: string; + sourceSurfaceId: string; + vertices: Vec3[]; + regionId?: string; + elementId?: string; + selectionKeys?: readonly string[]; + data?: PolyWorldData; +} + +interface PolygonBspCompileState { + fragments: PolygonBspFragment[]; + leafCount: number; + nodeCount: number; + fragmentCount: number; +} + +export interface PolyWorldBspSurface { + id: string; + vertices: readonly Vec3[]; + regionId?: string; + elementId?: string; + selectionKeys?: readonly string[]; + data?: PolyWorldData; +} + +export interface PolyWorldPolygonBspCompileInput { + surfaces: readonly PolyWorldBspSurface[]; + epsilon?: number; + maxDepth?: number; + splitIdPrefix?: string; + data?: PolyWorldData; +} + +export interface PolyWorldBspSurfaceFragment { + id: string; + sourceSurfaceId: string; + vertices: readonly Vec3[]; + regionId?: string; + elementId?: string; + selectionKeys?: readonly string[]; + data?: PolyWorldData; +} + +export interface PolyWorldPolygonBspCompileResult { + tree: PolyWorldBspTree; + fragments: readonly PolyWorldBspSurfaceFragment[]; +} + +export function compilePolyWorldPolygonBsp( + input: PolyWorldPolygonBspCompileInput, +): PolyWorldPolygonBspCompileResult { + const diagnostics = validatePolyWorldPolygonBspInput(input); + if (diagnostics.length > 0) throw new PolyWorldBspError(diagnostics); + + const state: PolygonBspCompileState = { + fragments: [], + leafCount: 0, + nodeCount: 0, + fragmentCount: 0, + }; + const epsilon = input.epsilon ?? 0.0001; + const maxDepth = input.maxDepth ?? input.surfaces.length * 2; + const fragments = input.surfaces.map((surface) => ({ + id: surface.id, + sourceSurfaceId: surface.id, + vertices: surface.vertices.map((vertex) => [...vertex] as Vec3), + regionId: surface.regionId, + elementId: surface.elementId, + selectionKeys: surface.selectionKeys === undefined ? undefined : [...surface.selectionKeys], + data: surface.data, + })); + const root = compilePolygonBspChild( + fragments, + input.splitIdPrefix ?? "polygon-bsp", + epsilon, + maxDepth, + state, + 0, + ); + const tree = createPolyWorldBspTree({ + root, + leaves: collectLeafRefs(root).map((leafId) => ({ + id: leafId, + data: { + compiled: true, + compiler: "polygon-bsp", + }, + })), + data: { + ...input.data, + compiled: true, + compiler: "polygon-bsp", + sourceSurfaceCount: input.surfaces.length, + fragmentCount: state.fragments.length, + }, + }); + return { + tree, + fragments: state.fragments.map((fragment) => ({ + ...fragment, + vertices: fragment.vertices.map((vertex) => [...vertex] as Vec3), + })), + }; +} + +export function validatePolyWorldPolygonBspInput( + input: PolyWorldPolygonBspCompileInput, +): PolyWorldBspDiagnostic[] { + const diagnostics: PolyWorldBspDiagnostic[] = []; + const surfaceIds = new Set(); + if (input.surfaces.length === 0) { + diagnostics.push({ + code: "poly-world-empty-polygon-bsp-surfaces", + message: "PolyWorld polygon BSP compiler requires at least one surface.", + field: "surfaces", + kind: "compile", + }); + } + for (const surface of input.surfaces) { + if (typeof surface.id !== "string" || surface.id.length === 0) { + diagnostics.push({ + code: "poly-world-empty-polygon-bsp-surface-id", + message: "PolyWorld polygon BSP surface requires a non-empty id.", + field: "id", + kind: "surface", + }); + } else if (surfaceIds.has(surface.id)) { + diagnostics.push({ + code: "poly-world-duplicate-polygon-bsp-surface-id", + message: `Duplicate PolyWorld polygon BSP surface id "${surface.id}".`, + id: surface.id, + field: "id", + kind: "surface", + }); + } + if (surface.id) surfaceIds.add(surface.id); + if (!Array.isArray(surface.vertices) || surface.vertices.length < 3) { + diagnostics.push({ + code: "poly-world-polygon-bsp-surface-too-few-vertices", + message: `PolyWorld polygon BSP surface "${surface.id}" requires at least three vertices.`, + id: surface.id, + field: "vertices", + kind: "surface", + }); + continue; + } + for (let index = 0; index < surface.vertices.length; index += 1) { + const vertex = surface.vertices[index]; + if (!Array.isArray(vertex) || vertex.length !== 3 || !vertex.every(isFiniteNumber)) { + diagnostics.push({ + code: "poly-world-invalid-polygon-bsp-vertex", + message: `PolyWorld polygon BSP surface "${surface.id}" vertex ${index} must be a finite Vec3.`, + id: surface.id, + field: `vertices.${index}`, + kind: "surface", + }); + } + } + if (surface.vertices.length >= 3 && resolvePolygonPlane(surface.vertices, 0.0001) === undefined) { + diagnostics.push({ + code: "poly-world-degenerate-polygon-bsp-surface", + message: `PolyWorld polygon BSP surface "${surface.id}" cannot produce a valid split plane.`, + id: surface.id, + field: "vertices", + kind: "surface", + }); + } + } + if (input.epsilon !== undefined && (!isFiniteNumber(input.epsilon) || input.epsilon <= 0)) { + diagnostics.push({ + code: "poly-world-invalid-polygon-bsp-epsilon", + message: "PolyWorld polygon BSP epsilon must be a positive finite number.", + field: "epsilon", + kind: "compile", + }); + } + if (input.maxDepth !== undefined && (!Number.isInteger(input.maxDepth) || input.maxDepth < 1)) { + diagnostics.push({ + code: "poly-world-invalid-polygon-bsp-max-depth", + message: "PolyWorld polygon BSP maxDepth must be a positive integer.", + field: "maxDepth", + kind: "compile", + }); + } + return diagnostics; +} + +function compilePolygonBspChild( + fragments: readonly PolygonBspFragment[], + idPrefix: string, + epsilon: number, + maxDepth: number, + state: PolygonBspCompileState, + depth: number, +): PolyWorldBspChild { + if (fragments.length === 0 || depth >= maxDepth) { + return createPolygonBspLeaf(fragments, idPrefix, state); + } + + const splitter = choosePolygonBspSplitter(fragments, epsilon); + if (splitter === undefined) return createPolygonBspLeaf(fragments, idPrefix, state); + + const front: PolygonBspFragment[] = []; + const back: PolygonBspFragment[] = []; + const coplanar: PolygonBspFragment[] = []; + + for (const fragment of fragments) { + const side = classifyPolygon(fragment.vertices, splitter.plane, epsilon); + if (side === "front") { + front.push(fragment); + } else if (side === "back") { + back.push(fragment); + } else if (side === "coplanar") { + coplanar.push(fragment); + } else { + const split = splitPolygonBspFragment(fragment, splitter.plane, epsilon, state); + if (split.front !== undefined) front.push(split.front); + if (split.back !== undefined) back.push(split.back); + } + } + + state.fragments.push(...coplanar.map(cloneFragment)); + + const nodeId = `${idPrefix}-node-${state.nodeCount}-${depth}-${splitter.fragment.sourceSurfaceId}`; + state.nodeCount += 1; + + return { + id: nodeId, + plane: splitter.plane, + back: compilePolygonBspChild(back, idPrefix, epsilon, maxDepth, state, depth + 1), + front: compilePolygonBspChild(front, idPrefix, epsilon, maxDepth, state, depth + 1), + data: { + compiled: true, + compiler: "polygon-bsp", + splitterSurfaceId: splitter.fragment.sourceSurfaceId, + splitterFragmentId: splitter.fragment.id, + surfaceIds: uniqueStrings(coplanar.map((fragment) => fragment.sourceSurfaceId)), + fragmentIds: coplanar.map((fragment) => fragment.id), + }, + }; +} + +function createPolygonBspLeaf( + fragments: readonly PolygonBspFragment[], + idPrefix: string, + state: PolygonBspCompileState, +): PolyWorldBspChild { + const leafId = `${idPrefix}-leaf-${state.leafCount}`; + state.leafCount += 1; + state.fragments.push(...fragments.map(cloneFragment)); + return { leafId }; +} + +function choosePolygonBspSplitter( + fragments: readonly PolygonBspFragment[], + epsilon: number, +): { fragment: PolygonBspFragment; plane: PolyWorldBspPlane } | undefined { + let best: + | { fragment: PolygonBspFragment; plane: PolyWorldBspPlane; score: number } + | undefined; + + for (const fragment of fragments) { + const plane = resolvePolygonPlane(fragment.vertices, epsilon); + if (plane === undefined) continue; + let front = 0; + let back = 0; + let splits = 0; + for (const other of fragments) { + const side = classifyPolygon(other.vertices, plane, epsilon); + if (side === "front") front += 1; + else if (side === "back") back += 1; + else if (side === "spanning") splits += 1; + } + const score = splits * 1000 + Math.abs(front - back); + if (best === undefined || score < best.score) best = { fragment, plane, score }; + } + + return best; +} + +function splitPolygonBspFragment( + fragment: PolygonBspFragment, + plane: PolyWorldBspPlane, + epsilon: number, + state: PolygonBspCompileState, +): { front?: PolygonBspFragment; back?: PolygonBspFragment } { + const frontVertices: Vec3[] = []; + const backVertices: Vec3[] = []; + const vertices = fragment.vertices; + + for (let index = 0; index < vertices.length; index += 1) { + const current = vertices[index] as Vec3; + const next = vertices[(index + 1) % vertices.length] as Vec3; + const currentDistance = signedPlaneDistance(plane, current); + const nextDistance = signedPlaneDistance(plane, next); + const currentFront = currentDistance > epsilon; + const currentBack = currentDistance < -epsilon; + const nextFront = nextDistance > epsilon; + const nextBack = nextDistance < -epsilon; + + if (!currentBack) pushDistinctVertex(frontVertices, current, epsilon); + if (!currentFront) pushDistinctVertex(backVertices, current, epsilon); + + if ((currentFront && nextBack) || (currentBack && nextFront)) { + const t = currentDistance / (currentDistance - nextDistance); + const intersection = lerpVec3(current, next, t); + pushDistinctVertex(frontVertices, intersection, epsilon); + pushDistinctVertex(backVertices, intersection, epsilon); + } + } + + return { + front: createSplitFragment(fragment, frontVertices, state), + back: createSplitFragment(fragment, backVertices, state), + }; +} + +function createSplitFragment( + source: PolygonBspFragment, + vertices: readonly Vec3[], + state: PolygonBspCompileState, +): PolygonBspFragment | undefined { + const cleaned = cleanPolygonVertices(vertices, 0.0001); + if (cleaned.length < 3) return undefined; + state.fragmentCount += 1; + return { + ...source, + id: `${source.sourceSurfaceId}#${state.fragmentCount}`, + vertices: cleaned, + data: { + ...source.data, + splitFromFragmentId: source.id, + }, + }; +} + +function classifyPolygon( + vertices: readonly Vec3[], + plane: PolyWorldBspPlane, + epsilon: number, +): PolygonBspSide { + let hasFront = false; + let hasBack = false; + for (const vertex of vertices) { + const distance = signedPlaneDistance(plane, vertex); + if (distance > epsilon) hasFront = true; + else if (distance < -epsilon) hasBack = true; + if (hasFront && hasBack) return "spanning"; + } + if (hasFront) return "front"; + if (hasBack) return "back"; + return "coplanar"; +} + +function resolvePolygonPlane( + vertices: readonly Vec3[], + epsilon: number, +): PolyWorldBspPlane | undefined { + const origin = vertices[0]; + if (origin === undefined) return undefined; + for (let i = 1; i < vertices.length - 1; i += 1) { + const a = vertices[i]; + const b = vertices[i + 1]; + if (a === undefined || b === undefined) continue; + const normal = normalizeVec3(cross(subtractVec3(a, origin), subtractVec3(b, origin))); + if (lengthSq(normal) <= epsilon * epsilon) continue; + return { + normal, + distance: dot(normal, origin), + epsilon, + }; + } + return undefined; +} + +function collectLeafRefs(child: PolyWorldBspChild): string[] { + if ("leafId" in child) return [child.leafId]; + return [...collectLeafRefs(child.back), ...collectLeafRefs(child.front)]; +} + +function cloneFragment(fragment: PolygonBspFragment): PolygonBspFragment { + return { + ...fragment, + vertices: fragment.vertices.map((vertex) => [...vertex] as Vec3), + selectionKeys: fragment.selectionKeys === undefined ? undefined : [...fragment.selectionKeys], + }; +} + +function cleanPolygonVertices(vertices: readonly Vec3[], epsilon: number): Vec3[] { + const cleaned: Vec3[] = []; + for (const vertex of vertices) pushDistinctVertex(cleaned, vertex, epsilon); + if (cleaned.length > 1 && sameVec3(cleaned[0] as Vec3, cleaned[cleaned.length - 1] as Vec3, epsilon)) { + cleaned.pop(); + } + return cleaned; +} + +function pushDistinctVertex(vertices: Vec3[], vertex: Vec3, epsilon: number): void { + if (vertices.length > 0 && sameVec3(vertices[vertices.length - 1] as Vec3, vertex, epsilon)) return; + vertices.push([...vertex] as Vec3); +} + +function uniqueStrings(values: readonly string[]): string[] { + return [...new Set(values)]; +} + +function isFiniteNumber(value: unknown): value is number { + return typeof value === "number" && Number.isFinite(value); +} diff --git a/packages/world/src/profiles/portal.ts b/packages/world/src/profiles/portal.ts new file mode 100644 index 000000000..5755f87da --- /dev/null +++ b/packages/world/src/profiles/portal.ts @@ -0,0 +1,483 @@ +import type { + PolyWorldLink, + PolyWorldSelection, + PolyWorldSelectionReason, + PolyWorldTopology, +} from "../topology"; + +export interface PolyWorldRegionSelectionKeysContext { + regionId: string; + topology: PolyWorldTopology; +} + +export type PolyWorldRegionSelectionKeys = + | Record + | ((context: PolyWorldRegionSelectionKeysContext) => readonly string[] | undefined); + +export interface PolyWorldPortalReasonLabels { + current?: string; + linked?: string; + visible?: string; + visibilitySelection?: string; + link?: string; + facing?: string; + closed?: string; + blocked?: string; + selectionKey?: string; +} + +export type PolyWorldPortalLinkStateValue = "open" | "closed" | "blocked" | boolean; + +export interface PolyWorldPortalLinkStateContext { + link: PolyWorldLink; + fromRegionId: string; + toRegionId: string; + depth: number; + topology: PolyWorldTopology; +} + +export type PolyWorldPortalLinkState = + | Record + | ((context: PolyWorldPortalLinkStateContext) => PolyWorldPortalLinkStateValue | undefined); + +export type PolyWorldPortalActivityTargetState = "preloaded" | "loaded" | "resident" | "active" | "rendered" | "inactive"; + +export interface PolyWorldPortalActivityOptions { + selectedRegionIds?: readonly string[]; + selectedTargetState?: PolyWorldPortalActivityTargetState; + loadedRegionIds?: readonly string[]; + residentRegionIds?: readonly string[]; + activeRegionIds?: readonly string[]; + renderedRegionIds?: readonly string[]; + preloadedRegionIds?: readonly string[]; +} + +export interface PolyWorldPortalActivityState { + selectedRegionIds: readonly string[]; + hiddenRegionIds: readonly string[]; + loadedRegionIds: readonly string[]; + residentRegionIds: readonly string[]; + activeRegionIds: readonly string[]; + renderedRegionIds: readonly string[]; + preloadedRegionIds: readonly string[]; + inactiveRegionIds: readonly string[]; +} + +export interface PolyWorldPortalSelectionOptions { + currentRegionId?: string; + includeCurrent?: boolean; + includeLinked?: boolean; + linkedDepth?: number; + visibleRegionIds?: readonly string[]; + visibilitySelection?: PolyWorldSelection; + linkIds?: readonly string[]; + facingLinkIds?: readonly string[]; + linkState?: PolyWorldPortalLinkState; + includeClosedLinks?: boolean; + selectionKeys?: readonly string[]; + regionSelectionKeys?: PolyWorldRegionSelectionKeys; + reasonLabels?: PolyWorldPortalReasonLabels; + reasons?: readonly PolyWorldSelectionReason[]; + data?: Record; +} + +export function selectPolyWorldPortalRegions( + topology: PolyWorldTopology, + options: PolyWorldPortalSelectionOptions, +): PolyWorldSelection { + const labels = { + current: "current", + linked: "linked", + visible: "visible", + visibilitySelection: "visibility-selection", + link: "link", + facing: "facing", + closed: "closed", + blocked: "blocked", + selectionKey: "selection-key", + ...options.reasonLabels, + }; + const regionIds: string[] = []; + const linkIds: string[] = []; + const selectionKeys: string[] = []; + const elementIds: string[] = []; + const sourceIds: string[] = []; + const aliases: string[] = []; + const reasons: PolyWorldSelectionReason[] = [...(options.reasons ?? [])]; + + if (options.currentRegionId !== undefined && options.includeCurrent !== false) { + add(regionIds, options.currentRegionId); + reasons.push({ + id: "poly-world-portal-current", + kind: "current", + label: labels.current, + regionIds: [options.currentRegionId], + }); + } + + if (options.currentRegionId !== undefined && options.includeLinked !== false) { + const linked = linkedRegions(topology, options.currentRegionId, options.linkedDepth ?? 1, { + linkState: options.linkState, + includeClosedLinks: options.includeClosedLinks === true, + }); + for (const linkedRegionId of linked.regionIds) add(regionIds, linkedRegionId); + for (const linkedLinkId of linked.linkIds) add(linkIds, linkedLinkId); + for (const linkedLinkId of linked.linkIds) { + const link = topology.linksById.get(linkedLinkId); + for (const selectionKey of link?.selectionKeys ?? []) add(selectionKeys, selectionKey); + } + if (linked.regionIds.length > 0 || linked.linkIds.length > 0) { + reasons.push({ + id: "poly-world-portal-linked", + kind: "linked", + label: labels.linked, + regionIds: linked.regionIds, + linkIds: linked.linkIds, + }); + } + if (linked.closedLinkIds.length > 0) { + reasons.push({ + id: "poly-world-portal-closed", + kind: "closed", + label: labels.closed, + linkIds: linked.closedLinkIds, + }); + } + if (linked.blockedLinkIds.length > 0) { + reasons.push({ + id: "poly-world-portal-blocked", + kind: "blocked", + label: labels.blocked, + linkIds: linked.blockedLinkIds, + }); + } + } + + for (const visibleRegionId of options.visibleRegionIds ?? []) add(regionIds, visibleRegionId); + if ((options.visibleRegionIds?.length ?? 0) > 0) { + reasons.push({ + id: "poly-world-portal-visible", + kind: "visible", + label: labels.visible, + regionIds: unique(options.visibleRegionIds), + }); + } + + if (options.visibilitySelection !== undefined) { + mergePortalVisibilitySelection(options.visibilitySelection, { + regionIds, + linkIds, + selectionKeys, + elementIds, + sourceIds, + aliases, + }); + reasons.push(...(options.visibilitySelection.reasons ?? [])); + if (hasPortalVisibilitySelectionEntries(options.visibilitySelection)) { + reasons.push({ + id: "poly-world-portal-visibility-selection", + kind: "visibilitySelection", + label: labels.visibilitySelection, + regionIds: unique(options.visibilitySelection.regionIds), + linkIds: unique(options.visibilitySelection.linkIds), + selectionKeys: unique(options.visibilitySelection.selectionKeys), + elementIds: unique(options.visibilitySelection.elementIds), + sourceIds: unique(options.visibilitySelection.sourceIds), + aliases: unique(options.visibilitySelection.aliases), + data: options.visibilitySelection.data, + }); + } + } + + const explicitLinkIds = unique([...(options.linkIds ?? []), ...(options.facingLinkIds ?? [])]); + for (const linkId of explicitLinkIds) { + const link = topology.linksById.get(linkId); + add(linkIds, linkId); + if (link === undefined) continue; + add(regionIds, link.fromRegionId); + add(regionIds, link.toRegionId); + for (const selectionKey of link.selectionKeys ?? []) add(selectionKeys, selectionKey); + } + + if ((options.linkIds?.length ?? 0) > 0) { + reasons.push({ + id: "poly-world-portal-link", + kind: "link", + label: labels.link, + linkIds: unique(options.linkIds), + }); + } + if ((options.facingLinkIds?.length ?? 0) > 0) { + reasons.push({ + id: "poly-world-portal-facing", + kind: "facing", + label: labels.facing, + linkIds: unique(options.facingLinkIds), + }); + } + + for (const regionId of regionIds) { + const region = topology.regionsById.get(regionId); + for (const selectionKey of region?.selectionKeys ?? []) add(selectionKeys, selectionKey); + for (const selectionKey of resolveRegionSelectionKeys(options.regionSelectionKeys, regionId, topology)) { + add(selectionKeys, selectionKey); + } + } + + for (const selectionKey of options.selectionKeys ?? []) add(selectionKeys, selectionKey); + if (selectionKeys.length > 0) { + reasons.push({ + id: "poly-world-portal-selection-key", + kind: "selectionKey", + label: labels.selectionKey, + selectionKeys, + }); + } + + return { + regionIds, + linkIds, + selectionKeys, + ...(elementIds.length === 0 ? {} : { elementIds }), + ...(sourceIds.length === 0 ? {} : { sourceIds }), + ...(aliases.length === 0 ? {} : { aliases }), + reasons, + data: options.data, + }; +} + +function mergePortalVisibilitySelection( + selection: PolyWorldSelection, + target: { + regionIds: string[]; + linkIds: string[]; + selectionKeys: string[]; + elementIds: string[]; + sourceIds: string[]; + aliases: string[]; + }, +): void { + for (const regionId of selection.regionIds ?? []) add(target.regionIds, regionId); + for (const linkId of selection.linkIds ?? []) add(target.linkIds, linkId); + for (const selectionKey of selection.selectionKeys ?? []) add(target.selectionKeys, selectionKey); + for (const elementId of selection.elementIds ?? []) add(target.elementIds, elementId); + for (const sourceId of selection.sourceIds ?? []) add(target.sourceIds, sourceId); + for (const alias of selection.aliases ?? []) add(target.aliases, alias); +} + +function hasPortalVisibilitySelectionEntries(selection: PolyWorldSelection): boolean { + return (selection.regionIds?.length ?? 0) > 0 || + (selection.linkIds?.length ?? 0) > 0 || + (selection.selectionKeys?.length ?? 0) > 0 || + (selection.elementIds?.length ?? 0) > 0 || + (selection.sourceIds?.length ?? 0) > 0 || + (selection.aliases?.length ?? 0) > 0; +} + +export function resolvePolyWorldPortalActivity( + topology: PolyWorldTopology, + selection: Pick, + options: PolyWorldPortalActivityOptions = {}, +): PolyWorldPortalActivityState { + const selectedRegionIds = unique(options.selectedRegionIds ?? selection.regionIds); + const selectedRegionSet = new Set(selectedRegionIds); + const loadedRegionIds = new Set(options.loadedRegionIds ?? []); + const residentRegionIds = new Set(options.residentRegionIds ?? []); + const activeRegionIds = new Set(options.activeRegionIds ?? []); + const renderedRegionIds = new Set(options.renderedRegionIds ?? []); + const preloadedRegionIds = new Set(options.preloadedRegionIds ?? []); + const selectedTargetState = options.selectedTargetState ?? "rendered"; + + for (const regionId of selectedRegionIds) { + addPortalActivityTargetState(regionId, selectedTargetState, { + loadedRegionIds, + residentRegionIds, + activeRegionIds, + renderedRegionIds, + preloadedRegionIds, + }); + } + for (const regionId of options.loadedRegionIds ?? []) { + addPortalActivityTargetState(regionId, "loaded", { + loadedRegionIds, + residentRegionIds, + activeRegionIds, + renderedRegionIds, + preloadedRegionIds, + }); + } + for (const regionId of options.residentRegionIds ?? []) { + addPortalActivityTargetState(regionId, "resident", { + loadedRegionIds, + residentRegionIds, + activeRegionIds, + renderedRegionIds, + preloadedRegionIds, + }); + } + for (const regionId of options.activeRegionIds ?? []) { + addPortalActivityTargetState(regionId, "active", { + loadedRegionIds, + residentRegionIds, + activeRegionIds, + renderedRegionIds, + preloadedRegionIds, + }); + } + for (const regionId of options.renderedRegionIds ?? []) { + addPortalActivityTargetState(regionId, "rendered", { + loadedRegionIds, + residentRegionIds, + activeRegionIds, + renderedRegionIds, + preloadedRegionIds, + }); + } + for (const regionId of options.preloadedRegionIds ?? []) { + addPortalActivityTargetState(regionId, "preloaded", { + loadedRegionIds, + residentRegionIds, + activeRegionIds, + renderedRegionIds, + preloadedRegionIds, + }); + } + + return { + selectedRegionIds, + hiddenRegionIds: topology.regions.map((region) => region.id).filter((regionId) => !selectedRegionSet.has(regionId)), + loadedRegionIds: orderPortalActivityRegionIds(topology, loadedRegionIds), + residentRegionIds: orderPortalActivityRegionIds(topology, residentRegionIds), + activeRegionIds: orderPortalActivityRegionIds(topology, activeRegionIds), + renderedRegionIds: orderPortalActivityRegionIds(topology, renderedRegionIds), + preloadedRegionIds: orderPortalActivityRegionIds(topology, preloadedRegionIds), + inactiveRegionIds: topology.regions.map((region) => region.id).filter((regionId) => !activeRegionIds.has(regionId)), + }; +} + +function linkedRegions( + topology: PolyWorldTopology, + currentRegionId: string, + depth: number, + options: { + linkState?: PolyWorldPortalLinkState; + includeClosedLinks: boolean; + }, +): { regionIds: string[]; linkIds: string[]; closedLinkIds: string[]; blockedLinkIds: string[] } { + const visited = new Set([currentRegionId]); + const regionIds: string[] = []; + const linkIds: string[] = []; + const closedLinkIds: string[] = []; + const blockedLinkIds: string[] = []; + let frontier = [currentRegionId]; + + for (let step = 0; step < depth; step += 1) { + const next: string[] = []; + for (const regionId of frontier) { + for (const link of topology.linksByRegionId.get(regionId) ?? []) { + const linkedRegionId = otherRegionId(link, regionId); + if (linkedRegionId === undefined) continue; + const state = resolveLinkState(options.linkState, { + link, + fromRegionId: regionId, + toRegionId: linkedRegionId, + depth: step, + topology, + }); + if (!options.includeClosedLinks && state !== "open") { + if (state === "blocked") add(blockedLinkIds, link.id); + else add(closedLinkIds, link.id); + continue; + } + add(linkIds, link.id); + if (visited.has(linkedRegionId)) continue; + visited.add(linkedRegionId); + add(regionIds, linkedRegionId); + next.push(linkedRegionId); + } + } + frontier = next; + if (frontier.length === 0) break; + } + + return { regionIds, linkIds, closedLinkIds, blockedLinkIds }; +} + +function otherRegionId(link: PolyWorldLink, regionId: string): string | undefined { + if (link.fromRegionId === regionId) return link.toRegionId; + if (link.direction !== "forward" && link.toRegionId === regionId) return link.fromRegionId; + return undefined; +} + +export function resolvePolyWorldRegionSelectionKeys( + regionSelectionKeys: PolyWorldRegionSelectionKeys | undefined, + regionId: string, + topology: PolyWorldTopology, +): readonly string[] { + return resolveRegionSelectionKeys(regionSelectionKeys, regionId, topology); +} + +function resolveLinkState( + state: PolyWorldPortalLinkState | undefined, + context: PolyWorldPortalLinkStateContext, +): "open" | "closed" | "blocked" { + if (state === undefined) return "open"; + const value = typeof state === "function" + ? state(context) + : state[context.link.id] ?? state[context.link.sourceId ?? ""] ?? undefined; + if (value === false || value === "closed") return "closed"; + if (value === "blocked") return "blocked"; + return "open"; +} + +function resolveRegionSelectionKeys( + regionSelectionKeys: PolyWorldRegionSelectionKeys | undefined, + regionId: string, + topology: PolyWorldTopology, +): readonly string[] { + if (regionSelectionKeys === undefined) return []; + if (typeof regionSelectionKeys === "function") { + return regionSelectionKeys({ regionId, topology }) ?? []; + } + return regionSelectionKeys[regionId] ?? []; +} + +function add(values: string[], value: string): void { + if (!values.includes(value)) values.push(value); +} + +function unique(values: readonly string[] | undefined): string[] { + return [...new Set(values ?? [])]; +} + +function addPortalActivityTargetState( + regionId: string, + targetState: PolyWorldPortalActivityTargetState, + sets: { + loadedRegionIds: Set; + residentRegionIds: Set; + activeRegionIds: Set; + renderedRegionIds: Set; + preloadedRegionIds: Set; + }, +): void { + if (targetState === "inactive") return; + if (targetState === "preloaded") { + sets.preloadedRegionIds.add(regionId); + return; + } + sets.loadedRegionIds.add(regionId); + if (targetState === "resident" || targetState === "active" || targetState === "rendered") { + sets.residentRegionIds.add(regionId); + } + if (targetState === "active" || targetState === "rendered") { + sets.activeRegionIds.add(regionId); + } + if (targetState === "rendered") { + sets.renderedRegionIds.add(regionId); + } +} + +function orderPortalActivityRegionIds(topology: PolyWorldTopology, regionIds: ReadonlySet): string[] { + return topology.regions.map((region) => region.id).filter((regionId) => regionIds.has(regionId)); +} diff --git a/packages/world/src/profiles/portalFlow.ts b/packages/world/src/profiles/portalFlow.ts new file mode 100644 index 000000000..7a2564dae --- /dev/null +++ b/packages/world/src/profiles/portalFlow.ts @@ -0,0 +1,447 @@ +import type { Vec3 } from "@layoutit/polycss-core"; +import type { + PolyWorldBounds, + PolyWorldData, + PolyWorldLink, + PolyWorldSelection, + PolyWorldSelectionReason, + PolyWorldTopology, +} from "../topology"; +import { resolvePolyWorldRegionByPoint } from "../topology"; +import type { + PolyWorldPortalLinkState, + PolyWorldPortalLinkStateContext, + PolyWorldPortalReasonLabels, + PolyWorldRegionSelectionKeys, +} from "./portal"; +import { resolvePolyWorldRegionSelectionKeys } from "./portal"; +import { + crossVec3, + dotVec3, + normalizeVec3OrUndefined, + scaleVec3, + subtractVec3, +} from "./bspGeometry"; + +interface PolyWorldPortalFlowClipPlane { + normal: Vec3; + distance: number; +} + +export type PolyWorldPortalFlowTraceStatus = + | "visible" + | "outside-broad-phase" + | "closed" + | "blocked" + | "depth-capped" + | "missing-link" + | "missing-portal" + | "degenerate-portal" + | "clipped"; + +export interface PolyWorldPortalFlowPortal { + id: string; + linkId: string; + bounds?: PolyWorldBounds; + vertices?: readonly Vec3[]; + selectionKeys?: readonly string[]; + data?: PolyWorldData; +} + +export interface PolyWorldPortalFlowOptions { + point: Vec3; + currentRegionId?: string; + forward: Vec3; + up?: Vec3; + aspect?: number; + fovDegrees?: number; + near?: number; + far?: number; + maxDepth?: number; + portals?: readonly PolyWorldPortalFlowPortal[]; + broadRegionIds?: readonly string[]; + portalState?: PolyWorldPortalLinkState; + includeTrace?: boolean; + regionSelectionKeys?: PolyWorldRegionSelectionKeys; + reasonLabels?: PolyWorldPortalReasonLabels; + reasons?: readonly PolyWorldSelectionReason[]; + data?: PolyWorldData; +} + +export interface PolyWorldPortalFlowTraceEntry { + portalId: string; + linkId: string; + fromRegionId: string; + toRegionId: string; + depth: number; + status: PolyWorldPortalFlowTraceStatus; + inputVertexCount: number; + clippedVertexCount?: number; + clipPlaneCount?: number; + selectionKeys?: readonly string[]; +} + +export interface PolyWorldPortalFlow { + currentRegionId?: string; + regionIds: readonly string[]; + linkIds: readonly string[]; + portalIds: readonly string[]; + selectionKeys: readonly string[]; + selection: PolyWorldSelection; + trace?: readonly PolyWorldPortalFlowTraceEntry[]; +} + +export function resolvePolyWorldPortalFlow( + topology: PolyWorldTopology, + options: PolyWorldPortalFlowOptions, +): PolyWorldPortalFlow { + const labels = { + current: "current", + visible: "visible", + link: "link", + closed: "closed", + blocked: "blocked", + selectionKey: "selection-key", + ...options.reasonLabels, + }; + const currentRegionId = options.currentRegionId + ?? resolvePolyWorldRegionByPoint(topology, options.point, { nearest: true })?.regionId; + const regionIds: string[] = []; + const linkIds: string[] = []; + const portalIds: string[] = []; + const selectionKeys: string[] = []; + const reasons: PolyWorldSelectionReason[] = [...(options.reasons ?? [])]; + const trace: PolyWorldPortalFlowTraceEntry[] = []; + + if (currentRegionId === undefined || !topology.regionsById.has(currentRegionId)) { + return { + currentRegionId, + regionIds, + linkIds, + portalIds, + selectionKeys, + selection: { regionIds, linkIds, selectionKeys, reasons, data: options.data }, + ...(options.includeTrace === true ? { trace } : {}), + }; + } + + add(regionIds, currentRegionId); + reasons.push({ + id: "poly-world-portal-flow-current", + kind: "current", + label: labels.current, + regionIds: [currentRegionId], + }); + + const initialClip = createPortalFlowFrustumClip(options); + const broadRegionIds = options.broadRegionIds === undefined ? undefined : new Set(options.broadRegionIds); + const maxDepth = options.maxDepth ?? 8; + const portalsByLinkId = new Map((options.portals ?? []).map((portal) => [portal.linkId, portal])); + const visited = new Set([currentRegionId]); + const queue: Array<{ regionId: string; depth: number; clipPlanes: readonly PolyWorldPortalFlowClipPlane[] }> = [ + { regionId: currentRegionId, depth: 0, clipPlanes: initialClip }, + ]; + + while (queue.length > 0) { + const item = queue.shift(); + if (item === undefined) break; + for (const link of topology.linksByRegionId.get(item.regionId) ?? []) { + const toRegionId = linkedRegionId(link, item.regionId); + if (toRegionId === undefined) continue; + if (visited.has(toRegionId)) continue; + const portal = portalsByLinkId.get(link.id); + const traceBase = { + portalId: portal?.id ?? link.id, + linkId: link.id, + fromRegionId: item.regionId, + toRegionId, + depth: item.depth + 1, + }; + + if (broadRegionIds !== undefined && !broadRegionIds.has(toRegionId)) { + trace.push({ + ...traceBase, + status: "outside-broad-phase", + inputVertexCount: portal?.vertices?.length ?? 0, + }); + continue; + } + const state = resolvePortalFlowLinkState(options.portalState, link, item.regionId, toRegionId, item.depth + 1, topology); + if (state === "closed") { + trace.push({ ...traceBase, status: "closed", inputVertexCount: portal?.vertices?.length ?? 0 }); + continue; + } + if (state === "blocked") { + trace.push({ ...traceBase, status: "blocked", inputVertexCount: portal?.vertices?.length ?? 0 }); + continue; + } + if (item.depth >= maxDepth) { + trace.push({ ...traceBase, status: "depth-capped", inputVertexCount: portal?.vertices?.length ?? 0 }); + continue; + } + if (portal === undefined) { + trace.push({ ...traceBase, status: "missing-portal", inputVertexCount: 0 }); + continue; + } + const vertices = resolvePortalFlowPortalVertices(portal); + if (vertices.length < 3) { + trace.push({ ...traceBase, status: "degenerate-portal", inputVertexCount: vertices.length }); + continue; + } + const clipped = clipPortalFlowPolygon(vertices, item.clipPlanes); + if (clipped.length < 3) { + trace.push({ + ...traceBase, + status: "clipped", + inputVertexCount: vertices.length, + clippedVertexCount: clipped.length, + clipPlaneCount: item.clipPlanes.length, + selectionKeys: portal.selectionKeys, + }); + continue; + } + + add(regionIds, toRegionId); + add(linkIds, link.id); + add(portalIds, portal.id); + for (const selectionKey of link.selectionKeys ?? []) add(selectionKeys, selectionKey); + for (const selectionKey of portal.selectionKeys ?? []) add(selectionKeys, selectionKey); + trace.push({ + ...traceBase, + status: "visible", + inputVertexCount: vertices.length, + clippedVertexCount: clipped.length, + clipPlaneCount: item.clipPlanes.length, + selectionKeys: portal.selectionKeys, + }); + if (!visited.has(toRegionId)) { + visited.add(toRegionId); + queue.push({ + regionId: toRegionId, + depth: item.depth + 1, + clipPlanes: [...item.clipPlanes, ...createPortalFlowPortalClipPlanes(options.point, clipped)], + }); + } + } + } + + for (const regionId of regionIds) { + const region = topology.regionsById.get(regionId); + for (const selectionKey of region?.selectionKeys ?? []) add(selectionKeys, selectionKey); + for (const selectionKey of resolvePolyWorldRegionSelectionKeys(options.regionSelectionKeys, regionId, topology)) { + add(selectionKeys, selectionKey); + } + } + if (regionIds.length > 0) { + reasons.push({ + id: "poly-world-portal-flow-visible", + kind: "visible", + label: labels.visible, + regionIds, + linkIds, + }); + } + const closedLinkIds = trace.filter((entry) => entry.status === "closed").map((entry) => entry.linkId); + if (closedLinkIds.length > 0) { + reasons.push({ + id: "poly-world-portal-flow-closed", + kind: "closed", + label: labels.closed, + linkIds: unique(closedLinkIds), + }); + } + const blockedLinkIds = trace.filter((entry) => entry.status === "blocked").map((entry) => entry.linkId); + if (blockedLinkIds.length > 0) { + reasons.push({ + id: "poly-world-portal-flow-blocked", + kind: "blocked", + label: labels.blocked, + linkIds: unique(blockedLinkIds), + }); + } + if (selectionKeys.length > 0) { + reasons.push({ + id: "poly-world-portal-flow-selection-key", + kind: "selectionKey", + label: labels.selectionKey, + selectionKeys, + }); + } + + return { + currentRegionId, + regionIds, + linkIds, + portalIds, + selectionKeys, + selection: { + regionIds, + linkIds, + selectionKeys, + reasons, + data: options.data, + }, + ...(options.includeTrace === true ? { trace } : {}), + }; +} + +function createPortalFlowFrustumClip(options: PolyWorldPortalFlowOptions): PolyWorldPortalFlowClipPlane[] { + const point = options.point; + const forward = normalizeVec3OrUndefined(options.forward) ?? [1, 0, 0]; + const up = normalizeVec3OrUndefined(options.up ?? [0, 0, 1]) ?? [0, 0, 1]; + const right = normalizeVec3OrUndefined(crossVec3(forward, up)) ?? [0, -1, 0]; + const resolvedUp = normalizeVec3OrUndefined(crossVec3(right, forward)) ?? up; + const fov = ((options.fovDegrees ?? 90) * Math.PI) / 180; + const near = Math.max(0.001, options.near ?? 0.01); + const far = Math.max(near, options.far ?? 1000); + const halfHeight = Math.tan(fov / 2) * near; + const halfWidth = halfHeight * (options.aspect ?? 1); + const center = addVec3(point, scaleVec3(forward, near)); + const topLeft = addVec3(addVec3(center, scaleVec3(resolvedUp, halfHeight)), scaleVec3(right, -halfWidth)); + const topRight = addVec3(addVec3(center, scaleVec3(resolvedUp, halfHeight)), scaleVec3(right, halfWidth)); + const bottomLeft = addVec3(addVec3(center, scaleVec3(resolvedUp, -halfHeight)), scaleVec3(right, -halfWidth)); + const bottomRight = addVec3(addVec3(center, scaleVec3(resolvedUp, -halfHeight)), scaleVec3(right, halfWidth)); + const planes = [ + planeFromPointNormal(addVec3(point, scaleVec3(forward, near)), forward), + planeFromPointNormal(addVec3(point, scaleVec3(forward, far)), scaleVec3(forward, -1)), + planeFromTriangle(point, bottomLeft, topLeft), + planeFromTriangle(point, topRight, bottomRight), + planeFromTriangle(point, topLeft, topRight), + planeFromTriangle(point, bottomRight, bottomLeft), + ].filter((plane): plane is PolyWorldPortalFlowClipPlane => plane !== undefined); + return planes; +} + +function createPortalFlowPortalClipPlanes( + origin: Vec3, + vertices: readonly Vec3[], +): PolyWorldPortalFlowClipPlane[] { + const centroid = vertices.reduce((sum, vertex) => [ + sum[0] + vertex[0] / vertices.length, + sum[1] + vertex[1] / vertices.length, + sum[2] + vertex[2] / vertices.length, + ], [0, 0, 0]); + const planes: PolyWorldPortalFlowClipPlane[] = []; + for (let index = 0; index < vertices.length; index += 1) { + const a = vertices[index] ?? vertices[0]; + const b = vertices[(index + 1) % vertices.length] ?? a; + const plane = planeFromTriangle(origin, a, b); + if (plane === undefined) continue; + if (signedDistance(plane, centroid) < 0) { + planes.push({ normal: scaleVec3(plane.normal, -1), distance: -plane.distance }); + continue; + } + planes.push(plane); + } + return planes; +} + +function clipPortalFlowPolygon( + vertices: readonly Vec3[], + planes: readonly PolyWorldPortalFlowClipPlane[], +): Vec3[] { + let output = vertices.map((vertex) => [...vertex] as Vec3); + for (const plane of planes) { + if (output.length === 0) break; + const input = output; + output = []; + for (let index = 0; index < input.length; index += 1) { + const current = input[index] ?? input[0]; + const previous = input[(index + input.length - 1) % input.length] ?? current; + const currentInside = signedDistance(plane, current) >= -0.0001; + const previousInside = signedDistance(plane, previous) >= -0.0001; + if (currentInside !== previousInside) { + output.push(intersectPortalFlowSegmentPlane(previous, current, plane)); + } + if (currentInside) output.push(current); + } + } + return output; +} + +function resolvePortalFlowPortalVertices(portal: PolyWorldPortalFlowPortal): Vec3[] { + if (portal.vertices !== undefined) return portal.vertices.map((vertex) => [...vertex] as Vec3); + if (portal.bounds === undefined) return []; + return verticesFromPortalFlowBounds(portal.bounds); +} + +function verticesFromPortalFlowBounds(bounds: PolyWorldBounds): Vec3[] { + const epsilon = 0.0001; + const zeroAxes = ([0, 1, 2] as const).filter((axis) => Math.abs(bounds.max[axis] - bounds.min[axis]) <= epsilon); + if (zeroAxes.length !== 1) return []; + const planeAxis = zeroAxes[0] ?? 0; + const [a, b] = ([0, 1, 2] as const).filter((axis) => axis !== planeAxis); + const makePoint = (va: number, vb: number): Vec3 => { + const point = [0, 0, 0] as Vec3; + point[planeAxis] = bounds.min[planeAxis]; + point[a] = va; + point[b] = vb; + return point; + }; + return [ + makePoint(bounds.min[a], bounds.min[b]), + makePoint(bounds.max[a], bounds.min[b]), + makePoint(bounds.max[a], bounds.max[b]), + makePoint(bounds.min[a], bounds.max[b]), + ]; +} + +function resolvePortalFlowLinkState( + state: PolyWorldPortalLinkState | undefined, + link: PolyWorldLink, + fromRegionId: string, + toRegionId: string, + depth: number, + topology: PolyWorldTopology, +): "open" | "closed" | "blocked" { + const value = typeof state === "function" + ? state({ link, fromRegionId, toRegionId, depth, topology } satisfies PolyWorldPortalLinkStateContext) + : state?.[link.id]; + if (value === false || value === "closed") return "closed"; + if (value === "blocked") return "blocked"; + return "open"; +} + +function linkedRegionId(link: PolyWorldLink, regionId: string): string | undefined { + if (link.fromRegionId === regionId) return link.toRegionId; + if (link.direction === "forward") return undefined; + if (link.toRegionId === regionId) return link.fromRegionId; + return undefined; +} + +function planeFromTriangle(a: Vec3, b: Vec3, c: Vec3): PolyWorldPortalFlowClipPlane | undefined { + const normal = normalizeVec3OrUndefined(crossVec3(subtractVec3(b, a), subtractVec3(c, a))); + if (normal === undefined) return undefined; + return planeFromPointNormal(a, normal); +} + +function planeFromPointNormal(point: Vec3, normal: Vec3): PolyWorldPortalFlowClipPlane { + return { normal, distance: dotVec3(normal, point) }; +} + +function signedDistance(plane: PolyWorldPortalFlowClipPlane, point: Vec3): number { + return dotVec3(plane.normal, point) - plane.distance; +} + +function intersectPortalFlowSegmentPlane(a: Vec3, b: Vec3, plane: PolyWorldPortalFlowClipPlane): Vec3 { + const da = signedDistance(plane, a); + const db = signedDistance(plane, b); + const t = da / (da - db || 1); + return [ + a[0] + (b[0] - a[0]) * t, + a[1] + (b[1] - a[1]) * t, + a[2] + (b[2] - a[2]) * t, + ]; +} + +function addVec3(a: Vec3, b: Vec3): Vec3 { + return [a[0] + b[0], a[1] + b[1], a[2] + b[2]]; +} + +function add(values: string[], value: string): void { + if (!values.includes(value)) values.push(value); +} + +function unique(values: readonly string[]): string[] { + return [...new Set(values)]; +} diff --git a/packages/world/src/profiles/portalFlowFrame.ts b/packages/world/src/profiles/portalFlowFrame.ts new file mode 100644 index 000000000..734c5ed89 --- /dev/null +++ b/packages/world/src/profiles/portalFlowFrame.ts @@ -0,0 +1,259 @@ +import type { + PolyWorldPortalFlowDebugSnapshot, + PolyWorldPortalFlowDebugSnapshotOptions, +} from "../debug"; +import { + createPolyWorldPortalFlowArtifactProof, + createPolyWorldPortalFlowDebugSnapshot, +} from "../debug/portalFlowSnapshot"; +import type { + PolyWorldLayerPlanPolicy, + PolyWorldTransition, + PolyWorldTransitionDebugOptions, + PolyWorldTransitionReadinessOptions, + PolyWorldTransitionStateOptions, +} from "../planner"; +import { planPolyWorldTransition } from "../planner"; +import type { PolyWorldState } from "../state"; +import type { + PolyWorldSelection, + PolyWorldSelectionElementRelationExpansionOptions, + PolyWorldTopology, +} from "../topology"; +import type { PolyWorldProfileArtifactProof } from "./artifact"; +import { + createPolyWorldProfileFrameSummary, + type PolyWorldProfileFrameSummary, +} from "./frameSummary"; +import type { + PolyWorldPortalActivityOptions, + PolyWorldPortalActivityState, + PolyWorldPortalActivityTargetState, +} from "./portal"; +import { resolvePolyWorldPortalActivity } from "./portal"; +import type { + PolyWorldPortalFlow, + PolyWorldPortalFlowOptions, + PolyWorldPortalFlowTraceStatus, +} from "./portalFlow"; +import { resolvePolyWorldPortalFlow } from "./portalFlow"; + +export type PolyWorldPortalFlowFrameStateOptions = PolyWorldTransitionStateOptions; + +export type PolyWorldPortalFlowFramePlanDebugOptions = PolyWorldTransitionDebugOptions; + +export type PolyWorldPortalFlowFrameDebugOptions = PolyWorldPortalFlowDebugSnapshotOptions; + +export type PolyWorldPortalFlowFramePlanRegionState = "selected" | PolyWorldPortalActivityTargetState; + +export interface PolyWorldPortalFlowFrameOptions extends PolyWorldPortalFlowOptions { + previousState: PolyWorldState; + policies: readonly PolyWorldLayerPlanPolicy[]; + activity?: false | PolyWorldPortalActivityOptions; + planRegionState?: PolyWorldPortalFlowFramePlanRegionState; + relations?: false | PolyWorldSelectionElementRelationExpansionOptions; + readiness?: PolyWorldTransitionReadinessOptions; + state?: PolyWorldPortalFlowFrameStateOptions; + planDebug?: false | PolyWorldPortalFlowFramePlanDebugOptions; + debug?: false | PolyWorldPortalFlowFrameDebugOptions; +} + +export interface PolyWorldPortalFlowFrameSets { + currentRegionId?: string; + selectedRegionIds: readonly string[]; + selectedLinkIds: readonly string[]; + selectedPortalIds: readonly string[]; + tracedPortalIds: readonly string[]; + rejectedPortalIds: readonly string[]; + visiblePortalIds: readonly string[]; + closedLinkIds: readonly string[]; + blockedLinkIds: readonly string[]; + clippedPortalIds: readonly string[]; + traceStatusCounts: Partial>; + activitySelectedRegionIds: readonly string[]; + activityLoadedRegionIds: readonly string[]; + activityResidentRegionIds: readonly string[]; + activityActiveRegionIds: readonly string[]; + activityRenderedRegionIds: readonly string[]; + activityPreloadedRegionIds: readonly string[]; + activityInactiveRegionIds: readonly string[]; + plannedElementIds: readonly string[]; +} + +export interface PolyWorldPortalFlowFrame extends PolyWorldTransition { + artifact: PolyWorldProfileArtifactProof; + flow: PolyWorldPortalFlow; + flowSets: PolyWorldPortalFlowFrameSets; + frameSummary: PolyWorldProfileFrameSummary; + activity?: PolyWorldPortalActivityState; + portalFlowDebug?: PolyWorldPortalFlowDebugSnapshot; +} + +export function planPolyWorldPortalFlowFrame( + topology: PolyWorldTopology, + options: PolyWorldPortalFlowFrameOptions, +): PolyWorldPortalFlowFrame { + const flow = resolvePolyWorldPortalFlow(topology, { + ...options, + includeTrace: options.includeTrace ?? options.debug !== false, + }); + const activity = options.activity === false + ? undefined + : resolvePolyWorldPortalActivity(topology, flow.selection, options.activity); + const transitionSelection = selectionForPortalFlowFramePlan( + topology, + flow.selection, + activity, + options.planRegionState ?? "selected", + ); + const transition = planPolyWorldTransition(topology, { + previousState: options.previousState, + policies: options.policies, + selection: transitionSelection, + relations: options.relations, + readiness: options.readiness, + state: options.state, + debug: options.planDebug, + }); + const artifact = createPolyWorldPortalFlowArtifactProof(topology, flow); + const flowSets = createPortalFlowFrameSets(flow, activity, transition); + + return { + artifact, + flow, + flowSets, + frameSummary: createPolyWorldProfileFrameSummary({ + artifact, + transition, + current: { + regionIds: flowSets.currentRegionId === undefined ? [] : [flowSets.currentRegionId], + }, + candidate: { + regionIds: flowSets.selectedRegionIds, + linkIds: flowSets.selectedLinkIds, + portalIds: flowSets.tracedPortalIds, + }, + broad: { + regionIds: flowSets.selectedRegionIds, + linkIds: flowSets.selectedLinkIds, + portalIds: flowSets.tracedPortalIds, + }, + view: { + regionIds: flowSets.selectedRegionIds, + linkIds: flowSets.selectedLinkIds, + portalIds: flowSets.visiblePortalIds, + }, + retained: { + regionIds: flowSets.activityResidentRegionIds, + }, + rejected: { + linkIds: uniqueStrings([...flowSets.closedLinkIds, ...flowSets.blockedLinkIds]), + portalIds: uniqueStrings([...flowSets.rejectedPortalIds, ...flowSets.clippedPortalIds]), + reasonCounts: flowSets.traceStatusCounts, + }, + }), + ...(activity === undefined ? {} : { activity }), + ...(options.debug === false ? {} : { + portalFlowDebug: createPolyWorldPortalFlowDebugSnapshot(topology, flow, options.debug), + }), + ...transition, + }; +} + +function createPortalFlowFrameSets( + flow: PolyWorldPortalFlow, + activity: PolyWorldPortalActivityState | undefined, + transition: PolyWorldTransition, +): PolyWorldPortalFlowFrameSets { + const trace = flow.trace ?? []; + const selectedPortalIds = uniqueStrings(flow.portalIds); + const selectedPortalSet = new Set(selectedPortalIds); + const tracedPortalIds = uniqueStrings(trace.map((entry) => entry.portalId)); + return { + ...(flow.currentRegionId === undefined ? {} : { currentRegionId: flow.currentRegionId }), + selectedRegionIds: uniqueStrings(flow.regionIds), + selectedLinkIds: uniqueStrings(flow.linkIds), + selectedPortalIds, + tracedPortalIds, + rejectedPortalIds: tracedPortalIds.filter((portalId) => !selectedPortalSet.has(portalId)), + visiblePortalIds: uniqueStrings(trace.flatMap((entry) => entry.status === "visible" ? [entry.portalId] : [])), + closedLinkIds: uniqueStrings(trace.flatMap((entry) => entry.status === "closed" ? [entry.linkId] : [])), + blockedLinkIds: uniqueStrings(trace.flatMap((entry) => entry.status === "blocked" ? [entry.linkId] : [])), + clippedPortalIds: uniqueStrings(trace.flatMap((entry) => entry.status === "clipped" ? [entry.portalId] : [])), + traceStatusCounts: countTraceStatuses(trace), + activitySelectedRegionIds: [...(activity?.selectedRegionIds ?? [])], + activityLoadedRegionIds: [...(activity?.loadedRegionIds ?? [])], + activityResidentRegionIds: [...(activity?.residentRegionIds ?? [])], + activityActiveRegionIds: [...(activity?.activeRegionIds ?? [])], + activityRenderedRegionIds: [...(activity?.renderedRegionIds ?? [])], + activityPreloadedRegionIds: [...(activity?.preloadedRegionIds ?? [])], + activityInactiveRegionIds: [...(activity?.inactiveRegionIds ?? [])], + plannedElementIds: uniqueStrings(transition.plan.entries.flatMap((entry) => + entry.elementId === undefined ? [] : [entry.elementId] + )), + }; +} + +function countTraceStatuses( + trace: readonly { status: PolyWorldPortalFlowTraceStatus }[], +): Partial> { + const counts: Partial> = {}; + for (const entry of trace) counts[entry.status] = (counts[entry.status] ?? 0) + 1; + return counts; +} + +function selectionForPortalFlowFramePlan( + topology: PolyWorldTopology, + selection: PolyWorldSelection, + activity: PolyWorldPortalActivityState | undefined, + state: PolyWorldPortalFlowFramePlanRegionState, +): PolyWorldSelection { + if (activity === undefined || state === "selected") return selection; + const selectionWithoutDirectElementSelectors: PolyWorldSelection = { + ...(selection.linkIds === undefined ? {} : { linkIds: selection.linkIds }), + ...(selection.reasons === undefined ? {} : { reasons: selection.reasons }), + ...(selection.data === undefined ? {} : { data: selection.data }), + }; + const regionIds = portalActivityRegionIds(activity, state); + const selectionKeys: string[] = []; + for (const regionId of regionIds) { + const region = topology.regionsById.get(regionId); + for (const selectionKey of region?.selectionKeys ?? []) add(selectionKeys, selectionKey); + } + return { + ...selectionWithoutDirectElementSelectors, + regionIds, + selectionKeys, + reasons: [ + ...(selection.reasons ?? []), + { + id: `poly-world-portal-flow-frame-${state}`, + kind: "portalFlowFrameState", + label: state, + regionIds, + data: { state, profile: "portal-flow" }, + }, + ], + }; +} + +function portalActivityRegionIds( + activity: PolyWorldPortalActivityState, + state: PolyWorldPortalFlowFramePlanRegionState, +): readonly string[] { + if (state === "loaded") return activity.loadedRegionIds; + if (state === "resident") return activity.residentRegionIds; + if (state === "active") return activity.activeRegionIds; + if (state === "rendered") return activity.renderedRegionIds; + if (state === "preloaded") return activity.preloadedRegionIds; + if (state === "inactive") return activity.inactiveRegionIds; + return activity.selectedRegionIds; +} + +function add(values: string[], value: string): void { + if (!values.includes(value)) values.push(value); +} + +function uniqueStrings(values: readonly string[]): string[] { + return [...new Set(values)]; +} diff --git a/packages/world/src/profiles/portalFrame.ts b/packages/world/src/profiles/portalFrame.ts new file mode 100644 index 000000000..35ad545c1 --- /dev/null +++ b/packages/world/src/profiles/portalFrame.ts @@ -0,0 +1,269 @@ +import type { PolyWorldPortalDebugSnapshot, PolyWorldPortalDebugSnapshotOptions } from "../debug"; +import { + createPolyWorldPortalArtifactProof, + createPolyWorldPortalDebugSnapshot, +} from "../debug/portalSnapshot"; +import type { + PolyWorldLayerPlanPolicy, + PolyWorldTransition, + PolyWorldTransitionDebugOptions, + PolyWorldTransitionReadinessOptions, + PolyWorldTransitionStateOptions, +} from "../planner"; +import { planPolyWorldTransition } from "../planner"; +import type { PolyWorldState } from "../state"; +import type { + PolyWorldSelection, + PolyWorldSelectionReason, + PolyWorldSelectionElementRelationExpansionOptions, + PolyWorldTopology, +} from "../topology"; +import type { PolyWorldProfileArtifactProof } from "./artifact"; +import { + createPolyWorldProfileFrameSummary, + type PolyWorldProfileFrameSummary, +} from "./frameSummary"; +import type { + PolyWorldPortalActivityOptions, + PolyWorldPortalActivityState, + PolyWorldPortalActivityTargetState, + PolyWorldPortalSelectionOptions, +} from "./portal"; +import { + resolvePolyWorldPortalActivity, + selectPolyWorldPortalRegions, +} from "./portal"; + +export type PolyWorldPortalFrameStateOptions = PolyWorldTransitionStateOptions; + +export type PolyWorldPortalFramePlanDebugOptions = PolyWorldTransitionDebugOptions; + +export type PolyWorldPortalFrameDebugOptions = Omit< + PolyWorldPortalDebugSnapshotOptions, + "currentRegionId" | "activity" +>; + +export type PolyWorldPortalFramePlanRegionState = "selected" | PolyWorldPortalActivityTargetState; + +export interface PolyWorldPortalFrameOptions extends PolyWorldPortalSelectionOptions { + previousState: PolyWorldState; + policies: readonly PolyWorldLayerPlanPolicy[]; + activity?: false | PolyWorldPortalActivityOptions; + planRegionState?: PolyWorldPortalFramePlanRegionState; + relations?: false | PolyWorldSelectionElementRelationExpansionOptions; + readiness?: PolyWorldTransitionReadinessOptions; + state?: PolyWorldPortalFrameStateOptions; + planDebug?: false | PolyWorldPortalFramePlanDebugOptions; + debug?: false | PolyWorldPortalFrameDebugOptions; +} + +export interface PolyWorldPortalFrameSets { + currentRegionId?: string; + selectedRegionIds: readonly string[]; + selectedLinkIds: readonly string[]; + selectedSelectionKeys: readonly string[]; + selectedElementIds: readonly string[]; + currentRegionIds: readonly string[]; + linkedRegionIds: readonly string[]; + linkedLinkIds: readonly string[]; + visibleRegionIds: readonly string[]; + visibilitySelectionRegionIds: readonly string[]; + visibilitySelectionElementIds: readonly string[]; + explicitLinkIds: readonly string[]; + facingLinkIds: readonly string[]; + closedLinkIds: readonly string[]; + blockedLinkIds: readonly string[]; + activitySelectedRegionIds: readonly string[]; + activityLoadedRegionIds: readonly string[]; + activityResidentRegionIds: readonly string[]; + activityActiveRegionIds: readonly string[]; + activityRenderedRegionIds: readonly string[]; + activityPreloadedRegionIds: readonly string[]; + activityInactiveRegionIds: readonly string[]; + plannedElementIds: readonly string[]; +} + +export interface PolyWorldPortalFrame extends PolyWorldTransition { + artifact: PolyWorldProfileArtifactProof; + selection: PolyWorldSelection; + portalSets: PolyWorldPortalFrameSets; + frameSummary: PolyWorldProfileFrameSummary; + activity?: PolyWorldPortalActivityState; + portalDebug?: PolyWorldPortalDebugSnapshot; +} + +export function planPolyWorldPortalFrame( + topology: PolyWorldTopology, + options: PolyWorldPortalFrameOptions, +): PolyWorldPortalFrame { + const selection = selectPolyWorldPortalRegions(topology, options); + const activity = options.activity === false + ? undefined + : resolvePolyWorldPortalActivity(topology, selection, options.activity); + const transitionSelection = selectionForPortalFramePlan( + topology, + selection, + activity, + options.planRegionState ?? "selected", + ); + const transition = planPolyWorldTransition(topology, { + previousState: options.previousState, + policies: options.policies, + selection: transitionSelection, + relations: options.relations, + readiness: options.readiness, + state: options.state, + debug: options.planDebug, + }); + const artifact = createPolyWorldPortalArtifactProof(topology, selection); + const portalSets = createPortalFrameSets(options, selection, activity, transition); + + return { + artifact, + selection, + portalSets, + frameSummary: createPolyWorldProfileFrameSummary({ + artifact, + transition, + current: { + regionIds: portalSets.currentRegionId === undefined ? [] : [portalSets.currentRegionId], + }, + candidate: { + regionIds: portalSets.selectedRegionIds, + linkIds: portalSets.selectedLinkIds, + elementIds: portalSets.selectedElementIds, + selectionKeys: portalSets.selectedSelectionKeys, + }, + broad: { + regionIds: portalSets.selectedRegionIds, + linkIds: portalSets.selectedLinkIds, + elementIds: portalSets.selectedElementIds, + selectionKeys: portalSets.selectedSelectionKeys, + }, + view: { + regionIds: portalSets.visibleRegionIds.length === 0 ? portalSets.selectedRegionIds : portalSets.visibleRegionIds, + linkIds: portalSets.facingLinkIds, + elementIds: portalSets.visibilitySelectionElementIds, + }, + retained: { + regionIds: portalSets.activityResidentRegionIds, + }, + rejected: { + linkIds: uniqueStrings([...portalSets.closedLinkIds, ...portalSets.blockedLinkIds]), + reasonCounts: { + closed: portalSets.closedLinkIds.length, + blocked: portalSets.blockedLinkIds.length, + }, + }, + }), + ...(activity === undefined ? {} : { activity }), + ...(options.debug === false ? {} : { + portalDebug: createPolyWorldPortalDebugSnapshot(topology, selection, { + ...options.debug, + currentRegionId: options.currentRegionId, + activity, + }), + }), + ...transition, + }; +} + +function createPortalFrameSets( + options: PolyWorldPortalFrameOptions, + selection: PolyWorldSelection, + activity: PolyWorldPortalActivityState | undefined, + transition: PolyWorldTransition, +): PolyWorldPortalFrameSets { + const reasons = selection.reasons ?? []; + return { + ...(options.currentRegionId === undefined ? {} : { currentRegionId: options.currentRegionId }), + selectedRegionIds: uniqueStrings(selection.regionIds ?? []), + selectedLinkIds: uniqueStrings(selection.linkIds ?? []), + selectedSelectionKeys: uniqueStrings(selection.selectionKeys ?? []), + selectedElementIds: uniqueStrings(selection.elementIds ?? []), + currentRegionIds: reasonStrings(reasons, "current", "regionIds"), + linkedRegionIds: reasonStrings(reasons, "linked", "regionIds"), + linkedLinkIds: reasonStrings(reasons, "linked", "linkIds"), + visibleRegionIds: reasonStrings(reasons, "visible", "regionIds"), + visibilitySelectionRegionIds: reasonStrings(reasons, "visibilitySelection", "regionIds"), + visibilitySelectionElementIds: reasonStrings(reasons, "visibilitySelection", "elementIds"), + explicitLinkIds: reasonStrings(reasons, "link", "linkIds"), + facingLinkIds: reasonStrings(reasons, "facing", "linkIds"), + closedLinkIds: reasonStrings(reasons, "closed", "linkIds"), + blockedLinkIds: reasonStrings(reasons, "blocked", "linkIds"), + activitySelectedRegionIds: [...(activity?.selectedRegionIds ?? [])], + activityLoadedRegionIds: [...(activity?.loadedRegionIds ?? [])], + activityResidentRegionIds: [...(activity?.residentRegionIds ?? [])], + activityActiveRegionIds: [...(activity?.activeRegionIds ?? [])], + activityRenderedRegionIds: [...(activity?.renderedRegionIds ?? [])], + activityPreloadedRegionIds: [...(activity?.preloadedRegionIds ?? [])], + activityInactiveRegionIds: [...(activity?.inactiveRegionIds ?? [])], + plannedElementIds: uniqueStrings(transition.plan.entries.flatMap((entry) => + entry.elementId === undefined ? [] : [entry.elementId] + )), + }; +} + +function selectionForPortalFramePlan( + topology: PolyWorldTopology, + selection: PolyWorldSelection, + activity: PolyWorldPortalActivityState | undefined, + state: PolyWorldPortalFramePlanRegionState, +): PolyWorldSelection { + if (activity === undefined || state === "selected") return selection; + const selectionWithoutDirectElementSelectors: PolyWorldSelection = { + ...(selection.linkIds === undefined ? {} : { linkIds: selection.linkIds }), + ...(selection.reasons === undefined ? {} : { reasons: selection.reasons }), + ...(selection.data === undefined ? {} : { data: selection.data }), + }; + const regionIds = portalActivityRegionIds(activity, state); + const selectionKeys: string[] = []; + for (const regionId of regionIds) { + const region = topology.regionsById.get(regionId); + for (const selectionKey of region?.selectionKeys ?? []) add(selectionKeys, selectionKey); + } + return { + ...selectionWithoutDirectElementSelectors, + regionIds, + selectionKeys, + reasons: [ + ...(selection.reasons ?? []), + { + id: `poly-world-portal-frame-${state}`, + kind: "portalFrameState", + label: state, + regionIds, + data: { state }, + }, + ], + }; +} + +function portalActivityRegionIds( + activity: PolyWorldPortalActivityState, + state: PolyWorldPortalFramePlanRegionState, +): readonly string[] { + if (state === "loaded") return activity.loadedRegionIds; + if (state === "resident") return activity.residentRegionIds; + if (state === "active") return activity.activeRegionIds; + if (state === "rendered") return activity.renderedRegionIds; + if (state === "preloaded") return activity.preloadedRegionIds; + if (state === "inactive") return activity.inactiveRegionIds; + return activity.selectedRegionIds; +} + +function add(values: string[], value: string): void { + if (!values.includes(value)) values.push(value); +} + +function reasonStrings( + reasons: readonly PolyWorldSelectionReason[], + kind: string, + field: "regionIds" | "linkIds" | "elementIds", +): string[] { + return uniqueStrings(reasons.flatMap((reason) => reason.kind === kind ? reason[field] ?? [] : [])); +} + +function uniqueStrings(values: readonly string[]): string[] { + return [...new Set(values)]; +} diff --git a/packages/world/src/profiles/profiles.test.ts b/packages/world/src/profiles/profiles.test.ts new file mode 100644 index 000000000..376391a24 --- /dev/null +++ b/packages/world/src/profiles/profiles.test.ts @@ -0,0 +1,4499 @@ +import { describe, expect, it } from "vitest"; +import { createPolyWorldBspDebugSnapshot, createPolyWorldPortalFlowDebugSnapshot } from "../debug"; +import { + createPolyWorldChunkTrackFixture, + createPolyWorldExactPvsFixture, + createPolyWorldFakeRoomGraphFixture, + createPolyWorldPartitionGalleryFixture, +} from "../testing/fixtures"; +import { createPolyWorldTopology, resolvePolyWorldElements } from "../topology"; +import { createPolyWorldState } from "../state"; +import { + auditPolyWorldProfileArtifactProof, + bakePolyWorldBspPvs, + certifyPolyWorldBspTopology, + compilePolyWorldBrushBsp, + compilePolyWorldBsp, + compilePolyWorldPolygonBsp, + createPolyWorldBspTree, + createPolyWorldChunkTree, + createPolyWorldProfileArtifactBundle, + createPolyWorldProfileArtifactProof, + decodePolyWorldBspPvsLeafIds, + decodePolyWorldBspPvsPortalIds, + PolyWorldBspError, + PolyWorldChunkTreeError, + planPolyWorldBspVisibilityFrame, + planPolyWorldChunkStreamingFrame, + planPolyWorldPortalFlowFrame, + planPolyWorldPortalFrame, + resolvePolyWorldBspBakedPvs, + resolvePolyWorldBspLeaf, + resolvePolyWorldBspPvs, + resolvePolyWorldBspViewSurfaceElements, + resolvePolyWorldBspViewPvs, + resolvePolyWorldBspVisibility, + resolvePolyWorldChunkTreeTraversal, + resolvePolyWorldPortalFlow, + selectPolyWorldBspPvs, + selectPolyWorldBspViewPvs, + selectPolyWorldChunkStreaming, + selectPolyWorldChunkStreamingState, + selectPolyWorldChunkWindow, + selectPolyWorldPortalRegions, + summarizePolyWorldBspTopologyProof, + tracePolyWorldBspViewPvs, + type PolyWorldBspChild, + type PolyWorldBspNode, + type PolyWorldBspPortal, +} from "./index"; + +describe("selectPolyWorldPortalRegions", () => { + it("canonicalizes profile artifact kind/source and strips forbidden BSP guarantees", () => { + const portalProof = createPolyWorldProfileArtifactProof({ + profile: "portal-flow", + artifactKind: "compiled-bsp-pvs", + sourceKind: "compiled", + producedBy: "test", + guarantees: [ + "authored-region-link-traversal", + "pvs-metadata-decode-audit", + "tree-root-leaf-reference-audit", + ], + }); + const areaPortalProof = createPolyWorldProfileArtifactProof({ + profile: "area-portals", + artifactKind: "compiled-bsp-pvs", + sourceKind: "compiled", + producedBy: "test", + guarantees: [ + "authored-region-link-traversal", + "camera-frustum-portal-clipping", + "view-clipped-pvs-traversal", + ], + }); + const chunkProof = createPolyWorldProfileArtifactProof({ + profile: "chunk-traversal", + artifactKind: "compiled-bsp-pvs", + sourceKind: "authored", + producedBy: "test", + guarantees: [ + "working-set-state-reporting", + "portal-clipped-baked-pvs", + "view-clipped-pvs-traversal", + ], + }); + + expect(portalProof).toMatchObject({ + profile: "portal-flow", + artifactKind: "authored-area-portal-flow", + sourceKind: "authored-runtime-selection", + guarantees: ["authored-region-link-traversal"], + }); + expect(portalProof.diagnostics.map((diagnostic) => diagnostic.code)).toEqual([ + "poly-world-profile-artifact-kind-mismatch", + "poly-world-profile-artifact-source-kind-mismatch", + "poly-world-profile-artifact-forbidden-guarantee", + "poly-world-profile-artifact-forbidden-guarantee", + ]); + expect(portalProof.diagnostics.map((diagnostic) => diagnostic.id).filter(Boolean)).toEqual([ + "pvs-metadata-decode-audit", + "tree-root-leaf-reference-audit", + ]); + expect(areaPortalProof).toMatchObject({ + profile: "area-portals", + artifactKind: "authored-area-portals", + sourceKind: "authored-runtime-selection", + guarantees: ["authored-region-link-traversal"], + }); + expect(areaPortalProof.diagnostics.map((diagnostic) => diagnostic.id).filter(Boolean)).toEqual([ + "camera-frustum-portal-clipping", + "view-clipped-pvs-traversal", + ]); + expect(chunkProof).toMatchObject({ + profile: "chunk-traversal", + artifactKind: "chunk-working-set", + sourceKind: "authored-runtime-selection", + guarantees: ["working-set-state-reporting"], + }); + expect(chunkProof.diagnostics.map((diagnostic) => diagnostic.id).filter(Boolean)).toEqual([ + "portal-clipped-baked-pvs", + "view-clipped-pvs-traversal", + ]); + expect(auditPolyWorldProfileArtifactProof(portalProof)).toEqual({ + schemaVersion: 1, + profile: "portal-flow", + valid: true, + diagnostics: [], + }); + expect(auditPolyWorldProfileArtifactProof(areaPortalProof)).toEqual({ + schemaVersion: 1, + profile: "area-portals", + valid: true, + diagnostics: [], + }); + expect(auditPolyWorldProfileArtifactProof(chunkProof)).toEqual({ + schemaVersion: 1, + profile: "chunk-traversal", + valid: true, + diagnostics: [], + }); + }); + + it("audits forged profile proof envelopes that overclaim reference guarantees", () => { + const canonical = createPolyWorldProfileArtifactProof({ + profile: "portal-flow", + artifactKind: "authored-area-portal-flow", + sourceKind: "authored-runtime-selection", + producedBy: "test", + guarantees: ["authored-region-link-traversal", "camera-frustum-portal-clipping"], + counts: { portalCount: 2 }, + coverage: { traceCoverage: 1 }, + }); + const forgedPortal = { + ...canonical, + artifactKind: "compiled-bsp-pvs", + sourceKind: "compiled", + guarantees: [...canonical.guarantees, "view-clipped-pvs-traversal"], + counts: { ...canonical.counts, badCount: Number.NaN }, + coverage: { ...canonical.coverage, badCoverage: Number.POSITIVE_INFINITY }, + } as typeof canonical; + const uncertifiedBsp = createPolyWorldProfileArtifactProof({ + profile: "bsp-pvs", + artifactKind: "compiled-bsp-pvs", + sourceKind: "compiled", + producedBy: "test", + guarantees: ["compiled-bsp-pvs"], + knownWeaknesses: ["bsp-certification-failed"], + }); + + expect(auditPolyWorldProfileArtifactProof(canonical).valid).toBe(true); + expect(auditPolyWorldProfileArtifactProof(forgedPortal)).toMatchObject({ + profile: "portal-flow", + valid: false, + diagnostics: [ + expect.objectContaining({ code: "poly-world-profile-artifact-kind-mismatch" }), + expect.objectContaining({ code: "poly-world-profile-artifact-source-kind-mismatch" }), + expect.objectContaining({ + code: "poly-world-profile-artifact-forbidden-guarantee", + id: "view-clipped-pvs-traversal", + }), + expect.objectContaining({ + code: "poly-world-profile-artifact-nonfinite-counts", + id: "badCount", + }), + expect.objectContaining({ + code: "poly-world-profile-artifact-nonfinite-coverage", + id: "badCoverage", + }), + ], + }); + expect(auditPolyWorldProfileArtifactProof(uncertifiedBsp)).toMatchObject({ + profile: "bsp-pvs", + valid: false, + diagnostics: [ + expect.objectContaining({ + code: "poly-world-profile-artifact-uncertified-bsp-guarantees", + }), + ], + }); + }); + + it("binds document artifact refs to audited profile proofs before frames trust them", () => { + const fixture = createPolyWorldPartitionGalleryFixture(); + const artifactRef = fixture.documentInput.profileArtifacts?.[0]; + if (artifactRef === undefined) throw new Error("Missing partition-gallery artifact ref."); + const proof = summarizePolyWorldBspTopologyProof(fixture.tree).artifact; + const bundle = createPolyWorldProfileArtifactBundle({ + entries: [ + { + ref: artifactRef, + proof, + }, + ], + }); + + expect(bundle.valid).toBe(true); + expect(bundle.diagnostics).toEqual([]); + expect(bundle.entries.map((entry) => entry.id)).toEqual(["partition-gallery-bsp"]); + expect(bundle.entriesById.get("partition-gallery-bsp")).toMatchObject({ + valid: true, + ref: { + profile: "bsp-pvs", + artifactKind: "compiled-bsp-pvs", + sourceKind: "compiled", + }, + proof: { + profile: "bsp-pvs", + artifactKind: "compiled-bsp-pvs", + sourceKind: "compiled", + }, + audit: { + valid: true, + }, + }); + expect(bundle.entryIdsByProfile.get("bsp-pvs")).toEqual(["partition-gallery-bsp"]); + expect(bundle.entriesById.get("partition-gallery-bsp")?.proof.guarantees) + .toContain("portal-clipped-baked-pvs"); + }); + + it("rejects artifact bundles when document refs do not match the proof they are handed", () => { + const fakeGraph = createPolyWorldFakeRoomGraphFixture(); + const exactPvs = createPolyWorldExactPvsFixture(); + const artifactRef = fakeGraph.documentInput.profileArtifacts?.[0]; + if (artifactRef === undefined) throw new Error("Missing fake graph artifact ref."); + const proof = summarizePolyWorldBspTopologyProof(exactPvs.tree).artifact; + const bundle = createPolyWorldProfileArtifactBundle({ + entries: [ + { + id: "fake-portal-flow", + ref: artifactRef, + proof, + }, + { + id: "fake-portal-flow", + ref: artifactRef, + proof, + }, + ], + }); + + expect(bundle.valid).toBe(false); + expect(bundle.entriesById.get("fake-portal-flow")?.valid).toBe(false); + expect(bundle.entryIdsByProfile.get("bsp-pvs")).toEqual(["fake-portal-flow", "fake-portal-flow"]); + expect(bundle.diagnostics).toEqual(expect.arrayContaining([ + expect.objectContaining({ + code: "poly-world-profile-artifact-bundle-profile-mismatch", + id: "fake-portal-flow", + }), + expect.objectContaining({ + code: "poly-world-profile-artifact-bundle-kind-mismatch", + id: "fake-portal-flow", + }), + expect.objectContaining({ + code: "poly-world-profile-artifact-bundle-source-kind-mismatch", + id: "fake-portal-flow", + }), + expect.objectContaining({ + code: "poly-world-profile-artifact-bundle-producer-mismatch", + id: "fake-portal-flow", + }), + expect.objectContaining({ + code: "poly-world-profile-artifact-bundle-duplicate-id", + id: "fake-portal-flow", + }), + ])); + }); + + it("selects current, linked, app-visible, and facing-link regions with diagnostic reasons", () => { + const topology = createPolyWorldTopology({ + regions: [ + { id: "start", selectionKeys: ["faces:start"] }, + { id: "bend", selectionKeys: ["faces:bend"] }, + { id: "vault", selectionKeys: ["faces:vault"] }, + { id: "sky", selectionKeys: ["faces:sky"] }, + ], + links: [ + { id: "start-bend", fromRegionId: "start", toRegionId: "bend", selectionKeys: ["portal:start-bend"] }, + { id: "bend-vault", fromRegionId: "bend", toRegionId: "vault", selectionKeys: ["portal:bend-vault"] }, + ], + elements: [ + { id: "start-world", regionIds: ["start"], layers: ["world"] }, + { id: "bend-world", regionIds: ["bend"], layers: ["world"] }, + { id: "vault-keyed-faces", selectionKeys: ["faces:vault"], layers: ["world"] }, + { id: "two-region-door", regionIds: ["start", "bend"], regionMatch: "all", layers: ["world"] }, + { id: "sky-faces", selectionKeys: ["faces:sky"], layers: ["sky"] }, + ], + }); + + const selection = selectPolyWorldPortalRegions(topology, { + currentRegionId: "start", + visibleRegionIds: ["vault"], + facingLinkIds: ["bend-vault"], + reasonLabels: { + current: "camera-region", + linked: "connected-region", + visible: "source-visible-region", + facing: "facing-link", + }, + }); + const resolution = resolvePolyWorldElements(topology, selection); + + expect(selection.regionIds).toEqual(["start", "bend", "vault"]); + expect(selection.linkIds).toEqual(["start-bend", "bend-vault"]); + expect(selection.selectionKeys).toEqual([ + "portal:start-bend", + "portal:bend-vault", + "faces:start", + "faces:bend", + "faces:vault", + ]); + expect(selection.reasons?.map((reason) => reason.label)).toEqual([ + "camera-region", + "connected-region", + "source-visible-region", + "facing-link", + "selection-key", + ]); + expect(resolution.elementIds).toEqual([ + "start-world", + "bend-world", + "vault-keyed-faces", + "two-region-door", + ]); + }); + + it("keeps closed and blocked room links out of linked traversal while reporting diagnostics", () => { + const topology = createPolyWorldTopology({ + regions: [ + { id: "studio", selectionKeys: ["faces:studio"] }, + { id: "gallery", selectionKeys: ["faces:gallery"] }, + { id: "vault", selectionKeys: ["faces:vault"] }, + { id: "engine", selectionKeys: ["faces:engine"] }, + ], + links: [ + { id: "studio-gallery", fromRegionId: "studio", toRegionId: "gallery", selectionKeys: ["portal:studio-gallery"] }, + { id: "gallery-vault", fromRegionId: "gallery", toRegionId: "vault", selectionKeys: ["portal:gallery-vault"] }, + { id: "gallery-engine", fromRegionId: "gallery", toRegionId: "engine", selectionKeys: ["portal:gallery-engine"] }, + ], + elements: [ + { id: "studio-shell", regionIds: ["studio"], layers: ["world"] }, + { id: "gallery-shell", regionIds: ["gallery"], layers: ["world"] }, + { id: "vault-shell", regionIds: ["vault"], layers: ["world"] }, + { id: "engine-shell", regionIds: ["engine"], layers: ["world"] }, + { id: "gallery-vault-frame", selectionKeys: ["portal:gallery-vault"], layers: ["world"] }, + ], + }); + + const selection = selectPolyWorldPortalRegions(topology, { + currentRegionId: "studio", + linkedDepth: 2, + linkState: { + "gallery-vault": "closed", + "gallery-engine": "blocked", + }, + }); + const resolution = resolvePolyWorldElements(topology, selection); + const forced = selectPolyWorldPortalRegions(topology, { + currentRegionId: "studio", + linkedDepth: 2, + includeClosedLinks: true, + linkState: () => "closed", + }); + + expect(selection.regionIds).toEqual(["studio", "gallery"]); + expect(selection.linkIds).toEqual(["studio-gallery"]); + expect(selection.selectionKeys).toEqual(["portal:studio-gallery", "faces:studio", "faces:gallery"]); + expect(selection.reasons?.map((reason) => [reason.kind, reason.linkIds])).toEqual([ + ["current", undefined], + ["linked", ["studio-gallery"]], + ["closed", ["gallery-vault"]], + ["blocked", ["gallery-engine"]], + ["selectionKey", undefined], + ]); + expect(resolution.elementIds).toEqual(["studio-shell", "gallery-shell"]); + expect(forced.regionIds).toEqual(["studio", "gallery", "vault", "engine"]); + expect(forced.linkIds).toEqual(["studio-gallery", "gallery-vault", "gallery-engine"]); + }); + + it("merges an external visibility selection into authored portal selection", () => { + const topology = createPolyWorldTopology({ + regions: [ + { id: "studio", selectionKeys: ["faces:studio"] }, + { id: "vault", selectionKeys: ["faces:vault"] }, + ], + elements: [ + { id: "studio-shell", regionIds: ["studio"], layers: ["world"] }, + { id: "vault-marker", selectionKeys: ["marker:vault"], layers: ["debug"] }, + ], + }); + + const selection = selectPolyWorldPortalRegions(topology, { + currentRegionId: "studio", + includeLinked: false, + visibilitySelection: { + regionIds: ["vault"], + selectionKeys: ["marker:vault"], + elementIds: ["vault-marker"], + reasons: [ + { + id: "bsp-view", + kind: "viewPvs", + label: "bsp-view", + regionIds: ["vault"], + }, + ], + }, + reasonLabels: { + visibilitySelection: "external-visible", + }, + }); + const resolution = resolvePolyWorldElements(topology, selection); + + expect(selection.regionIds).toEqual(["studio", "vault"]); + expect(selection.selectionKeys).toEqual(["marker:vault", "faces:studio", "faces:vault"]); + expect(selection.elementIds).toEqual(["vault-marker"]); + expect(selection.reasons?.map((reason) => [reason.kind, reason.label])).toEqual([ + ["current", "current"], + ["viewPvs", "bsp-view"], + ["visibilitySelection", "external-visible"], + ["selectionKey", "selection-key"], + ]); + expect(resolution.elementIds).toEqual(["studio-shell", "vault-marker"]); + }); + + it("plans a portal frame from rendered activity while keeping selected and activity debug separate", () => { + const topology = createPolyWorldTopology({ + regions: [ + { id: "studio", selectionKeys: ["faces:studio"] }, + { id: "gallery", selectionKeys: ["faces:gallery"] }, + { id: "vault", selectionKeys: ["faces:vault"] }, + ], + links: [ + { id: "studio-gallery", fromRegionId: "studio", toRegionId: "gallery" }, + { id: "gallery-vault", fromRegionId: "gallery", toRegionId: "vault" }, + ], + elements: [ + { id: "resident-root", selectionKeys: ["root:resident"], layers: ["resident"] }, + { + id: "studio-shell", + regionIds: ["studio"], + selectionKeys: ["faces:studio"], + containerId: "resident-root", + layers: ["render"], + }, + { + id: "gallery-shell", + regionIds: ["gallery"], + selectionKeys: ["faces:gallery"], + containerId: "resident-root", + layers: ["render"], + }, + { + id: "vault-shell", + regionIds: ["vault"], + selectionKeys: ["faces:vault"], + containerId: "resident-root", + layers: ["render"], + }, + ], + }); + const previousState = createPolyWorldState(topology, { selection: { regionIds: ["studio"] } }); + + const frame = planPolyWorldPortalFrame(topology, { + previousState, + currentRegionId: "studio", + linkedDepth: 2, + activity: { + selectedTargetState: "resident", + renderedRegionIds: ["studio", "gallery"], + }, + planRegionState: "rendered", + relations: {}, + policies: [ + { id: "resident", layer: "resident", elementLayers: ["resident"], phase: "mount" }, + { id: "render", layer: "render", elementLayers: ["render"] }, + ], + planDebug: { includeEntries: false }, + debug: { listLimit: 8 }, + }); + + expect(frame.selection.regionIds).toEqual(["studio", "gallery", "vault"]); + expect(frame.artifact).toMatchObject({ + profile: "area-portals", + artifactKind: "authored-area-portals", + sourceKind: "authored-runtime-selection", + producedBy: "selectPolyWorldPortalRegions", + counts: { + selectedRegionCount: 3, + selectedLinkCount: 2, + }, + }); + expect(frame.artifact.knownWeaknesses).toContain("not-camera-frustum-portal-clipping"); + expect(frame.activity?.residentRegionIds).toEqual(["studio", "gallery", "vault"]); + expect(frame.activity?.renderedRegionIds).toEqual(["studio", "gallery"]); + expect(frame.planningSelection?.regionIds).toEqual(["studio", "gallery"]); + expect(frame.planningSelection?.elementIds).toEqual(["resident-root"]); + expect(frame.debug?.planningSelection?.regionIds).toEqual(["studio", "gallery"]); + expect(frame.debug?.planningSelection?.elementIds).toEqual(["resident-root"]); + expect(frame.nextState.selectedRegionIds).toEqual(["gallery", "studio"]); + expect(frame.nextState.selectedElementIds).toEqual(["resident-root"]); + expect(frame.nextState.resolvedElementIds).toEqual(["gallery-shell", "resident-root", "studio-shell"]); + expect(frame.portalSets).toEqual({ + currentRegionId: "studio", + selectedRegionIds: ["studio", "gallery", "vault"], + selectedLinkIds: ["studio-gallery", "gallery-vault"], + selectedSelectionKeys: ["faces:studio", "faces:gallery", "faces:vault"], + selectedElementIds: [], + currentRegionIds: ["studio"], + linkedRegionIds: ["gallery", "vault"], + linkedLinkIds: ["studio-gallery", "gallery-vault"], + visibleRegionIds: [], + visibilitySelectionRegionIds: [], + visibilitySelectionElementIds: [], + explicitLinkIds: [], + facingLinkIds: [], + closedLinkIds: [], + blockedLinkIds: [], + activitySelectedRegionIds: ["studio", "gallery", "vault"], + activityLoadedRegionIds: ["studio", "gallery", "vault"], + activityResidentRegionIds: ["studio", "gallery", "vault"], + activityActiveRegionIds: ["studio", "gallery"], + activityRenderedRegionIds: ["studio", "gallery"], + activityPreloadedRegionIds: [], + activityInactiveRegionIds: ["vault"], + plannedElementIds: ["resident-root", "gallery-shell", "studio-shell"], + }); + expect(frame.frameSummary).toMatchObject({ + profile: "area-portals", + artifactKind: "authored-area-portals", + current: { regionIds: ["studio"] }, + candidate: { regionIds: ["gallery", "studio", "vault"] }, + broad: { regionIds: ["gallery", "studio", "vault"] }, + retained: { regionIds: ["gallery", "studio", "vault"] }, + planning: { + regionIds: ["gallery", "studio"], + elementIds: ["resident-root"], + }, + state: { + selectedRegionIds: ["gallery", "studio"], + resolvedElementIds: ["gallery-shell", "resident-root", "studio-shell"], + }, + plan: { + entryCount: 3, + plannedElementIds: ["gallery-shell", "resident-root", "studio-shell"], + }, + }); + expect(frame.plan.entries.map((entry) => [entry.layer, entry.elementId, entry.action])).toEqual([ + ["resident", "resident-root", "show"], + ["render", "gallery-shell", "show"], + ["render", "studio-shell", "retain"], + ]); + expect(frame.portalDebug?.regions.selectedRegionIds.count).toBe(3); + expect(frame.portalDebug?.activity?.renderedRegionIds.values).toEqual(["studio", "gallery"]); + expect(frame.debug?.plan.entryCount).toBe(3); + }); + + it("plans from external BSP visibility without leaking non-rendered direct elements", () => { + const topology = createPolyWorldTopology({ + regions: [ + { id: "studio", selectionKeys: ["faces:studio"] }, + { id: "gallery", selectionKeys: ["faces:gallery"] }, + { id: "vault", selectionKeys: ["faces:vault"] }, + ], + links: [ + { id: "studio-gallery", fromRegionId: "studio", toRegionId: "gallery" }, + { id: "gallery-vault", fromRegionId: "gallery", toRegionId: "vault" }, + ], + elements: [ + { id: "studio-shell", regionIds: ["studio"], layers: ["render"] }, + { id: "gallery-shell", regionIds: ["gallery"], layers: ["render"] }, + { id: "vault-shell", regionIds: ["vault"], layers: ["render"] }, + { id: "vault-direct-marker", selectionKeys: ["marker:vault"], layers: ["render"] }, + ], + }); + const previousState = createPolyWorldState(topology, { selection: { regionIds: ["studio"] } }); + + const frame = planPolyWorldPortalFrame(topology, { + previousState, + currentRegionId: "studio", + linkedDepth: 2, + visibilitySelection: { + regionIds: ["vault"], + elementIds: ["vault-direct-marker"], + reasons: [ + { + id: "bsp-view", + kind: "viewPvs", + label: "bsp-view", + regionIds: ["vault"], + elementIds: ["vault-direct-marker"], + }, + ], + }, + activity: { + selectedTargetState: "resident", + renderedRegionIds: ["studio", "gallery"], + }, + planRegionState: "rendered", + policies: [{ id: "render", layer: "render", elementLayers: ["render"] }], + debug: { listLimit: 8 }, + }); + + expect(frame.selection.regionIds).toEqual(["studio", "gallery", "vault"]); + expect(frame.selection.elementIds).toEqual(["vault-direct-marker"]); + expect(frame.portalDebug?.selection.reasonKinds).toMatchObject({ + current: 1, + linked: 1, + viewPvs: 1, + visibilitySelection: 1, + }); + expect(frame.planningSelection?.regionIds).toEqual(["studio", "gallery"]); + expect(frame.planningSelection?.elementIds).toBeUndefined(); + expect(frame.debug?.planningSelection?.regionIds).toEqual(["studio", "gallery"]); + expect(frame.debug?.planningSelection?.elementIds).toEqual([]); + expect(frame.nextState.selectedRegionIds).toEqual(["gallery", "studio"]); + expect(frame.nextState.resolvedElementIds).toEqual(["gallery-shell", "studio-shell"]); + expect(frame.nextState.resolvedElementIds).not.toContain("vault-direct-marker"); + expect(frame.nextState.resolvedElementIds).not.toContain("vault-shell"); + expect(frame.portalSets.visibilitySelectionRegionIds).toEqual(["vault"]); + expect(frame.portalSets.visibilitySelectionElementIds).toEqual(["vault-direct-marker"]); + expect(frame.portalSets.plannedElementIds).toEqual(["gallery-shell", "studio-shell"]); + }); +}); + +describe("resolvePolyWorldPortalFlow", () => { + const flowPortals = [ + { + id: "studio-gallery-portal", + linkId: "studio-gallery", + bounds: { min: [4, 1.5, 0], max: [4, 2.5, 2] }, + selectionKeys: ["portal:studio-gallery"], + }, + { + id: "gallery-vault-portal", + linkId: "gallery-vault", + bounds: { min: [8, 1, 0], max: [8, 3, 2] }, + selectionKeys: ["portal:gallery-vault"], + }, + { + id: "gallery-archive-portal", + linkId: "gallery-archive", + bounds: { min: [5, 4, 0], max: [7, 4, 2] }, + selectionKeys: ["portal:gallery-archive"], + }, + ]; + + function portalFlowTopology() { + return createPolyWorldTopology({ + regions: [ + { id: "studio", bounds: { min: [0, 0, 0], max: [4, 4, 3] }, selectionKeys: ["faces:studio"] }, + { id: "gallery", bounds: { min: [4, 0, 0], max: [8, 4, 3] }, selectionKeys: ["faces:gallery"] }, + { id: "vault", bounds: { min: [8, 0, 0], max: [12, 4, 3] }, selectionKeys: ["faces:vault"] }, + { id: "archive", bounds: { min: [4, 4, 0], max: [8, 8, 3] }, selectionKeys: ["faces:archive"] }, + ], + links: [ + { id: "studio-gallery", fromRegionId: "studio", toRegionId: "gallery", selectionKeys: ["link:studio-gallery"] }, + { id: "gallery-vault", fromRegionId: "gallery", toRegionId: "vault", selectionKeys: ["link:gallery-vault"] }, + { id: "gallery-archive", fromRegionId: "gallery", toRegionId: "archive", selectionKeys: ["link:gallery-archive"] }, + ], + elements: [ + { id: "studio-shell", regionIds: ["studio"], layers: ["render"] }, + { id: "gallery-shell", regionIds: ["gallery"], layers: ["render"] }, + { id: "vault-shell", regionIds: ["vault"], layers: ["render"] }, + { id: "archive-shell", regionIds: ["archive"], layers: ["render"] }, + ], + }); + } + + it("clips authored region portals through the camera view before selecting regions", () => { + const topology = portalFlowTopology(); + const flow = resolvePolyWorldPortalFlow(topology, { + point: [2, 2, 1], + currentRegionId: "studio", + forward: [1, 0, 0], + up: [0, 0, 1], + fovDegrees: 70, + aspect: 1, + maxDepth: 4, + portals: flowPortals, + includeTrace: true, + }); + const resolution = resolvePolyWorldElements(topology, flow.selection); + + expect(flow.currentRegionId).toBe("studio"); + expect(flow.regionIds).toEqual(["studio", "gallery", "vault"]); + expect(flow.linkIds).toEqual(["studio-gallery", "gallery-vault"]); + expect(flow.portalIds).toEqual(["studio-gallery-portal", "gallery-vault-portal"]); + expect(flow.selectionKeys).toEqual([ + "link:studio-gallery", + "portal:studio-gallery", + "link:gallery-vault", + "portal:gallery-vault", + "faces:studio", + "faces:gallery", + "faces:vault", + ]); + expect(flow.trace?.map((entry) => [entry.linkId, entry.status])).toEqual([ + ["studio-gallery", "visible"], + ["gallery-vault", "visible"], + ["gallery-archive", "clipped"], + ]); + expect(resolution.elementIds).toEqual(["studio-shell", "gallery-shell", "vault-shell"]); + }); + + it("keeps authored portal flow separate from broad phase, closed links, and looking away", () => { + const topology = portalFlowTopology(); + const away = resolvePolyWorldPortalFlow(topology, { + point: [2, 2, 1], + currentRegionId: "studio", + forward: [-1, 0, 0], + portals: flowPortals, + includeTrace: true, + }); + const closed = resolvePolyWorldPortalFlow(topology, { + point: [2, 2, 1], + currentRegionId: "studio", + forward: [1, 0, 0], + portals: flowPortals, + portalState: { "studio-gallery": "closed" }, + includeTrace: true, + }); + const broad = resolvePolyWorldPortalFlow(topology, { + point: [2, 2, 1], + currentRegionId: "studio", + forward: [1, 0, 0], + portals: flowPortals, + broadRegionIds: ["studio", "gallery"], + includeTrace: true, + }); + + expect(away.regionIds).toEqual(["studio"]); + expect(away.trace?.map((entry) => [entry.linkId, entry.status])).toEqual([ + ["studio-gallery", "clipped"], + ]); + expect(closed.regionIds).toEqual(["studio"]); + expect(closed.trace?.map((entry) => [entry.linkId, entry.status])).toEqual([ + ["studio-gallery", "closed"], + ]); + expect(closed.selection.reasons?.map((reason) => [reason.kind, reason.linkIds])).toEqual([ + ["current", undefined], + ["visible", []], + ["closed", ["studio-gallery"]], + ["selectionKey", undefined], + ]); + expect(broad.regionIds).toEqual(["studio", "gallery"]); + expect(broad.trace?.map((entry) => [entry.linkId, entry.status])).toEqual([ + ["studio-gallery", "visible"], + ["gallery-vault", "outside-broad-phase"], + ["gallery-archive", "outside-broad-phase"], + ]); + }); + + it("summarizes authored portal flow traces without presenting them as BSP", () => { + const topology = portalFlowTopology(); + const flow = resolvePolyWorldPortalFlow(topology, { + point: [2, 2, 1], + currentRegionId: "studio", + forward: [1, 0, 0], + up: [0, 0, 1], + fovDegrees: 70, + aspect: 1, + maxDepth: 4, + portals: flowPortals, + includeTrace: true, + }); + const snapshot = createPolyWorldPortalFlowDebugSnapshot(topology, flow, { + listLimit: 2, + includeTraceEntries: true, + entryLimit: 2, + metadata: { example: "portal-flow" }, + }); + + expect(snapshot.topology).toEqual({ + regionCount: 4, + linkCount: 3, + profile: "portal-flow", + }); + expect(snapshot.proof).toMatchObject({ + profile: "portal-flow", + artifactKind: "authored-area-portal-flow", + sourceKind: "authored-runtime-selection", + producedBy: "resolvePolyWorldPortalFlow", + counts: { + regionCount: 4, + linkCount: 3, + selectedRegionCount: 3, + hiddenRegionCount: 1, + selectedPortalCount: 2, + rejectedPortalCount: 1, + traceEntryCount: 3, + }, + }); + expect(snapshot.proof.guarantees).toContain("camera-frustum-portal-clipping"); + expect(snapshot.proof.knownWeaknesses).toContain("not-compiled-bsp-pvs"); + expect(snapshot.current.regionId).toBe("studio"); + expect(snapshot.regions.selectedRegionIds).toEqual({ + values: ["studio", "gallery"], + count: 3, + omitted: 1, + }); + expect(snapshot.regions.hiddenRegionIds).toEqual({ + values: ["archive"], + count: 1, + omitted: 0, + }); + expect(snapshot.links.selectedLinkIds.count).toBe(2); + expect(snapshot.portals.selectedPortalIds.count).toBe(2); + expect(snapshot.portals.rejectedPortalIds.values).toEqual(["gallery-archive-portal"]); + expect(snapshot.selection.reasonKinds).toMatchObject({ + current: 1, + visible: 1, + selectionKey: 1, + }); + expect(snapshot.trace?.statusCounts).toEqual({ + visible: 2, + clipped: 1, + }); + expect(snapshot.trace?.entries?.map((entry) => [entry.linkId, entry.status])).toEqual([ + ["studio-gallery", "visible"], + ["gallery-vault", "visible"], + ]); + expect(snapshot.trace?.omittedEntries).toBe(1); + expect(snapshot.metadata).toEqual({ example: "portal-flow" }); + }); + + it("plans authored portal-flow frames with activity-narrowed render selection", () => { + const topology = portalFlowTopology(); + const previousState = createPolyWorldState(topology, { + selection: { regionIds: ["studio"] }, + }); + const frame = planPolyWorldPortalFlowFrame(topology, { + previousState, + policies: [{ id: "render", layer: "render", elementLayers: ["render"] }], + point: [2, 2, 1], + currentRegionId: "studio", + forward: [1, 0, 0], + up: [0, 0, 1], + fovDegrees: 70, + aspect: 1, + maxDepth: 4, + portals: flowPortals, + activity: { + selectedTargetState: "resident", + renderedRegionIds: ["studio", "gallery"], + }, + planRegionState: "rendered", + debug: { includeTraceEntries: true, entryLimit: 4 }, + planDebug: { includeEntries: false }, + }); + + expect(frame.flow.regionIds).toEqual(["studio", "gallery", "vault"]); + expect(frame.artifact).toMatchObject({ + profile: "portal-flow", + artifactKind: "authored-area-portal-flow", + sourceKind: "authored-runtime-selection", + producedBy: "resolvePolyWorldPortalFlow", + counts: { + selectedRegionCount: 3, + selectedPortalCount: 2, + rejectedPortalCount: 1, + }, + }); + expect(frame.artifact.guarantees).toContain("camera-frustum-portal-clipping"); + expect(frame.activity?.residentRegionIds).toEqual(["studio", "gallery", "vault"]); + expect(frame.activity?.renderedRegionIds).toEqual(["studio", "gallery"]); + expect(frame.planningSelection?.regionIds).toEqual(["studio", "gallery"]); + expect(frame.planningSelection?.selectionKeys).toEqual(["faces:studio", "faces:gallery"]); + expect(frame.nextState.selectedRegionIds).toEqual(["gallery", "studio"]); + expect(frame.nextState.resolvedElementIds).toEqual(["gallery-shell", "studio-shell"]); + expect(frame.nextState.resolvedElementIds).not.toContain("vault-shell"); + expect(frame.flowSets).toEqual({ + currentRegionId: "studio", + selectedRegionIds: ["studio", "gallery", "vault"], + selectedLinkIds: ["studio-gallery", "gallery-vault"], + selectedPortalIds: ["studio-gallery-portal", "gallery-vault-portal"], + tracedPortalIds: ["studio-gallery-portal", "gallery-vault-portal", "gallery-archive-portal"], + rejectedPortalIds: ["gallery-archive-portal"], + visiblePortalIds: ["studio-gallery-portal", "gallery-vault-portal"], + closedLinkIds: [], + blockedLinkIds: [], + clippedPortalIds: ["gallery-archive-portal"], + traceStatusCounts: { + visible: 2, + clipped: 1, + }, + activitySelectedRegionIds: ["studio", "gallery", "vault"], + activityLoadedRegionIds: ["studio", "gallery", "vault"], + activityResidentRegionIds: ["studio", "gallery", "vault"], + activityActiveRegionIds: ["studio", "gallery"], + activityRenderedRegionIds: ["studio", "gallery"], + activityPreloadedRegionIds: [], + activityInactiveRegionIds: ["vault", "archive"], + plannedElementIds: ["gallery-shell", "studio-shell"], + }); + expect(frame.frameSummary).toMatchObject({ + profile: "portal-flow", + artifactKind: "authored-area-portal-flow", + current: { regionIds: ["studio"] }, + candidate: { + regionIds: ["gallery", "studio", "vault"], + portalIds: ["gallery-archive-portal", "gallery-vault-portal", "studio-gallery-portal"], + }, + view: { + regionIds: ["gallery", "studio", "vault"], + portalIds: ["gallery-vault-portal", "studio-gallery-portal"], + }, + rejected: { + portalIds: ["gallery-archive-portal"], + reasonCounts: { clipped: 1, visible: 2 }, + }, + planning: { + regionIds: ["gallery", "studio"], + selectionKeys: ["faces:gallery", "faces:studio"], + }, + plan: { + plannedElementIds: ["gallery-shell", "studio-shell"], + }, + }); + expect(frame.portalFlowDebug?.topology.profile).toBe("portal-flow"); + expect(frame.portalFlowDebug?.trace?.statusCounts).toEqual({ + visible: 2, + clipped: 1, + }); + expect(frame.portalFlowDebug?.trace?.entries?.map((entry) => [entry.linkId, entry.status])).toEqual([ + ["studio-gallery", "visible"], + ["gallery-vault", "visible"], + ["gallery-archive", "clipped"], + ]); + expect(frame.debug?.planningSelection?.regionIds).toEqual(["studio", "gallery"]); + expect(frame.debug?.planningSelection?.selectionKeys).toEqual(["faces:studio", "faces:gallery"]); + }); +}); + +describe("selectPolyWorldBspPvs", () => { + it("compiles solid brush bounds into empty and solid BSP leaves without portals through blockers", () => { + const result = compilePolyWorldBrushBsp({ + worldBounds: { min: [0, 0, 0], max: [3, 1, 1] }, + brushes: [{ id: "block", bounds: { min: [1, 0, 0], max: [2, 1, 1] } }], + regions: [ + { id: "left", bounds: { min: [0, 0, 0], max: [1, 1, 1] }, elementIds: ["left-room"] }, + { id: "right", bounds: { min: [2, 0, 0], max: [3, 1, 1] }, elementIds: ["right-room"] }, + ], + splitIdPrefix: "test-brush", + pvs: { projection: "xy" }, + }); + + const left = resolvePolyWorldBspLeaf(result.tree, [0.5, 0.5, 0.5]); + const solid = resolvePolyWorldBspLeaf(result.tree, [1.5, 0.5, 0.5]); + const right = resolvePolyWorldBspLeaf(result.tree, [2.5, 0.5, 0.5]); + + const gridNode = findBspNode(result.tree.root, (node) => + node.data?.axis !== undefined || !node.id.includes("-plane-") + ); + const nodes = collectBspNodes(result.tree.root); + + expect(result.tree.data).toMatchObject({ + compiled: true, + compiler: "brush-bsp", + partition: "recursive-plane", + leafBuilder: "recursive-convex-halfspace", + portalBuilder: "leaf-face-overlap", + }); + const certification = certifyPolyWorldBspTopology(result.tree); + expect(certification).toMatchObject({ + profile: "bsp-pvs", + certified: true, + diagnostics: [], + proof: { + artifact: { + profile: "bsp-pvs", + artifactKind: "compiled-bsp-pvs", + sourceKind: "compiled", + producedBy: "brush-bsp", + counts: { + leafCount: 3, + portalCount: 0, + bakedPvsCount: 3, + }, + coverage: { + rootLeafReferenceCoverage: 1, + bakedPvsCoverage: 1, + }, + }, + }, + }); + expect(certification.proof.artifact.guarantees).toContain("tree-root-leaf-reference-audit"); + expect(certification.proof.artifact.guarantees).toContain("compiled-bsp-pvs"); + expect(certification.proof.artifact.guarantees).toContain("portal-clipped-baked-pvs"); + expect(certification.proof.artifact.guarantees).not.toContain("baked-pvs-bitsets"); + expect(certification.proof.artifact.guarantees).toContain("pvs-direct-adjacency-audit"); + expect(certification.proof.artifact.knownWeaknesses).toContain("not-full-qbsp-vis-parity"); + expect(summarizePolyWorldBspTopologyProof(result.tree)).toMatchObject({ + profile: "bsp-pvs", + compiler: { + id: "brush-bsp", + compiled: true, + partition: "recursive-plane", + leafBuilder: "recursive-convex-halfspace", + portalBuilder: "leaf-face-overlap", + }, + tree: { + leafCount: 3, + portalCount: 0, + rootLeafRefCount: 3, + uniqueRootLeafRefCount: 3, + referencesEveryLeafOnce: true, + }, + leaves: { + solidCount: 1, + emptyCount: 2, + renderableCount: 2, + bakedPvsCount: 3, + bakedPvsCoverage: 1, + }, + portals: { + generatedCount: 0, + candidateCount: 0, + rejectedCandidateCount: 0, + }, + pvs: { + level: "portal-clipped-baked-pvs", + method: "portal-clipped-baked", + source: "polycss-world", + completeness: "complete", + indexed: true, + indexLeafCount: 3, + indexPortalCount: 0, + indexLeafCoverage: 1, + indexPortalCoverage: 0, + bakedLeafCount: 3, + bakedLeafCoverage: 1, + complete: true, + }, + evidence: { + validatedBy: "createPolyWorldBspTree", + guarantees: [ + "validated-tree-root-references", + "validated-portal-endpoints", + "validated-portal-leaf-adjacency", + "validated-pvs-bitset-widths", + "validated-pvs-direct-adjacency", + "validated-pvs-metadata", + ], + }, + }); + expect(result.emptyLeafIds).toHaveLength(2); + expect(result.solidLeafIds).toHaveLength(1); + expect(result.portals).toHaveLength(0); + expect(gridNode).toBeUndefined(); + expect(nodes.length).toBeGreaterThan(0); + expect(nodes.every((node) => node.data?.compiler === "brush-bsp")).toBe(true); + expect(nodes.every((node) => node.data?.partition === "recursive-plane")).toBe(true); + expect(nodes.some((node) => + node.data?.splitterSource === "brush" && node.data?.splitterSourceId === "block" + )).toBe(true); + expect(nodes.every((node) => + node.data?.splitterSource === "brush" || node.data?.splitterSource === "region" + )).toBe(true); + expect(result.tree.leaves.every((leaf) => /^test-brush-leaf-\d+$/.test(leaf.id))).toBe(true); + expect(left?.leaf.regionId).toBe("left"); + expect(left?.leaf.elementIds).toEqual(["left-room"]); + expect(solid?.leaf.data?.solid).toBe(true); + expect(solid?.leaf.data?.brushIds).toEqual(["block"]); + expect(right?.leaf.regionId).toBe("right"); + expect(decodeTestBitset(result.tree.pvsIndex?.leafIds ?? [], left?.leaf.pvs?.leafBits)).toEqual([left?.leafId]); + expect(decodeTestBitset(result.tree.pvsIndex?.leafIds ?? [], right?.leaf.pvs?.leafBits)).toEqual([right?.leafId]); + expect(left?.leaf.elementIds).toEqual(["left-room"]); + }); + + it("labels authored loose and unavailable PVS without compiled or baked guarantees", () => { + const pvsIndex = createTestPvsIndex(["a", "b"], []); + const authoredLooseTree = createPolyWorldBspTree({ + root: { + id: "root", + plane: { normal: [1, 0, 0], distance: 1 }, + back: { leafId: "a" }, + front: { leafId: "b" }, + }, + leaves: [ + { + id: "a", + regionId: "a", + pvs: { + leafBits: testBitset(2, [0]), + portalBits: testBitset(0, []), + regionIds: ["a"], + linkIds: [], + selectionKeys: [], + elementIds: [], + }, + }, + { id: "b", regionId: "b" }, + ], + pvsIndex, + data: { compiler: "authored" }, + }); + const noPvsTree = createPolyWorldBspTree({ + root: { + id: "root", + plane: { normal: [1, 0, 0], distance: 1 }, + back: { leafId: "a" }, + front: { leafId: "b" }, + }, + leaves: [ + { id: "a", regionId: "a" }, + { id: "b", regionId: "b" }, + ], + data: { compiler: "authored" }, + }); + + const looseProof = summarizePolyWorldBspTopologyProof(authoredLooseTree); + const noPvsProof = summarizePolyWorldBspTopologyProof(noPvsTree); + + expect(looseProof.pvs).toMatchObject({ + level: "authored-loose-pvs", + method: "authored-loose", + source: "authored", + completeness: "partial", + indexed: true, + bakedLeafCount: 1, + complete: false, + }); + expect(looseProof.artifact.guarantees).toEqual([ + "tree-root-leaf-reference-audit", + "portal-endpoint-audit", + "portal-leaf-adjacency-audit", + "pvs-bitset-width-audit", + "pvs-direct-adjacency-audit", + "pvs-metadata-decode-audit", + ]); + expect(looseProof.artifact.guarantees).not.toContain("compiled-bsp-pvs"); + expect(looseProof.artifact.guarantees).not.toContain("baked-pvs-bitsets"); + expect(looseProof.artifact.guarantees).not.toContain("portal-clipped-baked-pvs"); + expect(looseProof.artifact.knownWeaknesses).toEqual(expect.arrayContaining([ + "loose-pvs-not-full-vis", + "partial-pvs-coverage", + ])); + + expect(noPvsProof.pvs).toMatchObject({ + level: "certified-tree-only", + method: "none", + source: "none", + completeness: "none", + indexed: false, + bakedLeafCount: 0, + complete: false, + }); + expect(noPvsProof.artifact.guarantees).toEqual([ + "tree-root-leaf-reference-audit", + "portal-endpoint-audit", + "portal-leaf-adjacency-audit", + ]); + expect(noPvsProof.evidence.guarantees).toEqual([ + "validated-tree-root-references", + "validated-portal-endpoints", + "validated-portal-leaf-adjacency", + ]); + expect(noPvsProof.artifact.knownWeaknesses).toContain("pvs-unavailable"); + }); + + it("allows imported exact baked PVS to claim baked bitsets when coverage is complete", () => { + const pvsIndex = createTestPvsIndex(["a", "b"], ["ab"]); + const tree = createPolyWorldBspTree({ + root: { + id: "root", + plane: { normal: [1, 0, 0], distance: 1 }, + back: { leafId: "a" }, + front: { leafId: "b" }, + }, + leaves: [ + { + id: "a", + regionId: "a", + pvs: { + leafBits: testBitset(2, [0, 1]), + portalBits: testBitset(1, [0]), + regionIds: ["a", "b"], + linkIds: ["ab"], + selectionKeys: [], + elementIds: [], + }, + }, + { + id: "b", + regionId: "b", + pvs: { + leafBits: testBitset(2, [0, 1]), + portalBits: testBitset(1, [0]), + regionIds: ["a", "b"], + linkIds: ["ab"], + selectionKeys: [], + elementIds: [], + }, + }, + ], + portals: [ + { + id: "ab", + fromLeafId: "a", + toLeafId: "b", + linkId: "ab", + vertices: [[1, 0, 0], [1, 1, 0], [1, 1, 1], [1, 0, 1]], + }, + ], + pvsIndex, + data: { + compiled: true, + compiler: "test-vis", + pvsMethod: "exact-baked", + pvsSource: "test-vis", + }, + }); + const proof = summarizePolyWorldBspTopologyProof(tree); + + expect(proof.pvs).toMatchObject({ + level: "exact-baked-pvs", + method: "exact-baked", + source: "test-vis", + completeness: "complete", + complete: true, + }); + expect(proof.artifact.guarantees).toEqual(expect.arrayContaining([ + "compiled-bsp-pvs", + "baked-pvs-bitsets", + "pvs-bitset-width-audit", + "pvs-direct-adjacency-audit", + "pvs-metadata-decode-audit", + ])); + expect(proof.artifact.guarantees).not.toContain("portal-clipped-baked-pvs"); + expect(proof.artifact.knownWeaknesses).not.toContain("portal-flood-pvs-not-full-vis"); + }); + + it("uses the exact-PVS fixture as a complete imported bitset artifact", () => { + const fixture = createPolyWorldExactPvsFixture(); + const proof = summarizePolyWorldBspTopologyProof(fixture.tree); + const baked = resolvePolyWorldBspBakedPvs(fixture.tree, "middle"); + + expect(proof.pvs).toMatchObject({ + level: "exact-baked-pvs", + method: "exact-baked", + source: "fixture-vis", + completeness: "complete", + complete: true, + }); + expect(proof.artifact.guarantees).toEqual(expect.arrayContaining([ + "compiled-bsp-pvs", + "baked-pvs-bitsets", + "pvs-metadata-decode-audit", + ])); + expect(baked?.leafIds).toEqual(fixture.expectedLeafIds); + expect(baked?.regionIds).toEqual(fixture.expectedRegionIds); + }); + + it("labels partial imported baked PVS without complete baked-bitset claims", () => { + const pvsIndex = createTestPvsIndex(["a", "b"], []); + const tree = createPolyWorldBspTree({ + root: { + id: "root", + plane: { normal: [1, 0, 0], distance: 1 }, + back: { leafId: "a" }, + front: { leafId: "b" }, + }, + leaves: [ + { + id: "a", + regionId: "a", + pvs: { + leafBits: testBitset(2, [0]), + portalBits: testBitset(0, []), + regionIds: ["a"], + linkIds: [], + selectionKeys: [], + elementIds: [], + }, + }, + { id: "b", regionId: "b" }, + ], + pvsIndex, + data: { + compiled: true, + compiler: "partial-vis", + pvsMethod: "exact-baked", + pvsSource: "partial-vis", + }, + }); + const proof = summarizePolyWorldBspTopologyProof(tree); + + expect(proof.pvs).toMatchObject({ + level: "partial-baked-pvs", + method: "exact-baked", + source: "partial-vis", + completeness: "partial", + complete: false, + }); + expect(proof.artifact.guarantees).toContain("compiled-bsp-pvs"); + expect(proof.artifact.guarantees).not.toContain("baked-pvs-bitsets"); + expect(proof.artifact.knownWeaknesses).toContain("partial-pvs-coverage"); + }); + + it("compiles empty brush BSP portals around a partial blocker", () => { + const result = compilePolyWorldBrushBsp({ + worldBounds: { min: [0, 0, 0], max: [3, 2, 1] }, + brushes: [{ id: "block", bounds: { min: [1, 0, 0], max: [2, 1, 1] } }], + regions: [ + { id: "left", bounds: { min: [0, 0, 0], max: [1, 2, 1] } }, + { id: "right", bounds: { min: [2, 0, 0], max: [3, 2, 1] } }, + { id: "upper-route", regionId: "route", bounds: { min: [1, 1, 0], max: [2, 2, 1] } }, + ], + splitIdPrefix: "test-brush-open", + pvs: { projection: "xy" }, + }); + const leftLower = resolvePolyWorldBspLeaf(result.tree, [0.5, 0.5, 0.5]); + const rightLower = resolvePolyWorldBspLeaf(result.tree, [2.5, 0.5, 0.5]); + const solid = resolvePolyWorldBspLeaf(result.tree, [1.5, 0.5, 0.5]); + const reachable = new Set(); + const queue = leftLower === undefined ? [] : [leftLower.leafId]; + while (queue.length > 0) { + const leafId = queue.shift(); + if (leafId === undefined || reachable.has(leafId)) continue; + reachable.add(leafId); + for (const portal of result.portals) { + if (portal.fromLeafId === leafId && !reachable.has(portal.toLeafId)) queue.push(portal.toLeafId); + if (portal.toLeafId === leafId && !reachable.has(portal.fromLeafId)) queue.push(portal.fromLeafId); + } + } + + expect(result.emptyLeafIds).toHaveLength(5); + expect(result.solidLeafIds).toHaveLength(1); + expect(result.portals.length).toBeGreaterThan(0); + expect(result.portals.every((portal) => + portal.vertices !== undefined && + portal.vertices.length >= 3 && + portal.data?.compiler === "brush-bsp" && + portal.data?.partition === "recursive-plane" && + portal.data?.portalBuilder === "leaf-face-overlap" + )).toBe(true); + expect(result.tree.data?.portalCandidateCount).toBeGreaterThan(result.portals.length); + expect(result.tree.data?.rejectedPortalCandidateCount).toBeGreaterThan(0); + expect(result.portals.some((portal) => + portal.fromLeafId === leftLower?.leafId && portal.toLeafId === rightLower?.leafId || + portal.fromLeafId === rightLower?.leafId && portal.toLeafId === leftLower?.leafId + )).toBe(false); + expect(solid?.leaf.data?.solid).toBe(true); + expect(reachable.has(rightLower?.leafId ?? "")).toBe(true); + expect(decodeTestBitset(result.tree.pvsIndex?.leafIds ?? [], leftLower?.leaf.pvs?.leafBits)).toContain(leftLower?.leafId); + }); + + it("can mark space outside authored brush BSP regions as solid", () => { + const result = compilePolyWorldBrushBsp({ + worldBounds: { min: [0, 0, 0], max: [2, 1, 1] }, + brushes: [], + regions: [ + { id: "room", bounds: { min: [0, 0, 0], max: [1, 1, 1] }, elementIds: ["room-shell"] }, + ], + outside: "solid", + splitIdPrefix: "test-outside-solid", + pvs: { projection: "xy" }, + }); + const room = resolvePolyWorldBspLeaf(result.tree, [0.5, 0.5, 0.5]); + const outside = resolvePolyWorldBspLeaf(result.tree, [1.5, 0.5, 0.5]); + + expect(result.emptyLeafIds).toEqual([room?.leafId]); + expect(result.solidLeafIds).toEqual([outside?.leafId]); + expect(result.portals).toHaveLength(0); + expect(room?.leaf.regionId).toBe("room"); + expect(room?.leaf.elementIds).toEqual(["room-shell"]); + expect(outside?.leaf.data).toMatchObject({ solid: true, outside: true }); + expect(result.outsideLeafIds).toEqual([outside?.leafId]); + }); + + it("flood-fills exterior brush BSP space without leaking into a sealed room", () => { + const result = compilePolyWorldBrushBsp({ + worldBounds: { min: [0, 0, 0], max: [3, 3, 3] }, + brushes: [ + { id: "west-wall", bounds: { min: [0.9, 0.9, 0.9], max: [1, 2.1, 2.1] } }, + { id: "east-wall", bounds: { min: [2, 0.9, 0.9], max: [2.1, 2.1, 2.1] } }, + { id: "south-wall", bounds: { min: [0.9, 0.9, 0.9], max: [2.1, 1, 2.1] } }, + { id: "north-wall", bounds: { min: [0.9, 2, 0.9], max: [2.1, 2.1, 2.1] } }, + { id: "floor", bounds: { min: [0.9, 0.9, 0.9], max: [2.1, 2.1, 1] } }, + { id: "ceiling", bounds: { min: [0.9, 0.9, 2], max: [2.1, 2.1, 2.1] } }, + ], + regions: [ + { + id: "sealed", + bounds: { min: [1, 1, 1], max: [2, 2, 2] }, + elementIds: ["sealed-room"], + }, + ], + outside: "flood-fill", + splitIdPrefix: "test-sealed-room", + bakePvs: false, + }); + const sealed = resolvePolyWorldBspLeaf(result.tree, [1.5, 1.5, 1.5]); + const exterior = resolvePolyWorldBspLeaf(result.tree, [0.5, 0.5, 0.5]); + const exteriorReachable = reachableLeafIds(result.portals, exterior?.leafId); + + expect(sealed?.leaf.data?.solid).toBe(false); + expect(sealed?.leaf.regionId).toBe("sealed"); + expect(sealed?.leaf.elementIds).toEqual(["sealed-room"]); + expect(exterior?.leaf.data).toMatchObject({ solid: true, outside: true, outsideFill: true }); + expect(result.outsideLeafIds).toContain(exterior?.leafId); + expect(result.outsideLeafIds).not.toContain(sealed?.leafId); + expect(exteriorReachable.has(sealed?.leafId ?? "")).toBe(false); + }); + + it("flood-fills leaked brush BSP regions as outside space", () => { + const result = compilePolyWorldBrushBsp({ + worldBounds: { min: [0, 0, 0], max: [3, 3, 3] }, + brushes: [ + { id: "west-wall", bounds: { min: [0.9, 0.9, 0.9], max: [1, 2.1, 2.1] } }, + { id: "east-wall", bounds: { min: [2, 0.9, 0.9], max: [2.1, 2.1, 2.1] } }, + { id: "south-wall", bounds: { min: [0.9, 0.9, 0.9], max: [2.1, 1, 2.1] } }, + { id: "floor", bounds: { min: [0.9, 0.9, 0.9], max: [2.1, 2.1, 1] } }, + { id: "ceiling", bounds: { min: [0.9, 0.9, 2], max: [2.1, 2.1, 2.1] } }, + ], + regions: [ + { + id: "leaked", + bounds: { min: [1, 1, 1], max: [2, 2, 2] }, + elementIds: ["leaked-room"], + }, + ], + outside: "flood-fill", + splitIdPrefix: "test-leaked-room", + bakePvs: false, + }); + const leaked = resolvePolyWorldBspLeaf(result.tree, [1.5, 1.5, 1.5]); + + expect(leaked?.leaf.data).toMatchObject({ solid: true, outside: true, outsideFill: true }); + expect(leaked?.leaf.regionId).toBeUndefined(); + expect(leaked?.leaf.elementIds ?? []).toEqual([]); + expect(result.outsideLeafIds).toContain(leaked?.leafId); + expect(result.emptyLeafIds).not.toContain(leaked?.leafId ?? ""); + }); + + it("compiles sloped brush planes into non-axis solid and empty BSP leaves", () => { + const result = compilePolyWorldBrushBsp({ + worldBounds: { min: [0, 0, 0], max: [2, 2, 1] }, + brushes: [ + { + id: "diagonal-solid", + bounds: { min: [0, 0, 0], max: [2, 2, 1] }, + planes: [{ normal: [-1, -1, 0], distance: -2 }], + }, + ], + regions: [ + { + id: "walkable", + bounds: { min: [0, 0, 0], max: [2, 2, 1] }, + elementIds: ["walkable-room"], + }, + ], + splitIdPrefix: "test-sloped-brush", + bakePvs: false, + }); + const empty = resolvePolyWorldBspLeaf(result.tree, [0.5, 0.5, 0.5]); + const solid = resolvePolyWorldBspLeaf(result.tree, [1.5, 1, 0.5]); + const diagonalNode = findBspNode(result.tree.root, (node) => + Math.abs(node.plane.normal[0]) > 0.5 && Math.abs(node.plane.normal[1]) > 0.5 + ); + + expect(empty?.leaf.data?.solid).toBe(false); + expect(empty?.leaf.regionId).toBe("walkable"); + expect(empty?.leaf.elementIds).toEqual(["walkable-room"]); + expect(solid?.leaf.data?.solid).toBe(true); + expect(solid?.leaf.data?.brushIds).toEqual(["diagonal-solid"]); + expect(empty?.leafId).not.toBe(solid?.leafId); + expect(diagonalNode?.plane.normal[0]).toBeCloseTo(-Math.SQRT1_2); + expect(diagonalNode?.plane.normal[1]).toBeCloseTo(-Math.SQRT1_2); + expect(diagonalNode?.plane.normal[2]).toBeCloseTo(0); + expect(diagonalNode?.data).toMatchObject({ + compiler: "brush-bsp", + partition: "recursive-plane", + splitterSource: "brush", + splitterSourceId: "diagonal-solid", + }); + }); + + it("generates empty BSP portals around sloped brush solids", () => { + const result = compilePolyWorldBrushBsp({ + worldBounds: { min: [0, 0, 0], max: [3, 2, 1] }, + brushes: [ + { + id: "sloped-block", + bounds: { min: [1, 0, 0], max: [2, 1, 1] }, + planes: [{ normal: [-1, -1, 0], distance: -2.4 }], + }, + ], + regions: [ + { id: "left", bounds: { min: [0, 0, 0], max: [1, 2, 1] } }, + { id: "route", bounds: { min: [1, 1, 0], max: [2, 2, 1] } }, + { id: "right", bounds: { min: [2, 0, 0], max: [3, 2, 1] } }, + ], + splitIdPrefix: "test-sloped-route", + bakePvs: false, + }); + const leftLower = resolvePolyWorldBspLeaf(result.tree, [0.5, 0.5, 0.5]); + const rightLower = resolvePolyWorldBspLeaf(result.tree, [2.5, 0.5, 0.5]); + const solid = resolvePolyWorldBspLeaf(result.tree, [1.8, 0.8, 0.5]); + const openInsideBrushBounds = resolvePolyWorldBspLeaf(result.tree, [1.1, 0.1, 0.5]); + const reachable = reachableLeafIds(result.portals, leftLower?.leafId); + + expect(result.portals.length).toBeGreaterThan(0); + expect(solid?.leaf.data?.solid).toBe(true); + expect(openInsideBrushBounds?.leaf.data?.solid).toBe(false); + expect(reachable.has(rightLower?.leafId ?? "")).toBe(true); + }); + + it("rejects malformed brush BSP compiler input", () => { + expect(() => + compilePolyWorldBrushBsp({ + worldBounds: { min: [0, 0, 0], max: [1, 1, 1] }, + brushes: [ + { id: "dup", bounds: { min: [0, 0, 0], max: [0.5, 0.5, 0.5] } }, + { id: "dup", bounds: { min: [0.5, 0.5, 0.5], max: [1.5, 1, 1] } }, + ], + }), + ).toThrow(PolyWorldBspError); + expect(() => + compilePolyWorldBrushBsp({ + worldBounds: { min: [0, 0, 0], max: [1, 1, 1] }, + brushes: [{ id: "bad-plane", planes: [{ normal: [0, 0, 0], distance: 0 }] }], + }), + ).toThrow(PolyWorldBspError); + expect(() => + compilePolyWorldBrushBsp({ + worldBounds: { min: [0, 0, 0], max: [1, 1, 1] }, + brushes: [], + outside: "invalid" as never, + }), + ).toThrow(PolyWorldBspError); + }); + + it("compiles polygon geometry into a plane BSP and splits spanning surfaces", () => { + const result = compilePolyWorldPolygonBsp({ + surfaces: [ + { + id: "splitter-wall", + vertices: [ + [0, -1, 0], + [0, 1, 0], + [0, 1, 1], + [0, -1, 1], + ], + }, + { + id: "spanning-floor", + vertices: [ + [-2, -1, 0.5], + [2, -1, 0.5], + [2, 1, 0.5], + [-2, 1, 0.5], + ], + }, + ], + splitIdPrefix: "test-poly", + }); + + const leftLeaf = resolvePolyWorldBspLeaf(result.tree, [-1, 0, 0.5]); + const rightLeaf = resolvePolyWorldBspLeaf(result.tree, [1, 0, 0.5]); + + expect(result.tree.data).toMatchObject({ + compiled: true, + compiler: "polygon-bsp", + sourceSurfaceCount: 2, + }); + expect(result.fragments.length).toBeGreaterThan(2); + expect(result.fragments.some((fragment) => fragment.id.startsWith("spanning-floor#"))).toBe(true); + expect(leftLeaf?.leafId).toBeDefined(); + expect(rightLeaf?.leafId).toBeDefined(); + expect(leftLeaf?.leafId).not.toBe(rightLeaf?.leafId); + }); + + it("rejects degenerate polygon BSP surfaces", () => { + expect(() => + compilePolyWorldPolygonBsp({ + surfaces: [ + { + id: "line", + vertices: [ + [0, 0, 0], + [1, 0, 0], + [2, 0, 0], + ], + }, + ], + }), + ).toThrow(PolyWorldBspError); + }); + + it("compiles region bounds and portal openings into a BSP tree with baked PVS", () => { + const topology = createPolyWorldTopology({ + regions: [ + { id: "left" }, + { id: "middle" }, + { id: "right" }, + { id: "side" }, + ], + links: [ + { id: "left-middle", fromRegionId: "left", toRegionId: "middle", selectionKeys: ["portal:left-middle"] }, + { id: "middle-right", fromRegionId: "middle", toRegionId: "right", selectionKeys: ["portal:middle-right"] }, + { id: "middle-side", fromRegionId: "middle", toRegionId: "side", selectionKeys: ["portal:middle-side"] }, + ], + elements: [ + { id: "left-shell", regionIds: ["left"], layers: ["world"] }, + { id: "middle-shell", regionIds: ["middle"], layers: ["world"] }, + { id: "right-shell", regionIds: ["right"], layers: ["world"] }, + { id: "side-shell", regionIds: ["side"], layers: ["world"] }, + { id: "left-door", selectionKeys: ["portal:left-middle"], layers: ["world"] }, + { id: "right-door", selectionKeys: ["portal:middle-right"], layers: ["world"] }, + { id: "side-door", selectionKeys: ["portal:middle-side"], layers: ["world"] }, + ], + }); + const tree = compilePolyWorldBsp({ + regions: [ + { + id: "left", + bounds: { min: [-8, -2, 0], max: [-4, 2, 2] }, + pvsSamplePoints: [[-6, 0, 1]], + }, + { id: "middle", bounds: { min: [-4, -4, 0], max: [4, 4, 2] } }, + { id: "right", bounds: { min: [4, -2, 0], max: [8, 2, 2] } }, + { id: "side", bounds: { min: [-2, 4, 0], max: [2, 8, 2] } }, + ], + portals: [ + { + id: "left-middle", + fromRegionId: "left", + toRegionId: "middle", + linkId: "left-middle", + bounds: { min: [-4, -1, 0], max: [-4, 1, 2] }, + }, + { + id: "middle-right", + fromRegionId: "middle", + toRegionId: "right", + linkId: "middle-right", + bounds: { min: [4, -1, 0], max: [4, 1, 2] }, + }, + { + id: "middle-side", + fromRegionId: "middle", + toRegionId: "side", + linkId: "middle-side", + bounds: { min: [-1, 4, 0], max: [1, 4, 2] }, + }, + ], + pvs: { projection: "xy" }, + }); + + const leaf = resolvePolyWorldBspLeaf(tree, [-6, 0, 1]); + const leftPvs = resolvePolyWorldBspPvs(tree, "left", { projection: "xy" }); + const selection = selectPolyWorldBspPvs(topology, tree, { point: [-6, 0, 1] }); + const resolution = resolvePolyWorldElements(topology, selection); + + expect(tree.data).toMatchObject({ compiled: true, compiler: "bounds-bsp" }); + expect(tree.portals).toHaveLength(3); + expect(tree.portals.every((portal) => portal.data?.compiled === true)).toBe(true); + expect(leaf?.leafId).toBe("left"); + expect(leaf?.path.length).toBeGreaterThan(0); + expect(leftPvs.leafIds).toEqual(["left", "middle", "right"]); + expect(leftPvs.portalIds).toEqual(["left-middle", "middle-right"]); + expect(selection.regionIds).toEqual(["left", "middle", "right"]); + expect(selection.linkIds).toEqual(["left-middle", "middle-right"]); + expect(resolution.elementIds).toEqual([ + "left-shell", + "middle-shell", + "right-shell", + "left-door", + "right-door", + ]); + }); + + it("bakes BSP leaf PVS from portal geometry before selecting visible elements", () => { + const topology = createPolyWorldTopology({ + regions: [ + { id: "left" }, + { id: "middle" }, + { id: "right" }, + { id: "side" }, + ], + links: [ + { id: "left-middle", fromRegionId: "left", toRegionId: "middle", selectionKeys: ["portal:left-middle"] }, + { id: "middle-right", fromRegionId: "middle", toRegionId: "right", selectionKeys: ["portal:middle-right"] }, + { id: "middle-side", fromRegionId: "middle", toRegionId: "side", selectionKeys: ["portal:middle-side"] }, + ], + elements: [ + { id: "left-shell", regionIds: ["left"], layers: ["world"] }, + { id: "middle-shell", regionIds: ["middle"], layers: ["world"] }, + { id: "right-shell", regionIds: ["right"], layers: ["world"] }, + { id: "side-shell", regionIds: ["side"], layers: ["world"] }, + { id: "left-door", selectionKeys: ["portal:left-middle"], layers: ["world"] }, + { id: "right-door", selectionKeys: ["portal:middle-right"], layers: ["world"] }, + { id: "side-door", selectionKeys: ["portal:middle-side"], layers: ["world"] }, + ], + }); + const tree = bakePolyWorldBspPvs(createPolyWorldBspTree({ + root: { + id: "split-left", + plane: { normal: [1, 0, 0], distance: -4 }, + back: { leafId: "left-leaf" }, + front: { + id: "split-right", + plane: { normal: [1, 0, 0], distance: 4 }, + front: { leafId: "right-leaf" }, + back: { + id: "split-side", + plane: { normal: [0, 1, 0], distance: 4 }, + front: { leafId: "side-leaf" }, + back: { leafId: "middle-leaf" }, + }, + }, + }, + leaves: [ + { + id: "left-leaf", + regionId: "left", + clusterId: "cluster-left", + bounds: { min: [-8, -2, 0], max: [-4, 2, 2] }, + pvsSamplePoints: [[-6, 0, 1]], + }, + { id: "middle-leaf", regionId: "middle", clusterId: "cluster-middle", bounds: { min: [-4, -4, 0], max: [4, 4, 2] } }, + { id: "right-leaf", regionId: "right", clusterId: "cluster-right", bounds: { min: [4, -2, 0], max: [8, 2, 2] } }, + { id: "side-leaf", regionId: "side", clusterId: "cluster-side", bounds: { min: [-2, 4, 0], max: [2, 8, 2] } }, + ], + portals: [ + { + id: "portal-left-middle", + fromLeafId: "left-leaf", + toLeafId: "middle-leaf", + linkId: "left-middle", + vertices: [[-4, -1, 0], [-4, 1, 0], [-4, 1, 2], [-4, -1, 2]], + }, + { + id: "portal-middle-right", + fromLeafId: "middle-leaf", + toLeafId: "right-leaf", + linkId: "middle-right", + vertices: [[4, -1, 0], [4, 1, 0], [4, 1, 2], [4, -1, 2]], + }, + { + id: "portal-middle-side", + fromLeafId: "middle-leaf", + toLeafId: "side-leaf", + linkId: "middle-side", + vertices: [[-1, 4, 0], [1, 4, 0], [1, 4, 2], [-1, 4, 2]], + }, + ], + }), { projection: "xy" }); + + const leftPvs = resolvePolyWorldBspPvs(tree, "left-leaf", { projection: "xy" }); + const bakedLeftPvs = resolvePolyWorldBspBakedPvs(tree, "left-leaf"); + const proof = summarizePolyWorldBspTopologyProof(tree); + const selection = selectPolyWorldBspPvs(topology, tree, { point: [-6, 0, 1] }); + const resolution = resolvePolyWorldElements(topology, selection); + + expect(tree.portals.map((portal) => portal.linkId)).toContain("middle-side"); + expect(proof.pvs).toMatchObject({ + level: "portal-clipped-baked-pvs", + method: "portal-clipped-baked", + source: "polycss-world", + completeness: "complete", + }); + expect(proof.artifact.guarantees).toContain("portal-clipped-baked-pvs"); + expect(proof.artifact.knownWeaknesses).toContain("not-full-qbsp-vis-parity"); + expect(leftPvs.leafIds).toEqual(["left-leaf", "middle-leaf", "right-leaf"]); + expect(leftPvs.leafIds).not.toContain("side-leaf"); + expect(leftPvs.clusterIds).toEqual(["cluster-left", "cluster-middle", "cluster-right"]); + expect(leftPvs.portalIds).toEqual(["portal-left-middle", "portal-middle-right"]); + expect(leftPvs.portalIds).not.toContain("portal-middle-side"); + expect(bakedLeftPvs?.leafIds).toEqual(["left-leaf", "middle-leaf", "right-leaf"]); + expect(bakedLeftPvs?.clusterIds).toEqual(["cluster-left", "cluster-middle", "cluster-right"]); + expect(bakedLeftPvs?.portalIds).toEqual(["portal-left-middle", "portal-middle-right"]); + expect(leftPvs.regionIds).toEqual(["left", "middle", "right"]); + expect(leftPvs.linkIds).toEqual(["left-middle", "middle-right"]); + expect(tree.leavesById.get("left-leaf")?.pvs?.regionIds).toEqual(["left", "middle", "right"]); + expect(tree.leavesById.get("left-leaf")?.pvs?.leafBits).toBeInstanceOf(Uint32Array); + expect(decodePolyWorldBspPvsLeafIds(tree.pvsIndex!, tree.leavesById.get("left-leaf")!.pvs!)).toEqual([ + "left-leaf", + "middle-leaf", + "right-leaf", + ]); + expect(decodePolyWorldBspPvsPortalIds(tree.pvsIndex!, tree.leavesById.get("left-leaf")!.pvs!)).toEqual([ + "portal-left-middle", + "portal-middle-right", + ]); + expect(selection.regionIds).toEqual(["left", "middle", "right"]); + expect(selection.linkIds).toEqual(["left-middle", "middle-right"]); + expect(selection.selectionKeys).toEqual(["portal:left-middle", "portal:middle-right"]); + expect(selection.reasons?.find((reason) => reason.kind === "pvs")?.data).toEqual({ + leafId: "left-leaf", + portalIds: ["portal-left-middle", "portal-middle-right"], + leafIds: ["left-leaf", "middle-leaf", "right-leaf"], + clusterIds: ["cluster-left", "cluster-middle", "cluster-right"], + }); + expect(resolution.elementIds).toEqual([ + "left-shell", + "middle-shell", + "right-shell", + "left-door", + "right-door", + ]); + }); + + it("clips a baked BSP PVS through the current camera view before selecting elements", () => { + const topology = createPolyWorldTopology({ + regions: [ + { id: "left" }, + { id: "middle" }, + { id: "right" }, + ], + links: [ + { id: "left-middle", fromRegionId: "left", toRegionId: "middle", selectionKeys: ["portal:left-middle"] }, + { id: "middle-right", fromRegionId: "middle", toRegionId: "right", selectionKeys: ["portal:middle-right"] }, + ], + elements: [ + { id: "left-shell", regionIds: ["left"], layers: ["world"] }, + { id: "middle-shell", regionIds: ["middle"], layers: ["world"] }, + { id: "right-shell", regionIds: ["right"], layers: ["world"] }, + ], + }); + const tree = compilePolyWorldBsp({ + regions: [ + { id: "left", bounds: { min: [-8, -2, 0], max: [-4, 2, 2] } }, + { id: "middle", bounds: { min: [-4, -2, 0], max: [4, 2, 2] } }, + { id: "right", bounds: { min: [4, -2, 0], max: [8, 2, 2] } }, + ], + portals: [ + { + id: "left-middle", + fromRegionId: "left", + toRegionId: "middle", + linkId: "left-middle", + bounds: { min: [-4, -1, 0], max: [-4, 1, 2] }, + }, + { + id: "middle-right", + fromRegionId: "middle", + toRegionId: "right", + linkId: "middle-right", + bounds: { min: [4, -1, 0], max: [4, 1, 2] }, + }, + ], + pvs: { projection: "xy" }, + }); + const broad = resolvePolyWorldBspPvs(tree, "middle", { projection: "xy" }); + const view = resolvePolyWorldBspViewPvs(tree, { + leafId: "middle", + point: [0, 0, 1], + forward: [-1, 0, 0], + fovDegrees: 90, + projection: "xy", + }); + const trace = tracePolyWorldBspViewPvs(tree, { + leafId: "middle", + point: [0, 0, 1], + forward: [-1, 0, 0], + fovDegrees: 90, + projection: "xy", + }); + const selection = selectPolyWorldBspViewPvs(topology, tree, { + point: [0, 0, 1], + forward: [-1, 0, 0], + fovDegrees: 90, + projection: "xy", + }); + const resolution = resolvePolyWorldElements(topology, selection); + + expect(broad.regionIds).toEqual(["left", "middle", "right"]); + expect(view.broadPhaseLeafIds).toEqual(["left", "middle", "right"]); + expect(view.regionIds).toEqual(["left", "middle"]); + expect(view.regionIds).not.toContain("right"); + expect(view.portalIds).toEqual(["left-middle"]); + expect(trace.regionIds).toEqual(view.regionIds); + expect(trace.entries.map((entry) => [entry.portalId, entry.fromLeafId, entry.toLeafId, entry.status])).toEqual([ + ["left-middle", "middle", "left", "visible"], + ["middle-right", "middle", "right", "clipped"], + ]); + expect(trace.entries[0]).toMatchObject({ + inputVertexCount: 4, + clippedVertexCount: 4, + clipPlaneCount: 9, + linkId: "left-middle", + }); + expect(selection.regionIds).toEqual(["middle", "left"]); + expect(selection.reasons?.find((reason) => reason.kind === "viewPvs")?.data).toMatchObject({ + leafId: "middle", + leafIds: ["left", "middle"], + broadPhaseLeafIds: ["left", "middle", "right"], + }); + expect(resolution.elementIds).toEqual(["left-shell", "middle-shell"]); + }); + + it("resolves a complete BSP visibility frame for authored-world camera updates", () => { + const topology = createPolyWorldTopology({ + regions: [ + { id: "left" }, + { id: "middle" }, + { id: "right" }, + ], + links: [ + { id: "left-middle", fromRegionId: "left", toRegionId: "middle", selectionKeys: ["portal:left-middle"] }, + { id: "middle-right", fromRegionId: "middle", toRegionId: "right", selectionKeys: ["portal:middle-right"] }, + ], + elements: [ + { id: "left-shell", regionIds: ["left"], layers: ["world"] }, + { id: "middle-shell", regionIds: ["middle"], layers: ["world"] }, + { id: "right-shell", regionIds: ["right"], layers: ["world"] }, + ], + }); + const tree = compilePolyWorldBsp({ + regions: [ + { id: "left", bounds: { min: [-8, -2, 0], max: [-4, 2, 2] } }, + { id: "middle", bounds: { min: [-4, -2, 0], max: [4, 2, 2] } }, + { id: "right", bounds: { min: [4, -2, 0], max: [8, 2, 2] } }, + ], + portals: [ + { + id: "left-middle", + fromRegionId: "left", + toRegionId: "middle", + linkId: "left-middle", + bounds: { min: [-4, -1, 0], max: [-4, 1, 2] }, + }, + { + id: "middle-right", + fromRegionId: "middle", + toRegionId: "right", + linkId: "middle-right", + bounds: { min: [4, -1, 0], max: [4, 1, 2] }, + }, + ], + pvs: { projection: "xy" }, + }); + + const visibility = resolvePolyWorldBspVisibility(topology, tree, { + leafId: "middle", + point: [0, 0, 1], + forward: [-1, 0, 0], + fovDegrees: 90, + projection: "xy", + includeTrace: true, + debug: { listLimit: 2 }, + }); + const fallback = resolvePolyWorldBspVisibility(topology, tree, { + point: [99, 99, 1], + forward: [-1, 0, 0], + fovDegrees: 90, + projection: "xy", + regionIds: ["right"], + debug: false, + }); + + expect(visibility.leaf?.leafId).toBe("middle"); + expect(visibility.broadPvs?.regionIds).toEqual(["left", "middle", "right"]); + expect(visibility.viewPvs?.regionIds).toEqual(["left", "middle"]); + expect(visibility.selection.regionIds).toEqual(["middle", "left"]); + expect(visibility.trace?.entries.map((entry) => [entry.portalId, entry.status])).toEqual([ + ["left-middle", "visible"], + ["middle-right", "clipped"], + ]); + expect(visibility.debug?.current.broadPvs?.regionIds).toEqual({ + values: ["left", "middle"], + count: 3, + omitted: 1, + }); + expect(visibility.debug?.trace?.statusCounts).toEqual({ + visible: 1, + clipped: 1, + }); + expect(fallback.leaf).toBeUndefined(); + expect(fallback.selection.regionIds).toEqual(["right"]); + expect(fallback.debug).toBeUndefined(); + }); + + it("plans a BSP visibility frame into state diff and layer actions", () => { + const topology = createPolyWorldTopology({ + regions: [ + { id: "left" }, + { id: "middle" }, + { id: "right" }, + ], + links: [ + { id: "left-middle", fromRegionId: "left", toRegionId: "middle" }, + { id: "middle-right", fromRegionId: "middle", toRegionId: "right" }, + ], + elements: [ + { id: "left-front-surface", selectionKeys: ["surface:left-front"], layers: ["render"], resourceIds: ["mesh:left-front"] }, + { id: "left-side-surface", selectionKeys: ["surface:left-side"], layers: ["render"] }, + { id: "right-surface", selectionKeys: ["surface:right"], layers: ["render"] }, + ], + }); + const tree = compilePolyWorldBsp({ + regions: [ + { id: "left", bounds: { min: [-8, -2, 0], max: [-4, 2, 2] } }, + { id: "middle", bounds: { min: [-4, -2, 0], max: [4, 2, 2] } }, + { id: "right", bounds: { min: [4, -2, 0], max: [8, 2, 2] } }, + ], + portals: [ + { + id: "left-middle", + fromRegionId: "left", + toRegionId: "middle", + bounds: { min: [-4, -1, 0], max: [-4, 1, 2] }, + }, + { + id: "middle-right", + fromRegionId: "middle", + toRegionId: "right", + bounds: { min: [4, -1, 0], max: [4, 1, 2] }, + }, + ], + pvs: { projection: "xy" }, + }); + const previousState = createPolyWorldState(topology, { selection: { regionIds: ["middle", "right"] } }); + const surfaces = [ + { + id: "left-front", + elementId: "left-front-surface", + regionId: "left", + vertices: [[-5, -0.5, 0], [-5, 0.5, 0], [-5, 0.5, 2], [-5, -0.5, 2]], + }, + { + id: "left-side", + elementId: "left-side-surface", + regionId: "left", + vertices: [[-5, 1.6, 0], [-5, 1.9, 0], [-5, 1.9, 2], [-5, 1.6, 2]], + }, + { + id: "right", + elementId: "right-surface", + regionId: "right", + vertices: [[5, -0.5, 0], [5, 0.5, 0], [5, 0.5, 2], [5, -0.5, 2]], + }, + ] as const; + const surfaceElements = resolvePolyWorldBspViewSurfaceElements(tree, { + leafId: "middle", + point: [0, 0, 1], + forward: [-1, 0, 0], + fovDegrees: 24, + projection: "xy", + surfaces, + }); + + const frame = planPolyWorldBspVisibilityFrame(topology, tree, { + previousState, + policies: [{ id: "render", layer: "render", elementLayers: ["render"] }], + leafId: "middle", + point: [0, 0, 1], + forward: [-1, 0, 0], + fovDegrees: 24, + projection: "xy", + surfaces, + includeTrace: true, + debug: { listLimit: 3 }, + planDebug: { includeEntries: true, listLimit: 3 }, + readiness: { + resources: { + "mesh:left-front": "stale", + "mesh:right": "failed", + }, + }, + }); + + expect(frame.visibility.viewPvs?.regionIds).toEqual(["left", "middle"]); + expect(frame.artifact).toMatchObject({ + profile: "bsp-pvs", + artifactKind: "compiled-bsp-pvs", + sourceKind: "compiled", + producedBy: "bounds-bsp", + counts: { + leafCount: 3, + portalCount: 2, + }, + }); + expect(frame.artifact.guarantees).toContain("pvs-metadata-decode-audit"); + expect(surfaceElements.elementIds).toEqual(["left-front-surface"]); + expect(surfaceElements.structuralElementIds).toEqual([]); + expect(surfaceElements.detailElementIds).toEqual(["left-front-surface"]); + expect(frame.surfaceElements?.elementIds).toEqual(["left-front-surface"]); + expect(frame.visibilitySets).toEqual({ + currentLeafId: "middle", + broadPvsLeafIds: ["left", "middle", "right"], + viewPvsLeafIds: ["left", "middle"], + structuralSurfaceIds: [], + structuralElementIds: [], + detailSurfaceIds: ["left-front"], + detailElementIds: ["left-front-surface"], + plannedElementIds: ["left-front-surface"], + }); + expect(frame.frameSummary).toMatchObject({ + profile: "bsp-pvs", + artifactKind: "compiled-bsp-pvs", + current: { + leafIds: ["middle"], + regionIds: ["middle"], + }, + broad: { + leafIds: ["left", "middle", "right"], + }, + view: { + leafIds: ["left", "middle"], + surfaceIds: ["left-front"], + elementIds: ["left-front-surface"], + }, + planning: { + elementIds: ["left-front-surface"], + }, + readiness: { + resourceIds: ["mesh:left-front"], + staleResourceIds: ["mesh:left-front"], + blockedElementIds: ["left-front-surface"], + }, + plan: { + plannedElementIds: ["left-front-surface"], + blockedElementIds: [], + }, + }); + expect(frame.planningSelection?.elementIds).toEqual(["left-front-surface"]); + expect(frame.readiness?.resourceIds).toEqual(["mesh:left-front"]); + expect(frame.readiness?.staleResourceIds).toEqual(["mesh:left-front"]); + expect(frame.readiness?.blockedElementIds).toEqual(["left-front-surface"]); + expect(frame.debug?.planningSelection?.elementIds).toEqual(["left-front-surface"]); + expect(frame.nextState.resolvedElementIds).toEqual(["left-front-surface"]); + expect(frame.diff.resolvedElements.added).toEqual(["left-front-surface"]); + expect(frame.diff.resolvedElements.removed).toEqual([]); + expect(frame.diff.resolvedElements.retained).toEqual([]); + expect(frame.plan.actionCounts).toEqual({ + show: 1, + hide: 0, + retain: 0, + preload: 0, + noop: 0, + }); + expect(frame.debug?.plan.entryCount).toBe(1); + }); + + it("uses the partition-gallery fixture for broad PVS, view PVS, and structural retention", () => { + const fixture = createPolyWorldPartitionGalleryFixture(); + const previousState = createPolyWorldState(fixture.topology); + const frame = planPolyWorldBspVisibilityFrame(fixture.topology, fixture.tree, { + previousState, + policies: [{ id: "render-world", layer: "world", elementLayers: ["world"] }], + leafId: "gallery", + point: fixture.points.gallery, + forward: fixture.points.westView, + up: [0, 0, 1], + aspect: 1, + fovDegrees: 68, + projection: "xy", + includeTrace: true, + surfaces: fixture.surfaces, + readiness: { + resources: {}, + resourceDeclarations: fixture.documentInput.resources, + }, + debug: { listLimit: 12 }, + }); + + expect(frame.visibility.broadPvs?.regionIds).toEqual(fixture.expected.broadFromGallery); + expect(frame.visibility.viewPvs?.regionIds).toEqual(fixture.expected.westViewRegions); + expect(frame.visibilitySets.structuralSurfaceIds).toEqual(expect.arrayContaining([ + "studio-floor", + "studio-ceiling", + "gallery-floor", + "gallery-ceiling", + "gallery-opening-frame", + ])); + expect(frame.visibilitySets.detailSurfaceIds).not.toContain("vault-prop"); + expect(frame.frameSummary.broad.regionIds).toEqual(fixture.expected.summaryBroadFromGallery); + expect(frame.frameSummary.view.regionIds).toEqual(fixture.expected.summaryWestViewRegions); + expect(frame.frameSummary.retained.surfaceIds).toEqual(expect.arrayContaining([ + "studio-floor", + "gallery-ceiling", + ])); + expect(frame.readiness?.renderBlockingResourceIds).toEqual(expect.arrayContaining([ + "mesh:studio-floor-element", + "mesh:gallery-ceiling-element", + ])); + expect(frame.readiness?.staleResourceIds).toEqual([ + "mesh:gallery-prop-element", + "mesh:studio-prop-element", + ]); + expect(frame.readiness?.nonBlockingResourceIds).toEqual([ + "mesh:gallery-prop-element", + "mesh:studio-prop-element", + ]); + expect(frame.readiness?.blockedResourceIds).toEqual([]); + expect(frame.loadSet?.requestResourceIds).toEqual([ + "mesh:gallery-prop-element", + "mesh:studio-prop-element", + ]); + expect(frame.frameSummary.loadSet?.requestResourceIds).toEqual([ + "mesh:gallery-prop-element", + "mesh:studio-prop-element", + ]); + expect(frame.artifact.guarantees).toContain("portal-clipped-baked-pvs"); + expect(frame.artifact.knownWeaknesses).toContain("not-full-qbsp-vis-parity"); + }); + + it("keeps large floor and ceiling surfaces when the view footprint is inside the polygon", () => { + const tree = createPolyWorldBspTree({ + root: { leafId: "room" }, + leaves: [ + { + id: "room", + regionId: "room", + bounds: { min: [-16, -16, 0], max: [16, 16, 3] }, + }, + ], + }); + const floor = { + id: "floor", + elementId: "floor-surface", + regionId: "room", + vertices: [[-16, -16, 0], [16, -16, 0], [16, 16, 0], [-16, 16, 0]], + } as const; + const ceiling = { + id: "ceiling", + elementId: "ceiling-surface", + regionId: "room", + vertices: [[-16, -16, 3], [-16, 16, 3], [16, 16, 3], [16, -16, 3]], + } as const; + + expect(resolvePolyWorldBspViewSurfaceElements(tree, { + point: [0, 0, 1.2], + forward: [1, 0, -0.35], + fovDegrees: 48, + surfaces: [floor], + }).elementIds).toEqual(["floor-surface"]); + expect(resolvePolyWorldBspViewSurfaceElements(tree, { + point: [0, 0, 1.2], + forward: [1, 0, 0.35], + fovDegrees: 48, + surfaces: [ceiling], + }).elementIds).toEqual(["ceiling-surface"]); + }); + + it("keeps BSP leaf-owned surfaces by visible leaf membership", () => { + const tree = createPolyWorldBspTree({ + root: { leafId: "room" }, + leaves: [ + { + id: "room", + regionId: "room", + bounds: { min: [-8, -8, 0], max: [8, 8, 3] }, + }, + ], + }); + const vertices = [[-4, 7, 0], [-3, 7, 0], [-3, 7, 2], [-4, 7, 2]] as const; + + expect(resolvePolyWorldBspViewSurfaceElements(tree, { + point: [0, 0, 1], + forward: [-1, 0, 0], + fovDegrees: 16, + projection: "xy", + surfaces: [ + { + id: "leaf-side", + elementId: "leaf-side-surface", + regionId: "room", + leafId: "room", + vertices, + }, + { + id: "generic-side", + elementId: "generic-side-surface", + regionId: "room", + vertices, + }, + ], + }).elementIds).toEqual(["leaf-side-surface"]); + }); + + it("keeps structural BSP surfaces by visible leaf while clipping detail surfaces", () => { + const tree = createPolyWorldBspTree({ + root: { leafId: "room" }, + leaves: [ + { + id: "room", + regionId: "room", + bounds: { min: [-8, -8, 0], max: [8, 8, 3] }, + }, + ], + }); + const behindCameraWall = [[7, -1, 0], [7, 1, 0], [7, 1, 2], [7, -1, 2]] as const; + + const result = resolvePolyWorldBspViewSurfaceElements(tree, { + point: [0, 0, 1], + forward: [-1, 0, 0], + fovDegrees: 24, + projection: "xy", + surfaces: [ + { + id: "structural-wall", + elementId: "structural-wall-surface", + regionId: "room", + visibility: "structural", + vertices: behindCameraWall, + }, + { + id: "detail-wall", + elementId: "detail-wall-surface", + regionId: "room", + visibility: "detail", + vertices: behindCameraWall, + }, + ], + }); + + expect(result.elementIds).toEqual(["structural-wall-surface"]); + expect(result.structuralSurfaceIds).toEqual(["structural-wall"]); + expect(result.structuralElementIds).toEqual(["structural-wall-surface"]); + expect(result.detailSurfaceIds).toEqual([]); + expect(result.detailElementIds).toEqual([]); + }); + + it("uses BSP surface roles to keep openings stable while clipping props", () => { + const tree = createPolyWorldBspTree({ + root: { leafId: "room" }, + leaves: [ + { + id: "room", + regionId: "room", + bounds: { min: [-8, -8, 0], max: [8, 8, 3] }, + }, + ], + }); + const behindCameraWall = [[7, -1, 0], [7, 1, 0], [7, 1, 2], [7, -1, 2]] as const; + const result = resolvePolyWorldBspViewSurfaceElements(tree, { + point: [0, 0, 1], + forward: [-1, 0, 0], + fovDegrees: 24, + projection: "xy", + surfaces: [ + { + id: "door-frame", + elementId: "door-frame-surface", + regionId: "room", + role: "opening", + vertices: behindCameraWall, + }, + { + id: "crate", + elementId: "crate-surface", + regionId: "room", + role: "prop", + vertices: behindCameraWall, + }, + ], + }); + + expect(result.elementIds).toEqual(["door-frame-surface"]); + expect(result.structuralSurfaceIds).toEqual(["door-frame"]); + expect(result.structuralElementIds).toEqual(["door-frame-surface"]); + expect(result.detailSurfaceIds).toEqual([]); + expect(result.detailElementIds).toEqual([]); + expect(result.roles).toEqual([ + { + role: "opening", + count: 1, + surfaceIds: ["door-frame"], + elementIds: ["door-frame-surface"], + }, + ]); + }); + + it("clips BSP view PVS in 3D instead of only by projected yaw", () => { + const tree = bakePolyWorldBspPvs(createPolyWorldBspTree({ + root: { + id: "split-middle", + plane: { normal: [1, 0, 0], distance: -3 }, + front: { leafId: "middle" }, + back: { + id: "split-front-high", + plane: { normal: [0, 0, 1], distance: 5 }, + front: { leafId: "high" }, + back: { leafId: "front" }, + }, + }, + leaves: [ + { id: "middle", regionId: "middle", center: [0, 0, 1] }, + { id: "front", regionId: "front", center: [-6, 0, 1] }, + { id: "high", regionId: "high", center: [-6, 0, 9] }, + ], + portals: [ + { + id: "middle-front", + fromLeafId: "middle", + toLeafId: "front", + vertices: [[-4, -1, 0], [-4, 1, 0], [-4, 1, 2], [-4, -1, 2]], + }, + { + id: "middle-high", + fromLeafId: "middle", + toLeafId: "high", + vertices: [[-4, -1, 8], [-4, 1, 8], [-4, 1, 10], [-4, -1, 10]], + }, + ], + }), { projection: "xy" }); + + const broad = resolvePolyWorldBspPvs(tree, "middle", { projection: "xy" }); + const view = resolvePolyWorldBspViewPvs(tree, { + leafId: "middle", + point: [0, 0, 1], + forward: [-1, 0, 0], + up: [0, 0, 1], + aspect: 1, + fovDegrees: 60, + projection: "xy", + }); + + expect(broad.regionIds).toEqual(["middle", "front", "high"]); + expect(view.regionIds).toEqual(["middle", "front"]); + expect(view.regionIds).not.toContain("high"); + expect(view.portalIds).toEqual(["middle-front"]); + }); + + it("canonicalizes unordered manual BSP portal vertices before 3D PVS traversal", () => { + const tree = bakePolyWorldBspPvs(createPolyWorldBspTree({ + root: { + id: "split-middle-front", + plane: { normal: [1, 0, 0], distance: -3 }, + front: { leafId: "middle" }, + back: { leafId: "front" }, + }, + leaves: [ + { id: "middle", regionId: "middle", center: [0, 0, 1] }, + { id: "front", regionId: "front", center: [-6, 0, 1] }, + ], + portals: [ + { + id: "middle-front", + fromLeafId: "middle", + toLeafId: "front", + vertices: [[-4, 1, 2], [-4, -1, 0], [-4, -1, 2], [-4, 1, 0]], + }, + ], + }), { projection: "xy" }); + + const view = resolvePolyWorldBspViewPvs(tree, { + leafId: "middle", + point: [0, 0, 1], + forward: [-1, 0, 0], + fovDegrees: 70, + projection: "xy", + }); + + expect(tree.portalsById.get("middle-front")?.vertices).toEqual([ + [-4, -1, 0], + [-4, -1, 2], + [-4, 1, 2], + [-4, 1, 0], + ]); + expect(view.regionIds).toEqual(["middle", "front"]); + expect(view.portalIds).toEqual(["middle-front"]); + }); + + it("filters baked BSP PVS through dynamic portal state", () => { + const topology = createPolyWorldTopology({ + regions: [ + { id: "left" }, + { id: "middle" }, + { id: "right" }, + ], + links: [ + { id: "left-middle", fromRegionId: "left", toRegionId: "middle", selectionKeys: ["portal:left-middle"] }, + { id: "middle-right", fromRegionId: "middle", toRegionId: "right", selectionKeys: ["portal:middle-right"] }, + ], + elements: [ + { id: "left-shell", regionIds: ["left"], layers: ["world"] }, + { id: "middle-shell", regionIds: ["middle"], layers: ["world"] }, + { id: "right-shell", regionIds: ["right"], layers: ["world"] }, + ], + }); + const tree = compilePolyWorldBsp({ + regions: [ + { id: "left", bounds: { min: [-8, -2, 0], max: [-4, 2, 2] } }, + { id: "middle", bounds: { min: [-4, -2, 0], max: [4, 2, 2] } }, + { id: "right", bounds: { min: [4, -2, 0], max: [8, 2, 2] } }, + ], + portals: [ + { + id: "portal-left-middle", + fromRegionId: "left", + toRegionId: "middle", + linkId: "left-middle", + bounds: { min: [-4, -1, 0], max: [-4, 1, 2] }, + }, + { + id: "portal-middle-right", + fromRegionId: "middle", + toRegionId: "right", + linkId: "middle-right", + bounds: { min: [4, -1, 0], max: [4, 1, 2] }, + }, + ], + pvs: { projection: "xy" }, + }); + + const baked = resolvePolyWorldBspPvs(tree, "middle", { projection: "xy" }); + const closed = resolvePolyWorldBspViewPvs(tree, { + leafId: "middle", + point: [0, 0, 1], + forward: [-1, 0, 0], + fovDegrees: 90, + projection: "xy", + portalState: { "left-middle": "closed" }, + }); + const closedTrace = tracePolyWorldBspViewPvs(tree, { + leafId: "middle", + point: [0, 0, 1], + forward: [-1, 0, 0], + fovDegrees: 90, + projection: "xy", + portalState: { "left-middle": "closed" }, + }); + const selection = selectPolyWorldBspViewPvs(topology, tree, { + leafId: "middle", + point: [0, 0, 1], + forward: [-1, 0, 0], + fovDegrees: 90, + projection: "xy", + portalState: { "left-middle": false }, + }); + const resolution = resolvePolyWorldElements(topology, selection); + + expect(baked.regionIds).toEqual(["left", "middle", "right"]); + expect(closed.regionIds).toEqual(["middle"]); + expect(closed.portalIds).toEqual([]); + expect(closedTrace.entries.map((entry) => [entry.portalId, entry.status])).toEqual([ + ["portal-left-middle", "closed"], + ["portal-middle-right", "clipped"], + ]); + expect(resolution.elementIds).toEqual(["middle-shell"]); + }); + + it("distinguishes blocked BSP portals and depth-capped traversal in traces", () => { + const tree = compilePolyWorldBsp({ + regions: [ + { id: "left", bounds: { min: [-8, -2, 0], max: [-4, 2, 2] } }, + { id: "middle", bounds: { min: [-4, -2, 0], max: [4, 2, 2] } }, + { id: "right", bounds: { min: [4, -2, 0], max: [8, 2, 2] } }, + ], + portals: [ + { + id: "portal-left-middle", + fromRegionId: "left", + toRegionId: "middle", + linkId: "left-middle", + bounds: { min: [-4, -1, 0], max: [-4, 1, 2] }, + }, + { + id: "portal-middle-right", + fromRegionId: "middle", + toRegionId: "right", + linkId: "middle-right", + bounds: { min: [4, -1, 0], max: [4, 1, 2] }, + }, + ], + pvs: { projection: "xy" }, + }); + + const blockedTrace = tracePolyWorldBspViewPvs(tree, { + leafId: "middle", + point: [0, 0, 1], + forward: [-1, 0, 0], + fovDegrees: 90, + projection: "xy", + portalState: { "left-middle": "blocked" }, + }); + const depthCappedTrace = tracePolyWorldBspViewPvs(tree, { + leafId: "left", + point: [-6, 0, 1], + forward: [1, 0, 0], + fovDegrees: 360, + projection: "xy", + maxDepth: 1, + }); + + expect(blockedTrace.regionIds).toEqual(["middle"]); + expect(blockedTrace.entries.map((entry) => [entry.portalId, entry.status])).toEqual([ + ["portal-left-middle", "blocked"], + ["portal-middle-right", "clipped"], + ]); + expect(depthCappedTrace.regionIds).toEqual(["left", "middle"]); + expect(depthCappedTrace.entries.map((entry) => [entry.portalId, entry.fromLeafId, entry.toLeafId, entry.depth, entry.status])).toEqual([ + ["portal-left-middle", "left", "middle", 0, "visible"], + ["portal-middle-right", "middle", "right", 1, "depth-capped"], + ]); + }); + + it("reports portals outside the baked broad phase during deeper view traces", () => { + const tree = compilePolyWorldBsp({ + regions: [ + { id: "far", bounds: { min: [-12, -2, 0], max: [-8, 2, 2] } }, + { id: "left", bounds: { min: [-8, -2, 0], max: [-4, 2, 2] } }, + { id: "middle", bounds: { min: [-4, -2, 0], max: [4, 2, 2] } }, + ], + portals: [ + { + id: "middle-left", + fromRegionId: "middle", + toRegionId: "left", + linkId: "middle-left", + bounds: { min: [-4, -1, 0], max: [-4, 1, 2] }, + }, + { + id: "left-far", + fromRegionId: "left", + toRegionId: "far", + linkId: "left-far", + bounds: { min: [-8, -1, 0], max: [-8, 1, 2] }, + }, + ], + pvs: { projection: "xy", maxDepth: 1 }, + }); + + const baked = resolvePolyWorldBspBakedPvs(tree, "middle"); + const trace = tracePolyWorldBspViewPvs(tree, { + leafId: "middle", + point: [0, 0, 1], + forward: [-1, 0, 0], + fovDegrees: 360, + projection: "xy", + maxDepth: 4, + }); + const debug = createPolyWorldBspDebugSnapshot(tree, { + trace, + includeTraceEntries: true, + }); + + expect(baked?.leafIds).toEqual(["left", "middle"]); + expect(trace.regionIds).toEqual(["left", "middle"]); + expect(trace.entries.map((entry) => [entry.portalId, entry.fromLeafId, entry.toLeafId, entry.status])).toEqual([ + ["middle-left", "middle", "left", "visible"], + ["left-far", "left", "far", "outside-broad-phase"], + ]); + expect(debug.trace?.statusCounts).toEqual({ + visible: 1, + "outside-broad-phase": 1, + }); + expect(debug.trace?.entries?.map((entry) => [entry.portalId, entry.status])).toEqual([ + ["middle-left", "visible"], + ["left-far", "outside-broad-phase"], + ]); + }); + + it("rejects malformed BSP portal graphs", () => { + expect(() => + createPolyWorldBspTree({ + root: { leafId: "a" }, + leaves: [{ id: "a" }], + portals: [ + { + id: "bad-portal", + fromLeafId: "a", + toLeafId: "missing", + vertices: [[0, 0, 0]], + }, + ], + }), + ).toThrow(PolyWorldBspError); + expect(() => + createPolyWorldBspTree({ + root: { leafId: "a" }, + leaves: [{ id: "a" }, { id: "b" }], + portals: [ + { + id: "non-coplanar", + fromLeafId: "a", + toLeafId: "b", + vertices: [[0, 0, 0], [1, 0, 0], [1, 1, 0.2], [0, 1, 0]], + }, + ], + }), + ).toThrow(PolyWorldBspError); + expect(() => + createPolyWorldBspTree({ + root: { leafId: "a" }, + leaves: [{ id: "a" }, { id: "b" }], + portals: [ + { + id: "concave", + fromLeafId: "a", + toLeafId: "b", + vertices: [[0, 0, 0], [2, 0, 0], [1, 1, 0], [2, 2, 0], [0, 2, 0]], + }, + ], + }), + ).toThrow(PolyWorldBspError); + }); + + it("certifies malformed BSP topology without throwing so debug can report failed guarantees", () => { + const certification = certifyPolyWorldBspTopology({ + root: { leafId: "a" }, + leaves: [{ id: "a" }, { id: "orphan" }], + portals: [], + leavesById: new Map([ + ["a", { id: "a" }], + ["orphan", { id: "orphan" }], + ]), + portalsById: new Map(), + portalsByLeafId: new Map(), + }); + + expect(certification.certified).toBe(false); + expect(certification.diagnostics.map((diagnostic) => diagnostic.code)).toEqual([ + "poly-world-unreferenced-bsp-leaf", + "poly-world-unreachable-bsp-leaf", + ]); + expect(certification.proof.artifact.guarantees).toEqual([]); + expect(certification.proof.artifact.knownWeaknesses).toContain("bsp-certification-failed"); + expect(certification.proof.pvs.level).toBe("uncertified"); + expect(certification.proof.artifact.diagnostics.map((diagnostic) => diagnostic.code)).toEqual([ + "poly-world-unreferenced-bsp-leaf", + "poly-world-unreachable-bsp-leaf", + ]); + }); + + it("rejects BSP leaves that cannot be reached from the root or portal graph", () => { + const diagnostics = expectBspErrorCodes(() => + createPolyWorldBspTree({ + root: { leafId: "a" }, + leaves: [{ id: "a" }, { id: "orphan" }], + }), ["poly-world-unreachable-bsp-leaf"]); + + expect(diagnostics.find((diagnostic) => diagnostic.code === "poly-world-unreachable-bsp-leaf")).toMatchObject({ + id: "orphan", + }); + }); + + it("rejects BSP roots that do not reference each leaf exactly once", () => { + const diagnostics = expectBspErrorCodes(() => + createPolyWorldBspTree({ + root: { + id: "duplicate-root", + plane: { normal: [1, 0, 0], distance: 0 }, + back: { leafId: "a" }, + front: { leafId: "a" }, + }, + leaves: [ + { id: "a", bounds: { min: [-1, -1, 0], max: [0, 1, 1] } }, + { id: "b", bounds: { min: [0, -1, 0], max: [1, 1, 1] } }, + ], + portals: [ + { + id: "a-b", + fromLeafId: "a", + toLeafId: "b", + vertices: [[0, -1, 0], [0, 1, 0], [0, 1, 1], [0, -1, 1]], + }, + ], + }), [ + "poly-world-duplicate-bsp-leaf-ref", + "poly-world-unreferenced-bsp-leaf", + ]); + + expect(diagnostics).toEqual(expect.arrayContaining([ + expect.objectContaining({ + code: "poly-world-duplicate-bsp-leaf-ref", + id: "a", + field: "root", + }), + expect.objectContaining({ + code: "poly-world-unreferenced-bsp-leaf", + id: "b", + field: "root", + }), + ])); + }); + + it("rejects stale BSP PVS indices and bitsets with mismatched widths", () => { + expectBspErrorCodes(() => + createPolyWorldBspTree({ + root: { + id: "split", + plane: { normal: [1, 0, 0], distance: 0 }, + back: { leafId: "a" }, + front: { leafId: "b" }, + }, + leaves: [ + { + id: "a", + pvs: { + leafBits: new Uint32Array(0), + portalBits: new Uint32Array(0), + regionIds: [], + linkIds: [], + selectionKeys: [], + elementIds: [], + }, + }, + { id: "b" }, + ], + portals: [ + { + id: "ab", + fromLeafId: "a", + toLeafId: "b", + vertices: [[0, -1, 0], [0, 1, 0], [0, 1, 1], [0, -1, 1]], + }, + ], + pvsIndex: { + leafIds: ["a", "b"], + portalIds: ["ab"], + leafIndexById: new Map([["a", 1], ["b", 0]]), + portalIndexById: new Map([["ab", 0]]), + }, + }), [ + "poly-world-invalid-bsp-pvs-index-leaf-map", + "poly-world-invalid-bsp-pvs-leaf-bits-length", + "poly-world-invalid-bsp-pvs-portal-bits-length", + ]); + }); + + it("rejects baked BSP PVS metadata that disagrees with decoded bitsets", () => { + expectBspErrorCodes(() => + createPolyWorldBspTree({ + root: { + id: "split", + plane: { normal: [1, 0, 0], distance: 0 }, + back: { leafId: "a" }, + front: { leafId: "b" }, + }, + leaves: [ + { + id: "a", + regionId: "room-a", + elementIds: ["room-a-shell"], + pvs: { + leafBits: new Uint32Array([1]), + portalBits: new Uint32Array([0]), + regionIds: ["room-b"], + linkIds: ["a-b"], + selectionKeys: ["portal:a-b"], + elementIds: ["stale-shell"], + }, + }, + { id: "b", regionId: "room-b", elementIds: ["room-b-shell"] }, + ], + portals: [ + { + id: "a-b", + fromLeafId: "a", + toLeafId: "b", + linkId: "a-b", + selectionKeys: ["portal:a-b"], + vertices: [[0, -1, 0], [0, 1, 0], [0, 1, 1], [0, -1, 1]], + }, + ], + pvsIndex: { + leafIds: ["a", "b"], + portalIds: ["a-b"], + leafIndexById: new Map([["a", 0], ["b", 1]]), + portalIndexById: new Map([["a-b", 0]]), + }, + }), [ + "poly-world-bsp-pvs-region-ids-metadata-mismatch", + "poly-world-bsp-pvs-link-ids-metadata-mismatch", + "poly-world-bsp-pvs-selection-keys-metadata-mismatch", + "poly-world-bsp-pvs-element-ids-metadata-mismatch", + ]); + }); + + it("rejects baked BSP PVS bitsets that do not match portal reachability", () => { + expectBspErrorCodes(() => + createPolyWorldBspTree({ + root: { + id: "split-a", + plane: { normal: [1, 0, 0], distance: 0 }, + back: { leafId: "a" }, + front: { + id: "split-b", + plane: { normal: [1, 0, 0], distance: 1 }, + back: { leafId: "b" }, + front: { leafId: "c" }, + }, + }, + leaves: [ + { + id: "a", + regionId: "room-a", + elementIds: ["room-a-shell"], + pvs: { + leafBits: testBitset(3, [0, 2]), + portalBits: testBitset(2, []), + regionIds: ["room-a", "room-c"], + linkIds: [], + selectionKeys: [], + elementIds: ["room-a-shell", "room-c-shell"], + }, + }, + { id: "b", regionId: "room-b", elementIds: ["room-b-shell"] }, + { id: "c", regionId: "room-c", elementIds: ["room-c-shell"] }, + ], + portals: [ + { + id: "a-b", + fromLeafId: "a", + toLeafId: "b", + linkId: "a-b", + vertices: [[0, -1, 0], [0, 1, 0], [0, 1, 1], [0, -1, 1]], + }, + { + id: "b-c", + fromLeafId: "b", + toLeafId: "c", + linkId: "b-c", + vertices: [[1, -1, 0], [1, 1, 0], [1, 1, 1], [1, -1, 1]], + }, + ], + pvsIndex: createTestPvsIndex(["a", "b", "c"], ["a-b", "b-c"]), + }), ["poly-world-bsp-pvs-unreachable-leaf"]); + + expectBspErrorCodes(() => + createPolyWorldBspTree({ + root: { + id: "split", + plane: { normal: [1, 0, 0], distance: 0 }, + back: { leafId: "a" }, + front: { leafId: "b" }, + }, + leaves: [ + { + id: "a", + regionId: "room-a", + elementIds: ["room-a-shell"], + pvs: { + leafBits: testBitset(2, [1]), + portalBits: testBitset(1, [0]), + regionIds: ["room-b"], + linkIds: ["a-b"], + selectionKeys: [], + elementIds: ["room-b-shell"], + }, + }, + { id: "b", regionId: "room-b", elementIds: ["room-b-shell"] }, + ], + portals: [ + { + id: "a-b", + fromLeafId: "a", + toLeafId: "b", + linkId: "a-b", + vertices: [[0, -1, 0], [0, 1, 0], [0, 1, 1], [0, -1, 1]], + }, + ], + pvsIndex: createTestPvsIndex(["a", "b"], ["a-b"]), + }), [ + "poly-world-bsp-pvs-missing-source-leaf", + "poly-world-bsp-pvs-portal-outside-leaf-set", + ]); + + expectBspErrorCodes(() => + createPolyWorldBspTree({ + root: { + id: "split", + plane: { normal: [1, 0, 0], distance: 0 }, + back: { leafId: "a" }, + front: { leafId: "b" }, + }, + leaves: [ + { + id: "a", + regionId: "room-a", + elementIds: ["room-a-shell"], + pvs: { + leafBits: testBitset(2, [0]), + portalBits: testBitset(1, []), + regionIds: ["room-a"], + linkIds: [], + selectionKeys: [], + elementIds: ["room-a-shell"], + }, + }, + { id: "b", regionId: "room-b", elementIds: ["room-b-shell"] }, + ], + portals: [ + { + id: "a-b", + fromLeafId: "a", + toLeafId: "b", + linkId: "a-b", + vertices: [[0, -1, 0], [0, 1, 0], [0, 1, 1], [0, -1, 1]], + }, + ], + pvsIndex: createTestPvsIndex(["a", "b"], ["a-b"]), + }), [ + "poly-world-bsp-pvs-missing-adjacent-portal", + "poly-world-bsp-pvs-missing-adjacent-leaf", + ]); + }); + + it("rejects BSP portals through solid leaves or outside adjacent leaf bounds", () => { + expectBspErrorCodes(() => + createPolyWorldBspTree({ + root: { + id: "split", + plane: { normal: [1, 0, 0], distance: 0 }, + back: { leafId: "a" }, + front: { leafId: "b" }, + }, + leaves: [ + { id: "a", bounds: { min: [-1, -1, 0], max: [0, 1, 1] } }, + { id: "b", bounds: { min: [0, -1, 0], max: [1, 1, 1] }, data: { solid: true } }, + ], + portals: [ + { + id: "solid-portal", + fromLeafId: "a", + toLeafId: "b", + vertices: [[0, -0.5, 0], [0, 0.5, 0], [0, 0.5, 1], [0, -0.5, 1]], + }, + ], + }), ["poly-world-bsp-portal-solid-leaf"]); + + expectBspErrorCodes(() => + createPolyWorldBspTree({ + root: { + id: "split", + plane: { normal: [1, 0, 0], distance: 0 }, + back: { leafId: "a" }, + front: { leafId: "b" }, + }, + leaves: [ + { id: "a", bounds: { min: [-1, -1, 0], max: [0, 1, 1] } }, + { id: "b", bounds: { min: [0, -1, 0], max: [1, 1, 1] } }, + ], + portals: [ + { + id: "offset-portal", + fromLeafId: "a", + toLeafId: "b", + vertices: [[0.5, -0.5, 0], [0.5, 0.5, 0], [0.5, 0.5, 1], [0.5, -0.5, 1]], + }, + ], + }), [ + "poly-world-bsp-portal-vertices-outside-leaf-bounds", + "poly-world-bsp-portal-not-on-shared-bounds-face", + ]); + }); + + it("rejects malformed BSP compiler input", () => { + expect(() => + compilePolyWorldBsp({ + regions: [ + { id: "a", bounds: { min: [0, 0, 0], max: [4, 4, 2] } }, + ], + portals: [ + { id: "bad", fromRegionId: "a", toRegionId: "missing" }, + ], + }), + ).toThrow(PolyWorldBspError); + }); + + it("resolves a BSP leaf and selects its precomputed visibility set", () => { + const topology = createPolyWorldTopology({ + regions: [ + { id: "left" }, + { id: "middle" }, + { id: "right" }, + ], + links: [ + { id: "left-middle", fromRegionId: "left", toRegionId: "middle", selectionKeys: ["portal:left-middle"] }, + { id: "middle-right", fromRegionId: "middle", toRegionId: "right", selectionKeys: ["portal:middle-right"] }, + ], + elements: [ + { id: "left-shell", regionIds: ["left"], layers: ["world"] }, + { id: "middle-shell", regionIds: ["middle"], layers: ["world"] }, + { id: "right-shell", regionIds: ["right"], layers: ["world"] }, + { id: "left-door", selectionKeys: ["portal:left-middle"], layers: ["world"] }, + { id: "right-door", selectionKeys: ["portal:middle-right"], layers: ["world"] }, + ], + }); + const pvsIndex = createTestPvsIndex(["left-leaf", "middle-leaf", "right-leaf"], [ + "portal-left-middle", + "portal-middle-right", + ]); + const tree = createPolyWorldBspTree({ + root: { + id: "split-left", + plane: { normal: [1, 0, 0], distance: -4 }, + back: { leafId: "left-leaf" }, + front: { + id: "split-right", + plane: { normal: [1, 0, 0], distance: 4 }, + back: { leafId: "middle-leaf" }, + front: { leafId: "right-leaf" }, + }, + }, + leaves: [ + { + id: "left-leaf", + regionId: "left", + elementIds: ["left-shell"], + pvs: { + leafBits: testBitset(3, [0, 1]), + portalBits: testBitset(2, [0]), + regionIds: ["left", "middle"], + linkIds: ["left-middle"], + selectionKeys: ["portal:left-middle"], + elementIds: ["left-shell", "middle-shell"], + }, + }, + { + id: "middle-leaf", + regionId: "middle", + elementIds: ["middle-shell"], + pvs: { + leafBits: testBitset(3, [0, 1, 2]), + portalBits: testBitset(2, [0, 1]), + regionIds: ["left", "middle", "right"], + linkIds: ["left-middle", "middle-right"], + selectionKeys: ["portal:left-middle", "portal:middle-right"], + elementIds: ["left-shell", "middle-shell", "right-shell"], + }, + }, + { + id: "right-leaf", + regionId: "right", + elementIds: ["right-shell"], + pvs: { + leafBits: testBitset(3, [1, 2]), + portalBits: testBitset(2, [1]), + regionIds: ["middle", "right"], + linkIds: ["middle-right"], + selectionKeys: ["portal:middle-right"], + elementIds: ["middle-shell", "right-shell"], + }, + }, + ], + portals: [ + { + id: "portal-left-middle", + fromLeafId: "left-leaf", + toLeafId: "middle-leaf", + linkId: "left-middle", + selectionKeys: ["portal:left-middle"], + vertices: [[-4, -1, 0], [-4, 1, 0], [-4, 1, 2], [-4, -1, 2]], + }, + { + id: "portal-middle-right", + fromLeafId: "middle-leaf", + toLeafId: "right-leaf", + linkId: "middle-right", + selectionKeys: ["portal:middle-right"], + vertices: [[4, -1, 0], [4, 1, 0], [4, 1, 2], [4, -1, 2]], + }, + ], + pvsIndex, + }); + + const leaf = resolvePolyWorldBspLeaf(tree, [-5, 0, 0]); + const selection = selectPolyWorldBspPvs(topology, tree, { point: [-5, 0, 0] }); + const resolution = resolvePolyWorldElements(topology, selection); + + expect(leaf?.leafId).toBe("left-leaf"); + expect(leaf?.path).toEqual(["split-left"]); + expect(selection.regionIds).toEqual(["left", "middle"]); + expect(selection.linkIds).toEqual(["left-middle"]); + expect(selection.selectionKeys).toEqual(["portal:left-middle"]); + expect(selection.reasons?.map((reason) => reason.label)).toEqual([ + "bsp-leaf", + "pvs", + "selection-key", + ]); + expect(resolution.elementIds).toEqual(["left-shell", "middle-shell", "left-door"]); + }); +}); + +describe("selectPolyWorldChunkWindow", () => { + it("selects an ordered chunk window, active chunks, tagged regions, and region-derived selection keys", () => { + const topology = createPolyWorldTopology({ + regions: [ + { id: "chunk-0" }, + { id: "chunk-1" }, + { id: "chunk-2" }, + { id: "chunk-3" }, + { id: "chunk-4" }, + ], + elements: [ + { id: "road-1", regionIds: ["chunk-1"], layers: ["world"], tags: ["road"] }, + { id: "road-2", regionIds: ["chunk-2"], layers: ["world"], tags: ["road"] }, + { id: "road-3", regionIds: ["chunk-3"], layers: ["world"], tags: ["road"] }, + { id: "track-3", selectionKeys: ["track:chunk-3"], layers: ["track"], tags: ["source-track"] }, + { + id: "shared-sky", + regionIds: ["chunk-1", "chunk-2", "chunk-3"], + regionMatch: "any", + layers: ["sky"], + }, + ], + }); + + const selection = selectPolyWorldChunkWindow(topology, { + currentRegionId: "chunk-2", + before: 1, + after: 1, + activeRegionIds: ["chunk-0"], + taggedRegionSelections: [ + { + kind: "overscan", + label: "safety-overscan", + regionIds: ["chunk-4"], + tags: ["safety"], + selectionKeys: ["track:chunk-3"], + }, + ], + regionSelectionKeys: { + "chunk-1": ["section:1"], + "chunk-2": ["section:2"], + "chunk-3": ["section:3"], + }, + }); + const resolution = resolvePolyWorldElements(topology, selection); + + expect(selection.regionIds).toEqual(["chunk-0", "chunk-2", "chunk-1", "chunk-3", "chunk-4"]); + expect(selection.selectionKeys).toEqual(["track:chunk-3", "section:2", "section:1", "section:3"]); + expect(selection.reasons?.map((reason) => reason.label)).toEqual([ + "active", + "current", + "window", + "safety-overscan", + "selection-key", + ]); + expect(resolution.elementIds).toEqual(["road-1", "road-2", "road-3", "track-3", "shared-sky"]); + }); + + it("selects streaming-source chunk ranges with separate loaded, resident, active, rendered, and preloaded sets", () => { + const topology = createPolyWorldTopology({ + regions: [ + { id: "chunk-0", bounds: { min: [0, 0, 0], max: [1, 1, 1] }, selectionKeys: ["chunk:0"] }, + { id: "chunk-1", bounds: { min: [1, 0, 0], max: [2, 1, 1] }, selectionKeys: ["chunk:1"] }, + { id: "chunk-2", bounds: { min: [2, 0, 0], max: [3, 1, 1] }, selectionKeys: ["chunk:2"] }, + { id: "chunk-3", bounds: { min: [3, 0, 0], max: [4, 1, 1] }, selectionKeys: ["chunk:3"] }, + { id: "chunk-4", bounds: { min: [4, 0, 0], max: [5, 1, 1] }, selectionKeys: ["chunk:4"] }, + { id: "chunk-5", bounds: { min: [5, 0, 0], max: [6, 1, 1] }, selectionKeys: ["chunk:5"] }, + ], + elements: [ + { id: "road-1", regionIds: ["chunk-1"], layers: ["world"], tags: ["road"] }, + { id: "road-2", regionIds: ["chunk-2"], layers: ["world"], tags: ["road"] }, + { id: "road-3", regionIds: ["chunk-3"], layers: ["world"], tags: ["road"] }, + { id: "road-4", regionIds: ["chunk-4"], layers: ["world"], tags: ["road"] }, + { id: "road-5", regionIds: ["chunk-5"], layers: ["world"], tags: ["road"] }, + { + id: "distant-banner", + regionIds: ["chunk-4", "chunk-5"], + regionMatch: "any", + layers: ["world"], + tags: ["decor"], + }, + ], + }); + + const selection = selectPolyWorldChunkStreaming(topology, { + orderedRegionIds: ["chunk-0", "chunk-1", "chunk-2", "chunk-3", "chunk-4", "chunk-5"], + loadedRegionIds: ["chunk-1"], + residentRegionIds: ["chunk-1"], + preloadedRegionIds: ["chunk-0"], + sources: [ + { + id: "player-car", + point: [2.25, 0.5, 0.5], + before: 1, + after: 2, + targetState: "rendered", + priority: 10, + label: "player-stream", + selectionKeys: ["car:player"], + }, + { + id: "far-interest", + regionId: "chunk-5", + targetState: "loaded", + label: "far-load", + }, + { + id: "missing", + regionId: "chunk-x", + targetState: "loaded", + }, + ], + }); + const resolution = resolvePolyWorldElements(topology, selection); + const renderSelection = selectPolyWorldChunkStreamingState(topology, selection, "rendered", { + reasonLabel: "rendered-chunks", + }); + const renderResolution = resolvePolyWorldElements(topology, renderSelection); + + expect(selection.regionIds).toEqual(["chunk-1", "chunk-2", "chunk-3", "chunk-4", "chunk-5"]); + expect(selection.selectionKeys).toEqual([ + "car:player", + "chunk:1", + "chunk:2", + "chunk:3", + "chunk:4", + "chunk:5", + ]); + expect(selection.streaming).toEqual({ + requestedRegionIds: ["chunk-1", "chunk-2", "chunk-3", "chunk-4", "chunk-5"], + loadingRegionIds: ["chunk-2", "chunk-3", "chunk-4", "chunk-5"], + loadedRegionIds: ["chunk-1", "chunk-2", "chunk-3", "chunk-4", "chunk-5"], + residentRegionIds: ["chunk-1", "chunk-2", "chunk-3", "chunk-4"], + activeRegionIds: ["chunk-1", "chunk-2", "chunk-3", "chunk-4"], + renderedRegionIds: ["chunk-1", "chunk-2", "chunk-3", "chunk-4"], + preloadedRegionIds: ["chunk-0"], + missingRegionIds: ["chunk-x"], + sources: [ + { + sourceId: "player-car", + currentRegionId: "chunk-2", + selectedRegionIds: ["chunk-1", "chunk-2", "chunk-3", "chunk-4"], + targetState: "rendered", + priority: 10, + label: "player-stream", + tags: undefined, + data: undefined, + }, + { + sourceId: "far-interest", + currentRegionId: "chunk-5", + selectedRegionIds: ["chunk-5"], + targetState: "loaded", + priority: 0, + label: "far-load", + tags: undefined, + data: undefined, + }, + { + sourceId: "missing", + selectedRegionIds: [], + targetState: "loaded", + priority: 0, + label: "streaming-source", + tags: undefined, + missingRegionId: "chunk-x", + data: undefined, + }, + ], + }); + expect(selection.reasons?.map((reason) => reason.label)).toEqual([ + "player-stream", + "far-load", + "selection-key", + ]); + expect(resolution.elementIds).toEqual([ + "road-1", + "road-2", + "road-3", + "road-4", + "road-5", + "distant-banner", + ]); + expect(renderSelection.regionIds).toEqual(["chunk-1", "chunk-2", "chunk-3", "chunk-4"]); + expect(renderSelection.selectionKeys).toEqual(["chunk:1", "chunk:2", "chunk:3", "chunk:4"]); + expect(renderSelection.reasons?.map((reason) => reason.label)).toEqual(["rendered-chunks"]); + expect(renderResolution.elementIds).toEqual([ + "road-1", + "road-2", + "road-3", + "road-4", + "distant-banner", + ]); + }); + + it("expands streaming sources through a chunk graph without collapsing rendered state", () => { + const topology = createPolyWorldTopology({ + regions: [ + { id: "world", selectionKeys: ["chunk:world"] }, + { id: "sector-a", selectionKeys: ["chunk:sector-a"] }, + { id: "tile-a", selectionKeys: ["chunk:tile-a"] }, + { id: "tile-b", selectionKeys: ["chunk:tile-b"] }, + { id: "tile-c", selectionKeys: ["chunk:tile-c"] }, + ], + elements: [ + { id: "world-root", regionIds: ["world"], layers: ["world"] }, + { id: "sector-a-shell", regionIds: ["sector-a"], layers: ["world"] }, + { id: "tile-a-road", regionIds: ["tile-a"], layers: ["world"] }, + { id: "tile-b-road", regionIds: ["tile-b"], layers: ["world"] }, + { id: "tile-c-prop", regionIds: ["tile-c"], layers: ["world"] }, + ], + }); + + const selection = selectPolyWorldChunkStreaming(topology, { + chunkGraph: { + parentRegionIds: { + "sector-a": "world", + "tile-a": "sector-a", + }, + childRegionIds: { + "sector-a": ["tile-a", "tile-b"], + }, + relatedRegionIds: { + "tile-a": ["tile-c", "missing-tile"], + }, + }, + sources: [ + { + id: "camera", + regionId: "tile-a", + targetState: "resident", + priority: 10, + chunkGraphExpansion: { includeParents: true, includeRelated: true, recursive: true }, + }, + { + id: "sector-render", + regionId: "sector-a", + targetState: "rendered", + priority: 5, + chunkGraphExpansion: { includeChildren: true }, + }, + ], + }); + const rendered = selectPolyWorldChunkStreamingState(topology, selection, "rendered"); + const residentResolution = resolvePolyWorldElements(topology, selection); + const renderedResolution = resolvePolyWorldElements(topology, rendered); + + expect(selection.regionIds).toEqual(["tile-a", "sector-a", "tile-c", "world", "tile-b"]); + expect(selection.selectionKeys).toEqual([ + "chunk:tile-a", + "chunk:sector-a", + "chunk:tile-c", + "chunk:world", + "chunk:tile-b", + ]); + expect(selection.streaming.loadedRegionIds).toEqual(["sector-a", "tile-a", "tile-b", "tile-c", "world"]); + expect(selection.streaming.residentRegionIds).toEqual(["sector-a", "tile-a", "tile-b", "tile-c", "world"]); + expect(selection.streaming.renderedRegionIds).toEqual(["sector-a", "tile-a", "tile-b"]); + expect(selection.streaming.missingRegionIds).toEqual(["missing-tile"]); + expect(selection.streaming.sources[0]?.graphRegionIds).toEqual(["sector-a", "tile-c", "world"]); + expect(selection.streaming.sources[0]?.missingRegionIds).toEqual(["missing-tile"]); + expect(selection.streaming.sources[1]?.graphRegionIds).toEqual(["tile-a", "tile-b"]); + expect(residentResolution.elementIds).toEqual([ + "world-root", + "sector-a-shell", + "tile-a-road", + "tile-b-road", + "tile-c-prop", + ]); + expect(rendered.regionIds).toEqual(["sector-a", "tile-a", "tile-b"]); + expect(renderedResolution.elementIds).toEqual(["sector-a-shell", "tile-a-road", "tile-b-road"]); + }); + + it("uses a validated chunk tree as the streaming graph source", () => { + const topology = createPolyWorldTopology({ + regions: [ + { id: "world", bounds: { min: [0, 0, 0], max: [8, 8, 4] }, selectionKeys: ["chunk:world"] }, + { id: "sector-a", bounds: { min: [0, 0, 0], max: [4, 4, 3] }, selectionKeys: ["chunk:sector-a"] }, + { id: "tile-a", bounds: { min: [0, 0, 0], max: [2, 2, 2] }, selectionKeys: ["chunk:tile-a"] }, + { id: "tile-b", bounds: { min: [2, 0, 0], max: [4, 2, 2] }, selectionKeys: ["chunk:tile-b"] }, + ], + elements: [ + { id: "world-root", regionIds: ["world"], layers: ["world"] }, + { id: "sector-shell", regionIds: ["sector-a"], layers: ["world"] }, + { id: "tile-a-road", regionIds: ["tile-a"], layers: ["world"] }, + { id: "tile-b-road", regionIds: ["tile-b"], layers: ["world"] }, + ], + }); + const chunkTree = createPolyWorldChunkTree({ + chunks: [ + { + id: "world", + regionId: "world", + childIds: ["sector-a"], + available: true, + contentAvailable: true, + refinement: "add", + }, + { + id: "sector-a", + regionId: "sector-a", + parentId: "world", + childIds: ["tile-a", "tile-b"], + available: true, + contentAvailable: true, + geometricError: 2, + }, + { + id: "tile-a", + regionId: "tile-a", + parentId: "sector-a", + available: true, + contentAvailable: true, + resourceIds: ["mesh:tile-a"], + }, + { + id: "tile-b", + regionId: "tile-b", + parentId: "sector-a", + available: true, + contentAvailable: false, + }, + ], + }, { topology }); + + const selection = selectPolyWorldChunkStreaming(topology, { + chunkTree, + sources: [ + { + id: "camera", + regionId: "tile-a", + targetState: "rendered", + chunkGraphExpansion: { + includeParents: true, + recursive: true, + targetState: "resident", + }, + }, + { + id: "sector", + regionId: "sector-a", + targetState: "rendered", + chunkGraphExpansion: { includeChildren: true }, + }, + ], + }); + const rendered = selectPolyWorldChunkStreamingState(topology, selection, "rendered"); + const renderedResolution = resolvePolyWorldElements(topology, rendered); + + expect(chunkTree.rootChunkIds).toEqual(["world"]); + expect(chunkTree.availableChunkIds).toEqual(["world", "sector-a", "tile-a", "tile-b"]); + expect(chunkTree.contentChunkIds).toEqual(["world", "sector-a", "tile-a"]); + expect(selection.regionIds).toEqual(["tile-a", "sector-a", "world", "tile-b"]); + expect(selection.streaming.loadedRegionIds).toEqual(["sector-a", "tile-a", "tile-b", "world"]); + expect(selection.streaming.residentRegionIds).toEqual(["sector-a", "tile-a", "tile-b", "world"]); + expect(selection.streaming.renderedRegionIds).toEqual(["sector-a", "tile-a", "tile-b"]); + expect(selection.streaming.chunkTree).toEqual({ + chunkCount: 4, + rootChunkIds: ["world"], + availableChunkIds: ["world", "sector-a", "tile-a", "tile-b"], + contentChunkIds: ["world", "sector-a", "tile-a"], + maxDepth: 2, + }); + expect(selection.streaming.sources[0]).toMatchObject({ + sourceId: "camera", + selectedRegionIds: ["tile-a", "sector-a", "world"], + graphRegionIds: ["sector-a", "world"], + graphTargetState: "resident", + }); + expect(selection.streaming.sources[1]).toMatchObject({ + sourceId: "sector", + selectedRegionIds: ["sector-a", "tile-a", "tile-b"], + graphRegionIds: ["tile-a", "tile-b"], + }); + expect(rendered.regionIds).toEqual(["sector-a", "tile-a", "tile-b"]); + expect(renderedResolution.elementIds).toEqual(["sector-shell", "tile-a-road", "tile-b-road"]); + }); + + it("resolves budgeted chunk tree traversal with requested, held, skipped, unavailable, and clipped chunks", () => { + const chunkTree = createPolyWorldChunkTree({ + chunks: [ + { + id: "world", + regionId: "world", + childIds: ["sector-a", "sector-b"], + available: true, + contentAvailable: true, + refinement: "add", + cost: 1, + }, + { + id: "sector-a", + regionId: "sector-a", + parentId: "world", + childIds: ["tile-a", "tile-b", "tile-c"], + available: true, + contentAvailable: true, + refinement: "replace", + geometricError: 4, + cost: 1, + }, + { + id: "tile-a", + regionId: "tile-a", + parentId: "sector-a", + available: true, + contentAvailable: true, + priority: 10, + cost: 2, + }, + { + id: "tile-b", + regionId: "tile-b", + parentId: "sector-a", + available: true, + contentAvailable: false, + priority: 5, + cost: 1, + }, + { + id: "tile-c", + regionId: "tile-c", + parentId: "sector-a", + childIds: ["tile-c-detail"], + available: true, + contentAvailable: true, + priority: 1, + geometricError: 0.5, + cost: 3, + }, + { + id: "tile-c-detail", + regionId: "tile-c-detail", + parentId: "tile-c", + available: true, + contentAvailable: true, + cost: 1, + }, + { + id: "sector-b", + regionId: "sector-b", + parentId: "world", + available: false, + contentAvailable: false, + }, + ], + }); + + const traversal = resolvePolyWorldChunkTreeTraversal(chunkTree, { + currentRegionId: "tile-a", + budget: { + maxRenderedChunks: 2, + maxLoadedChunks: 4, + maxRenderCost: 3, + maxLoadCost: 10, + targetGeometricError: 1, + }, + }); + + expect(traversal.currentChunkId).toBe("tile-a"); + expect(traversal.refinedChunkIds).toEqual(["world", "sector-a"]); + expect(traversal.renderedChunkIds).toEqual(["world", "tile-a"]); + expect(traversal.loadedChunkIds).toEqual(["world", "sector-a", "tile-a", "tile-c"]); + expect(traversal.residentChunkIds).toEqual(["world", "sector-a", "tile-a", "tile-c"]); + expect(traversal.requestedChunkIds).toEqual(["tile-b"]); + expect(traversal.heldChunkIds).toEqual(["sector-a", "tile-c"]); + expect(traversal.unavailableChunkIds).toEqual(["sector-b"]); + expect(traversal.viewCulledChunkIds).toEqual([]); + expect(traversal.outsideRequestVolumeChunkIds).toEqual([]); + expect(traversal.skippedChunkIds).toEqual(["tile-c-detail"]); + expect(traversal.budgetClippedChunkIds).toEqual(["tile-c"]); + expect(traversal.selectedRegionIds).toEqual(["world", "sector-a", "tile-a", "tile-b", "tile-c"]); + expect(traversal.renderedRegionIds).toEqual(["world", "tile-a"]); + expect(traversal.requestedRegionIds).toEqual(["tile-b"]); + expect(traversal.totalRenderCost).toBe(3); + expect(traversal.totalLoadCost).toBe(7); + expect(traversal.entries.find((entry) => entry.chunkId === "sector-a")?.reasons).toEqual([ + "ancestor", + "refined", + "loaded", + "held", + "resident", + ]); + expect(traversal.entries.find((entry) => entry.chunkId === "tile-c")?.reasons).toEqual([ + "loaded", + "budget-clipped", + "held", + ]); + expect(traversal.entries.find((entry) => entry.chunkId === "tile-c-detail")?.reasons).toEqual(["skipped"]); + }); + + it("uses the chunk-track fixture for SSE, hold, request, cull, and unavailable states", () => { + const fixture = createPolyWorldChunkTrackFixture(); + const traversal = resolvePolyWorldChunkTreeTraversal(fixture.chunkTree, { + currentRegionId: "track-a", + ...fixture.camera, + budget: { + maxRenderedChunks: 2, + maxLoadedChunks: 5, + maxRenderCost: 3, + maxScreenSpaceError: 24, + }, + }); + + expect(traversal.currentChunkId).toBe("track-a"); + expect(traversal.screenSpaceError).toMatchObject({ + viewportHeight: 420, + maxError: 24, + }); + expect(traversal.refinedChunkIds).toEqual(["track-world", "track-sector", "track-c"]); + expect(traversal.renderedChunkIds).toEqual(["track-world", "track-a"]); + expect(traversal.loadedChunkIds).toEqual([ + "track-world", + "track-sector", + "track-a", + "track-c", + "track-c-detail", + ]); + expect(traversal.requestedChunkIds).toEqual(["track-b"]); + expect(traversal.heldChunkIds).toEqual(["track-sector", "track-c", "track-c-detail"]); + expect(traversal.unavailableChunkIds).toEqual(["track-unavailable"]); + expect(traversal.viewCulledChunkIds).toEqual(["track-side"]); + expect(traversal.outsideRequestVolumeChunkIds).toEqual(["track-request-gated"]); + expect(traversal.skippedChunkIds).toEqual(["track-side", "track-request-gated"]); + expect(traversal.budgetClippedChunkIds).toEqual(["track-c-detail"]); + expect(traversal.entries.find((entry) => entry.chunkId === "track-sector")?.reasons).toEqual([ + "ancestor", + "refined", + "loaded", + "held", + "resident", + ]); + expect(traversal.entries.find((entry) => entry.chunkId === "track-b")?.reasons).toEqual([ + "requested", + ]); + expect(traversal.entries.find((entry) => entry.chunkId === "track-c")?.reasons).toEqual([ + "refined", + "loaded", + "held", + "resident", + ]); + expect(traversal.entries.find((entry) => entry.chunkId === "track-c-detail")?.reasons).toEqual([ + "loaded", + "budget-clipped", + "held", + ]); + expect(traversal.entries.find((entry) => entry.chunkId === "track-side")?.reasons).toEqual([ + "view-culled", + "skipped", + ]); + expect(traversal.entries.find((entry) => entry.chunkId === "track-request-gated")?.reasons).toEqual([ + "outside-request-volume", + "skipped", + ]); + }); + + it("refines chunk trees by screen-space error when viewport data is available", () => { + const chunkTree = createPolyWorldChunkTree({ + chunks: [ + { + id: "sector", + regionId: "sector", + childIds: ["tile"], + bounds: { min: [10, -1, -1], max: [30, 1, 1] }, + available: true, + contentAvailable: true, + refinement: "add", + geometricError: 10, + }, + { + id: "tile", + regionId: "tile", + parentId: "sector", + bounds: { min: [12, -1, -1], max: [14, 1, 1] }, + available: true, + contentAvailable: true, + geometricError: 0.5, + }, + ], + }); + + const traversal = resolvePolyWorldChunkTreeTraversal(chunkTree, { + point: [0, 0, 0], + forward: [1, 0, 0], + fovDegrees: 90, + aspect: 1, + viewportHeight: 100, + budget: { + maxScreenSpaceError: 40, + targetGeometricError: 0, + }, + }); + const sectorEntry = traversal.entries.find((entry) => entry.chunkId === "sector"); + const tileEntry = traversal.entries.find((entry) => entry.chunkId === "tile"); + + expect(traversal.screenSpaceError).toEqual({ + viewportHeight: 100, + fovDegrees: 90, + maxError: 40, + distanceFloor: 0.0001, + }); + expect(traversal.budget).toEqual({ + maxScreenSpaceError: 40, + targetGeometricError: 0, + }); + expect(traversal.refinedChunkIds).toEqual(["sector"]); + expect(traversal.renderedChunkIds).toEqual(["sector", "tile"]); + expect(sectorEntry?.distanceToCamera).toBe(10); + expect(sectorEntry?.screenSpaceError).toBeCloseTo(50); + expect(sectorEntry?.reasons).toEqual(["root", "refined", "loaded", "rendered"]); + expect(tileEntry?.distanceToCamera).toBe(12); + expect(tileEntry?.screenSpaceError).toBeCloseTo(2.083333, 5); + + const belowThreshold = resolvePolyWorldChunkTreeTraversal(chunkTree, { + point: [0, 0, 0], + forward: [1, 0, 0], + fovDegrees: 90, + aspect: 1, + viewportHeight: 100, + budget: { + maxScreenSpaceError: 60, + targetGeometricError: 0, + }, + }); + expect(belowThreshold.refinedChunkIds).toEqual([]); + expect(belowThreshold.renderedChunkIds).toEqual(["sector"]); + expect(belowThreshold.skippedChunkIds).toEqual(["tile"]); + + const fallback = resolvePolyWorldChunkTreeTraversal(chunkTree, { + budget: { + targetGeometricError: 5, + }, + }); + expect(fallback.screenSpaceError).toBeUndefined(); + expect(fallback.refinedChunkIds).toEqual(["sector"]); + }); + + it("filters chunk tree traversal with camera frustum data while keeping the active path", () => { + const chunkTree = createPolyWorldChunkTree({ + chunks: [ + { + id: "world", + regionId: "world", + childIds: ["front", "side"], + bounds: { min: [-1, -2, -1], max: [8, 8, 1] }, + available: true, + contentAvailable: true, + refinement: "add", + cost: 1, + }, + { + id: "front", + regionId: "front", + parentId: "world", + bounds: { min: [2, -0.5, -0.5], max: [3, 0.5, 0.5] }, + available: true, + contentAvailable: true, + priority: 2, + cost: 1, + }, + { + id: "side", + regionId: "side", + parentId: "world", + bounds: { min: [2, 5, -0.5], max: [3, 6, 0.5] }, + available: true, + contentAvailable: true, + priority: 1, + cost: 1, + }, + ], + }); + + const traversal = resolvePolyWorldChunkTreeTraversal(chunkTree, { + currentRegionId: "front", + point: [0, 0, 0], + forward: [1, 0, 0], + up: [0, 0, 1], + fovDegrees: 50, + aspect: 1, + far: 10, + }); + + expect(traversal.currentChunkId).toBe("front"); + expect(traversal.selectedChunkIds).toEqual(["world", "front"]); + expect(traversal.renderedChunkIds).toEqual(["world", "front"]); + expect(traversal.viewCulledChunkIds).toEqual(["side"]); + expect(traversal.outsideRequestVolumeChunkIds).toEqual([]); + expect(traversal.skippedChunkIds).toEqual(["side"]); + expect(traversal.selectedRegionIds).toEqual(["world", "front"]); + expect(traversal.entries.find((entry) => entry.chunkId === "world")?.reasons).toEqual([ + "root", + "ancestor", + "refined", + "loaded", + "rendered", + ]); + expect(traversal.entries.find((entry) => entry.chunkId === "front")?.reasons).toEqual([ + "current", + "loaded", + "rendered", + ]); + expect(traversal.entries.find((entry) => entry.chunkId === "side")?.reasons).toEqual([ + "view-culled", + "skipped", + ]); + }); + + it("uses chunk content bounds for view culling and viewer request bounds for request eligibility", () => { + const chunkTree = createPolyWorldChunkTree({ + chunks: [ + { + id: "world", + regionId: "world", + childIds: ["front", "content-side", "request-only"], + bounds: { min: [-1, -2, -1], max: [8, 8, 1] }, + available: true, + contentAvailable: true, + refinement: "add", + cost: 1, + }, + { + id: "front", + regionId: "front", + parentId: "world", + bounds: { min: [2, -0.5, -0.5], max: [3, 0.5, 0.5] }, + available: true, + contentAvailable: true, + priority: 3, + cost: 1, + }, + { + id: "content-side", + regionId: "content-side", + parentId: "world", + bounds: { min: [2, -0.5, -0.5], max: [3, 0.5, 0.5] }, + contentBounds: { min: [2, 5, -0.5], max: [3, 6, 0.5] }, + available: true, + contentAvailable: true, + priority: 2, + cost: 1, + }, + { + id: "request-only", + regionId: "request-only", + parentId: "world", + bounds: { min: [2, -0.5, -0.5], max: [3, 0.5, 0.5] }, + viewerRequestBounds: { min: [50, -1, -1], max: [60, 1, 1] }, + available: true, + contentAvailable: true, + priority: 1, + cost: 1, + }, + ], + }); + + const traversal = resolvePolyWorldChunkTreeTraversal(chunkTree, { + currentRegionId: "front", + point: [0, 0, 0], + forward: [1, 0, 0], + up: [0, 0, 1], + fovDegrees: 50, + aspect: 1, + far: 10, + }); + + expect(traversal.renderedChunkIds).toEqual(["world", "front"]); + expect(traversal.viewCulledChunkIds).toEqual(["content-side"]); + expect(traversal.outsideRequestVolumeChunkIds).toEqual(["request-only"]); + expect(traversal.skippedChunkIds).toEqual(["content-side", "request-only"]); + expect(traversal.selectedRegionIds).toEqual(["world", "front"]); + expect(traversal.entries.find((entry) => entry.chunkId === "content-side")?.reasons).toEqual([ + "view-culled", + "skipped", + ]); + expect(traversal.entries.find((entry) => entry.chunkId === "request-only")?.reasons).toEqual([ + "outside-request-volume", + "skipped", + ]); + }); + + it("can drive streaming state from explicit chunk tree traversal without changing fetch policy", () => { + const topology = createPolyWorldTopology({ + regions: [ + { id: "world", selectionKeys: ["chunk:world"] }, + { id: "sector-a", selectionKeys: ["chunk:sector-a"] }, + { id: "tile-a", selectionKeys: ["chunk:tile-a"] }, + { id: "tile-b", selectionKeys: ["chunk:tile-b"] }, + { id: "tile-c", selectionKeys: ["chunk:tile-c"] }, + ], + elements: [ + { id: "world-root", regionIds: ["world"], layers: ["world"] }, + { id: "sector-shell", regionIds: ["sector-a"], layers: ["world"] }, + { id: "tile-a-road", regionIds: ["tile-a"], layers: ["world"] }, + { id: "tile-b-road", regionIds: ["tile-b"], layers: ["world"] }, + { id: "tile-c-prop", regionIds: ["tile-c"], layers: ["world"] }, + ], + }); + const chunkTree = createPolyWorldChunkTree({ + chunks: [ + { id: "world", regionId: "world", childIds: ["sector-a"], available: true, contentAvailable: true, refinement: "add", cost: 1 }, + { id: "sector-a", regionId: "sector-a", parentId: "world", childIds: ["tile-a", "tile-b", "tile-c"], available: true, contentAvailable: true, refinement: "replace", geometricError: 4, cost: 1 }, + { id: "tile-a", regionId: "tile-a", parentId: "sector-a", available: true, contentAvailable: true, priority: 10, cost: 2 }, + { id: "tile-b", regionId: "tile-b", parentId: "sector-a", available: true, contentAvailable: false, priority: 5, cost: 1 }, + { id: "tile-c", regionId: "tile-c", parentId: "sector-a", available: true, contentAvailable: true, priority: 1, cost: 3 }, + ], + }, { topology }); + + const selection = selectPolyWorldChunkStreaming(topology, { + chunkTree, + currentRegionId: "tile-a", + chunkTraversal: { + budget: { + maxRenderedChunks: 2, + maxLoadedChunks: 4, + maxRenderCost: 3, + }, + }, + reasonLabels: { chunkTreeTraversal: "budgeted-tree" }, + }); + const rendered = selectPolyWorldChunkStreamingState(topology, selection, "rendered"); + const renderedResolution = resolvePolyWorldElements(topology, rendered); + + expect(selection.regionIds).toEqual(["world", "sector-a", "tile-a", "tile-b", "tile-c"]); + expect(selection.streaming.chunkTraversal?.renderedChunkIds).toEqual(["world", "tile-a"]); + expect(selection.streaming.requestedRegionIds).toEqual(["world", "sector-a", "tile-a", "tile-b", "tile-c"]); + expect(selection.streaming.loadedRegionIds).toEqual(["sector-a", "tile-a", "tile-c", "world"]); + expect(selection.streaming.residentRegionIds).toEqual(["sector-a", "tile-a", "tile-c", "world"]); + expect(selection.streaming.renderedRegionIds).toEqual(["tile-a", "world"]); + expect(selection.streaming.chunkTraversal?.budgetClippedChunkIds).toEqual(["tile-c"]); + expect(selection.reasons?.find((reason) => reason.kind === "chunkTreeTraversal")?.label).toBe("budgeted-tree"); + expect(rendered.regionIds).toEqual(["tile-a", "world"]); + expect(renderedResolution.elementIds).toEqual(["world-root", "tile-a-road"]); + }); + + it("rejects invalid chunk trees before streaming selection uses them", () => { + const topology = createPolyWorldTopology({ + regions: [{ id: "root" }, { id: "child" }], + }); + + expect(() => createPolyWorldChunkTree({ + chunks: [ + { + id: "root", + regionId: "root", + available: false, + contentAvailable: true, + childIds: ["child", "missing"], + }, + { + id: "child", + regionId: "missing-region", + parentId: "root", + available: true, + bounds: { min: [1, 1, 1], max: [0, 1, 1] }, + contentBounds: { min: [0, 2, 0], max: [1, 1, 1] }, + viewerRequestBounds: { min: [0, 0, 2], max: [1, 1, 1] }, + }, + { + id: "cycle-a", + parentId: "cycle-b", + }, + { + id: "cycle-b", + parentId: "cycle-a", + }, + ], + }, { topology })).toThrow(PolyWorldChunkTreeError); + + try { + createPolyWorldChunkTree({ + chunks: [ + { + id: "root", + regionId: "root", + available: false, + contentAvailable: true, + childIds: ["child", "missing"], + }, + { + id: "child", + regionId: "missing-region", + parentId: "root", + available: true, + bounds: { min: [1, 1, 1], max: [0, 1, 1] }, + contentBounds: { min: [0, 2, 0], max: [1, 1, 1] }, + viewerRequestBounds: { min: [0, 0, 2], max: [1, 1, 1] }, + }, + { + id: "cycle-a", + parentId: "cycle-b", + }, + { + id: "cycle-b", + parentId: "cycle-a", + }, + ], + }, { topology }); + } catch (error) { + const diagnostics = (error as PolyWorldChunkTreeError).diagnostics; + expect(diagnostics).toEqual(expect.arrayContaining([ + expect.objectContaining({ code: "poly-world-unavailable-chunk-content", id: "root" }), + expect.objectContaining({ code: "poly-world-missing-chunk-child", id: "root", field: "childIds" }), + expect.objectContaining({ code: "poly-world-missing-chunk-region", id: "child", field: "regionId" }), + expect.objectContaining({ code: "poly-world-unavailable-chunk-parent", id: "child", field: "available" }), + expect.objectContaining({ code: "poly-world-invalid-chunk-bounds", id: "child", field: "bounds" }), + expect.objectContaining({ code: "poly-world-invalid-chunk-bounds", id: "child", field: "contentBounds" }), + expect.objectContaining({ code: "poly-world-invalid-chunk-bounds", id: "child", field: "viewerRequestBounds" }), + expect.objectContaining({ code: "poly-world-chunk-tree-cycle", id: "cycle-a", field: "parentId" }), + ])); + } + }); + + it("can render a child chunk while keeping graph-expanded parents resident", () => { + const topology = createPolyWorldTopology({ + regions: [ + { id: "world", selectionKeys: ["chunk:world"] }, + { id: "sector-a", selectionKeys: ["chunk:sector-a"] }, + { id: "tile-a", selectionKeys: ["chunk:tile-a"] }, + ], + elements: [ + { id: "world-root", regionIds: ["world"], layers: ["world"] }, + { id: "sector-a-shell", regionIds: ["sector-a"], layers: ["world"] }, + { id: "tile-a-road", regionIds: ["tile-a"], layers: ["world"] }, + ], + }); + + const selection = selectPolyWorldChunkStreaming(topology, { + chunkGraph: { + parentRegionIds: { + "sector-a": "world", + "tile-a": "sector-a", + }, + }, + sources: [ + { + id: "camera", + regionId: "tile-a", + targetState: "rendered", + chunkGraphExpansion: { + includeParents: true, + recursive: true, + targetState: "resident", + }, + }, + ], + }); + const rendered = selectPolyWorldChunkStreamingState(topology, selection, "rendered"); + const residentResolution = resolvePolyWorldElements(topology, selection); + const renderedResolution = resolvePolyWorldElements(topology, rendered); + + expect(selection.regionIds).toEqual(["tile-a", "sector-a", "world"]); + expect(selection.streaming.loadedRegionIds).toEqual(["sector-a", "tile-a", "world"]); + expect(selection.streaming.residentRegionIds).toEqual(["sector-a", "tile-a", "world"]); + expect(selection.streaming.renderedRegionIds).toEqual(["tile-a"]); + expect(selection.streaming.sources[0]).toMatchObject({ + sourceId: "camera", + selectedRegionIds: ["tile-a", "sector-a", "world"], + graphRegionIds: ["sector-a", "world"], + graphTargetState: "resident", + targetState: "rendered", + }); + expect(residentResolution.elementIds).toEqual(["world-root", "sector-a-shell", "tile-a-road"]); + expect(rendered.regionIds).toEqual(["tile-a"]); + expect(renderedResolution.elementIds).toEqual(["tile-a-road"]); + }); + + it("plans a chunk streaming frame from rendered chunks while preserving wider loaded state", () => { + const topology = createPolyWorldTopology({ + regions: [ + { id: "chunk-0", bounds: { min: [0, 0, 0], max: [1, 1, 1] }, selectionKeys: ["chunk:0"] }, + { id: "chunk-1", bounds: { min: [1, 0, 0], max: [2, 1, 1] }, selectionKeys: ["chunk:1"] }, + { id: "chunk-2", bounds: { min: [2, 0, 0], max: [3, 1, 1] }, selectionKeys: ["chunk:2"] }, + { id: "chunk-3", bounds: { min: [3, 0, 0], max: [4, 1, 1] }, selectionKeys: ["chunk:3"] }, + { id: "chunk-4", bounds: { min: [4, 0, 0], max: [5, 1, 1] }, selectionKeys: ["chunk:4"] }, + { id: "chunk-5", bounds: { min: [5, 0, 0], max: [6, 1, 1] }, selectionKeys: ["chunk:5"] }, + ], + elements: [ + { id: "road-1", regionIds: ["chunk-1"], layers: ["world"], tags: ["road"] }, + { id: "road-2", regionIds: ["chunk-2"], layers: ["world"], tags: ["road"] }, + { id: "road-3", regionIds: ["chunk-3"], layers: ["world"], tags: ["road"] }, + { id: "road-4", regionIds: ["chunk-4"], layers: ["world"], tags: ["road"] }, + { id: "road-5", regionIds: ["chunk-5"], layers: ["world"], tags: ["road"] }, + { + id: "distant-banner", + regionIds: ["chunk-4", "chunk-5"], + regionMatch: "any", + layers: ["world"], + tags: ["decor"], + }, + ], + }); + const previousState = createPolyWorldState(topology, { + selection: { regionIds: ["chunk-1"] }, + resolutionOptions: { layers: ["world"] }, + }); + + const frame = planPolyWorldChunkStreamingFrame(topology, { + previousState, + orderedRegionIds: ["chunk-0", "chunk-1", "chunk-2", "chunk-3", "chunk-4", "chunk-5"], + loadedRegionIds: ["chunk-1"], + residentRegionIds: ["chunk-1"], + sources: [ + { + id: "player-car", + point: [2.25, 0.5, 0.5], + before: 1, + after: 2, + targetState: "rendered", + priority: 10, + label: "player-stream", + }, + { + id: "far-interest", + regionId: "chunk-5", + targetState: "loaded", + label: "far-load", + }, + ], + renderSelection: { reasonLabel: "rendered-chunks" }, + state: { resolutionOptions: { layers: ["world"] } }, + policies: [{ id: "render", layer: "render", elementLayers: ["world"] }], + planDebug: { includeEntries: false }, + debug: { includeSources: true, listLimit: 8 }, + }); + + expect(frame.streamingSelection.streaming.loadedRegionIds).toEqual([ + "chunk-1", + "chunk-2", + "chunk-3", + "chunk-4", + "chunk-5", + ]); + expect(frame.artifact).toMatchObject({ + profile: "chunk-traversal", + artifactKind: "chunk-working-set", + sourceKind: "authored-runtime-selection", + producedBy: "selectPolyWorldChunkStreaming", + counts: { + selectedRegionCount: 5, + loadedRegionCount: 5, + renderedRegionCount: 4, + }, + }); + expect(frame.artifact.guarantees).toContain("streaming-state-separation"); + expect(frame.streamingSelection.streaming.renderedRegionIds).toEqual(["chunk-1", "chunk-2", "chunk-3", "chunk-4"]); + expect(frame.streamingSets).toEqual({ + selectedChunkIds: [], + renderedChunkIds: [], + loadedChunkIds: [], + residentChunkIds: [], + requestedChunkIds: [], + heldChunkIds: [], + unavailableChunkIds: [], + viewCulledChunkIds: [], + outsideRequestVolumeChunkIds: [], + skippedChunkIds: [], + budgetClippedChunkIds: [], + selectedRegionIds: ["chunk-1", "chunk-2", "chunk-3", "chunk-4", "chunk-5"], + renderedRegionIds: ["chunk-1", "chunk-2", "chunk-3", "chunk-4"], + loadedRegionIds: ["chunk-1", "chunk-2", "chunk-3", "chunk-4", "chunk-5"], + residentRegionIds: ["chunk-1", "chunk-2", "chunk-3", "chunk-4"], + requestedRegionIds: ["chunk-1", "chunk-2", "chunk-3", "chunk-4", "chunk-5"], + plannedElementIds: ["distant-banner", "road-2", "road-3", "road-4", "road-1"], + }); + expect(frame.frameSummary).toMatchObject({ + profile: "chunk-traversal", + artifactKind: "chunk-working-set", + candidate: { + regionIds: ["chunk-1", "chunk-2", "chunk-3", "chunk-4", "chunk-5"], + }, + broad: { + regionIds: ["chunk-1", "chunk-2", "chunk-3", "chunk-4", "chunk-5"], + }, + view: { + regionIds: ["chunk-1", "chunk-2", "chunk-3", "chunk-4"], + }, + retained: { + regionIds: ["chunk-1", "chunk-2", "chunk-3", "chunk-4"], + }, + planning: { + regionIds: ["chunk-1", "chunk-2", "chunk-3", "chunk-4"], + }, + state: { + resolvedElementIds: ["distant-banner", "road-1", "road-2", "road-3", "road-4"], + }, + plan: { + entryCount: 5, + plannedElementIds: ["distant-banner", "road-1", "road-2", "road-3", "road-4"], + }, + }); + expect(frame.planningSelection?.regionIds).toEqual(["chunk-1", "chunk-2", "chunk-3", "chunk-4"]); + expect(frame.debug?.planningSelection?.regionIds).toEqual(["chunk-1", "chunk-2", "chunk-3", "chunk-4"]); + expect(frame.nextState.selectedRegionIds).toEqual(["chunk-1", "chunk-2", "chunk-3", "chunk-4"]); + expect(frame.nextState.resolvedElementIds).toEqual([ + "distant-banner", + "road-1", + "road-2", + "road-3", + "road-4", + ]); + expect(frame.plan.entries.map((entry) => [entry.elementId, entry.action])).toEqual([ + ["distant-banner", "show"], + ["road-2", "show"], + ["road-3", "show"], + ["road-4", "show"], + ["road-1", "retain"], + ]); + expect(frame.chunkDebug?.streaming.loadedRegionIds.count).toBe(5); + expect(frame.chunkDebug?.streaming.renderedRegionIds.count).toBe(4); + expect(frame.debug?.plan.entryCount).toBe(5); + }); + + it("summarizes traversal-backed chunk frame working sets", () => { + const topology = createPolyWorldTopology({ + regions: [ + { id: "world" }, + { id: "tile-a" }, + { id: "tile-b" }, + { id: "tile-c" }, + ], + elements: [ + { id: "world-root", regionIds: ["world"], layers: ["world"] }, + { id: "tile-a-road", regionIds: ["tile-a"], layers: ["world"] }, + { id: "tile-b-road", regionIds: ["tile-b"], layers: ["world"] }, + { id: "tile-c-road", regionIds: ["tile-c"], layers: ["world"] }, + ], + }); + const previousState = createPolyWorldState(topology, { + selection: { regionIds: ["world"] }, + resolutionOptions: { layers: ["world"] }, + }); + const frame = planPolyWorldChunkStreamingFrame(topology, { + previousState, + chunkTree: { + chunks: [ + { id: "world", regionId: "world", childIds: ["tile-a", "tile-b", "tile-c"], available: true, contentAvailable: true, refinement: "add", cost: 1 }, + { id: "tile-a", regionId: "tile-a", parentId: "world", available: true, contentAvailable: true, priority: 10, cost: 2 }, + { id: "tile-b", regionId: "tile-b", parentId: "world", available: true, contentAvailable: false, priority: 5, cost: 1 }, + { id: "tile-c", regionId: "tile-c", parentId: "world", available: false, contentAvailable: false, priority: 1 }, + ], + }, + currentRegionId: "tile-a", + chunkTraversal: { + budget: { + maxRenderedChunks: 1, + maxLoadedChunks: 2, + }, + }, + state: { resolutionOptions: { layers: ["world"] } }, + policies: [{ id: "render", layer: "render", elementLayers: ["world"] }], + planDebug: { includeEntries: false }, + debug: { includeTraversalEntries: true, traversalEntryLimit: 2 }, + }); + + expect(frame.streamingSets).toEqual({ + currentChunkId: "tile-a", + selectedChunkIds: ["world", "tile-a", "tile-b"], + renderedChunkIds: ["world"], + loadedChunkIds: ["world", "tile-a"], + residentChunkIds: ["world", "tile-a"], + requestedChunkIds: ["tile-b"], + heldChunkIds: ["tile-a"], + unavailableChunkIds: ["tile-c"], + viewCulledChunkIds: [], + outsideRequestVolumeChunkIds: [], + skippedChunkIds: [], + budgetClippedChunkIds: ["tile-a"], + selectedRegionIds: ["world", "tile-a", "tile-b"], + renderedRegionIds: ["world"], + loadedRegionIds: ["world", "tile-a"], + residentRegionIds: ["world", "tile-a"], + requestedRegionIds: ["tile-b"], + plannedElementIds: ["world-root"], + }); + expect(frame.artifact).toMatchObject({ + profile: "chunk-traversal", + artifactKind: "chunk-working-set", + sourceKind: "authored-runtime-selection", + producedBy: "resolvePolyWorldChunkTreeTraversal", + counts: { + selectedChunkCount: 3, + heldChunkCount: 1, + budgetClippedChunkCount: 1, + }, + }); + expect(frame.artifact.guarantees).toContain("budgeted-traversal"); + expect(frame.nextState.resolvedElementIds).toEqual(["world-root"]); + expect(frame.chunkDebug?.streaming.chunkTraversal?.currentChunkId).toBe("tile-a"); + }); + + it("orders streaming sources by priority and expands loading ranges from bounds-only regions", () => { + const topology = createPolyWorldTopology({ + regions: [ + { id: "chunk-0", bounds: { min: [0, 0, 0], max: [1, 1, 1] }, selectionKeys: ["chunk:0"] }, + { id: "chunk-1", bounds: { min: [1, 0, 0], max: [2, 1, 1] }, selectionKeys: ["chunk:1"] }, + { id: "chunk-2", bounds: { min: [2, 0, 0], max: [3, 1, 1] }, selectionKeys: ["chunk:2"] }, + { id: "chunk-3", bounds: { min: [3, 0, 0], max: [4, 1, 1] }, selectionKeys: ["chunk:3"] }, + ], + }); + + const selection = selectPolyWorldChunkStreaming(topology, { + orderedRegionIds: ["chunk-0", "chunk-1", "chunk-2", "chunk-3"], + sources: [ + { + id: "low-far-load", + regionId: "chunk-3", + targetState: "loaded", + priority: 1, + label: "far-load", + }, + { + id: "high-resident-window", + regionId: "chunk-1", + targetState: "resident", + loadingRange: 1.1, + priority: 20, + label: "resident-range", + }, + ], + }); + + expect(selection.regionIds).toEqual(["chunk-1", "chunk-0", "chunk-2", "chunk-3"]); + expect(selection.selectionKeys).toEqual(["chunk:1", "chunk:0", "chunk:2", "chunk:3"]); + expect(selection.reasons?.map((reason) => reason.label)).toEqual([ + "resident-range", + "far-load", + "selection-key", + ]); + expect(selection.streaming.requestedRegionIds).toEqual(["chunk-1", "chunk-0", "chunk-2", "chunk-3"]); + expect(selection.streaming.loadingRegionIds).toEqual(["chunk-1", "chunk-0", "chunk-2", "chunk-3"]); + expect(selection.streaming.loadedRegionIds).toEqual(["chunk-0", "chunk-1", "chunk-2", "chunk-3"]); + expect(selection.streaming.residentRegionIds).toEqual(["chunk-0", "chunk-1", "chunk-2"]); + expect(selection.streaming.activeRegionIds).toEqual([]); + expect(selection.streaming.renderedRegionIds).toEqual([]); + expect(selection.streaming.sources.map((source) => [source.sourceId, source.selectedRegionIds, source.priority])).toEqual([ + ["high-resident-window", ["chunk-1", "chunk-0", "chunk-2"], 20], + ["low-far-load", ["chunk-3"], 1], + ]); + }); +}); + +function findBspNode( + child: PolyWorldBspChild, + predicate: (node: PolyWorldBspNode) => boolean, +): PolyWorldBspNode | undefined { + if ("leafId" in child) return undefined; + if (predicate(child)) return child; + return findBspNode(child.back, predicate) ?? findBspNode(child.front, predicate); +} + +function collectBspNodes(child: PolyWorldBspChild): PolyWorldBspNode[] { + if ("leafId" in child) return []; + return [child, ...collectBspNodes(child.back), ...collectBspNodes(child.front)]; +} + +function reachableLeafIds( + portals: readonly PolyWorldBspPortal[], + startLeafId: string | undefined, +): Set { + const reachable = new Set(); + const queue = startLeafId === undefined ? [] : [startLeafId]; + while (queue.length > 0) { + const leafId = queue.shift(); + if (leafId === undefined || reachable.has(leafId)) continue; + reachable.add(leafId); + for (const portal of portals) { + if (portal.fromLeafId === leafId && !reachable.has(portal.toLeafId)) queue.push(portal.toLeafId); + if (portal.toLeafId === leafId && !reachable.has(portal.fromLeafId)) queue.push(portal.fromLeafId); + } + } + return reachable; +} + +function createTestPvsIndex(leafIds: readonly string[], portalIds: readonly string[]) { + return { + leafIds, + portalIds, + leafIndexById: new Map(leafIds.map((leafId, index) => [leafId, index])), + portalIndexById: new Map(portalIds.map((portalId, index) => [portalId, index])), + }; +} + +function testBitset(size: number, indices: readonly number[]): Uint32Array { + const bits = new Uint32Array(Math.ceil(size / 32)); + for (const index of indices) bits[index >> 5] |= 1 << (index & 31); + return bits; +} + +function decodeTestBitset(ids: readonly string[], bits: Uint32Array | undefined): string[] { + if (bits === undefined) return []; + return ids.filter((_, index) => (bits[index >> 5] & (1 << (index & 31))) !== 0); +} + +function expectBspErrorCodes( + action: () => unknown, + codes: readonly string[], +): readonly { code: string; id?: string }[] { + try { + action(); + } catch (error) { + expect(error).toBeInstanceOf(PolyWorldBspError); + const diagnostics = (error as PolyWorldBspError).diagnostics; + expect(diagnostics.map((diagnostic) => diagnostic.code)).toEqual(expect.arrayContaining([...codes])); + return diagnostics; + } + throw new Error("Expected PolyWorldBspError."); +} diff --git a/packages/world/src/publicApi.test.ts b/packages/world/src/publicApi.test.ts new file mode 100644 index 000000000..527b27189 --- /dev/null +++ b/packages/world/src/publicApi.test.ts @@ -0,0 +1,109 @@ +import { describe, expect, it } from "vitest"; +import * as world from "./index"; + +const v1RuntimeExports = [ + "PolyWorldBspError", + "PolyWorldChunkTreeError", + "PolyWorldDocumentError", + "PolyWorldDomRegistry", + "PolyWorldDomRegistryError", + "PolyWorldTopologyError", + "adaptPolyWorldBspDebugSnapshot", + "adaptPolyWorldChunkStreamingDebugSnapshot", + "adaptPolyWorldDebugSnapshot", + "adaptPolyWorldDomApplyDebugSnapshot", + "adaptPolyWorldPlanDebugSnapshot", + "adaptPolyWorldPortalDebugSnapshot", + "adaptPolyWorldPortalFlowDebugSnapshot", + "applyPolyWorldDomPlan", + "auditPolyWorldProfileArtifactProof", + "bakePolyWorldBspPvs", + "certifyPolyWorldBspTopology", + "collectPolyWorldElementLayers", + "compilePolyWorldBrushBsp", + "compilePolyWorldBsp", + "compilePolyWorldPolygonBsp", + "createPolyWorldBspDebugSnapshot", + "createPolyWorldBspPvsIndex", + "createPolyWorldBspTree", + "createPolyWorldChunkGraphFromTree", + "createPolyWorldChunkStreamingDebugSnapshot", + "createPolyWorldChunkTree", + "createPolyWorldDebugSnapshot", + "createPolyWorldDocument", + "createPolyWorldDomApplyDebugSnapshot", + "createPolyWorldDomRegistry", + "createPolyWorldPlanDebugSnapshot", + "createPolyWorldPortalDebugSnapshot", + "createPolyWorldPortalFlowDebugSnapshot", + "createPolyWorldProfileArtifactBundle", + "createPolyWorldProfileArtifactBundleEntry", + "createPolyWorldProfileArtifactProof", + "createPolyWorldProfileFrameSummary", + "createPolyWorldResourceLoadSet", + "createPolyWorldResourceReadinessGuards", + "createPolyWorldState", + "createPolyWorldTopology", + "createPolyWorldTopologyCapabilityContract", + "decodePolyWorldBspPvsLeafIds", + "decodePolyWorldBspPvsPortalIds", + "diffPolyWorldIds", + "diffPolyWorldState", + "expandPolyWorldSelectionElementRelations", + "planPolyWorldBspVisibilityFrame", + "planPolyWorldChunkStreamingFrame", + "planPolyWorldElementSet", + "planPolyWorldLayers", + "planPolyWorldPortalFlowFrame", + "planPolyWorldPortalFrame", + "planPolyWorldTransition", + "resolvePolyWorldBspBakedPvs", + "resolvePolyWorldBspLeaf", + "resolvePolyWorldBspPvs", + "resolvePolyWorldBspViewPvs", + "resolvePolyWorldBspViewSurfaceElements", + "resolvePolyWorldBspVisibility", + "resolvePolyWorldChunkTreeTraversal", + "resolvePolyWorldElementRelations", + "resolvePolyWorldElementSubtree", + "resolvePolyWorldElements", + "resolvePolyWorldPortalActivity", + "resolvePolyWorldPortalFlow", + "resolvePolyWorldRegionByPoint", + "resolvePolyWorldRegionSelectionKeys", + "resolvePolyWorldSpatialElementRole", + "resolvePolyWorldSpatialElementVisibility", + "selectPolyWorldBspPvs", + "selectPolyWorldBspViewPvs", + "selectPolyWorldChunkStreaming", + "selectPolyWorldChunkStreamingState", + "selectPolyWorldChunkWindow", + "selectPolyWorldElementsByPurpose", + "selectPolyWorldPortalRegions", + "snapshotPolyWorldState", + "summarizePolyWorldBspTopologyProof", + "summarizePolyWorldChunkTree", + "summarizePolyWorldResourceReadiness", + "summarizePolyWorldSpatialElementRoles", + "tracePolyWorldBspViewPvs", + "validatePolyWorldBrushBspInput", + "validatePolyWorldBspCompileInput", + "validatePolyWorldBspTree", + "validatePolyWorldChunkTree", + "validatePolyWorldDocument", + "validatePolyWorldDomRecord", + "validatePolyWorldPolygonBspInput", + "validatePolyWorldTopology", +] as const; + +describe("public runtime API", () => { + it("keeps the V1 runtime export surface explicit", () => { + expect(Object.keys(world).sort()).toEqual([...v1RuntimeExports].sort()); + }); + + it("keeps stale topology naming out of runtime exports", () => { + const staleNames = Object.keys(world).filter((name) => /(?:Object|Member|Cell)/.test(name)); + + expect(staleNames).toEqual([]); + }); +}); diff --git a/packages/world/src/state/createState.ts b/packages/world/src/state/createState.ts new file mode 100644 index 000000000..5aac6b2a3 --- /dev/null +++ b/packages/world/src/state/createState.ts @@ -0,0 +1,115 @@ +import type { PolyWorldElement, PolyWorldSelection, PolyWorldTopology } from "../topology"; +import { resolvePolyWorldElements } from "../topology"; +import type { PolyWorldState, PolyWorldStateInput } from "./types"; + +export function createPolyWorldState( + topology: PolyWorldTopology, + input: PolyWorldStateInput = {}, +): PolyWorldState { + const selection: PolyWorldSelection = input.selection ?? selectionFromResolution(input.resolution); + const resolution = input.resolution ?? resolvePolyWorldElements(topology, selection, input.resolutionOptions); + const selectedRegionIds = uniqueSorted(resolution.selectedRegionIds); + const selectedLinkIds = uniqueSorted(resolution.selectedLinkIds); + const selectedSelectionKeys = uniqueSorted(resolution.selectedSelectionKeys); + const selectedElementIds = uniqueSorted(resolution.selectedElementIds); + const selectedSourceIds = uniqueSorted(resolution.selectedSourceIds); + const selectedAliases = uniqueSorted(resolution.selectedAliases); + const resolvedElementIds = uniqueSorted(resolution.elementIds); + const layers = uniqueSorted([ + ...(input.layers ?? []), + ...resolution.elements.flatMap((element) => element.layers ?? []), + ]); + const reasonLabels = uniqueSorted(selection.reasons?.map((reason) => reason.label)); + const selectionSignature = signature([ + ["regions", selectedRegionIds], + ["links", selectedLinkIds], + ["keys", selectedSelectionKeys], + ["elements", selectedElementIds], + ["sources", selectedSourceIds], + ["aliases", selectedAliases], + ]); + const elementSignature = resolvedElementIds.join(","); + const layerSignature = layers.join(","); + + return { + id: input.id, + selectedRegionIds, + selectedLinkIds, + selectedSelectionKeys, + selectedElementIds, + selectedSourceIds, + selectedAliases, + resolvedElementIds, + layers, + reasonLabels, + unresolved: { + regionIds: uniqueSorted(resolution.unresolved.regionIds), + linkIds: uniqueSorted(resolution.unresolved.linkIds), + selectionKeys: uniqueSorted(resolution.unresolved.selectionKeys), + elementIds: uniqueSorted(resolution.unresolved.elementIds), + sourceIds: uniqueSorted(resolution.unresolved.sourceIds), + aliases: uniqueSorted(resolution.unresolved.aliases), + }, + selectionSignature, + elementSignature, + layerSignature, + signature: signature([ + ["selection", [selectionSignature]], + ["elements", [elementSignature]], + ["layers", [layerSignature]], + ]), + data: input.data, + }; +} + +export function snapshotPolyWorldState(state: PolyWorldState): PolyWorldState { + return { + ...state, + selectedRegionIds: [...state.selectedRegionIds], + selectedLinkIds: [...state.selectedLinkIds], + selectedSelectionKeys: [...state.selectedSelectionKeys], + selectedElementIds: [...state.selectedElementIds], + selectedSourceIds: [...state.selectedSourceIds], + selectedAliases: [...state.selectedAliases], + resolvedElementIds: [...state.resolvedElementIds], + layers: [...state.layers], + reasonLabels: [...state.reasonLabels], + unresolved: { + regionIds: [...state.unresolved.regionIds], + linkIds: [...state.unresolved.linkIds], + selectionKeys: [...state.unresolved.selectionKeys], + elementIds: [...state.unresolved.elementIds], + sourceIds: [...state.unresolved.sourceIds], + aliases: [...state.unresolved.aliases], + }, + }; +} + +export function collectPolyWorldElementLayers(elements: readonly PolyWorldElement[]): string[] { + return uniqueSorted(elements.flatMap((element) => element.layers ?? [])); +} + +function selectionFromResolution(resolution: PolyWorldStateInput["resolution"]): PolyWorldSelection { + return { + regionIds: resolution?.selectedRegionIds ?? [], + linkIds: resolution?.selectedLinkIds ?? [], + selectionKeys: resolution?.selectedSelectionKeys ?? [], + elementIds: resolution?.selectedElementIds ?? [], + sourceIds: resolution?.selectedSourceIds ?? [], + aliases: resolution?.selectedAliases ?? [], + }; +} + +function signature(parts: readonly [string, readonly string[]][]): string { + return parts.map(([name, values]) => `${name}=${values.join(",")}`).join("|"); +} + +function uniqueSorted(values: readonly string[] | undefined): string[] { + return [...new Set(values ?? [])].sort(compareStrings); +} + +function compareStrings(a: string, b: string): number { + if (a < b) return -1; + if (a > b) return 1; + return 0; +} diff --git a/packages/world/src/state/diffState.ts b/packages/world/src/state/diffState.ts new file mode 100644 index 000000000..85c5838f7 --- /dev/null +++ b/packages/world/src/state/diffState.ts @@ -0,0 +1,39 @@ +import type { PolyWorldIdDiff, PolyWorldState, PolyWorldStateDiff } from "./types"; + +export function diffPolyWorldState( + previous: PolyWorldState, + next: PolyWorldState, +): PolyWorldStateDiff { + return { + previous, + next, + changed: previous.signature !== next.signature, + previousSignature: previous.signature, + nextSignature: next.signature, + regions: diffIds(previous.selectedRegionIds, next.selectedRegionIds), + links: diffIds(previous.selectedLinkIds, next.selectedLinkIds), + selectionKeys: diffIds(previous.selectedSelectionKeys, next.selectedSelectionKeys), + selectedElements: diffIds(previous.selectedElementIds, next.selectedElementIds), + sourceIds: diffIds(previous.selectedSourceIds, next.selectedSourceIds), + aliases: diffIds(previous.selectedAliases, next.selectedAliases), + resolvedElements: diffIds(previous.resolvedElementIds, next.resolvedElementIds), + layers: diffIds(previous.layers, next.layers), + }; +} + +export function diffPolyWorldIds( + previousIds: readonly string[], + nextIds: readonly string[], +): PolyWorldIdDiff { + return diffIds(previousIds, nextIds); +} + +function diffIds(previousIds: readonly string[], nextIds: readonly string[]): PolyWorldIdDiff { + const previous = new Set(previousIds); + const next = new Set(nextIds); + return { + added: nextIds.filter((id) => !previous.has(id)), + removed: previousIds.filter((id) => !next.has(id)), + retained: nextIds.filter((id) => previous.has(id)), + }; +} diff --git a/packages/world/src/state/index.ts b/packages/world/src/state/index.ts new file mode 100644 index 000000000..5111012a1 --- /dev/null +++ b/packages/world/src/state/index.ts @@ -0,0 +1,16 @@ +export { + collectPolyWorldElementLayers, + createPolyWorldState, + snapshotPolyWorldState, +} from "./createState"; +export { + diffPolyWorldIds, + diffPolyWorldState, +} from "./diffState"; +export type { + PolyWorldIdDiff, + PolyWorldState, + PolyWorldStateDiff, + PolyWorldStateInput, + PolyWorldStateSnapshot, +} from "./types"; diff --git a/packages/world/src/state/state.test.ts b/packages/world/src/state/state.test.ts new file mode 100644 index 000000000..096e8742d --- /dev/null +++ b/packages/world/src/state/state.test.ts @@ -0,0 +1,142 @@ +import { describe, expect, it } from "vitest"; +import { + createPolyWorldTopology, + resolvePolyWorldElements, +} from "../topology"; +import { + createPolyWorldState, + diffPolyWorldState, + snapshotPolyWorldState, +} from "./index"; + +function groupTopology() { + return createPolyWorldTopology({ + regions: [ + { id: "group-45", selectionKeys: ["group:45"] }, + { id: "group-14", selectionKeys: ["group:14"] }, + { id: "group-15", selectionKeys: ["group:15"] }, + { id: "group-36", selectionKeys: ["group:36"] }, + ], + links: [ + { + id: "portal-45-14", + fromRegionId: "group-45", + toRegionId: "group-14", + selectionKeys: ["portal:45:0:14"], + }, + ], + elements: [ + { id: "shell-45", regionIds: ["group-45"], layers: ["render"], tags: ["solid"] }, + { id: "shell-14", regionIds: ["group-14"], layers: ["render"], tags: ["solid"] }, + { id: "shell-15", regionIds: ["group-15"], layers: ["render"], tags: ["solid"] }, + { id: "portal-marker", selectionKeys: ["portal:45:0:14"], layers: ["debug"], tags: ["portal"] }, + { + id: "shared-door-volume", + regionIds: ["group-45", "group-14"], + regionMatch: "all", + layers: ["render", "collision"], + tags: ["connector"], + }, + ], + }); +} + +describe("createPolyWorldState", () => { + it("normalizes selection and resolution into deterministic state signatures", () => { + const topology = groupTopology(); + const selection = { + regionIds: ["group-14", "group-45", "group-14"], + selectionKeys: ["group:45", "portal:45:0:14"], + reasons: [{ label: "front-facing-portal" }], + }; + const resolution = resolvePolyWorldElements(topology, selection); + const a = createPolyWorldState(topology, { id: "product", selection, resolution }); + const b = createPolyWorldState(topology, { id: "rear-view", selection, resolution }); + + expect(a.selectedRegionIds).toEqual(["group-14", "group-45"]); + expect(a.selectedSelectionKeys).toEqual(["group:45", "portal:45:0:14"]); + expect(a.resolvedElementIds).toEqual(["portal-marker", "shared-door-volume", "shell-14", "shell-45"]); + expect(a.layers).toEqual(["collision", "debug", "render"]); + expect(a.reasonLabels).toEqual(["front-facing-portal"]); + expect(a.unresolved.selectionKeys).toEqual([]); + expect(a.signature).toBe(b.signature); + expect(a.id).toBe("product"); + expect(b.id).toBe("rear-view"); + }); + + it("preserves known topology keys that do not resolve elements without marking them unresolved", () => { + const topology = groupTopology(); + const state = createPolyWorldState(topology, { + selection: { + selectionKeys: ["group:36"], + }, + }); + + expect(state.selectedSelectionKeys).toEqual(["group:36"]); + expect(state.resolvedElementIds).toEqual([]); + expect(state.unresolved.selectionKeys).toEqual([]); + }); + + it("copies state snapshots so callers can keep independent applied states", () => { + const topology = groupTopology(); + const state = createPolyWorldState(topology, { + selection: { regionIds: ["group-45"] }, + data: { owner: "product" }, + }); + const snapshot = snapshotPolyWorldState(state); + + expect(snapshot).toEqual(state); + expect(snapshot.selectedRegionIds).not.toBe(state.selectedRegionIds); + expect(snapshot.unresolved).not.toBe(state.unresolved); + }); +}); + +describe("diffPolyWorldState", () => { + it("diffs group and portal shaped selections into added, removed, and retained ids", () => { + const topology = groupTopology(); + const previous = createPolyWorldState(topology, { + selection: { regionIds: ["group-45"] }, + }); + const next = createPolyWorldState(topology, { + selection: { + regionIds: ["group-45", "group-14", "group-15"], + linkIds: ["portal-45-14"], + selectionKeys: ["portal:45:0:14"], + }, + }); + const diff = diffPolyWorldState(previous, next); + + expect(diff.changed).toBe(true); + expect(diff.regions).toEqual({ + added: ["group-14", "group-15"], + removed: [], + retained: ["group-45"], + }); + expect(diff.links.added).toEqual(["portal-45-14"]); + expect(diff.selectionKeys.added).toEqual(["portal:45:0:14"]); + expect(diff.resolvedElements).toEqual({ + added: ["portal-marker", "shared-door-volume", "shell-14", "shell-15"], + removed: [], + retained: ["shell-45"], + }); + expect(diff.previousSignature).toBe(previous.signature); + expect(diff.nextSignature).toBe(next.signature); + }); + + it("reports unchanged states as retained-only diffs", () => { + const topology = groupTopology(); + const state = createPolyWorldState(topology, { + selection: { regionIds: ["group-45"] }, + }); + const diff = diffPolyWorldState(state, createPolyWorldState(topology, { + selection: { regionIds: ["group-45"] }, + })); + + expect(diff.changed).toBe(false); + expect(diff.resolvedElements).toEqual({ + added: [], + removed: [], + retained: ["shell-45"], + }); + }); +}); diff --git a/packages/world/src/state/types.ts b/packages/world/src/state/types.ts new file mode 100644 index 000000000..0bcaf6396 --- /dev/null +++ b/packages/world/src/state/types.ts @@ -0,0 +1,59 @@ +import type { + PolyWorldData, + PolyWorldElementResolution, + PolyWorldElementResolutionOptions, + PolyWorldSelection, + PolyWorldUnresolvedSelection, +} from "../topology"; + +export interface PolyWorldStateInput { + id?: string; + selection?: PolyWorldSelection; + resolution?: PolyWorldElementResolution; + resolutionOptions?: PolyWorldElementResolutionOptions; + layers?: readonly string[]; + data?: PolyWorldData; +} + +export interface PolyWorldState { + id?: string; + selectedRegionIds: readonly string[]; + selectedLinkIds: readonly string[]; + selectedSelectionKeys: readonly string[]; + selectedElementIds: readonly string[]; + selectedSourceIds: readonly string[]; + selectedAliases: readonly string[]; + resolvedElementIds: readonly string[]; + layers: readonly string[]; + reasonLabels: readonly string[]; + unresolved: PolyWorldUnresolvedSelection; + selectionSignature: string; + elementSignature: string; + layerSignature: string; + signature: string; + data?: PolyWorldData; +} + +export type PolyWorldStateSnapshot = PolyWorldState; + +export interface PolyWorldIdDiff { + added: readonly string[]; + removed: readonly string[]; + retained: readonly string[]; +} + +export interface PolyWorldStateDiff { + previous: PolyWorldState; + next: PolyWorldState; + changed: boolean; + previousSignature: string; + nextSignature: string; + regions: PolyWorldIdDiff; + links: PolyWorldIdDiff; + selectionKeys: PolyWorldIdDiff; + selectedElements: PolyWorldIdDiff; + sourceIds: PolyWorldIdDiff; + aliases: PolyWorldIdDiff; + resolvedElements: PolyWorldIdDiff; + layers: PolyWorldIdDiff; +} diff --git a/packages/world/src/testing/fixtures.ts b/packages/world/src/testing/fixtures.ts new file mode 100644 index 000000000..270867000 --- /dev/null +++ b/packages/world/src/testing/fixtures.ts @@ -0,0 +1,564 @@ +import type { Vec3 } from "@layoutit/polycss-core"; +import { + compilePolyWorldBsp, + createPolyWorldBspPvsIndex, + createPolyWorldBspTree, + createPolyWorldChunkTree, + type PolyWorldBspPortal, + type PolyWorldBspTree, + type PolyWorldBspViewSurfaceElement, + type PolyWorldChunkTree, +} from "../profiles"; +import { + createPolyWorldTopology, + type PolyWorldBounds, + type PolyWorldDocumentInput, + type PolyWorldLink, + type PolyWorldRegion, + type PolyWorldTopology, + type PolyWorldTopologyInput, +} from "../topology"; + +export interface PolyWorldPartitionGalleryFixture { + topologyInput: PolyWorldTopologyInput; + topology: PolyWorldTopology; + documentInput: PolyWorldDocumentInput; + tree: PolyWorldBspTree; + surfaces: readonly PolyWorldBspViewSurfaceElement[]; + rooms: readonly PolyWorldRegion[]; + links: readonly PolyWorldLink[]; + points: { + gallery: Vec3; + westView: Vec3; + eastView: Vec3; + engine: Vec3; + }; + expected: { + broadFromGallery: readonly string[]; + summaryBroadFromGallery: readonly string[]; + westViewRegions: readonly string[]; + summaryWestViewRegions: readonly string[]; + eastViewRegions: readonly string[]; + summaryEastViewRegions: readonly string[]; + }; +} + +export interface PolyWorldFakeRoomGraphFixture { + topologyInput: PolyWorldTopologyInput; + topology: PolyWorldTopology; + documentInput: PolyWorldDocumentInput; +} + +export interface PolyWorldExactPvsFixture { + tree: PolyWorldBspTree; + point: Vec3; + expectedLeafIds: readonly string[]; + expectedRegionIds: readonly string[]; +} + +export interface PolyWorldChunkTrackFixture { + topologyInput: PolyWorldTopologyInput; + topology: PolyWorldTopology; + chunkTree: PolyWorldChunkTree; + camera: { + point: Vec3; + forward: Vec3; + up: Vec3; + fovDegrees: number; + aspect: number; + viewportHeight: number; + }; +} + +const galleryRoomBounds: Record = { + studio: { min: [-12, -4, 0], max: [-4, 4, 3] }, + gallery: { min: [-4, -4, 0], max: [4, 4, 3] }, + vault: { min: [4, -4, 0], max: [12, 4, 3] }, + observatory: { min: [-4, 4, 0], max: [4, 12, 3] }, + engine: { min: [4, -12, 0], max: [12, -4, 3] }, + archive: { min: [4, 4, 0], max: [12, 12, 3] }, +}; + +const galleryLinkSpecs = [ + ["studio-gallery", "studio", "gallery", "east", "west"], + ["gallery-vault", "gallery", "vault", "east", "west"], + ["gallery-observatory", "gallery", "observatory", "north", "south"], + ["vault-engine", "vault", "engine", "south", "north"], + ["vault-archive", "vault", "archive", "north", "south"], +] as const; + +export function createPolyWorldPartitionGalleryFixture(): PolyWorldPartitionGalleryFixture { + const rooms = Object.entries(galleryRoomBounds).map(([id, bounds]) => ({ + id, + bounds, + center: boundsCenter(bounds), + selectionKeys: [`room:${id}`], + })); + const links = galleryLinkSpecs.map(([id, fromRegionId, toRegionId]) => ({ + id, + fromRegionId, + toRegionId, + selectionKeys: [`portal:${id}`], + })); + const surfaces = createPartitionGallerySurfaces(); + const topologyInput: PolyWorldTopologyInput = { + validation: { + strict: true, + requireRegionBounds: true, + requireElementLayers: true, + }, + regions: rooms, + links, + elements: surfaces.map((surface) => ({ + id: surface.elementId ?? surface.id, + path: `/World/PartitionGallery/${surface.regionId}/${surface.id}`, + regionIds: surface.regionId === undefined ? undefined : [surface.regionId], + layers: ["world"], + purposes: surface.role === "prop" ? ["render"] : ["render", "occluder"], + resourceIds: [`mesh:${surface.elementId ?? surface.id}`], + })), + spatialElements: surfaces.map((surface) => ({ + id: surface.id, + elementId: surface.elementId, + regionId: surface.regionId, + role: surface.role, + visibility: surface.visibility, + vertices: surface.vertices, + resourceIds: [`mesh:${surface.elementId ?? surface.id}`], + })), + }; + const topology = createPolyWorldTopology(topologyInput); + const tree = compilePolyWorldBsp({ + regions: rooms.map((room) => ({ + id: room.id, + regionId: room.id, + bounds: room.bounds, + elementIds: surfaces + .filter((surface) => surface.regionId === room.id) + .map((surface) => surface.elementId ?? surface.id), + })), + portals: galleryLinkSpecs.map(([id, fromRegionId, toRegionId, fromSide]) => ({ + id, + fromRegionId, + toRegionId, + linkId: id, + vertices: portalVertices(galleryRoomBounds[fromRegionId], fromSide), + selectionKeys: [`portal:${id}`], + })), + pvs: { projection: "xy", sampleInset: 0.25 }, + data: { + fixture: "partition-gallery", + }, + }); + const documentInput: PolyWorldDocumentInput = { + id: "partition-gallery", + topology: topologyInput, + capabilityIds: ["world-ir", "compiled-bsp-pvs", "resource-readiness", "dom-planning"], + profileArtifacts: [ + { + id: "partition-gallery-bsp", + profile: "bsp-pvs", + artifactKind: "compiled-bsp-pvs", + sourceKind: "compiled", + producedBy: "bounds-bsp", + elementIds: topologyInput.elements?.map((element) => element.id), + spatialElementIds: topologyInput.spatialElements?.map((spatialElement) => spatialElement.id), + }, + ], + resources: surfaces.map((surface) => ({ + id: `mesh:${surface.elementId ?? surface.id}`, + state: surface.role === "prop" ? "stale" : "ready", + renderBlocking: surface.role !== "prop", + elementIds: surface.elementId === undefined ? undefined : [surface.elementId], + spatialElementIds: [surface.id], + })), + planPolicies: [ + { + id: "render-world", + layer: "world", + elementLayers: ["world"], + }, + ], + }; + + return { + topologyInput, + topology, + documentInput, + tree, + surfaces, + rooms, + links, + points: { + gallery: [0, 0, 1.2], + westView: [-1, 0, 0], + eastView: [1, 0, 0], + engine: [8, -8, 1.2], + }, + expected: { + broadFromGallery: ["studio", "gallery", "vault", "observatory", "engine", "archive"], + summaryBroadFromGallery: ["archive", "engine", "gallery", "observatory", "studio", "vault"], + westViewRegions: ["studio", "gallery"], + summaryWestViewRegions: ["gallery", "studio"], + eastViewRegions: ["gallery", "vault"], + summaryEastViewRegions: ["gallery", "vault"], + }, + }; +} + +export function createPolyWorldFakeRoomGraphFixture(): PolyWorldFakeRoomGraphFixture { + const topologyInput: PolyWorldTopologyInput = { + regions: [ + { id: "studio", bounds: galleryRoomBounds.studio }, + { id: "gallery", bounds: galleryRoomBounds.gallery }, + { id: "vault", bounds: galleryRoomBounds.vault }, + ], + links: [ + { id: "studio-gallery", fromRegionId: "studio", toRegionId: "gallery" }, + { id: "gallery-vault", fromRegionId: "gallery", toRegionId: "vault" }, + ], + elements: [ + { id: "studio-shell", regionIds: ["studio"], layers: ["world"] }, + { id: "gallery-shell", regionIds: ["gallery"], layers: ["world"] }, + { id: "vault-shell", regionIds: ["vault"], layers: ["world"] }, + ], + }; + return { + topologyInput, + topology: createPolyWorldTopology(topologyInput), + documentInput: { + id: "fake-room-graph", + topology: topologyInput, + capabilityIds: ["world-ir", "area-portals"], + profileArtifacts: [ + { + id: "fake-portal-flow", + profile: "portal-flow", + artifactKind: "authored-area-portal-flow", + sourceKind: "authored-runtime-selection", + producedBy: "authored-links", + }, + ], + }, + }; +} + +export function createPolyWorldExactPvsFixture(): PolyWorldExactPvsFixture { + const portals: readonly PolyWorldBspPortal[] = [ + { + id: "left-middle", + fromLeafId: "left", + toLeafId: "middle", + linkId: "left-middle", + vertices: [[-2, -1, 0], [-2, 1, 0], [-2, 1, 2], [-2, -1, 2]], + }, + { + id: "middle-right", + fromLeafId: "middle", + toLeafId: "right", + linkId: "middle-right", + vertices: [[2, -1, 0], [2, 1, 0], [2, 1, 2], [2, -1, 2]], + }, + ]; + const index = createPolyWorldBspPvsIndex({ + leaves: [ + { id: "left" }, + { id: "middle" }, + { id: "right" }, + ], + portals, + }); + const tree = createPolyWorldBspTree({ + root: { + id: "root-x", + plane: { normal: [1, 0, 0], distance: 0 }, + back: { leafId: "left" }, + front: { + id: "right-split", + plane: { normal: [1, 0, 0], distance: 4 }, + back: { leafId: "middle" }, + front: { leafId: "right" }, + }, + }, + leaves: [ + exactPvsLeaf("left", "left", index, [0, 1], [0]), + exactPvsLeaf("middle", "middle", index, [0, 1, 2], [0, 1]), + exactPvsLeaf("right", "right", index, [1, 2], [1]), + ], + portals, + pvsIndex: index, + data: { + compiled: true, + compiler: "fixture-vis", + pvsMethod: "exact-baked", + pvsSource: "fixture-vis", + }, + }); + + return { + tree, + point: [0.5, 0, 1], + expectedLeafIds: ["left", "middle", "right"], + expectedRegionIds: ["left", "middle", "right"], + }; +} + +export function createPolyWorldChunkTrackFixture(): PolyWorldChunkTrackFixture { + const topologyInput: PolyWorldTopologyInput = { + regions: [ + chunkRegion("track-world", -2, 8), + chunkRegion("track-sector", 0, 8), + chunkRegion("track-a", 0, 2), + chunkRegion("track-b", 2, 4), + chunkRegion("track-c", 4, 6), + chunkRegion("track-c-detail", 4.5, 5.5), + chunkRegion("track-request-gated", 1, 2), + chunkRegion("track-side", 2, 4, 5), + chunkRegion("track-unavailable", 6, 8), + ], + elements: [ + "track-world", + "track-sector", + "track-a", + "track-b", + "track-c", + "track-c-detail", + "track-request-gated", + "track-side", + "track-unavailable", + ].map((regionId) => ({ + id: `${regionId}-mesh`, + regionIds: [regionId], + layers: ["world"], + resourceIds: [`mesh:${regionId}`], + })), + }; + const topology = createPolyWorldTopology(topologyInput); + const chunkTree = createPolyWorldChunkTree({ + chunks: [ + { + id: "track-world", + regionId: "track-world", + childIds: ["track-sector", "track-side", "track-unavailable"], + bounds: { min: [-2, -1, -0.25], max: [8, 6, 1] }, + available: true, + contentAvailable: true, + refinement: "add", + geometricError: 16, + cost: 1, + }, + { + id: "track-sector", + regionId: "track-sector", + parentId: "track-world", + childIds: ["track-a", "track-b", "track-request-gated", "track-c"], + bounds: { min: [0, -1, -0.25], max: [6, 1, 1] }, + available: true, + contentAvailable: true, + refinement: "replace", + geometricError: 8, + cost: 1, + }, + { + id: "track-a", + regionId: "track-a", + parentId: "track-sector", + bounds: { min: [0, -1, -0.25], max: [2, 1, 1] }, + available: true, + contentAvailable: true, + priority: 10, + cost: 2, + }, + { + id: "track-b", + regionId: "track-b", + parentId: "track-sector", + bounds: { min: [2, -1, -0.25], max: [4, 1, 1] }, + viewerRequestBounds: { min: [-3, -2, -1], max: [5, 2, 2] }, + available: true, + contentAvailable: false, + priority: 8, + cost: 1, + }, + { + id: "track-request-gated", + regionId: "track-request-gated", + parentId: "track-sector", + bounds: { min: [1, -0.5, -0.25], max: [2, 0.5, 1] }, + viewerRequestBounds: { min: [20, -2, -1], max: [24, 2, 2] }, + available: true, + contentAvailable: true, + priority: 6, + cost: 1, + }, + { + id: "track-c", + regionId: "track-c", + parentId: "track-sector", + childIds: ["track-c-detail"], + bounds: { min: [4, -1, -0.25], max: [6, 1, 1] }, + available: true, + contentAvailable: true, + geometricError: 1, + priority: 3, + cost: 3, + }, + { + id: "track-c-detail", + regionId: "track-c-detail", + parentId: "track-c", + bounds: { min: [4.5, -0.5, -0.25], max: [5.5, 0.5, 0.75] }, + available: true, + contentAvailable: true, + cost: 1, + }, + { + id: "track-side", + regionId: "track-side", + parentId: "track-world", + bounds: { min: [2, 5, -0.25], max: [4, 6, 1] }, + contentBounds: { min: [2, 5, -0.25], max: [4, 6, 1] }, + available: true, + contentAvailable: true, + priority: 1, + cost: 1, + }, + { + id: "track-unavailable", + regionId: "track-unavailable", + parentId: "track-world", + bounds: { min: [6, -1, -0.25], max: [8, 1, 1] }, + available: false, + contentAvailable: false, + cost: 1, + }, + ], + }, { topology }); + + return { + topologyInput, + topology, + chunkTree, + camera: { + point: [-2, 0, 0.5], + forward: [1, 0, 0], + up: [0, 0, 1], + fovDegrees: 55, + aspect: 1.4, + viewportHeight: 420, + }, + }; +} + +function createPartitionGallerySurfaces(): PolyWorldBspViewSurfaceElement[] { + return Object.entries(galleryRoomBounds).flatMap(([roomId, bounds]) => [ + surface(`${roomId}-floor`, roomId, "shell", "structural", [ + [bounds.min[0], bounds.min[1], bounds.min[2]], + [bounds.max[0], bounds.min[1], bounds.min[2]], + [bounds.max[0], bounds.max[1], bounds.min[2]], + [bounds.min[0], bounds.max[1], bounds.min[2]], + ]), + surface(`${roomId}-ceiling`, roomId, "shell", "structural", [ + [bounds.min[0], bounds.min[1], bounds.max[2]], + [bounds.min[0], bounds.max[1], bounds.max[2]], + [bounds.max[0], bounds.max[1], bounds.max[2]], + [bounds.max[0], bounds.min[1], bounds.max[2]], + ]), + surface(`${roomId}-opening-frame`, roomId, "opening", "structural", [ + [bounds.max[0], -1, 0], + [bounds.max[0], 1, 0], + [bounds.max[0], 1, 2], + [bounds.max[0], -1, 2], + ]), + surface(`${roomId}-prop`, roomId, "prop", "detail", [ + [bounds.max[0] - 0.8, bounds.max[1] - 0.8, 0], + [bounds.max[0] - 0.2, bounds.max[1] - 0.8, 0], + [bounds.max[0] - 0.2, bounds.max[1] - 0.8, 1], + [bounds.max[0] - 0.8, bounds.max[1] - 0.8, 1], + ]), + ]); +} + +function surface( + id: string, + regionId: string, + role: NonNullable, + visibility: NonNullable, + vertices: readonly Vec3[], +): PolyWorldBspViewSurfaceElement { + return { + id, + elementId: `${id}-element`, + regionId, + role, + visibility, + vertices: vertices.map((vertex) => [...vertex] as Vec3), + }; +} + +function portalVertices(bounds: PolyWorldBounds | undefined, side: "north" | "south" | "east" | "west"): Vec3[] { + if (bounds === undefined) throw new Error(`Missing bounds for portal side "${side}".`); + const z0 = 0.4; + const z1 = 2.4; + if (side === "east" || side === "west") { + const x = side === "east" ? bounds.max[0] : bounds.min[0]; + const y0 = (bounds.min[1] + bounds.max[1]) / 2 - 1; + const y1 = (bounds.min[1] + bounds.max[1]) / 2 + 1; + return [[x, y0, z0], [x, y1, z0], [x, y1, z1], [x, y0, z1]]; + } + const y = side === "north" ? bounds.max[1] : bounds.min[1]; + const x0 = (bounds.min[0] + bounds.max[0]) / 2 - 1; + const x1 = (bounds.min[0] + bounds.max[0]) / 2 + 1; + return [[x0, y, z0], [x1, y, z0], [x1, y, z1], [x0, y, z1]]; +} + +function boundsCenter(bounds: PolyWorldBounds): Vec3 { + return [ + (bounds.min[0] + bounds.max[0]) / 2, + (bounds.min[1] + bounds.max[1]) / 2, + (bounds.min[2] + bounds.max[2]) / 2, + ]; +} + +function exactPvsLeaf( + id: string, + regionId: string, + index: ReturnType, + leafIndexes: readonly number[], + portalIndexes: readonly number[], +) { + return { + id, + regionId, + bounds: exactLeafBounds(id), + pvs: { + leafBits: bitset(index.leafIds.length, leafIndexes), + portalBits: bitset(index.portalIds.length, portalIndexes), + regionIds: leafIndexes.map((leafIndex) => index.leafIds[leafIndex]), + linkIds: portalIndexes.map((portalIndex) => index.portalIds[portalIndex]), + selectionKeys: [], + elementIds: leafIndexes.map((leafIndex) => `${index.leafIds[leafIndex]}-shell`), + }, + elementIds: [`${id}-shell`], + }; +} + +function exactLeafBounds(id: string): PolyWorldBounds { + if (id === "left") return { min: [-6, -2, 0], max: [-2, 2, 2] }; + if (id === "middle") return { min: [-2, -2, 0], max: [2, 2, 2] }; + return { min: [2, -2, 0], max: [6, 2, 2] }; +} + +function bitset(length: number, indexes: readonly number[]): Uint32Array { + const bits = new Uint32Array(Math.ceil(length / 32)); + for (const index of indexes) bits[index >> 5] |= 1 << (index & 31); + return bits; +} + +function chunkRegion(id: string, minX: number, maxX: number, y = 0): PolyWorldRegion { + return { + id, + bounds: { min: [minX, y - 1, -0.25], max: [maxX, y + 1, 1] }, + selectionKeys: [`chunk:${id}`], + }; +} diff --git a/packages/world/src/topology/capabilities.ts b/packages/world/src/topology/capabilities.ts new file mode 100644 index 000000000..d47df90da --- /dev/null +++ b/packages/world/src/topology/capabilities.ts @@ -0,0 +1,421 @@ +export type PolyWorldTopologyCapabilityId = + | "world-ir" + | "compiled-bsp-pvs" + | "area-portals" + | "chunk-hierarchy" + | "resource-readiness" + | "dom-planning" + | "debug-proof"; + +export type PolyWorldTopologyCapabilityReference = + | "polycss" + | "x3d" + | "openusd" + | "quake-bsp-pvs" + | "quake-qbsp" + | "3d-tiles" + | "gltf-lod"; + +export type PolyWorldTopologyReferenceClaimLevel = + | "renderer-target" + | "structure-reference" + | "topology-proof" + | "compiler-boundary" + | "working-set-reference" + | "asset-boundary"; + +export interface PolyWorldTopologyCapability { + id: PolyWorldTopologyCapabilityId; + label: string; + packageOwns: readonly string[]; + appOwns: readonly string[]; + references: readonly PolyWorldTopologyCapabilityReference[]; + publicExports: readonly string[]; +} + +export interface PolyWorldTopologyReferenceContract { + id: PolyWorldTopologyCapabilityReference; + label: string; + claimLevel: PolyWorldTopologyReferenceClaimLevel; + sourceUrls: readonly string[]; + packageUses: readonly string[]; + outOfScope: readonly string[]; + compatibilityClaim: string; +} + +export interface PolyWorldTopologyCapabilityContract { + schemaVersion: 1; + packageName: "@layoutit/polycss-world"; + references: readonly PolyWorldTopologyReferenceContract[]; + capabilities: readonly PolyWorldTopologyCapability[]; + nonGoals: readonly string[]; +} + +const references: readonly PolyWorldTopologyReferenceContract[] = [ + { + id: "polycss", + label: "PolyCSS DOM Renderer", + claimLevel: "renderer-target", + sourceUrls: [ + "https://github.com/LayoutitStudio/polycss", + ], + packageUses: [ + "prepared DOM element identity", + "layer planning for DOM apply", + "debug surfaces for browser examples", + ], + outOfScope: [ + "renderer imports", + "custom element ownership", + "framework bindings", + ], + compatibilityClaim: "PolyCSS World plans topology for PolyCSS DOM scenes, but does not render polygons.", + }, + { + id: "x3d", + label: "X3D Grouping", + claimLevel: "structure-reference", + sourceUrls: [ + "https://www.web3d.org/specifications/X3Dv4/ISO-IEC19775-1v4-IS/Part01/concepts.html", + "https://www.web3d.org/documents/specifications/19775-1/V3.3/Part01/components/navigation.html", + ], + packageUses: [ + "stable hierarchy concepts", + "bounds as traversal hints", + "switch-like authored selection", + ], + outOfScope: [ + "X3D file loading", + "X3D node model compatibility", + "visibility proof from grouping bounds alone", + ], + compatibilityClaim: "Inspired by X3D grouping behavior, not an X3D runtime or loader.", + }, + { + id: "openusd", + label: "OpenUSD Scene Organization", + claimLevel: "structure-reference", + sourceUrls: [ + "https://openusd.org/release/glossary.html", + "https://openusd.org/24.08/api/class_usd_payloads.html", + "https://docs.nvidia.com/learn-openusd/latest/stage-setting/prim-property-paths.html", + ], + packageUses: [ + "stable element paths", + "purpose-like traversal gates", + "payload/load-set separation", + ], + outOfScope: [ + "USD composition arcs", + "USD layer stacks", + "USD payload loading", + ], + compatibilityClaim: "Uses USD-like organization lessons without claiming USD scene compatibility.", + }, + { + id: "quake-bsp-pvs", + label: "Quake BSP/PVS", + claimLevel: "topology-proof", + sourceUrls: [ + "https://github.com/id-Software/Quake", + ], + packageUses: [ + "camera leaf lookup", + "baked broad PVS", + "view-clipped PVS traversal", + "BSP/PVS proof diagnostics", + ], + outOfScope: [ + "Quake BSP file parsing", + "Quake renderer parity", + "gameplay movement or collision", + ], + compatibilityClaim: "Implements Quake-like BSP/PVS topology concepts, not Quake BSP format compatibility.", + }, + { + id: "quake-qbsp", + label: "Quake QBSP/VIS Toolchain", + claimLevel: "compiler-boundary", + sourceUrls: [ + "https://github.com/id-Software/Quake-Tools", + ], + packageUses: [ + "offline compiler/proof separation", + "PVS provenance vocabulary", + "weakness labeling for non-VIS artifacts", + ], + outOfScope: [ + "full qbsp compiler parity", + "full vis solver parity", + "map editor CSG pipeline", + ], + compatibilityClaim: "Keeps compiler/proof boundaries explicit without claiming qbsp/vis equivalence.", + }, + { + id: "3d-tiles", + label: "3D Tiles", + claimLevel: "working-set-reference", + sourceUrls: [ + "https://github.com/CesiumGS/3d-tiles/blob/main/specification/README.adoc", + "https://docs.ogc.org/cs/22-025r4/22-025r4.html", + ], + packageUses: [ + "chunk hierarchy", + "bounding-volume and availability metadata", + "refinement and geometric-error planning", + ], + outOfScope: [ + "3D Tiles loading", + "network scheduling", + "renderer LOD replacement", + ], + compatibilityClaim: "Uses 3D Tiles selection ideas, not 3D Tiles streaming runtime compatibility.", + }, + { + id: "gltf-lod", + label: "glTF LOD", + claimLevel: "asset-boundary", + sourceUrls: [ + "https://registry.khronos.org/glTF/specs/2.0/glTF-2.0.html", + ], + packageUses: [ + "asset-level LOD caution", + "separation of chunk topology from concrete mesh choice", + ], + outOfScope: [ + "glTF loading", + "vendor extension behavior", + "mesh selection or replacement", + ], + compatibilityClaim: "References LOD concepts only to keep chunk planning independent from asset formats.", + }, +]; + +const capabilities: readonly PolyWorldTopologyCapability[] = [ + { + id: "world-ir", + label: "World IR", + packageOwns: [ + "regions", + "links", + "prepared element identities", + "element paths", + "element hierarchy", + "spatial element catalogs", + "bounds", + "purpose traversal gates", + "resource id references", + "selection keys", + "source ids", + "aliases", + "layers", + "tags", + "relationship validation", + ], + appOwns: [ + "render element creation", + "camera controls", + "gameplay state", + "asset parsing", + ], + references: ["polycss", "x3d", "openusd"], + publicExports: [ + "createPolyWorldTopology", + "resolvePolyWorldElements", + "resolvePolyWorldElementSubtree", + "selectPolyWorldElementsByPurpose", + "resolvePolyWorldSpatialElementRole", + "summarizePolyWorldSpatialElementRoles", + ], + }, + { + id: "compiled-bsp-pvs", + label: "Compiled BSP/PVS", + packageOwns: [ + "BSP tree validation", + "leaf lookup", + "baked PVS bitsets", + "broad PVS selection", + "view-clipped PVS traversal", + "surface role selection", + "topology proof summaries", + ], + appOwns: [ + "first-person controls", + "collision response", + "source BSP loading", + "Quake-compatible qbsp/vis parity", + ], + references: ["quake-bsp-pvs", "quake-qbsp"], + publicExports: [ + "createPolyWorldBspTree", + "bakePolyWorldBspPvs", + "resolvePolyWorldBspLeaf", + "resolvePolyWorldBspViewPvs", + "planPolyWorldBspVisibilityFrame", + "summarizePolyWorldBspTopologyProof", + ], + }, + { + id: "area-portals", + label: "Authored Area Portals", + packageOwns: [ + "region-link traversal", + "authored portal flow", + "closed and blocked link state", + "portal activity planning", + "portal-flow debug traces", + ], + appOwns: [ + "door animation", + "door collision", + "room art", + "camera controls", + ], + references: ["x3d", "openusd"], + publicExports: [ + "selectPolyWorldPortalRegions", + "resolvePolyWorldPortalFlow", + "planPolyWorldPortalFrame", + "planPolyWorldPortalFlowFrame", + "createPolyWorldPortalFlowDebugSnapshot", + ], + }, + { + id: "chunk-hierarchy", + label: "Chunk Hierarchy", + packageOwns: [ + "chunk tree validation", + "availability", + "content availability", + "refinement metadata", + "geometric error metadata", + "budgeted traversal", + "streaming state selection", + ], + appOwns: [ + "fetch scheduling", + "cache eviction", + "mesh replacement", + "renderer LOD swaps", + ], + references: ["3d-tiles", "gltf-lod"], + publicExports: [ + "createPolyWorldChunkTree", + "resolvePolyWorldChunkTreeTraversal", + "selectPolyWorldChunkStreaming", + "planPolyWorldChunkStreamingFrame", + ], + }, + { + id: "resource-readiness", + label: "Resource Readiness", + packageOwns: [ + "resource id references", + "readiness load-set summaries", + "readiness guards", + "plan dependency reporting", + "blocked plan debug", + ], + appOwns: [ + "resource fetching", + "retry policy", + "cache storage", + "decode pipelines", + ], + references: ["openusd", "x3d"], + publicExports: [ + "createPolyWorldResourceReadinessGuards", + "summarizePolyWorldResourceReadiness", + "planPolyWorldLayers", + "planPolyWorldTransition", + ], + }, + { + id: "dom-planning", + label: "DOM Planning", + packageOwns: [ + "state snapshots", + "state diffs", + "layer plans", + "caller-record DOM apply", + "stable apply order", + "hidden-only apply", + ], + appOwns: [ + "DOM element creation", + "renderer integration", + "animation loops", + "event handling", + ], + references: ["polycss"], + publicExports: [ + "createPolyWorldState", + "diffPolyWorldState", + "planPolyWorldLayers", + "createPolyWorldDomRegistry", + "applyPolyWorldDomPlan", + ], + }, + { + id: "debug-proof", + label: "Debug And Proof", + packageOwns: [ + "compact debug snapshots", + "profile labels", + "counts with omitted detail", + "BSP proof metadata", + "trace status counts", + "plan/apply debug summaries", + ], + appOwns: [ + "debug UI rendering", + "minimap drawing", + "screenshots", + "browser interaction harnesses", + ], + references: ["polycss", "quake-bsp-pvs", "3d-tiles"], + publicExports: [ + "createPolyWorldBspDebugSnapshot", + "createPolyWorldPortalFlowDebugSnapshot", + "createPolyWorldChunkStreamingDebugSnapshot", + "createPolyWorldPlanDebugSnapshot", + "createPolyWorldDomApplyDebugSnapshot", + ], + }, +]; + +const nonGoals = [ + "format loaders", + "renderer imports", + "framework bindings", + "camera controls", + "pointer lock", + "physics", + "gameplay systems", + "networking", + "fetch scheduling", + "cache eviction", + "source-engine parity claims without source-compatible compilers", +] as const; + +export function createPolyWorldTopologyCapabilityContract(): PolyWorldTopologyCapabilityContract { + return { + schemaVersion: 1, + packageName: "@layoutit/polycss-world", + references: references.map((reference) => ({ + ...reference, + sourceUrls: [...reference.sourceUrls], + packageUses: [...reference.packageUses], + outOfScope: [...reference.outOfScope], + })), + capabilities: capabilities.map((capability) => ({ + ...capability, + packageOwns: [...capability.packageOwns], + appOwns: [...capability.appOwns], + references: [...capability.references], + publicExports: [...capability.publicExports], + })), + nonGoals: [...nonGoals], + }; +} diff --git a/packages/world/src/topology/createTopology.ts b/packages/world/src/topology/createTopology.ts new file mode 100644 index 000000000..09732b4bb --- /dev/null +++ b/packages/world/src/topology/createTopology.ts @@ -0,0 +1,968 @@ +import type { + PolyWorldBounds, + PolyWorldLink, + PolyWorldElement, + PolyWorldElementPurpose, + PolyWorldRegion, + PolyWorldSelectionKeyOwner, + PolyWorldSpatialElement, + PolyWorldSpatialElementRole, + PolyWorldSpatialElementVisibility, + PolyWorldTopology, + PolyWorldTopologyInput, + PolyWorldValidationDiagnostic, +} from "./types"; + +export class PolyWorldTopologyError extends Error { + readonly diagnostics: readonly PolyWorldValidationDiagnostic[]; + + constructor(diagnostics: readonly PolyWorldValidationDiagnostic[]) { + super(diagnostics.map((diagnostic) => diagnostic.message).join("\n")); + this.name = "PolyWorldTopologyError"; + this.diagnostics = diagnostics; + } +} + +export function validatePolyWorldTopology(input: PolyWorldTopologyInput): PolyWorldValidationDiagnostic[] { + const diagnostics: PolyWorldValidationDiagnostic[] = []; + const regions = input.regions ?? []; + const links = input.links ?? []; + const elements = input.elements ?? []; + const spatialElements = input.spatialElements ?? []; + const regionIds = new Set(); + const linkIds = new Set(); + const elementIds = new Set(); + const elementPaths = new Set(); + const spatialElementIds = new Set(); + + if (regions.length === 0) { + diagnostics.push({ + code: "poly-world-empty-regions", + message: "PolyWorld topology requires at least one region.", + field: "regions", + kind: "topology", + }); + } + + for (const region of regions) { + validateId("region", region.id, diagnostics); + if (region.id && regionIds.has(region.id)) { + diagnostics.push({ + code: "poly-world-duplicate-region-id", + message: `Duplicate PolyWorld region id "${region.id}".`, + id: region.id, + field: "id", + kind: "region", + }); + } + if (region.id) regionIds.add(region.id); + validateStringArray("region", region.id, "selectionKeys", region.selectionKeys, diagnostics); + validateStringArray("region", region.id, "aliases", region.aliases, diagnostics); + validateStringArray("region", region.id, "tags", region.tags, diagnostics); + validateVec3("region", region.id, "center", region.center, diagnostics); + validateBounds("region", region.id, region.bounds, diagnostics); + } + + for (const link of links) { + validateId("link", link.id, diagnostics); + if (link.id && linkIds.has(link.id)) { + diagnostics.push({ + code: "poly-world-duplicate-link-id", + message: `Duplicate PolyWorld link id "${link.id}".`, + id: link.id, + field: "id", + kind: "link", + }); + } + if (link.id) linkIds.add(link.id); + validateLinkEndpoint(link, "fromRegionId", regionIds, diagnostics); + validateLinkEndpoint(link, "toRegionId", regionIds, diagnostics); + if (link.direction !== undefined && link.direction !== "bidirectional" && link.direction !== "forward") { + diagnostics.push({ + code: "poly-world-invalid-link-direction", + message: `PolyWorld link "${link.id}" has invalid direction "${String(link.direction)}".`, + id: link.id, + field: "direction", + kind: "link", + }); + } + validateStringArray("link", link.id, "selectionKeys", link.selectionKeys, diagnostics); + validateStringArray("link", link.id, "aliases", link.aliases, diagnostics); + validateStringArray("link", link.id, "tags", link.tags, diagnostics); + } + + for (const element of elements) { + validateId("element", element.id, diagnostics); + if (element.id && elementIds.has(element.id)) { + diagnostics.push({ + code: "poly-world-duplicate-element-id", + message: `Duplicate PolyWorld element id "${element.id}".`, + id: element.id, + field: "id", + kind: "element", + }); + } + if (element.id) elementIds.add(element.id); + validateElementPath(element, elementPaths, diagnostics); + } + + for (const spatialElement of spatialElements) { + validateId("spatialElement", spatialElement.id, diagnostics); + if (spatialElement.id && spatialElementIds.has(spatialElement.id)) { + diagnostics.push({ + code: "poly-world-duplicate-spatial-element-id", + message: `Duplicate PolyWorld spatial element id "${spatialElement.id}".`, + id: spatialElement.id, + field: "id", + kind: "spatialElement", + }); + } + if (spatialElement.id) spatialElementIds.add(spatialElement.id); + } + + for (const element of elements) { + validateElementReferences(element, regionIds, diagnostics); + validateElementGraph(element, diagnostics); + validateElementRelation(element, "parentId", elementIds, diagnostics); + validateElementRelation(element, "containerId", elementIds, diagnostics); + validateStringArray("element", element.id, "selectionKeys", element.selectionKeys, diagnostics); + validateStringArray("element", element.id, "sourceIds", element.sourceIds, diagnostics); + validateStringArray("element", element.id, "aliases", element.aliases, diagnostics); + validateStringArray("element", element.id, "resourceIds", element.resourceIds, diagnostics); + validateStringArray("element", element.id, "layers", element.layers, diagnostics); + validateStringArray("element", element.id, "tags", element.tags, diagnostics); + } + + for (const spatialElement of spatialElements) { + validateSpatialElementReferences(spatialElement, regionIds, elementIds, diagnostics); + validateSpatialElementGeometry(spatialElement, diagnostics); + validateStringArray("spatialElement", spatialElement.id, "resourceIds", spatialElement.resourceIds, diagnostics); + validateStringArray("spatialElement", spatialElement.id, "aliases", spatialElement.aliases, diagnostics); + validateStringArray("spatialElement", spatialElement.id, "tags", spatialElement.tags, diagnostics); + } + + validateElementRelationCycles(elements, "parentId", diagnostics); + validateElementRelationCycles(elements, "containerId", diagnostics); + validateStrictTopology(input, regionIds, diagnostics); + + return diagnostics; +} + +export function createPolyWorldTopology(input: PolyWorldTopologyInput): PolyWorldTopology { + const diagnostics = validatePolyWorldTopology(input); + if (diagnostics.length > 0) { + throw new PolyWorldTopologyError(diagnostics); + } + + const regions = input.regions.map((region) => normalizeRegion(region)); + const links = (input.links ?? []).map((link) => ({ ...link })); + const elements = (input.elements ?? []).map((element) => normalizeElement(element)); + const spatialElements = (input.spatialElements ?? []).map((spatialElement) => normalizeSpatialElement(spatialElement)); + const regionsById = new Map(); + const linksById = new Map(); + const elementsById = new Map(); + const elementsByPath = new Map(); + const spatialElementsById = new Map(); + const spatialElementsByElementId = new Map(); + const spatialElementsByRegionId = new Map(); + const spatialElementsByLeafId = new Map(); + const spatialElementsByRole = new Map(); + const spatialElementsByVisibility = new Map(); + const spatialElementsByResourceId = new Map(); + const linksByRegionId = new Map(); + const elementsByRegionId = new Map(); + const elementsBySelectionKey = new Map(); + const selectionKeyOwnersByKey = new Map(); + const elementsBySourceId = new Map(); + const elementsByAlias = new Map(); + const elementsByPurpose = new Map(); + const elementsByResourceId = new Map(); + const elementsByLayer = new Map(); + const elementsByTag = new Map(); + const elementsByParentId = new Map(); + const elementsByContainerId = new Map(); + + for (const region of regions) { + regionsById.set(region.id, region); + for (const key of region.selectionKeys ?? []) { + pushMap(selectionKeyOwnersByKey, key, { kind: "region", id: region.id }); + } + } + + for (const link of links) { + linksById.set(link.id, link); + pushMap(linksByRegionId, link.fromRegionId, link); + pushMap(linksByRegionId, link.toRegionId, link); + for (const key of link.selectionKeys ?? []) { + pushMap(selectionKeyOwnersByKey, key, { kind: "link", id: link.id }); + } + } + + for (const element of elements) { + elementsById.set(element.id, element); + if (element.path !== undefined) elementsByPath.set(element.path, element); + for (const regionId of element.regionIds ?? []) pushMap(elementsByRegionId, regionId, element); + for (const key of element.selectionKeys ?? []) { + pushMap(elementsBySelectionKey, key, element); + pushMap(selectionKeyOwnersByKey, key, { kind: "element", id: element.id }); + } + for (const sourceId of element.sourceIds ?? []) pushMap(elementsBySourceId, sourceId, element); + for (const alias of element.aliases ?? []) pushMap(elementsByAlias, alias, element); + for (const purpose of element.purposes ?? []) pushMap(elementsByPurpose, purpose, element); + for (const resourceId of element.resourceIds ?? []) pushMap(elementsByResourceId, resourceId, element); + for (const layer of element.layers ?? []) pushMap(elementsByLayer, layer, element); + for (const tag of element.tags ?? []) pushMap(elementsByTag, tag, element); + if (element.parentId !== undefined) pushMap(elementsByParentId, element.parentId, element); + if (element.containerId !== undefined) pushMap(elementsByContainerId, element.containerId, element); + } + + for (const spatialElement of spatialElements) { + spatialElementsById.set(spatialElement.id, spatialElement); + if (spatialElement.elementId !== undefined) { + pushMap(spatialElementsByElementId, spatialElement.elementId, spatialElement); + } + if (spatialElement.regionId !== undefined) { + pushMap(spatialElementsByRegionId, spatialElement.regionId, spatialElement); + } + if (spatialElement.leafId !== undefined) { + pushMap(spatialElementsByLeafId, spatialElement.leafId, spatialElement); + } + if (spatialElement.role !== undefined) { + pushMap(spatialElementsByRole, spatialElement.role, spatialElement); + } + if (spatialElement.visibility !== undefined) { + pushMap(spatialElementsByVisibility, spatialElement.visibility, spatialElement); + } + for (const resourceId of spatialElement.resourceIds ?? []) { + pushMap(spatialElementsByResourceId, resourceId, spatialElement); + } + } + + return { + regions, + links, + elements, + spatialElements, + data: input.data, + regionsById, + linksById, + elementsById, + elementsByPath, + spatialElementsById, + spatialElementsByElementId, + spatialElementsByRegionId, + spatialElementsByLeafId, + spatialElementsByRole, + spatialElementsByVisibility, + spatialElementsByResourceId, + linksByRegionId, + elementsByRegionId, + elementsBySelectionKey, + selectionKeyOwnersByKey, + elementsBySourceId, + elementsByAlias, + elementsByPurpose, + elementsByResourceId, + elementsByLayer, + elementsByTag, + elementsByParentId, + elementsByContainerId, + }; +} + +function validateId( + kind: "region" | "link" | "element" | "spatialElement", + id: string, + diagnostics: PolyWorldValidationDiagnostic[], +): void { + if (typeof id !== "string" || id.length === 0) { + diagnostics.push({ + code: `poly-world-empty-${kind}-id`, + message: `PolyWorld ${kind} requires a non-empty id.`, + field: "id", + kind, + }); + } +} + +function validateSpatialElementReferences( + spatialElement: PolyWorldSpatialElement, + regionIds: ReadonlySet, + elementIds: ReadonlySet, + diagnostics: PolyWorldValidationDiagnostic[], +): void { + if ( + spatialElement.elementId === undefined && + spatialElement.regionId === undefined && + spatialElement.leafId === undefined + ) { + diagnostics.push({ + code: "poly-world-missing-spatial-element-reference", + message: `PolyWorld spatial element "${spatialElement.id}" requires elementId, regionId, or leafId.`, + id: spatialElement.id, + kind: "spatialElement", + }); + } + + if ( + spatialElement.elementId !== undefined && + (typeof spatialElement.elementId !== "string" || spatialElement.elementId.length === 0) + ) { + diagnostics.push({ + code: "poly-world-empty-spatial-element-reference", + message: `PolyWorld spatial element "${spatialElement.id}" has an empty elementId.`, + id: spatialElement.id, + field: "elementId", + kind: "spatialElement", + }); + } else if (spatialElement.elementId !== undefined && !elementIds.has(spatialElement.elementId)) { + diagnostics.push({ + code: "poly-world-missing-spatial-element-element", + message: `PolyWorld spatial element "${spatialElement.id}" references missing element "${spatialElement.elementId}".`, + id: spatialElement.id, + field: "elementId", + kind: "spatialElement", + }); + } + + if ( + spatialElement.regionId !== undefined && + (typeof spatialElement.regionId !== "string" || spatialElement.regionId.length === 0) + ) { + diagnostics.push({ + code: "poly-world-empty-spatial-element-reference", + message: `PolyWorld spatial element "${spatialElement.id}" has an empty regionId.`, + id: spatialElement.id, + field: "regionId", + kind: "spatialElement", + }); + } else if (spatialElement.regionId !== undefined && !regionIds.has(spatialElement.regionId)) { + diagnostics.push({ + code: "poly-world-missing-spatial-element-region", + message: `PolyWorld spatial element "${spatialElement.id}" references missing region "${spatialElement.regionId}".`, + id: spatialElement.id, + field: "regionId", + kind: "spatialElement", + }); + } + + if ( + spatialElement.leafId !== undefined && + (typeof spatialElement.leafId !== "string" || spatialElement.leafId.length === 0) + ) { + diagnostics.push({ + code: "poly-world-empty-spatial-element-reference", + message: `PolyWorld spatial element "${spatialElement.id}" has an empty leafId.`, + id: spatialElement.id, + field: "leafId", + kind: "spatialElement", + }); + } + + if ( + spatialElement.role !== undefined && + spatialElement.role !== "root" && + spatialElement.role !== "shell" && + spatialElement.role !== "opening" && + spatialElement.role !== "detail" && + spatialElement.role !== "prop" + ) { + diagnostics.push({ + code: "poly-world-invalid-spatial-element-role", + message: `PolyWorld spatial element "${spatialElement.id}" has invalid role "${String(spatialElement.role)}".`, + id: spatialElement.id, + field: "role", + kind: "spatialElement", + }); + } + + if ( + spatialElement.visibility !== undefined && + spatialElement.visibility !== "structural" && + spatialElement.visibility !== "detail" + ) { + diagnostics.push({ + code: "poly-world-invalid-spatial-element-visibility", + message: `PolyWorld spatial element "${spatialElement.id}" has invalid visibility "${String(spatialElement.visibility)}".`, + id: spatialElement.id, + field: "visibility", + kind: "spatialElement", + }); + } +} + +function validateSpatialElementGeometry( + spatialElement: PolyWorldSpatialElement, + diagnostics: PolyWorldValidationDiagnostic[], +): void { + validateBounds("spatialElement", spatialElement.id, spatialElement.bounds, diagnostics); + if (spatialElement.vertices === undefined) return; + if (spatialElement.vertices.length < 3) { + diagnostics.push({ + code: "poly-world-invalid-spatial-element-polygon", + message: `PolyWorld spatial element "${spatialElement.id}" vertices must contain at least three points.`, + id: spatialElement.id, + field: "vertices", + kind: "spatialElement", + }); + return; + } + for (let index = 0; index < spatialElement.vertices.length; index += 1) { + validateVec3("spatialElement", spatialElement.id, `vertices.${index}`, spatialElement.vertices[index], diagnostics); + } + if (!spatialElement.vertices.every(isFiniteVec3)) return; + const plane = polygonPlane(spatialElement.vertices); + if (plane === undefined) { + diagnostics.push({ + code: "poly-world-degenerate-spatial-element-polygon", + message: `PolyWorld spatial element "${spatialElement.id}" vertices must form a non-degenerate polygon.`, + id: spatialElement.id, + field: "vertices", + kind: "spatialElement", + }); + return; + } + if (!spatialElement.vertices.every((vertex) => Math.abs(dot(plane.normal, vertex) - plane.distance) <= 0.0001)) { + diagnostics.push({ + code: "poly-world-non-coplanar-spatial-element-polygon", + message: `PolyWorld spatial element "${spatialElement.id}" vertices must be coplanar.`, + id: spatialElement.id, + field: "vertices", + kind: "spatialElement", + }); + } +} + +function validateLinkEndpoint( + link: PolyWorldLink, + field: "fromRegionId" | "toRegionId", + regionIds: ReadonlySet, + diagnostics: PolyWorldValidationDiagnostic[], +): void { + const regionId = link[field]; + if (typeof regionId !== "string" || regionId.length === 0) { + diagnostics.push({ + code: "poly-world-empty-link-endpoint", + message: `PolyWorld link "${link.id}" requires a non-empty ${field}.`, + id: link.id, + field, + kind: "link", + }); + return; + } + if (!regionIds.has(regionId)) { + diagnostics.push({ + code: "poly-world-missing-link-region", + message: `PolyWorld link "${link.id}" references missing region "${regionId}".`, + id: link.id, + field, + kind: "link", + }); + } +} + +function validateElementPath( + element: PolyWorldElement, + elementPaths: Set, + diagnostics: PolyWorldValidationDiagnostic[], +): void { + if (element.path === undefined) return; + if (typeof element.path !== "string" || element.path.length === 0) { + diagnostics.push({ + code: "poly-world-empty-element-path", + message: `PolyWorld element "${element.id}" has an empty path.`, + id: element.id, + field: "path", + kind: "element", + }); + return; + } + if (!element.path.startsWith("/")) { + diagnostics.push({ + code: "poly-world-invalid-element-path", + message: `PolyWorld element "${element.id}" path must start with "/".`, + id: element.id, + field: "path", + kind: "element", + }); + } + if (elementPaths.has(element.path)) { + diagnostics.push({ + code: "poly-world-duplicate-element-path", + message: `Duplicate PolyWorld element path "${element.path}".`, + id: element.id, + field: "path", + kind: "element", + }); + } + elementPaths.add(element.path); +} + +function validateElementGraph( + element: PolyWorldElement, + diagnostics: PolyWorldValidationDiagnostic[], +): void { + validateBounds("element", element.id, element.bounds, diagnostics); + validateElementTransform(element, diagnostics); + validateElementPurposes(element, diagnostics); +} + +function validateElementTransform( + element: PolyWorldElement, + diagnostics: PolyWorldValidationDiagnostic[], +): void { + if (element.transform === undefined) return; + validateVec3("element", element.id, "transform.position", element.transform.position, diagnostics); + validateVec3("element", element.id, "transform.rotation", element.transform.rotation, diagnostics); + validateVec3("element", element.id, "transform.scale", element.transform.scale, diagnostics); + if (element.transform.matrix === undefined) return; + if (element.transform.matrix.length !== 16 || element.transform.matrix.some((value) => !Number.isFinite(value))) { + diagnostics.push({ + code: "poly-world-invalid-element-transform-matrix", + message: `PolyWorld element "${element.id}" transform.matrix must contain 16 finite numbers.`, + id: element.id, + field: "transform.matrix", + kind: "element", + }); + } +} + +function validateElementPurposes( + element: PolyWorldElement, + diagnostics: PolyWorldValidationDiagnostic[], +): void { + validateStringArray("element", element.id, "purposes", element.purposes, diagnostics); + const purposes = element.purposes ?? []; + const validPurposes = new Set(["render", "collision", "occluder", "portal", "chunk", "debug", "proxy"]); + for (const purpose of purposes) { + if (!validPurposes.has(purpose)) { + diagnostics.push({ + code: "poly-world-invalid-element-purpose", + message: `PolyWorld element "${element.id}" has invalid purpose "${String(purpose)}".`, + id: element.id, + field: "purposes", + kind: "element", + }); + } + } + if (purposes.includes("proxy") && purposes.includes("render")) { + diagnostics.push({ + code: "poly-world-conflicting-element-purposes", + message: `PolyWorld element "${element.id}" cannot be both proxy and render purpose; use separate elements so traversal gates stay explicit.`, + id: element.id, + field: "purposes", + kind: "element", + }); + } +} + +function validateElementReferences( + element: PolyWorldElement, + regionIds: ReadonlySet, + diagnostics: PolyWorldValidationDiagnostic[], +): void { + const hasRegionIds = element.regionIds !== undefined; + const hasSelectionKeys = element.selectionKeys !== undefined; + const hasSourceIds = element.sourceIds !== undefined; + const hasAliases = element.aliases !== undefined; + + if (!hasRegionIds && !hasSelectionKeys && !hasSourceIds && !hasAliases) { + diagnostics.push({ + code: "poly-world-missing-element-reference", + message: `PolyWorld element "${element.id}" requires regionIds, selectionKeys, sourceIds, or aliases.`, + id: element.id, + kind: "element", + }); + } + + validateStringArray("element", element.id, "regionIds", element.regionIds, diagnostics); + for (const regionId of element.regionIds ?? []) { + if (!regionIds.has(regionId)) { + diagnostics.push({ + code: "poly-world-missing-element-region", + message: `PolyWorld element "${element.id}" references missing region "${regionId}".`, + id: element.id, + field: "regionIds", + kind: "element", + }); + } + } + + if ( + element.regionMatch !== undefined && + element.regionMatch !== "any" && + element.regionMatch !== "all" + ) { + diagnostics.push({ + code: "poly-world-invalid-region-match", + message: `PolyWorld element "${element.id}" has invalid regionMatch "${String(element.regionMatch)}".`, + id: element.id, + field: "regionMatch", + kind: "element", + }); + } + + if ((element.regionIds?.length ?? 0) > 1 && element.regionMatch === undefined) { + diagnostics.push({ + code: "poly-world-ambiguous-region-match", + message: `PolyWorld element "${element.id}" spans multiple regions and must declare regionMatch.`, + id: element.id, + field: "regionMatch", + kind: "element", + }); + } +} + +function validateElementRelation( + element: PolyWorldElement, + field: "parentId" | "containerId", + elementIds: ReadonlySet, + diagnostics: PolyWorldValidationDiagnostic[], +): void { + const relatedElementId = element[field]; + if (relatedElementId === undefined) return; + if (typeof relatedElementId !== "string" || relatedElementId.length === 0) { + diagnostics.push({ + code: "poly-world-empty-element-relation", + message: `PolyWorld element "${element.id}" has an empty ${field}.`, + id: element.id, + field, + kind: "element", + }); + return; + } + if (relatedElementId === element.id) { + diagnostics.push({ + code: "poly-world-self-element-relation", + message: `PolyWorld element "${element.id}" cannot reference itself as ${field}.`, + id: element.id, + field, + kind: "element", + }); + return; + } + if (!elementIds.has(relatedElementId)) { + diagnostics.push({ + code: "poly-world-missing-element-relation", + message: `PolyWorld element "${element.id}" ${field} references missing element "${relatedElementId}".`, + id: element.id, + field, + kind: "element", + }); + } +} + +function validateElementRelationCycles( + elements: readonly PolyWorldElement[], + field: "parentId" | "containerId", + diagnostics: PolyWorldValidationDiagnostic[], +): void { + const elementsById = new Map(elements.map((element) => [element.id, element])); + + for (const element of elements) { + const path: string[] = []; + let current: PolyWorldElement | undefined = element; + + while (current !== undefined) { + if (path.includes(current.id)) { + const cycle = [...path.slice(path.indexOf(current.id)), current.id]; + diagnostics.push({ + code: "poly-world-element-relation-cycle", + message: `PolyWorld element "${element.id}" has a ${field} cycle: ${cycle.join(" -> ")}.`, + id: element.id, + field, + kind: "element", + }); + break; + } + + path.push(current.id); + const nextId = current[field]; + if (nextId === undefined || nextId === current.id) break; + current = elementsById.get(nextId); + } + } +} + +function validateStrictTopology( + input: PolyWorldTopologyInput, + regionIds: ReadonlySet, + diagnostics: PolyWorldValidationDiagnostic[], +): void { + const validation = input.validation; + const strict = validation?.strict === true; + const requireRegionSpatialReference = validation?.requireRegionSpatialReference ?? strict; + const requireRegionBounds = validation?.requireRegionBounds ?? false; + const requireConnectedRegions = validation?.requireConnectedRegions ?? strict; + const requireElementLayers = validation?.requireElementLayers ?? strict; + + if (requireRegionSpatialReference) { + validateRegionSpatialReferences(input.regions, diagnostics); + } + if (requireRegionBounds) { + validateRegionBoundsRequired(input.regions, diagnostics); + } + if (requireConnectedRegions) { + validateRegionConnectivity(input.regions, input.links ?? [], regionIds, diagnostics); + } + if (requireElementLayers) { + validateElementLayersRequired(input.elements ?? [], diagnostics); + } +} + +function validateRegionSpatialReferences( + regions: readonly PolyWorldRegion[], + diagnostics: PolyWorldValidationDiagnostic[], +): void { + for (const region of regions) { + if (region.bounds !== undefined || region.center !== undefined) continue; + diagnostics.push({ + code: "poly-world-missing-region-spatial-reference", + message: `PolyWorld region "${region.id}" requires bounds or center in strict topology validation.`, + id: region.id, + field: "bounds", + kind: "region", + }); + } +} + +function validateRegionBoundsRequired( + regions: readonly PolyWorldRegion[], + diagnostics: PolyWorldValidationDiagnostic[], +): void { + for (const region of regions) { + if (region.bounds !== undefined) continue; + diagnostics.push({ + code: "poly-world-missing-region-bounds", + message: `PolyWorld region "${region.id}" requires bounds when requireRegionBounds is enabled.`, + id: region.id, + field: "bounds", + kind: "region", + }); + } +} + +function validateRegionConnectivity( + regions: readonly PolyWorldRegion[], + links: readonly PolyWorldLink[], + regionIds: ReadonlySet, + diagnostics: PolyWorldValidationDiagnostic[], +): void { + const firstRegionId = regions.find((region) => typeof region.id === "string" && region.id.length > 0)?.id; + if (firstRegionId === undefined || regionIds.size <= 1) return; + + const linkedRegionIds = new Map>(); + for (const link of links) { + if (!regionIds.has(link.fromRegionId) || !regionIds.has(link.toRegionId)) continue; + addLinkedRegion(linkedRegionIds, link.fromRegionId, link.toRegionId); + addLinkedRegion(linkedRegionIds, link.toRegionId, link.fromRegionId); + } + + const visited = new Set(); + const queue = [firstRegionId]; + while (queue.length > 0) { + const regionId = queue.shift(); + if (regionId === undefined || visited.has(regionId)) continue; + visited.add(regionId); + for (const linkedRegionId of linkedRegionIds.get(regionId) ?? []) { + if (!visited.has(linkedRegionId)) queue.push(linkedRegionId); + } + } + + for (const region of regions) { + if (!region.id || visited.has(region.id)) continue; + diagnostics.push({ + code: "poly-world-unreachable-region", + message: `PolyWorld region "${region.id}" is not reachable from region "${firstRegionId}" in strict topology validation.`, + id: region.id, + field: "links", + kind: "region", + }); + } +} + +function validateElementLayersRequired( + elements: readonly PolyWorldElement[], + diagnostics: PolyWorldValidationDiagnostic[], +): void { + for (const element of elements) { + if (element.layers !== undefined) continue; + diagnostics.push({ + code: "poly-world-missing-element-layers", + message: `PolyWorld element "${element.id}" requires layers in strict topology validation.`, + id: element.id, + field: "layers", + kind: "element", + }); + } +} + +function addLinkedRegion( + linkedRegionIds: Map>, + regionId: string, + linkedRegionId: string, +): void { + const links = linkedRegionIds.get(regionId); + if (links === undefined) { + linkedRegionIds.set(regionId, new Set([linkedRegionId])); + return; + } + links.add(linkedRegionId); +} + +function validateStringArray( + kind: "region" | "link" | "element" | "spatialElement", + id: string, + field: string, + values: readonly string[] | undefined, + diagnostics: PolyWorldValidationDiagnostic[], +): void { + if (values === undefined) return; + if (values.length === 0) { + diagnostics.push({ + code: "poly-world-empty-array", + message: `PolyWorld ${kind} "${id}" has empty ${field}.`, + id, + field, + kind, + }); + return; + } + for (const value of values) { + if (typeof value !== "string" || value.length === 0) { + diagnostics.push({ + code: "poly-world-empty-array-value", + message: `PolyWorld ${kind} "${id}" has an empty value in ${field}.`, + id, + field, + kind, + }); + } + } +} + +function validateBounds( + kind: "region" | "element" | "spatialElement", + id: string, + bounds: PolyWorldBounds | undefined, + diagnostics: PolyWorldValidationDiagnostic[], +): void { + if (bounds === undefined) return; + validateVec3(kind, id, "bounds.min", bounds.min, diagnostics); + validateVec3(kind, id, "bounds.max", bounds.max, diagnostics); + for (let axis = 0; axis < 3; axis += 1) { + if (bounds.min[axis] > bounds.max[axis]) { + diagnostics.push({ + code: "poly-world-invalid-bounds", + message: `PolyWorld ${kind} "${id}" has bounds.min greater than bounds.max.`, + id, + field: "bounds", + kind, + }); + break; + } + } +} + +function validateVec3( + kind: "region" | "element" | "spatialElement", + id: string, + field: string, + value: readonly number[] | undefined, + diagnostics: PolyWorldValidationDiagnostic[], +): void { + if (value === undefined) return; + if (value.length !== 3 || value.some((coordinate) => !Number.isFinite(coordinate))) { + diagnostics.push({ + code: "poly-world-invalid-vec3", + message: `PolyWorld ${kind} "${id}" has invalid ${field}.`, + id, + field, + kind, + }); + } +} + +function normalizeElement(element: PolyWorldElement): PolyWorldElement { + return { + ...element, + bounds: element.bounds === undefined ? undefined : { + min: [...element.bounds.min], + max: [...element.bounds.max], + }, + transform: element.transform === undefined ? undefined : { + ...element.transform, + position: element.transform.position === undefined ? undefined : [...element.transform.position], + rotation: element.transform.rotation === undefined ? undefined : [...element.transform.rotation], + scale: element.transform.scale === undefined ? undefined : [...element.transform.scale], + matrix: element.transform.matrix === undefined ? undefined : [...element.transform.matrix], + }, + }; +} + +function normalizeRegion(region: PolyWorldRegion): PolyWorldRegion { + if (region.center !== undefined || region.bounds === undefined) return { ...region }; + const { min, max } = region.bounds; + return { + ...region, + center: [ + (min[0] + max[0]) / 2, + (min[1] + max[1]) / 2, + (min[2] + max[2]) / 2, + ], + }; +} + +function normalizeSpatialElement(spatialElement: PolyWorldSpatialElement): PolyWorldSpatialElement { + return { + ...spatialElement, + bounds: spatialElement.bounds === undefined ? undefined : { + min: [...spatialElement.bounds.min], + max: [...spatialElement.bounds.max], + }, + vertices: spatialElement.vertices?.map((vertex) => [...vertex]), + }; +} + +function polygonPlane(vertices: readonly (readonly number[])[]): { normal: [number, number, number]; distance: number } | undefined { + for (let index = 1; index < vertices.length - 1; index += 1) { + const normal = cross(subtract(vertices[index], vertices[0]), subtract(vertices[index + 1], vertices[0])); + const length = Math.hypot(normal[0], normal[1], normal[2]); + if (length <= 0.000001) continue; + const unit: [number, number, number] = [normal[0] / length, normal[1] / length, normal[2] / length]; + return { + normal: unit, + distance: dot(unit, vertices[0]), + }; + } + return undefined; +} + +function isFiniteVec3(value: readonly number[]): boolean { + return value.length === 3 && value.every((coordinate) => Number.isFinite(coordinate)); +} + +function subtract(a: readonly number[], b: readonly number[]): [number, number, number] { + return [a[0] - b[0], a[1] - b[1], a[2] - b[2]]; +} + +function cross(a: readonly number[], b: readonly number[]): [number, number, number] { + return [ + a[1] * b[2] - a[2] * b[1], + a[2] * b[0] - a[0] * b[2], + a[0] * b[1] - a[1] * b[0], + ]; +} + +function dot(a: readonly number[], b: readonly number[]): number { + return a[0] * b[0] + a[1] * b[1] + a[2] * b[2]; +} + +function pushMap(map: Map, key: string, value: T): void { + const values = map.get(key); + if (values === undefined) { + map.set(key, [value]); + return; + } + values.push(value); +} diff --git a/packages/world/src/topology/document.ts b/packages/world/src/topology/document.ts new file mode 100644 index 000000000..ad78fcd07 --- /dev/null +++ b/packages/world/src/topology/document.ts @@ -0,0 +1,585 @@ +import type { + PolyWorldProfileArtifactKind, + PolyWorldProfileArtifactProfile, + PolyWorldProfileArtifactSourceKind, +} from "../profiles/artifact"; +import type { PolyWorldResourceReadinessState } from "../planner/resources"; +import type { PolyWorldLayerPlanPolicy } from "../planner/types"; +import { + createPolyWorldTopology, + validatePolyWorldTopology, +} from "./createTopology"; +import { + createPolyWorldTopologyCapabilityContract, + type PolyWorldTopologyCapability, + type PolyWorldTopologyCapabilityContract, + type PolyWorldTopologyCapabilityId, +} from "./capabilities"; +import type { + PolyWorldData, + PolyWorldTopology, + PolyWorldTopologyInput, + PolyWorldValidationDiagnostic, +} from "./types"; + +export type PolyWorldDocumentDiagnosticKind = + | NonNullable + | "document" + | "capability" + | "profileArtifact" + | "resource" + | "planPolicy"; + +export interface PolyWorldDocumentDiagnostic { + code: string; + message: string; + id?: string; + field?: string; + kind?: PolyWorldDocumentDiagnosticKind; +} + +export interface PolyWorldDocumentProfileArtifactRef { + id: string; + profile: PolyWorldProfileArtifactProfile; + artifactKind?: PolyWorldProfileArtifactKind; + sourceKind?: PolyWorldProfileArtifactSourceKind; + producedBy?: string; + elementIds?: readonly string[]; + spatialElementIds?: readonly string[]; + resourceIds?: readonly string[]; + data?: PolyWorldData; +} + +export interface PolyWorldDocumentResourceDeclaration { + id: string; + state?: PolyWorldResourceReadinessState; + renderBlocking?: boolean; + preloadOnly?: boolean; + elementIds?: readonly string[]; + spatialElementIds?: readonly string[]; + label?: string; + message?: string; + data?: PolyWorldData; +} + +export interface PolyWorldDocumentPlanPolicy extends PolyWorldLayerPlanPolicy { + id: string; +} + +export interface PolyWorldDocumentInput { + id?: string; + label?: string; + topology: PolyWorldTopologyInput; + capabilityIds?: readonly PolyWorldTopologyCapabilityId[]; + profileArtifacts?: readonly PolyWorldDocumentProfileArtifactRef[]; + resources?: readonly PolyWorldDocumentResourceDeclaration[]; + planPolicies?: readonly PolyWorldDocumentPlanPolicy[]; + data?: PolyWorldData; +} + +export interface PolyWorldDocumentSummary { + regionCount: number; + linkCount: number; + elementCount: number; + spatialElementCount: number; + profileArtifactCount: number; + resourceCount: number; + planPolicyCount: number; + capabilityIds: readonly PolyWorldTopologyCapabilityId[]; +} + +export interface PolyWorldDocument { + schemaVersion: 1; + id?: string; + label?: string; + topology: PolyWorldTopology; + capabilityContract: PolyWorldTopologyCapabilityContract; + capabilityIds: readonly PolyWorldTopologyCapabilityId[]; + capabilities: readonly PolyWorldTopologyCapability[]; + profileArtifacts: readonly PolyWorldDocumentProfileArtifactRef[]; + resources: readonly PolyWorldDocumentResourceDeclaration[]; + planPolicies: readonly PolyWorldDocumentPlanPolicy[]; + profileArtifactsById: ReadonlyMap; + profileArtifactIdsByProfile: ReadonlyMap; + resourcesById: ReadonlyMap; + planPoliciesById: ReadonlyMap; + summary: PolyWorldDocumentSummary; + data?: PolyWorldData; +} + +export class PolyWorldDocumentError extends Error { + readonly diagnostics: readonly PolyWorldDocumentDiagnostic[]; + + constructor(diagnostics: readonly PolyWorldDocumentDiagnostic[]) { + super(diagnostics.map((diagnostic) => diagnostic.message).join("\n")); + this.name = "PolyWorldDocumentError"; + this.diagnostics = diagnostics; + } +} + +const validProfileArtifactProfiles = new Set([ + "bsp-pvs", + "area-portals", + "portal-flow", + "chunk-traversal", +]); + +const profileArtifactKindByProfile: Readonly> = { + "bsp-pvs": "compiled-bsp-pvs", + "area-portals": "authored-area-portals", + "portal-flow": "authored-area-portal-flow", + "chunk-traversal": "chunk-working-set", +}; + +const profileArtifactCapabilityByProfile: Readonly> = { + "bsp-pvs": "compiled-bsp-pvs", + "area-portals": "area-portals", + "portal-flow": "area-portals", + "chunk-traversal": "chunk-hierarchy", +}; + +const validProfileArtifactSourceKinds = new Set([ + "compiled", + "authored", + "authored-runtime-selection", +]); + +const validResourceReadinessStates = new Set([ + "missing", + "requested", + "loading", + "ready", + "failed", + "stale", +]); + +export function validatePolyWorldDocument( + input: PolyWorldDocumentInput, +): PolyWorldDocumentDiagnostic[] { + const diagnostics: PolyWorldDocumentDiagnostic[] = []; + const topology = input.topology; + if (topology === undefined) { + diagnostics.push({ + code: "poly-world-document-missing-topology", + message: "PolyWorld document requires topology input.", + field: "topology", + kind: "document", + }); + return diagnostics; + } + + diagnostics.push(...validatePolyWorldTopology(topology).map((diagnostic) => ({ ...diagnostic }))); + validateOptionalId(input.id, "document", diagnostics); + + const capabilityContract = createPolyWorldTopologyCapabilityContract(); + const allCapabilityIds = capabilityContract.capabilities.map((capability) => capability.id); + const knownCapabilityIds = new Set(allCapabilityIds); + const enabledCapabilityIds = new Set(input.capabilityIds ?? allCapabilityIds); + validateCapabilityIds(input.capabilityIds, knownCapabilityIds, diagnostics); + + const elementIds = new Set((topology.elements ?? []).map((element) => element.id)); + const spatialElementIds = new Set((topology.spatialElements ?? []).map((spatialElement) => spatialElement.id)); + validateProfileArtifacts(input.profileArtifacts ?? [], enabledCapabilityIds, elementIds, spatialElementIds, diagnostics); + validateResources(input.resources ?? [], elementIds, spatialElementIds, diagnostics); + validatePlanPolicies(input.planPolicies ?? [], elementIds, diagnostics); + + return diagnostics; +} + +export function createPolyWorldDocument(input: PolyWorldDocumentInput): PolyWorldDocument { + const diagnostics = validatePolyWorldDocument(input); + if (diagnostics.length > 0) { + throw new PolyWorldDocumentError(diagnostics); + } + + const topology = createPolyWorldTopology(input.topology); + const capabilityContract = createPolyWorldTopologyCapabilityContract(); + const capabilityIds = [...(input.capabilityIds ?? capabilityContract.capabilities.map((capability) => capability.id))]; + const capabilityIdSet = new Set(capabilityIds); + const capabilities = capabilityContract.capabilities.filter((capability) => capabilityIdSet.has(capability.id)); + const profileArtifacts = (input.profileArtifacts ?? []).map(cloneProfileArtifact); + const resources = (input.resources ?? []).map(cloneResource); + const planPolicies = (input.planPolicies ?? []).map(clonePlanPolicy); + const profileArtifactsById = new Map(); + const profileArtifactIdsByProfile = new Map(); + const resourcesById = new Map(); + const planPoliciesById = new Map(); + + for (const artifact of profileArtifacts) { + profileArtifactsById.set(artifact.id, artifact); + pushMap(profileArtifactIdsByProfile, artifact.profile, artifact.id); + } + for (const resource of resources) resourcesById.set(resource.id, resource); + for (const policy of planPolicies) planPoliciesById.set(policy.id, policy); + + return { + schemaVersion: 1, + ...(input.id === undefined ? {} : { id: input.id }), + ...(input.label === undefined ? {} : { label: input.label }), + topology, + capabilityContract, + capabilityIds, + capabilities, + profileArtifacts, + resources, + planPolicies, + profileArtifactsById, + profileArtifactIdsByProfile, + resourcesById, + planPoliciesById, + summary: { + regionCount: topology.regions.length, + linkCount: topology.links.length, + elementCount: topology.elements.length, + spatialElementCount: topology.spatialElements.length, + profileArtifactCount: profileArtifacts.length, + resourceCount: resources.length, + planPolicyCount: planPolicies.length, + capabilityIds, + }, + ...(input.data === undefined ? {} : { data: input.data }), + }; +} + +function validateCapabilityIds( + capabilityIds: readonly PolyWorldTopologyCapabilityId[] | undefined, + knownCapabilityIds: ReadonlySet, + diagnostics: PolyWorldDocumentDiagnostic[], +): void { + if (capabilityIds === undefined) return; + if (capabilityIds.length === 0) { + diagnostics.push({ + code: "poly-world-document-empty-capability-ids", + message: "PolyWorld document capabilityIds must not be empty when provided.", + field: "capabilityIds", + kind: "capability", + }); + return; + } + + const seen = new Set(); + for (const capabilityId of capabilityIds) { + if (typeof capabilityId !== "string" || capabilityId.length === 0) { + diagnostics.push({ + code: "poly-world-document-empty-capability-id", + message: "PolyWorld document capabilityIds must contain only non-empty strings.", + field: "capabilityIds", + kind: "capability", + }); + continue; + } + if (seen.has(capabilityId)) { + diagnostics.push({ + code: "poly-world-document-duplicate-capability-id", + message: `Duplicate PolyWorld document capability id "${capabilityId}".`, + id: capabilityId, + field: "capabilityIds", + kind: "capability", + }); + } + seen.add(capabilityId); + if (!knownCapabilityIds.has(capabilityId)) { + diagnostics.push({ + code: "poly-world-document-invalid-capability-id", + message: `PolyWorld document references unknown capability id "${capabilityId}".`, + id: capabilityId, + field: "capabilityIds", + kind: "capability", + }); + } + } +} + +function validateProfileArtifacts( + profileArtifacts: readonly PolyWorldDocumentProfileArtifactRef[], + enabledCapabilityIds: ReadonlySet, + elementIds: ReadonlySet, + spatialElementIds: ReadonlySet, + diagnostics: PolyWorldDocumentDiagnostic[], +): void { + const seen = new Set(); + for (const artifact of profileArtifacts) { + validateRequiredId(artifact.id, "profileArtifact", diagnostics); + if (artifact.id.length > 0 && seen.has(artifact.id)) { + diagnostics.push({ + code: "poly-world-document-duplicate-profile-artifact-id", + message: `Duplicate PolyWorld document profile artifact id "${artifact.id}".`, + id: artifact.id, + field: "profileArtifacts.id", + kind: "profileArtifact", + }); + } + if (artifact.id.length > 0) seen.add(artifact.id); + + if (!validProfileArtifactProfiles.has(artifact.profile)) { + diagnostics.push({ + code: "poly-world-document-invalid-profile-artifact-profile", + message: `PolyWorld document profile artifact "${artifact.id}" has invalid profile "${String(artifact.profile)}".`, + id: artifact.id, + field: "profileArtifacts.profile", + kind: "profileArtifact", + }); + } else if ( + artifact.artifactKind !== undefined && + artifact.artifactKind !== profileArtifactKindByProfile[artifact.profile] + ) { + diagnostics.push({ + code: "poly-world-document-profile-artifact-kind-mismatch", + message: `PolyWorld document profile artifact "${artifact.id}" cannot use artifact kind "${artifact.artifactKind}" for profile "${artifact.profile}".`, + id: artifact.id, + field: "profileArtifacts.artifactKind", + kind: "profileArtifact", + }); + } + validateProfileArtifactCapability(artifact, enabledCapabilityIds, diagnostics); + + if (artifact.sourceKind !== undefined && !validProfileArtifactSourceKinds.has(artifact.sourceKind)) { + diagnostics.push({ + code: "poly-world-document-invalid-profile-artifact-source-kind", + message: `PolyWorld document profile artifact "${artifact.id}" has invalid source kind "${String(artifact.sourceKind)}".`, + id: artifact.id, + field: "profileArtifacts.sourceKind", + kind: "profileArtifact", + }); + } + validateStringArray(artifact.id, "profileArtifacts.elementIds", artifact.elementIds, diagnostics, "profileArtifact"); + validateStringArray(artifact.id, "profileArtifacts.spatialElementIds", artifact.spatialElementIds, diagnostics, "profileArtifact"); + validateStringArray(artifact.id, "profileArtifacts.resourceIds", artifact.resourceIds, diagnostics, "profileArtifact"); + validateElementReferences(artifact.id, "profileArtifacts.elementIds", artifact.elementIds, elementIds, diagnostics, "profileArtifact"); + validateSpatialElementReferences(artifact.id, "profileArtifacts.spatialElementIds", artifact.spatialElementIds, spatialElementIds, diagnostics, "profileArtifact"); + } +} + +function validateProfileArtifactCapability( + artifact: PolyWorldDocumentProfileArtifactRef, + enabledCapabilityIds: ReadonlySet, + diagnostics: PolyWorldDocumentDiagnostic[], +): void { + if (!validProfileArtifactProfiles.has(artifact.profile)) return; + const capabilityId = profileArtifactCapabilityByProfile[artifact.profile]; + if (enabledCapabilityIds.has(capabilityId)) return; + diagnostics.push({ + code: "poly-world-document-profile-artifact-capability-disabled", + message: `PolyWorld document profile artifact "${artifact.id}" requires disabled capability "${capabilityId}".`, + id: artifact.id, + field: "profileArtifacts.profile", + kind: "profileArtifact", + }); +} + +function validateResources( + resources: readonly PolyWorldDocumentResourceDeclaration[], + elementIds: ReadonlySet, + spatialElementIds: ReadonlySet, + diagnostics: PolyWorldDocumentDiagnostic[], +): void { + const seen = new Set(); + for (const resource of resources) { + validateRequiredId(resource.id, "resource", diagnostics); + if (resource.id.length > 0 && seen.has(resource.id)) { + diagnostics.push({ + code: "poly-world-document-duplicate-resource-id", + message: `Duplicate PolyWorld document resource id "${resource.id}".`, + id: resource.id, + field: "resources.id", + kind: "resource", + }); + } + if (resource.id.length > 0) seen.add(resource.id); + if (resource.state !== undefined && !validResourceReadinessStates.has(resource.state)) { + diagnostics.push({ + code: "poly-world-document-invalid-resource-state", + message: `PolyWorld document resource "${resource.id}" has invalid state "${String(resource.state)}".`, + id: resource.id, + field: "resources.state", + kind: "resource", + }); + } + validateStringArray(resource.id, "resources.elementIds", resource.elementIds, diagnostics, "resource"); + validateStringArray(resource.id, "resources.spatialElementIds", resource.spatialElementIds, diagnostics, "resource"); + validateElementReferences(resource.id, "resources.elementIds", resource.elementIds, elementIds, diagnostics, "resource"); + validateSpatialElementReferences(resource.id, "resources.spatialElementIds", resource.spatialElementIds, spatialElementIds, diagnostics, "resource"); + } +} + +function validatePlanPolicies( + planPolicies: readonly PolyWorldDocumentPlanPolicy[], + elementIds: ReadonlySet, + diagnostics: PolyWorldDocumentDiagnostic[], +): void { + const seen = new Set(); + for (const policy of planPolicies) { + validateRequiredId(policy.id, "planPolicy", diagnostics); + if (policy.id.length > 0 && seen.has(policy.id)) { + diagnostics.push({ + code: "poly-world-document-duplicate-plan-policy-id", + message: `Duplicate PolyWorld document plan policy id "${policy.id}".`, + id: policy.id, + field: "planPolicies.id", + kind: "planPolicy", + }); + } + if (policy.id.length > 0) seen.add(policy.id); + if (typeof policy.layer !== "string" || policy.layer.length === 0) { + diagnostics.push({ + code: "poly-world-document-empty-plan-policy-layer", + message: `PolyWorld document plan policy "${policy.id}" requires a non-empty layer.`, + id: policy.id, + field: "planPolicies.layer", + kind: "planPolicy", + }); + } + validateStringArray(policy.id, "planPolicies.elementLayers", policy.elementLayers, diagnostics, "planPolicy"); + validateStringArray(policy.id, "planPolicies.tags", policy.tags, diagnostics, "planPolicy"); + validateStringArray(policy.id, "planPolicies.elementKinds", policy.elementKinds, diagnostics, "planPolicy"); + validateStringArray(policy.id, "planPolicies.elementIds", policy.elementIds, diagnostics, "planPolicy"); + validateElementReferences(policy.id, "planPolicies.elementIds", policy.elementIds, elementIds, diagnostics, "planPolicy"); + } +} + +function validateOptionalId( + id: string | undefined, + kind: PolyWorldDocumentDiagnosticKind, + diagnostics: PolyWorldDocumentDiagnostic[], +): void { + if (id === undefined) return; + if (typeof id === "string" && id.length > 0) return; + diagnostics.push({ + code: "poly-world-document-empty-id", + message: `PolyWorld ${kind} id must be a non-empty string when provided.`, + field: "id", + kind, + }); +} + +function validateRequiredId( + id: string, + kind: PolyWorldDocumentDiagnosticKind, + diagnostics: PolyWorldDocumentDiagnostic[], +): void { + if (typeof id === "string" && id.length > 0) return; + diagnostics.push({ + code: `poly-world-document-empty-${kind}-id`, + message: `PolyWorld document ${kind} requires a non-empty id.`, + field: "id", + kind, + }); +} + +function validateStringArray( + id: string, + field: string, + values: readonly string[] | undefined, + diagnostics: PolyWorldDocumentDiagnostic[], + kind: PolyWorldDocumentDiagnosticKind, +): void { + if (values === undefined) return; + if (values.length === 0) { + diagnostics.push({ + code: "poly-world-document-empty-array", + message: `PolyWorld document "${id}" has empty ${field}.`, + id, + field, + kind, + }); + return; + } + for (const value of values) { + if (typeof value === "string" && value.length > 0) continue; + diagnostics.push({ + code: "poly-world-document-empty-array-value", + message: `PolyWorld document "${id}" has an empty value in ${field}.`, + id, + field, + kind, + }); + } +} + +function validateElementReferences( + id: string, + field: string, + values: readonly string[] | undefined, + elementIds: ReadonlySet, + diagnostics: PolyWorldDocumentDiagnostic[], + kind: PolyWorldDocumentDiagnosticKind, +): void { + for (const elementId of values ?? []) { + if (!elementIds.has(elementId)) { + diagnostics.push({ + code: "poly-world-document-missing-element", + message: `PolyWorld document "${id}" references missing element "${elementId}".`, + id, + field, + kind, + }); + } + } +} + +function validateSpatialElementReferences( + id: string, + field: string, + values: readonly string[] | undefined, + spatialElementIds: ReadonlySet, + diagnostics: PolyWorldDocumentDiagnostic[], + kind: PolyWorldDocumentDiagnosticKind, +): void { + for (const spatialElementId of values ?? []) { + if (!spatialElementIds.has(spatialElementId)) { + diagnostics.push({ + code: "poly-world-document-missing-spatial-element", + message: `PolyWorld document "${id}" references missing spatial element "${spatialElementId}".`, + id, + field, + kind, + }); + } + } +} + +function cloneProfileArtifact( + artifact: PolyWorldDocumentProfileArtifactRef, +): PolyWorldDocumentProfileArtifactRef { + return { + ...artifact, + ...(artifact.elementIds === undefined ? {} : { elementIds: [...artifact.elementIds] }), + ...(artifact.spatialElementIds === undefined ? {} : { spatialElementIds: [...artifact.spatialElementIds] }), + ...(artifact.resourceIds === undefined ? {} : { resourceIds: [...artifact.resourceIds] }), + }; +} + +function cloneResource( + resource: PolyWorldDocumentResourceDeclaration, +): PolyWorldDocumentResourceDeclaration { + return { + ...resource, + ...(resource.elementIds === undefined ? {} : { elementIds: [...resource.elementIds] }), + ...(resource.spatialElementIds === undefined ? {} : { spatialElementIds: [...resource.spatialElementIds] }), + }; +} + +function clonePlanPolicy(policy: PolyWorldDocumentPlanPolicy): PolyWorldDocumentPlanPolicy { + return { + ...policy, + ...(policy.elementLayers === undefined ? {} : { elementLayers: [...policy.elementLayers] }), + ...(policy.tags === undefined ? {} : { tags: [...policy.tags] }), + ...(policy.elementKinds === undefined ? {} : { elementKinds: [...policy.elementKinds] }), + ...(policy.elementIds === undefined ? {} : { elementIds: [...policy.elementIds] }), + }; +} + +function pushMap(map: Map, key: K, value: V): void { + const values = map.get(key); + if (values === undefined) { + map.set(key, [value]); + return; + } + values.push(value); +} diff --git a/packages/world/src/topology/elementGraph.ts b/packages/world/src/topology/elementGraph.ts new file mode 100644 index 000000000..fb129d463 --- /dev/null +++ b/packages/world/src/topology/elementGraph.ts @@ -0,0 +1,205 @@ +import type { + PolyWorldElement, + PolyWorldElementPurpose, + PolyWorldSelection, + PolyWorldTopology, +} from "./types"; + +export type PolyWorldElementGraphRelation = "parent" | "container"; +export type PolyWorldElementPurposeMatch = "any" | "all"; + +export interface PolyWorldElementSubtreeOptions { + relation?: PolyWorldElementGraphRelation; + recursive?: boolean; + includeSeeds?: boolean; + purposes?: readonly PolyWorldElementPurpose[]; + purposeMatch?: PolyWorldElementPurposeMatch; + layers?: readonly string[]; + tags?: readonly string[]; +} + +export interface PolyWorldElementSubtree { + seedElementIds: readonly string[]; + relation: PolyWorldElementGraphRelation; + elementIds: readonly string[]; + descendantElementIds: readonly string[]; + missingElementIds: readonly string[]; +} + +export interface PolyWorldElementPurposeSelectionOptions { + match?: PolyWorldElementPurposeMatch; + layers?: readonly string[]; + tags?: readonly string[]; + includeDescendants?: boolean; + relation?: PolyWorldElementGraphRelation; + recursive?: boolean; + reasonLabel?: string; + reasonKind?: string; +} + +export function resolvePolyWorldElementSubtree( + topology: PolyWorldTopology, + seedElementIds: readonly string[], + options: PolyWorldElementSubtreeOptions = {}, +): PolyWorldElementSubtree { + const relation = options.relation ?? "parent"; + const recursive = options.recursive ?? true; + const includeSeeds = options.includeSeeds ?? true; + const seedIds = unique(seedElementIds); + const missingElementIds: string[] = []; + const descendantElementIds: string[] = []; + const purposeSet = options.purposes === undefined ? undefined : new Set(options.purposes); + const layerSet = options.layers === undefined ? undefined : new Set(options.layers); + const tagSet = options.tags === undefined ? undefined : new Set(options.tags); + + for (const seedElementId of seedIds) { + const seed = topology.elementsById.get(seedElementId); + if (seed === undefined) { + add(missingElementIds, seedElementId); + continue; + } + const children = relation === "parent" + ? topology.elementsByParentId.get(seedElementId) ?? [] + : topology.elementsByContainerId.get(seedElementId) ?? []; + collectDescendants(topology, children, relation, recursive, descendantElementIds); + } + + const filteredDescendantIds = topologyOrderedElementIds( + topology, + descendantElementIds.filter((elementId) => { + const element = topology.elementsById.get(elementId); + return element !== undefined && matchesElementFilters( + element, + purposeSet, + options.purposeMatch ?? "any", + layerSet, + tagSet, + ); + }), + ); + const seedElementIdsForResult = includeSeeds + ? seedIds.filter((elementId) => topology.elementsById.has(elementId)) + : []; + + return { + seedElementIds: seedIds, + relation, + elementIds: topologyOrderedElementIds(topology, [...seedElementIdsForResult, ...filteredDescendantIds]), + descendantElementIds: filteredDescendantIds, + missingElementIds, + }; +} + +export function selectPolyWorldElementsByPurpose( + topology: PolyWorldTopology, + purposes: readonly PolyWorldElementPurpose[], + options: PolyWorldElementPurposeSelectionOptions = {}, +): PolyWorldSelection { + const purposeSet = new Set(purposes); + const layerSet = options.layers === undefined ? undefined : new Set(options.layers); + const tagSet = options.tags === undefined ? undefined : new Set(options.tags); + const seedElementIds = topologyOrderedElementIds( + topology, + topology.elements + .filter((element) => matchesElementFilters(element, purposeSet, options.match ?? "any", layerSet, tagSet)) + .map((element) => element.id), + ); + const subtree = options.includeDescendants === true + ? resolvePolyWorldElementSubtree(topology, seedElementIds, { + relation: options.relation, + recursive: options.recursive, + includeSeeds: true, + purposes, + purposeMatch: options.match, + layers: options.layers, + tags: options.tags, + }) + : undefined; + const elementIds = subtree?.elementIds ?? seedElementIds; + + return { + elementIds, + reasons: [ + { + id: "poly-world-element-purpose", + kind: options.reasonKind ?? "element-purpose", + label: options.reasonLabel ?? "element-purpose", + elementIds, + data: { + purposes: [...purposes], + match: options.match ?? "any", + ...(subtree === undefined ? {} : { + relation: subtree.relation, + descendantElementIds: subtree.descendantElementIds, + }), + }, + }, + ], + }; +} + +function collectDescendants( + topology: PolyWorldTopology, + elements: readonly PolyWorldElement[], + relation: PolyWorldElementGraphRelation, + recursive: boolean, + descendantElementIds: string[], +): void { + for (const element of elements) { + if (descendantElementIds.includes(element.id)) continue; + descendantElementIds.push(element.id); + if (!recursive) continue; + const children = relation === "parent" + ? topology.elementsByParentId.get(element.id) ?? [] + : topology.elementsByContainerId.get(element.id) ?? []; + collectDescendants(topology, children, relation, recursive, descendantElementIds); + } +} + +function matchesElementFilters( + element: PolyWorldElement, + purposes: ReadonlySet | undefined, + purposeMatch: PolyWorldElementPurposeMatch, + layers: ReadonlySet | undefined, + tags: ReadonlySet | undefined, +): boolean { + if (!matchesPurpose(element.purposes, purposes, purposeMatch)) return false; + if (!matchesAny(element.layers, layers)) return false; + if (!matchesAny(element.tags, tags)) return false; + return true; +} + +function matchesPurpose( + values: readonly PolyWorldElementPurpose[] | undefined, + filter: ReadonlySet | undefined, + match: PolyWorldElementPurposeMatch, +): boolean { + if (filter === undefined || filter.size === 0) return true; + if (values === undefined) return false; + return match === "all" + ? [...filter].every((value) => values.includes(value)) + : values.some((value) => filter.has(value)); +} + +function matchesAny(values: readonly string[] | undefined, filter: ReadonlySet | undefined): boolean { + if (filter === undefined || filter.size === 0) return true; + if (values === undefined) return false; + return values.some((value) => filter.has(value)); +} + +function topologyOrderedElementIds(topology: PolyWorldTopology, elementIds: readonly string[]): string[] { + const elementIdSet = new Set(elementIds); + const ordered = topology.elements + .map((element) => element.id) + .filter((elementId) => elementIdSet.has(elementId)); + const missingOrder = elementIds.filter((elementId) => !topology.elementsById.has(elementId)); + return unique([...ordered, ...missingOrder]); +} + +function unique(values: readonly string[]): string[] { + return [...new Set(values)]; +} + +function add(values: string[], value: string): void { + if (!values.includes(value)) values.push(value); +} diff --git a/packages/world/src/topology/index.ts b/packages/world/src/topology/index.ts new file mode 100644 index 000000000..2f41812c8 --- /dev/null +++ b/packages/world/src/topology/index.ts @@ -0,0 +1,89 @@ +export { + PolyWorldDocumentError, + createPolyWorldDocument, + validatePolyWorldDocument, +} from "./document"; +export { + PolyWorldTopologyError, + createPolyWorldTopology, + validatePolyWorldTopology, +} from "./createTopology"; +export { resolvePolyWorldElements } from "./resolveElements"; +export { + expandPolyWorldSelectionElementRelations, + resolvePolyWorldElementRelations, +} from "./resolveRelations"; +export { + resolvePolyWorldElementSubtree, + selectPolyWorldElementsByPurpose, +} from "./elementGraph"; +export { createPolyWorldTopologyCapabilityContract } from "./capabilities"; +export { resolvePolyWorldRegionByPoint } from "./resolveRegion"; +export { + resolvePolyWorldSpatialElementRole, + resolvePolyWorldSpatialElementVisibility, + summarizePolyWorldSpatialElementRoles, +} from "./spatialElements"; +export type { + PolyWorldSpatialElementRoleSummary, +} from "./spatialElements"; +export type { + PolyWorldElementGraphRelation, + PolyWorldElementPurposeMatch, + PolyWorldElementPurposeSelectionOptions, + PolyWorldElementSubtree, + PolyWorldElementSubtreeOptions, +} from "./elementGraph"; +export type { + PolyWorldDocument, + PolyWorldDocumentDiagnostic, + PolyWorldDocumentDiagnosticKind, + PolyWorldDocumentInput, + PolyWorldDocumentPlanPolicy, + PolyWorldDocumentProfileArtifactRef, + PolyWorldDocumentResourceDeclaration, + PolyWorldDocumentSummary, +} from "./document"; +export type { + PolyWorldTopologyCapability, + PolyWorldTopologyCapabilityContract, + PolyWorldTopologyCapabilityId, + PolyWorldTopologyCapabilityReference, + PolyWorldTopologyReferenceContract, +} from "./capabilities"; +export type { + PolyWorldBounds, + PolyWorldData, + PolyWorldLink, + PolyWorldLinkDirection, + PolyWorldMissingElementRelation, + PolyWorldElement, + PolyWorldElementMatch, + PolyWorldElementMatchKind, + PolyWorldElementPurpose, + PolyWorldElementRelation, + PolyWorldElementRelationExpansion, + PolyWorldElementRelationExpansionOptions, + PolyWorldElementRelationKind, + PolyWorldElementResolution, + PolyWorldElementResolutionOptions, + PolyWorldElementTransform, + PolyWorldRegion, + PolyWorldRegionMatch, + PolyWorldRegionResolution, + PolyWorldRegionResolverOptions, + PolyWorldResolvedElement, + PolyWorldSelection, + PolyWorldSelectionElementRelationExpansionOptions, + PolyWorldSelectionKeyOwner, + PolyWorldSelectionKeyOwnerKind, + PolyWorldSelectionReason, + PolyWorldSpatialElement, + PolyWorldSpatialElementRole, + PolyWorldSpatialElementVisibility, + PolyWorldTopology, + PolyWorldTopologyInput, + PolyWorldTopologyValidationOptions, + PolyWorldUnresolvedSelection, + PolyWorldValidationDiagnostic, +} from "./types"; diff --git a/packages/world/src/topology/resolveElements.ts b/packages/world/src/topology/resolveElements.ts new file mode 100644 index 000000000..d1220ec60 --- /dev/null +++ b/packages/world/src/topology/resolveElements.ts @@ -0,0 +1,145 @@ +import type { + PolyWorldElement, + PolyWorldElementMatch, + PolyWorldElementResolution, + PolyWorldElementResolutionOptions, + PolyWorldSelection, + PolyWorldTopology, + PolyWorldUnresolvedSelection, +} from "./types"; + +export function resolvePolyWorldElements( + topology: PolyWorldTopology, + selection: PolyWorldSelection, + options: PolyWorldElementResolutionOptions = {}, +): PolyWorldElementResolution { + const selectedRegionIds = unique(selection.regionIds); + const selectedLinkIds = unique(selection.linkIds); + const selectedSelectionKeys = unique(selection.selectionKeys); + const selectedElementIds = unique(selection.elementIds); + const selectedSourceIds = unique(selection.sourceIds); + const selectedAliases = unique(selection.aliases); + const selectedRegionIdSet = new Set(selectedRegionIds); + const selectedSelectionKeySet = new Set(selectedSelectionKeys); + const selectedElementIdSet = new Set(selectedElementIds); + const selectedSourceIdSet = new Set(selectedSourceIds); + const selectedAliasSet = new Set(selectedAliases); + const layerFilter = options.layers === undefined ? undefined : new Set(options.layers); + const tagFilter = options.tags === undefined ? undefined : new Set(options.tags); + const resolved: Array<{ + element: PolyWorldElement; + elementId: string; + matches: PolyWorldElementMatch[]; + }> = []; + + for (const element of topology.elements) { + if (!passesFilter(element.layers, layerFilter)) continue; + if (!passesFilter(element.tags, tagFilter)) continue; + + const matches = collectElementMatches( + element, + selectedRegionIdSet, + selectedSelectionKeySet, + selectedElementIdSet, + selectedSourceIdSet, + selectedAliasSet, + ); + if (matches.length > 0) { + resolved.push({ + element, + elementId: element.id, + matches, + }); + } + } + + return { + elements: resolved.map((entry) => entry.element), + elementIds: resolved.map((entry) => entry.elementId), + resolved, + unresolved: unresolvedSelection(topology, { + regionIds: selectedRegionIds, + linkIds: selectedLinkIds, + selectionKeys: selectedSelectionKeys, + elementIds: selectedElementIds, + sourceIds: selectedSourceIds, + aliases: selectedAliases, + }), + selectedRegionIds, + selectedLinkIds, + selectedSelectionKeys, + selectedElementIds, + selectedSourceIds, + selectedAliases, + }; +} + +function collectElementMatches( + element: PolyWorldElement, + regionIds: ReadonlySet, + selectionKeys: ReadonlySet, + elementIds: ReadonlySet, + sourceIds: ReadonlySet, + aliases: ReadonlySet, +): PolyWorldElementMatch[] { + const matches: PolyWorldElementMatch[] = []; + + if (elementIds.has(element.id)) { + matches.push({ kind: "elementId", value: element.id }); + } + + for (const sourceId of element.sourceIds ?? []) { + if (sourceIds.has(sourceId)) matches.push({ kind: "sourceId", value: sourceId }); + } + + for (const alias of element.aliases ?? []) { + if (aliases.has(alias)) matches.push({ kind: "alias", value: alias }); + } + + for (const selectionKey of element.selectionKeys ?? []) { + if (selectionKeys.has(selectionKey)) matches.push({ kind: "selectionKey", value: selectionKey }); + } + + const elementRegionIds = element.regionIds ?? []; + if (elementRegionIds.length > 0 && regionIds.size > 0) { + const regionMatch = element.regionMatch ?? "any"; + if (regionMatch === "all") { + const allRegionsSelected = elementRegionIds.every((regionId) => regionIds.has(regionId)); + if (allRegionsSelected) { + for (const regionId of elementRegionIds) { + matches.push({ kind: "region", value: regionId }); + } + } + } else { + for (const regionId of elementRegionIds) { + if (regionIds.has(regionId)) matches.push({ kind: "region", value: regionId }); + } + } + } + + return matches; +} + +function unresolvedSelection( + topology: PolyWorldTopology, + selection: PolyWorldUnresolvedSelection, +): PolyWorldUnresolvedSelection { + return { + regionIds: selection.regionIds.filter((regionId) => !topology.regionsById.has(regionId)), + linkIds: selection.linkIds.filter((linkId) => !topology.linksById.has(linkId)), + selectionKeys: selection.selectionKeys.filter((selectionKey) => !topology.selectionKeyOwnersByKey.has(selectionKey)), + elementIds: selection.elementIds.filter((elementId) => !topology.elementsById.has(elementId)), + sourceIds: selection.sourceIds.filter((sourceId) => !topology.elementsBySourceId.has(sourceId)), + aliases: selection.aliases.filter((alias) => !topology.elementsByAlias.has(alias)), + }; +} + +function passesFilter(values: readonly string[] | undefined, filter: ReadonlySet | undefined): boolean { + if (filter === undefined) return true; + if (values === undefined) return false; + return values.some((value) => filter.has(value)); +} + +function unique(values: readonly string[] | undefined): string[] { + return [...new Set(values ?? [])]; +} diff --git a/packages/world/src/topology/resolveRegion.ts b/packages/world/src/topology/resolveRegion.ts new file mode 100644 index 000000000..d61b9e97e --- /dev/null +++ b/packages/world/src/topology/resolveRegion.ts @@ -0,0 +1,103 @@ +import type { + PolyWorldBounds, + PolyWorldRegion, + PolyWorldRegionResolution, + PolyWorldRegionResolverOptions, + PolyWorldTopology, +} from "./types"; +import type { Vec3 } from "@layoutit/polycss-core"; + +export function resolvePolyWorldRegionByPoint( + topology: PolyWorldTopology, + point: Vec3, + options: PolyWorldRegionResolverOptions = {}, +): PolyWorldRegionResolution | undefined { + const regions = resolveCandidateRegions(topology, options.regionIds); + let containing: PolyWorldRegion | undefined; + let containingVolume = Infinity; + + for (const region of regions) { + if (region.bounds === undefined || !containsPoint(region.bounds, point)) continue; + const volume = boundsVolume(region.bounds); + if (volume < containingVolume) { + containing = region; + containingVolume = volume; + } + } + + if (containing !== undefined) { + return { + region: containing, + regionId: containing.id, + reason: "bounds", + }; + } + + if (options.nearest !== true) return undefined; + + let nearestRegion: PolyWorldRegion | undefined; + let nearestDistanceSq = Infinity; + for (const region of regions) { + const center = region.center ?? centerFromBounds(region.bounds); + if (center === undefined) continue; + const distanceSq = squaredDistance(point, center); + if (distanceSq < nearestDistanceSq) { + nearestRegion = region; + nearestDistanceSq = distanceSq; + } + } + + if (nearestRegion === undefined) return undefined; + return { + region: nearestRegion, + regionId: nearestRegion.id, + reason: "nearest", + distanceSq: nearestDistanceSq, + }; +} + +function resolveCandidateRegions( + topology: PolyWorldTopology, + regionIds: readonly string[] | undefined, +): readonly PolyWorldRegion[] { + if (regionIds === undefined) return topology.regions; + return regionIds.flatMap((regionId) => { + const region = topology.regionsById.get(regionId); + return region === undefined ? [] : [region]; + }); +} + +function containsPoint(bounds: PolyWorldBounds, point: Vec3): boolean { + return ( + point[0] >= bounds.min[0] && + point[0] <= bounds.max[0] && + point[1] >= bounds.min[1] && + point[1] <= bounds.max[1] && + point[2] >= bounds.min[2] && + point[2] <= bounds.max[2] + ); +} + +function boundsVolume(bounds: PolyWorldBounds): number { + return ( + (bounds.max[0] - bounds.min[0]) * + (bounds.max[1] - bounds.min[1]) * + (bounds.max[2] - bounds.min[2]) + ); +} + +function centerFromBounds(bounds: PolyWorldBounds | undefined): Vec3 | undefined { + if (bounds === undefined) return undefined; + return [ + (bounds.min[0] + bounds.max[0]) / 2, + (bounds.min[1] + bounds.max[1]) / 2, + (bounds.min[2] + bounds.max[2]) / 2, + ]; +} + +function squaredDistance(a: Vec3, b: Vec3): number { + const dx = a[0] - b[0]; + const dy = a[1] - b[1]; + const dz = a[2] - b[2]; + return dx * dx + dy * dy + dz * dz; +} diff --git a/packages/world/src/topology/resolveRelations.ts b/packages/world/src/topology/resolveRelations.ts new file mode 100644 index 000000000..27378e949 --- /dev/null +++ b/packages/world/src/topology/resolveRelations.ts @@ -0,0 +1,164 @@ +import { resolvePolyWorldElements } from "./resolveElements"; +import type { + PolyWorldMissingElementRelation, + PolyWorldElement, + PolyWorldElementRelation, + PolyWorldElementRelationExpansion, + PolyWorldElementRelationExpansionOptions, + PolyWorldElementRelationKind, + PolyWorldSelection, + PolyWorldSelectionElementRelationExpansionOptions, + PolyWorldTopology, +} from "./types"; + +const defaultRelationExpansionOptions: Required = { + includeParents: true, + includeContainers: true, + recursive: true, +}; + +export function resolvePolyWorldElementRelations( + topology: PolyWorldTopology, + elementIds: readonly string[], + options: PolyWorldElementRelationExpansionOptions = {}, +): PolyWorldElementRelationExpansion { + const resolvedOptions = { ...defaultRelationExpansionOptions, ...options }; + const seedElementIds = unique(elementIds); + const relatedElementIds: string[] = []; + const parentElementIds: string[] = []; + const containerElementIds: string[] = []; + const missingElementIds: string[] = []; + const missingRelations: PolyWorldMissingElementRelation[] = []; + const relations: PolyWorldElementRelation[] = []; + + for (const elementId of seedElementIds) { + const element = topology.elementsById.get(elementId); + if (element === undefined) { + add(missingElementIds, elementId); + continue; + } + + if (resolvedOptions.includeParents) { + walkElementRelation( + topology, + element, + "parent", + resolvedOptions.recursive, + parentElementIds, + relatedElementIds, + missingRelations, + relations, + ); + } + + if (resolvedOptions.includeContainers) { + walkElementRelation( + topology, + element, + "container", + resolvedOptions.recursive, + containerElementIds, + relatedElementIds, + missingRelations, + relations, + ); + } + } + + return { + seedElementIds, + elementIds: topologyOrderedElementIds(topology, [...seedElementIds, ...relatedElementIds]), + relatedElementIds: topologyOrderedElementIds(topology, relatedElementIds), + parentElementIds: topologyOrderedElementIds(topology, parentElementIds), + containerElementIds: topologyOrderedElementIds(topology, containerElementIds), + missingElementIds, + missingRelations, + relations, + }; +} + +export function expandPolyWorldSelectionElementRelations( + topology: PolyWorldTopology, + selection: PolyWorldSelection, + options: PolyWorldSelectionElementRelationExpansionOptions = {}, +): PolyWorldSelection { + const resolution = resolvePolyWorldElements(topology, selection, options.resolutionOptions); + const expansion = resolvePolyWorldElementRelations(topology, resolution.elementIds, options); + if (expansion.relatedElementIds.length === 0) return selection; + + return { + ...selection, + elementIds: unique([...(selection.elementIds ?? []), ...expansion.relatedElementIds]), + reasons: [ + ...(selection.reasons ?? []), + { + label: options.reasonLabel ?? "element-relations", + kind: options.reasonKind ?? "element-relations", + elementIds: expansion.relatedElementIds, + data: { + parentElementIds: expansion.parentElementIds, + containerElementIds: expansion.containerElementIds, + }, + }, + ], + }; +} + +function walkElementRelation( + topology: PolyWorldTopology, + element: PolyWorldElement, + kind: PolyWorldElementRelationKind, + recursive: boolean, + kindElementIds: string[], + relatedElementIds: string[], + missingRelations: PolyWorldMissingElementRelation[], + relations: PolyWorldElementRelation[], +): void { + const field = kind === "parent" ? "parentId" : "containerId"; + const visited = new Set([element.id]); + let current = element; + let depth = 0; + + while (true) { + const relatedElementId = current[field]; + if (relatedElementId === undefined) return; + depth += 1; + + const relation: PolyWorldElementRelation = { + kind, + elementId: current.id, + relatedElementId, + depth, + }; + relations.push(relation); + + const relatedElement = topology.elementsById.get(relatedElementId); + if (relatedElement === undefined) { + missingRelations.push(relation); + return; + } + + add(kindElementIds, relatedElementId); + add(relatedElementIds, relatedElementId); + if (!recursive || visited.has(relatedElementId)) return; + visited.add(relatedElementId); + current = relatedElement; + } +} + +function topologyOrderedElementIds(topology: PolyWorldTopology, elementIds: readonly string[]): string[] { + const elementIdSet = new Set(elementIds); + const ordered = topology.elements + .map((element) => element.id) + .filter((elementId) => elementIdSet.has(elementId)); + const missingOrder = elementIds.filter((elementId) => !topology.elementsById.has(elementId)); + return unique([...ordered, ...missingOrder]); +} + +function unique(values: readonly string[]): string[] { + return [...new Set(values)]; +} + +function add(values: string[], value: string): void { + if (!values.includes(value)) values.push(value); +} diff --git a/packages/world/src/topology/spatialElements.ts b/packages/world/src/topology/spatialElements.ts new file mode 100644 index 000000000..30a958a84 --- /dev/null +++ b/packages/world/src/topology/spatialElements.ts @@ -0,0 +1,80 @@ +import type { + PolyWorldSpatialElement, + PolyWorldSpatialElementRole, + PolyWorldSpatialElementVisibility, +} from "./types"; + +export interface PolyWorldSpatialElementRoleSummary { + role: PolyWorldSpatialElementRole; + count: number; + spatialElementIds: readonly string[]; + elementIds: readonly string[]; +} + +export function resolvePolyWorldSpatialElementRole( + spatialElement: PolyWorldSpatialElement, +): PolyWorldSpatialElementRole { + if (spatialElement.role !== undefined) return spatialElement.role; + if (spatialElement.visibility === "structural") return "shell"; + if (spatialElement.visibility === "detail") return "detail"; + return spatialElement.leafId === undefined ? "detail" : "shell"; +} + +export function resolvePolyWorldSpatialElementVisibility( + spatialElement: PolyWorldSpatialElement, +): PolyWorldSpatialElementVisibility { + if (spatialElement.visibility !== undefined) return spatialElement.visibility; + switch (resolvePolyWorldSpatialElementRole(spatialElement)) { + case "root": + case "shell": + case "opening": + return "structural"; + case "detail": + case "prop": + return "detail"; + } +} + +export function summarizePolyWorldSpatialElementRoles( + spatialElements: readonly PolyWorldSpatialElement[], +): PolyWorldSpatialElementRoleSummary[] { + const summaries = new Map(); + for (const spatialElement of spatialElements) { + const role = resolvePolyWorldSpatialElementRole(spatialElement); + const summary = summaries.get(role); + if (summary === undefined) { + summaries.set(role, { + spatialElementIds: [spatialElement.id], + elementIds: [spatialElement.elementId ?? spatialElement.id], + }); + continue; + } + summary.spatialElementIds.push(spatialElement.id); + add(summary.elementIds, spatialElement.elementId ?? spatialElement.id); + } + return polyWorldSpatialElementRoleOrder.flatMap((role) => { + const summary = summaries.get(role); + if (summary === undefined) return []; + return [{ + role, + count: summary.spatialElementIds.length, + spatialElementIds: summary.spatialElementIds, + elementIds: summary.elementIds, + }]; + }); +} + +const polyWorldSpatialElementRoleOrder: readonly PolyWorldSpatialElementRole[] = [ + "root", + "shell", + "opening", + "detail", + "prop", +]; + +function add(values: T[], value: T): void { + if (!values.includes(value)) values.push(value); +} diff --git a/packages/world/src/topology/topology.test.ts b/packages/world/src/topology/topology.test.ts new file mode 100644 index 000000000..3076f2f06 --- /dev/null +++ b/packages/world/src/topology/topology.test.ts @@ -0,0 +1,1068 @@ +import { describe, expect, it } from "vitest"; +import { + PolyWorldDocumentError, + PolyWorldTopologyError, + createPolyWorldDocument, + createPolyWorldTopologyCapabilityContract, + createPolyWorldTopology, + expandPolyWorldSelectionElementRelations, + resolvePolyWorldElementSubtree, + resolvePolyWorldElementRelations, + resolvePolyWorldElements, + resolvePolyWorldRegionByPoint, + selectPolyWorldElementsByPurpose, +} from "./index"; +import type { PolyWorldDocumentInput, PolyWorldTopologyInput } from "./index"; +import { + createPolyWorldFakeRoomGraphFixture, + createPolyWorldPartitionGalleryFixture, +} from "../testing/fixtures"; + +function baseTopology(): PolyWorldTopologyInput { + return { + regions: [ + { + id: "atrium", + kind: "room", + bounds: { min: [0, 0, 0], max: [10, 10, 4] }, + selectionKeys: ["faces:atrium"], + aliases: ["room:0"], + }, + { + id: "hall", + kind: "room", + bounds: { min: [10, 0, 0], max: [20, 10, 4] }, + selectionKeys: ["faces:hall"], + }, + { + id: "service", + kind: "room", + center: [30, 0, 0], + }, + ], + links: [ + { + id: "atrium-hall", + fromRegionId: "atrium", + toRegionId: "hall", + kind: "portal", + selectionKeys: ["portal:atrium-hall"], + }, + ], + elements: [ + { + id: "atrium-shell", + kind: "mesh", + path: "/World/Atrium/Shell", + regionIds: ["atrium"], + bounds: { min: [0, 0, 0], max: [10, 10, 4] }, + purposes: ["render", "occluder"], + resourceIds: ["mesh:atrium-shell"], + layers: ["world"], + tags: ["solid"], + sourceIds: ["src:room-a"], + aliases: ["mesh:atrium"], + }, + { + id: "door-frame", + kind: "mesh", + path: "/World/Atrium/DoorFrame", + parentId: "atrium-shell", + containerId: "atrium-shell", + regionIds: ["atrium", "hall"], + regionMatch: "all", + transform: { position: [10, 5, 0], rotation: [0, 0, 0], scale: [1, 1, 1] }, + purposes: ["render", "portal"], + layers: ["world"], + tags: ["connector"], + }, + { + id: "hall-lights", + kind: "lights", + path: "/World/Hall/Lights", + regionIds: ["hall"], + purposes: ["debug"], + layers: ["effects"], + tags: ["dynamic"], + }, + { + id: "sky-banner", + kind: "mesh", + selectionKeys: ["faces:sky"], + layers: ["sky"], + }, + ], + spatialElements: [ + { + id: "atrium-floor-surface", + elementId: "atrium-shell", + regionId: "atrium", + leafId: "atrium-leaf", + role: "shell", + visibility: "structural", + resourceIds: ["texture:stone"], + vertices: [ + [0, 0, 0], + [10, 0, 0], + [10, 10, 0], + [0, 10, 0], + ], + }, + { + id: "atrium-door-opening", + elementId: "door-frame", + regionId: "atrium", + role: "opening", + bounds: { min: [9.9, 3, 0], max: [10, 7, 3] }, + }, + ], + }; +} + +describe("createPolyWorldTopology", () => { + it("exposes the V10 package capability contract without taking app-owned runtime work", () => { + const contract = createPolyWorldTopologyCapabilityContract(); + + expect(contract).toMatchObject({ + schemaVersion: 1, + packageName: "@layoutit/polycss-world", + }); + expect(contract.references.map((reference) => reference.id)).toEqual([ + "polycss", + "x3d", + "openusd", + "quake-bsp-pvs", + "quake-qbsp", + "3d-tiles", + "gltf-lod", + ]); + const referencesById = new Map(contract.references.map((reference) => [reference.id, reference])); + expect(referencesById.get("polycss")).toMatchObject({ + claimLevel: "renderer-target", + sourceUrls: ["https://github.com/LayoutitStudio/polycss"], + }); + expect(referencesById.get("x3d")).toMatchObject({ + claimLevel: "structure-reference", + }); + expect(referencesById.get("x3d")?.sourceUrls).toContain( + "https://www.web3d.org/specifications/X3Dv4/ISO-IEC19775-1v4-IS/Part01/concepts.html", + ); + expect(referencesById.get("x3d")?.outOfScope).toContain("visibility proof from grouping bounds alone"); + expect(referencesById.get("openusd")).toMatchObject({ + claimLevel: "structure-reference", + }); + expect(referencesById.get("openusd")?.outOfScope).toContain("USD payload loading"); + const quakeReference = contract.references.find((reference) => reference.id === "quake-bsp-pvs"); + expect(quakeReference?.claimLevel).toBe("topology-proof"); + expect(quakeReference?.sourceUrls).toContain("https://github.com/id-Software/Quake"); + expect(quakeReference?.packageUses).toContain("camera leaf lookup"); + expect(quakeReference?.outOfScope).toContain("Quake BSP file parsing"); + expect(quakeReference?.compatibilityClaim).toContain("not Quake BSP format compatibility"); + expect(referencesById.get("quake-qbsp")).toMatchObject({ + claimLevel: "compiler-boundary", + outOfScope: expect.arrayContaining(["full vis solver parity"]), + }); + const usdReference = contract.references.find((reference) => reference.id === "openusd"); + expect(usdReference?.packageUses).toContain("stable element paths"); + expect(usdReference?.outOfScope).toContain("USD composition arcs"); + const tilesReference = contract.references.find((reference) => reference.id === "3d-tiles"); + expect(tilesReference?.claimLevel).toBe("working-set-reference"); + expect(tilesReference?.packageUses).toContain("refinement and geometric-error planning"); + expect(tilesReference?.outOfScope).toContain("network scheduling"); + expect(tilesReference?.sourceUrls).toContain( + "https://github.com/CesiumGS/3d-tiles/blob/main/specification/README.adoc", + ); + expect(referencesById.get("gltf-lod")).toMatchObject({ + claimLevel: "asset-boundary", + outOfScope: expect.arrayContaining(["glTF loading", "mesh selection or replacement"]), + }); + expect(contract.capabilities.map((capability) => capability.id)).toEqual([ + "world-ir", + "compiled-bsp-pvs", + "area-portals", + "chunk-hierarchy", + "resource-readiness", + "dom-planning", + "debug-proof", + ]); + + const bsp = contract.capabilities.find((capability) => capability.id === "compiled-bsp-pvs"); + expect(bsp?.references).toEqual(["quake-bsp-pvs", "quake-qbsp"]); + expect(bsp?.packageOwns).toContain("view-clipped PVS traversal"); + expect(bsp?.publicExports).toContain("planPolyWorldBspVisibilityFrame"); + expect(bsp?.appOwns).toContain("first-person controls"); + expect(bsp?.appOwns).toContain("Quake-compatible qbsp/vis parity"); + + const chunks = contract.capabilities.find((capability) => capability.id === "chunk-hierarchy"); + expect(chunks?.references).toEqual(["3d-tiles", "gltf-lod"]); + expect(chunks?.packageOwns).toContain("budgeted traversal"); + expect(chunks?.appOwns).toContain("fetch scheduling"); + expect(chunks?.appOwns).toContain("renderer LOD swaps"); + + const areaPortals = contract.capabilities.find((capability) => capability.id === "area-portals"); + expect(areaPortals?.publicExports).toContain("planPolyWorldPortalFlowFrame"); + expect(areaPortals?.packageOwns).toContain("authored portal flow"); + + expect(contract.nonGoals).toContain("format loaders"); + expect(contract.nonGoals).toContain("renderer imports"); + expect(contract.nonGoals).toContain("source-engine parity claims without source-compatible compilers"); + }); + + it("creates a canonical authored-world document with topology, profiles, resources, and policies", () => { + const document = createPolyWorldDocument({ + id: "atrium-world", + label: "Atrium World", + topology: baseTopology(), + capabilityIds: ["world-ir", "compiled-bsp-pvs", "chunk-hierarchy", "resource-readiness"], + profileArtifacts: [ + { + id: "atrium-bsp", + profile: "bsp-pvs", + artifactKind: "compiled-bsp-pvs", + sourceKind: "compiled", + producedBy: "brush-bsp", + elementIds: ["atrium-shell"], + spatialElementIds: ["atrium-floor-surface"], + resourceIds: ["mesh:atrium-shell", "texture:stone"], + }, + { + id: "atrium-chunks", + profile: "chunk-traversal", + artifactKind: "chunk-working-set", + sourceKind: "authored-runtime-selection", + producedBy: "authored-track", + }, + ], + resources: [ + { + id: "mesh:atrium-shell", + state: "ready", + renderBlocking: true, + elementIds: ["atrium-shell"], + }, + { + id: "texture:stone", + state: "stale", + renderBlocking: false, + spatialElementIds: ["atrium-floor-surface"], + }, + ], + planPolicies: [ + { + id: "render-world", + layer: "world", + phase: "render", + elementLayers: ["world"], + targetStates: { + added: { rendered: true }, + retained: { rendered: true }, + }, + }, + ], + data: { source: "test" }, + }); + + expect(document).toMatchObject({ + schemaVersion: 1, + id: "atrium-world", + label: "Atrium World", + summary: { + regionCount: 3, + linkCount: 1, + elementCount: 4, + spatialElementCount: 2, + profileArtifactCount: 2, + resourceCount: 2, + planPolicyCount: 1, + capabilityIds: ["world-ir", "compiled-bsp-pvs", "chunk-hierarchy", "resource-readiness"], + }, + data: { source: "test" }, + }); + expect(document.topology.regionsById.get("atrium")?.center).toEqual([5, 5, 2]); + expect(document.capabilities.map((capability) => capability.id)).toEqual([ + "world-ir", + "compiled-bsp-pvs", + "chunk-hierarchy", + "resource-readiness", + ]); + expect(document.profileArtifactsById.get("atrium-bsp")?.producedBy).toBe("brush-bsp"); + expect(document.profileArtifactIdsByProfile.get("bsp-pvs")).toEqual(["atrium-bsp"]); + expect(document.profileArtifactIdsByProfile.get("chunk-traversal")).toEqual(["atrium-chunks"]); + expect(document.resourcesById.get("texture:stone")).toMatchObject({ + state: "stale", + spatialElementIds: ["atrium-floor-surface"], + }); + expect(document.planPoliciesById.get("render-world")?.elementLayers).toEqual(["world"]); + }); + + it("creates the partition-gallery fixture as a document-owned authored world boundary", () => { + const fixture = createPolyWorldPartitionGalleryFixture(); + const document = createPolyWorldDocument(fixture.documentInput); + + expect(document).toMatchObject({ + schemaVersion: 1, + id: "partition-gallery", + summary: { + regionCount: 6, + linkCount: 5, + elementCount: 24, + spatialElementCount: 24, + profileArtifactCount: 1, + resourceCount: 24, + planPolicyCount: 1, + capabilityIds: ["world-ir", "compiled-bsp-pvs", "resource-readiness", "dom-planning"], + }, + }); + expect(document.profileArtifactsById.get("partition-gallery-bsp")).toMatchObject({ + profile: "bsp-pvs", + artifactKind: "compiled-bsp-pvs", + sourceKind: "compiled", + producedBy: "bounds-bsp", + }); + expect(document.profileArtifactIdsByProfile.get("bsp-pvs")).toEqual(["partition-gallery-bsp"]); + expect(document.topology.elementsByPath.get("/World/PartitionGallery/gallery/gallery-floor")?.id) + .toBe("gallery-floor-element"); + expect(document.topology.spatialElementsByRole.get("shell")?.map((element) => element.id)) + .toContain("gallery-floor"); + expect(document.topology.spatialElementsByVisibility.get("structural")?.map((element) => element.id)) + .toEqual(expect.arrayContaining([ + "studio-floor", + "studio-ceiling", + "gallery-opening-frame", + ])); + expect(document.resourcesById.get("mesh:gallery-prop-element")).toMatchObject({ + state: "stale", + renderBlocking: false, + spatialElementIds: ["gallery-prop"], + }); + expect(document.planPoliciesById.get("render-world")?.elementLayers).toEqual(["world"]); + }); + + it("keeps a fake room graph in the portal-flow profile instead of letting it masquerade as BSP/PVS", () => { + const fixture = createPolyWorldFakeRoomGraphFixture(); + const document = createPolyWorldDocument(fixture.documentInput); + + expect(document.capabilityIds).toEqual(["world-ir", "area-portals"]); + expect(document.profileArtifactsById.get("fake-portal-flow")).toMatchObject({ + profile: "portal-flow", + artifactKind: "authored-area-portal-flow", + sourceKind: "authored-runtime-selection", + producedBy: "authored-links", + }); + expect(document.profileArtifactIdsByProfile.get("bsp-pvs")).toBeUndefined(); + expect(() => createPolyWorldDocument({ + ...fixture.documentInput, + profileArtifacts: [ + ...(fixture.documentInput.profileArtifacts ?? []), + { + id: "fake-bsp", + profile: "bsp-pvs", + artifactKind: "compiled-bsp-pvs", + sourceKind: "authored", + producedBy: "authored-links", + }, + ], + })).toThrow(PolyWorldDocumentError); + }); + + it("rejects profile artifact refs when their topology capability is disabled", () => { + const input: PolyWorldDocumentInput = { + topology: baseTopology(), + capabilityIds: ["world-ir", "resource-readiness"], + profileArtifacts: [ + { + id: "atrium-bsp", + profile: "bsp-pvs", + artifactKind: "compiled-bsp-pvs", + }, + { + id: "atrium-flow", + profile: "portal-flow", + artifactKind: "authored-area-portal-flow", + }, + { + id: "atrium-chunks", + profile: "chunk-traversal", + artifactKind: "chunk-working-set", + }, + ], + }; + + expect(() => createPolyWorldDocument(input)).toThrow(PolyWorldDocumentError); + try { + createPolyWorldDocument(input); + } catch (error) { + expect((error as PolyWorldDocumentError).diagnostics).toEqual(expect.arrayContaining([ + expect.objectContaining({ + code: "poly-world-document-profile-artifact-capability-disabled", + id: "atrium-bsp", + field: "profileArtifacts.profile", + kind: "profileArtifact", + }), + expect.objectContaining({ + code: "poly-world-document-profile-artifact-capability-disabled", + id: "atrium-flow", + field: "profileArtifacts.profile", + kind: "profileArtifact", + }), + expect.objectContaining({ + code: "poly-world-document-profile-artifact-capability-disabled", + id: "atrium-chunks", + field: "profileArtifacts.profile", + kind: "profileArtifact", + }), + ])); + } + }); + + it("rejects malformed authored-world document references before profile frames use them", () => { + const input: PolyWorldDocumentInput = { + id: "", + topology: baseTopology(), + capabilityIds: ["world-ir", "world-ir", "missing-capability" as never], + profileArtifacts: [ + { + id: "bad-profile", + profile: "bsp-pvs", + artifactKind: "chunk-working-set", + sourceKind: "compiled", + elementIds: ["missing-element"], + spatialElementIds: ["missing-spatial"], + }, + { + id: "bad-profile", + profile: "not-a-profile" as never, + sourceKind: "not-a-source" as never, + }, + ], + resources: [ + { + id: "resource-a", + state: "ready", + elementIds: ["atrium-shell"], + }, + { + id: "resource-a", + state: "not-ready" as never, + spatialElementIds: ["missing-spatial"], + }, + ], + planPolicies: [ + { + id: "render-world", + layer: "world", + elementIds: ["missing-element"], + }, + { + id: "render-world", + layer: "", + }, + ], + }; + + expect(() => createPolyWorldDocument(input)).toThrow(PolyWorldDocumentError); + try { + createPolyWorldDocument(input); + } catch (error) { + const diagnostics = (error as PolyWorldDocumentError).diagnostics; + expect(diagnostics).toEqual(expect.arrayContaining([ + expect.objectContaining({ code: "poly-world-document-empty-id", kind: "document" }), + expect.objectContaining({ code: "poly-world-document-duplicate-capability-id", id: "world-ir" }), + expect.objectContaining({ code: "poly-world-document-invalid-capability-id", id: "missing-capability" }), + expect.objectContaining({ code: "poly-world-document-duplicate-profile-artifact-id", id: "bad-profile" }), + expect.objectContaining({ code: "poly-world-document-profile-artifact-kind-mismatch", id: "bad-profile" }), + expect.objectContaining({ code: "poly-world-document-invalid-profile-artifact-profile", id: "bad-profile" }), + expect.objectContaining({ code: "poly-world-document-invalid-profile-artifact-source-kind", id: "bad-profile" }), + expect.objectContaining({ code: "poly-world-document-missing-element", id: "bad-profile", field: "profileArtifacts.elementIds" }), + expect.objectContaining({ code: "poly-world-document-missing-spatial-element", id: "bad-profile", field: "profileArtifacts.spatialElementIds" }), + expect.objectContaining({ code: "poly-world-document-duplicate-resource-id", id: "resource-a" }), + expect.objectContaining({ code: "poly-world-document-invalid-resource-state", id: "resource-a" }), + expect.objectContaining({ code: "poly-world-document-missing-spatial-element", id: "resource-a", field: "resources.spatialElementIds" }), + expect.objectContaining({ code: "poly-world-document-duplicate-plan-policy-id", id: "render-world" }), + expect.objectContaining({ code: "poly-world-document-empty-plan-policy-layer", id: "render-world" }), + expect.objectContaining({ code: "poly-world-document-missing-element", id: "render-world", field: "planPolicies.elementIds" }), + ])); + } + }); + + it("surfaces topology validation through the document gate", () => { + const input: PolyWorldDocumentInput = { + topology: { + regions: [], + }, + }; + + expect(() => createPolyWorldDocument(input)).toThrow(PolyWorldDocumentError); + try { + createPolyWorldDocument(input); + } catch (error) { + expect((error as PolyWorldDocumentError).diagnostics).toEqual(expect.arrayContaining([ + expect.objectContaining({ + code: "poly-world-empty-regions", + field: "regions", + kind: "topology", + }), + ])); + } + }); + + it("normalizes indexes and derives region centers from bounds", () => { + const topology = createPolyWorldTopology(baseTopology()); + + expect(topology.regionsById.get("atrium")?.center).toEqual([5, 5, 2]); + expect(topology.linksByRegionId.get("atrium")?.map((link) => link.id)).toEqual(["atrium-hall"]); + expect(topology.elementsByRegionId.get("atrium")?.map((element) => element.id)).toEqual([ + "atrium-shell", + "door-frame", + ]); + expect(topology.elementsByPath.get("/World/Atrium/Shell")?.id).toBe("atrium-shell"); + expect(topology.elementsBySelectionKey.get("faces:sky")?.map((element) => element.id)).toEqual(["sky-banner"]); + expect(topology.selectionKeyOwnersByKey.get("faces:atrium")).toEqual([ + { kind: "region", id: "atrium" }, + ]); + expect(topology.selectionKeyOwnersByKey.get("faces:sky")).toEqual([ + { kind: "element", id: "sky-banner" }, + ]); + expect(topology.elementsBySourceId.get("src:room-a")?.map((element) => element.id)).toEqual(["atrium-shell"]); + expect(topology.elementsByAlias.get("mesh:atrium")?.map((element) => element.id)).toEqual(["atrium-shell"]); + expect(topology.elementsByPurpose.get("render")?.map((element) => element.id)).toEqual([ + "atrium-shell", + "door-frame", + ]); + expect(topology.elementsByPurpose.get("portal")?.map((element) => element.id)).toEqual(["door-frame"]); + expect(topology.elementsByResourceId.get("mesh:atrium-shell")?.map((element) => element.id)).toEqual([ + "atrium-shell", + ]); + expect(topology.elementsByParentId.get("atrium-shell")?.map((element) => element.id)).toEqual(["door-frame"]); + expect(topology.elementsByContainerId.get("atrium-shell")?.map((element) => element.id)).toEqual(["door-frame"]); + expect(topology.spatialElementsByElementId.get("atrium-shell")?.map((spatialElement) => spatialElement.id)).toEqual([ + "atrium-floor-surface", + ]); + expect(topology.spatialElementsByRegionId.get("atrium")?.map((spatialElement) => spatialElement.id)).toEqual([ + "atrium-floor-surface", + "atrium-door-opening", + ]); + expect(topology.spatialElementsByLeafId.get("atrium-leaf")?.map((spatialElement) => spatialElement.id)).toEqual([ + "atrium-floor-surface", + ]); + expect(topology.spatialElementsByRole.get("opening")?.map((spatialElement) => spatialElement.id)).toEqual([ + "atrium-door-opening", + ]); + expect(topology.spatialElementsByVisibility.get("structural")?.map((spatialElement) => spatialElement.id)).toEqual([ + "atrium-floor-surface", + ]); + expect(topology.spatialElementsByResourceId.get("texture:stone")?.map((spatialElement) => spatialElement.id)).toEqual([ + "atrium-floor-surface", + ]); + }); + + it("rejects duplicate ids, missing endpoints, ambiguous multi-region elements, and empty arrays", () => { + const input: PolyWorldTopologyInput = { + regions: [ + { id: "a" }, + { id: "a" }, + ], + links: [ + { id: "bad-link", fromRegionId: "a", toRegionId: "missing" }, + ], + elements: [ + { id: "ambiguous", regionIds: ["a", "missing"] }, + { id: "empty-key", selectionKeys: [] }, + { id: "missing-parent", regionIds: ["a"], parentId: "nope" }, + { id: "self-container", regionIds: ["a"], containerId: "self-container" }, + ], + }; + + expect(() => createPolyWorldTopology(input)).toThrow(PolyWorldTopologyError); + try { + createPolyWorldTopology(input); + } catch (error) { + const codes = (error as PolyWorldTopologyError).diagnostics.map((diagnostic) => diagnostic.code); + expect(codes).toContain("poly-world-duplicate-region-id"); + expect(codes).toContain("poly-world-missing-link-region"); + expect(codes).toContain("poly-world-missing-element-region"); + expect(codes).toContain("poly-world-ambiguous-region-match"); + expect(codes).toContain("poly-world-empty-array"); + expect(codes).toContain("poly-world-missing-element-relation"); + expect(codes).toContain("poly-world-self-element-relation"); + } + }); + + it("indexes element parent and container relations even when the parent appears later", () => { + const topology = createPolyWorldTopology({ + regions: [{ id: "chunk" }], + elements: [ + { id: "chunk-leaf", regionIds: ["chunk"], parentId: "chunk-root", containerId: "chunk-root" }, + { id: "chunk-root", regionIds: ["chunk"], layers: ["resident"] }, + ], + }); + + expect(topology.elementsByParentId.get("chunk-root")?.map((element) => element.id)).toEqual(["chunk-leaf"]); + expect(topology.elementsByContainerId.get("chunk-root")?.map((element) => element.id)).toEqual(["chunk-leaf"]); + }); + + it("resolves element graph subtrees and purpose-filtered selections without touching DOM", () => { + const topology = createPolyWorldTopology({ + regions: [{ id: "room" }], + elements: [ + { id: "scene-root", selectionKeys: ["root:scene"], purposes: ["render"], layers: ["resident"] }, + { id: "room-root", parentId: "scene-root", containerId: "scene-root", selectionKeys: ["root:room"], purposes: ["render"], layers: ["resident"] }, + { id: "room-wall", parentId: "room-root", containerId: "room-root", regionIds: ["room"], purposes: ["render", "occluder"], layers: ["render"] }, + { id: "room-collider", parentId: "room-root", containerId: "room-root", regionIds: ["room"], purposes: ["collision"], layers: ["collision"] }, + { id: "room-debug", parentId: "room-root", selectionKeys: ["debug:room"], purposes: ["debug"], layers: ["debug"] }, + ], + }); + + expect(resolvePolyWorldElementSubtree(topology, ["room-root"], { purposes: ["render"] })).toEqual({ + seedElementIds: ["room-root"], + relation: "parent", + elementIds: ["room-root", "room-wall"], + descendantElementIds: ["room-wall"], + missingElementIds: [], + }); + expect(resolvePolyWorldElementSubtree(topology, ["missing", "scene-root"], { + relation: "container", + includeSeeds: false, + recursive: false, + })).toEqual({ + seedElementIds: ["missing", "scene-root"], + relation: "container", + elementIds: ["room-root"], + descendantElementIds: ["room-root"], + missingElementIds: ["missing"], + }); + + const selection = selectPolyWorldElementsByPurpose(topology, ["render"], { + includeDescendants: true, + reasonLabel: "render-purpose", + }); + + expect(selection.elementIds).toEqual(["scene-root", "room-root", "room-wall"]); + expect(selection.reasons).toEqual([ + expect.objectContaining({ + label: "render-purpose", + kind: "element-purpose", + elementIds: ["scene-root", "room-root", "room-wall"], + data: { + purposes: ["render"], + match: "any", + relation: "parent", + descendantElementIds: ["room-root", "room-wall"], + }, + }), + ]); + }); + + it("keeps authored-world strict validation opt-in", () => { + const topology = createPolyWorldTopology({ + regions: [ + { id: "entry", bounds: { min: [0, 0, 0], max: [4, 4, 3] } }, + { id: "loose" }, + ], + elements: [ + { id: "entry-shell", regionIds: ["entry"] }, + ], + }); + + expect(topology.regions.map((region) => region.id)).toEqual(["entry", "loose"]); + expect(topology.elements.map((element) => element.id)).toEqual(["entry-shell"]); + }); + + it("rejects malformed authored worlds when strict topology validation is enabled", () => { + const input: PolyWorldTopologyInput = { + validation: { strict: true }, + regions: [ + { id: "entry", bounds: { min: [0, 0, 0], max: [4, 4, 3] } }, + { id: "gallery" }, + { id: "vault", center: [12, 0, 0] }, + ], + links: [ + { id: "entry-gallery", fromRegionId: "entry", toRegionId: "gallery" }, + ], + elements: [ + { id: "entry-shell", regionIds: ["entry"] }, + { id: "gallery-shell", regionIds: ["gallery"], layers: ["world"] }, + ], + }; + + expect(() => createPolyWorldTopology(input)).toThrow(PolyWorldTopologyError); + try { + createPolyWorldTopology(input); + } catch (error) { + const diagnostics = (error as PolyWorldTopologyError).diagnostics; + expect(diagnostics).toEqual(expect.arrayContaining([ + expect.objectContaining({ + code: "poly-world-missing-region-spatial-reference", + id: "gallery", + field: "bounds", + kind: "region", + }), + expect.objectContaining({ + code: "poly-world-unreachable-region", + id: "vault", + field: "links", + kind: "region", + }), + expect.objectContaining({ + code: "poly-world-missing-element-layers", + id: "entry-shell", + field: "layers", + kind: "element", + }), + ])); + } + }); + + it("lets strict authored validation disable specific checks", () => { + const topology = createPolyWorldTopology({ + validation: { + strict: true, + requireConnectedRegions: false, + requireElementLayers: false, + }, + regions: [ + { id: "entry", bounds: { min: [0, 0, 0], max: [4, 4, 3] } }, + { id: "remote", center: [100, 0, 0] }, + ], + elements: [ + { id: "entry-shell", regionIds: ["entry"] }, + ], + }); + + expect(topology.regions.map((region) => region.id)).toEqual(["entry", "remote"]); + expect(topology.elementsByRegionId.get("entry")?.map((element) => element.id)).toEqual(["entry-shell"]); + }); + + it("can require explicit region bounds for compiler-owned authored topology", () => { + const input: PolyWorldTopologyInput = { + validation: { requireRegionBounds: true }, + regions: [ + { id: "entry", bounds: { min: [0, 0, 0], max: [4, 4, 3] } }, + { id: "marker-only", center: [8, 0, 0] }, + ], + }; + + expect(() => createPolyWorldTopology(input)).toThrow(PolyWorldTopologyError); + try { + createPolyWorldTopology(input); + } catch (error) { + expect((error as PolyWorldTopologyError).diagnostics).toEqual(expect.arrayContaining([ + expect.objectContaining({ + code: "poly-world-missing-region-bounds", + id: "marker-only", + field: "bounds", + kind: "region", + }), + ])); + } + }); + + it("rejects parent and container cycles before they reach planning or DOM apply", () => { + const input: PolyWorldTopologyInput = { + regions: [{ id: "room" }], + elements: [ + { id: "parent-a", regionIds: ["room"], parentId: "parent-b" }, + { id: "parent-b", regionIds: ["room"], parentId: "parent-a" }, + { id: "container-a", regionIds: ["room"], containerId: "container-b" }, + { id: "container-b", regionIds: ["room"], containerId: "container-a" }, + ], + }; + + expect(() => createPolyWorldTopology(input)).toThrow(PolyWorldTopologyError); + try { + createPolyWorldTopology(input); + } catch (error) { + const diagnostics = (error as PolyWorldTopologyError).diagnostics; + expect(diagnostics.filter((diagnostic) => diagnostic.code === "poly-world-element-relation-cycle")).toEqual( + expect.arrayContaining([ + expect.objectContaining({ id: "parent-a", field: "parentId" }), + expect.objectContaining({ id: "container-a", field: "containerId" }), + ]), + ); + } + }); + + it("rejects malformed element graph metadata before planning uses it", () => { + const input: PolyWorldTopologyInput = { + regions: [{ id: "room" }], + elements: [ + { + id: "bad-path-a", + path: "World/Bad", + regionIds: ["room"], + bounds: { min: [2, 0, 0], max: [1, 1, 1] }, + transform: { matrix: [1, 0, 0] }, + purposes: ["render", "proxy"], + }, + { + id: "bad-path-b", + path: "World/Bad", + selectionKeys: ["bad:path"], + transform: { position: [0, Number.NaN, 0] }, + purposes: ["sound" as never], + }, + ], + }; + + expect(() => createPolyWorldTopology(input)).toThrow(PolyWorldTopologyError); + try { + createPolyWorldTopology(input); + } catch (error) { + const diagnostics = (error as PolyWorldTopologyError).diagnostics; + expect(diagnostics).toEqual(expect.arrayContaining([ + expect.objectContaining({ code: "poly-world-invalid-element-path", id: "bad-path-a", field: "path" }), + expect.objectContaining({ code: "poly-world-duplicate-element-path", id: "bad-path-b", field: "path" }), + expect.objectContaining({ code: "poly-world-invalid-bounds", id: "bad-path-a", field: "bounds" }), + expect.objectContaining({ code: "poly-world-invalid-element-transform-matrix", id: "bad-path-a", field: "transform.matrix" }), + expect.objectContaining({ code: "poly-world-conflicting-element-purposes", id: "bad-path-a", field: "purposes" }), + expect.objectContaining({ code: "poly-world-invalid-vec3", id: "bad-path-b", field: "transform.position" }), + expect.objectContaining({ code: "poly-world-invalid-element-purpose", id: "bad-path-b", field: "purposes" }), + ])); + } + }); + + it("rejects malformed spatial element catalog entries", () => { + const input: PolyWorldTopologyInput = { + regions: [{ id: "room" }], + elements: [{ id: "room-shell", regionIds: ["room"] }], + spatialElements: [ + { + id: "room-surface", + elementId: "missing-element", + regionId: "missing-region", + role: "portal" as never, + vertices: [ + [0, 0, 0], + [1, 0, 0], + [1, 1, 1], + [0, 1, 0], + ], + }, + { + id: "empty-ref", + bounds: { min: [1, 1, 1], max: [0, 1, 1] }, + }, + ], + }; + + expect(() => createPolyWorldTopology(input)).toThrow(PolyWorldTopologyError); + try { + createPolyWorldTopology(input); + } catch (error) { + const diagnostics = (error as PolyWorldTopologyError).diagnostics; + expect(diagnostics).toEqual(expect.arrayContaining([ + expect.objectContaining({ + code: "poly-world-missing-spatial-element-element", + id: "room-surface", + field: "elementId", + kind: "spatialElement", + }), + expect.objectContaining({ + code: "poly-world-missing-spatial-element-region", + id: "room-surface", + field: "regionId", + kind: "spatialElement", + }), + expect.objectContaining({ + code: "poly-world-invalid-spatial-element-role", + id: "room-surface", + field: "role", + kind: "spatialElement", + }), + expect.objectContaining({ + code: "poly-world-non-coplanar-spatial-element-polygon", + id: "room-surface", + field: "vertices", + kind: "spatialElement", + }), + expect.objectContaining({ + code: "poly-world-missing-spatial-element-reference", + id: "empty-ref", + kind: "spatialElement", + }), + expect.objectContaining({ + code: "poly-world-invalid-bounds", + id: "empty-ref", + field: "bounds", + kind: "spatialElement", + }), + ])); + } + }); +}); + +describe("resolvePolyWorldElements", () => { + it("resolves single-region elements by region and requires all selected regions for all-match elements", () => { + const topology = createPolyWorldTopology(baseTopology()); + + expect(resolvePolyWorldElements(topology, { regionIds: ["atrium"] }).elementIds).toEqual(["atrium-shell"]); + expect(resolvePolyWorldElements(topology, { regionIds: ["atrium", "hall"] }).elementIds).toEqual([ + "atrium-shell", + "door-frame", + "hall-lights", + ]); + }); + + it("resolves by selection key, element id, source id, and alias independent of region selection", () => { + const topology = createPolyWorldTopology(baseTopology()); + const resolution = resolvePolyWorldElements(topology, { + selectionKeys: ["faces:sky"], + elementIds: ["hall-lights"], + sourceIds: ["src:room-a"], + aliases: ["mesh:atrium"], + }); + + expect(resolution.elementIds).toEqual(["atrium-shell", "hall-lights", "sky-banner"]); + expect(resolution.resolved.find((entry) => entry.elementId === "atrium-shell")?.matches).toEqual([ + { kind: "sourceId", value: "src:room-a" }, + { kind: "alias", value: "mesh:atrium" }, + ]); + }); + + it("filters resolved elements by layer and tag", () => { + const topology = createPolyWorldTopology(baseTopology()); + + expect( + resolvePolyWorldElements(topology, { regionIds: ["atrium", "hall"] }, { layers: ["effects"] }).elementIds, + ).toEqual(["hall-lights"]); + expect( + resolvePolyWorldElements(topology, { regionIds: ["atrium", "hall"] }, { tags: ["connector"] }).elementIds, + ).toEqual(["door-frame"]); + }); + + it("reports unknown selectors without treating known topology keys as element resolution errors", () => { + const topology = createPolyWorldTopology(baseTopology()); + const resolution = resolvePolyWorldElements(topology, { + regionIds: ["missing-region"], + linkIds: ["missing-link"], + selectionKeys: ["faces:atrium", "missing-key"], + elementIds: ["missing-element"], + sourceIds: ["missing-source"], + aliases: ["missing-alias"], + }); + + expect(resolution.unresolved).toEqual({ + regionIds: ["missing-region"], + linkIds: ["missing-link"], + selectionKeys: ["missing-key"], + elementIds: ["missing-element"], + sourceIds: ["missing-source"], + aliases: ["missing-alias"], + }); + }); +}); + +describe("resolvePolyWorldElementRelations", () => { + it("expands selected detail elements to stable parent and container roots", () => { + const topology = createPolyWorldTopology({ + regions: [{ id: "room" }], + elements: [ + { id: "scene-root", selectionKeys: ["root:scene"], layers: ["resident"] }, + { + id: "room-root", + selectionKeys: ["root:room"], + containerId: "scene-root", + layers: ["resident"], + }, + { + id: "room-wall", + regionIds: ["room"], + parentId: "room-root", + containerId: "room-root", + layers: ["render"], + }, + ], + }); + + const expansion = resolvePolyWorldElementRelations(topology, ["room-wall"]); + + expect(expansion).toEqual({ + seedElementIds: ["room-wall"], + elementIds: ["scene-root", "room-root", "room-wall"], + relatedElementIds: ["scene-root", "room-root"], + parentElementIds: ["room-root"], + containerElementIds: ["scene-root", "room-root"], + missingElementIds: [], + missingRelations: [], + relations: [ + { kind: "parent", elementId: "room-wall", relatedElementId: "room-root", depth: 1 }, + { kind: "container", elementId: "room-wall", relatedElementId: "room-root", depth: 1 }, + { kind: "container", elementId: "room-root", relatedElementId: "scene-root", depth: 2 }, + ], + }); + }); + + it("can expand a region selection with relation element ids while preserving the original selection", () => { + const topology = createPolyWorldTopology({ + regions: [{ id: "room" }], + elements: [ + { id: "scene-root", selectionKeys: ["root:scene"], layers: ["resident"] }, + { + id: "room-root", + selectionKeys: ["root:room"], + containerId: "scene-root", + layers: ["resident"], + }, + { + id: "room-wall", + regionIds: ["room"], + parentId: "room-root", + containerId: "room-root", + layers: ["render"], + }, + ], + }); + + const selection = expandPolyWorldSelectionElementRelations(topology, { + regionIds: ["room"], + reasons: [{ label: "view-pvs" }], + }); + const resolution = resolvePolyWorldElements(topology, selection); + + expect(selection.regionIds).toEqual(["room"]); + expect(selection.elementIds).toEqual(["scene-root", "room-root"]); + expect(selection.reasons?.map((reason) => reason.label)).toEqual(["view-pvs", "element-relations"]); + expect(selection.reasons?.[1]?.data).toEqual({ + parentElementIds: ["room-root"], + containerElementIds: ["scene-root", "room-root"], + }); + expect(resolution.elementIds).toEqual(["scene-root", "room-root", "room-wall"]); + }); + + it("reports missing seed ids while preserving deterministic known relation order", () => { + const topology = createPolyWorldTopology({ + regions: [{ id: "room" }], + elements: [ + { id: "root", selectionKeys: ["root"], layers: ["resident"] }, + { id: "leaf", regionIds: ["room"], parentId: "root", layers: ["render"] }, + ], + }); + + const expansion = resolvePolyWorldElementRelations(topology, ["missing", "leaf"]); + + expect(expansion.elementIds).toEqual(["root", "leaf", "missing"]); + expect(expansion.relatedElementIds).toEqual(["root"]); + expect(expansion.missingElementIds).toEqual(["missing"]); + }); +}); + +describe("resolvePolyWorldRegionByPoint", () => { + it("prefers the smallest containing bounds", () => { + const topology = createPolyWorldTopology({ + regions: [ + { id: "district", bounds: { min: [0, 0, 0], max: [100, 100, 10] } }, + { id: "room", bounds: { min: [10, 10, 0], max: [20, 20, 4] } }, + ], + elements: [{ id: "district-shell", regionIds: ["district"] }], + }); + + expect(resolvePolyWorldRegionByPoint(topology, [15, 15, 2])?.regionId).toBe("room"); + }); + + it("falls back to nearest center only when requested", () => { + const topology = createPolyWorldTopology(baseTopology()); + + expect(resolvePolyWorldRegionByPoint(topology, [28, 1, 0])).toBeUndefined(); + expect(resolvePolyWorldRegionByPoint(topology, [28, 1, 0], { nearest: true })?.regionId).toBe("service"); + }); +}); diff --git a/packages/world/src/topology/types.ts b/packages/world/src/topology/types.ts new file mode 100644 index 000000000..2bb59e42e --- /dev/null +++ b/packages/world/src/topology/types.ts @@ -0,0 +1,257 @@ +import type { Vec3 } from "@layoutit/polycss-core"; + +export type PolyWorldData = Record; +export type PolyWorldRegionMatch = "any" | "all"; +export type PolyWorldLinkDirection = "bidirectional" | "forward"; +export type PolyWorldElementMatchKind = "region" | "selectionKey" | "elementId" | "sourceId" | "alias"; +export type PolyWorldElementRelationKind = "parent" | "container"; +export type PolyWorldElementPurpose = "render" | "collision" | "occluder" | "portal" | "chunk" | "debug" | "proxy"; +export type PolyWorldSelectionKeyOwnerKind = "region" | "link" | "element"; +export type PolyWorldSpatialElementRole = "root" | "shell" | "opening" | "detail" | "prop"; +export type PolyWorldSpatialElementVisibility = "structural" | "detail"; + +export interface PolyWorldBounds { + min: Vec3; + max: Vec3; +} + +export interface PolyWorldElementTransform { + position?: Vec3; + rotation?: Vec3; + scale?: Vec3; + matrix?: readonly number[]; +} + +export interface PolyWorldRegion { + id: string; + kind?: string; + bounds?: PolyWorldBounds; + center?: Vec3; + selectionKeys?: readonly string[]; + sourceId?: string; + aliases?: readonly string[]; + tags?: readonly string[]; + data?: PolyWorldData; +} + +export interface PolyWorldLink { + id: string; + fromRegionId: string; + toRegionId: string; + direction?: PolyWorldLinkDirection; + kind?: string; + selectionKeys?: readonly string[]; + sourceId?: string; + aliases?: readonly string[]; + tags?: readonly string[]; + data?: PolyWorldData; +} + +export interface PolyWorldElement { + id: string; + kind?: string; + path?: string; + parentId?: string; + containerId?: string; + bounds?: PolyWorldBounds; + transform?: PolyWorldElementTransform; + purposes?: readonly PolyWorldElementPurpose[]; + resourceIds?: readonly string[]; + regionIds?: readonly string[]; + regionMatch?: PolyWorldRegionMatch; + selectionKeys?: readonly string[]; + sourceIds?: readonly string[]; + aliases?: readonly string[]; + layers?: readonly string[]; + tags?: readonly string[]; + data?: PolyWorldData; +} + +export interface PolyWorldSpatialElement { + id: string; + elementId?: string; + regionId?: string; + leafId?: string; + bounds?: PolyWorldBounds; + vertices?: readonly Vec3[]; + role?: PolyWorldSpatialElementRole; + visibility?: PolyWorldSpatialElementVisibility; + resourceIds?: readonly string[]; + sourceId?: string; + aliases?: readonly string[]; + tags?: readonly string[]; + data?: PolyWorldData; +} + +export interface PolyWorldSelectionReason { + id?: string; + label: string; + kind?: string; + regionIds?: readonly string[]; + linkIds?: readonly string[]; + selectionKeys?: readonly string[]; + elementIds?: readonly string[]; + sourceIds?: readonly string[]; + aliases?: readonly string[]; + tags?: readonly string[]; + data?: PolyWorldData; +} + +export interface PolyWorldSelection { + regionIds?: readonly string[]; + linkIds?: readonly string[]; + selectionKeys?: readonly string[]; + elementIds?: readonly string[]; + sourceIds?: readonly string[]; + aliases?: readonly string[]; + reasons?: readonly PolyWorldSelectionReason[]; + data?: PolyWorldData; +} + +export interface PolyWorldTopologyValidationOptions { + strict?: boolean; + requireRegionSpatialReference?: boolean; + requireRegionBounds?: boolean; + requireConnectedRegions?: boolean; + requireElementLayers?: boolean; +} + +export interface PolyWorldTopologyInput { + regions: readonly PolyWorldRegion[]; + links?: readonly PolyWorldLink[]; + elements?: readonly PolyWorldElement[]; + spatialElements?: readonly PolyWorldSpatialElement[]; + validation?: PolyWorldTopologyValidationOptions; + data?: PolyWorldData; +} + +export interface PolyWorldTopology { + regions: readonly PolyWorldRegion[]; + links: readonly PolyWorldLink[]; + elements: readonly PolyWorldElement[]; + spatialElements: readonly PolyWorldSpatialElement[]; + data?: PolyWorldData; + regionsById: ReadonlyMap; + linksById: ReadonlyMap; + elementsById: ReadonlyMap; + elementsByPath: ReadonlyMap; + spatialElementsById: ReadonlyMap; + spatialElementsByElementId: ReadonlyMap; + spatialElementsByRegionId: ReadonlyMap; + spatialElementsByLeafId: ReadonlyMap; + spatialElementsByRole: ReadonlyMap; + spatialElementsByVisibility: ReadonlyMap; + spatialElementsByResourceId: ReadonlyMap; + linksByRegionId: ReadonlyMap; + elementsByRegionId: ReadonlyMap; + elementsBySelectionKey: ReadonlyMap; + selectionKeyOwnersByKey: ReadonlyMap; + elementsBySourceId: ReadonlyMap; + elementsByAlias: ReadonlyMap; + elementsByPurpose: ReadonlyMap; + elementsByResourceId: ReadonlyMap; + elementsByLayer: ReadonlyMap; + elementsByTag: ReadonlyMap; + elementsByParentId: ReadonlyMap; + elementsByContainerId: ReadonlyMap; +} + +export interface PolyWorldSelectionKeyOwner { + kind: PolyWorldSelectionKeyOwnerKind; + id: string; +} + +export interface PolyWorldValidationDiagnostic { + code: string; + message: string; + id?: string; + field?: string; + kind?: "topology" | "region" | "link" | "element" | "spatialElement"; +} + +export interface PolyWorldElementResolutionOptions { + layers?: readonly string[]; + tags?: readonly string[]; +} + +export interface PolyWorldElementRelationExpansionOptions { + includeParents?: boolean; + includeContainers?: boolean; + recursive?: boolean; +} + +export interface PolyWorldElementRelation { + kind: PolyWorldElementRelationKind; + elementId: string; + relatedElementId: string; + depth: number; +} + +export interface PolyWorldMissingElementRelation { + kind: PolyWorldElementRelationKind; + elementId: string; + relatedElementId: string; + depth: number; +} + +export interface PolyWorldElementRelationExpansion { + seedElementIds: readonly string[]; + elementIds: readonly string[]; + relatedElementIds: readonly string[]; + parentElementIds: readonly string[]; + containerElementIds: readonly string[]; + missingElementIds: readonly string[]; + missingRelations: readonly PolyWorldMissingElementRelation[]; + relations: readonly PolyWorldElementRelation[]; +} + +export interface PolyWorldSelectionElementRelationExpansionOptions extends PolyWorldElementRelationExpansionOptions { + resolutionOptions?: PolyWorldElementResolutionOptions; + reasonLabel?: string; + reasonKind?: string; +} + +export interface PolyWorldElementMatch { + kind: PolyWorldElementMatchKind; + value: string; +} + +export interface PolyWorldResolvedElement { + element: PolyWorldElement; + elementId: string; + matches: readonly PolyWorldElementMatch[]; +} + +export interface PolyWorldUnresolvedSelection { + regionIds: readonly string[]; + linkIds: readonly string[]; + selectionKeys: readonly string[]; + elementIds: readonly string[]; + sourceIds: readonly string[]; + aliases: readonly string[]; +} + +export interface PolyWorldElementResolution { + elements: readonly PolyWorldElement[]; + elementIds: readonly string[]; + resolved: readonly PolyWorldResolvedElement[]; + unresolved: PolyWorldUnresolvedSelection; + selectedRegionIds: readonly string[]; + selectedLinkIds: readonly string[]; + selectedSelectionKeys: readonly string[]; + selectedElementIds: readonly string[]; + selectedSourceIds: readonly string[]; + selectedAliases: readonly string[]; +} + +export interface PolyWorldRegionResolution { + region: PolyWorldRegion; + regionId: string; + reason: "bounds" | "nearest"; + distanceSq?: number; +} + +export interface PolyWorldRegionResolverOptions { + regionIds?: readonly string[]; + nearest?: boolean; +} diff --git a/packages/world/tsconfig.build.json b/packages/world/tsconfig.build.json new file mode 100644 index 000000000..a2d944429 --- /dev/null +++ b/packages/world/tsconfig.build.json @@ -0,0 +1,20 @@ +{ + "compilerOptions": { + "target": "ES2020", + "module": "ESNext", + "moduleResolution": "Node", + "lib": ["ES2020"], + "declaration": true, + "declarationMap": true, + "esModuleInterop": true, + "forceConsistentCasingInFileNames": true, + "strict": true, + "skipLibCheck": true, + "outDir": "dist", + "paths": { + "@layoutit/polycss-core": ["../core/src/index.ts"], + "@layoutit/polycss-core/*": ["../core/src/*"] + } + }, + "include": ["src"] +} diff --git a/packages/world/tsconfig.json b/packages/world/tsconfig.json new file mode 100644 index 000000000..c6acaf517 --- /dev/null +++ b/packages/world/tsconfig.json @@ -0,0 +1,17 @@ +{ + "compilerOptions": { + "target": "ES2020", + "module": "ESNext", + "moduleResolution": "Node", + "lib": ["ES2020"], + "declaration": true, + "declarationMap": true, + "esModuleInterop": true, + "forceConsistentCasingInFileNames": true, + "strict": true, + "skipLibCheck": true, + "outDir": "dist", + "rootDir": "src" + }, + "include": ["src"] +} diff --git a/packages/world/tsup.config.ts b/packages/world/tsup.config.ts new file mode 100644 index 000000000..bc519d6cd --- /dev/null +++ b/packages/world/tsup.config.ts @@ -0,0 +1,13 @@ +import { defineConfig } from "tsup"; + +export default defineConfig({ + entry: { index: "src/index.ts" }, + format: ["esm", "cjs"], + dts: true, + splitting: false, + sourcemap: false, + clean: true, + minify: true, + target: "es2020", + tsconfig: "tsconfig.build.json", +}); diff --git a/packages/world/vitest.config.ts b/packages/world/vitest.config.ts new file mode 100644 index 000000000..931f7c99d --- /dev/null +++ b/packages/world/vitest.config.ts @@ -0,0 +1,17 @@ +import { defineConfig } from "vitest/config"; +import path from "path"; +import { fileURLToPath } from "url"; + +const dirname = path.dirname(fileURLToPath(import.meta.url)); + +export default defineConfig({ + test: { + include: ["src/**/*.test.ts"], + environment: "node", + }, + resolve: { + alias: { + "@layoutit/polycss-core": path.resolve(dirname, "../core/src/index.ts"), + }, + }, +}); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index ebfe69fbb..cba29d586 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -106,6 +106,22 @@ importers: specifier: ^6.0.0 version: 6.4.1(@types/node@25.5.0) + examples/world: + dependencies: + '@layoutit/polycss': + specifier: workspace:^ + version: link:../../packages/polycss + '@layoutit/polycss-world': + specifier: workspace:^ + version: link:../../packages/world + devDependencies: + typescript: + specifier: ^5.3.3 + version: 5.9.3 + vite: + specifier: ^6.0.0 + version: 6.4.1(@types/node@25.5.0) + packages/core: devDependencies: '@vitest/coverage-v8': @@ -230,6 +246,25 @@ importers: specifier: ^3.5.12 version: 3.5.30(typescript@5.9.3) + packages/world: + dependencies: + '@layoutit/polycss-core': + specifier: workspace:^ + version: link:../core + devDependencies: + '@vitest/coverage-v8': + specifier: ^3.1.1 + version: 3.2.4(vitest@3.2.4(@types/debug@4.1.13)(@types/node@25.5.0)(happy-dom@20.8.9)) + tsup: + specifier: ^8.0.1 + version: 8.5.1(postcss@8.5.8)(typescript@5.9.3) + typescript: + specifier: ^5.3.3 + version: 5.9.3 + vitest: + specifier: ^3.1.1 + version: 3.2.4(@types/debug@4.1.13)(@types/node@25.5.0)(happy-dom@20.8.9) + website: dependencies: '@astrojs/react':