From b2253cc9ab98cb146869317d2a6495afdbe70487 Mon Sep 17 00:00:00 2001 From: alowpoly Date: Tue, 18 Aug 2026 13:16:03 -0300 Subject: [PATCH 1/2] feat(cyclone): add prepared Cyclone adapter --- netlify.toml | 10 + package.json | 7 +- site/test/landing.test.mjs | 2 +- src/adapters/cyclone/NOTICE.md | 20 + src/adapters/cyclone/README.md | 54 ++ src/adapters/cyclone/index.html | 32 ++ .../cyclone/notes/references/source-lock.json | 28 + .../cyclone/src/csscyclone/client.mjs | 175 +++++++ .../src/csscyclone/preparedLightingAsset.mjs | 48 ++ .../src/csscyclone/preparedPlayback.mjs | 382 ++++++++++++++ .../cyclone/src/csscyclone/preparedStream.mjs | 257 +++++++++ .../cyclone/src/csscyclone/styles.css | 183 +++++++ src/adapters/cyclone/src/main.mjs | 5 + .../src/prepare/csscyclone/modelBuilder.mjs | 216 ++++++++ .../prepare/csscyclone/preparedLighting.mjs | 360 +++++++++++++ .../src/prepare/csscyclone/sourceModel.mjs | 488 ++++++++++++++++++ .../cyclone/test/csscyclone-shell.test.mjs | 31 ++ .../test/csscyclone-source-profile.test.mjs | 152 ++++++ .../test/csscyclone-stream-selection.test.mjs | 65 +++ src/adapters/cyclone/tools/native-oracle.cpp | 137 +++++ .../cyclone/tools/prepare-csscyclone.mjs | 357 +++++++++++++ .../cyclone/tools/write-native-frame.mjs | 41 ++ src/adapters/cyclone/vite.config.mjs | 34 ++ 23 files changed, 3082 insertions(+), 2 deletions(-) create mode 100644 src/adapters/cyclone/NOTICE.md create mode 100644 src/adapters/cyclone/README.md create mode 100644 src/adapters/cyclone/index.html create mode 100644 src/adapters/cyclone/notes/references/source-lock.json create mode 100644 src/adapters/cyclone/src/csscyclone/client.mjs create mode 100644 src/adapters/cyclone/src/csscyclone/preparedLightingAsset.mjs create mode 100644 src/adapters/cyclone/src/csscyclone/preparedPlayback.mjs create mode 100644 src/adapters/cyclone/src/csscyclone/preparedStream.mjs create mode 100644 src/adapters/cyclone/src/csscyclone/styles.css create mode 100644 src/adapters/cyclone/src/main.mjs create mode 100644 src/adapters/cyclone/src/prepare/csscyclone/modelBuilder.mjs create mode 100644 src/adapters/cyclone/src/prepare/csscyclone/preparedLighting.mjs create mode 100644 src/adapters/cyclone/src/prepare/csscyclone/sourceModel.mjs create mode 100644 src/adapters/cyclone/test/csscyclone-shell.test.mjs create mode 100644 src/adapters/cyclone/test/csscyclone-source-profile.test.mjs create mode 100644 src/adapters/cyclone/test/csscyclone-stream-selection.test.mjs create mode 100644 src/adapters/cyclone/tools/native-oracle.cpp create mode 100644 src/adapters/cyclone/tools/prepare-csscyclone.mjs create mode 100644 src/adapters/cyclone/tools/write-native-frame.mjs create mode 100644 src/adapters/cyclone/vite.config.mjs diff --git a/netlify.toml b/netlify.toml index beeff31..e40d3f8 100644 --- a/netlify.toml +++ b/netlify.toml @@ -42,6 +42,16 @@ [headers.values] Cache-Control = "public, max-age=0, must-revalidate" +[[headers]] + for = "/csscyclone/assets/*" + [headers.values] + Cache-Control = "public, max-age=31536000, immutable" + +[[headers]] + for = "/csscyclone/blocks/*" + [headers.values] + Cache-Control = "public, max-age=31536000, immutable" + [[headers]] for = "/cssmenger/assets/*" [headers.values] diff --git a/package.json b/package.json index cdc50de..32e5a5a 100644 --- a/package.json +++ b/package.json @@ -40,6 +40,7 @@ "dev:flowerbox": "vite --config src/adapters/flowerbox/vite.config.mjs --host 127.0.0.1", "dev:gears": "vite --config src/adapters/gears/vite.config.mjs --host 127.0.0.1", "dev:gravitywell": "vite --config src/adapters/gravitywell/vite.config.mjs --host 127.0.0.1", + "dev:cyclone": "vite --config src/adapters/cyclone/vite.config.mjs --host 127.0.0.1", "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", @@ -55,6 +56,8 @@ "build:gears:full": "pnpm prepare:gears:source && pnpm build:gears", "build:gravitywell": "vite build --config src/adapters/gravitywell/vite.config.mjs", "build:gravitywell:full": "pnpm prepare:gravitywell:artifact && pnpm build:gravitywell", + "build:cyclone": "vite build --config src/adapters/cyclone/vite.config.mjs", + "build:cyclone:full": "pnpm prepare:cyclone && pnpm build:cyclone", "build:electropaint": "vite build --config src/adapters/electropaint/vite.config.mjs", "build:electropaint:full": "pnpm prepare:electropaint:source && pnpm build:electropaint", "build:electropaint:deploy": "CSSSELECTROPAINT_DEPLOY_BUILD=1 pnpm build:electropaint", @@ -66,7 +69,7 @@ "build:solitaire": "vite build --config src/adapters/solitaire/vite.config.mjs", "build:solitaire:full": "pnpm prepare:solitaire && pnpm build:solitaire", "build:solitaire:deploy": "CSSSOLITAIRE_DEPLOY_BUILD=1 pnpm build:solitaire", - "build:deploy": "pnpm exec playwright install chromium && pnpm prepare:3dpipes && pnpm prepare:flowerbox:artifact && pnpm prepare:gears:artifact && pnpm prepare:gravitywell:artifact && pnpm prepare:menger:artifact && pnpm prepare:maze:artifact && pnpm prepare:electropaint:deploy && pnpm prepare:solitaire && CSSPIPES_DEPLOY_BUILD=1 pnpm build:3dpipes && CSSFLOWER_DEPLOY_BUILD=1 pnpm build:flowerbox && CSSGEARS_DEPLOY_BUILD=1 pnpm build:gears && CSSGRAVITYWELL_DEPLOY_BUILD=1 pnpm build:gravitywell && CSSMENGER_DEPLOY_BUILD=1 pnpm build:menger && CSSMAZE_DEPLOY_BUILD=1 pnpm build:maze && CSSSELECTROPAINT_DEPLOY_BUILD=1 pnpm build:electropaint && CSSSOLITAIRE_DEPLOY_BUILD=1 pnpm build:solitaire && CSSGRAPHICS_DEPLOY_BUILD=1 pnpm build:site", + "build:deploy": "pnpm exec playwright install chromium && pnpm prepare:3dpipes && pnpm prepare:flowerbox:artifact && pnpm prepare:gears:artifact && pnpm prepare:gravitywell:artifact && pnpm prepare:cyclone && 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 && CSSCYCLONE_DEPLOY_BUILD=1 pnpm build:cyclone && 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", "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", @@ -77,6 +80,7 @@ "prepare:gears:source": "node src/adapters/gears/tools/prepare-cssgears.mjs && node src/adapters/gears/tools/prepare-polycss-snapshot.mjs", "prepare:gravitywell:artifact": "node scripts/prepare/cssgravitywell-product-bank.mjs", "prepare:gravitywell:source": "node src/adapters/gravitywell/tools/prepare-cssgravitywell.mjs", + "prepare:cyclone": "node src/adapters/cyclone/tools/prepare-csscyclone.mjs", "prepare:electropaint:scene": "node src/adapters/electropaint/tools/prepare-scene.mjs", "prepare:electropaint:source": "pnpm prepare:electropaint:scene && node src/adapters/electropaint/tools/prepare-snapshot.mjs", "prepare:electropaint:artifact": "node scripts/prepare/cssselectropaint-product-bank.mjs", @@ -107,6 +111,7 @@ "test:gravitywell:assets": "node --test src/adapters/gravitywell/test/*.asset-test.mjs", "test:gravitywell:browser": "node src/adapters/gravitywell/tools/smoke-browser.mjs", "verify:gravitywell:bank": "node src/adapters/gravitywell/tools/verify-product-bank.mjs", + "test:cyclone": "node --test src/adapters/cyclone/test/*.test.mjs", "oracle:gravitywell:native:state": "node src/adapters/gravitywell/tools/run-native-state-oracle.mjs", "oracle:gravitywell:visual": "node src/adapters/gravitywell/tools/oracle/run-visual-oracle.mjs", "perf:gravitywell:frame-work": "node src/adapters/gravitywell/tools/trace-frame-work.mjs", diff --git a/site/test/landing.test.mjs b/site/test/landing.test.mjs index 2b42682..49ec323 100644 --- a/site/test/landing.test.mjs +++ b/site/test/landing.test.mjs @@ -168,7 +168,7 @@ test("deployment serves the landing at the root", async () => { assert.doesNotMatch(netlify, /to\s*=\s*"\/pipes\/"/u); assert.match( packageManifest, - /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/u, + /CSSGRAVITYWELL_DEPLOY_BUILD=1 pnpm build:gravitywell && CSSCYCLONE_DEPLOY_BUILD=1 pnpm build:cyclone && CSSMENGER_DEPLOY_BUILD=1 pnpm build:menger/u, ); assert.match( packageManifest, diff --git a/src/adapters/cyclone/NOTICE.md b/src/adapters/cyclone/NOTICE.md new file mode 100644 index 0000000..27e7db3 --- /dev/null +++ b/src/adapters/cyclone/NOTICE.md @@ -0,0 +1,20 @@ +# Cyclone adapter attribution + +This notice applies to `src/adapters/cyclone` and its generated browser +artifacts. It does not relicense the MIT-licensed cssGraphics core. + +The motion model, particle geometry, path interpolation, camera, and transform +order are adapted from Really Slick Cyclone, copyright 1999-2010 Terence M. +Welsh, distributed under GPL version 2 or any later version. The pinned source +is: + +- +- supporting `rslibs` revision + `42d251b1a751956f54c8aad263e78af527ef2b82` + +This adapter is distributed under GPL-2.0-or-later. The GPL version 2 terms are +included in this repository at `src/adapters/electropaint/LICENSE.GPL-2.0`. +There are no upstream image, model, video, or executable assets in the adapter. + +Cyclone names the upstream screensaver. This project is an independent CSS port +and is not endorsed by the original authors. diff --git a/src/adapters/cyclone/README.md b/src/adapters/cyclone/README.md new file mode 100644 index 0000000..de41611 --- /dev/null +++ b/src/adapters/cyclone/README.md @@ -0,0 +1,54 @@ +# cssCyclone + +This first slice translates Really Slick Cyclone into one retained PolyCSS Morph +graph. It mounts the source-default 400 particle roots with six solid triangle +leaves each and plays 24 source-continuous logical chunks of 450 prepared 20 ms +transform states. The stream is transported as 216 one-second prepared blocks +so decompression stays outside long animation frames. Source fixed-function +smooth-vertex lighting is prepared at the first published frame into a verified +lossless WebP atlas. The 2,400 initial leaf addresses are bound once. Later +address writes are limited to the six leaves of a particle when the source +restarts it with a new color. +There is no Canvas, SVG, WebGL, runtime random walk, geometry construction, +lighting calculation, matrix formatting, atlas rasterization, or DOM growth. +The prepared presentation applies a square-root bias to the source's random +saturation samples. Hue, lightness, RNG cadence, and geometry stay unchanged, +while low-saturation grey runs occur less often. This is an intentional product +color bias rather than an exact native-color claim. Viewports below 600px use a +90-degree presentation field of view instead of the source-default 80 degrees +so the cyclone fits narrow screens without changing prepared motion or geometry. + +The stream discards the source's dark startup by preparing 24 seconds before its +first published frame. Its 10,800 states cover 216 seconds before repeating. +Startup uses `crypto.getRandomValues` once to select a logical chunk and an +early frame from the first half of the stream, excluding the previous session +start chunk. Playback then follows the original source order. Each hash-bound +gzip block prefetches its exact successor; only the active and lookahead blocks +remain resident. The single terminal stream wrap is the only +non-source-continuous boundary. + +Source identity is pinned in `notes/references/source-lock.json`. Preparation +uses a fixed MT19937 authoring seed because current `rslibs` seeds its generator +from `std::random_device`. It also initializes the source's first target-point +buffer from the current path before the first update, replacing an upstream +uninitialized read with deterministic state. The source's duplicated X tangent +accumulation is otherwise retained. + +Run: + +```sh +pnpm prepare:cyclone +pnpm test:cyclone +pnpm build:cyclone +pnpm dev:cyclone +``` + +The first published frame qualifies the prepared smooth-light field. During +motion the highlight orientation stays attached to each particle while source +color restarts remain exact; this is an explicit performance approximation. +The complete dense scene is not pixel-identical because OpenGL also resolves +intersecting particles with a per-fragment depth buffer while the retained CSS +scene uses planar compositor ordering. The missing non-corner-shape triangle +fallback keeps this first slice from being a release claim. + +The adapter is GPL-2.0-or-later; see [NOTICE.md](NOTICE.md). diff --git a/src/adapters/cyclone/index.html b/src/adapters/cyclone/index.html new file mode 100644 index 0000000..fcc2abc --- /dev/null +++ b/src/adapters/cyclone/index.html @@ -0,0 +1,32 @@ + + + + + + + + + + Cyclone - Powered by PolyCSS + + + + + + diff --git a/src/adapters/cyclone/notes/references/source-lock.json b/src/adapters/cyclone/notes/references/source-lock.json new file mode 100644 index 0000000..73d90e2 --- /dev/null +++ b/src/adapters/cyclone/notes/references/source-lock.json @@ -0,0 +1,28 @@ +{ + "schema": "csscyclone-source-lock@1", + "repository": "https://github.com/reallyslickscreensavers/reallyslickscreensavers", + "revision": "5f0a788bf0cc47f66a233ed528919295cd1e7500", + "primary": { + "path": "src/cyclone/cyclone.cpp", + "sha256": "1b6268aaf4fe25a43a14a0dcea4c4c58f18c1d2472e1366af2287efd938ea286" + }, + "license": { + "path": "LICENSE", + "sha256": "90b664a6fbc82b38595cbb39606268bef0b2eb9b76b0e9e03826befe204190bb", + "expression": "GPL-2.0-or-later" + }, + "rslibs": { + "repository": "https://github.com/reallyslickscreensavers/rslibs", + "revision": "42d251b1a751956f54c8aad263e78af527ef2b82", + "files": [ + { + "path": "rsMath/rsMath.h", + "sha256": "8d81f07b7ec4a0abc2788161fd600a260b648f0747387aff5abfea2b2510da93" + }, + { + "path": "Rgbhsl/Rgbhsl.cpp", + "sha256": "1866954fedbb83f5076cf078b35501882307285a88874c4e29658da76130e05b" + } + ] + } +} diff --git a/src/adapters/cyclone/src/csscyclone/client.mjs b/src/adapters/cyclone/src/csscyclone/client.mjs new file mode 100644 index 0000000..cd875a5 --- /dev/null +++ b/src/adapters/cyclone/src/csscyclone/client.mjs @@ -0,0 +1,175 @@ +// SPDX-License-Identifier: GPL-2.0-or-later +import { createPolyPerspectiveCamera } from "@layoutit/polycss"; +import { loadPolyMorphPackage, mountPolyMorphModel } from "@layoutit/polycss-morph"; +import { loadCyclonePreparedLightingAsset } from "./preparedLightingAsset.mjs"; +import { createCyclonePreparedPlayer } from "./preparedPlayback.mjs"; +import { + createCyclonePreparedBlockLoader, + loadCyclonePreparedCatalog, + selectInitialCyclonePosition, +} from "./preparedStream.mjs"; + +const MODEL_ID = "cyclone"; +const LAST_START_CHUNK_STORAGE_KEY = "csscyclone:last-start-chunk"; + +export async function mountCycloneClient(host) { + const state = { + ready: false, + errors: [], + metadata: null, + catalog: null, + selection: null, + blockLoader: null, + mounted: null, + player: null, + }; + let lightingAsset = null; + installDebugApi(state); + try { + const [loaded, metadata] = await Promise.all([ + loadPolyMorphPackage("/csscyclone/model/", { modelId: MODEL_ID }), + fetchJson("/csscyclone/prepared.json", { cache: "no-store" }), + ]); + const catalog = await loadCyclonePreparedCatalog(metadata?.playback); + if (metadata?.schema !== "csscyclone-prepared-scene@1" || + metadata.status !== "ready" || + loaded.model.identity.id !== MODEL_ID || + metadata.model?.id !== MODEL_ID || + metadata.presentation?.preparedStream?.id !== catalog.streamId || + metadata.playback?.chunkCount !== catalog.chunkCount || + metadata.playback?.preparedBlockCount !== catalog.blockCount) { + throw new Error("Cyclone prepared model binding drifted"); + } + const selection = selectInitialCyclonePosition(catalog, { + previousChunkIndex: readPreviousStartChunk(catalog.randomStartChunkCount), + }); + rememberStartChunk(selection.chunkIndex); + const initialStreamFrameIndex = selection.chunkIndex * catalog.chunkFrameCount + selection.frameIndex; + const initialBlockIndex = Math.floor(initialStreamFrameIndex / catalog.blockFrameCount); + const initialBlockFrameIndex = initialStreamFrameIndex % catalog.blockFrameCount; + const blockLoader = createCyclonePreparedBlockLoader(catalog); + const nextBlockIndex = (initialBlockIndex + 1) % catalog.blockCount; + blockLoader.retain([initialBlockIndex, nextBlockIndex]); + const [initialBlock, loadedLightingAsset] = await Promise.all([ + blockLoader.load(initialBlockIndex), + loadCyclonePreparedLightingAsset(metadata.lighting), + ]); + lightingAsset = loadedLightingAsset; + const mounted = mountPolyMorphModel(host, loaded.model, { + resources: loaded.resources, + camera: createPolyPerspectiveCamera({ perspective: 800, target: [0, 0, 0], rotX: 0, rotY: 0, zoom: 50 }), + }); + cleanPreparedDom(mounted); + const player = createCyclonePreparedPlayer({ + mounted, + catalog, + initialBlock, + initialFrameIndex: initialBlockFrameIndex, + lighting: metadata.lighting, + lightingAsset, + loadBlock(streamBlockIndex) { + return blockLoader.load(streamBlockIndex); + }, + onBlockWindow(activeBlockIndex, pendingBlockIndex) { + blockLoader.retain([activeBlockIndex, pendingBlockIndex]); + }, + }); + player.resize(); + addEventListener("resize", player.resize, { passive: true }); + state.ready = true; + state.metadata = metadata; + state.catalog = catalog; + state.selection = selection; + state.blockLoader = blockLoader; + state.mounted = mounted; + state.player = player; + document.body.classList.replace("loading", "ready"); + player.resume(); + return state; + } catch (error) { + lightingAsset?.destroy(); + state.errors.push(String(error?.stack || error)); + document.body.classList.remove("loading"); + document.body.classList.add("error"); + console.error(error); + throw error; + } +} + +function cleanPreparedDom(mounted) { + mounted.cameraElement.className = "polycss-camera"; + mounted.cameraElement.removeAttribute("data-polycss-camera-projection"); + mounted.cameraElement.removeAttribute("data-polycss-camera-perspective"); + mounted.sceneElement.className = "polycss-scene"; + mounted.sceneElement.removeAttribute("aria-hidden"); + 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("color"); + element.style.removeProperty("opacity"); + element.style.removeProperty("transform-origin"); + element.style.removeProperty("visibility"); + } +} + +function installDebugApi(state) { + Object.defineProperty(window, "__cssCycloneDebug", { + 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); }, + metadata() { return state.metadata; }, + stats() { + if (!state.mounted || !state.player) return null; + return Object.freeze({ + retainedParticleRootCount: state.mounted.shapeElements.size, + retainedPolygonLeafCount: state.mounted.leafHandles.size, + initialChunkIndex: state.selection.chunkIndex, + initialFrameIndex: state.selection.frameIndex, + startupSelectionMode: state.selection.mode, + ...state.blockLoader.stats(), + ...state.player.stats(), + }); + }, + }), + }); +} + +function readPreviousStartChunk(randomStartChunkCount) { + try { + const stored = globalThis.sessionStorage?.getItem(LAST_START_CHUNK_STORAGE_KEY); + if (stored === null || stored === undefined) return null; + const chunkIndex = Number(stored); + return Number.isSafeInteger(chunkIndex) && chunkIndex >= 0 && chunkIndex < randomStartChunkCount + ? chunkIndex + : null; + } catch { + return null; + } +} + +function rememberStartChunk(chunkIndex) { + try { + globalThis.sessionStorage?.setItem(LAST_START_CHUNK_STORAGE_KEY, String(chunkIndex)); + } catch { + // Startup remains cryptographically random when storage is unavailable. + } +} + +async function fetchJson(url, options) { + const response = await fetch(url, options); + if (!response.ok) throw new Error(`Cyclone prepared asset failed: ${response.status} ${url}`); + return response.json(); +} diff --git a/src/adapters/cyclone/src/csscyclone/preparedLightingAsset.mjs b/src/adapters/cyclone/src/csscyclone/preparedLightingAsset.mjs new file mode 100644 index 0000000..2729d98 --- /dev/null +++ b/src/adapters/cyclone/src/csscyclone/preparedLightingAsset.mjs @@ -0,0 +1,48 @@ +export async function loadCyclonePreparedLightingAsset(lighting) { + if (lighting?.schema !== "csscyclone-prepared-smooth-lighting-atlas@4" || + typeof lighting.assetUrl !== "string" || + !/^[a-f0-9]{64}$/u.test(lighting.assetSha256 ?? "") || + !Number.isSafeInteger(lighting.width) || lighting.width < 1 || + !Number.isSafeInteger(lighting.height) || lighting.height < 1) { + throw new Error("Prepared Cyclone lighting asset contract is invalid"); + } + const response = await fetch(lighting.assetUrl, { cache: "force-cache" }); + if (!response.ok) throw new Error(`Prepared Cyclone lighting failed: ${response.status}`); + const bytes = await response.arrayBuffer(); + const actualSha256 = hex(await crypto.subtle.digest("SHA-256", bytes)); + if (actualSha256 !== lighting.assetSha256) { + throw new Error(`Prepared Cyclone lighting hash drifted (${actualSha256})`); + } + const url = URL.createObjectURL(new Blob([bytes], { type: lighting.mimeType })); + const image = new Image(); + image.decoding = "async"; + image.src = url; + try { + await image.decode(); + if (image.naturalWidth !== lighting.width || image.naturalHeight !== lighting.height) { + throw new Error( + `Prepared Cyclone lighting dimensions drifted (${image.naturalWidth}x${image.naturalHeight})`, + ); + } + } catch (error) { + URL.revokeObjectURL(url); + throw error; + } + let destroyed = false; + return Object.freeze({ + url, + byteLength: bytes.byteLength, + sha256: actualSha256, + destroy() { + if (destroyed) return; + destroyed = true; + URL.revokeObjectURL(url); + }, + }); +} + +function hex(buffer) { + return [...new Uint8Array(buffer)] + .map((value) => value.toString(16).padStart(2, "0")) + .join(""); +} diff --git a/src/adapters/cyclone/src/csscyclone/preparedPlayback.mjs b/src/adapters/cyclone/src/csscyclone/preparedPlayback.mjs new file mode 100644 index 0000000..06f181e --- /dev/null +++ b/src/adapters/cyclone/src/csscyclone/preparedPlayback.mjs @@ -0,0 +1,382 @@ +// SPDX-License-Identifier: GPL-2.0-or-later +import { createPolyMorphPreparedDomTarget } from "@layoutit/polycss-morph"; + +const CATALOG_SCHEMA = "csscyclone-prepared-stream-catalog@1"; +const BLOCK_SCHEMA = "csscyclone-prepared-stream-block@1"; +const PLAYBACK_SCHEMA = "csscyclone-prepared-dom-playback@3"; +const LIGHTING_SCHEMA = "csscyclone-prepared-smooth-lighting-atlas@4"; +const LIGHTING_BLOCK_SCHEMA = "csscyclone-prepared-lighting-block@1"; +const SOURCE_FIELD_OF_VIEW_DEGREES = 80; +const MOBILE_FIELD_OF_VIEW_DEGREES = 90; +const MOBILE_MAX_WIDTH = 600; + +export function resolveCyclonePerspective(viewportWidth, viewportHeight) { + const fieldOfViewDegrees = viewportWidth < MOBILE_MAX_WIDTH + ? MOBILE_FIELD_OF_VIEW_DEGREES + : SOURCE_FIELD_OF_VIEW_DEGREES; + const perspective = viewportHeight / (2 * Math.tan(fieldOfViewDegrees * Math.PI / 360)); + return Number(perspective.toFixed(4)); +} + +export function createCyclonePreparedPlayer({ + mounted, + catalog, + initialBlock, + initialFrameIndex, + lighting, + lightingAsset, + loadBlock, + onBlockWindow = () => undefined, + onError = () => undefined, + readNow = () => performance.now(), + requestDelay = (callback, delay) => setTimeout(callback, delay), + cancelDelay = (timer) => clearTimeout(timer), +}) { + validateBinding(mounted, catalog, initialBlock, initialFrameIndex, lighting, lightingAsset, loadBlock); + 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("Cyclone 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 })), + }); + mounted.modelElement.style.setProperty( + "--cyclone-lighting-atlas", + `url("${lightingAsset.url.replace(/"/gu, "%22")}")`, + ); + mounted.modelElement.style.setProperty("--cyclone-lighting-size", lighting.backgroundSize); + + let activeBlock = initialBlock; + let playback = activeBlock.playback; + let lightingFrames = activeBlock.lighting; + let activeBlockIndex = activeBlock.streamBlockIndex; + let pendingBlock = null; + let pendingBlockIndex = null; + let pendingBlockPromise = null; + let pendingBlockError = null; + let paused = true; + let destroyed = false; + let timer = null; + let clockOrigin = readNow() - initialFrameIndex * playback.frameMilliseconds; + let pausedAt = initialFrameIndex * playback.frameMilliseconds; + let frameIndex = initialFrameIndex; + let timerCallbackCount = 0; + let collapsedFrameCount = 0; + let applyCount = 0; + let shapeTransformWrites = 0; + let lightingLeafAddressWrites = 0; + let preparedChunkSwitchCount = 0; + let preparedBlockSwitchCount = 0; + let preparedBlockPrefetchCount = 0; + let runtimePreparedBlockWaitCount = 0; + let preparedContinuousHandoffCount = 0; + let preparedTerminalWrapCount = 0; + const lightingRowWrites = 0; + const lightingLeafBindingWrites = leafElements.length; + let currentParticleColorStateIndices = []; + + publishTransforms(playback.frames[initialFrameIndex]); + publishLighting(initialFrameIndex, true); + applyCount += 1; + queueNextBlock(); + + function publishTransforms(row) { + for (let operation = 0; operation < row.length; operation += 2) { + const particleIndex = row[operation]; + const transformIndex = row[operation + 1]; + shapeTransformWrites += Number(target.shapes[particleIndex].writeTransform(playback.transforms[transformIndex])); + } + } + + function publishLighting(nextFrameIndex, force = false) { + const nextStateIndices = lightingFrames.frameParticleColorStateIndices[nextFrameIndex]; + for (let particleIndex = 0; particleIndex < nextStateIndices.length; particleIndex += 1) { + const nextStateIndex = nextStateIndices[particleIndex]; + if (!force && nextStateIndex === currentParticleColorStateIndices[particleIndex]) continue; + const leafOffset = particleIndex * lighting.facesPerParticle; + const tileOffset = nextStateIndex * lighting.facesPerParticle; + for (let faceIndex = 0; faceIndex < lighting.facesPerParticle; faceIndex += 1) { + leafElements[leafOffset + faceIndex].style.backgroundPosition = + lighting.tileBackgroundPositions[tileOffset + faceIndex]; + if (!force) lightingLeafAddressWrites += 1; + } + } + currentParticleColorStateIndices = [...nextStateIndices]; + } + + function advanceTo(nextFrameIndex) { + if (frameIndex === nextFrameIndex) return; + const distance = nextFrameIndex - frameIndex; + if (distance < 1) throw new RangeError("Cyclone cannot rewind across a prepared block boundary"); + publishTransforms(playback.frames[nextFrameIndex]); + publishLighting(nextFrameIndex); + if (distance > 1) collapsedFrameCount += distance - 1; + frameIndex = nextFrameIndex; + applyCount += 1; + } + + function queueNextBlock() { + if (destroyed || pendingBlock || pendingBlockPromise) return pendingBlockPromise; + pendingBlockIndex = (activeBlockIndex + 1) % catalog.blockCount; + pendingBlockError = null; + preparedBlockPrefetchCount += 1; + onBlockWindow(activeBlockIndex, pendingBlockIndex); + pendingBlockPromise = Promise.resolve(loadBlock(pendingBlockIndex)).then((block) => { + if (destroyed) return null; + validateBlockBinding(block, catalog, mounted.model, lighting); + pendingBlock = block; + pendingBlockPromise = null; + return block; + }).catch((error) => { + pendingBlockError = error; + pendingBlockPromise = null; + onError(error); + return null; + }); + return pendingBlockPromise; + } + + async function activatePendingBlock() { + const waited = pendingBlock === null; + if (waited) { + runtimePreparedBlockWaitCount += 1; + if (pendingBlockError) throw pendingBlockError; + await (pendingBlockPromise ?? queueNextBlock()); + } + if (!pendingBlock) throw pendingBlockError ?? new Error("Prepared Cyclone lookahead block is unavailable"); + const previousBlockIndex = activeBlockIndex; + const previousChunkIndex = activeBlock.chunkIndex; + activeBlock = pendingBlock; + activeBlockIndex = pendingBlockIndex; + playback = activeBlock.playback; + lightingFrames = activeBlock.lighting; + pendingBlock = null; + pendingBlockIndex = null; + pendingBlockPromise = null; + pendingBlockError = null; + frameIndex = 0; + publishTransforms(playback.frames[0]); + publishLighting(0); + applyCount += 1; + preparedBlockSwitchCount += 1; + if (activeBlockIndex === previousBlockIndex + 1) preparedContinuousHandoffCount += 1; + else preparedTerminalWrapCount += 1; + if (activeBlock.chunkIndex !== previousChunkIndex) preparedChunkSwitchCount += 1; + queueNextBlock(); + return waited; + } + + function schedule() { + if (paused || destroyed || timer !== null) return; + const elapsed = Math.max(0, readNow() - clockOrigin); + const nextFrameTime = Math.min( + playback.durationMilliseconds, + (Math.floor(elapsed / playback.frameMilliseconds) + 1) * playback.frameMilliseconds, + ); + timer = requestDelay(wake, Math.max(1, Math.ceil(nextFrameTime - elapsed))); + } + + async function wake() { + timer = null; + if (paused || destroyed) return; + timerCallbackCount += 1; + try { + const elapsed = Math.max(0, readNow() - clockOrigin); + if (elapsed >= playback.durationMilliseconds) { + const boundaryTime = clockOrigin + playback.durationMilliseconds; + const waited = await activatePendingBlock(); + clockOrigin = waited ? readNow() : boundaryTime; + pausedAt = 0; + } else { + advanceTo(Math.floor(elapsed / playback.frameMilliseconds)); + } + } catch (error) { + paused = true; + onError(error); + return; + } + schedule(); + } + + function pause() { + if (paused || destroyed) return snapshot(); + pausedAt = Math.min( + playback.durationMilliseconds, + Math.max(0, readNow() - clockOrigin), + ); + paused = true; + if (timer !== null) cancelDelay(timer); + timer = null; + 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("Cyclone prepared frame is out of range"); + } + const wasPaused = paused; + if (!wasPaused) pause(); + if (nextFrameIndex !== frameIndex) { + publishTransforms(playback.frames[nextFrameIndex]); + publishLighting(nextFrameIndex); + frameIndex = nextFrameIndex; + applyCount += 1; + } + pausedAt = nextFrameIndex * playback.frameMilliseconds; + if (!wasPaused) resume(); + return snapshot(); + } + + function resize() { + const perspective = resolveCyclonePerspective(innerWidth, innerHeight); + mounted.cameraElement.style.perspective = `${perspective}px`; + mounted.sceneElement.style.transform = `translateZ(${perspective}px)`; + } + + function snapshot() { + target.assertStableDomIdentity(); + return Object.freeze({ + paused, + frameIndex, + streamFrameIndex: activeBlock.startFrameIndex + frameIndex, + frameCount: playback.frameCount, + streamFrameCount: catalog.streamFrameCount, + frameMilliseconds: playback.frameMilliseconds, + durationMilliseconds: playback.durationMilliseconds, + streamDurationMilliseconds: catalog.streamDurationMilliseconds, + activeBlockIndex, + activeChunkIndex: activeBlock.chunkIndex, + pendingBlockIndex, + pendingBlockReady: pendingBlock !== null, + timerCallbackCount, + collapsedFrameCount, + applyCount, + shapeTransformWrites, + lightingRowWrites, + lightingLeafBindingWrites, + lightingLeafAddressWrites, + preparedChunkSwitchCount, + preparedBlockSwitchCount, + preparedBlockPrefetchCount, + runtimePreparedBlockWaitCount, + preparedContinuousHandoffCount, + preparedTerminalWrapCount, + preparedLightingAssetBytes: lightingAsset.byteLength, + preparedLightingColorStateCount: lighting.colorStateCount, + preparedLightingColorRestartCount: lighting.colorRestartCount, + runtimeGeometryConstructionCount: 0, + runtimeAtlasRasterizationCount: 0, + runtimeLightingCalculationCount: 0, + runtimeLightingAtlasConstructionCount: 0, + runtimeMatrixFormattingCount: 0, + runtimeIdLookupCount: 0, + runtimePreparedStateMaterializationCount: 0, + runtimeDomGrowth: false, + retainedDomStable: true, + }); + } + + return Object.freeze({ + resize, + pause, + resume, + seekFrame, + stats: snapshot, + destroy() { + if (destroyed) return; + destroyed = true; + paused = true; + if (timer !== null) cancelDelay(timer); + timer = null; + removeEventListener("resize", resize); + target.destroy(); + mounted.destroy(); + lightingAsset.destroy(); + }, + }); +} + +function validateBinding(mounted, catalog, initialBlock, initialFrameIndex, lighting, lightingAsset, loadBlock) { + if (catalog?.schema !== CATALOG_SCHEMA || + catalog.blockCount !== catalog.entries?.length || + catalog.runtimeLookaheadBlockCount !== 1 || + lighting?.schema !== LIGHTING_SCHEMA || + lighting.streamId !== catalog.streamId || + lighting.chunkCount !== catalog.chunkCount || + lighting.chunkFrameCount !== catalog.chunkFrameCount || + lighting.leafCount !== mounted.model.render.leaves.length || + lighting.facesPerParticle * mounted.model.render.shapes.length !== lighting.leafCount || + lighting.tileBackgroundPositions?.length !== lighting.tileCount || + lighting.runtime?.lightingCalculations !== 0 || + lighting.runtime?.atlasConstruction !== 0 || + lightingAsset?.sha256 !== lighting.assetSha256 || + typeof loadBlock !== "function" || + !Number.isSafeInteger(initialFrameIndex) || initialFrameIndex < 0 || + initialFrameIndex >= catalog.blockFrameCount) { + throw new Error("Cyclone prepared stream binding drifted"); + } + validateBlockBinding(initialBlock, catalog, mounted.model, lighting); +} + +function validateBlockBinding(block, catalog, model, lighting) { + const playback = block?.playback; + const lightingFrames = block?.lighting; + if (block?.schema !== BLOCK_SCHEMA || + block.streamId !== catalog.streamId || + !Number.isSafeInteger(block.streamBlockIndex) || block.streamBlockIndex < 0 || + block.streamBlockIndex >= catalog.blockCount || + block.chunkIndex !== Math.floor(block.streamBlockIndex / catalog.blocksPerChunk) || + block.blockIndex !== block.streamBlockIndex % catalog.blocksPerChunk || + block.startFrameIndex !== block.streamBlockIndex * catalog.blockFrameCount || + block.frameCount !== catalog.blockFrameCount || + playback?.schema !== PLAYBACK_SCHEMA || + playback.modelId !== model.identity.id || + playback.streamId !== catalog.streamId || + playback.streamBlockIndex !== block.streamBlockIndex || + playback.chunkIndex !== block.chunkIndex || + playback.blockIndex !== block.blockIndex || + playback.chunkCount !== catalog.chunkCount || + playback.blockCount !== catalog.blockCount || + playback.blocksPerChunk !== catalog.blocksPerChunk || + playback.startFrameIndex !== block.startFrameIndex || + playback.frameCount !== block.frameCount || + playback.frames?.length !== playback.frameCount || + playback.particleCount !== model.render.shapes.length || + playback.leafCount !== model.render.leaves.length || + playback.mounted?.shapeTransformIndices?.length !== playback.particleCount || + playback.frames.some((row) => row?.length !== playback.particleCount * 2) || + lightingFrames?.schema !== LIGHTING_BLOCK_SCHEMA || + lightingFrames.streamId !== catalog.streamId || + lightingFrames.streamBlockIndex !== block.streamBlockIndex || + lightingFrames.chunkIndex !== block.chunkIndex || + lightingFrames.blockIndex !== block.blockIndex || + lightingFrames.startFrameIndex !== block.startFrameIndex || + lightingFrames.frameCount !== playback.frameCount || + lightingFrames.particleCount !== playback.particleCount || + lightingFrames.frameParticleColorStateIndices?.length !== playback.frameCount || + lightingFrames.frameParticleColorStateIndices.some((row) => row?.length !== playback.particleCount || + row.some((stateIndex) => !Number.isSafeInteger(stateIndex) || stateIndex < 0 || + stateIndex >= lighting.colorStateCount))) { + throw new Error(`Cyclone prepared block ${block?.streamBlockIndex ?? "unknown"} binding drifted`); + } +} diff --git a/src/adapters/cyclone/src/csscyclone/preparedStream.mjs b/src/adapters/cyclone/src/csscyclone/preparedStream.mjs new file mode 100644 index 0000000..8fc29b2 --- /dev/null +++ b/src/adapters/cyclone/src/csscyclone/preparedStream.mjs @@ -0,0 +1,257 @@ +const CATALOG_SCHEMA = "csscyclone-prepared-stream-catalog@1"; +const BLOCK_SCHEMA = "csscyclone-prepared-stream-block@1"; +const PLAYBACK_SCHEMA = "csscyclone-prepared-dom-playback@3"; +const LIGHTING_BLOCK_SCHEMA = "csscyclone-prepared-lighting-block@1"; + +export async function loadCyclonePreparedCatalog(descriptor) { + if (typeof descriptor?.catalogUrl !== "string" || + !/^[a-f0-9]{64}$/u.test(descriptor.catalogSha256 ?? "") || + !Number.isSafeInteger(descriptor.catalogBytes) || descriptor.catalogBytes < 1) { + throw new Error("Prepared Cyclone catalog descriptor is invalid"); + } + const bytes = new Uint8Array(await fetchBytes(descriptor.catalogUrl, { cache: "no-store" })); + await verifyBytes(bytes, descriptor.catalogBytes, descriptor.catalogSha256, "stream catalog"); + const catalog = JSON.parse(new TextDecoder().decode(bytes)); + validateCatalog(catalog); + return Object.freeze(catalog); +} + +export function selectInitialCyclonePosition(catalog, { + search = globalThis.location?.search ?? "", + previousChunkIndex = null, + randomUint32Pair = cryptoRandomUint32Pair, +} = {}) { + validateCatalog(catalog); + const params = new URLSearchParams(search); + const requestedChunk = params.get("chunk"); + const requestedFrame = params.get("frame"); + if (requestedChunk !== null || requestedFrame !== null) { + const chunkIndex = requestedChunk === null ? 0 : Number(requestedChunk); + const frameIndex = requestedFrame === null ? 0 : Number(requestedFrame); + validatePosition(catalog, chunkIndex, frameIndex); + return Object.freeze({ chunkIndex, frameIndex, mode: "explicit" }); + } + if (previousChunkIndex !== null && + (!Number.isSafeInteger(previousChunkIndex) || previousChunkIndex < 0 || + previousChunkIndex >= catalog.randomStartChunkCount)) { + throw new RangeError("Previous Cyclone start chunk is invalid"); + } + const values = randomUint32Pair(); + if (!Array.isArray(values) || values.length !== 2 || + values.some((value) => !Number.isSafeInteger(value) || value < 0 || value > 0xffffffff)) { + throw new RangeError("Cyclone startup random values must be two uint32 values"); + } + const availableChunkCount = previousChunkIndex === null || catalog.randomStartChunkCount === 1 + ? catalog.randomStartChunkCount + : catalog.randomStartChunkCount - 1; + let chunkIndex = values[0] % availableChunkCount; + if (previousChunkIndex !== null && chunkIndex >= previousChunkIndex) chunkIndex += 1; + const frameIndex = values[1] % catalog.randomStartFrameCount; + return Object.freeze({ + chunkIndex, + frameIndex, + mode: previousChunkIndex === null ? "crypto-random" : "crypto-random-no-repeat", + }); +} + +export function createCyclonePreparedBlockLoader(catalog) { + validateCatalog(catalog); + const records = new Map(); + let desiredBlockIndices = new Set(); + let loadCount = 0; + let releaseCount = 0; + let residentDecodedBytes = 0; + let peakResidentDecodedBytes = 0; + let destroyed = false; + + async function load(streamBlockIndex) { + if (destroyed) throw new Error("Prepared Cyclone block loader is destroyed"); + const descriptor = catalog.entries[streamBlockIndex]; + if (!descriptor || descriptor.index !== streamBlockIndex) { + throw new RangeError(`Prepared Cyclone block ${streamBlockIndex} is missing`); + } + const existing = records.get(streamBlockIndex); + if (existing?.block) return existing.block; + if (existing?.promise) return existing.promise; + const promise = (async () => { + const encoded = new Uint8Array(await fetchBytes(descriptor.assetUrl, { cache: "force-cache" })); + await verifyBytes(encoded, descriptor.byteLength, descriptor.sha256, `block ${streamBlockIndex}`); + const decoded = await decompressGzip(encoded); + await verifyBytes( + decoded, + descriptor.decodedByteLength, + descriptor.decodedSha256, + `decoded block ${streamBlockIndex}`, + ); + const block = JSON.parse(new TextDecoder().decode(decoded)); + validateBlock(block, descriptor, catalog); + const record = Object.freeze({ block, decodedByteLength: decoded.byteLength }); + records.set(streamBlockIndex, record); + loadCount += 1; + residentDecodedBytes += decoded.byteLength; + peakResidentDecodedBytes = Math.max(peakResidentDecodedBytes, residentDecodedBytes); + if (!desiredBlockIndices.has(streamBlockIndex)) release(streamBlockIndex, record); + return block; + })(); + records.set(streamBlockIndex, { promise }); + try { + return await promise; + } catch (error) { + if (records.get(streamBlockIndex)?.promise === promise) records.delete(streamBlockIndex); + throw error; + } + } + + function prefetch(streamBlockIndex) { + void load(streamBlockIndex).catch(() => undefined); + } + + function retain(streamBlockIndices) { + desiredBlockIndices = new Set(streamBlockIndices); + for (const [streamBlockIndex, record] of records) { + if (!desiredBlockIndices.has(streamBlockIndex)) release(streamBlockIndex, record); + } + } + + function release(streamBlockIndex, record) { + if (!record?.block || records.get(streamBlockIndex) !== record) return; + records.delete(streamBlockIndex); + residentDecodedBytes -= record.decodedByteLength; + releaseCount += 1; + } + + return Object.freeze({ + load, + prefetch, + retain, + stats() { + return Object.freeze({ + loadCount, + releaseCount, + residentBlockCount: [...records.values()].filter((record) => record?.block).length, + residentDecodedBytes, + peakResidentDecodedBytes, + desiredBlockIndices: Object.freeze([...desiredBlockIndices].sort((left, right) => left - right)), + }); + }, + destroy() { + destroyed = true; + retain([]); + }, + }); +} + +function validateCatalog(catalog) { + if (catalog?.schema !== CATALOG_SCHEMA || + typeof catalog.streamId !== "string" || + catalog.chunkCount !== 24 || + !Number.isSafeInteger(catalog.chunkFrameCount) || catalog.chunkFrameCount < 1 || + !Number.isSafeInteger(catalog.blockCount) || catalog.blockCount < catalog.chunkCount || + catalog.entries?.length !== catalog.blockCount || + !Number.isSafeInteger(catalog.blocksPerChunk) || catalog.blocksPerChunk < 1 || + catalog.blockCount !== catalog.chunkCount * catalog.blocksPerChunk || + !Number.isSafeInteger(catalog.blockFrameCount) || catalog.blockFrameCount < 1 || + catalog.chunkFrameCount !== catalog.blocksPerChunk * catalog.blockFrameCount || + !Number.isSafeInteger(catalog.frameMilliseconds) || catalog.frameMilliseconds < 1 || + catalog.streamFrameCount !== catalog.chunkCount * catalog.chunkFrameCount || + catalog.streamDurationMilliseconds !== catalog.streamFrameCount * catalog.frameMilliseconds || + !Number.isSafeInteger(catalog.randomStartChunkCount) || catalog.randomStartChunkCount < 1 || + catalog.randomStartChunkCount > catalog.chunkCount || + !Number.isSafeInteger(catalog.randomStartFrameCount) || catalog.randomStartFrameCount < 1 || + catalog.randomStartFrameCount > catalog.chunkFrameCount || + catalog.runtimeLookaheadBlockCount !== 1 || + catalog.entries.some((entry, index) => entry?.index !== index || + entry.chunkIndex !== Math.floor(index / catalog.blocksPerChunk) || + entry.blockIndex !== index % catalog.blocksPerChunk || + entry.startFrameIndex !== index * catalog.blockFrameCount || + entry.frameCount !== catalog.blockFrameCount || + entry.sourceContinuousFromPrevious !== (index > 0) || + entry.encoding !== "gzip-newline-json" || + typeof entry.assetUrl !== "string" || + !Number.isSafeInteger(entry.byteLength) || entry.byteLength < 1 || + !Number.isSafeInteger(entry.decodedByteLength) || entry.decodedByteLength < 1 || + !/^[a-f0-9]{64}$/u.test(entry.sha256 ?? "") || + !/^[a-f0-9]{64}$/u.test(entry.decodedSha256 ?? ""))) { + throw new Error("Prepared Cyclone stream catalog drifted"); + } +} + +function validateBlock(block, descriptor, catalog) { + const playback = block?.playback; + const lighting = block?.lighting; + if (block?.schema !== BLOCK_SCHEMA || + block.streamId !== catalog.streamId || + block.streamBlockIndex !== descriptor.index || + block.chunkIndex !== descriptor.chunkIndex || + block.blockIndex !== descriptor.blockIndex || + block.startFrameIndex !== descriptor.startFrameIndex || + block.frameCount !== descriptor.frameCount || + playback?.schema !== PLAYBACK_SCHEMA || + playback.streamId !== catalog.streamId || + playback.streamBlockIndex !== descriptor.index || + playback.chunkIndex !== descriptor.chunkIndex || + playback.blockIndex !== descriptor.blockIndex || + playback.chunkCount !== catalog.chunkCount || + playback.blockCount !== catalog.blockCount || + playback.blocksPerChunk !== catalog.blocksPerChunk || + playback.startFrameIndex !== descriptor.startFrameIndex || + playback.frameMilliseconds !== catalog.frameMilliseconds || + playback.frameCount !== descriptor.frameCount || + playback.durationMilliseconds !== descriptor.frameCount * catalog.frameMilliseconds || + playback.loop !== false || + playback.frames?.length !== descriptor.frameCount || + playback.mounted?.shapeTransformIndices?.length !== playback.particleCount || + playback.frames.some((row) => row?.length !== playback.particleCount * 2) || + lighting?.schema !== LIGHTING_BLOCK_SCHEMA || + lighting.streamId !== catalog.streamId || + lighting.streamBlockIndex !== descriptor.index || + lighting.chunkIndex !== descriptor.chunkIndex || + lighting.blockIndex !== descriptor.blockIndex || + lighting.startFrameIndex !== descriptor.startFrameIndex || + lighting.frameCount !== descriptor.frameCount || + lighting.particleCount !== playback.particleCount || + lighting.frameParticleColorStateIndices?.length !== descriptor.frameCount || + lighting.frameParticleColorStateIndices.some((row) => row?.length !== playback.particleCount)) { + throw new Error(`Prepared Cyclone block ${descriptor.index} drifted`); + } +} + +function validatePosition(catalog, chunkIndex, frameIndex) { + if (!Number.isSafeInteger(chunkIndex) || chunkIndex < 0 || chunkIndex >= catalog.chunkCount || + !Number.isSafeInteger(frameIndex) || frameIndex < 0 || frameIndex >= catalog.chunkFrameCount) { + throw new RangeError("Requested Cyclone stream position is invalid"); + } +} + +function cryptoRandomUint32Pair() { + const values = new Uint32Array(2); + globalThis.crypto.getRandomValues(values); + return [...values]; +} + +async function fetchBytes(url, options) { + const response = await fetch(url, options); + if (!response.ok) throw new Error(`Prepared Cyclone asset failed: ${response.status} ${url}`); + return response.arrayBuffer(); +} + +async function decompressGzip(bytes) { + const stream = new Blob([bytes]).stream().pipeThrough(new DecompressionStream("gzip")); + return new Uint8Array(await new Response(stream).arrayBuffer()); +} + +async function verifyBytes(bytes, expectedLength, expectedSha256, label) { + if (bytes.byteLength !== expectedLength) { + throw new Error(`Prepared Cyclone ${label} byte length drifted`); + } + const actualSha256 = hex(await crypto.subtle.digest("SHA-256", bytes)); + if (actualSha256 !== expectedSha256) { + throw new Error(`Prepared Cyclone ${label} hash drifted (${actualSha256})`); + } +} + +function hex(buffer) { + return [...new Uint8Array(buffer)] + .map((value) => value.toString(16).padStart(2, "0")) + .join(""); +} diff --git a/src/adapters/cyclone/src/csscyclone/styles.css b/src/adapters/cyclone/src/csscyclone/styles.css new file mode 100644 index 0000000..a6b3f0d --- /dev/null +++ b/src/adapters/cyclone/src/csscyclone/styles.css @@ -0,0 +1,183 @@ +:root { + color-scheme: dark; + font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; + background: linear-gradient(180deg, #0b1119 0%, #000 100%); + color: #eef2f4; +} + +* { box-sizing: border-box; } + +html, +body { + width: 100%; + height: 100%; + margin: 0; +} + +body { + position: relative; + overflow: hidden; + background: linear-gradient(180deg, #0b1119 0%, #000 100%); +} + +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: csscyclone-loading 0.8s linear infinite; +} + +@keyframes csscyclone-loading { + to { + transform: rotate(1turn); + } +} + +@media (prefers-reduced-motion: reduce) { + body.loading::after { + animation: none; + } +} + +.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: 190px; + 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-actions { + display: flex; + align-items: center; + height: 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; + background: transparent; + border: 0; + cursor: pointer; + 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: transparent; + 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 u { + position: absolute; + width: 32px; + height: 32px; + border-width: 0; + border-top-left-radius: 50% 100%; + border-top-right-radius: 50% 100%; + corner-top-left-shape: bevel; + corner-top-right-shape: bevel; + backface-visibility: hidden; + color: transparent; + background-color: transparent; + background-image: var(--cyclone-lighting-atlas); + background-repeat: no-repeat; + background-size: var(--cyclone-lighting-size); + image-rendering: auto; + transform-origin: 0 0; +} + +body.error::after { + content: "Cyclone failed to load."; + position: fixed; + inset: 0; + z-index: 30; + display: grid; + place-items: center; + color: #ffb5a7; +} diff --git a/src/adapters/cyclone/src/main.mjs b/src/adapters/cyclone/src/main.mjs new file mode 100644 index 0000000..24351a9 --- /dev/null +++ b/src/adapters/cyclone/src/main.mjs @@ -0,0 +1,5 @@ +// SPDX-License-Identifier: GPL-2.0-or-later +import "./csscyclone/styles.css"; +import { mountCycloneClient } from "./csscyclone/client.mjs"; + +mountCycloneClient(document.body).catch(() => {}); diff --git a/src/adapters/cyclone/src/prepare/csscyclone/modelBuilder.mjs b/src/adapters/cyclone/src/prepare/csscyclone/modelBuilder.mjs new file mode 100644 index 0000000..3f648dc --- /dev/null +++ b/src/adapters/cyclone/src/prepare/csscyclone/modelBuilder.mjs @@ -0,0 +1,216 @@ +import { + SOLID_TRIANGLE_CANONICAL_SIZE, + computeSolidTrianglePlanFromCssPoints, + formatMatrix3dValues, +} from "@layoutit/polycss"; +import { + CSSCYCLONE_BANK, + CSSCYCLONE_SOURCE, + buildCycloneSourceSequence, +} from "./sourceModel.mjs"; + +export const CSSCYCLONE_MODEL_ID = "cyclone"; +const PARTICLE_RADIUS = CSSCYCLONE_SOURCE.particleSize / 4; +const EQUATOR = Object.freeze(Array.from({ length: 3 }, (_, index) => { + const angle = index * Math.PI * 2 / 3; + return Object.freeze([Math.cos(angle) * PARTICLE_RADIUS, Math.sin(angle) * PARTICLE_RADIUS, 0]); +})); +export const CSSCYCLONE_PARTICLE_VERTICES = Object.freeze([ + Object.freeze([0, 0, PARTICLE_RADIUS]), + ...EQUATOR, + Object.freeze([0, 0, -PARTICLE_RADIUS]), +]); +export const CSSCYCLONE_FACE_INDICES = Object.freeze([ + Object.freeze([0, 1, 2]), + Object.freeze([0, 2, 3]), + Object.freeze([0, 3, 1]), + Object.freeze([4, 2, 1]), + Object.freeze([4, 3, 2]), + Object.freeze([4, 1, 3]), +]); + +export function buildCyclonePreparedModel({ source = buildCycloneSourceSequence() } = {}) { + const vertices = []; + const normals = []; + const polygons = []; + const shapes = []; + const leaves = []; + const initial = source.frames[0]; + for (let particleIndex = 0; particleIndex < source.bank.particleCount; particleIndex += 1) { + const vertexOffset = vertices.length; + const normalOffset = normals.length; + vertices.push(...CSSCYCLONE_PARTICLE_VERTICES.map((vertex) => Object.freeze([...vertex]))); + normals.push(...CSSCYCLONE_PARTICLE_VERTICES.map((vertex) => Object.freeze( + vertex.map((value) => rounded(value / PARTICLE_RADIUS)), + ))); + const shapeId = particleId(particleIndex); + shapes.push(Object.freeze({ id: shapeId, matrix: initial.particles[particleIndex].matrix })); + for (let faceIndex = 0; faceIndex < CSSCYCLONE_FACE_INDICES.length; faceIndex += 1) { + const localIndices = CSSCYCLONE_FACE_INDICES[faceIndex]; + const triangle = localIndices.map((index) => CSSCYCLONE_PARTICLE_VERTICES[index]); + const polygonId = `${shapeId}-face-${faceIndex}`; + polygons.push(Object.freeze({ + id: polygonId, + vertexIndices: Object.freeze(localIndices.map((index) => vertexOffset + index)), + normalIndices: Object.freeze(localIndices.map((index) => normalOffset + index)), + })); + leaves.push(Object.freeze({ + id: `leaf-${polygonId}`, + polygonId, + shapeId, + materialId: "particle", + strategy: "solid-triangle", + width: SOLID_TRIANGLE_CANONICAL_SIZE, + height: SOLID_TRIANGLE_CANONICAL_SIZE, + matrix: triangleMatrix(triangle, polygonId), + atlas: null, + fallback: null, + })); + } + } + const model = deepFreeze({ + schema: "polycss-morph.model@1", + identity: Object.freeze({ id: CSSCYCLONE_MODEL_ID, name: source.bank.name, revision: "0.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: 1, + 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: "particle", color: Object.freeze([1, 1, 1, 1]) })]), + render: Object.freeze({ + modelMatrix: source.modelMatrix, + 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: "csscyclone-preparer", + generatorVersion: "0.1.0", + sources: Object.freeze([Object.freeze({ + id: "really-slick-cyclone", + kind: "open-data", + uri: `${CSSCYCLONE_SOURCE.repository}/blob/${CSSCYCLONE_SOURCE.revision}/${CSSCYCLONE_SOURCE.path}`, + sha256: CSSCYCLONE_SOURCE.sha256, + license: CSSCYCLONE_SOURCE.license, + })]), + }), + }); + return deepFreeze({ + source, + model, + metrics: Object.freeze({ + retainedParticleRootCount: shapes.length, + retainedPolygonLeafCount: leaves.length, + sourceParticleCount: source.bank.particleCount, + polygonsPerParticle: CSSCYCLONE_FACE_INDICES.length, + runtimeGeometryConstructionCount: 0, + runtimeAtlasRasterizationCount: 0, + runtimeDomGrowth: false, + }), + }); +} + +export function buildCyclonePreparedPlayback({ source = buildCycloneSourceSequence() } = {}) { + const transforms = []; + const transformIndices = new Map(); + const internTransform = (matrix) => { + const transform = `matrix3d(${formatMatrix3dValues(matrix, 6)})`; + const existing = transformIndices.get(transform); + if (existing !== undefined) return existing; + const index = transforms.length; + transforms.push(transform); + transformIndices.set(transform, index); + return index; + }; + const first = source.frames[0]; + const mountedShapeTransformIndices = first.particles.map((particle) => internTransform(particle.matrix)); + const rowForFrame = (frame) => { + const shapeOperations = []; + for (let index = 0; index < frame.particles.length; index += 1) { + const particle = frame.particles[index]; + const transformIndex = internTransform(particle.matrix); + shapeOperations.push(index, transformIndex); + } + return Object.freeze(shapeOperations); + }; + const frames = source.frames.map(rowForFrame); + const playback = deepFreeze({ + schema: "csscyclone-prepared-dom-playback@3", + bankId: source.bank.id, + streamId: source.bank.streamId, + chunkIndex: source.bank.chunkIndex, + chunkCount: source.bank.chunkCount, + startFrameIndex: source.bank.startFrameIndex, + modelId: CSSCYCLONE_MODEL_ID, + frameMilliseconds: source.bank.frameMilliseconds, + durationMilliseconds: source.durationMilliseconds, + frameCount: frames.length, + loop: false, + particleCount: source.bank.particleCount, + leafCount: source.bank.particleCount * CSSCYCLONE_FACE_INDICES.length, + transforms: Object.freeze(transforms), + mounted: Object.freeze({ + shapeTransformIndices: Object.freeze(mountedShapeTransformIndices), + }), + frames: Object.freeze(frames), + }); + return deepFreeze({ + source, + playback, + metrics: Object.freeze({ + preparedFrameCount: frames.length, + uniquePreparedTransformCount: transforms.length, + shapeTransformSelections: frames.reduce((sum, row) => sum + row.length / 2, 0), + }), + }); +} + +function triangleMatrix(vertices, id) { + const plan = computeSolidTrianglePlanFromCssPoints( + { vertices: vertices.map((vertex) => [...vertex]) }, + 0, + { seamBleed: 0 }, + { includeColor: false, matrixDecimals: 10, primitive: "border" }, + ...vertices.flat(), + ); + const match = /^matrix3d\(([^)]+)\)$/u.exec(plan?.transformText ?? ""); + const values = match?.[1]?.split(",").map(Number); + if (!values || values.length !== 16 || values.some((value) => !Number.isFinite(value))) { + throw new Error(`Cyclone triangle is not renderable: ${id}`); + } + return Object.freeze(values.map((value) => rounded(value))); +} + +function particleId(index) { + return `particle-${String(index).padStart(3, "0")}`; +} + +function rounded(value) { + const result = Number(value.toFixed(10)); + return Object.is(result, -0) ? 0 : result; +} + +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; +} + +export { CSSCYCLONE_BANK }; diff --git a/src/adapters/cyclone/src/prepare/csscyclone/preparedLighting.mjs b/src/adapters/cyclone/src/prepare/csscyclone/preparedLighting.mjs new file mode 100644 index 0000000..815d9fb --- /dev/null +++ b/src/adapters/cyclone/src/prepare/csscyclone/preparedLighting.mjs @@ -0,0 +1,360 @@ +import { createHash } from "node:crypto"; +import sharp from "sharp"; +import { + CSSCYCLONE_FACE_INDICES, + CSSCYCLONE_PARTICLE_VERTICES, +} from "./modelBuilder.mjs"; + +const CONTENT_SIZE = 2; +const GUTTER = 1; +const SLOT_SIZE = CONTENT_SIZE + GUTTER * 2; +const MAXIMUM_COLUMNS = 1_200; +const MAXIMUM_TEXTURE_DIMENSION = 8_192; +const DISPLAY_SCALE = 16; +const LIGHT_DIRECTION = normalize([400, -200, 400]); +const HALF_VECTOR = normalize([ + LIGHT_DIRECTION[0], + LIGHT_DIRECTION[1], + LIGHT_DIRECTION[2] + 1, +]); + +export function createCyclonePreparedLightingStream() { + let particleCount = null; + let expectedChunkCount = null; + let chunkFrameCount = null; + let streamId = null; + let nextChunkIndex = 0; + let sourceStreamFrameCount = 0; + let frozenVertexNormals = null; + let currentColorStateIndices = null; + let previousColors = null; + let colorRestartCount = 0; + const colorStates = []; + + function add(source) { + validateSourceChunk(source); + if (particleCount === null) initialize(source); + if (source.bank.streamId !== streamId || + source.bank.chunkIndex !== nextChunkIndex || + source.bank.startFrameIndex !== sourceStreamFrameCount || + source.bank.particleCount !== particleCount || + source.bank.frameCount !== chunkFrameCount || + source.bank.chunkCount !== expectedChunkCount) { + throw new Error("Prepared Cyclone lighting chunks are not one continuous source stream"); + } + let chunkColorRestartCount = 0; + const frameParticleColorStateIndices = source.frames.map((frame, frameIndex) => Object.freeze( + frame.particles.map((particle, particleIndex) => { + if (particle.color !== previousColors[particleIndex]) { + if (sourceStreamFrameCount + frameIndex > 0) { + colorRestartCount += 1; + chunkColorRestartCount += 1; + } + currentColorStateIndices[particleIndex] = colorStates.length; + colorStates.push(Object.freeze({ + particleIndex, + baseColor: particle.colorRgb, + })); + previousColors[particleIndex] = particle.color; + } + return currentColorStateIndices[particleIndex]; + }), + )); + const chunk = deepFreeze({ + schema: "csscyclone-prepared-lighting-chunk@1", + streamId, + chunkIndex: source.bank.chunkIndex, + startFrameIndex: source.bank.startFrameIndex, + frameCount: source.frames.length, + particleCount, + colorRestartCount: chunkColorRestartCount, + frameParticleColorStateIndices, + }); + sourceStreamFrameCount += source.frames.length; + nextChunkIndex += 1; + return chunk; + } + + async function finalize({ allowPartial = false } = {}) { + if (particleCount === null || frozenVertexNormals === null || colorStates.length === 0) { + throw new Error("Prepared Cyclone lighting stream has no source chunks"); + } + if (!allowPartial && nextChunkIndex !== expectedChunkCount) { + throw new Error(`Prepared Cyclone lighting stream has ${nextChunkIndex}/${expectedChunkCount} chunks`); + } + return buildPreparedLightingAsset({ + particleCount, + chunkCount: nextChunkIndex, + chunkFrameCount, + streamId, + sourceStreamFrameCount, + frozenVertexNormals, + colorStates, + colorRestartCount, + }); + } + + function initialize(source) { + particleCount = source.bank.particleCount; + expectedChunkCount = source.bank.chunkCount; + chunkFrameCount = source.bank.frameCount; + streamId = source.bank.streamId; + currentColorStateIndices = Array(particleCount).fill(-1); + previousColors = Array(particleCount).fill(null); + frozenVertexNormals = source.frames[0].particles.map((particle) => { + const normalMatrix = inverseTranspose3(particle.matrix); + return CSSCYCLONE_PARTICLE_VERTICES.map((vertex) => + transform3(normalMatrix, normalize(vertex))); + }); + } + + return Object.freeze({ add, finalize }); +} + +export async function buildCyclonePreparedLighting({ source }) { + const stream = createCyclonePreparedLightingStream(); + const chunk = stream.add(source); + const prepared = await stream.finalize({ allowPartial: true }); + return Object.freeze({ ...prepared, chunk }); +} + +async function buildPreparedLightingAsset({ + particleCount, + chunkCount, + chunkFrameCount, + streamId, + sourceStreamFrameCount, + frozenVertexNormals, + colorStates, + colorRestartCount, +}) { + const leafCount = particleCount * CSSCYCLONE_FACE_INDICES.length; + const tileCount = colorStates.length * CSSCYCLONE_FACE_INDICES.length; + const columns = Math.min(MAXIMUM_COLUMNS, tileCount); + const rows = Math.ceil(tileCount / columns); + const width = columns * SLOT_SIZE; + const height = rows * SLOT_SIZE; + if (width > MAXIMUM_TEXTURE_DIMENSION || height > MAXIMUM_TEXTURE_DIMENSION) { + throw new Error(`Prepared Cyclone lighting atlas ${width}x${height} exceeds the image contract`); + } + const rgba = Buffer.alloc(width * height * 4); + for (let stateIndex = 0; stateIndex < colorStates.length; stateIndex += 1) { + const state = colorStates[stateIndex]; + const vertexColors = frozenVertexNormals[state.particleIndex].map((normal) => shadeVertex({ + baseColor: state.baseColor, + normal, + })); + for (let faceIndex = 0; faceIndex < CSSCYCLONE_FACE_INDICES.length; faceIndex += 1) { + writeAffineTriangleTile({ + rgba, + width, + columns, + tileIndex: stateIndex * CSSCYCLONE_FACE_INDICES.length + faceIndex, + vertexColors, + vertexIndices: CSSCYCLONE_FACE_INDICES[faceIndex], + }); + } + } + const bytes = await sharp(rgba, { + raw: { width, height, channels: 4 }, + limitInputPixels: false, + }).webp({ lossless: true, effort: 6 }).toBuffer(); + const assetSha256 = createHash("sha256").update(bytes).digest("hex"); + const tileBackgroundPositions = Object.freeze(Array.from({ length: tileCount }, (_, tileIndex) => { + const contentX = tileIndex % columns * SLOT_SIZE + GUTTER; + const contentY = Math.floor(tileIndex / columns) * SLOT_SIZE + GUTTER; + return `${-contentX * DISPLAY_SCALE}px ${-contentY * DISPLAY_SCALE}px`; + })); + const contract = deepFreeze({ + schema: "csscyclone-prepared-smooth-lighting-atlas@4", + technique: "prepared-stream-frame-zero-smooth-lighting-with-sparse-source-color-restarts", + source: "src/cyclone/cyclone.cpp#particle::update+initSaver", + streamId, + assetUrl: `/csscyclone/assets/lighting-${assetSha256}.webp`, + assetSha256, + encoding: "WebP-lossless-RGBA8", + mimeType: "image/webp", + lossless: true, + width, + height, + decodedBytes: width * height * 4, + byteLength: bytes.byteLength, + contentSize: CONTENT_SIZE, + gutterPixels: GUTTER, + slotSize: SLOT_SIZE, + columns, + rows, + tileCount, + leafCount, + sourceFaceCount: leafCount, + sourceFaceCoverageCount: leafCount, + sourceFaceCoverageExact: true, + sourceStreamFrameCount, + chunkCount, + chunkFrameCount, + colorStateCount: colorStates.length, + colorRestartCount, + facesPerParticle: CSSCYCLONE_FACE_INDICES.length, + lightingReferenceFrameIndex: 0, + sourceColorRestartsExact: true, + dynamicLightOrientation: false, + displayScale: DISPLAY_SCALE, + sampling: "two-by-two-affine-field-with-one-extrapolated-sample-gutter", + interpolation: "browser-perspective-transformed-stream-frame-zero-smooth-vertex-color-field", + backgroundSize: `${width * DISPLAY_SCALE}px ${height * DISPLAY_SCALE}px`, + tileBackgroundPositions, + material: Object.freeze({ + ambientAndDiffuse: "source-particle-rgb", + specular: Object.freeze([0.7, 0.7, 0.7, 1]), + shininess: 20, + }), + light: Object.freeze({ + model: "OpenGL-1.x-infinite-viewer-ambient-Lambert-Blinn-Phong", + globalAmbient: Object.freeze([0.2, 0.2, 0.2, 1]), + ambient: Object.freeze([0.25, 0.25, 0.25, 0]), + diffuse: Object.freeze([1, 1, 1, 0]), + specular: Object.freeze([1, 1, 1, 0]), + position: Object.freeze([400, -200, 400, 0]), + normalizeNormals: false, + }), + runtime: Object.freeze({ + geometryConstruction: 0, + atlasConstruction: 0, + lightingCalculations: 0, + colorCalculations: 0, + rootLightingRowWritesPerSample: 0, + maximumSparseLeafWritesPerParticleRestart: CSSCYCLONE_FACE_INDICES.length, + topologyMutation: false, + }), + }); + return Object.freeze({ + contract, + bytes, + metrics: Object.freeze({ + preparedLightingChunkCount: chunkCount, + preparedLightingStreamFrameCount: sourceStreamFrameCount, + preparedLightingColorStateCount: colorStates.length, + preparedLightingColorRestartCount: colorRestartCount, + preparedLightingAtlasBytes: bytes.byteLength, + preparedLightingAtlasDecodedBytes: contract.decodedBytes, + preparedLightingLeafBindingCount: leafCount, + runtimeLightingCalculations: 0, + runtimeLightingAtlasConstruction: 0, + runtimeLightingWritesPerSample: 0, + }), + }); +} + +function validateSourceChunk(source) { + if (source?.schema !== "csscyclone-source-sequence@2" || + !Array.isArray(source.frames) || source.frames.length < 1 || + source.frames.length !== source.bank?.frameCount || + source.frames.some((frame) => frame.particles?.length !== source.bank?.particleCount)) { + throw new TypeError("Complete Cyclone source chunk is required for prepared lighting"); + } +} + +function writeAffineTriangleTile({ + rgba, + width, + columns, + tileIndex, + vertexColors, + vertexIndices, +}) { + const originX = tileIndex % columns * SLOT_SIZE; + const originY = Math.floor(tileIndex / columns) * SLOT_SIZE; + for (let localY = -GUTTER; localY < CONTENT_SIZE + GUTTER; localY += 1) { + const v = (localY + 0.5) / CONTENT_SIZE; + for (let localX = -GUTTER; localX < CONTENT_SIZE + GUTTER; localX += 1) { + const u = (localX + 0.5) / CONTENT_SIZE; + const apex = 1 - v; + const right = u - 0.5 * apex; + const left = 1 - apex - right; + const color = [0, 1, 2].map((channel) => clampByte( + vertexColors[vertexIndices[0]][channel] * apex + + vertexColors[vertexIndices[1]][channel] * left + + vertexColors[vertexIndices[2]][channel] * right, + )); + const x = originX + localX + GUTTER; + const y = originY + localY + GUTTER; + const offset = (y * width + x) * 4; + rgba[offset] = color[0]; + rgba[offset + 1] = color[1]; + rgba[offset + 2] = color[2]; + rgba[offset + 3] = 255; + } + } +} + +function shadeVertex({ baseColor, normal }) { + const diffuse = Math.max(0, dot(normal, LIGHT_DIRECTION)); + const specular = diffuse > 0 + ? Math.pow(Math.max(0, dot(normal, HALF_VECTOR)), 20) + : 0; + return [0, 1, 2].map((channel) => clampByte( + (baseColor[channel] * 0.2 + + baseColor[channel] * 0.25 + + baseColor[channel] * diffuse + + 0.7 * specular) * 255, + )); +} + +function inverseTranspose3(matrix) { + const a00 = matrix[0]; + const a01 = matrix[4]; + const a02 = matrix[8]; + const a10 = matrix[1]; + const a11 = matrix[5]; + const a12 = matrix[9]; + const a20 = matrix[2]; + const a21 = matrix[6]; + const a22 = matrix[10]; + const c00 = a11 * a22 - a12 * a21; + const c01 = a12 * a20 - a10 * a22; + const c02 = a10 * a21 - a11 * a20; + const c10 = a02 * a21 - a01 * a22; + const c11 = a00 * a22 - a02 * a20; + const c12 = a01 * a20 - a00 * a21; + const c20 = a01 * a12 - a02 * a11; + const c21 = a02 * a10 - a00 * a12; + const c22 = a00 * a11 - a01 * a10; + const determinant = a00 * c00 + a01 * c01 + a02 * c02; + if (Math.abs(determinant) < 1e-12) throw new Error("Cyclone particle normal matrix is singular"); + const inverseDeterminant = 1 / determinant; + return [ + c00 * inverseDeterminant, c01 * inverseDeterminant, c02 * inverseDeterminant, + c10 * inverseDeterminant, c11 * inverseDeterminant, c12 * inverseDeterminant, + c20 * inverseDeterminant, c21 * inverseDeterminant, c22 * inverseDeterminant, + ]; +} + +function transform3(matrix, vector) { + return [ + matrix[0] * vector[0] + matrix[1] * vector[1] + matrix[2] * vector[2], + matrix[3] * vector[0] + matrix[4] * vector[1] + matrix[5] * vector[2], + matrix[6] * vector[0] + matrix[7] * vector[1] + matrix[8] * vector[2], + ]; +} + +function normalize(vector) { + const length = Math.hypot(...vector); + if (!(length > 0)) throw new Error("Cyclone lighting vector has no direction"); + return vector.map((value) => value / length); +} + +function dot(left, right) { + return left[0] * right[0] + left[1] * right[1] + left[2] * right[2]; +} + +function clampByte(value) { + return Math.max(0, Math.min(255, Math.round(value))); +} + +function 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/cyclone/src/prepare/csscyclone/sourceModel.mjs b/src/adapters/cyclone/src/prepare/csscyclone/sourceModel.mjs new file mode 100644 index 0000000..7ac7d4a --- /dev/null +++ b/src/adapters/cyclone/src/prepare/csscyclone/sourceModel.mjs @@ -0,0 +1,488 @@ +const PI = Math.PI; +const CONTROL_POINT_COUNT = 6; + +export const CSSCYCLONE_SOURCE = Object.freeze({ + repository: "https://github.com/reallyslickscreensavers/reallyslickscreensavers", + revision: "5f0a788bf0cc47f66a233ed528919295cd1e7500", + path: "src/cyclone/cyclone.cpp", + sha256: "1b6268aaf4fe25a43a14a0dcea4c4c58f18c1d2472e1366af2287efd938ea286", + rslibsRevision: "42d251b1a751956f54c8aad263e78af527ef2b82", + rslibsMathSha256: "8d81f07b7ec4a0abc2788161fd600a260b648f0747387aff5abfea2b2510da93", + rgbhslSha256: "1866954fedbb83f5076cf078b35501882307285a88874c4e29658da76130e05b", + license: "GPL-2.0-or-later", + fieldOfViewDegrees: 80, + viewDistance: 400, + wide: 200, + high: 200, + particleSize: 7, + complexity: 3, + speed: 10, + stretch: true, +}); + +export const CSSCYCLONE_BANK = Object.freeze({ + id: "desktop-stream", + name: "Cyclone", + seed: 1, + particleCount: 400, + frameMilliseconds: 20, + warmupFrames: 600, + frameCount: 450, + chunkCount: 24, +}); + +export const CSSCYCLONE_PRESENTATION = Object.freeze({ + saturationSampling: "sqrt-uniform", +}); + +const FACTORIALS = Object.freeze([1, 1, 2, 6, 24, 120, 720]); + +export function buildCycloneSourceSequence({ bank = CSSCYCLONE_BANK } = {}) { + return buildCycloneSourceChunks({ bank }).next().value; +} + +export function* buildCycloneSourceChunks({ bank = CSSCYCLONE_BANK } = {}) { + validateBank(bank); + const rng = createMt19937(bank.seed); + const cyclone = createCyclone(rng); + const particles = Array.from({ length: bank.particleCount }, () => createParticle(cyclone, rng)); + const deltaSeconds = bank.frameMilliseconds / 1_000; + for (let frame = 0; frame < bank.warmupFrames; frame += 1) { + stepSource(cyclone, particles, rng, deltaSeconds); + } + for (let chunkIndex = 0; chunkIndex < bank.chunkCount; chunkIndex += 1) { + const frames = []; + const startFrameIndex = chunkIndex * bank.frameCount; + for (let frame = 0; frame < bank.frameCount; frame += 1) { + stepSource(cyclone, particles, rng, deltaSeconds); + frames.push(Object.freeze({ + timeMs: (startFrameIndex + frame) * bank.frameMilliseconds, + particles: Object.freeze(particles.map((particle) => Object.freeze({ + matrix: particle.matrix, + color: particle.color, + colorRgb: particle.colorRgb, + }))), + })); + } + yield deepFreeze({ + schema: "csscyclone-source-sequence@2", + source: CSSCYCLONE_SOURCE, + bank: Object.freeze({ + ...bank, + id: `chunk-${String(chunkIndex).padStart(2, "0")}`, + streamId: bank.id, + chunkIndex, + startFrameIndex, + }), + modelMatrix: flattenCss(multiply4(scale4([1, -1, 1]), translation([0, 0, -CSSCYCLONE_SOURCE.viewDistance]))), + frames, + durationMilliseconds: bank.frameCount * bank.frameMilliseconds, + streamDurationMilliseconds: bank.chunkCount * bank.frameCount * bank.frameMilliseconds, + }); + } +} + +function validateBank(bank) { + for (const [name, value] of Object.entries({ + seed: bank?.seed, + particleCount: bank?.particleCount, + frameMilliseconds: bank?.frameMilliseconds, + warmupFrames: bank?.warmupFrames, + frameCount: bank?.frameCount, + chunkCount: bank?.chunkCount, + })) { + if (!Number.isSafeInteger(value) || value < (name === "warmupFrames" ? 0 : 1)) { + throw new RangeError(`Cyclone ${name} must be a positive safe integer`); + } + } +} + +function createCyclone(rng) { + const xyz = Array.from({ length: CONTROL_POINT_COUNT }, () => [0, 0, 0]); + const targetxyz = Array.from({ length: CONTROL_POINT_COUNT }, () => [0, 0, 0]); + const oldxyz = Array.from({ length: CONTROL_POINT_COUNT }, () => [0, 0, 0]); + const last = CONTROL_POINT_COUNT - 1; + xyz[last] = [randf(rng, 400) - 200, 200, randf(rng, 400) - 200]; + xyz[last - 1] = [xyz[last][0], randf(rng, 66) + 50, xyz[last][2]]; + for (let index = CSSCYCLONE_SOURCE.complexity; index > 1; index -= 1) { + xyz[index] = [ + xyz[index + 1][0] + randf(rng, 200) - 100, + randf(rng, 400) - 200, + xyz[index + 1][2] + randf(rng, 200) - 100, + ]; + } + xyz[1] = [ + xyz[2][0] + randf(rng, 100) - 50, + -randf(rng, 100) - 50, + xyz[2][2] + randf(rng, 100) - 50, + ]; + xyz[0] = [ + xyz[1][0] + randf(rng, 25) - 12, + -200, + xyz[1][2] + randf(rng, 25) - 12, + ]; + for (let index = 0; index < CONTROL_POINT_COUNT; index += 1) { + targetxyz[index] = [...xyz[index]]; + oldxyz[index] = [...xyz[index]]; + } + const width = Array(CONTROL_POINT_COUNT).fill(0); + width[last] = randf(rng, 175) + 75; + width[last - 1] = randf(rng, 60) + 15; + for (let index = CSSCYCLONE_SOURCE.complexity; index > 1; index -= 1) width[index] = randf(rng, 25) + 15; + width[1] = randf(rng, 25) + 5; + width[0] = randf(rng, 15) + 5; + const hue = randf(rng, 1); + const saturation = randomSaturation(rng); + return { + xyz, + targetxyz, + oldxyz, + xyzChange: Array.from({ length: CONTROL_POINT_COUNT }, () => [0, 0]), + width, + targetWidth: [...width], + oldWidth: [...width], + widthChange: Array.from({ length: CONTROL_POINT_COUNT }, () => [0, 0]), + hsl: [hue, saturation, 0], + oldhsl: [hue, saturation, 0], + targethsl: [randf(rng, 1), randomSaturation(rng), 1], + hslChange: [0, 10], + }; +} + +function createParticle(cyclone, rng) { + const particle = { + cyclone, + width: 0, + step: 0, + spinAngle: 0, + color: "#000000", + colorRgb: Object.freeze([0, 0, 0]), + matrix: identity4Flat(), + }; + resetParticle(particle, rng); + return particle; +} + +function resetParticle(particle, rng) { + particle.width = randf(rng, 0.8) + 0.2; + particle.step = 0; + particle.spinAngle = randf(rng, 360); + particle.colorRgb = Object.freeze(hsl2rgb(...particle.cyclone.hsl)); + particle.color = rgbHex(particle.colorRgb); +} + +function stepSource(cyclone, particles, rng, deltaSeconds) { + updateCyclone(cyclone, rng, deltaSeconds); + for (const particle of particles) updateParticle(particle, rng, deltaSeconds); +} + +function updateCyclone(cyclone, rng, deltaSeconds) { + const speed = CSSCYCLONE_SOURCE.speed; + const last = CONTROL_POINT_COUNT - 1; + retargetPoint(cyclone, last, rng, () => [randf(rng, 400) - 200, 200, randf(rng, 400) - 200], 75 / speed, 150 / speed); + retargetPoint(cyclone, last - 1, rng, () => [ + cyclone.xyz[last][0], + randf(rng, 66) + 50, + cyclone.xyz[last][2], + ], 75 / speed, 100 / speed); + for (let index = CSSCYCLONE_SOURCE.complexity; index > 1; index -= 1) { + retargetPoint(cyclone, index, rng, () => [ + cyclone.targetxyz[index + 1][0] + + (cyclone.targetxyz[index + 1][0] - cyclone.targetxyz[index + 2][0]) / 2 + randf(rng, 100) - 50, + clamp((cyclone.targetxyz[index + 1][1] + cyclone.targetxyz[index - 1][1]) / 2 + randf(rng, 25) - 12, -200, 200), + cyclone.targetxyz[index + 1][2] + + (cyclone.targetxyz[index + 1][2] - cyclone.targetxyz[index + 2][2]) / 2 + randf(rng, 100) - 50, + ], 50 / speed, 75 / speed); + } + retargetPoint(cyclone, 1, rng, () => [ + cyclone.targetxyz[2][0] + randf(rng, 100) - 50, + -randf(rng, 100) - 50, + cyclone.targetxyz[2][2] + randf(rng, 100) - 50, + ], 30 / speed, 50 / speed); + retargetPoint(cyclone, 0, rng, () => [ + cyclone.xyz[1][0] + randf(rng, 25) - 12, + -200, + cyclone.xyz[1][2] + randf(rng, 25) - 12, + ], 75 / speed, 100 / speed); + for (let index = 0; index < CONTROL_POINT_COUNT; index += 1) { + const transition = cyclone.xyzChange[index]; + const phase = transition[1] === 0 ? 0 : transition[0] / transition[1] * Math.PI * 2; + const between = (1 - Math.cos(phase)) / 2; + cyclone.xyz[index] = mix3(cyclone.oldxyz[index], cyclone.targetxyz[index], between); + transition[0] += deltaSeconds; + } + + retargetWidth(cyclone, last, rng, 75, 225, 50 / speed, 50 / speed); + retargetWidth(cyclone, last - 1, rng, 15, 100, 50 / speed, 50 / speed); + for (let index = CSSCYCLONE_SOURCE.complexity; index > 1; index -= 1) { + retargetWidth(cyclone, index, rng, 15, 50, 40 / speed, 50 / speed); + } + retargetWidth(cyclone, 1, rng, 5, 40, 30 / speed, 50 / speed); + retargetWidth(cyclone, 0, rng, 5, 30, 20 / speed, 50 / speed); + for (let index = 0; index < CONTROL_POINT_COUNT; index += 1) { + const transition = cyclone.widthChange[index]; + const between = transition[1] === 0 ? 0 : transition[0] / transition[1]; + cyclone.width[index] = mix(cyclone.oldWidth[index], cyclone.targetWidth[index], between); + transition[0] += deltaSeconds; + } + + if (cyclone.hslChange[0] >= cyclone.hslChange[1]) { + cyclone.oldhsl = [...cyclone.hsl]; + cyclone.targethsl = [randf(rng, 1), randomSaturation(rng), Math.min(1, randf(rng, 1) + 0.5)]; + cyclone.hslChange = [0, randf(rng, 30) + 2]; + } + const between = cyclone.hslChange[0] / cyclone.hslChange[1]; + const diff = cyclone.targethsl[0] - cyclone.oldhsl[0]; + const direction = (diff > 0.5 || diff < -0.5) && diff > 0.5 ? 1 : 0; + cyclone.hsl = hslTween(cyclone.oldhsl, cyclone.targethsl, between, direction); + cyclone.hslChange[0] += deltaSeconds; +} + +function retargetPoint(cyclone, index, rng, next, minimumDuration, randomDuration) { + const transition = cyclone.xyzChange[index]; + if (transition[0] < transition[1]) return; + cyclone.oldxyz[index] = [...cyclone.xyz[index]]; + cyclone.targetxyz[index] = next(); + transition[0] = 0; + transition[1] = randf(rng, randomDuration) + minimumDuration; +} + +function retargetWidth(cyclone, index, rng, minimum, randomRange, minimumDuration, randomDuration) { + const transition = cyclone.widthChange[index]; + if (transition[0] < transition[1]) return; + cyclone.oldWidth[index] = cyclone.width[index]; + cyclone.targetWidth[index] = randf(rng, randomRange) + minimum; + transition[0] = 0; + transition[1] = randf(rng, randomDuration) + minimumDuration; +} + +function updateParticle(particle, rng, deltaSeconds) { + if (particle.step > 1) resetParticle(particle, rng); + const cyclone = particle.cyclone; + const position = bezier(cyclone.xyz, particle.step); + const previous = bezier(cyclone.xyz, particle.step - 0.01); + const direction = normalize(sub3(position, previous)); + const up = [0, 1, 0]; + const crossVector = cross3(direction, up); + const tiltAngle = -Math.acos(clamp(dot3(direction, up), -1, 1)) * 180 / PI; + let widthIndex = Math.floor(particle.step * (CSSCYCLONE_SOURCE.complexity + 2)); + if (widthIndex >= CSSCYCLONE_SOURCE.complexity + 2) widthIndex = CSSCYCLONE_SOURCE.complexity + 1; + const between = (particle.step - widthIndex / (CSSCYCLONE_SOURCE.complexity + 2)) * (CSSCYCLONE_SOURCE.complexity + 2); + const cycloneWidth = cyclone.width[widthIndex] * (1 - between) + cyclone.width[widthIndex + 1] * between; + const stepDelta = 0.2 * deltaSeconds * CSSCYCLONE_SOURCE.speed / + (particle.width * particle.width * cycloneWidth); + particle.step += stepDelta; + const spinDelta = 1500 * deltaSeconds * CSSCYCLONE_SOURCE.speed / + (particle.width * cycloneWidth); + particle.spinAngle += spinDelta; + let stretch = particle.width * cycloneWidth * spinDelta * 0.02; + stretch = Math.min(stretch, cycloneWidth * 2 / CSSCYCLONE_SOURCE.particleSize); + stretch = Math.max(stretch, 3); + const matrix = multiply4( + translation(position), + multiply4( + rotationAxis(tiltAngle, crossVector), + multiply4( + rotationY(particle.spinAngle), + multiply4(translation([particle.width * cycloneWidth, 0, 0]), scale4([1, 1, stretch])), + ), + ), + ); + particle.matrix = flattenCss(matrix); +} + +function bezier(points, step) { + const output = [0, 0, 0]; + const degree = CONTROL_POINT_COUNT - 1; + for (let index = 0; index < CONTROL_POINT_COUNT; index += 1) { + const blend = FACTORIALS[degree] / (FACTORIALS[index] * FACTORIALS[degree - index]) * + Math.pow(step, index) * Math.pow(1 - step, degree - index); + output[0] += points[index][0] * blend; + output[1] += points[index][1] * blend; + output[2] += points[index][2] * blend; + } + return output; +} + +function hsl2rgb(hue, saturation, luminosity) { + let h = hue % 1; + if (h < 0) h += 1; + let red; + let green; + let blue; + if (h < 1 / 6) { + red = 1; green = h * 6; blue = 0; + } else if (h < 1 / 2) { + green = 1; + if (h < 1 / 3) { + red = 1 - (h - 1 / 6) * 6; blue = 0; + } else { + blue = (h - 1 / 3) * 6; red = 0; + } + } else if (h < 5 / 6) { + blue = 1; + if (h < 2 / 3) { + green = 1 - (h - 1 / 2) * 6; red = 0; + } else { + red = (h - 2 / 3) * 6; green = 0; + } + } else { + red = 1; blue = 1 - (h - 5 / 6) * 6; green = 0; + } + red = (1 - saturation * (1 - red)) * luminosity; + green = (1 - saturation * (1 - green)) * luminosity; + blue = (1 - saturation * (1 - blue)) * luminosity; + return [red, green, blue]; +} + +function hslTween(from, to, tween, direction) { + let hue; + if (!direction) { + hue = to[0] >= from[0] + ? from[0] + tween * (to[0] - from[0]) + : from[0] + tween * (1 - (from[0] - to[0])); + if (hue > 1) hue -= 1; + } else { + hue = from[0] >= to[0] + ? from[0] - tween * (from[0] - to[0]) + : from[0] - tween * (1 - (to[0] - from[0])); + if (hue < 0) hue += 1; + } + return [hue, mix(from[1], to[1], tween), mix(from[2], to[2], tween)]; +} + +function createMt19937(seed) { + const state = new Uint32Array(624); + state[0] = seed >>> 0; + for (let index = 1; index < state.length; index += 1) { + state[index] = (Math.imul(1812433253, state[index - 1] ^ (state[index - 1] >>> 30)) + index) >>> 0; + } + let cursor = state.length; + return Object.freeze({ + nextFloat() { + if (cursor >= state.length) { + for (let index = 0; index < state.length; index += 1) { + const value = (state[index] & 0x80000000) | (state[(index + 1) % 624] & 0x7fffffff); + state[index] = state[(index + 397) % 624] ^ (value >>> 1) ^ ((value & 1) ? 0x9908b0df : 0); + } + cursor = 0; + } + let value = state[cursor++]; + value ^= value >>> 11; + value ^= (value << 7) & 0x9d2c5680; + value ^= (value << 15) & 0xefc60000; + value ^= value >>> 18; + return (value >>> 8) / 0x01000000; + }, + }); +} + +function randf(rng, maximum) { + return rng.nextFloat() * maximum; +} + +function randomSaturation(rng) { + return Math.sqrt(randf(rng, 1)); +} + +function identity4() { + return [[1, 0, 0, 0], [0, 1, 0, 0], [0, 0, 1, 0], [0, 0, 0, 1]]; +} + +function identity4Flat() { + return Object.freeze([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 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 rotationY(degrees) { + const radians = degrees * PI / 180; + const cosine = Math.cos(radians); + const sine = Math.sin(radians); + return [[cosine, 0, sine, 0], [0, 1, 0, 0], [-sine, 0, cosine, 0], [0, 0, 0, 1]]; +} + +function rotationAxis(degrees, axis) { + const length = Math.hypot(...axis); + if (length < 1e-12 || Math.abs(degrees) < 1e-12) return identity4(); + const [x, y, z] = axis.map((value) => value / length); + const radians = degrees * PI / 180; + const cosine = Math.cos(radians); + const sine = Math.sin(radians); + const one = 1 - cosine; + return [ + [x * x * one + cosine, x * y * one - z * sine, x * z * one + y * sine, 0], + [y * x * one + z * sine, y * y * one + cosine, y * z * one - x * sine, 0], + [z * x * one - y * sine, z * y * one + x * sine, z * z * one + cosine, 0], + [0, 0, 0, 1], + ]; +} + +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(6)); + return Object.is(result, -0) ? 0 : result; +} + +function rgbHex(color) { + return `#${color.map((value) => Math.round(clamp(value, 0, 1) * 255).toString(16).padStart(2, "0")).join("")}`; +} + +function mix(left, right, amount) { + return left + (right - left) * amount; +} + +function mix3(left, right, amount) { + return left.map((value, index) => mix(value, right[index], amount)); +} + +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 cross3(left, right) { + return [ + left[1] * right[2] - right[1] * left[2], + left[2] * right[0] - right[2] * left[0], + left[0] * right[1] - right[0] * left[1], + ]; +} + +function normalize(vector) { + const length = Math.hypot(...vector); + return length === 0 ? [...vector] : vector.map((value) => value / length); +} + +function clamp(value, minimum, maximum) { + return Math.min(maximum, Math.max(minimum, value)); +} + +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/cyclone/test/csscyclone-shell.test.mjs b/src/adapters/cyclone/test/csscyclone-shell.test.mjs new file mode 100644 index 0000000..2180aa0 --- /dev/null +++ b/src/adapters/cyclone/test/csscyclone-shell.test.mjs @@ -0,0 +1,31 @@ +import assert from "node:assert/strict"; +import { readFile } from "node:fs/promises"; +import test from "node:test"; + +const adapter = new URL("../", import.meta.url); + +test("uses the cssGraphics shell without product UI or alternate renderers", async () => { + const [html, client, playback, styles] = await Promise.all([ + readFile(new URL("index.html", adapter), "utf8"), + readFile(new URL("src/csscyclone/client.mjs", adapter), "utf8"), + readFile(new URL("src/csscyclone/preparedPlayback.mjs", adapter), "utf8"), + readFile(new URL("src/csscyclone/styles.css", adapter), "utf8"), + ]); + assert.match(html, /class="site-wordmark-svg" viewBox="0 0 190 30"/u); + assert.match(html, /