From ba95baabfaa70c81bd5913ac6137b2c52b09f557 Mon Sep 17 00:00:00 2001 From: alowpoly Date: Tue, 18 Aug 2026 08:03:28 -0300 Subject: [PATCH 1/5] feat(platonic-folding): add source-backed adapter --- package.json | 8 + src/adapters/platonic-folding/AGENTS.md | 9 + .../platonic-folding/LICENSE.XSCREENSAVER | 12 + src/adapters/platonic-folding/README.md | 27 + src/adapters/platonic-folding/index.html | 39 + .../notes/provenance-bible.md | 30 + .../notes/references/source-lock.json | 14 + .../src/cssplatonicfolding/bankSelection.mjs | 3 + .../src/cssplatonicfolding/client.mjs | 242 ++++++ .../src/cssplatonicfolding/styles.css | 155 ++++ src/adapters/platonic-folding/src/main.mjs | 4 + .../cssplatonicfolding/modelBuilder.mjs | 186 +++++ .../cssplatonicfolding/rasterAtlas.mjs | 85 +++ .../cssplatonicfolding/sourceModel.mjs | 708 ++++++++++++++++++ ...cssplatonicfolding-bank-selection.test.mjs | 8 + .../test/cssplatonicfolding-shell.test.mjs | 20 + .../cssplatonicfolding-source-model.test.mjs | 68 ++ .../tools/check-prepare-determinism.mjs | 22 + .../native/headless/capture-platonicfolding.c | 427 +++++++++++ .../tools/native/headless/include/earth.h | 11 + .../native/headless/include/glsl-utils.h | 30 + .../native/headless/include/screenhackI.h | 45 ++ .../tools/native/headless/include/utils.h | 6 + .../tools/native/headless/include/visual.h | 12 + .../native/headless/include/ximage-loader.h | 15 + .../tools/native/headless/include/xlockmore.h | 116 +++ .../tools/oracle/capture-browser-frames.mjs | 147 ++++ .../tools/oracle/capture-native-frames.mjs | 94 +++ .../tools/oracle/frame-schedule.mjs | 20 + .../tools/oracle/oracle-support.mjs | 67 ++ .../tools/oracle/run-visual-oracle.mjs | 126 ++++ .../tools/prepare-cssplatonicfolding.mjs | 112 +++ .../platonic-folding/tools/smoke-browser.mjs | 107 +++ src/adapters/platonic-folding/vite.config.mjs | 18 + 34 files changed, 2993 insertions(+) create mode 100644 src/adapters/platonic-folding/AGENTS.md create mode 100644 src/adapters/platonic-folding/LICENSE.XSCREENSAVER create mode 100644 src/adapters/platonic-folding/README.md create mode 100644 src/adapters/platonic-folding/index.html create mode 100644 src/adapters/platonic-folding/notes/provenance-bible.md create mode 100644 src/adapters/platonic-folding/notes/references/source-lock.json create mode 100644 src/adapters/platonic-folding/src/cssplatonicfolding/bankSelection.mjs create mode 100644 src/adapters/platonic-folding/src/cssplatonicfolding/client.mjs create mode 100644 src/adapters/platonic-folding/src/cssplatonicfolding/styles.css create mode 100644 src/adapters/platonic-folding/src/main.mjs create mode 100644 src/adapters/platonic-folding/src/prepare/cssplatonicfolding/modelBuilder.mjs create mode 100644 src/adapters/platonic-folding/src/prepare/cssplatonicfolding/rasterAtlas.mjs create mode 100644 src/adapters/platonic-folding/src/prepare/cssplatonicfolding/sourceModel.mjs create mode 100644 src/adapters/platonic-folding/test/cssplatonicfolding-bank-selection.test.mjs create mode 100644 src/adapters/platonic-folding/test/cssplatonicfolding-shell.test.mjs create mode 100644 src/adapters/platonic-folding/test/cssplatonicfolding-source-model.test.mjs create mode 100644 src/adapters/platonic-folding/tools/check-prepare-determinism.mjs create mode 100644 src/adapters/platonic-folding/tools/native/headless/capture-platonicfolding.c create mode 100644 src/adapters/platonic-folding/tools/native/headless/include/earth.h create mode 100644 src/adapters/platonic-folding/tools/native/headless/include/glsl-utils.h create mode 100644 src/adapters/platonic-folding/tools/native/headless/include/screenhackI.h create mode 100644 src/adapters/platonic-folding/tools/native/headless/include/utils.h create mode 100644 src/adapters/platonic-folding/tools/native/headless/include/visual.h create mode 100644 src/adapters/platonic-folding/tools/native/headless/include/ximage-loader.h create mode 100644 src/adapters/platonic-folding/tools/native/headless/include/xlockmore.h create mode 100644 src/adapters/platonic-folding/tools/oracle/capture-browser-frames.mjs create mode 100644 src/adapters/platonic-folding/tools/oracle/capture-native-frames.mjs create mode 100644 src/adapters/platonic-folding/tools/oracle/frame-schedule.mjs create mode 100644 src/adapters/platonic-folding/tools/oracle/oracle-support.mjs create mode 100644 src/adapters/platonic-folding/tools/oracle/run-visual-oracle.mjs create mode 100644 src/adapters/platonic-folding/tools/prepare-cssplatonicfolding.mjs create mode 100644 src/adapters/platonic-folding/tools/smoke-browser.mjs create mode 100644 src/adapters/platonic-folding/vite.config.mjs diff --git a/package.json b/package.json index cdc50de..76acd1b 100644 --- a/package.json +++ b/package.json @@ -43,6 +43,7 @@ "dev:electropaint": "vite --config src/adapters/electropaint/vite.config.mjs --host 127.0.0.1", "dev:menger": "vite --config src/adapters/menger/vite.config.mjs --host 127.0.0.1", "dev:maze": "vite --config src/adapters/maze/vite.config.mjs --host 127.0.0.1", + "dev:platonic-folding": "vite --config src/adapters/platonic-folding/vite.config.mjs --host 127.0.0.1", "dev:solitaire": "vite --config src/adapters/solitaire/vite.config.mjs --host 127.0.0.1", "build": "pnpm build:site && pnpm build:lib", "build:site": "vite build", @@ -63,6 +64,8 @@ "build:maze": "vite build --config src/adapters/maze/vite.config.mjs", "build:maze:full": "pnpm prepare:maze:source && pnpm build:maze", "build:maze:deploy": "CSSMAZE_DEPLOY_BUILD=1 pnpm build:maze", + "build:platonic-folding": "vite build --config src/adapters/platonic-folding/vite.config.mjs", + "build:platonic-folding:full": "pnpm prepare:platonic-folding && pnpm build:platonic-folding", "build:solitaire": "vite build --config src/adapters/solitaire/vite.config.mjs", "build:solitaire:full": "pnpm prepare:solitaire && pnpm build:solitaire", "build:solitaire:deploy": "CSSSOLITAIRE_DEPLOY_BUILD=1 pnpm build:solitaire", @@ -88,6 +91,8 @@ "prepare:maze:artifact": "node scripts/prepare/cssmaze-product-bank.mjs", "prepare:maze:source": "node src/adapters/maze/tools/prepare-cssmaze.mjs && node src/adapters/maze/tools/prepare-polycss-snapshot.mjs", "prepare:maze:check": "node src/adapters/maze/tools/check-prepare-determinism.mjs", + "prepare:platonic-folding": "node src/adapters/platonic-folding/tools/prepare-cssplatonicfolding.mjs", + "prepare:platonic-folding:check": "node src/adapters/platonic-folding/tools/check-prepare-determinism.mjs", "prepare:solitaire": "node src/adapters/solitaire/tools/prepare-csssolitaire.mjs", "prepare:solitaire:check": "node src/adapters/solitaire/tools/check-prepare-determinism.mjs", "prepare:catalog": "pnpm build:lib && node scripts/prepare/catalog.mjs", @@ -147,6 +152,9 @@ "capture:maze:browser:frames": "node src/adapters/maze/tools/capture-browser-frames.mjs", "compare:maze:frames": "node src/adapters/maze/tools/compare-frame-sequence.mjs", "perf:maze:trace": "node src/adapters/maze/tools/trace-performance.mjs", + "test:platonic-folding": "node --test src/adapters/platonic-folding/test/*.test.mjs", + "test:platonic-folding:browser": "node src/adapters/platonic-folding/tools/smoke-browser.mjs", + "oracle:platonic-folding": "node src/adapters/platonic-folding/tools/oracle/run-visual-oracle.mjs", "test:solitaire": "node --test src/adapters/solitaire/test/csssolitaire-bank-selection.test.mjs src/adapters/solitaire/test/csssolitaire-shell.test.mjs src/adapters/solitaire/test/csssolitaire-source-profile.test.mjs", "test:solitaire:assets": "node --test src/adapters/solitaire/test/csssolitaire-assets.test.mjs", "test:solitaire:browser": "node src/adapters/solitaire/tools/smoke-browser.mjs", diff --git a/src/adapters/platonic-folding/AGENTS.md b/src/adapters/platonic-folding/AGENTS.md new file mode 100644 index 0000000..061a051 --- /dev/null +++ b/src/adapters/platonic-folding/AGENTS.md @@ -0,0 +1,9 @@ +# Platonic Folding adapter contract + +- Read `notes/provenance-bible.md` before changing topology, face geometry, colors, lighting, camera, timing, motion, or parity claims. +- Preserve the pinned XScreenSaver face graphs, 4/6/8/12/20 face census, dihedral fold angles, 25 ms cadence, quintic hinge easing, 45-degree field of view, and source portrait/landscape travel direction. +- Preparation owns the deterministic unfolding trees, complete playback, face colors, raster lighting rows, and two responsive product banks. The mobile bank's 1.55x eye distance is the only camera adaptation from the source constants. +- Runtime may mount one prepared Morph package, publish prepared model/shape/atlas-row state, and update the responsive perspective carrier. It must not calculate geometry, rasterize assets, grow the DOM, or rebuild topology during playback. +- Keep one stable retained graph with 50 face roots and 50 `` leaves. Only the active solid is visible. No Canvas, WebGL, WebGPU, SVG scene geometry, masks, clip paths, runtime atlas work, or paint-heavy CSS effects. +- Keep source checkouts, native binaries, captures, traces, and generated prepared assets ignored. +- Keep the independent headless CGL frame-sequence oracle passing across all five solids. Its qualified claim is a bounded source-faithful approximation, not visual parity or pixel identity. diff --git a/src/adapters/platonic-folding/LICENSE.XSCREENSAVER b/src/adapters/platonic-folding/LICENSE.XSCREENSAVER new file mode 100644 index 0000000..c1635a2 --- /dev/null +++ b/src/adapters/platonic-folding/LICENSE.XSCREENSAVER @@ -0,0 +1,12 @@ +Copyright (c) 2025-2026 Carsten Steger . + +Permission to use, copy, modify, and distribute this software and its +documentation for any purpose and without fee is hereby granted, provided that +the above copyright notice appear in all copies and that both that copyright +notice and this permission notice appear in supporting documentation. + +This file is provided AS IS with no warranties of any kind. The author shall +have no liability with respect to the infringement of copyrights, trade +secrets or any patents by this file or any part thereof. In no event will the +author be liable for any lost revenue or profits or other special, indirect +and consequential damages. diff --git a/src/adapters/platonic-folding/README.md b/src/adapters/platonic-folding/README.md new file mode 100644 index 0000000..c013eb1 --- /dev/null +++ b/src/adapters/platonic-folding/README.md @@ -0,0 +1,27 @@ +# css.graphics/platonic-folding + +Source-backed XScreenSaver Platonic Folding rendered as 50 retained PolyCSS +Morph face roots. Preparation bakes both responsive banks, every hinge pose, +source-style face colors, and quantized lighting into one raster atlas. + +```sh +export CSSPLATONICFOLDING_SOURCE_ROOT=/path/to/xscreensaver +pnpm prepare:platonic-folding +pnpm dev:platonic-folding +pnpm test:platonic-folding +pnpm prepare:platonic-folding:check +pnpm test:platonic-folding:browser +pnpm build:platonic-folding +pnpm oracle:platonic-folding +``` + +Runtime owns only the prepared playback clock, sparse Morph publication, and +responsive perspective. It performs no geometry construction, atlas +rasterization, topology rebuilding, or DOM growth. + +The oracle compiles the pinned XScreenSaver source into a headless CGL capture, +captures the same deterministic frame schedule from the browser, proves exact +native and browser A/A stability, and compares all five solids. The qualified +result is a bounded source-faithful approximation, not visual parity or pixel +identity; the remaining delta is confined to prepared atlas filtering and +64-state per-face color quantization. diff --git a/src/adapters/platonic-folding/index.html b/src/adapters/platonic-folding/index.html new file mode 100644 index 0000000..a26a023 --- /dev/null +++ b/src/adapters/platonic-folding/index.html @@ -0,0 +1,39 @@ + + + + + + + + + + + + + + + + + + + Platonic Folding - Powered by PolyCSS + + + + + + diff --git a/src/adapters/platonic-folding/notes/provenance-bible.md b/src/adapters/platonic-folding/notes/provenance-bible.md new file mode 100644 index 0000000..832ea06 --- /dev/null +++ b/src/adapters/platonic-folding/notes/provenance-bible.md @@ -0,0 +1,30 @@ +# Platonic Folding provenance + +The authority is XScreenSaver `hacks/glx/platonicfolding.c` at revision +`906693799e4fb7581436590cf84ecb2d3c9186ba`. The source file carries a +permissive use, copy, modification, and distribution notice from Carsten +Steger. The adjacent XML config supplies the public defaults and description. + +The prepared source model preserves the five face-adjacency graphs, base face +geometry, face counts, eye distances, maximum fold angles, 25 ms cadence, +180-step entrance and exit, quintic hinge easing, 0.5-degree rotation cadence, +45-degree field of view, and portrait/landscape travel direction. The mobile +bank moves the eye 1.55 times farther away so complete nets fit narrow screens. +The product +chooses the source-supported one-folding mode and a deterministic valid run in +which each solid uses joint folding. + +All five solids share one retained graph: 50 face roots and 50 raster leaves. +Only the active solid's 4, 6, 8, 12, or 20 leaves are visible. Preparation owns +the unfolding trees, complete 2,710-frame sequence, face matrices, source-style +face colors, quantized baked lighting rows, and atlas. Runtime owns the clock, +prepared sparse publication, and responsive perspective only. + +The independent oracle includes the pinned source file directly in a headless +CGL harness and exercises its GLSL folding draw path. Native and browser A/A +captures are exact. The matched native/browser schedule covers entrance, +folded, transition, unfolded, refolded, and exit poses for all five solids. The +qualified claim is a bounded source-faithful approximation rather than visual +parity or pixel identity: the remaining delta is confined to prepared atlas +filtering and the 64-state per-face color palettes. The existing upstream video +remains preview material, not proof. diff --git a/src/adapters/platonic-folding/notes/references/source-lock.json b/src/adapters/platonic-folding/notes/references/source-lock.json new file mode 100644 index 0000000..efe2f4e --- /dev/null +++ b/src/adapters/platonic-folding/notes/references/source-lock.json @@ -0,0 +1,14 @@ +{ + "title": "XScreenSaver Platonic Folding", + "revision": "906693799e4fb7581436590cf84ecb2d3c9186ba", + "repository": "https://github.com/Zygo/xscreensaver", + "primary": { + "path": "hacks/glx/platonicfolding.c", + "sha256": "2c2c31987b09941814000f2c1dbbfee8f92f7fa1a82fca120c2c57c03e03057d" + }, + "config": { + "path": "hacks/config/platonicfolding.xml", + "sha256": "2fb097c5365aba8b3510c355b3b81695177275ef3d4a0d4c3fd1c0bd38df759f" + }, + "license": "Per-file permissive notice by Carsten Steger, 2025-2026" +} diff --git a/src/adapters/platonic-folding/src/cssplatonicfolding/bankSelection.mjs b/src/adapters/platonic-folding/src/cssplatonicfolding/bankSelection.mjs new file mode 100644 index 0000000..a59a52d --- /dev/null +++ b/src/adapters/platonic-folding/src/cssplatonicfolding/bankSelection.mjs @@ -0,0 +1,3 @@ +export function selectPlatonicBank({ width = innerWidth, height = innerHeight } = {}) { + return width < height ? "mobile" : "desktop"; +} diff --git a/src/adapters/platonic-folding/src/cssplatonicfolding/client.mjs b/src/adapters/platonic-folding/src/cssplatonicfolding/client.mjs new file mode 100644 index 0000000..1807090 --- /dev/null +++ b/src/adapters/platonic-folding/src/cssplatonicfolding/client.mjs @@ -0,0 +1,242 @@ +import { createPolyPerspectiveCamera } from "@layoutit/polycss"; +import { + createPolyMorphPlaybackRuntime, + loadPolyMorphPackage, + mountPolyMorphModel, +} from "@layoutit/polycss-morph"; +import { selectPlatonicBank } from "./bankSelection.mjs"; + +export function mountPlatonicFolding(host) { + const state = { ready: false, errors: [], bankId: null, metadata: null, mounted: null, player: null }; + installDebugApi(state); + main().catch(fail); + return state; + + async function main() { + const bankId = selectPlatonicBank({ width: host.clientWidth || innerWidth, height: host.clientHeight || innerHeight }); + const modelId = `platonic-folding-${bankId}`; + const [loaded, metadata] = await Promise.all([ + loadPolyMorphPackage("/cssplatonicfolding/model/", { modelId }), + fetch("/cssplatonicfolding/prepared.json", { cache: "no-store" }) + .then(assertResponse) + .then((response) => response.json()), + ]); + const preparedBank = metadata?.preparedBanks?.find((bank) => bank.id === bankId); + if (metadata?.schema !== "cssplatonicfolding-prepared-scene@1" || + loaded.model.identity.id !== modelId || preparedBank?.modelId !== modelId) { + throw new Error("Platonic Folding prepared model binding drifted"); + } + const mounted = mountPolyMorphModel(host, loaded.model, { + resources: loaded.resources, + camera: createPolyPerspectiveCamera({ + perspective: 800, + target: [0, 0, 0], + rotX: 0, + rotY: 0, + zoom: 50, + distance: 0, + }), + }); + cleanPreparedDom(mounted); + const player = createPlayer(mounted, loaded.model); + player.resize(); + player.seekFrame(0); + addEventListener("resize", player.resize, { passive: true }); + state.ready = true; + state.bankId = bankId; + state.metadata = metadata; + state.mounted = mounted; + state.player = player; + document.body.classList.replace("loading", "ready"); + player.resume(); + } + + function fail(error) { + state.errors.push(String(error?.stack || error)); + document.body.classList.remove("loading"); + document.body.classList.add("error"); + console.error(error); + } +} + +function createPlayer(mounted, model) { + const runtime = createPolyMorphPlaybackRuntime(model); + const frameMilliseconds = model.playback.frames[1].timeMs - model.playback.frames[0].timeMs; + const durationMilliseconds = runtime.durationMs; + let paused = true; + let destroyed = false; + let timer = null; + let clockOrigin = performance.now(); + let pausedAt = 0; + let lastFrameIndex = -1; + let timerCallbackCount = 0; + let collapsedFrameCount = 0; + let applyCount = 0; + let modelTransformWrites = 0; + let shapeTransformWrites = 0; + let visibilityWrites = 0; + let atlasRowWrites = 0; + + function publish(elapsed) { + const sample = runtime.sample(elapsed); + if (sample.frameIndex === lastFrameIndex) return sample.frameIndex; + const result = mounted.apply(sample.update); + runtime.commit(sample); + lastFrameIndex = sample.frameIndex; + applyCount += 1; + modelTransformWrites += result.modelTransformWrites; + shapeTransformWrites += result.shapeTransformWrites; + visibilityWrites += result.visibilityWrites; + atlasRowWrites += result.atlasRowWrites; + return sample.frameIndex; + } + + function cancelScheduled() { + if (timer !== null) clearTimeout(timer); + timer = null; + } + + function schedule() { + if (paused || destroyed || timer !== null) return; + const elapsed = Math.max(0, performance.now() - clockOrigin); + const phase = elapsed % durationMilliseconds; + const nextFrame = (Math.floor(phase / frameMilliseconds) + 1) * frameMilliseconds; + timer = setTimeout(wake, Math.max(1, Math.ceil(nextFrame - phase))); + } + + function wake() { + timer = null; + if (paused || destroyed) return; + timerCallbackCount += 1; + const elapsed = Math.max(0, performance.now() - clockOrigin); + const dueFrame = Math.floor((elapsed % durationMilliseconds) / frameMilliseconds); + if (lastFrameIndex >= 0) { + const distance = dueFrame >= lastFrameIndex + ? dueFrame - lastFrameIndex + : runtime.frameCount - lastFrameIndex + dueFrame; + collapsedFrameCount += Math.max(0, distance - 1); + } + publish(elapsed); + schedule(); + } + + function resize() { + const perspective = Math.min(innerWidth, innerHeight) / + (2 * Math.tan(22.5 * Math.PI / 180)); + mounted.cameraElement.style.perspective = `${Number(perspective.toFixed(4))}px`; + mounted.sceneElement.style.transform = `translateZ(${Number(perspective.toFixed(4))}px)`; + return snapshot(); + } + + function snapshot() { + mounted.assertStableDomIdentity(); + return Object.freeze({ + paused, + frameIndex: lastFrameIndex, + frameCount: runtime.frameCount, + durationMilliseconds, + frameMilliseconds, + timerCallbackCount, + collapsedFrameCount, + applyCount, + modelTransformWrites, + shapeTransformWrites, + visibilityWrites, + atlasRowWrites, + runtimeGeometryConstructionCount: 0, + runtimeAtlasRasterizationCount: 0, + runtimeDomGrowth: false, + retainedDomStable: true, + }); + } + + return Object.freeze({ + resize, + resume() { + if (!paused || destroyed) return snapshot(); + clockOrigin = performance.now() - pausedAt; + paused = false; + schedule(); + return snapshot(); + }, + pause() { + if (paused || destroyed) return snapshot(); + pausedAt = performance.now() - clockOrigin; + paused = true; + cancelScheduled(); + return snapshot(); + }, + seekFrame(frameIndex) { + if (!Number.isSafeInteger(frameIndex) || frameIndex < 0 || frameIndex >= runtime.frameCount) { + throw new RangeError("Platonic Folding prepared frame is out of range"); + } + const wasPaused = paused; + if (!wasPaused) this.pause(); + pausedAt = model.playback.frames[frameIndex].timeMs; + publish(pausedAt); + if (!wasPaused) this.resume(); + return snapshot(); + }, + stats: snapshot, + destroy() { + paused = true; + destroyed = true; + cancelScheduled(); + removeEventListener("resize", resize); + mounted.destroy(); + }, + }); +} + +function cleanPreparedDom(mounted) { + mounted.cameraElement.className = "polycss-camera"; + mounted.cameraElement.removeAttribute("data-polycss-camera-projection"); + mounted.sceneElement.className = "polycss-scene"; + mounted.modelElement.removeAttribute("class"); + mounted.modelElement.removeAttribute("data-poly-morph-model"); + for (const element of mounted.shapeElements.values()) { + element.removeAttribute("class"); + element.removeAttribute("data-poly-morph-shape"); + } + for (const { element } of mounted.leafHandles.values()) { + element.removeAttribute("class"); + element.removeAttribute("data-poly-morph-leaf"); + element.removeAttribute("data-poly-morph-strategy"); + element.removeAttribute("data-poly-morph-resolved-strategy"); + element.style.removeProperty("backface-visibility"); + element.style.removeProperty("background-repeat"); + element.style.removeProperty("opacity"); + element.style.removeProperty("transform-origin"); + element.style.removeProperty("visibility"); + } +} + +function installDebugApi(state) { + Object.defineProperty(window, "__cssPlatonicFoldingDebug", { + configurable: true, + value: Object.freeze({ + get ready() { return state.ready; }, + get errors() { return Object.freeze([...state.errors]); }, + pause() { return state.player?.pause(); }, + resume() { return state.player?.resume(); }, + seekFrame(index) { return state.player?.seekFrame(index); }, + state() { return state.player?.stats() ?? null; }, + metadata() { return state.metadata; }, + stats() { + if (!state.mounted || !state.player) return null; + return Object.freeze({ + retainedFaceRootCount: state.mounted.shapeElements.size, + retainedPolygonLeafCount: state.mounted.leafHandles.size, + selectedPreparedBank: state.bankId, + preparedBankLoadCount: 1, + ...state.player.stats(), + }); + }, + }), + }); +} + +function assertResponse(response) { + if (!response.ok) throw new Error(`Platonic Folding prepared asset failed: ${response.status} ${response.url}`); + return response; +} diff --git a/src/adapters/platonic-folding/src/cssplatonicfolding/styles.css b/src/adapters/platonic-folding/src/cssplatonicfolding/styles.css new file mode 100644 index 0000000..91690b5 --- /dev/null +++ b/src/adapters/platonic-folding/src/cssplatonicfolding/styles.css @@ -0,0 +1,155 @@ +:root { + color-scheme: dark; + font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; + background: #000; + color: #eef2f4; +} + +* { box-sizing: border-box; } + +html, +body { + width: 100%; + height: 100%; + margin: 0; +} + +body { + position: relative; + overflow: hidden; + background: #000; +} + +body.loading::before { + content: ""; + position: fixed; + inset: 0; + z-index: 19; + background: #000; +} + +body.loading::after { + content: ""; + position: fixed; + inset: 50% auto auto 50%; + z-index: 20; + width: 18px; + height: 18px; + margin: -9px 0 0 -9px; + border: 1px solid rgb(240 240 240 / 18%); + border-top-color: rgb(240 240 240 / 70%); + border-radius: 50%; + animation: cssplatonicfolding-loading 0.8s linear infinite; +} + +@keyframes cssplatonicfolding-loading { to { transform: rotate(1turn); } } + +.site-header { + position: fixed; + inset: 0 0 auto; + z-index: 21; + display: flex; + align-items: center; + justify-content: space-between; + width: 100%; + height: 50px; + padding: 0 12px 0 16px; +} + +.site-wordmark-heading { margin: 0; font: inherit; } + +.site-wordmark { + display: inline-flex; + align-items: center; + color: #f0f0f0; + opacity: 0.72; + text-decoration: none; +} + +.site-wordmark-svg { + display: block; + width: 332px; + height: 30px; + overflow: visible; +} + +.site-wordmark-svg text { + fill: currentColor; + font-family: inherit; + font-size: 24px; + font-weight: 400; + letter-spacing: 0.1px; +} + +.site-wordmark-css { font-weight: 500; } +.site-wordmark-graphics { font-weight: 200; } +.site-wordmark-path { font-weight: 100; } + +.site-action-icon-only { + display: inline-flex; + width: 36px; + min-width: 36px; + height: 50px; + align-items: center; + justify-content: center; + padding: 0; + color: #f0f0f0; + opacity: 0.72; + text-decoration: none; +} + +.site-action-icon { + width: 18px; + height: 18px; + fill: none; + stroke: currentColor; + stroke-linecap: round; + stroke-linejoin: round; + stroke-width: 1.8; +} + +.site-wordmark:hover, +.site-wordmark:focus-visible, +.site-action-icon-only:hover, +.site-action-icon-only:focus-visible { opacity: 1; } + +body > .polycss-camera { + position: absolute; + inset: 0; + width: 100%; + height: 100%; + overflow: hidden; + background: #000; + perspective-origin: 50% 50%; +} + +body > .polycss-camera > .polycss-scene, +body > .polycss-camera > .polycss-scene > div, +body > .polycss-camera > .polycss-scene > div > div { + position: absolute; + transform-style: preserve-3d; + transform-origin: 0 0 0; +} + +body > .polycss-camera s { + position: absolute; + background-repeat: no-repeat; + backface-visibility: visible; + image-rendering: auto; + transform-origin: 0 0; +} + +body.error::after { + content: "Platonic Folding failed to load."; + position: fixed; + inset: 0; + z-index: 30; + display: grid; + place-items: center; + color: #ffb5a7; +} + +@media (max-width: 520px) { + .site-wordmark-svg { width: 278px; } + .site-wordmark-svg text { font-size: 20px; } +} diff --git a/src/adapters/platonic-folding/src/main.mjs b/src/adapters/platonic-folding/src/main.mjs new file mode 100644 index 0000000..bd6a22a --- /dev/null +++ b/src/adapters/platonic-folding/src/main.mjs @@ -0,0 +1,4 @@ +import "./cssplatonicfolding/styles.css"; +import { mountPlatonicFolding } from "./cssplatonicfolding/client.mjs"; + +mountPlatonicFolding(document.body); diff --git a/src/adapters/platonic-folding/src/prepare/cssplatonicfolding/modelBuilder.mjs b/src/adapters/platonic-folding/src/prepare/cssplatonicfolding/modelBuilder.mjs new file mode 100644 index 0000000..d5076a3 --- /dev/null +++ b/src/adapters/platonic-folding/src/prepare/cssplatonicfolding/modelBuilder.mjs @@ -0,0 +1,186 @@ +import { + CSSPLATONIC_RASTER_LEAF_SIZE, + buildPlatonicSourceSequence, +} from "./sourceModel.mjs"; +import { platonicRasterSlice } from "./rasterAtlas.mjs"; + +const IDENTITY = Object.freeze([1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1]); + +export function buildPlatonicPreparedModel({ bankId = "desktop" } = {}) { + const source = buildPlatonicSourceSequence({ bankId }); + const firstFrame = source.frames[0]; + const vertices = []; + const normals = [Object.freeze([0, 0, 1])]; + const polygons = []; + const shapes = []; + const leaves = []; + for (const face of source.faceDefinitions) { + const vertexOffset = vertices.length; + vertices.push( + Object.freeze([0, 0, 0]), + Object.freeze([1, 0, 0]), + Object.freeze([1, 1, 0]), + Object.freeze([0, 1, 0]), + ); + const polygonId = `polygon-${face.id}`; + polygons.push(Object.freeze({ + id: polygonId, + vertexIndices: Object.freeze([0, 1, 2, 3].map((index) => vertexOffset + index)), + normalIndices: Object.freeze([0, 0, 0, 0]), + })); + const initialFace = face.solidId === firstFrame.solidId + ? firstFrame.faces[face.faceIndex] + : null; + shapes.push(Object.freeze({ id: face.id, matrix: initialFace?.matrix ?? IDENTITY })); + leaves.push(Object.freeze({ + id: `leaf-${face.id}`, + polygonId, + shapeId: face.id, + materialId: "platonic-face", + strategy: "atlas-slice", + width: CSSPLATONIC_RASTER_LEAF_SIZE, + height: CSSPLATONIC_RASTER_LEAF_SIZE, + matrix: faceLeafMatrix(face), + atlas: platonicRasterSlice(face.faceColumn, source.faceDefinitions.length), + fallback: null, + })); + } + const playbackFrames = buildPlaybackFrames(source); + const model = deepFreeze({ + schema: "polycss-morph.model@1", + identity: Object.freeze({ + id: source.bank.modelId, + name: source.bank.name, + revision: "1.0.0", + }), + profile: "prepared-playback", + capabilities: Object.freeze(["prepared-playback", "retained-render", "sparse-updates"]), + budgets: Object.freeze({ + maxVertices: vertices.length, + maxPolygons: polygons.length, + maxLeaves: leaves.length, + maxFrames: playbackFrames.length, + maxJoints: 0, + maxResources: 2, + maxBytes: 64 * 1024 * 1024, + }), + topology: Object.freeze({ + vertices: Object.freeze(vertices), + normals: Object.freeze(normals), + polygons: Object.freeze(polygons), + }), + materials: Object.freeze([Object.freeze({ + id: "platonic-face", + color: Object.freeze([1, 1, 1, 1]), + })]), + render: Object.freeze({ + modelMatrix: firstFrame.modelMatrix, + shapes: Object.freeze(shapes), + leaves: Object.freeze(leaves), + }), + deformation: Object.freeze({ kind: "none" }), + controls: Object.freeze([]), + springs: Object.freeze([]), + animations: Object.freeze([]), + playback: Object.freeze({ + durationMs: source.durationMilliseconds, + loop: true, + frames: playbackFrames, + }), + provenance: Object.freeze({ + generator: "cssplatonicfolding-preparer", + generatorVersion: "1.0.0", + sources: Object.freeze([Object.freeze({ + id: "xscreensaver-platonicfolding", + kind: "open-data", + uri: `https://github.com/Zygo/xscreensaver/blob/${source.bank.commit}/${source.bank.primaryPath}`, + sha256: source.bank.primarySha256, + license: "XScreenSaver platonicfolding.c permissive notice", + })]), + }), + }); + return deepFreeze({ + source, + model, + metrics: Object.freeze({ + bankId, + sourceSolidCount: 5, + sourceFaceCount: source.faceDefinitions.length, + retainedShapeRootCount: shapes.length, + retainedPolygonLeafCount: leaves.length, + frameCount: playbackFrames.length, + durationMilliseconds: source.durationMilliseconds, + maximumVisibleLeaves: 20, + runtimeGeometryConstructionCount: 0, + runtimeAtlasRasterizationCount: 0, + runtimeDomGrowth: false, + }), + }); +} + +function buildPlaybackFrames(source) { + let previous = null; + return Object.freeze(source.frames.map((frame) => { + const shapes = []; + const leaves = []; + for (const face of source.faceDefinitions) { + const active = face.solidId === frame.solidId; + const wasActive = previous?.solidId === face.solidId; + if (active) { + const sample = frame.faces[face.faceIndex]; + const previousSample = wasActive ? previous.faces[face.faceIndex] : null; + if (!previousSample || !matricesEqual(sample.matrix, previousSample.matrix)) { + shapes.push(Object.freeze({ shapeId: face.id, matrix: sample.matrix })); + } + if (!previousSample || sample.lightRow !== previousSample.lightRow || !wasActive) { + leaves.push(Object.freeze({ + leafId: `leaf-${face.id}`, + matrix: null, + visible: wasActive ? null : true, + opacity: null, + atlasRow: sample.lightRow, + })); + } + } else if (!previous || wasActive) { + leaves.push(Object.freeze({ + leafId: `leaf-${face.id}`, + matrix: null, + visible: false, + opacity: null, + atlasRow: null, + })); + } + } + const prepared = Object.freeze({ + timeMs: frame.timeMs, + modelMatrix: previous && matricesEqual(frame.modelMatrix, previous.modelMatrix) + ? null + : frame.modelMatrix, + shapes: Object.freeze(shapes), + leaves: Object.freeze(leaves), + }); + previous = frame; + return prepared; + })); +} + +function faceLeafMatrix(face) { + return Object.freeze([ + face.bounds.width / CSSPLATONIC_RASTER_LEAF_SIZE, 0, 0, 0, + 0, face.bounds.height / CSSPLATONIC_RASTER_LEAF_SIZE, 0, 0, + 0, 0, 1, 0, + face.bounds.minX, face.bounds.minY, face.vertices[0][2], 1, + ].map((value) => Number(value.toFixed(10)))); +} + +function matricesEqual(left, right) { + return left.length === right.length && left.every((value, index) => value === right[index]); +} + +function deepFreeze(value) { + if (value && typeof value === "object" && !Object.isFrozen(value)) { + Object.freeze(value); + for (const child of Object.values(value)) deepFreeze(child); + } + return value; +} diff --git a/src/adapters/platonic-folding/src/prepare/cssplatonicfolding/rasterAtlas.mjs b/src/adapters/platonic-folding/src/prepare/cssplatonicfolding/rasterAtlas.mjs new file mode 100644 index 0000000..5b59e3a --- /dev/null +++ b/src/adapters/platonic-folding/src/prepare/cssplatonicfolding/rasterAtlas.mjs @@ -0,0 +1,85 @@ +import { PNG } from "pngjs"; +import { + CSSPLATONIC_LIGHT_LEVELS, + CSSPLATONIC_RASTER_LEAF_SIZE, +} from "./sourceModel.mjs"; + +export const CSSPLATONIC_RASTER_ATLAS_PATH = "assets/face-colors.png"; + +const GUTTER = 1; +const STRIDE = CSSPLATONIC_RASTER_LEAF_SIZE + GUTTER * 2; + +export function platonicRasterSlice(faceColumn, faceCount) { + return Object.freeze({ + resourcePath: CSSPLATONIC_RASTER_ATLAS_PATH, + x: faceColumn * STRIDE + GUTTER, + y: 0, + width: CSSPLATONIC_RASTER_LEAF_SIZE, + height: CSSPLATONIC_RASTER_LEAF_SIZE, + pageWidth: faceCount * STRIDE, + pageHeight: CSSPLATONIC_LIGHT_LEVELS * CSSPLATONIC_RASTER_LEAF_SIZE, + }); +} + +export function buildPlatonicRasterAtlas(faceDefinitions) { + const pageWidth = faceDefinitions.length * STRIDE; + const pageHeight = CSSPLATONIC_LIGHT_LEVELS * CSSPLATONIC_RASTER_LEAF_SIZE; + const png = new PNG({ width: pageWidth, height: pageHeight }); + for (let lightRow = 0; lightRow < CSSPLATONIC_LIGHT_LEVELS; lightRow += 1) { + for (const face of faceDefinitions) paintFace(png, face, lightRow); + } + return Uint8Array.from(PNG.sync.write(png, { + colorType: 6, + inputColorType: 6, + bitDepth: 8, + inputHasAlpha: true, + filterType: 4, + deflateLevel: 9, + deflateStrategy: 3, + })); +} + +function paintFace(png, face, lightRow) { + const left = face.faceColumn * STRIDE; + const top = lightRow * CSSPLATONIC_RASTER_LEAF_SIZE; + const color = face.lightPalette[lightRow]; + const polygon = face.vertices.map(([x, y]) => [ + (x - face.bounds.minX) / face.bounds.width, + (y - face.bounds.minY) / face.bounds.height, + ]); + for (let y = 0; y < CSSPLATONIC_RASTER_LEAF_SIZE; y += 1) { + for (let x = -GUTTER; x < CSSPLATONIC_RASTER_LEAF_SIZE + GUTTER; x += 1) { + const sampleX = Math.max(0, Math.min(CSSPLATONIC_RASTER_LEAF_SIZE - 1, x)); + const coverage = pixelCoverage(polygon, sampleX, y); + const offset = ((top + y) * png.width + left + x + GUTTER) * 4; + png.data[offset] = Math.round(color[0] * 255); + png.data[offset + 1] = Math.round(color[1] * 255); + png.data[offset + 2] = Math.round(color[2] * 255); + png.data[offset + 3] = Math.round(coverage * 255); + } + } +} + +function pixelCoverage(polygon, x, y) { + let hits = 0; + for (const offsetY of [0.25, 0.75]) { + for (const offsetX of [0.25, 0.75]) { + const point = [ + (x + offsetX) / CSSPLATONIC_RASTER_LEAF_SIZE, + (y + offsetY) / CSSPLATONIC_RASTER_LEAF_SIZE, + ]; + if (pointInPolygon(point, polygon)) hits += 1; + } + } + return hits / 4; +} + +function pointInPolygon([x, y], polygon) { + let inside = false; + for (let current = 0, previous = polygon.length - 1; current < polygon.length; previous = current++) { + const [x1, y1] = polygon[current]; + const [x2, y2] = polygon[previous]; + if ((y1 > y) !== (y2 > y) && x < (x2 - x1) * (y - y1) / (y2 - y1) + x1) inside = !inside; + } + return inside; +} diff --git a/src/adapters/platonic-folding/src/prepare/cssplatonicfolding/sourceModel.mjs b/src/adapters/platonic-folding/src/prepare/cssplatonicfolding/sourceModel.mjs new file mode 100644 index 0000000..75bd945 --- /dev/null +++ b/src/adapters/platonic-folding/src/prepare/cssplatonicfolding/sourceModel.mjs @@ -0,0 +1,708 @@ +const RANDOM_VECTOR = Object.freeze([ + 0o35340171546, 0o10401501101, 0o22364657325, 0o24130436022, 0o02167303062, + 0o37570375137, 0o37210607110, 0o16272055420, 0o23011770546, 0o17143426366, + 0o14753657433, 0o21657231332, 0o23553406142, 0o04236526362, 0o10365611275, + 0o07117336710, 0o11051276551, 0o02362132524, 0o01011540233, 0o12162531646, + 0o07056762337, 0o06631245521, 0o14164542224, 0o32633236305, 0o23342700176, + 0o02433062234, 0o15257225043, 0o26762051606, 0o00742573230, 0o05366042132, + 0o12126416411, 0o00520471171, 0o00725646277, 0o20116577576, 0o25765742604, + 0o07633473735, 0o15674255275, 0o17555634041, 0o06503154145, 0o21576344247, + 0o14577627653, 0o02707523333, 0o34146376720, 0o30060227734, 0o13765414060, + 0o36072251540, 0o07255221037, 0o24364674123, 0o06200353166, 0o10126373326, + 0o15664104320, 0o16401041535, 0o16215305520, 0o33115351014, 0o17411670323, +]); + +const SQRT_2_4 = Math.SQRT2 / 4; +const SQRT_2_2 = Math.SQRT1_2; +const SQRT_3_2 = Math.sqrt(3) / 2; +const COS_36 = Math.cos(Math.PI / 5); +const SIN_36 = Math.sin(Math.PI / 5); +const COS_72 = Math.cos(2 * Math.PI / 5); +const SIN_72 = Math.sin(2 * Math.PI / 5); +const DODECA_ICOSA_IN_RADIUS = (3 + Math.sqrt(5)) / 4; +const SOURCE_ORDER = Object.freeze([ + "icosahedron", + "dodecahedron", + "hexahedron", + "octahedron", + "tetrahedron", +]); +const LIGHT_DIRECTION = normalize([1, 1, 1]); +const LIGHT_HALF_VECTOR = normalize([LIGHT_DIRECTION[0], LIGHT_DIRECTION[1], LIGHT_DIRECTION[2] + 1]); + +export const CSSPLATONIC_FRAME_MILLISECONDS = 25; +export const CSSPLATONIC_LIGHT_LEVELS = 64; +export const CSSPLATONIC_RASTER_LEAF_SIZE = 64; +export const CSSPLATONIC_SEED = 26081705; + +export const PLATONIC_SOURCE = Object.freeze({ + commit: "906693799e4fb7581436590cf84ecb2d3c9186ba", + primaryPath: "hacks/glx/platonicfolding.c", + primarySha256: "2c2c31987b09941814000f2c1dbbfee8f92f7fa1a82fca120c2c57c03e03057d", + configPath: "hacks/config/platonicfolding.xml", + configSha256: "2fb097c5365aba8b3510c355b3b81695177275ef3d4a0d4c3fd1c0bd38df759f", + delayMicroseconds: 25_000, + appearFrames: 181, + hingeFrames: 90, + disappearFrames: 181, + fieldOfViewDegrees: 45, +}); + +export const PLATONIC_BANKS = Object.freeze({ + desktop: Object.freeze({ + ...PLATONIC_SOURCE, + id: "desktop", + modelId: "platonic-folding-desktop", + name: "Platonic Folding", + entryAxis: "vertical", + eyeDistanceScale: 1, + }), + mobile: Object.freeze({ + ...PLATONIC_SOURCE, + id: "mobile", + modelId: "platonic-folding-mobile", + name: "Platonic Folding", + entryAxis: "horizontal", + eyeDistanceScale: 1.55, + }), +}); + +const SOLIDS = Object.freeze({ + tetrahedron: solid({ + id: "tetrahedron", + faceCount: 4, + eyeDistance: 6, + maximumAngle: 109.4712206344907, + vertices: [ + [1, 0, -SQRT_2_4], + [-0.5, SQRT_3_2, -SQRT_2_4], + [-0.5, -SQRT_3_2, -SQRT_2_4], + ], + edges: [ + [0, 1, 0, 0], [0, 2, 2, 0], [0, 3, 1, 0], + [1, 2, 1, 2], [1, 3, 2, 1], [2, 3, 1, 2], + ], + }), + hexahedron: solid({ + id: "hexahedron", + faceCount: 6, + eyeDistance: 6, + maximumAngle: 90, + vertices: [ + [SQRT_2_2, -SQRT_2_2, -SQRT_2_2], + [SQRT_2_2, SQRT_2_2, -SQRT_2_2], + [-SQRT_2_2, SQRT_2_2, -SQRT_2_2], + [-SQRT_2_2, -SQRT_2_2, -SQRT_2_2], + ], + edges: [ + [0, 1, 0, 0], [0, 2, 1, 0], [0, 3, 2, 0], [0, 4, 3, 0], + [1, 2, 3, 1], [1, 4, 1, 3], [1, 5, 2, 0], [2, 3, 3, 1], + [2, 5, 2, 3], [3, 4, 3, 1], [3, 5, 2, 2], [4, 5, 2, 1], + ], + }), + octahedron: solid({ + id: "octahedron", + faceCount: 8, + eyeDistance: 6, + maximumAngle: 70.5287793655093, + vertices: [ + [1, 0, -SQRT_2_2], + [-0.5, SQRT_3_2, -SQRT_2_2], + [-0.5, -SQRT_3_2, -SQRT_2_2], + ], + edges: [ + [0, 1, 0, 0], [0, 2, 1, 0], [0, 3, 2, 0], [1, 4, 1, 2], + [1, 5, 2, 1], [2, 5, 1, 0], [2, 6, 2, 0], [3, 4, 2, 0], + [3, 6, 1, 2], [4, 7, 1, 1], [5, 7, 2, 0], [6, 7, 1, 2], + ], + }), + dodecahedron: solid({ + id: "dodecahedron", + faceCount: 12, + eyeDistance: 12, + maximumAngle: 63.434948822922, + vertices: [ + [1, 0, -DODECA_ICOSA_IN_RADIUS], + [COS_72, SIN_72, -DODECA_ICOSA_IN_RADIUS], + [-COS_36, SIN_36, -DODECA_ICOSA_IN_RADIUS], + [-COS_36, -SIN_36, -DODECA_ICOSA_IN_RADIUS], + [COS_72, -SIN_72, -DODECA_ICOSA_IN_RADIUS], + ], + edges: [ + [0, 1, 0, 0], [0, 2, 1, 4], [0, 3, 2, 0], [0, 4, 3, 3], [0, 5, 4, 1], + [1, 2, 4, 0], [1, 5, 1, 0], [1, 6, 2, 0], [1, 7, 3, 0], + [2, 3, 3, 1], [2, 7, 1, 4], [2, 8, 2, 2], [3, 4, 4, 4], + [3, 8, 2, 1], [3, 9, 3, 0], [4, 5, 2, 2], [4, 9, 0, 4], + [4, 10, 1, 4], [5, 6, 4, 1], [5, 10, 3, 3], [6, 7, 4, 1], + [6, 10, 2, 2], [6, 11, 3, 3], [7, 8, 3, 3], [7, 11, 2, 2], + [8, 9, 0, 1], [8, 11, 4, 1], [9, 10, 3, 0], [9, 11, 2, 0], + [10, 11, 1, 4], + ], + }), + icosahedron: solid({ + id: "icosahedron", + faceCount: 20, + eyeDistance: 12, + maximumAngle: 41.8103148957786, + vertices: [ + [1, 0, -DODECA_ICOSA_IN_RADIUS], + [-0.5, SQRT_3_2, -DODECA_ICOSA_IN_RADIUS], + [-0.5, -SQRT_3_2, -DODECA_ICOSA_IN_RADIUS], + ], + edges: [ + [0, 1, 2, 0], [0, 2, 0, 0], [0, 3, 1, 0], [1, 4, 2, 0], + [1, 9, 1, 0], [2, 5, 1, 2], [2, 6, 2, 1], [3, 7, 1, 0], + [3, 8, 2, 0], [4, 5, 2, 0], [4, 10, 1, 0], [5, 15, 1, 2], + [6, 7, 0, 1], [6, 14, 2, 1], [7, 13, 2, 0], [8, 9, 2, 1], + [8, 12, 1, 2], [9, 11, 2, 1], [10, 11, 1, 0], [10, 18, 2, 0], + [11, 17, 2, 1], [12, 13, 0, 2], [12, 17, 1, 2], [13, 16, 1, 0], + [14, 15, 2, 1], [14, 16, 0, 1], [15, 18, 0, 2], [16, 19, 2, 0], + [17, 19, 0, 2], [18, 19, 1, 1], + ], + }), +}); + +export function resolvePlatonicBank(bankId) { + const bank = PLATONIC_BANKS[bankId]; + if (!bank) throw new RangeError(`Unknown Platonic Folding bank: ${bankId}`); + return bank; +} + +export function platonicSolids() { + return SOURCE_ORDER.map((id) => SOLIDS[id]); +} + +export function buildPlatonicSourceSequence({ bankId = "desktop", seed = CSSPLATONIC_SEED } = {}) { + const bank = resolvePlatonicBank(bankId); + const rng = createYaRandom(seed); + const solids = SOURCE_ORDER.map((solidId, sequenceIndex) => { + const definition = SOLIDS[solidId]; + const colorMatrix = randomRotationMatrix(rng); + const tree = createUnfoldingTree(definition, rng); + const faceColors = buildFaceColors(definition, tree, colorMatrix); + return Object.freeze({ + definition, + tree, + faceColors, + alpha: sequenceIndex % 2 === 0 ? 300 : 120, + initialDelta: rng.frand(360), + deltaStep: sequenceIndex % 2 === 0 ? 0.5 : -0.5, + }); + }); + const faceDefinitions = []; + let faceColumn = 0; + for (const entry of solids) { + const box = polygonBounds(entry.definition.vertices); + for (let faceIndex = 0; faceIndex < entry.definition.faceCount; faceIndex += 1) { + faceDefinitions.push({ + id: `${entry.definition.id}-face-${String(faceIndex).padStart(2, "0")}`, + solidId: entry.definition.id, + faceIndex, + faceColumn, + vertices: entry.definition.vertices, + bounds: box, + color: entry.faceColors[faceIndex], + lightPalette: null, + }); + faceColumn += 1; + } + } + const frames = []; + for (const entry of solids) appendSolidFrames(frames, entry, bank); + prepareLightingStates(faceDefinitions, frames); + return deepFreeze({ + schema: "cssplatonicfolding-source-sequence@1", + bank, + seed, + faceDefinitions, + frames, + durationMilliseconds: frames.length * CSSPLATONIC_FRAME_MILLISECONDS, + }); +} + +function appendSolidFrames(frames, entry, bank) { + const { definition } = entry; + const eyeDistance = definition.eyeDistance * bank.eyeDistanceScale; + const travel = eyeDistance * 2 / 3; + const start = bank.entryAxis === "horizontal" ? [-travel, 0, 0] : [0, -travel, 0]; + const finish = bank.entryAxis === "horizontal" ? [travel, 0, 0] : [0, travel, 0]; + let solidFrame = 0; + for (let step = 0; step < PLATONIC_SOURCE.appearFrames; step += 1) { + const t = easeDecelerate(step / (PLATONIC_SOURCE.appearFrames - 1)); + pushFrame(frames, entry, solidFrame, mix3(start, [0, 0, 0], t), definition.maximumAngle, eyeDistance); + solidFrame += 1; + } + for (let step = 1; step <= PLATONIC_SOURCE.hingeFrames; step += 1) { + pushFrame(frames, entry, solidFrame, [0, 0, 0], definition.maximumAngle * + (1 - step / PLATONIC_SOURCE.hingeFrames), eyeDistance); + solidFrame += 1; + } + for (let step = 1; step <= PLATONIC_SOURCE.hingeFrames; step += 1) { + pushFrame(frames, entry, solidFrame, [0, 0, 0], definition.maximumAngle * + (step / PLATONIC_SOURCE.hingeFrames), eyeDistance); + solidFrame += 1; + } + for (let step = 0; step < PLATONIC_SOURCE.disappearFrames; step += 1) { + const t = easeAccelerate(step / (PLATONIC_SOURCE.disappearFrames - 1)); + pushFrame(frames, entry, solidFrame, mix3([0, 0, 0], finish, t), definition.maximumAngle, eyeDistance); + solidFrame += 1; + } +} + +function pushFrame(frames, entry, solidFrame, position, rawAngle, eyeDistance) { + const angles = Array(entry.definition.faceCount - 1).fill(rawAngle); + const poses = computeFacePoses(entry.definition, entry.tree, angles); + const rotation = multiply4(rotationX(entry.alpha), rotationZ( + entry.initialDelta + solidFrame * entry.deltaStep, + )); + const sourceModel = multiply4(translation([ + position[0], + position[1], + -eyeDistance, + ]), rotation); + const faceCenter = polygonCenter(entry.definition.vertices); + const modelMatrix = multiply4(scale4([1, -1, 1]), sourceModel); + frames.push({ + timeMs: frames.length * CSSPLATONIC_FRAME_MILLISECONDS, + solidId: entry.definition.id, + modelMatrix: flattenCss(modelMatrix), + faces: poses.map((pose, faceIndex) => ({ + faceIndex, + matrix: flattenCss(pose), + lightColor: lightColorForPose(entry.faceColors[faceIndex], faceCenter, pose, sourceModel), + lightRow: -1, + })), + }); +} + +function buildFaceColors(definition, tree, colorMatrix) { + const poses = computeFacePoses( + definition, + tree, + Array(definition.faceCount - 1).fill(definition.maximumAngle), + ); + return Object.freeze(poses.map((pose) => { + const center = [0, 0, 0]; + for (const vertex of definition.vertices) { + const transformed = transformPoint(pose, vertex); + center[0] += transformed[0]; + center[1] += transformed[1]; + center[2] += transformed[2]; + } + const rotated = normalize(transformDirection(colorMatrix, center)); + return Object.freeze(rotated.map((value) => (value + 1) / 2)); + })); +} + +function computeFacePoses(definition, tree, angles) { + const poses = Array(definition.faceCount); + const visit = (node, parentPose) => { + let pose = parentPose; + if (node.parent !== null) { + const easedAngle = easeQuintic(angles[node.foldAngleIndex], definition.maximumAngle); + const fold = foldRotation(node, definition.vertices, easedAngle); + pose = multiply4(parentPose, multiply4(fold, node.unfoldPose)); + } + poses[node.face] = pose; + for (const child of node.children) visit(child, pose); + }; + visit(tree, identity4()); + return poses; +} + +function foldRotation(node, vertices, angle) { + const first = transformPoint(node.unfoldPose, vertices[node.edgeSelf]); + const second = transformPoint(node.unfoldPose, vertices[(node.edgeSelf + 1) % vertices.length]); + const midpoint = mix3(first, second, 0.5); + const axis = normalize(sub3(second, first)); + const radial = normalize([midpoint[0], midpoint[1], 0]); + const basis = identity4(); + for (let row = 0; row < 3; row += 1) { + basis[row][0] = radial[row]; + basis[row][1] = row === 2 ? 1 : 0; + basis[row][2] = axis[row]; + } + return multiply4( + translation(midpoint), + multiply4(basis, multiply4(rotationZ(angle), multiply4(transpose4(basis), translation(midpoint.map((v) => -v))))), + ); +} + +function createUnfoldingTree(definition, rng) { + const edges = definition.edges.map(([src, dst, edgeSrc, edgeDst]) => ({ + src, + dst, + edgeSrc, + edgeDst, + weight: rng.frand(1), + })).sort((left, right) => left.weight - right.weight); + const parent = Array.from({ length: definition.faceCount }, (_, index) => index); + const rank = Array(definition.faceCount).fill(0); + const selected = []; + for (const edge of edges) { + const srcRoot = findRoot(parent, edge.src); + const dstRoot = findRoot(parent, edge.dst); + if (srcRoot === dstRoot) continue; + selected.push(edge); + unionRoots(parent, rank, srcRoot, dstRoot); + if (selected.length === definition.faceCount - 1) break; + } + const adjacency = Array.from({ length: definition.faceCount }, () => []); + for (const edge of selected) { + adjacency[edge.dst].unshift({ face: edge.src, edgeParent: edge.edgeDst, edgeSelf: edge.edgeSrc }); + adjacency[edge.src].unshift({ face: edge.dst, edgeParent: edge.edgeSrc, edgeSelf: edge.edgeDst }); + } + let foldAngleIndex = 0; + const build = (face, parentFace, edgeParent = -1, edgeSelf = -1) => { + const node = { + face, + parent: parentFace, + edgeParent, + edgeSelf, + foldAngleIndex: parentFace === null ? -1 : foldAngleIndex++, + unfoldPose: identity4(), + children: [], + }; + node.unfoldPose = parentFace === null + ? identity4() + : unfoldingPose(definition.vertices, edgeParent, edgeSelf); + node.children = adjacency[face] + .filter((entry) => entry.face !== parentFace) + .map((entry) => build(entry.face, face, entry.edgeParent, entry.edgeSelf)); + return node; + }; + return deepFreeze(build(0, null)); +} + +function unfoldingPose(vertices, edgeParent, edgeSelf) { + const parentFirst = vertices[edgeParent]; + const parentSecond = vertices[(edgeParent + 1) % vertices.length]; + const selfFirst = vertices[edgeSelf]; + const selfSecond = vertices[(edgeSelf + 1) % vertices.length]; + const parentDirection = normalize(sub3(parentSecond, parentFirst)); + const selfDirection = normalize(sub3(selfFirst, selfSecond)); + const crossZ = selfDirection[0] * parentDirection[1] - selfDirection[1] * parentDirection[0]; + const dot = selfDirection[0] * parentDirection[0] + selfDirection[1] * parentDirection[1]; + const angle = Math.atan2(crossZ, dot) * 180 / Math.PI; + const midpoint = mix3(parentFirst, parentSecond, 0.5); + return multiply4(translation([midpoint[0] * 2, midpoint[1] * 2, 0]), rotationZ(angle)); +} + +function lightColorForPose(baseColor, faceCenter, pose, sourceModel) { + const modelPose = multiply4(sourceModel, pose); + let normal = normalize(transformDirection(modelPose, [0, 0, 1])); + const center = transformPoint(modelPose, faceCenter); + if (dot3(normal, center.map((value) => -value)) < 0) { + normal = normal.map((value) => -value); + } + const diffuse = Math.max(0, dot3(normal, LIGHT_DIRECTION)); + const specular = diffuse === 0 + ? 0 + : 0.75 * Math.pow(Math.max(0, dot3(normal, LIGHT_HALF_VECTOR)), 30); + return baseColor.map((channel) => Math.min(1, channel * (0.5 + 0.7 * diffuse) + specular)); +} + +function polygonCenter(vertices) { + const center = [0, 0, 0]; + for (const vertex of vertices) { + for (let axis = 0; axis < 3; axis += 1) center[axis] += vertex[axis]; + } + return center.map((value) => value / vertices.length); +} + +function prepareLightingStates(faceDefinitions, frames) { + for (const face of faceDefinitions) { + const samples = frames + .filter((frame) => frame.solidId === face.solidId) + .map((frame) => frame.faces[face.faceIndex].lightColor); + face.lightPalette = clusterColors(samples, CSSPLATONIC_LIGHT_LEVELS); + for (const frame of frames) { + if (frame.solidId !== face.solidId) continue; + const sample = frame.faces[face.faceIndex]; + sample.lightRow = nearestColorIndex(sample.lightColor, face.lightPalette); + } + } +} + +function clusterColors(samples, count) { + const unique = []; + const keys = new Set(); + for (const sample of samples) { + const key = sample.map((value) => value.toFixed(10)).join(","); + if (keys.has(key)) continue; + keys.add(key); + unique.push(sample); + } + const centers = [unique[0].slice()]; + while (centers.length < Math.min(count, unique.length)) { + let selected = unique[0]; + let selectedDistance = -1; + for (const sample of unique) { + const distance = Math.min(...centers.map((center) => colorDistanceSquared(sample, center))); + if (distance > selectedDistance) { + selected = sample; + selectedDistance = distance; + } + } + centers.push(selected.slice()); + } + for (let iteration = 0; iteration < 24; iteration += 1) { + const sums = centers.map(() => [0, 0, 0]); + const weights = centers.map(() => 0); + for (const sample of samples) { + const index = nearestColorIndex(sample, centers); + weights[index] += 1; + for (let channel = 0; channel < 3; channel += 1) sums[index][channel] += sample[channel]; + } + let changed = false; + for (let index = 0; index < centers.length; index += 1) { + if (weights[index] === 0) continue; + const next = sums[index].map((value) => value / weights[index]); + if (colorDistanceSquared(next, centers[index]) > 1e-16) changed = true; + centers[index] = next; + } + if (!changed) break; + } + centers.sort((left, right) => { + const lightness = left[0] + left[1] + left[2] - right[0] - right[1] - right[2]; + if (lightness !== 0) return lightness; + for (let channel = 0; channel < 3; channel += 1) { + if (left[channel] !== right[channel]) return left[channel] - right[channel]; + } + return 0; + }); + while (centers.length < count) centers.push(centers.at(-1).slice()); + return centers; +} + +function nearestColorIndex(color, palette) { + let nearest = 0; + let nearestDistance = Infinity; + for (let index = 0; index < palette.length; index += 1) { + const distance = colorDistanceSquared(color, palette[index]); + if (distance < nearestDistance) { + nearest = index; + nearestDistance = distance; + } + } + return nearest; +} + +function colorDistanceSquared(left, right) { + let distance = 0; + for (let channel = 0; channel < 3; channel += 1) { + const delta = left[channel] - right[channel]; + distance += delta * delta; + } + return distance; +} + +function solid(value) { + return deepFreeze({ + ...value, + vertices: value.vertices.map((vertex) => Object.freeze(vertex)), + edges: value.edges.map((edge) => Object.freeze(edge)), + }); +} + +function polygonBounds(vertices) { + const xs = vertices.map((vertex) => vertex[0]); + const ys = vertices.map((vertex) => vertex[1]); + const minX = Math.min(...xs); + const maxX = Math.max(...xs); + const minY = Math.min(...ys); + const maxY = Math.max(...ys); + return Object.freeze({ minX, minY, width: maxX - minX, height: maxY - minY }); +} + +function createYaRandom(seed) { + const vector = RANDOM_VECTOR.map((value) => value >>> 0); + let value = seed >>> 0; + vector[0] = (vector[0] + value) >>> 0; + for (let index = 1; index < vector.length; index += 1) { + value = Math.imul(value, 999) >>> 0; + value = rotateLeft(value, 9); + value = (value + Math.imul(vector[index - 1], 1001)) >>> 0; + value = rotateLeft(value, 15); + vector[index] = (vector[index] + value) >>> 0; + } + let index1 = vector[0] % vector.length; + let index2 = (index1 + 24) % vector.length; + return Object.freeze({ + next() { + const result = (vector[index1] + vector[index2]) >>> 0; + vector[index1] = result; + index1 = (index1 + 1) % vector.length; + index2 = (index2 + 1) % vector.length; + return result; + }, + frand(maximum) { + return this.next() * maximum / 0xFFFFFFFF; + }, + }); +} + +function randomRotationMatrix(rng) { + const theta = rng.frand(2 * Math.PI); + const phi = rng.frand(2 * Math.PI); + const z = rng.frand(2); + const radius = Math.sqrt(z); + const vx = radius * Math.sin(phi); + const vy = radius * Math.cos(phi); + const vz = Math.sqrt(2 - z); + const sine = Math.sin(theta); + const cosine = Math.cos(theta); + const sx = vx * cosine - vy * sine; + const sy = vx * sine + vy * cosine; + const matrix = identity4(); + matrix[0][0] = vx * sx - cosine; + matrix[0][1] = vx * sy - sine; + matrix[0][2] = vx * vz; + matrix[1][0] = vy * sx + sine; + matrix[1][1] = vy * sy - cosine; + matrix[1][2] = vy * vz; + matrix[2][0] = vz * sx; + matrix[2][1] = vz * sy; + matrix[2][2] = 1 - z; + return matrix; +} + +function findRoot(parent, value) { + if (parent[value] !== value) parent[value] = findRoot(parent, parent[value]); + return parent[value]; +} + +function unionRoots(parent, rank, left, right) { + if (rank[left] < rank[right]) parent[left] = right; + else if (rank[left] > rank[right]) parent[right] = left; + else { + parent[right] = left; + rank[left] += 1; + } +} + +function identity4() { + return [ + [1, 0, 0, 0], + [0, 1, 0, 0], + [0, 0, 1, 0], + [0, 0, 0, 1], + ]; +} + +function multiply4(left, right) { + return left.map((row) => right[0].map((_, column) => + row.reduce((sum, value, index) => sum + value * right[index][column], 0))); +} + +function transpose4(matrix) { + return matrix[0].map((_, column) => matrix.map((row) => row[column])); +} + +function translation([x, y, z]) { + const matrix = identity4(); + matrix[0][3] = x; + matrix[1][3] = y; + matrix[2][3] = z; + return matrix; +} + +function scale4([x, y, z]) { + const matrix = identity4(); + matrix[0][0] = x; + matrix[1][1] = y; + matrix[2][2] = z; + return matrix; +} + +function rotationX(degrees) { + const radians = degrees * Math.PI / 180; + const cosine = Math.cos(radians); + const sine = Math.sin(radians); + return [ + [1, 0, 0, 0], + [0, cosine, -sine, 0], + [0, sine, cosine, 0], + [0, 0, 0, 1], + ]; +} + +function rotationZ(degrees) { + const radians = degrees * Math.PI / 180; + const cosine = Math.cos(radians); + const sine = Math.sin(radians); + return [ + [cosine, -sine, 0, 0], + [sine, cosine, 0, 0], + [0, 0, 1, 0], + [0, 0, 0, 1], + ]; +} + +function transformPoint(matrix, [x, y, z]) { + return [ + matrix[0][0] * x + matrix[0][1] * y + matrix[0][2] * z + matrix[0][3], + matrix[1][0] * x + matrix[1][1] * y + matrix[1][2] * z + matrix[1][3], + matrix[2][0] * x + matrix[2][1] * y + matrix[2][2] * z + matrix[2][3], + ]; +} + +function transformDirection(matrix, [x, y, z]) { + return [ + matrix[0][0] * x + matrix[0][1] * y + matrix[0][2] * z, + matrix[1][0] * x + matrix[1][1] * y + matrix[1][2] * z, + matrix[2][0] * x + matrix[2][1] * y + matrix[2][2] * z, + ]; +} + +function flattenCss(matrix) { + return Object.freeze(matrix[0].map((_, column) => matrix.map((row) => + rounded(row[column]))).flat()); +} + +function rounded(value) { + const result = Number(value.toFixed(10)); + return Object.is(result, -0) ? 0 : result; +} + +function easeQuintic(value, maximum) { + const t = value / maximum; + return maximum * ((6 * t - 15) * t + 10) * t * t * t; +} + +function easeAccelerate(value) { + return value * value * (2 - value); +} + +function easeDecelerate(value) { + return value * (1 + value * (1 - value)); +} + +function mix3(left, right, t) { + return left.map((value, index) => value + (right[index] - value) * t); +} + +function sub3(left, right) { + return left.map((value, index) => value - right[index]); +} + +function dot3(left, right) { + return left.reduce((sum, value, index) => sum + value * right[index], 0); +} + +function normalize(vector) { + const length = Math.hypot(...vector); + return length === 0 ? [...vector] : vector.map((value) => value / length); +} + +function rotateLeft(value, count) { + return ((value << count) | (value >>> (32 - count))) >>> 0; +} + +function deepFreeze(value) { + if (value && typeof value === "object" && !Object.isFrozen(value)) { + Object.freeze(value); + for (const child of Object.values(value)) deepFreeze(child); + } + return value; +} diff --git a/src/adapters/platonic-folding/test/cssplatonicfolding-bank-selection.test.mjs b/src/adapters/platonic-folding/test/cssplatonicfolding-bank-selection.test.mjs new file mode 100644 index 0000000..e2bb2d5 --- /dev/null +++ b/src/adapters/platonic-folding/test/cssplatonicfolding-bank-selection.test.mjs @@ -0,0 +1,8 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { selectPlatonicBank } from "../src/cssplatonicfolding/bankSelection.mjs"; + +test("portrait viewports use the horizontal-travel mobile bank", () => { + assert.equal(selectPlatonicBank({ width: 390, height: 844 }), "mobile"); + assert.equal(selectPlatonicBank({ width: 1024, height: 768 }), "desktop"); +}); diff --git a/src/adapters/platonic-folding/test/cssplatonicfolding-shell.test.mjs b/src/adapters/platonic-folding/test/cssplatonicfolding-shell.test.mjs new file mode 100644 index 0000000..cef5d93 --- /dev/null +++ b/src/adapters/platonic-folding/test/cssplatonicfolding-shell.test.mjs @@ -0,0 +1,20 @@ +import assert from "node:assert/strict"; +import { readFile } from "node:fs/promises"; +import test from "node:test"; + +const adapterRoot = new URL("../", import.meta.url); + +test("product shell stays retained-DOM and fast-path clean", async () => { + const [html, client, styles] = await Promise.all([ + readFile(new URL("index.html", adapterRoot), "utf8"), + readFile(new URL("src/cssplatonicfolding/client.mjs", adapterRoot), "utf8"), + readFile(new URL("src/cssplatonicfolding/styles.css", adapterRoot), "utf8"), + ]); + assert.doesNotMatch(html, /<(?:main|canvas)\b/u); + assert.doesNotMatch(client, /(?:WebGL|WebGPU|CanvasRenderingContext|createElement\(["']canvas)/u); + assert.doesNotMatch(styles, /(?:clip-path|mask-image|filter:|box-shadow|text-shadow|gradient\()/u); + assert.match(client, /mountPolyMorphModel/u); + assert.match(client, /createPolyMorphPlaybackRuntime/u); + assert.match(client, /setTimeout\(wake/u); + assert.doesNotMatch(client, /requestAnimationFrame/u); +}); diff --git a/src/adapters/platonic-folding/test/cssplatonicfolding-source-model.test.mjs b/src/adapters/platonic-folding/test/cssplatonicfolding-source-model.test.mjs new file mode 100644 index 0000000..cfee52d --- /dev/null +++ b/src/adapters/platonic-folding/test/cssplatonicfolding-source-model.test.mjs @@ -0,0 +1,68 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { validatePolyMorphModel } from "@layoutit/polycss-morph"; +import { buildPlatonicPreparedModel } from "../src/prepare/cssplatonicfolding/modelBuilder.mjs"; +import { + CSSPLATONIC_FRAME_MILLISECONDS, + buildPlatonicSourceSequence, + platonicSolids, +} from "../src/prepare/cssplatonicfolding/sourceModel.mjs"; + +test("source census and fold angles match the pinned five solids", () => { + assert.deepEqual(platonicSolids().map((solid) => [solid.id, solid.faceCount, solid.maximumAngle]), [ + ["icosahedron", 20, 41.8103148957786], + ["dodecahedron", 12, 63.434948822922], + ["hexahedron", 6, 90], + ["octahedron", 8, 70.5287793655093], + ["tetrahedron", 4, 109.4712206344907], + ]); +}); + +test("prepared source sequence is deterministic and follows source cadence", () => { + const first = buildPlatonicSourceSequence(); + const second = buildPlatonicSourceSequence(); + assert.deepEqual(first, second); + assert.equal(first.faceDefinitions.length, 50); + assert.equal(first.frames.length, 2_710); + assert.equal(first.durationMilliseconds, first.frames.length * CSSPLATONIC_FRAME_MILLISECONDS); + assert.equal(first.frames[0].solidId, "icosahedron"); + assert.equal(first.frames.at(-1).solidId, "tetrahedron"); + assert.notDeepEqual(first.frames[180].faces[1].matrix, first.frames[270].faces[1].matrix); + assert.ok(first.faceDefinitions.every((face) => face.lightPalette.length === 64)); + assert.ok(first.frames.every((frame) => frame.faces.every((face) => + Number.isInteger(face.lightRow) && face.lightRow >= 0 && face.lightRow < 64))); +}); + +test("prepared lighting preserves source specular and perspective-facing back sides", () => { + const source = buildPlatonicSourceSequence(); + const face = source.faceDefinitions.find((candidate) => + candidate.solidId === "icosahedron" && candidate.faceIndex === 12); + const highlighted = source.frames[225].faces[12].lightColor; + const backFacing = source.frames[235].faces[12].lightColor; + assert.equal(highlighted[0], 1); + assert.equal(highlighted[2], 1); + for (let channel = 0; channel < 3; channel += 1) { + assert.ok(Math.abs(backFacing[channel] - face.color[channel] * 0.5) < 1e-12); + } +}); + +test("Morph package retains one raster leaf per source face", () => { + const prepared = buildPlatonicPreparedModel(); + const model = validatePolyMorphModel(prepared.model); + assert.equal(model.render.shapes.length, 50); + assert.equal(model.render.leaves.length, 50); + assert.ok(model.render.leaves.every((leaf) => leaf.strategy === "atlas-slice")); + assert.ok(model.render.leaves.every((leaf) => leaf.atlas?.resourcePath === "assets/face-colors.png")); + assert.equal(model.playback.frames.length, 2_710); + assert.equal(model.playback.frames[0].leaves.length, 50); + assert.equal(prepared.metrics.maximumVisibleLeaves, 20); + assert.equal(prepared.metrics.runtimeGeometryConstructionCount, 0); + assert.equal(prepared.metrics.runtimeDomGrowth, false); +}); + +test("mobile changes travel direction without changing topology", () => { + const desktop = buildPlatonicPreparedModel({ bankId: "desktop" }); + const mobile = buildPlatonicPreparedModel({ bankId: "mobile" }); + assert.equal(desktop.model.render.leaves.length, mobile.model.render.leaves.length); + assert.notDeepEqual(desktop.model.playback.frames[0].modelMatrix, mobile.model.playback.frames[0].modelMatrix); +}); diff --git a/src/adapters/platonic-folding/tools/check-prepare-determinism.mjs b/src/adapters/platonic-folding/tools/check-prepare-determinism.mjs new file mode 100644 index 0000000..6ba7cfd --- /dev/null +++ b/src/adapters/platonic-folding/tools/check-prepare-determinism.mjs @@ -0,0 +1,22 @@ +#!/usr/bin/env node +import assert from "node:assert/strict"; +import { createHash } from "node:crypto"; +import { buildPlatonicPreparedModel } from "../src/prepare/cssplatonicfolding/modelBuilder.mjs"; +import { buildPlatonicRasterAtlas } from "../src/prepare/cssplatonicfolding/rasterAtlas.mjs"; + +for (const bankId of ["desktop", "mobile"]) { + const first = buildPlatonicPreparedModel({ bankId }); + const second = buildPlatonicPreparedModel({ bankId }); + assert.equal(hash(JSON.stringify(first.model)), hash(JSON.stringify(second.model))); + assert.deepEqual(first.metrics, second.metrics); +} +const source = buildPlatonicPreparedModel().source; +assert.equal( + hash(buildPlatonicRasterAtlas(source.faceDefinitions)), + hash(buildPlatonicRasterAtlas(source.faceDefinitions)), +); +console.log(JSON.stringify({ status: "deterministic", banks: ["desktop", "mobile"] })); + +function hash(value) { + return createHash("sha256").update(value).digest("hex"); +} diff --git a/src/adapters/platonic-folding/tools/native/headless/capture-platonicfolding.c b/src/adapters/platonic-folding/tools/native/headless/capture-platonicfolding.c new file mode 100644 index 0000000..2e50eac --- /dev/null +++ b/src/adapters/platonic-folding/tools/native/headless/capture-platonicfolding.c @@ -0,0 +1,427 @@ +#include +#include +#include +#include + +char *progname = "cssplatonicfolding-headless-native-oracle"; + +#define USE_GL 1 +#include "platonicfolding.c" + +static CGLContextObj capture_context; +static CGLPixelFormatObj capture_pixel_format; +static GLuint capture_framebuffer; +static GLuint capture_colorbuffer; +static GLuint capture_depthbuffer; +static GLXContext capture_glx_context; + +static const int source_order[] = { + ICOSAHEDRON, + DODECAHEDRON, + HEXAHEDRON, + OCTAHEDRON, + TETRAHEDRON, +}; + +static void create_offscreen_context(int width, int height); +static void destroy_offscreen_context(void); +static void initialize_profile_shader(platonicfoldingstruct *pf); +static void destroy_profile_shader(platonicfoldingstruct *pf); +static void initialize_profile_solid(ModeInfo *mi, int sequence_index, + float *initial_delta); +static void set_profile_frame(platonicfoldingstruct *pf, int sequence_index, + int local_frame, float initial_delta); +static int write_ppm(const char *path, int width, int height); +static long parse_nonnegative(const char *text, const char *label); +static long parse_positive(const char *text, const char *label); +static void assert_no_gl_errors(const char *stage); + +int +main(int argc, char **argv) +{ + ModeInfo mi; + platonicfoldingstruct *pf; + unsigned int seed; + int width; + int height; + int capture_count; + int current_sequence = -1; + float initial_delta = 0; + char path[4096]; + + if (argc < 6) { + fprintf(stderr, "usage: %s frames-directory seed width height frame [frame ...]\n", argv[0]); + return 2; + } + seed = (unsigned int)parse_positive(argv[2], "seed"); + width = (int)parse_positive(argv[3], "width"); + height = (int)parse_positive(argv[4], "height"); + capture_count = argc - 5; + if (mkdir(argv[1], 0777) != 0 && errno != EEXIST) { + perror("mkdir frames"); + return 3; + } + + memset(&mi, 0, sizeof(mi)); + mi.width = width; + mi.height = height; + create_offscreen_context(width, height); + glBindFramebufferEXT(GL_FRAMEBUFFER_EXT, capture_framebuffer); + glDrawBuffer(GL_COLOR_ATTACHMENT0_EXT); + glReadBuffer(GL_COLOR_ATTACHMENT0_EXT); + + platonicfolding = calloc(1, sizeof(*platonicfolding)); + if (!platonicfolding) abort(); + pf = &platonicfolding[0]; + pf->WindW = width; + pf->WindH = height; + pf->aspect = (float)width / (float)height; + pf->trackball = gltrackball_init(False); + initialize_profile_shader(pf); + assert_no_gl_errors("shader initialization"); + +#undef ya_rand_init + ya_rand_init(seed); + for (int capture_index = 0; capture_index < capture_count; capture_index++) { + int global_frame = (int)parse_nonnegative(argv[capture_index + 5], "frame"); + int sequence_index = global_frame / 542; + int local_frame = global_frame % 542; + if (sequence_index < current_sequence || sequence_index >= (int)countof(source_order)) { + fprintf(stderr, "frames must be ordered and within the prepared sequence\n"); + return 2; + } + while (current_sequence < sequence_index) { + current_sequence++; + initialize_profile_solid(&mi, current_sequence, &initial_delta); + } + set_profile_frame(pf, sequence_index, local_frame, initial_delta); + mi.polygon_count = polygon_folding_pf(&mi); + assert_no_gl_errors("source draw"); + snprintf(path, sizeof(path), "%s/frame_%04d.ppm", argv[1], capture_index); + if (!write_ppm(path, width, height)) return 4; + } + + gltrackball_free(pf->trackball); + destroy_profile_shader(pf); + free_base_polygons(pf->base_polygons); + free_tree(pf->polygon_unfolding); + free(platonicfolding); + platonicfolding = NULL; + destroy_offscreen_context(); + return 0; +} + +static void +assert_no_gl_errors(const char *stage) +{ + GLenum error = glGetError(); + if (error != GL_NO_ERROR) { + fprintf(stderr, "%s: OpenGL error 0x%x\n", stage, error); + exit(4); + } +} + +static void +initialize_profile_shader(platonicfoldingstruct *pf) +{ + const GLubyte pixel[] = { 0, 0, 0, 0 }; + const GLchar *vertex_sources[] = { glsl_GetGLSLVersionString(), vertex_shader }; + const GLchar *fragment_sources[] = { glsl_GetGLSLVersionString(), fragment_shader }; + if (!glsl_CompileAndLinkShaders(2, vertex_sources, 2, fragment_sources, + &pf->shader_program)) abort(); +#define ATTRIBUTE(name, source_name) do { \ + pf->name = glGetAttribLocation(pf->shader_program, source_name); \ + if (pf->name < 0) abort(); \ +} while (0) +#define UNIFORM(name, source_name) do { \ + pf->name = glGetUniformLocation(pf->shader_program, source_name); \ + if (pf->name < 0) abort(); \ +} while (0) + ATTRIBUTE(pos_index, "VertexPosition"); + ATTRIBUTE(normal_index, "VertexNormal"); + ATTRIBUTE(color_index, "VertexColor"); + ATTRIBUTE(tex_index, "VertexTexCoord"); + UNIFORM(mv_index, "MatModelView"); + UNIFORM(proj_index, "MatProj"); + UNIFORM(magma_offs_index, "TexOffsetMagma"); + UNIFORM(north_up_index, "NorthUp"); + UNIFORM(glbl_ambient_index, "LtGlblAmbient"); + UNIFORM(lt_ambient_index, "LtAmbient"); + UNIFORM(lt_diffuse_index, "LtDiffuse"); + UNIFORM(lt_specular_index, "LtSpecular"); + UNIFORM(lt_direction_index, "LtDirection"); + UNIFORM(lt_halfvect_index, "LtHalfVector"); + UNIFORM(ambient_index, "MatAmbient"); + UNIFORM(diffuse_index, "MatDiffuse"); + UNIFORM(specular_index, "MatSpecular"); + UNIFORM(shininess_index, "MatShininess"); + UNIFORM(bool_textures_index, "BoolTextures"); + UNIFORM(samp_mgm_index, "TextureSamplerMagma"); + UNIFORM(samp_day_index, "TextureSamplerDay"); + UNIFORM(samp_ngt_index, "TextureSamplerNight"); + UNIFORM(samp_wtr_index, "TextureSamplerWater"); +#undef ATTRIBUTE +#undef UNIFORM + glGenBuffers(1, &pf->vertex_buffer); + glGenBuffers(1, &pf->normal_buffer); + glGenBuffers(1, &pf->color_buffer); + glGenBuffers(1, &pf->tex_buffer); + glUseProgram(pf->shader_program); + glUniform1i(pf->samp_mgm_index, 0); + glUniform1i(pf->samp_day_index, 1); + glUniform1i(pf->samp_ngt_index, 2); + glUniform1i(pf->samp_wtr_index, 3); + glUseProgram(0); + glGenTextures(1, &pf->magma_tex); + glActiveTexture(GL_TEXTURE0); + glBindTexture(GL_TEXTURE_3D, pf->magma_tex); + glTexImage3D(GL_TEXTURE_3D, 0, GL_RGBA, 1, 1, 1, 0, GL_RGBA, + GL_UNSIGNED_BYTE, pixel); + glGenTextures(3, pf->earth_tex); + for (int index = 0; index < 3; index++) { + glActiveTexture(GL_TEXTURE1 + index); + glBindTexture(GL_TEXTURE_2D, pf->earth_tex[index]); + glTexImage2D(GL_TEXTURE_2D, 0, GL_RGBA, 1, 1, 0, GL_RGBA, + GL_UNSIGNED_BYTE, pixel); + } + glActiveTexture(GL_TEXTURE0); + pf->north_up = true; + pf->use_shaders = true; + pf->use_textures = false; + pf->textures_supported = false; + pf->use_vao = false; +} + +static void +destroy_profile_shader(platonicfoldingstruct *pf) +{ + glDeleteBuffers(1, &pf->vertex_buffer); + glDeleteBuffers(1, &pf->normal_buffer); + glDeleteBuffers(1, &pf->color_buffer); + glDeleteBuffers(1, &pf->tex_buffer); + glDeleteTextures(1, &pf->magma_tex); + glDeleteTextures(3, pf->earth_tex); + glDeleteProgram(pf->shader_program); +} + +static void +initialize_profile_solid(ModeInfo *mi, int sequence_index, + float *initial_delta) +{ + platonicfoldingstruct *pf = &platonicfolding[MI_SCREEN(mi)]; + int poly = source_order[sequence_index]; + init_color_matrix(pf->color_matrix); + if (pf->base_polygons) free_base_polygons(pf->base_polygons); + if (pf->polygon_unfolding) free_tree(pf->polygon_unfolding); + pf->base_polygons = NULL; + pf->polygon_unfolding = NULL; + switch (poly) { + case TETRAHEDRON: + pf->polygon_unfolding = create_random_polyhedron_unfolding( + TETRAHEDRON_NUM_FACES, TETRAHEDRON_NUM_EDGES, tetrahedron_edges); + pf->max_angle = TETRAHEDRON_MAX_ANGLE; + pf->num_fold_angles = TETRAHEDRON_NUM_FACES - 1; + pf->eye_pos = tetrahedron_eye_pos; + pf->base_polygons = init_base_polygons(pf->polygon_unfolding, &tetrahedron_triangle); + break; + case HEXAHEDRON: + pf->polygon_unfolding = create_random_polyhedron_unfolding( + HEXAHEDRON_NUM_FACES, HEXAHEDRON_NUM_EDGES, hexahedron_edges); + pf->max_angle = HEXAHEDRON_MAX_ANGLE; + pf->num_fold_angles = HEXAHEDRON_NUM_FACES - 1; + pf->eye_pos = hexahedron_eye_pos; + pf->base_polygons = init_base_polygons(pf->polygon_unfolding, &hexahedron_square); + break; + case OCTAHEDRON: + pf->polygon_unfolding = create_random_polyhedron_unfolding( + OCTAHEDRON_NUM_FACES, OCTAHEDRON_NUM_EDGES, octahedron_edges); + pf->max_angle = OCTAHEDRON_MAX_ANGLE; + pf->num_fold_angles = OCTAHEDRON_NUM_FACES - 1; + pf->eye_pos = octahedron_eye_pos; + pf->base_polygons = init_base_polygons(pf->polygon_unfolding, &octahedron_triangle); + break; + case DODECAHEDRON: + pf->polygon_unfolding = create_random_polyhedron_unfolding( + DODECAHEDRON_NUM_FACES, DODECAHEDRON_NUM_EDGES, dodecahedron_edges); + pf->max_angle = DODECAHEDRON_MAX_ANGLE; + pf->num_fold_angles = DODECAHEDRON_NUM_FACES - 1; + pf->eye_pos = dodecahedron_eye_pos; + pf->base_polygons = init_base_polygons(pf->polygon_unfolding, &dodecahedron_pentagon); + break; + case ICOSAHEDRON: + pf->polygon_unfolding = create_random_polyhedron_unfolding( + ICOSAHEDRON_NUM_FACES, ICOSAHEDRON_NUM_EDGES, icosahedron_edges); + pf->max_angle = ICOSAHEDRON_MAX_ANGLE; + pf->num_fold_angles = ICOSAHEDRON_NUM_FACES - 1; + pf->eye_pos = icosahedron_eye_pos; + pf->base_polygons = init_base_polygons(pf->polygon_unfolding, &icosahedron_triangle); + break; + default: + abort(); + } + determine_unfolding_poses(pf->polygon_unfolding, pf->base_polygons); + determine_polygon_color_data(pf->polygon_unfolding, pf->base_polygons, + pf->max_angle, pf->color_matrix); + *initial_delta = (float)frand(360.0); +} + +static void +set_profile_frame(platonicfoldingstruct *pf, int sequence_index, + int local_frame, float initial_delta) +{ + float raw_angle = pf->max_angle; + float travel = pf->eye_pos[2] * (2.0f / 3.0f); + float t = 0; + pf->poly_pos[0] = 0; + pf->poly_pos[1] = 0; + pf->poly_pos[2] = 0; + if (local_frame <= 180) { + t = ease((float)local_frame / 180.0f, 1.0f, EASING_DECEL); + pf->poly_pos[1] = -travel + travel * t; + } else if (local_frame <= 270) { + raw_angle = pf->max_angle * (1.0f - (float)(local_frame - 180) / 90.0f); + } else if (local_frame <= 360) { + raw_angle = pf->max_angle * ((float)(local_frame - 270) / 90.0f); + } else { + t = ease((float)(local_frame - 361) / 180.0f, 1.0f, EASING_ACCEL); + pf->poly_pos[1] = travel * t; + } + for (int index = 0; index < pf->num_fold_angles; index++) { + pf->angle[index] = raw_angle; + } + pf->alpha = sequence_index % 2 == 0 ? 300.0f : 120.0f; + pf->beta = 0; + pf->delta = initial_delta + local_frame * (sequence_index % 2 == 0 ? 0.5f : -0.5f); +} + +static int +write_ppm(const char *path, int width, int height) +{ + unsigned char *rgba = malloc((size_t)width * (size_t)height * 4); + unsigned char *row = malloc((size_t)width * 3); + FILE *stream; + if (!rgba || !row) abort(); + glFinish(); + glPixelStorei(GL_PACK_ALIGNMENT, 1); + glReadPixels(0, 0, width, height, GL_RGBA, GL_UNSIGNED_BYTE, rgba); + if (glGetError() != GL_NO_ERROR) return 0; + stream = fopen(path, "wb"); + if (!stream) return 0; + fprintf(stream, "P6\n%d %d\n255\n", width, height); + for (int y = height - 1; y >= 0; y--) { + const unsigned char *source = rgba + (size_t)y * (size_t)width * 4; + for (int x = 0; x < width; x++) { + row[x * 3] = source[x * 4]; + row[x * 3 + 1] = source[x * 4 + 1]; + row[x * 3 + 2] = source[x * 4 + 2]; + } + if (fwrite(row, 1, (size_t)width * 3, stream) != (size_t)width * 3) return 0; + } + free(row); + free(rgba); + return fclose(stream) == 0; +} + +static void +create_offscreen_context(int width, int height) +{ + CGLPixelFormatAttribute attributes[] = { + kCGLPFAOpenGLProfile, (CGLPixelFormatAttribute)kCGLOGLPVersion_Legacy, + kCGLPFAAccelerated, + kCGLPFAAllowOfflineRenderers, + kCGLPFAColorSize, (CGLPixelFormatAttribute)24, + kCGLPFAAlphaSize, (CGLPixelFormatAttribute)8, + kCGLPFADepthSize, (CGLPixelFormatAttribute)24, + (CGLPixelFormatAttribute)0, + }; + GLint count = 0; + if (CGLChoosePixelFormat(attributes, &capture_pixel_format, &count) != kCGLNoError) abort(); + if (CGLCreateContext(capture_pixel_format, NULL, &capture_context) != kCGLNoError) abort(); + if (CGLSetCurrentContext(capture_context) != kCGLNoError) abort(); + capture_glx_context = capture_context; + glGenFramebuffersEXT(1, &capture_framebuffer); + glBindFramebufferEXT(GL_FRAMEBUFFER_EXT, capture_framebuffer); + glGenRenderbuffersEXT(1, &capture_colorbuffer); + glBindRenderbufferEXT(GL_RENDERBUFFER_EXT, capture_colorbuffer); + glRenderbufferStorageEXT(GL_RENDERBUFFER_EXT, GL_RGBA8, width, height); + glFramebufferRenderbufferEXT(GL_FRAMEBUFFER_EXT, GL_COLOR_ATTACHMENT0_EXT, + GL_RENDERBUFFER_EXT, capture_colorbuffer); + glGenRenderbuffersEXT(1, &capture_depthbuffer); + glBindRenderbufferEXT(GL_RENDERBUFFER_EXT, capture_depthbuffer); + glRenderbufferStorageEXT(GL_RENDERBUFFER_EXT, GL_DEPTH_COMPONENT24, width, height); + glFramebufferRenderbufferEXT(GL_FRAMEBUFFER_EXT, GL_DEPTH_ATTACHMENT_EXT, + GL_RENDERBUFFER_EXT, capture_depthbuffer); + if (glCheckFramebufferStatusEXT(GL_FRAMEBUFFER_EXT) != GL_FRAMEBUFFER_COMPLETE_EXT) abort(); +} + +static void +destroy_offscreen_context(void) +{ + if (capture_depthbuffer) glDeleteRenderbuffersEXT(1, &capture_depthbuffer); + if (capture_colorbuffer) glDeleteRenderbuffersEXT(1, &capture_colorbuffer); + if (capture_framebuffer) glDeleteFramebuffersEXT(1, &capture_framebuffer); + CGLSetCurrentContext(NULL); + if (capture_context) CGLDestroyContext(capture_context); + if (capture_pixel_format) CGLDestroyPixelFormat(capture_pixel_format); +} + +static long +parse_nonnegative(const char *text, const char *label) +{ + char *end = NULL; + long value; + errno = 0; + value = strtol(text, &end, 10); + if (errno || !end || *end || value < 0 || value > INT_MAX) { + fprintf(stderr, "%s must be a non-negative integer\n", label); + exit(2); + } + return value; +} + +static long +parse_positive(const char *text, const char *label) +{ + long value = parse_nonnegative(text, label); + if (value == 0) { + fprintf(stderr, "%s must be positive\n", label); + exit(2); + } + return value; +} + +GLXContext *init_GL(ModeInfo *mi) { (void)mi; return &capture_glx_context; } +Bool glXMakeCurrent(Display *display, Window window, GLXContext context) { + (void)display; + (void)window; + (void)context; + return True; +} +void glXSwapBuffers(Display *display, Window window) { (void)display; (void)window; } +void do_fps(ModeInfo *mi) { (void)mi; } +Bool screenhack_event_helper(Display *display, Window window, XEvent *event) { + (void)display; + (void)window; + (void)event; + return False; +} +double current_device_rotation(void) { return 0; } + +const unsigned char earth_png[] = { 0 }; +int earth_png_size = 1; +const unsigned char earth_night_png[] = { 0 }; +int earth_night_png_size = 1; +const unsigned char earth_water_png[] = { 0 }; +int earth_water_png_size = 1; + +XImage *image_data_to_ximage(Display *display, Visual *visual, + const unsigned char *image_data, + unsigned long data_size) { + (void)display; + (void)visual; + (void)image_data; + (void)data_size; + abort(); +} + +int XDestroyImage(XImage *image) { (void)image; return 0; } diff --git a/src/adapters/platonic-folding/tools/native/headless/include/earth.h b/src/adapters/platonic-folding/tools/native/headless/include/earth.h new file mode 100644 index 0000000..cfe5ae1 --- /dev/null +++ b/src/adapters/platonic-folding/tools/native/headless/include/earth.h @@ -0,0 +1,11 @@ +#ifndef CSSPLATONICFOLDING_HEADLESS_EARTH_H +#define CSSPLATONICFOLDING_HEADLESS_EARTH_H + +extern const unsigned char earth_png[]; +extern int earth_png_size; +extern const unsigned char earth_night_png[]; +extern int earth_night_png_size; +extern const unsigned char earth_water_png[]; +extern int earth_water_png_size; + +#endif diff --git a/src/adapters/platonic-folding/tools/native/headless/include/glsl-utils.h b/src/adapters/platonic-folding/tools/native/headless/include/glsl-utils.h new file mode 100644 index 0000000..5d8071f --- /dev/null +++ b/src/adapters/platonic-folding/tools/native/headless/include/glsl-utils.h @@ -0,0 +1,30 @@ +#ifndef CSSPLATONICFOLDING_HEADLESS_GLSL_UTILS_H +#define CSSPLATONICFOLDING_HEADLESS_GLSL_UTILS_H + +void glsl_CopyMatrix(GLfloat c[16], GLfloat m[16]); +void glsl_Identity(GLfloat c[16]); +void glsl_MultMatrix(GLfloat c[16], GLfloat m[16]); +void glsl_MultMatrixVector(GLfloat o[4], GLfloat m[16], GLfloat v[4]); +void glsl_Rotate(GLfloat c[16], GLfloat angle, GLfloat x, GLfloat y, GLfloat z); +void glsl_Translate(GLfloat c[16], GLfloat tx, GLfloat ty, GLfloat tz); +void glsl_Scale(GLfloat c[16], GLfloat sx, GLfloat sy, GLfloat sz); +void glsl_LookAt(GLfloat c[16], GLfloat eyex, GLfloat eyey, GLfloat eyez, + GLfloat centerx, GLfloat centery, GLfloat centerz, + GLfloat upx, GLfloat upy, GLfloat upz); +void glsl_Perspective(GLfloat c[16], GLfloat fovy, GLfloat aspect, + GLfloat z_near, GLfloat z_far); +void glsl_Orthographic(GLfloat c[16], GLfloat left, GLfloat right, + GLfloat bottom, GLfloat top, GLfloat nearval, + GLfloat farval); +GLboolean glsl_GetGlAndGlslVersions(GLint *gl_major, GLint *gl_minor, + GLint *glsl_major, GLint *glsl_minor, + GLboolean *gl_gles3); +const GLchar *glsl_GetGLSLVersionString(void); +GLboolean glsl_IsCoreProfile(void); +GLboolean glsl_CompileAndLinkShaders(GLsizei vertex_shader_count, + const GLchar **vertex_shader_source, + GLsizei fragment_shader_count, + const GLchar **fragment_shader_source, + GLuint *shader_program); + +#endif diff --git a/src/adapters/platonic-folding/tools/native/headless/include/screenhackI.h b/src/adapters/platonic-folding/tools/native/headless/include/screenhackI.h new file mode 100644 index 0000000..83dc949 --- /dev/null +++ b/src/adapters/platonic-folding/tools/native/headless/include/screenhackI.h @@ -0,0 +1,45 @@ +#ifndef CSSPLATONICFOLDING_HEADLESS_SCREENHACK_I_H +#define CSSPLATONICFOLDING_HEADLESS_SCREENHACK_I_H + +#include +#include +#include +#include +#include +#include +#include + +#define GL_GLEXT_PROTOTYPES 1 +#include +#include +#include + +#ifndef GL_CONTEXT_PROFILE_MASK +#define GL_CONTEXT_PROFILE_MASK 0x9126 +#endif +#ifndef GL_CONTEXT_CORE_PROFILE_BIT +#define GL_CONTEXT_CORE_PROFILE_BIT 0x00000001 +#endif +#ifndef glBindVertexArray +#define glBindVertexArray glBindVertexArrayAPPLE +#define glGenVertexArrays glGenVertexArraysAPPLE +#define glDeleteVertexArrays glDeleteVertexArraysAPPLE +#endif + +#ifndef M_PI +#define M_PI 3.14159265358979323846 +#endif + +#define countof(value) (sizeof(value) / sizeof(*(value))) +#define MIN(left, right) ((left) < (right) ? (left) : (right)) + +typedef int Bool; + +#ifndef False +#define False 0 +#endif +#ifndef True +#define True 1 +#endif + +#endif diff --git a/src/adapters/platonic-folding/tools/native/headless/include/utils.h b/src/adapters/platonic-folding/tools/native/headless/include/utils.h new file mode 100644 index 0000000..3aa74f1 --- /dev/null +++ b/src/adapters/platonic-folding/tools/native/headless/include/utils.h @@ -0,0 +1,6 @@ +#ifndef CSSPLATONICFOLDING_HEADLESS_UTILS_H +#define CSSPLATONICFOLDING_HEADLESS_UTILS_H + +#include "xlockmore.h" + +#endif diff --git a/src/adapters/platonic-folding/tools/native/headless/include/visual.h b/src/adapters/platonic-folding/tools/native/headless/include/visual.h new file mode 100644 index 0000000..fd37b10 --- /dev/null +++ b/src/adapters/platonic-folding/tools/native/headless/include/visual.h @@ -0,0 +1,12 @@ +#ifndef CSSPLATONICFOLDING_HEADLESS_VISUAL_H +#define CSSPLATONICFOLDING_HEADLESS_VISUAL_H + +#include "xlockmore.h" + +static inline Bool has_writable_cells(Screen *screen, Visual *visual) { + (void)screen; + (void)visual; + return False; +} + +#endif diff --git a/src/adapters/platonic-folding/tools/native/headless/include/ximage-loader.h b/src/adapters/platonic-folding/tools/native/headless/include/ximage-loader.h new file mode 100644 index 0000000..e50e7e2 --- /dev/null +++ b/src/adapters/platonic-folding/tools/native/headless/include/ximage-loader.h @@ -0,0 +1,15 @@ +#ifndef CSSPLATONICFOLDING_HEADLESS_XIMAGE_LOADER_H +#define CSSPLATONICFOLDING_HEADLESS_XIMAGE_LOADER_H + +typedef struct { + int width; + int height; + char *data; +} XImage; + +XImage *image_data_to_ximage(Display *display, Visual *visual, + const unsigned char *image_data, + unsigned long data_size); +int XDestroyImage(XImage *image); + +#endif diff --git a/src/adapters/platonic-folding/tools/native/headless/include/xlockmore.h b/src/adapters/platonic-folding/tools/native/headless/include/xlockmore.h new file mode 100644 index 0000000..5617b2c --- /dev/null +++ b/src/adapters/platonic-folding/tools/native/headless/include/xlockmore.h @@ -0,0 +1,116 @@ +#ifndef CSSPLATONICFOLDING_HEADLESS_XLOCKMORE_H +#define CSSPLATONICFOLDING_HEADLESS_XLOCKMORE_H + +#include "screenhackI.h" +#include "yarandom.h" + +typedef struct Display Display; +typedef struct Screen Screen; +typedef struct Visual Visual; +typedef unsigned long Window; +typedef unsigned long Colormap; +typedef unsigned long KeySym; +typedef void *GLXContext; + +typedef struct { + unsigned long pixel; + unsigned short red; + unsigned short green; + unsigned short blue; + unsigned char flags; + unsigned char pad; +} XColor; + +typedef union XEvent { + struct { int type; } xany; + struct { int type; unsigned int button; unsigned int state; int x; int y; } xbutton; + struct { int type; int x; int y; } xmotion; +} XEvent; + +typedef struct { + Colormap colormap; +} XWindowAttributes; + +typedef struct { + int screen; + int width; + int height; + int count; + Bool wireframe_p; + Bool fps_p; + Bool drawn_p; + int polygon_count; + Display *display; + Window window; + Visual *visual; + XWindowAttributes xgwa; +} ModeInfo; + +typedef struct { + const char *option; + const char *specifier; + int arg_kind; + const char *value; +} XrmOptionDescRec; + +typedef struct { + void *var; + const char *name; + const char *class_name; + const char *default_value; + int type; +} argtype; + +typedef struct { + int option_count; + XrmOptionDescRec *options; + int variable_count; + argtype *variables; + void *defaults; +} ModeSpecOpt; + +enum { XrmoptionNoArg = 0, XrmoptionSepArg = 1 }; +enum { t_Bool = 1, t_Float = 2, t_Int = 3, t_String = 4 }; +enum { + ButtonPress = 4, + ButtonRelease = 5, + MotionNotify = 6, + Button1 = 1, +}; + +#define ENTRYPOINT +#define XSCREENSAVER_MODULE(name, prefix) +#define XSCREENSAVER_MODULE_2(name, symbol, prefix) + +#define MI_SCREEN(mi) ((mi)->screen) +#define MI_WIDTH(mi) ((mi)->width) +#define MI_HEIGHT(mi) ((mi)->height) +#define MI_COUNT(mi) ((mi)->count) +#define MI_IS_WIREFRAME(mi) ((mi)->wireframe_p) +#define MI_IS_FPS(mi) ((mi)->fps_p) +#define MI_IS_DRAWN(mi) ((mi)->drawn_p) +#define MI_DISPLAY(mi) ((mi)->display) +#define MI_WINDOW(mi) ((mi)->window) +#define MI_VISUAL(mi) ((mi)->visual) +#define MI_CLEARWINDOW(mi) ((void)(mi)) +#define MI_INIT(mi, storage) do { \ + if (!(storage)) (storage) = calloc(1, sizeof(*(storage))); \ + if (!(storage)) abort(); \ +} while (0) + +static inline int XLookupString(void *event, char *buffer, int length, KeySym *keysym, void *compose) { + (void)event; + (void)keysym; + (void)compose; + if (length > 0) buffer[0] = 0; + return 0; +} + +GLXContext *init_GL(ModeInfo *mi); +Bool glXMakeCurrent(Display *display, Window window, GLXContext context); +void glXSwapBuffers(Display *display, Window window); +void do_fps(ModeInfo *mi); +Bool screenhack_event_helper(Display *display, Window window, XEvent *event); +double current_device_rotation(void); + +#endif diff --git a/src/adapters/platonic-folding/tools/oracle/capture-browser-frames.mjs b/src/adapters/platonic-folding/tools/oracle/capture-browser-frames.mjs new file mode 100644 index 0000000..84838ff --- /dev/null +++ b/src/adapters/platonic-folding/tools/oracle/capture-browser-frames.mjs @@ -0,0 +1,147 @@ +#!/usr/bin/env node +import { spawn } from "node:child_process"; +import { mkdir, rm, writeFile } from "node:fs/promises"; +import { createServer } from "node:net"; +import { join, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; +import { chromium } from "playwright"; +import { adapterRoot, oracleRoot, repositoryRoot, writeJson } from "./oracle-support.mjs"; +import { resolvePlatonicOracleFrames } from "./frame-schedule.mjs"; + +export async function captureBrowserPlatonicFrames(options = {}) { + const frames = options.frames ?? resolvePlatonicOracleFrames(); + const width = positiveInteger(options.width, 960, "browser width"); + const height = positiveInteger(options.height, 600, "browser height"); + const outputDir = resolve(options.outputDir ?? join(oracleRoot, "browser", "capture")); + const framesDir = join(outputDir, "frames"); + await rm(outputDir, { recursive: true, force: true }); + await mkdir(framesDir, { recursive: true }); + + const port = await freePort(); + let serverOutput = ""; + const server = spawn("pnpm", [ + "exec", "vite", "--config", join(adapterRoot, "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 waitFor(() => serverOutput.includes("Local:") || serverOutput.includes(`127.0.0.1:${port}`), 20_000, () => { + if (server.exitCode !== null) throw new Error(`Vite exited early:\n${serverOutput}`); + }); + const browser = await chromium.launch({ channel: "chrome", headless: true }); + try { + const page = await browser.newPage({ viewport: { width, height }, deviceScaleFactor: 1 }); + const errors = []; + await page.route("**/favicon.ico", (request) => request.fulfill({ status: 204 })); + page.on("pageerror", (error) => errors.push(error.stack || error.message)); + page.on("console", (message) => { if (message.type() === "error") errors.push(message.text()); }); + const url = `http://127.0.0.1:${port}/`; + await page.goto(url, { waitUntil: "domcontentloaded" }); + await page.waitForFunction(() => window.__cssPlatonicFoldingDebug?.ready === true || + document.body.classList.contains("error"), null, { timeout: 30_000 }); + const initial = await page.evaluate(() => ({ + ready: window.__cssPlatonicFoldingDebug?.ready ?? false, + errors: window.__cssPlatonicFoldingDebug?.errors ?? [], + stats: window.__cssPlatonicFoldingDebug?.stats() ?? null, + forbiddenRendererElements: document.querySelectorAll(".polycss-camera canvas, .polycss-camera svg").length, + })); + if (errors.length || !initial.ready || initial.errors.length || + initial.stats?.selectedPreparedBank !== "desktop" || + initial.stats?.retainedFaceRootCount !== 50 || + initial.stats?.retainedPolygonLeafCount !== 50 || + initial.forbiddenRendererElements !== 0) { + throw new Error(`Platonic Folding browser oracle did not become ready: ${JSON.stringify({ initial, errors })}`); + } + await page.evaluate(() => { + window.__cssPlatonicFoldingDebug.pause(); + document.querySelector(".site-header")?.remove(); + }); + const rows = []; + for (let index = 0; index < frames.length; index += 1) { + const sourceFrame = frames[index]; + const row = await page.evaluate((frame) => { + const debug = window.__cssPlatonicFoldingDebug; + const state = debug.seekFrame(frame); + return { + sourceFrame: frame, + publishedFrame: state.frameIndex, + paused: state.paused, + visibleLeaves: [...document.querySelectorAll(".polycss-camera s")] + .filter((leaf) => getComputedStyle(leaf).visibility !== "hidden").length, + stableDom: state.retainedDomStable, + runtimeGeometryConstructionCount: state.runtimeGeometryConstructionCount, + runtimeAtlasRasterizationCount: state.runtimeAtlasRasterizationCount, + runtimeDomGrowth: state.runtimeDomGrowth, + }; + }, sourceFrame); + if (row.publishedFrame !== sourceFrame || !row.paused || !row.stableDom || + row.runtimeGeometryConstructionCount !== 0 || row.runtimeAtlasRasterizationCount !== 0 || + row.runtimeDomGrowth !== false) { + throw new Error(`Platonic Folding browser publication drifted: ${JSON.stringify(row)}`); + } + rows.push(row); + await page.locator(".polycss-camera").screenshot({ path: join(framesDir, frameName(index)) }); + } + const statesPath = join(outputDir, "states.jsonl"); + await writeFile(statesPath, `${rows.map((row) => JSON.stringify(row)).join("\n")}\n`); + const manifest = { + schema: "cssplatonicfolding-browser-frame-sequence@1", + url, + frames, + frameCount: frames.length, + viewport: { width, height, deviceScaleFactor: 1 }, + captureMode: "deterministic prepared frame seek", + framesDir, + statesPath, + initial, + }; + const manifestPath = join(outputDir, "browser-capture.json"); + await writeJson(manifestPath, manifest); + return Object.freeze({ ...manifest, manifestPath }); + } finally { + await browser.close(); + } + } finally { + server.kill("SIGTERM"); + } +} + +function frameName(index) { + return `frame_${String(index).padStart(4, "0")}.png`; +} + +function positiveInteger(value, fallback, label) { + const number = value === undefined ? fallback : Number(value); + if (!Number.isSafeInteger(number) || number <= 0) throw new RangeError(`${label} must be a positive integer`); + return number; +} + +async function freePort() { + return new Promise((resolvePort, reject) => { + const server = createServer(); + server.on("error", reject); + server.listen(0, "127.0.0.1", () => { + const address = server.address(); + server.close(() => resolvePort(typeof address === "object" && address ? address.port : 0)); + }); + }); +} + +async function waitFor(predicate, timeoutMilliseconds, onPoll) { + const started = Date.now(); + while (Date.now() - started < timeoutMilliseconds) { + onPoll(); + if (predicate()) return; + await new Promise((resolveWait) => setTimeout(resolveWait, 100)); + } + throw new Error("Timed out waiting for the Platonic Folding oracle server"); +} + +if (process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url)) { + captureBrowserPlatonicFrames().then((report) => console.log(JSON.stringify(report, null, 2))).catch((error) => { + console.error(error.stack || error.message || String(error)); + process.exitCode = 1; + }); +} diff --git a/src/adapters/platonic-folding/tools/oracle/capture-native-frames.mjs b/src/adapters/platonic-folding/tools/oracle/capture-native-frames.mjs new file mode 100644 index 0000000..3132e9a --- /dev/null +++ b/src/adapters/platonic-folding/tools/oracle/capture-native-frames.mjs @@ -0,0 +1,94 @@ +#!/usr/bin/env node +import { spawnSync } from "node:child_process"; +import { createHash } from "node:crypto"; +import { mkdir, readFile, rm } from "node:fs/promises"; +import { join, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; +import { CSSPLATONIC_SEED, PLATONIC_SOURCE } from "../../src/prepare/cssplatonicfolding/sourceModel.mjs"; +import { adapterRoot, assertReadable, oracleRoot, writeJson } from "./oracle-support.mjs"; +import { resolvePlatonicOracleFrames } from "./frame-schedule.mjs"; + +export async function captureNativePlatonicFrames(options = {}) { + const sourceRoot = resolve(options.sourceRoot ?? requiredSourceRoot()); + const frames = options.frames ?? resolvePlatonicOracleFrames(); + const width = positiveInteger(options.width, 960, "native width"); + const height = positiveInteger(options.height, 600, "native height"); + const outputDir = resolve(options.outputDir ?? join(oracleRoot, "native", "capture")); + const framesDir = join(outputDir, "frames"); + const executable = join(outputDir, "capture-platonicfolding"); + const primaryPath = join(sourceRoot, PLATONIC_SOURCE.primaryPath); + await assertReadable(primaryPath, "pinned platonicfolding.c"); + const sourceCommit = run("git", ["-C", sourceRoot, "rev-parse", "HEAD"]).stdout.trim(); + const primarySha256 = sha256(await readFile(primaryPath)); + if (sourceCommit !== PLATONIC_SOURCE.commit || primarySha256 !== PLATONIC_SOURCE.primarySha256) { + throw new Error(`Platonic Folding source identity mismatch: ${JSON.stringify({ sourceCommit, primarySha256 })}`); + } + await rm(outputDir, { recursive: true, force: true }); + await mkdir(framesDir, { recursive: true }); + compileCapture({ sourceRoot, executable }); + run(executable, [framesDir, String(CSSPLATONIC_SEED), String(width), String(height), ...frames.map(String)]); + const manifest = { + schema: "cssplatonicfolding-native-frame-sequence@1", + source: { root: sourceRoot, commit: sourceCommit, primaryPath: PLATONIC_SOURCE.primaryPath, primarySha256 }, + renderer: "pinned polygon_folding_pf GLSL path in a CGL legacy offscreen framebuffer", + seed: CSSPLATONIC_SEED, + frames, + frameCount: frames.length, + viewport: { width, height }, + framesDir, + }; + const manifestPath = join(outputDir, "native-capture.json"); + await writeJson(manifestPath, manifest); + return Object.freeze({ ...manifest, manifestPath }); +} + +function compileCapture({ sourceRoot, executable }) { + const includeRoot = join(adapterRoot, "tools", "native", "headless", "include"); + const source = join(adapterRoot, "tools", "native", "headless", "capture-platonicfolding.c"); + const glx = join(sourceRoot, "hacks", "glx"); + const utils = join(sourceRoot, "utils"); + run("clang", [ + "-std=gnu11", "-O2", "-Wno-deprecated-declarations", + "-DSTANDALONE", "-DUSE_GL", "-DHAVE_GLSL", "-DHAVE_UNISTD_H", "-DGETTIMEOFDAY_TWO_ARGS", + "-include", join(includeRoot, "xlockmore.h"), + "-I", includeRoot, "-I", glx, "-I", utils, + source, + join(glx, "glsl-utils.c"), + join(glx, "gltrackball.c"), + join(glx, "trackball.c"), + join(glx, "quaternion.c"), + join(utils, "yarandom.c"), + "-framework", "OpenGL", "-lm", "-o", executable, + ]); +} + +function run(command, args) { + const result = spawnSync(command, args, { encoding: "utf8", maxBuffer: 64 * 1024 * 1024 }); + if (result.error) throw result.error; + if (result.status !== 0) throw new Error(result.stderr || result.stdout || `${command} exited ${result.status}`); + return result; +} + +function requiredSourceRoot() { + if (!process.env.CSSPLATONICFOLDING_SOURCE_ROOT) { + throw new Error("Set CSSPLATONICFOLDING_SOURCE_ROOT to the pinned XScreenSaver checkout"); + } + return process.env.CSSPLATONICFOLDING_SOURCE_ROOT; +} + +function positiveInteger(value, fallback, label) { + const number = value === undefined ? fallback : Number(value); + if (!Number.isSafeInteger(number) || number <= 0) throw new RangeError(`${label} must be a positive integer`); + return number; +} + +function sha256(bytes) { + return createHash("sha256").update(bytes).digest("hex"); +} + +if (process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url)) { + captureNativePlatonicFrames().then((report) => console.log(JSON.stringify(report, null, 2))).catch((error) => { + console.error(error.stack || error.message || String(error)); + process.exitCode = 1; + }); +} diff --git a/src/adapters/platonic-folding/tools/oracle/frame-schedule.mjs b/src/adapters/platonic-folding/tools/oracle/frame-schedule.mjs new file mode 100644 index 0000000..4e47915 --- /dev/null +++ b/src/adapters/platonic-folding/tools/oracle/frame-schedule.mjs @@ -0,0 +1,20 @@ +const SOLID_FRAME_COUNT = 542; +const POSE_FRAMES = Object.freeze([90, 180, 225, 270, 315, 360, 450]); + +export const CSSPLATONIC_ORACLE_FRAMES = Object.freeze([ + 0, + ...Array.from({ length: 5 }, (_, solidIndex) => + POSE_FRAMES.map((frame) => solidIndex * SOLID_FRAME_COUNT + frame)).flat(), + 5 * SOLID_FRAME_COUNT - 1, +]); + +export function resolvePlatonicOracleFrames(value = process.env.CSSPLATONIC_ORACLE_FRAMES) { + if (!value) return CSSPLATONIC_ORACLE_FRAMES; + const frames = String(value).split(",").map((part) => Number(part.trim())); + if (frames.length === 0 || frames.some((frame, index) => + !Number.isSafeInteger(frame) || frame < 0 || frame >= 5 * SOLID_FRAME_COUNT || + (index > 0 && frame <= frames[index - 1]))) { + throw new RangeError("CSSPLATONIC_ORACLE_FRAMES must contain strictly increasing frames in 0..2709"); + } + return Object.freeze(frames); +} diff --git a/src/adapters/platonic-folding/tools/oracle/oracle-support.mjs b/src/adapters/platonic-folding/tools/oracle/oracle-support.mjs new file mode 100644 index 0000000..4f1db68 --- /dev/null +++ b/src/adapters/platonic-folding/tools/oracle/oracle-support.mjs @@ -0,0 +1,67 @@ +import { spawnSync } from "node:child_process"; +import { access, mkdir, readFile, writeFile } from "node:fs/promises"; +import { constants as fsConstants } from "node:fs"; +import { homedir } from "node:os"; +import { join, resolve } from "node:path"; + +export const adapterRoot = resolve(import.meta.dirname, "..", ".."); +export const repositoryRoot = resolve(adapterRoot, "..", "..", ".."); +export const oracleRoot = resolve( + process.env.CSSPLATONIC_ORACLE_OUT ?? + join(repositoryRoot, "bench", "results", "cssplatonicfolding", "oracle"), +); + +export async function compareFrameSequences({ + expected, + actual, + out, + label, + frameCount, + meanThreshold = 0, + changedThreshold = 0, + channelThreshold = 0, + diffFrames = "worst", +}) { + const script = resolve( + process.env.CSS_FRAME_SEQUENCE_ORACLE_SCRIPT ?? + join(process.env.CODEX_HOME ?? join(homedir(), ".codex"), "skills", "frame-sequence-oracle", "scripts", "frame-sequence.mjs"), + ); + await assertReadable(script, "frame-sequence oracle"); + const result = spawnSync(process.execPath, [ + script, + "compare", + "--expected", expected, + "--actual", actual, + "--out", out, + "--replace", + "--label", label, + "--expected-frames", String(frameCount), + "--mean-threshold", String(meanThreshold), + "--changed-threshold", String(changedThreshold), + "--channel-threshold", String(channelThreshold), + "--diff-frames", diffFrames, + "--diff-amplify", "1", + ], { encoding: "utf8", maxBuffer: 64 * 1024 * 1024 }); + if (result.error) throw result.error; + if (result.status !== 0) throw new Error(result.stderr || result.stdout || `Frame comparison exited ${result.status}`); + const manifestPath = join(out, `${label}.json`); + return Object.freeze({ ...(await readJson(manifestPath)), manifestPath }); +} + +export async function writeJson(path, value) { + await mkdir(resolve(path, ".."), { recursive: true }); + await writeFile(path, `${JSON.stringify(value, null, 2)}\n`); + return path; +} + +export async function readJson(path) { + return JSON.parse(await readFile(path, "utf8")); +} + +export async function assertReadable(path, label = "file") { + try { + await access(path, fsConstants.R_OK); + } catch { + throw new Error(`Missing ${label}: ${path}`); + } +} diff --git a/src/adapters/platonic-folding/tools/oracle/run-visual-oracle.mjs b/src/adapters/platonic-folding/tools/oracle/run-visual-oracle.mjs new file mode 100644 index 0000000..9a055c9 --- /dev/null +++ b/src/adapters/platonic-folding/tools/oracle/run-visual-oracle.mjs @@ -0,0 +1,126 @@ +#!/usr/bin/env node +import { spawnSync } from "node:child_process"; +import { copyFile, mkdir, rm } from "node:fs/promises"; +import { join, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; +import { captureBrowserPlatonicFrames } from "./capture-browser-frames.mjs"; +import { captureNativePlatonicFrames } from "./capture-native-frames.mjs"; +import { resolvePlatonicOracleFrames } from "./frame-schedule.mjs"; +import { compareFrameSequences, oracleRoot, writeJson } from "./oracle-support.mjs"; + +const BOUNDED_MEAN_DELTA = 0.7; +const BOUNDED_CHANGED_RATIO = 0.0125; +const BOUNDED_CHANNEL_DELTA = 8; + +export async function runPlatonicVisualOracle(options = {}) { + const outputDir = resolve(options.outputDir ?? oracleRoot); + const sourceRoot = options.sourceRoot ?? process.env.CSSPLATONICFOLDING_SOURCE_ROOT; + const frames = options.frames ?? resolvePlatonicOracleFrames(); + await rm(outputDir, { recursive: true, force: true }); + const nativeA = await captureNativePlatonicFrames({ sourceRoot, frames, outputDir: join(outputDir, "native", "run-a") }); + const nativeB = await captureNativePlatonicFrames({ sourceRoot, frames, outputDir: join(outputDir, "native", "run-b") }); + const browserA = await captureBrowserPlatonicFrames({ frames, outputDir: join(outputDir, "browser", "run-a") }); + const browserB = await captureBrowserPlatonicFrames({ frames, outputDir: join(outputDir, "browser", "run-b") }); + const nativeAa = await compareFrameSequences({ + expected: nativeA.framesDir, + actual: nativeB.framesDir, + out: join(outputDir, "native", "aa"), + label: "cssplatonicfolding_native_aa", + frameCount: frames.length, + }); + const browserAa = await compareFrameSequences({ + expected: browserA.framesDir, + actual: browserB.framesDir, + out: join(outputDir, "browser", "aa"), + label: "cssplatonicfolding_browser_aa", + frameCount: frames.length, + }); + const measurement = await compareFrameSequences({ + expected: nativeA.framesDir, + actual: browserA.framesDir, + out: join(outputDir, "native-browser", "measurement"), + label: "cssplatonicfolding_native_browser_measurement", + frameCount: frames.length, + meanThreshold: 255, + changedThreshold: 1, + channelThreshold: 0, + diffFrames: "worst", + }); + const bounded = await compareFrameSequences({ + expected: nativeA.framesDir, + actual: browserA.framesDir, + out: join(outputDir, "native-browser", "bounded"), + label: "cssplatonicfolding_native_browser_bounded", + frameCount: frames.length, + meanThreshold: BOUNDED_MEAN_DELTA, + changedThreshold: BOUNDED_CHANGED_RATIO, + channelThreshold: BOUNDED_CHANNEL_DELTA, + diffFrames: "worst", + }); + const exact = measurement.frames.every((frame) => frame.meanAbsDelta === 0 && frame.changedPixelRatio === 0); + const triptych = await publishTriptych({ + frame: measurement.worst[0].frame, + sourceFrame: frames[measurement.worst[0].frame], + nativeDir: nativeA.framesDir, + browserDir: browserA.framesDir, + comparison: measurement, + outputDir: join(outputDir, "native-browser", "triptych"), + }); + const report = { + schema: "cssplatonicfolding-visual-oracle@1", + source: nativeA.source, + renderer: nativeA.renderer, + frames, + frameCount: frames.length, + nativeAa: { exact: nativeAa.pass, reportPath: nativeAa.manifestPath }, + browserAa: { exact: browserAa.pass, reportPath: browserAa.manifestPath }, + visual: { + exact, + boundedMatch: bounded.pass, + qualification: exact ? "exact-pixel-match" : bounded.pass ? "source-faithful-bounded-approximation" : "diverged", + measurement: { reportPath: measurement.manifestPath, worst: measurement.worst[0] }, + bounded: { reportPath: bounded.manifestPath, thresholds: bounded.thresholds, worst: bounded.worst[0] }, + note: "The bounded gate preserves full-frame structure and source colors while allowing only measured OpenGL-versus-raster-atlas edge filtering and 64-state per-face palette quantization. It does not qualify visual parity.", + }, + triptych, + }; + report.pass = report.nativeAa.exact && report.browserAa.exact && report.visual.boundedMatch; + const reportPath = join(outputDir, "visual-oracle.json"); + await writeJson(reportPath, report); + if (!report.pass) throw new Error(`Platonic Folding visual oracle failed: ${JSON.stringify(report)}`); + return Object.freeze({ ...report, reportPath }); +} + +async function publishTriptych({ frame, sourceFrame, nativeDir, browserDir, comparison, outputDir }) { + await mkdir(outputDir, { recursive: true }); + const suffix = String(frame).padStart(4, "0"); + const native = join(outputDir, "native.png"); + const browser = join(outputDir, "browser.png"); + const absoluteDiff = join(outputDir, "absolute-diff.png"); + convertPpmToPng(join(nativeDir, `frame_${suffix}.ppm`), native); + await copyFile(join(browserDir, `frame_${suffix}.png`), browser); + const diff = comparison.diffs.find((entry) => entry.frame === frame); + await copyFile(diff.png, absoluteDiff); + return Object.freeze({ frame, sourceFrame, native, browser, absoluteDiff }); +} + +function convertPpmToPng(source, target) { + const result = spawnSync("sips", ["-s", "format", "png", source, "--out", target], { + encoding: "utf8", + maxBuffer: 8 * 1024 * 1024, + }); + if (result.error) throw result.error; + if (result.status !== 0) throw new Error(result.stderr || result.stdout || "Unable to convert native frame"); +} + +if (process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url)) { + runPlatonicVisualOracle().then((report) => console.log(JSON.stringify({ + pass: report.pass, + reportPath: report.reportPath, + visual: report.visual, + triptych: report.triptych, + }, null, 2))).catch((error) => { + console.error(error.stack || error.message || String(error)); + process.exitCode = 1; + }); +} diff --git a/src/adapters/platonic-folding/tools/prepare-cssplatonicfolding.mjs b/src/adapters/platonic-folding/tools/prepare-cssplatonicfolding.mjs new file mode 100644 index 0000000..da7ebb3 --- /dev/null +++ b/src/adapters/platonic-folding/tools/prepare-cssplatonicfolding.mjs @@ -0,0 +1,112 @@ +#!/usr/bin/env node +import { spawnSync } from "node:child_process"; +import { createHash } from "node:crypto"; +import { mkdir, readFile, rename, rm, writeFile } from "node:fs/promises"; +import { dirname, join, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; +import { buildPolyMorphCatalog, buildPolyMorphPackage } from "@layoutit/polycss-morph"; +import { buildPlatonicPreparedModel } from "../src/prepare/cssplatonicfolding/modelBuilder.mjs"; +import { + buildPlatonicRasterAtlas, + CSSPLATONIC_RASTER_ATLAS_PATH, +} from "../src/prepare/cssplatonicfolding/rasterAtlas.mjs"; +import { PLATONIC_BANKS, PLATONIC_SOURCE } from "../src/prepare/cssplatonicfolding/sourceModel.mjs"; + +const adapterRoot = resolve(dirname(fileURLToPath(import.meta.url)), ".."); +const repositoryRoot = resolve(adapterRoot, "..", "..", ".."); +const outputRoot = join(repositoryRoot, "build/generated/public/cssplatonicfolding"); +const stagingRoot = join(repositoryRoot, `build/generated/.cssplatonicfolding-${process.pid}`); +const sourceRoot = resolveRequiredSourceRoot(); +const sourceLock = JSON.parse(await readFile(join(adapterRoot, "notes/references/source-lock.json"), "utf8")); + +await assertSourceIdentity(); +await rm(stagingRoot, { recursive: true, force: true }); +await mkdir(stagingRoot, { recursive: true }); + +const firstPrepared = buildPlatonicPreparedModel({ bankId: "desktop" }); +const atlasBytes = buildPlatonicRasterAtlas(firstPrepared.source.faceDefinitions); +const catalogEntries = []; +const preparedBanks = []; +for (const bank of Object.values(PLATONIC_BANKS)) { + const prepared = bank.id === "desktop" ? firstPrepared : buildPlatonicPreparedModel({ bankId: bank.id }); + const built = await buildPolyMorphPackage(prepared.model, [{ + path: CSSPLATONIC_RASTER_ATLAS_PATH, + role: "image", + mediaType: "image/png", + bytes: atlasBytes, + }]); + const packageRoot = join(stagingRoot, "model", bank.modelId); + await mkdir(packageRoot, { recursive: true }); + for (const [path, bytes] of built.files) await writeBytes(join(packageRoot, path), bytes); + await writeBytes(join(packageRoot, "manifest.json"), built.manifestBytes); + catalogEntries.push({ + manifest: built.manifest, + manifestPath: `${bank.modelId}/manifest.json`, + manifestSha256: built.manifestSha256, + }); + preparedBanks.push({ + id: bank.id, + modelId: bank.modelId, + ...prepared.metrics, + manifestSha256: built.manifestSha256, + }); +} +const catalog = await buildPolyMorphCatalog(PLATONIC_BANKS.desktop.modelId, catalogEntries); +await writeBytes(join(stagingRoot, "model", "catalog.json"), catalog.bytes); +await writeJson(join(stagingRoot, "prepared.json"), { + schema: "cssplatonicfolding-prepared-scene@1", + status: "ready", + source: sourceLock, + renderer: { + package: "@layoutit/polycss-morph", + profile: "prepared-playback", + representation: "retained-source-face-roots-with-prepared-raster-lighting", + runtimeGeometryConstruction: false, + runtimeAtlasRasterization: false, + runtimeDomGrowth: false, + }, + presentation: { + frameMilliseconds: PLATONIC_SOURCE.delayMicroseconds / 1_000, + solidOrder: ["icosahedron", "dodecahedron", "hexahedron", "octahedron", "tetrahedron"], + foldMode: "source-joint", + foldingsPerSolid: 1, + }, + preparedBanks, + oracle: { + sourceState: "source-constants-and-topology-pinned", + visual: "source-faithful-bounded-approximation", + }, +}); +await rm(outputRoot, { recursive: true, force: true }); +await mkdir(dirname(outputRoot), { recursive: true }); +await rename(stagingRoot, outputRoot); +console.log(JSON.stringify({ outputRoot, atlasBytes: atlasBytes.byteLength, preparedBanks }, null, 2)); + +function resolveRequiredSourceRoot() { + const value = process.env.CSSPLATONICFOLDING_SOURCE_ROOT; + if (!value) throw new Error("Set CSSPLATONICFOLDING_SOURCE_ROOT to the pinned XScreenSaver checkout"); + return resolve(value); +} + +async function assertSourceIdentity() { + const head = spawnSync("git", ["-C", sourceRoot, "rev-parse", "HEAD"], { encoding: "utf8" }); + if (head.error) throw head.error; + if (head.status !== 0) throw new Error(`Unable to resolve Platonic Folding source revision:\n${head.stderr || head.stdout}`); + const revision = head.stdout.trim(); + if (revision !== sourceLock.revision || revision !== PLATONIC_SOURCE.commit) { + throw new Error(`Platonic Folding source revision drifted: ${revision}`); + } + for (const input of [sourceLock.primary, sourceLock.config]) { + const actual = createHash("sha256").update(await readFile(join(sourceRoot, input.path))).digest("hex"); + if (actual !== input.sha256) throw new Error(`Platonic Folding source bytes drifted: ${input.path}`); + } +} + +async function writeBytes(path, bytes) { + await mkdir(dirname(path), { recursive: true }); + await writeFile(path, bytes); +} + +async function writeJson(path, value) { + await writeBytes(path, `${JSON.stringify(value)}\n`); +} diff --git a/src/adapters/platonic-folding/tools/smoke-browser.mjs b/src/adapters/platonic-folding/tools/smoke-browser.mjs new file mode 100644 index 0000000..0f1d721 --- /dev/null +++ b/src/adapters/platonic-folding/tools/smoke-browser.mjs @@ -0,0 +1,107 @@ +#!/usr/bin/env node +import { spawn } from "node:child_process"; +import { mkdir, writeFile } from "node:fs/promises"; +import { createServer } from "node:net"; +import { resolve } from "node:path"; +import { chromium } from "playwright"; + +const adapterRoot = resolve(import.meta.dirname, ".."); +const repositoryRoot = resolve(adapterRoot, "..", "..", ".."); +const outputRoot = resolve(repositoryRoot, "bench/results/cssplatonicfolding/smoke"); +const externalUrl = process.env.CSSPLATONICFOLDING_SMOKE_URL; +const port = externalUrl ? null : await freePort(); +const route = externalUrl ?? `http://127.0.0.1:${port}/`; +let server = null; +let serverOutput = ""; +if (!externalUrl) { + server = spawn("pnpm", [ + "exec", "vite", "--config", resolve(adapterRoot, "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(); }); +} + +let browser; +try { + await mkdir(outputRoot, { recursive: true }); + if (server) await waitFor(() => serverOutput.includes("Local:"), 20_000); + browser = await chromium.launch({ channel: "chrome", headless: true }); + const desktop = await capture({ profile: "desktop", width: 1280, height: 800, bankId: "desktop" }); + const mobile = await capture({ profile: "mobile", width: 390, height: 844, bankId: "mobile" }); + const report = { schema: "cssplatonicfolding-browser-smoke@1", route, desktop, mobile }; + await writeFile(resolve(outputRoot, "report.json"), `${JSON.stringify(report, null, 2)}\n`); + console.log(JSON.stringify(report, null, 2)); +} finally { + await browser?.close(); + server?.kill("SIGTERM"); +} + +async function capture({ profile, width, height, bankId }) { + const page = await browser.newPage({ viewport: { width, height }, deviceScaleFactor: 1 }); + await page.route("**/favicon.ico", (request) => request.fulfill({ status: 204 })); + 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(route, { waitUntil: "domcontentloaded" }); + await page.waitForFunction(() => document.body.classList.contains("ready") || + document.body.classList.contains("error"), null, { timeout: 30_000 }); + const before = await page.evaluate(() => ({ + status: document.body.className, + ready: window.__cssPlatonicFoldingDebug?.ready, + errors: window.__cssPlatonicFoldingDebug?.errors ?? [], + stats: window.__cssPlatonicFoldingDebug?.stats(), + modelTransform: document.querySelector(".polycss-scene > div")?.style.transform, + leafCount: document.querySelectorAll(".polycss-scene s").length, + canvasCount: document.querySelectorAll("canvas").length, + svgSceneCount: document.querySelectorAll(".polycss-camera svg").length, + })); + await page.waitForTimeout(300); + const after = await page.evaluate(() => ({ + stats: window.__cssPlatonicFoldingDebug?.stats(), + modelTransform: document.querySelector(".polycss-scene > div")?.style.transform, + })); + await page.evaluate(() => { + window.__cssPlatonicFoldingDebug.pause(); + window.__cssPlatonicFoldingDebug.seekFrame(270); + }); + const visibleLeaves = await page.evaluate(() => [...document.querySelectorAll(".polycss-scene s")] + .filter((leaf) => getComputedStyle(leaf).visibility !== "hidden").length); + if (errors.length || before.errors.length || before.status !== "ready" || !before.ready || + before.stats?.selectedPreparedBank !== bankId || before.stats?.retainedFaceRootCount !== 50 || + before.leafCount !== 50 || visibleLeaves !== 20 || before.canvasCount !== 0 || + before.svgSceneCount !== 0 || before.stats?.runtimeGeometryConstructionCount !== 0 || + before.stats?.runtimeAtlasRasterizationCount !== 0 || before.stats?.runtimeDomGrowth !== false || + before.stats?.retainedDomStable !== true || + after.stats?.timerCallbackCount <= before.stats?.timerCallbackCount || + after.stats?.applyCount <= before.stats?.applyCount || + before.modelTransform === after.modelTransform) { + throw new Error(`Platonic Folding ${profile} browser smoke failed: ${JSON.stringify({ before, after, visibleLeaves, errors })}`); + } + const screenshotPath = resolve(outputRoot, `${profile}.png`); + await page.screenshot({ path: screenshotPath }); + await page.close(); + return Object.freeze({ width, height, bankId, leaves: 50, visibleLeaves, screenshotPath }); +} + +async function freePort() { + return new Promise((resolvePort, reject) => { + const socket = createServer(); + socket.unref(); + socket.on("error", reject); + socket.listen(0, "127.0.0.1", () => { + const address = socket.address(); + socket.close(() => resolvePort(address.port)); + }); + }); +} + +async function waitFor(predicate, timeoutMilliseconds) { + const started = Date.now(); + while (!predicate()) { + if (Date.now() - started > timeoutMilliseconds) { + throw new Error(`Platonic Folding server did not start:\n${serverOutput}`); + } + await new Promise((resolveDelay) => setTimeout(resolveDelay, 25)); + } +} diff --git a/src/adapters/platonic-folding/vite.config.mjs b/src/adapters/platonic-folding/vite.config.mjs new file mode 100644 index 0000000..00d9ebe --- /dev/null +++ b/src/adapters/platonic-folding/vite.config.mjs @@ -0,0 +1,18 @@ +import { resolve } from "node:path"; +import { fileURLToPath } from "node:url"; +import { defineConfig } from "vite"; + +const adapterRoot = fileURLToPath(new URL(".", import.meta.url)); +const repositoryRoot = resolve(adapterRoot, "..", "..", ".."); + +export default defineConfig({ + root: adapterRoot, + publicDir: resolve(repositoryRoot, "build/generated/public"), + server: { host: "127.0.0.1" }, + preview: { host: "127.0.0.1" }, + build: { + target: "es2022", + outDir: resolve(repositoryRoot, "dist/platonic-folding"), + emptyOutDir: true, + }, +}); From 657f50ff79857180927634864b92fb84917bc3bf Mon Sep 17 00:00:00 2001 From: alowpoly Date: Tue, 18 Aug 2026 08:38:52 -0300 Subject: [PATCH 2/5] perf(platonic-folding): prebake sparse playback --- src/adapters/platonic-folding/AGENTS.md | 4 +- src/adapters/platonic-folding/README.md | 14 +- .../notes/provenance-bible.md | 14 +- .../src/cssplatonicfolding/client.mjs | 149 +-------- .../cssplatonicfolding/preparedPlayback.mjs | 286 ++++++++++++++++++ .../cssplatonicfolding/modelBuilder.mjs | 197 ++++++++---- .../cssplatonicfolding/sourceModel.mjs | 2 - .../test/cssplatonicfolding-shell.test.mjs | 11 +- .../cssplatonicfolding-source-model.test.mjs | 82 ++++- .../tools/check-prepare-determinism.mjs | 17 +- .../tools/prepare-cssplatonicfolding.mjs | 64 ++-- .../platonic-folding/tools/smoke-browser.mjs | 23 +- 12 files changed, 604 insertions(+), 259 deletions(-) create mode 100644 src/adapters/platonic-folding/src/cssplatonicfolding/preparedPlayback.mjs diff --git a/src/adapters/platonic-folding/AGENTS.md b/src/adapters/platonic-folding/AGENTS.md index 061a051..d3818f5 100644 --- a/src/adapters/platonic-folding/AGENTS.md +++ b/src/adapters/platonic-folding/AGENTS.md @@ -2,8 +2,8 @@ - Read `notes/provenance-bible.md` before changing topology, face geometry, colors, lighting, camera, timing, motion, or parity claims. - Preserve the pinned XScreenSaver face graphs, 4/6/8/12/20 face census, dihedral fold angles, 25 ms cadence, quintic hinge easing, 45-degree field of view, and source portrait/landscape travel direction. -- Preparation owns the deterministic unfolding trees, complete playback, face colors, raster lighting rows, and two responsive product banks. The mobile bank's 1.55x eye distance is the only camera adaptation from the source constants. -- Runtime may mount one prepared Morph package, publish prepared model/shape/atlas-row state, and update the responsive perspective carrier. It must not calculate geometry, rasterize assets, grow the DOM, or rebuild topology during playback. +- Preparation owns the deterministic unfolding trees, complete sparse playback, preformatted transforms, face colors, raster lighting rows, and two responsive product banks. The mobile bank's 1.55x eye distance is the only camera adaptation from the source constants. +- Runtime may mount the one shared `static-prepared` Morph package, bind its source-ordered elements with `createPolyMorphPreparedDomTarget`, consume the selected bank's sparse operation rows, and update the responsive perspective carrier. Normal playback must not materialize full prepared states, diff the retained graph, format matrices, look up ids, calculate geometry, rasterize assets, grow the DOM, scan all leaves, or rebuild topology. - Keep one stable retained graph with 50 face roots and 50 `` leaves. Only the active solid is visible. No Canvas, WebGL, WebGPU, SVG scene geometry, masks, clip paths, runtime atlas work, or paint-heavy CSS effects. - Keep source checkouts, native binaries, captures, traces, and generated prepared assets ignored. - Keep the independent headless CGL frame-sequence oracle passing across all five solids. Its qualified claim is a bounded source-faithful approximation, not visual parity or pixel identity. diff --git a/src/adapters/platonic-folding/README.md b/src/adapters/platonic-folding/README.md index c013eb1..56e82ca 100644 --- a/src/adapters/platonic-folding/README.md +++ b/src/adapters/platonic-folding/README.md @@ -1,8 +1,9 @@ # css.graphics/platonic-folding Source-backed XScreenSaver Platonic Folding rendered as 50 retained PolyCSS -Morph face roots. Preparation bakes both responsive banks, every hinge pose, -source-style face colors, and quantized lighting into one raster atlas. +Morph face roots. Preparation bakes one shared `static-prepared` Morph package, +both responsive sparse playback banks, every hinge pose, source-style face +colors, and quantized lighting into one raster atlas. ```sh export CSSPLATONICFOLDING_SOURCE_ROOT=/path/to/xscreensaver @@ -15,9 +16,12 @@ pnpm build:platonic-folding pnpm oracle:platonic-folding ``` -Runtime owns only the prepared playback clock, sparse Morph publication, and -responsive perspective. It performs no geometry construction, atlas -rasterization, topology rebuilding, or DOM growth. +Runtime owns only the prepared playback clock, source-ordered sparse DOM +publication through `createPolyMorphPreparedDomTarget`, and responsive +perspective. Normal playback consumes preformatted transform and atlas-row +selections directly. It performs no prepared-state materialization, full-state +diff, matrix formatting, id lookup, geometry construction, atlas rasterization, +topology rebuilding, DOM growth, or full retained-graph scan. The oracle compiles the pinned XScreenSaver source into a headless CGL capture, captures the same deterministic frame schedule from the browser, proves exact diff --git a/src/adapters/platonic-folding/notes/provenance-bible.md b/src/adapters/platonic-folding/notes/provenance-bible.md index 832ea06..5782cd0 100644 --- a/src/adapters/platonic-folding/notes/provenance-bible.md +++ b/src/adapters/platonic-folding/notes/provenance-bible.md @@ -14,11 +14,15 @@ The product chooses the source-supported one-folding mode and a deterministic valid run in which each solid uses joint folding. -All five solids share one retained graph: 50 face roots and 50 raster leaves. -Only the active solid's 4, 6, 8, 12, or 20 leaves are visible. Preparation owns -the unfolding trees, complete 2,710-frame sequence, face matrices, source-style -face colors, quantized baked lighting rows, and atlas. Runtime owns the clock, -prepared sparse publication, and responsive perspective only. +All five solids share one `static-prepared` Morph graph: 50 face roots and 50 +raster leaves. Only the active solid's 4, 6, 8, 12, or 20 leaves are visible. +Preparation owns the unfolding trees, complete 2,710-frame sequence, +source-ordered sparse operation rows, preformatted face matrices, source-style +face colors, quantized baked lighting rows, and atlas. Runtime binds the shared +graph with `createPolyMorphPreparedDomTarget` and owns only the clock, direct +prepared operation selection, and responsive perspective. Full prepared-state +materialization, full-state diffs, matrix formatting, id lookup, and normal-path +full-graph scans remain outside playback. The independent oracle includes the pinned source file directly in a headless CGL harness and exercises its GLSL folding draw path. Native and browser A/A diff --git a/src/adapters/platonic-folding/src/cssplatonicfolding/client.mjs b/src/adapters/platonic-folding/src/cssplatonicfolding/client.mjs index 1807090..e7e5f6d 100644 --- a/src/adapters/platonic-folding/src/cssplatonicfolding/client.mjs +++ b/src/adapters/platonic-folding/src/cssplatonicfolding/client.mjs @@ -1,10 +1,12 @@ import { createPolyPerspectiveCamera } from "@layoutit/polycss"; import { - createPolyMorphPlaybackRuntime, loadPolyMorphPackage, mountPolyMorphModel, } from "@layoutit/polycss-morph"; import { selectPlatonicBank } from "./bankSelection.mjs"; +import { createPlatonicPreparedPlayer } from "./preparedPlayback.mjs"; + +const MODEL_ID = "platonic-folding"; export function mountPlatonicFolding(host) { const state = { ready: false, errors: [], bankId: null, metadata: null, mounted: null, player: null }; @@ -14,16 +16,21 @@ export function mountPlatonicFolding(host) { async function main() { const bankId = selectPlatonicBank({ width: host.clientWidth || innerWidth, height: host.clientHeight || innerHeight }); - const modelId = `platonic-folding-${bankId}`; - const [loaded, metadata] = await Promise.all([ - loadPolyMorphPackage("/cssplatonicfolding/model/", { modelId }), + const playbackPath = `/cssplatonicfolding/banks/${bankId}/playback.json`; + const [loaded, metadata, playback] = await Promise.all([ + loadPolyMorphPackage("/cssplatonicfolding/model/", { modelId: MODEL_ID }), fetch("/cssplatonicfolding/prepared.json", { cache: "no-store" }) .then(assertResponse) .then((response) => response.json()), + fetch(playbackPath) + .then(assertResponse) + .then((response) => response.json()), ]); const preparedBank = metadata?.preparedBanks?.find((bank) => bank.id === bankId); if (metadata?.schema !== "cssplatonicfolding-prepared-scene@1" || - loaded.model.identity.id !== modelId || preparedBank?.modelId !== modelId) { + loaded.model.identity.id !== MODEL_ID || preparedBank?.modelId !== MODEL_ID || + preparedBank?.playbackPath !== playbackPath || playback?.bankId !== bankId || + playback?.modelId !== MODEL_ID) { throw new Error("Platonic Folding prepared model binding drifted"); } const mounted = mountPolyMorphModel(host, loaded.model, { @@ -38,9 +45,8 @@ export function mountPlatonicFolding(host) { }), }); cleanPreparedDom(mounted); - const player = createPlayer(mounted, loaded.model); + const player = createPlatonicPreparedPlayer({ mounted, playback }); player.resize(); - player.seekFrame(0); addEventListener("resize", player.resize, { passive: true }); state.ready = true; state.bankId = bankId; @@ -59,135 +65,6 @@ export function mountPlatonicFolding(host) { } } -function createPlayer(mounted, model) { - const runtime = createPolyMorphPlaybackRuntime(model); - const frameMilliseconds = model.playback.frames[1].timeMs - model.playback.frames[0].timeMs; - const durationMilliseconds = runtime.durationMs; - let paused = true; - let destroyed = false; - let timer = null; - let clockOrigin = performance.now(); - let pausedAt = 0; - let lastFrameIndex = -1; - let timerCallbackCount = 0; - let collapsedFrameCount = 0; - let applyCount = 0; - let modelTransformWrites = 0; - let shapeTransformWrites = 0; - let visibilityWrites = 0; - let atlasRowWrites = 0; - - function publish(elapsed) { - const sample = runtime.sample(elapsed); - if (sample.frameIndex === lastFrameIndex) return sample.frameIndex; - const result = mounted.apply(sample.update); - runtime.commit(sample); - lastFrameIndex = sample.frameIndex; - applyCount += 1; - modelTransformWrites += result.modelTransformWrites; - shapeTransformWrites += result.shapeTransformWrites; - visibilityWrites += result.visibilityWrites; - atlasRowWrites += result.atlasRowWrites; - return sample.frameIndex; - } - - function cancelScheduled() { - if (timer !== null) clearTimeout(timer); - timer = null; - } - - function schedule() { - if (paused || destroyed || timer !== null) return; - const elapsed = Math.max(0, performance.now() - clockOrigin); - const phase = elapsed % durationMilliseconds; - const nextFrame = (Math.floor(phase / frameMilliseconds) + 1) * frameMilliseconds; - timer = setTimeout(wake, Math.max(1, Math.ceil(nextFrame - phase))); - } - - function wake() { - timer = null; - if (paused || destroyed) return; - timerCallbackCount += 1; - const elapsed = Math.max(0, performance.now() - clockOrigin); - const dueFrame = Math.floor((elapsed % durationMilliseconds) / frameMilliseconds); - if (lastFrameIndex >= 0) { - const distance = dueFrame >= lastFrameIndex - ? dueFrame - lastFrameIndex - : runtime.frameCount - lastFrameIndex + dueFrame; - collapsedFrameCount += Math.max(0, distance - 1); - } - publish(elapsed); - schedule(); - } - - function resize() { - const perspective = Math.min(innerWidth, innerHeight) / - (2 * Math.tan(22.5 * Math.PI / 180)); - mounted.cameraElement.style.perspective = `${Number(perspective.toFixed(4))}px`; - mounted.sceneElement.style.transform = `translateZ(${Number(perspective.toFixed(4))}px)`; - return snapshot(); - } - - function snapshot() { - mounted.assertStableDomIdentity(); - return Object.freeze({ - paused, - frameIndex: lastFrameIndex, - frameCount: runtime.frameCount, - durationMilliseconds, - frameMilliseconds, - timerCallbackCount, - collapsedFrameCount, - applyCount, - modelTransformWrites, - shapeTransformWrites, - visibilityWrites, - atlasRowWrites, - runtimeGeometryConstructionCount: 0, - runtimeAtlasRasterizationCount: 0, - runtimeDomGrowth: false, - retainedDomStable: true, - }); - } - - return Object.freeze({ - resize, - resume() { - if (!paused || destroyed) return snapshot(); - clockOrigin = performance.now() - pausedAt; - paused = false; - schedule(); - return snapshot(); - }, - pause() { - if (paused || destroyed) return snapshot(); - pausedAt = performance.now() - clockOrigin; - paused = true; - cancelScheduled(); - return snapshot(); - }, - seekFrame(frameIndex) { - if (!Number.isSafeInteger(frameIndex) || frameIndex < 0 || frameIndex >= runtime.frameCount) { - throw new RangeError("Platonic Folding prepared frame is out of range"); - } - const wasPaused = paused; - if (!wasPaused) this.pause(); - pausedAt = model.playback.frames[frameIndex].timeMs; - publish(pausedAt); - if (!wasPaused) this.resume(); - return snapshot(); - }, - stats: snapshot, - destroy() { - paused = true; - destroyed = true; - cancelScheduled(); - removeEventListener("resize", resize); - mounted.destroy(); - }, - }); -} - function cleanPreparedDom(mounted) { mounted.cameraElement.className = "polycss-camera"; mounted.cameraElement.removeAttribute("data-polycss-camera-projection"); diff --git a/src/adapters/platonic-folding/src/cssplatonicfolding/preparedPlayback.mjs b/src/adapters/platonic-folding/src/cssplatonicfolding/preparedPlayback.mjs new file mode 100644 index 0000000..2b4ca96 --- /dev/null +++ b/src/adapters/platonic-folding/src/cssplatonicfolding/preparedPlayback.mjs @@ -0,0 +1,286 @@ +import { createPolyMorphPreparedDomTarget } from "@layoutit/polycss-morph"; + +const PLAYBACK_SCHEMA = "cssplatonicfolding-prepared-dom-playback@1"; + +export function createPlatonicPreparedPlayer({ + mounted, + playback, + readNow = () => performance.now(), + requestDelay = (callback, delay) => setTimeout(callback, delay), + cancelDelay = (timer) => clearTimeout(timer), +}) { + validatePlaybackBinding(mounted, playback); + const shapeElements = mounted.model.render.shapes.map((shape) => mounted.shapeElements.get(shape.id)); + const leafElements = mounted.model.render.leaves.map((leaf) => mounted.leafHandles.get(leaf.id)?.element); + if (shapeElements.some((element) => !element) || leafElements.some((element) => !element)) { + throw new Error("Platonic Folding retained DOM binding is incomplete"); + } + + let publishedModelTransform = mounted.modelElement.style.transform; + const target = createPolyMorphPreparedDomTarget({ + model: { + element: mounted.modelElement, + writeTransform(transform) { + if (transform === publishedModelTransform) return false; + mounted.modelElement.style.transform = transform; + publishedModelTransform = transform; + return true; + }, + }, + shapes: shapeElements.map((element) => ({ element })), + leaves: leafElements.map((element) => ({ element })), + }); + const desiredShapeTransforms = Int32Array.from(playback.mounted.shapeTransformIndices); + const publishedShapeTransforms = Int32Array.from(playback.mounted.shapeTransformIndices); + const desiredAtlasRows = Int16Array.from(playback.mounted.atlasRows); + const publishedAtlasRows = Int16Array.from(playback.mounted.atlasRows); + const desiredVisibility = Uint8Array.from(playback.mounted.visibility); + const publishedVisibility = Uint8Array.from(playback.mounted.visibility); + let desiredModelTransform = playback.mounted.modelTransformIndex; + let publishedModelTransformIndex = playback.mounted.modelTransformIndex; + let paused = true; + let destroyed = false; + let timer = null; + let clockOrigin = readNow(); + let pausedAt = 0; + let frameIndex = -1; + let timerCallbackCount = 0; + let collapsedFrameCount = 0; + let applyCount = 0; + let modelTransformWrites = 0; + let shapeTransformWrites = 0; + let visibilityWrites = 0; + let atlasRowWrites = 0; + let runtimeCatchupFullStateScanCount = 0; + + applyFrameZero(); + + function applyFrameZero() { + applyRow(playback.frames[0], true); + frameIndex = 0; + applyCount += 1; + } + + function applyRow(row, publish) { + for (const leafIndex of row[3]) { + desiredVisibility[leafIndex] = 0; + if (!publish) continue; + visibilityWrites += Number(target.leaves[leafIndex].writeVisibility(false)); + publishedVisibility[leafIndex] = 0; + } + + if (row[0] >= 0) { + desiredModelTransform = row[0]; + if (publish) publishModelTransform(); + } + + for (let operation = 0; operation < row[1].length; operation += 2) { + const shapeIndex = row[1][operation]; + const transformIndex = row[1][operation + 1]; + desiredShapeTransforms[shapeIndex] = transformIndex; + if (!publish || publishedVisibility[shapeIndex] === 0) continue; + shapeTransformWrites += Number(target.shapes[shapeIndex].writeTransform(playback.transforms[transformIndex])); + publishedShapeTransforms[shapeIndex] = transformIndex; + } + + for (let operation = 0; operation < row[2].length; operation += 2) { + const leafIndex = row[2][operation]; + const atlasRow = row[2][operation + 1]; + desiredAtlasRows[leafIndex] = atlasRow; + if (!publish || publishedVisibility[leafIndex] === 0) continue; + atlasRowWrites += Number(target.leaves[leafIndex].writeImagePositionY(playback.imagePositionYs[atlasRow])); + publishedAtlasRows[leafIndex] = atlasRow; + } + + for (const leafIndex of row[4]) { + desiredVisibility[leafIndex] = 1; + if (!publish) continue; + publishVisibleLeafState(leafIndex); + visibilityWrites += Number(target.leaves[leafIndex].writeVisibility(true)); + publishedVisibility[leafIndex] = 1; + } + } + + function publishModelTransform() { + if (publishedModelTransformIndex === desiredModelTransform) return; + modelTransformWrites += Number(target.model.writeTransform(playback.transforms[desiredModelTransform])); + publishedModelTransformIndex = desiredModelTransform; + } + + function publishVisibleLeafState(leafIndex) { + if (publishedShapeTransforms[leafIndex] !== desiredShapeTransforms[leafIndex]) { + shapeTransformWrites += Number(target.shapes[leafIndex] + .writeTransform(playback.transforms[desiredShapeTransforms[leafIndex]])); + publishedShapeTransforms[leafIndex] = desiredShapeTransforms[leafIndex]; + } + if (publishedAtlasRows[leafIndex] !== desiredAtlasRows[leafIndex]) { + atlasRowWrites += Number(target.leaves[leafIndex] + .writeImagePositionY(playback.imagePositionYs[desiredAtlasRows[leafIndex]])); + publishedAtlasRows[leafIndex] = desiredAtlasRows[leafIndex]; + } + } + + function publishCachedState() { + runtimeCatchupFullStateScanCount += 1; + for (let leafIndex = 0; leafIndex < playback.leafCount; leafIndex += 1) { + if (desiredVisibility[leafIndex] === 0 && publishedVisibility[leafIndex] === 1) { + visibilityWrites += Number(target.leaves[leafIndex].writeVisibility(false)); + publishedVisibility[leafIndex] = 0; + } + } + publishModelTransform(); + for (let leafIndex = 0; leafIndex < playback.leafCount; leafIndex += 1) { + if (desiredVisibility[leafIndex] === 0) continue; + publishVisibleLeafState(leafIndex); + if (publishedVisibility[leafIndex] === 0) { + visibilityWrites += Number(target.leaves[leafIndex].writeVisibility(true)); + publishedVisibility[leafIndex] = 1; + } + } + } + + function advanceTo(nextFrameIndex) { + if (frameIndex === nextFrameIndex) return; + if (frameIndex < 0) { + applyFrameZero(); + if (nextFrameIndex === 0) return; + } + const distance = nextFrameIndex >= frameIndex + ? nextFrameIndex - frameIndex + : playback.frameCount - frameIndex + nextFrameIndex; + if (distance === 1) { + applyRow(nextFrameIndex === 0 ? playback.wrap : playback.frames[nextFrameIndex], true); + } else { + let cursor = frameIndex; + for (let step = 0; step < distance; step += 1) { + cursor = (cursor + 1) % playback.frameCount; + applyRow(cursor === 0 ? playback.wrap : playback.frames[cursor], false); + } + publishCachedState(); + collapsedFrameCount += distance - 1; + } + frameIndex = nextFrameIndex; + applyCount += 1; + } + + function cancelScheduled() { + if (timer !== null) cancelDelay(timer); + timer = null; + } + + function schedule() { + if (paused || destroyed || timer !== null) return; + const elapsed = Math.max(0, readNow() - clockOrigin); + const phase = elapsed % playback.durationMilliseconds; + const nextFrame = (Math.floor(phase / playback.frameMilliseconds) + 1) * playback.frameMilliseconds; + timer = requestDelay(wake, Math.max(1, Math.ceil(nextFrame - phase))); + } + + function wake() { + timer = null; + if (paused || destroyed) return; + timerCallbackCount += 1; + const elapsed = Math.max(0, readNow() - clockOrigin); + const dueFrame = Math.floor((elapsed % playback.durationMilliseconds) / playback.frameMilliseconds); + advanceTo(dueFrame); + schedule(); + } + + function pause() { + if (paused || destroyed) return snapshot(); + pausedAt = Math.max(0, readNow() - clockOrigin) % playback.durationMilliseconds; + paused = true; + cancelScheduled(); + return snapshot(); + } + + function resume() { + if (!paused || destroyed) return snapshot(); + clockOrigin = readNow() - pausedAt; + paused = false; + schedule(); + return snapshot(); + } + + function seekFrame(nextFrameIndex) { + if (!Number.isSafeInteger(nextFrameIndex) || nextFrameIndex < 0 || nextFrameIndex >= playback.frameCount) { + throw new RangeError("Platonic Folding prepared frame is out of range"); + } + const wasPaused = paused; + if (!wasPaused) pause(); + pausedAt = nextFrameIndex * playback.frameMilliseconds; + advanceTo(nextFrameIndex); + if (!wasPaused) resume(); + return snapshot(); + } + + function resize() { + const perspective = Math.min(innerWidth, innerHeight) / + (2 * Math.tan(22.5 * Math.PI / 180)); + const roundedPerspective = Number(perspective.toFixed(4)); + mounted.cameraElement.style.perspective = `${roundedPerspective}px`; + mounted.sceneElement.style.transform = `translateZ(${roundedPerspective}px)`; + } + + function snapshot() { + target.assertStableDomIdentity(); + return Object.freeze({ + paused, + frameIndex, + frameCount: playback.frameCount, + durationMilliseconds: playback.durationMilliseconds, + frameMilliseconds: playback.frameMilliseconds, + timerCallbackCount, + collapsedFrameCount, + applyCount, + modelTransformWrites, + shapeTransformWrites, + visibilityWrites, + atlasRowWrites, + preparedStateMaterializationCount: 0, + runtimeFullStateDiffCount: 0, + runtimeMatrixFormattingCount: 0, + runtimeIdLookupCount: 0, + runtimeNormalFullStateScanCount: 0, + runtimeCatchupFullStateScanCount, + runtimeHiddenShapeTransformWrites: 0, + runtimeHiddenAtlasRowWrites: 0, + runtimeGeometryConstructionCount: 0, + runtimeAtlasRasterizationCount: 0, + runtimeDomGrowth: false, + retainedDomStable: true, + }); + } + + return Object.freeze({ + resize, + resume, + pause, + seekFrame, + stats: snapshot, + destroy() { + if (destroyed) return; + paused = true; + destroyed = true; + cancelScheduled(); + removeEventListener("resize", resize); + target.destroy(); + mounted.destroy(); + }, + }); +} + +function validatePlaybackBinding(mounted, playback) { + if (playback?.schema !== PLAYBACK_SCHEMA || + playback.modelId !== mounted.model.identity.id || + playback.frameCount !== playback.frames?.length || + playback.frameCount < 1 || + playback.shapeCount !== mounted.model.render.shapes.length || + playback.leafCount !== mounted.model.render.leaves.length || + playback.mounted?.shapeTransformIndices?.length !== playback.shapeCount || + playback.mounted?.atlasRows?.length !== playback.leafCount || + playback.mounted?.visibility?.length !== playback.leafCount || + !Array.isArray(playback.wrap)) { + throw new Error("Platonic Folding prepared playback binding drifted"); + } +} diff --git a/src/adapters/platonic-folding/src/prepare/cssplatonicfolding/modelBuilder.mjs b/src/adapters/platonic-folding/src/prepare/cssplatonicfolding/modelBuilder.mjs index d5076a3..59fadda 100644 --- a/src/adapters/platonic-folding/src/prepare/cssplatonicfolding/modelBuilder.mjs +++ b/src/adapters/platonic-folding/src/prepare/cssplatonicfolding/modelBuilder.mjs @@ -1,13 +1,17 @@ +import { formatMatrix3dValues } from "@layoutit/polycss"; import { + CSSPLATONIC_LIGHT_LEVELS, CSSPLATONIC_RASTER_LEAF_SIZE, buildPlatonicSourceSequence, } from "./sourceModel.mjs"; import { platonicRasterSlice } from "./rasterAtlas.mjs"; +export const CSSPLATONIC_MODEL_ID = "platonic-folding"; + const IDENTITY = Object.freeze([1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1]); -export function buildPlatonicPreparedModel({ bankId = "desktop" } = {}) { - const source = buildPlatonicSourceSequence({ bankId }); +export function buildPlatonicPreparedModel() { + const source = buildPlatonicSourceSequence({ bankId: "desktop" }); const firstFrame = source.frames[0]; const vertices = []; const normals = [Object.freeze([0, 0, 1])]; @@ -45,21 +49,20 @@ export function buildPlatonicPreparedModel({ bankId = "desktop" } = {}) { fallback: null, })); } - const playbackFrames = buildPlaybackFrames(source); const model = deepFreeze({ schema: "polycss-morph.model@1", identity: Object.freeze({ - id: source.bank.modelId, + id: CSSPLATONIC_MODEL_ID, name: source.bank.name, - revision: "1.0.0", + revision: "1.1.0", }), - profile: "prepared-playback", - capabilities: Object.freeze(["prepared-playback", "retained-render", "sparse-updates"]), + profile: "static-prepared", + capabilities: Object.freeze(["retained-render"]), budgets: Object.freeze({ maxVertices: vertices.length, maxPolygons: polygons.length, maxLeaves: leaves.length, - maxFrames: playbackFrames.length, + maxFrames: 0, maxJoints: 0, maxResources: 2, maxBytes: 64 * 1024 * 1024, @@ -74,7 +77,7 @@ export function buildPlatonicPreparedModel({ bankId = "desktop" } = {}) { color: Object.freeze([1, 1, 1, 1]), })]), render: Object.freeze({ - modelMatrix: firstFrame.modelMatrix, + modelMatrix: IDENTITY, shapes: Object.freeze(shapes), leaves: Object.freeze(leaves), }), @@ -82,14 +85,10 @@ export function buildPlatonicPreparedModel({ bankId = "desktop" } = {}) { controls: Object.freeze([]), springs: Object.freeze([]), animations: Object.freeze([]), - playback: Object.freeze({ - durationMs: source.durationMilliseconds, - loop: true, - frames: playbackFrames, - }), + playback: null, provenance: Object.freeze({ generator: "cssplatonicfolding-preparer", - generatorVersion: "1.0.0", + generatorVersion: "1.1.0", sources: Object.freeze([Object.freeze({ id: "xscreensaver-platonicfolding", kind: "open-data", @@ -103,13 +102,10 @@ export function buildPlatonicPreparedModel({ bankId = "desktop" } = {}) { source, model, metrics: Object.freeze({ - bankId, sourceSolidCount: 5, sourceFaceCount: source.faceDefinitions.length, retainedShapeRootCount: shapes.length, retainedPolygonLeafCount: leaves.length, - frameCount: playbackFrames.length, - durationMilliseconds: source.durationMilliseconds, maximumVisibleLeaves: 20, runtimeGeometryConstructionCount: 0, runtimeAtlasRasterizationCount: 0, @@ -118,50 +114,127 @@ export function buildPlatonicPreparedModel({ bankId = "desktop" } = {}) { }); } -function buildPlaybackFrames(source) { - let previous = null; - return Object.freeze(source.frames.map((frame) => { - const shapes = []; - const leaves = []; - for (const face of source.faceDefinitions) { - const active = face.solidId === frame.solidId; - const wasActive = previous?.solidId === face.solidId; - if (active) { - const sample = frame.faces[face.faceIndex]; - const previousSample = wasActive ? previous.faces[face.faceIndex] : null; - if (!previousSample || !matricesEqual(sample.matrix, previousSample.matrix)) { - shapes.push(Object.freeze({ shapeId: face.id, matrix: sample.matrix })); - } - if (!previousSample || sample.lightRow !== previousSample.lightRow || !wasActive) { - leaves.push(Object.freeze({ - leafId: `leaf-${face.id}`, - matrix: null, - visible: wasActive ? null : true, - opacity: null, - atlasRow: sample.lightRow, - })); - } - } else if (!previous || wasActive) { - leaves.push(Object.freeze({ - leafId: `leaf-${face.id}`, - matrix: null, - visible: false, - opacity: null, - atlasRow: null, - })); +export function buildPlatonicPreparedPlayback({ bankId = "desktop" } = {}) { + const source = buildPlatonicSourceSequence({ bankId }); + const firstFrame = source.frames[0]; + const transforms = []; + const transformIndices = new Map(); + const internTransform = (matrix) => { + const transform = `matrix3d(${formatMatrix3dValues(matrix)})`; + const existing = transformIndices.get(transform); + if (existing !== undefined) return existing; + const index = transforms.length; + transforms.push(transform); + transformIndices.set(transform, index); + return index; + }; + const mountedModelTransformIndex = internTransform(IDENTITY); + const mountedShapeTransformIndices = source.faceDefinitions.map((face) => + internTransform(face.solidId === firstFrame.solidId + ? firstFrame.faces[face.faceIndex].matrix + : IDENTITY)); + const shapeTransformIndices = [...mountedShapeTransformIndices]; + const atlasRows = new Array(source.faceDefinitions.length).fill(0); + const visibility = new Array(source.faceDefinitions.length).fill(1); + let modelTransformIndex = mountedModelTransformIndex; + + const buildRow = (frame) => { + const shapeOperations = []; + const atlasOperations = []; + const hideLeaves = []; + const showLeaves = []; + for (let index = 0; index < source.faceDefinitions.length; index += 1) { + const face = source.faceDefinitions[index]; + if (face.solidId !== frame.solidId && visibility[index] === 1) { + hideLeaves.push(index); + visibility[index] = 0; } } - const prepared = Object.freeze({ - timeMs: frame.timeMs, - modelMatrix: previous && matricesEqual(frame.modelMatrix, previous.modelMatrix) - ? null - : frame.modelMatrix, - shapes: Object.freeze(shapes), - leaves: Object.freeze(leaves), - }); - previous = frame; - return prepared; - })); + const nextModelTransformIndex = internTransform(frame.modelMatrix); + const publishedModelTransformIndex = nextModelTransformIndex === modelTransformIndex + ? -1 + : nextModelTransformIndex; + modelTransformIndex = nextModelTransformIndex; + for (let index = 0; index < source.faceDefinitions.length; index += 1) { + const face = source.faceDefinitions[index]; + if (face.solidId !== frame.solidId) continue; + const sample = frame.faces[face.faceIndex]; + const nextShapeTransformIndex = internTransform(sample.matrix); + if (shapeTransformIndices[index] !== nextShapeTransformIndex) { + shapeOperations.push(index, nextShapeTransformIndex); + shapeTransformIndices[index] = nextShapeTransformIndex; + } + if (atlasRows[index] !== sample.lightRow) { + atlasOperations.push(index, sample.lightRow); + atlasRows[index] = sample.lightRow; + } + if (visibility[index] === 0) { + showLeaves.push(index); + visibility[index] = 1; + } + } + return freezeRow([ + publishedModelTransformIndex, + shapeOperations, + atlasOperations, + hideLeaves, + showLeaves, + ]); + }; + + const frames = source.frames.map(buildRow); + const wrap = buildRow(firstFrame); + const imagePositionYs = Object.freeze(Array.from( + { length: CSSPLATONIC_LIGHT_LEVELS }, + (_, row) => `${-row * CSSPLATONIC_RASTER_LEAF_SIZE}px`, + )); + const metrics = operationMetrics(frames, wrap); + const playback = deepFreeze({ + schema: "cssplatonicfolding-prepared-dom-playback@1", + bankId, + modelId: CSSPLATONIC_MODEL_ID, + frameMilliseconds: source.bank.delayMicroseconds / 1_000, + durationMilliseconds: source.durationMilliseconds, + frameCount: frames.length, + shapeCount: source.faceDefinitions.length, + leafCount: source.faceDefinitions.length, + transforms: Object.freeze(transforms), + imagePositionYs, + mounted: Object.freeze({ + modelTransformIndex: mountedModelTransformIndex, + shapeTransformIndices: Object.freeze(mountedShapeTransformIndices), + atlasRows: Object.freeze(new Array(source.faceDefinitions.length).fill(0)), + visibility: Object.freeze(new Array(source.faceDefinitions.length).fill(1)), + }), + frames: Object.freeze(frames), + wrap, + }); + return deepFreeze({ source, playback, metrics }); +} + +function operationMetrics(frames, wrap) { + const rows = [...frames, wrap]; + return Object.freeze({ + preparedFrameCount: frames.length, + modelTransformSelections: rows.reduce((sum, row) => sum + Number(row[0] >= 0), 0), + shapeTransformSelections: rows.reduce((sum, row) => sum + row[1].length / 2, 0), + atlasRowSelections: rows.reduce((sum, row) => sum + row[2].length / 2, 0), + visibilitySelections: rows.reduce((sum, row) => sum + row[3].length + row[4].length, 0), + loopHiddenShapeTransformSelections: wrap[1].filter((_, index) => index % 2 === 0) + .filter((leafIndex) => !wrap[4].includes(leafIndex)).length, + loopHiddenAtlasRowSelections: wrap[2].filter((_, index) => index % 2 === 0) + .filter((leafIndex) => !wrap[4].includes(leafIndex)).length, + }); +} + +function freezeRow(row) { + return Object.freeze([ + row[0], + Object.freeze(row[1]), + Object.freeze(row[2]), + Object.freeze(row[3]), + Object.freeze(row[4]), + ]); } function faceLeafMatrix(face) { @@ -173,10 +246,6 @@ function faceLeafMatrix(face) { ].map((value) => Number(value.toFixed(10)))); } -function matricesEqual(left, right) { - return left.length === right.length && left.every((value, index) => value === right[index]); -} - function deepFreeze(value) { if (value && typeof value === "object" && !Object.isFrozen(value)) { Object.freeze(value); diff --git a/src/adapters/platonic-folding/src/prepare/cssplatonicfolding/sourceModel.mjs b/src/adapters/platonic-folding/src/prepare/cssplatonicfolding/sourceModel.mjs index 75bd945..19d894b 100644 --- a/src/adapters/platonic-folding/src/prepare/cssplatonicfolding/sourceModel.mjs +++ b/src/adapters/platonic-folding/src/prepare/cssplatonicfolding/sourceModel.mjs @@ -52,7 +52,6 @@ export const PLATONIC_BANKS = Object.freeze({ desktop: Object.freeze({ ...PLATONIC_SOURCE, id: "desktop", - modelId: "platonic-folding-desktop", name: "Platonic Folding", entryAxis: "vertical", eyeDistanceScale: 1, @@ -60,7 +59,6 @@ export const PLATONIC_BANKS = Object.freeze({ mobile: Object.freeze({ ...PLATONIC_SOURCE, id: "mobile", - modelId: "platonic-folding-mobile", name: "Platonic Folding", entryAxis: "horizontal", eyeDistanceScale: 1.55, diff --git a/src/adapters/platonic-folding/test/cssplatonicfolding-shell.test.mjs b/src/adapters/platonic-folding/test/cssplatonicfolding-shell.test.mjs index cef5d93..2b1ccaa 100644 --- a/src/adapters/platonic-folding/test/cssplatonicfolding-shell.test.mjs +++ b/src/adapters/platonic-folding/test/cssplatonicfolding-shell.test.mjs @@ -5,16 +5,17 @@ import test from "node:test"; const adapterRoot = new URL("../", import.meta.url); test("product shell stays retained-DOM and fast-path clean", async () => { - const [html, client, styles] = await Promise.all([ + const [html, client, playback, styles] = await Promise.all([ readFile(new URL("index.html", adapterRoot), "utf8"), readFile(new URL("src/cssplatonicfolding/client.mjs", adapterRoot), "utf8"), + readFile(new URL("src/cssplatonicfolding/preparedPlayback.mjs", adapterRoot), "utf8"), readFile(new URL("src/cssplatonicfolding/styles.css", adapterRoot), "utf8"), ]); assert.doesNotMatch(html, /<(?:main|canvas)\b/u); - assert.doesNotMatch(client, /(?:WebGL|WebGPU|CanvasRenderingContext|createElement\(["']canvas)/u); + assert.doesNotMatch(`${client}\n${playback}`, /(?:WebGL|WebGPU|CanvasRenderingContext|createElement\(["']canvas)/u); assert.doesNotMatch(styles, /(?:clip-path|mask-image|filter:|box-shadow|text-shadow|gradient\()/u); assert.match(client, /mountPolyMorphModel/u); - assert.match(client, /createPolyMorphPlaybackRuntime/u); - assert.match(client, /setTimeout\(wake/u); - assert.doesNotMatch(client, /requestAnimationFrame/u); + assert.match(playback, /createPolyMorphPreparedDomTarget/u); + assert.match(playback, /setTimeout\(callback, delay\)/u); + assert.doesNotMatch(`${client}\n${playback}`, /(?:createPolyMorphPlaybackRuntime|mounted\.apply|requestAnimationFrame)/u); }); diff --git a/src/adapters/platonic-folding/test/cssplatonicfolding-source-model.test.mjs b/src/adapters/platonic-folding/test/cssplatonicfolding-source-model.test.mjs index cfee52d..15158d4 100644 --- a/src/adapters/platonic-folding/test/cssplatonicfolding-source-model.test.mjs +++ b/src/adapters/platonic-folding/test/cssplatonicfolding-source-model.test.mjs @@ -1,7 +1,11 @@ import assert from "node:assert/strict"; import test from "node:test"; +import { formatMatrix3dValues } from "@layoutit/polycss"; import { validatePolyMorphModel } from "@layoutit/polycss-morph"; -import { buildPlatonicPreparedModel } from "../src/prepare/cssplatonicfolding/modelBuilder.mjs"; +import { + buildPlatonicPreparedModel, + buildPlatonicPreparedPlayback, +} from "../src/prepare/cssplatonicfolding/modelBuilder.mjs"; import { CSSPLATONIC_FRAME_MILLISECONDS, buildPlatonicSourceSequence, @@ -46,23 +50,83 @@ test("prepared lighting preserves source specular and perspective-facing back si } }); -test("Morph package retains one raster leaf per source face", () => { +test("static Morph package retains one raster leaf per source face", () => { const prepared = buildPlatonicPreparedModel(); const model = validatePolyMorphModel(prepared.model); + assert.equal(model.profile, "static-prepared"); assert.equal(model.render.shapes.length, 50); assert.equal(model.render.leaves.length, 50); assert.ok(model.render.leaves.every((leaf) => leaf.strategy === "atlas-slice")); assert.ok(model.render.leaves.every((leaf) => leaf.atlas?.resourcePath === "assets/face-colors.png")); - assert.equal(model.playback.frames.length, 2_710); - assert.equal(model.playback.frames[0].leaves.length, 50); + assert.equal(model.playback, null); assert.equal(prepared.metrics.maximumVisibleLeaves, 20); assert.equal(prepared.metrics.runtimeGeometryConstructionCount, 0); assert.equal(prepared.metrics.runtimeDomGrowth, false); }); -test("mobile changes travel direction without changing topology", () => { - const desktop = buildPlatonicPreparedModel({ bankId: "desktop" }); - const mobile = buildPlatonicPreparedModel({ bankId: "mobile" }); - assert.equal(desktop.model.render.leaves.length, mobile.model.render.leaves.length); - assert.notDeepEqual(desktop.model.playback.frames[0].modelMatrix, mobile.model.playback.frames[0].modelMatrix); +test("prepared DOM playback contains sparse source-ordered operations only", () => { + const { playback, metrics } = buildPlatonicPreparedPlayback(); + assert.equal(playback.frames.length, 2_710); + assert.equal(playback.frames[0][3].length, 30); + assert.equal(playback.shapeCount, 50); + assert.equal(playback.leafCount, 50); + assert.equal(metrics.loopHiddenShapeTransformSelections, 0); + assert.equal(metrics.loopHiddenAtlasRowSelections, 0); + assert.ok(playback.frames.every((row) => row.length === 5)); + assert.ok(playback.frames.every((row) => row[1].length % 2 === 0 && row[2].length % 2 === 0)); + assert.ok(playback.transforms.every((transform) => /^matrix3d\([^)]+\)$/u.test(transform))); }); + +test("sparse playback reconstructs every visible source frame and the loop boundary", () => { + const { source, playback } = buildPlatonicPreparedPlayback(); + let modelTransformIndex = playback.mounted.modelTransformIndex; + const shapeTransformIndices = [...playback.mounted.shapeTransformIndices]; + const atlasRows = [...playback.mounted.atlasRows]; + const visibility = [...playback.mounted.visibility]; + for (let frameIndex = 0; frameIndex < playback.frameCount; frameIndex += 1) { + applyRow(playback.frames[frameIndex]); + assertFrame(source.frames[frameIndex]); + } + applyRow(playback.wrap); + assertFrame(source.frames[0]); + + function applyRow(row) { + for (const leafIndex of row[3]) visibility[leafIndex] = 0; + if (row[0] >= 0) modelTransformIndex = row[0]; + for (let index = 0; index < row[1].length; index += 2) { + shapeTransformIndices[row[1][index]] = row[1][index + 1]; + } + for (let index = 0; index < row[2].length; index += 2) { + atlasRows[row[2][index]] = row[2][index + 1]; + } + for (const leafIndex of row[4]) visibility[leafIndex] = 1; + } + + function assertFrame(frame) { + assert.equal(playback.transforms[modelTransformIndex], matrixText(frame.modelMatrix)); + for (let faceIndex = 0; faceIndex < source.faceDefinitions.length; faceIndex += 1) { + const face = source.faceDefinitions[faceIndex]; + const active = face.solidId === frame.solidId; + assert.equal(visibility[faceIndex], Number(active)); + if (!active) continue; + const sample = frame.faces[face.faceIndex]; + assert.equal(playback.transforms[shapeTransformIndices[faceIndex]], matrixText(sample.matrix)); + assert.equal(atlasRows[faceIndex], sample.lightRow); + } + } +}); + +test("mobile changes travel direction without changing prepared topology", () => { + const desktop = buildPlatonicPreparedPlayback({ bankId: "desktop" }).playback; + const mobile = buildPlatonicPreparedPlayback({ bankId: "mobile" }).playback; + assert.equal(desktop.shapeCount, mobile.shapeCount); + assert.equal(desktop.leafCount, mobile.leafCount); + assert.notEqual( + desktop.transforms[desktop.frames[0][0]], + mobile.transforms[mobile.frames[0][0]], + ); +}); + +function matrixText(matrix) { + return `matrix3d(${formatMatrix3dValues(matrix)})`; +} diff --git a/src/adapters/platonic-folding/tools/check-prepare-determinism.mjs b/src/adapters/platonic-folding/tools/check-prepare-determinism.mjs index 6ba7cfd..3064830 100644 --- a/src/adapters/platonic-folding/tools/check-prepare-determinism.mjs +++ b/src/adapters/platonic-folding/tools/check-prepare-determinism.mjs @@ -1,16 +1,23 @@ #!/usr/bin/env node import assert from "node:assert/strict"; import { createHash } from "node:crypto"; -import { buildPlatonicPreparedModel } from "../src/prepare/cssplatonicfolding/modelBuilder.mjs"; +import { + buildPlatonicPreparedModel, + buildPlatonicPreparedPlayback, +} from "../src/prepare/cssplatonicfolding/modelBuilder.mjs"; import { buildPlatonicRasterAtlas } from "../src/prepare/cssplatonicfolding/rasterAtlas.mjs"; +const firstModel = buildPlatonicPreparedModel(); +const secondModel = buildPlatonicPreparedModel(); +assert.equal(hash(JSON.stringify(firstModel.model)), hash(JSON.stringify(secondModel.model))); +assert.deepEqual(firstModel.metrics, secondModel.metrics); for (const bankId of ["desktop", "mobile"]) { - const first = buildPlatonicPreparedModel({ bankId }); - const second = buildPlatonicPreparedModel({ bankId }); - assert.equal(hash(JSON.stringify(first.model)), hash(JSON.stringify(second.model))); + const first = buildPlatonicPreparedPlayback({ bankId }); + const second = buildPlatonicPreparedPlayback({ bankId }); + assert.equal(hash(JSON.stringify(first.playback)), hash(JSON.stringify(second.playback))); assert.deepEqual(first.metrics, second.metrics); } -const source = buildPlatonicPreparedModel().source; +const source = firstModel.source; assert.equal( hash(buildPlatonicRasterAtlas(source.faceDefinitions)), hash(buildPlatonicRasterAtlas(source.faceDefinitions)), diff --git a/src/adapters/platonic-folding/tools/prepare-cssplatonicfolding.mjs b/src/adapters/platonic-folding/tools/prepare-cssplatonicfolding.mjs index da7ebb3..cdf057c 100644 --- a/src/adapters/platonic-folding/tools/prepare-cssplatonicfolding.mjs +++ b/src/adapters/platonic-folding/tools/prepare-cssplatonicfolding.mjs @@ -5,7 +5,11 @@ import { mkdir, readFile, rename, rm, writeFile } from "node:fs/promises"; import { dirname, join, resolve } from "node:path"; import { fileURLToPath } from "node:url"; import { buildPolyMorphCatalog, buildPolyMorphPackage } from "@layoutit/polycss-morph"; -import { buildPlatonicPreparedModel } from "../src/prepare/cssplatonicfolding/modelBuilder.mjs"; +import { + CSSPLATONIC_MODEL_ID, + buildPlatonicPreparedModel, + buildPlatonicPreparedPlayback, +} from "../src/prepare/cssplatonicfolding/modelBuilder.mjs"; import { buildPlatonicRasterAtlas, CSSPLATONIC_RASTER_ATLAS_PATH, @@ -23,47 +27,57 @@ await assertSourceIdentity(); await rm(stagingRoot, { recursive: true, force: true }); await mkdir(stagingRoot, { recursive: true }); -const firstPrepared = buildPlatonicPreparedModel({ bankId: "desktop" }); -const atlasBytes = buildPlatonicRasterAtlas(firstPrepared.source.faceDefinitions); -const catalogEntries = []; +const preparedModel = buildPlatonicPreparedModel(); +const atlasBytes = buildPlatonicRasterAtlas(preparedModel.source.faceDefinitions); +const built = await buildPolyMorphPackage(preparedModel.model, [{ + path: CSSPLATONIC_RASTER_ATLAS_PATH, + role: "image", + mediaType: "image/png", + bytes: atlasBytes, +}]); +const packageRoot = join(stagingRoot, "model", CSSPLATONIC_MODEL_ID); +await mkdir(packageRoot, { recursive: true }); +for (const [path, bytes] of built.files) await writeBytes(join(packageRoot, path), bytes); +await writeBytes(join(packageRoot, "manifest.json"), built.manifestBytes); +const catalog = await buildPolyMorphCatalog(CSSPLATONIC_MODEL_ID, [{ + manifest: built.manifest, + manifestPath: `${CSSPLATONIC_MODEL_ID}/manifest.json`, + manifestSha256: built.manifestSha256, +}]); +await writeBytes(join(stagingRoot, "model", "catalog.json"), catalog.bytes); + const preparedBanks = []; for (const bank of Object.values(PLATONIC_BANKS)) { - const prepared = bank.id === "desktop" ? firstPrepared : buildPlatonicPreparedModel({ bankId: bank.id }); - const built = await buildPolyMorphPackage(prepared.model, [{ - path: CSSPLATONIC_RASTER_ATLAS_PATH, - role: "image", - mediaType: "image/png", - bytes: atlasBytes, - }]); - const packageRoot = join(stagingRoot, "model", bank.modelId); - await mkdir(packageRoot, { recursive: true }); - for (const [path, bytes] of built.files) await writeBytes(join(packageRoot, path), bytes); - await writeBytes(join(packageRoot, "manifest.json"), built.manifestBytes); - catalogEntries.push({ - manifest: built.manifest, - manifestPath: `${bank.modelId}/manifest.json`, - manifestSha256: built.manifestSha256, - }); + const prepared = buildPlatonicPreparedPlayback({ bankId: bank.id }); + const playbackPath = `banks/${bank.id}/playback.json`; + const playbackBytes = Buffer.from(`${JSON.stringify(prepared.playback)}\n`); + await writeBytes(join(stagingRoot, playbackPath), playbackBytes); preparedBanks.push({ id: bank.id, - modelId: bank.modelId, + modelId: CSSPLATONIC_MODEL_ID, ...prepared.metrics, manifestSha256: built.manifestSha256, + playbackPath: `/cssplatonicfolding/${playbackPath}`, + playbackSha256: createHash("sha256").update(playbackBytes).digest("hex"), + playbackBytes: playbackBytes.byteLength, }); } -const catalog = await buildPolyMorphCatalog(PLATONIC_BANKS.desktop.modelId, catalogEntries); -await writeBytes(join(stagingRoot, "model", "catalog.json"), catalog.bytes); await writeJson(join(stagingRoot, "prepared.json"), { schema: "cssplatonicfolding-prepared-scene@1", status: "ready", source: sourceLock, renderer: { package: "@layoutit/polycss-morph", - profile: "prepared-playback", - representation: "retained-source-face-roots-with-prepared-raster-lighting", + profile: "static-prepared", + representation: "retained-source-face-roots-with-sparse-prepared-dom-playback", + morphTarget: "createPolyMorphPreparedDomTarget", runtimeGeometryConstruction: false, runtimeAtlasRasterization: false, runtimeDomGrowth: false, + runtimePreparedStateMaterialization: false, + runtimeFullStateDiff: false, + runtimeMatrixFormatting: false, + runtimeIdLookup: false, }, presentation: { frameMilliseconds: PLATONIC_SOURCE.delayMicroseconds / 1_000, diff --git a/src/adapters/platonic-folding/tools/smoke-browser.mjs b/src/adapters/platonic-folding/tools/smoke-browser.mjs index 0f1d721..f2f3383 100644 --- a/src/adapters/platonic-folding/tools/smoke-browser.mjs +++ b/src/adapters/platonic-folding/tools/smoke-browser.mjs @@ -67,17 +67,38 @@ async function capture({ profile, width, height, bankId }) { }); const visibleLeaves = await page.evaluate(() => [...document.querySelectorAll(".polycss-scene s")] .filter((leaf) => getComputedStyle(leaf).visibility !== "hidden").length); + const loop = await page.evaluate(() => { + window.__cssPlatonicFoldingDebug.seekFrame(2709); + window.__cssPlatonicFoldingDebug.seekFrame(0); + return { + stats: window.__cssPlatonicFoldingDebug.stats(), + visibleLeaves: [...document.querySelectorAll(".polycss-scene s")] + .filter((leaf) => getComputedStyle(leaf).visibility !== "hidden").length, + }; + }); if (errors.length || before.errors.length || before.status !== "ready" || !before.ready || before.stats?.selectedPreparedBank !== bankId || before.stats?.retainedFaceRootCount !== 50 || before.leafCount !== 50 || visibleLeaves !== 20 || before.canvasCount !== 0 || before.svgSceneCount !== 0 || before.stats?.runtimeGeometryConstructionCount !== 0 || before.stats?.runtimeAtlasRasterizationCount !== 0 || before.stats?.runtimeDomGrowth !== false || + before.stats?.preparedStateMaterializationCount !== 0 || + before.stats?.runtimeFullStateDiffCount !== 0 || + before.stats?.runtimeMatrixFormattingCount !== 0 || + before.stats?.runtimeIdLookupCount !== 0 || + before.stats?.runtimeNormalFullStateScanCount !== 0 || + before.stats?.runtimeHiddenShapeTransformWrites !== 0 || + before.stats?.runtimeHiddenAtlasRowWrites !== 0 || before.stats?.retainedDomStable !== true || + loop.visibleLeaves !== 20 || loop.stats?.frameIndex !== 0 || + loop.stats?.runtimeNormalFullStateScanCount !== 0 || + loop.stats?.runtimeHiddenShapeTransformWrites !== 0 || + loop.stats?.runtimeHiddenAtlasRowWrites !== 0 || after.stats?.timerCallbackCount <= before.stats?.timerCallbackCount || after.stats?.applyCount <= before.stats?.applyCount || before.modelTransform === after.modelTransform) { - throw new Error(`Platonic Folding ${profile} browser smoke failed: ${JSON.stringify({ before, after, visibleLeaves, errors })}`); + throw new Error(`Platonic Folding ${profile} browser smoke failed: ${JSON.stringify({ before, after, loop, visibleLeaves, errors })}`); } + await page.evaluate(() => window.__cssPlatonicFoldingDebug.seekFrame(270)); const screenshotPath = resolve(outputRoot, `${profile}.png`); await page.screenshot({ path: screenshotPath }); await page.close(); From f0d05440c6d783cf01ce780870ff95a830a211a6 Mon Sep 17 00:00:00 2001 From: alowpoly Date: Tue, 18 Aug 2026 08:45:00 -0300 Subject: [PATCH 3/5] fix(platonic-folding): wire deploy preview --- netlify.toml | 12 +++++- package.json | 2 +- src/adapters/platonic-folding/README.md | 5 +++ .../tools/prepare-cssplatonicfolding.mjs | 43 ++++++++++++++----- src/adapters/platonic-folding/vite.config.mjs | 26 +++++++++-- 5 files changed, 71 insertions(+), 17 deletions(-) diff --git a/netlify.toml b/netlify.toml index beeff31..c04e2c4 100644 --- a/netlify.toml +++ b/netlify.toml @@ -58,7 +58,7 @@ Cache-Control = "public, max-age=31536000, immutable" [[headers]] - for = "/csssolitaire/assets/*" +for = "/csssolitaire/assets/*" [headers.values] Cache-Control = "public, max-age=31536000, immutable" @@ -67,6 +67,16 @@ [headers.values] Cache-Control = "public, max-age=0, must-revalidate" +[[headers]] + for = "/cssplatonicfolding/model/*/assets/*" + [headers.values] + Cache-Control = "public, max-age=31536000, immutable" + +[[headers]] + for = "/cssplatonicfolding/*" + [headers.values] + Cache-Control = "public, max-age=0, must-revalidate" + [[headers]] for = "/cssselectropaint/variants/*/chunks/*.gz" [headers.values] diff --git a/package.json b/package.json index 76acd1b..4f9f80b 100644 --- a/package.json +++ b/package.json @@ -69,7 +69,7 @@ "build:solitaire": "vite build --config src/adapters/solitaire/vite.config.mjs", "build:solitaire:full": "pnpm prepare:solitaire && pnpm build:solitaire", "build:solitaire:deploy": "CSSSOLITAIRE_DEPLOY_BUILD=1 pnpm build:solitaire", - "build:deploy": "pnpm exec playwright install chromium && pnpm prepare:3dpipes && pnpm prepare:flowerbox:artifact && pnpm prepare:gears:artifact && pnpm prepare:gravitywell:artifact && pnpm prepare:menger:artifact && pnpm prepare:maze:artifact && pnpm prepare:electropaint:deploy && pnpm prepare:solitaire && CSSPIPES_DEPLOY_BUILD=1 pnpm build:3dpipes && CSSFLOWER_DEPLOY_BUILD=1 pnpm build:flowerbox && CSSGEARS_DEPLOY_BUILD=1 pnpm build:gears && CSSGRAVITYWELL_DEPLOY_BUILD=1 pnpm build:gravitywell && CSSMENGER_DEPLOY_BUILD=1 pnpm build:menger && CSSMAZE_DEPLOY_BUILD=1 pnpm build:maze && CSSSELECTROPAINT_DEPLOY_BUILD=1 pnpm build:electropaint && CSSSOLITAIRE_DEPLOY_BUILD=1 pnpm build:solitaire && CSSGRAPHICS_DEPLOY_BUILD=1 pnpm build:site", + "build:deploy": "pnpm exec playwright install chromium && pnpm prepare:3dpipes && pnpm prepare:flowerbox:artifact && pnpm prepare:gears:artifact && pnpm prepare:gravitywell:artifact && pnpm prepare:menger:artifact && pnpm prepare:maze:artifact && pnpm prepare:electropaint:deploy && pnpm prepare:platonic-folding && pnpm prepare:solitaire && CSSPIPES_DEPLOY_BUILD=1 pnpm build:3dpipes && CSSFLOWER_DEPLOY_BUILD=1 pnpm build:flowerbox && CSSGEARS_DEPLOY_BUILD=1 pnpm build:gears && CSSGRAVITYWELL_DEPLOY_BUILD=1 pnpm build:gravitywell && CSSMENGER_DEPLOY_BUILD=1 pnpm build:menger && CSSMAZE_DEPLOY_BUILD=1 pnpm build:maze && CSSSELECTROPAINT_DEPLOY_BUILD=1 pnpm build:electropaint && CSSPLATONICFOLDING_DEPLOY_BUILD=1 pnpm build:platonic-folding && CSSSOLITAIRE_DEPLOY_BUILD=1 pnpm build:solitaire && CSSGRAPHICS_DEPLOY_BUILD=1 pnpm build:site", "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", diff --git a/src/adapters/platonic-folding/README.md b/src/adapters/platonic-folding/README.md index 56e82ca..0a48f87 100644 --- a/src/adapters/platonic-folding/README.md +++ b/src/adapters/platonic-folding/README.md @@ -16,6 +16,11 @@ pnpm build:platonic-folding pnpm oracle:platonic-folding ``` +Local preparation verifies the exact checkout when +`CSSPLATONICFOLDING_SOURCE_ROOT` is set. Release builds fetch the same two files +from the pinned GitHub revision and verify their locked SHA-256 identities +before producing anything. + Runtime owns only the prepared playback clock, source-ordered sparse DOM publication through `createPolyMorphPreparedDomTarget`, and responsive perspective. Normal playback consumes preformatted transform and atlas-row diff --git a/src/adapters/platonic-folding/tools/prepare-cssplatonicfolding.mjs b/src/adapters/platonic-folding/tools/prepare-cssplatonicfolding.mjs index cdf057c..78aa9e3 100644 --- a/src/adapters/platonic-folding/tools/prepare-cssplatonicfolding.mjs +++ b/src/adapters/platonic-folding/tools/prepare-cssplatonicfolding.mjs @@ -20,7 +20,7 @@ const adapterRoot = resolve(dirname(fileURLToPath(import.meta.url)), ".."); const repositoryRoot = resolve(adapterRoot, "..", "..", ".."); const outputRoot = join(repositoryRoot, "build/generated/public/cssplatonicfolding"); const stagingRoot = join(repositoryRoot, `build/generated/.cssplatonicfolding-${process.pid}`); -const sourceRoot = resolveRequiredSourceRoot(); +const sourceRoot = resolveSourceRoot(); const sourceLock = JSON.parse(await readFile(join(adapterRoot, "notes/references/source-lock.json"), "utf8")); await assertSourceIdentity(); @@ -96,26 +96,47 @@ await mkdir(dirname(outputRoot), { recursive: true }); await rename(stagingRoot, outputRoot); console.log(JSON.stringify({ outputRoot, atlasBytes: atlasBytes.byteLength, preparedBanks }, null, 2)); -function resolveRequiredSourceRoot() { +function resolveSourceRoot() { const value = process.env.CSSPLATONICFOLDING_SOURCE_ROOT; - if (!value) throw new Error("Set CSSPLATONICFOLDING_SOURCE_ROOT to the pinned XScreenSaver checkout"); - return resolve(value); + return value ? resolve(value) : null; } async function assertSourceIdentity() { - const head = spawnSync("git", ["-C", sourceRoot, "rev-parse", "HEAD"], { encoding: "utf8" }); - if (head.error) throw head.error; - if (head.status !== 0) throw new Error(`Unable to resolve Platonic Folding source revision:\n${head.stderr || head.stdout}`); - const revision = head.stdout.trim(); - if (revision !== sourceLock.revision || revision !== PLATONIC_SOURCE.commit) { - throw new Error(`Platonic Folding source revision drifted: ${revision}`); + if (sourceLock.revision !== PLATONIC_SOURCE.commit) { + throw new Error("Platonic Folding source lock revision drifted"); + } + if (sourceRoot) { + const head = spawnSync("git", ["-C", sourceRoot, "rev-parse", "HEAD"], { encoding: "utf8" }); + if (head.error) throw head.error; + if (head.status !== 0) throw new Error(`Unable to resolve Platonic Folding source revision:\n${head.stderr || head.stdout}`); + const revision = head.stdout.trim(); + if (revision !== sourceLock.revision) { + throw new Error(`Platonic Folding source revision drifted: ${revision}`); + } } for (const input of [sourceLock.primary, sourceLock.config]) { - const actual = createHash("sha256").update(await readFile(join(sourceRoot, input.path))).digest("hex"); + const bytes = sourceRoot + ? await readFile(join(sourceRoot, input.path)) + : await fetchPinnedSource(input.path); + const actual = createHash("sha256").update(bytes).digest("hex"); if (actual !== input.sha256) throw new Error(`Platonic Folding source bytes drifted: ${input.path}`); } } +async function fetchPinnedSource(path) { + const repositoryPath = new URL(sourceLock.repository).pathname.replace(/^\//u, "").replace(/\/$/u, ""); + const url = `https://raw.githubusercontent.com/${repositoryPath}/${sourceLock.revision}/${path}`; + const controller = new AbortController(); + const timeout = setTimeout(() => controller.abort(), 30_000); + try { + const response = await fetch(url, { signal: controller.signal }); + if (!response.ok) throw new Error(`Platonic Folding pinned source download failed: ${response.status} ${path}`); + return Buffer.from(await response.arrayBuffer()); + } finally { + clearTimeout(timeout); + } +} + async function writeBytes(path, bytes) { await mkdir(dirname(path), { recursive: true }); await writeFile(path, bytes); diff --git a/src/adapters/platonic-folding/vite.config.mjs b/src/adapters/platonic-folding/vite.config.mjs index 00d9ebe..a88dca2 100644 --- a/src/adapters/platonic-folding/vite.config.mjs +++ b/src/adapters/platonic-folding/vite.config.mjs @@ -1,18 +1,36 @@ -import { resolve } from "node:path"; +import { cp, mkdir, rm } from "node:fs/promises"; +import { dirname, resolve } from "node:path"; import { fileURLToPath } from "node:url"; import { defineConfig } from "vite"; -const adapterRoot = fileURLToPath(new URL(".", import.meta.url)); +const adapterRoot = dirname(fileURLToPath(import.meta.url)); const repositoryRoot = resolve(adapterRoot, "..", "..", ".."); +const generatedPublicDir = resolve( + process.env.CSSPLATONICFOLDING_GENERATED_PUBLIC_DIR ?? resolve(repositoryRoot, "build/generated/public"), +); +const deployBuild = process.env.CSSPLATONICFOLDING_DEPLOY_BUILD === "1"; export default defineConfig({ + base: deployBuild ? "/platonic-folding/" : "/", root: adapterRoot, - publicDir: resolve(repositoryRoot, "build/generated/public"), + publicDir: deployBuild ? false : generatedPublicDir, + plugins: deployBuild ? [{ + name: "cssplatonicfolding-netlify-assets", + async closeBundle() { + await mkdir(resolve(repositoryRoot, "dist/site"), { recursive: true }); + await rm(resolve(repositoryRoot, "dist/site/cssplatonicfolding"), { recursive: true, force: true }); + await cp( + resolve(generatedPublicDir, "cssplatonicfolding"), + resolve(repositoryRoot, "dist/site/cssplatonicfolding"), + { recursive: true, force: true }, + ); + }, + }] : [], server: { host: "127.0.0.1" }, preview: { host: "127.0.0.1" }, build: { target: "es2022", - outDir: resolve(repositoryRoot, "dist/platonic-folding"), + outDir: resolve(repositoryRoot, deployBuild ? "dist/site/platonic-folding" : "dist/platonic-folding"), emptyOutDir: true, }, }); From f45247c276af4cff5686626aca75d6bb03ca058a Mon Sep 17 00:00:00 2001 From: alowpoly Date: Tue, 18 Aug 2026 08:47:46 -0300 Subject: [PATCH 4/5] fix(platonic-folding): preserve atlas cache headers --- netlify.toml | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/netlify.toml b/netlify.toml index c04e2c4..4dca449 100644 --- a/netlify.toml +++ b/netlify.toml @@ -68,14 +68,14 @@ for = "/csssolitaire/assets/*" Cache-Control = "public, max-age=0, must-revalidate" [[headers]] - for = "/cssplatonicfolding/model/*/assets/*" + for = "/cssplatonicfolding/*" [headers.values] - Cache-Control = "public, max-age=31536000, immutable" + Cache-Control = "public, max-age=0, must-revalidate" [[headers]] - for = "/cssplatonicfolding/*" + for = "/cssplatonicfolding/model/*/assets/*" [headers.values] - Cache-Control = "public, max-age=0, must-revalidate" + Cache-Control = "public, max-age=31536000, immutable" [[headers]] for = "/cssselectropaint/variants/*/chunks/*.gz" From 37e7bd8c9ccc71d055f6a0bab354cdc87bd23101 Mon Sep 17 00:00:00 2001 From: alowpoly Date: Tue, 18 Aug 2026 09:16:07 -0300 Subject: [PATCH 5/5] fix(platonic-folding): close prepared face seams --- src/adapters/platonic-folding/README.md | 3 +- .../notes/provenance-bible.md | 5 ++- .../cssplatonicfolding/modelBuilder.mjs | 3 +- .../cssplatonicfolding/rasterAtlas.mjs | 31 ++++++++++++-- .../cssplatonicfolding-source-model.test.mjs | 40 ++++++++++++++++++- 5 files changed, 74 insertions(+), 8 deletions(-) diff --git a/src/adapters/platonic-folding/README.md b/src/adapters/platonic-folding/README.md index 0a48f87..489aaa6 100644 --- a/src/adapters/platonic-folding/README.md +++ b/src/adapters/platonic-folding/README.md @@ -3,7 +3,8 @@ Source-backed XScreenSaver Platonic Folding rendered as 50 retained PolyCSS Morph face roots. Preparation bakes one shared `static-prepared` Morph package, both responsive sparse playback banks, every hinge pose, source-style face -colors, and quantized lighting into one raster atlas. +colors, six-decimal transforms, and quantized lighting with quarter-pixel edge +coverage into one raster atlas. ```sh export CSSPLATONICFOLDING_SOURCE_ROOT=/path/to/xscreensaver diff --git a/src/adapters/platonic-folding/notes/provenance-bible.md b/src/adapters/platonic-folding/notes/provenance-bible.md index 5782cd0..1d80051 100644 --- a/src/adapters/platonic-folding/notes/provenance-bible.md +++ b/src/adapters/platonic-folding/notes/provenance-bible.md @@ -17,8 +17,9 @@ which each solid uses joint folding. All five solids share one `static-prepared` Morph graph: 50 face roots and 50 raster leaves. Only the active solid's 4, 6, 8, 12, or 20 leaves are visible. Preparation owns the unfolding trees, complete 2,710-frame sequence, -source-ordered sparse operation rows, preformatted face matrices, source-style -face colors, quantized baked lighting rows, and atlas. Runtime binds the shared +source-ordered sparse operation rows, six-decimal preformatted face matrices, +source-style face colors, quantized baked lighting rows, and the atlas with +quarter-pixel prepared edge coverage. Runtime binds the shared graph with `createPolyMorphPreparedDomTarget` and owns only the clock, direct prepared operation selection, and responsive perspective. Full prepared-state materialization, full-state diffs, matrix formatting, id lookup, and normal-path diff --git a/src/adapters/platonic-folding/src/prepare/cssplatonicfolding/modelBuilder.mjs b/src/adapters/platonic-folding/src/prepare/cssplatonicfolding/modelBuilder.mjs index 59fadda..5ada9ca 100644 --- a/src/adapters/platonic-folding/src/prepare/cssplatonicfolding/modelBuilder.mjs +++ b/src/adapters/platonic-folding/src/prepare/cssplatonicfolding/modelBuilder.mjs @@ -7,6 +7,7 @@ import { import { platonicRasterSlice } from "./rasterAtlas.mjs"; export const CSSPLATONIC_MODEL_ID = "platonic-folding"; +export const CSSPLATONIC_MATRIX_DECIMALS = 6; const IDENTITY = Object.freeze([1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1]); @@ -120,7 +121,7 @@ export function buildPlatonicPreparedPlayback({ bankId = "desktop" } = {}) { const transforms = []; const transformIndices = new Map(); const internTransform = (matrix) => { - const transform = `matrix3d(${formatMatrix3dValues(matrix)})`; + const transform = `matrix3d(${formatMatrix3dValues(matrix, CSSPLATONIC_MATRIX_DECIMALS)})`; const existing = transformIndices.get(transform); if (existing !== undefined) return existing; const index = transforms.length; diff --git a/src/adapters/platonic-folding/src/prepare/cssplatonicfolding/rasterAtlas.mjs b/src/adapters/platonic-folding/src/prepare/cssplatonicfolding/rasterAtlas.mjs index 5b59e3a..804d4ca 100644 --- a/src/adapters/platonic-folding/src/prepare/cssplatonicfolding/rasterAtlas.mjs +++ b/src/adapters/platonic-folding/src/prepare/cssplatonicfolding/rasterAtlas.mjs @@ -8,6 +8,7 @@ export const CSSPLATONIC_RASTER_ATLAS_PATH = "assets/face-colors.png"; const GUTTER = 1; const STRIDE = CSSPLATONIC_RASTER_LEAF_SIZE + GUTTER * 2; +const EDGE_FEATHER_PIXELS = 0.25; export function platonicRasterSlice(faceColumn, faceCount) { return Object.freeze({ @@ -61,17 +62,24 @@ function paintFace(png, face, lightRow) { } function pixelCoverage(polygon, x, y) { - let hits = 0; + let coverage = 0; for (const offsetY of [0.25, 0.75]) { for (const offsetX of [0.25, 0.75]) { const point = [ (x + offsetX) / CSSPLATONIC_RASTER_LEAF_SIZE, (y + offsetY) / CSSPLATONIC_RASTER_LEAF_SIZE, ]; - if (pointInPolygon(point, polygon)) hits += 1; + coverage += pointCoverage(point, polygon); } } - return hits / 4; + return coverage / 4; +} + +function pointCoverage(point, polygon) { + if (pointInPolygon(point, polygon)) return 1; + const distance = pointPolygonEdgeDistance(point, polygon) * CSSPLATONIC_RASTER_LEAF_SIZE; + if (distance >= EDGE_FEATHER_PIXELS) return 0; + return 0.5 * (1 - distance / EDGE_FEATHER_PIXELS); } function pointInPolygon([x, y], polygon) { @@ -83,3 +91,20 @@ function pointInPolygon([x, y], polygon) { } return inside; } + +function pointPolygonEdgeDistance(point, polygon) { + let minimum = Infinity; + for (let current = 0, previous = polygon.length - 1; current < polygon.length; previous = current++) { + minimum = Math.min(minimum, pointSegmentDistance(point, polygon[previous], polygon[current])); + } + return minimum; +} + +function pointSegmentDistance([x, y], [x1, y1], [x2, y2]) { + const dx = x2 - x1; + const dy = y2 - y1; + const lengthSquared = dx * dx + dy * dy; + if (lengthSquared === 0) return Math.hypot(x - x1, y - y1); + const t = Math.max(0, Math.min(1, ((x - x1) * dx + (y - y1) * dy) / lengthSquared)); + return Math.hypot(x - (x1 + t * dx), y - (y1 + t * dy)); +} diff --git a/src/adapters/platonic-folding/test/cssplatonicfolding-source-model.test.mjs b/src/adapters/platonic-folding/test/cssplatonicfolding-source-model.test.mjs index 15158d4..b8305b0 100644 --- a/src/adapters/platonic-folding/test/cssplatonicfolding-source-model.test.mjs +++ b/src/adapters/platonic-folding/test/cssplatonicfolding-source-model.test.mjs @@ -2,12 +2,16 @@ import assert from "node:assert/strict"; import test from "node:test"; import { formatMatrix3dValues } from "@layoutit/polycss"; import { validatePolyMorphModel } from "@layoutit/polycss-morph"; +import { PNG } from "pngjs"; import { + CSSPLATONIC_MATRIX_DECIMALS, buildPlatonicPreparedModel, buildPlatonicPreparedPlayback, } from "../src/prepare/cssplatonicfolding/modelBuilder.mjs"; +import { buildPlatonicRasterAtlas } from "../src/prepare/cssplatonicfolding/rasterAtlas.mjs"; import { CSSPLATONIC_FRAME_MILLISECONDS, + CSSPLATONIC_RASTER_LEAF_SIZE, buildPlatonicSourceSequence, platonicSolids, } from "../src/prepare/cssplatonicfolding/sourceModel.mjs"; @@ -64,6 +68,30 @@ test("static Morph package retains one raster leaf per source face", () => { assert.equal(prepared.metrics.runtimeDomGrowth, false); }); +test("prepared raster coverage feathers source face edges", () => { + const source = buildPlatonicSourceSequence(); + const face = source.faceDefinitions[0]; + const atlas = PNG.sync.read(Buffer.from(buildPlatonicRasterAtlas([face]))); + const polygon = face.vertices.map(([x, y]) => [ + (x - face.bounds.minX) / face.bounds.width, + (y - face.bounds.minY) / face.bounds.height, + ]); + let strengthenedEdgePixelCount = 0; + for (let y = 0; y < CSSPLATONIC_RASTER_LEAF_SIZE; y += 1) { + for (let x = 0; x < CSSPLATONIC_RASTER_LEAF_SIZE; x += 1) { + const exactCoverage = [0.25, 0.75].reduce((total, offsetY) => + total + [0.25, 0.75].reduce((rowTotal, offsetX) => + rowTotal + Number(pointInPolygon([ + (x + offsetX) / CSSPLATONIC_RASTER_LEAF_SIZE, + (y + offsetY) / CSSPLATONIC_RASTER_LEAF_SIZE, + ], polygon)), 0), 0); + const alpha = atlas.data[(y * atlas.width + x + 1) * 4 + 3]; + if (alpha > Math.round(exactCoverage / 4 * 255)) strengthenedEdgePixelCount += 1; + } + } + assert.ok(strengthenedEdgePixelCount > 0); +}); + test("prepared DOM playback contains sparse source-ordered operations only", () => { const { playback, metrics } = buildPlatonicPreparedPlayback(); assert.equal(playback.frames.length, 2_710); @@ -128,5 +156,15 @@ test("mobile changes travel direction without changing prepared topology", () => }); function matrixText(matrix) { - return `matrix3d(${formatMatrix3dValues(matrix)})`; + return `matrix3d(${formatMatrix3dValues(matrix, CSSPLATONIC_MATRIX_DECIMALS)})`; +} + +function pointInPolygon([x, y], polygon) { + let inside = false; + for (let current = 0, previous = polygon.length - 1; current < polygon.length; previous = current++) { + const [x1, y1] = polygon[current]; + const [x2, y2] = polygon[previous]; + if ((y1 > y) !== (y2 > y) && x < (x2 - x1) * (y - y1) / (y2 - y1) + x1) inside = !inside; + } + return inside; }