From e577f9fa7ce10be6dbee12aad3cf2ddd67698807 Mon Sep 17 00:00:00 2001 From: Juan Cruz Fortunatti Date: Wed, 20 May 2026 00:48:33 +0200 Subject: [PATCH 01/55] docs: add API_DESIGN.md unified-shape spec --- API_DESIGN.md | 701 ++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 701 insertions(+) create mode 100644 API_DESIGN.md diff --git a/API_DESIGN.md b/API_DESIGN.md new file mode 100644 index 00000000..fe77e64b --- /dev/null +++ b/API_DESIGN.md @@ -0,0 +1,701 @@ +# Polycss API design — unified shape across all four paths + +Single source of truth for what the **same scene** looks like across the four supported usage paths: vanilla JS, custom elements (HTML), React, Vue. If any path diverges from this shape, it's a bug. + +**This describes the target state.** The current state has verified drift — see "Known drift" at the bottom. + +--- + +## Goal + +One mental model. A user who learns the React API can write the Vue, vanilla JS, and HTML versions without re-learning anything except idiom (camelCase vs kebab-case, function calls vs JSX). + +## Tree shape + +``` +PolyCamera +└── PolyScene + ├── Controls (PolyOrbitControls / PolyMapControls / PolyFirstPersonControls / PolyTransformControls) — optional + └── Content (PolyMesh / PolyGround / PolyPolygon / helpers) — one per node +``` + +**Why camera wraps scene:** CSS `perspective` only applies to descendants. The scene's `transform: matrix3d(...)` is read in that perspective space, so scene must be a DOM descendant of camera. This is fixed by the rendering model — three.js can put camera + scene as siblings because it computes perspective in matrix math, not CSS. + +## Camera taxonomy + +Two cameras, one shared orbital state. + +| Name | Projection | When to pick | +|---|---|---| +| `PolyOrthographicCamera` (alias `PolyCamera`) | `perspective: none` | **Default.** Isometric/voxel/diagrammatic scenes, 2.5D, technical drawings. Parallel projection plays nicely with DOM stacking; integer-pixel quads kill subpixel seams. | +| `PolyPerspectiveCamera` | `perspective: px` | Game-like scenes that need depth foreshortening. CSS `perspective` is a sensitive knob — pick a specific value, don't auto-tune. | + +Shared state: `rotX`, `rotY`, `target`, `distance`, `zoom`. Perspective also has `perspective` (px). + +**Why ortho is the default:** polycss's structural advantages (no per-frame JS, DOM-as-render-tree, integer-pixel atlas slicing) are most visible in orthographic scenes. The engine's identity is closer to "voxel/iso renderer that also does perspective" than "general 3D engine that defaults to perspective." Diverges from three.js's `PerspectiveCamera`-as-default convention deliberately — see "Open questions" for the tradeoff and the implied CLAUDE.md update. + +**No FPS camera, cinematic camera, etc.** "FPV" is `PolyPerspectiveCamera` + `PolyFirstPersonControls`. Camera defines projection; controls define behavior. + +## Controls taxonomy + +| Name | Behavior | +|---|---| +| `PolyOrbitControls` | Drag/wheel to orbit/zoom around target. | +| `PolyMapControls` | Like orbit but pan plane is horizontal (Google-Maps-style). | +| `PolyFirstPersonControls` | WASD + mouse-look. | +| `PolyTransformControls` | Gizmos for translate/rotate/scale on a target mesh. | + +--- + +## Minimal mesh — all four paths + +The same scene, every path. **If a path can't express this verbatim, it's a bug.** + +### Vanilla JS + +```js +import { createPolyCamera, createPolyScene, loadMesh } from "@layoutit/polycss"; + +const camera = createPolyCamera({ rotX: 65, rotY: 45 }); +const scene = createPolyScene(document.getElementById("app"), { camera }); + +scene.add(await loadMesh("/cottage.glb")); +``` + +### Custom elements (HTML) + +```html + + + + + + + +``` + +### React + +```tsx +import { PolyCamera, PolyScene, PolyMesh } from "@layoutit/polycss-react"; + + + + + + +``` + +### Vue 3 + +```vue + + + +``` + +--- + +## Minimal interactive scene (orbit controls) + +### Vanilla JS + +```js +import { + createPolyCamera, createPolyScene, createPolyOrbitControls, loadMesh, +} from "@layoutit/polycss"; + +const camera = createPolyCamera({ rotX: 65, rotY: 45 }); +const scene = createPolyScene(host, { camera }); +createPolyOrbitControls(scene, { drag: true, wheel: true }); + +scene.add(await loadMesh("/cottage.glb")); +``` + +### Custom elements + +```html + + + + + + +``` + +### React + +```tsx + + + + + + +``` + +### Vue + +```vue + + + + + + +``` + +--- + +## Manual polygons (no file) + +A mesh where geometry is defined inline as `Polygon[]` rather than loaded from a URL. The `Polygon` type lives in `@layoutit/polycss-core`: + +```ts +interface Polygon { + vertices: Vec3[]; // N coplanar vertices, CCW from outside + color?: string; + texture?: string; // URL + material?: PolyMaterial; + uvs?: Vec2[]; + data?: Record; +} +``` + +### Vanilla JS + +```js +import { createPolyCamera, createPolyScene } from "@layoutit/polycss"; + +const camera = createPolyCamera({ rotX: 65, rotY: 45 }); +const scene = createPolyScene(host, { camera }); + +scene.add({ + polygons: [ + { vertices: [[0, 0, 0], [100, 0, 0], [50, 100, 0]], color: "#ff6644" }, + ], +}); +``` + +### Custom elements + +`` is a child of ``. `vertices` is a JSON-stringified array. + +```html + + + + + + + +``` + +### React + +`polygons` prop is mutually exclusive with `src`. + +```tsx +const polygons = [ + { vertices: [[0, 0, 0], [100, 0, 0], [50, 100, 0]], color: "#ff6644" }, +]; + + + + + + +``` + +### Vue + +```vue + + + +``` + +### Built-in shape generators + +`@layoutit/polycss-core` (re-exported from every wrapper package) ships polygon factories for common primitives: `boxPolygons`, `arrowPolygons`, `ringPolygons`, `ringQuadPolygons`, `planePolygons`, `octahedronPolygons`, `axesHelperPolygons`. Each returns `Polygon[]` and slots into the same `polygons` field as raw construction: + +```js +import { boxPolygons } from "@layoutit/polycss"; +scene.add({ polygons: boxPolygons({ size: 100, color: "#ff6644" }) }); +``` + +--- + +## Primitive shape components + +Sugar over the polygon generators. `` is one-liner ergonomics for `` — same DOM output, less typing. Discoverable via autocomplete. + +**polycss-specific cost framing:** each segment is a DOM node, not a vertex on a GPU buffer. Cranking `radialSegments` from 12 to 32 *quadruples* paint cost in a way three.js users don't have to think about. Defaults are deliberately lower than three.js's. + +### Fixed-geometry primitives (no segment count) + +| Component | Faces | Core generator | +|---|---|---| +| `PolyBox` | 6 quads (axis-aligned → `` fast path) | `boxPolygons` ✅ | +| `PolyPlane` | 1 quad | `planePolygons` ✅ | +| `PolyTetrahedron` | 4 triangles | `tetrahedronPolygons` ❌ — add | +| `PolyOctahedron` | 8 triangles | `octahedronPolygons` ✅ | +| `PolyIcosahedron` | 20 triangles | `icosahedronPolygons` ❌ — add | +| `PolyDodecahedron` | 12 pentagons (`` on Chromium, `` elsewhere) | `dodecahedronPolygons` ❌ — add | + +### Parametric primitives (segment count controls cost) + +| Component | Default | Polygon count at default | Core generator | +|---|---|---|---| +| `PolyRing` (disc) | `segments: 32` | 32 triangles | `ringPolygons` ✅ | +| `PolyCylinder` | `radialSegments: 12` | ≈48 (24 side quads + 24 cap triangles) | `cylinderPolygons` ❌ — add | +| `PolyCone` | `radialSegments: 12` | 24 (12 side + 12 cap) | `conePolygons` ❌ — add. Could share `cylinderPolygons` impl with `radiusTop: 0`. | +| `PolyTorus` | `radialSegments: 12, tubularSegments: 16` | 192 quads | `torusPolygons` ❌ — add. Heaviest of the set; document. | + +### Deferred + +- **`PolySphere`** — three.js's default (`32×16`) is **1024 DOM nodes per sphere**. Even conservative `16×8` is 256. A sphere in polycss is a DOM-cost problem, not a math one. Hold off until either (a) a geodesic-subdivision generator with reasonable poly count, or (b) clear user demand. Workaround: subdivided icosahedron, or `boxPolygons` for "ball-ish." + +### Same scene across all four paths — `PolyBox` as the reference + +**Vanilla JS:** + +```js +import { createPolyCamera, createPolyScene, boxPolygons } from "@layoutit/polycss"; + +const camera = createPolyCamera({ rotX: 65, rotY: 45 }); +const scene = createPolyScene(host, { camera }); +scene.add({ polygons: boxPolygons({ size: 100, color: "#ff6644" }) }); +``` + +`PolyBox` factory (if shipped) wraps it: + +```js +import { createPolyCamera, createPolyScene, createPolyBox } from "@layoutit/polycss"; + +const camera = createPolyCamera({ rotX: 65, rotY: 45 }); +const scene = createPolyScene(host, { camera }); +scene.add(createPolyBox({ size: 100, color: "#ff6644" })); +``` + +**Custom elements:** + +```html + + + + + +``` + +**React:** + +```tsx + + + + + +``` + +**Vue:** + +```vue + + + + + +``` + +### Implementation rule + +**Don't add a `PolyX` component without the matching `xPolygons` generator in `@layoutit/polycss-core` first.** Every primitive component must be a thin wrapper around a pure-math core function. No shape-specific logic in the framework layers. + +Concrete order: + +1. **Core:** add `tetrahedronPolygons`, `icosahedronPolygons`, `dodecahedronPolygons`, `cylinderPolygons`, `conePolygons` (or just `cylinderPolygons` with `radiusTop: 0`), `torusPolygons`. +2. **React + Vue:** wire ``, ``, ``, `` (existing helpers) + the new ones. Mirror props and defaults between bindings. +3. **Custom elements:** mirror as ``, …, ``. Same prop set, kebab-case attributes. +4. **Vanilla JS:** add `createPolyBox(opts)` factories. Each returns `{ polygons: Polygon[] }` so it composes with `scene.add(...)` verbatim. + +### Deliberate non-mirror with three.js + +three.js splits a primitive into `` + `` so they're independently swappable. **polycss doesn't replicate that.** A `Polygon` carries its own `color` / `texture` / `material`, so the geometry-vs-material split has no place to land here. Shape components take a flat prop set (size, segments, color, texture, material) and emit a polygon array directly. + +--- + +## First-person scene + +FPV needs perspective foreshortening to feel right, so this example uses the explicit `PolyPerspectiveCamera` rather than the default `PolyCamera` (which is orthographic). + +### Vanilla JS + +```js +import { + createPolyPerspectiveCamera, createPolyScene, createPolyFirstPersonControls, loadMesh, +} from "@layoutit/polycss"; + +const camera = createPolyPerspectiveCamera({ rotX: 0, rotY: 0 }); +const scene = createPolyScene(host, { camera }); +createPolyFirstPersonControls(scene); +scene.add(await loadMesh("/world.glb")); +``` + +### Custom elements + +```html + + + + + + +``` + +### React / Vue + +```tsx + + + + + + +``` + +--- + +## Scene & mesh features + +The features below all exist today in some form. Listed here so the design covers them and any open question about their shape is locked before implementation. + +### Feature placement (where does it live?) + +| Feature | Camera | Scene | Mesh | Controls | +|---|---|---|---|---| +| `rotX` / `rotY` / `zoom` / `distance` / `target` (Vec3) | ✅ | — | — | — | +| `perspective` (px) | ✅ (perspective camera only) | — | — | — | +| `directionalLight` / `ambientLight` | — | ✅ | — | — | +| `textureLighting` (`"baked"` \| `"dynamic"`) | — | ✅ (inherited by meshes) | ✅ (override) | — | +| `textureQuality` (`number` \| `"auto"`) | — | ✅ (default) | ✅ (override) | — | +| `strategies` (`{ disable: [...] }`) | — | ✅ | ✅ | — | +| `autoCenter` (boolean) | — | ✅ (translates the world) | ✅ (recenters polygons into mesh-local space) | — | +| `shadow` (`{ color, opacity, lift }`) | — | ✅ (appearance config) | — | — | +| `castShadow` (boolean) | — | — | ✅ (per-mesh opt-in) | — | +| `meshResolution` (`"lossless"` \| `"lossy"`) | — | — | ⚠️ via `parseOptions` only (see open question) | — | +| `animate` (`{ speed, axis }` \| `false`) — i.e. "auto-rotate" | — | — | — | ✅ (orbit only) | + +### Lights + +**Currently:** lights are typed values passed as **props** on the scene, not components. + +```ts +type PolyDirectionalLight = { direction: Vec3; color?: string; intensity?: number }; +type PolyAmbientLight = { color?: string; intensity?: number }; +``` + +Across paths: + +```js +// Vanilla JS +const scene = createPolyScene(host, { + camera, + directionalLight: { direction: [0.4, -0.7, 0.59], color: "#ffffff", intensity: 1 }, + ambientLight: { color: "#ffffff", intensity: 0.4 }, +}); +``` + +```html + + + … + +``` + +```tsx +// React / Vue + +``` + +> **Open question — Lights as components?** three.js / R3F use `` and `` as scene-tree children, not props. polycss could mirror that as `` and `` siblings of `` inside ``. Pros: matches three.js mental model, makes multi-light scenes natural (today we only have one of each). Cons: lights aren't really "scene objects" in polycss — they're inputs to the rasterizer (baked) or CSS variables (dynamic), not transformable nodes. **Recommendation: defer.** Stick with object-shaped props for now. Reconsider if multi-light is added. + +### Shadows + +Dynamic-lighting feature only — baked mode does not emit shadow leaves. Two pieces: + +- `shadow?: { color?: string; opacity?: number; lift?: number }` on **scene** — appearance config. Defaults: `{ color: "#000000", opacity: 0.25, lift: 0.05 }`. +- `castShadow?: boolean` on **mesh** — per-mesh opt-in. + +Each polygon on a shadow-casting mesh emits a paired `` leaf (the cast-shadow leaf in the tag table, AGENTS.md). + +```tsx +// React + + + +``` + +```html + + + + +``` + +```js +// Vanilla JS +const scene = createPolyScene(host, { + camera, + textureLighting: "dynamic", + shadow: { opacity: 0.4, lift: 0.1 }, +}); +const mesh = await loadMesh("/cottage.glb"); +scene.add(mesh, { castShadow: true }); // ← second arg is mesh transform + flags +``` + +### Texture lighting modes + +`textureLighting: "baked" | "dynamic"` (default `"baked"`). + +- **Baked.** Lambert computed once per polygon on CPU; multiplied into atlas pixels (``) or inline `color` (`` / `` / ``). Moving a light requires re-rasterising. +- **Dynamic.** Scene root carries lights as CSS custom properties (`--plx/y/z`, `--plr/g/b`, etc.). Each leaf embeds its normal + base color inline. Lambert resolves at paint time via `calc()`. Moving a light mutates one var → no JS, no atlas redraw. Required for `castShadow`. + +Scene-level sets the default; mesh-level overrides per-mesh. + +### Texture quality + +`textureQuality?: number | "auto"` (default `"auto"`). + +- `"auto"` — device-appropriate budget: ~4 MB atlas + 64px sprite on mobile, ~16 MB + 128px on desktop. +- numeric `0.1..1` — explicit raster scale, forces 64px sprite. + +Set scene-level for the whole scene; override per mesh when one mesh needs more (or less) detail. + +### Mesh resolution + +Top-level `meshResolution?: "lossless" | "lossy"` prop on `` (Decision #6). + +- `"lossy"` (default) — bounded geometric approximation when it reduces polygon count. +- `"lossless"` — preserve the authored surface; only apply exact merges. + +```js +// Vanilla JS +scene.add(mesh, { meshResolution: "lossless" }); +``` + +```html + + +``` + +```tsx +// React + +``` + +```vue + + +``` + +`parseOptions` stays available for niche parser flags but is no longer the route for `meshResolution`. + +### Auto center + +`autoCenter?: boolean` exists at **two** levels: + +- **Scene level.** Translates the *world* so the bbox of all live meshes sits at origin. Camera orbits the model's visible center without shifting the mesh DOM. +- **Mesh level.** Re-centers a mesh's polygons into mesh-local space. Useful for OBJ/GLB assets whose origin is at a corner / feet / arbitrary point. + +These are independent — both can be `true`. Default: both `false`. + +### Auto rotation + +**There is no `autoRotate` prop.** Auto-rotation is the `animate` option on `PolyOrbitControls`: + +```ts +animate?: { speed: number; axis?: "x" | "y" } | false +``` + +```tsx +// React + + + // explicit off +``` + +```html + + + +``` + +```js +// Vanilla JS +createPolyOrbitControls(scene, { animate: { speed: 0.3 } }); +``` + +### Camera target + +`target?: Vec3` on the camera (`` / ``). The orbital state rotates around this point. Default `[0, 0, 0]`. + +```tsx +// React + +``` + +```html + + +``` + +```js +// Vanilla JS +const camera = createPolyCamera({ rotX: 65, rotY: 45, target: [10, 0, 0] }); +``` + +Combined with scene-level `autoCenter`, the effective orbit pivot is `target + autoCenterOffset`. Users typically set either, not both. + +### Cross-path consistency matrix + +Every feature in one place — confirm at a glance that the four paths agree on shape, field names, and defaults. Per-path syntactic differences (camelCase vs kebab-case, JSX vs HTML) are expected; what must match is the **field name root, the value shape, and the default**. + +| Feature | Lives on | Field root | Value shape | Default | Vanilla JS | Custom elements | React / Vue | +|---|---|---|---|---|---|---|---| +| Camera rotation | Camera | `rotX`, `rotY` | `number` (degrees) | `65`, `45` | `{ rotX: 65, rotY: 45 }` | `rot-x="65" rot-y="45"` | `rotX={65}` / `:rot-x="65"` | +| Camera target | Camera | `target` | `Vec3` (`[x,y,z]`) | `[0,0,0]` | `{ target: [10,0,0] }` | `target="10,0,0"` | `target={[10,0,0]}` | +| Camera zoom | Camera | `zoom` | `number` | `1` | `{ zoom: 1.5 }` | `zoom="1.5"` | `zoom={1.5}` | +| Camera distance | Camera | `distance` | `number` (CSS px) | (none) | `{ distance: 1200 }` | `distance="1200"` | `distance={1200}` | +| Perspective | Perspective camera | `perspective` | `number` (CSS px) | `8000` | `{ perspective: 4000 }` | `perspective="4000"` | `perspective={4000}` | +| Directional light | Scene | `directionalLight` | `{ direction: Vec3; color?: string; intensity?: number }` | (none) | object option | JSON attr `directional-light='{…}'` | object prop | +| Ambient light | Scene | `ambientLight` | `{ color?: string; intensity?: number }` | (none) | object option | JSON attr `ambient-light='{…}'` | object prop | +| Texture lighting | Scene (inheritable on mesh) | `textureLighting` | `"baked" \| "dynamic"` | `"baked"` | `{ textureLighting: "dynamic" }` | `texture-lighting="dynamic"` | `textureLighting="dynamic"` | +| Texture quality | Scene + mesh | `textureQuality` | `number \| "auto"` | `"auto"` | `{ textureQuality: 0.5 }` | `texture-quality="0.5"` | `textureQuality={0.5}` | +| Strategies | Scene + mesh | `strategies` | `{ disable?: ("b"\|"i"\|"u")[] }` | (none) | object option | JSON attr `strategies='{…}'` | object prop | +| Auto center (scene) | Scene | `autoCenter` | `boolean` | `false` | `{ autoCenter: true }` | bare attr `auto-center` | bare prop / `auto-center` | +| Auto center (mesh) | Mesh | `autoCenter` | `boolean` | `false` | `scene.add(m, { autoCenter: true })` | bare attr `auto-center` | bare prop | +| Shadow appearance | Scene | `shadow` | `{ color?: string; opacity?: number; lift?: number }` | `{ color: "#000000", opacity: 0.25, lift: 0.05 }` | object option | JSON attr `shadow='{…}'` | object prop | +| Cast shadow | Mesh | `castShadow` | `boolean` | `false` | `scene.add(m, { castShadow: true })` | bare attr `cast-shadow` | bare prop / `cast-shadow` | +| Mesh resolution | Mesh | `meshResolution` | `"lossless" \| "lossy"` | `"lossy"` | `scene.add(m, { meshResolution: "lossless" })` | `mesh-resolution="lossless"` | `meshResolution="lossless"` | +| Auto-rotate (orbit) | Orbit controls | `animate` | `{ speed: number; axis?: "x"\|"y" } \| false` | (off) | `{ animate: { speed: 0.3 } }` | flat attrs `animate-speed="0.3"` `animate-axis="x"` | object prop | + +**Known divergence — nested object props on custom elements.** HTML attributes can't carry structured objects directly. The doc uses **two conventions** depending on the object's character: + +- **Settings-shaped objects** (lights, shadow, strategies) → **JSON-stringified attribute**: `directional-light='{"direction":[…],"color":"…"}'`. One field, one value, parsed once at connect time. +- **Behavior-shaped objects with a "boolean + tuning"** quality (`animate`) → **flat attributes**: `animate-speed`, `animate-axis`. Reads naturally in HTML where the user toggles + tunes. + +This split is the price of supporting raw HTML and matches how React/Vue would naturally fall out via prop spread — `animate-speed` reads like a typical HTML attr; `directional-light='{…}'` is uglier but doesn't proliferate `directional-light-direction-x` etc. **No further nesting conventions** — if a new feature comes in with a nested object, pick one of these two patterns to match its character. + +--- + +## Per-path naming conventions + +| Concept | Vanilla JS | Custom elements | React | Vue (template) | +|---|---|---|---|---| +| Camera | `createPolyCamera(opts)` | `` | `` | `` | +| Scene | `createPolyScene(host, opts)` | `` | `` | `` | +| Mesh | `scene.add(await loadMesh(url))` | `` | `` | `` | +| Prop casing | camelCase (`rotX`) | kebab-case (`rot-x`) | camelCase (`rotX`) | kebab-case in template (`:rot-x`), camelCase in ` - - - -`, + + + + + +`, }, { id: "react", label: "React", language: "tsx", - code: `import { PolyCamera, PolyScene, PolyOrbitControls, PolyMesh } from "@layoutit/polycss-react"; + code: `import { PolyCamera, PolyScene, PolyOrbitControls, PolyBox } from "@layoutit/polycss-react"; export function App() { return ( - + ); @@ -59,12 +61,12 @@ export function App() { - + `, }, ].map(tab => ({ @@ -166,8 +168,8 @@ const ldJson = {
- +
@@ -224,7 +226,7 @@ const ldJson = {
-

polycss provides custom elements (<poly-scene>, <poly-mesh>), an imperative createPolyScene API, and optional React / Vue bindings. Each polygon in the mesh becomes a DOM element you can style and interact with: use whichever entry point fits your stack.

+

polycss provides custom elements (<poly-camera>, <poly-scene>, <poly-mesh>), an imperative createPolyCamera / createPolyScene API, and optional React / Vue bindings. Each polygon in the mesh becomes a DOM element you can style and interact with: use whichever entry point fits your stack.

@@ -276,34 +278,23 @@ const ldJson = { } fetchStars(); - // Hero model: loads the Apple GLB and mounts it with createPolyScene. + // Hero model: mounts a colored cube via boxPolygons (self-contained, no fetch). // autoCenter keeps drag gestures pivoted around the mesh centroid. async function mountHeroModel() { const host = document.getElementById("hero-root"); if (!host) return; try { - const { createPolyScene, createPolyOrbitControls, loadMesh } = await import("@layoutit/polycss"); - const parseResult = await loadMesh("/gallery/glb/apple.glb", { - gltfOptions: { - targetSize: 60, - defaultColor: "#cccccc", - }, - }); + const { createPolyCamera, createPolyScene, createPolyOrbitControls, boxPolygons } = await import("@layoutit/polycss"); - const scene = createPolyScene(host, { - rotX: 74.4, - rotY: 301.6, - zoom: 0.3, - autoCenter: true, - perspective: 100000, - }); - scene.add(parseResult); + const camera = createPolyCamera({ rotX: 65, rotY: 45, zoom: 0.3 }); + const scene = createPolyScene(host, { camera, autoCenter: true }); + scene.add({ polygons: boxPolygons({ size: 100, color: "#ff6644" }) }); - // Auto-rotate the hero apple for visual interest. - let rotY = 301.6; + // Auto-rotate the hero cube for visual interest. + let rotY = 45; function tick() { rotY = (rotY + 1.2) % 360; - scene.setOptions({ rotY }); + camera.setOptions({ rotY }); requestAnimationFrame(tick); } requestAnimationFrame(tick); From 7ac415f050bdcefdb41df5c2111ba061b774b154 Mon Sep 17 00:00:00 2001 From: Juan Cruz Fortunatti Date: Wed, 20 May 2026 01:49:42 +0200 Subject: [PATCH 18/55] docs(website): rewrite component docs for unified camera-wraps-scene API poly-camera.mdx: PolyCamera is now orthographic (not perspective alias); rewrite perspective-vs-ortho table, prop tables, all examples; remove perspective=false pattern; all vanilla examples use poly-camera > poly-scene. poly-scene.mdx: drop camera-on-scene description; all vanilla tabs now show poly-camera wrapping poly-scene; Scene with Camera and Lighting switches PolyCamera perspective={1000} to PolyPerspectiveCamera. poly-controls.mdx: prose updated to say camera owns state; imperative example uses createPolyCamera + createPolyScene({camera}); all poly-scene rot-x=... snippets replaced with poly-camera > poly-scene nesting. --- .../content/docs/components/poly-camera.mdx | 77 ++++++++++--------- .../content/docs/components/poly-controls.mdx | 37 +++++---- .../content/docs/components/poly-scene.mdx | 22 +++--- 3 files changed, 76 insertions(+), 60 deletions(-) diff --git a/website/src/content/docs/components/poly-camera.mdx b/website/src/content/docs/components/poly-camera.mdx index 20829c44..ee7524eb 100644 --- a/website/src/content/docs/components/poly-camera.mdx +++ b/website/src/content/docs/components/poly-camera.mdx @@ -5,9 +5,9 @@ description: "Camera components for controlling the 3D viewport: perspective, or import { Tabs, TabItem } from '@astrojs/starlight/components'; -polycss provides two camera components for React and Vue: `` for 3D scenes with depth foreshortening, and `` for parallel projection (map-style or technical rendering). `` is a re-exported alias for `` and can be used interchangeably. +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. -For vanilla custom elements, pass camera attributes directly to ``. For React and Vue, render `` inside a camera wrapper; this is what gives scenes and controls shared camera state. +`` is orthographic by default. Use `` when depth foreshortening is needed (e.g. first-person or game-like scenes). For pointer drag, wheel zoom, and autorotate, drop a `` child inside the scene: see **[PolyOrbitControls](/components/poly-controls)**. @@ -15,12 +15,14 @@ For pointer drag, wheel zoom, and autorotate, drop a `` child | Use case | Component | |----------|-----------| -| 3D scenes with depth foreshortening | `` (or ``) | -| 2D-map-style or technical parallel projection | `` | +| Isometric / voxel / diagrammatic scenes | `` (alias ``) — **default** | +| 3D scenes with depth foreshortening | `` | -This mirrors three.js's split: `PerspectiveCamera + OrbitControls dolly` ↔ `PolyPerspectiveCamera + dolly mode`; `OrthographicCamera + zoom` ↔ `PolyOrthographicCamera + default scale-zoom`. +polycss defaults to orthographic rather than perspective — polycss's strengths (integer-pixel atlas, no per-frame JS, DOM stacking) are most visible in ortho scenes. This deliberately diverges from three.js's default. -## `PolyPerspectiveCamera` / `PolyCamera` +## `PolyCamera` / `PolyOrthographicCamera` + +`PolyCamera` is an alias for `PolyOrthographicCamera`. Import either: they are identical. Uses `perspective: none` (parallel projection). No `perspective` prop. ### Props @@ -29,37 +31,37 @@ This mirrors three.js's split: `PerspectiveCamera + OrbitControls dolly` ↔ `Po | `zoom` | `number` | `1` | Scales the scene. `1` is the natural size; values above 1 zoom in, below 1 zoom out. | | `rotX` | `number` | `65` | Rotation around the X axis in degrees. | | `rotY` | `number` | `45` | Rotation around the Y axis in degrees (0–360). | -| `perspective` | `number` | `8000` | CSS perspective depth in pixels. Higher values feel flatter (more isometric); lower values exaggerate depth. | -| `distance` | `number` | `0` | Dolly pull-back in pixels. When > 0, adds `translateZ(-distance)px` to the scene transform: analogous to `OrbitControls` spherical radius. Increased by `dolly` mode in `PolyOrbitControls`. | +| `distance` | `number` | `0` | Dolly pull-back in pixels. When > 0, adds `translateZ(-distance)px` to the scene transform. Driven by `dolly` mode in `PolyOrbitControls`. | +| `target` | `Vec3` | `[0,0,0]` | Point in scene space the camera orbits around. | + +## `PolyPerspectiveCamera` -`PolyCamera` is an alias for `PolyPerspectiveCamera`. Import either: they are identical. To get orthographic projection, use `` instead; passing `perspective={false}` is no longer supported. +Adds CSS `perspective` for depth foreshortening. Use for game-like or first-person scenes. -## `PolyOrthographicCamera` +### Props -Hardcodes `perspective: "none"` (parallel projection). Use for 2D-map-style or technical rendering where no depth foreshortening is wanted. Accepts all props above **except** `perspective`: that prop is not available. +All props from `PolyCamera` above, plus: | Prop | Type | Default | Description | |------|------|---------|-------------| -| `zoom` | `number` | `1` | Scales the scene. | -| `rotX` | `number` | `65` | Rotation around the X axis in degrees. | -| `rotY` | `number` | `45` | Rotation around the Y axis in degrees (0–360). | -| `distance` | `number` | `0` | Dolly pull-back in pixels. | +| `perspective` | `number` | `8000` | CSS perspective depth in pixels. Higher values feel flatter (more isometric); lower values exaggerate depth. | ## Usage ### Basic Camera -Set an initial angle and perspective. +Set an initial angle. `PolyCamera` (orthographic) is the default. ```html - - - - + + + + + ``` @@ -94,30 +96,31 @@ import { PolyCamera, PolyScene, PolyMesh } from "@layoutit/polycss-vue"; -### Orthographic Camera +### Orthographic Camera (default) -For parallel projection, swap in `PolyOrthographicCamera`. It has no `perspective` prop. +`` / `PolyCamera` is orthographic. `PolyOrthographicCamera` is the explicit alias — use either. ```html - - - - + + + + + ``` ```tsx -import { PolyOrthographicCamera, PolyScene, PolyMesh } from "@layoutit/polycss-react"; +import { PolyCamera, PolyScene, PolyMesh } from "@layoutit/polycss-react"; export function App() { return ( - + - + ); } ``` @@ -125,15 +128,15 @@ export function App() { ```vue ``` @@ -145,10 +148,12 @@ Drop a `` child for drag, wheel, and `dt`-clamped autorotate. ```html - - - - + + + + + + ``` ```tsx diff --git a/website/src/content/docs/components/poly-controls.mdx b/website/src/content/docs/components/poly-controls.mdx index 456ecdff..06e5e152 100644 --- a/website/src/content/docs/components/poly-controls.mdx +++ b/website/src/content/docs/components/poly-controls.mdx @@ -10,7 +10,7 @@ polycss ships two controls components that follow the three.js split: - **`PolyOrbitControls`**: orbit-style drag (pointer drag rotates around the scene center) + wheel zoom + autorotate. This is the default pick for most scenes. - **`PolyMapControls`**: map/pan-style drag (pointer drag pans the camera across the surface). Use for top-down or flat layouts. -Both are modelled on the Three.js `OrbitControls` / `MapControls` pattern: vanilla `` / `createPolyScene` owns camera state, while React and Vue use the surrounding `` context. The controls component listens for pointer/wheel input and runs an optional `requestAnimationFrame` autorotate loop, then mutates that shared camera state. +Both are modelled on the Three.js `OrbitControls` / `MapControls` pattern: the wrapping `` / `PolyCamera` element owns camera state across all paths. The controls component listens for pointer/wheel input and runs an optional `requestAnimationFrame` autorotate loop, then mutates that shared camera state. They are available as custom elements (`` / ``), via the imperative `createPolyOrbitControls` / `createPolyMapControls` API, and as React / Vue components. @@ -47,10 +47,12 @@ They are available as custom elements (`` / ` - - - - + + + + + + ``` The presence of any `animate-*` attribute (`animate-speed`, `animate-axis`, `animate-pause-on-interaction`) implies `animate` is enabled. Removing them all turns animate off. @@ -94,9 +96,10 @@ import { PolyCamera, PolyScene, PolyOrbitControls, PolyMesh } from "@layoutit/po When you mount a scene through the `createPolyScene` imperative API, pair it with `createPolyOrbitControls(scene, options)`. The controls handle returns a small lifecycle interface for live updates. ```ts -import { createPolyScene, createPolyOrbitControls, loadMesh } from "@layoutit/polycss"; +import { createPolyCamera, createPolyScene, createPolyOrbitControls, loadMesh } from "@layoutit/polycss"; -const scene = createPolyScene(host, { rotX: 65, rotY: 45 }); +const camera = createPolyCamera({ rotX: 65, rotY: 45 }); +const scene = createPolyScene(host, { camera }); scene.add(await loadMesh("/cottage.glb")); const controls = createPolyOrbitControls(scene, { @@ -129,10 +132,12 @@ Use `` (or `createPolyMapControls`) when you want drag to pan ```html - - - - + + + + + + ``` @@ -160,10 +165,12 @@ Disable input but keep autorotate running: ```html - - - - + + + + + + ``` diff --git a/website/src/content/docs/components/poly-scene.mdx b/website/src/content/docs/components/poly-scene.mdx index b4da7ffe..ef78abfb 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. Vanilla `` also accepts camera attributes; React and Vue scenes receive camera state from a surrounding `` / `` / ``. +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. It's available as a custom element (``), via the imperative `createPolyScene(host, opts)` API, and as React / Vue components (``). @@ -22,7 +22,7 @@ It's available as a custom element (``), via the imperative `createP | `polygons` | `Polygon[]` | None | (Framework only.) Flat array of polygon objects rendered as direct children. Composes with JSX/slot children. | | `children` | None | None | `` / `` / `` (vanilla) or `` / `` / `` (React / Vue). | -**Camera state and input** are separate layers. For vanilla, set camera attributes (`rot-x`, `rot-y`, `zoom`, `perspective`) on ``. For React/Vue, set those props on ``. Add a child `` / `` to enable drag, wheel, or autorotate: see [PolyOrbitControls](/components/poly-controls). +**Camera state and input** are set on the wrapping camera element (`` / `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). ## Mesh props / attributes @@ -69,9 +69,11 @@ A scene with a single GLB mesh at default camera angle. ```html - - - + + + + + ``` @@ -80,7 +82,7 @@ import { PolyCamera, PolyScene, PolyMesh } from "@layoutit/polycss-react"; export function App() { return ( - + @@ -92,7 +94,7 @@ export function App() { ```vue ``` @@ -96,11 +96,11 @@ import { PolyCamera, PolyScene, PolyOrbitControls, PolyMesh } from "@layoutit/po When you mount a scene through the `createPolyScene` imperative API, pair it with `createPolyOrbitControls(scene, options)`. The controls handle returns a small lifecycle interface for live updates. ```ts -import { createPolyCamera, createPolyScene, createPolyOrbitControls, loadMesh } from "@layoutit/polycss"; +import { createPolyCamera, createPolyScene, createPolyOrbitControls, createPolyTorus } from "@layoutit/polycss"; const camera = createPolyCamera({ rotX: 65, rotY: 45 }); const scene = createPolyScene(host, { camera }); -scene.add(await loadMesh("https://polycss.com/gallery/obj/cottage.obj")); +scene.add(createPolyTorus({ color: "#4ecdc4" })); const controls = createPolyOrbitControls(scene, { drag: true, @@ -168,7 +168,7 @@ Disable input but keep autorotate running: - + ``` diff --git a/website/src/content/docs/components/poly-scene.mdx b/website/src/content/docs/components/poly-scene.mdx index 2aa4cc61..392c566a 100644 --- a/website/src/content/docs/components/poly-scene.mdx +++ b/website/src/content/docs/components/poly-scene.mdx @@ -62,7 +62,7 @@ interface PolyAmbientLight { ### Basic Scene -A scene with a single GLB mesh at default camera angle. +A scene with a dodecahedron at default camera angle. @@ -71,20 +71,20 @@ A scene with a single GLB mesh at default camera angle. - + ``` ```tsx -import { PolyCamera, PolyScene, PolyMesh } from "@layoutit/polycss-react"; +import { PolyCamera, PolyScene, PolyDodecahedron } from "@layoutit/polycss-react"; export function App() { return ( - + ); @@ -96,13 +96,13 @@ export function App() { ``` @@ -111,15 +111,15 @@ import { PolyCamera, PolyScene, PolyMesh } from "@layoutit/polycss-vue"; ### Scene with Camera and Lighting ```tsx -import { PolyPerspectiveCamera, PolyScene, PolyMesh } from "@layoutit/polycss-react"; +import { PolyPerspectiveCamera, PolyScene, PolyTorus, PolyBox } from "@layoutit/polycss-react"; - - + + ``` @@ -129,8 +129,8 @@ import { PolyPerspectiveCamera, PolyScene, PolyMesh } from "@layoutit/polycss-re ```tsx - - + + ``` From 50aab0743dfb7c661e9acd3dd1b0550017dc34c0 Mon Sep 17 00:00:00 2001 From: Juan Cruz Fortunatti Date: Wed, 20 May 2026 03:00:14 +0200 Subject: [PATCH 46/55] docs: swap cottage.obj to primitive components in guides (projections, shapes, performance) --- .../src/content/docs/guides/performance.mdx | 6 +++--- .../src/content/docs/guides/projections.mdx | 20 +++++++++---------- website/src/content/docs/guides/shapes.mdx | 4 ++-- 3 files changed, 15 insertions(+), 15 deletions(-) diff --git a/website/src/content/docs/guides/performance.mdx b/website/src/content/docs/guides/performance.mdx index ee00f4bc..657ce2cb 100644 --- a/website/src/content/docs/guides/performance.mdx +++ b/website/src/content/docs/guides/performance.mdx @@ -63,7 +63,7 @@ Generated atlas pages default to `textureQuality="auto"`. Auto starts from the p - + ``` @@ -72,12 +72,12 @@ Generated atlas pages default to `textureQuality="auto"`. Auto starts from the p // React - + // Or per mesh: - + ``` Use explicit numeric values when you want to override auto raster scale: `0.5` or `0.75` for distant or dense assets, `1` for close-up inspection when the runtime bitmap cost is acceptable. Numeric quality keeps the 64px atlas sprite size. diff --git a/website/src/content/docs/guides/projections.mdx b/website/src/content/docs/guides/projections.mdx index 40129582..e1c69e21 100644 --- a/website/src/content/docs/guides/projections.mdx +++ b/website/src/content/docs/guides/projections.mdx @@ -31,47 +31,47 @@ Higher `perspective` values feel flatter (more isometric-like); lower values exa - + - + - + ``` ```tsx -import { PolyCamera, PolyPerspectiveCamera, PolyScene, PolyMesh } from "@layoutit/polycss-react"; +import { PolyCamera, PolyPerspectiveCamera, PolyScene, PolyIcosahedron } from "@layoutit/polycss-react"; // Orthographic (default) - + // Standard perspective - + // Very flat / near-isometric perspective - + ``` @@ -82,20 +82,20 @@ import { PolyCamera, PolyPerspectiveCamera, PolyScene, PolyMesh } from "@layouti - + - + ``` diff --git a/website/src/content/docs/guides/shapes.mdx b/website/src/content/docs/guides/shapes.mdx index 29a55fc1..14096a39 100644 --- a/website/src/content/docs/guides/shapes.mdx +++ b/website/src/content/docs/guides/shapes.mdx @@ -275,7 +275,7 @@ import { loadMesh, createPolyCamera, createPolyScene } from "@layoutit/polycss"; const camera = createPolyCamera({ rotX: 65, rotY: 45 }); const scene = createPolyScene(document.getElementById("host")!, { camera }); -const result = await loadMesh("https://polycss.com/gallery/obj/cottage.obj"); +const result = await loadMesh("/model.glb"); scene.add(result); // ... later: scene.destroy(); // removes the scene and disposes registered meshes @@ -286,7 +286,7 @@ scene.destroy(); // removes the scene and disposes registered meshes import { PolyCamera, PolyScene, Poly, usePolyMesh } from "@layoutit/polycss-react"; function Viewer() { - const { polygons, loading, error } = usePolyMesh("https://polycss.com/gallery/obj/cottage.obj"); + const { polygons, loading, error } = usePolyMesh("/model.glb"); if (loading) return ; if (error) return
Error: {error}
; From 271bc4e60473b7affd94f6ba0d81c907b0fa0fd3 Mon Sep 17 00:00:00 2001 From: Juan Cruz Fortunatti Date: Wed, 20 May 2026 03:00:17 +0200 Subject: [PATCH 47/55] docs: pair cottage.obj with mtl companion in texture and headless API examples --- website/src/content/docs/api/headless.mdx | 7 +++++-- website/src/content/docs/guides/textures.mdx | 20 ++++++++++++++++---- 2 files changed, 21 insertions(+), 6 deletions(-) diff --git a/website/src/content/docs/api/headless.mdx b/website/src/content/docs/api/headless.mdx index 8ef8d7be..b41eb5d6 100644 --- a/website/src/content/docs/api/headless.mdx +++ b/website/src/content/docs/api/headless.mdx @@ -69,6 +69,7 @@ High-level convenience function: fetches a URL, detects OBJ vs glTF/GLB/VOX by e import { loadMesh } from "@layoutit/polycss-core"; const { polygons, dispose } = await loadMesh("https://polycss.com/gallery/obj/cottage.obj", { + mtlUrl: "https://polycss.com/gallery/obj/cottage.mtl", baseUrl: new URL("https://polycss.com/gallery/obj/cottage.obj", location.href).href, gltfOptions: { targetSize: 60 }, }); @@ -153,7 +154,9 @@ import { createPolyCamera, createPolyScene, createPolyOrbitControls, loadMesh } const camera = createPolyCamera({ rotX: 65, rotY: 45 }); const scene = createPolyScene(host, { camera }); -scene.add(await loadMesh("https://polycss.com/gallery/obj/cottage.obj")); +scene.add(await loadMesh("https://polycss.com/gallery/obj/cottage.obj", { + mtlUrl: "https://polycss.com/gallery/obj/cottage.mtl", +})); const controls = createPolyOrbitControls(scene, { drag: true, // default true @@ -225,7 +228,7 @@ Register ``, ``, ``, ``, ` - + ``` diff --git a/website/src/content/docs/guides/textures.mdx b/website/src/content/docs/guides/textures.mdx index 4d442e9a..fafcf44d 100644 --- a/website/src/content/docs/guides/textures.mdx +++ b/website/src/content/docs/guides/textures.mdx @@ -30,7 +30,10 @@ The simplest way to render a mesh is the mesh element with a `src`. It fetches t - + ``` @@ -43,7 +46,10 @@ export function App() { return ( - + ); @@ -55,7 +61,10 @@ export function App() { @@ -120,6 +129,7 @@ For programmatic loading with explicit lifecycle, use `loadMesh` from the core p import { createPolyCamera, loadMesh, createPolyScene } from "@layoutit/polycss"; const result = await loadMesh("https://polycss.com/gallery/obj/cottage.obj", { + mtlUrl: "https://polycss.com/gallery/obj/cottage.mtl", gltfOptions: { targetSize: 60 }, }); const camera = createPolyCamera({ rotX: 65, rotY: 45 }); @@ -134,7 +144,9 @@ scene.destroy(); // removes the scene and disposes registered meshes import { PolyCamera, PolyScene, Poly, usePolyMesh } from "@layoutit/polycss-react"; function Viewer() { - const { polygons, loading, error } = usePolyMesh("https://polycss.com/gallery/obj/cottage.obj"); + const { polygons, loading, error } = usePolyMesh("https://polycss.com/gallery/obj/cottage.obj", { + mtlUrl: "https://polycss.com/gallery/obj/cottage.mtl", + }); if (loading) return
Loading...
; if (error) return
Error: {error}
; From a42c824f0de0e410bfc76763d1eb6c016aba9647 Mon Sep 17 00:00:00 2001 From: Juan Cruz Fortunatti Date: Wed, 20 May 2026 03:07:56 +0200 Subject: [PATCH 48/55] feat(core): add spherePolygons (icosphere) helper --- packages/core/src/helpers/index.ts | 2 + .../core/src/helpers/spherePolygons.test.ts | 131 ++++++++++++++++++ packages/core/src/helpers/spherePolygons.ts | 98 +++++++++++++ packages/core/src/index.ts | 4 +- 4 files changed, 233 insertions(+), 2 deletions(-) create mode 100644 packages/core/src/helpers/spherePolygons.test.ts create mode 100644 packages/core/src/helpers/spherePolygons.ts diff --git a/packages/core/src/helpers/index.ts b/packages/core/src/helpers/index.ts index eec54069..79d8d8e0 100644 --- a/packages/core/src/helpers/index.ts +++ b/packages/core/src/helpers/index.ts @@ -12,6 +12,8 @@ export { planePolygons } from "./planePolygons"; export type { PlanePolygonsOptions } from "./planePolygons"; export { octahedronPolygons } from "./octahedronPolygons"; export type { OctahedronPolygonsOptions } from "./octahedronPolygons"; +export { spherePolygons } from "./spherePolygons"; +export type { SpherePolygonsOptions } from "./spherePolygons"; export { tetrahedronPolygons } from "./tetrahedronPolygons"; export type { TetrahedronPolygonsOptions } from "./tetrahedronPolygons"; export { icosahedronPolygons } from "./icosahedronPolygons"; diff --git a/packages/core/src/helpers/spherePolygons.test.ts b/packages/core/src/helpers/spherePolygons.test.ts new file mode 100644 index 00000000..1de37dd1 --- /dev/null +++ b/packages/core/src/helpers/spherePolygons.test.ts @@ -0,0 +1,131 @@ +import { describe, expect, it } from "vitest"; +import type { Vec3 } from "../types"; +import { spherePolygons } from "./spherePolygons"; + +// ── Test helpers ───────────────────────────────────────────────────────────── + +function cross(a: Vec3, b: Vec3): Vec3 { + return [ + a[1] * b[2] - a[2] * b[1], + a[2] * b[0] - a[0] * b[2], + a[0] * b[1] - a[1] * b[0], + ]; +} + +function sub(a: Vec3, b: Vec3): Vec3 { + return [a[0] - b[0], a[1] - b[1], a[2] - b[2]]; +} + +function dot(a: Vec3, b: Vec3): number { + return a[0] * b[0] + a[1] * b[1] + a[2] * b[2]; +} + +function len(v: Vec3): number { + return Math.sqrt(v[0] * v[0] + v[1] * v[1] + v[2] * v[2]); +} + +function isCCWFromOutside(vertices: Vec3[], solidCentroid: Vec3): boolean { + const [a, b, c] = vertices; + const n = cross(sub(b, a), sub(c, a)); + const fc: Vec3 = [0, 0, 0]; + for (const v of vertices) { fc[0] += v[0]; fc[1] += v[1]; fc[2] += v[2]; } + fc[0] /= vertices.length; fc[1] /= vertices.length; fc[2] /= vertices.length; + return dot(n, sub(fc, solidCentroid)) > 0; +} + +// ── Tests ───────────────────────────────────────────────────────────────────── + +describe("spherePolygons", () => { + it("default polygon count is 80 (icosahedron 20 × 4 at subdivisions 1)", () => { + expect(spherePolygons()).toHaveLength(80); + }); + + it("returns correct counts at subdivisions 0/1/2/3", () => { + expect(spherePolygons({ subdivisions: 0 })).toHaveLength(20); + expect(spherePolygons({ subdivisions: 1 })).toHaveLength(80); + expect(spherePolygons({ subdivisions: 2 })).toHaveLength(320); + expect(spherePolygons({ subdivisions: 3 })).toHaveLength(1280); + }); + + it("every face is a triangle", () => { + for (const p of spherePolygons()) { + expect(p.vertices).toHaveLength(3); + } + }); + + it("every face is coplanar (trivially true for triangles)", () => { + // Three points always define a plane, so this is an invariant by definition. + for (const p of spherePolygons()) { + expect(p.vertices).toHaveLength(3); + } + }); + + it("every face winds CCW from outside (outward normal = away from origin)", () => { + const centroid: Vec3 = [0, 0, 0]; + for (const p of spherePolygons()) { + expect(isCCWFromOutside(p.vertices, centroid)).toBe(true); + } + }); + + it("default radius is 50, vertex magnitude ≈ 50 for every vertex", () => { + const polygons = spherePolygons(); + for (const p of polygons) { + for (const v of p.vertices) { + expect(len(v)).toBeCloseTo(50, 4); + } + } + }); + + it("respects custom radius", () => { + const polygons = spherePolygons({ radius: 200 }); + for (const p of polygons) { + for (const v of p.vertices) { + expect(len(v)).toBeCloseTo(200, 3); + } + } + }); + + it("respects custom color", () => { + const polygons = spherePolygons({ color: "#abcdef" }); + for (const p of polygons) { + expect(p.color).toBe("#abcdef"); + } + }); + + it("uses default color #cccccc", () => { + const polygons = spherePolygons(); + for (const p of polygons) { + expect(p.color).toBe("#cccccc"); + } + }); + + it("custom radius and subdivisions round-trip correctly", () => { + const polygons = spherePolygons({ radius: 75, subdivisions: 2, color: "#112233" }); + expect(polygons).toHaveLength(320); + for (const p of polygons) { + expect(p.color).toBe("#112233"); + for (const v of p.vertices) { + expect(len(v)).toBeCloseTo(75, 3); + } + } + }); + + it("subdivisions > 3 is clamped to 3", () => { + expect(spherePolygons({ subdivisions: 5 })).toHaveLength(1280); + expect(spherePolygons({ subdivisions: 10 })).toHaveLength(1280); + }); + + it("all face normals point outward (dot with face centroid > 0)", () => { + const polygons = spherePolygons({ subdivisions: 1 }); + for (const p of polygons) { + const [a, b, c] = p.vertices; + const n = cross(sub(b, a), sub(c, a)); + const fc: Vec3 = [ + (a[0] + b[0] + c[0]) / 3, + (a[1] + b[1] + c[1]) / 3, + (a[2] + b[2] + c[2]) / 3, + ]; + expect(dot(n, fc)).toBeGreaterThan(0); + } + }); +}); diff --git a/packages/core/src/helpers/spherePolygons.ts b/packages/core/src/helpers/spherePolygons.ts new file mode 100644 index 00000000..9a3af40e --- /dev/null +++ b/packages/core/src/helpers/spherePolygons.ts @@ -0,0 +1,98 @@ +/** + * Icosphere (subdivided icosahedron) geometry — approximates a sphere with + * triangular faces. Each subdivision step quadruples the face count: + * subdivisions 0 → 20, 1 → 80, 2 → 320, 3 → 1280 (capped). + * + * Vertex coordinates use polycss world space: +X right, +Y forward, +Z up. + * The sphere is centered at the origin; all vertices sit at distance `radius`. + * Faces wind CCW from the outside (outward normal = away from origin). + */ +import type { Polygon, Vec3 } from "../types"; + +export interface SpherePolygonsOptions { + /** Radius of the sphere. Default 50. */ + radius?: number; + /** Subdivision level (0 = bare icosahedron, 20 triangles; each +1 quadruples count). Default 1 → 80 triangles. Cap at 3 (1280 triangles). */ + subdivisions?: number; + /** Fill color applied to all faces. */ + color?: string; +} + +export function spherePolygons(options: SpherePolygonsOptions = {}): Polygon[] { + const { radius = 50, color = "#cccccc" } = options; + // Cap subdivisions at 3 (1280 triangles) to prevent accidental extreme DOM cost. + const subdivisions = Math.min(Math.max(0, Math.floor(options.subdivisions ?? 1)), 3); + + const phi = (1 + Math.sqrt(5)) / 2; + + // 12 vertices of a regular icosahedron, normalized to the unit sphere. + let verts: Vec3[] = [ + [-1, phi, 0], + [ 1, phi, 0], + [-1, -phi, 0], + [ 1, -phi, 0], + [0, -1, phi], + [0, 1, phi], + [0, -1, -phi], + [0, 1, -phi], + [ phi, 0, -1], + [ phi, 0, 1], + [-phi, 0, -1], + [-phi, 0, 1], + ].map(([x, y, z]) => { + const l = Math.sqrt(x * x + y * y + z * z); + return [x / l, y / l, z / l] as Vec3; + }); + + // 20 triangular faces wound CCW from the outside. + let faces: [number, number, number][] = [ + [0, 11, 5], [0, 5, 1], [0, 1, 7], [0, 7, 10], [0, 10, 11], + [1, 5, 9], [5, 11, 4], [11, 10, 2], [10, 7, 6], [7, 1, 8], + [3, 9, 4], [3, 4, 2], [3, 2, 6], [3, 6, 8], [3, 8, 9], + [4, 9, 5], [2, 4, 11], [6, 2, 10], [8, 6, 7], [9, 8, 1], + ]; + + // Subdivide: each triangle is split into 4 by inserting midpoints on each + // edge. Midpoints are projected back onto the unit sphere to preserve the + // spherical shape. + for (let s = 0; s < subdivisions; s++) { + const newVerts: Vec3[] = [...verts]; + const newFaces: [number, number, number][] = []; + const midCache = new Map(); + + const midpoint = (i: number, j: number): number => { + const key = i < j ? `${i}-${j}` : `${j}-${i}`; + const cached = midCache.get(key); + if (cached !== undefined) return cached; + const a = verts[i], b = verts[j]; + let mx = (a[0] + b[0]) / 2, my = (a[1] + b[1]) / 2, mz = (a[2] + b[2]) / 2; + const l = Math.sqrt(mx * mx + my * my + mz * mz); + mx /= l; my /= l; mz /= l; + const idx = newVerts.length; + newVerts.push([mx, my, mz]); + midCache.set(key, idx); + return idx; + }; + + for (const [a, b, c] of faces) { + const ab = midpoint(a, b); + const bc = midpoint(b, c); + const ca = midpoint(c, a); + newFaces.push([a, ab, ca]); + newFaces.push([b, bc, ab]); + newFaces.push([c, ca, bc]); + newFaces.push([ab, bc, ca]); + } + + verts = newVerts; + faces = newFaces; + } + + // Scale from unit sphere to the requested radius. + const scaled: Vec3[] = verts.map(([x, y, z]) => [x * radius, y * radius, z * radius]); + + return faces.map(([a, b, c]) => ({ + vertices: [scaled[a], scaled[b], scaled[c]], + color, + })); +} diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 1ae6518e..78ac91ef 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -118,8 +118,8 @@ export type { } from "./cull/cameraBackfaceCulling"; // ── Helper geometry (boxes, axes, light marker, transform arrows / rings) ─ -export { axesHelperPolygons, boxPolygons, arrowPolygons, ringPolygons, ringQuadPolygons, planePolygons, octahedronPolygons, tetrahedronPolygons, icosahedronPolygons, dodecahedronPolygons, cylinderPolygons, conePolygons, torusPolygons } from "./helpers"; -export type { AxesHelperOptions, BoxFace, BoxFaceOptions, BoxPolygonsOptions, ArrowPolygonsOptions, RingPolygonsOptions, RingQuadPolygonsOptions, PlanePolygonsOptions, OctahedronPolygonsOptions, TetrahedronPolygonsOptions, IcosahedronPolygonsOptions, DodecahedronPolygonsOptions, CylinderPolygonsOptions, ConePolygonsOptions, TorusPolygonsOptions } from "./helpers"; +export { axesHelperPolygons, boxPolygons, arrowPolygons, ringPolygons, ringQuadPolygons, planePolygons, octahedronPolygons, spherePolygons, tetrahedronPolygons, icosahedronPolygons, dodecahedronPolygons, cylinderPolygons, conePolygons, torusPolygons } from "./helpers"; +export type { AxesHelperOptions, BoxFace, BoxFaceOptions, BoxPolygonsOptions, ArrowPolygonsOptions, RingPolygonsOptions, RingQuadPolygonsOptions, PlanePolygonsOptions, OctahedronPolygonsOptions, SpherePolygonsOptions, TetrahedronPolygonsOptions, IcosahedronPolygonsOptions, DodecahedronPolygonsOptions, CylinderPolygonsOptions, ConePolygonsOptions, TorusPolygonsOptions } from "./helpers"; // ── Animation ───────────────────────────────────────────────────── export { From b509a4d43592727a12cae9ea3bf93ec13e735753 Mon Sep 17 00:00:00 2001 From: Juan Cruz Fortunatti Date: Wed, 20 May 2026 03:08:33 +0200 Subject: [PATCH 49/55] feat(polycss): add createPolySphere factory --- packages/polycss/src/api/createPolyShapes.test.ts | 7 +++++++ packages/polycss/src/api/createPolyShapes.ts | 8 +++++++- packages/polycss/src/index.ts | 1 + 3 files changed, 15 insertions(+), 1 deletion(-) diff --git a/packages/polycss/src/api/createPolyShapes.test.ts b/packages/polycss/src/api/createPolyShapes.test.ts index 9575be6b..e60a8c57 100644 --- a/packages/polycss/src/api/createPolyShapes.test.ts +++ b/packages/polycss/src/api/createPolyShapes.test.ts @@ -4,6 +4,7 @@ import { createPolyPlane, createPolyRing, createPolyOctahedron, + createPolySphere, createPolyTetrahedron, createPolyIcosahedron, createPolyDodecahedron, @@ -52,6 +53,12 @@ describe("createPolyOctahedron", () => { }); }); +describe("createPolySphere", () => { + it("returns 80 triangular faces at default subdivisions 1", () => { + expect(createPolySphere().polygons).toHaveLength(80); + }); +}); + describe("createPolyTetrahedron", () => { it("returns 4 triangular faces", () => { expect(createPolyTetrahedron().polygons).toHaveLength(4); diff --git a/packages/polycss/src/api/createPolyShapes.ts b/packages/polycss/src/api/createPolyShapes.ts index 3c02ce92..5a9af298 100644 --- a/packages/polycss/src/api/createPolyShapes.ts +++ b/packages/polycss/src/api/createPolyShapes.ts @@ -9,6 +9,7 @@ import { planePolygons, ringPolygons, octahedronPolygons, + spherePolygons, tetrahedronPolygons, icosahedronPolygons, dodecahedronPolygons, @@ -19,6 +20,7 @@ import { type PlanePolygonsOptions, type RingPolygonsOptions, type OctahedronPolygonsOptions, + type SpherePolygonsOptions, type TetrahedronPolygonsOptions, type IcosahedronPolygonsOptions, type DodecahedronPolygonsOptions, @@ -27,7 +29,7 @@ import { type TorusPolygonsOptions, } from "@layoutit/polycss-core"; -export type { BoxPolygonsOptions, PlanePolygonsOptions, RingPolygonsOptions, OctahedronPolygonsOptions, TetrahedronPolygonsOptions, IcosahedronPolygonsOptions, DodecahedronPolygonsOptions, CylinderPolygonsOptions, ConePolygonsOptions, TorusPolygonsOptions }; +export type { BoxPolygonsOptions, PlanePolygonsOptions, RingPolygonsOptions, OctahedronPolygonsOptions, SpherePolygonsOptions, TetrahedronPolygonsOptions, IcosahedronPolygonsOptions, DodecahedronPolygonsOptions, CylinderPolygonsOptions, ConePolygonsOptions, TorusPolygonsOptions }; // Re-export ParseResult so callers can type the return value without a // separate import from polycss-core. @@ -53,6 +55,10 @@ export function createPolyOctahedron(options: OctahedronPolygonsOptions): ParseR return shapeResult(octahedronPolygons(options)); } +export function createPolySphere(options: SpherePolygonsOptions = {}): ParseResult { + return shapeResult(spherePolygons(options)); +} + export function createPolyTetrahedron(options: TetrahedronPolygonsOptions = {}): ParseResult { return shapeResult(tetrahedronPolygons(options)); } diff --git a/packages/polycss/src/index.ts b/packages/polycss/src/index.ts index 193d577b..9b7ce9b3 100644 --- a/packages/polycss/src/index.ts +++ b/packages/polycss/src/index.ts @@ -97,6 +97,7 @@ export { createPolyPlane, createPolyRing, createPolyOctahedron, + createPolySphere, createPolyTetrahedron, createPolyIcosahedron, createPolyDodecahedron, From e226f9ec881d688df2b033f2cef5f2973e6369d0 Mon Sep 17 00:00:00 2001 From: Juan Cruz Fortunatti Date: Wed, 20 May 2026 03:09:41 +0200 Subject: [PATCH 50/55] feat(polycss): register custom element --- .../src/elements/PolyShapeElements.test.ts | 12 ++++++++++++ .../polycss/src/elements/PolyShapeElements.ts | 15 +++++++++++++++ packages/polycss/src/elements/index.ts | 5 +++++ packages/polycss/src/index.ts | 1 + 4 files changed, 33 insertions(+) diff --git a/packages/polycss/src/elements/PolyShapeElements.test.ts b/packages/polycss/src/elements/PolyShapeElements.test.ts index 931a853a..ce5a8137 100644 --- a/packages/polycss/src/elements/PolyShapeElements.test.ts +++ b/packages/polycss/src/elements/PolyShapeElements.test.ts @@ -9,6 +9,7 @@ import { PolyPlaneElement, PolyRingElement, PolyOctahedronElement, + PolySphereElement, PolyTetrahedronElement, PolyIcosahedronElement, PolyDodecahedronElement, @@ -36,6 +37,9 @@ beforeAll(() => { if (!customElements.get("poly-tetrahedron")) { customElements.define("poly-tetrahedron", PolyTetrahedronElement); } + if (!customElements.get("poly-sphere")) { + customElements.define("poly-sphere", PolySphereElement); + } if (!customElements.get("poly-icosahedron")) { customElements.define("poly-icosahedron", PolyIcosahedronElement); } @@ -93,6 +97,9 @@ describe("registration", () => { it("registers poly-tetrahedron", () => { expect(customElements.get("poly-tetrahedron")).toBe(PolyTetrahedronElement); }); + it("registers poly-sphere", () => { + expect(customElements.get("poly-sphere")).toBe(PolySphereElement); + }); it("registers poly-icosahedron", () => { expect(customElements.get("poly-icosahedron")).toBe(PolyIcosahedronElement); }); @@ -136,6 +143,11 @@ describe("leaf DOM production", () => { expect(scene.querySelectorAll("i,b,s,u").length).toBeGreaterThan(0); }); + it("poly-sphere produces leaf nodes inside poly-scene", () => { + const scene = mountInScene("poly-sphere", { radius: "50", subdivisions: "1" }); + expect(scene.querySelectorAll("i,b,s,u").length).toBeGreaterThan(0); + }); + it("poly-icosahedron produces leaf nodes inside poly-scene", () => { const scene = mountInScene("poly-icosahedron", { size: "80" }); expect(scene.querySelectorAll("i,b,s,u").length).toBeGreaterThan(0); diff --git a/packages/polycss/src/elements/PolyShapeElements.ts b/packages/polycss/src/elements/PolyShapeElements.ts index 1b33013e..0f9cd953 100644 --- a/packages/polycss/src/elements/PolyShapeElements.ts +++ b/packages/polycss/src/elements/PolyShapeElements.ts @@ -16,6 +16,7 @@ import { planePolygons, ringPolygons, octahedronPolygons, + spherePolygons, tetrahedronPolygons, icosahedronPolygons, dodecahedronPolygons, @@ -234,6 +235,20 @@ export class PolyDodecahedronElement extends PolyShapeElement { } } +export class PolySphereElement extends PolyShapeElement { + static get observedAttributes(): string[] { + return ["radius", "subdivisions", "color", "auto-center", "position", "scale", "rotation"]; + } + + buildPolygons(): Polygon[] { + return spherePolygons({ + radius: numAttr(this, "radius", 50), + subdivisions: numAttr(this, "subdivisions", 1), + color: strAttr(this, "color"), + }); + } +} + // ── Parametric primitives ───────────────────────────────────────────────────── export class PolyCylinderElement extends PolyShapeElement { diff --git a/packages/polycss/src/elements/index.ts b/packages/polycss/src/elements/index.ts index d69fe3fb..e4989ba3 100644 --- a/packages/polycss/src/elements/index.ts +++ b/packages/polycss/src/elements/index.ts @@ -27,6 +27,7 @@ import { PolyPlaneElement, PolyRingElement, PolyOctahedronElement, + PolySphereElement, PolyTetrahedronElement, PolyIcosahedronElement, PolyDodecahedronElement, @@ -93,6 +94,9 @@ if (typeof customElements !== "undefined") { if (!customElements.get("poly-tetrahedron")) { customElements.define("poly-tetrahedron", PolyTetrahedronElement); } + if (!customElements.get("poly-sphere")) { + customElements.define("poly-sphere", PolySphereElement); + } if (!customElements.get("poly-icosahedron")) { customElements.define("poly-icosahedron", PolyIcosahedronElement); } @@ -128,6 +132,7 @@ export { PolyPlaneElement, PolyRingElement, PolyOctahedronElement, + PolySphereElement, PolyTetrahedronElement, PolyIcosahedronElement, PolyDodecahedronElement, diff --git a/packages/polycss/src/index.ts b/packages/polycss/src/index.ts index 9b7ce9b3..d62cf156 100644 --- a/packages/polycss/src/index.ts +++ b/packages/polycss/src/index.ts @@ -113,6 +113,7 @@ export { PolyPlaneElement, PolyRingElement, PolyOctahedronElement, + PolySphereElement, PolyTetrahedronElement, PolyIcosahedronElement, PolyDodecahedronElement, From b96d17d7c1609e5c33fd453ec377175cf1bb88e8 Mon Sep 17 00:00:00 2001 From: Juan Cruz Fortunatti Date: Wed, 20 May 2026 03:10:45 +0200 Subject: [PATCH 51/55] feat(react): add PolySphere component --- packages/react/src/index.ts | 2 ++ packages/react/src/shapes/PolyShapes.test.tsx | 8 ++++++++ packages/react/src/shapes/PolyShapes.tsx | 18 ++++++++++++++++++ packages/react/src/shapes/index.ts | 2 ++ 4 files changed, 30 insertions(+) diff --git a/packages/react/src/index.ts b/packages/react/src/index.ts index 0d8c4106..ef3183b7 100644 --- a/packages/react/src/index.ts +++ b/packages/react/src/index.ts @@ -48,6 +48,7 @@ export { PolyPlane, PolyRing, PolyOctahedron, + PolySphere, PolyTetrahedron, PolyIcosahedron, PolyDodecahedron, @@ -60,6 +61,7 @@ export type { PolyPlaneProps, PolyRingProps, PolyOctahedronProps, + PolySphereProps, PolyTetrahedronProps, PolyIcosahedronProps, PolyDodecahedronProps, diff --git a/packages/react/src/shapes/PolyShapes.test.tsx b/packages/react/src/shapes/PolyShapes.test.tsx index 9c367548..188a105f 100644 --- a/packages/react/src/shapes/PolyShapes.test.tsx +++ b/packages/react/src/shapes/PolyShapes.test.tsx @@ -8,6 +8,7 @@ import { PolyPlane, PolyRing, PolyOctahedron, + PolySphere, PolyTetrahedron, PolyIcosahedron, PolyDodecahedron, @@ -77,6 +78,13 @@ describe("PolyTetrahedron", () => { }); }); +describe("PolySphere", () => { + it("renders leaf DOM inside PolyCamera > PolyScene", () => { + const container = renderShape(); + expect(hasLeaves(container)).toBe(true); + }); +}); + describe("PolyIcosahedron", () => { it("renders leaf DOM inside PolyCamera > PolyScene", () => { const container = renderShape(); diff --git a/packages/react/src/shapes/PolyShapes.tsx b/packages/react/src/shapes/PolyShapes.tsx index 7c9202f0..5457a2d6 100644 --- a/packages/react/src/shapes/PolyShapes.tsx +++ b/packages/react/src/shapes/PolyShapes.tsx @@ -12,6 +12,7 @@ import { planePolygons, ringPolygons, octahedronPolygons, + spherePolygons, tetrahedronPolygons, icosahedronPolygons, dodecahedronPolygons, @@ -22,6 +23,7 @@ import { type PlanePolygonsOptions, type RingPolygonsOptions, type OctahedronPolygonsOptions, + type SpherePolygonsOptions, type TetrahedronPolygonsOptions, type IcosahedronPolygonsOptions, type DodecahedronPolygonsOptions, @@ -157,6 +159,22 @@ export function PolyDodecahedron({ return ; } +export interface PolySphereProps extends ShapeMeshProps, SpherePolygonsOptions {} + +export function PolySphere({ + radius, + subdivisions, + color, + ...meshProps +}: PolySphereProps) { + const polygons = useMemo( + () => spherePolygons({ radius, subdivisions, color }), + // eslint-disable-next-line react-hooks/exhaustive-deps + [radius, subdivisions, color], + ); + return ; +} + // ── Parametric primitives ───────────────────────────────────────────────────── export interface PolyCylinderProps extends ShapeMeshProps, CylinderPolygonsOptions {} diff --git a/packages/react/src/shapes/index.ts b/packages/react/src/shapes/index.ts index dcc99f18..d0c55606 100644 --- a/packages/react/src/shapes/index.ts +++ b/packages/react/src/shapes/index.ts @@ -5,6 +5,7 @@ export { PolyPlane, PolyRing, PolyOctahedron, + PolySphere, PolyTetrahedron, PolyIcosahedron, PolyDodecahedron, @@ -17,6 +18,7 @@ export type { PolyPlaneProps, PolyRingProps, PolyOctahedronProps, + PolySphereProps, PolyTetrahedronProps, PolyIcosahedronProps, PolyDodecahedronProps, From caf2f70e7d48de0dd6822e81e6724efd953f07a9 Mon Sep 17 00:00:00 2001 From: Juan Cruz Fortunatti Date: Wed, 20 May 2026 03:11:36 +0200 Subject: [PATCH 52/55] feat(vue): add PolySphere component --- packages/vue/src/index.ts | 1 + packages/vue/src/shapes/PolyShapes.test.ts | 8 ++++++ packages/vue/src/shapes/PolyShapes.ts | 30 ++++++++++++++++++++++ packages/vue/src/shapes/index.ts | 1 + 4 files changed, 40 insertions(+) diff --git a/packages/vue/src/index.ts b/packages/vue/src/index.ts index abd07b81..a5f45b21 100644 --- a/packages/vue/src/index.ts +++ b/packages/vue/src/index.ts @@ -31,6 +31,7 @@ export { PolyPlane, PolyRing, PolyOctahedron, + PolySphere, PolyTetrahedron, PolyIcosahedron, PolyDodecahedron, diff --git a/packages/vue/src/shapes/PolyShapes.test.ts b/packages/vue/src/shapes/PolyShapes.test.ts index 22ff5756..8a4dfdc6 100644 --- a/packages/vue/src/shapes/PolyShapes.test.ts +++ b/packages/vue/src/shapes/PolyShapes.test.ts @@ -7,6 +7,7 @@ import { PolyPlane, PolyRing, PolyOctahedron, + PolySphere, PolyTetrahedron, PolyIcosahedron, PolyDodecahedron, @@ -80,6 +81,13 @@ describe("PolyTetrahedron (Vue)", () => { }); }); +describe("PolySphere (Vue)", () => { + it("renders leaf DOM inside PolyCamera > PolyScene", () => { + const container = renderShape(h(PolySphere, { radius: 50, subdivisions: 1, color: "#3b82f6" })); + expect(hasLeaves(container)).toBe(true); + }); +}); + describe("PolyIcosahedron (Vue)", () => { it("renders leaf DOM inside PolyCamera > PolyScene", () => { const container = renderShape(h(PolyIcosahedron, { size: 80, color: "#8800ff" })); diff --git a/packages/vue/src/shapes/PolyShapes.ts b/packages/vue/src/shapes/PolyShapes.ts index ae32c101..3bc54bf3 100644 --- a/packages/vue/src/shapes/PolyShapes.ts +++ b/packages/vue/src/shapes/PolyShapes.ts @@ -13,6 +13,7 @@ import { planePolygons, ringPolygons, octahedronPolygons, + spherePolygons, tetrahedronPolygons, icosahedronPolygons, dodecahedronPolygons, @@ -259,6 +260,35 @@ export const PolyDodecahedron = defineComponent({ }, }); +export const PolySphere = defineComponent({ + name: "PolySphere", + props: { + radius: { type: Number, default: undefined }, + subdivisions: { type: Number, default: undefined }, + color: { type: String, default: undefined }, + // Common mesh props + position: { type: Array as unknown as PropType, default: undefined }, + scale: { type: [Number, Array] as unknown as PropType, default: undefined }, + rotation: { type: Array as unknown as PropType, default: undefined }, + autoCenter: { type: Boolean, default: false }, + id: { type: String, default: undefined }, + }, + setup(props) { + const polygons = computed(() => + spherePolygons({ radius: props.radius, subdivisions: props.subdivisions, color: props.color }), + ); + return () => + h(PolyMesh, { + polygons: polygons.value, + position: props.position, + scale: props.scale, + rotation: props.rotation, + autoCenter: props.autoCenter, + id: props.id, + }); + }, +}); + // ── Parametric primitives ───────────────────────────────────────────────────── export const PolyCylinder = defineComponent({ diff --git a/packages/vue/src/shapes/index.ts b/packages/vue/src/shapes/index.ts index 51dcee34..e7cb26d7 100644 --- a/packages/vue/src/shapes/index.ts +++ b/packages/vue/src/shapes/index.ts @@ -5,6 +5,7 @@ export { PolyPlane, PolyRing, PolyOctahedron, + PolySphere, PolyTetrahedron, PolyIcosahedron, PolyDodecahedron, From 0c839942711dbea41b498aef58529fd1f5ee9cce Mon Sep 17 00:00:00 2001 From: Juan Cruz Fortunatti Date: Wed, 20 May 2026 03:11:58 +0200 Subject: [PATCH 53/55] docs: ship PolySphere in API_DESIGN.md --- API_DESIGN.md | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/API_DESIGN.md b/API_DESIGN.md index 0f416868..532a7992 100644 --- a/API_DESIGN.md +++ b/API_DESIGN.md @@ -269,13 +269,14 @@ Sugar over the polygon generators. `` is one-liner ergonom | Component | Default | Polygon count at default | Core generator | |---|---|---|---| | `PolyRing` (disc) | `segments: 32` | 32 triangles | `ringPolygons` ✅ | -| `PolyCylinder` | `radialSegments: 12` | ≈48 (24 side quads + 24 cap triangles) | `cylinderPolygons` ❌ — add | -| `PolyCone` | `radialSegments: 12` | 24 (12 side + 12 cap) | `conePolygons` ❌ — add. Could share `cylinderPolygons` impl with `radiusTop: 0`. | -| `PolyTorus` | `radialSegments: 12, tubularSegments: 16` | 192 quads | `torusPolygons` ❌ — add. Heaviest of the set; document. | +| `PolySphere` | `subdivisions: 1` (cap: 3) | 80 triangles (subdivisions cap at 3 → 1280 triangles; each step quadruples DOM cost) | `spherePolygons` ✅ | +| `PolyCylinder` | `radialSegments: 12` | ≈48 (24 side quads + 24 cap triangles) | `cylinderPolygons` ✅ | +| `PolyCone` | `radialSegments: 12` | 24 (12 side + 12 cap) | `conePolygons` ✅ | +| `PolyTorus` | `radialSegments: 12, tubularSegments: 16` | 192 quads | `torusPolygons` ✅ | ### Deferred -- **`PolySphere`** — three.js's default (`32×16`) is **1024 DOM nodes per sphere**. Even conservative `16×8` is 256. A sphere in polycss is a DOM-cost problem, not a math one. Hold off until either (a) a geodesic-subdivision generator with reasonable poly count, or (b) clear user demand. Workaround: subdivided icosahedron, or `boxPolygons` for "ball-ish." +Currently no deferred primitives. ### Same scene across all four paths — `PolyBox` as the reference From acaf52ca08e14a63f52216fe59b3147820ce5914 Mon Sep 17 00:00:00 2001 From: Juan Cruz Fortunatti Date: Wed, 20 May 2026 03:12:15 +0200 Subject: [PATCH 54/55] feat(gallery): add Sphere to Primitives bucket --- .../GalleryWorkbench/presets/presetList.ts | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/website/src/components/GalleryWorkbench/presets/presetList.ts b/website/src/components/GalleryWorkbench/presets/presetList.ts index 3e7fd7bd..5ae55f6d 100644 --- a/website/src/components/GalleryWorkbench/presets/presetList.ts +++ b/website/src/components/GalleryWorkbench/presets/presetList.ts @@ -13,7 +13,7 @@ import { GLB_PRESET_FILES, POLY_PIZZA_PRESET_FILES, VOX_PRESET_FILES, OBJ_PRESET import { glbPreset, objPreset, voxPreset } from "./presetBuilders"; import { boxPolygons, planePolygons, ringPolygons, - tetrahedronPolygons, octahedronPolygons, icosahedronPolygons, dodecahedronPolygons, + spherePolygons, tetrahedronPolygons, octahedronPolygons, icosahedronPolygons, dodecahedronPolygons, cylinderPolygons, conePolygons, torusPolygons, } from "@layoutit/polycss"; @@ -53,6 +53,17 @@ export const PRESETS: PresetModel[] = [ rotY: 45, generatePolygons: () => ringPolygons({ axis: 2, radius: 50, halfThickness: 10, segments: 32, color: COLOR }), }, + { + id: "primitive-sphere", + label: "Sphere", + category: "Primitives", + kind: "primitive", + galleryBucket: "Primitives", + zoom: 0.05, + rotX: 65, + rotY: 45, + generatePolygons: () => spherePolygons({ radius: 50, subdivisions: 1, color: COLOR }), + }, { id: "primitive-tetrahedron", label: "Tetrahedron", From e687390dd937fbff79a9a29f824d4ff42cd9f825 Mon Sep 17 00:00:00 2001 From: Juan Cruz Fortunatti Date: Wed, 20 May 2026 03:24:48 +0200 Subject: [PATCH 55/55] chore: drop API_DESIGN.md from tracking (working doc, kept local) --- API_DESIGN.md | 701 -------------------------------------------------- 1 file changed, 701 deletions(-) delete mode 100644 API_DESIGN.md diff --git a/API_DESIGN.md b/API_DESIGN.md deleted file mode 100644 index 532a7992..00000000 --- a/API_DESIGN.md +++ /dev/null @@ -1,701 +0,0 @@ -# Polycss API design — unified shape across all four paths - -Single source of truth for what the **same scene** looks like across the four supported usage paths: vanilla JS, custom elements (HTML), React, Vue. If any path diverges from this shape, it's a bug. - -**This describes the target state.** The current state has verified drift — see "Known drift" at the bottom. - ---- - -## Goal - -One mental model. A user who learns the React API can write the Vue, vanilla JS, and HTML versions without re-learning anything except idiom (camelCase vs kebab-case, function calls vs JSX). - -## Tree shape - -``` -PolyCamera -└── PolyScene - ├── Controls (PolyOrbitControls / PolyMapControls / PolyFirstPersonControls / PolyTransformControls) — optional - └── Content (PolyMesh / PolyGround / PolyPolygon / helpers) — one per node -``` - -**Why camera wraps scene:** CSS `perspective` only applies to descendants. The scene's `transform: matrix3d(...)` is read in that perspective space, so scene must be a DOM descendant of camera. This is fixed by the rendering model — three.js can put camera + scene as siblings because it computes perspective in matrix math, not CSS. - -## Camera taxonomy - -Two cameras, one shared orbital state. - -| Name | Projection | When to pick | -|---|---|---| -| `PolyOrthographicCamera` (alias `PolyCamera`) | `perspective: none` | **Default.** Isometric/voxel/diagrammatic scenes, 2.5D, technical drawings. Parallel projection plays nicely with DOM stacking; integer-pixel quads kill subpixel seams. | -| `PolyPerspectiveCamera` | `perspective: px` | Game-like scenes that need depth foreshortening. CSS `perspective` is a sensitive knob — pick a specific value, don't auto-tune. | - -Shared state: `rotX`, `rotY`, `target`, `distance`, `zoom`. Perspective also has `perspective` (px). - -**Why ortho is the default:** polycss's structural advantages (no per-frame JS, DOM-as-render-tree, integer-pixel atlas slicing) are most visible in orthographic scenes. The engine's identity is closer to "voxel/iso renderer that also does perspective" than "general 3D engine that defaults to perspective." Diverges from three.js's `PerspectiveCamera`-as-default convention deliberately — see "Open questions" for the tradeoff and the implied CLAUDE.md update. - -**No FPS camera, cinematic camera, etc.** "FPV" is `PolyPerspectiveCamera` + `PolyFirstPersonControls`. Camera defines projection; controls define behavior. - -## Controls taxonomy - -| Name | Behavior | -|---|---| -| `PolyOrbitControls` | Drag/wheel to orbit/zoom around target. | -| `PolyMapControls` | Like orbit but pan plane is horizontal (Google-Maps-style). | -| `PolyFirstPersonControls` | WASD + mouse-look. | -| `PolyTransformControls` | Gizmos for translate/rotate/scale on a target mesh. | - ---- - -## Minimal mesh — all four paths - -The same scene, every path. **If a path can't express this verbatim, it's a bug.** - -### Vanilla JS - -```js -import { createPolyCamera, createPolyScene, createPolyIcosahedron } from "@layoutit/polycss"; - -const camera = createPolyCamera({ rotX: 65, rotY: 45 }); -const scene = createPolyScene(document.getElementById("app"), { camera }); - -scene.add(createPolyIcosahedron({ size: 100, color: "#ff6644" })); -``` - -### Custom elements (HTML) - -```html - - - - - - - -``` - -### React - -```tsx -import { PolyCamera, PolyScene, PolyIcosahedron } from "@layoutit/polycss-react"; - - - - - - -``` - -### Vue 3 - -```vue - - - -``` - ---- - -## Minimal interactive scene (orbit controls) - -### Vanilla JS - -```js -import { - createPolyCamera, createPolyScene, createPolyOrbitControls, createPolyTorus, -} from "@layoutit/polycss"; - -const camera = createPolyCamera({ rotX: 65, rotY: 45 }); -const scene = createPolyScene(host, { camera }); -createPolyOrbitControls(scene, { drag: true, wheel: true }); - -scene.add(createPolyTorus({ color: "#4ecdc4" })); -``` - -### Custom elements - -```html - - - - - - -``` - -### React - -```tsx - - - - - - -``` - -### Vue - -```vue - - - - - - -``` - ---- - -## Manual polygons (no file) - -A mesh where geometry is defined inline as `Polygon[]` rather than loaded from a URL. The `Polygon` type lives in `@layoutit/polycss-core`: - -```ts -interface Polygon { - vertices: Vec3[]; // N coplanar vertices, CCW from outside - color?: string; - texture?: string; // URL - material?: PolyMaterial; - uvs?: Vec2[]; - data?: Record; -} -``` - -### Vanilla JS - -```js -import { createPolyCamera, createPolyScene } from "@layoutit/polycss"; - -const camera = createPolyCamera({ rotX: 65, rotY: 45 }); -const scene = createPolyScene(host, { camera }); - -scene.add({ - polygons: [ - { vertices: [[0, 0, 0], [100, 0, 0], [50, 100, 0]], color: "#ff6644" }, - ], -}); -``` - -### Custom elements - -`` is a child of ``. `vertices` is a JSON-stringified array. - -```html - - - - - - - -``` - -### React - -`polygons` prop is mutually exclusive with `src`. - -```tsx -const polygons = [ - { vertices: [[0, 0, 0], [100, 0, 0], [50, 100, 0]], color: "#ff6644" }, -]; - - - - - - -``` - -### Vue - -```vue - - - -``` - -### Built-in shape generators - -`@layoutit/polycss-core` (re-exported from every wrapper package) ships polygon factories for common primitives: `boxPolygons`, `arrowPolygons`, `ringPolygons`, `ringQuadPolygons`, `planePolygons`, `octahedronPolygons`, `axesHelperPolygons`. Each returns `Polygon[]` and slots into the same `polygons` field as raw construction: - -```js -import { boxPolygons } from "@layoutit/polycss"; -scene.add({ polygons: boxPolygons({ size: 100, color: "#ff6644" }) }); -``` - ---- - -## Primitive shape components - -Sugar over the polygon generators. `` is one-liner ergonomics for `` — same DOM output, less typing. Discoverable via autocomplete. - -**polycss-specific cost framing:** each segment is a DOM node, not a vertex on a GPU buffer. Cranking `radialSegments` from 12 to 32 *quadruples* paint cost in a way three.js users don't have to think about. Defaults are deliberately lower than three.js's. - -### Fixed-geometry primitives (no segment count) - -| Component | Faces | Core generator | -|---|---|---| -| `PolyBox` | 6 quads (axis-aligned → `` fast path) | `boxPolygons` ✅ | -| `PolyPlane` | 1 quad | `planePolygons` ✅ | -| `PolyTetrahedron` | 4 triangles | `tetrahedronPolygons` ❌ — add | -| `PolyOctahedron` | 8 triangles | `octahedronPolygons` ✅ | -| `PolyIcosahedron` | 20 triangles | `icosahedronPolygons` ❌ — add | -| `PolyDodecahedron` | 12 pentagons (`` on Chromium, `` elsewhere) | `dodecahedronPolygons` ❌ — add | - -### Parametric primitives (segment count controls cost) - -| Component | Default | Polygon count at default | Core generator | -|---|---|---|---| -| `PolyRing` (disc) | `segments: 32` | 32 triangles | `ringPolygons` ✅ | -| `PolySphere` | `subdivisions: 1` (cap: 3) | 80 triangles (subdivisions cap at 3 → 1280 triangles; each step quadruples DOM cost) | `spherePolygons` ✅ | -| `PolyCylinder` | `radialSegments: 12` | ≈48 (24 side quads + 24 cap triangles) | `cylinderPolygons` ✅ | -| `PolyCone` | `radialSegments: 12` | 24 (12 side + 12 cap) | `conePolygons` ✅ | -| `PolyTorus` | `radialSegments: 12, tubularSegments: 16` | 192 quads | `torusPolygons` ✅ | - -### Deferred - -Currently no deferred primitives. - -### Same scene across all four paths — `PolyBox` as the reference - -**Vanilla JS:** - -```js -import { createPolyCamera, createPolyScene, boxPolygons } from "@layoutit/polycss"; - -const camera = createPolyCamera({ rotX: 65, rotY: 45 }); -const scene = createPolyScene(host, { camera }); -scene.add({ polygons: boxPolygons({ size: 100, color: "#ff6644" }) }); -``` - -`PolyBox` factory (if shipped) wraps it: - -```js -import { createPolyCamera, createPolyScene, createPolyBox } from "@layoutit/polycss"; - -const camera = createPolyCamera({ rotX: 65, rotY: 45 }); -const scene = createPolyScene(host, { camera }); -scene.add(createPolyBox({ size: 100, color: "#ff6644" })); -``` - -**Custom elements:** - -```html - - - - - -``` - -**React:** - -```tsx - - - - - -``` - -**Vue:** - -```vue - - - - - -``` - -### Implementation rule - -**Don't add a `PolyX` component without the matching `xPolygons` generator in `@layoutit/polycss-core` first.** Every primitive component must be a thin wrapper around a pure-math core function. No shape-specific logic in the framework layers. - -Concrete order: - -1. **Core:** add `tetrahedronPolygons`, `icosahedronPolygons`, `dodecahedronPolygons`, `cylinderPolygons`, `conePolygons` (or just `cylinderPolygons` with `radiusTop: 0`), `torusPolygons`. -2. **React + Vue:** wire ``, ``, ``, `` (existing helpers) + the new ones. Mirror props and defaults between bindings. -3. **Custom elements:** mirror as ``, …, ``. Same prop set, kebab-case attributes. -4. **Vanilla JS:** add `createPolyBox(opts)` factories. Each returns `{ polygons: Polygon[] }` so it composes with `scene.add(...)` verbatim. - -### Deliberate non-mirror with three.js - -three.js splits a primitive into `` + `` so they're independently swappable. **polycss doesn't replicate that.** A `Polygon` carries its own `color` / `texture` / `material`, so the geometry-vs-material split has no place to land here. Shape components take a flat prop set (size, segments, color, texture, material) and emit a polygon array directly. - ---- - -## First-person scene - -FPV needs perspective foreshortening to feel right, so this example uses the explicit `PolyPerspectiveCamera` rather than the default `PolyCamera` (which is orthographic). - -### Vanilla JS - -```js -import { - createPolyPerspectiveCamera, createPolyScene, createPolyFirstPersonControls, loadMesh, -} from "@layoutit/polycss"; - -const camera = createPolyPerspectiveCamera({ rotX: 0, rotY: 0 }); -const scene = createPolyScene(host, { camera }); -createPolyFirstPersonControls(scene); -scene.add(await loadMesh("/world.glb")); -``` - -### Custom elements - -```html - - - - - - -``` - -### React / Vue - -```tsx - - - - - - -``` - ---- - -## Scene & mesh features - -The features below all exist today in some form. Listed here so the design covers them and any open question about their shape is locked before implementation. - -### Feature placement (where does it live?) - -| Feature | Camera | Scene | Mesh | Controls | -|---|---|---|---|---| -| `rotX` / `rotY` / `zoom` / `distance` / `target` (Vec3) | ✅ | — | — | — | -| `perspective` (px) | ✅ (perspective camera only) | — | — | — | -| `directionalLight` / `ambientLight` | — | ✅ | — | — | -| `textureLighting` (`"baked"` \| `"dynamic"`) | — | ✅ (inherited by meshes) | ✅ (override) | — | -| `textureQuality` (`number` \| `"auto"`) | — | ✅ (default) | ✅ (override) | — | -| `strategies` (`{ disable: [...] }`) | — | ✅ | ✅ | — | -| `autoCenter` (boolean) | — | ✅ (translates the world) | ✅ (recenters polygons into mesh-local space) | — | -| `shadow` (`{ color, opacity, lift }`) | — | ✅ (appearance config) | — | — | -| `castShadow` (boolean) | — | — | ✅ (per-mesh opt-in) | — | -| `meshResolution` (`"lossless"` \| `"lossy"`) | — | — | ⚠️ via `parseOptions` only (see open question) | — | -| `animate` (`{ speed, axis }` \| `false`) — i.e. "auto-rotate" | — | — | — | ✅ (orbit only) | - -### Lights - -**Currently:** lights are typed values passed as **props** on the scene, not components. - -```ts -type PolyDirectionalLight = { direction: Vec3; color?: string; intensity?: number }; -type PolyAmbientLight = { color?: string; intensity?: number }; -``` - -Across paths: - -```js -// Vanilla JS -const scene = createPolyScene(host, { - camera, - directionalLight: { direction: [0.4, -0.7, 0.59], color: "#ffffff", intensity: 1 }, - ambientLight: { color: "#ffffff", intensity: 0.4 }, -}); -``` - -```html - - - … - -``` - -```tsx -// React / Vue - -``` - -> **Open question — Lights as components?** three.js / R3F use `` and `` as scene-tree children, not props. polycss could mirror that as `` and `` siblings of `` inside ``. Pros: matches three.js mental model, makes multi-light scenes natural (today we only have one of each). Cons: lights aren't really "scene objects" in polycss — they're inputs to the rasterizer (baked) or CSS variables (dynamic), not transformable nodes. **Recommendation: defer.** Stick with object-shaped props for now. Reconsider if multi-light is added. - -### Shadows - -Dynamic-lighting feature only — baked mode does not emit shadow leaves. Two pieces: - -- `shadow?: { color?: string; opacity?: number; lift?: number }` on **scene** — appearance config. Defaults: `{ color: "#000000", opacity: 0.25, lift: 0.05 }`. -- `castShadow?: boolean` on **mesh** — per-mesh opt-in. - -Each polygon on a shadow-casting mesh emits a paired `` leaf (the cast-shadow leaf in the tag table, AGENTS.md). - -```tsx -// React - - - -``` - -```html - - - - -``` - -```js -// Vanilla JS -const scene = createPolyScene(host, { - camera, - textureLighting: "dynamic", - shadow: { opacity: 0.4, lift: 0.1 }, -}); -scene.add(createPolyDodecahedron({ size: 100, color: "#a78bfa" }), { castShadow: true }); -``` - -### Texture lighting modes - -`textureLighting: "baked" | "dynamic"` (default `"baked"`). - -- **Baked.** Lambert computed once per polygon on CPU; multiplied into atlas pixels (``) or inline `color` (`` / `` / ``). Moving a light requires re-rasterising. -- **Dynamic.** Scene root carries lights as CSS custom properties (`--plx/y/z`, `--plr/g/b`, etc.). Each leaf embeds its normal + base color inline. Lambert resolves at paint time via `calc()`. Moving a light mutates one var → no JS, no atlas redraw. Required for `castShadow`. - -Scene-level sets the default; mesh-level overrides per-mesh. - -### Texture quality - -`textureQuality?: number | "auto"` (default `"auto"`). - -- `"auto"` — device-appropriate budget: ~4 MB atlas + 64px sprite on mobile, ~16 MB + 128px on desktop. -- numeric `0.1..1` — explicit raster scale, forces 64px sprite. - -Set scene-level for the whole scene; override per mesh when one mesh needs more (or less) detail. - -### Mesh resolution - -Top-level `meshResolution?: "lossless" | "lossy"` prop on `` (Decision #6). - -- `"lossy"` (default) — bounded geometric approximation when it reduces polygon count. -- `"lossless"` — preserve the authored surface; only apply exact merges. - -```js -// Vanilla JS -scene.add(mesh, { meshResolution: "lossless" }); -``` - -```html - - -``` - -```tsx -// React - -``` - -```vue - - -``` - -`parseOptions` stays available for niche parser flags but is no longer the route for `meshResolution`. - -### Auto center - -`autoCenter?: boolean` exists at **two** levels: - -- **Scene level.** Translates the *world* so the bbox of all live meshes sits at origin. Camera orbits the model's visible center without shifting the mesh DOM. -- **Mesh level.** Re-centers a mesh's polygons into mesh-local space. Useful for OBJ/GLB assets whose origin is at a corner / feet / arbitrary point. - -These are independent — both can be `true`. Default: both `false`. - -### Auto rotation - -**There is no `autoRotate` prop.** Auto-rotation is the `animate` option on `PolyOrbitControls`: - -```ts -animate?: { speed: number; axis?: "x" | "y" } | false -``` - -```tsx -// React - - - // explicit off -``` - -```html - - - -``` - -```js -// Vanilla JS -createPolyOrbitControls(scene, { animate: { speed: 0.3 } }); -``` - -### Camera target - -`target?: Vec3` on the camera (`` / ``). The orbital state rotates around this point. Default `[0, 0, 0]`. - -```tsx -// React - -``` - -```html - - -``` - -```js -// Vanilla JS -const camera = createPolyCamera({ rotX: 65, rotY: 45, target: [10, 0, 0] }); -``` - -Combined with scene-level `autoCenter`, the effective orbit pivot is `target + autoCenterOffset`. Users typically set either, not both. - -### Cross-path consistency matrix - -Every feature in one place — confirm at a glance that the four paths agree on shape, field names, and defaults. Per-path syntactic differences (camelCase vs kebab-case, JSX vs HTML) are expected; what must match is the **field name root, the value shape, and the default**. - -| Feature | Lives on | Field root | Value shape | Default | Vanilla JS | Custom elements | React / Vue | -|---|---|---|---|---|---|---|---| -| Camera rotation | Camera | `rotX`, `rotY` | `number` (degrees) | `65`, `45` | `{ rotX: 65, rotY: 45 }` | `rot-x="65" rot-y="45"` | `rotX={65}` / `:rot-x="65"` | -| Camera target | Camera | `target` | `Vec3` (`[x,y,z]`) | `[0,0,0]` | `{ target: [10,0,0] }` | `target="10,0,0"` | `target={[10,0,0]}` | -| Camera zoom | Camera | `zoom` | `number` | `1` | `{ zoom: 1.5 }` | `zoom="1.5"` | `zoom={1.5}` | -| Camera distance | Camera | `distance` | `number` (CSS px) | (none) | `{ distance: 1200 }` | `distance="1200"` | `distance={1200}` | -| Perspective | Perspective camera | `perspective` | `number` (CSS px) | `8000` | `{ perspective: 4000 }` | `perspective="4000"` | `perspective={4000}` | -| Directional light | Scene | `directionalLight` | `{ direction: Vec3; color?: string; intensity?: number }` | (none) | object option | JSON attr `directional-light='{…}'` | object prop | -| Ambient light | Scene | `ambientLight` | `{ color?: string; intensity?: number }` | (none) | object option | JSON attr `ambient-light='{…}'` | object prop | -| Texture lighting | Scene (inheritable on mesh) | `textureLighting` | `"baked" \| "dynamic"` | `"baked"` | `{ textureLighting: "dynamic" }` | `texture-lighting="dynamic"` | `textureLighting="dynamic"` | -| Texture quality | Scene + mesh | `textureQuality` | `number \| "auto"` | `"auto"` | `{ textureQuality: 0.5 }` | `texture-quality="0.5"` | `textureQuality={0.5}` | -| Strategies | Scene + mesh | `strategies` | `{ disable?: ("b"\|"i"\|"u")[] }` | (none) | object option | JSON attr `strategies='{…}'` | object prop | -| Auto center (scene) | Scene | `autoCenter` | `boolean` | `false` | `{ autoCenter: true }` | bare attr `auto-center` | bare prop / `auto-center` | -| Auto center (mesh) | Mesh | `autoCenter` | `boolean` | `false` | `scene.add(m, { autoCenter: true })` | bare attr `auto-center` | bare prop | -| Shadow appearance | Scene | `shadow` | `{ color?: string; opacity?: number; lift?: number }` | `{ color: "#000000", opacity: 0.25, lift: 0.05 }` | object option | JSON attr `shadow='{…}'` | object prop | -| Cast shadow | Mesh | `castShadow` | `boolean` | `false` | `scene.add(m, { castShadow: true })` | bare attr `cast-shadow` | bare prop / `cast-shadow` | -| Mesh resolution | Mesh | `meshResolution` | `"lossless" \| "lossy"` | `"lossy"` | `scene.add(m, { meshResolution: "lossless" })` | `mesh-resolution="lossless"` | `meshResolution="lossless"` | -| Auto-rotate (orbit) | Orbit controls | `animate` | `{ speed: number; axis?: "x"\|"y" } \| false` | (off) | `{ animate: { speed: 0.3 } }` | flat attrs `animate-speed="0.3"` `animate-axis="x"` | object prop | - -**Known divergence — nested object props on custom elements.** HTML attributes can't carry structured objects directly. The doc uses **two conventions** depending on the object's character: - -- **Settings-shaped objects** (lights, shadow, strategies) → **JSON-stringified attribute**: `directional-light='{"direction":[…],"color":"…"}'`. One field, one value, parsed once at connect time. -- **Behavior-shaped objects with a "boolean + tuning"** quality (`animate`) → **flat attributes**: `animate-speed`, `animate-axis`. Reads naturally in HTML where the user toggles + tunes. - -This split is the price of supporting raw HTML and matches how React/Vue would naturally fall out via prop spread — `animate-speed` reads like a typical HTML attr; `directional-light='{…}'` is uglier but doesn't proliferate `directional-light-direction-x` etc. **No further nesting conventions** — if a new feature comes in with a nested object, pick one of these two patterns to match its character. - ---- - -## Per-path naming conventions - -| Concept | Vanilla JS | Custom elements | React | Vue (template) | -|---|---|---|---|---| -| Camera | `createPolyCamera(opts)` | `` | `` | `` | -| Scene | `createPolyScene(host, opts)` | `` | `` | `` | -| Mesh | `scene.add(await loadMesh(url))` | `` | `` | `` | -| Prop casing | camelCase (`rotX`) | kebab-case (`rot-x`) | camelCase (`rotX`) | kebab-case in template (`:rot-x`), camelCase in `