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.
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. |
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. |
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. |
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).
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. Atlas-backed textured polygons pack their local-2D bounding rect (canvasW × canvasH) into atlas pages; source-exact textured polygons may instead carry textureImageSource + texturePresentation.backend="image" and render as direct image leaves without atlas rasterisation. 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, React, and Vue meshes render visible voxel quads as <b> leaves inside persistent signed-face wrappers (t, b, fl, br, fr, bl), with canonical matrix3d(...) transforms and projected tile4 scanline order inside each mounted face. Camera-facing culling mounts/removes those face wrappers instead of removing thousands of live brush children from the mesh root. .vox normalization snaps to the nearest integer CSS cell size so direct voxel matrices use integer pixel coordinates without any scale wrapper; same-color shared voxel edges get a tiny matrix-space overscan to hide compositor seams without fattening exterior silhouette edges. Brush colors still receive baked Lambert shading from the scene lights. Callers may opt into lossy .vox palette merging and small local face-region cleanup before greedy meshing when authored palettes contain visually redundant colors; gallery and builder route this through Mesh resolution so Lossy may simplify palettes while Lossless keeps palette colors exact. 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 | 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> |
Texture slice / atlas fallback | Atlas-backed textured polygons, direct textureImageSource polygons, or untextured non-rect on browsers without border-shape |
Atlas leaves use a packed bitmap slice on an auto-budgeted primitive (128px for desktop-class textureQuality="auto", 64px for mobile-class auto and explicit numeric quality by default; textureLeafSizing can switch to local or raster dimensions). Direct image leaves use the caller's source URL and source rect directly, keep source lighting, and may use guarded affine or projective matrices for exact quad mapping. Atlas position/size, image position/size, filtering (textureImageRendering), readiness, projection, and source rect are emitted as PolyCSS-owned metadata so callers do not parse private style strings. |
Atlas: bounding-rect area; direct image: none |
<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. Firefox uses a 96px border-triangle primitive to avoid large-perspective compositor banding. Exact corner-shape solids use a fixed 16px classed 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 |
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 is internal: detected shared solid edges get up to 1.5 CSS px of per-edge overscan, fitted to the polygon plan, rather than inflating every side of each participating polygon. It is not exposed as a scene, mesh, custom-element, or atlas renderer option.
Cast shadows are not a render-strategy leaf tag. Meshes with castShadow: true project casting polygons on the CPU into SVG shadow surfaces: one aggregate path for the ground plane in vanilla, per-mesh SVG paths in React/Vue, plus scene-level receiver surfaces where receiveShadow is enabled. EVERY polygon casts — shadow casters are NOT filtered to the camera-rendered set (atlas plan) or de-duplicated, because a polygon casts a shadow regardless of whether it's painted for the camera; filtering left camera-dependent holes in imported-mesh shadows. Coincident/overlapping projections are merged into one compound path per caster under fill-rule: nonzero, so they don't alpha-stack rather than being pre-dropped. The directional light projects in parallel; each pointLights entry with castShadow: true casts an additional radial shadow (each vertex projected along its own ray from the light position), and point-light passes always project the caster silhouette — projecting individual back-faces leaves the contact footprint unshadowed under radial divergence. 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 shadows). A lone directional light reduces this to the ambient-only fill (unchanged). All of a receiver FACE's lights are merged into one SVG per face so overlapping shadows composite correctly: a single-light face paints its remaining color directly (one path); a multi-light solid face paints a base = full-lit color C then each light as a mix-blend-mode: multiply layer with factor remaining/C, so the both-blocked overlap becomes C·∏factor (ambient only). mix-blend-mode works within one SVG but NOT across SVGs (preserve-3d isolates each SVG against a transparent backdrop — verified), which is why the merge is per-face rather than per-light. Textured receivers (per-pixel base, no uniform multiply) fall back to per-pass alpha layers that cumulatively darken. The per-face color uses the face CENTROID direction (matching the baked per-polygon shading) so the base leaves no visible color box. The per-face merge is the shared core helper computeMergedReceiverShadows (runs every light pass + aggregates each face into one SVG descriptor); all three renderers call it and only emit the <svg>/<path> nodes, so multi-light overlap is identical everywhere. Moving a light or changing caster/receiver geometry re-emits the shadow SVGs; this is DOM/SVG work only and does not redraw texture atlases.
Receiver-shadow geometry has two caster paths. The default per-mesh silhouette fast path (caster ≠ receiver, ≥40 polys) projects one outline per caster instead of every front-facing triangle — but only when the caster's silhouette under the current light is a clean union of simple closed loops (every silhouette vertex shared by exactly two silhouette edges). Meshes whose silhouette has non-manifold / T-junction / open-boundary vertices (imported architecture like the castle) fall back to the per-polygon union, which is gap-free for any topology. Light-back-facing caster polygons are normally culled (single-sided casting, correct for clean closed meshes); the per-poly path casts double-sided (skips that cull) for two cases — cross-mesh casters whose silhouette is unreliable, and ALL self-shadow casters (caster = receiver) — so badly-wound / single-sided interior walls don't leave holes. Closed meshes are unaffected by double-siding: their far back-faces sit below each lit receiver plane and get above-plane-culled, adding no spurious shadow.
Parametric shadows (all three renderers, opt-in via shadow.parametric + shadow.definition). A lightweight approximation that replaces a caster's per-poly/silhouette projection with low-resolution coverage-contour loops, so a complex caster emits far fewer shadow-path vertices. 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 for the normal receiver projector. Cross-mesh casting uses ONE flat proxy outline slid toward the light so it clears receivers below the caster (footprint is depth-invariant under directional projection); self-shadow uses depth-stratified bands, each biased to its far-from-light edge so a face is only shadowed by geometry genuinely in front of it. The approximation is governed by named rubrics measured parametric-vs-exact in bench/shadow-rubric.mjs; the current correction terms are: flat casters route to the exact path (their tilted proxy would project garbage onto a coplanar receiver), convex casters skip self-shadow (a convex surface self-shadows nothing), self-shadow depth-bias (flat band proxies otherwise over-darken), and hole subtraction (coverage holes — courtyards, the coliseum arena — are emitted with opposite winding by nesting depth and the override path preserves that relative winding so they subtract under the receiver's fill-rule: nonzero; the exact path keeps per-loop CCW). Higher definition trades DOM weight for fidelity and is the knob for resolving fine concave holes. Point lights are supported: each shadow-casting point light gets its own RADIAL override silhouette built from the caster-centroid → light direction (the small-object approximation point shading already uses), stored per-light in overridePointSilhouettes and selected per pass; the directional override is the wrong outline for a finite-distance light, so point passes never reuse it. Parametric does not change the exact path, which remains the default. Render style is selectable via shadow.style: "vector" (default) traces the smooth concave contour; "pixel" greedy-meshes the coverage mask into axis-aligned rectangles for a blocky/voxel shadow — holes (courtyards, the coliseum arena) fall out for free as absent cells (no winding/fill-rule work), and definition becomes the pixel-grid resolution (lower → chunkier; the block size is the aesthetic). Both styles share the per-mesh def, point-light, and progressive machinery. The override builder (buildParametricCasterOverride + isFlatCaster/isConvexCaster) lives in core so all three renderers produce byte-identical loops — vanilla receiverShadow.ts, React PolyMesh.tsx, and Vue PolyMesh.ts each just call it in their caster loop and drop the result on the ReceiverCasterInput. Per-mesh shadowDefinition is a PolyMeshTransform field (vanilla), a <PolyMesh shadowDefinition> prop (React), and a shadow-definition prop (Vue). The ONE intentional asymmetry: progressive dragDefinition is vanilla-only, because it's a createPolyScene orchestration concern (auto-detect a light-direction change → emit at the drag def → debounced full-def refine). React/Vue are declarative and re-render on prop change, so an app gets the same effect idiomatically by lowering shadow.definition in its own state during a drag and restoring it at rest. Animated shadows (shadow.followAnimation, all three renderers): a caster's shadow normally FREEZES during a same-topology deform — re-projecting every frame is expensive — so the default is frozen and followAnimation opts into tracking the pose (pair with a low parametric definition). Vanilla re-emits (throttled ~12fps) from the freshly-deformed polygons in setPolygons; React/Vue gate the caster re-registration (a same-topology polygon change is skipped unless followAnimation, so the receiver re-emits only when following). Topology changes (different polygon count) always re-emit regardless.
Cost + the laggless levers. Cross-mesh/floor cast shadows are cheap (one outline, ~1 receiver face) and follow a moving light at 60fps+; camera orbit is free (shadows ride the scene transform — only light/geometry changes re-emit). SELF-shadow is the expensive case: it projects every depth band onto every one of the mesh's own coplanar faces, so the recompute is O(faces × bands × points) and that work is irreducible by spatial culling (the depth-band proxies span the whole mesh, so their AABBs overlap nearly every face — a broad-phase cull was measured to reject nothing). The lever for smooth interaction is therefore reducing quality during motion, not a faster projector. Two knobs make this configurable:
- Per-mesh
PolyMeshTransform.shadowDefinitionoverrides the sceneshadow.definitionfor one mesh's cast/self shadow — a detailed caster stays sharp while a simple prop runs cheap in the same scene. shadow.dragDefinitionis the library's built-in progressive refinement: when set (andparametric), a light-DIRECTION change emits atmin(definition, dragDefinition)for a laggless drag (castle ~100fps at 16 vs 36fps at 96), then a debounced pass re-emits at fulldefinitiononce the light settles — the same atlas-rebake-at-rest escape hatch. It's auto-detected insetOptions(direction change = motion; an appearance edit like thedefinitionslider renders full immediately). Per-mesh def composes: motion caps each mesh atmin(its def, dragDefinition).
The fps↔definition curve and per-phase breakdown are measured with bench/shadow-trace.html + the chrome-trace skill; bench/shadow-parametric.html exposes both knobs (definition, dragDef sliders; ?def=, ?dragdef=).
The .vox fast path emits plain <b> elements inside .polycss-voxel-face wrappers. They intentionally reuse the cheap quad tag; each visible quad has one matrix3d(...), with same-color shared-edge overscan folded into the local left/top/width/height before matrix generation. The face wrappers are grouping nodes for cheap add/remove and are not render-strategy leaves. 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.
The scene takes one directionalLight, one ambientLight, and zero or more pointLights (PolyPointLight[]). Point lights 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 + ambient terms. This deliberately omits CSS gradients: point lights shade flat-per-face (an accepted approximation vs three.js's per-fragment PointLight(distance:0, decay:0); exact for small faces / distant lights). Point lights are baked-mode only — the dynamic mode's zero-JS light move can't express a per-face direction that varies with position, so dynamic scenes ignore pointLights entirely: not for surface shading, and not for shadows. (A point light casting a shadow onto a floor those same lights never lit would read as broken, so dynamic shadows are directional-only — see the lighting modes below.)
- Baked. Lambert (directional + each point light + ambient) is computed once on the CPU per polygon, multiplied into the inline
color(for<b>/<i>/<u>) or into the rasterised atlas pixels (for atlas-backed<s>). Direct image<s>leaves preserve source pixels and usetexturePresentation.lighting="source"; scene-lit direct images fall back to the atlas path. Moving a light requires explicit re-rasterising of affected lit atlas polys viamesh.rebakeAtlas()— the atlas bake (canvas raster + asynctoBlob) is the one expensive step, so the vanilla imperative API does NOT auto-rebake the lit surface on asetOptions({directionalLight})/ point-light change; that keeps high-frequency light drags fast (the caller rebakes, typically debounced to drag-end). Cast shadows ARE cheap (CPU-projected SVG paths) so they re-emit automatically on any light change — direction, intensity, or color (intensity 0 removes the shadow) — and follow the light interactively even while the baked lit side stays frozen. Renderer asymmetry: the declarative React/Vue components re-render → auto-rebake the lit surface on any light prop change; vanilla freezes it until an explicitrebakeAtlas(). This is intentional (vanilla keeps the fast-drag escape hatch); for live/animated lights prefer dynamic mode. Left as-is by design — do not "fix" the asymmetry by making vanilla auto-rebake without explicit approval. - Dynamic. 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. CSScalc()resolves the Lambert dot product and per-channel tint at paint time. Moving a light mutates scene-root vars for surface lighting — zero JS, no atlas redraw. Point lights are not represented in dynamic mode at all — neither surface shading nor shadows (see above). Cast shadows are directional-only in dynamic mode (CPU-projected SVG paths, ambient fill) and re-emit when the directional light changes.
All solid and atlas-backed tags work in both modes. Direct image <s> leaves are source-lit only; callers that need scene lighting use the atlas backend. 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.
- 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 favors low DOM render cost. The default
"lossy"loadMesh/ core import path first bakes solid texture swatches, merges visually redundant baked swatch colors, and tries endpoint-preserving static triangle simplification for eligible non-animated meshes. It then scores exact and approximate merge candidates by estimated render cost and keeps the cheapest direct candidate. Static simplification has a relaxed seam-key pass plus a stricter source-vertex fallback, and is accepted only when the final optimized DOM leaf count is lower than the baseline optimizer result. The polygon optimizer can also try a more aggressive triangle-pair merge candidate inside the same boundary displacement budget, but accepts it only when the render-cost win is material and whole-mesh seam diagnostics do not get worse. STL parse results are the conservative exception: they keep the lossless optimizer path and skip ray-based interior culling because public CAD/STL corpora frequently contain shell, winding, or topology quirks where false-positive culling is a visible data-loss bug. It avoids per-candidate seam-repair passes in the import path; targeted seam repair remains a lower-level helper for explicit repair workflows. - Fill ratio matters. An atlas-backed textured polygon's atlas slice equals its local-2D bounding rect. Empty space inside that slice is wasted bitmap pixels. Direct image leaves avoid atlas memory, but only for source-exact surfaces with preserved source metadata and source lighting. Prefer atlas 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.
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 pins each mounted triangle's baked color while transforms animate. Recomputing Lambert from every deformed low-poly face normal creates visible color pumping, so color refresh is internal opt-in rather than the default animation behavior. 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/STL/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.
- Brand text is PolyCSS. Keep lowercase
polycssonly for literal package names, import paths, CSS classes, domains, and other code identifiers. - Every public export gets a
Polyprefix. 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,PolyIframe. - Types:
PolyDirectionalLight,PolyPointLight,PolyAmbientLight,PolyTextureLightingMode,PolyTextureLeafSizing,PolyTextureBackend,PolyTextureImageRendering,PolyTextureImageLighting,PolyTextureProjection,PolyTexturePresentation,PolyTextureImageSource,PolyCameraProjection,PolyCameraSnapshot,PolyCameraSnapshotStats,PolyMeshTransformInput,PolySceneTransformInput,PolyAnimationMixer,PolyRenderStats. - Functions:
findPolyMeshHandle,injectPolyBaseStyles,collectPolyRenderStats,collectPolyTextureReadiness,queryPolyLeaves,resolvePolyTextureLeafGeometry,resolvePolyTextureImageSource,resolvePolyTexturePresentation,resolvePolyTextureImageRendering,buildPolyCameraSceneTransform,buildPolyMeshTransform,buildPolySceneTransform,capturePolyCameraSnapshot,polyCameraTargetToCss,resolvePolyCameraAppliedPerspectiveStyle,worldPositionToCss,worldPositionToPolyCss,cssPositionToWorld,polyCssPositionToWorld,worldDistanceToCss,worldDistanceToPolyCss,cssDistanceToWorld,polyCssDistanceToWorld,worldDirectionToCss,worldDirectionToPolyCss,worldDirectionalLightToCss,worldDirectionalLightToPolyCss,exportPolySceneSnapshot. - Vanilla factories:
create*names stay as-is (createPolyScene,createTransformControls,createSelect). - HTML custom elements:
poly-prefix + kebab-case. Existing tags:<poly-scene>,<poly-mesh>,<poly-iframe>,<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>). <poly-iframe>: flat textured "quad" whose "texture" is a live document (an<iframe>) instead of an atlas slice. NOT a render-strategy leaf — same transform conventions as<poly-mesh>(position/rotation/scalepost-parity; iframe content centered at the wrapper's local origin so rotation/scale pivot at the visible center). Mounted as a child of.polycss-sceneand inherits the camera transform.- 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. PolyCamerais a kept alias forPolyOrthographicCamera— the ergonomic default, optimised for iso/voxel/diagrammatic scenes which is PolyCSS's structural strength. Not deprecated.
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.
The DOM snapshot exporter is the current exception to mirrored React/Vue public exports: exportPolySceneSnapshot lives in @layoutit/polycss because it is browser DOM serialization, not component API. React/Vue callers import it from @layoutit/polycss and pass the rendered .polycss-camera / .polycss-scene element.
Renderer-owned browser glue. The canvas atlas pipeline (buildAtlasPages + helpers), browser-feature detection (isBorderShapeSupported, isSolidTriangleSupported, resolveSolidTrianglePrimitive), direct voxel renderer (voxelRenderer.ts), and injected .polycss-scene / .polycss-camera base styles exist as independent copies across the three renderers. This includes packages/polycss/src/render/atlas/, packages/react/src/scene/atlas/, packages/vue/src/scene/atlas/, the three renderer-local voxelRenderer.ts files, and the three sibling styles.ts files. This is deliberate — each renderer is self-contained on its dep graph (React/Vue do not import from the polycss package). 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
polycssfactory, both bindings expose it. - If I renamed a
coreexport, every package that imports it is updated. - 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 likewill-change: transformon.polycss-sceneexist 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.mdreflects the new state in this same PR.
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.mdis 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.mdin 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".
- No BC shims. Clean breaks only. No re-export aliases for renamed symbols. No
@deprecatedwrappers. If the API changes, callers update. - This applies even to the multi-package monorepo — all four packages move together.
- Conventional commits format. Single-line subject. No body unless genuinely useful.
- NO
Co-Authored-By: Claudetrailer. - 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.) - Branch names should not use a
codex/prefix. Use plain descriptive branch names unless the user explicitly asks for a different naming convention. - Never push without explicit user approval in the current conversation, even for an existing PR branch or a small follow-up fix. Commit locally and stop for review unless the user clearly asks to push.
- Don't auto-push subagent exploration branches — local commits only. The user pushes when ready.
mainis protected. All work lands via PR.
- 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 testruns the full suite across all four packages.pnpm buildis 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. Runpnpm test && pnpm buildas a unit; treat either failing as "not ready."- CI enforces both gates.
.github/workflows/ci.ymlrunspnpm test+pnpm build:packages+pnpm build:websiteon every PR againstmainand on every push tomain. Don't merge with red CI.
- 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.