diff --git a/.github/scripts/bump-versions.mjs b/.github/scripts/bump-versions.mjs index f2c62e27..64ea28f2 100644 --- a/.github/scripts/bump-versions.mjs +++ b/.github/scripts/bump-versions.mjs @@ -3,7 +3,7 @@ * Bump the version of every package under `packages/*` in lockstep. * * Usage: - * node scripts/bump-versions.mjs + * node .github/scripts/bump-versions.mjs * * Reads the current version from the first package, applies the requested * semver bump, writes the new version back to every package.json with a @@ -16,7 +16,6 @@ import { resolve, dirname } from "node:path"; import { fileURLToPath } from "node:url"; const __dirname = dirname(fileURLToPath(import.meta.url)); -// __dirname is .github/scripts — repo root is two levels up. const root = resolve(__dirname, "..", ".."); const packages = ["core", "polycss", "react", "vue"].map((d) => resolve(root, "packages", d, "package.json"), diff --git a/tools/sync-package-readmes.mjs b/.github/scripts/sync-package-readmes.mjs similarity index 97% rename from tools/sync-package-readmes.mjs rename to .github/scripts/sync-package-readmes.mjs index 3111152d..f1ffe2aa 100644 --- a/tools/sync-package-readmes.mjs +++ b/.github/scripts/sync-package-readmes.mjs @@ -2,7 +2,7 @@ import { copyFileSync } from "node:fs"; import { dirname, resolve } from "node:path"; import { fileURLToPath } from "node:url"; -const repoRoot = resolve(dirname(fileURLToPath(import.meta.url)), ".."); +const repoRoot = resolve(dirname(fileURLToPath(import.meta.url)), "..", ".."); const source = resolve(repoRoot, "README.md"); const targets = [ "packages/core/README.md", diff --git a/.github/workflows/publish-packages.yml b/.github/workflows/publish-packages.yml index 8cb2e34e..4bd78872 100644 --- a/.github/workflows/publish-packages.yml +++ b/.github/workflows/publish-packages.yml @@ -1,7 +1,7 @@ name: Publish packages # Manual-only. Bumps every package under `packages/*` in lockstep (using -# scripts/bump-versions.mjs), builds them, publishes to npm, then commits +# .github/scripts/bump-versions.mjs), builds them, publishes to npm, then commits # the version change and pushes a `v` tag back to main. on: workflow_dispatch: diff --git a/.gitignore b/.gitignore index c0f1c49b..2875a6b6 100644 --- a/.gitignore +++ b/.gitignore @@ -13,12 +13,9 @@ packages/vue/tsconfig.tsbuildinfo # Perf bench — generated artifacts (the harness scripts in bench/ ARE # tracked, but the bundled output and per-run results are local-only). -/bench/polycss.js -/bench/polycss-elements.js -/bench/polycss-react.js -/bench/polycss-render-stats.js -/bench/polycss-vue.js +/bench/.generated/ /bench/results/ +/bench/notes/results/ # Agent-tool internal state — worktree refs, scheduled-task locks, etc. /.claude/ diff --git a/AGENTS.md b/AGENTS.md index 6673accd..93f1741f 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -20,7 +20,7 @@ Public API is **mirrored** across React and Vue. Adding a hook on one side witho ## Rendering model — the mental model -**One visible `Polygon` → one leaf DOM element.** Leaves use canonical CSS primitives where possible and move scale into `matrix3d`; `border-shape` uses a larger fixed primitive because its paint geometry becomes unstable when collapsed to 1px. Textured polygons still pack their local-2D bounding rect (`canvasW × canvasH`) into the atlas. The HTML tag *is* the render strategy — the renderer picks one tag per polygon based on its shape and material. +**One visible `Polygon` → one leaf DOM element.** Leaves use canonical CSS primitives where possible and move scale into `matrix3d`; clipped solids use fixed primitives because their paint geometry becomes unstable when collapsed to 1px. Textured polygons still pack their local-2D bounding rect (`canvasW × canvasH`) into the atlas. The HTML tag *is* the render strategy — the renderer picks one tag per polygon based on its shape and material. Raw MagicaVoxel `.vox` sources have a narrower baked-mode fast path: `parseVox` still returns the polygon mesh for bounds, fallback rendering, and public handles, but also preserves a `PolyVoxelSource` marker. Eligible vanilla meshes render exact visible voxel quads as hostless `` leaves with canonical `matrix3d(...)` transforms and projected tile4 scanline DOM order. `.vox` normalization snaps to the nearest integer CSS cell size so direct voxel matrices use integer pixel coordinates without any scale wrapper. Brush colors still receive baked Lambert shading from the scene lights. Dynamic lighting, shadows, stable DOM animation, non-exact voxel geometry, and geometry replaced via `setPolygons` fall back to the polygon renderer. @@ -30,15 +30,15 @@ Voxel-shaped meshes are the exception to "all polygons stay mounted": meshes wit | Tag | Strategy | When chosen | Paint mechanism | Atlas memory | |---|---|---|---|---| -| `` | **Quads** | Axis-aligned rectangle, or untextured convex quad when the homography passes stability guards | `background: currentColor` on a fixed 64px rectangle; affine and projective quads normalize their `matrix3d` to that primitive, with tiny solid bleed on projective quads to overlap antialias seams | None | -| `` | **Border-shape clipped solid** | Untextured non-rect on browsers with CSS `border-shape` (Chromium + `pointer:fine` + `hover:hover`) | `border-color: currentColor` on a fixed 64px border-shape primitive, clipped by `border-shape: polygon(...)`; polygon bbox scale and tiny solid bleed are folded into `matrix3d` | None | +| `` | **Quads** | Axis-aligned rectangle, or untextured convex quad when the homography passes stability guards on non-Safari engines | `background: currentColor` on a fixed 64px rectangle; affine and projective quads normalize their `matrix3d` to that primitive, with tiny solid bleed on projective quads to overlap antialias seams. Safari-family browsers skip the projective quad path and fall through because transformed projective rectangles composite incorrectly there. | None | +| `` | **Border-shape clipped solid** | Untextured non-rect not caught by the exact corner-shape solid path, on browsers with CSS `border-shape` (Chromium + `pointer:fine` + `hover:hover`) | `border-color: currentColor` on a fixed 16px border-shape primitive, clipped by `border-shape: polygon(...)`; polygon bbox scale and tiny solid bleed are folded into `matrix3d` | None | | `` | **Atlas slice** | Textured polygons, or untextured non-rect on browsers without `border-shape` | `background-image` slice of packed bitmap on an auto-budgeted fixed primitive (128px for desktop-class `textureQuality="auto"`, 64px for mobile-class `auto` and explicit numeric quality); atlas position/size and `matrix3d` scale are normalized to the slice, shared textured edges get low-alpha atlas pixels repaired during atlas generation, and solid fallbacks get same-color edge bleed to avoid dark alpha fringes | Bounding-rect area | -| `` | **Stable solid triangle** | Opt-in for triangles via `renderPolygonsWithStableTriangles` on non-WebKit engines | CSS border-color triangle trick with a fixed canonical 64px border triangle; tiny solid bleed is folded into `matrix3d`. WebKit/Safari falls through to `` because transformed CSS border triangles composite incorrectly there. | None | +| `` | **Stable solid triangle / corner-shape solid** | Triangles on non-WebKit engines; or untextured non-triangle polygons whose normalized outline is exactly a rectangle with one or more beveled corners on browsers with CSS `corner-shape` | Triangles use a 32px box with two beveled top corners and `background: currentColor` when CSS `corner-shape` support is present, progressively falling back to the CSS border-color triangle trick; exact corner-shape solids use a bare fixed 16px box with inline per-corner radii + `corner-*-shape: bevel` and `background: currentColor`. Tiny solid bleed is folded into `matrix3d`. WebKit/Safari falls through to `` for border triangles because transformed CSS border triangles composite incorrectly there. | None | | `` | **Cast shadow leaf** | Per casting polygon when `castShadow: true` and dynamic lighting mode. Applies regardless of caster strategy — ``/``/``/`` all produce a `` shadow because only the polygon's outline matters, not its surface. | Same `border-color: currentColor` + `border-shape: polygon(...)` as ``, but transform composes `var(--shadow-proj)` to project the polygon onto the ground plane along the CSS-space light direction | None | -Strategies are ordered cheapest → most expensive. The mesher's job is to maximise `` / `` and minimise `` (see "Meshing implications" below). +Strategies are ordered cheapest → most expensive. The mesher's job is to maximise `` / `` / `` and minimise `` (see "Meshing implications" below). -Callers can opt out of specific strategies via `strategies: { disable: ["b" | "i" | "u"] }` on `RenderTextureAtlasOptions`. Disabled or unsupported strategies fall through the chain (`b → i → s`, `u → i → s`, `i → s`). `` is the universal fallback and cannot be disabled. +Callers can opt out of specific strategies via `strategies: { disable: ["b" | "i" | "u"] }` on `RenderTextureAtlasOptions`. Disabled or unsupported strategies fall through the chain (`b → i → s`, `u → i → s`, `i → s`). Disabling `"i"` also disables the exact corner-shape solid branch even though that branch emits a bare ``, because it belongs to the non-triangle clipped-solid family. `` is the universal fallback and cannot be disabled. The `.vox` fast path emits plain `` elements directly inside the mesh wrapper. They intentionally reuse the cheap quad tag, but they are exact voxel quads with one `matrix3d(...)` per visible quad, ordered by projected tile4 scanline order. Desktop-class documents use a canonical 1px primitive for the cheapest transform shape; mobile-class documents (`pointer: coarse` or `hover: none`) use an 8px primitive and divide the in-plane matrix scale by 8 to preserve identical CSS-space geometry while avoiding large GPU filtering gaps. @@ -64,7 +64,7 @@ All solid/atlas tags work in both modes. The `.vox` direct-matrix fast path is b This is the load-bearing constraint behind the whole engine. **JavaScript should not run per-frame to paint polygons when the motion can be expressed as a scene, mesh, camera, or light update.** Once the scene is built and the atlas is rasterised, the browser drives most rendering through CSS — `matrix3d` transforms, `calc()`-driven custom properties, `background-blend-mode`, `border-shape`, etc. -The current exception is imported skeletal animation. glTF/GLB skinning changes each polygon independently, so the vanilla stable-DOM animation path samples the active clip in JS, keeps the leaf set mounted, caches baked stable-triangle transform frames, and refreshes baked color on a cadence. That optimized path is the default; do not add a user-facing "baseline vs optimized" toggle or maintain a legacy slow path in product UI. +The current exception is imported skeletal animation. glTF/GLB skinning changes each polygon independently, so the vanilla stable-DOM animation path samples the active clip in JS, keeps the leaf set mounted, caches baked stable-triangle transform frames, and refreshes baked color on a cadence. On WebKit/Safari, where stable CSS triangles fall through to solid atlas `` leaves, same-topology animation updates keep the existing atlas elements and bitmap URLs mounted, cache transform frames once warmed, and hide briefly degenerate atlas triangles only until the next valid frame. That optimized path is the default; do not add a user-facing "baseline vs optimized" toggle or maintain a legacy slow path in product UI. | Where JS runs | Where JS does NOT run | |---|---| diff --git a/bench/ITERATION_GUARDRAILS.md b/bench/ITERATION_GUARDRAILS.md deleted file mode 100644 index f2ab0a09..00000000 --- a/bench/ITERATION_GUARDRAILS.md +++ /dev/null @@ -1,253 +0,0 @@ -# Perf-iteration guardrails - -Rules of engagement for an automated (or human-driven) loop that proposes -performance changes to the polycss renderer. Lives next to the bench -harness because the harness is the only legitimate way to claim a perf -result — every change has to pass through it. - ---- - -## Philosophy: polycss is **CSS-based polygon rendering** - -The renderer's job is to translate parsed polygons into DOM + CSS such -that the browser composites the scene **using the CSS engine, not -JavaScript-driven per-frame paint**. JS exists at the API surface -(setOptions, custom elements, React/Vue) but must not own the per-frame -visual update. - -Every proposed change is judged against this rule first. If the change -moves visual state from CSS into a per-frame JS write loop, it's out -of scope — even if it's faster. - ---- - -## What's on the table - -### ✅ Allowed: things you can change - -- **CSS rules in `packages/polycss/src/styles/styles.ts`** — cascade - variables, `calc()` shapes, `contain` declarations, - `transform-style`, `backface-visibility`, `background-blend-mode`, - `@property` registrations, the dynamic-mode rule body. The whole - cascade is in scope. -- **Atlas pipeline in `packages/polycss/src/render/textureAtlas.ts`** — - packing strategy, page count, encoding (canvas → blob URL), the - `applyAtlasBackground` per-element writes, mask-image vs alpha-clip, - page consolidation, atlas scale resolution. -- **`createPolyScene.ts`** — `setOptions` diff logic, `applySceneStyle` - internals, the cull bin classifier (`classifyNormal`), how culled - polys are hidden, `recomputeAutoCenter`, the auto-center wrapper - structure. -- **DOM structure inside the scene** — adding wrapper divs, changing - the parent-of-`` chain, reorganizing how the cull/dir classes - attach, as long as the scene element class names users depend on - (`.polycss-scene`, `.polycss-mesh`, ``) keep their public meaning. -- **Mesh post-processing** — the merge / normalize pipeline, dedup, - poly-count reduction tactics that preserve visual output. -- **Bench harness itself** — better filters, more bimodal detectors, - new mesh presets, new scenarios, smarter outlier rejection. - -### ❌ Off-limits: things you cannot change - -- **The four public APIs** — vanilla `createPolyScene` / - `` / React `` / Vue ``. - Add fields if needed; never break existing ones in an iteration - commit. (A breaking change is its own discrete commit, reviewed - separately, never bundled with a perf-claim commit.) -- **`SceneHandle` contract**: `add` / `setOptions` / `destroy` / - `host` / `getOptions`. Same rule — additive only mid-iteration. -- **The CSS-only render philosophy.** No `requestAnimationFrame` loop - inside the renderer that writes inline styles per polygon per frame. - No JS-computed `style.backgroundColor` / `style.filter` / - `style.opacity` writes per polygon per frame to apply visual state. - The cascade is the renderer; JS sets the inputs. -- **Visual output.** Every change must pass `pnpm bench:visual` (mean - ΔE per channel ≤ 0.01 on chicken + rock1 × 3 azimuths). A change - that fails visual is not a perf change — it's a different - rendering. Reject. -- **Existing 820 tests.** All must stay green. A change that needs to - drop tests should also explain *why* the test was checking the - wrong thing. -- **Browser support.** No `chrome://flags`-only features. No - experimental APIs not in evergreen Chrome / Safari / Firefox - baselines. Houdini Paint API in particular is still partial — only - acceptable as a progressive enhancement, not a requirement. - -### 🤔 Gray areas (need an explicit call) - -- **API surface additions.** Adding a new option (e.g., a - `mergeStrategy` knob) is fine in principle but should land as its - own commit with tests, not bundled with a perf claim that uses it. -- **New `textureLighting` modes.** Allowed only if they stay - CSS-driven (e.g., a new cascade shape, a Houdini Paint worklet - with a CSS fallback). Specifically forbidden: any mode whose - per-frame work is JS DOM mutation. -- **Removing back-compat shims.** Case-by-case, but never inside - an iteration commit — give it its own focused PR. - ---- - -## How an iteration is structured - -Each iteration follows a strict shape so results are reproducible and -attributable. - -### 1. Hypothesis - -A single sentence about what changes and why it might help. - -> *Hoist the Lambert dot product into a per-poly registered -> property so rgb() doesn't recompute it 3× per channel.* - -If the hypothesis spans multiple unrelated changes, split it. One -hypothesis = one iteration commit. - -### 2. Baseline - -Run the bench against the *current* main on the relevant mesh **before** -touching anything. Save: - -```sh -pnpm bench:perf -- --label baseline -``` - -Output goes to `bench/results/baseline.json`. - -### 3. Apply the change - -Single, surgical diff. If the hypothesis turns out to need ancillary -refactors, abandon and re-scope — surgical first, structural later. - -### 4. Re-bench + visual diff - -```sh -pnpm bench:perf -- --label after -pnpm bench:visual # MUST pass -pnpm -r test --run # MUST pass -``` - -### 5. Decide - -Compare `baseline.json` vs `after.json` on these axes: - -| Metric | Acceptance criterion | -| ------------------------------------- | -------------------------------------------- | -| `dynamic.light_rotate.fps_p50` | **Primary.** Improve ≥ 2 % on at least one mesh, OR | -| `dynamic.camera_rotate.fps_p50` | Improve ≥ 2 %, OR | -| `dynamic.static.fps_p50` | Improve ≥ 2 % without regressing the others | -| Any of the above | **Cannot regress** > 2 % on any mesh | -| `is_bimodal` flag | Cannot newly turn on for any scenario | -| Visual diff `mean_delta` | Each frame ≤ 0.01 (no exceptions) | -| All package tests | All 820 must pass | -| All four renderer paths | Cross-renderer regression > 2 % cancels even if vanilla wins | - -Numbers below the noise floor (~2 %) don't count. Run-to-run variance -of ~1 % is normal even with the fresh-chromium-per-scenario fix. - -### 6. Document outcome - -Each iteration leaves a row in the iteration log (proposed location: -`bench/ITERATION_LOG.md`) with: - -- Hypothesis ID, one-line summary -- Before / after p50 for the primary metric on each test mesh -- Visual diff max ΔE -- Verdict: **WIN** / **LOSE** / **FLAT** / **REJECT** (visual or test failure) -- One-sentence "why" — especially important for LOSE/FLAT, because - knowing *why* an idea didn't work prevents the next iteration from - proposing a sibling that fails for the same reason. - -### 7. Tree-search winners; don't chase losers - -If a hypothesis WINs, generate 2–3 sibling hypotheses that **build on** -the win (push the same lever further, combine with adjacent ideas, -test on a different mesh). If it LOSEs/FLATS, *do not* propose a near- -duplicate hypothesis — the cause that killed it likely kills the -sibling too. - ---- - -## Standard test meshes - -These three together cover the surface area worth measuring: - -| Mesh | Why it's interesting | -| -------- | ------------------------------------------------------------------- | -| `chicken`| Small (648 polys), flat-color MTL materials. Cascade-driven path. | -| `rock1` | Tiny (194 polys), UV-mapped texture (`map_Kd`). Atlas-blob path. | -| `saucer` | Large (6384 polys), procedural flat color. Stresses the cascade walk. | - -Run hypotheses against **all three** before drawing conclusions. A -change that helps one and hurts another is a trade-off, not a win. - -For "where does it break down?" research, bench `synth-10k`, -`synth-30k`, `synth-50k` — UV-sphere meshes built in the browser. - ---- - -## Standard scenarios - -The five always-on scenarios: - -``` -dynamic.static (idle frame floor under cascade) -dynamic.light_rotate (light-direction changes per frame) ← primary -dynamic.camera_rotate (camera transform changes per frame) -baked.static (idle frame floor without cascade) -baked.camera_rotate (camera transform changes, no light cost) -``` - -Per renderer (html / vanilla / react / vue) → 5 × 4 = 20 rows per mesh. -Run all four renderers on the primary metric to spot framework-tax -regressions. - ---- - -## Cataloged dead-ends (don't repropose) - -These have all been measured at one point or another and either -violated the architecture or regressed the numbers. Save the cycle: - -| Hypothesis | Why it doesn't work | -| -------------------------------------------------------- | ---------------------------------------------------------------------------- | -| `textureLighting: "dynamic-js"` (per-frame JS Lambert) | Architecturally out of scope (CSS-only philosophy). | -| `style.filter` on per-poly elements driven by JS | Same. | -| `clip-path` instead of `mask-image` | 4000+ `clip-path`s in `preserve-3d` = ~15 s/frame. Catastrophic. | -| `opacity: 0.999` to force compositor layer | Triple regression on light + camera + visual fail. Layer promo fights `preserve-3d`. | -| Per-poly `--polycss-tint` registered-property route | Cascade walk still visits all polys; calc resolution into `background-color` re-paints. No win. | -| `filter: brightness(calc(...))` on the CSS-driven path | Same — `calc()` in CSS still triggers `UpdateLayoutTree`. | -| `className`-swap to apply per-frame state | Rule re-matching cost > inline style cost when `mask-image` is present. | -| Bench-only frame-rate quantization to inflate fps | Not a real win — only "worked" because identical-value writes were skipped, not from temporal throttling. | -| Atlas page consolidation (4 → 2 pages) | Larger atlas → much slower `toBlob()` encoding bleeds into the measurement window. Real regression. | - -Re-proposing one of these requires explaining what changed since last -time — usually nothing has, and the answer is no. - ---- - -## Output / housekeeping - -- Per-iteration JSON lives in `bench/results/