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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 13 additions & 4 deletions .github/scripts/bump-versions.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -11,15 +11,24 @@
* untouched), and prints the new version to stdout (also exported as
* NEXT_VERSION when running inside GitHub Actions).
*/
import { readFileSync, writeFileSync, appendFileSync } from "node:fs";
import {
appendFileSync,
existsSync,
readFileSync,
readdirSync,
writeFileSync,
} from "node:fs";
import { resolve, dirname } from "node:path";
import { fileURLToPath } from "node:url";

const __dirname = dirname(fileURLToPath(import.meta.url));
const root = resolve(__dirname, "..", "..");
const packages = ["core", "polycss", "react", "vue"].map((d) =>
resolve(root, "packages", d, "package.json"),
);
const packagesRoot = resolve(root, "packages");
const packages = readdirSync(packagesRoot, { withFileTypes: true })
.filter((entry) => entry.isDirectory())
.map((entry) => resolve(packagesRoot, entry.name, "package.json"))
.filter(existsSync)
.sort();

const bump = process.argv[2];
if (!["patch", "minor", "major"].includes(bump)) {
Expand Down
24 changes: 23 additions & 1 deletion .github/scripts/sync-package-readmes.mjs
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { copyFileSync } from "node:fs";
import { dirname, resolve } from "node:path";
import { dirname, relative, resolve } from "node:path";
import { fileURLToPath } from "node:url";

const repoRoot = resolve(dirname(fileURLToPath(import.meta.url)), "..", "..");
Expand All @@ -10,6 +10,28 @@ const targets = [
"packages/react/README.md",
"packages/vue/README.md",
];
const packageSpecificTargets = [
"packages/fonts/README.md",
"packages/morph/README.md",
];

const invokedFrom = relative(repoRoot, process.cwd());
const invokedFromPackageReadme = invokedFrom.startsWith("packages/")
? `${invokedFrom}/README.md`
: undefined;

if (
invokedFromPackageReadme !== undefined
&& packageSpecificTargets.includes(invokedFromPackageReadme)
) {
console.log(`[sync-package-readmes] preserved ${invokedFromPackageReadme}`);
process.exit(0);
}

if (invokedFromPackageReadme !== undefined && !targets.includes(invokedFromPackageReadme)) {
console.log(`[sync-package-readmes] skipped for ${invokedFromPackageReadme}`);
process.exit(0);
}

for (const target of targets) {
copyFileSync(source, resolve(repoRoot, target));
Expand Down
9 changes: 9 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -37,8 +37,17 @@ jobs:
- name: Run tests
run: pnpm test

- name: Enforce Morph coverage
run: pnpm --filter @layoutit/polycss-morph test:coverage

- name: Build packages (DTS + JS)
run: pnpm build:packages

- name: Certify Morph package
run: pnpm --filter @layoutit/polycss-morph test:package

- name: Certify Morph with registry dependencies
run: pnpm --filter @layoutit/polycss-morph test:package:registry

- name: Build website
run: pnpm build:website
41 changes: 41 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ Monorepo layout (pnpm workspaces):
| `packages/react` | `@layoutit/polycss-react` | React components + hooks. Owns its own copy of atlas rasterisation. Depends on `core` only — **NOT on `polycss`.** |
| `packages/vue` | `@layoutit/polycss-vue` | Vue 3 mirror of the React package. Owns its own copy of atlas rasterisation. Depends on `core` only. |
| `packages/fonts` | `@layoutit/polycss-fonts` | Fonts + text → extruded 3D `Polygon[]`. Hand-written TrueType (`glyf`) reader + extruder (flat/round/bevel profiles) + Google Fonts loader. Framework-agnostic (returns `Polygon[]`, no React/Vue mirror needed). Depends on `core` + `earcut`. |
| `packages/morph` | `@layoutit/polycss-morph` | Framework-agnostic prepared-model contracts, deterministic Node preparation, browser loading, retained DOM mounting, sparse deformation, controls, springs, animation, joint skinning, and prepared playback. The browser entry uses public `@layoutit/polycss` APIs; Node-only preparation lives at `@layoutit/polycss-morph/prepare`. No React/Vue mirrors. |
| `website` | `@layoutit/polycss-website` | Astro + Starlight docs site. Not published. |
| `examples/{html,vanilla,react,vue,fontcss}` | private | Per-framework Vite apps demonstrating the minimal usage for each renderer (`fontcss` demos `@layoutit/polycss-fonts`). Workspace members so they resolve to local `workspace:^` packages. Not published. |

Expand Down Expand Up @@ -105,6 +106,46 @@ The current exception is imported skeletal animation. glTF/GLB skinning changes

If you find yourself wanting a `requestAnimationFrame` loop to update many DOM nodes outside skeletal animation, stop. Find the CSS variable that should be carrying the change, and update that single variable on a single ancestor. Cascading + `@property`-registered custom properties do the rest.

### PolyCSS Morph boundary

`@layoutit/polycss-morph` is an imperative, framework-agnostic prepared-model
layer. It does not replace ordinary `Polygon[]` loading and it does not add
React or Vue wrappers.

- `@layoutit/polycss-morph/prepare` is the Node-only authoring boundary. It
reads strict config plus glTF/GLB input and writes a deterministic,
content-addressed package with `manifest.json` last. The generic preparer
directly authors `static-prepared` and `morph-regions` with canonical solid
CSS triangle leaves for base-color materials plus packed prepared alpha-atlas
pages for browsers without the corner-shape triangle primitive. Every
polygon owns a slice sized to its local-2D bounding rect and a precomputed
transform relation; there is no shared canonical triangle mask. The pages
are generated with Node built-ins, add no native image dependency, and are
selected once at mount without runtime rasterization. Product adapters or
dedicated tooling may author other validated image-backed, `joint-skin`, and
`prepared-playback` packages.
- `@layoutit/polycss-morph` is the browser-safe boundary. It validates and loads
prepared packages, mounts one retained PolyCSS graph, and exposes
caller-driven runtimes for morphs, controls, springs, animation, skinning,
and prepared playback.
- A mount uses PolyCSS's public solid-triangle support check, so Firefox keeps
its border-triangle path while WebKit/Safari selects each leaf's prepared
polygon-sized atlas slice. Image paint comes only from loader-verified bytes:
mount creates object URLs once and revokes them at teardown, with no resource
refetch or arbitrary prepared CSS injection. It creates topology and leaves
once. Runtime samples may update only declared model, shape, or leaf state.
They must preserve leaf identity, must not rebuild topology or redraw atlases,
and must not own a scheduler. Prepared playback commits a sample only after
the caller's retained apply succeeds. The caller owns input and timing.
- Morph does not copy renderer feature detection or maintain a fourth visual
browser harness. Package certification exercises native and fallback retained
DOM resolution; actual engine-specific triangle painting remains covered by
the renderer-owned browser paths that define `isSolidTriangleSupported`.
- The four executable profiles are `static-prepared`, `morph-regions`,
`joint-skin`, and `prepared-playback`.
- Product-specific source cadence, schemas, preparation provenance, mounting
paths, product behavior, and oracle evidence stay in the consuming product.

## Naming (three.js parity)

- Brand text is **PolyCSS**. Keep lowercase `polycss` only for literal package names, import paths, CSS classes, domains, and other code identifiers.
Expand Down
56 changes: 56 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -153,6 +153,61 @@ const html = await exportPolySceneSnapshot(scene.host);

If any referenced asset cannot be inlined, the function throws `PolySceneSnapshotError` with `code: "ASSET_INLINE_FAILED"`.

### Prepared retained models

The source tree includes `@layoutit/polycss-morph`, an imperative,
framework-agnostic package for models prepared ahead of time and updated through
one retained PolyCSS DOM graph. Version `0.0.1` is qualified for publication;
this documentation does not announce npm registry availability.

Preparation is explicitly Node-only:

```ts
import { preparePolyMorphModel } from "@layoutit/polycss-morph/prepare";

await preparePolyMorphModel({
configPath: "./source/prepare.json",
outputRoot: "./public/model/package",
});
```

The browser entry loads, mounts, samples, and applies updates:

```ts
import {
createPolyMorphDeformationRuntime,
loadPolyMorphPackage,
mountPolyMorphModel,
} from "@layoutit/polycss-morph";

const loaded = await loadPolyMorphPackage("/model/");
const mounted = mountPolyMorphModel(host, loaded.model, {
resources: loaded.resources,
});
const deformation = createPolyMorphDeformationRuntime(loaded.model);
const frame = deformation.sample({
tick: 0,
morphWeights: { "corner-lift": 0.5 },
});

mounted.apply({ leaves: frame.leafUpdates });
```

Morph supports `static-prepared`, `morph-regions`, `joint-skin`, and
`prepared-playback` profiles. It does not own an animation scheduler: callers
sample controls, springs, clips, skinning, or playback and pass only changed
rows to `mounted.apply(...)`. Mounted leaf identity stays stable, and runtime
updates do not rebuild topology or redraw prepared image resources.
Prepared playback uses a two-phase sample: apply `sample.update`, then call
`runtime.commit(sample)` only after the retained mount accepts the update.

Prepared solid triangles use the native CSS triangle primitive when supported.
Preparation also emits packed alpha-atlas pages for WebKit/Safari and other
browsers without a supported CSS triangle primitive. Every polygon receives a
slice sized to its local-2D bounding rect. Mount creates object URLs from the
already-verified package bytes, selects the fallback once, and revokes those
URLs at teardown; it never refetches, generates, or redraws the atlas.

### Polygon Data Model

Each polygon describes one renderable face:
Expand Down Expand Up @@ -233,6 +288,7 @@ Each visible polygon is emitted as one leaf element; the renderer chooses the le
| `@layoutit/polycss` | Vanilla custom elements and imperative `createPolyScene` API. |
| `@layoutit/polycss-react` | React components, hooks, controls, and core re-exports. |
| `@layoutit/polycss-vue` | Vue 3 components, composables, controls, and core re-exports. |
| `@layoutit/polycss-morph` | Prepared retained-model contracts, Node preparation, browser loading, sparse deformation, controls, springs, animation, skinning, and playback. |

## Made with PolyCSS

Expand Down
12 changes: 12 additions & 0 deletions packages/morph/CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
# Changelog

## 0.0.1

- Prepare deterministic, content-addressed retained models from glTF and GLB
sources through the Node-only entry.
- Load and mount prepared models through the browser-safe entry with stable leaf
identity and caller-owned timing.
- Support static models, sparse morph regions, controls, springs, animation,
joint skinning, and prepared playback.
- Use native CSS triangle primitives where available and prepared,
polygon-sized alpha-atlas slices elsewhere, with no runtime rasterization.
101 changes: 101 additions & 0 deletions packages/morph/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,101 @@
# @layoutit/polycss-morph

Prepared retained-model deformation and playback for PolyCSS.

Version `0.0.1` is the first release candidate. This README documents the
source-tree API; it does not announce npm registry availability.

## Boundary

Morph has two public entries:

- `@layoutit/polycss-morph/prepare` is Node-only. It reads strict authoring
config and glTF/GLB source, builds topology and retained render plans,
emits canonical solid CSS triangle leaves plus packed alpha-atlas fallback
pages with one local-size slice per polygon, and writes a deterministic,
content-addressed package with `manifest.json` last.
- `@layoutit/polycss-morph` is browser-safe. It validates and loads prepared
packages, mounts one retained PolyCSS graph, and exposes imperative,
caller-driven runtimes.

The generic Node preparer directly creates `static-prepared` and
`morph-regions` models. The browser runtime executes all four validated
profiles:

| Profile | Runtime contract |
|---|---|
| `static-prepared` | Mount a prepared retained graph without deformation. |
| `morph-regions` | Apply sparse prepared morph targets, semantic controls, springs, and clips. |
| `joint-skin` | Evaluate a validated joint hierarchy and weighted vertex deformation. |
| `prepared-playback` | Apply source-ordered model, shape, visibility, opacity, transform, and atlas-row changes. |

There are no React or Vue wrappers. React, Vue, and vanilla applications use
the same imperative package.

## Prepare in Node

```ts
import { preparePolyMorphModel } from "@layoutit/polycss-morph/prepare";

const report = await preparePolyMorphModel({
configPath: "./source/prepare.json",
outputRoot: "./public/model/package",
});

console.log(report.manifestSha256);
```

Pass `check: true` to verify that an existing output directory matches the
deterministic package exactly without rewriting it.

## Load and mount in a browser

```ts
import {
createPolyMorphDeformationRuntime,
loadPolyMorphPackage,
mountPolyMorphModel,
} from "@layoutit/polycss-morph";

const loaded = await loadPolyMorphPackage("/model/");
const mounted = mountPolyMorphModel(host, loaded.model, {
resources: loaded.resources,
});
const deformation = createPolyMorphDeformationRuntime(loaded.model);
const frame = deformation.sample({
tick: 0,
morphWeights: { "corner-lift": 0.5 },
});

mounted.apply({ leaves: frame.leafUpdates });
```

The browser API is intentionally imperative:

- load or validate a model;
- mount once;
- create only the runtimes the model uses;
- sample them from application input or time;
- pass changed model, shape, or leaf rows to `mounted.apply(...)`;
- for prepared playback, call `runtime.commit(sample)` only after
`mounted.apply(sample.update)` succeeds;
- call `mounted.destroy()` at teardown.

Morph owns no `requestAnimationFrame` loop, interval, or other scheduler. A
mounted model keeps the same leaf elements for its lifetime. Runtime updates do
not rebuild topology, add or remove leaves, construct image resources, or
redraw prepared image resources.

The browser resolves prepared triangles once during mount. Supporting browsers,
including Firefox, use a native CSS triangle primitive. WebKit/Safari and other
browsers without a supported primitive use each leaf's prepared polygon-sized
atlas slice. Mount creates object URLs from the loader's already-verified image
bytes and revokes them at teardown; it does not refetch package resources.
Atlas pages are generated with Node built-ins, so Morph has no Sharp or other
native image dependency.

## Consumer adapters

Product-specific source cadence, schemas, input ordering, presentation, and
oracle tooling stay in the consuming product. Product adapters own their
prepared packages, mounting paths, presentation, and oracle evidence.
74 changes: 74 additions & 0 deletions packages/morph/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
{
"name": "@layoutit/polycss-morph",
"version": "0.0.1",
"description": "Prepared retained-model deformation and playback for PolyCSS.",
"type": "module",
"main": "dist/index.cjs",
"module": "dist/index.js",
"types": "dist/index.d.ts",
"keywords": [
"polycss",
"morph",
"animation",
"skinning",
"3d",
"css",
"matrix3d",
"dom"
],
"license": "MIT",
"repository": {
"type": "git",
"url": "https://github.com/LayoutitStudio/polycss.git",
"directory": "packages/morph"
},
"bugs": {
"url": "https://github.com/LayoutitStudio/polycss/issues"
},
"homepage": "https://github.com/LayoutitStudio/polycss#readme",
"files": [
"dist"
],
"exports": {
".": {
"types": "./dist/index.d.ts",
"import": "./dist/index.js",
"require": "./dist/index.cjs"
},
"./prepare": {
"types": "./dist/prepare.d.ts",
"import": "./dist/prepare.js",
"require": "./dist/prepare.cjs"
}
},
"typesVersions": {
"*": {
"prepare": [
"dist/prepare.d.ts"
]
}
},
"scripts": {
"build": "tsup",
"test": "vitest run",
"test:package": "node scripts/certify-package.mjs",
"test:package:registry": "node scripts/certify-package.mjs --registry-dependencies",
"test:coverage": "vitest run --coverage",
"prepack": "node ../../.github/scripts/sync-package-readmes.mjs",
"prepublishOnly": "npm run build"
},
"publishConfig": {
"access": "public"
},
"dependencies": {
"@layoutit/polycss": "workspace:^"
},
"devDependencies": {
"@types/node": "^25.0.0",
"@vitest/coverage-v8": "^3.1.1",
"happy-dom": "^20.7.0",
"tsup": "^8.0.1",
"typescript": "^5.3.3",
"vitest": "^3.1.1"
}
}
Loading
Loading