diff --git a/README.md b/README.md index b1d296a..9eaa0d0 100644 --- a/README.md +++ b/README.md @@ -40,6 +40,21 @@ pnpm build:3dpipes:full pnpm dev:3dpipes ``` +### Flower Box + +[`src/adapters/flowerbox`](src/adapters/flowerbox) is an independent PolyCSS +reconstruction of the classic Flower Box. Its rounded prepared cycle uses +1,200 stable retained triangle leaves, prepared Morph transforms, and a +hash-bound q60 space-texel lighting atlas. + +```sh +pnpm prepare:flowerbox:artifact +pnpm build:flowerbox +pnpm dev:flowerbox +``` + +Microsoft source, binaries, captures, and oracle packets are not included. + ## License cssGraphics source code is [MIT licensed](LICENSE). Third-party models retain diff --git a/netlify.toml b/netlify.toml index f5fa88d..43fb4ef 100644 --- a/netlify.toml +++ b/netlify.toml @@ -10,3 +10,8 @@ to = "/pipes/" status = 302 force = true + +[[headers]] + for = "/cssflower/assets/*" + [headers.values] + Cache-Control = "public, max-age=31536000, immutable" diff --git a/package.json b/package.json index 3785177..fa682aa 100644 --- a/package.json +++ b/package.json @@ -35,20 +35,29 @@ "scripts": { "dev": "vite --host 127.0.0.1", "dev:3dpipes": "vite --config src/adapters/3dpipes/vite.config.mjs --host 127.0.0.1", + "dev:flowerbox": "vite --config src/adapters/flowerbox/vite.config.mjs --host 127.0.0.1", "build": "pnpm build:site && pnpm build:lib", "build:site": "vite build", "build:lib": "vite build --mode library && node scripts/build-cli.mjs", "build:3dpipes": "vite build --config src/adapters/3dpipes/vite.config.mjs", "build:3dpipes:full": "pnpm prepare:3dpipes && pnpm build:3dpipes", - "build:deploy": "pnpm exec playwright install chromium && pnpm prepare:3dpipes && CSSPIPES_DEPLOY_BUILD=1 pnpm build:3dpipes", + "build:flowerbox": "vite build --config src/adapters/flowerbox/vite.config.mjs", + "build:flowerbox:full": "pnpm prepare:flowerbox:artifact && pnpm build:flowerbox", + "build:deploy": "pnpm exec playwright install chromium && pnpm prepare:3dpipes && pnpm prepare:flowerbox:artifact && CSSPIPES_DEPLOY_BUILD=1 pnpm build:3dpipes && CSSFLOWER_DEPLOY_BUILD=1 pnpm build:flowerbox", "prepack": "pnpm build:lib", "preview": "vite preview --host 127.0.0.1", "prepare:super-mario-64": "pnpm build:lib && node scripts/prepare/super-mario-64.mjs", "prepare:3dpipes": "node src/adapters/3dpipes/tools/prepare-csspipes.mjs", + "prepare:flowerbox:artifact": "node scripts/prepare/flowerbox-product-bank.mjs", + "prepare:flowerbox:source": "node src/adapters/flowerbox/tools/prepare-cssflower.mjs && node src/adapters/flowerbox/tools/prepare-polycss-snapshot.mjs", "prepare:catalog": "pnpm build:lib && node scripts/prepare/catalog.mjs", "prepare:site-models": "node scripts/prepare/site-models.mjs", "prepare:site-previews": "pnpm prepare:site-models && node scripts/prepare/site-previews.mjs", "verify:source-only": "node scripts/verify-source-only.mjs --strict", + "verify:flowerbox:bank": "node src/adapters/flowerbox/tools/verify-product-bank.mjs", + "verify:flowerbox:runtime": "node src/adapters/flowerbox/tools/audit-runtime-surface.mjs", + "test:flowerbox:browser": "node src/adapters/flowerbox/tools/smoke-browser.mjs", + "test:flowerbox:deploy": "node src/adapters/flowerbox/tools/smoke-browser.mjs --deploy", "typecheck": "tsc --noEmit" }, "dependencies": { diff --git a/scripts/prepare/flowerbox-product-bank.mjs b/scripts/prepare/flowerbox-product-bank.mjs new file mode 100644 index 0000000..a47dc1a --- /dev/null +++ b/scripts/prepare/flowerbox-product-bank.mjs @@ -0,0 +1,101 @@ +#!/usr/bin/env node + +import { createHash } from "node:crypto"; +import { spawnSync } from "node:child_process"; +import { mkdir, readFile, rename, rm, writeFile } from "node:fs/promises"; +import { dirname, join, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; +import { inspectFlowerboxProductBank } from "../../src/adapters/flowerbox/tools/productBank.mjs"; + +const repositoryRoot = resolve(dirname(fileURLToPath(import.meta.url)), "..", ".."); +const lock = JSON.parse(await readFile( + join(repositoryRoot, "src/adapters/flowerbox/prepared-bank.lock.json"), + "utf8", +)); +if (lock.schema !== "cssflower-prepared-bank-lock@3" || + lock.retainedTriangleLeafCount !== 1_200 || lock.retainedRotationRootCount !== 1 || + lock.timelineStateCount !== 360 || lock.geometryStateCount !== 46 || + lock.transformBlockCount !== 3 || lock.lightingAssetCount !== 1 || + lock.lightingQuality !== 60 || lock.visibilityMinimumOwnedPixels !== 8) { + throw new Error("Flower Box prepared-bank lock does not bind the rounded retained Morph product"); +} +const generatedRoot = resolve( + process.env.CSSFLOWER_GENERATED_ROOT ?? join(repositoryRoot, "build", "generated"), +); +const publicRoot = join(generatedRoot, "public"); +const targetRoot = join(publicRoot, "cssflower"); + +try { + const current = await inspectFlowerboxProductBank(targetRoot); + const descriptorBytes = await readFile(join(targetRoot, "product-bank.json")); + if (current.closureSha256 === lock.productClosureSha256 && + current.closureBytes === lock.productClosureBytes && + current.fileCount === lock.productFileCount && + descriptorBytes.length === lock.productDescriptorByteLength && + sha256(descriptorBytes) === lock.productDescriptorSha256) { + process.stdout.write(`${JSON.stringify({ status: "ready", source: "existing", ...current }, null, 2)}\n`); + process.exit(0); + } +} catch { + // Missing or stale output is replaced only after the downloaded bank verifies. +} + +const localArchive = process.env.CSSFLOWER_PRODUCT_BANK_ARCHIVE; +const cacheRoot = join(repositoryRoot, ".local", "downloads", "cssflower"); +const archivePath = localArchive + ? resolve(localArchive) + : join(cacheRoot, `${lock.archiveSha256}.tar.gz`); +let archiveBytes; +try { + archiveBytes = await readFile(archivePath); +} catch (error) { + if (localArchive || error?.code !== "ENOENT") throw error; + const response = await fetch(lock.url, { redirect: "follow" }); + if (!response.ok) throw new Error(`Flower Box product bank download failed: ${response.status}`); + archiveBytes = Buffer.from(await response.arrayBuffer()); + await mkdir(cacheRoot, { recursive: true }); + await writeFile(archivePath, archiveBytes); +} +if (archiveBytes.length !== lock.archiveByteLength || sha256(archiveBytes) !== lock.archiveSha256) { + throw new Error("Flower Box product bank archive identity mismatch"); +} + +const listing = spawnSync("tar", ["-tzf", archivePath], { encoding: "utf8" }); +if (listing.error) throw listing.error; +if (listing.status !== 0) throw new Error(`Unable to inspect Flower Box product bank:\n${listing.stderr}`); +const entries = listing.stdout.trim().split("\n").filter(Boolean); +if (entries.length === 0 || entries.some((entry) => + entry.startsWith("/") || entry.split("/").includes("..") || + !(entry === "cssflower" || entry === "cssflower/" || entry.startsWith("cssflower/")))) { + throw new Error("Flower Box product bank archive contains an unsafe path"); +} + +const stagingRoot = join(generatedRoot, `.flowerbox-product-${process.pid}`); +await rm(stagingRoot, { recursive: true, force: true }); +await mkdir(stagingRoot, { recursive: true }); +const extraction = spawnSync("tar", ["-xzf", archivePath, "-C", stagingRoot], { encoding: "utf8" }); +if (extraction.error) throw extraction.error; +if (extraction.status !== 0) throw new Error(`Unable to extract Flower Box product bank:\n${extraction.stderr}`); +try { + const stagedProduct = join(stagingRoot, "cssflower"); + const summary = await inspectFlowerboxProductBank(stagedProduct); + const descriptorBytes = await readFile(join(stagedProduct, "product-bank.json")); + if (summary.closureSha256 !== lock.productClosureSha256 || summary.closureBytes !== lock.productClosureBytes || + summary.fileCount !== lock.productFileCount) { + throw new Error("Flower Box unpacked product bank identity mismatch"); + } + if (descriptorBytes.length !== lock.productDescriptorByteLength || + sha256(descriptorBytes) !== lock.productDescriptorSha256) { + throw new Error("Flower Box product-bank descriptor identity mismatch"); + } + await mkdir(publicRoot, { recursive: true }); + await rm(targetRoot, { recursive: true, force: true }); + await rename(stagedProduct, targetRoot); + process.stdout.write(`${JSON.stringify({ status: "ready", source: localArchive ? "local-archive" : "release", ...summary }, null, 2)}\n`); +} finally { + await rm(stagingRoot, { recursive: true, force: true }); +} + +function sha256(bytes) { + return createHash("sha256").update(bytes).digest("hex"); +} diff --git a/scripts/verify-source-only.mjs b/scripts/verify-source-only.mjs index 6080dd1..eab5acf 100644 --- a/scripts/verify-source-only.mjs +++ b/scripts/verify-source-only.mjs @@ -15,7 +15,9 @@ const STRICT_SOURCE_RENDERER_PATTERNS = Object.freeze([ ["wasm-runtime", /WebAssembly\.(?:compile|instantiate)|instantiateStreaming\(|\.wasm(?:\b|["'])/iu], ["emulator-runtime", /(?:from|import\s*)[\s\S]{0,80}["'][^"']*(?:dosbox|mupen64|retroarch|emulator)[^"']*["']/iu], ]); -const STRICT_AUDITOR_PATHS = new Set(); +const STRICT_AUDITOR_PATHS = new Set([ + "src/adapters/flowerbox/tools/audit-runtime-surface.mjs", +]); const STRICT_PATH_GUARD_PATHS = new Set([ "scripts/verify-source-only.mjs", ]); @@ -34,9 +36,12 @@ const DISTRIBUTION_PREFIXES = Object.freeze([ "site/public/previews/", ]); const README_MEDIA_PATHS = new Set([ + "site/public/favicon.ico", "site/readme/animated-morph-sphere.gif", "site/readme/cube-to-sphere.gif", "site/readme/pipes.gif", + "src/adapters/3dpipes/public/pipes-social.png", + "src/adapters/flowerbox/public/flower-social.png", ]); const SHA256 = /^[a-f0-9]{64}$/u; diff --git a/site/public/sitemap.xml b/site/public/sitemap.xml index f5f56e1..8df1557 100644 --- a/site/public/sitemap.xml +++ b/site/public/sitemap.xml @@ -6,4 +6,7 @@ https://css.graphics/pipes/ + + https://css.graphics/flower/ + diff --git a/src/adapters/flowerbox/README.md b/src/adapters/flowerbox/README.md new file mode 100644 index 0000000..1e240bf --- /dev/null +++ b/src/adapters/flowerbox/README.md @@ -0,0 +1,38 @@ +# Flower Box + +An independently authored PolyCSS reconstruction of the classic 1995 Flower +Box. The rounded default-cube bloom and complete rotation cycle are rendered +through 1,200 stable retained HTML triangle leaves and one retained rotation +root. + +The browser loads one prepared PolyCSS snapshot, three prepared matrix3d +blocks, and one q60 prepared space-texel lighting grid. PolyCSS Morph writes +only selected prepared leaf transforms; a prepared source-camera visibility +schedule suppresses cells owning fewer than eight pixels. The runtime does not +construct geometry, project vertices, calculate normals or lighting, +rasterize, or grow the DOM. The canonical page is the responsive `/` route; +there is no separate presentation or oracle mode. + +From the repository root: + +```sh +pnpm prepare:flowerbox:artifact +pnpm build:flowerbox +pnpm dev:flowerbox +``` + +The public rounded q60 product bank is a content-addressed build artifact. Its +lock binds the archive and unpacked closure; generated output remains ignored +under `build/generated/`. A full source preparation is available separately +through `pnpm prepare:flowerbox:source` when the pinned `avifenc` is supplied. + +The source profile is bound to `DigitalMars/dmc` revision +`9478d25a677f70dbe4fc0ed317cc5a5e5050ef8b`. Exact native-state qualification +was performed locally across 9,331 ticks. That evidence and the owned native +inputs are not part of the product bank; native/browser pixel parity is not +claimed. + +The implementation is covered by the repository's [MIT license](../../../LICENSE). +Microsoft source, binaries, native captures, and oracle packets are not +included or downloaded. This independent experiment is not affiliated with or +endorsed by Microsoft. diff --git a/src/adapters/flowerbox/index.html b/src/adapters/flowerbox/index.html new file mode 100644 index 0000000..13df2c6 --- /dev/null +++ b/src/adapters/flowerbox/index.html @@ -0,0 +1,23 @@ + + + + + + Flower Box — HTML and CSS experiment + + + + + + + + + + + + + + + + + diff --git a/src/adapters/flowerbox/prepared-bank.lock.json b/src/adapters/flowerbox/prepared-bank.lock.json new file mode 100644 index 0000000..804c6e8 --- /dev/null +++ b/src/adapters/flowerbox/prepared-bank.lock.json @@ -0,0 +1,30 @@ +{ + "schema": "cssflower-prepared-bank-lock@3", + "tag": "cssflower-product-rounded-q60-v1", + "asset": "cssflower-product-rounded-q60-v1.tar.gz", + "url": "https://github.com/layoutit/cssGraphics/releases/download/cssflower-product-rounded-q60-v1/cssflower-product-rounded-q60-v1.tar.gz", + "archiveByteLength": 5312404, + "archiveSha256": "8ad850187c297ab8f1671a29f656f4eb098d8370219183e4b37f9e6c4b1fe9e5", + "productClosureBytes": 5324102, + "productClosureSha256": "f7e8f9144b859fc34ccbc74a980e07180154ece23eb0783f85f92d6470655b83", + "productFileCount": 7, + "productDescriptorByteLength": 1886, + "productDescriptorSha256": "2418737362c5c50f2f245dabef45571b002fc279ca165863e352d2f91d517758", + "retainedTriangleLeafCount": 1200, + "retainedRotationRootCount": 1, + "timelineStateCount": 360, + "geometryStateCount": 46, + "retainedSourceOracleTimelineStateCount": 9331, + "transformBlockCount": 3, + "transformAssetBytes": 1347149, + "lightingAssetCount": 1, + "lightingAssetBytes": 3848891, + "lightingQuality": 60, + "visibilityMinimumOwnedPixels": 8, + "publicBoundary": { + "microsoftSourceIncluded": false, + "microsoftBinaryIncluded": false, + "nativeCaptureIncluded": false, + "oraclePacketIncluded": false + } +} diff --git a/src/adapters/flowerbox/public/flower-social.png b/src/adapters/flowerbox/public/flower-social.png new file mode 100644 index 0000000..6f42891 Binary files /dev/null and b/src/adapters/flowerbox/public/flower-social.png differ diff --git a/src/adapters/flowerbox/src/cssflower/client.mjs b/src/adapters/flowerbox/src/cssflower/client.mjs new file mode 100644 index 0000000..25d80b9 --- /dev/null +++ b/src/adapters/flowerbox/src/cssflower/client.mjs @@ -0,0 +1,86 @@ +import { + loadPreparedManifest, + loadPreparedScene, +} from "./manifestClient.mjs"; +import { mountPreparedPolycssSnapshot } from "./polycssScene.mjs"; +import { createCssflowerPreparedPlayer } from "./preparedPlayback.mjs"; +import { createRouteState } from "./routeState.mjs"; +import { installCssflowerDebugApi } from "./debugApi.mjs"; +import { installCssflowerStagePresentation } from "./stagePresentation.mjs"; + +export function mountCssflowerClient(host) { + if (!(host instanceof HTMLElement)) throw new TypeError("cssFlower scene host is missing"); + const state = { + ready: false, + status: "loading", + route: null, + manifest: null, + sceneData: null, + mount: null, + errors: [], + }; + installCssflowerDebugApi(state); + + window.addEventListener("error", (event) => { + recordError(state, host, event.message || String(event.error || "error")); + }); + window.addEventListener("unhandledrejection", (event) => { + recordError(state, host, String(event.reason?.message || event.reason || "unhandled rejection")); + }); + + main().catch((error) => { + recordError(state, host, error.stack || error.message || String(error)); + }); + + async function main() { + const route = createRouteState(); + state.route = route; + + const manifest = await loadPreparedManifest(route); + state.manifest = manifest; + + const { entry, sceneData, snapshotHtml, preparedAssets } = await loadPreparedScene(manifest, route); + state.sceneData = sceneData; + + const snapshot = mountPreparedPolycssSnapshot({ host, sceneData, snapshotHtml, preparedAssets }); + const presentation = installCssflowerStagePresentation({ host, camera: snapshot.camera }); + const player = await createCssflowerPreparedPlayer({ + playback: sceneData.playback, + lighting: sceneData.lighting, + rotationRoot: snapshot.rotationRoot, + mesh: snapshot.mesh, + leaves: snapshot.leaves, + transformBlocks: preparedAssets.transformBlocks, + lightingPages: preparedAssets.lightingPages, + }); + state.mount = Object.freeze({ + ...snapshot, + player, + stats() { + return Object.freeze({ + ...player.stats(), + ...snapshot.stats(), + ...presentation.stats(), + }); + }, + destroy() { + presentation.destroy(); + player.destroy(); + preparedAssets.transformBlocks.destroy(); + preparedAssets.lightingPages.destroy(); + snapshot.destroy(); + }, + }); + state.ready = true; + state.status = "ready"; + host.classList.add("r"); + requestAnimationFrame(() => player.resume()); + } +} + +function recordError(state, host, message) { + state.errors.push(message); + state.status = "error"; + host.classList.remove("r"); + host.classList.add("e"); +} diff --git a/src/adapters/flowerbox/src/cssflower/debugApi.mjs b/src/adapters/flowerbox/src/cssflower/debugApi.mjs new file mode 100644 index 0000000..a26eb8f --- /dev/null +++ b/src/adapters/flowerbox/src/cssflower/debugApi.mjs @@ -0,0 +1,54 @@ +export function installCssflowerDebugApi(state) { + const api = { + get ready() { + return state.ready; + }, + get status() { + return state.status; + }, + get manifest() { + return state.manifest; + }, + get scene() { + return state.sceneData; + }, + get route() { + return state.route; + }, + errors() { + return [...state.errors]; + }, + stats() { + return state.mount?.stats?.() ?? null; + }, + pause() { + return state.mount?.player?.pause?.() ?? null; + }, + resume() { + return state.mount?.player?.resume?.() ?? null; + }, + step(count = 1) { + return state.mount?.player?.step?.(count) ?? null; + }, + setTick(tick) { + return state.mount?.player?.setTick?.(tick) ?? null; + }, + sample() { + return state.mount?.player?.sample?.() ?? null; + }, + nodes() { + return state.mount?.player?.nodes?.() ?? null; + }, + assertStableDomIdentity() { + state.mount?.assertStableDomIdentity?.(); + state.mount?.player?.assertStableDomIdentity?.(); + return true; + }, + meshes() { + const stats = state.mount?.stats?.(); + return stats ? [{ id: "flower-box-default-cube", polygons: stats.retainedTriangleLeafCount }] : []; + }, + }; + globalThis.__cssFlowerDebug = api; + return api; +} diff --git a/src/adapters/flowerbox/src/cssflower/manifestClient.mjs b/src/adapters/flowerbox/src/cssflower/manifestClient.mjs new file mode 100644 index 0000000..d88894f --- /dev/null +++ b/src/adapters/flowerbox/src/cssflower/manifestClient.mjs @@ -0,0 +1,296 @@ +import { + sceneEntryForRoute, + routeSceneLabel, +} from "./routeState.mjs"; +import { + createPreparedLightingPageLoader, + createPreparedTransformBlockLoader, + validatePreparedMorphAssets, +} from "./preparedAssetLoaders.mjs"; +import { + CSSFLOWER_BOUNDARY_SEAM_BLEED, + CSSFLOWER_LIGHTING_ATLAS_HEIGHT, + CSSFLOWER_LIGHTING_ATLAS_WIDTH, + CSSFLOWER_LIGHTING_GUTTER, + CSSFLOWER_LIGHTING_GRID_COLUMNS, + CSSFLOWER_LIGHTING_GRID_HEIGHT, + CSSFLOWER_LIGHTING_GRID_ROWS, + CSSFLOWER_LIGHTING_GRID_WIDTH, + CSSFLOWER_LIGHTING_LAYOUT, + CSSFLOWER_LIGHTING_PAGE_COUNT, + CSSFLOWER_LIGHTING_PAGE_ROWS, + CSSFLOWER_LIGHTING_RASTER_MODE, + CSSFLOWER_LIGHTING_SAMPLING, + CSSFLOWER_LIGHTING_SCHEMA, + CSSFLOWER_LIGHTING_STATE_SLICE_HEIGHT, + CSSFLOWER_FRONT_FACE_DILATION_TICKS, + CSSFLOWER_FRONT_FACE_SCHEDULE_ENCODING, + CSSFLOWER_FRONT_FACE_SCHEDULE_SCHEMA, + CSSFLOWER_SEAM_BLEED, + CSSFLOWER_SEAM_BLEED_POLICY, + CSSFLOWER_VISIBILITY_POLICY, +} from "./renderContract.mjs"; + +export async function loadPreparedManifest(routeState) { + const manifest = await fetchJson(routeState.manifestUrl, { + notFoundMessage: "Missing generated cssFlower — Microsoft Flower Box manifest at " + routeState.manifestUrl + ". Run pnpm prepare:cssflower first.", + }); + if (manifest?.schema !== "cssflower-manifest@1" || manifest?.status !== "ready") { + throw new Error("Generated cssFlower — Microsoft Flower Box manifest is not ready (" + (manifest?.status ?? "missing status") + "). Run pnpm prepare:cssflower first."); + } + return manifest; +} + +export async function loadPreparedScene(manifest, routeState) { + const entry = sceneEntryForRoute(manifest, routeState); + if (!entry || typeof entry.sceneUrl !== "string") { + throw new Error("Generated cssFlower — Microsoft Flower Box manifest does not include " + routeSceneLabel(routeState) + ". Run pnpm prepare:cssflower first."); + } + const sceneData = await fetchJson(entry.sceneUrl, { + notFoundMessage: "Missing generated cssFlower — Microsoft Flower Box scene at " + entry.sceneUrl + ". Run pnpm prepare:cssflower first.", + }); + const snapshotHtml = typeof entry.snapshotUrl === "string" && entry.snapshotUrl + ? await fetchText(entry.snapshotUrl, { + notFoundMessage: "Missing generated cssFlower — Microsoft Flower Box PolyCSS snapshot at " + entry.snapshotUrl + ". Run pnpm prepare:cssflower first.", + }) + : null; + if (sceneData?.schema !== "cssflower-prepared-scene@1" || + sceneData?.playback?.schema !== "cssflower-prepared-playback@1" || + sceneData?.renderer?.morphTarget !== "createPolyMorphPreparedDomTarget" || + sceneData?.renderer?.stableDom !== true || + sceneData?.renderer?.seamBleed !== CSSFLOWER_SEAM_BLEED || + sceneData?.renderer?.boundarySeamBleed !== CSSFLOWER_BOUNDARY_SEAM_BLEED || + sceneData?.renderer?.seamBleedPolicy !== CSSFLOWER_SEAM_BLEED_POLICY || + sceneData?.renderer?.seamBleedSharedEdgeCount !== 1680 || + sceneData?.renderer?.seamBleedBoundaryEdgeCount !== 240 || + sceneData?.renderer?.seamBleedBoundaryVertexCount !== 240 || + sceneData?.renderer?.seamBleedBoundaryAdjacentTriangleCount !== 432 || + sceneData?.renderer?.merge !== false || + sceneData?.lighting?.schema !== CSSFLOWER_LIGHTING_SCHEMA || + sceneData?.lighting?.physicalLayout !== CSSFLOWER_LIGHTING_LAYOUT || + sceneData?.lighting?.rasterMode !== CSSFLOWER_LIGHTING_RASTER_MODE || + sceneData?.lighting?.sampling !== CSSFLOWER_LIGHTING_SAMPLING || + sceneData?.lighting?.gutter !== CSSFLOWER_LIGHTING_GUTTER || + sceneData?.lighting?.stateSliceHeight !== CSSFLOWER_LIGHTING_STATE_SLICE_HEIGHT || + sceneData?.lighting?.atlasWidth !== CSSFLOWER_LIGHTING_ATLAS_WIDTH || + sceneData?.lighting?.atlasHeight !== CSSFLOWER_LIGHTING_ATLAS_HEIGHT || + sceneData?.lighting?.gridColumns !== CSSFLOWER_LIGHTING_GRID_COLUMNS || + sceneData?.lighting?.gridRows !== CSSFLOWER_LIGHTING_GRID_ROWS || + sceneData?.lighting?.gridWidth !== CSSFLOWER_LIGHTING_GRID_WIDTH || + sceneData?.lighting?.gridHeight !== CSSFLOWER_LIGHTING_GRID_HEIGHT || + sceneData?.lighting?.faceCount !== 1200 || + sceneData?.lighting?.timelineRowCount !== 360 || + sceneData?.lighting?.pageRowCount !== CSSFLOWER_LIGHTING_PAGE_ROWS || + sceneData?.lighting?.pageCount !== CSSFLOWER_LIGHTING_PAGE_COUNT || + sceneData?.lighting?.pages?.length !== CSSFLOWER_LIGHTING_PAGE_COUNT || + sceneData?.lighting?.leafSizing !== "raster" || + sceneData?.lighting?.boundarySeamBleed !== CSSFLOWER_BOUNDARY_SEAM_BLEED || + sceneData?.lighting?.seamBleedPolicy !== CSSFLOWER_SEAM_BLEED_POLICY || + sceneData?.lighting?.boundaryVertexCount !== 240 || + sceneData?.lighting?.boundaryAdjacentTriangleCount !== 432 || + sceneData?.lighting?.sharedEdgeIncidenceCount !== 3360 || + sceneData?.lighting?.boundaryEdgeIncidenceCount !== 240 || + sceneData?.lighting?.faces?.length !== 1200 || + !sceneData?.lighting?.faces?.every((face, index) => + face?.sourceOrder === index && + Number.isSafeInteger(face.leafWidth) && face.leafWidth >= 2 && + Number.isSafeInteger(face.leafHeight) && face.leafHeight >= 2 && + face.tileWidth === face.leafWidth && + face.tileHeight === face.leafHeight && + Number.isSafeInteger(face.contentX) && Number.isSafeInteger(face.contentY) && + face.contentX >= CSSFLOWER_LIGHTING_GUTTER && + face.contentX + face.tileWidth <= CSSFLOWER_LIGHTING_ATLAS_WIDTH - CSSFLOWER_LIGHTING_GUTTER && + face.contentY >= CSSFLOWER_LIGHTING_GUTTER && + face.contentY + face.tileHeight <= CSSFLOWER_LIGHTING_STATE_SLICE_HEIGHT - CSSFLOWER_LIGHTING_GUTTER && + typeof face.backgroundSize === "string" && + face.backgroundPositionX === `${-face.contentX}px` && + face.backgroundPositionY === `${-face.contentY}px` && + Number.isSafeInteger(face?.seamEdgeMask) && face.seamEdgeMask >= 1 && face.seamEdgeMask <= 7 && + (face.boundaryAdjacent === true + ? face.seamBleed === CSSFLOWER_BOUNDARY_SEAM_BLEED + : face.boundaryAdjacent === false && face.seamBleed === CSSFLOWER_SEAM_BLEED)) || + sceneData?.lighting?.backgroundPositionYs?.length !== CSSFLOWER_LIGHTING_PAGE_ROWS || + sceneData?.lighting?.backgroundPositionXs?.length !== CSSFLOWER_LIGHTING_PAGE_COUNT || + !validPreparedLightingAddressSchedule(sceneData?.lighting?.addressSchedule) || + sceneData?.lighting?.rowSelection !== "prepared-exact-rgb8-sparse-leaf-address-schedule" || + sceneData?.lighting?.temporalInterpolation !== false || + sceneData?.lighting?.runtimeRootFrameVariables !== 1 || + !validPreparedFrontFacingSchedule(sceneData?.playback?.frontFacingSchedule) || + sceneData?.metrics?.preparedLeafCount !== 1200 || + sceneData?.metrics?.preparedRootCount !== 1 || + sceneData?.metrics?.runtimePolygonConstructionCount !== 0 || + sceneData?.metrics?.runtimeRadialProjectionCount !== 0 || + sceneData?.metrics?.runtimeNormalCalculationCount !== 0 || + sceneData?.metrics?.runtimeLightingCalculationCount !== 0 || + sceneData?.metrics?.runtimeDomGrowth !== false) { + throw new Error("Generated cssFlower scene is not the retained default-cube contract."); + } + if (!snapshotHtml || entry.snapshot?.schema !== "cssflower-retained-snapshot-contract@1") { + throw new Error("Generated cssFlower retained snapshot binding is missing. Run pnpm prepare:cssflower first."); + } + const addressBytes = decodePreparedLightingAddressBytes(sceneData.lighting.addressSchedule); + const frontFacingBytes = decodePreparedFrontFacingBytes(sceneData.playback.frontFacingSchedule); + await assertSha256( + addressBytes, + sceneData.lighting.addressSchedule.faceIndicesSha256, + "exact sparse-lighting address schedule", + ); + await assertSha256( + frontFacingBytes, + sceneData.playback.frontFacingSchedule.dataSha256, + "front-face transform schedule", + ); + await assertSha256(new TextEncoder().encode(snapshotHtml), entry.snapshot.sha256, "snapshot"); + validatePreparedMorphAssets(sceneData.playback, sceneData.lighting); + const transformBlocks = createPreparedTransformBlockLoader(sceneData.playback.transformAsset); + const lightingPages = createPreparedLightingPageLoader(sceneData.lighting); + const initialState = sceneData.playback.cycle.states[0]; + try { + await Promise.all([ + transformBlocks.prime( + initialState.geometryStateIndex, + initialState.nextTransformBlockGeometryStateIndex, + ), + lightingPages.prime(initialState.lightingPageIndex, initialState.nextLightingPageIndex), + ]); + } catch (error) { + transformBlocks.destroy(); + lightingPages.destroy(); + throw error; + } + return { + entry, + sceneData, + snapshotHtml, + preparedAssets: Object.freeze({ transformBlocks, lightingPages }), + }; +} + +function validPreparedLightingAddressSchedule(schedule) { + return schedule?.schema === "cssflower-prepared-exact-sparse-lighting-address-schedule@1" && + schedule.stateCount === 360 && schedule.faceCount === 1_200 && schedule.threshold === 0 && + schedule.selectionDomain === "prepared-source-vertex-lighting-rgb8" && + schedule.comparison === "exact-three-canonical-point-rgb8-signature-per-retained-triangle" && + schedule.cycleBoundaryPolicy === "force-all-faces-to-state-zero-on-each-360-state-wrap" && + Number.isSafeInteger(schedule.updateCount) && schedule.updateCount >= 1_200 && + Number.isFinite(schedule.meanUpdatesPerState) && schedule.meanUpdatesPerState > 0 && + Number.isSafeInteger(schedule.p95UpdatesPerState) && schedule.p95UpdatesPerState >= 0 && + Number.isSafeInteger(schedule.maximumUpdatesPerState) && schedule.maximumUpdatesPerState === 1_200 && + schedule.offsets?.length === 361 && schedule.offsets[0] === 0 && schedule.offsets[1] === 1_200 && + schedule.offsets.at(-1) === schedule.updateCount && schedule.offsets.every((value, index) => + Number.isSafeInteger(value) && value >= 0 && (index === 0 || value >= schedule.offsets[index - 1])) && + schedule.faceIndicesEncoding === "base64-u16le-state-major-updated-face-indices" && + schedule.faceIndicesByteLength === schedule.updateCount * 2 && + /^[a-f0-9]{64}$/u.test(schedule.faceIndicesSha256 ?? "") && + typeof schedule.faceIndicesBase64 === "string" && schedule.faceIndicesBase64.length > 0 && + schedule.runtimeSelection === "prepared-state-range-only-no-lighting-or-geometry-calculation"; +} + +function validPreparedFrontFacingSchedule(schedule) { + return schedule?.schema === CSSFLOWER_FRONT_FACE_SCHEDULE_SCHEMA && + schedule.stateCount === 360 && schedule.faceCount === 1_200 && + schedule.dilationTicks === CSSFLOWER_FRONT_FACE_DILATION_TICKS && + schedule.selectionDomain === CSSFLOWER_VISIBILITY_POLICY.selectionDomain && + schedule.depthComparison === CSSFLOWER_VISIBILITY_POLICY.depthComparison && + schedule.minimumOwnedPixels === CSSFLOWER_VISIBILITY_POLICY.minimumOwnedPixels && + schedule.sampleGrid === CSSFLOWER_VISIBILITY_POLICY.sampleGrid && + schedule.adjacency === CSSFLOWER_VISIBILITY_POLICY.adjacency && + schedule.adjacencyRings === CSSFLOWER_VISIBILITY_POLICY.adjacencyRings && + schedule.dilationPolicy === CSSFLOWER_VISIBILITY_POLICY.dilationPolicy && + schedule.encoding === CSSFLOWER_FRONT_FACE_SCHEDULE_ENCODING && + schedule.bytesPerState === 150 && schedule.byteLength === schedule.stateCount * schedule.bytesPerState && + Number.isSafeInteger(schedule.selectedFaceCount) && schedule.selectedFaceCount > 0 && + schedule.selectedFaceCount < schedule.stateCount * schedule.faceCount && + schedule.suppressedFaceCount === schedule.stateCount * schedule.faceCount - schedule.selectedFaceCount && + schedule.meanSelectedFacesPerState === schedule.selectedFaceCount / schedule.stateCount && + Number.isSafeInteger(schedule.minimumSelectedFacesPerState) && schedule.minimumSelectedFacesPerState > 0 && + Number.isSafeInteger(schedule.maximumSelectedFacesPerState) && + schedule.maximumSelectedFacesPerState >= schedule.minimumSelectedFacesPerState && + schedule.maximumSelectedFacesPerState <= schedule.faceCount && + schedule.initialVisibilitySelectionCount === schedule.faceCount && + Number.isSafeInteger(schedule.visibilityChangeCount) && schedule.visibilityChangeCount > 0 && + /^[a-f0-9]{64}$/u.test(schedule.dataSha256 ?? "") && + typeof schedule.dataBase64 === "string" && schedule.dataBase64.length > 0 && + schedule.runtimeSelection === "prepared-bit-test-only-no-geometry-projection-normal-or-lighting-calculation"; +} + +function decodePreparedFrontFacingBytes(schedule) { + let encoded; + try { + encoded = atob(schedule.dataBase64); + } catch (error) { + throw new Error(`Generated cssFlower front-face schedule encoding is invalid: ${error.message}`); + } + if (encoded.length !== schedule.byteLength) { + throw new Error("Generated cssFlower front-face schedule byte length drifted."); + } + return Uint8Array.from(encoded, (character) => character.charCodeAt(0)); +} + +function decodePreparedLightingAddressBytes(schedule) { + let encoded; + try { + encoded = atob(schedule.faceIndicesBase64); + } catch (error) { + throw new Error(`Generated cssFlower sparse-lighting address encoding is invalid: ${error.message}`); + } + if (encoded.length !== schedule.faceIndicesByteLength) { + throw new Error("Generated cssFlower sparse-lighting address byte length drifted."); + } + return Uint8Array.from(encoded, (character) => character.charCodeAt(0)); +} + +async function fetchJson(url, { notFoundMessage = "" } = {}) { + const response = await fetch(url); + if (!response.ok) { + if (response.status === 404 && notFoundMessage) throw new Error(notFoundMessage); + throw new Error("Failed to load " + url + ": " + response.status); + } + if (url.endsWith(".gz")) { + const decoded = await decodedGzipResponseBytes(response); + try { + return JSON.parse(new TextDecoder().decode(decoded)); + } catch (error) { + throw new Error(`Prepared cssFlower gzip JSON ${url} is invalid: ${error.message}`); + } + } + const contentType = response.headers.get("content-type") ?? ""; + if (!contentType.includes("application/json")) { + throw new Error(notFoundMessage || ("Expected JSON from " + url + " but got " + (contentType || "unknown content type"))); + } + return response.json(); +} + +async function fetchText(url, { notFoundMessage = "" } = {}) { + const response = await fetch(url); + if (!response.ok) { + if (response.status === 404 && notFoundMessage) throw new Error(notFoundMessage); + throw new Error("Failed to load " + url + ": " + response.status); + } + if (url.endsWith(".gz")) { + return new TextDecoder().decode(await decodedGzipResponseBytes(response)); + } + return response.text(); +} + +async function decompressGzip(bytes) { + if (typeof DecompressionStream !== "function") { + throw new Error("This browser cannot decode prepared cssFlower gzip assets."); + } + const stream = new Blob([bytes]).stream().pipeThrough(new DecompressionStream("gzip")); + return new Uint8Array(await new Response(stream).arrayBuffer()); +} + +async function decodedGzipResponseBytes(response) { + const bytes = new Uint8Array(await response.arrayBuffer()); + return (response.headers.get("content-encoding") ?? "").toLowerCase().includes("gzip") + ? bytes + : decompressGzip(bytes); +} + +async function assertSha256(bytes, expected, label) { + if (!/^[a-f0-9]{64}$/.test(expected ?? "")) throw new Error(`Generated cssFlower ${label} hash is missing.`); + const digest = await crypto.subtle.digest("SHA-256", bytes); + const actual = [...new Uint8Array(digest)].map((value) => value.toString(16).padStart(2, "0")).join(""); + if (actual !== expected) throw new Error(`Generated cssFlower ${label} identity mismatch (${actual}).`); +} diff --git a/src/adapters/flowerbox/src/cssflower/polycssScene.mjs b/src/adapters/flowerbox/src/cssflower/polycssScene.mjs new file mode 100644 index 0000000..2c236e2 --- /dev/null +++ b/src/adapters/flowerbox/src/cssflower/polycssScene.mjs @@ -0,0 +1,140 @@ +import { collectPolyRenderStats } from "@layoutit/polycss"; + +let preparedStyle = null; + +export function mountPreparedPolycssSnapshot({ host, sceneData, snapshotHtml, preparedAssets }) { + if (!(host instanceof HTMLElement)) throw new Error("Missing cssFlower host."); + const doc = new DOMParser().parseFromString(snapshotHtml, "text/html"); + if (doc.querySelector("script, canvas, svg")) { + throw new Error("Prepared PolyCSS snapshot contains a forbidden runtime/render element."); + } + if ([...doc.querySelectorAll("*")].some((element) => + element.getAttributeNames().some((name) => name.startsWith("data-")))) { + throw new Error("Prepared PolyCSS snapshot contains forbidden data attributes."); + } + + const styleElement = doc.querySelector("style"); + const preparedLeafRuleCount = (styleElement?.textContent.match(/\.polycss-mesh>u\.[a-zA-Z]{1,2} \{/gu) ?? []).length; + const cameraElement = doc.body.firstElementChild; + const sceneElement = cameraElement?.firstElementChild; + const rootElement = sceneElement?.firstElementChild; + const meshElement = rootElement?.firstElementChild; + if (!(styleElement instanceof HTMLStyleElement) || !(cameraElement instanceof HTMLElement) || + !cameraElement.classList.contains("polycss-camera") || !(sceneElement instanceof HTMLElement) || + !sceneElement.classList.contains("polycss-scene") || !(rootElement instanceof HTMLElement) || + !(meshElement instanceof HTMLElement) || !meshElement.classList.contains("polycss-mesh") || + preparedLeafRuleCount !== 1200 || !preparedAssets?.lightingPages?.urlFor) { + throw new Error("Prepared cssFlower direct PolyCSS hierarchy is missing."); + } + + removePreparedSnapshotStyles(); + const importedStyle = document.importNode(styleElement, true); + document.head.appendChild(importedStyle); + preparedStyle = importedStyle; + const importedCamera = document.importNode(cameraElement, true); + const importedScene = importedCamera.firstElementChild; + const importedRoot = importedScene?.firstElementChild; + const importedMesh = importedRoot?.firstElementChild; + if (!(importedRoot instanceof HTMLElement) || !(importedMesh instanceof HTMLElement)) { + throw new Error("Prepared cssFlower retained root is missing."); + } + importedRoot.style.setProperty( + "--cssflower-space-texels", + `url("${preparedAssets.lightingPages.urlFor(0)}")`, + ); + host.replaceChildren(importedCamera); + + const camera = host.firstElementChild; + const scene = camera?.firstElementChild; + const rotationRoot = scene?.firstElementChild; + const mesh = rotationRoot?.firstElementChild; + if (!(camera instanceof HTMLElement) || !camera.classList.contains("polycss-camera") || + !(scene instanceof HTMLElement) || !scene.classList.contains("polycss-scene") || + !(rotationRoot instanceof HTMLElement) || !(mesh instanceof HTMLElement) || + !mesh.classList.contains("polycss-mesh")) { + throw new Error("Prepared cssFlower camera, scene, root, or mesh is missing."); + } + + const leaves = [...mesh.children]; + const triangleIds = sceneData.lighting.faces.map((face) => face.triangleId); + const retainedLeafClasses = leaves.map((leaf) => leaf.className); + if (leaves.length !== 1200 || triangleIds.length !== 1200 || new Set(triangleIds).size !== 1200 || + new Set(retainedLeafClasses).size !== 1200 || + scene.children.length !== 1 || rotationRoot.children.length !== 1) { + throw new Error(`Prepared cssFlower retained target count drifted (${leaves.length} leaves).`); + } + for (let index = 0; index < leaves.length; index += 1) { + const leaf = leaves[index]; + const face = sceneData.lighting.faces[index]; + if (!(leaf instanceof HTMLElement) || leaf.tagName !== "U" || + !/^[a-zA-Z]{1,2}$/u.test(leaf.className) || + leaf.style.length !== 0 || + !Number.isSafeInteger(face.leafWidth) || !Number.isSafeInteger(face.leafHeight) || + face.sourceOrder !== index || face.triangleId !== triangleIds[index]) { + throw new Error(`Prepared cssFlower retained leaf ${index} is not source-addressable by prepared order.`); + } + } + + const stableNodes = Object.freeze([camera, scene, rotationRoot, mesh, ...leaves]); + let runtimeDomCreationCount = 0; + let runtimeDomRemovalCount = 0; + const observer = new MutationObserver((records) => { + for (const record of records) { + runtimeDomCreationCount += record.addedNodes.length; + runtimeDomRemovalCount += record.removedNodes.length; + } + }); + observer.observe(host, { childList: true, subtree: true }); + + function assertStableDomIdentity() { + const currentCamera = host.firstElementChild; + const currentScene = currentCamera?.firstElementChild; + const currentRoot = currentScene?.firstElementChild; + const currentMesh = currentRoot?.firstElementChild; + const currentLeaves = currentMesh ? [...currentMesh.children] : []; + if (currentCamera !== stableNodes[0] || currentScene !== stableNodes[1] || + currentRoot !== stableNodes[2] || currentMesh !== stableNodes[3] || + currentLeaves.length !== leaves.length || + currentLeaves.some((leaf, index) => leaf !== stableNodes[index + 4])) { + throw new Error("Prepared cssFlower retained DOM identity changed."); + } + return true; + } + + return Object.freeze({ + camera, + scene, + mesh, + rotationRoot, + leaves: Object.freeze(leaves), + triangleIds: Object.freeze([...triangleIds]), + assertStableDomIdentity, + stats() { + assertStableDomIdentity(); + const polycss = collectPolyRenderStats(rotationRoot, { + polygonCount: sceneData.metrics.preparedLeafCount, + }); + return Object.freeze({ + mode: "prepared-snapshot", + retainedRotationRootCount: 1, + retainedTriangleLeafCount: leaves.length, + retainedTriangleIdCount: triangleIds.length, + runtimeDomCreationCount, + runtimeDomRemovalCount, + runtimeDomMutationCount: runtimeDomCreationCount + runtimeDomRemovalCount, + runtimeDomGrowth: false, + polycss, + }); + }, + destroy() { + observer.disconnect(); + host.replaceChildren(); + removePreparedSnapshotStyles(); + }, + }); +} + +function removePreparedSnapshotStyles() { + preparedStyle?.remove(); + preparedStyle = null; +} diff --git a/src/adapters/flowerbox/src/cssflower/preparedAssetLoaders.mjs b/src/adapters/flowerbox/src/cssflower/preparedAssetLoaders.mjs new file mode 100644 index 0000000..1bac73b --- /dev/null +++ b/src/adapters/flowerbox/src/cssflower/preparedAssetLoaders.mjs @@ -0,0 +1,423 @@ +import { + CSSFLOWER_LIGHTING_ATLAS_ENCODING, + CSSFLOWER_LIGHTING_ATLAS_MIME_TYPE, + CSSFLOWER_LIGHTING_ATLAS_QUALITY, + CSSFLOWER_LIGHTING_GRID_COLUMNS, + CSSFLOWER_LIGHTING_GRID_DECODED_BYTES, + CSSFLOWER_LIGHTING_GRID_HEIGHT, + CSSFLOWER_LIGHTING_GRID_ROWS, + CSSFLOWER_LIGHTING_GRID_WIDTH, + CSSFLOWER_LIGHTING_PAGE_COUNT, + CSSFLOWER_FRONT_FACE_DILATION_TICKS, + CSSFLOWER_FRONT_FACE_SCHEDULE_ENCODING, + CSSFLOWER_FRONT_FACE_SCHEDULE_SCHEMA, + CSSFLOWER_TRANSFORM_BLOCK_GEOMETRY_STATES, + CSSFLOWER_TRANSFORM_BLOCK_SCHEMA, + CSSFLOWER_VISIBILITY_POLICY, +} from "./renderContract.mjs"; + +export function validatePreparedMorphAssets(playback, lighting) { + const transforms = playback?.transformAsset; + const frontFacing = playback?.frontFacingSchedule; + if (frontFacing?.schema !== CSSFLOWER_FRONT_FACE_SCHEDULE_SCHEMA || + frontFacing.stateCount !== playback?.cycle?.stateCount || frontFacing.stateCount !== 360 || + frontFacing.faceCount !== 1_200 || + frontFacing.dilationTicks !== CSSFLOWER_FRONT_FACE_DILATION_TICKS || + frontFacing.selectionDomain !== CSSFLOWER_VISIBILITY_POLICY.selectionDomain || + frontFacing.depthComparison !== CSSFLOWER_VISIBILITY_POLICY.depthComparison || + frontFacing.minimumOwnedPixels !== CSSFLOWER_VISIBILITY_POLICY.minimumOwnedPixels || + frontFacing.sampleGrid !== CSSFLOWER_VISIBILITY_POLICY.sampleGrid || + frontFacing.adjacency !== CSSFLOWER_VISIBILITY_POLICY.adjacency || + frontFacing.adjacencyRings !== CSSFLOWER_VISIBILITY_POLICY.adjacencyRings || + frontFacing.dilationPolicy !== CSSFLOWER_VISIBILITY_POLICY.dilationPolicy || + frontFacing.encoding !== CSSFLOWER_FRONT_FACE_SCHEDULE_ENCODING || + frontFacing.bytesPerState !== Math.ceil(frontFacing.faceCount / 8) || + frontFacing.byteLength !== frontFacing.stateCount * frontFacing.bytesPerState || + !Number.isSafeInteger(frontFacing.selectedFaceCount) || frontFacing.selectedFaceCount < 1 || + frontFacing.selectedFaceCount >= frontFacing.stateCount * frontFacing.faceCount || + frontFacing.suppressedFaceCount !== frontFacing.stateCount * frontFacing.faceCount - frontFacing.selectedFaceCount || + frontFacing.meanSelectedFacesPerState !== frontFacing.selectedFaceCount / frontFacing.stateCount || + !Number.isSafeInteger(frontFacing.minimumSelectedFacesPerState) || frontFacing.minimumSelectedFacesPerState < 1 || + !Number.isSafeInteger(frontFacing.maximumSelectedFacesPerState) || + frontFacing.maximumSelectedFacesPerState > frontFacing.faceCount || + frontFacing.maximumSelectedFacesPerState < frontFacing.minimumSelectedFacesPerState || + frontFacing.initialVisibilitySelectionCount !== frontFacing.faceCount || + !Number.isSafeInteger(frontFacing.visibilityChangeCount) || frontFacing.visibilityChangeCount < 1 || + !/^[a-f0-9]{64}$/u.test(frontFacing.dataSha256 ?? "") || + typeof frontFacing.dataBase64 !== "string" || frontFacing.dataBase64.length < 1 || + frontFacing.runtimeSelection !== "prepared-bit-test-only-no-geometry-projection-normal-or-lighting-calculation") { + throw new Error("Complete prepared cssFlower owned-pixel visibility transform schedule is required"); + } + if (transforms?.schema !== CSSFLOWER_TRANSFORM_BLOCK_SCHEMA || + transforms.distribution !== "public-independent-prepared-transform-blocks" || + transforms.encoding !== "gzip-newline-utf8-geometry-state-major-triangle-major-matrix3d" || + transforms.componentCount !== 16 || transforms.triangleCount !== 1_200 || + transforms.geometryStateCount !== playback?.cycle?.geometryStateCount || + transforms.blockGeometryStateCount !== CSSFLOWER_TRANSFORM_BLOCK_GEOMETRY_STATES || + transforms.blockCount !== Math.ceil(transforms.geometryStateCount / transforms.blockGeometryStateCount) || + transforms.blocks?.length !== transforms.blockCount) { + throw new Error("Complete prepared cssFlower Morph transform blocks are required"); + } + let coveredGeometryStates = 0; + for (let blockIndex = 0; blockIndex < transforms.blocks.length; blockIndex += 1) { + const block = transforms.blocks[blockIndex]; + const expectedStateCount = Math.min( + transforms.blockGeometryStateCount, + transforms.geometryStateCount - coveredGeometryStates, + ); + if (block?.index !== blockIndex || block.startGeometryStateIndex !== coveredGeometryStates || + block.geometryStateCount !== expectedStateCount || block.triangleCount !== 1_200 || + block.transformCount !== expectedStateCount * 1_200 || + !validAssetDescriptor(block, "/cssflower/assets/transforms/")) { + throw new Error(`Prepared cssFlower transform block ${blockIndex} is invalid`); + } + coveredGeometryStates += expectedStateCount; + } + if (coveredGeometryStates !== transforms.geometryStateCount || + transforms.byteLength !== transforms.blocks.reduce((sum, block) => sum + block.byteLength, 0) || + transforms.decodedByteLength !== transforms.blocks.reduce((sum, block) => sum + block.decodedByteLength, 0)) { + throw new Error("Prepared cssFlower transform block coverage is incomplete"); + } + + if (lighting?.visualEncoding?.encoding !== CSSFLOWER_LIGHTING_ATLAS_ENCODING || + lighting.visualEncoding.mimeType !== CSSFLOWER_LIGHTING_ATLAS_MIME_TYPE || + lighting.visualEncoding.quality !== CSSFLOWER_LIGHTING_ATLAS_QUALITY || + lighting.visualEncoding.exactGeometry !== true || + lighting.visualEncoding.exactPreparedPixels !== false || + lighting.pageCount !== CSSFLOWER_LIGHTING_PAGE_COUNT || lighting.pages?.length !== lighting.pageCount || + lighting.grid?.schema !== "cssflower-prepared-leaf-lighting-grid@1" || + lighting.grid.encoding !== CSSFLOWER_LIGHTING_ATLAS_ENCODING || + lighting.grid.mimeType !== CSSFLOWER_LIGHTING_ATLAS_MIME_TYPE || + lighting.grid.quality !== CSSFLOWER_LIGHTING_ATLAS_QUALITY || + lighting.grid.columns !== CSSFLOWER_LIGHTING_GRID_COLUMNS || + lighting.grid.rows !== CSSFLOWER_LIGHTING_GRID_ROWS || + lighting.grid.width !== CSSFLOWER_LIGHTING_GRID_WIDTH || + lighting.grid.height !== CSSFLOWER_LIGHTING_GRID_HEIGHT || + lighting.grid.decodedBytes !== CSSFLOWER_LIGHTING_GRID_DECODED_BYTES || + !validAssetDescriptor(lighting.grid, "/cssflower/assets/lighting/grid-")) { + throw new Error("Complete prepared cssFlower leaf-lighting grid is required"); + } + let coveredTimelineStates = 0; + for (let pageIndex = 0; pageIndex < lighting.pages.length; pageIndex += 1) { + const page = lighting.pages[pageIndex]; + const expectedRows = Math.min(lighting.pageRowCount, lighting.timelineRowCount - coveredTimelineStates); + if (page?.index !== pageIndex || page.startStateIndex !== coveredTimelineStates || + page.usedRowCount !== expectedRows || page.rowCount !== lighting.pageRowCount || + page.width !== lighting.atlasWidth || page.height !== lighting.atlasHeight || + page.decodedBytes !== page.width * page.height * 4 || + page.gridColumn !== pageIndex || page.gridRow !== 0 || + page.gridOffsetX !== pageIndex * lighting.atlasWidth || page.gridOffsetY !== 0 || + page.sourceEncoding !== "PNG-RGB8" || + !Number.isSafeInteger(page.sourcePngByteLength) || page.sourcePngByteLength < 1 || + !/^[a-f0-9]{64}$/u.test(page.sourcePngSha256 ?? "")) { + throw new Error(`Prepared cssFlower lighting page ${pageIndex} is invalid`); + } + coveredTimelineStates += expectedRows; + } + if (coveredTimelineStates !== lighting.timelineRowCount) { + throw new Error("Prepared cssFlower lighting page coverage is incomplete"); + } + if (playback.cycle.states.some((state) => { + const expectedBlockIndex = Math.floor( + state.geometryStateIndex / transforms.blockGeometryStateCount, + ); + const nextBlockIndex = Math.floor( + state.nextTransformBlockGeometryStateIndex / transforms.blockGeometryStateCount, + ); + return state.transformBlockIndex !== expectedBlockIndex || + !Number.isSafeInteger(state.nextTransformBlockGeometryStateIndex) || + state.nextTransformBlockGeometryStateIndex < 0 || + state.nextTransformBlockGeometryStateIndex >= transforms.geometryStateCount || + nextBlockIndex === expectedBlockIndex || + !Number.isSafeInteger(state.lightingPageIndex) || state.lightingPageIndex < 0 || + state.lightingPageIndex >= lighting.pageCount || + !Number.isSafeInteger(state.lightingPageRowIndex) || state.lightingPageRowIndex < 0 || + state.lightingPageRowIndex >= lighting.pageRowCount || + !Number.isSafeInteger(state.nextLightingPageIndex) || state.nextLightingPageIndex < 0 || + state.nextLightingPageIndex >= lighting.pageCount || + state.nextLightingPageIndex === state.lightingPageIndex; + })) { + throw new Error("Prepared cssFlower asset prefetch schedule is incomplete"); + } +} + +export function createPreparedTransformBlockLoader(transforms) { + const records = new Map(); + const errors = []; + let currentBlockIndex = 0; + let loadCount = 0; + let releaseCount = 0; + let residentDecodedBytes = 0; + let peakResidentDecodedBytes = 0; + let desiredBlockIndices = new Set(); + let destroyed = false; + + async function ensure(blockIndex) { + if (destroyed) throw new Error("Prepared cssFlower transform loader is destroyed"); + const block = transforms.blocks[blockIndex]; + if (!block || block.index !== blockIndex) throw new RangeError(`Prepared transform block ${blockIndex} is missing`); + const existing = records.get(blockIndex); + if (existing?.transforms) return existing; + if (existing?.promise) return existing.promise; + const promise = (async () => { + const encoded = new Uint8Array(await fetchBytes(block.assetUrl)); + if (encoded.byteLength !== block.byteLength) throw new Error(`Prepared transform block ${blockIndex} byte length drifted`); + await assertSha256(encoded, block.sha256, `transform block ${blockIndex}`); + const decoded = await decompressGzip(encoded); + if (decoded.byteLength !== block.decodedByteLength) throw new Error(`Prepared transform block ${blockIndex} decoded length drifted`); + await assertSha256(decoded, block.decodedSha256, `decoded transform block ${blockIndex}`); + const text = new TextDecoder().decode(decoded); + if (!text.endsWith("\n")) throw new Error(`Prepared transform block ${blockIndex} is not newline terminated`); + const rows = text.slice(0, -1).split("\n"); + if (rows.length !== block.transformCount || rows.some((row) => !/^matrix3d\([^)]+\)$/u.test(row))) { + throw new Error(`Prepared transform block ${blockIndex} rows are invalid`); + } + const record = Object.freeze({ blockIndex, transforms: Object.freeze(rows) }); + records.set(blockIndex, record); + loadCount += 1; + residentDecodedBytes += block.decodedByteLength; + peakResidentDecodedBytes = Math.max(peakResidentDecodedBytes, residentDecodedBytes); + if (destroyed || !desiredBlockIndices.has(blockIndex)) release(blockIndex, record); + return record; + })(); + records.set(blockIndex, { promise }); + try { + return await promise; + } catch (error) { + if (records.get(blockIndex)?.promise === promise) records.delete(blockIndex); + errors.push(String(error?.message || error)); + throw error; + } + } + + function release(blockIndex, record) { + if (!record?.transforms || records.get(blockIndex) !== record) return; + records.delete(blockIndex); + residentDecodedBytes -= transforms.blocks[blockIndex].decodedByteLength; + releaseCount += 1; + } + + function releaseExcept(indices) { + desiredBlockIndices = new Set(indices); + for (const [blockIndex, record] of records) { + if (!indices.has(blockIndex)) release(blockIndex, record); + } + } + + function blockIndexForGeometryState(geometryStateIndex) { + if (!Number.isSafeInteger(geometryStateIndex) || geometryStateIndex < 0 || + geometryStateIndex >= transforms.geometryStateCount) { + throw new RangeError(`Prepared geometry state ${geometryStateIndex} is invalid`); + } + return Math.floor(geometryStateIndex / transforms.blockGeometryStateCount); + } + + function prefetch(blockIndex) { + if (records.get(blockIndex)?.transforms) return; + void ensure(blockIndex).catch(() => undefined); + } + + return Object.freeze({ + async prime(geometryStateIndex, nextGeometryStateIndex) { + const current = blockIndexForGeometryState(geometryStateIndex); + const next = blockIndexForGeometryState(nextGeometryStateIndex); + currentBlockIndex = current; + releaseExcept(new Set([current, next])); + await Promise.all([...new Set([current, next])].map(ensure)); + }, + async activate(geometryStateIndex, nextGeometryStateIndex) { + const target = blockIndexForGeometryState(geometryStateIndex); + const next = blockIndexForGeometryState(nextGeometryStateIndex); + releaseExcept(new Set([currentBlockIndex, target, next])); + const record = await ensure(target); + currentBlockIndex = target; + prefetch(next); + return record; + }, + forEachTransform(geometryStateIndex, visit) { + if (typeof visit !== "function") throw new TypeError("Prepared transform visitor is required"); + const blockIndex = blockIndexForGeometryState(geometryStateIndex); + const record = records.get(blockIndex); + if (!record?.transforms) throw new Error(`Prepared transform block ${blockIndex} is not decoded`); + const localState = geometryStateIndex - transforms.blocks[blockIndex].startGeometryStateIndex; + const start = localState * transforms.triangleCount; + for (let leafIndex = 0; leafIndex < transforms.triangleCount; leafIndex += 1) { + visit(record.transforms[start + leafIndex], leafIndex); + } + }, + transformAt(geometryStateIndex, leafIndex) { + if (!Number.isSafeInteger(leafIndex) || leafIndex < 0 || leafIndex >= transforms.triangleCount) { + throw new RangeError(`Prepared transform leaf ${leafIndex} is invalid`); + } + const blockIndex = blockIndexForGeometryState(geometryStateIndex); + const record = records.get(blockIndex); + if (!record?.transforms) throw new Error(`Prepared transform block ${blockIndex} is not decoded`); + const localState = geometryStateIndex - transforms.blocks[blockIndex].startGeometryStateIndex; + return record.transforms[localState * transforms.triangleCount + leafIndex]; + }, + commitPresented(geometryStateIndex, nextGeometryStateIndex) { + const current = blockIndexForGeometryState(geometryStateIndex); + const next = blockIndexForGeometryState(nextGeometryStateIndex); + releaseExcept(new Set([current, next])); + prefetch(next); + }, + stats() { + return Object.freeze({ + schema: "cssflower-prepared-transform-block-loader@1", + currentBlockIndex, + loadCount, + releaseCount, + residentBlockCount: [...records.values()].filter((record) => record?.transforms).length, + residentDecodedBytes, + peakResidentDecodedBytes, + desiredBlockIndices: Object.freeze([...desiredBlockIndices].sort((a, b) => a - b)), + errors: Object.freeze([...errors]), + }); + }, + destroy() { + destroyed = true; + releaseExcept(new Set()); + }, + }); +} + +export function createPreparedLightingPageLoader(lighting) { + const errors = []; + let record = null; + let promise = null; + let currentPageIndex = 0; + let loadCount = 0; + let releaseCount = 0; + let destroyed = false; + + async function ensure() { + if (destroyed) throw new Error("Prepared cssFlower lighting loader is destroyed"); + if (record?.url) return record; + if (promise) return promise; + promise = (async () => { + const bytes = new Uint8Array(await fetchBytes(lighting.grid.assetUrl)); + if (bytes.byteLength !== lighting.grid.byteLength) throw new Error("Prepared lighting grid byte length drifted"); + await assertSha256(bytes, lighting.grid.sha256, "lighting grid"); + const url = URL.createObjectURL(new Blob([bytes], { type: lighting.grid.mimeType })); + let image; + try { + image = await decodeImage(url); + } catch (error) { + URL.revokeObjectURL(url); + throw error; + } + if (image.naturalWidth !== lighting.grid.width || image.naturalHeight !== lighting.grid.height) { + image.removeAttribute("src"); + URL.revokeObjectURL(url); + throw new Error(`Prepared lighting grid dimensions drifted (${image.naturalWidth}x${image.naturalHeight})`); + } + record = Object.freeze({ gridIndex: 0, url, image }); + loadCount += 1; + if (destroyed) release(); + return record; + })(); + try { + return await promise; + } catch (error) { + errors.push(String(error?.message || error)); + throw error; + } finally { + promise = null; + } + } + + function release() { + if (!record?.url) return; + record.image.removeAttribute("src"); + URL.revokeObjectURL(record.url); + record = null; + releaseCount += 1; + } + + function assertPageIndex(pageIndex) { + if (!Number.isSafeInteger(pageIndex) || pageIndex < 0 || pageIndex >= lighting.pageCount) { + throw new RangeError(`Prepared lighting page ${pageIndex} is missing`); + } + } + + return Object.freeze({ + async prime(pageIndex, nextPageIndex) { + assertPageIndex(pageIndex); + assertPageIndex(nextPageIndex); + currentPageIndex = pageIndex; + await ensure(); + }, + urlFor(pageIndex) { + assertPageIndex(pageIndex); + if (!record?.url) throw new Error(`Prepared lighting page ${pageIndex} is not decoded`); + return record.url; + }, + async activate(pageIndex, nextPageIndex) { + assertPageIndex(pageIndex); + assertPageIndex(nextPageIndex); + await ensure(); + currentPageIndex = pageIndex; + return record; + }, + commitPresented(pageIndex, nextPageIndex) { + assertPageIndex(pageIndex); + assertPageIndex(nextPageIndex); + if (pageIndex !== currentPageIndex || !record?.url) { + throw new Error(`Prepared lighting page ${pageIndex} cannot be committed before presentation`); + } + }, + stats() { + return Object.freeze({ + schema: "cssflower-prepared-lighting-grid-loader@1", + currentPageIndex, + loadCount, + releaseCount, + residentGridCount: record?.url ? 1 : 0, + residentDecodedBytes: record?.url ? lighting.grid.decodedBytes : 0, + peakResidentDecodedBytes: loadCount > 0 ? lighting.grid.decodedBytes : 0, + errors: Object.freeze([...errors]), + }); + }, + destroy() { + destroyed = true; + release(); + }, + }); +} + +function validAssetDescriptor(value, prefix) { + return typeof value?.assetUrl === "string" && value.assetUrl.startsWith(prefix) && + Number.isSafeInteger(value.byteLength) && value.byteLength > 0 && + /^[a-f0-9]{64}$/u.test(value.sha256 ?? "") && + (!Object.hasOwn(value, "decodedByteLength") || + Number.isSafeInteger(value.decodedByteLength) && value.decodedByteLength > 0 && + /^[a-f0-9]{64}$/u.test(value.decodedSha256 ?? "")); +} + +async function fetchBytes(url) { + const response = await fetch(url); + if (!response.ok) throw new Error(`Failed to load ${url}: ${response.status}`); + return response.arrayBuffer(); +} + +async function decompressGzip(bytes) { + if (typeof DecompressionStream !== "function") { + throw new Error("This browser cannot decode prepared cssFlower gzip assets"); + } + const stream = new Blob([bytes]).stream().pipeThrough(new DecompressionStream("gzip")); + return new Uint8Array(await new Response(stream).arrayBuffer()); +} + +async function assertSha256(bytes, expected, label) { + const digest = await crypto.subtle.digest("SHA-256", bytes); + const actual = [...new Uint8Array(digest)].map((value) => value.toString(16).padStart(2, "0")).join(""); + if (actual !== expected) throw new Error(`Generated cssFlower ${label} identity mismatch (${actual})`); +} + +async function decodeImage(url) { + const image = new Image(); + image.decoding = "async"; + image.src = url; + await image.decode(); + return image; +} diff --git a/src/adapters/flowerbox/src/cssflower/preparedPlayback.mjs b/src/adapters/flowerbox/src/cssflower/preparedPlayback.mjs new file mode 100644 index 0000000..8f2552f --- /dev/null +++ b/src/adapters/flowerbox/src/cssflower/preparedPlayback.mjs @@ -0,0 +1,504 @@ +import { createPolyMorphPreparedDomTarget } from "@layoutit/polycss-morph"; +import { + CSSFLOWER_FRONT_FACE_DILATION_TICKS, + CSSFLOWER_FRONT_FACE_SCHEDULE_ENCODING, + CSSFLOWER_FRONT_FACE_SCHEDULE_SCHEMA, + CSSFLOWER_VISIBILITY_POLICY, +} from "./renderContract.mjs"; + +export function timelineStateIndexForTick(tick, cycle) { + if (!Number.isSafeInteger(tick) || tick < 0) throw new RangeError("cssFlower tick must be a non-negative safe integer"); + if (tick < cycle.stateCount) return tick; + return cycle.cycleStartState + ((tick - cycle.cycleStartState) % cycle.cycleLength); +} + +export async function createCssflowerPreparedPlayer(options) { + const { + lighting, + lightingPages, + mesh, + playback, + rotationRoot, + transformBlocks, + } = options; + const leaves = [...options.leaves]; + validatePlayback({ playback, lighting, transformBlocks, lightingPages, rotationRoot, mesh, leaves }); + const requestFrame = options.requestFrame ?? globalThis.requestAnimationFrame.bind(globalThis); + const cancelFrame = options.cancelFrame ?? globalThis.cancelAnimationFrame.bind(globalThis); + const frameMilliseconds = 1000 / playback.sourceTicksPerSecond; + let paused = true; + let destroyed = false; + let request = null; + let nextFrameAt = null; + let globalTick = 0; + let timelineStateIndex = -1; + let geometryStateIndex = -1; + let rootStateIndex = -1; + let lightingPageIndex = -1; + let lightingPageRowIndex = -1; + let preparedStatesApplied = 0; + let preparedGeometryStatePublishes = 0; + let modelTransformWrites = 0; + let leafTransformWrites = 0; + let selectedLeafTransformAttempts = 0; + let leafTransformSelectionTests = 0; + let suppressedLeafTransformWrites = 0; + let visibilityCatchupTransformAttempts = 0; + let visibilityCatchupTransformWrites = 0; + let leafVisibilityWrites = 0; + let preparedFrontFacingStateSelections = 0; + let presentedFrontFacingStateIndex = -1; + let currentFrontFacingLeafCount = 0; + let lightingAtlasWrites = 0; + let lightingColumnWrites = 0; + let lightingRowWrites = 0; + let preparedLightingAddressWrites = 0; + let preparedLightingStateSelections = 0; + let preparedLightingSkippedStateSelections = 0; + let runtimeSchedulerCallbacks = 0; + const lightingAddressSchedule = decodePreparedLightingAddressSchedule(lighting.addressSchedule); + const frontFacingSchedule = decodePreparedFrontFacingSchedule(playback.frontFacingSchedule); + const selectedFrontFacingByFace = new Uint8Array(leaves.length); + selectedFrontFacingByFace.fill(255); + const newlySelectedFaces = []; + const selectedLightingStateByFace = new Uint16Array(leaves.length); + const pendingLightingStateByFace = new Int16Array(leaves.length); + pendingLightingStateByFace.fill(-1); + const pendingLightingFaces = []; + let presentedLightingStateIndex = -1; + + const morphTarget = createPolyMorphPreparedDomTarget({ + model: { + element: rotationRoot, + writeTransform(transform) { + if (rotationRoot.style.transform === transform) return false; + rotationRoot.style.transform = transform; + return true; + }, + }, + shapes: [{ element: mesh }], + leaves: leaves.map((element) => ({ element })), + }); + + function applyPreparedFrontFacingSelection(nextStateIndex) { + newlySelectedFaces.length = 0; + if (presentedFrontFacingStateIndex === nextStateIndex) return; + const stateOffset = nextStateIndex * frontFacingSchedule.bytesPerState; + let selectedCount = 0; + for (let faceIndex = 0; faceIndex < leaves.length; faceIndex += 1) { + const selected = ( + frontFacingSchedule.bytes[stateOffset + (faceIndex >> 3)] & (1 << (faceIndex & 7)) + ) !== 0; + selectedCount += Number(selected); + const selectedByte = Number(selected); + if (selectedFrontFacingByFace[faceIndex] === selectedByte) continue; + if (selected && selectedFrontFacingByFace[faceIndex] === 0) newlySelectedFaces.push(faceIndex); + if (morphTarget.leaves[faceIndex].writeVisibility(selected)) leafVisibilityWrites += 1; + selectedFrontFacingByFace[faceIndex] = selectedByte; + } + currentFrontFacingLeafCount = selectedCount; + presentedFrontFacingStateIndex = nextStateIndex; + preparedFrontFacingStateSelections += 1; + } + + function isPreparedFrontFacing(faceIndex) { + return selectedFrontFacingByFace[faceIndex] === 1; + } + + function applyPreparedLightingAddress(faceIndex, stateIndex) { + if (selectedLightingStateByFace[faceIndex] === stateIndex) return; + const addressState = playback.cycle.states[stateIndex]; + const face = lighting.faces[faceIndex]; + const x = -(addressState.lightingPageIndex * lighting.atlasWidth + face.contentX); + const y = -(addressState.lightingPageRowIndex * lighting.stateSliceHeight + face.contentY); + leaves[faceIndex].style.backgroundPosition = `${x}px ${y}px`; + selectedLightingStateByFace[faceIndex] = stateIndex; + preparedLightingAddressWrites += 1; + } + + function applyPreparedLightingAddresses(nextStateIndex) { + if (presentedLightingStateIndex === -1) { + if (nextStateIndex !== playback.cycle.initialState) { + throw new Error("Prepared cssFlower lighting addresses must initialize at the prepared initial state"); + } + presentedLightingStateIndex = nextStateIndex; + return; + } + if (nextStateIndex === presentedLightingStateIndex) return; + const advance = ( + nextStateIndex - presentedLightingStateIndex + lightingAddressSchedule.stateCount + ) % lightingAddressSchedule.stateCount; + if (advance > 1) preparedLightingSkippedStateSelections += advance - 1; + if (advance === 1) { + const start = lightingAddressSchedule.offsets[nextStateIndex]; + const end = lightingAddressSchedule.offsets[nextStateIndex + 1]; + for (let update = start; update < end; update += 1) { + applyPreparedLightingAddress(lightingAddressSchedule.faceIndices[update], nextStateIndex); + } + presentedLightingStateIndex = nextStateIndex; + preparedLightingStateSelections += 1; + return; + } + pendingLightingFaces.length = 0; + for (let offset = 1; offset <= advance; offset += 1) { + const stateIndex = (presentedLightingStateIndex + offset) % lightingAddressSchedule.stateCount; + const start = lightingAddressSchedule.offsets[stateIndex]; + const end = lightingAddressSchedule.offsets[stateIndex + 1]; + for (let update = start; update < end; update += 1) { + const faceIndex = lightingAddressSchedule.faceIndices[update]; + if (pendingLightingStateByFace[faceIndex] < 0) pendingLightingFaces.push(faceIndex); + pendingLightingStateByFace[faceIndex] = stateIndex; + } + } + for (const faceIndex of pendingLightingFaces) { + const stateIndex = pendingLightingStateByFace[faceIndex]; + pendingLightingStateByFace[faceIndex] = -1; + applyPreparedLightingAddress(faceIndex, stateIndex); + } + presentedLightingStateIndex = nextStateIndex; + preparedLightingStateSelections += 1; + } + + async function applyTick(tick) { + if (destroyed) throw new Error("Prepared cssFlower player is destroyed"); + if (!Number.isSafeInteger(tick) || tick < 0) throw new RangeError("cssFlower tick must be a non-negative safe integer"); + const nextTimelineStateIndex = timelineStateIndexForTick(tick, playback.cycle); + const state = playback.cycle.states[nextTimelineStateIndex]; + if (!state) throw new Error(`Prepared cssFlower timeline state ${nextTimelineStateIndex} is missing`); + const geometryChanged = state.geometryStateIndex !== geometryStateIndex; + const lightingPageChanged = state.lightingPageIndex !== lightingPageIndex; + applyPreparedFrontFacingSelection(nextTimelineStateIndex); + + if (geometryChanged) { + await transformBlocks.activate( + state.geometryStateIndex, + state.nextTransformBlockGeometryStateIndex, + ); + transformBlocks.forEachTransform(state.geometryStateIndex, (transform, leafIndex) => { + leafTransformSelectionTests += 1; + if (!isPreparedFrontFacing(leafIndex)) { + suppressedLeafTransformWrites += 1; + return; + } + selectedLeafTransformAttempts += 1; + if (morphTarget.leaves[leafIndex].writeTransform(transform)) leafTransformWrites += 1; + }); + preparedGeometryStatePublishes += 1; + transformBlocks.commitPresented( + state.geometryStateIndex, + state.nextTransformBlockGeometryStateIndex, + ); + } else if (newlySelectedFaces.length > 0) { + for (const leafIndex of newlySelectedFaces) { + visibilityCatchupTransformAttempts += 1; + const transform = transformBlocks.transformAt(state.geometryStateIndex, leafIndex); + if (morphTarget.leaves[leafIndex].writeTransform(transform)) { + leafTransformWrites += 1; + visibilityCatchupTransformWrites += 1; + } + } + } + + if (lightingPageChanged) { + await lightingPages.activate( + state.lightingPageIndex, + state.nextLightingPageIndex, + ); + lightingPages.commitPresented(state.lightingPageIndex, state.nextLightingPageIndex); + } + applyPreparedLightingAddresses(nextTimelineStateIndex); + if (morphTarget.model.writeTransform(playback.cycle.rootTransforms[state.rootStateIndex])) { + modelTransformWrites += 1; + } + + globalTick = tick; + timelineStateIndex = nextTimelineStateIndex; + geometryStateIndex = state.geometryStateIndex; + rootStateIndex = state.rootStateIndex; + lightingPageIndex = state.lightingPageIndex; + lightingPageRowIndex = state.lightingPageRowIndex; + preparedStatesApplied += 1; + morphTarget.assertStableDomIdentity(); + return globalTick; + } + + async function loop(timestamp) { + request = null; + runtimeSchedulerCallbacks += 1; + if (paused) return; + if (nextFrameAt === null) { + nextFrameAt = timestamp + frameMilliseconds; + } else if (timestamp >= nextFrameAt - 0.5) { + const elapsedSteps = Math.max(1, Math.floor((timestamp - nextFrameAt) / frameMilliseconds) + 1); + await applyTick(globalTick + elapsedSteps); + nextFrameAt += elapsedSteps * frameMilliseconds; + } + if (!paused) request = requestFrame(loop); + } + + function pause() { + paused = true; + nextFrameAt = null; + if (request !== null) cancelFrame(request); + request = null; + return globalTick; + } + + await applyTick(0); + return Object.freeze({ + get tick() { return globalTick; }, + get paused() { return paused; }, + pause, + resume() { + if (!paused) return globalTick; + paused = false; + nextFrameAt = null; + request = requestFrame(loop); + return globalTick; + }, + async step(count = 1) { + pause(); + const amount = Math.trunc(Number(count)); + if (!Number.isSafeInteger(amount) || amount < 1) throw new RangeError("cssFlower step count must be a positive integer"); + return applyTick(globalTick + amount); + }, + async setTick(value) { + pause(); + const tick = Math.trunc(Number(value)); + return applyTick(tick); + }, + assertStableDomIdentity() { + morphTarget.assertStableDomIdentity(); + return true; + }, + sample() { + return Object.freeze({ + globalTick, + timelineStateIndex, + geometryStateIndex, + rootStateIndex, + lightingPageIndex, + lightingPageRowIndex, + }); + }, + stats() { + morphTarget.assertStableDomIdentity(); + const state = playback.cycle.states[timelineStateIndex]; + return Object.freeze({ + schema: "cssflower-prepared-player-stats@1", + morphTarget: "@layoutit/polycss-morph#createPolyMorphPreparedDomTarget", + morphAdopted: true, + morphStableDomIdentity: true, + paused, + globalTick, + timelineStateIndex, + geometryStateIndex, + transformBlockIndex: state.transformBlockIndex, + rootStateIndex, + lightingPageIndex, + lightingPageRowIndex, + sourceSf: state.sf, + sourceSfHex: state.sfHex, + sourceSfi: state.sfi, + sourceSfiHex: state.sfiHex, + sourceRotationDegrees: [state.rotationXDegrees, state.rotationYDegrees, state.rotationZDegrees], + sourceRotationIncrementDegrees: [3, 2, 0], + retainedTriangleLeafCount: leaves.length, + retainedRotationRootCount: 1, + retainedPreparedShapeCount: 1, + preparedTimelineStateCount: playback.cycle.stateCount, + preparedGeometryStateCount: playback.cycle.geometryStateCount, + preparedRootStateCount: playback.cycle.rootStateCount, + preparedTransformBlockCount: playback.transformAsset.blockCount, + preparedLightingPageCount: lighting.pageCount, + preparedStatesApplied, + runtimePreparedGeometryStatePublishes: preparedGeometryStatePublishes, + runtimeModelTransformWrites: modelTransformWrites, + runtimeShapeTransformWrites: 0, + runtimeLeafTransformWrites: leafTransformWrites, + runtimeSelectedLeafTransformAttempts: selectedLeafTransformAttempts, + runtimeLeafTransformSelectionTests: leafTransformSelectionTests, + runtimeSuppressedLeafTransformWrites: suppressedLeafTransformWrites, + runtimeVisibilityCatchupTransformAttempts: visibilityCatchupTransformAttempts, + runtimeVisibilityCatchupTransformWrites: visibilityCatchupTransformWrites, + runtimeLeafVisibilityWrites: leafVisibilityWrites, + runtimePreparedFrontFacingStateSelections: preparedFrontFacingStateSelections, + preparedFrontFacingDilationTicks: frontFacingSchedule.dilationTicks, + preparedFrontFacingSelectedFaceCount: frontFacingSchedule.selectedFaceCount, + preparedFrontFacingVisibilityChangeCount: frontFacingSchedule.visibilityChangeCount, + preparedVisibilitySelectionDomain: frontFacingSchedule.selectionDomain, + preparedVisibilityMinimumOwnedPixels: frontFacingSchedule.minimumOwnedPixels, + preparedVisibilitySampleGrid: frontFacingSchedule.sampleGrid, + preparedVisibilityAdjacencyRings: frontFacingSchedule.adjacencyRings, + currentFrontFacingLeafCount, + runtimeLightingAtlasWrites: lightingAtlasWrites, + runtimeLightingColumnWrites: lightingColumnWrites, + runtimeLightingRowWrites: lightingRowWrites, + runtimePreparedLightingAddressWrites: preparedLightingAddressWrites, + runtimePreparedLightingStateSelections: preparedLightingStateSelections, + runtimePreparedLightingSkippedStateSelections: preparedLightingSkippedStateSelections, + runtimeDirectLeafCssTextWrites: 0, + runtimeProjectedFrameWrites: 0, + runtimeProjectedAtlasWrites: 0, + runtimePreparedPageLayoutAdoptions: 0, + runtimePreparedPageBoundaryLeafStyleWrites: 0, + transformBlockLoader: transformBlocks.stats(), + lightingPageLoader: lightingPages.stats(), + pendingLightingCommitCount: 0, + runtimeSchedulerCallbacks, + runtimePolygonConstructionCount: 0, + runtimeGeometryConstructionCount: 0, + runtimeRadialProjectionCount: 0, + runtimeProjectionCalculationCount: 0, + runtimeRasterizationCount: 0, + runtimeNormalCalculationCount: 0, + runtimeLightingCalculationCount: 0, + runtimeAtlasConstructionCount: 0, + runtimeDomCreationCount: 0, + runtimeDomRemovalCount: 0, + runtimeDomGrowth: false, + }); + }, + nodes() { + return Object.freeze({ rotationRoot, mesh, leaves: Object.freeze([...leaves]) }); + }, + destroy() { + if (destroyed) return; + destroyed = true; + pause(); + morphTarget.destroy(); + }, + }); +} + +function validatePlayback({ playback, lighting, transformBlocks, lightingPages, rotationRoot, mesh, leaves }) { + if (playback?.schema !== "cssflower-prepared-playback@1" || + playback.target !== "createPolyMorphPreparedDomTarget" || + playback.scope !== "rounded-product-cycle-spike-phase-omitted" || + playback.sourceTicksPerSecond !== 30 || + playback.cycle?.schema !== "cssflower-prepared-rounded-product-cycle@1" || + playback.cycle?.stateCount !== 360 || + playback.cycle?.cycleStartState !== 0 || + playback.cycle?.cycleLength !== 360 || + playback.cycle?.bloomTraceStateCount !== 90 || + playback.cycle?.bloomCycleLength !== 90 || + playback.cycle?.bloomPeakGeometryStateIndex !== 45 || + playback.cycle?.bloomPeakSfHex !== "400ffffc" || + playback.cycle?.bloomPeakSfNominal !== 2.25 || + playback.cycle?.omittedSourceSfAtOrAbove !== 2.5 || + playback.cycle?.geometryStateCount !== 46 || + playback.cycle?.rootStateCount !== 360 || + playback.cycle?.states?.length !== 360 || + playback.cycle?.rootTransforms?.length !== 360 || + playback.cycle.states.some((state) => + !Number.isSafeInteger(state.rootStateIndex) || state.rootStateIndex < 0 || state.rootStateIndex >= 360 || + !Number.isSafeInteger(state.geometryStateIndex) || state.geometryStateIndex < 0 || + state.geometryStateIndex >= playback.cycle.geometryStateCount || + !Number.isSafeInteger(state.transformBlockIndex) || + !Number.isSafeInteger(state.nextTransformBlockGeometryStateIndex) || + !Number.isSafeInteger(state.lightingPageIndex) || + !Number.isSafeInteger(state.lightingPageRowIndex) || + !Number.isSafeInteger(state.nextLightingPageIndex)) || + lighting?.backgroundPositionXs?.length !== lighting?.pageCount || + lighting?.backgroundPositionYs?.length !== lighting?.pageRowCount || + !transformBlocks?.activate || !transformBlocks?.forEachTransform || + !transformBlocks?.commitPresented || !transformBlocks?.stats || + !lightingPages?.activate || !lightingPages?.commitPresented || + !lightingPages?.urlFor || !lightingPages?.stats || + !(rotationRoot instanceof HTMLElement) || !(mesh instanceof HTMLElement) || + leaves.length !== 1_200) { + throw new Error("Complete prepared cssFlower retained PolyCSS Morph playback is required"); + } +} + +function decodePreparedLightingAddressSchedule(schedule) { + if (schedule?.schema !== "cssflower-prepared-exact-sparse-lighting-address-schedule@1" || + schedule.stateCount !== 360 || schedule.faceCount !== 1_200 || schedule.threshold !== 0 || + schedule.faceIndicesEncoding !== "base64-u16le-state-major-updated-face-indices" || + schedule.offsets?.length !== 361 || schedule.offsets[0] !== 0 || + schedule.offsets.at(-1) !== schedule.updateCount || + !schedule.offsets.every((value, index) => Number.isSafeInteger(value) && value >= 0 && + (index === 0 || value >= schedule.offsets[index - 1])) || + typeof schedule.faceIndicesBase64 !== "string") { + throw new Error("Prepared cssFlower exact sparse-lighting schedule is invalid"); + } + const encoded = atob(schedule.faceIndicesBase64); + if (encoded.length !== schedule.faceIndicesByteLength || encoded.length !== schedule.updateCount * 2) { + throw new Error("Prepared cssFlower sparse-lighting schedule byte length drifted"); + } + const faceIndices = new Uint16Array(schedule.updateCount); + for (let index = 0; index < faceIndices.length; index += 1) { + faceIndices[index] = encoded.charCodeAt(index * 2) | (encoded.charCodeAt(index * 2 + 1) << 8); + if (faceIndices[index] >= schedule.faceCount) { + throw new Error(`Prepared cssFlower sparse-lighting face ${index} is out of range`); + } + } + return Object.freeze({ + stateCount: schedule.stateCount, + faceCount: schedule.faceCount, + offsets: Object.freeze([...schedule.offsets]), + faceIndices, + }); +} + +function decodePreparedFrontFacingSchedule(schedule) { + if (schedule?.schema !== CSSFLOWER_FRONT_FACE_SCHEDULE_SCHEMA || + schedule.stateCount !== 360 || schedule.faceCount !== 1_200 || + schedule.dilationTicks !== CSSFLOWER_FRONT_FACE_DILATION_TICKS || + schedule.selectionDomain !== CSSFLOWER_VISIBILITY_POLICY.selectionDomain || + schedule.depthComparison !== CSSFLOWER_VISIBILITY_POLICY.depthComparison || + schedule.minimumOwnedPixels !== CSSFLOWER_VISIBILITY_POLICY.minimumOwnedPixels || + schedule.sampleGrid !== CSSFLOWER_VISIBILITY_POLICY.sampleGrid || + schedule.adjacency !== CSSFLOWER_VISIBILITY_POLICY.adjacency || + schedule.adjacencyRings !== CSSFLOWER_VISIBILITY_POLICY.adjacencyRings || + schedule.dilationPolicy !== CSSFLOWER_VISIBILITY_POLICY.dilationPolicy || + schedule.encoding !== CSSFLOWER_FRONT_FACE_SCHEDULE_ENCODING || + schedule.bytesPerState !== Math.ceil(schedule.faceCount / 8) || + schedule.byteLength !== schedule.stateCount * schedule.bytesPerState || + !Number.isSafeInteger(schedule.selectedFaceCount) || schedule.selectedFaceCount < 1 || + schedule.suppressedFaceCount !== schedule.stateCount * schedule.faceCount - schedule.selectedFaceCount || + schedule.initialVisibilitySelectionCount !== schedule.faceCount || + !Number.isSafeInteger(schedule.visibilityChangeCount) || schedule.visibilityChangeCount < 1 || + typeof schedule.dataBase64 !== "string") { + throw new Error("Prepared cssFlower owned-pixel visibility transform schedule is invalid"); + } + const encoded = atob(schedule.dataBase64); + if (encoded.length !== schedule.byteLength) { + throw new Error("Prepared cssFlower front-face transform schedule byte length drifted"); + } + const bytes = Uint8Array.from(encoded, (character) => character.charCodeAt(0)); + let selectedFaceCount = 0; + let visibilityChangeCount = 0; + for (let stateIndex = 0; stateIndex < schedule.stateCount; stateIndex += 1) { + const stateOffset = stateIndex * schedule.bytesPerState; + for (let faceIndex = 0; faceIndex < schedule.faceCount; faceIndex += 1) { + const selected = Number((bytes[stateOffset + (faceIndex >> 3)] & (1 << (faceIndex & 7))) !== 0); + selectedFaceCount += selected; + const previousStateIndex = (stateIndex + schedule.stateCount - 1) % schedule.stateCount; + const previousStateOffset = previousStateIndex * schedule.bytesPerState; + const previouslySelected = Number( + (bytes[previousStateOffset + (faceIndex >> 3)] & (1 << (faceIndex & 7))) !== 0, + ); + visibilityChangeCount += Number(previouslySelected !== selected); + } + } + if (selectedFaceCount !== schedule.selectedFaceCount) { + throw new Error("Prepared cssFlower front-face transform selection count drifted"); + } + if (visibilityChangeCount !== schedule.visibilityChangeCount) { + throw new Error("Prepared cssFlower front-face visibility-change count drifted"); + } + return Object.freeze({ + stateCount: schedule.stateCount, + faceCount: schedule.faceCount, + dilationTicks: schedule.dilationTicks, + selectionDomain: schedule.selectionDomain, + minimumOwnedPixels: schedule.minimumOwnedPixels, + sampleGrid: schedule.sampleGrid, + adjacencyRings: schedule.adjacencyRings, + bytesPerState: schedule.bytesPerState, + bytes, + selectedFaceCount, + visibilityChangeCount, + }); +} diff --git a/src/adapters/flowerbox/src/cssflower/renderContract.mjs b/src/adapters/flowerbox/src/cssflower/renderContract.mjs new file mode 100644 index 0000000..35cc18f --- /dev/null +++ b/src/adapters/flowerbox/src/cssflower/renderContract.mjs @@ -0,0 +1,64 @@ +export const CSSFLOWER_SEAM_BLEED = 0.2; +export const CSSFLOWER_SEAM_BLEED_TEXT = String(CSSFLOWER_SEAM_BLEED); +export const CSSFLOWER_BOUNDARY_SEAM_BLEED = 0.05; +export const CSSFLOWER_BOUNDARY_SEAM_BLEED_TEXT = String(CSSFLOWER_BOUNDARY_SEAM_BLEED); +export const CSSFLOWER_SEAM_BLEED_POLICY = "side-local-shared-edges-boundary-ring-damped"; +export const CSSFLOWER_LIGHTING_SCHEMA = "cssflower-prepared-space-texel-lighting@4"; +export const CSSFLOWER_LIGHTING_LAYOUT = "guttered-leaf-raster-shelves-by-horizontal-page-grid-and-timeline-state-slices"; +export const CSSFLOWER_LIGHTING_RASTER_MODE = "leaf-resolution"; +export const CSSFLOWER_LIGHTING_SAMPLING = "endpoint-aligned-pixel-centers"; +export const CSSFLOWER_LIGHTING_PAGE_ROWS = 32; +export const CSSFLOWER_LIGHTING_PAGE_COUNT = 12; +export const CSSFLOWER_LIGHTING_MICRO_CONTENT_SIZE = 2; +export const CSSFLOWER_LIGHTING_GUTTER = 1; +export const CSSFLOWER_LIGHTING_MICRO_SLOT_SIZE = CSSFLOWER_LIGHTING_MICRO_CONTENT_SIZE + CSSFLOWER_LIGHTING_GUTTER * 2; +export const CSSFLOWER_LIGHTING_ATLAS_WIDTH = 768; +export const CSSFLOWER_LIGHTING_MICRO_TILES_PER_SHELF = Math.floor( + CSSFLOWER_LIGHTING_ATLAS_WIDTH / CSSFLOWER_LIGHTING_MICRO_SLOT_SIZE, +); +export const CSSFLOWER_LIGHTING_STATE_SLICE_HEIGHT = 226; +export const CSSFLOWER_LIGHTING_ATLAS_HEIGHT = 7_232; +export const CSSFLOWER_LIGHTING_GRID_COLUMNS = CSSFLOWER_LIGHTING_PAGE_COUNT; +export const CSSFLOWER_LIGHTING_GRID_ROWS = 1; +export const CSSFLOWER_LIGHTING_GRID_WIDTH = CSSFLOWER_LIGHTING_ATLAS_WIDTH * CSSFLOWER_LIGHTING_GRID_COLUMNS; +export const CSSFLOWER_LIGHTING_GRID_HEIGHT = CSSFLOWER_LIGHTING_ATLAS_HEIGHT; +export const CSSFLOWER_LIGHTING_GRID_DECODED_BYTES = CSSFLOWER_LIGHTING_GRID_WIDTH * CSSFLOWER_LIGHTING_GRID_HEIGHT * 4; +export const CSSFLOWER_LIGHTING_ATLAS_ENCODING = "avif-grid-12x1-lossy-q60-speed6-yuv444"; +export const CSSFLOWER_LIGHTING_ATLAS_MIME_TYPE = "image/avif"; +export const CSSFLOWER_LIGHTING_ATLAS_EXTENSION = "avif"; +export const CSSFLOWER_LIGHTING_ATLAS_QUALITY = 60; +export const CSSFLOWER_LIGHTING_VISUAL_BANK_MAX_BYTES = 125_000_000; +export const CSSFLOWER_TRANSFORM_BLOCK_SCHEMA = "cssflower-prepared-matrix3d-blocks@1"; +export const CSSFLOWER_TRANSFORM_BLOCK_GEOMETRY_STATES = 16; +export const CSSFLOWER_FRONT_FACE_SCHEDULE_SCHEMA = "cssflower-prepared-front-face-transform-schedule@1"; +export const CSSFLOWER_FRONT_FACE_SCHEDULE_ENCODING = "base64-lsb0-state-major-bitset"; +export const CSSFLOWER_VISIBILITY_POLICY = Object.freeze({ + schema: "cssflower-prepared-depth16-owned-pixel-visibility-policy@1", + selectionDomain: "prepared-source-camera-depth16-owned-pixel-occlusion", + depthComparison: "source-depth16-less", + minimumOwnedPixels: 8, + sampleGrid: 1, + adjacency: "edge", + adjacencyRings: 0, + temporalDilationTicks: 1, + dilationPolicy: "cyclic-union-of-previous-current-next-prepared-state", +}); +export const CSSFLOWER_FRONT_FACE_DILATION_TICKS = CSSFLOWER_VISIBILITY_POLICY.temporalDilationTicks; +export const CSSFLOWER_PROJECTED_ATLAS_ENCODING = "avif-lossy-q60-speed6-yuv444"; +export const CSSFLOWER_PROJECTED_ATLAS_MIME_TYPE = "image/avif"; +export const CSSFLOWER_PROJECTED_ATLAS_EXTENSION = "avif"; +export const CSSFLOWER_PROJECTED_ATLAS_QUALITY = 60; +export const CSSFLOWER_PROJECTED_VISUAL_BANK_MAX_BYTES = 45_000_000; +export const CSSFLOWER_PROJECTED_VISUAL_ACCEPTANCE = Object.freeze({ + schema: "cssflower-q60-exact-reference-visual-envelope@1", + selection: "user-rejected-visible-q40-compression-promote-q60-from-calibrated-q90-to-q20-sweep", + reference: "lossless-webp-retained-dom-browser-sequence", + meanAbsDelta: 0.35, + rmsDelta: 2.5, + changedPixelRatio: 0.38, + maxAbsDelta: 225, + alphaMaxAbsDelta: 0, + interiorMeanAbsDelta: 1.75, + interiorRmsDelta: 2.5, + exactBackgroundMaxAbsDelta: 0, +}); diff --git a/src/adapters/flowerbox/src/cssflower/routeState.mjs b/src/adapters/flowerbox/src/cssflower/routeState.mjs new file mode 100644 index 0000000..9cb8378 --- /dev/null +++ b/src/adapters/flowerbox/src/cssflower/routeState.mjs @@ -0,0 +1,40 @@ +export const DEFAULT_SCENE_ID = "default-cube"; +export const PUBLIC_ROUTE_PARAMS = ["scene"]; + +export function createRouteState(search = globalThis.location?.search ?? "") { + const params = new URLSearchParams(search); + const scene = cleanSceneId(params.get("scene")) ?? DEFAULT_SCENE_ID; + return { + params, + scene, + manifestUrl: "/cssflower/manifest.json", + publicRoute: publicRouteFor({ scene }), + routeContract: "?scene=", + }; +} + +export function publicRouteFor({ scene = DEFAULT_SCENE_ID } = {}) { + const base = globalThis.location?.pathname?.startsWith("/flower") ? "/flower/" : "/"; + if (scene === DEFAULT_SCENE_ID) return base; + const params = new URLSearchParams(); + params.set("scene", scene); + return base + "?" + params.toString(); +} + +export function sceneEntryForRoute(manifest, routeState) { + const scenes = Array.isArray(manifest?.scenes) ? manifest.scenes : []; + return scenes.find((scene) => scene.id === routeState.scene) + ?? scenes.find((scene) => scene.id === manifest?.defaultScene?.id) + ?? scenes[0] + ?? null; +} + +export function routeSceneLabel(routeState) { + return "scene=" + routeState.scene; +} + +export function cleanSceneId(value) { + if (typeof value !== "string" || !value.trim()) return null; + const clean = value.trim().toLowerCase(); + return /^[a-z0-9._-]+$/.test(clean) ? clean : null; +} diff --git a/src/adapters/flowerbox/src/cssflower/stagePresentation.mjs b/src/adapters/flowerbox/src/cssflower/stagePresentation.mjs new file mode 100644 index 0000000..41dee6a --- /dev/null +++ b/src/adapters/flowerbox/src/cssflower/stagePresentation.mjs @@ -0,0 +1,61 @@ +export const CSSFLOWER_PREPARED_STAGE_EDGE = 720; +export const CSSFLOWER_RESPONSIVE_STAGE_FRACTION = 1; + +export function cssflowerStageScale(viewportWidth, viewportHeight) { + if (!Number.isFinite(viewportWidth) || !Number.isFinite(viewportHeight) || + viewportWidth <= 0 || viewportHeight <= 0) { + throw new RangeError("cssFlower viewport dimensions must be positive finite numbers"); + } + return Math.min(viewportWidth, viewportHeight) / CSSFLOWER_PREPARED_STAGE_EDGE * + CSSFLOWER_RESPONSIVE_STAGE_FRACTION; +} + +export function installCssflowerStagePresentation({ host, camera }) { + if (!(host instanceof HTMLElement)) throw new TypeError("cssFlower stage host is missing"); + if (!(camera instanceof HTMLElement) || !camera.classList.contains("polycss-camera")) { + throw new TypeError("cssFlower prepared camera is missing"); + } + let scale = 1; + let writes = 0; + let resizeObserver = null; + + function apply() { + const rect = host.getBoundingClientRect(); + const nextScale = cssflowerStageScale(rect.width, rect.height); + const serialized = String(Number(nextScale.toFixed(8))); + if (camera.style.scale !== serialized) { + camera.style.scale = serialized; + writes += 1; + } + scale = Number(serialized); + } + + apply(); + if (typeof ResizeObserver === "function") { + resizeObserver = new ResizeObserver(apply); + resizeObserver.observe(host); + } else { + window.addEventListener("resize", apply, { passive: true }); + } + + return Object.freeze({ + stats() { + return Object.freeze({ + stagePresentation: "responsive", + preparedStageEdgePixels: CSSFLOWER_PREPARED_STAGE_EDGE, + responsivePresentationFit: "contain", + responsivePresentationStageFraction: CSSFLOWER_RESPONSIVE_STAGE_FRACTION, + presentationScale: scale, + runtimePresentationScaleWrites: writes, + runtimeModelGeometryCalculations: 0, + }); + }, + destroy() { + resizeObserver?.disconnect(); + if (resizeObserver === null) { + window.removeEventListener("resize", apply); + } + camera.style.removeProperty("scale"); + }, + }); +} diff --git a/src/adapters/flowerbox/src/cssflower/styles.css b/src/adapters/flowerbox/src/cssflower/styles.css new file mode 100644 index 0000000..f8f9968 --- /dev/null +++ b/src/adapters/flowerbox/src/cssflower/styles.css @@ -0,0 +1,58 @@ +:root { + background: #000; + color-scheme: dark; +} + +html, +html > body { + margin: 0; + overflow: hidden; + background: #000; +} + +html { + width: 100%; + height: 100%; +} + +html > body { + position: relative; + width: 100vw; + height: 100vh; + height: 100dvh; +} + +html > body:not(.r):not(.e)::after { + content: ""; + position: fixed; + inset: 50% auto auto 50%; + z-index: 1; + width: 18px; + height: 18px; + margin: -9px 0 0 -9px; + border: 1px solid rgb(240 240 240 / 18%); + border-top-color: rgb(240 240 240 / 70%); + border-radius: 50%; + animation: l 0.8s linear infinite; +} + +@keyframes l { + to { + transform: rotate(1turn); + } +} + +@media (prefers-reduced-motion: reduce) { + html > body:not(.r):not(.e)::after { + animation: none; + } +} + +body > .polycss-camera { + position: absolute !important; + inset: calc(50% - 360px) auto auto calc(50% - 360px) !important; + width: 720px !important; + height: 720px !important; + scale: 1; + transform-origin: 50% 50%; +} diff --git a/src/adapters/flowerbox/src/main.mjs b/src/adapters/flowerbox/src/main.mjs new file mode 100644 index 0000000..96c90f1 --- /dev/null +++ b/src/adapters/flowerbox/src/main.mjs @@ -0,0 +1,3 @@ +import { mountCssflowerClient } from "./cssflower/client.mjs"; + +mountCssflowerClient(document.body); diff --git a/src/adapters/flowerbox/src/prepare/cssflower/bloomCycle.mjs b/src/adapters/flowerbox/src/prepare/cssflower/bloomCycle.mjs new file mode 100644 index 0000000..ea14c38 --- /dev/null +++ b/src/adapters/flowerbox/src/prepare/cssflower/bloomCycle.mjs @@ -0,0 +1,195 @@ +import { + CSSFLOWER_SOURCE_PROFILE, + FLOAT, + floatBits, + floatHex, + preparedRootTransform, +} from "./sourceProfile.mjs"; + +export const CSSFLOWER_PRODUCT_BLOOM_PEAK_GEOMETRY_STATE = 45; +export const CSSFLOWER_PRODUCT_BLOOM_PEAK_SF_NOMINAL = 2.25; +export const CSSFLOWER_PRODUCT_BLOOM_CYCLE_LENGTH = 90; +export const CSSFLOWER_PRODUCT_ROTATION_CYCLE_LENGTH = 360; + +export function buildPreparedBloomCycle() { + const bloom = CSSFLOWER_SOURCE_PROFILE.bloom; + let sf = FLOAT(bloom.resetSf); + let sfi = FLOAT(bloom.sfIncrement); + const min = FLOAT(bloom.minSf); + const max = FLOAT(bloom.maxSf); + const seen = new Map(); + const states = []; + + while (states.length < 10_000) { + const key = `${floatBits(sf)}:${floatBits(sfi)}`; + if (seen.has(key)) { + const cycleStartState = seen.get(key); + const geometryBySf = new Map(); + const geometryStates = []; + for (const state of states) { + const sfKey = floatBits(state.sf); + let geometryStateIndex = geometryBySf.get(sfKey); + if (geometryStateIndex === undefined) { + geometryStateIndex = geometryStates.length; + geometryBySf.set(sfKey, geometryStateIndex); + geometryStates.push(Object.freeze({ + index: geometryStateIndex, + sf: state.sf, + sfHex: state.sfHex, + firstTick: state.tick, + })); + } + state.geometryStateIndex = geometryStateIndex; + } + const rootTransforms = Object.freeze(Array.from({ length: 360 }, (_, tick) => + preparedRootTransform( + tick * CSSFLOWER_SOURCE_PROFILE.rotation.xDegreesPerUpdate, + tick * CSSFLOWER_SOURCE_PROFILE.rotation.yDegreesPerUpdate, + ))); + return Object.freeze({ + schema: "cssflower-prepared-bloom-cycle@1", + initialState: 0, + stateCount: states.length, + cycleStartState, + cycleLength: states.length - cycleStartState, + repeatKey: key, + geometryStateCount: geometryStates.length, + geometryStates: Object.freeze(geometryStates), + rootStateCount: rootTransforms.length, + rootTransforms, + states: Object.freeze(states.map((state) => Object.freeze(state))), + }); + } + const tick = states.length; + seen.set(key, tick); + states.push({ + tick, + sf, + sfHex: floatHex(sf), + sfi, + sfiHex: floatHex(sfi), + rotationXDegrees: tick * CSSFLOWER_SOURCE_PROFILE.rotation.xDegreesPerUpdate, + rotationYDegrees: tick * CSSFLOWER_SOURCE_PROFILE.rotation.yDegreesPerUpdate, + rotationZDegrees: 0, + rootStateIndex: tick % 360, + geometryStateIndex: -1, + }); + sf = FLOAT(sf + sfi); + if (sf > max || sf < min) sfi = FLOAT(-sfi); + } + throw new Error("cssFlower bloom state failed to enter a repeatable binary32 cycle"); +} + +export function buildPreparedFullRotationCycle() { + const bloomCycle = buildPreparedBloomCycle(); + const combinedCycleLength = leastCommonMultiple( + bloomCycle.cycleLength, + bloomCycle.rootStateCount, + ); + const stateCount = bloomCycle.cycleStartState + combinedCycleLength; + const states = Array.from({ length: stateCount }, (_, tick) => { + const bloomStateIndex = tick < bloomCycle.stateCount + ? tick + : bloomCycle.cycleStartState + ((tick - bloomCycle.cycleStartState) % bloomCycle.cycleLength); + const bloomState = bloomCycle.states[bloomStateIndex]; + return Object.freeze({ + ...bloomState, + tick, + bloomStateIndex, + rotationXDegrees: tick * CSSFLOWER_SOURCE_PROFILE.rotation.xDegreesPerUpdate, + rotationYDegrees: tick * CSSFLOWER_SOURCE_PROFILE.rotation.yDegreesPerUpdate, + rotationZDegrees: 0, + rootStateIndex: tick % bloomCycle.rootStateCount, + }); + }); + const repeatState = states[bloomCycle.cycleStartState]; + return Object.freeze({ + schema: "cssflower-prepared-full-rotation-cycle@1", + initialState: 0, + stateCount, + cycleStartState: bloomCycle.cycleStartState, + cycleLength: combinedCycleLength, + repeatKey: `${floatBits(repeatState.sf)}:${floatBits(repeatState.sfi)}:${repeatState.rootStateIndex}`, + bloomTraceStateCount: bloomCycle.stateCount, + bloomCycleStartState: bloomCycle.cycleStartState, + bloomCycleLength: bloomCycle.cycleLength, + geometryStateCount: bloomCycle.geometryStateCount, + geometryStates: bloomCycle.geometryStates, + rootStateCount: bloomCycle.rootStateCount, + rootTransforms: bloomCycle.rootTransforms, + states: Object.freeze(states), + }); +} + +export function buildPreparedRoundedProductCycle() { + const sourceBloom = buildPreparedBloomCycle(); + const peakGeometryStateIndex = CSSFLOWER_PRODUCT_BLOOM_PEAK_GEOMETRY_STATE; + const geometryStates = sourceBloom.geometryStates.slice(0, peakGeometryStateIndex + 1); + const bloomGeometryStateIndices = Object.freeze([ + ...Array.from({ length: peakGeometryStateIndex + 1 }, (_, index) => index), + ...Array.from({ length: peakGeometryStateIndex - 1 }, (_, index) => peakGeometryStateIndex - 1 - index), + ]); + if (bloomGeometryStateIndices.length !== CSSFLOWER_PRODUCT_BLOOM_CYCLE_LENGTH || + geometryStates.length !== peakGeometryStateIndex + 1 || + geometryStates.at(-1)?.sfHex !== "400ffffc") { + throw new Error("cssFlower rounded product bloom contract drifted"); + } + + const sourceIncrement = Math.fround(CSSFLOWER_SOURCE_PROFILE.bloom.sfIncrement); + const states = Array.from({ length: CSSFLOWER_PRODUCT_ROTATION_CYCLE_LENGTH }, (_, tick) => { + const productBloomPhaseIndex = tick % CSSFLOWER_PRODUCT_BLOOM_CYCLE_LENGTH; + const geometryStateIndex = bloomGeometryStateIndices[productBloomPhaseIndex]; + const geometryState = geometryStates[geometryStateIndex]; + const sfi = Math.fround(productBloomPhaseIndex < peakGeometryStateIndex + ? sourceIncrement + : -sourceIncrement); + return Object.freeze({ + tick, + sf: geometryState.sf, + sfHex: geometryState.sfHex, + sfi, + sfiHex: floatHex(sfi), + rotationXDegrees: tick * CSSFLOWER_SOURCE_PROFILE.rotation.xDegreesPerUpdate, + rotationYDegrees: tick * CSSFLOWER_SOURCE_PROFILE.rotation.yDegreesPerUpdate, + rotationZDegrees: 0, + rootStateIndex: tick, + geometryStateIndex, + bloomStateIndex: productBloomPhaseIndex, + productBloomPhaseIndex, + }); + }); + + return Object.freeze({ + schema: "cssflower-prepared-rounded-product-cycle@1", + scope: "source-derived-rounded-cube-to-bloom-with-spike-phase-omitted", + initialState: 0, + stateCount: CSSFLOWER_PRODUCT_ROTATION_CYCLE_LENGTH, + cycleStartState: 0, + cycleLength: CSSFLOWER_PRODUCT_ROTATION_CYCLE_LENGTH, + repeatKey: `${floatBits(states[0].sf)}:${floatBits(states[0].sfi)}:${states[0].rootStateIndex}`, + bloomTraceStateCount: CSSFLOWER_PRODUCT_BLOOM_CYCLE_LENGTH, + bloomCycleStartState: 0, + bloomCycleLength: CSSFLOWER_PRODUCT_BLOOM_CYCLE_LENGTH, + bloomPeakGeometryStateIndex: peakGeometryStateIndex, + bloomPeakSf: geometryStates.at(-1).sf, + bloomPeakSfHex: geometryStates.at(-1).sfHex, + bloomPeakSfNominal: CSSFLOWER_PRODUCT_BLOOM_PEAK_SF_NOMINAL, + omittedSourceSfAtOrAbove: 2.5, + geometryStateCount: geometryStates.length, + geometryStates, + rootStateCount: sourceBloom.rootStateCount, + rootTransforms: sourceBloom.rootTransforms, + states: Object.freeze(states), + }); +} + +function leastCommonMultiple(left, right) { + return left / greatestCommonDivisor(left, right) * right; +} + +function greatestCommonDivisor(left, right) { + let a = Math.abs(left); + let b = Math.abs(right); + while (b !== 0) [a, b] = [b, a % b]; + return a; +} diff --git a/src/adapters/flowerbox/src/prepare/cssflower/compilePreparedCycle.mjs b/src/adapters/flowerbox/src/prepare/cssflower/compilePreparedCycle.mjs new file mode 100644 index 0000000..68ca2e2 --- /dev/null +++ b/src/adapters/flowerbox/src/prepare/cssflower/compilePreparedCycle.mjs @@ -0,0 +1,725 @@ +import { createHash } from "node:crypto"; +import { + SOLID_TRIANGLE_CANONICAL_SIZE, + computeSolidTrianglePlan, + computeTextureAtlasPlanPublic, + resolveAtlasLeafBox, +} from "@layoutit/polycss"; +import { PNG } from "pngjs"; +import { + CSSFLOWER_BOUNDARY_SEAM_BLEED, + CSSFLOWER_LIGHTING_GRID_COLUMNS, + CSSFLOWER_LIGHTING_GRID_DECODED_BYTES, + CSSFLOWER_LIGHTING_GRID_HEIGHT, + CSSFLOWER_LIGHTING_GRID_ROWS, + CSSFLOWER_LIGHTING_GRID_WIDTH, + CSSFLOWER_LIGHTING_LAYOUT, + CSSFLOWER_LIGHTING_RASTER_MODE, + CSSFLOWER_LIGHTING_SAMPLING, + CSSFLOWER_LIGHTING_SCHEMA, + CSSFLOWER_SEAM_BLEED, + CSSFLOWER_SEAM_BLEED_POLICY, +} from "../../cssflower/renderContract.mjs"; +import { + buildPreparedFullRotationCycle, + buildPreparedRoundedProductCycle, +} from "./bloomCycle.mjs"; +import { + buildCubeTopology, + buildSideSiblingSeamPlan, + computeSmoothPointNormals, + deformCubePoints, + trianglePolygon, +} from "./cubeTopology.mjs"; +import { CSSFLOWER_CAMERA, CSSFLOWER_SOURCE_PROFILE } from "./sourceProfile.mjs"; +import { auditPreparedQuadMergeEligibility } from "./quadMergeAudit.mjs"; +import { + CSSFLOWER_LEAF_RASTER_PAGE_ROWS, + buildPreparedLeafRasterLayout, + writePreparedLeafRasterLightingTile, +} from "./leafRasterLighting.mjs"; + +const MATRIX_COMPONENTS = 16; +const MINIMUM_RASTER_LEAF_SIZE = 4; +export const CSSFLOWER_LIGHTING_PAGE_ROWS = CSSFLOWER_LEAF_RASTER_PAGE_ROWS; + +export async function compilePreparedCssflowerCycle({ + nativeAuthorityStatus = "missing", + readLightingPage, + writeLightingPage, +} = {}) { + if (typeof readLightingPage !== "function" || typeof writeLightingPage !== "function") { + throw new TypeError("cssFlower production compilation requires a streaming cached lighting-page store"); + } + assertLittleEndian(); + const topology = buildCubeTopology(); + const siblingSeamPlan = buildSideSiblingSeamPlan(topology); + if (siblingSeamPlan.policy !== CSSFLOWER_SEAM_BLEED_POLICY) { + throw new Error("cssFlower prepared seam policy drifted"); + } + const seamEdgesByMask = buildSeamEdgesByMask(); + const sourceCycle = buildPreparedFullRotationCycle(); + const cycle = attachPreparedLightingRows(buildPreparedRoundedProductCycle()); + const quadMergeAudit = auditPreparedQuadMergeEligibility(topology, sourceCycle); + const rasterFaces = selectPreparedRasterFaces(topology, sourceCycle, siblingSeamPlan, seamEdgesByMask); + const rasterLayout = buildPreparedLeafRasterLayout(rasterFaces); + const matrixValues = new Float32Array(sourceCycle.geometryStateCount * topology.triangleCount * MATRIX_COMPONENTS); + const atlasWidth = rasterLayout.atlasWidth; + const atlasHeight = rasterLayout.atlasHeight; + const stateEvidence = []; + const geometryByState = new Array(sourceCycle.geometryStateCount); + const canonicalPointIndices = new Uint16Array(sourceCycle.geometryStateCount * topology.triangleCount * 3); + let initialPolygons = null; + + for (const geometryState of sourceCycle.geometryStates) { + const positions = deformCubePoints(topology, geometryState.sf); + const normals = computeSmoothPointNormals(topology, positions); + geometryByState[geometryState.index] = Object.freeze({ positions, normals }); + const transformStateOffset = geometryState.index * topology.triangleCount * MATRIX_COMPONENTS; + + for (const triangle of topology.triangles) { + const polygon = trianglePolygon(topology, triangle, positions); + const rasterFace = rasterFaces[triangle.index]; + const seamEdgeMask = siblingSeamPlan.edgeMasks[triangle.index]; + const seamEdges = seamEdgesByMask[seamEdgeMask]; + const texturePlan = computeTextureAtlasPlanPublic(polygon, triangle.index, { + seamBleed: rasterFace.seamBleed, + seamEdges, + }); + const solidPlan = computeSolidTrianglePlan(polygon, triangle.index, { + seamBleed: rasterFace.seamBleed, + seamEdges, + stableTriangleMatrixDecimals: 6, + textureLighting: "baked", + }, { + primitive: "corner", + includeColor: false, + matrixDecimals: 6, + }); + if (!texturePlan || texturePlan.bleedRatio !== rasterFace.seamBleed || + !sameEdgeSet(texturePlan.seamBleedEdges, seamEdges) || + texturePlan.projectiveMatrix !== null || !solidPlan?.transformText) { + throw new Error(`cssFlower state ${geometryState.index} triangle ${triangle.index} lost its prepared seam-bleed triangle plan`); + } + matrixValues.set( + fitCanonicalTransformToRasterLeaf( + parseMatrix3d(solidPlan.transformText), + rasterFace.leafWidth, + rasterFace.leafHeight, + ), + transformStateOffset + triangle.index * MATRIX_COMPONENTS, + ); + const { a, b, c } = solidPlan.basis; + canonicalPointIndices.set([ + triangle.pointIndices[c], + triangle.pointIndices[a], + triangle.pointIndices[b], + ], (geometryState.index * topology.triangleCount + triangle.index) * 3); + } + + if (geometryState.index === sourceCycle.states[0].geometryStateIndex) { + initialPolygons = topology.triangles.map((triangle) => trianglePolygon(topology, triangle, positions)); + } + const transformStart = transformStateOffset * Float32Array.BYTES_PER_ELEMENT; + const transformEnd = transformStart + topology.triangleCount * MATRIX_COMPONENTS * Float32Array.BYTES_PER_ELEMENT; + stateEvidence.push(Object.freeze({ + geometryStateIndex: geometryState.index, + firstTick: geometryState.firstTick, + sf: geometryState.sf, + sfHex: geometryState.sfHex, + positionsSha256: sha256(typedArrayBytes(positions)), + normalsSha256: sha256(typedArrayBytes(normals)), + transformsSha256: sha256(Buffer.from(matrixValues.buffer, transformStart, transformEnd - transformStart)), + })); + } + + if (!initialPolygons || initialPolygons.length !== 1200) { + throw new Error("cssFlower initial 1,200-triangle product was not compiled"); + } + const productTransformByteLength = cycle.geometryStateCount * topology.triangleCount * MATRIX_COMPONENTS * Float32Array.BYTES_PER_ELEMENT; + const transformBytes = Buffer.from(matrixValues.buffer, 0, productTransformByteLength); + const vertexLightingByState = Object.freeze(cycle.states.map((state) => { + const geometry = geometryByState[state.geometryStateIndex]; + if (!geometry) throw new Error(`cssFlower lighting state ${state.tick} has no prepared geometry`); + return computePreparedVertexLightingUnquantized( + topology, + geometry.positions, + geometry.normals, + state, + ); + })); + const lightingAddressSchedule = buildPreparedLightingAddressSchedule({ + topology, + cycle, + canonicalPointIndices, + vertexLightingByState, + }); + const lightingPreparation = await prepareLightingPages({ + topology, + cycle, + canonicalPointIndices, + atlasWidth, + atlasHeight, + rasterLayout, + vertexLightingByState, + readLightingPage, + writeLightingPage, + }); + const lightingPages = lightingPreparation.pages; + const firstLightingPage = lightingPages[0]; + const topologyEvidence = topologyEvidenceFor(topology); + const evidence = Object.freeze({ + schema: "cssflower-prepared-state-evidence@1", + profileId: CSSFLOWER_SOURCE_PROFILE.id, + implementation: "independently-authored-cssflower-preparer", + nativeAuthorityStatus, + engineIndependence: Object.freeze({ + status: "pass", + preparedInputReadsNativeReplay: false, + preparedInputReadsNativeState: false, + browserReadsNativeReplay: false, + }), + topology: topologyEvidence, + update: Object.freeze({ + stateCount: sourceCycle.stateCount, + geometryStateCount: sourceCycle.geometryStateCount, + cycleStartState: sourceCycle.cycleStartState, + cycleLength: sourceCycle.cycleLength, + bloomTraceStateCount: sourceCycle.bloomTraceStateCount, + bloomCycleLength: sourceCycle.bloomCycleLength, + rootStateCount: sourceCycle.rootStateCount, + productStateCount: cycle.stateCount, + productGeometryStateCount: cycle.geometryStateCount, + productCycleLength: cycle.cycleLength, + productBloomCycleLength: cycle.bloomCycleLength, + productBloomPeakSf: cycle.bloomPeakSf, + }), + camera: CSSFLOWER_CAMERA, + light: CSSFLOWER_SOURCE_PROFILE.light, + materials: topologyEvidence.materials, + rasterAtlas: Object.freeze({ + leafSizing: "raster", + seamBleed: CSSFLOWER_SEAM_BLEED, + boundarySeamBleed: CSSFLOWER_BOUNDARY_SEAM_BLEED, + seamBleedPolicy: siblingSeamPlan.policy, + boundaryVertexCount: siblingSeamPlan.boundaryVertexCount, + boundaryAdjacentTriangleCount: siblingSeamPlan.boundaryAdjacentTriangleCount, + sharedEdgeCount: siblingSeamPlan.sharedEdgeCount, + sharedEdgeIncidenceCount: siblingSeamPlan.sharedEdgeIncidenceCount, + boundaryEdgeCount: siblingSeamPlan.boundaryEdgeCount, + boundaryEdgeIncidenceCount: siblingSeamPlan.boundaryEdgeIncidenceCount, + canonicalSize: SOLID_TRIANGLE_CANONICAL_SIZE, + sampling: rasterLayout.sampling, + gutter: rasterLayout.gutter, + gutterPolicy: rasterLayout.gutterPolicy, + packing: rasterLayout.packing, + packingEfficiency: rasterLayout.packingEfficiency, + stateSliceHeight: rasterLayout.stateSliceHeight, + atlasWidth, + atlasHeight, + selection: "per-face maximum PolyCSS raster leaf box across every prepared geometry state", + faceCount: rasterFaces.length, + minimumLeafWidth: Math.min(...rasterFaces.map((face) => face.leafWidth)), + maximumLeafWidth: Math.max(...rasterFaces.map((face) => face.leafWidth)), + minimumLeafHeight: Math.min(...rasterFaces.map((face) => face.leafHeight)), + maximumLeafHeight: Math.max(...rasterFaces.map((face) => face.leafHeight)), + pageRows: CSSFLOWER_LIGHTING_PAGE_ROWS, + pageCount: lightingPages.length, + gridColumns: CSSFLOWER_LIGHTING_GRID_COLUMNS, + gridRows: CSSFLOWER_LIGHTING_GRID_ROWS, + gridWidth: CSSFLOWER_LIGHTING_GRID_WIDTH, + gridHeight: CSSFLOWER_LIGHTING_GRID_HEIGHT, + gridDecodedBytes: CSSFLOWER_LIGHTING_GRID_DECODED_BYTES, + }), + quadMergeAudit, + geometryStates: Object.freeze(stateEvidence), + ticks: Object.freeze(sourceCycle.states.map((state) => Object.freeze({ + tick: state.tick, + sf: state.sf, + sfHex: state.sfHex, + sfi: state.sfi, + sfiHex: state.sfiHex, + rotationXDegrees: state.rotationXDegrees, + rotationYDegrees: state.rotationYDegrees, + rotationZDegrees: state.rotationZDegrees, + rootStateIndex: state.rootStateIndex, + geometryStateIndex: state.geometryStateIndex, + }))), + }); + + return Object.freeze({ + topology, + cycle, + sourceCycle, + initialPolygons: Object.freeze(initialPolygons), + transformBytes, + transformSha256: sha256(transformBytes), + lightingSha256: firstLightingPage.sha256, + lightingPages, + lightingPageCache: Object.freeze({ + hitCount: lightingPreparation.cacheHitCount, + missCount: lightingPreparation.cacheMissCount, + }), + lighting: Object.freeze({ + schema: CSSFLOWER_LIGHTING_SCHEMA, + techniqueReference: "cssGraphics Mario prepared space-time texel seam with per-leaf raster-sized fields", + topology: "stable PolyCSS triangles select opaque endpoint-aligned leaf-resolution lighting rasters from bounded prepared timeline pages", + physicalLayout: CSSFLOWER_LIGHTING_LAYOUT, + assetUrl: firstLightingPage.assetUrl, + assetSha256: firstLightingPage.sha256, + rasterMode: CSSFLOWER_LIGHTING_RASTER_MODE, + sampling: CSSFLOWER_LIGHTING_SAMPLING, + gutter: rasterLayout.gutter, + gutterPolicy: rasterLayout.gutterPolicy, + packing: rasterLayout.packing, + packingEfficiency: rasterLayout.packingEfficiency, + stateSliceHeight: rasterLayout.stateSliceHeight, + minimumTileWidth: Math.min(...rasterFaces.map((face) => face.leafWidth)), + maximumTileWidth: Math.max(...rasterFaces.map((face) => face.leafWidth)), + minimumTileHeight: Math.min(...rasterFaces.map((face) => face.leafHeight)), + maximumTileHeight: Math.max(...rasterFaces.map((face) => face.leafHeight)), + minimumLeafWidth: Math.min(...rasterFaces.map((face) => face.leafWidth)), + maximumLeafWidth: Math.max(...rasterFaces.map((face) => face.leafWidth)), + minimumLeafHeight: Math.min(...rasterFaces.map((face) => face.leafHeight)), + maximumLeafHeight: Math.max(...rasterFaces.map((face) => face.leafHeight)), + leafSizing: "raster", + seamBleed: CSSFLOWER_SEAM_BLEED, + boundarySeamBleed: CSSFLOWER_BOUNDARY_SEAM_BLEED, + seamBleedPolicy: siblingSeamPlan.policy, + boundaryVertexCount: siblingSeamPlan.boundaryVertexCount, + boundaryAdjacentTriangleCount: siblingSeamPlan.boundaryAdjacentTriangleCount, + sharedEdgeCount: siblingSeamPlan.sharedEdgeCount, + sharedEdgeIncidenceCount: siblingSeamPlan.sharedEdgeIncidenceCount, + boundaryEdgeCount: siblingSeamPlan.boundaryEdgeCount, + boundaryEdgeIncidenceCount: siblingSeamPlan.boundaryEdgeIncidenceCount, + canonicalLeafSize: SOLID_TRIANGLE_CANONICAL_SIZE, + rasterSelection: "per-face maximum resolveAtlasLeafBox(..., raster) across every prepared geometry state", + faceCount: topology.triangleCount, + timelineRowCount: cycle.stateCount, + geometryStateCount: cycle.geometryStateCount, + atlasWidth, + atlasHeight, + gridColumns: CSSFLOWER_LIGHTING_GRID_COLUMNS, + gridRows: CSSFLOWER_LIGHTING_GRID_ROWS, + gridWidth: CSSFLOWER_LIGHTING_GRID_WIDTH, + gridHeight: CSSFLOWER_LIGHTING_GRID_HEIGHT, + pageRowCount: CSSFLOWER_LIGHTING_PAGE_ROWS, + pageCount: lightingPages.length, + pages: lightingPages, + totalEncodedBytes: lightingPages.reduce((sum, page) => sum + page.byteLength, 0), + decodedBytesPerFullPage: atlasWidth * atlasHeight * 4, + decodedGridBytes: CSSFLOWER_LIGHTING_GRID_DECODED_BYTES, + addressSchedule: lightingAddressSchedule, + backgroundPositionXs: Object.freeze(Array.from( + { length: lightingPages.length }, + (_, pageIndex) => `${-pageIndex * atlasWidth}px`, + )), + backgroundPositionYs: Object.freeze(Array.from( + { length: CSSFLOWER_LIGHTING_PAGE_ROWS }, + (_, rowIndex) => `${-rowIndex * rasterLayout.stateSliceHeight}px`, + )), + faces: Object.freeze(rasterFaces.map((face, faceIndex) => { + const placement = rasterLayout.placements[faceIndex]; + return Object.freeze({ + ...face, + tileWidth: face.leafWidth, + tileHeight: face.leafHeight, + slotX: placement.slotX, + slotY: placement.slotY, + slotWidth: placement.slotWidth, + slotHeight: placement.slotHeight, + contentX: placement.contentX, + contentY: placement.contentY, + backgroundSize: `${CSSFLOWER_LIGHTING_GRID_WIDTH}px ${CSSFLOWER_LIGHTING_GRID_HEIGHT}px`, + backgroundPositionX: `${-placement.contentX}px`, + backgroundPositionY: `${-placement.contentY}px`, + }); + })), + rowSelection: "prepared-exact-rgb8-sparse-leaf-address-schedule", + temporalInterpolation: false, + sourceModelviewLighting: "identity-set-positional-light-then-Rx-Ry-Rz-with-normalize-and-infinite-viewer", + rotationAware: true, + runtimeRootFrameVariables: 1, + runtimePreparedPagePreload: false, + runtimeLightingAssetCount: 1, + runtimeLightingCalculations: 0, + runtimeAtlasConstruction: 0, + }), + evidence, + quadMergeAudit, + siblingSeamPlan, + }); +} + +function attachPreparedLightingRows(sourceCycle) { + return Object.freeze({ + ...sourceCycle, + states: Object.freeze(sourceCycle.states.map((state) => Object.freeze({ + ...state, + lightingPageIndex: Math.floor(state.tick / CSSFLOWER_LIGHTING_PAGE_ROWS), + lightingPageRowIndex: state.tick % CSSFLOWER_LIGHTING_PAGE_ROWS, + }))), + }); +} + +async function prepareLightingPages({ + topology, + cycle, + canonicalPointIndices, + atlasWidth, + atlasHeight, + rasterLayout, + vertexLightingByState, + readLightingPage, + writeLightingPage, +}) { + const pageCount = Math.ceil(cycle.stateCount / CSSFLOWER_LIGHTING_PAGE_ROWS); + const pages = []; + let cacheHitCount = 0; + let cacheMissCount = 0; + for (let pageIndex = 0; pageIndex < pageCount; pageIndex += 1) { + const startStateIndex = pageIndex * CSSFLOWER_LIGHTING_PAGE_ROWS; + const usedRowCount = Math.min(CSSFLOWER_LIGHTING_PAGE_ROWS, cycle.stateCount - startStateIndex); + const expectedPage = Object.freeze({ + index: pageIndex, + startStateIndex, + usedRowCount, + rowCount: CSSFLOWER_LIGHTING_PAGE_ROWS, + assetUrl: lightingPageAssetUrl(pageIndex), + role: "cssflower-prepared-full-rotation-leaf-raster-space-time-page", + encoding: "PNG-RGB8", + opaqueUsedRows: true, + width: atlasWidth, + height: atlasHeight, + decodedBytes: atlasWidth * atlasHeight * 4, + }); + const cachedPage = await readLightingPage(expectedPage); + if (cachedPage) { + assertPreparedLightingPageDescriptor(cachedPage, expectedPage); + pages.push(Object.freeze(cachedPage)); + cacheHitCount += 1; + continue; + } + cacheMissCount += 1; + const atlas = new PNG({ width: atlasWidth, height: atlasHeight, colorType: 6 }); + const atlasPixels = new Uint32Array( + atlas.data.buffer, + atlas.data.byteOffset, + atlas.data.byteLength / Uint32Array.BYTES_PER_ELEMENT, + ); + for (let rowIndex = 0; rowIndex < usedRowCount; rowIndex += 1) { + const state = cycle.states[startStateIndex + rowIndex]; + const vertexColors = vertexLightingByState[startStateIndex + rowIndex]; + if (!(vertexColors instanceof Float64Array)) { + throw new Error(`cssFlower lighting state ${state.tick} has no prepared vertex colors`); + } + for (const triangle of topology.triangles) { + writePreparedLeafRasterLightingTile({ + atlasData: atlas.data, + atlasPixels, + atlasWidth, + rowIndex, + faceIndex: triangle.index, + layout: rasterLayout, + canonicalPointIndices, + canonicalPointOffset: (state.geometryStateIndex * topology.triangleCount + triangle.index) * 3, + vertexColors, + }); + } + } + const bytes = PNG.sync.write(atlas, { colorType: 2, inputColorType: 6 }); + const pageWithBytes = Object.freeze({ + ...expectedPage, + byteLength: bytes.length, + sha256: sha256(bytes), + bytes, + }); + await writeLightingPage(pageWithBytes); + const { bytes: _bytes, ...page } = pageWithBytes; + pages.push(Object.freeze(page)); + } + return Object.freeze({ + pages: Object.freeze(pages), + cacheHitCount, + cacheMissCount, + }); +} + +function buildPreparedLightingAddressSchedule({ + topology, + cycle, + canonicalPointIndices, + vertexLightingByState, +}) { + const faceCount = topology.triangleCount; + const signatureStride = 9; + if (cycle?.stateCount !== 360 || vertexLightingByState?.length !== cycle.stateCount || + !(canonicalPointIndices instanceof Uint16Array) || + canonicalPointIndices.length < cycle.geometryStateCount * faceCount * 3) { + throw new Error("cssFlower sparse lighting preparation inputs are incomplete"); + } + const selectedSignatures = new Uint8Array(faceCount * signatureStride); + const currentSignature = new Uint8Array(signatureStride); + const updateOffsets = new Uint32Array(cycle.stateCount + 1); + const updatedFaceIndices = []; + for (let stateIndex = 0; stateIndex < cycle.stateCount; stateIndex += 1) { + const state = cycle.states[stateIndex]; + const vertexColors = vertexLightingByState[stateIndex]; + updateOffsets[stateIndex] = updatedFaceIndices.length; + for (let faceIndex = 0; faceIndex < faceCount; faceIndex += 1) { + preparedFaceLightingSignature( + currentSignature, + canonicalPointIndices, + (state.geometryStateIndex * faceCount + faceIndex) * 3, + vertexColors, + ); + const selectedOffset = faceIndex * signatureStride; + let changed = stateIndex === 0; + for (let channel = 0; channel < signatureStride && !changed; channel += 1) { + changed = selectedSignatures[selectedOffset + channel] !== currentSignature[channel]; + } + if (!changed) continue; + selectedSignatures.set(currentSignature, selectedOffset); + updatedFaceIndices.push(faceIndex); + } + } + updateOffsets[cycle.stateCount] = updatedFaceIndices.length; + if (updateOffsets[1] !== faceCount || updatedFaceIndices.length < faceCount) { + throw new Error("cssFlower sparse lighting schedule did not bind the complete retained target"); + } + const indices = new Uint16Array(updatedFaceIndices); + const indexBytes = Buffer.from(indices.buffer, indices.byteOffset, indices.byteLength); + const counts = Array.from( + { length: cycle.stateCount }, + (_, stateIndex) => updateOffsets[stateIndex + 1] - updateOffsets[stateIndex], + ); + const sortedCounts = [...counts].sort((left, right) => left - right); + return Object.freeze({ + schema: "cssflower-prepared-exact-sparse-lighting-address-schedule@1", + stateCount: cycle.stateCount, + faceCount, + selectionDomain: "prepared-source-vertex-lighting-rgb8", + comparison: "exact-three-canonical-point-rgb8-signature-per-retained-triangle", + threshold: 0, + cycleBoundaryPolicy: "force-all-faces-to-state-zero-on-each-360-state-wrap", + updateCount: indices.length, + meanUpdatesPerState: indices.length / cycle.stateCount, + p95UpdatesPerState: sortedCounts[Math.ceil(sortedCounts.length * 0.95) - 1], + maximumUpdatesPerState: sortedCounts.at(-1), + offsets: Object.freeze(Array.from(updateOffsets)), + faceIndicesEncoding: "base64-u16le-state-major-updated-face-indices", + faceIndicesByteLength: indexBytes.length, + faceIndicesSha256: sha256(indexBytes), + faceIndicesBase64: indexBytes.toString("base64"), + runtimeSelection: "prepared-state-range-only-no-lighting-or-geometry-calculation", + visualEquivalence: "source-rgb8-exact-with-bounded-location-dependent-lossy-atlas-raster-drift", + }); +} + +function preparedFaceLightingSignature( + output, + canonicalPointIndices, + canonicalPointOffset, + vertexColors, +) { + if (!(output instanceof Uint8Array) || output.length !== 9 || + !(vertexColors instanceof Float64Array)) { + throw new TypeError("cssFlower prepared lighting signature buffers are invalid"); + } + for (let vertex = 0; vertex < 3; vertex += 1) { + const pointIndex = canonicalPointIndices[canonicalPointOffset + vertex]; + for (let channel = 0; channel < 3; channel += 1) { + output[vertex * 3 + channel] = clampByte(vertexColors[pointIndex * 3 + channel]); + } + } + return output; +} + +function assertPreparedLightingPageDescriptor(actual, expected) { + for (const [key, value] of Object.entries(expected)) { + if (actual?.[key] !== value) { + throw new Error(`cssFlower cached lighting page ${expected.index} drifted at ${key}`); + } + } + if (!Number.isSafeInteger(actual.byteLength) || actual.byteLength < 1 || + !/^[a-f0-9]{64}$/u.test(actual.sha256 ?? "") || "bytes" in actual) { + throw new Error(`cssFlower cached lighting page ${expected.index} has an invalid identity`); + } +} + +function lightingPageAssetUrl(pageIndex) { + return pageIndex === 0 + ? "/cssflower/assets/flower-box-space-texels.png" + : `/cssflower/assets/flower-box-space-texels-page-${String(pageIndex).padStart(3, "0")}.png`; +} + +function selectPreparedRasterFaces(topology, cycle, siblingSeamPlan, seamEdgesByMask) { + const faces = topology.triangles.map((triangle) => ({ + sourceOrder: triangle.index, + triangleId: triangle.id, + seamEdgeMask: siblingSeamPlan.edgeMasks[triangle.index], + seamBleed: siblingSeamPlan.boundaryAdjacentTriangles[triangle.index] + ? CSSFLOWER_BOUNDARY_SEAM_BLEED + : CSSFLOWER_SEAM_BLEED, + boundaryAdjacent: siblingSeamPlan.boundaryAdjacentTriangles[triangle.index], + leafWidth: MINIMUM_RASTER_LEAF_SIZE, + leafHeight: MINIMUM_RASTER_LEAF_SIZE, + })); + for (const geometryState of cycle.geometryStates) { + const positions = deformCubePoints(topology, geometryState.sf); + for (const triangle of topology.triangles) { + const polygon = trianglePolygon(topology, triangle, positions); + const face = faces[triangle.index]; + const seamEdges = seamEdgesByMask[siblingSeamPlan.edgeMasks[triangle.index]]; + const plan = computeTextureAtlasPlanPublic(polygon, triangle.index, { + seamBleed: face.seamBleed, + seamEdges, + }); + if (!plan || plan.bleedRatio !== face.seamBleed || + !sameEdgeSet(plan.seamBleedEdges, seamEdges) || plan.projectiveMatrix !== null) { + throw new Error(`cssFlower raster state ${geometryState.index} triangle ${triangle.index} lost its prepared seam-bleed affine atlas plan`); + } + const box = resolveAtlasLeafBox(plan, 1, "raster", SOLID_TRIANGLE_CANONICAL_SIZE); + if (box.sizing !== "raster" || !Number.isFinite(box.width) || !Number.isFinite(box.height)) { + throw new Error(`cssFlower raster state ${geometryState.index} triangle ${triangle.index} has no PolyCSS raster leaf box`); + } + face.leafWidth = Math.max(face.leafWidth, Math.ceil(box.width)); + face.leafHeight = Math.max(face.leafHeight, Math.ceil(box.height)); + } + } + return Object.freeze(faces.map((face) => Object.freeze(face))); +} + +function buildSeamEdgesByMask() { + return Object.freeze(Array.from({ length: 8 }, (_, mask) => new Set( + [0, 1, 2].filter((edgeIndex) => (mask & (1 << edgeIndex)) !== 0), + ))); +} + +function sameEdgeSet(actual, expected) { + return actual?.size === expected.size && [...expected].every((edgeIndex) => actual.has(edgeIndex)); +} + +function fitCanonicalTransformToRasterLeaf(values, leafWidth, leafHeight) { + const fitted = [...values]; + const xScale = SOLID_TRIANGLE_CANONICAL_SIZE / leafWidth; + const yScale = SOLID_TRIANGLE_CANONICAL_SIZE / leafHeight; + for (const index of [0, 1, 2]) fitted[index] *= xScale; + for (const index of [4, 5, 6]) fitted[index] *= yScale; + return fitted; +} + +function topologyEvidenceFor(topology) { + return Object.freeze({ + schema: topology.schema, + subdivision: topology.subdivision, + sideCount: topology.sideCount, + sideLocalPointCount: topology.sideLocalPointCount, + triangleCount: topology.triangleCount, + topology: topology.topology, + merge: topology.merge, + pointIdsSha256: sha256(Buffer.from(topology.points.map((point) => point.id).join("\n"))), + triangleIdsSha256: sha256(Buffer.from(topology.triangles.map((triangle) => triangle.id).join("\n"))), + trianglePointIndicesSha256: sha256(Buffer.from(new Uint16Array(topology.triangles.flatMap((triangle) => triangle.pointIndices)).buffer)), + materials: Object.freeze([...new Map(topology.triangles.map((triangle) => [ + triangle.material.id, + Object.freeze({ id: triangle.material.id, color: triangle.material.color }), + ])).values()]), + }); +} + +export function computePreparedVertexLighting(topology, positions, normals, state) { + const unquantized = computePreparedVertexLightingUnquantized(topology, positions, normals, state); + return Uint8ClampedArray.from(unquantized, clampByte); +} + +export function computePreparedVertexLightingUnquantized(topology, positions, normals, state) { + if (!state || ![state.rotationXDegrees, state.rotationYDegrees, state.rotationZDegrees].every(Number.isFinite)) { + throw new TypeError("Prepared cssFlower lighting requires one source rotation state"); + } + const out = new Float64Array(topology.points.length * 3); + const light = CSSFLOWER_SOURCE_PROFILE.light; + const rotation = sourceModelRotation(state); + for (const point of topology.points) { + const offset = point.index * 3; + const position = rotateSourceVector(rotation, [positions[offset], positions[offset + 1], positions[offset + 2]]); + const normal = normalize(rotateSourceVector(rotation, [normals[offset], normals[offset + 1], normals[offset + 2]])); + const toLight = normalize([light.position[0] - position[0], light.position[1] - position[1], light.position[2] - position[2]]); + const halfway = normalize([toLight[0], toLight[1], toLight[2] + 1]); + const diffuse = Math.max(0, dot(normal, toLight)); + const specular = diffuse > 0 ? light.specular[0] * Math.pow(Math.max(0, dot(normal, halfway)), light.shininess) : 0; + const base = topology.triangles[point.side * 200].material.rgb; + for (let channel = 0; channel < 3; channel += 1) { + const ambient = 255 * light.globalAmbient[channel] * light.materialAmbient[channel]; + out[offset + channel] = Math.max(0, Math.min(255, + ambient + base[channel] * diffuse + 255 * specular, + )); + } + } + return out; +} + +function sourceModelRotation(state) { + const radians = Math.PI / 180; + const physicalRootState = Number.isInteger(state.rootStateIndex) ? state.rootStateIndex : null; + const x = (physicalRootState === null + ? state.rotationXDegrees + : physicalRootState * CSSFLOWER_SOURCE_PROFILE.rotation.xDegreesPerUpdate) * radians; + const y = (physicalRootState === null + ? state.rotationYDegrees + : physicalRootState * CSSFLOWER_SOURCE_PROFILE.rotation.yDegreesPerUpdate) * radians; + const z = state.rotationZDegrees * radians; + return Object.freeze({ + cx: Math.cos(x), sx: Math.sin(x), + cy: Math.cos(y), sy: Math.sin(y), + cz: Math.cos(z), sz: Math.sin(z), + }); +} + +function rotateSourceVector(rotation, value) { + const xAfterZ = rotation.cz * value[0] - rotation.sz * value[1]; + const yAfterZ = rotation.sz * value[0] + rotation.cz * value[1]; + const xAfterY = rotation.cy * xAfterZ + rotation.sy * value[2]; + const zAfterY = -rotation.sy * xAfterZ + rotation.cy * value[2]; + return [ + xAfterY, + rotation.cx * yAfterZ - rotation.sx * zAfterY, + rotation.sx * yAfterZ + rotation.cx * zAfterY, + ]; +} + +function parseMatrix3d(value) { + const match = /^matrix3d\(([^)]+)\)$/u.exec(value); + if (!match) throw new Error(`Invalid prepared cssFlower transform ${value}`); + const values = match[1].split(",").map(Number); + if (values.length !== MATRIX_COMPONENTS || values.some((component) => !Number.isFinite(component))) { + throw new Error(`Invalid prepared cssFlower matrix component count in ${value}`); + } + return values; +} + +function typedArrayBytes(value) { + return Buffer.from(value.buffer, value.byteOffset, value.byteLength); +} + +function sha256(bytes) { + return createHash("sha256").update(bytes).digest("hex"); +} + +function normalize(value) { + const length = Math.hypot(value[0], value[1], value[2]) || 1; + return [value[0] / length, value[1] / length, value[2] / length]; +} + +function dot(left, right) { + return left[0] * right[0] + left[1] * right[1] + left[2] * right[2]; +} + +function clampByte(value) { + return Math.max(0, Math.min(255, Math.round(value))); +} + +function assertLittleEndian() { + const bytes = new Uint8Array(new Uint32Array([0x01020304]).buffer); + if (bytes[0] !== 0x04) throw new Error("cssFlower transform asset requires little-endian Float32"); +} diff --git a/src/adapters/flowerbox/src/prepare/cssflower/cubeTopology.mjs b/src/adapters/flowerbox/src/prepare/cssflower/cubeTopology.mjs new file mode 100644 index 0000000..eea2a20 --- /dev/null +++ b/src/adapters/flowerbox/src/prepare/cssflower/cubeTopology.mjs @@ -0,0 +1,267 @@ +import { + CSSFLOWER_SIDE_MATERIALS, + CSSFLOWER_SOURCE_PROFILE, + FLOAT, + sourceToPolyCss, +} from "./sourceProfile.mjs"; + +const SOURCE_CUBE_PLANES = Object.freeze([ + Object.freeze({ id: "front", base: [-0.5, -0.5, 0.5], xAxis: [1, 0, 0], yAxis: [0, 1, 0] }), + Object.freeze({ id: "back", base: [0.5, -0.5, -0.5], xAxis: [-1, 0, 0], yAxis: [0, 1, 0] }), + Object.freeze({ id: "top", base: [0.5, 0.5, -0.5], xAxis: [-1, 0, 0], yAxis: [0, 0, 1] }), + Object.freeze({ id: "bottom", base: [-0.5, -0.5, -0.5], xAxis: [1, 0, 0], yAxis: [0, 0, 1] }), + Object.freeze({ id: "right", base: [0.5, -0.5, -0.5], xAxis: [0, 1, 0], yAxis: [0, 0, 1] }), + Object.freeze({ id: "left", base: [-0.5, 0.5, -0.5], xAxis: [0, -1, 0], yAxis: [0, 0, 1] }), +]); + +export function buildCubeTopology(subdivision = CSSFLOWER_SOURCE_PROFILE.subdivision) { + if (subdivision !== 10) throw new RangeError("The first cssFlower slice requires subdivision 10"); + const points = []; + const triangles = []; + const strips = []; + const sideStride = (subdivision + 1) ** 2; + + for (let side = 0; side < SOURCE_CUBE_PLANES.length; side += 1) { + const plane = SOURCE_CUBE_PLANES[side]; + for (let sourceX = 0; sourceX <= subdivision; sourceX += 1) { + const x = FLOAT(FLOAT(sourceX) / FLOAT(subdivision)); + for (let sourceY = 0; sourceY <= subdivision; sourceY += 1) { + const y = FLOAT(FLOAT(sourceY) / FLOAT(subdivision)); + const source = mapToSourcePlane(plane, x, y); + const scaledDistance = multiplyFloat(sourceLength(source), 2); + const radialCoefficient = divideFloat(subtractFloat(1, scaledDistance), scaledDistance); + const pointIndex = points.length; + points.push(Object.freeze({ + id: `side-${String(side).padStart(2, "0")}-point-${String(sourceX * (subdivision + 1) + sourceY).padStart(3, "0")}`, + index: pointIndex, + side, + sideId: plane.id, + row: sourceX, + column: sourceY, + source: Object.freeze(source), + radialCoefficient, + })); + } + } + + const index = (sourceX, sourceY) => side * sideStride + sourceX * (subdivision + 1) + sourceY; + for (let strip = 0; strip < subdivision; strip += 1) { + const stripPointIndices = []; + for (let sourceY = 0; sourceY <= subdivision; sourceY += 1) { + stripPointIndices.push(index(strip, sourceY), index(strip + 1, sourceY)); + } + strips.push(Object.freeze({ side, strip, pointIndices: Object.freeze(stripPointIndices) })); + for (let stripIndex = 2; stripIndex < stripPointIndices.length; stripIndex += 1) { + const pointIndices = (stripIndex & 1) === 0 + ? [stripPointIndices[stripIndex - 2], stripPointIndices[stripIndex - 1], stripPointIndices[stripIndex]] + : [stripPointIndices[stripIndex - 1], stripPointIndices[stripIndex - 2], stripPointIndices[stripIndex]]; + const stripTriangle = stripIndex - 2; + const triangleIndex = triangles.length; + triangles.push(Object.freeze({ + id: `side-${String(side).padStart(2, "0")}-strip-${String(strip).padStart(2, "0")}-triangle-${String(stripTriangle).padStart(2, "0")}`, + index: triangleIndex, + side, + sideId: plane.id, + strip, + column: Math.floor(stripTriangle / 2), + stripTriangle, + cellTriangle: stripTriangle & 1, + material: CSSFLOWER_SIDE_MATERIALS[side], + pointIndices: Object.freeze(pointIndices), + })); + } + } + } + + if (points.length !== 726 || triangles.length !== 1200) { + throw new Error(`cssFlower cube topology drifted (${points.length} points, ${triangles.length} triangles)`); + } + return Object.freeze({ + schema: "cssflower-cube-topology@1", + subdivision, + sideCount: SOURCE_CUBE_PLANES.length, + sideLocalPointCount: points.length, + triangleCount: triangles.length, + topology: "six-side-local-ordered-triangle-strips", + merge: false, + points: Object.freeze(points), + strips: Object.freeze(strips), + triangles: Object.freeze(triangles), + }); +} + +export function buildSideSiblingSeamPlan(topology) { + if (topology?.triangleCount !== 1200 || topology?.sideCount !== 6) { + throw new TypeError("cssFlower sibling seams require the retained default-cube topology"); + } + const edgeOwners = new Map(); + const edgeMasks = new Array(topology.triangleCount).fill(0); + for (const triangle of topology.triangles) { + for (let edgeIndex = 0; edgeIndex < 3; edgeIndex += 1) { + const first = triangle.pointIndices[edgeIndex]; + const second = triangle.pointIndices[(edgeIndex + 1) % 3]; + const low = Math.min(first, second); + const high = Math.max(first, second); + const key = `${triangle.side}:${low}:${high}`; + const record = edgeOwners.get(key) ?? { pointIndices: Object.freeze([low, high]), owners: [] }; + record.owners.push(Object.freeze({ triangleIndex: triangle.index, edgeIndex })); + edgeOwners.set(key, record); + } + } + + const boundaryPointIndices = new Set(); + for (const { pointIndices, owners } of edgeOwners.values()) { + if (owners.length !== 1) continue; + boundaryPointIndices.add(pointIndices[0]); + boundaryPointIndices.add(pointIndices[1]); + } + + let sharedEdgeCount = 0; + let boundaryEdgeCount = 0; + for (const { owners } of edgeOwners.values()) { + if (owners.length === 1) { + boundaryEdgeCount += 1; + continue; + } + if (owners.length !== 2) { + throw new Error(`cssFlower side-local edge has ${owners.length} owners`); + } + sharedEdgeCount += 1; + for (const owner of owners) { + edgeMasks[owner.triangleIndex] |= 1 << owner.edgeIndex; + } + } + const sharedEdgeIncidenceCount = sharedEdgeCount * 2; + const boundaryEdgeIncidenceCount = boundaryEdgeCount; + const boundaryAdjacentTriangles = topology.triangles.map((triangle) => ( + triangle.pointIndices.some((pointIndex) => boundaryPointIndices.has(pointIndex)) + )); + const boundaryAdjacentTriangleCount = boundaryAdjacentTriangles.filter(Boolean).length; + if (sharedEdgeCount !== 1680 || boundaryEdgeCount !== 240 || + sharedEdgeIncidenceCount !== 3360 || boundaryEdgeIncidenceCount !== 240 || + boundaryPointIndices.size !== 240 || boundaryAdjacentTriangleCount !== 432 || + edgeMasks.reduce((sum, mask) => sum + bitCount3(mask), 0) !== sharedEdgeIncidenceCount || + edgeMasks.some((mask) => mask < 1 || mask > 7)) { + throw new Error("cssFlower side-local sibling seam inventory drifted"); + } + return Object.freeze({ + schema: "cssflower-side-sibling-seam-plan@1", + policy: "side-local-shared-edges-boundary-ring-damped", + boundaryVertexCount: boundaryPointIndices.size, + boundaryAdjacentTriangles: Object.freeze(boundaryAdjacentTriangles), + boundaryAdjacentTriangleCount, + edgeMasks: Object.freeze(edgeMasks), + sharedEdgeCount, + sharedEdgeIncidenceCount, + boundaryEdgeCount, + boundaryEdgeIncidenceCount, + }); +} + +export function deformCubePoints(topology, sf) { + const positions = new Float32Array(topology.points.length * 3); + for (const point of topology.points) { + const scale = addFloat(multiplyFloat(point.radialCoefficient, FLOAT(sf)), 1); + const offset = point.index * 3; + positions[offset] = multiplyFloat(point.source[0], scale); + positions[offset + 1] = multiplyFloat(point.source[1], scale); + positions[offset + 2] = multiplyFloat(point.source[2], scale); + } + return positions; +} + +export function computeSmoothPointNormals(topology, positions) { + const sums = new Float32Array(topology.points.length * 3); + for (const strip of topology.strips) { + let index1 = strip.pointIndices[0]; + let index2 = strip.pointIndices[1]; + for (let stripTriangle = 0; stripTriangle < strip.pointIndices.length - 2; stripTriangle += 1) { + const index3 = strip.pointIndices[stripTriangle + 2]; + const a = index1 * 3; + const b = index2 * 3; + const c = index3 * 3; + const v1 = [ + subtractFloat(positions[c], positions[a]), + subtractFloat(positions[c + 1], positions[a + 1]), + subtractFloat(positions[c + 2], positions[a + 2]), + ]; + const v2 = [ + subtractFloat(positions[b], positions[a]), + subtractFloat(positions[b + 1], positions[a + 1]), + subtractFloat(positions[b + 2], positions[a + 2]), + ]; + const normal = [ + subtractFloat(multiplyFloat(v1[1], v2[2]), multiplyFloat(v2[1], v1[2])), + subtractFloat(multiplyFloat(v1[2], v2[0]), multiplyFloat(v2[2], v1[0])), + subtractFloat(multiplyFloat(v1[0], v2[1]), multiplyFloat(v2[0], v1[1])), + ]; + if ((stripTriangle & 1) === 0) { + normal[0] = FLOAT(-normal[0]); + normal[1] = FLOAT(-normal[1]); + normal[2] = FLOAT(-normal[2]); + } + for (const pointIndex of [index1, index2, index3]) { + const offset = pointIndex * 3; + sums[offset] = addFloat(sums[offset], normal[0]); + sums[offset + 1] = addFloat(sums[offset + 1], normal[1]); + sums[offset + 2] = addFloat(sums[offset + 2], normal[2]); + } + index1 = index2; + index2 = index3; + } + } + return sums; +} + +export function trianglePolygon(topology, triangle, positions) { + return { + vertices: triangle.pointIndices.map((pointIndex) => { + const offset = pointIndex * 3; + return sourceToPolyCss([positions[offset], positions[offset + 1], positions[offset + 2]]); + }), + color: triangle.material.color, + data: { + "cssflower-triangle": triangle.id, + "cssflower-leaf-index": triangle.index, + "cssflower-side": triangle.side, + "cssflower-side-id": triangle.sideId, + "cssflower-strip": triangle.strip, + "cssflower-strip-triangle": triangle.stripTriangle, + "cssflower-material": triangle.material.id, + "cssflower-seam-bleed": 0, + }, + }; +} + +function mapToSourcePlane(plane, x, y) { + return [0, 1, 2].map((component) => addFloat( + addFloat(multiplyFloat(x, plane.xAxis[component]), multiplyFloat(y, plane.yAxis[component])), + plane.base[component], + )); +} + +function sourceLength(value) { + const xy = addFloat(multiplyFloat(value[0], value[0]), multiplyFloat(value[1], value[1])); + const xyz = addFloat(xy, multiplyFloat(value[2], value[2])); + return FLOAT(Math.sqrt(xyz)); +} + +function addFloat(left, right) { + return FLOAT(FLOAT(left) + FLOAT(right)); +} + +function subtractFloat(left, right) { + return FLOAT(FLOAT(left) - FLOAT(right)); +} + +function multiplyFloat(left, right) { + return FLOAT(FLOAT(left) * FLOAT(right)); +} + +function divideFloat(left, right) { + return FLOAT(FLOAT(left) / FLOAT(right)); +} + +function bitCount3(mask) { + return (mask & 1) + ((mask >> 1) & 1) + ((mask >> 2) & 1); +} diff --git a/src/adapters/flowerbox/src/prepare/cssflower/dataSource.mjs b/src/adapters/flowerbox/src/prepare/cssflower/dataSource.mjs new file mode 100644 index 0000000..87b1f57 --- /dev/null +++ b/src/adapters/flowerbox/src/prepare/cssflower/dataSource.mjs @@ -0,0 +1,78 @@ +import { existsSync, readFileSync, statSync } from "node:fs"; +import { resolve } from "node:path"; + +export const NATIVE_ROOT_ENV = "CSSFLOWER_NATIVE_ROOT"; +const EXPECTED_SOURCE_COMMIT = "9478d25a677f70dbe4fc0ed317cc5a5e5050ef8b"; +const REPO_ROOT = resolve(import.meta.dirname, "../../../../../.."); +const RETAINED_NATIVE_COMPARISON = resolve( + REPO_ROOT, + ".local/oracle/cssflower/native/state/native-cssflower-compare.json", +); + +export async function resolveCssflowerDataSource(options = {}) { + const rawRoot = options.nativeRoot ?? process.env[NATIVE_ROOT_ENV] ?? ""; + const qualification = retainedNativeQualification(); + if (rawRoot) { + const root = resolve(String(rawRoot)); + if (!existsSync(root) || !statSync(root).isDirectory()) { + throw new Error(NATIVE_ROOT_ENV + " does not point to a readable directory: " + root); + } + return { + kind: "user-supplied-native-authority", + env: NATIVE_ROOT_ENV, + root, + publicLabel: NATIVE_ROOT_ENV, + legalLabel: "owned-local-authority-not-redistributed", + nativeAuthorityStatus: qualification ? "available-qualified" : "unqualified", + nativeQualification: qualification, + }; + } + + if (qualification) { + return { + kind: "identity-bound-local-native-authority", + env: null, + root: null, + publicLabel: "ignored local identity-bound native oracle", + legalLabel: "local-oracle-evidence-not-redistributed", + nativeAuthorityStatus: "available-qualified", + nativeQualification: qualification, + }; + } + + return { + kind: "documented-source-behavior", + env: null, + root: null, + publicLabel: "src/adapters/flowerbox/README.md", + legalLabel: "independently-authored-results-only", + nativeAuthorityStatus: "missing", + nativeQualification: null, + }; +} + +function retainedNativeQualification() { + if (!existsSync(RETAINED_NATIVE_COMPARISON)) return null; + try { + const report = JSON.parse(readFileSync(RETAINED_NATIVE_COMPARISON, "utf8")); + if (report?.status !== "pass" || + report?.nativeBinding?.sourceCommit !== EXPECTED_SOURCE_COMMIT || + report?.nativeStateWasNotUsedAsCandidateInput !== true || + report?.firstDivergence !== null || + report?.comparedTickCount !== 9_331) { + return null; + } + return Object.freeze({ + schema: report.schema, + status: report.status, + sourceCommit: report.nativeBinding.sourceCommit, + executableSha256: report.nativeBinding.executable.sha256, + compilerSha256: report.nativeBinding.compiler.sha256, + comparedTickCount: report.comparedTickCount, + firstDivergence: report.firstDivergence, + candidateIndependence: report.engineIndependence.status, + }); + } catch { + return null; + } +} diff --git a/src/adapters/flowerbox/src/prepare/cssflower/leafRasterLighting.mjs b/src/adapters/flowerbox/src/prepare/cssflower/leafRasterLighting.mjs new file mode 100644 index 0000000..41bec9d --- /dev/null +++ b/src/adapters/flowerbox/src/prepare/cssflower/leafRasterLighting.mjs @@ -0,0 +1,430 @@ +import { + CSSFLOWER_LIGHTING_ATLAS_WIDTH, + CSSFLOWER_LIGHTING_GUTTER, + CSSFLOWER_LIGHTING_MICRO_CONTENT_SIZE, + CSSFLOWER_LIGHTING_PAGE_ROWS, + CSSFLOWER_LIGHTING_SAMPLING, +} from "../../cssflower/renderContract.mjs"; + +export const CSSFLOWER_LEAF_RASTER_GUTTER = 1; +export const CSSFLOWER_LEAF_RASTER_ATLAS_WIDTH = 768; +export const CSSFLOWER_LEAF_RASTER_PAGE_ROWS = CSSFLOWER_LIGHTING_PAGE_ROWS; +export const CSSFLOWER_LEAF_RASTER_MAX_TEXTURE_DIMENSION = 8_192; + +const lightingWeightCache = new Map(); +const affineMicroWeightCache = new Map(); + +export function buildPreparedLeafRasterLayout(faces, options = {}) { + if (!Array.isArray(faces) || faces.length < 1) { + throw new TypeError("Prepared cssFlower leaf raster layout requires faces"); + } + const atlasWidth = options.atlasWidth ?? CSSFLOWER_LEAF_RASTER_ATLAS_WIDTH; + const pageRowCount = options.pageRowCount ?? CSSFLOWER_LEAF_RASTER_PAGE_ROWS; + const gutter = options.gutter ?? CSSFLOWER_LEAF_RASTER_GUTTER; + const maximumTextureDimension = options.maximumTextureDimension ?? + CSSFLOWER_LEAF_RASTER_MAX_TEXTURE_DIMENSION; + if (!Number.isSafeInteger(atlasWidth) || atlasWidth < 1 || atlasWidth > maximumTextureDimension || + !Number.isSafeInteger(pageRowCount) || pageRowCount < 1 || + !Number.isSafeInteger(gutter) || gutter < 1 || + !Number.isSafeInteger(maximumTextureDimension) || maximumTextureDimension < 1) { + throw new RangeError("Prepared cssFlower leaf raster layout options are invalid"); + } + + const tiles = faces.map((face, faceIndex) => { + const sourceOrder = Number(face?.sourceOrder); + const width = Number(face?.leafWidth); + const height = Number(face?.leafHeight); + if (!Number.isSafeInteger(sourceOrder) || sourceOrder !== faceIndex || + !Number.isSafeInteger(width) || width < 2 || + !Number.isSafeInteger(height) || height < 2) { + throw new TypeError(`Prepared cssFlower leaf raster face ${faceIndex} is invalid`); + } + const slotWidth = width + gutter * 2; + const slotHeight = height + gutter * 2; + if (slotWidth > atlasWidth) { + throw new RangeError(`Prepared cssFlower leaf raster face ${faceIndex} exceeds the atlas width`); + } + return { sourceOrder, width, height, slotWidth, slotHeight }; + }).sort((left, right) => ( + right.slotHeight - left.slotHeight || + right.slotWidth - left.slotWidth || + left.sourceOrder - right.sourceOrder + )); + + const placements = new Array(faces.length); + let x = 0; + let y = 0; + let shelfHeight = 0; + let shelfCount = 1; + for (const tile of tiles) { + if (x > 0 && x + tile.slotWidth > atlasWidth) { + y += shelfHeight; + x = 0; + shelfHeight = 0; + shelfCount += 1; + } + placements[tile.sourceOrder] = Object.freeze({ + sourceOrder: tile.sourceOrder, + slotX: x, + slotY: y, + slotWidth: tile.slotWidth, + slotHeight: tile.slotHeight, + contentX: x + gutter, + contentY: y + gutter, + width: tile.width, + height: tile.height, + }); + x += tile.slotWidth; + shelfHeight = Math.max(shelfHeight, tile.slotHeight); + } + const stateSliceHeight = y + shelfHeight; + const atlasHeight = stateSliceHeight * pageRowCount; + if (atlasHeight > maximumTextureDimension) { + throw new RangeError( + `Prepared cssFlower leaf raster page ${atlasWidth}x${atlasHeight} exceeds ${maximumTextureDimension}`, + ); + } + const occupiedPixelsPerState = placements.reduce((sum, placement) => ( + sum + placement.slotWidth * placement.slotHeight + ), 0); + const decodedBytesPerFullPage = atlasWidth * atlasHeight * 4; + return Object.freeze({ + schema: "cssflower-prepared-leaf-raster-layout@1", + packing: "height-descending-deterministic-shelves", + sampling: "endpoint-aligned-pixel-centers", + gutterPolicy: "one-pixel-clamped-edge-duplication", + atlasWidth, + atlasHeight, + pageRowCount, + stateSliceHeight, + gutter, + shelfCount, + maximumTextureDimension, + occupiedPixelsPerState, + packedPixelsPerState: atlasWidth * stateSliceHeight, + packingEfficiency: occupiedPixelsPerState / (atlasWidth * stateSliceHeight), + decodedBytesPerFullPage, + placements: Object.freeze(placements), + }); +} + +export function buildPreparedAffineMicroRasterLayout(faces, options = {}) { + if (!Array.isArray(faces) || faces.length < 1) { + throw new TypeError("Prepared cssFlower affine micro-raster layout requires faces"); + } + const atlasWidth = options.atlasWidth ?? CSSFLOWER_LIGHTING_ATLAS_WIDTH; + const pageRowCount = options.pageRowCount ?? CSSFLOWER_LIGHTING_PAGE_ROWS; + const contentSize = options.contentSize ?? CSSFLOWER_LIGHTING_MICRO_CONTENT_SIZE; + const gutter = options.gutter ?? CSSFLOWER_LIGHTING_GUTTER; + const maximumTextureDimension = options.maximumTextureDimension ?? + CSSFLOWER_LEAF_RASTER_MAX_TEXTURE_DIMENSION; + const slotSize = contentSize + gutter * 2; + if (!Number.isSafeInteger(atlasWidth) || atlasWidth < slotSize || atlasWidth > maximumTextureDimension || + !Number.isSafeInteger(pageRowCount) || pageRowCount < 1 || + !Number.isSafeInteger(contentSize) || contentSize < 2 || + !Number.isSafeInteger(gutter) || gutter < 1 || + !Number.isSafeInteger(maximumTextureDimension) || maximumTextureDimension < 1) { + throw new RangeError("Prepared cssFlower affine micro-raster layout options are invalid"); + } + for (let index = 0; index < faces.length; index += 1) { + const face = faces[index]; + if (face?.sourceOrder !== index || !Number.isSafeInteger(face.leafWidth) || face.leafWidth < 2 || + !Number.isSafeInteger(face.leafHeight) || face.leafHeight < 2) { + throw new TypeError(`Prepared cssFlower affine micro-raster face ${index} is invalid`); + } + } + const tilesPerShelf = Math.floor(atlasWidth / slotSize); + const shelfCount = Math.ceil(faces.length / tilesPerShelf); + const stateSliceHeight = shelfCount * slotSize; + const atlasHeight = stateSliceHeight * pageRowCount; + if (atlasHeight > maximumTextureDimension) { + throw new RangeError( + `Prepared cssFlower affine micro-raster page ${atlasWidth}x${atlasHeight} exceeds ${maximumTextureDimension}`, + ); + } + const placements = Object.freeze(faces.map((face, index) => { + const slotX = (index % tilesPerShelf) * slotSize; + const slotY = Math.floor(index / tilesPerShelf) * slotSize; + return Object.freeze({ + sourceOrder: face.sourceOrder, + slotX, + slotY, + slotWidth: slotSize, + slotHeight: slotSize, + contentX: slotX + gutter, + contentY: slotY + gutter, + width: contentSize, + height: contentSize, + leafWidth: face.leafWidth, + leafHeight: face.leafHeight, + }); + })); + const occupiedPixelsPerState = faces.length * slotSize * slotSize; + const packedPixelsPerState = atlasWidth * stateSliceHeight; + return Object.freeze({ + schema: "cssflower-prepared-affine-micro-raster-layout@1", + packing: "source-order-fixed-square-micro-texel-shelves", + sampling: CSSFLOWER_LIGHTING_SAMPLING, + gutterPolicy: "one-affine-extrapolated-sample-plus-outer-clamped-padding", + displayExpansion: "per-leaf-static-background-size", + atlasWidth, + atlasHeight, + pageRowCount, + stateSliceHeight, + contentSize, + gutter, + slotSize, + tilesPerShelf, + shelfCount, + maximumTextureDimension, + occupiedPixelsPerState, + contentPixelsPerState: faces.length * contentSize * contentSize, + packedPixelsPerState, + packingEfficiency: occupiedPixelsPerState / packedPixelsPerState, + decodedBytesPerFullPage: atlasWidth * atlasHeight * 4, + placements, + }); +} + +export function writePreparedAffineMicroLightingTile({ + atlasData, + atlasPixels, + atlasWidth, + rowIndex, + faceIndex, + layout, + canonicalPointIndices, + canonicalPointOffset = 0, + vertexColors, +}) { + if (!(atlasData instanceof Uint8Array)) { + throw new TypeError("Prepared cssFlower affine micro-raster atlas data must be bytes"); + } + const placement = layout?.placements?.[faceIndex]; + const validPointIndices = Array.isArray(canonicalPointIndices) || + canonicalPointIndices instanceof Uint16Array || + canonicalPointIndices instanceof Uint32Array; + const validVertexColors = vertexColors instanceof Uint8Array || + vertexColors instanceof Uint8ClampedArray || + vertexColors instanceof Float32Array || + vertexColors instanceof Float64Array; + const pixels = atlasPixels ?? ( + atlasData.byteOffset % Uint32Array.BYTES_PER_ELEMENT === 0 + ? new Uint32Array( + atlasData.buffer, + atlasData.byteOffset, + atlasData.byteLength / Uint32Array.BYTES_PER_ELEMENT, + ) + : null + ); + if (!pixels || pixels.length * Uint32Array.BYTES_PER_ELEMENT !== atlasData.byteLength || + layout?.schema !== "cssflower-prepared-affine-micro-raster-layout@1" || + !placement || atlasWidth !== layout.atlasWidth || + !Number.isSafeInteger(rowIndex) || rowIndex < 0 || rowIndex >= layout.pageRowCount || + !validPointIndices || !Number.isSafeInteger(canonicalPointOffset) || canonicalPointOffset < 0 || + canonicalPointOffset + 3 > canonicalPointIndices.length || !validVertexColors) { + throw new TypeError("Prepared cssFlower affine micro-raster tile inputs are invalid"); + } + const point0 = canonicalPointIndices[canonicalPointOffset]; + const point1 = canonicalPointIndices[canonicalPointOffset + 1]; + const point2 = canonicalPointIndices[canonicalPointOffset + 2]; + const weights = preparedAffineMicroWeights(layout.contentSize, layout.gutter); + const originX = placement.contentX - layout.gutter; + const originY = rowIndex * layout.stateSliceHeight + placement.contentY - layout.gutter; + let weightOffset = 0; + for (let y = 0; y < layout.slotSize; y += 1) { + for (let x = 0; x < layout.slotSize; x += 1) { + const apex = weights[weightOffset]; + const left = weights[weightOffset + 1]; + const right = weights[weightOffset + 2]; + weightOffset += 3; + const red = clampByte( + vertexColors[point0 * 3] * apex + + vertexColors[point1 * 3] * left + + vertexColors[point2 * 3] * right, + ); + const green = clampByte( + vertexColors[point0 * 3 + 1] * apex + + vertexColors[point1 * 3 + 1] * left + + vertexColors[point2 * 3 + 1] * right, + ); + const blue = clampByte( + vertexColors[point0 * 3 + 2] * apex + + vertexColors[point1 * 3 + 2] * left + + vertexColors[point2 * 3 + 2] * right, + ); + pixels[(originY + y) * atlasWidth + originX + x] = + red | (green << 8) | (blue << 16) | (255 << 24); + } + } +} + +export function writePreparedLeafRasterLightingTile({ + atlasData, + atlasPixels, + atlasWidth, + rowIndex, + faceIndex, + layout, + canonicalPointIndices, + canonicalPointOffset = 0, + vertexColors, +}) { + if (!(atlasData instanceof Uint8Array)) { + throw new TypeError("Prepared cssFlower leaf raster atlas data must be bytes"); + } + const placement = layout?.placements?.[faceIndex]; + const validPointIndices = Array.isArray(canonicalPointIndices) || + canonicalPointIndices instanceof Uint16Array || + canonicalPointIndices instanceof Uint32Array; + const validVertexColors = vertexColors instanceof Uint8Array || + vertexColors instanceof Uint8ClampedArray || + vertexColors instanceof Float32Array || + vertexColors instanceof Float64Array; + const pixels = atlasPixels ?? ( + atlasData.byteOffset % Uint32Array.BYTES_PER_ELEMENT === 0 + ? new Uint32Array( + atlasData.buffer, + atlasData.byteOffset, + atlasData.byteLength / Uint32Array.BYTES_PER_ELEMENT, + ) + : null + ); + if (!pixels || pixels.length * Uint32Array.BYTES_PER_ELEMENT !== atlasData.byteLength || + !placement || atlasWidth !== layout.atlasWidth || + !Number.isSafeInteger(rowIndex) || rowIndex < 0 || rowIndex >= layout.pageRowCount || + !validPointIndices || + !Number.isSafeInteger(canonicalPointOffset) || canonicalPointOffset < 0 || + canonicalPointOffset + 3 > canonicalPointIndices.length || + !validVertexColors) { + throw new TypeError("Prepared cssFlower leaf raster tile inputs are invalid"); + } + const point0 = canonicalPointIndices[canonicalPointOffset]; + const point1 = canonicalPointIndices[canonicalPointOffset + 1]; + const point2 = canonicalPointIndices[canonicalPointOffset + 2]; + const originX = placement.contentX; + const originY = rowIndex * layout.stateSliceHeight + placement.contentY; + const weights = preparedLightingWeights(placement.width, placement.height); + let weightOffset = 0; + for (let y = 0; y < placement.height; y += 1) { + let firstPixel = 0; + let lastPixel = 0; + for (let x = 0; x < placement.width; x += 1) { + const apex = weights[weightOffset]; + const left = weights[weightOffset + 1]; + const right = weights[weightOffset + 2]; + weightOffset += 3; + const red = clampByte( + vertexColors[point0 * 3] * apex + + vertexColors[point1 * 3] * left + + vertexColors[point2 * 3] * right, + ); + const green = clampByte( + vertexColors[point0 * 3 + 1] * apex + + vertexColors[point1 * 3 + 1] * left + + vertexColors[point2 * 3 + 1] * right, + ); + const blue = clampByte( + vertexColors[point0 * 3 + 2] * apex + + vertexColors[point1 * 3 + 2] * left + + vertexColors[point2 * 3 + 2] * right, + ); + const pixel = red | (green << 8) | (blue << 16) | (255 << 24); + pixels[(originY + y) * atlasWidth + originX + x] = pixel; + if (x === 0) firstPixel = pixel; + if (x === placement.width - 1) lastPixel = pixel; + } + const rowOffset = (originY + y) * atlasWidth; + for (let gutterX = 1; gutterX <= layout.gutter; gutterX += 1) { + pixels[rowOffset + originX - gutterX] = firstPixel; + pixels[rowOffset + originX + placement.width - 1 + gutterX] = lastPixel; + } + } + const slotX = originX - layout.gutter; + const slotWidth = placement.width + layout.gutter * 2; + const firstRowOffset = originY * atlasWidth + slotX; + const lastRowOffset = (originY + placement.height - 1) * atlasWidth + slotX; + for (let gutterY = 1; gutterY <= layout.gutter; gutterY += 1) { + pixels.copyWithin( + (originY - gutterY) * atlasWidth + slotX, + firstRowOffset, + firstRowOffset + slotWidth, + ); + pixels.copyWithin( + (originY + placement.height - 1 + gutterY) * atlasWidth + slotX, + lastRowOffset, + lastRowOffset + slotWidth, + ); + } +} + +function preparedLightingWeights(width, height) { + const key = `${width}x${height}`; + const cached = lightingWeightCache.get(key); + if (cached) return cached; + const weights = new Float64Array(width * height * 3); + let offset = 0; + for (let y = 0; y < height; y += 1) { + const v = y / (height - 1); + for (let x = 0; x < width; x += 1) { + const u = x / (width - 1); + const apex = 1 - v; + const right = u - 0.5 * apex; + const left = 1 - apex - right; + weights[offset] = apex; + weights[offset + 1] = left; + weights[offset + 2] = right; + offset += 3; + } + } + lightingWeightCache.set(key, weights); + return weights; +} + +function preparedAffineMicroWeights(contentSize, gutter) { + const key = `${contentSize}:${gutter}`; + const cached = affineMicroWeightCache.get(key); + if (cached) return cached; + const slotSize = contentSize + gutter * 2; + const weights = new Float64Array(slotSize * slotSize * 3); + const minimumSample = -0.5 / contentSize; + const maximumSample = 1 + 0.5 / contentSize; + let offset = 0; + for (let localY = -gutter; localY < contentSize + gutter; localY += 1) { + const v = clamp((localY + 0.5) / contentSize, minimumSample, maximumSample); + for (let localX = -gutter; localX < contentSize + gutter; localX += 1) { + const u = clamp((localX + 0.5) / contentSize, minimumSample, maximumSample); + const apex = 1 - v; + const right = u - 0.5 * apex; + const left = 1 - apex - right; + weights[offset] = apex; + weights[offset + 1] = left; + weights[offset + 2] = right; + offset += 3; + } + } + affineMicroWeightCache.set(key, weights); + return weights; +} + +export function leafRasterBackgroundBinding(layout, faceIndex, rowIndex) { + const placement = layout?.placements?.[faceIndex]; + if (!placement || !Number.isSafeInteger(rowIndex) || rowIndex < 0 || rowIndex >= layout.pageRowCount) { + throw new RangeError("Prepared cssFlower leaf raster background binding is invalid"); + } + return Object.freeze({ + backgroundSize: `${layout.atlasWidth}px ${layout.atlasHeight}px`, + backgroundPositionX: `${-placement.contentX}px`, + backgroundPositionY: `${-(rowIndex * layout.stateSliceHeight + placement.contentY)}px`, + temporalOffsetY: `${-rowIndex * layout.stateSliceHeight}px`, + faceOffsetY: `${-placement.contentY}px`, + }); +} + +function clampByte(value) { + return Math.max(0, Math.min(255, Math.round(value))); +} + +function clamp(value, minimum, maximum) { + return Math.max(minimum, Math.min(maximum, value)); +} diff --git a/src/adapters/flowerbox/src/prepare/cssflower/paths.mjs b/src/adapters/flowerbox/src/prepare/cssflower/paths.mjs new file mode 100644 index 0000000..1bdc928 --- /dev/null +++ b/src/adapters/flowerbox/src/prepare/cssflower/paths.mjs @@ -0,0 +1,95 @@ +import { fileURLToPath } from "node:url"; +import { join, resolve } from "node:path"; +import { + CSSFLOWER_LIGHTING_ATLAS_EXTENSION, + CSSFLOWER_PROJECTED_ATLAS_EXTENSION, +} from "../../cssflower/renderContract.mjs"; + +export const repoRoot = resolve(fileURLToPath(new URL("../../../../../../", import.meta.url))); +export const adapterRoot = resolve(fileURLToPath(new URL("../../../", import.meta.url))); +export const localRoot = join(repoRoot, ".local", "cssflower"); +export const generatedRoot = resolve( + process.env.CSSFLOWER_GENERATED_ROOT ?? join(repoRoot, "build", "generated"), +); +export const generatedPublicRoot = join(generatedRoot, "public", "cssflower"); +export const generatedSceneDir = join(generatedPublicRoot, "scenes"); +export const generatedAssetDir = join(generatedPublicRoot, "assets"); +export const generatedTransformAssetDir = join(generatedAssetDir, "transforms"); +export const generatedLightingAssetDir = join(generatedAssetDir, "lighting"); +export const generatedProjectedAssetDir = join(generatedAssetDir, "projected"); +export const manifestPath = join(generatedPublicRoot, "manifest.json"); +export const generatedTransformsPath = join(generatedAssetDir, "flower-box-transforms.f32"); +export const generatedLightingPath = join(generatedAssetDir, "flower-box-space-texels.png"); +export const generatedStateEvidencePath = join(generatedAssetDir, "flower-box-state-evidence.json"); +export const localPreparedTransformsPath = join(localRoot, "prepared", "flower-box-transforms.f32"); + +export function generatedScenePath(sceneId) { + return join(generatedSceneDir, sceneId + ".json"); +} + +export function generatedSceneUrl(sceneId) { + return "/cssflower/scenes/" + sceneId + ".json"; +} + +export function generatedLightingPagePath(pageIndex) { + if (!Number.isSafeInteger(pageIndex) || pageIndex < 0) { + throw new RangeError("cssFlower lighting page index must be a non-negative integer"); + } + return pageIndex === 0 + ? generatedLightingPath + : join(generatedAssetDir, `flower-box-space-texels-page-${String(pageIndex).padStart(3, "0")}.png`); +} + +export function generatedTransformBlockPath(sha256) { + assertSha256(sha256); + return join(generatedTransformAssetDir, `block-${sha256}.matrix3d.pack`); +} + +export function generatedTransformBlockUrl(sha256) { + assertSha256(sha256); + return `/cssflower/assets/transforms/block-${sha256}.matrix3d.pack`; +} + +export function generatedPreparedLightingPath(sha256) { + assertSha256(sha256); + return join(generatedLightingAssetDir, `grid-${sha256}.${CSSFLOWER_LIGHTING_ATLAS_EXTENSION}`); +} + +export function generatedPreparedLightingUrl(sha256) { + assertSha256(sha256); + return `/cssflower/assets/lighting/grid-${sha256}.${CSSFLOWER_LIGHTING_ATLAS_EXTENSION}`; +} + +export function generatedProjectedAtlasPath(sha256) { + assertSha256(sha256); + return join(generatedProjectedAssetDir, `atlas-${sha256}.${CSSFLOWER_PROJECTED_ATLAS_EXTENSION}`); +} + +export function generatedProjectedAtlasUrl(sha256) { + assertSha256(sha256); + return `/cssflower/assets/projected/atlas-${sha256}.${CSSFLOWER_PROJECTED_ATLAS_EXTENSION}`; +} + +export function generatedProjectedLayoutPath(sha256) { + assertSha256(sha256); + return join(generatedProjectedAssetDir, `layout-${sha256}.i16`); +} + +export function generatedProjectedLayoutUrl(sha256) { + assertSha256(sha256); + return `/cssflower/assets/projected/layout-${sha256}.i16`; +} + +export function generatedSharedLayoutBlockPath(sha256) { + assertSha256(sha256); + return join(generatedProjectedAssetDir, `layout-block-${sha256}.i16pack`); +} + +export function generatedSharedLayoutBlockUrl(sha256) { + assertSha256(sha256); + return `/cssflower/assets/projected/layout-block-${sha256}.i16pack`; +} + +function assertSha256(value) { + if (!/^[a-f0-9]{64}$/u.test(value ?? "")) throw new TypeError("cssFlower content-addressed asset hash is invalid"); +} diff --git a/src/adapters/flowerbox/src/prepare/cssflower/prepare.mjs b/src/adapters/flowerbox/src/prepare/cssflower/prepare.mjs new file mode 100644 index 0000000..d2e1705 --- /dev/null +++ b/src/adapters/flowerbox/src/prepare/cssflower/prepare.mjs @@ -0,0 +1,43 @@ +import { + buildCssflowerFirstSliceScene, +} from "./sceneBuilder.mjs"; +import { compilePreparedCssflowerCycle } from "./compilePreparedCycle.mjs"; +import { + resolveCssflowerDataSource, +} from "./dataSource.mjs"; +import { + writeCssflowerPreparedOutput, +} from "./writeManifest.mjs"; +import { + writeCssflowerPreparedAssets, + createCssflowerPreparedLightingPageStore, +} from "./writePreparedAssets.mjs"; + +export async function prepareCssflower(options = {}) { + const dataSource = await resolveCssflowerDataSource({ + nativeRoot: options.nativeRoot, + }); + const sceneId = options.scene ?? "default-cube"; + const lightingPageStore = await createCssflowerPreparedLightingPageStore(); + const compiled = await compilePreparedCssflowerCycle({ + nativeAuthorityStatus: dataSource?.nativeAuthorityStatus ?? "missing", + readLightingPage: lightingPageStore.read, + writeLightingPage: lightingPageStore.write, + }); + const assets = await writeCssflowerPreparedAssets(compiled, { lightingPageStore }); + const { scene } = await buildCssflowerFirstSliceScene({ + compiled, + dataSource, + preparedAssets: assets, + sceneId, + }); + const output = await writeCssflowerPreparedOutput({ + scenes: [scene], + defaultSceneId: scene.id, + }); + return Object.freeze({ + ...output, + scene, + assets: Object.freeze({ ...assets, lightingPageCache: lightingPageStore.stats() }), + }); +} diff --git a/src/adapters/flowerbox/src/prepare/cssflower/projectedPixels.mjs b/src/adapters/flowerbox/src/prepare/cssflower/projectedPixels.mjs new file mode 100644 index 0000000..d4d25e9 --- /dev/null +++ b/src/adapters/flowerbox/src/prepare/cssflower/projectedPixels.mjs @@ -0,0 +1,1024 @@ +import { createHash } from "node:crypto"; +import { PNG } from "pngjs"; +import { + buildPreparedFullRotationCycle, + buildPreparedRoundedProductCycle, +} from "./bloomCycle.mjs"; +import { + buildCubeTopology, + computeSmoothPointNormals, + deformCubePoints, +} from "./cubeTopology.mjs"; +import { computePreparedVertexLightingUnquantized } from "./compilePreparedCycle.mjs"; +import { CSSFLOWER_SOURCE_PROFILE } from "./sourceProfile.mjs"; + +const EDGE = CSSFLOWER_SOURCE_PROFILE.camera.stagePixels; +const HALF_EDGE = EDGE / 2; +const CAMERA = CSSFLOWER_SOURCE_PROFILE.camera; +const FOCAL_PIXELS = HALF_EDGE / Math.tan(CAMERA.fovDegrees * Math.PI / 360); +const ATLAS_WIDTH = 2_048; +const SPACE_TIME_ATLAS_WIDTH = 8_192; +const DEPTH_MAX = (1 << 16) - 1; +const EPSILON = 1e-10; +const topology = buildCubeTopology(); +const cycle = buildPreparedFullRotationCycle(); +const roundedProductOwnedPixelCounts = new Map(); +const roundedProductTriangleAdjacency = new Map(); + +export function buildCssflowerPreparedInverseRootTransforms() { + return Object.freeze(Array.from({ length: cycle.rootStateCount }, (_, rootStateIndex) => ( + preparedInverseRootTransform({ rootStateIndex }) + ))); +} + +export function buildCssflowerPreparedFrontFacingSchedule() { + return buildFrontFacingSchedule(cycle); +} + +export function buildCssflowerPreparedRoundedFrontFacingSchedule(dilationTicks = 0) { + if (!Number.isSafeInteger(dilationTicks) || dilationTicks < 0 || dilationTicks > 4) { + throw new RangeError("Prepared cssFlower front-face dilation must be an integer from zero through four"); + } + const schedule = buildFrontFacingSchedule(buildPreparedRoundedProductCycle()); + if (dilationTicks === 0) return schedule; + return Object.freeze(schedule.map((_, stateIndex) => { + const selected = new Uint8Array(topology.triangleCount); + for (let offset = -dilationTicks; offset <= dilationTicks; offset += 1) { + const neighbor = (stateIndex + offset + schedule.length) % schedule.length; + for (const leafIndex of schedule[neighbor]) selected[leafIndex] = 1; + } + return Object.freeze(Array.from(selected, (value, leafIndex) => value === 1 ? leafIndex : -1) + .filter((leafIndex) => leafIndex >= 0)); + })); +} + +export function buildCssflowerPreparedRoundedOcclusionSchedule(options = {}) { + const { + adjacency = "edge", + adjacencyRings = 1, + minimumOwnedPixels = 1, + sampleGrid = 1, + temporalDilationTicks = 1, + } = options; + if (!["edge", "vertex"].includes(adjacency) || + !Number.isSafeInteger(adjacencyRings) || adjacencyRings < 0 || adjacencyRings > 4 || + !Number.isSafeInteger(minimumOwnedPixels) || minimumOwnedPixels < 1 || minimumOwnedPixels > EDGE * EDGE || + ![1, 2, 4].includes(sampleGrid) || + !Number.isSafeInteger(temporalDilationTicks) || temporalDilationTicks < 0 || temporalDilationTicks > 4) { + throw new RangeError("Prepared cssFlower occlusion schedule options are invalid"); + } + const ownedPixelCounts = getRoundedProductOwnedPixelCounts(sampleGrid); + const adjacencyTable = adjacencyRings > 0 ? triangleAdjacency(adjacency) : null; + const spatialSchedule = ownedPixelCounts.map((counts) => { + const selected = new Uint8Array(topology.triangleCount); + for (let leafIndex = 0; leafIndex < counts.length; leafIndex += 1) { + if (counts[leafIndex] >= minimumOwnedPixels) selected[leafIndex] = 1; + } + for (let ring = 0; ring < adjacencyRings; ring += 1) { + const previous = selected.slice(); + for (let leafIndex = 0; leafIndex < previous.length; leafIndex += 1) { + if (previous[leafIndex] !== 1) continue; + for (const neighbor of adjacencyTable[leafIndex]) selected[neighbor] = 1; + } + } + return selected; + }); + return Object.freeze(spatialSchedule.map((_, stateIndex) => { + const selected = new Uint8Array(topology.triangleCount); + for (let offset = -temporalDilationTicks; offset <= temporalDilationTicks; offset += 1) { + const neighbor = (stateIndex + offset + spatialSchedule.length) % spatialSchedule.length; + for (let leafIndex = 0; leafIndex < topology.triangleCount; leafIndex += 1) { + selected[leafIndex] |= spatialSchedule[neighbor][leafIndex]; + } + } + return Object.freeze(Array.from(selected, (value, leafIndex) => value === 1 ? leafIndex : -1) + .filter((leafIndex) => leafIndex >= 0)); + })); +} + +function getRoundedProductOwnedPixelCounts(sampleGrid) { + const cached = roundedProductOwnedPixelCounts.get(sampleGrid); + if (cached) return cached; + const productCycle = buildPreparedRoundedProductCycle(); + const sampleCount = sampleGrid * sampleGrid; + const owner = new Int16Array(EDGE * EDGE * sampleCount); + const depth = new Uint16Array(EDGE * EDGE * sampleCount); + const counts = new Uint32Array(topology.triangleCount); + const result = Object.freeze(productCycle.states.map((state) => { + owner.fill(-1); + depth.fill(DEPTH_MAX); + counts.fill(0); + const positions = deformCubePoints(topology, state.sf); + const rotation = sourceModelRotation(state); + const projectedPoints = topology.points.map((point) => { + const pointOffset = point.index * 3; + const world = rotateSourceVector(rotation, [ + positions[pointOffset], + positions[pointOffset + 1], + positions[pointOffset + 2], + ]); + const eyeDistance = CAMERA.eye[2] - world[2]; + if (eyeDistance < CAMERA.near || eyeDistance > CAMERA.far) { + throw new Error(`cssFlower prepared occlusion point ${point.index} left the source clip interval`); + } + return { + x: HALF_EDGE + FOCAL_PIXELS * world[0] / eyeDistance, + y: HALF_EDGE - FOCAL_PIXELS * world[1] / eyeDistance, + windowDepth: windowDepthForEyeDistance(eyeDistance), + }; + }); + for (const triangle of topology.triangles) { + const first = projectedPoints[triangle.pointIndices[0]]; + const second = projectedPoints[triangle.pointIndices[1]]; + const third = projectedPoints[triangle.pointIndices[2]]; + const area = orient(first, second, third); + if (area >= -EPSILON) continue; + const inverseArea = 1 / area; + const minX = Math.max(0, Math.floor(Math.min(first.x, second.x, third.x))); + const maxX = Math.min(EDGE - 1, Math.floor(Math.max(first.x, second.x, third.x))); + const minY = Math.max(0, Math.floor(Math.min(first.y, second.y, third.y))); + const maxY = Math.min(EDGE - 1, Math.floor(Math.max(first.y, second.y, third.y))); + for (let y = minY; y <= maxY; y += 1) { + for (let x = minX; x <= maxX; x += 1) { + const pixelIndex = y * EDGE + x; + for (let sampleY = 0; sampleY < sampleGrid; sampleY += 1) { + for (let sampleX = 0; sampleX < sampleGrid; sampleX += 1) { + const sample = { + x: x + (sampleX + 0.5) / sampleGrid, + y: y + (sampleY + 0.5) / sampleGrid, + }; + const weight0 = orient(second, third, sample) * inverseArea; + const weight1 = orient(third, first, sample) * inverseArea; + const weight2 = orient(first, second, sample) * inverseArea; + if (weight0 < -EPSILON || weight1 < -EPSILON || weight2 < -EPSILON) continue; + const windowDepth = weight0 * first.windowDepth + + weight1 * second.windowDepth + weight2 * third.windowDepth; + const depth16 = Math.max(0, Math.min(DEPTH_MAX, Math.round(windowDepth * DEPTH_MAX))); + const sampleIndex = pixelIndex * sampleCount + sampleY * sampleGrid + sampleX; + if (depth16 >= depth[sampleIndex]) continue; + const previousOwner = owner[sampleIndex]; + if (previousOwner >= 0) counts[previousOwner] -= 1; + owner[sampleIndex] = triangle.index; + depth[sampleIndex] = depth16; + counts[triangle.index] += 1; + } + } + } + } + } + const result = Object.freeze(Array.from(counts)); + if (!result.some((count) => count > 0)) { + throw new Error(`Prepared cssFlower occlusion schedule is empty at tick ${state.tick}`); + } + return result; + })); + roundedProductOwnedPixelCounts.set(sampleGrid, result); + return result; +} + +function triangleAdjacency(kind) { + const cached = roundedProductTriangleAdjacency.get(kind); + if (cached) return cached; + const owners = new Map(); + for (const triangle of topology.triangles) { + const pointKeys = triangle.pointIndices.map((pointIndex) => topology.points[pointIndex].source.join(",")); + const keys = kind === "edge" + ? [ + [pointKeys[0], pointKeys[1]].sort().join("|"), + [pointKeys[1], pointKeys[2]].sort().join("|"), + [pointKeys[2], pointKeys[0]].sort().join("|"), + ] + : pointKeys; + for (const key of keys) { + const indices = owners.get(key) ?? []; + indices.push(triangle.index); + owners.set(key, indices); + } + } + const neighbors = Array.from({ length: topology.triangleCount }, () => new Set()); + for (const indices of owners.values()) { + for (const left of indices) { + for (const right of indices) { + if (left !== right) neighbors[left].add(right); + } + } + } + const result = Object.freeze(neighbors.map((values) => Object.freeze([...values].sort((a, b) => a - b)))); + roundedProductTriangleAdjacency.set(kind, result); + return result; +} + +function buildFrontFacingSchedule(selectedCycle) { + return Object.freeze(selectedCycle.states.map((state) => { + const positions = deformCubePoints(topology, state.sf); + const rotation = sourceModelRotation(state); + const projectedPoints = topology.points.map((point) => { + const offset = point.index * 3; + const world = rotateSourceVector(rotation, [ + positions[offset], + positions[offset + 1], + positions[offset + 2], + ]); + const eyeDistance = CAMERA.eye[2] - world[2]; + return Object.freeze({ + x: HALF_EDGE + FOCAL_PIXELS * world[0] / eyeDistance, + y: HALF_EDGE - FOCAL_PIXELS * world[1] / eyeDistance, + }); + }); + const leafIndices = topology.triangles + .filter((triangle) => { + const vertices = triangle.pointIndices.map((pointIndex) => projectedPoints[pointIndex]); + return orient(vertices[0], vertices[1], vertices[2]) < -EPSILON; + }) + .map((triangle) => triangle.index); + if (leafIndices.length < 1 || leafIndices.length >= topology.triangleCount) { + throw new Error(`Prepared cssFlower front-face count ${leafIndices.length} drifted at tick ${state.tick}`); + } + return Object.freeze(leafIndices); + })); +} + +export function scanCssflowerProjectedLeafBounds() { + const leaves = Array.from({ length: topology.triangleCount }, (_, index) => ({ + index, + maxWidth: 0, + maxHeight: 0, + maxArea: 0, + maxAreaTick: -1, + visibleStateCount: 0, + })); + const states = []; + for (const state of cycle.states) { + const positions = deformCubePoints(topology, state.sf); + const rotation = sourceModelRotation(state); + const projectedPoints = topology.points.map((point) => { + const offset = point.index * 3; + const world = rotateSourceVector(rotation, [ + positions[offset], + positions[offset + 1], + positions[offset + 2], + ]); + const eyeDistance = CAMERA.eye[2] - world[2]; + return Object.freeze({ + x: HALF_EDGE + FOCAL_PIXELS * world[0] / eyeDistance, + y: HALF_EDGE - FOCAL_PIXELS * world[1] / eyeDistance, + }); + }); + let frontFacingTriangleCount = 0; + let boundingArea = 0; + let maxLeafArea = 0; + let maxLeafWidth = 0; + let maxLeafHeight = 0; + for (const triangle of topology.triangles) { + const vertices = triangle.pointIndices.map((pointIndex) => projectedPoints[pointIndex]); + if (orient(vertices[0], vertices[1], vertices[2]) >= -EPSILON) continue; + frontFacingTriangleCount += 1; + const minX = Math.max(0, Math.ceil(Math.min(...vertices.map((value) => value.x)) - 0.5)); + const maxX = Math.min(EDGE - 1, Math.floor(Math.max(...vertices.map((value) => value.x)) - 0.5)); + const minY = Math.max(0, Math.ceil(Math.min(...vertices.map((value) => value.y)) - 0.5)); + const maxY = Math.min(EDGE - 1, Math.floor(Math.max(...vertices.map((value) => value.y)) - 0.5)); + const width = Math.max(0, maxX - minX + 1); + const height = Math.max(0, maxY - minY + 1); + const area = width * height; + const leaf = leaves[triangle.index]; + leaf.visibleStateCount += 1; + leaf.maxWidth = Math.max(leaf.maxWidth, width); + leaf.maxHeight = Math.max(leaf.maxHeight, height); + if (area > leaf.maxArea) { + leaf.maxArea = area; + leaf.maxAreaTick = state.tick; + } + boundingArea += area; + maxLeafArea = Math.max(maxLeafArea, area); + maxLeafWidth = Math.max(maxLeafWidth, width); + maxLeafHeight = Math.max(maxLeafHeight, height); + } + states.push(Object.freeze({ + tick: state.tick, + sf: state.sf, + sfHex: state.sfHex, + geometryStateIndex: state.geometryStateIndex, + rootStateIndex: state.rootStateIndex, + frontFacingTriangleCount, + boundingArea, + maxLeafArea, + maxLeafWidth, + maxLeafHeight, + })); + } + const fixedSlotArea = leaves.reduce((sum, leaf) => sum + leaf.maxWidth * leaf.maxHeight, 0); + return Object.freeze({ + schema: "cssflower-projected-leaf-bound-scan@1", + stateCount: states.length, + retainedLeafCount: leaves.length, + fixedSlotArea, + fixedSlotDecodedBytesPerFrame: fixedSlotArea * 4, + maximumLeafWidth: Math.max(...leaves.map((leaf) => leaf.maxWidth)), + maximumLeafHeight: Math.max(...leaves.map((leaf) => leaf.maxHeight)), + maximumLeafArea: Math.max(...leaves.map((leaf) => leaf.maxArea)), + leaves: Object.freeze(leaves.map((leaf) => Object.freeze(leaf))), + states: Object.freeze(states), + }); +} + +export function prepareCssflowerProjectedPixelSpaceTimeBank(ticks) { + if (!Array.isArray(ticks) || ticks.length < 1 || + ticks.some((tick) => !Number.isSafeInteger(tick) || tick < 0) || + new Set(ticks).size !== ticks.length) { + throw new TypeError("Prepared projected space-time bank requires unique non-negative safe ticks"); + } + const states = ticks.map((tick) => rasterizeCssflowerProjectedPixels(tick)); + const patchesByState = states.map((state) => state.leaves); + const slots = Array.from({ length: topology.triangleCount }, (_, index) => { + const visiblePatches = patchesByState + .map((patchesForState) => patchesForState[index]) + .filter((patch) => patch.pixelCount > 0); + const left = visiblePatches.length ? Math.min(...visiblePatches.map((patch) => patch.left)) : EDGE; + const top = visiblePatches.length ? Math.min(...visiblePatches.map((patch) => patch.top)) : EDGE; + const right = visiblePatches.length ? Math.max(...visiblePatches.map((patch) => patch.right)) : -1; + const bottom = visiblePatches.length ? Math.max(...visiblePatches.map((patch) => patch.bottom)) : -1; + return { + index, + pixelCount: visiblePatches.reduce((sum, patch) => sum + patch.pixelCount, 0), + left, + top, + right, + bottom, + width: Math.max(0, right - left + 1), + height: Math.max(0, bottom - top + 1), + atlasX: 0, + atlasY: 0, + }; + }); + const atlasHeight = packSpaceTimeLeafStrips(slots, states.length); + const atlas = new PNG({ width: SPACE_TIME_ATLAS_WIDTH, height: atlasHeight, colorType: 6 }); + for (let stateIndex = 0; stateIndex < states.length; stateIndex += 1) { + const state = states[stateIndex]; + const frame = state.frameImage; + const patches = patchesByState[stateIndex]; + for (let pixelIndex = 0; pixelIndex < state.owner.length; pixelIndex += 1) { + const triangleIndex = state.owner[pixelIndex]; + if (triangleIndex < 0) continue; + const slot = slots[triangleIndex]; + const x = pixelIndex % EDGE; + const y = Math.floor(pixelIndex / EDGE); + const atlasX = slot.atlasX + x - slot.left; + const atlasY = slot.atlasY + stateIndex * slot.height + y - slot.top; + const sourceOffset = pixelIndex * 4; + const atlasOffset = (atlasY * atlas.width + atlasX) * 4; + atlas.data[atlasOffset] = frame.data[sourceOffset]; + atlas.data[atlasOffset + 1] = frame.data[sourceOffset + 1]; + atlas.data[atlasOffset + 2] = frame.data[sourceOffset + 2]; + atlas.data[atlasOffset + 3] = 255; + } + } + const atlasBytes = PNG.sync.write(atlas, { colorType: 6, inputColorType: 6 }); + const leafCss = slots.map((slot) => slot.pixelCount === 0 + ? hiddenBankLeafCss() + : spaceTimeBankLeafCss(slot, atlas.width, atlas.height)); + const packets = states.map((state, stateIndex) => { + const sourceState = cycle.states[state.timelineStateIndex]; + return Object.freeze({ + tick: state.tick, + sf: state.state.sf, + rootTransform: state.rootTransform, + meshTransform: preparedInverseRootTransform(sourceState), + frameIndex: stateIndex, + visibleLeafCount: state.topology.visibleLeafCount, + }); + }); + return Object.freeze({ + schema: "cssflower-prepared-projected-pixel-space-time-bank@1", + layout: "source-order-fixed-union-leaf-strips-by-consecutive-source-frame-rows", + ticks: Object.freeze([...ticks]), + retainedLeafCount: topology.triangleCount, + activeUnionLeafCount: slots.filter((slot) => slot.pixelCount > 0).length, + frameCount: states.length, + slots: Object.freeze(slots.map((slot) => Object.freeze({ ...slot }))), + leafCss: Object.freeze(leafCss), + packets: Object.freeze(packets), + atlas: Object.freeze({ + width: atlas.width, + height: atlas.height, + bytes: atlasBytes, + sha256: sha256(atlasBytes), + dataUrl: `data:image/png;base64,${atlasBytes.toString("base64")}`, + }), + states: Object.freeze(states.map(projectedStateDescriptor)), + authority: Object.freeze({ + precedent: "cssgraphics-super-mario-64-source-order-face-columns-by-source-frame-rows", + input: "independently-prepared-cssflower-source-state", + nativeStateIngestion: false, + nativePixelIngestion: false, + runtimeProjection: false, + runtimeRasterization: false, + runtimeGeometryConstruction: false, + }), + }); +} + +export function prepareCssflowerProjectedPixelBank(ticks) { + if (!Array.isArray(ticks) || ticks.length < 1 || + ticks.some((tick) => !Number.isSafeInteger(tick) || tick < 0) || + new Set(ticks).size !== ticks.length) { + throw new TypeError("Prepared projected-pixel bank requires unique non-negative safe ticks"); + } + const states = ticks.map((tick) => prepareCssflowerProjectedPixels(tick)); + const patchesByState = states.map((state) => buildLeafPatches(state.owner)); + const slots = Array.from({ length: topology.triangleCount }, (_, index) => { + const patches = patchesByState.map((patchesForState) => patchesForState[index]); + return { + index, + pixelCount: patches.reduce((sum, patch) => sum + patch.pixelCount, 0), + width: Math.max(0, ...patches.map((patch) => patch.width)), + height: Math.max(0, ...patches.map((patch) => patch.height)), + atlasX: 0, + atlasY: 0, + }; + }); + const stateStride = packLeafPatches(slots); + const atlas = new PNG({ width: ATLAS_WIDTH, height: stateStride * states.length, colorType: 6 }); + for (let stateIndex = 0; stateIndex < states.length; stateIndex += 1) { + const state = states[stateIndex]; + const frame = PNG.sync.read(state.frame.bytes); + const patches = patchesByState[stateIndex]; + const stateY = stateIndex * stateStride; + for (let pixelIndex = 0; pixelIndex < state.owner.length; pixelIndex += 1) { + const triangleIndex = state.owner[pixelIndex]; + if (triangleIndex < 0) continue; + const slot = slots[triangleIndex]; + const patch = patches[triangleIndex]; + const x = pixelIndex % EDGE; + const y = Math.floor(pixelIndex / EDGE); + const atlasX = slot.atlasX + x - patch.left; + const atlasY = stateY + slot.atlasY + y - patch.top; + const sourceOffset = pixelIndex * 4; + const atlasOffset = (atlasY * atlas.width + atlasX) * 4; + atlas.data[atlasOffset] = frame.data[sourceOffset]; + atlas.data[atlasOffset + 1] = frame.data[sourceOffset + 1]; + atlas.data[atlasOffset + 2] = frame.data[sourceOffset + 2]; + atlas.data[atlasOffset + 3] = 255; + } + } + const atlasBytes = PNG.sync.write(atlas, { colorType: 6, inputColorType: 6 }); + const leafCss = slots.map((slot) => slot.pixelCount === 0 + ? hiddenBankLeafCss() + : bankLeafCss(slot, atlas.width, atlas.height)); + const packets = states.map((state, stateIndex) => { + const inverseRoot = inverseCssRootRotation(cycle.states[state.timelineStateIndex]); + const patches = patchesByState[stateIndex]; + return Object.freeze({ + tick: state.tick, + sf: state.state.sf, + rootTransform: state.rootTransform, + atlasY: -stateIndex * stateStride, + visibleLeafCount: state.topology.visibleLeafCount, + transforms: Object.freeze(patches.map((patch, triangleIndex) => { + if (slots[triangleIndex].pixelCount === 0) return "none"; + return patch.pixelCount === 0 + ? projectedLeafTransform({ left: -10_000, top: -10_000 }, inverseRoot) + : projectedLeafTransform(patch, inverseRoot); + })), + }); + }); + return Object.freeze({ + schema: "cssflower-prepared-projected-pixel-bank@1", + ticks: Object.freeze([...ticks]), + retainedLeafCount: topology.triangleCount, + activeUnionLeafCount: slots.filter((slot) => slot.pixelCount > 0).length, + stateStride, + leafCss: Object.freeze(leafCss), + packets: Object.freeze(packets), + atlas: Object.freeze({ + width: atlas.width, + height: atlas.height, + bytes: atlasBytes, + sha256: sha256(atlasBytes), + dataUrl: `data:image/png;base64,${atlasBytes.toString("base64")}`, + }), + states: Object.freeze(states), + authority: Object.freeze({ + input: "independently-prepared-cssflower-source-state", + nativeStateIngestion: false, + nativePixelIngestion: false, + runtimeProjection: false, + runtimeRasterization: false, + runtimeGeometryConstruction: false, + }), + }); +} + +function rasterizeCssflowerProjectedPixels(tick) { + if (!Number.isSafeInteger(tick) || tick < 0) { + throw new RangeError("Prepared projected pixels require a non-negative safe tick"); + } + const timelineStateIndex = tick < cycle.stateCount + ? tick + : cycle.cycleStartState + ((tick - cycle.cycleStartState) % cycle.cycleLength); + const state = cycle.states[timelineStateIndex]; + const positions = deformCubePoints(topology, state.sf); + const normals = computeSmoothPointNormals(topology, positions); + const colors = computePreparedVertexLightingUnquantized(topology, positions, normals, state); + const rotation = sourceModelRotation(state); + const projectedPoints = topology.points.map((point) => { + const offset = point.index * 3; + const world = rotateSourceVector(rotation, [ + positions[offset], + positions[offset + 1], + positions[offset + 2], + ]); + const eyeDistance = CAMERA.eye[2] - world[2]; + if (eyeDistance < CAMERA.near || eyeDistance > CAMERA.far) { + throw new Error(`cssFlower projected point ${point.index} left the qualified source clip interval`); + } + return Object.freeze({ + x: HALF_EDGE + FOCAL_PIXELS * world[0] / eyeDistance, + y: HALF_EDGE - FOCAL_PIXELS * world[1] / eyeDistance, + inverseEyeDistance: 1 / eyeDistance, + windowDepth: windowDepthForEyeDistance(eyeDistance), + colorOffset: offset, + }); + }); + + const frame = new PNG({ width: EDGE, height: EDGE, colorType: 6 }); + const owner = new Int16Array(EDGE * EDGE); + const depth = new Uint16Array(EDGE * EDGE); + owner.fill(-1); + depth.fill(DEPTH_MAX); + for (let offset = 0; offset < frame.data.length; offset += 4) frame.data[offset + 3] = 255; + + let frontFacingTriangleCount = 0; + let candidateFragmentCount = 0; + let depthAcceptedFragmentCount = 0; + for (const triangle of topology.triangles) { + const vertices = triangle.pointIndices.map((pointIndex) => projectedPoints[pointIndex]); + const area = orient(vertices[0], vertices[1], vertices[2]); + // Source GL uses the default CCW front face. The stored image has its Y axis + // flipped relative to OpenGL window coordinates, so front faces are negative. + if (area >= -EPSILON) continue; + frontFacingTriangleCount += 1; + const minX = Math.max(0, Math.ceil(Math.min(...vertices.map((value) => value.x)) - 0.5)); + const maxX = Math.min(EDGE - 1, Math.floor(Math.max(...vertices.map((value) => value.x)) - 0.5)); + const minY = Math.max(0, Math.ceil(Math.min(...vertices.map((value) => value.y)) - 0.5)); + const maxY = Math.min(EDGE - 1, Math.floor(Math.max(...vertices.map((value) => value.y)) - 0.5)); + for (let y = minY; y <= maxY; y += 1) { + for (let x = minX; x <= maxX; x += 1) { + const sample = { x: x + 0.5, y: y + 0.5 }; + const barycentric = [ + orient(vertices[1], vertices[2], sample) / area, + orient(vertices[2], vertices[0], sample) / area, + orient(vertices[0], vertices[1], sample) / area, + ]; + if (barycentric.some((value) => value < -EPSILON)) continue; + candidateFragmentCount += 1; + const inverseEyeDistance = barycentric.reduce((sum, weight, index) => ( + sum + weight * vertices[index].inverseEyeDistance + ), 0); + const windowDepth = barycentric.reduce((sum, weight, index) => ( + sum + weight * vertices[index].windowDepth + ), 0); + const depth16 = Math.max(0, Math.min(DEPTH_MAX, Math.round(windowDepth * DEPTH_MAX))); + const pixelIndex = y * EDGE + x; + if (depth16 >= depth[pixelIndex]) continue; + depthAcceptedFragmentCount += 1; + depth[pixelIndex] = depth16; + owner[pixelIndex] = triangle.index; + const frameOffset = pixelIndex * 4; + for (let channel = 0; channel < 3; channel += 1) { + const numerator = barycentric.reduce((sum, weight, index) => ( + sum + weight * colors[vertices[index].colorOffset + channel] * vertices[index].inverseEyeDistance + ), 0); + frame.data[frameOffset + channel] = clampByte(numerator / inverseEyeDistance); + } + } + } + } + + const leaves = buildLeafPatches(owner); + const rootTransform = cycle.rootTransforms[state.rootStateIndex]; + const ownedPixelCount = leaves.reduce((sum, leaf) => sum + leaf.pixelCount, 0); + return Object.freeze({ + tick, + timelineStateIndex, + state: Object.freeze({ + sf: state.sf, + sfHex: state.sfHex, + sfi: state.sfi, + sfiHex: state.sfiHex, + geometryStateIndex: state.geometryStateIndex, + rootStateIndex: state.rootStateIndex, + rotationDegrees: Object.freeze([ + state.rotationXDegrees, + state.rotationYDegrees, + state.rotationZDegrees, + ]), + }), + viewport: Object.freeze({ width: EDGE, height: EDGE, deviceScaleFactor: 1 }), + projection: Object.freeze({ + fovDegrees: CAMERA.fovDegrees, + eye: Object.freeze([...CAMERA.eye]), + near: CAMERA.near, + far: CAMERA.far, + focalPixels: FOCAL_PIXELS, + rasterSample: "integer-pixel-center", + cull: "source-default-CCW-front", + depth: "source-depth16-less", + interpolation: "perspective-correct-smooth-vertex-lighting", + }), + topology: Object.freeze({ + pointCount: topology.points.length, + triangleCount: topology.triangleCount, + retainedLeafCount: topology.triangleCount, + frontFacingTriangleCount, + visibleLeafCount: leaves.filter((leaf) => leaf.pixelCount > 0).length, + ownedPixelCount, + candidateFragmentCount, + depthAcceptedFragmentCount, + }), + rootTransform, + frameImage: frame, + owner, + leaves, + authority: Object.freeze({ + input: "independently-prepared-cssflower-source-state", + nativeStateIngestion: false, + nativePixelIngestion: false, + runtimeProjection: false, + runtimeRasterization: false, + runtimeGeometryConstruction: false, + }), + }); +} + +export function prepareCssflowerProjectedPixels(tick) { + const raster = rasterizeCssflowerProjectedPixels(tick); + const leaves = raster.leaves; + const atlasHeight = packLeafPatches(leaves); + const atlas = new PNG({ width: ATLAS_WIDTH, height: atlasHeight, colorType: 6 }); + for (let pixelIndex = 0; pixelIndex < raster.owner.length; pixelIndex += 1) { + const triangleIndex = raster.owner[pixelIndex]; + if (triangleIndex < 0) continue; + const leaf = leaves[triangleIndex]; + const x = pixelIndex % EDGE; + const y = Math.floor(pixelIndex / EDGE); + const atlasX = leaf.atlasX + x - leaf.left; + const atlasY = leaf.atlasY + y - leaf.top; + const sourceOffset = pixelIndex * 4; + const atlasOffset = (atlasY * atlas.width + atlasX) * 4; + atlas.data[atlasOffset] = raster.frameImage.data[sourceOffset]; + atlas.data[atlasOffset + 1] = raster.frameImage.data[sourceOffset + 1]; + atlas.data[atlasOffset + 2] = raster.frameImage.data[sourceOffset + 2]; + atlas.data[atlasOffset + 3] = 255; + } + const frameBytes = PNG.sync.write(raster.frameImage, { colorType: 2, inputColorType: 6 }); + const atlasBytes = PNG.sync.write(atlas, { colorType: 6, inputColorType: 6 }); + const sourceState = cycle.states[raster.timelineStateIndex]; + const inverseRoot = inverseCssRootRotation(sourceState); + const leafCss = leaves.map((leaf) => leaf.pixelCount === 0 + ? hiddenLeafCss() + : visibleLeafCss(leaf, inverseRoot, atlas.width, atlas.height)); + return Object.freeze({ + ...projectedStateDescriptor(raster), + schema: "cssflower-prepared-projected-pixels@1", + leafCss: Object.freeze(leafCss), + atlas: Object.freeze({ + width: atlas.width, + height: atlas.height, + bytes: atlasBytes, + sha256: sha256(atlasBytes), + dataUrl: `data:image/png;base64,${atlasBytes.toString("base64")}`, + }), + frame: Object.freeze({ + width: raster.frameImage.width, + height: raster.frameImage.height, + bytes: frameBytes, + sha256: sha256(frameBytes), + }), + owner: raster.owner, + }); +} + +export function prepareCssflowerProjectedFrame(tick) { + const raster = rasterizeCssflowerProjectedPixels(tick); + return Object.freeze({ + ...projectedStateDescriptor(raster), + schema: "cssflower-prepared-projected-frame@1", + frameImage: raster.frameImage, + owner: raster.owner, + leaves: raster.leaves, + }); +} + +function projectedStateDescriptor(raster) { + return Object.freeze({ + tick: raster.tick, + timelineStateIndex: raster.timelineStateIndex, + state: raster.state, + viewport: raster.viewport, + projection: raster.projection, + topology: raster.topology, + rootTransform: raster.rootTransform, + authority: raster.authority, + }); +} + +function buildLeafPatches(owner) { + const leaves = Array.from({ length: topology.triangleCount }, (_, index) => ({ + index, + pixelCount: 0, + left: EDGE, + top: EDGE, + right: -1, + bottom: -1, + width: 0, + height: 0, + atlasX: 0, + atlasY: 0, + })); + for (let pixelIndex = 0; pixelIndex < owner.length; pixelIndex += 1) { + const triangleIndex = owner[pixelIndex]; + if (triangleIndex < 0) continue; + const x = pixelIndex % EDGE; + const y = Math.floor(pixelIndex / EDGE); + const leaf = leaves[triangleIndex]; + leaf.pixelCount += 1; + leaf.left = Math.min(leaf.left, x); + leaf.top = Math.min(leaf.top, y); + leaf.right = Math.max(leaf.right, x); + leaf.bottom = Math.max(leaf.bottom, y); + } + for (const leaf of leaves) { + if (leaf.pixelCount === 0) continue; + leaf.width = leaf.right - leaf.left + 1; + leaf.height = leaf.bottom - leaf.top + 1; + } + return leaves; +} + +function packLeafPatches(leaves) { + let x = 1; + let y = 1; + let rowHeight = 0; + for (const leaf of leaves) { + if (leaf.pixelCount === 0) continue; + if (leaf.width + 2 > ATLAS_WIDTH) { + throw new Error(`Projected leaf ${leaf.index} exceeds the prepared atlas width`); + } + if (x + leaf.width + 1 > ATLAS_WIDTH) { + x = 1; + y += rowHeight + 2; + rowHeight = 0; + } + leaf.atlasX = x; + leaf.atlasY = y; + x += leaf.width + 2; + rowHeight = Math.max(rowHeight, leaf.height); + } + return Math.max(1, y + rowHeight + 1); +} + +function packSpaceTimeLeafStrips(slots, stateCount) { + let x = 1; + let y = 1; + let rowHeight = 0; + for (const slot of slots) { + if (slot.pixelCount === 0) continue; + const stripHeight = slot.height * stateCount; + if (slot.width + 2 > SPACE_TIME_ATLAS_WIDTH || stripHeight + 2 > 8_192) { + throw new Error(`Projected space-time leaf ${slot.index} exceeds the 8192px page bound`); + } + if (x + slot.width + 1 > SPACE_TIME_ATLAS_WIDTH) { + x = 1; + y += rowHeight + 2; + rowHeight = 0; + } + slot.atlasX = x; + slot.atlasY = y; + x += slot.width + 2; + rowHeight = Math.max(rowHeight, stripHeight); + } + const height = Math.max(1, y + rowHeight + 1); + if (height > 8_192) throw new Error(`Projected space-time page height ${height} exceeds 8192px`); + return height; +} + +function visibleLeafCss(leaf, inverseRoot, atlasWidth, atlasHeight) { + return [ + "position:absolute", + "display:block", + "left:0", + "top:0", + `width:${leaf.width}px`, + `height:${leaf.height}px`, + "box-sizing:content-box", + "margin:0", + "padding:0", + "border:0", + "border-radius:0", + "corner-top-left-shape:initial", + "corner-top-right-shape:initial", + "corner-bottom-right-shape:initial", + "corner-bottom-left-shape:initial", + "transform-origin:0 0", + "transform-style:preserve-3d", + "backface-visibility:visible", + `transform:${projectedLeafTransform(leaf, inverseRoot)}`, + "background-image:var(--cssflower-projected-atlas)", + "background-color:transparent", + "background-repeat:no-repeat", + `background-position:${-leaf.atlasX}px ${-leaf.atlasY}px`, + `background-size:${atlasWidth}px ${atlasHeight}px`, + "image-rendering:auto", + "color:transparent", + "line-height:0", + "text-decoration:none", + ].join(";"); +} + +function bankLeafCss(slot, atlasWidth, atlasHeight) { + return [ + "position:absolute", + "display:block", + "left:0", + "top:0", + `width:${slot.width}px`, + `height:${slot.height}px`, + "box-sizing:content-box", + "margin:0", + "padding:0", + "border:0", + "border-radius:0", + "corner-top-left-shape:initial", + "corner-top-right-shape:initial", + "corner-bottom-right-shape:initial", + "corner-bottom-left-shape:initial", + "transform-origin:0 0", + "transform-style:preserve-3d", + "backface-visibility:visible", + "background-image:var(--cssflower-projected-atlas)", + "background-color:transparent", + "background-repeat:no-repeat", + `background-position:${-slot.atlasX}px calc(var(--cssflower-projected-y) - ${slot.atlasY}px)`, + `background-size:${atlasWidth}px ${atlasHeight}px`, + "image-rendering:pixelated", + "color:transparent", + "line-height:0", + "text-decoration:none", + ].join(";"); +} + +function spaceTimeBankLeafCss(slot, atlasWidth, atlasHeight) { + return [ + "position:absolute", + "display:block", + "left:0", + "top:0", + `width:${slot.width}px`, + `height:${slot.height}px`, + "box-sizing:content-box", + "margin:0", + "padding:0", + "border:0", + "border-radius:0", + "corner-top-left-shape:initial", + "corner-top-right-shape:initial", + "corner-bottom-right-shape:initial", + "corner-bottom-left-shape:initial", + "transform-origin:0 0", + "transform-style:preserve-3d", + "backface-visibility:visible", + `transform:${projectedLeafScreenTranslation(slot)}`, + "background-image:var(--cssflower-projected-atlas)", + "background-color:transparent", + "background-repeat:no-repeat", + `background-position:${-slot.atlasX}px calc(${-slot.atlasY}px - var(--cssflower-projected-frame) * ${slot.height}px)`, + `background-size:${atlasWidth}px ${atlasHeight}px`, + "image-rendering:pixelated", + "color:transparent", + "line-height:0", + "text-decoration:none", + ].join(";"); +} + +function hiddenBankLeafCss() { + return "position:absolute;display:none;left:0;top:0;width:0;height:0;transform:none;background:none;border:0"; +} + +function projectedLeafTransform(leaf, inverseRoot) { + const targetTranslation = [leaf.left - HALF_EDGE, leaf.top - HALF_EDGE, 0]; + const localTranslation = multiplyRotationVector(inverseRoot, targetTranslation); + const matrix = [ + inverseRoot[0][0], inverseRoot[1][0], inverseRoot[2][0], 0, + inverseRoot[0][1], inverseRoot[1][1], inverseRoot[2][1], 0, + inverseRoot[0][2], inverseRoot[1][2], inverseRoot[2][2], 0, + localTranslation[0], localTranslation[1], localTranslation[2], 1, + ].map(formatCssNumber).join(","); + return `matrix3d(${matrix})`; +} + +function projectedLeafFactoredTransform(leaf, state) { + const x = normalizeDegrees(state.rootStateIndex * CSSFLOWER_SOURCE_PROFILE.rotation.xDegreesPerUpdate); + const y = normalizeDegrees(state.rootStateIndex * CSSFLOWER_SOURCE_PROFILE.rotation.yDegreesPerUpdate); + const dx = leaf.left - HALF_EDGE; + const dy = leaf.top - HALF_EDGE; + return `rotateY(${-y}deg) rotateX(${x}deg) translate3d(${dx}px,${dy}px,0px)`; +} + +function preparedInverseRootTransform(state) { + const x = normalizeDegrees(state.rootStateIndex * CSSFLOWER_SOURCE_PROFILE.rotation.xDegreesPerUpdate); + const y = normalizeDegrees(state.rootStateIndex * CSSFLOWER_SOURCE_PROFILE.rotation.yDegreesPerUpdate); + return `rotateY(${-y}deg) rotateX(${x}deg)`; +} + +function projectedLeafScreenTranslation(leaf) { + return `translate3d(${leaf.left - HALF_EDGE}px,${leaf.top - HALF_EDGE}px,0px)`; +} + +function hiddenLeafCss() { + return "position:absolute;display:none;left:0;top:0;width:0;height:0;transform:none;background:none;border:0"; +} + +function inverseCssRootRotation(state) { + const x = -normalizeDegrees(state.rootStateIndex * CSSFLOWER_SOURCE_PROFILE.rotation.xDegreesPerUpdate) * Math.PI / 180; + const y = normalizeDegrees(state.rootStateIndex * CSSFLOWER_SOURCE_PROFILE.rotation.yDegreesPerUpdate) * Math.PI / 180; + const rx = [ + [1, 0, 0], + [0, Math.cos(x), -Math.sin(x)], + [0, Math.sin(x), Math.cos(x)], + ]; + const ry = [ + [Math.cos(y), 0, Math.sin(y)], + [0, 1, 0], + [-Math.sin(y), 0, Math.cos(y)], + ]; + const root = multiplyRotation(rx, ry); + return transposeRotation(root); +} + +function multiplyRotation(left, right) { + return Array.from({ length: 3 }, (_, row) => Array.from({ length: 3 }, (_, column) => ( + left[row][0] * right[0][column] + + left[row][1] * right[1][column] + + left[row][2] * right[2][column] + ))); +} + +function transposeRotation(value) { + return Array.from({ length: 3 }, (_, row) => Array.from({ length: 3 }, (_, column) => value[column][row])); +} + +function multiplyRotationVector(rotation, value) { + return rotation.map((row) => row[0] * value[0] + row[1] * value[1] + row[2] * value[2]); +} + +function sourceModelRotation(state) { + const radians = Math.PI / 180; + const x = state.rootStateIndex * CSSFLOWER_SOURCE_PROFILE.rotation.xDegreesPerUpdate * radians; + const y = state.rootStateIndex * CSSFLOWER_SOURCE_PROFILE.rotation.yDegreesPerUpdate * radians; + const z = state.rotationZDegrees * radians; + return Object.freeze({ + cx: Math.cos(x), sx: Math.sin(x), + cy: Math.cos(y), sy: Math.sin(y), + cz: Math.cos(z), sz: Math.sin(z), + }); +} + +function rotateSourceVector(rotation, value) { + const xAfterZ = rotation.cz * value[0] - rotation.sz * value[1]; + const yAfterZ = rotation.sz * value[0] + rotation.cz * value[1]; + const xAfterY = rotation.cy * xAfterZ + rotation.sy * value[2]; + const zAfterY = -rotation.sy * xAfterZ + rotation.cy * value[2]; + return [ + xAfterY, + rotation.cx * yAfterZ - rotation.sx * zAfterY, + rotation.sx * yAfterZ + rotation.cx * zAfterY, + ]; +} + +function windowDepthForEyeDistance(eyeDistance) { + const ndc = (CAMERA.far + CAMERA.near) / (CAMERA.far - CAMERA.near) - + (2 * CAMERA.far * CAMERA.near) / ((CAMERA.far - CAMERA.near) * eyeDistance); + return (ndc + 1) / 2; +} + +function orient(a, b, c) { + return (b.x - a.x) * (c.y - a.y) - (b.y - a.y) * (c.x - a.x); +} + +function clampByte(value) { + return Math.max(0, Math.min(255, Math.round(value))); +} + +function normalizeDegrees(value) { + const normalized = value % 360; + return normalized < 0 ? normalized + 360 : normalized; +} + +function formatCssNumber(value) { + const rounded = Math.round(value * 1_000_000_000) / 1_000_000_000; + return String(Object.is(rounded, -0) ? 0 : rounded); +} + +function sha256(bytes) { + return createHash("sha256").update(bytes).digest("hex"); +} diff --git a/src/adapters/flowerbox/src/prepare/cssflower/provenance.mjs b/src/adapters/flowerbox/src/prepare/cssflower/provenance.mjs new file mode 100644 index 0000000..8bd74d2 --- /dev/null +++ b/src/adapters/flowerbox/src/prepare/cssflower/provenance.mjs @@ -0,0 +1,29 @@ +export function sourceProvenanceFor(dataSource) { + return { + project: "cssFlower — Microsoft Flower Box", + dataKind: dataSource.kind, + behaviorAuthority: "src/adapters/flowerbox/README.md", + sourceRevision: "DigitalMars/dmc@9478d25a677f70dbe4fc0ed317cc5a5e5050ef8b", + sourceFiles: [ + { id: "GEOM.C", sha256: "7689779fd3e37a06245f38a0190b863a8ee4c4ed0364d8799206c37f549a69ff" }, + { id: "GEOM.H", sha256: "6d8d179530db1bc26ed2f30430f3280cafe8cb598aa671aa57a4121bed3edadf" }, + { id: "SSFLWBOX.C", sha256: "5322ab133840f5fbd7a4de2d1d701f7c5f333295442c8d728409869bec95d168" }, + { id: "SSFLWBOX.H", sha256: "dfcb99b876480ee5e29b0f72ac9650fb8e2790e8efa023154f974c12cd2ad822" }, + ], + localAuthorityLabel: dataSource.publicLabel, + nativeAuthorityStatus: dataSource.nativeAuthorityStatus, + nativeQualification: dataSource.nativeQualification ?? null, + legal: dataSource.legalLabel ?? "user-supplied-data-not-redistributed", + acquisition: dataSource.nativeQualification + ? "explicit-user-authorized-local-git-fetch-ignored-not-redistributed" + : "none", + redistributableUpstreamBytes: false, + }; +} + +export function assertNoBrowserPathLeaks(value) { + const text = JSON.stringify(value); + if (/\/Users\/|\\\\Users\\\\|file:\/\//.test(text)) { + throw new Error("Generated browser JSON contains a local absolute path."); + } +} diff --git a/src/adapters/flowerbox/src/prepare/cssflower/quadMergeAudit.mjs b/src/adapters/flowerbox/src/prepare/cssflower/quadMergeAudit.mjs new file mode 100644 index 0000000..ddc38eb --- /dev/null +++ b/src/adapters/flowerbox/src/prepare/cssflower/quadMergeAudit.mjs @@ -0,0 +1,109 @@ +import { deformCubePoints } from "./cubeTopology.mjs"; + +export function auditPreparedQuadMergeEligibility(topology, cycle) { + if (topology.triangleCount % 2 !== 0) throw new Error("cssFlower triangle bank cannot be paired into source cells"); + const sourceCellCount = topology.triangleCount / 2; + const nonCoplanarCells = new Set(); + let testedStateCellPairs = 0; + let exactCoplanarStateCellPairs = 0; + let exactNonCoplanarStateCellPairs = 0; + let minimumPositivePlaneDistance = Number.POSITIVE_INFINITY; + let maximumPlaneDistance = 0; + let firstNonCoplanar = null; + let worstNonCoplanar = null; + + for (const geometryState of cycle.geometryStates) { + const positions = deformCubePoints(topology, geometryState.sf); + for (let cellIndex = 0; cellIndex < sourceCellCount; cellIndex += 1) { + const first = topology.triangles[cellIndex * 2]; + const second = topology.triangles[cellIndex * 2 + 1]; + if (first.side !== second.side || first.strip !== second.strip || + first.column !== second.column || first.material.id !== second.material.id) { + throw new Error(`cssFlower source cell pairing drifted at ${cellIndex}`); + } + const pointIndices = [...new Set([...first.pointIndices, ...second.pointIndices])]; + if (pointIndices.length !== 4) throw new Error(`cssFlower source cell ${cellIndex} does not have four corners`); + const points = pointIndices.map((pointIndex) => pointAt(positions, pointIndex)); + const ab = subtract(points[1], points[0]); + const ac = subtract(points[2], points[0]); + const ad = subtract(points[3], points[0]); + const normal = cross(ab, ac); + const signedVolume6 = dot(normal, ad); + const normalLength = Math.hypot(normal[0], normal[1], normal[2]); + const planeDistance = normalLength > 0 ? Math.abs(signedVolume6) / normalLength : Number.POSITIVE_INFINITY; + testedStateCellPairs += 1; + if (signedVolume6 === 0) { + exactCoplanarStateCellPairs += 1; + continue; + } + exactNonCoplanarStateCellPairs += 1; + nonCoplanarCells.add(cellIndex); + minimumPositivePlaneDistance = Math.min(minimumPositivePlaneDistance, planeDistance); + const coordinate = { + geometryStateIndex: geometryState.index, + firstTick: geometryState.firstTick, + sf: geometryState.sf, + sfHex: geometryState.sfHex, + cellIndex, + side: first.side, + strip: first.strip, + column: first.column, + triangleIds: [first.id, second.id], + pointIndices, + signedVolume6, + planeDistance, + }; + if (!firstNonCoplanar) firstNonCoplanar = Object.freeze(coordinate); + if (planeDistance > maximumPlaneDistance) { + maximumPlaneDistance = planeDistance; + worstNonCoplanar = Object.freeze(coordinate); + } + } + } + + const acrossAllStatesEligibleCellCount = sourceCellCount - nonCoplanarCells.size; + if (acrossAllStatesEligibleCellCount !== 0 || exactNonCoplanarStateCellPairs === 0) { + throw new Error("cssFlower quad merge audit unexpectedly found an across-cycle eligible source cell"); + } + return Object.freeze({ + schema: "cssflower-quad-merge-audit@1", + primitiveConsidered: Object.freeze({ tag: "b", kind: "polycss-solid-quad" }), + status: "rejected-noncoplanar-across-prepared-cycle", + comparison: "exact-signed-volume-on-float32-prepared-positions", + sourceCellCount, + geometryStateCount: cycle.geometryStateCount, + testedStateCellPairs, + exactCoplanarStateCellPairs, + exactNonCoplanarStateCellPairs, + cellsNonCoplanarInAtLeastOneState: nonCoplanarCells.size, + acrossAllStatesEligibleCellCount, + minimumPositivePlaneDistance, + maximumPlaneDistance, + firstNonCoplanar, + worstNonCoplanar, + geometryEquivalence: false, + lightingEquivalence: "not-evaluated-after-geometry-prerequisite-failed", + decision: "retain-two-stable-triangle-leaves-per-source-cell", + }); +} + +function pointAt(positions, index) { + const offset = index * 3; + return [positions[offset], positions[offset + 1], positions[offset + 2]]; +} + +function subtract(left, right) { + return [left[0] - right[0], left[1] - right[1], left[2] - right[2]]; +} + +function cross(left, right) { + return [ + left[1] * right[2] - left[2] * right[1], + left[2] * right[0] - left[0] * right[2], + left[0] * right[1] - left[1] * right[0], + ]; +} + +function dot(left, right) { + return left[0] * right[0] + left[1] * right[1] + left[2] * right[2]; +} diff --git a/src/adapters/flowerbox/src/prepare/cssflower/sceneBuilder.mjs b/src/adapters/flowerbox/src/prepare/cssflower/sceneBuilder.mjs new file mode 100644 index 0000000..6e6e329 --- /dev/null +++ b/src/adapters/flowerbox/src/prepare/cssflower/sceneBuilder.mjs @@ -0,0 +1,363 @@ +import { createHash } from "node:crypto"; +import { sourceProvenanceFor } from "./provenance.mjs"; +import { cssflowerSlicePlan } from "./slicePlan.mjs"; +import { CSSFLOWER_CAMERA, CSSFLOWER_SIDE_MATERIALS, CSSFLOWER_SOURCE_PROFILE } from "./sourceProfile.mjs"; +import { buildCssflowerPreparedRoundedOcclusionSchedule } from "./projectedPixels.mjs"; +import { + CSSFLOWER_BOUNDARY_SEAM_BLEED, + CSSFLOWER_FRONT_FACE_DILATION_TICKS, + CSSFLOWER_FRONT_FACE_SCHEDULE_ENCODING, + CSSFLOWER_FRONT_FACE_SCHEDULE_SCHEMA, + CSSFLOWER_LIGHTING_ATLAS_ENCODING, + CSSFLOWER_LIGHTING_ATLAS_MIME_TYPE, + CSSFLOWER_LIGHTING_ATLAS_QUALITY, + CSSFLOWER_LIGHTING_GRID_COLUMNS, + CSSFLOWER_LIGHTING_GRID_HEIGHT, + CSSFLOWER_LIGHTING_GRID_ROWS, + CSSFLOWER_LIGHTING_GRID_WIDTH, + CSSFLOWER_SEAM_BLEED, + CSSFLOWER_TRANSFORM_BLOCK_GEOMETRY_STATES, + CSSFLOWER_VISIBILITY_POLICY, +} from "../../cssflower/renderContract.mjs"; + +export async function buildCssflowerFirstSliceScene({ + compiled, + dataSource, + preparedAssets, + sceneId = "default-cube", +} = {}) { + if (sceneId !== "default-cube") { + throw new RangeError(`Unknown prepared cssFlower scene ${sceneId}`); + } + if (compiled?.topology?.triangleCount !== 1_200 || + preparedAssets?.transforms?.triangleCount !== compiled.topology.triangleCount || + preparedAssets.transforms.geometryStateCount !== compiled.cycle.geometryStateCount || + preparedAssets?.lighting?.pageCount !== compiled.lighting.pageCount) { + throw new Error("Complete source-bound PolyCSS Morph preparation is required"); + } + const playbackStates = preparePlaybackAssetSchedule(compiled.cycle); + const frontFacingSchedule = prepareFrontFacingTransformSchedule( + buildCssflowerPreparedRoundedOcclusionSchedule({ + adjacency: CSSFLOWER_VISIBILITY_POLICY.adjacency, + adjacencyRings: CSSFLOWER_VISIBILITY_POLICY.adjacencyRings, + minimumOwnedPixels: CSSFLOWER_VISIBILITY_POLICY.minimumOwnedPixels, + sampleGrid: CSSFLOWER_VISIBILITY_POLICY.sampleGrid, + temporalDilationTicks: CSSFLOWER_VISIBILITY_POLICY.temporalDilationTicks, + }), + compiled.topology.triangleCount, + ); + const scene = Object.freeze({ + schema: "cssflower-prepared-scene@1", + id: "default-cube", + label: "Microsoft Flower Box — default cube", + mode: cssflowerSlicePlan.mode, + artifactMode: cssflowerSlicePlan.artifactMode, + source: sourceProvenanceFor(dataSource), + sourceProfile: CSSFLOWER_SOURCE_PROFILE, + renderer: Object.freeze({ + package: "@layoutit/polycss", + version: "0.2.11", + morphPackage: "@layoutit/polycss-morph", + morphVersion: "0.2.11", + morphTarget: "createPolyMorphPreparedDomTarget", + representation: "stable-source-triangle-leaves-with-prepared-owned-pixel-occlusion-matrix3d-blocks-and-exact-sparse-leaf-lighting-addresses", + textureBackend: "atlas", + textureLeafSizing: "raster", + stableDom: true, + seamBleed: CSSFLOWER_SEAM_BLEED, + boundarySeamBleed: CSSFLOWER_BOUNDARY_SEAM_BLEED, + seamBleedPolicy: compiled.siblingSeamPlan.policy, + seamBleedSharedEdgeCount: compiled.siblingSeamPlan.sharedEdgeCount, + seamBleedBoundaryEdgeCount: compiled.siblingSeamPlan.boundaryEdgeCount, + seamBleedBoundaryVertexCount: compiled.siblingSeamPlan.boundaryVertexCount, + seamBleedBoundaryAdjacentTriangleCount: compiled.siblingSeamPlan.boundaryAdjacentTriangleCount, + merge: false, + quadPrimitiveAudit: compiled.quadMergeAudit, + meshResolution: "lossless", + runtimeGeometryConstruction: false, + runtimeRadialProjection: false, + runtimeNormalCalculation: false, + runtimeLightingCalculation: false, + runtimeDomGrowth: false, + }), + camera: CSSFLOWER_CAMERA, + controls: "none", + background: "#000000", + textureLighting: "baked", + textureQuality: 1, + materials: CSSFLOWER_SIDE_MATERIALS.map(({ id, color }) => ({ id, color })), + lighting: publicLightingContract(compiled.lighting, preparedAssets.lighting), + playback: Object.freeze({ + schema: "cssflower-prepared-playback@1", + target: "createPolyMorphPreparedDomTarget", + scope: "rounded-product-cycle-spike-phase-omitted", + sourceTicksPerSecond: CSSFLOWER_SOURCE_PROFILE.presentationTicksPerSecond, + transformAsset: preparedAssets.transforms, + frontFacingSchedule, + stateEvidenceUrl: "/cssflower/assets/flower-box-state-evidence.json", + cycle: Object.freeze({ + schema: compiled.cycle.schema, + initialState: compiled.cycle.initialState, + stateCount: compiled.cycle.stateCount, + cycleStartState: compiled.cycle.cycleStartState, + cycleLength: compiled.cycle.cycleLength, + bloomTraceStateCount: compiled.cycle.bloomTraceStateCount, + bloomCycleStartState: compiled.cycle.bloomCycleStartState, + bloomCycleLength: compiled.cycle.bloomCycleLength, + bloomPeakGeometryStateIndex: compiled.cycle.bloomPeakGeometryStateIndex, + bloomPeakSf: compiled.cycle.bloomPeakSf, + bloomPeakSfHex: compiled.cycle.bloomPeakSfHex, + bloomPeakSfNominal: compiled.cycle.bloomPeakSfNominal, + omittedSourceSfAtOrAbove: compiled.cycle.omittedSourceSfAtOrAbove, + geometryStateCount: compiled.cycle.geometryStateCount, + rootStateCount: compiled.cycle.rootStateCount, + rootTransforms: compiled.cycle.rootTransforms, + states: playbackStates, + }), + }), + meshes: Object.freeze([Object.freeze({ + id: "flower-box-default-cube", + kind: "flower-box-cube", + sourceId: "FLWBOX:cube:subdivision-10", + stableDom: true, + excludeFromAutoCenter: true, + transform: Object.freeze({}), + polygons: Object.freeze(compiled.initialPolygons.map((polygon, index) => Object.freeze({ + ...polygon, + data: Object.freeze({ + ...polygon.data, + "cssflower-seam-bleed": compiled.lighting.faces[index].seamBleed, + "cssflower-seam-edge-mask": compiled.siblingSeamPlan.edgeMasks[index], + }), + }))), + })]), + metrics: Object.freeze({ + meshCount: 1, + sourceSideCount: compiled.topology.sideCount, + sourceSideLocalPointCount: compiled.topology.sideLocalPointCount, + sourceTriangleCount: compiled.topology.triangleCount, + preparedPolygonCount: compiled.topology.triangleCount, + preparedLeafCount: compiled.topology.triangleCount, + preparedRootCount: 1, + preparedGeometryStateCount: compiled.cycle.geometryStateCount, + preparedTimelineStateCount: compiled.cycle.stateCount, + preparedCombinedCycleLength: compiled.cycle.cycleLength, + preparedBloomCycleLength: compiled.cycle.bloomCycleLength, + preparedRootStateCount: compiled.cycle.rootStateCount, + retainedSourceOracleTimelineStateCount: compiled.sourceCycle.stateCount, + retainedSourceOracleGeometryStateCount: compiled.sourceCycle.geometryStateCount, + retainedSourceOracleCombinedCycleLength: compiled.sourceCycle.cycleLength, + preparedTransformBlockCount: preparedAssets.transforms.blockCount, + preparedTransformEncodedBytes: preparedAssets.transforms.byteLength, + preparedTransformDecodedBytes: preparedAssets.transforms.decodedByteLength, + preparedFrontFacingDilationTicks: frontFacingSchedule.dilationTicks, + preparedVisibilitySelectionDomain: frontFacingSchedule.selectionDomain, + preparedVisibilityMinimumOwnedPixels: frontFacingSchedule.minimumOwnedPixels, + preparedVisibilitySampleGrid: frontFacingSchedule.sampleGrid, + preparedVisibilityAdjacencyRings: frontFacingSchedule.adjacencyRings, + preparedFrontFacingSelectedCount: frontFacingSchedule.selectedFaceCount, + preparedFrontFacingSuppressedCount: frontFacingSchedule.suppressedFaceCount, + preparedFrontFacingMeanSelectedPerState: frontFacingSchedule.meanSelectedFacesPerState, + preparedFrontFacingMinimumSelectedPerState: frontFacingSchedule.minimumSelectedFacesPerState, + preparedFrontFacingMaximumSelectedPerState: frontFacingSchedule.maximumSelectedFacesPerState, + preparedFrontFacingVisibilityChangeCount: frontFacingSchedule.visibilityChangeCount, + preparedLightingPageCount: preparedAssets.lighting.pageCount, + preparedLightingAssetCount: preparedAssets.lighting.assetCount, + preparedLightingEncodedBytes: preparedAssets.lighting.contentAddressedBytes, + preparedLightingFieldCount: compiled.cycle.stateCount * compiled.topology.triangleCount, + preparedLightingAddressUpdateCount: compiled.lighting.addressSchedule.updateCount, + preparedLightingMeanAddressUpdatesPerState: compiled.lighting.addressSchedule.meanUpdatesPerState, + preparedLightingP95AddressUpdatesPerState: compiled.lighting.addressSchedule.p95UpdatesPerState, + preparedLightingMaximumAddressUpdatesPerState: compiled.lighting.addressSchedule.maximumUpdatesPerState, + preparedSeamSharedEdgeCount: compiled.siblingSeamPlan.sharedEdgeCount, + preparedSeamSharedEdgeIncidenceCount: compiled.siblingSeamPlan.sharedEdgeIncidenceCount, + preparedSeamBoundaryEdgeCount: compiled.siblingSeamPlan.boundaryEdgeCount, + preparedSeamBoundaryEdgeIncidenceCount: compiled.siblingSeamPlan.boundaryEdgeIncidenceCount, + preparedSeamBoundaryVertexCount: compiled.siblingSeamPlan.boundaryVertexCount, + preparedSeamBoundaryAdjacentTriangleCount: compiled.siblingSeamPlan.boundaryAdjacentTriangleCount, + mergedCellCount: 0, + mergeCandidateCount: compiled.quadMergeAudit.sourceCellCount, + mergeEligibleCellCount: compiled.quadMergeAudit.acrossAllStatesEligibleCellCount, + runtimePolygonConstructionCount: 0, + runtimeRadialProjectionCount: 0, + runtimeNormalCalculationCount: 0, + runtimeLightingCalculationCount: 0, + runtimeDomGrowth: false, + }), + oracle: Object.freeze({ + stateEvidenceSchema: compiled.evidence.schema, + stateEvidenceUrl: "/cssflower/assets/flower-box-state-evidence.json", + implementationEngineIndependence: compiled.evidence.engineIndependence, + nativeAuthorityStatus: dataSource?.nativeAuthorityStatus ?? "missing", + nativeStateComparison: dataSource?.nativeQualification?.status === "pass" + ? "exact-pass-9331-ticks" + : "pending-owned-byte-identified-authority", + visualComparison: dataSource?.nativeQualification?.status === "pass" + ? "rounded-product-common-prefix-only-full-source-visual-report-retained-as-historical-evidence" + : "pending-state-correctness-and-native-authority", + }), + warnings: Object.freeze([ + ...(dataSource?.nativeQualification?.status === "pass" ? [ + "The independently generated topology, binary32 bloom state, rotations, positions, normals, materials, camera, and light configuration pass the retained exact 9,331-tick identity-bound native comparison.", + "Native/browser visual parity is not claimed: calibrated pixelmatch still reports measured rasterization and lighting divergence.", + ] : [ + "The radial coefficient formula, exact material channels, normal averaging order, and fixed-function lighting realization are independently authored candidates pending identity-bound native differential validation.", + "Native state and visual parity are not claimed because no owned source or binary authority is present under ignored local storage.", + ]), + "The source update cadence is not yet authority-bound; 30 ticks per second is a deterministic browser presentation cadence, not a native timing claim.", + "The product deliberately omits source states at sf >= 2.5 and uses a prepared 90-state rounded bloom inside one 360-state rotation. The independent 9,331-tick source/native oracle remains complete; product/native state parity is claimed only through the shared tick-0-to-45 prefix.", + "The product uses the owner-accepted prepare-time depth16 owned-pixel visibility schedule with an eight-pixel minimum and one-tick cyclic dilation. Its sparse browser edge-coverage drift is accepted bounded product behavior, not native or prior-browser pixel parity.", + `Prepared leaf-resolution lighting is encoded as ${CSSFLOWER_LIGHTING_ATLAS_ENCODING}; exact state and topology are retained, but decoded browser pixels are bounded-lossy and require explicit visual acceptance.`, + "Runtime lighting selection uses an exact prepared RGB8 per-triangle change schedule. Reusing a prior atlas address is state-exact in that selection domain; the lossy AVIF grid can still produce small location-dependent raster differences when an equivalent source field is addressed from a different prepared state.", + "PolyCSS quads were evaluated for all 600 source cells across all 414 prepared states; every cell is noncoplanar in at least one state, so exact geometry equivalence fails and the 1,200 stable triangle leaves are retained.", + ]), + }); + return Object.freeze({ scene, compiled }); +} + +function preparePlaybackAssetSchedule(cycle) { + const states = cycle?.states; + if (!Array.isArray(states) || states.length !== cycle.stateCount || + !Number.isSafeInteger(cycle.cycleStartState) || cycle.cycleStartState < 0 || + cycle.cycleStartState >= states.length) { + throw new Error("Prepared cssFlower playback schedule is invalid"); + } + return Object.freeze(states.map((state, timelineStateIndex) => { + const transformBlockIndex = Math.floor( + state.geometryStateIndex / CSSFLOWER_TRANSFORM_BLOCK_GEOMETRY_STATES, + ); + const nextTransformState = nextDistinctPreparedState( + states, + timelineStateIndex, + cycle.cycleStartState, + (candidate) => Math.floor( + candidate.geometryStateIndex / CSSFLOWER_TRANSFORM_BLOCK_GEOMETRY_STATES, + ), + ); + const nextLightingState = nextDistinctPreparedState( + states, + timelineStateIndex, + cycle.cycleStartState, + (candidate) => candidate.lightingPageIndex, + ); + return Object.freeze({ + ...state, + transformBlockIndex, + nextTransformBlockGeometryStateIndex: nextTransformState.geometryStateIndex, + nextLightingPageIndex: nextLightingState.lightingPageIndex, + }); + })); +} + +function prepareFrontFacingTransformSchedule(schedule, faceCount) { + if (!Array.isArray(schedule) || schedule.length !== 360 || faceCount !== 1_200 || + schedule.some((indices) => !Array.isArray(indices) || indices.length < 1 || + indices.some((faceIndex) => !Number.isSafeInteger(faceIndex) || faceIndex < 0 || faceIndex >= faceCount) || + new Set(indices).size !== indices.length)) { + throw new Error("Prepared cssFlower front-face transform schedule is invalid"); + } + const bytesPerState = Math.ceil(faceCount / 8); + const bytes = Buffer.alloc(schedule.length * bytesPerState); + const selectedByState = schedule.map((indices, stateIndex) => { + for (const faceIndex of indices) { + bytes[stateIndex * bytesPerState + (faceIndex >> 3)] |= 1 << (faceIndex & 7); + } + return indices.length; + }); + const selectedFaceCount = selectedByState.reduce((sum, count) => sum + count, 0); + let visibilityChangeCount = 0; + for (let stateIndex = 0; stateIndex < schedule.length; stateIndex += 1) { + const previousStateIndex = (stateIndex + schedule.length - 1) % schedule.length; + for (let byteIndex = 0; byteIndex < bytesPerState; byteIndex += 1) { + visibilityChangeCount += popcount8( + bytes[stateIndex * bytesPerState + byteIndex] ^ + bytes[previousStateIndex * bytesPerState + byteIndex], + ); + } + } + const fieldCount = schedule.length * faceCount; + return Object.freeze({ + schema: CSSFLOWER_FRONT_FACE_SCHEDULE_SCHEMA, + stateCount: schedule.length, + faceCount, + dilationTicks: CSSFLOWER_FRONT_FACE_DILATION_TICKS, + selectionDomain: CSSFLOWER_VISIBILITY_POLICY.selectionDomain, + depthComparison: CSSFLOWER_VISIBILITY_POLICY.depthComparison, + minimumOwnedPixels: CSSFLOWER_VISIBILITY_POLICY.minimumOwnedPixels, + sampleGrid: CSSFLOWER_VISIBILITY_POLICY.sampleGrid, + adjacency: CSSFLOWER_VISIBILITY_POLICY.adjacency, + adjacencyRings: CSSFLOWER_VISIBILITY_POLICY.adjacencyRings, + dilationPolicy: CSSFLOWER_VISIBILITY_POLICY.dilationPolicy, + encoding: CSSFLOWER_FRONT_FACE_SCHEDULE_ENCODING, + bytesPerState, + byteLength: bytes.length, + selectedFaceCount, + suppressedFaceCount: fieldCount - selectedFaceCount, + meanSelectedFacesPerState: selectedFaceCount / schedule.length, + minimumSelectedFacesPerState: Math.min(...selectedByState), + maximumSelectedFacesPerState: Math.max(...selectedByState), + initialVisibilitySelectionCount: faceCount, + visibilityChangeCount, + dataSha256: createHash("sha256").update(bytes).digest("hex"), + dataBase64: bytes.toString("base64"), + runtimeSelection: "prepared-bit-test-only-no-geometry-projection-normal-or-lighting-calculation", + }); +} + +function popcount8(value) { + let count = 0; + for (let bits = value & 255; bits !== 0; bits &= bits - 1) count += 1; + return count; +} + +function nextDistinctPreparedState(states, stateIndex, cycleStartState, keyFor) { + const currentKey = keyFor(states[stateIndex]); + let cursor = stateIndex + 1 < states.length ? stateIndex + 1 : cycleStartState; + for (let checked = 0; checked < states.length; checked += 1) { + const candidate = states[cursor]; + if (keyFor(candidate) !== currentKey) return candidate; + cursor = cursor + 1 < states.length ? cursor + 1 : cycleStartState; + } + throw new Error(`Prepared cssFlower schedule never leaves asset key ${currentKey}`); +} + +export function createCssflowerSceneContract(value = {}) { + return value; +} + +function publicLightingContract(lighting, assets) { + if (assets?.encoding !== CSSFLOWER_LIGHTING_ATLAS_ENCODING || + assets.mimeType !== CSSFLOWER_LIGHTING_ATLAS_MIME_TYPE || + assets.quality !== CSSFLOWER_LIGHTING_ATLAS_QUALITY || + assets.assetCount !== 1 || + assets.grid?.columns !== CSSFLOWER_LIGHTING_GRID_COLUMNS || + assets.grid?.rows !== CSSFLOWER_LIGHTING_GRID_ROWS || + assets.grid?.width !== CSSFLOWER_LIGHTING_GRID_WIDTH || + assets.grid?.height !== CSSFLOWER_LIGHTING_GRID_HEIGHT || + assets.pages?.length !== lighting.pages.length) { + throw new Error("Prepared cssFlower public lighting asset contract is incomplete"); + } + return Object.freeze({ + ...lighting, + distribution: assets.distribution, + assetUrl: assets.grid.assetUrl, + assetSha256: assets.grid.sha256, + grid: assets.grid, + pages: assets.pages, + totalEncodedBytes: assets.encodedGridBytes, + contentAddressedBytes: assets.contentAddressedBytes, + assetCount: assets.assetCount, + visualEncoding: Object.freeze({ + codec: "AVIF", + encoding: assets.encoding, + mimeType: assets.mimeType, + quality: assets.quality, + chromaSubsampling: "4:4:4", + speed: 6, + policy: "bounded-lossy-prepared-leaf-lighting-only", + exactGeometry: true, + exactPreparedPixels: false, + }), + encoder: assets.encoder, + }); +} diff --git a/src/adapters/flowerbox/src/prepare/cssflower/slicePlan.mjs b/src/adapters/flowerbox/src/prepare/cssflower/slicePlan.mjs new file mode 100644 index 0000000..0a6bcbf --- /dev/null +++ b/src/adapters/flowerbox/src/prepare/cssflower/slicePlan.mjs @@ -0,0 +1,21 @@ +export const cssflowerSlicePlan = Object.freeze({ + schema: "polycss-port-slice-plan@1", + title: "cssFlower — Microsoft Flower Box", + slug: "cssflower", + mode: "model-viewer", + artifactMode: "prepared-polycss-snapshot", + referenceHarness: "source-dump", + defaultSceneId: "default-cube", + firstSlice: Object.freeze({ + kind: "fixed-topology-deforming-model", + label: "Microsoft Flower Box default cube, subdivision 10", + routeContract: "/", + expectedInputs: Object.freeze(["documented source-behavior profile", "optional owned native authority"]), + cameraIntent: "source 45-degree square perspective from eye (0,0,3.5)", + }), + formatHints: Object.freeze(["procedural-cube", "prepared-float32-matrix-cycle", "prepared-space-texel-atlas"]), +}); + +export function describeFirstSlice() { + return `${cssflowerSlicePlan.firstSlice.label} (${cssflowerSlicePlan.artifactMode})`; +} diff --git a/src/adapters/flowerbox/src/prepare/cssflower/sourceProfile.mjs b/src/adapters/flowerbox/src/prepare/cssflower/sourceProfile.mjs new file mode 100644 index 0000000..8559f21 --- /dev/null +++ b/src/adapters/flowerbox/src/prepare/cssflower/sourceProfile.mjs @@ -0,0 +1,116 @@ +export const FLOAT = Math.fround; +const POLYCSS_SOURCE_UNIT_PIXELS = 50; + +export const CSSFLOWER_SOURCE_PROFILE = Object.freeze({ + schema: "cssflower-source-profile@1", + id: "microsoft-flower-box-default-cube-subdiv10", + authority: "src/adapters/flowerbox/README.md", + authorityStatus: "pinned-source-native-state-validated-locally-not-packaged", + geometry: "cube", + subdivision: 10, + sideCount: 6, + sideLocalPointCount: 726, + triangleCount: 1200, + smoothShading: true, + bloom: Object.freeze({ + resetSf: 0, + minSf: -1.1, + maxSf: 5.1, + sfIncrement: 0.05, + radialFormula: "d = float32(length(basePosition) * 2); vlen = float32((1 - d) / d); position = float32(basePosition * float32(vlen * sf + 1))", + radialFormulaStatus: "pinned-source-operation-order", + crossingRule: "reverse-sfi-after-crossing-bound-without-clamp", + arithmetic: "ieee754-binary32-stepwise", + }), + rotation: Object.freeze({ xDegreesPerUpdate: 3, yDegreesPerUpdate: 2, zDegreesPerUpdate: 0 }), + colorCycling: false, + camera: Object.freeze({ + fovDegrees: 45, + eye: Object.freeze([0, 0, 3.5]), + target: Object.freeze([0, 0, 0]), + up: Object.freeze([0, 1, 0]), + near: 2, + far: 5, + aspect: 1, + stagePixels: 720, + }), + light: Object.freeze({ + position: Object.freeze([2, 2, 10, 1]), + specular: Object.freeze([0.8, 0.8, 0.8, 1]), + shininess: 30, + globalAmbient: Object.freeze([0.2, 0.2, 0.2, 1]), + materialAmbient: Object.freeze([0.2, 0.2, 0.2, 1]), + lightAmbient: Object.freeze([0, 0, 0, 1]), + lightDiffuse: Object.freeze([1, 1, 1, 1]), + lightSpecular: Object.freeze([1, 1, 1, 1]), + normalizeNormals: true, + localViewer: false, + positionSetUnderIdentityModelview: true, + cameraTransformMatrix: "projection", + }), + presentationTicksPerSecond: 30, +}); + +export const CSSFLOWER_SIDE_MATERIALS = Object.freeze([ + Object.freeze({ id: "front-red", color: "#ff0000", rgb: Object.freeze([255, 0, 0]) }), + Object.freeze({ id: "back-green", color: "#00ff00", rgb: Object.freeze([0, 255, 0]) }), + Object.freeze({ id: "top-blue", color: "#0000ff", rgb: Object.freeze([0, 0, 255]) }), + Object.freeze({ id: "bottom-magenta", color: "#ff00ff", rgb: Object.freeze([255, 0, 255]) }), + Object.freeze({ id: "right-cyan", color: "#00ffff", rgb: Object.freeze([0, 255, 255]) }), + Object.freeze({ id: "left-yellow", color: "#ffff00", rgb: Object.freeze([255, 255, 0]) }), +]); + +export const CSSFLOWER_CAMERA = Object.freeze(buildPolyCssCamera()); + +export function buildPolyCssCamera() { + const source = CSSFLOWER_SOURCE_PROFILE.camera; + const perspective = source.stagePixels / 2 / Math.tan(source.fovDegrees * Math.PI / 360); + return { + projection: "perspective", + perspective, + zoom: POLYCSS_SOURCE_UNIT_PIXELS, + rotX: 0, + rotY: 0, + target: [0, 0, 0], + distance: POLYCSS_SOURCE_UNIT_PIXELS * source.eye[2] - perspective, + calibration: { + sourceUnitPixels: POLYCSS_SOURCE_UNIT_PIXELS, + equation: "zoom*p/(p+distance-sourceUnitPixels*z) = p/(eyeZ-z)", + depthAware: true, + }, + sourceViewport: { width: source.stagePixels, height: source.stagePixels }, + source: { + fovDegrees: source.fovDegrees, + eye: [...source.eye], + target: [...source.target], + up: [...source.up], + near: source.near, + far: source.far, + aspect: source.aspect, + }, + }; +} + +export function sourceToPolyCss([x, y, z]) { + return [FLOAT(-y), FLOAT(x), FLOAT(z)]; +} + +export function preparedRootTransform(xDegrees, yDegrees) { + const x = normalizeDegrees(xDegrees); + const y = normalizeDegrees(yDegrees); + return `rotateX(${-x}deg) rotateY(${y}deg)`; +} + +export function normalizeDegrees(value) { + const normalized = value % 360; + return Object.is(normalized, -0) ? 0 : normalized < 0 ? normalized + 360 : normalized; +} + +export function floatBits(value) { + const floats = new Float32Array([FLOAT(value)]); + return new Uint32Array(floats.buffer)[0]; +} + +export function floatHex(value) { + return floatBits(value).toString(16).padStart(8, "0"); +} diff --git a/src/adapters/flowerbox/src/prepare/cssflower/writeManifest.mjs b/src/adapters/flowerbox/src/prepare/cssflower/writeManifest.mjs new file mode 100644 index 0000000..9f8b48f --- /dev/null +++ b/src/adapters/flowerbox/src/prepare/cssflower/writeManifest.mjs @@ -0,0 +1,87 @@ +import { mkdir, rename, writeFile } from "node:fs/promises"; +import { dirname } from "node:path"; +import { + generatedPublicRoot, + generatedScenePath, + generatedSceneUrl, + manifestPath, +} from "./paths.mjs"; +import { assertNoBrowserPathLeaks } from "./provenance.mjs"; + +export async function writeCssflowerPreparedOutput({ + title = "cssFlower — Microsoft Flower Box", + scenes, + defaultSceneId, + warnings = [], + debugApi = "window.__cssFlowerDebug", +} = {}) { + if (!Array.isArray(scenes) || scenes.length === 0) { + throw new Error("writeCssflowerPreparedOutput requires at least one prepared scene."); + } + await mkdir(generatedPublicRoot, { recursive: true }); + const manifestScenes = []; + for (const scene of scenes) { + await writeJsonAtomic(generatedScenePath(scene.id), scene); + const snapshot = snapshotEntryForScene(scene); + manifestScenes.push({ + id: scene.id, + label: scene.label, + sceneUrl: generatedSceneUrl(scene.id), + ...snapshot, + metrics: scene.metrics ?? {}, + warnings: scene.warnings ?? [], + }); + } + const defaultId = defaultSceneId ?? scenes[0].id; + const manifest = { + schema: "cssflower-manifest@1", + status: "ready", + title, + artifactMode: "prepared-polycss-snapshot", + scaffoldMode: "model-viewer", + generatedAssetRoot: "/cssflower/", + defaultScene: { id: defaultId }, + scenes: manifestScenes, + source: scenes[0].source, + sourceProfileId: scenes[0].sourceProfile?.id, + renderer: scenes[0].renderer, + metrics: scenes[0].metrics, + oracle: scenes[0].oracle, + assets: { + transforms: scenes[0].playback?.transformAsset, + lighting: scenes[0].lighting, + stateEvidenceUrl: scenes[0].playback?.stateEvidenceUrl, + }, + warnings, + runtime: { + debugApi, + routeContract: "?scene=", + }, + }; + await writeJsonAtomic(manifestPath, manifest); + return { manifestPath, manifest }; +} + +function snapshotEntryForScene(scene) { + if (scene.snapshotUrl) { + return { + snapshotUrl: scene.snapshotUrl, + snapshotKind: scene.snapshotKind ?? "polycss-exported-html", + artifactKind: "prepared-polycss-snapshot", + }; + } + if ("prepared-polycss-snapshot" !== "prepared-polycss-snapshot") return {}; + return { + snapshotUrl: "/cssflower/scenes/" + scene.id + ".polycss.html", + snapshotKind: "polycss-exported-html", + artifactKind: "prepared-polycss-snapshot", + }; +} + +async function writeJsonAtomic(path, value) { + assertNoBrowserPathLeaks(value); + await mkdir(dirname(path), { recursive: true }); + const tmp = path + ".tmp"; + await writeFile(tmp, JSON.stringify(value, null, 2) + "\n"); + await rename(tmp, path); +} diff --git a/src/adapters/flowerbox/src/prepare/cssflower/writePreparedAssets.mjs b/src/adapters/flowerbox/src/prepare/cssflower/writePreparedAssets.mjs new file mode 100644 index 0000000..4da31b6 --- /dev/null +++ b/src/adapters/flowerbox/src/prepare/cssflower/writePreparedAssets.mjs @@ -0,0 +1,479 @@ +import { createHash } from "node:crypto"; +import { execFile } from "node:child_process"; +import { copyFile, link, mkdir, readFile, readdir, rename, rm, stat, unlink, writeFile } from "node:fs/promises"; +import { gzipSync } from "node:zlib"; +import { promisify } from "node:util"; +import { dirname, join } from "node:path"; +import { + CSSFLOWER_LIGHTING_ATLAS_ENCODING, + CSSFLOWER_LIGHTING_ATLAS_MIME_TYPE, + CSSFLOWER_LIGHTING_ATLAS_QUALITY, + CSSFLOWER_LIGHTING_GRID_COLUMNS, + CSSFLOWER_LIGHTING_GRID_DECODED_BYTES, + CSSFLOWER_LIGHTING_GRID_HEIGHT, + CSSFLOWER_LIGHTING_GRID_ROWS, + CSSFLOWER_LIGHTING_GRID_WIDTH, + CSSFLOWER_TRANSFORM_BLOCK_GEOMETRY_STATES, + CSSFLOWER_TRANSFORM_BLOCK_SCHEMA, +} from "../../cssflower/renderContract.mjs"; +import { + generatedLightingPagePath, + generatedAssetDir, + generatedLightingAssetDir, + generatedProjectedAssetDir, + generatedPreparedLightingPath, + generatedPreparedLightingUrl, + generatedStateEvidencePath, + generatedTransformAssetDir, + generatedTransformBlockPath, + generatedTransformBlockUrl, + generatedTransformsPath, + localRoot, + localPreparedTransformsPath, + repoRoot, +} from "./paths.mjs"; +import { assertNoBrowserPathLeaks } from "./provenance.mjs"; + +const execFileAsync = promisify(execFile); +const DEFAULT_AVIFENC = "/opt/homebrew/bin/avifenc"; + +export async function writeCssflowerPreparedAssets(compiled, { + avifenc = process.env.CSSFLOWER_AVIFENC || DEFAULT_AVIFENC, + lightingPageStore, +} = {}) { + if (!lightingPageStore?.pathFor) { + throw new TypeError("Prepared cssFlower lighting page store is required"); + } + await writeAtomic(localPreparedTransformsPath, compiled.transformBytes); + await unlinkIfPresent(generatedTransformsPath); + await rm(generatedProjectedAssetDir, { recursive: true, force: true }); + await pruneLegacyLightingPages(); + await prunePublicOracleProofAssets(); + if (!Array.isArray(compiled.lightingPages) || compiled.lightingPages.length !== compiled.lighting.pageCount) { + throw new Error("Prepared cssFlower lighting pages are incomplete"); + } + assertNoBrowserPathLeaks(compiled.evidence); + await writeAtomic(generatedStateEvidencePath, Buffer.from(JSON.stringify(compiled.evidence, null, 2) + "\n")); + const [transforms, lighting] = await Promise.all([ + materializeTransformBlocks(compiled), + materializePreparedLightingPages(compiled, lightingPageStore, avifenc), + ]); + return Object.freeze({ + transformBytes: compiled.transformBytes.length, + transformSha256: compiled.transformSha256, + lightingBytes: lighting.contentAddressedBytes, + lightingSha256: lighting.grid.sha256, + lightingPageCount: compiled.lightingPages.length, + decodedLightingGridBytes: CSSFLOWER_LIGHTING_GRID_DECODED_BYTES, + transforms, + lighting, + }); +} + +async function materializeTransformBlocks(compiled) { + const triangleCount = compiled.topology.triangleCount; + const geometryStateCount = compiled.cycle.geometryStateCount; + const componentCount = 16; + const values = new Float32Array( + compiled.transformBytes.buffer, + compiled.transformBytes.byteOffset, + compiled.transformBytes.byteLength / Float32Array.BYTES_PER_ELEMENT, + ); + if (triangleCount !== 1_200 || values.length !== geometryStateCount * triangleCount * componentCount) { + throw new Error("Prepared cssFlower matrix3d source is incomplete"); + } + const blocks = []; + const keep = new Set(); + for (let startGeometryStateIndex = 0; startGeometryStateIndex < geometryStateCount; + startGeometryStateIndex += CSSFLOWER_TRANSFORM_BLOCK_GEOMETRY_STATES) { + const blockGeometryStateCount = Math.min( + CSSFLOWER_TRANSFORM_BLOCK_GEOMETRY_STATES, + geometryStateCount - startGeometryStateIndex, + ); + const transformCount = blockGeometryStateCount * triangleCount; + const lines = new Array(transformCount); + for (let transformIndex = 0; transformIndex < transformCount; transformIndex += 1) { + const componentOffset = (startGeometryStateIndex * triangleCount + transformIndex) * componentCount; + const components = new Array(componentCount); + for (let component = 0; component < componentCount; component += 1) { + components[component] = formatCssNumber(values[componentOffset + component]); + } + lines[transformIndex] = `matrix3d(${components.join(",")})`; + } + const decoded = Buffer.from(`${lines.join("\n")}\n`); + const encoded = gzipSync(decoded, { level: 9, mtime: 0 }); + const encodedSha256 = sha256(encoded); + const decodedSha256 = sha256(decoded); + const assetPath = generatedTransformBlockPath(encodedSha256); + await writeAtomic(assetPath, encoded); + keep.add(assetPath); + blocks.push(Object.freeze({ + index: blocks.length, + startGeometryStateIndex, + geometryStateCount: blockGeometryStateCount, + triangleCount, + transformCount, + assetUrl: generatedTransformBlockUrl(encodedSha256), + byteLength: encoded.length, + sha256: encodedSha256, + decodedByteLength: decoded.length, + decodedSha256, + })); + } + await pruneAssetDirectory(generatedTransformAssetDir, keep); + return Object.freeze({ + schema: CSSFLOWER_TRANSFORM_BLOCK_SCHEMA, + distribution: "public-independent-prepared-transform-blocks", + encoding: "gzip-newline-utf8-geometry-state-major-triangle-major-matrix3d", + componentCount, + triangleCount, + geometryStateCount, + blockGeometryStateCount: CSSFLOWER_TRANSFORM_BLOCK_GEOMETRY_STATES, + blockCount: blocks.length, + byteLength: blocks.reduce((sum, block) => sum + block.byteLength, 0), + decodedByteLength: blocks.reduce((sum, block) => sum + block.decodedByteLength, 0), + sourceFloat32: Object.freeze({ + distribution: "ignored-local-preparation-evidence", + sha256: compiled.transformSha256, + byteLength: compiled.transformBytes.length, + }), + blocks: Object.freeze(blocks), + }); +} + +async function materializePreparedLightingPages(compiled, lightingPageStore, avifenc) { + const encoder = await executableIdentity(avifenc, ["--version"]); + const binding = sha256(Buffer.from(JSON.stringify({ + encoding: CSSFLOWER_LIGHTING_ATLAS_ENCODING, + quality: CSSFLOWER_LIGHTING_ATLAS_QUALITY, + gridColumns: CSSFLOWER_LIGHTING_GRID_COLUMNS, + gridRows: CSSFLOWER_LIGHTING_GRID_ROWS, + encoder, + }))); + if (compiled.lightingPages.length !== CSSFLOWER_LIGHTING_GRID_COLUMNS) { + throw new Error("Prepared cssFlower horizontal lighting grid source count drifted"); + } + const sourcePaths = compiled.lightingPages.map((page) => lightingPageStore.pathFor(page.index)); + const sourceBinding = sha256(Buffer.from(compiled.lightingPages.map((page) => page.sha256).join("\n"))); + const cacheRoot = join(localRoot, "cache", "prepared-leaf-lighting-avif-grid", binding); + const encodedCachePath = join(cacheRoot, `${sourceBinding}.avif`); + let encoded; + try { + encoded = await readFile(encodedCachePath); + if (encoded.length < 1) throw Object.assign(new Error("empty encoded lighting grid cache"), { code: "ENOENT" }); + } catch (error) { + if (error?.code !== "ENOENT") throw error; + await mkdir(dirname(encodedCachePath), { recursive: true }); + const temporary = `${encodedCachePath}.tmp-${process.pid}.avif`; + await execFileAsync(avifenc, [ + "--qcolor", String(CSSFLOWER_LIGHTING_ATLAS_QUALITY), + "--speed", "6", + "--jobs", "2", + "--yuv", "444", + "--ignore-exif", + "--ignore-xmp", + "--ignore-icc", + "--grid", `${CSSFLOWER_LIGHTING_GRID_COLUMNS}x${CSSFLOWER_LIGHTING_GRID_ROWS}`, + ...sourcePaths, + temporary, + ], { maxBuffer: 8 * 1024 * 1024 }); + await rename(temporary, encodedCachePath); + encoded = await readFile(encodedCachePath); + } + const encodedSha256 = sha256(encoded); + const grid = Object.freeze({ + schema: "cssflower-prepared-leaf-lighting-grid@1", + assetUrl: generatedPreparedLightingUrl(encodedSha256), + encoding: CSSFLOWER_LIGHTING_ATLAS_ENCODING, + mimeType: CSSFLOWER_LIGHTING_ATLAS_MIME_TYPE, + quality: CSSFLOWER_LIGHTING_ATLAS_QUALITY, + chromaSubsampling: "4:4:4", + exactPreparedPixels: false, + columns: CSSFLOWER_LIGHTING_GRID_COLUMNS, + rows: CSSFLOWER_LIGHTING_GRID_ROWS, + cellWidth: compiled.lighting.atlasWidth, + cellHeight: compiled.lighting.atlasHeight, + width: CSSFLOWER_LIGHTING_GRID_WIDTH, + height: CSSFLOWER_LIGHTING_GRID_HEIGHT, + decodedBytes: CSSFLOWER_LIGHTING_GRID_DECODED_BYTES, + byteLength: encoded.length, + sha256: encodedSha256, + }); + const pages = Object.freeze(compiled.lightingPages.map((sourcePage) => Object.freeze({ + index: sourcePage.index, + startStateIndex: sourcePage.startStateIndex, + usedRowCount: sourcePage.usedRowCount, + rowCount: sourcePage.rowCount, + role: sourcePage.role, + width: sourcePage.width, + height: sourcePage.height, + decodedBytes: sourcePage.decodedBytes, + gridColumn: sourcePage.index, + gridRow: 0, + gridOffsetX: sourcePage.index * sourcePage.width, + gridOffsetY: 0, + sourceEncoding: sourcePage.encoding, + sourcePngByteLength: sourcePage.byteLength, + sourcePngSha256: sourcePage.sha256, + }))); + const asset = { + source: encodedCachePath, + target: generatedPreparedLightingPath(encodedSha256), + }; + await pruneAssetDirectory(generatedLightingAssetDir, new Set([asset.target])); + await materializeContentAddressedAssets([asset], 1); + return Object.freeze({ + schema: "cssflower-prepared-leaf-lighting-assets@2", + distribution: "public-independent-prepared-leaf-lighting-horizontal-grid", + encoding: CSSFLOWER_LIGHTING_ATLAS_ENCODING, + mimeType: CSSFLOWER_LIGHTING_ATLAS_MIME_TYPE, + quality: CSSFLOWER_LIGHTING_ATLAS_QUALITY, + pageCount: pages.length, + assetCount: 1, + encodedGridBytes: encoded.length, + contentAddressedBytes: encoded.length, + encoder, + grid, + pages, + }); +} + +async function pruneAssetDirectory(root, keepPaths) { + await mkdir(root, { recursive: true }); + const entries = await readdir(root, { withFileTypes: true }); + await Promise.all(entries.map(async (entry) => { + if (!entry.isFile()) return; + const path = join(root, entry.name); + if (!keepPaths.has(path)) await unlink(path); + })); +} + +async function materializeContentAddressedAssets(assets, concurrency) { + let nextIndex = 0; + await Promise.all(Array.from({ length: Math.min(concurrency, assets.length) }, async () => { + while (nextIndex < assets.length) { + const index = nextIndex; + nextIndex += 1; + await hardlinkAtomic(assets[index].source, assets[index].target, index); + } + })); +} + +async function hardlinkAtomic(source, target, uniqueIndex) { + await mkdir(dirname(target), { recursive: true }); + try { + const [sourceStat, targetStat] = await Promise.all([stat(source), stat(target)]); + if (sourceStat.dev === targetStat.dev && sourceStat.ino === targetStat.ino) return; + } catch (error) { + if (error?.code !== "ENOENT") throw error; + } + const temporary = `${target}.tmp-${process.pid}-${uniqueIndex}`; + try { + await link(source, temporary); + } catch (error) { + if (error?.code !== "EXDEV") throw error; + await copyFile(source, temporary); + } + await rename(temporary, target); +} + +export async function createCssflowerPreparedLightingPageStore() { + const binding = await lightingCacheBinding(repoRoot); + const cacheParent = join(repoRoot, ".local", "cache", "cssflower", "prepared-leaf-lighting"); + const cacheRoot = join(cacheParent, binding); + const pagePaths = new Map(); + let hitCount = 0; + let migratedHitCount = 0; + let missCount = 0; + let writeCount = 0; + return Object.freeze({ + binding, + async read(expectedPage) { + const roots = [cacheRoot, ...(await legacyLightingCacheRoots(cacheParent, cacheRoot))]; + for (const root of roots) { + const paths = cachePaths(root, expectedPage.index); + try { + const [metadataBytes, bytes] = await Promise.all([ + readFile(paths.metadata), + readFile(paths.png), + ]); + const metadata = JSON.parse(metadataBytes.toString("utf8")); + const page = metadata?.page; + if (!preparedLightingCacheEntryMatches({ + metadata, + bytes, + binding, + expectedPage, + })) { + continue; + } + pagePaths.set(page.index, paths.png); + if (page.index === 0) await writeAtomic(generatedLightingPagePath(page.index), bytes); + hitCount += 1; + if (root !== cacheRoot) migratedHitCount += 1; + return Object.freeze(page); + } catch (error) { + if (error?.code !== "ENOENT" && !(error instanceof SyntaxError)) throw error; + } + } + missCount += 1; + return null; + }, + async write(page) { + if (!validPageWithBytes(page)) { + throw new TypeError("Prepared cssFlower streaming lighting page is invalid"); + } + const paths = cachePaths(cacheRoot, page.index); + const { bytes, ...descriptor } = page; + const metadata = Buffer.from(`${JSON.stringify({ + schema: "cssflower-prepared-leaf-lighting-cache@1", + binding, + page: descriptor, + }, null, 2)}\n`); + await Promise.all([ + ...(page.index === 0 ? [writeAtomic(generatedLightingPagePath(page.index), bytes)] : []), + writeAtomic(paths.png, bytes), + writeAtomic(paths.metadata, metadata), + ]); + pagePaths.set(page.index, paths.png); + writeCount += 1; + }, + pathFor(pageIndex) { + const path = pagePaths.get(pageIndex); + if (!path) throw new Error(`Prepared cssFlower lighting page ${pageIndex} has no verified cache path`); + return path; + }, + stats() { + return Object.freeze({ binding, hitCount, migratedHitCount, missCount, writeCount }); + }, + }); +} + +async function legacyLightingCacheRoots(cacheParent, currentRoot) { + try { + const entries = await readdir(cacheParent, { withFileTypes: true }); + return entries + .filter((entry) => entry.isDirectory()) + .map((entry) => join(cacheParent, entry.name)) + .filter((root) => root !== currentRoot) + .sort(); + } catch (error) { + if (error?.code === "ENOENT") return []; + throw error; + } +} + +async function pruneLegacyLightingPages() { + await mkdir(generatedAssetDir, { recursive: true }); + const entries = await readdir(generatedAssetDir, { withFileTypes: true }); + await Promise.all(entries.map(async (entry) => { + if (!entry.isFile() || !/^flower-box-space-texels-page-\d{3}\.png$/u.test(entry.name)) return; + await unlink(join(generatedAssetDir, entry.name)); + })); +} + +async function prunePublicOracleProofAssets() { + await mkdir(generatedAssetDir, { recursive: true }); + const entries = await readdir(generatedAssetDir, { withFileTypes: true }); + await Promise.all(entries.map(async (entry) => { + if (!entry.isFile() || !/^projected-(?:pixel-proof|spike|transition)-/u.test(entry.name)) return; + await unlink(join(generatedAssetDir, entry.name)); + })); +} + +async function unlinkIfPresent(path) { + try { + await unlink(path); + } catch (error) { + if (error?.code !== "ENOENT") throw error; + } +} + +async function lightingCacheBinding(repoRoot) { + const paths = [ + "pnpm-lock.yaml", + "src/adapters/flowerbox/src/cssflower/renderContract.mjs", + "src/adapters/flowerbox/src/prepare/cssflower/bloomCycle.mjs", + "src/adapters/flowerbox/src/prepare/cssflower/compilePreparedCycle.mjs", + "src/adapters/flowerbox/src/prepare/cssflower/cubeTopology.mjs", + "src/adapters/flowerbox/src/prepare/cssflower/leafRasterLighting.mjs", + "src/adapters/flowerbox/src/prepare/cssflower/sourceProfile.mjs", + ]; + const hash = createHash("sha256"); + for (const path of paths) { + hash.update(path); + hash.update("\0"); + hash.update(await readFile(join(repoRoot, path))); + hash.update("\0"); + } + return hash.digest("hex"); +} + +function cachePaths(cacheRoot, index) { + const stem = `page-${String(index).padStart(3, "0")}`; + return Object.freeze({ + png: join(cacheRoot, `${stem}.png`), + metadata: join(cacheRoot, `${stem}.json`), + }); +} + +function sameExpectedPage(actual, expected) { + if (!actual || !Number.isSafeInteger(actual.byteLength) || actual.byteLength < 1 || + !/^[a-f0-9]{64}$/u.test(actual.sha256 ?? "")) return false; + return Object.entries(expected).every(([key, value]) => actual[key] === value); +} + +export function preparedLightingCacheEntryMatches({ metadata, bytes, binding, expectedPage }) { + const page = metadata?.page; + return metadata?.schema === "cssflower-prepared-leaf-lighting-cache@1" && + metadata.binding === binding && + sameExpectedPage(page, expectedPage) && + bytes instanceof Uint8Array && + bytes.length === page.byteLength && + sha256(bytes) === page.sha256; +} + +function validPageWithBytes(page) { + return Number.isSafeInteger(page?.index) && page.index >= 0 && + page?.bytes instanceof Uint8Array && page.bytes.length === page.byteLength && + sha256(page.bytes) === page.sha256; +} + +async function executableIdentity(path, versionArgs) { + const [bytes, fileStat, version] = await Promise.all([ + readFile(path), + stat(path), + execFileAsync(path, versionArgs, { maxBuffer: 1024 * 1024 }), + ]); + return Object.freeze({ + path, + byteLength: fileStat.size, + sha256: sha256(bytes), + version: `${version.stdout}${version.stderr}`.trim(), + flags: Object.freeze([ + "--qcolor", String(CSSFLOWER_LIGHTING_ATLAS_QUALITY), + "--speed", "6", + "--jobs", "2", + "--yuv", "444", + "--ignore-exif", + "--ignore-xmp", + "--ignore-icc", + ]), + }); +} + +function formatCssNumber(value) { + const rounded = Math.round(value * 1_000_000) / 1_000_000; + return String(Object.is(rounded, -0) ? 0 : rounded); +} + +function sha256(bytes) { + return createHash("sha256").update(bytes).digest("hex"); +} + +async function writeAtomic(path, bytes) { + await mkdir(dirname(path), { recursive: true }); + const temporary = `${path}.tmp`; + await writeFile(temporary, bytes); + await rename(temporary, path); +} diff --git a/src/adapters/flowerbox/tools/audit-runtime-surface.mjs b/src/adapters/flowerbox/tools/audit-runtime-surface.mjs new file mode 100644 index 0000000..15d5685 --- /dev/null +++ b/src/adapters/flowerbox/tools/audit-runtime-surface.mjs @@ -0,0 +1,103 @@ +#!/usr/bin/env node + +import { existsSync } from "node:fs"; +import { mkdir, readFile, writeFile } from "node:fs/promises"; +import { dirname, join, resolve } from "node:path"; +import { inspectFlowerboxProductBank } from "./productBank.mjs"; + +const repositoryRoot = resolve(import.meta.dirname, "..", "..", "..", ".."); +const adapterRoot = resolve(import.meta.dirname, ".."); +const runtimeFiles = [ + "index.html", + "src/main.mjs", + "src/cssflower/client.mjs", + "src/cssflower/debugApi.mjs", + "src/cssflower/manifestClient.mjs", + "src/cssflower/polycssScene.mjs", + "src/cssflower/preparedAssetLoaders.mjs", + "src/cssflower/preparedPlayback.mjs", + "src/cssflower/renderContract.mjs", + "src/cssflower/routeState.mjs", + "src/cssflower/stagePresentation.mjs", + "src/cssflower/styles.css", +]; +const failures = []; +const sources = new Map(await Promise.all(runtimeFiles.map(async (path) => [ + path, + await readFile(join(adapterRoot, path), "utf8"), +]))); + +const index = sources.get("index.html"); +if (!/<\/body>/u.test(index.replace(/\s+/gu, "")) || + /<(?:main|header|section|article|form|button|input|output|img|video|canvas|svg)\b/iu.test(index) || + /\bdata-[a-z0-9-]+=/iu.test(index)) { + failures.push("index.html is not the empty direct-camera shell"); +} +for (const [path, source] of sources) { + reject(path, source, "alternate renderer", /(?:getContext\s*\(|WebGLRenderingContext|WebGPU|GPUDevice|from\s+["']three["']|createElement\s*\(\s*["'](?:canvas|svg)["']|<(?:canvas|svg)\b)/iu); + reject(path, source, "browser prepare or oracle import", /from\s+["'][^"']*(?:\/prepare\/|\/oracle\/)/u); + reject(path, source, "runtime geometry or lighting construction", /\b(?:buildCubeTopology|deformCubePoints|computeSmoothPointNormals|computePreparedVertexLighting|createPolyScene)\b/u); + reject(path, source, "native replay ingestion", /fetch[^\n]*(?:native|replay|state-packet)|(?:native|replay)[^\n]*fetch/iu); +} +const productCss = sources.get("src/cssflower/styles.css"); +reject("src/cssflower/styles.css", productCss, "paint-heavy product CSS", /(?:clip-path|mask(?:-image)?|filter|box-shadow|text-shadow|linear-gradient|radial-gradient|mix-blend-mode)\s*:/iu); +const playback = sources.get("src/cssflower/preparedPlayback.mjs"); +if (!playback.includes("createPolyMorphPreparedDomTarget({") || + !playback.includes("morphTarget.leaves[leafIndex].writeTransform(transform)")) { + failures.push("PolyCSS Morph prepared leaf target is missing"); +} +if (/projectedPageStyles|applyPreparedProjectedLeafLayout|\.style\.cssText/u.test(playback)) { + failures.push("Projected-page or direct leaf cssText path is present"); +} +if (/\b(?:document|DOMParser|MutationObserver|Image)\b|createElement|appendChild|replaceChildren/u.test(playback)) { + failures.push("Prepared playback constructs DOM"); +} +if (existsSync(join(adapterRoot, "src/cssflower/projectedPageStyles.mjs"))) { + failures.push("Projected-page runtime module remains"); +} + +const bank = await inspectFlowerboxProductBank(join(repositoryRoot, "build", "generated", "public", "cssflower")); +if (bank.closureBytes >= 7_000_000) failures.push(`Product bank is too large: ${bank.closureBytes}`); +if (bank.timelineStateCount !== 360 || bank.geometryStateCount !== 46 || + bank.transformBlockCount !== 3 || bank.lightingAssetCount !== 1 || + bank.lightingQuality !== 60 || bank.visibilityMinimumOwnedPixels !== 8) { + failures.push("Product bank is not the accepted rounded q60/min-8 closure"); +} + +const report = { + schema: "cssgraphics-flowerbox-runtime-audit@2", + status: failures.length === 0 ? "pass" : "fail", + renderer: "retained-dom-polycss-only", + morphTarget: "@layoutit/polycss-morph#createPolyMorphPreparedDomTarget", + runtimeFiles, + bank, + runtime: { + retainedTriangleLeafCount: 1_200, + retainedRotationRootCount: 1, + geometryConstruction: false, + projectionCalculation: false, + rasterization: false, + normalCalculation: false, + lightingCalculation: false, + domGrowth: false, + }, + excluded: [ + "Microsoft source", + "Microsoft binaries", + "native captures", + "oracle packets", + "projected visual packs", + "Three.js", + "pixelmatch", + ], + failures, +}; +const reportPath = join(repositoryRoot, "build", "reports", "flowerbox-runtime-audit.json"); +await mkdir(dirname(reportPath), { recursive: true }); +await writeFile(reportPath, `${JSON.stringify(report, null, 2)}\n`); +process.stdout.write(`${JSON.stringify({ reportPath, ...report }, null, 2)}\n`); +if (failures.length) process.exitCode = 1; + +function reject(path, source, rule, expression) { + if (expression.test(source)) failures.push(`${path}: ${rule}`); +} diff --git a/src/adapters/flowerbox/tools/package-product-bank.mjs b/src/adapters/flowerbox/tools/package-product-bank.mjs new file mode 100644 index 0000000..d3b2c6d --- /dev/null +++ b/src/adapters/flowerbox/tools/package-product-bank.mjs @@ -0,0 +1,185 @@ +#!/usr/bin/env node + +import { createHash } from "node:crypto"; +import { cp, mkdir, readFile, rename, rm, writeFile } from "node:fs/promises"; +import { dirname, join, resolve } from "node:path"; +import { gzipSync, gunzipSync } from "node:zlib"; +import { + inspectFlowerboxProductBank, + writeFlowerboxProductBankDescriptor, +} from "./productBank.mjs"; + +const args = parseArgs(process.argv.slice(2)); +if (!args.source) { + throw new Error("Usage: package-product-bank.mjs --source [--output ]"); +} +const sourceRoot = resolve(args.source); +const outputRoot = resolve(args.output ?? "build/generated/public/cssflower"); +const stagingRoot = `${outputRoot}.staging-${process.pid}`; + +await rm(stagingRoot, { recursive: true, force: true }); +await mkdir(dirname(stagingRoot), { recursive: true }); +await cp(sourceRoot, stagingRoot, { recursive: true, force: true }); + +try { + const sourceManifestBytes = await readFile(join(stagingRoot, "manifest.json")); + const sourceSceneEncoded = await readFile(join(stagingRoot, "scenes", "default-cube.json.gz")); + const sourceSnapshotEncoded = await readFile(join(stagingRoot, "scenes", "default-cube.polycss.html.gz")); + const sourceSnapshotDecoded = gunzipSync(sourceSnapshotEncoded); + const manifest = JSON.parse(sourceManifestBytes.toString("utf8")); + const scene = JSON.parse(gunzipSync(sourceSceneEncoded).toString("utf8")); + const entry = manifest.scenes?.find((candidate) => candidate.id === "default-cube"); + if (!entry) throw new Error("Source Flower Box manifest is missing default-cube"); + + scene.label = "Flower Box — default cube"; + scene.source = { + project: "Flower Box — PolyCSS experiment", + dataKind: "documented-source-behavior", + behaviorAuthority: "src/adapters/flowerbox/README.md", + sourceRevision: scene.source?.sourceRevision, + sourceFiles: scene.source?.sourceFiles, + nativeAuthorityStatus: "qualified-locally-not-packaged", + legal: "independently-authored-results-only", + redistributableUpstreamBytes: false, + }; + if (scene.sourceProfile) { + scene.sourceProfile.authority = "src/adapters/flowerbox/README.md"; + scene.sourceProfile.authorityStatus = "pinned-source-native-state-validated-locally-not-packaged"; + } + if (scene.lighting?.encoder) delete scene.lighting.encoder.path; + if (scene.playback?.transformAsset) delete scene.playback.transformAsset.sourceFloat32; + delete scene.meshes; + delete scene.oracle; + delete scene.playback.stateEvidenceUrl; + scene.warnings = [ + "Independent source-informed PolyCSS experiment; Microsoft source, binaries, captures, and oracle packets are not packaged.", + ...scene.warnings, + ]; + + const sceneDecoded = Buffer.from(`${JSON.stringify(scene)}\n`); + const sceneEncoded = gzipSync(sceneDecoded, { level: 9, mtime: 0 }); + await writeFile(join(stagingRoot, "scenes", "default-cube.json.gz"), sceneEncoded); + await rm(join(stagingRoot, "assets", "flower-box-state-evidence.json.gz"), { force: true }); + await rm(join(stagingRoot, "product-bank.json"), { force: true }); + + const transformAssets = await Promise.all(scene.playback.transformAsset.blocks.map(async (block) => { + const bytes = await readFile(publicAssetPath(stagingRoot, block.assetUrl)); + return identityAsset(`transform:${block.index}`, block.assetUrl, bytes); + })); + const lightingBytes = await readFile(publicAssetPath(stagingRoot, scene.lighting.grid.assetUrl)); + + manifest.title = "Flower Box — PolyCSS experiment"; + entry.label = scene.label; + entry.warnings = [...scene.warnings]; + entry.sceneEncoding = "gzip"; + entry.snapshotEncoding = "gzip"; + entry.snapshot = { + ...entry.snapshot, + url: entry.snapshotUrl, + transportEncoding: "gzip", + transportByteLength: sourceSnapshotEncoded.length, + transportSha256: sha256(sourceSnapshotEncoded), + }; + manifest.assets = { + transforms: { + distribution: scene.playback.transformAsset.distribution, + schema: scene.playback.transformAsset.schema, + blockCount: scene.playback.transformAsset.blockCount, + byteLength: scene.playback.transformAsset.byteLength, + }, + lighting: { + distribution: scene.lighting.distribution, + schema: scene.lighting.schema, + assetSha256: scene.lighting.assetSha256, + byteLength: scene.lighting.grid.byteLength, + timelineRowCount: scene.lighting.timelineRowCount, + quality: scene.lighting.grid.quality, + }, + }; + manifest.productionTransport = { + schema: "cssflower-product-static-transport@2", + exactDecodedSceneAndSnapshotBytes: true, + runtimeGeometryConstruction: false, + runtimeProjection: false, + runtimeRasterization: false, + runtimeLightingCalculation: false, + assets: [ + gzipAsset("scene:default-cube", entry.sceneUrl, sceneDecoded, sceneEncoded), + gzipAsset("snapshot:default-cube", entry.snapshotUrl, sourceSnapshotDecoded, sourceSnapshotEncoded), + ...transformAssets, + identityAsset("lighting:grid", scene.lighting.grid.assetUrl, lightingBytes), + ], + }; + await writeFile(join(stagingRoot, "manifest.json"), `${JSON.stringify(manifest, null, 2)}\n`); + + const summary = await inspectFlowerboxProductBank(stagingRoot, { verifyDescriptor: false }); + await writeFlowerboxProductBankDescriptor(stagingRoot, summary, { + sourceManifestSha256: sha256(sourceManifestBytes), + sourceSceneEncodedSha256: sha256(sourceSceneEncoded), + sourceSnapshotEncodedSha256: sha256(sourceSnapshotEncoded), + productPolicy: "rounded-360-state-retained-polycss-morph-q60-minimum-8-owned-pixels", + sanitization: [ + "native qualification identities", + "oracle metadata and state evidence", + "prepare-only mesh geometry", + "ignored transform source descriptor", + "local encoder path", + "projected visual-pack transport", + ], + }); + await inspectFlowerboxProductBank(stagingRoot); + await rm(outputRoot, { recursive: true, force: true }); + await rename(stagingRoot, outputRoot); + process.stdout.write(`${JSON.stringify({ outputRoot, ...summary }, null, 2)}\n`); +} catch (error) { + await rm(stagingRoot, { recursive: true, force: true }); + throw error; +} + +function gzipAsset(id, url, decoded, encoded) { + return { + id, + url, + encoding: "gzip", + decodedByteLength: decoded.length, + decodedSha256: sha256(decoded), + encodedByteLength: encoded.length, + encodedSha256: sha256(encoded), + }; +} + +function identityAsset(id, url, bytes) { + return { + id, + url, + encoding: "identity", + byteLength: bytes.length, + sha256: sha256(bytes), + }; +} + +function publicAssetPath(root, url) { + if (typeof url !== "string" || !url.startsWith("/cssflower/") || url.includes("..")) { + throw new Error(`Unsafe Flower Box product asset URL: ${url}`); + } + return join(root, url.slice("/cssflower/".length)); +} + +function parseArgs(argv) { + const parsed = {}; + for (let index = 0; index < argv.length; index += 1) { + const argument = argv[index]; + if (!argument.startsWith("--")) continue; + const value = argv[index + 1]; + if (!value || value.startsWith("--")) parsed[argument.slice(2)] = true; + else { + parsed[argument.slice(2)] = value; + index += 1; + } + } + return parsed; +} + +function sha256(bytes) { + return createHash("sha256").update(bytes).digest("hex"); +} diff --git a/src/adapters/flowerbox/tools/polycss-snapshot-page.html b/src/adapters/flowerbox/tools/polycss-snapshot-page.html new file mode 100644 index 0000000..f5506dc --- /dev/null +++ b/src/adapters/flowerbox/tools/polycss-snapshot-page.html @@ -0,0 +1,16 @@ + + + + + + cssFlower — Microsoft Flower Box PolyCSS Snapshot + + + +
+ + + diff --git a/src/adapters/flowerbox/tools/polycss-snapshot-page.mjs b/src/adapters/flowerbox/tools/polycss-snapshot-page.mjs new file mode 100644 index 0000000..fe5476c --- /dev/null +++ b/src/adapters/flowerbox/tools/polycss-snapshot-page.mjs @@ -0,0 +1,448 @@ +import { + collectPolyRenderStats, + createPolyPerspectiveCamera, + createPolyScene, + exportPolySceneSnapshot, +} from "@layoutit/polycss"; +import { + CSSFLOWER_BOUNDARY_SEAM_BLEED, + CSSFLOWER_BOUNDARY_SEAM_BLEED_TEXT, + CSSFLOWER_LIGHTING_ATLAS_HEIGHT, + CSSFLOWER_LIGHTING_ATLAS_WIDTH, + CSSFLOWER_LIGHTING_GRID_COLUMNS, + CSSFLOWER_LIGHTING_GRID_HEIGHT, + CSSFLOWER_LIGHTING_GRID_ROWS, + CSSFLOWER_LIGHTING_GRID_WIDTH, + CSSFLOWER_LIGHTING_LAYOUT, + CSSFLOWER_LIGHTING_PAGE_COUNT, + CSSFLOWER_LIGHTING_PAGE_ROWS, + CSSFLOWER_LIGHTING_RASTER_MODE, + CSSFLOWER_LIGHTING_SCHEMA, + CSSFLOWER_SEAM_BLEED, + CSSFLOWER_SEAM_BLEED_TEXT, + CSSFLOWER_SEAM_BLEED_POLICY, +} from "../src/cssflower/renderContract.mjs"; + +const host = document.getElementById("scene"); +const params = new URLSearchParams(location.search); +const sceneUrl = params.get("sceneUrl"); + +main().catch((error) => { + window.__cssFlowerDebugSnapshot = { + status: "error", + error: error.stack || error.message || String(error), + }; +}); + +async function main() { + if (!(host instanceof HTMLElement) || !sceneUrl?.startsWith("/cssflower/scenes/")) { + throw new Error("cssFlower snapshot page requires a generated cssFlower sceneUrl"); + } + const sceneData = await fetchJson(sceneUrl); + validateScene(sceneData); + const preparationLightingUrl = sceneData.lighting.grid.assetUrl; + const [, , initialTransforms] = await Promise.all([ + fetchVerifiedBytes(preparationLightingUrl, sceneData.lighting.grid.sha256), + loadImage(preparationLightingUrl), + loadInitialPreparedTransforms(sceneData), + ]); + const { scene, mesh } = createSnapshotScene(sceneData); + try { + const retained = mountRetainedTargets({ scene, mesh, sceneData, initialTransforms, preparationLightingUrl }); + scene.applyCamera(); + await scene.whenTexturesReady(); + await new Promise((resolve) => requestAnimationFrame(() => requestAnimationFrame(resolve))); + + const stats = collectPolyRenderStats(host, { + polygonCount: sceneData.metrics.preparedLeafCount, + scopeSelector: "[data-cssflower-rotation-root]", + }); + assertSnapshotStats(stats, retained, sceneData); + const exported = await exportPolySceneSnapshot(host); + const preparedAtlasUrl = sceneData.lighting.grid.assetUrl; + const html = prepareExportedSnapshot(restorePreparedLightingReference(exported, preparedAtlasUrl)); + assertExportedSnapshot(html, sceneData); + window.__cssFlowerDebugSnapshot = { + status: "ready", + sceneUrl, + sceneId: sceneData.id, + html, + stats, + retainedLeafCount: retained.leaves.length, + retainedRotationRootCount: 1, + triangleIdCount: retained.triangleIds.size, + seamBleed: CSSFLOWER_SEAM_BLEED, + boundarySeamBleed: CSSFLOWER_BOUNDARY_SEAM_BLEED, + boundaryAdjacentTriangleCount: sceneData.renderer.seamBleedBoundaryAdjacentTriangleCount, + mergedCellCount: 0, + lightingAtlasStateCount: sceneData.lighting.timelineRowCount, + lightingAtlasDataUrlCount: (html.match(/data:image\/png;base64/gu) ?? []).length, + preparedAtlasReferenceCount: (html.match(/\/cssflower\/assets\/lighting\/grid-[a-f0-9]{64}\.avif/gu) ?? []).length, + scriptCount: (html.match(/ face.boundaryAdjacent === true && face.seamBleed === CSSFLOWER_BOUNDARY_SEAM_BLEED).length !== 432 || + sceneData.lighting.faces.filter((face) => face.boundaryAdjacent === false && face.seamBleed === CSSFLOWER_SEAM_BLEED).length !== 768 || + sceneData.lighting?.backgroundPositionXs?.length !== CSSFLOWER_LIGHTING_PAGE_COUNT || + sceneData.lighting?.backgroundPositionYs?.length !== CSSFLOWER_LIGHTING_PAGE_ROWS || + sceneData.lighting?.runtimeLightingCalculations !== 0) { + throw new Error("Prepared cssFlower default-cube scene contract is invalid"); + } +} + +function createSnapshotScene(sceneData) { + const camera = createSceneCamera(sceneData); + const scene = createPolyScene(host, { + camera, + ambientLight: { color: "#ffffff", intensity: Math.PI }, + directionalLight: { direction: [0, -1, 1], color: "#ffffff", intensity: 0 }, + textureLighting: "baked", + textureQuality: 1, + textureLeafSizing: "raster", + textureBackend: "atlas", + textureProjection: "affine", + seamBleed: CSSFLOWER_SEAM_BLEED, + autoCenter: false, + }); + const sourceMesh = sceneData.meshes[0]; + const mesh = scene.add({ + polygons: sourceMesh.polygons, + objectUrls: [], + warnings: [], + dispose: () => undefined, + }, { + ...(sourceMesh.transform ?? {}), + id: sourceMesh.id, + merge: false, + meshResolution: "lossless", + stableDom: true, + excludeFromAutoCenter: true, + }); + return { scene, mesh }; +} + +function mountRetainedTargets({ scene, mesh, sceneData, initialTransforms, preparationLightingUrl }) { + const sceneRoot = host.querySelector(".polycss-scene"); + if (!(sceneRoot instanceof HTMLElement) || !(mesh?.element instanceof HTMLElement)) { + throw new Error("PolyCSS failed to mount the cssFlower scene and mesh roots"); + } + const root = document.createElement("div"); + root.dataset.cssflowerRotationRoot = "true"; + Object.assign(root.style, { + position: "absolute", + left: "0", + top: "0", + width: "0", + height: "0", + transformOrigin: "0 0 0", + transformStyle: "preserve-3d", + transform: sceneData.playback.cycle.rootTransforms[0], + }); + root.style.setProperty("--cssflower-space-texels", `url("${preparationLightingUrl}")`); + sceneRoot.append(root); + root.append(mesh.element); + + const leaves = [...mesh.element.querySelectorAll("[data-cssflower-leaf-index]")] + .sort((left, right) => integerAttribute(left, "data-cssflower-leaf-index") - integerAttribute(right, "data-cssflower-leaf-index")); + if (leaves.length !== 1200) { + throw new Error(`Prepared cssFlower retained bank drifted (${leaves.length} leaves)`); + } + const triangleIds = new Set(); + for (let index = 0; index < leaves.length; index += 1) { + const leaf = leaves[index]; + if (integerAttribute(leaf, "data-cssflower-leaf-index") !== index) { + throw new Error(`Prepared cssFlower leaf order diverged at ${index}`); + } + const triangleId = leaf.getAttribute("data-cssflower-triangle"); + if (!triangleId || triangleIds.has(triangleId)) { + throw new Error(`Prepared cssFlower triangle identity diverged at ${index}`); + } + triangleIds.add(triangleId); + const face = sceneData.lighting.faces[index]; + if (face?.sourceOrder !== index || face.triangleId !== triangleId || + !Number.isSafeInteger(face.leafWidth) || face.tileWidth !== face.leafWidth || + !Number.isSafeInteger(face.leafHeight) || face.tileHeight !== face.leafHeight || + typeof face.backgroundPositionX !== "string" || typeof face.backgroundPositionY !== "string") { + throw new Error(`Prepared cssFlower raster face binding diverged at ${index}`); + } + leaf.dataset.cssflowerRetainedLeaf = "true"; + leaf.dataset.cssflowerLightingField = String(index); + leaf.dataset.cssflowerSeamBleed = String(face.seamBleed); + leaf.dataset.cssflowerSeamEdgeMask = String(face.seamEdgeMask); + leaf.dataset.polycssTextureBackend = "atlas"; + leaf.dataset.polycssTextureLeafSizing = "raster"; + leaf.dataset.polycssTextureImageRendering = "auto"; + leaf.dataset.polycssTextureLighting = "baked"; + leaf.dataset.polycssTextureProjection = "affine"; + leaf.dataset.polycssTextureLeafWidth = String(face.leafWidth); + leaf.dataset.polycssTextureLeafHeight = String(face.leafHeight); + if (!leaf.style.transform.startsWith("matrix3d(") || + typeof initialTransforms[index] !== "string" || + !initialTransforms[index].startsWith("matrix3d(")) { + throw new Error(`Prepared cssFlower initial leaf transform ${index} is missing`); + } + leaf.style.transform = initialTransforms[index]; + leaf.style.backgroundImage = "var(--cssflower-space-texels)"; + leaf.style.backgroundColor = "transparent"; + leaf.style.backgroundRepeat = "no-repeat"; + leaf.style.width = `${face.leafWidth}px`; + leaf.style.height = `${face.leafHeight}px`; + leaf.style.setProperty("--polycss-atlas-width", `${face.leafWidth}px`); + leaf.style.setProperty("--polycss-atlas-height", `${face.leafHeight}px`); + leaf.style.setProperty("--polycss-atlas-leaf-sizing", "raster"); + leaf.style.backgroundPositionX = face.backgroundPositionX; + leaf.style.backgroundPositionY = face.backgroundPositionY; + leaf.style.backgroundSize = face.backgroundSize; + leaf.style.imageRendering = "auto"; + } + return { root, leaves, triangleIds }; +} + +function assertSnapshotStats(stats, retained, sceneData) { + if (retained.leaves.length !== 1200 || retained.triangleIds.size !== 1200 || + host.querySelectorAll("[data-cssflower-rotation-root]").length !== 1 || + host.querySelectorAll(`[data-cssflower-seam-bleed="${CSSFLOWER_SEAM_BLEED_TEXT}"]`).length !== 768 || + host.querySelectorAll(`[data-cssflower-seam-bleed="${CSSFLOWER_BOUNDARY_SEAM_BLEED_TEXT}"]`).length !== 432 || + host.querySelectorAll('[data-polycss-texture-backend="atlas"][data-polycss-texture-leaf-sizing="raster"]').length !== 1200 || + host.querySelectorAll('[data-polycss-texture-image-rendering="auto"]').length !== 1200 || + stats.polygonCount !== sceneData.metrics.preparedLeafCount || + stats.mountedPolygonLeafCount !== sceneData.metrics.preparedLeafCount || + stats.surfaceLeafCounts.stableTriangle !== sceneData.metrics.preparedLeafCount || + stats.surfaceLeafCounts.quad !== 0) { + throw new Error(`Prepared cssFlower PolyCSS leaf stats drifted: ${JSON.stringify(stats)}`); + } +} + +function assertExportedSnapshot(html, sceneData) { + const count = (expression) => (html.match(expression) ?? []).length; + const dataUrlCount = count(/data:image\/png;base64/gu); + const preparedAtlasReferenceCount = count(/\/cssflower\/assets\/lighting\/grid-[a-f0-9]{64}\.avif/gu); + if (!html.includes("polycss-scene") || + count(/<\/u>/gu) !== 1200 || + count(/\.polycss-mesh>u\.[a-zA-Z]{1,2} \{/gu) !== 1200 || + count(/--polycss-atlas-leaf-sizing: raster/gu) !== 1 || + count(/image-rendering: auto/gu) !== 1 || + !/