diff --git a/.github/scripts/bump-versions.mjs b/.github/scripts/bump-versions.mjs index 64ea28f2..e29bdb75 100644 --- a/.github/scripts/bump-versions.mjs +++ b/.github/scripts/bump-versions.mjs @@ -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)) { diff --git a/.github/scripts/sync-package-readmes.mjs b/.github/scripts/sync-package-readmes.mjs index f1ffe2aa..f6ec7a3e 100644 --- a/.github/scripts/sync-package-readmes.mjs +++ b/.github/scripts/sync-package-readmes.mjs @@ -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)), "..", ".."); @@ -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)); diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 27ec52a7..e651c019 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -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 diff --git a/AGENTS.md b/AGENTS.md index 8ff5d619..b12b0de8 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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. | @@ -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. diff --git a/README.md b/README.md index 9daf6e25..92804b16 100644 --- a/README.md +++ b/README.md @@ -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: @@ -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 diff --git a/packages/morph/CHANGELOG.md b/packages/morph/CHANGELOG.md new file mode 100644 index 00000000..f584c54b --- /dev/null +++ b/packages/morph/CHANGELOG.md @@ -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. diff --git a/packages/morph/README.md b/packages/morph/README.md new file mode 100644 index 00000000..4493246d --- /dev/null +++ b/packages/morph/README.md @@ -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. diff --git a/packages/morph/package.json b/packages/morph/package.json new file mode 100644 index 00000000..9765bf63 --- /dev/null +++ b/packages/morph/package.json @@ -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" + } +} diff --git a/packages/morph/scripts/certify-package.mjs b/packages/morph/scripts/certify-package.mjs new file mode 100644 index 00000000..ffad921c --- /dev/null +++ b/packages/morph/scripts/certify-package.mjs @@ -0,0 +1,557 @@ +import { execFile } from "node:child_process"; +import { createHash } from "node:crypto"; +import { + mkdir, + mkdtemp, + readFile, + realpath, + readdir, + rm, + stat, + writeFile, +} from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { + dirname, + join, + relative, + resolve, +} from "node:path"; +import { fileURLToPath } from "node:url"; +import { promisify } from "node:util"; +import { generateCertificationFixture } from "./generate-certification-fixture.mjs"; + +const runFile = promisify(execFile); +const morphRoot = resolve(dirname(fileURLToPath(import.meta.url)), ".."); +const repoRoot = resolve(morphRoot, "..", ".."); +const registryDependencies = process.argv.includes("--registry-dependencies"); +const reportFlag = process.argv.indexOf("--report"); +const reportArgument = reportFlag === -1 ? null : process.argv[reportFlag + 1]; +if ( + reportFlag !== -1 + && (reportArgument === undefined || reportArgument.startsWith("--")) +) { + throw new Error("--report requires a path"); +} +const reportPath = reportArgument + ? resolve(process.cwd(), reportArgument) + : null; + +async function run(command, args, cwd, options = {}) { + const result = await runFile(command, args, { + cwd, + env: { ...process.env, ...options.env }, + maxBuffer: 32 * 1024 * 1024, + }); + return { + stdout: result.stdout.trim(), + stderr: result.stderr.trim(), + }; +} + +async function packPackage(packageName, packageRoot, artifactRoot, ignoreScripts) { + const before = new Set(await readdir(artifactRoot)); + await run( + "pnpm", + [ + `--config.ignore-scripts=${ignoreScripts ? "true" : "false"}`, + "--dir", + packageRoot, + "pack", + "--pack-destination", + artifactRoot, + ], + repoRoot, + ); + const created = (await readdir(artifactRoot)) + .filter((entry) => !before.has(entry) && entry.endsWith(".tgz")); + if (created.length !== 1) { + throw new Error(`${packageName} pack created ${created.length} tarballs`); + } + return resolve(artifactRoot, created[0]); +} + +async function tarInventory(tarball) { + const result = await run("tar", ["-tzf", tarball], repoRoot); + return result.stdout.split("\n").filter(Boolean).sort(); +} + +async function extractTarball(tarball, target) { + await mkdir(target, { recursive: true }); + await run("tar", ["-xzf", tarball, "-C", target], repoRoot); +} + +function exactInventory(actual, expected) { + if ( + actual.length !== expected.length + || actual.some((entry, index) => entry !== expected[index]) + ) { + throw new Error( + `unexpected Morph tarball inventory:\n${actual.join("\n")}`, + ); + } +} + +function fileDependency(tarball, consumerRoot) { + return `file:${relative(consumerRoot, tarball).replaceAll("\\", "/")}`; +} + +function linkDependency(packageRoot, consumerRoot) { + return `link:${relative(consumerRoot, packageRoot).replaceAll("\\", "/")}`; +} + +async function writeConsumer( + consumerRoot, + artifactRoot, + tarballs, + polycssDependency, +) { + const sourceRoot = resolve(consumerRoot, "source"); + const morphDependency = fileDependency(tarballs.morph, consumerRoot); + const [canonicalConsumerRoot, happyDomRoot, typescriptRoot] = await Promise.all([ + realpath(consumerRoot), + realpath(resolve(morphRoot, "node_modules/happy-dom")), + realpath(resolve(morphRoot, "node_modules/typescript")), + ]); + const dependencies = { + "@layoutit/polycss": registryDependencies + ? polycssDependency + : fileDependency(tarballs.polycss, consumerRoot), + "@layoutit/polycss-morph": morphDependency, + "happy-dom": linkDependency(happyDomRoot, canonicalConsumerRoot), + "typescript": linkDependency(typescriptRoot, canonicalConsumerRoot), + }; + const consumerPackage = { + name: "polycss-morph-clean-consumer", + private: true, + version: "0.0.0", + type: "module", + dependencies, + ...(registryDependencies + ? {} + : { + pnpm: { + overrides: { + "@layoutit/polycss-core": fileDependency(tarballs.core, consumerRoot), + "@layoutit/polycss": fileDependency(tarballs.polycss, consumerRoot), + }, + }, + }), + }; + await generateCertificationFixture(sourceRoot); + await writeFile( + resolve(consumerRoot, "package.json"), + `${JSON.stringify(consumerPackage, null, 2)}\n`, + ); + await writeFile(resolve(consumerRoot, "tsconfig.json"), `${JSON.stringify({ + compilerOptions: { + target: "ES2020", + module: "NodeNext", + moduleResolution: "NodeNext", + lib: ["ES2020", "DOM", "DOM.Iterable"], + strict: true, + skipLibCheck: false, + noEmit: true, + }, + include: ["declarations.ts"], + }, null, 2)}\n`); + await writeFile(resolve(consumerRoot, "declarations.ts"), ` +import { createPolyOrthographicCamera } from "@layoutit/polycss"; +import { + createPolyMorphDeformationRuntime, + mountPolyMorphModel, + type PolyMorphModel, +} from "@layoutit/polycss-morph"; +import { + preparePolyMorphModel, + type PolyMorphPrepareOptions, +} from "@layoutit/polycss-morph/prepare"; + +declare const host: HTMLElement; +declare const model: PolyMorphModel; + +const camera = createPolyOrthographicCamera({ zoom: 1 }); +const mounted = mountPolyMorphModel(host, model, { camera }); +const runtime = createPolyMorphDeformationRuntime(model); +const options: PolyMorphPrepareOptions = { + configPath: "./source/prepare.json", + outputRoot: "./model/package", +}; + +void runtime.sample({ tick: 0 }); +void mounted; +void preparePolyMorphModel(options); +`); + await writeFile(resolve(consumerRoot, "prepare-smoke.mjs"), ` +import { mkdir, writeFile } from "node:fs/promises"; +import { resolve } from "node:path"; +import { + buildPolyMorphCatalog, + preparePolyMorphModel, +} from "@layoutit/polycss-morph/prepare"; + +const outputRoot = resolve("model/package"); +const report = await preparePolyMorphModel({ + configPath: resolve("source/prepare.json"), + outputRoot, +}); +const catalog = await buildPolyMorphCatalog(report.model.identity.id, [{ + manifest: report.manifest, + manifestPath: "package/manifest.json", + manifestSha256: report.manifestSha256, +}]); +await mkdir(resolve("model"), { recursive: true }); +await writeFile(resolve("model/catalog.json"), catalog.bytes); +console.log(JSON.stringify({ + identity: report.model.identity, + profile: report.model.profile, + leaves: report.model.render.leaves.length, + manifestSha256: report.manifestSha256, + files: [...report.files, "catalog.json"].sort(), + writeOrder: report.writeOrder, +})); +`); + await writeFile(resolve(consumerRoot, "browser-smoke.mjs"), ` +import { readFile } from "node:fs/promises"; +import { resolve, sep } from "node:path"; +import { Window } from "happy-dom"; + +const browser = new Window({ url: "https://consumer.test/" }); +for (const [key, value] of Object.entries({ + window: browser, + document: browser.document, + location: browser.location, + navigator: browser.navigator, + HTMLElement: browser.HTMLElement, + Element: browser.Element, + Node: browser.Node, + CSSStyleSheet: browser.CSSStyleSheet, + getComputedStyle: browser.getComputedStyle.bind(browser), +})) { + Object.defineProperty(globalThis, key, { + configurable: true, + writable: true, + value, + }); +} + +const modelRoot = resolve("model"); +const fetchImpl = async (input) => { + const raw = typeof input === "string" + ? input + : input instanceof URL + ? input.href + : input.url; + const url = new URL(raw); + if (url.origin !== "https://consumer.test" || !url.pathname.startsWith("/model/")) { + return new Response(null, { status: 404 }); + } + const path = decodeURIComponent(url.pathname.slice("/model/".length)); + const absolute = resolve(modelRoot, path); + if (absolute !== modelRoot && !absolute.startsWith(\`\${modelRoot}\${sep}\`)) { + return new Response(null, { status: 404 }); + } + try { + const bytes = await readFile(absolute); + return new Response(bytes, { + status: 200, + headers: { "content-length": String(bytes.byteLength) }, + }); + } catch { + return new Response(null, { status: 404 }); + } +}; + +const { + createPolyMorphDeformationRuntime, + loadPolyMorphPackage, + mountPolyMorphModel, +} = await import("@layoutit/polycss-morph"); +const { createPolyOrthographicCamera } = await import("@layoutit/polycss"); +const loaded = await loadPolyMorphPackage("https://consumer.test/model/", { fetchImpl }); +Object.defineProperty(browser, "CSS", { + configurable: true, + value: { supports: () => true }, +}); +const host = document.createElement("main"); +document.body.appendChild(host); +const mounted = mountPolyMorphModel(host, loaded.model, { + camera: createPolyOrthographicCamera({ zoom: 1 }), + resources: loaded.resources, +}); +const identities = [...mounted.leafHandles.values()].map((entry) => entry.element); +const deformation = createPolyMorphDeformationRuntime(loaded.model); +if (loaded.model.deformation.kind !== "morph-regions") { + throw new Error("packed fixture is not a morph-regions model"); +} +const targetId = loaded.model.deformation.targets[0]?.id; +if (!targetId) { + throw new Error("packed fixture contains no morph target"); +} +const frame = deformation.sample({ + tick: 0, + morphWeights: { [targetId]: 0.5 }, +}); +const applied = mounted.apply({ leaves: frame.leafUpdates }); +mounted.assertStableDomIdentity(); +const identityStable = [...mounted.leafHandles.values()] + .every((entry, index) => entry.element === identities[index]); +const solidLeaves = loaded.model.render.leaves.every((leaf) => + leaf.strategy === "solid-triangle" + && leaf.atlas === null + && leaf.fallback + && leaf.fallback.width === leaf.fallback.atlas.width + && leaf.fallback.height === leaf.fallback.atlas.height); +const fallbackPaths = new Set(loaded.model.render.leaves.map( + (leaf) => leaf.fallback?.atlas.resourcePath, +)); +fallbackPaths.delete(undefined); +const fallbackSlices = new Set(loaded.model.render.leaves.map((leaf) => + leaf.fallback + ? \`\${leaf.fallback.atlas.resourcePath}:\${leaf.fallback.atlas.x}:\${leaf.fallback.atlas.y}\` + : "")); +fallbackSlices.delete(""); +const polygonSizedFallbacks = + fallbackSlices.size === loaded.model.render.leaves.length; +const canonicalDom = [...mounted.leafHandles.values()].every(({ element }) => + element.localName === "u" + && element.style.width === "" + && element.style.height === ""); + +Object.defineProperty(browser, "CSS", { + configurable: true, + value: { supports: () => false }, +}); +Object.defineProperty(browser.navigator, "userAgent", { + configurable: true, + value: "Mozilla/5.0 Version/18.0 Safari/605.1.15", +}); +const fallbackHost = document.createElement("main"); +document.body.appendChild(fallbackHost); +const fallbackMounted = mountPolyMorphModel(fallbackHost, loaded.model, { + camera: createPolyOrthographicCamera({ zoom: 1 }), + resources: loaded.resources, +}); +const fallbackIdentities = [...fallbackMounted.leafHandles.values()] + .map((entry) => entry.element); +const fallbackApplied = fallbackMounted.apply({ leaves: frame.leafUpdates }); +fallbackMounted.assertStableDomIdentity(); +const fallbackDom = [...fallbackMounted.leafHandles.values()] + .every(({ element }, index) => { + const fallback = loaded.model.render.leaves[index]?.fallback; + return !!fallback + && element === fallbackIdentities[index] + && element.localName === "s" + && element.dataset.polyMorphResolvedStrategy === "atlas-slice" + && element.style.width === \`\${fallback.width}px\` + && element.style.height === \`\${fallback.height}px\` + && element.style.getPropertyValue("mask-image").includes("blob:") + && element.style.getPropertyValue("-webkit-mask-image").includes("blob:"); + }); + +if ( + mounted.leafHandles.size !== loaded.model.render.leaves.length + || frame.dirtyLeafIds.length === 0 + || !identityStable + || !solidLeaves + || !polygonSizedFallbacks + || !canonicalDom + || applied.atlasRedraws !== 0 + || applied.topologyConstructions !== 0 + || applied.schedulerCallbacks !== 0 + || !fallbackDom + || fallbackApplied.atlasRedraws !== 0 + || fallbackApplied.topologyConstructions !== 0 + || fallbackApplied.schedulerCallbacks !== 0 +) { + throw new Error("packed browser entry violated the retained-model contract"); +} + +console.log(JSON.stringify({ + identity: loaded.model.identity, + profile: loaded.model.profile, + resources: [...loaded.resources.keys()].sort(), + leaves: mounted.leafHandles.size, + targetId, + dirtyLeafIds: frame.dirtyLeafIds, + identityStable, + solidLeaves, + polygonSizedFallbacks, + canonicalDom, + applied, + stats: mounted.stats, + fallback: { + identityStable: fallbackDom, + pages: fallbackPaths.size, + slices: fallbackSlices.size, + verifiedImageResources: fallbackPaths.size, + applied: fallbackApplied, + stats: fallbackMounted.stats, + }, +})); +mounted.destroy(); +fallbackMounted.destroy(); +await browser.happyDOM.abort(); +`); + return artifactRoot; +} + +function parseJsonOutput(result, label) { + const line = result.stdout.split("\n").filter(Boolean).at(-1); + if (!line) throw new Error(`${label} produced no output`); + return JSON.parse(line); +} + +const tempRoot = await mkdtemp(join(tmpdir(), "polycss-morph-cert-")); +const artifactRoot = resolve(tempRoot, "artifacts"); +const consumerRoot = resolve(tempRoot, "consumer"); +const extractedRoot = resolve(tempRoot, "extracted"); + +try { + await Promise.all([ + mkdir(artifactRoot, { recursive: true }), + mkdir(consumerRoot, { recursive: true }), + ]); + const pnpmVersion = (await run("pnpm", ["--version"], repoRoot)).stdout; + await run("pnpm", ["--filter", "@layoutit/polycss-core", "build"], repoRoot); + await run("pnpm", ["--filter", "@layoutit/polycss", "build"], repoRoot); + await run("pnpm", ["--filter", "@layoutit/polycss-morph", "build"], repoRoot); + const coreTarball = registryDependencies + ? null + : await packPackage( + "core", + resolve(repoRoot, "packages/core"), + artifactRoot, + true, + ); + const polycssTarball = registryDependencies + ? null + : await packPackage( + "polycss", + resolve(repoRoot, "packages/polycss"), + artifactRoot, + true, + ); + const morphTarball = await packPackage("morph", morphRoot, artifactRoot, false); + const inventory = await tarInventory(morphTarball); + const distFiles = (await readdir(resolve(morphRoot, "dist"))) + .sort() + .map((entry) => `package/dist/${entry}`); + exactInventory(inventory, [ + "package/LICENSE", + "package/README.md", + ...distFiles, + "package/package.json", + ].sort()); + await extractTarball(morphTarball, extractedRoot); + const packedPackage = JSON.parse( + await readFile(resolve(extractedRoot, "package/package.json"), "utf8"), + ); + const polycssPackage = JSON.parse( + await readFile(resolve(repoRoot, "packages/polycss/package.json"), "utf8"), + ); + if ( + packedPackage.name !== "@layoutit/polycss-morph" + || packedPackage.dependencies?.["@layoutit/polycss"] !== `^${polycssPackage.version}` + || Object.hasOwn(packedPackage.dependencies ?? {}, "sharp") + || JSON.stringify(Object.keys(packedPackage.exports).sort()) !== JSON.stringify([".", "./prepare"]) + || packedPackage.typesVersions?.["*"]?.prepare?.[0] !== "dist/prepare.d.ts" + || JSON.stringify(packedPackage.files) !== JSON.stringify(["dist"]) + || JSON.stringify(packedPackage).includes("workspace:") + ) { + throw new Error("packed Morph package metadata is not distributable"); + } + const runtimeOutputNames = (await readdir(resolve(extractedRoot, "package/dist"))) + .filter((entry) => entry.endsWith(".js") || entry.endsWith(".cjs")) + .sort(); + if ( + JSON.stringify(runtimeOutputNames) + !== JSON.stringify(["index.cjs", "index.js", "prepare.cjs", "prepare.js"]) + ) { + throw new Error(`unexpected runtime chunks: ${runtimeOutputNames.join(", ")}`); + } + const browserOutputs = await Promise.all([ + readFile(resolve(extractedRoot, "package/dist/index.js"), "utf8"), + readFile(resolve(extractedRoot, "package/dist/index.cjs"), "utf8"), + ]); + const browserForbidden = browserOutputs.flatMap((source, index) => + ["node:", "sharp", "preparePolyMorphModel"] + .filter((token) => source.includes(token)) + .map((token) => ({ output: index === 0 ? "index.js" : "index.cjs", token }))); + if (browserForbidden.length > 0) { + throw new Error(`browser entry contains Node preparation edges: ${JSON.stringify(browserForbidden)}`); + } + await writeConsumer(consumerRoot, artifactRoot, { + core: coreTarball, + polycss: polycssTarball, + morph: morphTarball, + }, packedPackage.dependencies["@layoutit/polycss"]); + await run( + "pnpm", + [ + "install", + ...(registryDependencies ? [] : ["--offline"]), + "--frozen-lockfile=false", + ], + consumerRoot, + ); + const installedPolycss = JSON.parse( + await readFile( + resolve(consumerRoot, "node_modules/@layoutit/polycss/package.json"), + "utf8", + ), + ); + const declarationResult = await run( + "pnpm", + ["exec", "tsc", "-p", "tsconfig.json"], + consumerRoot, + ); + const prepareResult = parseJsonOutput( + await run("node", ["prepare-smoke.mjs"], consumerRoot), + "prepare smoke", + ); + const browserResult = parseJsonOutput( + await run("node", ["browser-smoke.mjs"], consumerRoot), + "browser smoke", + ); + const morphBytes = await readFile(morphTarball); + const morphStat = await stat(morphTarball); + const report = { + schema: "polycss-morph.package-certification@1", + completedAt: new Date().toISOString(), + nodeVersion: process.version, + pnpmVersion, + package: { + name: packedPackage.name, + version: packedPackage.version, + tarball: morphTarball.split("/").at(-1), + bytes: morphStat.size, + sha256: createHash("sha256").update(morphBytes).digest("hex"), + inventory, + exports: packedPackage.exports, + dependencies: packedPackage.dependencies, + browserForbidden, + }, + consumer: { + installMode: registryDependencies + ? "registry-dependencies" + : "offline-tarballs", + offlineInstall: !registryDependencies, + polycssVersion: installedPolycss.version, + declarations: declarationResult.stderr === "", + prepare: prepareResult, + browser: browserResult, + }, + }; + if (reportPath) { + await mkdir(dirname(reportPath), { recursive: true }); + await writeFile(reportPath, `${JSON.stringify(report, null, 2)}\n`); + } + console.log(JSON.stringify(report, null, 2)); +} finally { + if (process.env.POLYCSS_MORPH_KEEP_CERT_TEMP !== "1") { + await rm(tempRoot, { recursive: true, force: true }); + } else { + console.error(`kept certification temp at ${tempRoot}`); + } +} diff --git a/packages/morph/scripts/generate-certification-fixture.mjs b/packages/morph/scripts/generate-certification-fixture.mjs new file mode 100644 index 00000000..9268d3e4 --- /dev/null +++ b/packages/morph/scripts/generate-certification-fixture.mjs @@ -0,0 +1,230 @@ +import { mkdir, writeFile } from "node:fs/promises"; +import { resolve } from "node:path"; + +const spec = { + columns: 40, + rows: 26, + width: 7.2, + height: 4.4, + controlColumns: [4, 8, 12, 16, 20, 24, 28, 32, 36], + controlRows: [4, 9, 13, 17, 22], + radius: 5.8, + lift: 1.25, + press: -1.05, +}; + +function targetName(column, row, direction) { + return `Pin C${String(column).padStart(2, "0")} R${String(row).padStart(2, "0")} ${direction}`; +} + +function targetId(column, row, direction) { + return `pin-c${String(column).padStart(2, "0")}-r${String(row).padStart(2, "0")}-${direction.toLowerCase()}`; +} + +function align4(value) { + return (value + 3) & ~3; +} + +function extrema(values, components) { + const minimum = Array.from({ length: components }, () => Infinity); + const maximum = Array.from({ length: components }, () => -Infinity); + for (let offset = 0; offset < values.length; offset += components) { + for (let component = 0; component < components; component += 1) { + const value = values[offset + component]; + minimum[component] = Math.min(minimum[component], value); + maximum[component] = Math.max(maximum[component], value); + } + } + return { minimum, maximum }; +} + +function buildPlaneSource(spec) { + const vertexColumns = spec.columns + 1; + const vertexRows = spec.rows + 1; + const positions = []; + for (let row = 0; row < vertexRows; row += 1) { + const y = -spec.height / 2 + (row / spec.rows) * spec.height; + for (let column = 0; column < vertexColumns; column += 1) { + const x = -spec.width / 2 + (column / spec.columns) * spec.width; + positions.push(x, y, 0); + } + } + + const vertexIndex = (column, row) => row * vertexColumns + column; + const indices = []; + for (let row = 0; row < spec.rows; row += 1) { + for (let column = 0; column < spec.columns; column += 1) { + const topLeft = vertexIndex(column, row); + const topRight = vertexIndex(column + 1, row); + const bottomLeft = vertexIndex(column, row + 1); + const bottomRight = vertexIndex(column + 1, row + 1); + if ((column + row) % 2 === 0) { + indices.push(topLeft, topRight, bottomRight, topLeft, bottomRight, bottomLeft); + } else { + indices.push(topLeft, topRight, bottomLeft, topRight, bottomRight, bottomLeft); + } + } + } + + const targetRows = []; + for (const row of spec.controlRows) { + for (const column of spec.controlColumns) { + for (const [direction, height] of [["Lift", spec.lift], ["Press", spec.press]]) { + const deltas = []; + for (let vertexRow = 0; vertexRow < vertexRows; vertexRow += 1) { + for (let vertexColumn = 0; vertexColumn < vertexColumns; vertexColumn += 1) { + const distance = Math.hypot(vertexColumn - column, vertexRow - row); + const falloff = distance >= spec.radius + ? 0 + : (1 - distance / spec.radius) ** 2; + const boundary = Math.min( + 1, + vertexColumn / 1.5, + (spec.columns - vertexColumn) / 1.5, + vertexRow / 1.5, + (spec.rows - vertexRow) / 1.5, + ); + const influence = falloff * Math.max(0, boundary); + deltas.push(0, 0, height * influence); + } + } + targetRows.push({ + id: targetId(column, row, direction), + name: targetName(column, row, direction), + values: deltas, + }); + } + } + } + + const bufferViews = []; + const accessors = []; + const chunks = []; + let byteLength = 0; + + function append(values, componentType, type, target, includeBounds = false) { + const TypedArray = componentType === 5123 ? Uint16Array : Float32Array; + const typed = new TypedArray(values); + const alignedOffset = align4(byteLength); + if (alignedOffset > byteLength) chunks.push(Buffer.alloc(alignedOffset - byteLength)); + const bytes = Buffer.from(typed.buffer, typed.byteOffset, typed.byteLength); + const bufferView = bufferViews.length; + bufferViews.push({ + buffer: 0, + byteOffset: alignedOffset, + byteLength: bytes.byteLength, + target, + }); + chunks.push(bytes); + byteLength = alignedOffset + bytes.byteLength; + const components = type === "SCALAR" ? 1 : 3; + const accessor = accessors.length; + const row = { + bufferView, + componentType, + count: typed.length / components, + type, + }; + if (includeBounds) { + const { minimum, maximum } = extrema(typed, components); + row.min = minimum; + row.max = maximum; + } + accessors.push(row); + return accessor; + } + + const positionAccessor = append(positions, 5126, "VEC3", 34962, true); + const indexAccessor = append(indices, 5123, "SCALAR", 34963, true); + const targetAccessors = targetRows.map((target) => + append(target.values, 5126, "VEC3", 34962)); + const buffer = Buffer.concat(chunks); + + const gltf = { + asset: { + version: "2.0", + generator: "polycss-morph-certification-fixture", + }, + scene: 0, + scenes: [{ nodes: [0] }], + nodes: [{ mesh: 0, name: "Certification Plane" }], + buffers: [{ + byteLength: buffer.byteLength, + uri: `data:application/octet-stream;base64,${buffer.toString("base64")}`, + }], + bufferViews, + accessors, + materials: [{ + name: "Certification surface", + pbrMetallicRoughness: { + baseColorFactor: [0.57, 0.56, 0.51, 1], + metallicFactor: 0, + roughnessFactor: 0.72, + }, + }], + meshes: [{ + name: "Certification Plane", + extras: { + targetNames: targetRows.map((target) => target.name), + }, + primitives: [{ + attributes: { POSITION: positionAccessor }, + indices: indexAccessor, + material: 0, + mode: 4, + targets: targetAccessors.map((accessor) => ({ POSITION: accessor })), + }], + }], + }; + + const prepare = { + schema: "polycss-morph.prepare@1", + identity: { + id: "certification-plane", + name: "Certification Plane", + revision: "1.0.0", + }, + profile: "morph-regions", + source: { + path: "model.gltf", + id: "generated-certification-plane", + kind: "generated", + uri: "urn:polycss:test:certification-plane", + license: "MIT", + }, + transform: { + axes: ["x", "y", "z"], + signs: [1, 1, 1], + scale: 120, + center: true, + }, + morphAliases: Object.fromEntries(targetRows.map((target) => [target.name, target.id])), + controls: [], + springs: [], + animations: [], + budgets: { + maxVertices: vertexColumns * vertexRows, + maxPolygons: spec.columns * spec.rows * 2, + maxLeaves: spec.columns * spec.rows * 2, + maxFrames: 1, + maxJoints: 1, + maxResources: 4, + maxBytes: 8000000, + }, + }; + + return { gltf, prepare }; +} + +export async function generateCertificationFixture(outputRoot) { + const generated = buildPlaneSource(spec); + const generatedRoot = resolve(outputRoot); + await mkdir(generatedRoot, { recursive: true }); + const gltfPath = resolve(generatedRoot, "model.gltf"); + const configPath = resolve(generatedRoot, "prepare.json"); + await Promise.all([ + writeFile(gltfPath, `${JSON.stringify(generated.gltf)}\n`), + writeFile(configPath, `${JSON.stringify(generated.prepare, null, 2)}\n`), + ]); + return { configPath, gltfPath, spec }; +} diff --git a/packages/morph/src/contracts/index.ts b/packages/morph/src/contracts/index.ts new file mode 100644 index 00000000..4c61ae84 --- /dev/null +++ b/packages/morph/src/contracts/index.ts @@ -0,0 +1,52 @@ +export { + isPolyMorphId, + isPolyMorphResourcePath, + PolyMorphContractError, + validatePolyMorphModel, +} from "./validation.js"; +export { + POLY_MORPH_CATALOG_SCHEMA, + POLY_MORPH_MODEL_SCHEMA, + POLY_MORPH_PACKAGE_SCHEMA, +} from "./types.js"; +export type { + PolyMorphAnimationChannel, + PolyMorphAnimationClip, + PolyMorphAnimationTarget, + PolyMorphAtlasSlice, + PolyMorphBudgets, + PolyMorphCapability, + PolyMorphColor, + PolyMorphControl, + PolyMorphControlTarget, + PolyMorphDeformation, + PolyMorphJoint, + PolyMorphJointDeformation, + PolyMorphJointInfluence, + PolyMorphMat4, + PolyMorphMaterial, + PolyMorphModel, + PolyMorphModelIdentity, + PolyMorphNoDeformation, + PolyMorphPlayback, + PolyMorphPlaybackFrame, + PolyMorphPlaybackLeafUpdate, + PolyMorphPlaybackShapeUpdate, + PolyMorphPolygon, + PolyMorphProfile, + PolyMorphProvenance, + PolyMorphProvenanceSource, + PolyMorphQuat, + PolyMorphRegionDeformation, + PolyMorphRenderFallback, + PolyMorphRenderLeaf, + PolyMorphRenderPlan, + PolyMorphRenderShape, + PolyMorphRenderStrategy, + PolyMorphSkinVertex, + PolyMorphSparseDelta, + PolyMorphSpring, + PolyMorphTarget, + PolyMorphTopology, + PolyMorphVec3, +} from "./types.js"; diff --git a/packages/morph/src/contracts/types.ts b/packages/morph/src/contracts/types.ts new file mode 100644 index 00000000..c2566cd8 --- /dev/null +++ b/packages/morph/src/contracts/types.ts @@ -0,0 +1,272 @@ +export const POLY_MORPH_MODEL_SCHEMA = "polycss-morph.model@1" as const; +export const POLY_MORPH_CATALOG_SCHEMA = "polycss-morph.catalog@1" as const; +export const POLY_MORPH_PACKAGE_SCHEMA = "polycss-morph.package@1" as const; + +export type PolyMorphProfile = + | "joint-skin" + | "morph-regions" + | "prepared-playback" + | "static-prepared"; + +export type PolyMorphCapability = + | "animation" + | "joint-skinning" + | "morph-targets" + | "prepared-playback" + | "retained-render" + | "semantic-controls" + | "sparse-updates" + | "springs"; + +export type PolyMorphVec3 = readonly [number, number, number]; +export type PolyMorphQuat = readonly [number, number, number, number]; +export type PolyMorphMat4 = readonly [ + number, + number, + number, + number, + number, + number, + number, + number, + number, + number, + number, + number, + number, + number, + number, + number, +]; +export type PolyMorphColor = readonly [number, number, number, number]; + +export interface PolyMorphModelIdentity { + readonly id: string; + readonly name: string; + readonly revision: string; +} + +export interface PolyMorphPolygon { + readonly id: string; + readonly vertexIndices: readonly number[]; + readonly normalIndices: readonly number[]; +} + +export interface PolyMorphTopology { + readonly vertices: readonly PolyMorphVec3[]; + readonly normals: readonly PolyMorphVec3[]; + readonly polygons: readonly PolyMorphPolygon[]; +} + +export interface PolyMorphMaterial { + readonly id: string; + readonly color: PolyMorphColor; +} + +export type PolyMorphRenderStrategy = + | "atlas-slice" + | "direct-image" + | "solid-quad" + | "solid-triangle"; + +export interface PolyMorphAtlasSlice { + readonly resourcePath: string; + readonly x: number; + readonly y: number; + readonly width: number; + readonly height: number; + readonly pageWidth: number; + readonly pageHeight: number; +} + +export interface PolyMorphRenderShape { + readonly id: string; + readonly matrix: PolyMorphMat4; +} + +export interface PolyMorphRenderFallback { + readonly width: number; + readonly height: number; + readonly matrixFromLeaf: PolyMorphMat4; + readonly atlas: PolyMorphAtlasSlice; +} + +export interface PolyMorphRenderLeaf { + readonly id: string; + readonly polygonId: string; + readonly shapeId: string; + readonly materialId: string; + readonly strategy: PolyMorphRenderStrategy; + readonly width: number; + readonly height: number; + readonly matrix: PolyMorphMat4; + readonly atlas: PolyMorphAtlasSlice | null; + readonly fallback: PolyMorphRenderFallback | null; +} + +export interface PolyMorphRenderPlan { + readonly modelMatrix: PolyMorphMat4; + readonly shapes: readonly PolyMorphRenderShape[]; + readonly leaves: readonly PolyMorphRenderLeaf[]; +} + +export interface PolyMorphSparseDelta { + readonly vertexIndex: number; + readonly position: PolyMorphVec3 | null; + readonly normal: PolyMorphVec3 | null; +} + +export interface PolyMorphTarget { + readonly id: string; + readonly deltas: readonly PolyMorphSparseDelta[]; +} + +export interface PolyMorphNoDeformation { + readonly kind: "none"; +} + +export interface PolyMorphRegionDeformation { + readonly kind: "morph-regions"; + readonly targets: readonly PolyMorphTarget[]; +} + +export interface PolyMorphJoint { + readonly id: string; + readonly parentId: string | null; + readonly restMatrix: PolyMorphMat4; + readonly inverseBindMatrix: PolyMorphMat4; +} + +export interface PolyMorphJointInfluence { + readonly jointId: string; + readonly weight: number; +} + +export interface PolyMorphSkinVertex { + readonly vertexIndex: number; + readonly influences: readonly PolyMorphJointInfluence[]; +} + +export interface PolyMorphJointDeformation { + readonly kind: "joint-skin"; + readonly joints: readonly PolyMorphJoint[]; + readonly vertices: readonly PolyMorphSkinVertex[]; +} + +export type PolyMorphDeformation = + | PolyMorphJointDeformation + | PolyMorphNoDeformation + | PolyMorphRegionDeformation; + +export interface PolyMorphControlTarget { + readonly targetId: string; + readonly scale: number; +} + +export interface PolyMorphControl { + readonly id: string; + readonly anchor: PolyMorphVec3; + readonly axis: PolyMorphVec3; + readonly radius: number; + readonly minimum: number; + readonly maximum: number; + readonly initial: number; + readonly targets: readonly PolyMorphControlTarget[]; +} + +export interface PolyMorphSpring { + readonly id: string; + readonly controlId: string; + readonly stiffness: number; + readonly damping: number; +} + +export type PolyMorphAnimationTarget = + | "control-value" + | "joint-rotation" + | "joint-scale" + | "joint-translation" + | "morph-weight" + | "shape-matrix"; + +export interface PolyMorphAnimationChannel { + readonly target: PolyMorphAnimationTarget; + readonly targetId: string; + readonly interpolation: "linear" | "step"; + readonly timesMs: readonly number[]; + readonly values: readonly (readonly number[])[]; +} + +export interface PolyMorphAnimationClip { + readonly id: string; + readonly durationMs: number; + readonly loop: boolean; + readonly channels: readonly PolyMorphAnimationChannel[]; +} + +export interface PolyMorphPlaybackShapeUpdate { + readonly shapeId: string; + readonly matrix: PolyMorphMat4; +} + +export interface PolyMorphPlaybackLeafUpdate { + readonly leafId: string; + readonly matrix: PolyMorphMat4 | null; + readonly visible: boolean | null; + readonly opacity: number | null; + readonly atlasRow: number | null; +} + +export interface PolyMorphPlaybackFrame { + readonly timeMs: number; + readonly modelMatrix: PolyMorphMat4 | null; + readonly shapes: readonly PolyMorphPlaybackShapeUpdate[]; + readonly leaves: readonly PolyMorphPlaybackLeafUpdate[]; +} + +export interface PolyMorphPlayback { + readonly durationMs: number; + readonly loop: boolean; + readonly frames: readonly PolyMorphPlaybackFrame[]; +} + +export interface PolyMorphBudgets { + readonly maxVertices: number; + readonly maxPolygons: number; + readonly maxLeaves: number; + readonly maxFrames: number; + readonly maxJoints: number; + readonly maxResources: number; + readonly maxBytes: number; +} + +export interface PolyMorphProvenanceSource { + readonly id: string; + readonly kind: "authored" | "generated" | "open-data"; + readonly uri: string; + readonly sha256: string | null; + readonly license: string; +} + +export interface PolyMorphProvenance { + readonly generator: string; + readonly generatorVersion: string; + readonly sources: readonly PolyMorphProvenanceSource[]; +} + +export interface PolyMorphModel { + readonly schema: typeof POLY_MORPH_MODEL_SCHEMA; + readonly identity: PolyMorphModelIdentity; + readonly profile: PolyMorphProfile; + readonly capabilities: readonly PolyMorphCapability[]; + readonly budgets: PolyMorphBudgets; + readonly topology: PolyMorphTopology; + readonly materials: readonly PolyMorphMaterial[]; + readonly render: PolyMorphRenderPlan; + readonly deformation: PolyMorphDeformation; + readonly controls: readonly PolyMorphControl[]; + readonly springs: readonly PolyMorphSpring[]; + readonly animations: readonly PolyMorphAnimationClip[]; + readonly playback: PolyMorphPlayback | null; + readonly provenance: PolyMorphProvenance; +} diff --git a/packages/morph/src/contracts/validation.test.ts b/packages/morph/src/contracts/validation.test.ts new file mode 100644 index 00000000..70b6dde1 --- /dev/null +++ b/packages/morph/src/contracts/validation.test.ts @@ -0,0 +1,314 @@ +import { readFileSync } from "node:fs"; +import { fileURLToPath } from "node:url"; +import { describe, expect, it } from "vitest"; +import { + isPolyMorphId, + isPolyMorphResourcePath, + PolyMorphContractError, + validatePolyMorphModel, +} from "./index.js"; +import { + clonePolyMorphFixture, + createPolyMorphModelFixture, + POLY_MORPH_IDENTITY_MATRIX, +} from "../testing/modelFixture.js"; +import { createPolyMorphSkinningFixture } from "../testing/skinningFixture.js"; + +function expectCode(value: unknown, code: string): void { + try { + validatePolyMorphModel(value); + } catch (error) { + expect(error).toBeInstanceOf(PolyMorphContractError); + expect((error as PolyMorphContractError).code).toBe(code); + return; + } + throw new Error(`Expected ${code}.`); +} + +describe("validatePolyMorphModel", () => { + it.each([ + "static-prepared", + "morph-regions", + "joint-skin", + "prepared-playback", + ] as const)("accepts a complete %s fixture", (profile) => { + const fixture = createPolyMorphModelFixture(profile); + const model = validatePolyMorphModel(fixture); + expect(model.profile).toBe(profile); + expect(model.render.leaves[0]?.id).toBe("gem-panel-leaf"); + }); + + it("rejects undeclared keys at every strict object boundary", () => { + const fixture = createPolyMorphModelFixture() as unknown as Record; + fixture.extra = true; + expectCode(fixture, "invalid-keys"); + + const nested = clonePolyMorphFixture(createPolyMorphModelFixture()) as unknown as { + identity: Record; + }; + nested.identity.extra = true; + expectCode(nested, "invalid-keys"); + }); + + it("accepts only normalized ids and package-relative resource paths", () => { + expect(isPolyMorphId("gem-panel")).toBe(true); + expect(isPolyMorphId("Gem_Panel")).toBe(false); + expect(isPolyMorphResourcePath("assets/gem.webp")).toBe(true); + expect(isPolyMorphResourcePath("../assets/gem.webp")).toBe(false); + expect(isPolyMorphResourcePath("/assets/gem.webp")).toBe(false); + expect(isPolyMorphResourcePath("http:evil.example/manifest.json")).toBe(false); + expect(isPolyMorphResourcePath("javascript:alert.js")).toBe(false); + expect(isPolyMorphResourcePath("%2e%2e/%2e%2e/secret.json")).toBe(false); + expect(isPolyMorphResourcePath("assets/%2e%2e/secret.json")).toBe(false); + + const fixture = clonePolyMorphFixture(createPolyMorphModelFixture()); + fixture.identity.id = "Gem_Panel"; + expectCode(fixture, "invalid-id"); + + const textured = clonePolyMorphFixture(createPolyMorphModelFixture()); + textured.render.leaves[0]!.strategy = "atlas-slice"; + textured.render.leaves[0]!.atlas = { + resourcePath: "../atlas.webp", + x: 0, + y: 0, + width: 4, + height: 4, + pageWidth: 4, + pageHeight: 4, + }; + expectCode(textured, "invalid-path"); + + const fallback = clonePolyMorphFixture(createPolyMorphModelFixture()); + fallback.render.leaves[0]!.fallback = { + width: 7, + height: 5, + matrixFromLeaf: POLY_MORPH_IDENTITY_MATRIX, + atlas: { + resourcePath: "../solid-triangle.png", + x: 0, + y: 0, + width: 7, + height: 5, + pageWidth: 7, + pageHeight: 5, + }, + }; + expectCode(fallback, "invalid-path"); + }); + + it("validates a local-size fallback slice on each solid triangle", () => { + const fixture = clonePolyMorphFixture(createPolyMorphModelFixture()); + fixture.render.leaves[0]!.fallback = { + width: 7, + height: 5, + matrixFromLeaf: POLY_MORPH_IDENTITY_MATRIX, + atlas: { + resourcePath: "assets/solid-triangles-000.png", + x: 1, + y: 2, + width: 7, + height: 5, + pageWidth: 20, + pageHeight: 10, + }, + }; + expect( + validatePolyMorphModel(fixture).render.leaves[0]!.fallback, + ).toEqual(fixture.render.leaves[0]!.fallback); + + fixture.render.leaves[0]!.fallback!.width = 8; + expectCode(fixture, "invalid-size"); + }); + + it("pins one stable retained leaf to every polygon", () => { + const missing = clonePolyMorphFixture(createPolyMorphModelFixture()); + missing.render.leaves = []; + expectCode(missing, "unstable-topology"); + + const duplicate = clonePolyMorphFixture(createPolyMorphModelFixture()); + duplicate.topology.polygons.push({ + id: "second-panel", + vertexIndices: [0, 2, 1], + normalIndices: [0, 2, 1], + }); + duplicate.render.leaves.push({ + ...duplicate.render.leaves[0]!, + id: "second-leaf", + }); + expectCode(duplicate, "duplicate-id"); + }); + + it("bounds sparse deltas and requires actual changes", () => { + const outOfRange = clonePolyMorphFixture(createPolyMorphModelFixture("morph-regions")); + outOfRange.deformation.targets[0]!.deltas[0]!.vertexIndex = 3; + expectCode(outOfRange, "out-of-range"); + + const empty = clonePolyMorphFixture(createPolyMorphModelFixture("morph-regions")); + empty.deformation.targets[0]!.deltas[0]!.position = null; + empty.deformation.targets[0]!.deltas[0]!.normal = null; + expectCode(empty, "empty-delta"); + }); + + it("bounds semantic controls and resolves their morph targets", () => { + const fixture = clonePolyMorphFixture(createPolyMorphModelFixture("morph-regions")); + fixture.capabilities = [ + "morph-targets", + "retained-render", + "semantic-controls", + "sparse-updates", + "springs", + ]; + fixture.controls = [ + { + id: "stretch-control", + anchor: [0, 0, 0], + axis: [0, 1, 0], + radius: 2, + minimum: -1, + maximum: 1, + initial: 0, + targets: [{ targetId: "stretch", scale: 1 }], + }, + ]; + fixture.springs = [ + { + id: "stretch-return", + controlId: "stretch-control", + stiffness: 80, + damping: 12, + }, + ]; + expect(validatePolyMorphModel(fixture).controls).toHaveLength(1); + + fixture.controls[0]!.initial = 2; + expectCode(fixture, "invalid-control-bounds"); + fixture.controls[0]!.initial = 0; + fixture.controls[0]!.targets[0]!.targetId = "unknown-target"; + expectCode(fixture, "unknown-reference"); + }); + + it("requires profile-specific sections and capabilities", () => { + const mismatched = clonePolyMorphFixture(createPolyMorphModelFixture("morph-regions")); + mismatched.deformation = { kind: "none" }; + expectCode(mismatched, "profile-mismatch"); + + const missingPlayback = clonePolyMorphFixture(createPolyMorphModelFixture("prepared-playback")); + missingPlayback.playback = null; + expectCode(missingPlayback, "profile-mismatch"); + + const missingCapability = clonePolyMorphFixture(createPolyMorphModelFixture("joint-skin")); + missingCapability.capabilities = ["retained-render", "sparse-updates"]; + expectCode(missingCapability, "missing-capability"); + }); + + it("validates animation references, sample shapes, and ordered time", () => { + const fixture = clonePolyMorphFixture(createPolyMorphModelFixture("morph-regions")); + fixture.capabilities = ["animation", "morph-targets", "retained-render", "sparse-updates"]; + fixture.animations = [ + { + id: "pulse", + durationMs: 1000, + loop: true, + channels: [ + { + target: "morph-weight", + targetId: "stretch", + interpolation: "linear", + timesMs: [0, 500, 1000], + values: [[0], [1], [0]], + }, + ], + }, + ]; + expect(validatePolyMorphModel(fixture).animations[0]?.id).toBe("pulse"); + fixture.animations[0]!.channels[0]!.timesMs = [0, 500, 500]; + expectCode(fixture, "invalid-animation-time"); + }); + + it("requires step interpolation for matrices and non-zero quaternions", () => { + const matrixFixture = clonePolyMorphFixture( + createPolyMorphModelFixture("morph-regions"), + ); + matrixFixture.capabilities = [ + "animation", + "morph-targets", + "retained-render", + "sparse-updates", + ]; + matrixFixture.animations = [{ + id: "shape-step", + durationMs: 1000, + loop: false, + channels: [{ + target: "shape-matrix", + targetId: "gem", + interpolation: "linear", + timesMs: [0, 1000], + values: [ + [...POLY_MORPH_IDENTITY_MATRIX], + [...POLY_MORPH_IDENTITY_MATRIX], + ], + }], + }]; + expectCode(matrixFixture, "invalid-interpolation"); + + const quaternionFixture = clonePolyMorphFixture( + createPolyMorphSkinningFixture(), + ); + quaternionFixture.animations[0]!.channels[0]!.values[0] = [0, 0, 0, 0]; + expectCode(quaternionFixture, "invalid-animation-value"); + }); + + it("rejects removed render strategies", () => { + const fixture = clonePolyMorphFixture(createPolyMorphModelFixture()); + (fixture.render.leaves[0] as { strategy: string }).strategy = "solid-clipped"; + expectCode(fixture, "invalid-strategy"); + }); + + it("validates ordered prepared playback updates against retained ids", () => { + const fixture = clonePolyMorphFixture(createPolyMorphModelFixture("prepared-playback")); + fixture.playback!.frames.push({ + timeMs: 500, + modelMatrix: POLY_MORPH_IDENTITY_MATRIX, + shapes: [{ shapeId: "gem", matrix: POLY_MORPH_IDENTITY_MATRIX }], + leaves: [ + { + leafId: "gem-panel-leaf", + matrix: POLY_MORPH_IDENTITY_MATRIX, + visible: null, + opacity: 0.5, + atlasRow: 1, + }, + ], + }); + expect(validatePolyMorphModel(fixture).playback?.frames).toHaveLength(2); + fixture.playback!.frames[1]!.leaves[0]!.leafId = "unknown-leaf"; + expectCode(fixture, "unknown-reference"); + }); + + it("keeps internal leaf tags out of the public contracts", () => { + const publicFiles = ["types.ts", "validation.ts", "index.ts"].map((name) => + readFileSync(fileURLToPath(new URL(name, import.meta.url)), "utf8").toLowerCase()); + for (const source of publicFiles) { + expect(source).not.toMatch(/<(?:b|i|s|u)>/); + } + }); + + it("validates joint hierarchy, references, coverage, and normalized weights", () => { + const unknownJoint = clonePolyMorphFixture(createPolyMorphModelFixture("joint-skin")); + unknownJoint.deformation.vertices[0]!.influences[0]!.jointId = "missing"; + expectCode(unknownJoint, "unknown-reference"); + + const badWeight = clonePolyMorphFixture(createPolyMorphModelFixture("joint-skin")); + badWeight.deformation.vertices[1]!.influences[0]!.weight = 0.6; + expectCode(badWeight, "invalid-weight"); + + const cycle = clonePolyMorphFixture(createPolyMorphModelFixture("joint-skin")); + cycle.deformation.joints[0]!.parentId = "tip"; + expectCode(cycle, "invalid-hierarchy"); + + const missingVertex = clonePolyMorphFixture(createPolyMorphModelFixture("joint-skin")); + missingVertex.deformation.vertices.pop(); + expectCode(missingVertex, "missing-skin-vertex"); + }); +}); diff --git a/packages/morph/src/contracts/validation.ts b/packages/morph/src/contracts/validation.ts new file mode 100644 index 00000000..25bec981 --- /dev/null +++ b/packages/morph/src/contracts/validation.ts @@ -0,0 +1,876 @@ +import { + POLY_MORPH_MODEL_SCHEMA, + type PolyMorphAnimationChannel, + type PolyMorphAnimationTarget, + type PolyMorphAtlasSlice, + type PolyMorphCapability, + type PolyMorphControl, + type PolyMorphDeformation, + type PolyMorphJoint, + type PolyMorphMat4, + type PolyMorphModel, + type PolyMorphPlayback, + type PolyMorphProfile, + type PolyMorphProvenanceSource, + type PolyMorphRenderFallback, + type PolyMorphRenderLeaf, + type PolyMorphRenderStrategy, + type PolyMorphTopology, + type PolyMorphVec3, +} from "./types.js"; + +const ID = /^[a-z][a-z0-9]*(?:-[a-z0-9]+)*$/; +const PATH_SEGMENT = /^[a-z0-9][a-z0-9._-]*$/; +const REVISION = /^\d+\.\d+\.\d+$/; +const SHA256 = /^[a-f0-9]{64}$/; +const PROFILES = new Set([ + "joint-skin", + "morph-regions", + "prepared-playback", + "static-prepared", +]); +const CAPABILITIES = new Set([ + "animation", + "joint-skinning", + "morph-targets", + "prepared-playback", + "retained-render", + "semantic-controls", + "sparse-updates", + "springs", +]); +const STRATEGIES = new Set([ + "atlas-slice", + "direct-image", + "solid-quad", + "solid-triangle", +]); +const ANIMATION_TARGETS = new Set([ + "control-value", + "joint-rotation", + "joint-scale", + "joint-translation", + "morph-weight", + "shape-matrix", +]); + +export class PolyMorphContractError extends TypeError { + readonly code: string; + readonly path: string; + + constructor(code: string, path: string, message: string) { + super(`${path}: ${message}`); + this.name = "PolyMorphContractError"; + this.code = code; + this.path = path; + } +} + +function fail(code: string, path: string, message: string): never { + throw new PolyMorphContractError(code, path, message); +} + +function record( + value: unknown, + path: string, + keys: readonly string[], +): Record { + if (!value || typeof value !== "object" || Array.isArray(value)) { + fail("invalid-type", path, "expected an object"); + } + const actual = Object.keys(value).sort(); + const expected = [...keys].sort(); + if (actual.length !== expected.length || actual.some((key, index) => key !== expected[index])) { + fail("invalid-keys", path, `expected exactly ${expected.join(", ")}`); + } + return value as Record; +} + +function array(value: unknown, path: string): readonly unknown[] { + if (!Array.isArray(value)) fail("invalid-type", path, "expected an array"); + return value; +} + +function string(value: unknown, path: string): string { + if (typeof value !== "string" || value.length === 0) { + fail("invalid-string", path, "expected a non-empty string"); + } + return value; +} + +function number(value: unknown, path: string): number { + if (typeof value !== "number" || !Number.isFinite(value)) { + fail("invalid-number", path, "expected a finite number"); + } + return value; +} + +function integer(value: unknown, path: string, minimum = 0): number { + const result = number(value, path); + if (!Number.isSafeInteger(result) || result < minimum) { + fail("invalid-integer", path, `expected an integer >= ${minimum}`); + } + return result; +} + +function boolean(value: unknown, path: string): boolean { + if (typeof value !== "boolean") fail("invalid-boolean", path, "expected a boolean"); + return value; +} + +function id(value: unknown, path: string): string { + const result = string(value, path); + if (!ID.test(result)) fail("invalid-id", path, "expected a normalized kebab-case id"); + return result; +} + +function uniqueIds(values: readonly string[], path: string): void { + const seen = new Set(); + for (const value of values) { + if (seen.has(value)) fail("duplicate-id", path, `duplicate id ${value}`); + seen.add(value); + } +} + +function vec3(value: unknown, path: string): PolyMorphVec3 { + const values = array(value, path); + if (values.length !== 3) fail("invalid-vector", path, "expected three components"); + return values.map((part, index) => number(part, `${path}[${index}]`)) as unknown as PolyMorphVec3; +} + +function mat4(value: unknown, path: string): PolyMorphMat4 { + const values = array(value, path); + if (values.length !== 16) fail("invalid-matrix", path, "expected sixteen components"); + return values.map((part, index) => number(part, `${path}[${index}]`)) as unknown as PolyMorphMat4; +} + +function normalizedPath(value: unknown, path: string): string { + const result = string(value, path); + if ( + result.split("/").some((part) => !PATH_SEGMENT.test(part)) + ) { + fail( + "invalid-path", + path, + "expected lowercase URL-safe package path segments", + ); + } + return result; +} + +function indexInRange(value: unknown, path: string, length: number): number { + const result = integer(value, path); + if (result >= length) fail("out-of-range", path, `expected an index below ${length}`); + return result; +} + +function validateTopology(value: unknown, path: string): PolyMorphTopology { + const input = record(value, path, ["normals", "polygons", "vertices"]); + const vertices = array(input.vertices, `${path}.vertices`).map((entry, index) => + vec3(entry, `${path}.vertices[${index}]`)); + const normals = array(input.normals, `${path}.normals`).map((entry, index) => + vec3(entry, `${path}.normals[${index}]`)); + if (vertices.length === 0) fail("missing-topology", `${path}.vertices`, "expected at least one vertex"); + if (normals.length === 0) fail("missing-topology", `${path}.normals`, "expected at least one normal"); + const polygons = array(input.polygons, `${path}.polygons`).map((entry, index) => { + const itemPath = `${path}.polygons[${index}]`; + const polygon = record(entry, itemPath, ["id", "normalIndices", "vertexIndices"]); + const vertexIndices = array(polygon.vertexIndices, `${itemPath}.vertexIndices`).map((part, partIndex) => + indexInRange(part, `${itemPath}.vertexIndices[${partIndex}]`, vertices.length)); + const normalIndices = array(polygon.normalIndices, `${itemPath}.normalIndices`).map((part, partIndex) => + indexInRange(part, `${itemPath}.normalIndices[${partIndex}]`, normals.length)); + if (vertexIndices.length < 3) fail("invalid-polygon", itemPath, "expected at least three vertices"); + if (normalIndices.length !== vertexIndices.length) { + fail("invalid-polygon", itemPath, "normal and vertex index counts must match"); + } + if (new Set(vertexIndices).size !== vertexIndices.length) { + fail("invalid-polygon", itemPath, "vertex indices must be unique"); + } + return { + id: id(polygon.id, `${itemPath}.id`), + vertexIndices, + normalIndices, + }; + }); + if (polygons.length === 0) fail("missing-topology", `${path}.polygons`, "expected at least one polygon"); + uniqueIds(polygons.map((polygon) => polygon.id), `${path}.polygons`); + return { vertices, normals, polygons }; +} + +function validateRenderLeaf( + value: unknown, + path: string, + references: { + readonly polygonIds: ReadonlySet; + readonly shapeIds: ReadonlySet; + readonly materialIds: ReadonlySet; + }, +): PolyMorphRenderLeaf { + const input = record(value, path, [ + "atlas", + "fallback", + "height", + "id", + "materialId", + "matrix", + "polygonId", + "shapeId", + "strategy", + "width", + ]); + const strategy = string(input.strategy, `${path}.strategy`) as PolyMorphRenderStrategy; + if (!STRATEGIES.has(strategy)) fail("invalid-strategy", `${path}.strategy`, "unknown render strategy"); + const polygonId = id(input.polygonId, `${path}.polygonId`); + const shapeId = id(input.shapeId, `${path}.shapeId`); + const materialId = id(input.materialId, `${path}.materialId`); + if (!references.polygonIds.has(polygonId)) fail("unknown-reference", `${path}.polygonId`, polygonId); + if (!references.shapeIds.has(shapeId)) fail("unknown-reference", `${path}.shapeId`, shapeId); + if (!references.materialIds.has(materialId)) fail("unknown-reference", `${path}.materialId`, materialId); + const width = number(input.width, `${path}.width`); + const height = number(input.height, `${path}.height`); + if (width <= 0 || height <= 0) fail("invalid-size", path, "leaf dimensions must be positive"); + const needsAtlas = strategy === "atlas-slice" || strategy === "direct-image"; + const atlas = input.atlas === null + ? null + : validateAtlasSlice(input.atlas, `${path}.atlas`); + if (needsAtlas !== (atlas !== null)) { + fail("invalid-strategy", `${path}.atlas`, needsAtlas + ? "image strategies require an atlas/source slice" + : "solid strategies cannot declare an atlas/source slice"); + } + const fallback = input.fallback === null + ? null + : validateRenderFallback(input.fallback, `${path}.fallback`); + if (fallback !== null && strategy !== "solid-triangle") { + fail( + "invalid-strategy", + `${path}.fallback`, + "only solid-triangle leaves may declare a prepared fallback", + ); + } + return { + id: id(input.id, `${path}.id`), + polygonId, + shapeId, + materialId, + strategy, + width, + height, + matrix: mat4(input.matrix, `${path}.matrix`), + atlas, + fallback, + }; +} + +function validateRenderFallback( + value: unknown, + path: string, +): PolyMorphRenderFallback { + const input = record(value, path, [ + "atlas", + "height", + "matrixFromLeaf", + "width", + ]); + const width = integer(input.width, `${path}.width`, 1); + const height = integer(input.height, `${path}.height`, 1); + const atlas = validateAtlasSlice(input.atlas, `${path}.atlas`); + if (atlas.width !== width || atlas.height !== height) { + fail( + "invalid-size", + path, + "fallback dimensions must match its per-polygon atlas slice", + ); + } + return { + width, + height, + matrixFromLeaf: mat4(input.matrixFromLeaf, `${path}.matrixFromLeaf`), + atlas, + }; +} + +function validateAtlasSlice( + value: unknown, + path: string, +): PolyMorphAtlasSlice { + const input = record(value, path, [ + "height", + "pageHeight", + "pageWidth", + "resourcePath", + "width", + "x", + "y", + ]); + const atlas = { + resourcePath: normalizedPath(input.resourcePath, `${path}.resourcePath`), + x: integer(input.x, `${path}.x`), + y: integer(input.y, `${path}.y`), + width: integer(input.width, `${path}.width`, 1), + height: integer(input.height, `${path}.height`, 1), + pageWidth: integer(input.pageWidth, `${path}.pageWidth`, 1), + pageHeight: integer(input.pageHeight, `${path}.pageHeight`, 1), + }; + if (atlas.x + atlas.width > atlas.pageWidth || atlas.y + atlas.height > atlas.pageHeight) { + fail("out-of-range", path, "slice must fit inside its page"); + } + return atlas; +} + +function validateDeformation( + value: unknown, + path: string, + topology: PolyMorphTopology, +): PolyMorphDeformation { + if (!value || typeof value !== "object" || Array.isArray(value)) { + fail("invalid-type", path, "expected a deformation object"); + } + const kind = string((value as Record).kind, `${path}.kind`); + if (kind === "none") { + record(value, path, ["kind"]); + return { kind }; + } + if (kind === "morph-regions") { + const input = record(value, path, ["kind", "targets"]); + const targets = array(input.targets, `${path}.targets`).map((entry, targetIndex) => { + const targetPath = `${path}.targets[${targetIndex}]`; + const target = record(entry, targetPath, ["deltas", "id"]); + const deltas = array(target.deltas, `${targetPath}.deltas`).map((deltaEntry, deltaIndex) => { + const deltaPath = `${targetPath}.deltas[${deltaIndex}]`; + const delta = record(deltaEntry, deltaPath, ["normal", "position", "vertexIndex"]); + const position = delta.position === null ? null : vec3(delta.position, `${deltaPath}.position`); + const normal = delta.normal === null ? null : vec3(delta.normal, `${deltaPath}.normal`); + if (position === null && normal === null) { + fail("empty-delta", deltaPath, "expected a position or normal delta"); + } + return { + vertexIndex: indexInRange(delta.vertexIndex, `${deltaPath}.vertexIndex`, topology.vertices.length), + position, + normal, + }; + }); + if (deltas.length === 0) fail("missing-deltas", `${targetPath}.deltas`, "expected at least one delta"); + const indices = deltas.map((delta) => String(delta.vertexIndex)); + uniqueIds(indices, `${targetPath}.deltas`); + return { id: id(target.id, `${targetPath}.id`), deltas }; + }); + if (targets.length === 0) fail("missing-targets", `${path}.targets`, "expected at least one target"); + uniqueIds(targets.map((target) => target.id), `${path}.targets`); + return { kind, targets }; + } + if (kind === "joint-skin") { + const input = record(value, path, ["joints", "kind", "vertices"]); + const joints = array(input.joints, `${path}.joints`).map((entry, jointIndex) => { + const jointPath = `${path}.joints[${jointIndex}]`; + const joint = record(entry, jointPath, ["id", "inverseBindMatrix", "parentId", "restMatrix"]); + return { + id: id(joint.id, `${jointPath}.id`), + parentId: joint.parentId === null ? null : id(joint.parentId, `${jointPath}.parentId`), + restMatrix: mat4(joint.restMatrix, `${jointPath}.restMatrix`), + inverseBindMatrix: mat4(joint.inverseBindMatrix, `${jointPath}.inverseBindMatrix`), + }; + }); + if (joints.length === 0) fail("missing-joints", `${path}.joints`, "expected at least one joint"); + uniqueIds(joints.map((joint) => joint.id), `${path}.joints`); + validateJointHierarchy(joints, `${path}.joints`); + const jointIds = new Set(joints.map((joint) => joint.id)); + const vertices = array(input.vertices, `${path}.vertices`).map((entry, vertexIndex) => { + const vertexPath = `${path}.vertices[${vertexIndex}]`; + const vertex = record(entry, vertexPath, ["influences", "vertexIndex"]); + const resolvedIndex = indexInRange(vertex.vertexIndex, `${vertexPath}.vertexIndex`, topology.vertices.length); + const influences = array(vertex.influences, `${vertexPath}.influences`).map((entryInfluence, influenceIndex) => { + const influencePath = `${vertexPath}.influences[${influenceIndex}]`; + const influence = record(entryInfluence, influencePath, ["jointId", "weight"]); + const jointId = id(influence.jointId, `${influencePath}.jointId`); + if (!jointIds.has(jointId)) fail("unknown-reference", `${influencePath}.jointId`, jointId); + const weight = number(influence.weight, `${influencePath}.weight`); + if (weight <= 0 || weight > 1) fail("invalid-weight", `${influencePath}.weight`, "expected 0 < weight <= 1"); + return { jointId, weight }; + }); + if (influences.length === 0) fail("missing-influences", `${vertexPath}.influences`, "expected at least one influence"); + uniqueIds(influences.map((influence) => influence.jointId), `${vertexPath}.influences`); + const total = influences.reduce((sum, influence) => sum + influence.weight, 0); + if (Math.abs(total - 1) > 1e-6) fail("invalid-weight", `${vertexPath}.influences`, "weights must sum to 1"); + return { vertexIndex: resolvedIndex, influences }; + }); + if (vertices.length !== topology.vertices.length) { + fail("missing-skin-vertex", `${path}.vertices`, "every topology vertex must have one skin record"); + } + const skinIndices = vertices.map((vertex) => String(vertex.vertexIndex)); + uniqueIds(skinIndices, `${path}.vertices`); + return { kind, joints, vertices }; + } + fail("invalid-deformation", `${path}.kind`, `unknown deformation kind ${kind}`); +} + +function validateJointHierarchy(joints: readonly PolyMorphJoint[], path: string): void { + const byId = new Map(joints.map((joint) => [joint.id, joint])); + const roots = joints.filter((joint) => joint.parentId === null); + if (roots.length !== 1) fail("invalid-hierarchy", path, "expected exactly one root joint"); + for (const joint of joints) { + if (joint.parentId !== null && !byId.has(joint.parentId)) { + fail("unknown-reference", path, `joint ${joint.id} references ${joint.parentId}`); + } + const seen = new Set(); + let ancestor: PolyMorphJoint | undefined = joint; + while (ancestor) { + if (seen.has(ancestor.id)) fail("invalid-hierarchy", path, `cycle at joint ${ancestor.id}`); + seen.add(ancestor.id); + ancestor = ancestor.parentId === null ? undefined : byId.get(ancestor.parentId); + } + } +} + +function channelArity(target: PolyMorphAnimationTarget): number { + if (target === "control-value" || target === "morph-weight") return 1; + if (target === "joint-rotation") return 4; + if (target === "shape-matrix") return 16; + return 3; +} + +function validateAnimationChannel( + value: unknown, + path: string, + references: { + readonly controlIds: ReadonlySet; + readonly jointIds: ReadonlySet; + readonly shapeIds: ReadonlySet; + readonly targetIds: ReadonlySet; + }, + durationMs: number, +): PolyMorphAnimationChannel { + const input = record(value, path, ["interpolation", "target", "targetId", "timesMs", "values"]); + const target = string(input.target, `${path}.target`) as PolyMorphAnimationTarget; + if (!ANIMATION_TARGETS.has(target)) fail("invalid-animation-target", `${path}.target`, target); + const targetId = id(input.targetId, `${path}.targetId`); + const referenceSet = target === "control-value" + ? references.controlIds + : target === "morph-weight" + ? references.targetIds + : target === "shape-matrix" + ? references.shapeIds + : references.jointIds; + if (!referenceSet.has(targetId)) fail("unknown-reference", `${path}.targetId`, targetId); + const interpolation = string(input.interpolation, `${path}.interpolation`); + if (interpolation !== "linear" && interpolation !== "step") { + fail("invalid-interpolation", `${path}.interpolation`, interpolation); + } + if (target === "shape-matrix" && interpolation !== "step") { + fail( + "invalid-interpolation", + `${path}.interpolation`, + "shape matrices require step interpolation", + ); + } + const timesMs = array(input.timesMs, `${path}.timesMs`).map((entry, index) => + number(entry, `${path}.timesMs[${index}]`)); + const values = array(input.values, `${path}.values`).map((entry, valueIndex) => { + const parts = array(entry, `${path}.values[${valueIndex}]`); + const arity = channelArity(target); + if (parts.length !== arity) { + fail("invalid-animation-value", `${path}.values[${valueIndex}]`, `expected ${arity} components`); + } + const result = parts.map((part, partIndex) => + number(part, `${path}.values[${valueIndex}][${partIndex}]`)); + if (target === "joint-rotation" && Math.hypot(...result) <= 1e-12) { + fail( + "invalid-animation-value", + `${path}.values[${valueIndex}]`, + "quaternion must be non-zero", + ); + } + return result; + }); + if (timesMs.length === 0 || timesMs.length !== values.length) { + fail("invalid-animation-samples", path, "times and values must have the same non-zero length"); + } + if (timesMs[0] !== 0 || timesMs.some((time, index) => + time < 0 || time > durationMs || (index > 0 && time <= timesMs[index - 1]!))) { + fail("invalid-animation-time", `${path}.timesMs`, "times must start at zero and increase within the clip"); + } + return { + target, + targetId, + interpolation, + timesMs, + values, + }; +} + +function validatePlayback( + value: unknown, + path: string, + leafIds: ReadonlySet, + shapeIds: ReadonlySet, +): PolyMorphPlayback { + const input = record(value, path, ["durationMs", "frames", "loop"]); + const durationMs = number(input.durationMs, `${path}.durationMs`); + if (durationMs <= 0) fail("invalid-duration", `${path}.durationMs`, "expected a positive duration"); + const frames = array(input.frames, `${path}.frames`).map((entry, frameIndex) => { + const framePath = `${path}.frames[${frameIndex}]`; + const frame = record(entry, framePath, ["leaves", "modelMatrix", "shapes", "timeMs"]); + const timeMs = number(frame.timeMs, `${framePath}.timeMs`); + const shapes = array(frame.shapes, `${framePath}.shapes`).map((shapeEntry, shapeIndex) => { + const shapePath = `${framePath}.shapes[${shapeIndex}]`; + const shape = record(shapeEntry, shapePath, ["matrix", "shapeId"]); + const shapeId = id(shape.shapeId, `${shapePath}.shapeId`); + if (!shapeIds.has(shapeId)) fail("unknown-reference", `${shapePath}.shapeId`, shapeId); + return { shapeId, matrix: mat4(shape.matrix, `${shapePath}.matrix`) }; + }); + uniqueIds(shapes.map((shape) => shape.shapeId), `${framePath}.shapes`); + const leaves = array(frame.leaves, `${framePath}.leaves`).map((leafEntry, leafIndex) => { + const leafPath = `${framePath}.leaves[${leafIndex}]`; + const leaf = record(leafEntry, leafPath, ["atlasRow", "leafId", "matrix", "opacity", "visible"]); + const leafId = id(leaf.leafId, `${leafPath}.leafId`); + if (!leafIds.has(leafId)) fail("unknown-reference", `${leafPath}.leafId`, leafId); + const opacity = leaf.opacity === null ? null : number(leaf.opacity, `${leafPath}.opacity`); + if (opacity !== null && (opacity < 0 || opacity > 1)) { + fail("out-of-range", `${leafPath}.opacity`, "expected 0 <= opacity <= 1"); + } + return { + leafId, + matrix: leaf.matrix === null ? null : mat4(leaf.matrix, `${leafPath}.matrix`), + visible: leaf.visible === null ? null : boolean(leaf.visible, `${leafPath}.visible`), + opacity, + atlasRow: leaf.atlasRow === null ? null : integer(leaf.atlasRow, `${leafPath}.atlasRow`), + }; + }); + uniqueIds(leaves.map((leaf) => leaf.leafId), `${framePath}.leaves`); + return { + timeMs, + modelMatrix: frame.modelMatrix === null ? null : mat4(frame.modelMatrix, `${framePath}.modelMatrix`), + shapes, + leaves, + }; + }); + if (frames.length === 0) fail("missing-frames", `${path}.frames`, "expected at least one frame"); + if (frames[0]!.timeMs !== 0 || frames.some((frame, index) => + frame.timeMs < 0 + || frame.timeMs > durationMs + || (index > 0 && frame.timeMs <= frames[index - 1]!.timeMs))) { + fail("invalid-frame-time", `${path}.frames`, "frames must start at zero and increase within the timeline"); + } + return { durationMs, loop: boolean(input.loop, `${path}.loop`), frames }; +} + +function requiredCapabilities( + profile: PolyMorphProfile, + model: { + readonly controls: readonly unknown[]; + readonly springs: readonly unknown[]; + readonly animations: readonly unknown[]; + }, +): readonly PolyMorphCapability[] { + const required: PolyMorphCapability[] = ["retained-render"]; + if (profile === "morph-regions") required.push("morph-targets", "sparse-updates"); + if (profile === "joint-skin") required.push("joint-skinning", "sparse-updates"); + if (profile === "prepared-playback") required.push("prepared-playback", "sparse-updates"); + if (model.controls.length > 0) required.push("semantic-controls"); + if (model.springs.length > 0) required.push("springs"); + if (model.animations.length > 0) required.push("animation"); + return required; +} + +export function validatePolyMorphModel(value: unknown): PolyMorphModel { + const path = "$"; + const input = record(value, path, [ + "animations", + "budgets", + "capabilities", + "controls", + "deformation", + "identity", + "materials", + "playback", + "profile", + "provenance", + "render", + "schema", + "springs", + "topology", + ]); + if (input.schema !== POLY_MORPH_MODEL_SCHEMA) { + fail("invalid-schema", "$.schema", `expected ${POLY_MORPH_MODEL_SCHEMA}`); + } + const identityInput = record(input.identity, "$.identity", ["id", "name", "revision"]); + const identity = { + id: id(identityInput.id, "$.identity.id"), + name: string(identityInput.name, "$.identity.name"), + revision: string(identityInput.revision, "$.identity.revision"), + }; + if (!REVISION.test(identity.revision)) { + fail("invalid-revision", "$.identity.revision", "expected x.y.z"); + } + const profile = string(input.profile, "$.profile") as PolyMorphProfile; + if (!PROFILES.has(profile)) fail("invalid-profile", "$.profile", profile); + const capabilities = array(input.capabilities, "$.capabilities").map((entry, index) => { + const capability = string(entry, `$.capabilities[${index}]`) as PolyMorphCapability; + if (!CAPABILITIES.has(capability)) { + fail("invalid-capability", `$.capabilities[${index}]`, capability); + } + return capability; + }); + uniqueIds(capabilities, "$.capabilities"); + if (capabilities.some((capability, index) => index > 0 && capability <= capabilities[index - 1]!)) { + fail("non-canonical-order", "$.capabilities", "capabilities must be sorted"); + } + const budgetInput = record(input.budgets, "$.budgets", [ + "maxBytes", + "maxFrames", + "maxJoints", + "maxLeaves", + "maxPolygons", + "maxResources", + "maxVertices", + ]); + const budgets = { + maxVertices: integer(budgetInput.maxVertices, "$.budgets.maxVertices"), + maxPolygons: integer(budgetInput.maxPolygons, "$.budgets.maxPolygons"), + maxLeaves: integer(budgetInput.maxLeaves, "$.budgets.maxLeaves"), + maxFrames: integer(budgetInput.maxFrames, "$.budgets.maxFrames"), + maxJoints: integer(budgetInput.maxJoints, "$.budgets.maxJoints"), + maxResources: integer(budgetInput.maxResources, "$.budgets.maxResources"), + maxBytes: integer(budgetInput.maxBytes, "$.budgets.maxBytes"), + }; + const topology = validateTopology(input.topology, "$.topology"); + const materials = array(input.materials, "$.materials").map((entry, materialIndex) => { + const materialPath = `$.materials[${materialIndex}]`; + const material = record(entry, materialPath, ["color", "id"]); + const colorParts = array(material.color, `${materialPath}.color`); + if (colorParts.length !== 4) fail("invalid-color", `${materialPath}.color`, "expected four components"); + const color = colorParts.map((part, partIndex) => { + const component = number(part, `${materialPath}.color[${partIndex}]`); + if (component < 0 || component > 1) { + fail("out-of-range", `${materialPath}.color[${partIndex}]`, "expected 0 <= component <= 1"); + } + return component; + }) as [number, number, number, number]; + return { id: id(material.id, `${materialPath}.id`), color }; + }); + if (materials.length === 0) fail("missing-materials", "$.materials", "expected at least one material"); + uniqueIds(materials.map((material) => material.id), "$.materials"); + const renderInput = record(input.render, "$.render", [ + "leaves", + "modelMatrix", + "shapes", + ]); + const shapes = array(renderInput.shapes, "$.render.shapes").map((entry, shapeIndex) => { + const shapePath = `$.render.shapes[${shapeIndex}]`; + const shape = record(entry, shapePath, ["id", "matrix"]); + return { + id: id(shape.id, `${shapePath}.id`), + matrix: mat4(shape.matrix, `${shapePath}.matrix`), + }; + }); + if (shapes.length === 0) fail("missing-shapes", "$.render.shapes", "expected at least one shape"); + uniqueIds(shapes.map((shape) => shape.id), "$.render.shapes"); + const leaves = array(renderInput.leaves, "$.render.leaves").map((entry, leafIndex) => + validateRenderLeaf(entry, `$.render.leaves[${leafIndex}]`, { + polygonIds: new Set(topology.polygons.map((polygon) => polygon.id)), + shapeIds: new Set(shapes.map((shape) => shape.id)), + materialIds: new Set(materials.map((material) => material.id)), + })); + uniqueIds(leaves.map((leaf) => leaf.id), "$.render.leaves"); + uniqueIds(leaves.map((leaf) => leaf.polygonId), "$.render.leaves[*].polygonId"); + if (leaves.length !== topology.polygons.length) { + fail("unstable-topology", "$.render.leaves", "every polygon must bind exactly one retained leaf"); + } + const render = { + modelMatrix: mat4(renderInput.modelMatrix, "$.render.modelMatrix"), + shapes, + leaves, + }; + const deformation = validateDeformation(input.deformation, "$.deformation", topology); + if ( + (profile === "static-prepared" || profile === "prepared-playback") + ? deformation.kind !== "none" + : deformation.kind !== profile + ) { + fail("profile-mismatch", "$.deformation.kind", `does not match profile ${profile}`); + } + const targetIds = new Set(deformation.kind === "morph-regions" + ? deformation.targets.map((target) => target.id) + : []); + const jointIds = new Set(deformation.kind === "joint-skin" + ? deformation.joints.map((joint) => joint.id) + : []); + const controls = array(input.controls, "$.controls").map((entry, controlIndex): PolyMorphControl => { + const controlPath = `$.controls[${controlIndex}]`; + const control = record(entry, controlPath, [ + "anchor", + "axis", + "id", + "initial", + "maximum", + "minimum", + "radius", + "targets", + ]); + const minimum = number(control.minimum, `${controlPath}.minimum`); + const maximum = number(control.maximum, `${controlPath}.maximum`); + const initial = number(control.initial, `${controlPath}.initial`); + if (minimum > maximum || initial < minimum || initial > maximum) { + fail("invalid-control-bounds", controlPath, "expected minimum <= initial <= maximum"); + } + const anchor = vec3(control.anchor, `${controlPath}.anchor`); + const axis = vec3(control.axis, `${controlPath}.axis`); + const axisLength = Math.hypot(...axis); + if (axisLength < 1e-9) fail("invalid-control-axis", `${controlPath}.axis`, "axis must be non-zero"); + const radius = number(control.radius, `${controlPath}.radius`); + if (radius <= 0) fail("invalid-control-radius", `${controlPath}.radius`, "radius must be positive"); + const targets = array(control.targets, `${controlPath}.targets`).map((entryTarget, targetIndex) => { + const targetPath = `${controlPath}.targets[${targetIndex}]`; + const target = record(entryTarget, targetPath, ["scale", "targetId"]); + const targetId = id(target.targetId, `${targetPath}.targetId`); + if (!targetIds.has(targetId)) fail("unknown-reference", `${targetPath}.targetId`, targetId); + return { targetId, scale: number(target.scale, `${targetPath}.scale`) }; + }); + uniqueIds(targets.map((target) => target.targetId), `${controlPath}.targets`); + return { + id: id(control.id, `${controlPath}.id`), + anchor, + axis, + radius, + minimum, + maximum, + initial, + targets, + }; + }); + uniqueIds(controls.map((control) => control.id), "$.controls"); + const controlIds = new Set(controls.map((control) => control.id)); + const springs = array(input.springs, "$.springs").map((entry, springIndex) => { + const springPath = `$.springs[${springIndex}]`; + const spring = record(entry, springPath, ["controlId", "damping", "id", "stiffness"]); + const controlId = id(spring.controlId, `${springPath}.controlId`); + if (!controlIds.has(controlId)) fail("unknown-reference", `${springPath}.controlId`, controlId); + const stiffness = number(spring.stiffness, `${springPath}.stiffness`); + const damping = number(spring.damping, `${springPath}.damping`); + if (stiffness <= 0 || damping < 0) { + fail("invalid-spring", springPath, "expected stiffness > 0 and damping >= 0"); + } + return { id: id(spring.id, `${springPath}.id`), controlId, stiffness, damping }; + }); + uniqueIds(springs.map((spring) => spring.id), "$.springs"); + const shapeIds = new Set(shapes.map((shape) => shape.id)); + const animations = array(input.animations, "$.animations").map((entry, clipIndex) => { + const clipPath = `$.animations[${clipIndex}]`; + const clip = record(entry, clipPath, ["channels", "durationMs", "id", "loop"]); + const durationMs = number(clip.durationMs, `${clipPath}.durationMs`); + if (durationMs <= 0) fail("invalid-duration", `${clipPath}.durationMs`, "expected a positive duration"); + const channels = array(clip.channels, `${clipPath}.channels`).map((channel, channelIndex) => + validateAnimationChannel(channel, `${clipPath}.channels[${channelIndex}]`, { + controlIds, + jointIds, + shapeIds, + targetIds, + }, durationMs)); + if (channels.length === 0) fail("missing-channels", `${clipPath}.channels`, "expected at least one channel"); + const channelKeys = channels.map((channel) => `${channel.target}:${channel.targetId}`); + uniqueIds(channelKeys, `${clipPath}.channels`); + return { + id: id(clip.id, `${clipPath}.id`), + durationMs, + loop: boolean(clip.loop, `${clipPath}.loop`), + channels, + }; + }); + uniqueIds(animations.map((clip) => clip.id), "$.animations"); + const playback = input.playback === null + ? null + : validatePlayback( + input.playback, + "$.playback", + new Set(leaves.map((leaf) => leaf.id)), + shapeIds, + ); + if ((profile === "prepared-playback") !== (playback !== null)) { + fail("profile-mismatch", "$.playback", "playback is required only by the prepared-playback profile"); + } + const provenanceInput = record(input.provenance, "$.provenance", [ + "generator", + "generatorVersion", + "sources", + ]); + const sources = array(provenanceInput.sources, "$.provenance.sources").map((entry, sourceIndex) => { + const sourcePath = `$.provenance.sources[${sourceIndex}]`; + const source = record(entry, sourcePath, ["id", "kind", "license", "sha256", "uri"]); + const kind = string(source.kind, `${sourcePath}.kind`) as PolyMorphProvenanceSource["kind"]; + if (kind !== "authored" && kind !== "generated" && kind !== "open-data") { + fail("invalid-source-kind", `${sourcePath}.kind`, kind); + } + const uri = string(source.uri, `${sourcePath}.uri`); + if (uri.startsWith("/") || uri.startsWith("file:") || uri.includes("\\")) { + fail("invalid-source-uri", `${sourcePath}.uri`, "local filesystem paths are forbidden"); + } + const sha256 = source.sha256 === null ? null : string(source.sha256, `${sourcePath}.sha256`); + if (sha256 !== null && !SHA256.test(sha256)) { + fail("invalid-hash", `${sourcePath}.sha256`, "expected lowercase SHA-256"); + } + return { + id: id(source.id, `${sourcePath}.id`), + kind, + uri, + sha256, + license: string(source.license, `${sourcePath}.license`), + }; + }); + if (sources.length === 0) fail("missing-provenance", "$.provenance.sources", "expected at least one source"); + uniqueIds(sources.map((source) => source.id), "$.provenance.sources"); + const provenance = { + generator: id(provenanceInput.generator, "$.provenance.generator"), + generatorVersion: string(provenanceInput.generatorVersion, "$.provenance.generatorVersion"), + sources, + }; + if (!REVISION.test(provenance.generatorVersion)) { + fail("invalid-revision", "$.provenance.generatorVersion", "expected x.y.z"); + } + const required = requiredCapabilities(profile, { controls, springs, animations }); + for (const capability of required) { + if (!capabilities.includes(capability)) { + fail("missing-capability", "$.capabilities", `profile requires ${capability}`); + } + } + const jointCount = deformation.kind === "joint-skin" ? deformation.joints.length : 0; + const frameCount = playback?.frames.length ?? 0; + const actualBudgets = [ + ["maxVertices", topology.vertices.length, budgets.maxVertices], + ["maxPolygons", topology.polygons.length, budgets.maxPolygons], + ["maxLeaves", leaves.length, budgets.maxLeaves], + ["maxFrames", frameCount, budgets.maxFrames], + ["maxJoints", jointCount, budgets.maxJoints], + ] as const; + for (const [key, actual, maximum] of actualBudgets) { + if (actual > maximum) fail("budget-exceeded", `$.budgets.${key}`, `${actual} exceeds ${maximum}`); + } + return { + schema: POLY_MORPH_MODEL_SCHEMA, + identity, + profile, + capabilities, + budgets, + topology, + materials, + render, + deformation, + controls, + springs, + animations, + playback, + provenance, + }; +} + +export function isPolyMorphId(value: string): boolean { + return ID.test(value); +} + +export function isPolyMorphResourcePath(value: string): boolean { + try { + normalizedPath(value, "$"); + return true; + } catch { + return false; + } +} diff --git a/packages/morph/src/entrypoints.test.ts b/packages/morph/src/entrypoints.test.ts new file mode 100644 index 00000000..cdfd8120 --- /dev/null +++ b/packages/morph/src/entrypoints.test.ts @@ -0,0 +1,15 @@ +import { describe, expect, it } from "vitest"; + +describe("package entry points", () => { + it("loads the browser entry in a DOM environment", async () => { + const runtime = await import("./index.js"); + expect(runtime.validatePolyMorphModel).toBeTypeOf("function"); + expect(document.createElement("div")).toBeInstanceOf(HTMLElement); + }); + + it("loads the preparation entry independently", async () => { + const prepare = await import("./prepare.js"); + expect(prepare.validatePolyMorphModel).toBeTypeOf("function"); + expect(prepare.preparePolyMorphModel).toBeTypeOf("function"); + }); +}); diff --git a/packages/morph/src/index.ts b/packages/morph/src/index.ts new file mode 100644 index 00000000..9d3e4599 --- /dev/null +++ b/packages/morph/src/index.ts @@ -0,0 +1,5 @@ +export * from "./contracts/index.js"; +export * from "./load/index.js"; +export * from "./package/index.js"; +export * from "./render/index.js"; +export * from "./runtime/index.js"; diff --git a/packages/morph/src/load/index.ts b/packages/morph/src/load/index.ts new file mode 100644 index 00000000..c316c1a1 --- /dev/null +++ b/packages/morph/src/load/index.ts @@ -0,0 +1,5 @@ +export { + loadPolyMorphCatalog, + loadPolyMorphPackage, +} from "./load.js"; +export type { PolyMorphLoadOptions } from "./load.js"; diff --git a/packages/morph/src/load/load.test.ts b/packages/morph/src/load/load.test.ts new file mode 100644 index 00000000..076900d1 --- /dev/null +++ b/packages/morph/src/load/load.test.ts @@ -0,0 +1,423 @@ +import { readFileSync } from "node:fs"; +import { resolve } from "node:path"; +import { describe, expect, it } from "vitest"; +import { + buildPolyMorphCatalog, + buildPolyMorphPackage, + encodePolyMorphCanonicalJson, + hashPolyMorphBytes, + PolyMorphPackageError, + type PolyMorphBuiltCatalog, + type PolyMorphBuiltPackage, +} from "../package/index.js"; +import { + clonePolyMorphFixture, + createPolyMorphModelFixture, +} from "../testing/modelFixture.js"; +import { + loadPolyMorphCatalog, + loadPolyMorphPackage, + type PolyMorphLoadOptions, +} from "./index.js"; + +const BASE_URL = "https://assets.example.test/morph/"; +const MANIFEST_PATH = "models/morph-gem/manifest.json"; + +interface LoadFixture { + readonly builtPackage: PolyMorphBuiltPackage; + readonly builtCatalog: PolyMorphBuiltCatalog; + readonly files: Map; +} + +async function createLoadFixture(): Promise { + const builtPackage = await buildPolyMorphPackage( + createPolyMorphModelFixture("morph-regions"), + [ + { + path: "assets/gem.bin", + role: "data", + mediaType: "application/octet-stream", + bytes: new Uint8Array([2, 7, 1, 8]), + }, + ], + ); + const builtCatalog = await buildPolyMorphCatalog("morph-gem", [ + { + manifest: builtPackage.manifest, + manifestPath: MANIFEST_PATH, + manifestSha256: builtPackage.manifestSha256, + }, + ]); + const files = new Map([ + [`${BASE_URL}catalog.json`, builtCatalog.bytes], + [`${BASE_URL}${MANIFEST_PATH}`, builtPackage.manifestBytes], + ]); + for (const [path, bytes] of builtPackage.files) { + files.set(`${BASE_URL}models/morph-gem/${path}`, bytes); + } + return { builtPackage, builtCatalog, files }; +} + +function fetchFrom(files: ReadonlyMap): typeof fetch { + return (async (input: RequestInfo | URL) => { + const url = input instanceof Request ? input.url : String(input); + const bytes = files.get(url); + if (!bytes) return new Response("missing", { status: 404 }); + return new Response(Uint8Array.from(bytes), { + status: 200, + headers: { + "content-length": String(bytes.byteLength), + "content-type": url.endsWith(".json") ? "application/json" : "application/octet-stream", + }, + }); + }) as typeof fetch; +} + +async function replaceFixtureModel( + fixture: LoadFixture, + mutate: (model: { + budgets: { + maxResources: number; + maxBytes: number; + }; + }) => void, +): Promise { + const model = createPolyMorphModelFixture("morph-regions") as unknown as { + budgets: { + maxResources: number; + maxBytes: number; + }; + }; + mutate(model); + const modelBytes = encodePolyMorphCanonicalJson(model); + const manifest = { + ...fixture.builtPackage.manifest, + resources: fixture.builtPackage.manifest.resources.map((descriptor) => + descriptor.path === fixture.builtPackage.manifest.modelPath + ? { + ...descriptor, + bytes: modelBytes.byteLength, + sha256: "", + } + : descriptor), + }; + const modelDescriptor = manifest.resources.find( + ({ path }) => path === manifest.modelPath, + )!; + (modelDescriptor as { sha256: string }).sha256 = + await hashPolyMorphBytes(modelBytes); + const manifestBytes = encodePolyMorphCanonicalJson(manifest); + const manifestSha256 = await hashPolyMorphBytes(manifestBytes); + const catalog = { + ...fixture.builtCatalog.catalog, + packages: fixture.builtCatalog.catalog.packages.map((row) => ({ + ...row, + manifestSha256, + })), + }; + fixture.files.set(`${BASE_URL}catalog.json`, encodePolyMorphCanonicalJson(catalog)); + fixture.files.set(`${BASE_URL}${MANIFEST_PATH}`, manifestBytes); + fixture.files.set(`${BASE_URL}models/morph-gem/model.json`, modelBytes); +} + +async function expectLoadCode( + fixture: LoadFixture, + code: string, + options: PolyMorphLoadOptions = {}, +): Promise { + try { + await loadPolyMorphPackage(BASE_URL, { + fetchImpl: fetchFrom(fixture.files), + ...options, + }); + } catch (error) { + expect(error).toBeInstanceOf(PolyMorphPackageError); + expect((error as PolyMorphPackageError).code).toBe(code); + return; + } + throw new Error(`Expected ${code}.`); +} + +async function expectPackageCode( + action: Promise, + code: string, +): Promise { + try { + await action; + } catch (error) { + expect(error).toBeInstanceOf(PolyMorphPackageError); + expect((error as PolyMorphPackageError).code).toBe(code); + return; + } + throw new Error(`Expected ${code}.`); +} + +describe("browser package loading", () => { + it("loads and binds catalog, manifest, model, and resource hashes", async () => { + const fixture = await createLoadFixture(); + const loadedCatalog = await loadPolyMorphCatalog(BASE_URL, { + fetchImpl: fetchFrom(fixture.files), + }); + expect(loadedCatalog.sha256).toBe(fixture.builtCatalog.sha256); + + const loaded = await loadPolyMorphPackage(BASE_URL, { + fetchImpl: fetchFrom(fixture.files), + }); + expect(loaded.catalogSha256).toBe(fixture.builtCatalog.sha256); + expect(loaded.manifestSha256).toBe(fixture.builtPackage.manifestSha256); + expect(loaded.model.identity.id).toBe("morph-gem"); + expect([...loaded.resources]).toHaveLength(2); + for (const [path, resource] of loaded.resources) { + expect(path).toBe(resource.descriptor.path); + expect(await hashPolyMorphBytes(resource.bytes)).toBe(resource.descriptor.sha256); + } + }); + + it("fails closed on tampered and stale-hash resources", async () => { + const fixture = await createLoadFixture(); + fixture.files.set( + `${BASE_URL}models/morph-gem/assets/gem.bin`, + new Uint8Array([2, 7, 1, 9]), + ); + await expectLoadCode(fixture, "stale-hash"); + + const staleManifest = await createLoadFixture(); + staleManifest.files.set( + `${BASE_URL}${MANIFEST_PATH}`, + encodePolyMorphCanonicalJson({ + ...staleManifest.builtPackage.manifest, + profile: "static-prepared", + }), + ); + await expectLoadCode(staleManifest, "stale-hash"); + }); + + it("fails closed on missing and unknown resources", async () => { + const missing = await createLoadFixture(); + missing.files.delete(`${BASE_URL}models/morph-gem/assets/gem.bin`); + await expectLoadCode(missing, "request-failed"); + + const unknown = await createLoadFixture(); + await expectLoadCode(unknown, "unknown-package", { modelId: "unknown-gem" }); + }); + + it("fails closed on catalog/manifest profile mismatches", async () => { + const fixture = await createLoadFixture(); + const catalog = clonePolyMorphFixture(fixture.builtCatalog.catalog); + catalog.packages[0]!.profile = "static-prepared"; + fixture.files.set(`${BASE_URL}catalog.json`, encodePolyMorphCanonicalJson(catalog)); + await expectLoadCode(fixture, "profile-mismatch"); + }); + + it("bounds individual resources and total declared bytes", async () => { + const individual = await createLoadFixture(); + await expectLoadCode(individual, "resource-too-large", { maxResourceBytes: 64 }); + + const total = await createLoadFixture(); + await expectLoadCode(total, "package-too-large", { + maxResourceBytes: 1_000_000, + maxTotalBytes: total.builtPackage.manifest.resources[0]!.bytes, + }); + + const resourceCount = await createLoadFixture(); + await expectLoadCode(resourceCount, "package-too-large", { + maxResources: 1, + }); + }); + + it("enforces the verified model's authored package budgets", async () => { + const resources = await createLoadFixture(); + await replaceFixtureModel(resources, (model) => { + model.budgets.maxResources = 1; + }); + await expectLoadCode(resources, "budget-exceeded"); + + const bytes = await createLoadFixture(); + await replaceFixtureModel(bytes, (model) => { + model.budgets.maxBytes = 1; + }); + await expectLoadCode(bytes, "budget-exceeded"); + }); + + it.each([ + "http:evil.example/manifest.json", + "javascript:alert.js", + "%2e%2e/%2e%2e/secret.json", + "models/%2e%2e/secret.json", + ])("rejects unsafe catalog package paths: %s", async (manifestPath) => { + const fixture = await createLoadFixture(); + const catalog = clonePolyMorphFixture(fixture.builtCatalog.catalog); + catalog.packages[0]!.manifestPath = manifestPath; + fixture.files.set( + `${BASE_URL}catalog.json`, + encodePolyMorphCanonicalJson(catalog), + ); + await expectLoadCode(fixture, "invalid-path"); + }); + + it.each([ + null, + "", + "https://assets.example.test\\morph", + "https://assets.example.test/morph#fragment", + "https://assets.example.test/morph?query=1", + "file:///tmp/morph/", + "http://[", + ])("rejects unsafe base URLs: %s", async (baseUrl) => { + await expectPackageCode( + loadPolyMorphCatalog(baseUrl as string, { fetchImpl: fetchFrom(new Map()) }), + "invalid-base-url", + ); + }); + + it("normalizes a missing trailing slash", async () => { + const fixture = await createLoadFixture(); + const loaded = await loadPolyMorphCatalog(BASE_URL.slice(0, -1), { + fetchImpl: fetchFrom(fixture.files), + }); + expect(loaded.sha256).toBe(fixture.builtCatalog.sha256); + }); + + it("rejects invalid resource and package limits", async () => { + const fixture = await createLoadFixture(); + await expectPackageCode( + loadPolyMorphCatalog(BASE_URL, { + fetchImpl: fetchFrom(fixture.files), + maxResourceBytes: 0, + }), + "invalid-limit", + ); + await expectPackageCode( + loadPolyMorphPackage(BASE_URL, { + fetchImpl: fetchFrom(fixture.files), + maxTotalBytes: 1.5, + }), + "invalid-limit", + ); + await expectPackageCode( + loadPolyMorphPackage(BASE_URL, { + fetchImpl: fetchFrom(fixture.files), + maxResources: 0, + }), + "invalid-limit", + ); + await expectPackageCode( + loadPolyMorphPackage(BASE_URL, { + fetchImpl: fetchFrom(fixture.files), + requestTimeoutMs: 0, + }), + "invalid-limit", + ); + }); + + it("normalizes transport failures and rejects dishonest content lengths", async () => { + const offline = new Error("offline"); + const throwingFetch = (async () => { + throw offline; + }) as typeof fetch; + await expect( + loadPolyMorphCatalog(BASE_URL, { fetchImpl: throwingFetch }), + ).rejects.toMatchObject({ + code: "request-failed", + cause: offline, + }); + + const statusFetch = (async () => new Response("unavailable", { + status: 503, + })) as typeof fetch; + await expectPackageCode( + loadPolyMorphCatalog(BASE_URL, { fetchImpl: statusFetch }), + "request-failed", + ); + + const invalidLengthFetch = (async () => new Response("{}", { + headers: { "content-length": "not-a-number" }, + })) as typeof fetch; + await expectPackageCode( + loadPolyMorphCatalog(BASE_URL, { fetchImpl: invalidLengthFetch }), + "resource-too-large", + ); + + const controller = new AbortController(); + const abort = new DOMException("cancelled", "AbortError"); + controller.abort(abort); + const abortedFetch = (async (_input, init) => { + throw init?.signal?.reason; + }) as typeof fetch; + await expect(loadPolyMorphCatalog(BASE_URL, { + fetchImpl: abortedFetch, + signal: controller.signal, + })).rejects.toBe(abort); + }); + + it("enforces streamed and array-buffer byte limits", async () => { + const fixture = await createLoadFixture(); + const streamedFetch = (async () => new Response(fixture.builtCatalog.bytes)) as typeof fetch; + await expectPackageCode( + loadPolyMorphCatalog(BASE_URL, { + fetchImpl: streamedFetch, + maxResourceBytes: fixture.builtCatalog.bytes.byteLength - 1, + }), + "resource-too-large", + ); + + const arrayBufferWithoutLength = (async () => ({ + ok: true, + status: 200, + headers: new Headers(), + body: null, + arrayBuffer: async () => fixture.builtCatalog.bytes.buffer.slice( + fixture.builtCatalog.bytes.byteOffset, + fixture.builtCatalog.bytes.byteOffset + fixture.builtCatalog.bytes.byteLength, + ), + } as Response)) as typeof fetch; + await expectPackageCode( + loadPolyMorphCatalog(BASE_URL, { + fetchImpl: arrayBufferWithoutLength, + maxResourceBytes: fixture.builtCatalog.bytes.byteLength, + }), + "resource-too-large", + ); + + const arrayBufferFetch = (async () => ({ + ok: true, + status: 200, + headers: new Headers({ + "content-length": String(fixture.builtCatalog.bytes.byteLength), + }), + body: null, + arrayBuffer: async () => fixture.builtCatalog.bytes.buffer.slice( + fixture.builtCatalog.bytes.byteOffset, + fixture.builtCatalog.bytes.byteOffset + fixture.builtCatalog.bytes.byteLength, + ), + } as Response)) as typeof fetch; + await expectPackageCode( + loadPolyMorphCatalog(BASE_URL, { + fetchImpl: arrayBufferFetch, + maxResourceBytes: fixture.builtCatalog.bytes.byteLength - 1, + }), + "resource-too-large", + ); + await expect( + loadPolyMorphCatalog(BASE_URL, { + fetchImpl: arrayBufferFetch, + maxResourceBytes: fixture.builtCatalog.bytes.byteLength, + }), + ).resolves.toMatchObject({ sha256: fixture.builtCatalog.sha256 }); + }); + + it("rejects resources whose delivered size differs from the manifest", async () => { + const fixture = await createLoadFixture(); + fixture.files.set( + `${BASE_URL}models/morph-gem/assets/gem.bin`, + new Uint8Array([2, 7, 1]), + ); + await expectLoadCode(fixture, "stale-size"); + }); + + it("keeps the loader free of product paths and Node-only dependencies", () => { + const source = readFileSync(resolve(process.cwd(), "src/load/load.ts"), "utf8"); + expect(source).not.toMatch(/node:|sharp|from\s+["'][^"']*prepare/); + expect(source).not.toMatch(new RegExp(["css", "fa", "ce"].join(""), "i")); + }); +}); diff --git a/packages/morph/src/load/load.ts b/packages/morph/src/load/load.ts new file mode 100644 index 00000000..9baff222 --- /dev/null +++ b/packages/morph/src/load/load.ts @@ -0,0 +1,360 @@ +import { validatePolyMorphModel } from "../contracts/index.js"; +import { + assertPolyMorphPackageModelBinding, + decodePolyMorphJson, + hashPolyMorphBytes, + PolyMorphPackageError, + validatePolyMorphCatalog, + validatePolyMorphPackageManifest, + type PolyMorphCatalog, + type PolyMorphLoadedPackage, + type PolyMorphLoadedResource, + type PolyMorphResourceDescriptor, +} from "../package/index.js"; + +const DEFAULT_MAX_RESOURCE_BYTES = 64 * 1024 * 1024; +const DEFAULT_MAX_RESOURCES = 1024; +const DEFAULT_MAX_TOTAL_BYTES = 128 * 1024 * 1024; +const DEFAULT_REQUEST_TIMEOUT_MS = 30_000; + +export interface PolyMorphLoadOptions { + readonly fetchImpl?: typeof fetch; + readonly modelId?: string; + readonly maxResourceBytes?: number; + readonly maxResources?: number; + readonly maxTotalBytes?: number; + readonly requestTimeoutMs?: number; + readonly signal?: AbortSignal; +} + +function boundedInteger(value: number | undefined, fallback: number, path: string): number { + const result = value ?? fallback; + if (!Number.isSafeInteger(result) || result <= 0) { + throw new PolyMorphPackageError("invalid-limit", path, "expected a positive safe integer"); + } + return result; +} + +function resolveBaseUrl(baseUrl: string): URL { + if ( + typeof baseUrl !== "string" + || baseUrl.length === 0 + || baseUrl.includes("\\") + || baseUrl.includes("#") + || baseUrl.includes("?") + ) { + throw new PolyMorphPackageError("invalid-base-url", "$.baseUrl", "expected a clean HTTP(S) base URL"); + } + let url: URL; + try { + url = new URL(baseUrl, globalThis.location?.href ?? "https://polycss.invalid/"); + } catch { + throw new PolyMorphPackageError("invalid-base-url", "$.baseUrl", "expected a valid URL"); + } + if (url.protocol !== "http:" && url.protocol !== "https:") { + throw new PolyMorphPackageError("invalid-base-url", "$.baseUrl", "expected HTTP or HTTPS"); + } + if (!url.pathname.endsWith("/")) url.pathname += "/"; + return url; +} + +async function readBounded( + response: Response, + path: string, + maximum: number, +): Promise { + const header = response.headers.get("content-length"); + if (header !== null) { + const declared = Number(header); + if (!Number.isSafeInteger(declared) || declared < 0 || declared > maximum) { + throw new PolyMorphPackageError("resource-too-large", path, `declared bytes exceed ${maximum}`); + } + } + if (response.body?.getReader) { + const reader = response.body.getReader(); + const chunks: Uint8Array[] = []; + let length = 0; + while (true) { + const result = await reader.read(); + if (result.done) break; + length += result.value.byteLength; + if (length > maximum) { + await reader.cancel(); + throw new PolyMorphPackageError("resource-too-large", path, `bytes exceed ${maximum}`); + } + chunks.push(result.value); + } + const bytes = new Uint8Array(length); + let offset = 0; + for (const chunk of chunks) { + bytes.set(chunk, offset); + offset += chunk.byteLength; + } + return bytes; + } + if (header === null) { + throw new PolyMorphPackageError( + "resource-too-large", + path, + "content-length is required without a readable body", + ); + } + const bytes = new Uint8Array(await response.arrayBuffer()); + if (bytes.byteLength > maximum) { + throw new PolyMorphPackageError("resource-too-large", path, `bytes exceed ${maximum}`); + } + return bytes; +} + +async function requestBytes( + fetchImpl: typeof fetch, + url: URL, + maximum: number, + signal: AbortSignal | undefined, + timeoutMs: number, +): Promise { + const requestSignal = signal ?? AbortSignal.timeout(timeoutMs); + let response: Response; + try { + response = await fetchImpl(url, { cache: "no-store", signal: requestSignal }); + } catch (cause) { + if (requestSignal.aborted) throw cause; + throw new PolyMorphPackageError( + "request-failed", + url.pathname, + "request failed", + { cause }, + ); + } + if (!response.ok) { + throw new PolyMorphPackageError("request-failed", url.pathname, `HTTP ${response.status}`); + } + return readBounded(response, url.pathname, maximum); +} + +async function assertHash(bytes: Uint8Array, expected: string, path: string): Promise { + const actual = await hashPolyMorphBytes(bytes); + if (actual !== expected) { + throw new PolyMorphPackageError("stale-hash", path, `expected ${expected}, received ${actual}`); + } +} + +function packageRoot(baseUrl: URL, manifestPath: string): URL { + const slash = manifestPath.lastIndexOf("/"); + return resolvePackageUrl(baseUrl, manifestPath.slice(0, slash + 1)); +} + +function resolvePackageUrl(baseUrl: URL, path: string): URL { + const resolved = new URL(path, baseUrl); + if ( + resolved.origin !== baseUrl.origin + || !resolved.href.startsWith(baseUrl.href) + ) { + throw new PolyMorphPackageError( + "invalid-path", + path, + "package path escapes its base URL", + ); + } + return resolved; +} + +export async function loadPolyMorphCatalog( + baseUrl: string, + options: Omit< + PolyMorphLoadOptions, + "maxResources" | "maxTotalBytes" | "modelId" + > = {}, +): Promise<{ readonly catalog: PolyMorphCatalog; readonly bytes: Uint8Array; readonly sha256: string }> { + const base = resolveBaseUrl(baseUrl); + const maximum = boundedInteger( + options.maxResourceBytes, + DEFAULT_MAX_RESOURCE_BYTES, + "$.maxResourceBytes", + ); + const fetchImpl = options.fetchImpl ?? globalThis.fetch; + if (typeof fetchImpl !== "function") { + throw new PolyMorphPackageError("missing-fetch", "$.fetchImpl", "a fetch implementation is required"); + } + const timeoutMs = boundedInteger( + options.requestTimeoutMs, + DEFAULT_REQUEST_TIMEOUT_MS, + "$.requestTimeoutMs", + ); + const bytes = await requestBytes( + fetchImpl, + resolvePackageUrl(base, "catalog.json"), + maximum, + options.signal, + timeoutMs, + ); + const catalog = validatePolyMorphCatalog(decodePolyMorphJson(bytes, "$.catalog")); + return { catalog, bytes, sha256: await hashPolyMorphBytes(bytes) }; +} + +export async function loadPolyMorphPackage( + baseUrl: string, + options: PolyMorphLoadOptions = {}, +): Promise { + const base = resolveBaseUrl(baseUrl); + const maxResourceBytes = boundedInteger( + options.maxResourceBytes, + DEFAULT_MAX_RESOURCE_BYTES, + "$.maxResourceBytes", + ); + const maxResources = boundedInteger( + options.maxResources, + DEFAULT_MAX_RESOURCES, + "$.maxResources", + ); + const maxTotalBytes = boundedInteger( + options.maxTotalBytes, + DEFAULT_MAX_TOTAL_BYTES, + "$.maxTotalBytes", + ); + const requestTimeoutMs = boundedInteger( + options.requestTimeoutMs, + DEFAULT_REQUEST_TIMEOUT_MS, + "$.requestTimeoutMs", + ); + const fetchImpl = options.fetchImpl ?? globalThis.fetch; + if (typeof fetchImpl !== "function") { + throw new PolyMorphPackageError("missing-fetch", "$.fetchImpl", "a fetch implementation is required"); + } + const loadedCatalog = await loadPolyMorphCatalog(base.href, { + fetchImpl, + maxResourceBytes, + requestTimeoutMs, + signal: options.signal, + }); + const selected = options.modelId ?? loadedCatalog.catalog.defaultId; + const row = loadedCatalog.catalog.packages.find((entry) => entry.id === selected); + if (!row) throw new PolyMorphPackageError("unknown-package", "$.modelId", selected); + const manifestBytes = await requestBytes( + fetchImpl, + resolvePackageUrl(base, row.manifestPath), + maxResourceBytes, + options.signal, + requestTimeoutMs, + ); + await assertHash(manifestBytes, row.manifestSha256, row.manifestPath); + const manifest = validatePolyMorphPackageManifest( + decodePolyMorphJson(manifestBytes, "$.manifest"), + ); + if ( + manifest.identity.id !== row.id + || manifest.identity.name !== row.name + || manifest.identity.revision !== row.revision + || manifest.profile !== row.profile + ) { + throw new PolyMorphPackageError( + "profile-mismatch", + "$.manifest", + "catalog row does not match its manifest", + ); + } + const root = packageRoot(base, row.manifestPath); + if (manifest.resources.length > maxResources) { + throw new PolyMorphPackageError( + "package-too-large", + "$.manifest.resources", + `${manifest.resources.length} resources exceed ${maxResources}`, + ); + } + let totalBytes = 0; + for (const descriptor of manifest.resources) { + if (descriptor.bytes > maxResourceBytes) { + throw new PolyMorphPackageError( + "resource-too-large", + descriptor.path, + `declared bytes exceed ${maxResourceBytes}`, + ); + } + totalBytes += descriptor.bytes; + if (totalBytes > maxTotalBytes) { + throw new PolyMorphPackageError( + "package-too-large", + descriptor.path, + `declared package bytes exceed ${maxTotalBytes}`, + ); + } + } + const resources = new Map(); + const loadResource = async ( + descriptor: PolyMorphResourceDescriptor, + ): Promise => { + const bytes = await requestBytes( + fetchImpl, + resolvePackageUrl(root, descriptor.path), + Math.min(maxResourceBytes, descriptor.bytes), + options.signal, + requestTimeoutMs, + ); + if (bytes.byteLength !== descriptor.bytes) { + throw new PolyMorphPackageError( + "stale-size", + descriptor.path, + `expected ${descriptor.bytes}, received ${bytes.byteLength}`, + ); + } + await assertHash(bytes, descriptor.sha256, descriptor.path); + return { descriptor, bytes }; + }; + const modelDescriptor = manifest.resources.find( + (descriptor) => descriptor.path === manifest.modelPath, + )!; + const modelResource = await loadResource(modelDescriptor); + resources.set(modelDescriptor.path, modelResource); + const model = validatePolyMorphModel( + decodePolyMorphJson(modelResource.bytes, "$.model"), + ); + assertPolyMorphPackageModelBinding(manifest, model); + if (manifest.resources.length > model.budgets.maxResources) { + throw new PolyMorphPackageError( + "budget-exceeded", + "$.budgets.maxResources", + `${manifest.resources.length} resources exceed ${model.budgets.maxResources}`, + ); + } + if (totalBytes > model.budgets.maxBytes) { + throw new PolyMorphPackageError( + "budget-exceeded", + "$.budgets.maxBytes", + `${totalBytes} bytes exceed ${model.budgets.maxBytes}`, + ); + } + for (const descriptor of manifest.resources) { + if (descriptor === modelDescriptor) continue; + resources.set(descriptor.path, await loadResource(descriptor)); + } + const imagePaths = new Set(model.render.leaves.flatMap((leaf) => [ + ...(leaf.atlas ? [leaf.atlas.resourcePath] : []), + ...(leaf.fallback ? [leaf.fallback.atlas.resourcePath] : []), + ])); + for (const path of imagePaths) { + const resource = resources.get(path); + if (!resource) { + throw new PolyMorphPackageError( + "missing-resource", + path, + "model image is not declared by the package", + ); + } + if (resource.descriptor.role !== "image") { + throw new PolyMorphPackageError( + "invalid-role", + path, + "model image resource must use the image role", + ); + } + } + return { + catalog: loadedCatalog.catalog, + catalogSha256: loadedCatalog.sha256, + catalogRow: row, + manifest, + manifestSha256: row.manifestSha256, + model, + resources, + }; +} diff --git a/packages/morph/src/package/build.ts b/packages/morph/src/package/build.ts new file mode 100644 index 00000000..65707e5b --- /dev/null +++ b/packages/morph/src/package/build.ts @@ -0,0 +1,161 @@ +import { + POLY_MORPH_CATALOG_SCHEMA, + POLY_MORPH_PACKAGE_SCHEMA, + validatePolyMorphModel, + type PolyMorphModel, +} from "../contracts/index.js"; +import { + encodePolyMorphCanonicalJson, + hashPolyMorphBytes, +} from "./canonical.js"; +import { PolyMorphPackageError } from "./error.js"; +import type { + PolyMorphBuiltCatalog, + PolyMorphBuiltPackage, + PolyMorphCatalogRow, + PolyMorphPackageManifest, + PolyMorphResourceDescriptor, + PolyMorphResourceInput, +} from "./types.js"; +import { + validatePolyMorphCatalog, + validatePolyMorphPackageManifest, +} from "./validation.js"; + +const MODEL_PATH = "model.json"; + +function modelImagePaths(model: PolyMorphModel): readonly string[] { + return [...new Set(model.render.leaves.flatMap((leaf) => [ + ...(leaf.atlas ? [leaf.atlas.resourcePath] : []), + ...(leaf.fallback ? [leaf.fallback.atlas.resourcePath] : []), + ]))].sort(); +} + +export async function buildPolyMorphPackage( + modelInput: unknown, + resourceInputs: readonly PolyMorphResourceInput[] = [], +): Promise { + const model = validatePolyMorphModel(modelInput); + const modelBytes = encodePolyMorphCanonicalJson(model); + const files = new Map([[MODEL_PATH, modelBytes]]); + const descriptors: PolyMorphResourceDescriptor[] = [ + { + path: MODEL_PATH, + role: "model", + mediaType: "application/json", + bytes: modelBytes.byteLength, + sha256: await hashPolyMorphBytes(modelBytes), + }, + ]; + for (const input of resourceInputs) { + if (files.has(input.path)) { + throw new PolyMorphPackageError("duplicate", input.path, "resource path is already present"); + } + const bytes = Uint8Array.from(input.bytes); + files.set(input.path, bytes); + descriptors.push({ + path: input.path, + role: input.role, + mediaType: input.mediaType, + bytes: bytes.byteLength, + sha256: await hashPolyMorphBytes(bytes), + }); + } + for (const path of modelImagePaths(model)) { + const descriptor = descriptors.find((entry) => entry.path === path); + if (!descriptor) { + throw new PolyMorphPackageError( + "missing-resource", + path, + "model image is not included in the package", + ); + } + if (descriptor.role !== "image") { + throw new PolyMorphPackageError( + "invalid-role", + path, + "model image resource must use the image role", + ); + } + } + descriptors.sort((left, right) => + left.path < right.path ? -1 : left.path > right.path ? 1 : 0); + const manifest: PolyMorphPackageManifest = validatePolyMorphPackageManifest({ + schema: POLY_MORPH_PACKAGE_SCHEMA, + identity: model.identity, + profile: model.profile, + modelPath: MODEL_PATH, + resources: descriptors, + }); + const totalBytes = descriptors.reduce((sum, descriptor) => sum + descriptor.bytes, 0); + if (descriptors.length > model.budgets.maxResources) { + throw new PolyMorphPackageError( + "budget-exceeded", + "$.budgets.maxResources", + `${descriptors.length} resources exceed ${model.budgets.maxResources}`, + ); + } + if (totalBytes > model.budgets.maxBytes) { + throw new PolyMorphPackageError( + "budget-exceeded", + "$.budgets.maxBytes", + `${totalBytes} bytes exceed ${model.budgets.maxBytes}`, + ); + } + const manifestBytes = encodePolyMorphCanonicalJson(manifest); + return { + manifest, + manifestBytes, + manifestSha256: await hashPolyMorphBytes(manifestBytes), + files, + }; +} + +export async function buildPolyMorphCatalog( + defaultId: string, + packages: readonly { + readonly manifest: PolyMorphPackageManifest; + readonly manifestPath: string; + readonly manifestSha256: string; + }[], +): Promise { + const rows: PolyMorphCatalogRow[] = packages.map((entry) => ({ + id: entry.manifest.identity.id, + name: entry.manifest.identity.name, + revision: entry.manifest.identity.revision, + profile: entry.manifest.profile, + manifestPath: entry.manifestPath, + manifestSha256: entry.manifestSha256, + })); + rows.sort((left, right) => + left.id < right.id ? -1 : left.id > right.id ? 1 : 0); + const catalog = validatePolyMorphCatalog({ + schema: POLY_MORPH_CATALOG_SCHEMA, + defaultId, + packages: rows, + }); + const bytes = encodePolyMorphCanonicalJson(catalog); + return { + catalog, + bytes, + sha256: await hashPolyMorphBytes(bytes), + }; +} + +export function assertPolyMorphPackageModelBinding( + manifest: PolyMorphPackageManifest, + model: PolyMorphModel, +): void { + if ( + model.identity.id !== manifest.identity.id + || model.identity.name !== manifest.identity.name + || model.identity.revision !== manifest.identity.revision + || model.profile !== manifest.profile + ) { + throw new PolyMorphPackageError( + "profile-mismatch", + "$.model", + "model identity or profile does not match its manifest", + ); + } +} diff --git a/packages/morph/src/package/canonical.ts b/packages/morph/src/package/canonical.ts new file mode 100644 index 00000000..68f29ed2 --- /dev/null +++ b/packages/morph/src/package/canonical.ts @@ -0,0 +1,72 @@ +import { PolyMorphPackageError } from "./error.js"; + +function canonicalValue(value: unknown, path: string, seen: WeakSet): string { + if (value === null) return "null"; + if (typeof value === "string" || typeof value === "boolean") return JSON.stringify(value); + if (typeof value === "number") { + if (!Number.isFinite(value)) { + throw new PolyMorphPackageError("invalid-json", path, "numbers must be finite"); + } + return JSON.stringify(Object.is(value, -0) ? 0 : value); + } + if (Array.isArray(value)) { + if (seen.has(value)) throw new PolyMorphPackageError("invalid-json", path, "cycles are forbidden"); + seen.add(value); + const result = `[${value.map((entry, index) => + canonicalValue(entry, `${path}[${index}]`, seen)).join(",")}]`; + seen.delete(value); + return result; + } + if (typeof value === "object" && value) { + if (seen.has(value)) throw new PolyMorphPackageError("invalid-json", path, "cycles are forbidden"); + const prototype = Object.getPrototypeOf(value); + if (prototype !== Object.prototype && prototype !== null) { + throw new PolyMorphPackageError("invalid-json", path, "expected a plain object"); + } + seen.add(value); + const input = value as Record; + const result = `{${Object.keys(input).sort().map((key) => { + const entry = input[key]; + if (entry === undefined || typeof entry === "function" || typeof entry === "symbol") { + throw new PolyMorphPackageError("invalid-json", `${path}.${key}`, "value is not JSON"); + } + return `${JSON.stringify(key)}:${canonicalValue(entry, `${path}.${key}`, seen)}`; + }).join(",")}}`; + seen.delete(value); + return result; + } + throw new PolyMorphPackageError("invalid-json", path, "value is not JSON"); +} + +export function stringifyPolyMorphCanonicalJson(value: unknown): string { + return canonicalValue(value, "$", new WeakSet()); +} + +export function encodePolyMorphCanonicalJson(value: unknown): Uint8Array { + return new TextEncoder().encode(stringifyPolyMorphCanonicalJson(value)); +} + +export function decodePolyMorphJson(bytes: Uint8Array, path = "$"): unknown { + let text: string; + try { + text = new TextDecoder("utf-8", { fatal: true }).decode(bytes); + } catch { + throw new PolyMorphPackageError("invalid-utf8", path, "expected UTF-8 bytes"); + } + try { + return JSON.parse(text) as unknown; + } catch { + throw new PolyMorphPackageError("invalid-json", path, "expected valid JSON"); + } +} + +export async function hashPolyMorphBytes(bytes: Uint8Array): Promise { + if (!globalThis.crypto?.subtle) { + throw new PolyMorphPackageError("missing-crypto", "$", "Web Crypto is required"); + } + const copy = Uint8Array.from(bytes); + const digest = await globalThis.crypto.subtle.digest("SHA-256", copy); + return [...new Uint8Array(digest)] + .map((value) => value.toString(16).padStart(2, "0")) + .join(""); +} diff --git a/packages/morph/src/package/error.ts b/packages/morph/src/package/error.ts new file mode 100644 index 00000000..8a3db05e --- /dev/null +++ b/packages/morph/src/package/error.ts @@ -0,0 +1,18 @@ +export class PolyMorphPackageError extends Error { + readonly code: string; + readonly path: string; + readonly cause?: unknown; + + constructor( + code: string, + path: string, + message: string, + options: { readonly cause?: unknown } = {}, + ) { + super(`${path}: ${message}`); + this.name = "PolyMorphPackageError"; + this.code = code; + this.path = path; + if ("cause" in options) this.cause = options.cause; + } +} diff --git a/packages/morph/src/package/index.ts b/packages/morph/src/package/index.ts new file mode 100644 index 00000000..bf0d3f56 --- /dev/null +++ b/packages/morph/src/package/index.ts @@ -0,0 +1,28 @@ +export { + decodePolyMorphJson, + encodePolyMorphCanonicalJson, + hashPolyMorphBytes, + stringifyPolyMorphCanonicalJson, +} from "./canonical.js"; +export { + assertPolyMorphPackageModelBinding, + buildPolyMorphCatalog, + buildPolyMorphPackage, +} from "./build.js"; +export { PolyMorphPackageError } from "./error.js"; +export { + validatePolyMorphCatalog, + validatePolyMorphPackageManifest, +} from "./validation.js"; +export type { + PolyMorphBuiltCatalog, + PolyMorphBuiltPackage, + PolyMorphCatalog, + PolyMorphCatalogRow, + PolyMorphLoadedPackage, + PolyMorphLoadedResource, + PolyMorphPackageManifest, + PolyMorphResourceDescriptor, + PolyMorphResourceInput, + PolyMorphResourceRole, +} from "./types.js"; diff --git a/packages/morph/src/package/package.test.ts b/packages/morph/src/package/package.test.ts new file mode 100644 index 00000000..8106b1dd --- /dev/null +++ b/packages/morph/src/package/package.test.ts @@ -0,0 +1,199 @@ +import { describe, expect, it } from "vitest"; +import { + buildPolyMorphCatalog, + buildPolyMorphPackage, + encodePolyMorphCanonicalJson, + hashPolyMorphBytes, + PolyMorphPackageError, + stringifyPolyMorphCanonicalJson, + validatePolyMorphCatalog, + validatePolyMorphPackageManifest, +} from "./index.js"; +import { + clonePolyMorphFixture, + createPolyMorphModelFixture, +} from "../testing/modelFixture.js"; + +function expectPackageCode(action: () => unknown, code: string): void { + try { + action(); + } catch (error) { + expect(error).toBeInstanceOf(PolyMorphPackageError); + expect((error as PolyMorphPackageError).code).toBe(code); + return; + } + throw new Error(`Expected ${code}.`); +} + +describe("canonical package construction", () => { + it("serializes equivalent objects to identical bytes and hashes", async () => { + const left = { z: [3, { b: true, a: null }], a: "gem" }; + const right = { a: "gem", z: [3, { a: null, b: true }] }; + const leftBytes = encodePolyMorphCanonicalJson(left); + const rightBytes = encodePolyMorphCanonicalJson(right); + expect(leftBytes).toEqual(rightBytes); + expect(stringifyPolyMorphCanonicalJson(left)).toBe( + "{\"a\":\"gem\",\"z\":[3,{\"a\":null,\"b\":true}]}", + ); + expect(await hashPolyMorphBytes(leftBytes)).toBe(await hashPolyMorphBytes(rightBytes)); + }); + + it("builds byte-identical packages from equivalent model key order", async () => { + const fixture = createPolyMorphModelFixture("morph-regions"); + const reversed = Object.fromEntries(Object.entries(fixture).reverse()); + const resource = { + path: "assets/gem.bin", + role: "data" as const, + mediaType: "application/octet-stream", + bytes: new Uint8Array([3, 1, 4, 1, 5]), + }; + const left = await buildPolyMorphPackage(fixture, [resource]); + const right = await buildPolyMorphPackage(reversed, [resource]); + expect(left.manifestBytes).toEqual(right.manifestBytes); + expect(left.files.get("model.json")).toEqual(right.files.get("model.json")); + expect(left.manifestSha256).toBe(right.manifestSha256); + }); + + it("sorts and content-binds every declared resource", async () => { + const built = await buildPolyMorphPackage(createPolyMorphModelFixture(), [ + { + path: "data/metadata.bin", + role: "data", + mediaType: "application/octet-stream", + bytes: new TextEncoder().encode("gem"), + }, + { + path: "assets/gem.webp", + role: "image", + mediaType: "image/webp", + bytes: new Uint8Array([1, 2, 3]), + }, + ]); + expect(built.manifest.resources.map((resource) => resource.path)).toEqual([ + "assets/gem.webp", + "data/metadata.bin", + "model.json", + ]); + for (const descriptor of built.manifest.resources) { + const bytes = built.files.get(descriptor.path); + expect(bytes?.byteLength).toBe(descriptor.bytes); + expect(bytes && await hashPolyMorphBytes(bytes)).toBe(descriptor.sha256); + } + }); + + it("requires every prepared image reference to bind an image resource", async () => { + const model = clonePolyMorphFixture(createPolyMorphModelFixture()); + model.render.leaves[0]!.fallback = { + width: 7, + height: 5, + matrixFromLeaf: model.render.leaves[0]!.matrix, + atlas: { + resourcePath: "assets/solid-triangles-000.png", + x: 0, + y: 0, + width: 7, + height: 5, + pageWidth: 7, + pageHeight: 5, + }, + }; + await expect(buildPolyMorphPackage(model)).rejects.toMatchObject({ + code: "missing-resource", + }); + await expect(buildPolyMorphPackage(model, [{ + path: "assets/solid-triangles-000.png", + role: "data", + mediaType: "image/png", + bytes: new Uint8Array([1]), + }])).rejects.toMatchObject({ + code: "invalid-role", + }); + await expect(buildPolyMorphPackage(model, [{ + path: "assets/solid-triangles-000.png", + role: "image", + mediaType: "image/png", + bytes: new Uint8Array([1]), + }])).resolves.toMatchObject({ + manifest: { + resources: expect.arrayContaining([ + expect.objectContaining({ + path: "assets/solid-triangles-000.png", + role: "image", + }), + ]), + }, + }); + }); + + it("requires image resources to declare an image media type", async () => { + await expect(buildPolyMorphPackage(createPolyMorphModelFixture(), [{ + path: "assets/paint.bin", + role: "image", + mediaType: "application/octet-stream", + bytes: new Uint8Array([1]), + }])).rejects.toMatchObject({ + code: "invalid-media-type", + }); + }); + + it("rejects unknown, duplicate, unsafe, and malformed manifest data", () => { + const fixture = { + schema: "polycss-morph.package@1", + identity: { id: "morph-gem", name: "Morph Gem", revision: "1.0.0" }, + profile: "static-prepared", + modelPath: "model.json", + resources: [ + { + path: "model.json", + role: "model", + mediaType: "application/json", + bytes: 1, + sha256: "a".repeat(64), + }, + ], + }; + expect(validatePolyMorphPackageManifest(fixture).modelPath).toBe("model.json"); + + expectPackageCode(() => + validatePolyMorphPackageManifest({ ...fixture, unknown: true }), "invalid-keys"); + expectPackageCode(() => + validatePolyMorphPackageManifest({ + ...fixture, + resources: [...fixture.resources, fixture.resources[0]], + }), "duplicate"); + expectPackageCode(() => + validatePolyMorphPackageManifest({ + ...fixture, + modelPath: "../model.json", + resources: [{ ...fixture.resources[0], path: "../model.json" }], + }), "invalid-path"); + expectPackageCode(() => + validatePolyMorphPackageManifest({ + ...fixture, + resources: [{ ...fixture.resources[0], role: "unknown" }], + }), "invalid-role"); + }); + + it("builds a strict content-addressed catalog", async () => { + const first = await buildPolyMorphPackage(createPolyMorphModelFixture()); + const secondModel = clonePolyMorphFixture(createPolyMorphModelFixture()); + secondModel.identity.id = "second-gem"; + secondModel.identity.name = "Second Gem"; + const second = await buildPolyMorphPackage(secondModel); + const built = await buildPolyMorphCatalog("morph-gem", [ + { + manifest: second.manifest, + manifestPath: "models/second-gem/manifest.json", + manifestSha256: second.manifestSha256, + }, + { + manifest: first.manifest, + manifestPath: "models/morph-gem/manifest.json", + manifestSha256: first.manifestSha256, + }, + ]); + expect(built.catalog.packages.map((row) => row.id)).toEqual(["morph-gem", "second-gem"]); + expect(await hashPolyMorphBytes(built.bytes)).toBe(built.sha256); + expect(validatePolyMorphCatalog(built.catalog)).toEqual(built.catalog); + }); +}); diff --git a/packages/morph/src/package/types.ts b/packages/morph/src/package/types.ts new file mode 100644 index 00000000..dea053d3 --- /dev/null +++ b/packages/morph/src/package/types.ts @@ -0,0 +1,78 @@ +import type { + POLY_MORPH_CATALOG_SCHEMA, + POLY_MORPH_PACKAGE_SCHEMA, + PolyMorphModel, + PolyMorphModelIdentity, + PolyMorphProfile, +} from "../contracts/index.js"; + +export type PolyMorphResourceRole = + | "data" + | "image" + | "model"; + +export interface PolyMorphResourceDescriptor { + readonly path: string; + readonly role: PolyMorphResourceRole; + readonly mediaType: string; + readonly bytes: number; + readonly sha256: string; +} + +export interface PolyMorphPackageManifest { + readonly schema: typeof POLY_MORPH_PACKAGE_SCHEMA; + readonly identity: PolyMorphModelIdentity; + readonly profile: PolyMorphProfile; + readonly modelPath: string; + readonly resources: readonly PolyMorphResourceDescriptor[]; +} + +export interface PolyMorphCatalogRow { + readonly id: string; + readonly name: string; + readonly revision: string; + readonly profile: PolyMorphProfile; + readonly manifestPath: string; + readonly manifestSha256: string; +} + +export interface PolyMorphCatalog { + readonly schema: typeof POLY_MORPH_CATALOG_SCHEMA; + readonly defaultId: string; + readonly packages: readonly PolyMorphCatalogRow[]; +} + +export interface PolyMorphResourceInput { + readonly path: string; + readonly role: Exclude; + readonly mediaType: string; + readonly bytes: Uint8Array; +} + +export interface PolyMorphBuiltPackage { + readonly manifest: PolyMorphPackageManifest; + readonly manifestBytes: Uint8Array; + readonly manifestSha256: string; + readonly files: ReadonlyMap; +} + +export interface PolyMorphBuiltCatalog { + readonly catalog: PolyMorphCatalog; + readonly bytes: Uint8Array; + readonly sha256: string; +} + +export interface PolyMorphLoadedResource { + readonly descriptor: PolyMorphResourceDescriptor; + readonly bytes: Uint8Array; +} + +export interface PolyMorphLoadedPackage { + readonly catalog: PolyMorphCatalog; + readonly catalogSha256: string; + readonly catalogRow: PolyMorphCatalogRow; + readonly manifest: PolyMorphPackageManifest; + readonly manifestSha256: string; + readonly model: PolyMorphModel; + readonly resources: ReadonlyMap; +} diff --git a/packages/morph/src/package/validation.ts b/packages/morph/src/package/validation.ts new file mode 100644 index 00000000..1dbb8343 --- /dev/null +++ b/packages/morph/src/package/validation.ts @@ -0,0 +1,213 @@ +import { + POLY_MORPH_CATALOG_SCHEMA, + POLY_MORPH_PACKAGE_SCHEMA, + isPolyMorphId, + isPolyMorphResourcePath, + type PolyMorphProfile, +} from "../contracts/index.js"; +import { PolyMorphPackageError } from "./error.js"; +import type { + PolyMorphCatalog, + PolyMorphCatalogRow, + PolyMorphPackageManifest, + PolyMorphResourceDescriptor, + PolyMorphResourceRole, +} from "./types.js"; + +const SHA256 = /^[a-f0-9]{64}$/; +const REVISION = /^\d+\.\d+\.\d+$/; +const MEDIA_TYPE = /^[a-z0-9!#$&^_.+-]+\/[a-z0-9!#$&^_.+-]+$/; +const PROFILES = new Set([ + "joint-skin", + "morph-regions", + "prepared-playback", + "static-prepared", +]); +const ROLES = new Set([ + "data", + "image", + "model", +]); + +function fail(code: string, path: string, message: string): never { + throw new PolyMorphPackageError(code, path, message); +} + +function record(value: unknown, path: string, keys: readonly string[]): Record { + if (!value || typeof value !== "object" || Array.isArray(value)) { + fail("invalid-type", path, "expected an object"); + } + const actual = Object.keys(value).sort(); + const expected = [...keys].sort(); + if (actual.length !== expected.length || actual.some((key, index) => key !== expected[index])) { + fail("invalid-keys", path, `expected exactly ${expected.join(", ")}`); + } + return value as Record; +} + +function array(value: unknown, path: string): readonly unknown[] { + if (!Array.isArray(value)) fail("invalid-type", path, "expected an array"); + return value; +} + +function text(value: unknown, path: string): string { + if (typeof value !== "string" || value.length === 0) { + fail("invalid-string", path, "expected a non-empty string"); + } + return value; +} + +function normalizedId(value: unknown, path: string): string { + const result = text(value, path); + if (!isPolyMorphId(result)) fail("invalid-id", path, "expected a normalized kebab-case id"); + return result; +} + +function normalizedPath(value: unknown, path: string): string { + const result = text(value, path); + if (!isPolyMorphResourcePath(result)) { + fail("invalid-path", path, "expected a normalized package-relative path"); + } + return result; +} + +function revision(value: unknown, path: string): string { + const result = text(value, path); + if (!REVISION.test(result)) fail("invalid-revision", path, "expected x.y.z"); + return result; +} + +function profile(value: unknown, path: string): PolyMorphProfile { + const result = text(value, path) as PolyMorphProfile; + if (!PROFILES.has(result)) fail("invalid-profile", path, result); + return result; +} + +function sha256(value: unknown, path: string): string { + const result = text(value, path); + if (!SHA256.test(result)) fail("invalid-hash", path, "expected lowercase SHA-256"); + return result; +} + +function byteCount(value: unknown, path: string): number { + if (typeof value !== "number" || !Number.isSafeInteger(value) || value < 0) { + fail("invalid-bytes", path, "expected a non-negative safe integer"); + } + return value; +} + +function resource(value: unknown, path: string): PolyMorphResourceDescriptor { + const input = record(value, path, ["bytes", "mediaType", "path", "role", "sha256"]); + const role = text(input.role, `${path}.role`) as PolyMorphResourceRole; + if (!ROLES.has(role)) fail("invalid-role", `${path}.role`, role); + const mediaType = text(input.mediaType, `${path}.mediaType`).toLowerCase(); + if (!MEDIA_TYPE.test(mediaType) || mediaType !== input.mediaType) { + fail("invalid-media-type", `${path}.mediaType`, "expected a normalized type/subtype"); + } + if (role === "image" && !mediaType.startsWith("image/")) { + fail( + "invalid-media-type", + `${path}.mediaType`, + "image resources require an image media type", + ); + } + return { + path: normalizedPath(input.path, `${path}.path`), + role, + mediaType, + bytes: byteCount(input.bytes, `${path}.bytes`), + sha256: sha256(input.sha256, `${path}.sha256`), + }; +} + +function unique(values: readonly string[], path: string): void { + const seen = new Set(); + for (const value of values) { + if (seen.has(value)) fail("duplicate", path, value); + seen.add(value); + } +} + +function assertCanonicalOrder(values: readonly string[], path: string): void { + if (values.some((value, index) => index > 0 && value <= values[index - 1]!)) { + fail("non-canonical-order", path, "entries must be unique and sorted"); + } +} + +export function validatePolyMorphPackageManifest(value: unknown): PolyMorphPackageManifest { + const input = record(value, "$", ["identity", "modelPath", "profile", "resources", "schema"]); + if (input.schema !== POLY_MORPH_PACKAGE_SCHEMA) { + fail("invalid-schema", "$.schema", `expected ${POLY_MORPH_PACKAGE_SCHEMA}`); + } + const identityInput = record(input.identity, "$.identity", ["id", "name", "revision"]); + const identity = { + id: normalizedId(identityInput.id, "$.identity.id"), + name: text(identityInput.name, "$.identity.name"), + revision: revision(identityInput.revision, "$.identity.revision"), + }; + const resources = array(input.resources, "$.resources").map((entry, index) => + resource(entry, `$.resources[${index}]`)); + if (resources.length === 0) fail("missing-resource", "$.resources", "expected at least the model resource"); + const paths = resources.map((entry) => entry.path); + unique(paths, "$.resources"); + assertCanonicalOrder(paths, "$.resources"); + const modelPath = normalizedPath(input.modelPath, "$.modelPath"); + const models = resources.filter((entry) => entry.role === "model"); + if (models.length !== 1 || models[0]!.path !== modelPath) { + fail("invalid-model-resource", "$.resources", "expected exactly one model resource matching modelPath"); + } + if (models[0]!.mediaType !== "application/json") { + fail("invalid-model-resource", "$.resources", "model resource must be application/json"); + } + return { + schema: POLY_MORPH_PACKAGE_SCHEMA, + identity, + profile: profile(input.profile, "$.profile"), + modelPath, + resources, + }; +} + +function catalogRow(value: unknown, path: string): PolyMorphCatalogRow { + const input = record(value, path, [ + "id", + "manifestPath", + "manifestSha256", + "name", + "profile", + "revision", + ]); + const manifestPath = normalizedPath(input.manifestPath, `${path}.manifestPath`); + if (!manifestPath.endsWith("/manifest.json")) { + fail("invalid-manifest-path", `${path}.manifestPath`, "expected a package manifest.json path"); + } + return { + id: normalizedId(input.id, `${path}.id`), + name: text(input.name, `${path}.name`), + revision: revision(input.revision, `${path}.revision`), + profile: profile(input.profile, `${path}.profile`), + manifestPath, + manifestSha256: sha256(input.manifestSha256, `${path}.manifestSha256`), + }; +} + +export function validatePolyMorphCatalog(value: unknown): PolyMorphCatalog { + const input = record(value, "$", ["defaultId", "packages", "schema"]); + if (input.schema !== POLY_MORPH_CATALOG_SCHEMA) { + fail("invalid-schema", "$.schema", `expected ${POLY_MORPH_CATALOG_SCHEMA}`); + } + const packages = array(input.packages, "$.packages").map((entry, index) => + catalogRow(entry, `$.packages[${index}]`)); + if (packages.length === 0) fail("missing-package", "$.packages", "expected at least one package"); + const ids = packages.map((entry) => entry.id); + unique(ids, "$.packages"); + assertCanonicalOrder(ids, "$.packages"); + unique(packages.map((entry) => entry.manifestPath), "$.packages[*].manifestPath"); + const defaultId = normalizedId(input.defaultId, "$.defaultId"); + if (!ids.includes(defaultId)) fail("unknown-default", "$.defaultId", defaultId); + return { + schema: POLY_MORPH_CATALOG_SCHEMA, + defaultId, + packages, + }; +} diff --git a/packages/morph/src/prepare.ts b/packages/morph/src/prepare.ts new file mode 100644 index 00000000..4ac6744f --- /dev/null +++ b/packages/morph/src/prepare.ts @@ -0,0 +1,3 @@ +export * from "./contracts/index.js"; +export * from "./package/index.js"; +export * from "./prepare/index.js"; diff --git a/packages/morph/src/prepare/compile.ts b/packages/morph/src/prepare/compile.ts new file mode 100644 index 00000000..598753cc --- /dev/null +++ b/packages/morph/src/prepare/compile.ts @@ -0,0 +1,448 @@ +import { + computeSolidTrianglePlanFromCssPoints, + SOLID_TRIANGLE_CANONICAL_SIZE, + type Polygon, +} from "@layoutit/polycss"; +import { + POLY_MORPH_MODEL_SCHEMA, + validatePolyMorphModel, + type PolyMorphCapability, + type PolyMorphColor, + type PolyMorphMat4, + type PolyMorphModel, + type PolyMorphTarget, + type PolyMorphVec3, +} from "../contracts/index.js"; +import { buildPolyMorphPackage } from "../package/index.js"; +import { failPolyMorphPrepare } from "./error.js"; +import { + buildPolyMorphSolidTriangleAtlas, +} from "./solidTriangleAtlas.js"; +import packageMetadata from "../../package.json"; +import type { + PolyMorphGltfDocument, + PolyMorphPrepareConfig, + PolyMorphPreparedPackage, + PolyMorphPreparedSource, +} from "./types.js"; + +const IDENTITY: PolyMorphMat4 = [ + 1, 0, 0, 0, + 0, 1, 0, 0, + 0, 0, 1, 0, + 0, 0, 0, 1, +]; +const PREPARER_VERSION = packageMetadata.version; + +function rounded(value: number): number { + const result = Number(value.toFixed(10)); + return Object.is(result, -0) ? 0 : result; +} + +function roundedVec3(value: readonly number[]): PolyMorphVec3 { + return [rounded(value[0]!), rounded(value[1]!), rounded(value[2]!)]; +} + +function normalizedName(value: string, fallback: string): string { + const slug = value + .normalize("NFKD") + .replace(/([a-z0-9])([A-Z])/gu, "$1-$2") + .replace(/[^A-Za-z0-9]+/gu, "-") + .replace(/^-+|-+$/gu, "") + .toLowerCase(); + const candidate = slug || fallback; + return /^[a-z]/u.test(candidate) ? candidate : `item-${candidate}`; +} + +function transformPoint( + matrix: PolyMorphMat4, + [x, y, z]: PolyMorphVec3, +): PolyMorphVec3 { + const w = matrix[3] * x + matrix[7] * y + matrix[11] * z + matrix[15]; + if (!Number.isFinite(w) || Math.abs(w) < 1e-12) { + failPolyMorphPrepare( + "invalid-transform", + "$.source.nodes", + "node has a singular projective transform", + ); + } + return [ + (matrix[0] * x + matrix[4] * y + matrix[8] * z + matrix[12]) / w, + (matrix[1] * x + matrix[5] * y + matrix[9] * z + matrix[13]) / w, + (matrix[2] * x + matrix[6] * y + matrix[10] * z + matrix[14]) / w, + ]; +} + +function transformVector( + matrix: PolyMorphMat4, + [x, y, z]: PolyMorphVec3, +): PolyMorphVec3 { + return [ + matrix[0] * x + matrix[4] * y + matrix[8] * z, + matrix[1] * x + matrix[5] * y + matrix[9] * z, + matrix[2] * x + matrix[6] * y + matrix[10] * z, + ]; +} + +function authoredVector( + vector: PolyMorphVec3, + config: PolyMorphPrepareConfig, +): PolyMorphVec3 { + const source = { x: vector[0], y: vector[1], z: vector[2] }; + return roundedVec3(config.transform.axes.map((axis, index) => + source[axis] * config.transform.signs[index]! * config.transform.scale)); +} + +function reversesHandedness( + matrix: PolyMorphMat4, + config: PolyMorphPrepareConfig, +): boolean { + const sourceBasis: readonly PolyMorphVec3[] = [ + [1, 0, 0], + [0, 1, 0], + [0, 0, 1], + ]; + const basis = sourceBasis.map((vector) => { + const transformed = transformVector( + matrix, + vector, + ); + const source = { + x: transformed[0], + y: transformed[1], + z: transformed[2], + }; + return config.transform.axes.map((axis, index) => + source[axis] * config.transform.signs[index]! * config.transform.scale, + ) as unknown as PolyMorphVec3; + }); + const [x, y, z] = basis; + const determinant = + x![0] * (y![1] * z![2] - y![2] * z![1]) + - y![0] * (x![1] * z![2] - x![2] * z![1]) + + z![0] * (x![1] * y![2] - x![2] * y![1]); + return determinant < 0; +} + +function subtract(left: PolyMorphVec3, right: PolyMorphVec3): PolyMorphVec3 { + return [ + left[0] - right[0], + left[1] - right[1], + left[2] - right[2], + ]; +} + +function cross(left: PolyMorphVec3, right: PolyMorphVec3): PolyMorphVec3 { + return [ + left[1] * right[2] - left[2] * right[1], + left[2] * right[0] - left[0] * right[2], + left[0] * right[1] - left[1] * right[0], + ]; +} + +function normalise(value: readonly number[], path: string): PolyMorphVec3 { + const length = Math.hypot(...value); + if (!(length > 1e-10)) { + failPolyMorphPrepare("degenerate-triangle", path, "triangle has zero area"); + } + return roundedVec3(value.map((component) => component / length)); +} + +function computeNormals( + vertices: readonly PolyMorphVec3[], + polygons: readonly { readonly id: string; readonly vertexIndices: readonly number[] }[], +): readonly PolyMorphVec3[] { + const sums = Array.from({ length: vertices.length }, () => [0, 0, 0]); + for (const polygon of polygons) { + const [a, b, c] = polygon.vertexIndices.map((index) => vertices[index]!); + const value = cross(subtract(b!, a!), subtract(c!, a!)); + if (Math.hypot(...value) <= 1e-10) { + failPolyMorphPrepare( + "degenerate-triangle", + `$.topology.${polygon.id}`, + "triangle has zero area", + ); + } + for (const index of polygon.vertexIndices) { + sums[index]![0] += value[0]; + sums[index]![1] += value[1]; + sums[index]![2] += value[2]; + } + } + return sums.map((sum, index) => + normalise(sum, `$.topology.normals[${index}]`)); +} + +function triangleMatrix( + vertices: readonly PolyMorphVec3[], + path: string, +): PolyMorphMat4 { + const polygon: Polygon = { vertices: vertices.map((vertex) => [...vertex]) }; + const [p0, p1, p2] = vertices; + const plan = computeSolidTrianglePlanFromCssPoints( + polygon, + 0, + { seamBleed: 0 }, + { + includeColor: false, + matrixDecimals: 10, + primitive: "border", + }, + p0![0], p0![1], p0![2], + p1![0], p1![1], p1![2], + p2![0], p2![1], p2![2], + ); + const match = /^matrix3d\(([^)]+)\)$/u.exec(plan?.transformText ?? ""); + const values = match?.[1]?.split(",").map(Number); + if (!values || values.length !== 16 || values.some((value) => !Number.isFinite(value))) { + failPolyMorphPrepare("unrenderable-triangle", path, "PolyCSS produced no stable matrix"); + } + return values.map(rounded) as unknown as PolyMorphMat4; +} + +function capabilities(config: PolyMorphPrepareConfig): readonly PolyMorphCapability[] { + const values = new Set(["retained-render"]); + if (config.profile === "morph-regions") { + values.add("morph-targets"); + values.add("sparse-updates"); + } + if (config.controls.length > 0) values.add("semantic-controls"); + if (config.springs.length > 0) values.add("springs"); + if (config.animations.length > 0) values.add("animation"); + return [...values].sort(); +} + +export async function compilePolyMorphSource( + source: PolyMorphGltfDocument, + config: PolyMorphPrepareConfig, +): Promise { + type PendingPolygon = { + readonly id: string; + readonly shapeId: string; + readonly materialIndex: number; + readonly vertexIndices: readonly [number, number, number]; + }; + const vertices: PolyMorphVec3[] = []; + const pendingPolygons: PendingPolygon[] = []; + const shapes: { id: string; matrix: PolyMorphMat4 }[] = []; + const aliasEntries = Object.entries(config.morphAliases); + const deltasByTarget = new Map( + aliasEntries.map(([, targetId]) => [targetId, []]), + ); + const usedMaterialIndices = new Set(); + let shapeIndex = 0; + let polygonIndex = 0; + + for (const instance of source.instances) { + const reverseWinding = reversesHandedness(instance.matrix, config); + for (const primitive of instance.primitives) { + const shapeId = `shape-${String(shapeIndex).padStart(4, "0")}-${normalizedName( + `${instance.meshName}-${primitive.primitiveIndex}`, + "mesh", + )}`; + shapes.push({ id: shapeId, matrix: IDENTITY }); + const targetByName = new Map( + primitive.targets.map((target) => [target.name, target]), + ); + if ( + aliasEntries.some(([sourceName]) => !targetByName.has(sourceName)) + ) { + failPolyMorphPrepare( + "missing-morph", + `$.source.${shapeId}`, + "every primitive must contain every configured morph target", + ); + } + const usedVertexIndices = [...new Set(primitive.triangles.flat())] + .sort((left, right) => left - right); + const preparedIndexBySource = new Map(); + for (const localIndex of usedVertexIndices) { + const position = primitive.positions[localIndex]!; + preparedIndexBySource.set(localIndex, vertices.length); + const world = transformPoint(instance.matrix, position); + vertices.push(authoredVector(world, config)); + for (const [sourceName, targetId] of aliasEntries) { + const sourceDelta = targetByName.get(sourceName)!.positionDeltas[localIndex]!; + const worldDelta = transformVector(instance.matrix, sourceDelta); + deltasByTarget.get(targetId)!.push(authoredVector(worldDelta, config)); + } + } + usedMaterialIndices.add(primitive.materialIndex); + for (const triangle of primitive.triangles) { + const ordered = reverseWinding + ? [triangle[0], triangle[2], triangle[1]] + : triangle; + pendingPolygons.push({ + id: `polygon-${String(polygonIndex).padStart(6, "0")}`, + shapeId, + materialIndex: primitive.materialIndex, + vertexIndices: [ + preparedIndexBySource.get(ordered[0])!, + preparedIndexBySource.get(ordered[1])!, + preparedIndexBySource.get(ordered[2])!, + ], + }); + polygonIndex += 1; + } + shapeIndex += 1; + } + } + + if (config.transform.center) { + const minimum = [Infinity, Infinity, Infinity]; + const maximum = [-Infinity, -Infinity, -Infinity]; + for (const vertex of vertices) { + for (let axis = 0; axis < 3; axis += 1) { + minimum[axis] = Math.min(minimum[axis]!, vertex[axis]!); + maximum[axis] = Math.max(maximum[axis]!, vertex[axis]!); + } + } + const center = minimum.map((value, axis) => + (value + maximum[axis]!) / 2) as unknown as PolyMorphVec3; + for (let index = 0; index < vertices.length; index += 1) { + vertices[index] = roundedVec3(subtract(vertices[index]!, center)); + } + } + + const materialRows = [...usedMaterialIndices].sort((left, right) => left - right); + const sourceMaterialByIndex = new Map( + source.materials.map((material) => [material.sourceIndex, material]), + ); + const materials = materialRows.map((sourceIndex) => { + const sourceMaterial = sourceIndex === -1 + ? { name: "Default", color: [1, 1, 1, 1] as PolyMorphColor } + : sourceMaterialByIndex.get(sourceIndex); + if (!sourceMaterial) { + failPolyMorphPrepare( + "invalid-material", + "$.source.materials", + `primitive references missing material ${sourceIndex}`, + ); + } + return { + sourceIndex, + id: sourceIndex === -1 + ? "material-default" + : `material-${String(sourceIndex).padStart(4, "0")}-${normalizedName( + sourceMaterial.name, + "source", + )}`, + color: sourceMaterial.color, + }; + }); + const materialIdByIndex = new Map( + materials.map((material) => [material.sourceIndex, material.id]), + ); + const polygons = pendingPolygons.map((polygon) => ({ + id: polygon.id, + vertexIndices: polygon.vertexIndices, + normalIndices: polygon.vertexIndices, + })); + const normals = computeNormals(vertices, polygons); + const targets: PolyMorphTarget[] = aliasEntries.map(([, targetId]) => ({ + id: targetId, + deltas: deltasByTarget.get(targetId)! + .map((position, vertexIndex) => ({ position, vertexIndex })) + .filter(({ position }) => Math.hypot(...position) > 1e-12) + .map(({ position, vertexIndex }) => ({ + vertexIndex, + position, + normal: null, + })), + })); + if (targets.some((target) => target.deltas.length === 0)) { + failPolyMorphPrepare( + "empty-morph", + "$.morphAliases", + "every configured morph target must move at least one vertex", + ); + } + const baseLeaves = pendingPolygons.map((polygon) => { + const materialId = materialIdByIndex.get(polygon.materialIndex)!; + return { + id: `leaf-${polygon.id}`, + polygonId: polygon.id, + shapeId: polygon.shapeId, + materialId, + strategy: "solid-triangle" as const, + width: SOLID_TRIANGLE_CANONICAL_SIZE, + height: SOLID_TRIANGLE_CANONICAL_SIZE, + matrix: triangleMatrix( + polygon.vertexIndices.map((index) => vertices[index]!), + `$.render.${polygon.id}`, + ), + atlas: null, + fallback: null, + }; + }); + const fallbackAtlas = buildPolyMorphSolidTriangleAtlas( + pendingPolygons.map((polygon, index) => ({ + vertexIndices: polygon.vertexIndices, + vertices: polygon.vertexIndices.map((vertexIndex) => + vertices[vertexIndex]!) as unknown as readonly [ + PolyMorphVec3, + PolyMorphVec3, + PolyMorphVec3, + ], + materialId: materialIdByIndex.get(polygon.materialIndex)!, + leafMatrix: baseLeaves[index]!.matrix, + })), + ); + const leaves = baseLeaves.map((leaf, index) => ({ + ...leaf, + fallback: fallbackAtlas.fallbacks[index]!, + })); + const modelInput: PolyMorphModel = { + schema: POLY_MORPH_MODEL_SCHEMA, + identity: config.identity, + profile: config.profile, + capabilities: capabilities(config), + budgets: config.budgets, + topology: { vertices, normals, polygons }, + materials: materials.map(({ id, color }) => ({ id, color })), + render: { + modelMatrix: IDENTITY, + shapes, + leaves, + }, + deformation: config.profile === "morph-regions" + ? { kind: "morph-regions", targets } + : { kind: "none" }, + controls: config.controls, + springs: config.springs, + animations: config.animations, + playback: null, + provenance: { + generator: "polycss-morph", + generatorVersion: PREPARER_VERSION, + sources: [{ + id: config.source.id, + kind: config.source.kind, + uri: config.source.uri, + sha256: source.contentSha256, + license: config.source.license, + }], + }, + }; + const model = validatePolyMorphModel(modelInput); + return { + model, + fallbackAtlasPages: fallbackAtlas.pages, + }; +} + +export async function buildPolyMorphPreparedPackage( + source: PolyMorphGltfDocument, + config: PolyMorphPrepareConfig, +): Promise { + const prepared = await compilePolyMorphSource(source, config); + const built = await buildPolyMorphPackage(prepared.model, [ + ...prepared.fallbackAtlasPages.map((page) => ({ + path: page.path, + role: "image", + mediaType: "image/png", + bytes: page.bytes, + } as const)), + ]); + return { source, prepared, package: built }; +} diff --git a/packages/morph/src/prepare/config.ts b/packages/morph/src/prepare/config.ts new file mode 100644 index 00000000..531d7c76 --- /dev/null +++ b/packages/morph/src/prepare/config.ts @@ -0,0 +1,282 @@ +import { readFile } from "node:fs/promises"; +import { dirname, isAbsolute, relative, resolve, sep } from "node:path"; +import { isPolyMorphId, type PolyMorphBudgets } from "../contracts/index.js"; +import { failPolyMorphPrepare } from "./error.js"; +import { + POLY_MORPH_PREPARE_SCHEMA, + type PolyMorphPrepareConfig, +} from "./types.js"; + +function record( + value: unknown, + path: string, + keys: readonly string[], +): Record { + if (!value || typeof value !== "object" || Array.isArray(value)) { + failPolyMorphPrepare("invalid-config", path, "expected an object"); + } + const actual = Object.keys(value).sort(); + const expected = [...keys].sort(); + if ( + actual.length !== expected.length + || actual.some((key, index) => key !== expected[index]) + ) { + failPolyMorphPrepare( + "invalid-config", + path, + `expected exactly ${expected.join(", ")}`, + ); + } + return value as Record; +} + +function array(value: unknown, path: string): readonly unknown[] { + if (!Array.isArray(value)) { + failPolyMorphPrepare("invalid-config", path, "expected an array"); + } + return value; +} + +function text(value: unknown, path: string): string { + if (typeof value !== "string" || value.length === 0 || value !== value.trim()) { + failPolyMorphPrepare("invalid-config", path, "expected non-empty trimmed text"); + } + return value; +} + +function id(value: unknown, path: string): string { + const result = text(value, path); + if (!isPolyMorphId(result)) { + failPolyMorphPrepare("invalid-config", path, "expected a normalized kebab-case id"); + } + return result; +} + +function finite(value: unknown, path: string): number { + if (typeof value !== "number" || !Number.isFinite(value)) { + failPolyMorphPrepare("invalid-config", path, "expected a finite number"); + } + return value; +} + +function positiveInteger(value: unknown, path: string): number { + const result = finite(value, path); + if (!Number.isSafeInteger(result) || result < 1) { + failPolyMorphPrepare("invalid-config", path, "expected a positive integer"); + } + return result; +} + +function parseBudgets(value: unknown): PolyMorphBudgets { + const input = record(value, "$.budgets", [ + "maxBytes", + "maxFrames", + "maxJoints", + "maxLeaves", + "maxPolygons", + "maxResources", + "maxVertices", + ]); + return { + maxVertices: positiveInteger(input.maxVertices, "$.budgets.maxVertices"), + maxPolygons: positiveInteger(input.maxPolygons, "$.budgets.maxPolygons"), + maxLeaves: positiveInteger(input.maxLeaves, "$.budgets.maxLeaves"), + maxFrames: positiveInteger(input.maxFrames, "$.budgets.maxFrames"), + maxJoints: positiveInteger(input.maxJoints, "$.budgets.maxJoints"), + maxResources: positiveInteger(input.maxResources, "$.budgets.maxResources"), + maxBytes: positiveInteger(input.maxBytes, "$.budgets.maxBytes"), + }; +} + +export function parsePolyMorphPrepareConfig(value: unknown): PolyMorphPrepareConfig { + const input = record(value, "$", [ + "animations", + "budgets", + "controls", + "identity", + "morphAliases", + "profile", + "schema", + "source", + "springs", + "transform", + ]); + if (input.schema !== POLY_MORPH_PREPARE_SCHEMA) { + failPolyMorphPrepare( + "invalid-config", + "$.schema", + `expected ${POLY_MORPH_PREPARE_SCHEMA}`, + ); + } + const identityInput = record(input.identity, "$.identity", ["id", "name", "revision"]); + const identity = { + id: id(identityInput.id, "$.identity.id"), + name: text(identityInput.name, "$.identity.name"), + revision: text(identityInput.revision, "$.identity.revision"), + }; + if (!/^\d+\.\d+\.\d+$/u.test(identity.revision)) { + failPolyMorphPrepare("invalid-config", "$.identity.revision", "expected x.y.z"); + } + const profile = text(input.profile, "$.profile"); + if (profile !== "morph-regions" && profile !== "static-prepared") { + failPolyMorphPrepare( + "invalid-config", + "$.profile", + "generic preparation supports morph-regions or static-prepared", + ); + } + const sourceInput = record(input.source, "$.source", [ + "id", + "kind", + "license", + "path", + "uri", + ]); + const kind = text(sourceInput.kind, "$.source.kind"); + if (kind !== "authored" && kind !== "generated" && kind !== "open-data") { + failPolyMorphPrepare("invalid-config", "$.source.kind", "unknown source kind"); + } + const sourcePath = text(sourceInput.path, "$.source.path"); + if ( + isAbsolute(sourcePath) + || sourcePath.includes("\\") + || sourcePath.split("/").some((part) => part === "" || part === "." || part === "..") + ) { + failPolyMorphPrepare( + "unsafe-path", + "$.source.path", + "expected a normalized config-relative path", + ); + } + const source = { + id: id(sourceInput.id, "$.source.id"), + kind, + path: sourcePath, + uri: text(sourceInput.uri, "$.source.uri"), + license: text(sourceInput.license, "$.source.license"), + } as const; + if ( + source.uri.startsWith("/") + || source.uri.startsWith("file:") + || source.uri.includes("\\") + ) { + failPolyMorphPrepare( + "invalid-config", + "$.source.uri", + "local filesystem paths are forbidden", + ); + } + const transformInput = record(input.transform, "$.transform", [ + "axes", + "center", + "scale", + "signs", + ]); + const axes = array(transformInput.axes, "$.transform.axes"); + if ( + axes.length !== 3 + || axes.some((axis) => axis !== "x" && axis !== "y" && axis !== "z") + || new Set(axes).size !== 3 + ) { + failPolyMorphPrepare( + "invalid-config", + "$.transform.axes", + "expected each source axis exactly once", + ); + } + const signs = array(transformInput.signs, "$.transform.signs"); + if (signs.length !== 3 || signs.some((sign) => sign !== -1 && sign !== 1)) { + failPolyMorphPrepare( + "invalid-config", + "$.transform.signs", + "expected three values of -1 or 1", + ); + } + const scale = finite(transformInput.scale, "$.transform.scale"); + if (!(scale > 0)) { + failPolyMorphPrepare("invalid-config", "$.transform.scale", "expected a positive scale"); + } + if (typeof transformInput.center !== "boolean") { + failPolyMorphPrepare("invalid-config", "$.transform.center", "expected a boolean"); + } + const aliases = record( + input.morphAliases, + "$.morphAliases", + Object.keys((input.morphAliases ?? {}) as object), + ); + const morphAliases = Object.fromEntries( + Object.entries(aliases) + .map(([sourceName, targetId]) => [ + text(sourceName, "$.morphAliases key"), + id(targetId, `$.morphAliases.${sourceName}`), + ]) + .sort(([left], [right]) => left < right ? -1 : left > right ? 1 : 0), + ); + if (new Set(Object.values(morphAliases)).size !== Object.keys(morphAliases).length) { + failPolyMorphPrepare( + "invalid-config", + "$.morphAliases", + "prepared target ids must be unique", + ); + } + if ( + (profile === "morph-regions") !== (Object.keys(morphAliases).length > 0) + ) { + failPolyMorphPrepare( + "invalid-config", + "$.morphAliases", + "morph-regions requires aliases and static-prepared forbids them", + ); + } + const config: PolyMorphPrepareConfig = { + schema: POLY_MORPH_PREPARE_SCHEMA, + identity, + profile, + source, + transform: { + axes: axes as PolyMorphPrepareConfig["transform"]["axes"], + signs: signs as PolyMorphPrepareConfig["transform"]["signs"], + scale, + center: transformInput.center, + }, + morphAliases, + controls: structuredClone(array(input.controls, "$.controls")) as PolyMorphPrepareConfig["controls"], + springs: structuredClone(array(input.springs, "$.springs")) as PolyMorphPrepareConfig["springs"], + animations: structuredClone(array(input.animations, "$.animations")) as PolyMorphPrepareConfig["animations"], + budgets: parseBudgets(input.budgets), + }; + return config; +} + +export async function readPolyMorphPrepareConfig( + configPath: string, +): Promise { + let value: unknown; + try { + value = JSON.parse(await readFile(configPath, "utf8")) as unknown; + } catch { + failPolyMorphPrepare( + "invalid-config", + "$", + "config is not readable UTF-8 JSON", + ); + } + return parsePolyMorphPrepareConfig(value); +} + +export function resolvePolyMorphSourcePath( + configPath: string, + sourcePath: string, +): string { + const root = resolve(dirname(configPath)); + const candidate = resolve(root, sourcePath); + const rel = relative(root, candidate); + if (rel === ".." || rel.startsWith(`..${sep}`) || isAbsolute(rel)) { + failPolyMorphPrepare( + "unsafe-path", + "$.source.path", + "source escapes its authoring directory", + ); + } + return candidate; +} diff --git a/packages/morph/src/prepare/error.ts b/packages/morph/src/prepare/error.ts new file mode 100644 index 00000000..ec1b74be --- /dev/null +++ b/packages/morph/src/prepare/error.ts @@ -0,0 +1,19 @@ +export class PolyMorphPrepareError extends Error { + readonly code: string; + readonly path: string; + + constructor(code: string, path: string, message: string) { + super(`${path}: ${message}`); + this.name = "PolyMorphPrepareError"; + this.code = code; + this.path = path; + } +} + +export function failPolyMorphPrepare( + code: string, + path: string, + message: string, +): never { + throw new PolyMorphPrepareError(code, path, message); +} diff --git a/packages/morph/src/prepare/gltf.ts b/packages/morph/src/prepare/gltf.ts new file mode 100644 index 00000000..7dba86d8 --- /dev/null +++ b/packages/morph/src/prepare/gltf.ts @@ -0,0 +1,696 @@ +import { createHash } from "node:crypto"; +import { readFile } from "node:fs/promises"; +import { dirname, extname, isAbsolute, relative, resolve, sep } from "node:path"; +import type { PolyMorphMat4, PolyMorphVec3 } from "../contracts/index.js"; +import { failPolyMorphPrepare } from "./error.js"; +import type { + PolyMorphGltfDocument, + PolyMorphGltfInstance, + PolyMorphGltfMaterial, + PolyMorphGltfPrimitive, +} from "./types.js"; + +type JsonObject = Record; + +const COMPONENT_COUNTS: Readonly> = { + SCALAR: 1, + VEC2: 2, + VEC3: 3, + VEC4: 4, + MAT4: 16, +}; + +const COMPONENT_BYTES: Readonly> = { + 5120: 1, + 5121: 1, + 5122: 2, + 5123: 2, + 5125: 4, + 5126: 4, +}; + +function sha256(bytes: Uint8Array): string { + return createHash("sha256").update(bytes).digest("hex"); +} + +function parseJson(bytes: Uint8Array, path: string): JsonObject { + try { + const value = JSON.parse( + new TextDecoder("utf-8", { fatal: true }).decode(bytes), + ) as unknown; + if (!value || typeof value !== "object" || Array.isArray(value)) { + failPolyMorphPrepare("invalid-gltf", path, "expected a JSON object"); + } + return value as JsonObject; + } catch (error) { + if (error instanceof Error && error.name === "PolyMorphPrepareError") throw error; + failPolyMorphPrepare("invalid-gltf", path, "expected UTF-8 JSON"); + } +} + +function decodeGlb(bytes: Uint8Array): { + readonly json: JsonObject; + readonly binary: Uint8Array | null; +} { + if (bytes.byteLength < 20) { + failPolyMorphPrepare("invalid-glb", "$.source", "GLB is truncated"); + } + const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength); + if ( + view.getUint32(0, true) !== 0x46546c67 + || view.getUint32(4, true) !== 2 + || view.getUint32(8, true) !== bytes.byteLength + ) { + failPolyMorphPrepare("invalid-glb", "$.source", "invalid GLB header"); + } + let offset = 12; + let json: JsonObject | null = null; + let binary: Uint8Array | null = null; + while (offset < bytes.byteLength) { + if (offset + 8 > bytes.byteLength) { + failPolyMorphPrepare("invalid-glb", "$.source", "truncated GLB chunk header"); + } + const length = view.getUint32(offset, true); + const type = view.getUint32(offset + 4, true); + const start = offset + 8; + const end = start + length; + if (length % 4 !== 0 || end > bytes.byteLength) { + failPolyMorphPrepare("invalid-glb", "$.source", "invalid GLB chunk bounds"); + } + if (type === 0x4e4f534a) { + if (json) { + failPolyMorphPrepare("invalid-glb", "$.source", "duplicate JSON chunk"); + } + json = parseJson(bytes.subarray(start, end), "$.source.json"); + } else if (type === 0x004e4942) { + if (binary) { + failPolyMorphPrepare("invalid-glb", "$.source", "duplicate binary chunk"); + } + binary = bytes.subarray(start, end); + } else { + failPolyMorphPrepare( + "invalid-glb", + "$.source", + `unsupported GLB chunk type ${type}`, + ); + } + offset = end; + } + if (!json) { + failPolyMorphPrepare("invalid-glb", "$.source", "missing JSON chunk"); + } + return { json, binary }; +} + +function normalizedRelativePath(value: unknown, path: string): string { + if ( + typeof value !== "string" + || value.length === 0 + || isAbsolute(value) + || value.includes("\\") + || value.includes("?") + || value.includes("#") + || value.split("/").some((part) => part === "" || part === "." || part === "..") + ) { + failPolyMorphPrepare( + "unsafe-path", + path, + "expected a normalized source-relative path", + ); + } + return value; +} + +function dataUriBytes(uri: string, path: string): Uint8Array { + const match = /^data:application\/(?:octet-stream|gltf-buffer);base64,([A-Za-z0-9+/]*={0,2})$/u.exec(uri); + if (!match) { + failPolyMorphPrepare( + "unsupported-uri", + path, + "expected a base64 glTF buffer data URI", + ); + } + return Uint8Array.from(Buffer.from(match[1], "base64")); +} + +async function loadBuffers( + json: JsonObject, + sourcePath: string, + glbBinary: Uint8Array | null, +): Promise<{ + readonly buffers: readonly Uint8Array[]; + readonly files: readonly { readonly path: string; readonly sha256: string }[]; +}> { + const rows = Array.isArray(json.buffers) ? json.buffers as JsonObject[] : []; + if (rows.length === 0) { + failPolyMorphPrepare("missing-buffer", "$.source.buffers", "expected at least one buffer"); + } + const sourceRoot = dirname(sourcePath); + const buffers: Uint8Array[] = []; + const files: { path: string; sha256: string }[] = []; + for (const [index, row] of rows.entries()) { + const path = `$.source.buffers[${index}]`; + if (!Number.isSafeInteger(row?.byteLength) || row.byteLength < 0) { + failPolyMorphPrepare("invalid-buffer", path, "invalid byteLength"); + } + let bytes: Uint8Array; + let identity: string; + if (row.uri === undefined) { + if (index !== 0 || !glbBinary) { + failPolyMorphPrepare("missing-buffer", path, "missing URI or GLB binary chunk"); + } + bytes = glbBinary; + identity = ""; + } else if (typeof row.uri === "string" && row.uri.startsWith("data:")) { + bytes = dataUriBytes(row.uri, `${path}.uri`); + identity = ""; + } else { + const sourceRelative = normalizedRelativePath(row.uri, `${path}.uri`); + const absolute = resolve(sourceRoot, sourceRelative); + const rel = relative(sourceRoot, absolute); + if (rel === ".." || rel.startsWith(`..${sep}`) || isAbsolute(rel)) { + failPolyMorphPrepare("unsafe-path", `${path}.uri`, "buffer escapes source directory"); + } + bytes = new Uint8Array(await readFile(absolute)); + identity = sourceRelative; + } + const declaredLength = row.byteLength as number; + if (bytes.byteLength < declaredLength || bytes.byteLength - declaredLength > 3) { + failPolyMorphPrepare("invalid-buffer", path, "buffer length disagrees with byteLength"); + } + const exact = bytes.subarray(0, declaredLength); + buffers.push(exact); + files.push({ path: identity, sha256: sha256(exact) }); + } + return { buffers, files }; +} + +function readComponent( + view: DataView, + componentType: number, + offset: number, +): number { + switch (componentType) { + case 5120: return view.getInt8(offset); + case 5121: return view.getUint8(offset); + case 5122: return view.getInt16(offset, true); + case 5123: return view.getUint16(offset, true); + case 5125: return view.getUint32(offset, true); + case 5126: return view.getFloat32(offset, true); + default: + failPolyMorphPrepare( + "invalid-accessor", + "$.source.accessors", + `unsupported component type ${componentType}`, + ); + } +} + +function normalizedComponent(value: number, componentType: number): number { + switch (componentType) { + case 5120: return Math.max(-1, value / 127); + case 5121: return value / 255; + case 5122: return Math.max(-1, value / 32767); + case 5123: return value / 65535; + case 5125: return value / 4294967295; + default: return value; + } +} + +function createAccessorReader( + json: JsonObject, + buffers: readonly Uint8Array[], +): (accessorIndex: number, path: string) => readonly (number | readonly number[])[] { + const accessors = Array.isArray(json.accessors) ? json.accessors as JsonObject[] : []; + const views = Array.isArray(json.bufferViews) ? json.bufferViews as JsonObject[] : []; + return (accessorIndex, path) => { + const accessor = accessors[accessorIndex]; + if ( + !accessor + || accessor.sparse !== undefined + || !Number.isSafeInteger(accessor.count) + || accessor.count < 0 + ) { + failPolyMorphPrepare("invalid-accessor", path, "missing, sparse, or invalid accessor"); + } + const components = COMPONENT_COUNTS[accessor.type]; + const bytesPerComponent = COMPONENT_BYTES[accessor.componentType]; + const bufferView = views[accessor.bufferView]; + if ( + !components + || !bytesPerComponent + || !bufferView + || !Number.isSafeInteger(accessor.bufferView) + || !Number.isSafeInteger(bufferView.buffer) + ) { + failPolyMorphPrepare("invalid-accessor", path, "unsupported accessor metadata"); + } + const buffer = buffers[bufferView.buffer]; + if (!buffer) { + failPolyMorphPrepare("invalid-accessor", path, "references an absent buffer"); + } + const itemBytes = components * bytesPerComponent; + const stride = bufferView.byteStride ?? itemBytes; + const viewOffset = bufferView.byteOffset ?? 0; + const accessorOffset = accessor.byteOffset ?? 0; + const viewLength = bufferView.byteLength; + const start = viewOffset + accessorOffset; + const end = start + Math.max(0, accessor.count - 1) * stride + itemBytes; + if ( + !Number.isSafeInteger(viewOffset) + || viewOffset < 0 + || !Number.isSafeInteger(viewLength) + || viewLength < 0 + || viewOffset + viewLength > buffer.byteLength + || !Number.isSafeInteger(accessorOffset) + || accessorOffset < 0 + || accessorOffset % bytesPerComponent !== 0 + || !Number.isSafeInteger(stride) + || stride < itemBytes + || (bufferView.byteStride !== undefined && (stride > 252 || stride % 4 !== 0)) + || !Number.isSafeInteger(start) + || start < 0 + || start % bytesPerComponent !== 0 + || end > viewOffset + viewLength + ) { + failPolyMorphPrepare("invalid-accessor", path, "invalid accessor byte layout"); + } + const view = new DataView(buffer.buffer, buffer.byteOffset, buffer.byteLength); + const result: (number | readonly number[])[] = []; + for (let index = 0; index < accessor.count; index += 1) { + const values: number[] = []; + for (let component = 0; component < components; component += 1) { + const raw = readComponent( + view, + accessor.componentType, + start + index * stride + component * bytesPerComponent, + ); + const value = accessor.normalized + ? normalizedComponent(raw, accessor.componentType) + : raw; + if (!Number.isFinite(value)) { + failPolyMorphPrepare( + "invalid-accessor", + `${path}[${index}][${component}]`, + "non-finite component", + ); + } + values.push(Object.is(value, -0) ? 0 : value); + } + result.push(components === 1 ? values[0]! : values); + } + return result; + }; +} + +function vec3Rows( + values: readonly (number | readonly number[])[], + path: string, +): readonly PolyMorphVec3[] { + return values.map((value, index) => { + if (!Array.isArray(value) || value.length !== 3) { + failPolyMorphPrepare("invalid-accessor", `${path}[${index}]`, "expected VEC3"); + } + return [value[0]!, value[1]!, value[2]!] as const; + }); +} + +function triangleIndices( + mode: number, + values: readonly (number | readonly number[])[], + path: string, +): readonly (readonly [number, number, number])[] { + const indices = values.map((value, index) => { + if (!Number.isSafeInteger(value) || (value as number) < 0) { + failPolyMorphPrepare("invalid-index", `${path}[${index}]`, "expected an unsigned integer"); + } + return value as number; + }); + const triangles: [number, number, number][] = []; + if (mode === 4) { + if (indices.length % 3 !== 0) { + failPolyMorphPrepare("invalid-primitive", path, "incomplete triangle list"); + } + for (let index = 0; index < indices.length; index += 3) { + triangles.push([indices[index]!, indices[index + 1]!, indices[index + 2]!]); + } + } else if (mode === 5) { + for (let index = 2; index < indices.length; index += 1) { + triangles.push(index % 2 === 0 + ? [indices[index - 2]!, indices[index - 1]!, indices[index]!] + : [indices[index - 1]!, indices[index - 2]!, indices[index]!]); + } + } else if (mode === 6) { + for (let index = 2; index < indices.length; index += 1) { + triangles.push([indices[0]!, indices[index - 1]!, indices[index]!]); + } + } else { + failPolyMorphPrepare( + "unsupported-primitive", + path, + `mode ${mode} is not triangles, triangle strip, or triangle fan`, + ); + } + return triangles.filter(([a, b, c]) => a !== b && b !== c && a !== c); +} + +const IDENTITY: PolyMorphMat4 = [ + 1, 0, 0, 0, + 0, 1, 0, 0, + 0, 0, 1, 0, + 0, 0, 0, 1, +]; + +function multiply(left: PolyMorphMat4, right: PolyMorphMat4): PolyMorphMat4 { + const output = new Array(16).fill(0); + for (let column = 0; column < 4; column += 1) { + for (let row = 0; row < 4; row += 1) { + for (let axis = 0; axis < 4; axis += 1) { + output[column * 4 + row]! += + left[axis * 4 + row]! * right[column * 4 + axis]!; + } + } + } + return output as unknown as PolyMorphMat4; +} + +function nodeMatrix(node: JsonObject, path: string): PolyMorphMat4 { + if (node.matrix !== undefined) { + if ( + !Array.isArray(node.matrix) + || node.matrix.length !== 16 + || node.matrix.some((value: unknown) => + typeof value !== "number" || !Number.isFinite(value)) + || node.translation !== undefined + || node.rotation !== undefined + || node.scale !== undefined + ) { + failPolyMorphPrepare("invalid-node", path, "invalid or ambiguous node matrix"); + } + return node.matrix as unknown as PolyMorphMat4; + } + const translation = node.translation ?? [0, 0, 0]; + const rotation = node.rotation ?? [0, 0, 0, 1]; + const scale = node.scale ?? [1, 1, 1]; + if ( + !Array.isArray(translation) + || translation.length !== 3 + || !Array.isArray(rotation) + || rotation.length !== 4 + || !Array.isArray(scale) + || scale.length !== 3 + || [...translation, ...rotation, ...scale].some( + (value) => typeof value !== "number" || !Number.isFinite(value), + ) + ) { + failPolyMorphPrepare("invalid-node", path, "invalid node TRS"); + } + const [x, y, z, w] = rotation as number[]; + const length = Math.hypot(x!, y!, z!, w!); + if (!(length > 0)) { + failPolyMorphPrepare("invalid-node", `${path}.rotation`, "zero quaternion"); + } + const qx = x! / length; + const qy = y! / length; + const qz = z! / length; + const qw = w! / length; + const [sx, sy, sz] = scale as number[]; + const [tx, ty, tz] = translation as number[]; + return [ + (1 - 2 * (qy * qy + qz * qz)) * sx!, + (2 * (qx * qy + qz * qw)) * sx!, + (2 * (qx * qz - qy * qw)) * sx!, + 0, + (2 * (qx * qy - qz * qw)) * sy!, + (1 - 2 * (qx * qx + qz * qz)) * sy!, + (2 * (qy * qz + qx * qw)) * sy!, + 0, + (2 * (qx * qz + qy * qw)) * sz!, + (2 * (qy * qz - qx * qw)) * sz!, + (1 - 2 * (qx * qx + qy * qy)) * sz!, + 0, + tx!, ty!, tz!, 1, + ]; +} + +function sceneInstances(json: JsonObject): readonly { + readonly nodeIndex: number; + readonly nodeName: string; + readonly meshIndex: number; + readonly matrix: PolyMorphMat4; +}[] { + const nodes = Array.isArray(json.nodes) ? json.nodes as JsonObject[] : []; + const scenes = Array.isArray(json.scenes) ? json.scenes as JsonObject[] : []; + let roots: readonly number[]; + if (scenes.length > 0) { + const sceneIndex = json.scene ?? 0; + const selected = scenes[sceneIndex]?.nodes; + if (!Array.isArray(selected)) { + failPolyMorphPrepare("invalid-scene", "$.source.scene", "default scene has no nodes"); + } + roots = selected; + } else { + const children = new Set( + nodes.flatMap((node) => Array.isArray(node.children) ? node.children : []), + ); + roots = nodes.map((_, index) => index).filter((index) => !children.has(index)); + } + const instances: { + nodeIndex: number; + nodeName: string; + meshIndex: number; + matrix: PolyMorphMat4; + }[] = []; + const visit = ( + nodeIndex: number, + parentMatrix: PolyMorphMat4, + ancestry: ReadonlySet, + ): void => { + if (!Number.isSafeInteger(nodeIndex) || nodeIndex < 0 || nodeIndex >= nodes.length) { + failPolyMorphPrepare("invalid-node", "$.source.nodes", `unknown node ${nodeIndex}`); + } + if (ancestry.has(nodeIndex)) { + failPolyMorphPrepare("invalid-node", "$.source.nodes", `node cycle at ${nodeIndex}`); + } + const node = nodes[nodeIndex]!; + const matrix = multiply(parentMatrix, nodeMatrix(node, `$.source.nodes[${nodeIndex}]`)); + if (node.mesh !== undefined) { + if (!Number.isSafeInteger(node.mesh) || node.mesh < 0) { + failPolyMorphPrepare( + "invalid-node", + `$.source.nodes[${nodeIndex}].mesh`, + "invalid mesh index", + ); + } + instances.push({ + nodeIndex, + nodeName: typeof node.name === "string" && node.name.length > 0 + ? node.name + : `node-${nodeIndex}`, + meshIndex: node.mesh, + matrix, + }); + } + const next = new Set(ancestry).add(nodeIndex); + const children = node.children ?? []; + if (!Array.isArray(children)) { + failPolyMorphPrepare( + "invalid-node", + `$.source.nodes[${nodeIndex}].children`, + "expected an array", + ); + } + for (const child of children) visit(child, matrix, next); + }; + for (const root of roots) visit(root, IDENTITY, new Set()); + return instances; +} + +function parseMaterials(json: JsonObject): readonly PolyMorphGltfMaterial[] { + const rows = Array.isArray(json.materials) ? json.materials as JsonObject[] : []; + return rows.map((material, sourceIndex) => { + if (!material || typeof material !== "object" || Array.isArray(material)) { + failPolyMorphPrepare( + "invalid-material", + `$.source.materials[${sourceIndex}]`, + "expected a material object", + ); + } + const raw = material.pbrMetallicRoughness?.baseColorFactor ?? [1, 1, 1, 1]; + if ( + !Array.isArray(raw) + || raw.length !== 4 + || raw.some((value: unknown) => + typeof value !== "number" + || !Number.isFinite(value) + || value < 0 + || value > 1) + ) { + failPolyMorphPrepare( + "invalid-material", + `$.source.materials[${sourceIndex}]`, + "baseColorFactor must have four unit components", + ); + } + return { + sourceIndex, + name: typeof material.name === "string" && material.name.length > 0 + ? material.name + : `material-${sourceIndex}`, + color: [raw[0], raw[1], raw[2], raw[3]], + }; + }); +} + +function parsePrimitives( + json: JsonObject, + buffers: readonly Uint8Array[], + instances: ReturnType, +): readonly PolyMorphGltfInstance[] { + const readAccessor = createAccessorReader(json, buffers); + const meshes = Array.isArray(json.meshes) ? json.meshes as JsonObject[] : []; + return instances.map((instance) => { + const mesh = meshes[instance.meshIndex]; + if (!mesh || !Array.isArray(mesh.primitives) || mesh.primitives.length === 0) { + failPolyMorphPrepare( + "invalid-mesh", + `$.source.meshes[${instance.meshIndex}]`, + "mesh has no primitives", + ); + } + const targetNames = Array.isArray(mesh.extras?.targetNames) + ? mesh.extras.targetNames.map(String) + : []; + const primitives: PolyMorphGltfPrimitive[] = mesh.primitives.map( + (primitive: JsonObject, primitiveIndex: number) => { + const path = `$.source.meshes[${instance.meshIndex}].primitives[${primitiveIndex}]`; + if (!primitive?.attributes || !Number.isSafeInteger(primitive.attributes.POSITION)) { + failPolyMorphPrepare("invalid-primitive", path, "missing POSITION accessor"); + } + const positions = vec3Rows( + readAccessor(primitive.attributes.POSITION, `${path}.POSITION`), + `${path}.POSITION`, + ); + const sourceIndices = primitive.indices === undefined + ? positions.map((_, index) => index) + : readAccessor(primitive.indices, `${path}.indices`); + const triangles = triangleIndices(primitive.mode ?? 4, sourceIndices, `${path}.indices`); + if ( + triangles.length === 0 + || triangles.some((triangle) => + triangle.some((index) => index >= positions.length)) + ) { + failPolyMorphPrepare("invalid-index", path, "triangle indices are empty or out of range"); + } + const targets = Array.isArray(primitive.targets) + ? primitive.targets.map((target: JsonObject, targetIndex: number) => { + if (!Number.isSafeInteger(target.POSITION)) { + failPolyMorphPrepare( + "invalid-morph", + `${path}.targets[${targetIndex}]`, + "missing POSITION accessor", + ); + } + const positionDeltas = vec3Rows( + readAccessor( + target.POSITION, + `${path}.targets[${targetIndex}].POSITION`, + ), + `${path}.targets[${targetIndex}].POSITION`, + ); + if (positionDeltas.length !== positions.length) { + failPolyMorphPrepare( + "invalid-morph", + `${path}.targets[${targetIndex}]`, + "target does not cover POSITION", + ); + } + return { + index: targetIndex, + name: targetNames[targetIndex] ?? `morph-${targetIndex}`, + positionDeltas, + }; + }) + : []; + const materialIndex = primitive.material ?? -1; + if (!Number.isSafeInteger(materialIndex) || materialIndex < -1) { + failPolyMorphPrepare( + "invalid-material", + `${path}.material`, + "invalid material index", + ); + } + return { + primitiveIndex, + materialIndex, + positions, + triangles, + targets, + }; + }, + ); + return { + ...instance, + meshName: typeof mesh.name === "string" && mesh.name.length > 0 + ? mesh.name + : `mesh-${instance.meshIndex}`, + primitives, + }; + }); +} + +export async function loadPolyMorphGltf( + sourcePath: string, +): Promise { + const absolute = resolve(sourcePath); + const source = new Uint8Array(await readFile(absolute)); + const extension = extname(absolute).toLowerCase(); + let format: "glb" | "gltf"; + let json: JsonObject; + let binary: Uint8Array | null = null; + if (extension === ".glb") { + format = "glb"; + ({ json, binary } = decodeGlb(source)); + } else if (extension === ".gltf") { + format = "gltf"; + json = parseJson(source, "$.source"); + } else { + failPolyMorphPrepare( + "unsupported-format", + "$.source.path", + "expected .gltf or .glb", + ); + } + if ( + json.asset?.version !== "2.0" + || (Array.isArray(json.extensionsRequired) && json.extensionsRequired.length > 0) + ) { + failPolyMorphPrepare( + "unsupported-gltf", + "$.source", + "expected core glTF 2.0 without required extensions", + ); + } + const loaded = await loadBuffers(json, absolute, binary); + const instances = parsePrimitives( + json, + loaded.buffers, + sceneInstances(json), + ); + if (instances.length === 0) { + failPolyMorphPrepare("empty-model", "$.source", "default scene has no mesh instances"); + } + const sourceSha256 = sha256(source); + const contentSha256 = sha256(new TextEncoder().encode(JSON.stringify({ + source: sourceSha256, + buffers: loaded.files, + }))); + return { + format, + sourceBytes: source.byteLength, + sourceSha256, + contentSha256, + materials: parseMaterials(json), + instances, + }; +} diff --git a/packages/morph/src/prepare/index.ts b/packages/morph/src/prepare/index.ts new file mode 100644 index 00000000..c7888274 --- /dev/null +++ b/packages/morph/src/prepare/index.ts @@ -0,0 +1,31 @@ +export { + parsePolyMorphPrepareConfig, + readPolyMorphPrepareConfig, + resolvePolyMorphSourcePath, +} from "./config.js"; +export { + buildPolyMorphPreparedPackage, + compilePolyMorphSource, +} from "./compile.js"; +export { + failPolyMorphPrepare, + PolyMorphPrepareError, +} from "./error.js"; +export { loadPolyMorphGltf } from "./gltf.js"; +export { preparePolyMorphModel } from "./prepare.js"; +export { POLY_MORPH_PREPARE_SCHEMA } from "./types.js"; +export type { + PolyMorphGltfDocument, + PolyMorphGltfInstance, + PolyMorphGltfMaterial, + PolyMorphGltfPrimitive, + PolyMorphGltfTarget, + PolyMorphPreparedPackage, + PolyMorphPreparedSource, + PolyMorphPrepareConfig, + PolyMorphPrepareOptions, + PolyMorphPrepareProfile, + PolyMorphPrepareReport, + PolyMorphPrepareSource, + PolyMorphPrepareTransform, +} from "./types.js"; diff --git a/packages/morph/src/prepare/prepare.test.ts b/packages/morph/src/prepare/prepare.test.ts new file mode 100644 index 00000000..bde60516 --- /dev/null +++ b/packages/morph/src/prepare/prepare.test.ts @@ -0,0 +1,1098 @@ +import { + mkdtemp, + readFile, + readdir, + rm, + writeFile, +} from "node:fs/promises"; +import { join } from "node:path"; +import { tmpdir } from "node:os"; +import { afterEach, describe, expect, it } from "vitest"; +import { compilePolyMorphSource } from "./compile.js"; +import { + parsePolyMorphPrepareConfig, + readPolyMorphPrepareConfig, + resolvePolyMorphSourcePath, +} from "./config.js"; +import { PolyMorphPrepareError } from "./error.js"; +import { loadPolyMorphGltf } from "./gltf.js"; +import { preparePolyMorphModel } from "./prepare.js"; +import { + POLY_MORPH_PREPARE_SCHEMA, + type PolyMorphGltfDocument, + type PolyMorphPrepareConfig, +} from "./types.js"; + +const roots: string[] = []; + +afterEach(async () => { + await Promise.all(roots.splice(0).map((root) => + rm(root, { recursive: true, force: true }))); +}); + +async function temporaryRoot(): Promise { + const root = await mkdtemp(join(tmpdir(), "polycss-morph-prepare-")); + roots.push(root); + return root; +} + +function concatenate(parts: readonly Uint8Array[]): Uint8Array { + const bytes = new Uint8Array(parts.reduce((sum, part) => sum + part.byteLength, 0)); + let offset = 0; + for (const part of parts) { + bytes.set(part, offset); + offset += part.byteLength; + } + return bytes; +} + +function floatBytes(values: readonly number[]): Uint8Array { + const bytes = new Uint8Array(values.length * 4); + const view = new DataView(bytes.buffer); + values.forEach((value, index) => view.setFloat32(index * 4, value, true)); + return bytes; +} + +function uint16Bytes(values: readonly number[]): Uint8Array { + const bytes = new Uint8Array(values.length * 2); + const view = new DataView(bytes.buffer); + values.forEach((value, index) => view.setUint16(index * 2, value, true)); + return bytes; +} + +function padded(bytes: Uint8Array, alignment: number, fill = 0): Uint8Array { + const length = Math.ceil(bytes.byteLength / alignment) * alignment; + const output = new Uint8Array(length); + output.fill(fill); + output.set(bytes); + return output; +} + +function fixtureDocument(bufferByteLength: number): Record { + return { + asset: { version: "2.0", generator: "polycss-morph-test" }, + buffers: [{ byteLength: bufferByteLength }], + bufferViews: [ + { buffer: 0, byteOffset: 0, byteLength: 48 }, + { buffer: 0, byteOffset: 48, byteLength: 12 }, + { buffer: 0, byteOffset: 60, byteLength: 48 }, + ], + accessors: [ + { bufferView: 0, componentType: 5126, count: 4, type: "VEC3" }, + { bufferView: 1, componentType: 5123, count: 6, type: "SCALAR" }, + { bufferView: 2, componentType: 5126, count: 4, type: "VEC3" }, + ], + materials: [{ + name: "Signal Orange", + pbrMetallicRoughness: { baseColorFactor: [1, 0.25, 0.05, 1] }, + }], + meshes: [{ + name: "Kite", + extras: { targetNames: ["Lift"] }, + primitives: [{ + attributes: { POSITION: 0 }, + indices: 1, + material: 0, + mode: 4, + targets: [{ POSITION: 2 }], + }], + }], + nodes: [{ name: "Kite Root", mesh: 0 }], + scenes: [{ nodes: [0] }], + scene: 0, + }; +} + +function fixtureBinary(): Uint8Array { + return concatenate([ + floatBytes([ + -1, -0.5, 0, + 1, -0.5, 0, + 0.65, 0.5, 0, + -0.65, 0.5, 0, + ]), + uint16Bytes([0, 1, 2, 0, 2, 3]), + floatBytes([ + 0, 0, 0, + 0, 0, 0, + 0, 0.6, 0.2, + 0, 0.6, 0.2, + ]), + ]); +} + +function encodeGlb(json: Record, binary: Uint8Array): Uint8Array { + const jsonBytes = padded( + new TextEncoder().encode(JSON.stringify(json)), + 4, + 0x20, + ); + const binBytes = padded(binary, 4); + const output = new Uint8Array(12 + 8 + jsonBytes.length + 8 + binBytes.length); + const view = new DataView(output.buffer); + view.setUint32(0, 0x46546c67, true); + view.setUint32(4, 2, true); + view.setUint32(8, output.byteLength, true); + view.setUint32(12, jsonBytes.byteLength, true); + view.setUint32(16, 0x4e4f534a, true); + output.set(jsonBytes, 20); + const binOffset = 20 + jsonBytes.byteLength; + view.setUint32(binOffset, binBytes.byteLength, true); + view.setUint32(binOffset + 4, 0x004e4942, true); + output.set(binBytes, binOffset + 8); + return output; +} + +function fixtureConfig(sourcePath = "kite.glb") { + return { + schema: POLY_MORPH_PREPARE_SCHEMA, + identity: { + id: "morph-kite", + name: "Morph Kite", + revision: "1.0.0", + }, + profile: "morph-regions", + source: { + path: sourcePath, + id: "authored-kite", + kind: "authored", + uri: "urn:polycss:morph-kite", + license: "MIT", + }, + transform: { + axes: ["x", "y", "z"], + signs: [1, 1, 1], + scale: 64, + center: true, + }, + morphAliases: { Lift: "lift" }, + controls: [{ + id: "kite-tip", + anchor: [0, 32, 0], + axis: [0, 1, 0], + radius: 18, + minimum: 0, + maximum: 1, + initial: 0, + targets: [{ targetId: "lift", scale: 1 }], + }], + springs: [{ + id: "kite-tip-spring", + controlId: "kite-tip", + stiffness: 120, + damping: 18, + }], + animations: [{ + id: "kite-flap", + durationMs: 1000, + loop: true, + channels: [{ + target: "control-value", + targetId: "kite-tip", + interpolation: "linear", + timesMs: [0, 500, 1000], + values: [[0], [1], [0]], + }], + }], + budgets: { + maxVertices: 8, + maxPolygons: 4, + maxLeaves: 4, + maxFrames: 1, + maxJoints: 1, + maxResources: 4, + maxBytes: 1_000_000, + }, + }; +} + +type MutablePrepareConfig = ReturnType; +type MutableGltfDocument = Record; + +function expectPrepareFailure( + action: () => unknown, + code: string, + path?: string, +): void { + try { + action(); + } catch (error) { + expect(error).toBeInstanceOf(PolyMorphPrepareError); + expect(error).toMatchObject({ + code, + ...(path ? { path } : {}), + }); + return; + } + throw new Error(`Expected ${code}.`); +} + +async function expectPrepareRejection( + action: Promise, + code: string, + path?: string, +): Promise { + try { + await action; + } catch (error) { + expect(error).toBeInstanceOf(PolyMorphPrepareError); + expect(error).toMatchObject({ + code, + ...(path ? { path } : {}), + }); + return; + } + throw new Error(`Expected ${code}.`); +} + +async function writeEmbeddedGltf( + root: string, + name: string, + mutate: (document: MutableGltfDocument) => void = () => {}, +): Promise { + const binary = fixtureBinary(); + const document = fixtureDocument(binary.byteLength) as MutableGltfDocument; + document.buffers[0].uri = + `data:application/octet-stream;base64,${Buffer.from(binary).toString("base64")}`; + mutate(document); + const sourcePath = join(root, `${name}.gltf`); + await writeFile(sourcePath, JSON.stringify(document)); + return sourcePath; +} + +function mixedSizeConfig(): PolyMorphPrepareConfig { + return { + schema: POLY_MORPH_PREPARE_SCHEMA, + identity: { + id: "mixed-triangles", + name: "Mixed Triangles", + revision: "1.0.0", + }, + profile: "static-prepared", + source: { + path: "mixed-triangles.gltf", + id: "mixed-triangles-source", + kind: "generated", + uri: "urn:polycss:mixed-triangles", + license: "MIT", + }, + transform: { + axes: ["x", "y", "z"], + signs: [1, 1, 1], + scale: 1, + center: false, + }, + morphAliases: {}, + controls: [], + springs: [], + animations: [], + budgets: { + maxVertices: 9, + maxPolygons: 3, + maxLeaves: 3, + maxFrames: 1, + maxJoints: 1, + maxResources: 4, + maxBytes: 1_000_000, + }, + }; +} + +function mixedSizeSource(): PolyMorphGltfDocument { + return { + format: "gltf", + sourceBytes: 1, + sourceSha256: "a".repeat(64), + contentSha256: "b".repeat(64), + materials: [{ + sourceIndex: 0, + name: "White", + color: [1, 1, 1, 1], + }], + instances: [{ + nodeIndex: 0, + nodeName: "Mixed Triangles", + meshIndex: 0, + meshName: "Mixed Triangles", + matrix: [ + 1, 0, 0, 0, + 0, 1, 0, 0, + 0, 0, 1, 0, + 0, 0, 0, 1, + ], + primitives: [{ + primitiveIndex: 0, + materialIndex: 0, + positions: [ + [0, 0, 0], [12, 0, 0], [0, 5, 0], + [0, 10, 0], [24, 10, 0], [0, 19, 0], + [0, 30, 0], [7, 30, 0], [0, 47, 0], + ], + triangles: [ + [0, 1, 2], + [3, 4, 5], + [6, 7, 8], + ], + targets: [], + }], + }], + }; +} + +async function writeFixture( + root: string, +): Promise<{ readonly configPath: string; readonly sourcePath: string }> { + const binary = fixtureBinary(); + const sourcePath = join(root, "kite.glb"); + const configPath = join(root, "prepare.json"); + await writeFile(sourcePath, encodeGlb(fixtureDocument(binary.byteLength), binary)); + await writeFile(configPath, JSON.stringify(fixtureConfig("kite.glb"))); + return { configPath, sourcePath }; +} + +async function packageBytes(root: string): Promise> { + const paths = [ + "assets/solid-triangles-000.png", + "manifest.json", + "model.json", + ]; + return new Map(await Promise.all(paths.map(async (path) => [ + path, + new Uint8Array(await readFile(join(root, path))), + ] as const))); +} + +describe("preparePolyMorphModel", () => { + it("produces byte-identical complete packages and writes the manifest last", async () => { + const root = await temporaryRoot(); + const { configPath } = await writeFixture(root); + const firstRoot = join(root, "first"); + const secondRoot = join(root, "second"); + const first = await preparePolyMorphModel({ configPath, outputRoot: firstRoot }); + const second = await preparePolyMorphModel({ configPath, outputRoot: secondRoot }); + + expect(first.files).toEqual([ + "assets/solid-triangles-000.png", + "manifest.json", + "model.json", + ]); + expect(first.writeOrder.at(-1)).toBe("manifest.json"); + expect((await readdir(firstRoot)).sort()).toEqual([ + "assets", + "manifest.json", + "model.json", + ]); + expect(await readdir(join(firstRoot, "assets"))).toEqual([ + "solid-triangles-000.png", + ]); + const firstBytes = await packageBytes(firstRoot); + const secondBytes = await packageBytes(secondRoot); + for (const [path, bytes] of firstBytes) { + expect(secondBytes.get(path)).toEqual(bytes); + } + expect(first.manifestSha256).toBe(second.manifestSha256); + expect(first.model.profile).toBe("morph-regions"); + expect(first.model.deformation.kind).toBe("morph-regions"); + expect(first.model.render.leaves).toHaveLength(2); + expect(first.model.render.leaves.every((leaf) => + leaf.strategy === "solid-triangle" + && leaf.width === 32 + && leaf.height === 32 + && leaf.atlas === null + && leaf.fallback !== null + && leaf.fallback.width === leaf.fallback.atlas.width + && leaf.fallback.height === leaf.fallback.atlas.height + && leaf.fallback.atlas.resourcePath === "assets/solid-triangles-000.png" + && (leaf.fallback.width !== 32 || leaf.fallback.height !== 32))).toBe(true); + expect(new Set(first.model.render.leaves.map((leaf) => + `${leaf.fallback!.atlas.x}:${leaf.fallback!.atlas.y}`, + )).size).toBe(2); + expect( + [...firstBytes.get("assets/solid-triangles-000.png")!.subarray(0, 8)], + ).toEqual([137, 80, 78, 71, 13, 10, 26, 10]); + const manifest = JSON.parse( + new TextDecoder().decode(firstBytes.get("manifest.json")), + ) as { resources: readonly { path: string }[] }; + expect(manifest.resources.map(({ path }) => path)).toEqual([ + "assets/solid-triangles-000.png", + "model.json", + ]); + }); + + it("passes exact check mode and detects byte or inventory drift", async () => { + const root = await temporaryRoot(); + const { configPath } = await writeFixture(root); + const outputRoot = join(root, "package"); + await preparePolyMorphModel({ configPath, outputRoot }); + const checked = await preparePolyMorphModel({ + configPath, + outputRoot, + check: true, + }); + expect(checked).toMatchObject({ + checked: true, + changed: false, + writeOrder: [], + }); + + await writeFile(join(outputRoot, "model.json"), "drift"); + await expect(preparePolyMorphModel({ + configPath, + outputRoot, + check: true, + })).rejects.toMatchObject>({ code: "drift" }); + + const repaired = await preparePolyMorphModel({ configPath, outputRoot }); + expect(repaired.changed).toBe(true); + const unchanged = await preparePolyMorphModel({ configPath, outputRoot }); + expect(unchanged).toMatchObject({ + checked: false, + changed: false, + writeOrder: [], + }); + await writeFile(join(outputRoot, "extra.json"), "{}"); + await expect(preparePolyMorphModel({ + configPath, + outputRoot, + check: true, + })).rejects.toMatchObject>({ code: "drift" }); + }); + + it("leaves the prior package untouched when source preparation fails", async () => { + const root = await temporaryRoot(); + const { configPath, sourcePath } = await writeFixture(root); + const outputRoot = join(root, "package"); + await preparePolyMorphModel({ configPath, outputRoot }); + const before = await packageBytes(outputRoot); + + await writeFile(sourcePath, new Uint8Array([0, 1, 2])); + await expect(preparePolyMorphModel({ + configPath, + outputRoot, + })).rejects.toBeInstanceOf(PolyMorphPrepareError); + const after = await packageBytes(outputRoot); + for (const [path, bytes] of before) expect(after.get(path)).toEqual(bytes); + }); + + it("rejects the removed atlas authoring option", async () => { + const root = await temporaryRoot(); + const binary = fixtureBinary(); + const sourcePath = join(root, "kite.glb"); + const configPath = join(root, "prepare.json"); + await writeFile(sourcePath, encodeGlb(fixtureDocument(binary.byteLength), binary)); + await writeFile(configPath, JSON.stringify({ + ...fixtureConfig(), + atlas: { format: "webp", tileSize: 8 }, + })); + await expect(preparePolyMorphModel({ + configPath, + outputRoot: join(root, "package"), + })).rejects.toMatchObject>({ + code: "invalid-config", + path: "$", + }); + }); +}); + +describe("compilePolyMorphSource", () => { + it("generates deterministic polygon-sized fallbacks for mixed triangles", async () => { + const first = await compilePolyMorphSource( + mixedSizeSource(), + mixedSizeConfig(), + ); + const second = await compilePolyMorphSource( + mixedSizeSource(), + mixedSizeConfig(), + ); + const fallbacks = first.model.render.leaves.map((leaf) => leaf.fallback!); + const pagePaths = new Set(first.fallbackAtlasPages.map((page) => page.path)); + + expect(first.fallbackAtlasPages).toHaveLength(1); + expect(first.fallbackAtlasPages.every(({ width, height }) => + width < 128 && height < 128)).toBe(true); + expect(new Set( + fallbacks.map(({ width, height }) => `${width}:${height}`), + ).size) + .toBeGreaterThan(1); + expect(fallbacks.every(({ width, height, atlas }) => + width === atlas.width + && height === atlas.height + && pagePaths.has(atlas.resourcePath) + && atlas.x + atlas.width <= atlas.pageWidth + && atlas.y + atlas.height <= atlas.pageHeight)).toBe(true); + expect(second.model.render.leaves.map((leaf) => leaf.fallback)) + .toEqual(first.model.render.leaves.map((leaf) => leaf.fallback)); + expect(second.fallbackAtlasPages.map(({ path, width, height, bytes }) => ({ + path, + width, + height, + bytes: [...bytes], + }))).toEqual(first.fallbackAtlasPages.map(({ path, width, height, bytes }) => ({ + path, + width, + height, + bytes: [...bytes], + }))); + }); + + it("preserves front-face winding through reflective authoring transforms", async () => { + const config = mixedSizeConfig(); + config.transform.signs = [-1, 1, 1]; + const compiled = await compilePolyMorphSource(mixedSizeSource(), config); + expect(compiled.model.topology.polygons[0]!.vertexIndices).toEqual([0, 2, 1]); + expect(compiled.model.topology.normals[0]).toEqual([0, 0, 1]); + }); +}); + +describe("prepare config validation", () => { + it("accepts both generic authoring profiles and resolves local sources", () => { + const morph = fixtureConfig(); + (morph.morphAliases as Record).Bend = "bend"; + const parsedMorph = parsePolyMorphPrepareConfig(morph); + expect(Object.keys(parsedMorph.morphAliases)).toEqual(["Bend", "Lift"]); + + const staticPrepared = fixtureConfig(); + staticPrepared.profile = "static-prepared"; + staticPrepared.source.kind = "open-data"; + staticPrepared.transform.axes = ["z", "x", "y"]; + staticPrepared.transform.signs = [-1, 1, -1]; + staticPrepared.transform.center = false; + staticPrepared.morphAliases = {} as MutablePrepareConfig["morphAliases"]; + expect(parsePolyMorphPrepareConfig(staticPrepared)).toMatchObject({ + profile: "static-prepared", + source: { kind: "open-data" }, + transform: { center: false }, + morphAliases: {}, + }); + + expect(resolvePolyMorphSourcePath("/tmp/morph/prepare.json", "model/kite.glb")) + .toBe("/tmp/morph/model/kite.glb"); + }); + + const invalidCases: readonly { + readonly name: string; + readonly code: string; + readonly path: string; + readonly mutate: (config: MutablePrepareConfig) => void; + }[] = [ + { + name: "unknown root keys", + code: "invalid-config", + path: "$", + mutate: (config) => { + (config as unknown as Record).atlas = {}; + }, + }, + { + name: "schema revisions", + code: "invalid-config", + path: "$.schema", + mutate: (config) => { + config.schema = "polycss-morph.prepare@2"; + }, + }, + { + name: "unnormalized ids", + code: "invalid-config", + path: "$.identity.id", + mutate: (config) => { + config.identity.id = "Morph Kite"; + }, + }, + { + name: "blank names", + code: "invalid-config", + path: "$.identity.name", + mutate: (config) => { + config.identity.name = " "; + }, + }, + { + name: "non-semver revisions", + code: "invalid-config", + path: "$.identity.revision", + mutate: (config) => { + config.identity.revision = "v1"; + }, + }, + { + name: "unsupported profiles", + code: "invalid-config", + path: "$.profile", + mutate: (config) => { + config.profile = "joint-skin"; + }, + }, + { + name: "unknown source kinds", + code: "invalid-config", + path: "$.source.kind", + mutate: (config) => { + config.source.kind = "private"; + }, + }, + { + name: "absolute source paths", + code: "unsafe-path", + path: "$.source.path", + mutate: (config) => { + config.source.path = "/tmp/kite.glb"; + }, + }, + { + name: "parent source paths", + code: "unsafe-path", + path: "$.source.path", + mutate: (config) => { + config.source.path = "../kite.glb"; + }, + }, + { + name: "filesystem source URIs", + code: "invalid-config", + path: "$.source.uri", + mutate: (config) => { + config.source.uri = "file:///tmp/kite.glb"; + }, + }, + { + name: "duplicate axes", + code: "invalid-config", + path: "$.transform.axes", + mutate: (config) => { + config.transform.axes = ["x", "x", "z"]; + }, + }, + { + name: "invalid axis signs", + code: "invalid-config", + path: "$.transform.signs", + mutate: (config) => { + config.transform.signs = [1, 0, 1]; + }, + }, + { + name: "non-positive scales", + code: "invalid-config", + path: "$.transform.scale", + mutate: (config) => { + config.transform.scale = 0; + }, + }, + { + name: "non-boolean centering", + code: "invalid-config", + path: "$.transform.center", + mutate: (config) => { + config.transform.center = "yes" as unknown as boolean; + }, + }, + { + name: "duplicate target aliases", + code: "invalid-config", + path: "$.morphAliases", + mutate: (config) => { + (config.morphAliases as Record).Bend = "lift"; + }, + }, + { + name: "morph profiles without aliases", + code: "invalid-config", + path: "$.morphAliases", + mutate: (config) => { + config.morphAliases = {} as MutablePrepareConfig["morphAliases"]; + }, + }, + { + name: "static profiles with aliases", + code: "invalid-config", + path: "$.morphAliases", + mutate: (config) => { + config.profile = "static-prepared"; + }, + }, + { + name: "non-array controls", + code: "invalid-config", + path: "$.controls", + mutate: (config) => { + config.controls = {} as MutablePrepareConfig["controls"]; + }, + }, + { + name: "non-array springs", + code: "invalid-config", + path: "$.springs", + mutate: (config) => { + config.springs = {} as MutablePrepareConfig["springs"]; + }, + }, + { + name: "non-array animations", + code: "invalid-config", + path: "$.animations", + mutate: (config) => { + config.animations = {} as MutablePrepareConfig["animations"]; + }, + }, + { + name: "zero budgets", + code: "invalid-config", + path: "$.budgets.maxBytes", + mutate: (config) => { + config.budgets.maxBytes = 0; + }, + }, + { + name: "fractional budgets", + code: "invalid-config", + path: "$.budgets.maxVertices", + mutate: (config) => { + config.budgets.maxVertices = 1.5; + }, + }, + ]; + + it.each(invalidCases)("rejects $name", ({ code, path, mutate }) => { + const config = structuredClone(fixtureConfig()); + mutate(config); + expectPrepareFailure(() => parsePolyMorphPrepareConfig(config), code, path); + }); + + it("rejects non-object configs, unreadable JSON, and escaped resolved paths", async () => { + expectPrepareFailure( + () => parsePolyMorphPrepareConfig(null), + "invalid-config", + "$", + ); + const root = await temporaryRoot(); + const configPath = join(root, "prepare.json"); + await writeFile(configPath, "{"); + await expectPrepareRejection( + readPolyMorphPrepareConfig(configPath), + "invalid-config", + "$", + ); + expectPrepareFailure( + () => resolvePolyMorphSourcePath(configPath, "../kite.glb"), + "unsafe-path", + "$.source.path", + ); + }); +}); + +describe("loadPolyMorphGltf", () => { + it("loads a JSON glTF with a source-relative external buffer", async () => { + const root = await temporaryRoot(); + const binary = fixtureBinary(); + const document = fixtureDocument(binary.byteLength); + (document.buffers as Record[])[0]!.uri = "kite.bin"; + await writeFile(join(root, "kite.bin"), binary); + await writeFile(join(root, "kite.gltf"), JSON.stringify(document)); + + const loaded = await loadPolyMorphGltf(join(root, "kite.gltf")); + expect(loaded.format).toBe("gltf"); + expect(loaded.instances[0]?.primitives[0]?.targets[0]?.name).toBe("Lift"); + expect(loaded.contentSha256).toMatch(/^[a-f0-9]{64}$/u); + }); + + it("loads embedded triangle strips with implicit scenes and node TRS", async () => { + const root = await temporaryRoot(); + const sourcePath = await writeEmbeddedGltf(root, "strip", (document) => { + document.accessors[1].count = 4; + document.meshes[0].primitives[0].mode = 5; + delete document.meshes[0].primitives[0].material; + delete document.meshes[0].extras; + delete document.meshes[0].name; + document.nodes[0] = { + mesh: 0, + translation: [1, 2, 3], + rotation: [0, 0, Math.SQRT1_2, Math.SQRT1_2], + scale: [2, 3, 4], + }; + delete document.scenes; + delete document.scene; + delete document.materials; + }); + + const loaded = await loadPolyMorphGltf(sourcePath); + expect(loaded.format).toBe("gltf"); + expect(loaded.instances[0]).toMatchObject({ + nodeName: "node-0", + meshName: "mesh-0", + primitives: [{ + materialIndex: -1, + triangles: [[0, 1, 2], [2, 1, 0]], + targets: [{ name: "morph-0" }], + }], + }); + }); + + const invalidGltfCases: readonly { + readonly name: string; + readonly code: string; + readonly mutate: (document: MutableGltfDocument) => void; + }[] = [ + { + name: "glTF 1 assets", + code: "unsupported-gltf", + mutate: (document) => { + document.asset.version = "1.0"; + }, + }, + { + name: "required extensions", + code: "unsupported-gltf", + mutate: (document) => { + document.extensionsRequired = ["KHR_draco_mesh_compression"]; + }, + }, + { + name: "missing buffers", + code: "missing-buffer", + mutate: (document) => { + document.buffers = []; + }, + }, + { + name: "negative buffer lengths", + code: "invalid-buffer", + mutate: (document) => { + document.buffers[0].byteLength = -1; + }, + }, + { + name: "unsupported data URIs", + code: "unsupported-uri", + mutate: (document) => { + document.buffers[0].uri = "data:text/plain;base64,AA=="; + }, + }, + { + name: "mismatched buffer lengths", + code: "invalid-buffer", + mutate: (document) => { + document.buffers[0].byteLength -= 4; + }, + }, + { + name: "sparse accessors", + code: "invalid-accessor", + mutate: (document) => { + document.accessors[0].sparse = {}; + }, + }, + { + name: "unknown accessor types", + code: "invalid-accessor", + mutate: (document) => { + document.accessors[0].type = "VEC5"; + }, + }, + { + name: "absent accessor buffers", + code: "invalid-accessor", + mutate: (document) => { + document.bufferViews[0].buffer = 1; + }, + }, + { + name: "invalid accessor strides", + code: "invalid-accessor", + mutate: (document) => { + document.bufferViews[0].byteStride = 1; + }, + }, + { + name: "misaligned accessor strides", + code: "invalid-accessor", + mutate: (document) => { + document.bufferViews[0].byteStride = 14; + }, + }, + { + name: "oversized accessor strides", + code: "invalid-accessor", + mutate: (document) => { + document.bufferViews[0].byteStride = 256; + }, + }, + { + name: "accessors outside their buffer views", + code: "invalid-accessor", + mutate: (document) => { + document.accessors[0].byteOffset = 12; + }, + }, + { + name: "buffer views outside their buffers", + code: "invalid-accessor", + mutate: (document) => { + document.bufferViews[0].byteLength = 200; + }, + }, + { + name: "scalar positions", + code: "invalid-accessor", + mutate: (document) => { + document.accessors[0].type = "SCALAR"; + }, + }, + { + name: "vector indices", + code: "invalid-index", + mutate: (document) => { + document.accessors[1].type = "VEC3"; + document.accessors[1].count = 2; + }, + }, + { + name: "non-triangle primitive modes", + code: "unsupported-primitive", + mutate: (document) => { + document.meshes[0].primitives[0].mode = 1; + }, + }, + { + name: "incomplete triangle lists", + code: "invalid-primitive", + mutate: (document) => { + document.accessors[1].count = 5; + }, + }, + { + name: "missing scene nodes", + code: "invalid-scene", + mutate: (document) => { + document.scenes[0] = {}; + }, + }, + { + name: "unknown scene nodes", + code: "invalid-node", + mutate: (document) => { + document.scenes[0].nodes = [9]; + }, + }, + { + name: "node cycles", + code: "invalid-node", + mutate: (document) => { + document.nodes[0].children = [0]; + }, + }, + { + name: "negative mesh indices", + code: "invalid-node", + mutate: (document) => { + document.nodes[0].mesh = -1; + }, + }, + { + name: "non-array node children", + code: "invalid-node", + mutate: (document) => { + document.nodes[0].children = {}; + }, + }, + { + name: "short node matrices", + code: "invalid-node", + mutate: (document) => { + document.nodes[0].matrix = new Array(15).fill(0); + }, + }, + { + name: "zero node quaternions", + code: "invalid-node", + mutate: (document) => { + document.nodes[0].rotation = [0, 0, 0, 0]; + }, + }, + { + name: "empty meshes", + code: "invalid-mesh", + mutate: (document) => { + document.meshes[0].primitives = []; + }, + }, + { + name: "missing positions", + code: "invalid-primitive", + mutate: (document) => { + delete document.meshes[0].primitives[0].attributes.POSITION; + }, + }, + { + name: "morph targets without positions", + code: "invalid-morph", + mutate: (document) => { + document.meshes[0].primitives[0].targets[0] = {}; + }, + }, + { + name: "short morph targets", + code: "invalid-morph", + mutate: (document) => { + document.accessors[2].count = 3; + }, + }, + { + name: "negative material indices", + code: "invalid-material", + mutate: (document) => { + document.meshes[0].primitives[0].material = -2; + }, + }, + { + name: "null materials", + code: "invalid-material", + mutate: (document) => { + document.materials[0] = null; + }, + }, + { + name: "out-of-range material colors", + code: "invalid-material", + mutate: (document) => { + document.materials[0].pbrMetallicRoughness.baseColorFactor = [2, 0, 0, 1]; + }, + }, + { + name: "empty default scenes", + code: "empty-model", + mutate: (document) => { + document.scenes[0].nodes = []; + }, + }, + ]; + + it.each(invalidGltfCases)("rejects $name", async ({ name, code, mutate }) => { + const root = await temporaryRoot(); + const sourcePath = await writeEmbeddedGltf(root, name.replaceAll(" ", "-"), mutate); + await expectPrepareRejection(loadPolyMorphGltf(sourcePath), code); + }); + + it("rejects malformed source containers", async () => { + const root = await temporaryRoot(); + const invalidJson = join(root, "invalid.gltf"); + const nonObjectJson = join(root, "array.gltf"); + const unsupported = join(root, "model.obj"); + const truncatedGlb = join(root, "truncated.glb"); + const invalidHeaderGlb = join(root, "header.glb"); + await writeFile(invalidJson, "{"); + await writeFile(nonObjectJson, "[]"); + await writeFile(unsupported, ""); + await writeFile(truncatedGlb, new Uint8Array([1, 2, 3])); + const binary = fixtureBinary(); + const invalidHeader = encodeGlb(fixtureDocument(binary.byteLength), binary); + invalidHeader[0] = 0; + await writeFile(invalidHeaderGlb, invalidHeader); + + await expectPrepareRejection(loadPolyMorphGltf(invalidJson), "invalid-gltf"); + await expectPrepareRejection(loadPolyMorphGltf(nonObjectJson), "invalid-gltf"); + await expectPrepareRejection(loadPolyMorphGltf(unsupported), "unsupported-format"); + await expectPrepareRejection(loadPolyMorphGltf(truncatedGlb), "invalid-glb"); + await expectPrepareRejection(loadPolyMorphGltf(invalidHeaderGlb), "invalid-glb"); + }); +}); diff --git a/packages/morph/src/prepare/prepare.ts b/packages/morph/src/prepare/prepare.ts new file mode 100644 index 00000000..034ce5a3 --- /dev/null +++ b/packages/morph/src/prepare/prepare.ts @@ -0,0 +1,217 @@ +import { + existsSync, +} from "node:fs"; +import { + mkdir, + mkdtemp, + readFile, + readdir, + rename, + rm, + writeFile, +} from "node:fs/promises"; +import { + basename, + dirname, + isAbsolute, + join, + parse, + relative, + resolve, + sep, +} from "node:path"; +import { readPolyMorphPrepareConfig, resolvePolyMorphSourcePath } from "./config.js"; +import { buildPolyMorphPreparedPackage } from "./compile.js"; +import { failPolyMorphPrepare } from "./error.js"; +import { loadPolyMorphGltf } from "./gltf.js"; +import type { + PolyMorphPrepareOptions, + PolyMorphPrepareReport, +} from "./types.js"; + +function safeOutputRoot(value: unknown): string { + if (typeof value !== "string" || value.length === 0) { + failPolyMorphPrepare("invalid-argument", "$.outputRoot", "expected a path"); + } + const target = resolve(value); + if (target === parse(target).root) { + failPolyMorphPrepare("unsafe-path", "$.outputRoot", "filesystem root is forbidden"); + } + return target; +} + +async function inventory(root: string, current = ""): Promise { + const directory = join(root, current); + const entries = await readdir(directory, { withFileTypes: true }); + const files: string[] = []; + for (const entry of entries.sort((left, right) => + left.name < right.name ? -1 : left.name > right.name ? 1 : 0)) { + const path = current === "" ? entry.name : `${current}/${entry.name}`; + if (entry.isSymbolicLink()) { + failPolyMorphPrepare("unsafe-output", path, "symbolic links are forbidden"); + } + if (entry.isDirectory()) { + files.push(...await inventory(root, path)); + } else if (entry.isFile()) { + files.push(path); + } else { + failPolyMorphPrepare("unsafe-output", path, "unsupported filesystem entry"); + } + } + return files; +} + +async function packageDrift( + target: string, + expected: ReadonlyMap, +): Promise<{ readonly path: string; readonly message: string } | null> { + if (!existsSync(target)) { + return { path: "$.outputRoot", message: "prepared package is missing" }; + } + const actualPaths = await inventory(target); + const expectedPaths = [...expected.keys()].sort(); + if ( + actualPaths.length !== expectedPaths.length + || actualPaths.some((path, index) => path !== expectedPaths[index]) + ) { + return { + path: "$.outputRoot", + message: `inventory differs: expected ${expectedPaths.join(", ")}`, + }; + } + for (const path of expectedPaths) { + const actual = new Uint8Array(await readFile(join(target, path))); + const wanted = expected.get(path)!; + if ( + actual.byteLength !== wanted.byteLength + || actual.some((value, index) => value !== wanted[index]) + ) { + return { path, message: "prepared bytes differ" }; + } + } + return null; +} + +async function assertExactPackage( + target: string, + expected: ReadonlyMap, +): Promise { + const drift = await packageDrift(target, expected); + if (drift) failPolyMorphPrepare("drift", drift.path, drift.message); +} + +async function writePackage( + target: string, + files: ReadonlyMap, +): Promise { + const parent = dirname(target); + await mkdir(parent, { recursive: true }); + const staging = await mkdtemp(join(parent, `.polycss-morph-${basename(target)}-`)); + const backup = `${target}.previous-${process.pid}`; + const nonManifestPaths = [...files.keys()] + .filter((path) => path !== "manifest.json") + .sort(); + const writeOrder = [...nonManifestPaths, "manifest.json"]; + let movedPrevious = false; + try { + for (const path of writeOrder) { + const absolute = resolve(staging, path); + const rel = relative(staging, absolute); + if (rel === ".." || rel.startsWith(`..${sep}`) || isAbsolute(rel)) { + failPolyMorphPrepare("unsafe-path", path, "package path escapes staging"); + } + await mkdir(dirname(absolute), { recursive: true }); + await writeFile(absolute, files.get(path)!); + } + if (existsSync(backup)) { + failPolyMorphPrepare( + "unsafe-output", + "$.outputRoot", + `backup already exists at ${backup}`, + ); + } + if (existsSync(target)) { + await rename(target, backup); + movedPrevious = true; + } + await rename(staging, target); + if (movedPrevious) { + await rm(backup, { recursive: true, force: true }).catch(() => {}); + } + return writeOrder; + } catch (error) { + if (movedPrevious && !existsSync(target) && existsSync(backup)) { + await rename(backup, target); + } + throw error; + } finally { + if (existsSync(staging)) { + await rm(staging, { recursive: true, force: true }); + } + } +} + +export async function preparePolyMorphModel( + options: PolyMorphPrepareOptions, +): Promise { + if (!options || typeof options !== "object") { + failPolyMorphPrepare("invalid-argument", "$", "expected prepare options"); + } + if (typeof options.configPath !== "string" || options.configPath.length === 0) { + failPolyMorphPrepare("invalid-argument", "$.configPath", "expected a path"); + } + if (options.check !== undefined && typeof options.check !== "boolean") { + failPolyMorphPrepare("invalid-argument", "$.check", "expected a boolean"); + } + const configPath = resolve(options.configPath); + const outputRoot = safeOutputRoot(options.outputRoot); + const config = await readPolyMorphPrepareConfig(configPath); + const sourcePath = resolvePolyMorphSourcePath(configPath, config.source.path); + const source = await loadPolyMorphGltf(sourcePath); + const built = await buildPolyMorphPreparedPackage(source, config); + const files = new Map(built.package.files); + files.set("manifest.json", built.package.manifestBytes); + const expectedPaths = [...files.keys()].sort(); + if (options.check) { + await assertExactPackage(outputRoot, files); + return { + config, + source, + model: built.prepared.model, + manifest: built.package.manifest, + manifestSha256: built.package.manifestSha256, + outputRoot, + files: expectedPaths, + writeOrder: [], + checked: true, + changed: false, + }; + } + if (await packageDrift(outputRoot, files) === null) { + return { + config, + source, + model: built.prepared.model, + manifest: built.package.manifest, + manifestSha256: built.package.manifestSha256, + outputRoot, + files: expectedPaths, + writeOrder: [], + checked: false, + changed: false, + }; + } + const writeOrder = await writePackage(outputRoot, files); + return { + config, + source, + model: built.prepared.model, + manifest: built.package.manifest, + manifestSha256: built.package.manifestSha256, + outputRoot, + files: expectedPaths, + writeOrder, + checked: false, + changed: true, + }; +} diff --git a/packages/morph/src/prepare/solidTriangleAtlas.ts b/packages/morph/src/prepare/solidTriangleAtlas.ts new file mode 100644 index 00000000..000fbd7b --- /dev/null +++ b/packages/morph/src/prepare/solidTriangleAtlas.ts @@ -0,0 +1,412 @@ +import { deflateSync } from "node:zlib"; +import { + computeTextureAtlasPlanPublic, + packTextureAtlasPlansWithScale, + type PackedTextureAtlasEntry, + type Polygon, + type TextureAtlasPlan, +} from "@layoutit/polycss"; +import type { + PolyMorphMat4, + PolyMorphRenderFallback, + PolyMorphVec3, +} from "../contracts/index.js"; +import { failPolyMorphPrepare } from "./error.js"; + +const BLEED = 1.5; +const SAMPLES_PER_AXIS = 4; +const PNG_SIGNATURE = Uint8Array.from([ + 137, 80, 78, 71, 13, 10, 26, 10, +]); + +export interface PolyMorphTriangleAtlasInput { + readonly vertexIndices: readonly [number, number, number]; + readonly vertices: readonly [PolyMorphVec3, PolyMorphVec3, PolyMorphVec3]; + readonly materialId: string; + readonly leafMatrix: PolyMorphMat4; +} + +export interface PolyMorphTriangleAtlasPage { + readonly path: string; + readonly width: number; + readonly height: number; + readonly bytes: Uint8Array; +} + +export interface PolyMorphTriangleAtlas { + readonly fallbacks: readonly PolyMorphRenderFallback[]; + readonly pages: readonly PolyMorphTriangleAtlasPage[]; +} + +function rounded(value: number): number { + const result = Number(value.toFixed(10)); + return Object.is(result, -0) ? 0 : result; +} + +function parseMatrix(value: string, path: string): PolyMorphMat4 { + const values = value.split(",").map(Number); + if (values.length !== 16 || values.some((part) => !Number.isFinite(part))) { + failPolyMorphPrepare( + "unrenderable-triangle", + path, + "PolyCSS produced an invalid fallback matrix", + ); + } + return values.map(rounded) as unknown as PolyMorphMat4; +} + +function multiply(left: PolyMorphMat4, right: PolyMorphMat4): PolyMorphMat4 { + const output = new Array(16).fill(0); + for (let column = 0; column < 4; column += 1) { + for (let row = 0; row < 4; row += 1) { + let sum = 0; + for (let axis = 0; axis < 4; axis += 1) { + sum += left[axis * 4 + row]! * right[column * 4 + axis]!; + } + output[column * 4 + row] = rounded(sum); + } + } + return output as unknown as PolyMorphMat4; +} + +function invertAffine(value: PolyMorphMat4, path: string): PolyMorphMat4 { + const a00 = value[0], a01 = value[4], a02 = value[8]; + const a10 = value[1], a11 = value[5], a12 = value[9]; + const a20 = value[2], a21 = value[6], a22 = value[10]; + const determinant = + a00 * (a11 * a22 - a12 * a21) + - a01 * (a10 * a22 - a12 * a20) + + a02 * (a10 * a21 - a11 * a20); + if (!Number.isFinite(determinant) || Math.abs(determinant) <= 1e-12) { + failPolyMorphPrepare( + "unrenderable-triangle", + path, + "solid triangle matrix is not invertible", + ); + } + const inverseDeterminant = 1 / determinant; + const r00 = (a11 * a22 - a12 * a21) * inverseDeterminant; + const r01 = (a02 * a21 - a01 * a22) * inverseDeterminant; + const r02 = (a01 * a12 - a02 * a11) * inverseDeterminant; + const r10 = (a12 * a20 - a10 * a22) * inverseDeterminant; + const r11 = (a00 * a22 - a02 * a20) * inverseDeterminant; + const r12 = (a02 * a10 - a00 * a12) * inverseDeterminant; + const r20 = (a10 * a21 - a11 * a20) * inverseDeterminant; + const r21 = (a01 * a20 - a00 * a21) * inverseDeterminant; + const r22 = (a00 * a11 - a01 * a10) * inverseDeterminant; + const tx = value[12], ty = value[13], tz = value[14]; + return [ + rounded(r00), rounded(r10), rounded(r20), 0, + rounded(r01), rounded(r11), rounded(r21), 0, + rounded(r02), rounded(r12), rounded(r22), 0, + rounded(-(r00 * tx + r01 * ty + r02 * tz)), + rounded(-(r10 * tx + r11 * ty + r12 * tz)), + rounded(-(r20 * tx + r21 * ty + r22 * tz)), + 1, + ]; +} + +function shiftedMatrix(value: string, padding: number): string { + if (padding === 0) return value; + const matrix = parseMatrix(value, "$.render.fallback"); + const shifted = [...matrix] as number[]; + shifted[12] -= padding * matrix[0] + padding * matrix[4]; + shifted[13] -= padding * matrix[1] + padding * matrix[5]; + shifted[14] -= padding * matrix[2] + padding * matrix[6]; + return shifted.map(rounded).join(","); +} + +function surfaceInfo(vertices: readonly PolyMorphVec3[]): { + readonly normal: PolyMorphVec3; + readonly plane: number; +} | null { + const [a, b, c] = vertices; + const ab = [b![0] - a![0], b![1] - a![1], b![2] - a![2]]; + const ac = [c![0] - a![0], c![1] - a![1], c![2] - a![2]]; + const cross = [ + ab[1]! * ac[2]! - ab[2]! * ac[1]!, + ab[2]! * ac[0]! - ab[0]! * ac[2]!, + ab[0]! * ac[1]! - ab[1]! * ac[0]!, + ]; + const length = Math.hypot(...cross); + if (length <= 1e-12) return null; + const normal: PolyMorphVec3 = [ + cross[0]! / length, + cross[1]! / length, + cross[2]! / length, + ]; + return { + normal, + plane: normal[0] * a![0] + normal[1] * a![1] + normal[2] * a![2], + }; +} + +function buildSeamEdges( + inputs: readonly PolyMorphTriangleAtlasInput[], +): readonly ReadonlySet[] { + const surfaces = inputs.map((input) => surfaceInfo(input.vertices)); + const owners = new Map>(); + for (let polygonIndex = 0; polygonIndex < inputs.length; polygonIndex += 1) { + const indices = inputs[polygonIndex]!.vertexIndices; + for (let edgeIndex = 0; edgeIndex < 3; edgeIndex += 1) { + const left = indices[edgeIndex]!; + const right = indices[(edgeIndex + 1) % 3]!; + const key = left < right ? `${left}:${right}` : `${right}:${left}`; + const owner = { polygonIndex, edgeIndex }; + const rows = owners.get(key); + if (rows) rows.push(owner); + else owners.set(key, [owner]); + } + } + const result = Array.from({ length: inputs.length }, () => new Set()); + for (const rows of owners.values()) { + for (let leftIndex = 0; leftIndex < rows.length; leftIndex += 1) { + for (let rightIndex = leftIndex + 1; rightIndex < rows.length; rightIndex += 1) { + const left = rows[leftIndex]!; + const right = rows[rightIndex]!; + const leftInput = inputs[left.polygonIndex]!; + const rightInput = inputs[right.polygonIndex]!; + const leftSurface = surfaces[left.polygonIndex]; + const rightSurface = surfaces[right.polygonIndex]; + if ( + leftInput.materialId !== rightInput.materialId + || !leftSurface + || !rightSurface + ) { + continue; + } + const dot = + leftSurface.normal[0] * rightSurface.normal[0] + + leftSurface.normal[1] * rightSurface.normal[1] + + leftSurface.normal[2] * rightSurface.normal[2]; + if ( + dot < 1 - 1e-6 + || Math.abs(leftSurface.plane - rightSurface.plane) > 1e-5 + ) { + continue; + } + result[left.polygonIndex]!.add(left.edgeIndex); + result[right.polygonIndex]!.add(right.edgeIndex); + } + } + } + return result; +} + +function createPlans( + inputs: readonly PolyMorphTriangleAtlasInput[], + seamEdges: readonly ReadonlySet[], +): TextureAtlasPlan[] { + return inputs.map((input, index) => { + const polygon: Polygon = { + color: "#ffffff", + vertices: input.vertices.map(([x, y, z]) => [y, x, z]), + }; + const plan = computeTextureAtlasPlanPublic(polygon, index, { + tileSize: 1, + layerElevation: 1, + seamBleed: 0, + }); + if (!plan) { + failPolyMorphPrepare( + "unrenderable-triangle", + `$.render.leaves[${index}].fallback`, + "PolyCSS produced no per-polygon atlas plan", + ); + } + const edges = seamEdges[index]!; + const padding = edges.size > 0 ? Math.ceil(BLEED) : 0; + return { + ...plan, + matrix: shiftedMatrix(plan.matrix, padding), + canvasW: plan.canvasW + padding * 2, + canvasH: plan.canvasH + padding * 2, + screenPts: plan.screenPts.map((value) => value + padding), + seamBleed: edges.size > 0 ? BLEED : undefined, + seamBleedEdges: new Set(edges), + seamBleedEdgeAmounts: edges.size > 0 + ? new Map([...edges].map((edge) => [edge, BLEED])) + : undefined, + }; + }); +} + +function covered( + points: readonly number[], + seamEdges: ReadonlySet, + x: number, + y: number, +): boolean { + let area = 0; + for (let index = 0; index < points.length; index += 2) { + const next = (index + 2) % points.length; + area += points[index]! * points[next + 1]! - points[next]! * points[index + 1]!; + } + const orientation = area >= 0 ? 1 : -1; + for (let index = 0; index < points.length; index += 2) { + const edgeIndex = index / 2; + const next = (index + 2) % points.length; + const ax = points[index]!, ay = points[index + 1]!; + const bx = points[next]!, by = points[next + 1]!; + const dx = bx - ax, dy = by - ay; + const length = Math.hypot(dx, dy); + if (length <= 1e-12) return false; + const distance = orientation * (dx * (y - ay) - dy * (x - ax)) / length; + if (distance < -(seamEdges.has(edgeIndex) ? BLEED : 0)) return false; + } + return true; +} + +function alphaAt(entry: PackedTextureAtlasEntry, x: number, y: number): number { + let samples = 0; + const seamEdges = entry.seamBleedEdges ?? new Set(); + for (let sampleY = 0; sampleY < SAMPLES_PER_AXIS; sampleY += 1) { + for (let sampleX = 0; sampleX < SAMPLES_PER_AXIS; sampleX += 1) { + if (covered( + entry.screenPts, + seamEdges, + x + (sampleX + 0.5) / SAMPLES_PER_AXIS, + y + (sampleY + 0.5) / SAMPLES_PER_AXIS, + )) { + samples += 1; + } + } + } + return Math.round(samples * 255 / (SAMPLES_PER_AXIS * SAMPLES_PER_AXIS)); +} + +function crc32(bytes: Uint8Array): number { + let crc = 0xffffffff; + for (const byte of bytes) { + crc ^= byte; + for (let bit = 0; bit < 8; bit += 1) { + crc = (crc >>> 1) ^ (0xedb88320 & -(crc & 1)); + } + } + return (crc ^ 0xffffffff) >>> 0; +} + +function chunk(type: string, data: Uint8Array): Uint8Array { + const typeBytes = new TextEncoder().encode(type); + const output = new Uint8Array(12 + data.byteLength); + const view = new DataView(output.buffer); + view.setUint32(0, data.byteLength); + output.set(typeBytes, 4); + output.set(data, 8); + view.setUint32(8 + data.byteLength, crc32(output.subarray(4, 8 + data.byteLength))); + return output; +} + +function concatenate(parts: readonly Uint8Array[]): Uint8Array { + const output = new Uint8Array( + parts.reduce((total, part) => total + part.byteLength, 0), + ); + let offset = 0; + for (const part of parts) { + output.set(part, offset); + offset += part.byteLength; + } + return output; +} + +function png( + width: number, + height: number, + entries: readonly PackedTextureAtlasEntry[], +): Uint8Array { + const stride = 1 + width * 4; + const pixels = new Uint8Array(height * stride); + for (const entry of entries) { + for (let y = 0; y < entry.canvasH; y += 1) { + for (let x = 0; x < entry.canvasW; x += 1) { + const alpha = alphaAt(entry, x, y); + if (alpha === 0) continue; + const pixel = (entry.y + y) * stride + 1 + (entry.x + x) * 4; + pixels[pixel] = 255; + pixels[pixel + 1] = 255; + pixels[pixel + 2] = 255; + pixels[pixel + 3] = alpha; + } + } + } + const header = new Uint8Array(13); + const view = new DataView(header.buffer); + view.setUint32(0, width); + view.setUint32(4, height); + header[8] = 8; + header[9] = 6; + return concatenate([ + PNG_SIGNATURE, + chunk("IHDR", header), + chunk("IDAT", deflateSync(pixels, { level: 9 })), + chunk("IEND", new Uint8Array()), + ]); +} + +function pagePath(index: number): string { + return `assets/solid-triangles-${String(index).padStart(3, "0")}.png`; +} + +export function buildPolyMorphSolidTriangleAtlas( + inputs: readonly PolyMorphTriangleAtlasInput[], +): PolyMorphTriangleAtlas { + const plans = createPlans(inputs, buildSeamEdges(inputs)); + const { packed, atlasScale } = packTextureAtlasPlansWithScale( + plans, + 1, + null, + "local", + ); + if (atlasScale !== 1) { + failPolyMorphPrepare( + "invalid-atlas", + "$.render.fallback", + "per-polygon fallback atlas must retain CSS-pixel resolution", + ); + } + const fallbacks = inputs.map((input, index): PolyMorphRenderFallback => { + const entry = packed.entries[index]; + if (!entry) { + failPolyMorphPrepare( + "invalid-atlas", + `$.render.leaves[${index}].fallback`, + "per-polygon fallback was not packed", + ); + } + const page = packed.pages[entry.pageIndex]!; + const atlasMatrix = parseMatrix( + entry.atlasMatrix, + `$.render.leaves[${index}].fallback.matrix`, + ); + return { + width: entry.canvasW, + height: entry.canvasH, + matrixFromLeaf: multiply( + invertAffine( + input.leafMatrix, + `$.render.leaves[${index}].matrix`, + ), + atlasMatrix, + ), + atlas: { + resourcePath: pagePath(entry.pageIndex), + x: entry.x, + y: entry.y, + width: entry.canvasW, + height: entry.canvasH, + pageWidth: page.width, + pageHeight: page.height, + }, + }; + }); + const pages = packed.pages.map((page, index): PolyMorphTriangleAtlasPage => ({ + path: pagePath(index), + width: page.width, + height: page.height, + bytes: png(page.width, page.height, page.entries), + })); + return { fallbacks, pages }; +} diff --git a/packages/morph/src/prepare/types.ts b/packages/morph/src/prepare/types.ts new file mode 100644 index 00000000..52e6a3d2 --- /dev/null +++ b/packages/morph/src/prepare/types.ts @@ -0,0 +1,126 @@ +import type { + PolyMorphAnimationClip, + PolyMorphBudgets, + PolyMorphControl, + PolyMorphMat4, + PolyMorphModel, + PolyMorphProfile, + PolyMorphSpring, + PolyMorphVec3, +} from "../contracts/index.js"; +import type { + PolyMorphBuiltPackage, + PolyMorphPackageManifest, +} from "../package/index.js"; + +export const POLY_MORPH_PREPARE_SCHEMA = "polycss-morph.prepare@1" as const; + +export type PolyMorphPrepareProfile = Extract< + PolyMorphProfile, + "morph-regions" | "static-prepared" +>; + +export interface PolyMorphPrepareSource { + readonly path: string; + readonly id: string; + readonly kind: "authored" | "generated" | "open-data"; + readonly uri: string; + readonly license: string; +} + +export interface PolyMorphPrepareTransform { + readonly axes: readonly ["x" | "y" | "z", "x" | "y" | "z", "x" | "y" | "z"]; + readonly signs: readonly [-1 | 1, -1 | 1, -1 | 1]; + readonly scale: number; + readonly center: boolean; +} + +export interface PolyMorphPrepareConfig { + readonly schema: typeof POLY_MORPH_PREPARE_SCHEMA; + readonly identity: { + readonly id: string; + readonly name: string; + readonly revision: string; + }; + readonly profile: PolyMorphPrepareProfile; + readonly source: PolyMorphPrepareSource; + readonly transform: PolyMorphPrepareTransform; + readonly morphAliases: Readonly>; + readonly controls: readonly PolyMorphControl[]; + readonly springs: readonly PolyMorphSpring[]; + readonly animations: readonly PolyMorphAnimationClip[]; + readonly budgets: PolyMorphBudgets; +} + +export interface PolyMorphGltfMaterial { + readonly sourceIndex: number; + readonly name: string; + readonly color: readonly [number, number, number, number]; +} + +export interface PolyMorphGltfTarget { + readonly index: number; + readonly name: string; + readonly positionDeltas: readonly PolyMorphVec3[]; +} + +export interface PolyMorphGltfPrimitive { + readonly primitiveIndex: number; + readonly materialIndex: number; + readonly positions: readonly PolyMorphVec3[]; + readonly triangles: readonly (readonly [number, number, number])[]; + readonly targets: readonly PolyMorphGltfTarget[]; +} + +export interface PolyMorphGltfInstance { + readonly nodeIndex: number; + readonly nodeName: string; + readonly meshIndex: number; + readonly meshName: string; + readonly matrix: PolyMorphMat4; + readonly primitives: readonly PolyMorphGltfPrimitive[]; +} + +export interface PolyMorphGltfDocument { + readonly format: "glb" | "gltf"; + readonly sourceBytes: number; + readonly sourceSha256: string; + readonly contentSha256: string; + readonly materials: readonly PolyMorphGltfMaterial[]; + readonly instances: readonly PolyMorphGltfInstance[]; +} + +export interface PolyMorphPreparedSource { + readonly model: PolyMorphModel; + readonly fallbackAtlasPages: readonly { + readonly path: string; + readonly width: number; + readonly height: number; + readonly bytes: Uint8Array; + }[]; +} + +export interface PolyMorphPrepareOptions { + readonly configPath: string; + readonly outputRoot: string; + readonly check?: boolean; +} + +export interface PolyMorphPrepareReport { + readonly config: PolyMorphPrepareConfig; + readonly source: PolyMorphGltfDocument; + readonly model: PolyMorphModel; + readonly manifest: PolyMorphPackageManifest; + readonly manifestSha256: string; + readonly outputRoot: string; + readonly files: readonly string[]; + readonly writeOrder: readonly string[]; + readonly checked: boolean; + readonly changed: boolean; +} + +export interface PolyMorphPreparedPackage { + readonly source: PolyMorphGltfDocument; + readonly prepared: PolyMorphPreparedSource; + readonly package: PolyMorphBuiltPackage; +} diff --git a/packages/morph/src/render/index.ts b/packages/morph/src/render/index.ts new file mode 100644 index 00000000..3089d902 --- /dev/null +++ b/packages/morph/src/render/index.ts @@ -0,0 +1,13 @@ +export { mountPolyMorphModel } from "./mount.js"; +export { PolyMorphRenderError } from "./renderError.js"; +export type { + PolyMorphApplyResult, + PolyMorphCamera, + PolyMorphLeafHandle, + PolyMorphLeafUpdate, + PolyMorphMountedModel, + PolyMorphMountOptions, + PolyMorphRenderStats, + PolyMorphRetainedUpdate, + PolyMorphShapeUpdate, +} from "./types.js"; diff --git a/packages/morph/src/render/mount.test.ts b/packages/morph/src/render/mount.test.ts new file mode 100644 index 00000000..c0bcb670 --- /dev/null +++ b/packages/morph/src/render/mount.test.ts @@ -0,0 +1,439 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { PolyMorphContractError } from "../contracts/index.js"; +import { + clonePolyMorphFixture, + createPolyMorphModelFixture, + POLY_MORPH_IDENTITY_MATRIX, +} from "../testing/modelFixture.js"; +import { + mountPolyMorphModel, + PolyMorphRenderError, +} from "./index.js"; + +function imageResources(paths: readonly string[]) { + return new Map(paths.map((path, index) => [ + path, + { + descriptor: { + path, + role: "image" as const, + mediaType: "image/png", + bytes: 1, + sha256: "0".repeat(64), + }, + bytes: new Uint8Array([index]), + }, + ])); +} + +function overrideUserAgent(value: string): () => void { + const navigator = document.defaultView!.navigator; + const prior = Object.getOwnPropertyDescriptor(navigator, "userAgent"); + Object.defineProperty(navigator, "userAgent", { + configurable: true, + value, + }); + return () => { + if (prior) Object.defineProperty(navigator, "userAgent", prior); + else delete (navigator as Navigator & { userAgent?: string }).userAgent; + }; +} + +function createTwoLeafFixture() { + const fixture = clonePolyMorphFixture(createPolyMorphModelFixture()); + fixture.topology.vertices.push([1, 1, 0]); + fixture.topology.normals.push([0, 0, 1]); + fixture.topology.polygons.push({ + id: "accent-panel", + vertexIndices: [1, 3, 2], + normalIndices: [1, 3, 2], + }); + fixture.materials.push({ + id: "cyan", + color: [0, 0.8, 1, 1], + }); + fixture.render.shapes.push({ + id: "accent", + matrix: POLY_MORPH_IDENTITY_MATRIX, + }); + fixture.render.leaves.push({ + id: "accent-panel-leaf", + polygonId: "accent-panel", + shapeId: "accent", + materialId: "cyan", + strategy: "solid-quad", + width: 64, + height: 64, + matrix: POLY_MORPH_IDENTITY_MATRIX, + atlas: null, + fallback: null, + }); + return fixture; +} + +describe("mountPolyMorphModel", () => { + let host: HTMLElement; + + beforeEach(() => { + document.body.replaceChildren(); + host = document.createElement("div"); + document.body.appendChild(host); + let objectUrl = 0; + vi.spyOn(document.defaultView!.URL, "createObjectURL") + .mockImplementation(() => `blob:test-${objectUrl += 1}`); + vi.spyOn(document.defaultView!.URL, "revokeObjectURL") + .mockImplementation(() => undefined); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + it("mounts every declared shape and leaf exactly once", () => { + const mounted = mountPolyMorphModel(host, createTwoLeafFixture()); + expect(mounted.shapeElements.size).toBe(2); + expect(mounted.leafHandles.size).toBe(2); + expect(mounted.modelElement.querySelectorAll(".polycss-morph-shape")).toHaveLength(2); + expect(mounted.modelElement.querySelectorAll(".polycss-morph-leaf")).toHaveLength(2); + expect(mounted.leafHandles.get("gem-panel-leaf")?.element.localName).toBe("u"); + expect(mounted.leafHandles.get("accent-panel-leaf")?.element.localName).toBe("b"); + expect(mounted.leafHandles.get("gem-panel-leaf")?.element.style.width).toBe(""); + expect(mounted.leafHandles.get("gem-panel-leaf")?.element.style.height).toBe(""); + expect(mounted.leafHandles.get("accent-panel-leaf")?.element.style.width).toBe("64px"); + expect(mounted.leafHandles.get("accent-panel-leaf")?.element.style.height).toBe("64px"); + expect(mounted.cameraElement.querySelector("style")).toBeNull(); + expect(mounted.stats).toMatchObject({ + mountCount: 1, + shapeRoots: 2, + leafCount: 2, + topologyConstructions: 1, + atlasConstructions: 0, + schedulerCount: 0, + }); + }); + + it("mounts polygon-sized slices across prepared pages when corner triangles are unavailable", () => { + const view = document.defaultView as Window & { + CSS?: { supports(property: string, value: string): boolean }; + }; + const priorCss = view.CSS; + Object.defineProperty(view, "CSS", { + configurable: true, + value: { supports: () => false }, + }); + const restoreUserAgent = overrideUserAgent( + "Mozilla/5.0 Version/18.0 Safari/605.1.15", + ); + try { + const fixture = createTwoLeafFixture(); + fixture.render.leaves[1]!.strategy = "solid-triangle"; + fixture.render.leaves[1]!.width = 32; + fixture.render.leaves[1]!.height = 32; + fixture.render.leaves[0]!.fallback = { + width: 7, + height: 5, + matrixFromLeaf: [ + 1, 0, 0, 0, + 0, 1, 0, 0, + 0, 0, 1, 0, + 4, 0, 0, 1, + ], + atlas: { + resourcePath: "assets/solid-triangles-000.png", + x: 1, + y: 1, + width: 7, + height: 5, + pageWidth: 24, + pageHeight: 12, + }, + }; + fixture.render.leaves[1]!.fallback = { + width: 11, + height: 9, + matrixFromLeaf: POLY_MORPH_IDENTITY_MATRIX, + atlas: { + resourcePath: "assets/solid-triangles-001.png", + x: 1, + y: 1, + width: 11, + height: 9, + pageWidth: 13, + pageHeight: 11, + }, + }; + const mounted = mountPolyMorphModel(host, fixture, { + resources: imageResources([ + "assets/solid-triangles-000.png", + "assets/solid-triangles-001.png", + ]), + }); + const leaves = [...mounted.leafHandles.values()].map(({ element }) => element); + expect(leaves.every((element) => element.localName === "s")).toBe(true); + expect(leaves.every((element) => + element.dataset.polyMorphResolvedStrategy === "atlas-slice" + && element.style.backgroundColor === "currentcolor")).toBe(true); + expect(leaves.map((element) => + element.style.getPropertyValue("mask-image"))).toEqual([ + 'url("blob:test-1")', + 'url("blob:test-2")', + ]); + expect(leaves.map((element) => + element.style.getPropertyValue("-webkit-mask-image"))).toEqual([ + 'url("blob:test-1")', + 'url("blob:test-2")', + ]); + expect(leaves.map((element) => [ + element.style.width, + element.style.height, + ])).toEqual([["7px", "5px"], ["11px", "9px"]]); + expect(document.defaultView!.URL.createObjectURL).toHaveBeenCalledTimes(2); + const translated = [...POLY_MORPH_IDENTITY_MATRIX] as number[]; + translated[12] = 10; + expect(mounted.apply({ + leaves: [{ + leafId: "gem-panel-leaf", + matrix: translated as typeof POLY_MORPH_IDENTITY_MATRIX, + }], + }).leafTransformWrites).toBe(1); + expect(leaves[0]!.style.transform).toBe( + "matrix3d(1,0,0,0,0,1,0,0,0,0,1,0,14,0,0,1)", + ); + mounted.assertStableDomIdentity(); + expect(mounted.stats.atlasConstructions).toBe(0); + } finally { + restoreUserAgent(); + Object.defineProperty(view, "CSS", { + configurable: true, + value: priorCss, + }); + } + }); + + it("fails before mounting when a browser needs an undeclared fallback", () => { + const view = document.defaultView as Window & { + CSS?: { supports(property: string, value: string): boolean }; + }; + const priorCss = view.CSS; + Object.defineProperty(view, "CSS", { + configurable: true, + value: { supports: () => false }, + }); + const restoreUserAgent = overrideUserAgent( + "Mozilla/5.0 Version/18.0 Safari/605.1.15", + ); + try { + expect(() => + mountPolyMorphModel(host, createPolyMorphModelFixture()) + ).toThrowError(PolyMorphRenderError); + expect(host.childElementCount).toBe(0); + } finally { + restoreUserAgent(); + Object.defineProperty(view, "CSS", { + configurable: true, + value: priorCss, + }); + } + }); + + it("keeps Firefox on the native border-triangle path", () => { + const view = document.defaultView as Window & { + CSS?: { supports(property: string, value: string): boolean }; + }; + const priorCss = view.CSS; + Object.defineProperty(view, "CSS", { + configurable: true, + value: { supports: () => false }, + }); + const restoreUserAgent = overrideUserAgent( + "Mozilla/5.0 Firefox/141.0", + ); + try { + const mounted = mountPolyMorphModel(host, createPolyMorphModelFixture()); + expect(mounted.leafHandles.get("gem-panel-leaf")!.element.localName).toBe("u"); + expect(document.defaultView!.URL.createObjectURL).not.toHaveBeenCalled(); + } finally { + restoreUserAgent(); + Object.defineProperty(view, "CSS", { + configurable: true, + value: priorCss, + }); + } + }); + + it("preserves identity and writes only caller-declared dirty rows", () => { + const mounted = mountPolyMorphModel(host, createTwoLeafFixture()); + const target = mounted.leafHandles.get("gem-panel-leaf")!.element; + const untouched = mounted.leafHandles.get("accent-panel-leaf")!.element; + const targetIdentity = target; + const untouchedTransform = untouched.style.transform; + const translated = [...POLY_MORPH_IDENTITY_MATRIX] as number[]; + translated[12] = 12; + + const first = mounted.apply({ + leaves: [ + { + leafId: "gem-panel-leaf", + matrix: translated as typeof POLY_MORPH_IDENTITY_MATRIX, + visible: false, + opacity: 0.5, + }, + ], + }); + expect(first).toMatchObject({ + dirtyLeavesVisited: 1, + leafTransformWrites: 1, + visibilityWrites: 1, + opacityWrites: 1, + domCreations: 0, + domRemovals: 0, + topologyConstructions: 0, + atlasRedraws: 0, + schedulerCallbacks: 0, + }); + expect(mounted.leafHandles.get("gem-panel-leaf")?.element).toBe(targetIdentity); + expect(untouched.style.transform).toBe(untouchedTransform); + mounted.assertStableDomIdentity(); + + const repeated = mounted.apply({ + leaves: [ + { + leafId: "gem-panel-leaf", + matrix: translated as typeof POLY_MORPH_IDENTITY_MATRIX, + visible: false, + opacity: 0.5, + }, + ], + }); + expect(repeated).toMatchObject({ + dirtyLeavesVisited: 1, + leafTransformWrites: 0, + visibilityWrites: 0, + opacityWrites: 0, + }); + }); + + it("applies model and shape matrices without visiting leaves", () => { + const mounted = mountPolyMorphModel(host, createTwoLeafFixture()); + const translated = [...POLY_MORPH_IDENTITY_MATRIX] as number[]; + translated[13] = 8; + const result = mounted.apply({ + modelMatrix: translated as typeof POLY_MORPH_IDENTITY_MATRIX, + shapes: [{ + shapeId: "accent", + matrix: translated as typeof POLY_MORPH_IDENTITY_MATRIX, + }], + }); + expect(result).toMatchObject({ + modelTransformWrites: 1, + shapeTransformWrites: 1, + leafTransformWrites: 0, + dirtyLeavesVisited: 0, + }); + }); + + it("maps image paint once and advances only validated rows", () => { + const fixture = clonePolyMorphFixture(createPolyMorphModelFixture()); + const leaf = fixture.render.leaves[0]!; + leaf.strategy = "atlas-slice"; + leaf.width = 4; + leaf.height = 4; + leaf.atlas = { + resourcePath: "assets/gem.webp", + x: 0, + y: 0, + width: 4, + height: 4, + pageWidth: 4, + pageHeight: 8, + }; + const mounted = mountPolyMorphModel(host, fixture, { + resources: imageResources(["assets/gem.webp"]), + }); + const element = mounted.leafHandles.get("gem-panel-leaf")!.element; + expect(element.localName).toBe("s"); + expect(element.style.width).toBe("4px"); + expect(element.style.height).toBe("4px"); + expect(element.style.backgroundImage).toContain("blob:test-1"); + expect(element.style.backfaceVisibility).toBe("visible"); + expect(document.defaultView!.URL.createObjectURL).toHaveBeenCalledOnce(); + + expect(mounted.apply({ + leaves: [{ leafId: "gem-panel-leaf", atlasRow: 1 }], + }).atlasRowWrites).toBe(1); + expect(element.style.backgroundPosition).toBe("0px -4px"); + expect(() => mounted.apply({ + leaves: [{ leafId: "gem-panel-leaf", atlasRow: 2 }], + })).toThrowError(PolyMorphRenderError); + expect(mounted.stats.atlasConstructions).toBe(0); + mounted.destroy(); + expect(document.defaultView!.URL.revokeObjectURL) + .toHaveBeenCalledWith("blob:test-1"); + }); + + it("rejects incomplete plans and unknown update handles", () => { + const incomplete = clonePolyMorphFixture(createPolyMorphModelFixture()); + incomplete.render.leaves = []; + expect(() => mountPolyMorphModel(host, incomplete)).toThrowError(PolyMorphContractError); + expect(host.childElementCount).toBe(0); + + const mounted = mountPolyMorphModel(host, createTwoLeafFixture()); + expect(() => mounted.apply({ + shapes: [{ shapeId: "missing-shape", matrix: POLY_MORPH_IDENTITY_MATRIX }], + })).toThrowError(PolyMorphRenderError); + expect(() => mounted.apply({ + leaves: [{ leafId: "missing-leaf", visible: false }], + })).toThrowError(PolyMorphRenderError); + + const translated = [...POLY_MORPH_IDENTITY_MATRIX] as number[]; + translated[12] = 9; + const accent = mounted.leafHandles.get("accent-panel-leaf")!.element; + const before = accent.style.transform; + const applyCount = mounted.stats.applyCount; + expect(() => mounted.apply({ + leaves: [ + { + leafId: "accent-panel-leaf", + matrix: translated as typeof POLY_MORPH_IDENTITY_MATRIX, + }, + { leafId: "gem-panel-leaf", atlasRow: 1 }, + ], + })).toThrowError(PolyMorphRenderError); + expect(accent.style.transform).toBe(before); + expect(mounted.stats.applyCount).toBe(applyCount); + }); + + it("never schedules animation and destroys idempotently", () => { + const requestFrame = vi.fn(() => 1); + const previous = globalThis.requestAnimationFrame; + Object.defineProperty(globalThis, "requestAnimationFrame", { + configurable: true, + value: requestFrame, + }); + try { + const mounted = mountPolyMorphModel(host, createPolyMorphModelFixture()); + expect(host.style.position).toBe("relative"); + mounted.apply({}); + expect(requestFrame).not.toHaveBeenCalled(); + mounted.destroy(); + mounted.destroy(); + expect(mounted.destroyed).toBe(true); + expect(host.childElementCount).toBe(0); + expect(host.style.position).toBe(""); + expect(() => mounted.assertStableDomIdentity()).toThrowError(PolyMorphRenderError); + } finally { + Object.defineProperty(globalThis, "requestAnimationFrame", { + configurable: true, + value: previous, + }); + } + }); + + it("updates the camera only when caller state changes", () => { + const mounted = mountPolyMorphModel(host, createPolyMorphModelFixture()); + expect(mounted.updateCamera()).toBe(false); + mounted.camera.update({ rotY: 30 }); + expect(mounted.updateCamera()).toBe(true); + expect(mounted.updateCamera()).toBe(false); + }); +}); diff --git a/packages/morph/src/render/mount.ts b/packages/morph/src/render/mount.ts new file mode 100644 index 00000000..4118570e --- /dev/null +++ b/packages/morph/src/render/mount.ts @@ -0,0 +1,615 @@ +import { + buildPolyCameraSceneTransform, + capturePolyCameraSnapshot, + createPolyCamera, + formatMatrix3dValues, + injectPolyBaseStyles, + isSolidTriangleSupported, +} from "@layoutit/polycss"; +import { + validatePolyMorphModel, + type PolyMorphAtlasSlice, + type PolyMorphMat4, + type PolyMorphRenderLeaf, +} from "../contracts/index.js"; +import { PolyMorphRenderError } from "./renderError.js"; +import type { + PolyMorphApplyResult, + PolyMorphLeafHandle, + PolyMorphLeafUpdate, + PolyMorphMountedModel, + PolyMorphMountOptions, + PolyMorphRetainedUpdate, + PolyMorphShapeUpdate, +} from "./types.js"; + +const TAG_BY_STRATEGY = { + "atlas-slice": "s", + "direct-image": "s", + "solid-quad": "b", + "solid-triangle": "u", +} as const; + +function fail(code: string, path: string, message: string): never { + throw new PolyMorphRenderError(code, path, message); +} + +function matrix(value: unknown, path: string): PolyMorphMat4 { + if ( + !Array.isArray(value) + || value.length !== 16 + || value.some((entry) => typeof entry !== "number" || !Number.isFinite(entry)) + ) { + fail("invalid-matrix", path, "expected sixteen finite components"); + } + return value as unknown as PolyMorphMat4; +} + +function matrixText(value: PolyMorphMat4): string { + return `matrix3d(${formatMatrix3dValues(value)})`; +} + +function multiply(left: PolyMorphMat4, right: PolyMorphMat4): PolyMorphMat4 { + const output = new Array(16).fill(0); + for (let column = 0; column < 4; column += 1) { + for (let row = 0; row < 4; row += 1) { + let sum = 0; + for (let axis = 0; axis < 4; axis += 1) { + sum += left[axis * 4 + row]! * right[column * 4 + axis]!; + } + output[column * 4 + row] = Object.is(sum, -0) ? 0 : sum; + } + } + return output as unknown as PolyMorphMat4; +} + +function resolvedLeafMatrix( + leaf: PolyMorphRenderLeaf, + value: PolyMorphMat4, + useFallback: boolean, +): PolyMorphMat4 { + return useFallback && leaf.fallback + ? multiply(value, leaf.fallback.matrixFromLeaf) + : value; +} + +function colorText(color: readonly [number, number, number, number]): string { + const [red, green, blue, alpha] = color; + return `rgba(${Math.round(red * 255)}, ${Math.round(green * 255)}, ${Math.round(blue * 255)}, ${alpha})`; +} + +function exactKeys( + value: unknown, + path: string, + required: readonly string[], + optional: readonly string[] = [], +): Record { + if (!value || typeof value !== "object" || Array.isArray(value)) { + fail("invalid-update", path, "expected an object"); + } + const allowed = new Set([...required, ...optional]); + const keys = Object.keys(value); + if (required.some((key) => !keys.includes(key)) || keys.some((key) => !allowed.has(key))) { + fail("invalid-update", path, `allowed keys are ${[...allowed].sort().join(", ")}`); + } + return value as Record; +} + +function orderedUnique(values: readonly string[], path: string): void { + const seen = new Set(); + for (const value of values) { + if (seen.has(value)) fail("duplicate-update", path, value); + seen.add(value); + } +} + +function validateShapeUpdate(value: unknown, path: string): PolyMorphShapeUpdate { + const input = exactKeys(value, path, ["matrix", "shapeId"]); + if (typeof input.shapeId !== "string") fail("invalid-update", `${path}.shapeId`, "expected a string"); + return { + shapeId: input.shapeId, + matrix: matrix(input.matrix, `${path}.matrix`), + }; +} + +function validateLeafUpdate(value: unknown, path: string): PolyMorphLeafUpdate { + const input = exactKeys(value, path, ["leafId"], ["atlasRow", "matrix", "opacity", "visible"]); + if (typeof input.leafId !== "string") fail("invalid-update", `${path}.leafId`, "expected a string"); + const result: { + leafId: string; + matrix?: PolyMorphMat4; + visible?: boolean; + opacity?: number; + atlasRow?: number; + } = { leafId: input.leafId }; + if (input.matrix !== undefined) result.matrix = matrix(input.matrix, `${path}.matrix`); + if (input.visible !== undefined) { + if (typeof input.visible !== "boolean") { + fail("invalid-update", `${path}.visible`, "expected a boolean"); + } + result.visible = input.visible; + } + if (input.opacity !== undefined) { + if ( + typeof input.opacity !== "number" + || !Number.isFinite(input.opacity) + || input.opacity < 0 + || input.opacity > 1 + ) { + fail("invalid-update", `${path}.opacity`, "expected 0 <= opacity <= 1"); + } + result.opacity = input.opacity; + } + if (input.atlasRow !== undefined) { + if (!Number.isSafeInteger(input.atlasRow) || (input.atlasRow as number) < 0) { + fail("invalid-update", `${path}.atlasRow`, "expected a non-negative integer"); + } + result.atlasRow = input.atlasRow as number; + } + return result; +} + +function validateUpdate(value: unknown): PolyMorphRetainedUpdate { + const input = exactKeys(value, "$", [], ["leaves", "modelMatrix", "shapes"]); + const shapes = input.shapes === undefined + ? undefined + : (() => { + if (!Array.isArray(input.shapes)) fail("invalid-update", "$.shapes", "expected an array"); + const result = input.shapes.map((entry, index) => + validateShapeUpdate(entry, `$.shapes[${index}]`)); + orderedUnique(result.map((entry) => entry.shapeId), "$.shapes"); + return result; + })(); + const leaves = input.leaves === undefined + ? undefined + : (() => { + if (!Array.isArray(input.leaves)) fail("invalid-update", "$.leaves", "expected an array"); + const result = input.leaves.map((entry, index) => + validateLeafUpdate(entry, `$.leaves[${index}]`)); + orderedUnique(result.map((entry) => entry.leafId), "$.leaves"); + return result; + })(); + return { + ...(input.modelMatrix === undefined + ? {} + : { modelMatrix: matrix(input.modelMatrix, "$.modelMatrix") }), + ...(shapes === undefined ? {} : { shapes }), + ...(leaves === undefined ? {} : { leaves }), + }; +} + +function applyAtlasPaint( + element: HTMLElement, + leaf: PolyMorphRenderLeaf, + atlas: PolyMorphAtlasSlice, + resourceUrl: string, +): void { + element.style.setProperty("--polycss-atlas-width", `${leaf.width}px`); + element.style.setProperty("--polycss-atlas-height", `${leaf.height}px`); + element.style.backgroundImage = `url("${resourceUrl.replace(/"/gu, "%22")}")`; + element.style.backgroundPosition = `${-atlas.x}px ${-atlas.y}px`; + element.style.backgroundSize = `${atlas.pageWidth}px ${atlas.pageHeight}px`; +} + +function applySolidTriangleFallbackPaint( + element: HTMLElement, + atlas: PolyMorphAtlasSlice, + width: number, + height: number, + resourceUrl: string, +): void { + const image = `url("${resourceUrl.replace(/"/gu, "%22")}")`; + const position = `${-atlas.x}px ${-atlas.y}px`; + const size = `${atlas.pageWidth}px ${atlas.pageHeight}px`; + element.style.width = `${width}px`; + element.style.height = `${height}px`; + element.style.backgroundColor = "currentColor"; + element.style.setProperty("mask-image", image); + element.style.setProperty("mask-mode", "alpha"); + element.style.setProperty("mask-position", position); + element.style.setProperty("mask-repeat", "no-repeat"); + element.style.setProperty("mask-size", size); + element.style.setProperty("-webkit-mask-image", image); + element.style.setProperty("-webkit-mask-position", position); + element.style.setProperty("-webkit-mask-repeat", "no-repeat"); + element.style.setProperty("-webkit-mask-size", size); +} + +function createLeaf( + doc: Document, + leaf: PolyMorphRenderLeaf, + materialColor: readonly [number, number, number, number], + resolveVerifiedImageUrl: (path: string, errorPath: string) => string, + useSolidTriangleFallback: boolean, +): HTMLElement { + const fallback = useSolidTriangleFallback ? leaf.fallback : null; + const element = doc.createElement( + fallback ? "s" : TAG_BY_STRATEGY[leaf.strategy], + ); + element.className = "polycss-morph-leaf"; + element.dataset.polyMorphLeaf = leaf.id; + element.dataset.polyMorphStrategy = leaf.strategy; + element.dataset.polyMorphResolvedStrategy = fallback + ? "atlas-slice" + : leaf.strategy; + element.style.transform = matrixText( + resolvedLeafMatrix(leaf, leaf.matrix, fallback !== null), + ); + element.style.color = colorText(materialColor); + element.style.backfaceVisibility = "visible"; + element.style.backgroundRepeat = "no-repeat"; + element.style.opacity = "1"; + element.style.transformOrigin = "0 0"; + element.style.visibility = "visible"; + if (leaf.atlas) { + element.style.width = `${leaf.width}px`; + element.style.height = `${leaf.height}px`; + applyAtlasPaint( + element, + leaf, + leaf.atlas, + resolveVerifiedImageUrl(leaf.atlas.resourcePath, leaf.id), + ); + } else if (fallback) { + applySolidTriangleFallbackPaint( + element, + fallback.atlas, + fallback.width, + fallback.height, + resolveVerifiedImageUrl(fallback.atlas.resourcePath, leaf.id), + ); + } else if (leaf.strategy === "solid-quad") { + element.style.width = `${leaf.width}px`; + element.style.height = `${leaf.height}px`; + } + return element; +} + +export function mountPolyMorphModel( + host: HTMLElement, + modelInput: unknown, + options: PolyMorphMountOptions = {}, +): PolyMorphMountedModel { + if (!host || typeof host.appendChild !== "function" || !host.ownerDocument) { + fail("invalid-host", "$.host", "expected an HTMLElement"); + } + const model = validatePolyMorphModel(modelInput); + const doc = host.ownerDocument; + const useSolidTriangleFallback = model.render.leaves.some( + (leaf) => leaf.strategy === "solid-triangle", + ) && !isSolidTriangleSupported(doc); + const resourceUrls = new Map(); + const objectUrlApi = typeof doc.defaultView?.URL?.createObjectURL === "function" + ? doc.defaultView.URL + : globalThis.URL; + const blobConstructor = doc.defaultView?.Blob ?? globalThis.Blob; + const releaseResourceUrls = (): void => { + for (const url of resourceUrls.values()) objectUrlApi.revokeObjectURL(url); + resourceUrls.clear(); + }; + const resolveVerifiedImageUrl = (path: string, errorPath: string): string => { + const cached = resourceUrls.get(path); + if (cached) return cached; + const resource = options.resources?.get(path); + if ( + !resource + || resource.descriptor.path !== path + || resource.descriptor.role !== "image" + ) { + fail( + "missing-resource", + errorPath, + "image-backed leaves require their verified package resource", + ); + } + if ( + typeof objectUrlApi?.createObjectURL !== "function" + || typeof objectUrlApi?.revokeObjectURL !== "function" + || typeof blobConstructor !== "function" + ) { + fail( + "missing-object-url", + errorPath, + "this browser cannot mount verified image bytes", + ); + } + const bytes = resource.bytes.slice(); + const resolved = objectUrlApi.createObjectURL(new blobConstructor( + [bytes.buffer], + { type: resource.descriptor.mediaType }, + )); + resourceUrls.set(path, resolved); + return resolved; + }; + try { + for (const leaf of model.render.leaves) { + if (leaf.atlas) { + resolveVerifiedImageUrl(leaf.atlas.resourcePath, leaf.id); + } + if (useSolidTriangleFallback && leaf.strategy === "solid-triangle") { + if (!leaf.fallback) { + fail( + "missing-solid-triangle-fallback", + `${leaf.id}.fallback`, + "this browser requires a prepared per-polygon atlas slice", + ); + } + resolveVerifiedImageUrl(leaf.fallback.atlas.resourcePath, leaf.id); + } + } + } catch (error) { + releaseResourceUrls(); + throw error; + } + injectPolyBaseStyles(doc); + const computed = doc.defaultView?.getComputedStyle(host); + const initialHostPosition = host.style.position; + let changedHostPosition = false; + if (!computed || computed.position === "static" || computed.position === "") { + host.style.position = "relative"; + changedHostPosition = true; + } + const camera = options.camera ?? createPolyCamera({ zoom: 1 }); + const cameraElement = doc.createElement("div"); + cameraElement.className = "polycss-camera polycss-morph-camera"; + const cameraSnapshot = capturePolyCameraSnapshot(camera); + cameraElement.style.perspective = cameraSnapshot.appliedPerspectiveStyle; + cameraElement.dataset.polycssCameraProjection = cameraSnapshot.projection; + host.appendChild(cameraElement); + + const sceneElement = doc.createElement("div"); + sceneElement.className = "polycss-scene polycss-morph-scene"; + sceneElement.setAttribute("aria-hidden", "true"); + const initialCameraTransform = buildPolyCameraSceneTransform(camera.state); + sceneElement.style.transform = initialCameraTransform; + cameraElement.appendChild(sceneElement); + + const modelElement = doc.createElement("div"); + modelElement.className = "polycss-mesh polycss-morph-model"; + modelElement.dataset.polyMorphModel = model.identity.id; + let modelTransform = matrixText(model.render.modelMatrix); + modelElement.style.transform = modelTransform; + sceneElement.appendChild(modelElement); + + const shapeElements = new Map(); + const shapeTransforms = new Map(); + for (const shape of model.render.shapes) { + const element = doc.createElement("div"); + element.className = "polycss-mesh polycss-morph-shape"; + element.dataset.polyMorphShape = shape.id; + const transform = matrixText(shape.matrix); + element.style.transform = transform; + modelElement.appendChild(element); + shapeElements.set(shape.id, element); + shapeTransforms.set(shape.id, transform); + } + + const materialById = new Map(model.materials.map((material) => [material.id, material])); + const leafHandles = new Map(); + const leafStates = new Map(); + for (const leaf of model.render.leaves) { + const shapeElement = shapeElements.get(leaf.shapeId); + const material = materialById.get(leaf.materialId); + if (!shapeElement || !material) { + fail("mount-coverage", leaf.id, "leaf references are incomplete"); + } + const element = createLeaf( + doc, + leaf, + material.color, + resolveVerifiedImageUrl, + useSolidTriangleFallback && leaf.strategy === "solid-triangle", + ); + shapeElement.appendChild(element); + leafHandles.set(leaf.id, { id: leaf.id, plan: leaf, element }); + leafStates.set(leaf.id, { + transform: matrixText(resolvedLeafMatrix( + leaf, + leaf.matrix, + useSolidTriangleFallback && leaf.strategy === "solid-triangle", + )), + visible: true, + opacity: 1, + atlasRow: 0, + }); + } + + const stableShapes = [...shapeElements.entries()]; + const stableLeaves = [...leafHandles.entries()]; + let destroyed = false; + let cameraTransform = initialCameraTransform; + const counters = { + applyCount: 0, + totalTransformWrites: 0, + totalVisibilityWrites: 0, + totalOpacityWrites: 0, + totalAtlasRowWrites: 0, + }; + + const assertActive = (): void => { + if (destroyed) fail("destroyed", "$", "mounted model is destroyed"); + }; + + const assertStableDomIdentity = (): void => { + assertActive(); + if (shapeElements.size !== stableShapes.length || leafHandles.size !== stableLeaves.length) { + fail("identity-drift", "$", "retained handle maps changed"); + } + for (const [id, element] of stableShapes) { + if (shapeElements.get(id) !== element || element.parentElement !== modelElement) { + fail("identity-drift", id, "shape element identity changed"); + } + } + for (const [id, handle] of stableLeaves) { + const shape = shapeElements.get(handle.plan.shapeId); + if (leafHandles.get(id) !== handle || handle.element.parentElement !== shape) { + fail("identity-drift", id, "leaf element identity changed"); + } + } + }; + + const apply = (updateInput: PolyMorphRetainedUpdate): PolyMorphApplyResult => { + assertActive(); + const update = validateUpdate(updateInput); + const resolvedShapes = (update.shapes ?? []).map((shape) => { + const element = shapeElements.get(shape.shapeId); + if (!element) fail("unknown-shape", shape.shapeId, "no retained shape handle"); + return { shape, element }; + }); + const resolvedLeaves = (update.leaves ?? []).map((leaf) => { + const handle = leafHandles.get(leaf.leafId); + const state = leafStates.get(leaf.leafId); + if (!handle || !state) { + fail("unknown-leaf", leaf.leafId, "no retained leaf handle"); + } + let atlasPosition: string | undefined; + if (leaf.atlasRow !== undefined) { + const atlas = handle.plan.atlas; + if (!atlas) fail("invalid-atlas-row", leaf.leafId, "leaf has no image rows"); + const y = atlas.y + leaf.atlasRow * atlas.height; + if (y + atlas.height > atlas.pageHeight) { + fail("invalid-atlas-row", leaf.leafId, "row exceeds the image page"); + } + atlasPosition = `${-atlas.x}px ${-y}px`; + } + return { leaf, handle, state, atlasPosition }; + }); + assertStableDomIdentity(); + let modelTransformWrites = 0; + let shapeTransformWrites = 0; + let leafTransformWrites = 0; + let visibilityWrites = 0; + let opacityWrites = 0; + let atlasRowWrites = 0; + if (update.modelMatrix) { + const next = matrixText(update.modelMatrix); + if (modelTransform !== next) { + modelTransform = next; + modelElement.style.transform = next; + modelTransformWrites += 1; + } + } + for (const { shape, element } of resolvedShapes) { + const next = matrixText(shape.matrix); + if (shapeTransforms.get(shape.shapeId) !== next) { + shapeTransforms.set(shape.shapeId, next); + element.style.transform = next; + shapeTransformWrites += 1; + } + } + for (const { leaf, handle, state, atlasPosition } of resolvedLeaves) { + if (leaf.matrix) { + const next = matrixText(resolvedLeafMatrix( + handle.plan, + leaf.matrix, + useSolidTriangleFallback && handle.plan.strategy === "solid-triangle", + )); + if (state.transform !== next) { + state.transform = next; + handle.element.style.transform = next; + leafTransformWrites += 1; + } + } + if (leaf.visible !== undefined) { + const next = leaf.visible ? "visible" : "hidden"; + if (state.visible !== leaf.visible) { + state.visible = leaf.visible; + handle.element.style.visibility = next; + visibilityWrites += 1; + } + } + if (leaf.opacity !== undefined) { + const next = String(leaf.opacity); + if (state.opacity !== leaf.opacity) { + state.opacity = leaf.opacity; + handle.element.style.opacity = next; + opacityWrites += 1; + } + } + if (leaf.atlasRow !== undefined) { + if (state.atlasRow !== leaf.atlasRow) { + state.atlasRow = leaf.atlasRow; + handle.element.style.backgroundPosition = atlasPosition!; + atlasRowWrites += 1; + } + } + } + assertStableDomIdentity(); + counters.applyCount += 1; + counters.totalTransformWrites += modelTransformWrites + shapeTransformWrites + leafTransformWrites; + counters.totalVisibilityWrites += visibilityWrites; + counters.totalOpacityWrites += opacityWrites; + counters.totalAtlasRowWrites += atlasRowWrites; + return { + modelTransformWrites, + shapeTransformWrites, + leafTransformWrites, + visibilityWrites, + opacityWrites, + atlasRowWrites, + dirtyLeavesVisited: update.leaves?.length ?? 0, + domCreations: 0, + domRemovals: 0, + topologyConstructions: 0, + atlasRedraws: 0, + schedulerCallbacks: 0, + }; + }; + + const mounted: PolyMorphMountedModel = { + model, + camera, + cameraElement, + sceneElement, + modelElement, + shapeElements, + leafHandles, + get stats() { + return { + mountCount: 1 as const, + shapeRoots: shapeElements.size, + leafCount: leafHandles.size, + topologyConstructions: 1 as const, + atlasConstructions: 0 as const, + schedulerCount: 0 as const, + ...counters, + }; + }, + get destroyed() { + return destroyed; + }, + apply, + updateCamera(): boolean { + assertActive(); + const next = buildPolyCameraSceneTransform(camera.state); + if (cameraTransform === next) return false; + cameraTransform = next; + sceneElement.style.transform = next; + const snapshot = capturePolyCameraSnapshot(camera); + cameraElement.style.perspective = snapshot.appliedPerspectiveStyle; + cameraElement.dataset.polycssCameraProjection = snapshot.projection; + return true; + }, + assertStableDomIdentity, + destroy(): void { + if (destroyed) return; + destroyed = true; + cameraElement.remove(); + shapeElements.clear(); + leafHandles.clear(); + leafStates.clear(); + shapeTransforms.clear(); + releaseResourceUrls(); + if (changedHostPosition && host.style.position === "relative") { + host.style.position = initialHostPosition; + } + }, + }; + mounted.assertStableDomIdentity(); + return mounted; +} diff --git a/packages/morph/src/render/renderError.ts b/packages/morph/src/render/renderError.ts new file mode 100644 index 00000000..0f75f893 --- /dev/null +++ b/packages/morph/src/render/renderError.ts @@ -0,0 +1,11 @@ +export class PolyMorphRenderError extends Error { + readonly code: string; + readonly path: string; + + constructor(code: string, path: string, message: string) { + super(`${path}: ${message}`); + this.name = "PolyMorphRenderError"; + this.code = code; + this.path = path; + } +} diff --git a/packages/morph/src/render/types.ts b/packages/morph/src/render/types.ts new file mode 100644 index 00000000..c69e4582 --- /dev/null +++ b/packages/morph/src/render/types.ts @@ -0,0 +1,89 @@ +import type { + PolyMorphMat4, + PolyMorphModel, + PolyMorphRenderLeaf, +} from "../contracts/index.js"; +import type { + PolyOrthographicCameraHandle, + PolyPerspectiveCameraHandle, +} from "@layoutit/polycss"; +import type { PolyMorphLoadedResource } from "../package/index.js"; + +export type PolyMorphCamera = + | PolyOrthographicCameraHandle + | PolyPerspectiveCameraHandle; + +export interface PolyMorphMountOptions { + readonly camera?: PolyMorphCamera; + readonly resources?: ReadonlyMap; +} + +export interface PolyMorphShapeUpdate { + readonly shapeId: string; + readonly matrix: PolyMorphMat4; +} + +export interface PolyMorphLeafUpdate { + readonly leafId: string; + readonly matrix?: PolyMorphMat4; + readonly visible?: boolean; + readonly opacity?: number; + readonly atlasRow?: number; +} + +export interface PolyMorphRetainedUpdate { + readonly modelMatrix?: PolyMorphMat4; + readonly shapes?: readonly PolyMorphShapeUpdate[]; + readonly leaves?: readonly PolyMorphLeafUpdate[]; +} + +export interface PolyMorphApplyResult { + readonly modelTransformWrites: number; + readonly shapeTransformWrites: number; + readonly leafTransformWrites: number; + readonly visibilityWrites: number; + readonly opacityWrites: number; + readonly atlasRowWrites: number; + readonly dirtyLeavesVisited: number; + readonly domCreations: 0; + readonly domRemovals: 0; + readonly topologyConstructions: 0; + readonly atlasRedraws: 0; + readonly schedulerCallbacks: 0; +} + +export interface PolyMorphRenderStats { + readonly mountCount: 1; + readonly shapeRoots: number; + readonly leafCount: number; + readonly topologyConstructions: 1; + readonly atlasConstructions: 0; + readonly schedulerCount: 0; + readonly applyCount: number; + readonly totalTransformWrites: number; + readonly totalVisibilityWrites: number; + readonly totalOpacityWrites: number; + readonly totalAtlasRowWrites: number; +} + +export interface PolyMorphLeafHandle { + readonly id: string; + readonly plan: PolyMorphRenderLeaf; + readonly element: HTMLElement; +} + +export interface PolyMorphMountedModel { + readonly model: PolyMorphModel; + readonly camera: PolyMorphCamera; + readonly cameraElement: HTMLElement; + readonly sceneElement: HTMLElement; + readonly modelElement: HTMLElement; + readonly shapeElements: ReadonlyMap; + readonly leafHandles: ReadonlyMap; + readonly stats: PolyMorphRenderStats; + readonly destroyed: boolean; + apply(update: PolyMorphRetainedUpdate): PolyMorphApplyResult; + updateCamera(): boolean; + assertStableDomIdentity(): void; + destroy(): void; +} diff --git a/packages/morph/src/runtime/animation/animation.test.ts b/packages/morph/src/runtime/animation/animation.test.ts new file mode 100644 index 00000000..9d6d90de --- /dev/null +++ b/packages/morph/src/runtime/animation/animation.test.ts @@ -0,0 +1,72 @@ +import { describe, expect, it } from "vitest"; +import { clonePolyMorphFixture } from "../../testing/modelFixture.js"; +import { createPolyMorphRuntimeFixture } from "../../testing/runtimeFixture.js"; +import { createPolyMorphSkinningFixture } from "../../testing/skinningFixture.js"; +import { PolyMorphRuntimeError } from "../runtimeError.js"; +import { createPolyMorphAnimationRuntime } from "./index.js"; + +describe("PolyMorph animation sampling", () => { + it("samples exact boundaries and deterministic linear interpolation", () => { + const runtime = createPolyMorphAnimationRuntime(createPolyMorphRuntimeFixture()); + expect(runtime.sample("pulse", 0).morphWeights.stretch).toBe(0); + expect(runtime.sample("pulse", 250).morphWeights.stretch).toBe(0.5); + expect(runtime.sample("pulse", 500).morphWeights.stretch).toBe(1); + expect(runtime.sample("pulse", 750).morphWeights.stretch).toBe(0.5); + }); + + it("wraps loops and clamps non-looping clips", () => { + const looping = createPolyMorphAnimationRuntime(createPolyMorphRuntimeFixture()); + expect(looping.sample("pulse", 1000).sampledTimeMs).toBe(0); + expect(looping.sample("pulse", 1250).morphWeights.stretch).toBe(0.5); + + const fixture = clonePolyMorphFixture(createPolyMorphRuntimeFixture()); + fixture.animations[0]!.loop = false; + const clamped = createPolyMorphAnimationRuntime(fixture); + expect(clamped.sample("pulse", 1250).sampledTimeMs).toBe(1000); + expect(clamped.sample("pulse", 1250).morphWeights.stretch).toBe(0); + }); + + it("honors step interpolation", () => { + const fixture = clonePolyMorphFixture(createPolyMorphRuntimeFixture()); + fixture.animations[0]!.channels[0]!.interpolation = "step"; + const runtime = createPolyMorphAnimationRuntime(fixture); + expect(runtime.sample("pulse", 499).morphWeights.stretch).toBe(0); + expect(runtime.sample("pulse", 500).morphWeights.stretch).toBe(1); + expect(runtime.sample("pulse", 501).morphWeights.stretch).toBe(1); + }); + + it("takes the shortest quaternion path and freezes sampled vectors", () => { + const fixture = clonePolyMorphFixture(createPolyMorphSkinningFixture()); + fixture.animations[0]!.channels = [{ + target: "joint-rotation", + targetId: "tip", + interpolation: "linear", + timesMs: [0, 1000], + values: [[0, 0, 0, 1], [0, 0, 0, -1]], + }]; + const runtime = createPolyMorphAnimationRuntime(fixture); + const rotation = runtime.sample("tip-rise", 500) + .jointTransforms.get("tip")!.rotation!; + expect(rotation).toEqual([0, 0, 0, 1]); + expect(Object.isFrozen(rotation)).toBe(true); + + fixture.animations[0]!.channels = [{ + target: "joint-translation", + targetId: "tip", + interpolation: "linear", + timesMs: [0, 1000], + values: [[0, 0, 0], [0, 2, 0]], + }]; + const translation = createPolyMorphAnimationRuntime(fixture) + .sample("tip-rise", 500).jointTransforms.get("tip")!.translation!; + expect(translation).toEqual([0, 1, 0]); + expect(Object.isFrozen(translation)).toBe(true); + }); + + it("fails closed on unknown clips and invalid time", () => { + const runtime = createPolyMorphAnimationRuntime(createPolyMorphRuntimeFixture()); + expect(() => runtime.sample("missing", 0)).toThrowError(PolyMorphRuntimeError); + expect(() => runtime.sample("pulse", -1)).toThrowError(PolyMorphRuntimeError); + expect(() => runtime.sample("pulse", Number.NaN)).toThrowError(PolyMorphRuntimeError); + }); +}); diff --git a/packages/morph/src/runtime/animation/animation.ts b/packages/morph/src/runtime/animation/animation.ts new file mode 100644 index 00000000..7782190c --- /dev/null +++ b/packages/morph/src/runtime/animation/animation.ts @@ -0,0 +1,131 @@ +import { + validatePolyMorphModel, + type PolyMorphAnimationChannel, + type PolyMorphAnimationClip, + type PolyMorphMat4, + type PolyMorphModel, + type PolyMorphQuat, + type PolyMorphVec3, +} from "../../contracts/index.js"; +import { PolyMorphRuntimeError } from "../runtimeError.js"; + +export interface PolyMorphJointAnimationSample { + readonly translation?: PolyMorphVec3; + readonly rotation?: PolyMorphQuat; + readonly scale?: PolyMorphVec3; +} + +export interface PolyMorphAnimationSample { + readonly clipId: string; + readonly requestedTimeMs: number; + readonly sampledTimeMs: number; + readonly morphWeights: Readonly>; + readonly controlValues: Readonly>; + readonly jointTransforms: ReadonlyMap; + readonly shapeMatrices: ReadonlyMap; +} + +export interface PolyMorphAnimationRuntime { + readonly model: PolyMorphModel; + readonly clipIds: readonly string[]; + sample(clipId: string, timeMs: number): PolyMorphAnimationSample; +} + +function fail(code: string, path: string, message: string): never { + throw new PolyMorphRuntimeError(code, path, message); +} + +function frozenSample(value: readonly number[]): readonly number[] { + return Object.freeze([...value]); +} + +function sampleChannel(channel: PolyMorphAnimationChannel, timeMs: number): readonly number[] { + const { timesMs, values } = channel; + if (timeMs <= timesMs[0]!) return frozenSample(values[0]!); + for (let index = 1; index < timesMs.length; index += 1) { + const rightTime = timesMs[index]!; + if (timeMs > rightTime) continue; + const leftTime = timesMs[index - 1]!; + const left = values[index - 1]!; + const right = values[index]!; + if (timeMs === rightTime) return frozenSample(right); + if (channel.interpolation === "step") return frozenSample(left); + const amount = (timeMs - leftTime) / (rightTime - leftTime); + if (channel.target === "joint-rotation") { + const dot = left.reduce( + (sum, value, part) => sum + value * right[part]!, + 0, + ); + const direction = dot < 0 ? -1 : 1; + const interpolated = left.map((value, part) => + value + (right[part]! * direction - value) * amount); + const length = Math.hypot(...interpolated); + return Object.freeze(interpolated.map((value) => value / length)); + } + return Object.freeze( + left.map((value, part) => value + (right[part]! - value) * amount), + ); + } + return frozenSample(values[values.length - 1]!); +} + +function sampleTime(clip: PolyMorphAnimationClip, requested: number): number { + if (clip.loop) { + const wrapped = requested % clip.durationMs; + return Object.is(wrapped, -0) ? 0 : wrapped; + } + return Math.min(requested, clip.durationMs); +} + +export function createPolyMorphAnimationRuntime(modelInput: unknown): PolyMorphAnimationRuntime { + const model = validatePolyMorphModel(modelInput); + const clips = new Map(model.animations.map((clip) => [clip.id, clip])); + return Object.freeze({ + model, + clipIds: Object.freeze([...clips.keys()]), + sample(clipId: string, timeMs: number): PolyMorphAnimationSample { + const clip = clips.get(clipId); + if (!clip) fail("unknown-clip", "$.clipId", clipId); + if (!Number.isFinite(timeMs) || timeMs < 0) { + fail("invalid-time", "$.timeMs", "expected a finite non-negative time"); + } + const resolvedTime = sampleTime(clip, timeMs); + const morphWeights: Record = {}; + const controlValues: Record = {}; + const joints = new Map(); + const shapeMatrices = new Map(); + for (const channel of clip.channels) { + const value = sampleChannel(channel, resolvedTime); + if (channel.target === "morph-weight") morphWeights[channel.targetId] = value[0]!; + if (channel.target === "control-value") controlValues[channel.targetId] = value[0]!; + if (channel.target === "shape-matrix") { + shapeMatrices.set(channel.targetId, value as unknown as PolyMorphMat4); + } + if (channel.target.startsWith("joint-")) { + const joint = joints.get(channel.targetId) ?? {}; + if (channel.target === "joint-translation") { + joint.translation = value as unknown as PolyMorphVec3; + } else if (channel.target === "joint-rotation") { + joint.rotation = value as unknown as PolyMorphQuat; + } else { + joint.scale = value as unknown as PolyMorphVec3; + } + joints.set(channel.targetId, joint); + } + } + return { + clipId, + requestedTimeMs: timeMs, + sampledTimeMs: resolvedTime, + morphWeights: Object.freeze(morphWeights), + controlValues: Object.freeze(controlValues), + jointTransforms: joints, + shapeMatrices, + }; + }, + }); +} diff --git a/packages/morph/src/runtime/animation/index.ts b/packages/morph/src/runtime/animation/index.ts new file mode 100644 index 00000000..d3df1869 --- /dev/null +++ b/packages/morph/src/runtime/animation/index.ts @@ -0,0 +1,6 @@ +export { createPolyMorphAnimationRuntime } from "./animation.js"; +export type { + PolyMorphAnimationRuntime, + PolyMorphAnimationSample, + PolyMorphJointAnimationSample, +} from "./animation.js"; diff --git a/packages/morph/src/runtime/controls/controls.test.ts b/packages/morph/src/runtime/controls/controls.test.ts new file mode 100644 index 00000000..c694eab2 --- /dev/null +++ b/packages/morph/src/runtime/controls/controls.test.ts @@ -0,0 +1,64 @@ +import { describe, expect, it } from "vitest"; +import { createPolyMorphRuntimeFixture } from "../../testing/runtimeFixture.js"; +import { PolyMorphRuntimeError } from "../runtimeError.js"; +import { + createPolyMorphControlRuntime, + createPolyMorphControlState, + pickPolyMorphControl, + stepPolyMorphControls, +} from "./index.js"; + +describe("PolyMorph semantic controls", () => { + it("picks the nearest bounded control", () => { + const runtime = createPolyMorphControlRuntime(createPolyMorphRuntimeFixture()); + expect(pickPolyMorphControl(runtime, [0, 1, 0])).toBe("stretch-control"); + expect(pickPolyMorphControl(runtime, [10, 10, 10])).toBeNull(); + }); + + it("tracks a held directional displacement and clamps its value", () => { + const runtime = createPolyMorphControlRuntime(createPolyMorphRuntimeFixture()); + let state = createPolyMorphControlState(runtime); + let step = stepPolyMorphControls(runtime, state, { point: [0, 1, 0], active: true }); + expect(step.pickedControlId).toBe("stretch-control"); + expect(step.heldTarget?.value).toBe(0); + state = step.state; + + step = stepPolyMorphControls(runtime, state, { point: [0, 1.75, 0], active: true }); + expect(step.heldTarget?.value).toBe(0.75); + state = step.state; + + step = stepPolyMorphControls(runtime, state, { point: [0, 4, 0], active: true }); + expect(step.heldTarget?.value).toBe(1); + }); + + it("supports spring release and explicit freeze release", () => { + const runtime = createPolyMorphControlRuntime(createPolyMorphRuntimeFixture()); + let state = createPolyMorphControlState(runtime); + state = stepPolyMorphControls(runtime, state, { point: [0, 1, 0], active: true }).state; + state = stepPolyMorphControls(runtime, state, { point: [0, 1.5, 0], active: true }).state; + const released = stepPolyMorphControls(runtime, state, { + point: [0, 1.5, 0], + active: false, + }); + expect(released.releasedControlId).toBe("stretch-control"); + expect(released.state.frozenControlIds).toEqual([]); + + state = stepPolyMorphControls(runtime, released.state, { point: [0, 1, 0], active: true }).state; + state = stepPolyMorphControls(runtime, state, { point: [0, 1.5, 0], active: true }).state; + const frozen = stepPolyMorphControls(runtime, state, { + point: [0, 1.5, 0], + active: false, + freezeOnRelease: true, + }); + expect(frozen.state.frozenControlIds).toEqual(["stretch-control"]); + }); + + it("fails closed on malformed points and state", () => { + const runtime = createPolyMorphControlRuntime(createPolyMorphRuntimeFixture()); + const state = createPolyMorphControlState(runtime); + expect(() => stepPolyMorphControls(runtime, state, { + point: [0, Number.NaN, 0], + active: true, + })).toThrowError(PolyMorphRuntimeError); + }); +}); diff --git a/packages/morph/src/runtime/controls/controls.ts b/packages/morph/src/runtime/controls/controls.ts new file mode 100644 index 00000000..8de68ab8 --- /dev/null +++ b/packages/morph/src/runtime/controls/controls.ts @@ -0,0 +1,175 @@ +import { + validatePolyMorphModel, + type PolyMorphControl, + type PolyMorphModel, + type PolyMorphVec3, +} from "../../contracts/index.js"; +import { PolyMorphRuntimeError } from "../runtimeError.js"; + +export interface PolyMorphControlRuntime { + readonly model: PolyMorphModel; + readonly controlIds: readonly string[]; + readonly controls: ReadonlyMap; +} + +export interface PolyMorphControlState { + readonly tick: number; + readonly active: boolean; + readonly heldControlId: string | null; + readonly holdStartPoint: PolyMorphVec3 | null; + readonly holdStartValue: number; + readonly values: Readonly>; + readonly frozenControlIds: readonly string[]; +} + +export interface PolyMorphControlInput { + readonly point: PolyMorphVec3 | null; + readonly active: boolean; + readonly freezeOnRelease?: boolean; +} + +export interface PolyMorphControlStep { + readonly state: PolyMorphControlState; + readonly pickedControlId: string | null; + readonly releasedControlId: string | null; + readonly heldTarget: Readonly<{ controlId: string; value: number }> | null; +} + +function fail(code: string, path: string, message: string): never { + throw new PolyMorphRuntimeError(code, path, message); +} + +function point(value: PolyMorphVec3 | null): PolyMorphVec3 | null { + if (value === null) return null; + if ( + !Array.isArray(value) + || value.length !== 3 + || value.some((part) => typeof part !== "number" || !Number.isFinite(part)) + ) { + fail("invalid-point", "$.point", "expected three finite components or null"); + } + return [value[0], value[1], value[2]]; +} + +function normalizedAxis(control: PolyMorphControl): PolyMorphVec3 { + const length = Math.hypot(...control.axis); + return [ + control.axis[0] / length, + control.axis[1] / length, + control.axis[2] / length, + ]; +} + +export function createPolyMorphControlRuntime(modelInput: unknown): PolyMorphControlRuntime { + const model = validatePolyMorphModel(modelInput); + return Object.freeze({ + model, + controlIds: Object.freeze(model.controls.map((control) => control.id)), + controls: new Map(model.controls.map((control) => [control.id, control])), + }); +} + +export function createPolyMorphControlState( + runtime: PolyMorphControlRuntime, +): PolyMorphControlState { + return Object.freeze({ + tick: -1, + active: false, + heldControlId: null, + holdStartPoint: null, + holdStartValue: 0, + values: Object.freeze(Object.fromEntries( + runtime.controlIds.map((id) => [id, runtime.controls.get(id)!.initial]), + )), + frozenControlIds: Object.freeze([]), + }); +} + +export function pickPolyMorphControl( + runtime: PolyMorphControlRuntime, + inputPoint: PolyMorphVec3, +): string | null { + const resolved = point(inputPoint)!; + let best: { id: string; distance: number } | null = null; + for (const controlId of runtime.controlIds) { + const control = runtime.controls.get(controlId)!; + const distance = Math.hypot( + resolved[0] - control.anchor[0], + resolved[1] - control.anchor[1], + resolved[2] - control.anchor[2], + ); + if (distance <= control.radius && (!best || distance < best.distance)) { + best = { id: controlId, distance }; + } + } + return best?.id ?? null; +} + +export function stepPolyMorphControls( + runtime: PolyMorphControlRuntime, + state: PolyMorphControlState, + input: PolyMorphControlInput, +): PolyMorphControlStep { + if (!state || !Number.isSafeInteger(state.tick) || state.tick < -1) { + fail("invalid-state", "$.state", "control state is invalid"); + } + if (typeof input?.active !== "boolean") fail("invalid-input", "$.active", "expected a boolean"); + const resolvedPoint = point(input.point); + let heldControlId = state.heldControlId; + let holdStartPoint = state.holdStartPoint; + let holdStartValue = state.holdStartValue; + let pickedControlId: string | null = null; + let releasedControlId: string | null = null; + const values = { ...state.values }; + const frozen = new Set(state.frozenControlIds); + const pressed = input.active && !state.active; + const released = !input.active && state.active; + if (pressed && resolvedPoint) { + heldControlId = pickPolyMorphControl(runtime, resolvedPoint); + if (heldControlId) { + pickedControlId = heldControlId; + holdStartPoint = resolvedPoint; + holdStartValue = values[heldControlId] ?? runtime.controls.get(heldControlId)!.initial; + frozen.delete(heldControlId); + } + } + if (input.active && heldControlId && resolvedPoint && holdStartPoint) { + const control = runtime.controls.get(heldControlId)!; + const axis = normalizedAxis(control); + const displacement = + (resolvedPoint[0] - holdStartPoint[0]) * axis[0] + + (resolvedPoint[1] - holdStartPoint[1]) * axis[1] + + (resolvedPoint[2] - holdStartPoint[2]) * axis[2]; + values[heldControlId] = Math.max( + control.minimum, + Math.min(control.maximum, holdStartValue + displacement), + ); + } + if (released) { + releasedControlId = heldControlId; + if (heldControlId) { + if (input.freezeOnRelease === true) frozen.add(heldControlId); + else frozen.delete(heldControlId); + } + heldControlId = null; + holdStartPoint = null; + holdStartValue = 0; + } + const next = Object.freeze({ + tick: state.tick + 1, + active: input.active, + heldControlId, + holdStartPoint, + holdStartValue, + values: Object.freeze(values), + frozenControlIds: Object.freeze([...frozen].sort()), + }); + return Object.freeze({ + state: next, + pickedControlId, + releasedControlId, + heldTarget: input.active && heldControlId + ? Object.freeze({ controlId: heldControlId, value: values[heldControlId]! }) + : null, + }); +} diff --git a/packages/morph/src/runtime/controls/index.ts b/packages/morph/src/runtime/controls/index.ts new file mode 100644 index 00000000..aedfa42b --- /dev/null +++ b/packages/morph/src/runtime/controls/index.ts @@ -0,0 +1,12 @@ +export { + createPolyMorphControlRuntime, + createPolyMorphControlState, + pickPolyMorphControl, + stepPolyMorphControls, +} from "./controls.js"; +export type { + PolyMorphControlInput, + PolyMorphControlRuntime, + PolyMorphControlState, + PolyMorphControlStep, +} from "./controls.js"; diff --git a/packages/morph/src/runtime/deformation/deformation.test.ts b/packages/morph/src/runtime/deformation/deformation.test.ts new file mode 100644 index 00000000..c36dd012 --- /dev/null +++ b/packages/morph/src/runtime/deformation/deformation.test.ts @@ -0,0 +1,140 @@ +import { describe, expect, it } from "vitest"; +import { mountPolyMorphModel } from "../../render/index.js"; +import { + clonePolyMorphFixture, + createPolyMorphModelFixture, +} from "../../testing/modelFixture.js"; +import { createPolyMorphRuntimeFixture } from "../../testing/runtimeFixture.js"; +import { PolyMorphRuntimeError } from "../runtimeError.js"; +import { createPolyMorphDeformationRuntime } from "./index.js"; + +describe("PolyMorph deformation runtime", () => { + it("emits no writes for static steps", () => { + const runtime = createPolyMorphDeformationRuntime(createPolyMorphModelFixture()); + const first = runtime.sample({ tick: 0 }); + const second = runtime.sample({ tick: 1 }); + expect(first.positions).toEqual(runtime.basePositions); + expect(first.leafUpdates).toEqual([]); + expect(second.leafUpdates).toEqual([]); + expect(second).toMatchObject({ + runtimePolygonConstructions: 0, + runtimeTopologyConstructions: 0, + atlasRedraws: 0, + }); + }); + + it("matches hand-computed sparse vertex and normal deltas", () => { + const runtime = createPolyMorphDeformationRuntime(createPolyMorphRuntimeFixture()); + const frame = runtime.sample({ + tick: 1, + morphWeights: { stretch: 0.5 }, + }); + expect(frame.positions[2]).toEqual([0, 1.25, 0]); + expect(frame.normals[2]![0]).toBe(0); + expect(frame.normals[2]![1]).toBeCloseTo(0.4472135955); + expect(frame.normals[2]![2]).toBeCloseTo(0.894427191); + expect(frame.dirtyLeafIds).toEqual(["gem-panel-leaf"]); + expect(frame.leafUpdates).toHaveLength(1); + expect(frame.leafUpdates[0]?.matrix).toHaveLength(16); + expect(Object.isFrozen(frame.positions)).toBe(true); + expect(Object.isFrozen(frame.positions[2]!)).toBe(true); + expect(Object.isFrozen(frame.normals[2]!)).toBe(true); + }); + + it("combines bounded semantic control values with explicit morph weights", () => { + const runtime = createPolyMorphDeformationRuntime(createPolyMorphRuntimeFixture()); + const frame = runtime.sample({ + tick: 1, + morphWeights: { stretch: 0.25 }, + controlValues: { "stretch-control": 0.25 }, + }); + expect(frame.morphWeights.stretch).toBe(0.5); + expect(frame.positions[2]).toEqual([0, 1.25, 0]); + }); + + it("turns repeated samples into no-op dirty rows", () => { + const runtime = createPolyMorphDeformationRuntime(createPolyMorphRuntimeFixture()); + const input = { + tick: 1, + morphWeights: { stretch: 0.75 }, + controlValues: { "stretch-control": 0 }, + }; + expect(runtime.sample(input).dirtyLeafIds).toEqual(["gem-panel-leaf"]); + expect(runtime.sample({ ...input, tick: 2 }).dirtyLeafIds).toEqual([]); + expect(runtime.sample({ ...input, tick: 3 }).leafUpdates).toEqual([]); + }); + + it("feeds only changed leaves into the retained mount", () => { + const model = createPolyMorphRuntimeFixture(); + const runtime = createPolyMorphDeformationRuntime(model); + const host = document.createElement("div"); + document.body.appendChild(host); + const mounted = mountPolyMorphModel(host, model); + const changedIdentity = mounted.leafHandles.get("gem-panel-leaf")!.element; + const staticIdentity = mounted.leafHandles.get("static-panel-leaf")!.element; + const staticTransform = staticIdentity.style.transform; + const frame = runtime.sample({ tick: 1, morphWeights: { stretch: 1 } }); + const result = mounted.apply({ leaves: frame.leafUpdates }); + expect(result.dirtyLeavesVisited).toBe(1); + expect(mounted.leafHandles.get("gem-panel-leaf")!.element).toBe(changedIdentity); + expect(mounted.leafHandles.get("static-panel-leaf")!.element).toBe(staticIdentity); + expect(staticIdentity.style.transform).toBe(staticTransform); + expect(mounted.apply({ + leaves: runtime.sample({ tick: 2, morphWeights: { stretch: 1 } }).leafUpdates, + }).dirtyLeavesVisited).toBe(0); + }); + + it("fails closed on unknown ids, out-of-range values, and unsupported profiles", () => { + const runtime = createPolyMorphDeformationRuntime(createPolyMorphRuntimeFixture()); + expect(() => runtime.sample({ + tick: 0, + morphWeights: { unknown: 1 }, + })).toThrowError(PolyMorphRuntimeError); + expect(() => runtime.sample({ + tick: 0, + morphWeights: { stretch: 1.1 }, + })).toThrowError(PolyMorphRuntimeError); + expect(() => runtime.sample({ + tick: 0, + controlValues: { "stretch-control": -0.1 }, + })).toThrowError(PolyMorphRuntimeError); + expect(() => + createPolyMorphDeformationRuntime(createPolyMorphModelFixture("joint-skin"))) + .toThrowError(PolyMorphRuntimeError); + }); + + it("hides and restores a temporarily degenerate triangle", () => { + const fixture = clonePolyMorphFixture(createPolyMorphModelFixture("morph-regions")); + fixture.deformation.targets[0]!.deltas[0]!.position = [0, -1, 0]; + const runtime = createPolyMorphDeformationRuntime(fixture); + expect(runtime.sample({ tick: 1, morphWeights: { stretch: 1 } }).leafUpdates[0]) + .toMatchObject({ visible: false }); + expect(runtime.sample({ tick: 2, morphWeights: { stretch: 0 } }).leafUpdates[0]) + .toMatchObject({ visible: true }); + }); + + it("does not publish failed quad geometry into the next sample", () => { + const fixture = clonePolyMorphFixture( + createPolyMorphModelFixture("morph-regions"), + ); + fixture.topology.vertices.push([1, 1, 0]); + fixture.topology.normals.push([0, 0, 1]); + fixture.topology.polygons[0]!.vertexIndices = [0, 1, 3, 2]; + fixture.topology.polygons[0]!.normalIndices = [0, 1, 3, 2]; + fixture.render.leaves[0]!.strategy = "solid-quad"; + fixture.render.leaves[0]!.width = 1; + fixture.render.leaves[0]!.height = 1; + const runtime = createPolyMorphDeformationRuntime(fixture); + + expect(() => runtime.sample({ + tick: 1, + morphWeights: { stretch: 1 }, + })).toThrowError(PolyMorphRuntimeError); + const recovered = runtime.sample({ + tick: 2, + morphWeights: { stretch: 0 }, + }); + expect(recovered.positions).toEqual(fixture.topology.vertices); + expect(recovered.dirtyLeafIds).toEqual([]); + }); +}); diff --git a/packages/morph/src/runtime/deformation/deformation.ts b/packages/morph/src/runtime/deformation/deformation.ts new file mode 100644 index 00000000..4af9486a --- /dev/null +++ b/packages/morph/src/runtime/deformation/deformation.ts @@ -0,0 +1,368 @@ +import { + computeSolidTrianglePlanFromCssPoints, + SOLID_TRIANGLE_CANONICAL_SIZE, + type Polygon, + type SolidTriangleBasis, +} from "@layoutit/polycss"; +import { + validatePolyMorphModel, + type PolyMorphMat4, + type PolyMorphModel, + type PolyMorphRenderLeaf, + type PolyMorphTarget, + type PolyMorphVec3, +} from "../../contracts/index.js"; +import type { PolyMorphLeafUpdate } from "../../render/index.js"; +import { PolyMorphRuntimeError } from "../runtimeError.js"; +import type { + PolyMorphDeformationFrame, + PolyMorphDeformationInput, + PolyMorphDeformationRuntime, +} from "./types.js"; + +export interface PolyMorphCompiledLeaf { + readonly leaf: PolyMorphRenderLeaf; + readonly vertexIndices: readonly number[]; + readonly polygon: Polygon; + readonly basis: SolidTriangleBasis | null; +} + +type MutableVec3 = [number, number, number]; + +function fail(code: string, path: string, message: string): never { + throw new PolyMorphRuntimeError(code, path, message); +} + +function finite(value: unknown, path: string): number { + if (typeof value !== "number" || !Number.isFinite(value)) { + fail("invalid-number", path, "expected a finite number"); + } + return Object.is(value, -0) ? 0 : value; +} + +function normalize(value: PolyMorphVec3): MutableVec3 { + const length = Math.hypot(...value); + if (length <= 1e-12) return [0, 0, 0]; + return [value[0] / length, value[1] / length, value[2] / length]; +} + +function cloneVectors(values: readonly PolyMorphVec3[]): MutableVec3[] { + return values.map((value) => [value[0], value[1], value[2]]); +} + +function freezeVectors(values: MutableVec3[]): readonly PolyMorphVec3[] { + for (const value of values) Object.freeze(value); + return Object.freeze(values); +} + +function trianglePlan( + compiled: PolyMorphCompiledLeaf, + positions: readonly PolyMorphVec3[], +): { readonly matrix: PolyMorphMat4 | null; readonly visible: boolean } { + const [i0, i1, i2] = compiled.vertexIndices; + const p0 = positions[i0!]!; + const p1 = positions[i1!]!; + const p2 = positions[i2!]!; + const plan = computeSolidTrianglePlanFromCssPoints( + compiled.polygon, + 0, + { seamBleed: 0 }, + { + basis: compiled.basis ?? undefined, + includeColor: false, + primitive: "corner-bevel", + }, + p0[0], p0[1], p0[2], + p1[0], p1[1], p1[2], + p2[0], p2[1], p2[2], + ); + if (!plan) return { matrix: null, visible: false }; + const match = /^matrix3d\(([^)]+)\)$/u.exec(plan.transformText); + if (!match) fail("invalid-transform", compiled.leaf.id, "PolyCSS returned no matrix3d"); + const values = match[1]!.split(",").map(Number); + if (values.length !== 16 || values.some((value) => !Number.isFinite(value))) { + fail("invalid-transform", compiled.leaf.id, "PolyCSS returned an invalid matrix3d"); + } + const xScale = SOLID_TRIANGLE_CANONICAL_SIZE / compiled.leaf.width; + const yScale = SOLID_TRIANGLE_CANONICAL_SIZE / compiled.leaf.height; + for (const index of [0, 1, 2]) values[index] *= xScale; + for (const index of [4, 5, 6]) values[index] *= yScale; + return { + matrix: values as unknown as PolyMorphMat4, + visible: true, + }; +} + +function quadMatrix( + compiled: PolyMorphCompiledLeaf, + positions: readonly PolyMorphVec3[], +): { readonly matrix: PolyMorphMat4; readonly visible: true } { + const [i0, i1, i2, i3] = compiled.vertexIndices; + const p0 = positions[i0!]!; + const p1 = positions[i1!]!; + const p2 = positions[i2!]!; + const p3 = positions[i3!]!; + const predicted = [ + p1[0] + p3[0] - p0[0], + p1[1] + p3[1] - p0[1], + p1[2] + p3[2] - p0[2], + ] as const; + if (Math.hypot( + predicted[0] - p2[0], + predicted[1] - p2[1], + predicted[2] - p2[2], + ) > 1e-6) { + fail("non-affine-polygon", compiled.leaf.id, "deformed quad is not a parallelogram"); + } + const x = [ + (p1[0] - p0[0]) / compiled.leaf.width, + (p1[1] - p0[1]) / compiled.leaf.width, + (p1[2] - p0[2]) / compiled.leaf.width, + ] as const; + const y = [ + (p3[0] - p0[0]) / compiled.leaf.height, + (p3[1] - p0[1]) / compiled.leaf.height, + (p3[2] - p0[2]) / compiled.leaf.height, + ] as const; + const cross = [ + x[1] * y[2] - x[2] * y[1], + x[2] * y[0] - x[0] * y[2], + x[0] * y[1] - x[1] * y[0], + ] as PolyMorphVec3; + const z = normalize(cross); + if (Math.hypot(...z) <= 1e-12) { + fail("degenerate-polygon", compiled.leaf.id, "deformed quad has no area"); + } + return { + matrix: [ + x[0], x[1], x[2], 0, + y[0], y[1], y[2], 0, + z[0], z[1], z[2], 0, + p0[0], p0[1], p0[2], 1, + ], + visible: true, + }; +} + +export function computePolyMorphPreparedLeafMatrix( + compiled: PolyMorphCompiledLeaf, + positions: readonly PolyMorphVec3[], +): { readonly matrix: PolyMorphMat4 | null; readonly visible: boolean } { + if (compiled.vertexIndices.length === 3) return trianglePlan(compiled, positions); + if (compiled.vertexIndices.length === 4) return quadMatrix(compiled, positions); + fail( + "unsupported-deformation", + compiled.leaf.id, + "caller-driven deformation supports prepared triangles and affine quads", + ); +} + +export function compilePolyMorphPreparedLeaf( + model: PolyMorphModel, + leaf: PolyMorphRenderLeaf, +): PolyMorphCompiledLeaf { + const polygon = model.topology.polygons.find((entry) => entry.id === leaf.polygonId); + if (!polygon) fail("unknown-polygon", leaf.id, leaf.polygonId); + const vertices = polygon.vertexIndices.map((index) => [...model.topology.vertices[index]!] as [ + number, + number, + number, + ]); + const poly: Polygon = { vertices }; + let basis: SolidTriangleBasis | null = null; + if (vertices.length === 3) { + const [p0, p1, p2] = vertices; + const plan = computeSolidTrianglePlanFromCssPoints( + poly, + 0, + { seamBleed: 0 }, + { includeColor: false, primitive: "corner-bevel" }, + p0![0], p0![1], p0![2], + p1![0], p1![1], p1![2], + p2![0], p2![1], p2![2], + ); + if (!plan) fail("degenerate-polygon", leaf.id, "base triangle has no area"); + basis = plan.basis; + } + return { + leaf, + vertexIndices: polygon.vertexIndices, + polygon: poly, + basis, + }; +} + +function exactNumericRecord( + value: Readonly> | undefined, + ids: readonly string[], + path: string, + bounds: ReadonlyMap, +): Record { + const input = value ?? {}; + if (!input || typeof input !== "object" || Array.isArray(input)) { + fail("invalid-input", path, "expected an object"); + } + const allowed = new Set(ids); + const unknown = Object.keys(input).find((key) => !allowed.has(key)); + if (unknown) fail("unknown-id", path, unknown); + return Object.fromEntries(ids.map((id) => { + const [minimum, maximum] = bounds.get(id) ?? [0, 1]; + const requested = finite(input[id] ?? 0, `${path}.${id}`); + if (requested < minimum || requested > maximum) { + fail("out-of-range", `${path}.${id}`, `expected ${minimum} <= value <= ${maximum}`); + } + return [id, requested]; + })); +} + +function targetMap(model: PolyMorphModel): ReadonlyMap { + return new Map(model.deformation.kind === "morph-regions" + ? model.deformation.targets.map((target) => [target.id, target]) + : []); +} + +export function createPolyMorphDeformationRuntime( + modelInput: unknown, +): PolyMorphDeformationRuntime { + const model = validatePolyMorphModel(modelInput); + if (model.deformation.kind === "joint-skin") { + fail("profile-not-executable", "$.profile", "joint skin requires the skinning runtime"); + } + const targets = targetMap(model); + const targetIds = [...targets.keys()]; + const controlIds = model.controls.map((control) => control.id); + const targetBounds = new Map(targetIds.map((id) => [id, [0, 1] as const])); + const controlBounds = new Map(model.controls.map((control) => [ + control.id, + [control.minimum, control.maximum] as const, + ])); + const leafByPolygon = new Map(model.render.leaves.map((leaf) => [leaf.polygonId, leaf])); + const polygonsByVertex = new Map(); + for (const polygon of model.topology.polygons) { + for (const vertexIndex of polygon.vertexIndices) { + const rows = polygonsByVertex.get(vertexIndex) ?? []; + rows.push(polygon.id); + polygonsByVertex.set(vertexIndex, rows); + } + } + const affectedLeavesByTarget = new Map>(); + for (const target of targets.values()) { + const leafIds = new Set(); + for (const delta of target.deltas) { + for (const polygonId of polygonsByVertex.get(delta.vertexIndex) ?? []) { + const leaf = leafByPolygon.get(polygonId); + if (leaf) leafIds.add(leaf.id); + } + } + affectedLeavesByTarget.set(target.id, leafIds); + } + const compiledLeaves = new Map(model.render.leaves.map((leaf) => [ + leaf.id, + compilePolyMorphPreparedLeaf(model, leaf), + ])); + const basePositions = freezeVectors(cloneVectors(model.topology.vertices)); + const baseNormals = freezeVectors(cloneVectors(model.topology.normals)); + let priorEffective = Object.fromEntries(targetIds.map((id) => [id, 0])) as Record; + let lastPositions = basePositions; + let lastNormals = baseNormals; + + const sample = (input: PolyMorphDeformationInput): PolyMorphDeformationFrame => { + if (!input || !Number.isSafeInteger(input.tick) || input.tick < 0) { + fail("invalid-tick", "$.tick", "expected a non-negative safe integer"); + } + const requestedMorph = exactNumericRecord( + input.morphWeights, + targetIds, + "$.morphWeights", + targetBounds, + ); + const controlValues = exactNumericRecord( + input.controlValues, + controlIds, + "$.controlValues", + controlBounds, + ); + const effective: Record = { ...requestedMorph }; + for (const control of model.controls) { + const value = controlValues[control.id]!; + for (const target of control.targets) { + effective[target.targetId] = Math.max( + 0, + Math.min(1, (effective[target.targetId] ?? 0) + value * target.scale), + ); + } + } + const changedTargets = targetIds.filter((id) => effective[id] !== priorEffective[id]); + let positions: readonly PolyMorphVec3[] = lastPositions; + let normals: readonly PolyMorphVec3[] = lastNormals; + if (changedTargets.length > 0) { + const nextPositions = cloneVectors(basePositions); + let nextNormals = cloneVectors(baseNormals); + for (const targetId of targetIds) { + const weight = effective[targetId]!; + if (weight === 0) continue; + for (const delta of targets.get(targetId)!.deltas) { + const position = nextPositions[delta.vertexIndex]!; + if (delta.position) { + position[0] += delta.position[0] * weight; + position[1] += delta.position[1] * weight; + position[2] += delta.position[2] * weight; + } + const normal = nextNormals[delta.vertexIndex]!; + if (delta.normal) { + normal[0] += delta.normal[0] * weight; + normal[1] += delta.normal[1] * weight; + normal[2] += delta.normal[2] * weight; + } + } + } + nextNormals = nextNormals.map(normalize); + positions = freezeVectors(nextPositions); + normals = freezeVectors(nextNormals); + } + const dirtySet = new Set(); + for (const targetId of changedTargets) { + for (const leafId of affectedLeavesByTarget.get(targetId) ?? []) dirtySet.add(leafId); + } + const dirtyLeafIds = model.render.leaves + .map((leaf) => leaf.id) + .filter((id) => dirtySet.has(id)); + const leafUpdates: PolyMorphLeafUpdate[] = dirtyLeafIds.map((leafId) => { + const prepared = computePolyMorphPreparedLeafMatrix(compiledLeaves.get(leafId)!, positions); + return { + leafId, + visible: prepared.visible, + ...(prepared.matrix ? { matrix: prepared.matrix } : {}), + }; + }); + lastPositions = positions; + lastNormals = normals; + priorEffective = { ...effective }; + return { + tick: input.tick, + positions, + normals, + morphWeights: Object.freeze({ ...effective }), + controlValues: Object.freeze({ ...controlValues }), + dirtyLeafIds: Object.freeze(dirtyLeafIds), + leafUpdates: Object.freeze(leafUpdates), + runtimePolygonConstructions: 0, + runtimeTopologyConstructions: 0, + atlasRedraws: 0, + }; + }; + + return Object.freeze({ + model, + targetIds: Object.freeze(targetIds), + controlIds: Object.freeze(controlIds), + basePositions: Object.freeze(basePositions), + baseNormals: Object.freeze(baseNormals), + sample, + reset(): void { + priorEffective = Object.fromEntries(targetIds.map((id) => [id, 0])); + lastPositions = basePositions; + lastNormals = baseNormals; + }, + }); +} diff --git a/packages/morph/src/runtime/deformation/index.ts b/packages/morph/src/runtime/deformation/index.ts new file mode 100644 index 00000000..05a40bca --- /dev/null +++ b/packages/morph/src/runtime/deformation/index.ts @@ -0,0 +1,7 @@ +export { createPolyMorphDeformationRuntime } from "./deformation.js"; +export type { + PolyMorphDeformationFrame, + PolyMorphDeformationInput, + PolyMorphDeformationRuntime, + PolyMorphPreparedLeafMatrix, +} from "./types.js"; diff --git a/packages/morph/src/runtime/deformation/types.ts b/packages/morph/src/runtime/deformation/types.ts new file mode 100644 index 00000000..2a080672 --- /dev/null +++ b/packages/morph/src/runtime/deformation/types.ts @@ -0,0 +1,41 @@ +import type { + PolyMorphMat4, + PolyMorphModel, + PolyMorphVec3, +} from "../../contracts/index.js"; +import type { PolyMorphLeafUpdate } from "../../render/index.js"; + +export interface PolyMorphDeformationInput { + readonly tick: number; + readonly morphWeights?: Readonly>; + readonly controlValues?: Readonly>; +} + +export interface PolyMorphDeformationFrame { + readonly tick: number; + readonly positions: readonly PolyMorphVec3[]; + readonly normals: readonly PolyMorphVec3[]; + readonly morphWeights: Readonly>; + readonly controlValues: Readonly>; + readonly dirtyLeafIds: readonly string[]; + readonly leafUpdates: readonly PolyMorphLeafUpdate[]; + readonly runtimePolygonConstructions: 0; + readonly runtimeTopologyConstructions: 0; + readonly atlasRedraws: 0; +} + +export interface PolyMorphDeformationRuntime { + readonly model: PolyMorphModel; + readonly targetIds: readonly string[]; + readonly controlIds: readonly string[]; + readonly basePositions: readonly PolyMorphVec3[]; + readonly baseNormals: readonly PolyMorphVec3[]; + sample(input: PolyMorphDeformationInput): PolyMorphDeformationFrame; + reset(): void; +} + +export interface PolyMorphPreparedLeafMatrix { + readonly leafId: string; + readonly matrix: PolyMorphMat4 | null; + readonly visible: boolean; +} diff --git a/packages/morph/src/runtime/index.ts b/packages/morph/src/runtime/index.ts new file mode 100644 index 00000000..54978dc6 --- /dev/null +++ b/packages/morph/src/runtime/index.ts @@ -0,0 +1,9 @@ +export * from "./animation/index.js"; +export * from "./controls/index.js"; +export * from "./deformation/index.js"; +export * from "./playback/index.js"; +export * from "./prepared-state/index.js"; +export { POLY_MORPH_EXECUTABLE_PROFILES } from "./profiles.js"; +export { PolyMorphRuntimeError } from "./runtimeError.js"; +export * from "./skinning/index.js"; +export * from "./springs/index.js"; diff --git a/packages/morph/src/runtime/playback/index.ts b/packages/morph/src/runtime/playback/index.ts new file mode 100644 index 00000000..027451fe --- /dev/null +++ b/packages/morph/src/runtime/playback/index.ts @@ -0,0 +1,5 @@ +export { createPolyMorphPlaybackRuntime } from "./playback.js"; +export type { + PolyMorphPlaybackRuntime, + PolyMorphPlaybackSample, +} from "./playback.js"; diff --git a/packages/morph/src/runtime/playback/playback.test.ts b/packages/morph/src/runtime/playback/playback.test.ts new file mode 100644 index 00000000..6ceb81df --- /dev/null +++ b/packages/morph/src/runtime/playback/playback.test.ts @@ -0,0 +1,162 @@ +import { describe, expect, it, vi } from "vitest"; +import { PolyMorphContractError } from "../../contracts/index.js"; +import { mountPolyMorphModel } from "../../render/index.js"; +import { + clonePolyMorphFixture, + createPolyMorphModelFixture, +} from "../../testing/modelFixture.js"; +import { createPolyMorphPlaybackFixture } from "../../testing/playbackFixture.js"; +import { + POLY_MORPH_EXECUTABLE_PROFILES, + PolyMorphRuntimeError, +} from "../index.js"; +import { createPolyMorphPlaybackRuntime } from "./index.js"; + +describe("PolyMorph prepared playback", () => { + it("samples exact frame boundaries and constant intervals", () => { + const runtime = createPolyMorphPlaybackRuntime(createPolyMorphPlaybackFixture()); + expect(runtime.sample(0)).toMatchObject({ + sampledTimeMs: 0, + frameIndex: 0, + dirtyLeafIds: [], + }); + expect(runtime.sample(249)).toMatchObject({ + sampledTimeMs: 249, + frameIndex: 0, + dirtyLeafIds: [], + }); + const changed = runtime.sample(250); + expect(changed.frameIndex).toBe(1); + expect(changed.modelChanged).toBe(true); + expect(changed.dirtyShapeIds).toEqual(["gem"]); + expect(changed.dirtyLeafIds).toEqual(["gem-panel-leaf"]); + expect(changed.state.leaves[0]).toMatchObject({ + visible: false, + opacity: 0.5, + atlasRow: 1, + }); + runtime.commit(changed); + expect(runtime.sample(500).update).toEqual({}); + }); + + it("loops deterministically and diffs a backwards wrap", () => { + const left = createPolyMorphPlaybackRuntime(createPolyMorphPlaybackFixture()); + const right = createPolyMorphPlaybackRuntime(createPolyMorphPlaybackFixture()); + const times = [250, 500, 750, 1250, 2000]; + const leftSamples = times.map((time) => { + const sample = left.sample(time); + left.commit(sample); + return sample; + }); + const rightSamples = times.map((time) => { + const sample = right.sample(time); + right.commit(sample); + return sample; + }); + expect(leftSamples).toEqual(rightSamples); + expect(leftSamples[3]).toMatchObject({ + sampledTimeMs: 250, + frameIndex: 1, + dirtyLeafIds: ["gem-panel-leaf"], + }); + expect(leftSamples[4]?.sampledTimeMs).toBe(0); + }); + + it("feeds sparse state into the retained mount without remounting", () => { + const fixture = createPolyMorphPlaybackFixture(); + const runtime = createPolyMorphPlaybackRuntime(fixture); + const host = document.createElement("div"); + document.body.appendChild(host); + const mounted = mountPolyMorphModel(host, fixture, { + resources: new Map([[ + "assets/gem.webp", + { + descriptor: { + path: "assets/gem.webp", + role: "image", + mediaType: "image/webp", + bytes: 1, + sha256: "0".repeat(64), + }, + bytes: new Uint8Array([1]), + }, + ]]), + }); + const animated = mounted.leafHandles.get("gem-panel-leaf")!.element; + const stable = mounted.leafHandles.get("static-panel-leaf")!.element; + const sample = runtime.sample(250); + const result = mounted.apply(sample.update); + runtime.commit(sample); + expect(result).toMatchObject({ + dirtyLeavesVisited: 1, + modelTransformWrites: 1, + shapeTransformWrites: 1, + leafTransformWrites: 1, + visibilityWrites: 1, + opacityWrites: 1, + atlasRowWrites: 1, + domCreations: 0, + domRemovals: 0, + topologyConstructions: 0, + atlasRedraws: 0, + }); + expect(mounted.leafHandles.get("gem-panel-leaf")!.element).toBe(animated); + expect(mounted.leafHandles.get("static-panel-leaf")!.element).toBe(stable); + expect(runtime.sample(500).dirtyLeafIds).toEqual([]); + }); + + it("commits playback state only after the caller applies a sample", () => { + const runtime = createPolyMorphPlaybackRuntime( + createPolyMorphPlaybackFixture(), + ); + const first = runtime.sample(250); + const retry = runtime.sample(250); + expect(retry.update).toEqual(first.update); + expect(() => runtime.commit(first)).toThrowError(PolyMorphRuntimeError); + runtime.commit(retry); + expect(runtime.sample(250).update).toEqual({}); + }); + + it("rejects malformed packets and invalid image rows", () => { + const unknown = clonePolyMorphFixture(createPolyMorphPlaybackFixture()); + unknown.playback!.frames[1]!.leaves[0]!.leafId = "missing-leaf"; + expect(() => createPolyMorphPlaybackRuntime(unknown)).toThrowError(PolyMorphContractError); + + const unordered = clonePolyMorphFixture(createPolyMorphPlaybackFixture()); + unordered.playback!.frames[1]!.timeMs = 0; + expect(() => createPolyMorphPlaybackRuntime(unordered)).toThrowError(PolyMorphContractError); + + const row = clonePolyMorphFixture(createPolyMorphPlaybackFixture()); + row.playback!.frames[1]!.leaves[0]!.atlasRow = 2; + expect(() => createPolyMorphPlaybackRuntime(row)).toThrowError(PolyMorphRuntimeError); + + expect(() => createPolyMorphPlaybackRuntime(createPolyMorphModelFixture())) + .toThrowError(PolyMorphRuntimeError); + }); + + it("rejects invalid sample time and never owns a scheduler", () => { + const requestFrame = vi.fn(() => 1); + const previous = globalThis.requestAnimationFrame; + Object.defineProperty(globalThis, "requestAnimationFrame", { + configurable: true, + value: requestFrame, + }); + try { + const runtime = createPolyMorphPlaybackRuntime(createPolyMorphPlaybackFixture()); + expect(() => runtime.sample(-1)).toThrowError(PolyMorphRuntimeError); + expect(() => runtime.sample(Number.NaN)).toThrowError(PolyMorphRuntimeError); + runtime.sample(250); + expect(requestFrame).not.toHaveBeenCalled(); + } finally { + Object.defineProperty(globalThis, "requestAnimationFrame", { + configurable: true, + value: previous, + }); + } + }); + + it("advertises prepared playback only with the executable runtime present", () => { + expect(POLY_MORPH_EXECUTABLE_PROFILES).toContain("prepared-playback"); + expect(createPolyMorphPlaybackRuntime).toBeTypeOf("function"); + }); +}); diff --git a/packages/morph/src/runtime/playback/playback.ts b/packages/morph/src/runtime/playback/playback.ts new file mode 100644 index 00000000..f0c1c5ed --- /dev/null +++ b/packages/morph/src/runtime/playback/playback.ts @@ -0,0 +1,143 @@ +import { + validatePolyMorphModel, + type PolyMorphModel, +} from "../../contracts/index.js"; +import type { PolyMorphRetainedUpdate } from "../../render/index.js"; +import { + applyPolyMorphPlaybackFrame, + createPolyMorphPreparedState, + diffPolyMorphPreparedStates, + type PolyMorphPreparedState, +} from "../prepared-state/index.js"; +import { PolyMorphRuntimeError } from "../runtimeError.js"; + +export interface PolyMorphPlaybackSample { + readonly requestedTimeMs: number; + readonly sampledTimeMs: number; + readonly frameIndex: number; + readonly state: PolyMorphPreparedState; + readonly update: PolyMorphRetainedUpdate; + readonly modelChanged: boolean; + readonly dirtyShapeIds: readonly string[]; + readonly dirtyLeafIds: readonly string[]; + readonly domCreations: 0; + readonly domRemovals: 0; + readonly topologyConstructions: 0; + readonly atlasConstructions: 0; + readonly atlasRedraws: 0; + readonly schedulerCallbacks: 0; +} + +export interface PolyMorphPlaybackRuntime { + readonly model: PolyMorphModel; + readonly durationMs: number; + readonly loop: boolean; + readonly frameCount: number; + sample(timeMs: number): PolyMorphPlaybackSample; + commit(sample: PolyMorphPlaybackSample): void; + reset(): void; +} + +function fail(code: string, path: string, message: string): never { + throw new PolyMorphRuntimeError(code, path, message); +} + +function sampledTime(durationMs: number, loop: boolean, timeMs: number): number { + if (loop) { + const result = timeMs % durationMs; + return Object.is(result, -0) ? 0 : result; + } + return Math.min(timeMs, durationMs); +} + +function frameAt(times: readonly number[], timeMs: number): number { + let low = 0; + let high = times.length - 1; + while (low <= high) { + const middle = Math.floor((low + high) / 2); + if (times[middle]! <= timeMs) low = middle + 1; + else high = middle - 1; + } + return Math.max(0, high); +} + +export function createPolyMorphPlaybackRuntime( + modelInput: unknown, +): PolyMorphPlaybackRuntime { + const model = validatePolyMorphModel(modelInput); + if (model.profile !== "prepared-playback" || !model.playback) { + fail("profile-mismatch", "$.profile", "playback requires the prepared-playback profile"); + } + const playback = model.playback; + const initial = createPolyMorphPreparedState(model); + const states: PolyMorphPreparedState[] = []; + let state = initial; + const leafById = new Map(model.render.leaves.map((leaf) => [leaf.id, leaf])); + for (const [index, frame] of playback.frames.entries()) { + for (const update of frame.leaves) { + if (update.atlasRow === null) continue; + const leaf = leafById.get(update.leafId)!; + if (!leaf.atlas) { + fail("invalid-atlas-row", `$.playback.frames[${index}]`, `${leaf.id} has no image rows`); + } + const y = leaf.atlas.y + update.atlasRow * leaf.atlas.height; + if (y + leaf.atlas.height > leaf.atlas.pageHeight) { + fail("invalid-atlas-row", `$.playback.frames[${index}]`, `${leaf.id} row exceeds its page`); + } + } + state = applyPolyMorphPlaybackFrame(state, frame); + states.push(state); + } + const times = playback.frames.map((frame) => frame.timeMs); + let current = initial; + let pending: PolyMorphPlaybackSample | null = null; + const sample = (timeMs: number): PolyMorphPlaybackSample => { + if (!Number.isFinite(timeMs) || timeMs < 0) { + fail("invalid-time", "$.timeMs", "expected a finite non-negative time"); + } + const resolvedTime = sampledTime(playback.durationMs, playback.loop, timeMs); + const frameIndex = frameAt(times, resolvedTime); + const next = states[frameIndex]!; + const diff = diffPolyMorphPreparedStates(current, next); + const result = Object.freeze({ + requestedTimeMs: timeMs, + sampledTimeMs: resolvedTime, + frameIndex, + state: next, + update: diff.update, + modelChanged: diff.modelChanged, + dirtyShapeIds: diff.dirtyShapeIds, + dirtyLeafIds: diff.dirtyLeafIds, + domCreations: 0, + domRemovals: 0, + topologyConstructions: 0, + atlasConstructions: 0, + atlasRedraws: 0, + schedulerCallbacks: 0, + }); + pending = result; + return result; + }; + return Object.freeze({ + model, + durationMs: playback.durationMs, + loop: playback.loop, + frameCount: playback.frames.length, + sample, + commit(sampled: PolyMorphPlaybackSample): void { + if (sampled !== pending) { + fail( + "invalid-sample", + "$.sample", + "commit requires the latest unapplied playback sample", + ); + } + current = sampled.state; + pending = null; + }, + reset(): void { + current = initial; + pending = null; + }, + }); +} diff --git a/packages/morph/src/runtime/prepared-state/index.ts b/packages/morph/src/runtime/prepared-state/index.ts new file mode 100644 index 00000000..12f4257e --- /dev/null +++ b/packages/morph/src/runtime/prepared-state/index.ts @@ -0,0 +1,11 @@ +export { + applyPolyMorphPlaybackFrame, + createPolyMorphPreparedState, + diffPolyMorphPreparedStates, +} from "./state.js"; +export type { + PolyMorphPreparedLeafState, + PolyMorphPreparedShapeState, + PolyMorphPreparedState, + PolyMorphPreparedStateDiff, +} from "./state.js"; diff --git a/packages/morph/src/runtime/prepared-state/state.test.ts b/packages/morph/src/runtime/prepared-state/state.test.ts new file mode 100644 index 00000000..a65d98e1 --- /dev/null +++ b/packages/morph/src/runtime/prepared-state/state.test.ts @@ -0,0 +1,39 @@ +import { describe, expect, it } from "vitest"; +import { createPolyMorphPlaybackFixture } from "../../testing/playbackFixture.js"; +import { + applyPolyMorphPlaybackFrame, + createPolyMorphPreparedState, + diffPolyMorphPreparedStates, +} from "./index.js"; + +describe("PolyMorph prepared state", () => { + it("applies sparse frames without changing topology or source order", () => { + const fixture = createPolyMorphPlaybackFixture(); + const initial = createPolyMorphPreparedState(fixture); + const next = applyPolyMorphPlaybackFrame(initial, fixture.playback!.frames[1]!); + expect(next.shapes.map((shape) => shape.shapeId)).toEqual(["gem"]); + expect(next.leaves.map((leaf) => leaf.leafId)).toEqual([ + "gem-panel-leaf", + "static-panel-leaf", + ]); + expect(next.leaves[0]).toMatchObject({ + visible: false, + opacity: 0.5, + atlasRow: 1, + }); + expect(next.leaves[1]).toBe(initial.leaves[1]); + }); + + it("diffs only changed model, shape, and leaf fields", () => { + const fixture = createPolyMorphPlaybackFixture(); + const initial = createPolyMorphPreparedState(fixture); + const next = applyPolyMorphPlaybackFrame(initial, fixture.playback!.frames[1]!); + const diff = diffPolyMorphPreparedStates(initial, next); + expect(diff.modelChanged).toBe(true); + expect(diff.dirtyShapeIds).toEqual(["gem"]); + expect(diff.dirtyLeafIds).toEqual(["gem-panel-leaf"]); + expect(diff.update.shapes).toHaveLength(1); + expect(diff.update.leaves).toHaveLength(1); + expect(diffPolyMorphPreparedStates(next, next).update).toEqual({}); + }); +}); diff --git a/packages/morph/src/runtime/prepared-state/state.ts b/packages/morph/src/runtime/prepared-state/state.ts new file mode 100644 index 00000000..82c47fa1 --- /dev/null +++ b/packages/morph/src/runtime/prepared-state/state.ts @@ -0,0 +1,157 @@ +import { + validatePolyMorphModel, + type PolyMorphMat4, + type PolyMorphPlaybackFrame, +} from "../../contracts/index.js"; +import type { + PolyMorphLeafUpdate, + PolyMorphRetainedUpdate, + PolyMorphShapeUpdate, +} from "../../render/index.js"; +import { PolyMorphRuntimeError } from "../runtimeError.js"; + +export interface PolyMorphPreparedShapeState { + readonly shapeId: string; + readonly matrix: PolyMorphMat4; +} + +export interface PolyMorphPreparedLeafState { + readonly leafId: string; + readonly matrix: PolyMorphMat4; + readonly visible: boolean; + readonly opacity: number; + readonly atlasRow: number; +} + +export interface PolyMorphPreparedState { + readonly modelMatrix: PolyMorphMat4; + readonly shapes: readonly PolyMorphPreparedShapeState[]; + readonly leaves: readonly PolyMorphPreparedLeafState[]; +} + +export interface PolyMorphPreparedStateDiff { + readonly update: PolyMorphRetainedUpdate; + readonly modelChanged: boolean; + readonly dirtyShapeIds: readonly string[]; + readonly dirtyLeafIds: readonly string[]; +} + +function fail(code: string, path: string, message: string): never { + throw new PolyMorphRuntimeError(code, path, message); +} + +function cloneMatrix(value: PolyMorphMat4): PolyMorphMat4 { + return Object.freeze([...value]) as unknown as PolyMorphMat4; +} + +function matrixEqual(left: PolyMorphMat4, right: PolyMorphMat4): boolean { + return left.every((value, index) => value === right[index]); +} + +export function createPolyMorphPreparedState(modelInput: unknown): PolyMorphPreparedState { + const model = validatePolyMorphModel(modelInput); + return Object.freeze({ + modelMatrix: cloneMatrix(model.render.modelMatrix), + shapes: Object.freeze(model.render.shapes.map((shape) => Object.freeze({ + shapeId: shape.id, + matrix: cloneMatrix(shape.matrix), + }))), + leaves: Object.freeze(model.render.leaves.map((leaf) => Object.freeze({ + leafId: leaf.id, + matrix: cloneMatrix(leaf.matrix), + visible: true, + opacity: 1, + atlasRow: 0, + }))), + }); +} + +export function applyPolyMorphPlaybackFrame( + state: PolyMorphPreparedState, + frame: PolyMorphPlaybackFrame, +): PolyMorphPreparedState { + const shapeUpdates = new Map(frame.shapes.map((shape) => [shape.shapeId, shape])); + const leafUpdates = new Map(frame.leaves.map((leaf) => [leaf.leafId, leaf])); + for (const id of shapeUpdates.keys()) { + if (!state.shapes.some((shape) => shape.shapeId === id)) { + fail("unknown-shape", "$.frame.shapes", id); + } + } + for (const id of leafUpdates.keys()) { + if (!state.leaves.some((leaf) => leaf.leafId === id)) { + fail("unknown-leaf", "$.frame.leaves", id); + } + } + return Object.freeze({ + modelMatrix: frame.modelMatrix === null + ? state.modelMatrix + : cloneMatrix(frame.modelMatrix), + shapes: Object.freeze(state.shapes.map((shape) => { + const update = shapeUpdates.get(shape.shapeId); + return update + ? Object.freeze({ shapeId: shape.shapeId, matrix: cloneMatrix(update.matrix) }) + : shape; + })), + leaves: Object.freeze(state.leaves.map((leaf) => { + const update = leafUpdates.get(leaf.leafId); + if (!update) return leaf; + return Object.freeze({ + leafId: leaf.leafId, + matrix: update.matrix === null ? leaf.matrix : cloneMatrix(update.matrix), + visible: update.visible ?? leaf.visible, + opacity: update.opacity ?? leaf.opacity, + atlasRow: update.atlasRow ?? leaf.atlasRow, + }); + })), + }); +} + +export function diffPolyMorphPreparedStates( + previous: PolyMorphPreparedState, + next: PolyMorphPreparedState, +): PolyMorphPreparedStateDiff { + if ( + previous.shapes.length !== next.shapes.length + || previous.leaves.length !== next.leaves.length + ) { + fail("state-mismatch", "$", "prepared state topology differs"); + } + const modelChanged = !matrixEqual(previous.modelMatrix, next.modelMatrix); + const shapes: PolyMorphShapeUpdate[] = []; + for (let index = 0; index < next.shapes.length; index += 1) { + const left = previous.shapes[index]!; + const right = next.shapes[index]!; + if (left.shapeId !== right.shapeId) fail("state-mismatch", "$.shapes", "source order differs"); + if (!matrixEqual(left.matrix, right.matrix)) { + shapes.push({ shapeId: right.shapeId, matrix: right.matrix }); + } + } + const leaves: PolyMorphLeafUpdate[] = []; + for (let index = 0; index < next.leaves.length; index += 1) { + const left = previous.leaves[index]!; + const right = next.leaves[index]!; + if (left.leafId !== right.leafId) fail("state-mismatch", "$.leaves", "source order differs"); + const update: { + leafId: string; + matrix?: PolyMorphMat4; + visible?: boolean; + opacity?: number; + atlasRow?: number; + } = { leafId: right.leafId }; + if (!matrixEqual(left.matrix, right.matrix)) update.matrix = right.matrix; + if (left.visible !== right.visible) update.visible = right.visible; + if (left.opacity !== right.opacity) update.opacity = right.opacity; + if (left.atlasRow !== right.atlasRow) update.atlasRow = right.atlasRow; + if (Object.keys(update).length > 1) leaves.push(update); + } + return Object.freeze({ + update: Object.freeze({ + ...(modelChanged ? { modelMatrix: next.modelMatrix } : {}), + ...(shapes.length > 0 ? { shapes: Object.freeze(shapes) } : {}), + ...(leaves.length > 0 ? { leaves: Object.freeze(leaves) } : {}), + }), + modelChanged, + dirtyShapeIds: Object.freeze(shapes.map((shape) => shape.shapeId)), + dirtyLeafIds: Object.freeze(leaves.map((leaf) => leaf.leafId)), + }); +} diff --git a/packages/morph/src/runtime/profiles.ts b/packages/morph/src/runtime/profiles.ts new file mode 100644 index 00000000..28d9c463 --- /dev/null +++ b/packages/morph/src/runtime/profiles.ts @@ -0,0 +1,8 @@ +import type { PolyMorphProfile } from "../contracts/index.js"; + +export const POLY_MORPH_EXECUTABLE_PROFILES = Object.freeze([ + "joint-skin", + "morph-regions", + "prepared-playback", + "static-prepared", +] as const satisfies readonly PolyMorphProfile[]); diff --git a/packages/morph/src/runtime/runtimeError.ts b/packages/morph/src/runtime/runtimeError.ts new file mode 100644 index 00000000..3e8503a7 --- /dev/null +++ b/packages/morph/src/runtime/runtimeError.ts @@ -0,0 +1,11 @@ +export class PolyMorphRuntimeError extends Error { + readonly code: string; + readonly path: string; + + constructor(code: string, path: string, message: string) { + super(`${path}: ${message}`); + this.name = "PolyMorphRuntimeError"; + this.code = code; + this.path = path; + } +} diff --git a/packages/morph/src/runtime/skinning/index.ts b/packages/morph/src/runtime/skinning/index.ts new file mode 100644 index 00000000..d90b0f83 --- /dev/null +++ b/packages/morph/src/runtime/skinning/index.ts @@ -0,0 +1,6 @@ +export { createPolyMorphSkinningRuntime } from "./skinning.js"; +export type { + PolyMorphSkinningFrame, + PolyMorphSkinningInput, + PolyMorphSkinningRuntime, +} from "./skinning.js"; diff --git a/packages/morph/src/runtime/skinning/skinning.test.ts b/packages/morph/src/runtime/skinning/skinning.test.ts new file mode 100644 index 00000000..dc6cbb47 --- /dev/null +++ b/packages/morph/src/runtime/skinning/skinning.test.ts @@ -0,0 +1,146 @@ +import { describe, expect, it } from "vitest"; +import { PolyMorphContractError } from "../../contracts/index.js"; +import { clonePolyMorphFixture } from "../../testing/modelFixture.js"; +import { createPolyMorphSkinningFixture } from "../../testing/skinningFixture.js"; +import { + POLY_MORPH_EXECUTABLE_PROFILES, + PolyMorphRuntimeError, +} from "../index.js"; +import { createPolyMorphSkinningRuntime } from "./index.js"; + +describe("PolyMorph joint skinning", () => { + it("keeps the exact rest pose without dirty rows", () => { + const fixture = createPolyMorphSkinningFixture(); + const runtime = createPolyMorphSkinningRuntime(fixture); + const frame = runtime.sample({ tick: 0 }); + expect(frame.positions).toEqual(fixture.topology.vertices); + expect(frame.normals).toEqual(fixture.topology.normals); + expect(frame.dirtyLeafIds).toEqual([]); + expect(frame.leafUpdates).toEqual([]); + }); + + it("matches hand-computed parent-child and weighted deformation", () => { + const runtime = createPolyMorphSkinningRuntime(createPolyMorphSkinningFixture()); + const frame = runtime.sample({ + tick: 1, + jointTransforms: new Map([ + ["root", { translation: [2, 0, 0] }], + ["tip", { translation: [0, 2, 0] }], + ]), + }); + expect(frame.positions[0]).toEqual([2, 0, 0]); + expect(frame.positions[1]).toEqual([3, 1, 0]); + expect(frame.positions[2]).toEqual([2, 3, 0]); + expect(frame.normals).toEqual([ + [0, 0, 1], + [0, 0, 1], + [0, 0, 1], + ]); + expect(frame.dirtyLeafIds).toEqual(["gem-panel-leaf"]); + expect(frame.leafUpdates[0]?.matrix).toHaveLength(16); + expect(Object.isFrozen(frame.positions)).toBe(true); + expect(Object.isFrozen(frame.positions[0]!)).toBe(true); + expect(Object.isFrozen(frame.normals[0]!)).toBe(true); + }); + + it("uses inverse-transpose normal transforms for non-uniform scale", () => { + const fixture = clonePolyMorphFixture(createPolyMorphSkinningFixture()); + const unit = Math.SQRT1_2; + fixture.topology.normals = fixture.topology.normals.map(() => [unit, unit, 0]); + const runtime = createPolyMorphSkinningRuntime(fixture); + const frame = runtime.sample({ + tick: 1, + jointTransforms: new Map([["root", { scale: [2, 1, 1] }]]), + }); + expect(frame.normals[0]![0]).toBeCloseTo(1 / Math.sqrt(5)); + expect(frame.normals[0]![1]).toBeCloseTo(2 / Math.sqrt(5)); + }); + + it("turns a repeated pose into a no-op", () => { + const runtime = createPolyMorphSkinningRuntime(createPolyMorphSkinningFixture()); + const transforms = new Map([ + ["tip", { translation: [0, 1, 0] as const }], + ]); + expect(runtime.sample({ tick: 1, jointTransforms: transforms }).dirtyLeafIds) + .toEqual(["gem-panel-leaf"]); + expect(runtime.sample({ tick: 2, jointTransforms: transforms }).dirtyLeafIds) + .toEqual([]); + }); + + it("samples joint clips at boundaries and applies them to prepared leaves", () => { + const runtime = createPolyMorphSkinningRuntime(createPolyMorphSkinningFixture()); + const middle = runtime.sampleClip("tip-rise", 500, 1); + expect(middle.positions[1]).toEqual([1, 0.5, 0]); + expect(middle.positions[2]).toEqual([0, 2, 0]); + expect(middle.dirtyLeafIds).toEqual(["gem-panel-leaf"]); + const end = runtime.sampleClip("tip-rise", 1000, 2); + expect(end.positions[2]).toEqual([0, 3, 0]); + }); + + it("respects deterministic clip looping", () => { + const fixture = clonePolyMorphFixture(createPolyMorphSkinningFixture()); + fixture.animations[0]!.loop = true; + const runtime = createPolyMorphSkinningRuntime(fixture); + expect(runtime.sampleClip("tip-rise", 1000, 1).positions).toEqual(fixture.topology.vertices); + expect(runtime.sampleClip("tip-rise", 1500, 2).positions[2]).toEqual([0, 2, 0]); + }); + + it("rejects invalid hierarchy, references, and weights through the contract gate", () => { + const cyclic = clonePolyMorphFixture(createPolyMorphSkinningFixture()); + cyclic.deformation.joints[0]!.parentId = "tip"; + expect(() => createPolyMorphSkinningRuntime(cyclic)).toThrowError(PolyMorphContractError); + + const unknown = clonePolyMorphFixture(createPolyMorphSkinningFixture()); + unknown.deformation.vertices[0]!.influences[0]!.jointId = "missing"; + expect(() => createPolyMorphSkinningRuntime(unknown)).toThrowError(PolyMorphContractError); + + const weights = clonePolyMorphFixture(createPolyMorphSkinningFixture()); + weights.deformation.vertices[1]!.influences[0]!.weight = 0.75; + expect(() => createPolyMorphSkinningRuntime(weights)).toThrowError(PolyMorphContractError); + }); + + it("rejects unknown joints and malformed pose transforms", () => { + const runtime = createPolyMorphSkinningRuntime(createPolyMorphSkinningFixture()); + expect(() => runtime.sample({ + tick: 0, + jointTransforms: new Map([["missing", { translation: [1, 0, 0] }]]), + })).toThrowError(PolyMorphRuntimeError); + expect(() => runtime.sample({ + tick: 0, + jointTransforms: new Map([["tip", { rotation: [0, 0, 0, 0] }]]), + })).toThrowError(PolyMorphRuntimeError); + expect(() => runtime.sample({ + tick: 0, + jointTransforms: new Map([["tip", { scale: [1, 0, 1] }]]), + })).toThrowError(PolyMorphRuntimeError); + }); + + it("advertises joint-skin only with the executable runtime present", () => { + expect(POLY_MORPH_EXECUTABLE_PROFILES).toContain("joint-skin"); + expect(createPolyMorphSkinningRuntime).toBeTypeOf("function"); + }); + + it("does not publish failed quad geometry into the next pose", () => { + const fixture = clonePolyMorphFixture(createPolyMorphSkinningFixture()); + fixture.topology.vertices.push([1, 1, 0]); + fixture.topology.normals.push([0, 0, 1]); + fixture.topology.polygons[0]!.vertexIndices = [0, 1, 3, 2]; + fixture.topology.polygons[0]!.normalIndices = [0, 1, 3, 2]; + fixture.render.leaves[0]!.strategy = "solid-quad"; + fixture.render.leaves[0]!.width = 1; + fixture.render.leaves[0]!.height = 1; + fixture.deformation.vertices.push({ + vertexIndex: 3, + influences: [{ jointId: "root", weight: 1 }], + }); + const runtime = createPolyMorphSkinningRuntime(fixture); + + expect(() => runtime.sample({ + tick: 1, + jointTransforms: new Map([["tip", { translation: [0, 1, 0] }]]), + })).toThrowError(PolyMorphRuntimeError); + const recovered = runtime.sample({ tick: 2 }); + expect(recovered.positions).toEqual(fixture.topology.vertices); + expect(recovered.dirtyLeafIds).toEqual([]); + }); +}); diff --git a/packages/morph/src/runtime/skinning/skinning.ts b/packages/morph/src/runtime/skinning/skinning.ts new file mode 100644 index 00000000..dee78dc7 --- /dev/null +++ b/packages/morph/src/runtime/skinning/skinning.ts @@ -0,0 +1,415 @@ +import { + validatePolyMorphModel, + type PolyMorphJoint, + type PolyMorphMat4, + type PolyMorphModel, + type PolyMorphQuat, + type PolyMorphVec3, +} from "../../contracts/index.js"; +import type { PolyMorphLeafUpdate } from "../../render/index.js"; +import { + createPolyMorphAnimationRuntime, + type PolyMorphJointAnimationSample, +} from "../animation/index.js"; +import { + compilePolyMorphPreparedLeaf, + computePolyMorphPreparedLeafMatrix, +} from "../deformation/deformation.js"; +import { PolyMorphRuntimeError } from "../runtimeError.js"; + +type MutableVec3 = [number, number, number]; + +export interface PolyMorphSkinningInput { + readonly tick: number; + readonly jointTransforms?: ReadonlyMap; +} + +export interface PolyMorphSkinningFrame { + readonly tick: number; + readonly positions: readonly PolyMorphVec3[]; + readonly normals: readonly PolyMorphVec3[]; + readonly globalJointMatrices: ReadonlyMap; + readonly skinMatrices: ReadonlyMap; + readonly dirtyLeafIds: readonly string[]; + readonly leafUpdates: readonly PolyMorphLeafUpdate[]; + readonly runtimePolygonConstructions: 0; + readonly runtimeTopologyConstructions: 0; + readonly atlasRedraws: 0; +} + +export interface PolyMorphSkinningRuntime { + readonly model: PolyMorphModel; + readonly jointIds: readonly string[]; + readonly basePositions: readonly PolyMorphVec3[]; + readonly baseNormals: readonly PolyMorphVec3[]; + sample(input: PolyMorphSkinningInput): PolyMorphSkinningFrame; + sampleClip(clipId: string, timeMs: number, tick: number): PolyMorphSkinningFrame; + reset(): void; +} + +function fail(code: string, path: string, message: string): never { + throw new PolyMorphRuntimeError(code, path, message); +} + +function identity(): PolyMorphMat4 { + return [ + 1, 0, 0, 0, + 0, 1, 0, 0, + 0, 0, 1, 0, + 0, 0, 0, 1, + ]; +} + +function multiply(left: PolyMorphMat4, right: PolyMorphMat4): PolyMorphMat4 { + const output = new Array(16).fill(0); + for (let column = 0; column < 4; column += 1) { + for (let row = 0; row < 4; row += 1) { + let sum = 0; + for (let index = 0; index < 4; index += 1) { + sum += left[index * 4 + row]! * right[column * 4 + index]!; + } + output[column * 4 + row] = Object.is(sum, -0) ? 0 : sum; + } + } + return Object.freeze(output) as unknown as PolyMorphMat4; +} + +function normalizedQuaternion(value: PolyMorphQuat | undefined, path: string): PolyMorphQuat { + if (value === undefined) return [0, 0, 0, 1]; + if ( + !Array.isArray(value) + || value.length !== 4 + || value.some((part) => typeof part !== "number" || !Number.isFinite(part)) + ) { + fail("invalid-quaternion", path, "expected four finite components"); + } + const length = Math.hypot(...value); + if (length <= 1e-12) fail("invalid-quaternion", path, "quaternion must be non-zero"); + return [value[0] / length, value[1] / length, value[2] / length, value[3] / length]; +} + +function vector( + value: PolyMorphVec3 | undefined, + fallback: PolyMorphVec3, + path: string, + positive = false, +): PolyMorphVec3 { + if (value === undefined) return fallback; + if ( + !Array.isArray(value) + || value.length !== 3 + || value.some((part) => typeof part !== "number" || !Number.isFinite(part)) + ) { + fail("invalid-vector", path, "expected three finite components"); + } + if (positive && value.some((part) => part <= 0)) { + fail("invalid-scale", path, "scale components must be positive"); + } + return [value[0], value[1], value[2]]; +} + +function transformMatrix( + value: PolyMorphJointAnimationSample | undefined, + path: string, +): PolyMorphMat4 { + if (value === undefined) return identity(); + if (!value || typeof value !== "object" || Array.isArray(value)) { + fail("invalid-joint-transform", path, "expected an object"); + } + const keys = Object.keys(value); + if (keys.some((key) => key !== "translation" && key !== "rotation" && key !== "scale")) { + fail("invalid-joint-transform", path, "unknown transform field"); + } + const translation = vector(value.translation, [0, 0, 0], `${path}.translation`); + const scale = vector(value.scale, [1, 1, 1], `${path}.scale`, true); + const [x, y, z, w] = normalizedQuaternion(value.rotation, `${path}.rotation`); + const xx = x * x; + const yy = y * y; + const zz = z * z; + const xy = x * y; + const xz = x * z; + const yz = y * z; + const wx = w * x; + const wy = w * y; + const wz = w * z; + return [ + (1 - 2 * (yy + zz)) * scale[0], + (2 * (xy + wz)) * scale[0], + (2 * (xz - wy)) * scale[0], + 0, + (2 * (xy - wz)) * scale[1], + (1 - 2 * (xx + zz)) * scale[1], + (2 * (yz + wx)) * scale[1], + 0, + (2 * (xz + wy)) * scale[2], + (2 * (yz - wx)) * scale[2], + (1 - 2 * (xx + yy)) * scale[2], + 0, + translation[0], + translation[1], + translation[2], + 1, + ]; +} + +function transformPoint(matrix: PolyMorphMat4, point: PolyMorphVec3): MutableVec3 { + return [ + matrix[0] * point[0] + matrix[4] * point[1] + matrix[8] * point[2] + matrix[12], + matrix[1] * point[0] + matrix[5] * point[1] + matrix[9] * point[2] + matrix[13], + matrix[2] * point[0] + matrix[6] * point[1] + matrix[10] * point[2] + matrix[14], + ]; +} + +function transformNormal( + matrix: PolyMorphMat4, + normal: PolyMorphVec3, + path: string, +): MutableVec3 { + const [a, d, g] = matrix; + const b = matrix[4]; + const e = matrix[5]; + const h = matrix[6]; + const c = matrix[8]; + const f = matrix[9]; + const i = matrix[10]; + const determinant = a! * (e! * i! - f! * h!) + - b! * (d! * i! - f! * g!) + + c! * (d! * h! - e! * g!); + if (Math.abs(determinant) <= 1e-12) { + fail("invalid-normal-transform", path, "joint skin matrix is singular"); + } + return [ + ( + (e! * i! - f! * h!) * normal[0] + + (f! * g! - d! * i!) * normal[1] + + (d! * h! - e! * g!) * normal[2] + ) / determinant, + ( + (c! * h! - b! * i!) * normal[0] + + (a! * i! - c! * g!) * normal[1] + + (b! * g! - a! * h!) * normal[2] + ) / determinant, + ( + (b! * f! - c! * e!) * normal[0] + + (c! * d! - a! * f!) * normal[1] + + (a! * e! - b! * d!) * normal[2] + ) / determinant, + ]; +} + +function normalize(value: MutableVec3): MutableVec3 { + const length = Math.hypot(...value); + if (length <= 1e-12) return [0, 0, 0]; + return [value[0] / length, value[1] / length, value[2] / length]; +} + +function freezeVectors(values: MutableVec3[]): readonly PolyMorphVec3[] { + for (const value of values) Object.freeze(value); + return Object.freeze(values); +} + +function matricesEqual(left: PolyMorphMat4, right: PolyMorphMat4): boolean { + return left.every((value, index) => Math.abs(value - right[index]!) <= 1e-12); +} + +function globalMatrices( + joints: readonly PolyMorphJoint[], + transforms: ReadonlyMap, +): ReadonlyMap { + const byId = new Map(joints.map((joint) => [joint.id, joint])); + const resolved = new Map(); + const resolve = (joint: PolyMorphJoint): PolyMorphMat4 => { + const cached = resolved.get(joint.id); + if (cached) return cached; + const local = multiply( + joint.restMatrix, + transformMatrix(transforms.get(joint.id), `$.jointTransforms.${joint.id}`), + ); + const global = joint.parentId === null + ? local + : multiply(resolve(byId.get(joint.parentId)!), local); + resolved.set(joint.id, global); + return global; + }; + for (const joint of joints) resolve(joint); + return resolved; +} + +function skinMatrices( + joints: readonly PolyMorphJoint[], + globals: ReadonlyMap, +): ReadonlyMap { + return new Map(joints.map((joint) => [ + joint.id, + multiply(globals.get(joint.id)!, joint.inverseBindMatrix), + ])); +} + +function validateTransforms( + value: ReadonlyMap | undefined, + jointIds: ReadonlySet, +): ReadonlyMap { + const transforms = value ?? new Map(); + if (!transforms || typeof transforms.entries !== "function") { + fail("invalid-joint-transform", "$.jointTransforms", "expected a map"); + } + for (const [id, transform] of transforms) { + if (!jointIds.has(id)) fail("unknown-joint", "$.jointTransforms", id); + transformMatrix(transform, `$.jointTransforms.${id}`); + } + return transforms; +} + +export function createPolyMorphSkinningRuntime( + modelInput: unknown, +): PolyMorphSkinningRuntime { + const model = validatePolyMorphModel(modelInput); + if (model.deformation.kind !== "joint-skin") { + fail("profile-mismatch", "$.profile", "skinning requires the joint-skin profile"); + } + const deformation = model.deformation; + const jointIds = deformation.joints.map((joint) => joint.id); + const jointIdSet = new Set(jointIds); + const restGlobals = globalMatrices(deformation.joints, new Map()); + const restSkin = skinMatrices(deformation.joints, restGlobals); + const basePositions = freezeVectors( + model.topology.vertices.map((value) => [...value] as MutableVec3), + ); + const baseNormals = freezeVectors( + model.topology.normals.map((value) => [...value] as MutableVec3), + ); + const compiledLeaves = new Map(model.render.leaves.map((leaf) => [ + leaf.id, + compilePolyMorphPreparedLeaf(model, leaf), + ])); + const polygonsByVertex = new Map(); + for (const polygon of model.topology.polygons) { + for (const vertexIndex of polygon.vertexIndices) { + const values = polygonsByVertex.get(vertexIndex) ?? []; + values.push(polygon.id); + polygonsByVertex.set(vertexIndex, values); + } + } + const leafByPolygon = new Map(model.render.leaves.map((leaf) => [leaf.polygonId, leaf.id])); + const skinVertexByIndex = new Map(deformation.vertices.map((vertex) => [ + vertex.vertexIndex, + vertex, + ])); + + const skinGeometry = ( + matrices: ReadonlyMap, + ): { positions: MutableVec3[]; normals: MutableVec3[] } => { + const positions: MutableVec3[] = []; + const normals: MutableVec3[] = []; + for (let vertexIndex = 0; vertexIndex < basePositions.length; vertexIndex += 1) { + const skin = skinVertexByIndex.get(vertexIndex)!; + const position: MutableVec3 = [0, 0, 0]; + const normal: MutableVec3 = [0, 0, 0]; + for (const influence of skin.influences) { + const matrix = matrices.get(influence.jointId)!; + const transformedPosition = transformPoint(matrix, basePositions[vertexIndex]!); + const transformedNormal = transformNormal( + matrix, + baseNormals[vertexIndex]!, + `$.skinMatrices.${influence.jointId}`, + ); + position[0] += transformedPosition[0] * influence.weight; + position[1] += transformedPosition[1] * influence.weight; + position[2] += transformedPosition[2] * influence.weight; + normal[0] += transformedNormal[0] * influence.weight; + normal[1] += transformedNormal[1] * influence.weight; + normal[2] += transformedNormal[2] * influence.weight; + } + positions.push(position); + normals.push(normalize(normal)); + } + return { positions, normals }; + }; + + const restGeometry = skinGeometry(restSkin); + const restPositions = freezeVectors(restGeometry.positions); + const restNormals = freezeVectors(restGeometry.normals); + let priorSkin = new Map(restSkin); + let lastPositions = restPositions; + let lastNormals = restNormals; + const animation = createPolyMorphAnimationRuntime(model); + + const sample = (input: PolyMorphSkinningInput): PolyMorphSkinningFrame => { + if (!input || !Number.isSafeInteger(input.tick) || input.tick < 0) { + fail("invalid-tick", "$.tick", "expected a non-negative safe integer"); + } + const transforms = validateTransforms(input.jointTransforms, jointIdSet); + const globals = globalMatrices(deformation.joints, transforms); + const matrices = skinMatrices(deformation.joints, globals); + const changedJoints = new Set(jointIds.filter((id) => + !matricesEqual(matrices.get(id)!, priorSkin.get(id)!))); + let positions: readonly PolyMorphVec3[] = lastPositions; + let normals: readonly PolyMorphVec3[] = lastNormals; + if (changedJoints.size > 0) { + const geometry = skinGeometry(matrices); + positions = freezeVectors(geometry.positions); + normals = freezeVectors(geometry.normals); + } + const dirtyVertices = new Set(); + if (changedJoints.size > 0) { + for (const vertex of deformation.vertices) { + if (vertex.influences.some((influence) => changedJoints.has(influence.jointId))) { + dirtyVertices.add(vertex.vertexIndex); + } + } + } + const dirtyLeafSet = new Set(); + for (const vertexIndex of dirtyVertices) { + for (const polygonId of polygonsByVertex.get(vertexIndex) ?? []) { + const leafId = leafByPolygon.get(polygonId); + if (leafId) dirtyLeafSet.add(leafId); + } + } + const dirtyLeafIds = model.render.leaves + .map((leaf) => leaf.id) + .filter((id) => dirtyLeafSet.has(id)); + const leafUpdates = dirtyLeafIds.map((leafId): PolyMorphLeafUpdate => { + const prepared = computePolyMorphPreparedLeafMatrix( + compiledLeaves.get(leafId)!, + positions, + ); + return { + leafId, + visible: prepared.visible, + ...(prepared.matrix ? { matrix: prepared.matrix } : {}), + }; + }); + lastPositions = positions; + lastNormals = normals; + priorSkin = new Map(matrices); + return { + tick: input.tick, + positions, + normals, + globalJointMatrices: globals, + skinMatrices: matrices, + dirtyLeafIds: Object.freeze(dirtyLeafIds), + leafUpdates: Object.freeze(leafUpdates), + runtimePolygonConstructions: 0, + runtimeTopologyConstructions: 0, + atlasRedraws: 0, + }; + }; + + return Object.freeze({ + model, + jointIds: Object.freeze(jointIds), + basePositions: Object.freeze(basePositions), + baseNormals: Object.freeze(baseNormals), + sample, + sampleClip(clipId: string, timeMs: number, tick: number): PolyMorphSkinningFrame { + const animationFrame = animation.sample(clipId, timeMs); + return sample({ tick, jointTransforms: animationFrame.jointTransforms }); + }, + reset(): void { + priorSkin = new Map(restSkin); + lastPositions = restPositions; + lastNormals = restNormals; + }, + }); +} diff --git a/packages/morph/src/runtime/springs/index.ts b/packages/morph/src/runtime/springs/index.ts new file mode 100644 index 00000000..f228e089 --- /dev/null +++ b/packages/morph/src/runtime/springs/index.ts @@ -0,0 +1,10 @@ +export { + createPolyMorphSpringRuntime, + createPolyMorphSpringState, + stepPolyMorphSprings, +} from "./springs.js"; +export type { + PolyMorphSpringRuntime, + PolyMorphSpringState, + PolyMorphSpringStepOptions, +} from "./springs.js"; diff --git a/packages/morph/src/runtime/springs/springs.test.ts b/packages/morph/src/runtime/springs/springs.test.ts new file mode 100644 index 00000000..00c6df62 --- /dev/null +++ b/packages/morph/src/runtime/springs/springs.test.ts @@ -0,0 +1,75 @@ +import { describe, expect, it } from "vitest"; +import { createPolyMorphRuntimeFixture } from "../../testing/runtimeFixture.js"; +import { PolyMorphRuntimeError } from "../runtimeError.js"; +import { + createPolyMorphSpringRuntime, + createPolyMorphSpringState, + stepPolyMorphSprings, +} from "./index.js"; + +describe("PolyMorph springs", () => { + it("holds an exact target without integration drift", () => { + const runtime = createPolyMorphSpringRuntime(createPolyMorphRuntimeFixture()); + const state = createPolyMorphSpringState(runtime); + const held = stepPolyMorphSprings(runtime, state, { + deltaMs: 16, + heldTarget: { controlId: "stretch-control", value: 0.75 }, + }); + expect(held.values["stretch-control"]).toBe(0.75); + expect(held.velocities["stretch-control"]).toBe(0); + }); + + it("converges deterministically to the authored initial value", () => { + const runtime = createPolyMorphSpringRuntime(createPolyMorphRuntimeFixture()); + let left = createPolyMorphSpringState(runtime, { "stretch-control": 1 }); + let right = createPolyMorphSpringState(runtime, { "stretch-control": 1 }); + for (let index = 0; index < 500; index += 1) { + left = stepPolyMorphSprings(runtime, left, { deltaMs: 16 }); + right = stepPolyMorphSprings(runtime, right, { deltaMs: 16 }); + } + expect(left).toEqual(right); + expect(left.values["stretch-control"]).toBe(0); + expect(left.velocities["stretch-control"]).toBe(0); + expect(left.atRest).toBe(true); + }); + + it("stays stable for large caller steps and clears velocity at a bound", () => { + const runtime = createPolyMorphSpringRuntime(createPolyMorphRuntimeFixture()); + const state = createPolyMorphSpringState(runtime, { "stretch-control": 1 }); + const largeStep = stepPolyMorphSprings(runtime, state, { deltaMs: 1000 }); + expect(largeStep.values["stretch-control"]).toBeGreaterThanOrEqual(0); + expect(largeStep.values["stretch-control"]).toBeLessThanOrEqual(1); + expect(Number.isFinite(largeStep.velocities["stretch-control"]!)).toBe(true); + + const clamped = stepPolyMorphSprings(runtime, state, { deltaMs: 500 }); + expect(clamped.values["stretch-control"]).toBe(0); + expect(clamped.velocities["stretch-control"]).toBe(0); + expect(clamped.atRest).toBe(true); + }); + + it("leaves explicitly frozen controls unchanged", () => { + const runtime = createPolyMorphSpringRuntime(createPolyMorphRuntimeFixture()); + const state = createPolyMorphSpringState(runtime, { "stretch-control": 0.6 }); + const frozen = stepPolyMorphSprings(runtime, state, { + deltaMs: 16, + frozenControlIds: ["stretch-control"], + }); + expect(frozen.values["stretch-control"]).toBe(0.6); + expect(frozen.velocities["stretch-control"]).toBe(0); + }); + + it("fails closed on invalid time, ids, and held values", () => { + const runtime = createPolyMorphSpringRuntime(createPolyMorphRuntimeFixture()); + const state = createPolyMorphSpringState(runtime); + expect(() => stepPolyMorphSprings(runtime, state, { deltaMs: 0 })) + .toThrowError(PolyMorphRuntimeError); + expect(() => stepPolyMorphSprings(runtime, state, { + deltaMs: 16, + frozenControlIds: ["missing"], + })).toThrowError(PolyMorphRuntimeError); + expect(() => stepPolyMorphSprings(runtime, state, { + deltaMs: 16, + heldTarget: { controlId: "stretch-control", value: 2 }, + })).toThrowError(PolyMorphRuntimeError); + }); +}); diff --git a/packages/morph/src/runtime/springs/springs.ts b/packages/morph/src/runtime/springs/springs.ts new file mode 100644 index 00000000..218fb8b8 --- /dev/null +++ b/packages/morph/src/runtime/springs/springs.ts @@ -0,0 +1,184 @@ +import { + validatePolyMorphModel, + type PolyMorphControl, + type PolyMorphModel, + type PolyMorphSpring, +} from "../../contracts/index.js"; +import { PolyMorphRuntimeError } from "../runtimeError.js"; + +export interface PolyMorphSpringRuntime { + readonly model: PolyMorphModel; + readonly controlIds: readonly string[]; + readonly controls: ReadonlyMap; + readonly springs: ReadonlyMap; +} + +export interface PolyMorphSpringState { + readonly tick: number; + readonly values: Readonly>; + readonly velocities: Readonly>; + readonly atRest: boolean; +} + +export interface PolyMorphSpringStepOptions { + readonly deltaMs: number; + readonly heldTarget?: Readonly<{ controlId: string; value: number }> | null; + readonly frozenControlIds?: readonly string[]; +} + +function fail(code: string, path: string, message: string): never { + throw new PolyMorphRuntimeError(code, path, message); +} + +function integrateSpring( + displacement: number, + velocity: number, + stiffness: number, + damping: number, + deltaSeconds: number, +): readonly [number, number] { + const discriminant = damping * damping - 4 * stiffness; + const criticalTolerance = 1e-9 * Math.max(1, damping * damping, stiffness); + if (Math.abs(discriminant) <= criticalTolerance) { + const decay = damping / 2; + const slope = velocity + decay * displacement; + const envelope = Math.exp(-decay * deltaSeconds); + return [ + envelope * (displacement + slope * deltaSeconds), + envelope * (velocity - decay * slope * deltaSeconds), + ]; + } + if (discriminant < 0) { + const decay = damping / 2; + const frequency = Math.sqrt(stiffness - decay * decay); + const angle = frequency * deltaSeconds; + const cosine = Math.cos(angle); + const sine = Math.sin(angle); + const envelope = Math.exp(-decay * deltaSeconds); + return [ + envelope * ( + displacement * cosine + + ((velocity + decay * displacement) / frequency) * sine + ), + envelope * ( + velocity * cosine + - ((decay * velocity + stiffness * displacement) / frequency) * sine + ), + ]; + } + const root = Math.sqrt(discriminant); + const firstRate = (-damping + root) / 2; + const secondRate = (-damping - root) / 2; + const firstWeight = (velocity - secondRate * displacement) + / (firstRate - secondRate); + const secondWeight = displacement - firstWeight; + const firstTerm = firstWeight * Math.exp(firstRate * deltaSeconds); + const secondTerm = secondWeight * Math.exp(secondRate * deltaSeconds); + return [ + firstTerm + secondTerm, + firstRate * firstTerm + secondRate * secondTerm, + ]; +} + +export function createPolyMorphSpringRuntime(modelInput: unknown): PolyMorphSpringRuntime { + const model = validatePolyMorphModel(modelInput); + const springs = new Map(); + for (const spring of model.springs) springs.set(spring.controlId, spring); + return Object.freeze({ + model, + controlIds: Object.freeze(model.controls.map((control) => control.id)), + controls: new Map(model.controls.map((control) => [control.id, control])), + springs, + }); +} + +export function createPolyMorphSpringState( + runtime: PolyMorphSpringRuntime, + values?: Readonly>, +): PolyMorphSpringState { + const unknown = Object.keys(values ?? {}).find((id) => !runtime.controls.has(id)); + if (unknown) fail("unknown-id", "$.values", unknown); + const initial = Object.fromEntries(runtime.controlIds.map((id) => { + const control = runtime.controls.get(id)!; + const value = values?.[id] ?? control.initial; + if (!Number.isFinite(value) || value < control.minimum || value > control.maximum) { + fail("out-of-range", `$.values.${id}`, "value is outside control bounds"); + } + return [id, value]; + })); + return Object.freeze({ + tick: -1, + values: Object.freeze(initial), + velocities: Object.freeze(Object.fromEntries(runtime.controlIds.map((id) => [id, 0]))), + atRest: runtime.controlIds.every((id) => initial[id] === runtime.controls.get(id)!.initial), + }); +} + +export function stepPolyMorphSprings( + runtime: PolyMorphSpringRuntime, + state: PolyMorphSpringState, + options: PolyMorphSpringStepOptions, +): PolyMorphSpringState { + if (!state || !Number.isSafeInteger(state.tick) || state.tick < -1) { + fail("invalid-state", "$.state", "spring state is invalid"); + } + if (!Number.isFinite(options?.deltaMs) || options.deltaMs <= 0 || options.deltaMs > 1000) { + fail("invalid-time", "$.deltaMs", "expected 0 < deltaMs <= 1000"); + } + const held = options.heldTarget ?? null; + if (held) { + const control = runtime.controls.get(held.controlId); + if (!control) fail("unknown-id", "$.heldTarget.controlId", held.controlId); + if (!Number.isFinite(held.value) || held.value < control.minimum || held.value > control.maximum) { + fail("out-of-range", "$.heldTarget.value", "value is outside control bounds"); + } + } + const frozen = new Set(options.frozenControlIds ?? []); + for (const id of frozen) { + if (!runtime.controls.has(id)) fail("unknown-id", "$.frozenControlIds", id); + } + const dt = options.deltaMs / 1000; + const values: Record = {}; + const velocities: Record = {}; + let atRest = true; + for (const id of runtime.controlIds) { + const control = runtime.controls.get(id)!; + const priorValue = state.values[id] ?? control.initial; + const priorVelocity = state.velocities[id] ?? 0; + if (held?.controlId === id) { + values[id] = held.value; + velocities[id] = 0; + } else if (frozen.has(id) || !runtime.springs.has(id)) { + values[id] = priorValue; + velocities[id] = 0; + } else { + const spring = runtime.springs.get(id)!; + const displacement = priorValue - control.initial; + const [nextDisplacement, nextVelocity] = integrateSpring( + displacement, + priorVelocity, + spring.stiffness, + spring.damping, + dt, + ); + let velocity = nextVelocity; + let value = control.initial + nextDisplacement; + const clamped = Math.max(control.minimum, Math.min(control.maximum, value)); + if (clamped !== value) velocity = 0; + value = clamped; + if (Math.abs(value - control.initial) < 1e-6 && Math.abs(velocity) < 1e-6) { + value = control.initial; + velocity = 0; + } + values[id] = Object.is(value, -0) ? 0 : value; + velocities[id] = Object.is(velocity, -0) ? 0 : velocity; + } + if (values[id] !== control.initial || velocities[id] !== 0) atRest = false; + } + return Object.freeze({ + tick: state.tick + 1, + values: Object.freeze(values), + velocities: Object.freeze(velocities), + atRest, + }); +} diff --git a/packages/morph/src/testing/modelFixture.ts b/packages/morph/src/testing/modelFixture.ts new file mode 100644 index 00000000..0573f702 --- /dev/null +++ b/packages/morph/src/testing/modelFixture.ts @@ -0,0 +1,192 @@ +import { + POLY_MORPH_MODEL_SCHEMA, + type PolyMorphMat4, + type PolyMorphModel, + type PolyMorphProfile, +} from "../contracts/index.js"; + +export const POLY_MORPH_IDENTITY_MATRIX: PolyMorphMat4 = [ + 1, 0, 0, 0, + 0, 1, 0, 0, + 0, 0, 1, 0, + 0, 0, 0, 1, +]; + +export function clonePolyMorphFixture(value: T): T { + return JSON.parse(JSON.stringify(value)) as T; +} + +export function createPolyMorphModelFixture( + profile: PolyMorphProfile = "static-prepared", +): PolyMorphModel { + const base: PolyMorphModel = { + schema: POLY_MORPH_MODEL_SCHEMA, + identity: { + id: "morph-gem", + name: "Morph Gem", + revision: "1.0.0", + }, + profile, + capabilities: ["retained-render"], + budgets: { + maxVertices: 16, + maxPolygons: 8, + maxLeaves: 8, + maxFrames: 8, + maxJoints: 8, + maxResources: 4, + maxBytes: 1_000_000, + }, + topology: { + vertices: [ + [0, 0, 0], + [1, 0, 0], + [0, 1, 0], + ], + normals: [ + [0, 0, 1], + [0, 0, 1], + [0, 0, 1], + ], + polygons: [ + { + id: "gem-panel", + vertexIndices: [0, 1, 2], + normalIndices: [0, 1, 2], + }, + ], + }, + materials: [ + { + id: "amber", + color: [1, 0.5, 0.1, 1], + }, + ], + render: { + modelMatrix: POLY_MORPH_IDENTITY_MATRIX, + shapes: [ + { + id: "gem", + matrix: POLY_MORPH_IDENTITY_MATRIX, + }, + ], + leaves: [ + { + id: "gem-panel-leaf", + polygonId: "gem-panel", + shapeId: "gem", + materialId: "amber", + strategy: "solid-triangle", + width: 32, + height: 32, + matrix: POLY_MORPH_IDENTITY_MATRIX, + atlas: null, + fallback: null, + }, + ], + }, + deformation: { kind: "none" }, + controls: [], + springs: [], + animations: [], + playback: null, + provenance: { + generator: "polycss-morph", + generatorVersion: "1.0.0", + sources: [ + { + id: "authored-gem", + kind: "authored", + uri: "urn:polycss:morph-gem", + sha256: null, + license: "MIT", + }, + ], + }, + }; + if (profile === "morph-regions") { + return { + ...base, + capabilities: ["morph-targets", "retained-render", "sparse-updates"], + deformation: { + kind: "morph-regions", + targets: [ + { + id: "stretch", + deltas: [ + { + vertexIndex: 2, + position: [0, 0.5, 0], + normal: null, + }, + ], + }, + ], + }, + }; + } + if (profile === "joint-skin") { + return { + ...base, + capabilities: ["joint-skinning", "retained-render", "sparse-updates"], + deformation: { + kind: "joint-skin", + joints: [ + { + id: "root", + parentId: null, + restMatrix: POLY_MORPH_IDENTITY_MATRIX, + inverseBindMatrix: POLY_MORPH_IDENTITY_MATRIX, + }, + { + id: "tip", + parentId: "root", + restMatrix: POLY_MORPH_IDENTITY_MATRIX, + inverseBindMatrix: POLY_MORPH_IDENTITY_MATRIX, + }, + ], + vertices: [ + { + vertexIndex: 0, + influences: [{ jointId: "root", weight: 1 }], + }, + { + vertexIndex: 1, + influences: [{ jointId: "root", weight: 0.5 }, { jointId: "tip", weight: 0.5 }], + }, + { + vertexIndex: 2, + influences: [{ jointId: "tip", weight: 1 }], + }, + ], + }, + }; + } + if (profile === "prepared-playback") { + return { + ...base, + capabilities: ["prepared-playback", "retained-render", "sparse-updates"], + playback: { + durationMs: 1000, + loop: true, + frames: [ + { + timeMs: 0, + modelMatrix: null, + shapes: [], + leaves: [ + { + leafId: "gem-panel-leaf", + matrix: null, + visible: true, + opacity: 1, + atlasRow: null, + }, + ], + }, + ], + }, + }; + } + return base; +} diff --git a/packages/morph/src/testing/playbackFixture.ts b/packages/morph/src/testing/playbackFixture.ts new file mode 100644 index 00000000..fe946449 --- /dev/null +++ b/packages/morph/src/testing/playbackFixture.ts @@ -0,0 +1,105 @@ +import type { PolyMorphMat4, PolyMorphModel } from "../contracts/index.js"; +import { + clonePolyMorphFixture, + createPolyMorphModelFixture, + POLY_MORPH_IDENTITY_MATRIX, +} from "./modelFixture.js"; + +export function polyMorphTranslation( + x: number, + y: number, + z: number, +): PolyMorphMat4 { + const matrix = [...POLY_MORPH_IDENTITY_MATRIX] as number[]; + matrix[12] = x; + matrix[13] = y; + matrix[14] = z; + return matrix as unknown as PolyMorphMat4; +} + +export function createPolyMorphPlaybackFixture(): PolyMorphModel { + const fixture = clonePolyMorphFixture(createPolyMorphModelFixture("prepared-playback")); + fixture.topology.vertices.push( + [3, 0, 0], + [4, 0, 0], + [3, 1, 0], + ); + fixture.topology.normals.push( + [0, 0, 1], + [0, 0, 1], + [0, 0, 1], + ); + fixture.topology.polygons.push({ + id: "static-panel", + vertexIndices: [3, 4, 5], + normalIndices: [3, 4, 5], + }); + const animatedLeaf = fixture.render.leaves[0]!; + animatedLeaf.strategy = "atlas-slice"; + animatedLeaf.width = 4; + animatedLeaf.height = 4; + animatedLeaf.atlas = { + resourcePath: "assets/gem.webp", + x: 0, + y: 0, + width: 4, + height: 4, + pageWidth: 4, + pageHeight: 8, + }; + fixture.render.leaves.push({ + id: "static-panel-leaf", + polygonId: "static-panel", + shapeId: "gem", + materialId: "amber", + strategy: "solid-triangle", + width: 32, + height: 32, + matrix: POLY_MORPH_IDENTITY_MATRIX, + atlas: null, + fallback: null, + }); + fixture.playback = { + durationMs: 1000, + loop: true, + frames: [ + { + timeMs: 0, + modelMatrix: null, + shapes: [], + leaves: [], + }, + { + timeMs: 250, + modelMatrix: polyMorphTranslation(1, 0, 0), + shapes: [{ + shapeId: "gem", + matrix: polyMorphTranslation(0, 2, 0), + }], + leaves: [{ + leafId: "gem-panel-leaf", + matrix: polyMorphTranslation(0, 0, 3), + visible: false, + opacity: 0.5, + atlasRow: 1, + }], + }, + { + timeMs: 750, + modelMatrix: POLY_MORPH_IDENTITY_MATRIX, + shapes: [{ + shapeId: "gem", + matrix: POLY_MORPH_IDENTITY_MATRIX, + }], + leaves: [{ + leafId: "gem-panel-leaf", + matrix: POLY_MORPH_IDENTITY_MATRIX, + visible: true, + opacity: 1, + atlasRow: 0, + }], + }, + ], + }; + return fixture; +} diff --git a/packages/morph/src/testing/runtimeFixture.ts b/packages/morph/src/testing/runtimeFixture.ts new file mode 100644 index 00000000..a0114c53 --- /dev/null +++ b/packages/morph/src/testing/runtimeFixture.ts @@ -0,0 +1,85 @@ +import { + type PolyMorphModel, +} from "../contracts/index.js"; +import { + clonePolyMorphFixture, + createPolyMorphModelFixture, + POLY_MORPH_IDENTITY_MATRIX, +} from "./modelFixture.js"; + +export function createPolyMorphRuntimeFixture(): PolyMorphModel { + const fixture = clonePolyMorphFixture(createPolyMorphModelFixture("morph-regions")); + fixture.capabilities = [ + "animation", + "morph-targets", + "retained-render", + "semantic-controls", + "sparse-updates", + "springs", + ]; + fixture.topology.vertices.push( + [3, 0, 0], + [4, 0, 0], + [3, 1, 0], + ); + fixture.topology.normals.push( + [0, 0, 1], + [0, 0, 1], + [0, 0, 1], + ); + fixture.topology.polygons.push({ + id: "static-panel", + vertexIndices: [3, 4, 5], + normalIndices: [3, 4, 5], + }); + fixture.render.leaves.push({ + id: "static-panel-leaf", + polygonId: "static-panel", + shapeId: "gem", + materialId: "amber", + strategy: "solid-triangle", + width: 32, + height: 32, + matrix: POLY_MORPH_IDENTITY_MATRIX, + atlas: null, + fallback: null, + }); + fixture.deformation.targets[0]!.deltas[0]!.normal = [0, 1, 0]; + fixture.controls = [ + { + id: "stretch-control", + anchor: [0, 1, 0], + axis: [0, 1, 0], + radius: 1, + minimum: 0, + maximum: 1, + initial: 0, + targets: [{ targetId: "stretch", scale: 1 }], + }, + ]; + fixture.springs = [ + { + id: "stretch-return", + controlId: "stretch-control", + stiffness: 80, + damping: 12, + }, + ]; + fixture.animations = [ + { + id: "pulse", + durationMs: 1000, + loop: true, + channels: [ + { + target: "morph-weight", + targetId: "stretch", + interpolation: "linear", + timesMs: [0, 500, 1000], + values: [[0], [1], [0]], + }, + ], + }, + ]; + return fixture; +} diff --git a/packages/morph/src/testing/skinningFixture.ts b/packages/morph/src/testing/skinningFixture.ts new file mode 100644 index 00000000..f871cc3b --- /dev/null +++ b/packages/morph/src/testing/skinningFixture.ts @@ -0,0 +1,32 @@ +import type { PolyMorphModel } from "../contracts/index.js"; +import { + clonePolyMorphFixture, + createPolyMorphModelFixture, +} from "./modelFixture.js"; + +export function createPolyMorphSkinningFixture(): PolyMorphModel { + const fixture = clonePolyMorphFixture(createPolyMorphModelFixture("joint-skin")); + fixture.capabilities = [ + "animation", + "joint-skinning", + "retained-render", + "sparse-updates", + ]; + fixture.animations = [ + { + id: "tip-rise", + durationMs: 1000, + loop: false, + channels: [ + { + target: "joint-translation", + targetId: "tip", + interpolation: "linear", + timesMs: [0, 1000], + values: [[0, 0, 0], [0, 2, 0]], + }, + ], + }, + ]; + return fixture; +} diff --git a/packages/morph/tsconfig.json b/packages/morph/tsconfig.json new file mode 100644 index 00000000..d7158c12 --- /dev/null +++ b/packages/morph/tsconfig.json @@ -0,0 +1,18 @@ +{ + "compilerOptions": { + "target": "ES2020", + "module": "ESNext", + "moduleResolution": "Bundler", + "resolveJsonModule": true, + "lib": ["ES2020", "DOM", "DOM.Iterable"], + "declaration": true, + "declarationMap": true, + "esModuleInterop": true, + "forceConsistentCasingInFileNames": true, + "strict": true, + "skipLibCheck": true, + "outDir": "dist", + "rootDir": "src" + }, + "include": ["src"] +} diff --git a/packages/morph/tsup.config.ts b/packages/morph/tsup.config.ts new file mode 100644 index 00000000..feb155a4 --- /dev/null +++ b/packages/morph/tsup.config.ts @@ -0,0 +1,16 @@ +import { defineConfig } from "tsup"; + +export default defineConfig({ + entry: { + index: "src/index.ts", + prepare: "src/prepare.ts", + }, + format: ["esm", "cjs"], + dts: true, + splitting: false, + sourcemap: false, + clean: true, + minify: true, + target: "es2020", + tsconfig: "tsconfig.json", +}); diff --git a/packages/morph/vitest.config.ts b/packages/morph/vitest.config.ts new file mode 100644 index 00000000..bc179579 --- /dev/null +++ b/packages/morph/vitest.config.ts @@ -0,0 +1,32 @@ +import path from "node:path"; +import { defineConfig } from "vitest/config"; + +export default defineConfig({ + test: { + include: ["src/**/*.test.ts"], + environment: "happy-dom", + coverage: { + provider: "v8", + reporter: ["text", "html", "json-summary"], + include: ["src/**/*.ts"], + exclude: [ + "src/**/*.test.ts", + "src/**/index.ts", + "src/**/types.ts", + "src/testing/**", + ], + thresholds: { + statements: 90, + branches: 80, + functions: 95, + lines: 90, + }, + }, + }, + resolve: { + alias: { + "@layoutit/polycss": path.resolve(__dirname, "../polycss/src/index.ts"), + "@layoutit/polycss-core": path.resolve(__dirname, "../core/src/index.ts"), + }, + }, +}); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index ebfe69fb..7d3f8cb5 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -146,6 +146,31 @@ importers: specifier: ^3.1.1 version: 3.2.4(@types/debug@4.1.13)(@types/node@25.5.0)(happy-dom@20.8.9) + packages/morph: + dependencies: + '@layoutit/polycss': + specifier: workspace:^ + version: link:../polycss + devDependencies: + '@types/node': + specifier: ^25.0.0 + version: 25.5.0 + '@vitest/coverage-v8': + specifier: ^3.1.1 + version: 3.2.4(vitest@3.2.4(@types/debug@4.1.13)(@types/node@25.5.0)(happy-dom@20.8.9)) + happy-dom: + specifier: ^20.7.0 + version: 20.8.9 + tsup: + specifier: ^8.0.1 + version: 8.5.1(postcss@8.5.8)(typescript@5.9.3) + typescript: + specifier: ^5.3.3 + version: 5.9.3 + vitest: + specifier: ^3.1.1 + version: 3.2.4(@types/debug@4.1.13)(@types/node@25.5.0)(happy-dom@20.8.9) + packages/polycss: dependencies: '@layoutit/polycss-core': diff --git a/website/astro.config.mjs b/website/astro.config.mjs index efee6807..088c151c 100644 --- a/website/astro.config.mjs +++ b/website/astro.config.mjs @@ -97,6 +97,7 @@ export default defineConfig({ { label: 'Performance', slug: 'guides/performance' }, { label: 'Projections', slug: 'guides/projections' }, { label: 'Animation', slug: 'guides/animation' }, + { label: 'Prepared Morph Models', slug: 'guides/morph' }, ], }, { diff --git a/website/src/content/docs/api/headless.mdx b/website/src/content/docs/api/headless.mdx index 6a92adcf..41881197 100644 --- a/website/src/content/docs/api/headless.mdx +++ b/website/src/content/docs/api/headless.mdx @@ -568,3 +568,4 @@ The root package exports the matching element classes for manual registration or | `@layoutit/polycss/elements` | Side-effect: registers the PolyCSS custom elements | | `@layoutit/polycss-core` | Pure parsers / math, zero DOM: `parseObj`, `parseGltf`, `parseVox`, `parseStl`, `loadMesh`, types | | `@layoutit/polycss-fonts` | Font parsing, Google font loading, and text-to-polygon mesh generation | +| `@layoutit/polycss-morph` | Browser-safe prepared-model contracts, loading, retained mounting, sparse deformation, controls, springs, animation, skinning, and playback; Node preparation is exported separately from `/prepare` | diff --git a/website/src/content/docs/guides/morph.mdx b/website/src/content/docs/guides/morph.mdx new file mode 100644 index 00000000..8604e342 --- /dev/null +++ b/website/src/content/docs/guides/morph.mdx @@ -0,0 +1,102 @@ +--- +title: Prepared Morph Models +description: Prepare retained PolyCSS models in Node, then load and update them through a stable browser DOM graph. +--- + +`@layoutit/polycss-morph` is an imperative, framework-agnostic layer for models +whose topology, paint resources, and update plans are prepared ahead of time. +It is currently a workspace package under standalone qualification; this page +documents the source-tree API and does not announce npm registry availability. + +Use ordinary `Polygon[]` loading for ordinary meshes. Use Morph when a model +needs a retained leaf graph plus sparse deformation, semantic controls, +springs, joint skinning, or an externally prepared playback stream. + +## Two explicit entries + +The Node and browser dependency graphs are separate: + +| Entry | Environment | Owns | +|---|---|---| +| `@layoutit/polycss-morph/prepare` | Node | Strict config and glTF/GLB intake, normalization, topology, canonical solid-triangle plans, packed fallback pages with one local-size slice per polygon, provenance, content hashes, and atomic package writes. | +| `@layoutit/polycss-morph` | Browser or shared code | Contracts, validation, bounded loading, retained mounting, deformation, controls, springs, animation sampling, joint skinning, and prepared playback. | + +The generic preparer directly authors `static-prepared` and `morph-regions` +models. Dedicated tooling or a product adapter can author validated +`joint-skin` and `prepared-playback` models. + +## Prepare + +```ts +import { preparePolyMorphModel } from "@layoutit/polycss-morph/prepare"; + +await preparePolyMorphModel({ + configPath: "./source/prepare.json", + outputRoot: "./public/model/package", +}); +``` + +Preparation writes resources before `manifest.json`. Re-running with +`check: true` verifies the complete existing inventory and bytes without +rewriting them. + +## Load, mount, update + +```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: { "pin-c06-r04-lift": 0.5 }, +}); + +mounted.apply({ leaves: frame.leafUpdates }); +``` + +The same imperative API is used from vanilla, React, or Vue applications. +Morph does not ship framework wrappers. +Prepared playback commits explicitly: apply `sample.update`, then call +`runtime.commit(sample)` only after `mounted.apply(...)` succeeds. + +## Executable profiles + +| Profile | Behavior | +|---|---| +| `static-prepared` | Retained rendering with no deformation. | +| `morph-regions` | Sparse morph targets, semantic controls, springs, and clip channels. | +| `joint-skin` | Hierarchical joint transforms and normalized weighted skinning. | +| `prepared-playback` | Source-ordered retained model, shape, transform, visibility, opacity, and image-row changes. | + +## Runtime invariant + +Mount once, then sample and apply: + +- the application owns input and timing; +- Morph owns no animation scheduler; +- leaf elements retain identity until teardown; +- unchanged samples perform no writes; +- sparse samples visit only affected leaves; +- runtime updates do not reconstruct topology or redraw prepared image resources. + +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 alpha-atlas slice instead. Mount uses the loader's +verified image bytes and revokes their object URLs at teardown; the browser +never refetches, generates, or redraws +those pages. Dedicated tooling may also author other image-backed strategies, +including prepared-playback consumers. + +## Consumer boundary + +A consuming product keeps its source-specific schemas, cadence, input ordering, +presentation, and oracle tooling. Product adapters own their prepared packages, +mounting paths, presentation, and oracle evidence.