From 374317724015e6e1100eccb0151c0484417c133a Mon Sep 17 00:00:00 2001 From: Juan Cruz Fortunatti Date: Thu, 13 Aug 2026 01:37:39 +0200 Subject: [PATCH 01/37] feat(skills): add @layoutit/polycss-skills npx installer for the PolyCSS agent skill --- .github/scripts/sync-package-readmes.mjs | 1 + .github/scripts/sync-skill-docs.mjs | 140 ++++++ .github/scripts/sync-skill-docs.test.mjs | 83 ++++ .github/workflows/ci.yml | 3 + AGENTS.md | 26 ++ README.md | 19 + package.json | 2 + packages/polycss/README.md | 1 + packages/react/README.md | 1 + packages/skills/README.md | 103 +++++ packages/skills/bin/polycss-skills.mjs | 223 ++++++++++ packages/skills/package.json | 41 ++ packages/skills/skill/SKILL.md | 190 +++++++++ packages/skills/skill/docs/animation.md | 128 ++++++ packages/skills/skill/docs/api-index.md | 193 +++++++++ .../skills/skill/docs/authoring-polygons.md | 174 ++++++++ .../skill/docs/controls-and-interaction.md | 149 +++++++ packages/skills/skill/docs/lighting.md | 116 +++++ packages/skills/skill/docs/loading-models.md | 145 +++++++ packages/skills/skill/docs/performance.md | 107 +++++ .../skills/skill/docs/scenes-and-cameras.md | 173 ++++++++ packages/skills/skill/docs/shadows.md | 128 ++++++ .../skill/docs/shapes-and-primitives.md | 198 +++++++++ packages/skills/skill/docs/textures.md | 100 +++++ packages/skills/skill/docs/three-parity.md | 126 ++++++ packages/skills/skill/docs/troubleshooting.md | 77 ++++ packages/skills/src/install.mjs | 249 +++++++++++ packages/skills/src/install.test.mjs | 359 ++++++++++++++++ packages/skills/vitest.config.mjs | 19 + packages/vue/README.md | 1 + pnpm-lock.yaml | 9 + website/public/skill.md | 399 ++++-------------- website/public/skill/SKILL.md | 190 +++++++++ website/public/skill/docs/animation.md | 128 ++++++ website/public/skill/docs/api-index.md | 193 +++++++++ .../public/skill/docs/authoring-polygons.md | 174 ++++++++ .../skill/docs/controls-and-interaction.md | 149 +++++++ website/public/skill/docs/lighting.md | 116 +++++ website/public/skill/docs/loading-models.md | 145 +++++++ website/public/skill/docs/performance.md | 107 +++++ .../public/skill/docs/scenes-and-cameras.md | 173 ++++++++ website/public/skill/docs/shadows.md | 128 ++++++ .../skill/docs/shapes-and-primitives.md | 198 +++++++++ website/public/skill/docs/textures.md | 100 +++++ website/public/skill/docs/three-parity.md | 126 ++++++ website/public/skill/docs/troubleshooting.md | 77 ++++ 46 files changed, 5378 insertions(+), 309 deletions(-) create mode 100644 .github/scripts/sync-skill-docs.mjs create mode 100644 .github/scripts/sync-skill-docs.test.mjs create mode 100644 packages/skills/README.md create mode 100644 packages/skills/bin/polycss-skills.mjs create mode 100644 packages/skills/package.json create mode 100644 packages/skills/skill/SKILL.md create mode 100644 packages/skills/skill/docs/animation.md create mode 100644 packages/skills/skill/docs/api-index.md create mode 100644 packages/skills/skill/docs/authoring-polygons.md create mode 100644 packages/skills/skill/docs/controls-and-interaction.md create mode 100644 packages/skills/skill/docs/lighting.md create mode 100644 packages/skills/skill/docs/loading-models.md create mode 100644 packages/skills/skill/docs/performance.md create mode 100644 packages/skills/skill/docs/scenes-and-cameras.md create mode 100644 packages/skills/skill/docs/shadows.md create mode 100644 packages/skills/skill/docs/shapes-and-primitives.md create mode 100644 packages/skills/skill/docs/textures.md create mode 100644 packages/skills/skill/docs/three-parity.md create mode 100644 packages/skills/skill/docs/troubleshooting.md create mode 100644 packages/skills/src/install.mjs create mode 100644 packages/skills/src/install.test.mjs create mode 100644 packages/skills/vitest.config.mjs create mode 100644 website/public/skill/SKILL.md create mode 100644 website/public/skill/docs/animation.md create mode 100644 website/public/skill/docs/api-index.md create mode 100644 website/public/skill/docs/authoring-polygons.md create mode 100644 website/public/skill/docs/controls-and-interaction.md create mode 100644 website/public/skill/docs/lighting.md create mode 100644 website/public/skill/docs/loading-models.md create mode 100644 website/public/skill/docs/performance.md create mode 100644 website/public/skill/docs/scenes-and-cameras.md create mode 100644 website/public/skill/docs/shadows.md create mode 100644 website/public/skill/docs/shapes-and-primitives.md create mode 100644 website/public/skill/docs/textures.md create mode 100644 website/public/skill/docs/three-parity.md create mode 100644 website/public/skill/docs/troubleshooting.md diff --git a/.github/scripts/sync-package-readmes.mjs b/.github/scripts/sync-package-readmes.mjs index 592837850..9835976c9 100644 --- a/.github/scripts/sync-package-readmes.mjs +++ b/.github/scripts/sync-package-readmes.mjs @@ -29,6 +29,7 @@ const targets = [ "packages/domformat/README.md", "packages/fonts/README.md", "packages/morph/README.md", + "packages/skills/README.md", ]; const checkOnly = process.argv.includes("--check"); diff --git a/.github/scripts/sync-skill-docs.mjs b/.github/scripts/sync-skill-docs.mjs new file mode 100644 index 000000000..29ac2e8e1 --- /dev/null +++ b/.github/scripts/sync-skill-docs.mjs @@ -0,0 +1,140 @@ +/** + * Publishes the skill tree owned by `packages/skills` to the website. + * + * `packages/skills/skill/` is the single source of truth — it is what + * `npx @layoutit/polycss-skills` installs. The website serves the same bytes so + * an agent that cannot run npx can fetch them: + * + * website/public/skill/** — the tree verbatim, relative links intact + * website/public/skill.md — the long-standing entry-point URL, with + * `docs/x.md` links rewritten to `/skill/docs/x.md` + * because it is served one level up from the tree + * + * Run with `--check` in CI to fail on drift instead of writing. + */ +import { + mkdirSync, + readFileSync, + readdirSync, + realpathSync, + rmSync, + writeFileSync, +} from "node:fs"; +import { dirname, join, posix, relative, resolve, sep } from "node:path"; +import { fileURLToPath } from "node:url"; + +const repoRoot = resolve(dirname(fileURLToPath(import.meta.url)), "..", ".."); +const sourceDir = resolve(repoRoot, "packages/skills/skill"); +const treeDir = resolve(repoRoot, "website/public/skill"); +const entryFile = resolve(repoRoot, "website/public/skill.md"); + +const checkOnly = process.argv.includes("--check"); + +const fail = (message) => { + console.error(`[sync-skill-docs] ${message}`); + process.exit(1); +}; + +export function listFiles(dir, prefix = "") { + const out = []; + let entries; + try { + entries = readdirSync(dir, { withFileTypes: true }); + } catch { + return out; + } + for (const entry of entries.sort((a, b) => (a.name < b.name ? -1 : 1))) { + const rel = prefix ? posix.join(prefix, entry.name) : entry.name; + if (entry.isDirectory()) out.push(...listFiles(join(dir, entry.name), rel)); + else if (entry.isFile()) out.push(rel); + } + return out; +} + +/** + * `website/public/skill.md` sits one directory above `website/public/skill/`, + * so its relative doc links would 404. Rewrite them to site-absolute paths. + */ +export function rewriteEntryLinks(text) { + return text.replace(/\]\(docs\//g, "](/skill/docs/"); +} + +const toNative = (rel) => rel.split("/").join(sep); +const readOrNull = (path) => { + try { + return readFileSync(path, "utf8"); + } catch { + return null; + } +}; + +function main() { + const files = listFiles(sourceDir); + if (!files.includes("SKILL.md")) { + fail(`${relative(repoRoot, sourceDir)} has no SKILL.md — refusing to run`); + } + + const drifted = []; + let written = 0; + + const write = (path, next) => { + if (readOrNull(path) === next) return; + if (checkOnly) { + drifted.push(relative(repoRoot, path)); + return; + } + mkdirSync(dirname(path), { recursive: true }); + writeFileSync(path, next); + written += 1; + }; + + for (const rel of files) { + write(join(treeDir, toNative(rel)), readFileSync(join(sourceDir, toNative(rel)), "utf8")); + } + + write(entryFile, rewriteEntryLinks(readFileSync(join(sourceDir, "SKILL.md"), "utf8"))); + + // Files the skill no longer ships must not linger on the website, or an agent + // fetching by URL keeps reading a doc the package dropped. + const shipped = new Set(files); + for (const rel of listFiles(treeDir)) { + if (shipped.has(rel)) continue; + const path = join(treeDir, toNative(rel)); + if (checkOnly) { + drifted.push(`${relative(repoRoot, path)} (stale)`); + continue; + } + rmSync(path, { force: true }); + written += 1; + } + + if (checkOnly) { + if (drifted.length > 0) { + console.error( + `[sync-skill-docs] website skill copy is stale:\n ${drifted.join("\n ")}\n` + + "Edit packages/skills/skill/, then run `pnpm sync:skill`.", + ); + process.exit(1); + } + console.log("[sync-skill-docs] website skill copy is up to date"); + return; + } + + console.log( + `[sync-skill-docs] ${written} file${written === 1 ? "" : "s"} updated from ${files.length} skill file${files.length === 1 ? "" : "s"}`, + ); +} + +// `import.meta.url` is realpathed by Node but `argv[1]` is not, so a symlink +// anywhere in the invocation path would make this compare unequal — main() +// would silently never run and `--check` would exit 0 on stale content. +const invokedDirectly = () => { + if (!process.argv[1]) return false; + try { + return realpathSync(process.argv[1]) === realpathSync(fileURLToPath(import.meta.url)); + } catch { + return false; + } +}; + +if (invokedDirectly()) main(); diff --git a/.github/scripts/sync-skill-docs.test.mjs b/.github/scripts/sync-skill-docs.test.mjs new file mode 100644 index 000000000..66efad39a --- /dev/null +++ b/.github/scripts/sync-skill-docs.test.mjs @@ -0,0 +1,83 @@ +import assert from "node:assert/strict"; +import { execFileSync } from "node:child_process"; +import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { dirname, join, resolve } from "node:path"; +import { after, test } from "node:test"; +import { fileURLToPath } from "node:url"; + +import { listFiles, rewriteEntryLinks } from "./sync-skill-docs.mjs"; + +const here = dirname(fileURLToPath(import.meta.url)); +const repoRoot = resolve(here, "..", ".."); +const script = join(here, "sync-skill-docs.mjs"); + +const temps = []; +after(() => { + for (const dir of temps) rmSync(dir, { recursive: true, force: true }); +}); +const tmp = () => { + const dir = mkdtempSync(join(tmpdir(), "skill-docs-")); + temps.push(dir); + return dir; +}; + +test("listFiles returns sorted POSIX paths and skips directories", () => { + const dir = tmp(); + mkdirSync(join(dir, "docs")); + writeFileSync(join(dir, "SKILL.md"), "a"); + writeFileSync(join(dir, "docs", "b.md"), "b"); + writeFileSync(join(dir, "docs", "a.md"), "a"); + + assert.deepEqual(listFiles(dir), ["SKILL.md", "docs/a.md", "docs/b.md"]); +}); + +test("listFiles tolerates a missing directory", () => { + assert.deepEqual(listFiles(join(tmp(), "nope")), []); +}); + +test("rewriteEntryLinks makes doc links site-absolute", () => { + assert.equal( + rewriteEntryLinks("see [x](docs/lighting.md) and [y](docs/shadows.md)"), + "see [x](/skill/docs/lighting.md) and [y](/skill/docs/shadows.md)", + ); +}); + +test("rewriteEntryLinks leaves other links alone", () => { + const input = "[a](https://polycss.com) [b](/skill/docs/x.md) [c](./docs/x.md) `docs/x.md`"; + assert.equal(rewriteEntryLinks(input), input); +}); + +test("--check exits 0 on a synced tree", () => { + // The committed website copy must already match the package source. + execFileSync(process.execPath, [script, "--check"], { cwd: repoRoot, stdio: "pipe" }); +}); + +test("--check fails and writes nothing when the website copy drifts", () => { + const target = join(repoRoot, "website/public/skill/SKILL.md"); + const original = readFileSync(target); + writeFileSync(target, `${original}\ndrift\n`); + try { + assert.throws( + () => execFileSync(process.execPath, [script, "--check"], { cwd: repoRoot, stdio: "pipe" }), + (error) => error.status === 1, + ); + // --check must never repair what it reports. + assert.equal(readFileSync(target).toString(), `${original}\ndrift\n`); + } finally { + writeFileSync(target, original); + } +}); + +test("--check reports a file the skill no longer ships", () => { + const stale = join(repoRoot, "website/public/skill/docs/__stale__.md"); + writeFileSync(stale, "gone"); + try { + assert.throws( + () => execFileSync(process.execPath, [script, "--check"], { cwd: repoRoot, stdio: "pipe" }), + (error) => error.status === 1 && /stale/.test(String(error.stderr)), + ); + } finally { + rmSync(stale, { force: true }); + } +}); diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 37b83570e..73d15c922 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -40,6 +40,9 @@ jobs: - name: Check README shared blocks run: pnpm check:readmes + - name: Check website skill copy + run: pnpm check:skill + - name: Run tests run: pnpm test diff --git a/AGENTS.md b/AGENTS.md index 7844a6853..4157bf8bd 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -17,6 +17,7 @@ Monorepo layout (pnpm workspaces): | `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. | | `packages/domformat` | `@layoutit/polycss-domformat` | Private strict-TypeScript `domformat@0` writer, reader, validator, CLI, and browser mount with repository-side conformance. Owns the producer-neutral wire contract; producer lowering stays in producer packages. Runtime installs contain unbundled ESM and declarations but exclude certification material. Not published. | +| `packages/skills` | `@layoutit/polycss-skills` | Zero-dependency `npx` installer for the PolyCSS agent skill. Owns `skill/SKILL.md` + `skill/docs/*.md` — the source of truth for what agents are told about PolyCSS. No renderer code, no runtime dependency on any other package. | | `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. | @@ -240,6 +241,7 @@ Before opening a PR: - [ ] If I touched the canvas atlas pipeline (`rasterise.ts` / `buildAtlasPages.ts`), browser-feature detection, or direct voxel renderer in ONE renderer, the same fix lands in the other two renderers (`polycss` + react + vue) in this PR. - [ ] If I touched any of the three `styles.ts` (`packages/polycss/src/styles/styles.ts`, `packages/react/src/styles/styles.ts`, `packages/vue/src/styles/styles.ts`), the other two are consistent — CSS rules cover every emitted tag for both lighting modes, and shared properties like `will-change: transform` on `.polycss-scene` exist in all three. - [ ] Website docs (`website/src/content/docs/**`) and READMEs reflect any user-visible change. +- [ ] If a user-visible change contradicts the agent skill, `packages/skills/skill/**` is updated and `pnpm sync:skill` has been run (see "The agent skill" below). - [ ] If I edited a `` block, I edited it in the ROOT `README.md` and ran `pnpm sync:readmes` (see "Package READMEs" below). - [ ] If I changed a render strategy, lighting mode, naming convention, or the JS-in-render-loop rules, `AGENTS.md` reflects the new state in this same PR. @@ -275,6 +277,30 @@ matching markers. Current blocks are `links`, `packages`, `showcase`, and - A package opts in per block simply by containing the markers. `fonts` and `morph` carry none today and are left entirely alone. +## The agent skill + +`packages/skills/skill/` is the **single source of truth** for what coding +agents are told about PolyCSS: `SKILL.md` is the entry point (conventions, the +silent-failure invariants, minimal scenes, and the docs index) and +`skill/docs/*.md` holds the per-topic reference. `@layoutit/polycss-skills` +publishes that tree with a zero-dependency `npx` installer. + +- **Edit `packages/skills/skill/`, never the website copy.** Then run + `pnpm sync:skill`. CI runs `pnpm check:skill`, which fails on drift. +- `.github/scripts/sync-skill-docs.mjs` mirrors the tree to + `website/public/skill/` verbatim and to `website/public/skill.md` with doc + links rewritten site-absolute (that file is served one level above the tree). + It also deletes website copies of docs the skill has dropped. +- Adding or removing a doc means updating the index table in `SKILL.md`; the + package's tests fail on an unindexed doc, a link to a file that does not + ship, and a broken cross-doc relative link. +- The skill documents the *current* behaviour, including the renderer + divergences recorded in this file (vanilla's missing ground-shadow fallback, + the baked-light rebake asymmetry, `seamBleed`). When one of those is + reconciled, the skill changes in the same PR. +- The package is plain ESM with no build step. Its `bin` runs under `npx` in + someone else's project, so it must stay dependency-free. + ## Backward compatibility - **No BC shims.** Clean breaks only. No re-export aliases for renamed symbols. No `@deprecated` wrappers. If the API changes, callers update. diff --git a/README.md b/README.md index b734ff0c3..4c06908ea 100644 --- a/README.md +++ b/README.md @@ -245,6 +245,7 @@ Each visible polygon is emitted as one leaf element; the renderer chooses the le | `@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-model loading, retained DOM animation, morph targets, skinning, and playback. | +| `@layoutit/polycss-skills` | `npx @layoutit/polycss-skills` — installs the PolyCSS agent skill into `.claude/skills` or `.agents/skills`. | | `@layoutit/polycss-domformat` | Private MIT-licensed producer-neutral `domformat@0` runtime for canonical JSON plus digest-bound sibling resources; conformance and specifications stay repository-side. Not published. | @@ -265,6 +266,24 @@ external direct-versus-canonical animated proof with retained DOM and computed paint semantics and reports bounded subpixel Chromium compositor differences. +## Coding Agents + +PolyCSS ships an agent skill: an entry-point `SKILL.md` plus a folder of +reference docs covering authoring invariants, scene setup, lighting, shadows, +textures, animation, performance, Three.js parity, and troubleshooting. + +```bash +npx @layoutit/polycss-skills +``` + +It installs into `.claude/skills/polycss/` and `.agents/skills/polycss/`, +detecting whichever your project already uses. Upgrades are content-hashed, so +re-running it refreshes the shipped docs and leaves your edits alone. + +The same content is served at [polycss.com/skill.md](https://polycss.com/skill.md), +with the reference docs under `polycss.com/skill/docs/`, for agents that can +fetch a URL but not run a command. + ## Made with PolyCSS diff --git a/package.json b/package.json index 85bfcb41b..0a54cb974 100644 --- a/package.json +++ b/package.json @@ -23,7 +23,9 @@ "test": "pnpm --filter './packages/*' -r --if-present test", "test:coverage": "pnpm --filter './packages/*' -r --if-present test:coverage", "sync:readmes": "node .github/scripts/sync-package-readmes.mjs", + "sync:skill": "node .github/scripts/sync-skill-docs.mjs", "check:readmes": "node .github/scripts/sync-package-readmes.mjs --check", + "check:skill": "node .github/scripts/sync-skill-docs.mjs --check", "test:scripts": "node --test .github/scripts/*.test.mjs", "publish:all": "pnpm sync:readmes && pnpm --filter './packages/*' --filter '!@layoutit/polycss-domformat' -r publish --access public", "dev:website": "pnpm --filter @layoutit/polycss-website dev", diff --git a/packages/polycss/README.md b/packages/polycss/README.md index 2204902e0..983dca0e6 100644 --- a/packages/polycss/README.md +++ b/packages/polycss/README.md @@ -262,6 +262,7 @@ Each visible polygon is emitted as one leaf element; the renderer chooses the le | `@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-model loading, retained DOM animation, morph targets, skinning, and playback. | +| `@layoutit/polycss-skills` | `npx @layoutit/polycss-skills` — installs the PolyCSS agent skill into `.claude/skills` or `.agents/skills`. | | `@layoutit/polycss-domformat` | Private MIT-licensed producer-neutral `domformat@0` runtime for canonical JSON plus digest-bound sibling resources; conformance and specifications stay repository-side. Not published. | diff --git a/packages/react/README.md b/packages/react/README.md index cfc2f9a72..a882da448 100644 --- a/packages/react/README.md +++ b/packages/react/README.md @@ -233,6 +233,7 @@ to place that primitive in 3D space. Polygon count is the dominant cost. | `@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-model loading, retained DOM animation, morph targets, skinning, and playback. | +| `@layoutit/polycss-skills` | `npx @layoutit/polycss-skills` — installs the PolyCSS agent skill into `.claude/skills` or `.agents/skills`. | | `@layoutit/polycss-domformat` | Private MIT-licensed producer-neutral `domformat@0` runtime for canonical JSON plus digest-bound sibling resources; conformance and specifications stay repository-side. Not published. | diff --git a/packages/skills/README.md b/packages/skills/README.md new file mode 100644 index 000000000..db9960030 --- /dev/null +++ b/packages/skills/README.md @@ -0,0 +1,103 @@ +# PolyCSS Skills + +The PolyCSS agent skill, packaged as an installer. One command drops +`SKILL.md` plus a folder of reference docs into your project so Claude Code, +Codex, or any agent that reads a skills directory writes PolyCSS correctly +instead of guessing. + + +Visit [polycss.com](https://polycss.com) for docs and model examples. + +Join [chat.polycss.com](https://chat.polycss.com) for support and community discussions. + +PolyCSS primitives banner + + +## Install + +```bash +npx @layoutit/polycss-skills +``` + +No dependencies, nothing added to your `package.json`. It detects which agent +directories your project already has and installs into each of them: + +| Agent | Destination | +|---|---| +| Claude Code | `.claude/skills/polycss/` | +| Codex | `.agents/skills/polycss/` | + +A project with neither gets `.claude/skills/polycss/`. Start a new agent session +afterwards so the skill is picked up. + +## What lands on disk + +``` +.claude/skills/polycss/ + SKILL.md entry point — conventions, invariants, minimal scenes + docs/authoring-polygons.md winding, color format, coplanarity, the optimizer + docs/scenes-and-cameras.md scene options, camera props, custom elements + docs/shapes-and-primitives.md primitives and raw polygon generators + docs/loading-models.md OBJ, STL, glTF/GLB, VOX, parse options + docs/lighting.md baked vs dynamic, directional, ambient, point + docs/shadows.md castShadow, receiveShadow, parametric shadows + docs/textures.md UVs, the atlas pipeline, texture quality + docs/controls-and-interaction.md orbit, map, first-person, selection, gizmos + docs/animation.md glTF clips, mixers, stable DOM + docs/performance.md leaf counts, render strategies, voxel fast paths + docs/three-parity.md porting Three.js scenes + docs/troubleshooting.md symptom to cause + docs/api-index.md export inventory per package +``` + +`SKILL.md` is the entry point and carries the index; the agent reads a doc when +the task calls for it. + +## Options + +``` +--agent claude, codex, or all. Repeatable and comma-separated. +--dir Install into this exact directory instead of an agent's. +--cwd Project root to install into (default: current directory). +--global Install into your home directory instead of the project. +--force Overwrite files you have edited since installing. +--dry-run Print what would change, write nothing. +--list List the files this package would install. +``` + +## Upgrading + +Run the command again. Installs are content-hashed in a `.polycss-skill.json` +manifest next to the files, so an upgrade rewrites the docs it owns, drops docs +the skill no longer ships, and leaves anything you added alone. + +If you have edited an installed file, the upgrade stops and names it rather than +overwriting your work. Re-run with `--force` when you want the shipped version +back. + +## Fetching instead of installing + +The same content is served at +[polycss.com/skill.md](https://polycss.com/skill.md), with the reference docs +under `polycss.com/skill/docs/`. Use that when an agent can fetch a URL but +cannot run a command. + + +## Packages + +| Package | Description | +|---|---| +| `@layoutit/polycss-core` | Pure math, parsers, lighting, camera helpers, mesh optimization. Zero browser globals. | +| `@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-model loading, retained DOM animation, morph targets, skinning, and playback. | +| `@layoutit/polycss-skills` | `npx @layoutit/polycss-skills` — installs the PolyCSS agent skill into `.claude/skills` or `.agents/skills`. | +| `@layoutit/polycss-domformat` | Private MIT-licensed producer-neutral `domformat@0` runtime for canonical JSON plus digest-bound sibling resources; conformance and specifications stay repository-side. Not published. | + + + +## License + +MIT. + diff --git a/packages/skills/bin/polycss-skills.mjs b/packages/skills/bin/polycss-skills.mjs new file mode 100644 index 000000000..483666590 --- /dev/null +++ b/packages/skills/bin/polycss-skills.mjs @@ -0,0 +1,223 @@ +#!/usr/bin/env node +/** + * `npx @layoutit/polycss-skills` — install the PolyCSS agent skill into a project. + */ +import { readFileSync } from "node:fs"; +import { dirname, isAbsolute, relative, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; + +import { + AGENTS, + bundledSkillDir, + expandAgents, + installSkill, + listFiles, + resolveTargets, + SKILL_NAME, +} from "../src/install.mjs"; + +const here = dirname(fileURLToPath(import.meta.url)); +const { version } = JSON.parse(readFileSync(resolve(here, "..", "package.json"), "utf8")); + +const USAGE = `polycss-skills ${version} + +Installs the PolyCSS skill (SKILL.md + reference docs) so your coding agent +knows how to write PolyCSS. + +Usage + npx @layoutit/polycss-skills [options] + +Options + --agent Target agent: ${Object.keys(AGENTS).join(", ")}, or all. + Repeatable and comma-separated. Defaults to whichever + agent directories already exist, else ${AGENTS.claude.root}. + --dir Install into this exact directory instead of an agent's. + --cwd Project root to install into (default: current directory). + --global Install into your home directory instead of the project. + --force Overwrite files you have edited since installing. + --dry-run Print what would change, write nothing. + --list List the files this package would install. + --help, -h Show this message. + --version, -v Print the version. + +Agents +${Object.entries(AGENTS) + .map(([name, agent]) => ` ${name.padEnd(8)} ${agent.label} — ${agent.skills}/${SKILL_NAME}`) + .join("\n")} +`; + +function parseArgs(argv) { + const options = { + agents: [], + dir: null, + cwd: process.cwd(), + global: false, + force: false, + dryRun: false, + list: false, + help: false, + version: false, + }; + + for (let i = 0; i < argv.length; i += 1) { + const arg = argv[i]; + const valueOf = (name) => { + const next = argv[i + 1]; + if (next === undefined || next.startsWith("-")) { + throw new Error(`${name} requires a value`); + } + i += 1; + return next; + }; + + switch (arg) { + case "--agent": + case "-a": + options.agents.push(valueOf(arg)); + break; + case "--dir": + options.dir = valueOf(arg); + break; + case "--cwd": + options.cwd = valueOf(arg); + break; + case "--global": + options.global = true; + break; + case "--force": + case "-f": + options.force = true; + break; + case "--dry-run": + options.dryRun = true; + break; + case "--list": + options.list = true; + break; + case "--help": + case "-h": + options.help = true; + break; + case "--version": + case "-v": + options.version = true; + break; + default: + if (arg.startsWith("--agent=")) options.agents.push(arg.slice(8)); + else if (arg.startsWith("--dir=")) options.dir = arg.slice(6); + else if (arg.startsWith("--cwd=")) options.cwd = arg.slice(6); + else throw new Error(`unknown option "${arg}"`); + } + } + + return options; +} + +function display(path) { + const rel = relative(process.cwd(), path); + return !rel || rel.startsWith("..") || isAbsolute(rel) ? path : rel; +} + +function main(argv) { + let options; + try { + options = parseArgs(argv); + } catch (error) { + console.error(`polycss-skills: ${error.message}\n`); + console.error(USAGE); + return 1; + } + + if (options.help) { + console.log(USAGE); + return 0; + } + if (options.version) { + console.log(version); + return 0; + } + + const sourceDir = bundledSkillDir(); + + if (options.list) { + for (const file of listFiles(sourceDir)) console.log(file); + return 0; + } + + let targets; + try { + targets = options.dir + ? [{ agent: null, label: "custom directory", dir: resolve(options.cwd, options.dir) }] + : resolveTargets({ + cwd: resolve(options.cwd), + requested: expandAgents(options.agents), + global: options.global, + }); + } catch (error) { + console.error(`polycss-skills: ${error.message}`); + return 1; + } + + let blocked = false; + + for (const target of targets) { + let result; + try { + result = installSkill({ + sourceDir, + destDir: target.dir, + version, + force: options.force, + dryRun: options.dryRun, + }); + } catch (error) { + console.error(`polycss-skills: ${target.label}: ${error.message}`); + return 1; + } + + const where = display(target.dir); + + if (result.conflicts.length > 0) { + blocked = true; + console.error( + `polycss-skills: ${where} has local edits to:\n` + + result.conflicts.map((file) => ` ${file}`).join("\n") + + "\nRe-run with --force to overwrite them.", + ); + continue; + } + + if (options.dryRun) { + const summary = + result.write.length === 0 && result.remove.length === 0 + ? "already up to date" + : `would write ${result.write.length}, remove ${result.remove.length}`; + console.log(`polycss-skills: ${where} — ${summary} (dry run)`); + continue; + } + + if (result.write.length === 0 && result.remove.length === 0) { + console.log(`polycss-skills: ${where} — already up to date (${version})`); + continue; + } + + console.log( + `polycss-skills: installed ${SKILL_NAME} ${version} into ${where}` + + ` (${result.write.length} written` + + (result.remove.length > 0 ? `, ${result.remove.length} removed` : "") + + ")", + ); + for (const file of result.kept) { + console.log(` kept your edited copy of ${file} (no longer part of the skill)`); + } + } + + if (blocked) return 1; + + if (!options.dryRun) { + console.log("\nStart a new agent session so it picks the skill up."); + } + return 0; +} + +process.exitCode = main(process.argv.slice(2)); diff --git a/packages/skills/package.json b/packages/skills/package.json new file mode 100644 index 000000000..058ad8687 --- /dev/null +++ b/packages/skills/package.json @@ -0,0 +1,41 @@ +{ + "name": "@layoutit/polycss-skills", + "version": "0.2.11", + "description": "Installs the PolyCSS agent skill (SKILL.md + reference docs) into .claude/skills or .agents/skills.", + "type": "module", + "bin": { + "polycss-skills": "bin/polycss-skills.mjs" + }, + "exports": { + ".": "./src/install.mjs", + "./package.json": "./package.json" + }, + "keywords": ["polycss", "skill", "agent", "claude", "codex", "ai", "docs"], + "license": "MIT", + "repository": { + "type": "git", + "url": "https://github.com/LayoutitStudio/polycss.git", + "directory": "packages/skills" + }, + "bugs": { + "url": "https://github.com/LayoutitStudio/polycss/issues" + }, + "homepage": "https://github.com/LayoutitStudio/polycss#readme", + "files": [ + "bin", + "src/install.mjs", + "skill" + ], + "scripts": { + "test": "vitest run", + "test:coverage": "vitest run --coverage", + "prepack": "node ../../.github/scripts/sync-package-readmes.mjs" + }, + "publishConfig": { + "access": "public" + }, + "devDependencies": { + "vitest": "^3.1.1", + "@vitest/coverage-v8": "^3.1.1" + } +} diff --git a/packages/skills/skill/SKILL.md b/packages/skills/skill/SKILL.md new file mode 100644 index 000000000..130a2c922 --- /dev/null +++ b/packages/skills/skill/SKILL.md @@ -0,0 +1,190 @@ +--- +name: polycss +description: Build PolyCSS scenes that render 3D meshes, primitive shapes, or custom polygons as DOM/CSS polygon elements. Use when asked to create, port, debug, or explain PolyCSS code in vanilla JavaScript, React, or Vue. +--- + +# PolyCSS — DOM 3D Rendering + +PolyCSS renders 3D polygon meshes as real DOM elements transformed with CSS +`matrix3d(...)`. No WebGL, no canvas-per-frame. It supports OBJ/MTL, STL, +glTF/GLB, VOX, generated primitives, colors, textures, dynamic lighting, +shadows, controls, selection, animation, and per-polygon interaction. + +Use native PolyCSS when authoring PolyCSS-first scenes. Use the Three.js parity +API when porting Three.js code or generating code from Three-shaped examples. + +## Reference docs + +Read the file that matches the task before writing non-trivial code. + +| File | Read it when | +|---|---| +| [docs/authoring-polygons.md](docs/authoring-polygons.md) | **Generating `Polygon[]` by hand.** Winding, color format, coplanarity, the optimizer. Silent-failure rules. | +| [docs/scenes-and-cameras.md](docs/scenes-and-cameras.md) | Setting up a scene, camera props, scene options, custom elements, coordinates. | +| [docs/shapes-and-primitives.md](docs/shapes-and-primitives.md) | Boxes, spheres, planes, Platonic solids, raw polygon generators. | +| [docs/loading-models.md](docs/loading-models.md) | `loadMesh`, ``, OBJ/MTL/STL/glTF/GLB/VOX, parse options. | +| [docs/lighting.md](docs/lighting.md) | Directional/ambient/point lights, baked vs dynamic, rebaking. | +| [docs/shadows.md](docs/shadows.md) | `castShadow`, `receiveShadow`, parametric shadows, renderer differences. | +| [docs/textures.md](docs/textures.md) | UV textures, the atlas pipeline, texture quality, presentation options. | +| [docs/controls-and-interaction.md](docs/controls-and-interaction.md) | Orbit/map/first-person controls, selection, transform gizmos, click handlers. | +| [docs/animation.md](docs/animation.md) | Skeletal clips from glTF/GLB, `usePolyAnimation`, stable DOM. | +| [docs/performance.md](docs/performance.md) | Leaf counts, render strategies, atlas memory, voxel fast paths. | +| [docs/three-parity.md](docs/three-parity.md) | Porting Three.js scenes through the `*/three` subpaths. | +| [docs/troubleshooting.md](docs/troubleshooting.md) | **Something renders wrong.** Symptom → cause table. | +| [docs/api-index.md](docs/api-index.md) | "Does this export exist?" Package-by-package export inventory. | + +## Packages + +| Package | Use | +|---|---| +| `@layoutit/polycss` | Vanilla + custom elements. Re-exports all of core. | +| `@layoutit/polycss-react` | React components and hooks. Re-exports core. | +| `@layoutit/polycss-vue` | Vue 3 mirror of React. Re-exports core. | +| `@layoutit/polycss-core` | Pure math and parsers, zero browser globals (Node, workers). | +| `@layoutit/polycss-fonts` | Text → extruded 3D `Polygon[]`. | +| `@layoutit/polycss-morph` | Prepared models with retained DOM, morphs, skinning, playback. | + +React and Vue depend on `core` only — **never import `@layoutit/polycss` from a +React or Vue app.** The public API is mirrored between React and Vue: same +names, same defaults, idiomatic differences only (refs vs reactives). + +## Imports + +```ts +import { + createPolyCamera, + createPolyPerspectiveCamera, + createPolyScene, + createPolyOrbitControls, + createPolyBox, + createPolyPlane, + loadMesh, +} from "@layoutit/polycss"; +``` + +```tsx +import { + PolyCamera, + PolyPerspectiveCamera, + PolyScene, + PolyMesh, + PolyGround, + PolyOrbitControls, + Poly, +} from "@layoutit/polycss-react"; // or "@layoutit/polycss-vue" +``` + +## Conventions + +- Coordinates are PolyCSS world space `[x, y, z]` with **+Z up**. World Y maps + to CSS X (screen-right at identity rotation) and world X to CSS Y + (screen-down); the default camera (`rotX: 65, rotY: 45`) presents that as an + isometric view. +- Camera rotations are degrees: `rotX`, `rotY`. +- `zoom` is on-screen CSS pixels per world unit (default `0.65`; orbit controls + clamp to `0.1`–`10` by default). `BASE_TILE` (50) is the world-unit → CSS px + factor; you need it when converting world units to raw CSS pixels yourself — + e.g. `` mounts a document `width × 50` px wide. +- `PolyCamera` / `createPolyCamera` are **orthographic** by default (this + deliberately diverges from three.js). Use `PolyPerspectiveCamera` / + `createPolyPerspectiveCamera` for depth foreshortening. +- The camera is the **outer** node; the scene nests inside it. CSS `perspective` + only applies to descendants. +- Every public export is `Poly`-prefixed, except generic math types (`Vec2`, + `Vec3`, `Polygon`, `PolyMaterial`) and the `*/three` subpaths. + +## Authoring polygons — the five silent failures + +A `Polygon` is a plain object; `vertices` is the only required field. + +```ts +interface Polygon { + vertices: [number, number, number][]; // 3+ points, CCW seen from outside + color?: string; // hex or rgb()/rgba() ONLY + texture?: string; // image URL + uvs?: [number, number][]; // one per vertex + material?: PolyMaterial; // shared material; material.texture wins over `texture` + data?: Record; // → data-* attributes +} +``` + +These constraints fail with **no throw and no console warning**: + +1. **Winding decides visibility.** Vertex order sets the normal by the + right-hand rule (`(v1-v0) × (v2-v0)`), and PolyCSS backface-culls every leaf. + Wind counter-clockwise as seen from the side you want to look at. +2. **`color` is not a full CSS color.** Only `#rgb`, `#rrggbb`, `rgb()`, and + `rgba()` parse. `"tomato"`, `hsl()`, and `color()` render **white**. +3. **Non-triangular polygons must be coplanar**, or they are flattened onto + their average plane and crack against their neighbours. Triangles are safe. +4. **The optimizer rewrites geometry by default** (`merge: true`, + `meshResolution: "lossy"`). Pass `{ merge: false }` to render your array + as authored. +5. **Degenerate polygons vanish silently** — under 3 vertices, zero area, or a + degenerate first edge. + +Read [docs/authoring-polygons.md](docs/authoring-polygons.md) in full before +generating geometry — it covers per-parser winding behaviour, which entry points +normalize, and the exact optimizer thresholds. + +## Minimal scene + +Vanilla: + +```ts +const camera = createPolyCamera({ rotX: 65, rotY: 45 }); +const scene = createPolyScene(document.getElementById("host")!, { + camera, + textureLighting: "dynamic", + directionalLight: { direction: [0.5, -0.6, 0.7], color: "#ffffff", intensity: 1 }, + ambientLight: { color: "#ffffff", intensity: 0.35 }, +}); + +createPolyOrbitControls(scene, { drag: true, wheel: true }); + +scene.add(createPolyBox({ size: 100, color: "#ffd166" }), { position: [0, 0, 50] }); +scene.add(await loadMesh("/model.glb"), { castShadow: true }); +// Vanilla has no ground fallback — a caster needs an explicit receiver. +scene.add(createPolyPlane({ axis: 2, size: 60, offset: 0, color: "#7d848e" }), { receiveShadow: true }); +``` + +React (Vue mirrors this with kebab-case props): + +```tsx + + + + + + {polygons.map((p, i) => select(i)} />)} + + +``` + +Custom elements (no build step): + +```html + + + + + + + + +``` + +## Rules of thumb + +- **Polygon count is the dominant cost.** One visible polygon = one DOM leaf, + one `matrix3d`, one paint. Halving polygon count beats every other + optimisation. +- **Never run a `requestAnimationFrame` loop to update many leaves.** Camera, + mesh, and light motion are single-ancestor CSS updates. If you find yourself + writing a per-frame loop over polygons, you are fighting the engine. +- **Prefer `textureLighting: "dynamic"`** for live or animated lights (zero JS + per light change). Prefer `"baked"` for point lights and maximum fidelity. +- **`scene.destroy()` and `result.dispose()`** release atlas blob URLs. The mesh + element and `usePolyMesh` do it for you. + +Full documentation: https://polycss.com diff --git a/packages/skills/skill/docs/animation.md b/packages/skills/skill/docs/animation.md new file mode 100644 index 000000000..a50df88f0 --- /dev/null +++ b/packages/skills/skill/docs/animation.md @@ -0,0 +1,128 @@ +# Animation + +Skeletal animation from glTF/GLB is the **one renderer exception** to the +"no JS in the render loop" rule. Skinning changes each polygon independently, so +the clip is sampled in JS, the leaf set stays mounted, and baked transform +frames are cached. + +Everything else — camera motion, mesh motion, light changes, autorotate — is a +single-ancestor CSS update. Do not write a per-frame loop over polygons for +those. + +## How it works + +When `loadMesh()` or `parseGltf()` finds usable clips, `ParseResult.animation` +exposes clip metadata and a `sample()` function. Sampling evaluates the source +animation at a time, applies the pose, and returns `Polygon[]` for that moment. +`createPolyAnimationMixer` (core) and `usePolyAnimation` (React/Vue) sit on top +and manage actions, looping, speed, fades, and cross-fades. + +## Mesh setup matters + +Animated meshes need **stable triangle topology**: + +- Vanilla: `scene.add(result, { merge: false, stableDom: true })` +- React/Vue: `meshResolution="lossless"` and/or `merge={false}` — there is + **no `stableDom` prop**; leaf identity across same-topology frames is handled + internally. + +## Vanilla + +The mesh handle returned by `scene.add()` satisfies `PolyAnimationTarget`. You +own the loop. + +```ts +import { createPolyAnimationMixer, loadMesh } from "@layoutit/polycss"; + +const result = await loadMesh("/character.glb", { meshResolution: "lossless" }); +const mesh = scene.add(result, { merge: false, stableDom: true }); + +if (result.animation?.clips.length) { + const mixer = createPolyAnimationMixer(mesh, result.animation); + mixer.clipAction(result.animation.clips[0].name).reset().play(); + + let last = performance.now(); + const tick = (now: number) => { + mixer.update((now - last) / 1000); + last = now; + requestAnimationFrame(tick); + }; + requestAnimationFrame(tick); +} +``` + +## React + +`usePolyAnimation` mirrors drei's `useAnimations` and **owns its own rAF loop**. +It returns `clips`, `names`, `actions`, `mixer`, and a `ref`. Load the mesh +yourself when you need both `polygons` and the animation controller. + +```tsx +const [result, setResult] = useState(null); +const meshRef = useRef(null); +const { actions, names } = usePolyAnimation( + result?.animation?.clips, + result?.animation, + meshRef, +); + +useEffect(() => { + const first = names[0]; + if (first) actions[first]?.reset().play(); +}, [actions, names]); + +return ( + + {result && ( + + )} + +); +``` + +Dispose the `ParseResult` on unmount — `loadMesh` created blob URLs. + +## Vue + +Same shape; `clips`, `names`, `actions`, and `mixer` are computed refs, and the +arguments are passed as refs: + +```ts +const clips = computed(() => result.value?.animation?.clips); +const controller = computed(() => result.value?.animation); +const { actions, names } = usePolyAnimation(clips, controller, meshRef); + +watchEffect(() => { + const first = names.value[0]; + if (first) actions.value[first]?.reset().play(); +}); +``` + +## Actions + +Both the hook and the core mixer expose the familiar three.js-shaped methods: +`play`, `stop`, `reset`, `fadeIn`, `fadeOut`, `crossFadeTo`, `setLoop`, +`setEffectiveTimeScale`, `setEffectiveWeight`. + +`LoopOnce`, `LoopRepeat`, and `LoopPingPong` match the three.js numeric +constants. + +Cross-fading assumes the sampled clips share matching polygon counts and vertex +order — true for clips from the same parsed mesh. + +## Shadows during animation + +An animated caster's shadow **freezes** by default. Set +`shadow.followAnimation: true` to track the pose, and pair it with a low +parametric `definition` — see [shadows.md](shadows.md). + +## Browser note + +On WebKit/Safari, stable CSS triangles fall through to atlas `` leaves. +Same-topology updates keep the existing elements and bitmap URLs mounted and +cache transform frames once warmed. This optimized path is the default; there is +no "baseline vs optimized" toggle. + +Color is **pinned** to the baked value while transforms animate. Recomputing +Lambert from every deformed low-poly face normal causes visible color pumping, +so color refresh is not the default. diff --git a/packages/skills/skill/docs/api-index.md b/packages/skills/skill/docs/api-index.md new file mode 100644 index 000000000..28c5b5f68 --- /dev/null +++ b/packages/skills/skill/docs/api-index.md @@ -0,0 +1,193 @@ +# API Index + +Use this to check whether a name exists before writing it. If a symbol is not +here and not in your editor's completions, do not invent it. + +## Where things live + +- `@layoutit/polycss` does `export * from "@layoutit/polycss-core"`, so every + core name is available from it, plus the imperative API, custom elements, and + the renderer's atlas internals. +- `@layoutit/polycss-react` and `@layoutit/polycss-vue` re-export a **curated + list** of core — parsers, generators, math, and the common types — not all of + it. A core name missing from their index (for example `PolyPointLight`, or the + `resolvePolyTexture*` helpers) is imported from `@layoutit/polycss-core` + directly, which React and Vue already depend on. Never import + `@layoutit/polycss` from a React or Vue app. +- The React and Vue public surfaces are **mirrored**. The only value exports + that differ are the idiomatic context handles: React has `PolyCameraContext` + and `useCameraContext`; Vue has `PolyCameraContextKey` and + `PolySelectionContextKey`. + +## Components (React / Vue) + +`Poly`, `PolyScene`, `PolyMesh`, `PolyIframe`, `PolyGround`, +`PolyCamera`, `PolyPerspectiveCamera`, `PolyOrthographicCamera`, +`PolyOrbitControls`, `PolyMapControls`, `PolyFirstPersonControls`, +`PolyTransformControls`, `PolySelect`, +`PolyAxesHelper`, `PolyDirectionalLightHelper`, +and the shapes: `PolyBox`, `PolyPlane`, `PolyRing`, `PolySphere`, +`PolyCylinder`, `PolyCone`, `PolyTorus`, `PolyTetrahedron`, `PolyOctahedron`, +`PolyIcosahedron`, `PolyDodecahedron`. + +## Hooks / composables (React / Vue) + +`usePolyCamera`, `usePolyMesh`, `usePolyMaterial`, `usePolySceneContext`, +`usePolySelect`, `usePolySelectionApi`, `usePolyAnimation`. + +## Vanilla-only (`@layoutit/polycss`) + +Factories: `createPolyScene`, `createPolyCamera`, `createPolyPerspectiveCamera`, +`createPolyOrthographicCamera`, `createPolyOrbitControls`, +`createPolyMapControls`, `createPolyFirstPersonControls`, +`createTransformControls`, `createSelect`, and the shape factories +`createPolyBox`, `createPolyPlane`, `createPolyRing`, `createPolySphere`, +`createPolyCylinder`, `createPolyCone`, `createPolyTorus`, +`createPolyTetrahedron`, `createPolyOctahedron`, `createPolyIcosahedron`, +`createPolyDodecahedron`. + +Note the two `create*` names without a `Poly` infix: `createSelect` and +`createTransformControls`. + +Snapshot: `exportPolySceneSnapshot`, `PolySceneSnapshotError`. + +Element classes: `PolySceneElement`, `PolyMeshElement`, `PolyPolygonElement`, +`PolyIframeElement`, `PolyCameraElement`, `PolyPerspectiveCameraElement`, +`PolyOrthographicCameraElement`, `PolyOrbitControlsElement`, +`PolyMapControlsElement`, `PolyFirstPersonControlsElement`, +`PolyTransformControlsElement`, `PolySelectElement`, and the shape element +classes. Importing `@layoutit/polycss` does **not** register them — import +`@layoutit/polycss/elements` for that side effect. + +## Custom element tags + +``, ``, ``, ``, +``, ``, ``, +``, ``, ``, +``, ``, ``, +``, and the shapes ``, ``, +``, ``, ``, ``, +``, ``, ``, +``, ``. + +## Parsing and loading (all packages) + +`loadMesh`, `parseObj`, `parseMtl`, `parseStl`, `parseGltf`, `parseVox`, +`normalizePolygons`. + +## Geometry generators (all packages) + +`boxPolygons`, `planePolygons`, `ringPolygons`, `spherePolygons`, +`cylinderPolygons`, `conePolygons`, `torusPolygons`, `tetrahedronPolygons`, +`octahedronPolygons`, `icosahedronPolygons`, `dodecahedronPolygons`, +`axesHelperPolygons`, `arrowPolygons`. + +## Optimizer and mesh ops (all packages) + +`optimizeMeshPolygons`, `optimizeMeshParseResult`, +`optimizeAnimatedMeshPolygons`, `mergePolygons`, `cullInteriorPolygons`, +`simplifyTriangleMeshPolygons`, `coverPlanarPolygons`, `repairMeshSeams`, +`bakeSolidTextureSamples`, `bakeSolidTextureSampledPolygons`, +`seamOverlapDiagnostics`, `seamOverlapPolygons`, `seamFacetSplitPolygons`. + +## Camera and coordinate math (all packages) + +`buildPolyCameraSceneTransform`, `buildPolyMeshTransform`, +`buildPolySceneTransform`, `capturePolyCameraSnapshot`, +`polyCameraTargetToCss`, `resolvePolyCameraAppliedPerspectiveStyle`, +`worldPositionToCss` / `worldPositionToPolyCss`, +`cssPositionToWorld` / `polyCssPositionToWorld`, +`worldDistanceToCss` / `worldDistanceToPolyCss`, +`cssDistanceToWorld` / `polyCssDistanceToWorld`, +`worldDirectionToCss` / `worldDirectionToPolyCss`, +`worldDirectionalLightToCss` / `worldDirectionalLightToPolyCss`. + +Constant: `BASE_TILE` (50). + +## Diagnostics and DOM helpers + +`collectPolyRenderStats`, `collectPolyTextureReadiness`, `queryPolyLeaves`, +`findPolyMeshHandle`, `pointInMeshElement`, `findMeshUnderPoint`, +`injectPolyBaseStyles`. + +Texture resolution (from `@layoutit/polycss-core` or `@layoutit/polycss`, **not** +the React/Vue indexes): `resolvePolyTextureLeafGeometry`, +`resolvePolyTextureImageSource`, `resolvePolyTexturePresentation`, +`resolvePolyTextureImageRendering`. + +## Animation + +`createPolyAnimationMixer`, `usePolyAnimation` (React/Vue), and the loop +constants `LoopOnce`, `LoopRepeat`, `LoopPingPong`. + +## Key types + +Geometry: `Vec2`, `Vec3`, `Polygon`, `PolyMaterial`, `ParseResult`, +`MeshResolution`. + +Lights: `PolyDirectionalLight`, `PolyAmbientLight` (all packages); +`PolyPointLight` (core, `@layoutit/polycss`, and the `*/three` subpaths — React +and Vue accept the `pointLights` prop but do not re-export the type, so import +it from `@layoutit/polycss-core`). + +Texture: `PolyTextureLightingMode`, `PolyTextureLeafSizing`, +`PolyTextureBackend`, `PolyTextureImageRendering`, `PolyTextureImageLighting`, +`PolyTextureProjection`, `PolyTexturePresentation`, `PolyTextureImageSource`. + +Camera: `PolyCameraProjection`, `PolyCameraSnapshot`, `PolyCameraSnapshotStats`. + +Scene/mesh: `PolyMeshHandle`, `PolyMeshTransformInput`, +`PolySceneTransformInput` (all packages); `PolySceneOptions`, +`PolySceneHandle`, `PolyMeshTransform` (vanilla — React/Vue use component prop +types such as `PolySceneProps` and `PolyMeshProps` instead). + +Render: `PolyRenderStrategy`, `PolyRenderStrategiesOption`, `PolyRenderStats`, +`PolyLeafInfo` (all packages); `TextureQuality` (core and `@layoutit/polycss`). + +Parse options: `LoadMeshOptions`, `ObjParseOptions`, `StlParseOptions`, +`GltfParseOptions`, `VoxParseOptions`, `UseMeshOptions`. + +Animation: `PolyAnimationMixer`, `PolyAnimationAction`, `PolyAnimationClip`, +`PolyAnimationTarget`, `ParseAnimationController`, `ParseAnimationClip`, +`LoopMode`. + +Controls (vanilla): `PolyOrbitControlsOptions`, `PolyMapControlsOptions`, +`PolySelectOptions`, `PolySelectionHandle`, `PolyTransformControlsOptions` +(+ matching `*Handle` types). React/Vue use `PolyOrbitControlsProps`, +`PolyMapControlsProps`, `PolySelectProps`, `PolyTransformControlsProps`. + +`PolyFirstPersonControlsOptions` and `PolyFirstPersonControlsHandle` are +exported by **all three** renderers, not vanilla only. + +## Three parity subpaths + +`@layoutit/polycss-core/three`, `@layoutit/polycss/three`, +`@layoutit/polycss-react/three`, `@layoutit/polycss-vue/three`. + +Names: `Vector3`, `Euler`, `Object3D`, `PerspectiveCamera`, +`OrthographicCamera`, `DirectionalLight`, `PointLight`, `AmbientLight`, +`transformPolygonsToPoly`, `mountPolyThreeScene` (vanilla), +`PolyThreePerspectiveCamera`, `PolyThreeOrthographicCamera`, `PolyThreeMesh` +(React/Vue). + +## Other packages + +`@layoutit/polycss-fonts`: `textPolygons`, `composeText`, `loadGoogleFont`, +`listGoogleFonts`. + +`@layoutit/polycss-morph`: `loadPolyMorphPackage`, `mountPolyMorphModel`, +`createPolyMorphPreparedDomTarget`; Node-only preparation under +`@layoutit/polycss-morph/prepare`. Profiles: `static-prepared`, +`morph-regions`, `joint-skin`, `prepared-playback`. + +## Names that do NOT exist + +- No `polygons` option on `createPolyScene` — use `scene.add(...)`. +- No `polygons` attribute on `` — use `` or the + imperative API. +- No `receiveShadow` prop on ``. +- No `stableDom` prop in React/Vue — vanilla `scene.add` option only. +- No `merge` option on `` or ``. +- No render-time `doubleSided` flag on `Polygon`. +- `exportPolySceneSnapshot` is **not** exported from React or Vue — import it + from `@layoutit/polycss` and pass the rendered element. diff --git a/packages/skills/skill/docs/authoring-polygons.md b/packages/skills/skill/docs/authoring-polygons.md new file mode 100644 index 000000000..cfd6d028c --- /dev/null +++ b/packages/skills/skill/docs/authoring-polygons.md @@ -0,0 +1,174 @@ +# Authoring Polygons + +Read this before generating `Polygon[]` by hand. Every constraint here fails +**silently** — no throw, no console warning, no visual error state. + +## The shape + +```ts +interface Polygon { + vertices: [number, number, number][]; // 3+ points, CCW seen from outside + color?: string; // hex or rgb()/rgba() ONLY + texture?: string; // image URL + uvs?: [number, number][]; // one per vertex + material?: PolyMaterial; // shared material; material.texture wins over `texture` + textureImageSource?: PolyTextureImageSource; // source image metadata; needs texturePresentation.backend="image" (advanced) + texturePresentation?: PolyTexturePresentation; // per-polygon texture overrides (advanced) + data?: Record; // → data-* attributes +} +``` + +Fields not listed here (`textureWrap`, `textureTriangles`, `doubleSided`, …) +are parser-internal — do not author them. + +## What each entry point does for you + +How much cleanup you get for free varies by parser and by entry point. + +**Winding:** STL repairs it from connectivity; `.vox` is correct by +construction; **OBJ and glTF preserve source winding as-is**. All parsers fit to +target size and normalize into PolyCSS Z-up coordinates. The axis transform is +per-format: OBJ and glTF apply a cyclic `(x,y,z) → (z,x,y)` permutation (never a +y↔z swap, so handedness is preserved); STL defaults to identity axes; `.vox` is +already Z-up. + +**Validation:** only React/Vue `` runs `normalizePolygons` +(drops degenerates, strips mismatched `uvs`, replaces bad colors with +`#cccccc`, fan-triangulates non-coplanar n-gons) — and its warnings are never +surfaced. `scene.add(...)`, ``, ``, and +`` do **not** normalize. + +## 1. Winding decides visibility + +Vertex order sets the face normal by the right-hand rule +(`(v1-v0) × (v2-v0)`), and PolyCSS backface-culls every leaf. A reversed face is +invisible from the side you meant to show, and shades from the flipped normal — +typically ambient-only, since the directional term clamps at zero (it darkens, +it does not invert). + +Shadows differ by path: React/Vue's ground fallback projects every polygon +regardless of orientation, but the `receiveShadow` path (vanilla's only +mechanism) light-back-face-culls casters, so a reversed open face can lose its +shadow too. + +**Wind counter-clockwise as seen from the side you want to look at.** + +```ts +// Faces +Z (up) — visible from above. +{ vertices: [[0,0,0], [1,0,0], [1,1,0], [0,1,0]], color: "#d8d2c7" } +// Same quad reversed — faces -Z, invisible from above. +{ vertices: [[0,0,0], [0,1,0], [1,1,0], [1,0,0]], color: "#d8d2c7" } +``` + +Corollaries: + +- Solids wind outward; rooms and interiors wind **inward**. +- Mirroring or negative scale reverses handedness and requires reversing + winding. +- Reversing vertices requires reversing `uvs` in the same order. +- `doubleSided` is importer-internal and is **not** a render-time flag — it will + not make a face visible from behind. There is no way to make one polygon + visible from both sides; emit two polygons with opposite winding. + +**Diagnostic rule:** a single-sided face disappearing when the camera moves +behind it is correct behavior, not a bug. The winding symptom is a surface +missing or flickering **from the viewpoint it was built to be seen from** — it +exists in the data, its neighbours render, but it only shows from the opposite +side. Then inspect winding and normal before touching culling, lighting, or +camera code. + +## 2. `color` is not a full CSS color + +Only `#rgb`, `#rrggbb`, `rgb()`, and `rgba()` parse. Named colors (`"tomato"`), +`hsl()`, and `color()` fail silently — rendering **white**, or `#cccccc` on the +normalizing `` path. + +Convert before authoring: + +```ts +// Wrong — renders white. +{ vertices, color: "rebeccapurple" } +// Right. +{ vertices, color: "#663399" } +``` + +## 3. Non-triangular polygons must be coplanar + +On every path except ``, a non-planar n-gon is flattened +onto its average plane, opening cracks against its neighbours; +`` instead fan-triangulates it, silently changing topology. +Triangles are always safe. + +If you need a quad whose corners do not lie on one plane, emit two triangles +instead. If you deliberately snap a vertex onto a shared plane to enable a +merge, propagate the new position to **every** polygon that references it, or +you have traded a flatten for a crack. + +## 4. The optimizer rewrites geometry by default + +`merge` defaults to `true` and `meshResolution` to `"lossy"`: + +- Coincident faces within `0.05` world units are deduped. +- Interior faces are culled. +- Lossy merging starts at `0.35` world units of plane displacement / `0.04` + boundary / `15°` — but that is not the ceiling: the optimizer also tries + aggressive `30°`, `45°`, and `60°` variants (the widest at `0.06` boundary), + accepted on a material render-cost win. + +The degree values are angular thresholds; the displacement budgets are absolute +world units. **None are configurable.** + +Dedupe and interior culling count as exact reductions and still run under +`meshResolution: "lossless"` — with one parse-time exception: STL parse results +force the lossless optimizer *and* pass `skipInteriorCull`, but that protection +does not survive into the renderer's own pass, which culls again unless you set +`merge: false`. + +`merge: false` renders the array you pass untouched, but only on +`scene.add(...)` and ``. It does **not** exist on +`` (always normalized + merged) or ``, and it +cannot undo `loadMesh`'s own parse-time optimization. + +There is no exact-as-authored path for file geometry — the parsers normalize +(fit to `targetSize` `60`, origin reposition, per-format axis normalization, +rounding, fan-triangulation; STL repairs winding; `.vox` greedy-meshes quads). +To preserve the *direct parser output* from renderer optimization, call +`parseObj` / `parseStl` / `parseGltf` / `parseVox` directly and add with +`merge: false`. + +## 5. Degenerate polygons vanish silently + +Under 3 vertices, zero area, or a degenerate first edge produces no leaf and no +console output. If a polygon you authored is simply absent from the DOM, check +for duplicate consecutive vertices before anything else. + +## Getting geometry into a scene + +`scene.add()` takes a `ParseResult`, not a raw array. Wrap it yourself: + +```ts +scene.add({ polygons, objectUrls: [], warnings: [], dispose: () => {} }, { merge: false }); +``` + +There is **no** `polygons` option on `createPolyScene`. In React/Vue, +`` and `` both exist and +behave differently (see the validation note above). + +## Meshing for cheap rendering + +If you are generating geometry programmatically, the mesher's job is to +maximise cheap leaves and minimise atlas-backed ones. + +- **Polygon count is the dominant cost.** One visible polygon = one DOM node, + one `matrix3d`, one paint. +- **Fill ratio matters for textured polygons.** A textured polygon's atlas slice + equals its local-2D bounding rect; empty space inside is wasted bitmap. + Axis-aligned rectangle = 1.0 (and the fastest path); right-isosceles triangle + = 0.5; skinny triangles are far worse and many of them balloon atlas memory. +- **Regular grids are not required.** Any planar tiling whose edges match across + neighbours (no T-junctions, no cracks) is valid. Break the grid where it lets + you fit larger axis-aligned rects to flat regions. +- **Track cumulative vertex displacement**, not per-merge error, when snapping + vertices to shared planes. Errors compound. + +See [performance.md](performance.md) for the render-strategy table. diff --git a/packages/skills/skill/docs/controls-and-interaction.md b/packages/skills/skill/docs/controls-and-interaction.md new file mode 100644 index 000000000..968fc07f5 --- /dev/null +++ b/packages/skills/skill/docs/controls-and-interaction.md @@ -0,0 +1,149 @@ +# Controls and Interaction + +Controls are **additive layers**, following the three.js split. They attach +their own pointer/wheel listeners; only `animate` runs a `requestAnimationFrame` +loop, and it updates one ancestor transform, not per-polygon state. + +| Control | Purpose | +|---|---| +| `PolyOrbitControls` / `createPolyOrbitControls` | Drag orbit + wheel zoom + autorotate. Default pick. | +| `PolyMapControls` / `createPolyMapControls` | Drag **pans** instead of orbiting. Top-down / flat layouts. | +| `PolyFirstPersonControls` / `createPolyFirstPersonControls` | Pointer-lock mouselook + WASD, jump, crouch. | +| `PolyTransformControls` / `createTransformControls` | Translate/rotate gizmo on a selected mesh handle. | +| `PolySelect` / `createSelect` | Pointer picking over mesh handles. | + +Camera controls mutate the wrapping camera state. Transform controls mutate the +attached mesh handle via `setTransform`. + +## Orbit / Map options + +| Prop | Type | Default | Notes | +|---|---|---|---| +| `drag` | `boolean` | `true` | Pointer-drag rotation (orbit) or pan (map). | +| `wheel` | `boolean` | `true` | Wheel / pinch zoom. Mac trackpad pinch arrives as `wheel` with `ctrlKey`, so this covers both. | +| `invert` | `boolean \| number` | `false` | `true` reverses; a number scales sensitivity (negative inverts). | +| `minZoom` / `maxZoom` | `number` | `0.1` / `10` | Zoom clamps. | +| `dolly` | `boolean` | `false` | Wheel drives `distance` instead of `zoom`. | +| `minDistance` / `maxDistance` | `number` | `0` / `5000` | Dolly clamps. | +| `animate` | `false \| { speed?, axis?, pauseOnInteraction? }` | `false` | Autorotate. | + +`animate` fields: `speed` (default `0.3`, degrees per 60 Hz-equivalent frame ≈ +18 deg/sec), `axis` (`"y"` default, `"x"` tilts), `pauseOnInteraction` (default +`true`). The tick is `dt`-clamped at 50 ms so speed is refresh-rate independent +and a refocused tab does not jump. + +**Zoom vs dolly:** the default wheel behaviour scales the whole scene (good for +isometric/map-style). `dolly` moves the viewpoint back along the view axis, +mirroring three.js `OrbitControls` changing the spherical radius — better for +perspective scenes where foreshortening should stay consistent. + +On custom elements, the presence of any `animate-*` attribute +(`animate-speed`, `animate-axis`, `animate-pause-on-interaction`) implies +`animate` is enabled; removing them all turns it off. + +## Imperative handle + +```ts +const controls = createPolyOrbitControls(scene, { + drag: true, + wheel: true, + animate: { speed: 0.3, axis: "y", pauseOnInteraction: true }, +}); + +controls.update({ animate: false }); // live partial update +controls.pause(); // detach listeners + cancel rAF +controls.resume(); +controls.destroy(); + +controls.addEventListener("change", (e) => console.log(e.camera)); +controls.addEventListener("start", () => {}); // interaction begin +controls.addEventListener("end", () => {}); // interaction end +``` + +## First-person controls + +Click the scene to acquire pointer lock; Escape releases it. + +| Prop | Default | | Prop | Default | +|---|---|---|---|---| +| `enabled` | `true` | | `moveSpeed` | `5` (world units/sec) | +| `lookEnabled` | `true` | | `jumpVelocity` | `7` | +| `moveEnabled` | `true` | | `gravity` | `18` | +| `jumpEnabled` | `true` | | `eyeHeight` | `1.7` | +| `crouchEnabled` | `true` | | `crouchHeight` | `1` | +| `lookSensitivity` | `0.15` (deg/px) | | `groundZ` | `0` | +| `invertY` | `false` | | `minPitch` / `maxPitch` | `5` / `175` | + +Pair it with `PolyPerspectiveCamera` — first-person scenes want +foreshortening. + +Imperative handles expose `lock()`, `unlock()`, `isLocked()`, `getOrigin()`, +`setOrigin()`, `pause()`, `resume()`, `destroy()`, `update(partial)`. + +## Selection + +For whole-mesh selection use `PolySelect` / `` rather than wiring +every polygon. It tracks selected `PolyMeshHandle`s and supports multi-select. + +```tsx + setSelected(meshes[0] ?? null)}> + + + +``` + +- `usePolySelect()` reads the current selection inside a subtree. +- `usePolySelectionApi()` gives a nested toolbar `set`, `add`, `remove`, + `toggle`, `clear`. +- Lower-level DOM helpers: `findPolyMeshHandle(el)`, + `pointInMeshElement(meshEl, clientX, clientY)`, + `findMeshUnderPoint(clientX, clientY, filter?)`. They use the same + bounding-rect fallback that selection and transform controls use for clipped + polygon leaves. + +Raycasting runs on pointer events only — never per frame. + +## Transform controls + +Translate mode gives axis arrows and plane handles; rotate mode gives axis +rings. Dragging updates the attached mesh directly. + +Key props: `object`, `mode`, `size`, `showX`, `showY`, `showZ`, +`translationSnap`, `rotationSnap`, `enabled`, `onChange`, `onObjectChange`, +`onMouseDown`, `onMouseUp`, `onDraggingChanged`. + +## Per-polygon events + +Every polygon is a real DOM element, so ordinary handlers, classes, and CSS +work — in every entry point. + +```tsx +{polygons.map((p, i) => ( + select(i)} + onMouseEnter={() => setHovered(i)} + className={hovered === i ? "highlight" : ""} + style={{ transition: "filter 0.2s" }} + /> +))} +``` + +```css +.highlight { filter: brightness(1.5); } +``` + +```js +// Vanilla custom elements +const el = document.createElement("poly-polygon"); +el.setAttribute("vertices", JSON.stringify(p.vertices)); +el.addEventListener("click", () => el.classList.toggle("selected")); +scene.appendChild(el); +``` + +Use `polygon.data` to attach `data-*` attributes for CSS selectors and event +delegation. + +Merged polygons lose per-polygon addressing — pass `merge: false` when you need +one leaf per source polygon. diff --git a/packages/skills/skill/docs/lighting.md b/packages/skills/skill/docs/lighting.md new file mode 100644 index 000000000..0fc6d5845 --- /dev/null +++ b/packages/skills/skill/docs/lighting.md @@ -0,0 +1,116 @@ +# Lighting + +The scene takes one `directionalLight`, one `ambientLight`, and zero or more +`pointLights`. + +```ts +interface PolyDirectionalLight { + direction: [number, number, number]; // surface → light source + color?: string; // default "#ffffff" + intensity?: number; // default 1 +} + +interface PolyAmbientLight { + color?: string; // default "#ffffff" + intensity?: number; // default 0.4 +} +``` + +`direction` is the vector from the surface *toward* the light. It is normalized +internally, so it need not be unit length. + +## Point lights + +`pointLights: PolyPointLight[]` are **direction-only** — no distance falloff. +Per polygon the contribution is `color · intensity · max(0, n · L̂)`, where `L̂` +is the unit direction from the surface to the light position. Multiple colored +lights accumulate per-channel alongside the directional and ambient terms. + +They shade **flat per face** (an accepted approximation vs three.js's +per-fragment `PointLight(distance: 0, decay: 0)`; exact for small faces and +distant lights). + +Point lights are **baked mode only**. Dynamic mode ignores them entirely — not +for surface shading and not for shadows. + +## Lighting modes + +Set with `textureLighting`. + +### `"baked"` (default) + +Lambert (directional + each point light + ambient) is computed once on the CPU +per polygon and multiplied into the inline `color` for solid leaves, or into the +rasterised atlas pixels for textured leaves. + +Best fidelity; the only mode that supports point lights; the Three.js parity +baseline. + +**Moving a light requires a rebake**, and the two renderer families differ: + +- **Vanilla does not auto-rebake** on `setOptions({ directionalLight })`. Call + `mesh.rebakeAtlas()` explicitly, typically debounced to drag-end. This is + deliberate: it keeps high-frequency light drags fast. +- **React/Vue re-render and do auto-rebake** on any light prop change. +- **Exception:** a vanilla `setOptions({ pointLights })` change *does* re-render + every mesh. The freeze applies to the directional light only. + +Cast shadows are cheap (CPU-projected SVG) and re-emit automatically on any +light change in both renderer families, so a scene can have a live shadow over a +frozen baked surface until you rebake. + +### `"dynamic"` + +The scene root carries the directional + ambient setup as custom properties +(`--plx/y/z`, `--plr/g/b`, `--pli`, `--par/g/b`, `--pai`). Each leaf embeds its +surface normal (`--pnx/y/z`) and base color (`--psr/g/b`) inline. CSS `calc()` +resolves the Lambert dot product and per-channel tint at paint time. + +Moving a light is a handful of CSS variable writes on one ancestor — zero JS, no +atlas redraw. + +Trade-offs: no point lights at all, and cast shadows are directional-only. + +### Choosing + +- Live or animated lights → `"dynamic"`. +- Point lights, maximum fidelity, Three.js parity → `"baked"`. + +`mountPolyThreeScene(...)` defaults to `"baked"` because baked Lambert is the +Three-parity baseline. + +## Per-mesh override + +React/Vue expose `textureLighting` as a `` prop. Vanilla meshes +inherit the scene value. + +## Example + +```ts +const scene = createPolyScene(host, { + camera, + textureLighting: "dynamic", + directionalLight: { direction: [0.5, -0.6, 0.7], color: "#ffe4a8", intensity: 1 }, + ambientLight: { color: "#ffffff", intensity: 0.35 }, +}); + +// Dynamic mode: free. +scene.setOptions({ directionalLight: { direction: [-0.3, -0.8, 0.5] } }); +``` + +```ts +// Baked mode: cheap during the drag, rebake once at the end. +onDrag((direction) => scene.setOptions({ directionalLight: { direction } })); +onDragEnd(() => meshHandles.forEach((m) => m.rebakeAtlas())); +``` + +```tsx + +``` + +Point lights only take effect in the default `"baked"` mode — the snippet above +would silently ignore them under `textureLighting="dynamic"`. diff --git a/packages/skills/skill/docs/loading-models.md b/packages/skills/skill/docs/loading-models.md new file mode 100644 index 000000000..8261b8d52 --- /dev/null +++ b/packages/skills/skill/docs/loading-models.md @@ -0,0 +1,145 @@ +# Loading Models + +## Supported formats + +| Format | Extension | Notes | +|---|---|---| +| OBJ + MTL | `.obj` + `.mtl` | UV maps via `vt`, textures from `map_Kd`. | +| STL | `.stl` | ASCII or binary triangle mesh; binary Magics face colors. No standard units, textures, UVs, or hierarchy. | +| glTF | `.gltf` | Embedded or external buffers, `TEXCOORD_0` UVs. | +| GLB | `.glb` | Binary glTF; embedded textures extracted as blob URLs. | +| MagicaVoxel | `.vox` | Exposed faces become colored quads; eligible baked-mode meshes use a direct-voxel fast path. | + +## Declarative + +```html + +``` + +```tsx + +``` + +Fetches, parses, and mounts one leaf per visible polygon inside a +`.polycss-mesh` wrapper. Disposal is automatic on unmount or `src` change. + +React/Vue add `fallback` (`#fallback` slot) and `errorFallback` (`#error` slot). + +## Imperative + +```ts +import { createPolyCamera, createPolyScene, loadMesh } from "@layoutit/polycss"; + +const result = await loadMesh("/cottage.obj", { + mtlUrl: "/cottage.mtl", + objOptions: { targetSize: 30 }, +}); +const scene = createPolyScene(host, { camera: createPolyCamera({ rotX: 65, rotY: 45 }) }); +scene.add(result); + +// later +scene.destroy(); // removes the scene and disposes registered meshes +``` + +`loadMesh` returns a `ParseResult`: + +```ts +interface ParseResult { + polygons: Polygon[]; + objectUrls: string[]; + warnings: string[]; + dispose: () => void; // revokes blob URLs +} +``` + +React/Vue wrap this as `usePolyMesh(url, opts)` → `{ polygons, loading, error }`, +which disposes on unmount. + +Format-specific parsers are also exported directly: `parseObj`, `parseStl`, +`parseGltf`, `parseVox`. Use them when you need the raw parser output without +`loadMesh`'s optimization pass, then `scene.add(result, { merge: false })`. + +## Parse options + +Nested per format under `loadMesh` / `parseOptions`: + +```tsx + +``` + +- **`targetSize`** (default `60`) scales the model so its longest axis fits that + many world units. It does not decimate geometry. `.vox` snaps to the nearest + integer voxel CSS cell size, so the final size may differ slightly. +- **`materialColors` / `materialTextures`** override by material name without + editing the source file. Available under `objOptions` and `gltfOptions`. For + glTF, an explicit `materialColors` entry **wins over** the color derived from + the file's material. +- **`includeObjects` / `excludeObjects`** filter by object name. +- **`baseUrl`** resolves relative texture paths for OBJ/glTF. +- **`solidTextureSamples`** converts texture-backed faces whose sampled UV + region is effectively one color into solid-color polygons before optimization + — avoids atlas slices for assets that use texture images as color swatches. +- **`paletteMergeDistance` / `colorRegionMergeDistance`** (`.vox`) fold nearby + opaque, hue-compatible colors before greedy meshing and clean up small color + islands. **Lossy** — they change authored colors. +- **`meshResolution`** (`"lossy"` default / `"lossless"`) is the optimizer + intent. On `` the top-level `meshResolution` prop wins over + `parseOptions.meshResolution`. + +## Optimization on load + +`loadMesh` optimizes at parse time and `scene.add` optimizes again at render +time. `merge: false` only affects the second pass — it cannot restore source +geometry. See [authoring-polygons.md](authoring-polygons.md) §4 for the exact +thresholds and the STL exception. + +## Per-polygon control over a loaded mesh + +```tsx +// React render prop + + {(polygon, index) => ( + setSelected(index)} /> + )} + +``` + +```vue + + + + +``` + +```ts +// Vanilla: one mesh handle per polygon +const result = await loadMesh("/character.glb"); +const handles = result.polygons.map((polygon, i) => + scene.add( + { polygons: [polygon], objectUrls: [], warnings: [], dispose: () => {} }, + { id: `polygon-${i}`, merge: false }, + ), +); +``` + +Merged polygons lose per-polygon DOM addressing, which is why `merge: false` +matters here. + +## Blob URL lifecycle + +Embedded textures and generated atlas pages are blob URLs, revoked on +`dispose()`. Never hold references to them across remounts. The mesh element, +``, and `usePolyMesh` handle this for you; imperative callers must +call `dispose()` (or `scene.destroy()` for registered meshes). diff --git a/packages/skills/skill/docs/performance.md b/packages/skills/skill/docs/performance.md new file mode 100644 index 000000000..33aa7709b --- /dev/null +++ b/packages/skills/skill/docs/performance.md @@ -0,0 +1,107 @@ +# Performance + +Performance scales with **mounted leaf count** and **atlas area**. Every visible +polygon is one DOM element with a CSS transform. + +Measured on a 10k-triangle mesh with autorotate over 7 s: scripting ~579 ms +(mostly React re-renders), rendering (style recalc + layout) ~2130 ms. Rendering +dominates — reducing polygon count beats optimising JS. + +## The no-JS-in-the-render-loop principle + +| JS runs here | JS does NOT run here | +|---|---| +| Scene construction, mesh ops, vertex snapping | Per-frame polygon paint | +| Model import, mesh optimisation, coplanar merging | Per-frame Lambert (dynamic mode is pure CSS) | +| Atlas planning + rasterisation (one-shot) | Per-frame atlas redraw | +| Control input handling | Per-frame transform recompute of every polygon | +| Camera math → one scene-root CSS variable | Per-polygon JS in any hot path | +| Hover/selection raycasting (pointer events only) | Continuous renderer "ticks" | + +If you want a `requestAnimationFrame` loop that updates many renderer DOM nodes, +stop and find the CSS variable that should carry the change instead. The only +sanctioned exception is skeletal animation ([animation.md](animation.md)). + +## Render strategies + +The renderer picks the cheapest CSS primitive that can represent each polygon, +then places it with `matrix3d(...)`. Ordered cheapest → most expensive: + +| Leaf | Chosen for | Atlas memory | +|---|---|---| +| `` | Axis-aligned rectangles and stable quads. `background: currentColor`. | none | +| `` | Solid triangles and exact beveled-corner solids (`corner-shape`), with a border-width triangle fallback. | none | +| `` | Other solid clipped polygons, via `border-shape: polygon(...)`. | none | +| `` | Textured polygons **and** the universal fallback. | bounding-rect area | + +These are internal tags, not public API — never document or depend on them in +app code, but do understand that the mesher's job is to maximise ``/``/`` +and minimise ``. + +Fall-through when a strategy is unsupported or disabled: `b → i → s`, +`u → i → s`, `i → s`. `` cannot be disabled. + +`strategies={{ disable: ["b", "i", "u"] }}` forces atlas rendering — a +diagnostic for comparing output or isolating a browser compositor bug, not a +production setting. + +## Diagnostics + +- `collectPolyRenderStats(root)` — mounted leaf mix by strategy. +- `collectPolyTextureReadiness(root)` — whether atlas bitmaps have decoded. +- `queryPolyLeaves(root)` — the leaf elements themselves. + +## Automatic mesh optimization + +`meshResolution: "lossy"` (default) bakes solid texture swatches, merges +visually redundant swatch colors, tries static triangle simplification for +eligible non-animated imports, merges compatible polygons, and can use bounded +geometric approximation when that lowers estimated DOM render cost. Wider lossy +candidates are gated by whole-mesh seam diagnostics and a minimum render-cost +win. + +`"lossless"` keeps exact planar candidates only; dedupe and interior culling +still run. + +Best on architectural meshes with large flat surfaces — walls, floors, ceilings, +voxel faces. + +Limitations: per-polygon DOM addressing is lost inside a merged region, and +UV-textured polygons only merge when texture mapping can be preserved. + +## Levers, in order of impact + +1. **Fewer polygons.** Lower-poly source assets, or let the lossy optimizer + work (don't reflexively pass `merge: false`). +2. **Fewer textured polygons.** `solidTextureSamples` converts uniform texture + swatches into solid colors, skipping atlas slices entirely. +3. **Lower `textureQuality`.** `0.5` costs about a quarter of the bitmap memory + of `1`. +4. **Cheaper shadows.** `shadow.parametric` with a modest `definition`; avoid + self-shadow on complex meshes; use `dragDefinition` (vanilla) or lower + `definition` in state during interaction. +5. **Dynamic lighting** if lights move — it removes the atlas rebake entirely. + +`targetSize` does **not** reduce polygon count. It only sets world-space scale +(and therefore atlas footprint size). + +## Voxel fast paths + +Voxel-shaped meshes are the exception to "all polygons stay mounted": a mesh +with at most the six axis-aligned face normals mounts only camera-facing leaves +and patches the mounted set when the camera or mesh rotation crosses a +visible-normal boundary. Non-voxel meshes keep the full leaf DOM mounted — +broad camera-dependent culling is not worth the mutation cost. + +Raw `.vox` sources additionally get a direct-voxel fast path: eligible +baked-mode meshes in vanilla, React, and Vue render visible voxel quads directly +as `` leaves inside persistent signed-face wrappers. + +Falls back to the polygon renderer for: dynamic lighting, shadows, stable-DOM +animation, non-exact voxel geometry, and geometry replaced via `setPolygons()`. + +## Atlas lifecycle + +Textured meshes do a one-time atlas pass at mount; very large texture footprints +still cost memory and startup time. Blob URLs are revoked on `dispose()` / +unmount. Don't hold references across remounts. diff --git a/packages/skills/skill/docs/scenes-and-cameras.md b/packages/skills/skill/docs/scenes-and-cameras.md new file mode 100644 index 000000000..c43af97fd --- /dev/null +++ b/packages/skills/skill/docs/scenes-and-cameras.md @@ -0,0 +1,173 @@ +# Scenes and Cameras + +## Structure + +The camera is the **outer** node and the scene nests inside it. This is not a +style choice: CSS `perspective` only applies to descendants, so the scene's +`transform: matrix3d(...)` must be a child of the element carrying the +projection. + +- React/Vue: `PolyScene` **throws** outside a camera component. +- Vanilla: `createPolyScene(host, opts)` takes a **required** `camera` handle. +- `` is the one exception: with no ancestor camera element it builds + an *implicit* camera from its own `perspective`, `rot-x`, `rot-y`, `zoom`, + `distance`, and `target` attributes. + +## Coordinates + +World space is `[x, y, z]` with **+Z up**. World Y maps to CSS X (screen-right +at identity rotation) and world X to CSS Y (screen-down). The default camera +(`rotX: 65, rotY: 45`) presents that as an isometric view. + +`BASE_TILE` is `50` — the world-unit → CSS pixel factor you need when converting +world units to raw CSS pixels yourself. + +## Cameras + +`PolyCamera` is an alias for `PolyOrthographicCamera` — identical, and the +**default**. This deliberately diverges from three.js, because PolyCSS's +strengths (integer-pixel atlas, no per-frame JS, DOM stacking) show best in +orthographic scenes. Use `PolyPerspectiveCamera` when you need depth +foreshortening (first-person, game-like). + +| Prop | Type | Default | Meaning | +|---|---|---|---| +| `zoom` | `number` | `0.65` | On-screen CSS pixels per world unit. Higher zooms in. Orbit controls clamp to `0.1`–`10` (`minZoom` / `maxZoom`). | +| `rotX` | `number` | `65` | Rotation around X in degrees. | +| `rotY` | `number` | `45` | Rotation around Y in degrees (0–360). | +| `distance` | `number` | `0` | Dolly pull-back in pixels; adds `translateZ(-distance)px`. Equivalent to increasing the orbit radius in three.js. Driven by `dolly` mode on orbit controls. | +| `target` | `Vec3` | `[0,0,0]` | Point in scene space the camera orbits. | +| `perspective` | `number` | `32000` | **`PolyPerspectiveCamera` only.** CSS perspective depth in px. Higher is flatter. | + +`zoom` scales; `distance` moves the viewpoint back along the view axis. They are +not interchangeable. + +## Scene options + +Set on `createPolyScene(host, opts)`, `` props, or `` +attributes (kebab-case). + +| Option | Type | Default | Notes | +|---|---|---|---| +| `camera` | camera handle | — | Vanilla only, required. | +| `directionalLight` | `PolyDirectionalLight` | none | See [lighting.md](lighting.md). | +| `pointLights` | `PolyPointLight[]` | none | Baked mode only. | +| `ambientLight` | `PolyAmbientLight` | none | | +| `textureLighting` | `"baked" \| "dynamic"` | `"baked"` | | +| `textureQuality` | `number \| "auto"` | `"auto"` | Atlas bitmap budget + sprite size. | +| `textureLeafSizing` | `"canonical" \| "local" \| "raster"` | `"canonical"` | Scene/atlas-wide; **no per-polygon override**. | +| `textureImageRendering` | `"auto" \| "pixelated"` | `"auto"` | | +| `textureBackend` | `"auto" \| "atlas" \| "image"` | `"auto"` | `"auto"` always resolves to the atlas today; direct image leaves need explicit `"image"`. | +| `textureProjection` | `"affine" \| "projective"` | `"affine"` | | +| `seamBleed` | `number \| "auto"` | `1.5` | Overscan on shared solid seams. **Semantics differ by renderer** — see below. | +| `strategies` | `{ disable?: ("b"\|"i"\|"u")[] }` | none | Diagnostics. `` cannot be disabled. | +| `autoCenter` | `boolean` | `false` | Rotate around content bbox center instead of world origin. Polygon data is not mutated. Meshes opt out with `excludeFromAutoCenter`. | +| `centerPolygons` | `Polygon[]` | none | **Framework only.** bbox source for `autoCenter` when polygons live in child meshes. | +| `shadow` | object | see [shadows.md](shadows.md) | | +| `polygons` | `Polygon[]` | none | **Framework only.** Composes with children. Note: this is the only path that runs `normalizePolygons`. | + +`seamBleed` caveat: only the numeric default `1.5` behaves identically across +renderers. Vanilla clamps a number to `0..1` and multiplies the `1.5` px +default; React/Vue pass the raw number through. `"auto"` resolves to the full +`1.5` px in vanilla but produces **no** shared-edge overscan in React/Vue. +Prefer leaving it alone. + +## Mesh transforms + +`scene.add(result, transform)` / `` props: + +| Option | Type | Notes | +|---|---|---| +| `id` | `string` | Reflected as `data-poly-mesh-id`; used by selection and gizmos. | +| `position` | `Vec3` | Offset in scene space. | +| `scale` | `number \| Vec3` | | +| `rotation` | `Vec3` | Euler **degrees** `[x, y, z]`. | +| `autoCenter` | `boolean` | Shifts the mesh so its bbox center sits at the local origin before `position`. | +| `castShadow` / `receiveShadow` | `boolean` | See [shadows.md](shadows.md). | +| `merge` | `boolean` | Default `true`. `false` renders the array entering the renderer exactly as given. | +| `meshResolution` | `"lossy" \| "lossless"` | Default `"lossy"`. | +| `stableDom` | `boolean` | Vanilla only; needed for skeletal animation. | +| `shadowDefinition` | `number` | Per-mesh parametric shadow detail. | +| `excludeFromAutoCenter` | `boolean` | | + +React/Vue additionally expose per-mesh `textureLighting`, `textureQuality`, +`textureLeafSizing`, `textureImageRendering`, `textureBackend`, +`textureProjection`, `seamBleed`, `atomicAtlas`, and `onFrameReady`. Vanilla +meshes inherit the scene values for those. + +## Custom element caveats + +`` supports `directional-*`, `ambient-*`, `texture-lighting`, +`texture-quality`, `texture-leaf-sizing`, `texture-image-rendering`, +`texture-backend`, `texture-projection`, `auto-center`, and the implicit-camera +attributes. Only `perspective`, `rot-x`, `rot-y`, and `zoom` are *observed* — +mutating `distance` or `target` alone does not update the implicit camera. +`perspective` only selects the camera type at connect time. Use the imperative +API for `shadow`, `seamBleed`, and `strategies`. + +`` supports `src`, `mtl`, `mesh-resolution`, `position`, `scale`, +`rotation`, `auto-center`, `cast-shadow`, `receive-shadow`, plus the OBJ-only +parse attributes `target-size`, `default-color`, `palette`, `include-objects`, +`exclude-objects`. `position`, `scale`, `rotation`, `cast-shadow`, and +`receive-shadow` update live; changing `src`, `mtl`, `mesh-resolution`, or an +OBJ parse attribute tears the mesh down and reloads it; `auto-center` is read at +load only. There is **no** `polygons` attribute — use `` or the +imperative API. + +Note `mesh-resolution` threads into the **parse** only; the element's own +`scene.add` call always renders at the default resolution. Use the imperative +API when you need to control both passes. + +## Lifecycle + +```ts +const camera = createPolyCamera({ rotX: 65, rotY: 45 }); +const scene = createPolyScene(host, { camera }); +const result = await loadMesh("/model.glb"); +const handle = scene.add(result, { position: [0, 0, 10] }); + +handle.remove(); +scene.destroy(); // removes the scene and disposes registered meshes +result.dispose(); // revokes blob URLs if you kept the result yourself +``` + +`` / `` / `usePolyMesh` dispose automatically. + +## Helpers + +| Helper | Props | +|---|---| +| `` / `PolyAxesHelper` | `size`, `thickness`, `negative`, `xColor`, `yColor`, `zColor` | +| `` / `PolyDirectionalLightHelper` | React/Vue: `light`, `target`, `distance`, `size`, `color`. Vanilla: `direction`, `target`, `distance`, `size`, `color`. | + +## `` / `` + +A live document rendered as a flat quad in the scene, using the same +`position` / `rotation` / `scale` conventions as a mesh; content is centered on +the wrapper's local origin so rotation and scale pivot at the visible center. + +`width` and `height` are **world units**, not pixels — the mounted document is +`width × 50` by `height × 50` CSS px, so `16 × 9` yields an 800 × 450 px page. + +```html + + + +``` + +## Snapshot export + +`exportPolySceneSnapshot(target)` lives in `@layoutit/polycss` only (it is +browser DOM serialization, not component API). React/Vue callers import it from +there and pass the rendered `.polycss-camera` / `.polycss-scene` element. + +```ts +import { exportPolySceneSnapshot } from "@layoutit/polycss"; +const html = await exportPolySceneSnapshot(scene.host); +``` + +It clones the rendered DOM, injects only the CSS that snapshot needs, inlines +`url(...)` images as data URIs, strips scripts and inline handlers, and returns +a standalone HTML document string with no PolyCSS runtime import. Throws +`PolySceneSnapshotError` with `code: "ASSET_INLINE_FAILED"` if an asset cannot +be inlined. diff --git a/packages/skills/skill/docs/shadows.md b/packages/skills/skill/docs/shadows.md new file mode 100644 index 000000000..881cb9a0d --- /dev/null +++ b/packages/skills/skill/docs/shadows.md @@ -0,0 +1,128 @@ +# Shadows + +Cast shadows are **CPU-projected SVG surfaces**, not render-strategy leaves. +Casting polygons are projected onto scene-level receiver surfaces and emitted as +``/`` nodes. They work in both lighting modes; dynamic-mode shadows +are directional-only. + +```ts +scene.add(model, { castShadow: true }); +scene.add(floor, { receiveShadow: true }); +scene.setOptions({ + shadow: { color: "#000000", opacity: 0.3, parametric: true, definition: 32 }, +}); +``` + +## Receivers differ by renderer — this is the #1 shadow gotcha + +- **Vanilla has no ground fallback.** A `castShadow` mesh draws *nothing* until + some mesh in the scene has `receiveShadow: true`. This was dropped for + Three.js parity. You must add a receiver: + + ```ts + scene.add(createPolyPlane({ axis: 2, size: 60, offset: 0, color: "#7d848e" }), + { receiveShadow: true }); + ``` + +- **React/Vue additionally project onto a per-mesh ground plane** when a caster + has no receiver, and drop that fallback as soon as any receiver exists. This + is what `` relies on — `PolyGround` has **no** `receiveShadow` + prop. + +Reconciling the two is an open decision; write code that works under both by +adding an explicit receiver. + +## `shadow` options + +| Key | Default | Meaning | +|---|---|---| +| `color` | `"#000000"` | | +| `opacity` | `0.25` | | +| `lift` | `0.05` | Offset above the receiver plane to avoid z-fighting. | +| `maxExtend` | `2000` | SVG extent cap. | +| `parametric` | `false` | Swap exact projection for a cheap low-resolution silhouette. | +| `definition` | `16` | Parametric detail. Higher = sharper + more DOM. | +| `style` | `"vector"` | `"vector"` traces a smooth contour; `"pixel"` greedy-meshes the coverage mask into blocky rectangles. | +| `followAnimation` | `false` | Track an animated caster's pose instead of freezing its shadow. | +| `dragDefinition` | none | **Vanilla only.** Progressive refinement during a light drag. | + +Per-mesh `shadowDefinition` overrides `shadow.definition` for one mesh (vanilla +`PolyMeshTransform` field, React `shadowDefinition` prop, Vue +`shadow-definition` prop). + +## Parametric shadows + +Opt in with `shadow.parametric: true`. Per caster, the light-perpendicular +coverage is rasterised into a mask (resolution scales with `definition`), traced +with marching squares, simplified, and lifted back to 3D. A complex caster then +emits far fewer shadow-path vertices. + +`definition` is the knob for resolving fine concave holes; higher trades DOM +weight for fidelity. `style: "pixel"` makes holes fall out for free as absent +cells and turns `definition` into the pixel-grid resolution (lower = chunkier — +the block size is the aesthetic). + +Point lights are supported: each shadow-casting point light gets its own radial +override silhouette. + +Parametric is an approximation with named correction terms: flat casters route +to the exact path, convex casters skip self-shadow, self-shadow bands are +depth-biased, and coverage holes are emitted with opposite winding so they +subtract. It does not change the exact path, which remains the default. + +## Colored shadows + +Shadows are **shaded, not flat black**. Each light's shadow is filled with the +receiver lit by every *other* light (the blocked light removed), so a region +shadowed from one colored light still shows the remaining lights' color — three.js +colored-shadow semantics. A lone directional light reduces this to the +ambient-only fill. + +All of a receiver face's lights are merged into one SVG per face so overlapping +shadows composite correctly. + +## Point-light shadows + +Each `pointLights` entry with `castShadow: true` casts an additional **radial** +shadow (each vertex projected along its own ray from the light position). Point +shadows are **baked mode only**, like point-light shading. + +## Cost + +- Cross-mesh and floor shadows are cheap: one outline, ~1 receiver face. They + follow a moving light at 60fps+. +- Camera orbit is **free** — shadows ride the scene transform. Only light or + geometry changes re-emit. +- **Self-shadow is the expensive case** (caster = receiver): it projects every + depth band onto every coplanar face of the same mesh. Reduce quality during + motion rather than looking for a faster projector. + +Levers for smooth interaction: + +- Per-mesh `shadowDefinition` — a detailed caster stays sharp while a simple + prop runs cheap in the same scene. +- `shadow.dragDefinition` (vanilla) — emits at `min(definition, dragDefinition)` + while the light *direction* changes, then a debounced pass re-emits at full + `definition` once the light settles. Auto-detected in `setOptions`: a + direction change counts as motion; an appearance edit renders full + immediately. +- React/Vue get the same effect idiomatically: lower `shadow.definition` in your + own state during the drag and restore it at rest. + +## Animated casters + +A caster's shadow **freezes** during a same-topology deform by default — +re-projecting every frame is expensive. `shadow.followAnimation: true` opts into +tracking the pose; pair it with a low parametric `definition`. Topology changes +(different polygon count) always re-emit regardless. + +## Notes + +- Every polygon casts. Casters are *not* filtered to the camera-rendered set — + a polygon casts regardless of whether it is painted for the camera. +- Coincident/back-to-back duplicate faces are pre-dropped. +- Light-back-facing caster polygons are normally culled (correct for clean + closed meshes). Self-shadow casters and unreliable-silhouette cross-mesh + casters cast double-sided so badly-wound interior walls don't leave holes. +- Moving a light or changing geometry re-emits the shadow SVGs. This is DOM/SVG + work only and does **not** redraw texture atlases. diff --git a/packages/skills/skill/docs/shapes-and-primitives.md b/packages/skills/skill/docs/shapes-and-primitives.md new file mode 100644 index 000000000..e5f82985d --- /dev/null +++ b/packages/skills/skill/docs/shapes-and-primitives.md @@ -0,0 +1,198 @@ +# Shapes and Primitives + +Three layers, same geometry: + +| Layer | Form | Returns | +|---|---|---| +| Core generators | `boxPolygons(opts)` | `Polygon[]` | +| Vanilla factories | `createPolyBox(opts)` | `ParseResult` for `scene.add(...)` | +| Components | `` / `` | Mounted mesh | + +Core generators are exported from every package (`@layoutit/polycss`, +`-react`, `-vue`, `-core`). Use them when you want raw arrays to post-process. + +## Options and defaults + +Defaults below are the generator defaults. Colors default to `#cccccc` unless +noted. + +### `boxPolygons` / `createPolyBox` / `PolyBox` + +```ts +{ + size?: number | Vec3; // default 1×1×1 + center?: Vec3; // default origin + min?: Vec3; max?: Vec3; // explicit bounds — win over size/center + // BoxFaceOptions, applied to every face: color | texture | material | uvs | data + color?: string; + texture?: string; + material?: PolyMaterial; + uvs?: [number, number][]; + data?: Record; + // Per-face override. BoxFace = "right" | "left" | "front" | "back" | "top" | "bottom". + faces?: Partial>; // false omits the face +} +``` + +```ts +const polygons = boxPolygons({ + min: [0, 0, 0], + max: [2, 1, 0.5], + color: "#d8d2c7", + data: { tileId: "tile-1" }, + faces: { top: { texture: "/tile.png", data: { face: "top" } }, bottom: false }, +}); +``` + +### `planePolygons` / `createPolyPlane` / `PolyPlane` + +`axis` is **required**. + +```ts +{ + axis: 0 | 1 | 2; // perpendicular axis: 0=YZ, 1=XZ, 2=XY plane + size?: number; // HALF-extent along each in-plane axis, default 0.4 + offset?: number | [number, number]; // in-plane center, default `size * 2` + along?: number; // position along the perpendicular axis, default 0 + color?: string; +} +``` + +Two traps: `size` is a **half-extent**, and `offset` defaults to `size * 2`, so +a plane you expected at the origin lands in the `+A/+B` corner. For a centered +ground plane pass `offset: 0` explicitly: + +```ts +createPolyPlane({ axis: 2, size: 60, offset: 0, color: "#7d848e" }); +``` + +### `spherePolygons` / `createPolySphere` / `PolySphere` + +```ts +{ radius?: number; // default 50 + subdivisions?: number; // default 1 (80 triangles); clamped to 0..3 + color?: string; } +``` + +Subdivision 0 = 20 triangles, each level quadruples: 1 → 80, 2 → 320, 3 → 1280. +The cap at 3 is deliberate — DOM cost. + +### `cylinderPolygons` / `conePolygons` + +```ts +// cylinder +{ radius?: number; // bottom cap, default 50 + radiusTop?: number; // defaults to `radius`; 0 makes a cone + height?: number; // along Z, default 100 + radialSegments?: number; // default 12 + color?: string; } + +// cone === cylinder with radiusTop: 0 +{ radius?: number; height?: number; radialSegments?: number; color?: string; } +``` + +### `torusPolygons` + +```ts +{ radius?: number; // center-to-tube-center, default 50 + tube?: number; // tube radius, default 15 + radialSegments?: number; // around the ring, default 12 + tubularSegments?: number; // around the cross-section, default 16 + color?: string; } +``` + +### `ringPolygons` + +`axis` and `radius` are **required**. + +```ts +{ axis: 0 | 1 | 2; // perpendicular axis + radius: number; // mid-radius of the annulus band + halfThickness?: number; // band spans radius ± halfThickness + segments?: number; + color?: string; } +``` + +### Platonic solids + +`tetrahedronPolygons`, `icosahedronPolygons`, `dodecahedronPolygons`: + +```ts +{ size?: number; // circumradius, default 100 + color?: string; } +``` + +`octahedronPolygons` differs — `center` and `size` are both **required**: + +```ts +{ center: Vec3; size: number; color?: string; } // size = half-extent +``` + +### Other generators + +`axesHelperPolygons`, `arrowPolygons`, `ringQuadPolygons`. + +## Usage + +```ts +// Vanilla +scene.add(createPolyBox({ size: 100, color: "#ffd166" }), { position: [0, 0, 50] }); +scene.add(createPolySphere({ radius: 40, subdivisions: 2, color: "#7dd3fc" })); +scene.add(createPolyTorus({ radius: 60, tube: 18, color: "#4ecdc4" })); +``` + +```tsx +// React / Vue — geometry options plus the common mesh props + + + + + +``` + +```html + + + + + +``` + +Shape components accept their geometry options plus the common mesh props +(`position`, `scale`, `rotation`, `autoCenter`, `id`, and event props where +supported). + +## The single-polygon primitive + +`` (vanilla) and `` (React/Vue) render one polygon as one +leaf. They forward standard DOM props (`onclick`, `class`, `style`, `aria-*`). +Neither normalizes its input — see [authoring-polygons.md](authoring-polygons.md). + +```html + +``` + +```tsx + + +``` + +## Shared materials + +Use `material` when several polygons share one texture identity. React and Vue +export `usePolyMaterial` to keep that object stable across rerenders: + +```tsx +const material = usePolyMaterial({ texture: "/stone.png", key: "stone" }); +; +``` + +`material.texture` wins over a polygon's own `texture`. + +## Text + +`@layoutit/polycss-fonts` turns text into extruded 3D `Polygon[]`: +`textPolygons(font, text, { depth, profile })` for basic extrusion, +`composeText(...)` for the multi-line/warp composer, plus `loadGoogleFont` and +`listGoogleFonts`. Framework-agnostic — feed the result to `scene.add(...)` or +``. diff --git a/packages/skills/skill/docs/textures.md b/packages/skills/skill/docs/textures.md new file mode 100644 index 000000000..6415e3aba --- /dev/null +++ b/packages/skills/skill/docs/textures.md @@ -0,0 +1,100 @@ +# Textures + +A polygon is textured when it has `texture` (or `material.texture`) plus `uvs`, +one UV pair per vertex. `material.texture` wins over `texture`. + +## The atlas pipeline + +Rasterisation happens **once**, at mount, not per frame: + +1. Extract or fetch the texture image. +2. Solve a 6-DOF affine transform from the polygon's UVs to its 2D footprint. +3. Pack polygon footprints into one or more atlas pages. +4. Clip, draw texture pixels or shaded color fills, and export pages to blob + URLs via `canvas.toBlob()`. +5. Repair antialiased pixels along shared textured edges, then render each + polygon as an `` leaf with `background-image` / `-size` / `-position`. + +Atlas blob URLs are revoked on unmount or `dispose()`. + +Flat-color polygons bypass the atlas entirely when they can render as CSS +solids or `border-shape` polygons — that is the cheap path, and the mesher +should aim for it. + +## Fill ratio + +A textured polygon's atlas slice equals its **local-2D bounding rect**. Empty +space inside that rect is wasted bitmap memory. + +- axis-aligned rectangle → 1.0 (and the fastest path) +- right-isosceles triangle → 0.5 +- skinny/long triangle → ≪ 0.5, the worst case + +Many skinny textured triangles balloon atlas memory. This is the main reason to +prefer rectangle-friendly meshing. + +## `textureQuality` + +Default `"auto"`. Auto starts from the packed atlas area, caps oversized runtime +bitmaps by page side length and decoded-memory budget, and chooses the fixed CSS +sprite size used by atlas leaves: **128px** for desktop-class auto (avoids +Safari/Firefox compositor flattening artifacts), **64px** for mobile-class auto +and for explicit numeric quality. + +Numeric values override the raster scale: `0.5` uses about a quarter of the +atlas bitmap memory of `1`. Use `0.5`–`0.75` for distant or dense assets, `1` +for close-up inspection. Numeric quality keeps the 64px sprite size. + +```html + +``` + +```tsx + + {/* React/Vue per-mesh */} +``` + +## Texture presentation options + +Scene defaults, overridable per mesh in React/Vue: + +| Option | Values | Default | Meaning | +|---|---|---|---| +| `textureBackend` | `"auto" \| "atlas" \| "image"` | `"auto"` | `"auto"` **always resolves to the atlas today.** Direct image leaves require an explicit `"image"`. | +| `textureImageRendering` | `"auto" \| "pixelated"` | `"auto"` | CSS image filtering. Use `"pixelated"` for pixel-art textures. | +| `textureProjection` | `"affine" \| "projective"` | `"affine"` | Projection request for textured quads. | +| `textureLeafSizing` | `"canonical" \| "local" \| "raster"` | `"canonical"` | Leaf CSS primitive sizing. **Scene/atlas-wide — no per-polygon override.** | + +Precedence for a given polygon: scene defaults → `material.presentation` → +source `imageRendering` → `polygon.texturePresentation`. + +Direct image leaves (`backend: "image"` with `textureImageSource`) skip atlas +rasterisation and use the caller's source URL and source rect directly. They +**preserve source lighting** (`texturePresentation.lighting = "source"`) — a +scene-lit direct image falls back to the atlas path. Use them for source-exact +surfaces; use the atlas when you want scene lighting. + +Atlas position/size, image position/size, filtering, readiness, projection, and +source rect are exposed as PolyCSS-owned metadata. Read them with +`resolvePolyTextureLeafGeometry`, `resolvePolyTextureImageSource`, +`resolvePolyTexturePresentation`, and `resolvePolyTextureImageRendering` rather +than parsing style strings. + +## Seams + +Shared textured edges are repaired automatically during atlas generation: +geometry is unchanged, only low-alpha atlas pixels at shared edges are filled +from nearby opaque texels. + +Solid (untextured) shared edges use `seamBleed` instead — see +[scenes-and-cameras.md](scenes-and-cameras.md) for the renderer divergence. The +numeric default `1.5` is the only value that behaves identically everywhere. + +## Readiness + +`collectPolyTextureReadiness(root)` reports whether atlas bitmaps have decoded. +Textured scenes need real settle time before a screenshot — an atlas that has +not loaded yet looks like a rendering regression but is not one. + +React/Vue `atomicAtlas` holds the previous atlas frame until the next is +decoded, then swaps atomically; `onFrameReady` fires on that swap. diff --git a/packages/skills/skill/docs/three-parity.md b/packages/skills/skill/docs/three-parity.md new file mode 100644 index 000000000..88b34ddfe --- /dev/null +++ b/packages/skills/skill/docs/three-parity.md @@ -0,0 +1,126 @@ +# Three.js Parity + +Use the explicit `*/three` subpaths when porting a Three.js scene or generating +code from Three-shaped examples. They are **adapters over PolyCSS**, not a +Three.js runtime dependency — `three` is not installed. + +| Subpath | Contents | +|---|---| +| `@layoutit/polycss-core/three` | Pure math wrappers, camera conversion, lights, transforms. | +| `@layoutit/polycss/three` | The core surface plus vanilla scene helpers (`mountPolyThreeScene`). | +| `@layoutit/polycss-react/three` | `PolyThreePerspectiveCamera`, `PolyThreeOrthographicCamera`, `PolyThreeMesh`. | +| `@layoutit/polycss-vue/three` | Same three components. | + +Three-compatible names are the point here, so these subpaths deliberately break +the `Poly` prefix rule — except the React/Vue components, which keep a +`PolyThree` prefix. + +## Conventions inside the parity surface + +- Coordinates are **Y-up** Three authoring space. +- Object rotations are **radians**, XYZ Euler. +- Cameras are `PerspectiveCamera(fov, aspect, near, far)` or + `OrthographicCamera(left, right, top, bottom, near, far)`. +- Frame with `camera.position.set(...)` and `camera.lookAt(...)`. +- Directional lights use the Three source vector, + `light.target.position` → `light.position`. +- Geometry converts to native PolyCSS coordinates with + `transformPolygonsToPoly`; the Y-up → Z-up axis map is `[x, -z, y]`, so + winding and Lambert lighting stay right-handed. + +Do **not** mix conventions. Inside a parity scene, keep radians and Y-up; the +adapter handles the conversion once. + +## Lighting mode + +`mountPolyThreeScene(...)` defaults `textureLighting` to `"baked"` because baked +Lambert is the Three-parity baseline. Dynamic lighting remains available as an +explicit opt-in for live CSS light changes, but it is not the exact conformance +mode. + +## Imports + +```ts +import { + PerspectiveCamera, + OrthographicCamera, + Object3D, + Vector3, + Euler, + DirectionalLight, + PointLight, + AmbientLight, + transformPolygonsToPoly, + mountPolyThreeScene, +} from "@layoutit/polycss/three"; +``` + +```tsx +import { + PolyThreePerspectiveCamera, + PolyThreeOrthographicCamera, + PolyThreeMesh, + DirectionalLight, +} from "@layoutit/polycss-react/three"; // or "@layoutit/polycss-vue/three" +``` + +## Vanilla example + +```ts +const camera = new PerspectiveCamera(50, 16 / 9, 0.1, 100); +camera.position.set(3, 2, 5); +camera.lookAt(0, 0, 0); + +const object = new Object3D(); +object.rotation.set(0, Math.PI / 4, 0); + +mountPolyThreeScene(document.querySelector("#scene")!, { + camera, + cameraOptions: { viewportHeight: 420 }, + polygons: transformPolygonsToPoly( + boxPolygons({ size: 1, color: "#66aaff" }), + object, + ), +}); +``` + +## React example + +The parity camera wraps a normal `PolyScene`; lights convert with +`toPolyDirectionalLight()`. + +```tsx +import { PolyScene } from "@layoutit/polycss-react"; +import { + DirectionalLight, + PolyThreeMesh, + PolyThreePerspectiveCamera, +} from "@layoutit/polycss-react/three"; + +const sun = new DirectionalLight("#ffffff", 1); +sun.position.set(3, 5, 4); +sun.target.position.set(0, 0, 0); + +export function App() { + return ( + + + + + + ); +} +``` + +## What does not carry over + +The parity surface covers cameras, transforms, lights, and geometry conversion. +It is not a Three.js runtime: materials, shaders, post-processing, raycasting +semantics, and scene-graph traversal are PolyCSS's, not Three's. When a Three +feature has no PolyCSS equivalent, express the intent in native PolyCSS terms +rather than reaching for a missing shim. + +Full reference: https://polycss.com/api/three-parity diff --git a/packages/skills/skill/docs/troubleshooting.md b/packages/skills/skill/docs/troubleshooting.md new file mode 100644 index 000000000..cb050886e --- /dev/null +++ b/packages/skills/skill/docs/troubleshooting.md @@ -0,0 +1,77 @@ +# Troubleshooting + +Most PolyCSS failures are **silent**. Nothing throws, nothing logs, the geometry +is simply wrong or absent. Work the symptom table before adding instrumentation. + +## Symptom → cause + +| Symptom | Most likely cause | +|---|---| +| A face is missing from the viewpoint it was built for, but shows from behind | **Winding.** Reverse the vertex order (and `uvs` with it). | +| A face disappears when the camera moves behind it | Correct behavior. Backface culling is per-leaf and there is no double-sided flag. Emit two polygons if you need both sides. | +| Everything renders **white** | `color` is not `#rgb` / `#rrggbb` / `rgb()` / `rgba()`. Named colors and `hsl()` fail silently. | +| Everything renders `#cccccc` | Same cause, but on the `` path, which substitutes a fallback color. | +| A polygon you authored is simply absent from the DOM | Degenerate: <3 vertices, zero area, or a duplicate first edge. | +| Cracks or gaps between neighbouring quads | A non-coplanar n-gon was flattened onto its average plane. Use triangles, or snap shared vertices and propagate. | +| Geometry looks different from what you passed in | The optimizer ran (`merge` defaults `true`, `meshResolution` `"lossy"`). Pass `{ merge: false }`. | +| `merge: false` still changes the mesh | `loadMesh` already optimized at parse time. Call `parseObj`/`parseStl`/`parseGltf`/`parseVox` directly, then add with `merge: false`. | +| **No shadow appears at all** in vanilla | Vanilla has no ground fallback. Add a mesh with `receiveShadow: true`. | +| Shadow works in React but not vanilla | Same cause — React/Vue have the ground-plane fallback, vanilla does not. | +| Shadow vanished when you added a floor in React/Vue | Adding any receiver disables the ground fallback. Set `receiveShadow` on that floor. | +| Point lights do nothing | `textureLighting: "dynamic"` ignores `pointLights` entirely — shading and shadows. Switch to `"baked"`. | +| Moving the light doesn't change the surface (vanilla) | Baked mode does not auto-rebake on a `directionalLight` change. Call `mesh.rebakeAtlas()`, typically at drag-end. Shadows still move; the lit surface does not. | +| Shadows move but the surface stays lit the old way | Same as above — expected, and the reason the escape hatch exists. | +| Shadow doesn't follow an animated mesh | Shadows freeze during a same-topology deform. Set `shadow.followAnimation: true` and lower `definition`. | +| A texture renders blurry when it should be crisp | Set `textureImageRendering: "pixelated"`. | +| Textures look missing right after mount | The atlas is still decoding. Check `collectPolyTextureReadiness(root)`; textured scenes need real settle time before a screenshot. | +| `textureBackend: "auto"` didn't give a direct image leaf | `"auto"` always resolves to the atlas today. Request `"image"` explicitly. | +| A direct image leaf ignores scene lighting | By design — direct image leaves are source-lit. Use the atlas backend for scene lighting. | +| `PolyScene` throws | It must be nested inside a camera component. | +| The scene is invisible / flat / wrong scale | Check camera nesting (camera outer, scene inner), then `zoom` (CSS px per world unit, default `0.65`) and `targetSize`. | +| `` or `target` changes do nothing | Only `perspective`, `rot-x`, `rot-y`, `zoom` are observed on the implicit camera. Change one of those, or use the imperative API. | +| `` change does nothing | Read at load only. | +| Per-polygon click handlers stopped firing after a change | Polygons got merged into one leaf. Use `merge: false`. | +| Animation plays but the mesh flickers or re-mounts | Topology is not stable. Vanilla needs `{ merge: false, stableDom: true }`; React/Vue need `meshResolution="lossless"` / `merge={false}`. | +| Animated colors "pump" between frames | Expected — color is pinned to the baked value during animation on purpose. | +| Frame rate collapses on orbit | Too many mounted leaves. Reduce polygon count first; camera motion itself is a single-ancestor transform and should be free. | +| Frame rate collapses when dragging a light | Self-shadow recompute. Use `shadow.parametric` with a lower `definition`, `dragDefinition` (vanilla), or lower `definition` in state during the drag. | +| Blob URL / broken texture after a remount | A revoked atlas URL was held across remounts. Never cache them; let `dispose()` run. | +| Import fails to resolve in a React/Vue app | Do not import `@layoutit/polycss` there. Core names come from the framework package; anything missing comes from `@layoutit/polycss-core`. | +| `exportPolySceneSnapshot` not found in React/Vue | It only exists in `@layoutit/polycss`. Import it there and pass the rendered element. | +| `PolySceneSnapshotError` with `ASSET_INLINE_FAILED` | An asset referenced by the snapshot could not be inlined (usually CORS). | + +## Debug checklist + +1. **Is the leaf in the DOM?** Inspect with devtools or `queryPolyLeaves(root)`. + Absent → degenerate polygon or culling. Present but invisible → winding, + color, or transform. +2. **What strategy did it get?** `collectPolyRenderStats(root)`. An unexpected + `` count means solid polygons are falling through — check + `strategies.disable` and browser support. +3. **Does it render with `strategies={{ disable: ["b","i","u"] }}`?** If yes, the + bug is in a solid strategy or its browser support, not your geometry. +4. **Does it render with `merge: false`?** If yes, the optimizer changed it. +5. **Does it render with a flat ambient light only?** If yes, the problem is a + normal — which means winding. + +## Browser-specific behaviour + +Some strategies are engine-gated, so the leaf mix legitimately differs across +browsers: + +- Projective quads and border triangles fall through to `` on + WebKit/Safari — transformed projective rectangles and CSS border triangles + composite incorrectly there. +- `` (`border-shape`) requires Chromium with a fine pointer and hover. +- Firefox uses a larger border-triangle primitive to avoid compositor banding. + +A leaf-count difference between Chrome and Safari is expected. A *visual* +difference is not. + +## Things that are not bugs + +- Backface culling hiding a single-sided face from behind. +- Vanilla's baked surface not updating on a light drag. +- Vanilla drawing no shadow without a `receiveShadow` mesh. +- Voxel meshes mounting only camera-facing leaves. +- Merged regions losing per-polygon DOM addressing. diff --git a/packages/skills/src/install.mjs b/packages/skills/src/install.mjs new file mode 100644 index 000000000..b42632816 --- /dev/null +++ b/packages/skills/src/install.mjs @@ -0,0 +1,249 @@ +/** + * Installs the bundled PolyCSS skill into an agent's skills directory. + * + * The package ships `skill/SKILL.md` plus `skill/docs/*.md`. Installing copies + * that tree verbatim into `//polycss/` and records a + * manifest of content hashes alongside it, so a later upgrade can tell an + * untouched install (safe to overwrite) from one the user has edited (refuse + * unless `--force`). Files the user adds themselves are never touched. + * + * Zero runtime dependencies: this runs under `npx` in someone else's project. + */ +import { createHash } from "node:crypto"; +import { + mkdirSync, + readFileSync, + readdirSync, + rmSync, + statSync, + writeFileSync, +} from "node:fs"; +import { homedir } from "node:os"; +import { dirname, join, posix, resolve, sep } from "node:path"; +import { fileURLToPath } from "node:url"; + +export const SKILL_NAME = "polycss"; +export const MANIFEST_FILE = ".polycss-skill.json"; + +/** + * Where each agent runtime looks for project-local skills. `root` is the + * marker directory used for auto-detection — a project that already has + * `.claude/` is a Claude Code project even if it has no skills yet. + */ +export const AGENTS = { + claude: { label: "Claude Code", root: ".claude", skills: ".claude/skills" }, + codex: { label: "Codex", root: ".agents", skills: ".agents/skills" }, +}; + +export const DEFAULT_AGENT = "claude"; + +/** Root of the shipped skill tree (`packages/skills/skill`). */ +export function bundledSkillDir() { + return resolve(dirname(fileURLToPath(import.meta.url)), "..", "skill"); +} + +const sha256 = (buffer) => createHash("sha256").update(buffer).digest("hex"); + +/** + * Every file under `dir`, as POSIX-style relative paths, sorted. POSIX + * separators keep the manifest byte-identical across Windows and Unix installs. + */ +export function listFiles(dir, prefix = "") { + const out = []; + let entries; + try { + entries = readdirSync(dir, { withFileTypes: true }); + } catch { + return out; + } + for (const entry of entries.sort((a, b) => (a.name < b.name ? -1 : 1))) { + const rel = prefix ? posix.join(prefix, entry.name) : entry.name; + if (entry.isDirectory()) out.push(...listFiles(join(dir, entry.name), rel)); + else if (entry.isFile()) out.push(rel); + } + return out; +} + +const toNative = (rel) => rel.split("/").join(sep); + +function readManifest(destDir) { + try { + const parsed = JSON.parse(readFileSync(join(destDir, MANIFEST_FILE), "utf8")); + if (!parsed || typeof parsed !== "object") return null; + const files = parsed.files; + if (!files || typeof files !== "object" || Array.isArray(files)) return null; + // A manifest we cannot fully trust is worse than none: a bad entry would + // read as "unmodified" and silently overwrite the user's edit. + for (const value of Object.values(files)) { + if (typeof value !== "string") return null; + } + return { version: typeof parsed.version === "string" ? parsed.version : null, files }; + } catch { + return null; + } +} + +function readIfFile(path) { + try { + if (!statSync(path).isFile()) return null; + return readFileSync(path); + } catch { + return null; + } +} + +/** + * Decide what installing into `destDir` would do, without touching disk. + * + * `conflicts` are files that exist on disk, differ from what we would write, + * and are not accounted for by the manifest — i.e. hand-edited. They block the + * install unless `force` is set. + */ +export function planInstall({ sourceDir, destDir, force = false }) { + const sourceFiles = listFiles(sourceDir); + if (sourceFiles.length === 0) { + throw new Error(`no skill files found in ${sourceDir}`); + } + + const manifest = readManifest(destDir); + const write = []; + const unchanged = []; + const conflicts = []; + const remove = []; + const kept = []; + + for (const rel of sourceFiles) { + const next = readFileSync(join(sourceDir, toNative(rel))); + const current = readIfFile(join(destDir, toNative(rel))); + + if (current === null) { + write.push(rel); + continue; + } + if (current.equals(next)) { + unchanged.push(rel); + continue; + } + const recorded = manifest?.files?.[rel]; + if (recorded && recorded === sha256(current)) write.push(rel); + else if (force) write.push(rel); + else conflicts.push(rel); + } + + const shipped = new Set(sourceFiles); + for (const rel of Object.keys(manifest?.files ?? {})) { + if (shipped.has(rel)) continue; + const current = readIfFile(join(destDir, toNative(rel))); + if (current === null) continue; + // Dropped from a newer version of the skill. Reclaim it only if it still + // matches what we installed; an edited leftover is the user's file now. + if (sha256(current) === manifest.files[rel] || force) remove.push(rel); + else kept.push(rel); + } + + return { + sourceFiles, + write, + unchanged, + conflicts, + remove, + kept, + hadManifest: manifest !== null, + previousVersion: manifest?.version ?? null, + }; +} + +/** + * Apply a plan. Returns the plan, annotated with `applied: false` when it was a + * dry run or was blocked by conflicts. + */ +export function installSkill({ + sourceDir = bundledSkillDir(), + destDir, + version = "0.0.0", + force = false, + dryRun = false, +} = {}) { + const plan = planInstall({ sourceDir, destDir, force }); + + if (plan.conflicts.length > 0 || dryRun) { + return { ...plan, destDir, applied: false }; + } + + const files = {}; + for (const rel of plan.sourceFiles) { + const target = join(destDir, toNative(rel)); + const bytes = readFileSync(join(sourceDir, toNative(rel))); + files[rel] = sha256(bytes); + if (!plan.write.includes(rel)) continue; + mkdirSync(dirname(target), { recursive: true }); + writeFileSync(target, bytes); + } + + for (const rel of plan.remove) { + rmSync(join(destDir, toNative(rel)), { force: true }); + } + + mkdirSync(destDir, { recursive: true }); + writeFileSync( + join(destDir, MANIFEST_FILE), + `${JSON.stringify({ skill: SKILL_NAME, version, files }, null, 2)}\n`, + ); + + return { ...plan, destDir, applied: true }; +} + +/** + * Resolve which agent directories to install into. + * + * Explicit `--agent` wins. Otherwise every agent whose marker directory already + * exists is selected, so a project set up for both gets both. A project with + * neither falls back to a single default rather than prompting — `npx` runs + * unattended often enough that a prompt is a worse default than a printed note. + */ +export function resolveTargets({ cwd, requested = [], global = false } = {}) { + const base = global ? homedir() : cwd; + const names = requested.length > 0 ? requested : null; + + if (names) { + return names.map((name) => { + const agent = AGENTS[name]; + if (!agent) { + throw new Error( + `unknown agent "${name}" — expected one of: ${Object.keys(AGENTS).join(", ")}, all`, + ); + } + return { agent: name, label: agent.label, dir: join(base, agent.skills, SKILL_NAME) }; + }); + } + + const detected = Object.entries(AGENTS).filter(([, agent]) => { + try { + return statSync(join(base, agent.root)).isDirectory(); + } catch { + return false; + } + }); + + const chosen = detected.length > 0 ? detected : [[DEFAULT_AGENT, AGENTS[DEFAULT_AGENT]]]; + return chosen.map(([name, agent]) => ({ + agent: name, + label: agent.label, + dir: join(base, agent.skills, SKILL_NAME), + detected: detected.length > 0, + })); +} + +/** Expand `--agent` values, including the `all` alias and comma-separated lists. */ +export function expandAgents(values) { + const out = []; + for (const value of values) { + for (const part of value.split(",")) { + const name = part.trim(); + if (!name) continue; + if (name === "all") out.push(...Object.keys(AGENTS)); + else out.push(name); + } + } + return [...new Set(out)]; +} diff --git a/packages/skills/src/install.test.mjs b/packages/skills/src/install.test.mjs new file mode 100644 index 000000000..0e9b56484 --- /dev/null +++ b/packages/skills/src/install.test.mjs @@ -0,0 +1,359 @@ +import { createHash } from "node:crypto"; +import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { dirname, join } from "node:path"; +import { afterEach, describe, expect, it } from "vitest"; + +import { + AGENTS, + bundledSkillDir, + DEFAULT_AGENT, + expandAgents, + installSkill, + listFiles, + MANIFEST_FILE, + planInstall, + resolveTargets, + SKILL_NAME, +} from "./install.mjs"; + +const temps = []; +const tmp = () => { + const dir = mkdtempSync(join(tmpdir(), "polycss-skills-")); + temps.push(dir); + return dir; +}; +afterEach(() => { + for (const dir of temps.splice(0)) rmSync(dir, { recursive: true, force: true }); +}); + +function fixture(files) { + const dir = tmp(); + for (const [rel, body] of Object.entries(files)) { + const path = join(dir, rel); + mkdirSync(dirname(path), { recursive: true }); + writeFileSync(path, body); + } + return dir; +} + +const read = (dir, rel) => readFileSync(join(dir, rel), "utf8"); +const manifest = (dir) => JSON.parse(read(dir, MANIFEST_FILE)); + +describe("listFiles", () => { + it("returns sorted POSIX-relative paths and ignores directories", () => { + const dir = fixture({ "SKILL.md": "a", "docs/b.md": "b", "docs/a.md": "a" }); + expect(listFiles(dir)).toEqual(["SKILL.md", "docs/a.md", "docs/b.md"]); + }); + + it("returns an empty list for a missing directory", () => { + expect(listFiles(join(tmp(), "nope"))).toEqual([]); + }); +}); + +describe("installSkill", () => { + it("writes every file and a manifest on a fresh install", () => { + const sourceDir = fixture({ "SKILL.md": "hello", "docs/a.md": "aye" }); + const destDir = join(tmp(), "polycss"); + + const result = installSkill({ sourceDir, destDir, version: "1.2.3" }); + + expect(result.applied).toBe(true); + expect(result.write).toEqual(["SKILL.md", "docs/a.md"]); + expect(read(destDir, "SKILL.md")).toBe("hello"); + expect(read(destDir, "docs/a.md")).toBe("aye"); + expect(manifest(destDir)).toMatchObject({ skill: SKILL_NAME, version: "1.2.3" }); + expect(Object.keys(manifest(destDir).files)).toEqual(["SKILL.md", "docs/a.md"]); + }); + + it("is idempotent — a second run writes nothing", () => { + const sourceDir = fixture({ "SKILL.md": "hello" }); + const destDir = join(tmp(), "polycss"); + + installSkill({ sourceDir, destDir, version: "1.0.0" }); + const again = installSkill({ sourceDir, destDir, version: "1.0.0" }); + + expect(again.write).toEqual([]); + expect(again.unchanged).toEqual(["SKILL.md"]); + }); + + it("upgrades an untouched install in place", () => { + const destDir = join(tmp(), "polycss"); + installSkill({ sourceDir: fixture({ "SKILL.md": "v1" }), destDir, version: "1.0.0" }); + + const result = installSkill({ + sourceDir: fixture({ "SKILL.md": "v2" }), + destDir, + version: "2.0.0", + }); + + expect(result.applied).toBe(true); + expect(read(destDir, "SKILL.md")).toBe("v2"); + expect(manifest(destDir).version).toBe("2.0.0"); + }); + + it("refuses to overwrite a file the user edited", () => { + const destDir = join(tmp(), "polycss"); + installSkill({ sourceDir: fixture({ "SKILL.md": "v1" }), destDir, version: "1.0.0" }); + writeFileSync(join(destDir, "SKILL.md"), "my notes"); + + const result = installSkill({ + sourceDir: fixture({ "SKILL.md": "v2" }), + destDir, + version: "2.0.0", + }); + + expect(result.applied).toBe(false); + expect(result.conflicts).toEqual(["SKILL.md"]); + expect(read(destDir, "SKILL.md")).toBe("my notes"); + }); + + it("overwrites an edited file under force", () => { + const destDir = join(tmp(), "polycss"); + installSkill({ sourceDir: fixture({ "SKILL.md": "v1" }), destDir, version: "1.0.0" }); + writeFileSync(join(destDir, "SKILL.md"), "my notes"); + + const result = installSkill({ + sourceDir: fixture({ "SKILL.md": "v2" }), + destDir, + version: "2.0.0", + force: true, + }); + + expect(result.applied).toBe(true); + expect(read(destDir, "SKILL.md")).toBe("v2"); + }); + + it("treats a pre-existing directory with no manifest as a conflict", () => { + const destDir = fixture({ "SKILL.md": "someone else's file" }); + + const result = installSkill({ + sourceDir: fixture({ "SKILL.md": "ours" }), + destDir, + version: "1.0.0", + }); + + expect(result.applied).toBe(false); + expect(result.conflicts).toEqual(["SKILL.md"]); + }); + + it("adopts a manifest-less directory whose content already matches", () => { + const destDir = fixture({ "SKILL.md": "same" }); + + const result = installSkill({ + sourceDir: fixture({ "SKILL.md": "same" }), + destDir, + version: "1.0.0", + }); + + expect(result.applied).toBe(true); + expect(result.conflicts).toEqual([]); + expect(manifest(destDir).files["SKILL.md"]).toBe( + createHash("sha256").update("same").digest("hex"), + ); + }); + + it("removes files dropped from a newer version", () => { + const destDir = join(tmp(), "polycss"); + installSkill({ + sourceDir: fixture({ "SKILL.md": "v1", "docs/gone.md": "old" }), + destDir, + version: "1.0.0", + }); + + const result = installSkill({ + sourceDir: fixture({ "SKILL.md": "v2" }), + destDir, + version: "2.0.0", + }); + + expect(result.remove).toEqual(["docs/gone.md"]); + expect(() => read(destDir, "docs/gone.md")).toThrow(); + expect(manifest(destDir).files["docs/gone.md"]).toBeUndefined(); + }); + + it("keeps an edited file that a newer version dropped", () => { + const destDir = join(tmp(), "polycss"); + installSkill({ + sourceDir: fixture({ "SKILL.md": "v1", "docs/gone.md": "old" }), + destDir, + version: "1.0.0", + }); + writeFileSync(join(destDir, "docs/gone.md"), "my notes"); + + const result = installSkill({ + sourceDir: fixture({ "SKILL.md": "v2" }), + destDir, + version: "2.0.0", + }); + + expect(result.applied).toBe(true); + expect(result.remove).toEqual([]); + expect(result.kept).toEqual(["docs/gone.md"]); + expect(read(destDir, "docs/gone.md")).toBe("my notes"); + }); + + it("leaves files the user added alone", () => { + const destDir = join(tmp(), "polycss"); + installSkill({ sourceDir: fixture({ "SKILL.md": "v1" }), destDir, version: "1.0.0" }); + writeFileSync(join(destDir, "MY-NOTES.md"), "mine"); + + installSkill({ sourceDir: fixture({ "SKILL.md": "v2" }), destDir, version: "2.0.0" }); + + expect(read(destDir, "MY-NOTES.md")).toBe("mine"); + expect(manifest(destDir).files["MY-NOTES.md"]).toBeUndefined(); + }); + + it("writes nothing on a dry run", () => { + const sourceDir = fixture({ "SKILL.md": "hello" }); + const destDir = join(tmp(), "polycss"); + + const result = installSkill({ sourceDir, destDir, version: "1.0.0", dryRun: true }); + + expect(result.applied).toBe(false); + expect(result.write).toEqual(["SKILL.md"]); + expect(() => read(destDir, "SKILL.md")).toThrow(); + }); + + it("ignores a corrupt manifest rather than trusting it", () => { + const destDir = join(tmp(), "polycss"); + installSkill({ sourceDir: fixture({ "SKILL.md": "v1" }), destDir, version: "1.0.0" }); + writeFileSync(join(destDir, MANIFEST_FILE), "{ not json"); + writeFileSync(join(destDir, "SKILL.md"), "edited"); + + const result = installSkill({ + sourceDir: fixture({ "SKILL.md": "v2" }), + destDir, + version: "2.0.0", + }); + + expect(result.conflicts).toEqual(["SKILL.md"]); + }); + + it("rejects a manifest whose hashes are not strings", () => { + const destDir = join(tmp(), "polycss"); + installSkill({ sourceDir: fixture({ "SKILL.md": "v1" }), destDir, version: "1.0.0" }); + writeFileSync( + join(destDir, MANIFEST_FILE), + JSON.stringify({ skill: SKILL_NAME, files: { "SKILL.md": { sha: 1 } } }), + ); + writeFileSync(join(destDir, "SKILL.md"), "edited"); + + const result = installSkill({ + sourceDir: fixture({ "SKILL.md": "v2" }), + destDir, + version: "2.0.0", + }); + + expect(result.conflicts).toEqual(["SKILL.md"]); + }); + + it("throws when the source tree is empty", () => { + expect(() => + installSkill({ sourceDir: fixture({}), destDir: join(tmp(), "polycss") }), + ).toThrow(/no skill files/); + }); +}); + +describe("planInstall", () => { + it("does not touch disk", () => { + const destDir = join(tmp(), "polycss"); + planInstall({ sourceDir: fixture({ "SKILL.md": "hello" }), destDir }); + expect(() => read(destDir, "SKILL.md")).toThrow(); + }); +}); + +describe("resolveTargets", () => { + it("selects every agent whose marker directory exists", () => { + const cwd = tmp(); + mkdirSync(join(cwd, AGENTS.claude.root)); + mkdirSync(join(cwd, AGENTS.codex.root)); + + expect(resolveTargets({ cwd }).map((t) => t.agent).sort()).toEqual(["claude", "codex"]); + }); + + it("selects only the detected agent", () => { + const cwd = tmp(); + mkdirSync(join(cwd, AGENTS.codex.root)); + + const targets = resolveTargets({ cwd }); + expect(targets).toHaveLength(1); + expect(targets[0].agent).toBe("codex"); + expect(targets[0].dir).toBe(join(cwd, AGENTS.codex.skills, SKILL_NAME)); + }); + + it("falls back to the default agent when nothing is detected", () => { + const targets = resolveTargets({ cwd: tmp() }); + expect(targets).toHaveLength(1); + expect(targets[0].agent).toBe(DEFAULT_AGENT); + expect(targets[0].detected).toBe(false); + }); + + it("honours an explicit request over detection", () => { + const cwd = tmp(); + mkdirSync(join(cwd, AGENTS.claude.root)); + + const targets = resolveTargets({ cwd, requested: ["codex"] }); + expect(targets.map((t) => t.agent)).toEqual(["codex"]); + }); + + it("rejects an unknown agent", () => { + expect(() => resolveTargets({ cwd: tmp(), requested: ["cursor"] })).toThrow( + /unknown agent "cursor"/, + ); + }); +}); + +describe("expandAgents", () => { + it("expands `all`, splits commas, and dedupes", () => { + expect(expandAgents(["all"])).toEqual(Object.keys(AGENTS)); + expect(expandAgents(["claude,codex"])).toEqual(["claude", "codex"]); + expect(expandAgents(["claude", "claude"])).toEqual(["claude"]); + expect(expandAgents([" codex , "])).toEqual(["codex"]); + }); +}); + +describe("the shipped skill", () => { + it("ships SKILL.md plus a docs folder", () => { + const files = listFiles(bundledSkillDir()); + expect(files).toContain("SKILL.md"); + expect(files.filter((f) => f.startsWith("docs/")).length).toBeGreaterThan(0); + }); + + it("has name and description frontmatter", () => { + const text = readFileSync(join(bundledSkillDir(), "SKILL.md"), "utf8"); + expect(text.startsWith("---\n")).toBe(true); + const frontmatter = text.slice(4, text.indexOf("\n---", 4)); + expect(frontmatter).toMatch(/^name: polycss$/m); + expect(frontmatter).toMatch(/^description: \S/m); + }); + + it("links only to docs that exist", () => { + const dir = bundledSkillDir(); + const shipped = new Set(listFiles(dir)); + const text = readFileSync(join(dir, "SKILL.md"), "utf8"); + const links = [...text.matchAll(/\]\((docs\/[^)#]+)/g)].map((m) => m[1]); + + expect(links.length).toBeGreaterThan(0); + for (const link of links) expect(shipped).toContain(link); + }); + + it("indexes every shipped doc from SKILL.md", () => { + const dir = bundledSkillDir(); + const text = readFileSync(join(dir, "SKILL.md"), "utf8"); + for (const file of listFiles(dir).filter((f) => f.startsWith("docs/"))) { + expect(text).toContain(file); + } + }); + + it("resolves every cross-doc relative link", () => { + const dir = bundledSkillDir(); + const shipped = new Set(listFiles(dir)); + for (const file of listFiles(dir).filter((f) => f.startsWith("docs/"))) { + const text = readFileSync(join(dir, file), "utf8"); + for (const [, link] of text.matchAll(/\]\((?!https?:|#)([^)#]+)/g)) { + expect(shipped, `${file} → ${link}`).toContain(`docs/${link}`); + } + } + }); +}); diff --git a/packages/skills/vitest.config.mjs b/packages/skills/vitest.config.mjs new file mode 100644 index 000000000..05ca7a4f0 --- /dev/null +++ b/packages/skills/vitest.config.mjs @@ -0,0 +1,19 @@ +import { defineConfig } from "vitest/config"; + +export default defineConfig({ + test: { + include: ["src/**/*.test.mjs"], + environment: "node", + coverage: { + provider: "v8", + reporter: ["text", "json-summary"], + include: ["src/install.mjs"], + thresholds: { + statements: 85, + branches: 85, + functions: 90, + lines: 85, + }, + }, + }, +}); diff --git a/packages/vue/README.md b/packages/vue/README.md index 96150cb73..31f7cc2fb 100644 --- a/packages/vue/README.md +++ b/packages/vue/README.md @@ -255,6 +255,7 @@ to place that primitive in 3D space. Polygon count is the dominant cost. | `@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-model loading, retained DOM animation, morph targets, skinning, and playback. | +| `@layoutit/polycss-skills` | `npx @layoutit/polycss-skills` — installs the PolyCSS agent skill into `.claude/skills` or `.agents/skills`. | | `@layoutit/polycss-domformat` | Private MIT-licensed producer-neutral `domformat@0` runtime for canonical JSON plus digest-bound sibling resources; conformance and specifications stay repository-side. Not published. | diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 6f9a66fa0..d73134f90 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -248,6 +248,15 @@ importers: specifier: ^3.1.1 version: 3.2.4(@types/debug@4.1.13)(@types/node@25.5.0)(happy-dom@17.6.3)(tsx@4.23.11) + packages/skills: + devDependencies: + '@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)(tsx@4.23.11)) + vitest: + specifier: ^3.1.1 + version: 3.2.4(@types/debug@4.1.13)(@types/node@25.5.0)(happy-dom@20.8.9)(tsx@4.23.11) + packages/vue: dependencies: '@layoutit/polycss-core': diff --git a/website/public/skill.md b/website/public/skill.md index ff5a3d13c..13290c090 100644 --- a/website/public/skill.md +++ b/website/public/skill.md @@ -6,14 +6,49 @@ description: Build PolyCSS scenes that render 3D meshes, primitive shapes, or cu # PolyCSS — DOM 3D Rendering PolyCSS renders 3D polygon meshes as real DOM elements transformed with CSS -`matrix3d(...)`. It supports OBJ/MTL, STL, glTF/GLB, VOX, generated primitives, -colors, textures, dynamic lighting, shadows, controls, selection, animation, and -per-polygon interaction. +`matrix3d(...)`. No WebGL, no canvas-per-frame. It supports OBJ/MTL, STL, +glTF/GLB, VOX, generated primitives, colors, textures, dynamic lighting, +shadows, controls, selection, animation, and per-polygon interaction. Use native PolyCSS when authoring PolyCSS-first scenes. Use the Three.js parity API when porting Three.js code or generating code from Three-shaped examples. -## Native Imports +## Reference docs + +Read the file that matches the task before writing non-trivial code. + +| File | Read it when | +|---|---| +| [docs/authoring-polygons.md](/skill/docs/authoring-polygons.md) | **Generating `Polygon[]` by hand.** Winding, color format, coplanarity, the optimizer. Silent-failure rules. | +| [docs/scenes-and-cameras.md](/skill/docs/scenes-and-cameras.md) | Setting up a scene, camera props, scene options, custom elements, coordinates. | +| [docs/shapes-and-primitives.md](/skill/docs/shapes-and-primitives.md) | Boxes, spheres, planes, Platonic solids, raw polygon generators. | +| [docs/loading-models.md](/skill/docs/loading-models.md) | `loadMesh`, ``, OBJ/MTL/STL/glTF/GLB/VOX, parse options. | +| [docs/lighting.md](/skill/docs/lighting.md) | Directional/ambient/point lights, baked vs dynamic, rebaking. | +| [docs/shadows.md](/skill/docs/shadows.md) | `castShadow`, `receiveShadow`, parametric shadows, renderer differences. | +| [docs/textures.md](/skill/docs/textures.md) | UV textures, the atlas pipeline, texture quality, presentation options. | +| [docs/controls-and-interaction.md](/skill/docs/controls-and-interaction.md) | Orbit/map/first-person controls, selection, transform gizmos, click handlers. | +| [docs/animation.md](/skill/docs/animation.md) | Skeletal clips from glTF/GLB, `usePolyAnimation`, stable DOM. | +| [docs/performance.md](/skill/docs/performance.md) | Leaf counts, render strategies, atlas memory, voxel fast paths. | +| [docs/three-parity.md](/skill/docs/three-parity.md) | Porting Three.js scenes through the `*/three` subpaths. | +| [docs/troubleshooting.md](/skill/docs/troubleshooting.md) | **Something renders wrong.** Symptom → cause table. | +| [docs/api-index.md](/skill/docs/api-index.md) | "Does this export exist?" Package-by-package export inventory. | + +## Packages + +| Package | Use | +|---|---| +| `@layoutit/polycss` | Vanilla + custom elements. Re-exports all of core. | +| `@layoutit/polycss-react` | React components and hooks. Re-exports core. | +| `@layoutit/polycss-vue` | Vue 3 mirror of React. Re-exports core. | +| `@layoutit/polycss-core` | Pure math and parsers, zero browser globals (Node, workers). | +| `@layoutit/polycss-fonts` | Text → extruded 3D `Polygon[]`. | +| `@layoutit/polycss-morph` | Prepared models with retained DOM, morphs, skinning, playback. | + +React and Vue depend on `core` only — **never import `@layoutit/polycss` from a +React or Vue app.** The public API is mirrored between React and Vue: same +names, same defaults, idiomatic differences only (refs vs reactives). + +## Imports ```ts import { @@ -36,22 +71,10 @@ import { PolyGround, PolyOrbitControls, Poly, -} from "@layoutit/polycss-react"; +} from "@layoutit/polycss-react"; // or "@layoutit/polycss-vue" ``` -```ts -import { - PolyCamera, - PolyPerspectiveCamera, - PolyScene, - PolyMesh, - PolyGround, - PolyOrbitControls, - Poly, -} from "@layoutit/polycss-vue"; -``` - -## Native Conventions +## Conventions - Coordinates are PolyCSS world space `[x, y, z]` with **+Z up**. World Y maps to CSS X (screen-right at identity rotation) and world X to CSS Y @@ -62,12 +85,17 @@ import { clamp to `0.1`–`10` by default). `BASE_TILE` (50) is the world-unit → CSS px factor; you need it when converting world units to raw CSS pixels yourself — e.g. `` mounts a document `width × 50` px wide. -- `PolyCamera` / `createPolyCamera` are orthographic by default. -- Use `PolyPerspectiveCamera` / `createPolyPerspectiveCamera` for perspective. +- `PolyCamera` / `createPolyCamera` are **orthographic** by default (this + deliberately diverges from three.js). Use `PolyPerspectiveCamera` / + `createPolyPerspectiveCamera` for depth foreshortening. +- The camera is the **outer** node; the scene nests inside it. CSS `perspective` + only applies to descendants. +- Every public export is `Poly`-prefixed, except generic math types (`Vec2`, + `Vec3`, `Polygon`, `PolyMaterial`) and the `*/three` subpaths. -## Authoring Polygons — read before generating geometry +## Authoring polygons — the five silent failures -A `Polygon` is a plain object. `vertices` is the only required field. +A `Polygon` is a plain object; `vertices` is the only required field. ```ts interface Polygon { @@ -76,97 +104,30 @@ interface Polygon { texture?: string; // image URL uvs?: [number, number][]; // one per vertex material?: PolyMaterial; // shared material; material.texture wins over `texture` - textureImageSource?: PolyTextureImageSource; // source image metadata; needs texturePresentation.backend="image" (advanced) - texturePresentation?: PolyTexturePresentation; // per-polygon texture overrides (advanced) data?: Record; // → data-* attributes } ``` -Fields not listed here (`textureWrap`, `textureTriangles`, `doubleSided`, …) are -parser-internal — do not author them. - -How much cleanup you get for free varies by parser and by entry point: - -- **Winding:** STL repairs it from connectivity; `.vox` is correct by - construction; **OBJ and glTF preserve source winding as-is**. All parsers - fit to target size and normalize into PolyCSS Z-up coordinates. The axis - transform is per-format: OBJ and glTF apply a cyclic `(x,y,z) → (z,x,y)` - permutation (never a y↔z swap, so handedness is preserved); STL defaults to - identity axes; `.vox` is already Z-up. -- **Validation:** only React/Vue `` runs - `normalizePolygons` (drops degenerates, strips mismatched `uvs`, replaces bad - colors with `#cccccc`, fan-triangulates non-coplanar n-gons) — and its - warnings are never surfaced. `scene.add(...)`, ``, - ``, and `` do **not** normalize. - -So for hand-authored polygons these constraints fail *silently* — no throw, no -console warning: - -**1. Winding decides visibility.** Vertex order sets the face normal by the -right-hand rule (`(v1-v0) × (v2-v0)`), and PolyCSS backface-culls every leaf. A -reversed face is invisible from the side you meant to show, and shades from the -flipped normal — typically ambient-only, since the directional term clamps at -zero (it darkens, it does not invert). Shadows differ by path: React/Vue's ground fallback -projects every polygon regardless of orientation, but the `receiveShadow` path -(vanilla's only mechanism) light-back-face-culls casters, so a reversed open -face can lose its shadow too. Wind counter-clockwise as seen from the side you want to look at. +These constraints fail with **no throw and no console warning**: -```ts -// Faces +Z (up) — visible from above. -{ vertices: [[0,0,0], [1,0,0], [1,1,0], [0,1,0]], color: "#d8d2c7" } -// Same quad reversed — faces -Z, invisible from above. -{ vertices: [[0,0,0], [0,1,0], [1,1,0], [1,0,0]], color: "#d8d2c7" } -``` +1. **Winding decides visibility.** Vertex order sets the normal by the + right-hand rule (`(v1-v0) × (v2-v0)`), and PolyCSS backface-culls every leaf. + Wind counter-clockwise as seen from the side you want to look at. +2. **`color` is not a full CSS color.** Only `#rgb`, `#rrggbb`, `rgb()`, and + `rgba()` parse. `"tomato"`, `hsl()`, and `color()` render **white**. +3. **Non-triangular polygons must be coplanar**, or they are flattened onto + their average plane and crack against their neighbours. Triangles are safe. +4. **The optimizer rewrites geometry by default** (`merge: true`, + `meshResolution: "lossy"`). Pass `{ merge: false }` to render your array + as authored. +5. **Degenerate polygons vanish silently** — under 3 vertices, zero area, or a + degenerate first edge. + +Read [docs/authoring-polygons.md](/skill/docs/authoring-polygons.md) in full before +generating geometry — it covers per-parser winding behaviour, which entry points +normalize, and the exact optimizer thresholds. -Corollaries: solids wind outward but rooms/interiors wind inward; mirroring or -negative scale reverses handedness and requires reversing winding; reversing -vertices requires reversing `uvs` in the same order. `doubleSided` is -importer-internal and is **not** a render-time flag — it will not make a face -visible from behind. - -*Diagnostic rule:* a single-sided face disappearing when the camera moves behind -it is correct behavior, not a bug. The winding symptom is a surface missing or -flickering **from the viewpoint it was built to be seen from** — it exists in -the data, its neighbours render, but it only shows from the opposite side. Then -inspect winding and normal before touching culling, lighting, or camera code. - -**2. `color` is not a full CSS color.** Only `#rgb`, `#rrggbb`, `rgb()`, and -`rgba()` parse. Named colors (`"tomato"`), `hsl()`, and `color()` fail silently -— rendering **white**, or `#cccccc` on the normalizing `` -path. - -**3. Non-triangular polygons must be coplanar.** On every path except -``, a non-planar n-gon is flattened onto its average plane, -opening cracks against its neighbours; `` instead -fan-triangulates it, silently changing topology. Triangles are always safe. - -**4. The optimizer rewrites geometry by default.** `merge` defaults to `true` -and `meshResolution` to `"lossy"`: coincident faces within `0.05` world units -are deduped, interior faces culled, and lossy merging starts at `0.35` world -units of plane displacement / `0.04` boundary / `15°` — but that is not the -ceiling: the optimizer also tries aggressive `30°`, `45°`, and `60°` variants -(the widest at `0.06` boundary), accepted on a material render-cost win. The -degree values are angular thresholds; the displacement budgets are absolute -world units. None are configurable. Dedupe and interior culling count as exact -reductions and still run under `meshResolution: "lossless"` — with one -parse-time exception: STL parse results force the lossless optimizer *and* pass -`skipInteriorCull`, but that protection does not survive into the renderer's own -pass, which culls again unless you set `merge: false`. `merge: false` renders -the array you pass -untouched, but only on `scene.add(...)` and `` — it does not -exist on `` (always normalized + merged) or ``, -and it cannot undo `loadMesh`'s own parse-time optimization. There is no -exact-as-authored path for file geometry — the parsers normalize (fit to -`targetSize` `60`, origin reposition, per-format axis normalization, rounding, -fan-triangulation; STL repairs winding; `.vox` greedy-meshes quads). To preserve -the *direct parser output* from renderer optimization, call -`parseObj`/`parseStl`/`parseGltf`/`parseVox` directly and add with -`merge: false`. - -**5. Degenerate polygons vanish silently** — under 3 vertices, zero area, or a -degenerate first edge produces no leaf and no console output. - -## Building a Scene +## Minimal scene Vanilla: @@ -191,8 +152,8 @@ React (Vue mirrors this with kebab-case props): ```tsx - + {polygons.map((p, i) => select(i)} />)} @@ -200,210 +161,30 @@ React (Vue mirrors this with kebab-case props): ``` -**Primitives.** Vanilla `createPolyBox`, `createPolyPlane`, `createPolySphere`, -`createPolyCylinder`, `createPolyCone`, `createPolyTorus`, `createPolyRing`, and -the Platonic solids (`createPolyTetrahedron`, `createPolyOctahedron`, -`createPolyIcosahedron`, `createPolyDodecahedron`). Core exports the matching -`*Polygons` generators (`boxPolygons`, `spherePolygons`, …) that return raw -`Polygon[]`. - -**Loading.** `loadMesh(url, opts)` handles `.obj` (+ `mtlUrl`), `.stl`, `.gltf`, -`.glb`, and `.vox`, and returns a `ParseResult` you pass to `scene.add(...)`. -In React/Vue use `` or the `usePolyMesh` hook/composable. - -**Controls.** Vanilla `createPolyOrbitControls`, `createPolyMapControls`, -`createPolyFirstPersonControls`, `createTransformControls`, `createSelect`. -React/Vue: ``, ``, -``, ``, `` with -`usePolySelect` / `usePolySelectionApi`. - -**Animation.** `usePolyAnimation` (React/Vue) drives imported skeletal clips. -Animated meshes need stable triangle topology: vanilla passes -`scene.add(mesh, { merge: false, stableDom: true })`; React/Vue pass -`merge={false}` — there is no `stableDom` prop, leaf identity across -same-topology frames is handled internally. - -## Lighting - -The scene takes one `directionalLight`, one `ambientLight`, and optional -`pointLights`. Directional `direction` is the vector from the surface *toward* -the light; it is normalized internally, so it need not be unit length. Point -lights are direction-only (no distance falloff) and shade flat per face. - -Two modes, set via `textureLighting`: - -- **`"baked"`** (default) — Lambert is computed on the CPU and multiplied into - inline colors and atlas pixels. Best fidelity; supports point lights. Moving a - light needs a rebake. **Vanilla does not auto-rebake** on a - `setOptions({ directionalLight })` — call `mesh.rebakeAtlas()` explicitly - (typically debounced to drag-end). React/Vue re-render and *do* auto-rebake. -- **`"dynamic"`** — lighting resolves in CSS `calc()` from scene-root custom - properties. Moving a light is a few CSS variable writes, zero JS, no atlas - redraw. **Point lights are ignored entirely in dynamic mode** — not for - shading, not for shadows. - -Prefer `"dynamic"` for live/animated lights; prefer `"baked"` for point lights, -maximum fidelity, and Three.js parity. - -## Shadows - -Cast shadows are CPU-projected SVG surfaces, not render-strategy leaves. Mark -casters with `castShadow` and receivers with `receiveShadow`; they work in both -lighting modes (dynamic mode is directional-only). - -```ts -scene.add(model, { castShadow: true }); -scene.add(floor, { receiveShadow: true }); -scene.setOptions({ shadow: { color: "#000000", opacity: 0.3, parametric: true, definition: 32 } }); -``` +Custom elements (no build step): -- **Receivers differ by renderer.** Vanilla has no ground fallback: a - `castShadow` mesh draws nothing until some mesh has `receiveShadow: true`, so - `scene.add(floor, { receiveShadow: true })` is required (the snippet above - does this). React/Vue additionally project onto the scene ground plane - automatically when no receiver exists — that is what `` relies on - (it has **no** `receiveShadow` prop) — and drop that fallback as soon as any - receiver exists. -- `shadow.parametric: true` casts a low-resolution coverage silhouette per - caster instead of full geometry — far cheaper. `definition` (default `16`) - is the detail knob; `` overrides it per mesh. -- `shadow.style: "vector" | "pixel"` — `"pixel"` gives blocky/voxel shadows. -- `shadow.followAnimation` — animated casters freeze their shadow by default; - opt in to track the pose. -- `shadow.dragDefinition` is **vanilla only** (progressive refinement during a - light drag). React/Vue get the same effect by lowering `definition` in state. - -## Other Packages - -- **`@layoutit/polycss-fonts`** — text → extruded 3D `Polygon[]`. - `textPolygons(font, text, { depth, profile })` for basic extrusion, - `composeText(...)` for the full multi-line/warp composer, plus - `loadGoogleFont` / `listGoogleFonts`. Framework-agnostic. -- **`@layoutit/polycss-morph`** — prepared models with retained DOM. - `@layoutit/polycss-morph/prepare` is Node-only authoring; - `loadPolyMorphPackage` + `mountPolyMorphModel` run in the browser. The caller - owns timing; morph does not schedule frames. -- **`@layoutit/polycss-core`** — pure math/parsers with zero browser globals, - for Node build steps and workers. - -## Three.js Parity Imports - -Use these when the scene is described in Three.js terms: +```html + -```ts -import { - PerspectiveCamera, - OrthographicCamera, - Object3D, - Vector3, - DirectionalLight, - PointLight, - AmbientLight, - transformPolygonsToPoly, - mountPolyThreeScene, -} from "@layoutit/polycss/three"; + + + + + + ``` -React: - -```tsx -import { - PolyThreePerspectiveCamera, - PolyThreeOrthographicCamera, - PolyThreeMesh, - DirectionalLight, -} from "@layoutit/polycss-react/three"; -``` +## Rules of thumb -Vue: - -```ts -import { - PolyThreePerspectiveCamera, - PolyThreeOrthographicCamera, - PolyThreeMesh, - DirectionalLight, -} from "@layoutit/polycss-vue/three"; -``` - -## Three.js Parity Conventions - -- Coordinates are Three/Y-up authoring space. -- Object rotations are radians, XYZ Euler. -- Cameras are `PerspectiveCamera(fov, aspect, near, far)` or - `OrthographicCamera(left, right, top, bottom, near, far)`. -- Frame with `camera.position.set(...)` and `camera.lookAt(...)`. -- Directional lights use the Three.js source vector, `light.target.position` → `light.position`. -- Geometry converts internally with the right-handed axis map `[x, -z, y]`, so - winding and Lambert lighting stay correct. -- `mountPolyThreeScene(...)` defaults to baked lighting for Three parity. - Use `textureLighting: "dynamic"` only when live CSS light changes matter more - than strict conformance. - -## React Parity Example - -```tsx -import { PolyScene } from "@layoutit/polycss-react"; -import { - DirectionalLight, - PolyThreeMesh, - PolyThreePerspectiveCamera, -} from "@layoutit/polycss-react/three"; - -const sun = new DirectionalLight("#ffffff", 1); -sun.position.set(3, 5, 4); -sun.target.position.set(0, 0, 0); - -export function App() { - return ( - - - - - - ); -} -``` - -## Vanilla Parity Example - -```ts -import { - Object3D, - PerspectiveCamera, - boxPolygons, - mountPolyThreeScene, - transformPolygonsToPoly, -} from "@layoutit/polycss/three"; - -const camera = new PerspectiveCamera(50, 16 / 9, 0.1, 100); -camera.position.set(3, 2, 5); -camera.lookAt(0, 0, 0); - -const object = new Object3D(); -object.rotation.set(0, Math.PI / 4, 0); - -mountPolyThreeScene(document.querySelector("#scene")!, { - camera, - cameraOptions: { viewportHeight: 420 }, - polygons: transformPolygonsToPoly( - boxPolygons({ size: 1, color: "#66aaff" }), - object, - ), -}); -``` +- **Polygon count is the dominant cost.** One visible polygon = one DOM leaf, + one `matrix3d`, one paint. Halving polygon count beats every other + optimisation. +- **Never run a `requestAnimationFrame` loop to update many leaves.** Camera, + mesh, and light motion are single-ancestor CSS updates. If you find yourself + writing a per-frame loop over polygons, you are fighting the engine. +- **Prefer `textureLighting: "dynamic"`** for live or animated lights (zero JS + per light change). Prefer `"baked"` for point lights and maximum fidelity. +- **`scene.destroy()` and `result.dispose()`** release atlas blob URLs. The mesh + element and `usePolyMesh` do it for you. -Full docs: https://polycss.com/api/three-parity -Authoring reference: https://polycss.com/core-concepts#authoring-polygons +Full documentation: https://polycss.com diff --git a/website/public/skill/SKILL.md b/website/public/skill/SKILL.md new file mode 100644 index 000000000..130a2c922 --- /dev/null +++ b/website/public/skill/SKILL.md @@ -0,0 +1,190 @@ +--- +name: polycss +description: Build PolyCSS scenes that render 3D meshes, primitive shapes, or custom polygons as DOM/CSS polygon elements. Use when asked to create, port, debug, or explain PolyCSS code in vanilla JavaScript, React, or Vue. +--- + +# PolyCSS — DOM 3D Rendering + +PolyCSS renders 3D polygon meshes as real DOM elements transformed with CSS +`matrix3d(...)`. No WebGL, no canvas-per-frame. It supports OBJ/MTL, STL, +glTF/GLB, VOX, generated primitives, colors, textures, dynamic lighting, +shadows, controls, selection, animation, and per-polygon interaction. + +Use native PolyCSS when authoring PolyCSS-first scenes. Use the Three.js parity +API when porting Three.js code or generating code from Three-shaped examples. + +## Reference docs + +Read the file that matches the task before writing non-trivial code. + +| File | Read it when | +|---|---| +| [docs/authoring-polygons.md](docs/authoring-polygons.md) | **Generating `Polygon[]` by hand.** Winding, color format, coplanarity, the optimizer. Silent-failure rules. | +| [docs/scenes-and-cameras.md](docs/scenes-and-cameras.md) | Setting up a scene, camera props, scene options, custom elements, coordinates. | +| [docs/shapes-and-primitives.md](docs/shapes-and-primitives.md) | Boxes, spheres, planes, Platonic solids, raw polygon generators. | +| [docs/loading-models.md](docs/loading-models.md) | `loadMesh`, ``, OBJ/MTL/STL/glTF/GLB/VOX, parse options. | +| [docs/lighting.md](docs/lighting.md) | Directional/ambient/point lights, baked vs dynamic, rebaking. | +| [docs/shadows.md](docs/shadows.md) | `castShadow`, `receiveShadow`, parametric shadows, renderer differences. | +| [docs/textures.md](docs/textures.md) | UV textures, the atlas pipeline, texture quality, presentation options. | +| [docs/controls-and-interaction.md](docs/controls-and-interaction.md) | Orbit/map/first-person controls, selection, transform gizmos, click handlers. | +| [docs/animation.md](docs/animation.md) | Skeletal clips from glTF/GLB, `usePolyAnimation`, stable DOM. | +| [docs/performance.md](docs/performance.md) | Leaf counts, render strategies, atlas memory, voxel fast paths. | +| [docs/three-parity.md](docs/three-parity.md) | Porting Three.js scenes through the `*/three` subpaths. | +| [docs/troubleshooting.md](docs/troubleshooting.md) | **Something renders wrong.** Symptom → cause table. | +| [docs/api-index.md](docs/api-index.md) | "Does this export exist?" Package-by-package export inventory. | + +## Packages + +| Package | Use | +|---|---| +| `@layoutit/polycss` | Vanilla + custom elements. Re-exports all of core. | +| `@layoutit/polycss-react` | React components and hooks. Re-exports core. | +| `@layoutit/polycss-vue` | Vue 3 mirror of React. Re-exports core. | +| `@layoutit/polycss-core` | Pure math and parsers, zero browser globals (Node, workers). | +| `@layoutit/polycss-fonts` | Text → extruded 3D `Polygon[]`. | +| `@layoutit/polycss-morph` | Prepared models with retained DOM, morphs, skinning, playback. | + +React and Vue depend on `core` only — **never import `@layoutit/polycss` from a +React or Vue app.** The public API is mirrored between React and Vue: same +names, same defaults, idiomatic differences only (refs vs reactives). + +## Imports + +```ts +import { + createPolyCamera, + createPolyPerspectiveCamera, + createPolyScene, + createPolyOrbitControls, + createPolyBox, + createPolyPlane, + loadMesh, +} from "@layoutit/polycss"; +``` + +```tsx +import { + PolyCamera, + PolyPerspectiveCamera, + PolyScene, + PolyMesh, + PolyGround, + PolyOrbitControls, + Poly, +} from "@layoutit/polycss-react"; // or "@layoutit/polycss-vue" +``` + +## Conventions + +- Coordinates are PolyCSS world space `[x, y, z]` with **+Z up**. World Y maps + to CSS X (screen-right at identity rotation) and world X to CSS Y + (screen-down); the default camera (`rotX: 65, rotY: 45`) presents that as an + isometric view. +- Camera rotations are degrees: `rotX`, `rotY`. +- `zoom` is on-screen CSS pixels per world unit (default `0.65`; orbit controls + clamp to `0.1`–`10` by default). `BASE_TILE` (50) is the world-unit → CSS px + factor; you need it when converting world units to raw CSS pixels yourself — + e.g. `` mounts a document `width × 50` px wide. +- `PolyCamera` / `createPolyCamera` are **orthographic** by default (this + deliberately diverges from three.js). Use `PolyPerspectiveCamera` / + `createPolyPerspectiveCamera` for depth foreshortening. +- The camera is the **outer** node; the scene nests inside it. CSS `perspective` + only applies to descendants. +- Every public export is `Poly`-prefixed, except generic math types (`Vec2`, + `Vec3`, `Polygon`, `PolyMaterial`) and the `*/three` subpaths. + +## Authoring polygons — the five silent failures + +A `Polygon` is a plain object; `vertices` is the only required field. + +```ts +interface Polygon { + vertices: [number, number, number][]; // 3+ points, CCW seen from outside + color?: string; // hex or rgb()/rgba() ONLY + texture?: string; // image URL + uvs?: [number, number][]; // one per vertex + material?: PolyMaterial; // shared material; material.texture wins over `texture` + data?: Record; // → data-* attributes +} +``` + +These constraints fail with **no throw and no console warning**: + +1. **Winding decides visibility.** Vertex order sets the normal by the + right-hand rule (`(v1-v0) × (v2-v0)`), and PolyCSS backface-culls every leaf. + Wind counter-clockwise as seen from the side you want to look at. +2. **`color` is not a full CSS color.** Only `#rgb`, `#rrggbb`, `rgb()`, and + `rgba()` parse. `"tomato"`, `hsl()`, and `color()` render **white**. +3. **Non-triangular polygons must be coplanar**, or they are flattened onto + their average plane and crack against their neighbours. Triangles are safe. +4. **The optimizer rewrites geometry by default** (`merge: true`, + `meshResolution: "lossy"`). Pass `{ merge: false }` to render your array + as authored. +5. **Degenerate polygons vanish silently** — under 3 vertices, zero area, or a + degenerate first edge. + +Read [docs/authoring-polygons.md](docs/authoring-polygons.md) in full before +generating geometry — it covers per-parser winding behaviour, which entry points +normalize, and the exact optimizer thresholds. + +## Minimal scene + +Vanilla: + +```ts +const camera = createPolyCamera({ rotX: 65, rotY: 45 }); +const scene = createPolyScene(document.getElementById("host")!, { + camera, + textureLighting: "dynamic", + directionalLight: { direction: [0.5, -0.6, 0.7], color: "#ffffff", intensity: 1 }, + ambientLight: { color: "#ffffff", intensity: 0.35 }, +}); + +createPolyOrbitControls(scene, { drag: true, wheel: true }); + +scene.add(createPolyBox({ size: 100, color: "#ffd166" }), { position: [0, 0, 50] }); +scene.add(await loadMesh("/model.glb"), { castShadow: true }); +// Vanilla has no ground fallback — a caster needs an explicit receiver. +scene.add(createPolyPlane({ axis: 2, size: 60, offset: 0, color: "#7d848e" }), { receiveShadow: true }); +``` + +React (Vue mirrors this with kebab-case props): + +```tsx + + + + + + {polygons.map((p, i) => select(i)} />)} + + +``` + +Custom elements (no build step): + +```html + + + + + + + + +``` + +## Rules of thumb + +- **Polygon count is the dominant cost.** One visible polygon = one DOM leaf, + one `matrix3d`, one paint. Halving polygon count beats every other + optimisation. +- **Never run a `requestAnimationFrame` loop to update many leaves.** Camera, + mesh, and light motion are single-ancestor CSS updates. If you find yourself + writing a per-frame loop over polygons, you are fighting the engine. +- **Prefer `textureLighting: "dynamic"`** for live or animated lights (zero JS + per light change). Prefer `"baked"` for point lights and maximum fidelity. +- **`scene.destroy()` and `result.dispose()`** release atlas blob URLs. The mesh + element and `usePolyMesh` do it for you. + +Full documentation: https://polycss.com diff --git a/website/public/skill/docs/animation.md b/website/public/skill/docs/animation.md new file mode 100644 index 000000000..a50df88f0 --- /dev/null +++ b/website/public/skill/docs/animation.md @@ -0,0 +1,128 @@ +# Animation + +Skeletal animation from glTF/GLB is the **one renderer exception** to the +"no JS in the render loop" rule. Skinning changes each polygon independently, so +the clip is sampled in JS, the leaf set stays mounted, and baked transform +frames are cached. + +Everything else — camera motion, mesh motion, light changes, autorotate — is a +single-ancestor CSS update. Do not write a per-frame loop over polygons for +those. + +## How it works + +When `loadMesh()` or `parseGltf()` finds usable clips, `ParseResult.animation` +exposes clip metadata and a `sample()` function. Sampling evaluates the source +animation at a time, applies the pose, and returns `Polygon[]` for that moment. +`createPolyAnimationMixer` (core) and `usePolyAnimation` (React/Vue) sit on top +and manage actions, looping, speed, fades, and cross-fades. + +## Mesh setup matters + +Animated meshes need **stable triangle topology**: + +- Vanilla: `scene.add(result, { merge: false, stableDom: true })` +- React/Vue: `meshResolution="lossless"` and/or `merge={false}` — there is + **no `stableDom` prop**; leaf identity across same-topology frames is handled + internally. + +## Vanilla + +The mesh handle returned by `scene.add()` satisfies `PolyAnimationTarget`. You +own the loop. + +```ts +import { createPolyAnimationMixer, loadMesh } from "@layoutit/polycss"; + +const result = await loadMesh("/character.glb", { meshResolution: "lossless" }); +const mesh = scene.add(result, { merge: false, stableDom: true }); + +if (result.animation?.clips.length) { + const mixer = createPolyAnimationMixer(mesh, result.animation); + mixer.clipAction(result.animation.clips[0].name).reset().play(); + + let last = performance.now(); + const tick = (now: number) => { + mixer.update((now - last) / 1000); + last = now; + requestAnimationFrame(tick); + }; + requestAnimationFrame(tick); +} +``` + +## React + +`usePolyAnimation` mirrors drei's `useAnimations` and **owns its own rAF loop**. +It returns `clips`, `names`, `actions`, `mixer`, and a `ref`. Load the mesh +yourself when you need both `polygons` and the animation controller. + +```tsx +const [result, setResult] = useState(null); +const meshRef = useRef(null); +const { actions, names } = usePolyAnimation( + result?.animation?.clips, + result?.animation, + meshRef, +); + +useEffect(() => { + const first = names[0]; + if (first) actions[first]?.reset().play(); +}, [actions, names]); + +return ( + + {result && ( + + )} + +); +``` + +Dispose the `ParseResult` on unmount — `loadMesh` created blob URLs. + +## Vue + +Same shape; `clips`, `names`, `actions`, and `mixer` are computed refs, and the +arguments are passed as refs: + +```ts +const clips = computed(() => result.value?.animation?.clips); +const controller = computed(() => result.value?.animation); +const { actions, names } = usePolyAnimation(clips, controller, meshRef); + +watchEffect(() => { + const first = names.value[0]; + if (first) actions.value[first]?.reset().play(); +}); +``` + +## Actions + +Both the hook and the core mixer expose the familiar three.js-shaped methods: +`play`, `stop`, `reset`, `fadeIn`, `fadeOut`, `crossFadeTo`, `setLoop`, +`setEffectiveTimeScale`, `setEffectiveWeight`. + +`LoopOnce`, `LoopRepeat`, and `LoopPingPong` match the three.js numeric +constants. + +Cross-fading assumes the sampled clips share matching polygon counts and vertex +order — true for clips from the same parsed mesh. + +## Shadows during animation + +An animated caster's shadow **freezes** by default. Set +`shadow.followAnimation: true` to track the pose, and pair it with a low +parametric `definition` — see [shadows.md](shadows.md). + +## Browser note + +On WebKit/Safari, stable CSS triangles fall through to atlas `` leaves. +Same-topology updates keep the existing elements and bitmap URLs mounted and +cache transform frames once warmed. This optimized path is the default; there is +no "baseline vs optimized" toggle. + +Color is **pinned** to the baked value while transforms animate. Recomputing +Lambert from every deformed low-poly face normal causes visible color pumping, +so color refresh is not the default. diff --git a/website/public/skill/docs/api-index.md b/website/public/skill/docs/api-index.md new file mode 100644 index 000000000..28c5b5f68 --- /dev/null +++ b/website/public/skill/docs/api-index.md @@ -0,0 +1,193 @@ +# API Index + +Use this to check whether a name exists before writing it. If a symbol is not +here and not in your editor's completions, do not invent it. + +## Where things live + +- `@layoutit/polycss` does `export * from "@layoutit/polycss-core"`, so every + core name is available from it, plus the imperative API, custom elements, and + the renderer's atlas internals. +- `@layoutit/polycss-react` and `@layoutit/polycss-vue` re-export a **curated + list** of core — parsers, generators, math, and the common types — not all of + it. A core name missing from their index (for example `PolyPointLight`, or the + `resolvePolyTexture*` helpers) is imported from `@layoutit/polycss-core` + directly, which React and Vue already depend on. Never import + `@layoutit/polycss` from a React or Vue app. +- The React and Vue public surfaces are **mirrored**. The only value exports + that differ are the idiomatic context handles: React has `PolyCameraContext` + and `useCameraContext`; Vue has `PolyCameraContextKey` and + `PolySelectionContextKey`. + +## Components (React / Vue) + +`Poly`, `PolyScene`, `PolyMesh`, `PolyIframe`, `PolyGround`, +`PolyCamera`, `PolyPerspectiveCamera`, `PolyOrthographicCamera`, +`PolyOrbitControls`, `PolyMapControls`, `PolyFirstPersonControls`, +`PolyTransformControls`, `PolySelect`, +`PolyAxesHelper`, `PolyDirectionalLightHelper`, +and the shapes: `PolyBox`, `PolyPlane`, `PolyRing`, `PolySphere`, +`PolyCylinder`, `PolyCone`, `PolyTorus`, `PolyTetrahedron`, `PolyOctahedron`, +`PolyIcosahedron`, `PolyDodecahedron`. + +## Hooks / composables (React / Vue) + +`usePolyCamera`, `usePolyMesh`, `usePolyMaterial`, `usePolySceneContext`, +`usePolySelect`, `usePolySelectionApi`, `usePolyAnimation`. + +## Vanilla-only (`@layoutit/polycss`) + +Factories: `createPolyScene`, `createPolyCamera`, `createPolyPerspectiveCamera`, +`createPolyOrthographicCamera`, `createPolyOrbitControls`, +`createPolyMapControls`, `createPolyFirstPersonControls`, +`createTransformControls`, `createSelect`, and the shape factories +`createPolyBox`, `createPolyPlane`, `createPolyRing`, `createPolySphere`, +`createPolyCylinder`, `createPolyCone`, `createPolyTorus`, +`createPolyTetrahedron`, `createPolyOctahedron`, `createPolyIcosahedron`, +`createPolyDodecahedron`. + +Note the two `create*` names without a `Poly` infix: `createSelect` and +`createTransformControls`. + +Snapshot: `exportPolySceneSnapshot`, `PolySceneSnapshotError`. + +Element classes: `PolySceneElement`, `PolyMeshElement`, `PolyPolygonElement`, +`PolyIframeElement`, `PolyCameraElement`, `PolyPerspectiveCameraElement`, +`PolyOrthographicCameraElement`, `PolyOrbitControlsElement`, +`PolyMapControlsElement`, `PolyFirstPersonControlsElement`, +`PolyTransformControlsElement`, `PolySelectElement`, and the shape element +classes. Importing `@layoutit/polycss` does **not** register them — import +`@layoutit/polycss/elements` for that side effect. + +## Custom element tags + +``, ``, ``, ``, +``, ``, ``, +``, ``, ``, +``, ``, ``, +``, and the shapes ``, ``, +``, ``, ``, ``, +``, ``, ``, +``, ``. + +## Parsing and loading (all packages) + +`loadMesh`, `parseObj`, `parseMtl`, `parseStl`, `parseGltf`, `parseVox`, +`normalizePolygons`. + +## Geometry generators (all packages) + +`boxPolygons`, `planePolygons`, `ringPolygons`, `spherePolygons`, +`cylinderPolygons`, `conePolygons`, `torusPolygons`, `tetrahedronPolygons`, +`octahedronPolygons`, `icosahedronPolygons`, `dodecahedronPolygons`, +`axesHelperPolygons`, `arrowPolygons`. + +## Optimizer and mesh ops (all packages) + +`optimizeMeshPolygons`, `optimizeMeshParseResult`, +`optimizeAnimatedMeshPolygons`, `mergePolygons`, `cullInteriorPolygons`, +`simplifyTriangleMeshPolygons`, `coverPlanarPolygons`, `repairMeshSeams`, +`bakeSolidTextureSamples`, `bakeSolidTextureSampledPolygons`, +`seamOverlapDiagnostics`, `seamOverlapPolygons`, `seamFacetSplitPolygons`. + +## Camera and coordinate math (all packages) + +`buildPolyCameraSceneTransform`, `buildPolyMeshTransform`, +`buildPolySceneTransform`, `capturePolyCameraSnapshot`, +`polyCameraTargetToCss`, `resolvePolyCameraAppliedPerspectiveStyle`, +`worldPositionToCss` / `worldPositionToPolyCss`, +`cssPositionToWorld` / `polyCssPositionToWorld`, +`worldDistanceToCss` / `worldDistanceToPolyCss`, +`cssDistanceToWorld` / `polyCssDistanceToWorld`, +`worldDirectionToCss` / `worldDirectionToPolyCss`, +`worldDirectionalLightToCss` / `worldDirectionalLightToPolyCss`. + +Constant: `BASE_TILE` (50). + +## Diagnostics and DOM helpers + +`collectPolyRenderStats`, `collectPolyTextureReadiness`, `queryPolyLeaves`, +`findPolyMeshHandle`, `pointInMeshElement`, `findMeshUnderPoint`, +`injectPolyBaseStyles`. + +Texture resolution (from `@layoutit/polycss-core` or `@layoutit/polycss`, **not** +the React/Vue indexes): `resolvePolyTextureLeafGeometry`, +`resolvePolyTextureImageSource`, `resolvePolyTexturePresentation`, +`resolvePolyTextureImageRendering`. + +## Animation + +`createPolyAnimationMixer`, `usePolyAnimation` (React/Vue), and the loop +constants `LoopOnce`, `LoopRepeat`, `LoopPingPong`. + +## Key types + +Geometry: `Vec2`, `Vec3`, `Polygon`, `PolyMaterial`, `ParseResult`, +`MeshResolution`. + +Lights: `PolyDirectionalLight`, `PolyAmbientLight` (all packages); +`PolyPointLight` (core, `@layoutit/polycss`, and the `*/three` subpaths — React +and Vue accept the `pointLights` prop but do not re-export the type, so import +it from `@layoutit/polycss-core`). + +Texture: `PolyTextureLightingMode`, `PolyTextureLeafSizing`, +`PolyTextureBackend`, `PolyTextureImageRendering`, `PolyTextureImageLighting`, +`PolyTextureProjection`, `PolyTexturePresentation`, `PolyTextureImageSource`. + +Camera: `PolyCameraProjection`, `PolyCameraSnapshot`, `PolyCameraSnapshotStats`. + +Scene/mesh: `PolyMeshHandle`, `PolyMeshTransformInput`, +`PolySceneTransformInput` (all packages); `PolySceneOptions`, +`PolySceneHandle`, `PolyMeshTransform` (vanilla — React/Vue use component prop +types such as `PolySceneProps` and `PolyMeshProps` instead). + +Render: `PolyRenderStrategy`, `PolyRenderStrategiesOption`, `PolyRenderStats`, +`PolyLeafInfo` (all packages); `TextureQuality` (core and `@layoutit/polycss`). + +Parse options: `LoadMeshOptions`, `ObjParseOptions`, `StlParseOptions`, +`GltfParseOptions`, `VoxParseOptions`, `UseMeshOptions`. + +Animation: `PolyAnimationMixer`, `PolyAnimationAction`, `PolyAnimationClip`, +`PolyAnimationTarget`, `ParseAnimationController`, `ParseAnimationClip`, +`LoopMode`. + +Controls (vanilla): `PolyOrbitControlsOptions`, `PolyMapControlsOptions`, +`PolySelectOptions`, `PolySelectionHandle`, `PolyTransformControlsOptions` +(+ matching `*Handle` types). React/Vue use `PolyOrbitControlsProps`, +`PolyMapControlsProps`, `PolySelectProps`, `PolyTransformControlsProps`. + +`PolyFirstPersonControlsOptions` and `PolyFirstPersonControlsHandle` are +exported by **all three** renderers, not vanilla only. + +## Three parity subpaths + +`@layoutit/polycss-core/three`, `@layoutit/polycss/three`, +`@layoutit/polycss-react/three`, `@layoutit/polycss-vue/three`. + +Names: `Vector3`, `Euler`, `Object3D`, `PerspectiveCamera`, +`OrthographicCamera`, `DirectionalLight`, `PointLight`, `AmbientLight`, +`transformPolygonsToPoly`, `mountPolyThreeScene` (vanilla), +`PolyThreePerspectiveCamera`, `PolyThreeOrthographicCamera`, `PolyThreeMesh` +(React/Vue). + +## Other packages + +`@layoutit/polycss-fonts`: `textPolygons`, `composeText`, `loadGoogleFont`, +`listGoogleFonts`. + +`@layoutit/polycss-morph`: `loadPolyMorphPackage`, `mountPolyMorphModel`, +`createPolyMorphPreparedDomTarget`; Node-only preparation under +`@layoutit/polycss-morph/prepare`. Profiles: `static-prepared`, +`morph-regions`, `joint-skin`, `prepared-playback`. + +## Names that do NOT exist + +- No `polygons` option on `createPolyScene` — use `scene.add(...)`. +- No `polygons` attribute on `` — use `` or the + imperative API. +- No `receiveShadow` prop on ``. +- No `stableDom` prop in React/Vue — vanilla `scene.add` option only. +- No `merge` option on `` or ``. +- No render-time `doubleSided` flag on `Polygon`. +- `exportPolySceneSnapshot` is **not** exported from React or Vue — import it + from `@layoutit/polycss` and pass the rendered element. diff --git a/website/public/skill/docs/authoring-polygons.md b/website/public/skill/docs/authoring-polygons.md new file mode 100644 index 000000000..cfd6d028c --- /dev/null +++ b/website/public/skill/docs/authoring-polygons.md @@ -0,0 +1,174 @@ +# Authoring Polygons + +Read this before generating `Polygon[]` by hand. Every constraint here fails +**silently** — no throw, no console warning, no visual error state. + +## The shape + +```ts +interface Polygon { + vertices: [number, number, number][]; // 3+ points, CCW seen from outside + color?: string; // hex or rgb()/rgba() ONLY + texture?: string; // image URL + uvs?: [number, number][]; // one per vertex + material?: PolyMaterial; // shared material; material.texture wins over `texture` + textureImageSource?: PolyTextureImageSource; // source image metadata; needs texturePresentation.backend="image" (advanced) + texturePresentation?: PolyTexturePresentation; // per-polygon texture overrides (advanced) + data?: Record; // → data-* attributes +} +``` + +Fields not listed here (`textureWrap`, `textureTriangles`, `doubleSided`, …) +are parser-internal — do not author them. + +## What each entry point does for you + +How much cleanup you get for free varies by parser and by entry point. + +**Winding:** STL repairs it from connectivity; `.vox` is correct by +construction; **OBJ and glTF preserve source winding as-is**. All parsers fit to +target size and normalize into PolyCSS Z-up coordinates. The axis transform is +per-format: OBJ and glTF apply a cyclic `(x,y,z) → (z,x,y)` permutation (never a +y↔z swap, so handedness is preserved); STL defaults to identity axes; `.vox` is +already Z-up. + +**Validation:** only React/Vue `` runs `normalizePolygons` +(drops degenerates, strips mismatched `uvs`, replaces bad colors with +`#cccccc`, fan-triangulates non-coplanar n-gons) — and its warnings are never +surfaced. `scene.add(...)`, ``, ``, and +`` do **not** normalize. + +## 1. Winding decides visibility + +Vertex order sets the face normal by the right-hand rule +(`(v1-v0) × (v2-v0)`), and PolyCSS backface-culls every leaf. A reversed face is +invisible from the side you meant to show, and shades from the flipped normal — +typically ambient-only, since the directional term clamps at zero (it darkens, +it does not invert). + +Shadows differ by path: React/Vue's ground fallback projects every polygon +regardless of orientation, but the `receiveShadow` path (vanilla's only +mechanism) light-back-face-culls casters, so a reversed open face can lose its +shadow too. + +**Wind counter-clockwise as seen from the side you want to look at.** + +```ts +// Faces +Z (up) — visible from above. +{ vertices: [[0,0,0], [1,0,0], [1,1,0], [0,1,0]], color: "#d8d2c7" } +// Same quad reversed — faces -Z, invisible from above. +{ vertices: [[0,0,0], [0,1,0], [1,1,0], [1,0,0]], color: "#d8d2c7" } +``` + +Corollaries: + +- Solids wind outward; rooms and interiors wind **inward**. +- Mirroring or negative scale reverses handedness and requires reversing + winding. +- Reversing vertices requires reversing `uvs` in the same order. +- `doubleSided` is importer-internal and is **not** a render-time flag — it will + not make a face visible from behind. There is no way to make one polygon + visible from both sides; emit two polygons with opposite winding. + +**Diagnostic rule:** a single-sided face disappearing when the camera moves +behind it is correct behavior, not a bug. The winding symptom is a surface +missing or flickering **from the viewpoint it was built to be seen from** — it +exists in the data, its neighbours render, but it only shows from the opposite +side. Then inspect winding and normal before touching culling, lighting, or +camera code. + +## 2. `color` is not a full CSS color + +Only `#rgb`, `#rrggbb`, `rgb()`, and `rgba()` parse. Named colors (`"tomato"`), +`hsl()`, and `color()` fail silently — rendering **white**, or `#cccccc` on the +normalizing `` path. + +Convert before authoring: + +```ts +// Wrong — renders white. +{ vertices, color: "rebeccapurple" } +// Right. +{ vertices, color: "#663399" } +``` + +## 3. Non-triangular polygons must be coplanar + +On every path except ``, a non-planar n-gon is flattened +onto its average plane, opening cracks against its neighbours; +`` instead fan-triangulates it, silently changing topology. +Triangles are always safe. + +If you need a quad whose corners do not lie on one plane, emit two triangles +instead. If you deliberately snap a vertex onto a shared plane to enable a +merge, propagate the new position to **every** polygon that references it, or +you have traded a flatten for a crack. + +## 4. The optimizer rewrites geometry by default + +`merge` defaults to `true` and `meshResolution` to `"lossy"`: + +- Coincident faces within `0.05` world units are deduped. +- Interior faces are culled. +- Lossy merging starts at `0.35` world units of plane displacement / `0.04` + boundary / `15°` — but that is not the ceiling: the optimizer also tries + aggressive `30°`, `45°`, and `60°` variants (the widest at `0.06` boundary), + accepted on a material render-cost win. + +The degree values are angular thresholds; the displacement budgets are absolute +world units. **None are configurable.** + +Dedupe and interior culling count as exact reductions and still run under +`meshResolution: "lossless"` — with one parse-time exception: STL parse results +force the lossless optimizer *and* pass `skipInteriorCull`, but that protection +does not survive into the renderer's own pass, which culls again unless you set +`merge: false`. + +`merge: false` renders the array you pass untouched, but only on +`scene.add(...)` and ``. It does **not** exist on +`` (always normalized + merged) or ``, and it +cannot undo `loadMesh`'s own parse-time optimization. + +There is no exact-as-authored path for file geometry — the parsers normalize +(fit to `targetSize` `60`, origin reposition, per-format axis normalization, +rounding, fan-triangulation; STL repairs winding; `.vox` greedy-meshes quads). +To preserve the *direct parser output* from renderer optimization, call +`parseObj` / `parseStl` / `parseGltf` / `parseVox` directly and add with +`merge: false`. + +## 5. Degenerate polygons vanish silently + +Under 3 vertices, zero area, or a degenerate first edge produces no leaf and no +console output. If a polygon you authored is simply absent from the DOM, check +for duplicate consecutive vertices before anything else. + +## Getting geometry into a scene + +`scene.add()` takes a `ParseResult`, not a raw array. Wrap it yourself: + +```ts +scene.add({ polygons, objectUrls: [], warnings: [], dispose: () => {} }, { merge: false }); +``` + +There is **no** `polygons` option on `createPolyScene`. In React/Vue, +`` and `` both exist and +behave differently (see the validation note above). + +## Meshing for cheap rendering + +If you are generating geometry programmatically, the mesher's job is to +maximise cheap leaves and minimise atlas-backed ones. + +- **Polygon count is the dominant cost.** One visible polygon = one DOM node, + one `matrix3d`, one paint. +- **Fill ratio matters for textured polygons.** A textured polygon's atlas slice + equals its local-2D bounding rect; empty space inside is wasted bitmap. + Axis-aligned rectangle = 1.0 (and the fastest path); right-isosceles triangle + = 0.5; skinny triangles are far worse and many of them balloon atlas memory. +- **Regular grids are not required.** Any planar tiling whose edges match across + neighbours (no T-junctions, no cracks) is valid. Break the grid where it lets + you fit larger axis-aligned rects to flat regions. +- **Track cumulative vertex displacement**, not per-merge error, when snapping + vertices to shared planes. Errors compound. + +See [performance.md](performance.md) for the render-strategy table. diff --git a/website/public/skill/docs/controls-and-interaction.md b/website/public/skill/docs/controls-and-interaction.md new file mode 100644 index 000000000..968fc07f5 --- /dev/null +++ b/website/public/skill/docs/controls-and-interaction.md @@ -0,0 +1,149 @@ +# Controls and Interaction + +Controls are **additive layers**, following the three.js split. They attach +their own pointer/wheel listeners; only `animate` runs a `requestAnimationFrame` +loop, and it updates one ancestor transform, not per-polygon state. + +| Control | Purpose | +|---|---| +| `PolyOrbitControls` / `createPolyOrbitControls` | Drag orbit + wheel zoom + autorotate. Default pick. | +| `PolyMapControls` / `createPolyMapControls` | Drag **pans** instead of orbiting. Top-down / flat layouts. | +| `PolyFirstPersonControls` / `createPolyFirstPersonControls` | Pointer-lock mouselook + WASD, jump, crouch. | +| `PolyTransformControls` / `createTransformControls` | Translate/rotate gizmo on a selected mesh handle. | +| `PolySelect` / `createSelect` | Pointer picking over mesh handles. | + +Camera controls mutate the wrapping camera state. Transform controls mutate the +attached mesh handle via `setTransform`. + +## Orbit / Map options + +| Prop | Type | Default | Notes | +|---|---|---|---| +| `drag` | `boolean` | `true` | Pointer-drag rotation (orbit) or pan (map). | +| `wheel` | `boolean` | `true` | Wheel / pinch zoom. Mac trackpad pinch arrives as `wheel` with `ctrlKey`, so this covers both. | +| `invert` | `boolean \| number` | `false` | `true` reverses; a number scales sensitivity (negative inverts). | +| `minZoom` / `maxZoom` | `number` | `0.1` / `10` | Zoom clamps. | +| `dolly` | `boolean` | `false` | Wheel drives `distance` instead of `zoom`. | +| `minDistance` / `maxDistance` | `number` | `0` / `5000` | Dolly clamps. | +| `animate` | `false \| { speed?, axis?, pauseOnInteraction? }` | `false` | Autorotate. | + +`animate` fields: `speed` (default `0.3`, degrees per 60 Hz-equivalent frame ≈ +18 deg/sec), `axis` (`"y"` default, `"x"` tilts), `pauseOnInteraction` (default +`true`). The tick is `dt`-clamped at 50 ms so speed is refresh-rate independent +and a refocused tab does not jump. + +**Zoom vs dolly:** the default wheel behaviour scales the whole scene (good for +isometric/map-style). `dolly` moves the viewpoint back along the view axis, +mirroring three.js `OrbitControls` changing the spherical radius — better for +perspective scenes where foreshortening should stay consistent. + +On custom elements, the presence of any `animate-*` attribute +(`animate-speed`, `animate-axis`, `animate-pause-on-interaction`) implies +`animate` is enabled; removing them all turns it off. + +## Imperative handle + +```ts +const controls = createPolyOrbitControls(scene, { + drag: true, + wheel: true, + animate: { speed: 0.3, axis: "y", pauseOnInteraction: true }, +}); + +controls.update({ animate: false }); // live partial update +controls.pause(); // detach listeners + cancel rAF +controls.resume(); +controls.destroy(); + +controls.addEventListener("change", (e) => console.log(e.camera)); +controls.addEventListener("start", () => {}); // interaction begin +controls.addEventListener("end", () => {}); // interaction end +``` + +## First-person controls + +Click the scene to acquire pointer lock; Escape releases it. + +| Prop | Default | | Prop | Default | +|---|---|---|---|---| +| `enabled` | `true` | | `moveSpeed` | `5` (world units/sec) | +| `lookEnabled` | `true` | | `jumpVelocity` | `7` | +| `moveEnabled` | `true` | | `gravity` | `18` | +| `jumpEnabled` | `true` | | `eyeHeight` | `1.7` | +| `crouchEnabled` | `true` | | `crouchHeight` | `1` | +| `lookSensitivity` | `0.15` (deg/px) | | `groundZ` | `0` | +| `invertY` | `false` | | `minPitch` / `maxPitch` | `5` / `175` | + +Pair it with `PolyPerspectiveCamera` — first-person scenes want +foreshortening. + +Imperative handles expose `lock()`, `unlock()`, `isLocked()`, `getOrigin()`, +`setOrigin()`, `pause()`, `resume()`, `destroy()`, `update(partial)`. + +## Selection + +For whole-mesh selection use `PolySelect` / `` rather than wiring +every polygon. It tracks selected `PolyMeshHandle`s and supports multi-select. + +```tsx + setSelected(meshes[0] ?? null)}> + + + +``` + +- `usePolySelect()` reads the current selection inside a subtree. +- `usePolySelectionApi()` gives a nested toolbar `set`, `add`, `remove`, + `toggle`, `clear`. +- Lower-level DOM helpers: `findPolyMeshHandle(el)`, + `pointInMeshElement(meshEl, clientX, clientY)`, + `findMeshUnderPoint(clientX, clientY, filter?)`. They use the same + bounding-rect fallback that selection and transform controls use for clipped + polygon leaves. + +Raycasting runs on pointer events only — never per frame. + +## Transform controls + +Translate mode gives axis arrows and plane handles; rotate mode gives axis +rings. Dragging updates the attached mesh directly. + +Key props: `object`, `mode`, `size`, `showX`, `showY`, `showZ`, +`translationSnap`, `rotationSnap`, `enabled`, `onChange`, `onObjectChange`, +`onMouseDown`, `onMouseUp`, `onDraggingChanged`. + +## Per-polygon events + +Every polygon is a real DOM element, so ordinary handlers, classes, and CSS +work — in every entry point. + +```tsx +{polygons.map((p, i) => ( + select(i)} + onMouseEnter={() => setHovered(i)} + className={hovered === i ? "highlight" : ""} + style={{ transition: "filter 0.2s" }} + /> +))} +``` + +```css +.highlight { filter: brightness(1.5); } +``` + +```js +// Vanilla custom elements +const el = document.createElement("poly-polygon"); +el.setAttribute("vertices", JSON.stringify(p.vertices)); +el.addEventListener("click", () => el.classList.toggle("selected")); +scene.appendChild(el); +``` + +Use `polygon.data` to attach `data-*` attributes for CSS selectors and event +delegation. + +Merged polygons lose per-polygon addressing — pass `merge: false` when you need +one leaf per source polygon. diff --git a/website/public/skill/docs/lighting.md b/website/public/skill/docs/lighting.md new file mode 100644 index 000000000..0fc6d5845 --- /dev/null +++ b/website/public/skill/docs/lighting.md @@ -0,0 +1,116 @@ +# Lighting + +The scene takes one `directionalLight`, one `ambientLight`, and zero or more +`pointLights`. + +```ts +interface PolyDirectionalLight { + direction: [number, number, number]; // surface → light source + color?: string; // default "#ffffff" + intensity?: number; // default 1 +} + +interface PolyAmbientLight { + color?: string; // default "#ffffff" + intensity?: number; // default 0.4 +} +``` + +`direction` is the vector from the surface *toward* the light. It is normalized +internally, so it need not be unit length. + +## Point lights + +`pointLights: PolyPointLight[]` are **direction-only** — no distance falloff. +Per polygon the contribution is `color · intensity · max(0, n · L̂)`, where `L̂` +is the unit direction from the surface to the light position. Multiple colored +lights accumulate per-channel alongside the directional and ambient terms. + +They shade **flat per face** (an accepted approximation vs three.js's +per-fragment `PointLight(distance: 0, decay: 0)`; exact for small faces and +distant lights). + +Point lights are **baked mode only**. Dynamic mode ignores them entirely — not +for surface shading and not for shadows. + +## Lighting modes + +Set with `textureLighting`. + +### `"baked"` (default) + +Lambert (directional + each point light + ambient) is computed once on the CPU +per polygon and multiplied into the inline `color` for solid leaves, or into the +rasterised atlas pixels for textured leaves. + +Best fidelity; the only mode that supports point lights; the Three.js parity +baseline. + +**Moving a light requires a rebake**, and the two renderer families differ: + +- **Vanilla does not auto-rebake** on `setOptions({ directionalLight })`. Call + `mesh.rebakeAtlas()` explicitly, typically debounced to drag-end. This is + deliberate: it keeps high-frequency light drags fast. +- **React/Vue re-render and do auto-rebake** on any light prop change. +- **Exception:** a vanilla `setOptions({ pointLights })` change *does* re-render + every mesh. The freeze applies to the directional light only. + +Cast shadows are cheap (CPU-projected SVG) and re-emit automatically on any +light change in both renderer families, so a scene can have a live shadow over a +frozen baked surface until you rebake. + +### `"dynamic"` + +The scene root carries the directional + ambient setup as custom properties +(`--plx/y/z`, `--plr/g/b`, `--pli`, `--par/g/b`, `--pai`). Each leaf embeds its +surface normal (`--pnx/y/z`) and base color (`--psr/g/b`) inline. CSS `calc()` +resolves the Lambert dot product and per-channel tint at paint time. + +Moving a light is a handful of CSS variable writes on one ancestor — zero JS, no +atlas redraw. + +Trade-offs: no point lights at all, and cast shadows are directional-only. + +### Choosing + +- Live or animated lights → `"dynamic"`. +- Point lights, maximum fidelity, Three.js parity → `"baked"`. + +`mountPolyThreeScene(...)` defaults to `"baked"` because baked Lambert is the +Three-parity baseline. + +## Per-mesh override + +React/Vue expose `textureLighting` as a `` prop. Vanilla meshes +inherit the scene value. + +## Example + +```ts +const scene = createPolyScene(host, { + camera, + textureLighting: "dynamic", + directionalLight: { direction: [0.5, -0.6, 0.7], color: "#ffe4a8", intensity: 1 }, + ambientLight: { color: "#ffffff", intensity: 0.35 }, +}); + +// Dynamic mode: free. +scene.setOptions({ directionalLight: { direction: [-0.3, -0.8, 0.5] } }); +``` + +```ts +// Baked mode: cheap during the drag, rebake once at the end. +onDrag((direction) => scene.setOptions({ directionalLight: { direction } })); +onDragEnd(() => meshHandles.forEach((m) => m.rebakeAtlas())); +``` + +```tsx + +``` + +Point lights only take effect in the default `"baked"` mode — the snippet above +would silently ignore them under `textureLighting="dynamic"`. diff --git a/website/public/skill/docs/loading-models.md b/website/public/skill/docs/loading-models.md new file mode 100644 index 000000000..8261b8d52 --- /dev/null +++ b/website/public/skill/docs/loading-models.md @@ -0,0 +1,145 @@ +# Loading Models + +## Supported formats + +| Format | Extension | Notes | +|---|---|---| +| OBJ + MTL | `.obj` + `.mtl` | UV maps via `vt`, textures from `map_Kd`. | +| STL | `.stl` | ASCII or binary triangle mesh; binary Magics face colors. No standard units, textures, UVs, or hierarchy. | +| glTF | `.gltf` | Embedded or external buffers, `TEXCOORD_0` UVs. | +| GLB | `.glb` | Binary glTF; embedded textures extracted as blob URLs. | +| MagicaVoxel | `.vox` | Exposed faces become colored quads; eligible baked-mode meshes use a direct-voxel fast path. | + +## Declarative + +```html + +``` + +```tsx + +``` + +Fetches, parses, and mounts one leaf per visible polygon inside a +`.polycss-mesh` wrapper. Disposal is automatic on unmount or `src` change. + +React/Vue add `fallback` (`#fallback` slot) and `errorFallback` (`#error` slot). + +## Imperative + +```ts +import { createPolyCamera, createPolyScene, loadMesh } from "@layoutit/polycss"; + +const result = await loadMesh("/cottage.obj", { + mtlUrl: "/cottage.mtl", + objOptions: { targetSize: 30 }, +}); +const scene = createPolyScene(host, { camera: createPolyCamera({ rotX: 65, rotY: 45 }) }); +scene.add(result); + +// later +scene.destroy(); // removes the scene and disposes registered meshes +``` + +`loadMesh` returns a `ParseResult`: + +```ts +interface ParseResult { + polygons: Polygon[]; + objectUrls: string[]; + warnings: string[]; + dispose: () => void; // revokes blob URLs +} +``` + +React/Vue wrap this as `usePolyMesh(url, opts)` → `{ polygons, loading, error }`, +which disposes on unmount. + +Format-specific parsers are also exported directly: `parseObj`, `parseStl`, +`parseGltf`, `parseVox`. Use them when you need the raw parser output without +`loadMesh`'s optimization pass, then `scene.add(result, { merge: false })`. + +## Parse options + +Nested per format under `loadMesh` / `parseOptions`: + +```tsx + +``` + +- **`targetSize`** (default `60`) scales the model so its longest axis fits that + many world units. It does not decimate geometry. `.vox` snaps to the nearest + integer voxel CSS cell size, so the final size may differ slightly. +- **`materialColors` / `materialTextures`** override by material name without + editing the source file. Available under `objOptions` and `gltfOptions`. For + glTF, an explicit `materialColors` entry **wins over** the color derived from + the file's material. +- **`includeObjects` / `excludeObjects`** filter by object name. +- **`baseUrl`** resolves relative texture paths for OBJ/glTF. +- **`solidTextureSamples`** converts texture-backed faces whose sampled UV + region is effectively one color into solid-color polygons before optimization + — avoids atlas slices for assets that use texture images as color swatches. +- **`paletteMergeDistance` / `colorRegionMergeDistance`** (`.vox`) fold nearby + opaque, hue-compatible colors before greedy meshing and clean up small color + islands. **Lossy** — they change authored colors. +- **`meshResolution`** (`"lossy"` default / `"lossless"`) is the optimizer + intent. On `` the top-level `meshResolution` prop wins over + `parseOptions.meshResolution`. + +## Optimization on load + +`loadMesh` optimizes at parse time and `scene.add` optimizes again at render +time. `merge: false` only affects the second pass — it cannot restore source +geometry. See [authoring-polygons.md](authoring-polygons.md) §4 for the exact +thresholds and the STL exception. + +## Per-polygon control over a loaded mesh + +```tsx +// React render prop + + {(polygon, index) => ( + setSelected(index)} /> + )} + +``` + +```vue + + + + +``` + +```ts +// Vanilla: one mesh handle per polygon +const result = await loadMesh("/character.glb"); +const handles = result.polygons.map((polygon, i) => + scene.add( + { polygons: [polygon], objectUrls: [], warnings: [], dispose: () => {} }, + { id: `polygon-${i}`, merge: false }, + ), +); +``` + +Merged polygons lose per-polygon DOM addressing, which is why `merge: false` +matters here. + +## Blob URL lifecycle + +Embedded textures and generated atlas pages are blob URLs, revoked on +`dispose()`. Never hold references to them across remounts. The mesh element, +``, and `usePolyMesh` handle this for you; imperative callers must +call `dispose()` (or `scene.destroy()` for registered meshes). diff --git a/website/public/skill/docs/performance.md b/website/public/skill/docs/performance.md new file mode 100644 index 000000000..33aa7709b --- /dev/null +++ b/website/public/skill/docs/performance.md @@ -0,0 +1,107 @@ +# Performance + +Performance scales with **mounted leaf count** and **atlas area**. Every visible +polygon is one DOM element with a CSS transform. + +Measured on a 10k-triangle mesh with autorotate over 7 s: scripting ~579 ms +(mostly React re-renders), rendering (style recalc + layout) ~2130 ms. Rendering +dominates — reducing polygon count beats optimising JS. + +## The no-JS-in-the-render-loop principle + +| JS runs here | JS does NOT run here | +|---|---| +| Scene construction, mesh ops, vertex snapping | Per-frame polygon paint | +| Model import, mesh optimisation, coplanar merging | Per-frame Lambert (dynamic mode is pure CSS) | +| Atlas planning + rasterisation (one-shot) | Per-frame atlas redraw | +| Control input handling | Per-frame transform recompute of every polygon | +| Camera math → one scene-root CSS variable | Per-polygon JS in any hot path | +| Hover/selection raycasting (pointer events only) | Continuous renderer "ticks" | + +If you want a `requestAnimationFrame` loop that updates many renderer DOM nodes, +stop and find the CSS variable that should carry the change instead. The only +sanctioned exception is skeletal animation ([animation.md](animation.md)). + +## Render strategies + +The renderer picks the cheapest CSS primitive that can represent each polygon, +then places it with `matrix3d(...)`. Ordered cheapest → most expensive: + +| Leaf | Chosen for | Atlas memory | +|---|---|---| +| `` | Axis-aligned rectangles and stable quads. `background: currentColor`. | none | +| `` | Solid triangles and exact beveled-corner solids (`corner-shape`), with a border-width triangle fallback. | none | +| `` | Other solid clipped polygons, via `border-shape: polygon(...)`. | none | +| `` | Textured polygons **and** the universal fallback. | bounding-rect area | + +These are internal tags, not public API — never document or depend on them in +app code, but do understand that the mesher's job is to maximise ``/``/`` +and minimise ``. + +Fall-through when a strategy is unsupported or disabled: `b → i → s`, +`u → i → s`, `i → s`. `` cannot be disabled. + +`strategies={{ disable: ["b", "i", "u"] }}` forces atlas rendering — a +diagnostic for comparing output or isolating a browser compositor bug, not a +production setting. + +## Diagnostics + +- `collectPolyRenderStats(root)` — mounted leaf mix by strategy. +- `collectPolyTextureReadiness(root)` — whether atlas bitmaps have decoded. +- `queryPolyLeaves(root)` — the leaf elements themselves. + +## Automatic mesh optimization + +`meshResolution: "lossy"` (default) bakes solid texture swatches, merges +visually redundant swatch colors, tries static triangle simplification for +eligible non-animated imports, merges compatible polygons, and can use bounded +geometric approximation when that lowers estimated DOM render cost. Wider lossy +candidates are gated by whole-mesh seam diagnostics and a minimum render-cost +win. + +`"lossless"` keeps exact planar candidates only; dedupe and interior culling +still run. + +Best on architectural meshes with large flat surfaces — walls, floors, ceilings, +voxel faces. + +Limitations: per-polygon DOM addressing is lost inside a merged region, and +UV-textured polygons only merge when texture mapping can be preserved. + +## Levers, in order of impact + +1. **Fewer polygons.** Lower-poly source assets, or let the lossy optimizer + work (don't reflexively pass `merge: false`). +2. **Fewer textured polygons.** `solidTextureSamples` converts uniform texture + swatches into solid colors, skipping atlas slices entirely. +3. **Lower `textureQuality`.** `0.5` costs about a quarter of the bitmap memory + of `1`. +4. **Cheaper shadows.** `shadow.parametric` with a modest `definition`; avoid + self-shadow on complex meshes; use `dragDefinition` (vanilla) or lower + `definition` in state during interaction. +5. **Dynamic lighting** if lights move — it removes the atlas rebake entirely. + +`targetSize` does **not** reduce polygon count. It only sets world-space scale +(and therefore atlas footprint size). + +## Voxel fast paths + +Voxel-shaped meshes are the exception to "all polygons stay mounted": a mesh +with at most the six axis-aligned face normals mounts only camera-facing leaves +and patches the mounted set when the camera or mesh rotation crosses a +visible-normal boundary. Non-voxel meshes keep the full leaf DOM mounted — +broad camera-dependent culling is not worth the mutation cost. + +Raw `.vox` sources additionally get a direct-voxel fast path: eligible +baked-mode meshes in vanilla, React, and Vue render visible voxel quads directly +as `` leaves inside persistent signed-face wrappers. + +Falls back to the polygon renderer for: dynamic lighting, shadows, stable-DOM +animation, non-exact voxel geometry, and geometry replaced via `setPolygons()`. + +## Atlas lifecycle + +Textured meshes do a one-time atlas pass at mount; very large texture footprints +still cost memory and startup time. Blob URLs are revoked on `dispose()` / +unmount. Don't hold references across remounts. diff --git a/website/public/skill/docs/scenes-and-cameras.md b/website/public/skill/docs/scenes-and-cameras.md new file mode 100644 index 000000000..c43af97fd --- /dev/null +++ b/website/public/skill/docs/scenes-and-cameras.md @@ -0,0 +1,173 @@ +# Scenes and Cameras + +## Structure + +The camera is the **outer** node and the scene nests inside it. This is not a +style choice: CSS `perspective` only applies to descendants, so the scene's +`transform: matrix3d(...)` must be a child of the element carrying the +projection. + +- React/Vue: `PolyScene` **throws** outside a camera component. +- Vanilla: `createPolyScene(host, opts)` takes a **required** `camera` handle. +- `` is the one exception: with no ancestor camera element it builds + an *implicit* camera from its own `perspective`, `rot-x`, `rot-y`, `zoom`, + `distance`, and `target` attributes. + +## Coordinates + +World space is `[x, y, z]` with **+Z up**. World Y maps to CSS X (screen-right +at identity rotation) and world X to CSS Y (screen-down). The default camera +(`rotX: 65, rotY: 45`) presents that as an isometric view. + +`BASE_TILE` is `50` — the world-unit → CSS pixel factor you need when converting +world units to raw CSS pixels yourself. + +## Cameras + +`PolyCamera` is an alias for `PolyOrthographicCamera` — identical, and the +**default**. This deliberately diverges from three.js, because PolyCSS's +strengths (integer-pixel atlas, no per-frame JS, DOM stacking) show best in +orthographic scenes. Use `PolyPerspectiveCamera` when you need depth +foreshortening (first-person, game-like). + +| Prop | Type | Default | Meaning | +|---|---|---|---| +| `zoom` | `number` | `0.65` | On-screen CSS pixels per world unit. Higher zooms in. Orbit controls clamp to `0.1`–`10` (`minZoom` / `maxZoom`). | +| `rotX` | `number` | `65` | Rotation around X in degrees. | +| `rotY` | `number` | `45` | Rotation around Y in degrees (0–360). | +| `distance` | `number` | `0` | Dolly pull-back in pixels; adds `translateZ(-distance)px`. Equivalent to increasing the orbit radius in three.js. Driven by `dolly` mode on orbit controls. | +| `target` | `Vec3` | `[0,0,0]` | Point in scene space the camera orbits. | +| `perspective` | `number` | `32000` | **`PolyPerspectiveCamera` only.** CSS perspective depth in px. Higher is flatter. | + +`zoom` scales; `distance` moves the viewpoint back along the view axis. They are +not interchangeable. + +## Scene options + +Set on `createPolyScene(host, opts)`, `` props, or `` +attributes (kebab-case). + +| Option | Type | Default | Notes | +|---|---|---|---| +| `camera` | camera handle | — | Vanilla only, required. | +| `directionalLight` | `PolyDirectionalLight` | none | See [lighting.md](lighting.md). | +| `pointLights` | `PolyPointLight[]` | none | Baked mode only. | +| `ambientLight` | `PolyAmbientLight` | none | | +| `textureLighting` | `"baked" \| "dynamic"` | `"baked"` | | +| `textureQuality` | `number \| "auto"` | `"auto"` | Atlas bitmap budget + sprite size. | +| `textureLeafSizing` | `"canonical" \| "local" \| "raster"` | `"canonical"` | Scene/atlas-wide; **no per-polygon override**. | +| `textureImageRendering` | `"auto" \| "pixelated"` | `"auto"` | | +| `textureBackend` | `"auto" \| "atlas" \| "image"` | `"auto"` | `"auto"` always resolves to the atlas today; direct image leaves need explicit `"image"`. | +| `textureProjection` | `"affine" \| "projective"` | `"affine"` | | +| `seamBleed` | `number \| "auto"` | `1.5` | Overscan on shared solid seams. **Semantics differ by renderer** — see below. | +| `strategies` | `{ disable?: ("b"\|"i"\|"u")[] }` | none | Diagnostics. `` cannot be disabled. | +| `autoCenter` | `boolean` | `false` | Rotate around content bbox center instead of world origin. Polygon data is not mutated. Meshes opt out with `excludeFromAutoCenter`. | +| `centerPolygons` | `Polygon[]` | none | **Framework only.** bbox source for `autoCenter` when polygons live in child meshes. | +| `shadow` | object | see [shadows.md](shadows.md) | | +| `polygons` | `Polygon[]` | none | **Framework only.** Composes with children. Note: this is the only path that runs `normalizePolygons`. | + +`seamBleed` caveat: only the numeric default `1.5` behaves identically across +renderers. Vanilla clamps a number to `0..1` and multiplies the `1.5` px +default; React/Vue pass the raw number through. `"auto"` resolves to the full +`1.5` px in vanilla but produces **no** shared-edge overscan in React/Vue. +Prefer leaving it alone. + +## Mesh transforms + +`scene.add(result, transform)` / `` props: + +| Option | Type | Notes | +|---|---|---| +| `id` | `string` | Reflected as `data-poly-mesh-id`; used by selection and gizmos. | +| `position` | `Vec3` | Offset in scene space. | +| `scale` | `number \| Vec3` | | +| `rotation` | `Vec3` | Euler **degrees** `[x, y, z]`. | +| `autoCenter` | `boolean` | Shifts the mesh so its bbox center sits at the local origin before `position`. | +| `castShadow` / `receiveShadow` | `boolean` | See [shadows.md](shadows.md). | +| `merge` | `boolean` | Default `true`. `false` renders the array entering the renderer exactly as given. | +| `meshResolution` | `"lossy" \| "lossless"` | Default `"lossy"`. | +| `stableDom` | `boolean` | Vanilla only; needed for skeletal animation. | +| `shadowDefinition` | `number` | Per-mesh parametric shadow detail. | +| `excludeFromAutoCenter` | `boolean` | | + +React/Vue additionally expose per-mesh `textureLighting`, `textureQuality`, +`textureLeafSizing`, `textureImageRendering`, `textureBackend`, +`textureProjection`, `seamBleed`, `atomicAtlas`, and `onFrameReady`. Vanilla +meshes inherit the scene values for those. + +## Custom element caveats + +`` supports `directional-*`, `ambient-*`, `texture-lighting`, +`texture-quality`, `texture-leaf-sizing`, `texture-image-rendering`, +`texture-backend`, `texture-projection`, `auto-center`, and the implicit-camera +attributes. Only `perspective`, `rot-x`, `rot-y`, and `zoom` are *observed* — +mutating `distance` or `target` alone does not update the implicit camera. +`perspective` only selects the camera type at connect time. Use the imperative +API for `shadow`, `seamBleed`, and `strategies`. + +`` supports `src`, `mtl`, `mesh-resolution`, `position`, `scale`, +`rotation`, `auto-center`, `cast-shadow`, `receive-shadow`, plus the OBJ-only +parse attributes `target-size`, `default-color`, `palette`, `include-objects`, +`exclude-objects`. `position`, `scale`, `rotation`, `cast-shadow`, and +`receive-shadow` update live; changing `src`, `mtl`, `mesh-resolution`, or an +OBJ parse attribute tears the mesh down and reloads it; `auto-center` is read at +load only. There is **no** `polygons` attribute — use `` or the +imperative API. + +Note `mesh-resolution` threads into the **parse** only; the element's own +`scene.add` call always renders at the default resolution. Use the imperative +API when you need to control both passes. + +## Lifecycle + +```ts +const camera = createPolyCamera({ rotX: 65, rotY: 45 }); +const scene = createPolyScene(host, { camera }); +const result = await loadMesh("/model.glb"); +const handle = scene.add(result, { position: [0, 0, 10] }); + +handle.remove(); +scene.destroy(); // removes the scene and disposes registered meshes +result.dispose(); // revokes blob URLs if you kept the result yourself +``` + +`` / `` / `usePolyMesh` dispose automatically. + +## Helpers + +| Helper | Props | +|---|---| +| `` / `PolyAxesHelper` | `size`, `thickness`, `negative`, `xColor`, `yColor`, `zColor` | +| `` / `PolyDirectionalLightHelper` | React/Vue: `light`, `target`, `distance`, `size`, `color`. Vanilla: `direction`, `target`, `distance`, `size`, `color`. | + +## `` / `` + +A live document rendered as a flat quad in the scene, using the same +`position` / `rotation` / `scale` conventions as a mesh; content is centered on +the wrapper's local origin so rotation and scale pivot at the visible center. + +`width` and `height` are **world units**, not pixels — the mounted document is +`width × 50` by `height × 50` CSS px, so `16 × 9` yields an 800 × 450 px page. + +```html + + + +``` + +## Snapshot export + +`exportPolySceneSnapshot(target)` lives in `@layoutit/polycss` only (it is +browser DOM serialization, not component API). React/Vue callers import it from +there and pass the rendered `.polycss-camera` / `.polycss-scene` element. + +```ts +import { exportPolySceneSnapshot } from "@layoutit/polycss"; +const html = await exportPolySceneSnapshot(scene.host); +``` + +It clones the rendered DOM, injects only the CSS that snapshot needs, inlines +`url(...)` images as data URIs, strips scripts and inline handlers, and returns +a standalone HTML document string with no PolyCSS runtime import. Throws +`PolySceneSnapshotError` with `code: "ASSET_INLINE_FAILED"` if an asset cannot +be inlined. diff --git a/website/public/skill/docs/shadows.md b/website/public/skill/docs/shadows.md new file mode 100644 index 000000000..881cb9a0d --- /dev/null +++ b/website/public/skill/docs/shadows.md @@ -0,0 +1,128 @@ +# Shadows + +Cast shadows are **CPU-projected SVG surfaces**, not render-strategy leaves. +Casting polygons are projected onto scene-level receiver surfaces and emitted as +``/`` nodes. They work in both lighting modes; dynamic-mode shadows +are directional-only. + +```ts +scene.add(model, { castShadow: true }); +scene.add(floor, { receiveShadow: true }); +scene.setOptions({ + shadow: { color: "#000000", opacity: 0.3, parametric: true, definition: 32 }, +}); +``` + +## Receivers differ by renderer — this is the #1 shadow gotcha + +- **Vanilla has no ground fallback.** A `castShadow` mesh draws *nothing* until + some mesh in the scene has `receiveShadow: true`. This was dropped for + Three.js parity. You must add a receiver: + + ```ts + scene.add(createPolyPlane({ axis: 2, size: 60, offset: 0, color: "#7d848e" }), + { receiveShadow: true }); + ``` + +- **React/Vue additionally project onto a per-mesh ground plane** when a caster + has no receiver, and drop that fallback as soon as any receiver exists. This + is what `` relies on — `PolyGround` has **no** `receiveShadow` + prop. + +Reconciling the two is an open decision; write code that works under both by +adding an explicit receiver. + +## `shadow` options + +| Key | Default | Meaning | +|---|---|---| +| `color` | `"#000000"` | | +| `opacity` | `0.25` | | +| `lift` | `0.05` | Offset above the receiver plane to avoid z-fighting. | +| `maxExtend` | `2000` | SVG extent cap. | +| `parametric` | `false` | Swap exact projection for a cheap low-resolution silhouette. | +| `definition` | `16` | Parametric detail. Higher = sharper + more DOM. | +| `style` | `"vector"` | `"vector"` traces a smooth contour; `"pixel"` greedy-meshes the coverage mask into blocky rectangles. | +| `followAnimation` | `false` | Track an animated caster's pose instead of freezing its shadow. | +| `dragDefinition` | none | **Vanilla only.** Progressive refinement during a light drag. | + +Per-mesh `shadowDefinition` overrides `shadow.definition` for one mesh (vanilla +`PolyMeshTransform` field, React `shadowDefinition` prop, Vue +`shadow-definition` prop). + +## Parametric shadows + +Opt in with `shadow.parametric: true`. Per caster, the light-perpendicular +coverage is rasterised into a mask (resolution scales with `definition`), traced +with marching squares, simplified, and lifted back to 3D. A complex caster then +emits far fewer shadow-path vertices. + +`definition` is the knob for resolving fine concave holes; higher trades DOM +weight for fidelity. `style: "pixel"` makes holes fall out for free as absent +cells and turns `definition` into the pixel-grid resolution (lower = chunkier — +the block size is the aesthetic). + +Point lights are supported: each shadow-casting point light gets its own radial +override silhouette. + +Parametric is an approximation with named correction terms: flat casters route +to the exact path, convex casters skip self-shadow, self-shadow bands are +depth-biased, and coverage holes are emitted with opposite winding so they +subtract. It does not change the exact path, which remains the default. + +## Colored shadows + +Shadows are **shaded, not flat black**. Each light's shadow is filled with the +receiver lit by every *other* light (the blocked light removed), so a region +shadowed from one colored light still shows the remaining lights' color — three.js +colored-shadow semantics. A lone directional light reduces this to the +ambient-only fill. + +All of a receiver face's lights are merged into one SVG per face so overlapping +shadows composite correctly. + +## Point-light shadows + +Each `pointLights` entry with `castShadow: true` casts an additional **radial** +shadow (each vertex projected along its own ray from the light position). Point +shadows are **baked mode only**, like point-light shading. + +## Cost + +- Cross-mesh and floor shadows are cheap: one outline, ~1 receiver face. They + follow a moving light at 60fps+. +- Camera orbit is **free** — shadows ride the scene transform. Only light or + geometry changes re-emit. +- **Self-shadow is the expensive case** (caster = receiver): it projects every + depth band onto every coplanar face of the same mesh. Reduce quality during + motion rather than looking for a faster projector. + +Levers for smooth interaction: + +- Per-mesh `shadowDefinition` — a detailed caster stays sharp while a simple + prop runs cheap in the same scene. +- `shadow.dragDefinition` (vanilla) — emits at `min(definition, dragDefinition)` + while the light *direction* changes, then a debounced pass re-emits at full + `definition` once the light settles. Auto-detected in `setOptions`: a + direction change counts as motion; an appearance edit renders full + immediately. +- React/Vue get the same effect idiomatically: lower `shadow.definition` in your + own state during the drag and restore it at rest. + +## Animated casters + +A caster's shadow **freezes** during a same-topology deform by default — +re-projecting every frame is expensive. `shadow.followAnimation: true` opts into +tracking the pose; pair it with a low parametric `definition`. Topology changes +(different polygon count) always re-emit regardless. + +## Notes + +- Every polygon casts. Casters are *not* filtered to the camera-rendered set — + a polygon casts regardless of whether it is painted for the camera. +- Coincident/back-to-back duplicate faces are pre-dropped. +- Light-back-facing caster polygons are normally culled (correct for clean + closed meshes). Self-shadow casters and unreliable-silhouette cross-mesh + casters cast double-sided so badly-wound interior walls don't leave holes. +- Moving a light or changing geometry re-emits the shadow SVGs. This is DOM/SVG + work only and does **not** redraw texture atlases. diff --git a/website/public/skill/docs/shapes-and-primitives.md b/website/public/skill/docs/shapes-and-primitives.md new file mode 100644 index 000000000..e5f82985d --- /dev/null +++ b/website/public/skill/docs/shapes-and-primitives.md @@ -0,0 +1,198 @@ +# Shapes and Primitives + +Three layers, same geometry: + +| Layer | Form | Returns | +|---|---|---| +| Core generators | `boxPolygons(opts)` | `Polygon[]` | +| Vanilla factories | `createPolyBox(opts)` | `ParseResult` for `scene.add(...)` | +| Components | `` / `` | Mounted mesh | + +Core generators are exported from every package (`@layoutit/polycss`, +`-react`, `-vue`, `-core`). Use them when you want raw arrays to post-process. + +## Options and defaults + +Defaults below are the generator defaults. Colors default to `#cccccc` unless +noted. + +### `boxPolygons` / `createPolyBox` / `PolyBox` + +```ts +{ + size?: number | Vec3; // default 1×1×1 + center?: Vec3; // default origin + min?: Vec3; max?: Vec3; // explicit bounds — win over size/center + // BoxFaceOptions, applied to every face: color | texture | material | uvs | data + color?: string; + texture?: string; + material?: PolyMaterial; + uvs?: [number, number][]; + data?: Record; + // Per-face override. BoxFace = "right" | "left" | "front" | "back" | "top" | "bottom". + faces?: Partial>; // false omits the face +} +``` + +```ts +const polygons = boxPolygons({ + min: [0, 0, 0], + max: [2, 1, 0.5], + color: "#d8d2c7", + data: { tileId: "tile-1" }, + faces: { top: { texture: "/tile.png", data: { face: "top" } }, bottom: false }, +}); +``` + +### `planePolygons` / `createPolyPlane` / `PolyPlane` + +`axis` is **required**. + +```ts +{ + axis: 0 | 1 | 2; // perpendicular axis: 0=YZ, 1=XZ, 2=XY plane + size?: number; // HALF-extent along each in-plane axis, default 0.4 + offset?: number | [number, number]; // in-plane center, default `size * 2` + along?: number; // position along the perpendicular axis, default 0 + color?: string; +} +``` + +Two traps: `size` is a **half-extent**, and `offset` defaults to `size * 2`, so +a plane you expected at the origin lands in the `+A/+B` corner. For a centered +ground plane pass `offset: 0` explicitly: + +```ts +createPolyPlane({ axis: 2, size: 60, offset: 0, color: "#7d848e" }); +``` + +### `spherePolygons` / `createPolySphere` / `PolySphere` + +```ts +{ radius?: number; // default 50 + subdivisions?: number; // default 1 (80 triangles); clamped to 0..3 + color?: string; } +``` + +Subdivision 0 = 20 triangles, each level quadruples: 1 → 80, 2 → 320, 3 → 1280. +The cap at 3 is deliberate — DOM cost. + +### `cylinderPolygons` / `conePolygons` + +```ts +// cylinder +{ radius?: number; // bottom cap, default 50 + radiusTop?: number; // defaults to `radius`; 0 makes a cone + height?: number; // along Z, default 100 + radialSegments?: number; // default 12 + color?: string; } + +// cone === cylinder with radiusTop: 0 +{ radius?: number; height?: number; radialSegments?: number; color?: string; } +``` + +### `torusPolygons` + +```ts +{ radius?: number; // center-to-tube-center, default 50 + tube?: number; // tube radius, default 15 + radialSegments?: number; // around the ring, default 12 + tubularSegments?: number; // around the cross-section, default 16 + color?: string; } +``` + +### `ringPolygons` + +`axis` and `radius` are **required**. + +```ts +{ axis: 0 | 1 | 2; // perpendicular axis + radius: number; // mid-radius of the annulus band + halfThickness?: number; // band spans radius ± halfThickness + segments?: number; + color?: string; } +``` + +### Platonic solids + +`tetrahedronPolygons`, `icosahedronPolygons`, `dodecahedronPolygons`: + +```ts +{ size?: number; // circumradius, default 100 + color?: string; } +``` + +`octahedronPolygons` differs — `center` and `size` are both **required**: + +```ts +{ center: Vec3; size: number; color?: string; } // size = half-extent +``` + +### Other generators + +`axesHelperPolygons`, `arrowPolygons`, `ringQuadPolygons`. + +## Usage + +```ts +// Vanilla +scene.add(createPolyBox({ size: 100, color: "#ffd166" }), { position: [0, 0, 50] }); +scene.add(createPolySphere({ radius: 40, subdivisions: 2, color: "#7dd3fc" })); +scene.add(createPolyTorus({ radius: 60, tube: 18, color: "#4ecdc4" })); +``` + +```tsx +// React / Vue — geometry options plus the common mesh props + + + + + +``` + +```html + + + + + +``` + +Shape components accept their geometry options plus the common mesh props +(`position`, `scale`, `rotation`, `autoCenter`, `id`, and event props where +supported). + +## The single-polygon primitive + +`` (vanilla) and `` (React/Vue) render one polygon as one +leaf. They forward standard DOM props (`onclick`, `class`, `style`, `aria-*`). +Neither normalizes its input — see [authoring-polygons.md](authoring-polygons.md). + +```html + +``` + +```tsx + + +``` + +## Shared materials + +Use `material` when several polygons share one texture identity. React and Vue +export `usePolyMaterial` to keep that object stable across rerenders: + +```tsx +const material = usePolyMaterial({ texture: "/stone.png", key: "stone" }); +; +``` + +`material.texture` wins over a polygon's own `texture`. + +## Text + +`@layoutit/polycss-fonts` turns text into extruded 3D `Polygon[]`: +`textPolygons(font, text, { depth, profile })` for basic extrusion, +`composeText(...)` for the multi-line/warp composer, plus `loadGoogleFont` and +`listGoogleFonts`. Framework-agnostic — feed the result to `scene.add(...)` or +``. diff --git a/website/public/skill/docs/textures.md b/website/public/skill/docs/textures.md new file mode 100644 index 000000000..6415e3aba --- /dev/null +++ b/website/public/skill/docs/textures.md @@ -0,0 +1,100 @@ +# Textures + +A polygon is textured when it has `texture` (or `material.texture`) plus `uvs`, +one UV pair per vertex. `material.texture` wins over `texture`. + +## The atlas pipeline + +Rasterisation happens **once**, at mount, not per frame: + +1. Extract or fetch the texture image. +2. Solve a 6-DOF affine transform from the polygon's UVs to its 2D footprint. +3. Pack polygon footprints into one or more atlas pages. +4. Clip, draw texture pixels or shaded color fills, and export pages to blob + URLs via `canvas.toBlob()`. +5. Repair antialiased pixels along shared textured edges, then render each + polygon as an `` leaf with `background-image` / `-size` / `-position`. + +Atlas blob URLs are revoked on unmount or `dispose()`. + +Flat-color polygons bypass the atlas entirely when they can render as CSS +solids or `border-shape` polygons — that is the cheap path, and the mesher +should aim for it. + +## Fill ratio + +A textured polygon's atlas slice equals its **local-2D bounding rect**. Empty +space inside that rect is wasted bitmap memory. + +- axis-aligned rectangle → 1.0 (and the fastest path) +- right-isosceles triangle → 0.5 +- skinny/long triangle → ≪ 0.5, the worst case + +Many skinny textured triangles balloon atlas memory. This is the main reason to +prefer rectangle-friendly meshing. + +## `textureQuality` + +Default `"auto"`. Auto starts from the packed atlas area, caps oversized runtime +bitmaps by page side length and decoded-memory budget, and chooses the fixed CSS +sprite size used by atlas leaves: **128px** for desktop-class auto (avoids +Safari/Firefox compositor flattening artifacts), **64px** for mobile-class auto +and for explicit numeric quality. + +Numeric values override the raster scale: `0.5` uses about a quarter of the +atlas bitmap memory of `1`. Use `0.5`–`0.75` for distant or dense assets, `1` +for close-up inspection. Numeric quality keeps the 64px sprite size. + +```html + +``` + +```tsx + + {/* React/Vue per-mesh */} +``` + +## Texture presentation options + +Scene defaults, overridable per mesh in React/Vue: + +| Option | Values | Default | Meaning | +|---|---|---|---| +| `textureBackend` | `"auto" \| "atlas" \| "image"` | `"auto"` | `"auto"` **always resolves to the atlas today.** Direct image leaves require an explicit `"image"`. | +| `textureImageRendering` | `"auto" \| "pixelated"` | `"auto"` | CSS image filtering. Use `"pixelated"` for pixel-art textures. | +| `textureProjection` | `"affine" \| "projective"` | `"affine"` | Projection request for textured quads. | +| `textureLeafSizing` | `"canonical" \| "local" \| "raster"` | `"canonical"` | Leaf CSS primitive sizing. **Scene/atlas-wide — no per-polygon override.** | + +Precedence for a given polygon: scene defaults → `material.presentation` → +source `imageRendering` → `polygon.texturePresentation`. + +Direct image leaves (`backend: "image"` with `textureImageSource`) skip atlas +rasterisation and use the caller's source URL and source rect directly. They +**preserve source lighting** (`texturePresentation.lighting = "source"`) — a +scene-lit direct image falls back to the atlas path. Use them for source-exact +surfaces; use the atlas when you want scene lighting. + +Atlas position/size, image position/size, filtering, readiness, projection, and +source rect are exposed as PolyCSS-owned metadata. Read them with +`resolvePolyTextureLeafGeometry`, `resolvePolyTextureImageSource`, +`resolvePolyTexturePresentation`, and `resolvePolyTextureImageRendering` rather +than parsing style strings. + +## Seams + +Shared textured edges are repaired automatically during atlas generation: +geometry is unchanged, only low-alpha atlas pixels at shared edges are filled +from nearby opaque texels. + +Solid (untextured) shared edges use `seamBleed` instead — see +[scenes-and-cameras.md](scenes-and-cameras.md) for the renderer divergence. The +numeric default `1.5` is the only value that behaves identically everywhere. + +## Readiness + +`collectPolyTextureReadiness(root)` reports whether atlas bitmaps have decoded. +Textured scenes need real settle time before a screenshot — an atlas that has +not loaded yet looks like a rendering regression but is not one. + +React/Vue `atomicAtlas` holds the previous atlas frame until the next is +decoded, then swaps atomically; `onFrameReady` fires on that swap. diff --git a/website/public/skill/docs/three-parity.md b/website/public/skill/docs/three-parity.md new file mode 100644 index 000000000..88b34ddfe --- /dev/null +++ b/website/public/skill/docs/three-parity.md @@ -0,0 +1,126 @@ +# Three.js Parity + +Use the explicit `*/three` subpaths when porting a Three.js scene or generating +code from Three-shaped examples. They are **adapters over PolyCSS**, not a +Three.js runtime dependency — `three` is not installed. + +| Subpath | Contents | +|---|---| +| `@layoutit/polycss-core/three` | Pure math wrappers, camera conversion, lights, transforms. | +| `@layoutit/polycss/three` | The core surface plus vanilla scene helpers (`mountPolyThreeScene`). | +| `@layoutit/polycss-react/three` | `PolyThreePerspectiveCamera`, `PolyThreeOrthographicCamera`, `PolyThreeMesh`. | +| `@layoutit/polycss-vue/three` | Same three components. | + +Three-compatible names are the point here, so these subpaths deliberately break +the `Poly` prefix rule — except the React/Vue components, which keep a +`PolyThree` prefix. + +## Conventions inside the parity surface + +- Coordinates are **Y-up** Three authoring space. +- Object rotations are **radians**, XYZ Euler. +- Cameras are `PerspectiveCamera(fov, aspect, near, far)` or + `OrthographicCamera(left, right, top, bottom, near, far)`. +- Frame with `camera.position.set(...)` and `camera.lookAt(...)`. +- Directional lights use the Three source vector, + `light.target.position` → `light.position`. +- Geometry converts to native PolyCSS coordinates with + `transformPolygonsToPoly`; the Y-up → Z-up axis map is `[x, -z, y]`, so + winding and Lambert lighting stay right-handed. + +Do **not** mix conventions. Inside a parity scene, keep radians and Y-up; the +adapter handles the conversion once. + +## Lighting mode + +`mountPolyThreeScene(...)` defaults `textureLighting` to `"baked"` because baked +Lambert is the Three-parity baseline. Dynamic lighting remains available as an +explicit opt-in for live CSS light changes, but it is not the exact conformance +mode. + +## Imports + +```ts +import { + PerspectiveCamera, + OrthographicCamera, + Object3D, + Vector3, + Euler, + DirectionalLight, + PointLight, + AmbientLight, + transformPolygonsToPoly, + mountPolyThreeScene, +} from "@layoutit/polycss/three"; +``` + +```tsx +import { + PolyThreePerspectiveCamera, + PolyThreeOrthographicCamera, + PolyThreeMesh, + DirectionalLight, +} from "@layoutit/polycss-react/three"; // or "@layoutit/polycss-vue/three" +``` + +## Vanilla example + +```ts +const camera = new PerspectiveCamera(50, 16 / 9, 0.1, 100); +camera.position.set(3, 2, 5); +camera.lookAt(0, 0, 0); + +const object = new Object3D(); +object.rotation.set(0, Math.PI / 4, 0); + +mountPolyThreeScene(document.querySelector("#scene")!, { + camera, + cameraOptions: { viewportHeight: 420 }, + polygons: transformPolygonsToPoly( + boxPolygons({ size: 1, color: "#66aaff" }), + object, + ), +}); +``` + +## React example + +The parity camera wraps a normal `PolyScene`; lights convert with +`toPolyDirectionalLight()`. + +```tsx +import { PolyScene } from "@layoutit/polycss-react"; +import { + DirectionalLight, + PolyThreeMesh, + PolyThreePerspectiveCamera, +} from "@layoutit/polycss-react/three"; + +const sun = new DirectionalLight("#ffffff", 1); +sun.position.set(3, 5, 4); +sun.target.position.set(0, 0, 0); + +export function App() { + return ( + + + + + + ); +} +``` + +## What does not carry over + +The parity surface covers cameras, transforms, lights, and geometry conversion. +It is not a Three.js runtime: materials, shaders, post-processing, raycasting +semantics, and scene-graph traversal are PolyCSS's, not Three's. When a Three +feature has no PolyCSS equivalent, express the intent in native PolyCSS terms +rather than reaching for a missing shim. + +Full reference: https://polycss.com/api/three-parity diff --git a/website/public/skill/docs/troubleshooting.md b/website/public/skill/docs/troubleshooting.md new file mode 100644 index 000000000..cb050886e --- /dev/null +++ b/website/public/skill/docs/troubleshooting.md @@ -0,0 +1,77 @@ +# Troubleshooting + +Most PolyCSS failures are **silent**. Nothing throws, nothing logs, the geometry +is simply wrong or absent. Work the symptom table before adding instrumentation. + +## Symptom → cause + +| Symptom | Most likely cause | +|---|---| +| A face is missing from the viewpoint it was built for, but shows from behind | **Winding.** Reverse the vertex order (and `uvs` with it). | +| A face disappears when the camera moves behind it | Correct behavior. Backface culling is per-leaf and there is no double-sided flag. Emit two polygons if you need both sides. | +| Everything renders **white** | `color` is not `#rgb` / `#rrggbb` / `rgb()` / `rgba()`. Named colors and `hsl()` fail silently. | +| Everything renders `#cccccc` | Same cause, but on the `` path, which substitutes a fallback color. | +| A polygon you authored is simply absent from the DOM | Degenerate: <3 vertices, zero area, or a duplicate first edge. | +| Cracks or gaps between neighbouring quads | A non-coplanar n-gon was flattened onto its average plane. Use triangles, or snap shared vertices and propagate. | +| Geometry looks different from what you passed in | The optimizer ran (`merge` defaults `true`, `meshResolution` `"lossy"`). Pass `{ merge: false }`. | +| `merge: false` still changes the mesh | `loadMesh` already optimized at parse time. Call `parseObj`/`parseStl`/`parseGltf`/`parseVox` directly, then add with `merge: false`. | +| **No shadow appears at all** in vanilla | Vanilla has no ground fallback. Add a mesh with `receiveShadow: true`. | +| Shadow works in React but not vanilla | Same cause — React/Vue have the ground-plane fallback, vanilla does not. | +| Shadow vanished when you added a floor in React/Vue | Adding any receiver disables the ground fallback. Set `receiveShadow` on that floor. | +| Point lights do nothing | `textureLighting: "dynamic"` ignores `pointLights` entirely — shading and shadows. Switch to `"baked"`. | +| Moving the light doesn't change the surface (vanilla) | Baked mode does not auto-rebake on a `directionalLight` change. Call `mesh.rebakeAtlas()`, typically at drag-end. Shadows still move; the lit surface does not. | +| Shadows move but the surface stays lit the old way | Same as above — expected, and the reason the escape hatch exists. | +| Shadow doesn't follow an animated mesh | Shadows freeze during a same-topology deform. Set `shadow.followAnimation: true` and lower `definition`. | +| A texture renders blurry when it should be crisp | Set `textureImageRendering: "pixelated"`. | +| Textures look missing right after mount | The atlas is still decoding. Check `collectPolyTextureReadiness(root)`; textured scenes need real settle time before a screenshot. | +| `textureBackend: "auto"` didn't give a direct image leaf | `"auto"` always resolves to the atlas today. Request `"image"` explicitly. | +| A direct image leaf ignores scene lighting | By design — direct image leaves are source-lit. Use the atlas backend for scene lighting. | +| `PolyScene` throws | It must be nested inside a camera component. | +| The scene is invisible / flat / wrong scale | Check camera nesting (camera outer, scene inner), then `zoom` (CSS px per world unit, default `0.65`) and `targetSize`. | +| `` or `target` changes do nothing | Only `perspective`, `rot-x`, `rot-y`, `zoom` are observed on the implicit camera. Change one of those, or use the imperative API. | +| `` change does nothing | Read at load only. | +| Per-polygon click handlers stopped firing after a change | Polygons got merged into one leaf. Use `merge: false`. | +| Animation plays but the mesh flickers or re-mounts | Topology is not stable. Vanilla needs `{ merge: false, stableDom: true }`; React/Vue need `meshResolution="lossless"` / `merge={false}`. | +| Animated colors "pump" between frames | Expected — color is pinned to the baked value during animation on purpose. | +| Frame rate collapses on orbit | Too many mounted leaves. Reduce polygon count first; camera motion itself is a single-ancestor transform and should be free. | +| Frame rate collapses when dragging a light | Self-shadow recompute. Use `shadow.parametric` with a lower `definition`, `dragDefinition` (vanilla), or lower `definition` in state during the drag. | +| Blob URL / broken texture after a remount | A revoked atlas URL was held across remounts. Never cache them; let `dispose()` run. | +| Import fails to resolve in a React/Vue app | Do not import `@layoutit/polycss` there. Core names come from the framework package; anything missing comes from `@layoutit/polycss-core`. | +| `exportPolySceneSnapshot` not found in React/Vue | It only exists in `@layoutit/polycss`. Import it there and pass the rendered element. | +| `PolySceneSnapshotError` with `ASSET_INLINE_FAILED` | An asset referenced by the snapshot could not be inlined (usually CORS). | + +## Debug checklist + +1. **Is the leaf in the DOM?** Inspect with devtools or `queryPolyLeaves(root)`. + Absent → degenerate polygon or culling. Present but invisible → winding, + color, or transform. +2. **What strategy did it get?** `collectPolyRenderStats(root)`. An unexpected + `` count means solid polygons are falling through — check + `strategies.disable` and browser support. +3. **Does it render with `strategies={{ disable: ["b","i","u"] }}`?** If yes, the + bug is in a solid strategy or its browser support, not your geometry. +4. **Does it render with `merge: false`?** If yes, the optimizer changed it. +5. **Does it render with a flat ambient light only?** If yes, the problem is a + normal — which means winding. + +## Browser-specific behaviour + +Some strategies are engine-gated, so the leaf mix legitimately differs across +browsers: + +- Projective quads and border triangles fall through to `` on + WebKit/Safari — transformed projective rectangles and CSS border triangles + composite incorrectly there. +- `` (`border-shape`) requires Chromium with a fine pointer and hover. +- Firefox uses a larger border-triangle primitive to avoid compositor banding. + +A leaf-count difference between Chrome and Safari is expected. A *visual* +difference is not. + +## Things that are not bugs + +- Backface culling hiding a single-sided face from behind. +- Vanilla's baked surface not updating on a light drag. +- Vanilla drawing no shadow without a `receiveShadow` mesh. +- Voxel meshes mounting only camera-facing leaves. +- Merged regions losing per-polygon DOM addressing. From 66ec7208249fe80148625c7366d86d6a67a3bcfc Mon Sep 17 00:00:00 2001 From: Juan Cruz Fortunatti Date: Thu, 13 Aug 2026 01:59:44 +0200 Subject: [PATCH 02/37] test(skills): add browser-graded skill evaluation harness with agent adapters --- .gitignore | 4 + eval/skill/README.md | 108 +++++ eval/skill/agents.mjs | 93 ++++ eval/skill/harness/index.html | 126 +++++ eval/skill/oracle/01-static-cube.mjs | 13 + eval/skill/oracle/02-orbiting-cube.mjs | 24 + eval/skill/oracle/03-cube-with-shadow.mjs | 28 ++ eval/skill/oracle/04-two-shapes.mjs | 22 + .../oracle/05-hand-authored-polygons.mjs | 43 ++ eval/skill/oracle/06-composed-scene.mjs | 39 ++ eval/skill/run.mjs | 285 +++++++++++ eval/skill/selftest.mjs | 202 ++++++++ eval/skill/tasks.mjs | 459 ++++++++++++++++++ eval/skill/verify.mjs | 203 ++++++++ package.json | 2 + 15 files changed, 1651 insertions(+) create mode 100644 eval/skill/README.md create mode 100644 eval/skill/agents.mjs create mode 100644 eval/skill/harness/index.html create mode 100644 eval/skill/oracle/01-static-cube.mjs create mode 100644 eval/skill/oracle/02-orbiting-cube.mjs create mode 100644 eval/skill/oracle/03-cube-with-shadow.mjs create mode 100644 eval/skill/oracle/04-two-shapes.mjs create mode 100644 eval/skill/oracle/05-hand-authored-polygons.mjs create mode 100644 eval/skill/oracle/06-composed-scene.mjs create mode 100644 eval/skill/run.mjs create mode 100644 eval/skill/selftest.mjs create mode 100644 eval/skill/tasks.mjs create mode 100644 eval/skill/verify.mjs diff --git a/.gitignore b/.gitignore index 689dbaf02..486944d43 100644 --- a/.gitignore +++ b/.gitignore @@ -27,3 +27,7 @@ website/.baseline-shots/ # chrome-trace skill raw output chrome-trace.json /tmp/*.trace.json + +# skill evaluation scratch +eval/skill/.work/ +eval/skill/.generated/ diff --git a/eval/skill/README.md b/eval/skill/README.md new file mode 100644 index 000000000..e76e04d59 --- /dev/null +++ b/eval/skill/README.md @@ -0,0 +1,108 @@ +# Skill evaluation + +Does an agent that has only the PolyCSS skill actually write correct PolyCSS? + +This harness answers that empirically. It hands a coding agent a throwaway +workspace containing nothing but the installed skill and a task, runs the +agent's real CLI, then grades **what the resulting scene actually paints in +Chromium** — not what the agent said it did. + +```bash +pnpm eval:skill --agent oracle # reference solutions (should be 100%) +pnpm eval:skill --agent claude # one agent, all tasks +pnpm eval:skill --agent all --json out.json # every agent on PATH +pnpm eval:skill --task 03-cube-with-shadow --agent claude,codex +pnpm eval:selftest # prove the graders can fail +``` + +## How a run works + +For each (agent, task) pair: + +1. **Isolate.** A fresh directory gets `npx polycss-skills --agent all` and a + `TASK.md`. Nothing else — no repo, no examples, no existing scenes. A good + score can only have come from the skill. +2. **Run the agent** non-interactively in that directory. The task asks for one + file, `scene.mjs`, exporting `mount(host)`. +3. **Build** it with esbuild, aliasing `@layoutit/polycss` to workspace source. + An import that does not exist is a build error here — invented exports fail + loudly instead of mysteriously. +4. **Render** it in Chromium and sample the result twice, ~1.2s apart, so + motion is observable. +5. **Grade** with the task's checks. + +## Grading on pixels, not DOM boxes + +The graders sample painted pixels (Chromium decodes the screenshot inside the +page, so no image library is needed), plus a few structural DOM facts like mesh +count and shadow paths. + +This is not a stylistic preference. Leaf strategies other than `` carry +`backface-visibility: hidden`, so a back-facing leaf **keeps its bounding rect +while painting nothing**. An earlier rect-based grader scored a fully reversed +mesh as perfectly visible. Only pixels survive that. + +## Tasks + +| Task | What it really tests | +|---|---| +| `01-static-cube` | Camera/scene nesting, a primitive, a parseable color, and *not* animating when nothing asked for it. | +| `02-orbiting-cube` | Reaching for `createPolyOrbitControls` instead of hand-rolling a `requestAnimationFrame` loop. | +| `03-cube-with-shadow` | The vanilla no-ground-fallback trap: a caster with no `receiveShadow` mesh draws nothing. | +| `04-two-shapes` | Two meshes, two colors, positioned in world space so neither hides the other. | +| `05-hand-authored-polygons` | Winding. Four flat tiles wound CCW from above; any tile wound the other way is backface-culled and paints nothing. | +| `06-composed-scene` | Everything at once: ground, three primitives, lights, shadows, controls. | + +### Why tiles and not a pyramid + +The winding task started as a hand-authored pyramid, and reversing **every** +face changed the image by 0.05%. A closed solid rendered inside-out looks +almost identical — the near faces vanish and the far faces appear in the same +silhouette. Measured, not assumed: + +| pyramid | painted | shades | +|---|---|---| +| correct winding | 13.14% | 5 | +| one face reversed | 13.16% | 5 | +| two faces reversed | 8.94% | 4 | +| every face reversed | 13.09% | 5 | + +Winding is only *observable* on surfaces that are single-sided from the +viewpoint, which is what four open tiles give. The underlying rule was +confirmed separately: one tilted triangle wound away from the camera mounts its +leaf and paints zero pixels. + +## Trusting the graders + +A check that cannot fail measures nothing. `pnpm eval:selftest` takes each +reference solution, injects one specific mistake the skill warns about, and +asserts the matching check catches it *and* that the scene still mounts — so a +mutation cannot "pass" by breaking everything. + +Current controls: reversed winding, a CSS named color, a shadow caster with no +receiver, a missing autorotate, an unwanted autorotate, overlapping shapes, a +shape helper used where the task demanded hand-authored geometry, and an +import that does not exist. + +## Adding an agent + +One entry in `agents.mjs` with the CLI's non-interactive flags. Agents run with +approvals bypassed because the workspace is a throwaway temp directory. CLI +flags drift between releases; if one adapter breaks, fix that `argv` and +nothing else. + +## Adding a task + +Add to `tasks.mjs` and drop a reference solution in `oracle/.mjs`. The +oracle must score 100% — if it does not, the grader is wrong, not the agent. +Add a mutation to `selftest.mjs` for any new check that could silently pass. + +## Interpreting results + +`--agent oracle` scoring below 100% is a **harness** bug. A real agent scoring +below 100% is a finding: read the failure reason, then decide whether the skill +failed to say something, said it somewhere the agent did not look, or the agent +ignored it. The first two are fixable in `packages/skills/skill/`. + +Use `--keep` to leave the workspaces in `.work/` and read what the agent +actually wrote. diff --git a/eval/skill/agents.mjs b/eval/skill/agents.mjs new file mode 100644 index 000000000..36f7572a8 --- /dev/null +++ b/eval/skill/agents.mjs @@ -0,0 +1,93 @@ +/** + * Agent adapters for the skill evaluation. + * + * Each adapter turns a prompt + workspace directory into a non-interactive CLI + * invocation. Every agent runs with approvals bypassed because the workspace is + * a throwaway temp directory containing nothing but the task and the installed + * skill — see run.mjs, which builds it. + * + * Adding an agent is one entry here. Flags drift between CLI releases; if an + * adapter stops working, fix the `argv` for that one agent and nothing else. + */ +import { execFileSync } from "node:child_process"; + +/** Resolve a CLI on PATH without throwing. */ +function which(bin) { + try { + return execFileSync("command", ["-v", bin], { + shell: "/bin/sh", + encoding: "utf8", + stdio: ["ignore", "pipe", "ignore"], + }).trim(); + } catch { + return null; + } +} + +export const AGENTS = { + claude: { + label: "Claude Code", + bin: "claude", + argv: (prompt) => ["--print", "--dangerously-skip-permissions", prompt], + }, + codex: { + label: "Codex", + bin: "codex", + argv: (prompt, cwd) => [ + "exec", + "--sandbox", + "workspace-write", + "--skip-git-repo-check", + "--cd", + cwd, + prompt, + ], + }, + gemini: { + label: "Gemini CLI", + bin: "gemini", + argv: (prompt) => ["--prompt", prompt, "--yolo", "--skip-trust"], + }, + grok: { + label: "Grok", + bin: "grok", + argv: (prompt, cwd) => ["--single", prompt, "--always-approve", "--cwd", cwd], + }, + /** + * Not an agent: writes the reference solution straight into the workspace. + * This is how we test the harness itself — `oracle` must score 100% on every + * task, or a failure elsewhere is the grader's fault, not the agent's. + */ + oracle: { + label: "Reference solution", + bin: null, + argv: null, + }, +}; + +export const AGENT_NAMES = Object.keys(AGENTS); + +export function isAvailable(name) { + const agent = AGENTS[name]; + if (!agent) return false; + if (agent.bin === null) return true; + return which(agent.bin) !== null; +} + +export function availableAgents() { + return AGENT_NAMES.filter(isAvailable); +} + +/** Expand `all` and comma-separated lists into a deduped agent name list. */ +export function expandAgents(values) { + const out = []; + for (const value of values) { + for (const part of value.split(",")) { + const name = part.trim(); + if (!name) continue; + if (name === "all") out.push(...availableAgents().filter((n) => n !== "oracle")); + else out.push(name); + } + } + return [...new Set(out)]; +} diff --git a/eval/skill/harness/index.html b/eval/skill/harness/index.html new file mode 100644 index 000000000..60a685b89 --- /dev/null +++ b/eval/skill/harness/index.html @@ -0,0 +1,126 @@ + + + + + PolyCSS skill eval harness + + + +
+ + + diff --git a/eval/skill/oracle/01-static-cube.mjs b/eval/skill/oracle/01-static-cube.mjs new file mode 100644 index 000000000..cfe8b74db --- /dev/null +++ b/eval/skill/oracle/01-static-cube.mjs @@ -0,0 +1,13 @@ +import { createPolyBox, createPolyCamera, createPolyScene } from "@layoutit/polycss"; + +export function mount(host) { + const camera = createPolyCamera({ rotX: 65, rotY: 45, zoom: 3 }); + const scene = createPolyScene(host, { + camera, + directionalLight: { direction: [0.5, -0.6, 0.7], color: "#ffffff", intensity: 1 }, + ambientLight: { color: "#ffffff", intensity: 0.4 }, + }); + + scene.add(createPolyBox({ size: 100, color: "#ff8c1a" })); + return scene; +} diff --git a/eval/skill/oracle/02-orbiting-cube.mjs b/eval/skill/oracle/02-orbiting-cube.mjs new file mode 100644 index 000000000..842eff100 --- /dev/null +++ b/eval/skill/oracle/02-orbiting-cube.mjs @@ -0,0 +1,24 @@ +import { + createPolyBox, + createPolyCamera, + createPolyOrbitControls, + createPolyScene, +} from "@layoutit/polycss"; + +export function mount(host) { + const camera = createPolyCamera({ rotX: 65, rotY: 45, zoom: 3 }); + const scene = createPolyScene(host, { + camera, + directionalLight: { direction: [0.5, -0.6, 0.7], color: "#ffffff", intensity: 1 }, + ambientLight: { color: "#ffffff", intensity: 0.4 }, + }); + + scene.add(createPolyBox({ size: 100, color: "#14b8a6" })); + createPolyOrbitControls(scene, { + drag: true, + wheel: true, + animate: { speed: 0.6, axis: "y" }, + }); + + return scene; +} diff --git a/eval/skill/oracle/03-cube-with-shadow.mjs b/eval/skill/oracle/03-cube-with-shadow.mjs new file mode 100644 index 000000000..98e9b9903 --- /dev/null +++ b/eval/skill/oracle/03-cube-with-shadow.mjs @@ -0,0 +1,28 @@ +import { + createPolyBox, + createPolyCamera, + createPolyPlane, + createPolyScene, +} from "@layoutit/polycss"; + +export function mount(host) { + const camera = createPolyCamera({ rotX: 60, rotY: 45, zoom: 2 }); + const scene = createPolyScene(host, { + camera, + directionalLight: { direction: [0.45, -0.55, 0.7], color: "#ffffff", intensity: 1 }, + ambientLight: { color: "#ffffff", intensity: 0.35 }, + shadow: { opacity: 0.35 }, + }); + + // Vanilla has no ground-shadow fallback: without an explicit receiver the + // caster draws nothing at all. + scene.add(createPolyPlane({ axis: 2, size: 160, offset: 0, color: "#94a3b8" }), { + receiveShadow: true, + }); + scene.add(createPolyBox({ size: 100, color: "#fbbf24" }), { + position: [0, 0, 70], + castShadow: true, + }); + + return scene; +} diff --git a/eval/skill/oracle/04-two-shapes.mjs b/eval/skill/oracle/04-two-shapes.mjs new file mode 100644 index 000000000..ce3da59c4 --- /dev/null +++ b/eval/skill/oracle/04-two-shapes.mjs @@ -0,0 +1,22 @@ +import { + createPolyBox, + createPolyCamera, + createPolyScene, + createPolySphere, +} from "@layoutit/polycss"; + +export function mount(host) { + const camera = createPolyCamera({ rotX: 65, rotY: 45, zoom: 1.6 }); + const scene = createPolyScene(host, { + camera, + directionalLight: { direction: [0.5, -0.6, 0.7], color: "#ffffff", intensity: 1 }, + ambientLight: { color: "#ffffff", intensity: 0.4 }, + }); + + scene.add(createPolyBox({ size: 90, color: "#6366f1" }), { position: [0, -110, 0] }); + scene.add(createPolySphere({ radius: 50, subdivisions: 2, color: "#f43f5e" }), { + position: [0, 110, 0], + }); + + return scene; +} diff --git a/eval/skill/oracle/05-hand-authored-polygons.mjs b/eval/skill/oracle/05-hand-authored-polygons.mjs new file mode 100644 index 000000000..961afd814 --- /dev/null +++ b/eval/skill/oracle/05-hand-authored-polygons.mjs @@ -0,0 +1,43 @@ +import { createPolyCamera, createPolyScene } from "@layoutit/polycss"; + +const COLOR = "#84cc16"; +const SIZE = 70; +const GAP = 12; + +/** + * One flat tile on z = 0, wound counter-clockwise seen from +Z so its normal + * points up at the camera. Reversing any of these four vertex orders flips the + * normal and the tile is backface-culled — it paints nothing. + */ +function tile(cx, cy) { + const h = SIZE / 2; + return { + vertices: [ + [cx - h, cy - h, 0], + [cx + h, cy - h, 0], + [cx + h, cy + h, 0], + [cx - h, cy + h, 0], + ], + color: COLOR, + }; +} + +const step = SIZE + GAP; +const polygons = [ + tile(-step / 2, -step / 2), + tile(step / 2, -step / 2), + tile(step / 2, step / 2), + tile(-step / 2, step / 2), +]; + +export function mount(host) { + const camera = createPolyCamera({ rotX: 58, rotY: 45, zoom: 2.4 }); + const scene = createPolyScene(host, { + camera, + directionalLight: { direction: [0.5, -0.6, 0.7], color: "#ffffff", intensity: 1 }, + ambientLight: { color: "#ffffff", intensity: 0.45 }, + }); + + scene.add({ polygons, objectUrls: [], warnings: [], dispose: () => {} }, { merge: false }); + return scene; +} diff --git a/eval/skill/oracle/06-composed-scene.mjs b/eval/skill/oracle/06-composed-scene.mjs new file mode 100644 index 000000000..c2d3f7a9f --- /dev/null +++ b/eval/skill/oracle/06-composed-scene.mjs @@ -0,0 +1,39 @@ +import { + createPolyBox, + createPolyCamera, + createPolyCylinder, + createPolyOrbitControls, + createPolyPlane, + createPolyScene, + createPolyTorus, +} from "@layoutit/polycss"; + +export function mount(host) { + const camera = createPolyCamera({ rotX: 62, rotY: 45, zoom: 1.1 }); + const scene = createPolyScene(host, { + camera, + directionalLight: { direction: [0.45, -0.55, 0.7], color: "#ffffff", intensity: 1 }, + ambientLight: { color: "#ffffff", intensity: 0.35 }, + shadow: { opacity: 0.3, parametric: true, definition: 24 }, + }); + + scene.add(createPolyPlane({ axis: 2, size: 260, offset: 0, color: "#64748b" }), { + receiveShadow: true, + }); + + scene.add(createPolyBox({ size: 80, color: "#ef4444" }), { + position: [0, -150, 55], + castShadow: true, + }); + scene.add(createPolyCylinder({ radius: 45, height: 110, color: "#3b82f6" }), { + position: [0, 0, 70], + castShadow: true, + }); + scene.add(createPolyTorus({ radius: 55, tube: 18, color: "#eab308" }), { + position: [0, 150, 60], + castShadow: true, + }); + + createPolyOrbitControls(scene, { drag: true, wheel: true }); + return scene; +} diff --git a/eval/skill/run.mjs b/eval/skill/run.mjs new file mode 100644 index 000000000..6c46f1d3a --- /dev/null +++ b/eval/skill/run.mjs @@ -0,0 +1,285 @@ +#!/usr/bin/env node +/** + * Runs the PolyCSS skill evaluation. + * + * For each (agent, task) pair: + * 1. build a throwaway workspace containing ONLY the installed skill and a + * TASK.md — no repo access, no examples, nothing to copy from; + * 2. run the agent's CLI non-interactively in that workspace; + * 3. bundle whatever `scene.mjs` it produced and grade what renders. + * + * The workspace isolation is the whole point. An agent that scores well here + * did it from the skill, not from the surrounding source tree. + * + * Usage: + * node eval/skill/run.mjs --agent oracle + * node eval/skill/run.mjs --agent claude,codex --task 01-static-cube + * node eval/skill/run.mjs --agent all --json results.json + */ +import { execFileSync } from "node:child_process"; +import { + copyFileSync, + mkdirSync, + readFileSync, + rmSync, + writeFileSync, +} from "node:fs"; +import { basename, join, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; + +import { AGENTS, AGENT_NAMES, availableAgents, expandAgents, isAvailable } from "./agents.mjs"; +import { TASK_IDS, selectTasks } from "./tasks.mjs"; +import { verifyCandidates } from "./verify.mjs"; + +const here = resolve(fileURLToPath(import.meta.url), ".."); +const repoRoot = resolve(here, "..", ".."); +const workRoot = join(here, ".work"); +const installer = join(repoRoot, "packages/skills/bin/polycss-skills.mjs"); + +const USAGE = `Usage: node eval/skill/run.mjs [options] + +Options + --agent Comma-separated: ${AGENT_NAMES.join(", ")}, or all. + Default: oracle. + --task Comma-separated task ids. Default: all. + --keep Keep the agent workspaces for inspection. + --headed Run Chromium headed. + --settle Delay between the two DOM samples (default 1200). + --timeout Per-agent-invocation timeout (default 600). + --json Also write the full result as JSON. + --list List agents and tasks, then exit. + +Tasks +${TASK_IDS.map((id) => ` ${id}`).join("\n")} +`; + +function parseArgs(argv) { + const options = { + agents: [], + tasks: [], + keep: false, + headed: false, + settle: 1200, + timeout: 600, + json: null, + list: false, + }; + for (let i = 0; i < argv.length; i += 1) { + const arg = argv[i]; + const value = () => { + const next = argv[i + 1]; + if (next === undefined || next.startsWith("--")) throw new Error(`${arg} requires a value`); + i += 1; + return next; + }; + if (arg === "--agent") options.agents.push(value()); + else if (arg === "--task") options.tasks.push(...value().split(",").map((t) => t.trim())); + else if (arg === "--keep") options.keep = true; + else if (arg === "--headed") options.headed = true; + else if (arg === "--settle") options.settle = Number(value()); + else if (arg === "--timeout") options.timeout = Number(value()); + else if (arg === "--json") options.json = value(); + else if (arg === "--list") options.list = true; + else if (arg === "--help" || arg === "-h") return null; + else throw new Error(`unknown option "${arg}"`); + } + return options; +} + +/** + * A workspace holds the installed skill and the task, and nothing else. Agents + * read their skills directory relative to the working directory, so installing + * both flavours covers every CLI without special-casing. + */ +function makeWorkspace(agent, task) { + const dir = join(workRoot, agent, task.id); + rmSync(dir, { recursive: true, force: true }); + mkdirSync(dir, { recursive: true }); + + execFileSync(process.execPath, [installer, "--cwd", dir, "--agent", "all"], { + stdio: "pipe", + }); + + writeFileSync(join(dir, "TASK.md"), `# Task\n\n${task.prompt}\n`); + return dir; +} + +function runAgent(name, dir, task, timeoutSeconds) { + const agent = AGENTS[name]; + + if (name === "oracle") { + copyFileSync(join(here, "oracle", `${task.id}.mjs`), join(dir, "scene.mjs")); + return { ok: true, ms: 0, output: "reference solution copied" }; + } + + const prompt = `Read TASK.md in this directory and complete it. You have the PolyCSS skill installed in this workspace — use it.\n\n${task.prompt}`; + const started = Date.now(); + try { + const output = execFileSync(agent.bin, agent.argv(prompt, dir), { + cwd: dir, + encoding: "utf8", + timeout: timeoutSeconds * 1000, + maxBuffer: 64 * 1024 * 1024, + stdio: ["ignore", "pipe", "pipe"], + }); + return { ok: true, ms: Date.now() - started, output }; + } catch (error) { + return { + ok: false, + ms: Date.now() - started, + output: `${error.stdout ?? ""}${error.stderr ?? ""}`.trim() || String(error.message), + }; + } +} + +const bar = (passed, total) => { + const filled = total === 0 ? 0 : Math.round((passed / total) * 10); + return `${"#".repeat(filled)}${".".repeat(10 - filled)}`; +}; + +async function main(argv) { + let options; + try { + options = parseArgs(argv); + } catch (error) { + console.error(`${error.message}\n\n${USAGE}`); + return 1; + } + if (options === null) { + console.log(USAGE); + return 0; + } + if (options.list) { + console.log("Agents:"); + for (const name of AGENT_NAMES) { + console.log(` ${name.padEnd(8)} ${isAvailable(name) ? "available" : "not on PATH"}`); + } + console.log(`\nTasks:\n${TASK_IDS.map((id) => ` ${id}`).join("\n")}`); + return 0; + } + + let agents; + let tasks; + try { + agents = expandAgents(options.agents.length > 0 ? options.agents : ["oracle"]); + tasks = selectTasks(options.tasks); + } catch (error) { + console.error(error.message); + return 1; + } + + const missing = agents.filter((name) => !AGENT_NAMES.includes(name)); + if (missing.length > 0) { + console.error(`unknown agent(s): ${missing.join(", ")}\nknown: ${AGENT_NAMES.join(", ")}`); + return 1; + } + const offline = agents.filter((name) => !isAvailable(name)); + if (offline.length > 0) { + console.error( + `not on PATH: ${offline.join(", ")}\navailable: ${availableAgents().join(", ")}`, + ); + return 1; + } + + console.log( + `[eval] ${agents.length} agent(s) x ${tasks.length} task(s) = ${agents.length * tasks.length} run(s)\n`, + ); + + const candidates = []; + + for (const name of agents) { + for (const task of tasks) { + process.stdout.write(`[eval] ${name} / ${task.id} ... `); + const dir = makeWorkspace(name, task); + const run = runAgent(name, dir, task, options.timeout); + + let source = null; + try { + source = readFileSync(join(dir, "scene.mjs"), "utf8"); + } catch { + /* the agent never produced one */ + } + + console.log( + source === null + ? `no scene.mjs (${(run.ms / 1000).toFixed(0)}s)` + : `wrote scene.mjs (${(run.ms / 1000).toFixed(0)}s)`, + ); + + candidates.push({ + key: `${name}__${task.id}`, + agent: name, + task, + dir, + run, + source, + entry: source === null ? null : join(dir, "scene.mjs"), + }); + } + } + + const buildable = candidates.filter((c) => c.entry !== null); + console.log(`\n[eval] grading ${buildable.length} scene(s) in Chromium ...\n`); + + const graded = + buildable.length === 0 + ? [] + : await verifyCandidates(buildable, { headed: options.headed, settleMs: options.settle }); + const byKey = new Map(graded.map((g) => [g.key, g])); + + const rows = candidates.map((candidate) => { + const result = byKey.get(candidate.key); + const checks = + result?.checks ?? + candidate.task.checks.map((check) => ({ + id: check.id, + describe: check.describe, + pass: false, + reason: "not run — the agent produced no scene.mjs", + })); + const passed = checks.filter((c) => c.pass).length; + return { + agent: candidate.agent, + task: candidate.task.id, + wroteScene: candidate.source !== null, + buildOk: result?.build?.ok ?? false, + buildError: result?.build?.ok === false ? result.build.error : null, + seconds: Math.round(candidate.run.ms / 1000), + passed, + total: checks.length, + checks, + }; + }); + + for (const agent of agents) { + const mine = rows.filter((r) => r.agent === agent); + const passed = mine.reduce((n, r) => n + r.passed, 0); + const total = mine.reduce((n, r) => n + r.total, 0); + console.log(`${AGENTS[agent].label} — ${passed}/${total} checks`); + for (const row of mine) { + console.log(` ${bar(row.passed, row.total)} ${row.passed}/${row.total} ${row.task} (${row.seconds}s)`); + if (row.buildError) console.log(` build failed: ${row.buildError}`); + for (const check of row.checks.filter((c) => !c.pass)) { + console.log(` x ${check.id}: ${check.reason}`); + } + } + console.log(); + } + + if (options.json) { + writeFileSync(resolve(options.json), `${JSON.stringify({ rows }, null, 2)}\n`); + console.log(`[eval] wrote ${options.json}`); + } + if (!options.keep) { + rmSync(workRoot, { recursive: true, force: true }); + } else { + console.log(`[eval] workspaces kept in ${workRoot.replace(`${repoRoot}/`, "")}`); + } + + const allPassed = rows.every((r) => r.passed === r.total); + // Only the reference solution is expected to be perfect; a real agent + // scoring below 100% is a finding, not a harness failure. + return agents.length === 1 && agents[0] === "oracle" && !allPassed ? 1 : 0; +} + +process.exitCode = await main(process.argv.slice(2)); diff --git a/eval/skill/selftest.mjs b/eval/skill/selftest.mjs new file mode 100644 index 000000000..8a492cc77 --- /dev/null +++ b/eval/skill/selftest.mjs @@ -0,0 +1,202 @@ +#!/usr/bin/env node +/** + * Negative controls for the skill evaluation. + * + * A grader that never fails grades nothing. Each mutation below takes a + * reference solution, injects one specific mistake the skill explicitly warns + * about, and asserts the matching check catches it — and that the OTHER checks + * stay green, so a mutation cannot pass by breaking the scene wholesale. + * + * Run: `pnpm eval:selftest` (needs Chromium; ~30s). + */ +import assert from "node:assert/strict"; +import { mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { join, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; + +import { TASKS } from "./tasks.mjs"; +import { verifyCandidates } from "./verify.mjs"; + +const here = resolve(fileURLToPath(import.meta.url), ".."); +const scratch = join(here, ".work", "__selftest__"); + +const task = (id) => TASKS.find((t) => t.id === id); +const oracle = (id) => readFileSync(join(here, "oracle", `${id}.mjs`), "utf8"); + +/** Apply a required replacement, failing loudly if the source drifted. */ +function patch(source, from, to) { + if (!source.includes(from)) { + throw new Error(`mutation target not found in oracle source: ${JSON.stringify(from)}`); + } + return source.replace(from, to); +} + +const MUTATIONS = [ + { + // Reversing only SOME faces is the realistic bug and the one that shows. + // Reversing a closed solid's every face is nearly invisible — you simply + // see its inside — which is why the winding task uses open tiles. + name: "tiles wound away from the camera vanish", + taskId: "05-hand-authored-polygons", + expect: "faces-visible", + mutate: (source) => + patch( + source, + ` [cx - h, cy - h, 0], + [cx + h, cy - h, 0], + [cx + h, cy + h, 0], + [cx - h, cy + h, 0],`, + ` [cx - h, cy + h, 0], + [cx + h, cy + h, 0], + [cx + h, cy - h, 0], + [cx - h, cy - h, 0],`, + ), + }, + { + name: "a CSS named color renders white", + taskId: "01-static-cube", + expect: "color#ff8c1a", + mutate: (source) => patch(source, `color: "#ff8c1a"`, `color: "orange"`), + }, + { + name: "a caster with no receiver draws no shadow in vanilla", + taskId: "03-cube-with-shadow", + expect: "shadow-drawn", + mutate: (source) => + patch( + source, + `scene.add(createPolyPlane({ axis: 2, size: 160, offset: 0, color: "#94a3b8" }), { + receiveShadow: true, + });`, + `scene.add(createPolyPlane({ axis: 2, size: 160, offset: 0, color: "#94a3b8" }));`, + ), + }, + { + name: "no orbit controls means no autorotate", + taskId: "02-orbiting-cube", + expect: "moving", + mutate: (source) => + patch( + source, + ` createPolyOrbitControls(scene, { + drag: true, + wheel: true, + animate: { speed: 0.6, axis: "y" }, + });`, + "", + ), + }, + { + name: "autorotate on a supposedly fixed camera", + taskId: "01-static-cube", + expect: "still", + mutate: (source) => + patch( + source, + `import { createPolyBox, createPolyCamera, createPolyScene } from "@layoutit/polycss";`, + `import { + createPolyBox, + createPolyCamera, + createPolyOrbitControls, + createPolyScene, +} from "@layoutit/polycss";`, + ).replace( + ` scene.add(createPolyBox({ size: 100, color: "#ff8c1a" }));`, + ` scene.add(createPolyBox({ size: 100, color: "#ff8c1a" })); + createPolyOrbitControls(scene, { animate: { speed: 2, axis: "y" } });`, + ), + }, + { + name: "overlapping shapes are not side by side", + taskId: "04-two-shapes", + expect: "separated", + mutate: (source) => + patch(source, `{ position: [0, -110, 0] }`, `{ position: [0, 0, 0] }`).replace( + ` position: [0, 110, 0],`, + ` position: [0, 0, 0],`, + ), + }, + { + name: "a shape helper is not hand-authored geometry", + taskId: "05-hand-authored-polygons", + expect: "authored", + mutate: (source) => + patch( + source, + `import { createPolyCamera, createPolyScene } from "@layoutit/polycss";`, + `import { createPolyCamera, createPolyScene, createPolyBox } from "@layoutit/polycss";`, + ).replace( + ` scene.add({ polygons, objectUrls: [], warnings: [], dispose: () => {} }, { merge: false });`, + ` void polygons; + scene.add(createPolyBox({ size: 140, color: COLOR }));`, + ), + }, + { + name: "an export that does not exist fails the build", + taskId: "01-static-cube", + expect: "*", + mutate: (source) => + patch(source, `createPolyScene }`, `createPolyScene, createPolyCube }`).replace( + ` scene.add(createPolyBox({ size: 100, color: "#ff8c1a" }));`, + ` scene.add(createPolyCube({ size: 100, color: "#ff8c1a" }));`, + ), + }, +]; + +rmSync(scratch, { recursive: true, force: true }); +mkdirSync(scratch, { recursive: true }); + +const candidates = MUTATIONS.map((mutation, index) => { + const source = mutation.mutate(oracle(mutation.taskId)); + const dir = join(scratch, String(index)); + mkdirSync(dir, { recursive: true }); + const entry = join(dir, "scene.mjs"); + writeFileSync(entry, source); + return { + key: `mutant-${index}`, + agent: "mutant", + task: task(mutation.taskId), + mutation, + entry, + source, + }; +}); + +console.log(`[selftest] grading ${candidates.length} mutation(s) ...\n`); +const results = await verifyCandidates(candidates, { settleMs: 1200 }); + +let failures = 0; +for (const result of results) { + const { mutation } = result; + const failed = result.checks.filter((c) => !c.pass).map((c) => c.id); + + try { + if (mutation.expect === "*") { + // A build failure must take every check down with it. + assert.equal(result.build.ok, false, "expected the bundle to fail to build"); + assert.equal(failed.length, result.checks.length, "expected every check to be skipped"); + } else { + assert.ok( + failed.includes(mutation.expect), + `expected check "${mutation.expect}" to fail; failing checks were [${failed.join(", ") || "none"}]`, + ); + // Guard against a mutation that "passes" by breaking everything: the + // scene must still mount and paint. + assert.ok( + !failed.includes("mounts") && !failed.includes("scene"), + `mutation broke the scene outright (failed: ${failed.join(", ")})`, + ); + } + console.log(` ok ${mutation.name} -> caught by [${failed.join(", ")}]`); + } catch (error) { + failures += 1; + console.log(` FAIL ${mutation.name}\n ${error.message}`); + } +} + +rmSync(scratch, { recursive: true, force: true }); + +console.log( + `\n[selftest] ${results.length - failures}/${results.length} mutations caught by the graders`, +); +process.exitCode = failures === 0 ? 0 : 1; diff --git a/eval/skill/tasks.mjs b/eval/skill/tasks.mjs new file mode 100644 index 000000000..e8549bda0 --- /dev/null +++ b/eval/skill/tasks.mjs @@ -0,0 +1,459 @@ +/** + * Skill evaluation tasks, ordered by difficulty. + * + * Each task is a prompt plus objective checks over what actually rendered. A + * check returns `true` to pass or a string explaining the failure. Checks never + * inspect the agent's prose — only the built bundle and the live DOM — so a + * confident wrong answer scores zero. + * + * Checks grade painted pixels plus a few structural DOM facts. Pixels are the + * load-bearing evidence: leaves other than `` are `backface-visibility: + * hidden`, so a reversed face keeps its box while painting nothing. + */ + +const CONTRACT = `Write your scene in a single file \`scene.mjs\` in this directory. + +It must export one function: + + export function mount(host) { ... } + +\`host\` is an empty \`
\` that is already in the document, sized 900x600. +Build the scene inside it. Import everything from "@layoutit/polycss" — the +bundler resolves that specifier, so do not add a package.json, do not install +anything, and do not use a CDN URL. + +Do not write any other file. Do not run a dev server. Do not try to open a +browser. When \`scene.mjs\` is written, you are done.`; + +const hueOf = ([r, g, b]) => { + const max = Math.max(r, g, b); + const min = Math.min(r, g, b); + if (max === min) return null; + const d = max - min; + let h; + if (max === r) h = ((g - b) / d) % 6; + else if (max === g) h = (b - r) / d + 2; + else h = (r - g) / d + 4; + return ((h * 60) % 360 + 360) % 360; +}; + +const hueDistance = (a, b) => { + const d = Math.abs(a - b) % 360; + return d > 180 ? 360 - d : d; +}; + +const hexToRgb = (hex) => { + const s = hex.replace("#", ""); + const full = s.length === 3 ? [...s].map((c) => c + c).join("") : s; + return [0, 2, 4].map((i) => parseInt(full.slice(i, i + 2), 16)); +}; + +/** + * Everything below grades PAINTED PIXELS, not DOM boxes. + * + * Leaf strategies other than `` carry `backface-visibility: hidden`, so a + * back-facing leaf keeps a bounding rect while painting nothing. Reading rects + * would score a fully reversed mesh as visible — measured, not assumed. Pixel + * samples are the only evidence that survives that. + */ + +/** The harness page paints on white, so anything off-white is scene content. */ +const isBackground = ([r, g, b]) => r > 246 && g > 246 && b > 246; + +const paintedPixels = (snapshot) => snapshot.pixels.rgb.filter((rgb) => !isBackground(rgb)); + +const paintedFraction = (snapshot) => + paintedPixels(snapshot).length / snapshot.pixels.rgb.length; + +/** + * Lambert shading multiplies every channel by the same factor under a white + * light, so hue survives baking. A colored light shifts it, which is why the + * color tasks ask for default lighting. + */ +const pixelsWithHue = (snapshot, hex, tolerance = 30) => { + const target = hueOf(hexToRgb(hex)); + if (target === null) return []; + const { cols } = snapshot.pixels; + const out = []; + snapshot.pixels.rgb.forEach((rgb, index) => { + if (isBackground(rgb)) return; + const hue = hueOf(rgb); + if (hue === null || hueDistance(hue, target) > tolerance) return; + out.push({ rgb, x: index % cols, y: Math.floor(index / cols) }); + }); + return out; +}; + +/** At 120x80 a shape worth seeing covers well over 40 samples. */ +const hasHue = (snapshot, hex, minPixels = 40) => + pixelsWithHue(snapshot, hex).length >= minPixels; + +const boundsOf = (pixels) => ({ + x0: Math.min(...pixels.map((p) => p.x)), + x1: Math.max(...pixels.map((p) => p.x)), + y0: Math.min(...pixels.map((p) => p.y)), + y1: Math.max(...pixels.map((p) => p.y)), +}); + +/** How many samples changed between the two captures. */ +function changedFraction(a, b) { + const left = a.pixels.rgb; + const right = b.pixels.rgb; + if (left.length !== right.length) return 1; + let changed = 0; + for (let i = 0; i < left.length; i += 1) { + const d = + Math.abs(left[i][0] - right[i][0]) + + Math.abs(left[i][1] - right[i][1]) + + Math.abs(left[i][2] - right[i][2]); + if (d > 24) changed += 1; + } + return changed / left.length; +} + +/** + * Distinct Lambert shading levels within one hue. Each differently-oriented + * face of a solid gets its own brightness, so this counts how many faces of a + * shape are actually being painted — the signal that separates correct winding + * from a mesh whose faces are all wound inward. + */ +function shadeLevels(snapshot, hex, minPixels = 15) { + const buckets = new Map(); + for (const { rgb } of pixelsWithHue(snapshot, hex)) { + const luma = Math.round((0.2126 * rgb[0] + 0.7152 * rgb[1] + 0.0722 * rgb[2]) / 12); + buckets.set(luma, (buckets.get(luma) ?? 0) + 1); + } + return [...buckets.values()].filter((n) => n >= minPixels).length; +} + +/* ── shared checks ────────────────────────────────────────────────── */ + +const mountsCleanly = { + id: "mounts", + describe: "mounts with no console errors or exceptions", + run: ({ errors }) => (errors.length === 0 ? true : `runtime errors: ${errors.join(" / ")}`), +}; + +const hasScene = { + id: "scene", + describe: "renders exactly one PolyCSS scene inside a camera", + run: ({ first }) => { + if (first.sceneCount !== 1) return `expected 1 .polycss-scene, found ${first.sceneCount}`; + if (first.cameraCount < 1) return "no .polycss-camera ancestor — camera must wrap the scene"; + return true; + }, +}; + +const paintsSomething = (minFraction = 0.03) => ({ + id: "paints", + describe: `paints at least ${Math.round(minFraction * 100)}% of the viewport`, + run: ({ first }) => { + const covered = paintedFraction(first); + return covered >= minFraction + ? true + : `only ${(covered * 100).toFixed(1)}% of the viewport is painted (${first.leafTotal} leaves mounted)`; + }, +}); + +const colorMatches = (hex) => ({ + id: `color${hex}`, + describe: `paints a visible surface in the requested color (${hex})`, + run: ({ first }) => { + const n = pixelsWithHue(first, hex).length; + return n >= 40 ? true : `only ${n} painted samples near the hue of ${hex}`; + }, +}); + +const isStill = { + id: "still", + describe: "camera does not move on its own", + run: ({ first, second }) => { + const changed = changedFraction(first, second); + return changed < 0.01 + ? true + : `${(changed * 100).toFixed(1)}% of the image changed between samples — the task asked for a fixed camera`; + }, +}; + +const isMoving = { + id: "moving", + describe: "camera orbits over time", + run: ({ first, second }) => { + const changed = changedFraction(first, second); + return changed >= 0.02 + ? true + : `only ${(changed * 100).toFixed(1)}% of the image changed between samples — nothing is animating`; + }, +}; + +/* ── tasks ────────────────────────────────────────────────────────── */ + +export const TASKS = [ + { + id: "01-static-cube", + title: "Static colored cube", + prompt: `Build a PolyCSS scene showing a single cube. + +- The cube must be orange: #ff8c1a. +- The camera is fixed — it must not rotate, orbit, or animate. +- Use the default lighting so the cube keeps its color. +- The cube should fill a good part of the viewport. + +${CONTRACT}`, + checks: [ + mountsCleanly, + hasScene, + paintsSomething(), + colorMatches("#ff8c1a"), + isStill, + { + id: "one-mesh", + describe: "adds exactly one mesh", + run: ({ first }) => + first.meshCount === 1 ? true : `expected 1 mesh, found ${first.meshCount}`, + }, + { + id: "cheap-leaves", + describe: "a box renders as solid quad leaves, not atlas slices", + run: ({ first }) => + first.strategies.s === 0 + ? true + : `${first.strategies.s} atlas leaves — an untextured box should be solid quads`, + }, + ], + }, + + { + id: "02-orbiting-cube", + title: "Cube with an orbiting camera", + prompt: `Build a PolyCSS scene showing a single cube. + +- The cube must be teal: #14b8a6. +- The camera must orbit the cube continuously on its own, without any user + input, at a slow speed. +- The user should also be able to drag to rotate and use the wheel to zoom. +- Use the default lighting so the cube keeps its color. + +${CONTRACT}`, + checks: [ + mountsCleanly, + hasScene, + paintsSomething(), + colorMatches("#14b8a6"), + isMoving, + { + id: "no-raf-loop", + describe: "does not hand-roll a per-polygon animation loop", + run: ({ source }) => + /requestAnimationFrame/.test(source) && !/createPolyOrbitControls|OrbitControls/.test(source) + ? "hand-rolled requestAnimationFrame loop instead of orbit controls" + : true, + }, + ], + }, + + { + id: "03-cube-with-shadow", + title: "Cube casting a shadow", + prompt: `Build a PolyCSS scene showing a single cube resting above a flat ground +surface, lit by a directional light. + +- The cube must be amber: #fbbf24. +- The cube must cast a visible shadow onto the ground. +- The camera is fixed. + +${CONTRACT}`, + checks: [ + mountsCleanly, + hasScene, + paintsSomething(), + colorMatches("#fbbf24"), + { + id: "shadow-drawn", + describe: "a cast shadow is actually painted", + run: ({ first }) => + first.shadow.pathCount > 0 && first.shadow.area > 100 + ? true + : `no shadow geometry rendered (${first.shadow.svgCount} svg, ${first.shadow.pathCount} paths, area ${first.shadow.area})`, + }, + { + id: "receiver", + describe: "a receiver exists — vanilla has no ground-shadow fallback", + run: ({ source }) => + /receiveShadow/.test(source) + ? true + : "no receiveShadow anywhere; in vanilla a caster with no receiver draws nothing", + }, + ], + }, + + { + id: "04-two-shapes", + title: "Two shapes side by side", + prompt: `Build a PolyCSS scene showing two different shapes side by side, clearly +separated so neither hides the other: + +- a cube in indigo #6366f1 +- a sphere in rose #f43f5e + +The camera is fixed. Use the default lighting. + +${CONTRACT}`, + checks: [ + mountsCleanly, + hasScene, + paintsSomething(0.04), + colorMatches("#6366f1"), + colorMatches("#f43f5e"), + { + id: "two-meshes", + describe: "adds two separate meshes", + run: ({ first }) => + first.meshCount === 2 ? true : `expected 2 meshes, found ${first.meshCount}`, + }, + { + id: "separated", + describe: "the two shapes do not overlap on screen", + run: ({ first }) => { + const indigo = pixelsWithHue(first, "#6366f1"); + const rose = pixelsWithHue(first, "#f43f5e"); + if (indigo.length < 40 || rose.length < 40) { + return `one shape is missing (${indigo.length} vs ${rose.length} samples)`; + } + const a = boundsOf(indigo); + const b = boundsOf(rose); + const overlapX = Math.min(a.x1, b.x1) - Math.max(a.x0, b.x0); + const overlapY = Math.min(a.y1, b.y1) - Math.max(a.y0, b.y0); + return overlapX < 0 || overlapY < 0 + ? true + : `painted regions overlap by ${overlapX + 1}x${overlapY + 1} samples`; + }, + }, + ], + }, + + { + id: "05-hand-authored-polygons", + title: "Hand-authored polygons", + prompt: `Build a PolyCSS scene showing a flat 2x2 checkerboard of four square +tiles lying on the ground, seen from above at an angle. + +- Author the four tiles yourself as a plain array of polygon objects. Do not + use any built-in shape helper or generator, and do not load a model file. +- Every tile is lime: #84cc16. +- All four tiles must be visible from the camera looking down at them. +- Leave a small gap between the tiles so the four squares read separately. + +${CONTRACT}`, + checks: [ + mountsCleanly, + hasScene, + colorMatches("#84cc16"), + { + id: "authored", + describe: "geometry is hand-authored, not generated by a helper", + run: ({ source }) => + /(box|sphere|cone|cylinder|plane|torus|tetrahedron|octahedron|icosahedron|dodecahedron)Polygons|createPoly(Box|Sphere|Cone|Cylinder|Plane|Torus|Tetrahedron|Octahedron|Icosahedron|Dodecahedron)/i.test( + source, + ) + ? "used a built-in shape helper instead of authoring polygons" + : true, + }, + { + id: "faces-visible", + describe: "all four tiles face the camera (correct winding)", + run: ({ first }) => { + // Four flat tiles share one normal, so they share one Lambert shade + // and the only question is how much of that shade is painted. A tile + // wound the other way is backface-culled and paints nothing at all, + // so lost coverage counts the tiles that got their vertex order + // wrong. Four tiles were measured at ~9% of the viewport; require + // enough for all four rather than a bare majority. + const painted = pixelsWithHue(first, "#84cc16").length; + const total = first.pixels.rgb.length; + const share = painted / total; + return share >= 0.055 + ? true + : `only ${(share * 100).toFixed(1)}% of the viewport is lime - tiles wound away from the camera are backface-culled and paint nothing`; + }, + }, + { + id: "no-named-colors", + describe: "uses a parseable color format", + run: ({ source }) => + /color:\s*["'](?!#|rgb)[a-z]+["']/i.test(source) + ? "used a CSS named color; PolyCSS parses only hex, rgb() and rgba()" + : true, + }, + ], + }, + + { + id: "06-composed-scene", + title: "Composed scene", + prompt: `Build a small PolyCSS scene that composes several things together: + +- A flat ground surface in slate #64748b. +- Three shapes standing on the ground, spread out so all three are visible: + a cube in red #ef4444, a cylinder in blue #3b82f6, and a torus in + yellow #eab308. +- A directional light plus some ambient fill. +- All three shapes cast shadows onto the ground. +- The user can drag to orbit and use the wheel to zoom. + +${CONTRACT}`, + checks: [ + mountsCleanly, + hasScene, + paintsSomething(0.15), + colorMatches("#ef4444"), + colorMatches("#3b82f6"), + colorMatches("#eab308"), + { + id: "four-meshes", + describe: "ground plus three shapes are separate meshes", + run: ({ first }) => + first.meshCount >= 4 ? true : `expected at least 4 meshes, found ${first.meshCount}`, + }, + { + id: "shadow-drawn", + describe: "shadows are painted on the ground", + run: ({ first }) => + first.shadow.pathCount > 0 && first.shadow.area > 100 + ? true + : `no shadow geometry rendered (${first.shadow.pathCount} paths, area ${first.shadow.area})`, + }, + { + id: "controls", + describe: "uses the built-in controls rather than manual input handling", + run: ({ source }) => + /createPolyOrbitControls|createPolyMapControls/.test(source) + ? true + : "no PolyCSS controls; drag/wheel should not be hand-wired", + }, + ], + }, +]; + +export const TASK_IDS = TASKS.map((t) => t.id); + +export function selectTasks(ids) { + if (ids.length === 0) return TASKS; + const unknown = ids.filter((id) => !TASK_IDS.includes(id)); + if (unknown.length > 0) { + throw new Error(`unknown task(s): ${unknown.join(", ")}\nknown: ${TASK_IDS.join(", ")}`); + } + return TASKS.filter((t) => ids.includes(t.id)); +} + +export const __testing = { + hueOf, + hueDistance, + hexToRgb, + hasHue, + pixelsWithHue, + paintedFraction, + changedFraction, + shadeLevels, + boundsOf, +}; diff --git a/eval/skill/verify.mjs b/eval/skill/verify.mjs new file mode 100644 index 000000000..e4dadb086 --- /dev/null +++ b/eval/skill/verify.mjs @@ -0,0 +1,203 @@ +/** + * Builds a candidate `scene.mjs` and grades what it actually renders. + * + * Two stages, both objective: + * build — esbuild resolves `@layoutit/polycss` against workspace SOURCE and + * fails on any import that does not exist. An invented export is a + * build error here, not a mystery at runtime. + * render — Chromium mounts the bundle, and each task's checks run over two + * DOM snapshots (so motion is observable) plus the source text. + * + * Grading never reads the agent's prose. A scene that does not paint scores + * zero no matter how confident the explanation was. + */ +import { build } from "esbuild"; +import { createServer } from "node:http"; +import { readFile } from "node:fs/promises"; +import { extname, join, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; +import { chromium } from "playwright"; + +import { chromiumArgsWithGpuDefault } from "../../bench/chromium-defaults.mjs"; + +const here = resolve(fileURLToPath(import.meta.url), ".."); +const repoRoot = resolve(here, "..", ".."); + +/** Same aliasing as bench/build.mjs: bundle workspace SOURCE, not dist. */ +const ALIASES = { + "@layoutit/polycss-core": resolve(repoRoot, "packages/core/src/index.ts"), + "@layoutit/polycss-core/three": resolve(repoRoot, "packages/core/src/three/index.ts"), + "@layoutit/polycss": resolve(repoRoot, "packages/polycss/src/index.ts"), + "@layoutit/polycss/elements": resolve(repoRoot, "packages/polycss/src/elements/index.ts"), + "@layoutit/polycss/three": resolve(repoRoot, "packages/polycss/src/three.ts"), +}; + +const MIME = { + ".html": "text/html; charset=utf-8", + ".js": "text/javascript; charset=utf-8", + ".mjs": "text/javascript; charset=utf-8", + ".css": "text/css; charset=utf-8", +}; + +export async function bundleScene(entry, outfile) { + try { + await build({ + entryPoints: [entry], + outfile, + bundle: true, + format: "esm", + platform: "browser", + target: "es2020", + alias: ALIASES, + loader: { ".ts": "ts", ".tsx": "tsx" }, + jsx: "automatic", + logLevel: "silent", + define: { "process.env.NODE_ENV": '"production"' }, + }); + return { ok: true }; + } catch (error) { + const messages = (error.errors ?? []).map((e) => { + const where = e.location ? `${e.location.file}:${e.location.line}` : ""; + return `${where} ${e.text}`.trim(); + }); + return { ok: false, error: messages.join("; ") || String(error.message ?? error) }; + } +} + +/** + * Serve the harness page and the built bundles. A local server (rather than + * file://) is required because Chromium refuses ES module imports over file://. + */ +async function serve(roots) { + const server = createServer(async (req, res) => { + const path = decodeURIComponent(new URL(req.url, "http://localhost").pathname); + for (const [prefix, dir] of Object.entries(roots)) { + if (!path.startsWith(prefix)) continue; + const rel = path.slice(prefix.length).replace(/^\/+/, ""); + // Contain reads to the mounted directory — the file name comes from a + // request, and these bundles sit next to the rest of the repo. + const target = resolve(dir, rel); + if (target !== resolve(dir) && !target.startsWith(resolve(dir) + "/")) break; + try { + const body = await readFile(target); + res.writeHead(200, { "content-type": MIME[extname(target)] ?? "application/octet-stream" }); + res.end(body); + return; + } catch { + break; + } + } + res.writeHead(404).end("not found"); + }); + + await new Promise((done) => server.listen(0, "127.0.0.1", done)); + const { port } = server.address(); + return { + origin: `http://127.0.0.1:${port}`, + close: () => new Promise((done) => server.close(done)), + }; +} + +/** + * Two snapshots separated by `settleMs`: the first after the scene has mounted + * and atlases have had time to decode, the second late enough that autorotate + * has visibly moved the leaves. + */ +async function probeScene(page, url, { settleMs = 1200, mountTimeoutMs = 15000 } = {}) { + await page.goto(url, { waitUntil: "load" }); + const mounted = await page.evaluate( + (ms) => + Promise.race([ + window.__mounted, + new Promise((r) => setTimeout(() => r("timeout"), ms)), + ]), + mountTimeoutMs, + ); + + const sample = async () => { + const dom = await page.evaluate(() => window.__probe()); + const shot = await page.locator("#host").screenshot({ type: "png" }); + const pixels = await page.evaluate( + (dataUrl) => window.__samplePixels(dataUrl), + `data:image/png;base64,${shot.toString("base64")}`, + ); + return { ...dom, pixels }; + }; + + const first = await sample(); + await page.waitForTimeout(settleMs); + const second = await sample(); + const errors = await page.evaluate(() => window.__evalErrors.slice()); + + if (mounted === "timeout") errors.unshift("mount() did not settle within the timeout"); + return { first, second, errors, mounted: mounted === true }; +} + +export async function verifyCandidates(candidates, { headed = false, settleMs } = {}) { + const bundleDir = resolve(here, ".generated"); + const built = []; + + for (const candidate of candidates) { + const outfile = join(bundleDir, `${candidate.key}.js`); + const result = await bundleScene(candidate.entry, outfile); + built.push({ ...candidate, outfile, build: result }); + } + + const server = await serve({ + "/harness": join(here, "harness"), + "/bundles": bundleDir, + }); + + const browser = await chromium.launch({ + headless: !headed, + args: chromiumArgsWithGpuDefault(), + }); + + try { + const page = await browser.newPage({ viewport: { width: 1000, height: 700 } }); + const results = []; + + for (const candidate of built) { + if (!candidate.build.ok) { + results.push({ + ...candidate, + checks: candidate.task.checks.map((check) => ({ + id: check.id, + describe: check.describe, + pass: false, + reason: "not run — bundle failed to build", + })), + }); + continue; + } + + const url = `${server.origin}/harness/index.html?bundle=${encodeURIComponent( + `/bundles/${candidate.key}.js`, + )}`; + const probe = await probeScene(page, url, { settleMs }); + const ctx = { ...probe, source: candidate.source }; + + const checks = candidate.task.checks.map((check) => { + let outcome; + try { + outcome = check.run(ctx); + } catch (error) { + outcome = `check threw: ${error.message}`; + } + return { + id: check.id, + describe: check.describe, + pass: outcome === true, + reason: outcome === true ? null : String(outcome), + }; + }); + + results.push({ ...candidate, probe, checks }); + } + + return results; + } finally { + await browser.close(); + await server.close(); + } +} diff --git a/package.json b/package.json index 0a54cb974..d61a29c1a 100644 --- a/package.json +++ b/package.json @@ -26,6 +26,8 @@ "sync:skill": "node .github/scripts/sync-skill-docs.mjs", "check:readmes": "node .github/scripts/sync-package-readmes.mjs --check", "check:skill": "node .github/scripts/sync-skill-docs.mjs --check", + "eval:skill": "node eval/skill/run.mjs", + "eval:selftest": "node eval/skill/selftest.mjs", "test:scripts": "node --test .github/scripts/*.test.mjs", "publish:all": "pnpm sync:readmes && pnpm --filter './packages/*' --filter '!@layoutit/polycss-domformat' -r publish --access public", "dev:website": "pnpm --filter @layoutit/polycss-website dev", From c6e1819671b3b058f30ba64d666b163b6b5628c7 Mon Sep 17 00:00:00 2001 From: Juan Cruz Fortunatti Date: Thu, 13 Aug 2026 02:17:44 +0200 Subject: [PATCH 03/37] test(skills): grade framing, surface brightness and per-face shading in the eval --- eval/skill/selftest.mjs | 25 ++++++++ eval/skill/tasks.mjs | 123 ++++++++++++++++++++++++++++++++++++++-- 2 files changed, 143 insertions(+), 5 deletions(-) diff --git a/eval/skill/selftest.mjs b/eval/skill/selftest.mjs index 8a492cc77..10b8c647a 100644 --- a/eval/skill/selftest.mjs +++ b/eval/skill/selftest.mjs @@ -131,6 +131,31 @@ const MUTATIONS = [ scene.add(createPolyBox({ size: 140, color: COLOR }));`, ), }, + { + name: "an overscaled cube runs past the viewport edges", + taskId: "01-static-cube", + expect: "scale", + mutate: (source) => patch(source, `zoom: 3`, `zoom: 24`), + }, + { + name: "an undersized cube is a speck", + taskId: "01-static-cube", + expect: "scale", + mutate: (source) => patch(source, `zoom: 3`, `zoom: 0.25`), + }, + { + name: "a light pointing away leaves the cube near-black", + taskId: "01-static-cube", + expect: "brightness", + mutate: (source) => + patch( + source, + `directionalLight: { direction: [0.5, -0.6, 0.7], color: "#ffffff", intensity: 1 }, + ambientLight: { color: "#ffffff", intensity: 0.4 },`, + `directionalLight: { direction: [0, 0, -1], color: "#ffffff", intensity: 1 }, + ambientLight: { color: "#ffffff", intensity: 0.04 },`, + ), + }, { name: "an export that does not exist fails the build", taskId: "01-static-cube", diff --git a/eval/skill/tasks.mjs b/eval/skill/tasks.mjs index e8549bda0..d2d079d0d 100644 --- a/eval/skill/tasks.mjs +++ b/eval/skill/tasks.mjs @@ -111,6 +111,53 @@ function changedFraction(a, b) { return changed / left.length; } +const luma = ([r, g, b]) => 0.2126 * r + 0.7152 * g + 0.0722 * b; + +const percentile = (sorted, p) => + sorted.length === 0 ? 0 : sorted[Math.min(sorted.length - 1, Math.floor(sorted.length * p))]; + +/** + * Brightness of the surface painted in `hex`, as the median luma of its + * pixels. A scene whose light points away from every visible face still paints + * the right hue at near-black, and a blown-out one washes to white — both are + * "the right color" to a hue test and wrong to a human. + */ +function surfaceBrightness(snapshot, hex) { + const lumas = pixelsWithHue(snapshot, hex) + .map((p) => luma(p.rgb)) + .sort((a, b) => a - b); + return { count: lumas.length, median: percentile(lumas, 0.5) }; +} + +/** + * Shadow contrast, measured on the receiver rather than on the shadow markup. + * Counting `` nodes proves a shadow was emitted, not that it darkens + * anything — `opacity: 0` emits the same paths. Comparing the receiver's dark + * tail against its own median is what separates a visible shadow from a + * technically-present one. + * + * `excludeHex` drops the caster's own pixels so the statistics describe the + * ground, not the object standing on it. + */ +function receiverContrast(snapshot, excludeHex) { + const target = excludeHex === null ? null : hueOf(hexToRgb(excludeHex)); + const lumas = []; + for (const rgb of snapshot.pixels.rgb) { + if (isBackground(rgb)) continue; + if (target !== null) { + const hue = hueOf(rgb); + if (hue !== null && hueDistance(hue, target) <= 30) continue; + } + lumas.push(luma(rgb)); + } + lumas.sort((a, b) => a - b); + const median = percentile(lumas, 0.5); + const dark = percentile(lumas, 0.1); + const threshold = median * 0.85; + const darkShare = lumas.length === 0 ? 0 : lumas.filter((l) => l < threshold).length / lumas.length; + return { count: lumas.length, median, dark, ratio: median === 0 ? 1 : dark / median, darkShare }; +} + /** * Distinct Lambert shading levels within one hue. Each differently-oriented * face of a solid gets its own brightness, so this counts how many faces of a @@ -155,6 +202,60 @@ const paintsSomething = (minFraction = 0.03) => ({ }, }); +/** + * Framing, as a band rather than a floor. `zoom` is CSS pixels per world unit, + * so the on-screen size of a shape is set by TWO numbers — its world size and + * the camera zoom — and getting either wrong is invisible to a floor-only + * check: a cube scaled past the viewport edges paints ~100% and "passes". + */ +const framedWithin = (minFraction, maxFraction) => ({ + id: "scale", + describe: `fills between ${Math.round(minFraction * 100)}% and ${Math.round(maxFraction * 100)}% of the viewport`, + run: ({ first }) => { + const covered = paintedFraction(first); + if (covered < minFraction) { + return `fills only ${(covered * 100).toFixed(1)}% of the viewport - too small; raise the shape size or the camera zoom`; + } + if (covered > maxFraction) { + return `fills ${(covered * 100).toFixed(1)}% of the viewport - overscaled and running past the edges; lower the shape size or the camera zoom`; + } + return true; + }, +}); + +/** + * The surface is actually lit. A hue test alone passes a cube whose every + * visible face points away from the light (correct hue, near-black) and one + * blown out to white — both read as "the right color" to a hue check. + */ +const litWithin = (hex, minLuma, maxLuma) => ({ + id: "brightness", + describe: `paints ${hex} at a usable brightness`, + run: ({ first }) => { + const { count, median } = surfaceBrightness(first, hex); + if (count < 40) return `not enough ${hex} surface to judge brightness (${count} samples)`; + if (median < minLuma) { + return `${hex} surfaces average luma ${median.toFixed(0)} - too dark; the light points away from every visible face, or ambient is too low`; + } + if (median > maxLuma) { + return `${hex} surfaces average luma ${median.toFixed(0)} - blown out; lower the light or ambient intensity`; + } + return true; + }, +}); + +/** A lit solid shows a different Lambert shade per face orientation. */ +const isShaded = (hex, minShades = 2) => ({ + id: "shaded", + describe: "faces are individually shaded rather than flat-filled", + run: ({ first }) => { + const levels = shadeLevels(first, hex); + return levels >= minShades + ? true + : `only ${levels} distinct shade(s) of ${hex} - the faces are not being lit separately`; + }, +}); + const colorMatches = (hex) => ({ id: `color${hex}`, describe: `paints a visible surface in the requested color (${hex})`, @@ -196,15 +297,20 @@ export const TASKS = [ - The cube must be orange: #ff8c1a. - The camera is fixed — it must not rotate, orbit, or animate. -- Use the default lighting so the cube keeps its color. -- The cube should fill a good part of the viewport. +- Light it with a plain white directional light plus ambient fill, so the cube + reads clearly as its own color and each face is shaded differently. +- Frame it so the cube fills roughly a third of the 900x600 viewport: clearly + more than a speck, and not running off the edges. Remember that on-screen + size comes from BOTH the shape's world size and the camera zoom. ${CONTRACT}`, checks: [ mountsCleanly, hasScene, - paintsSomething(), + framedWithin(0.08, 0.6), colorMatches("#ff8c1a"), + litWithin("#ff8c1a", 40, 210), + isShaded("#ff8c1a", 2), isStill, { id: "one-mesh", @@ -232,14 +338,18 @@ ${CONTRACT}`, - The camera must orbit the cube continuously on its own, without any user input, at a slow speed. - The user should also be able to drag to rotate and use the wheel to zoom. -- Use the default lighting so the cube keeps its color. +- Light it with a plain white directional light plus ambient fill. +- Frame it so the cube fills roughly a third of the 900x600 viewport: clearly + more than a speck, and not running off the edges. Remember that on-screen + size comes from BOTH the shape's world size and the camera zoom. ${CONTRACT}`, checks: [ mountsCleanly, hasScene, - paintsSomething(), + framedWithin(0.08, 0.6), colorMatches("#14b8a6"), + litWithin("#14b8a6", 25, 190), isMoving, { id: "no-raf-loop", @@ -447,6 +557,9 @@ export function selectTasks(ids) { } export const __testing = { + luma, + surfaceBrightness, + receiverContrast, hueOf, hueDistance, hexToRgb, From 1397bd1c9e4af988590a289fdddecac67807ecb0 Mon Sep 17 00:00:00 2001 From: Juan Cruz Fortunatti Date: Thu, 13 Aug 2026 02:31:24 +0200 Subject: [PATCH 04/37] fix(shadow): apply the documented 0.05 lift default to receiver shadows --- packages/polycss/src/api/scene/receiverShadow.ts | 2 +- packages/react/src/scene/PolyMesh.tsx | 2 +- packages/vue/src/scene/PolyMesh.ts | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/polycss/src/api/scene/receiverShadow.ts b/packages/polycss/src/api/scene/receiverShadow.ts index 6d3d6f324..0e468ca52 100644 --- a/packages/polycss/src/api/scene/receiverShadow.ts +++ b/packages/polycss/src/api/scene/receiverShadow.ts @@ -140,7 +140,7 @@ export function emitReceiverShadows( const hasTexture = receiverEntry.polygons.some((p) => p.texture !== undefined); const receiverScale = meshScaleVec3(receiverEntry.handle.transform.scale); const rbboxCss = receiverEntry.bboxCenterCss; - const cacheShadowLift = options.shadow?.lift ?? 0.001; + const cacheShadowLift = options.shadow?.lift ?? 0.05; const cacheKey = `${receiverEntry.polygons.length}|${receiverDedupDrop.size}|${rpos.join(",")}|${rrot.join(",")}|${receiverScale.join(",")}|${rbboxCss ? rbboxCss.join(",") : "n"}|${cacheShadowLift}`; let cachedPlanes = receiverShadowCache.get(receiverEntry) as ReceiverFacePlane[] | undefined; if (cachedPlanes === undefined || receiverShadowCacheKey.get(receiverEntry) !== cacheKey) { diff --git a/packages/react/src/scene/PolyMesh.tsx b/packages/react/src/scene/PolyMesh.tsx index 3399642a1..6e9d6a770 100644 --- a/packages/react/src/scene/PolyMesh.tsx +++ b/packages/react/src/scene/PolyMesh.tsx @@ -1027,7 +1027,7 @@ export const PolyMesh = forwardRef(function PolyM const runDirectionalShadow = !!sceneDirectionalLight?.direction && (sceneDirectionalLight.intensity ?? 1) > 0; const hasShadowPoints = shadowPointIndices.length > 0; - const shadowLift = sceneShadow?.lift ?? 0.001; + const shadowLift = sceneShadow?.lift ?? 0.05; const planes = prepareReceiverFacePlanes( polygons, position ?? [0, 0, 0], diff --git a/packages/vue/src/scene/PolyMesh.ts b/packages/vue/src/scene/PolyMesh.ts index 60f205018..4a34a42a2 100644 --- a/packages/vue/src/scene/PolyMesh.ts +++ b/packages/vue/src/scene/PolyMesh.ts @@ -682,7 +682,7 @@ export const PolyMesh = defineComponent({ const runDirectionalShadow = !!ctx?.directionalLight?.direction && (ctx.directionalLight.intensity ?? 1) > 0; const hasShadowPoints = shadowPointIndices.length > 0; - const shadowLift = ctx?.shadow?.lift ?? 0.001; + const shadowLift = ctx?.shadow?.lift ?? 0.05; const planes = prepareReceiverFacePlanes( polygons.value, props.position ?? [0, 0, 0], From c098587b651dae006723bcb17f205b51399df6a4 Mon Sep 17 00:00:00 2001 From: Juan Cruz Fortunatti Date: Thu, 13 Aug 2026 02:31:24 +0200 Subject: [PATCH 05/37] test(skills): require cast shadows to visibly darken the receiver --- eval/skill/selftest.mjs | 15 +++++++++++++++ eval/skill/tasks.mjs | 21 +++++++++++++++++++++ 2 files changed, 36 insertions(+) diff --git a/eval/skill/selftest.mjs b/eval/skill/selftest.mjs index 10b8c647a..ed7fd4788 100644 --- a/eval/skill/selftest.mjs +++ b/eval/skill/selftest.mjs @@ -156,6 +156,21 @@ const MUTATIONS = [ ambientLight: { color: "#ffffff", intensity: 0.04 },`, ), }, + { + name: "a zero-opacity shadow emits paths but darkens nothing", + taskId: "03-cube-with-shadow", + expect: "shadow-contrast", + mutate: (source) => patch(source, `shadow: { opacity: 0.35 }`, `shadow: { opacity: 0 }`), + }, + { + // The regression that motivated the check: a lift too small to clear the + // receiver leaves the shadow z-fighting and painted over. + name: "a shadow lift too small to clear the receiver is invisible", + taskId: "03-cube-with-shadow", + expect: "shadow-contrast", + mutate: (source) => + patch(source, `shadow: { opacity: 0.35 }`, `shadow: { opacity: 0.35, lift: 0.001 }`), + }, { name: "an export that does not exist fails the build", taskId: "01-static-cube", diff --git a/eval/skill/tasks.mjs b/eval/skill/tasks.mjs index d2d079d0d..ddd9d8c1d 100644 --- a/eval/skill/tasks.mjs +++ b/eval/skill/tasks.mjs @@ -287,6 +287,25 @@ const isMoving = { }, }; +/** + * The shadow must DARKEN the receiver, not merely exist in the markup. An + * `opacity: 0` shadow, or one z-fighting with the surface it lands on, emits + * exactly the same `` nodes as a working one — which is how a + * renderer-wide invisible-shadow default survived until this check existed. + */ +const shadowDarkensReceiver = (casterHex) => ({ + id: "shadow-contrast", + describe: "the cast shadow visibly darkens the receiver", + run: ({ first }) => { + if (first.shadow.pathCount === 0) return "no shadow geometry was emitted at all"; + const c = receiverContrast(first, casterHex); + if (c.count < 200) return `not enough receiver surface to measure (${c.count} samples)`; + return c.ratio <= 0.95 + ? true + : `the receiver's dark tail is ${(c.ratio * 100).toFixed(0)}% of its median brightness - shadow paths are emitted but nothing is darker`; + }, +}); + /* ── tasks ────────────────────────────────────────────────────────── */ export const TASKS = [ @@ -386,6 +405,7 @@ ${CONTRACT}`, ? true : `no shadow geometry rendered (${first.shadow.svgCount} svg, ${first.shadow.pathCount} paths, area ${first.shadow.area})`, }, + shadowDarkensReceiver("#fbbf24"), { id: "receiver", describe: "a receiver exists — vanilla has no ground-shadow fallback", @@ -533,6 +553,7 @@ ${CONTRACT}`, ? true : `no shadow geometry rendered (${first.shadow.pathCount} paths, area ${first.shadow.area})`, }, + shadowDarkensReceiver(null), { id: "controls", describe: "uses the built-in controls rather than manual input handling", From 1d3204170e8635d8a46a62f4c910998619171e0e Mon Sep 17 00:00:00 2001 From: Juan Cruz Fortunatti Date: Thu, 13 Aug 2026 02:36:03 +0200 Subject: [PATCH 06/37] test(skills): add a no-skill Three.js control track to the eval --- .../oracle/{ => polycss}/01-static-cube.mjs | 0 .../oracle/{ => polycss}/02-orbiting-cube.mjs | 0 .../{ => polycss}/03-cube-with-shadow.mjs | 0 .../oracle/{ => polycss}/04-two-shapes.mjs | 0 .../05-hand-authored-polygons.mjs | 0 .../{ => polycss}/06-composed-scene.mjs | 0 eval/skill/oracle/three/01-static-cube.mjs | 20 +++ eval/skill/oracle/three/02-orbiting-cube.mjs | 25 +++ .../oracle/three/03-cube-with-shadow.mjs | 31 ++++ eval/skill/oracle/three/04-two-shapes.mjs | 27 +++ .../three/05-hand-authored-polygons.mjs | 49 ++++++ eval/skill/oracle/three/06-composed-scene.mjs | 33 ++++ eval/skill/oracle/three/_common.mjs | 26 +++ eval/skill/run.mjs | 105 +++++++++--- eval/skill/selftest.mjs | 2 +- eval/skill/tasks.mjs | 159 ++++++++++-------- eval/skill/tracks.mjs | 88 ++++++++++ eval/skill/verify.mjs | 29 ++-- 18 files changed, 484 insertions(+), 110 deletions(-) rename eval/skill/oracle/{ => polycss}/01-static-cube.mjs (100%) rename eval/skill/oracle/{ => polycss}/02-orbiting-cube.mjs (100%) rename eval/skill/oracle/{ => polycss}/03-cube-with-shadow.mjs (100%) rename eval/skill/oracle/{ => polycss}/04-two-shapes.mjs (100%) rename eval/skill/oracle/{ => polycss}/05-hand-authored-polygons.mjs (100%) rename eval/skill/oracle/{ => polycss}/06-composed-scene.mjs (100%) create mode 100644 eval/skill/oracle/three/01-static-cube.mjs create mode 100644 eval/skill/oracle/three/02-orbiting-cube.mjs create mode 100644 eval/skill/oracle/three/03-cube-with-shadow.mjs create mode 100644 eval/skill/oracle/three/04-two-shapes.mjs create mode 100644 eval/skill/oracle/three/05-hand-authored-polygons.mjs create mode 100644 eval/skill/oracle/three/06-composed-scene.mjs create mode 100644 eval/skill/oracle/three/_common.mjs create mode 100644 eval/skill/tracks.mjs diff --git a/eval/skill/oracle/01-static-cube.mjs b/eval/skill/oracle/polycss/01-static-cube.mjs similarity index 100% rename from eval/skill/oracle/01-static-cube.mjs rename to eval/skill/oracle/polycss/01-static-cube.mjs diff --git a/eval/skill/oracle/02-orbiting-cube.mjs b/eval/skill/oracle/polycss/02-orbiting-cube.mjs similarity index 100% rename from eval/skill/oracle/02-orbiting-cube.mjs rename to eval/skill/oracle/polycss/02-orbiting-cube.mjs diff --git a/eval/skill/oracle/03-cube-with-shadow.mjs b/eval/skill/oracle/polycss/03-cube-with-shadow.mjs similarity index 100% rename from eval/skill/oracle/03-cube-with-shadow.mjs rename to eval/skill/oracle/polycss/03-cube-with-shadow.mjs diff --git a/eval/skill/oracle/04-two-shapes.mjs b/eval/skill/oracle/polycss/04-two-shapes.mjs similarity index 100% rename from eval/skill/oracle/04-two-shapes.mjs rename to eval/skill/oracle/polycss/04-two-shapes.mjs diff --git a/eval/skill/oracle/05-hand-authored-polygons.mjs b/eval/skill/oracle/polycss/05-hand-authored-polygons.mjs similarity index 100% rename from eval/skill/oracle/05-hand-authored-polygons.mjs rename to eval/skill/oracle/polycss/05-hand-authored-polygons.mjs diff --git a/eval/skill/oracle/06-composed-scene.mjs b/eval/skill/oracle/polycss/06-composed-scene.mjs similarity index 100% rename from eval/skill/oracle/06-composed-scene.mjs rename to eval/skill/oracle/polycss/06-composed-scene.mjs diff --git a/eval/skill/oracle/three/01-static-cube.mjs b/eval/skill/oracle/three/01-static-cube.mjs new file mode 100644 index 000000000..911786645 --- /dev/null +++ b/eval/skill/oracle/three/01-static-cube.mjs @@ -0,0 +1,20 @@ +import * as THREE from "three"; +import { lights, makeRenderer } from "./_common.mjs"; + +export function mount(host) { + const renderer = makeRenderer(host); + const scene = new THREE.Scene(); + const camera = new THREE.PerspectiveCamera(45, 900 / 600, 0.1, 100); + camera.position.set(5, 4, 5); + camera.lookAt(0, 0, 0); + lights(scene); + + scene.add( + new THREE.Mesh( + new THREE.BoxGeometry(4, 4, 4), + new THREE.MeshLambertMaterial({ color: 0xff8c1a }), + ), + ); + + renderer.setAnimationLoop(() => renderer.render(scene, camera)); +} diff --git a/eval/skill/oracle/three/02-orbiting-cube.mjs b/eval/skill/oracle/three/02-orbiting-cube.mjs new file mode 100644 index 000000000..c235d32a7 --- /dev/null +++ b/eval/skill/oracle/three/02-orbiting-cube.mjs @@ -0,0 +1,25 @@ +import * as THREE from "three"; +import { lights, makeRenderer } from "./_common.mjs"; + +export function mount(host) { + const renderer = makeRenderer(host); + const scene = new THREE.Scene(); + const camera = new THREE.PerspectiveCamera(45, 900 / 600, 0.1, 100); + lights(scene); + + scene.add( + new THREE.Mesh( + new THREE.BoxGeometry(4, 4, 4), + new THREE.MeshLambertMaterial({ color: 0x14b8a6 }), + ), + ); + + const radius = 8; + let angle = 0; + renderer.setAnimationLoop(() => { + angle += 0.01; + camera.position.set(Math.cos(angle) * radius, 4.5, Math.sin(angle) * radius); + camera.lookAt(0, 0, 0); + renderer.render(scene, camera); + }); +} diff --git a/eval/skill/oracle/three/03-cube-with-shadow.mjs b/eval/skill/oracle/three/03-cube-with-shadow.mjs new file mode 100644 index 000000000..222f9fc74 --- /dev/null +++ b/eval/skill/oracle/three/03-cube-with-shadow.mjs @@ -0,0 +1,31 @@ +import * as THREE from "three"; +import { lights, makeRenderer } from "./_common.mjs"; + +export function mount(host) { + const renderer = makeRenderer(host); + const scene = new THREE.Scene(); + const camera = new THREE.PerspectiveCamera(45, 900 / 600, 0.1, 100); + camera.position.set(6, 8, 9); + camera.lookAt(0, 1, 0); + // Light from behind-left so the shadow falls toward the camera instead of + // hiding behind the cube. + lights(scene).position.set(-5, 9, -4); + + const ground = new THREE.Mesh( + new THREE.PlaneGeometry(16, 16), + new THREE.MeshLambertMaterial({ color: 0x94a3b8 }), + ); + ground.rotation.x = -Math.PI / 2; + ground.receiveShadow = true; + scene.add(ground); + + const cube = new THREE.Mesh( + new THREE.BoxGeometry(3, 3, 3), + new THREE.MeshLambertMaterial({ color: 0xfbbf24 }), + ); + cube.position.y = 2.2; + cube.castShadow = true; + scene.add(cube); + + renderer.setAnimationLoop(() => renderer.render(scene, camera)); +} diff --git a/eval/skill/oracle/three/04-two-shapes.mjs b/eval/skill/oracle/three/04-two-shapes.mjs new file mode 100644 index 000000000..3cede73f5 --- /dev/null +++ b/eval/skill/oracle/three/04-two-shapes.mjs @@ -0,0 +1,27 @@ +import * as THREE from "three"; +import { lights, makeRenderer } from "./_common.mjs"; + +export function mount(host) { + const renderer = makeRenderer(host); + const scene = new THREE.Scene(); + const camera = new THREE.PerspectiveCamera(45, 900 / 600, 0.1, 100); + camera.position.set(0, 6, 12); + camera.lookAt(0, 0, 0); + lights(scene); + + const cube = new THREE.Mesh( + new THREE.BoxGeometry(3.2, 3.2, 3.2), + new THREE.MeshLambertMaterial({ color: 0x6366f1 }), + ); + cube.position.x = -3.6; + scene.add(cube); + + const sphere = new THREE.Mesh( + new THREE.SphereGeometry(1.9, 32, 16), + new THREE.MeshLambertMaterial({ color: 0xf43f5e }), + ); + sphere.position.x = 3.6; + scene.add(sphere); + + renderer.setAnimationLoop(() => renderer.render(scene, camera)); +} diff --git a/eval/skill/oracle/three/05-hand-authored-polygons.mjs b/eval/skill/oracle/three/05-hand-authored-polygons.mjs new file mode 100644 index 000000000..08546cc86 --- /dev/null +++ b/eval/skill/oracle/three/05-hand-authored-polygons.mjs @@ -0,0 +1,49 @@ +import * as THREE from "three"; +import { lights, makeRenderer } from "./_common.mjs"; + +const SIZE = 2.6; +const GAP = 0.45; + +/** One flat tile, wound counter-clockwise seen from above. */ +function tile(cx, cz) { + const h = SIZE / 2; + const geometry = new THREE.BufferGeometry(); + geometry.setAttribute( + "position", + new THREE.Float32BufferAttribute( + [ + cx - h, 0, cz + h, + cx + h, 0, cz + h, + cx + h, 0, cz - h, + cx - h, 0, cz + h, + cx + h, 0, cz - h, + cx - h, 0, cz - h, + ], + 3, + ), + ); + geometry.computeVertexNormals(); + return geometry; +} + +export function mount(host) { + const renderer = makeRenderer(host); + const scene = new THREE.Scene(); + const camera = new THREE.PerspectiveCamera(45, 900 / 600, 0.1, 100); + camera.position.set(0, 8, 8); + camera.lookAt(0, 0, 0); + lights(scene); + + const material = new THREE.MeshLambertMaterial({ color: 0x84cc16 }); + const step = SIZE + GAP; + for (const [x, z] of [ + [-step / 2, -step / 2], + [step / 2, -step / 2], + [step / 2, step / 2], + [-step / 2, step / 2], + ]) { + scene.add(new THREE.Mesh(tile(x, z), material)); + } + + renderer.setAnimationLoop(() => renderer.render(scene, camera)); +} diff --git a/eval/skill/oracle/three/06-composed-scene.mjs b/eval/skill/oracle/three/06-composed-scene.mjs new file mode 100644 index 000000000..a17f4f08d --- /dev/null +++ b/eval/skill/oracle/three/06-composed-scene.mjs @@ -0,0 +1,33 @@ +import * as THREE from "three"; +import { lights, makeRenderer } from "./_common.mjs"; + +export function mount(host) { + const renderer = makeRenderer(host); + const scene = new THREE.Scene(); + const camera = new THREE.PerspectiveCamera(45, 900 / 600, 0.1, 100); + camera.position.set(0, 9, 14); + camera.lookAt(0, 0.5, 0); + lights(scene); + + const ground = new THREE.Mesh( + new THREE.PlaneGeometry(26, 26), + new THREE.MeshLambertMaterial({ color: 0x64748b }), + ); + ground.rotation.x = -Math.PI / 2; + ground.receiveShadow = true; + scene.add(ground); + + const add = (geometry, color, x, y) => { + const mesh = new THREE.Mesh(geometry, new THREE.MeshLambertMaterial({ color })); + mesh.position.set(x, y, 0); + mesh.castShadow = true; + scene.add(mesh); + return mesh; + }; + + add(new THREE.BoxGeometry(3, 3, 3), 0xef4444, -5.2, 1.5); + add(new THREE.CylinderGeometry(1.5, 1.5, 3.4, 24), 0x3b82f6, 0, 1.7); + add(new THREE.TorusGeometry(1.7, 0.6, 16, 32), 0xeab308, 5.2, 1.8).rotation.x = Math.PI / 2; + + renderer.setAnimationLoop(() => renderer.render(scene, camera)); +} diff --git a/eval/skill/oracle/three/_common.mjs b/eval/skill/oracle/three/_common.mjs new file mode 100644 index 000000000..3a9a3cd2c --- /dev/null +++ b/eval/skill/oracle/three/_common.mjs @@ -0,0 +1,26 @@ +import * as THREE from "three"; + +/** Shared setup so each reference scene shows only what its task is about. */ +export function makeRenderer(host) { + const renderer = new THREE.WebGLRenderer({ antialias: true }); + renderer.setSize(900, 600, false); + renderer.setPixelRatio(1); + renderer.setClearColor(0xffffff, 1); + renderer.shadowMap.enabled = true; + renderer.shadowMap.type = THREE.PCFSoftShadowMap; + host.appendChild(renderer.domElement); + return renderer; +} + +export function lights(scene, { intensity = 2.4, ambient = 0.55 } = {}) { + const sun = new THREE.DirectionalLight(0xffffff, intensity); + sun.position.set(5, 8, 4); + sun.castShadow = true; + sun.shadow.mapSize.set(1024, 1024); + const d = 12; + Object.assign(sun.shadow.camera, { left: -d, right: d, top: d, bottom: -d, near: 0.5, far: 40 }); + sun.shadow.camera.updateProjectionMatrix(); + scene.add(sun); + scene.add(new THREE.AmbientLight(0xffffff, ambient)); + return sun; +} diff --git a/eval/skill/run.mjs b/eval/skill/run.mjs index 6c46f1d3a..b5a799e12 100644 --- a/eval/skill/run.mjs +++ b/eval/skill/run.mjs @@ -19,6 +19,7 @@ import { execFileSync } from "node:child_process"; import { copyFileSync, + existsSync, mkdirSync, readFileSync, rmSync, @@ -28,7 +29,8 @@ import { basename, join, resolve } from "node:path"; import { fileURLToPath } from "node:url"; import { AGENTS, AGENT_NAMES, availableAgents, expandAgents, isAvailable } from "./agents.mjs"; -import { TASK_IDS, selectTasks } from "./tasks.mjs"; +import { allChecks, TASK_IDS, selectTasks } from "./tasks.mjs"; +import { selectTracks, TRACK_NAMES, TRACKS } from "./tracks.mjs"; import { verifyCandidates } from "./verify.mjs"; const here = resolve(fileURLToPath(import.meta.url), ".."); @@ -41,6 +43,8 @@ const USAGE = `Usage: node eval/skill/run.mjs [options] Options --agent Comma-separated: ${AGENT_NAMES.join(", ")}, or all. Default: oracle. + --track Comma-separated: ${TRACK_NAMES.join(", ")}, or all. + Default: polycss. "three" is the no-skill control. --task Comma-separated task ids. Default: all. --keep Keep the agent workspaces for inspection. --headed Run Chromium headed. @@ -56,6 +60,7 @@ ${TASK_IDS.map((id) => ` ${id}`).join("\n")} function parseArgs(argv) { const options = { agents: [], + tracks: [], tasks: [], keep: false, headed: false, @@ -73,6 +78,7 @@ function parseArgs(argv) { return next; }; if (arg === "--agent") options.agents.push(value()); + else if (arg === "--track") options.tracks.push(...value().split(",").map((t) => t.trim())); else if (arg === "--task") options.tasks.push(...value().split(",").map((t) => t.trim())); else if (arg === "--keep") options.keep = true; else if (arg === "--headed") options.headed = true; @@ -91,28 +97,40 @@ function parseArgs(argv) { * read their skills directory relative to the working directory, so installing * both flavours covers every CLI without special-casing. */ -function makeWorkspace(agent, task) { - const dir = join(workRoot, agent, task.id); +function makeWorkspace(track, agent, task) { + const dir = join(workRoot, track, agent, task.id); rmSync(dir, { recursive: true, force: true }); mkdirSync(dir, { recursive: true }); - execFileSync(process.execPath, [installer, "--cwd", dir, "--agent", "all"], { - stdio: "pipe", - }); + // Only the track under test gets the skill. The control measures what the + // model already knows, so handing it anything would defeat the comparison. + if (TRACKS[track].installSkill) { + execFileSync(process.execPath, [installer, "--cwd", dir, "--agent", "all"], { stdio: "pipe" }); + } - writeFileSync(join(dir, "TASK.md"), `# Task\n\n${task.prompt}\n`); + writeFileSync(join(dir, "TASK.md"), `# Task\n\n${taskPrompt(track, task)}\n`); return dir; } -function runAgent(name, dir, task, timeoutSeconds) { +const taskPrompt = (track, task) => `${task.prompt}\n\n${TRACKS[track].contract}`; + +function runAgent(name, track, dir, task, timeoutSeconds) { const agent = AGENTS[name]; if (name === "oracle") { - copyFileSync(join(here, "oracle", `${task.id}.mjs`), join(dir, "scene.mjs")); + const from = join(here, "oracle", track, `${task.id}.mjs`); + copyFileSync(from, join(dir, "scene.mjs")); + // The Three reference scenes share a small setup helper; the bundler + // resolves it relative to the copied entry. + const shared = join(here, "oracle", track, "_common.mjs"); + if (existsSync(shared)) copyFileSync(shared, join(dir, "_common.mjs")); return { ok: true, ms: 0, output: "reference solution copied" }; } - const prompt = `Read TASK.md in this directory and complete it. You have the PolyCSS skill installed in this workspace — use it.\n\n${task.prompt}`; + const preamble = TRACKS[track].installSkill + ? "Read TASK.md in this directory and complete it. You have the PolyCSS skill installed in this workspace — use it." + : "Read TASK.md in this directory and complete it."; + const prompt = `${preamble}\n\n${taskPrompt(track, task)}`; const started = Date.now(); try { const output = execFileSync(agent.bin, agent.argv(prompt, dir), { @@ -160,9 +178,11 @@ async function main(argv) { let agents; let tasks; + let tracks; try { agents = expandAgents(options.agents.length > 0 ? options.agents : ["oracle"]); tasks = selectTasks(options.tasks); + tracks = selectTracks(options.tracks); } catch (error) { console.error(error.message); return 1; @@ -182,16 +202,17 @@ async function main(argv) { } console.log( - `[eval] ${agents.length} agent(s) x ${tasks.length} task(s) = ${agents.length * tasks.length} run(s)\n`, + `[eval] ${agents.length} agent(s) x ${tracks.length} track(s) x ${tasks.length} task(s) = ${agents.length * tracks.length * tasks.length} run(s)\n`, ); const candidates = []; for (const name of agents) { - for (const task of tasks) { - process.stdout.write(`[eval] ${name} / ${task.id} ... `); - const dir = makeWorkspace(name, task); - const run = runAgent(name, dir, task, options.timeout); + for (const track of tracks) { + for (const task of tasks) { + process.stdout.write(`[eval] ${name} / ${track} / ${task.id} ... `); + const dir = makeWorkspace(track, name, task); + const run = runAgent(name, track, dir, task, options.timeout); let source = null; try { @@ -207,14 +228,17 @@ async function main(argv) { ); candidates.push({ - key: `${name}__${task.id}`, + key: `${name}__${track}__${task.id}`, agent: name, + trackName: track, + track: TRACKS[track], task, dir, run, source, entry: source === null ? null : join(dir, "scene.mjs"), }); + } } } @@ -229,9 +253,12 @@ async function main(argv) { const rows = candidates.map((candidate) => { const result = byKey.get(candidate.key); + const applicable = candidate.track.installSkill + ? allChecks(candidate.task) + : candidate.task.visual; const checks = result?.checks ?? - candidate.task.checks.map((check) => ({ + applicable.map((check) => ({ id: check.id, describe: check.describe, pass: false, @@ -240,6 +267,7 @@ async function main(argv) { const passed = checks.filter((c) => c.pass).length; return { agent: candidate.agent, + track: candidate.trackName, task: candidate.task.id, wroteScene: candidate.source !== null, buildOk: result?.build?.ok ?? false, @@ -247,21 +275,46 @@ async function main(argv) { seconds: Math.round(candidate.run.ms / 1000), passed, total: checks.length, + visualPassed: checks.filter((c) => c.pass && candidate.task.visual.some((v) => v.id === c.id)).length, + visualTotal: candidate.task.visual.length, checks, }; }); for (const agent of agents) { - const mine = rows.filter((r) => r.agent === agent); - const passed = mine.reduce((n, r) => n + r.passed, 0); - const total = mine.reduce((n, r) => n + r.total, 0); - console.log(`${AGENTS[agent].label} — ${passed}/${total} checks`); - for (const row of mine) { - console.log(` ${bar(row.passed, row.total)} ${row.passed}/${row.total} ${row.task} (${row.seconds}s)`); - if (row.buildError) console.log(` build failed: ${row.buildError}`); - for (const check of row.checks.filter((c) => !c.pass)) { - console.log(` x ${check.id}: ${check.reason}`); + for (const track of tracks) { + const mine = rows.filter((r) => r.agent === agent && r.track === track); + if (mine.length === 0) continue; + const passed = mine.reduce((n, r) => n + r.passed, 0); + const total = mine.reduce((n, r) => n + r.total, 0); + console.log(`${AGENTS[agent].label} / ${TRACKS[track].label} — ${passed}/${total} checks`); + for (const row of mine) { + console.log( + ` ${bar(row.passed, row.total)} ${row.passed}/${row.total} ${row.task} (${row.seconds}s)`, + ); + if (row.buildError) console.log(` build failed: ${row.buildError}`); + for (const check of row.checks.filter((c) => !c.pass)) { + console.log(` x ${check.id}: ${check.reason}`); + } } + console.log(); + } + } + + // The comparison the control exists for: the same visual criteria, scored on + // each track independently. Never a pixel diff between the two. + if (tracks.length > 1) { + console.log("Visual criteria only (comparable across tracks)\n"); + const width = Math.max(...agents.map((a) => AGENTS[a].label.length)); + console.log(`${"agent".padEnd(width)} ${tracks.map((t) => TRACKS[t].label.padEnd(10)).join(" ")}`); + for (const agent of agents) { + const cells = tracks.map((track) => { + const mine = rows.filter((r) => r.agent === agent && r.track === track); + const p = mine.reduce((n, r) => n + r.visualPassed, 0); + const t = mine.reduce((n, r) => n + r.visualTotal, 0); + return `${p}/${t}`.padEnd(10); + }); + console.log(`${AGENTS[agent].label.padEnd(width)} ${cells.join(" ")}`); } console.log(); } diff --git a/eval/skill/selftest.mjs b/eval/skill/selftest.mjs index ed7fd4788..fee991a7b 100644 --- a/eval/skill/selftest.mjs +++ b/eval/skill/selftest.mjs @@ -21,7 +21,7 @@ const here = resolve(fileURLToPath(import.meta.url), ".."); const scratch = join(here, ".work", "__selftest__"); const task = (id) => TASKS.find((t) => t.id === id); -const oracle = (id) => readFileSync(join(here, "oracle", `${id}.mjs`), "utf8"); +const oracle = (id) => readFileSync(join(here, "oracle", "polycss", `${id}.mjs`), "utf8"); /** Apply a required replacement, failing loudly if the source drifted. */ function patch(source, from, to) { diff --git a/eval/skill/tasks.mjs b/eval/skill/tasks.mjs index ddd9d8c1d..b1a8e2392 100644 --- a/eval/skill/tasks.mjs +++ b/eval/skill/tasks.mjs @@ -11,20 +11,6 @@ * hidden`, so a reversed face keeps its box while painting nothing. */ -const CONTRACT = `Write your scene in a single file \`scene.mjs\` in this directory. - -It must export one function: - - export function mount(host) { ... } - -\`host\` is an empty \`
\` that is already in the document, sized 900x600. -Build the scene inside it. Import everything from "@layoutit/polycss" — the -bundler resolves that specifier, so do not add a package.json, do not install -anything, and do not use a CDN URL. - -Do not write any other file. Do not run a dev server. Do not try to open a -browser. When \`scene.mjs\` is written, you are done.`; - const hueOf = ([r, g, b]) => { const max = Math.max(r, g, b); const min = Math.min(r, g, b); @@ -57,10 +43,43 @@ const hexToRgb = (hex) => { * samples are the only evidence that survives that. */ -/** The harness page paints on white, so anything off-white is scene content. */ -const isBackground = ([r, g, b]) => r > 246 && g > 246 && b > 246; +/** + * The page background, measured rather than assumed. A PolyCSS scene is DOM on + * a white page; a WebGL control track paints whatever clear color it chose. The + * four corners are background in every framing this suite grades, so their + * modal color is the ground truth for "not scene content". + */ +function backgroundOf(snapshot) { + if (snapshot.__bg) return snapshot.__bg; + const { cols, rows, rgb } = snapshot.pixels; + const block = 6; + const counts = new Map(); + for (const [ox, oy] of [ + [0, 0], + [cols - block, 0], + [0, rows - block], + [cols - block, rows - block], + ]) { + for (let y = 0; y < block; y += 1) { + for (let x = 0; x < block; x += 1) { + const c = rgb[(oy + y) * cols + (ox + x)]; + const key = c.map((v) => Math.round(v / 8) * 8).join(","); + counts.set(key, (counts.get(key) ?? 0) + 1); + } + } + } + const best = [...counts.entries()].sort((a, b) => b[1] - a[1])[0][0].split(",").map(Number); + snapshot.__bg = best; + return best; +} -const paintedPixels = (snapshot) => snapshot.pixels.rgb.filter((rgb) => !isBackground(rgb)); +const isBackgroundOf = (rgb, bg) => + Math.abs(rgb[0] - bg[0]) + Math.abs(rgb[1] - bg[1]) + Math.abs(rgb[2] - bg[2]) <= 30; + +const paintedPixels = (snapshot) => { + const bg = backgroundOf(snapshot); + return snapshot.pixels.rgb.filter((rgb) => !isBackgroundOf(rgb, bg)); +}; const paintedFraction = (snapshot) => paintedPixels(snapshot).length / snapshot.pixels.rgb.length; @@ -74,9 +93,10 @@ const pixelsWithHue = (snapshot, hex, tolerance = 30) => { const target = hueOf(hexToRgb(hex)); if (target === null) return []; const { cols } = snapshot.pixels; + const bg = backgroundOf(snapshot); const out = []; snapshot.pixels.rgb.forEach((rgb, index) => { - if (isBackground(rgb)) return; + if (isBackgroundOf(rgb, bg)) return; const hue = hueOf(rgb); if (hue === null || hueDistance(hue, target) > tolerance) return; out.push({ rgb, x: index % cols, y: Math.floor(index / cols) }); @@ -141,9 +161,10 @@ function surfaceBrightness(snapshot, hex) { */ function receiverContrast(snapshot, excludeHex) { const target = excludeHex === null ? null : hueOf(hexToRgb(excludeHex)); + const bg = backgroundOf(snapshot); const lumas = []; for (const rgb of snapshot.pixels.rgb) { - if (isBackground(rgb)) continue; + if (isBackgroundOf(rgb, bg)) continue; if (target !== null) { const hue = hueOf(rgb); if (hue !== null && hueDistance(hue, target) <= 30) continue; @@ -297,7 +318,6 @@ const shadowDarkensReceiver = (casterHex) => ({ id: "shadow-contrast", describe: "the cast shadow visibly darkens the receiver", run: ({ first }) => { - if (first.shadow.pathCount === 0) return "no shadow geometry was emitted at all"; const c = receiverContrast(first, casterHex); if (c.count < 200) return `not enough receiver surface to measure (${c.count} samples)`; return c.ratio <= 0.95 @@ -320,17 +340,17 @@ export const TASKS = [ reads clearly as its own color and each face is shaded differently. - Frame it so the cube fills roughly a third of the 900x600 viewport: clearly more than a speck, and not running off the edges. Remember that on-screen - size comes from BOTH the shape's world size and the camera zoom. - -${CONTRACT}`, - checks: [ + size comes from BOTH the shape's world size and the camera zoom.`, + visual: [ mountsCleanly, - hasScene, framedWithin(0.08, 0.6), colorMatches("#ff8c1a"), litWithin("#ff8c1a", 40, 210), isShaded("#ff8c1a", 2), isStill, + ], + native: [ + hasScene, { id: "one-mesh", describe: "adds exactly one mesh", @@ -360,16 +380,16 @@ ${CONTRACT}`, - Light it with a plain white directional light plus ambient fill. - Frame it so the cube fills roughly a third of the 900x600 viewport: clearly more than a speck, and not running off the edges. Remember that on-screen - size comes from BOTH the shape's world size and the camera zoom. - -${CONTRACT}`, - checks: [ + size comes from BOTH the shape's world size and the camera zoom.`, + visual: [ mountsCleanly, - hasScene, framedWithin(0.08, 0.6), colorMatches("#14b8a6"), litWithin("#14b8a6", 25, 190), isMoving, + ], + native: [ + hasScene, { id: "no-raf-loop", describe: "does not hand-roll a per-polygon animation loop", @@ -389,14 +409,15 @@ surface, lit by a directional light. - The cube must be amber: #fbbf24. - The cube must cast a visible shadow onto the ground. -- The camera is fixed. - -${CONTRACT}`, - checks: [ +- The camera is fixed.`, + visual: [ mountsCleanly, - hasScene, paintsSomething(), colorMatches("#fbbf24"), + shadowDarkensReceiver("#fbbf24"), + ], + native: [ + hasScene, { id: "shadow-drawn", describe: "a cast shadow is actually painted", @@ -405,7 +426,6 @@ ${CONTRACT}`, ? true : `no shadow geometry rendered (${first.shadow.svgCount} svg, ${first.shadow.pathCount} paths, area ${first.shadow.area})`, }, - shadowDarkensReceiver("#fbbf24"), { id: "receiver", describe: "a receiver exists — vanilla has no ground-shadow fallback", @@ -426,21 +446,12 @@ separated so neither hides the other: - a cube in indigo #6366f1 - a sphere in rose #f43f5e -The camera is fixed. Use the default lighting. - -${CONTRACT}`, - checks: [ +The camera is fixed. Use the default lighting.`, + visual: [ mountsCleanly, - hasScene, paintsSomething(0.04), colorMatches("#6366f1"), colorMatches("#f43f5e"), - { - id: "two-meshes", - describe: "adds two separate meshes", - run: ({ first }) => - first.meshCount === 2 ? true : `expected 2 meshes, found ${first.meshCount}`, - }, { id: "separated", describe: "the two shapes do not overlap on screen", @@ -460,6 +471,15 @@ ${CONTRACT}`, }, }, ], + native: [ + hasScene, + { + id: "two-meshes", + describe: "adds two separate meshes", + run: ({ first }) => + first.meshCount === 2 ? true : `expected 2 meshes, found ${first.meshCount}`, + }, + ], }, { @@ -472,23 +492,10 @@ tiles lying on the ground, seen from above at an angle. use any built-in shape helper or generator, and do not load a model file. - Every tile is lime: #84cc16. - All four tiles must be visible from the camera looking down at them. -- Leave a small gap between the tiles so the four squares read separately. - -${CONTRACT}`, - checks: [ +- Leave a small gap between the tiles so the four squares read separately.`, + visual: [ mountsCleanly, - hasScene, colorMatches("#84cc16"), - { - id: "authored", - describe: "geometry is hand-authored, not generated by a helper", - run: ({ source }) => - /(box|sphere|cone|cylinder|plane|torus|tetrahedron|octahedron|icosahedron|dodecahedron)Polygons|createPoly(Box|Sphere|Cone|Cylinder|Plane|Torus|Tetrahedron|Octahedron|Icosahedron|Dodecahedron)/i.test( - source, - ) - ? "used a built-in shape helper instead of authoring polygons" - : true, - }, { id: "faces-visible", describe: "all four tiles face the camera (correct winding)", @@ -507,6 +514,19 @@ ${CONTRACT}`, : `only ${(share * 100).toFixed(1)}% of the viewport is lime - tiles wound away from the camera are backface-culled and paint nothing`; }, }, + ], + native: [ + hasScene, + { + id: "authored", + describe: "geometry is hand-authored, not generated by a helper", + run: ({ source }) => + /(box|sphere|cone|cylinder|plane|torus|tetrahedron|octahedron|icosahedron|dodecahedron)Polygons|createPoly(Box|Sphere|Cone|Cylinder|Plane|Torus|Tetrahedron|Octahedron|Icosahedron|Dodecahedron)/i.test( + source, + ) + ? "used a built-in shape helper instead of authoring polygons" + : true, + }, { id: "no-named-colors", describe: "uses a parseable color format", @@ -529,16 +549,17 @@ ${CONTRACT}`, yellow #eab308. - A directional light plus some ambient fill. - All three shapes cast shadows onto the ground. -- The user can drag to orbit and use the wheel to zoom. - -${CONTRACT}`, - checks: [ +- The user can drag to orbit and use the wheel to zoom.`, + visual: [ mountsCleanly, - hasScene, paintsSomething(0.15), colorMatches("#ef4444"), colorMatches("#3b82f6"), colorMatches("#eab308"), + shadowDarkensReceiver(null), + ], + native: [ + hasScene, { id: "four-meshes", describe: "ground plus three shapes are separate meshes", @@ -553,7 +574,6 @@ ${CONTRACT}`, ? true : `no shadow geometry rendered (${first.shadow.pathCount} paths, area ${first.shadow.area})`, }, - shadowDarkensReceiver(null), { id: "controls", describe: "uses the built-in controls rather than manual input handling", @@ -568,6 +588,9 @@ ${CONTRACT}`, export const TASK_IDS = TASKS.map((t) => t.id); +/** Every check a task can run, for reporting totals. */ +export const allChecks = (task) => [...task.visual, ...task.native]; + export function selectTasks(ids) { if (ids.length === 0) return TASKS; const unknown = ids.filter((id) => !TASK_IDS.includes(id)); diff --git a/eval/skill/tracks.mjs b/eval/skill/tracks.mjs new file mode 100644 index 000000000..b54310ee2 --- /dev/null +++ b/eval/skill/tracks.mjs @@ -0,0 +1,88 @@ +/** + * The two tracks an agent is asked to build the same scene in. + * + * `polycss` is what we are actually evaluating. `three` is the **control**: the + * same model, the same task, a library it already knows well, and no skill + * installed. It exists to answer the question a PolyCSS score alone cannot — + * "did the agent fail because our skill is inadequate, or because the task + * itself is hard for it?" + * + * The tracks are graded independently and never compared pixel-to-pixel. A + * PolyCSS scene is never diffed against a Three.js render, and the Three.js + * output is never a reference image. Both are measured against the SAME + * task-level visual criteria — is it the right color, the right size, lit, + * moving, shadowed — so the two scores are comparable as scores while each + * scene is judged only on its own merits. + * + * Workspaces are separate and neither knows the other exists. + */ +import { resolve } from "node:path"; +import { fileURLToPath } from "node:url"; + +const repoRoot = resolve(fileURLToPath(import.meta.url), "..", "..", ".."); + +const SHARED_CONTRACT = `Write your scene in a single file \`scene.mjs\` in this directory. + +It must export one function: + + export function mount(host) { ... } + +\`host\` is an empty \`
\` that is already in the document, sized 900x600. +Build the scene inside it. + +Do not write any other file. Do not add a package.json, do not install +anything, and do not use a CDN URL. Do not run a dev server. Do not try to open +a browser. When \`scene.mjs\` is written, you are done.`; + +export const TRACKS = { + polycss: { + label: "PolyCSS", + /** The skill under test is installed into the workspace. */ + installSkill: true, + contract: `${SHARED_CONTRACT} + +Import everything from "@layoutit/polycss" — the bundler resolves that +specifier. The scene renders as DOM elements, on the page's white background.`, + alias: { + "@layoutit/polycss-core": resolve(repoRoot, "packages/core/src/index.ts"), + "@layoutit/polycss-core/three": resolve(repoRoot, "packages/core/src/three/index.ts"), + "@layoutit/polycss": resolve(repoRoot, "packages/polycss/src/index.ts"), + "@layoutit/polycss/elements": resolve(repoRoot, "packages/polycss/src/elements/index.ts"), + "@layoutit/polycss/three": resolve(repoRoot, "packages/polycss/src/three.ts"), + }, + }, + + three: { + label: "Three.js", + /** + * No skill, deliberately. The control measures what the model can do from + * its own training, which is the whole point of the comparison. + */ + installSkill: false, + contract: `${SHARED_CONTRACT} + +Import from "three" (and "three/addons/..." for anything under examples/jsm) — +the bundler resolves those specifiers. + +Create your own WebGLRenderer sized to the host and append its canvas to the +host. Set the renderer clear color to white, so the scene reads on the page the +same way any other scene would. Drive it with a continuous render loop.`, + alias: { + three: resolve(repoRoot, "node_modules/three/build/three.module.js"), + "three/addons": resolve(repoRoot, "node_modules/three/examples/jsm"), + "three/examples/jsm": resolve(repoRoot, "node_modules/three/examples/jsm"), + }, + }, +}; + +export const TRACK_NAMES = Object.keys(TRACKS); + +export function selectTracks(names) { + const requested = names.length === 0 ? ["polycss"] : names; + const expanded = requested.flatMap((n) => (n === "all" ? TRACK_NAMES : [n])); + const unknown = expanded.filter((n) => !TRACK_NAMES.includes(n)); + if (unknown.length > 0) { + throw new Error(`unknown track(s): ${unknown.join(", ")}\nknown: ${TRACK_NAMES.join(", ")}, all`); + } + return [...new Set(expanded)]; +} diff --git a/eval/skill/verify.mjs b/eval/skill/verify.mjs index e4dadb086..a90b5a1b3 100644 --- a/eval/skill/verify.mjs +++ b/eval/skill/verify.mjs @@ -19,18 +19,9 @@ import { fileURLToPath } from "node:url"; import { chromium } from "playwright"; import { chromiumArgsWithGpuDefault } from "../../bench/chromium-defaults.mjs"; +import { TRACKS } from "./tracks.mjs"; const here = resolve(fileURLToPath(import.meta.url), ".."); -const repoRoot = resolve(here, "..", ".."); - -/** Same aliasing as bench/build.mjs: bundle workspace SOURCE, not dist. */ -const ALIASES = { - "@layoutit/polycss-core": resolve(repoRoot, "packages/core/src/index.ts"), - "@layoutit/polycss-core/three": resolve(repoRoot, "packages/core/src/three/index.ts"), - "@layoutit/polycss": resolve(repoRoot, "packages/polycss/src/index.ts"), - "@layoutit/polycss/elements": resolve(repoRoot, "packages/polycss/src/elements/index.ts"), - "@layoutit/polycss/three": resolve(repoRoot, "packages/polycss/src/three.ts"), -}; const MIME = { ".html": "text/html; charset=utf-8", @@ -39,7 +30,9 @@ const MIME = { ".css": "text/css; charset=utf-8", }; -export async function bundleScene(entry, outfile) { +/** Each track brings its own aliases: workspace SOURCE for PolyCSS, the + * workspace copy of three for the control. */ +export async function bundleScene(entry, outfile, alias = TRACKS.polycss.alias) { try { await build({ entryPoints: [entry], @@ -48,7 +41,7 @@ export async function bundleScene(entry, outfile) { format: "esm", platform: "browser", target: "es2020", - alias: ALIASES, + alias, loader: { ".ts": "ts", ".tsx": "tsx" }, jsx: "automatic", logLevel: "silent", @@ -139,7 +132,11 @@ export async function verifyCandidates(candidates, { headed = false, settleMs } for (const candidate of candidates) { const outfile = join(bundleDir, `${candidate.key}.js`); - const result = await bundleScene(candidate.entry, outfile); + const result = await bundleScene( + candidate.entry, + outfile, + (candidate.track ?? TRACKS.polycss).alias, + ); built.push({ ...candidate, outfile, build: result }); } @@ -158,10 +155,12 @@ export async function verifyCandidates(candidates, { headed = false, settleMs } const results = []; for (const candidate of built) { + const checksFor = (c) => [...c.task.visual, ...((c.track ?? TRACKS.polycss).installSkill ? c.task.native : [])]; + if (!candidate.build.ok) { results.push({ ...candidate, - checks: candidate.task.checks.map((check) => ({ + checks: checksFor(candidate).map((check) => ({ id: check.id, describe: check.describe, pass: false, @@ -177,7 +176,7 @@ export async function verifyCandidates(candidates, { headed = false, settleMs } const probe = await probeScene(page, url, { settleMs }); const ctx = { ...probe, source: candidate.source }; - const checks = candidate.task.checks.map((check) => { + const checks = checksFor(candidate).map((check) => { let outcome; try { outcome = check.run(ctx); From 3290f80a595db06fc2d228c96a04f1d7884e55cb Mon Sep 17 00:00:00 2001 From: Juan Cruz Fortunatti Date: Thu, 13 Aug 2026 02:37:58 +0200 Subject: [PATCH 07/37] test(skills): measure framing on the subject hue so an overscaled shape cannot pass --- eval/skill/tasks.mjs | 17 ++++++++++------- 1 file changed, 10 insertions(+), 7 deletions(-) diff --git a/eval/skill/tasks.mjs b/eval/skill/tasks.mjs index b1a8e2392..01b5c827b 100644 --- a/eval/skill/tasks.mjs +++ b/eval/skill/tasks.mjs @@ -229,16 +229,19 @@ const paintsSomething = (minFraction = 0.03) => ({ * the camera zoom — and getting either wrong is invisible to a floor-only * check: a cube scaled past the viewport edges paints ~100% and "passes". */ -const framedWithin = (minFraction, maxFraction) => ({ +const framedWithin = (hex, minFraction, maxFraction) => ({ id: "scale", - describe: `fills between ${Math.round(minFraction * 100)}% and ${Math.round(maxFraction * 100)}% of the viewport`, + describe: `the subject fills between ${Math.round(minFraction * 100)}% and ${Math.round(maxFraction * 100)}% of the viewport`, run: ({ first }) => { - const covered = paintedFraction(first); + // Measured on the SUBJECT's own hue, not on "anything not background". + // A shape scaled past every edge becomes the background by definition — + // corner sampling would call the cube the page and report ~0% painted. + const covered = pixelsWithHue(first, hex).length / first.pixels.rgb.length; if (covered < minFraction) { - return `fills only ${(covered * 100).toFixed(1)}% of the viewport - too small; raise the shape size or the camera zoom`; + return `the ${hex} subject fills only ${(covered * 100).toFixed(1)}% of the viewport - too small, or scaled so far past the edges that it fills the frame; raise or lower both the shape size and the camera zoom`; } if (covered > maxFraction) { - return `fills ${(covered * 100).toFixed(1)}% of the viewport - overscaled and running past the edges; lower the shape size or the camera zoom`; + return `the ${hex} subject fills ${(covered * 100).toFixed(1)}% of the viewport - overscaled and running past the edges; lower the shape size or the camera zoom`; } return true; }, @@ -343,7 +346,7 @@ export const TASKS = [ size comes from BOTH the shape's world size and the camera zoom.`, visual: [ mountsCleanly, - framedWithin(0.08, 0.6), + framedWithin("#ff8c1a", 0.05, 0.55), colorMatches("#ff8c1a"), litWithin("#ff8c1a", 40, 210), isShaded("#ff8c1a", 2), @@ -383,7 +386,7 @@ export const TASKS = [ size comes from BOTH the shape's world size and the camera zoom.`, visual: [ mountsCleanly, - framedWithin(0.08, 0.6), + framedWithin("#14b8a6", 0.05, 0.55), colorMatches("#14b8a6"), litWithin("#14b8a6", 25, 190), isMoving, From 50af3246dbe6559651710aefb3960ef84e6209bc Mon Sep 17 00:00:00 2001 From: Juan Cruz Fortunatti Date: Thu, 13 Aug 2026 02:39:37 +0200 Subject: [PATCH 08/37] test(skills): treat the shared white page as background on both tracks --- eval/skill/tasks.mjs | 53 ++++++++++++-------------------------------- 1 file changed, 14 insertions(+), 39 deletions(-) diff --git a/eval/skill/tasks.mjs b/eval/skill/tasks.mjs index 01b5c827b..ee5026fca 100644 --- a/eval/skill/tasks.mjs +++ b/eval/skill/tasks.mjs @@ -44,42 +44,17 @@ const hexToRgb = (hex) => { */ /** - * The page background, measured rather than assumed. A PolyCSS scene is DOM on - * a white page; a WebGL control track paints whatever clear color it chose. The - * four corners are background in every framing this suite grades, so their - * modal color is the ground truth for "not scene content". + * Both tracks render on a white page: PolyCSS paints DOM over it, and the + * Three.js contract requires a white renderer clear color. So "not white" is + * scene content on either track. + * + * Sampling the corners instead would be self-defeating for the one case that + * most needs catching — a shape scaled past every edge fills the corners, and + * would be measured as the background it overran. */ -function backgroundOf(snapshot) { - if (snapshot.__bg) return snapshot.__bg; - const { cols, rows, rgb } = snapshot.pixels; - const block = 6; - const counts = new Map(); - for (const [ox, oy] of [ - [0, 0], - [cols - block, 0], - [0, rows - block], - [cols - block, rows - block], - ]) { - for (let y = 0; y < block; y += 1) { - for (let x = 0; x < block; x += 1) { - const c = rgb[(oy + y) * cols + (ox + x)]; - const key = c.map((v) => Math.round(v / 8) * 8).join(","); - counts.set(key, (counts.get(key) ?? 0) + 1); - } - } - } - const best = [...counts.entries()].sort((a, b) => b[1] - a[1])[0][0].split(",").map(Number); - snapshot.__bg = best; - return best; -} +const isBackground = ([r, g, b]) => r > 244 && g > 244 && b > 244; -const isBackgroundOf = (rgb, bg) => - Math.abs(rgb[0] - bg[0]) + Math.abs(rgb[1] - bg[1]) + Math.abs(rgb[2] - bg[2]) <= 30; - -const paintedPixels = (snapshot) => { - const bg = backgroundOf(snapshot); - return snapshot.pixels.rgb.filter((rgb) => !isBackgroundOf(rgb, bg)); -}; +const paintedPixels = (snapshot) => snapshot.pixels.rgb.filter((rgb) => !isBackground(rgb)); const paintedFraction = (snapshot) => paintedPixels(snapshot).length / snapshot.pixels.rgb.length; @@ -93,10 +68,9 @@ const pixelsWithHue = (snapshot, hex, tolerance = 30) => { const target = hueOf(hexToRgb(hex)); if (target === null) return []; const { cols } = snapshot.pixels; - const bg = backgroundOf(snapshot); const out = []; snapshot.pixels.rgb.forEach((rgb, index) => { - if (isBackgroundOf(rgb, bg)) return; + if (isBackground(rgb)) return; const hue = hueOf(rgb); if (hue === null || hueDistance(hue, target) > tolerance) return; out.push({ rgb, x: index % cols, y: Math.floor(index / cols) }); @@ -161,10 +135,9 @@ function surfaceBrightness(snapshot, hex) { */ function receiverContrast(snapshot, excludeHex) { const target = excludeHex === null ? null : hueOf(hexToRgb(excludeHex)); - const bg = backgroundOf(snapshot); const lumas = []; for (const rgb of snapshot.pixels.rgb) { - if (isBackgroundOf(rgb, bg)) continue; + if (isBackground(rgb)) continue; if (target !== null) { const hue = hueOf(rgb); if (hue !== null && hueDistance(hue, target) <= 30) continue; @@ -236,7 +209,9 @@ const framedWithin = (hex, minFraction, maxFraction) => ({ // Measured on the SUBJECT's own hue, not on "anything not background". // A shape scaled past every edge becomes the background by definition — // corner sampling would call the cube the page and report ~0% painted. - const covered = pixelsWithHue(first, hex).length / first.pixels.rgb.length; + const covered = paintedFraction(first); + const subject = pixelsWithHue(first, hex).length / first.pixels.rgb.length; + if (subject < 0.02) return `almost none of the viewport is ${hex} (${(subject * 100).toFixed(1)}%)`; if (covered < minFraction) { return `the ${hex} subject fills only ${(covered * 100).toFixed(1)}% of the viewport - too small, or scaled so far past the edges that it fills the frame; raise or lower both the shape size and the camera zoom`; } From 133010396af4c2de61e9795391db1530ccc7a525 Mon Sep 17 00:00:00 2001 From: Juan Cruz Fortunatti Date: Thu, 13 Aug 2026 04:00:55 +0200 Subject: [PATCH 09/37] test(skills): isolate workspaces outside the repo and calibrate thresholds from the control --- eval/skill/run.mjs | 93 ++++++++++++++++++++++++++++++++++++++++---- eval/skill/tasks.mjs | 26 ++++++++----- 2 files changed, 102 insertions(+), 17 deletions(-) diff --git a/eval/skill/run.mjs b/eval/skill/run.mjs index b5a799e12..5d090339a 100644 --- a/eval/skill/run.mjs +++ b/eval/skill/run.mjs @@ -25,7 +25,8 @@ import { rmSync, writeFileSync, } from "node:fs"; -import { basename, join, resolve } from "node:path"; +import { tmpdir } from "node:os"; +import { join, resolve } from "node:path"; import { fileURLToPath } from "node:url"; import { AGENTS, AGENT_NAMES, availableAgents, expandAgents, isAvailable } from "./agents.mjs"; @@ -35,7 +36,13 @@ import { verifyCandidates } from "./verify.mjs"; const here = resolve(fileURLToPath(import.meta.url), ".."); const repoRoot = resolve(here, "..", ".."); -const workRoot = join(here, ".work"); +/** + * Workspaces live OUTSIDE the repository. Inside it, an agent can walk up out + * of its workspace and read the PolyCSS monorepo — measured: a control-track + * run with no skill installed found `@layoutit/polycss/three` in the source + * tree and imported it, which is not the clean room this is supposed to be. + */ +const workRoot = join(tmpdir(), "polycss-skill-eval"); const installer = join(repoRoot, "packages/skills/bin/polycss-skills.mjs"); const USAGE = `Usage: node eval/skill/run.mjs [options] @@ -47,8 +54,11 @@ Options Default: polycss. "three" is the no-skill control. --task Comma-separated task ids. Default: all. --keep Keep the agent workspaces for inspection. + --reuse Reuse a workspace that already has a scene.mjs instead of + re-running the agent. Grades existing work for free. + --no-preflight Skip the per-agent readiness probe. --headed Run Chromium headed. - --settle Delay between the two DOM samples (default 1200). + --settle Delay between the two DOM samples (default 2000). --timeout Per-agent-invocation timeout (default 600). --json Also write the full result as JSON. --list List agents and tasks, then exit. @@ -63,8 +73,10 @@ function parseArgs(argv) { tracks: [], tasks: [], keep: false, + reuse: false, + preflight: true, headed: false, - settle: 1200, + settle: 2000, timeout: 600, json: null, list: false, @@ -81,6 +93,8 @@ function parseArgs(argv) { else if (arg === "--track") options.tracks.push(...value().split(",").map((t) => t.trim())); else if (arg === "--task") options.tasks.push(...value().split(",").map((t) => t.trim())); else if (arg === "--keep") options.keep = true; + else if (arg === "--reuse") options.reuse = true; + else if (arg === "--no-preflight") options.preflight = false; else if (arg === "--headed") options.headed = true; else if (arg === "--settle") options.settle = Number(value()); else if (arg === "--timeout") options.timeout = Number(value()); @@ -137,6 +151,10 @@ function runAgent(name, track, dir, task, timeoutSeconds) { cwd: dir, encoding: "utf8", timeout: timeoutSeconds * 1000, + // SIGTERM is advisory and some CLIs ignore it while blocked on an + // interactive prompt — one unauthenticated agent sat on a task for 48 + // minutes past its timeout before this was SIGKILL. + killSignal: "SIGKILL", maxBuffer: 64 * 1024 * 1024, stdio: ["ignore", "pipe", "pipe"], }); @@ -150,6 +168,42 @@ function runAgent(name, track, dir, task, timeoutSeconds) { } } +/** + * One trivial task per agent before the real matrix starts. An agent that is + * not logged in blocks on an interactive prompt forever; finding that out now + * costs one minute instead of an hour of dead runs. + */ +function preflight(name, timeoutSeconds = 90) { + if (name === "oracle") return { ok: true }; + const dir = join(workRoot, "__preflight__", name); + rmSync(dir, { recursive: true, force: true }); + mkdirSync(dir, { recursive: true }); + const agent = AGENTS[name]; + try { + execFileSync( + agent.bin, + agent.argv("Write a file named ready.txt containing the word READY. Then stop.", dir), + { + cwd: dir, + encoding: "utf8", + timeout: timeoutSeconds * 1000, + killSignal: "SIGKILL", + maxBuffer: 8 * 1024 * 1024, + stdio: ["ignore", "pipe", "pipe"], + }, + ); + } catch (error) { + const out = `${error.stdout ?? ""}${error.stderr ?? ""}`; + if (/authenticat|sign in|login|api key/i.test(out)) { + return { ok: false, reason: "needs authentication — run the CLI once interactively and log in" }; + } + return { ok: false, reason: `probe failed after ${timeoutSeconds}s (${error.code ?? error.message})` }; + } + return existsSync(join(dir, "ready.txt")) + ? { ok: true } + : { ok: false, reason: "probe returned but wrote no file" }; +} + const bar = (passed, total) => { const filled = total === 0 ? 0 : Math.round((passed / total) * 10); return `${"#".repeat(filled)}${".".repeat(10 - filled)}`; @@ -201,6 +255,21 @@ async function main(argv) { return 1; } + if (options.preflight && !options.reuse) { + for (const name of [...agents]) { + process.stdout.write(`[eval] preflight ${name} ... `); + const ready = preflight(name); + console.log(ready.ok ? "ready" : `SKIPPED: ${ready.reason}`); + if (!ready.ok) agents.splice(agents.indexOf(name), 1); + } + rmSync(join(workRoot, "__preflight__"), { recursive: true, force: true }); + if (agents.length === 0) { + console.error("[eval] no agent is ready"); + return 1; + } + console.log(); + } + console.log( `[eval] ${agents.length} agent(s) x ${tracks.length} track(s) x ${tasks.length} task(s) = ${agents.length * tracks.length * tasks.length} run(s)\n`, ); @@ -211,8 +280,14 @@ async function main(argv) { for (const track of tracks) { for (const task of tasks) { process.stdout.write(`[eval] ${name} / ${track} / ${task.id} ... `); - const dir = makeWorkspace(track, name, task); - const run = runAgent(name, track, dir, task, options.timeout); + const existing = join(workRoot, track, name, task.id, "scene.mjs"); + const reusing = options.reuse && existsSync(existing); + const dir = reusing + ? join(workRoot, track, name, task.id) + : makeWorkspace(track, name, task); + const run = reusing + ? { ok: true, ms: 0, output: "reused existing workspace" } + : runAgent(name, track, dir, task, options.timeout); let source = null; try { @@ -224,7 +299,9 @@ async function main(argv) { console.log( source === null ? `no scene.mjs (${(run.ms / 1000).toFixed(0)}s)` - : `wrote scene.mjs (${(run.ms / 1000).toFixed(0)}s)`, + : reusing + ? "reused scene.mjs" + : `wrote scene.mjs (${(run.ms / 1000).toFixed(0)}s)`, ); candidates.push({ @@ -326,7 +403,7 @@ async function main(argv) { if (!options.keep) { rmSync(workRoot, { recursive: true, force: true }); } else { - console.log(`[eval] workspaces kept in ${workRoot.replace(`${repoRoot}/`, "")}`); + console.log(`[eval] workspaces kept in ${workRoot}`); } const allPassed = rows.every((r) => r.passed === r.total); diff --git a/eval/skill/tasks.mjs b/eval/skill/tasks.mjs index ee5026fca..2794847d2 100644 --- a/eval/skill/tasks.mjs +++ b/eval/skill/tasks.mjs @@ -146,10 +146,13 @@ function receiverContrast(snapshot, excludeHex) { } lumas.sort((a, b) => a - b); const median = percentile(lumas, 0.5); - const dark = percentile(lumas, 0.1); - const threshold = median * 0.85; - const darkShare = lumas.length === 0 ? 0 : lumas.filter((l) => l < threshold).length / lumas.length; - return { count: lumas.length, median, dark, ratio: median === 0 ? 1 : dark / median, darkShare }; + // Size of the dark patch, not a percentile of it. Once the caster's own + // shaded faces are excluded, a shadow is a small minority of receiver + // pixels — the 10th percentile still lands on lit ground, which read as + // "no contrast" even for shadows that are plainly visible. + const darkShare = + lumas.length === 0 ? 0 : lumas.filter((l) => l < median * 0.88).length / lumas.length; + return { count: lumas.length, median, darkShare }; } /** @@ -280,7 +283,7 @@ const isMoving = { describe: "camera orbits over time", run: ({ first, second }) => { const changed = changedFraction(first, second); - return changed >= 0.02 + return changed >= 0.01 ? true : `only ${(changed * 100).toFixed(1)}% of the image changed between samples — nothing is animating`; }, @@ -298,9 +301,9 @@ const shadowDarkensReceiver = (casterHex) => ({ run: ({ first }) => { const c = receiverContrast(first, casterHex); if (c.count < 200) return `not enough receiver surface to measure (${c.count} samples)`; - return c.ratio <= 0.95 + return c.darkShare >= 0.005 ? true - : `the receiver's dark tail is ${(c.ratio * 100).toFixed(0)}% of its median brightness - shadow paths are emitted but nothing is darker`; + : `only ${(c.darkShare * 100).toFixed(2)}% of the receiver is meaningfully darker than the rest - shadow geometry may be emitted, but nothing is shaded by it`; }, }); @@ -424,7 +427,9 @@ separated so neither hides the other: - a cube in indigo #6366f1 - a sphere in rose #f43f5e -The camera is fixed. Use the default lighting.`, +The camera is fixed. Use the default lighting. Frame the pair so they together +fill a good part of the 900x600 viewport rather than sitting small in the +middle: on-screen size comes from BOTH the shape sizes and the camera zoom.`, visual: [ mountsCleanly, paintsSomething(0.04), @@ -527,7 +532,10 @@ tiles lying on the ground, seen from above at an angle. yellow #eab308. - A directional light plus some ambient fill. - All three shapes cast shadows onto the ground. -- The user can drag to orbit and use the wheel to zoom.`, +- The user can drag to orbit and use the wheel to zoom. + +Frame the scene so it fills a good part of the 900x600 viewport: on-screen size +comes from BOTH the world sizes and the camera zoom.`, visual: [ mountsCleanly, paintsSomething(0.15), From 86388dd8252e98faeddf6b9d82ad7a0604c4fdc3 Mon Sep 17 00:00:00 2001 From: Juan Cruz Fortunatti Date: Thu, 13 Aug 2026 04:53:39 +0200 Subject: [PATCH 10/37] test(skills): state the shadow-visibility and framing requirements, count tiles by region --- eval/skill/tasks.mjs | 61 ++++++++++++++++++++++++++++++++++---------- 1 file changed, 48 insertions(+), 13 deletions(-) diff --git a/eval/skill/tasks.mjs b/eval/skill/tasks.mjs index 2794847d2..f4cab0d88 100644 --- a/eval/skill/tasks.mjs +++ b/eval/skill/tasks.mjs @@ -82,6 +82,40 @@ const pixelsWithHue = (snapshot, hex, tolerance = 30) => { const hasHue = (snapshot, hex, minPixels = 40) => pixelsWithHue(snapshot, hex).length >= minPixels; +/** + * Count 4-connected regions in a set of sampled pixels, ignoring specks. Used + * to ask "how many separate surfaces are painted" without caring how big they + * are on screen. + */ +function countRegions(pixels, cols, minSize = 4) { + const set = new Set(pixels.map((p) => p.y * cols + p.x)); + const seen = new Set(); + let regions = 0; + for (const start of set) { + if (seen.has(start)) continue; + const stack = [start]; + seen.add(start); + let size = 0; + while (stack.length > 0) { + const index = stack.pop(); + size += 1; + const x = index % cols; + const y = Math.floor(index / cols); + for (const [dx, dy] of [[1, 0], [-1, 0], [0, 1], [0, -1]]) { + const nx = x + dx; + const ny = y + dy; + if (nx < 0 || nx >= cols) continue; + const next = ny * cols + nx; + if (!set.has(next) || seen.has(next)) continue; + seen.add(next); + stack.push(next); + } + } + if (size >= minSize) regions += 1; + } + return regions; +} + const boundsOf = (pixels) => ({ x0: Math.min(...pixels.map((p) => p.x)), x1: Math.max(...pixels.map((p) => p.x)), @@ -389,7 +423,9 @@ export const TASKS = [ surface, lit by a directional light. - The cube must be amber: #fbbf24. -- The cube must cast a visible shadow onto the ground. +- The cube must cast a shadow onto the ground that is clearly visible from the + camera. Place the light so the shadow falls to one side rather than directly + behind the cube, where the cube itself would hide it. - The camera is fixed.`, visual: [ mountsCleanly, @@ -475,7 +511,9 @@ tiles lying on the ground, seen from above at an angle. use any built-in shape helper or generator, and do not load a model file. - Every tile is lime: #84cc16. - All four tiles must be visible from the camera looking down at them. -- Leave a small gap between the tiles so the four squares read separately.`, +- Leave a small gap between the tiles so the four squares read separately. +- Frame the grid so it fills a good part of the 900x600 viewport: on-screen size + comes from BOTH the tile sizes and the camera zoom.`, visual: [ mountsCleanly, colorMatches("#84cc16"), @@ -483,18 +521,15 @@ tiles lying on the ground, seen from above at an angle. id: "faces-visible", describe: "all four tiles face the camera (correct winding)", run: ({ first }) => { - // Four flat tiles share one normal, so they share one Lambert shade - // and the only question is how much of that shade is painted. A tile - // wound the other way is backface-culled and paints nothing at all, - // so lost coverage counts the tiles that got their vertex order - // wrong. Four tiles were measured at ~9% of the viewport; require - // enough for all four rather than a bare majority. - const painted = pixelsWithHue(first, "#84cc16").length; - const total = first.pixels.rgb.length; - const share = painted / total; - return share >= 0.055 + // Count separate painted regions, not total coverage. A tile wound + // away from the camera is backface-culled and paints nothing, so it + // costs a whole region — while a small-but-correct grid still shows + // four. Coverage alone conflated "wound inward" with "zoomed out", + // and blamed winding for what was really framing. + const regions = countRegions(pixelsWithHue(first, "#84cc16"), first.pixels.cols); + return regions >= 4 ? true - : `only ${(share * 100).toFixed(1)}% of the viewport is lime - tiles wound away from the camera are backface-culled and paint nothing`; + : `only ${regions} separate lime region(s) painted, expected 4 - a tile wound away from the camera is backface-culled and paints nothing`; }, }, ], From 19ca9656f5a90811d5757a33d8baa53591bba6fb Mon Sep 17 00:00:00 2001 From: Juan Cruz Fortunatti Date: Thu, 13 Aug 2026 05:09:30 +0200 Subject: [PATCH 11/37] test(skills): erode before counting regions, sample denser, scope workspace cleanup --- eval/skill/harness/index.html | 2 +- eval/skill/run.mjs | 7 ++++++- eval/skill/tasks.mjs | 20 +++++++++++++++++++- 3 files changed, 26 insertions(+), 3 deletions(-) diff --git a/eval/skill/harness/index.html b/eval/skill/harness/index.html index 60a685b89..c9f44dee3 100644 --- a/eval/skill/harness/index.html +++ b/eval/skill/harness/index.html @@ -54,7 +54,7 @@ * reversed. Chromium decodes the screenshot for us, so this needs no * image library. */ - window.__samplePixels = async (dataUrl, cols = 120, rows = 80) => { + window.__samplePixels = async (dataUrl, cols = 240, rows = 160) => { const img = new Image(); img.src = dataUrl; await img.decode(); diff --git a/eval/skill/run.mjs b/eval/skill/run.mjs index 5d090339a..b8a252a89 100644 --- a/eval/skill/run.mjs +++ b/eval/skill/run.mjs @@ -401,7 +401,12 @@ async function main(argv) { console.log(`[eval] wrote ${options.json}`); } if (!options.keep) { - rmSync(workRoot, { recursive: true, force: true }); + // Remove only what THIS run created. Wiping the shared root also deleted + // workspaces another run had deliberately kept, which lost evidence that + // had already cost real agent invocations to produce. + for (const candidate of candidates) { + rmSync(candidate.dir, { recursive: true, force: true }); + } } else { console.log(`[eval] workspaces kept in ${workRoot}`); } diff --git a/eval/skill/tasks.mjs b/eval/skill/tasks.mjs index f4cab0d88..46be12dec 100644 --- a/eval/skill/tasks.mjs +++ b/eval/skill/tasks.mjs @@ -88,7 +88,25 @@ const hasHue = (snapshot, hex, minPixels = 40) => * are on screen. */ function countRegions(pixels, cols, minSize = 4) { - const set = new Set(pixels.map((p) => p.y * cols + p.x)); + const raw = new Set(pixels.map((p) => p.y * cols + p.x)); + + // Erode by one sample first. Shapes that merely touch — four tiles meeting + // at a corner, say — bridge into a single blob at this sampling density, + // which read as "three tiles are missing" for a scene that was perfect. + // Dropping boundary samples separates a point contact from a real join. + const set = new Set(); + for (const index of raw) { + const x = index % cols; + const y = Math.floor(index / cols); + const neighbours = [ + raw.has(index - 1) && x > 0, + raw.has(index + 1) && x < cols - 1, + raw.has(index - cols), + raw.has(index + cols), + ].filter(Boolean).length; + if (neighbours === 4) set.add(index); + } + const seen = new Set(); let regions = 0; for (const start of set) { From 32322c31b94dbcf962118ec0dd068cd634bd7ba5 Mon Sep 17 00:00:00 2001 From: Juan Cruz Fortunatti Date: Fri, 14 Aug 2026 21:14:54 +0200 Subject: [PATCH 12/37] docs(agents): document the skill evaluation harness --- AGENTS.md | 32 ++++++++++++++++++++++++++++++++ 1 file changed, 32 insertions(+) diff --git a/AGENTS.md b/AGENTS.md index 4157bf8bd..277ffd4d9 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -301,6 +301,38 @@ publishes that tree with a zero-dependency `npx` installer. - The package is plain ESM with no build step. Its `bin` runs under `npx` in someone else's project, so it must stay dependency-free. +### Measuring the skill + +`eval/skill/` answers whether an agent holding only the skill actually writes +correct PolyCSS. It hands a real coding-agent CLI a throwaway workspace +containing the installed skill and one task, then grades **what the scene +paints in Chromium** — never the agent's prose. + +- `pnpm eval:skill --agent oracle --track all` — reference solutions, must be + 100%. Anything less is a harness bug, not an agent result. +- `pnpm eval:selftest` — mutates each reference solution with one mistake the + skill warns about and asserts the matching check catches it. A grader that + cannot fail measures nothing. +- `pnpm eval:skill --agent claude,codex,grok --track all` — the real matrix. + Costs real agent invocations; `--reuse` re-grades existing workspaces free. + +Two design rules that are load-bearing: + +- **Workspaces live outside the repository** (`$TMPDIR`). Inside it, an agent + walks up out of its workspace and reads this monorepo — measured, on the + no-skill control track. +- **The `three` track is a control, never a reference.** Same model, same task, + no skill, separate workspace. The two tracks are graded independently against + the same visual criteria; PolyCSS output is never diffed against a Three.js + render. It exists to separate "the skill is inadequate" from "this task is + hard for this model", and it has repeatedly proved the grader wrong rather + than the agent. + +Grade on painted pixels, not DOM boxes: every leaf except `` is +`backface-visibility: hidden`, so a reversed face keeps its bounding rect while +painting nothing. Prefer tolerance bands over exact values — models frame +scenes differently and that is not a defect. + ## Backward compatibility - **No BC shims.** Clean breaks only. No re-export aliases for renamed symbols. No `@deprecated` wrappers. If the API changes, callers update. From 3f4b9614a6364bfd7b30c09d81e63c1a4972cbb5 Mon Sep 17 00:00:00 2001 From: Juan Cruz Fortunatti Date: Fri, 14 Aug 2026 21:48:17 +0200 Subject: [PATCH 13/37] fix(skills): contain installer paths and stop symlinks escaping the skill directory --- packages/skills/src/install.mjs | 115 ++++++++++++++++++--- packages/skills/src/install.test.mjs | 147 +++++++++++++++++++++++++++ 2 files changed, 249 insertions(+), 13 deletions(-) diff --git a/packages/skills/src/install.mjs b/packages/skills/src/install.mjs index b42632816..aedaaaa41 100644 --- a/packages/skills/src/install.mjs +++ b/packages/skills/src/install.mjs @@ -11,11 +11,14 @@ */ import { createHash } from "node:crypto"; import { + lstatSync, mkdirSync, readFileSync, readdirSync, + renameSync, rmSync, statSync, + unlinkSync, writeFileSync, } from "node:fs"; import { homedir } from "node:os"; @@ -66,16 +69,50 @@ export function listFiles(dir, prefix = "") { const toNative = (rel) => rel.split("/").join(sep); +/** + * A path we are willing to act on inside the skill directory. + * + * Manifest keys are read off disk and are therefore untrusted input: a + * `../../../package.json` entry whose hash matched would otherwise be joined to + * `destDir` and deleted during stale-file cleanup. Everything must be a plain + * forward-slash relative path with no traversal, no root, and no drive letter. + */ +export function isSafeRelativePath(rel) { + if (typeof rel !== "string" || rel.length === 0) return false; + if (rel.includes("\\") || rel.includes("\u0000")) return false; + if (rel.startsWith("/") || /^[a-zA-Z]:/.test(rel)) return false; + const parts = rel.split("/"); + if (parts.some((part) => part === "" || part === "." || part === "..")) return false; + return true; +} + +/** + * Resolve `rel` under `root`, returning null unless it stays inside. Belt and + * braces over `isSafeRelativePath` — containment is re-checked immediately + * before every read, write and delete, not once at parse time. + */ +function containedPath(root, rel) { + if (!isSafeRelativePath(rel)) return null; + const base = resolve(root); + const target = resolve(base, toNative(rel)); + if (target !== base && !target.startsWith(base + sep)) return null; + return target; +} + function readManifest(destDir) { + const path = containedPath(destDir, MANIFEST_FILE); + if (path === null || isSymlink(path)) return null; try { - const parsed = JSON.parse(readFileSync(join(destDir, MANIFEST_FILE), "utf8")); + const parsed = JSON.parse(readFileSync(path, "utf8")); if (!parsed || typeof parsed !== "object") return null; const files = parsed.files; if (!files || typeof files !== "object" || Array.isArray(files)) return null; // A manifest we cannot fully trust is worse than none: a bad entry would - // read as "unmodified" and silently overwrite the user's edit. - for (const value of Object.values(files)) { + // read as "unmodified" and silently overwrite the user's edit, and an + // unsafe key would send a delete outside the skill directory entirely. + for (const [key, value] of Object.entries(files)) { if (typeof value !== "string") return null; + if (containedPath(destDir, key) === null) return null; } return { version: typeof parsed.version === "string" ? parsed.version : null, files }; } catch { @@ -83,15 +120,50 @@ function readManifest(destDir) { } } +const isSymlink = (path) => { + try { + return lstatSync(path).isSymbolicLink(); + } catch { + return false; + } +}; + +/** + * Read a managed file, refusing to follow symlinks. + * + * Returns `SYMLINK` for a link so callers treat it as a conflict rather than + * silently writing through it: installing over a `SKILL.md` symlink otherwise + * overwrites whatever it points at, anywhere on disk. + */ +const SYMLINK = Symbol("symlink"); + function readIfFile(path) { try { - if (!statSync(path).isFile()) return null; + const stat = lstatSync(path); + if (stat.isSymbolicLink()) return SYMLINK; + if (!stat.isFile()) return null; return readFileSync(path); } catch { return null; } } +/** Replace `path` without following a symlink that may sit at it. */ +function writeFileNoFollow(path, bytes) { + mkdirSync(dirname(path), { recursive: true }); + if (isSymlink(path)) unlinkSync(path); + // Same-directory temp + rename: rename replaces the entry itself, so a link + // created between the check and the write cannot be followed either. + const temp = `${path}.polycss-skill-tmp`; + try { + writeFileSync(temp, bytes, { flag: "wx" }); + } catch { + rmSync(temp, { force: true }); + writeFileSync(temp, bytes); + } + renameSync(temp, path); +} + /** * Decide what installing into `destDir` would do, without touching disk. * @@ -113,9 +185,22 @@ export function planInstall({ sourceDir, destDir, force = false }) { const kept = []; for (const rel of sourceFiles) { + const target = containedPath(destDir, rel); + if (target === null) { + conflicts.push(rel); + continue; + } const next = readFileSync(join(sourceDir, toNative(rel))); - const current = readIfFile(join(destDir, toNative(rel))); + const current = readIfFile(target); + if (current === SYMLINK) { + // A managed path that is a link points somewhere we do not own, so it is + // never written *through*. Under an explicit --force the link itself is + // replaced by a real file; without one it blocks the install. + if (force) write.push(rel); + else conflicts.push(rel); + continue; + } if (current === null) { write.push(rel); continue; @@ -133,8 +218,10 @@ export function planInstall({ sourceDir, destDir, force = false }) { const shipped = new Set(sourceFiles); for (const rel of Object.keys(manifest?.files ?? {})) { if (shipped.has(rel)) continue; - const current = readIfFile(join(destDir, toNative(rel))); - if (current === null) continue; + const target = containedPath(destDir, rel); + if (target === null) continue; + const current = readIfFile(target); + if (current === null || current === SYMLINK) continue; // Dropped from a newer version of the skill. Reclaim it only if it still // matches what we installed; an edited leftover is the user's file now. if (sha256(current) === manifest.files[rel] || force) remove.push(rel); @@ -172,21 +259,23 @@ export function installSkill({ const files = {}; for (const rel of plan.sourceFiles) { - const target = join(destDir, toNative(rel)); + const target = containedPath(destDir, rel); + if (target === null) continue; const bytes = readFileSync(join(sourceDir, toNative(rel))); files[rel] = sha256(bytes); if (!plan.write.includes(rel)) continue; - mkdirSync(dirname(target), { recursive: true }); - writeFileSync(target, bytes); + writeFileNoFollow(target, bytes); } for (const rel of plan.remove) { - rmSync(join(destDir, toNative(rel)), { force: true }); + const target = containedPath(destDir, rel); + if (target === null) continue; + rmSync(target, { force: true }); } mkdirSync(destDir, { recursive: true }); - writeFileSync( - join(destDir, MANIFEST_FILE), + writeFileNoFollow( + containedPath(destDir, MANIFEST_FILE), `${JSON.stringify({ skill: SKILL_NAME, version, files }, null, 2)}\n`, ); diff --git a/packages/skills/src/install.test.mjs b/packages/skills/src/install.test.mjs index 0e9b56484..889112642 100644 --- a/packages/skills/src/install.test.mjs +++ b/packages/skills/src/install.test.mjs @@ -4,12 +4,14 @@ import { tmpdir } from "node:os"; import { dirname, join } from "node:path"; import { afterEach, describe, expect, it } from "vitest"; +import { symlinkSync } from "node:fs"; import { AGENTS, bundledSkillDir, DEFAULT_AGENT, expandAgents, installSkill, + isSafeRelativePath, listFiles, MANIFEST_FILE, planInstall, @@ -255,6 +257,151 @@ describe("installSkill", () => { }); }); +describe("hostile manifests and links stay inside the skill directory", () => { + it("rejects traversal, absolute, backslash and dot path segments", () => { + for (const bad of [ + "../package.json", + "../../../package.json", + "docs/../../escape.md", + "/etc/passwd", + "C:/Windows/system.ini", + "docs\\win.md", + "./docs/a.md", + "docs//a.md", + "", + ]) { + expect(isSafeRelativePath(bad), bad).toBe(false); + } + for (const good of ["SKILL.md", "docs/a.md", "docs/nested/b.md"]) { + expect(isSafeRelativePath(good), good).toBe(true); + } + }); + + it("does not delete a file outside the skill directory named by the manifest", () => { + const project = tmp(); + const destDir = join(project, ".claude", "skills", "polycss"); + const outsider = join(project, "package.json"); + writeFileSync(outsider, '{"name":"victim"}'); + + installSkill({ + sourceDir: fixture({ "SKILL.md": "v1" }), + destDir, + version: "1.0.0", + }); + + // A manifest entry pointing out of the tree, with the hash the cleanup + // path checks before reclaiming a dropped file. + writeFileSync( + join(destDir, MANIFEST_FILE), + JSON.stringify({ + skill: SKILL_NAME, + version: "1.0.0", + files: { + "SKILL.md": createHash("sha256").update("v1").digest("hex"), + "../../../package.json": createHash("sha256") + .update('{"name":"victim"}') + .digest("hex"), + }, + }), + ); + + installSkill({ sourceDir: fixture({ "SKILL.md": "v2" }), destDir, version: "2.0.0" }); + + expect(readFileSync(outsider, "utf8")).toBe('{"name":"victim"}'); + }); + + it("does not delete outside the skill directory under --force either", () => { + const project = tmp(); + const destDir = join(project, "skills", "polycss"); + const outsider = join(project, "package.json"); + writeFileSync(outsider, "keep me"); + + installSkill({ sourceDir: fixture({ "SKILL.md": "v1" }), destDir, version: "1.0.0" }); + writeFileSync( + join(destDir, MANIFEST_FILE), + JSON.stringify({ + skill: SKILL_NAME, + files: { "../../package.json": "whatever" }, + }), + ); + + installSkill({ + sourceDir: fixture({ "SKILL.md": "v2" }), + destDir, + version: "2.0.0", + force: true, + }); + + expect(readFileSync(outsider, "utf8")).toBe("keep me"); + }); + + it("refuses to write through a symlinked managed file", () => { + const project = tmp(); + const destDir = join(project, "skills", "polycss"); + const outsider = join(project, "secret.txt"); + writeFileSync(outsider, "original"); + + installSkill({ sourceDir: fixture({ "SKILL.md": "v1" }), destDir, version: "1.0.0" }); + rmSync(join(destDir, "SKILL.md")); + symlinkSync(outsider, join(destDir, "SKILL.md")); + + const result = installSkill({ + sourceDir: fixture({ "SKILL.md": "v2" }), + destDir, + version: "2.0.0", + }); + + expect(result.applied).toBe(false); + expect(result.conflicts).toEqual(["SKILL.md"]); + expect(readFileSync(outsider, "utf8")).toBe("original"); + }); + + it("replaces a symlink rather than following it under --force", () => { + const project = tmp(); + const destDir = join(project, "skills", "polycss"); + const outsider = join(project, "secret.txt"); + writeFileSync(outsider, "original"); + + installSkill({ sourceDir: fixture({ "SKILL.md": "v1" }), destDir, version: "1.0.0" }); + rmSync(join(destDir, "SKILL.md")); + symlinkSync(outsider, join(destDir, "SKILL.md")); + + installSkill({ + sourceDir: fixture({ "SKILL.md": "v2" }), + destDir, + version: "2.0.0", + force: true, + }); + + expect(readFileSync(outsider, "utf8")).toBe("original"); + expect(read(destDir, "SKILL.md")).toBe("v2"); + }); + + it("ignores a manifest that is itself a symlink", () => { + const project = tmp(); + const destDir = join(project, "skills", "polycss"); + const planted = join(project, "planted.json"); + + installSkill({ sourceDir: fixture({ "SKILL.md": "v1" }), destDir, version: "1.0.0" }); + writeFileSync( + planted, + JSON.stringify({ skill: SKILL_NAME, files: { "SKILL.md": "not-the-real-hash" } }), + ); + rmSync(join(destDir, MANIFEST_FILE)); + symlinkSync(planted, join(destDir, MANIFEST_FILE)); + writeFileSync(join(destDir, "SKILL.md"), "user edit"); + + const result = installSkill({ + sourceDir: fixture({ "SKILL.md": "v2" }), + destDir, + version: "2.0.0", + }); + + expect(result.applied).toBe(false); + expect(result.conflicts).toEqual(["SKILL.md"]); + }); +}); + describe("planInstall", () => { it("does not touch disk", () => { const destDir = join(tmp(), "polycss"); From 6fa0b86c84107cca700a4a9b1f2d922600dfc756 Mon Sep 17 00:00:00 2001 From: Juan Cruz Fortunatti Date: Fri, 14 Aug 2026 21:48:17 +0200 Subject: [PATCH 14/37] refactor(shadow): share one DEFAULT_SHADOW_LIFT across the three renderers --- packages/core/src/index.ts | 1 + .../core/src/shadow/computeReceiverShadows.ts | 16 ++++++++++++++++ .../src/shadow/mergedReceiverShadows.test.ts | 19 +++++++++++++++++++ packages/polycss/src/api/createPolyScene.ts | 3 ++- packages/polycss/src/api/scene/equality.ts | 3 ++- .../polycss/src/api/scene/receiverShadow.ts | 3 ++- packages/react/src/scene/PolyMesh.tsx | 3 ++- packages/react/src/scene/PolyScene.tsx | 3 ++- packages/vue/src/scene/PolyMesh.ts | 3 ++- packages/vue/src/scene/PolyScene.ts | 3 ++- 10 files changed, 50 insertions(+), 7 deletions(-) diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 446ab55bd..a16c87be0 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -227,6 +227,7 @@ export { prepareCasterEdgeOwners, prepareCasterPolyItems, prepareReceiverFacePlanes, + DEFAULT_SHADOW_LIFT, } from "./shadow/computeReceiverShadows"; export type { CasterPolyItem, diff --git a/packages/core/src/shadow/computeReceiverShadows.ts b/packages/core/src/shadow/computeReceiverShadows.ts index 26df79174..3c9055bb0 100644 --- a/packages/core/src/shadow/computeReceiverShadows.ts +++ b/packages/core/src/shadow/computeReceiverShadows.ts @@ -344,6 +344,22 @@ export function prepareCasterPolyItems( * shadow SVG composites above the surface without z-fighting (matches the * ground-shadow `shadow.lift` option). */ +/** + * Default clearance between a receiver face and the shadow painted on it, in + * world units. + * + * Single source of truth on purpose: this default was previously written out at + * seven call sites across the three renderers, and two of them drifted to + * `0.001` — small enough that the receiver painted over its own shadow, so + * `castShadow` + `receiveShadow` emitted paths that darkened nothing. + * + * Note this is a WORLD-unit value while the depth conflict it must win is + * resolved in device pixels, so it still fails below roughly `zoom: 1`. That + * limitation is documented for callers; fixing it needs projection-aware + * clearance. + */ +export const DEFAULT_SHADOW_LIFT = 0.05; + export function prepareReceiverFacePlanes( polygons: readonly Polygon[], position: Vec3, diff --git a/packages/core/src/shadow/mergedReceiverShadows.test.ts b/packages/core/src/shadow/mergedReceiverShadows.test.ts index 8c455f5d0..1b26145f4 100644 --- a/packages/core/src/shadow/mergedReceiverShadows.test.ts +++ b/packages/core/src/shadow/mergedReceiverShadows.test.ts @@ -2,6 +2,7 @@ import { describe, it, expect } from "vitest"; import { computeMergedReceiverShadows, prepareCasterPolyItems, + DEFAULT_SHADOW_LIFT, prepareReceiverFacePlanes, } from "./computeReceiverShadows"; import { worldPositionToCss } from "./receiverFaceGroups"; @@ -105,3 +106,21 @@ describe("computeMergedReceiverShadows", () => { expect(faces.length).toBe(0); }); }); + +describe("DEFAULT_SHADOW_LIFT", () => { + it("matches the documented default", () => { + // Regression guard. This default was written out at seven call sites and + // two drifted to 0.001, which is too small to clear the receiver — cast + // shadows emitted paths that darkened nothing in all three renderers. + expect(DEFAULT_SHADOW_LIFT).toBe(0.05); + }); + + it("offsets the receiver plane away from the surface", () => { + const [flat] = prepareReceiverFacePlanes([floor], [0, 0, 0], 1, new Set(), 0, null); + const [lifted] = prepareReceiverFacePlanes( + [floor], [0, 0, 0], 1, new Set(), DEFAULT_SHADOW_LIFT, null, + ); + expect(flat.lift).toBe(0); + expect(lifted.lift).toBeGreaterThan(0); + }); +}); diff --git a/packages/polycss/src/api/createPolyScene.ts b/packages/polycss/src/api/createPolyScene.ts index b547f5505..85203005f 100644 --- a/packages/polycss/src/api/createPolyScene.ts +++ b/packages/polycss/src/api/createPolyScene.ts @@ -49,6 +49,7 @@ import { optimizeMeshPolygons, parseHexColor, polygonCssSurfaceNormal, + DEFAULT_SHADOW_LIFT, } from "@layoutit/polycss-core"; import { cssBorderShapeForPlan, @@ -1550,7 +1551,7 @@ export function createPolyScene( } return; } - const lift = currentOptions.shadow?.lift ?? 0.05; + const lift = currentOptions.shadow?.lift ?? DEFAULT_SHADOW_LIFT; // World Z → CSS Z: the ground plane in CSS-Z coordinates. Lift is added // (not subtracted) so the shadow plane sits slightly *above* the model // bbox floor — putting it on top of a receiver mesh placed at minZ diff --git a/packages/polycss/src/api/scene/equality.ts b/packages/polycss/src/api/scene/equality.ts index 8f896325c..48872dae0 100644 --- a/packages/polycss/src/api/scene/equality.ts +++ b/packages/polycss/src/api/scene/equality.ts @@ -7,6 +7,7 @@ import type { Vec3 } from "@layoutit/polycss-core"; import type { PolyRenderStrategiesOption } from "../../render/textureAtlas"; import type { PolySceneOptions } from "./types"; +import { DEFAULT_SHADOW_LIFT } from "@layoutit/polycss-core"; export function strategiesEqual( a: PolyRenderStrategiesOption | undefined, @@ -33,7 +34,7 @@ export function shadowOptsEqual( return ( (a?.color ?? "#000000") === (b?.color ?? "#000000") && (a?.opacity ?? 0.25) === (b?.opacity ?? 0.25) - && (a?.lift ?? 0.05) === (b?.lift ?? 0.05) + && (a?.lift ?? DEFAULT_SHADOW_LIFT) === (b?.lift ?? DEFAULT_SHADOW_LIFT) && (a?.maxExtend ?? 2000) === (b?.maxExtend ?? 2000) && (a?.parametric ?? false) === (b?.parametric ?? false) && (a?.definition ?? 16) === (b?.definition ?? 16) diff --git a/packages/polycss/src/api/scene/receiverShadow.ts b/packages/polycss/src/api/scene/receiverShadow.ts index 0e468ca52..703ea03ec 100644 --- a/packages/polycss/src/api/scene/receiverShadow.ts +++ b/packages/polycss/src/api/scene/receiverShadow.ts @@ -22,6 +22,7 @@ import { type ReceiverCasterInput, type ReceiverFacePlane, type Vec3, + DEFAULT_SHADOW_LIFT, } from "@layoutit/polycss-core"; import { ensureShadowRoot } from "./shadowSvg"; import { meshShadowId } from "./shadowCache"; @@ -140,7 +141,7 @@ export function emitReceiverShadows( const hasTexture = receiverEntry.polygons.some((p) => p.texture !== undefined); const receiverScale = meshScaleVec3(receiverEntry.handle.transform.scale); const rbboxCss = receiverEntry.bboxCenterCss; - const cacheShadowLift = options.shadow?.lift ?? 0.05; + const cacheShadowLift = options.shadow?.lift ?? DEFAULT_SHADOW_LIFT; const cacheKey = `${receiverEntry.polygons.length}|${receiverDedupDrop.size}|${rpos.join(",")}|${rrot.join(",")}|${receiverScale.join(",")}|${rbboxCss ? rbboxCss.join(",") : "n"}|${cacheShadowLift}`; let cachedPlanes = receiverShadowCache.get(receiverEntry) as ReceiverFacePlane[] | undefined; if (cachedPlanes === undefined || receiverShadowCacheKey.get(receiverEntry) !== cacheKey) { diff --git a/packages/react/src/scene/PolyMesh.tsx b/packages/react/src/scene/PolyMesh.tsx index 6e9d6a770..68c1b10b1 100644 --- a/packages/react/src/scene/PolyMesh.tsx +++ b/packages/react/src/scene/PolyMesh.tsx @@ -59,6 +59,7 @@ import { type CameraCullRotation, type EdgeOwners, type ReceiverCasterInput, + DEFAULT_SHADOW_LIFT, } from "@layoutit/polycss-core"; import type { TransformProps } from "../shapes/types"; import { usePolyMesh, type UseMeshOptions } from "./useMesh"; @@ -1027,7 +1028,7 @@ export const PolyMesh = forwardRef(function PolyM const runDirectionalShadow = !!sceneDirectionalLight?.direction && (sceneDirectionalLight.intensity ?? 1) > 0; const hasShadowPoints = shadowPointIndices.length > 0; - const shadowLift = sceneShadow?.lift ?? 0.05; + const shadowLift = sceneShadow?.lift ?? DEFAULT_SHADOW_LIFT; const planes = prepareReceiverFacePlanes( polygons, position ?? [0, 0, 0], diff --git a/packages/react/src/scene/PolyScene.tsx b/packages/react/src/scene/PolyScene.tsx index 66767d7c8..d7d7caa0d 100644 --- a/packages/react/src/scene/PolyScene.tsx +++ b/packages/react/src/scene/PolyScene.tsx @@ -17,6 +17,7 @@ import { parseHexColor, resolvePolyTextureLeafGeometry, worldDirectionToCss, + DEFAULT_SHADOW_LIFT, } from "@layoutit/polycss-core"; import type { ShadowCasterRegistration, ShadowOptions } from "./sceneContext"; import { useCameraContext } from "../camera/context"; @@ -377,7 +378,7 @@ function PolySceneInner({ } } if (!Number.isFinite(minWorldZ)) return null; - const lift = shadow?.lift ?? 0.05; + const lift = shadow?.lift ?? DEFAULT_SHADOW_LIFT; return (minWorldZ + lift) * BASE_TILE; }, [shadow]); diff --git a/packages/vue/src/scene/PolyMesh.ts b/packages/vue/src/scene/PolyMesh.ts index 4a34a42a2..f926f6a27 100644 --- a/packages/vue/src/scene/PolyMesh.ts +++ b/packages/vue/src/scene/PolyMesh.ts @@ -34,6 +34,7 @@ import { cornerShapeGeometryForPlan, resolvePolyTextureLeafGeometry, worldDirectionalLightToCss, + DEFAULT_SHADOW_LIFT, } from "@layoutit/polycss-core"; import { BASE_TILE, @@ -682,7 +683,7 @@ export const PolyMesh = defineComponent({ const runDirectionalShadow = !!ctx?.directionalLight?.direction && (ctx.directionalLight.intensity ?? 1) > 0; const hasShadowPoints = shadowPointIndices.length > 0; - const shadowLift = ctx?.shadow?.lift ?? 0.05; + const shadowLift = ctx?.shadow?.lift ?? DEFAULT_SHADOW_LIFT; const planes = prepareReceiverFacePlanes( polygons.value, props.position ?? [0, 0, 0], diff --git a/packages/vue/src/scene/PolyScene.ts b/packages/vue/src/scene/PolyScene.ts index f2caaebc2..dac6a1453 100644 --- a/packages/vue/src/scene/PolyScene.ts +++ b/packages/vue/src/scene/PolyScene.ts @@ -37,6 +37,7 @@ import { parseHexColor, resolvePolyTextureLeafGeometry, worldDirectionToCss, + DEFAULT_SHADOW_LIFT, } from "@layoutit/polycss-core"; import { PolyCameraContextKey } from "../camera"; import { usePolySceneContext } from "./useSceneContext"; @@ -449,7 +450,7 @@ export const PolyScene = defineComponent({ if (groundCssZ.value !== null) groundCssZ.value = null; return; } - const lift = props.shadow?.lift ?? 0.05; + const lift = props.shadow?.lift ?? DEFAULT_SHADOW_LIFT; const next = (minWorldZ + lift) * DEFAULT_TILE; if (groundCssZ.value !== next) groundCssZ.value = next; if (!el) return; From b8cfc6f3c9f525912309b6cc31b3aa32d9527520 Mon Sep 17 00:00:00 2001 From: Juan Cruz Fortunatti Date: Fri, 14 Aug 2026 21:48:17 +0200 Subject: [PATCH 15/37] docs(skills): correct colour defaults, readiness claims, import rule and shadow zoom limit --- packages/skills/skill/SKILL.md | 14 ++++++++--- packages/skills/skill/docs/performance.md | 4 +++- packages/skills/skill/docs/shadows.md | 23 +++++++++++++++++++ .../skill/docs/shapes-and-primitives.md | 6 +++-- packages/skills/skill/docs/textures.md | 12 +++++++--- packages/skills/skill/docs/troubleshooting.md | 4 +++- website/public/skill.md | 14 ++++++++--- website/public/skill/SKILL.md | 14 ++++++++--- website/public/skill/docs/performance.md | 4 +++- website/public/skill/docs/shadows.md | 23 +++++++++++++++++++ .../skill/docs/shapes-and-primitives.md | 6 +++-- website/public/skill/docs/textures.md | 12 +++++++--- website/public/skill/docs/troubleshooting.md | 4 +++- 13 files changed, 117 insertions(+), 23 deletions(-) diff --git a/packages/skills/skill/SKILL.md b/packages/skills/skill/SKILL.md index 130a2c922..f9b9d6386 100644 --- a/packages/skills/skill/SKILL.md +++ b/packages/skills/skill/SKILL.md @@ -44,9 +44,17 @@ Read the file that matches the task before writing non-trivial code. | `@layoutit/polycss-fonts` | Text → extruded 3D `Polygon[]`. | | `@layoutit/polycss-morph` | Prepared models with retained DOM, morphs, skinning, playback. | -React and Vue depend on `core` only — **never import `@layoutit/polycss` from a -React or Vue app.** The public API is mirrored between React and Vue: same -names, same defaults, idiomatic differences only (refs vs reactives). +React and Vue depend on `core` only, so **do not import renderer or component +APIs from `@layoutit/polycss` in a React or Vue app** — use the framework +package, and take anything it does not re-export from `@layoutit/polycss-core`. + +The one documented exception is `exportPolySceneSnapshot`, which lives only in +`@layoutit/polycss` because it is browser DOM serialization rather than +component API; React and Vue callers import it from there and pass the rendered +element. See [docs/api-index.md](docs/api-index.md). + +The public API is mirrored between React and Vue: same names, same defaults, +idiomatic differences only (refs vs reactives). ## Imports diff --git a/packages/skills/skill/docs/performance.md b/packages/skills/skill/docs/performance.md index 33aa7709b..832f09f02 100644 --- a/packages/skills/skill/docs/performance.md +++ b/packages/skills/skill/docs/performance.md @@ -48,7 +48,9 @@ production setting. ## Diagnostics - `collectPolyRenderStats(root)` — mounted leaf mix by strategy. -- `collectPolyTextureReadiness(root)` — whether atlas bitmaps have decoded. +- `collectPolyTextureReadiness(root)` — renderer-reported texture readiness. A + progress signal, not proof of decode: direct-image leaves count as ready when + their CSS URL is assigned. - `queryPolyLeaves(root)` — the leaf elements themselves. ## Automatic mesh optimization diff --git a/packages/skills/skill/docs/shadows.md b/packages/skills/skill/docs/shadows.md index 881cb9a0d..255713a89 100644 --- a/packages/skills/skill/docs/shadows.md +++ b/packages/skills/skill/docs/shadows.md @@ -32,6 +32,29 @@ scene.setOptions({ Reconciling the two is an open decision; write code that works under both by adding an explicit receiver. +## Known limitation: shadows vanish at low camera zoom + +`shadow.lift` is expressed in **world units**, but the depth conflict it has to +win against the receiver is resolved in **device pixels**. The scene transform +scales the lift along with everything else, so below roughly `zoom: 1` the +shadow plane and the receiver collapse into the same pixel and the receiver +paints over the shadow. Paths are still emitted — nothing errors, nothing warns, +and the shadow is simply invisible. + +The default camera zoom is `0.65`, which is inside that range. Measured on a +cube over a plane with the default `lift`: + +| `zoom` | shadow | +|---|---| +| 0.5 | none | +| 0.65 (default) | none | +| 1.0 | visible | +| 2.0 | visible | + +Until this is fixed, a scene that keeps the default zoom needs a larger lift — +`shadow: { lift: 0.2 }` is enough at `zoom: 0.65` — or a camera at `zoom: 1` or +above. + ## `shadow` options | Key | Default | Meaning | diff --git a/packages/skills/skill/docs/shapes-and-primitives.md b/packages/skills/skill/docs/shapes-and-primitives.md index e5f82985d..3a7473aa4 100644 --- a/packages/skills/skill/docs/shapes-and-primitives.md +++ b/packages/skills/skill/docs/shapes-and-primitives.md @@ -13,8 +13,10 @@ Core generators are exported from every package (`@layoutit/polycss`, ## Options and defaults -Defaults below are the generator defaults. Colors default to `#cccccc` unless -noted. +Defaults below are the generator defaults. The default `color` is **not +uniform**: `boxPolygons`, `planePolygons`, `ringPolygons`, and +`octahedronPolygons` default to `#ffffff`; every other generator defaults to +`#cccccc`. Pass `color` explicitly rather than relying on either. ### `boxPolygons` / `createPolyBox` / `PolyBox` diff --git a/packages/skills/skill/docs/textures.md b/packages/skills/skill/docs/textures.md index 6415e3aba..47570ee41 100644 --- a/packages/skills/skill/docs/textures.md +++ b/packages/skills/skill/docs/textures.md @@ -92,9 +92,15 @@ numeric default `1.5` is the only value that behaves identically everywhere. ## Readiness -`collectPolyTextureReadiness(root)` reports whether atlas bitmaps have decoded. -Textured scenes need real settle time before a screenshot — an atlas that has -not loaded yet looks like a rendering regression but is not one. +`collectPolyTextureReadiness(root)` reports the renderer's own readiness state +for texture leaves. Treat it as a progress signal, **not as proof that every +texture decoded**: a direct-image leaf counts as ready once its CSS URL is +assigned, which says nothing about whether the browser has fetched or decoded +the bytes. + +Textured scenes still need real settle time before a screenshot — an atlas that +has not painted yet looks like a rendering regression but is not one. Readiness +narrows that window; it does not close it. React/Vue `atomicAtlas` holds the previous atlas frame until the next is decoded, then swaps atomically; `onFrameReady` fires on that swap. diff --git a/packages/skills/skill/docs/troubleshooting.md b/packages/skills/skill/docs/troubleshooting.md index cb050886e..4a80d7a82 100644 --- a/packages/skills/skill/docs/troubleshooting.md +++ b/packages/skills/skill/docs/troubleshooting.md @@ -16,6 +16,8 @@ is simply wrong or absent. Work the symptom table before adding instrumentation. | Geometry looks different from what you passed in | The optimizer ran (`merge` defaults `true`, `meshResolution` `"lossy"`). Pass `{ merge: false }`. | | `merge: false` still changes the mesh | `loadMesh` already optimized at parse time. Call `parseObj`/`parseStl`/`parseGltf`/`parseVox` directly, then add with `merge: false`. | | **No shadow appears at all** in vanilla | Vanilla has no ground fallback. Add a mesh with `receiveShadow: true`. | +| Shadow still absent with a caster, a receiver and a light | Camera `zoom` below ~1 (the default is `0.65`). `shadow.lift` is in world units and scales with the camera, so it stops clearing the receiver. Raise `shadow.lift` to `0.2`, or the zoom to `1`. | +| Shadow is hidden behind the object casting it | The light is nearly parallel to the view direction. Move it to one side so the shadow falls where the camera can see it. | | Shadow works in React but not vanilla | Same cause — React/Vue have the ground-plane fallback, vanilla does not. | | Shadow vanished when you added a floor in React/Vue | Adding any receiver disables the ground fallback. Set `receiveShadow` on that floor. | | Point lights do nothing | `textureLighting: "dynamic"` ignores `pointLights` entirely — shading and shadows. Switch to `"baked"`. | @@ -23,7 +25,7 @@ is simply wrong or absent. Work the symptom table before adding instrumentation. | Shadows move but the surface stays lit the old way | Same as above — expected, and the reason the escape hatch exists. | | Shadow doesn't follow an animated mesh | Shadows freeze during a same-topology deform. Set `shadow.followAnimation: true` and lower `definition`. | | A texture renders blurry when it should be crisp | Set `textureImageRendering: "pixelated"`. | -| Textures look missing right after mount | The atlas is still decoding. Check `collectPolyTextureReadiness(root)`; textured scenes need real settle time before a screenshot. | +| Textures look missing right after mount | The atlas has not painted yet. `collectPolyTextureReadiness(root)` narrows the window but does not prove decode — direct-image leaves report ready once their URL is assigned. Give textured scenes real settle time before a screenshot. | | `textureBackend: "auto"` didn't give a direct image leaf | `"auto"` always resolves to the atlas today. Request `"image"` explicitly. | | A direct image leaf ignores scene lighting | By design — direct image leaves are source-lit. Use the atlas backend for scene lighting. | | `PolyScene` throws | It must be nested inside a camera component. | diff --git a/website/public/skill.md b/website/public/skill.md index 13290c090..31547532f 100644 --- a/website/public/skill.md +++ b/website/public/skill.md @@ -44,9 +44,17 @@ Read the file that matches the task before writing non-trivial code. | `@layoutit/polycss-fonts` | Text → extruded 3D `Polygon[]`. | | `@layoutit/polycss-morph` | Prepared models with retained DOM, morphs, skinning, playback. | -React and Vue depend on `core` only — **never import `@layoutit/polycss` from a -React or Vue app.** The public API is mirrored between React and Vue: same -names, same defaults, idiomatic differences only (refs vs reactives). +React and Vue depend on `core` only, so **do not import renderer or component +APIs from `@layoutit/polycss` in a React or Vue app** — use the framework +package, and take anything it does not re-export from `@layoutit/polycss-core`. + +The one documented exception is `exportPolySceneSnapshot`, which lives only in +`@layoutit/polycss` because it is browser DOM serialization rather than +component API; React and Vue callers import it from there and pass the rendered +element. See [docs/api-index.md](/skill/docs/api-index.md). + +The public API is mirrored between React and Vue: same names, same defaults, +idiomatic differences only (refs vs reactives). ## Imports diff --git a/website/public/skill/SKILL.md b/website/public/skill/SKILL.md index 130a2c922..f9b9d6386 100644 --- a/website/public/skill/SKILL.md +++ b/website/public/skill/SKILL.md @@ -44,9 +44,17 @@ Read the file that matches the task before writing non-trivial code. | `@layoutit/polycss-fonts` | Text → extruded 3D `Polygon[]`. | | `@layoutit/polycss-morph` | Prepared models with retained DOM, morphs, skinning, playback. | -React and Vue depend on `core` only — **never import `@layoutit/polycss` from a -React or Vue app.** The public API is mirrored between React and Vue: same -names, same defaults, idiomatic differences only (refs vs reactives). +React and Vue depend on `core` only, so **do not import renderer or component +APIs from `@layoutit/polycss` in a React or Vue app** — use the framework +package, and take anything it does not re-export from `@layoutit/polycss-core`. + +The one documented exception is `exportPolySceneSnapshot`, which lives only in +`@layoutit/polycss` because it is browser DOM serialization rather than +component API; React and Vue callers import it from there and pass the rendered +element. See [docs/api-index.md](docs/api-index.md). + +The public API is mirrored between React and Vue: same names, same defaults, +idiomatic differences only (refs vs reactives). ## Imports diff --git a/website/public/skill/docs/performance.md b/website/public/skill/docs/performance.md index 33aa7709b..832f09f02 100644 --- a/website/public/skill/docs/performance.md +++ b/website/public/skill/docs/performance.md @@ -48,7 +48,9 @@ production setting. ## Diagnostics - `collectPolyRenderStats(root)` — mounted leaf mix by strategy. -- `collectPolyTextureReadiness(root)` — whether atlas bitmaps have decoded. +- `collectPolyTextureReadiness(root)` — renderer-reported texture readiness. A + progress signal, not proof of decode: direct-image leaves count as ready when + their CSS URL is assigned. - `queryPolyLeaves(root)` — the leaf elements themselves. ## Automatic mesh optimization diff --git a/website/public/skill/docs/shadows.md b/website/public/skill/docs/shadows.md index 881cb9a0d..255713a89 100644 --- a/website/public/skill/docs/shadows.md +++ b/website/public/skill/docs/shadows.md @@ -32,6 +32,29 @@ scene.setOptions({ Reconciling the two is an open decision; write code that works under both by adding an explicit receiver. +## Known limitation: shadows vanish at low camera zoom + +`shadow.lift` is expressed in **world units**, but the depth conflict it has to +win against the receiver is resolved in **device pixels**. The scene transform +scales the lift along with everything else, so below roughly `zoom: 1` the +shadow plane and the receiver collapse into the same pixel and the receiver +paints over the shadow. Paths are still emitted — nothing errors, nothing warns, +and the shadow is simply invisible. + +The default camera zoom is `0.65`, which is inside that range. Measured on a +cube over a plane with the default `lift`: + +| `zoom` | shadow | +|---|---| +| 0.5 | none | +| 0.65 (default) | none | +| 1.0 | visible | +| 2.0 | visible | + +Until this is fixed, a scene that keeps the default zoom needs a larger lift — +`shadow: { lift: 0.2 }` is enough at `zoom: 0.65` — or a camera at `zoom: 1` or +above. + ## `shadow` options | Key | Default | Meaning | diff --git a/website/public/skill/docs/shapes-and-primitives.md b/website/public/skill/docs/shapes-and-primitives.md index e5f82985d..3a7473aa4 100644 --- a/website/public/skill/docs/shapes-and-primitives.md +++ b/website/public/skill/docs/shapes-and-primitives.md @@ -13,8 +13,10 @@ Core generators are exported from every package (`@layoutit/polycss`, ## Options and defaults -Defaults below are the generator defaults. Colors default to `#cccccc` unless -noted. +Defaults below are the generator defaults. The default `color` is **not +uniform**: `boxPolygons`, `planePolygons`, `ringPolygons`, and +`octahedronPolygons` default to `#ffffff`; every other generator defaults to +`#cccccc`. Pass `color` explicitly rather than relying on either. ### `boxPolygons` / `createPolyBox` / `PolyBox` diff --git a/website/public/skill/docs/textures.md b/website/public/skill/docs/textures.md index 6415e3aba..47570ee41 100644 --- a/website/public/skill/docs/textures.md +++ b/website/public/skill/docs/textures.md @@ -92,9 +92,15 @@ numeric default `1.5` is the only value that behaves identically everywhere. ## Readiness -`collectPolyTextureReadiness(root)` reports whether atlas bitmaps have decoded. -Textured scenes need real settle time before a screenshot — an atlas that has -not loaded yet looks like a rendering regression but is not one. +`collectPolyTextureReadiness(root)` reports the renderer's own readiness state +for texture leaves. Treat it as a progress signal, **not as proof that every +texture decoded**: a direct-image leaf counts as ready once its CSS URL is +assigned, which says nothing about whether the browser has fetched or decoded +the bytes. + +Textured scenes still need real settle time before a screenshot — an atlas that +has not painted yet looks like a rendering regression but is not one. Readiness +narrows that window; it does not close it. React/Vue `atomicAtlas` holds the previous atlas frame until the next is decoded, then swaps atomically; `onFrameReady` fires on that swap. diff --git a/website/public/skill/docs/troubleshooting.md b/website/public/skill/docs/troubleshooting.md index cb050886e..4a80d7a82 100644 --- a/website/public/skill/docs/troubleshooting.md +++ b/website/public/skill/docs/troubleshooting.md @@ -16,6 +16,8 @@ is simply wrong or absent. Work the symptom table before adding instrumentation. | Geometry looks different from what you passed in | The optimizer ran (`merge` defaults `true`, `meshResolution` `"lossy"`). Pass `{ merge: false }`. | | `merge: false` still changes the mesh | `loadMesh` already optimized at parse time. Call `parseObj`/`parseStl`/`parseGltf`/`parseVox` directly, then add with `merge: false`. | | **No shadow appears at all** in vanilla | Vanilla has no ground fallback. Add a mesh with `receiveShadow: true`. | +| Shadow still absent with a caster, a receiver and a light | Camera `zoom` below ~1 (the default is `0.65`). `shadow.lift` is in world units and scales with the camera, so it stops clearing the receiver. Raise `shadow.lift` to `0.2`, or the zoom to `1`. | +| Shadow is hidden behind the object casting it | The light is nearly parallel to the view direction. Move it to one side so the shadow falls where the camera can see it. | | Shadow works in React but not vanilla | Same cause — React/Vue have the ground-plane fallback, vanilla does not. | | Shadow vanished when you added a floor in React/Vue | Adding any receiver disables the ground fallback. Set `receiveShadow` on that floor. | | Point lights do nothing | `textureLighting: "dynamic"` ignores `pointLights` entirely — shading and shadows. Switch to `"baked"`. | @@ -23,7 +25,7 @@ is simply wrong or absent. Work the symptom table before adding instrumentation. | Shadows move but the surface stays lit the old way | Same as above — expected, and the reason the escape hatch exists. | | Shadow doesn't follow an animated mesh | Shadows freeze during a same-topology deform. Set `shadow.followAnimation: true` and lower `definition`. | | A texture renders blurry when it should be crisp | Set `textureImageRendering: "pixelated"`. | -| Textures look missing right after mount | The atlas is still decoding. Check `collectPolyTextureReadiness(root)`; textured scenes need real settle time before a screenshot. | +| Textures look missing right after mount | The atlas has not painted yet. `collectPolyTextureReadiness(root)` narrows the window but does not prove decode — direct-image leaves report ready once their URL is assigned. Give textured scenes real settle time before a screenshot. | | `textureBackend: "auto"` didn't give a direct image leaf | `"auto"` always resolves to the atlas today. Request `"image"` explicitly. | | A direct image leaf ignores scene lighting | By design — direct image leaves are source-lit. Use the atlas backend for scene lighting. | | `PolyScene` throws | It must be nested inside a camera component. | From c41a1acde11c7c274eb36f3f294d4e358a4fd48d Mon Sep 17 00:00:00 2001 From: Juan Cruz Fortunatti Date: Fri, 14 Aug 2026 21:48:17 +0200 Subject: [PATCH 16/37] docs(eval): state what the workspace isolation does and does not guarantee --- eval/skill/README.md | 53 +++++++++++++++++++++++++++++++------------- eval/skill/run.mjs | 19 ++++++++++++---- 2 files changed, 53 insertions(+), 19 deletions(-) diff --git a/eval/skill/README.md b/eval/skill/README.md index e76e04d59..17d27d5ed 100644 --- a/eval/skill/README.md +++ b/eval/skill/README.md @@ -8,29 +8,46 @@ agent's real CLI, then grades **what the resulting scene actually paints in Chromium** — not what the agent said it did. ```bash -pnpm eval:skill --agent oracle # reference solutions (should be 100%) -pnpm eval:skill --agent claude # one agent, all tasks -pnpm eval:skill --agent all --json out.json # every agent on PATH +pnpm eval:skill --agent oracle --track all # reference solutions (must be 100%) +pnpm eval:skill --agent claude --track all # one agent, both tracks, all tasks +pnpm eval:skill --agent all --track all --keep --json out.json pnpm eval:skill --task 03-cube-with-shadow --agent claude,codex -pnpm eval:selftest # prove the graders can fail +pnpm eval:skill --agent claude --reuse # re-grade existing work, no new calls +pnpm eval:selftest # prove the graders can fail ``` ## How a run works For each (agent, task) pair: -1. **Isolate.** A fresh directory gets `npx polycss-skills --agent all` and a - `TASK.md`. Nothing else — no repo, no examples, no existing scenes. A good - score can only have come from the skill. +1. **Isolate.** A fresh directory outside the repository gets + `npx polycss-skills --agent all` and a `TASK.md`, and nothing else. 2. **Run the agent** non-interactively in that directory. The task asks for one file, `scene.mjs`, exporting `mount(host)`. 3. **Build** it with esbuild, aliasing `@layoutit/polycss` to workspace source. An import that does not exist is a build error here — invented exports fail loudly instead of mysteriously. -4. **Render** it in Chromium and sample the result twice, ~1.2s apart, so - motion is observable. +4. **Render** it in Chromium and sample the result twice, ~2s apart, so motion + is observable. 5. **Grade** with the task's checks. +## What the isolation is worth + +The workspace lives outside the repo, which stops an agent reaching this +monorepo by walking up from its working directory. One did exactly that on the +no-skill control track before the move, and wrote PolyCSS in the Three.js +control. + +That is the limit of it. This is **not a sandbox**: the host filesystem is still +reachable by absolute path, HOME and any global skills are visible, network is +open, and sibling task workspaces share a root. Several adapters run with +approvals bypassed. + +So read a score as *the skill was sufficient*, not *the skill was the only +source*. The cross-track comparison is the trustworthy signal, because both +tracks run under identical conditions. For a stronger claim, run each case in a +container with a temporary HOME and network denied. + ## Grading on pixels, not DOM boxes The graders sample painted pixels (Chromium decodes the screenshot inside the @@ -50,7 +67,7 @@ mesh as perfectly visible. Only pixels survive that. | `02-orbiting-cube` | Reaching for `createPolyOrbitControls` instead of hand-rolling a `requestAnimationFrame` loop. | | `03-cube-with-shadow` | The vanilla no-ground-fallback trap: a caster with no `receiveShadow` mesh draws nothing. | | `04-two-shapes` | Two meshes, two colors, positioned in world space so neither hides the other. | -| `05-hand-authored-polygons` | Winding. Four flat tiles wound CCW from above; any tile wound the other way is backface-culled and paints nothing. | +| `05-hand-authored-polygons` | Winding. Four flat tiles wound CCW from above; any tile wound the other way is backface-culled and paints nothing, costing a whole painted region. | | `06-composed-scene` | Everything at once: ground, three primitives, lights, shadows, controls. | ### Why tiles and not a pyramid @@ -93,9 +110,11 @@ nothing else. ## Adding a task -Add to `tasks.mjs` and drop a reference solution in `oracle/.mjs`. The -oracle must score 100% — if it does not, the grader is wrong, not the agent. -Add a mutation to `selftest.mjs` for any new check that could silently pass. +Add to `tasks.mjs`, splitting its checks into `visual` (graded on both tracks) +and `native` (PolyCSS-only), then drop a reference solution in +`oracle/polycss/.mjs` and `oracle/three/.mjs`. Both oracles +must score 100% — if either does not, the grader is wrong, not the agent. Add a +mutation to `selftest.mjs` for any new check that could silently pass. ## Interpreting results @@ -104,5 +123,9 @@ below 100% is a finding: read the failure reason, then decide whether the skill failed to say something, said it somewhere the agent did not look, or the agent ignored it. The first two are fixable in `packages/skills/skill/`. -Use `--keep` to leave the workspaces in `.work/` and read what the agent -actually wrote. +Use `--keep` to leave the workspaces under `$TMPDIR/polycss-skill-eval` and read +what the agent actually wrote. `--reuse` then re-grades them without spending +another agent call, which is how to iterate on a grader. + +Screenshot a red result before believing it. Three separate findings in this +suite turned out to be grader bugs, not agent mistakes. diff --git a/eval/skill/run.mjs b/eval/skill/run.mjs index b8a252a89..1aae141a2 100644 --- a/eval/skill/run.mjs +++ b/eval/skill/run.mjs @@ -3,13 +3,24 @@ * Runs the PolyCSS skill evaluation. * * For each (agent, task) pair: - * 1. build a throwaway workspace containing ONLY the installed skill and a - * TASK.md — no repo access, no examples, nothing to copy from; + * 1. build a throwaway workspace containing only the installed skill and a + * TASK.md; * 2. run the agent's CLI non-interactively in that workspace; * 3. bundle whatever `scene.mjs` it produced and grade what renders. * - * The workspace isolation is the whole point. An agent that scores well here - * did it from the skill, not from the surrounding source tree. + * WHAT THE ISOLATION IS, AND IS NOT + * + * The workspace sits outside the repository, so an agent cannot reach this + * monorepo by walking up from its working directory — which one did, on the + * no-skill control track, before the move. + * + * It is NOT a sandbox. The agent still has the host filesystem by absolute + * path, the real HOME and any globally installed skills, network access, and + * sibling task workspaces under the same root. Several adapters also run with + * approvals bypassed. So a score here is evidence that the skill is sufficient, + * not proof that it was the only source used. Treat cross-track differences as + * the signal and single-track absolutes as approximate; for a stronger claim, + * run each case in a container with a temporary HOME and no network. * * Usage: * node eval/skill/run.mjs --agent oracle From 91ba2dcd4ec31cf7404c1a26059ae6949cf29491 Mon Sep 17 00:00:00 2001 From: Juan Cruz Fortunatti Date: Fri, 14 Aug 2026 22:07:19 +0200 Subject: [PATCH 17/37] docs(skills): note spherePolygons and ringQuadPolygons are core-only re-exports --- packages/skills/skill/docs/api-index.md | 17 ++++++++++++----- .../skills/skill/docs/shapes-and-primitives.md | 10 +++++++--- website/public/skill/docs/api-index.md | 17 ++++++++++++----- .../public/skill/docs/shapes-and-primitives.md | 10 +++++++--- 4 files changed, 38 insertions(+), 16 deletions(-) diff --git a/packages/skills/skill/docs/api-index.md b/packages/skills/skill/docs/api-index.md index 28c5b5f68..a34fa5999 100644 --- a/packages/skills/skill/docs/api-index.md +++ b/packages/skills/skill/docs/api-index.md @@ -11,9 +11,11 @@ here and not in your editor's completions, do not invent it. - `@layoutit/polycss-react` and `@layoutit/polycss-vue` re-export a **curated list** of core — parsers, generators, math, and the common types — not all of it. A core name missing from their index (for example `PolyPointLight`, or the - `resolvePolyTexture*` helpers) is imported from `@layoutit/polycss-core` - directly, which React and Vue already depend on. Never import - `@layoutit/polycss` from a React or Vue app. + `resolvePolyTexture*` helpers, `spherePolygons`) is imported from + `@layoutit/polycss-core` directly, which React and Vue already depend on. + Do not take renderer or component APIs from `@layoutit/polycss` in a React or + Vue app — the one exception is `exportPolySceneSnapshot`, which exists only + there (see "Names that do NOT exist" below). - The React and Vue public surfaces are **mirrored**. The only value exports that differ are the idiomatic context handles: React has `PolyCameraContext` and `useCameraContext`; Vue has `PolyCameraContextKey` and @@ -75,13 +77,18 @@ classes. Importing `@layoutit/polycss` does **not** register them — import `loadMesh`, `parseObj`, `parseMtl`, `parseStl`, `parseGltf`, `parseVox`, `normalizePolygons`. -## Geometry generators (all packages) +## Geometry generators -`boxPolygons`, `planePolygons`, `ringPolygons`, `spherePolygons`, +From every package: `boxPolygons`, `planePolygons`, `ringPolygons`, `cylinderPolygons`, `conePolygons`, `torusPolygons`, `tetrahedronPolygons`, `octahedronPolygons`, `icosahedronPolygons`, `dodecahedronPolygons`, `axesHelperPolygons`, `arrowPolygons`. +**`spherePolygons` and `ringQuadPolygons` are the exceptions:** they are +exported from `@layoutit/polycss-core` and `@layoutit/polycss` but **not** from +the React or Vue indexes, even though the `` component exists. In a +React or Vue app import them from `@layoutit/polycss-core`. + ## Optimizer and mesh ops (all packages) `optimizeMeshPolygons`, `optimizeMeshParseResult`, diff --git a/packages/skills/skill/docs/shapes-and-primitives.md b/packages/skills/skill/docs/shapes-and-primitives.md index 3a7473aa4..ee9e6de6c 100644 --- a/packages/skills/skill/docs/shapes-and-primitives.md +++ b/packages/skills/skill/docs/shapes-and-primitives.md @@ -8,8 +8,11 @@ Three layers, same geometry: | Vanilla factories | `createPolyBox(opts)` | `ParseResult` for `scene.add(...)` | | Components | `` / `` | Mounted mesh | -Core generators are exported from every package (`@layoutit/polycss`, -`-react`, `-vue`, `-core`). Use them when you want raw arrays to post-process. +Core generators are exported from every package (`@layoutit/polycss`, `-react`, +`-vue`, `-core`) with two exceptions: **`spherePolygons` and `ringQuadPolygons` +are not re-exported by React or Vue** — import those from +`@layoutit/polycss-core`, which both framework packages already depend on. Use +generators when you want raw arrays to post-process. ## Options and defaults @@ -132,7 +135,8 @@ The cap at 3 is deliberate — DOM cost. ### Other generators -`axesHelperPolygons`, `arrowPolygons`, `ringQuadPolygons`. +`axesHelperPolygons`, `arrowPolygons`, and `ringQuadPolygons` (core and +`@layoutit/polycss` only — see the note at the top). ## Usage diff --git a/website/public/skill/docs/api-index.md b/website/public/skill/docs/api-index.md index 28c5b5f68..a34fa5999 100644 --- a/website/public/skill/docs/api-index.md +++ b/website/public/skill/docs/api-index.md @@ -11,9 +11,11 @@ here and not in your editor's completions, do not invent it. - `@layoutit/polycss-react` and `@layoutit/polycss-vue` re-export a **curated list** of core — parsers, generators, math, and the common types — not all of it. A core name missing from their index (for example `PolyPointLight`, or the - `resolvePolyTexture*` helpers) is imported from `@layoutit/polycss-core` - directly, which React and Vue already depend on. Never import - `@layoutit/polycss` from a React or Vue app. + `resolvePolyTexture*` helpers, `spherePolygons`) is imported from + `@layoutit/polycss-core` directly, which React and Vue already depend on. + Do not take renderer or component APIs from `@layoutit/polycss` in a React or + Vue app — the one exception is `exportPolySceneSnapshot`, which exists only + there (see "Names that do NOT exist" below). - The React and Vue public surfaces are **mirrored**. The only value exports that differ are the idiomatic context handles: React has `PolyCameraContext` and `useCameraContext`; Vue has `PolyCameraContextKey` and @@ -75,13 +77,18 @@ classes. Importing `@layoutit/polycss` does **not** register them — import `loadMesh`, `parseObj`, `parseMtl`, `parseStl`, `parseGltf`, `parseVox`, `normalizePolygons`. -## Geometry generators (all packages) +## Geometry generators -`boxPolygons`, `planePolygons`, `ringPolygons`, `spherePolygons`, +From every package: `boxPolygons`, `planePolygons`, `ringPolygons`, `cylinderPolygons`, `conePolygons`, `torusPolygons`, `tetrahedronPolygons`, `octahedronPolygons`, `icosahedronPolygons`, `dodecahedronPolygons`, `axesHelperPolygons`, `arrowPolygons`. +**`spherePolygons` and `ringQuadPolygons` are the exceptions:** they are +exported from `@layoutit/polycss-core` and `@layoutit/polycss` but **not** from +the React or Vue indexes, even though the `` component exists. In a +React or Vue app import them from `@layoutit/polycss-core`. + ## Optimizer and mesh ops (all packages) `optimizeMeshPolygons`, `optimizeMeshParseResult`, diff --git a/website/public/skill/docs/shapes-and-primitives.md b/website/public/skill/docs/shapes-and-primitives.md index 3a7473aa4..ee9e6de6c 100644 --- a/website/public/skill/docs/shapes-and-primitives.md +++ b/website/public/skill/docs/shapes-and-primitives.md @@ -8,8 +8,11 @@ Three layers, same geometry: | Vanilla factories | `createPolyBox(opts)` | `ParseResult` for `scene.add(...)` | | Components | `` / `` | Mounted mesh | -Core generators are exported from every package (`@layoutit/polycss`, -`-react`, `-vue`, `-core`). Use them when you want raw arrays to post-process. +Core generators are exported from every package (`@layoutit/polycss`, `-react`, +`-vue`, `-core`) with two exceptions: **`spherePolygons` and `ringQuadPolygons` +are not re-exported by React or Vue** — import those from +`@layoutit/polycss-core`, which both framework packages already depend on. Use +generators when you want raw arrays to post-process. ## Options and defaults @@ -132,7 +135,8 @@ The cap at 3 is deliberate — DOM cost. ### Other generators -`axesHelperPolygons`, `arrowPolygons`, `ringQuadPolygons`. +`axesHelperPolygons`, `arrowPolygons`, and `ringQuadPolygons` (core and +`@layoutit/polycss` only — see the note at the top). ## Usage From 24e4f49a1d3ef61bd773411f30b410f134194d42 Mon Sep 17 00:00:00 2001 From: Juan Cruz Fortunatti Date: Fri, 14 Aug 2026 22:12:51 +0200 Subject: [PATCH 18/37] fix(skills): refuse symlinked directories inside the install destination --- packages/skills/src/install.mjs | 50 +++++++++++++--- packages/skills/src/install.test.mjs | 90 ++++++++++++++++++++++++++++ 2 files changed, 133 insertions(+), 7 deletions(-) diff --git a/packages/skills/src/install.mjs b/packages/skills/src/install.mjs index aedaaaa41..e648a252e 100644 --- a/packages/skills/src/install.mjs +++ b/packages/skills/src/install.mjs @@ -13,6 +13,7 @@ import { createHash } from "node:crypto"; import { lstatSync, mkdirSync, + realpathSync, readFileSync, readdirSync, renameSync, @@ -86,17 +87,52 @@ export function isSafeRelativePath(rel) { return true; } +const realpathOrSelf = (path) => { + try { + return realpathSync(path); + } catch { + return path; + } +}; + /** - * Resolve `rel` under `root`, returning null unless it stays inside. Belt and - * braces over `isSafeRelativePath` — containment is re-checked immediately - * before every read, write and delete, not once at parse time. + * Resolve `rel` under `root`, returning null unless it genuinely stays inside. + * + * Lexical resolution is not enough. `resolve()` does not follow symlinked path + * COMPONENTS, so a planted `docs -> /elsewhere` directory passes a + * `startsWith(base)` check while every write under it lands outside the skill + * directory — reproduced: an ordinary install wrote all 13 docs into the link + * target. So every intermediate component is `lstat`ed and a symlink anywhere + * along the path refuses the operation outright. + * + * The leaf is deliberately exempt: a symlinked managed FILE is still resolved + * here, and the caller treats it as a conflict (or, under `--force`, unlinks + * and replaces the link itself rather than writing through it). */ function containedPath(root, rel) { if (!isSafeRelativePath(rel)) return null; - const base = resolve(root); - const target = resolve(base, toNative(rel)); - if (target !== base && !target.startsWith(base + sep)) return null; - return target; + // The destination root may legitimately be a symlink — a shared skills + // directory is a reasonable layout — so containment is measured against its + // real location rather than refused. + const base = realpathOrSelf(resolve(root)); + const parts = rel.split("/"); + let current = base; + + for (let i = 0; i < parts.length; i += 1) { + current = join(current, parts[i]); + const isLeaf = i === parts.length - 1; + let stat = null; + try { + stat = lstatSync(current); + } catch { + // Nothing exists from here down; the rest is ours to create inside base. + break; + } + if (!isLeaf && stat.isSymbolicLink()) return null; + } + + const target = join(base, ...parts.map((part) => part)); + return target.startsWith(base + sep) ? target : null; } function readManifest(destDir) { diff --git a/packages/skills/src/install.test.mjs b/packages/skills/src/install.test.mjs index 889112642..8a69ca027 100644 --- a/packages/skills/src/install.test.mjs +++ b/packages/skills/src/install.test.mjs @@ -377,6 +377,96 @@ describe("hostile manifests and links stay inside the skill directory", () => { expect(read(destDir, "SKILL.md")).toBe("v2"); }); + it("refuses to write through a symlinked intermediate directory", () => { + // Lexical containment passes for `docs/x.md` even when `docs` is a link, + // so this escaped an ordinary install with no manifest and no --force. + const project = tmp(); + const destDir = join(project, "skills", "polycss"); + const outside = join(project, "outside"); + mkdirSync(outside, { recursive: true }); + mkdirSync(destDir, { recursive: true }); + symlinkSync(outside, join(destDir, "docs")); + + const result = installSkill({ + sourceDir: fixture({ "SKILL.md": "v1", "docs/a.md": "aye", "docs/b.md": "bee" }), + destDir, + version: "1.0.0", + }); + + expect(result.applied).toBe(false); + expect(result.conflicts).toEqual(["docs/a.md", "docs/b.md"]); + expect(listFiles(outside)).toEqual([]); + }); + + it("refuses a symlinked intermediate directory under --force too", () => { + const project = tmp(); + const destDir = join(project, "skills", "polycss"); + const outside = join(project, "outside"); + mkdirSync(outside, { recursive: true }); + writeFileSync(join(outside, "a.md"), "theirs"); + mkdirSync(destDir, { recursive: true }); + symlinkSync(outside, join(destDir, "docs")); + + installSkill({ + sourceDir: fixture({ "SKILL.md": "v1", "docs/a.md": "ours" }), + destDir, + version: "1.0.0", + force: true, + }); + + expect(readFileSync(join(outside, "a.md"), "utf8")).toBe("theirs"); + }); + + it("does not delete through a symlinked intermediate directory", () => { + const project = tmp(); + const destDir = join(project, "skills", "polycss"); + const outside = join(project, "outside"); + mkdirSync(outside, { recursive: true }); + writeFileSync(join(outside, "precious.md"), "keep me"); + + installSkill({ sourceDir: fixture({ "SKILL.md": "v1" }), destDir, version: "1.0.0" }); + symlinkSync(outside, join(destDir, "docs")); + writeFileSync( + join(destDir, MANIFEST_FILE), + JSON.stringify({ + skill: SKILL_NAME, + files: { + "SKILL.md": createHash("sha256").update("v1").digest("hex"), + "docs/precious.md": createHash("sha256").update("keep me").digest("hex"), + }, + }), + ); + + const result = installSkill({ + sourceDir: fixture({ "SKILL.md": "v2" }), + destDir, + version: "2.0.0", + force: true, + }); + + expect(result.remove).toEqual([]); + expect(readFileSync(join(outside, "precious.md"), "utf8")).toBe("keep me"); + }); + + it("still installs into a destination root that is itself a symlink", () => { + // A shared skills directory is a legitimate layout; only links INSIDE the + // destination are refused. + const project = tmp(); + const real = join(project, "real-skills"); + mkdirSync(real, { recursive: true }); + const destDir = join(project, "linked-skills"); + symlinkSync(real, destDir); + + const result = installSkill({ + sourceDir: fixture({ "SKILL.md": "v1", "docs/a.md": "aye" }), + destDir, + version: "1.0.0", + }); + + expect(result.applied).toBe(true); + expect(readFileSync(join(real, "docs", "a.md"), "utf8")).toBe("aye"); + }); + it("ignores a manifest that is itself a symlink", () => { const project = tmp(); const destDir = join(project, "skills", "polycss"); From 56ae84dbb615b2250847f7a1c6defa739adb7344 Mon Sep 17 00:00:00 2001 From: Juan Cruz Fortunatti Date: Fri, 14 Aug 2026 22:12:55 +0200 Subject: [PATCH 19/37] docs(skills): scope renderer-only exports and cross-renderer control defaults --- packages/skills/skill/docs/api-index.md | 19 ++++++++++++------- .../skill/docs/controls-and-interaction.md | 5 +++-- .../skills/skill/docs/scenes-and-cameras.md | 4 ++-- website/public/skill/docs/api-index.md | 19 ++++++++++++------- .../skill/docs/controls-and-interaction.md | 5 +++-- .../public/skill/docs/scenes-and-cameras.md | 4 ++-- 6 files changed, 34 insertions(+), 22 deletions(-) diff --git a/packages/skills/skill/docs/api-index.md b/packages/skills/skill/docs/api-index.md index a34fa5999..e574b5a45 100644 --- a/packages/skills/skill/docs/api-index.md +++ b/packages/skills/skill/docs/api-index.md @@ -113,9 +113,13 @@ Constant: `BASE_TILE` (50). ## Diagnostics and DOM helpers -`collectPolyRenderStats`, `collectPolyTextureReadiness`, `queryPolyLeaves`, -`findPolyMeshHandle`, `pointInMeshElement`, `findMeshUnderPoint`, -`injectPolyBaseStyles`. +Renderer packages only — these touch the DOM, so **none of them are in `core`**. + +From `@layoutit/polycss`, `-react` and `-vue`: `collectPolyRenderStats`, +`collectPolyTextureReadiness`, `queryPolyLeaves`, `injectPolyBaseStyles`. + +React and Vue only: `findPolyMeshHandle`, `pointInMeshElement`, +`findMeshUnderPoint`. Vanilla has no exported equivalent. Texture resolution (from `@layoutit/polycss-core` or `@layoutit/polycss`, **not** the React/Vue indexes): `resolvePolyTextureLeafGeometry`, @@ -143,13 +147,14 @@ Texture: `PolyTextureLightingMode`, `PolyTextureLeafSizing`, Camera: `PolyCameraProjection`, `PolyCameraSnapshot`, `PolyCameraSnapshotStats`. -Scene/mesh: `PolyMeshHandle`, `PolyMeshTransformInput`, -`PolySceneTransformInput` (all packages); `PolySceneOptions`, +Scene/mesh: `PolyMeshTransformInput`, `PolySceneTransformInput` (all packages); +`PolyMeshHandle` (renderer packages only — not in `core`); `PolySceneOptions`, `PolySceneHandle`, `PolyMeshTransform` (vanilla — React/Vue use component prop types such as `PolySceneProps` and `PolyMeshProps` instead). -Render: `PolyRenderStrategy`, `PolyRenderStrategiesOption`, `PolyRenderStats`, -`PolyLeafInfo` (all packages); `TextureQuality` (core and `@layoutit/polycss`). +Render: `PolyRenderStrategy`, `PolyRenderStrategiesOption` (all packages); +`PolyRenderStats` and `PolyLeafInfo` (renderer packages only — not in `core`); +`TextureQuality` (core and `@layoutit/polycss`). Parse options: `LoadMeshOptions`, `ObjParseOptions`, `StlParseOptions`, `GltfParseOptions`, `VoxParseOptions`, `UseMeshOptions`. diff --git a/packages/skills/skill/docs/controls-and-interaction.md b/packages/skills/skill/docs/controls-and-interaction.md index 968fc07f5..2581ea105 100644 --- a/packages/skills/skill/docs/controls-and-interaction.md +++ b/packages/skills/skill/docs/controls-and-interaction.md @@ -24,7 +24,7 @@ attached mesh handle via `setTransform`. | `invert` | `boolean \| number` | `false` | `true` reverses; a number scales sensitivity (negative inverts). | | `minZoom` / `maxZoom` | `number` | `0.1` / `10` | Zoom clamps. | | `dolly` | `boolean` | `false` | Wheel drives `distance` instead of `zoom`. | -| `minDistance` / `maxDistance` | `number` | `0` / `5000` | Dolly clamps. | +| `minDistance` / `maxDistance` | `number` | `0` / see note | Dolly clamps. **`maxDistance` defaults differ:** vanilla is `Infinity`, React/Vue is `5000`. Set it explicitly if it matters. | | `animate` | `false \| { speed?, axis?, pauseOnInteraction? }` | `false` | Autorotate. | `animate` fields: `speed` (default `0.3`, degrees per 60 Hz-equivalent frame ≈ @@ -95,7 +95,8 @@ every polygon. It tracks selected `PolyMeshHandle`s and supports multi-select. - `usePolySelect()` reads the current selection inside a subtree. - `usePolySelectionApi()` gives a nested toolbar `set`, `add`, `remove`, `toggle`, `clear`. -- Lower-level DOM helpers: `findPolyMeshHandle(el)`, +- Lower-level DOM helpers, **React and Vue only** — vanilla exports no + equivalent: `findPolyMeshHandle(el)`, `pointInMeshElement(meshEl, clientX, clientY)`, `findMeshUnderPoint(clientX, clientY, filter?)`. They use the same bounding-rect fallback that selection and transform controls use for clipped diff --git a/packages/skills/skill/docs/scenes-and-cameras.md b/packages/skills/skill/docs/scenes-and-cameras.md index c43af97fd..d187ef966 100644 --- a/packages/skills/skill/docs/scenes-and-cameras.md +++ b/packages/skills/skill/docs/scenes-and-cameras.md @@ -82,13 +82,13 @@ Prefer leaving it alone. | `position` | `Vec3` | Offset in scene space. | | `scale` | `number \| Vec3` | | | `rotation` | `Vec3` | Euler **degrees** `[x, y, z]`. | -| `autoCenter` | `boolean` | Shifts the mesh so its bbox center sits at the local origin before `position`. | +| `autoCenter` | `boolean` | **Not a vanilla `scene.add` option** — it is a `` prop and a `` attribute only. Shifts the mesh so its bbox center sits at the local origin before `position`. (The scene-level `autoCenter` above is a different, unrelated option.) | | `castShadow` / `receiveShadow` | `boolean` | See [shadows.md](shadows.md). | | `merge` | `boolean` | Default `true`. `false` renders the array entering the renderer exactly as given. | | `meshResolution` | `"lossy" \| "lossless"` | Default `"lossy"`. | | `stableDom` | `boolean` | Vanilla only; needed for skeletal animation. | | `shadowDefinition` | `number` | Per-mesh parametric shadow detail. | -| `excludeFromAutoCenter` | `boolean` | | +| `excludeFromAutoCenter` | `boolean` | **Vanilla only.** Keeps this mesh out of the scene's auto-center bbox — for helpers and debug overlays. | React/Vue additionally expose per-mesh `textureLighting`, `textureQuality`, `textureLeafSizing`, `textureImageRendering`, `textureBackend`, diff --git a/website/public/skill/docs/api-index.md b/website/public/skill/docs/api-index.md index a34fa5999..e574b5a45 100644 --- a/website/public/skill/docs/api-index.md +++ b/website/public/skill/docs/api-index.md @@ -113,9 +113,13 @@ Constant: `BASE_TILE` (50). ## Diagnostics and DOM helpers -`collectPolyRenderStats`, `collectPolyTextureReadiness`, `queryPolyLeaves`, -`findPolyMeshHandle`, `pointInMeshElement`, `findMeshUnderPoint`, -`injectPolyBaseStyles`. +Renderer packages only — these touch the DOM, so **none of them are in `core`**. + +From `@layoutit/polycss`, `-react` and `-vue`: `collectPolyRenderStats`, +`collectPolyTextureReadiness`, `queryPolyLeaves`, `injectPolyBaseStyles`. + +React and Vue only: `findPolyMeshHandle`, `pointInMeshElement`, +`findMeshUnderPoint`. Vanilla has no exported equivalent. Texture resolution (from `@layoutit/polycss-core` or `@layoutit/polycss`, **not** the React/Vue indexes): `resolvePolyTextureLeafGeometry`, @@ -143,13 +147,14 @@ Texture: `PolyTextureLightingMode`, `PolyTextureLeafSizing`, Camera: `PolyCameraProjection`, `PolyCameraSnapshot`, `PolyCameraSnapshotStats`. -Scene/mesh: `PolyMeshHandle`, `PolyMeshTransformInput`, -`PolySceneTransformInput` (all packages); `PolySceneOptions`, +Scene/mesh: `PolyMeshTransformInput`, `PolySceneTransformInput` (all packages); +`PolyMeshHandle` (renderer packages only — not in `core`); `PolySceneOptions`, `PolySceneHandle`, `PolyMeshTransform` (vanilla — React/Vue use component prop types such as `PolySceneProps` and `PolyMeshProps` instead). -Render: `PolyRenderStrategy`, `PolyRenderStrategiesOption`, `PolyRenderStats`, -`PolyLeafInfo` (all packages); `TextureQuality` (core and `@layoutit/polycss`). +Render: `PolyRenderStrategy`, `PolyRenderStrategiesOption` (all packages); +`PolyRenderStats` and `PolyLeafInfo` (renderer packages only — not in `core`); +`TextureQuality` (core and `@layoutit/polycss`). Parse options: `LoadMeshOptions`, `ObjParseOptions`, `StlParseOptions`, `GltfParseOptions`, `VoxParseOptions`, `UseMeshOptions`. diff --git a/website/public/skill/docs/controls-and-interaction.md b/website/public/skill/docs/controls-and-interaction.md index 968fc07f5..2581ea105 100644 --- a/website/public/skill/docs/controls-and-interaction.md +++ b/website/public/skill/docs/controls-and-interaction.md @@ -24,7 +24,7 @@ attached mesh handle via `setTransform`. | `invert` | `boolean \| number` | `false` | `true` reverses; a number scales sensitivity (negative inverts). | | `minZoom` / `maxZoom` | `number` | `0.1` / `10` | Zoom clamps. | | `dolly` | `boolean` | `false` | Wheel drives `distance` instead of `zoom`. | -| `minDistance` / `maxDistance` | `number` | `0` / `5000` | Dolly clamps. | +| `minDistance` / `maxDistance` | `number` | `0` / see note | Dolly clamps. **`maxDistance` defaults differ:** vanilla is `Infinity`, React/Vue is `5000`. Set it explicitly if it matters. | | `animate` | `false \| { speed?, axis?, pauseOnInteraction? }` | `false` | Autorotate. | `animate` fields: `speed` (default `0.3`, degrees per 60 Hz-equivalent frame ≈ @@ -95,7 +95,8 @@ every polygon. It tracks selected `PolyMeshHandle`s and supports multi-select. - `usePolySelect()` reads the current selection inside a subtree. - `usePolySelectionApi()` gives a nested toolbar `set`, `add`, `remove`, `toggle`, `clear`. -- Lower-level DOM helpers: `findPolyMeshHandle(el)`, +- Lower-level DOM helpers, **React and Vue only** — vanilla exports no + equivalent: `findPolyMeshHandle(el)`, `pointInMeshElement(meshEl, clientX, clientY)`, `findMeshUnderPoint(clientX, clientY, filter?)`. They use the same bounding-rect fallback that selection and transform controls use for clipped diff --git a/website/public/skill/docs/scenes-and-cameras.md b/website/public/skill/docs/scenes-and-cameras.md index c43af97fd..d187ef966 100644 --- a/website/public/skill/docs/scenes-and-cameras.md +++ b/website/public/skill/docs/scenes-and-cameras.md @@ -82,13 +82,13 @@ Prefer leaving it alone. | `position` | `Vec3` | Offset in scene space. | | `scale` | `number \| Vec3` | | | `rotation` | `Vec3` | Euler **degrees** `[x, y, z]`. | -| `autoCenter` | `boolean` | Shifts the mesh so its bbox center sits at the local origin before `position`. | +| `autoCenter` | `boolean` | **Not a vanilla `scene.add` option** — it is a `` prop and a `` attribute only. Shifts the mesh so its bbox center sits at the local origin before `position`. (The scene-level `autoCenter` above is a different, unrelated option.) | | `castShadow` / `receiveShadow` | `boolean` | See [shadows.md](shadows.md). | | `merge` | `boolean` | Default `true`. `false` renders the array entering the renderer exactly as given. | | `meshResolution` | `"lossy" \| "lossless"` | Default `"lossy"`. | | `stableDom` | `boolean` | Vanilla only; needed for skeletal animation. | | `shadowDefinition` | `number` | Per-mesh parametric shadow detail. | -| `excludeFromAutoCenter` | `boolean` | | +| `excludeFromAutoCenter` | `boolean` | **Vanilla only.** Keeps this mesh out of the scene's auto-center bbox — for helpers and debug overlays. | React/Vue additionally expose per-mesh `textureLighting`, `textureQuality`, `textureLeafSizing`, `textureImageRendering`, `textureBackend`, From 12ff93a29b35c6447a1b000e93a42413430955ae Mon Sep 17 00:00:00 2001 From: Juan Cruz Fortunatti Date: Fri, 14 Aug 2026 23:00:14 +0200 Subject: [PATCH 20/37] fix(shadow): reattach the projector JSDoc and mirror DEFAULT_SHADOW_LIFT to React and Vue --- .../core/src/shadow/computeReceiverShadows.ts | 20 +++++++++---------- packages/react/src/index.ts | 1 + packages/vue/src/index.ts | 1 + 3 files changed, 12 insertions(+), 10 deletions(-) diff --git a/packages/core/src/shadow/computeReceiverShadows.ts b/packages/core/src/shadow/computeReceiverShadows.ts index 3c9055bb0..413fc6d42 100644 --- a/packages/core/src/shadow/computeReceiverShadows.ts +++ b/packages/core/src/shadow/computeReceiverShadows.ts @@ -334,16 +334,6 @@ export function prepareCasterPolyItems( return out; } -/** - * Build ReceiverFacePlane[] for a receiver mesh. Pure: groups coplanar - * polygons, computes (u,v) basis + outline, applies interior occlusion cull - * (drops face planes hidden behind a parallel face plane within wall- - * thickness range). - * - * `shadowLift` is the world-unit lift applied along each face normal so the - * shadow SVG composites above the surface without z-fighting (matches the - * ground-shadow `shadow.lift` option). - */ /** * Default clearance between a receiver face and the shadow painted on it, in * world units. @@ -360,6 +350,16 @@ export function prepareCasterPolyItems( */ export const DEFAULT_SHADOW_LIFT = 0.05; +/** + * Build ReceiverFacePlane[] for a receiver mesh. Pure: groups coplanar + * polygons, computes (u,v) basis + outline, applies interior occlusion cull + * (drops face planes hidden behind a parallel face plane within wall- + * thickness range). + * + * `shadowLift` is the world-unit lift applied along each face normal so the + * shadow SVG composites above the surface without z-fighting (matches the + * ground-shadow `shadow.lift` option). + */ export function prepareReceiverFacePlanes( polygons: readonly Polygon[], position: Vec3, diff --git a/packages/react/src/index.ts b/packages/react/src/index.ts index 9fdd3babf..e0948b3f9 100644 --- a/packages/react/src/index.ts +++ b/packages/react/src/index.ts @@ -285,6 +285,7 @@ export { BASE_TILE, DEFAULT_CAMERA_STATE, DEFAULT_PROJECTION, + DEFAULT_SHADOW_LIFT, normalizeInvertMultiplier, buildPolyCameraSceneTransform, capturePolyCameraSnapshot, diff --git a/packages/vue/src/index.ts b/packages/vue/src/index.ts index fceb4e9ca..06fc78e19 100644 --- a/packages/vue/src/index.ts +++ b/packages/vue/src/index.ts @@ -276,6 +276,7 @@ export { BASE_TILE, DEFAULT_CAMERA_STATE, DEFAULT_PROJECTION, + DEFAULT_SHADOW_LIFT, normalizeInvertMultiplier, buildPolyCameraSceneTransform, capturePolyCameraSnapshot, From 0878f351d44c0330902c4ddf5ca21cafd2488cb7 Mon Sep 17 00:00:00 2001 From: Juan Cruz Fortunatti Date: Fri, 14 Aug 2026 23:00:14 +0200 Subject: [PATCH 21/37] fix(eval): make --reuse never invoke an agent and namespace runs by id --- eval/skill/run.mjs | 58 +++++++++++++++++++++++++++++++++++----------- 1 file changed, 45 insertions(+), 13 deletions(-) diff --git a/eval/skill/run.mjs b/eval/skill/run.mjs index 1aae141a2..874b4b452 100644 --- a/eval/skill/run.mjs +++ b/eval/skill/run.mjs @@ -65,8 +65,10 @@ Options Default: polycss. "three" is the no-skill control. --task Comma-separated task ids. Default: all. --keep Keep the agent workspaces for inspection. - --reuse Reuse a workspace that already has a scene.mjs instead of - re-running the agent. Grades existing work for free. + --reuse Grade existing workspaces only. Never invokes an agent: + a case with no scene.mjs is SKIPPED, not re-run. + --run Namespace this run's workspaces (default "current"), so a + later run cannot overwrite evidence you kept. --no-preflight Skip the per-agent readiness probe. --headed Run Chromium headed. --settle Delay between the two DOM samples (default 2000). @@ -85,6 +87,7 @@ function parseArgs(argv) { tasks: [], keep: false, reuse: false, + run: "current", preflight: true, headed: false, settle: 2000, @@ -105,6 +108,7 @@ function parseArgs(argv) { else if (arg === "--task") options.tasks.push(...value().split(",").map((t) => t.trim())); else if (arg === "--keep") options.keep = true; else if (arg === "--reuse") options.reuse = true; + else if (arg === "--run") options.run = value(); else if (arg === "--no-preflight") options.preflight = false; else if (arg === "--headed") options.headed = true; else if (arg === "--settle") options.settle = Number(value()); @@ -122,8 +126,8 @@ function parseArgs(argv) { * read their skills directory relative to the working directory, so installing * both flavours covers every CLI without special-casing. */ -function makeWorkspace(track, agent, task) { - const dir = join(workRoot, track, agent, task.id); +function makeWorkspace(runId, track, agent, task) { + const dir = workspaceDir(runId, track, agent, task); rmSync(dir, { recursive: true, force: true }); mkdirSync(dir, { recursive: true }); @@ -139,15 +143,27 @@ function makeWorkspace(track, agent, task) { const taskPrompt = (track, task) => `${task.prompt}\n\n${TRACKS[track].contract}`; +/** + * Workspaces are namespaced per run. Without that, every run owned the same + * `track/agent/task` path and wiped it before starting — a second `--keep` run + * silently destroyed the evidence the first one was asked to preserve, and two + * concurrent runs would fight over the same directories. + */ +const workspaceDir = (runId, track, agent, task) => + join(workRoot, runId, track, agent, task.id); + function runAgent(name, track, dir, task, timeoutSeconds) { const agent = AGENTS[name]; if (name === "oracle") { - const from = join(here, "oracle", track, `${task.id}.mjs`); + // The control shares the intervention's reference solutions — it differs + // only in whether the agent was given the skill. + const oracleTrack = track === "polycss-noskill" ? "polycss" : track; + const from = join(here, "oracle", oracleTrack, `${task.id}.mjs`); copyFileSync(from, join(dir, "scene.mjs")); // The Three reference scenes share a small setup helper; the bundler // resolves it relative to the copied entry. - const shared = join(here, "oracle", track, "_common.mjs"); + const shared = join(here, "oracle", oracleTrack, "_common.mjs"); if (existsSync(shared)) copyFileSync(shared, join(dir, "_common.mjs")); return { ok: true, ms: 0, output: "reference solution copied" }; } @@ -286,16 +302,25 @@ async function main(argv) { ); const candidates = []; + let skipped = 0; for (const name of agents) { for (const track of tracks) { for (const task of tasks) { process.stdout.write(`[eval] ${name} / ${track} / ${task.id} ... `); - const existing = join(workRoot, track, name, task.id, "scene.mjs"); - const reusing = options.reuse && existsSync(existing); - const dir = reusing - ? join(workRoot, track, name, task.id) - : makeWorkspace(track, name, task); + const existingDir = workspaceDir(options.run, track, name, task); + const reusing = options.reuse && existsSync(join(existingDir, "scene.mjs")); + + // Reuse means "grade what is already there". Falling through to the + // agent here would spend real, paid calls behind a flag documented as + // costing none. + if (options.reuse && !reusing) { + console.log("skipped — no scene.mjs to reuse"); + skipped += 1; + continue; + } + + const dir = reusing ? existingDir : makeWorkspace(options.run, track, name, task); const run = reusing ? { ok: true, ms: 0, output: "reused existing workspace" } : runAgent(name, track, dir, task, options.timeout); @@ -330,6 +355,10 @@ async function main(argv) { } } + if (skipped > 0) { + console.log(`\n[eval] skipped ${skipped} case(s) with nothing to reuse`); + } + const buildable = candidates.filter((c) => c.entry !== null); console.log(`\n[eval] grading ${buildable.length} scene(s) in Chromium ...\n`); @@ -392,7 +421,10 @@ async function main(argv) { // The comparison the control exists for: the same visual criteria, scored on // each track independently. Never a pixel diff between the two. if (tracks.length > 1) { - console.log("Visual criteria only (comparable across tracks)\n"); + console.log( + "Visual criteria only. polycss vs polycss-noskill isolates the skill;\n" + + "three is an external baseline, not a control.\n", + ); const width = Math.max(...agents.map((a) => AGENTS[a].label.length)); console.log(`${"agent".padEnd(width)} ${tracks.map((t) => TRACKS[t].label.padEnd(10)).join(" ")}`); for (const agent of agents) { @@ -419,7 +451,7 @@ async function main(argv) { rmSync(candidate.dir, { recursive: true, force: true }); } } else { - console.log(`[eval] workspaces kept in ${workRoot}`); + console.log(`[eval] workspaces kept in ${join(workRoot, options.run)}`); } const allPassed = rows.every((r) => r.passed === r.total); From 9123e2171c959f9e7f2a42d8647abb54c48cb626 Mon Sep 17 00:00:00 2001 From: Juan Cruz Fortunatti Date: Fri, 14 Aug 2026 23:00:14 +0200 Subject: [PATCH 22/37] feat(eval): add a polycss-noskill control track and demote three to a baseline --- eval/skill/README.md | 32 +++++++++++++++++++++++---- eval/skill/tracks.mjs | 50 ++++++++++++++++++++++++++++--------------- 2 files changed, 61 insertions(+), 21 deletions(-) diff --git a/eval/skill/README.md b/eval/skill/README.md index 17d27d5ed..d3baed1e2 100644 --- a/eval/skill/README.md +++ b/eval/skill/README.md @@ -9,10 +9,11 @@ Chromium** — not what the agent said it did. ```bash pnpm eval:skill --agent oracle --track all # reference solutions (must be 100%) +pnpm eval:skill --agent claude --track polycss,polycss-noskill # the real control pnpm eval:skill --agent claude --track all # one agent, both tracks, all tasks pnpm eval:skill --agent all --track all --keep --json out.json pnpm eval:skill --task 03-cube-with-shadow --agent claude,codex -pnpm eval:skill --agent claude --reuse # re-grade existing work, no new calls +pnpm eval:skill --agent claude --reuse --run r1 # re-grade only; never calls an agent pnpm eval:selftest # prove the graders can fail ``` @@ -31,6 +32,24 @@ For each (agent, task) pair: is observable. 5. **Grade** with the task's checks. +## Tracks: what is a control and what is not + +| Track | Role | +|---|---| +| `polycss` | The intervention — PolyCSS with the skill installed. | +| `polycss-noskill` | The **control**. Same library, task and contract; skill withheld. `polycss` minus this is the skill's effect. | +| `three` | An external **baseline**, not a control. | + +The Three.js track changes the library, its API and its authoring contract at +the same time as it removes the skill, so a polycss-vs-three delta conflates the +skill with how familiar and how costly each library is for that model. It still +earns its place — it answers "is this task hard for this model at all?" and it +calibrates the graders, which is how two over-strict checks were caught — but it +does not measure the skill. Use `polycss-noskill` for that. + +No track is diffed against another pixel-for-pixel; each is graded on its own +against the same task-level criteria, which is what makes the scores comparable. + ## What the isolation is worth The workspace lives outside the repo, which stops an agent reaching this @@ -123,9 +142,14 @@ below 100% is a finding: read the failure reason, then decide whether the skill failed to say something, said it somewhere the agent did not look, or the agent ignored it. The first two are fixable in `packages/skills/skill/`. -Use `--keep` to leave the workspaces under `$TMPDIR/polycss-skill-eval` and read -what the agent actually wrote. `--reuse` then re-grades them without spending -another agent call, which is how to iterate on a grader. +Use `--keep` to leave the workspaces under `$TMPDIR/polycss-skill-eval/` +and read what the agent actually wrote. Runs are namespaced by `--run ` +(default `current`) so a later run cannot wipe evidence you kept, and +concurrent runs do not collide. + +`--reuse` re-grades a kept run and **never invokes an agent**: a case with no +`scene.mjs` is skipped and reported, not re-run. That is how to iterate on a +grader for free. Screenshot a red result before believing it. Three separate findings in this suite turned out to be grader bugs, not agent mistakes. diff --git a/eval/skill/tracks.mjs b/eval/skill/tracks.mjs index b54310ee2..4366cf51b 100644 --- a/eval/skill/tracks.mjs +++ b/eval/skill/tracks.mjs @@ -1,20 +1,25 @@ /** - * The two tracks an agent is asked to build the same scene in. + * The tracks a task can be run in. * - * `polycss` is what we are actually evaluating. `three` is the **control**: the - * same model, the same task, a library it already knows well, and no skill - * installed. It exists to answer the question a PolyCSS score alone cannot — - * "did the agent fail because our skill is inadequate, or because the task - * itself is hard for it?" + * `polycss` is the intervention: PolyCSS with the skill installed. * - * The tracks are graded independently and never compared pixel-to-pixel. A - * PolyCSS scene is never diffed against a Three.js render, and the Three.js - * output is never a reference image. Both are measured against the SAME - * task-level visual criteria — is it the right color, the right size, lit, - * moving, shadowed — so the two scores are comparable as scores while each - * scene is judged only on its own merits. + * `polycss-noskill` is the actual CONTROL. Same library, same task, same + * contract, skill withheld — so the only thing that differs is the + * intervention, and the delta is attributable to the skill. * - * Workspaces are separate and neither knows the other exists. + * `three` is an external BASELINE, not a control. It changes the rendering + * library, its API and its whole authoring contract at the same time as it + * removes the skill, so a polycss-vs-three delta conflates the skill with how + * familiar and how expensive each library is for that model. It answers a + * different and still useful question — "is this task hard for this model at + * all?" — and it calibrates the graders. It does not measure the skill. + * + * No track is ever compared to another pixel-for-pixel. A PolyCSS scene is + * never diffed against a Three.js render and neither is a reference image for + * the other; each is graded on its own against the same task-level visual + * criteria, which is what makes the SCORES comparable. + * + * Workspaces are separate per track and neither knows the other exists. */ import { resolve } from "node:path"; import { fileURLToPath } from "node:url"; @@ -52,12 +57,17 @@ specifier. The scene renders as DOM elements, on the page's white background.`, }, }, + "polycss-noskill": { + label: "PolyCSS (no skill)", + /** The control: identical to `polycss` except the skill is withheld. */ + installSkill: false, + contract: null, // filled in below — identical to the polycss contract + alias: null, + }, + three: { label: "Three.js", - /** - * No skill, deliberately. The control measures what the model can do from - * its own training, which is the whole point of the comparison. - */ + /** External baseline — a different library, not a control. See the header. */ installSkill: false, contract: `${SHARED_CONTRACT} @@ -86,3 +96,9 @@ export function selectTracks(names) { } return [...new Set(expanded)]; } + +// The control must differ from the intervention in exactly one way, so it +// inherits the PolyCSS contract and aliases verbatim rather than restating +// them, where a copy could drift. +TRACKS["polycss-noskill"].contract = TRACKS.polycss.contract; +TRACKS["polycss-noskill"].alias = TRACKS.polycss.alias; From 9b1e3d239bd9e89a7cc9d92f0c0e30b5edd3c51c Mon Sep 17 00:00:00 2001 From: Juan Cruz Fortunatti Date: Fri, 14 Aug 2026 23:00:14 +0200 Subject: [PATCH 23/37] docs(skills): correct ambient freeze, colour defaults, PolyPointLight scope and gizmo example --- packages/skills/skill/docs/api-index.md | 10 ++++--- .../skill/docs/controls-and-interaction.md | 29 ++++++++++++++++--- packages/skills/skill/docs/lighting.md | 20 +++++++++---- .../skill/docs/shapes-and-primitives.md | 10 +++++-- website/public/skill/docs/api-index.md | 10 ++++--- .../skill/docs/controls-and-interaction.md | 29 ++++++++++++++++--- website/public/skill/docs/lighting.md | 20 +++++++++---- .../skill/docs/shapes-and-primitives.md | 10 +++++-- 8 files changed, 104 insertions(+), 34 deletions(-) diff --git a/packages/skills/skill/docs/api-index.md b/packages/skills/skill/docs/api-index.md index e574b5a45..1b23241ba 100644 --- a/packages/skills/skill/docs/api-index.md +++ b/packages/skills/skill/docs/api-index.md @@ -136,10 +136,12 @@ constants `LoopOnce`, `LoopRepeat`, `LoopPingPong`. Geometry: `Vec2`, `Vec3`, `Polygon`, `PolyMaterial`, `ParseResult`, `MeshResolution`. -Lights: `PolyDirectionalLight`, `PolyAmbientLight` (all packages); -`PolyPointLight` (core, `@layoutit/polycss`, and the `*/three` subpaths — React -and Vue accept the `pointLights` prop but do not re-export the type, so import -it from `@layoutit/polycss-core`). +Lights: `PolyDirectionalLight`, `PolyAmbientLight` (all packages). +`PolyPointLight` is narrower: `@layoutit/polycss-core`, `@layoutit/polycss`, and +`@layoutit/polycss/three` only. It is **not** re-exported by +`@layoutit/polycss-core/three`, `@layoutit/polycss-react/three`, or +`@layoutit/polycss-vue/three`, and React and Vue accept the `pointLights` prop +without exporting the type — take it from `@layoutit/polycss-core`. Texture: `PolyTextureLightingMode`, `PolyTextureLeafSizing`, `PolyTextureBackend`, `PolyTextureImageRendering`, `PolyTextureImageLighting`, diff --git a/packages/skills/skill/docs/controls-and-interaction.md b/packages/skills/skill/docs/controls-and-interaction.md index 2581ea105..dea052065 100644 --- a/packages/skills/skill/docs/controls-and-interaction.md +++ b/packages/skills/skill/docs/controls-and-interaction.md @@ -12,8 +12,16 @@ loop, and it updates one ancestor transform, not per-polygon state. | `PolyTransformControls` / `createTransformControls` | Translate/rotate gizmo on a selected mesh handle. | | `PolySelect` / `createSelect` | Pointer picking over mesh handles. | -Camera controls mutate the wrapping camera state. Transform controls mutate the -attached mesh handle via `setTransform`. +Camera controls mutate the wrapping camera state. + +Transform controls differ by renderer, and this catches people: + +- **Vanilla** mesh handles expose `setTransform`, so the gizmo moves the mesh + directly. +- **React and Vue** handles deliberately have **no** `setTransform`. The gizmo + only *reports* — it emits `onObjectChange`, and you must commit the new + position/rotation to your own state. A gizmo wired without that callback drags + visibly but the mesh never moves. ## Orbit / Map options @@ -86,12 +94,25 @@ For whole-mesh selection use `PolySelect` / `` rather than wiring every polygon. It tracks selected `PolyMeshHandle`s and supports multi-select. ```tsx +// React: the mesh is controlled state, and onObjectChange commits the drag. +const [selected, setSelected] = useState(null); +const [position, setPosition] = useState([0, 0, 0]); + setSelected(meshes[0] ?? null)}> - + - + setPosition(e.position)} +/> ``` +Drop `onObjectChange` and nothing moves — the gizmo has no way to write back. +In vanilla the equivalent needs no callback, because `createTransformControls` +calls `handle.setTransform(...)` itself. + - `usePolySelect()` reads the current selection inside a subtree. - `usePolySelectionApi()` gives a nested toolbar `set`, `add`, `remove`, `toggle`, `clear`. diff --git a/packages/skills/skill/docs/lighting.md b/packages/skills/skill/docs/lighting.md index 0fc6d5845..809b358f9 100644 --- a/packages/skills/skill/docs/lighting.md +++ b/packages/skills/skill/docs/lighting.md @@ -52,12 +52,20 @@ baseline. `mesh.rebakeAtlas()` explicitly, typically debounced to drag-end. This is deliberate: it keeps high-frequency light drags fast. - **React/Vue re-render and do auto-rebake** on any light prop change. -- **Exception:** a vanilla `setOptions({ pointLights })` change *does* re-render - every mesh. The freeze applies to the directional light only. - -Cast shadows are cheap (CPU-projected SVG) and re-emit automatically on any -light change in both renderer families, so a scene can have a live shadow over a -frozen baked surface until you rebake. +- **Vanilla `setOptions({ pointLights })` *does* re-render** every mesh. +- **Vanilla `setOptions({ ambientLight })` changes nothing on its own.** There + is no ambient branch in the change detection at all, so the baked surface + stays stale *and* the shadow fill — which is derived from ambient — is not + re-emitted. Change the directional light in the same call, or call + `mesh.rebakeAtlas()`, to make an ambient edit take effect. + +So the vanilla freeze covers the directional **and** ambient lights, not the +directional light alone. + +Cast shadows are cheap (CPU-projected SVG) and re-emit on a *directional* or +*point* light change in both renderer families — so a scene can show a live +shadow over a frozen baked surface until you rebake. An ambient-only change in +vanilla re-emits nothing. ### `"dynamic"` diff --git a/packages/skills/skill/docs/shapes-and-primitives.md b/packages/skills/skill/docs/shapes-and-primitives.md index ee9e6de6c..50d4981d4 100644 --- a/packages/skills/skill/docs/shapes-and-primitives.md +++ b/packages/skills/skill/docs/shapes-and-primitives.md @@ -17,9 +17,13 @@ generators when you want raw arrays to post-process. ## Options and defaults Defaults below are the generator defaults. The default `color` is **not -uniform**: `boxPolygons`, `planePolygons`, `ringPolygons`, and -`octahedronPolygons` default to `#ffffff`; every other generator defaults to -`#cccccc`. Pass `color` explicitly rather than relying on either. +uniform** — pass `color` explicitly rather than relying on any of these: + +| Generators | Default | +|---|---| +| `boxPolygons`, `planePolygons`, `ringPolygons`, `octahedronPolygons`, `arrowPolygons`, `ringQuadPolygons` | `#ffffff` | +| `spherePolygons`, `cylinderPolygons`, `conePolygons`, `torusPolygons`, `tetrahedronPolygons`, `icosahedronPolygons`, `dodecahedronPolygons` | `#cccccc` | +| `axesPolygons` | per axis: `xColor` `#ff3a3a`, `yColor` `#3aff3a`, `zColor` `#3a8aff` | ### `boxPolygons` / `createPolyBox` / `PolyBox` diff --git a/website/public/skill/docs/api-index.md b/website/public/skill/docs/api-index.md index e574b5a45..1b23241ba 100644 --- a/website/public/skill/docs/api-index.md +++ b/website/public/skill/docs/api-index.md @@ -136,10 +136,12 @@ constants `LoopOnce`, `LoopRepeat`, `LoopPingPong`. Geometry: `Vec2`, `Vec3`, `Polygon`, `PolyMaterial`, `ParseResult`, `MeshResolution`. -Lights: `PolyDirectionalLight`, `PolyAmbientLight` (all packages); -`PolyPointLight` (core, `@layoutit/polycss`, and the `*/three` subpaths — React -and Vue accept the `pointLights` prop but do not re-export the type, so import -it from `@layoutit/polycss-core`). +Lights: `PolyDirectionalLight`, `PolyAmbientLight` (all packages). +`PolyPointLight` is narrower: `@layoutit/polycss-core`, `@layoutit/polycss`, and +`@layoutit/polycss/three` only. It is **not** re-exported by +`@layoutit/polycss-core/three`, `@layoutit/polycss-react/three`, or +`@layoutit/polycss-vue/three`, and React and Vue accept the `pointLights` prop +without exporting the type — take it from `@layoutit/polycss-core`. Texture: `PolyTextureLightingMode`, `PolyTextureLeafSizing`, `PolyTextureBackend`, `PolyTextureImageRendering`, `PolyTextureImageLighting`, diff --git a/website/public/skill/docs/controls-and-interaction.md b/website/public/skill/docs/controls-and-interaction.md index 2581ea105..dea052065 100644 --- a/website/public/skill/docs/controls-and-interaction.md +++ b/website/public/skill/docs/controls-and-interaction.md @@ -12,8 +12,16 @@ loop, and it updates one ancestor transform, not per-polygon state. | `PolyTransformControls` / `createTransformControls` | Translate/rotate gizmo on a selected mesh handle. | | `PolySelect` / `createSelect` | Pointer picking over mesh handles. | -Camera controls mutate the wrapping camera state. Transform controls mutate the -attached mesh handle via `setTransform`. +Camera controls mutate the wrapping camera state. + +Transform controls differ by renderer, and this catches people: + +- **Vanilla** mesh handles expose `setTransform`, so the gizmo moves the mesh + directly. +- **React and Vue** handles deliberately have **no** `setTransform`. The gizmo + only *reports* — it emits `onObjectChange`, and you must commit the new + position/rotation to your own state. A gizmo wired without that callback drags + visibly but the mesh never moves. ## Orbit / Map options @@ -86,12 +94,25 @@ For whole-mesh selection use `PolySelect` / `` rather than wiring every polygon. It tracks selected `PolyMeshHandle`s and supports multi-select. ```tsx +// React: the mesh is controlled state, and onObjectChange commits the drag. +const [selected, setSelected] = useState(null); +const [position, setPosition] = useState([0, 0, 0]); + setSelected(meshes[0] ?? null)}> - + - + setPosition(e.position)} +/> ``` +Drop `onObjectChange` and nothing moves — the gizmo has no way to write back. +In vanilla the equivalent needs no callback, because `createTransformControls` +calls `handle.setTransform(...)` itself. + - `usePolySelect()` reads the current selection inside a subtree. - `usePolySelectionApi()` gives a nested toolbar `set`, `add`, `remove`, `toggle`, `clear`. diff --git a/website/public/skill/docs/lighting.md b/website/public/skill/docs/lighting.md index 0fc6d5845..809b358f9 100644 --- a/website/public/skill/docs/lighting.md +++ b/website/public/skill/docs/lighting.md @@ -52,12 +52,20 @@ baseline. `mesh.rebakeAtlas()` explicitly, typically debounced to drag-end. This is deliberate: it keeps high-frequency light drags fast. - **React/Vue re-render and do auto-rebake** on any light prop change. -- **Exception:** a vanilla `setOptions({ pointLights })` change *does* re-render - every mesh. The freeze applies to the directional light only. - -Cast shadows are cheap (CPU-projected SVG) and re-emit automatically on any -light change in both renderer families, so a scene can have a live shadow over a -frozen baked surface until you rebake. +- **Vanilla `setOptions({ pointLights })` *does* re-render** every mesh. +- **Vanilla `setOptions({ ambientLight })` changes nothing on its own.** There + is no ambient branch in the change detection at all, so the baked surface + stays stale *and* the shadow fill — which is derived from ambient — is not + re-emitted. Change the directional light in the same call, or call + `mesh.rebakeAtlas()`, to make an ambient edit take effect. + +So the vanilla freeze covers the directional **and** ambient lights, not the +directional light alone. + +Cast shadows are cheap (CPU-projected SVG) and re-emit on a *directional* or +*point* light change in both renderer families — so a scene can show a live +shadow over a frozen baked surface until you rebake. An ambient-only change in +vanilla re-emits nothing. ### `"dynamic"` diff --git a/website/public/skill/docs/shapes-and-primitives.md b/website/public/skill/docs/shapes-and-primitives.md index ee9e6de6c..50d4981d4 100644 --- a/website/public/skill/docs/shapes-and-primitives.md +++ b/website/public/skill/docs/shapes-and-primitives.md @@ -17,9 +17,13 @@ generators when you want raw arrays to post-process. ## Options and defaults Defaults below are the generator defaults. The default `color` is **not -uniform**: `boxPolygons`, `planePolygons`, `ringPolygons`, and -`octahedronPolygons` default to `#ffffff`; every other generator defaults to -`#cccccc`. Pass `color` explicitly rather than relying on either. +uniform** — pass `color` explicitly rather than relying on any of these: + +| Generators | Default | +|---|---| +| `boxPolygons`, `planePolygons`, `ringPolygons`, `octahedronPolygons`, `arrowPolygons`, `ringQuadPolygons` | `#ffffff` | +| `spherePolygons`, `cylinderPolygons`, `conePolygons`, `torusPolygons`, `tetrahedronPolygons`, `icosahedronPolygons`, `dodecahedronPolygons` | `#cccccc` | +| `axesPolygons` | per axis: `xColor` `#ff3a3a`, `yColor` `#3aff3a`, `zColor` `#3a8aff` | ### `boxPolygons` / `createPolyBox` / `PolyBox` From 9d245b4f06fc31c2be9ebf597ea1afdbf14d15b4 Mon Sep 17 00:00:00 2001 From: Juan Cruz Fortunatti Date: Fri, 14 Aug 2026 23:18:54 +0200 Subject: [PATCH 24/37] fix(skills): resolve containment through symlinked ancestors and randomise the temp name --- packages/skills/README.md | 11 +++++++++ packages/skills/src/install.mjs | 41 +++++++++++++++++++++++---------- 2 files changed, 40 insertions(+), 12 deletions(-) diff --git a/packages/skills/README.md b/packages/skills/README.md index db9960030..0f62a01e6 100644 --- a/packages/skills/README.md +++ b/packages/skills/README.md @@ -75,6 +75,17 @@ If you have edited an installed file, the upgrade stops and names it rather than overwriting your work. Re-run with `--force` when you want the shipped version back. +## What the installer refuses + +It only ever writes inside the skill directory. Manifest entries are validated +as plain relative paths, and a symlink anywhere *inside* the destination — a +managed file or one of its parent directories — is refused rather than written +through, so nothing outside can be created or deleted. + +The destination root itself is followed if it is a symlink, because a shared +skills directory is a reasonable layout. If you do not want that, pass an +explicit `--dir`. + ## Fetching instead of installing The same content is served at diff --git a/packages/skills/src/install.mjs b/packages/skills/src/install.mjs index e648a252e..84dc3a598 100644 --- a/packages/skills/src/install.mjs +++ b/packages/skills/src/install.mjs @@ -9,7 +9,7 @@ * * Zero runtime dependencies: this runs under `npx` in someone else's project. */ -import { createHash } from "node:crypto"; +import { createHash, randomBytes } from "node:crypto"; import { lstatSync, mkdirSync, @@ -87,13 +87,29 @@ export function isSafeRelativePath(rel) { return true; } -const realpathOrSelf = (path) => { - try { - return realpathSync(path); - } catch { - return path; +/** + * Real location of `path`, resolving through the nearest ancestor that exists. + * + * A plain `realpathSync` throws when the destination has not been created yet + * and the lexical fallback then hides a symlinked ANCESTOR — so a planted + * `.claude/skills -> /elsewhere` would be silently followed on a first install. + */ +function realpathThroughAncestors(path) { + const absolute = resolve(path); + const trailing = []; + let current = absolute; + + for (;;) { + try { + return join(realpathSync(current), ...trailing); + } catch { + const parent = dirname(current); + if (parent === current) return absolute; + trailing.unshift(current.slice(parent.length + 1)); + current = parent; + } } -}; +} /** * Resolve `rel` under `root`, returning null unless it genuinely stays inside. @@ -114,7 +130,7 @@ function containedPath(root, rel) { // The destination root may legitimately be a symlink — a shared skills // directory is a reasonable layout — so containment is measured against its // real location rather than refused. - const base = realpathOrSelf(resolve(root)); + const base = realpathThroughAncestors(root); const parts = rel.split("/"); let current = base; @@ -190,14 +206,15 @@ function writeFileNoFollow(path, bytes) { if (isSymlink(path)) unlinkSync(path); // Same-directory temp + rename: rename replaces the entry itself, so a link // created between the check and the write cannot be followed either. - const temp = `${path}.polycss-skill-tmp`; + // Random suffix + exclusive create: a predictable temp name is a file another + // local process can plant and win a race against. + const temp = `${path}.${randomBytes(6).toString("hex")}.polycss-skill-tmp`; try { writeFileSync(temp, bytes, { flag: "wx" }); - } catch { + renameSync(temp, path); + } finally { rmSync(temp, { force: true }); - writeFileSync(temp, bytes); } - renameSync(temp, path); } /** From 7067903fced07f397e5229902a7da2d4145803e2 Mon Sep 17 00:00:00 2001 From: Juan Cruz Fortunatti Date: Fri, 14 Aug 2026 23:18:54 +0200 Subject: [PATCH 25/37] fix(eval): validate run ids and fail a run that grades nothing --- eval/skill/run.mjs | 22 ++++++++++++++++++++++ 1 file changed, 22 insertions(+) diff --git a/eval/skill/run.mjs b/eval/skill/run.mjs index 874b4b452..3be76a1bb 100644 --- a/eval/skill/run.mjs +++ b/eval/skill/run.mjs @@ -152,6 +152,20 @@ const taskPrompt = (track, task) => `${task.prompt}\n\n${TRACKS[track].contract} const workspaceDir = (runId, track, agent, task) => join(workRoot, runId, track, agent, task.id); +/** + * Run ids name a directory that gets recursively deleted, so they are + * validated rather than trusted: `--run ..` resolved above the workspace root + * and would have taken a sibling directory with it. + */ +function assertSafeRunId(runId) { + if (!/^[A-Za-z0-9._-]+$/.test(runId) || runId === "." || runId === "..") { + throw new Error( + `invalid --run id ${JSON.stringify(runId)} — use letters, digits, dot, dash or underscore`, + ); + } + return runId; +} + function runAgent(name, track, dir, task, timeoutSeconds) { const agent = AGENTS[name]; @@ -264,6 +278,7 @@ async function main(argv) { agents = expandAgents(options.agents.length > 0 ? options.agents : ["oracle"]); tasks = selectTasks(options.tasks); tracks = selectTracks(options.tracks); + assertSafeRunId(options.run); } catch (error) { console.error(error.message); return 1; @@ -454,6 +469,13 @@ async function main(argv) { console.log(`[eval] workspaces kept in ${join(workRoot, options.run)}`); } + if (rows.length === 0) { + // `rows.every` is vacuously true, so a run where every case was skipped + // would otherwise exit 0 and read as a green result in CI. + console.error("[eval] no scenes were graded"); + return 1; + } + const allPassed = rows.every((r) => r.passed === r.total); // Only the reference solution is expected to be perfect; a real agent // scoring below 100% is a finding, not a harness failure. From 502915ff13e0d441403e30a5dc4a647e359cd8f5 Mon Sep 17 00:00:00 2001 From: Juan Cruz Fortunatti Date: Fri, 14 Aug 2026 23:18:54 +0200 Subject: [PATCH 26/37] docs(skills): use the exported axesHelperPolygons name and guard the optional gizmo field --- packages/skills/skill/docs/controls-and-interaction.md | 4 +++- packages/skills/skill/docs/shapes-and-primitives.md | 2 +- website/public/skill/docs/controls-and-interaction.md | 4 +++- website/public/skill/docs/shapes-and-primitives.md | 2 +- 4 files changed, 8 insertions(+), 4 deletions(-) diff --git a/packages/skills/skill/docs/controls-and-interaction.md b/packages/skills/skill/docs/controls-and-interaction.md index dea052065..2d9223020 100644 --- a/packages/skills/skill/docs/controls-and-interaction.md +++ b/packages/skills/skill/docs/controls-and-interaction.md @@ -105,7 +105,9 @@ const [position, setPosition] = useState([0, 0, 0]); object={selected} mode="translate" translationSnap={10} - onObjectChange={(e) => setPosition(e.position)} + // `position` and `rotation` are both optional — only the one the current + // mode changed is present. + onObjectChange={(e) => { if (e.position) setPosition(e.position); }} /> ``` diff --git a/packages/skills/skill/docs/shapes-and-primitives.md b/packages/skills/skill/docs/shapes-and-primitives.md index 50d4981d4..b9adda268 100644 --- a/packages/skills/skill/docs/shapes-and-primitives.md +++ b/packages/skills/skill/docs/shapes-and-primitives.md @@ -23,7 +23,7 @@ uniform** — pass `color` explicitly rather than relying on any of these: |---|---| | `boxPolygons`, `planePolygons`, `ringPolygons`, `octahedronPolygons`, `arrowPolygons`, `ringQuadPolygons` | `#ffffff` | | `spherePolygons`, `cylinderPolygons`, `conePolygons`, `torusPolygons`, `tetrahedronPolygons`, `icosahedronPolygons`, `dodecahedronPolygons` | `#cccccc` | -| `axesPolygons` | per axis: `xColor` `#ff3a3a`, `yColor` `#3aff3a`, `zColor` `#3a8aff` | +| `axesHelperPolygons` | per axis: `xColor` `#ff3a3a`, `yColor` `#3aff3a`, `zColor` `#3a8aff` | ### `boxPolygons` / `createPolyBox` / `PolyBox` diff --git a/website/public/skill/docs/controls-and-interaction.md b/website/public/skill/docs/controls-and-interaction.md index dea052065..2d9223020 100644 --- a/website/public/skill/docs/controls-and-interaction.md +++ b/website/public/skill/docs/controls-and-interaction.md @@ -105,7 +105,9 @@ const [position, setPosition] = useState([0, 0, 0]); object={selected} mode="translate" translationSnap={10} - onObjectChange={(e) => setPosition(e.position)} + // `position` and `rotation` are both optional — only the one the current + // mode changed is present. + onObjectChange={(e) => { if (e.position) setPosition(e.position); }} /> ``` diff --git a/website/public/skill/docs/shapes-and-primitives.md b/website/public/skill/docs/shapes-and-primitives.md index 50d4981d4..b9adda268 100644 --- a/website/public/skill/docs/shapes-and-primitives.md +++ b/website/public/skill/docs/shapes-and-primitives.md @@ -23,7 +23,7 @@ uniform** — pass `color` explicitly rather than relying on any of these: |---|---| | `boxPolygons`, `planePolygons`, `ringPolygons`, `octahedronPolygons`, `arrowPolygons`, `ringQuadPolygons` | `#ffffff` | | `spherePolygons`, `cylinderPolygons`, `conePolygons`, `torusPolygons`, `tetrahedronPolygons`, `icosahedronPolygons`, `dodecahedronPolygons` | `#cccccc` | -| `axesPolygons` | per axis: `xColor` `#ff3a3a`, `yColor` `#3aff3a`, `zColor` `#3a8aff` | +| `axesHelperPolygons` | per axis: `xColor` `#ff3a3a`, `yColor` `#3aff3a`, `zColor` `#3a8aff` | ### `boxPolygons` / `createPolyBox` / `PolyBox` From 73a87e6f527a776b44a655261b1d317979288bf5 Mon Sep 17 00:00:00 2001 From: Juan Cruz Fortunatti Date: Fri, 14 Aug 2026 23:53:54 +0200 Subject: [PATCH 27/37] fix(skills): confine auto-detected installs to the project root --- packages/skills/bin/polycss-skills.mjs | 3 ++ packages/skills/src/install.mjs | 48 +++++++++++++++++++------- 2 files changed, 39 insertions(+), 12 deletions(-) diff --git a/packages/skills/bin/polycss-skills.mjs b/packages/skills/bin/polycss-skills.mjs index 483666590..7e7f15f0b 100644 --- a/packages/skills/bin/polycss-skills.mjs +++ b/packages/skills/bin/polycss-skills.mjs @@ -169,6 +169,9 @@ function main(argv) { version, force: options.force, dryRun: options.dryRun, + // Auto-detected targets are confined to the project. A destination the + // user named explicitly (--dir/--global) may live anywhere. + boundary: options.dir !== null || options.global ? null : resolve(options.cwd), }); } catch (error) { console.error(`polycss-skills: ${target.label}: ${error.message}`); diff --git a/packages/skills/src/install.mjs b/packages/skills/src/install.mjs index 84dc3a598..8bdd826b2 100644 --- a/packages/skills/src/install.mjs +++ b/packages/skills/src/install.mjs @@ -125,8 +125,21 @@ function realpathThroughAncestors(path) { * here, and the caller treats it as a conflict (or, under `--force`, unlinks * and replaces the link itself rather than writing through it). */ -function containedPath(root, rel) { +/** True when `root` really lives inside `boundary`, following symlinks on both. */ +function withinBoundary(root, boundary) { + const outer = realpathThroughAncestors(boundary); + const real = realpathThroughAncestors(root); + return real === outer || real.startsWith(outer + sep); +} + +function containedPath(root, rel, boundary = null) { if (!isSafeRelativePath(rel)) return null; + // For an auto-detected destination the caller passes the project root as a + // boundary. A hostile repository can ship `.claude/skills -> /elsewhere`, and + // without this the install follows it straight out of the project. Comparing + // REAL paths on both sides keeps ordinary system links (`/var` -> + // `/private/var`) from reading as an escape. + if (boundary !== null && !withinBoundary(root, boundary)) return null; // The destination root may legitimately be a symlink — a shared skills // directory is a reasonable layout — so containment is measured against its // real location rather than refused. @@ -151,8 +164,8 @@ function containedPath(root, rel) { return target.startsWith(base + sep) ? target : null; } -function readManifest(destDir) { - const path = containedPath(destDir, MANIFEST_FILE); +function readManifest(destDir, boundary) { + const path = containedPath(destDir, MANIFEST_FILE, boundary); if (path === null || isSymlink(path)) return null; try { const parsed = JSON.parse(readFileSync(path, "utf8")); @@ -164,7 +177,7 @@ function readManifest(destDir) { // unsafe key would send a delete outside the skill directory entirely. for (const [key, value] of Object.entries(files)) { if (typeof value !== "string") return null; - if (containedPath(destDir, key) === null) return null; + if (containedPath(destDir, key, boundary) === null) return null; } return { version: typeof parsed.version === "string" ? parsed.version : null, files }; } catch { @@ -224,13 +237,23 @@ function writeFileNoFollow(path, bytes) { * and are not accounted for by the manifest — i.e. hand-edited. They block the * install unless `force` is set. */ -export function planInstall({ sourceDir, destDir, force = false }) { +export function planInstall({ sourceDir, destDir, force = false, boundary = null }) { const sourceFiles = listFiles(sourceDir); if (sourceFiles.length === 0) { throw new Error(`no skill files found in ${sourceDir}`); } - const manifest = readManifest(destDir); + // Fail loudly rather than reporting every file as a "local edit": the + // destination itself resolves outside the project the caller confined us to. + if (boundary !== null && !withinBoundary(destDir, boundary)) { + const outer = realpathThroughAncestors(boundary); + throw new Error( + `${destDir} resolves to ${realpathThroughAncestors(destDir)}, outside ${outer} — ` + + "a symlinked parent directory points out of the project. Pass --dir explicitly if that is intended.", + ); + } + + const manifest = readManifest(destDir, boundary); const write = []; const unchanged = []; const conflicts = []; @@ -238,7 +261,7 @@ export function planInstall({ sourceDir, destDir, force = false }) { const kept = []; for (const rel of sourceFiles) { - const target = containedPath(destDir, rel); + const target = containedPath(destDir, rel, boundary); if (target === null) { conflicts.push(rel); continue; @@ -271,7 +294,7 @@ export function planInstall({ sourceDir, destDir, force = false }) { const shipped = new Set(sourceFiles); for (const rel of Object.keys(manifest?.files ?? {})) { if (shipped.has(rel)) continue; - const target = containedPath(destDir, rel); + const target = containedPath(destDir, rel, boundary); if (target === null) continue; const current = readIfFile(target); if (current === null || current === SYMLINK) continue; @@ -303,8 +326,9 @@ export function installSkill({ version = "0.0.0", force = false, dryRun = false, + boundary = null, } = {}) { - const plan = planInstall({ sourceDir, destDir, force }); + const plan = planInstall({ sourceDir, destDir, force, boundary }); if (plan.conflicts.length > 0 || dryRun) { return { ...plan, destDir, applied: false }; @@ -312,7 +336,7 @@ export function installSkill({ const files = {}; for (const rel of plan.sourceFiles) { - const target = containedPath(destDir, rel); + const target = containedPath(destDir, rel, boundary); if (target === null) continue; const bytes = readFileSync(join(sourceDir, toNative(rel))); files[rel] = sha256(bytes); @@ -321,14 +345,14 @@ export function installSkill({ } for (const rel of plan.remove) { - const target = containedPath(destDir, rel); + const target = containedPath(destDir, rel, boundary); if (target === null) continue; rmSync(target, { force: true }); } mkdirSync(destDir, { recursive: true }); writeFileNoFollow( - containedPath(destDir, MANIFEST_FILE), + containedPath(destDir, MANIFEST_FILE, boundary), `${JSON.stringify({ skill: SKILL_NAME, version, files }, null, 2)}\n`, ); From f188f008fbee659fb5e6cd1824fef3ebd36b0916 Mon Sep 17 00:00:00 2001 From: Juan Cruz Fortunatti Date: Fri, 14 Aug 2026 23:53:55 +0200 Subject: [PATCH 28/37] fix(eval): keep reused workspaces, isolate track roots, unique run ids, fail on missing cases --- eval/skill/run.mjs | 63 ++++++++++++++++++++++++++++++++++++---------- 1 file changed, 50 insertions(+), 13 deletions(-) diff --git a/eval/skill/run.mjs b/eval/skill/run.mjs index 3be76a1bb..365ef067b 100644 --- a/eval/skill/run.mjs +++ b/eval/skill/run.mjs @@ -27,6 +27,7 @@ * node eval/skill/run.mjs --agent claude,codex --task 01-static-cube * node eval/skill/run.mjs --agent all --json results.json */ +import { randomBytes } from "node:crypto"; import { execFileSync } from "node:child_process"; import { copyFileSync, @@ -62,13 +63,18 @@ Options --agent Comma-separated: ${AGENT_NAMES.join(", ")}, or all. Default: oracle. --track Comma-separated: ${TRACK_NAMES.join(", ")}, or all. - Default: polycss. "three" is the no-skill control. + Default: polycss. "polycss-noskill" is the control (same + library, skill withheld); "three" is an external baseline, + not a control. --task Comma-separated task ids. Default: all. --keep Keep the agent workspaces for inspection. --reuse Grade existing workspaces only. Never invokes an agent: a case with no scene.mjs is SKIPPED, not re-run. - --run Namespace this run's workspaces (default "current"), so a - later run cannot overwrite evidence you kept. + --run Namespace this run's workspaces. Defaults to a fresh unique + id, printed on start, so no run can overwrite another's. + Required with --reuse, which needs a specific run to grade. + --allow-missing With --reuse, tolerate requested cases that have no + scene.mjs instead of failing. --no-preflight Skip the per-agent readiness probe. --headed Run Chromium headed. --settle Delay between the two DOM samples (default 2000). @@ -87,7 +93,8 @@ function parseArgs(argv) { tasks: [], keep: false, reuse: false, - run: "current", + allowMissing: false, + run: null, preflight: true, headed: false, settle: 2000, @@ -108,6 +115,7 @@ function parseArgs(argv) { else if (arg === "--task") options.tasks.push(...value().split(",").map((t) => t.trim())); else if (arg === "--keep") options.keep = true; else if (arg === "--reuse") options.reuse = true; + else if (arg === "--allow-missing") options.allowMissing = true; else if (arg === "--run") options.run = value(); else if (arg === "--no-preflight") options.preflight = false; else if (arg === "--headed") options.headed = true; @@ -150,7 +158,20 @@ const taskPrompt = (track, task) => `${task.prompt}\n\n${TRACKS[track].contract} * concurrent runs would fight over the same directories. */ const workspaceDir = (runId, track, agent, task) => - join(workRoot, runId, track, agent, task.id); + join(workRoot, runId, `track-${track}`, agent, task.id); + +/** + * Control tracks run first, and each track owns a separate root. + * + * With one shared root the no-skill control could read the intervention's + * installed skill through a sibling path — reproduced at + * `../../../polycss//.agents/skills/polycss/SKILL.md`. Ordering the + * skill-less tracks first means those workspaces do not exist yet while the + * control runs. This narrows the leak; it is not a sandbox, and the README + * says so. + */ +const orderTracks = (names) => + [...names].sort((a, b) => Number(TRACKS[a].installSkill) - Number(TRACKS[b].installSkill)); /** * Run ids name a directory that gets recursively deleted, so they are @@ -214,9 +235,9 @@ function runAgent(name, track, dir, task, timeoutSeconds) { * not logged in blocks on an interactive prompt forever; finding that out now * costs one minute instead of an hour of dead runs. */ -function preflight(name, timeoutSeconds = 90) { +function preflight(name, runId, timeoutSeconds = 90) { if (name === "oracle") return { ok: true }; - const dir = join(workRoot, "__preflight__", name); + const dir = join(workRoot, runId, "__preflight__", name); rmSync(dir, { recursive: true, force: true }); mkdirSync(dir, { recursive: true }); const agent = AGENTS[name]; @@ -278,6 +299,10 @@ async function main(argv) { agents = expandAgents(options.agents.length > 0 ? options.agents : ["oracle"]); tasks = selectTasks(options.tasks); tracks = selectTracks(options.tracks); + if (options.reuse && options.run === null) { + throw new Error("--reuse needs --run : it grades a specific retained run"); + } + if (options.run === null) options.run = `run-${randomBytes(4).toString("hex")}`; assertSafeRunId(options.run); } catch (error) { console.error(error.message); @@ -300,11 +325,11 @@ async function main(argv) { if (options.preflight && !options.reuse) { for (const name of [...agents]) { process.stdout.write(`[eval] preflight ${name} ... `); - const ready = preflight(name); + const ready = preflight(name, options.run); console.log(ready.ok ? "ready" : `SKIPPED: ${ready.reason}`); if (!ready.ok) agents.splice(agents.indexOf(name), 1); } - rmSync(join(workRoot, "__preflight__"), { recursive: true, force: true }); + rmSync(join(workRoot, options.run, "__preflight__"), { recursive: true, force: true }); if (agents.length === 0) { console.error("[eval] no agent is ready"); return 1; @@ -313,14 +338,15 @@ async function main(argv) { } console.log( - `[eval] ${agents.length} agent(s) x ${tracks.length} track(s) x ${tasks.length} task(s) = ${agents.length * tracks.length * tasks.length} run(s)\n`, + `[eval] run ${options.run} — ${agents.length} agent(s) x ${tracks.length} track(s) x ${tasks.length} task(s) = ${agents.length * tracks.length * tasks.length} run(s)\n`, ); const candidates = []; + const created = new Set(); let skipped = 0; for (const name of agents) { - for (const track of tracks) { + for (const track of orderTracks(tracks)) { for (const task of tasks) { process.stdout.write(`[eval] ${name} / ${track} / ${task.id} ... `); const existingDir = workspaceDir(options.run, track, name, task); @@ -336,6 +362,7 @@ async function main(argv) { } const dir = reusing ? existingDir : makeWorkspace(options.run, track, name, task); + if (!reusing) created.add(dir); const run = reusing ? { ok: true, ms: 0, output: "reused existing workspace" } : runAgent(name, track, dir, task, options.timeout); @@ -462,8 +489,11 @@ async function main(argv) { // Remove only what THIS run created. Wiping the shared root also deleted // workspaces another run had deliberately kept, which lost evidence that // had already cost real agent invocations to produce. - for (const candidate of candidates) { - rmSync(candidate.dir, { recursive: true, force: true }); + // Only directories this invocation created. Reused inputs belong to an + // earlier run that was explicitly kept; deleting them destroyed the very + // evidence the reuse flow exists to re-grade. + for (const dir of created) { + rmSync(dir, { recursive: true, force: true }); } } else { console.log(`[eval] workspaces kept in ${join(workRoot, options.run)}`); @@ -476,6 +506,13 @@ async function main(argv) { return 1; } + if (skipped > 0 && !options.allowMissing) { + console.error( + `[eval] ${skipped} requested case(s) had nothing to reuse — pass --allow-missing to tolerate that`, + ); + return 1; + } + const allPassed = rows.every((r) => r.passed === r.total); // Only the reference solution is expected to be perfect; a real agent // scoring below 100% is a finding, not a harness failure. From c04ff655064a447892589f71701f76ae83ca7204 Mon Sep 17 00:00:00 2001 From: Juan Cruz Fortunatti Date: Fri, 14 Aug 2026 23:53:55 +0200 Subject: [PATCH 29/37] refactor(shadow): rename the shared lift constant to POLY_DEFAULT_SHADOW_LIFT --- packages/core/src/index.ts | 2 +- packages/core/src/shadow/computeReceiverShadows.ts | 2 +- packages/core/src/shadow/mergedReceiverShadows.test.ts | 8 ++++---- packages/polycss/src/api/createPolyScene.ts | 4 ++-- packages/polycss/src/api/scene/equality.ts | 4 ++-- packages/polycss/src/api/scene/receiverShadow.ts | 4 ++-- packages/react/src/index.ts | 2 +- packages/react/src/scene/PolyMesh.tsx | 4 ++-- packages/react/src/scene/PolyScene.tsx | 4 ++-- packages/vue/src/index.ts | 2 +- packages/vue/src/scene/PolyMesh.ts | 4 ++-- packages/vue/src/scene/PolyScene.ts | 4 ++-- 12 files changed, 22 insertions(+), 22 deletions(-) diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index a16c87be0..794439bb5 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -227,7 +227,7 @@ export { prepareCasterEdgeOwners, prepareCasterPolyItems, prepareReceiverFacePlanes, - DEFAULT_SHADOW_LIFT, + POLY_DEFAULT_SHADOW_LIFT, } from "./shadow/computeReceiverShadows"; export type { CasterPolyItem, diff --git a/packages/core/src/shadow/computeReceiverShadows.ts b/packages/core/src/shadow/computeReceiverShadows.ts index 413fc6d42..40fcdf3be 100644 --- a/packages/core/src/shadow/computeReceiverShadows.ts +++ b/packages/core/src/shadow/computeReceiverShadows.ts @@ -348,7 +348,7 @@ export function prepareCasterPolyItems( * limitation is documented for callers; fixing it needs projection-aware * clearance. */ -export const DEFAULT_SHADOW_LIFT = 0.05; +export const POLY_DEFAULT_SHADOW_LIFT = 0.05; /** * Build ReceiverFacePlane[] for a receiver mesh. Pure: groups coplanar diff --git a/packages/core/src/shadow/mergedReceiverShadows.test.ts b/packages/core/src/shadow/mergedReceiverShadows.test.ts index 1b26145f4..22a35c874 100644 --- a/packages/core/src/shadow/mergedReceiverShadows.test.ts +++ b/packages/core/src/shadow/mergedReceiverShadows.test.ts @@ -2,7 +2,7 @@ import { describe, it, expect } from "vitest"; import { computeMergedReceiverShadows, prepareCasterPolyItems, - DEFAULT_SHADOW_LIFT, + POLY_DEFAULT_SHADOW_LIFT, prepareReceiverFacePlanes, } from "./computeReceiverShadows"; import { worldPositionToCss } from "./receiverFaceGroups"; @@ -107,18 +107,18 @@ describe("computeMergedReceiverShadows", () => { }); }); -describe("DEFAULT_SHADOW_LIFT", () => { +describe("POLY_DEFAULT_SHADOW_LIFT", () => { it("matches the documented default", () => { // Regression guard. This default was written out at seven call sites and // two drifted to 0.001, which is too small to clear the receiver — cast // shadows emitted paths that darkened nothing in all three renderers. - expect(DEFAULT_SHADOW_LIFT).toBe(0.05); + expect(POLY_DEFAULT_SHADOW_LIFT).toBe(0.05); }); it("offsets the receiver plane away from the surface", () => { const [flat] = prepareReceiverFacePlanes([floor], [0, 0, 0], 1, new Set(), 0, null); const [lifted] = prepareReceiverFacePlanes( - [floor], [0, 0, 0], 1, new Set(), DEFAULT_SHADOW_LIFT, null, + [floor], [0, 0, 0], 1, new Set(), POLY_DEFAULT_SHADOW_LIFT, null, ); expect(flat.lift).toBe(0); expect(lifted.lift).toBeGreaterThan(0); diff --git a/packages/polycss/src/api/createPolyScene.ts b/packages/polycss/src/api/createPolyScene.ts index 85203005f..9b10efeb6 100644 --- a/packages/polycss/src/api/createPolyScene.ts +++ b/packages/polycss/src/api/createPolyScene.ts @@ -49,7 +49,7 @@ import { optimizeMeshPolygons, parseHexColor, polygonCssSurfaceNormal, - DEFAULT_SHADOW_LIFT, + POLY_DEFAULT_SHADOW_LIFT, } from "@layoutit/polycss-core"; import { cssBorderShapeForPlan, @@ -1551,7 +1551,7 @@ export function createPolyScene( } return; } - const lift = currentOptions.shadow?.lift ?? DEFAULT_SHADOW_LIFT; + const lift = currentOptions.shadow?.lift ?? POLY_DEFAULT_SHADOW_LIFT; // World Z → CSS Z: the ground plane in CSS-Z coordinates. Lift is added // (not subtracted) so the shadow plane sits slightly *above* the model // bbox floor — putting it on top of a receiver mesh placed at minZ diff --git a/packages/polycss/src/api/scene/equality.ts b/packages/polycss/src/api/scene/equality.ts index 48872dae0..4b5e73dea 100644 --- a/packages/polycss/src/api/scene/equality.ts +++ b/packages/polycss/src/api/scene/equality.ts @@ -7,7 +7,7 @@ import type { Vec3 } from "@layoutit/polycss-core"; import type { PolyRenderStrategiesOption } from "../../render/textureAtlas"; import type { PolySceneOptions } from "./types"; -import { DEFAULT_SHADOW_LIFT } from "@layoutit/polycss-core"; +import { POLY_DEFAULT_SHADOW_LIFT } from "@layoutit/polycss-core"; export function strategiesEqual( a: PolyRenderStrategiesOption | undefined, @@ -34,7 +34,7 @@ export function shadowOptsEqual( return ( (a?.color ?? "#000000") === (b?.color ?? "#000000") && (a?.opacity ?? 0.25) === (b?.opacity ?? 0.25) - && (a?.lift ?? DEFAULT_SHADOW_LIFT) === (b?.lift ?? DEFAULT_SHADOW_LIFT) + && (a?.lift ?? POLY_DEFAULT_SHADOW_LIFT) === (b?.lift ?? POLY_DEFAULT_SHADOW_LIFT) && (a?.maxExtend ?? 2000) === (b?.maxExtend ?? 2000) && (a?.parametric ?? false) === (b?.parametric ?? false) && (a?.definition ?? 16) === (b?.definition ?? 16) diff --git a/packages/polycss/src/api/scene/receiverShadow.ts b/packages/polycss/src/api/scene/receiverShadow.ts index 703ea03ec..e5254d797 100644 --- a/packages/polycss/src/api/scene/receiverShadow.ts +++ b/packages/polycss/src/api/scene/receiverShadow.ts @@ -22,7 +22,7 @@ import { type ReceiverCasterInput, type ReceiverFacePlane, type Vec3, - DEFAULT_SHADOW_LIFT, + POLY_DEFAULT_SHADOW_LIFT, } from "@layoutit/polycss-core"; import { ensureShadowRoot } from "./shadowSvg"; import { meshShadowId } from "./shadowCache"; @@ -141,7 +141,7 @@ export function emitReceiverShadows( const hasTexture = receiverEntry.polygons.some((p) => p.texture !== undefined); const receiverScale = meshScaleVec3(receiverEntry.handle.transform.scale); const rbboxCss = receiverEntry.bboxCenterCss; - const cacheShadowLift = options.shadow?.lift ?? DEFAULT_SHADOW_LIFT; + const cacheShadowLift = options.shadow?.lift ?? POLY_DEFAULT_SHADOW_LIFT; const cacheKey = `${receiverEntry.polygons.length}|${receiverDedupDrop.size}|${rpos.join(",")}|${rrot.join(",")}|${receiverScale.join(",")}|${rbboxCss ? rbboxCss.join(",") : "n"}|${cacheShadowLift}`; let cachedPlanes = receiverShadowCache.get(receiverEntry) as ReceiverFacePlane[] | undefined; if (cachedPlanes === undefined || receiverShadowCacheKey.get(receiverEntry) !== cacheKey) { diff --git a/packages/react/src/index.ts b/packages/react/src/index.ts index e0948b3f9..5401284e3 100644 --- a/packages/react/src/index.ts +++ b/packages/react/src/index.ts @@ -285,7 +285,7 @@ export { BASE_TILE, DEFAULT_CAMERA_STATE, DEFAULT_PROJECTION, - DEFAULT_SHADOW_LIFT, + POLY_DEFAULT_SHADOW_LIFT, normalizeInvertMultiplier, buildPolyCameraSceneTransform, capturePolyCameraSnapshot, diff --git a/packages/react/src/scene/PolyMesh.tsx b/packages/react/src/scene/PolyMesh.tsx index 68c1b10b1..60f445f01 100644 --- a/packages/react/src/scene/PolyMesh.tsx +++ b/packages/react/src/scene/PolyMesh.tsx @@ -59,7 +59,7 @@ import { type CameraCullRotation, type EdgeOwners, type ReceiverCasterInput, - DEFAULT_SHADOW_LIFT, + POLY_DEFAULT_SHADOW_LIFT, } from "@layoutit/polycss-core"; import type { TransformProps } from "../shapes/types"; import { usePolyMesh, type UseMeshOptions } from "./useMesh"; @@ -1028,7 +1028,7 @@ export const PolyMesh = forwardRef(function PolyM const runDirectionalShadow = !!sceneDirectionalLight?.direction && (sceneDirectionalLight.intensity ?? 1) > 0; const hasShadowPoints = shadowPointIndices.length > 0; - const shadowLift = sceneShadow?.lift ?? DEFAULT_SHADOW_LIFT; + const shadowLift = sceneShadow?.lift ?? POLY_DEFAULT_SHADOW_LIFT; const planes = prepareReceiverFacePlanes( polygons, position ?? [0, 0, 0], diff --git a/packages/react/src/scene/PolyScene.tsx b/packages/react/src/scene/PolyScene.tsx index d7d7caa0d..3df19fbc7 100644 --- a/packages/react/src/scene/PolyScene.tsx +++ b/packages/react/src/scene/PolyScene.tsx @@ -17,7 +17,7 @@ import { parseHexColor, resolvePolyTextureLeafGeometry, worldDirectionToCss, - DEFAULT_SHADOW_LIFT, + POLY_DEFAULT_SHADOW_LIFT, } from "@layoutit/polycss-core"; import type { ShadowCasterRegistration, ShadowOptions } from "./sceneContext"; import { useCameraContext } from "../camera/context"; @@ -378,7 +378,7 @@ function PolySceneInner({ } } if (!Number.isFinite(minWorldZ)) return null; - const lift = shadow?.lift ?? DEFAULT_SHADOW_LIFT; + const lift = shadow?.lift ?? POLY_DEFAULT_SHADOW_LIFT; return (minWorldZ + lift) * BASE_TILE; }, [shadow]); diff --git a/packages/vue/src/index.ts b/packages/vue/src/index.ts index 06fc78e19..85bd50cb4 100644 --- a/packages/vue/src/index.ts +++ b/packages/vue/src/index.ts @@ -276,7 +276,7 @@ export { BASE_TILE, DEFAULT_CAMERA_STATE, DEFAULT_PROJECTION, - DEFAULT_SHADOW_LIFT, + POLY_DEFAULT_SHADOW_LIFT, normalizeInvertMultiplier, buildPolyCameraSceneTransform, capturePolyCameraSnapshot, diff --git a/packages/vue/src/scene/PolyMesh.ts b/packages/vue/src/scene/PolyMesh.ts index f926f6a27..43c973460 100644 --- a/packages/vue/src/scene/PolyMesh.ts +++ b/packages/vue/src/scene/PolyMesh.ts @@ -34,7 +34,7 @@ import { cornerShapeGeometryForPlan, resolvePolyTextureLeafGeometry, worldDirectionalLightToCss, - DEFAULT_SHADOW_LIFT, + POLY_DEFAULT_SHADOW_LIFT, } from "@layoutit/polycss-core"; import { BASE_TILE, @@ -683,7 +683,7 @@ export const PolyMesh = defineComponent({ const runDirectionalShadow = !!ctx?.directionalLight?.direction && (ctx.directionalLight.intensity ?? 1) > 0; const hasShadowPoints = shadowPointIndices.length > 0; - const shadowLift = ctx?.shadow?.lift ?? DEFAULT_SHADOW_LIFT; + const shadowLift = ctx?.shadow?.lift ?? POLY_DEFAULT_SHADOW_LIFT; const planes = prepareReceiverFacePlanes( polygons.value, props.position ?? [0, 0, 0], diff --git a/packages/vue/src/scene/PolyScene.ts b/packages/vue/src/scene/PolyScene.ts index dac6a1453..fdf89d1a8 100644 --- a/packages/vue/src/scene/PolyScene.ts +++ b/packages/vue/src/scene/PolyScene.ts @@ -37,7 +37,7 @@ import { parseHexColor, resolvePolyTextureLeafGeometry, worldDirectionToCss, - DEFAULT_SHADOW_LIFT, + POLY_DEFAULT_SHADOW_LIFT, } from "@layoutit/polycss-core"; import { PolyCameraContextKey } from "../camera"; import { usePolySceneContext } from "./useSceneContext"; @@ -450,7 +450,7 @@ export const PolyScene = defineComponent({ if (groundCssZ.value !== null) groundCssZ.value = null; return; } - const lift = props.shadow?.lift ?? DEFAULT_SHADOW_LIFT; + const lift = props.shadow?.lift ?? POLY_DEFAULT_SHADOW_LIFT; const next = (minWorldZ + lift) * DEFAULT_TILE; if (groundCssZ.value !== next) groundCssZ.value = next; if (!el) return; From aa5992f1e271b501f674ddff89bf63ca0ab49fe7 Mon Sep 17 00:00:00 2001 From: Juan Cruz Fortunatti Date: Fri, 14 Aug 2026 23:53:55 +0200 Subject: [PATCH 30/37] docs(skills): scope gizmo behaviour by renderer and state both ambient workarounds --- packages/skills/skill/docs/controls-and-interaction.md | 6 +++++- packages/skills/skill/docs/lighting.md | 8 ++++++-- website/public/skill/docs/controls-and-interaction.md | 6 +++++- website/public/skill/docs/lighting.md | 8 ++++++-- 4 files changed, 22 insertions(+), 6 deletions(-) diff --git a/packages/skills/skill/docs/controls-and-interaction.md b/packages/skills/skill/docs/controls-and-interaction.md index 2d9223020..58c9399da 100644 --- a/packages/skills/skill/docs/controls-and-interaction.md +++ b/packages/skills/skill/docs/controls-and-interaction.md @@ -130,7 +130,11 @@ Raycasting runs on pointer events only — never per frame. ## Transform controls Translate mode gives axis arrows and plane handles; rotate mode gives axis -rings. Dragging updates the attached mesh directly. +rings. **In vanilla** dragging updates the attached mesh directly, because +`createTransformControls` calls `handle.setTransform(...)` itself. **In React +and Vue** dragging updates nothing on its own — the gizmo emits +`onObjectChange` and your state update is what moves the mesh (and the gizmo +with it). Key props: `object`, `mode`, `size`, `showX`, `showY`, `showZ`, `translationSnap`, `rotationSnap`, `enabled`, `onChange`, `onObjectChange`, diff --git a/packages/skills/skill/docs/lighting.md b/packages/skills/skill/docs/lighting.md index 809b358f9..49b2ac8ec 100644 --- a/packages/skills/skill/docs/lighting.md +++ b/packages/skills/skill/docs/lighting.md @@ -56,8 +56,12 @@ baseline. - **Vanilla `setOptions({ ambientLight })` changes nothing on its own.** There is no ambient branch in the change detection at all, so the baked surface stays stale *and* the shadow fill — which is derived from ambient — is not - re-emitted. Change the directional light in the same call, or call - `mesh.rebakeAtlas()`, to make an ambient edit take effect. + re-emitted. Two outputs are stale and **neither workaround fixes both**: + `mesh.rebakeAtlas()` refreshes that mesh's baked paint but does not re-emit + the receiver shadow, while touching the directional light in the same + `setOptions` call re-emits the shadow but leaves baked mesh colors frozen. To + fully apply an ambient edit, do both — nudge the directional light *and* + rebake every affected mesh. So the vanilla freeze covers the directional **and** ambient lights, not the directional light alone. diff --git a/website/public/skill/docs/controls-and-interaction.md b/website/public/skill/docs/controls-and-interaction.md index 2d9223020..58c9399da 100644 --- a/website/public/skill/docs/controls-and-interaction.md +++ b/website/public/skill/docs/controls-and-interaction.md @@ -130,7 +130,11 @@ Raycasting runs on pointer events only — never per frame. ## Transform controls Translate mode gives axis arrows and plane handles; rotate mode gives axis -rings. Dragging updates the attached mesh directly. +rings. **In vanilla** dragging updates the attached mesh directly, because +`createTransformControls` calls `handle.setTransform(...)` itself. **In React +and Vue** dragging updates nothing on its own — the gizmo emits +`onObjectChange` and your state update is what moves the mesh (and the gizmo +with it). Key props: `object`, `mode`, `size`, `showX`, `showY`, `showZ`, `translationSnap`, `rotationSnap`, `enabled`, `onChange`, `onObjectChange`, diff --git a/website/public/skill/docs/lighting.md b/website/public/skill/docs/lighting.md index 809b358f9..49b2ac8ec 100644 --- a/website/public/skill/docs/lighting.md +++ b/website/public/skill/docs/lighting.md @@ -56,8 +56,12 @@ baseline. - **Vanilla `setOptions({ ambientLight })` changes nothing on its own.** There is no ambient branch in the change detection at all, so the baked surface stays stale *and* the shadow fill — which is derived from ambient — is not - re-emitted. Change the directional light in the same call, or call - `mesh.rebakeAtlas()`, to make an ambient edit take effect. + re-emitted. Two outputs are stale and **neither workaround fixes both**: + `mesh.rebakeAtlas()` refreshes that mesh's baked paint but does not re-emit + the receiver shadow, while touching the directional light in the same + `setOptions` call re-emits the shadow but leaves baked mesh colors frozen. To + fully apply an ambient edit, do both — nudge the directional light *and* + rebake every affected mesh. So the vanilla freeze covers the directional **and** ambient lights, not the directional light alone. From 2a5b4080723b13af8f0081356063cd213b6d8027 Mon Sep 17 00:00:00 2001 From: Juan Cruz Fortunatti Date: Sun, 16 Aug 2026 06:09:16 +0200 Subject: [PATCH 31/37] fix(eval): send identical prompts to both PolyCSS tracks and run controls before interventions --- eval/skill/run.mjs | 66 +++++++++++++++++++++++++++++++++++++++++----- 1 file changed, 59 insertions(+), 7 deletions(-) diff --git a/eval/skill/run.mjs b/eval/skill/run.mjs index 365ef067b..53d121114 100644 --- a/eval/skill/run.mjs +++ b/eval/skill/run.mjs @@ -203,10 +203,11 @@ function runAgent(name, track, dir, task, timeoutSeconds) { return { ok: true, ms: 0, output: "reference solution copied" }; } - const preamble = TRACKS[track].installSkill - ? "Read TASK.md in this directory and complete it. You have the PolyCSS skill installed in this workspace — use it." - : "Read TASK.md in this directory and complete it."; - const prompt = `${preamble}\n\n${taskPrompt(track, task)}`; + // IDENTICAL on every track. The intervention used to carry an extra "you + // have the skill installed — use it" cue, which measured the files plus an + // activation prompt rather than the skill. Whether the agent discovers the + // project-local skill IS the intervention; telling it to is not. + const prompt = `Read TASK.md in this directory and complete it.\n\n${taskPrompt(track, task)}`; const started = Date.now(); try { const output = execFileSync(agent.bin, agent.argv(prompt, dir), { @@ -266,6 +267,24 @@ function preflight(name, runId, timeoutSeconds = 90) { : { ok: false, reason: "probe returned but wrote no file" }; } +/** Best-effort CLI version string, so a result can be tied to what produced it. */ +function agentVersion(name) { + const agent = AGENTS[name]; + if (!agent?.bin) return null; + try { + return execFileSync(agent.bin, ["--version"], { + encoding: "utf8", + timeout: 20000, + killSignal: "SIGKILL", + stdio: ["ignore", "pipe", "ignore"], + }) + .trim() + .split("\n")[0]; + } catch { + return null; + } +} + const bar = (passed, total) => { const filled = total === 0 ? 0 : Math.round((passed / total) * 10); return `${"#".repeat(filled)}${".".repeat(10 - filled)}`; @@ -341,12 +360,17 @@ async function main(argv) { `[eval] run ${options.run} — ${agents.length} agent(s) x ${tracks.length} track(s) x ${tasks.length} task(s) = ${agents.length * tracks.length * tasks.length} run(s)\n`, ); + const startedAt = new Date(); const candidates = []; const created = new Set(); let skipped = 0; - for (const name of agents) { - for (const track of orderTracks(tracks)) { + // Track is the OUTER loop. With agents outermost, ordering tracks only + // isolated the FIRST agent: once Claude's with-skill workspaces existed, + // Codex's control could read them at + // ../../../track-polycss/claude//.agents/skills/polycss/SKILL.md. + for (const track of orderTracks(tracks)) { + for (const name of agents) { for (const task of tasks) { process.stdout.write(`[eval] ${name} / ${track} / ${task.id} ... `); const existingDir = workspaceDir(options.run, track, name, task); @@ -383,6 +407,7 @@ async function main(argv) { ); candidates.push({ + order: candidates.length, key: `${name}__${track}__${task.id}`, agent: name, trackName: track, @@ -425,6 +450,7 @@ async function main(argv) { })); const passed = checks.filter((c) => c.pass).length; return { + order: candidate.order, agent: candidate.agent, track: candidate.trackName, task: candidate.task.id, @@ -482,7 +508,33 @@ async function main(argv) { } if (options.json) { - writeFileSync(resolve(options.json), `${JSON.stringify({ rows }, null, 2)}\n`); + // Rows alone cannot be audited. Record what produced them. + const provenance = { + runId: options.run, + commit: (() => { + try { + return execFileSync("git", ["rev-parse", "HEAD"], { + cwd: repoRoot, + encoding: "utf8", + }).trim(); + } catch { + return null; + } + })(), + startedAt: startedAt.toISOString(), + finishedAt: new Date().toISOString(), + agents, + tracks, + tasks: tasks.map((t) => t.id), + timeoutSeconds: options.timeout, + settleMs: options.settle, + reuse: options.reuse, + trackOrder: orderTracks(tracks), + agentVersions: Object.fromEntries( + agents.map((name) => [name, agentVersion(name)]), + ), + }; + writeFileSync(resolve(options.json), `${JSON.stringify({ provenance, rows }, null, 2)}\n`); console.log(`[eval] wrote ${options.json}`); } if (!options.keep) { From 54d2ce535d0cb9fdd5b587dc39174fa392d6ce24 Mon Sep 17 00:00:00 2001 From: Juan Cruz Fortunatti Date: Sun, 16 Aug 2026 06:09:16 +0200 Subject: [PATCH 32/37] docs(eval): record the fixed track order as a timing confound --- eval/skill/README.md | 18 ++++++++++++++---- 1 file changed, 14 insertions(+), 4 deletions(-) diff --git a/eval/skill/README.md b/eval/skill/README.md index d3baed1e2..1aa021716 100644 --- a/eval/skill/README.md +++ b/eval/skill/README.md @@ -10,7 +10,7 @@ Chromium** — not what the agent said it did. ```bash pnpm eval:skill --agent oracle --track all # reference solutions (must be 100%) pnpm eval:skill --agent claude --track polycss,polycss-noskill # the real control -pnpm eval:skill --agent claude --track all # one agent, both tracks, all tasks +pnpm eval:skill --agent claude --track all # one agent, all three tracks pnpm eval:skill --agent all --track all --keep --json out.json pnpm eval:skill --task 03-cube-with-shadow --agent claude,codex pnpm eval:skill --agent claude --reuse --run r1 # re-grade only; never calls an agent @@ -40,6 +40,13 @@ For each (agent, task) pair: | `polycss-noskill` | The **control**. Same library, task and contract; skill withheld. `polycss` minus this is the skill's effect. | | `three` | An external **baseline**, not a control. | +Track order is fixed: skill-less tracks always run first, so no control ever +executes while an installed skill exists on disk. That buys isolation at the +cost of confounding *timing* — every control runs before its intervention, so +cache warming, rate limits and provider variation are inseparable from any +duration difference. Treat wall-clock deltas as a fixed-order observation, not +an effect of the skill. Only the per-check scores are comparable. + The Three.js track changes the library, its API and its authoring contract at the same time as it removes the skill, so a polycss-vs-three delta conflates the skill with how familiar and how costly each library is for that model. It still @@ -143,9 +150,12 @@ failed to say something, said it somewhere the agent did not look, or the agent ignored it. The first two are fixable in `packages/skills/skill/`. Use `--keep` to leave the workspaces under `$TMPDIR/polycss-skill-eval/` -and read what the agent actually wrote. Runs are namespaced by `--run ` -(default `current`) so a later run cannot wipe evidence you kept, and -concurrent runs do not collide. +and read what the agent actually wrote. Runs are namespaced by `--run ` so a later +run cannot wipe evidence you kept, and concurrent runs do not collide. + +Run ids are random by default (`run-a1b2c3d4`) and printed at the top of the +run, so nothing can overwrite anything; `--reuse` therefore requires an explicit +`--run `. `--reuse` re-grades a kept run and **never invokes an agent**: a case with no `scene.mjs` is skipped and reported, not re-run. That is how to iterate on a From 2d9350cbdb42b88c68397f4471b2812f0c8eb448 Mon Sep 17 00:00:00 2001 From: Juan Cruz Fortunatti Date: Sun, 16 Aug 2026 06:09:16 +0200 Subject: [PATCH 33/37] docs(skills): correct the install boundary policy and drop the false naming rule --- packages/skills/README.md | 9 ++++++--- packages/skills/skill/SKILL.md | 11 +++++++++-- website/public/skill.md | 11 +++++++++-- website/public/skill/SKILL.md | 11 +++++++++-- 4 files changed, 33 insertions(+), 9 deletions(-) diff --git a/packages/skills/README.md b/packages/skills/README.md index 0f62a01e6..395924d2d 100644 --- a/packages/skills/README.md +++ b/packages/skills/README.md @@ -82,9 +82,12 @@ as plain relative paths, and a symlink anywhere *inside* the destination — a managed file or one of its parent directories — is refused rather than written through, so nothing outside can be created or deleted. -The destination root itself is followed if it is a symlink, because a shared -skills directory is a reasonable layout. If you do not want that, pass an -explicit `--dir`. +An auto-detected destination is confined to your project: if `.claude/skills` +(or any parent) is a symlink pointing outside it, the install is refused rather +than followed, so a repository cannot redirect it somewhere else. + +Installing outside the project is opt-in — pass `--dir ` or `--global` +and that boundary is lifted, because you named the destination yourself. ## Fetching instead of installing diff --git a/packages/skills/skill/SKILL.md b/packages/skills/skill/SKILL.md index f9b9d6386..5ee6d34d8 100644 --- a/packages/skills/skill/SKILL.md +++ b/packages/skills/skill/SKILL.md @@ -98,8 +98,15 @@ import { `createPolyPerspectiveCamera` for depth foreshortening. - The camera is the **outer** node; the scene nests inside it. CSS `perspective` only applies to descendants. -- Every public export is `Poly`-prefixed, except generic math types (`Vec2`, - `Vec3`, `Polygon`, `PolyMaterial`) and the `*/three` subpaths. +- **Do not infer names from a prefix rule.** `Poly` prefixing is a convention + for newer renderer-facing components, hooks and types — not a description of + the export inventory. Plenty of public names have no prefix: `loadMesh`, + `parseObj` / `parseStl` / `parseGltf` / `parseVox`, every `*Polygons` + generator, `BASE_TILE`, `LoopOnce` / `LoopRepeat` / `LoopPingPong`, the + generic math types (`Vec2`, `Vec3`, `Polygon`), and the vanilla factories + `createSelect` and `createTransformControls`. The `*/three` subpaths use + Three-compatible names deliberately. Check + [docs/api-index.md](docs/api-index.md) rather than guessing. ## Authoring polygons — the five silent failures diff --git a/website/public/skill.md b/website/public/skill.md index 31547532f..c9dce353b 100644 --- a/website/public/skill.md +++ b/website/public/skill.md @@ -98,8 +98,15 @@ import { `createPolyPerspectiveCamera` for depth foreshortening. - The camera is the **outer** node; the scene nests inside it. CSS `perspective` only applies to descendants. -- Every public export is `Poly`-prefixed, except generic math types (`Vec2`, - `Vec3`, `Polygon`, `PolyMaterial`) and the `*/three` subpaths. +- **Do not infer names from a prefix rule.** `Poly` prefixing is a convention + for newer renderer-facing components, hooks and types — not a description of + the export inventory. Plenty of public names have no prefix: `loadMesh`, + `parseObj` / `parseStl` / `parseGltf` / `parseVox`, every `*Polygons` + generator, `BASE_TILE`, `LoopOnce` / `LoopRepeat` / `LoopPingPong`, the + generic math types (`Vec2`, `Vec3`, `Polygon`), and the vanilla factories + `createSelect` and `createTransformControls`. The `*/three` subpaths use + Three-compatible names deliberately. Check + [docs/api-index.md](/skill/docs/api-index.md) rather than guessing. ## Authoring polygons — the five silent failures diff --git a/website/public/skill/SKILL.md b/website/public/skill/SKILL.md index f9b9d6386..5ee6d34d8 100644 --- a/website/public/skill/SKILL.md +++ b/website/public/skill/SKILL.md @@ -98,8 +98,15 @@ import { `createPolyPerspectiveCamera` for depth foreshortening. - The camera is the **outer** node; the scene nests inside it. CSS `perspective` only applies to descendants. -- Every public export is `Poly`-prefixed, except generic math types (`Vec2`, - `Vec3`, `Polygon`, `PolyMaterial`) and the `*/three` subpaths. +- **Do not infer names from a prefix rule.** `Poly` prefixing is a convention + for newer renderer-facing components, hooks and types — not a description of + the export inventory. Plenty of public names have no prefix: `loadMesh`, + `parseObj` / `parseStl` / `parseGltf` / `parseVox`, every `*Polygons` + generator, `BASE_TILE`, `LoopOnce` / `LoopRepeat` / `LoopPingPong`, the + generic math types (`Vec2`, `Vec3`, `Polygon`), and the vanilla factories + `createSelect` and `createTransformControls`. The `*/three` subpaths use + Three-compatible names deliberately. Check + [docs/api-index.md](docs/api-index.md) rather than guessing. ## Authoring polygons — the five silent failures From 935277c7de1f75064e0ad3e53d635c12e6ae2b95 Mon Sep 17 00:00:00 2001 From: Juan Cruz Fortunatti Date: Sun, 16 Aug 2026 17:38:36 +0200 Subject: [PATCH 34/37] fix(eval): isolate bundles per invocation and contain a failing candidate --- eval/skill/verify.mjs | 36 +++++++++++++++++++++++++++++++++--- 1 file changed, 33 insertions(+), 3 deletions(-) diff --git a/eval/skill/verify.mjs b/eval/skill/verify.mjs index a90b5a1b3..21eb51561 100644 --- a/eval/skill/verify.mjs +++ b/eval/skill/verify.mjs @@ -13,7 +13,9 @@ */ import { build } from "esbuild"; import { createServer } from "node:http"; +import { mkdtempSync, rmSync } from "node:fs"; import { readFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; import { extname, join, resolve } from "node:path"; import { fileURLToPath } from "node:url"; import { chromium } from "playwright"; @@ -127,7 +129,10 @@ async function probeScene(page, url, { settleMs = 1200, mountTimeoutMs = 15000 } } export async function verifyCandidates(candidates, { headed = false, settleMs } = {}) { - const bundleDir = resolve(here, ".generated"); + // Unique per invocation. A shared `.generated/____.js` + // meant two concurrent runs with the same key raced on one file and both + // graded whichever bundle won. + const bundleDir = mkdtempSync(join(tmpdir(), "polycss-skill-bundles-")); const built = []; for (const candidate of candidates) { @@ -151,7 +156,7 @@ export async function verifyCandidates(candidates, { headed = false, settleMs } }); try { - const page = await browser.newPage({ viewport: { width: 1000, height: 700 } }); + let page = await browser.newPage({ viewport: { width: 1000, height: 700 } }); const results = []; for (const candidate of built) { @@ -173,7 +178,31 @@ export async function verifyCandidates(candidates, { headed = false, settleMs } const url = `${server.origin}/harness/index.html?bundle=${encodeURIComponent( `/bundles/${candidate.key}.js`, )}`; - const probe = await probeScene(page, url, { settleMs }); + // A candidate is untrusted code. One that breaks the probe — or the page + // itself — used to reject the whole verifier, so every later candidate + // went ungraded. Failures become a failed row and the page is rebuilt. + let probe; + try { + probe = await probeScene(page, url, { settleMs }); + } catch (error) { + results.push({ + ...candidate, + probe: null, + checks: checksFor(candidate).map((check) => ({ + id: check.id, + describe: check.describe, + pass: false, + reason: `grading failed: ${String(error.message ?? error).split("\n")[0]}`, + })), + }); + try { + await page.close(); + } catch { + /* already gone */ + } + page = await browser.newPage({ viewport: { width: 1000, height: 700 } }); + continue; + } const ctx = { ...probe, source: candidate.source }; const checks = checksFor(candidate).map((check) => { @@ -198,5 +227,6 @@ export async function verifyCandidates(candidates, { headed = false, settleMs } } finally { await browser.close(); await server.close(); + rmSync(bundleDir, { recursive: true, force: true }); } } From 3bf8e08fed093a74442428a80b32555410c58652 Mon Sep 17 00:00:00 2001 From: Juan Cruz Fortunatti Date: Sun, 16 Aug 2026 17:38:36 +0200 Subject: [PATCH 35/37] fix(eval): refuse an existing run root, validate numeric flags, gate on the oracle, measure framing on the subject --- eval/skill/run.mjs | 39 ++++++++++++++++++++++++++++++++++++--- eval/skill/selftest.mjs | 23 +++++++++++++++++++++++ eval/skill/tasks.mjs | 17 ++++++++--------- 3 files changed, 67 insertions(+), 12 deletions(-) diff --git a/eval/skill/run.mjs b/eval/skill/run.mjs index 53d121114..41baf0be4 100644 --- a/eval/skill/run.mjs +++ b/eval/skill/run.mjs @@ -75,6 +75,7 @@ Options Required with --reuse, which needs a specific run to grade. --allow-missing With --reuse, tolerate requested cases that have no scene.mjs instead of failing. + --replace Clear an existing run root before a fresh run. --no-preflight Skip the per-agent readiness probe. --headed Run Chromium headed. --settle Delay between the two DOM samples (default 2000). @@ -86,6 +87,15 @@ Tasks ${TASK_IDS.map((id) => ` ${id}`).join("\n")} `; +/** `--timeout nope` used to become NaN, pass silently, and land as null in the JSON. */ +function positiveNumber(flag, raw) { + const value = Number(raw); + if (!Number.isFinite(value) || value <= 0) { + throw new Error(`${flag} needs a positive number, got ${JSON.stringify(raw)}`); + } + return value; +} + function parseArgs(argv) { const options = { agents: [], @@ -94,6 +104,7 @@ function parseArgs(argv) { keep: false, reuse: false, allowMissing: false, + replace: false, run: null, preflight: true, headed: false, @@ -116,11 +127,12 @@ function parseArgs(argv) { else if (arg === "--keep") options.keep = true; else if (arg === "--reuse") options.reuse = true; else if (arg === "--allow-missing") options.allowMissing = true; + else if (arg === "--replace") options.replace = true; else if (arg === "--run") options.run = value(); else if (arg === "--no-preflight") options.preflight = false; else if (arg === "--headed") options.headed = true; - else if (arg === "--settle") options.settle = Number(value()); - else if (arg === "--timeout") options.timeout = Number(value()); + else if (arg === "--settle") options.settle = positiveNumber(arg, value()); + else if (arg === "--timeout") options.timeout = positiveNumber(arg, value()); else if (arg === "--json") options.json = value(); else if (arg === "--list") options.list = true; else if (arg === "--help" || arg === "-h") return null; @@ -323,6 +335,17 @@ async function main(argv) { } if (options.run === null) options.run = `run-${randomBytes(4).toString("hex")}`; assertSafeRunId(options.run); + // Track-first ordering only isolates a CLEAN root. Naming an existing kept + // run leaves the previous track's installed skill readable by this run's + // control, so a fresh run refuses it unless the caller asks to replace it. + if (!options.reuse && existsSync(join(workRoot, options.run))) { + if (!options.replace) { + throw new Error( + `run "${options.run}" already exists — pass --replace to clear it, --reuse to grade it, or omit --run for a fresh id`, + ); + } + rmSync(join(workRoot, options.run), { recursive: true, force: true }); + } } catch (error) { console.error(error.message); return 1; @@ -565,10 +588,20 @@ async function main(argv) { return 1; } + // The oracle is the harness's own correctness gate, so it fails the run even + // when other agents are selected — a broken oracle beside a passing agent + // used to exit 0. + const oracleRows = rows.filter((r) => r.agent === "oracle"); + if (oracleRows.some((r) => r.passed !== r.total)) { + console.error("[eval] reference solutions regressed — the graders or the oracles are wrong"); + return 1; + } + const allPassed = rows.every((r) => r.passed === r.total); // Only the reference solution is expected to be perfect; a real agent // scoring below 100% is a finding, not a harness failure. - return agents.length === 1 && agents[0] === "oracle" && !allPassed ? 1 : 0; + // Real agents scoring below 100% is a finding, not a harness failure. + return allPassed || agents.some((a) => a !== "oracle") ? 0 : 1; } process.exitCode = await main(process.argv.slice(2)); diff --git a/eval/skill/selftest.mjs b/eval/skill/selftest.mjs index fee991a7b..2fe703af0 100644 --- a/eval/skill/selftest.mjs +++ b/eval/skill/selftest.mjs @@ -171,6 +171,29 @@ const MUTATIONS = [ mutate: (source) => patch(source, `shadow: { opacity: 0.35 }`, `shadow: { opacity: 0.35, lift: 0.001 }`), }, + { + // The framing check used to measure ALL painted pixels, so a tiny correct + // target passed whenever unrelated content filled the frame. + name: "a tiny subject beside a large distractor", + taskId: "01-static-cube", + expect: "scale", + mutate: (source) => + patch( + source, + `import { createPolyBox, createPolyCamera, createPolyScene } from "@layoutit/polycss";`, + `import { + createPolyBox, + createPolyCamera, + createPolyPlane, + createPolyScene, +} from "@layoutit/polycss";`, + ).replace( + ` scene.add(createPolyBox({ size: 100, color: "#ff8c1a" }));`, + ` // A big unrelated surface plus a speck of the requested colour. + scene.add(createPolyPlane({ axis: 2, size: 400, offset: 0, color: "#3355aa" })); + scene.add(createPolyBox({ size: 8, color: "#ff8c1a" }), { position: [0, 0, 30] });`, + ), + }, { name: "an export that does not exist fails the build", taskId: "01-static-cube", diff --git a/eval/skill/tasks.mjs b/eval/skill/tasks.mjs index 46be12dec..8a9b7be68 100644 --- a/eval/skill/tasks.mjs +++ b/eval/skill/tasks.mjs @@ -261,17 +261,16 @@ const framedWithin = (hex, minFraction, maxFraction) => ({ id: "scale", describe: `the subject fills between ${Math.round(minFraction * 100)}% and ${Math.round(maxFraction * 100)}% of the viewport`, run: ({ first }) => { - // Measured on the SUBJECT's own hue, not on "anything not background". - // A shape scaled past every edge becomes the background by definition — - // corner sampling would call the cube the page and report ~0% painted. - const covered = paintedFraction(first); + // Measured on the SUBJECT's own hue. Using total painted coverage let a + // tiny correctly-coloured target pass whenever unrelated content filled + // the viewport — and a shape scaled past every edge becomes the background + // by definition, so corner sampling cannot be trusted either. const subject = pixelsWithHue(first, hex).length / first.pixels.rgb.length; - if (subject < 0.02) return `almost none of the viewport is ${hex} (${(subject * 100).toFixed(1)}%)`; - if (covered < minFraction) { - return `the ${hex} subject fills only ${(covered * 100).toFixed(1)}% of the viewport - too small, or scaled so far past the edges that it fills the frame; raise or lower both the shape size and the camera zoom`; + if (subject < minFraction) { + return `the ${hex} subject fills only ${(subject * 100).toFixed(1)}% of the viewport - too small, or scaled so far past the edges that it fills the frame; adjust both the shape size and the camera zoom`; } - if (covered > maxFraction) { - return `the ${hex} subject fills ${(covered * 100).toFixed(1)}% of the viewport - overscaled and running past the edges; lower the shape size or the camera zoom`; + if (subject > maxFraction) { + return `the ${hex} subject fills ${(subject * 100).toFixed(1)}% of the viewport - overscaled and running past the edges; lower the shape size or the camera zoom`; } return true; }, From 5529c7a8bb23b167b5d93071cdaf18474b7fca12 Mon Sep 17 00:00:00 2001 From: Juan Cruz Fortunatti Date: Sun, 16 Aug 2026 17:38:36 +0200 Subject: [PATCH 36/37] docs(agents): describe the three evaluation tracks and which one is the control --- AGENTS.md | 28 ++++++++++++++++++++-------- eval/skill/README.md | 2 +- 2 files changed, 21 insertions(+), 9 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 277ffd4d9..e9d685b15 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -319,14 +319,26 @@ paints in Chromium** — never the agent's prose. Two design rules that are load-bearing: - **Workspaces live outside the repository** (`$TMPDIR`). Inside it, an agent - walks up out of its workspace and reads this monorepo — measured, on the - no-skill control track. -- **The `three` track is a control, never a reference.** Same model, same task, - no skill, separate workspace. The two tracks are graded independently against - the same visual criteria; PolyCSS output is never diffed against a Three.js - render. It exists to separate "the skill is inadequate" from "this task is - hard for this model", and it has repeatedly proved the grader wrong rather - than the agent. + walks up out of its workspace and reads this monorepo — measured, on a + no-skill track. +- **Three tracks, and only one is a control.** `polycss` is the intervention; + `polycss-noskill` is the CONTROL (same library, task, contract and prompt, + skill withheld), so `polycss` minus `polycss-noskill` is the skill's effect; + `three` is an EXTERNAL BASELINE, not a control — it swaps the library, API and + authoring contract at the same time as it removes the skill, so that delta + conflates the skill with library familiarity. It earns its place by answering + "is this task hard for this model at all?" and by calibrating the graders, and + it has repeatedly proved a grader wrong rather than an agent. +- **Both PolyCSS tracks get byte-identical prompts.** Whether the agent + discovers the project-local skill IS the intervention; telling it one exists + is not. An earlier version cued the intervention only, and measured the files + plus the cue. +- **Track is the outer loop and skill-less tracks run first**, so no control + executes while an installed skill exists on disk. Track order is therefore + fixed, which confounds wall-clock comparisons — treat timing as a fixed-order + observation, never as an effect of the skill. +- Tracks are graded independently and never diffed against each other; no + track's output is a reference image for another. Grade on painted pixels, not DOM boxes: every leaf except `` is `backface-visibility: hidden`, so a reversed face keeps its bounding rect while diff --git a/eval/skill/README.md b/eval/skill/README.md index 1aa021716..6a116e624 100644 --- a/eval/skill/README.md +++ b/eval/skill/README.md @@ -22,7 +22,7 @@ pnpm eval:selftest # prove the graders can fail For each (agent, task) pair: 1. **Isolate.** A fresh directory outside the repository gets - `npx polycss-skills --agent all` and a `TASK.md`, and nothing else. + `npx @layoutit/polycss-skills --agent all` and a `TASK.md`, and nothing else. 2. **Run the agent** non-interactively in that directory. The task asks for one file, `scene.mjs`, exporting `mount(host)`. 3. **Build** it with esbuild, aliasing `@layoutit/polycss` to workspace source. From 43114adb5e152870f08913de3a9fa13700820e51 Mon Sep 17 00:00:00 2001 From: Juan Cruz Fortunatti Date: Sun, 16 Aug 2026 17:38:36 +0200 Subject: [PATCH 37/37] docs(skills): fix runnable examples, ParseResult and hook shapes, and export scoping --- packages/skills/skill/docs/api-index.md | 3 +- .../skill/docs/controls-and-interaction.md | 8 +++-- packages/skills/skill/docs/loading-models.md | 30 +++++++++++++++---- .../skills/skill/docs/scenes-and-cameras.md | 2 +- .../skill/docs/shapes-and-primitives.md | 19 +++++++----- packages/skills/skill/docs/three-parity.md | 1 + website/public/skill/docs/api-index.md | 3 +- .../skill/docs/controls-and-interaction.md | 8 +++-- website/public/skill/docs/loading-models.md | 30 +++++++++++++++---- .../public/skill/docs/scenes-and-cameras.md | 2 +- .../skill/docs/shapes-and-primitives.md | 19 +++++++----- website/public/skill/docs/three-parity.md | 1 + 12 files changed, 90 insertions(+), 36 deletions(-) diff --git a/packages/skills/skill/docs/api-index.md b/packages/skills/skill/docs/api-index.md index 1b23241ba..ffd5fa3d6 100644 --- a/packages/skills/skill/docs/api-index.md +++ b/packages/skills/skill/docs/api-index.md @@ -109,7 +109,8 @@ React or Vue app import them from `@layoutit/polycss-core`. `worldDirectionToCss` / `worldDirectionToPolyCss`, `worldDirectionalLightToCss` / `worldDirectionalLightToPolyCss`. -Constant: `BASE_TILE` (50). +Constants: `BASE_TILE` (50) and `POLY_DEFAULT_SHADOW_LIFT` (0.05, the default +clearance between a receiver face and the shadow painted on it). ## Diagnostics and DOM helpers diff --git a/packages/skills/skill/docs/controls-and-interaction.md b/packages/skills/skill/docs/controls-and-interaction.md index 58c9399da..9c934429f 100644 --- a/packages/skills/skill/docs/controls-and-interaction.md +++ b/packages/skills/skill/docs/controls-and-interaction.md @@ -163,11 +163,13 @@ work — in every entry point. ``` ```js -// Vanilla custom elements +// Vanilla custom elements. `` children go inside the +// element itself, not a createPolyScene handle. +const sceneEl = document.querySelector("poly-scene"); const el = document.createElement("poly-polygon"); -el.setAttribute("vertices", JSON.stringify(p.vertices)); +el.setAttribute("vertices", JSON.stringify(polygon.vertices)); el.addEventListener("click", () => el.classList.toggle("selected")); -scene.appendChild(el); +sceneEl.appendChild(el); ``` Use `polygon.data` to attach `data-*` attributes for CSS selectors and event diff --git a/packages/skills/skill/docs/loading-models.md b/packages/skills/skill/docs/loading-models.md index 8261b8d52..8fd37adc4 100644 --- a/packages/skills/skill/docs/loading-models.md +++ b/packages/skills/skill/docs/loading-models.md @@ -46,14 +46,29 @@ scene.destroy(); // removes the scene and disposes registered meshes ```ts interface ParseResult { polygons: Polygon[]; - objectUrls: string[]; + objectUrls: string[]; // blob URLs minted during parse — do not mutate + warnings: string[]; // non-fatal parse warnings + dispose: () => void; // idempotent; revokes the object URLs + voxelSource?: PolyVoxelSource; // `.vox` fast-path marker + animation?: ParseAnimationController; // glTF/GLB clips, when present +} +``` + +React/Vue wrap this as `usePolyMesh(url, opts)`, which disposes on unmount and +returns more than the polygons: + +```ts +interface UseMeshResult { + polygons: Polygon[]; + voxelSource: ParseResult["voxelSource"]; + loading: boolean; + error: Error | null; warnings: string[]; - dispose: () => void; // revokes blob URLs + dispose: () => void; // also called for you on unmount } ``` -React/Vue wrap this as `usePolyMesh(url, opts)` → `{ polygons, loading, error }`, -which disposes on unmount. +Read `warnings` — parse problems surface there rather than throwing. Format-specific parsers are also exported directly: `parseObj`, `parseStl`, `parseGltf`, `parseVox`. Use them when you need the raw parser output without @@ -124,14 +139,19 @@ thresholds and the STL exception. ``` ```ts -// Vanilla: one mesh handle per polygon +// Vanilla: one mesh handle per polygon. const result = await loadMesh("/character.glb"); const handles = result.polygons.map((polygon, i) => scene.add( + // Each wrapper is a view onto `result`; it owns no URLs of its own. { polygons: [polygon], objectUrls: [], warnings: [], dispose: () => {} }, { id: `polygon-${i}`, merge: false }, ), ); + +// Keep `result` — the wrappers above have no-op disposers, so its object URLs +// are only released when you dispose the original. +// later: handles.forEach((h) => h.remove()); result.dispose(); ``` Merged polygons lose per-polygon DOM addressing, which is why `merge: false` diff --git a/packages/skills/skill/docs/scenes-and-cameras.md b/packages/skills/skill/docs/scenes-and-cameras.md index d187ef966..4cde54967 100644 --- a/packages/skills/skill/docs/scenes-and-cameras.md +++ b/packages/skills/skill/docs/scenes-and-cameras.md @@ -88,7 +88,7 @@ Prefer leaving it alone. | `meshResolution` | `"lossy" \| "lossless"` | Default `"lossy"`. | | `stableDom` | `boolean` | Vanilla only; needed for skeletal animation. | | `shadowDefinition` | `number` | Per-mesh parametric shadow detail. | -| `excludeFromAutoCenter` | `boolean` | **Vanilla only.** Keeps this mesh out of the scene's auto-center bbox — for helpers and debug overlays. | +| `excludeFromAutoCenter` | `boolean` | **Vanilla only, and a `scene.add(result, transform)` option** — there is no React/Vue prop. Keeps this mesh out of the scene's auto-center bbox, for helpers and debug overlays. | React/Vue additionally expose per-mesh `textureLighting`, `textureQuality`, `textureLeafSizing`, `textureImageRendering`, `textureBackend`, diff --git a/packages/skills/skill/docs/shapes-and-primitives.md b/packages/skills/skill/docs/shapes-and-primitives.md index b9adda268..b955a6e8d 100644 --- a/packages/skills/skill/docs/shapes-and-primitives.md +++ b/packages/skills/skill/docs/shapes-and-primitives.md @@ -139,8 +139,8 @@ The cap at 3 is deliberate — DOM cost. ### Other generators -`axesHelperPolygons`, `arrowPolygons`, and `ringQuadPolygons` (core and -`@layoutit/polycss` only — see the note at the top). +`axesHelperPolygons` and `arrowPolygons` (all packages), plus `ringQuadPolygons` +(core and `@layoutit/polycss` only — see the note at the top). ## Usage @@ -152,12 +152,15 @@ scene.add(createPolyTorus({ radius: 60, tube: 18, color: "#4ecdc4" })); ``` ```tsx -// React / Vue — geometry options plus the common mesh props - - - - - +// React / Vue — geometry options plus the common mesh props. +// PolyScene throws outside a camera component, so it is always wrapped. + + + + + + + ``` ```html diff --git a/packages/skills/skill/docs/three-parity.md b/packages/skills/skill/docs/three-parity.md index 88b34ddfe..319d4acd6 100644 --- a/packages/skills/skill/docs/three-parity.md +++ b/packages/skills/skill/docs/three-parity.md @@ -50,6 +50,7 @@ import { DirectionalLight, PointLight, AmbientLight, + boxPolygons, transformPolygonsToPoly, mountPolyThreeScene, } from "@layoutit/polycss/three"; diff --git a/website/public/skill/docs/api-index.md b/website/public/skill/docs/api-index.md index 1b23241ba..ffd5fa3d6 100644 --- a/website/public/skill/docs/api-index.md +++ b/website/public/skill/docs/api-index.md @@ -109,7 +109,8 @@ React or Vue app import them from `@layoutit/polycss-core`. `worldDirectionToCss` / `worldDirectionToPolyCss`, `worldDirectionalLightToCss` / `worldDirectionalLightToPolyCss`. -Constant: `BASE_TILE` (50). +Constants: `BASE_TILE` (50) and `POLY_DEFAULT_SHADOW_LIFT` (0.05, the default +clearance between a receiver face and the shadow painted on it). ## Diagnostics and DOM helpers diff --git a/website/public/skill/docs/controls-and-interaction.md b/website/public/skill/docs/controls-and-interaction.md index 58c9399da..9c934429f 100644 --- a/website/public/skill/docs/controls-and-interaction.md +++ b/website/public/skill/docs/controls-and-interaction.md @@ -163,11 +163,13 @@ work — in every entry point. ``` ```js -// Vanilla custom elements +// Vanilla custom elements. `` children go inside the +// element itself, not a createPolyScene handle. +const sceneEl = document.querySelector("poly-scene"); const el = document.createElement("poly-polygon"); -el.setAttribute("vertices", JSON.stringify(p.vertices)); +el.setAttribute("vertices", JSON.stringify(polygon.vertices)); el.addEventListener("click", () => el.classList.toggle("selected")); -scene.appendChild(el); +sceneEl.appendChild(el); ``` Use `polygon.data` to attach `data-*` attributes for CSS selectors and event diff --git a/website/public/skill/docs/loading-models.md b/website/public/skill/docs/loading-models.md index 8261b8d52..8fd37adc4 100644 --- a/website/public/skill/docs/loading-models.md +++ b/website/public/skill/docs/loading-models.md @@ -46,14 +46,29 @@ scene.destroy(); // removes the scene and disposes registered meshes ```ts interface ParseResult { polygons: Polygon[]; - objectUrls: string[]; + objectUrls: string[]; // blob URLs minted during parse — do not mutate + warnings: string[]; // non-fatal parse warnings + dispose: () => void; // idempotent; revokes the object URLs + voxelSource?: PolyVoxelSource; // `.vox` fast-path marker + animation?: ParseAnimationController; // glTF/GLB clips, when present +} +``` + +React/Vue wrap this as `usePolyMesh(url, opts)`, which disposes on unmount and +returns more than the polygons: + +```ts +interface UseMeshResult { + polygons: Polygon[]; + voxelSource: ParseResult["voxelSource"]; + loading: boolean; + error: Error | null; warnings: string[]; - dispose: () => void; // revokes blob URLs + dispose: () => void; // also called for you on unmount } ``` -React/Vue wrap this as `usePolyMesh(url, opts)` → `{ polygons, loading, error }`, -which disposes on unmount. +Read `warnings` — parse problems surface there rather than throwing. Format-specific parsers are also exported directly: `parseObj`, `parseStl`, `parseGltf`, `parseVox`. Use them when you need the raw parser output without @@ -124,14 +139,19 @@ thresholds and the STL exception. ``` ```ts -// Vanilla: one mesh handle per polygon +// Vanilla: one mesh handle per polygon. const result = await loadMesh("/character.glb"); const handles = result.polygons.map((polygon, i) => scene.add( + // Each wrapper is a view onto `result`; it owns no URLs of its own. { polygons: [polygon], objectUrls: [], warnings: [], dispose: () => {} }, { id: `polygon-${i}`, merge: false }, ), ); + +// Keep `result` — the wrappers above have no-op disposers, so its object URLs +// are only released when you dispose the original. +// later: handles.forEach((h) => h.remove()); result.dispose(); ``` Merged polygons lose per-polygon DOM addressing, which is why `merge: false` diff --git a/website/public/skill/docs/scenes-and-cameras.md b/website/public/skill/docs/scenes-and-cameras.md index d187ef966..4cde54967 100644 --- a/website/public/skill/docs/scenes-and-cameras.md +++ b/website/public/skill/docs/scenes-and-cameras.md @@ -88,7 +88,7 @@ Prefer leaving it alone. | `meshResolution` | `"lossy" \| "lossless"` | Default `"lossy"`. | | `stableDom` | `boolean` | Vanilla only; needed for skeletal animation. | | `shadowDefinition` | `number` | Per-mesh parametric shadow detail. | -| `excludeFromAutoCenter` | `boolean` | **Vanilla only.** Keeps this mesh out of the scene's auto-center bbox — for helpers and debug overlays. | +| `excludeFromAutoCenter` | `boolean` | **Vanilla only, and a `scene.add(result, transform)` option** — there is no React/Vue prop. Keeps this mesh out of the scene's auto-center bbox, for helpers and debug overlays. | React/Vue additionally expose per-mesh `textureLighting`, `textureQuality`, `textureLeafSizing`, `textureImageRendering`, `textureBackend`, diff --git a/website/public/skill/docs/shapes-and-primitives.md b/website/public/skill/docs/shapes-and-primitives.md index b9adda268..b955a6e8d 100644 --- a/website/public/skill/docs/shapes-and-primitives.md +++ b/website/public/skill/docs/shapes-and-primitives.md @@ -139,8 +139,8 @@ The cap at 3 is deliberate — DOM cost. ### Other generators -`axesHelperPolygons`, `arrowPolygons`, and `ringQuadPolygons` (core and -`@layoutit/polycss` only — see the note at the top). +`axesHelperPolygons` and `arrowPolygons` (all packages), plus `ringQuadPolygons` +(core and `@layoutit/polycss` only — see the note at the top). ## Usage @@ -152,12 +152,15 @@ scene.add(createPolyTorus({ radius: 60, tube: 18, color: "#4ecdc4" })); ``` ```tsx -// React / Vue — geometry options plus the common mesh props - - - - - +// React / Vue — geometry options plus the common mesh props. +// PolyScene throws outside a camera component, so it is always wrapped. + + + + + + + ``` ```html diff --git a/website/public/skill/docs/three-parity.md b/website/public/skill/docs/three-parity.md index 88b34ddfe..319d4acd6 100644 --- a/website/public/skill/docs/three-parity.md +++ b/website/public/skill/docs/three-parity.md @@ -50,6 +50,7 @@ import { DirectionalLight, PointLight, AmbientLight, + boxPolygons, transformPolygonsToPoly, mountPolyThreeScene, } from "@layoutit/polycss/three";