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
+
+
+
+
+
+
+
+
+
+
+
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