Skip to content

Latest commit

 

History

History
149 lines (105 loc) · 20.3 KB

File metadata and controls

149 lines (105 loc) · 20.3 KB

Polycss — agent guide

This file is the single source of truth for AI coding agents (Claude Code, Cursor, etc.). CLAUDE.md is a symlink to this file — always edit AGENTS.md, never CLAUDE.md. The constraints below describe the current design and the rules we work under; if a request conflicts with one of them, push back before doing it.

What this repo is

polycss is a CSS-based polygon mesh rendering engine. It paints 3D meshes by emitting one DOM element per polygon, transforming it with matrix3d, and letting the browser composite the result. No WebGL, no canvas-per-frame. Rasterisation only happens once, into a texture atlas; everything after that is pure DOM + CSS.

Monorepo layout (pnpm workspaces):

Package npm name Role
packages/core @layoutit/polycss-core Pure math: Vec3, Polygon, scene, camera, mesh ops, atlas planning. Zero browser globals (lib: ES2020 only).
packages/polycss @layoutit/polycss Vanilla renderer + custom elements (<poly-scene>, etc.). Owns DOM emission, CSS injection, its own copy of atlas rasterisation. Depends on core only.
packages/react @layoutit/polycss-react React components + hooks. Owns its own copy of atlas rasterisation. Depends on core only — NOT on polycss.
packages/vue @layoutit/polycss-vue Vue 3 mirror of the React package. Owns its own copy of atlas rasterisation. Depends on core only.
website @layoutit/polycss-website Astro + Starlight docs site. Not published.
examples/{html,vanilla,react,vue} private Per-framework Vite apps demonstrating the minimal usage for each renderer. Workspace members so they resolve to local workspace:^ packages. Not published.

Public API is mirrored across React and Vue. Adding a hook on one side without adding the matching composable on the other is not acceptable (see "Cross-package discipline" below).

Rendering model — the mental model

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 <b> 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.

Voxel-shaped meshes are the exception to "all polygons stay mounted": meshes with at most the six axis-aligned face normals, excluding helpers/auto-center-exempt meshes, automatically mount only camera-facing leaves and patch 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 DOM culling is not worth the mutation cost.

Tag-as-strategy table

Tag Strategy When chosen Paint mechanism Atlas memory
<b> 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
<i> 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
<s> 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
<u> 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 <s> for border triangles because transformed CSS border triangles composite incorrectly there. None
<q> Cast shadow leaf Per casting polygon when castShadow: true and dynamic lighting mode. Applies regardless of caster strategy — <b>/<i>/<s>/<u> all produce a <q> shadow because only the polygon's outline matters, not its surface. Same border-color: currentColor + border-shape: polygon(...) as <i>, 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 <b> / <u> / <i> and minimise <s> (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). Disabling "i" also disables the exact corner-shape solid branch even though that branch emits a bare <u>, because it belongs to the non-triangle clipped-solid family. <s> is the universal fallback and cannot be disabled. Solid seam bleed defaults to 1.5 CSS px on detected shared edges; callers can set seamBleed, where "auto" computes a fitted amount from each polygon plan and numeric values clamp the per-side CSS-pixel overscan. The renderer applies bleed only to detected shared seam edges of solid primitives, rather than inflating every side of each participating polygon.

The .vox fast path emits plain <b> 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.

Lighting modes (PolyTextureLightingMode = "baked" | "dynamic")

  • Baked. Lambert is computed once on the CPU per polygon, multiplied into the inline color (for <b>/<i>/<u>) or into the rasterised atlas pixels (for <s>). Moving a light requires re-rasterising affected polys.
  • Dynamic. Scene root carries the light 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 mutates one var on the scene root — zero JS, no atlas redraw.

All solid/atlas tags work in both modes. The .vox direct-matrix fast path is baked-only for now; dynamic mode uses the polygon path so lighting semantics stay correct. The full coverage matrix is in packages/polycss/src/styles/styles.ts.

Meshing implications (what generators must respect)

  • Polygon count is the dominant cost. Each polygon is one DOM node, one matrix3d, one paint. Halving the polygon count is almost always worth a more complex mesher.
  • Lossy optimization includes bounded seam repair. The default "lossy" path merges compatible polygons, then repairs high-risk seams with targeted overlap and a small split budget. This can add a few triangles back when it prevents visible cracks; the goal is lower visible seam risk, not a strict guarantee that lossy always has fewer polygons than lossless.
  • Fill ratio matters. A textured polygon's atlas slice equals its local-2D bounding rect. Empty space inside that slice is wasted bitmap pixels. Prefer shapes with high area / boundingRect.area:
    • axis-aligned rectangle = 1.0 (and hits the fastest path)
    • right-isosceles triangle = 0.5
    • skinny/long triangle ≪ 0.5 (worst case — many such triangles balloon atlas memory)
  • Regular grids are not a constraint. Vertices may sit anywhere on the surface. 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.
  • Coplanarity is a hard requirement at render time, but the mesher can engineer it. A non-triangular polygon must have all vertices on a common plane within a small epsilon, or the renderer snaps the offending vertex in isolation and opens a visible seam with adjacent polygons. The mesher avoids this either by (a) only merging when natural coplanarity holds, or (b) deliberately snapping shared vertices to a common plane and propagating the new position to every polygon that references them. Snap-and-propagate is preferred when it widens the merge opportunity, subject to the budget below.
  • Vertex displacement budget. Every snap consumes budget on the moved vertex and on all polygons that reference it. Track cumulative displacement from the original DEM-sampled position per vertex; reject any merge that would push a vertex past the user's height tolerance. Errors compound across merges, so the bound is per-vertex cumulative drift, not per-merge.

The "no JS in the render loop" principle

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. On WebKit/Safari, where stable CSS triangles fall through to solid atlas <s> 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
Scene construction (createPolyScene, mesh ops, vertex snapping) Per-frame polygon paint
OBJ/glTF/GLB import, mesh optimisation, coplanar merging Per-frame Lambert evaluation (dynamic mode is pure CSS)
Atlas planning + rasterisation (one-shot to <canvas>, then toBlob) Per-frame atlas redraw (only on baked-mode light changes)
Control input handling (PolyOrbitControls, PolyMapControls, PolyTransformControls) Per-frame transform recomputation of every polygon for camera/mesh motion — only the scene-root or mesh-root transform changes
Camera math (matrix4 product → scene-root transform CSS var) Per-polygon JS in any hot path
Hover/selection raycasting (only on pointer events, not per frame) Continuous re-rendering "ticks"

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

Naming (three.js parity)

  • Every public export gets a Poly prefix. Exceptions are generic math types: Vec2, Vec3, Polygon, PolyMaterial (already prefixed).
  • Hooks/composables: usePolyCamera, usePolyMesh, usePolySceneContext, usePolySelect, usePolySelectionApi, usePolyAnimation.
  • Components: PolyPerspectiveCamera, PolyOrthographicCamera, PolyOrbitControls, PolyMapControls, PolyTransformControls, PolySelect, PolyAxesHelper, PolyDirectionalLightHelper.
  • Types: PolyDirectionalLight, PolyAmbientLight, PolyTextureLightingMode, PolyAnimationMixer, PolyRenderStats.
  • Functions: findPolyMeshHandle, injectPolyBaseStyles, collectPolyRenderStats, buildPolyVoxelFaceData, buildPolyVoxelSlicePlan.
  • Vanilla factories: create* names stay as-is (createPolyScene, createTransformControls, createSelect).
  • HTML custom elements: poly- prefix + kebab-case. Existing tags: <poly-scene>, <poly-mesh>, <poly-polygon>, <poly-perspective-camera>, <poly-orthographic-camera>, <poly-axes-helper>, <poly-directional-light-helper>. Any new element follows the same shape (e.g. <poly-transform-controls>, <poly-select>).
  • Leaf DOM tags (<b>, <i>, <s>, <u>): internal render-strategy tags. Not part of the public API and not user-facing — do not document them as such.
  • PolyCamera is a kept alias for PolyOrthographicCamera — the ergonomic default, optimised for iso/voxel/diagrammatic scenes which is polycss's structural strength. Not deprecated.

Cross-package discipline

The React and Vue packages are mirror images. Any public API change in one must land in the other in the same PR. Same names, same arguments, same defaults, same return shapes (allowing for idiomatic differences — refs vs reactives, useEffect vs watchEffect).

When you change packages/polycss or packages/core in a way that affects the public surface (new option, renamed export, changed default), the React and Vue bindings update in the same PR. Don't ship a polycss change that leaves the bindings stale.

Renderer-owned browser glue. The canvas atlas pipeline (buildAtlasPages + helpers), browser-feature detection (isBorderShapeSupported, isSolidTriangleSupported, resolveSolidTrianglePrimitive), and the injected .polycss-scene / .polycss-camera base styles exist as independent copies in three places: packages/polycss/src/render/atlas/, packages/react/src/scene/atlas/, packages/vue/src/scene/atlas/ (plus three sibling styles.ts files). This is deliberate — each renderer is self-contained on its dep graph (React/Vue do not import from polycss). The trade-off is that a bug fix in any of these files MUST be mirrored into the other two. Coverage is pinned per copy by the co-located test files.

Before opening a PR:

  • If I touched a React component/hook, the Vue composable/component matches.
  • If I touched a Vue component/composable, the React component/hook matches.
  • If I added an option to a polycss factory, both bindings expose it.
  • If I renamed a core export, every package that imports it is updated.
  • If I touched the canvas atlas pipeline (rasterise.ts / buildAtlasPages.ts) or browser-feature detection 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 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.

Iterating on the system

The rendering model, tag table, lighting modes, and naming conventions described in this document are the current design — not frozen. Render strategies can be added or removed, lighting modes can change shape, the public API will keep evolving. The rules for evolving them:

  • AGENTS.md is the canonical reference. Edit it directly; CLAUDE.md is just a symlink that exists so Claude Code finds the same content.
  • Architectural changes require user approval. Dropping a render strategy, adding a lighting mode, renaming a public-facing convention, changing what JS is allowed in the render path — propose, don't decide. The user (human) is the architect.
  • Same-PR sync. Any PR that adds, removes, or materially changes a render strategy, lighting mode, naming rule, or cross-package contract must update AGENTS.md in the same PR. An API change that lands without an AGENTS.md update is an incomplete change.
  • Don't append-only. Prune content that no longer reflects the codebase. If a strategy is dropped, remove its row from the tag table — don't leave a "deprecated" note. If a hook is renamed, update the naming section in place — don't list the old name "for reference".

Backward compatibility

  • No BC shims. Clean breaks only. No re-export aliases for renamed symbols. No @deprecated wrappers. If the API changes, callers update.
  • This applies even to the multi-package monorepo — all four packages move together.

Commits & PRs

  • Conventional commits format. Single-line subject. No body unless genuinely useful.
  • NO Co-Authored-By: Claude trailer.
  • NO "🤖 Generated with Claude Code" footer in PR bodies, commit messages, issue comments, or anywhere else.
  • Never amend commits. New follow-up commits only. (Pre-commit hook failures: fix and create a new commit, don't --amend.)
  • Don't auto-push subagent exploration branches — local commits only. The user pushes when ready.
  • main is protected. All work lands via PR.

Tests & build

  • Refactors must keep all tests passing. Don't delete or weaken assertions to make a refactor go through.
  • If a renamed export still has tests for the old name, rename the test imports — don't keep the old export as an alias just to satisfy them.
  • pnpm test runs the full suite across all four packages.
  • pnpm build is mandatory before opening a PR. Vitest doesn't catch DTS / declaration build failures (tsup runs strict type-checking that vitest's transient TS pass doesn't enforce). A green test run with a red build is a real failure mode. Run pnpm test && pnpm build as a unit; treat either failing as "not ready."
  • CI enforces both gates. .github/workflows/ci.yml runs pnpm test + pnpm build:packages + pnpm build:website on every PR against main and on every push to main. Don't merge with red CI.

Style / process

  • No time estimates in planning docs ("2 days", "1 hour" etc.). This is agentic engineering, not human team scheduling.
  • Prune superseded content from long planning docs as you go — don't just append.
  • No half-finished features, no speculative abstractions, no defensive code for cases that can't happen.
  • No comments explaining what code does — the code already says that. Comments are for why: a non-obvious constraint, a workaround for a specific browser bug, an invariant that isn't visible locally.