Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions netlify.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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]
Expand Down
7 changes: 6 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -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",
Expand All @@ -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",
Expand All @@ -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",
Expand Down Expand Up @@ -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",
Expand Down
2 changes: 1 addition & 1 deletion site/test/landing.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
20 changes: 20 additions & 0 deletions src/adapters/cyclone/NOTICE.md
Original file line number Diff line number Diff line change
@@ -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:

- <https://github.com/reallyslickscreensavers/reallyslickscreensavers/tree/5f0a788bf0cc47f66a233ed528919295cd1e7500>
- 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.
54 changes: 54 additions & 0 deletions src/adapters/cyclone/README.md
Original file line number Diff line number Diff line change
@@ -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).
32 changes: 32 additions & 0 deletions src/adapters/cyclone/index.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta name="description" content="Really Slick Cyclone rendered entirely with HTML and CSS using PolyCSS.">
<meta name="robots" content="noindex, nofollow">
<meta name="theme-color" content="#000000">
<link rel="icon" href="data:,">
<title>Cyclone - Powered by PolyCSS</title>
</head>
<body class="loading">
<header class="site-header">
<h1 class="site-wordmark-heading">
<a class="site-wordmark" href="/" aria-label="Cyclone - css.graphics home">
<svg class="site-wordmark-svg" viewBox="0 0 190 30" aria-hidden="true" focusable="false">
<text x="0" y="23"><tspan class="site-wordmark-css">css.</tspan><tspan class="site-wordmark-graphics">graphics</tspan><tspan class="site-wordmark-path">/cyclone</tspan></text>
</svg>
</a>
</h1>
<nav class="site-actions" aria-label="Scene actions">
<a class="site-action-icon-only" href="https://github.com/layoutit/cssGraphics" target="_blank" rel="noopener noreferrer" aria-label="View cssGraphics on GitHub" title="View cssGraphics on GitHub">
<svg class="site-action-icon" viewBox="0 0 24 24" aria-hidden="true" focusable="false">
<path d="M15 22v-4a4.8 4.8 0 0 0-1-3.5c3.3-.4 6.8-1.6 6.8-7A5.5 5.5 0 0 0 19.3 3.7 5.1 5.1 0 0 0 19.1 0S17.9-.4 15 1.5a13.4 13.4 0 0 0-6 0C6.1-.4 4.9 0 4.9 0a5.1 5.1 0 0 0-.2 3.7 5.5 5.5 0 0 0-1.5 3.8c0 5.4 3.5 6.6 6.8 7A4.8 4.8 0 0 0 9 18v4"></path>
<path d="M9 19c-3 .9-3-1.5-4.2-2"></path>
</svg>
</a>
</nav>
</header>
<script type="module" src="/src/main.mjs"></script>
</body>
</html>
28 changes: 28 additions & 0 deletions src/adapters/cyclone/notes/references/source-lock.json
Original file line number Diff line number Diff line change
@@ -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"
}
]
}
}
175 changes: 175 additions & 0 deletions src/adapters/cyclone/src/csscyclone/client.mjs
Original file line number Diff line number Diff line change
@@ -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();
}
Loading