From 298c12a53218633586eeebaaa562c7b5406fd693 Mon Sep 17 00:00:00 2001 From: Juan Cruz Fortunatti Date: Sun, 9 Aug 2026 00:09:34 +0200 Subject: [PATCH 01/18] docs(readme): give each package its own README and fix stale claims --- README.md | 12 +- packages/core/README.md | 313 ++++++++++--------------------------- packages/polycss/README.md | 86 ++++++---- packages/react/README.md | 236 ++++++++++++---------------- packages/vue/README.md | 312 ++++++++++++++++++------------------ 5 files changed, 397 insertions(+), 562 deletions(-) diff --git a/README.md b/README.md index df5ac333..25ea1c14 100644 --- a/README.md +++ b/README.md @@ -4,7 +4,7 @@ A CSS polygon mesh library. A 3D engine for the DOM. Renders OBJ/MTL, STL, glTF/ Visit [polycss.com](https://polycss.com) for docs and model examples. -Join [chat.polycss.com](https://chat,polycss.com) for support and community discussions. +Join [chat.polycss.com](https://chat.polycss.com) for support and community discussions. PolyCSS primitives banner @@ -84,7 +84,8 @@ export default function App() { - `position`, `scale`, and `rotation` transform the mesh wrapper. - `autoCenter` shifts the mesh bbox center to local origin. - `meshResolution` chooses `"lossy"` (default) or `"lossless"` optimization. STL imports use the conservative lossless path in both modes. -- `castShadow` emits CSS-projected shadows in dynamic lighting mode. +- `castShadow` emits CPU-projected SVG shadows. It works in both `"baked"` and `"dynamic"` lighting modes; dynamic-mode shadows are directional-only. +- `shadowDefinition` overrides the scene's parametric shadow resolution for this mesh. ### Controls @@ -179,6 +180,12 @@ const polygons = [ ]; ``` +Authoring `Polygon[]` by hand has real constraints — vertex winding decides +whether a face is visible at all, `color` does not accept CSS named colors, and +non-triangular polygons must be coplanar. Read +[Authoring Polygons](https://polycss.com/core-concepts#authoring-polygons) +before generating geometry. + Render polygons directly when you need per-face DOM events or custom styling: ```tsx @@ -241,6 +248,7 @@ Each visible polygon is emitted as one leaf element; the renderer chooses the le | `@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-fonts` | Fonts + text → extruded 3D `Polygon[]`. Framework-agnostic. | | `@layoutit/polycss-morph` | Prepared-model loading, retained DOM animation, morph targets, skinning, and playback. | ## Made with PolyCSS diff --git a/packages/core/README.md b/packages/core/README.md index 9e379187..5cabf734 100644 --- a/packages/core/README.md +++ b/packages/core/README.md @@ -1,249 +1,102 @@ -# PolyCSS +# @layoutit/polycss-core -A CSS polygon mesh library. A 3D engine for the DOM. Renders OBJ/MTL, STL, glTF/GLB, and VOX as real HTML elements transformed with CSS `matrix3d(...)`. Supports colors, textures, lighting, shadows, shapes and animations. Works with React, Vue or plain JavaScript. +The pure-math core of [PolyCSS](https://polycss.com). Vec3/polygon math, scene +and camera math, mesh parsers, mesh optimization, lighting, shadow projection, +and texture-atlas planning — with **zero browser globals** (built against +`lib: ES2020` only). -Visit [polycss.com](https://polycss.com) for docs and model examples. - -PolyCSS primitives banner - -## Installation - -```bash - -# Vanilla -npm install @layoutit/polycss - -# React -npm install @layoutit/polycss-react - -# Vue -npm install @layoutit/polycss-vue - -``` - -You can also load PolyCSS directly from a CDN. Here is a minimal custom-element scene: - -```html - - - - - - - - -``` - -PolyCSS intro - -## Framework Components - -React and Vue expose the same component model. `` owns the viewpoint, `` owns lighting and options, and `` loads or receives polygon data. - -```tsx -import { PolyCamera, PolyScene, PolyOrbitControls, PolyMesh } from "@layoutit/polycss-react"; - -export default function App() { - return ( - - - - - - - ); -} -``` - -## Three.js Parity API - -When porting Three.js scenes or generating code with an agent, use the explicit -`*/three` subpaths: - -- `@layoutit/polycss-core/three` -- `@layoutit/polycss/three` -- `@layoutit/polycss-react/three` -- `@layoutit/polycss-vue/three` - -They expose Three-like `PerspectiveCamera`, `OrthographicCamera`, `Object3D`, -`Vector3`, `DirectionalLight`, `PointLight`, `AmbientLight`, radians for object -rotations, Y-up authoring coordinates, and `camera.position` + `camera.lookAt(...)` -framing. The adapters convert into native PolyCSS coordinates with a right-handed -axis map, so the apparent object size, projection, orientation, depth ordering, -and light direction line up with Three.js scene math while still rendering -through the DOM. - -```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 ( - - - - - - ); -} -``` - -Full reference: [polycss.com/api/three-parity](https://polycss.com/api/three-parity). - -## API Reference - -### PolyCamera - -- `rotX`, `rotY` control the orbit angle in degrees. -- `zoom` scales the projected scene. -- `target` pans the camera target in world coordinates. -- `distance` adds dolly pull-back. -- `PolyCamera` is the orthographic default. Use `PolyPerspectiveCamera` when you want perspective depth. - -### PolyScene - -- `polygons` renders a static `Polygon[]` directly. -- `directionalLight`, `pointLights` (direction-only, baked mode; optional per-light `castShadow`), and `ambientLight` control scene lighting. -- `textureLighting` chooses `"baked"` or `"dynamic"`. -- `textureQuality` controls atlas raster budget. -- `strategies` can disable selected render strategies for diagnostics. -- `autoCenter` rotates around the rendered mesh bounds instead of world origin. - -### PolyMesh +This package does not render anything. It has no DOM access, emits no elements, +and injects no CSS. If you want to draw a scene, install a renderer instead: -- `src` loads `.obj`, `.gltf`, `.glb`, or `.vox` files. -- `mtl` loads companion OBJ materials. -- `polygons` accepts pre-parsed geometry. -- `position`, `scale`, and `rotation` transform the mesh wrapper. -- `autoCenter` shifts the mesh bbox center to local origin. -- `meshResolution` chooses `"lossy"` (default) or `"lossless"` optimization. STL imports use the conservative lossless path in both modes. -- `castShadow` emits CSS-projected shadows in dynamic lighting mode. - -### Controls - -- `` adds drag orbit, shift-drag pan, wheel zoom, and optional auto-rotate. -- `` uses pan-first map-style input. -- `` provides keyboard and pointer-look navigation. -- `` adds translate/rotate gizmos for selected mesh handles. - -### Snapshot Export - -The vanilla package exports `exportPolySceneSnapshot(target)`. It clones the current rendered `.polycss-camera` / `.polycss-scene` DOM, injects only the PolyCSS CSS needed by that snapshot, inlines CSS `url(...)` image assets as `data:image/...;base64,...`, strips scripts and inline event handlers, and returns a standalone HTML document string with no PolyCSS runtime import. It works with rendered React/Vue scenes too; import it from `@layoutit/polycss` and pass the rendered camera or scene element. - -```ts -import { exportPolySceneSnapshot } from "@layoutit/polycss"; - -const html = await exportPolySceneSnapshot(scene.host); -``` - -If any referenced asset cannot be inlined, the function throws `PolySceneSnapshotError` with `code: "ASSET_INLINE_FAILED"`. +| Package | Use it for | +|---|---| +| [`@layoutit/polycss`](https://www.npmjs.com/package/@layoutit/polycss) | Vanilla JS renderer + custom elements (``) | +| [`@layoutit/polycss-react`](https://www.npmjs.com/package/@layoutit/polycss-react) | React components and hooks | +| [`@layoutit/polycss-vue`](https://www.npmjs.com/package/@layoutit/polycss-vue) | Vue 3 components and composables | -### Polygon Data Model +All three renderers depend on this package and re-export most of its surface, so +you rarely install it directly. Reach for it when you need PolyCSS geometry work +**outside a browser** — a Node build step, a worker, a test, a server-side mesh +pipeline, or your own renderer. -Each polygon describes one renderable face: +Visit [polycss.com](https://polycss.com) for docs and model examples. -```ts -const polygons = [ - { - vertices: [[0, 0, 0], [60, 0, 0], [0, 60, 0]], - color: "#f97316", - }, - { - vertices: [[0, 0, 0], [60, 0, 0], [60, 60, 0], [0, 60, 0]], - texture: "/texture.png", - uvs: [[0, 0], [1, 0], [1, 1], [0, 1]], - }, -]; -``` +## Installation -Render polygons directly when you need per-face DOM events or custom styling: - -```tsx - - - {polygons.map((polygon, index) => ( - console.log("clicked polygon", index)} - className="my-polygon" - /> - ))} - - +```bash +npm install @layoutit/polycss-core ``` -## Loading Mesh Files +## Parsing a mesh without a browser -Use `loadMesh()` to parse supported model formats: +`loadMesh` and the individual parsers are pure functions over bytes and strings, +so they run under Node: ```ts -import { createPolyCamera, createPolyScene, loadMesh } from "@layoutit/polycss"; +import { parseObj, optimizeMeshPolygons } from "@layoutit/polycss-core"; -const host = document.getElementById("polycss")!; -const camera = createPolyCamera({ rotX: 65, rotY: 45 }); -const scene = createPolyScene(host, { camera }); +const result = parseObj(await readFile("cottage.obj", "utf8")); +const optimized = optimizeMeshPolygons(result.polygons, { meshResolution: "lossy" }); -const mesh = await loadMesh("https://polycss.com/gallery/obj/cottage.obj", { - mtlUrl: "https://polycss.com/gallery/obj/cottage.mtl", -}); - -scene.add(mesh); +console.log(result.polygons.length, "→", optimized.length); ``` -Supported formats: - -- OBJ + MTL, including `map_Kd` textures and UV coordinates. -- STL triangle meshes, including binary Magics face colors. STL has no standard units, textures, UVs, or hierarchy, so imports skip lossy simplification and ray-based interior culling. -- glTF / GLB, including embedded images and `TEXCOORD_0`. -- MagicaVoxel `.vox`, with direct voxel fast paths when eligible. -- Generated primitives: box, plane, ring, sphere, torus, cylinder, cone, and Platonic solids. - -## Performance - -PolyCSS renders through the DOM, so performance is mostly shaped by two things: the number of mounted leaves, and the amount of texture atlas area the browser has to paint. The renderer tries to keep the common cases cheap. Simple surfaces stay as solid CSS elements, while textured, irregular, or high-detail geometry falls back to atlas-backed slices only when needed. - -Each visible polygon is emitted as one leaf element; the renderer chooses the least expensive CSS primitive that can represent the polygon, then uses `matrix3d(...)` to place that primitive in 3D space. - -- `` uses `background: currentColor` on a fixed box for solid rectangles and stable quads. -- `` uses `corner-shape` for stable triangles and beveled-corner solids, with a `border-width` triangle fallback when needed. -- `` clips solid polygons with `border-shape: polygon(...)` when the browser supports it. -- `` maps a packed texture-atlas slice with `background-image`, and is the fallback for textured or unsupported shapes. - -## 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. | - -## Made with PolyCSS - -[cssQuake](https://cssquake.com) --> A CSS port of Quake (1996) - -quake - - -[Layoutit Terra](https://terra.layoutit.com) --> A CSS Terrain Generator - -layoutit-terra +Supported formats: OBJ (+ MTL), STL (ASCII and binary, including Magics face +colors), glTF / GLB (embedded images, `TEXCOORD_0`), and MagicaVoxel `.vox`. +`loadMesh` fetches and dispatches by extension; the `parse*` functions take +already-loaded input. + +## What's in here + +- **Types** — `Polygon`, `PolyMaterial`, `Vec2`, `Vec3`, the `PolyTexture*` + presentation types, `PolyDirectionalLight`, `PolyPointLight`, + `PolyAmbientLight`, `PolyTextureLightingMode`, `MeshResolution`. +- **Scene + camera math** — `buildSceneContext`, `computeSceneBbox`, + `normalizePolygons`, `createIsometricCamera`, `buildPolyCameraSceneTransform`, + `capturePolyCameraSnapshot`, `screenToWorldRay`, `screenToWorldOnSphere`, + `BASE_TILE`. +- **Transforms** — `buildPolyMeshTransform`, `buildPolySceneTransform`, + rotation and quaternion helpers. +- **Color + lighting** — `parseColor`, `parsePureColor`, `shadeColor`, + `computeShapeLighting`. +- **Primitives** — `boxPolygons`, `planePolygons`, `spherePolygons`, + `cylinderPolygons`, `conePolygons`, `torusPolygons`, `ringPolygons`, + `axesHelperPolygons`, and the Platonic solids. +- **Mesh optimization** — `optimizeMeshPolygons`, `mergePolygons`, + `dedupeOverlappingPolygons`, `cullInteriorPolygons`, + `simplifyTriangleMeshPolygons`, `repairMeshSeams`. +- **Culling** — `polygonFacesCamera`, `polygonCssSurfaceNormal`, + `cameraCullNormalGroups`, and the voxel camera-cull helpers. +- **Shadow projection** — `buildParametricCasterOverride`, + `computeParametricShadowSilhouette`, `computeCoverageShadowSilhouette`, + `projectCssVertexToGround`, `convexHull2D`. +- **Atlas planning** — the pure-math half of the texture atlas pipeline. Canvas + rasterisation itself lives in each renderer, because it needs the DOM. + +Everything exported from `src/index.ts` is the supported surface; anything else +is implementation detail. + +## Authoring polygons directly + +If you build `Polygon[]` by hand, read +[Authoring Polygons](https://polycss.com/core-concepts#authoring-polygons) +first. Three constraints bite immediately: + +- **Winding is CCW seen from the outside.** Vertex order sets the face normal + via the right-hand rule, and PolyCSS backface-culls, so a reversed face is + invisible. +- **`color` accepts hex and `rgb()`/`rgba()` only** — not CSS named colors. +- **Non-triangular polygons must be coplanar.** Renderers do not call + `normalizePolygons` for you. + +## Three.js parity + +`@layoutit/polycss-core/three` exposes Three-like math wrappers (`Vector3`, +`Euler`, `Object3D`, `PerspectiveCamera`, `OrthographicCamera`, +`DirectionalLight`, `PointLight`, `AmbientLight`) plus +`transformPolygonsToPoly` for converting Y-up authoring geometry into native +PolyCSS coordinates. See +[polycss.com/api/three-parity](https://polycss.com/api/three-parity). ## License diff --git a/packages/polycss/README.md b/packages/polycss/README.md index 9e379187..6e70b035 100644 --- a/packages/polycss/README.md +++ b/packages/polycss/README.md @@ -36,25 +36,33 @@ You can also load PolyCSS directly from a CDN. Here is a minimal custom-element PolyCSS intro -## Framework Components +## Imperative API -React and Vue expose the same component model. `` owns the viewpoint, `` owns lighting and options, and `` loads or receives polygon data. +`createPolyCamera` owns the viewpoint, `createPolyScene` owns lighting and +options, and meshes are added to the scene: -```tsx -import { PolyCamera, PolyScene, PolyOrbitControls, PolyMesh } from "@layoutit/polycss-react"; +```ts +import { + createPolyBox, + createPolyCamera, + createPolyOrbitControls, + createPolyScene, +} from "@layoutit/polycss"; -export default function App() { - return ( - - - - - - - ); -} +const host = document.getElementById("polycss")!; +const camera = createPolyCamera({ rotX: 65, rotY: 45 }); +const scene = createPolyScene(host, { camera, textureLighting: "dynamic" }); + +createPolyOrbitControls(scene, { drag: true, wheel: true }); + +scene.add(createPolyBox({ size: 100, color: "#ffd166" })); ``` +Using React or Vue instead? Install +[`@layoutit/polycss-react`](https://www.npmjs.com/package/@layoutit/polycss-react) +or [`@layoutit/polycss-vue`](https://www.npmjs.com/package/@layoutit/polycss-vue), +which expose the same model as components. + ## Three.js Parity API When porting Three.js scenes or generating code with an agent, use the explicit @@ -130,14 +138,29 @@ Full reference: [polycss.com/api/three-parity](https://polycss.com/api/three-par - `position`, `scale`, and `rotation` transform the mesh wrapper. - `autoCenter` shifts the mesh bbox center to local origin. - `meshResolution` chooses `"lossy"` (default) or `"lossless"` optimization. STL imports use the conservative lossless path in both modes. -- `castShadow` emits CSS-projected shadows in dynamic lighting mode. +- `castShadow` emits CPU-projected SVG shadows. It works in both `"baked"` and `"dynamic"` lighting modes; dynamic-mode shadows are directional-only. +- `shadowDefinition` overrides the scene's parametric shadow resolution for this mesh. ### Controls -- `` adds drag orbit, shift-drag pan, wheel zoom, and optional auto-rotate. -- `` uses pan-first map-style input. -- `` provides keyboard and pointer-look navigation. -- `` adds translate/rotate gizmos for selected mesh handles. +- `createPolyOrbitControls(scene, opts)` adds drag orbit, shift-drag pan, wheel zoom, and optional auto-rotate. +- `createPolyMapControls(scene, opts)` uses pan-first map-style input. +- `createPolyFirstPersonControls(scene, opts)` provides keyboard and pointer-look navigation. +- `createTransformControls(scene, opts)` adds translate/rotate gizmos for selected mesh handles. +- `createSelect(scene, opts)` adds pointer picking over mesh handles. + +### PolyIframe + +The `` custom element renders a live document as a flat quad inside +the scene, using the same `position` / `rotation` / `scale` conventions as a +mesh. Its content is centered on the wrapper's local origin, so rotation and +scale pivot at the visible center. React and Vue expose it as ``. + +```html + + + +``` ### Snapshot Export @@ -169,23 +192,18 @@ const polygons = [ ]; ``` -Render polygons directly when you need per-face DOM events or custom styling: +Pass them straight to the scene: -```tsx - - - {polygons.map((polygon, index) => ( - console.log("clicked polygon", index)} - className="my-polygon" - /> - ))} - - +```ts +const scene = createPolyScene(host, { camera, polygons }); ``` +Authoring `Polygon[]` by hand has real constraints — winding decides visibility, +`color` does not accept CSS named colors, and non-triangular polygons must be +coplanar. Read +[Authoring Polygons](https://polycss.com/core-concepts#authoring-polygons) +before you generate geometry. + ## Loading Mesh Files Use `loadMesh()` to parse supported model formats: @@ -231,6 +249,8 @@ Each visible polygon is emitted as one leaf element; the renderer chooses the le | `@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-fonts` | Fonts + text → extruded 3D `Polygon[]`. Framework-agnostic. | +| `@layoutit/polycss-morph` | Prepared-model loading, retained DOM animation, morph targets, skinning, and playback. | ## Made with PolyCSS diff --git a/packages/react/README.md b/packages/react/README.md index 9e379187..52f123df 100644 --- a/packages/react/README.md +++ b/packages/react/README.md @@ -1,6 +1,8 @@ -# PolyCSS +# @layoutit/polycss-react -A CSS polygon mesh library. A 3D engine for the DOM. Renders OBJ/MTL, STL, glTF/GLB, and VOX as real HTML elements transformed with CSS `matrix3d(...)`. Supports colors, textures, lighting, shadows, shapes and animations. Works with React, Vue or plain JavaScript. +React bindings for [PolyCSS](https://polycss.com) — a 3D engine for the DOM. +Renders OBJ/MTL, STL, glTF/GLB, and VOX meshes as real HTML elements +transformed with CSS `matrix3d(...)`. No WebGL, no canvas-per-frame. Visit [polycss.com](https://polycss.com) for docs and model examples. @@ -9,36 +11,13 @@ Visit [polycss.com](https://polycss.com) for docs and model examples. ## Installation ```bash - -# Vanilla -npm install @layoutit/polycss - -# React npm install @layoutit/polycss-react - -# Vue -npm install @layoutit/polycss-vue - -``` - -You can also load PolyCSS directly from a CDN. Here is a minimal custom-element scene: - -```html - - - - - - - - ``` -PolyCSS intro - -## Framework Components +## Quick start -React and Vue expose the same component model. `` owns the viewpoint, `` owns lighting and options, and `` loads or receives polygon data. +`` owns the viewpoint, `` owns lighting and options, and +`` loads or receives polygon data. ```tsx import { PolyCamera, PolyScene, PolyOrbitControls, PolyMesh } from "@layoutit/polycss-react"; @@ -55,103 +34,88 @@ export default function App() { } ``` -## Three.js Parity API - -When porting Three.js scenes or generating code with an agent, use the explicit -`*/three` subpaths: - -- `@layoutit/polycss-core/three` -- `@layoutit/polycss/three` -- `@layoutit/polycss-react/three` -- `@layoutit/polycss-vue/three` - -They expose Three-like `PerspectiveCamera`, `OrthographicCamera`, `Object3D`, -`Vector3`, `DirectionalLight`, `PointLight`, `AmbientLight`, radians for object -rotations, Y-up authoring coordinates, and `camera.position` + `camera.lookAt(...)` -framing. The adapters convert into native PolyCSS coordinates with a right-handed -axis map, so the apparent object size, projection, orientation, depth ordering, -and light direction line up with Three.js scene math while still rendering -through the DOM. - -```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 ( - - - - - - ); -} -``` - -Full reference: [polycss.com/api/three-parity](https://polycss.com/api/three-parity). +PolyCSS intro -## API Reference +## Components -### PolyCamera +### `` - `rotX`, `rotY` control the orbit angle in degrees. -- `zoom` scales the projected scene. +- `zoom` is CSS pixels per world unit. - `target` pans the camera target in world coordinates. - `distance` adds dolly pull-back. -- `PolyCamera` is the orthographic default. Use `PolyPerspectiveCamera` when you want perspective depth. +- `PolyCamera` is the orthographic default. Use `` for + perspective depth, or `` for the explicit name. -### PolyScene +### `` - `polygons` renders a static `Polygon[]` directly. -- `directionalLight`, `pointLights` (direction-only, baked mode; optional per-light `castShadow`), and `ambientLight` control scene lighting. +- `directionalLight`, `pointLights` (direction-only, baked mode; optional + per-light `castShadow`), and `ambientLight` control scene lighting. - `textureLighting` chooses `"baked"` or `"dynamic"`. - `textureQuality` controls atlas raster budget. +- `shadow` configures cast-shadow color, opacity, and the parametric shadow + knobs (`parametric`, `definition`, `style`, `followAnimation`). - `strategies` can disable selected render strategies for diagnostics. - `autoCenter` rotates around the rendered mesh bounds instead of world origin. -### PolyMesh +Unlike the vanilla renderer, React re-renders on prop change, so a light change +**auto-rebakes** the lit surface in baked mode. For live or animated lights, +prefer `textureLighting="dynamic"`. + +### `` -- `src` loads `.obj`, `.gltf`, `.glb`, or `.vox` files. +- `src` loads `.obj`, `.stl`, `.gltf`, `.glb`, or `.vox` files. - `mtl` loads companion OBJ materials. - `polygons` accepts pre-parsed geometry. - `position`, `scale`, and `rotation` transform the mesh wrapper. -- `autoCenter` shifts the mesh bbox center to local origin. -- `meshResolution` chooses `"lossy"` (default) or `"lossless"` optimization. STL imports use the conservative lossless path in both modes. -- `castShadow` emits CSS-projected shadows in dynamic lighting mode. +- `autoCenter` shifts the mesh bbox center to local origin. Note this rewrites + vertex data, so `getPolygons()` returns the shifted coordinates. +- `meshResolution` chooses `"lossy"` (default) or `"lossless"` optimization. + STL imports use the conservative lossless path in both modes. +- `merge` (default `true`) runs the polygon optimizer. Set `false` to keep + authored geometry exactly as given. +- `castShadow` / `receiveShadow` opt the mesh into CPU-projected SVG shadows. +- `shadowDefinition` overrides the scene parametric shadow resolution for this + mesh. + +### `` + +Renders a live document as a flat quad in the scene, with 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. + +```tsx + +``` ### Controls -- `` adds drag orbit, shift-drag pan, wheel zoom, and optional auto-rotate. +- `` adds drag orbit, shift-drag pan, wheel zoom, and + optional auto-rotate. - `` uses pan-first map-style input. - `` provides keyboard and pointer-look navigation. -- `` adds translate/rotate gizmos for selected mesh handles. +- `` adds translate/rotate gizmos for selected mesh + handles. +- `` adds pointer picking; pair with `usePolySelect` / + `usePolySelectionApi`. -### Snapshot Export +### Helpers -The vanilla package exports `exportPolySceneSnapshot(target)`. It clones the current rendered `.polycss-camera` / `.polycss-scene` DOM, injects only the PolyCSS CSS needed by that snapshot, inlines CSS `url(...)` image assets as `data:image/...;base64,...`, strips scripts and inline event handlers, and returns a standalone HTML document string with no PolyCSS runtime import. It works with rendered React/Vue scenes too; import it from `@layoutit/polycss` and pass the rendered camera or scene element. +``, ``, ``, and the +`` shape component for one-off polygons. -```ts -import { exportPolySceneSnapshot } from "@layoutit/polycss"; - -const html = await exportPolySceneSnapshot(scene.host); -``` +## Hooks -If any referenced asset cannot be inlined, the function throws `PolySceneSnapshotError` with `code: "ASSET_INLINE_FAILED"`. +- `usePolyCamera` — read/drive the camera handle from the context. +- `usePolySceneContext` — access the underlying scene handle. +- `usePolyMesh` — load a mesh imperatively; returns `{ polygons, voxelSource, + loading, error, warnings, dispose }`, where `error` is an `Error | null`. +- `usePolyMaterial` — resolve material state for a mesh. +- `usePolySelect`, `usePolySelectionApi` — selection state and imperative API. +- `usePolyAnimation` — drive imported skeletal/morph clips. -### Polygon Data Model +## Polygon data model Each polygon describes one renderable face: @@ -186,42 +150,55 @@ Render polygons directly when you need per-face DOM events or custom styling: ``` -## Loading Mesh Files +Authoring `Polygon[]` by hand has real constraints — vertex winding decides +whether a face is visible at all, `color` does not accept CSS named colors, and +non-triangular polygons must be coplanar. Read +[Authoring Polygons](https://polycss.com/core-concepts#authoring-polygons) +before generating geometry. -Use `loadMesh()` to parse supported model formats: +## Three.js parity API -```ts -import { createPolyCamera, createPolyScene, loadMesh } from "@layoutit/polycss"; +When porting Three.js scenes or generating code with an agent, use the explicit +`@layoutit/polycss-react/three` subpath. It exposes `PolyThreePerspectiveCamera`, +`PolyThreeOrthographicCamera`, `PolyThreeMesh`, and the Three-like light classes, +with radians for object rotations and Y-up authoring coordinates. -const host = document.getElementById("polycss")!; -const camera = createPolyCamera({ rotX: 65, rotY: 45 }); -const scene = createPolyScene(host, { camera }); +```tsx +import { PolyScene } from "@layoutit/polycss-react"; +import { + DirectionalLight, + PolyThreeMesh, + PolyThreePerspectiveCamera, +} from "@layoutit/polycss-react/three"; -const mesh = await loadMesh("https://polycss.com/gallery/obj/cottage.obj", { - mtlUrl: "https://polycss.com/gallery/obj/cottage.mtl", -}); +const sun = new DirectionalLight("#ffffff", 1); +sun.position.set(3, 5, 4); +sun.target.position.set(0, 0, 0); -scene.add(mesh); +export function App() { + return ( + + + + + + ); +} ``` -Supported formats: - -- OBJ + MTL, including `map_Kd` textures and UV coordinates. -- STL triangle meshes, including binary Magics face colors. STL has no standard units, textures, UVs, or hierarchy, so imports skip lossy simplification and ray-based interior culling. -- glTF / GLB, including embedded images and `TEXCOORD_0`. -- MagicaVoxel `.vox`, with direct voxel fast paths when eligible. -- Generated primitives: box, plane, ring, sphere, torus, cylinder, cone, and Platonic solids. +Full reference: [polycss.com/api/three-parity](https://polycss.com/api/three-parity). ## Performance -PolyCSS renders through the DOM, so performance is mostly shaped by two things: the number of mounted leaves, and the amount of texture atlas area the browser has to paint. The renderer tries to keep the common cases cheap. Simple surfaces stay as solid CSS elements, while textured, irregular, or high-detail geometry falls back to atlas-backed slices only when needed. - -Each visible polygon is emitted as one leaf element; the renderer chooses the least expensive CSS primitive that can represent the polygon, then uses `matrix3d(...)` to place that primitive in 3D space. +Each visible polygon is emitted as one leaf element; the renderer chooses the +least expensive CSS primitive that can represent it, then uses `matrix3d(...)` +to place that primitive in 3D space. Polygon count is the dominant cost. -- `` uses `background: currentColor` on a fixed box for solid rectangles and stable quads. -- `` uses `corner-shape` for stable triangles and beveled-corner solids, with a `border-width` triangle fallback when needed. -- `` clips solid polygons with `border-shape: polygon(...)` when the browser supports it. -- `` maps a packed texture-atlas slice with `background-image`, and is the fallback for textured or unsupported shapes. +- `` uses `background: currentColor` for solid rectangles and stable quads. +- `` uses `corner-shape` for stable triangles and beveled-corner solids. +- `` clips solid polygons with `border-shape: polygon(...)` where supported. +- `` maps a packed texture-atlas slice, and is the fallback for textured or + unsupported shapes. ## Packages @@ -231,19 +208,8 @@ Each visible polygon is emitted as one leaf element; the renderer chooses the le | `@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. | - -## Made with PolyCSS - -[cssQuake](https://cssquake.com) --> A CSS port of Quake (1996) - -quake - - -[Layoutit Terra](https://terra.layoutit.com) --> A CSS Terrain Generator - -layoutit-terra +| `@layoutit/polycss-fonts` | Fonts + text → extruded 3D `Polygon[]`. Framework-agnostic. | +| `@layoutit/polycss-morph` | Prepared-model loading, retained DOM animation, morph targets, skinning, and playback. | ## License diff --git a/packages/vue/README.md b/packages/vue/README.md index 9e379187..2fab9880 100644 --- a/packages/vue/README.md +++ b/packages/vue/README.md @@ -1,6 +1,8 @@ -# PolyCSS +# @layoutit/polycss-vue -A CSS polygon mesh library. A 3D engine for the DOM. Renders OBJ/MTL, STL, glTF/GLB, and VOX as real HTML elements transformed with CSS `matrix3d(...)`. Supports colors, textures, lighting, shadows, shapes and animations. Works with React, Vue or plain JavaScript. +Vue 3 bindings for [PolyCSS](https://polycss.com) — a 3D engine for the DOM. +Renders OBJ/MTL, STL, glTF/GLB, and VOX meshes as real HTML elements +transformed with CSS `matrix3d(...)`. No WebGL, no canvas-per-frame. Visit [polycss.com](https://polycss.com) for docs and model examples. @@ -9,149 +11,127 @@ Visit [polycss.com](https://polycss.com) for docs and model examples. ## Installation ```bash - -# Vanilla -npm install @layoutit/polycss - -# React -npm install @layoutit/polycss-react - -# Vue npm install @layoutit/polycss-vue - ``` -You can also load PolyCSS directly from a CDN. Here is a minimal custom-element scene: - -```html - - - - - - - - -``` - -PolyCSS intro - -## Framework Components - -React and Vue expose the same component model. `` owns the viewpoint, `` owns lighting and options, and `` loads or receives polygon data. - -```tsx -import { PolyCamera, PolyScene, PolyOrbitControls, PolyMesh } from "@layoutit/polycss-react"; - -export default function App() { - return ( - - - - +## Quick start + +`` owns the viewpoint, `` owns lighting and options, and +`` loads or receives polygon data. + +```vue + -When porting Three.js scenes or generating code with an agent, use the explicit -`*/three` subpaths: - -- `@layoutit/polycss-core/three` -- `@layoutit/polycss/three` -- `@layoutit/polycss-react/three` -- `@layoutit/polycss-vue/three` - -They expose Three-like `PerspectiveCamera`, `OrthographicCamera`, `Object3D`, -`Vector3`, `DirectionalLight`, `PointLight`, `AmbientLight`, radians for object -rotations, Y-up authoring coordinates, and `camera.position` + `camera.lookAt(...)` -framing. The adapters convert into native PolyCSS coordinates with a right-handed -axis map, so the apparent object size, projection, orientation, depth ordering, -and light direction line up with Three.js scene math while still rendering -through the DOM. - -```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 ( - - - - - - ); -} + ``` -Full reference: [polycss.com/api/three-parity](https://polycss.com/api/three-parity). +PolyCSS intro + +## Components -## API Reference +Props are listed in their template (kebab-case) form. -### PolyCamera +### `` -- `rotX`, `rotY` control the orbit angle in degrees. -- `zoom` scales the projected scene. +- `rot-x`, `rot-y` control the orbit angle in degrees. +- `zoom` is CSS pixels per world unit. - `target` pans the camera target in world coordinates. - `distance` adds dolly pull-back. -- `PolyCamera` is the orthographic default. Use `PolyPerspectiveCamera` when you want perspective depth. +- `PolyCamera` is the orthographic default. Use `` for + perspective depth, or `` for the explicit name. -### PolyScene +### `` - `polygons` renders a static `Polygon[]` directly. -- `directionalLight`, `pointLights` (direction-only, baked mode; optional per-light `castShadow`), and `ambientLight` control scene lighting. -- `textureLighting` chooses `"baked"` or `"dynamic"`. -- `textureQuality` controls atlas raster budget. +- `directional-light`, `point-lights` (direction-only, baked mode; optional + per-light `castShadow`), and `ambient-light` control scene lighting. +- `texture-lighting` chooses `"baked"` or `"dynamic"`. +- `texture-quality` controls atlas raster budget. +- `shadow` configures cast-shadow color, opacity, and the parametric shadow + knobs (`parametric`, `definition`, `style`, `followAnimation`). - `strategies` can disable selected render strategies for diagnostics. -- `autoCenter` rotates around the rendered mesh bounds instead of world origin. +- `auto-center` rotates around the rendered mesh bounds instead of world origin. -### PolyMesh +Unlike the vanilla renderer, Vue re-renders on prop change, so a light change +**auto-rebakes** the lit surface in baked mode. For live or animated lights, +prefer `texture-lighting="dynamic"`. -- `src` loads `.obj`, `.gltf`, `.glb`, or `.vox` files. +### `` + +- `src` loads `.obj`, `.stl`, `.gltf`, `.glb`, or `.vox` files. - `mtl` loads companion OBJ materials. - `polygons` accepts pre-parsed geometry. - `position`, `scale`, and `rotation` transform the mesh wrapper. -- `autoCenter` shifts the mesh bbox center to local origin. -- `meshResolution` chooses `"lossy"` (default) or `"lossless"` optimization. STL imports use the conservative lossless path in both modes. -- `castShadow` emits CSS-projected shadows in dynamic lighting mode. +- `auto-center` shifts the mesh bbox center to local origin. Note this rewrites + vertex data, so `getPolygons()` returns the shifted coordinates. +- `mesh-resolution` chooses `"lossy"` (default) or `"lossless"` optimization. + STL imports use the conservative lossless path in both modes. +- `merge` (default `true`) runs the polygon optimizer. Set `false` to keep + authored geometry exactly as given. +- `cast-shadow` / `receive-shadow` opt the mesh into CPU-projected SVG shadows. +- `shadow-definition` overrides the scene parametric shadow resolution for this + mesh. + +### `` + +Renders a live document as a flat quad in the scene, with 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. + +```vue + +``` ### Controls -- `` adds drag orbit, shift-drag pan, wheel zoom, and optional auto-rotate. +- `` adds drag orbit, shift-drag pan, wheel zoom, and + optional auto-rotate. - `` uses pan-first map-style input. - `` provides keyboard and pointer-look navigation. -- `` adds translate/rotate gizmos for selected mesh handles. +- `` adds translate/rotate gizmos for selected mesh + handles. +- `` adds pointer picking; pair with `usePolySelect` / + `usePolySelectionApi`. -### Snapshot Export +### Helpers -The vanilla package exports `exportPolySceneSnapshot(target)`. It clones the current rendered `.polycss-camera` / `.polycss-scene` DOM, injects only the PolyCSS CSS needed by that snapshot, inlines CSS `url(...)` image assets as `data:image/...;base64,...`, strips scripts and inline event handlers, and returns a standalone HTML document string with no PolyCSS runtime import. It works with rendered React/Vue scenes too; import it from `@layoutit/polycss` and pass the rendered camera or scene element. +``, ``, ``, and the +`` shape component for one-off polygons. -```ts -import { exportPolySceneSnapshot } from "@layoutit/polycss"; +## Composables -const html = await exportPolySceneSnapshot(scene.host); -``` +- `usePolyCamera` — read/drive the camera handle from the provided context. +- `usePolySceneContext` — access the underlying scene handle. +- `usePolyMesh` — load a mesh imperatively; exposes polygons, voxel source, + loading, error, and warnings as reactive state. +- `usePolyMaterial` — resolve material state for a mesh. +- `usePolySelect`, `usePolySelectionApi` — selection state and imperative API. +- `usePolyAnimation` — drive imported skeletal/morph clips. -If any referenced asset cannot be inlined, the function throws `PolySceneSnapshotError` with `code: "ASSET_INLINE_FAILED"`. +Injection keys (`PolyCameraContextKey`, `PolySelectionContextKey`) are exported +for components that provide their own context. -### Polygon Data Model +## Polygon data model Each polygon describes one renderable face: @@ -171,57 +151,76 @@ const polygons = [ Render polygons directly when you need per-face DOM events or custom styling: -```tsx - - - {polygons.map((polygon, index) => ( +```vue + ``` -## Loading Mesh Files +Authoring `Polygon[]` by hand has real constraints — vertex winding decides +whether a face is visible at all, `color` does not accept CSS named colors, and +non-triangular polygons must be coplanar. Read +[Authoring Polygons](https://polycss.com/core-concepts#authoring-polygons) +before generating geometry. -Use `loadMesh()` to parse supported model formats: +## Three.js parity API -```ts -import { createPolyCamera, createPolyScene, loadMesh } from "@layoutit/polycss"; - -const host = document.getElementById("polycss")!; -const camera = createPolyCamera({ rotX: 65, rotY: 45 }); -const scene = createPolyScene(host, { camera }); - -const mesh = await loadMesh("https://polycss.com/gallery/obj/cottage.obj", { - mtlUrl: "https://polycss.com/gallery/obj/cottage.mtl", -}); +When porting Three.js scenes or generating code with an agent, use the explicit +`@layoutit/polycss-vue/three` subpath. It exposes `PolyThreePerspectiveCamera`, +`PolyThreeOrthographicCamera`, `PolyThreeMesh`, and the Three-like light classes, +with radians for object rotations and Y-up authoring coordinates. + +```vue + + + ``` -Supported formats: - -- OBJ + MTL, including `map_Kd` textures and UV coordinates. -- STL triangle meshes, including binary Magics face colors. STL has no standard units, textures, UVs, or hierarchy, so imports skip lossy simplification and ray-based interior culling. -- glTF / GLB, including embedded images and `TEXCOORD_0`. -- MagicaVoxel `.vox`, with direct voxel fast paths when eligible. -- Generated primitives: box, plane, ring, sphere, torus, cylinder, cone, and Platonic solids. +Full reference: [polycss.com/api/three-parity](https://polycss.com/api/three-parity). ## Performance -PolyCSS renders through the DOM, so performance is mostly shaped by two things: the number of mounted leaves, and the amount of texture atlas area the browser has to paint. The renderer tries to keep the common cases cheap. Simple surfaces stay as solid CSS elements, while textured, irregular, or high-detail geometry falls back to atlas-backed slices only when needed. - -Each visible polygon is emitted as one leaf element; the renderer chooses the least expensive CSS primitive that can represent the polygon, then uses `matrix3d(...)` to place that primitive in 3D space. +Each visible polygon is emitted as one leaf element; the renderer chooses the +least expensive CSS primitive that can represent it, then uses `matrix3d(...)` +to place that primitive in 3D space. Polygon count is the dominant cost. -- `` uses `background: currentColor` on a fixed box for solid rectangles and stable quads. -- `` uses `corner-shape` for stable triangles and beveled-corner solids, with a `border-width` triangle fallback when needed. -- `` clips solid polygons with `border-shape: polygon(...)` when the browser supports it. -- `` maps a packed texture-atlas slice with `background-image`, and is the fallback for textured or unsupported shapes. +- `` uses `background: currentColor` for solid rectangles and stable quads. +- `` uses `corner-shape` for stable triangles and beveled-corner solids. +- `` clips solid polygons with `border-shape: polygon(...)` where supported. +- `` maps a packed texture-atlas slice, and is the fallback for textured or + unsupported shapes. ## Packages @@ -231,19 +230,8 @@ Each visible polygon is emitted as one leaf element; the renderer chooses the le | `@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. | - -## Made with PolyCSS - -[cssQuake](https://cssquake.com) --> A CSS port of Quake (1996) - -quake - - -[Layoutit Terra](https://terra.layoutit.com) --> A CSS Terrain Generator - -layoutit-terra +| `@layoutit/polycss-fonts` | Fonts + text → extruded 3D `Polygon[]`. Framework-agnostic. | +| `@layoutit/polycss-morph` | Prepared-model loading, retained DOM animation, morph targets, skinning, and playback. | ## License From 1eb9eb7d6edb704f45959ac42835c475ed4857df Mon Sep 17 00:00:00 2001 From: Juan Cruz Fortunatti Date: Sun, 9 Aug 2026 00:10:40 +0200 Subject: [PATCH 02/18] docs(website): fix broken shadow and error-handling examples, complete PolyMesh prop table --- .../content/docs/components/poly-scene.mdx | 4 +++- website/src/content/docs/guides/lighting.mdx | 24 +++++++++++++++++-- website/src/content/docs/guides/textures.mdx | 2 +- 3 files changed, 26 insertions(+), 4 deletions(-) diff --git a/website/src/content/docs/components/poly-scene.mdx b/website/src/content/docs/components/poly-scene.mdx index e1191dbc..eb954016 100644 --- a/website/src/content/docs/components/poly-scene.mdx +++ b/website/src/content/docs/components/poly-scene.mdx @@ -39,7 +39,7 @@ React/Vue `` supports the full table. The `` custom element | Prop | Type | Description | |------|------|-------------| | `id` | `string` | Stable mesh identifier. Reflected as `data-poly-mesh-id` and exposed on mesh handles for selection / transform tools. | -| `src` | `string` | URL to `.obj`, `.glb`, `.gltf`, or `.vox`. | +| `src` | `string` | URL to `.obj`, `.stl`, `.glb`, `.gltf`, or `.vox`. | | `polygons` | `Polygon[]` | Pre-parsed polygons (alternative to `src`). Framework only. | | `position` | `Vec3` | `[x, y, z]` offset in scene space. | | `scale` | `number \| Vec3` | Uniform or per-axis scale. | @@ -54,7 +54,9 @@ React/Vue `` supports the full table. The `` custom element | `parseOptions` | `UseMeshOptions` | Parser options forwarded to `loadMesh`; `meshResolution` defaults to `"lossy"`. | | `meshResolution` | `"lossless" \| "lossy"` | Top-level optimizer intent. Wins over `parseOptions.meshResolution`; defaults to `"lossy"`. | | `castShadow` | `boolean` | Emit SVG cast shadows in both lighting modes; projections update when light, ground, or mesh geometry changes. | +| `receiveShadow` | `boolean` | Casters project per-coplanar-face SVG shadows onto this mesh's visible surfaces (Three.js `mesh.receiveShadow` semantics). Any receiver in the scene disables the casters' ground-shadow fallback. Defaults to `false`. | | `shadowDefinition` | `number` | Per-mesh parametric-shadow detail, overriding the scene's `shadow.definition` (only when `shadow.parametric`). | +| `merge` | `boolean` | Run the polygon optimizer. Defaults to `true`. Set `false` to render authored geometry exactly as given — no merging, deduping, or interior culling. | | `fallback` | `ReactNode` | Rendered while `src` is loading. (React / Vue only.) | | `errorFallback` | `(error: Error) => ReactNode` | Rendered if parse fails. (React / Vue only.) | | `children` | `(polygon, index) => ReactNode` | Per-polygon render prop / scoped slot. (React / Vue only.) | diff --git a/website/src/content/docs/guides/lighting.mdx b/website/src/content/docs/guides/lighting.mdx index 8abffd4f..dc083255 100644 --- a/website/src/content/docs/guides/lighting.mdx +++ b/website/src/content/docs/guides/lighting.mdx @@ -153,7 +153,7 @@ scene.setOptions({ shadow: { color: "#000000", opacity: 0.3, lift: 0.02 } }); ```tsx - + ``` @@ -161,13 +161,33 @@ scene.setOptions({ shadow: { color: "#000000", opacity: 0.3, lift: 0.02 } }); ```vue - + ``` +### Two ways to receive a shadow + +There are two distinct receiver mechanisms, and mixing them up is a common +source of "my shadow disappeared": + +- **Ground-shadow fallback.** A `castShadow` mesh projects onto the scene's + ground plane automatically, with no receiver mesh required. This is what + `` relies on — it is a convenience quad that renders with + `castShadow: false` and has **no `receiveShadow` prop of its own**. Passing + `receiveShadow` to `` does nothing. +- **`receiveShadow` meshes.** Marking any mesh `receiveShadow` makes casters + project per-coplanar-face SVG shadows onto each of its visible surfaces + (Three.js `mesh.receiveShadow` semantics). As soon as *any* receiver exists in + the scene, casters **drop the ground-shadow fallback** so the receiver paints + the only shadow pass. + +So `` alone works, and an explicit `receiveShadow` mesh alone works +— but adding a receiver elsewhere in the scene will silently turn off the +ground fallback under ``. + What to expect: - **Directional shadows** work in both lighting modes and only appear for a directional light with `intensity > 0` (intensity `0`, or no directional light, casts nothing — matching three.js). diff --git a/website/src/content/docs/guides/textures.mdx b/website/src/content/docs/guides/textures.mdx index 9ac3a1ae..457e1925 100644 --- a/website/src/content/docs/guides/textures.mdx +++ b/website/src/content/docs/guides/textures.mdx @@ -164,7 +164,7 @@ function Viewer() { }); if (loading) return
Loading...
; - if (error) return
Error: {error}
; + if (error) return
Error: {error.message}
; return ( From e8015cf5c6300f150e734411d2357509aeca5f34 Mon Sep 17 00:00:00 2001 From: Juan Cruz Fortunatti Date: Sun, 9 Aug 2026 00:11:17 +0200 Subject: [PATCH 03/18] docs(readme): revert root README to comma fix only --- README.md | 10 +--------- 1 file changed, 1 insertion(+), 9 deletions(-) diff --git a/README.md b/README.md index 25ea1c14..a1fce00e 100644 --- a/README.md +++ b/README.md @@ -84,8 +84,7 @@ export default function App() { - `position`, `scale`, and `rotation` transform the mesh wrapper. - `autoCenter` shifts the mesh bbox center to local origin. - `meshResolution` chooses `"lossy"` (default) or `"lossless"` optimization. STL imports use the conservative lossless path in both modes. -- `castShadow` emits CPU-projected SVG shadows. It works in both `"baked"` and `"dynamic"` lighting modes; dynamic-mode shadows are directional-only. -- `shadowDefinition` overrides the scene's parametric shadow resolution for this mesh. +- `castShadow` emits CSS-projected shadows in dynamic lighting mode. ### Controls @@ -180,12 +179,6 @@ const polygons = [ ]; ``` -Authoring `Polygon[]` by hand has real constraints — vertex winding decides -whether a face is visible at all, `color` does not accept CSS named colors, and -non-triangular polygons must be coplanar. Read -[Authoring Polygons](https://polycss.com/core-concepts#authoring-polygons) -before generating geometry. - Render polygons directly when you need per-face DOM events or custom styling: ```tsx @@ -248,7 +241,6 @@ Each visible polygon is emitted as one leaf element; the renderer chooses the le | `@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-fonts` | Fonts + text → extruded 3D `Polygon[]`. Framework-agnostic. | | `@layoutit/polycss-morph` | Prepared-model loading, retained DOM animation, morph targets, skinning, and playback. | ## Made with PolyCSS From f88b8aa574ade478d9d00b0179523c58f77ea16d Mon Sep 17 00:00:00 2001 From: Juan Cruz Fortunatti Date: Sun, 9 Aug 2026 00:12:20 +0200 Subject: [PATCH 04/18] docs(types): sync scene/mesh options and document texture presentation --- website/src/content/docs/api/types.mdx | 111 ++++++++++++++++++++++++- 1 file changed, 108 insertions(+), 3 deletions(-) diff --git a/website/src/content/docs/api/types.mdx b/website/src/content/docs/api/types.mdx index 666bd48c..e24dc183 100644 --- a/website/src/content/docs/api/types.mdx +++ b/website/src/content/docs/api/types.mdx @@ -11,9 +11,13 @@ The atomic renderable primitive. Each visible polygon becomes one renderer-owned ```ts interface Polygon { - /** Three or more [x, y, z] world-space points, CCW winding from the outside. */ + /** Three or more [x, y, z] world-space points, CCW winding seen from the + * outside. Winding sets the face normal via the right-hand rule, and + * PolyCSS backface-culls — a reversed face is invisible. */ vertices: [number, number, number][]; - /** CSS color. Falls back to "#cccccc" when neither color nor texture is set. */ + /** Hex (`#rgb` / `#rrggbb`) or `rgb()` / `rgba()` only — NOT CSS named + * colors. Unparseable values render white. Falls back to "#cccccc" when + * neither color nor texture is set. */ color?: string; /** Image URL for UV-mapped rendering. When set with `uvs`, the renderer * applies an affine UV transform. Without `uvs`, single-tile fill. */ @@ -24,12 +28,22 @@ interface Polygon { textureAlphaMode?: PolyTextureAlphaMode; /** Shared material. `material.texture` takes precedence over `texture`. */ material?: PolyMaterial; + /** Source-exact image backing. Renders as a direct image leaf using the + * caller's URL and source rect — no atlas rasterisation, no atlas memory, + * and source lighting is preserved. */ + textureImageSource?: PolyTextureImageSource; + /** Backend, projection, filtering, and lighting request for this polygon's + * texture. See Texture Presentation below. */ + texturePresentation?: PolyTexturePresentation; /** UV coordinates: one per vertex. Must match vertices.length. * Mismatched arrays are stripped during normalizePolygons(). */ uvs?: [number, number][]; /** Source UV triangles preserved by import/merge passes for atlas rasterization. */ textureTriangles?: TextureTriangle[]; - /** Source material requested two-sided rendering. */ + /** Importer-internal. Records that a source material requested two-sided + * rendering, so optimization passes don't collapse intentional + * reverse-wound faces. It is NOT a render-time flag — setting it does not + * make a polygon visible from behind. Orient your winding instead. */ doubleSided?: boolean; /** User-controlled metadata. Reflected to the DOM as data-* attributes * when rendering via . Keys must have string, number, or boolean values. */ @@ -76,6 +90,59 @@ type PolyTextureAlphaMode = "opaque" | "mask" | "blend"; --- +## Texture Presentation + +How a textured polygon is painted. PolyCSS has two texture backends: the +**atlas** backend rasterises each polygon's local-2D bounding rect into packed +atlas pages, and the **image** backend renders a direct image leaf straight from +a source URL and rect — no rasterisation and no atlas memory, but source pixels +are preserved as-is. + +```ts +/** Leaf primitive sizing for texture leaves. Default "canonical". */ +type PolyTextureLeafSizing = "canonical" | "local" | "raster"; + +/** Requested backend. "auto" lets the renderer choose. Default "auto". */ +type PolyTextureBackend = "auto" | "atlas" | "image"; + +/** CSS `image-rendering` for the leaf. Default "auto". */ +type PolyTextureImageRendering = "auto" | "pixelated"; + +/** Whether the image is lit by the scene or shown as authored. */ +type PolyTextureImageLighting = "scene" | "source"; + +/** UV mapping. "projective" enables exact quad mapping where the + * compositor-stability guards allow it. Default "affine". */ +type PolyTextureProjection = "affine" | "projective"; + +interface PolyTextureImageSource { + url: string; + width: number; + height: number; + /** Sub-rect of the source image to draw. Defaults to the whole image. */ + sourceRect?: { x: number; y: number; width: number; height: number }; + imageRendering?: PolyTextureImageRendering; +} + +interface PolyTexturePresentation { + imageRendering?: PolyTextureImageRendering; + backend?: PolyTextureBackend; + lighting?: PolyTextureImageLighting; + projection?: PolyTextureProjection; +} +``` + +Direct image leaves (`backend: "image"`) are **source-lit only** — they use +`lighting: "source"` and keep the source pixels untouched. A textured polygon +that needs scene lighting falls back to the atlas backend. + +These are also scene-level defaults: `textureBackend`, `textureProjection`, +`textureImageRendering`, and `textureLeafSizing` on `PolySceneOptions` (and the +matching `` props) set the default for every polygon, and a polygon's +own `texturePresentation` overrides it. + +--- + ## `PolyDirectionalLight` Controls the directional light for the scene. @@ -240,17 +307,49 @@ interface PolySceneOptions { directionalLight?: PolyDirectionalLight; pointLights?: PolyPointLight[]; ambientLight?: PolyAmbientLight; + /** Defaults to "baked". */ textureLighting?: PolyTextureLightingMode; textureQuality?: TextureQuality; + /** Texture leaf primitive sizing. Defaults to "canonical". */ + textureLeafSizing?: PolyTextureLeafSizing; + /** Default image filtering for atlas and direct image texture leaves. */ + textureImageRendering?: PolyTextureImageRendering; + /** Default texture backend request. Defaults to "auto". */ + textureBackend?: PolyTextureBackend; + /** Default texture projection request. Defaults to "affine". */ + textureProjection?: PolyTextureProjection; seamBleed?: PolySeamBleed; strategies?: PolyRenderStrategiesOption; autoCenter?: boolean; shadow?: { + /** Default: "#000000". */ color?: string; + /** 0..1. Default: 0.25. */ opacity?: number; + /** World units above the receiver, avoids z-fighting. Default: 0.05. */ lift?: number; + /** Max CSS px the shadow may extend past the mesh footprint. Default: 2000. */ maxExtend?: number; + /** Cast one low-resolution coverage silhouette per caster instead of + * projecting full geometry. Default: false. */ + parametric?: boolean; + /** Parametric silhouette detail. Default: 16. Overridden per mesh by + * `PolyMeshTransform.shadowDefinition`. */ + definition?: number; + /** Progressive refinement: definition used while the directional light is + * actively being dragged, then a debounced pass re-emits at full + * `definition`. Vanilla `createPolyScene` only. Unset → no progressive pass. */ + dragDefinition?: number; + /** "vector" (default) traces a smooth concave contour; "pixel" + * greedy-meshes the coverage into blocky rectangles. */ + style?: "vector" | "pixel"; + /** Re-emit shadows while a mesh animates instead of freezing at the last + * pose. Throttled internally (~12fps). Default: false. */ + followAnimation?: boolean; }; + /** Emit `data-poly-shadow-*` attribution attributes on every shadow SVG and + * path, for DevTools inspection. Default: false. */ + debugShadowAttrs?: boolean; } interface PolyMeshTransform { @@ -258,12 +357,18 @@ interface PolyMeshTransform { position?: Vec3; scale?: number | Vec3; rotation?: Vec3; + /** Merge coplanar polygons before rendering. Defaults to true. Set false for + * animated meshes whose triangle topology must stay stable, or to render + * authored geometry exactly as given. */ merge?: boolean; meshResolution?: MeshResolution; stableDom?: boolean; excludeFromAutoCenter?: boolean; castShadow?: boolean; receiveShadow?: boolean; + /** Per-mesh parametric-shadow detail, overriding the scene's + * `shadow.definition`. Only used when `shadow.parametric` is true. */ + shadowDefinition?: number; } interface PolySceneHandle { From 7c8aac7a835348fb52a33d4462ac51878173f314 Mon Sep 17 00:00:00 2001 From: Juan Cruz Fortunatti Date: Sun, 9 Aug 2026 00:13:34 +0200 Subject: [PATCH 05/18] docs(core-concepts): document polygon authoring invariants --- packages/react/README.md | 2 +- packages/vue/README.md | 2 +- website/src/content/docs/api/three-parity.mdx | 2 +- website/src/content/docs/core-concepts.mdx | 107 +++++++++++++++++- 4 files changed, 109 insertions(+), 4 deletions(-) diff --git a/packages/react/README.md b/packages/react/README.md index 52f123df..2d46810a 100644 --- a/packages/react/README.md +++ b/packages/react/README.md @@ -41,7 +41,7 @@ export default function App() { ### `` - `rotX`, `rotY` control the orbit angle in degrees. -- `zoom` is CSS pixels per world unit. +- `zoom` scales the projected scene (default `0.65`). One world unit is 50 CSS px before zoom. - `target` pans the camera target in world coordinates. - `distance` adds dolly pull-back. - `PolyCamera` is the orthographic default. Use `` for diff --git a/packages/vue/README.md b/packages/vue/README.md index 2fab9880..d1dc7f3a 100644 --- a/packages/vue/README.md +++ b/packages/vue/README.md @@ -54,7 +54,7 @@ Props are listed in their template (kebab-case) form. ### `` - `rot-x`, `rot-y` control the orbit angle in degrees. -- `zoom` is CSS pixels per world unit. +- `zoom` scales the projected scene (default `0.65`). One world unit is 50 CSS px before zoom. - `target` pans the camera target in world coordinates. - `distance` adds dolly pull-back. - `PolyCamera` is the orthographic default. Use `` for diff --git a/website/src/content/docs/api/three-parity.mdx b/website/src/content/docs/api/three-parity.mdx index 36c258ea..c7aa6e4f 100644 --- a/website/src/content/docs/api/three-parity.mdx +++ b/website/src/content/docs/api/three-parity.mdx @@ -26,7 +26,7 @@ direction. ## Conventions The native PolyCSS API uses PolyCSS conventions: Z-up scene math, camera rotations -in degrees, and `zoom` as CSS pixels per world unit. +in degrees, and `zoom` as a scale multiplier on the projected scene (one world unit is 50 CSS px before zoom). The `*/three` subpaths intentionally use Three-style conventions: diff --git a/website/src/content/docs/core-concepts.mdx b/website/src/content/docs/core-concepts.mdx index ca74c159..f4508966 100644 --- a/website/src/content/docs/core-concepts.mdx +++ b/website/src/content/docs/core-concepts.mdx @@ -68,7 +68,7 @@ Each polygon is a plain object. The only required field is `vertices` (three or ```ts interface Polygon { vertices: [number, number, number][]; // Required: 3+ [x, y, z] points in world space - color?: string; // CSS color ("#f97316", "tomato") + color?: string; // Hex or rgb()/rgba() only — "#f97316" texture?: string; // Image URL for UV-mapped face material?: PolyMaterial; // Shared texture material uvs?: [number, number][]; // UV coordinates (one per vertex) @@ -96,6 +96,111 @@ Scene content renders relative to the (0,0,0) origin. Most mesh files are author ``` +## Authoring Polygons + +Imported meshes get repaired on the way in — parsers fix winding, normalize +coordinates, and strip malformed data. **Hand-authored `Polygon[]` does not.** +If you generate geometry in code (an architectural kit, a procedural terrain, a +shape library), these constraints are load-bearing, and violating them fails +silently rather than throwing. + +### Winding determines visibility + +Vertex order sets the face normal by the right-hand rule, and PolyCSS +**backface-culls** every leaf. A polygon wound the wrong way is not merely +shaded oddly — it is invisible, it casts no shadow, and it lights inverted. + +Vertices are **counter-clockwise seen from the outside** (the side you want to +look at): + +```ts +// Faces +Z (up). CCW when viewed from above. +const floor = { + vertices: [[0, 0, 0], [1, 0, 0], [1, 1, 0], [0, 1, 0]], + color: "#d8d2c7", +}; + +// The SAME quad reversed faces -Z (down) and is invisible from above. +const broken = { + vertices: [[0, 0, 0], [0, 1, 0], [1, 1, 0], [1, 0, 0]], + color: "#d8d2c7", +}; +``` + +The normal is `(v1 - v0) × (v2 - v0)`, normalized. For the first quad that is +`(1,0,0) × (1,1,0) = (0,0,1)` — pointing up, so the face is visible from above. + +Consequences worth internalizing when you build a shape library: + +- **Solids face outward; rooms face inward.** A box you look at from outside and + a room you stand inside are the same six quads with opposite winding. +- **Mirroring reverses handedness.** Any negative scale or mirror transform + flips the effective winding of every face it touches, so mirrored geometry + needs its vertex order reversed to compensate. +- **Reverse UVs with the vertices.** If a polygon has `uvs`, reversing + `vertices` without reversing `uvs` in the same order silently remaps the + texture. +- **`doubleSided` is not the fix.** It exists so importers can stop the + optimizer collapsing intentional reverse-wound faces; it is not a render-time + flag and will not make a face visible from behind. Orient the winding. + +**Diagnostic rule:** if a polygon exists in your generated data but disappears +when the camera crosses to its other side, inspect its winding and normal +*before* you touch culling, lighting, or camera code. That symptom is winding +essentially every time. + +### `color` is not a full CSS color + +Only hex (`#rgb`, `#rrggbb`) and `rgb()` / `rgba()` parse. CSS named colors +(`"tomato"`, `"red"`), `hsl()`, and `color()` do **not** — an unparseable value +renders **white**, with no warning. + +```ts +{ color: "tomato" } // ✗ renders white +{ color: "#ff6347" } // ✓ +``` + +### Non-triangular polygons must be coplanar + +Renderers do not call `normalizePolygons` for you — it is available, but the +render path does not apply it. A quad or n-gon whose vertices are not on a +common plane is flattened onto their average plane when its local 2D basis is +built, which moves vertices out from under their neighbours and opens visible +cracks. + +Triangles are always coplanar, so this only bites n-gons. If you merge faces +into quads, either merge only where coplanarity genuinely holds, or snap the +shared vertices onto a common plane and propagate the new position to **every** +polygon that references them. + +### The optimizer rewrites your geometry by default + +`merge` defaults to `true` and `meshResolution` defaults to `"lossy"`, so +authored geometry is merged, deduped, and interior-culled before it renders: + +- Coincident faces closer than `0.05` world units are deduped. +- Fully-interior faces are culled. +- Lossy merging tolerates up to `0.35` world units of plane displacement and + `0.04` of boundary displacement, at up to `15°` of angle change. These are + **absolute world units and not configurable**, so small-scale hand-authored + meshes can get visibly welded. + +If you authored geometry precisely and want it rendered exactly as given, set +`merge: false`, or `meshResolution: "lossless"` to keep only exact reductions. + +### Degenerate polygons vanish silently + +Fewer than three vertices, a zero-length edge, or a zero-area face produces no +leaf and no console output. If a face is missing and the winding is right, check +for duplicate or collinear vertices. + +### Scale + +One world unit is **50 CSS pixels** (`BASE_TILE`). Camera `zoom` is a scale +multiplier applied on top of that conversion — it defaults to `0.65`, so at the +default camera one world unit covers about 32.5 CSS px, and at `zoom: 1` it +covers 50. + ## Rendering Pipeline PolyCSS is structured in three layers: From 69fac3c9bb5495c6c29a5a883b0a86943dda82b5 Mon Sep 17 00:00:00 2001 From: Juan Cruz Fortunatti Date: Sun, 9 Aug 2026 00:14:45 +0200 Subject: [PATCH 06/18] docs(skill): add native API depth and authoring invariants --- website/public/skill.md | 195 ++++++++++++++++++++++++++++++++++++++-- 1 file changed, 187 insertions(+), 8 deletions(-) diff --git a/website/public/skill.md b/website/public/skill.md index 450c62b1..8a5fbe9c 100644 --- a/website/public/skill.md +++ b/website/public/skill.md @@ -20,6 +20,8 @@ import { createPolyCamera, createPolyPerspectiveCamera, createPolyScene, + createPolyOrbitControls, + createPolyBox, loadMesh, } from "@layoutit/polycss"; ``` @@ -30,7 +32,9 @@ import { PolyPerspectiveCamera, PolyScene, PolyMesh, + PolyGround, PolyOrbitControls, + Poly, } from "@layoutit/polycss-react"; ``` @@ -40,10 +44,191 @@ import { PolyPerspectiveCamera, PolyScene, PolyMesh, + PolyGround, PolyOrbitControls, + Poly, } from "@layoutit/polycss-vue"; ``` +## Native Conventions + +- Coordinates are PolyCSS world space: `[x, y, z]`, **+X right, +Y forward, + +Z up**. +- Camera rotations are degrees: `rotX`, `rotY`. +- One world unit is 50 CSS px; `zoom` is a scale multiplier on top of that + (default `0.65`). +- `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. + +```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 + data?: Record; // → data-* attributes +} +``` + +Imported meshes get repaired on the way in. **Hand-authored polygons do not.** +These constraints fail *silently* — no throw, no 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, casts no shadow, and lights inverted. 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 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:* if a polygon exists in your data but disappears when the +camera crosses to its other side, 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()` render +**white**. + +**3. Non-triangular polygons must be coplanar.** Renderers do not run +`normalizePolygons` for you. A non-planar n-gon is flattened onto the average +plane, opening cracks against its neighbours. 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 tolerates up to `0.35` +world units of plane displacement (absolute units, not configurable). For +precise authored geometry pass `merge: false` or `meshResolution: "lossless"`. + +**5. Degenerate polygons vanish silently** — under 3 vertices, zero-length edge, +or zero area produces no leaf and no console output. + +## Building a 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"), { autoCenter: true, castShadow: true }); +``` + +React (Vue mirrors this with kebab-case props): + +```tsx + + + + + + {polygons.map((p, i) => select(i)} />)} + + +``` + +**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 want `merge: false` and `stableDom: true` so triangle topology +and leaf identity stay stable across frames. + +## Lighting + +The scene takes one `directionalLight`, one `ambientLight`, and optional +`pointLights`. Directional `direction` is the unit vector from the surface +*toward* the light. 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 } }); +``` + +- Two receiver mechanisms: a caster projects onto the scene ground plane + automatically (what `` relies on — it has **no** `receiveShadow` + prop), *or* onto explicit `receiveShadow` meshes. As soon as any receiver + exists, casters drop the ground fallback. +- `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: @@ -58,6 +243,7 @@ import { PointLight, AmbientLight, transformPolygonsToPoly, + mountPolyThreeScene, } from "@layoutit/polycss/three"; ``` @@ -83,14 +269,6 @@ import { } from "@layoutit/polycss-vue/three"; ``` -## Native Conventions - -- Coordinates are PolyCSS world space: `[x, y, z]`, with Z up. -- Camera rotations are degrees: `rotX`, `rotY`. -- `zoom` is CSS pixels per world unit. -- `PolyCamera` / `createPolyCamera` are orthographic by default. -- Use `PolyPerspectiveCamera` / `createPolyPerspectiveCamera` for perspective. - ## Three.js Parity Conventions - Coordinates are Three/Y-up authoring space. @@ -171,3 +349,4 @@ mountPolyThreeScene(document.querySelector("#scene")!, { ``` Full docs: https://polycss.com/api/three-parity +Authoring reference: https://polycss.com/core-concepts#authoring-polygons From 63f2d3e433376ed2a44b5908c5a30d9516d13f26 Mon Sep 17 00:00:00 2001 From: Juan Cruz Fortunatti Date: Sun, 9 Aug 2026 02:33:46 +0200 Subject: [PATCH 07/18] docs: correct API signatures, iframe units, and parser/normalization behavior --- packages/core/README.md | 7 +- packages/polycss/README.md | 14 +++- packages/react/README.md | 14 ++-- packages/vue/README.md | 14 ++-- website/public/skill.md | 50 ++++++++----- website/src/content/docs/api/three-parity.mdx | 2 +- website/src/content/docs/core-concepts.mdx | 72 ++++++++++++++----- 7 files changed, 125 insertions(+), 48 deletions(-) diff --git a/packages/core/README.md b/packages/core/README.md index 5cabf734..194fb1ad 100644 --- a/packages/core/README.md +++ b/packages/core/README.md @@ -29,10 +29,13 @@ npm install @layoutit/polycss-core ## Parsing a mesh without a browser -`loadMesh` and the individual parsers are pure functions over bytes and strings, -so they run under Node: +The parsers (`parseObj`, `parseStl`, `parseGltf`, `parseVox`, `parseMtl`) are +pure, synchronous functions over already-loaded bytes and strings, so they run +under Node. `loadMesh` is the convenience wrapper on top: it fetches a URL and +dispatches by extension, so it needs `fetch` and is not pure. ```ts +import { readFile } from "node:fs/promises"; import { parseObj, optimizeMeshPolygons } from "@layoutit/polycss-core"; const result = parseObj(await readFile("cottage.obj", "utf8")); diff --git a/packages/polycss/README.md b/packages/polycss/README.md index 6e70b035..80cb184f 100644 --- a/packages/polycss/README.md +++ b/packages/polycss/README.md @@ -156,9 +156,13 @@ the scene, using the same `position` / `rotation` / `scale` conventions as a mesh. Its content is centered on the wrapper's local origin, so rotation and scale pivot at the visible center. React and Vue expose it as ``. +`width` and `height` are **world units**, not pixels — the mounted document is +`width × 50` CSS px wide (`BASE_TILE`), so `16 × 9` yields an 800 × 450 px page. +`position` is world units too. + ```html - + ``` @@ -192,12 +196,16 @@ const polygons = [ ]; ``` -Pass them straight to the scene: +Geometry enters the scene through `scene.add()`, which takes a `ParseResult`. +There is no `polygons` scene option — wrap a raw `Polygon[]` yourself: ```ts -const scene = createPolyScene(host, { camera, polygons }); +scene.add({ polygons, objectUrls: [], warnings: [], dispose: () => {} }); ``` +`scene.add` runs the mesh optimizer by default. Pass `{ merge: false }` as the +second argument to render authored polygons exactly as given. + Authoring `Polygon[]` by hand has real constraints — winding decides visibility, `color` does not accept CSS named colors, and non-triangular polygons must be coplanar. Read diff --git a/packages/react/README.md b/packages/react/README.md index 2d46810a..55202258 100644 --- a/packages/react/README.md +++ b/packages/react/README.md @@ -41,7 +41,7 @@ export default function App() { ### `` - `rotX`, `rotY` control the orbit angle in degrees. -- `zoom` scales the projected scene (default `0.65`). One world unit is 50 CSS px before zoom. +- `zoom` is on-screen CSS pixels per world unit (Three.js `OrthographicCamera.zoom` style). - `target` pans the camera target in world coordinates. - `distance` adds dolly pull-back. - `PolyCamera` is the orthographic default. Use `` for @@ -85,8 +85,11 @@ Renders a live document as a flat quad in the scene, with 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` CSS px wide (`BASE_TILE`), so `16 × 9` yields an 800 × 450 px page. + ```tsx - + ``` ### Controls @@ -107,8 +110,11 @@ the wrapper's local origin, so rotation and scale pivot at the visible center. ## Hooks -- `usePolyCamera` — read/drive the camera handle from the context. -- `usePolySceneContext` — access the underlying scene handle. +- `usePolyCamera(options)` — create and drive the scene camera store from camera + options; returns `{ store, cameraRef, sceneElRef, cameraElRef, + applyTransformDirect }`. +- `usePolySceneContext(polygons, { directionalLight })` — run the scene pipeline + (normalize + merge) over a polygon list; returns `{ polygons, sceneBbox }`. - `usePolyMesh` — load a mesh imperatively; returns `{ polygons, voxelSource, loading, error, warnings, dispose }`, where `error` is an `Error | null`. - `usePolyMaterial` — resolve material state for a mesh. diff --git a/packages/vue/README.md b/packages/vue/README.md index d1dc7f3a..5b73ce90 100644 --- a/packages/vue/README.md +++ b/packages/vue/README.md @@ -54,7 +54,7 @@ Props are listed in their template (kebab-case) form. ### `` - `rot-x`, `rot-y` control the orbit angle in degrees. -- `zoom` scales the projected scene (default `0.65`). One world unit is 50 CSS px before zoom. +- `zoom` is on-screen CSS pixels per world unit (Three.js `OrthographicCamera.zoom` style). - `target` pans the camera target in world coordinates. - `distance` adds dolly pull-back. - `PolyCamera` is the orthographic default. Use `` for @@ -98,8 +98,11 @@ Renders a live document as a flat quad in the scene, with 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` CSS px wide (`BASE_TILE`), so `16 × 9` yields an 800 × 450 px page. + ```vue - + ``` ### Controls @@ -120,8 +123,11 @@ the wrapper's local origin, so rotation and scale pivot at the visible center. ## Composables -- `usePolyCamera` — read/drive the camera handle from the provided context. -- `usePolySceneContext` — access the underlying scene handle. +- `usePolyCamera(options)` — create and drive the scene camera store from camera + options; returns the camera store plus scene/camera element refs. +- `usePolySceneContext(polygons, options)` — run the scene pipeline (normalize + + merge) over a reactive polygon list; returns a ref of + `{ polygons, sceneBbox }`. - `usePolyMesh` — load a mesh imperatively; exposes polygons, voxel source, loading, error, and warnings as reactive state. - `usePolyMaterial` — resolve material state for a mesh. diff --git a/website/public/skill.md b/website/public/skill.md index 8a5fbe9c..5610125f 100644 --- a/website/public/skill.md +++ b/website/public/skill.md @@ -55,8 +55,10 @@ import { - Coordinates are PolyCSS world space: `[x, y, z]`, **+X right, +Y forward, +Z up**. - Camera rotations are degrees: `rotX`, `rotY`. -- One world unit is 50 CSS px; `zoom` is a scale multiplier on top of that - (default `0.65`). +- `zoom` is on-screen CSS pixels per world unit (Three.js + `OrthographicCamera.zoom` style). `BASE_TILE` (50) is the internal world-unit + → CSS px factor, and matters only for APIs that take world units directly, + like ``. - `PolyCamera` / `createPolyCamera` are orthographic by default. - Use `PolyPerspectiveCamera` / `createPolyPerspectiveCamera` for perspective. @@ -74,8 +76,19 @@ interface Polygon { } ``` -Imported meshes get repaired on the way in. **Hand-authored polygons do not.** -These constraints fail *silently* — no throw, no warning: +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 + normalize coordinates with a handedness-preserving axis map. +- **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 @@ -100,18 +113,21 @@ camera crosses to its other side, 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()` render -**white**. +`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.** Renderers do not run -`normalizePolygons` for you. A non-planar n-gon is flattened onto the average -plane, opening cracks against its neighbours. Triangles are always safe. +**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 tolerates up to `0.35` -world units of plane displacement (absolute units, not configurable). For -precise authored geometry pass `merge: false` or `meshResolution: "lossless"`. +world units of plane displacement (absolute units, not configurable). Dedupe and +interior culling count as exact reductions and still run under +`meshResolution: "lossless"` — only `merge: false` renders geometry untouched. **5. Degenerate polygons vanish silently** — under 3 vertices, zero-length edge, or zero area produces no leaf and no console output. @@ -166,15 +182,17 @@ React/Vue: ``, ``, `usePolySelect` / `usePolySelectionApi`. **Animation.** `usePolyAnimation` (React/Vue) drives imported skeletal clips. -Animated meshes want `merge: false` and `stableDom: true` so triangle topology -and leaf identity stay stable across frames. +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 unit vector from the surface -*toward* the light. Point lights are direction-only (no distance falloff) and -shade flat per face. +`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`: diff --git a/website/src/content/docs/api/three-parity.mdx b/website/src/content/docs/api/three-parity.mdx index c7aa6e4f..36c258ea 100644 --- a/website/src/content/docs/api/three-parity.mdx +++ b/website/src/content/docs/api/three-parity.mdx @@ -26,7 +26,7 @@ direction. ## Conventions The native PolyCSS API uses PolyCSS conventions: Z-up scene math, camera rotations -in degrees, and `zoom` as a scale multiplier on the projected scene (one world unit is 50 CSS px before zoom). +in degrees, and `zoom` as CSS pixels per world unit. The `*/three` subpaths intentionally use Three-style conventions: diff --git a/website/src/content/docs/core-concepts.mdx b/website/src/content/docs/core-concepts.mdx index f4508966..adc03d2d 100644 --- a/website/src/content/docs/core-concepts.mdx +++ b/website/src/content/docs/core-concepts.mdx @@ -98,12 +98,40 @@ Scene content renders relative to the (0,0,0) origin. Most mesh files are author ## Authoring Polygons -Imported meshes get repaired on the way in — parsers fix winding, normalize -coordinates, and strip malformed data. **Hand-authored `Polygon[]` does not.** -If you generate geometry in code (an architectural kit, a procedural terrain, a -shape library), these constraints are load-bearing, and violating them fails +If you generate geometry in code — an architectural kit, a procedural terrain, a +shape library — the constraints below are load-bearing, and violating them fails silently rather than throwing. +How much cleanup you get for free depends on **which parser** produced the mesh +and **which entry point** you hand it to. Neither is uniform: + +| Source | Winding treatment | +|---|---| +| STL | Repaired. Connectivity orients closed components outward; open components follow a consistent supplied-normal signal. | +| `.vox` | Correct by construction — faces are generated CCW-from-outside. | +| OBJ | **Preserved as authored.** A file wound inconsistently stays that way. | +| glTF / GLB | **Preserved as authored.** `doubleSided` materials emit reversed duplicate triangles. | + +Every parser normalizes coordinates (fit-to-target scale, Y-up → Z-up), and the +axis permutation is chosen specifically so it does *not* flip handedness. + +`normalizePolygons` — which drops degenerate polygons, strips mismatched `uvs`, +replaces unparseable colors with `#cccccc`, and **fan-triangulates non-coplanar +n-gons** — runs on only one path: + +- **React/Vue `` normalizes.** Your non-coplanar quad comes + back as triangles. It records warnings, but they are not surfaced to the + console, so the repair is silent. +- **`scene.add(...)`, ``, ``, and `` do + not.** They run the mesh optimizer (or nothing, with `merge: false`). Here a + non-coplanar n-gon is flattened onto its average plane when its local 2D basis + is built — opening cracks against its neighbours — and degenerate polygons + vanish without a trace. + +If you generate geometry, the portable move is to emit triangles or genuinely +coplanar n-gons, or to call `normalizePolygons` yourself and inspect the +warnings it returns. + ### Winding determines visibility Vertex order sets the face normal by the right-hand rule, and PolyCSS @@ -152,21 +180,22 @@ essentially every time. ### `color` is not a full CSS color Only hex (`#rgb`, `#rrggbb`) and `rgb()` / `rgba()` parse. CSS named colors -(`"tomato"`, `"red"`), `hsl()`, and `color()` do **not** — an unparseable value -renders **white**, with no warning. +(`"tomato"`, `"red"`), `hsl()`, and `color()` do **not**, and the failure is +silent either way: on the normalizing `` path the value is +replaced with `#cccccc`, and everywhere else it renders **white**. ```ts -{ color: "tomato" } // ✗ renders white +{ color: "tomato" } // ✗ silently wrong (white, or #cccccc) { color: "#ff6347" } // ✓ ``` ### Non-triangular polygons must be coplanar -Renderers do not call `normalizePolygons` for you — it is available, but the -render path does not apply it. A quad or n-gon whose vertices are not on a -common plane is flattened onto their average plane when its local 2D basis is -built, which moves vertices out from under their neighbours and opens visible -cracks. +On every path except ``, a quad or n-gon whose vertices are +not on a common plane is flattened onto their average plane when its local 2D +basis is built, which moves vertices out from under their neighbours and opens +visible cracks. `` instead fan-triangulates it, which avoids +the crack but silently changes your topology. Triangles are always coplanar, so this only bites n-gons. If you merge faces into quads, either merge only where coplanarity genuinely holds, or snap the @@ -185,8 +214,10 @@ authored geometry is merged, deduped, and interior-culled before it renders: **absolute world units and not configurable**, so small-scale hand-authored meshes can get visibly welded. -If you authored geometry precisely and want it rendered exactly as given, set -`merge: false`, or `meshResolution: "lossless"` to keep only exact reductions. +Note that dedupe and interior culling are treated as *exact* reductions, so they +still run under `meshResolution: "lossless"` — only the lossy approximation is +switched off. **`merge: false` is the only setting that renders your geometry +completely untouched.** ### Degenerate polygons vanish silently @@ -196,10 +227,15 @@ for duplicate or collinear vertices. ### Scale -One world unit is **50 CSS pixels** (`BASE_TILE`). Camera `zoom` is a scale -multiplier applied on top of that conversion — it defaults to `0.65`, so at the -default camera one world unit covers about 32.5 CSS px, and at `zoom: 1` it -covers 50. +Camera `zoom` is **on-screen CSS pixels per world unit**, matching Three.js's +`OrthographicCamera.zoom` shape — at `zoom: 50`, one world unit renders 50 px +across. + +Internally, renderer geometry already lives at `BASE_TILE` (50) CSS px per world +unit, and the camera transform divides that back out (`scale(zoom / 50)`), so +the two cancel. You only need `BASE_TILE` when converting world units to raw CSS +px yourself — for example ``, which is in world units and +mounts a document `width × 50` CSS px wide. ## Rendering Pipeline From 7bd9cb6030164231b4e907a9f7d60ef921e9cbd6 Mon Sep 17 00:00:00 2001 From: Juan Cruz Fortunatti Date: Sun, 9 Aug 2026 11:44:26 +0200 Subject: [PATCH 08/18] docs: scope merge/STL/autoCenter claims to real per-path behavior --- packages/core/README.md | 6 ++-- packages/polycss/README.md | 28 ++++++++-------- packages/react/README.md | 19 ++++++++--- packages/vue/README.md | 19 ++++++++--- website/public/skill.md | 30 +++++++++++++---- .../content/docs/components/poly-scene.mdx | 10 +++++- website/src/content/docs/core-concepts.mdx | 32 +++++++++++++------ 7 files changed, 102 insertions(+), 42 deletions(-) diff --git a/packages/core/README.md b/packages/core/README.md index 194fb1ad..445694b2 100644 --- a/packages/core/README.md +++ b/packages/core/README.md @@ -89,8 +89,10 @@ first. Three constraints bite immediately: via the right-hand rule, and PolyCSS backface-culls, so a reversed face is invisible. - **`color` accepts hex and `rgb()`/`rgba()` only** — not CSS named colors. -- **Non-triangular polygons must be coplanar.** Renderers do not call - `normalizePolygons` for you. +- **Non-triangular polygons must be coplanar.** Only the React/Vue + `` entry point runs `normalizePolygons` for you + (fan-triangulating non-coplanar n-gons); `scene.add(...)`, + ``, ``, and `` do not. ## Three.js parity diff --git a/packages/polycss/README.md b/packages/polycss/README.md index 80cb184f..e830c100 100644 --- a/packages/polycss/README.md +++ b/packages/polycss/README.md @@ -121,25 +121,23 @@ Full reference: [polycss.com/api/three-parity](https://polycss.com/api/three-par - `distance` adds dolly pull-back. - `PolyCamera` is the orthographic default. Use `PolyPerspectiveCamera` when you want perspective depth. -### PolyScene +### Scene options (`createPolyScene`) -- `polygons` renders a static `Polygon[]` directly. +- Geometry enters through `scene.add(parseResult, transform)` — there is **no** `polygons` option. In markup, use `` or `` children. - `directionalLight`, `pointLights` (direction-only, baked mode; optional per-light `castShadow`), and `ambientLight` control scene lighting. - `textureLighting` chooses `"baked"` or `"dynamic"`. -- `textureQuality` controls atlas raster budget. +- `textureQuality` controls atlas raster budget; `textureLeafSizing`, `textureImageRendering`, `textureBackend`, and `textureProjection` set per-polygon texture defaults. - `strategies` can disable selected render strategies for diagnostics. -- `autoCenter` rotates around the rendered mesh bounds instead of world origin. - -### PolyMesh - -- `src` loads `.obj`, `.gltf`, `.glb`, or `.vox` files. -- `mtl` loads companion OBJ materials. -- `polygons` accepts pre-parsed geometry. -- `position`, `scale`, and `rotation` transform the mesh wrapper. -- `autoCenter` shifts the mesh bbox center to local origin. -- `meshResolution` chooses `"lossy"` (default) or `"lossless"` optimization. STL imports use the conservative lossless path in both modes. -- `castShadow` emits CPU-projected SVG shadows. It works in both `"baked"` and `"dynamic"` lighting modes; dynamic-mode shadows are directional-only. -- `shadowDefinition` overrides the scene's parametric shadow resolution for this mesh. +- `autoCenter` rotates around the union bbox of all added meshes instead of world origin, updating as meshes are added or removed. Individual meshes opt out with `excludeFromAutoCenter`. + +### Mesh options + +`` attributes: `src` (loads `.obj`, `.stl`, `.gltf`, `.glb`, or `.vox`), `mtl`, `position`, `scale`, `rotation`, `auto-center`, `mesh-resolution`, `cast-shadow`, `receive-shadow`, `target-size`, `default-color`, `palette`, `include-objects`, `exclude-objects`. There is **no** `polygons` attribute — pass pre-parsed geometry to `scene.add(...)`, or use `` for inline one-off polygons. + +`scene.add(result, transform)` additionally accepts `merge`, `meshResolution`, `stableDom`, `shadowDefinition`, `excludeFromAutoCenter`, and `id`. These are imperative-only — they are not `` attributes. + +- `cast-shadow` / `receive-shadow` emit CPU-projected SVG shadows. They work in both `"baked"` and `"dynamic"` lighting modes; dynamic-mode shadows are directional-only. +- `mesh-resolution` chooses `"lossy"` (default) or `"lossless"`. Note it 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. ### Controls diff --git a/packages/react/README.md b/packages/react/README.md index 55202258..d753532a 100644 --- a/packages/react/README.md +++ b/packages/react/README.md @@ -57,7 +57,13 @@ export default function App() { - `shadow` configures cast-shadow color, opacity, and the parametric shadow knobs (`parametric`, `definition`, `style`, `followAnimation`). - `strategies` can disable selected render strategies for diagnostics. -- `autoCenter` rotates around the rendered mesh bounds instead of world origin. +- `autoCenter` rotates around the bbox of the scene's own `polygons` prop (or + `centerPolygons` when given) instead of world origin. It does **not** see + geometry inside child `` components — with + `` the bbox is empty and + nothing shifts. Pass the mesh's polygons as `centerPolygons`, or use + `` to recenter the mesh itself. (Vanilla + `createPolyScene` differs — it unions every added mesh.) Unlike the vanilla renderer, React re-renders on prop change, so a light change **auto-rebakes** the lit surface in baked mode. For live or animated lights, @@ -72,9 +78,14 @@ prefer `textureLighting="dynamic"`. - `autoCenter` shifts the mesh bbox center to local origin. Note this rewrites vertex data, so `getPolygons()` returns the shifted coordinates. - `meshResolution` chooses `"lossy"` (default) or `"lossless"` optimization. - STL imports use the conservative lossless path in both modes. -- `merge` (default `true`) runs the polygon optimizer. Set `false` to keep - authored geometry exactly as given. + `.stl` **parsing** is conservative — the loader always uses the lossless + optimizer and skips interior culling — but rendering re-optimizes the loaded + polygons: the second pass follows `meshResolution` and interior-culls even at + `"lossless"`. +- `merge` (default `true`) runs the polygon optimizer. Set `false` to render the + polygons you passed exactly as given. It cannot undo `loadMesh`'s own + parse-time optimization, so `src`-loaded geometry is already optimized before + this switch is consulted. - `castShadow` / `receiveShadow` opt the mesh into CPU-projected SVG shadows. - `shadowDefinition` overrides the scene parametric shadow resolution for this mesh. diff --git a/packages/vue/README.md b/packages/vue/README.md index 5b73ce90..022ce5c3 100644 --- a/packages/vue/README.md +++ b/packages/vue/README.md @@ -70,7 +70,13 @@ Props are listed in their template (kebab-case) form. - `shadow` configures cast-shadow color, opacity, and the parametric shadow knobs (`parametric`, `definition`, `style`, `followAnimation`). - `strategies` can disable selected render strategies for diagnostics. -- `auto-center` rotates around the rendered mesh bounds instead of world origin. +- `auto-center` rotates around the bbox of the scene's own `polygons` prop (or + `center-polygons` when given) instead of world origin. It does **not** see + geometry inside child `` components — with + `` the bbox is empty + and nothing shifts. Pass the mesh's polygons as `center-polygons`, or use + `` to recenter the mesh itself. (Vanilla + `createPolyScene` differs — it unions every added mesh.) Unlike the vanilla renderer, Vue re-renders on prop change, so a light change **auto-rebakes** the lit surface in baked mode. For live or animated lights, @@ -85,9 +91,14 @@ prefer `texture-lighting="dynamic"`. - `auto-center` shifts the mesh bbox center to local origin. Note this rewrites vertex data, so `getPolygons()` returns the shifted coordinates. - `mesh-resolution` chooses `"lossy"` (default) or `"lossless"` optimization. - STL imports use the conservative lossless path in both modes. -- `merge` (default `true`) runs the polygon optimizer. Set `false` to keep - authored geometry exactly as given. + `.stl` **parsing** is conservative — the loader always uses the lossless + optimizer and skips interior culling — but rendering re-optimizes the loaded + polygons: the second pass follows `mesh-resolution` and interior-culls even at + `"lossless"`. +- `merge` (default `true`) runs the polygon optimizer. Set `false` to render the + polygons you passed exactly as given. It cannot undo `loadMesh`'s own + parse-time optimization, so `src`-loaded geometry is already optimized before + this switch is consulted. - `cast-shadow` / `receive-shadow` opt the mesh into CPU-projected SVG shadows. - `shadow-definition` overrides the scene parametric shadow resolution for this mesh. diff --git a/website/public/skill.md b/website/public/skill.md index 5610125f..7d81d44c 100644 --- a/website/public/skill.md +++ b/website/public/skill.md @@ -72,10 +72,16 @@ interface Polygon { 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-exact image leaf (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 @@ -92,8 +98,11 @@ 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, casts no shadow, and lights inverted. Wind -counter-clockwise as seen from the side you want to look at. +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). It still casts a ground shadow: shadow +projection ignores winding. Wind counter-clockwise as seen from the side you +want to look at. ```ts // Faces +Z (up) — visible from above. @@ -108,9 +117,11 @@ 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:* if a polygon exists in your data but disappears when the -camera crosses to its other side, inspect winding and normal before touching -culling, lighting, or camera code. +*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 @@ -127,7 +138,12 @@ and `meshResolution` to `"lossy"`: coincident faces within `0.05` world units are deduped, interior faces culled, and lossy merging tolerates up to `0.35` world units of plane displacement (absolute units, not configurable). Dedupe and interior culling count as exact reductions and still run under -`meshResolution: "lossless"` — only `merge: false` renders geometry untouched. +`meshResolution: "lossless"`. `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. For file geometry +exactly as authored, call `parseObj`/`parseStl`/`parseGltf`/`parseVox` directly +and add with `merge: false`. **5. Degenerate polygons vanish silently** — under 3 vertices, zero-length edge, or zero area produces no leaf and no console output. @@ -148,7 +164,7 @@ const scene = createPolyScene(document.getElementById("host")!, { createPolyOrbitControls(scene, { drag: true, wheel: true }); scene.add(createPolyBox({ size: 100, color: "#ffd166" }), { position: [0, 0, 50] }); -scene.add(await loadMesh("/model.glb"), { autoCenter: true, castShadow: true }); +scene.add(await loadMesh("/model.glb"), { castShadow: true }); ``` React (Vue mirrors this with kebab-case props): diff --git a/website/src/content/docs/components/poly-scene.mdx b/website/src/content/docs/components/poly-scene.mdx index eb954016..d3a211c1 100644 --- a/website/src/content/docs/components/poly-scene.mdx +++ b/website/src/content/docs/components/poly-scene.mdx @@ -13,7 +13,7 @@ It's available as a custom element (``), via the imperative `createP (React / Vue prop names use camelCase; the `` custom element accepts the kebab-case form, e.g. `textureQuality` → `texture-quality`.) -The React/Vue components and `createPolyScene()` support the full table. The `` custom element supports `directional-*`, `ambient-*`, `texture-lighting`, `texture-quality`, `auto-center`, and implicit camera attributes; use the imperative API for options such as `shadow`, `seamBleed`, and `strategies` in vanilla. +The React/Vue components and `createPolyScene()` support the full table. The `` custom element supports `directional-*`, `ambient-*`, `texture-lighting`, `texture-quality`, `texture-leaf-sizing`, `texture-image-rendering`, `texture-backend`, `texture-projection`, `auto-center`, and implicit camera attributes; use the imperative API for options such as `shadow`, `seamBleed`, and `strategies` in vanilla. | Prop | Type | Default | Description | |------|------|---------|-------------| @@ -22,6 +22,10 @@ The React/Vue components and `createPolyScene()` support the full table. The `

` atlas slices; `` cannot be disabled. | | `autoCenter` | `boolean` | `false` | Rotate around the content bbox center instead of world origin. Polygon data is not mutated. | @@ -46,6 +50,10 @@ React/Vue `` supports the full table. The `` custom element | `rotation` | `Vec3` | Euler rotation in degrees `[x, y, z]`. | | `textureLighting` | `"baked" \| "dynamic"` | Per-mesh lighting mode override. Defaults to the scene value. | | `textureQuality` | `number \| "auto"` | Atlas bitmap budget and compositor sprite size. React / Vue only; vanilla meshes inherit the scene's `texture-quality`. | +| `textureLeafSizing` | `"canonical" \| "local" \| "raster"` | Per-mesh override of the scene default. React / Vue only; vanilla meshes inherit the scene value. | +| `textureImageRendering` | `"auto" \| "pixelated"` | Per-mesh override of the scene default. React / Vue only; vanilla meshes inherit the scene value. | +| `textureBackend` | `"auto" \| "atlas" \| "image"` | Per-mesh override of the scene default. React / Vue only; vanilla meshes inherit the scene value. | +| `textureProjection` | `"affine" \| "projective"` | Per-mesh override of the scene default. React / Vue only; vanilla meshes inherit the scene value. | | `seamBleed` | `number \| "auto"` | Per-mesh solid seam overscan. React / Vue only; vanilla meshes inherit the scene setting. | | `atomicAtlas` | `boolean` | Hold the previous atlas frame until the next frame is decoded, then swap atomically. React / Vue only. | | `onFrameReady` | `() => void` | Fires when an atomic atlas frame swaps to a ready one. React / Vue only. | diff --git a/website/src/content/docs/core-concepts.mdx b/website/src/content/docs/core-concepts.mdx index adc03d2d..9b8dd2d0 100644 --- a/website/src/content/docs/core-concepts.mdx +++ b/website/src/content/docs/core-concepts.mdx @@ -135,8 +135,12 @@ warnings it returns. ### Winding determines visibility Vertex order sets the face normal by the right-hand rule, and PolyCSS -**backface-culls** every leaf. A polygon wound the wrong way is not merely -shaded oddly — it is invisible, it casts no shadow, and it lights inverted. +**backface-culls** every leaf. A polygon wound the wrong way is invisible from +the side you meant to show, and its Lambert shading is computed from the flipped +normal — typically ambient-only, since the directional term clamps at zero (it +darkens, it does not invert). Winding does **not** stop shadow casting: ground +shadows project every polygon regardless of orientation, so a reversed face can +be invisible and still cast. Vertices are **counter-clockwise seen from the outside** (the side you want to look at): @@ -172,10 +176,12 @@ Consequences worth internalizing when you build a shape library: optimizer collapsing intentional reverse-wound faces; it is not a render-time flag and will not make a face visible from behind. Orient the winding. -**Diagnostic rule:** if a polygon exists in your generated data but disappears -when the camera crosses to its other side, inspect its winding and normal -*before* you touch culling, lighting, or camera code. That symptom is winding -essentially every time. +**Diagnostic rule:** a single-sided face is *supposed* to disappear when the +camera moves behind it — that alone is not a bug. The winding symptom is a +surface missing or flickering **from the viewpoint it was built to be seen +from**: the polygon exists in your data, its neighbours render, but the face +only shows from the opposite side. When you see that, inspect the face's winding +and normal *before* you touch culling, lighting, or camera code. ### `color` is not a full CSS color @@ -216,8 +222,16 @@ authored geometry is merged, deduped, and interior-culled before it renders: Note that dedupe and interior culling are treated as *exact* reductions, so they still run under `meshResolution: "lossless"` — only the lossy approximation is -switched off. **`merge: false` is the only setting that renders your geometry -completely untouched.** +switched off. + +**`merge: false` renders the polygon array you pass completely untouched — but +only on the paths that accept it**: vanilla `scene.add(...)` and React/Vue +``. It does not exist on `` +(always normalized and merged) or on the `` custom element. And it +cannot undo `loadMesh`'s own parse-time optimization — geometry loaded from a +file is deduped and optimized *before* `merge` is ever consulted. To render file +geometry exactly as authored, parse it with `parseObj` / `parseStl` / +`parseGltf` / `parseVox` directly and add the result with `merge: false`. ### Degenerate polygons vanish silently @@ -260,7 +274,7 @@ You normally do not target these tags directly; use `Poly`, `PolyMesh`, classes, ## Automatic Polygon Merge -Before rendering, PolyCSS automatically optimizes loaded meshes. `meshResolution: "lossless"` keeps exact planar candidates only; the default `"lossy"` mode also bakes solid texture swatches, merges visually redundant baked swatch colors, tries static triangle simplification for eligible non-animated imports, and can merge near-coplanar candidates within a bounded displacement budget. Candidates are accepted only when the final DOM win is meaningful and whole-mesh seam diagnostics do not regress. STL imports use the conservative lossless path and skip ray-based interior culling in both modes because public CAD/STL files often contain shell, winding, or topology quirks. This keeps DOM element counts low for flat surfaces without changing the intended rendered shape. +Before rendering, PolyCSS automatically optimizes loaded meshes. `meshResolution: "lossless"` keeps exact planar candidates only; the default `"lossy"` mode also bakes solid texture swatches, merges visually redundant baked swatch colors, tries static triangle simplification for eligible non-animated imports, and can merge near-coplanar candidates within a bounded displacement budget. Candidates are accepted only when the final DOM win is meaningful and whole-mesh seam diagnostics do not regress. STL **parsing** is conservative — the import pass uses the lossless optimizer and skips ray-based interior culling, because public CAD/STL files often contain shell, winding, or topology quirks. That protection is parse-time only: the renderer's own optimization pass does not know the geometry came from STL, so it interior-culls and, by default, lossy-merges. Preserve the parsed STL surface with `merge: false`. This keeps DOM element counts low for flat surfaces without changing the intended rendered shape. Per-polygon DOM identity is preserved for polygons that cannot merge; polygons inside a merged flat region become one rendered element. From b4114a3d1d97950dc1602d855317d14dcd1036eb Mon Sep 17 00:00:00 2001 From: Juan Cruz Fortunatti Date: Sun, 9 Aug 2026 12:40:55 +0200 Subject: [PATCH 09/18] docs: correct seamBleed, parser identity, availability, and material contracts --- AGENTS.md | 2 +- website/public/skill.md | 10 +++++++--- website/src/content/docs/api/types.mdx | 14 +++++++++++--- website/src/content/docs/components/poly-scene.mdx | 10 +++++----- website/src/content/docs/core-concepts.mdx | 10 ++++++++-- 5 files changed, 32 insertions(+), 14 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index b4226375..bd83e22a 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -50,7 +50,7 @@ Voxel-shaped meshes are the exception to "all polygons stay mounted": meshes wit 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`). 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. 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. +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. Solid seam bleed gives detected shared solid edges 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 exposed: `seamBleed` is a scene option in all three renderers plus a React/Vue per-mesh prop, though not a custom-element attribute. **Known divergence:** vanilla clamps the number to `0..1` and multiplies the `1.5` px default (`resolveBleedRatio`), while React/Vue pass it through as the absolute CSS-px request (`resolveSeamBleed`). Defaults match; non-default numbers do not. Unifying this is a pending cross-renderer fix, not a docs problem. 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 ``/`` 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. diff --git a/website/public/skill.md b/website/public/skill.md index 7d81d44c..db035d55 100644 --- a/website/public/skill.md +++ b/website/public/skill.md @@ -141,9 +141,13 @@ interior culling count as exact reductions and still run under `meshResolution: "lossless"`. `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. For file geometry -exactly as authored, call `parseObj`/`parseStl`/`parseGltf`/`parseVox` directly -and add with `merge: false`. +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, Z-up axis remap, coordinate 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-length edge, or zero area produces no leaf and no console output. diff --git a/website/src/content/docs/api/types.mdx b/website/src/content/docs/api/types.mdx index e24dc183..83cb212c 100644 --- a/website/src/content/docs/api/types.mdx +++ b/website/src/content/docs/api/types.mdx @@ -63,6 +63,12 @@ interface PolyMaterial { texture: string; /** Optional stable identity for renderer-side dedupe/cache paths. */ key?: string; + /** Source-exact image sampling (URL + source rect) for direct image leaves + * that skip atlas rasterisation. */ + imageSource?: PolyTextureImageSource; + /** Per-material texture presentation (backend, projection, filtering, + * lighting). See Texture Presentation below. */ + presentation?: PolyTexturePresentation; } ``` @@ -357,9 +363,11 @@ interface PolyMeshTransform { position?: Vec3; scale?: number | Vec3; rotation?: Vec3; - /** Merge coplanar polygons before rendering. Defaults to true. Set false for - * animated meshes whose triangle topology must stay stable, or to render - * authored geometry exactly as given. */ + /** Run the mesh optimizer (coincident-face dedupe, interior-face cull, and + * coplanar/lossy merging per `meshResolution`) before rendering. Defaults to + * true. `false` skips all of it — for animated meshes whose triangle + * topology must stay stable, or to render the polygon array exactly as + * given. */ merge?: boolean; meshResolution?: MeshResolution; stableDom?: boolean; diff --git a/website/src/content/docs/components/poly-scene.mdx b/website/src/content/docs/components/poly-scene.mdx index d3a211c1..4447c471 100644 --- a/website/src/content/docs/components/poly-scene.mdx +++ b/website/src/content/docs/components/poly-scene.mdx @@ -5,7 +5,7 @@ description: Scene component that sets up the 3D viewport, camera, and lighting import { Tabs, TabItem } from '@astrojs/starlight/components'; -The scene is the root of every PolyCSS render tree. It applies scene-level lighting and atlas options, then renders its children (typically meshes or individual polygons) in 3D space. `` / `PolyScene` must always be nested inside a camera element (`` / `PolyCamera` or the perspective variant): the camera owns the projection and orbital state. +The scene is the root of every PolyCSS render tree. It applies scene-level lighting and atlas options, then renders its children (typically meshes or individual polygons) in 3D space. `PolyScene` (React/Vue) must be nested inside a camera component (`PolyCamera`, `PolyPerspectiveCamera`, or `PolyOrthographicCamera`) — it throws otherwise — and `createPolyScene()` takes a required camera handle in its options. The `` custom element prefers an ancestor camera element, but can stand alone: without one it builds an implicit camera from its own `perspective`, `rot-x`, `rot-y`, and `zoom` attributes. It's available as a custom element (``), via the imperative `createPolyScene(host, opts)` API, and as React / Vue components (``). @@ -13,7 +13,7 @@ It's available as a custom element (``), via the imperative `createP (React / Vue prop names use camelCase; the `` custom element accepts the kebab-case form, e.g. `textureQuality` → `texture-quality`.) -The React/Vue components and `createPolyScene()` support the full table. The `` custom element supports `directional-*`, `ambient-*`, `texture-lighting`, `texture-quality`, `texture-leaf-sizing`, `texture-image-rendering`, `texture-backend`, `texture-projection`, `auto-center`, and implicit camera attributes; use the imperative API for options such as `shadow`, `seamBleed`, and `strategies` in vanilla. +The React/Vue components and `createPolyScene()` support the full table except rows marked otherwise (`polygons`, `children`, and `centerPolygons` are framework-only; `shadow.dragDefinition` — and the imperative-only `debugShadowAttrs` option, not listed here — are vanilla-only). The `` custom element supports `directional-*`, `ambient-*`, `texture-lighting`, `texture-quality`, `texture-leaf-sizing`, `texture-image-rendering`, `texture-backend`, `texture-projection`, `auto-center`, and — when no ancestor camera element is present — the implicit camera attributes `perspective`, `rot-x`, `rot-y`, `zoom`; use the imperative API for options such as `shadow`, `seamBleed`, and `strategies` in vanilla. | Prop | Type | Default | Description | |------|------|---------|-------------| @@ -26,7 +26,7 @@ The React/Vue components and `createPolyScene()` support the full table. The `

` atlas slices; `` cannot be disabled. | | `autoCenter` | `boolean` | `false` | Rotate around the content bbox center instead of world origin. Polygon data is not mutated. | | `centerPolygons` | `Polygon[]` | None | (Framework only.) Bbox source for `autoCenter` when renderable polygons live inside child meshes. | @@ -34,11 +34,11 @@ The React/Vue components and `createPolyScene()` support the full table. The `

` / `PolyCamera`): `rot-x`, `rot-y`, `zoom`, `distance`. `` carries no camera attributes. Add a child `` / `` to enable drag, wheel, or autorotate: see [PolyOrbitControls](/components/poly-controls). +**Camera state and input** are normally set on the wrapping camera element (`` / `PolyCamera`): `rot-x`, `rot-y`, `zoom`, `distance`. Without an ancestor camera element, `` falls back to an implicit camera driven by its own `perspective`, `rot-x`, `rot-y`, and `zoom` attributes. Add a child `` / `` to enable drag, wheel, or autorotate: see [PolyOrbitControls](/components/poly-controls). ## Mesh props / attributes -React/Vue `` supports the full table. The `` custom element currently supports `src`, `mtl`, `mesh-resolution`, `position`, `scale`, `rotation`, and `auto-center`; use `scene.add(result, opts)` for advanced vanilla mesh options such as `castShadow`. +React/Vue `` supports the full table. The `` custom element supports `src`, `mtl`, `mesh-resolution`, `position`, `scale`, `rotation`, `auto-center`, `cast-shadow`, `receive-shadow`, plus the OBJ parse attributes `target-size`, `default-color`, `palette`, `include-objects`, and `exclude-objects` (these five affect `.obj` sources only). `position`, `scale`, `rotation`, `cast-shadow`, and `receive-shadow` update live; the parse attributes trigger a reload. Use `scene.add(result, opts)` for vanilla options the element doesn't expose, such as `merge`, `stableDom`, and `shadowDefinition`. | Prop | Type | Description | |------|------|-------------| diff --git a/website/src/content/docs/core-concepts.mdx b/website/src/content/docs/core-concepts.mdx index 9b8dd2d0..5de14147 100644 --- a/website/src/content/docs/core-concepts.mdx +++ b/website/src/content/docs/core-concepts.mdx @@ -229,8 +229,14 @@ only on the paths that accept it**: vanilla `scene.add(...)` and React/Vue ``. It does not exist on `` (always normalized and merged) or on the `` custom element. And it cannot undo `loadMesh`'s own parse-time optimization — geometry loaded from a -file is deduped and optimized *before* `merge` is ever consulted. To render file -geometry exactly as authored, parse it with `parseObj` / `parseStl` / +file is deduped and optimized *before* `merge` is ever consulted. + +There is **no fully identity path for file geometry**: the parsers themselves +normalize their input (fit to `targetSize`, default `60`; reposition the origin; +remap axes to Z-up; round coordinates; fan-triangulate n-gons and drop +degenerate triangles). STL additionally repairs winding, and `.vox` synthesizes +greedy-meshed quads from the voxel grid. To preserve the *direct parser output* +from any further renderer optimization, call `parseObj` / `parseStl` / `parseGltf` / `parseVox` directly and add the result with `merge: false`. ### Degenerate polygons vanish silently From 9707dc653d04ce1d6f4ce261627ef4ead1ea6e4d Mon Sep 17 00:00:00 2001 From: Juan Cruz Fortunatti Date: Sun, 9 Aug 2026 13:23:13 +0200 Subject: [PATCH 10/18] docs: document seamBleed auto divergence and implicit camera attribute limits --- AGENTS.md | 2 +- packages/polycss/README.md | 3 ++- packages/react/README.md | 3 ++- packages/vue/README.md | 3 ++- website/src/content/docs/api/types.mdx | 3 +++ website/src/content/docs/components/poly-scene.mdx | 10 +++++----- 6 files changed, 15 insertions(+), 9 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index bd83e22a..73a75c5e 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -50,7 +50,7 @@ Voxel-shaped meshes are the exception to "all polygons stay mounted": meshes wit 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`). 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. Solid seam bleed gives detected shared solid edges 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 exposed: `seamBleed` is a scene option in all three renderers plus a React/Vue per-mesh prop, though not a custom-element attribute. **Known divergence:** vanilla clamps the number to `0..1` and multiplies the `1.5` px default (`resolveBleedRatio`), while React/Vue pass it through as the absolute CSS-px request (`resolveSeamBleed`). Defaults match; non-default numbers do not. Unifying this is a pending cross-renderer fix, not a docs problem. +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. Solid seam bleed gives detected shared solid edges 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 exposed: `seamBleed` is a scene option in all three renderers plus a React/Vue per-mesh prop, though not a custom-element attribute. **Known divergence:** vanilla clamps the number to `0..1` and multiplies the `1.5` px default (`resolveBleedRatio`), while React/Vue pass the raw value straight into core plan construction, where `normalizedSeamBleed` accepts finite positive numbers only. `"auto"` therefore diverges too: vanilla resolves it to the full `1.5` px default, but React/Vue's `"auto"` yields no shared-edge overscan at all (the plan's `bleedRatio` still resolves to `1`, so per-strategy primitive bleeds are unchanged). Only the numeric default `1.5` matches across renderers. Unifying this is a pending cross-renderer fix, not a docs problem. 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 ``/`` 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. diff --git a/packages/polycss/README.md b/packages/polycss/README.md index e830c100..6cfddbc9 100644 --- a/packages/polycss/README.md +++ b/packages/polycss/README.md @@ -155,7 +155,8 @@ mesh. Its content is centered on the wrapper's local origin, so rotation and scale pivot at the visible center. React and Vue expose it as ``. `width` and `height` are **world units**, not pixels — the mounted document is -`width × 50` CSS px wide (`BASE_TILE`), so `16 × 9` yields an 800 × 450 px page. +`width × 50` by `height × 50` CSS px (`BASE_TILE`), so `16 × 9` yields an +800 × 450 px page. `position` is world units too. ```html diff --git a/packages/react/README.md b/packages/react/README.md index d753532a..1973d644 100644 --- a/packages/react/README.md +++ b/packages/react/README.md @@ -97,7 +97,8 @@ Renders a live document as a flat quad in the scene, with the same 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` CSS px wide (`BASE_TILE`), so `16 × 9` yields an 800 × 450 px page. +`width × 50` by `height × 50` CSS px (`BASE_TILE`), so `16 × 9` yields an +800 × 450 px page. ```tsx diff --git a/packages/vue/README.md b/packages/vue/README.md index 022ce5c3..3d04407c 100644 --- a/packages/vue/README.md +++ b/packages/vue/README.md @@ -110,7 +110,8 @@ Renders a live document as a flat quad in the scene, with the same 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` CSS px wide (`BASE_TILE`), so `16 × 9` yields an 800 × 450 px page. +`width × 50` by `height × 50` CSS px (`BASE_TILE`), so `16 × 9` yields an +800 × 450 px page. ```vue diff --git a/website/src/content/docs/api/types.mdx b/website/src/content/docs/api/types.mdx index 83cb212c..e08b8b5d 100644 --- a/website/src/content/docs/api/types.mdx +++ b/website/src/content/docs/api/types.mdx @@ -244,6 +244,8 @@ type PolySeamBleedEdges = `TextureQuality` controls atlas bitmap budget and CSS sprite size. `PolySeamBleed` controls solid-primitive overscan for detected shared seam edges. +Renderer semantics for `PolySeamBleed` currently diverge: vanilla `createPolyScene` treats a number as a `0..1` ratio of the built-in `1.5` px default and resolves `"auto"` to the full `1.5` px, while React/Vue treat a number as the requested CSS-pixel amount and treat an explicit `"auto"` as no shared-edge overscan at all (atlas planning accepts numeric amounts only; per-strategy primitive bleeds are unaffected). The numeric default `1.5` behaves identically everywhere. + --- ## `PolyRenderStrategiesOption` @@ -324,6 +326,7 @@ interface PolySceneOptions { textureBackend?: PolyTextureBackend; /** Default texture projection request. Defaults to "affine". */ textureProjection?: PolyTextureProjection; + /** Shared-edge solid overscan. See `PolySeamBleed` above for cross-renderer semantics. */ seamBleed?: PolySeamBleed; strategies?: PolyRenderStrategiesOption; autoCenter?: boolean; diff --git a/website/src/content/docs/components/poly-scene.mdx b/website/src/content/docs/components/poly-scene.mdx index 4447c471..b373ae20 100644 --- a/website/src/content/docs/components/poly-scene.mdx +++ b/website/src/content/docs/components/poly-scene.mdx @@ -5,7 +5,7 @@ description: Scene component that sets up the 3D viewport, camera, and lighting import { Tabs, TabItem } from '@astrojs/starlight/components'; -The scene is the root of every PolyCSS render tree. It applies scene-level lighting and atlas options, then renders its children (typically meshes or individual polygons) in 3D space. `PolyScene` (React/Vue) must be nested inside a camera component (`PolyCamera`, `PolyPerspectiveCamera`, or `PolyOrthographicCamera`) — it throws otherwise — and `createPolyScene()` takes a required camera handle in its options. The `` custom element prefers an ancestor camera element, but can stand alone: without one it builds an implicit camera from its own `perspective`, `rot-x`, `rot-y`, and `zoom` attributes. +The scene is the root of every PolyCSS render tree. It applies scene-level lighting and atlas options, then renders its children (typically meshes or individual polygons) in 3D space. `PolyScene` (React/Vue) must be nested inside a camera component (`PolyCamera`, `PolyPerspectiveCamera`, or `PolyOrthographicCamera`) — it throws otherwise — and `createPolyScene()` takes a required camera handle in its options. The `` custom element prefers an ancestor camera element, but can stand alone: without one it builds an implicit camera from its own `perspective`, `rot-x`, `rot-y`, `zoom`, `distance`, and `target` attributes. It's available as a custom element (``), via the imperative `createPolyScene(host, opts)` API, and as React / Vue components (``). @@ -13,7 +13,7 @@ It's available as a custom element (``), via the imperative `createP (React / Vue prop names use camelCase; the `` custom element accepts the kebab-case form, e.g. `textureQuality` → `texture-quality`.) -The React/Vue components and `createPolyScene()` support the full table except rows marked otherwise (`polygons`, `children`, and `centerPolygons` are framework-only; `shadow.dragDefinition` — and the imperative-only `debugShadowAttrs` option, not listed here — are vanilla-only). The `` custom element supports `directional-*`, `ambient-*`, `texture-lighting`, `texture-quality`, `texture-leaf-sizing`, `texture-image-rendering`, `texture-backend`, `texture-projection`, `auto-center`, and — when no ancestor camera element is present — the implicit camera attributes `perspective`, `rot-x`, `rot-y`, `zoom`; use the imperative API for options such as `shadow`, `seamBleed`, and `strategies` in vanilla. +The React/Vue components and `createPolyScene()` support the full table except rows marked otherwise (`polygons`, `children`, and `centerPolygons` are framework-only; `shadow.dragDefinition` — and the imperative-only `debugShadowAttrs` option, not listed here — are vanilla-only). The `` custom element supports `directional-*`, `ambient-*`, `texture-lighting`, `texture-quality`, `texture-leaf-sizing`, `texture-image-rendering`, `texture-backend`, `texture-projection`, `auto-center`, and — when no ancestor camera element is present — the implicit camera attributes `perspective`, `rot-x`, `rot-y`, `zoom`, `distance`, and `target`. Only `perspective`, `rot-x`, `rot-y`, and `zoom` are *observed*: mutating `distance` or `target` alone does not update the implicit camera — their current values are read at connect time and re-applied only when a `rot-x`, `rot-y`, or `zoom` mutation next fires. (`perspective` likewise only selects the camera type at connect.) Use the imperative API for options such as `shadow`, `seamBleed`, and `strategies` in vanilla. | Prop | Type | Default | Description | |------|------|---------|-------------| @@ -26,7 +26,7 @@ The React/Vue components and `createPolyScene()` support the full table except r | `textureImageRendering` | `"auto" \| "pixelated"` | `"auto"` | Default image filtering for atlas and direct-image texture leaves. | | `textureBackend` | `"auto" \| "atlas" \| "image"` | `"auto"` | Default texture backend request per polygon. | | `textureProjection` | `"affine" \| "projective"` | `"affine"` | Default texture projection request for textured quads. | -| `seamBleed` | `number \| "auto"` | `1.5` | Overscan on detected shared solid seam edges. Every request is additionally fitted per edge to what the polygon plan can safely absorb; `0` disables it. **Numeric semantics currently differ by renderer:** React/Vue treat the number as the requested CSS-pixel amount, while vanilla `createPolyScene` clamps it to `0..1` and multiplies the built-in `1.5` px default (so vanilla values above `1` behave like `1`, capping at 1.5 px). Defaults agree; only non-default numbers diverge. | +| `seamBleed` | `number \| "auto"` | `1.5` | Overscan on detected shared solid seam edges. Every request is additionally fitted per edge to what the polygon plan can safely absorb; `0` disables it. **Semantics currently differ by renderer.** *Numbers:* React/Vue treat the number as the requested CSS-pixel amount, while vanilla `createPolyScene` clamps it to `0..1` and multiplies the built-in `1.5` px default (so vanilla values above `1` behave like `1`, capping at 1.5 px). *`"auto"`:* vanilla resolves it to the full `1.5` px default, but React/Vue forward it into atlas planning, which accepts numeric amounts only — so an explicit `"auto"` in React/Vue produces **no** shared-edge overscan (per-strategy primitive bleeds are unaffected). Only the numeric default `1.5` behaves identically everywhere. | | `strategies` | `{ disable?: ("b" \| "i" \| "u")[] }` | None | Diagnostic override for render strategy selection. Disabled solid strategies fall through to `` atlas slices; `` cannot be disabled. | | `autoCenter` | `boolean` | `false` | Rotate around the content bbox center instead of world origin. Polygon data is not mutated. | | `centerPolygons` | `Polygon[]` | None | (Framework only.) Bbox source for `autoCenter` when renderable polygons live inside child meshes. | @@ -34,7 +34,7 @@ The React/Vue components and `createPolyScene()` support the full table except r | `polygons` | `Polygon[]` | None | (Framework only.) Flat array of polygon objects rendered as direct children. Composes with JSX/slot children. | | `children` | None | None | Meshes, polygons, controls, helpers, selection wrappers, and transform controls. | -**Camera state and input** are normally set on the wrapping camera element (`` / `PolyCamera`): `rot-x`, `rot-y`, `zoom`, `distance`. Without an ancestor camera element, `` falls back to an implicit camera driven by its own `perspective`, `rot-x`, `rot-y`, and `zoom` attributes. Add a child `` / `` to enable drag, wheel, or autorotate: see [PolyOrbitControls](/components/poly-controls). +**Camera state and input** are normally set on the wrapping camera element (`` / `PolyCamera`): `rot-x`, `rot-y`, `zoom`, `distance`. Without an ancestor camera element, `` falls back to an implicit camera driven by its own `perspective`, `rot-x`, `rot-y`, `zoom`, `distance`, and `target` attributes (`distance` and `target` are read at connect and on the next `rot-x` / `rot-y` / `zoom` change — they are not live-observed on their own). Add a child `` / `` to enable drag, wheel, or autorotate: see [PolyOrbitControls](/components/poly-controls). ## Mesh props / attributes @@ -64,7 +64,7 @@ React/Vue `` supports the full table. The `` custom element | `castShadow` | `boolean` | Emit SVG cast shadows in both lighting modes; projections update when light, ground, or mesh geometry changes. | | `receiveShadow` | `boolean` | Casters project per-coplanar-face SVG shadows onto this mesh's visible surfaces (Three.js `mesh.receiveShadow` semantics). Any receiver in the scene disables the casters' ground-shadow fallback. Defaults to `false`. | | `shadowDefinition` | `number` | Per-mesh parametric-shadow detail, overriding the scene's `shadow.definition` (only when `shadow.parametric`). | -| `merge` | `boolean` | Run the polygon optimizer. Defaults to `true`. Set `false` to render authored geometry exactly as given — no merging, deduping, or interior culling. | +| `merge` | `boolean` | Run the polygon optimizer (dedupe, interior cull, coplanar/lossy merge). Defaults to `true`. Set `false` to render **the polygon array entering the renderer** exactly as given. It cannot restore source-file geometry: with `src`, `loadMesh` has already optimized the parse result before `merge` is consulted. | | `fallback` | `ReactNode` | Rendered while `src` is loading. (React / Vue only.) | | `errorFallback` | `(error: Error) => ReactNode` | Rendered if parse fails. (React / Vue only.) | | `children` | `(polygon, index) => ReactNode` | Per-polygon render prop / scoped slot. (React / Vue only.) | From 2d40ec0ec413efbdae462f24fbc651bb040b4daf Mon Sep 17 00:00:00 2001 From: Juan Cruz Fortunatti Date: Sun, 9 Aug 2026 16:59:48 +0200 Subject: [PATCH 11/18] docs: correct parser lifecycle, texture precedence, axis handling, and lossy bounds --- packages/core/README.md | 15 ++++++++---- website/public/skill.md | 14 +++++++---- website/src/content/docs/api/types.mdx | 14 +++++++---- website/src/content/docs/core-concepts.mdx | 27 +++++++++++++++------- 4 files changed, 50 insertions(+), 20 deletions(-) diff --git a/packages/core/README.md b/packages/core/README.md index 445694b2..a2495d5d 100644 --- a/packages/core/README.md +++ b/packages/core/README.md @@ -30,8 +30,13 @@ npm install @layoutit/polycss-core ## Parsing a mesh without a browser The parsers (`parseObj`, `parseStl`, `parseGltf`, `parseVox`, `parseMtl`) are -pure, synchronous functions over already-loaded bytes and strings, so they run -under Node. `loadMesh` is the convenience wrapper on top: it fetches a URL and +synchronous functions over already-loaded bytes and strings, so they run under +Node. `parseGltf` has two caveats: `.gltf` files with external `.bin` buffers +need an `options.resolveBuffer` callback returning the bytes as a `Uint8Array` +**synchronously** (returning a Promise throws — read the buffers first), and +embedded images mint blob object URLs, so callers must call `result.dispose()` +when done with the mesh. `dispose()` is idempotent, and a no-op for the other +parsers. `loadMesh` is the convenience wrapper on top: it fetches a URL and dispatches by extension, so it needs `fetch` and is not pure. ```ts @@ -76,8 +81,10 @@ already-loaded input. - **Atlas planning** — the pure-math half of the texture atlas pipeline. Canvas rasterisation itself lives in each renderer, because it needs the DOM. -Everything exported from `src/index.ts` is the supported surface; anything else -is implementation detail. +The package has two public entry points: the root (`@layoutit/polycss-core`, +exported from `src/index.ts`) and the Three.js parity subpath +(`@layoutit/polycss-core/three`, described below). Everything they export is the +supported surface; anything else is implementation detail. ## Authoring polygons directly diff --git a/website/public/skill.md b/website/public/skill.md index db035d55..6baf1576 100644 --- a/website/public/skill.md +++ b/website/public/skill.md @@ -86,7 +86,10 @@ 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 - normalize coordinates with a handedness-preserving axis map. + 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 @@ -135,15 +138,18 @@ 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 tolerates up to `0.35` -world units of plane displacement (absolute units, not configurable). Dedupe and +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. All +absolute world units, not configurable. Dedupe and interior culling count as exact reductions and still run under `meshResolution: "lossless"`. `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, Z-up axis remap, coordinate rounding, +`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 diff --git a/website/src/content/docs/api/types.mdx b/website/src/content/docs/api/types.mdx index e08b8b5d..75481e7b 100644 --- a/website/src/content/docs/api/types.mdx +++ b/website/src/content/docs/api/types.mdx @@ -142,10 +142,16 @@ Direct image leaves (`backend: "image"`) are **source-lit only** — they use `lighting: "source"` and keep the source pixels untouched. A textured polygon that needs scene lighting falls back to the atlas backend. -These are also scene-level defaults: `textureBackend`, `textureProjection`, -`textureImageRendering`, and `textureLeafSizing` on `PolySceneOptions` (and the -matching `` props) set the default for every polygon, and a polygon's -own `texturePresentation` overrides it. +These are also scene-level defaults: `textureBackend`, `textureProjection`, and +`textureImageRendering` on `PolySceneOptions` (and the matching `` +props) set the default for every polygon. From weakest to strongest, the +resolved presentation is: scene defaults → `material.presentation` → (for +`imageRendering` only) the selected image source's own `imageRendering` → the +polygon's `texturePresentation`. The image source itself resolves as +`polygon.textureImageSource` over `material.imageSource`. + +`textureLeafSizing` is different: it is a scene/atlas-level option with **no** +per-polygon override — `PolyTexturePresentation` has no leaf-sizing field. --- diff --git a/website/src/content/docs/core-concepts.mdx b/website/src/content/docs/core-concepts.mdx index 5de14147..cec0d89a 100644 --- a/website/src/content/docs/core-concepts.mdx +++ b/website/src/content/docs/core-concepts.mdx @@ -23,7 +23,7 @@ Where voxcss used to render a single voxel cube, PolyCSS renders any regular pol PolyCSS exposes three composable concepts. Each one ships as a custom element (vanilla) and as a React / Vue component: -- **Scene** (`` / `PolyScene`): the render tree root. Always nested inside a camera element. Sets up lighting and fills its parent element. +- **Scene** (`` / `PolyScene`): the render tree root. Normally nested inside a camera element (the custom element can also stand alone — see [Camera](#camera)). Sets up lighting and fills its parent element. - **Mesh** (`` / `PolyMesh`): loads a mesh from a URL (OBJ / STL / glTF / GLB / VOX). Internally expands to one polygon child per face. Convenience wrapper around the parser + renderer. - **Polygon** (`` / `Poly`): one polygon. The atomic primitive. Renders as one internal DOM leaf with `transform: matrix3d(...)`. Accepts standard DOM event handlers, classes, and styles: this is what makes PolyCSS "DOM-native 3D" rather than "3D inside a black-box canvas". @@ -31,7 +31,9 @@ A mesh element is internally `polygons.map(p => )`, so any ren ## Camera -The camera element (`` / `PolyCamera`) is always the **outer** node. `` / `PolyScene` is nested inside it. Camera attributes (`rot-x`, `rot-y`, `zoom`, `distance`) belong on the camera element, never on the scene. `PolyCamera` is orthographic by default; use `PolyPerspectiveCamera` for depth foreshortening. +The camera element (`` / `PolyCamera`) is normally the **outer** node, with `` / `PolyScene` nested inside it, and camera attributes (`rot-x`, `rot-y`, `zoom`, `distance`) belong on the camera element rather than the scene. `PolyCamera` is orthographic by default; use `PolyPerspectiveCamera` for depth foreshortening. + +**One exception:** the `` custom element can stand alone. With no ancestor camera element it builds an implicit camera from its own `perspective`, `rot-x`, `rot-y`, `zoom`, `distance`, and `target` attributes — see [PolyScene](/components/poly-scene). React/Vue have no such fallback: `PolyScene` throws outside a camera component. ```html @@ -112,8 +114,13 @@ and **which entry point** you hand it to. Neither is uniform: | OBJ | **Preserved as authored.** A file wound inconsistently stays that way. | | glTF / GLB | **Preserved as authored.** `doubleSided` materials emit reversed duplicate triangles. | -Every parser normalizes coordinates (fit-to-target scale, Y-up → Z-up), and the -axis permutation is chosen specifically so it does *not* flip handedness. +Every parser fits the mesh to its target size and normalizes into PolyCSS's Z-up +coordinates, but the axis transform is per-format: OBJ and glTF/GLB apply the +cyclic permutation `(x,y,z) → (z,x,y)` to bring their +Y-up convention to +Z-up +(glTF can opt out with `upAxis: "z"`); STL defaults to identity axes, the common +CAD export convention, with the permutation as opt-in; `.vox` is already Z-up +and only rotates the horizontal plane. Where a permutation is applied it is +cyclic rather than a y↔z swap, precisely so it never flips handedness. `normalizePolygons` — which drops degenerate polygons, strips mismatched `uvs`, replaces unparseable colors with `#cccccc`, and **fan-triangulates non-coplanar @@ -215,10 +222,14 @@ authored geometry is merged, deduped, and interior-culled before it renders: - Coincident faces closer than `0.05` world units are deduped. - Fully-interior faces are culled. -- Lossy merging tolerates up to `0.35` world units of plane displacement and - `0.04` of boundary displacement, at up to `15°` of angle change. These are - **absolute world units and not configurable**, so small-scale hand-authored - meshes can get visibly welded. +- Lossy merging starts at up to `0.35` world units of plane displacement and + `0.04` of boundary displacement, at up to `15°` of angle change. **These are + not the maximum.** When the default pass doesn't pay off, the optimizer also + tries progressively more aggressive variants at `30°`, `45°`, and `60°` — the + widest also raising boundary displacement to `0.06`. Those are accepted only + on a material render-cost win with non-worsening seam diagnostics, but they + *can* apply to your geometry. All values are **absolute world units and not + configurable**, so small-scale hand-authored meshes can get visibly welded. Note that dedupe and interior culling are treated as *exact* reductions, so they still run under `meshResolution: "lossless"` — only the lossy approximation is From c9a1e4beca9863acaeb3683346efff45de2c03b6 Mon Sep 17 00:00:00 2001 From: Juan Cruz Fortunatti Date: Sun, 9 Aug 2026 17:21:11 +0200 Subject: [PATCH 12/18] build(readme): sync only shared blocks so packages keep their own READMEs --- .github/scripts/sync-package-readmes.mjs | 107 +++++++++++++++++++---- .github/workflows/ci.yml | 3 + AGENTS.md | 24 +++++ README.md | 8 ++ package.json | 1 + packages/core/README.md | 8 ++ packages/polycss/README.md | 22 ++--- packages/react/README.md | 9 +- packages/vue/README.md | 9 +- 9 files changed, 157 insertions(+), 34 deletions(-) diff --git a/.github/scripts/sync-package-readmes.mjs b/.github/scripts/sync-package-readmes.mjs index f6ec7a3e..9fc3e125 100644 --- a/.github/scripts/sync-package-readmes.mjs +++ b/.github/scripts/sync-package-readmes.mjs @@ -1,40 +1,111 @@ -import { copyFileSync } from "node:fs"; +/** + * Syncs the SHARED blocks of the root README into each package README. + * + * Each package README is a real, hand-written, committed file — what you read + * in the repo is what publishes to npm. This script only refreshes the regions + * delimited by: + * + * + * + * Everything between those blocks is package-specific and never touched. A + * package opts in per block simply by containing the matching markers; a + * package with no markers (or a subset) is left alone accordingly. + * + * Runs as `prepack` in every publishable package, so a stale block can never + * reach npm. Run with `--check` in CI to fail on drift instead of writing. + */ +import { readFileSync, writeFileSync } from "node:fs"; import { dirname, relative, resolve } from "node:path"; import { fileURLToPath } from "node:url"; const repoRoot = resolve(dirname(fileURLToPath(import.meta.url)), "..", ".."); const source = resolve(repoRoot, "README.md"); + const targets = [ "packages/core/README.md", "packages/polycss/README.md", "packages/react/README.md", "packages/vue/README.md", -]; -const packageSpecificTargets = [ "packages/fonts/README.md", "packages/morph/README.md", ]; -const invokedFrom = relative(repoRoot, process.cwd()); -const invokedFromPackageReadme = invokedFrom.startsWith("packages/") - ? `${invokedFrom}/README.md` - : undefined; +const checkOnly = process.argv.includes("--check"); -if ( - invokedFromPackageReadme !== undefined - && packageSpecificTargets.includes(invokedFromPackageReadme) -) { - console.log(`[sync-package-readmes] preserved ${invokedFromPackageReadme}`); - process.exit(0); +const blockRe = (name) => + new RegExp( + `[\\s\\S]*?`, + ); + +/** Every block name the root README publishes, in document order. */ +function sharedBlockNames(text) { + return [...text.matchAll(//g)].map( + (m) => m[1], + ); } -if (invokedFromPackageReadme !== undefined && !targets.includes(invokedFromPackageReadme)) { - console.log(`[sync-package-readmes] skipped for ${invokedFromPackageReadme}`); - process.exit(0); +const rootText = readFileSync(source, "utf8"); +const names = sharedBlockNames(rootText); + +if (names.length === 0) { + console.error( + "[sync-package-readmes] no shared blocks found in the root README — refusing to run", + ); + process.exit(1); +} + +const blocks = new Map(); +for (const name of names) { + const match = rootText.match(blockRe(name)); + if (!match) { + console.error( + `[sync-package-readmes] block "${name}" has a start marker but no end marker`, + ); + process.exit(1); + } + blocks.set(name, match[0]); } +const drifted = []; +let updated = 0; + for (const target of targets) { - copyFileSync(source, resolve(repoRoot, target)); + const path = resolve(repoRoot, target); + let text; + try { + text = readFileSync(path, "utf8"); + } catch { + continue; + } + + let next = text; + for (const [name, block] of blocks) { + const re = blockRe(name); + if (re.test(next)) next = next.replace(re, block); + } + + if (next === text) continue; + if (checkOnly) { + drifted.push(target); + continue; + } + writeFileSync(path, next); + updated += 1; + console.log(`[sync-package-readmes] updated ${relative(repoRoot, path)}`); +} + +if (checkOnly) { + if (drifted.length > 0) { + console.error( + `[sync-package-readmes] shared blocks are stale in:\n ${drifted.join("\n ")}\n` + + "Edit the block in the root README, then run `pnpm sync:readmes`.", + ); + process.exit(1); + } + console.log("[sync-package-readmes] shared blocks are up to date"); + process.exit(0); } -console.log(`[sync-package-readmes] copied README.md to ${targets.length} package READMEs`); +console.log( + `[sync-package-readmes] ${updated} README${updated === 1 ? "" : "s"} updated, ${names.length} shared block${names.length === 1 ? "" : "s"}`, +); diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index e651c019..c7724eb4 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -34,6 +34,9 @@ jobs: - name: Install dependencies run: pnpm install --frozen-lockfile + - name: Check README shared blocks + run: pnpm check:readmes + - name: Run tests run: pnpm test diff --git a/AGENTS.md b/AGENTS.md index 73a75c5e..239937f1 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -189,6 +189,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 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. ## Iterating on the system @@ -200,6 +201,29 @@ The rendering model, tag table, lighting modes, and naming conventions described - **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". +## Package READMEs + +Each `packages/*/README.md` is a real, hand-written, committed file that is +published to npm **as-is**. What you read in the repo is what ships — there is +no generated README. + +Regions wrapped in `` / +`` are the exception: they are owned by the +root `README.md` and mirrored into every package README that contains the +matching markers. Current blocks are `links`, `packages`, `showcase`, and +`license`. + +- **Edit a shared block in the root `README.md`, never in a package README.** + Then run `pnpm sync:readmes`. +- Everything outside the markers is package-specific. Write it in the root + README's voice, but say what that package actually does — `core` documents + core, `vue` shows Vue code. +- `.github/scripts/sync-package-readmes.mjs` runs as `prepack` in every + publishable package, so a stale shared block cannot reach npm. +- CI runs `pnpm check:readmes`, which fails on drift instead of writing. +- A package opts in per block simply by containing the markers. `fonts` and + `morph` carry none today and are left entirely alone. + ## 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 a1fce00e..f49500c2 100644 --- a/README.md +++ b/README.md @@ -2,11 +2,13 @@ A CSS polygon mesh library. A 3D engine for the DOM. Renders OBJ/MTL, STL, glTF/GLB, and VOX as real HTML elements transformed with CSS `matrix3d(...)`. Supports colors, textures, lighting, shadows, shapes and animations. Works with React, Vue or plain JavaScript. + 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 + ## Installation @@ -233,6 +235,7 @@ Each visible polygon is emitted as one leaf element; the renderer chooses the le - `` clips solid polygons with `border-shape: polygon(...)` when the browser supports it. - `` maps a packed texture-atlas slice with `background-image`, and is the fallback for textured or unsupported shapes. + ## Packages | Package | Description | @@ -242,7 +245,9 @@ 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. | + + ## Made with PolyCSS [cssQuake](https://cssquake.com) @@ -255,7 +260,10 @@ Each visible polygon is emitted as one leaf element; the renderer chooses the le -> A CSS Terrain Generator layoutit-terra + + ## License MIT. + diff --git a/package.json b/package.json index 8e437054..1f89b541 100644 --- a/package.json +++ b/package.json @@ -23,6 +23,7 @@ "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", + "check:readmes": "node .github/scripts/sync-package-readmes.mjs --check", "publish:all": "pnpm sync:readmes && pnpm --filter './packages/*' -r publish --access public", "dev:website": "pnpm --filter @layoutit/polycss-website dev", "build:website": "pnpm --filter @layoutit/polycss-website build", diff --git a/packages/core/README.md b/packages/core/README.md index a2495d5d..fc3550e8 100644 --- a/packages/core/README.md +++ b/packages/core/README.md @@ -19,8 +19,14 @@ you rarely install it directly. Reach for it when you need PolyCSS geometry work **outside a browser** — a Node build step, a worker, a test, a server-side mesh pipeline, or your own renderer. + 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 + + ## Installation ```bash @@ -110,6 +116,8 @@ first. Three constraints bite immediately: PolyCSS coordinates. See [polycss.com/api/three-parity](https://polycss.com/api/three-parity). + ## License MIT. + diff --git a/packages/polycss/README.md b/packages/polycss/README.md index 6cfddbc9..fa912b43 100644 --- a/packages/polycss/README.md +++ b/packages/polycss/README.md @@ -2,9 +2,13 @@ A CSS polygon mesh library. A 3D engine for the DOM. Renders OBJ/MTL, STL, glTF/GLB, and VOX as real HTML elements transformed with CSS `matrix3d(...)`. Supports colors, textures, lighting, shadows, shapes and animations. Works with React, Vue or plain JavaScript. + 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 + ## Installation @@ -248,6 +252,7 @@ Each visible polygon is emitted as one leaf element; the renderer chooses the le - `` clips solid polygons with `border-shape: polygon(...)` when the browser supports it. - `` maps a packed texture-atlas slice with `background-image`, and is the fallback for textured or unsupported shapes. + ## Packages | Package | Description | @@ -256,22 +261,11 @@ Each visible polygon is emitted as one leaf element; the renderer chooses the le | `@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-fonts` | Fonts + text → extruded 3D `Polygon[]`. Framework-agnostic. | | `@layoutit/polycss-morph` | Prepared-model loading, retained DOM animation, morph targets, skinning, and playback. | + -## Made with PolyCSS - -[cssQuake](https://cssquake.com) --> A CSS port of Quake (1996) - -quake - - -[Layoutit Terra](https://terra.layoutit.com) --> A CSS Terrain Generator - -layoutit-terra - + ## License MIT. + diff --git a/packages/react/README.md b/packages/react/README.md index 1973d644..0e44b94b 100644 --- a/packages/react/README.md +++ b/packages/react/README.md @@ -4,9 +4,13 @@ React bindings for [PolyCSS](https://polycss.com) — a 3D engine for the DOM. Renders OBJ/MTL, STL, glTF/GLB, and VOX meshes as real HTML elements transformed with CSS `matrix3d(...)`. No WebGL, no canvas-per-frame. + 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 + ## Installation @@ -218,6 +222,7 @@ to place that primitive in 3D space. Polygon count is the dominant cost. - `` maps a packed texture-atlas slice, and is the fallback for textured or unsupported shapes. + ## Packages | Package | Description | @@ -226,9 +231,11 @@ to place that primitive in 3D space. Polygon count is the dominant cost. | `@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-fonts` | Fonts + text → extruded 3D `Polygon[]`. Framework-agnostic. | | `@layoutit/polycss-morph` | Prepared-model loading, retained DOM animation, morph targets, skinning, and playback. | + + ## License MIT. + diff --git a/packages/vue/README.md b/packages/vue/README.md index 3d04407c..3dd01ba1 100644 --- a/packages/vue/README.md +++ b/packages/vue/README.md @@ -4,9 +4,13 @@ Vue 3 bindings for [PolyCSS](https://polycss.com) — a 3D engine for the DOM. Renders OBJ/MTL, STL, glTF/GLB, and VOX meshes as real HTML elements transformed with CSS `matrix3d(...)`. No WebGL, no canvas-per-frame. + 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 + ## Installation @@ -240,6 +244,7 @@ to place that primitive in 3D space. Polygon count is the dominant cost. - `` maps a packed texture-atlas slice, and is the fallback for textured or unsupported shapes. + ## Packages | Package | Description | @@ -248,9 +253,11 @@ to place that primitive in 3D space. Polygon count is the dominant cost. | `@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-fonts` | Fonts + text → extruded 3D `Polygon[]`. Framework-agnostic. | | `@layoutit/polycss-morph` | Prepared-model loading, retained DOM animation, morph targets, skinning, and playback. | + + ## License MIT. + From 73491daf094bc8dd05af13c70f3d4efe7b016dc6 Mon Sep 17 00:00:00 2001 From: Juan Cruz Fortunatti Date: Sun, 9 Aug 2026 22:28:03 +0200 Subject: [PATCH 13/18] docs: fail closed on malformed readme markers and reconcile camera guidance --- .github/scripts/sync-package-readmes.mjs | 67 +++++++++++++------ website/public/skill.md | 12 ++-- .../content/docs/components/poly-camera.mdx | 4 +- website/src/content/docs/core-concepts.mdx | 8 ++- website/src/content/docs/quickstart.mdx | 2 +- 5 files changed, 63 insertions(+), 30 deletions(-) diff --git a/.github/scripts/sync-package-readmes.mjs b/.github/scripts/sync-package-readmes.mjs index 9fc3e125..ec7fdc2b 100644 --- a/.github/scripts/sync-package-readmes.mjs +++ b/.github/scripts/sync-package-readmes.mjs @@ -37,33 +37,57 @@ const blockRe = (name) => `[\\s\\S]*?`, ); -/** Every block name the root README publishes, in document order. */ -function sharedBlockNames(text) { - return [...text.matchAll(//g)].map( - (m) => m[1], - ); +const START_RE = //g; +const END_RE = //g; + +const fail = (message) => { + console.error(`[sync-package-readmes] ${message}`); + process.exit(1); +}; + +const countByName = (text, re) => { + const counts = new Map(); + for (const m of text.matchAll(re)) { + counts.set(m[1], (counts.get(m[1]) ?? 0) + 1); + } + return counts; +}; + +/** + * Every marker must appear exactly once and be balanced. Anything else is a + * malformed file, not a partially-syncable one: silently skipping it is how a + * stale block reaches npm. + */ +function assertWellFormed(text, label, allowed) { + const starts = countByName(text, START_RE); + const ends = countByName(text, END_RE); + + for (const [name, n] of starts) { + if (n > 1) fail(`${label}: block "${name}" has ${n} start markers, expected 1`); + if ((ends.get(name) ?? 0) !== 1) + fail(`${label}: block "${name}" has a start marker but no matching end marker`); + if (allowed && !allowed.has(name)) + fail(`${label}: block "${name}" is not defined in the root README`); + } + for (const [name, n] of ends) { + if (n > 1) fail(`${label}: block "${name}" has ${n} end markers, expected 1`); + if (!starts.has(name)) + fail(`${label}: block "${name}" has an end marker but no matching start marker`); + } + return starts; } const rootText = readFileSync(source, "utf8"); -const names = sharedBlockNames(rootText); +const rootStarts = assertWellFormed(rootText, "root README", null); +const names = [...rootStarts.keys()]; if (names.length === 0) { - console.error( - "[sync-package-readmes] no shared blocks found in the root README — refusing to run", - ); - process.exit(1); + fail("no shared blocks found in the root README — refusing to run"); } const blocks = new Map(); for (const name of names) { - const match = rootText.match(blockRe(name)); - if (!match) { - console.error( - `[sync-package-readmes] block "${name}" has a start marker but no end marker`, - ); - process.exit(1); - } - blocks.set(name, match[0]); + blocks.set(name, rootText.match(blockRe(name))[0]); } const drifted = []; @@ -78,10 +102,11 @@ for (const target of targets) { continue; } + const present = assertWellFormed(text, target, new Set(names)); + let next = text; - for (const [name, block] of blocks) { - const re = blockRe(name); - if (re.test(next)) next = next.replace(re, block); + for (const name of present.keys()) { + next = next.replace(blockRe(name), blocks.get(name)); } if (next === text) continue; diff --git a/website/public/skill.md b/website/public/skill.md index 6baf1576..767b6e36 100644 --- a/website/public/skill.md +++ b/website/public/skill.md @@ -141,10 +141,14 @@ 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. All -absolute world units, not configurable. Dedupe and -interior culling count as exact reductions and still run under -`meshResolution: "lossless"`. `merge: false` renders the array you pass +(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 diff --git a/website/src/content/docs/components/poly-camera.mdx b/website/src/content/docs/components/poly-camera.mdx index 8851822f..da362792 100644 --- a/website/src/content/docs/components/poly-camera.mdx +++ b/website/src/content/docs/components/poly-camera.mdx @@ -5,7 +5,9 @@ description: "Camera components for controlling the 3D viewport: perspective, or import { Tabs, TabItem } from '@astrojs/starlight/components'; -PolyCSS provides two camera components: `` (alias ``) for parallel projection, and `` for scenes with depth foreshortening. The camera element is always the **outer** node — `` / `PolyScene` is nested inside it. This is required by the CSS rendering model: CSS `perspective` only applies to descendants, so the scene's `transform: matrix3d(...)` must be a child of the camera. +PolyCSS provides two camera components: `` (alias ``) for parallel projection, and `` for scenes with depth foreshortening. The camera element is normally the **outer** node — `` / `PolyScene` is nested inside it. The CSS rendering model requires this nesting: CSS `perspective` only applies to descendants, so the scene's `transform: matrix3d(...)` must be a child of the element carrying the projection. + +React and Vue enforce it — `PolyScene` throws outside a camera component, and `createPolyScene()` takes a required camera handle. The `` custom element is the one exception: with no ancestor camera element it builds an **implicit** camera wrapper from its own `perspective`, `rot-x`, `rot-y`, `zoom`, `distance`, and `target` attributes, which satisfies the same nesting requirement internally. See [PolyScene](/components/poly-scene). `` is orthographic by default. Use `` when depth foreshortening is needed (e.g. first-person or game-like scenes). diff --git a/website/src/content/docs/core-concepts.mdx b/website/src/content/docs/core-concepts.mdx index cec0d89a..a13a5cc6 100644 --- a/website/src/content/docs/core-concepts.mdx +++ b/website/src/content/docs/core-concepts.mdx @@ -27,7 +27,7 @@ PolyCSS exposes three composable concepts. Each one ships as a custom element (v - **Mesh** (`` / `PolyMesh`): loads a mesh from a URL (OBJ / STL / glTF / GLB / VOX). Internally expands to one polygon child per face. Convenience wrapper around the parser + renderer. - **Polygon** (`` / `Poly`): one polygon. The atomic primitive. Renders as one internal DOM leaf with `transform: matrix3d(...)`. Accepts standard DOM event handlers, classes, and styles: this is what makes PolyCSS "DOM-native 3D" rather than "3D inside a black-box canvas". -A mesh element is internally `polygons.map(p => )`, so any rendered mesh can be inspected, styled, or handled per-polygon. +A loaded mesh does **not** expand into `` elements — the renderer mounts one internal leaf per visible polygon inside a `.polycss-mesh` wrapper, and the leaf tag is a private strategy choice (see [Render Strategies](#render-strategies)). `` / `` exists for polygons you author yourself. To style or handle a loaded mesh per-polygon, use its render prop / scoped slot, or target the mesh wrapper and its leaves by class and `data-*` attributes. ## Camera @@ -228,8 +228,10 @@ authored geometry is merged, deduped, and interior-culled before it renders: tries progressively more aggressive variants at `30°`, `45°`, and `60°` — the widest also raising boundary displacement to `0.06`. Those are accepted only on a material render-cost win with non-worsening seam diagnostics, but they - *can* apply to your geometry. All values are **absolute world units and not - configurable**, so small-scale hand-authored meshes can get visibly welded. + *can* apply to your geometry. The degree values are angular thresholds; the + plane and boundary displacement budgets are **absolute world units**. None of + them are configurable, so small-scale hand-authored meshes can get visibly + welded. Note that dedupe and interior culling are treated as *exact* reductions, so they still run under `meshResolution: "lossless"` — only the lossy approximation is diff --git a/website/src/content/docs/quickstart.mdx b/website/src/content/docs/quickstart.mdx index 79a2ca8c..975a1031 100644 --- a/website/src/content/docs/quickstart.mdx +++ b/website/src/content/docs/quickstart.mdx @@ -30,7 +30,7 @@ npm install @layoutit/polycss-vue ## 2. Add a scene and load a mesh -The camera element (`` / `PolyCamera`) is always the outer node: it owns the projection and orbital state. `` / `PolyScene` is nested inside it and carries lighting and atlas options. The mesh element (`` / `PolyMesh`) loads OBJ, STL, glTF, GLB, or VOX files and renders their polygons. `PolyCamera` uses orthographic projection by default; use `PolyPerspectiveCamera` for depth foreshortening. +The camera element (`` / `PolyCamera`) is normally the outer node: it owns the projection and orbital state. (The `` custom element can also stand alone and drive an implicit camera from its own attributes — see [PolyScene](/components/poly-scene).) `` / `PolyScene` is nested inside it and carries lighting and atlas options. The mesh element (`` / `PolyMesh`) loads OBJ, STL, glTF, GLB, or VOX files and renders their polygons. `PolyCamera` uses orthographic projection by default; use `PolyPerspectiveCamera` for depth foreshortening. From 35cf577348e010fd90e2422f57937e99ef8a8d07 Mon Sep 17 00:00:00 2001 From: Juan Cruz Fortunatti Date: Sun, 9 Aug 2026 22:52:52 +0200 Subject: [PATCH 14/18] docs: scope ground-shadow fallback to React/Vue --- AGENTS.md | 2 +- .../content/docs/components/poly-scene.mdx | 2 +- website/src/content/docs/guides/lighting.mdx | 31 ++++++++++--------- 3 files changed, 19 insertions(+), 16 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 239937f1..92ed27b5 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -52,7 +52,7 @@ Strategies are ordered cheapest → most expensive. The mesher's job is to maxim 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. Solid seam bleed gives detected shared solid edges 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 exposed: `seamBleed` is a scene option in all three renderers plus a React/Vue per-mesh prop, though not a custom-element attribute. **Known divergence:** vanilla clamps the number to `0..1` and multiplies the `1.5` px default (`resolveBleedRatio`), while React/Vue pass the raw value straight into core plan construction, where `normalizedSeamBleed` accepts finite positive numbers only. `"auto"` therefore diverges too: vanilla resolves it to the full `1.5` px default, but React/Vue's `"auto"` yields no shared-edge overscan at all (the plan's `bleedRatio` still resolves to `1`, so per-strategy primitive bleeds are unchanged). Only the numeric default `1.5` matches across renderers. Unifying this is a pending cross-renderer fix, not a docs problem. -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 ``/`` 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. +Cast shadows are not a render-strategy leaf tag. Meshes with `castShadow: true` project casting polygons on the CPU into SVG shadow surfaces onto scene-level receiver surfaces where `receiveShadow` is enabled. **Renderer divergence on the no-receiver case:** vanilla dropped its legacy virtual ground-shadow fallback for Three.js parity — a caster with no receiver in the scene draws nothing, `emitGroundShadow` is now dead code, and `hideGroundShadow()` suppresses any legacy leftovers every tick. React/Vue still emit a per-mesh ground-plane shadow when a caster has no receiver, and drop it as soon as any receiver exists. Reconciling the two is an open decision. 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 ``/`` 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. diff --git a/website/src/content/docs/components/poly-scene.mdx b/website/src/content/docs/components/poly-scene.mdx index b373ae20..66a5cf50 100644 --- a/website/src/content/docs/components/poly-scene.mdx +++ b/website/src/content/docs/components/poly-scene.mdx @@ -62,7 +62,7 @@ React/Vue `` supports the full table. The `` custom element | `parseOptions` | `UseMeshOptions` | Parser options forwarded to `loadMesh`; `meshResolution` defaults to `"lossy"`. | | `meshResolution` | `"lossless" \| "lossy"` | Top-level optimizer intent. Wins over `parseOptions.meshResolution`; defaults to `"lossy"`. | | `castShadow` | `boolean` | Emit SVG cast shadows in both lighting modes; projections update when light, ground, or mesh geometry changes. | -| `receiveShadow` | `boolean` | Casters project per-coplanar-face SVG shadows onto this mesh's visible surfaces (Three.js `mesh.receiveShadow` semantics). Any receiver in the scene disables the casters' ground-shadow fallback. Defaults to `false`. | +| `receiveShadow` | `boolean` | Casters project per-coplanar-face SVG shadows onto this mesh's visible surfaces (Three.js `mesh.receiveShadow` semantics). Defaults to `false`. In React/Vue, any receiver in the scene disables the casters' ground-shadow fallback; vanilla has no such fallback, so a receiver is required for any shadow to appear. | | `shadowDefinition` | `number` | Per-mesh parametric-shadow detail, overriding the scene's `shadow.definition` (only when `shadow.parametric`). | | `merge` | `boolean` | Run the polygon optimizer (dedupe, interior cull, coplanar/lossy merge). Defaults to `true`. Set `false` to render **the polygon array entering the renderer** exactly as given. It cannot restore source-file geometry: with `src`, `loadMesh` has already optimized the parse result before `merge` is consulted. | | `fallback` | `ReactNode` | Rendered while `src` is loading. (React / Vue only.) | diff --git a/website/src/content/docs/guides/lighting.mdx b/website/src/content/docs/guides/lighting.mdx index dc083255..03879c1c 100644 --- a/website/src/content/docs/guides/lighting.mdx +++ b/website/src/content/docs/guides/lighting.mdx @@ -173,20 +173,23 @@ scene.setOptions({ shadow: { color: "#000000", opacity: 0.3, lift: 0.02 } }); There are two distinct receiver mechanisms, and mixing them up is a common source of "my shadow disappeared": -- **Ground-shadow fallback.** A `castShadow` mesh projects onto the scene's - ground plane automatically, with no receiver mesh required. This is what - `` relies on — it is a convenience quad that renders with - `castShadow: false` and has **no `receiveShadow` prop of its own**. Passing - `receiveShadow` to `` does nothing. -- **`receiveShadow` meshes.** Marking any mesh `receiveShadow` makes casters - project per-coplanar-face SVG shadows onto each of its visible surfaces - (Three.js `mesh.receiveShadow` semantics). As soon as *any* receiver exists in - the scene, casters **drop the ground-shadow fallback** so the receiver paints - the only shadow pass. - -So `` alone works, and an explicit `receiveShadow` mesh alone works -— but adding a receiver elsewhere in the scene will silently turn off the -ground fallback under ``. +- **Ground-shadow fallback — React/Vue only.** A `castShadow` mesh projects onto + the scene's ground plane automatically, with no receiver mesh required. This is + what `` relies on: it is a convenience quad that renders with + `castShadow: false` and has **no `receiveShadow` prop of its own** (passing one + does nothing). **Vanilla `createPolyScene` has no such fallback** — a caster + with no receiver in the scene emits no shadow, so vanilla scenes must mark a + floor mesh `receiveShadow: true` explicitly. +- **`receiveShadow` meshes — all renderers.** Marking any mesh `receiveShadow` + makes casters project per-coplanar-face SVG shadows onto each of its visible + surfaces (Three.js `mesh.receiveShadow` semantics). In React/Vue, as soon as + *any* receiver exists in the scene, casters **drop the ground-shadow fallback** + so the receiver paints the only shadow pass. + +So in React/Vue, `` alone works and an explicit `receiveShadow` mesh +alone works — but adding a receiver elsewhere silently turns off the ground +fallback under ``. In vanilla, an explicit receiver is the only +option. What to expect: From 67257fd59fcd38f98ab94f61267cd7ccdc886aee Mon Sep 17 00:00:00 2001 From: Juan Cruz Fortunatti Date: Sun, 9 Aug 2026 22:56:21 +0200 Subject: [PATCH 15/18] docs: scope renderer-specific shadow, throttle, and backend claims --- AGENTS.md | 6 ++--- packages/react/README.md | 5 ++-- packages/vue/README.md | 5 ++-- website/public/skill.md | 21 +++++++++------- website/src/content/docs/api/types.mdx | 14 ++++++++--- .../content/docs/components/poly-scene.mdx | 6 ++--- website/src/content/docs/core-concepts.mdx | 25 ++++++++++--------- 7 files changed, 47 insertions(+), 35 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 92ed27b5..70a976df 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -50,7 +50,7 @@ Voxel-shaped meshes are the exception to "all polygons stay mounted": meshes wit 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`). 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. Solid seam bleed gives detected shared solid edges 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 exposed: `seamBleed` is a scene option in all three renderers plus a React/Vue per-mesh prop, though not a custom-element attribute. **Known divergence:** vanilla clamps the number to `0..1` and multiplies the `1.5` px default (`resolveBleedRatio`), while React/Vue pass the raw value straight into core plan construction, where `normalizedSeamBleed` accepts finite positive numbers only. `"auto"` therefore diverges too: vanilla resolves it to the full `1.5` px default, but React/Vue's `"auto"` yields no shared-edge overscan at all (the plan's `bleedRatio` still resolves to `1`, so per-strategy primitive bleeds are unchanged). Only the numeric default `1.5` matches across renderers. Unifying this is a pending cross-renderer fix, not a docs problem. +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. Solid seam bleed gives detected shared solid edges 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 exposed: `seamBleed` is a scene option in all three renderers plus a React/Vue per-mesh prop, though not a custom-element attribute. **Known divergence:** vanilla clamps the number to `0..1` and multiplies the `1.5` px default (`resolveBleedRatio`), while React/Vue pass the raw value straight into core plan construction, where `normalizedSeamBleed` accepts finite positive numbers only. `"auto"` therefore diverges too: vanilla resolves it to the full `1.5` px default, but React/Vue's `"auto"` yields no shared-edge overscan at all (the plan's `bleedRatio` still resolves to `1`, so per-strategy primitive bleeds are unchanged). Only the numeric default `1.5` matches across renderers. Sub-1 numbers additionally scale the per-strategy primitive bleeds in both, by different factors (vanilla via its recomputed ratio, React/Vue by the raw value). Unifying this is a pending cross-renderer fix, not a docs problem. Cast shadows are not a render-strategy leaf tag. Meshes with `castShadow: true` project casting polygons on the CPU into SVG shadow surfaces onto scene-level receiver surfaces where `receiveShadow` is enabled. **Renderer divergence on the no-receiver case:** vanilla dropped its legacy virtual ground-shadow fallback for Three.js parity — a caster with no receiver in the scene draws nothing, `emitGroundShadow` is now dead code, and `hideGroundShadow()` suppresses any legacy leftovers every tick. React/Vue still emit a per-mesh ground-plane shadow when a caster has no receiver, and drop it as soon as any receiver exists. Reconciling the two is an open decision. 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 ``/`` 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. @@ -72,7 +72,7 @@ The scene takes one `directionalLight`, one `ambientLight`, and zero or more `po ### Lighting modes (`PolyTextureLightingMode = "baked" | "dynamic"`) -- **Baked.** Lambert (directional + each point light + ambient) is computed once on the CPU per polygon, multiplied into the inline `color` (for ``/``/``) or into the rasterised atlas pixels (for atlas-backed ``). Direct image `` leaves preserve source pixels and use `texturePresentation.lighting="source"`; scene-lit direct images fall back to the atlas path. Moving a light requires explicit re-rasterising of affected lit atlas polys via `mesh.rebakeAtlas()` — the atlas bake (canvas raster + async `toBlob`) is the one expensive step, so the vanilla imperative API does NOT auto-rebake the lit surface on a `setOptions({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 explicit `rebakeAtlas()`. 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. +- **Baked.** Lambert (directional + each point light + ambient) is computed once on the CPU per polygon, multiplied into the inline `color` (for ``/``/``) or into the rasterised atlas pixels (for atlas-backed ``). Direct image `` leaves preserve source pixels and use `texturePresentation.lighting="source"`; scene-lit direct images fall back to the atlas path. Moving a light requires explicit re-rasterising of affected lit atlas polys via `mesh.rebakeAtlas()` — the atlas bake (canvas raster + async `toBlob`) is the one expensive step, so the vanilla imperative API does NOT auto-rebake the lit surface on a `setOptions({directionalLight})` 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 explicit `rebakeAtlas()`. 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. **Exception:** a `setOptions({pointLights})` change DOES re-render every mesh in vanilla (`createPolyScene.ts` `pointLightsChanged` → `renderEntry`), so the freeze applies to the directional light only. - **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. CSS `calc()` 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 `` 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`. @@ -165,7 +165,7 @@ React or Vue wrappers. - **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: ``, ``, ``, ``, ``, ``, ``, ``. Any new element follows the same shape (e.g. ``, ``). +- **HTML custom elements:** `poly-` prefix + kebab-case. Registered tags (see `packages/polycss/src/elements/index.ts`): ``, ``, ``, ``, ``, ``, ``, ``, ``, ``, ``, ``, ``, and the shape elements (``, ``, ``, ``, ``, ``, ``, and the Platonic solids). Any new element follows the same shape. - **``:** flat textured "quad" whose "texture" is a live document (an `