diff --git a/CSS-FEATURES.md b/CSS-FEATURES.md new file mode 100644 index 0000000..d65c886 --- /dev/null +++ b/CSS-FEATURES.md @@ -0,0 +1,11 @@ +# CSS feature TODOs + +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()` | WebKit `multiply` bug | Replace the `linear-gradient(color, color)` tint layer in `Filter`. WebKit 27.2 paints `image()` without applying `background-blend-mode: multiply`, which the tint relies on. | +| `user-select` | Safari, preview only | Remove `-webkit-user-select` from `styles.css`. | diff --git a/examples/billboard.html b/examples/billboard.html new file mode 100644 index 0000000..03098f9 --- /dev/null +++ b/examples/billboard.html @@ -0,0 +1,90 @@ + + + + + + c3r - Billboards + + + +
+ Sprites that always face the camera, on a tumbling ring around a box +
+ + + diff --git a/examples/canvas.html b/examples/canvas.html index 630639a..c1d11c6 100644 --- a/examples/canvas.html +++ b/examples/canvas.html @@ -35,7 +35,6 @@
Each face is a canvas element, draw on them
+ + diff --git a/examples/index.html b/examples/index.html index 061561b..f0bdfe2 100644 --- a/examples/index.html +++ b/examples/index.html @@ -44,6 +44,8 @@

Standalone

  • Spinning box
  • Filter effects
  • Drawable canvas cube
  • +
  • Billboards
  • +
  • Bonfire
  • Ported three.js examples

    diff --git a/examples/package.json b/examples/package.json index 1c07a61..fa0459b 100644 --- a/examples/package.json +++ b/examples/package.json @@ -8,7 +8,6 @@ }, "devDependencies": { "@picocss/pico": "^2.1.1", - "gl-matrix": "^3.4.4", "nipplejs": "^1.0.4", "primitive-geometry": "^3.3.1", "three": "^0.186.1", diff --git a/examples/public/textures/bark.jpg b/examples/public/textures/bark.jpg new file mode 100644 index 0000000..1d54b5e Binary files /dev/null and b/examples/public/textures/bark.jpg differ diff --git a/examples/public/textures/bonfire_1.png b/examples/public/textures/bonfire_1.png new file mode 100644 index 0000000..712833b Binary files /dev/null and b/examples/public/textures/bonfire_1.png differ diff --git a/examples/public/textures/bonfire_2.png b/examples/public/textures/bonfire_2.png new file mode 100644 index 0000000..4518d9e Binary files /dev/null and b/examples/public/textures/bonfire_2.png differ diff --git a/examples/public/textures/firelight.svg b/examples/public/textures/firelight.svg new file mode 100644 index 0000000..17f8415 --- /dev/null +++ b/examples/public/textures/firelight.svg @@ -0,0 +1,97 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/examples/public/textures/log_end.png b/examples/public/textures/log_end.png new file mode 100644 index 0000000..bd048da Binary files /dev/null and b/examples/public/textures/log_end.png differ diff --git a/examples/public/textures/smoke_puff.png b/examples/public/textures/smoke_puff.png new file mode 100644 index 0000000..252fa06 Binary files /dev/null and b/examples/public/textures/smoke_puff.png differ diff --git a/package-lock.json b/package-lock.json index 45b383e..450e8e7 100644 --- a/package-lock.json +++ b/package-lock.json @@ -30,7 +30,6 @@ "name": "c3r-examples", "devDependencies": { "@picocss/pico": "^2.1.1", - "gl-matrix": "^3.4.4", "nipplejs": "^1.0.4", "primitive-geometry": "^3.3.1", "three": "^0.186.1", @@ -2065,7 +2064,7 @@ } }, "packages/c3r": { - "version": "0.3.0", + "version": "0.4.0", "license": "MIT", "dependencies": { "gl-matrix": "^3.4.4" @@ -2076,13 +2075,14 @@ } }, "packages/c3r-three": { - "version": "0.3.0", + "version": "0.4.0", "license": "MIT", "dependencies": { - "c3r": "^0.3.0" + "c3r": "^0.4.0" }, "devDependencies": { "@types/three": "^0.186.0", + "gl-matrix": "^3.4.4", "three": "^0.186.1", "vite": "^8.3.5" }, diff --git a/packages/c3r-three/README.md b/packages/c3r-three/README.md index 0e26129..1151887 100644 --- a/packages/c3r-three/README.md +++ b/packages/c3r-three/README.md @@ -20,7 +20,7 @@ document.body.appendChild(renderer.domElement); renderer.render(scene, camera); ``` -Until `setSize()` the scenes fill the window and follow it, like a C3R `Container`. An orthographic camera needs `setSize()`. +Until `setSize()` the scenes fill the window and follow it, like a C3R `Container`. `CSS3DObject` and `CSS3DSprite` are re-exported from `three/addons`, so their elements receive pointer events as with three's renderer. diff --git a/packages/c3r-three/package.json b/packages/c3r-three/package.json index 00f0996..82b17a7 100644 --- a/packages/c3r-three/package.json +++ b/packages/c3r-three/package.json @@ -1,6 +1,6 @@ { "name": "c3r-three", - "version": "0.3.0", + "version": "0.4.0", "description": "three.js CSS3DRenderer built on C3R, with geometry and texture support", "keywords": [ "three", @@ -34,7 +34,7 @@ "prepublishOnly": "npm run build" }, "dependencies": { - "c3r": "^0.3.0" + "c3r": "^0.4.0" }, "peerDependencies": { "@types/three": ">=0.160.0", @@ -47,6 +47,7 @@ }, "devDependencies": { "@types/three": "^0.186.0", + "gl-matrix": "^3.4.4", "three": "^0.186.1", "vite": "^8.3.5" } diff --git a/packages/c3r-three/src/C3RRenderer.ts b/packages/c3r-three/src/C3RRenderer.ts index 711ca0e..b701ec3 100644 --- a/packages/c3r-three/src/C3RRenderer.ts +++ b/packages/c3r-three/src/C3RRenderer.ts @@ -72,6 +72,7 @@ export class C3RRenderer { #children = new WeakMap>(); #meshes = new WeakMap(); #materials = new WeakMap(); + #poses = new WeakMap(); getSize(): { width: number; height: number } { return { width: this.#width, height: this.#height }; @@ -112,7 +113,7 @@ export class C3RRenderer { camera.updateMatrixWorld(); this.#syncCamera(container, camera); - this.#syncObject(scene, container.root, container); + this.#syncObject(scene, container.root, camera); void container.bake(); } @@ -149,14 +150,10 @@ export class C3RRenderer { } } - container.camera.matrix = camera.matrixWorld.elements; + this.#setPose(container.camera, camera.matrixWorld.elements); } - #syncObject( - object: Object3D, - node: C3R.Group, - container: C3R.Container, - ): void { + #syncObject(object: Object3D, node: C3R.Group, camera: Camera): void { const { style } = node.domElement; const display = object.visible ? "" : "none"; if (style.display !== display) style.display = display; @@ -164,12 +161,12 @@ export class C3RRenderer { const isSprite = object instanceof Sprite || object instanceof CSS3DSprite; const matrix = isSprite - ? this.#spriteMatrix(object, node, container) + ? this.#spriteMatrix(object, node, camera) : object.matrix.elements; - node.matrix = matrix; + this.#setPose(node, matrix); - this.#syncChildren(object, node, container); + this.#syncChildren(object, node, camera); if (isSprite && node instanceof C3R.ElementObject) { node.rotation = @@ -182,13 +179,17 @@ export class C3RRenderer { if (object instanceof Mesh) this.#syncMesh(object, node); } - #spriteMatrix( - object: Object3D, - node: C3R.Group, - container: C3R.Container, - ): number[] { + // Most nodes hold still, a new Transform each frame is the slow part + #setPose(target: C3R.Group | C3R.Camera, elements: number[]): void { + const last = this.#poses.get(target); + if (last?.every((v, i) => v === elements[i])) return; + target.transform = C3R.Transform.matrix(elements); + this.#poses.set(target, [...elements]); + } + + #spriteMatrix(object: Object3D, node: C3R.Group, camera: Camera): number[] { // Billboard in world space - _matrix.extractRotation(_matrix.fromArray(container.camera.matrix)); + _matrix.extractRotation(camera.matrixWorld); object.matrixWorld.decompose(_position, _quaternion, _scale); _matrix.setPosition(_position).scale(_scale); @@ -206,11 +207,7 @@ export class C3RRenderer { return _matrix.elements; } - #syncChildren( - object: Object3D, - node: C3R.Group, - container: C3R.Container, - ): void { + #syncChildren(object: Object3D, node: C3R.Group, camera: Camera): void { const previous = this.#children.get(node) ?? new Map(); const current = new Map(); @@ -230,7 +227,7 @@ export class C3RRenderer { this.#children.set(node, current); for (const [child, childNode] of current) { - this.#syncObject(child, childNode, container); + this.#syncObject(child, childNode, camera); } } diff --git a/packages/c3r-three/test/earth.browser.test.ts b/packages/c3r-three/test/earth.browser.test.ts index 51cae53..2df50a0 100644 --- a/packages/c3r-three/test/earth.browser.test.ts +++ b/packages/c3r-three/test/earth.browser.test.ts @@ -1,5 +1,5 @@ import { expect, it } from "vitest"; -import { commands, page } from "vitest/browser"; +import { commands, page, server } from "vitest/browser"; import pixelmatch from "pixelmatch"; import { CanvasTexture, @@ -96,7 +96,11 @@ async function pixels(element: Element, name: string) { return context.getImageData(0, 0, W, H); } -it("renders the earth example like WebGLRenderer", async () => { +// Firefox misorders a polygon cut by an earlier one's plane under perspective, +// which this camera hits, and Playwright's WebKit is not Safari +const chromiumOnly = it.skipIf(server.browser !== "chromium"); + +chromiumOnly("renders the earth example like WebGLRenderer", async () => { const { scene, camera } = await earthScene(); document.body.style.cssText = "margin:0;background:#fff"; diff --git a/packages/c3r/README.md b/packages/c3r/README.md index 07a5789..9275e4e 100644 --- a/packages/c3r/README.md +++ b/packages/c3r/README.md @@ -17,7 +17,7 @@ import { Container, Polygon } from "c3r"; Or with a script tag, using [GitHub Releases](https://github.com/K-Adam/CSS3D-Renderer/releases) or a CDN: ```html - + ``` It bundles gl-matrix and exposes a `C3R` global. @@ -27,8 +27,7 @@ TypeScript declarations are included. ## API ```js -import { Container, Group, Material } from "c3r"; -import { mat4 } from "gl-matrix"; +import { Container, Group, Material, Transform } from "c3r"; import { cube } from "primitive-geometry"; const container = new Container(); // fills the window until setSize() @@ -41,33 +40,44 @@ box.addMesh(cube({ sx: 200 })); box.material = new Material(); box.material.background = "crate.jpg"; -container.camera.matrix = mat4.fromTranslation(mat4.create(), [0, 0, 400]); +container.camera.transform = Transform.translate([0, 0, 400]); ``` -| Class | What it is | -| ----------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `Container` | The viewport: `domElement`, `root` group, `camera`, `setSize()`, `unitScale`, `bake()` fits it, `toHTML()` (async) | -| `Group` | A transformed node: `matrix`, `tween` (one or an array), `addChild()`, `addPolygon()`, `addTriangles()` merges into polygons, `addMesh()` takes flat `positions`, `uvs`, `cells` | -| `Polygon` | A flat, convex or concave textured polygon from CCW vertices, with `uvScale`, `uvOffset`, `textureSize` (set it for a data url, otherwise `bake()` measures it) | -| `ElementObject` | Any HTML element in the scene, centered on the origin, with `scale`, `center`, `rotation` | -| `Tween` | A CSS animation of a group or camera: `keyframes` of `Transform` and `Filter` steps, `from`, `duration`, `delay`, `easing`, `loop`, `alternate`, `paused`, `hover` | -| `Transform`, `Filter` | Steps from factories: `translate` (scene units), `rotateX`, `rotateY`, `rotateZ`, `scale`; `opacity`, `brightness`, `contrast`, `saturate`, `grayscale`, `sepia`, `invert`, `hueRotate`, `tint` | -| `Material` | `background` (`[r, g, b, a]` in 0-1, or an image URL), `filter` (one or an array of `Filter` steps, before a tween's), `doubleSided`, inherited by the object's descendants; on an `ElementObject`, its element only | -| `PerspectiveCamera`, `OrthographicCamera` | `matrix` (pose), `viewMatrix` (read-only inverse), `tween`, and `fov`, or `left`, `right`, `top`, `bottom` | +| Class | What it is | +| ----------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `Container` | The viewport: `domElement`, `root` group, `camera`, `setSize()`, `unitScale`, `bake()` fits it, `toHTML()` (async) | +| `Group` | A transformed node: `transform` and `tween` (one or an array each, `[]` resets `transform`), `clock`, `addChild()`, `addPolygon()`, `addTriangles()` merges into polygons, `addMesh()` takes flat `positions`, `uvs`, `cells` | +| `Polygon` | A flat, convex or concave textured polygon from CCW vertices, with `uvScale`, `uvOffset`, `textureSize` (set it for a data url, otherwise `bake()` measures it) | +| `ElementObject` | Any HTML element in the scene, centered on the origin, with `scale`, `center`, `rotation` | +| `Billboard` | A group that faces the camera | +| `ParticleSystem` | A group of camera-facing particles, each tweened through its life: `count`, `lifetime`, `lifetimeRandomness`, `mesh`, `particle` | +| `CssValue` | A CSS value built from factories: `number`, `px`, `rad`, `add`, `sub`, `mul`, `div`, `fn` | +| `Tween` | A CSS animation of a group or camera: `keyframes` of `Transform` and `Filter` steps, `from`, `duration`, `delay`, `easing`, `loop`, `alternate`, `paused`, `hover` | +| `Transform`, `Filter` | Steps from factories: `translate` (scene units), `rotateX`, `rotateY`, `rotateZ`, `scale`, `matrix` (column-major); `opacity`, `brightness`, `contrast`, `saturate`, `grayscale`, `sepia`, `invert`, `hueRotate`, `tint` | +| `Material` | `background` (`[r, g, b, a]` in 0-1, or an image URL), `filter` (one or an array of `Filter` steps, before a tween's), `doubleSided`, inherited by the object's descendants; on an `ElementObject`, its element only | +| `PerspectiveCamera`, `OrthographicCamera` | `transform` (pose), `tween`, and `fov`, or `left`, `right`, `top`, `bottom` | An image's alpha shows through. A color and an image don't combine, tint either with `Filter.tint`; a color hides an inherited image and an image hides an inherited color. Every property applies on assignment; there is no `update()` to call. A polygon is laid out from its vertices on construction, so put it in a group to move it. +A material's `flipbook = { columns, rows, frames?, fps? }` plays on polygons an image of frames in a grid, in reading order at 30 fps by default, with the uvs covering one cell. + A `Material`, `Tween` or camera is a class rule in one shared stylesheet, so the objects using it share one style. The rule stays until `[Symbol.dispose]()` is called, or the owner leaves a `using` block; otherwise it is removed once the owner and its elements are garbage-collected. -A tween animates a group after its `matrix`, from rest or from the `from` steps, and a group's tweens apply in array order. Its keyframes play in sequence, `duration` seconds each unless written as `{ steps, duration }`, and `easing` applies to each; a keyframe is one step or an array of steps, and what it doesn't list holds. Transforms apply in the order they first appear, `from` first. Angles are radians and may exceed a half turn. `Filter` steps apply to the polygons and `ElementObject` elements, and a nested group's filters replace its parent's. With `hover`, a `loop` plays only while hovered; any other tween takes one keyframe and eases to it and back. Setting `paused` later pauses everything using the tween; a hover transition can't pause, so it stops responding to hover instead. It runs on the main thread and `bake()` ignores its scale. Use a negative `delay`, in seconds, to offset the phase. +A `transform` array applies in order, like CSS. A tween animates a group after its `transform`, from rest or from the `from` steps, and a group's tweens apply in array order. Its keyframes play in sequence, `duration` seconds each unless written as `{ steps, duration }`, and `easing` applies to each; a keyframe is one step or an array of steps, and what it doesn't list holds. Transforms apply in the order they first appear, `from` first. Angles are radians and may exceed a half turn. A `Transform.matrix` keyframe tweens its translation, rotation and scale separately, and can't have shear, perspective or a zero scale. `Filter` steps apply to the polygons and `ElementObject` elements, and a nested group's filters replace its parent's. With `hover`, a `loop` plays only while hovered; any other tween takes one keyframe and eases to it and back. Setting `paused` later pauses everything using the tween; a hover transition can't pause, so it stops responding to hover instead. It runs on the main thread and `bake()` ignores its scale. Use a negative `delay`, in seconds, to offset the phase. + +A group's `clock` runs `Group.time`, in seconds, for its own and its descendants' materials. It wraps hourly and restyles the subtree every frame, so it is off by default. Filters other than `hueRotate` and `tint` also take a `CssValue`, such as `Filter.opacity(CssValue.fn("sin", Group.time))`, which a tween can't animate. `Filter.tint(color)` multiplies each pixel by an sRGB color in 0-1, like a sprite tint. It applies to polygons only, before the other filters, and semi-transparent pixels take a slight tint halo. -Aim a camera with `mat4.targetTo(mat4.create(), eye, target, [0, 1, 0])`. Its tween moves it after its `matrix` (`translate([0, 0, 400])` backs away) and can't `hover` or scale by zero or less; its filters apply to every group without its own, and `bake()` ignores it. +A `ParticleSystem` runs without per-frame JS. Its `particle` callback runs once per particle with `random(min, max)` and returns `from`, `keyframes` and `easing` like a `Tween`, durations relative to the life. `lifetimeRandomness` shortens each life by up to that fraction, 0.1 by default. Particles start at random points of their lives and loop. A `Transform.translate` moves one in the scene, the other steps apply after it faces the camera. Its material's flipbook plays once per life, in step only when set before the system is shown. -On a live page, call `container.bake()` after building, heavily scaling or changing textures; `toHTML()` awaits it for you. It also sizes polygons to their image's texels, loading each url once. +A `CssValue` comes only from its factories, which refuse text that could escape a declaration. -An orthographic camera needs `setSize()`: without it the container takes the window size at construction and does not follow resizes. +A `Billboard` undoes every rotation above it and its own, so turn a child group to spin it. A scale that isn't uniform, a shear, or a mirror in a tween skews it. + +Aim a camera with gl-matrix (install it): `Transform.matrix(mat4.targetTo(mat4.create(), eye, target, [0, 1, 0]))`. Its tween moves it after its `transform` (`translate([0, 0, 400])` backs away) and can't `hover` or scale by zero or less; its filters apply to every group without its own, and `bake()` ignores it. + +On a live page, call `container.bake()` after building, heavily scaling or changing textures; `toHTML()` awaits it for you. It also sizes polygons to their image's texels, loading each url once. The stylesheet is adopted by `document` only, so a container inside a shadow root is unstyled. @@ -94,7 +104,6 @@ GlobalRegistrator.register({ const { Container } = await import("c3r"); const container = new Container(); -container.setSize(800, 600); // ... build the scene const html = `${await container.toHTML()}`; diff --git a/packages/c3r/package.json b/packages/c3r/package.json index 00b670a..13d847c 100644 --- a/packages/c3r/package.json +++ b/packages/c3r/package.json @@ -1,6 +1,6 @@ { "name": "c3r", - "version": "0.3.0", + "version": "0.4.0", "description": "CSS 3D Renderer", "keywords": [ "typescript", diff --git a/packages/c3r/src/C3R.ts b/packages/c3r/src/C3R.ts index 5ed4ca6..d084b48 100644 --- a/packages/c3r/src/C3R.ts +++ b/packages/c3r/src/C3R.ts @@ -1,6 +1,7 @@ -export { Material } from "./css/Material.js"; +export { type Flipbook, Material } from "./css/Material.js"; export { Filter } from "./css/Filter.js"; export { Transform } from "./css/Transform.js"; +export { type CssFunction, CssValue } from "./css/CssValue.js"; export { type Keyframe, Tween, type TweenOptions } from "./css/Tween.js"; export { cssText, styleHTML } from "./css/styles.js"; @@ -9,6 +10,11 @@ export { Group, type IndexedMesh } from "./dom/Group.js"; export { Container } from "./dom/Container.js"; export { Polygon, type PolygonOptions } from "./dom/Polygon.js"; export { ElementObject } from "./dom/ElementObject.js"; +export { Billboard } from "./dom/Billboard.js"; +export { + ParticleSystem, + type ParticleSystemOptions, +} from "./dom/ParticleSystem.js"; export type { Vertex } from "./math/Vertex.js"; export { diff --git a/packages/c3r/src/css/Camera.ts b/packages/c3r/src/css/Camera.ts index 3157393..d01f3dc 100644 --- a/packages/c3r/src/css/Camera.ts +++ b/packages/c3r/src/css/Camera.ts @@ -2,18 +2,31 @@ import "../polyfill.js"; import { mat4, type ReadonlyMat4 } from "gl-matrix"; +import { refreshCamera } from "../dom/Accumulator.js"; + import { deleteRule, insertRule } from "./styles.js"; +import { + allFinite, + composeTransforms, + matrixText, + ownTransform, + type Transform, +} from "./Transform.js"; import { setTweens, type Tween } from "./Tween.js"; let nextId = 0; +const viewMatrices = new WeakMap(); + +export const viewMatrixOf = (camera: Camera): ReadonlyMat4 => + viewMatrices.get(camera)!; + // The projection math lives in styles.css, cameras only set its variables export abstract class Camera { readonly className = `c3r-camera-${nextId++}`; abstract readonly projectionClass: string; - #matrix = mat4.create(); - #viewMatrix = mat4.create(); + #transform: Transform | readonly Transform[] = []; #tween: Tween | readonly Tween[] | null = null; #paramsRule: CSSStyleRule | null = insertRule(`.${this.className}`, this); // Changes every frame @@ -23,23 +36,24 @@ export abstract class Camera { ); constructor() { + viewMatrices.set(this, mat4.create()); this.#writeMatrix(); } - get matrix(): ReadonlyMat4 { - return this.#matrix; + get transform(): Transform | readonly Transform[] { + return this.#transform; } - set matrix(matrix: ReadonlyMat4) { - if (mat4.exactEquals(matrix, this.#matrix)) return; - if (!mat4.invert(this.#viewMatrix, matrix)) - throw new RangeError("A camera matrix must be invertible"); - mat4.copy(this.#matrix, matrix); + set transform(transform: Transform | readonly Transform[]) { + const view = mat4.invert(mat4.create(), composeTransforms(transform)); + if (!view || !allFinite(view)) + throw new RangeError("A camera transform must be invertible"); + this.#transform = ownTransform(transform); + const current = viewMatrices.get(this)!; + if (mat4.exactEquals(view, current)) return; + mat4.copy(current, view); this.#writeMatrix(); - } - - get viewMatrix(): ReadonlyMat4 { - return this.#viewMatrix; + this.#refresh(); } get tween(): Tween | readonly Tween[] | null { @@ -49,20 +63,29 @@ export abstract class Camera { set tween(tween: Tween | readonly Tween[] | null) { const tweens = setTweens(this.#viewRule?.style, tween, true); this.#tween = Array.isArray(tween) ? tweens : tween; + this.#refresh(); } #writeMatrix(): void { this.#viewRule?.style.setProperty( "--c3r-camera-view", - `matrix3d(${[...this.#viewMatrix].join()})`, + matrixText(viewMatrixOf(this)), ); } + // Not from the constructor, an input rule may read a subclass's fields, + // which exist once it sets its params + #refresh(): void { + const style = this.#viewRule?.style; + if (style) refreshCamera(this, style); + } + // Rewriting an unchanged rule would still restyle the whole scene protected setParam(name: string, value: string): void { const style = this.#paramsRule?.style; - if (style && style.getPropertyValue(name) !== value) - style.setProperty(name, value); + if (!style || style.getPropertyValue(name) === value) return; + style.setProperty(name, value); + this.#refresh(); } [Symbol.dispose](): void { diff --git a/packages/c3r/src/css/CssValue.ts b/packages/c3r/src/css/CssValue.ts new file mode 100644 index 0000000..f577eaf --- /dev/null +++ b/packages/c3r/src/css/CssValue.ts @@ -0,0 +1,298 @@ +import { plainValue } from "./styles.js"; + +// Typed OM unit names, so a later move onto the platform keeps them +type Unit = "number" | "px" | "rad"; + +type Arg = CssValue | number; + +type CssNode = + | { readonly kind: "unit"; readonly value: number; readonly unit: Unit } + | { + readonly kind: "math"; + readonly op: "+" | "-" | "*" | "/"; + readonly left: Arg; + readonly right: Arg; + } + | { + readonly kind: "ref"; + readonly name: string; + readonly syntax: string; + readonly fallback: CssValue | null; + } + | { + readonly kind: "fn"; + readonly name: CssFunction; + readonly type: CssFunctionType; + readonly args: readonly Arg[]; + } + | { readonly kind: "matrixProduct"; readonly factors: readonly CssValue[] } + | { + readonly kind: "raw"; + readonly text: string; + readonly type: string | null; + }; + +// What each function produces, a math one typed by its arguments. Only +// comma-separated functions, and none loads a resource or reads the page +const functionTypes = { + matrix: "", + matrix3d: "", + translate: "", + translate3d: "", + translateX: "", + translateY: "", + translateZ: "", + scale: "", + scale3d: "", + scaleX: "", + scaleY: "", + scaleZ: "", + rotate: "", + rotate3d: "", + rotateX: "", + rotateY: "", + rotateZ: "", + skew: "", + skewX: "", + skewY: "", + perspective: "", + brightness: "", + contrast: "", + grayscale: "", + "hue-rotate": "", + invert: "", + opacity: "", + saturate: "", + sepia: "", + rgb: "", + hsl: "", + min: "", + max: "", + clamp: "", + round: "", + mod: "", + rem: "", + abs: "", + sign: "", + sin: "", + cos: "", + tan: "", + asin: "", + acos: "", + atan: "", + atan2: "", + pow: "", + sqrt: "", + hypot: "", + log: "", + exp: "", +} as const; + +export type CssFunction = keyof typeof functionTypes; +type CssFunctionType = (typeof functionTypes)[CssFunction]; + +// The argument types a math function takes and the type it gives, a missing one +// takes any numeric type and gives it back +const mathTypes: Partial> = { + sign: [[], ""], + sin: [["", ""], ""], + cos: [["", ""], ""], + tan: [["", ""], ""], + asin: [[""], ""], + acos: [[""], ""], + atan: [[""], ""], + atan2: [[], ""], + pow: [[""], ""], + sqrt: [[""], ""], + log: [[""], ""], + exp: [[""], ""], +}; + +const unitTypes: Record = { + number: "", + px: "", + rad: "", +}; +const numeric = new Set(Object.values(unitTypes)); + +// Only the factories can construct, a subclass could pass any text +const key = Symbol(); + +const finite = (n: number): number => { + if (!Number.isFinite(n)) throw new RangeError(`Invalid number ${n}`); + return n === 0 ? 0 : n; +}; + +let create: (node: CssNode, text: string) => CssValue; +let read: (value: CssValue) => { node: CssNode; text: string }; +let isMade: (value: object) => boolean; + +export class CssValue { + readonly #node: CssNode; + readonly #text: string; + + static { + create = (node, text) => new CssValue(key, node, text); + read = (value) => ({ node: value.#node, text: value.#text }); + isMade = (value) => #node in value; + } + + private constructor(k: symbol, node: CssNode, text: string) { + if (k !== key) throw new TypeError("Not a CssValue"); + this.#node = Object.freeze(node); + this.#text = text; + // An own toString can't shadow the printed text + Object.freeze(this); + } + + toString(): string { + return this.#text; + } + + static number(value: number): CssValue { + return CssValue.#unit(value, "number"); + } + + static px(value: number): CssValue { + return CssValue.#unit(value, "px"); + } + + static rad(value: number): CssValue { + return CssValue.#unit(value, "rad"); + } + + static add(left: Arg, right: Arg): CssValue { + return CssValue.#math("+", left, right); + } + + static sub(left: Arg, right: Arg): CssValue { + return CssValue.#math("-", left, right); + } + + static mul(left: Arg, right: Arg): CssValue { + return CssValue.#math("*", left, right); + } + + static div(left: Arg, right: Arg): CssValue { + return CssValue.#math("/", left, right); + } + + static fn(name: CssFunction, ...args: Arg[]): CssValue { + if (!Object.hasOwn(functionTypes, name)) + throw new RangeError(`Unsupported function ${name}`); + const values = args.map(checked); + return create( + { + kind: "fn", + name, + type: functionTypes[name], + args: Object.freeze(values), + }, + `${name}(${values.map((v) => print(v)).join()})`, + ); + } + + static #unit(value: number, unit: Unit): CssValue { + const n = finite(value); + return create( + { kind: "unit", value: n, unit }, + `${n}${unit === "number" ? "" : unit}`, + ); + } + + static #math(op: "+" | "-" | "*" | "/", left: Arg, right: Arg): CssValue { + const l = checked(left); + const r = checked(right); + return create( + { kind: "math", op, left: l, right: r }, + `calc(${print(l, true)} ${op} ${print(r, true)})`, + ); + } +} + +function checked(value: T): T { + if (typeof value === "number") return finite(value) as T; + if (typeof value !== "object" || value === null || !isMade(value)) + throw new TypeError("Not a CssValue"); + return value; +} + +// a × b, so b applies first. Null and empty factors are the identity +export function matrixMultiply(...factors: (CssValue | null)[]): CssValue { + const kept = factors.filter( + (f): f is CssValue => f !== null && print(checked(f)) !== "", + ); + for (const f of kept) { + const type = typeOf(f); + if (type !== "" && type !== "") + throw new TypeError(`matrixMultiply takes transforms, not ${print(f)}`); + } + return create( + { kind: "matrixProduct", factors: Object.freeze(kept) }, + kept.map((f) => print(f)).join(" "), + ); +} + +export const textOf = (value: CssValue): string => read(checked(value)).text; + +// The type a value computes to, null when unknown or invalid +export function typeOf(value: Arg): string | null { + if (typeof value === "number") return ""; + const { node } = read(checked(value)); + switch (node.kind) { + case "unit": + return unitTypes[node.unit]; + case "ref": + return node.syntax; + case "raw": + return node.type; + case "matrixProduct": + return ""; + case "fn": { + if (node.type !== "") return node.type; + const types = new Set(node.args.map(typeOf)); + const [type] = types; + if (types.size !== 1 || !numeric.has(type)) return null; + const [takes = [], gives = type] = mathTypes[node.name] ?? []; + return !takes.length || takes.includes(type!) ? gives : null; + } + case "math": { + const left = typeOf(node.left); + const right = typeOf(node.right); + if (!numeric.has(left) || !numeric.has(right)) return null; + if (node.op === "+" || node.op === "-") + return left === right ? left : null; + if (node.op === "*") + return left === "" ? right : right === "" ? left : null; + if (right === "") return left; + return left === right ? "" : null; + } + } +} + +// Inside calc() a nested calc() only needs its parentheses +function print(value: Arg, operand = false): string { + if (typeof value === "number") return String(value); + const { node, text } = read(value); + return operand && node.kind === "math" ? text.slice("calc".length) : text; +} + +// Internal: users only get Group.time +export function cssVar( + name: string, + syntax: string, + fallback?: CssValue, +): CssValue { + if (!/^--[\w-]+$/.test(name)) throw new RangeError(`Invalid name ${name}`); + const or = fallback === undefined ? null : checked(fallback); + return create( + { kind: "ref", name, syntax, fallback: or }, + or ? `var(${name}, ${textOf(or)})` : `var(${name})`, + ); +} + +// Internal: bridges text the library built itself, such as tween arguments +export function cssRaw(text: string, type: string | null = null): CssValue { + if (!plainValue(text, true)) throw new RangeError(`Invalid value ${text}`); + return create({ kind: "raw", text, type }, text); +} diff --git a/packages/c3r/src/css/Filter.ts b/packages/c3r/src/css/Filter.ts index a467708..59c1d37 100644 --- a/packages/c3r/src/css/Filter.ts +++ b/packages/c3r/src/css/Filter.ts @@ -1,5 +1,6 @@ import type { ReadonlyVec3 } from "gl-matrix"; +import { type CssValue, textOf, typeOf } from "./CssValue.js"; import { type Kind, radians, @@ -9,7 +10,15 @@ import { stepText, } from "./steps.js"; -const amount = (n: number): string => { +const amount = (n: number | CssValue): string => { + if (typeof n !== "number") { + const text = textOf(n); + // A wrong type invalidates the whole filter list without an error, and a + // negative literal is a parse error while an expression clamps + if (typeOf(n) !== "" || Number(text) < 0) + throw new RangeError(`Invalid amount ${text}`); + return text; + } if (!(n >= 0 && Number.isFinite(n))) throw new RangeError(`Invalid amount ${n}`); return String(n); @@ -27,40 +36,36 @@ export class Filter { syntax = "", kind: Kind = "filter", ): Filter { - return register(new Filter(), { - name, - kind, - syntax, - identity, - values: [value], - }); + return register(new Filter(), [ + { name, kind, syntax, identity, values: [value] }, + ]); } - static opacity(n: number): Filter { + static opacity(n: number | CssValue): Filter { return Filter.#create("opacity", "1", amount(n)); } - static brightness(n: number): Filter { + static brightness(n: number | CssValue): Filter { return Filter.#create("brightness", "1", amount(n)); } - static contrast(n: number): Filter { + static contrast(n: number | CssValue): Filter { return Filter.#create("contrast", "1", amount(n)); } - static saturate(n: number): Filter { + static saturate(n: number | CssValue): Filter { return Filter.#create("saturate", "1", amount(n)); } - static grayscale(n: number): Filter { + static grayscale(n: number | CssValue): Filter { return Filter.#create("grayscale", "0", amount(n)); } - static sepia(n: number): Filter { + static sepia(n: number | CssValue): Filter { return Filter.#create("sepia", "0", amount(n)); } - static invert(n: number): Filter { + static invert(n: number | CssValue): Filter { return Filter.#create("invert", "0", amount(n)); } @@ -83,7 +88,7 @@ export const filterSteps = ( filter: Filter | readonly Filter[] | null, ): Step[] => [filter ?? []].flat().map((f) => { - const step = stepOf(f); + const [step] = stepOf(f) ?? []; if (!step || step.kind === "transform") throw new TypeError("Not a Filter"); return step; }); diff --git a/packages/c3r/src/css/Material.ts b/packages/c3r/src/css/Material.ts index ea5d419..8163ac7 100644 --- a/packages/c3r/src/css/Material.ts +++ b/packages/c3r/src/css/Material.ts @@ -11,6 +11,13 @@ import { import { loadImage } from "./images.js"; import { deleteRule, insertRule } from "./styles.js"; +export interface Flipbook { + columns: number; + rows: number; + frames?: number; + fps?: number; +} + let nextId = 0; export class Material { @@ -20,6 +27,7 @@ export class Material { #filter: Filter | readonly Filter[] | null = null; #filterKey = ""; #doubleSided = false; + #flipbook: Required | null = null; #rule: CSSStyleRule | null = insertRule(`.${this.className}`, this); @@ -73,6 +81,7 @@ export class Material { this.#set("--c3r-background-alpha", alpha); // Masks a tint layer out of transparent pixels this.#set("--c3r-alpha-mask", image); + this.#applyFlipbook(); } get filter(): Filter | readonly Filter[] | null { @@ -100,6 +109,39 @@ export class Material { this.#set("backface-visibility", doubleSided ? "visible" : null); } + get flipbook(): Readonly | null { + return this.#flipbook; + } + + set flipbook(flipbook: Flipbook | null) { + let values: Required | null = null; + if (flipbook) { + const { columns, rows, frames = columns * rows, fps = 30 } = flipbook; + if (![columns, rows, frames].every((n) => Number.isInteger(n) && n > 0)) + throw new RangeError("Flipbook counts must be positive integers"); + if (frames > columns * rows) + throw new RangeError("More frames than cells"); + if (!(fps > 0 && Number.isFinite(fps))) + throw new RangeError(`Invalid fps ${fps}`); + values = Object.freeze({ columns, rows, frames, fps }); + } + this.#flipbook = values; + this.#applyFlipbook(); + } + + #applyFlipbook(): void { + const values = this.#flipbook; + // Like a background, its own one hides an inherited flipbook + const none = this.#background === null ? null : "initial"; + this.#set("--c3r-columns", values ? String(values.columns) : none); + this.#set("--c3r-rows", values ? String(values.rows) : none); + this.#set("--c3r-frames", values ? String(values.frames) : none); + this.#set( + "--c3r-flipbook-duration", + values ? `${values.frames / values.fps}s` : none, + ); + } + #set(name: string, value: string | null): void { const style = this.#rule?.style; if (!style) return; diff --git a/packages/c3r/src/css/Transform.ts b/packages/c3r/src/css/Transform.ts index a81ec60..80b665f 100644 --- a/packages/c3r/src/css/Transform.ts +++ b/packages/c3r/src/css/Transform.ts @@ -1,15 +1,142 @@ -import type { ReadonlyVec3 } from "gl-matrix"; +import { + mat3, + mat4, + quat, + vec3, + type ReadonlyMat4, + type ReadonlyVec3, +} from "gl-matrix"; +import { CssValue, cssRaw } from "./CssValue.js"; import { radians, register, type Step } from "./steps.js"; -const vector = (v: ReadonlyVec3, unit = ""): string[] => { +// Finite numbers only, an object's toString would reach the stylesheet +const vector = (v: ReadonlyVec3): [number, number, number] => { const values = Array.from(v); if (values.length !== 3 || !values.every(Number.isFinite)) throw new RangeError(`Invalid vector ${values}`); - return values.map((n) => n + unit); + return values as [number, number, number]; }; -const negate = (value: string) => `calc(-1 * ${value})`; +const negate = (value: string) => String(CssValue.mul(-1, cssRaw(value))); + +// Float32 overflows to Infinity, which CSS drops +export function allFinite(m: ReadonlyMat4): boolean { + for (let i = 0; i < 16; i++) if (!Number.isFinite(m[i])) return false; + return true; +} + +// A tween reaches a negative scale through zero +const scaleInverse = (scale: number[]) => + scale.every((n) => n > 0) + ? (value: string) => String(CssValue.div(1, cssRaw(value))) + : null; + +interface Decomposed { + translation: number[]; + axis: number[]; + angle: number; + scale: number[]; +} + +// Null with perspective, shear or a zero scale, a tween can't split those +export function decompose(m: ReadonlyMat4): Decomposed | null { + if (![m[3], m[7], m[11], m[15] - 1].every((n) => Math.abs(n) <= 1e-6)) + return null; + const columns = [0, 4, 8].map((i) => + vec3.fromValues(m[i], m[i + 1], m[i + 2]), + ); + const lengths = columns.map((c) => vec3.length(c)); + const max = Math.max(...lengths); + if (!lengths.every((n) => n > 1e-6 * max)) return null; + const rotation = (mirror: number) => { + const scale = lengths.map((n, i) => (i === mirror ? -n : n)); + const r = new Float32Array( + columns.flatMap((c, i) => Array.from(c, (n) => n / scale[i])), + ); + const q = quat.fromMat3(quat.create(), r); + quat.normalize(q, q); + if (q[3] < 0) quat.scale(q, q, -1); + const sine = Math.hypot(q[0], q[1], q[2]); + return { scale, q, sine, angle: 2 * Math.atan2(sine, q[3]) }; + }; + // A mirror goes on the axis that leaves the least rotation + const { scale, q, sine, angle } = + mat3.determinant(mat3.fromMat4(mat3.create(), m)) > 0 + ? rotation(-1) + : [0, 1, 2].map(rotation).reduce((a, b) => (b.angle < a.angle ? b : a)); + const translation = [m[12], m[13], m[14]]; + const rebuilt = mat4.fromRotationTranslationScale( + mat4.create(), + q, + translation as vec3, + scale as vec3, + ); + for (const i of [0, 1, 2, 4, 5, 6, 8, 9, 10]) + if (!(Math.abs(rebuilt[i] - m[i]) <= 1e-4 * max)) return null; + const axis = + sine > 1e-9 ? [q[0], q[1], q[2]].map((n) => n / sine) : [0, 0, 1]; + return { translation, axis, angle, scale }; +} + +// Separate tracks, so a camera can invert each one +const matrixSteps = ({ translation, axis, angle, scale }: Decomposed) => { + const turn = (rad: number) => + String(CssValue.fn("rotate3d", ...axis, CssValue.rad(rad))); + return [ + { + name: "matrix", + fn: "translate3d", + syntax: "", + identity: "0px", + values: translation.map((n) => String(CssValue.px(n))), + }, + { + name: "matrix-rotate", + syntax: "", + identity: String(CssValue.fn("rotate3d", 0, 0, 1, CssValue.rad(0))), + values: [turn(angle), turn(-angle)], + invert: (value: string) => value, + rotates: true, + }, + { + name: "matrix-scale", + fn: "scale3d", + syntax: "", + identity: "1", + values: scale.map(String), + invert: scaleInverse(scale), + }, + ]; +}; + +// Per frame and per node, so joined by hand from numbers already finite +export const matrixText = (m: ReadonlyMat4) => `matrix3d(${[...m].join()})`; + +const matrices = new WeakMap(); + +function matrixOf(transform: Transform): ReadonlyMat4 { + const m = matrices.get(transform); + if (!m) throw new TypeError("Not a Transform"); + return m; +} + +export function composeTransforms( + transform: Transform | readonly Transform[], +): ReadonlyMat4 { + // Checked on creation, c3r-three sets one per node every frame + if (!Array.isArray(transform)) return matrixOf(transform as Transform); + const out = mat4.create(); + for (const t of transform) mat4.mul(out, out, matrixOf(t)); + if (!allFinite(out)) throw new RangeError(`Invalid transform ${[...out]}`); + return out; +} + +// A frozen copy, so the getter can't change with the caller's array +export const ownTransform = ( + transform: Transform | readonly Transform[], +): Transform | readonly Transform[] => + Array.isArray(transform) ? Object.freeze([...transform]) : transform; export class Transform { declare private readonly brand: "transform"; @@ -17,50 +144,83 @@ export class Transform { private constructor() {} static #create( + matrix: ReadonlyMat4, + steps: Omit[] | (() => Omit[]), + ): Transform { + if (!allFinite(matrix)) + throw new RangeError(`Invalid transform ${[...matrix]}`); + const transform = new Transform(); + matrices.set(transform, matrix); + const complete = (list: Omit[]): Step[] => + list.map((step) => ({ kind: "transform", invert: negate, ...step })); + return register( + transform, + typeof steps === "function" ? () => complete(steps()) : complete(steps), + ); + } + + static #rotate( name: string, - syntax: string, - identity: string, - values: string[], - invert: Step["invert"] = negate, + from: (out: mat4, rad: number) => mat4, + angle: number, ): Transform { - return register(new Transform(), { - name, - kind: "transform", - syntax, - identity, - values, - invert, - }); + return Transform.#create(from(mat4.create(), angle), [ + { + name, + syntax: "", + identity: "0rad", + values: [radians(angle)], + rotates: true, + }, + ]); } // Scene units static translate(v: ReadonlyVec3): Transform { - return Transform.#create("translate3d", "", "0px", vector(v, "px")); + const values = vector(v); + return Transform.#create(mat4.fromTranslation(mat4.create(), values), [ + { + name: "translate3d", + syntax: "", + identity: "0px", + values: values.map((n) => String(CssValue.px(n))), + moves: true, + }, + ]); } static rotateX(angle: number): Transform { - return Transform.#create("rotateX", "", "0rad", [radians(angle)]); + return Transform.#rotate("rotateX", mat4.fromXRotation, angle); } static rotateY(angle: number): Transform { - return Transform.#create("rotateY", "", "0rad", [radians(angle)]); + return Transform.#rotate("rotateY", mat4.fromYRotation, angle); } static rotateZ(angle: number): Transform { - return Transform.#create("rotateZ", "", "0rad", [radians(angle)]); + return Transform.#rotate("rotateZ", mat4.fromZRotation, angle); } static scale(s: number | ReadonlyVec3): Transform { - const v = typeof s === "number" ? [s, s, s] : s; - return Transform.#create( - "scale3d", - "", - "1", - vector(v), - // A tween reaches a negative scale through zero - Array.from(v).every((n) => n > 0) - ? (value) => `calc(1 / ${value})` - : null, - ); + const values = vector(typeof s === "number" ? [s, s, s] : s); + return Transform.#create(mat4.fromScaling(mat4.create(), values), [ + { + name: "scale3d", + syntax: "", + identity: "1", + values: values.map(String), + invert: scaleInverse(values), + }, + ]); + } + + static matrix(m: ArrayLike): Transform { + const matrix = Float32Array.from(m); + if (matrix.length !== 16) + throw new RangeError(`A matrix takes 16 numbers, got ${matrix.length}`); + return Transform.#create(matrix, () => { + const parts = decompose(matrix); + return parts ? matrixSteps(parts) : []; + }); } } diff --git a/packages/c3r/src/css/Tween.ts b/packages/c3r/src/css/Tween.ts index 16b2f53..9d1f64d 100644 --- a/packages/c3r/src/css/Tween.ts +++ b/packages/c3r/src/css/Tween.ts @@ -1,11 +1,11 @@ import "../polyfill.js"; import { type Filter, filterTexts, filterVariables } from "./Filter.js"; -import { type Step, stepOf, stepText } from "./steps.js"; -import { deleteRule, insertRule } from "./styles.js"; +import { inverseStepValue, type Step, stepOf, stepText } from "./steps.js"; +import { deleteRule, insertRule, plainValue } from "./styles.js"; import type { Transform } from "./Transform.js"; -type Steps = Transform | Filter | readonly (Transform | Filter)[]; +export type Steps = Transform | Filter | readonly (Transform | Filter)[]; export type Keyframe = Steps | { steps: Steps; duration?: number }; export interface TweenOptions { @@ -33,11 +33,20 @@ interface Style { const styles = new WeakMap(); +type AnimatedStep = { step: Step; args: string[] }; +const animatedSteps = new WeakMap(); + +// Transform steps with their animated arguments, so others can build on them +export const transformSteps = (tween: Tween): readonly AnimatedStep[] => + animatedSteps.get(tween) ?? []; + // Throws before writing anything export function setTweens( style: CSSStyleDeclaration | undefined, tween: Tween | readonly Tween[] | null, inverse = false, + // An inline animation replaces any other, so the group's clock joins the list + extra = "", ): readonly Tween[] { const tweens = Object.freeze([...new Set([tween ?? []].flat())]); const parts = tweens.map((tween) => { @@ -45,7 +54,7 @@ export function setTweens( if (!part) throw new TypeError("Not a Tween"); if (inverse && part.hover) throw new RangeError("A camera can't hover"); if (inverse && part.transform && !part.inverse) - throw new RangeError("A camera scales by positive factors only"); + throw new RangeError("A camera can't tween a zero or negative scale"); return part; }); const list = (key: keyof Style, separator: string, from = parts) => @@ -54,7 +63,7 @@ export function setTweens( "--c3r-tween-transform": inverse ? list("inverse", " ", parts.toReversed()) : list("transform", " "), - animation: list("animation", ", "), + animation: [list("animation", ", "), extra].filter(Boolean).join(", "), transition: list("transition", ", "), ...filterVariables( "--c3r-tween-", @@ -97,13 +106,23 @@ export class Tween { const apply = (changes: Steps, where: string) => { const names = new Set(); for (const change of [changes].flat()) { - const step = stepOf(change); - if (!step) throw new TypeError("Not a Transform or Filter"); - if (names.has(step.name)) - throw new RangeError(`${step.name} twice in ${where}`); - names.add(step.name); - pose.set(step.name, step); - invertible &&= step.invert !== null; + const list = stepOf(change); + if (!list) throw new TypeError("Not a Transform or Filter"); + if (!list.length) + throw new RangeError( + "A tween can't decompose a matrix with shear, perspective or a zero scale", + ); + for (const step of list) { + // A from value becomes an initial-value, which can't read var(), and + // Firefox steps a var() keyframe + if (!step.values.every((v) => plainValue(v))) + throw new RangeError("A tween can't animate a var()"); + if (names.has(step.name)) + throw new RangeError(`${step.name} twice in ${where}`); + names.add(step.name); + pose.set(step.name, step); + invertible &&= step.invert !== null; + } } return names.size; }; @@ -127,18 +146,16 @@ export class Tween { const timingFunction = probe.getPropertyValue("transition-timing-function"); // Only one easing fits the shorthands, and a server-side DOM keeps any text let depth = 0; - let malformed = false; + let several = false; for (const c of timingFunction) { depth += c === "(" ? 1 : c === ")" ? -1 : 0; - if (depth < 0 || (c === "," && !depth)) malformed = true; + if (c === "," && !depth) several = true; } if ( - !timingFunction || - malformed || - depth || - /^\s*(inherit|initial|unset|revert(-layer)?)\s*$|\b(var|env|attr)\(|[^\w\s().,%+\-/]/i.test( - timingFunction, - ) + several || + !timingFunction.trim() || + !plainValue(timingFunction) || + /^\s*(inherit|initial|unset|revert(-layer)?)\s*$/i.test(timingFunction) ) throw new RangeError(`Invalid easing ${easing}`); if (!Number.isFinite(delay)) throw new RangeError(`Invalid delay ${delay}`); @@ -207,14 +224,18 @@ export class Tween { if (hover) style.setProperty("pointer-events", "auto"); const timing = `${timingFunction} ${delay}s`; + const steps = transforms.map((step) => ({ step, args: args(step) })); + animatedSteps.set(this, steps); // Fallbacks keep the element's other tweens when this one is disposed const part: Style = { hover }; - if (transforms.length) { - part.transform = transforms.map((s) => stepText(s, args(s))).join(" "); + if (steps.length) { + part.transform = steps + .map(({ step, args }) => stepText(step, args)) + .join(" "); if (invertible) - part.inverse = transforms + part.inverse = steps .toReversed() - .map((s) => stepText(s, args(s).map(s.invert!))) + .map(({ step, args }) => String(inverseStepValue(step, args))) .join(" "); } // Inherited as resolved text, a filter on the group would flatten it diff --git a/packages/c3r/src/css/steps.ts b/packages/c3r/src/css/steps.ts index f6c4728..64a51ec 100644 --- a/packages/c3r/src/css/steps.ts +++ b/packages/c3r/src/css/steps.ts @@ -1,30 +1,59 @@ +import { type CssFunction, CssValue, cssRaw } from "./CssValue.js"; + export type Kind = "transform" | "filter" | "layer"; export interface Step { name: string; + // The CSS function, when the track name differs + fn?: string; kind: Kind; syntax: string; identity: string; values: string[]; // Transforms only, for the camera, null without an inverse invert?: ((value: string) => string) | null; + // Set by the factories of steps that only turn, so others can undo them + rotates?: boolean; + // Set by the factories of steps that only move + moves?: boolean; } // Only factory-made steps, a forged one would inject raw CSS -const steps = new WeakMap(); +type Steps = readonly Step[] | (() => readonly Step[]); +const steps = new WeakMap(); -export function register(owner: T, step: Step): T { - steps.set(owner, step); +// A function builds the steps once a tween asks, most transforms never tween +export function register(owner: T, list: Steps): T { + steps.set(owner, list); return owner; } -export const stepOf = (value: unknown): Step | undefined => - steps.get(value as object); +export function stepOf(value: unknown): readonly Step[] | undefined { + const list = steps.get(value as object); + if (typeof list !== "function") return list; + const built = list(); + steps.set(value as object, built); + return built; +} + +// A transform list is a whole value, the second one is its inverse +export const stepValue = ( + step: Step, + args: string[], + inverse = false, +): CssValue => + step.syntax === "" + ? cssRaw(args[inverse ? 1 : 0], "") + : CssValue.fn( + (step.fn ?? step.name) as CssFunction, + ...args.map((arg) => cssRaw(arg)), + ); + +// Steps invert their values, and a transform list picks its stored inverse +export const inverseStepValue = (step: Step, args: string[]): CssValue => + stepValue(step, args.map(step.invert!), true); -export const stepText = (step: Step, args = step.values): string => - `${step.name}(${args.join()})`; +export const stepText = (...args: Parameters): string => + String(stepValue(...args)); -export const radians = (angle: number): string => { - if (!Number.isFinite(angle)) throw new RangeError(`Invalid angle ${angle}`); - return `${angle}rad`; -}; +export const radians = (angle: number): string => String(CssValue.rad(angle)); diff --git a/packages/c3r/src/css/styles.css b/packages/c3r/src/css/styles.css index 0633692..9c07707 100644 --- a/packages/c3r/src/css/styles.css +++ b/packages/c3r/src/css/styles.css @@ -17,6 +17,39 @@ inherits: false; } +@property --c3r-flipbook-progress { + syntax: ""; + inherits: false; + initial-value: 0; +} + +/* Firefox animates a var() keyframe discretely, so this one is a plain number */ +@keyframes c3r-flipbook { + to { + --c3r-flipbook-progress: 1; + } +} + +/* An integer, so a stepped frame is exact for mod() and round() */ +@property --c3r-frame { + syntax: ""; + inherits: false; + initial-value: 0; +} + +@property --c3r-time { + syntax: ""; + inherits: true; + initial-value: 0; +} + +/* Wraps hourly, a whole number of cycles per hour has no seam */ +@keyframes c3r-time { + to { + --c3r-time: 3600; + } +} + .c3r-container { overflow: hidden; position: relative; @@ -76,11 +109,11 @@ .c3r-orthographic > .c3r-view { transform: scale( calc( - var(--c3r-viewport-x) / + var(--c3r-viewport-width) / 1px / (var(--c3r-camera-right) - var(--c3r-camera-left)) ), calc( - var(--c3r-viewport-y) / + var(--c3r-viewport-height) / 1px / (var(--c3r-camera-top) - var(--c3r-camera-bottom)) ) ) @@ -121,6 +154,14 @@ filter: var(--c3r-filter,) var(--c3r-tween-filter,); transform: matrix(1, 0, 0, 1, 0, 0); transform-origin: 0 0; + /* On the content, a tween's inline animation on the group would replace it. + Invalid without a flipbook's frames, so none plays */ + animation: c3r-flipbook var(--c3r-flipbook-duration) + steps(var(--c3r-frames), jump-end) var(--c3r-flipbook-delay, 0s) infinite; + --c3r-frame: calc(var(--c3r-flipbook-progress) * var(--c3r-frames)); + /* Invalid without a flipbook, so a browser without mod() still maps plain textures */ + --c3r-column: mod(var(--c3r-frame), var(--c3r-columns)); + --c3r-row: round(down, var(--c3r-frame) / var(--c3r-columns), 1); } /* No specificity, so the user's own styles win */ diff --git a/packages/c3r/src/css/styles.ts b/packages/c3r/src/css/styles.ts index aea2898..5ef34ef 100644 --- a/packages/c3r/src/css/styles.ts +++ b/packages/c3r/src/css/styles.ts @@ -3,11 +3,24 @@ import css from "./styles.css?inline"; let sheet: CSSStyleSheet | undefined; let baseRules = 0; +// Firefox stops an out of view animation unless it changes layout, which a +// custom property never does. A min-width under its 1/60px unit counts but +// moves nothing, and as text it survives server-side output +// https://bugzilla.mozilla.org/show_bug.cgi?id=1487412 +// https://bugzilla.mozilla.org/show_bug.cgi?id=1992412 +// https://bugzilla.mozilla.org/show_bug.cgi?id=2087794 +const firefoxCopy = (keyframes: string): string => + `@supports (-moz-appearance: none) { ${keyframes.replace(/}\s*$/, "0% { min-width: 0px } 100% { min-width: 0.001px } }")} }`; +const copies = new WeakMap(); + export function ensureStyles(): CSSStyleSheet { if (!sheet) { sheet = new CSSStyleSheet(); sheet.replaceSync(css); baseRules = sheet.cssRules.length; + for (const { cssText } of Array.from(sheet.cssRules)) + if (cssText.startsWith("@keyframes")) + sheet.insertRule(firefoxCopy(cssText), sheet.cssRules.length); document.adoptedStyleSheets.push(sheet); } return sheet; @@ -18,6 +31,7 @@ const rejected = new Set(); export function deleteRule(rule: CSSRule | null): void { if (!rule) return; + deleteRule(copies.get(rule) ?? null); registry.unregister(rule); rejected.delete(rule); const sheet = rule.parentStyleSheet; @@ -40,14 +54,33 @@ export function insertRule( try { rule = sheet.cssRules[sheet.insertRule(text, sheet.cssRules.length)]; } catch (error) { - if (!selector.startsWith("@property")) throw error; + // A server-side DOM rejects every @property, a browser only an invalid one + if (!selector.startsWith("@property") || "CSSPropertyRule" in globalThis) + throw error; rule = { cssText: text } as CSSRule; rejected.add(rule); } registry.register(owner, rule, rule); + if (selector.startsWith("@keyframes")) + copies.set(rule, insertRule(firefoxCopy(text), owner, "")); return rule as CSSStyleRule; } +// Nothing that ends the rule, comments out the rest or loads a resource, and +// reads other properties only when allowed +export function plainValue(text: string, vars = false): boolean { + let depth = 0; + for (const c of text) { + depth += c === "(" ? 1 : c === ")" ? -1 : 0; + if (depth < 0) return false; + } + return ( + !depth && + (vars || !/\bvar\(/i.test(text)) && + !/\/\*|\b(env|attr|url)\(|[^\w\s().,%+\-*/#]/i.test(text) + ); +} + // Base styles as source text, a server-side DOM drops @property export const cssText = (): string => [ diff --git a/packages/c3r/src/dom/Accumulator.ts b/packages/c3r/src/dom/Accumulator.ts new file mode 100644 index 0000000..08a2203 --- /dev/null +++ b/packages/c3r/src/dom/Accumulator.ts @@ -0,0 +1,273 @@ +import type { Camera } from "../css/Camera.js"; +import { cssVar, type CssValue, textOf } from "../css/CssValue.js"; +import { deleteRule, insertRule, plainValue } from "../css/styles.js"; + +import { type Object3D, parents } from "./Object3D.js"; + +export interface AccumulatorOptions { + // --name is the result, --name-input each node's input + name: string; + syntax: string; + // The input of a node that sets none, and the value above the top + identity: CssValue; + combine: (parent: CssValue, input: CssValue) => CssValue; + input?: (node: Object3D | Camera) => CssValue | null; +} + +// Targets in each node's subtree, per accumulator +const counts = new WeakMap>(); +const properties = new Set(); +// Frees the names of an accumulator collected without disposing +const released = new FinalizationRegistry((names: string[]) => { + for (const p of names) properties.delete(p); + for (const camera of live(cameras)) + cameraStyles.get(camera)!.removeProperty(names[1]); +}); + +// Weak, so neither keeps the other alive +const inputs = new Set>(); +const cameras = new Set>(); +const cameraStyles = new WeakMap(); + +const live = (refs: Set>): T[] => { + const alive: T[] = []; + for (const ref of refs) { + const value = ref.deref(); + if (value) alive.push(value); + else refs.delete(ref); + } + return alive; +}; + +let shift: (acc: Accumulator, from: Object3D | undefined, n: number) => void; +let write: ( + acc: Accumulator, + node: Object3D | Camera, + style: CSSStyleDeclaration, +) => void; + +export const moveTargets = (obj: Object3D, sign: 1 | -1): void => { + for (const [acc, n] of counts.get(obj) ?? []) + shift(acc, parents.get(obj), sign * n); +}; + +export const refreshInput = (node: Object3D): void => { + for (const acc of counts.get(node)?.keys() ?? []) + write(acc, node, node.domElement.style); +}; + +export const refreshCamera = ( + camera: Camera, + style: CSSStyleDeclaration, +): void => { + if (!cameraStyles.has(camera)) cameras.add(new WeakRef(camera)); + cameraStyles.set(camera, style); + for (const acc of live(inputs)) write(acc, camera, style); +}; + +let library = false; + +export const libraryAccumulator = ( + options: AccumulatorOptions, +): Accumulator => { + library = true; + try { + return new Accumulator(options); + } finally { + library = false; + } +}; + +function validate({ name, syntax, identity }: AccumulatorOptions): string { + if (!/^[a-z_][\w-]*$/i.test(name)) + throw new RangeError( + `Invalid name ${name}, use letters, digits, _ or - without a leading --`, + ); + if ([`--${name}`, `--${name}-input`].some((p) => properties.has(p))) + throw new RangeError(`${name} is in use, dispose its Accumulator first`); + if (name.startsWith("c3r-") && !library) + throw new RangeError("The c3r- prefix is reserved"); + if (!/^[\w <>|+#*-]+$/.test(syntax)) + throw new RangeError(`Invalid syntax ${syntax}`); + // An initial value can't depend on other properties, so no var() in it + const initial = textOf(identity); + if (!initial || !plainValue(initial)) + throw new RangeError( + `Invalid identity "${initial}", it must print a value and read no other property`, + ); + return initial; +} + +let nextId = 0; + +export class Accumulator { + readonly className = `c3r-accumulator-${nextId++}`; + + readonly #result: string; + readonly #syntax: string; + readonly #inputProperty: string; + readonly #input: AccumulatorOptions["input"]; + readonly #start: string; + readonly #weakSelf = new WeakRef(this); + #rules: CSSRule[] = []; + readonly #targets = new WeakSet(); + // Weak, so dispose can clean the chain without keeping its nodes alive + readonly #chain = new Set>(); + readonly #chainRefs = new WeakMap>(); + // Collected nodes leave dead refs, swept whenever the set doubles + #pruneAt = 64; + + static { + shift = (acc, from, n) => { + for (let node = from; node; node = parents.get(node)) acc.#count(node, n); + }; + write = (acc, node, style) => acc.#write(node, style); + } + + constructor(options: AccumulatorOptions) { + const initial = validate(options); + const { name, syntax, identity, combine, input } = options; + const result = `--${name}`; + const inputProperty = `${result}-input`; + const names = [result, inputProperty]; + const slot = (i: number) => `--${this.className}-${i}`; + const flag = `--${this.className}-odd`; + const fold = (parent: CssValue) => + textOf(combine(parent, cssVar(inputProperty, syntax))); + const slotRef = (i: number) => cssVar(slot(i), syntax); + const set = (i: number) => + `${slot(i)}: ${fold(slotRef(1 - i))}; ${result}: ${textOf(slotRef(i))};`; + const seed = `${slot(0)}: ${fold(identity)}; ${slot(1)}: ${textOf(slotRef(0))};`; + + const register = (property: string, inherits: boolean) => + this.#addRule( + `@property ${property}`, + `{ syntax: "${syntax}"; inherits: ${inherits}; initial-value: ${initial}; }`, + ); + const cls = `.${this.className}`; + try { + register(inputProperty, false); + for (const property of [result, slot(0), slot(1)]) + register(property, true); + // Emulates `--name: combine(inherit(--name), var(--name-input))` until + // Firefox ships inherit() and Chrome enables it, then the parent ref + // turns into it: a node reads its parent's slot and writes the other, + // the flag alternating them down the chain + this.#addRule( + `@container not style(${flag}: 1)`, + `{ ${cls} { ${flag}: 1; ${set(0)} } }`, + ); + this.#addRule( + `@container style(${flag}: 1)`, + `{ ${cls} { ${flag}: 0; ${set(1)} } }`, + ); + this.#addRule(`.c3r-view:has(> ${cls})`, `{ ${seed} }`); + // Off the chain, a changing value restyles no deeper than one level + this.#addRule( + `:is(.c3r-group, .c3r-polygon):not(${cls}, .c3r-view)`, + `{ ${result}: initial; ${slot(0)}: initial; ${slot(1)}: initial; }`, + ); + } catch (error) { + // A browser rejects a syntax or identity the allowlist lets through + for (const rule of this.#rules) deleteRule(rule); + throw error; + } + for (const p of names) properties.add(p); + this.#result = result; + this.#syntax = syntax; + this.#inputProperty = inputProperty; + this.#input = input; + this.#start = `{ ${seed} ${result}: ${textOf(slotRef(0))}; }`; + released.register(this, names, this); + if (!input) return; + inputs.add(this.#weakSelf); + for (const camera of live(cameras)) + this.#write(camera, cameraStyles.get(camera)!); + } + + get result(): CssValue { + return cssVar(this.#result, this.#syntax); + } + + addTarget(obj: Object3D): void { + if (this.#targets.has(obj)) return; + this.#targets.add(obj); + shift(this, obj, 1); + } + + removeTarget(obj: Object3D): void { + if (this.#targets.delete(obj)) shift(this, obj, -1); + } + + restartUnder(selector: string): void { + if (!this.#rules.length) return; + const invalid = new RangeError(`Invalid selector ${selector}`); + if (/[{};@<]|\/\*/.test(selector)) throw invalid; + try { + document.createDocumentFragment().querySelector(selector); + // An unclosed ( or string passes alone but breaks the wrapped rule + this.#addRule(`:is(${selector}) > .${this.className}`, this.#start); + } catch { + throw invalid; + } + } + + #addRule(selector: string, block: string): void { + this.#rules.push(insertRule(selector, this, block)); + } + + // A failing rule leaves the identity, so the tree stays consistent + #write(node: Object3D | Camera, style: CSSStyleDeclaration): void { + if (!this.#input || !this.#rules.length) return; + let value = ""; + try { + const result = this.#input(node); + value = result === null ? "" : textOf(result); + } catch (error) { + if (typeof reportError === "function") reportError(error); + else console.error(error); + } + style.setProperty(this.#inputProperty, value); + } + + #count(node: Object3D, n: number): void { + if (!this.#rules.length) return; + const map = counts.get(node) ?? new Map(); + const old = map.get(this) ?? 0; + const now = old + n; + if (now) map.set(this, now); + else map.delete(this); + if (map.size) counts.set(node, map); + else counts.delete(node); + if (old > 0 === now > 0) return; + node.domElement.classList.toggle(this.className, now > 0); + if (now > 0) { + const ref = new WeakRef(node); + this.#chainRefs.set(node, ref); + this.#chain.add(ref); + if (this.#chain.size >= this.#pruneAt) + this.#pruneAt = 2 * live(this.#chain).length + 64; + this.#write(node, node.domElement.style); + } else { + this.#chain.delete(this.#chainRefs.get(node)!); + this.#chainRefs.delete(node); + if (this.#input) + node.domElement.style.removeProperty(this.#inputProperty); + } + } + + [Symbol.dispose](): void { + if (!this.#rules.length) return; + // A later accumulator may take the name, so the chain leaves first + for (const node of live(this.#chain)) + this.#count(node, -counts.get(node)!.get(this)!); + for (const rule of this.#rules) deleteRule(rule); + this.#rules = []; + released.unregister(this); + for (const p of [this.#result, this.#inputProperty]) properties.delete(p); + inputs.delete(this.#weakSelf); + if (this.#input) + for (const camera of live(cameras)) + cameraStyles.get(camera)!.removeProperty(this.#inputProperty); + } +} diff --git a/packages/c3r/src/dom/Billboard.ts b/packages/c3r/src/dom/Billboard.ts new file mode 100644 index 0000000..bfca005 --- /dev/null +++ b/packages/c3r/src/dom/Billboard.ts @@ -0,0 +1,82 @@ +import type { ReadonlyMat4 } from "gl-matrix"; + +import { Camera, viewMatrixOf } from "../css/Camera.js"; +import { CssValue, cssVar, matrixMultiply } from "../css/CssValue.js"; +import { inverseStepValue, stepValue } from "../css/steps.js"; +import { insertRule } from "../css/styles.js"; +import { decompose } from "../css/Transform.js"; +import { transformSteps, type Tween } from "../css/Tween.js"; + +import { type Accumulator, libraryAccumulator } from "./Accumulator.js"; +import { Group } from "./Group.js"; +import { matrixOf, type Object3D } from "./Object3D.js"; + +// Undoes the rotation and any mirror, keeps the scale. Null with shear or +// perspective, which have no rotation +function counterRotationOf(m: ReadonlyMat4): CssValue | null { + const parts = decompose(m); + if (!parts) return null; + const { axis, angle, scale } = parts; + const mirror = scale.some((n) => n < 0) + ? CssValue.fn("scale3d", ...scale.map(Math.sign)) + : null; + return matrixMultiply( + mirror, + CssValue.fn("rotate3d", ...axis, CssValue.rad(-angle)), + ); +} + +const rotations = (tween: Tween | readonly Tween[] | null) => + [tween ?? []] + .flat() + .flatMap((t) => transformSteps(t)) + .filter(({ step }) => step.rotates); + +// A group turns by its matrix then its tweens, so it counters them backwards. +// The view turns by the inverse of the camera, so it counters it forwards +function counterRotation(node: Object3D | Camera): CssValue { + if (node instanceof Camera) + return matrixMultiply( + counterRotationOf(viewMatrixOf(node)), + ...rotations(node.tween).map(({ step, args }) => stepValue(step, args)), + ); + const tweens = node instanceof Group ? rotations(node.tween) : []; + return matrixMultiply( + ...tweens + .toReversed() + .map(({ step, args }) => inverseStepValue(step, args)), + counterRotationOf(matrixOf(node)), + ); +} + +let facing: Accumulator | undefined; + +function createFacing(): Accumulator { + const acc = libraryAccumulator({ + name: "c3r-facing", + syntax: "", + identity: CssValue.fn("rotate", CssValue.rad(0)), + combine: (parent, input) => matrixMultiply(input, parent), + input: counterRotation, + }); + const identity = matrixMultiply(); + const transform = matrixMultiply( + cssVar("--c3r-matrix", "", identity), + cssVar("--c3r-tween-transform", "", identity), + acc.result, + ); + // After the base rules, so it wins over .c3r-group + insertRule(".c3r-billboard", acc, `{ transform: ${transform}; }`); + // A billboard already faces the camera, so a nested one only counters what + // turns below it + acc.restartUnder(".c3r-billboard"); + return acc; +} + +export class Billboard extends Group { + constructor() { + super(); + this.domElement.classList.add("c3r-billboard"); + (facing ??= createFacing()).addTarget(this); + } +} diff --git a/packages/c3r/src/dom/Container.ts b/packages/c3r/src/dom/Container.ts index 0418cba..cd9770a 100644 --- a/packages/c3r/src/dom/Container.ts +++ b/packages/c3r/src/dom/Container.ts @@ -1,13 +1,13 @@ import { mat4, type ReadonlyMat4 } from "gl-matrix"; -import type { Camera } from "../css/Camera.js"; +import { type Camera, viewMatrixOf } from "../css/Camera.js"; import { imageInfo, loadImage } from "../css/images.js"; import { PerspectiveCamera } from "../css/PerspectiveCamera.js"; import { styleHTML } from "../css/styles.js"; import { ElementObject } from "./ElementObject.js"; import { Group } from "./Group.js"; -import type { Object3D } from "./Object3D.js"; +import { matrixOf, type Object3D, parents } from "./Object3D.js"; import { measurePolygon, Polygon } from "./Polygon.js"; // Keeps a Camera, and its rules, alive while its element is @@ -36,7 +36,6 @@ export class Container { this.domElement.appendChild(this.viewElement); this.viewElement.appendChild(this.#root.domElement); - this.resetSize(); } get root(): Group { @@ -45,7 +44,9 @@ export class Container { set root(root: Group) { if (root === this.#root) return; - this.#root.domElement.replaceWith(root.domElement); + parents.get(root)?.removeChild(root); + // The old root may have moved into another group + this.viewElement.replaceChildren(root.domElement); this.#root = root; } @@ -103,20 +104,30 @@ export class Container { node: Object3D, parent: ReadonlyMat4, image: string | null, + cells: readonly [number, number], ) => { if (node.domElement.style.display === "none") return; // Like the CSS variables: an image or a color replaces, null inherits - const background = node.material?.background; - if (background != null && !(node instanceof ElementObject)) + const material = node instanceof ElementObject ? null : node.material; + const background = material?.background; + if (background != null) { image = typeof background === "string" ? background : null; + cells = [1, 1]; + } + if (material?.flipbook) + cells = [material.flipbook.columns, material.flipbook.rows]; if (node instanceof Polygon) { const info = image === null ? null : imageInfo(image); // Keeps the old size while a new image loads if (info === undefined) loading.push(loadImage(image!)); - else measurePolygon(node, info?.size ?? null); + else + measurePolygon( + node, + info && [info.size[0] / cells[0], info.size[1] / cells[1]], + ); } // Measuring refits the matrix - const m = mat4.mul(mat4.create(), parent, node.matrix); + const m = mat4.mul(mat4.create(), parent, matrixOf(node)); if (node instanceof Polygon) { const scale = Math.min( Math.hypot(m[0], m[1], m[2]), @@ -124,10 +135,10 @@ export class Container { ); if (scale > 0) density = Math.max(density, 1 / scale); } else if (node instanceof Group) { - for (const child of node.children) visit(child, m, image); + for (const child of node.children) visit(child, m, image, cells); } }; - visit(this.#root, this.#camera.viewMatrix, null); + visit(this.#root, viewMatrixOf(this.#camera), null, [1, 1]); const exponent = Math.ceil(Math.log2(density || 1)); this.unitScale = 2 ** Math.min(Math.max(exponent, -20), 20); return loading; @@ -147,9 +158,6 @@ export class Container { const { style } = this.domElement; style.setProperty("--c3r-viewport-width", `${width}px`); style.setProperty("--c3r-viewport-height", `${height}px`); - // Unitless for the ortho scale, CSS can't divide lengths yet - style.setProperty("--c3r-viewport-x", String(width)); - style.setProperty("--c3r-viewport-y", String(height)); } resetSize(): void { @@ -159,7 +167,5 @@ export class Container { const { style } = this.domElement; style.removeProperty("--c3r-viewport-width"); style.removeProperty("--c3r-viewport-height"); - style.setProperty("--c3r-viewport-x", String(innerWidth)); - style.setProperty("--c3r-viewport-y", String(innerHeight)); } } diff --git a/packages/c3r/src/dom/Group.ts b/packages/c3r/src/dom/Group.ts index bee9bae..fbb9c79 100644 --- a/packages/c3r/src/dom/Group.ts +++ b/packages/c3r/src/dom/Group.ts @@ -1,5 +1,9 @@ -import type { ReadonlyMat4 } from "gl-matrix"; - +import { type CssValue, cssVar } from "../css/CssValue.js"; +import { + composeTransforms, + ownTransform, + type Transform, +} from "../css/Transform.js"; import { setTweens, type Tween } from "../css/Tween.js"; import { MeshBuilder, @@ -8,11 +12,10 @@ import { } from "../math/MeshBuilder.js"; import type { Vertex } from "../math/Vertex.js"; -import { Object3D } from "./Object3D.js"; +import { moveTargets, refreshInput } from "./Accumulator.js"; +import { Object3D, parents } from "./Object3D.js"; import { Polygon, type PolygonOptions } from "./Polygon.js"; -const parents = new WeakMap(); - // Keeps the tweens, and their rules, alive while their element is const elementTweens = new WeakMap(); @@ -24,7 +27,9 @@ export interface IndexedMesh { export class Group extends Object3D { readonly #children: Object3D[] = []; + #transform: Transform | readonly Transform[] = []; #tween: Tween | readonly Tween[] | null = null; + #clock = false; constructor() { super(); @@ -32,11 +37,13 @@ export class Group extends Object3D { this.domElement.classList.add("c3r-group"); } - override get matrix(): ReadonlyMat4 { - return super.matrix; + get transform(): Transform | readonly Transform[] { + return this.#transform; } - override set matrix(matrix: ReadonlyMat4) { + set transform(transform: Transform | readonly Transform[]) { + const matrix = composeTransforms(transform); + this.#transform = ownTransform(transform); this.setMatrix(matrix); } @@ -46,12 +53,32 @@ export class Group extends Object3D { set tween(tween: Tween | readonly Tween[] | null) { const { classList, style } = this.domElement; - const tweens = setTweens(style, tween); + const tweens = setTweens( + style, + tween, + false, + this.#clock ? "c3r-time 3600s linear infinite" : "", + ); for (const old of elementTweens.get(this.domElement) ?? []) classList.remove(old.className); for (const { className } of tweens) classList.add(className); elementTweens.set(this.domElement, tweens); this.#tween = Array.isArray(tween) ? tweens : tween; + refreshInput(this); + } + + // Seconds, read where a group above runs its clock + static readonly time: CssValue = cssVar("--c3r-time", ""); + + // Opt-in, every frame restyles the group's subtree + get clock(): boolean { + return this.#clock; + } + + set clock(clock: boolean) { + if (clock === this.#clock) return; + this.#clock = clock; + this.tween = this.#tween; } get children(): readonly Object3D[] { @@ -59,8 +86,11 @@ export class Group extends Object3D { } addChild(obj: Object3D): void { + if (obj.domElement.contains(this.domElement)) + throw new RangeError("A group can't contain its ancestor"); parents.get(obj)?.removeChild(obj); parents.set(obj, this); + moveTargets(obj, 1); this.#children.push(obj); this.domElement.appendChild(obj.domElement); } @@ -70,6 +100,7 @@ export class Group extends Object3D { if (index < 0) return; this.#children.splice(index, 1); + moveTargets(obj, -1); parents.delete(obj); obj.domElement.remove(); } diff --git a/packages/c3r/src/dom/Object3D.ts b/packages/c3r/src/dom/Object3D.ts index 57888d1..8ffb8ac 100644 --- a/packages/c3r/src/dom/Object3D.ts +++ b/packages/c3r/src/dom/Object3D.ts @@ -2,30 +2,35 @@ import { mat4, type ReadonlyMat4 } from "gl-matrix"; import type { Material } from "../css/Material.js"; import { ensureStyles } from "../css/styles.js"; +import { matrixText } from "../css/Transform.js"; + +import { refreshInput } from "./Accumulator.js"; +import type { Group } from "./Group.js"; // Keeps a Material, and its rule, alive while its element is const elementMaterials = new WeakMap(); +const matrices = new WeakMap(); + +export const matrixOf = (obj: Object3D): ReadonlyMat4 => matrices.get(obj)!; + +export const parents = new WeakMap(); + export abstract class Object3D { readonly domElement: HTMLDivElement = document.createElement("div"); - #matrix = mat4.create(); #material: Material | null = null; constructor() { ensureStyles(); - } - - get matrix(): ReadonlyMat4 { - return this.#matrix; + matrices.set(this, mat4.create()); } protected setMatrix(matrix: ReadonlyMat4): void { - if (mat4.exactEquals(matrix, this.#matrix)) return; - mat4.copy(this.#matrix, matrix); - this.domElement.style.setProperty( - "--c3r-matrix", - `matrix3d(${[...matrix].join()})`, - ); + const current = matrices.get(this)!; + if (mat4.exactEquals(matrix, current)) return; + mat4.copy(current, matrix); + this.domElement.style.setProperty("--c3r-matrix", matrixText(matrix)); + refreshInput(this); } get material(): Material | null { diff --git a/packages/c3r/src/dom/ParticleSystem.ts b/packages/c3r/src/dom/ParticleSystem.ts new file mode 100644 index 0000000..0fcfe2c --- /dev/null +++ b/packages/c3r/src/dom/ParticleSystem.ts @@ -0,0 +1,101 @@ +import type { Filter } from "../css/Filter.js"; +import { stepOf } from "../css/steps.js"; +import type { Transform } from "../css/Transform.js"; +import { type Steps, Tween, type TweenOptions } from "../css/Tween.js"; + +import { Billboard } from "./Billboard.js"; +import { Group, type IndexedMesh } from "./Group.js"; + +export interface ParticleSystemOptions { + count: number; + // Seconds + lifetime: number; + // Shortens each life by up to this fraction, so the particles drift apart + lifetimeRandomness?: number; + mesh: IndexedMesh; + // Called once per particle, so its random values are fixed for it. Durations + // are relative and fill the life + particle: (context: { + random(min?: number, max?: number): number; + }) => Pick; +} + +// A billboard counters its own rotations, so only translations go on it and +// the rest applies in the view plane, on its body +const moves = (step: Transform | Filter) => + stepOf(step)?.every((s) => s.moves) ?? false; + +export class ParticleSystem extends Group { + #tweens: Tween[] = []; + + constructor({ + count, + lifetime, + lifetimeRandomness = 0.1, + mesh, + particle, + }: ParticleSystemOptions) { + super(); + if (!(Number.isInteger(count) && count > 0)) + throw new RangeError(`Invalid count ${count}`); + if (!(lifetime > 0 && Number.isFinite(lifetime))) + throw new RangeError(`Invalid lifetime ${lifetime}`); + if (!(lifetimeRandomness >= 0 && lifetimeRandomness < 1)) + throw new RangeError(`Invalid lifetimeRandomness ${lifetimeRandomness}`); + + const random = (min = 0, max = 1) => min + Math.random() * (max - min); + for (let i = 0; i < count; i++) { + const life = lifetime * (1 - lifetimeRandomness * random()); + // Negative, so the system starts full + const delay = -random() * life; + const { from = [], keyframes, easing } = particle({ random }); + const frames = keyframes.map((k) => { + const { steps, duration: weight = 1 } = "steps" in k ? k : { steps: k }; + if (!(weight > 0 && Number.isFinite(weight))) + throw new RangeError(`Invalid duration ${weight}`); + return { steps, weight }; + }); + const total = frames.reduce((sum, k) => sum + k.weight, 0); + const side = (keep: boolean) => { + const pick = (steps: Steps) => + [steps].flat().filter((s) => moves(s) === keep); + const start = pick(from); + let own = frames.map((k) => ({ + steps: pick(k.steps), + duration: (k.weight / total) * life, + })); + if (!own.some((k) => k.steps.length)) { + if (!start.length) return null; + // Holds what only the start sets + own = [{ steps: start, duration: life }]; + } + const made = new Tween({ + from: start, + keyframes: own, + easing, + delay, + loop: true, + }); + this.#tweens.push(made); + return made; + }; + + const billboard = new Billboard(); + // The material's flipbook plays once per life + const { style } = billboard.domElement; + style.setProperty("--c3r-flipbook-duration", `${life}s`); + style.setProperty("--c3r-flipbook-delay", `${delay}s`); + billboard.tween = side(true); + const body = new Group(); + body.tween = side(false); + body.addMesh(mesh); + billboard.addChild(body); + this.addChild(billboard); + } + } + + [Symbol.dispose](): void { + for (const tween of this.#tweens) tween[Symbol.dispose](); + this.#tweens = []; + } +} diff --git a/packages/c3r/src/dom/Polygon.ts b/packages/c3r/src/dom/Polygon.ts index ce32e1d..843deab 100644 --- a/packages/c3r/src/dom/Polygon.ts +++ b/packages/c3r/src/dom/Polygon.ts @@ -490,8 +490,8 @@ export class Polygon extends Object3D { const scx = 1 / xsize; const scy = 1 / ysize; - const position = `calc(var(--c3r-width) * ${-txmin * scx}) calc(var(--c3r-height) * ${1 + tymin * scy})`; - const size = `${100 * scx}% ${100 * scy}%`; + const position = `calc(var(--c3r-width) * ${-scx} * (${txmin} + var(--c3r-column, 0))) calc(var(--c3r-height) * (1 + ${scy} * (${tymin} - var(--c3r-row, 0) - 1)))`; + const size = `calc(${100 * scx}% * var(--c3r-columns, 1)) calc(${100 * scy}% * var(--c3r-rows, 1))`; style.backgroundPosition = style.maskPosition = position; style.backgroundSize = style.maskSize = size; diff --git a/packages/c3r/test/accumulator.browser.test.ts b/packages/c3r/test/accumulator.browser.test.ts new file mode 100644 index 0000000..f2d16f5 --- /dev/null +++ b/packages/c3r/test/accumulator.browser.test.ts @@ -0,0 +1,211 @@ +import { afterEach, expect, it, vi } from "vitest"; +import { + Camera, + Container, + CssValue, + Group, + type Object3D, + PerspectiveCamera, + Transform, +} from "c3r"; +import { cssVar, matrixMultiply } from "../src/css/CssValue.js"; +import { + Accumulator, + type AccumulatorOptions, +} from "../src/dom/Accumulator.js"; + +afterEach(() => vi.restoreAllMocks()); + +const options = (name = "sum") => ({ + name, + syntax: "", + identity: CssValue.number(0), + combine: (parent: CssValue, input: CssValue) => CssValue.add(parent, input), +}); + +const sum = (name = "sum", input?: AccumulatorOptions["input"]) => + new Accumulator({ ...options(name), input }); + +const scene = () => { + const container = new Container(); + document.body.append(container.domElement); + return { + root: container.root, + container, + [Symbol.dispose]: () => container.domElement.remove(), + }; +}; + +const group = (parent: Group | null, input?: number) => { + const g = new Group(); + parent?.addChild(g); + if (input !== undefined) + g.domElement.style.setProperty("--sum-input", String(input)); + return g; +}; + +const value = (obj: Object3D, name = "sum") => + Number(getComputedStyle(obj.domElement).getPropertyValue(`--${name}`).trim()); + +const chained = (acc: Accumulator, obj: Object3D) => + obj.domElement.classList.contains(acc.className); + +it("folds each path once, sharing the common ancestors", () => { + using s = scene(); + using acc = sum(); + const a = group(s.root, 2); + const b = group(a); + const c = group(b, 3); + const d = group(a); + const off = group(s.root, 7); + acc.addTarget(c); + acc.addTarget(d); + expect(value(c)).toBe(5); + expect(value(d)).toBe(2); + expect(value(off)).toBe(0); + + acc.removeTarget(c); + expect([c, b, a].map((g) => chained(acc, g))).toEqual([false, false, true]); +}); + +it("refolds when a subtree moves to another depth", () => { + using s = scene(); + using acc = sum(); + const a = group(s.root, 1); + const b = group(a, 10); + const moved = group(null, 100); + const target = group(group(moved), 1000); + acc.addTarget(target); + b.addChild(moved); + expect(value(target)).toBe(1111); + a.addChild(moved); + expect(value(target)).toBe(1101); + expect(chained(acc, b)).toBe(false); + + let parent = a; + for (let i = 0; i < 5; i++) parent = group(parent, 1); + parent.addChild(moved); + expect(value(target)).toBe(1106); +}); + +it("follows a new root", () => { + using s = scene(); + using acc = sum(); + const a = group(s.root, 2); + const b = group(a, 3); + const target = group(b, 4); + acc.addTarget(target); + // Still another group's child + s.container.root = b; + expect(chained(acc, a)).toBe(false); + expect(value(target)).toBe(7); +}); + +const rules = () => + document.adoptedStyleSheets.map((s) => s.cssRules.length).join(); + +it("rejects invalid options", () => { + const before = rules(); + for (const bad of [ + { syntax: '"' }, + { name: "c3r-facing2" }, + { name: "a b" }, + { identity: matrixMultiply() }, + { identity: cssVar("--x", "") }, + ]) + expect(() => new Accumulator({ ...options("safe"), ...bad })).toThrow( + RangeError, + ); + const fake = Object.create(CssValue.prototype) as CssValue; + for (const bad of [{ identity: fake }, { combine: () => fake }]) + expect(() => new Accumulator({ ...options("safe"), ...bad })).toThrow( + TypeError, + ); + expect(rules()).toBe(before); +}); + +it("frees its names, rules and chain on dispose", () => { + using s = scene(); + const before = rules(); + const target = group(s.root); + const first = sum("safe", () => CssValue.number(5)); + first.addTarget(target); + expect(() => sum("safe")).toThrow(RangeError); + expect(() => sum("safe-input")).toThrow(RangeError); + first[Symbol.dispose](); + expect(rules()).toBe(before); + expect(chained(first, target)).toBe(false); + using again = sum("safe"); + again.addTarget(target); + expect(value(target, "safe")).toBe(0); +}); + +// The x translation, read back from the matrix the node writes +const x = (node: Object3D) => + node.domElement.style.getPropertyValue("--c3r-matrix").split(",")[12] ?? "0"; + +it("builds each input from its node and refreshes it", () => { + using s = scene(); + let top = 100; + using acc = sum("offset", (node) => + CssValue.number(node instanceof Camera ? top : Number(x(node))), + ); + const a = group(s.root); + a.transform = Transform.translate([10, 0, 0]); + const target = group(a); + target.transform = Transform.translate([5, 0, 0]); + acc.addTarget(target); + expect(value(target, "offset")).toBe(115); + + a.transform = Transform.translate([20, 0, 0]); + expect(value(target, "offset")).toBe(125); + top = 200; + s.container.camera.transform = Transform.translate([0, 0, 1]); + expect(value(target, "offset")).toBe(225); + + acc.removeTarget(target); + expect(a.domElement.style.getPropertyValue("--offset-input")).toBe(""); +}); + +it("reports a failing input rule and leaves the identity", () => { + using s = scene(); + const reported = vi + .spyOn(globalThis, "reportError") + .mockImplementation(() => {}); + using acc = sum("faulty", (node) => { + if (node instanceof Camera) return CssValue.number(1); + throw new Error("rule failed"); + }); + const target = group(s.root); + acc.addTarget(target); + expect(reported).toHaveBeenCalledTimes(2); + expect(value(target, "faulty")).toBe(1); +}); + +it("runs a camera's rule once its projection is set", () => { + using s = scene(); + const reported = vi.spyOn(globalThis, "reportError"); + using acc = sum("fov", (node) => + CssValue.number(node instanceof PerspectiveCamera ? node.fov : 0), + ); + const target = group(s.root); + acc.addTarget(target); + using camera = new PerspectiveCamera(50); + s.container.camera = camera; + camera.fov = 40; + expect(value(target, "fov")).toBe(40); + expect(reported).not.toHaveBeenCalled(); +}); + +it("restarts the fold under a selector", () => { + using s = scene(); + using acc = sum(); + const a = group(s.root, 2); + a.domElement.classList.add("fresh"); + const target = group(group(a, 3), 4); + acc.addTarget(target); + acc.restartUnder(".fresh"); + expect(value(target)).toBe(7); + expect(() => acc.restartUnder("x { }")).toThrow(RangeError); + expect(() => acc.restartUnder(":not(a")).toThrow(RangeError); +}); diff --git a/packages/c3r/test/billboard.browser.test.ts b/packages/c3r/test/billboard.browser.test.ts new file mode 100644 index 0000000..47bfca9 --- /dev/null +++ b/packages/c3r/test/billboard.browser.test.ts @@ -0,0 +1,144 @@ +import { expect, it } from "vitest"; +import { mat4 } from "gl-matrix"; +import { + Billboard, + Container, + Group, + type Object3D, + PerspectiveCamera, + Transform, + Tween, +} from "c3r"; + +// Halfway through, held still +const half = { duration: 2, delay: -1, paused: true }; + +// The view's own flip stays +const facing = [1, 0, 0, 0, -1, 0, 0, 0, 1]; + +const matrix = (element: Element) => + new DOMMatrix(getComputedStyle(element).transform); + +const product = (elements: Element[]) => + elements.reduce((m, e) => m.multiply(matrix(e)), new DOMMatrix()); + +const linear = (m: DOMMatrix) => + [m.m11, m.m12, m.m13, m.m21, m.m22, m.m23, m.m31, m.m32, m.m33].map( + (v) => +v.toFixed(4) + 0, + ); + +const chain = (obj: Object3D, container: Container) => { + const elements: Element[] = []; + for ( + let e: Element | null = obj.domElement; + e && e !== container.domElement; + e = e.parentElement + ) + elements.unshift(e); + return elements; +}; + +// The linear part of everything that turns it, from the view down +const faces = (obj: Object3D, container: Container) => + linear(product(chain(obj, container))); + +const facingInput = (obj: Object3D) => + obj.domElement.style.getPropertyValue("--c3r-facing-input"); + +const scene = (parentTransform: Transform[]) => { + const container = new Container(); + document.body.append(container.domElement); + container.camera.transform = [ + Transform.rotateY(0.3), + Transform.translate([0, 0, 400]), + ]; + const cameraTween = new Tween({ + keyframes: [Transform.rotateX(0.8)], + ...half, + }); + container.camera.tween = cameraTween; + const grand = new Group(); + container.root.addChild(grand); + const grandTween = new Tween({ keyframes: [Transform.rotateY(1)], ...half }); + grand.tween = grandTween; + const parent = new Group(); + grand.addChild(parent); + parent.transform = parentTransform; + const own = [Transform.translate([10, 20, 5]), Transform.rotateZ(0.7)]; + const billboard = new Billboard(); + parent.addChild(billboard); + billboard.transform = own; + const ownTween = new Tween({ keyframes: [Transform.rotateX(1.2)], ...half }); + billboard.tween = ownTween; + const plain = new Group(); + parent.addChild(plain); + plain.transform = own; + return { + container, + grand, + parent, + billboard, + plain, + [Symbol.dispose]: () => { + container.domElement.remove(); + for (const t of [cameraTween, grandTween, ownTween]) t[Symbol.dispose](); + }, + }; +}; + +it("cancels every rotation and mirror above it, nested too", () => { + using s = scene([ + Transform.scale([-1, 1, 1]), + Transform.rotateX(0.5), + Transform.translate([30, 0, 0]), + Transform.rotateZ(0.2), + ]); + expect(faces(s.billboard, s.container)).toEqual(facing); + // Turning in place keeps it where a plain group would be + const at = (obj: Object3D) => { + const m = product(chain(obj, s.container).slice(1)); + return [m.m41, m.m42, m.m43].map((v) => +v.toFixed(3) + 0); + }; + expect(at(s.billboard)).toEqual(at(s.plain)); + + const turned = new Group(); + s.billboard.addChild(turned); + turned.transform = Transform.rotateY(0.9); + const inner = new Billboard(); + turned.addChild(inner); + expect(faces(inner, s.container)).toEqual(facing); +}); + +it("counters several tweens on a group and on the camera in order", () => { + using s = scene([Transform.rotateX(0.5)]); + using a = new Tween({ keyframes: [Transform.rotateY(1)], ...half }); + using b = new Tween({ keyframes: [Transform.rotateX(0.6)], ...half }); + using c = new Tween({ keyframes: [Transform.rotateX(0.8)], ...half }); + using d = new Tween({ keyframes: [Transform.rotateZ(0.5)], ...half }); + s.grand.tween = [a, b]; + s.container.camera.tween = [c, d]; + expect(faces(s.billboard, s.container)).toEqual(facing); +}); + +it("ignores a disposed camera", () => { + new Billboard(); + const camera = new PerspectiveCamera(); + using tween = new Tween({ keyframes: [Transform.rotateX(0.8)], ...half }); + camera[Symbol.dispose](); + expect(() => { + camera.transform = Transform.rotateY(1); + camera.tween = tween; + }).not.toThrow(); +}); + +it("counters a mirror and a matrix tween with plain transform functions", () => { + using s = scene([Transform.scale([-1, 1, 1])]); + using spin = new Tween({ + keyframes: [Transform.matrix(mat4.fromZRotation(mat4.create(), 1))], + ...half, + }); + s.parent.tween = spin; + expect(facingInput(s.parent)).toBe( + `var(--${spin.className}-matrix-rotate-y, rotate3d(0,0,1,0rad)) scale3d(-1,1,1) rotate3d(0,0,1,0rad)`, + ); +}); diff --git a/packages/c3r/test/c3r.test.ts b/packages/c3r/test/c3r.test.ts index 0368c44..10514df 100644 --- a/packages/c3r/test/c3r.test.ts +++ b/packages/c3r/test/c3r.test.ts @@ -1,7 +1,8 @@ import { describe, expect, it, vi } from "vitest"; -import { mat4, vec3, vec4 } from "gl-matrix"; +import { mat4, quat, vec3, vec4 } from "gl-matrix"; import { cube, cylinder } from "primitive-geometry"; import { + CssValue, Container, cssText, ElementObject, @@ -17,6 +18,10 @@ import { Tween, type Vertex, } from "c3r"; +import { viewMatrixOf } from "../src/css/Camera.js"; +import { Accumulator } from "../src/dom/Accumulator.js"; +import { decompose } from "../src/css/Transform.js"; +import { matrixOf } from "../src/dom/Object3D.js"; const vertex = (x: number, y: number, z: number, u = 0, v = 0): Vertex => ({ position: [x, y, z], @@ -52,7 +57,7 @@ describe("Polygon", () => { ])("places the element on the vertices of a %s, front side out", (_, vertices) => { const poly = new Polygon(vertices); - const M = poly.matrix; + const M = matrixOf(poly); const MI = mat4.invert(mat4.create(), M)!; const width = parseFloat(poly.domElement.style.getPropertyValue("--c3r-width")); const height = parseFloat(poly.domElement.style.getPropertyValue("--c3r-height")); @@ -85,7 +90,7 @@ describe("Polygon", () => { vertex(1, 0, 0), vertex(0, 1, 0), ]); - const M = poly.matrix; + const M = matrixOf(poly); const col = (i: number) => Math.hypot(M[i * 4], M[i * 4 + 1], M[i * 4 + 2]); expect(col(2) / col(0)).toBeCloseTo(1); @@ -206,11 +211,12 @@ describe("Polygon.clampUV", () => { edges: square.map(() => [{ point: [0, 0, 1], fold: "concave" }, null]), }); const { style } = poly.contentElement; + const sizes = () => + [...style.backgroundSize.matchAll(/([\d.e+-]+)%/g)].map((m) => +m[1]); - expect(parseFloat(style.backgroundSize)).toBeLessThan(100); + expect(sizes()[0]).toBeLessThan(100); poly.clampUV = true; - for (const size of style.backgroundSize.split(" ")) - expect(parseFloat(size)).toBeCloseTo(100); + for (const size of sizes()) expect(size).toBeCloseTo(100); }); }); @@ -331,7 +337,7 @@ describe("Group.addTriangles", () => { .clipPath; for (const [, x, y] of clip.matchAll(/([-\d.e]+)% ([-\d.e]+)%/g)) { const p = vec3.fromValues((+x / 100) * width, (+y / 100) * height, 0); - vec3.transformMat4(p, p, poly.matrix); + vec3.transformMat4(p, p, matrixOf(poly)); expect(p[1]).toBeGreaterThan(-1e-6); } }); @@ -357,7 +363,7 @@ describe("Group.addTriangles", () => { const points = [...clip.matchAll(/([-\d.e]+)% ([-\d.e]+)%/g)].map( ([, x, y]) => { const p = vec3.fromValues((+x / 100) * width, (+y / 100) * height, 0); - return vec3.transformMat4(p, p, poly.matrix); + return vec3.transformMat4(p, p, matrixOf(poly)); }, ); // The centre is on or inside every edge of the clip path @@ -491,6 +497,16 @@ describe("Group", () => { expect(child.domElement.parentElement).toBe(b.domElement); }); + it("rejects an ancestor without changing either group", () => { + const parent = new Group(); + const child = new Group(); + parent.addChild(child); + + expect(() => child.addChild(parent)).toThrow(RangeError); + expect(child.children).toHaveLength(0); + expect(parent.children).toEqual([child]); + }); + it("replaces the container root in the DOM", () => { const container = new Container(); const root = new Group(); @@ -568,6 +584,25 @@ describe("Material", () => { expect(rule()).not.toContain("backface-visibility"); }); + it("plays a flipbook", () => { + using material = new Material(); + const rule = () => ruleOf(material.className); + + material.flipbook = { columns: 8, rows: 4, frames: 30 }; + expect(rule()).toMatch(/--c3r-frames: ?30;.*--c3r-flipbook-duration: ?1s/); + material.flipbook = null; + expect(rule()).not.toContain("--c3r-"); + material.background = "leaf.png"; + expect(rule()).toMatch(/--c3r-columns: ?initial/); + + for (const flipbook of [ + { columns: 1.5, rows: 1 }, + { columns: 2, rows: 2, frames: 5 }, + { columns: 2, rows: 2, fps: 0 }, + ]) + expect(() => (material.flipbook = flipbook)).toThrow(RangeError); + }); + it("paints a color or an image, hiding the other from a parent", () => { using material = new Material(); const vars = () => @@ -655,12 +690,90 @@ describe("Material", () => { }); }); +describe("Transform", () => { + it("composes in CSS order", () => { + const group = new Group(); + const steps = [Transform.translate([1, 2, 3]), Transform.rotateZ(1)]; + group.transform = steps; + steps.pop(); + const expected = mat4.fromTranslation(mat4.create(), [1, 2, 3]); + mat4.rotateZ(expected, expected, 1); + expect(matrixOf(group)).toEqual(expected); + expect(group.transform).toHaveLength(2); + }); + + it("rejects an invalid matrix", () => { + expect(() => Transform.matrix([1, 0, 0])).toThrow(RangeError); + expect(() => Transform.matrix(Array(16).fill(NaN))).toThrow(RangeError); + const huge = Transform.scale(1e20); + expect(() => (new Group().transform = [huge, huge])).toThrow(RangeError); + using camera = new PerspectiveCamera(); + expect(() => (camera.transform = Transform.scale(1e-39))).toThrow( + RangeError, + ); + }); + + it("decomposes a matrix into translation, rotation and scale", () => { + const rebuild = (m: mat4) => { + const { translation, axis, angle, scale } = decompose(m)!; + const q = quat.setAxisAngle(quat.create(), axis as vec3, angle); + return mat4.fromRotationTranslationScale( + mat4.create(), + q, + translation as vec3, + scale as vec3, + ); + }; + const turned = mat4.fromTranslation(mat4.create(), [1, 2, 3]); + mat4.rotate(turned, turned, 2, [1, 2, 3]); + mat4.scale(turned, turned, [0.5, 2, 3]); + const halfTurn = mat4.fromYRotation(mat4.create(), Math.PI); + for (const m of [turned, mat4.create(), halfTurn]) + for (const [i, n] of rebuild(m).entries()) expect(n).toBeCloseTo(m[i], 5); + + const mirror = decompose(mat4.fromScaling(mat4.create(), [1, 1, -1]))!; + expect(mirror.angle).toBe(0); + expect(mirror.scale).toEqual([1, 1, -1]); + + const shear = mat4.fromTranslation(mat4.create(), [1e5, 0, 0]); + shear[4] = 0.01; + const perspective = mat4.create(); + perspective[11] = -0.001; + for (const m of [shear, perspective, new Float32Array(16)]) + expect(decompose(m)).toBeNull(); + }); + + it("tweens a matrix only when it decomposes", () => { + const zero = Transform.matrix(new Float32Array(16)); + expect(() => (new Group().transform = zero)).not.toThrow(); + expect(() => new Tween({ keyframes: [zero] })).toThrow(RangeError); + const moved = Transform.matrix(mat4.fromXRotation(mat4.create(), 1)); + expect(() => + new Tween({ + keyframes: [[Transform.translate([1, 2, 3]), moved]], + })[Symbol.dispose](), + ).not.toThrow(); + expect(() => (new Material().filter = zero as never)).toThrow(TypeError); + }); + + it("copies its input", () => { + const v: [number, number, number] = [1, 2, 3]; + using tween = new Tween({ keyframes: [Transform.translate(v)] }); + v[0] = 9; + const keyframes = new RegExp( + String.raw`@keyframes ${tween.className} \{[^@]*`, + ).exec(cssText())![0]; + expect(keyframes).toContain(": 1px"); + expect(keyframes).not.toContain("9px"); + }); +}); + describe("Tween", () => { const turn = 2 * Math.PI; it("animates groups through class rules, removed on dispose", () => { const group = new Group(); - group.matrix = mat4.fromTranslation(mat4.create(), [1, 2, 3]); + group.transform = Transform.translate([1, 2, 3]); expect(group.domElement.style.getPropertyValue("--c3r-matrix")).toBe( "matrix3d(1,0,0,0,0,1,0,0,0,0,1,0,1,2,3,1)", ); @@ -778,6 +891,21 @@ describe("Tween", () => { expect(ruleOf(sequence.className)).toContain(`${sequence.className} 5s`); }); + it("keeps a Firefox copy of each keyframes rule in the output", () => { + const copy = (name: string) => + new RegExp( + `@supports \\(-moz-appearance: none\\) \\{\\s*@keyframes ${name} \\{[^@]*min-width: 0\\.001px`, + ); + expect(cssText()).toMatch(copy("c3r-flipbook")); + expect(cssText().indexOf("@supports (-moz-appearance")).toBeGreaterThan( + cssText().indexOf("@keyframes c3r-flipbook"), + ); + const tween = new Tween({ keyframes: [Filter.opacity(0)] }); + expect(cssText()).toMatch(copy(tween.className)); + tween[Symbol.dispose](); + expect(cssText()).not.toContain(tween.className); + }); + it("plays a loop on hover only, removed on dispose", () => { let selector; { @@ -960,7 +1088,7 @@ describe("server-side rendering", () => { it("exports the scene as static html", async () => { const container = new Container(); const group = new Group(); - group.matrix = mat4.fromScaling(mat4.create(), [0.01, 0.01, 0.01]); + group.transform = Transform.scale(0.01); group.addPolygon([vertex(0, 0, 0), vertex(1, 0, 0), vertex(0, 1, 0)]); container.root.addChild(group); @@ -1017,6 +1145,25 @@ describe("server-side rendering", () => { } }); + it("keeps an input rule's result inside its declaration", async () => { + using error = vi.spyOn(console, "error").mockImplementation(() => {}); + const container = new Container(); + using acc = new Accumulator({ + name: "unsafe", + syntax: "*", + identity: CssValue.number(0), + combine: (parent, input) => CssValue.add(parent, input), + input: () => "0; } * {color:red}" as unknown as CssValue, + }); + const target = new Group(); + container.root.addChild(target); + acc.addTarget(target); + const html = await container.toHTML(); + expect(html).not.toMatch(/color: ?red/); + expect(cssText()).not.toMatch(/color: ?red/); + expect(error).toHaveBeenCalled(); + }); + it("sizes an orthographic view from setSize, without layout", () => { const container = new Container(); container.setSize(400, 300); @@ -1029,7 +1176,7 @@ describe("server-side rendering", () => { "c3r-orthographic", camera.className, ]); - expect(style.getPropertyValue("--c3r-viewport-x")).toBe("400"); + expect(style.getPropertyValue("--c3r-viewport-width")).toBe("400px"); expect(cssText()).toMatch( new RegExp( String.raw`\.${camera.className} \{[^}]*--c3r-camera-left: ?-200`, @@ -1043,13 +1190,13 @@ describe("cameras", () => { const container = new Container(); { using camera = new PerspectiveCamera(60); - camera.matrix = mat4.fromTranslation(mat4.create(), [0, 0, 400]); + const pose = Transform.translate([0, 0, 400]); + camera.transform = pose; container.camera = camera; - expect(camera.viewMatrix[14]).toBe(-400); - expect( - () => (camera.matrix = mat4.fromScaling(mat4.create(), [0, 0, 0])), - ).toThrow(RangeError); - expect(camera.matrix[14]).toBe(400); + expect(viewMatrixOf(camera)[14]).toBe(-400); + expect(() => (camera.transform = Transform.scale(0))).toThrow(RangeError); + expect(camera.transform).toBe(pose); + expect(viewMatrixOf(camera)[14]).toBe(-400); expect(container.domElement.classList).toContain("c3r-perspective"); expect(cssText()).toMatch( @@ -1081,14 +1228,14 @@ describe("cameras", () => { it("fits the unit scale to the densest visible polygon", () => { const container = new Container(); const group = new Group(); - group.matrix = mat4.fromScaling(mat4.create(), [0.01, 0.01, 0.01]); + group.transform = Transform.scale(0.01); container.root.addChild(group); const poly = group.addPolygon([ vertex(0, 0, 0), vertex(1, 0, 0), vertex(0, 1, 0), ]); - const [x, y, z] = poly.matrix; + const [x, y, z] = matrixOf(poly); const domPixel = Math.hypot(x, y, z) * 0.01; const fit = () => { container.bake(); @@ -1100,14 +1247,14 @@ describe("cameras", () => { expect(scale * domPixel).toBeGreaterThanOrEqual(1); expect(scale * domPixel).toBeLessThan(2); - container.camera.matrix = mat4.fromScaling(mat4.create(), [0.5, 0.5, 0.5]); + container.camera.transform = Transform.scale(0.5); expect(fit()).toBe(scale / 2); group.domElement.style.display = "none"; expect(fit()).toBe(1); group.domElement.style.display = ""; - group.matrix = mat4.fromScaling(mat4.create(), [0, 0, 0]); + group.transform = Transform.scale(0); expect(fit()).toBe(1); }); }); diff --git a/packages/c3r/test/camera.browser.test.ts b/packages/c3r/test/camera.browser.test.ts index 66dd7f6..b4ab252 100644 --- a/packages/c3r/test/camera.browser.test.ts +++ b/packages/c3r/test/camera.browser.test.ts @@ -1,4 +1,5 @@ import { expect, it } from "vitest"; +import { page } from "vitest/browser"; import { Container, OrthographicCamera, PerspectiveCamera } from "c3r"; it("resolves the camera projections from styles.css", () => { @@ -20,3 +21,16 @@ it("resolves the camera projections from styles.css", () => { expect(perspective()).toBe("150px"); expect([view().m22, view().m43]).toEqual([-1, 150]); }); + +it("keeps an orthographic scale in step with a window resize", async () => { + await page.viewport(1000, 400); + const container = new Container(); + document.body.append(container.domElement); + container.camera = new OrthographicCamera(0, 500, 100, 0); + const view = () => + new DOMMatrix(getComputedStyle(container.viewElement).transform); + expect([view().m11, view().m22]).toEqual([2, -4]); + + await page.viewport(800, 200); + expect([view().m11, view().m22]).toEqual([1.6, -2]); +}); diff --git a/packages/c3r/test/cssvalue.test.ts b/packages/c3r/test/cssvalue.test.ts new file mode 100644 index 0000000..74f40eb --- /dev/null +++ b/packages/c3r/test/cssvalue.test.ts @@ -0,0 +1,73 @@ +import { expect, it } from "vitest"; +import { CssValue } from "c3r"; +import { + cssRaw, + cssVar, + matrixMultiply, + textOf, + typeOf, +} from "../src/css/CssValue.js"; + +const x = cssVar("--x", ""); + +it("prints math as calc, nesting in parentheses", () => { + expect(String(CssValue.mul(CssValue.add(1, x), 2))).toBe( + "calc((1 + var(--x)) * 2)", + ); + expect(String(CssValue.fn("rotateY", CssValue.mul(-1, x)))).toBe( + "rotateY(calc(-1 * var(--x)))", + ); + expect(String(CssValue.fn("matrix3d", 1, 0, -0, 2))).toBe( + "matrix3d(1,0,0,2)", + ); +}); + +it("matrix multiplies transforms only, skipping identities", () => { + const turn = CssValue.fn("rotateX", CssValue.rad(1)); + const list = cssVar("--t", "", matrixMultiply()); + expect(String(matrixMultiply(turn, null, matrixMultiply(), list))).toBe( + "rotateX(1rad) var(--t, )", + ); + for (const value of [x, CssValue.number(2), CssValue.fn("opacity", 1)]) + expect(() => matrixMultiply(value)).toThrow(TypeError); +}); + +it("types values by what they compute to", () => { + const { px, rad, add, mul, div, fn } = CssValue; + expect(typeOf(px(1))).toBe(""); + expect(typeOf(mul(px(1), x))).toBe(""); + expect(typeOf(div(px(1), px(2)))).toBe(""); + expect(typeOf(add(px(1), 1))).toBeNull(); + expect(typeOf(fn("sin", rad(1)))).toBe(""); + expect(typeOf(matrixMultiply())).toBe(""); + const turn = fn("rotate", rad(1)); + for (const invalid of [ + add(turn, turn), + div(px(1), rad(1)), + div(1, px(1)), + fn("min", 1, px(1)), + fn("sqrt", px(1)), + fn("asin", rad(1)), + ]) + expect(typeOf(invalid)).toBeNull(); +}); + +it("refuses values that could leave the declaration", () => { + for (const build of [ + () => CssValue.number(NaN), + () => CssValue.fn("url" as never, 1), + () => CssValue.fn("rotatex" as never, 1), + () => cssVar("--x; color: red", ""), + () => cssRaw("0; } * {color:red}"), + ]) + expect(build).toThrow(RangeError); + const fake = Object.create(CssValue.prototype) as CssValue; + expect(() => CssValue.mul(2, fake)).toThrow(TypeError); + const Forged = class extends (CssValue as unknown as new () => object) {}; + expect(() => Reflect.construct(Forged, [], CssValue)).toThrow(TypeError); + const value = CssValue.number(1); + expect(() => + Object.defineProperty(value, "toString", { value: () => "x; y" }), + ).toThrow(TypeError); + expect(textOf(value)).toBe("1"); +}); diff --git a/packages/c3r/test/flipbook.browser.test.ts b/packages/c3r/test/flipbook.browser.test.ts new file mode 100644 index 0000000..06d7f06 --- /dev/null +++ b/packages/c3r/test/flipbook.browser.test.ts @@ -0,0 +1,72 @@ +import { expect, it } from "vitest"; +import { page } from "vitest/browser"; +import { Container, Material, OrthographicCamera } from "c3r"; + +const COLORS = [ + [255, 0, 0], + [0, 255, 0], + [0, 0, 255], + [255, 255, 0], + [0, 255, 255], + [255, 0, 255], +]; + +// Two by three cells, in reading order +const atlas = async () => { + const canvas = document.createElement("canvas"); + canvas.width = 64; + canvas.height = 96; + const context = canvas.getContext("2d")!; + COLORS.forEach(([r, g, b], i) => { + context.fillStyle = `rgb(${r},${g},${b})`; + context.fillRect((i % 2) * 32, Math.floor(i / 2) * 32, 32, 32); + }); + const blob = await new Promise((resolve) => + canvas.toBlob(resolve), + ); + return URL.createObjectURL(blob!); +}; + +const centerColor = async (element: Element) => { + const { base64 } = await page.screenshot({ element, base64: true }); + const image = new Image(); + image.src = `data:image/png;base64,${base64}`; + await image.decode(); + const context = document.createElement("canvas").getContext("2d")!; + context.drawImage(image, -image.width / 2, -image.height / 2); + return [...context.getImageData(0, 0, 1, 1).data.slice(0, 3)]; +}; + +it("steps through the cells of a flipbook", async () => { + const container = new Container(); + document.body.append(container.domElement); + container.setSize(100, 100); + container.camera = new OrthographicCamera(0, 1, 1, 0); + + const material = new Material(); + material.background = await atlas(); + material.flipbook = { columns: 2, rows: 3, fps: 1 }; + container.root.material = material; + const square = [ + [0, 0], + [1, 0], + [1, 1], + [0, 1], + ].map(([x, y]) => ({ position: [x, y, 0], uv: [x, y] })); + const face = container.root.addPolygon(square); + const cell = container.root.addPolygon(square); + cell.textureSize = [32, 32]; + await container.bake(); + const width = (polygon: typeof face) => + polygon.domElement.style.getPropertyValue("--c3r-width"); + expect(width(face)).toBe(width(cell)); + container.root.removeChild(cell); + + const [animation] = face.contentElement.getAnimations(); + animation.pause(); + for (const [i, color] of COLORS.entries()) { + animation.currentTime = i * 1000 + 500; + expect(await centerColor(container.domElement)).toEqual(color); + } + container.domElement.remove(); +}); diff --git a/packages/c3r/test/particles.browser.test.ts b/packages/c3r/test/particles.browser.test.ts new file mode 100644 index 0000000..ebffcf5 --- /dev/null +++ b/packages/c3r/test/particles.browser.test.ts @@ -0,0 +1,118 @@ +import { expect, it } from "vitest"; +import { Container, Filter, Material, ParticleSystem, Transform } from "c3r"; + +const triangle = { + positions: [0, 0, 0, 1, 0, 0, 0, 1, 0], + uvs: [0, 0, 1, 0, 0, 1], + cells: [0, 1, 2], +}; + +const transform = (element: Element) => + new DOMMatrix(getComputedStyle(element).transform); + +// At a point of the 2s life, one loop past the particle's random start, so no +// animation sits before it +const freeze = (element: Element, time: number) => { + for (const animation of element.getAnimations({ subtree: true })) { + animation.pause(); + animation.currentTime = + time + 2000 + Number(animation.effect!.getTiming().delay); + } +}; + +it("tweens each particle, moving it in the scene and turning it in the view", () => { + const container = new Container(); + document.body.append(container.domElement); + const scales: number[] = []; + using system = new ParticleSystem({ + count: 2, + lifetime: 2, + lifetimeRandomness: 0, + mesh: triangle, + particle: ({ random }) => { + const scale = random(2, 4); + scales.push(scale); + return { + from: Transform.translate([0, 0, 0]), + keyframes: [ + { + steps: [ + Transform.translate([100, 0, 0]), + Transform.rotateZ(Math.PI / 2), + Transform.scale(scale), + Filter.tint([1, 0.5, 0]), + Filter.opacity(0.5), + ], + duration: 3, + }, + ], + }; + }, + }); + container.root.addChild(system); + const material = new Material(); + material.background = [1, 1, 1, 1]; + material.flipbook = { columns: 2, rows: 2 }; + system.material = material; + + const [particle] = system.children; + const body = particle.domElement.querySelector(".c3r-group")!; + // Relative durations fill the 2s life, so this is halfway + freeze(particle.domElement, 1000); + expect(transform(particle.domElement).m41).toBeCloseTo(50); + const { m11, m12 } = transform(body); + expect(m11).toBeCloseTo(m12); + expect(Math.hypot(m11, m12)).toBeCloseTo((1 + scales[0]) / 2); + const style = getComputedStyle(body); + expect(style.getPropertyValue("--c3r-tween-filter")).toContain("opacity("); + expect(style.getPropertyValue("--c3r-tween-layers")).toContain("rgb("); + + const content = particle.domElement.querySelector(".c3r-polygon-content")!; + freeze(particle.domElement, 750); + expect(getComputedStyle(content).getPropertyValue("--c3r-frame")).toBe("1"); + + system[Symbol.dispose](); + expect(transform(particle.domElement).m41).toBe(0); + container.domElement.remove(); +}); + +it("rejects invalid options", () => { + const options = { + count: 1, + lifetime: 1, + mesh: triangle, + particle: () => ({ keyframes: [Filter.opacity(0)] }), + }; + expect(() => new ParticleSystem({ ...options, count: 0 })).toThrow( + RangeError, + ); + expect(() => new ParticleSystem({ ...options, lifetime: 0 })).toThrow( + RangeError, + ); + expect( + () => new ParticleSystem({ ...options, lifetimeRandomness: 1 }), + ).toThrow(RangeError); + expect( + () => + new ParticleSystem({ + ...options, + particle: () => ({ + keyframes: [{ steps: Filter.opacity(0), duration: -1 }], + }), + }), + ).toThrow(RangeError); + // A start alone on one side holds, like a plain tween + using spun = new ParticleSystem({ + ...options, + particle: () => ({ + from: Transform.rotateZ(1), + keyframes: [Transform.translate([0, 10, 0])], + }), + }); + const container = new Container(); + document.body.append(container.domElement); + container.root.addChild(spun); + const body = spun.children[0].domElement.querySelector(".c3r-group")!; + expect(transform(body).m11).toBeCloseTo(Math.cos(1)); + container.domElement.remove(); +}); diff --git a/packages/c3r/test/tween.browser.test.ts b/packages/c3r/test/tween.browser.test.ts index 95d833c..0faf063 100644 --- a/packages/c3r/test/tween.browser.test.ts +++ b/packages/c3r/test/tween.browser.test.ts @@ -3,6 +3,7 @@ import { userEvent } from "vitest/browser"; import { mat4 } from "gl-matrix"; import { Container, + CssValue, cssText, Filter, Group, @@ -29,7 +30,7 @@ const square = (parent: Group) => const scene = () => { const container = new Container(); document.body.append(container.domElement); - container.camera.matrix = mat4.fromTranslation(mat4.create(), [0, 0, 400]); + container.camera.transform = Transform.translate([0, 0, 400]); const group = new Group(); container.root.addChild(group); return { @@ -40,19 +41,42 @@ const scene = () => { }; }; +const view = (c: Container) => + Array.from( + new DOMMatrix(getComputedStyle(c.viewElement).transform).toFloat64Array(), + (v) => +v.toFixed(2) + 0, + ); + const transform = (element: Element) => { const m = new DOMMatrix(getComputedStyle(element).transform); return [m.m11, m.m22, m.m41].map((v) => +v.toFixed(6)); }; -it("composes the tween after the matrix, past a half turn", () => { +it("composes the tween after the transform, past a half turn", () => { using s = scene(); - s.group.matrix = mat4.fromTranslation(mat4.create(), [100, 0, 0]); + s.group.transform = Transform.translate([100, 0, 0]); using tween = new Tween({ keyframes: [Transform.rotateZ(turn)], ...half }); s.group.tween = tween; expect(transform(s.group.domElement)).toEqual([-1, -1, 100]); }); +it("tweens a matrix as a rotation", () => { + using a = scene(); + using b = scene(); + using matrix = new Tween({ + keyframes: [Transform.matrix(mat4.fromZRotation(mat4.create(), turn / 4))], + ...half, + }); + using angle = new Tween({ + keyframes: [Transform.rotateZ(turn / 4)], + ...half, + }); + a.group.tween = matrix; + b.group.tween = angle; + expect(transform(a.group.domElement)).toEqual(transform(b.group.domElement)); + expect(transform(a.group.domElement)[0]).toBeCloseTo(Math.SQRT1_2); +}); + it("holds the previous keyframe's steps", () => { using s = scene(); using tween = new Tween({ @@ -65,9 +89,9 @@ it("holds the previous keyframe's steps", () => { expect(transform(s.group.domElement)).toEqual([-1, -1, 50]); }); -it("keeps the matrix and the other tweens when one is disposed", () => { +it("keeps the transform and the other tweens when one is disposed", () => { using s = scene(); - s.group.matrix = mat4.fromTranslation(mat4.create(), [100, 0, 0]); + s.group.transform = Transform.translate([100, 0, 0]); using spin = new Tween({ keyframes: [Transform.rotateZ(turn)], ...half }); const tint = new Tween({ keyframes: [Filter.sepia(1)], ...half }); using fade = new Tween({ keyframes: [Filter.opacity(0)], ...half }); @@ -82,7 +106,7 @@ it("keeps the matrix and the other tweens when one is disposed", () => { expect(s.group.domElement.getAnimations()).toHaveLength(1); }); -it("moves the camera after its matrix", () => { +it("moves the camera after its transform", () => { using s = scene(); using move = new Tween({ keyframes: [[Transform.translate([100, 20, 50]), Transform.rotateY(1)]], @@ -95,20 +119,33 @@ it("moves the camera after its matrix", () => { s.container.camera.tween = [move, grow]; using still = scene(); - const world = mat4.clone(s.container.camera.matrix); - mat4.translate(world, world, [50, 10, 25]); - mat4.rotateY(world, world, 0.5); - mat4.scale(world, world, [1.5, 1, 0.75]); - still.container.camera.matrix = world; - - const view = (c: Container) => - Array.from( - new DOMMatrix(getComputedStyle(c.viewElement).transform).toFloat64Array(), - (v) => +v.toFixed(2) + 0, - ); + still.container.camera.transform = [ + Transform.translate([0, 0, 400]), + Transform.translate([50, 10, 25]), + Transform.rotateY(0.5), + Transform.scale([1.5, 1, 0.75]), + ]; + expect(view(s.container)).toEqual(view(still.container)); }); +it("moves the camera halfway along a matrix's parts", () => { + using a = scene(); + using b = scene(); + const pose = (t: number) => { + const m = mat4.fromTranslation(mat4.create(), [100 * t, 20 * t, 50 * t]); + mat4.rotate(m, m, t, [1, 2, 3]); + return mat4.scale(m, m, [1 + 0.5 * t, 1, 1 - 0.25 * t]); + }; + using matrix = new Tween({ keyframes: [Transform.matrix(pose(1))], ...half }); + a.container.camera.tween = matrix; + b.container.camera.transform = [ + Transform.translate([0, 0, 400]), + Transform.matrix(pose(0.5)), + ]; + expect(view(a.container)).toEqual(view(b.container)); +}); + it("rests a hover transition at `from`, also when assigned later", () => { using s = scene(); using fade = new Tween({ @@ -227,11 +264,14 @@ it("tweens a tint linearly in sRGB", () => { using s = scene(); using tween = new Tween({ keyframes: [Filter.tint([0, 0.5, 1])], ...half }); s.group.tween = tween; - expect( - getComputedStyle(s.group.domElement).getPropertyValue( - `--${tween.className}-tint`, - ), - ).toBe("rgb(128, 191, 255)"); + const tint = getComputedStyle(s.group.domElement).getPropertyValue( + `--${tween.className}-tint`, + ); + // Firefox and WebKit round the end color to 8 bits before interpolating + const channels = tint.match(/[\d.]+/g)!.map(Number); + [127.5, 191.25, 255].forEach((expected, i) => + expect(Math.abs(channels[i] - expected)).toBeLessThanOrEqual(1), + ); }); // A blob url, data urls skip the check @@ -316,3 +356,37 @@ it("keeps the rest of the animation after a commented easing", () => { "infinite", ); }); + +it("runs a clock that filters read, beside the tweens", () => { + using s = scene(); + s.group.material = new Material(); + s.group.material.filter = Filter.opacity(CssValue.div(Group.time, 10)); + using tween = new Tween({ keyframes: [Filter.brightness(2)], ...half }); + s.group.tween = tween; + s.group.clock = true; + + const animations = s.group.domElement.getAnimations(); + expect(animations.map((a) => (a as CSSAnimation).animationName)).toEqual([ + tween.className, + "c3r-time", + ]); + animations[1].pause(); + animations[1].currentTime = 5000; + expect(getComputedStyle(s.face.contentElement).filter).toBe( + "opacity(0.5) brightness(1.5)", + ); + + s.group.clock = false; + expect(s.group.domElement.getAnimations()).toHaveLength(1); + expect(() => new Tween({ keyframes: [Filter.opacity(Group.time)] })).toThrow( + RangeError, + ); +}); + +it("rejects a filter amount of the wrong type", () => { + expect(() => Filter.opacity(CssValue.px(1))).toThrow(RangeError); + expect(() => Filter.opacity(CssValue.number(-1))).toThrow(RangeError); + expect(() => + Filter.opacity(CssValue.fn("sin", CssValue.mul(Group.time, 2))), + ).not.toThrow(); +});