From 19838867b10b4709ffb1662543c8ba31deb4d640 Mon Sep 17 00:00:00 2001 From: alowpoly Date: Thu, 6 Aug 2026 14:10:43 -0300 Subject: [PATCH 1/5] feat: add Flower Box PolyCSS experiment --- README.md | 14 + package.json | 11 +- scripts/prepare/flowerbox-product-bank.mjs | 91 ++ scripts/verify-source-only.mjs | 7 +- site/public/sitemap.xml | 3 + src/adapters/flowerbox/README.md | 28 + src/adapters/flowerbox/index.html | 28 + .../flowerbox/prepared-bank.lock.json | 29 + .../flowerbox/public/flower-social.png | Bin 0 -> 119448 bytes .../flowerbox/src/cssflower/client.mjs | 95 ++ .../flowerbox/src/cssflower/debugApi.mjs | 49 ++ .../src/cssflower/manifestClient.mjs | 495 +++++++++++ .../flowerbox/src/cssflower/polycssScene.mjs | 144 ++++ .../src/cssflower/preparedPlayback.mjs | 255 ++++++ .../src/cssflower/projectedPageStyles.mjs | 70 ++ .../src/cssflower/renderContract.mjs | 31 + .../flowerbox/src/cssflower/routeState.mjs | 40 + .../src/cssflower/stagePresentation.mjs | 50 ++ .../flowerbox/src/cssflower/styles.css | 88 ++ src/adapters/flowerbox/src/main.mjs | 4 + .../src/prepare/cssflower/bloomCycle.mjs | 128 +++ .../cssflower/compilePreparedCycle.mjs | 598 +++++++++++++ .../src/prepare/cssflower/cubeTopology.mjs | 267 ++++++ .../src/prepare/cssflower/dataSource.mjs | 33 + .../prepare/cssflower/leafRasterLighting.mjs | 246 ++++++ .../flowerbox/src/prepare/cssflower/paths.mjs | 70 ++ .../src/prepare/cssflower/prepare.mjs | 43 + .../src/prepare/cssflower/projectedPixels.mjs | 812 ++++++++++++++++++ .../src/prepare/cssflower/provenance.mjs | 29 + .../src/prepare/cssflower/quadMergeAudit.mjs | 109 +++ .../src/prepare/cssflower/sceneBuilder.mjs | 272 ++++++ .../prepare/cssflower/sharedFramePacking.mjs | 86 ++ .../cssflower/sharedFramePageStore.mjs | 362 ++++++++ .../cssflower/sharedFramePageWorker.mjs | 119 +++ .../prepare/cssflower/sharedFramePages.mjs | 216 +++++ .../src/prepare/cssflower/slicePlan.mjs | 21 + .../src/prepare/cssflower/sourceProfile.mjs | 116 +++ .../src/prepare/cssflower/writeManifest.mjs | 87 ++ .../prepare/cssflower/writePreparedAssets.mjs | 248 ++++++ .../flowerbox/tools/audit-runtime-surface.mjs | 72 ++ .../flowerbox/tools/package-product-bank.mjs | 131 +++ .../tools/polycss-snapshot-page.html | 16 + .../flowerbox/tools/polycss-snapshot-page.mjs | 337 ++++++++ .../flowerbox/tools/prepare-cssflower.mjs | 43 + .../tools/prepare-polycss-snapshot.mjs | 131 +++ src/adapters/flowerbox/tools/productBank.mjs | 165 ++++ .../flowerbox/tools/smoke-browser.mjs | 134 +++ .../flowerbox/tools/verify-product-bank.mjs | 8 + src/adapters/flowerbox/vite.config.mjs | 36 + 49 files changed, 6465 insertions(+), 2 deletions(-) create mode 100644 scripts/prepare/flowerbox-product-bank.mjs create mode 100644 src/adapters/flowerbox/README.md create mode 100644 src/adapters/flowerbox/index.html create mode 100644 src/adapters/flowerbox/prepared-bank.lock.json create mode 100644 src/adapters/flowerbox/public/flower-social.png create mode 100644 src/adapters/flowerbox/src/cssflower/client.mjs create mode 100644 src/adapters/flowerbox/src/cssflower/debugApi.mjs create mode 100644 src/adapters/flowerbox/src/cssflower/manifestClient.mjs create mode 100644 src/adapters/flowerbox/src/cssflower/polycssScene.mjs create mode 100644 src/adapters/flowerbox/src/cssflower/preparedPlayback.mjs create mode 100644 src/adapters/flowerbox/src/cssflower/projectedPageStyles.mjs create mode 100644 src/adapters/flowerbox/src/cssflower/renderContract.mjs create mode 100644 src/adapters/flowerbox/src/cssflower/routeState.mjs create mode 100644 src/adapters/flowerbox/src/cssflower/stagePresentation.mjs create mode 100644 src/adapters/flowerbox/src/cssflower/styles.css create mode 100644 src/adapters/flowerbox/src/main.mjs create mode 100644 src/adapters/flowerbox/src/prepare/cssflower/bloomCycle.mjs create mode 100644 src/adapters/flowerbox/src/prepare/cssflower/compilePreparedCycle.mjs create mode 100644 src/adapters/flowerbox/src/prepare/cssflower/cubeTopology.mjs create mode 100644 src/adapters/flowerbox/src/prepare/cssflower/dataSource.mjs create mode 100644 src/adapters/flowerbox/src/prepare/cssflower/leafRasterLighting.mjs create mode 100644 src/adapters/flowerbox/src/prepare/cssflower/paths.mjs create mode 100644 src/adapters/flowerbox/src/prepare/cssflower/prepare.mjs create mode 100644 src/adapters/flowerbox/src/prepare/cssflower/projectedPixels.mjs create mode 100644 src/adapters/flowerbox/src/prepare/cssflower/provenance.mjs create mode 100644 src/adapters/flowerbox/src/prepare/cssflower/quadMergeAudit.mjs create mode 100644 src/adapters/flowerbox/src/prepare/cssflower/sceneBuilder.mjs create mode 100644 src/adapters/flowerbox/src/prepare/cssflower/sharedFramePacking.mjs create mode 100644 src/adapters/flowerbox/src/prepare/cssflower/sharedFramePageStore.mjs create mode 100644 src/adapters/flowerbox/src/prepare/cssflower/sharedFramePageWorker.mjs create mode 100644 src/adapters/flowerbox/src/prepare/cssflower/sharedFramePages.mjs create mode 100644 src/adapters/flowerbox/src/prepare/cssflower/slicePlan.mjs create mode 100644 src/adapters/flowerbox/src/prepare/cssflower/sourceProfile.mjs create mode 100644 src/adapters/flowerbox/src/prepare/cssflower/writeManifest.mjs create mode 100644 src/adapters/flowerbox/src/prepare/cssflower/writePreparedAssets.mjs create mode 100644 src/adapters/flowerbox/tools/audit-runtime-surface.mjs create mode 100644 src/adapters/flowerbox/tools/package-product-bank.mjs create mode 100644 src/adapters/flowerbox/tools/polycss-snapshot-page.html create mode 100644 src/adapters/flowerbox/tools/polycss-snapshot-page.mjs create mode 100644 src/adapters/flowerbox/tools/prepare-cssflower.mjs create mode 100644 src/adapters/flowerbox/tools/prepare-polycss-snapshot.mjs create mode 100644 src/adapters/flowerbox/tools/productBank.mjs create mode 100644 src/adapters/flowerbox/tools/smoke-browser.mjs create mode 100644 src/adapters/flowerbox/tools/verify-product-bank.mjs create mode 100644 src/adapters/flowerbox/vite.config.mjs diff --git a/README.md b/README.md index b1d296a..9cc7e6f 100644 --- a/README.md +++ b/README.md @@ -40,6 +40,20 @@ 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 complete prepared cycle uses +1,200 stable retained triangle leaves and streams a hash-bound q40 visual bank. + +```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/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..22c4f79 --- /dev/null +++ b/scripts/prepare/flowerbox-product-bank.mjs @@ -0,0 +1,91 @@ +#!/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", +)); +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 && + 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) { + 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..2bbe4ec --- /dev/null +++ b/src/adapters/flowerbox/README.md @@ -0,0 +1,28 @@ +# Flower Box + +An independently authored PolyCSS reconstruction of the classic 1995 Flower +Box. The complete default-cube bloom and rotation cycle is rendered through +1,200 stable retained HTML triangle leaves and one retained rotation root. + +The browser loads one prepared snapshot and selects hash-bound prepared AVIF +pages, source-order leaf windows, and root transforms. It does not construct +geometry, project vertices, calculate normals or lighting, rasterize, or grow +the DOM at runtime. + +From the repository root: + +```sh +pnpm prepare:flowerbox:artifact +pnpm build:flowerbox +pnpm dev:flowerbox +``` + +The public q40 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 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..4867bc6 --- /dev/null +++ b/src/adapters/flowerbox/index.html @@ -0,0 +1,28 @@ + + + + + + Flower Box — HTML and CSS experiment + + + + + + + + + + + + + + +
+
+ Loading… +

PolyCSS · independent experiment, not affiliated with Microsoft

+
+ + + diff --git a/src/adapters/flowerbox/prepared-bank.lock.json b/src/adapters/flowerbox/prepared-bank.lock.json new file mode 100644 index 0000000..1d889d6 --- /dev/null +++ b/src/adapters/flowerbox/prepared-bank.lock.json @@ -0,0 +1,29 @@ +{ + "schema": "cssflower-prepared-bank-lock@1", + "tag": "cssflower-product-q40-v1", + "asset": "cssflower-product-q40-v1.tar.gz", + "url": "https://github.com/layoutit/cssGraphics/releases/download/cssflower-product-q40-v1/cssflower-product-q40-v1.tar.gz", + "archiveByteLength": 29334538, + "archiveSha256": "3e623ef672e3a2754e3ba58d53f87e48b45d625875d57c4110669c64c21867ff", + "productClosureBytes": 29579964, + "productClosureSha256": "8d91b0370d3ee16a6ddeff6d0f5b95f26b628ac6d961f31d11290a549f803b31", + "productDescriptorByteLength": 1606, + "productDescriptorSha256": "2be1268273cadb07eb0460386efa15a6262617d33bca8cb479ea01ddd07b1d1b", + "retainedTriangleLeafCount": 1200, + "retainedRotationRootCount": 1, + "timelineStateCount": 9331, + "projectedPageCount": 2333, + "projectedAtlasAssetCount": 2273, + "projectedLayoutBlockCount": 37, + "visualEncoding": { + "codec": "AVIF", + "quality": 40, + "chromaSubsampling": "4:4:4" + }, + "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 0000000000000000000000000000000000000000..6f428914f84fba82e209f33d8fd422f28561215f GIT binary patch literal 119448 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 literal 0 HcmV?d00001 diff --git a/src/adapters/flowerbox/src/cssflower/client.mjs b/src/adapters/flowerbox/src/cssflower/client.mjs new file mode 100644 index 0000000..cab33a8 --- /dev/null +++ b/src/adapters/flowerbox/src/cssflower/client.mjs @@ -0,0 +1,95 @@ +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() { + const host = document.getElementById("scene"); + const status = document.getElementById("status"); + const presentation = installCssflowerStagePresentation(host); + const state = { + ready: false, + route: null, + manifest: null, + sceneData: null, + mount: null, + errors: [], + }; + installCssflowerDebugApi(state); + + window.addEventListener("error", (event) => { + recordError(state, event.message || String(event.error || "error"), status); + }); + window.addEventListener("unhandledrejection", (event) => { + recordError(state, String(event.reason?.message || event.reason || "unhandled rejection"), status); + }); + + main().catch((error) => { + recordError(state, error.stack || error.message || String(error), status); + }); + + async function main() { + document.body.dataset.productView = "1"; + document.body.dataset.gameView = "polycss"; + document.body.dataset.portSlug = "cssflower"; + setStatus(status, "Loading Flower Box…", "loading"); + + const route = createRouteState(); + state.route = route; + document.body.dataset.routeScene = route.scene; + + const manifest = await loadPreparedManifest(route); + state.manifest = manifest; + + setStatus(status, "Loading prepared scene…", "loading"); + const { entry, sceneData, snapshotHtml, projectedPages } = await loadPreparedScene(manifest, route); + state.sceneData = sceneData; + document.body.dataset.sceneUrl = entry.sceneUrl; + if (entry.snapshotUrl) document.body.dataset.snapshotUrl = entry.snapshotUrl; + + setStatus(status, "Mounting retained PolyCSS scene…", "loading"); + const snapshot = mountPreparedPolycssSnapshot({ host, sceneData, snapshotHtml, projectedPages }); + const player = await createCssflowerPreparedPlayer({ + playback: sceneData.playback, + rotationRoot: snapshot.rotationRoot, + mesh: snapshot.mesh, + leaves: snapshot.leaves, + projectedPages, + }); + state.mount = Object.freeze({ + ...snapshot, + player, + stats() { + return Object.freeze({ + ...player.stats(), + ...snapshot.stats(), + ...presentation.stats(), + }); + }, + destroy() { + presentation.destroy(); + player.destroy(); + projectedPages.destroy(); + snapshot.destroy(); + }, + }); + state.ready = true; + setStatus(status, "Ready — 1,200 retained PolyCSS triangles", "ready"); + requestAnimationFrame(() => player.resume()); + } +} + +function recordError(state, message, status) { + state.errors.push(message); + setStatus(status, message, "error"); +} + +function setStatus(status, message, kind = "loading") { + document.body.dataset.portStatus = kind; + if (status) status.textContent = message; +} diff --git a/src/adapters/flowerbox/src/cssflower/debugApi.mjs b/src/adapters/flowerbox/src/cssflower/debugApi.mjs new file mode 100644 index 0000000..db0ff22 --- /dev/null +++ b/src/adapters/flowerbox/src/cssflower/debugApi.mjs @@ -0,0 +1,49 @@ +export function installCssflowerDebugApi(state) { + const api = { + get ready() { + return state.ready; + }, + 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; + }, + 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; + document.body.setAttribute("data-cssflower-devtools-helper", "window.__cssFlowerDebug"); + 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..8789206 --- /dev/null +++ b/src/adapters/flowerbox/src/cssflower/manifestClient.mjs @@ -0,0 +1,495 @@ +import { + sceneEntryForRoute, + routeSceneLabel, +} from "./routeState.mjs"; +import { + CSSFLOWER_BOUNDARY_SEAM_BLEED, + CSSFLOWER_LIGHTING_ATLAS_HEIGHT, + CSSFLOWER_LIGHTING_ATLAS_WIDTH, + CSSFLOWER_LIGHTING_GUTTER, + CSSFLOWER_LIGHTING_LAYOUT, + CSSFLOWER_LIGHTING_PAGE_COUNT, + CSSFLOWER_LIGHTING_PAGE_ROWS, + CSSFLOWER_LIGHTING_SCHEMA, + CSSFLOWER_LIGHTING_STATE_SLICE_HEIGHT, + CSSFLOWER_PROJECTED_ATLAS_ENCODING, + CSSFLOWER_PROJECTED_ATLAS_MIME_TYPE, + CSSFLOWER_PROJECTED_ATLAS_QUALITY, + CSSFLOWER_SEAM_BLEED, + CSSFLOWER_SEAM_BLEED_POLICY, +} from "./renderContract.mjs"; + +export async function loadPreparedManifest(routeState) { + const manifest = await fetchJson(routeState.manifestUrl, { + notFoundMessage: "Missing prepared Flower Box manifest at " + routeState.manifestUrl + ". Run pnpm prepare:flowerbox:artifact first.", + }); + if (manifest?.schema !== "cssflower-manifest@1" || manifest?.status !== "ready") { + throw new Error("Prepared Flower Box manifest is not ready (" + (manifest?.status ?? "missing status") + "). Run pnpm prepare:flowerbox:artifact first."); + } + return manifest; +} + +export async function loadPreparedScene(manifest, routeState) { + const entry = sceneEntryForRoute(manifest, routeState); + if (!entry || typeof entry.sceneUrl !== "string") { + throw new Error("Prepared Flower Box manifest does not include " + routeSceneLabel(routeState) + ". Run pnpm prepare:flowerbox:artifact first."); + } + const sceneData = await fetchJson(entry.sceneUrl, { + notFoundMessage: "Missing prepared Flower Box scene at " + entry.sceneUrl + ". Run pnpm prepare:flowerbox:artifact first.", + }); + const snapshotHtml = typeof entry.snapshotUrl === "string" && entry.snapshotUrl + ? await fetchText(entry.snapshotUrl, { + notFoundMessage: "Missing prepared Flower Box PolyCSS snapshot at " + entry.snapshotUrl + ". Run pnpm prepare:flowerbox:artifact 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 !== "leaf-resolution" || + sceneData?.lighting?.sampling !== "endpoint-aligned-pixel-centers" || + 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?.faceCount !== 1200 || + sceneData?.lighting?.timelineRowCount !== 9_331 || + 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 && + face.tileWidth === face.leafWidth && face.tileHeight === face.leafHeight && + Number.isSafeInteger(face.contentX) && Number.isSafeInteger(face.contentY) && + face.contentX >= CSSFLOWER_LIGHTING_GUTTER && face.contentX + face.leafWidth < CSSFLOWER_LIGHTING_ATLAS_WIDTH && + face.contentY >= CSSFLOWER_LIGHTING_GUTTER && face.contentY + face.leafHeight < CSSFLOWER_LIGHTING_STATE_SLICE_HEIGHT && + 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?.rowSelection !== "prepared-timeline-state-page-and-row-index" || + sceneData?.lighting?.temporalInterpolation !== false || + 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("Prepared Flower Box retained snapshot binding is missing. Run pnpm prepare:flowerbox:artifact first."); + } + await assertSha256(new TextEncoder().encode(snapshotHtml), entry.snapshot.sha256, "snapshot"); + validateProjectedPixels(sceneData.playback); + const projectedPages = createPreparedProjectedPageLoader(sceneData.playback.projectedPixels); + await projectedPages.prime(0, 1); + return { + entry, + sceneData, + snapshotHtml, + projectedPages, + }; +} + +function createPreparedProjectedPageLoader(projected) { + const records = new Map(); + const layoutBlocks = new Map(); + const errors = []; + let currentPageIndex = 0; + let pageLoadCount = 0; + let pageReleaseCount = 0; + let residentDecodedBytes = 0; + let peakResidentDecodedBytes = 0; + let desiredPageIndices = new Set(); + let destroyed = false; + + async function ensure(pageIndex) { + if (destroyed) throw new Error("Prepared cssFlower projected page loader is destroyed"); + const page = projected.pages[pageIndex]; + if (!page || page.index !== pageIndex) throw new RangeError(`Prepared cssFlower projected page ${pageIndex} is missing`); + const existing = records.get(pageIndex); + if (existing?.url) return existing; + if (existing?.promise) return existing.promise; + const promise = (async () => { + const block = projected.layoutBlocks[page.layout.blockIndex]; + const [atlasBytes, layoutBlock] = await Promise.all([ + fetchBytes(page.atlas.assetUrl, { + notFoundMessage: `Missing prepared Flower Box projected atlas ${pageIndex}. Run pnpm prepare:flowerbox:artifact first.`, + }), + ensureLayoutBlock(block), + ]); + if (atlasBytes.byteLength !== page.atlas.byteLength) { + throw new Error(`Generated cssFlower projected atlas ${pageIndex} byte length is invalid.`); + } + const layoutBytes = layoutBlock.bytes.subarray( + page.layout.blockByteOffset, + page.layout.blockByteOffset + page.layout.byteLength, + ); + if (layoutBytes.byteLength !== page.layout.byteLength || layoutBytes.byteLength % 2 !== 0) { + throw new Error(`Generated cssFlower projected layout ${pageIndex} byte length is invalid.`); + } + await Promise.all([ + assertSha256(atlasBytes, page.atlas.sha256, `projected atlas ${pageIndex}`), + assertSha256(layoutBytes, page.layout.sha256, `projected layout ${pageIndex}`), + ]); + const url = URL.createObjectURL(new Blob([atlasBytes], { type: page.atlas.mimeType })); + let image; + try { + image = await decodeImage(url); + } catch (error) { + URL.revokeObjectURL(url); + throw error; + } + const record = Object.freeze({ + pageIndex, + url, + image, + layoutBlockIndex: block.index, + layoutValues: new Int16Array( + layoutBytes.buffer, + layoutBytes.byteOffset, + layoutBytes.byteLength / Int16Array.BYTES_PER_ELEMENT, + ), + decodedBytes: page.atlas.decodedBytes, + }); + records.set(pageIndex, record); + pageLoadCount += 1; + residentDecodedBytes += page.atlas.decodedBytes; + peakResidentDecodedBytes = Math.max(peakResidentDecodedBytes, residentDecodedBytes); + if (destroyed || !desiredPageIndices.has(pageIndex)) releaseRecord(pageIndex, record); + return record; + })(); + records.set(pageIndex, { promise }); + try { + return await promise; + } catch (error) { + if (records.get(pageIndex)?.promise === promise) records.delete(pageIndex); + errors.push(String(error?.message || error)); + throw error; + } + } + + async function ensureLayoutBlock(block) { + if (!block || projected.layoutBlocks[block.index] !== block) { + throw new Error("Prepared cssFlower shared layout block is missing"); + } + const existing = layoutBlocks.get(block.index); + if (existing?.bytes) return existing; + if (existing?.promise) return existing.promise; + const promise = (async () => { + const compressed = await fetchBytes(block.assetUrl, { + notFoundMessage: `Missing prepared Flower Box shared layout block ${block.index}. Run pnpm prepare:flowerbox:artifact first.`, + }); + if (compressed.byteLength !== block.byteLength) { + throw new Error(`Generated cssFlower shared layout block ${block.index} byte length is invalid.`); + } + await assertSha256(compressed, block.sha256, `shared layout block ${block.index}`); + const bytes = await decompressGzip(compressed); + if (bytes.byteLength !== block.decodedByteLength) { + throw new Error(`Generated cssFlower shared layout block ${block.index} decoded byte length is invalid.`); + } + await assertSha256(bytes, block.decodedSha256, `decoded shared layout block ${block.index}`); + const record = Object.freeze({ index: block.index, bytes }); + layoutBlocks.set(block.index, record); + const desiredBlocks = new Set([...desiredPageIndices].map((pageIndex) => ( + projected.pages[pageIndex].layout.blockIndex + ))); + if (destroyed || !desiredBlocks.has(block.index)) layoutBlocks.delete(block.index); + return record; + })(); + layoutBlocks.set(block.index, { promise }); + try { + return await promise; + } catch (error) { + if (layoutBlocks.get(block.index)?.promise === promise) layoutBlocks.delete(block.index); + throw error; + } + } + + function releaseRecord(pageIndex, record) { + if (!record?.url || records.get(pageIndex) !== record) return; + record.image.removeAttribute("src"); + URL.revokeObjectURL(record.url); + records.delete(pageIndex); + pageReleaseCount += 1; + residentDecodedBytes -= record.decodedBytes; + } + + function releaseExcept(keep) { + desiredPageIndices = new Set(keep); + for (const [pageIndex, record] of records) { + if (keep.has(pageIndex) || !record?.url) continue; + releaseRecord(pageIndex, record); + } + const keepBlocks = new Set([...keep].map((pageIndex) => projected.pages[pageIndex].layout.blockIndex)); + for (const [blockIndex, record] of layoutBlocks) { + if (keepBlocks.has(blockIndex) || !record?.bytes) continue; + layoutBlocks.delete(blockIndex); + } + } + + function prefetch(pageIndex) { + if (pageIndex === currentPageIndex || records.get(pageIndex)?.url) return; + void ensure(pageIndex).catch(() => undefined); + } + + return Object.freeze({ + async prime(pageIndex, nextPageIndex) { + currentPageIndex = pageIndex; + releaseExcept(new Set([pageIndex, nextPageIndex])); + await Promise.all([ensure(pageIndex), ensure(nextPageIndex)]); + }, + urlFor(pageIndex) { + const record = records.get(pageIndex); + if (!record?.url) throw new Error(`Prepared cssFlower projected page ${pageIndex} is not decoded`); + return record.url; + }, + layoutFor(pageIndex) { + const record = records.get(pageIndex); + if (!(record?.layoutValues instanceof Int16Array)) { + throw new Error(`Prepared cssFlower projected layout ${pageIndex} is not decoded`); + } + return record.layoutValues; + }, + async activate(pageIndex, nextPageIndex) { + releaseExcept(new Set([currentPageIndex, pageIndex])); + const record = await ensure(pageIndex); + currentPageIndex = pageIndex; + return record; + }, + commitPresented(pageIndex, nextPageIndex) { + if (pageIndex !== currentPageIndex || !records.get(pageIndex)?.url) { + throw new Error(`Prepared cssFlower projected page ${pageIndex} cannot be committed before presentation`); + } + releaseExcept(new Set([pageIndex, nextPageIndex])); + prefetch(nextPageIndex); + }, + stats() { + return Object.freeze({ + schema: "cssflower-prepared-projected-page-loader@1", + currentPageIndex, + pageLoadCount, + pageReleaseCount, + residentPageCount: [...records.values()].filter((record) => record?.url).length, + residentDecodedBytes, + peakResidentDecodedBytes, + residentPageBudget: projected.decodedResidentPageBudget, + peakPageBudget: projected.decodedPeakPageBudget, + desiredPageIndices: Object.freeze([...desiredPageIndices].sort((left, right) => left - right)), + residentLayoutBlockCount: [...layoutBlocks.values()].filter((record) => record?.bytes).length, + residentDecodedLayoutBytes: [...layoutBlocks.values()].reduce( + (sum, record) => sum + (record?.bytes?.byteLength ?? 0), + 0, + ), + errors: Object.freeze([...errors]), + }); + }, + destroy() { + destroyed = true; + releaseExcept(new Set()); + }, + }); +} + +function validateProjectedPixels(playback) { + const projected = playback?.projectedPixels; + if (projected?.schema !== "cssflower-prepared-projected-pixel-playback@1" || + projected.representation !== "shared-frame-windows" || + projected.physicalLayout !== "source-order-retained-leaf-windows-over-screen-aligned-prepared-frame-pages" || + projected.rasterMode !== "source-camera-projected-pixels" || + projected.visualEncoding?.codec !== "AVIF" || + projected.visualEncoding?.mimeType !== CSSFLOWER_PROJECTED_ATLAS_MIME_TYPE || + projected.visualEncoding?.quality !== CSSFLOWER_PROJECTED_ATLAS_QUALITY || + projected.visualEncoding?.chromaSubsampling !== "4:4:4" || + projected.visualEncoding?.exactStateAndTopology !== true || + projected.visualEncoding?.exactPreparedPixels !== false || + projected.stateCount !== playback.cycle.stateCount || + projected.cycleStartState !== playback.cycle.cycleStartState || + projected.cycleLength !== playback.cycle.cycleLength || + projected.retainedLeafCount !== 1_200 || + !Number.isSafeInteger(projected.pageCount) || projected.pageCount < 1 || + projected.pages?.length !== projected.pageCount || + !Number.isSafeInteger(projected.layoutBlockPageCount) || projected.layoutBlockPageCount !== 64 || + !Array.isArray(projected.layoutBlocks) || projected.layoutBlocks.length < 1 || + projected.decodedResidentPageBudget !== 2 || + projected.decodedPeakPageBudget !== 2 || + projected.maximumDecodedPageBytes > 16 * 1024 * 1024 || + projected.inverseRootTransforms?.length !== playback.cycle.rootStateCount || + projected.inverseRootTransforms.some((transform) => typeof transform !== "string") || + projected.runtimeProjection !== false || projected.runtimeRasterization !== false || + projected.runtimeGeometryConstruction !== false || projected.runtimeNormalCalculation !== false || + projected.runtimeLightingCalculation !== false || projected.runtimeDomGrowth !== false) { + throw new Error("Complete prepared cssFlower projected-pixel playback is required"); + } + let layoutBlockDecodedBytes = 0; + for (let blockIndex = 0; blockIndex < projected.layoutBlocks.length; blockIndex += 1) { + const block = projected.layoutBlocks[blockIndex]; + const expectedPageCount = Math.min( + projected.layoutBlockPageCount, + projected.pageCount - blockIndex * projected.layoutBlockPageCount, + ); + if (block?.schema !== "cssflower-prepared-shared-layout-block@1" || block.index !== blockIndex || + block.startPageIndex !== blockIndex * projected.layoutBlockPageCount || + block.pageCount !== expectedPageCount || + block.encoding !== "gzip-concatenated-int16-page-layouts" || + !Number.isSafeInteger(block.byteLength) || block.byteLength < 1 || + block.decodedByteLength !== block.pageCount * 14_400 || + !/^[a-f0-9]{64}$/.test(block.sha256 ?? "") || + !/^[a-f0-9]{64}$/.test(block.decodedSha256 ?? "") || + typeof block.assetUrl !== "string") { + throw new Error(`Prepared cssFlower shared layout block ${blockIndex} is invalid`); + } + layoutBlockDecodedBytes += block.decodedByteLength; + } + if (layoutBlockDecodedBytes !== projected.rawLayoutBytes) { + throw new Error("Prepared cssFlower shared layout block coverage is incomplete"); + } + let nextStateIndex = 0; + for (let pageIndex = 0; pageIndex < projected.pages.length; pageIndex += 1) { + const page = projected.pages[pageIndex]; + const atlas = page?.atlas; + const layout = page?.layout; + const horizontal = atlas?.packing === "horizontal-union"; + const vertical = atlas?.packing === "vertical-union"; + const expectedOffsets = Array.from( + { length: page?.usedFrameCount ?? 0 }, + (_, frameIndex) => frameIndex === 0 ? 0 : horizontal + ? -frameIndex * atlas.frameWidth + : -frameIndex * atlas.frameHeight, + ); + if (page?.index !== pageIndex || page.startStateIndex !== nextStateIndex || + page.frameCount !== 4 || !Number.isSafeInteger(page.usedFrameCount) || + page.usedFrameCount < 1 || page.usedFrameCount > page.frameCount || + !Number.isSafeInteger(page.activeUnionLeafCount) || page.activeUnionLeafCount < 1 || + page.activeUnionLeafCount > 1_200 || atlas?.encoding !== CSSFLOWER_PROJECTED_ATLAS_ENCODING || + atlas.mimeType !== CSSFLOWER_PROJECTED_ATLAS_MIME_TYPE || + atlas.quality !== CSSFLOWER_PROJECTED_ATLAS_QUALITY || + !Number.isSafeInteger(atlas.width) || atlas.width < 1 || + !Number.isSafeInteger(atlas.height) || atlas.height < 1 || + !Number.isSafeInteger(atlas.frameWidth) || atlas.frameWidth < 1 || + !Number.isSafeInteger(atlas.frameHeight) || atlas.frameHeight < 1 || + (!horizontal && !vertical) || + atlas.width !== atlas.frameWidth * (horizontal ? page.usedFrameCount : 1) || + atlas.height !== atlas.frameHeight * (vertical ? page.usedFrameCount : 1) || + !arraysEqual(atlas.frameBackgroundOffsets, expectedOffsets) || + !Number.isSafeInteger(atlas.byteLength) || atlas.byteLength < 1 || + atlas.decodedBytes !== atlas.width * atlas.height * 4 || + atlas.decodedBytes > projected.maximumDecodedPageBytes || + !/^[a-f0-9]{64}$/.test(atlas.sha256 ?? "") || typeof atlas.assetUrl !== "string" || + layout?.schema !== "cssflower-prepared-shared-frame-leaf-layout@1" || + layout.encoding !== "int16-little-endian-source-order-width-height-dx-dy-frame-background-x-frame-background-y" || + layout.componentCount !== 6 || layout.bytesPerLeaf !== 12 || layout.leafCount !== 1_200 || + layout.byteLength !== 14_400 || !/^[a-f0-9]{64}$/.test(layout.sha256 ?? "") || + layout.blockIndex !== Math.floor(pageIndex / projected.layoutBlockPageCount) || + layout.blockByteOffset !== (pageIndex % projected.layoutBlockPageCount) * layout.byteLength) { + throw new Error(`Prepared cssFlower projected page ${pageIndex} is invalid`); + } + nextStateIndex += page.usedFrameCount; + } + if (nextStateIndex !== projected.stateCount || playback.cycle.states.some((state, stateIndex) => { + const page = projected.pages[state.projectedPageIndex]; + return !page || !Number.isSafeInteger(state.projectedFrameIndex) || state.projectedFrameIndex < 0 || + state.projectedFrameIndex >= page.usedFrameCount || + page.startStateIndex + state.projectedFrameIndex !== stateIndex; + })) { + throw new Error("Prepared cssFlower projected state mapping is incomplete"); + } +} + +function arraysEqual(actual, expected) { + return Array.isArray(actual) && actual.length === expected.length && + actual.every((value, index) => value === expected[index]); +} + +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 fetchBytes(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); + } + 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 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}).`); +} + +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/polycssScene.mjs b/src/adapters/flowerbox/src/cssflower/polycssScene.mjs new file mode 100644 index 0000000..98a3e83 --- /dev/null +++ b/src/adapters/flowerbox/src/cssflower/polycssScene.mjs @@ -0,0 +1,144 @@ +import { collectPolyRenderStats } from "@layoutit/polycss"; +import { applyPreparedProjectedLeafLayout } from "./projectedPageStyles.mjs"; + +export function mountPreparedPolycssSnapshot({ host, sceneData, snapshotHtml, projectedPages }) { + if (!(host instanceof HTMLElement)) throw new Error("Missing #scene 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."); + } + const styleElement = doc.querySelector("style"); + const cameraElement = doc.querySelector(".polycss-camera"); + if (!styleElement || !cameraElement) { + throw new Error("Prepared PolyCSS snapshot is missing style or camera DOM."); + } + removePreparedSnapshotStyles(); + const importedStyle = document.importNode(styleElement, true); + importedStyle.setAttribute("data-cssflower-snapshot-style", "1"); + document.head.appendChild(importedStyle); + const importedCamera = document.importNode(cameraElement, true); + const importedRoot = importedCamera.querySelector("[data-cssflower-rotation-root]"); + const importedScene = importedCamera.querySelector(".polycss-scene"); + const importedMesh = importedRoot?.querySelector(".polycss-mesh"); + if (!(importedRoot instanceof HTMLElement) || !(importedScene instanceof HTMLElement) || + !(importedMesh instanceof HTMLElement) || !projectedPages?.urlFor || !projectedPages?.layoutFor) { + throw new Error("Prepared cssFlower projected page loader is missing."); + } + const importedLeaves = [...importedRoot.querySelectorAll("[data-cssflower-leaf-index]")] + .sort((left, right) => leafIndex(left) - leafIndex(right)); + const initialProjectedPage = sceneData.playback.projectedPixels.pages[0]; + importedCamera.style.setProperty("perspective", "none", "important"); + importedScene.style.setProperty("transform", "none", "important"); + importedScene.style.setProperty("transform-style", "preserve-3d", "important"); + importedRoot.style.setProperty("--cssflower-projected-atlas", `url("${projectedPages.urlFor(0)}")`); + importedRoot.style.setProperty("--cssflower-projected-frame-offset", "0px"); + importedMesh.style.setProperty("transform-origin", "0 0", "important"); + importedMesh.style.setProperty("transform-style", "preserve-3d", "important"); + importedMesh.style.transform = sceneData.playback.projectedPixels.inverseRootTransforms[0]; + applyPreparedProjectedLeafLayout({ + leaves: importedLeaves, + layoutValues: projectedPages.layoutFor(0), + atlas: initialProjectedPage.atlas, + }); + host.replaceChildren(importedCamera); + + const camera = host.querySelector(".polycss-camera"); + const scene = host.querySelector(".polycss-scene"); + const rotationRoot = host.querySelector("[data-cssflower-rotation-root]"); + const mesh = rotationRoot?.querySelector(".polycss-mesh"); + if (!(camera instanceof HTMLElement) || !(scene instanceof HTMLElement) || + !(rotationRoot instanceof HTMLElement) || !(mesh instanceof HTMLElement)) { + throw new Error("Prepared cssFlower camera, scene, or rotation root is missing."); + } + const leaves = [...rotationRoot.querySelectorAll("[data-cssflower-leaf-index]")] + .sort((left, right) => leafIndex(left) - leafIndex(right)); + const triangleIds = new Set(); + if (leaves.length !== 1200 || host.querySelectorAll("[data-cssflower-rotation-root]").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 triangleId = leaf.getAttribute("data-cssflower-triangle"); + const seamEdgeMaskText = leaf.getAttribute("data-cssflower-seam-edge-mask"); + const seamEdgeMask = seamEdgeMaskText === null ? null : Number(seamEdgeMaskText); + const expectedFace = sceneData.lighting?.faces?.[index]; + const expectedSeamEdgeMask = expectedFace?.seamEdgeMask; + const seamBleed = Number(leaf.getAttribute("data-cssflower-seam-bleed")); + const seamEdgeMaskInvalid = Number.isSafeInteger(expectedSeamEdgeMask) + ? seamEdgeMask !== expectedSeamEdgeMask + : seamEdgeMask !== null && (!Number.isSafeInteger(seamEdgeMask) || seamEdgeMask < 1 || seamEdgeMask > 7); + if (leafIndex(leaf) !== index || + leaf.getAttribute("data-cssflower-retained-leaf") !== "true" || + seamBleed !== expectedFace?.seamBleed || + seamEdgeMaskInvalid || + !triangleId || triangleIds.has(triangleId)) { + throw new Error(`Prepared cssFlower retained leaf ${index} is not source-addressable.`); + } + triangleIds.add(triangleId); + } + const stableNodes = Object.freeze([rotationRoot, ...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 }); + document.body.dataset.polycssArtifact = "prepared-snapshot"; + + function assertStableDomIdentity() { + const currentRoot = host.querySelector("[data-cssflower-rotation-root]"); + const currentLeaves = [...host.querySelectorAll("[data-cssflower-leaf-index]")] + .sort((left, right) => leafIndex(left) - leafIndex(right)); + if (currentRoot !== stableNodes[0] || currentLeaves.length !== leaves.length || + currentLeaves.some((leaf, index) => leaf !== stableNodes[index + 1])) { + 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(host, { + polygonCount: sceneData.metrics.preparedLeafCount, + scopeSelector: "[data-cssflower-rotation-root]", + }); + return Object.freeze({ + mode: "prepared-snapshot", + retainedRotationRootCount: 1, + retainedTriangleLeafCount: leaves.length, + retainedTriangleIdCount: triangleIds.size, + runtimeDomCreationCount, + runtimeDomRemovalCount, + runtimeDomMutationCount: runtimeDomCreationCount + runtimeDomRemovalCount, + runtimeDomGrowth: false, + polycss, + }); + }, + destroy() { + observer.disconnect(); + host.replaceChildren(); + removePreparedSnapshotStyles(); + }, + }); +} + +function leafIndex(element) { + const value = Number(element.getAttribute("data-cssflower-leaf-index")); + if (!Number.isInteger(value)) throw new Error("Prepared cssFlower leaf index must be an integer."); + return value; +} + +function removePreparedSnapshotStyles() { + for (const style of document.querySelectorAll("style[data-cssflower-snapshot-style]")) style.remove(); +} diff --git a/src/adapters/flowerbox/src/cssflower/preparedPlayback.mjs b/src/adapters/flowerbox/src/cssflower/preparedPlayback.mjs new file mode 100644 index 0000000..076e48a --- /dev/null +++ b/src/adapters/flowerbox/src/cssflower/preparedPlayback.mjs @@ -0,0 +1,255 @@ +import { createPolyMorphPreparedDomTarget } from "@layoutit/polycss-morph"; +import { applyPreparedProjectedLeafLayout } from "./projectedPageStyles.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 { playback, mesh, projectedPages, rotationRoot } = options; + const leaves = [...options.leaves]; + validatePlayback(playback, projectedPages, rotationRoot, mesh, leaves); + const projected = playback.projectedPixels; + 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 request = null; + let nextFrameAt = null; + let globalTick = 0; + let timelineStateIndex = -1; + let geometryStateIndex = -1; + let rootStateIndex = -1; + let projectedPageIndex = 0; + let projectedFrameIndex = -1; + let preparedStatesApplied = 0; + let modelTransformWrites = 0; + let shapeTransformWrites = 0; + let projectedFrameWrites = 0; + let projectedAtlasWrites = 0; + let preparedPageLayoutAdoptions = 0; + let preparedPageBoundaryLeafStyleWrites = 0; + let runtimeSchedulerCallbacks = 0; + + 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 })), + }); + + async function applyTick(tick) { + 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 nextRootStateIndex = state.rootStateIndex; + const nextProjectedPageIndex = state.projectedPageIndex; + const nextProjectedFrameIndex = state.projectedFrameIndex; + let nextResidentPageIndex = null; + if (nextProjectedPageIndex !== projectedPageIndex) { + nextResidentPageIndex = projectedPageAfter(projected, playback.cycle, nextProjectedPageIndex); + const record = await projectedPages.activate(nextProjectedPageIndex, nextResidentPageIndex); + const atlasImage = `url("${record.url}")`; + if (rotationRoot.style.getPropertyValue("--cssflower-projected-atlas") !== atlasImage) { + rotationRoot.style.setProperty("--cssflower-projected-atlas", atlasImage); + projectedAtlasWrites += 1; + } + applyPreparedProjectedLeafLayout({ + leaves, + layoutValues: record.layoutValues, + atlas: projected.pages[nextProjectedPageIndex].atlas, + }); + preparedPageLayoutAdoptions += 1; + preparedPageBoundaryLeafStyleWrites += leaves.length; + } + const frameValue = `${projected.pages[nextProjectedPageIndex].atlas.frameBackgroundOffsets[nextProjectedFrameIndex]}px`; + if (rotationRoot.style.getPropertyValue("--cssflower-projected-frame-offset") !== frameValue) { + rotationRoot.style.setProperty("--cssflower-projected-frame-offset", frameValue); + projectedFrameWrites += 1; + } + if (morphTarget.model.writeTransform(playback.cycle.rootTransforms[nextRootStateIndex])) { + modelTransformWrites += 1; + } + if (morphTarget.shapes[0].writeTransform(projected.inverseRootTransforms[nextRootStateIndex])) { + shapeTransformWrites += 1; + } + globalTick = tick; + timelineStateIndex = nextTimelineStateIndex; + geometryStateIndex = state.geometryStateIndex; + rootStateIndex = nextRootStateIndex; + projectedPageIndex = nextProjectedPageIndex; + projectedFrameIndex = nextProjectedFrameIndex; + preparedStatesApplied += 1; + rotationRoot.dataset.cssflowerGlobalTick = String(globalTick); + rotationRoot.dataset.cssflowerTimelineStateIndex = String(timelineStateIndex); + rotationRoot.dataset.cssflowerGeometryStateIndex = String(geometryStateIndex); + rotationRoot.dataset.cssflowerRootStateIndex = String(rootStateIndex); + rotationRoot.dataset.cssflowerProjectedPage = String(projectedPageIndex); + rotationRoot.dataset.cssflowerProjectedFrame = String(projectedFrameIndex); + if (nextResidentPageIndex !== null) { + await waitForPresentedPaint(requestFrame); + projectedPages.commitPresented(nextProjectedPageIndex, nextResidentPageIndex); + } + 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); + } + + await applyTick(0); + return Object.freeze({ + get tick() { return globalTick; }, + get paused() { return paused; }, + pause() { + paused = true; + nextFrameAt = null; + if (request !== null) cancelFrame(request); + request = null; + return globalTick; + }, + resume() { + if (!paused) return globalTick; + paused = false; + nextFrameAt = null; + request = requestFrame(loop); + return globalTick; + }, + async step(count = 1) { + this.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) { + this.pause(); + const tick = Math.trunc(Number(value)); + return applyTick(tick); + }, + assertStableDomIdentity() { + morphTarget.assertStableDomIdentity(); + return true; + }, + 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, + rootStateIndex, + projectedPageIndex, + projectedFrameIndex, + 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, + preparedProjectedPageCount: projected.pageCount, + preparedStatesApplied, + runtimeModelTransformWrites: modelTransformWrites, + runtimeShapeTransformWrites: shapeTransformWrites, + runtimeLeafTransformWrites: 0, + runtimePerFrameLeafStyleWrites: 0, + runtimeProjectedFrameWrites: projectedFrameWrites, + runtimeProjectedAtlasWrites: projectedAtlasWrites, + runtimePreparedPageLayoutAdoptions: preparedPageLayoutAdoptions, + runtimePreparedPageBoundaryLeafStyleWrites: preparedPageBoundaryLeafStyleWrites, + projectedPageLoader: projectedPages.stats(), + 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() { + this.pause(); + morphTarget.destroy(); + }, + }); +} + +function projectedPageAfter(projected, cycle, pageIndex) { + if (pageIndex + 1 < projected.pageCount) return pageIndex + 1; + return cycle.states[cycle.cycleStartState].projectedPageIndex; +} + +function validatePlayback(playback, projectedPages, rotationRoot, mesh, leaves) { + const projected = playback?.projectedPixels; + if (playback?.schema !== "cssflower-prepared-playback@1" || + playback.target !== "createPolyMorphPreparedDomTarget" || + playback.sourceTicksPerSecond !== 30 || + playback.cycle?.stateCount !== 9_331 || + playback.cycle?.cycleStartState !== 331 || + playback.cycle?.cycleLength !== 9_000 || + playback.cycle?.bloomTraceStateCount !== 581 || + playback.cycle?.bloomCycleLength !== 250 || + playback.cycle?.geometryStateCount !== 414 || + playback.cycle?.rootStateCount !== 360 || + playback.cycle?.states?.length !== 9_331 || + playback.cycle?.rootTransforms?.length !== 360 || + projected?.schema !== "cssflower-prepared-projected-pixel-playback@1" || + projected.stateCount !== playback.cycle.stateCount || + projected.retainedLeafCount !== 1_200 || + projected.pages?.length !== projected.pageCount || + projected.inverseRootTransforms?.length !== playback.cycle.rootStateCount || + playback.cycle.states.some((state) => + !Number.isSafeInteger(state.rootStateIndex) || state.rootStateIndex < 0 || state.rootStateIndex >= 360 || + !Number.isSafeInteger(state.projectedPageIndex) || state.projectedPageIndex < 0 || + state.projectedPageIndex >= projected.pageCount || + !Number.isSafeInteger(state.projectedFrameIndex) || state.projectedFrameIndex < 0 || + state.projectedFrameIndex >= projected.pages[state.projectedPageIndex].usedFrameCount) || + !projectedPages?.activate || !projectedPages?.commitPresented || + !projectedPages?.urlFor || !projectedPages?.layoutFor || !projectedPages?.stats || + !(rotationRoot instanceof HTMLElement) || !(mesh instanceof HTMLElement) || + leaves.length !== 1_200) { + throw new Error("Complete prepared cssFlower projected Morph playback is required"); + } +} + +function waitForPresentedPaint(requestFrame) { + return new Promise((resolvePaint) => requestFrame(() => requestFrame(resolvePaint))); +} diff --git a/src/adapters/flowerbox/src/cssflower/projectedPageStyles.mjs b/src/adapters/flowerbox/src/cssflower/projectedPageStyles.mjs new file mode 100644 index 0000000..e991b17 --- /dev/null +++ b/src/adapters/flowerbox/src/cssflower/projectedPageStyles.mjs @@ -0,0 +1,70 @@ +const COMPONENTS_PER_LEAF = 6; + +export function applyPreparedProjectedLeafLayout({ leaves, layoutValues, atlas }) { + const frameOffsetAxis = preparedFrameOffsetAxis(atlas); + if (!Array.isArray(leaves) || leaves.length !== 1_200 || + !(layoutValues instanceof Int16Array) || layoutValues.length !== leaves.length * COMPONENTS_PER_LEAF || + !Number.isSafeInteger(atlas?.width) || atlas.width < 1 || + !Number.isSafeInteger(atlas?.height) || atlas.height < 1) { + throw new TypeError("Complete prepared projected leaf layout is required"); + } + for (let leafIndex = 0; leafIndex < leaves.length; leafIndex += 1) { + const offset = leafIndex * COMPONENTS_PER_LEAF; + const width = layoutValues[offset]; + const height = layoutValues[offset + 1]; + const dx = layoutValues[offset + 2]; + const dy = layoutValues[offset + 3]; + const backgroundX = layoutValues[offset + 4]; + const backgroundY = layoutValues[offset + 5]; + leaves[leafIndex].style.cssText = width === 0 + ? hiddenProjectedLeafCss() + : visibleProjectedLeafCss({ width, height, dx, dy, backgroundX, backgroundY, atlas, frameOffsetAxis }); + } + return leaves.length; +} + +function visibleProjectedLeafCss({ width, height, dx, dy, backgroundX, backgroundY, atlas, frameOffsetAxis }) { + const backgroundPosition = frameOffsetAxis === "x" + ? `calc(${backgroundX}px + var(--cssflower-projected-frame-offset)) ${backgroundY}px` + : `${backgroundX}px calc(${backgroundY}px + var(--cssflower-projected-frame-offset))`; + return [ + "position:absolute", + "display:block", + "left:0", + "top:0", + `width:${width}px`, + `height:${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:translate3d(${dx}px,${dy}px,0px)`, + "background-image:var(--cssflower-projected-atlas)", + "background-color:transparent", + "background-repeat:no-repeat", + `background-position:${backgroundPosition}`, + `background-size:${atlas.width}px ${atlas.height}px`, + "image-rendering:auto", + "color:transparent", + "line-height:0", + "text-decoration:none", + ].join(";"); +} + +function preparedFrameOffsetAxis(atlas) { + if (atlas?.packing === "horizontal-union") return "x"; + if (atlas?.packing === "vertical-union") return "y"; + throw new TypeError("Prepared projected atlas packing is invalid"); +} + +function hiddenProjectedLeafCss() { + return "position:absolute;display:none;left:0;top:0;width:0;height:0;transform:none;background:none;border:0"; +} diff --git a/src/adapters/flowerbox/src/cssflower/renderContract.mjs b/src/adapters/flowerbox/src/cssflower/renderContract.mjs new file mode 100644 index 0000000..560d5bd --- /dev/null +++ b/src/adapters/flowerbox/src/cssflower/renderContract.mjs @@ -0,0 +1,31 @@ +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@3"; +export const CSSFLOWER_LIGHTING_LAYOUT = "guttered-leaf-raster-shelves-by-paged-timeline-state-slices"; +export const CSSFLOWER_LIGHTING_PAGE_ROWS = 32; +export const CSSFLOWER_LIGHTING_PAGE_COUNT = 292; +export const CSSFLOWER_LIGHTING_ATLAS_WIDTH = 768; +export const CSSFLOWER_LIGHTING_ATLAS_HEIGHT = 7_232; +export const CSSFLOWER_LIGHTING_STATE_SLICE_HEIGHT = 226; +export const CSSFLOWER_LIGHTING_GUTTER = 1; +export const CSSFLOWER_PROJECTED_ATLAS_ENCODING = "avif-lossy-q40-speed6-yuv444"; +export const CSSFLOWER_PROJECTED_ATLAS_MIME_TYPE = "image/avif"; +export const CSSFLOWER_PROJECTED_ATLAS_EXTENSION = "avif"; +export const CSSFLOWER_PROJECTED_ATLAS_QUALITY = 40; +export const CSSFLOWER_PROJECTED_VISUAL_BANK_MAX_BYTES = 40_000_000; +export const CSSFLOWER_PROJECTED_VISUAL_ACCEPTANCE = Object.freeze({ + schema: "cssflower-q40-exact-reference-visual-envelope@1", + selection: "preselected-between-clean-q40-and-first-visible-blocking-q35", + reference: "lossless-webp-retained-dom-browser-sequence", + meanAbsDelta: 0.55, + rmsDelta: 3.25, + changedPixelRatio: 0.4, + maxAbsDelta: 250, + alphaMaxAbsDelta: 0, + interiorMeanAbsDelta: 2.5, + interiorRmsDelta: 3.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..09d0a25 --- /dev/null +++ b/src/adapters/flowerbox/src/cssflower/stagePresentation.mjs @@ -0,0 +1,50 @@ +export const CSSFLOWER_PREPARED_STAGE_EDGE = 720; +export const CSSFLOWER_RESPONSIVE_PRESENTATION_INSET = 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_PRESENTATION_INSET; +} + +export function installCssflowerStagePresentation(host) { + if (!(host instanceof HTMLElement)) throw new TypeError("cssFlower stage host is missing"); + const mode = "product"; + let scale = 1; + let writes = 0; + + document.body.dataset.stagePresentation = mode; + + function apply() { + const nextScale = cssflowerStageScale(host.clientWidth, host.clientHeight); + const serialized = String(Number(nextScale.toFixed(8))); + if (host.style.getPropertyValue("--cssflower-presentation-scale") !== serialized) { + host.style.setProperty("--cssflower-presentation-scale", serialized); + writes += 1; + } + scale = nextScale; + } + + apply(); + window.addEventListener("resize", apply, { passive: true }); + + return Object.freeze({ + get mode() { return mode; }, + stats() { + return Object.freeze({ + stagePresentation: mode, + preparedStageEdgePixels: CSSFLOWER_PREPARED_STAGE_EDGE, + responsivePresentationInset: CSSFLOWER_RESPONSIVE_PRESENTATION_INSET, + presentationScale: scale, + runtimePresentationScaleWrites: writes, + runtimeModelGeometryCalculations: 0, + }); + }, + destroy() { + window.removeEventListener("resize", apply); + }, + }); +} diff --git a/src/adapters/flowerbox/src/cssflower/styles.css b/src/adapters/flowerbox/src/cssflower/styles.css new file mode 100644 index 0000000..2b3358f --- /dev/null +++ b/src/adapters/flowerbox/src/cssflower/styles.css @@ -0,0 +1,88 @@ +:root { + color-scheme: dark; + font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; + background: #000; + color: #d8d8d8; +} + +html, +body, +#app, +#scene { + width: 100%; + height: 100%; + margin: 0; + overflow: hidden; + background: #000; +} + +#app, +#scene { + position: fixed; + inset: 0; +} + +#scene { + contain: layout paint size style; +} + +#scene > .polycss-camera { + position: absolute !important; + left: 50% !important; + top: 50% !important; + width: 720px !important; + height: 720px !important; + translate: -50% -50%; + scale: var(--cssflower-presentation-scale, 1); + transform-origin: 50% 50%; +} + +#status, +#credit { + position: fixed; + z-index: 2; + margin: 0; + padding: 8px 10px; + background: rgb(0 0 0 / 72%); + color: #b9c1bc; + font-size: 11px; + line-height: 1.35; +} + +#status { + left: 50%; + bottom: 12px; + max-width: min(640px, calc(100vw - 24px)); + translate: -50% 0; + transition: opacity 180ms ease; +} + +#credit { + right: 8px; + bottom: 8px; + opacity: 0.42; +} + +#credit a { + color: inherit; +} + +body[data-port-status="ready"] #status { + opacity: 0; + pointer-events: none; +} + +body[data-port-status="error"] #status { + color: #ffd5cf; + opacity: 1; +} + +.cssflower-mesh { + pointer-events: none; +} + +@media (max-width: 600px) { + #credit { + font-size: 9px; + } +} diff --git a/src/adapters/flowerbox/src/main.mjs b/src/adapters/flowerbox/src/main.mjs new file mode 100644 index 0000000..ebef4c6 --- /dev/null +++ b/src/adapters/flowerbox/src/main.mjs @@ -0,0 +1,4 @@ +import "./cssflower/styles.css"; +import { mountCssflowerClient } from "./cssflower/client.mjs"; + +mountCssflowerClient(); 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..e2e1d04 --- /dev/null +++ b/src/adapters/flowerbox/src/prepare/cssflower/bloomCycle.mjs @@ -0,0 +1,128 @@ +import { + CSSFLOWER_SOURCE_PROFILE, + FLOAT, + floatBits, + floatHex, + preparedRootTransform, +} from "./sourceProfile.mjs"; + +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), + }); +} + +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..ab509d7 --- /dev/null +++ b/src/adapters/flowerbox/src/prepare/cssflower/compilePreparedCycle.mjs @@ -0,0 +1,598 @@ +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_LAYOUT, + CSSFLOWER_LIGHTING_SCHEMA, + CSSFLOWER_SEAM_BLEED, + CSSFLOWER_SEAM_BLEED_POLICY, +} from "../../cssflower/renderContract.mjs"; +import { buildPreparedFullRotationCycle } 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 cycle = attachPreparedLightingRows(buildPreparedFullRotationCycle()); + const quadMergeAudit = auditPreparedQuadMergeEligibility(topology, cycle); + const rasterFaces = selectPreparedRasterFaces(topology, cycle, siblingSeamPlan, seamEdgesByMask); + const rasterLayout = buildPreparedLeafRasterLayout(rasterFaces); + const matrixValues = new Float32Array(cycle.geometryStateCount * topology.triangleCount * MATRIX_COMPONENTS); + const atlasWidth = rasterLayout.atlasWidth; + const atlasHeight = rasterLayout.atlasHeight; + const stateEvidence = []; + const geometryByState = new Array(cycle.geometryStateCount); + const canonicalPointIndices = new Uint16Array(cycle.geometryStateCount * topology.triangleCount * 3); + let initialPolygons = null; + + for (const geometryState of cycle.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 === cycle.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 transformBytes = Buffer.from(matrixValues.buffer); + const lightingPreparation = await prepareLightingPages({ + topology, + cycle, + geometryByState, + canonicalPointIndices, + atlasWidth, + atlasHeight, + rasterLayout, + 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: cycle.stateCount, + geometryStateCount: cycle.geometryStateCount, + cycleStartState: cycle.cycleStartState, + cycleLength: cycle.cycleLength, + bloomTraceStateCount: cycle.bloomTraceStateCount, + bloomCycleLength: cycle.bloomCycleLength, + rootStateCount: cycle.rootStateCount, + }), + 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, + decodedResidentPageBudget: 2, + decodedPeakPageBudget: 3, + }), + quadMergeAudit, + geometryStates: Object.freeze(stateEvidence), + ticks: Object.freeze(cycle.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, + lightingPageIndex: state.lightingPageIndex, + lightingPageRowIndex: state.lightingPageRowIndex, + }))), + }); + + return Object.freeze({ + topology, + cycle, + 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: "leaf-resolution", + sampling: rasterLayout.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)), + 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, + pageRowCount: CSSFLOWER_LIGHTING_PAGE_ROWS, + pageCount: lightingPages.length, + pages: lightingPages, + totalEncodedBytes: lightingPages.reduce((sum, page) => sum + page.byteLength, 0), + decodedBytesPerFullPage: atlasWidth * atlasHeight * 4, + decodedResidentPageBudget: 2, + decodedPeakPageBudget: 3, + 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: `${atlasWidth}px ${atlasHeight}px`, + backgroundPositionX: `${-placement.contentX}px`, + backgroundPositionY: `calc(var(--cssflower-lighting-y) - ${placement.contentY}px)`, + }); + })), + rowSelection: "prepared-timeline-state-page-and-row-index", + temporalInterpolation: false, + sourceModelviewLighting: "identity-set-positional-light-then-Rx-Ry-Rz-with-normalize-and-infinite-viewer", + rotationAware: true, + runtimeRootFrameVariables: 2, + runtimePreparedPagePreload: true, + runtimeDecodedResidentPageBudget: 2, + runtimeDecodedPeakPageBudget: 3, + 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, + geometryByState, + canonicalPointIndices, + atlasWidth, + atlasHeight, + rasterLayout, + 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 geometry = geometryByState[state.geometryStateIndex]; + if (!geometry) throw new Error(`cssFlower lighting state ${state.tick} has no prepared geometry`); + const vertexColors = computePreparedVertexLightingUnquantized( + topology, + geometry.positions, + geometry.normals, + state, + ); + 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 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 formatCssNumber(value) { + const rounded = Math.round(value * 1_000_000) / 1_000_000; + return String(Object.is(rounded, -0) ? 0 : rounded); +} + +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..3e18f1f --- /dev/null +++ b/src/adapters/flowerbox/src/prepare/cssflower/dataSource.mjs @@ -0,0 +1,33 @@ +import { existsSync, statSync } from "node:fs"; +import { resolve } from "node:path"; + +export const NATIVE_ROOT_ENV = "CSSFLOWER_NATIVE_ROOT"; + +export async function resolveCssflowerDataSource(options = {}) { + const rawRoot = options.nativeRoot ?? process.env[NATIVE_ROOT_ENV] ?? ""; + 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: "local-input-not-packaged", + nativeQualification: null, + }; + } + + return { + kind: "documented-source-behavior", + env: null, + root: null, + publicLabel: "src/adapters/flowerbox/README.md", + legalLabel: "independently-authored-results-only", + nativeAuthorityStatus: "not-packaged", + nativeQualification: 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..ce2ef04 --- /dev/null +++ b/src/adapters/flowerbox/src/prepare/cssflower/leafRasterLighting.mjs @@ -0,0 +1,246 @@ +import { + CSSFLOWER_LIGHTING_ATLAS_WIDTH, + CSSFLOWER_LIGHTING_GUTTER, + CSSFLOWER_LIGHTING_PAGE_ROWS, +} from "../../cssflower/renderContract.mjs"; + +export const CSSFLOWER_LEAF_RASTER_GUTTER = CSSFLOWER_LIGHTING_GUTTER; +export const CSSFLOWER_LEAF_RASTER_ATLAS_WIDTH = CSSFLOWER_LIGHTING_ATLAS_WIDTH; +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(); + +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 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; +} + +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))); +} 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..519acad --- /dev/null +++ b/src/adapters/flowerbox/src/prepare/cssflower/paths.mjs @@ -0,0 +1,70 @@ +import { fileURLToPath } from "node:url"; +import { join, resolve } from "node:path"; +import { 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 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 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..2d61121 --- /dev/null +++ b/src/adapters/flowerbox/src/prepare/cssflower/prepare.mjs @@ -0,0 +1,43 @@ +import { + buildCssflowerFirstSliceScene, +} from "./sceneBuilder.mjs"; +import { + resolveCssflowerDataSource, +} from "./dataSource.mjs"; +import { + writeCssflowerPreparedOutput, +} from "./writeManifest.mjs"; +import { + writeCssflowerPreparedAssets, + createCssflowerPreparedLightingPageStore, +} from "./writePreparedAssets.mjs"; +import { prepareCssflowerSharedFrameWindowPages } from "./sharedFramePageStore.mjs"; + +export async function prepareCssflower(options = {}) { + const dataSource = await resolveCssflowerDataSource({ + nativeRoot: options.nativeRoot, + }); + const sceneId = options.scene ?? "default-cube"; + const lightingPageStore = await createCssflowerPreparedLightingPageStore(); + const projectedPixels = await prepareCssflowerSharedFrameWindowPages({ + concurrency: options.concurrency, + onProgress: options.onProjectedProgress, + }); + const { scene, compiled } = await buildCssflowerFirstSliceScene({ + dataSource, + projectedPixels, + sceneId, + readLightingPage: lightingPageStore.read, + writeLightingPage: lightingPageStore.write, + }); + const assets = await writeCssflowerPreparedAssets(compiled, projectedPixels); + 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..f89496f --- /dev/null +++ b/src/adapters/flowerbox/src/prepare/cssflower/projectedPixels.mjs @@ -0,0 +1,812 @@ +import { createHash } from "node:crypto"; +import { PNG } from "pngjs"; +import { buildPreparedFullRotationCycle } 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(); + +export function buildCssflowerPreparedInverseRootTransforms() { + return Object.freeze(Array.from({ length: cycle.rootStateCount }, (_, rootStateIndex) => ( + preparedInverseRootTransform({ rootStateIndex }) + ))); +} + +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..9b5db70 --- /dev/null +++ b/src/adapters/flowerbox/src/prepare/cssflower/sceneBuilder.mjs @@ -0,0 +1,272 @@ +import { compilePreparedCssflowerCycle } from "./compilePreparedCycle.mjs"; +import { sourceProvenanceFor } from "./provenance.mjs"; +import { cssflowerSlicePlan } from "./slicePlan.mjs"; +import { CSSFLOWER_CAMERA, CSSFLOWER_SIDE_MATERIALS, CSSFLOWER_SOURCE_PROFILE } from "./sourceProfile.mjs"; +import { + CSSFLOWER_BOUNDARY_SEAM_BLEED, + CSSFLOWER_PROJECTED_ATLAS_ENCODING, + CSSFLOWER_PROJECTED_ATLAS_MIME_TYPE, + CSSFLOWER_PROJECTED_ATLAS_QUALITY, + CSSFLOWER_SEAM_BLEED, +} from "../../cssflower/renderContract.mjs"; +import { + generatedProjectedAtlasUrl, + generatedSharedLayoutBlockUrl, +} from "./paths.mjs"; + +export async function buildCssflowerFirstSliceScene({ + dataSource, + projectedPixels, + readLightingPage, + sceneId = "default-cube", + writeLightingPage, +} = {}) { + if (sceneId !== "default-cube") { + throw new RangeError(`Unknown prepared cssFlower scene ${sceneId}`); + } + const compiled = await compilePreparedCssflowerCycle({ + nativeAuthorityStatus: dataSource?.nativeAuthorityStatus ?? "missing", + readLightingPage, + writeLightingPage, + }); + if (projectedPixels?.schema !== "cssflower-prepared-shared-frame-window-pages@1" || + projectedPixels.stateCount !== compiled.cycle.stateCount || + projectedPixels.retainedLeafCount !== compiled.topology.triangleCount) { + throw new Error("Complete source-bound projected-pixel preparation is required"); + } + 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-leaf-windows-over-shared-screen-aligned-prepared-frame-pages", + 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), + playback: Object.freeze({ + schema: "cssflower-prepared-playback@1", + target: "createPolyMorphPreparedDomTarget", + sourceTicksPerSecond: CSSFLOWER_SOURCE_PROFILE.presentationTicksPerSecond, + transformAsset: Object.freeze({ + distribution: "ignored-local-preparation-evidence", + url: null, + sha256: compiled.transformSha256, + encoding: "float32-little-endian-state-major-triangle-major-matrix3d", + componentCount: 16, + triangleCount: compiled.topology.triangleCount, + geometryStateCount: compiled.cycle.geometryStateCount, + byteLength: compiled.transformBytes.length, + }), + stateEvidenceUrl: "/cssflower/assets/flower-box-state-evidence.json", + projectedPixels: projectedPixelContract(projectedPixels), + 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, + geometryStateCount: compiled.cycle.geometryStateCount, + rootStateCount: compiled.cycle.rootStateCount, + rootTransforms: compiled.cycle.rootTransforms, + states: Object.freeze(compiled.cycle.states.map((state, stateIndex) => Object.freeze({ + ...state, + projectedPageIndex: projectedPixels.statePageIndices[stateIndex], + projectedFrameIndex: projectedPixels.stateFrameIndices[stateIndex], + }))), + }), + }), + 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, + preparedProjectedPixelPageCount: projectedPixels.pageCount, + preparedProjectedPixelAtlasAssetCount: projectedPixels.pageCount - projectedPixels.atlasAliasCount, + preparedProjectedPixelLayoutAssetCount: projectedPixels.layoutBlocks.length, + preparedProjectedPixelMaximumDecodedPageBytes: projectedPixels.maximumDecodedPageBytes, + preparedProjectedPixelMaximumAdjacentTwoPageBytes: projectedPixels.maximumAdjacentTwoPageBytes, + preparedLightingFieldCount: compiled.cycle.stateCount * compiled.topology.triangleCount, + 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" + ? "measured-divergence-see-ignored-local-pixelmatch-report" + : "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.", + "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 }); +} + +export function createCssflowerSceneContract(value = {}) { + return value; +} + +function publicLightingContract(lighting) { + return Object.freeze({ + ...lighting, + distribution: "ignored-local-preparation-evidence", + assetUrl: null, + pages: Object.freeze(lighting.pages.map((page) => Object.freeze({ + ...page, + assetUrl: null, + }))), + }); +} + +function projectedPixelContract(prepared) { + return Object.freeze({ + schema: "cssflower-prepared-projected-pixel-playback@1", + techniqueReference: "cssGraphics Mario prepared space-time texel seam extended to shared screen-aligned source-camera frame windows", + representation: "shared-frame-windows", + physicalLayout: prepared.layout, + rasterMode: "source-camera-projected-pixels", + sampling: "integer-pixel-center", + cull: "source-default-CCW-front", + depth: "source-depth16-less", + interpolation: "perspective-correct-smooth-vertex-lighting", + encoding: `${CSSFLOWER_PROJECTED_ATLAS_ENCODING} plus gzip-blocked int16 source-order leaf layouts`, + visualEncoding: Object.freeze({ + codec: "AVIF", + mimeType: CSSFLOWER_PROJECTED_ATLAS_MIME_TYPE, + quality: CSSFLOWER_PROJECTED_ATLAS_QUALITY, + chromaSubsampling: "4:4:4", + speed: 6, + policy: "user-accepted-bounded-lossy-prepared-pixels", + exactStateAndTopology: true, + exactPreparedPixels: false, + }), + stateCount: prepared.stateCount, + cycleStartState: prepared.cycleStartState, + cycleLength: prepared.cycleLength, + retainedLeafCount: prepared.retainedLeafCount, + pageCount: prepared.pageCount, + decodedResidentPageBudget: prepared.decodedResidentPageBudget, + decodedPeakPageBudget: prepared.decodedPeakPageBudget, + maximumDecodedPageBytes: prepared.maximumDecodedPageBytes, + maximumAdjacentTwoPageBytes: prepared.maximumAdjacentTwoPageBytes, + encodedAtlasBytes: prepared.encodedAtlasBytes, + rawLayoutBytes: prepared.rawLayoutBytes, + compressedLayoutBytes: prepared.compressedLayoutBytes, + layoutBlockPageCount: prepared.layoutBlockPageCount, + contentAddressedAtlasBytes: prepared.contentAddressedAtlasBytes, + atlasAliasCount: prepared.atlasAliasCount, + inverseRootTransforms: prepared.inverseRootTransforms, + encoder: prepared.encoder, + layoutBlocks: Object.freeze(prepared.layoutBlocks.map((block) => Object.freeze({ + ...block, + assetUrl: generatedSharedLayoutBlockUrl(block.sha256), + }))), + pages: Object.freeze(prepared.pages.map((page) => Object.freeze({ + index: page.index, + sourcePageIndex: page.sourcePageIndex, + frameCount: page.frameCount, + startStateIndex: page.startStateIndex, + usedFrameCount: page.usedFrameCount, + activeUnionLeafCount: page.activeUnionLeafCount, + atlas: Object.freeze({ + ...page.atlas, + assetUrl: generatedProjectedAtlasUrl(page.atlas.sha256), + }), + layout: Object.freeze({ + ...page.layout, + }), + }))), + authority: prepared.authority, + runtimeProjection: false, + runtimeRasterization: false, + runtimeGeometryConstruction: false, + runtimeNormalCalculation: false, + runtimeLightingCalculation: false, + runtimeDomGrowth: false, + }); +} diff --git a/src/adapters/flowerbox/src/prepare/cssflower/sharedFramePacking.mjs b/src/adapters/flowerbox/src/prepare/cssflower/sharedFramePacking.mjs new file mode 100644 index 0000000..3636a33 --- /dev/null +++ b/src/adapters/flowerbox/src/prepare/cssflower/sharedFramePacking.mjs @@ -0,0 +1,86 @@ +import { PNG } from "pngjs"; + +export const CSSFLOWER_SHARED_FRAME_PACKINGS = Object.freeze([ + "horizontal-union", + "vertical-union", +]); + +export function buildCssflowerSharedFramePackingCandidates(atlas) { + validatePreparedHorizontalAtlas(atlas); + const source = PNG.sync.read(atlas.bytes); + if (source.width !== atlas.width || source.height !== atlas.height) { + throw new Error("Prepared shared-frame PNG dimensions drifted"); + } + const horizontal = packingDescriptor({ + packing: "horizontal-union", + frameWidth: atlas.frameWidth, + frameHeight: atlas.frameHeight, + frameCount: atlas.frameBackgroundXs.length, + bytes: Buffer.from(atlas.bytes), + }); + if (horizontal.frameCount === 1) return Object.freeze([horizontal]); + + const verticalImage = new PNG({ + width: atlas.frameWidth, + height: atlas.frameHeight * horizontal.frameCount, + colorType: 6, + }); + for (let frameIndex = 0; frameIndex < horizontal.frameCount; frameIndex += 1) { + for (let y = 0; y < atlas.frameHeight; y += 1) { + const sourceOffset = (y * source.width + frameIndex * atlas.frameWidth) * 4; + const targetOffset = ((frameIndex * atlas.frameHeight + y) * verticalImage.width) * 4; + source.data.copy( + verticalImage.data, + targetOffset, + sourceOffset, + sourceOffset + atlas.frameWidth * 4, + ); + } + } + const vertical = packingDescriptor({ + packing: "vertical-union", + frameWidth: atlas.frameWidth, + frameHeight: atlas.frameHeight, + frameCount: horizontal.frameCount, + bytes: PNG.sync.write(verticalImage, { colorType: 2, inputColorType: 6 }), + }); + return Object.freeze([horizontal, vertical]); +} + +function packingDescriptor({ packing, frameWidth, frameHeight, frameCount, bytes }) { + const horizontal = packing === "horizontal-union"; + return Object.freeze({ + packing, + frameCount, + width: horizontal ? frameWidth * frameCount : frameWidth, + height: horizontal ? frameHeight : frameHeight * frameCount, + frameWidth, + frameHeight, + frameBackgroundXs: Object.freeze(Array.from( + { length: frameCount }, + (_, frameIndex) => horizontal && frameIndex > 0 ? -frameIndex * frameWidth : 0, + )), + frameBackgroundYs: Object.freeze(Array.from( + { length: frameCount }, + (_, frameIndex) => !horizontal && frameIndex > 0 ? -frameIndex * frameHeight : 0, + )), + frameBackgroundOffsets: Object.freeze(Array.from( + { length: frameCount }, + (_, frameIndex) => frameIndex === 0 ? 0 : horizontal ? -frameIndex * frameWidth : -frameIndex * frameHeight, + )), + bytes, + }); +} + +function validatePreparedHorizontalAtlas(atlas) { + if (!Buffer.isBuffer(atlas?.bytes) || atlas.bytes.length < 1 || + !Number.isSafeInteger(atlas.width) || atlas.width < 1 || + !Number.isSafeInteger(atlas.height) || atlas.height < 1 || + !Number.isSafeInteger(atlas.frameWidth) || atlas.frameWidth < 1 || + !Number.isSafeInteger(atlas.frameHeight) || atlas.frameHeight < 1 || + atlas.width !== atlas.frameWidth * atlas.frameBackgroundXs?.length || + atlas.height !== atlas.frameHeight || + atlas.frameBackgroundXs.some((value, frameIndex) => value !== -frameIndex * atlas.frameWidth)) { + throw new TypeError("Complete prepared horizontal shared-frame PNG is required"); + } +} diff --git a/src/adapters/flowerbox/src/prepare/cssflower/sharedFramePageStore.mjs b/src/adapters/flowerbox/src/prepare/cssflower/sharedFramePageStore.mjs new file mode 100644 index 0000000..9fbf463 --- /dev/null +++ b/src/adapters/flowerbox/src/prepare/cssflower/sharedFramePageStore.mjs @@ -0,0 +1,362 @@ +import { spawnSync } from "node:child_process"; +import { createHash } from "node:crypto"; +import { mkdir, readFile, rename, stat, writeFile } from "node:fs/promises"; +import { availableParallelism } from "node:os"; +import { dirname, join, resolve } from "node:path"; +import { Worker } from "node:worker_threads"; +import { constants as zlibConstants, gzipSync } from "node:zlib"; +import { CSSFLOWER_PROJECTED_ATLAS_QUALITY } from "../../cssflower/renderContract.mjs"; +import { + CSSFLOWER_SHARED_LAYOUT_BLOCK_PAGE_COUNT, + buildCssflowerSharedFramePagePlan, +} from "./sharedFramePages.mjs"; +import { repoRoot } from "./paths.mjs"; + +const CACHE_SCHEMA = "cssflower-prepared-shared-frame-page-cache@1"; +const DEFAULT_AVIFENC = "/opt/homebrew/bin/avifenc"; + +export async function prepareCssflowerSharedFrameWindowPages({ + avifenc = process.env.CSSFLOWER_AVIFENC || DEFAULT_AVIFENC, + concurrency = Math.min(4, availableParallelism()), + onProgress, +} = {}) { + if (!Number.isSafeInteger(concurrency) || concurrency < 1) { + throw new RangeError("Shared-frame preparation concurrency must be a positive integer"); + } + const encoder = await avifencIdentity(avifenc); + const binding = await cacheBinding(repoRoot, encoder); + const plan = buildCssflowerSharedFramePagePlan(); + const cacheRoot = cacheRootFor(repoRoot, binding); + const descriptors = new Array(plan.pages.length); + const misses = []; + let hitCount = 0; + let completedCount = 0; + + for (let pageIndex = 0; pageIndex < plan.pages.length; pageIndex += 1) { + const page = plan.pages[pageIndex]; + const cached = await readCachedPage(cacheRoot, binding, page); + if (cached) { + descriptors[pageIndex] = cached; + hitCount += 1; + completedCount += 1; + onProgress?.({ completedCount, totalCount: plan.pages.length, hitCount, missCount: misses.length, pageIndex, source: "cache" }); + } else { + misses.push({ page }); + } + } + + await runWorkerPool({ + tasks: misses, + concurrency: Math.min(concurrency, Math.max(1, misses.length)), + avifenc, + async accept(task, result) { + await writeCachedPage(cacheRoot, binding, task.page, result); + descriptors[task.page.index] = result.descriptor; + completedCount += 1; + onProgress?.({ completedCount, totalCount: plan.pages.length, hitCount, missCount: misses.length, pageIndex: task.page.index, source: "prepared" }); + }, + }); + + const packed = await packLayoutBlocks(cacheRoot, descriptors); + const pages = Object.freeze(descriptors.map((descriptor, pageIndex) => Object.freeze({ + ...descriptor, + index: pageIndex, + frameCount: plan.frameCount, + layout: Object.freeze({ + ...descriptor.layout, + blockIndex: Math.floor(pageIndex / CSSFLOWER_SHARED_LAYOUT_BLOCK_PAGE_COUNT), + blockByteOffset: (pageIndex % CSSFLOWER_SHARED_LAYOUT_BLOCK_PAGE_COUNT) * descriptor.layout.byteLength, + }), + }))); + const statePageIndices = new Uint16Array(plan.stateCount); + const stateFrameIndices = new Uint8Array(plan.stateCount); + for (const page of pages) { + for (let frameIndex = 0; frameIndex < page.usedFrameCount; frameIndex += 1) { + const stateIndex = page.startStateIndex + frameIndex; + statePageIndices[stateIndex] = page.index; + stateFrameIndices[stateIndex] = frameIndex; + } + } + const atlasContent = contentAddressSummary(pages, "atlas"); + const maximumDecodedPageBytes = Math.max(...pages.map((page) => page.atlas.decodedBytes)); + const maximumAdjacentTwoPageBytes = Math.max(...pages.map((page, pageIndex) => ( + page.atlas.decodedBytes + (pages[pageIndex + 1]?.atlas.decodedBytes ?? 0) + ))); + return Object.freeze({ + schema: "cssflower-prepared-shared-frame-window-pages@1", + binding, + encoder, + layout: plan.layout, + stateCount: plan.stateCount, + cycleStartState: plan.cycleStartState, + cycleLength: plan.cycleLength, + retainedLeafCount: plan.retainedLeafCount, + frameCount: plan.frameCount, + pageCount: pages.length, + decodedResidentPageBudget: 2, + decodedPeakPageBudget: 2, + maximumDecodedPageBytes, + maximumAdjacentTwoPageBytes, + encodedAtlasBytes: pages.reduce((sum, page) => sum + page.atlas.byteLength, 0), + contentAddressedAtlasBytes: atlasContent.byteLength, + atlasAliasCount: atlasContent.aliasCount, + rawLayoutBytes: pages.reduce((sum, page) => sum + page.layout.byteLength, 0), + compressedLayoutBytes: packed.blocks.reduce((sum, block) => sum + block.byteLength, 0), + layoutBlockPageCount: CSSFLOWER_SHARED_LAYOUT_BLOCK_PAGE_COUNT, + layoutBlocks: packed.blocks, + inverseRootTransforms: plan.inverseRootTransforms, + statePageIndices, + stateFrameIndices, + pages, + cache: Object.freeze({ + hitCount, + missCount: misses.length, + writeCount: misses.length, + }), + authority: plan.authority, + }); +} + +export function cssflowerSharedFramePageCachePaths({ binding, pageIndex }) { + if (!/^[a-f0-9]{64}$/u.test(binding ?? "") || !Number.isSafeInteger(pageIndex) || pageIndex < 0) { + throw new TypeError("Prepared shared-frame cache locator is invalid"); + } + return cachePaths(cacheRootFor(repoRoot, binding), pageIndex); +} + +export function cssflowerSharedLayoutBlockCachePath({ binding, sha256 }) { + if (!/^[a-f0-9]{64}$/u.test(binding ?? "") || !/^[a-f0-9]{64}$/u.test(sha256 ?? "")) { + throw new TypeError("Prepared shared layout-block cache locator is invalid"); + } + return join(cacheRootFor(repoRoot, binding), "layout-blocks", `block-${sha256}.i16.gz`); +} + +async function packLayoutBlocks(cacheRoot, pages) { + const blocks = []; + for (let startPageIndex = 0; startPageIndex < pages.length; startPageIndex += CSSFLOWER_SHARED_LAYOUT_BLOCK_PAGE_COUNT) { + const blockPages = pages.slice(startPageIndex, startPageIndex + CSSFLOWER_SHARED_LAYOUT_BLOCK_PAGE_COUNT); + const decoded = Buffer.concat(await Promise.all(blockPages.map((page) => ( + readFile(cachePaths(cacheRoot, page.index).layout) + )))); + const bytes = gzipSync(decoded, { + level: 9, + mtime: 0, + strategy: zlibConstants.Z_DEFAULT_STRATEGY, + }); + const block = Object.freeze({ + schema: "cssflower-prepared-shared-layout-block@1", + index: blocks.length, + startPageIndex, + pageCount: blockPages.length, + encoding: "gzip-concatenated-int16-page-layouts", + byteLength: bytes.length, + decodedByteLength: decoded.length, + sha256: sha256(bytes), + decodedSha256: sha256(decoded), + }); + await writeAtomic(join(cacheRoot, "layout-blocks", `block-${block.sha256}.i16.gz`), bytes); + blocks.push(block); + } + return Object.freeze({ blocks: Object.freeze(blocks) }); +} + +async function readCachedPage(cacheRoot, binding, page) { + const paths = cachePaths(cacheRoot, page.index); + try { + const [metadataBytes, atlasBytes, layoutBytes] = await Promise.all([ + readFile(paths.metadata), + readFile(paths.atlas), + readFile(paths.layout), + ]); + const metadata = JSON.parse(metadataBytes.toString("utf8")); + const descriptor = metadata?.page; + if (metadata?.schema !== CACHE_SCHEMA || metadata.binding !== binding || + !matchesRequest(descriptor, page) || !validCachedAsset(descriptor.atlas, atlasBytes) || + !validCachedAsset(descriptor.layout, layoutBytes)) return null; + return descriptor; + } catch (error) { + if (error?.code === "ENOENT" || error instanceof SyntaxError) return null; + throw error; + } +} + +async function writeCachedPage(cacheRoot, binding, request, result) { + const { descriptor, atlasBytes, layoutBytes } = result; + if (!matchesRequest(descriptor, request) || !validCachedAsset(descriptor.atlas, atlasBytes) || + !validCachedAsset(descriptor.layout, layoutBytes)) { + throw new Error(`Prepared shared-frame page ${request.index} failed cache validation`); + } + const paths = cachePaths(cacheRoot, request.index); + const metadata = Buffer.from(`${JSON.stringify({ + schema: CACHE_SCHEMA, + binding, + page: descriptor, + }, null, 2)}\n`); + await Promise.all([ + writeAtomic(paths.atlas, atlasBytes), + writeAtomic(paths.layout, layoutBytes), + writeAtomic(paths.metadata, metadata), + ]); +} + +async function runWorkerPool({ tasks, concurrency, avifenc, accept }) { + if (tasks.length === 0) return; + const workerUrl = new URL("./sharedFramePageWorker.mjs", import.meta.url); + let nextTaskIndex = 0; + let settledCount = 0; + let failed = false; + const workers = []; + await new Promise((resolvePromise, rejectPromise) => { + const fail = async (error) => { + if (failed) return; + failed = true; + await Promise.all(workers.map((worker) => worker.terminate().catch(() => undefined))); + rejectPromise(error); + }; + const dispatch = (worker) => { + if (failed) return; + if (nextTaskIndex >= tasks.length) { + if (settledCount === tasks.length) resolvePromise(); + return; + } + const task = tasks[nextTaskIndex]; + nextTaskIndex += 1; + worker.currentTask = task; + worker.postMessage({ taskId: task.page.index, page: task.page, avifenc }); + }; + for (let workerIndex = 0; workerIndex < concurrency; workerIndex += 1) { + const worker = new Worker(workerUrl); + workers.push(worker); + worker.on("error", fail); + worker.on("exit", (code) => { + if (!failed && code !== 0 && settledCount < tasks.length) { + void fail(new Error(`Shared-frame worker exited ${code}`)); + } + }); + worker.on("message", async (message) => { + if (failed) return; + const task = worker.currentTask; + if (!task || message.taskId !== task.page.index) { + return void fail(new Error("Shared-frame worker response drifted")); + } + if (message.error) return void fail(new Error(message.error)); + try { + await accept(task, message.result); + settledCount += 1; + worker.currentTask = null; + if (settledCount === tasks.length) resolvePromise(); + else dispatch(worker); + } catch (error) { + void fail(error); + } + }); + dispatch(worker); + } + }); + await Promise.all(workers.map((worker) => worker.terminate())); +} + +async function avifencIdentity(path) { + const [bytes, info] = await Promise.all([readFile(path), stat(path)]); + const versionRun = spawnSync(path, ["--version"], { encoding: "utf8" }); + if (versionRun.error) throw versionRun.error; + if (versionRun.status !== 0) throw new Error(`avifenc --version exited ${versionRun.status}`); + return Object.freeze({ + name: "avifenc", + version: `${versionRun.stdout}${versionRun.stderr}`.trim(), + byteLength: info.size, + sha256: sha256(bytes), + flags: Object.freeze([ + "--qcolor", String(CSSFLOWER_PROJECTED_ATLAS_QUALITY), "--speed", "6", "--yuv", "444", + "--ignore-exif", "--ignore-xmp", "--ignore-icc", + ]), + }); +} + +async function cacheBinding(repoRoot, encoder) { + const files = [ + "pnpm-lock.yaml", + "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/projectedPixels.mjs", + "src/adapters/flowerbox/src/prepare/cssflower/sharedFramePages.mjs", + "src/adapters/flowerbox/src/prepare/cssflower/sharedFramePacking.mjs", + "src/adapters/flowerbox/src/prepare/cssflower/sharedFramePageWorker.mjs", + "src/adapters/flowerbox/src/prepare/cssflower/sourceProfile.mjs", + ]; + const hash = createHash("sha256"); + for (const path of files) { + hash.update(path); + hash.update("\0"); + hash.update(await readFile(join(repoRoot, path))); + hash.update("\0"); + } + hash.update(JSON.stringify(encoder)); + return hash.digest("hex"); +} + +function cacheRootFor(repoRoot, binding) { + return join(repoRoot, ".local", "cache", "cssflower", "prepared-shared-frame-windows", binding); +} + +function cachePaths(cacheRoot, pageIndex) { + const stem = `page-${String(pageIndex).padStart(4, "0")}`; + return Object.freeze({ + atlas: join(cacheRoot, `${stem}.avif`), + layout: join(cacheRoot, `${stem}.i16`), + metadata: join(cacheRoot, `${stem}.json`), + }); +} + +function matchesRequest(descriptor, request) { + const atlas = descriptor?.atlas; + const horizontal = atlas?.packing === "horizontal-union"; + const vertical = atlas?.packing === "vertical-union"; + const expectedOffsets = Array.from( + { length: request.usedFrameCount }, + (_, frameIndex) => frameIndex === 0 ? 0 : horizontal + ? -frameIndex * atlas.frameWidth + : -frameIndex * atlas.frameHeight, + ); + return descriptor?.schema === "cssflower-prepared-shared-frame-window-page@1" && + descriptor.index === request.index && descriptor.startStateIndex === request.startStateIndex && + descriptor.usedFrameCount === request.usedFrameCount && descriptor.retainedLeafCount === 1_200 && + (horizontal || vertical) && + atlas.width === atlas.frameWidth * (horizontal ? descriptor.usedFrameCount : 1) && + atlas.height === atlas.frameHeight * (vertical ? descriptor.usedFrameCount : 1) && + arraysEqual(atlas.frameBackgroundOffsets, expectedOffsets) && + descriptor.authority?.nativeStateIngestion === false && descriptor.authority?.nativePixelIngestion === false && + descriptor.authority?.runtimeProjection === false && descriptor.authority?.runtimeRasterization === false; +} + +function arraysEqual(actual, expected) { + return Array.isArray(actual) && actual.length === expected.length && + actual.every((value, index) => value === expected[index]); +} + +function validCachedAsset(descriptor, bytes) { + return Number.isSafeInteger(descriptor?.byteLength) && descriptor.byteLength === bytes.length && + /^[a-f0-9]{64}$/u.test(descriptor.sha256 ?? "") && sha256(bytes) === descriptor.sha256; +} + +function contentAddressSummary(pages, field) { + const unique = new Map(); + for (const page of pages) unique.set(page[field].sha256, page[field].byteLength); + return Object.freeze({ + uniqueCount: unique.size, + aliasCount: pages.length - unique.size, + byteLength: [...unique.values()].reduce((sum, byteLength) => sum + byteLength, 0), + }); +} + +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-${process.pid}`; + await writeFile(temporary, bytes); + await rename(temporary, path); +} diff --git a/src/adapters/flowerbox/src/prepare/cssflower/sharedFramePageWorker.mjs b/src/adapters/flowerbox/src/prepare/cssflower/sharedFramePageWorker.mjs new file mode 100644 index 0000000..225fd7d --- /dev/null +++ b/src/adapters/flowerbox/src/prepare/cssflower/sharedFramePageWorker.mjs @@ -0,0 +1,119 @@ +import { spawnSync } from "node:child_process"; +import { createHash } from "node:crypto"; +import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { parentPort } from "node:worker_threads"; +import { + CSSFLOWER_PROJECTED_ATLAS_ENCODING, + CSSFLOWER_PROJECTED_ATLAS_MIME_TYPE, + CSSFLOWER_PROJECTED_ATLAS_QUALITY, +} from "../../cssflower/renderContract.mjs"; +import { prepareCssflowerSharedFramePage } from "./sharedFramePages.mjs"; +import { buildCssflowerSharedFramePackingCandidates } from "./sharedFramePacking.mjs"; + +if (!parentPort) throw new Error("Shared-frame page worker requires a parent port"); + +parentPort.on("message", async ({ taskId, page, avifenc }) => { + try { + const prepared = prepareCssflowerSharedFramePage(page); + const encodedPackings = await encodeLossyAvifCandidates( + buildCssflowerSharedFramePackingCandidates(prepared.atlas), + avifenc, + ); + const selected = encodedPackings[0]; + const atlasBytes = selected.bytes; + const layoutBytes = Buffer.from(prepared.layout.bytes); + parentPort.postMessage({ + taskId, + result: { + descriptor: { + schema: prepared.schema, + index: prepared.index, + startStateIndex: prepared.startStateIndex, + usedFrameCount: prepared.usedFrameCount, + retainedLeafCount: prepared.retainedLeafCount, + activeUnionLeafCount: prepared.activeUnionLeafCount, + atlas: { + encoding: CSSFLOWER_PROJECTED_ATLAS_ENCODING, + mimeType: CSSFLOWER_PROJECTED_ATLAS_MIME_TYPE, + quality: CSSFLOWER_PROJECTED_ATLAS_QUALITY, + packing: selected.packing, + width: selected.width, + height: selected.height, + frameWidth: prepared.atlas.frameWidth, + frameHeight: prepared.atlas.frameHeight, + cropLeft: prepared.atlas.cropLeft, + cropTop: prepared.atlas.cropTop, + frameBackgroundOffsets: selected.frameBackgroundOffsets, + byteLength: atlasBytes.length, + decodedBytes: selected.width * selected.height * 4, + sha256: sha256(atlasBytes), + }, + layout: { + schema: prepared.layout.schema, + encoding: prepared.layout.encoding, + componentCount: prepared.layout.componentCount, + bytesPerLeaf: prepared.layout.bytesPerLeaf, + leafCount: prepared.layout.leafCount, + byteLength: layoutBytes.length, + sha256: sha256(layoutBytes), + }, + authority: prepared.authority, + }, + atlasBytes, + layoutBytes, + }, + }); + } catch (error) { + parentPort.postMessage({ + taskId, + error: String(error?.stack || error?.message || error), + }); + } +}); + +async function encodeLossyAvifCandidates(candidates, avifenc) { + const temporaryRoot = await mkdtemp(join(tmpdir(), "cssflower-shared-frame-page-")); + try { + const encoded = []; + for (let index = 0; index < candidates.length; index += 1) { + const candidate = candidates[index]; + const input = join(temporaryRoot, `page-${index}.png`); + const output = join(temporaryRoot, `page-${index}.avif`); + await writeFile(input, candidate.bytes); + const result = spawnSync(avifenc, [ + "--qcolor", + String(CSSFLOWER_PROJECTED_ATLAS_QUALITY), + "--speed", + "6", + "--yuv", + "444", + "--ignore-exif", + "--ignore-xmp", + "--ignore-icc", + input, + output, + ], { encoding: "utf8" }); + if (result.error) throw result.error; + if (result.status !== 0) { + throw new Error(`avifenc exited ${result.status}: ${result.stderr || result.stdout}`); + } + encoded.push(Object.freeze({ + ...candidate, + bytes: await readFile(output), + })); + } + encoded.sort((left, right) => ( + left.bytes.length - right.bytes.length || + left.packing.localeCompare(right.packing) + )); + return Object.freeze(encoded); + } finally { + await rm(temporaryRoot, { recursive: true, force: true }); + } +} + +function sha256(bytes) { + return createHash("sha256").update(bytes).digest("hex"); +} diff --git a/src/adapters/flowerbox/src/prepare/cssflower/sharedFramePages.mjs b/src/adapters/flowerbox/src/prepare/cssflower/sharedFramePages.mjs new file mode 100644 index 0000000..0cf130c --- /dev/null +++ b/src/adapters/flowerbox/src/prepare/cssflower/sharedFramePages.mjs @@ -0,0 +1,216 @@ +import { createHash } from "node:crypto"; +import { PNG } from "pngjs"; +import { buildPreparedFullRotationCycle } from "./bloomCycle.mjs"; +import { + buildCssflowerPreparedInverseRootTransforms, + prepareCssflowerProjectedFrame, +} from "./projectedPixels.mjs"; + +export const CSSFLOWER_SHARED_FRAME_PAGE_FRAME_COUNT = 4; +export const CSSFLOWER_SHARED_LAYOUT_COMPONENT_COUNT = 6; +export const CSSFLOWER_SHARED_LAYOUT_BYTES_PER_LEAF = + CSSFLOWER_SHARED_LAYOUT_COMPONENT_COUNT * Int16Array.BYTES_PER_ELEMENT; +export const CSSFLOWER_SHARED_LAYOUT_BLOCK_PAGE_COUNT = 64; + +const STAGE_PIXELS = 720; +const HALF_STAGE_PIXELS = STAGE_PIXELS / 2; +const cycle = buildPreparedFullRotationCycle(); + +export function buildCssflowerSharedFramePagePlan({ + frameCount = CSSFLOWER_SHARED_FRAME_PAGE_FRAME_COUNT, +} = {}) { + if (frameCount !== CSSFLOWER_SHARED_FRAME_PAGE_FRAME_COUNT) { + throw new RangeError(`Prepared shared-frame page span must be ${CSSFLOWER_SHARED_FRAME_PAGE_FRAME_COUNT}`); + } + const pages = []; + for (let startStateIndex = 0; startStateIndex < cycle.stateCount; startStateIndex += frameCount) { + const usedFrameCount = Math.min(frameCount, cycle.stateCount - startStateIndex); + pages.push(Object.freeze({ + index: pages.length, + startStateIndex, + usedFrameCount, + ticks: Object.freeze(Array.from({ length: usedFrameCount }, (_, offset) => startStateIndex + offset)), + })); + } + return Object.freeze({ + schema: "cssflower-prepared-shared-frame-page-plan@1", + layout: "source-order-retained-leaf-windows-over-screen-aligned-prepared-frame-pages", + stateCount: cycle.stateCount, + cycleStartState: cycle.cycleStartState, + cycleLength: cycle.cycleLength, + retainedLeafCount: 1_200, + frameCount, + pageCount: pages.length, + pages: Object.freeze(pages), + inverseRootTransforms: buildCssflowerPreparedInverseRootTransforms(), + authority: Object.freeze({ + precedent: "cssgraphics-mario-prepared-space-time-texels-with-raster-leaf-sizing", + input: "independently-prepared-cssflower-source-state", + nativeStateIngestion: false, + nativePixelIngestion: false, + runtimeProjection: false, + runtimeRasterization: false, + runtimeGeometryConstruction: false, + runtimeNormalCalculation: false, + runtimeLightingCalculation: false, + runtimeDomGrowth: false, + }), + }); +} + +export function prepareCssflowerSharedFramePage(page) { + if (!validPageRequest(page)) throw new TypeError("Prepared shared-frame page request is invalid"); + const frames = page.ticks.map((tick) => prepareCssflowerProjectedFrame(tick)); + const slots = unionLeafSlots(frames); + const crop = unionPageCrop(slots); + const atlasImage = new PNG({ + width: crop.width * frames.length, + height: crop.height, + colorType: 6, + }); + for (let frameIndex = 0; frameIndex < frames.length; frameIndex += 1) { + copyOpaqueFrameCrop(frames[frameIndex].frameImage, atlasImage, crop, frameIndex); + } + const atlasBytes = PNG.sync.write(atlasImage, { colorType: 2, inputColorType: 6 }); + const layoutBytes = encodeCssflowerSharedLeafLayout(slots, crop); + return Object.freeze({ + schema: "cssflower-prepared-shared-frame-window-page@1", + index: page.index, + startStateIndex: page.startStateIndex, + usedFrameCount: page.usedFrameCount, + retainedLeafCount: 1_200, + activeUnionLeafCount: slots.filter((slot) => slot.pixelCount > 0).length, + atlas: Object.freeze({ + encoding: "png-rgb8-lossless-pre-transport", + width: atlasImage.width, + height: atlasImage.height, + frameWidth: crop.width, + frameHeight: crop.height, + cropLeft: crop.left, + cropTop: crop.top, + byteLength: atlasBytes.length, + decodedBytes: atlasImage.width * atlasImage.height * 4, + sha256: sha256(atlasBytes), + frameBackgroundXs: Object.freeze(Array.from( + { length: page.usedFrameCount }, + (_, frameIndex) => frameIndex === 0 ? 0 : -frameIndex * crop.width, + )), + bytes: atlasBytes, + }), + layout: Object.freeze({ + schema: "cssflower-prepared-shared-frame-leaf-layout@1", + encoding: "int16-little-endian-source-order-width-height-dx-dy-frame-background-x-frame-background-y", + componentCount: CSSFLOWER_SHARED_LAYOUT_COMPONENT_COUNT, + bytesPerLeaf: CSSFLOWER_SHARED_LAYOUT_BYTES_PER_LEAF, + leafCount: 1_200, + byteLength: layoutBytes.length, + sha256: sha256(layoutBytes), + bytes: layoutBytes, + }), + packets: Object.freeze(frames.map((frame, frameIndex) => Object.freeze({ + tick: frame.tick, + sf: frame.state.sf, + rootTransform: frame.rootTransform, + frameIndex, + visibleLeafCount: frame.topology.visibleLeafCount, + }))), + authority: Object.freeze({ + ...frames[0].authority, + precedent: "cssgraphics-mario-prepared-space-time-texels-with-raster-leaf-sizing", + runtimeNormalCalculation: false, + runtimeLightingCalculation: false, + runtimeDomGrowth: false, + }), + }); +} + +export function encodeCssflowerSharedLeafLayout(slots, crop) { + if (!Array.isArray(slots) || slots.length !== 1_200 || + slots.some((slot, index) => slot?.index !== index) || + !Number.isSafeInteger(crop?.left) || !Number.isSafeInteger(crop?.top)) { + throw new TypeError("Complete prepared shared-frame leaf slots and crop are required"); + } + const bytes = Buffer.alloc(slots.length * CSSFLOWER_SHARED_LAYOUT_BYTES_PER_LEAF); + for (const slot of slots) { + const values = slot.pixelCount === 0 + ? [0, 0, 0, 0, 0, 0] + : [ + slot.width, + slot.height, + slot.left - HALF_STAGE_PIXELS, + slot.top - HALF_STAGE_PIXELS, + -(slot.left - crop.left), + -(slot.top - crop.top), + ]; + for (let component = 0; component < values.length; component += 1) { + const value = values[component]; + if (!Number.isSafeInteger(value) || value < -32_768 || value > 32_767) { + throw new RangeError(`Prepared shared-frame leaf ${slot.index} component ${component} exceeds int16`); + } + bytes.writeInt16LE( + value, + (slot.index * CSSFLOWER_SHARED_LAYOUT_COMPONENT_COUNT + component) * Int16Array.BYTES_PER_ELEMENT, + ); + } + } + return bytes; +} + +function unionLeafSlots(frames) { + return Array.from({ length: 1_200 }, (_, index) => { + const visible = frames.map((frame) => frame.leaves[index]).filter((leaf) => leaf.pixelCount > 0); + const left = visible.length ? Math.min(...visible.map((leaf) => leaf.left)) : STAGE_PIXELS; + const top = visible.length ? Math.min(...visible.map((leaf) => leaf.top)) : STAGE_PIXELS; + const right = visible.length ? Math.max(...visible.map((leaf) => leaf.right)) : -1; + const bottom = visible.length ? Math.max(...visible.map((leaf) => leaf.bottom)) : -1; + return Object.freeze({ + index, + pixelCount: visible.reduce((sum, leaf) => sum + leaf.pixelCount, 0), + left, + top, + right, + bottom, + width: Math.max(0, right - left + 1), + height: Math.max(0, bottom - top + 1), + }); + }); +} + +function unionPageCrop(slots) { + const visible = slots.filter((slot) => slot.pixelCount > 0); + if (visible.length === 0) throw new Error("Prepared shared-frame page has no visible source triangles"); + const left = Math.min(...visible.map((slot) => slot.left)); + const top = Math.min(...visible.map((slot) => slot.top)); + const right = Math.max(...visible.map((slot) => slot.right)); + const bottom = Math.max(...visible.map((slot) => slot.bottom)); + return Object.freeze({ + left, + top, + right, + bottom, + width: right - left + 1, + height: bottom - top + 1, + }); +} + +function copyOpaqueFrameCrop(source, target, crop, frameIndex) { + const targetFrameX = frameIndex * crop.width; + for (let y = 0; y < crop.height; y += 1) { + const sourceOffset = ((crop.top + y) * source.width + crop.left) * 4; + const targetOffset = (y * target.width + targetFrameX) * 4; + source.data.copy(target.data, targetOffset, sourceOffset, sourceOffset + crop.width * 4); + } +} + +function validPageRequest(page) { + return Number.isSafeInteger(page?.index) && page.index >= 0 && + Number.isSafeInteger(page.startStateIndex) && page.startStateIndex >= 0 && + Number.isSafeInteger(page.usedFrameCount) && page.usedFrameCount >= 1 && + page.usedFrameCount <= CSSFLOWER_SHARED_FRAME_PAGE_FRAME_COUNT && + Array.isArray(page.ticks) && page.ticks.length === page.usedFrameCount && + page.ticks.every((tick, offset) => tick === page.startStateIndex + offset && tick < cycle.stateCount); +} + +function sha256(bytes) { + return createHash("sha256").update(bytes).digest("hex"); +} 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..f919fd6 --- /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-validation-in-progress", + 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..531e02e --- /dev/null +++ b/src/adapters/flowerbox/src/prepare/cssflower/writePreparedAssets.mjs @@ -0,0 +1,248 @@ +import { createHash } from "node:crypto"; +import { copyFile, link, mkdir, readFile, readdir, rename, stat, unlink, writeFile } from "node:fs/promises"; +import { dirname, join, resolve } from "node:path"; +import { + generatedLightingPagePath, + generatedAssetDir, + generatedProjectedAtlasPath, + generatedProjectedAssetDir, + generatedSharedLayoutBlockPath, + generatedStateEvidencePath, + generatedTransformsPath, + localPreparedTransformsPath, + repoRoot, +} from "./paths.mjs"; +import { + cssflowerSharedFramePageCachePaths, + cssflowerSharedLayoutBlockCachePath, +} from "./sharedFramePageStore.mjs"; +import { assertNoBrowserPathLeaks } from "./provenance.mjs"; + +export async function writeCssflowerPreparedAssets(compiled, projectedPixels) { + await writeAtomic(localPreparedTransformsPath, compiled.transformBytes); + await unlinkIfPresent(generatedTransformsPath); + 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 projected = await materializeProjectedPixels(projectedPixels); + return Object.freeze({ + transformBytes: compiled.transformBytes.length, + transformSha256: compiled.transformSha256, + lightingBytes: compiled.lightingPages.reduce((sum, page) => sum + page.byteLength, 0), + lightingSha256: compiled.lightingSha256, + lightingPageCount: compiled.lightingPages.length, + decodedResidentPageBudgetBytes: compiled.lighting.decodedBytesPerFullPage * compiled.lighting.decodedResidentPageBudget, + decodedPeakPageBudgetBytes: compiled.lighting.decodedBytesPerFullPage * compiled.lighting.decodedPeakPageBudget, + projected, + }); +} + +async function materializeProjectedPixels(projected) { + if (projected?.schema !== "cssflower-prepared-shared-frame-window-pages@1" || + projected.pages?.length !== projected.pageCount || projected.retainedLeafCount !== 1_200) { + throw new TypeError("Complete shared-frame projected-pixel preparation is required"); + } + const atlasAssets = new Map(); + for (const page of projected.pages) { + const cache = cssflowerSharedFramePageCachePaths({ + binding: projected.binding, + pageIndex: page.index, + }); + atlasAssets.set(page.atlas.sha256, { source: cache.atlas, target: generatedProjectedAtlasPath(page.atlas.sha256) }); + } + const layoutBlockAssets = projected.layoutBlocks.map((block) => ({ + source: cssflowerSharedLayoutBlockCachePath({ binding: projected.binding, sha256: block.sha256 }), + target: generatedSharedLayoutBlockPath(block.sha256), + })); + const assets = [...atlasAssets.values(), ...layoutBlockAssets]; + await pruneGeneratedProjectedAssets(new Set(assets.map((asset) => asset.target))); + await materializeContentAddressedAssets(assets, 16); + return Object.freeze({ + pageCount: projected.pageCount, + atlasAssetCount: atlasAssets.size, + layoutAssetCount: layoutBlockAssets.length, + atlasAliasCount: projected.atlasAliasCount, + encodedAtlasBytes: projected.encodedAtlasBytes, + rawLayoutBytes: projected.rawLayoutBytes, + compressedLayoutBytes: projected.compressedLayoutBytes, + contentAddressedAtlasBytes: projected.contentAddressedAtlasBytes, + maximumDecodedPageBytes: projected.maximumDecodedPageBytes, + maximumAdjacentTwoPageBytes: projected.maximumAdjacentTwoPageBytes, + }); +} + +async function pruneGeneratedProjectedAssets(keepPaths) { + await mkdir(generatedProjectedAssetDir, { recursive: true }); + const entries = await readdir(generatedProjectedAssetDir, { withFileTypes: true }); + await Promise.all(entries.map(async (entry) => { + if (!entry.isFile()) return; + const path = join(generatedProjectedAssetDir, 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 cacheRoot = join(repoRoot, ".local", "cache", "cssflower", "prepared-leaf-lighting", binding); + let hitCount = 0; + let missCount = 0; + let writeCount = 0; + return Object.freeze({ + binding, + async read(expectedPage) { + const paths = cachePaths(cacheRoot, 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 (metadata?.schema !== "cssflower-prepared-leaf-lighting-cache@1" || + metadata.binding !== binding || !sameExpectedPage(page, expectedPage) || + bytes.length !== page.byteLength || sha256(bytes) !== page.sha256) { + missCount += 1; + return null; + } + if (page.index === 0) await writeAtomic(generatedLightingPagePath(page.index), bytes); + hitCount += 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), + ]); + writeCount += 1; + }, + stats() { + return Object.freeze({ binding, hitCount, missCount, writeCount }); + }, + }); +} + +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); +} + +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; +} + +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..2b2d0cc --- /dev/null +++ b/src/adapters/flowerbox/tools/audit-runtime-surface.mjs @@ -0,0 +1,72 @@ +#!/usr/bin/env node + +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/preparedPlayback.mjs", + "src/cssflower/projectedPageStyles.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"), +]))); + +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({")) failures.push("PolyCSS Morph prepared target is missing"); +if (/\b(?:document|DOMParser|MutationObserver)\b|createElement|appendChild|replaceChildren/u.test(playback)) { + failures.push("Prepared playback constructs DOM"); +} + +const bank = await inspectFlowerboxProductBank(join(repositoryRoot, "build", "generated", "public", "cssflower")); +if (bank.closureBytes >= 31_000_000) failures.push(`Product bank is too large: ${bank.closureBytes}`); +const report = { + schema: "cssgraphics-flowerbox-runtime-audit@1", + status: failures.length === 0 ? "pass" : "fail", + renderer: "retained-dom-polycss-only", + 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", "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..a4d9edf --- /dev/null +++ b/src/adapters/flowerbox/tools/package-product-bank.mjs @@ -0,0 +1,131 @@ +#!/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 }); + +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 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: "not-packaged", + legal: "independently-authored-results-only", + redistributableUpstreamBytes: false, +}; +if (scene.sourceProfile) scene.sourceProfile.authority = "src/adapters/flowerbox/README.md"; +delete scene.meshes; +delete scene.oracle; +delete scene.playback.stateEvidenceUrl; +delete scene.playback.transformAsset; +scene.warnings = [ + "Independent source-informed PolyCSS experiment; Microsoft source, binaries, captures, and oracle packets are not packaged.", +]; + +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 }); + +manifest.title = "Flower Box — PolyCSS experiment"; +entry.label = scene.label; +entry.warnings = [...scene.warnings]; +manifest.assets = { + projected: { + pageCount: scene.playback.projectedPixels.pageCount, + atlasAssetCount: scene.metrics.preparedProjectedPixelAtlasAssetCount, + layoutBlockCount: scene.playback.projectedPixels.layoutBlocks.length, + visualEncoding: scene.playback.projectedPixels.visualEncoding, + }, +}; +manifest.productionTransport = { + schema: "cssflower-product-static-transport@1", + exactDecodedSceneAndSnapshotBytes: true, + runtimeGeometryConstruction: false, + runtimeRasterization: false, + runtimeLightingCalculation: false, + assets: [ + transportAsset("scene:default-cube", entry.sceneUrl, sceneDecoded, sceneEncoded), + transportAsset( + "snapshot:default-cube", + entry.snapshotUrl, + gunzipSync(sourceSnapshotEncoded), + sourceSnapshotEncoded, + ), + ], +}; +await writeFile(join(stagingRoot, "manifest.json"), `${JSON.stringify(manifest, null, 2)}\n`); +await rm(join(stagingRoot, "product-bank.json"), { force: true }); + +const summary = await inspectFlowerboxProductBank(stagingRoot, { verifyDescriptor: false }); +await writeFlowerboxProductBankDescriptor(stagingRoot, summary, { + sourceManifestSha256: sha256(sourceManifestBytes), + sourceSceneEncodedSha256: sha256(sourceSceneEncoded), + sourceSnapshotEncodedSha256: sha256(sourceSnapshotEncoded), + sanitization: [ + "native qualification metadata", + "oracle metadata and state evidence", + "prepare-only mesh geometry", + "ignored transform-asset descriptor", + ], +}); +await inspectFlowerboxProductBank(stagingRoot); +await rm(outputRoot, { recursive: true, force: true }); +await rename(stagingRoot, outputRoot); +process.stdout.write(`${JSON.stringify({ outputRoot, ...summary }, null, 2)}\n`); + +function transportAsset(id, url, decoded, encoded) { + return { + id, + url, + encoding: "gzip", + decodedByteLength: decoded.length, + decodedSha256: sha256(decoded), + encodedByteLength: encoded.length, + encodedSha256: sha256(encoded), + }; +} + +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..149041f --- /dev/null +++ b/src/adapters/flowerbox/tools/polycss-snapshot-page.mjs @@ -0,0 +1,337 @@ +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_LAYOUT, + CSSFLOWER_LIGHTING_PAGE_COUNT, + CSSFLOWER_LIGHTING_PAGE_ROWS, + 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"); +const preparationLightingUrl = "/cssflower/assets/flower-box-space-texels.png"; + +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); + await Promise.all([ + fetchVerifiedBytes(preparationLightingUrl, sceneData.lighting.assetSha256), + loadImage(preparationLightingUrl), + ]); + const { scene, mesh } = createSnapshotScene(sceneData); + try { + const retained = mountRetainedTargets({ scene, mesh, sceneData }); + 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.playback.projectedPixels.pages[0].atlas.assetUrl; + const html = 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\/projected\/atlas-[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?.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 }) { + 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"; + root.dataset.cssflowerRootStateIndex = "0"; + root.dataset.cssflowerGeometryStateIndex = "0"; + root.dataset.cssflowerLightingRow = "0"; + root.dataset.cssflowerLightingPage = "0"; + root.dataset.cssflowerLightingPageRow = "0"; + root.dataset.cssflowerRuntimeDomGrowth = "false"; + root.dataset.cssflowerRuntimeGeometryConstruction = "false"; + root.dataset.cssflowerRuntimeLightingCalculation = "false"; + 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}")`); + root.style.setProperty("--cssflower-lighting-y", sceneData.lighting.backgroundPositionYs[0]); + 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.leafWidth !== face.tileWidth || + !Number.isSafeInteger(face.leafHeight) || face.leafHeight !== face.tileHeight || + 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(")) { + throw new Error(`Prepared cssFlower initial leaf transform ${index} is missing`); + } + 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"; + } + root.dataset.cssflowerStableLeafCount = String(leaves.length); + 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\/projected\/atlas-[a-f0-9]{64}\.avif/gu); + if (!html.includes("polycss-scene") || + count(/ 3_000_000 || + sceneData.renderer.merge !== false) { + throw new Error(`Prepared cssFlower snapshot sanitization failed (${html.length} bytes, ${dataUrlCount} atlas data URLs, ${preparedAtlasReferenceCount} atlas references)`); + } +} + +function restorePreparedLightingReference(html, assetUrl) { + const matches = html.match(/data:image\/png;base64,[a-zA-Z0-9+/=]+/gu) ?? []; + if (matches.length !== 1) { + throw new Error(`Expected one exported prepared-lighting data URL, found ${matches.length}`); + } + return html.replace(matches[0], assetUrl); +} + +function integerAttribute(element, name) { + const value = Number(element.getAttribute(name)); + if (!Number.isInteger(value)) throw new Error(`${name} must be an integer`); + return value; +} + +async function fetchJson(url) { + const response = await fetch(url, { cache: "no-store" }); + if (!response.ok) throw new Error(`Failed to load ${url}: ${response.status}`); + return response.json(); +} + +async function fetchVerifiedBytes(url, expectedSha256) { + const response = await fetch(url, { cache: "no-store" }); + if (!response.ok) throw new Error(`Prepared cssFlower asset request failed for ${url}: ${response.status}`); + const bytes = await response.arrayBuffer(); + const actualSha256 = await sha256(bytes); + if (actualSha256 !== expectedSha256) { + throw new Error(`Prepared cssFlower asset identity mismatch for ${url}: ${actualSha256}`); + } + return bytes; +} + +async function sha256(bytes) { + const digest = await crypto.subtle.digest("SHA-256", bytes); + return [...new Uint8Array(digest)].map((value) => value.toString(16).padStart(2, "0")).join(""); +} + +async function loadImage(url) { + const image = new Image(); + image.decoding = "async"; + image.src = url; + await image.decode(); +} + +function createSceneCamera(sceneData) { + const camera = sceneData.camera; + return createPolyPerspectiveCamera({ + perspective: camera.perspective, + zoom: camera.zoom, + rotX: camera.rotX, + rotY: camera.rotY, + target: camera.target, + distance: camera.distance, + }); +} diff --git a/src/adapters/flowerbox/tools/prepare-cssflower.mjs b/src/adapters/flowerbox/tools/prepare-cssflower.mjs new file mode 100644 index 0000000..ebd967a --- /dev/null +++ b/src/adapters/flowerbox/tools/prepare-cssflower.mjs @@ -0,0 +1,43 @@ +#!/usr/bin/env node +import { prepareCssflower } from "../src/prepare/cssflower/prepare.mjs"; + +const options = parseArgs(process.argv.slice(2)); +let lastProjectedProgress = 0; + +prepareCssflower({ + ...options, + onProjectedProgress(progress) { + if (progress.completedCount !== progress.totalCount && progress.completedCount - lastProjectedProgress < 25) return; + lastProjectedProgress = progress.completedCount; + console.log(`shared frame pages ${progress.completedCount}/${progress.totalCount} (${progress.source})`); + }, +}).then((result) => { + console.log(JSON.stringify({ + manifest: result.manifestPath, + scenes: result.manifest.scenes, + }, null, 2)); +}).catch((error) => { + console.error(error.stack || error.message || String(error)); + process.exitCode = 1; +}); + +function parseArgs(argv) { + const out = {}; + for (let i = 0; i < argv.length; i += 1) { + const arg = argv[i]; + if (!arg.startsWith("--")) continue; + const key = arg.slice(2); + const next = argv[i + 1]; + if (!next || next.startsWith("--")) { + out[key] = true; + } else { + out[key] = next; + i += 1; + } + } + return { + scene: out.scene, + nativeRoot: out["native-root"], + concurrency: out.concurrency === undefined ? undefined : Number(out.concurrency), + }; +} diff --git a/src/adapters/flowerbox/tools/prepare-polycss-snapshot.mjs b/src/adapters/flowerbox/tools/prepare-polycss-snapshot.mjs new file mode 100644 index 0000000..dcf8363 --- /dev/null +++ b/src/adapters/flowerbox/tools/prepare-polycss-snapshot.mjs @@ -0,0 +1,131 @@ +#!/usr/bin/env node +import { createHash } from "node:crypto"; +import { spawn } from "node:child_process"; +import { mkdir, readFile, rename, unlink, writeFile } from "node:fs/promises"; +import { createServer } from "node:net"; +import { dirname, join } from "node:path"; +import { chromium } from "playwright"; + +const args = parseArgs(process.argv.slice(2)); +const sceneId = args.scene ?? "default-cube"; +const sceneUrl = "/cssflower/scenes/" + sceneId + ".json"; +const generatedRoot = join("build", "generated", "public", "cssflower"); +const manifestPath = join(generatedRoot, "manifest.json"); +const snapshotPath = join(generatedRoot, "scenes", sceneId + ".polycss.html"); +const snapshotUrl = "/cssflower/scenes/" + sceneId + ".polycss.html"; +const temporaryLightingSeedPath = join(generatedRoot, "assets", "flower-box-space-texels.png"); +const port = await freePort(); +let output = ""; +const server = spawn("pnpm", [ + "exec", "vite", "--config", "src/adapters/flowerbox/vite.config.mjs", + "--host", "127.0.0.1", "--port", String(port), "--strictPort", +], { + stdio: ["ignore", "pipe", "pipe"], +}); +server.stdout.on("data", (chunk) => { output += chunk.toString(); }); +server.stderr.on("data", (chunk) => { output += chunk.toString(); }); + +try { + await waitFor(() => output.includes("Local:") || output.includes("http://127.0.0.1:" + port), 20_000, () => { + if (server.exitCode !== null) throw new Error("Vite exited early:\n" + output); + }); + const browser = await chromium.launch({ headless: true }); + try { + const page = await browser.newPage({ viewport: { width: 960, height: 720 }, deviceScaleFactor: 1 }); + await page.goto("http://127.0.0.1:" + port + "/tools/polycss-snapshot-page.html?sceneUrl=" + encodeURIComponent(sceneUrl), { waitUntil: "networkidle" }); + await page.waitForFunction(() => { + const status = window.__cssFlowerDebugSnapshot?.status; + return status === "ready" || status === "error"; + }, null, { timeout: 60_000 }); + const snapshot = await page.evaluate(() => window.__cssFlowerDebugSnapshot); + if (snapshot?.status === "error") { + throw new Error("Snapshot page failed:\n" + snapshot.error); + } + if (typeof snapshot.html !== "string" || !snapshot.html.includes("polycss-scene") || snapshot.html.includes(" entry.id === sceneId); + if (!manifestScene) throw new Error(`Manifest scene ${sceneId} is missing`); + const snapshotContract = { + schema: "cssflower-retained-snapshot-contract@1", + url: snapshotUrl, + sha256: snapshotSha256, + byteLength: snapshotBytes.length, + retainedTriangleLeafCount: snapshot.retainedLeafCount, + retainedRotationRootCount: snapshot.retainedRotationRootCount, + triangleIdCount: snapshot.triangleIdCount, + seamBleed: snapshot.seamBleed, + boundarySeamBleed: snapshot.boundarySeamBleed, + boundaryAdjacentTriangleCount: snapshot.boundaryAdjacentTriangleCount, + mergedCellCount: snapshot.mergedCellCount, + lightingAtlasStateCount: snapshot.lightingAtlasStateCount, + lightingAtlasDataUrlCount: snapshot.lightingAtlasDataUrlCount, + preparedAtlasReferenceCount: snapshot.preparedAtlasReferenceCount, + lightingAtlasSelfContained: false, + scriptCount: snapshot.scriptCount, + canvasCount: snapshot.canvasCount, + svgCount: snapshot.svgCount, + surfaceLeafCounts: snapshot.stats.surfaceLeafCounts, + }; + manifestScene.snapshot = snapshotContract; + manifest.assets = { ...manifest.assets, snapshot: snapshotContract }; + await writeAtomic(manifestPath, Buffer.from(JSON.stringify(manifest, null, 2) + "\n")); + await unlink(temporaryLightingSeedPath).catch((error) => { + if (error?.code !== "ENOENT") throw error; + }); + console.log(JSON.stringify({ snapshotPath, snapshotUrl, ...snapshotContract }, null, 2)); + } finally { + await browser.close(); + } +} finally { + server.kill("SIGTERM"); +} + +function parseArgs(argv) { + const out = {}; + for (let i = 0; i < argv.length; i += 1) { + const arg = argv[i]; + if (!arg.startsWith("--")) continue; + const key = arg.slice(2); + const next = argv[i + 1]; + if (!next || next.startsWith("--")) out[key] = true; + else { + out[key] = next; + i += 1; + } + } + return out; +} + +async function writeAtomic(path, bytes) { + await mkdir(dirname(path), { recursive: true }); + const temporary = `${path}.tmp-${process.pid}`; + await writeFile(temporary, bytes); + await rename(temporary, path); +} + +async function freePort() { + return new Promise((resolvePort, reject) => { + const srv = createServer(); + srv.listen(0, "127.0.0.1", () => { + const address = srv.address(); + const port = typeof address === "object" && address ? address.port : 0; + srv.close(() => resolvePort(port)); + }); + srv.on("error", reject); + }); +} + +async function waitFor(predicate, timeoutMs, onPoll = () => undefined) { + const start = Date.now(); + while (Date.now() - start < timeoutMs) { + onPoll(); + if (predicate()) return; + await new Promise((resolve) => setTimeout(resolve, 100)); + } + throw new Error("Timed out waiting for Vite.\n" + output); +} diff --git a/src/adapters/flowerbox/tools/productBank.mjs b/src/adapters/flowerbox/tools/productBank.mjs new file mode 100644 index 0000000..d0c84c5 --- /dev/null +++ b/src/adapters/flowerbox/tools/productBank.mjs @@ -0,0 +1,165 @@ +import { createHash } from "node:crypto"; +import { readFile, readdir, writeFile } from "node:fs/promises"; +import { join, relative, sep } from "node:path"; +import { gunzipSync } from "node:zlib"; + +export const FLOWERBOX_PRODUCT_BANK_SCHEMA = "cssflower-product-bank@1"; + +export async function inspectFlowerboxProductBank(root, { verifyDescriptor = true } = {}) { + const manifestBytes = await readFile(join(root, "manifest.json")); + const manifest = JSON.parse(manifestBytes.toString("utf8")); + const entry = manifest.scenes?.find((candidate) => candidate.id === "default-cube"); + assert(manifest.schema === "cssflower-manifest@1" && manifest.status === "ready", "manifest contract"); + assert(entry?.sceneUrl === "/cssflower/scenes/default-cube.json.gz", "scene URL"); + assert(entry?.snapshotUrl === "/cssflower/scenes/default-cube.polycss.html.gz", "snapshot URL"); + + const scenePath = join(root, "scenes", "default-cube.json.gz"); + const snapshotPath = join(root, "scenes", "default-cube.polycss.html.gz"); + const sceneEncoded = await readFile(scenePath); + const snapshotEncoded = await readFile(snapshotPath); + const sceneDecoded = gunzipSync(sceneEncoded); + const snapshotDecoded = gunzipSync(snapshotEncoded); + const scene = JSON.parse(sceneDecoded.toString("utf8")); + const projected = scene.playback?.projectedPixels; + + assert(scene.schema === "cssflower-prepared-scene@1", "scene schema"); + assert(scene.metrics?.preparedLeafCount === 1_200 && scene.metrics?.preparedRootCount === 1, "retained counts"); + assert(scene.renderer?.morphTarget === "createPolyMorphPreparedDomTarget" && scene.renderer?.stableDom === true, "retained Morph target"); + assert(scene.renderer?.merge === false && scene.metrics?.mergedCellCount === 0, "triangle topology"); + assert(scene.metrics?.runtimePolygonConstructionCount === 0 && scene.metrics?.runtimeRadialProjectionCount === 0 && + scene.metrics?.runtimeNormalCalculationCount === 0 && scene.metrics?.runtimeLightingCalculationCount === 0 && + scene.metrics?.runtimeDomGrowth === false, "zero runtime construction"); + assert(projected?.schema === "cssflower-prepared-projected-pixel-playback@1" && projected.pageCount === 2_333 && + projected.pages?.length === 2_333 && projected.layoutBlocks?.length === 37 && projected.retainedLeafCount === 1_200, + "prepared visual bank"); + assert(projected.visualEncoding?.codec === "AVIF" && projected.visualEncoding?.quality === 40 && + projected.visualEncoding?.chromaSubsampling === "4:4:4", "q40 AVIF binding"); + assert(projected.runtimeProjection === false && projected.runtimeRasterization === false && + projected.runtimeGeometryConstruction === false && projected.runtimeNormalCalculation === false && + projected.runtimeLightingCalculation === false && projected.runtimeDomGrowth === false, "projected runtime boundary"); + assert(!scene.meshes && !scene.oracle && !scene.playback.stateEvidenceUrl && !scene.playback.transformAsset, + "product-only scene"); + assert(!manifest.assets?.stateEvidence && !manifest.productionTransport?.assets?.some((asset) => asset.id === "state-evidence"), + "state evidence excluded"); + const publicText = `${manifestBytes}\n${sceneDecoded}`; + assert(!/(?:\.local\/|nativeQualification|executableSha256|compilerSha256|stateEvidenceUrl)/u.test(publicText), + "private oracle metadata excluded"); + const snapshot = snapshotDecoded.toString("utf8"); + assert(count(snapshot, /data-cssflower-retained-leaf="true"/gu) === 1_200, "snapshot leaves"); + assert(count(snapshot, /data-cssflower-rotation-root="true"/gu) === 1, "snapshot root"); + assert(count(snapshot, /<(?:script|canvas|svg)\b/giu) === 0, "snapshot forbidden elements"); + assert(sha256(snapshotDecoded) === entry.snapshot.sha256, "snapshot decoded identity"); + + const expectedAssets = new Map(); + for (const page of projected.pages) { + addExpected(expectedAssets, page.atlas.assetUrl, page.atlas.byteLength, page.atlas.sha256); + } + for (const block of projected.layoutBlocks) { + addExpected(expectedAssets, block.assetUrl, block.byteLength, block.sha256); + } + await parallel([...expectedAssets.entries()], 24, async ([url, expected]) => { + const bytes = await readFile(publicPath(root, url)); + assert(bytes.length === expected.byteLength && sha256(bytes) === expected.sha256, `asset ${url}`); + }); + + const files = (await walk(root)) + .map((path) => relative(root, path).split(sep).join("/")) + .filter((path) => path !== "product-bank.json") + .sort(); + const closure = createHash("sha256"); + let closureBytes = 0; + for (const path of files) { + const bytes = await readFile(join(root, path)); + closure.update(path).update("\0").update(bytes).update("\0"); + closureBytes += bytes.length; + } + const summary = Object.freeze({ + schema: FLOWERBOX_PRODUCT_BANK_SCHEMA, + closureSha256: closure.digest("hex"), + closureBytes, + fileCount: files.length, + retainedTriangleLeafCount: 1_200, + retainedRotationRootCount: 1, + timelineStateCount: 9_331, + projectedPageCount: projected.pageCount, + projectedAtlasAssetCount: new Set(projected.pages.map((page) => page.atlas.assetUrl)).size, + projectedLayoutBlockCount: projected.layoutBlocks.length, + projectedVisualBankBytes: projected.contentAddressedAtlasBytes + projected.compressedLayoutBytes, + sceneEncodedSha256: sha256(sceneEncoded), + sceneDecodedSha256: sha256(sceneDecoded), + snapshotEncodedSha256: sha256(snapshotEncoded), + snapshotDecodedSha256: sha256(snapshotDecoded), + }); + if (verifyDescriptor) { + const descriptor = JSON.parse(await readFile(join(root, "product-bank.json"), "utf8")); + for (const [key, value] of Object.entries(summary)) { + assert(descriptor[key] === value, `product descriptor ${key}`); + } + } + return summary; +} + +export async function writeFlowerboxProductBankDescriptor(root, summary, source) { + const descriptor = { + ...summary, + source, + transport: { + archiveFormat: "tar+gzip", + runtimeDownloadsArchive: false, + deployUnpacksStaticFiles: true, + }, + publicBoundary: { + microsoftSourceIncluded: false, + microsoftBinaryIncluded: false, + nativeCaptureIncluded: false, + oraclePacketIncluded: false, + }, + }; + await writeFile(join(root, "product-bank.json"), `${JSON.stringify(descriptor, null, 2)}\n`); + return descriptor; +} + +function addExpected(map, url, byteLength, hash) { + assert(typeof url === "string" && url.startsWith("/cssflower/") && !url.includes(".."), "asset URL"); + assert(Number.isSafeInteger(byteLength) && byteLength > 0 && /^[a-f0-9]{64}$/u.test(hash), "asset descriptor"); + const previous = map.get(url); + if (previous) assert(previous.byteLength === byteLength && previous.sha256 === hash, `alias ${url}`); + else map.set(url, { byteLength, sha256: hash }); +} + +function publicPath(root, url) { + return join(root, url.slice("/cssflower/".length)); +} + +async function walk(root) { + const paths = []; + for (const entry of await readdir(root, { withFileTypes: true })) { + const path = join(root, entry.name); + if (entry.isDirectory()) paths.push(...await walk(path)); + else if (entry.isFile()) paths.push(path); + else throw new Error(`Unsupported product-bank entry ${path}`); + } + return paths; +} + +async function parallel(values, concurrency, task) { + let next = 0; + await Promise.all(Array.from({ length: Math.min(concurrency, values.length) }, async () => { + while (next < values.length) { + const index = next++; + await task(values[index]); + } + })); +} + +function sha256(bytes) { + return createHash("sha256").update(bytes).digest("hex"); +} + +function count(text, expression) { + return (text.match(expression) ?? []).length; +} + +function assert(condition, label) { + if (!condition) throw new Error(`Flower Box product bank failed ${label}`); +} diff --git a/src/adapters/flowerbox/tools/smoke-browser.mjs b/src/adapters/flowerbox/tools/smoke-browser.mjs new file mode 100644 index 0000000..dceb504 --- /dev/null +++ b/src/adapters/flowerbox/tools/smoke-browser.mjs @@ -0,0 +1,134 @@ +#!/usr/bin/env node + +import { spawn } from "node:child_process"; +import { mkdir, writeFile } from "node:fs/promises"; +import { createServer } from "node:net"; +import { join, resolve } from "node:path"; +import { chromium } from "playwright"; +import { PNG } from "pngjs"; + +const repositoryRoot = resolve(import.meta.dirname, "..", "..", "..", ".."); +const deploy = process.argv.includes("--deploy"); +const evidenceRoot = join(repositoryRoot, ".local", "evidence", deploy + ? "flowerbox-deploy-smoke" + : "flowerbox-public-smoke"); +const screenshotPath = join(evidenceRoot, "flowerbox.png"); +const reportPath = join(evidenceRoot, "report.json"); +const port = await freePort(); +let serverOutput = ""; +const server = spawn("pnpm", deploy ? [ + "exec", "vite", "preview", "--host", "127.0.0.1", "--port", String(port), "--strictPort", + "--outDir", resolve(repositoryRoot, "dist/site"), +] : [ + "exec", "vite", "--config", "src/adapters/flowerbox/vite.config.mjs", + "--host", "127.0.0.1", "--port", String(port), "--strictPort", +], { cwd: repositoryRoot, stdio: ["ignore", "pipe", "pipe"] }); +server.stdout.on("data", (chunk) => { serverOutput += chunk.toString(); }); +server.stderr.on("data", (chunk) => { serverOutput += chunk.toString(); }); + +try { + await mkdir(evidenceRoot, { recursive: true }); + await waitFor(() => serverOutput.includes("Local:"), 20_000); + const browser = await chromium.launch({ headless: true }); + try { + const page = await browser.newPage({ viewport: { width: 1280, height: 800 }, deviceScaleFactor: 1 }); + const errors = []; + page.on("pageerror", (error) => errors.push(error.stack || error.message)); + page.on("console", (message) => { + if (message.type() === "error") errors.push(message.text()); + }); + await page.goto(`http://127.0.0.1:${port}/${deploy ? "flower/" : ""}`, { waitUntil: "domcontentloaded" }); + await page.waitForFunction(() => ["ready", "error"].includes(document.body.dataset.portStatus), null, { timeout: 30_000 }); + const proof = await page.evaluate(async () => { + const debug = globalThis.__cssFlowerDebug; + if (!debug?.ready) throw new Error(document.getElementById("status")?.textContent || "Flower Box debug API missing"); + debug.pause(); + const initial = debug.nodes(); + const root = initial.rotationRoot; + const leaves = [...initial.leaves]; + const rows = []; + for (const tick of [0, 102, 831, 9_330, 9_331]) { + await debug.setTick(tick); + await new Promise((resolveFrame) => requestAnimationFrame(() => requestAnimationFrame(resolveFrame))); + const current = debug.nodes(); + rows.push({ + tick, + sameRoot: current.rotationRoot === root, + sameLeaves: current.leaves.every((leaf, index) => leaf === leaves[index]), + stats: debug.stats(), + }); + } + debug.assertStableDomIdentity(); + return { + portStatus: document.body.dataset.portStatus, + stagePresentation: document.body.dataset.stagePresentation, + retainedLeafCount: document.querySelectorAll("[data-cssflower-retained-leaf]").length, + retainedRootCount: document.querySelectorAll("[data-cssflower-rotation-root]").length, + canvasCount: document.querySelectorAll("canvas").length, + svgCount: document.querySelectorAll("svg").length, + oraclePaneCount: document.querySelectorAll("#native-pane, #diff-pane").length, + rows, + finalStats: debug.stats(), + }; + }); + await page.screenshot({ path: screenshotPath }); + const visibility = inspectVisibility(PNG.sync.read(await page.screenshot())); + const report = { schema: "cssgraphics-flowerbox-browser-smoke@1", deploy, ...proof, visibility, errors, screenshotPath }; + assertProof(report); + await writeFile(reportPath, `${JSON.stringify(report, null, 2)}\n`); + process.stdout.write(`${JSON.stringify({ status: "pass", reportPath, screenshotPath, ...visibility, finalStats: report.finalStats }, null, 2)}\n`); + } finally { + await browser.close(); + } +} finally { + server.kill("SIGTERM"); +} + +function assertProof(report) { + const stats = report.finalStats; + if (report.portStatus !== "ready" || report.stagePresentation !== "product" || report.errors.length || + report.retainedLeafCount !== 1_200 || report.retainedRootCount !== 1 || report.canvasCount || report.svgCount || + report.oraclePaneCount || report.visibility.chromaticPixelCount < 1_000 || + report.rows.some((row) => !row.sameRoot || !row.sameLeaves) || + stats.runtimeGeometryConstructionCount !== 0 || stats.runtimeProjectionCalculationCount !== 0 || + stats.runtimeRasterizationCount !== 0 || stats.runtimeNormalCalculationCount !== 0 || + stats.runtimeLightingCalculationCount !== 0 || stats.runtimeDomGrowth !== false || + stats.projectedPageLoader?.residentPageCount > 2 || stats.projectedPageLoader?.errors?.length) { + throw new Error(`Flower Box browser smoke failed:\n${JSON.stringify(report, null, 2)}`); + } +} + +function inspectVisibility(png) { + let nonBlackPixelCount = 0; + let chromaticPixelCount = 0; + for (let offset = 0; offset < png.data.length; offset += 4) { + const red = png.data[offset]; + const green = png.data[offset + 1]; + const blue = png.data[offset + 2]; + if (red || green || blue) nonBlackPixelCount += 1; + if (Math.max(red, green, blue) - Math.min(red, green, blue) >= 12) chromaticPixelCount += 1; + } + return { nonBlackPixelCount, chromaticPixelCount }; +} + +function freePort() { + return new Promise((resolvePort, reject) => { + const server = createServer(); + server.listen(0, "127.0.0.1", () => { + const address = server.address(); + const port = typeof address === "object" && address ? address.port : 0; + server.close(() => resolvePort(port)); + }); + server.on("error", reject); + }); +} + +async function waitFor(predicate, timeoutMs) { + const start = Date.now(); + while (Date.now() - start < timeoutMs) { + if (server.exitCode !== null) throw new Error(`Vite exited early:\n${serverOutput}`); + if (predicate()) return; + await new Promise((resolveWait) => setTimeout(resolveWait, 100)); + } + throw new Error(`Timed out waiting for Vite:\n${serverOutput}`); +} diff --git a/src/adapters/flowerbox/tools/verify-product-bank.mjs b/src/adapters/flowerbox/tools/verify-product-bank.mjs new file mode 100644 index 0000000..f82aa44 --- /dev/null +++ b/src/adapters/flowerbox/tools/verify-product-bank.mjs @@ -0,0 +1,8 @@ +#!/usr/bin/env node + +import { resolve } from "node:path"; +import { inspectFlowerboxProductBank } from "./productBank.mjs"; + +const root = resolve(process.argv[2] ?? "build/generated/public/cssflower"); +const summary = await inspectFlowerboxProductBank(root); +process.stdout.write(`${JSON.stringify({ status: "pass", root, ...summary }, null, 2)}\n`); diff --git a/src/adapters/flowerbox/vite.config.mjs b/src/adapters/flowerbox/vite.config.mjs new file mode 100644 index 0000000..37d6b0f --- /dev/null +++ b/src/adapters/flowerbox/vite.config.mjs @@ -0,0 +1,36 @@ +import { cp, mkdir } from "node:fs/promises"; +import { dirname, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; +import { defineConfig } from "vite"; + +const adapterRoot = dirname(fileURLToPath(import.meta.url)); +const repositoryRoot = resolve(adapterRoot, "..", "..", ".."); +const generatedPublicDir = resolve( + process.env.CSSFLOWER_GENERATED_PUBLIC_DIR ?? + resolve(repositoryRoot, "build/generated/public"), +); +const deployBuild = process.env.CSSFLOWER_DEPLOY_BUILD === "1"; + +export default defineConfig({ + base: deployBuild ? "/flower/" : "/", + root: adapterRoot, + publicDir: deployBuild ? resolve(adapterRoot, "public") : generatedPublicDir, + plugins: deployBuild ? [{ + name: "flowerbox-netlify-assets", + async closeBundle() { + await mkdir(resolve(repositoryRoot, "dist/site"), { recursive: true }); + await cp( + resolve(generatedPublicDir, "cssflower"), + resolve(repositoryRoot, "dist/site/cssflower"), + { recursive: true, force: true }, + ); + }, + }] : [], + server: { host: "127.0.0.1" }, + preview: { host: "127.0.0.1" }, + build: { + target: "es2022", + outDir: resolve(repositoryRoot, deployBuild ? "dist/site/flower" : "dist/flowerbox"), + emptyOutDir: true, + }, +}); From 5f8a1b0d55e067b27e5385b54f958857f0eefa60 Mon Sep 17 00:00:00 2001 From: alowpoly Date: Thu, 6 Aug 2026 15:08:06 -0300 Subject: [PATCH 2/5] perf(flowerbox): pack prepared visual pages --- netlify.toml | 5 + scripts/prepare/flowerbox-product-bank.mjs | 4 + src/adapters/flowerbox/README.md | 9 +- .../flowerbox/prepared-bank.lock.json | 23 +- .../src/cssflower/manifestClient.mjs | 239 ++++++++++++++---- .../src/cssflower/preparedPlayback.mjs | 22 +- .../flowerbox/tools/audit-runtime-surface.mjs | 10 + .../flowerbox/tools/package-product-bank.mjs | 118 +++++++++ src/adapters/flowerbox/tools/productBank.mjs | 101 +++++++- .../flowerbox/tools/smoke-browser.mjs | 30 ++- 10 files changed, 485 insertions(+), 76 deletions(-) diff --git a/netlify.toml b/netlify.toml index f5fa88d..eccc7f8 100644 --- a/netlify.toml +++ b/netlify.toml @@ -10,3 +10,8 @@ to = "/pipes/" status = 302 force = true + +[[headers]] + for = "/cssflower/assets/projected/*" + [headers.values] + Cache-Control = "public, max-age=31536000, immutable" diff --git a/scripts/prepare/flowerbox-product-bank.mjs b/scripts/prepare/flowerbox-product-bank.mjs index 22c4f79..0f9584c 100644 --- a/scripts/prepare/flowerbox-product-bank.mjs +++ b/scripts/prepare/flowerbox-product-bank.mjs @@ -12,6 +12,10 @@ const lock = JSON.parse(await readFile( join(repositoryRoot, "src/adapters/flowerbox/prepared-bank.lock.json"), "utf8", )); +if (lock.schema !== "cssflower-prepared-bank-lock@2" || lock.projectedVisualPackCount !== 37 || + lock.projectedVisualPackAssetCount !== 37) { + throw new Error("Flower Box prepared-bank lock does not bind the visual-pack transport"); +} const generatedRoot = resolve( process.env.CSSFLOWER_GENERATED_ROOT ?? join(repositoryRoot, "build", "generated"), ); diff --git a/src/adapters/flowerbox/README.md b/src/adapters/flowerbox/README.md index 2bbe4ec..b263ec1 100644 --- a/src/adapters/flowerbox/README.md +++ b/src/adapters/flowerbox/README.md @@ -4,10 +4,13 @@ An independently authored PolyCSS reconstruction of the classic 1995 Flower Box. The complete default-cube bloom and rotation cycle is rendered through 1,200 stable retained HTML triangle leaves and one retained rotation root. -The browser loads one prepared snapshot and selects hash-bound prepared AVIF -pages, source-order leaf windows, and root transforms. It does not construct +The browser loads one prepared snapshot and streams 37 hash-bound visual packs +aligned to the prepared 64-page layout blocks. It slices the original AVIF +bytes from those packs, decodes only the active and next pages, and selects +prepared source-order leaf windows and root transforms. It does not construct geometry, project vertices, calculate normals or lighting, rasterize, or grow -the DOM at runtime. +the DOM at runtime. Playback presents every prepared state in order; a delayed +pack pauses the clock instead of dropping states to catch up. From the repository root: diff --git a/src/adapters/flowerbox/prepared-bank.lock.json b/src/adapters/flowerbox/prepared-bank.lock.json index 1d889d6..2b63b7a 100644 --- a/src/adapters/flowerbox/prepared-bank.lock.json +++ b/src/adapters/flowerbox/prepared-bank.lock.json @@ -1,20 +1,23 @@ { - "schema": "cssflower-prepared-bank-lock@1", - "tag": "cssflower-product-q40-v1", - "asset": "cssflower-product-q40-v1.tar.gz", - "url": "https://github.com/layoutit/cssGraphics/releases/download/cssflower-product-q40-v1/cssflower-product-q40-v1.tar.gz", - "archiveByteLength": 29334538, - "archiveSha256": "3e623ef672e3a2754e3ba58d53f87e48b45d625875d57c4110669c64c21867ff", - "productClosureBytes": 29579964, - "productClosureSha256": "8d91b0370d3ee16a6ddeff6d0f5b95f26b628ac6d961f31d11290a549f803b31", - "productDescriptorByteLength": 1606, - "productDescriptorSha256": "2be1268273cadb07eb0460386efa15a6262617d33bca8cb479ea01ddd07b1d1b", + "schema": "cssflower-prepared-bank-lock@2", + "tag": "cssflower-product-q40-v2", + "asset": "cssflower-product-q40-v2.tar.gz", + "url": "https://github.com/layoutit/cssGraphics/releases/download/cssflower-product-q40-v2/cssflower-product-q40-v2.tar.gz", + "archiveByteLength": 29655480, + "archiveSha256": "290570fda718e96754f4bb57fdcb1dfc7a1184b6a5d1510088c7f18d96c29fa4", + "productClosureBytes": 30249145, + "productClosureSha256": "0663e211844001fca1bf62a5915791d8ec7d23c38616ea9c80b29874c8835de1", + "productDescriptorByteLength": 1787, + "productDescriptorSha256": "cdc4d4db07584fb0582cdf62bc9aafdddb8a7a882652e64148fba5e7bcd39219", "retainedTriangleLeafCount": 1200, "retainedRotationRootCount": 1, "timelineStateCount": 9331, "projectedPageCount": 2333, "projectedAtlasAssetCount": 2273, "projectedLayoutBlockCount": 37, + "projectedVisualPackCount": 37, + "projectedVisualPackAssetCount": 37, + "projectedVisualPackBytes": 29407427, "visualEncoding": { "codec": "AVIF", "quality": 40, diff --git a/src/adapters/flowerbox/src/cssflower/manifestClient.mjs b/src/adapters/flowerbox/src/cssflower/manifestClient.mjs index 8789206..a3c0fce 100644 --- a/src/adapters/flowerbox/src/cssflower/manifestClient.mjs +++ b/src/adapters/flowerbox/src/cssflower/manifestClient.mjs @@ -113,15 +113,28 @@ export async function loadPreparedScene(manifest, routeState) { } function createPreparedProjectedPageLoader(projected) { + const transport = projected.transport; + const cycleStartPageIndex = projected.pages.findIndex((page) => + projected.cycleStartState >= page.startStateIndex && + projected.cycleStartState < page.startStateIndex + page.usedFrameCount); + if (cycleStartPageIndex < 0) throw new Error("Prepared cssFlower cycle-start page is missing"); const records = new Map(); - const layoutBlocks = new Map(); + const packRecords = new Map(); const errors = []; let currentPageIndex = 0; let pageLoadCount = 0; let pageReleaseCount = 0; + let packLoadCount = 0; + let packReleaseCount = 0; + let earlyPackPrefetchCount = 0; let residentDecodedBytes = 0; let peakResidentDecodedBytes = 0; + let residentEncodedPackBytes = 0; + let peakResidentEncodedPackBytes = 0; + let peakResidentPackCount = 0; let desiredPageIndices = new Set(); + let desiredPackIndices = new Set(); + let earlyPrefetchPackIndex = null; let destroyed = false; async function ensure(pageIndex) { @@ -132,17 +145,17 @@ function createPreparedProjectedPageLoader(projected) { if (existing?.url) return existing; if (existing?.promise) return existing.promise; const promise = (async () => { - const block = projected.layoutBlocks[page.layout.blockIndex]; - const [atlasBytes, layoutBlock] = await Promise.all([ - fetchBytes(page.atlas.assetUrl, { - notFoundMessage: `Missing prepared Flower Box projected atlas ${pageIndex}. Run pnpm prepare:flowerbox:artifact first.`, - }), - ensureLayoutBlock(block), - ]); - if (atlasBytes.byteLength !== page.atlas.byteLength) { + const pack = transport.packs[page.layout.blockIndex]; + const packRecord = await ensurePack(pack.index); + const atlasSlice = pack.atlasSlices[pageIndex - pack.startPageIndex]; + const atlasBytes = packRecord.bytes.subarray( + atlasSlice.byteOffset, + atlasSlice.byteOffset + atlasSlice.byteLength, + ); + if (atlasBytes.byteLength !== page.atlas.byteLength || atlasSlice.sha256 !== page.atlas.sha256) { throw new Error(`Generated cssFlower projected atlas ${pageIndex} byte length is invalid.`); } - const layoutBytes = layoutBlock.bytes.subarray( + const layoutBytes = packRecord.layoutBytes.subarray( page.layout.blockByteOffset, page.layout.blockByteOffset + page.layout.byteLength, ); @@ -165,7 +178,7 @@ function createPreparedProjectedPageLoader(projected) { pageIndex, url, image, - layoutBlockIndex: block.index, + layoutBlockIndex: pack.index, layoutValues: new Int16Array( layoutBytes.buffer, layoutBytes.byteOffset, @@ -190,43 +203,115 @@ function createPreparedProjectedPageLoader(projected) { } } - async function ensureLayoutBlock(block) { - if (!block || projected.layoutBlocks[block.index] !== block) { - throw new Error("Prepared cssFlower shared layout block is missing"); - } - const existing = layoutBlocks.get(block.index); + async function ensurePack(packIndex) { + const pack = transport.packs[packIndex]; + if (!pack || pack.index !== packIndex) throw new Error("Prepared cssFlower visual pack is missing"); + const existing = packRecords.get(packIndex); if (existing?.bytes) return existing; if (existing?.promise) return existing.promise; + const controller = new AbortController(); const promise = (async () => { - const compressed = await fetchBytes(block.assetUrl, { - notFoundMessage: `Missing prepared Flower Box shared layout block ${block.index}. Run pnpm prepare:flowerbox:artifact first.`, - }); - if (compressed.byteLength !== block.byteLength) { - throw new Error(`Generated cssFlower shared layout block ${block.index} byte length is invalid.`); + const bytes = new Uint8Array(await fetchBytes(pack.assetUrl, { + notFoundMessage: `Missing prepared Flower Box visual pack ${packIndex}. Run pnpm prepare:flowerbox:artifact first.`, + signal: controller.signal, + })); + if (bytes.byteLength !== pack.byteLength) { + throw new Error(`Generated cssFlower visual pack ${packIndex} byte length is invalid.`); } - await assertSha256(compressed, block.sha256, `shared layout block ${block.index}`); - const bytes = await decompressGzip(compressed); - if (bytes.byteLength !== block.decodedByteLength) { - throw new Error(`Generated cssFlower shared layout block ${block.index} decoded byte length is invalid.`); + await assertSha256(bytes, pack.sha256, `visual pack ${packIndex}`); + const compressedLayout = bytes.subarray( + pack.layout.byteOffset, + pack.layout.byteOffset + pack.layout.byteLength, + ); + if (compressedLayout.byteLength !== pack.layout.byteLength) { + throw new Error(`Generated cssFlower visual pack ${packIndex} layout slice is invalid.`); } - await assertSha256(bytes, block.decodedSha256, `decoded shared layout block ${block.index}`); - const record = Object.freeze({ index: block.index, bytes }); - layoutBlocks.set(block.index, record); - const desiredBlocks = new Set([...desiredPageIndices].map((pageIndex) => ( - projected.pages[pageIndex].layout.blockIndex - ))); - if (destroyed || !desiredBlocks.has(block.index)) layoutBlocks.delete(block.index); + await assertSha256(compressedLayout, pack.layout.sha256, `visual pack ${packIndex} layout`); + const layoutBytes = await decompressGzip(compressedLayout); + if (layoutBytes.byteLength !== pack.layout.decodedByteLength) { + throw new Error(`Generated cssFlower visual pack ${packIndex} decoded layout byte length is invalid.`); + } + await assertSha256( + layoutBytes, + pack.layout.decodedSha256, + `decoded visual pack ${packIndex} layout`, + ); + const record = Object.freeze({ index: packIndex, bytes, layoutBytes }); + packRecords.set(packIndex, record); + packLoadCount += 1; + residentEncodedPackBytes += bytes.byteLength; + peakResidentEncodedPackBytes = Math.max(peakResidentEncodedPackBytes, residentEncodedPackBytes); + peakResidentPackCount = Math.max(peakResidentPackCount, residentPackCount()); + if (destroyed || !desiredPackIndices.has(packIndex)) releasePack(packIndex, record); return record; })(); - layoutBlocks.set(block.index, { promise }); + packRecords.set(packIndex, { promise, controller }); try { return await promise; } catch (error) { - if (layoutBlocks.get(block.index)?.promise === promise) layoutBlocks.delete(block.index); + if (packRecords.get(packIndex)?.promise === promise) packRecords.delete(packIndex); + if (error?.name !== "AbortError") errors.push(String(error?.message || error)); throw error; } } + function releasePack(packIndex, record) { + if (!record?.bytes || packRecords.get(packIndex) !== record) return; + packRecords.delete(packIndex); + packReleaseCount += 1; + residentEncodedPackBytes -= record.bytes.byteLength; + } + + function reconcilePackResidency() { + const keepPacks = new Set([...desiredPageIndices].map(packIndexForPage)); + if (earlyPrefetchPackIndex !== null) keepPacks.add(earlyPrefetchPackIndex); + if (keepPacks.size > transport.compressedResidentPackBudget) { + throw new Error("Prepared cssFlower visual pack residency exceeds its bound"); + } + desiredPackIndices = keepPacks; + for (const [packIndex, record] of packRecords) { + if (keepPacks.has(packIndex)) continue; + if (record?.bytes) releasePack(packIndex, record); + else if (record?.controller) { + record.controller.abort(); + packRecords.delete(packIndex); + } + } + } + + function residentPackCount() { + return [...packRecords.values()].filter((record) => record?.bytes).length; + } + + function packIndexForPage(pageIndex) { + const page = projected.pages[pageIndex]; + if (!page) throw new RangeError(`Prepared cssFlower projected page ${pageIndex} is missing`); + return page.layout.blockIndex; + } + + function nextPackIndex(packIndex) { + if (packIndex + 1 < transport.packCount) return packIndex + 1; + return packIndexForPage(cycleStartPageIndex); + } + + function pageAfter(pageIndex) { + if (pageIndex + 1 < projected.pageCount) return pageIndex + 1; + return cycleStartPageIndex; + } + + function maybePrefetchPack(pageIndex) { + const packIndex = packIndexForPage(pageIndex); + const pack = transport.packs[packIndex]; + const offset = pageIndex - pack.startPageIndex; + earlyPrefetchPackIndex = offset >= transport.earlyPrefetchPageOffset + ? nextPackIndex(packIndex) + : null; + reconcilePackResidency(); + if (earlyPrefetchPackIndex === null) return; + if (!packRecords.has(earlyPrefetchPackIndex)) earlyPackPrefetchCount += 1; + void ensurePack(earlyPrefetchPackIndex).catch(() => undefined); + } + function releaseRecord(pageIndex, record) { if (!record?.url || records.get(pageIndex) !== record) return; record.image.removeAttribute("src"); @@ -242,11 +327,7 @@ function createPreparedProjectedPageLoader(projected) { if (keep.has(pageIndex) || !record?.url) continue; releaseRecord(pageIndex, record); } - const keepBlocks = new Set([...keep].map((pageIndex) => projected.pages[pageIndex].layout.blockIndex)); - for (const [blockIndex, record] of layoutBlocks) { - if (keepBlocks.has(blockIndex) || !record?.bytes) continue; - layoutBlocks.delete(blockIndex); - } + reconcilePackResidency(); } function prefetch(pageIndex) { @@ -257,6 +338,7 @@ function createPreparedProjectedPageLoader(projected) { return Object.freeze({ async prime(pageIndex, nextPageIndex) { currentPageIndex = pageIndex; + earlyPrefetchPackIndex = null; releaseExcept(new Set([pageIndex, nextPageIndex])); await Promise.all([ensure(pageIndex), ensure(nextPageIndex)]); }, @@ -273,6 +355,7 @@ function createPreparedProjectedPageLoader(projected) { return record.layoutValues; }, async activate(pageIndex, nextPageIndex) { + if (pageIndex !== pageAfter(currentPageIndex)) earlyPrefetchPackIndex = null; releaseExcept(new Set([currentPageIndex, pageIndex])); const record = await ensure(pageIndex); currentPageIndex = pageIndex; @@ -284,10 +367,11 @@ function createPreparedProjectedPageLoader(projected) { } releaseExcept(new Set([pageIndex, nextPageIndex])); prefetch(nextPageIndex); + maybePrefetchPack(pageIndex); }, stats() { return Object.freeze({ - schema: "cssflower-prepared-projected-page-loader@1", + schema: "cssflower-prepared-projected-page-loader@2", currentPageIndex, pageLoadCount, pageReleaseCount, @@ -297,9 +381,19 @@ function createPreparedProjectedPageLoader(projected) { residentPageBudget: projected.decodedResidentPageBudget, peakPageBudget: projected.decodedPeakPageBudget, desiredPageIndices: Object.freeze([...desiredPageIndices].sort((left, right) => left - right)), - residentLayoutBlockCount: [...layoutBlocks.values()].filter((record) => record?.bytes).length, - residentDecodedLayoutBytes: [...layoutBlocks.values()].reduce( - (sum, record) => sum + (record?.bytes?.byteLength ?? 0), + packLoadCount, + packReleaseCount, + earlyPackPrefetchCount, + residentPackCount: residentPackCount(), + residentEncodedPackBytes, + peakResidentEncodedPackBytes, + peakResidentPackCount, + residentPackBudget: transport.compressedResidentPackBudget, + desiredPackIndices: Object.freeze([...desiredPackIndices].sort((left, right) => left - right)), + earlyPrefetchPackIndex, + residentLayoutBlockCount: residentPackCount(), + residentDecodedLayoutBytes: [...packRecords.values()].reduce( + (sum, record) => sum + (record?.layoutBytes?.byteLength ?? 0), 0, ), errors: Object.freeze([...errors]), @@ -307,6 +401,7 @@ function createPreparedProjectedPageLoader(projected) { }, destroy() { destroyed = true; + earlyPrefetchPackIndex = null; releaseExcept(new Set()); }, }); @@ -342,6 +437,7 @@ function validateProjectedPixels(playback) { projected.runtimeLightingCalculation !== false || projected.runtimeDomGrowth !== false) { throw new Error("Complete prepared cssFlower projected-pixel playback is required"); } + validateProjectedTransport(projected); let layoutBlockDecodedBytes = 0; for (let blockIndex = 0; blockIndex < projected.layoutBlocks.length; blockIndex += 1) { const block = projected.layoutBlocks[blockIndex]; @@ -417,6 +513,59 @@ function validateProjectedPixels(playback) { } } +function validateProjectedTransport(projected) { + const transport = projected.transport; + if (transport?.schema !== "cssflower-prepared-visual-pack-transport@1" || + transport.representation !== "layout-block-aligned-exact-byte-slices" || + transport.packCount !== projected.layoutBlocks.length || transport.packCount !== 37 || + transport.blockPageCount !== projected.layoutBlockPageCount || + transport.compressedResidentPackBudget !== 2 || transport.earlyPrefetchPageOffset !== 16 || + transport.logicalContentAddressedAtlasBytes !== projected.contentAddressedAtlasBytes || + transport.logicalCompressedLayoutBytes !== projected.compressedLayoutBytes || + transport.runtimeGeometryConstruction !== false || transport.runtimeProjection !== false || + transport.runtimeRasterization !== false || transport.runtimeLightingCalculation !== false || + transport.packs?.length !== transport.packCount) { + throw new Error("Complete prepared cssFlower visual-pack transport is required"); + } + let totalPackBytes = 0; + let maximumPackBytes = 0; + for (let packIndex = 0; packIndex < transport.packs.length; packIndex += 1) { + const pack = transport.packs[packIndex]; + const block = projected.layoutBlocks[packIndex]; + if (pack?.schema !== "cssflower-prepared-visual-pack@1" || pack.index !== packIndex || + pack.startPageIndex !== block.startPageIndex || pack.pageCount !== block.pageCount || + !Number.isSafeInteger(pack.byteLength) || pack.byteLength < 1 || + !/^[a-f0-9]{64}$/.test(pack.sha256 ?? "") || + pack.assetUrl !== `/cssflower/assets/projected/visual-pack-${pack.sha256}.bin` || + pack.layout?.byteOffset !== 0 || pack.layout.byteLength !== block.byteLength || + pack.layout.sha256 !== block.sha256 || pack.layout.decodedByteLength !== block.decodedByteLength || + pack.layout.decodedSha256 !== block.decodedSha256 || + pack.atlasSlices?.length !== pack.pageCount) { + throw new Error(`Prepared cssFlower visual pack ${packIndex} is invalid`); + } + let expectedOffset = pack.layout.byteLength; + for (let localPageIndex = 0; localPageIndex < pack.pageCount; localPageIndex += 1) { + const pageIndex = pack.startPageIndex + localPageIndex; + const page = projected.pages[pageIndex]; + const slice = pack.atlasSlices[localPageIndex]; + if (slice?.pageIndex !== pageIndex || slice.byteOffset !== expectedOffset || + slice.byteLength !== page.atlas.byteLength || slice.sha256 !== page.atlas.sha256 || + slice.mimeType !== page.atlas.mimeType) { + throw new Error(`Prepared cssFlower visual pack ${packIndex} atlas slice ${pageIndex} is invalid`); + } + expectedOffset += slice.byteLength; + } + if (expectedOffset !== pack.byteLength) { + throw new Error(`Prepared cssFlower visual pack ${packIndex} byte coverage is incomplete`); + } + totalPackBytes += pack.byteLength; + maximumPackBytes = Math.max(maximumPackBytes, pack.byteLength); + } + if (totalPackBytes !== transport.totalPackBytes || maximumPackBytes !== transport.maximumPackBytes) { + throw new Error("Prepared cssFlower visual-pack aggregate bytes are invalid"); + } +} + function arraysEqual(actual, expected) { return Array.isArray(actual) && actual.length === expected.length && actual.every((value, index) => value === expected[index]); @@ -455,8 +604,8 @@ async function fetchText(url, { notFoundMessage = "" } = {}) { return response.text(); } -async function fetchBytes(url, { notFoundMessage = "" } = {}) { - const response = await fetch(url); +async function fetchBytes(url, { notFoundMessage = "", signal } = {}) { + const response = await fetch(url, { signal }); if (!response.ok) { if (response.status === 404 && notFoundMessage) throw new Error(notFoundMessage); throw new Error("Failed to load " + url + ": " + response.status); diff --git a/src/adapters/flowerbox/src/cssflower/preparedPlayback.mjs b/src/adapters/flowerbox/src/cssflower/preparedPlayback.mjs index 076e48a..3594dad 100644 --- a/src/adapters/flowerbox/src/cssflower/preparedPlayback.mjs +++ b/src/adapters/flowerbox/src/cssflower/preparedPlayback.mjs @@ -14,6 +14,7 @@ export async function createCssflowerPreparedPlayer(options) { const projected = playback.projectedPixels; const requestFrame = options.requestFrame ?? globalThis.requestAnimationFrame.bind(globalThis); const cancelFrame = options.cancelFrame ?? globalThis.cancelAnimationFrame.bind(globalThis); + const now = options.now ?? (() => globalThis.performance.now()); const frameMilliseconds = 1000 / playback.sourceTicksPerSecond; let paused = true; let request = null; @@ -32,6 +33,9 @@ export async function createCssflowerPreparedPlayer(options) { let preparedPageLayoutAdoptions = 0; let preparedPageBoundaryLeafStyleWrites = 0; let runtimeSchedulerCallbacks = 0; + let runtimeSchedulerStateTransitions = 0; + let runtimeSchedulerLateResetCount = 0; + let runtimeSchedulerMaximumLatenessMs = 0; const morphTarget = createPolyMorphPreparedDomTarget({ model: { @@ -110,9 +114,17 @@ export async function createCssflowerPreparedPlayer(options) { 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; + const scheduledAt = nextFrameAt; + await applyTick(globalTick + 1); + runtimeSchedulerStateTransitions += 1; + nextFrameAt = scheduledAt + frameMilliseconds; + const completedAt = now(); + if (completedAt > nextFrameAt) { + const lateness = completedAt - nextFrameAt; + runtimeSchedulerLateResetCount += 1; + runtimeSchedulerMaximumLatenessMs = Math.max(runtimeSchedulerMaximumLatenessMs, lateness); + nextFrameAt = completedAt + frameMilliseconds; + } } if (!paused) request = requestFrame(loop); } @@ -189,6 +201,10 @@ export async function createCssflowerPreparedPlayer(options) { runtimePreparedPageBoundaryLeafStyleWrites: preparedPageBoundaryLeafStyleWrites, projectedPageLoader: projectedPages.stats(), runtimeSchedulerCallbacks, + runtimeSchedulerStateTransitions, + runtimeSchedulerSkippedPreparedStateCount: 0, + runtimeSchedulerLateResetCount, + runtimeSchedulerMaximumLatenessMs, runtimePolygonConstructionCount: 0, runtimeGeometryConstructionCount: 0, runtimeRadialProjectionCount: 0, diff --git a/src/adapters/flowerbox/tools/audit-runtime-surface.mjs b/src/adapters/flowerbox/tools/audit-runtime-surface.mjs index 2b2d0cc..6aa2755 100644 --- a/src/adapters/flowerbox/tools/audit-runtime-surface.mjs +++ b/src/adapters/flowerbox/tools/audit-runtime-surface.mjs @@ -36,12 +36,22 @@ 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({")) failures.push("PolyCSS Morph prepared target is missing"); +if (!playback.includes("runtimeSchedulerSkippedPreparedStateCount: 0") || playback.includes("elapsedSteps")) { + failures.push("Prepared playback does not retain sequential no-skip scheduling"); +} if (/\b(?:document|DOMParser|MutationObserver)\b|createElement|appendChild|replaceChildren/u.test(playback)) { failures.push("Prepared playback constructs DOM"); } +const manifestClient = sources.get("src/cssflower/manifestClient.mjs"); +if (!manifestClient.includes("cssflower-prepared-visual-pack-transport@1")) { + failures.push("Prepared visual-pack transport is missing"); +} const bank = await inspectFlowerboxProductBank(join(repositoryRoot, "build", "generated", "public", "cssflower")); if (bank.closureBytes >= 31_000_000) failures.push(`Product bank is too large: ${bank.closureBytes}`); +if (bank.projectedVisualPackCount !== 37 || bank.projectedVisualPackAssetCount !== 37) { + failures.push("Product bank does not contain the 37 block-aligned visual packs"); +} const report = { schema: "cssgraphics-flowerbox-runtime-audit@1", status: failures.length === 0 ? "pass" : "fail", diff --git a/src/adapters/flowerbox/tools/package-product-bank.mjs b/src/adapters/flowerbox/tools/package-product-bank.mjs index a4d9edf..38380bc 100644 --- a/src/adapters/flowerbox/tools/package-product-bank.mjs +++ b/src/adapters/flowerbox/tools/package-product-bank.mjs @@ -47,6 +47,11 @@ scene.warnings = [ "Independent source-informed PolyCSS experiment; Microsoft source, binaries, captures, and oracle packets are not packaged.", ]; +const visualPackSummary = await packageProjectedVisualPacks( + stagingRoot, + scene.playback.projectedPixels, +); + 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); @@ -60,6 +65,8 @@ manifest.assets = { pageCount: scene.playback.projectedPixels.pageCount, atlasAssetCount: scene.metrics.preparedProjectedPixelAtlasAssetCount, layoutBlockCount: scene.playback.projectedPixels.layoutBlocks.length, + visualPackCount: visualPackSummary.packCount, + visualPackBytes: visualPackSummary.totalPackBytes, visualEncoding: scene.playback.projectedPixels.visualEncoding, }, }; @@ -92,6 +99,7 @@ await writeFlowerboxProductBankDescriptor(stagingRoot, summary, { "oracle metadata and state evidence", "prepare-only mesh geometry", "ignored transform-asset descriptor", + "individual projected atlas and layout transport files", ], }); await inspectFlowerboxProductBank(stagingRoot); @@ -111,6 +119,116 @@ function transportAsset(id, url, decoded, encoded) { }; } +async function packageProjectedVisualPacks(root, projected) { + const pageCount = projected?.pageCount; + const blockPageCount = projected?.layoutBlockPageCount; + if (!Number.isSafeInteger(pageCount) || pageCount < 1 || blockPageCount !== 64 || + projected.pages?.length !== pageCount || !Array.isArray(projected.layoutBlocks) || + projected.layoutBlocks.length !== Math.ceil(pageCount / blockPageCount)) { + throw new Error("Source Flower Box projected bank cannot be packed"); + } + + const sourceAssets = new Set(); + const assetCache = new Map(); + const packs = []; + let totalPackBytes = 0; + let maximumPackBytes = 0; + for (const block of projected.layoutBlocks) { + if (block.index !== packs.length || block.startPageIndex !== block.index * blockPageCount) { + throw new Error(`Source Flower Box layout block ${block.index} is out of order`); + } + const layoutBytes = await readSourceAsset(block.assetUrl, block.byteLength, block.sha256); + const chunks = [layoutBytes]; + const layout = { + byteOffset: 0, + byteLength: layoutBytes.length, + sha256: block.sha256, + decodedByteLength: block.decodedByteLength, + decodedSha256: block.decodedSha256, + }; + const atlasSlices = []; + let byteOffset = layoutBytes.length; + for (let localPageIndex = 0; localPageIndex < block.pageCount; localPageIndex += 1) { + const pageIndex = block.startPageIndex + localPageIndex; + const page = projected.pages[pageIndex]; + if (page?.index !== pageIndex || page.layout?.blockIndex !== block.index) { + throw new Error(`Source Flower Box projected page ${pageIndex} is not aligned to its layout block`); + } + const atlasBytes = await readSourceAsset( + page.atlas.assetUrl, + page.atlas.byteLength, + page.atlas.sha256, + ); + chunks.push(atlasBytes); + atlasSlices.push({ + pageIndex, + byteOffset, + byteLength: atlasBytes.length, + sha256: page.atlas.sha256, + mimeType: page.atlas.mimeType, + }); + byteOffset += atlasBytes.length; + } + const packBytes = Buffer.concat(chunks); + const packSha256 = sha256(packBytes); + const assetUrl = `/cssflower/assets/projected/visual-pack-${packSha256}.bin`; + await writeFile(publicAssetPath(root, assetUrl), packBytes); + packs.push({ + schema: "cssflower-prepared-visual-pack@1", + index: block.index, + startPageIndex: block.startPageIndex, + pageCount: block.pageCount, + assetUrl, + byteLength: packBytes.length, + sha256: packSha256, + layout, + atlasSlices, + }); + totalPackBytes += packBytes.length; + maximumPackBytes = Math.max(maximumPackBytes, packBytes.length); + } + + for (const url of sourceAssets) await rm(publicAssetPath(root, url), { force: true }); + projected.transport = { + schema: "cssflower-prepared-visual-pack-transport@1", + representation: "layout-block-aligned-exact-byte-slices", + packCount: packs.length, + blockPageCount, + compressedResidentPackBudget: 2, + earlyPrefetchPageOffset: 16, + totalPackBytes, + maximumPackBytes, + logicalContentAddressedAtlasBytes: projected.contentAddressedAtlasBytes, + logicalCompressedLayoutBytes: projected.compressedLayoutBytes, + runtimeGeometryConstruction: false, + runtimeProjection: false, + runtimeRasterization: false, + runtimeLightingCalculation: false, + packs, + }; + return { packCount: packs.length, totalPackBytes, maximumPackBytes }; + + async function readSourceAsset(url, expectedByteLength, expectedSha256) { + sourceAssets.add(url); + let bytes = assetCache.get(url); + if (!bytes) { + bytes = await readFile(publicAssetPath(root, url)); + assetCache.set(url, bytes); + } + if (bytes.length !== expectedByteLength || sha256(bytes) !== expectedSha256) { + throw new Error(`Source Flower Box projected asset identity mismatch: ${url}`); + } + return bytes; + } +} + +function publicAssetPath(root, url) { + if (typeof url !== "string" || !url.startsWith("/cssflower/") || url.includes("..")) { + throw new Error(`Unsafe Flower Box projected asset URL: ${url}`); + } + return join(root, url.slice("/cssflower/".length)); +} + function parseArgs(argv) { const parsed = {}; for (let index = 0; index < argv.length; index += 1) { diff --git a/src/adapters/flowerbox/tools/productBank.mjs b/src/adapters/flowerbox/tools/productBank.mjs index d0c84c5..028e752 100644 --- a/src/adapters/flowerbox/tools/productBank.mjs +++ b/src/adapters/flowerbox/tools/productBank.mjs @@ -3,7 +3,7 @@ import { readFile, readdir, writeFile } from "node:fs/promises"; import { join, relative, sep } from "node:path"; import { gunzipSync } from "node:zlib"; -export const FLOWERBOX_PRODUCT_BANK_SCHEMA = "cssflower-product-bank@1"; +export const FLOWERBOX_PRODUCT_BANK_SCHEMA = "cssflower-product-bank@2"; export async function inspectFlowerboxProductBank(root, { verifyDescriptor = true } = {}) { const manifestBytes = await readFile(join(root, "manifest.json")); @@ -50,22 +50,16 @@ export async function inspectFlowerboxProductBank(root, { verifyDescriptor = tru assert(count(snapshot, /<(?:script|canvas|svg)\b/giu) === 0, "snapshot forbidden elements"); assert(sha256(snapshotDecoded) === entry.snapshot.sha256, "snapshot decoded identity"); - const expectedAssets = new Map(); - for (const page of projected.pages) { - addExpected(expectedAssets, page.atlas.assetUrl, page.atlas.byteLength, page.atlas.sha256); - } - for (const block of projected.layoutBlocks) { - addExpected(expectedAssets, block.assetUrl, block.byteLength, block.sha256); - } - await parallel([...expectedAssets.entries()], 24, async ([url, expected]) => { - const bytes = await readFile(publicPath(root, url)); - assert(bytes.length === expected.byteLength && sha256(bytes) === expected.sha256, `asset ${url}`); - }); + const visualPacks = await inspectVisualPacks(root, projected); const files = (await walk(root)) .map((path) => relative(root, path).split(sep).join("/")) .filter((path) => path !== "product-bank.json") .sort(); + const projectedAssetFiles = files.filter((path) => path.startsWith("assets/projected/")); + assert(projectedAssetFiles.length === visualPacks.assetCount && projectedAssetFiles.every((path) => + /^assets\/projected\/visual-pack-[a-f0-9]{64}\.bin$/u.test(path)), + "pack-only projected transport"); const closure = createHash("sha256"); let closureBytes = 0; for (const path of files) { @@ -84,7 +78,10 @@ export async function inspectFlowerboxProductBank(root, { verifyDescriptor = tru projectedPageCount: projected.pageCount, projectedAtlasAssetCount: new Set(projected.pages.map((page) => page.atlas.assetUrl)).size, projectedLayoutBlockCount: projected.layoutBlocks.length, - projectedVisualBankBytes: projected.contentAddressedAtlasBytes + projected.compressedLayoutBytes, + projectedVisualPackCount: visualPacks.packCount, + projectedVisualPackAssetCount: visualPacks.assetCount, + projectedVisualPackBytes: visualPacks.totalPackBytes, + projectedLogicalVisualBankBytes: projected.contentAddressedAtlasBytes + projected.compressedLayoutBytes, sceneEncodedSha256: sha256(sceneEncoded), sceneDecodedSha256: sha256(sceneDecoded), snapshotEncodedSha256: sha256(snapshotEncoded), @@ -99,6 +96,84 @@ export async function inspectFlowerboxProductBank(root, { verifyDescriptor = tru return summary; } +async function inspectVisualPacks(root, projected) { + const transport = projected.transport; + assert(transport?.schema === "cssflower-prepared-visual-pack-transport@1" && + transport.representation === "layout-block-aligned-exact-byte-slices" && + transport.packCount === projected.layoutBlocks.length && transport.packCount === 37 && + transport.blockPageCount === projected.layoutBlockPageCount && + transport.compressedResidentPackBudget === 2 && transport.earlyPrefetchPageOffset === 16 && + transport.logicalContentAddressedAtlasBytes === projected.contentAddressedAtlasBytes && + transport.logicalCompressedLayoutBytes === projected.compressedLayoutBytes && + transport.runtimeGeometryConstruction === false && transport.runtimeProjection === false && + transport.runtimeRasterization === false && transport.runtimeLightingCalculation === false && + transport.packs?.length === transport.packCount, + "visual pack transport"); + + const expectedAssets = new Map(); + let totalPackBytes = 0; + let maximumPackBytes = 0; + for (let packIndex = 0; packIndex < transport.packs.length; packIndex += 1) { + const pack = transport.packs[packIndex]; + const block = projected.layoutBlocks[packIndex]; + assert(pack?.schema === "cssflower-prepared-visual-pack@1" && pack.index === packIndex && + pack.startPageIndex === block.startPageIndex && pack.pageCount === block.pageCount && + Number.isSafeInteger(pack.byteLength) && pack.byteLength > 0 && + /^[a-f0-9]{64}$/u.test(pack.sha256 ?? "") && + pack.layout?.byteOffset === 0 && pack.layout.byteLength === block.byteLength && + pack.layout.sha256 === block.sha256 && pack.layout.decodedByteLength === block.decodedByteLength && + pack.layout.decodedSha256 === block.decodedSha256 && + pack.atlasSlices?.length === pack.pageCount, + `visual pack ${packIndex} descriptor`); + addExpected(expectedAssets, pack.assetUrl, pack.byteLength, pack.sha256); + let expectedOffset = pack.layout.byteLength; + for (let localPageIndex = 0; localPageIndex < pack.pageCount; localPageIndex += 1) { + const pageIndex = pack.startPageIndex + localPageIndex; + const page = projected.pages[pageIndex]; + const slice = pack.atlasSlices[localPageIndex]; + assert(slice?.pageIndex === pageIndex && slice.byteOffset === expectedOffset && + slice.byteLength === page.atlas.byteLength && slice.sha256 === page.atlas.sha256 && + slice.mimeType === page.atlas.mimeType, + `visual pack ${packIndex} page ${pageIndex} descriptor`); + expectedOffset += slice.byteLength; + } + assert(expectedOffset === pack.byteLength, `visual pack ${packIndex} byte coverage`); + totalPackBytes += pack.byteLength; + maximumPackBytes = Math.max(maximumPackBytes, pack.byteLength); + } + assert(totalPackBytes === transport.totalPackBytes && maximumPackBytes === transport.maximumPackBytes, + "visual pack aggregate bytes"); + + await parallel(transport.packs, 8, async (pack) => { + const bytes = await readFile(publicPath(root, pack.assetUrl)); + assert(bytes.length === pack.byteLength && sha256(bytes) === pack.sha256, + `visual pack ${pack.index} identity`); + const block = projected.layoutBlocks[pack.index]; + const layoutCompressed = bytes.subarray( + pack.layout.byteOffset, + pack.layout.byteOffset + pack.layout.byteLength, + ); + assert(layoutCompressed.length === block.byteLength && sha256(layoutCompressed) === block.sha256, + `visual pack ${pack.index} layout slice`); + const layoutDecoded = gunzipSync(layoutCompressed); + assert(layoutDecoded.length === block.decodedByteLength && sha256(layoutDecoded) === block.decodedSha256, + `visual pack ${pack.index} decoded layout`); + for (const slice of pack.atlasSlices) { + const page = projected.pages[slice.pageIndex]; + const atlasBytes = bytes.subarray(slice.byteOffset, slice.byteOffset + slice.byteLength); + assert(atlasBytes.length === page.atlas.byteLength && sha256(atlasBytes) === page.atlas.sha256, + `visual pack ${pack.index} atlas slice ${slice.pageIndex}`); + } + }); + + return Object.freeze({ + packCount: transport.packCount, + assetCount: expectedAssets.size, + totalPackBytes, + maximumPackBytes, + }); +} + export async function writeFlowerboxProductBankDescriptor(root, summary, source) { const descriptor = { ...summary, diff --git a/src/adapters/flowerbox/tools/smoke-browser.mjs b/src/adapters/flowerbox/tools/smoke-browser.mjs index dceb504..e500475 100644 --- a/src/adapters/flowerbox/tools/smoke-browser.mjs +++ b/src/adapters/flowerbox/tools/smoke-browser.mjs @@ -58,6 +58,13 @@ try { stats: debug.stats(), }); } + await debug.setTick(0); + const schedulerBefore = debug.stats(); + debug.resume(); + await new Promise((resolveWait) => setTimeout(resolveWait, 3_200)); + debug.pause(); + await new Promise((resolveFrame) => requestAnimationFrame(() => requestAnimationFrame(resolveFrame))); + const schedulerAfter = debug.stats(); debug.assertStableDomIdentity(); return { portStatus: document.body.dataset.portStatus, @@ -68,7 +75,19 @@ try { svgCount: document.querySelectorAll("svg").length, oraclePaneCount: document.querySelectorAll("#native-pane, #diff-pane").length, rows, - finalStats: debug.stats(), + schedulerProof: { + startTick: schedulerBefore.globalTick, + endTick: schedulerAfter.globalTick, + tickDelta: schedulerAfter.globalTick - schedulerBefore.globalTick, + transitionDelta: schedulerAfter.runtimeSchedulerStateTransitions - + schedulerBefore.runtimeSchedulerStateTransitions, + skippedPreparedStateCount: schedulerAfter.runtimeSchedulerSkippedPreparedStateCount, + visualPackLoadDelta: schedulerAfter.projectedPageLoader.packLoadCount - + schedulerBefore.projectedPageLoader.packLoadCount, + visualPackReleaseDelta: schedulerAfter.projectedPageLoader.packReleaseCount - + schedulerBefore.projectedPageLoader.packReleaseCount, + }, + finalStats: schedulerAfter, }; }); await page.screenshot({ path: screenshotPath }); @@ -93,7 +112,14 @@ function assertProof(report) { stats.runtimeGeometryConstructionCount !== 0 || stats.runtimeProjectionCalculationCount !== 0 || stats.runtimeRasterizationCount !== 0 || stats.runtimeNormalCalculationCount !== 0 || stats.runtimeLightingCalculationCount !== 0 || stats.runtimeDomGrowth !== false || - stats.projectedPageLoader?.residentPageCount > 2 || stats.projectedPageLoader?.errors?.length) { + stats.runtimeSchedulerSkippedPreparedStateCount !== 0 || + report.schedulerProof?.startTick !== 0 || report.schedulerProof.tickDelta < 75 || + report.schedulerProof.tickDelta !== report.schedulerProof.transitionDelta || + report.schedulerProof.visualPackLoadDelta !== 1 || report.schedulerProof.visualPackReleaseDelta > 1 || + stats.projectedPageLoader?.residentPageCount > 2 || + stats.projectedPageLoader?.residentPackCount > 2 || + stats.projectedPageLoader?.peakResidentPackCount > 2 || + stats.projectedPageLoader?.errors?.length) { throw new Error(`Flower Box browser smoke failed:\n${JSON.stringify(report, null, 2)}`); } } From 0abc208dfc56ee47034cf064fe14246e5bf0eab8 Mon Sep 17 00:00:00 2001 From: alowpoly Date: Thu, 6 Aug 2026 15:17:33 -0300 Subject: [PATCH 3/5] fix(flowerbox): bind portable product archive --- src/adapters/flowerbox/prepared-bank.lock.json | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/src/adapters/flowerbox/prepared-bank.lock.json b/src/adapters/flowerbox/prepared-bank.lock.json index 2b63b7a..39405d4 100644 --- a/src/adapters/flowerbox/prepared-bank.lock.json +++ b/src/adapters/flowerbox/prepared-bank.lock.json @@ -1,10 +1,10 @@ { "schema": "cssflower-prepared-bank-lock@2", - "tag": "cssflower-product-q40-v2", - "asset": "cssflower-product-q40-v2.tar.gz", - "url": "https://github.com/layoutit/cssGraphics/releases/download/cssflower-product-q40-v2/cssflower-product-q40-v2.tar.gz", - "archiveByteLength": 29655480, - "archiveSha256": "290570fda718e96754f4bb57fdcb1dfc7a1184b6a5d1510088c7f18d96c29fa4", + "tag": "cssflower-product-q40-v3", + "asset": "cssflower-product-q40-v3.tar.gz", + "url": "https://github.com/layoutit/cssGraphics/releases/download/cssflower-product-q40-v3/cssflower-product-q40-v3.tar.gz", + "archiveByteLength": 29651446, + "archiveSha256": "e18ddcc861bed32e9dcc5711645417eacc499456ead107dd078ef5069c604db1", "productClosureBytes": 30249145, "productClosureSha256": "0663e211844001fca1bf62a5915791d8ec7d23c38616ea9c80b29874c8835de1", "productDescriptorByteLength": 1787, From fd34290d8eafac33099113565de0a65df15460fb Mon Sep 17 00:00:00 2001 From: alowpoly Date: Fri, 7 Aug 2026 09:43:44 -0300 Subject: [PATCH 4/5] feat(flowerbox): publish retained Morph product --- README.md | 5 +- netlify.toml | 2 +- scripts/prepare/flowerbox-product-bank.mjs | 14 +- src/adapters/flowerbox/README.md | 33 +- src/adapters/flowerbox/index.html | 11 +- .../flowerbox/prepared-bank.lock.json | 42 +- .../flowerbox/src/cssflower/client.mjs | 46 +- .../flowerbox/src/cssflower/debugApi.mjs | 7 +- .../src/cssflower/manifestClient.mjs | 622 ++++-------------- .../flowerbox/src/cssflower/polycssScene.mjs | 142 ++-- .../src/cssflower/preparedAssetLoaders.mjs | 423 ++++++++++++ .../src/cssflower/preparedPlayback.mjs | 469 +++++++++---- .../src/cssflower/projectedPageStyles.mjs | 70 -- .../src/cssflower/renderContract.mjs | 65 +- .../src/cssflower/stagePresentation.mjs | 41 +- .../flowerbox/src/cssflower/styles.css | 82 +-- src/adapters/flowerbox/src/main.mjs | 3 +- .../src/prepare/cssflower/bloomCycle.mjs | 67 ++ .../cssflower/compilePreparedCycle.mjs | 223 +++++-- .../src/prepare/cssflower/dataSource.mjs | 53 +- .../prepare/cssflower/leafRasterLighting.mjs | 188 +++++- .../flowerbox/src/prepare/cssflower/paths.mjs | 27 +- .../src/prepare/cssflower/prepare.mjs | 18 +- .../src/prepare/cssflower/projectedPixels.mjs | 214 +++++- .../src/prepare/cssflower/sceneBuilder.mjs | 315 +++++---- .../prepare/cssflower/sharedFramePacking.mjs | 86 --- .../cssflower/sharedFramePageStore.mjs | 362 ---------- .../cssflower/sharedFramePageWorker.mjs | 119 ---- .../prepare/cssflower/sharedFramePages.mjs | 216 ------ .../src/prepare/cssflower/sourceProfile.mjs | 2 +- .../prepare/cssflower/writePreparedAssets.mjs | 369 +++++++++-- .../flowerbox/tools/audit-runtime-surface.mjs | 47 +- .../flowerbox/tools/package-product-bank.mjs | 316 ++++----- .../flowerbox/tools/polycss-snapshot-page.mjs | 179 ++++- src/adapters/flowerbox/tools/productBank.mjs | 245 +++---- .../flowerbox/tools/smoke-browser.mjs | 413 +++++++++--- 36 files changed, 3113 insertions(+), 2423 deletions(-) create mode 100644 src/adapters/flowerbox/src/cssflower/preparedAssetLoaders.mjs delete mode 100644 src/adapters/flowerbox/src/cssflower/projectedPageStyles.mjs delete mode 100644 src/adapters/flowerbox/src/prepare/cssflower/sharedFramePacking.mjs delete mode 100644 src/adapters/flowerbox/src/prepare/cssflower/sharedFramePageStore.mjs delete mode 100644 src/adapters/flowerbox/src/prepare/cssflower/sharedFramePageWorker.mjs delete mode 100644 src/adapters/flowerbox/src/prepare/cssflower/sharedFramePages.mjs diff --git a/README.md b/README.md index 9cc7e6f..9eaa0d0 100644 --- a/README.md +++ b/README.md @@ -43,8 +43,9 @@ pnpm dev:3dpipes ### Flower Box [`src/adapters/flowerbox`](src/adapters/flowerbox) is an independent PolyCSS -reconstruction of the classic Flower Box. Its complete prepared cycle uses -1,200 stable retained triangle leaves and streams a hash-bound q40 visual bank. +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 diff --git a/netlify.toml b/netlify.toml index eccc7f8..43fb4ef 100644 --- a/netlify.toml +++ b/netlify.toml @@ -12,6 +12,6 @@ force = true [[headers]] - for = "/cssflower/assets/projected/*" + for = "/cssflower/assets/*" [headers.values] Cache-Control = "public, max-age=31536000, immutable" diff --git a/scripts/prepare/flowerbox-product-bank.mjs b/scripts/prepare/flowerbox-product-bank.mjs index 0f9584c..a47dc1a 100644 --- a/scripts/prepare/flowerbox-product-bank.mjs +++ b/scripts/prepare/flowerbox-product-bank.mjs @@ -12,9 +12,12 @@ const lock = JSON.parse(await readFile( join(repositoryRoot, "src/adapters/flowerbox/prepared-bank.lock.json"), "utf8", )); -if (lock.schema !== "cssflower-prepared-bank-lock@2" || lock.projectedVisualPackCount !== 37 || - lock.projectedVisualPackAssetCount !== 37) { - throw new Error("Flower Box prepared-bank lock does not bind the visual-pack transport"); +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"), @@ -26,6 +29,8 @@ 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`); @@ -75,7 +80,8 @@ 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) { + 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 || diff --git a/src/adapters/flowerbox/README.md b/src/adapters/flowerbox/README.md index b263ec1..1e240bf 100644 --- a/src/adapters/flowerbox/README.md +++ b/src/adapters/flowerbox/README.md @@ -1,16 +1,17 @@ # Flower Box An independently authored PolyCSS reconstruction of the classic 1995 Flower -Box. The complete default-cube bloom and rotation cycle is rendered through -1,200 stable retained HTML triangle leaves and one retained rotation root. +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 snapshot and streams 37 hash-bound visual packs -aligned to the prepared 64-page layout blocks. It slices the original AVIF -bytes from those packs, decodes only the active and next pages, and selects -prepared source-order leaf windows and root transforms. It does not construct -geometry, project vertices, calculate normals or lighting, rasterize, or grow -the DOM at runtime. Playback presents every prepared state in order; a delayed -pack pauses the clock instead of dropping states to catch up. +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: @@ -20,10 +21,16 @@ pnpm build:flowerbox pnpm dev:flowerbox ``` -The public q40 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 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 diff --git a/src/adapters/flowerbox/index.html b/src/adapters/flowerbox/index.html index 4867bc6..13df2c6 100644 --- a/src/adapters/flowerbox/index.html +++ b/src/adapters/flowerbox/index.html @@ -16,13 +16,8 @@ - - -
+ - + + diff --git a/src/adapters/flowerbox/prepared-bank.lock.json b/src/adapters/flowerbox/prepared-bank.lock.json index 39405d4..804c6e8 100644 --- a/src/adapters/flowerbox/prepared-bank.lock.json +++ b/src/adapters/flowerbox/prepared-bank.lock.json @@ -1,28 +1,26 @@ { - "schema": "cssflower-prepared-bank-lock@2", - "tag": "cssflower-product-q40-v3", - "asset": "cssflower-product-q40-v3.tar.gz", - "url": "https://github.com/layoutit/cssGraphics/releases/download/cssflower-product-q40-v3/cssflower-product-q40-v3.tar.gz", - "archiveByteLength": 29651446, - "archiveSha256": "e18ddcc861bed32e9dcc5711645417eacc499456ead107dd078ef5069c604db1", - "productClosureBytes": 30249145, - "productClosureSha256": "0663e211844001fca1bf62a5915791d8ec7d23c38616ea9c80b29874c8835de1", - "productDescriptorByteLength": 1787, - "productDescriptorSha256": "cdc4d4db07584fb0582cdf62bc9aafdddb8a7a882652e64148fba5e7bcd39219", + "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": 9331, - "projectedPageCount": 2333, - "projectedAtlasAssetCount": 2273, - "projectedLayoutBlockCount": 37, - "projectedVisualPackCount": 37, - "projectedVisualPackAssetCount": 37, - "projectedVisualPackBytes": 29407427, - "visualEncoding": { - "codec": "AVIF", - "quality": 40, - "chromaSubsampling": "4:4:4" - }, + "timelineStateCount": 360, + "geometryStateCount": 46, + "retainedSourceOracleTimelineStateCount": 9331, + "transformBlockCount": 3, + "transformAssetBytes": 1347149, + "lightingAssetCount": 1, + "lightingAssetBytes": 3848891, + "lightingQuality": 60, + "visibilityMinimumOwnedPixels": 8, "publicBoundary": { "microsoftSourceIncluded": false, "microsoftBinaryIncluded": false, diff --git a/src/adapters/flowerbox/src/cssflower/client.mjs b/src/adapters/flowerbox/src/cssflower/client.mjs index cab33a8..763c19c 100644 --- a/src/adapters/flowerbox/src/cssflower/client.mjs +++ b/src/adapters/flowerbox/src/cssflower/client.mjs @@ -8,12 +8,11 @@ import { createRouteState } from "./routeState.mjs"; import { installCssflowerDebugApi } from "./debugApi.mjs"; import { installCssflowerStagePresentation } from "./stagePresentation.mjs"; -export function mountCssflowerClient() { - const host = document.getElementById("scene"); - const status = document.getElementById("status"); - const presentation = installCssflowerStagePresentation(host); +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, @@ -23,43 +22,36 @@ export function mountCssflowerClient() { installCssflowerDebugApi(state); window.addEventListener("error", (event) => { - recordError(state, event.message || String(event.error || "error"), status); + recordError(state, event.message || String(event.error || "error")); }); window.addEventListener("unhandledrejection", (event) => { - recordError(state, String(event.reason?.message || event.reason || "unhandled rejection"), status); + recordError(state, String(event.reason?.message || event.reason || "unhandled rejection")); }); main().catch((error) => { - recordError(state, error.stack || error.message || String(error), status); + recordError(state, error.stack || error.message || String(error)); }); async function main() { - document.body.dataset.productView = "1"; - document.body.dataset.gameView = "polycss"; - document.body.dataset.portSlug = "cssflower"; - setStatus(status, "Loading Flower Box…", "loading"); - const route = createRouteState(); state.route = route; - document.body.dataset.routeScene = route.scene; const manifest = await loadPreparedManifest(route); state.manifest = manifest; - setStatus(status, "Loading prepared scene…", "loading"); - const { entry, sceneData, snapshotHtml, projectedPages } = await loadPreparedScene(manifest, route); + const { entry, sceneData, snapshotHtml, preparedAssets } = await loadPreparedScene(manifest, route); state.sceneData = sceneData; - document.body.dataset.sceneUrl = entry.sceneUrl; - if (entry.snapshotUrl) document.body.dataset.snapshotUrl = entry.snapshotUrl; - setStatus(status, "Mounting retained PolyCSS scene…", "loading"); - const snapshot = mountPreparedPolycssSnapshot({ host, sceneData, snapshotHtml, projectedPages }); + 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, - projectedPages, + transformBlocks: preparedAssets.transformBlocks, + lightingPages: preparedAssets.lightingPages, }); state.mount = Object.freeze({ ...snapshot, @@ -74,22 +66,18 @@ export function mountCssflowerClient() { destroy() { presentation.destroy(); player.destroy(); - projectedPages.destroy(); + preparedAssets.transformBlocks.destroy(); + preparedAssets.lightingPages.destroy(); snapshot.destroy(); }, }); state.ready = true; - setStatus(status, "Ready — 1,200 retained PolyCSS triangles", "ready"); + state.status = "ready"; requestAnimationFrame(() => player.resume()); } } -function recordError(state, message, status) { +function recordError(state, message) { state.errors.push(message); - setStatus(status, message, "error"); -} - -function setStatus(status, message, kind = "loading") { - document.body.dataset.portStatus = kind; - if (status) status.textContent = message; + state.status = "error"; } diff --git a/src/adapters/flowerbox/src/cssflower/debugApi.mjs b/src/adapters/flowerbox/src/cssflower/debugApi.mjs index db0ff22..a26eb8f 100644 --- a/src/adapters/flowerbox/src/cssflower/debugApi.mjs +++ b/src/adapters/flowerbox/src/cssflower/debugApi.mjs @@ -3,6 +3,9 @@ export function installCssflowerDebugApi(state) { get ready() { return state.ready; }, + get status() { + return state.status; + }, get manifest() { return state.manifest; }, @@ -30,6 +33,9 @@ export function installCssflowerDebugApi(state) { setTick(tick) { return state.mount?.player?.setTick?.(tick) ?? null; }, + sample() { + return state.mount?.player?.sample?.() ?? null; + }, nodes() { return state.mount?.player?.nodes?.() ?? null; }, @@ -44,6 +50,5 @@ export function installCssflowerDebugApi(state) { }, }; globalThis.__cssFlowerDebug = api; - document.body.setAttribute("data-cssflower-devtools-helper", "window.__cssFlowerDebug"); return api; } diff --git a/src/adapters/flowerbox/src/cssflower/manifestClient.mjs b/src/adapters/flowerbox/src/cssflower/manifestClient.mjs index a3c0fce..d88894f 100644 --- a/src/adapters/flowerbox/src/cssflower/manifestClient.mjs +++ b/src/adapters/flowerbox/src/cssflower/manifestClient.mjs @@ -2,29 +2,41 @@ 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_PROJECTED_ATLAS_ENCODING, - CSSFLOWER_PROJECTED_ATLAS_MIME_TYPE, - CSSFLOWER_PROJECTED_ATLAS_QUALITY, + 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 prepared Flower Box manifest at " + routeState.manifestUrl + ". Run pnpm prepare:flowerbox:artifact first.", + 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("Prepared Flower Box manifest is not ready (" + (manifest?.status ?? "missing status") + "). Run pnpm prepare:flowerbox:artifact first."); + throw new Error("Generated cssFlower — Microsoft Flower Box manifest is not ready (" + (manifest?.status ?? "missing status") + "). Run pnpm prepare:cssflower first."); } return manifest; } @@ -32,14 +44,14 @@ export async function loadPreparedManifest(routeState) { export async function loadPreparedScene(manifest, routeState) { const entry = sceneEntryForRoute(manifest, routeState); if (!entry || typeof entry.sceneUrl !== "string") { - throw new Error("Prepared Flower Box manifest does not include " + routeSceneLabel(routeState) + ". Run pnpm prepare:flowerbox:artifact first."); + 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 prepared Flower Box scene at " + entry.sceneUrl + ". Run pnpm prepare:flowerbox:artifact first.", + 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 prepared Flower Box PolyCSS snapshot at " + entry.snapshotUrl + ". Run pnpm prepare:flowerbox:artifact first.", + 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" || @@ -56,14 +68,18 @@ export async function loadPreparedScene(manifest, routeState) { sceneData?.renderer?.merge !== false || sceneData?.lighting?.schema !== CSSFLOWER_LIGHTING_SCHEMA || sceneData?.lighting?.physicalLayout !== CSSFLOWER_LIGHTING_LAYOUT || - sceneData?.lighting?.rasterMode !== "leaf-resolution" || - sceneData?.lighting?.sampling !== "endpoint-aligned-pixel-centers" || + 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 !== 9_331 || + 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 || @@ -77,17 +93,29 @@ export async function loadPreparedScene(manifest, routeState) { sceneData?.lighting?.faces?.length !== 1200 || !sceneData?.lighting?.faces?.every((face, index) => face?.sourceOrder === index && - face.tileWidth === face.leafWidth && face.tileHeight === face.leafHeight && + 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.leafWidth < CSSFLOWER_LIGHTING_ATLAS_WIDTH && - face.contentY >= CSSFLOWER_LIGHTING_GUTTER && face.contentY + face.leafHeight < CSSFLOWER_LIGHTING_STATE_SLICE_HEIGHT && + 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?.rowSelection !== "prepared-timeline-state-page-and-row-index" || + 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 || @@ -98,477 +126,118 @@ export async function loadPreparedScene(manifest, routeState) { 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("Prepared Flower Box retained snapshot binding is missing. Run pnpm prepare:flowerbox:artifact first."); + 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"); - validateProjectedPixels(sceneData.playback); - const projectedPages = createPreparedProjectedPageLoader(sceneData.playback.projectedPixels); - await projectedPages.prime(0, 1); + 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, - projectedPages, + preparedAssets: Object.freeze({ transformBlocks, lightingPages }), }; } -function createPreparedProjectedPageLoader(projected) { - const transport = projected.transport; - const cycleStartPageIndex = projected.pages.findIndex((page) => - projected.cycleStartState >= page.startStateIndex && - projected.cycleStartState < page.startStateIndex + page.usedFrameCount); - if (cycleStartPageIndex < 0) throw new Error("Prepared cssFlower cycle-start page is missing"); - const records = new Map(); - const packRecords = new Map(); - const errors = []; - let currentPageIndex = 0; - let pageLoadCount = 0; - let pageReleaseCount = 0; - let packLoadCount = 0; - let packReleaseCount = 0; - let earlyPackPrefetchCount = 0; - let residentDecodedBytes = 0; - let peakResidentDecodedBytes = 0; - let residentEncodedPackBytes = 0; - let peakResidentEncodedPackBytes = 0; - let peakResidentPackCount = 0; - let desiredPageIndices = new Set(); - let desiredPackIndices = new Set(); - let earlyPrefetchPackIndex = null; - let destroyed = false; - - async function ensure(pageIndex) { - if (destroyed) throw new Error("Prepared cssFlower projected page loader is destroyed"); - const page = projected.pages[pageIndex]; - if (!page || page.index !== pageIndex) throw new RangeError(`Prepared cssFlower projected page ${pageIndex} is missing`); - const existing = records.get(pageIndex); - if (existing?.url) return existing; - if (existing?.promise) return existing.promise; - const promise = (async () => { - const pack = transport.packs[page.layout.blockIndex]; - const packRecord = await ensurePack(pack.index); - const atlasSlice = pack.atlasSlices[pageIndex - pack.startPageIndex]; - const atlasBytes = packRecord.bytes.subarray( - atlasSlice.byteOffset, - atlasSlice.byteOffset + atlasSlice.byteLength, - ); - if (atlasBytes.byteLength !== page.atlas.byteLength || atlasSlice.sha256 !== page.atlas.sha256) { - throw new Error(`Generated cssFlower projected atlas ${pageIndex} byte length is invalid.`); - } - const layoutBytes = packRecord.layoutBytes.subarray( - page.layout.blockByteOffset, - page.layout.blockByteOffset + page.layout.byteLength, - ); - if (layoutBytes.byteLength !== page.layout.byteLength || layoutBytes.byteLength % 2 !== 0) { - throw new Error(`Generated cssFlower projected layout ${pageIndex} byte length is invalid.`); - } - await Promise.all([ - assertSha256(atlasBytes, page.atlas.sha256, `projected atlas ${pageIndex}`), - assertSha256(layoutBytes, page.layout.sha256, `projected layout ${pageIndex}`), - ]); - const url = URL.createObjectURL(new Blob([atlasBytes], { type: page.atlas.mimeType })); - let image; - try { - image = await decodeImage(url); - } catch (error) { - URL.revokeObjectURL(url); - throw error; - } - const record = Object.freeze({ - pageIndex, - url, - image, - layoutBlockIndex: pack.index, - layoutValues: new Int16Array( - layoutBytes.buffer, - layoutBytes.byteOffset, - layoutBytes.byteLength / Int16Array.BYTES_PER_ELEMENT, - ), - decodedBytes: page.atlas.decodedBytes, - }); - records.set(pageIndex, record); - pageLoadCount += 1; - residentDecodedBytes += page.atlas.decodedBytes; - peakResidentDecodedBytes = Math.max(peakResidentDecodedBytes, residentDecodedBytes); - if (destroyed || !desiredPageIndices.has(pageIndex)) releaseRecord(pageIndex, record); - return record; - })(); - records.set(pageIndex, { promise }); - try { - return await promise; - } catch (error) { - if (records.get(pageIndex)?.promise === promise) records.delete(pageIndex); - errors.push(String(error?.message || error)); - throw error; - } - } - - async function ensurePack(packIndex) { - const pack = transport.packs[packIndex]; - if (!pack || pack.index !== packIndex) throw new Error("Prepared cssFlower visual pack is missing"); - const existing = packRecords.get(packIndex); - if (existing?.bytes) return existing; - if (existing?.promise) return existing.promise; - const controller = new AbortController(); - const promise = (async () => { - const bytes = new Uint8Array(await fetchBytes(pack.assetUrl, { - notFoundMessage: `Missing prepared Flower Box visual pack ${packIndex}. Run pnpm prepare:flowerbox:artifact first.`, - signal: controller.signal, - })); - if (bytes.byteLength !== pack.byteLength) { - throw new Error(`Generated cssFlower visual pack ${packIndex} byte length is invalid.`); - } - await assertSha256(bytes, pack.sha256, `visual pack ${packIndex}`); - const compressedLayout = bytes.subarray( - pack.layout.byteOffset, - pack.layout.byteOffset + pack.layout.byteLength, - ); - if (compressedLayout.byteLength !== pack.layout.byteLength) { - throw new Error(`Generated cssFlower visual pack ${packIndex} layout slice is invalid.`); - } - await assertSha256(compressedLayout, pack.layout.sha256, `visual pack ${packIndex} layout`); - const layoutBytes = await decompressGzip(compressedLayout); - if (layoutBytes.byteLength !== pack.layout.decodedByteLength) { - throw new Error(`Generated cssFlower visual pack ${packIndex} decoded layout byte length is invalid.`); - } - await assertSha256( - layoutBytes, - pack.layout.decodedSha256, - `decoded visual pack ${packIndex} layout`, - ); - const record = Object.freeze({ index: packIndex, bytes, layoutBytes }); - packRecords.set(packIndex, record); - packLoadCount += 1; - residentEncodedPackBytes += bytes.byteLength; - peakResidentEncodedPackBytes = Math.max(peakResidentEncodedPackBytes, residentEncodedPackBytes); - peakResidentPackCount = Math.max(peakResidentPackCount, residentPackCount()); - if (destroyed || !desiredPackIndices.has(packIndex)) releasePack(packIndex, record); - return record; - })(); - packRecords.set(packIndex, { promise, controller }); - try { - return await promise; - } catch (error) { - if (packRecords.get(packIndex)?.promise === promise) packRecords.delete(packIndex); - if (error?.name !== "AbortError") errors.push(String(error?.message || error)); - throw error; - } - } - - function releasePack(packIndex, record) { - if (!record?.bytes || packRecords.get(packIndex) !== record) return; - packRecords.delete(packIndex); - packReleaseCount += 1; - residentEncodedPackBytes -= record.bytes.byteLength; - } - - function reconcilePackResidency() { - const keepPacks = new Set([...desiredPageIndices].map(packIndexForPage)); - if (earlyPrefetchPackIndex !== null) keepPacks.add(earlyPrefetchPackIndex); - if (keepPacks.size > transport.compressedResidentPackBudget) { - throw new Error("Prepared cssFlower visual pack residency exceeds its bound"); - } - desiredPackIndices = keepPacks; - for (const [packIndex, record] of packRecords) { - if (keepPacks.has(packIndex)) continue; - if (record?.bytes) releasePack(packIndex, record); - else if (record?.controller) { - record.controller.abort(); - packRecords.delete(packIndex); - } - } - } - - function residentPackCount() { - return [...packRecords.values()].filter((record) => record?.bytes).length; - } - - function packIndexForPage(pageIndex) { - const page = projected.pages[pageIndex]; - if (!page) throw new RangeError(`Prepared cssFlower projected page ${pageIndex} is missing`); - return page.layout.blockIndex; - } - - function nextPackIndex(packIndex) { - if (packIndex + 1 < transport.packCount) return packIndex + 1; - return packIndexForPage(cycleStartPageIndex); - } - - function pageAfter(pageIndex) { - if (pageIndex + 1 < projected.pageCount) return pageIndex + 1; - return cycleStartPageIndex; - } - - function maybePrefetchPack(pageIndex) { - const packIndex = packIndexForPage(pageIndex); - const pack = transport.packs[packIndex]; - const offset = pageIndex - pack.startPageIndex; - earlyPrefetchPackIndex = offset >= transport.earlyPrefetchPageOffset - ? nextPackIndex(packIndex) - : null; - reconcilePackResidency(); - if (earlyPrefetchPackIndex === null) return; - if (!packRecords.has(earlyPrefetchPackIndex)) earlyPackPrefetchCount += 1; - void ensurePack(earlyPrefetchPackIndex).catch(() => undefined); - } - - function releaseRecord(pageIndex, record) { - if (!record?.url || records.get(pageIndex) !== record) return; - record.image.removeAttribute("src"); - URL.revokeObjectURL(record.url); - records.delete(pageIndex); - pageReleaseCount += 1; - residentDecodedBytes -= record.decodedBytes; - } - - function releaseExcept(keep) { - desiredPageIndices = new Set(keep); - for (const [pageIndex, record] of records) { - if (keep.has(pageIndex) || !record?.url) continue; - releaseRecord(pageIndex, record); - } - reconcilePackResidency(); - } - - function prefetch(pageIndex) { - if (pageIndex === currentPageIndex || records.get(pageIndex)?.url) return; - void ensure(pageIndex).catch(() => undefined); - } +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"; +} - return Object.freeze({ - async prime(pageIndex, nextPageIndex) { - currentPageIndex = pageIndex; - earlyPrefetchPackIndex = null; - releaseExcept(new Set([pageIndex, nextPageIndex])); - await Promise.all([ensure(pageIndex), ensure(nextPageIndex)]); - }, - urlFor(pageIndex) { - const record = records.get(pageIndex); - if (!record?.url) throw new Error(`Prepared cssFlower projected page ${pageIndex} is not decoded`); - return record.url; - }, - layoutFor(pageIndex) { - const record = records.get(pageIndex); - if (!(record?.layoutValues instanceof Int16Array)) { - throw new Error(`Prepared cssFlower projected layout ${pageIndex} is not decoded`); - } - return record.layoutValues; - }, - async activate(pageIndex, nextPageIndex) { - if (pageIndex !== pageAfter(currentPageIndex)) earlyPrefetchPackIndex = null; - releaseExcept(new Set([currentPageIndex, pageIndex])); - const record = await ensure(pageIndex); - currentPageIndex = pageIndex; - return record; - }, - commitPresented(pageIndex, nextPageIndex) { - if (pageIndex !== currentPageIndex || !records.get(pageIndex)?.url) { - throw new Error(`Prepared cssFlower projected page ${pageIndex} cannot be committed before presentation`); - } - releaseExcept(new Set([pageIndex, nextPageIndex])); - prefetch(nextPageIndex); - maybePrefetchPack(pageIndex); - }, - stats() { - return Object.freeze({ - schema: "cssflower-prepared-projected-page-loader@2", - currentPageIndex, - pageLoadCount, - pageReleaseCount, - residentPageCount: [...records.values()].filter((record) => record?.url).length, - residentDecodedBytes, - peakResidentDecodedBytes, - residentPageBudget: projected.decodedResidentPageBudget, - peakPageBudget: projected.decodedPeakPageBudget, - desiredPageIndices: Object.freeze([...desiredPageIndices].sort((left, right) => left - right)), - packLoadCount, - packReleaseCount, - earlyPackPrefetchCount, - residentPackCount: residentPackCount(), - residentEncodedPackBytes, - peakResidentEncodedPackBytes, - peakResidentPackCount, - residentPackBudget: transport.compressedResidentPackBudget, - desiredPackIndices: Object.freeze([...desiredPackIndices].sort((left, right) => left - right)), - earlyPrefetchPackIndex, - residentLayoutBlockCount: residentPackCount(), - residentDecodedLayoutBytes: [...packRecords.values()].reduce( - (sum, record) => sum + (record?.layoutBytes?.byteLength ?? 0), - 0, - ), - errors: Object.freeze([...errors]), - }); - }, - destroy() { - destroyed = true; - earlyPrefetchPackIndex = null; - releaseExcept(new Set()); - }, - }); +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 validateProjectedPixels(playback) { - const projected = playback?.projectedPixels; - if (projected?.schema !== "cssflower-prepared-projected-pixel-playback@1" || - projected.representation !== "shared-frame-windows" || - projected.physicalLayout !== "source-order-retained-leaf-windows-over-screen-aligned-prepared-frame-pages" || - projected.rasterMode !== "source-camera-projected-pixels" || - projected.visualEncoding?.codec !== "AVIF" || - projected.visualEncoding?.mimeType !== CSSFLOWER_PROJECTED_ATLAS_MIME_TYPE || - projected.visualEncoding?.quality !== CSSFLOWER_PROJECTED_ATLAS_QUALITY || - projected.visualEncoding?.chromaSubsampling !== "4:4:4" || - projected.visualEncoding?.exactStateAndTopology !== true || - projected.visualEncoding?.exactPreparedPixels !== false || - projected.stateCount !== playback.cycle.stateCount || - projected.cycleStartState !== playback.cycle.cycleStartState || - projected.cycleLength !== playback.cycle.cycleLength || - projected.retainedLeafCount !== 1_200 || - !Number.isSafeInteger(projected.pageCount) || projected.pageCount < 1 || - projected.pages?.length !== projected.pageCount || - !Number.isSafeInteger(projected.layoutBlockPageCount) || projected.layoutBlockPageCount !== 64 || - !Array.isArray(projected.layoutBlocks) || projected.layoutBlocks.length < 1 || - projected.decodedResidentPageBudget !== 2 || - projected.decodedPeakPageBudget !== 2 || - projected.maximumDecodedPageBytes > 16 * 1024 * 1024 || - projected.inverseRootTransforms?.length !== playback.cycle.rootStateCount || - projected.inverseRootTransforms.some((transform) => typeof transform !== "string") || - projected.runtimeProjection !== false || projected.runtimeRasterization !== false || - projected.runtimeGeometryConstruction !== false || projected.runtimeNormalCalculation !== false || - projected.runtimeLightingCalculation !== false || projected.runtimeDomGrowth !== false) { - throw new Error("Complete prepared cssFlower projected-pixel playback is required"); - } - validateProjectedTransport(projected); - let layoutBlockDecodedBytes = 0; - for (let blockIndex = 0; blockIndex < projected.layoutBlocks.length; blockIndex += 1) { - const block = projected.layoutBlocks[blockIndex]; - const expectedPageCount = Math.min( - projected.layoutBlockPageCount, - projected.pageCount - blockIndex * projected.layoutBlockPageCount, - ); - if (block?.schema !== "cssflower-prepared-shared-layout-block@1" || block.index !== blockIndex || - block.startPageIndex !== blockIndex * projected.layoutBlockPageCount || - block.pageCount !== expectedPageCount || - block.encoding !== "gzip-concatenated-int16-page-layouts" || - !Number.isSafeInteger(block.byteLength) || block.byteLength < 1 || - block.decodedByteLength !== block.pageCount * 14_400 || - !/^[a-f0-9]{64}$/.test(block.sha256 ?? "") || - !/^[a-f0-9]{64}$/.test(block.decodedSha256 ?? "") || - typeof block.assetUrl !== "string") { - throw new Error(`Prepared cssFlower shared layout block ${blockIndex} is invalid`); - } - layoutBlockDecodedBytes += block.decodedByteLength; - } - if (layoutBlockDecodedBytes !== projected.rawLayoutBytes) { - throw new Error("Prepared cssFlower shared layout block coverage is incomplete"); - } - let nextStateIndex = 0; - for (let pageIndex = 0; pageIndex < projected.pages.length; pageIndex += 1) { - const page = projected.pages[pageIndex]; - const atlas = page?.atlas; - const layout = page?.layout; - const horizontal = atlas?.packing === "horizontal-union"; - const vertical = atlas?.packing === "vertical-union"; - const expectedOffsets = Array.from( - { length: page?.usedFrameCount ?? 0 }, - (_, frameIndex) => frameIndex === 0 ? 0 : horizontal - ? -frameIndex * atlas.frameWidth - : -frameIndex * atlas.frameHeight, - ); - if (page?.index !== pageIndex || page.startStateIndex !== nextStateIndex || - page.frameCount !== 4 || !Number.isSafeInteger(page.usedFrameCount) || - page.usedFrameCount < 1 || page.usedFrameCount > page.frameCount || - !Number.isSafeInteger(page.activeUnionLeafCount) || page.activeUnionLeafCount < 1 || - page.activeUnionLeafCount > 1_200 || atlas?.encoding !== CSSFLOWER_PROJECTED_ATLAS_ENCODING || - atlas.mimeType !== CSSFLOWER_PROJECTED_ATLAS_MIME_TYPE || - atlas.quality !== CSSFLOWER_PROJECTED_ATLAS_QUALITY || - !Number.isSafeInteger(atlas.width) || atlas.width < 1 || - !Number.isSafeInteger(atlas.height) || atlas.height < 1 || - !Number.isSafeInteger(atlas.frameWidth) || atlas.frameWidth < 1 || - !Number.isSafeInteger(atlas.frameHeight) || atlas.frameHeight < 1 || - (!horizontal && !vertical) || - atlas.width !== atlas.frameWidth * (horizontal ? page.usedFrameCount : 1) || - atlas.height !== atlas.frameHeight * (vertical ? page.usedFrameCount : 1) || - !arraysEqual(atlas.frameBackgroundOffsets, expectedOffsets) || - !Number.isSafeInteger(atlas.byteLength) || atlas.byteLength < 1 || - atlas.decodedBytes !== atlas.width * atlas.height * 4 || - atlas.decodedBytes > projected.maximumDecodedPageBytes || - !/^[a-f0-9]{64}$/.test(atlas.sha256 ?? "") || typeof atlas.assetUrl !== "string" || - layout?.schema !== "cssflower-prepared-shared-frame-leaf-layout@1" || - layout.encoding !== "int16-little-endian-source-order-width-height-dx-dy-frame-background-x-frame-background-y" || - layout.componentCount !== 6 || layout.bytesPerLeaf !== 12 || layout.leafCount !== 1_200 || - layout.byteLength !== 14_400 || !/^[a-f0-9]{64}$/.test(layout.sha256 ?? "") || - layout.blockIndex !== Math.floor(pageIndex / projected.layoutBlockPageCount) || - layout.blockByteOffset !== (pageIndex % projected.layoutBlockPageCount) * layout.byteLength) { - throw new Error(`Prepared cssFlower projected page ${pageIndex} is invalid`); - } - nextStateIndex += page.usedFrameCount; +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 (nextStateIndex !== projected.stateCount || playback.cycle.states.some((state, stateIndex) => { - const page = projected.pages[state.projectedPageIndex]; - return !page || !Number.isSafeInteger(state.projectedFrameIndex) || state.projectedFrameIndex < 0 || - state.projectedFrameIndex >= page.usedFrameCount || - page.startStateIndex + state.projectedFrameIndex !== stateIndex; - })) { - throw new Error("Prepared cssFlower projected state mapping is incomplete"); + 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 validateProjectedTransport(projected) { - const transport = projected.transport; - if (transport?.schema !== "cssflower-prepared-visual-pack-transport@1" || - transport.representation !== "layout-block-aligned-exact-byte-slices" || - transport.packCount !== projected.layoutBlocks.length || transport.packCount !== 37 || - transport.blockPageCount !== projected.layoutBlockPageCount || - transport.compressedResidentPackBudget !== 2 || transport.earlyPrefetchPageOffset !== 16 || - transport.logicalContentAddressedAtlasBytes !== projected.contentAddressedAtlasBytes || - transport.logicalCompressedLayoutBytes !== projected.compressedLayoutBytes || - transport.runtimeGeometryConstruction !== false || transport.runtimeProjection !== false || - transport.runtimeRasterization !== false || transport.runtimeLightingCalculation !== false || - transport.packs?.length !== transport.packCount) { - throw new Error("Complete prepared cssFlower visual-pack transport is required"); +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}`); } - let totalPackBytes = 0; - let maximumPackBytes = 0; - for (let packIndex = 0; packIndex < transport.packs.length; packIndex += 1) { - const pack = transport.packs[packIndex]; - const block = projected.layoutBlocks[packIndex]; - if (pack?.schema !== "cssflower-prepared-visual-pack@1" || pack.index !== packIndex || - pack.startPageIndex !== block.startPageIndex || pack.pageCount !== block.pageCount || - !Number.isSafeInteger(pack.byteLength) || pack.byteLength < 1 || - !/^[a-f0-9]{64}$/.test(pack.sha256 ?? "") || - pack.assetUrl !== `/cssflower/assets/projected/visual-pack-${pack.sha256}.bin` || - pack.layout?.byteOffset !== 0 || pack.layout.byteLength !== block.byteLength || - pack.layout.sha256 !== block.sha256 || pack.layout.decodedByteLength !== block.decodedByteLength || - pack.layout.decodedSha256 !== block.decodedSha256 || - pack.atlasSlices?.length !== pack.pageCount) { - throw new Error(`Prepared cssFlower visual pack ${packIndex} is invalid`); - } - let expectedOffset = pack.layout.byteLength; - for (let localPageIndex = 0; localPageIndex < pack.pageCount; localPageIndex += 1) { - const pageIndex = pack.startPageIndex + localPageIndex; - const page = projected.pages[pageIndex]; - const slice = pack.atlasSlices[localPageIndex]; - if (slice?.pageIndex !== pageIndex || slice.byteOffset !== expectedOffset || - slice.byteLength !== page.atlas.byteLength || slice.sha256 !== page.atlas.sha256 || - slice.mimeType !== page.atlas.mimeType) { - throw new Error(`Prepared cssFlower visual pack ${packIndex} atlas slice ${pageIndex} is invalid`); - } - expectedOffset += slice.byteLength; - } - if (expectedOffset !== pack.byteLength) { - throw new Error(`Prepared cssFlower visual pack ${packIndex} byte coverage is incomplete`); - } - totalPackBytes += pack.byteLength; - maximumPackBytes = Math.max(maximumPackBytes, pack.byteLength); - } - if (totalPackBytes !== transport.totalPackBytes || maximumPackBytes !== transport.maximumPackBytes) { - throw new Error("Prepared cssFlower visual-pack aggregate bytes are invalid"); + if (encoded.length !== schedule.faceIndicesByteLength) { + throw new Error("Generated cssFlower sparse-lighting address byte length drifted."); } -} - -function arraysEqual(actual, expected) { - return Array.isArray(actual) && actual.length === expected.length && - actual.every((value, index) => value === expected[index]); + return Uint8Array.from(encoded, (character) => character.charCodeAt(0)); } async function fetchJson(url, { notFoundMessage = "" } = {}) { @@ -604,15 +273,6 @@ async function fetchText(url, { notFoundMessage = "" } = {}) { return response.text(); } -async function fetchBytes(url, { notFoundMessage = "", signal } = {}) { - const response = await fetch(url, { signal }); - if (!response.ok) { - if (response.status === 404 && notFoundMessage) throw new Error(notFoundMessage); - 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."); @@ -634,11 +294,3 @@ async function assertSha256(bytes, expected, label) { 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/polycssScene.mjs b/src/adapters/flowerbox/src/cssflower/polycssScene.mjs index 98a3e83..2c236e2 100644 --- a/src/adapters/flowerbox/src/cssflower/polycssScene.mjs +++ b/src/adapters/flowerbox/src/cssflower/polycssScene.mjs @@ -1,82 +1,81 @@ import { collectPolyRenderStats } from "@layoutit/polycss"; -import { applyPreparedProjectedLeafLayout } from "./projectedPageStyles.mjs"; -export function mountPreparedPolycssSnapshot({ host, sceneData, snapshotHtml, projectedPages }) { - if (!(host instanceof HTMLElement)) throw new Error("Missing #scene host."); +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 cameraElement = doc.querySelector(".polycss-camera"); - if (!styleElement || !cameraElement) { - throw new Error("Prepared PolyCSS snapshot is missing style or camera DOM."); + 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); - importedStyle.setAttribute("data-cssflower-snapshot-style", "1"); document.head.appendChild(importedStyle); + preparedStyle = importedStyle; const importedCamera = document.importNode(cameraElement, true); - const importedRoot = importedCamera.querySelector("[data-cssflower-rotation-root]"); - const importedScene = importedCamera.querySelector(".polycss-scene"); - const importedMesh = importedRoot?.querySelector(".polycss-mesh"); - if (!(importedRoot instanceof HTMLElement) || !(importedScene instanceof HTMLElement) || - !(importedMesh instanceof HTMLElement) || !projectedPages?.urlFor || !projectedPages?.layoutFor) { - throw new Error("Prepared cssFlower projected page loader is missing."); + 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."); } - const importedLeaves = [...importedRoot.querySelectorAll("[data-cssflower-leaf-index]")] - .sort((left, right) => leafIndex(left) - leafIndex(right)); - const initialProjectedPage = sceneData.playback.projectedPixels.pages[0]; - importedCamera.style.setProperty("perspective", "none", "important"); - importedScene.style.setProperty("transform", "none", "important"); - importedScene.style.setProperty("transform-style", "preserve-3d", "important"); - importedRoot.style.setProperty("--cssflower-projected-atlas", `url("${projectedPages.urlFor(0)}")`); - importedRoot.style.setProperty("--cssflower-projected-frame-offset", "0px"); - importedMesh.style.setProperty("transform-origin", "0 0", "important"); - importedMesh.style.setProperty("transform-style", "preserve-3d", "important"); - importedMesh.style.transform = sceneData.playback.projectedPixels.inverseRootTransforms[0]; - applyPreparedProjectedLeafLayout({ - leaves: importedLeaves, - layoutValues: projectedPages.layoutFor(0), - atlas: initialProjectedPage.atlas, - }); + importedRoot.style.setProperty( + "--cssflower-space-texels", + `url("${preparedAssets.lightingPages.urlFor(0)}")`, + ); host.replaceChildren(importedCamera); - const camera = host.querySelector(".polycss-camera"); - const scene = host.querySelector(".polycss-scene"); - const rotationRoot = host.querySelector("[data-cssflower-rotation-root]"); - const mesh = rotationRoot?.querySelector(".polycss-mesh"); - if (!(camera instanceof HTMLElement) || !(scene instanceof HTMLElement) || - !(rotationRoot instanceof HTMLElement) || !(mesh instanceof HTMLElement)) { - throw new Error("Prepared cssFlower camera, scene, or rotation root is missing."); + 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 = [...rotationRoot.querySelectorAll("[data-cssflower-leaf-index]")] - .sort((left, right) => leafIndex(left) - leafIndex(right)); - const triangleIds = new Set(); - if (leaves.length !== 1200 || host.querySelectorAll("[data-cssflower-rotation-root]").length !== 1) { + + 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 triangleId = leaf.getAttribute("data-cssflower-triangle"); - const seamEdgeMaskText = leaf.getAttribute("data-cssflower-seam-edge-mask"); - const seamEdgeMask = seamEdgeMaskText === null ? null : Number(seamEdgeMaskText); - const expectedFace = sceneData.lighting?.faces?.[index]; - const expectedSeamEdgeMask = expectedFace?.seamEdgeMask; - const seamBleed = Number(leaf.getAttribute("data-cssflower-seam-bleed")); - const seamEdgeMaskInvalid = Number.isSafeInteger(expectedSeamEdgeMask) - ? seamEdgeMask !== expectedSeamEdgeMask - : seamEdgeMask !== null && (!Number.isSafeInteger(seamEdgeMask) || seamEdgeMask < 1 || seamEdgeMask > 7); - if (leafIndex(leaf) !== index || - leaf.getAttribute("data-cssflower-retained-leaf") !== "true" || - seamBleed !== expectedFace?.seamBleed || - seamEdgeMaskInvalid || - !triangleId || triangleIds.has(triangleId)) { - throw new Error(`Prepared cssFlower retained leaf ${index} is not source-addressable.`); + 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.`); } - triangleIds.add(triangleId); } - const stableNodes = Object.freeze([rotationRoot, ...leaves]); + + const stableNodes = Object.freeze([camera, scene, rotationRoot, mesh, ...leaves]); let runtimeDomCreationCount = 0; let runtimeDomRemovalCount = 0; const observer = new MutationObserver((records) => { @@ -86,14 +85,17 @@ export function mountPreparedPolycssSnapshot({ host, sceneData, snapshotHtml, pr } }); observer.observe(host, { childList: true, subtree: true }); - document.body.dataset.polycssArtifact = "prepared-snapshot"; function assertStableDomIdentity() { - const currentRoot = host.querySelector("[data-cssflower-rotation-root]"); - const currentLeaves = [...host.querySelectorAll("[data-cssflower-leaf-index]")] - .sort((left, right) => leafIndex(left) - leafIndex(right)); - if (currentRoot !== stableNodes[0] || currentLeaves.length !== leaves.length || - currentLeaves.some((leaf, index) => leaf !== stableNodes[index + 1])) { + 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; @@ -109,15 +111,14 @@ export function mountPreparedPolycssSnapshot({ host, sceneData, snapshotHtml, pr assertStableDomIdentity, stats() { assertStableDomIdentity(); - const polycss = collectPolyRenderStats(host, { + const polycss = collectPolyRenderStats(rotationRoot, { polygonCount: sceneData.metrics.preparedLeafCount, - scopeSelector: "[data-cssflower-rotation-root]", }); return Object.freeze({ mode: "prepared-snapshot", retainedRotationRootCount: 1, retainedTriangleLeafCount: leaves.length, - retainedTriangleIdCount: triangleIds.size, + retainedTriangleIdCount: triangleIds.length, runtimeDomCreationCount, runtimeDomRemovalCount, runtimeDomMutationCount: runtimeDomCreationCount + runtimeDomRemovalCount, @@ -133,12 +134,7 @@ export function mountPreparedPolycssSnapshot({ host, sceneData, snapshotHtml, pr }); } -function leafIndex(element) { - const value = Number(element.getAttribute("data-cssflower-leaf-index")); - if (!Number.isInteger(value)) throw new Error("Prepared cssFlower leaf index must be an integer."); - return value; -} - function removePreparedSnapshotStyles() { - for (const style of document.querySelectorAll("style[data-cssflower-snapshot-style]")) style.remove(); + 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 index 3594dad..8f2552f 100644 --- a/src/adapters/flowerbox/src/cssflower/preparedPlayback.mjs +++ b/src/adapters/flowerbox/src/cssflower/preparedPlayback.mjs @@ -1,5 +1,10 @@ import { createPolyMorphPreparedDomTarget } from "@layoutit/polycss-morph"; -import { applyPreparedProjectedLeafLayout } from "./projectedPageStyles.mjs"; +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"); @@ -8,34 +13,59 @@ export function timelineStateIndexForTick(tick, cycle) { } export async function createCssflowerPreparedPlayer(options) { - const { playback, mesh, projectedPages, rotationRoot } = options; + const { + lighting, + lightingPages, + mesh, + playback, + rotationRoot, + transformBlocks, + } = options; const leaves = [...options.leaves]; - validatePlayback(playback, projectedPages, rotationRoot, mesh, leaves); - const projected = playback.projectedPixels; + 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 now = options.now ?? (() => globalThis.performance.now()); 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 projectedPageIndex = 0; - let projectedFrameIndex = -1; + let lightingPageIndex = -1; + let lightingPageRowIndex = -1; let preparedStatesApplied = 0; + let preparedGeometryStatePublishes = 0; let modelTransformWrites = 0; - let shapeTransformWrites = 0; - let projectedFrameWrites = 0; - let projectedAtlasWrites = 0; - let preparedPageLayoutAdoptions = 0; - let preparedPageBoundaryLeafStyleWrites = 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; - let runtimeSchedulerStateTransitions = 0; - let runtimeSchedulerLateResetCount = 0; - let runtimeSchedulerMaximumLatenessMs = 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: { @@ -50,59 +80,144 @@ export async function createCssflowerPreparedPlayer(options) { 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 nextRootStateIndex = state.rootStateIndex; - const nextProjectedPageIndex = state.projectedPageIndex; - const nextProjectedFrameIndex = state.projectedFrameIndex; - let nextResidentPageIndex = null; - if (nextProjectedPageIndex !== projectedPageIndex) { - nextResidentPageIndex = projectedPageAfter(projected, playback.cycle, nextProjectedPageIndex); - const record = await projectedPages.activate(nextProjectedPageIndex, nextResidentPageIndex); - const atlasImage = `url("${record.url}")`; - if (rotationRoot.style.getPropertyValue("--cssflower-projected-atlas") !== atlasImage) { - rotationRoot.style.setProperty("--cssflower-projected-atlas", atlasImage); - projectedAtlasWrites += 1; - } - applyPreparedProjectedLeafLayout({ - leaves, - layoutValues: record.layoutValues, - atlas: projected.pages[nextProjectedPageIndex].atlas, + 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; }); - preparedPageLayoutAdoptions += 1; - preparedPageBoundaryLeafStyleWrites += leaves.length; + 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; + } + } } - const frameValue = `${projected.pages[nextProjectedPageIndex].atlas.frameBackgroundOffsets[nextProjectedFrameIndex]}px`; - if (rotationRoot.style.getPropertyValue("--cssflower-projected-frame-offset") !== frameValue) { - rotationRoot.style.setProperty("--cssflower-projected-frame-offset", frameValue); - projectedFrameWrites += 1; + + if (lightingPageChanged) { + await lightingPages.activate( + state.lightingPageIndex, + state.nextLightingPageIndex, + ); + lightingPages.commitPresented(state.lightingPageIndex, state.nextLightingPageIndex); } - if (morphTarget.model.writeTransform(playback.cycle.rootTransforms[nextRootStateIndex])) { + applyPreparedLightingAddresses(nextTimelineStateIndex); + if (morphTarget.model.writeTransform(playback.cycle.rootTransforms[state.rootStateIndex])) { modelTransformWrites += 1; } - if (morphTarget.shapes[0].writeTransform(projected.inverseRootTransforms[nextRootStateIndex])) { - shapeTransformWrites += 1; - } + globalTick = tick; timelineStateIndex = nextTimelineStateIndex; geometryStateIndex = state.geometryStateIndex; - rootStateIndex = nextRootStateIndex; - projectedPageIndex = nextProjectedPageIndex; - projectedFrameIndex = nextProjectedFrameIndex; + rootStateIndex = state.rootStateIndex; + lightingPageIndex = state.lightingPageIndex; + lightingPageRowIndex = state.lightingPageRowIndex; preparedStatesApplied += 1; - rotationRoot.dataset.cssflowerGlobalTick = String(globalTick); - rotationRoot.dataset.cssflowerTimelineStateIndex = String(timelineStateIndex); - rotationRoot.dataset.cssflowerGeometryStateIndex = String(geometryStateIndex); - rotationRoot.dataset.cssflowerRootStateIndex = String(rootStateIndex); - rotationRoot.dataset.cssflowerProjectedPage = String(projectedPageIndex); - rotationRoot.dataset.cssflowerProjectedFrame = String(projectedFrameIndex); - if (nextResidentPageIndex !== null) { - await waitForPresentedPaint(requestFrame); - projectedPages.commitPresented(nextProjectedPageIndex, nextResidentPageIndex); - } morphTarget.assertStableDomIdentity(); return globalTick; } @@ -114,32 +229,26 @@ export async function createCssflowerPreparedPlayer(options) { if (nextFrameAt === null) { nextFrameAt = timestamp + frameMilliseconds; } else if (timestamp >= nextFrameAt - 0.5) { - const scheduledAt = nextFrameAt; - await applyTick(globalTick + 1); - runtimeSchedulerStateTransitions += 1; - nextFrameAt = scheduledAt + frameMilliseconds; - const completedAt = now(); - if (completedAt > nextFrameAt) { - const lateness = completedAt - nextFrameAt; - runtimeSchedulerLateResetCount += 1; - runtimeSchedulerMaximumLatenessMs = Math.max(runtimeSchedulerMaximumLatenessMs, lateness); - nextFrameAt = completedAt + frameMilliseconds; - } + 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() { - paused = true; - nextFrameAt = null; - if (request !== null) cancelFrame(request); - request = null; - return globalTick; - }, + pause, resume() { if (!paused) return globalTick; paused = false; @@ -148,13 +257,13 @@ export async function createCssflowerPreparedPlayer(options) { return globalTick; }, async step(count = 1) { - this.pause(); + 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) { - this.pause(); + pause(); const tick = Math.trunc(Number(value)); return applyTick(tick); }, @@ -162,6 +271,16 @@ export async function createCssflowerPreparedPlayer(options) { morphTarget.assertStableDomIdentity(); return true; }, + sample() { + return Object.freeze({ + globalTick, + timelineStateIndex, + geometryStateIndex, + rootStateIndex, + lightingPageIndex, + lightingPageRowIndex, + }); + }, stats() { morphTarget.assertStableDomIdentity(); const state = playback.cycle.states[timelineStateIndex]; @@ -174,9 +293,10 @@ export async function createCssflowerPreparedPlayer(options) { globalTick, timelineStateIndex, geometryStateIndex, + transformBlockIndex: state.transformBlockIndex, rootStateIndex, - projectedPageIndex, - projectedFrameIndex, + lightingPageIndex, + lightingPageRowIndex, sourceSf: state.sf, sourceSfHex: state.sfHex, sourceSfi: state.sfi, @@ -189,22 +309,43 @@ export async function createCssflowerPreparedPlayer(options) { preparedTimelineStateCount: playback.cycle.stateCount, preparedGeometryStateCount: playback.cycle.geometryStateCount, preparedRootStateCount: playback.cycle.rootStateCount, - preparedProjectedPageCount: projected.pageCount, + preparedTransformBlockCount: playback.transformAsset.blockCount, + preparedLightingPageCount: lighting.pageCount, preparedStatesApplied, + runtimePreparedGeometryStatePublishes: preparedGeometryStatePublishes, runtimeModelTransformWrites: modelTransformWrites, - runtimeShapeTransformWrites: shapeTransformWrites, - runtimeLeafTransformWrites: 0, - runtimePerFrameLeafStyleWrites: 0, - runtimeProjectedFrameWrites: projectedFrameWrites, - runtimeProjectedAtlasWrites: projectedAtlasWrites, - runtimePreparedPageLayoutAdoptions: preparedPageLayoutAdoptions, - runtimePreparedPageBoundaryLeafStyleWrites: preparedPageBoundaryLeafStyleWrites, - projectedPageLoader: projectedPages.stats(), + 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, - runtimeSchedulerStateTransitions, - runtimeSchedulerSkippedPreparedStateCount: 0, - runtimeSchedulerLateResetCount, - runtimeSchedulerMaximumLatenessMs, runtimePolygonConstructionCount: 0, runtimeGeometryConstructionCount: 0, runtimeRadialProjectionCount: 0, @@ -222,50 +363,142 @@ export async function createCssflowerPreparedPlayer(options) { return Object.freeze({ rotationRoot, mesh, leaves: Object.freeze([...leaves]) }); }, destroy() { - this.pause(); + if (destroyed) return; + destroyed = true; + pause(); morphTarget.destroy(); }, }); } -function projectedPageAfter(projected, cycle, pageIndex) { - if (pageIndex + 1 < projected.pageCount) return pageIndex + 1; - return cycle.states[cycle.cycleStartState].projectedPageIndex; -} - -function validatePlayback(playback, projectedPages, rotationRoot, mesh, leaves) { - const projected = playback?.projectedPixels; +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?.stateCount !== 9_331 || - playback.cycle?.cycleStartState !== 331 || - playback.cycle?.cycleLength !== 9_000 || - playback.cycle?.bloomTraceStateCount !== 581 || - playback.cycle?.bloomCycleLength !== 250 || - playback.cycle?.geometryStateCount !== 414 || + 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 !== 9_331 || + playback.cycle?.states?.length !== 360 || playback.cycle?.rootTransforms?.length !== 360 || - projected?.schema !== "cssflower-prepared-projected-pixel-playback@1" || - projected.stateCount !== playback.cycle.stateCount || - projected.retainedLeafCount !== 1_200 || - projected.pages?.length !== projected.pageCount || - projected.inverseRootTransforms?.length !== playback.cycle.rootStateCount || playback.cycle.states.some((state) => !Number.isSafeInteger(state.rootStateIndex) || state.rootStateIndex < 0 || state.rootStateIndex >= 360 || - !Number.isSafeInteger(state.projectedPageIndex) || state.projectedPageIndex < 0 || - state.projectedPageIndex >= projected.pageCount || - !Number.isSafeInteger(state.projectedFrameIndex) || state.projectedFrameIndex < 0 || - state.projectedFrameIndex >= projected.pages[state.projectedPageIndex].usedFrameCount) || - !projectedPages?.activate || !projectedPages?.commitPresented || - !projectedPages?.urlFor || !projectedPages?.layoutFor || !projectedPages?.stats || + !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 projected Morph playback is required"); + 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 waitForPresentedPaint(requestFrame) { - return new Promise((resolvePaint) => requestFrame(() => requestFrame(resolvePaint))); +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/projectedPageStyles.mjs b/src/adapters/flowerbox/src/cssflower/projectedPageStyles.mjs deleted file mode 100644 index e991b17..0000000 --- a/src/adapters/flowerbox/src/cssflower/projectedPageStyles.mjs +++ /dev/null @@ -1,70 +0,0 @@ -const COMPONENTS_PER_LEAF = 6; - -export function applyPreparedProjectedLeafLayout({ leaves, layoutValues, atlas }) { - const frameOffsetAxis = preparedFrameOffsetAxis(atlas); - if (!Array.isArray(leaves) || leaves.length !== 1_200 || - !(layoutValues instanceof Int16Array) || layoutValues.length !== leaves.length * COMPONENTS_PER_LEAF || - !Number.isSafeInteger(atlas?.width) || atlas.width < 1 || - !Number.isSafeInteger(atlas?.height) || atlas.height < 1) { - throw new TypeError("Complete prepared projected leaf layout is required"); - } - for (let leafIndex = 0; leafIndex < leaves.length; leafIndex += 1) { - const offset = leafIndex * COMPONENTS_PER_LEAF; - const width = layoutValues[offset]; - const height = layoutValues[offset + 1]; - const dx = layoutValues[offset + 2]; - const dy = layoutValues[offset + 3]; - const backgroundX = layoutValues[offset + 4]; - const backgroundY = layoutValues[offset + 5]; - leaves[leafIndex].style.cssText = width === 0 - ? hiddenProjectedLeafCss() - : visibleProjectedLeafCss({ width, height, dx, dy, backgroundX, backgroundY, atlas, frameOffsetAxis }); - } - return leaves.length; -} - -function visibleProjectedLeafCss({ width, height, dx, dy, backgroundX, backgroundY, atlas, frameOffsetAxis }) { - const backgroundPosition = frameOffsetAxis === "x" - ? `calc(${backgroundX}px + var(--cssflower-projected-frame-offset)) ${backgroundY}px` - : `${backgroundX}px calc(${backgroundY}px + var(--cssflower-projected-frame-offset))`; - return [ - "position:absolute", - "display:block", - "left:0", - "top:0", - `width:${width}px`, - `height:${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:translate3d(${dx}px,${dy}px,0px)`, - "background-image:var(--cssflower-projected-atlas)", - "background-color:transparent", - "background-repeat:no-repeat", - `background-position:${backgroundPosition}`, - `background-size:${atlas.width}px ${atlas.height}px`, - "image-rendering:auto", - "color:transparent", - "line-height:0", - "text-decoration:none", - ].join(";"); -} - -function preparedFrameOffsetAxis(atlas) { - if (atlas?.packing === "horizontal-union") return "x"; - if (atlas?.packing === "vertical-union") return "y"; - throw new TypeError("Prepared projected atlas packing is invalid"); -} - -function hiddenProjectedLeafCss() { - return "position:absolute;display:none;left:0;top:0;width:0;height:0;transform:none;background:none;border:0"; -} diff --git a/src/adapters/flowerbox/src/cssflower/renderContract.mjs b/src/adapters/flowerbox/src/cssflower/renderContract.mjs index 560d5bd..35cc18f 100644 --- a/src/adapters/flowerbox/src/cssflower/renderContract.mjs +++ b/src/adapters/flowerbox/src/cssflower/renderContract.mjs @@ -3,29 +3,62 @@ 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@3"; -export const CSSFLOWER_LIGHTING_LAYOUT = "guttered-leaf-raster-shelves-by-paged-timeline-state-slices"; +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 = 292; +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_ATLAS_HEIGHT = 7_232; +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_GUTTER = 1; -export const CSSFLOWER_PROJECTED_ATLAS_ENCODING = "avif-lossy-q40-speed6-yuv444"; +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 = 40; -export const CSSFLOWER_PROJECTED_VISUAL_BANK_MAX_BYTES = 40_000_000; +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-q40-exact-reference-visual-envelope@1", - selection: "preselected-between-clean-q40-and-first-visible-blocking-q35", + 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.55, - rmsDelta: 3.25, - changedPixelRatio: 0.4, - maxAbsDelta: 250, + meanAbsDelta: 0.35, + rmsDelta: 2.5, + changedPixelRatio: 0.38, + maxAbsDelta: 225, alphaMaxAbsDelta: 0, - interiorMeanAbsDelta: 2.5, - interiorRmsDelta: 3.5, + interiorMeanAbsDelta: 1.75, + interiorRmsDelta: 2.5, exactBackgroundMaxAbsDelta: 0, }); diff --git a/src/adapters/flowerbox/src/cssflower/stagePresentation.mjs b/src/adapters/flowerbox/src/cssflower/stagePresentation.mjs index 09d0a25..41dee6a 100644 --- a/src/adapters/flowerbox/src/cssflower/stagePresentation.mjs +++ b/src/adapters/flowerbox/src/cssflower/stagePresentation.mjs @@ -1,5 +1,5 @@ export const CSSFLOWER_PREPARED_STAGE_EDGE = 720; -export const CSSFLOWER_RESPONSIVE_PRESENTATION_INSET = 1; +export const CSSFLOWER_RESPONSIVE_STAGE_FRACTION = 1; export function cssflowerStageScale(viewportWidth, viewportHeight) { if (!Number.isFinite(viewportWidth) || !Number.isFinite(viewportHeight) || @@ -7,44 +7,55 @@ export function cssflowerStageScale(viewportWidth, viewportHeight) { throw new RangeError("cssFlower viewport dimensions must be positive finite numbers"); } return Math.min(viewportWidth, viewportHeight) / CSSFLOWER_PREPARED_STAGE_EDGE * - CSSFLOWER_RESPONSIVE_PRESENTATION_INSET; + CSSFLOWER_RESPONSIVE_STAGE_FRACTION; } -export function installCssflowerStagePresentation(host) { +export function installCssflowerStagePresentation({ host, camera }) { if (!(host instanceof HTMLElement)) throw new TypeError("cssFlower stage host is missing"); - const mode = "product"; + if (!(camera instanceof HTMLElement) || !camera.classList.contains("polycss-camera")) { + throw new TypeError("cssFlower prepared camera is missing"); + } let scale = 1; let writes = 0; - - document.body.dataset.stagePresentation = mode; + let resizeObserver = null; function apply() { - const nextScale = cssflowerStageScale(host.clientWidth, host.clientHeight); + const rect = host.getBoundingClientRect(); + const nextScale = cssflowerStageScale(rect.width, rect.height); const serialized = String(Number(nextScale.toFixed(8))); - if (host.style.getPropertyValue("--cssflower-presentation-scale") !== serialized) { - host.style.setProperty("--cssflower-presentation-scale", serialized); + if (camera.style.scale !== serialized) { + camera.style.scale = serialized; writes += 1; } - scale = nextScale; + scale = Number(serialized); } apply(); - window.addEventListener("resize", apply, { passive: true }); + if (typeof ResizeObserver === "function") { + resizeObserver = new ResizeObserver(apply); + resizeObserver.observe(host); + } else { + window.addEventListener("resize", apply, { passive: true }); + } return Object.freeze({ - get mode() { return mode; }, stats() { return Object.freeze({ - stagePresentation: mode, + stagePresentation: "responsive", preparedStageEdgePixels: CSSFLOWER_PREPARED_STAGE_EDGE, - responsivePresentationInset: CSSFLOWER_RESPONSIVE_PRESENTATION_INSET, + responsivePresentationFit: "contain", + responsivePresentationStageFraction: CSSFLOWER_RESPONSIVE_STAGE_FRACTION, presentationScale: scale, runtimePresentationScaleWrites: writes, runtimeModelGeometryCalculations: 0, }); }, destroy() { - window.removeEventListener("resize", apply); + 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 index 2b3358f..cc57aee 100644 --- a/src/adapters/flowerbox/src/cssflower/styles.css +++ b/src/adapters/flowerbox/src/cssflower/styles.css @@ -1,88 +1,32 @@ :root { - color-scheme: dark; - font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; background: #000; - color: #d8d8d8; + color-scheme: dark; } html, -body, -#app, -#scene { - width: 100%; - height: 100%; +html > body { margin: 0; overflow: hidden; background: #000; } -#app, -#scene { - position: fixed; - inset: 0; +html { + width: 100%; + height: 100%; } -#scene { - contain: layout paint size style; +html > body { + position: relative; + width: 100vw; + height: 100vh; + height: 100dvh; } -#scene > .polycss-camera { +body > .polycss-camera { position: absolute !important; - left: 50% !important; - top: 50% !important; + inset: calc(50% - 360px) auto auto calc(50% - 360px) !important; width: 720px !important; height: 720px !important; - translate: -50% -50%; - scale: var(--cssflower-presentation-scale, 1); + scale: 1; transform-origin: 50% 50%; } - -#status, -#credit { - position: fixed; - z-index: 2; - margin: 0; - padding: 8px 10px; - background: rgb(0 0 0 / 72%); - color: #b9c1bc; - font-size: 11px; - line-height: 1.35; -} - -#status { - left: 50%; - bottom: 12px; - max-width: min(640px, calc(100vw - 24px)); - translate: -50% 0; - transition: opacity 180ms ease; -} - -#credit { - right: 8px; - bottom: 8px; - opacity: 0.42; -} - -#credit a { - color: inherit; -} - -body[data-port-status="ready"] #status { - opacity: 0; - pointer-events: none; -} - -body[data-port-status="error"] #status { - color: #ffd5cf; - opacity: 1; -} - -.cssflower-mesh { - pointer-events: none; -} - -@media (max-width: 600px) { - #credit { - font-size: 9px; - } -} diff --git a/src/adapters/flowerbox/src/main.mjs b/src/adapters/flowerbox/src/main.mjs index ebef4c6..96c90f1 100644 --- a/src/adapters/flowerbox/src/main.mjs +++ b/src/adapters/flowerbox/src/main.mjs @@ -1,4 +1,3 @@ -import "./cssflower/styles.css"; import { mountCssflowerClient } from "./cssflower/client.mjs"; -mountCssflowerClient(); +mountCssflowerClient(document.body); diff --git a/src/adapters/flowerbox/src/prepare/cssflower/bloomCycle.mjs b/src/adapters/flowerbox/src/prepare/cssflower/bloomCycle.mjs index e2e1d04..ea14c38 100644 --- a/src/adapters/flowerbox/src/prepare/cssflower/bloomCycle.mjs +++ b/src/adapters/flowerbox/src/prepare/cssflower/bloomCycle.mjs @@ -6,6 +6,11 @@ import { 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); @@ -116,6 +121,68 @@ export function buildPreparedFullRotationCycle() { }); } +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; } diff --git a/src/adapters/flowerbox/src/prepare/cssflower/compilePreparedCycle.mjs b/src/adapters/flowerbox/src/prepare/cssflower/compilePreparedCycle.mjs index ab509d7..68ca2e2 100644 --- a/src/adapters/flowerbox/src/prepare/cssflower/compilePreparedCycle.mjs +++ b/src/adapters/flowerbox/src/prepare/cssflower/compilePreparedCycle.mjs @@ -8,12 +8,22 @@ import { 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 } from "./bloomCycle.mjs"; +import { + buildPreparedFullRotationCycle, + buildPreparedRoundedProductCycle, +} from "./bloomCycle.mjs"; import { buildCubeTopology, buildSideSiblingSeamPlan, @@ -48,19 +58,20 @@ export async function compilePreparedCssflowerCycle({ throw new Error("cssFlower prepared seam policy drifted"); } const seamEdgesByMask = buildSeamEdgesByMask(); - const cycle = attachPreparedLightingRows(buildPreparedFullRotationCycle()); - const quadMergeAudit = auditPreparedQuadMergeEligibility(topology, cycle); - const rasterFaces = selectPreparedRasterFaces(topology, cycle, siblingSeamPlan, seamEdgesByMask); + 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(cycle.geometryStateCount * topology.triangleCount * MATRIX_COMPONENTS); + const matrixValues = new Float32Array(sourceCycle.geometryStateCount * topology.triangleCount * MATRIX_COMPONENTS); const atlasWidth = rasterLayout.atlasWidth; const atlasHeight = rasterLayout.atlasHeight; const stateEvidence = []; - const geometryByState = new Array(cycle.geometryStateCount); - const canonicalPointIndices = new Uint16Array(cycle.geometryStateCount * topology.triangleCount * 3); + const geometryByState = new Array(sourceCycle.geometryStateCount); + const canonicalPointIndices = new Uint16Array(sourceCycle.geometryStateCount * topology.triangleCount * 3); let initialPolygons = null; - for (const geometryState of cycle.geometryStates) { + for (const geometryState of sourceCycle.geometryStates) { const positions = deformCubePoints(topology, geometryState.sf); const normals = computeSmoothPointNormals(topology, positions); geometryByState[geometryState.index] = Object.freeze({ positions, normals }); @@ -106,7 +117,7 @@ export async function compilePreparedCssflowerCycle({ ], (geometryState.index * topology.triangleCount + triangle.index) * 3); } - if (geometryState.index === cycle.states[0].geometryStateIndex) { + if (geometryState.index === sourceCycle.states[0].geometryStateIndex) { initialPolygons = topology.triangles.map((triangle) => trianglePolygon(topology, triangle, positions)); } const transformStart = transformStateOffset * Float32Array.BYTES_PER_ELEMENT; @@ -125,15 +136,32 @@ export async function compilePreparedCssflowerCycle({ if (!initialPolygons || initialPolygons.length !== 1200) { throw new Error("cssFlower initial 1,200-triangle product was not compiled"); } - const transformBytes = Buffer.from(matrixValues.buffer); + 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, - geometryByState, canonicalPointIndices, atlasWidth, atlasHeight, rasterLayout, + vertexLightingByState, readLightingPage, writeLightingPage, }); @@ -153,13 +181,18 @@ export async function compilePreparedCssflowerCycle({ }), topology: topologyEvidence, update: Object.freeze({ - stateCount: cycle.stateCount, - geometryStateCount: cycle.geometryStateCount, - cycleStartState: cycle.cycleStartState, - cycleLength: cycle.cycleLength, - bloomTraceStateCount: cycle.bloomTraceStateCount, - bloomCycleLength: cycle.bloomCycleLength, - rootStateCount: cycle.rootStateCount, + 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, @@ -192,12 +225,15 @@ export async function compilePreparedCssflowerCycle({ maximumLeafHeight: Math.max(...rasterFaces.map((face) => face.leafHeight)), pageRows: CSSFLOWER_LIGHTING_PAGE_ROWS, pageCount: lightingPages.length, - decodedResidentPageBudget: 2, - decodedPeakPageBudget: 3, + 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(cycle.states.map((state) => Object.freeze({ + ticks: Object.freeze(sourceCycle.states.map((state) => Object.freeze({ tick: state.tick, sf: state.sf, sfHex: state.sfHex, @@ -208,14 +244,13 @@ export async function compilePreparedCssflowerCycle({ rotationZDegrees: state.rotationZDegrees, rootStateIndex: state.rootStateIndex, geometryStateIndex: state.geometryStateIndex, - lightingPageIndex: state.lightingPageIndex, - lightingPageRowIndex: state.lightingPageRowIndex, }))), }); return Object.freeze({ topology, cycle, + sourceCycle, initialPolygons: Object.freeze(initialPolygons), transformBytes, transformSha256: sha256(transformBytes), @@ -232,8 +267,8 @@ export async function compilePreparedCssflowerCycle({ physicalLayout: CSSFLOWER_LIGHTING_LAYOUT, assetUrl: firstLightingPage.assetUrl, assetSha256: firstLightingPage.sha256, - rasterMode: "leaf-resolution", - sampling: rasterLayout.sampling, + rasterMode: CSSFLOWER_LIGHTING_RASTER_MODE, + sampling: CSSFLOWER_LIGHTING_SAMPLING, gutter: rasterLayout.gutter, gutterPolicy: rasterLayout.gutterPolicy, packing: rasterLayout.packing, @@ -243,6 +278,10 @@ export async function compilePreparedCssflowerCycle({ 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, @@ -260,13 +299,21 @@ export async function compilePreparedCssflowerCycle({ 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, - decodedResidentPageBudget: 2, - decodedPeakPageBudget: 3, + 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`, @@ -283,19 +330,18 @@ export async function compilePreparedCssflowerCycle({ slotHeight: placement.slotHeight, contentX: placement.contentX, contentY: placement.contentY, - backgroundSize: `${atlasWidth}px ${atlasHeight}px`, + backgroundSize: `${CSSFLOWER_LIGHTING_GRID_WIDTH}px ${CSSFLOWER_LIGHTING_GRID_HEIGHT}px`, backgroundPositionX: `${-placement.contentX}px`, - backgroundPositionY: `calc(var(--cssflower-lighting-y) - ${placement.contentY}px)`, + backgroundPositionY: `${-placement.contentY}px`, }); })), - rowSelection: "prepared-timeline-state-page-and-row-index", + 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: 2, - runtimePreparedPagePreload: true, - runtimeDecodedResidentPageBudget: 2, - runtimeDecodedPeakPageBudget: 3, + runtimeRootFrameVariables: 1, + runtimePreparedPagePreload: false, + runtimeLightingAssetCount: 1, runtimeLightingCalculations: 0, runtimeAtlasConstruction: 0, }), @@ -319,11 +365,11 @@ function attachPreparedLightingRows(sourceCycle) { async function prepareLightingPages({ topology, cycle, - geometryByState, canonicalPointIndices, atlasWidth, atlasHeight, rasterLayout, + vertexLightingByState, readLightingPage, writeLightingPage, }) { @@ -363,14 +409,10 @@ async function prepareLightingPages({ ); for (let rowIndex = 0; rowIndex < usedRowCount; rowIndex += 1) { const state = cycle.states[startStateIndex + rowIndex]; - const geometry = geometryByState[state.geometryStateIndex]; - if (!geometry) throw new Error(`cssFlower lighting state ${state.tick} has no prepared geometry`); - const vertexColors = computePreparedVertexLightingUnquantized( - topology, - geometry.positions, - geometry.normals, - state, - ); + 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, @@ -403,6 +445,96 @@ async function prepareLightingPages({ }); } +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) { @@ -587,11 +719,6 @@ function clampByte(value) { return Math.max(0, Math.min(255, Math.round(value))); } -function formatCssNumber(value) { - const rounded = Math.round(value * 1_000_000) / 1_000_000; - return String(Object.is(rounded, -0) ? 0 : rounded); -} - 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/dataSource.mjs b/src/adapters/flowerbox/src/prepare/cssflower/dataSource.mjs index 3e18f1f..87b1f57 100644 --- a/src/adapters/flowerbox/src/prepare/cssflower/dataSource.mjs +++ b/src/adapters/flowerbox/src/prepare/cssflower/dataSource.mjs @@ -1,10 +1,17 @@ -import { existsSync, statSync } from "node:fs"; +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()) { @@ -16,8 +23,20 @@ export async function resolveCssflowerDataSource(options = {}) { root, publicLabel: NATIVE_ROOT_ENV, legalLabel: "owned-local-authority-not-redistributed", - nativeAuthorityStatus: "local-input-not-packaged", - nativeQualification: null, + 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, }; } @@ -27,7 +46,33 @@ export async function resolveCssflowerDataSource(options = {}) { root: null, publicLabel: "src/adapters/flowerbox/README.md", legalLabel: "independently-authored-results-only", - nativeAuthorityStatus: "not-packaged", + 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 index ce2ef04..41bec9d 100644 --- a/src/adapters/flowerbox/src/prepare/cssflower/leafRasterLighting.mjs +++ b/src/adapters/flowerbox/src/prepare/cssflower/leafRasterLighting.mjs @@ -1,15 +1,18 @@ 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 = CSSFLOWER_LIGHTING_GUTTER; -export const CSSFLOWER_LEAF_RASTER_ATLAS_WIDTH = CSSFLOWER_LIGHTING_ATLAS_WIDTH; +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) { @@ -105,6 +108,157 @@ export function buildPreparedLeafRasterLayout(faces, options = {}) { }); } +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, @@ -227,6 +381,32 @@ function preparedLightingWeights(width, height) { 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) { @@ -244,3 +424,7 @@ export function leafRasterBackgroundBinding(layout, faceIndex, rowIndex) { 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 index 519acad..1bdc928 100644 --- a/src/adapters/flowerbox/src/prepare/cssflower/paths.mjs +++ b/src/adapters/flowerbox/src/prepare/cssflower/paths.mjs @@ -1,6 +1,9 @@ import { fileURLToPath } from "node:url"; import { join, resolve } from "node:path"; -import { CSSFLOWER_PROJECTED_ATLAS_EXTENSION } from "../../cssflower/renderContract.mjs"; +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))); @@ -11,6 +14,8 @@ export const generatedRoot = resolve( 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"); @@ -35,6 +40,26 @@ export function generatedLightingPagePath(pageIndex) { : 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}`); diff --git a/src/adapters/flowerbox/src/prepare/cssflower/prepare.mjs b/src/adapters/flowerbox/src/prepare/cssflower/prepare.mjs index 2d61121..d2e1705 100644 --- a/src/adapters/flowerbox/src/prepare/cssflower/prepare.mjs +++ b/src/adapters/flowerbox/src/prepare/cssflower/prepare.mjs @@ -1,6 +1,7 @@ import { buildCssflowerFirstSliceScene, } from "./sceneBuilder.mjs"; +import { compilePreparedCssflowerCycle } from "./compilePreparedCycle.mjs"; import { resolveCssflowerDataSource, } from "./dataSource.mjs"; @@ -11,7 +12,6 @@ import { writeCssflowerPreparedAssets, createCssflowerPreparedLightingPageStore, } from "./writePreparedAssets.mjs"; -import { prepareCssflowerSharedFrameWindowPages } from "./sharedFramePageStore.mjs"; export async function prepareCssflower(options = {}) { const dataSource = await resolveCssflowerDataSource({ @@ -19,18 +19,18 @@ export async function prepareCssflower(options = {}) { }); const sceneId = options.scene ?? "default-cube"; const lightingPageStore = await createCssflowerPreparedLightingPageStore(); - const projectedPixels = await prepareCssflowerSharedFrameWindowPages({ - concurrency: options.concurrency, - onProgress: options.onProjectedProgress, + const compiled = await compilePreparedCssflowerCycle({ + nativeAuthorityStatus: dataSource?.nativeAuthorityStatus ?? "missing", + readLightingPage: lightingPageStore.read, + writeLightingPage: lightingPageStore.write, }); - const { scene, compiled } = await buildCssflowerFirstSliceScene({ + const assets = await writeCssflowerPreparedAssets(compiled, { lightingPageStore }); + const { scene } = await buildCssflowerFirstSliceScene({ + compiled, dataSource, - projectedPixels, + preparedAssets: assets, sceneId, - readLightingPage: lightingPageStore.read, - writeLightingPage: lightingPageStore.write, }); - const assets = await writeCssflowerPreparedAssets(compiled, projectedPixels); const output = await writeCssflowerPreparedOutput({ scenes: [scene], defaultSceneId: scene.id, diff --git a/src/adapters/flowerbox/src/prepare/cssflower/projectedPixels.mjs b/src/adapters/flowerbox/src/prepare/cssflower/projectedPixels.mjs index f89496f..d4d25e9 100644 --- a/src/adapters/flowerbox/src/prepare/cssflower/projectedPixels.mjs +++ b/src/adapters/flowerbox/src/prepare/cssflower/projectedPixels.mjs @@ -1,6 +1,9 @@ import { createHash } from "node:crypto"; import { PNG } from "pngjs"; -import { buildPreparedFullRotationCycle } from "./bloomCycle.mjs"; +import { + buildPreparedFullRotationCycle, + buildPreparedRoundedProductCycle, +} from "./bloomCycle.mjs"; import { buildCubeTopology, computeSmoothPointNormals, @@ -19,6 +22,8 @@ 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) => ( @@ -26,6 +31,213 @@ export function buildCssflowerPreparedInverseRootTransforms() { ))); } +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, diff --git a/src/adapters/flowerbox/src/prepare/cssflower/sceneBuilder.mjs b/src/adapters/flowerbox/src/prepare/cssflower/sceneBuilder.mjs index 9b5db70..6e6e329 100644 --- a/src/adapters/flowerbox/src/prepare/cssflower/sceneBuilder.mjs +++ b/src/adapters/flowerbox/src/prepare/cssflower/sceneBuilder.mjs @@ -1,39 +1,51 @@ -import { compilePreparedCssflowerCycle } from "./compilePreparedCycle.mjs"; +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_PROJECTED_ATLAS_ENCODING, - CSSFLOWER_PROJECTED_ATLAS_MIME_TYPE, - CSSFLOWER_PROJECTED_ATLAS_QUALITY, + 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"; -import { - generatedProjectedAtlasUrl, - generatedSharedLayoutBlockUrl, -} from "./paths.mjs"; export async function buildCssflowerFirstSliceScene({ + compiled, dataSource, - projectedPixels, - readLightingPage, + preparedAssets, sceneId = "default-cube", - writeLightingPage, } = {}) { if (sceneId !== "default-cube") { throw new RangeError(`Unknown prepared cssFlower scene ${sceneId}`); } - const compiled = await compilePreparedCssflowerCycle({ - nativeAuthorityStatus: dataSource?.nativeAuthorityStatus ?? "missing", - readLightingPage, - writeLightingPage, - }); - if (projectedPixels?.schema !== "cssflower-prepared-shared-frame-window-pages@1" || - projectedPixels.stateCount !== compiled.cycle.stateCount || - projectedPixels.retainedLeafCount !== compiled.topology.triangleCount) { - throw new Error("Complete source-bound projected-pixel preparation is required"); + 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", @@ -48,7 +60,7 @@ export async function buildCssflowerFirstSliceScene({ morphPackage: "@layoutit/polycss-morph", morphVersion: "0.2.11", morphTarget: "createPolyMorphPreparedDomTarget", - representation: "stable-source-triangle-leaf-windows-over-shared-screen-aligned-prepared-frame-pages", + representation: "stable-source-triangle-leaves-with-prepared-owned-pixel-occlusion-matrix3d-blocks-and-exact-sparse-leaf-lighting-addresses", textureBackend: "atlas", textureLeafSizing: "raster", stableDom: true, @@ -74,23 +86,15 @@ export async function buildCssflowerFirstSliceScene({ textureLighting: "baked", textureQuality: 1, materials: CSSFLOWER_SIDE_MATERIALS.map(({ id, color }) => ({ id, color })), - lighting: publicLightingContract(compiled.lighting), + 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: Object.freeze({ - distribution: "ignored-local-preparation-evidence", - url: null, - sha256: compiled.transformSha256, - encoding: "float32-little-endian-state-major-triangle-major-matrix3d", - componentCount: 16, - triangleCount: compiled.topology.triangleCount, - geometryStateCount: compiled.cycle.geometryStateCount, - byteLength: compiled.transformBytes.length, - }), + transformAsset: preparedAssets.transforms, + frontFacingSchedule, stateEvidenceUrl: "/cssflower/assets/flower-box-state-evidence.json", - projectedPixels: projectedPixelContract(projectedPixels), cycle: Object.freeze({ schema: compiled.cycle.schema, initialState: compiled.cycle.initialState, @@ -100,14 +104,15 @@ export async function buildCssflowerFirstSliceScene({ 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: Object.freeze(compiled.cycle.states.map((state, stateIndex) => Object.freeze({ - ...state, - projectedPageIndex: projectedPixels.statePageIndices[stateIndex], - projectedFrameIndex: projectedPixels.stateFrameIndices[stateIndex], - }))), + states: playbackStates, }), }), meshes: Object.freeze([Object.freeze({ @@ -139,12 +144,31 @@ export async function buildCssflowerFirstSliceScene({ preparedCombinedCycleLength: compiled.cycle.cycleLength, preparedBloomCycleLength: compiled.cycle.bloomCycleLength, preparedRootStateCount: compiled.cycle.rootStateCount, - preparedProjectedPixelPageCount: projectedPixels.pageCount, - preparedProjectedPixelAtlasAssetCount: projectedPixels.pageCount - projectedPixels.atlasAliasCount, - preparedProjectedPixelLayoutAssetCount: projectedPixels.layoutBlocks.length, - preparedProjectedPixelMaximumDecodedPageBytes: projectedPixels.maximumDecodedPageBytes, - preparedProjectedPixelMaximumAdjacentTwoPageBytes: projectedPixels.maximumAdjacentTwoPageBytes, + 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, @@ -169,7 +193,7 @@ export async function buildCssflowerFirstSliceScene({ ? "exact-pass-9331-ticks" : "pending-owned-byte-identified-authority", visualComparison: dataSource?.nativeQualification?.status === "pass" - ? "measured-divergence-see-ignored-local-pixelmatch-report" + ? "rounded-product-common-prefix-only-full-source-visual-report-retained-as-historical-evidence" : "pending-state-correctness-and-native-authority", }), warnings: Object.freeze([ @@ -181,92 +205,159 @@ export async function buildCssflowerFirstSliceScene({ "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 }); } -export function createCssflowerSceneContract(value = {}) { - return value; +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 publicLightingContract(lighting) { +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({ - ...lighting, - distribution: "ignored-local-preparation-evidence", - assetUrl: null, - pages: Object.freeze(lighting.pages.map((page) => Object.freeze({ - ...page, - assetUrl: null, - }))), + 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 projectedPixelContract(prepared) { +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({ - schema: "cssflower-prepared-projected-pixel-playback@1", - techniqueReference: "cssGraphics Mario prepared space-time texel seam extended to shared screen-aligned source-camera frame windows", - representation: "shared-frame-windows", - physicalLayout: prepared.layout, - rasterMode: "source-camera-projected-pixels", - sampling: "integer-pixel-center", - cull: "source-default-CCW-front", - depth: "source-depth16-less", - interpolation: "perspective-correct-smooth-vertex-lighting", - encoding: `${CSSFLOWER_PROJECTED_ATLAS_ENCODING} plus gzip-blocked int16 source-order leaf layouts`, + ...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", - mimeType: CSSFLOWER_PROJECTED_ATLAS_MIME_TYPE, - quality: CSSFLOWER_PROJECTED_ATLAS_QUALITY, + encoding: assets.encoding, + mimeType: assets.mimeType, + quality: assets.quality, chromaSubsampling: "4:4:4", speed: 6, - policy: "user-accepted-bounded-lossy-prepared-pixels", - exactStateAndTopology: true, + policy: "bounded-lossy-prepared-leaf-lighting-only", + exactGeometry: true, exactPreparedPixels: false, }), - stateCount: prepared.stateCount, - cycleStartState: prepared.cycleStartState, - cycleLength: prepared.cycleLength, - retainedLeafCount: prepared.retainedLeafCount, - pageCount: prepared.pageCount, - decodedResidentPageBudget: prepared.decodedResidentPageBudget, - decodedPeakPageBudget: prepared.decodedPeakPageBudget, - maximumDecodedPageBytes: prepared.maximumDecodedPageBytes, - maximumAdjacentTwoPageBytes: prepared.maximumAdjacentTwoPageBytes, - encodedAtlasBytes: prepared.encodedAtlasBytes, - rawLayoutBytes: prepared.rawLayoutBytes, - compressedLayoutBytes: prepared.compressedLayoutBytes, - layoutBlockPageCount: prepared.layoutBlockPageCount, - contentAddressedAtlasBytes: prepared.contentAddressedAtlasBytes, - atlasAliasCount: prepared.atlasAliasCount, - inverseRootTransforms: prepared.inverseRootTransforms, - encoder: prepared.encoder, - layoutBlocks: Object.freeze(prepared.layoutBlocks.map((block) => Object.freeze({ - ...block, - assetUrl: generatedSharedLayoutBlockUrl(block.sha256), - }))), - pages: Object.freeze(prepared.pages.map((page) => Object.freeze({ - index: page.index, - sourcePageIndex: page.sourcePageIndex, - frameCount: page.frameCount, - startStateIndex: page.startStateIndex, - usedFrameCount: page.usedFrameCount, - activeUnionLeafCount: page.activeUnionLeafCount, - atlas: Object.freeze({ - ...page.atlas, - assetUrl: generatedProjectedAtlasUrl(page.atlas.sha256), - }), - layout: Object.freeze({ - ...page.layout, - }), - }))), - authority: prepared.authority, - runtimeProjection: false, - runtimeRasterization: false, - runtimeGeometryConstruction: false, - runtimeNormalCalculation: false, - runtimeLightingCalculation: false, - runtimeDomGrowth: false, + encoder: assets.encoder, }); } diff --git a/src/adapters/flowerbox/src/prepare/cssflower/sharedFramePacking.mjs b/src/adapters/flowerbox/src/prepare/cssflower/sharedFramePacking.mjs deleted file mode 100644 index 3636a33..0000000 --- a/src/adapters/flowerbox/src/prepare/cssflower/sharedFramePacking.mjs +++ /dev/null @@ -1,86 +0,0 @@ -import { PNG } from "pngjs"; - -export const CSSFLOWER_SHARED_FRAME_PACKINGS = Object.freeze([ - "horizontal-union", - "vertical-union", -]); - -export function buildCssflowerSharedFramePackingCandidates(atlas) { - validatePreparedHorizontalAtlas(atlas); - const source = PNG.sync.read(atlas.bytes); - if (source.width !== atlas.width || source.height !== atlas.height) { - throw new Error("Prepared shared-frame PNG dimensions drifted"); - } - const horizontal = packingDescriptor({ - packing: "horizontal-union", - frameWidth: atlas.frameWidth, - frameHeight: atlas.frameHeight, - frameCount: atlas.frameBackgroundXs.length, - bytes: Buffer.from(atlas.bytes), - }); - if (horizontal.frameCount === 1) return Object.freeze([horizontal]); - - const verticalImage = new PNG({ - width: atlas.frameWidth, - height: atlas.frameHeight * horizontal.frameCount, - colorType: 6, - }); - for (let frameIndex = 0; frameIndex < horizontal.frameCount; frameIndex += 1) { - for (let y = 0; y < atlas.frameHeight; y += 1) { - const sourceOffset = (y * source.width + frameIndex * atlas.frameWidth) * 4; - const targetOffset = ((frameIndex * atlas.frameHeight + y) * verticalImage.width) * 4; - source.data.copy( - verticalImage.data, - targetOffset, - sourceOffset, - sourceOffset + atlas.frameWidth * 4, - ); - } - } - const vertical = packingDescriptor({ - packing: "vertical-union", - frameWidth: atlas.frameWidth, - frameHeight: atlas.frameHeight, - frameCount: horizontal.frameCount, - bytes: PNG.sync.write(verticalImage, { colorType: 2, inputColorType: 6 }), - }); - return Object.freeze([horizontal, vertical]); -} - -function packingDescriptor({ packing, frameWidth, frameHeight, frameCount, bytes }) { - const horizontal = packing === "horizontal-union"; - return Object.freeze({ - packing, - frameCount, - width: horizontal ? frameWidth * frameCount : frameWidth, - height: horizontal ? frameHeight : frameHeight * frameCount, - frameWidth, - frameHeight, - frameBackgroundXs: Object.freeze(Array.from( - { length: frameCount }, - (_, frameIndex) => horizontal && frameIndex > 0 ? -frameIndex * frameWidth : 0, - )), - frameBackgroundYs: Object.freeze(Array.from( - { length: frameCount }, - (_, frameIndex) => !horizontal && frameIndex > 0 ? -frameIndex * frameHeight : 0, - )), - frameBackgroundOffsets: Object.freeze(Array.from( - { length: frameCount }, - (_, frameIndex) => frameIndex === 0 ? 0 : horizontal ? -frameIndex * frameWidth : -frameIndex * frameHeight, - )), - bytes, - }); -} - -function validatePreparedHorizontalAtlas(atlas) { - if (!Buffer.isBuffer(atlas?.bytes) || atlas.bytes.length < 1 || - !Number.isSafeInteger(atlas.width) || atlas.width < 1 || - !Number.isSafeInteger(atlas.height) || atlas.height < 1 || - !Number.isSafeInteger(atlas.frameWidth) || atlas.frameWidth < 1 || - !Number.isSafeInteger(atlas.frameHeight) || atlas.frameHeight < 1 || - atlas.width !== atlas.frameWidth * atlas.frameBackgroundXs?.length || - atlas.height !== atlas.frameHeight || - atlas.frameBackgroundXs.some((value, frameIndex) => value !== -frameIndex * atlas.frameWidth)) { - throw new TypeError("Complete prepared horizontal shared-frame PNG is required"); - } -} diff --git a/src/adapters/flowerbox/src/prepare/cssflower/sharedFramePageStore.mjs b/src/adapters/flowerbox/src/prepare/cssflower/sharedFramePageStore.mjs deleted file mode 100644 index 9fbf463..0000000 --- a/src/adapters/flowerbox/src/prepare/cssflower/sharedFramePageStore.mjs +++ /dev/null @@ -1,362 +0,0 @@ -import { spawnSync } from "node:child_process"; -import { createHash } from "node:crypto"; -import { mkdir, readFile, rename, stat, writeFile } from "node:fs/promises"; -import { availableParallelism } from "node:os"; -import { dirname, join, resolve } from "node:path"; -import { Worker } from "node:worker_threads"; -import { constants as zlibConstants, gzipSync } from "node:zlib"; -import { CSSFLOWER_PROJECTED_ATLAS_QUALITY } from "../../cssflower/renderContract.mjs"; -import { - CSSFLOWER_SHARED_LAYOUT_BLOCK_PAGE_COUNT, - buildCssflowerSharedFramePagePlan, -} from "./sharedFramePages.mjs"; -import { repoRoot } from "./paths.mjs"; - -const CACHE_SCHEMA = "cssflower-prepared-shared-frame-page-cache@1"; -const DEFAULT_AVIFENC = "/opt/homebrew/bin/avifenc"; - -export async function prepareCssflowerSharedFrameWindowPages({ - avifenc = process.env.CSSFLOWER_AVIFENC || DEFAULT_AVIFENC, - concurrency = Math.min(4, availableParallelism()), - onProgress, -} = {}) { - if (!Number.isSafeInteger(concurrency) || concurrency < 1) { - throw new RangeError("Shared-frame preparation concurrency must be a positive integer"); - } - const encoder = await avifencIdentity(avifenc); - const binding = await cacheBinding(repoRoot, encoder); - const plan = buildCssflowerSharedFramePagePlan(); - const cacheRoot = cacheRootFor(repoRoot, binding); - const descriptors = new Array(plan.pages.length); - const misses = []; - let hitCount = 0; - let completedCount = 0; - - for (let pageIndex = 0; pageIndex < plan.pages.length; pageIndex += 1) { - const page = plan.pages[pageIndex]; - const cached = await readCachedPage(cacheRoot, binding, page); - if (cached) { - descriptors[pageIndex] = cached; - hitCount += 1; - completedCount += 1; - onProgress?.({ completedCount, totalCount: plan.pages.length, hitCount, missCount: misses.length, pageIndex, source: "cache" }); - } else { - misses.push({ page }); - } - } - - await runWorkerPool({ - tasks: misses, - concurrency: Math.min(concurrency, Math.max(1, misses.length)), - avifenc, - async accept(task, result) { - await writeCachedPage(cacheRoot, binding, task.page, result); - descriptors[task.page.index] = result.descriptor; - completedCount += 1; - onProgress?.({ completedCount, totalCount: plan.pages.length, hitCount, missCount: misses.length, pageIndex: task.page.index, source: "prepared" }); - }, - }); - - const packed = await packLayoutBlocks(cacheRoot, descriptors); - const pages = Object.freeze(descriptors.map((descriptor, pageIndex) => Object.freeze({ - ...descriptor, - index: pageIndex, - frameCount: plan.frameCount, - layout: Object.freeze({ - ...descriptor.layout, - blockIndex: Math.floor(pageIndex / CSSFLOWER_SHARED_LAYOUT_BLOCK_PAGE_COUNT), - blockByteOffset: (pageIndex % CSSFLOWER_SHARED_LAYOUT_BLOCK_PAGE_COUNT) * descriptor.layout.byteLength, - }), - }))); - const statePageIndices = new Uint16Array(plan.stateCount); - const stateFrameIndices = new Uint8Array(plan.stateCount); - for (const page of pages) { - for (let frameIndex = 0; frameIndex < page.usedFrameCount; frameIndex += 1) { - const stateIndex = page.startStateIndex + frameIndex; - statePageIndices[stateIndex] = page.index; - stateFrameIndices[stateIndex] = frameIndex; - } - } - const atlasContent = contentAddressSummary(pages, "atlas"); - const maximumDecodedPageBytes = Math.max(...pages.map((page) => page.atlas.decodedBytes)); - const maximumAdjacentTwoPageBytes = Math.max(...pages.map((page, pageIndex) => ( - page.atlas.decodedBytes + (pages[pageIndex + 1]?.atlas.decodedBytes ?? 0) - ))); - return Object.freeze({ - schema: "cssflower-prepared-shared-frame-window-pages@1", - binding, - encoder, - layout: plan.layout, - stateCount: plan.stateCount, - cycleStartState: plan.cycleStartState, - cycleLength: plan.cycleLength, - retainedLeafCount: plan.retainedLeafCount, - frameCount: plan.frameCount, - pageCount: pages.length, - decodedResidentPageBudget: 2, - decodedPeakPageBudget: 2, - maximumDecodedPageBytes, - maximumAdjacentTwoPageBytes, - encodedAtlasBytes: pages.reduce((sum, page) => sum + page.atlas.byteLength, 0), - contentAddressedAtlasBytes: atlasContent.byteLength, - atlasAliasCount: atlasContent.aliasCount, - rawLayoutBytes: pages.reduce((sum, page) => sum + page.layout.byteLength, 0), - compressedLayoutBytes: packed.blocks.reduce((sum, block) => sum + block.byteLength, 0), - layoutBlockPageCount: CSSFLOWER_SHARED_LAYOUT_BLOCK_PAGE_COUNT, - layoutBlocks: packed.blocks, - inverseRootTransforms: plan.inverseRootTransforms, - statePageIndices, - stateFrameIndices, - pages, - cache: Object.freeze({ - hitCount, - missCount: misses.length, - writeCount: misses.length, - }), - authority: plan.authority, - }); -} - -export function cssflowerSharedFramePageCachePaths({ binding, pageIndex }) { - if (!/^[a-f0-9]{64}$/u.test(binding ?? "") || !Number.isSafeInteger(pageIndex) || pageIndex < 0) { - throw new TypeError("Prepared shared-frame cache locator is invalid"); - } - return cachePaths(cacheRootFor(repoRoot, binding), pageIndex); -} - -export function cssflowerSharedLayoutBlockCachePath({ binding, sha256 }) { - if (!/^[a-f0-9]{64}$/u.test(binding ?? "") || !/^[a-f0-9]{64}$/u.test(sha256 ?? "")) { - throw new TypeError("Prepared shared layout-block cache locator is invalid"); - } - return join(cacheRootFor(repoRoot, binding), "layout-blocks", `block-${sha256}.i16.gz`); -} - -async function packLayoutBlocks(cacheRoot, pages) { - const blocks = []; - for (let startPageIndex = 0; startPageIndex < pages.length; startPageIndex += CSSFLOWER_SHARED_LAYOUT_BLOCK_PAGE_COUNT) { - const blockPages = pages.slice(startPageIndex, startPageIndex + CSSFLOWER_SHARED_LAYOUT_BLOCK_PAGE_COUNT); - const decoded = Buffer.concat(await Promise.all(blockPages.map((page) => ( - readFile(cachePaths(cacheRoot, page.index).layout) - )))); - const bytes = gzipSync(decoded, { - level: 9, - mtime: 0, - strategy: zlibConstants.Z_DEFAULT_STRATEGY, - }); - const block = Object.freeze({ - schema: "cssflower-prepared-shared-layout-block@1", - index: blocks.length, - startPageIndex, - pageCount: blockPages.length, - encoding: "gzip-concatenated-int16-page-layouts", - byteLength: bytes.length, - decodedByteLength: decoded.length, - sha256: sha256(bytes), - decodedSha256: sha256(decoded), - }); - await writeAtomic(join(cacheRoot, "layout-blocks", `block-${block.sha256}.i16.gz`), bytes); - blocks.push(block); - } - return Object.freeze({ blocks: Object.freeze(blocks) }); -} - -async function readCachedPage(cacheRoot, binding, page) { - const paths = cachePaths(cacheRoot, page.index); - try { - const [metadataBytes, atlasBytes, layoutBytes] = await Promise.all([ - readFile(paths.metadata), - readFile(paths.atlas), - readFile(paths.layout), - ]); - const metadata = JSON.parse(metadataBytes.toString("utf8")); - const descriptor = metadata?.page; - if (metadata?.schema !== CACHE_SCHEMA || metadata.binding !== binding || - !matchesRequest(descriptor, page) || !validCachedAsset(descriptor.atlas, atlasBytes) || - !validCachedAsset(descriptor.layout, layoutBytes)) return null; - return descriptor; - } catch (error) { - if (error?.code === "ENOENT" || error instanceof SyntaxError) return null; - throw error; - } -} - -async function writeCachedPage(cacheRoot, binding, request, result) { - const { descriptor, atlasBytes, layoutBytes } = result; - if (!matchesRequest(descriptor, request) || !validCachedAsset(descriptor.atlas, atlasBytes) || - !validCachedAsset(descriptor.layout, layoutBytes)) { - throw new Error(`Prepared shared-frame page ${request.index} failed cache validation`); - } - const paths = cachePaths(cacheRoot, request.index); - const metadata = Buffer.from(`${JSON.stringify({ - schema: CACHE_SCHEMA, - binding, - page: descriptor, - }, null, 2)}\n`); - await Promise.all([ - writeAtomic(paths.atlas, atlasBytes), - writeAtomic(paths.layout, layoutBytes), - writeAtomic(paths.metadata, metadata), - ]); -} - -async function runWorkerPool({ tasks, concurrency, avifenc, accept }) { - if (tasks.length === 0) return; - const workerUrl = new URL("./sharedFramePageWorker.mjs", import.meta.url); - let nextTaskIndex = 0; - let settledCount = 0; - let failed = false; - const workers = []; - await new Promise((resolvePromise, rejectPromise) => { - const fail = async (error) => { - if (failed) return; - failed = true; - await Promise.all(workers.map((worker) => worker.terminate().catch(() => undefined))); - rejectPromise(error); - }; - const dispatch = (worker) => { - if (failed) return; - if (nextTaskIndex >= tasks.length) { - if (settledCount === tasks.length) resolvePromise(); - return; - } - const task = tasks[nextTaskIndex]; - nextTaskIndex += 1; - worker.currentTask = task; - worker.postMessage({ taskId: task.page.index, page: task.page, avifenc }); - }; - for (let workerIndex = 0; workerIndex < concurrency; workerIndex += 1) { - const worker = new Worker(workerUrl); - workers.push(worker); - worker.on("error", fail); - worker.on("exit", (code) => { - if (!failed && code !== 0 && settledCount < tasks.length) { - void fail(new Error(`Shared-frame worker exited ${code}`)); - } - }); - worker.on("message", async (message) => { - if (failed) return; - const task = worker.currentTask; - if (!task || message.taskId !== task.page.index) { - return void fail(new Error("Shared-frame worker response drifted")); - } - if (message.error) return void fail(new Error(message.error)); - try { - await accept(task, message.result); - settledCount += 1; - worker.currentTask = null; - if (settledCount === tasks.length) resolvePromise(); - else dispatch(worker); - } catch (error) { - void fail(error); - } - }); - dispatch(worker); - } - }); - await Promise.all(workers.map((worker) => worker.terminate())); -} - -async function avifencIdentity(path) { - const [bytes, info] = await Promise.all([readFile(path), stat(path)]); - const versionRun = spawnSync(path, ["--version"], { encoding: "utf8" }); - if (versionRun.error) throw versionRun.error; - if (versionRun.status !== 0) throw new Error(`avifenc --version exited ${versionRun.status}`); - return Object.freeze({ - name: "avifenc", - version: `${versionRun.stdout}${versionRun.stderr}`.trim(), - byteLength: info.size, - sha256: sha256(bytes), - flags: Object.freeze([ - "--qcolor", String(CSSFLOWER_PROJECTED_ATLAS_QUALITY), "--speed", "6", "--yuv", "444", - "--ignore-exif", "--ignore-xmp", "--ignore-icc", - ]), - }); -} - -async function cacheBinding(repoRoot, encoder) { - const files = [ - "pnpm-lock.yaml", - "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/projectedPixels.mjs", - "src/adapters/flowerbox/src/prepare/cssflower/sharedFramePages.mjs", - "src/adapters/flowerbox/src/prepare/cssflower/sharedFramePacking.mjs", - "src/adapters/flowerbox/src/prepare/cssflower/sharedFramePageWorker.mjs", - "src/adapters/flowerbox/src/prepare/cssflower/sourceProfile.mjs", - ]; - const hash = createHash("sha256"); - for (const path of files) { - hash.update(path); - hash.update("\0"); - hash.update(await readFile(join(repoRoot, path))); - hash.update("\0"); - } - hash.update(JSON.stringify(encoder)); - return hash.digest("hex"); -} - -function cacheRootFor(repoRoot, binding) { - return join(repoRoot, ".local", "cache", "cssflower", "prepared-shared-frame-windows", binding); -} - -function cachePaths(cacheRoot, pageIndex) { - const stem = `page-${String(pageIndex).padStart(4, "0")}`; - return Object.freeze({ - atlas: join(cacheRoot, `${stem}.avif`), - layout: join(cacheRoot, `${stem}.i16`), - metadata: join(cacheRoot, `${stem}.json`), - }); -} - -function matchesRequest(descriptor, request) { - const atlas = descriptor?.atlas; - const horizontal = atlas?.packing === "horizontal-union"; - const vertical = atlas?.packing === "vertical-union"; - const expectedOffsets = Array.from( - { length: request.usedFrameCount }, - (_, frameIndex) => frameIndex === 0 ? 0 : horizontal - ? -frameIndex * atlas.frameWidth - : -frameIndex * atlas.frameHeight, - ); - return descriptor?.schema === "cssflower-prepared-shared-frame-window-page@1" && - descriptor.index === request.index && descriptor.startStateIndex === request.startStateIndex && - descriptor.usedFrameCount === request.usedFrameCount && descriptor.retainedLeafCount === 1_200 && - (horizontal || vertical) && - atlas.width === atlas.frameWidth * (horizontal ? descriptor.usedFrameCount : 1) && - atlas.height === atlas.frameHeight * (vertical ? descriptor.usedFrameCount : 1) && - arraysEqual(atlas.frameBackgroundOffsets, expectedOffsets) && - descriptor.authority?.nativeStateIngestion === false && descriptor.authority?.nativePixelIngestion === false && - descriptor.authority?.runtimeProjection === false && descriptor.authority?.runtimeRasterization === false; -} - -function arraysEqual(actual, expected) { - return Array.isArray(actual) && actual.length === expected.length && - actual.every((value, index) => value === expected[index]); -} - -function validCachedAsset(descriptor, bytes) { - return Number.isSafeInteger(descriptor?.byteLength) && descriptor.byteLength === bytes.length && - /^[a-f0-9]{64}$/u.test(descriptor.sha256 ?? "") && sha256(bytes) === descriptor.sha256; -} - -function contentAddressSummary(pages, field) { - const unique = new Map(); - for (const page of pages) unique.set(page[field].sha256, page[field].byteLength); - return Object.freeze({ - uniqueCount: unique.size, - aliasCount: pages.length - unique.size, - byteLength: [...unique.values()].reduce((sum, byteLength) => sum + byteLength, 0), - }); -} - -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-${process.pid}`; - await writeFile(temporary, bytes); - await rename(temporary, path); -} diff --git a/src/adapters/flowerbox/src/prepare/cssflower/sharedFramePageWorker.mjs b/src/adapters/flowerbox/src/prepare/cssflower/sharedFramePageWorker.mjs deleted file mode 100644 index 225fd7d..0000000 --- a/src/adapters/flowerbox/src/prepare/cssflower/sharedFramePageWorker.mjs +++ /dev/null @@ -1,119 +0,0 @@ -import { spawnSync } from "node:child_process"; -import { createHash } from "node:crypto"; -import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; -import { tmpdir } from "node:os"; -import { join } from "node:path"; -import { parentPort } from "node:worker_threads"; -import { - CSSFLOWER_PROJECTED_ATLAS_ENCODING, - CSSFLOWER_PROJECTED_ATLAS_MIME_TYPE, - CSSFLOWER_PROJECTED_ATLAS_QUALITY, -} from "../../cssflower/renderContract.mjs"; -import { prepareCssflowerSharedFramePage } from "./sharedFramePages.mjs"; -import { buildCssflowerSharedFramePackingCandidates } from "./sharedFramePacking.mjs"; - -if (!parentPort) throw new Error("Shared-frame page worker requires a parent port"); - -parentPort.on("message", async ({ taskId, page, avifenc }) => { - try { - const prepared = prepareCssflowerSharedFramePage(page); - const encodedPackings = await encodeLossyAvifCandidates( - buildCssflowerSharedFramePackingCandidates(prepared.atlas), - avifenc, - ); - const selected = encodedPackings[0]; - const atlasBytes = selected.bytes; - const layoutBytes = Buffer.from(prepared.layout.bytes); - parentPort.postMessage({ - taskId, - result: { - descriptor: { - schema: prepared.schema, - index: prepared.index, - startStateIndex: prepared.startStateIndex, - usedFrameCount: prepared.usedFrameCount, - retainedLeafCount: prepared.retainedLeafCount, - activeUnionLeafCount: prepared.activeUnionLeafCount, - atlas: { - encoding: CSSFLOWER_PROJECTED_ATLAS_ENCODING, - mimeType: CSSFLOWER_PROJECTED_ATLAS_MIME_TYPE, - quality: CSSFLOWER_PROJECTED_ATLAS_QUALITY, - packing: selected.packing, - width: selected.width, - height: selected.height, - frameWidth: prepared.atlas.frameWidth, - frameHeight: prepared.atlas.frameHeight, - cropLeft: prepared.atlas.cropLeft, - cropTop: prepared.atlas.cropTop, - frameBackgroundOffsets: selected.frameBackgroundOffsets, - byteLength: atlasBytes.length, - decodedBytes: selected.width * selected.height * 4, - sha256: sha256(atlasBytes), - }, - layout: { - schema: prepared.layout.schema, - encoding: prepared.layout.encoding, - componentCount: prepared.layout.componentCount, - bytesPerLeaf: prepared.layout.bytesPerLeaf, - leafCount: prepared.layout.leafCount, - byteLength: layoutBytes.length, - sha256: sha256(layoutBytes), - }, - authority: prepared.authority, - }, - atlasBytes, - layoutBytes, - }, - }); - } catch (error) { - parentPort.postMessage({ - taskId, - error: String(error?.stack || error?.message || error), - }); - } -}); - -async function encodeLossyAvifCandidates(candidates, avifenc) { - const temporaryRoot = await mkdtemp(join(tmpdir(), "cssflower-shared-frame-page-")); - try { - const encoded = []; - for (let index = 0; index < candidates.length; index += 1) { - const candidate = candidates[index]; - const input = join(temporaryRoot, `page-${index}.png`); - const output = join(temporaryRoot, `page-${index}.avif`); - await writeFile(input, candidate.bytes); - const result = spawnSync(avifenc, [ - "--qcolor", - String(CSSFLOWER_PROJECTED_ATLAS_QUALITY), - "--speed", - "6", - "--yuv", - "444", - "--ignore-exif", - "--ignore-xmp", - "--ignore-icc", - input, - output, - ], { encoding: "utf8" }); - if (result.error) throw result.error; - if (result.status !== 0) { - throw new Error(`avifenc exited ${result.status}: ${result.stderr || result.stdout}`); - } - encoded.push(Object.freeze({ - ...candidate, - bytes: await readFile(output), - })); - } - encoded.sort((left, right) => ( - left.bytes.length - right.bytes.length || - left.packing.localeCompare(right.packing) - )); - return Object.freeze(encoded); - } finally { - await rm(temporaryRoot, { recursive: true, force: true }); - } -} - -function sha256(bytes) { - return createHash("sha256").update(bytes).digest("hex"); -} diff --git a/src/adapters/flowerbox/src/prepare/cssflower/sharedFramePages.mjs b/src/adapters/flowerbox/src/prepare/cssflower/sharedFramePages.mjs deleted file mode 100644 index 0cf130c..0000000 --- a/src/adapters/flowerbox/src/prepare/cssflower/sharedFramePages.mjs +++ /dev/null @@ -1,216 +0,0 @@ -import { createHash } from "node:crypto"; -import { PNG } from "pngjs"; -import { buildPreparedFullRotationCycle } from "./bloomCycle.mjs"; -import { - buildCssflowerPreparedInverseRootTransforms, - prepareCssflowerProjectedFrame, -} from "./projectedPixels.mjs"; - -export const CSSFLOWER_SHARED_FRAME_PAGE_FRAME_COUNT = 4; -export const CSSFLOWER_SHARED_LAYOUT_COMPONENT_COUNT = 6; -export const CSSFLOWER_SHARED_LAYOUT_BYTES_PER_LEAF = - CSSFLOWER_SHARED_LAYOUT_COMPONENT_COUNT * Int16Array.BYTES_PER_ELEMENT; -export const CSSFLOWER_SHARED_LAYOUT_BLOCK_PAGE_COUNT = 64; - -const STAGE_PIXELS = 720; -const HALF_STAGE_PIXELS = STAGE_PIXELS / 2; -const cycle = buildPreparedFullRotationCycle(); - -export function buildCssflowerSharedFramePagePlan({ - frameCount = CSSFLOWER_SHARED_FRAME_PAGE_FRAME_COUNT, -} = {}) { - if (frameCount !== CSSFLOWER_SHARED_FRAME_PAGE_FRAME_COUNT) { - throw new RangeError(`Prepared shared-frame page span must be ${CSSFLOWER_SHARED_FRAME_PAGE_FRAME_COUNT}`); - } - const pages = []; - for (let startStateIndex = 0; startStateIndex < cycle.stateCount; startStateIndex += frameCount) { - const usedFrameCount = Math.min(frameCount, cycle.stateCount - startStateIndex); - pages.push(Object.freeze({ - index: pages.length, - startStateIndex, - usedFrameCount, - ticks: Object.freeze(Array.from({ length: usedFrameCount }, (_, offset) => startStateIndex + offset)), - })); - } - return Object.freeze({ - schema: "cssflower-prepared-shared-frame-page-plan@1", - layout: "source-order-retained-leaf-windows-over-screen-aligned-prepared-frame-pages", - stateCount: cycle.stateCount, - cycleStartState: cycle.cycleStartState, - cycleLength: cycle.cycleLength, - retainedLeafCount: 1_200, - frameCount, - pageCount: pages.length, - pages: Object.freeze(pages), - inverseRootTransforms: buildCssflowerPreparedInverseRootTransforms(), - authority: Object.freeze({ - precedent: "cssgraphics-mario-prepared-space-time-texels-with-raster-leaf-sizing", - input: "independently-prepared-cssflower-source-state", - nativeStateIngestion: false, - nativePixelIngestion: false, - runtimeProjection: false, - runtimeRasterization: false, - runtimeGeometryConstruction: false, - runtimeNormalCalculation: false, - runtimeLightingCalculation: false, - runtimeDomGrowth: false, - }), - }); -} - -export function prepareCssflowerSharedFramePage(page) { - if (!validPageRequest(page)) throw new TypeError("Prepared shared-frame page request is invalid"); - const frames = page.ticks.map((tick) => prepareCssflowerProjectedFrame(tick)); - const slots = unionLeafSlots(frames); - const crop = unionPageCrop(slots); - const atlasImage = new PNG({ - width: crop.width * frames.length, - height: crop.height, - colorType: 6, - }); - for (let frameIndex = 0; frameIndex < frames.length; frameIndex += 1) { - copyOpaqueFrameCrop(frames[frameIndex].frameImage, atlasImage, crop, frameIndex); - } - const atlasBytes = PNG.sync.write(atlasImage, { colorType: 2, inputColorType: 6 }); - const layoutBytes = encodeCssflowerSharedLeafLayout(slots, crop); - return Object.freeze({ - schema: "cssflower-prepared-shared-frame-window-page@1", - index: page.index, - startStateIndex: page.startStateIndex, - usedFrameCount: page.usedFrameCount, - retainedLeafCount: 1_200, - activeUnionLeafCount: slots.filter((slot) => slot.pixelCount > 0).length, - atlas: Object.freeze({ - encoding: "png-rgb8-lossless-pre-transport", - width: atlasImage.width, - height: atlasImage.height, - frameWidth: crop.width, - frameHeight: crop.height, - cropLeft: crop.left, - cropTop: crop.top, - byteLength: atlasBytes.length, - decodedBytes: atlasImage.width * atlasImage.height * 4, - sha256: sha256(atlasBytes), - frameBackgroundXs: Object.freeze(Array.from( - { length: page.usedFrameCount }, - (_, frameIndex) => frameIndex === 0 ? 0 : -frameIndex * crop.width, - )), - bytes: atlasBytes, - }), - layout: Object.freeze({ - schema: "cssflower-prepared-shared-frame-leaf-layout@1", - encoding: "int16-little-endian-source-order-width-height-dx-dy-frame-background-x-frame-background-y", - componentCount: CSSFLOWER_SHARED_LAYOUT_COMPONENT_COUNT, - bytesPerLeaf: CSSFLOWER_SHARED_LAYOUT_BYTES_PER_LEAF, - leafCount: 1_200, - byteLength: layoutBytes.length, - sha256: sha256(layoutBytes), - bytes: layoutBytes, - }), - packets: Object.freeze(frames.map((frame, frameIndex) => Object.freeze({ - tick: frame.tick, - sf: frame.state.sf, - rootTransform: frame.rootTransform, - frameIndex, - visibleLeafCount: frame.topology.visibleLeafCount, - }))), - authority: Object.freeze({ - ...frames[0].authority, - precedent: "cssgraphics-mario-prepared-space-time-texels-with-raster-leaf-sizing", - runtimeNormalCalculation: false, - runtimeLightingCalculation: false, - runtimeDomGrowth: false, - }), - }); -} - -export function encodeCssflowerSharedLeafLayout(slots, crop) { - if (!Array.isArray(slots) || slots.length !== 1_200 || - slots.some((slot, index) => slot?.index !== index) || - !Number.isSafeInteger(crop?.left) || !Number.isSafeInteger(crop?.top)) { - throw new TypeError("Complete prepared shared-frame leaf slots and crop are required"); - } - const bytes = Buffer.alloc(slots.length * CSSFLOWER_SHARED_LAYOUT_BYTES_PER_LEAF); - for (const slot of slots) { - const values = slot.pixelCount === 0 - ? [0, 0, 0, 0, 0, 0] - : [ - slot.width, - slot.height, - slot.left - HALF_STAGE_PIXELS, - slot.top - HALF_STAGE_PIXELS, - -(slot.left - crop.left), - -(slot.top - crop.top), - ]; - for (let component = 0; component < values.length; component += 1) { - const value = values[component]; - if (!Number.isSafeInteger(value) || value < -32_768 || value > 32_767) { - throw new RangeError(`Prepared shared-frame leaf ${slot.index} component ${component} exceeds int16`); - } - bytes.writeInt16LE( - value, - (slot.index * CSSFLOWER_SHARED_LAYOUT_COMPONENT_COUNT + component) * Int16Array.BYTES_PER_ELEMENT, - ); - } - } - return bytes; -} - -function unionLeafSlots(frames) { - return Array.from({ length: 1_200 }, (_, index) => { - const visible = frames.map((frame) => frame.leaves[index]).filter((leaf) => leaf.pixelCount > 0); - const left = visible.length ? Math.min(...visible.map((leaf) => leaf.left)) : STAGE_PIXELS; - const top = visible.length ? Math.min(...visible.map((leaf) => leaf.top)) : STAGE_PIXELS; - const right = visible.length ? Math.max(...visible.map((leaf) => leaf.right)) : -1; - const bottom = visible.length ? Math.max(...visible.map((leaf) => leaf.bottom)) : -1; - return Object.freeze({ - index, - pixelCount: visible.reduce((sum, leaf) => sum + leaf.pixelCount, 0), - left, - top, - right, - bottom, - width: Math.max(0, right - left + 1), - height: Math.max(0, bottom - top + 1), - }); - }); -} - -function unionPageCrop(slots) { - const visible = slots.filter((slot) => slot.pixelCount > 0); - if (visible.length === 0) throw new Error("Prepared shared-frame page has no visible source triangles"); - const left = Math.min(...visible.map((slot) => slot.left)); - const top = Math.min(...visible.map((slot) => slot.top)); - const right = Math.max(...visible.map((slot) => slot.right)); - const bottom = Math.max(...visible.map((slot) => slot.bottom)); - return Object.freeze({ - left, - top, - right, - bottom, - width: right - left + 1, - height: bottom - top + 1, - }); -} - -function copyOpaqueFrameCrop(source, target, crop, frameIndex) { - const targetFrameX = frameIndex * crop.width; - for (let y = 0; y < crop.height; y += 1) { - const sourceOffset = ((crop.top + y) * source.width + crop.left) * 4; - const targetOffset = (y * target.width + targetFrameX) * 4; - source.data.copy(target.data, targetOffset, sourceOffset, sourceOffset + crop.width * 4); - } -} - -function validPageRequest(page) { - return Number.isSafeInteger(page?.index) && page.index >= 0 && - Number.isSafeInteger(page.startStateIndex) && page.startStateIndex >= 0 && - Number.isSafeInteger(page.usedFrameCount) && page.usedFrameCount >= 1 && - page.usedFrameCount <= CSSFLOWER_SHARED_FRAME_PAGE_FRAME_COUNT && - Array.isArray(page.ticks) && page.ticks.length === page.usedFrameCount && - page.ticks.every((tick, offset) => tick === page.startStateIndex + offset && tick < cycle.stateCount); -} - -function sha256(bytes) { - return createHash("sha256").update(bytes).digest("hex"); -} diff --git a/src/adapters/flowerbox/src/prepare/cssflower/sourceProfile.mjs b/src/adapters/flowerbox/src/prepare/cssflower/sourceProfile.mjs index f919fd6..8559f21 100644 --- a/src/adapters/flowerbox/src/prepare/cssflower/sourceProfile.mjs +++ b/src/adapters/flowerbox/src/prepare/cssflower/sourceProfile.mjs @@ -5,7 +5,7 @@ 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-validation-in-progress", + authorityStatus: "pinned-source-native-state-validated-locally-not-packaged", geometry: "cube", subdivision: 10, sideCount: 6, diff --git a/src/adapters/flowerbox/src/prepare/cssflower/writePreparedAssets.mjs b/src/adapters/flowerbox/src/prepare/cssflower/writePreparedAssets.mjs index 531e02e..4da31b6 100644 --- a/src/adapters/flowerbox/src/prepare/cssflower/writePreparedAssets.mjs +++ b/src/adapters/flowerbox/src/prepare/cssflower/writePreparedAssets.mjs @@ -1,26 +1,52 @@ import { createHash } from "node:crypto"; -import { copyFile, link, mkdir, readFile, readdir, rename, stat, unlink, writeFile } from "node:fs/promises"; -import { dirname, join, resolve } from "node:path"; +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, - generatedProjectedAtlasPath, + generatedLightingAssetDir, generatedProjectedAssetDir, - generatedSharedLayoutBlockPath, + generatedPreparedLightingPath, + generatedPreparedLightingUrl, generatedStateEvidencePath, + generatedTransformAssetDir, + generatedTransformBlockPath, + generatedTransformBlockUrl, generatedTransformsPath, + localRoot, localPreparedTransformsPath, repoRoot, } from "./paths.mjs"; -import { - cssflowerSharedFramePageCachePaths, - cssflowerSharedLayoutBlockCachePath, -} from "./sharedFramePageStore.mjs"; import { assertNoBrowserPathLeaks } from "./provenance.mjs"; -export async function writeCssflowerPreparedAssets(compiled, projectedPixels) { +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) { @@ -28,59 +54,196 @@ export async function writeCssflowerPreparedAssets(compiled, projectedPixels) { } assertNoBrowserPathLeaks(compiled.evidence); await writeAtomic(generatedStateEvidencePath, Buffer.from(JSON.stringify(compiled.evidence, null, 2) + "\n")); - const projected = await materializeProjectedPixels(projectedPixels); + const [transforms, lighting] = await Promise.all([ + materializeTransformBlocks(compiled), + materializePreparedLightingPages(compiled, lightingPageStore, avifenc), + ]); return Object.freeze({ transformBytes: compiled.transformBytes.length, transformSha256: compiled.transformSha256, - lightingBytes: compiled.lightingPages.reduce((sum, page) => sum + page.byteLength, 0), - lightingSha256: compiled.lightingSha256, + lightingBytes: lighting.contentAddressedBytes, + lightingSha256: lighting.grid.sha256, lightingPageCount: compiled.lightingPages.length, - decodedResidentPageBudgetBytes: compiled.lighting.decodedBytesPerFullPage * compiled.lighting.decodedResidentPageBudget, - decodedPeakPageBudgetBytes: compiled.lighting.decodedBytesPerFullPage * compiled.lighting.decodedPeakPageBudget, - projected, + decodedLightingGridBytes: CSSFLOWER_LIGHTING_GRID_DECODED_BYTES, + transforms, + lighting, }); } -async function materializeProjectedPixels(projected) { - if (projected?.schema !== "cssflower-prepared-shared-frame-window-pages@1" || - projected.pages?.length !== projected.pageCount || projected.retainedLeafCount !== 1_200) { - throw new TypeError("Complete shared-frame projected-pixel preparation is required"); +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 atlasAssets = new Map(); - for (const page of projected.pages) { - const cache = cssflowerSharedFramePageCachePaths({ - binding: projected.binding, - pageIndex: page.index, - }); - atlasAssets.set(page.atlas.sha256, { source: cache.atlas, target: generatedProjectedAtlasPath(page.atlas.sha256) }); + 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, + })); } - const layoutBlockAssets = projected.layoutBlocks.map((block) => ({ - source: cssflowerSharedLayoutBlockCachePath({ binding: projected.binding, sha256: block.sha256 }), - target: generatedSharedLayoutBlockPath(block.sha256), - })); - const assets = [...atlasAssets.values(), ...layoutBlockAssets]; - await pruneGeneratedProjectedAssets(new Set(assets.map((asset) => asset.target))); - await materializeContentAddressedAssets(assets, 16); + await pruneAssetDirectory(generatedTransformAssetDir, keep); return Object.freeze({ - pageCount: projected.pageCount, - atlasAssetCount: atlasAssets.size, - layoutAssetCount: layoutBlockAssets.length, - atlasAliasCount: projected.atlasAliasCount, - encodedAtlasBytes: projected.encodedAtlasBytes, - rawLayoutBytes: projected.rawLayoutBytes, - compressedLayoutBytes: projected.compressedLayoutBytes, - contentAddressedAtlasBytes: projected.contentAddressedAtlasBytes, - maximumDecodedPageBytes: projected.maximumDecodedPageBytes, - maximumAdjacentTwoPageBytes: projected.maximumAdjacentTwoPageBytes, + 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 pruneGeneratedProjectedAssets(keepPaths) { - await mkdir(generatedProjectedAssetDir, { recursive: true }); - const entries = await readdir(generatedProjectedAssetDir, { withFileTypes: true }); +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(generatedProjectedAssetDir, entry.name); + const path = join(root, entry.name); if (!keepPaths.has(path)) await unlink(path); })); } @@ -116,35 +279,45 @@ async function hardlinkAtomic(source, target, uniqueIndex) { export async function createCssflowerPreparedLightingPageStore() { const binding = await lightingCacheBinding(repoRoot); - const cacheRoot = join(repoRoot, ".local", "cache", "cssflower", "prepared-leaf-lighting", binding); + 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 paths = cachePaths(cacheRoot, 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 (metadata?.schema !== "cssflower-prepared-leaf-lighting-cache@1" || - metadata.binding !== binding || !sameExpectedPage(page, expectedPage) || - bytes.length !== page.byteLength || sha256(bytes) !== page.sha256) { - missCount += 1; - return null; + 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; } - if (page.index === 0) await writeAtomic(generatedLightingPagePath(page.index), bytes); - hitCount += 1; - return Object.freeze(page); - } catch (error) { - if (error?.code !== "ENOENT" && !(error instanceof SyntaxError)) throw error; - missCount += 1; - return null; } + missCount += 1; + return null; }, async write(page) { if (!validPageWithBytes(page)) { @@ -162,14 +335,34 @@ export async function createCssflowerPreparedLightingPageStore() { 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, missCount, writeCount }); + 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 }); @@ -230,12 +423,50 @@ function sameExpectedPage(actual, expected) { 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"); } diff --git a/src/adapters/flowerbox/tools/audit-runtime-surface.mjs b/src/adapters/flowerbox/tools/audit-runtime-surface.mjs index 6aa2755..15d5685 100644 --- a/src/adapters/flowerbox/tools/audit-runtime-surface.mjs +++ b/src/adapters/flowerbox/tools/audit-runtime-surface.mjs @@ -1,5 +1,6 @@ #!/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"; @@ -13,8 +14,8 @@ const runtimeFiles = [ "src/cssflower/debugApi.mjs", "src/cssflower/manifestClient.mjs", "src/cssflower/polycssScene.mjs", + "src/cssflower/preparedAssetLoaders.mjs", "src/cssflower/preparedPlayback.mjs", - "src/cssflower/projectedPageStyles.mjs", "src/cssflower/renderContract.mjs", "src/cssflower/routeState.mjs", "src/cssflower/stagePresentation.mjs", @@ -26,6 +27,12 @@ const sources = new Map(await Promise.all(runtimeFiles.map(async (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); @@ -35,27 +42,33 @@ for (const [path, source] of sources) { 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({")) failures.push("PolyCSS Morph prepared target is missing"); -if (!playback.includes("runtimeSchedulerSkippedPreparedStateCount: 0") || playback.includes("elapsedSteps")) { - failures.push("Prepared playback does not retain sequential no-skip scheduling"); +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)\b|createElement|appendChild|replaceChildren/u.test(playback)) { +if (/\b(?:document|DOMParser|MutationObserver|Image)\b|createElement|appendChild|replaceChildren/u.test(playback)) { failures.push("Prepared playback constructs DOM"); } -const manifestClient = sources.get("src/cssflower/manifestClient.mjs"); -if (!manifestClient.includes("cssflower-prepared-visual-pack-transport@1")) { - failures.push("Prepared visual-pack transport is missing"); +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 >= 31_000_000) failures.push(`Product bank is too large: ${bank.closureBytes}`); -if (bank.projectedVisualPackCount !== 37 || bank.projectedVisualPackAssetCount !== 37) { - failures.push("Product bank does not contain the 37 block-aligned visual packs"); +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@1", + 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: { @@ -68,7 +81,15 @@ const report = { lightingCalculation: false, domGrowth: false, }, - excluded: ["Microsoft source", "Microsoft binaries", "native captures", "oracle packets", "Three.js", "pixelmatch"], + 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"); diff --git a/src/adapters/flowerbox/tools/package-product-bank.mjs b/src/adapters/flowerbox/tools/package-product-bank.mjs index 38380bc..d3b2c6d 100644 --- a/src/adapters/flowerbox/tools/package-product-bank.mjs +++ b/src/adapters/flowerbox/tools/package-product-bank.mjs @@ -10,7 +10,9 @@ import { } from "./productBank.mjs"; const args = parseArgs(process.argv.slice(2)); -if (!args.source) throw new Error("Usage: package-product-bank.mjs --source [--output ]"); +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}`; @@ -19,95 +21,122 @@ await rm(stagingRoot, { recursive: true, force: true }); await mkdir(dirname(stagingRoot), { recursive: true }); await cp(sourceRoot, stagingRoot, { recursive: true, force: true }); -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 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: "not-packaged", - legal: "independently-authored-results-only", - redistributableUpstreamBytes: false, -}; -if (scene.sourceProfile) scene.sourceProfile.authority = "src/adapters/flowerbox/README.md"; -delete scene.meshes; -delete scene.oracle; -delete scene.playback.stateEvidenceUrl; -delete scene.playback.transformAsset; -scene.warnings = [ - "Independent source-informed PolyCSS experiment; Microsoft source, binaries, captures, and oracle packets are not packaged.", -]; - -const visualPackSummary = await packageProjectedVisualPacks( - stagingRoot, - scene.playback.projectedPixels, -); - -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 }); - -manifest.title = "Flower Box — PolyCSS experiment"; -entry.label = scene.label; -entry.warnings = [...scene.warnings]; -manifest.assets = { - projected: { - pageCount: scene.playback.projectedPixels.pageCount, - atlasAssetCount: scene.metrics.preparedProjectedPixelAtlasAssetCount, - layoutBlockCount: scene.playback.projectedPixels.layoutBlocks.length, - visualPackCount: visualPackSummary.packCount, - visualPackBytes: visualPackSummary.totalPackBytes, - visualEncoding: scene.playback.projectedPixels.visualEncoding, - }, -}; -manifest.productionTransport = { - schema: "cssflower-product-static-transport@1", - exactDecodedSceneAndSnapshotBytes: true, - runtimeGeometryConstruction: false, - runtimeRasterization: false, - runtimeLightingCalculation: false, - assets: [ - transportAsset("scene:default-cube", entry.sceneUrl, sceneDecoded, sceneEncoded), - transportAsset( - "snapshot:default-cube", - entry.snapshotUrl, - gunzipSync(sourceSnapshotEncoded), - sourceSnapshotEncoded, - ), - ], -}; -await writeFile(join(stagingRoot, "manifest.json"), `${JSON.stringify(manifest, null, 2)}\n`); -await rm(join(stagingRoot, "product-bank.json"), { force: true }); - -const summary = await inspectFlowerboxProductBank(stagingRoot, { verifyDescriptor: false }); -await writeFlowerboxProductBankDescriptor(stagingRoot, summary, { - sourceManifestSha256: sha256(sourceManifestBytes), - sourceSceneEncodedSha256: sha256(sourceSceneEncoded), - sourceSnapshotEncodedSha256: sha256(sourceSnapshotEncoded), - sanitization: [ - "native qualification metadata", - "oracle metadata and state evidence", - "prepare-only mesh geometry", - "ignored transform-asset descriptor", - "individual projected atlas and layout transport files", - ], -}); -await inspectFlowerboxProductBank(stagingRoot); -await rm(outputRoot, { recursive: true, force: true }); -await rename(stagingRoot, outputRoot); -process.stdout.write(`${JSON.stringify({ outputRoot, ...summary }, null, 2)}\n`); +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 transportAsset(id, url, decoded, encoded) { +function gzipAsset(id, url, decoded, encoded) { return { id, url, @@ -119,112 +148,19 @@ function transportAsset(id, url, decoded, encoded) { }; } -async function packageProjectedVisualPacks(root, projected) { - const pageCount = projected?.pageCount; - const blockPageCount = projected?.layoutBlockPageCount; - if (!Number.isSafeInteger(pageCount) || pageCount < 1 || blockPageCount !== 64 || - projected.pages?.length !== pageCount || !Array.isArray(projected.layoutBlocks) || - projected.layoutBlocks.length !== Math.ceil(pageCount / blockPageCount)) { - throw new Error("Source Flower Box projected bank cannot be packed"); - } - - const sourceAssets = new Set(); - const assetCache = new Map(); - const packs = []; - let totalPackBytes = 0; - let maximumPackBytes = 0; - for (const block of projected.layoutBlocks) { - if (block.index !== packs.length || block.startPageIndex !== block.index * blockPageCount) { - throw new Error(`Source Flower Box layout block ${block.index} is out of order`); - } - const layoutBytes = await readSourceAsset(block.assetUrl, block.byteLength, block.sha256); - const chunks = [layoutBytes]; - const layout = { - byteOffset: 0, - byteLength: layoutBytes.length, - sha256: block.sha256, - decodedByteLength: block.decodedByteLength, - decodedSha256: block.decodedSha256, - }; - const atlasSlices = []; - let byteOffset = layoutBytes.length; - for (let localPageIndex = 0; localPageIndex < block.pageCount; localPageIndex += 1) { - const pageIndex = block.startPageIndex + localPageIndex; - const page = projected.pages[pageIndex]; - if (page?.index !== pageIndex || page.layout?.blockIndex !== block.index) { - throw new Error(`Source Flower Box projected page ${pageIndex} is not aligned to its layout block`); - } - const atlasBytes = await readSourceAsset( - page.atlas.assetUrl, - page.atlas.byteLength, - page.atlas.sha256, - ); - chunks.push(atlasBytes); - atlasSlices.push({ - pageIndex, - byteOffset, - byteLength: atlasBytes.length, - sha256: page.atlas.sha256, - mimeType: page.atlas.mimeType, - }); - byteOffset += atlasBytes.length; - } - const packBytes = Buffer.concat(chunks); - const packSha256 = sha256(packBytes); - const assetUrl = `/cssflower/assets/projected/visual-pack-${packSha256}.bin`; - await writeFile(publicAssetPath(root, assetUrl), packBytes); - packs.push({ - schema: "cssflower-prepared-visual-pack@1", - index: block.index, - startPageIndex: block.startPageIndex, - pageCount: block.pageCount, - assetUrl, - byteLength: packBytes.length, - sha256: packSha256, - layout, - atlasSlices, - }); - totalPackBytes += packBytes.length; - maximumPackBytes = Math.max(maximumPackBytes, packBytes.length); - } - - for (const url of sourceAssets) await rm(publicAssetPath(root, url), { force: true }); - projected.transport = { - schema: "cssflower-prepared-visual-pack-transport@1", - representation: "layout-block-aligned-exact-byte-slices", - packCount: packs.length, - blockPageCount, - compressedResidentPackBudget: 2, - earlyPrefetchPageOffset: 16, - totalPackBytes, - maximumPackBytes, - logicalContentAddressedAtlasBytes: projected.contentAddressedAtlasBytes, - logicalCompressedLayoutBytes: projected.compressedLayoutBytes, - runtimeGeometryConstruction: false, - runtimeProjection: false, - runtimeRasterization: false, - runtimeLightingCalculation: false, - packs, +function identityAsset(id, url, bytes) { + return { + id, + url, + encoding: "identity", + byteLength: bytes.length, + sha256: sha256(bytes), }; - return { packCount: packs.length, totalPackBytes, maximumPackBytes }; - - async function readSourceAsset(url, expectedByteLength, expectedSha256) { - sourceAssets.add(url); - let bytes = assetCache.get(url); - if (!bytes) { - bytes = await readFile(publicAssetPath(root, url)); - assetCache.set(url, bytes); - } - if (bytes.length !== expectedByteLength || sha256(bytes) !== expectedSha256) { - throw new Error(`Source Flower Box projected asset identity mismatch: ${url}`); - } - return bytes; - } } function publicAssetPath(root, url) { if (typeof url !== "string" || !url.startsWith("/cssflower/") || url.includes("..")) { - throw new Error(`Unsafe Flower Box projected asset URL: ${url}`); + throw new Error(`Unsafe Flower Box product asset URL: ${url}`); } return join(root, url.slice("/cssflower/".length)); } diff --git a/src/adapters/flowerbox/tools/polycss-snapshot-page.mjs b/src/adapters/flowerbox/tools/polycss-snapshot-page.mjs index 149041f..fe5476c 100644 --- a/src/adapters/flowerbox/tools/polycss-snapshot-page.mjs +++ b/src/adapters/flowerbox/tools/polycss-snapshot-page.mjs @@ -9,9 +9,14 @@ import { 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, @@ -21,7 +26,6 @@ import { const host = document.getElementById("scene"); const params = new URLSearchParams(location.search); const sceneUrl = params.get("sceneUrl"); -const preparationLightingUrl = "/cssflower/assets/flower-box-space-texels.png"; main().catch((error) => { window.__cssFlowerDebugSnapshot = { @@ -36,13 +40,15 @@ async function main() { } const sceneData = await fetchJson(sceneUrl); validateScene(sceneData); - await Promise.all([ - fetchVerifiedBytes(preparationLightingUrl, sceneData.lighting.assetSha256), + 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 }); + const retained = mountRetainedTargets({ scene, mesh, sceneData, initialTransforms, preparationLightingUrl }); scene.applyCamera(); await scene.whenTexturesReady(); await new Promise((resolve) => requestAnimationFrame(() => requestAnimationFrame(resolve))); @@ -53,8 +59,8 @@ async function main() { }); assertSnapshotStats(stats, retained, sceneData); const exported = await exportPolySceneSnapshot(host); - const preparedAtlasUrl = sceneData.playback.projectedPixels.pages[0].atlas.assetUrl; - const html = restorePreparedLightingReference(exported, preparedAtlasUrl); + const preparedAtlasUrl = sceneData.lighting.grid.assetUrl; + const html = prepareExportedSnapshot(restorePreparedLightingReference(exported, preparedAtlasUrl)); assertExportedSnapshot(html, sceneData); window.__cssFlowerDebugSnapshot = { status: "ready", @@ -71,7 +77,7 @@ async function main() { mergedCellCount: 0, lightingAtlasStateCount: sceneData.lighting.timelineRowCount, lightingAtlasDataUrlCount: (html.match(/data:image\/png;base64/gu) ?? []).length, - preparedAtlasReferenceCount: (html.match(/\/cssflower\/assets\/projected\/atlas-[a-f0-9]{64}\.avif/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"); @@ -159,21 +172,13 @@ function createSnapshotScene(sceneData) { return { scene, mesh }; } -function mountRetainedTargets({ scene, mesh, sceneData }) { +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"; - root.dataset.cssflowerRootStateIndex = "0"; - root.dataset.cssflowerGeometryStateIndex = "0"; - root.dataset.cssflowerLightingRow = "0"; - root.dataset.cssflowerLightingPage = "0"; - root.dataset.cssflowerLightingPageRow = "0"; - root.dataset.cssflowerRuntimeDomGrowth = "false"; - root.dataset.cssflowerRuntimeGeometryConstruction = "false"; - root.dataset.cssflowerRuntimeLightingCalculation = "false"; Object.assign(root.style, { position: "absolute", left: "0", @@ -185,7 +190,6 @@ function mountRetainedTargets({ scene, mesh, sceneData }) { transform: sceneData.playback.cycle.rootTransforms[0], }); root.style.setProperty("--cssflower-space-texels", `url("${preparationLightingUrl}")`); - root.style.setProperty("--cssflower-lighting-y", sceneData.lighting.backgroundPositionYs[0]); sceneRoot.append(root); root.append(mesh.element); @@ -207,8 +211,8 @@ function mountRetainedTargets({ scene, mesh, sceneData }) { triangleIds.add(triangleId); const face = sceneData.lighting.faces[index]; if (face?.sourceOrder !== index || face.triangleId !== triangleId || - !Number.isSafeInteger(face.leafWidth) || face.leafWidth !== face.tileWidth || - !Number.isSafeInteger(face.leafHeight) || face.leafHeight !== face.tileHeight || + !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}`); } @@ -223,9 +227,12 @@ function mountRetainedTargets({ scene, mesh, sceneData }) { leaf.dataset.polycssTextureProjection = "affine"; leaf.dataset.polycssTextureLeafWidth = String(face.leafWidth); leaf.dataset.polycssTextureLeafHeight = String(face.leafHeight); - if (!leaf.style.transform.startsWith("matrix3d(")) { + 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"; @@ -239,7 +246,6 @@ function mountRetainedTargets({ scene, mesh, sceneData }) { leaf.style.backgroundSize = face.backgroundSize; leaf.style.imageRendering = "auto"; } - root.dataset.cssflowerStableLeafCount = String(leaves.length); return { root, leaves, triangleIds }; } @@ -261,18 +267,17 @@ function assertSnapshotStats(stats, retained, sceneData) { function assertExportedSnapshot(html, sceneData) { const count = (expression) => (html.match(expression) ?? []).length; const dataUrlCount = count(/data:image\/png;base64/gu); - const preparedAtlasReferenceCount = count(/\/cssflower\/assets\/projected\/atlas-[a-f0-9]{64}\.avif/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 || + !/
-
- Loading… -

PolyCSS · independent experiment, not affiliated with Microsoft

-