diff --git a/netlify.toml b/netlify.toml index beeff31..4dca449 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/*" + [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 = "/cssselectropaint/variants/*/chunks/*.gz" [headers.values] diff --git a/package.json b/package.json index cdc50de..4f9f80b 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,10 +64,12 @@ "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", - "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", @@ -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..d3818f5 --- /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 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/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..489aaa6 --- /dev/null +++ b/src/adapters/platonic-folding/README.md @@ -0,0 +1,37 @@ +# css.graphics/platonic-folding + +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, six-decimal transforms, and quantized lighting with quarter-pixel edge +coverage 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 +``` + +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 +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 +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..1d80051 --- /dev/null +++ b/src/adapters/platonic-folding/notes/provenance-bible.md @@ -0,0 +1,35 @@ +# 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 `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, 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 +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 +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..e7e5f6d --- /dev/null +++ b/src/adapters/platonic-folding/src/cssplatonicfolding/client.mjs @@ -0,0 +1,119 @@ +import { createPolyPerspectiveCamera } from "@layoutit/polycss"; +import { + 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 }; + installDebugApi(state); + main().catch(fail); + return state; + + async function main() { + const bankId = selectPlatonicBank({ width: host.clientWidth || innerWidth, height: host.clientHeight || innerHeight }); + 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 !== 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, { + resources: loaded.resources, + camera: createPolyPerspectiveCamera({ + perspective: 800, + target: [0, 0, 0], + rotX: 0, + rotY: 0, + zoom: 50, + distance: 0, + }), + }); + cleanPreparedDom(mounted); + const player = createPlatonicPreparedPlayer({ mounted, playback }); + player.resize(); + 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 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/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/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..5ada9ca --- /dev/null +++ b/src/adapters/platonic-folding/src/prepare/cssplatonicfolding/modelBuilder.mjs @@ -0,0 +1,256 @@ +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"; +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]); + +export function buildPlatonicPreparedModel() { + const source = buildPlatonicSourceSequence({ bankId: "desktop" }); + 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 model = deepFreeze({ + schema: "polycss-morph.model@1", + identity: Object.freeze({ + id: CSSPLATONIC_MODEL_ID, + name: source.bank.name, + revision: "1.1.0", + }), + profile: "static-prepared", + capabilities: Object.freeze(["retained-render"]), + budgets: Object.freeze({ + maxVertices: vertices.length, + maxPolygons: polygons.length, + maxLeaves: leaves.length, + maxFrames: 0, + 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: IDENTITY, + shapes: Object.freeze(shapes), + leaves: Object.freeze(leaves), + }), + deformation: Object.freeze({ kind: "none" }), + controls: Object.freeze([]), + springs: Object.freeze([]), + animations: Object.freeze([]), + playback: null, + provenance: Object.freeze({ + generator: "cssplatonicfolding-preparer", + generatorVersion: "1.1.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({ + sourceSolidCount: 5, + sourceFaceCount: source.faceDefinitions.length, + retainedShapeRootCount: shapes.length, + retainedPolygonLeafCount: leaves.length, + maximumVisibleLeaves: 20, + runtimeGeometryConstructionCount: 0, + runtimeAtlasRasterizationCount: 0, + runtimeDomGrowth: false, + }), + }); +} + +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, CSSPLATONIC_MATRIX_DECIMALS)})`; + 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 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) { + 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 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..804d4ca --- /dev/null +++ b/src/adapters/platonic-folding/src/prepare/cssplatonicfolding/rasterAtlas.mjs @@ -0,0 +1,110 @@ +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; +const EDGE_FEATHER_PIXELS = 0.25; + +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 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, + ]; + coverage += pointCoverage(point, polygon); + } + } + 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) { + 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; +} + +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/src/prepare/cssplatonicfolding/sourceModel.mjs b/src/adapters/platonic-folding/src/prepare/cssplatonicfolding/sourceModel.mjs new file mode 100644 index 0000000..19d894b --- /dev/null +++ b/src/adapters/platonic-folding/src/prepare/cssplatonicfolding/sourceModel.mjs @@ -0,0 +1,706 @@ +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", + name: "Platonic Folding", + entryAxis: "vertical", + eyeDistanceScale: 1, + }), + mobile: Object.freeze({ + ...PLATONIC_SOURCE, + id: "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..2b1ccaa --- /dev/null +++ b/src/adapters/platonic-folding/test/cssplatonicfolding-shell.test.mjs @@ -0,0 +1,21 @@ +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, 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}\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(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 new file mode 100644 index 0000000..b8305b0 --- /dev/null +++ b/src/adapters/platonic-folding/test/cssplatonicfolding-source-model.test.mjs @@ -0,0 +1,170 @@ +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"; + +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("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, null); + assert.equal(prepared.metrics.maximumVisibleLeaves, 20); + assert.equal(prepared.metrics.runtimeGeometryConstructionCount, 0); + 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); + 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, 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; +} 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..3064830 --- /dev/null +++ b/src/adapters/platonic-folding/tools/check-prepare-determinism.mjs @@ -0,0 +1,29 @@ +#!/usr/bin/env node +import assert from "node:assert/strict"; +import { createHash } from "node:crypto"; +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 = 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 = firstModel.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..78aa9e3 --- /dev/null +++ b/src/adapters/platonic-folding/tools/prepare-cssplatonicfolding.mjs @@ -0,0 +1,147 @@ +#!/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 { + CSSPLATONIC_MODEL_ID, + buildPlatonicPreparedModel, + buildPlatonicPreparedPlayback, +} 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 = resolveSourceRoot(); +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 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 = 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: CSSPLATONIC_MODEL_ID, + ...prepared.metrics, + manifestSha256: built.manifestSha256, + playbackPath: `/cssplatonicfolding/${playbackPath}`, + playbackSha256: createHash("sha256").update(playbackBytes).digest("hex"), + playbackBytes: playbackBytes.byteLength, + }); +} +await writeJson(join(stagingRoot, "prepared.json"), { + schema: "cssplatonicfolding-prepared-scene@1", + status: "ready", + source: sourceLock, + renderer: { + package: "@layoutit/polycss-morph", + 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, + 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 resolveSourceRoot() { + const value = process.env.CSSPLATONICFOLDING_SOURCE_ROOT; + return value ? resolve(value) : null; +} + +async function assertSourceIdentity() { + 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 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); +} + +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..f2f3383 --- /dev/null +++ b/src/adapters/platonic-folding/tools/smoke-browser.mjs @@ -0,0 +1,128 @@ +#!/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); + 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, loop, visibleLeaves, errors })}`); + } + await page.evaluate(() => window.__cssPlatonicFoldingDebug.seekFrame(270)); + 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..a88dca2 --- /dev/null +++ b/src/adapters/platonic-folding/vite.config.mjs @@ -0,0 +1,36 @@ +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 = 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: 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, deployBuild ? "dist/site/platonic-folding" : "dist/platonic-folding"), + emptyOutDir: true, + }, +});