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
2 changes: 2 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -2,3 +2,5 @@ node_modules/
dist/
__screenshots__/
.vitest/
examples/public/demos/*/index.html
examples/public/demos/lights-out/frame.html
5 changes: 4 additions & 1 deletion .prettierignore
Original file line number Diff line number Diff line change
@@ -1,3 +1,6 @@
**/dist/
package-lock.json
examples/public/
examples/public/**/*.*
!examples/public/demos/*/*.mjs
!examples/public/demos/*/*.css
!examples/public/demos/find-library/corpus.json
14 changes: 7 additions & 7 deletions CSS-FEATURES.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,10 +2,10 @@

Changes to make once a feature is stable in Chrome, Firefox and Safari. Status from 2026-10-31.

| Feature | Waiting on | Change |
| ---------------- | ----------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `inherit()` | Chrome release, Firefox | Replace the alternating slots and `@container style()` rules in `Accumulator` with `--name: combine(inherit(--name), var(--name-input))`. Chrome 158 and 159 compute it without a flag only when the declaration contains `var()`, and `CSS.supports()` still reports false. |
| `random()` | Firefox, in Nightly 161 | Give the `particle()` callback of `ParticleSystem` per-element CSS values instead of the `Math.random()` numbers its `random` helper returns. This changes its API. Check the final keyword name first, `element-scoped` is being renamed to `per-element`. |
| `@function` | Firefox | Express the filters and transforms from the `particle()` callback as custom functions of its random values and a shared progress, so `ParticleSystem` no longer needs `Tween`s per particle. |
| `image(<color>)` | WebKit `multiply` bug | Replace the `linear-gradient(color, color)` tint layer in `Filter`. WebKit 27.2 paints `image(<color>)` without applying `background-blend-mode: multiply`, which the tint relies on. |
| `user-select` | Safari, preview only | Remove `-webkit-user-select` from `styles.css`. |
| Feature | Waiting on | Change |
| ---------------- | ----------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `inherit()` | Chrome release, Firefox | Replace the alternating slots and `@container style()` rules in `Accumulator` with `--name: combine(inherit(--name), var(--name-input))`. Chrome 158 and 159 compute it without a flag only when the declaration contains `var()`, and `CSS.supports()` still reports false. A composite does this for each `--name-k`. |
| `random()` | Firefox, in Nightly 161 | Give the `particle()` callback of `ParticleSystem` per-element CSS values instead of the `Math.random()` numbers its `random` helper returns. This changes its API. Check the final keyword name first, `element-scoped` is being renamed to `per-element`. |
| `@function` | Firefox | Express the filters and transforms from the `particle()` callback as custom functions of its random values and a shared progress, so `ParticleSystem` no longer needs `Tween`s per particle. Also the repeated specular and point light stops in `Shading.ts`. |
| `image(<color>)` | WebKit `multiply` bug | Replace the `linear-gradient(color, color)` tint layer in `Filter`. WebKit 27.2 paints `image(<color>)` without applying `background-blend-mode: multiply`, which the tint relies on. |
| `user-select` | Safari, preview only | Remove `-webkit-user-select` from `styles.css`. |
15 changes: 15 additions & 0 deletions examples/demos.eta
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="<%= it.viewport ?? "width=device-width,initial-scale=1" %>">
<meta http-equiv="Content-Security-Policy" content="script-src 'none'">
<title><%= it.title %></title>
<link rel="icon" href="data:,">
<link rel="stylesheet" href="style.css">
<style><%~ it.css %></style>
</head>
<body>
<%~ it.body %>
</body>
</html>
273 changes: 273 additions & 0 deletions examples/demos.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,273 @@
// Each public/demos/<name>/index.mjs writes the page next to it. The pages run no script, so
// they are generated before Vite serves or copies them, and are not committed.
import { execFile } from "node:child_process";
import {
existsSync,
mkdtempSync,
readFileSync,
readdirSync,
rmSync,
writeFileSync,
} from "node:fs";
import { tmpdir } from "node:os";
import { join, relative, resolve } from "node:path";
import { Eta } from "eta";
import { mat4 } from "gl-matrix";
import { build } from "vite";

const DEMOS = resolve(import.meta.dirname, "public/demos");
const LAYOUT = resolve(import.meta.dirname, "demos.eta");

// From source when generation starts, a dev server picks up library edits on restart
const buildBundle = async () => {
const outDir = mkdtempSync(join(tmpdir(), "c3r-"));
await build({
configFile: false,
logLevel: "error",
build: {
lib: {
entry: resolve(import.meta.dirname, "../packages/c3r/src/C3R.ts"),
name: "C3R",
formats: ["umd"],
fileName: () => "c3r.js",
},
outDir,
minify: false,
},
});
return join(outDir, "c3r.js");
};

let bundle;
// The plugin passes its build to each generator, which then skips its own
const c3rBundle = () =>
(bundle ??= process.env.C3R_BUNDLE
? Promise.resolve(process.env.C3R_BUNDLE)
: buildBundle());

// Set by happyC3R, for addFlat
let C3R;
// dir is served at /, for scenes that measure their images
export async function happyC3R(dir) {
const { GlobalRegistrator } = await import("@happy-dom/global-registrator");
GlobalRegistrator.register({
url: "http://localhost/",
settings: {
enableIframePageLoading: true,
handleDisabledFileLoadingAsSuccess: true,
...(dir && {
enableImageFileLoading: true,
fetch: { virtualServers: [{ url: "/", directory: dir }] },
}),
},
});
const module = { exports: {} };
new Function("module", "exports", readFileSync(await c3rBundle(), "utf8"))(
module,
module.exports,
);
return (C3R = module.exports);
}

export const load = (f) => JSON.parse(readFileSync(f, "utf8"));

// Writes <dir>/<name>.html from <dir>/<name>.eta, which can use the shared layout as "@layout"
export const writePage = (dir, data, name = "index") => {
const eta = new Eta({ views: dir });
eta.loadTemplate("@layout", readFileSync(LAYOUT, "utf8"));
writeFileSync(join(dir, `${name}.html`), eta.render(name, data));
};

export const sub = (a, b) => a.map((x, i) => x - b[i]);
export const cross = (a, b) => [
a[1] * b[2] - a[2] * b[1],
a[2] * b[0] - a[0] * b[2],
a[0] * b[1] - a[1] * b[0],
];
const unit = (a) => {
const l = Math.hypot(...a) || 1;
return a.map((x) => x / l);
};
export const dot = (a, b) => a[0] * b[0] + a[1] * b[1] + a[2] * b[2];

// The value c3r gives a camera at eye looking at target, for CSS-only camera states
export const cameraView = (eye, target) => {
const view = mat4.invert(
mat4.create(),
mat4.targetTo(mat4.create(), eye, target, [0, 1, 0]),
);
return `--c3r-camera-view:matrix3d(${[...view].map((v) => +v.toFixed(5)).join(",")})`;
};

// The faces of the box from lo to hi, each counterclockwise seen from outside
export const boxFaces = ([x0, y0, z0], [x1, y1, z1]) => ({
"+z": [
[x0, y0, z1],
[x1, y0, z1],
[x1, y1, z1],
[x0, y1, z1],
],
"-z": [
[x1, y0, z0],
[x0, y0, z0],
[x0, y1, z0],
[x1, y1, z0],
],
"+x": [
[x1, y0, z1],
[x1, y0, z0],
[x1, y1, z0],
[x1, y1, z1],
],
"-x": [
[x0, y0, z0],
[x0, y0, z1],
[x0, y1, z1],
[x0, y1, z0],
],
"+y": [
[x0, y1, z1],
[x1, y1, z1],
[x1, y1, z0],
[x0, y1, z0],
],
"-y": [
[x0, y0, z0],
[x1, y0, z0],
[x1, y0, z1],
[x0, y0, z1],
],
});
// A rectangle facing side, lo and hi share its coordinate along that axis
export const rect = (side, lo, hi) => boxFaces(lo, hi)[side];
export const normalOf = (p) => unit(cross(sub(p[1], p[0]), sub(p[2], p[0])));
// A face turned away from every eye can stay out of the DOM
export const facesAny = (pts, eyes) => {
const n = normalOf(pts);
return eyes.some((eye) => dot(n, sub(eye, pts[0])) > 0);
};
const areaOf = (p) =>
Math.hypot(...cross(sub(p[1], p[0]), sub(p[2], p[0]))) / 2;
export const hex = (rgb) =>
"#" +
rgb
.map((v) =>
Math.max(0, Math.min(255, Math.round(v)))
.toString(16)
.padStart(2, "0"),
)
.join("");
export const rgb = (h) => [1, 3, 5].map((i) => parseInt(h.slice(i, i + 2), 16));

// c3r's flat shading sits in background-color, which the page's own color would replace.
// So the light is baked into the color, and it follows no later transform.
export const lit = (color, n, { ambient, intensity, direction }) => {
const k = ambient + intensity * Math.max(0, -dot(n, unit(direction)));
return hex(rgb(color).map((v) => v * k));
};

// Colors replace the atlas, so uvs only need to stay affine across a plane
export const toVertices = (p) => {
// Rounded, so coplanar triangles get the same uv basis and can merge
const n = unit(normalOf(p).map((x) => Math.round(x * 500) / 500));
const a = Math.abs(n[1]) > 0.9 ? [1, 0, 0] : [0, 1, 0];
const u = unit(cross(a, n));
const v = cross(n, u);
return p.map((q) => ({
position: q,
uv: [dot(q, u) / 200, dot(q, v) / 200],
}));
};

// One polygon per merged face, each with its own element from makeEl(color).
// With an eye, faces go in far to near: Firefox can fall back on document order.
export const addFlat = (group, tris, makeEl, eye) => {
const byColor = new Map();
for (const t of tris) {
// Kits ship zero-area triangles, which have no plane
if (areaOf(t.p) < 5e-4) continue;
if (!byColor.has(t.c)) byColor.set(t.c, []);
byColor.get(t.c).push(t.p);
}
const all = [...byColor].flatMap(([c, list]) =>
new C3R.MeshBuilder([list.map(toVertices)], { uvTolerance: 100 })
.faces(0)
.map((f) => ({ ...f, c })),
);
if (eye) {
const far = ({ vertices }) =>
Math.max(...vertices.map((v) => Math.hypot(...sub(v.position, eye))));
all.sort((a, b) => far(b) - far(a));
}
for (const { vertices, edges, c } of all) {
// Slivers from kit meshes collapse to under 3 sides once simplified
try {
group.addPolygon(vertices, { contentElement: makeEl(c), edges });
} catch {}
}
};

const generate = async (name) => {
const env = { ...process.env, C3R_BUNDLE: await c3rBundle() };
const script = join(DEMOS, name, "index.mjs");
return new Promise((res, rej) =>
execFile(process.execPath, [script], { env }, (e, _, stderr) =>
e ? rej(new Error(`${name}: ${stderr || e.message}`)) : res(),
),
);
};
const demoNames = () =>
readdirSync(DEMOS).filter((d) => existsSync(join(DEMOS, d, "index.mjs")));

// Sources directly in a demo folder. Not the page it writes, which would loop
const demoOfSource = (file) => {
const [name, source, ...deeper] = relative(DEMOS, file).split(/[\\/]/);
return !deeper.length && /\.(mjs|json|eta)$/.test(source) ? name : undefined;
};

export function demos() {
let built;
return {
name: "demos",
// Before the build starts, its timings would count the wait on the generators
async configResolved(config) {
if (config.command !== "build") return;
built = resolve(config.root, config.build.outDir, "demos");
await Promise.all(demoNames().map(generate));
},
// Public files are copied whole, the generator sources don't belong online
closeBundle() {
if (!built || !existsSync(built)) return;
for (const name of demoNames())
for (const file of readdirSync(join(built, name)))
if (demoOfSource(join(DEMOS, name, file)))
rmSync(join(built, name, file));
},
configureServer(server) {
const running = new Map();
const regenerate = (name) => {
const run = generate(name).catch((e) =>
server.config.logger.error(e.message),
);
running.set(name, run);
return run;
};
demoNames().forEach(regenerate);
const reload = () => server.ws.send({ type: "full-reload" });
server.watcher.on("change", (file) => {
if (resolve(file) === LAYOUT) {
Promise.all(demoNames().map(regenerate)).then(reload);
return;
}
const name = demoOfSource(file);
if (running.has(name)) regenerate(name).then(reload);
});
// A page asked for before it is written waits for it
server.middlewares.use(async (req, _, next) => {
const name = /^\/demos\/([^/]+)\//.exec(req.url)?.[1];
if (running.has(name)) await running.get(name);
next();
});
},
};
}
40 changes: 27 additions & 13 deletions examples/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -41,26 +41,40 @@ <h1>CSS 3D examples</h1>
<h2>Standalone</h2>

<ul>
<li><a href="cube.html">Spinning box</a></li>
<li><a href="filters.html">Filter effects</a></li>
<li><a href="canvas.html">Drawable canvas cube</a></li>
<li><a href="billboard.html">Billboards</a></li>
<li><a href="fire.html">Bonfire</a></li>
<li><a href="standalone/cube.html">Spinning box</a></li>
<li><a href="standalone/filters.html">Filter effects</a></li>
<li><a href="standalone/canvas.html">Drawable canvas cube</a></li>
<li><a href="standalone/billboard.html">Billboards</a></li>
<li><a href="standalone/fire.html">Bonfire</a></li>
<li><a href="standalone/shading.html">Shading</a></li>
<li><a href="standalone/specular.html">Specular</a></li>
<li><a href="standalone/point_light.html">Point light</a></li>
</ul>

<h2>Tech demos</h2>

<ul>
<li><a href="demos/find-library/index.html">Find Library</a></li>
<li><a href="demos/find-code/index.html">Find the Code</a></li>
<li><a href="demos/fog-of-history/index.html">Fog of History</a></li>
<li><a href="demos/lights-out/index.html">Lights Out</a></li>
<li><a href="demos/moon/index.html">The Moon</a></li>
<li><a href="demos/globe-gores/index.html">America, folded</a></li>
</ul>

<h2>Ported three.js examples</h2>

<ul>
<li><a href="three_geometry_cube.html">Cube geometry</a></li>
<li><a href="three_geometry_earth.html">Earth</a></li>
<li><a href="threejs/geometry_cube.html">Cube geometry</a></li>
<li><a href="threejs/geometry_earth.html">Earth</a></li>
<li>
<a href="three_interactive_cubes_tween.html">Interactive cubes</a>
<a href="threejs/interactive_cubes_tween.html">Interactive cubes</a>
</li>
<li><a href="three_periodictable.html">Periodic table</a></li>
<li><a href="three_sprites.html">Sprites</a></li>
<li><a href="three_fps.html">FPS</a></li>
<li><a href="three_loader_obj.html">OBJ loader</a></li>
<li><a href="three_physics_rope.html">Physics rope</a></li>
<li><a href="threejs/periodictable.html">Periodic table</a></li>
<li><a href="threejs/sprites.html">Sprites</a></li>
<li><a href="threejs/fps.html">FPS</a></li>
<li><a href="threejs/loader_obj.html">OBJ loader</a></li>
<li><a href="threejs/physics_rope.html">Physics rope</a></li>
</ul>
</main>

Expand Down
3 changes: 3 additions & 0 deletions examples/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,10 @@
"build": "vite build"
},
"devDependencies": {
"@happy-dom/global-registrator": "^21.0.4",
"@picocss/pico": "^2.1.1",
"eta": "^4.6.0",
"gl-matrix": "^3.4.4",
"nipplejs": "^1.0.4",
"primitive-geometry": "^3.3.1",
"three": "^0.186.1",
Expand Down
Loading
Loading