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/.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/AGENTS.md b/AGENTS.md index 7844a6853..277ffd4d9 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,62 @@ 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. + +### 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. 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/eval/skill/README.md b/eval/skill/README.md new file mode 100644 index 000000000..1aa021716 --- /dev/null +++ b/eval/skill/README.md @@ -0,0 +1,165 @@ +# 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 --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, 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 +pnpm eval:selftest # prove the graders can fail +``` + +## How a run works + +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. +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, ~2s apart, so motion + 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. | + +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 +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 +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 +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, costing a whole painted region. | +| `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`, 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 + +`--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 under `$TMPDIR/polycss-skill-eval/` +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 +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/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..c9f44dee3 --- /dev/null +++ b/eval/skill/harness/index.html @@ -0,0 +1,126 @@ + + + + + PolyCSS skill eval harness + + + +
+ + + diff --git a/eval/skill/oracle/polycss/01-static-cube.mjs b/eval/skill/oracle/polycss/01-static-cube.mjs new file mode 100644 index 000000000..cfe8b74db --- /dev/null +++ b/eval/skill/oracle/polycss/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/polycss/02-orbiting-cube.mjs b/eval/skill/oracle/polycss/02-orbiting-cube.mjs new file mode 100644 index 000000000..842eff100 --- /dev/null +++ b/eval/skill/oracle/polycss/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/polycss/03-cube-with-shadow.mjs b/eval/skill/oracle/polycss/03-cube-with-shadow.mjs new file mode 100644 index 000000000..98e9b9903 --- /dev/null +++ b/eval/skill/oracle/polycss/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/polycss/04-two-shapes.mjs b/eval/skill/oracle/polycss/04-two-shapes.mjs new file mode 100644 index 000000000..ce3da59c4 --- /dev/null +++ b/eval/skill/oracle/polycss/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/polycss/05-hand-authored-polygons.mjs b/eval/skill/oracle/polycss/05-hand-authored-polygons.mjs new file mode 100644 index 000000000..961afd814 --- /dev/null +++ b/eval/skill/oracle/polycss/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/polycss/06-composed-scene.mjs b/eval/skill/oracle/polycss/06-composed-scene.mjs new file mode 100644 index 000000000..c2d3f7a9f --- /dev/null +++ b/eval/skill/oracle/polycss/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/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 new file mode 100644 index 000000000..53d121114 --- /dev/null +++ b/eval/skill/run.mjs @@ -0,0 +1,574 @@ +#!/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; + * 2. run the agent's CLI non-interactively in that workspace; + * 3. bundle whatever `scene.mjs` it produced and grade what renders. + * + * 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 + * 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, + existsSync, + mkdirSync, + readFileSync, + rmSync, + writeFileSync, +} from "node:fs"; +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"; +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), ".."); +const repoRoot = resolve(here, "..", ".."); +/** + * 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] + +Options + --agent Comma-separated: ${AGENT_NAMES.join(", ")}, or all. + Default: oracle. + --track Comma-separated: ${TRACK_NAMES.join(", ")}, or all. + 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. 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). + --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: [], + tracks: [], + tasks: [], + keep: false, + reuse: false, + allowMissing: false, + run: null, + preflight: true, + headed: false, + settle: 2000, + 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 === "--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 === "--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; + 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(runId, track, agent, task) { + const dir = workspaceDir(runId, track, agent, task); + rmSync(dir, { recursive: true, force: true }); + mkdirSync(dir, { recursive: true }); + + // 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${taskPrompt(track, task)}\n`); + return dir; +} + +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-${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 + * 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]; + + if (name === "oracle") { + // 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", oracleTrack, "_common.mjs"); + if (existsSync(shared)) copyFileSync(shared, join(dir, "_common.mjs")); + return { ok: true, ms: 0, output: "reference solution copied" }; + } + + // 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), { + 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"], + }); + 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), + }; + } +} + +/** + * 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, runId, timeoutSeconds = 90) { + if (name === "oracle") return { ok: true }; + const dir = join(workRoot, runId, "__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" }; +} + +/** 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)}`; +}; + +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; + let tracks; + try { + 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); + 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; + } + + if (options.preflight && !options.reuse) { + for (const name of [...agents]) { + process.stdout.write(`[eval] 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, options.run, "__preflight__"), { recursive: true, force: true }); + if (agents.length === 0) { + console.error("[eval] no agent is ready"); + return 1; + } + console.log(); + } + + console.log( + `[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; + + // 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); + 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); + if (!reusing) created.add(dir); + const run = reusing + ? { ok: true, ms: 0, output: "reused existing workspace" } + : runAgent(name, track, 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)` + : reusing + ? "reused scene.mjs" + : `wrote scene.mjs (${(run.ms / 1000).toFixed(0)}s)`, + ); + + candidates.push({ + order: candidates.length, + key: `${name}__${track}__${task.id}`, + agent: name, + trackName: track, + track: TRACKS[track], + task, + dir, + run, + source, + entry: source === null ? null : join(dir, "scene.mjs"), + }); + } + } + } + + 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`); + + 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 applicable = candidate.track.installSkill + ? allChecks(candidate.task) + : candidate.task.visual; + const checks = + result?.checks ?? + applicable.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 { + order: candidate.order, + agent: candidate.agent, + track: candidate.trackName, + 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, + 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) { + 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. 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) { + 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(); + } + + if (options.json) { + // 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) { + // 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. + // 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)}`); + } + + 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; + } + + 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. + 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..fee991a7b --- /dev/null +++ b/eval/skill/selftest.mjs @@ -0,0 +1,242 @@ +#!/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", "polycss", `${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 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: "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", + 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..46be12dec --- /dev/null +++ b/eval/skill/tasks.mjs @@ -0,0 +1,655 @@ +/** + * 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 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. + */ + +/** + * 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. + */ +const isBackground = ([r, g, b]) => r > 244 && g > 244 && b > 244; + +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; + +/** + * 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 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) { + 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)), + 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; +} + +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); + // 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 }; +} + +/** + * 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)`; + }, +}); + +/** + * 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 = (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); + 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 (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`; + } + 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})`, + 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.01 + ? true + : `only ${(changed * 100).toFixed(1)}% of the image changed between samples — nothing is animating`; + }, +}; + +/** + * 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 }) => { + const c = receiverContrast(first, casterHex); + if (c.count < 200) return `not enough receiver surface to measure (${c.count} samples)`; + return c.darkShare >= 0.005 + ? true + : `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`; + }, +}); + +/* ── 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. +- 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.`, + visual: [ + mountsCleanly, + framedWithin("#ff8c1a", 0.05, 0.55), + colorMatches("#ff8c1a"), + litWithin("#ff8c1a", 40, 210), + isShaded("#ff8c1a", 2), + isStill, + ], + native: [ + hasScene, + { + 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. +- 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.`, + visual: [ + mountsCleanly, + framedWithin("#14b8a6", 0.05, 0.55), + colorMatches("#14b8a6"), + litWithin("#14b8a6", 25, 190), + isMoving, + ], + native: [ + hasScene, + { + 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 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, + paintsSomething(), + colorMatches("#fbbf24"), + shadowDarkensReceiver("#fbbf24"), + ], + native: [ + hasScene, + { + 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. 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), + colorMatches("#6366f1"), + colorMatches("#f43f5e"), + { + 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`; + }, + }, + ], + native: [ + hasScene, + { + id: "two-meshes", + describe: "adds two separate meshes", + run: ({ first }) => + first.meshCount === 2 ? true : `expected 2 meshes, found ${first.meshCount}`, + }, + ], + }, + + { + 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. +- 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"), + { + id: "faces-visible", + describe: "all four tiles face the camera (correct winding)", + run: ({ first }) => { + // 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 ${regions} separate lime region(s) painted, expected 4 - a tile wound away from the camera is backface-culled and paints 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", + 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. + +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), + colorMatches("#ef4444"), + colorMatches("#3b82f6"), + colorMatches("#eab308"), + shadowDarkensReceiver(null), + ], + native: [ + hasScene, + { + 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); + +/** 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)); + 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 = { + luma, + surfaceBrightness, + receiverContrast, + hueOf, + hueDistance, + hexToRgb, + hasHue, + pixelsWithHue, + paintedFraction, + changedFraction, + shadeLevels, + boundsOf, +}; diff --git a/eval/skill/tracks.mjs b/eval/skill/tracks.mjs new file mode 100644 index 000000000..4366cf51b --- /dev/null +++ b/eval/skill/tracks.mjs @@ -0,0 +1,104 @@ +/** + * The tracks a task can be run in. + * + * `polycss` is the intervention: PolyCSS with the skill installed. + * + * `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. + * + * `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"; + +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"), + }, + }, + + "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", + /** External baseline — a different library, not a control. See the header. */ + 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)]; +} + +// 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; diff --git a/eval/skill/verify.mjs b/eval/skill/verify.mjs new file mode 100644 index 000000000..a90b5a1b3 --- /dev/null +++ b/eval/skill/verify.mjs @@ -0,0 +1,202 @@ +/** + * 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"; +import { TRACKS } from "./tracks.mjs"; + +const here = resolve(fileURLToPath(import.meta.url), ".."); + +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", +}; + +/** 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], + outfile, + bundle: true, + format: "esm", + platform: "browser", + target: "es2020", + alias, + 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, + (candidate.track ?? TRACKS.polycss).alias, + ); + 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) { + const checksFor = (c) => [...c.task.visual, ...((c.track ?? TRACKS.polycss).installSkill ? c.task.native : [])]; + + if (!candidate.build.ok) { + results.push({ + ...candidate, + checks: checksFor(candidate).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 = checksFor(candidate).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 85bfcb41b..d61a29c1a 100644 --- a/package.json +++ b/package.json @@ -23,7 +23,11 @@ "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", + "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", diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 446ab55bd..794439bb5 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -227,6 +227,7 @@ export { prepareCasterEdgeOwners, prepareCasterPolyItems, prepareReceiverFacePlanes, + 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 26df79174..40fcdf3be 100644 --- a/packages/core/src/shadow/computeReceiverShadows.ts +++ b/packages/core/src/shadow/computeReceiverShadows.ts @@ -334,6 +334,22 @@ export function prepareCasterPolyItems( return out; } +/** + * 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 POLY_DEFAULT_SHADOW_LIFT = 0.05; + /** * Build ReceiverFacePlane[] for a receiver mesh. Pure: groups coplanar * polygons, computes (u,v) basis + outline, applies interior occlusion cull diff --git a/packages/core/src/shadow/mergedReceiverShadows.test.ts b/packages/core/src/shadow/mergedReceiverShadows.test.ts index 8c455f5d0..22a35c874 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, + POLY_DEFAULT_SHADOW_LIFT, prepareReceiverFacePlanes, } from "./computeReceiverShadows"; import { worldPositionToCss } from "./receiverFaceGroups"; @@ -105,3 +106,21 @@ describe("computeMergedReceiverShadows", () => { expect(faces.length).toBe(0); }); }); + +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(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(), POLY_DEFAULT_SHADOW_LIFT, null, + ); + expect(flat.lift).toBe(0); + expect(lifted.lift).toBeGreaterThan(0); + }); +}); 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/polycss/src/api/createPolyScene.ts b/packages/polycss/src/api/createPolyScene.ts index b547f5505..9b10efeb6 100644 --- a/packages/polycss/src/api/createPolyScene.ts +++ b/packages/polycss/src/api/createPolyScene.ts @@ -49,6 +49,7 @@ import { optimizeMeshPolygons, parseHexColor, polygonCssSurfaceNormal, + POLY_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 ?? 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 8f896325c..4b5e73dea 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 { POLY_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 ?? 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 6d3d6f324..e5254d797 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, + POLY_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.001; + 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/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/react/src/index.ts b/packages/react/src/index.ts index 9fdd3babf..5401284e3 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, + POLY_DEFAULT_SHADOW_LIFT, normalizeInvertMultiplier, buildPolyCameraSceneTransform, capturePolyCameraSnapshot, diff --git a/packages/react/src/scene/PolyMesh.tsx b/packages/react/src/scene/PolyMesh.tsx index 3399642a1..60f445f01 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, + POLY_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.001; + 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 66767d7c8..3df19fbc7 100644 --- a/packages/react/src/scene/PolyScene.tsx +++ b/packages/react/src/scene/PolyScene.tsx @@ -17,6 +17,7 @@ import { parseHexColor, resolvePolyTextureLeafGeometry, worldDirectionToCss, + POLY_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 ?? POLY_DEFAULT_SHADOW_LIFT; return (minWorldZ + lift) * BASE_TILE; }, [shadow]); diff --git a/packages/skills/README.md b/packages/skills/README.md new file mode 100644 index 000000000..395924d2d --- /dev/null +++ b/packages/skills/README.md @@ -0,0 +1,117 @@ +# 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. + +## 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. + +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 + +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..7e7f15f0b --- /dev/null +++ b/packages/skills/bin/polycss-skills.mjs @@ -0,0 +1,226 @@ +#!/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, + // 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}`); + 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..5ee6d34d8 --- /dev/null +++ b/packages/skills/skill/SKILL.md @@ -0,0 +1,205 @@ +--- +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, 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 + +```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. +- **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 + +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..1b23241ba --- /dev/null +++ b/packages/skills/skill/docs/api-index.md @@ -0,0 +1,207 @@ +# 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, `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 + `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 + +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`, +`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 + +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`, +`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` 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`, +`PolyTextureProjection`, `PolyTexturePresentation`, `PolyTextureImageSource`. + +Camera: `PolyCameraProjection`, `PolyCameraSnapshot`, `PolyCameraSnapshotStats`. + +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` (all packages); +`PolyRenderStats` and `PolyLeafInfo` (renderer packages only — not in `core`); +`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..58c9399da --- /dev/null +++ b/packages/skills/skill/docs/controls-and-interaction.md @@ -0,0 +1,177 @@ +# 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 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 + +| 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` / 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 ≈ +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 +// 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)}> + + + { if (e.position) 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`. +- 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 + 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. **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`, +`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..49b2ac8ec --- /dev/null +++ b/packages/skills/skill/docs/lighting.md @@ -0,0 +1,128 @@ +# 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. +- **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. 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. + +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"` + +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..832f09f02 --- /dev/null +++ b/packages/skills/skill/docs/performance.md @@ -0,0 +1,109 @@ +# 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)` — 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 + +`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..d187ef966 --- /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` | **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` | **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`, +`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..255713a89 --- /dev/null +++ b/packages/skills/skill/docs/shadows.md @@ -0,0 +1,151 @@ +# 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. + +## 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 | +|---|---|---| +| `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..b9adda268 --- /dev/null +++ b/packages/skills/skill/docs/shapes-and-primitives.md @@ -0,0 +1,208 @@ +# 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`) 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 + +Defaults below are the generator defaults. The default `color` is **not +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` | +| `axesHelperPolygons` | per axis: `xColor` `#ff3a3a`, `yColor` `#3aff3a`, `zColor` `#3a8aff` | + +### `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`, and `ringQuadPolygons` (core and +`@layoutit/polycss` only — see the note at the top). + +## 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..47570ee41 --- /dev/null +++ b/packages/skills/skill/docs/textures.md @@ -0,0 +1,106 @@ +# 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 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/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..4a80d7a82 --- /dev/null +++ b/packages/skills/skill/docs/troubleshooting.md @@ -0,0 +1,79 @@ +# 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 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"`. | +| 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 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. | +| 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..8bdd826b2 --- /dev/null +++ b/packages/skills/src/install.mjs @@ -0,0 +1,415 @@ +/** + * 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, randomBytes } from "node:crypto"; +import { + lstatSync, + mkdirSync, + realpathSync, + readFileSync, + readdirSync, + renameSync, + rmSync, + statSync, + unlinkSync, + 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); + +/** + * 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; +} + +/** + * 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. + * + * 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). + */ +/** 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. + const base = realpathThroughAncestors(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, boundary) { + const path = containedPath(destDir, MANIFEST_FILE, boundary); + if (path === null || isSymlink(path)) return null; + try { + 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, 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, boundary) === null) return null; + } + return { version: typeof parsed.version === "string" ? parsed.version : null, files }; + } catch { + return null; + } +} + +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 { + 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. + // 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" }); + renameSync(temp, path); + } finally { + rmSync(temp, { force: true }); + } +} + +/** + * 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, boundary = null }) { + const sourceFiles = listFiles(sourceDir); + if (sourceFiles.length === 0) { + throw new Error(`no skill files found in ${sourceDir}`); + } + + // 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 = []; + const remove = []; + const kept = []; + + for (const rel of sourceFiles) { + const target = containedPath(destDir, rel, boundary); + if (target === null) { + conflicts.push(rel); + continue; + } + const next = readFileSync(join(sourceDir, 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; + } + 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 target = containedPath(destDir, rel, boundary); + 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); + 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, + boundary = null, +} = {}) { + const plan = planInstall({ sourceDir, destDir, force, boundary }); + + if (plan.conflicts.length > 0 || dryRun) { + return { ...plan, destDir, applied: false }; + } + + const files = {}; + for (const rel of plan.sourceFiles) { + const target = containedPath(destDir, rel, boundary); + if (target === null) continue; + const bytes = readFileSync(join(sourceDir, toNative(rel))); + files[rel] = sha256(bytes); + if (!plan.write.includes(rel)) continue; + writeFileNoFollow(target, bytes); + } + + for (const rel of plan.remove) { + const target = containedPath(destDir, rel, boundary); + if (target === null) continue; + rmSync(target, { force: true }); + } + + mkdirSync(destDir, { recursive: true }); + writeFileNoFollow( + containedPath(destDir, MANIFEST_FILE, boundary), + `${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..8a69ca027 --- /dev/null +++ b/packages/skills/src/install.test.mjs @@ -0,0 +1,596 @@ +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 { symlinkSync } from "node:fs"; +import { + AGENTS, + bundledSkillDir, + DEFAULT_AGENT, + expandAgents, + installSkill, + isSafeRelativePath, + 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("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("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"); + 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"); + 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/packages/vue/src/index.ts b/packages/vue/src/index.ts index fceb4e9ca..85bd50cb4 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, + POLY_DEFAULT_SHADOW_LIFT, normalizeInvertMultiplier, buildPolyCameraSceneTransform, capturePolyCameraSnapshot, diff --git a/packages/vue/src/scene/PolyMesh.ts b/packages/vue/src/scene/PolyMesh.ts index 60f205018..43c973460 100644 --- a/packages/vue/src/scene/PolyMesh.ts +++ b/packages/vue/src/scene/PolyMesh.ts @@ -34,6 +34,7 @@ import { cornerShapeGeometryForPlan, resolvePolyTextureLeafGeometry, worldDirectionalLightToCss, + POLY_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.001; + 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 f2caaebc2..fdf89d1a8 100644 --- a/packages/vue/src/scene/PolyScene.ts +++ b/packages/vue/src/scene/PolyScene.ts @@ -37,6 +37,7 @@ import { parseHexColor, resolvePolyTextureLeafGeometry, worldDirectionToCss, + POLY_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 ?? POLY_DEFAULT_SHADOW_LIFT; const next = (minWorldZ + lift) * DEFAULT_TILE; if (groundCssZ.value !== next) groundCssZ.value = next; if (!el) return; 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..c9dce353b 100644 --- a/website/public/skill.md +++ b/website/public/skill.md @@ -6,14 +6,57 @@ 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, 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 ```ts import { @@ -36,22 +79,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 +93,24 @@ 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. - -## Authoring Polygons — read before generating geometry - -A `Polygon` is a plain object. `vertices` is the only required field. +- `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. +- **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 + +A `Polygon` is a plain object; `vertices` is the only required field. ```ts interface Polygon { @@ -76,97 +119,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. -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 +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. + +## Minimal scene Vanilla: @@ -191,8 +167,8 @@ React (Vue mirrors this with kebab-case props): ```tsx - + {polygons.map((p, i) => select(i)} />)} @@ -200,210 +176,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: +## Rules of thumb -```tsx -import { - PolyThreePerspectiveCamera, - PolyThreeOrthographicCamera, - PolyThreeMesh, - DirectionalLight, -} from "@layoutit/polycss-react/three"; -``` - -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..5ee6d34d8 --- /dev/null +++ b/website/public/skill/SKILL.md @@ -0,0 +1,205 @@ +--- +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, 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 + +```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. +- **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 + +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..1b23241ba --- /dev/null +++ b/website/public/skill/docs/api-index.md @@ -0,0 +1,207 @@ +# 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, `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 + `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 + +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`, +`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 + +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`, +`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` 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`, +`PolyTextureProjection`, `PolyTexturePresentation`, `PolyTextureImageSource`. + +Camera: `PolyCameraProjection`, `PolyCameraSnapshot`, `PolyCameraSnapshotStats`. + +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` (all packages); +`PolyRenderStats` and `PolyLeafInfo` (renderer packages only — not in `core`); +`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..58c9399da --- /dev/null +++ b/website/public/skill/docs/controls-and-interaction.md @@ -0,0 +1,177 @@ +# 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 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 + +| 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` / 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 ≈ +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 +// 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)}> + + + { if (e.position) 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`. +- 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 + 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. **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`, +`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..49b2ac8ec --- /dev/null +++ b/website/public/skill/docs/lighting.md @@ -0,0 +1,128 @@ +# 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. +- **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. 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. + +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"` + +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..832f09f02 --- /dev/null +++ b/website/public/skill/docs/performance.md @@ -0,0 +1,109 @@ +# 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)` — 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 + +`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..d187ef966 --- /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` | **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` | **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`, +`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..255713a89 --- /dev/null +++ b/website/public/skill/docs/shadows.md @@ -0,0 +1,151 @@ +# 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. + +## 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 | +|---|---|---| +| `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..b9adda268 --- /dev/null +++ b/website/public/skill/docs/shapes-and-primitives.md @@ -0,0 +1,208 @@ +# 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`) 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 + +Defaults below are the generator defaults. The default `color` is **not +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` | +| `axesHelperPolygons` | per axis: `xColor` `#ff3a3a`, `yColor` `#3aff3a`, `zColor` `#3a8aff` | + +### `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`, and `ringQuadPolygons` (core and +`@layoutit/polycss` only — see the note at the top). + +## 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..47570ee41 --- /dev/null +++ b/website/public/skill/docs/textures.md @@ -0,0 +1,106 @@ +# 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 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/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..4a80d7a82 --- /dev/null +++ b/website/public/skill/docs/troubleshooting.md @@ -0,0 +1,79 @@ +# 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 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"`. | +| 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 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. | +| 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.