diff --git a/.gitignore b/.gitignore index d5767983..c0f1c49b 100644 --- a/.gitignore +++ b/.gitignore @@ -16,6 +16,7 @@ packages/vue/tsconfig.tsbuildinfo /bench/polycss.js /bench/polycss-elements.js /bench/polycss-react.js +/bench/polycss-render-stats.js /bench/polycss-vue.js /bench/results/ diff --git a/AGENTS.md b/AGENTS.md index 5e8f73a8..6673accd 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -62,26 +62,28 @@ All solid/atlas tags work in both modes. The `.vox` direct-matrix fast path is b ## The "no JS in the render loop" principle -This is the load-bearing constraint behind the whole engine. **JavaScript never runs per-frame to paint polygons.** Once the scene is built and the atlas is rasterised, the browser drives the render entirely through CSS — `matrix3d` transforms, `calc()`-driven custom properties, `background-blend-mode`, `border-shape`, etc. +This is the load-bearing constraint behind the whole engine. **JavaScript should not run per-frame to paint polygons when the motion can be expressed as a scene, mesh, camera, or light update.** Once the scene is built and the atlas is rasterised, the browser drives most rendering through CSS — `matrix3d` transforms, `calc()`-driven custom properties, `background-blend-mode`, `border-shape`, etc. + +The current exception is imported skeletal animation. glTF/GLB skinning changes each polygon independently, so the vanilla stable-DOM animation path samples the active clip in JS, keeps the leaf set mounted, caches baked stable-triangle transform frames, and refreshes baked color on a cadence. That optimized path is the default; do not add a user-facing "baseline vs optimized" toggle or maintain a legacy slow path in product UI. | Where JS runs | Where JS does NOT run | |---|---| | Scene construction (`createPolyScene`, mesh ops, vertex snapping) | Per-frame polygon paint | | OBJ/glTF/GLB import, mesh optimisation, coplanar merging | Per-frame Lambert evaluation (dynamic mode is pure CSS) | | Atlas planning + rasterisation (one-shot to ``, then `toBlob`) | Per-frame atlas redraw (only on baked-mode light changes) | -| Control input handling (`PolyOrbitControls`, `PolyMapControls`, `PolyTransformControls`) | Per-frame transform recomputation of every polygon — only the scene-root or mesh-root transform changes | +| Control input handling (`PolyOrbitControls`, `PolyMapControls`, `PolyTransformControls`) | Per-frame transform recomputation of every polygon for camera/mesh motion — only the scene-root or mesh-root transform changes | | Camera math (matrix4 product → scene-root `transform` CSS var) | Per-polygon JS in any hot path | | Hover/selection raycasting (only on pointer events, not per frame) | Continuous re-rendering "ticks" | -If you find yourself wanting a `requestAnimationFrame` loop to update many DOM nodes, stop. Find the CSS variable that should be carrying the change, and update that single variable on a single ancestor. Cascading + `@property`-registered custom properties do the rest. +If you find yourself wanting a `requestAnimationFrame` loop to update many DOM nodes outside skeletal animation, stop. Find the CSS variable that should be carrying the change, and update that single variable on a single ancestor. Cascading + `@property`-registered custom properties do the rest. ## Naming (three.js parity) - Every public export gets a `Poly` prefix. Exceptions are generic math types: `Vec2`, `Vec3`, `Polygon`, `PolyMaterial` (already prefixed). - **Hooks/composables:** `usePolyCamera`, `usePolyMesh`, `usePolySceneContext`, `usePolySelect`, `usePolySelectionApi`, `usePolyAnimation`. - **Components:** `PolyPerspectiveCamera`, `PolyOrthographicCamera`, `PolyOrbitControls`, `PolyMapControls`, `PolyTransformControls`, `PolySelect`, `PolyAxesHelper`, `PolyDirectionalLightHelper`. -- **Types:** `PolyDirectionalLight`, `PolyAmbientLight`, `PolyTextureLightingMode`, `PolyAnimationMixer`. -- **Functions:** `findPolyMeshHandle`, `injectPolyBaseStyles`, `buildPolyVoxelFaceData`, `buildPolyVoxelSlicePlan`. +- **Types:** `PolyDirectionalLight`, `PolyAmbientLight`, `PolyTextureLightingMode`, `PolyAnimationMixer`, `PolyRenderStats`. +- **Functions:** `findPolyMeshHandle`, `injectPolyBaseStyles`, `collectPolyRenderStats`, `buildPolyVoxelFaceData`, `buildPolyVoxelSlicePlan`. - **Vanilla factories:** `create*` names stay as-is (`createPolyScene`, `createTransformControls`, `createSelect`). - **HTML custom elements:** `poly-` prefix + kebab-case. Existing tags: ``, ``, ``, ``, ``, ``, ``. Any new element follows the same shape (e.g. ``, ``). - **Leaf DOM tags (``, ``, ``, ``):** internal render-strategy tags. Not part of the public API and not user-facing — do not document them as such. diff --git a/README.md b/README.md index 70495771..ad8a69f3 100644 --- a/README.md +++ b/README.md @@ -1,13 +1,15 @@

- polycss + polycss

# polycss -Render textured 3D meshes as inspectable DOM. No WebGL, no scene canvas, no runtime 3D engine: just DOM polygons positioned with CSS `matrix3d(...)`. Style them with CSS, inspect them in DevTools, and make them interactive through framework components, custom elements, or render props. +A CSS polygon mesh engine. A 3D renderer for the DOM. Renders OBJ, glTF, GLB, MagicaVoxel `.vox`, and generated primitives as real HTML elements transformed with CSS `matrix3d(...)`. Supports colors, textures, lighting, shadows, controls, selection, animation, and per-polygon interaction. Works with React, Vue, custom elements, or plain JavaScript. Visit [polycss.com](https://polycss.com) for docs and model examples. +polycss scene + ## Installation ```bash @@ -21,65 +23,119 @@ npm install @layoutit/polycss-vue npm install @layoutit/polycss ``` -## Quick start: React +You can also load polycss directly from a CDN. Here is a minimal custom-element scene: + +```html + + + + + + + + +``` + +## Framework Components + +React and Vue expose the same component model. `` owns the viewpoint, `` owns lighting and atlas options, and `` loads or receives polygon data. ```tsx -import { PolyCamera, PolyScene, PolyOrbitControls, PolyIcosahedron } from "@layoutit/polycss-react"; +import { PolyCamera, PolyScene, PolyOrbitControls, PolyMesh } from "@layoutit/polycss-react"; -export function App() { +export default function App() { return ( - + - + ); } ``` -## Quick start: Vue +The Vue package mirrors the same names and props with Vue casing: ```vue ``` -## Quick start: Vanilla HTML - -```html - - - - - - - - +## API Reference + +### PolyCamera + +- `rotX`, `rotY` control the orbit angle in degrees. +- `zoom` scales the projected scene. +- `target` pans the camera target in world coordinates. +- `distance` adds dolly pull-back. +- `PolyCamera` is the orthographic default. Use `PolyPerspectiveCamera` when you want perspective depth. + +### PolyScene + +- `polygons` renders a static `Polygon[]` directly. +- `directionalLight` and `ambientLight` control scene lighting. +- `textureLighting` chooses `"baked"` or `"dynamic"`. +- `textureQuality` controls atlas raster budget. +- `strategies` can disable selected render strategies for diagnostics. +- `autoCenter` rotates around the rendered mesh bounds instead of world origin. + +### PolyMesh + +- `src` loads `.obj`, `.gltf`, `.glb`, or `.vox` files. +- `mtl` loads companion OBJ materials. +- `polygons` accepts pre-parsed geometry. +- `position`, `scale`, and `rotation` transform the mesh wrapper. +- `autoCenter` shifts the mesh bbox center to local origin. +- `meshResolution` chooses `"lossy"` (default) or `"lossless"` optimization. +- `castShadow` emits CSS-projected shadows in dynamic lighting mode. + +### Controls + +- `` adds drag orbit, shift-drag pan, wheel zoom, and optional auto-rotate. +- `` uses pan-first map-style input. +- `` provides keyboard and pointer-look navigation. +- `` adds translate/rotate gizmos for selected mesh handles. + +### Polygon Data Model + +Each polygon describes one renderable face: + +```ts +const polygons = [ + { + vertices: [[0, 0, 0], [60, 0, 0], [0, 60, 0]], + color: "#f97316", + }, + { + vertices: [[0, 0, 0], [60, 0, 0], [60, 60, 0], [0, 60, 0]], + texture: "/texture.png", + uvs: [[0, 0], [1, 0], [1, 1], [0, 1]], + }, +]; ``` -## Per-polygon interactivity - Render polygons directly when you need per-face DOM events or custom styling: ```tsx - {polygons.map((p, index) => ( + {polygons.map((polygon, index) => ( alert(`clicked polygon ${index}`)} + {...polygon} + onClick={() => console.log("clicked polygon", index)} className="my-polygon" /> ))} @@ -87,20 +143,64 @@ Render polygons directly when you need per-face DOM events or custom styling: ``` +## Loading Mesh Files + +Use `loadMesh()` from `@layoutit/polycss`, `@layoutit/polycss-react`, or `@layoutit/polycss-vue` to parse supported model formats: + +```ts +import { createPolyCamera, createPolyScene, loadMesh } from "@layoutit/polycss"; + +const host = document.getElementById("polycss")!; +const camera = createPolyCamera({ rotX: 65, rotY: 45 }); +const scene = createPolyScene(host, { camera }); + +const mesh = await loadMesh("https://polycss.com/gallery/obj/cottage.obj", { + mtlUrl: "https://polycss.com/gallery/obj/cottage.mtl", +}); + +scene.add(mesh); +``` + +Supported formats: + +- OBJ + MTL, including `map_Kd` textures and UV coordinates. +- glTF / GLB, including embedded images and `TEXCOORD_0`. +- MagicaVoxel `.vox`, with direct voxel fast paths when eligible. +- Generated primitives: box, plane, ring, sphere, torus, cylinder, cone, and Platonic solids. + +## Performance + +polycss renders in the DOM, so performance is mostly determined by how many polygons are mounted and how much texture atlas area they consume. The renderer uses several CSS strategies so simple surfaces stay cheap and textured or irregular surfaces fall back to atlas slices. + +- One visible polygon becomes one leaf DOM element. +- Flat rectangles and stable quads use solid CSS leaves. +- Textured polygons are packed into generated texture atlases. +- Dynamic lighting runs through CSS custom properties instead of per-frame JavaScript. +- Voxel-shaped meshes mount only camera-facing leaves when the mesh is eligible. +- `meshResolution: "lossy"` can merge compatible polygons to reduce DOM node count. + +For diagnostics, all renderer packages export `collectPolyRenderStats(root)`, which returns mounted polygon leaf counts, shadow counts, surface categories, and bucket counts for an already-rendered scene. + ## Packages | Package | Description | |---|---| -| `@layoutit/polycss-core` | Parsers, geometry, lighting, and camera helpers. | -| `@layoutit/polycss-react` | React components (`PolyCamera`, `PolyScene`, `PolyOrbitControls`, `PolyMapControls`, `PolyMesh`, `Poly`). | -| `@layoutit/polycss-vue` | Vue 3 components with the same rendering surface. | -| `@layoutit/polycss` | Vanilla custom elements + imperative `createPolyScene` API. | +| `@layoutit/polycss-core` | Pure math, parsers, lighting, camera helpers, mesh optimization. Zero browser globals. | +| `@layoutit/polycss` | Vanilla custom elements and imperative `createPolyScene` API. | +| `@layoutit/polycss-react` | React components, hooks, controls, and core re-exports. | +| `@layoutit/polycss-vue` | Vue 3 components, composables, controls, and core re-exports. | + +## Made with polycss + +[Layoutit Voxels](https://voxels.layoutit.com) +-> A CSS Voxel editor + +layoutit-voxels -## Supported formats +[Layoutit Terra](https://terra.layoutit.com) +-> A CSS Terrain Generator -- OBJ + MTL, including `map_Kd` textures and UV coordinates -- GLB and self-contained glTF, including embedded images and `TEXCOORD_0` -- MagicaVoxel `.vox`, with face-culling and custom/default palettes +layoutit-terra ## License diff --git a/bench/README.md b/bench/README.md index 34803b38..dfc44e46 100644 --- a/bench/README.md +++ b/bench/README.md @@ -16,6 +16,7 @@ contributors to verify perf claims and catch render regressions. ```sh pnpm bench:serve # static server on :4400 with an index page pnpm bench:perf # build bundles + run all 4 renderers × 5 scenarios +pnpm bench:animated-human # build bundles + run the animated human run bench pnpm bench:lossy # compare lossless / previous lossy / auto lossy counts pnpm bench:visual # screenshot diff against bench/baselines/*.png pnpm bench:visual --record # capture new baselines (after intentional renderer changes) @@ -30,6 +31,9 @@ All scripts also work directly: ```sh node bench/perf-bench.mjs --mesh saucer --label run1 node bench/perf-bench.mjs --mesh chicken --renderer react,vue +node bench/animated-human-bench.mjs --mode baked,dynamic --label human-run +node bench/animated-human-bench.mjs --compare-stable-dom --trace +node bench/animated-human-bench.mjs --mesh poly-pizza/animated-robot.glb --clip run --animation-driver progressive-style-cache node bench/lossy-optimizer-bench.mjs --json bench/results/lossy-optimizer.json node bench/lossy-optimizer-bench.mjs --models ducky,shark,bicycle node bench/perf-visual.mjs --mesh chicken --tolerance 0.005 @@ -137,6 +141,33 @@ tests above what the gallery's OBJs cover. Use `domOrder` for pure post-render DOM-order probes; `polygonOrder` changes the polygon array before render planning and is only for diagnostics. +`animated-human.html` is the focused animated-model page. It loads +`/gallery/glb/poly-pizza/animated-human.glb` by default, chooses the run-like +clip when available, and drives `createPolyAnimationMixer.update(dt)` into +`PolyMeshHandle.setPolygons(..., { merge:false, stableDom:true })`. The +Playwright runner accepts `--mode baked,dynamic`, `--clip `, +`--target-size `, `--compare-stable-dom`, +`--stable-triangle-color-steps `, +`--stable-triangle-color-policy cadence|adaptive`, +`--stable-triangle-color-freeze-frames `, +`--stable-triangle-color-budget `, +`--stable-triangle-color-max-age `, +`--stable-triangle-color-max-step `, +`--animation-driver js|progressive-style-cache|js-style-cache|typed-om-style-cache|css-keyframes`, +`--compare-stable-triangle-debug`, `--require-solid-triangles`, `--trace`, and +the same GPU lane flags as the other browser benches. The color freeze option +keeps exact baked colors but staggers leaf color writes across frames; adaptive +color policy spends a capped write budget on leaves with the largest accumulated +color error first. The max-step option caps the per-write RGB channel delta so +cadence updates drift toward the next baked color instead of jumping directly to +it. `css-keyframes` is a bench-only prototype that samples the clip into +per-leaf CSS animations, removing per-frame JS playback from the measurement +window. The solid-triangle guard fails the run if the page leaves the baked +`` path. Use `--stable-triangle-color-freeze-frames 0` to keep the initial +baked colors and skip color writes during animation. The stable-triangle debug +comparison is diagnostic: it splits normal updates, transform-only writes, and +plan-only updates to attribute animation bottlenecks. + --- ## Files @@ -152,6 +183,7 @@ bench/ with strategy/order/transform diagnostics perf-react.html loads polycss-react.js (JSX entry) perf-vue.html loads polycss-vue.js (Vue entry) + animated-human.html vanilla animated GLB page for the human run sequence entries/ react.tsx React 19 entry: useState-driven per-frame updates vue.ts Vue 3 entry: ref() + render funcs (no SFC compiler) @@ -163,6 +195,10 @@ bench/ a single instance. perf-bench.mjs Playwright runner. Fresh chromium per scenario, ephemeral port, structured JSON output. + animated-human-bench.mjs + GPU-default Playwright runner for the animated + human run sequence. Reports FPS, mixer/update cost, + setPolygons cost, render stats, and optional trace. lossy-optimizer-bench.mjs Polygon-count strategy bench for lossless, previous pair-only lossy, forced grouped lossy, @@ -209,10 +245,10 @@ solid-color count per stage. Use `--models ` for targeted iteration. The default corpus starts with the previous hand-checked models (`Elephant.glb`, `Dog.glb`, `ducky.glb`) and now runs 28 models. `Duck.glb`, -`FishAnimated.glb`, `AnimatedMushnub.glb`, the Khronos animated fox, and +`FishAnimated.glb`, `AnimatedMushnub.glb`, the Quaternius fox, and `Shark.glb` cover known regression/safety cases; `poly-pizza/cactus-a.glb` and `poly-pizza/glass.glb` are small grouped-plane wins; `Electricguitar.glb`, -`Dump truck.glb`, `Policecar.glb`, `Violin.glb`, and `Bicycle.glb` cover +`Dump truck.glb`, `Policecar.glb`, and `Violin.glb` cover mostly-rectangulated and mechanical runtime cases; `AnimatedSnake.glb`, `AnimatedWizard.glb`, `Zebra.glb`, `Bear.glb`, `Horse.glb`, `Cheetah.glb`, `Dinosaur.glb`, `Gorilla.glb`, `Hippo.glb`, `Dragon.glb`, `Lobster.glb`, diff --git a/bench/animated-human-bench.mjs b/bench/animated-human-bench.mjs new file mode 100644 index 00000000..ce939dee --- /dev/null +++ b/bench/animated-human-bench.mjs @@ -0,0 +1,771 @@ +#!/usr/bin/env node +/** + * Playwright benchmark for the animated human run sequence. + * + * The page drives the real vanilla animation path: + * parse GLB -> sample clip -> createPolyAnimationMixer.update(dt) -> + * PolyMeshHandle.setPolygons(..., { merge:false, stableDom:true }). + * + * Usage: + * node bench/animated-human-bench.mjs + * node bench/animated-human-bench.mjs --mode baked,dynamic --label human-run + * node bench/animated-human-bench.mjs --compare-stable-dom --trace + * node bench/animated-human-bench.mjs --profile + * node bench/animated-human-bench.mjs --mesh poly-pizza/animated-human.glb --clip run + */ +import { createServer } from "node:http"; +import { mkdirSync, writeFileSync } from "node:fs"; +import { readFile } from "node:fs/promises"; +import { dirname, extname, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; +import { chromium } from "playwright"; +import { chromiumArgsWithGpuDefault } from "./chromium-defaults.mjs"; + +const __dirname = dirname(fileURLToPath(import.meta.url)); +const repoRoot = resolve(__dirname, ".."); +const benchDir = resolve(repoRoot, "bench"); +const galleryDir = resolve(repoRoot, "website/public/gallery"); + +const argv = process.argv.slice(2); +const flag = (name) => argv.indexOf(`--${name}`); +const optStr = (name, dflt = "") => { + const i = flag(name); + if (i >= 0) return argv[i + 1] ?? dflt; + const prefixed = argv.find((arg) => arg.startsWith(`--${name}=`)); + return prefixed ? prefixed.slice(name.length + 3) : dflt; +}; +const optNum = (name, dflt) => { + const raw = optStr(name); + if (raw.trim() === "") return dflt; + const value = Number(raw); + return Number.isFinite(value) ? value : dflt; +}; +const optAll = (name) => { + const values = []; + for (let i = 0; i < argv.length; i += 1) { + const arg = argv[i]; + if (arg === `--${name}` && argv[i + 1]) { + values.push(argv[i + 1]); + i += 1; + } else if (arg.startsWith(`--${name}=`)) { + values.push(arg.slice(name.length + 3)); + } + } + return values; +}; +const hasFlag = (name) => flag(name) >= 0 || argv.includes(`--${name}=true`); +const hasOpt = (name) => + flag(name) >= 0 || argv.some((arg) => arg.startsWith(`--${name}=`)); + +const MESH = optStr("mesh", "poly-pizza/animated-human.glb"); +const CLIP = optStr("clip", "run"); +const TARGET_SIZE = optNum("target-size", optNum("targetSize", 72)); +const TIME_SCALE = optNum("time-scale", optNum("timeScale", 1)); +const WARMUP_MS = optNum("warmup", 2000); +const SAMPLE_MS = optNum("sample", 5000); +const RUNS = Math.max(1, optNum("runs", 1)); +const LABEL = optStr("label"); +const JSON_PATH = optStr("json"); +const HEADED = hasFlag("headed"); +const TRACE = hasFlag("trace"); +const PROFILE = hasFlag("profile"); +const COMPARE_STABLE_TRIANGLE_DEBUG = hasFlag("compare-stable-triangle-debug"); +const COMPARE_ANIMATION_DRIVER = hasFlag("compare-animation-driver"); +const COMPARE_ANIMATION_FRAME_CACHE = hasFlag("compare-animation-frame-cache"); +const COMPARE_ANIMATED_MESH_OPTIMIZATION = hasFlag("compare-animated-mesh-optimization"); +const REQUIRE_SOLID_TRIANGLES = hasFlag("require-solid-triangles"); +const BROWSER_EXECUTABLE = optStr("browser-executable"); +const SOFTWARE_BACKEND = hasFlag("software-backend"); +const SOLID_TEXTURE_SAMPLES = !hasFlag("no-solid-texture-samples"); +const DISABLE_STRATEGIES = optStr("disable-strategies", optStr("disableStrategies")); +const STABLE_TRIANGLE_DEBUG = optStr("stable-triangle-debug", optStr("stableTriangleDebug")); +const ANIMATION_DRIVER = optStr("animation-driver", optStr("animationDriver", "js")); +const ANIMATION_FRAME_CACHE = hasFlag("animation-frame-cache") || hasFlag("animationFrameCache"); +const ANIMATION_FRAME_CACHE_FRAMES = optNum( + "animation-frame-cache-frames", + optNum("animationFrameCacheFrames", 60), +); +const KEYFRAME_SAMPLES = optNum("keyframe-samples", optNum("keyframeSamples", 24)); +const DEFAULT_STABLE_TRIANGLE_COLOR_STEPS = 0; +const DEFAULT_STABLE_TRIANGLE_COLOR_POLICY = "cadence"; +const DEFAULT_STABLE_TRIANGLE_COLOR_FREEZE_FRAMES = 12; +const DEFAULT_STABLE_TRIANGLE_COLOR_BUDGET = 0.16; +const DEFAULT_STABLE_TRIANGLE_COLOR_MAX_AGE = 8; +const DEFAULT_STABLE_TRIANGLE_COLOR_MAX_STEP = 8; +const HAS_STABLE_TRIANGLE_COLOR_STEPS = + hasOpt("stable-triangle-color-steps") || hasOpt("stableTriangleColorSteps"); +const STABLE_TRIANGLE_COLOR_STEPS = optNum( + "stable-triangle-color-steps", + optNum("stableTriangleColorSteps", 0), +); +const HAS_STABLE_TRIANGLE_COLOR_POLICY = + hasOpt("stable-triangle-color-policy") || hasOpt("stableTriangleColorPolicy"); +const STABLE_TRIANGLE_COLOR_POLICY = optStr( + "stable-triangle-color-policy", + optStr("stableTriangleColorPolicy", DEFAULT_STABLE_TRIANGLE_COLOR_POLICY), +); +const HAS_STABLE_TRIANGLE_COLOR_FREEZE_FRAMES = + hasOpt("stable-triangle-color-freeze-frames") || hasOpt("stableTriangleColorFreezeFrames"); +const STABLE_TRIANGLE_COLOR_FREEZE_FRAMES = optNum( + "stable-triangle-color-freeze-frames", + optNum("stableTriangleColorFreezeFrames", 0), +); +const HAS_STABLE_TRIANGLE_COLOR_BUDGET = + hasOpt("stable-triangle-color-budget") || hasOpt("stableTriangleColorBudget"); +const STABLE_TRIANGLE_COLOR_BUDGET = optNum( + "stable-triangle-color-budget", + optNum("stableTriangleColorBudget", 0), +); +const HAS_STABLE_TRIANGLE_COLOR_MAX_AGE = + hasOpt("stable-triangle-color-max-age") || hasOpt("stableTriangleColorMaxAge"); +const STABLE_TRIANGLE_COLOR_MAX_AGE = optNum( + "stable-triangle-color-max-age", + optNum("stableTriangleColorMaxAge", 0), +); +const HAS_STABLE_TRIANGLE_COLOR_MAX_STEP = + hasOpt("stable-triangle-color-max-step") || hasOpt("stableTriangleColorMaxStep"); +const STABLE_TRIANGLE_COLOR_MAX_STEP = optNum( + "stable-triangle-color-max-step", + optNum("stableTriangleColorMaxStep", 0), +); +const HAS_STABLE_TRIANGLE_MATRIX_DECIMALS = + hasOpt("stable-triangle-matrix-decimals") || hasOpt("stableTriangleMatrixDecimals"); +const STABLE_TRIANGLE_MATRIX_DECIMALS = optNum( + "stable-triangle-matrix-decimals", + optNum("stableTriangleMatrixDecimals", 3), +); +const STABLE_TRIANGLE_COLOR_STEPS_LABEL = HAS_STABLE_TRIANGLE_COLOR_STEPS + ? String(STABLE_TRIANGLE_COLOR_STEPS) + : `auto(${DEFAULT_STABLE_TRIANGLE_COLOR_STEPS})`; +const STABLE_TRIANGLE_COLOR_POLICY_LABEL = HAS_STABLE_TRIANGLE_COLOR_POLICY + ? STABLE_TRIANGLE_COLOR_POLICY + : `auto(${DEFAULT_STABLE_TRIANGLE_COLOR_POLICY})`; +const STABLE_TRIANGLE_COLOR_FREEZE_FRAMES_LABEL = HAS_STABLE_TRIANGLE_COLOR_FREEZE_FRAMES + ? String(STABLE_TRIANGLE_COLOR_FREEZE_FRAMES) + : `auto(${DEFAULT_STABLE_TRIANGLE_COLOR_FREEZE_FRAMES})`; +const STABLE_TRIANGLE_COLOR_BUDGET_LABEL = HAS_STABLE_TRIANGLE_COLOR_BUDGET + ? String(STABLE_TRIANGLE_COLOR_BUDGET) + : `auto(${DEFAULT_STABLE_TRIANGLE_COLOR_BUDGET} adaptive)`; +const STABLE_TRIANGLE_COLOR_MAX_AGE_LABEL = HAS_STABLE_TRIANGLE_COLOR_MAX_AGE + ? String(STABLE_TRIANGLE_COLOR_MAX_AGE) + : `auto(${DEFAULT_STABLE_TRIANGLE_COLOR_MAX_AGE} adaptive)`; +const STABLE_TRIANGLE_COLOR_MAX_STEP_LABEL = HAS_STABLE_TRIANGLE_COLOR_MAX_STEP + ? String(STABLE_TRIANGLE_COLOR_MAX_STEP) + : `auto(${DEFAULT_STABLE_TRIANGLE_COLOR_MAX_STEP})`; +const STABLE_TRIANGLE_MATRIX_DECIMALS_LABEL = HAS_STABLE_TRIANGLE_MATRIX_DECIMALS + ? String(STABLE_TRIANGLE_MATRIX_DECIMALS) + : "auto(3)"; +const CHROMIUM_ARGS = chromiumArgsWithGpuDefault([ + ...optAll("chromium-arg"), + ...optAll("chromium-args").flatMap((value) => value.split(/\s+/).filter(Boolean)), +], { softwareBackend: SOFTWARE_BACKEND }); + +const MODES = optStr("mode", "baked") + .split(",") + .map((value) => value.trim()) + .filter(Boolean) + .map((value) => value === "dynamic" ? "dynamic" : "baked"); +const STABLE_DOM_VARIANTS = hasFlag("compare-stable-dom") + ? [true, false] + : [!hasFlag("no-stable-dom")]; +const STABLE_TRIANGLE_DEBUG_VARIANTS = COMPARE_STABLE_TRIANGLE_DEBUG + ? ["", "transform-only", "plan-only"] + : [STABLE_TRIANGLE_DEBUG].filter((value) => + value === "" || value === "transform-only" || value === "plan-only" + ); +const VALID_ANIMATION_DRIVERS = [ + "js", + "css-keyframes", + "js-style-cache", + "typed-om-style-cache", + "progressive-style-cache", +]; +const ANIMATION_DRIVER_VARIANTS = COMPARE_ANIMATION_DRIVER + ? ["js", "progressive-style-cache", "js-style-cache", "css-keyframes"] + : [VALID_ANIMATION_DRIVERS.includes(ANIMATION_DRIVER) + ? ANIMATION_DRIVER + : "js"]; +const ANIMATION_FRAME_CACHE_VARIANTS = COMPARE_ANIMATION_FRAME_CACHE + ? [false, true] + : [ANIMATION_FRAME_CACHE]; +const ANIMATED_MESH_OPTIMIZATION_VARIANTS = COMPARE_ANIMATED_MESH_OPTIMIZATION + ? [false, true] + : [hasFlag("animated-mesh-optimization") || hasFlag("animatedMeshOptimization")]; + +const MIME = { + ".html": "text/html; charset=utf-8", + ".js": "application/javascript; charset=utf-8", + ".mjs": "application/javascript; charset=utf-8", + ".css": "text/css; charset=utf-8", + ".json": "application/json; charset=utf-8", + ".glb": "model/gltf-binary", + ".gltf": "application/json; charset=utf-8", + ".png": "image/png", + ".jpg": "image/jpeg", + ".jpeg": "image/jpeg", + ".webp": "image/webp", +}; + +const TRACE_CATEGORIES = [ + "devtools.timeline", + "disabled-by-default-devtools.timeline", + "blink", + "blink.user_timing", + "cc", + "gpu", + "viz", + "renderer.scheduler", +].join(","); + +function startServer() { + return new Promise((resolveStart, rejectStart) => { + const server = createServer(async (req, res) => { + try { + const u = new URL(req.url, "http://localhost"); + const safe = u.pathname.replace(/\/+/g, "/"); + if (safe.includes("..")) { + res.writeHead(403); + res.end(); + return; + } + const abs = safe.startsWith("/gallery/") + ? resolve(galleryDir, safe.slice("/gallery/".length)) + : resolve(benchDir, safe === "/" ? "animated-human.html" : safe.slice(1)); + const data = await readFile(abs); + res.writeHead(200, { + "Content-Type": MIME[extname(abs).toLowerCase()] || "application/octet-stream", + "Cache-Control": "no-store", + }); + res.end(data); + } catch (error) { + res.writeHead(404); + res.end(String(error?.message ?? error)); + } + }); + server.on("error", rejectStart); + server.listen(0, "127.0.0.1", () => { + const addr = server.address(); + resolveStart({ server, port: typeof addr === "object" ? addr.port : 0 }); + }); + }); +} + +function stopServer(server) { + return new Promise((resolveStop) => server.close(resolveStop)); +} + +function quantile(values, q) { + const sorted = values.filter(Number.isFinite).sort((a, b) => a - b); + if (sorted.length === 0) return 0; + const i = (sorted.length - 1) * q; + const lo = Math.floor(i); + const hi = Math.ceil(i); + if (lo === hi) return sorted[lo]; + return sorted[lo] + (sorted[hi] - sorted[lo]) * (i - lo); +} + +function summarizeFrameTimes(samples) { + const dtsRaw = samples.map((sample) => sample.dt).filter((dt) => dt > 0); + const dts = dtsRaw.filter((dt) => dt < 2000); + const p50 = quantile(dts, 0.5); + const p95 = quantile(dts, 0.95); + const p99 = quantile(dts, 0.99); + return { + fps_p50: p50 > 0 ? +(1000 / p50).toFixed(2) : 0, + fps_p95: p95 > 0 ? +(1000 / p95).toFixed(2) : 0, + frame_time_p50_ms: +p50.toFixed(3), + frame_time_p95_ms: +p95.toFixed(3), + frame_time_p99_ms: +p99.toFixed(3), + sample_count: dts.length, + sample_count_raw: dtsRaw.length, + sample_count_filtered: dtsRaw.length - dts.length, + }; +} + +function summarizeDurations(samples, key) { + const values = samples + .map((sample) => Number(sample?.[key])) + .filter((value) => Number.isFinite(value) && value >= 0); + if (values.length === 0) { + return { p50_ms: 0, p95_ms: 0, p99_ms: 0, avg_ms: 0, max_ms: 0 }; + } + const sum = values.reduce((acc, value) => acc + value, 0); + return { + p50_ms: +quantile(values, 0.5).toFixed(3), + p95_ms: +quantile(values, 0.95).toFixed(3), + p99_ms: +quantile(values, 0.99).toFixed(3), + avg_ms: +(sum / values.length).toFixed(3), + max_ms: +Math.max(...values).toFixed(3), + }; +} + +function metricMap(metrics) { + const out = new Map(); + for (const metric of metrics?.metrics ?? []) out.set(metric.name, metric.value); + return out; +} + +function diffPerformanceMetrics(before, after) { + const a = metricMap(before); + const b = metricMap(after); + const keys = [ + "Timestamp", + "Documents", + "Frames", + "JSEventListeners", + "Nodes", + "LayoutCount", + "RecalcStyleCount", + "LayoutDuration", + "RecalcStyleDuration", + "ScriptDuration", + "TaskDuration", + "JSHeapUsedSize", + "JSHeapTotalSize", + ]; + const out = {}; + for (const key of keys) { + const beforeValue = a.get(key); + const afterValue = b.get(key); + if (beforeValue === undefined || afterValue === undefined) continue; + const value = key === "Timestamp" || key === "Documents" || key === "Frames" || + key === "JSEventListeners" || key === "Nodes" || + key === "JSHeapUsedSize" || key === "JSHeapTotalSize" + ? afterValue + : afterValue - beforeValue; + out[key] = Number(value.toFixed(key.endsWith("Duration") ? 6 : 3)); + } + return out; +} + +function summarizeTraceEvents(events) { + const byName = new Map(); + let completeEventCount = 0; + let totalDurationUs = 0; + for (const event of events) { + if (event?.ph !== "X" || typeof event.dur !== "number") continue; + completeEventCount += 1; + totalDurationUs += event.dur; + const prev = byName.get(event.name) ?? { count: 0, durationUs: 0 }; + prev.count += 1; + prev.durationUs += event.dur; + byName.set(event.name, prev); + } + + const get = (...names) => { + let count = 0; + let durationUs = 0; + for (const name of names) { + const entry = byName.get(name); + if (!entry) continue; + count += entry.count; + durationUs += entry.durationUs; + } + return { count, duration_ms: +(durationUs / 1000).toFixed(3) }; + }; + + return { + eventCount: events.length, + completeEventCount, + totalCompleteDurationMs: +(totalDurationUs / 1000).toFixed(3), + groups: { + style: get("UpdateLayoutTree", "RecalculateStyles"), + layout: get("Layout"), + prePaint: get("PrePaint"), + paint: get("Paint"), + raster: get("RasterTask", "ImageDecodeTask", "Decode Image"), + script: get("FunctionCall", "EvaluateScript", "EventDispatch", "TimerFire", "FireAnimationFrame"), + compositorMain: get( + "ProxyMain::BeginMainFrame", + "WebFrameWidgetImpl::UpdateLifecycle", + "PaintArtifactCompositor::Update", + "Layerize", + "Commit", + "ProxyImpl::ReadyToCommit", + ), + compositorImpl: get( + "LayerTreeImpl::UpdateDrawProperties", + "LayerTreeHostImpl::PrepareToDraw", + "MainFrame.Draw", + "SubmitCompositorFrame", + ), + }, + topEvents: [...byName.entries()] + .sort((a, b) => b[1].durationUs - a[1].durationUs) + .slice(0, 16) + .map(([name, entry]) => ({ + name, + count: entry.count, + duration_ms: +(entry.durationUs / 1000).toFixed(3), + })), + }; +} + +async function startTrace(cdp) { + const events = []; + cdp.on("Tracing.dataCollected", (payload) => { + if (Array.isArray(payload.value)) events.push(...payload.value); + }); + await cdp.send("Performance.enable"); + await cdp.send("Tracing.start", { + transferMode: "ReportEvents", + categories: TRACE_CATEGORIES, + }); + return events; +} + +async function stopTrace(cdp, events) { + const done = new Promise((resolveDone) => { + cdp.once("Tracing.tracingComplete", resolveDone); + }); + await cdp.send("Tracing.end"); + await done; + return summarizeTraceEvents(events); +} + +function cpuFrameLabel(callFrame) { + const fn = callFrame?.functionName || "(anonymous)"; + const url = callFrame?.url ? callFrame.url.split("/").pop() : ""; + const line = Number.isFinite(callFrame?.lineNumber) ? callFrame.lineNumber + 1 : 0; + return url ? `${fn} (${url}:${line})` : fn; +} + +function summarizeCpuProfile(profile) { + const nodeById = new Map(); + for (const node of profile?.nodes ?? []) nodeById.set(node.id, node); + const samples = profile?.samples ?? []; + const deltas = profile?.timeDeltas ?? []; + const fallbackDeltaUs = samples.length > 0 && Number.isFinite(profile?.endTime) && Number.isFinite(profile?.startTime) + ? ((profile.endTime - profile.startTime) * 1000) / samples.length + : 0; + + let totalUs = 0; + const byFrame = new Map(); + for (let i = 0; i < samples.length; i += 1) { + const node = nodeById.get(samples[i]); + if (!node) continue; + const deltaUs = Number.isFinite(deltas[i]) ? deltas[i] : fallbackDeltaUs; + totalUs += deltaUs; + const label = cpuFrameLabel(node.callFrame); + const entry = byFrame.get(label) ?? { + frame: label, + functionName: node.callFrame?.functionName || "(anonymous)", + url: node.callFrame?.url || "", + line: Number.isFinite(node.callFrame?.lineNumber) ? node.callFrame.lineNumber + 1 : null, + column: Number.isFinite(node.callFrame?.columnNumber) ? node.callFrame.columnNumber + 1 : null, + self_ms: 0, + samples: 0, + }; + entry.self_ms += deltaUs / 1000; + entry.samples += 1; + byFrame.set(label, entry); + } + + const topSelf = [...byFrame.values()] + .sort((a, b) => b.self_ms - a.self_ms) + .slice(0, 24) + .map((entry) => ({ + ...entry, + self_ms: +entry.self_ms.toFixed(3), + self_pct: totalUs > 0 ? +((entry.self_ms * 1000 / totalUs) * 100).toFixed(2) : 0, + })); + + return { + samples: samples.length, + total_ms: +(totalUs / 1000).toFixed(3), + topSelf, + }; +} + +async function startCpuProfile(cdp) { + await cdp.send("Profiler.enable"); + await cdp.send("Profiler.setSamplingInterval", { interval: 100 }); + await cdp.send("Profiler.start"); +} + +async function stopCpuProfile(cdp) { + const { profile } = await cdp.send("Profiler.stop"); + return summarizeCpuProfile(profile); +} + +function scenarioKey({ + mode, + stableDom, + stableTriangleDebug, + animationDriver, + animationFrameCache, + animatedMeshOptimization, + run, +}) { + const stable = stableDom ? "stable" : "remount"; + const debugSuffix = COMPARE_STABLE_TRIANGLE_DEBUG || stableTriangleDebug + ? `.${stableTriangleDebug || "normal"}` + : ""; + const driverSuffix = COMPARE_ANIMATION_DRIVER || animationDriver !== "js" + ? `.${animationDriver}` + : ""; + const frameCacheSuffix = animationFrameCache ? ".framecache" : ""; + const meshOptSuffix = animatedMeshOptimization ? ".meshopt" : ""; + return RUNS > 1 + ? `${mode}.${stable}${debugSuffix}${driverSuffix}${frameCacheSuffix}${meshOptSuffix}.r${run + 1}` + : `${mode}.${stable}${debugSuffix}${driverSuffix}${frameCacheSuffix}${meshOptSuffix}`; +} + +function buildUrl(port, scenario) { + const params = new URLSearchParams({ + mesh: MESH, + clip: CLIP, + mode: scenario.mode, + stableDom: scenario.stableDom ? "1" : "0", + animationDriver: scenario.animationDriver, + animationFrameCache: scenario.animationFrameCache ? "1" : "0", + animationFrameCacheFrames: String(ANIMATION_FRAME_CACHE_FRAMES), + keyframeSamples: String(KEYFRAME_SAMPLES), + animatedMeshOptimization: scenario.animatedMeshOptimization ? "1" : "0", + targetSize: String(TARGET_SIZE), + timeScale: String(TIME_SCALE), + solidTextureSamples: SOLID_TEXTURE_SAMPLES ? "1" : "0", + }); + if (DISABLE_STRATEGIES) params.set("disableStrategies", DISABLE_STRATEGIES); + if (scenario.stableTriangleDebug) params.set("stableTriangleDebug", scenario.stableTriangleDebug); + if (HAS_STABLE_TRIANGLE_COLOR_POLICY) { + params.set("stableTriangleColorPolicy", STABLE_TRIANGLE_COLOR_POLICY); + } + if (HAS_STABLE_TRIANGLE_COLOR_STEPS) { + params.set("stableTriangleColorSteps", String(STABLE_TRIANGLE_COLOR_STEPS)); + } + if (HAS_STABLE_TRIANGLE_COLOR_FREEZE_FRAMES) { + params.set("stableTriangleColorFreezeFrames", String(STABLE_TRIANGLE_COLOR_FREEZE_FRAMES)); + } + if ( + HAS_STABLE_TRIANGLE_COLOR_BUDGET || + STABLE_TRIANGLE_COLOR_POLICY === "adaptive" + ) { + params.set( + "stableTriangleColorBudget", + String(STABLE_TRIANGLE_COLOR_BUDGET || DEFAULT_STABLE_TRIANGLE_COLOR_BUDGET), + ); + } + if ( + HAS_STABLE_TRIANGLE_COLOR_MAX_AGE || + STABLE_TRIANGLE_COLOR_POLICY === "adaptive" + ) { + params.set( + "stableTriangleColorMaxAge", + String(STABLE_TRIANGLE_COLOR_MAX_AGE || DEFAULT_STABLE_TRIANGLE_COLOR_MAX_AGE), + ); + } + if (HAS_STABLE_TRIANGLE_COLOR_MAX_STEP) { + params.set("stableTriangleColorMaxStep", String(STABLE_TRIANGLE_COLOR_MAX_STEP)); + } + if (HAS_STABLE_TRIANGLE_MATRIX_DECIMALS) { + params.set("stableTriangleMatrixDecimals", String(STABLE_TRIANGLE_MATRIX_DECIMALS)); + } + return `http://127.0.0.1:${port}/animated-human.html?${params.toString()}`; +} + +async function runScenario(port, scenario) { + const launchOptions = { headless: !HEADED, args: CHROMIUM_ARGS }; + if (BROWSER_EXECUTABLE) launchOptions.executablePath = BROWSER_EXECUTABLE; + const browser = await chromium.launch(launchOptions); + try { + const ctx = await browser.newContext({ viewport: { width: 1280, height: 800 } }); + const page = await ctx.newPage(); + const url = buildUrl(port, scenario); + + await page.goto(url, { waitUntil: "load" }); + await page.waitForFunction(() => window.__perf__?.ready === true, null, { timeout: 45000 }); + await page.waitForTimeout(WARMUP_MS); + + const cdp = TRACE || PROFILE ? await ctx.newCDPSession(page) : null; + let traceEvents = null; + let metricsBefore = null; + if (cdp) { + if (PROFILE) await startCpuProfile(cdp); + if (TRACE) traceEvents = await startTrace(cdp); + metricsBefore = await cdp.send("Performance.getMetrics"); + } + + const startIndexes = await page.evaluate(() => ({ + samples: window.__perf__.samples.length, + animationSamples: window.__perf__.animationSamples.length, + })); + await page.waitForTimeout(SAMPLE_MS); + + const metricsAfter = cdp ? await cdp.send("Performance.getMetrics") : null; + const trace = cdp && TRACE ? await stopTrace(cdp, traceEvents) : null; + const cpuProfile = cdp && PROFILE ? await stopCpuProfile(cdp) : null; + const pageResult = await page.evaluate((from) => ({ + samples: window.__perf__.samples.slice(from.samples), + animationSamples: window.__perf__.animationSamples.slice(from.animationSamples), + polyCount: window.__perf__.polyCount, + renderStats: window.__perf__.renderStats, + animation: window.__perf__.animation, + }), startIndexes); + + await ctx.close(); + + return { + ...summarizeFrameTimes(pageResult.samples), + animation_update: summarizeDurations(pageResult.animationSamples, "updateMs"), + set_polygons: summarizeDurations(pageResult.animationSamples, "setPolygonsMs"), + sample_and_mixer: summarizeDurations(pageResult.animationSamples, "nonSetPolygonsMs"), + animation_sample_count: pageResult.animationSamples.length, + polyCount: pageResult.polyCount, + renderStats: pageResult.renderStats, + animation: pageResult.animation, + trace, + cpuProfile, + performanceMetrics: metricsBefore && metricsAfter + ? diffPerformanceMetrics(metricsBefore, metricsAfter) + : null, + }; + } finally { + await browser.close(); + } +} + +const scenarios = []; +for (const mode of MODES) { + for (const stableDom of STABLE_DOM_VARIANTS) { + for (const stableTriangleDebug of STABLE_TRIANGLE_DEBUG_VARIANTS) { + for (const animationDriver of ANIMATION_DRIVER_VARIANTS) { + for (const animationFrameCache of ANIMATION_FRAME_CACHE_VARIANTS) { + for (const animatedMeshOptimization of ANIMATED_MESH_OPTIMIZATION_VARIANTS) { + for (let run = 0; run < RUNS; run += 1) { + scenarios.push({ + mode, + stableDom, + stableTriangleDebug, + animationDriver, + animationFrameCache, + animatedMeshOptimization, + run, + }); + } + } + } + } + } + } +} + +console.log(`[animated-human] mesh=${MESH} clip=${CLIP} targetSize=${TARGET_SIZE} warmup=${WARMUP_MS}ms sample=${SAMPLE_MS}ms animatedMeshOptimization=${ANIMATED_MESH_OPTIMIZATION_VARIANTS.join(",")} animationDriver=${ANIMATION_DRIVER_VARIANTS.join(",")} animationFrameCache=${ANIMATION_FRAME_CACHE_VARIANTS.join(",")} animationFrameCacheFrames=${ANIMATION_FRAME_CACHE_FRAMES} keyframeSamples=${KEYFRAME_SAMPLES} stableTriangleDebug=${STABLE_TRIANGLE_DEBUG_VARIANTS.map((value) => value || "normal").join(",")} colorPolicy=${STABLE_TRIANGLE_COLOR_POLICY_LABEL} colorSteps=${STABLE_TRIANGLE_COLOR_STEPS_LABEL} colorFreezeFrames=${STABLE_TRIANGLE_COLOR_FREEZE_FRAMES_LABEL} colorBudget=${STABLE_TRIANGLE_COLOR_BUDGET_LABEL} colorMaxAge=${STABLE_TRIANGLE_COLOR_MAX_AGE_LABEL} colorMaxStep=${STABLE_TRIANGLE_COLOR_MAX_STEP_LABEL} matrixDecimals=${STABLE_TRIANGLE_MATRIX_DECIMALS_LABEL}`); +if (BROWSER_EXECUTABLE) console.log(`[animated-human] browser=${BROWSER_EXECUTABLE}`); +if (SOFTWARE_BACKEND) console.log("[animated-human] software backend=on"); +if (CHROMIUM_ARGS.length > 0) console.log(`[animated-human] chromium args=${CHROMIUM_ARGS.join(" ")}`); +if (TRACE) console.log("[animated-human] trace=on"); +if (PROFILE) console.log("[animated-human] profile=on"); +if (REQUIRE_SOLID_TRIANGLES) console.log("[animated-human] require solid triangle path=on"); + +const { server, port } = await startServer(); +console.log(`[animated-human] server :${port}`); + +try { + const results = {}; + for (const scenario of scenarios) { + const key = scenarioKey(scenario); + process.stdout.write(` ${key.padEnd(30)}`); + const result = await runScenario(port, scenario); + const polygonStats = result.renderStats?.polygons; + const tags = result.renderStats?.dom?.tags; + if ( + REQUIRE_SOLID_TRIANGLES && + ( + result.polyCount <= 0 || + polygonStats?.solidTriangles !== result.polyCount || + tags?.u !== result.polyCount + ) + ) { + throw new Error( + `${key} left the baked solid triangle path: ` + + `polygons solid/textured=${polygonStats?.solid ?? "?"}/${polygonStats?.textured ?? "?"}, ` + + `tags b/i/s/u/q=${tags?.b ?? "?"}/${tags?.i ?? "?"}/${tags?.s ?? "?"}/${tags?.u ?? "?"}/${tags?.q ?? "?"}`, + ); + } + results[key] = result; + const tagNote = tags ? ` tags b/i/s/u/q=${tags.b}/${tags.i}/${tags.s}/${tags.u}/${tags.q}` : ""; + const traceNote = result.trace?.groups + ? ` trace script/style/paint/comp=${result.trace.groups.script.duration_ms.toFixed(1)}/${result.trace.groups.style.duration_ms.toFixed(1)}/${result.trace.groups.paint.duration_ms.toFixed(1)}/${(result.trace.groups.compositorMain.duration_ms + result.trace.groups.compositorImpl.duration_ms).toFixed(1)}ms` + : ""; + const profileTop = result.cpuProfile?.topSelf?.[0]; + const profileNote = profileTop + ? ` profile top=${profileTop.functionName || profileTop.frame} ${profileTop.self_ms.toFixed(1)}ms` + : ""; + process.stdout.write( + `p50=${result.fps_p50.toFixed(1).padStart(5)}fps ` + + `p95=${result.fps_p95.toFixed(1).padStart(5)}fps ` + + `update p50=${result.animation_update.p50_ms.toFixed(2)}ms ` + + `setPolys p50=${result.set_polygons.p50_ms.toFixed(2)}ms ` + + `clip=${result.animation?.clip?.name ?? "?"}${tagNote}${traceNote}${profileNote}\n`, + ); + } + + const out = { + mesh: MESH, + clip: CLIP, + targetSize: TARGET_SIZE, + timeScale: TIME_SCALE, + solidTextureSamples: SOLID_TEXTURE_SAMPLES, + compareAnimatedMeshOptimization: COMPARE_ANIMATED_MESH_OPTIMIZATION, + compareAnimationDriver: COMPARE_ANIMATION_DRIVER, + compareAnimationFrameCache: COMPARE_ANIMATION_FRAME_CACHE, + animatedMeshOptimization: ANIMATED_MESH_OPTIMIZATION_VARIANTS.length === 1 + ? ANIMATED_MESH_OPTIMIZATION_VARIANTS[0] + : "compare", + animationDriver: ANIMATION_DRIVER_VARIANTS.length === 1 ? ANIMATION_DRIVER_VARIANTS[0] : "compare", + animationFrameCache: ANIMATION_FRAME_CACHE_VARIANTS.length === 1 + ? ANIMATION_FRAME_CACHE_VARIANTS[0] + : "compare", + animationFrameCacheFrames: ANIMATION_FRAME_CACHE_FRAMES, + keyframeSamples: KEYFRAME_SAMPLES, + stableTriangleDebug: STABLE_TRIANGLE_DEBUG || null, + stableTriangleColorPolicy: HAS_STABLE_TRIANGLE_COLOR_POLICY + ? STABLE_TRIANGLE_COLOR_POLICY + : "auto", + stableTriangleColorSteps: HAS_STABLE_TRIANGLE_COLOR_STEPS + ? (STABLE_TRIANGLE_COLOR_STEPS > 1 ? STABLE_TRIANGLE_COLOR_STEPS : null) + : "auto", + defaultStableTriangleColorSteps: DEFAULT_STABLE_TRIANGLE_COLOR_STEPS, + defaultStableTriangleColorPolicy: DEFAULT_STABLE_TRIANGLE_COLOR_POLICY, + stableTriangleColorFreezeFrames: HAS_STABLE_TRIANGLE_COLOR_FREEZE_FRAMES + ? STABLE_TRIANGLE_COLOR_FREEZE_FRAMES + : "auto", + defaultStableTriangleColorFreezeFrames: DEFAULT_STABLE_TRIANGLE_COLOR_FREEZE_FRAMES, + stableTriangleColorBudget: HAS_STABLE_TRIANGLE_COLOR_BUDGET + ? (STABLE_TRIANGLE_COLOR_BUDGET > 0 ? STABLE_TRIANGLE_COLOR_BUDGET : null) + : "auto", + defaultStableTriangleColorBudget: DEFAULT_STABLE_TRIANGLE_COLOR_BUDGET, + stableTriangleColorMaxAge: HAS_STABLE_TRIANGLE_COLOR_MAX_AGE + ? (STABLE_TRIANGLE_COLOR_MAX_AGE > 0 ? STABLE_TRIANGLE_COLOR_MAX_AGE : null) + : "auto", + defaultStableTriangleColorMaxAge: DEFAULT_STABLE_TRIANGLE_COLOR_MAX_AGE, + stableTriangleColorMaxStep: HAS_STABLE_TRIANGLE_COLOR_MAX_STEP + ? (STABLE_TRIANGLE_COLOR_MAX_STEP > 0 ? STABLE_TRIANGLE_COLOR_MAX_STEP : null) + : "auto", + defaultStableTriangleColorMaxStep: DEFAULT_STABLE_TRIANGLE_COLOR_MAX_STEP, + stableTriangleMatrixDecimals: HAS_STABLE_TRIANGLE_MATRIX_DECIMALS + ? STABLE_TRIANGLE_MATRIX_DECIMALS + : "auto", + compareStableTriangleDebug: COMPARE_STABLE_TRIANGLE_DEBUG, + requireSolidTriangles: REQUIRE_SOLID_TRIANGLES, + disableStrategies: DISABLE_STRATEGIES || null, + browserExecutable: BROWSER_EXECUTABLE || null, + chromiumArgs: CHROMIUM_ARGS, + softwareBackend: SOFTWARE_BACKEND, + warmup_ms: WARMUP_MS, + sample_ms: SAMPLE_MS, + results, + }; + + const outputPath = JSON_PATH || (LABEL ? resolve(repoRoot, "bench/results", `${LABEL}.json`) : ""); + if (outputPath) { + mkdirSync(dirname(outputPath), { recursive: true }); + writeFileSync(outputPath, JSON.stringify(out, null, 2) + "\n"); + console.log(`[animated-human] wrote ${outputPath}`); + } + + console.log(JSON.stringify(out, null, 2)); +} finally { + await stopServer(server); +} diff --git a/bench/animated-human.html b/bench/animated-human.html new file mode 100644 index 00000000..81671a64 --- /dev/null +++ b/bench/animated-human.html @@ -0,0 +1,648 @@ + + + + + polycss bench - animated human + + + +
+ + + + diff --git a/bench/build.mjs b/bench/build.mjs index 93148111..0ada604c 100644 --- a/bench/build.mjs +++ b/bench/build.mjs @@ -6,6 +6,8 @@ * + loadMesh) used by perf-vanilla.html * bench/polycss-elements.js ← side-effect bundle that registers the * custom elements; used by perf-html.html + * bench/polycss-render-stats.js + * ← shared render stats helper used by perf-shared.mjs * bench/polycss-react.js ← React entry (bench/entries/react.tsx) * bundled with React + ReactDOM + @layoutit/polycss-react * bench/polycss-vue.js ← Vue entry (bench/entries/vue.ts) bundled @@ -69,6 +71,11 @@ const targets = [ entry: resolve(repoRoot, "packages/polycss/src/elements/index.ts"), out: resolve(__dirname, "polycss-elements.js"), }, + { + label: "render stats helper", + entry: resolve(__dirname, "entries/renderStats.ts"), + out: resolve(__dirname, "polycss-render-stats.js"), + }, { label: "react entry", entry: resolve(__dirname, "entries/react.tsx"), diff --git a/bench/entries/renderStats.ts b/bench/entries/renderStats.ts new file mode 100644 index 00000000..a0d89a59 --- /dev/null +++ b/bench/entries/renderStats.ts @@ -0,0 +1 @@ +export { collectPolyRenderStats } from "@layoutit/polycss"; diff --git a/bench/lossy-optimizer-bench.mjs b/bench/lossy-optimizer-bench.mjs index 434cd0d5..6997e0f1 100644 --- a/bench/lossy-optimizer-bench.mjs +++ b/bench/lossy-optimizer-bench.mjs @@ -35,7 +35,7 @@ const MODELS = [ { id: "duck", label: "Duck", path: "website/public/gallery/glb/Duck.glb" }, { id: "fish-animated", label: "FishAnimated", path: "website/public/gallery/glb/FishAnimated.glb" }, { id: "mushnub-animated", label: "AnimatedMushnub", path: "website/public/gallery/glb/AnimatedMushnub.glb" }, - { id: "animated-fox", label: "AnimatedFox", path: "website/public/gallery/glb/khronos/animated-fox.glb" }, + { id: "fox", label: "Fox", path: "website/public/gallery/glb/poly-pizza/animated-fox-quaternius.glb" }, { id: "shark", label: "Shark", path: "website/public/gallery/glb/Shark.glb" }, { id: "cactus-a", label: "Cactus A", path: "website/public/gallery/glb/poly-pizza/cactus-a.glb" }, { id: "glass", label: "Glass", path: "website/public/gallery/glb/poly-pizza/glass.glb" }, @@ -56,7 +56,6 @@ const MODELS = [ { id: "bear", label: "Bear", path: "website/public/gallery/glb/Bear.glb" }, { id: "horse", label: "Horse", path: "website/public/gallery/glb/Horse.glb" }, { id: "cheetah", label: "Cheetah", path: "website/public/gallery/glb/Cheetah.glb" }, - { id: "bicycle", label: "Bicycle", path: "website/public/gallery/glb/Bicycle.glb" }, ]; const DEFAULT_LOSSY_APPROXIMATE = { diff --git a/bench/nonvoxel-rotation-bench.mjs b/bench/nonvoxel-rotation-bench.mjs index e2fb895b..cca0b75f 100644 --- a/bench/nonvoxel-rotation-bench.mjs +++ b/bench/nonvoxel-rotation-bench.mjs @@ -118,9 +118,9 @@ const MODEL_CORPUS = [ reason: "Mechanical GLB with many rectilinear parts.", }, { - id: "bicycle", - label: "Bicycle GLB", - mesh: "glb:Bicycle.glb", + id: "violin", + label: "Violin GLB", + mesh: "glb:Violin.glb", params: { zoom: "0.35", targetSize: "60" }, reason: "Thin-structure GLB with many separated parts.", }, diff --git a/bench/nonvoxel-visual-compare.mjs b/bench/nonvoxel-visual-compare.mjs index ee0c2afb..b8cf14b0 100644 --- a/bench/nonvoxel-visual-compare.mjs +++ b/bench/nonvoxel-visual-compare.mjs @@ -69,7 +69,7 @@ const MODELS = [ { id: "saucer", mesh: "saucer" }, { id: "teapot", mesh: "teapot" }, { id: "ducky", mesh: "ducky" }, - { id: "bicycle", mesh: "glb:Bicycle.glb", params: { zoom: "0.35", targetSize: "60" } }, + { id: "violin", mesh: "glb:Violin.glb", params: { zoom: "0.35", targetSize: "60" } }, { id: "elephant", mesh: "glb:Elephant.glb", params: { zoom: "0.35", targetSize: "60" } }, { id: "policecar", mesh: "glb:Policecar.glb", params: { zoom: "0.35", targetSize: "60" } }, ]; diff --git a/bench/perf-shared.mjs b/bench/perf-shared.mjs index 756b68f5..7d579499 100644 --- a/bench/perf-shared.mjs +++ b/bench/perf-shared.mjs @@ -13,6 +13,7 @@ * specific. Each page handles its own mount and its own per-frame state * update; this module just provides the measurement surface. */ +import { collectPolyRenderStats } from "./polycss-render-stats.js"; export const PRESETS = { saucer: { @@ -248,12 +249,18 @@ export function collectPolygonStats(polygons = []) { export function collectRenderStats({ polygons, root } = {}) { const sceneRoot = root ?? document.querySelector(".polycss-scene"); - const tags = { b: 0, i: 0, s: 0, u: 0, q: 0 }; + const render = collectPolyRenderStats(sceneRoot, { + polygonCount: polygons?.length ?? 0, + }); + const tags = { + b: render.surfaceLeafCounts.quad, + i: render.surfaceLeafCounts.clippedSolid, + s: render.surfaceLeafCounts.atlas, + u: render.surfaceLeafCounts.stableTriangle, + q: render.shadowLeafCount, + }; let inlineStyleChars = 0; if (sceneRoot) { - for (const tag of Object.keys(tags)) { - tags[tag] = sceneRoot.querySelectorAll(tag).length; - } for (const el of sceneRoot.querySelectorAll("b,i,s,u,q")) { inlineStyleChars += el.getAttribute("style")?.length ?? 0; } @@ -268,9 +275,9 @@ export function collectRenderStats({ polygons, root } = {}) { polygons: collectPolygonStats(polygons ?? []), dom: { tags, - leafCount: tags.b + tags.i + tags.s + tags.u, - shadowCount: tags.q, - buckets: sceneRoot?.querySelectorAll(".polycss-bucket").length ?? 0, + leafCount: render.mountedPolygonLeafCount, + shadowCount: render.shadowLeafCount, + buckets: render.bucketCount, inlineStyleChars, }, }; diff --git a/package.json b/package.json index 57101180..3474e737 100644 --- a/package.json +++ b/package.json @@ -22,12 +22,14 @@ "build:packages": "pnpm --filter './packages/*' -r build", "test": "pnpm --filter './packages/*' -r --if-present test", "test:coverage": "pnpm --filter './packages/*' -r --if-present test:coverage", - "publish:all": "pnpm --filter './packages/*' -r publish --access public", + "sync:readmes": "node tools/sync-package-readmes.mjs", + "publish:all": "pnpm sync:readmes && pnpm --filter './packages/*' -r publish --access public", "dev:website": "pnpm --filter @layoutit/polycss-website dev", "build:website": "pnpm --filter @layoutit/polycss-website build", "bench:build": "node bench/build.mjs", "bench:serve": "node bench/perf-serve.mjs --port 4400", "bench:perf": "node bench/build.mjs && node bench/perf-bench.mjs", + "bench:animated-human": "node bench/build.mjs && node bench/animated-human-bench.mjs", "bench:lossy": "pnpm --filter @layoutit/polycss-core build && node bench/lossy-optimizer-bench.mjs", "bench:visual": "node bench/build.mjs && node bench/perf-visual.mjs" }, diff --git a/packages/core/README.md b/packages/core/README.md index 39114150..ad8a69f3 100644 --- a/packages/core/README.md +++ b/packages/core/README.md @@ -1,135 +1,207 @@ -> **Status: pre-1.0. APIs may still change before a stable 1.0 release.** +

+ polycss +

-# @layoutit/polycss-core +# polycss -Framework-agnostic math, parsers, and helpers for CSS polygon-mesh rendering. Zero browser globals: runs in Node, workers, or any JS environment. +A CSS polygon mesh engine. A 3D renderer for the DOM. Renders OBJ, glTF, GLB, MagicaVoxel `.vox`, and generated primitives as real HTML elements transformed with CSS `matrix3d(...)`. Supports colors, textures, lighting, shadows, controls, selection, animation, and per-polygon interaction. Works with React, Vue, custom elements, or plain JavaScript. -This package contains the entire non-rendering side of polycss: OBJ / glTF / GLB / MagicaVoxel parsers, polygon normalization, coplanar merge, Lambert lighting, isometric camera state, and all shared TypeScript types. +Visit [polycss.com](https://polycss.com) for docs and model examples. -## When to use directly +polycss scene -Most users install `@layoutit/polycss-react`, `@layoutit/polycss-vue`, or `@layoutit/polycss` (vanilla). Those packages include `@layoutit/polycss-core` as a transitive runtime dependency and re-export its public types and functions, so you never need to write `import ... from "@layoutit/polycss-core"` in application code. +## Installation -Install `@layoutit/polycss-core` directly when you: +```bash +# React +npm install @layoutit/polycss-react -- Build custom rendering outside React / Vue / vanilla (e.g., a Svelte wrapper, a server-side OBJ validator, a CLI mesh processor). -- Want only the parsers / math without any rendering layer. -- Are writing a polycss plugin or tooling that must remain framework-neutral. +# Vue +npm install @layoutit/polycss-vue -```bash -npm install @layoutit/polycss-core +# Vanilla / custom elements +npm install @layoutit/polycss ``` -## Public surface +You can also load polycss directly from a CDN. Here is a minimal custom-element scene: -### Types +```html + -| Type | Description | -|---|---| -| `Vec2` | `[number, number]`: 2D point or UV coordinate | -| `Vec3` | `[number, number, number]`: 3D point or direction | -| `Polygon` | Single renderable polygon: `vertices`, optional `color`, `texture`, `uvs`, `data` | -| `PolyDirectionalLight` | Directional light: `direction`, optional `color`, optional `intensity` | -| `PolyAmbientLight` | Ambient fill light: optional `color`, optional `intensity` | -| `ParseResult` | Unified parser return: `polygons`, `objectUrls`, `dispose()`, `warnings` | -| `ObjParseOptions` | Options for `parseObj` | -| `GltfParseOptions` | Options for `parseGltf` | -| `VoxParseOptions` | Options for `parseVox` | -| `MtlParseResult` | `{ colors, textures }` from `parseMtl` | -| `NormalizeResult` | `{ polygons, warnings }` from `normalizePolygons` | -| `CameraState` | Camera target, angles, zoom, and dolly distance | -| `CameraHandle` | Mutable camera object from `createIsometricCamera` | -| `AutoRotateOption` | `boolean | number | { axis, speed, pauseOnInteraction }` | -| `BoxPolygonsOptions` | Options for `boxPolygons`: size/center or min/max bounds, materials, face overrides | - -### Functions - -| Function | Description | -|---|---| -| `normalizePolygons(input)` | Validates polygons. Drops degenerate ones, auto-triangulates non-coplanar N-gons, strips mismatched UVs. Returns `{ polygons, warnings }`. | -| `mergePolygons(polygons)` | Coplanar same-material adjacent merge. Reduces DOM element count on flat surfaces. | -| `optimizeMeshPolygons(polygons, options?)` | Applies lossless or lossy mesh-resolution optimization and chooses the smallest accepted candidate; defaults to `meshResolution: "lossy"`. | -| `computeSceneBbox(polygons)` | Computes min/max bounds across all polygon vertices. | -| `createIsometricCamera(initial?)` | Creates a mutable camera handle with `state`, `update(partial)`, and `getStyle()`. | -| `boxPolygons(options?)` | Creates six quad `Polygon`s for an axis-aligned box/cuboid. Supports per-face material/data overrides and omitted faces. | -| `parseObj(text, options?)` | Parses OBJ text into `ParseResult`. Supports UV (`vt`), materials, `map_Kd` textures. | -| `parseMtl(text)` | Parses MTL text into `{ colors, textures }`. | -| `parseGltf(buffer, options?)` | Parses GLB or glTF `ArrayBuffer` into `ParseResult`. Extracts embedded textures as blob URLs. | -| `parseVox(buffer, options?)` | Parses MagicaVoxel `.vox` `ArrayBuffer` into `ParseResult`. Face-culls interior voxel faces and emits exposed quads. `targetSize` snaps to integer voxel CSS cells for the fast-path renderer. | -| `loadMesh(url, options?)` | Fetches a URL, dispatches to the right parser by extension (`.obj`, `.glb`, `.gltf`, `.vox`). Returns `Promise` and defaults to `meshResolution: "lossy"`. | -| `parseColor(input)` | Parse any CSS color string to `{ r, g, b, a }`. | -| `shadeColor(input, lambert, ...)` | Apply Lambert shading factor to a color. | -| `computeShapeLighting(normal, baseColor, light?)` | Compute shaded color for a polygon face given a directional light and surface normal. | - -## Examples - -### Parse an OBJ file + + + + + + +``` -```ts -import { parseObj } from "@layoutit/polycss-core"; +## Framework Components -const text = await fetch("/cottage.obj").then(r => r.text()); -const { polygons, warnings, dispose } = parseObj(text, { - targetSize: 40, - defaultColor: "#cccccc", -}); +React and Vue expose the same component model. `` owns the viewpoint, `` owns lighting and atlas options, and `` loads or receives polygon data. -console.log(polygons.length, "polygons"); -warnings.forEach(w => console.warn(w)); +```tsx +import { PolyCamera, PolyScene, PolyOrbitControls, PolyMesh } from "@layoutit/polycss-react"; -// Revoke any blob URLs created during parse (OBJ rarely creates them, -// but always call dispose() for correctness): -dispose(); +export default function App() { + return ( + + + + + + + ); +} ``` -### Normalize a polygon list +The Vue package mirrors the same names and props with Vue casing: + +```vue + + + +``` -```ts -import { normalizePolygons } from "@layoutit/polycss-core"; -import type { Polygon } from "@layoutit/polycss-core"; +## API Reference -const raw: Polygon[] = [ - { vertices: [[0,0,0], [1,0,0], [0,1,0]], color: "#f00" }, - { vertices: [[0,0,0], [0,0,0], [0,0,0]] }, // degenerate: will be dropped - { vertices: [[0,0,0], [1,0,0], [0.5,1,0], [0.5,1,0.1]] }, // non-coplanar quad → triangulated -]; +### PolyCamera -const { polygons, warnings } = normalizePolygons(raw); -console.log(polygons.length); // 2 (degenerate dropped; quad fan-triangulated into 2) -warnings.forEach(w => console.warn(w)); -``` +- `rotX`, `rotY` control the orbit angle in degrees. +- `zoom` scales the projected scene. +- `target` pans the camera target in world coordinates. +- `distance` adds dolly pull-back. +- `PolyCamera` is the orthographic default. Use `PolyPerspectiveCamera` when you want perspective depth. -### Merge coplanar polygons +### PolyScene -```ts -import { parseGltf, mergePolygons } from "@layoutit/polycss-core"; +- `polygons` renders a static `Polygon[]` directly. +- `directionalLight` and `ambientLight` control scene lighting. +- `textureLighting` chooses `"baked"` or `"dynamic"`. +- `textureQuality` controls atlas raster budget. +- `strategies` can disable selected render strategies for diagnostics. +- `autoCenter` rotates around the rendered mesh bounds instead of world origin. -const buf = await fetch("/cottage.glb").then(r => r.arrayBuffer()); -const { polygons, dispose } = parseGltf(buf, { targetSize: 60 }); +### PolyMesh -// Merge coplanar same-material triangles to reduce DOM element count -const merged = mergePolygons(polygons); -console.log(`${polygons.length} triangles → ${merged.length} merged polygons`); +- `src` loads `.obj`, `.gltf`, `.glb`, or `.vox` files. +- `mtl` loads companion OBJ materials. +- `polygons` accepts pre-parsed geometry. +- `position`, `scale`, and `rotation` transform the mesh wrapper. +- `autoCenter` shifts the mesh bbox center to local origin. +- `meshResolution` chooses `"lossy"` (default) or `"lossless"` optimization. +- `castShadow` emits CSS-projected shadows in dynamic lighting mode. -dispose(); // always revoke GLB blob URLs when done -``` +### Controls + +- `` adds drag orbit, shift-drag pan, wheel zoom, and optional auto-rotate. +- `` uses pan-first map-style input. +- `` provides keyboard and pointer-look navigation. +- `` adds translate/rotate gizmos for selected mesh handles. -### Create a box shape +### Polygon Data Model + +Each polygon describes one renderable face: ```ts -import { boxPolygons } from "@layoutit/polycss-core"; - -const polygons = boxPolygons({ - min: [0, 0, 0], - max: [2, 1, 0.5], - color: "#d8d2c7", - data: { tileId: "tile-1" }, - faces: { - top: { - texture: "/tile.png", - data: { face: "top" }, - }, - bottom: false, +const polygons = [ + { + vertices: [[0, 0, 0], [60, 0, 0], [0, 60, 0]], + color: "#f97316", + }, + { + vertices: [[0, 0, 0], [60, 0, 0], [60, 60, 0], [0, 60, 0]], + texture: "/texture.png", + uvs: [[0, 0], [1, 0], [1, 1], [0, 1]], }, +]; +``` + +Render polygons directly when you need per-face DOM events or custom styling: + +```tsx + + + {polygons.map((polygon, index) => ( + console.log("clicked polygon", index)} + className="my-polygon" + /> + ))} + + +``` + +## Loading Mesh Files + +Use `loadMesh()` from `@layoutit/polycss`, `@layoutit/polycss-react`, or `@layoutit/polycss-vue` to parse supported model formats: + +```ts +import { createPolyCamera, createPolyScene, loadMesh } from "@layoutit/polycss"; + +const host = document.getElementById("polycss")!; +const camera = createPolyCamera({ rotX: 65, rotY: 45 }); +const scene = createPolyScene(host, { camera }); + +const mesh = await loadMesh("https://polycss.com/gallery/obj/cottage.obj", { + mtlUrl: "https://polycss.com/gallery/obj/cottage.mtl", }); + +scene.add(mesh); ``` + +Supported formats: + +- OBJ + MTL, including `map_Kd` textures and UV coordinates. +- glTF / GLB, including embedded images and `TEXCOORD_0`. +- MagicaVoxel `.vox`, with direct voxel fast paths when eligible. +- Generated primitives: box, plane, ring, sphere, torus, cylinder, cone, and Platonic solids. + +## Performance + +polycss renders in the DOM, so performance is mostly determined by how many polygons are mounted and how much texture atlas area they consume. The renderer uses several CSS strategies so simple surfaces stay cheap and textured or irregular surfaces fall back to atlas slices. + +- One visible polygon becomes one leaf DOM element. +- Flat rectangles and stable quads use solid CSS leaves. +- Textured polygons are packed into generated texture atlases. +- Dynamic lighting runs through CSS custom properties instead of per-frame JavaScript. +- Voxel-shaped meshes mount only camera-facing leaves when the mesh is eligible. +- `meshResolution: "lossy"` can merge compatible polygons to reduce DOM node count. + +For diagnostics, all renderer packages export `collectPolyRenderStats(root)`, which returns mounted polygon leaf counts, shadow counts, surface categories, and bucket counts for an already-rendered scene. + +## Packages + +| Package | Description | +|---|---| +| `@layoutit/polycss-core` | Pure math, parsers, lighting, camera helpers, mesh optimization. Zero browser globals. | +| `@layoutit/polycss` | Vanilla custom elements and imperative `createPolyScene` API. | +| `@layoutit/polycss-react` | React components, hooks, controls, and core re-exports. | +| `@layoutit/polycss-vue` | Vue 3 components, composables, controls, and core re-exports. | + +## Made with polycss + +[Layoutit Voxels](https://voxels.layoutit.com) +-> A CSS Voxel editor + +layoutit-voxels + +[Layoutit Terra](https://terra.layoutit.com) +-> A CSS Terrain Generator + +layoutit-terra + +## License + +MIT. diff --git a/packages/core/package.json b/packages/core/package.json index c9043913..4b44512b 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -31,6 +31,7 @@ "build": "tsup", "test": "vitest run --passWithNoTests", "test:coverage": "vitest run --coverage --passWithNoTests", + "prepack": "node ../../tools/sync-package-readmes.mjs", "prepublishOnly": "npm run build" }, "publishConfig": { diff --git a/packages/core/src/animation/optimizeAnimatedMeshPolygons.ts b/packages/core/src/animation/optimizeAnimatedMeshPolygons.ts new file mode 100644 index 00000000..7c08694c --- /dev/null +++ b/packages/core/src/animation/optimizeAnimatedMeshPolygons.ts @@ -0,0 +1,95 @@ +import type { MeshResolution, Polygon, Vec3 } from "../types"; +import type { ParseResult } from "../parser/types"; +import { cullInteriorPolygons } from "../cull/cullInteriorPolygons"; + +export interface OptimizeAnimatedMeshPolygonsOptions { + /** Public quality/resolution intent. Defaults to "lossy". */ + meshResolution?: MeshResolution; +} + +interface SourceVertex { + polygonIndex: number; + vertexIndex: number; +} + +interface PlannedPolygon { + rest: Polygon; + sources: SourceVertex[]; +} + +function mappedVertices(frame: Polygon[], sources: SourceVertex[]): Vec3[] | null { + const vertices: Vec3[] = []; + for (const source of sources) { + const vertex = frame[source.polygonIndex]?.vertices[source.vertexIndex]; + if (!vertex || vertex.some((value) => !Number.isFinite(value))) return null; + vertices.push([vertex[0], vertex[1], vertex[2]]); + } + return vertices; +} + +function plannedOriginalPolygon(polygons: Polygon[], index: number): PlannedPolygon | null { + const polygon = polygons[index]; + if (!polygon) return null; + return { + rest: polygon, + sources: polygon.vertices.map((_, vertexIndex) => ({ polygonIndex: index, vertexIndex })), + }; +} + +function buildCulledTrianglePlan(polygons: Polygon[]): PlannedPolygon[] { + const culled = cullInteriorPolygons(polygons); + if (culled.length >= polygons.length) return []; + const sourceIndex = new Map(); + polygons.forEach((polygon, index) => sourceIndex.set(polygon, index)); + const plan: PlannedPolygon[] = []; + for (const polygon of culled) { + const index = sourceIndex.get(polygon); + if (index === undefined) return []; + const original = plannedOriginalPolygon(polygons, index); + if (!original) return []; + plan.push(original); + } + return plan; +} + +function applyPlanToFrame(frame: Polygon[], plan: PlannedPolygon[]): Polygon[] { + const out: Polygon[] = []; + for (const item of plan) { + const vertices = mappedVertices(frame, item.sources); + if (!vertices) return frame; + const color = frame[item.sources[0]?.polygonIndex]?.color ?? item.rest.color; + out.push({ + ...item.rest, + vertices, + color, + }); + } + return out; +} + +export function optimizeAnimatedMeshPolygons( + result: ParseResult, + options: OptimizeAnimatedMeshPolygonsOptions = {}, +): ParseResult { + if (!result.animation || result.polygons.length === 0 || options.meshResolution === "lossless") { + return result; + } + + const culledPlan = buildCulledTrianglePlan(result.polygons); + if (culledPlan.length === 0 || culledPlan.length >= result.polygons.length) return result; + + return { + ...result, + polygons: culledPlan.map((item) => item.rest), + animation: { + ...result.animation, + sample(clip, timeSeconds) { + return applyPlanToFrame(result.animation!.sample(clip, timeSeconds), culledPlan); + }, + }, + metadata: { + ...result.metadata, + triangleCount: culledPlan.length, + }, + }; +} diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 78ac91ef..0dd24774 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -128,6 +128,8 @@ export { LoopRepeat, LoopPingPong, } from "./animation"; +export { optimizeAnimatedMeshPolygons } from "./animation/optimizeAnimatedMeshPolygons"; +export type { OptimizeAnimatedMeshPolygonsOptions } from "./animation/optimizeAnimatedMeshPolygons"; export type { PolyAnimationClip, PolyAnimationAction, diff --git a/packages/core/src/parser/loadMesh.test.ts b/packages/core/src/parser/loadMesh.test.ts index f39e47f3..cf5afbff 100644 --- a/packages/core/src/parser/loadMesh.test.ts +++ b/packages/core/src/parser/loadMesh.test.ts @@ -39,6 +39,17 @@ const TEXTURED_SMALL_UV_QUAD_OBJ = [ "f 1/1 2/2 3/3 4/4", "", ].join("\n"); +const TEXTURED_SKINNY_UV_TRIANGLE_OBJ = [ + "v 0 0 0", + "v 1 0 0", + "v 0 1 0", + "vt 0.1 0.1", + "vt 0.9 0.1", + "vt 0.9 0.12", + "usemtl Swatch", + "f 1/1 2/2 3/3", + "", +].join("\n"); function makeMockFetch(opts: { ok?: boolean; @@ -324,7 +335,7 @@ describe("loadMesh", () => { expect(result.polygons.some((polygon) => polygon.uvs !== undefined)).toBe(true); }); - it("keeps uniform local samples texture-backed when the source texture is detailed", async () => { + it("bakes uniform local samples when the source texture is globally detailed", async () => { vi.stubGlobal("fetch", makeMockFetch({ text: TEXTURED_SMALL_UV_QUAD_OBJ })); stubTexturePixels(4, 4, new Uint8Array([ 0, 0, 0, 255, 255, 255, 255, 255, 0, 0, 0, 255, 255, 255, 255, 255, @@ -337,8 +348,39 @@ describe("loadMesh", () => { objOptions: { materialTextures: { Swatch: "swatch.png" } }, }); - expect(result.polygons.some((polygon) => polygon.texture === "swatch.png")).toBe(true); - expect(result.polygons.some((polygon) => polygon.uvs !== undefined)).toBe(true); + expect(result.polygons.length).toBeGreaterThan(0); + expect(result.polygons.every((polygon) => polygon.texture === undefined)).toBe(true); + expect(result.polygons.every((polygon) => polygon.uvs === undefined)).toBe(true); + expect(result.polygons.every((polygon) => /^#[0-9a-f]{6}$/.test(polygon.color))).toBe(true); + }); + + it("bakes dominant swatch samples with a single neighboring texel", async () => { + vi.stubGlobal("fetch", makeMockFetch({ text: TEXTURED_SKINNY_UV_TRIANGLE_OBJ })); + const pixels = new Uint8Array(16 * 16 * 4); + for (let y = 0; y < 16; y++) { + for (let x = 0; x < 16; x++) { + const offset = (y * 16 + x) * 4; + const checker = y < 8 && (x + y) % 2 === 0; + pixels[offset] = checker ? 0 : 219; + pixels[offset + 1] = checker ? 0 : 135; + pixels[offset + 2] = checker ? 0 : 41; + pixels[offset + 3] = 255; + } + } + const outlier = (14 * 16 + 4) * 4; + pixels[outlier] = 83; + pixels[outlier + 1] = 52; + pixels[outlier + 2] = 16; + stubTexturePixels(16, 16, pixels); + + const result = await loadMesh("model.obj", { + objOptions: { materialTextures: { Swatch: "swatch.png" } }, + }); + + expect(result.polygons).toHaveLength(1); + expect(result.polygons[0].texture).toBeUndefined(); + expect(result.polygons[0].uvs).toBeUndefined(); + expect(result.polygons[0].color).toBe("#db8729"); }); it("keeps non-uniform texture samples texture-backed", async () => { diff --git a/packages/core/src/parser/parseGltf.test.ts b/packages/core/src/parser/parseGltf.test.ts index f54d1603..a9433b1e 100644 --- a/packages/core/src/parser/parseGltf.test.ts +++ b/packages/core/src/parser/parseGltf.test.ts @@ -2,6 +2,8 @@ import { describe, it, expect, vi, afterEach } from "vitest"; import { readFileSync } from "fs"; import { resolve } from "path"; import { parseGltf } from "./parseGltf"; +import { bakeSolidTextureSamples } from "./solidTextureSamples"; +import { optimizeAnimatedMeshPolygons } from "../animation/optimizeAnimatedMeshPolygons"; // ── Real GLB fixture (lead test — matches voxcss parseMagicaVoxel pattern) ─ @@ -153,6 +155,38 @@ describe("parseGltf — animated fixture (FishAnimated.glb)", () => { } expect(totalDelta).toBeGreaterThan(1); }); + + it("keeps robot running samples aligned with rest-pose triangle filtering", () => { + const result = parseGltf(loadGlbFile("poly-pizza", "animated-robot.glb"), { + gridShift: 0, + targetSize: 72, + }); + const running = result.animation?.clips.find((clip) => /running/i.test(clip.name)); + expect(running).toBeDefined(); + expect(result.polygons.length).toBeGreaterThan(3000); + for (const time of [0, 1 / 24, 0.25, running!.duration / 2, running!.duration - 1 / 60]) { + const frame = result.animation!.sample(running!.name, time); + expect(frame).toHaveLength(result.polygons.length); + } + }); + + it("can reduce robot animation with a stable animated mesh plan", async () => { + const parsed = parseGltf(loadGlbFile("poly-pizza", "animated-robot.glb"), { + gridShift: 0, + targetSize: 72, + }); + const baked = await bakeSolidTextureSamples(parsed); + const optimized = optimizeAnimatedMeshPolygons(baked, { + meshResolution: "lossy", + }); + const running = optimized.animation?.clips.find((clip) => /running/i.test(clip.name)); + expect(running).toBeDefined(); + expect(optimized.polygons.length).toBeLessThan(baked.polygons.length); + expect(optimized.polygons.length).toBeGreaterThan(0); + for (const time of [0, 0.25, running!.duration / 2]) { + expect(optimized.animation!.sample(running!.name, time)).toHaveLength(optimized.polygons.length); + } + }); }); // ── GLB / glTF binary builder helpers ───────────────────────────────────── @@ -590,6 +624,31 @@ describe("parseGltf", () => { expect(result.polygons[0].color).toBe("#custom"); }); + it("materialTextures override attaches texture and UVs by material name", () => { + const { glb } = buildTriangleGlb({ + materialColor: [1, 1, 1, 1], + materialName: "Texture", + includeTexcoord: true, + }); + const result = parseGltf(glb, { + materialTextures: { Texture: "/textures/atlas.png" }, + }); + expect(result.polygons[0].texture).toBe("/textures/atlas.png"); + expect(result.polygons[0].uvs).toEqual([[0, 1], [1, 1], [0, 0]]); + }); + + it("materialTextures override takes priority over embedded baseColorTexture", () => { + const { glb } = buildTriangleGlb({ + materialName: "Texture", + includeTexcoord: true, + textureUrl: "embedded.png", + }); + const result = parseGltf(glb, { + materialTextures: { Texture: "/textures/override.png" }, + }); + expect(result.polygons[0].texture).toBe("/textures/override.png"); + }); + it("custom defaultColor is used when no material", () => { const { glb } = buildTriangleGlb(); const result = parseGltf(glb, { defaultColor: "#334455" }); diff --git a/packages/core/src/parser/parseGltf.ts b/packages/core/src/parser/parseGltf.ts index f49af5fe..dd9e88b5 100644 --- a/packages/core/src/parser/parseGltf.ts +++ b/packages/core/src/parser/parseGltf.ts @@ -36,6 +36,12 @@ export interface GltfParseOptions { * material's `pbrMetallicRoughness.baseColorFactor` if not in this map. */ materialColors?: Record; + /** + * Override map: glTF material name → texture image URL. Takes priority over + * `pbrMetallicRoughness.baseColorTexture`; useful for GLB/GLTF exports that + * preserved UVs but dropped external image references. + */ + materialTextures?: Record; /** * Which axis is "up" in the source mesh. * - "y" (default, glTF spec): cyclic permutation (x,y,z) → (z,x,y) so @@ -538,6 +544,7 @@ interface AnimatedPrimitiveSource { skinIndex?: number; positions: Vec3[]; indices: number[]; + triangleMask: boolean[]; color: string; texture?: string; uvs?: Vec2[]; @@ -563,6 +570,14 @@ interface RuntimeAnimationClip { channels: RuntimeAnimationChannel[]; } +function sameProjectedVertex(a: Vec3, b: Vec3): boolean { + return a[0] === b[0] && a[1] === b[1] && a[2] === b[2]; +} + +function isDegenerateProjectedTriangle(v0: Vec3, v1: Vec3, v2: Vec3): boolean { + return sameProjectedVertex(v0, v1) || sameProjectedVertex(v0, v2) || sameProjectedVertex(v1, v2); +} + function readAccessorTupleArray( doc: GltfDoc, bin: Uint8Array, @@ -701,11 +716,6 @@ function buildAnimationController( const v0 = project(v0World); const v1 = project(v1World); const v2 = project(v2World); - if ( - (v0[0] === v1[0] && v0[1] === v1[1] && v0[2] === v1[2]) || - (v0[0] === v2[0] && v0[1] === v2[1] && v0[2] === v2[2]) || - (v1[0] === v2[0] && v1[1] === v2[1] && v1[2] === v2[2]) - ) return null; const polygon: Polygon = { vertices: [v0, v1, v2], color }; if (texture) polygon.texture = texture; if (uvs) polygon.uvs = uvs; @@ -785,7 +795,9 @@ function buildAnimationController( } } - for (let i = 0; i + 2 < source.indices.length; i += 3) { + let triangleOrdinal = 0; + for (let i = 0; i + 2 < source.indices.length; i += 3, triangleOrdinal++) { + if (!source.triangleMask[triangleOrdinal]) continue; const i0 = source.indices[i]; const i1 = source.indices[i + 1]; const i2 = source.indices[i + 2]; @@ -813,6 +825,7 @@ export function parseGltf(input: ArrayBuffer | Uint8Array, options?: GltfParseOp const gridShift = options?.gridShift ?? 1; const defaultColor = options?.defaultColor ?? "#888888"; const materialOverrides = options?.materialColors ?? {}; + const materialTextureOverrides = options?.materialTextures ?? {}; const buf: ArrayBuffer = input instanceof Uint8Array ? input.buffer.slice(input.byteOffset, input.byteOffset + input.byteLength) as ArrayBuffer @@ -834,7 +847,16 @@ export function parseGltf(input: ArrayBuffer | Uint8Array, options?: GltfParseOp const { urls: imageUrls, objectUrls } = extractImageUrls(doc, bin, options?.baseUrl); const matTexMap = buildMaterialTextureMap(doc, imageUrls); - interface RawTri { v0: Vec3; v1: Vec3; v2: Vec3; color: string; texture?: string; uvs?: Vec2[]; } + interface RawTri { + v0: Vec3; + v1: Vec3; + v2: Vec3; + color: string; + texture?: string; + uvs?: Vec2[]; + source?: AnimatedPrimitiveSource; + sourceTriangleIndex?: number; + } const rawTris: RawTri[] = []; const animatedSources: AnimatedPrimitiveSource[] = []; const meshNames: string[] = (doc.meshes ?? []).map((m, i) => m.name ?? `mesh_${i}`); @@ -853,7 +875,11 @@ export function parseGltf(input: ArrayBuffer | Uint8Array, options?: GltfParseOp prim.material !== undefined ? doc.materials?.[prim.material] : undefined, defaultColor ); - const texture = prim.material !== undefined ? matTexMap.get(prim.material) : undefined; + const texture = matName && materialTextureOverrides[matName] + ? materialTextureOverrides[matName] + : prim.material !== undefined + ? matTexMap.get(prim.material) + : undefined; const { array: posArr, count: vertCount } = readAccessor(doc, bin!, prim.attributes.POSITION); if (!(posArr instanceof Float32Array)) continue; @@ -889,24 +915,29 @@ export function parseGltf(input: ArrayBuffer | Uint8Array, options?: GltfParseOp indices = positions.map((_, i) => i); } + let animatedSource: AnimatedPrimitiveSource | undefined; if ((doc.animations?.length ?? 0) > 0) { const joints = readAccessorTupleArray(doc, bin, prim.attributes.JOINTS_0, 4, vertCount); const weights = readAccessorTupleArray(doc, bin, prim.attributes.WEIGHTS_0, 4, vertCount); - animatedSources.push({ + animatedSource = { meshNode, meshBindWorld: world, skinIndex: meshNode !== null ? doc.nodes?.[meshNode]?.skin : undefined, positions: localPositions, indices, + triangleMask: [], color, texture, uvs: uvs ?? undefined, joints, weights, - }); + }; + animatedSources.push(animatedSource); } for (let i = 0; i + 2 < indices.length; i += 3) { + const sourceTriangleIndex = animatedSource ? animatedSource.triangleMask.length : undefined; + if (animatedSource) animatedSource.triangleMask.push(false); const v0 = positions[indices[i]]; const v1 = positions[indices[i + 1]]; const v2 = positions[indices[i + 2]]; @@ -916,7 +947,7 @@ export function parseGltf(input: ArrayBuffer | Uint8Array, options?: GltfParseOp const u0 = uvs[indices[i]], u1 = uvs[indices[i + 1]], u2 = uvs[indices[i + 2]]; if (u0 && u1 && u2) triUvs = [u0, u1, u2]; } - rawTris.push({ v0, v1, v2, color, texture, uvs: triUvs }); + rawTris.push({ v0, v1, v2, color, texture, uvs: triUvs, source: animatedSource, sourceTriangleIndex }); } } } @@ -979,18 +1010,16 @@ export function parseGltf(input: ArrayBuffer | Uint8Array, options?: GltfParseOp round((x - minX) * scale + gridShift), round((y - minY) * scale + gridShift), ]; - const animation = buildAnimationController(doc, bin, animatedSources, project); - const polygons: Polygon[] = []; for (const t of rawTris) { const v0 = project(t.v0); const v1 = project(t.v1); const v2 = project(t.v2); - if ( - (v0[0] === v1[0] && v0[1] === v1[1] && v0[2] === v1[2]) || - (v0[0] === v2[0] && v0[1] === v2[1] && v0[2] === v2[2]) || - (v1[0] === v2[0] && v1[1] === v2[1] && v1[2] === v2[2]) - ) continue; + const degenerate = isDegenerateProjectedTriangle(v0, v1, v2); + if (t.source && t.sourceTriangleIndex !== undefined) { + t.source.triangleMask[t.sourceTriangleIndex] = !degenerate; + } + if (degenerate) continue; const p: Polygon = { vertices: [v0, v1, v2], color: t.color, @@ -999,6 +1028,7 @@ export function parseGltf(input: ArrayBuffer | Uint8Array, options?: GltfParseOp if (t.uvs) p.uvs = t.uvs; polygons.push(p); } + const animation = buildAnimationController(doc, bin, animatedSources, project); return { polygons, diff --git a/packages/core/src/parser/solidTextureSamples.ts b/packages/core/src/parser/solidTextureSamples.ts index af126c9c..61842eb2 100644 --- a/packages/core/src/parser/solidTextureSamples.ts +++ b/packages/core/src/parser/solidTextureSamples.ts @@ -58,12 +58,16 @@ interface ColorStats { min: SampledColor; max: SampledColor; sum: SampledColor; + colors: Map; count: number; } const DEFAULT_MAX_TEXTURE_PIXELS = 16 * 1024 * 1024; const DEFAULT_COLOR_TOLERANCE = 2; const SMOOTH_SWATCH_TOLERANCE = 32; +const DOMINANT_SWATCH_MAX_COLOR_COUNT = 2; +const DOMINANT_SWATCH_MAX_OUTLIER_RATIO = 0.08; +const DOMINANT_SWATCH_MIN_SAMPLES = 8; const DETAIL_SAMPLE_TARGET = 128; const DETAIL_EDGE_THRESHOLD = 32; const LOW_DETAIL_MAX_EDGE_RATIO = 0.045; @@ -273,10 +277,15 @@ function createColorStats(): ColorStats { min: { r: 255, g: 255, b: 255, a: 255 }, max: { r: 0, g: 0, b: 0, a: 0 }, sum: { r: 0, g: 0, b: 0, a: 0 }, + colors: new Map(), count: 0, }; } +function colorKey(color: SampledColor): string { + return `${color.r},${color.g},${color.b},${color.a}`; +} + function addColor(stats: ColorStats, color: SampledColor): void { stats.min.r = Math.min(stats.min.r, color.r); stats.min.g = Math.min(stats.min.g, color.g); @@ -290,6 +299,9 @@ function addColor(stats: ColorStats, color: SampledColor): void { stats.sum.g += color.g; stats.sum.b += color.b; stats.sum.a += color.a; + const key = colorKey(color); + const prev = stats.colors.get(key); + stats.colors.set(key, { color, count: (prev?.count ?? 0) + 1 }); stats.count++; } @@ -302,6 +314,21 @@ function statsColor(stats: ColorStats): SampledColor { }; } +function dominantSwatchColor(stats: ColorStats): SampledColor | null { + if (stats.count < DOMINANT_SWATCH_MIN_SAMPLES) return null; + if (stats.colors.size <= 1 || stats.colors.size > DOMINANT_SWATCH_MAX_COLOR_COUNT) return null; + + let dominant: { color: SampledColor; count: number } | null = null; + for (const entry of stats.colors.values()) { + if (!dominant || entry.count > dominant.count) dominant = entry; + } + if (!dominant) return null; + + const outliers = stats.count - dominant.count; + const maxOutliers = Math.max(1, Math.floor(stats.count * DOMINANT_SWATCH_MAX_OUTLIER_RATIO)); + return outliers <= maxOutliers ? dominant.color : null; +} + function solidColorForPolygon( polygon: Polygon, sampler: TextureSampler, @@ -310,7 +337,6 @@ function solidColorForPolygon( ): string | null { const triangles = polygonTextureTriangles(polygon); if (triangles.length === 0) return null; - if (!explicitTolerance && !sampler.lowDetail) return null; const stats = createColorStats(); @@ -324,7 +350,12 @@ function solidColorForPolygon( if (stats.count === 0) return null; if (colorsClose(stats.min, stats.max, tolerance)) return colorToCss(statsColor(stats)); - if (explicitTolerance) return null; + // Skinny UV islands in swatch atlases can graze one neighboring swatch texel. + if (!explicitTolerance) { + const dominant = dominantSwatchColor(stats); + if (dominant) return colorToCss(dominant); + } + if (explicitTolerance || !sampler.lowDetail) return null; if (!colorsClose(stats.min, stats.max, SMOOTH_SWATCH_TOLERANCE)) return null; return colorToCss(statsColor(stats)); } diff --git a/packages/polycss/README.md b/packages/polycss/README.md index 2988e112..ad8a69f3 100644 --- a/packages/polycss/README.md +++ b/packages/polycss/README.md @@ -1,200 +1,207 @@ -> **Status: pre-1.0. APIs may still change before a stable 1.0 release.** +

+ polycss +

# polycss -Vanilla JS / custom elements package for CSS-based polygon mesh rendering. Loads OBJ, glTF, GLB, and MagicaVoxel `.vox` files; renders each polygon as a real DOM element (atlas-backed `` for both textured and flat-color faces) positioned with `transform: matrix3d(...)`. No WebGL, no canvas-as-scene. +A CSS polygon mesh engine. A 3D renderer for the DOM. Renders OBJ, glTF, GLB, MagicaVoxel `.vox`, and generated primitives as real HTML elements transformed with CSS `matrix3d(...)`. Supports colors, textures, lighting, shadows, controls, selection, animation, and per-polygon interaction. Works with React, Vue, custom elements, or plain JavaScript. -Two entry points: +Visit [polycss.com](https://polycss.com) for docs and model examples. -- **`polycss`**: imperative `createPolyScene` API + custom element classes (without auto-registering them). -- **`polycss/elements`**: side-effect import that registers the scene, mesh, polygon, controls, camera, helper, select, and transform-control custom elements. +polycss scene -## Install +## Installation ```bash +# React +npm install @layoutit/polycss-react + +# Vue +npm install @layoutit/polycss-vue + +# Vanilla / custom elements npm install @layoutit/polycss ``` -Or via CDN (no build step): +You can also load polycss directly from a CDN. Here is a minimal custom-element scene: ```html + + + + + + + ``` -## Custom elements (declarative, primary path) +## Framework Components -Register elements with the side-effect import: +React and Vue expose the same component model. `` owns the viewpoint, `` owns lighting and atlas options, and `` loads or receives polygon data. -```html - +```tsx +import { PolyCamera, PolyScene, PolyOrbitControls, PolyMesh } from "@layoutit/polycss-react"; - - - +export default function App() { + return ( + + + + + + + ); +} ``` -With per-polygon elements: +The Vue package mirrors the same names and props with Vue casing: -```html - - - - +```vue + + + ``` -Custom elements accept standard DOM events: no framework needed: +## API Reference -```html - +### PolyCamera - +### PolyMesh + +- `src` loads `.obj`, `.gltf`, `.glb`, or `.vox` files. +- `mtl` loads companion OBJ materials. +- `polygons` accepts pre-parsed geometry. +- `position`, `scale`, and `rotation` transform the mesh wrapper. +- `autoCenter` shifts the mesh bbox center to local origin. +- `meshResolution` chooses `"lossy"` (default) or `"lossless"` optimization. +- `castShadow` emits CSS-projected shadows in dynamic lighting mode. + +### Controls + +- `` adds drag orbit, shift-drag pan, wheel zoom, and optional auto-rotate. +- `` uses pan-first map-style input. +- `` provides keyboard and pointer-look navigation. +- `` adds translate/rotate gizmos for selected mesh handles. + +### Polygon Data Model - +Each polygon describes one renderable face: + +```ts +const polygons = [ + { + vertices: [[0, 0, 0], [60, 0, 0], [0, 60, 0]], + color: "#f97316", + }, + { + vertices: [[0, 0, 0], [60, 0, 0], [60, 60, 0], [0, 60, 0]], + texture: "/texture.png", + uvs: [[0, 0], [1, 0], [1, 1], [0, 1]], + }, +]; ``` -### Custom element attributes +Render polygons directly when you need per-face DOM events or custom styling: + +```tsx + + + {polygons.map((polygon, index) => ( + console.log("clicked polygon", index)} + className="my-polygon" + /> + ))} + + +``` -**``** +## Loading Mesh Files -| Attribute | Description | -|---|---| -| `perspective` | CSS perspective distance in pixels | -| `rot-x` | Camera X-axis rotation in degrees | -| `rot-y` | Camera Y-axis rotation in degrees | -| `zoom` | Scale factor | -| `directional-direction` | Comma-separated `x, y, z` e.g. `"0.5, -0.7, 0.6"` | -| `directional-color` | Directional light color hex | -| `directional-intensity` | Directional light intensity | -| `ambient-intensity` | Ambient light intensity | -| `ambient-color` | Ambient light color hex | -| `texture-lighting` | `"baked"` or `"dynamic"` | -| `atlas-scale` | Atlas bitmap budget and compositor sprite size; lower numeric values reduce memory/detail | - -For pointer drag, wheel zoom, and autorotate, drop a `` child inside the scene (or wire `createPolyOrbitControls(scene, ...)` against the imperative API). For pan-first map-style input use `` / `createPolyMapControls` instead. Mirrors Three.js's split between camera state (``) and camera input. - -**``** - -| Attribute | Description | -|---|---| -| `src` | URL to `.obj`, `.glb`, `.gltf`, or `.vox` | -| `position` | Comma-separated `x, y, z` | -| `scale` | Uniform scale factor | -| `rotation` | Comma-separated euler degrees `x, y, z` | -| `auto-center` | Boolean: shift mesh bbox center to origin | +Use `loadMesh()` from `@layoutit/polycss`, `@layoutit/polycss-react`, or `@layoutit/polycss-vue` to parse supported model formats: -**``** +```ts +import { createPolyCamera, createPolyScene, loadMesh } from "@layoutit/polycss"; -| Attribute | Description | -|---|---| -| `vertices` | JSON array of `[x,y,z]` arrays | -| `color` | CSS color | -| `texture` | Image URL | -| `uvs` | JSON array of `[u,v]` pairs | -| `position` | Comma-separated `x, y, z` | -| `scale` | Uniform scale factor | -| `rotation` | Comma-separated euler degrees `x, y, z` | - -## Imperative API (escape hatch) - -For programmatic control without custom elements: - -```js -import { createPolyScene, loadMesh } from "@layoutit/polycss"; - -const scene = createPolyScene(document.querySelector("#scene"), { - perspective: 1000, - rotX: 65, - rotY: 45, - directionalLight: { direction: [0.5, -0.7, 0.6] }, -}); +const host = document.getElementById("polycss")!; +const camera = createPolyCamera({ rotX: 65, rotY: 45 }); +const scene = createPolyScene(host, { camera }); -const mesh = await loadMesh("/cottage.glb", { - gltfOptions: { targetSize: 60 }, +const mesh = await loadMesh("https://polycss.com/gallery/obj/cottage.obj", { + mtlUrl: "https://polycss.com/gallery/obj/cottage.mtl", }); -const handle = scene.add(mesh, { position: [0, 0, 0] }); -// Later: -handle.setTransform({ position: [5, 0, 0] }); -handle.remove(); -mesh.dispose(); +scene.add(mesh); ``` -### Imperative API reference - -**`createPolyScene(host, options)`** +Supported formats: -| Option | Type | Description | -|---|---|---| -| `perspective` | `number` | CSS perspective distance | -| `rotX` | `number` | Camera X rotation in degrees | -| `rotY` | `number` | Camera Y rotation in degrees | -| `zoom` | `number` | Camera zoom scale | -| `distance` | `number` | Camera dolly pull-back in CSS pixels | -| `target` | `Vec3` | World-coordinate camera target | -| `directionalLight` | `PolyDirectionalLight` | Directional light config | -| `ambientLight` | `PolyAmbientLight` | Ambient light config | -| `textureLighting` | `"baked" \| "dynamic"` | Texture lighting mode | -| `textureQuality` | `number \| "auto"` | Atlas bitmap budget and compositor sprite size | -| `autoCenter` | `boolean` | Rotate around the union bbox center of added meshes | +- OBJ + MTL, including `map_Kd` textures and UV coordinates. +- glTF / GLB, including embedded images and `TEXCOORD_0`. +- MagicaVoxel `.vox`, with direct voxel fast paths when eligible. +- Generated primitives: box, plane, ring, sphere, torus, cylinder, cone, and Platonic solids. -Returns a `PolySceneHandle`: +## Performance -```ts -interface PolySceneHandle { - add(mesh: ParseResult, opts?: { position?: Vec3; scale?: number | Vec3; rotation?: Vec3 }): PolyMeshHandle; - setOptions(partial: Partial): void; - destroy(): void; -} -``` +polycss renders in the DOM, so performance is mostly determined by how many polygons are mounted and how much texture atlas area they consume. The renderer uses several CSS strategies so simple surfaces stay cheap and textured or irregular surfaces fall back to atlas slices. -**`loadMesh(url, options?)`** +- One visible polygon becomes one leaf DOM element. +- Flat rectangles and stable quads use solid CSS leaves. +- Textured polygons are packed into generated texture atlases. +- Dynamic lighting runs through CSS custom properties instead of per-frame JavaScript. +- Voxel-shaped meshes mount only camera-facing leaves when the mesh is eligible. +- `meshResolution: "lossy"` can merge compatible polygons to reduce DOM node count. -Fetches and parses a mesh by URL (dispatches by extension: `.obj`, `.glb`, `.gltf`, `.vox`). Returns `Promise`. -Mesh optimization defaults to `meshResolution: "lossy"`; pass `"lossless"` for exact planar candidates only. +For diagnostics, all renderer packages export `collectPolyRenderStats(root)`, which returns mounted polygon leaf counts, shadow counts, surface categories, and bucket counts for an already-rendered scene. -## Subpath imports +## Packages -| Import | Effect | +| Package | Description | |---|---| -| `import { createPolyScene } from "@layoutit/polycss"` | Imperative API + custom element classes (no auto-registration) | -| `import "@layoutit/polycss/elements"` | Side-effect: registers the polycss custom elements | +| `@layoutit/polycss-core` | Pure math, parsers, lighting, camera helpers, mesh optimization. Zero browser globals. | +| `@layoutit/polycss` | Vanilla custom elements and imperative `createPolyScene` API. | +| `@layoutit/polycss-react` | React components, hooks, controls, and core re-exports. | +| `@layoutit/polycss-vue` | Vue 3 components, composables, controls, and core re-exports. | -## Re-exports from `@layoutit/polycss-core` +## Made with polycss -All `@layoutit/polycss-core` exports are re-exported from `@layoutit/polycss`, so vanilla users install one package: +[Layoutit Voxels](https://voxels.layoutit.com) +-> A CSS Voxel editor -```ts -import { parseObj, parseGltf, parseVox, loadMesh, normalizePolygons } from "@layoutit/polycss"; -import type { Polygon, Vec3, ParseResult } from "@layoutit/polycss"; -``` +layoutit-voxels + +[Layoutit Terra](https://terra.layoutit.com) +-> A CSS Terrain Generator + +layoutit-terra -## Docs +## License -Full documentation at [polycss.com](https://polycss.com). +MIT. diff --git a/packages/polycss/package.json b/packages/polycss/package.json index 59696582..cafd2adb 100644 --- a/packages/polycss/package.json +++ b/packages/polycss/package.json @@ -36,6 +36,7 @@ "build": "tsup", "test": "vitest run --passWithNoTests", "test:coverage": "vitest run --coverage --passWithNoTests", + "prepack": "node ../../tools/sync-package-readmes.mjs", "prepublishOnly": "npm run build" }, "publishConfig": { diff --git a/packages/polycss/src/api/createPolyScene.test.ts b/packages/polycss/src/api/createPolyScene.test.ts index 71be493e..64e2c791 100644 --- a/packages/polycss/src/api/createPolyScene.test.ts +++ b/packages/polycss/src/api/createPolyScene.test.ts @@ -123,6 +123,54 @@ function topQuad(color = "#123456"): Polygon { }; } +function translatedTopQuad(x: number, y: number, color: string): Polygon { + return { + vertices: [ + [x, y, 1], + [x + 1, y, 1], + [x + 1, y + 1, 1], + [x, y + 1, 1], + ], + color, + }; +} + +function backTopQuad(color = "#654321"): Polygon { + return { + vertices: [ + [0, 0, 1], + [0, 1, 1], + [1, 1, 1], + [1, 0, 1], + ], + color, + }; +} + +function sideQuad(color = "#ff0000"): Polygon { + return { + vertices: [ + [0, 0, 0], + [0, 0, 1], + [1, 0, 1], + [1, 0, 0], + ], + color, + }; +} + +function backSideQuad(color = "#00ff00"): Polygon { + return { + vertices: [ + [0, 0, 0], + [1, 0, 0], + [1, 0, 1], + [0, 0, 1], + ], + color, + }; +} + function makeParseResult(polygons: Polygon[] = [triangle()]): ParseResult { let disposed = false; return { @@ -170,6 +218,60 @@ function makeVoxelExactParseResult(): ParseResult { }; } +function makeTwoSidedVoxelExactParseResult(): ParseResult { + return { + ...makeParseResult([topQuad("#ff0000"), backTopQuad("#00ff00")]), + voxelSource: { + kind: "magica-vox", + cells: [{ x: 0, y: 0, z: 0, color: "#ff0000" }], + rows: 1, + cols: 1, + depth: 1, + scale: 1, + gridShift: 0, + sourceBytes: 64, + }, + }; +} + +function makeTwoTopVoxelExactParseResult(): ParseResult { + return { + ...makeParseResult([ + translatedTopQuad(0, 0, "#ff0000"), + translatedTopQuad(10, 0, "#00ff00"), + ]), + voxelSource: { + kind: "magica-vox", + cells: [ + { x: 0, y: 0, z: 0, color: "#ff0000" }, + { x: 10, y: 0, z: 0, color: "#00ff00" }, + ], + rows: 11, + cols: 1, + depth: 1, + scale: 1, + gridShift: 0, + sourceBytes: 64, + }, + }; +} + +function makeTwoSidedVoxelSideParseResult(): ParseResult { + return { + ...makeParseResult([sideQuad("#ff0000"), backSideQuad("#00ff00")]), + voxelSource: { + kind: "magica-vox", + cells: [{ x: 0, y: 0, z: 0, color: "#ff0000" }], + rows: 1, + cols: 1, + depth: 1, + scale: 1, + gridShift: 0, + sourceBytes: 64, + }, + }; +} + function getSceneEl(host: HTMLElement): HTMLElement { const sceneEl = host.querySelector(".polycss-scene") as HTMLElement | null; expect(sceneEl).not.toBeNull(); @@ -505,6 +607,324 @@ describe("createPolyScene", () => { expect(after[0].style.transform).not.toBe(beforeTransform); }); + it("keeps stableDom triangle leaves mounted when animation frames degenerate", () => { + scene = makeScene(host); + const firstTriangle: Polygon = { + vertices: [ + [0, 0, 0], + [1, 0, 0], + [0, 1, 1], + ], + color: "#ff0000", + }; + const secondTriangle: Polygon = { + vertices: [ + [0, 0, 0], + [0, 1, 0], + [1, 0, 1], + ], + color: "#0000ff", + }; + const restoredTriangle: Polygon = { + vertices: [ + [0, 0, 0], + [0, 1, 0], + [1, 0, 2], + ], + color: "#ffff00", + }; + const degenerateTriangle: Polygon = { + vertices: [ + [0, 0, 0], + [1, 0, 0], + [2, 0, 0], + ], + color: "#00ff00", + }; + const handle = scene.add(makeParseResult([firstTriangle, secondTriangle]), { + merge: false, + stableDom: true, + }); + const before = Array.from(host.querySelectorAll("u")) as HTMLElement[]; + expect(before.length).toBe(2); + + handle.setPolygons([firstTriangle, degenerateTriangle], { + merge: false, + stableDom: true, + recomputeAutoCenter: false, + }); + + const hidden = Array.from(host.querySelectorAll("u")) as HTMLElement[]; + expect(hidden).toEqual(before); + expect(hidden[0].style.visibility).toBe(""); + expect(hidden[1].style.visibility).toBe("hidden"); + + handle.setPolygons([firstTriangle, restoredTriangle], { + merge: false, + stableDom: true, + recomputeAutoCenter: false, + }); + + const restored = Array.from(host.querySelectorAll("u")) as HTMLElement[]; + expect(restored).toEqual(before); + expect(restored[1].style.visibility).toBe(""); + }); + + it("creates hidden stableDom triangle placeholders for initially degenerate animation frames", () => { + scene = makeScene(host); + const degenerateTriangle: Polygon = { + vertices: [ + [0, 0, 0], + [1, 0, 0], + [2, 0, 0], + ], + color: "#00ff00", + }; + const restoredTriangle: Polygon = { + vertices: [ + [0, 0, 0], + [0, 1, 0], + [1, 0, 2], + ], + color: "#ffff00", + }; + const handle = scene.add(makeParseResult([triangle(), degenerateTriangle]), { + merge: false, + stableDom: true, + }); + const before = Array.from(host.querySelectorAll("u")) as HTMLElement[]; + expect(before.length).toBe(2); + expect(before[1].style.visibility).toBe("hidden"); + + handle.setPolygons([triangle(), restoredTriangle], { + merge: false, + stableDom: true, + recomputeAutoCenter: false, + }); + + const restored = Array.from(host.querySelectorAll("u")) as HTMLElement[]; + expect(restored).toEqual(before); + expect(restored[1].style.visibility).toBe(""); + expect(restored[1].style.transform).not.toBe(""); + }); + + it("reselects the stableDom triangle basis when an animated triangle changes shape", () => { + scene = makeScene(host); + const initialTriangle: Polygon = { + vertices: [ + [0, 0, 0], + [0, 2, 0], + [1, 1, 1], + ], + color: "#ff0000", + }; + const reshapedTriangle: Polygon = { + vertices: [ + [0, 0, 0], + [0, 1, 0], + [1, 2, 1], + ], + color: "#00ff00", + }; + const handle = scene.add(makeParseResult([initialTriangle]), { + merge: false, + stableDom: true, + }); + const leaf = host.querySelector("u") as HTMLElement; + const initialTransform = leaf.style.transform; + + handle.setPolygons([reshapedTriangle], { + merge: false, + stableDom: true, + recomputeAutoCenter: false, + }); + + expect(host.querySelector("u")).toBe(leaf); + expect(leaf.style.visibility).toBe(""); + expect(leaf.style.transform).not.toBe(initialTransform); + }); + + it("can refresh stableDom triangle color without changing its transform", () => { + scene = makeScene(host); + const baseTriangle: Polygon = { + vertices: [ + [0, 0, 0], + [1, 0, 0], + [0, 1, 1], + ], + color: "#ff0000", + }; + const nextTriangle: Polygon = { + vertices: [ + [0, 0, 0], + [2, 0, 0], + [0, 1, 1], + ], + color: "#0000ff", + }; + const handle = scene.add(makeParseResult([baseTriangle]), { + merge: false, + stableDom: true, + }); + const leaf = host.querySelector("u") as HTMLElement; + const initialTransform = leaf.style.transform; + const initialColor = leaf.style.color; + + handle.setPolygons([nextTriangle], { + merge: false, + stableDom: true, + recomputeAutoCenter: false, + stableTriangleUpdateMode: "color-only", + stableTriangleColorFreezeFrames: 1, + stableTriangleColorMaxStep: 0, + } as Parameters[1] & { + stableTriangleUpdateMode: "color-only"; + stableTriangleColorFreezeFrames: number; + stableTriangleColorMaxStep: number; + }); + + expect(host.querySelector("u")).toBe(leaf); + expect(leaf.style.visibility).toBe(""); + expect(leaf.style.transform).toBe(initialTransform); + expect(leaf.style.color).not.toBe(initialColor); + }); + + it("staggers optimized stableDom triangle color writes without quantizing colors", () => { + scene = makeScene(host); + const baseTriangle: Polygon = { + vertices: [ + [0, 0, 0], + [1, 0, 0], + [0, 1, 1], + ], + color: "#ff0000", + }; + const nextTriangle: Polygon = { ...baseTriangle, color: "#0000ff" }; + const handle = scene.add(makeParseResult([baseTriangle]), { + merge: false, + stableDom: true, + }); + const leaf = host.querySelector("u") as HTMLElement; + const initialColor = leaf.style.color; + + const updateOptions = { + merge: false, + stableDom: true, + recomputeAutoCenter: false, + stableTriangleColorSteps: 0, + stableTriangleColorFreezeFrames: 3, + } as Parameters[1] & { + stableTriangleColorSteps: number; + stableTriangleColorFreezeFrames: number; + }; + + handle.setPolygons([nextTriangle], updateOptions); + expect(leaf.style.color).toBe(initialColor); + handle.setPolygons([nextTriangle], updateOptions); + expect(leaf.style.color).toBe(initialColor); + handle.setPolygons([nextTriangle], updateOptions); + expect(leaf.style.color).not.toBe(initialColor); + expect(leaf.style.color).not.toBe(""); + }); + + it("can skip optimized stableDom triangle color writes", () => { + scene = makeScene(host); + const baseTriangle: Polygon = { + vertices: [ + [0, 0, 0], + [1, 0, 0], + [0, 1, 1], + ], + color: "#ff0000", + }; + const nextTriangle: Polygon = { ...baseTriangle, color: "#0000ff" }; + const handle = scene.add(makeParseResult([baseTriangle]), { + merge: false, + stableDom: true, + }); + const leaf = host.querySelector("u") as HTMLElement; + const initialColor = leaf.style.color; + + const updateOptions = { + merge: false, + stableDom: true, + recomputeAutoCenter: false, + stableTriangleColorSteps: 0, + stableTriangleColorFreezeFrames: 0, + } as Parameters[1] & { + stableTriangleColorSteps: number; + stableTriangleColorFreezeFrames: number; + }; + + handle.setPolygons([nextTriangle], updateOptions); + handle.setPolygons([nextTriangle], updateOptions); + handle.setPolygons([nextTriangle], updateOptions); + + expect(leaf.style.color).toBe(initialColor); + }); + + it("can limit optimized stableDom triangle color jumps", () => { + scene = makeScene(host); + const baseTriangle: Polygon = { + vertices: [ + [0, 0, 0], + [1, 0, 0], + [0, 1, 1], + ], + color: "#ff0000", + }; + const nextTriangle: Polygon = { ...baseTriangle, color: "#0000ff" }; + + const exactHandle = scene.add(makeParseResult([baseTriangle]), { + merge: false, + stableDom: true, + }); + const exactLeaf = exactHandle.element.querySelector("u") as HTMLElement; + exactHandle.setPolygons([nextTriangle], { + merge: false, + stableDom: true, + recomputeAutoCenter: false, + stableTriangleColorSteps: 0, + stableTriangleColorFreezeFrames: 1, + } as Parameters[1] & { + stableTriangleColorSteps: number; + stableTriangleColorFreezeFrames: number; + }); + const exactColor = exactLeaf.style.color; + exactHandle.remove(); + + const handle = scene.add(makeParseResult([baseTriangle]), { + merge: false, + stableDom: true, + }); + const leaf = handle.element.querySelector("u") as HTMLElement; + const initialColor = leaf.style.color; + + const updateOptions = { + merge: false, + stableDom: true, + recomputeAutoCenter: false, + stableTriangleColorSteps: 0, + stableTriangleColorFreezeFrames: 1, + stableTriangleColorMaxStep: 8, + } as Parameters[1] & { + stableTriangleColorSteps: number; + stableTriangleColorFreezeFrames: number; + stableTriangleColorMaxStep: number; + }; + + handle.setPolygons([nextTriangle], updateOptions); + const steppedColor = leaf.style.color; + + expect(steppedColor).not.toBe(initialColor); + expect(steppedColor).not.toBe(exactColor); + expect(steppedColor).not.toBe(""); + + handle.setPolygons([nextTriangle], updateOptions); + + expect(leaf.style.color).not.toBe(steppedColor); + }); + it("preserves caller-mounted mesh wrapper children across setPolygons()", () => { scene = makeScene(host); const handle = scene.add(makeParseResult([triangle()]), { merge: false }); @@ -864,6 +1284,77 @@ describe("createPolyScene", () => { expect(host.querySelectorAll(".polycss-mesh i, .polycss-mesh b, .polycss-mesh s, .polycss-mesh u").length).toBe(1); }); + it("updates mounted voxel leaves when mesh rotation changes the visible normal set", () => { + scene = makeScene(host, {}, { rotX: 0, rotY: 0 }); + const handle = scene.add(makeParseResult([ + { ...triangle(), data: { face: "front" } }, + { ...backTriangle(), data: { face: "back" } }, + ]), { merge: false }); + const firstLeaf = host.querySelector(".polycss-mesh i, .polycss-mesh b, .polycss-mesh s, .polycss-mesh u"); + expect(firstLeaf).not.toBeNull(); + expect((firstLeaf as HTMLElement).dataset.face).toBe("front"); + + handle.setTransform({ rotation: [180, 0, 0] }); + + const nextLeaf = host.querySelector(".polycss-mesh i, .polycss-mesh b, .polycss-mesh s, .polycss-mesh u"); + expect(nextLeaf).not.toBe(firstLeaf); + expect((nextLeaf as HTMLElement).dataset.face).toBe("back"); + expect(host.querySelectorAll(".polycss-mesh i, .polycss-mesh b, .polycss-mesh s, .polycss-mesh u").length).toBe(1); + }); + + it("updates direct voxel brushes when mesh rotation changes the visible face set", () => { + scene = makeScene(host, { + directionalLight: { direction: [0, 0, 1], intensity: 0 }, + ambientLight: { color: "#ffffff", intensity: 1 }, + }, { rotX: 0, rotY: 0 }); + const handle = scene.add(makeTwoSidedVoxelExactParseResult()); + const firstBrush = host.querySelector(".polycss-mesh > b") as HTMLElement | null; + expect(firstBrush).not.toBeNull(); + expect(firstBrush!.style.color).toMatch(/^(#ff0000|rgb\(255, 0, 0\))$/); + expect(host.querySelectorAll(".polycss-mesh > b").length).toBe(1); + + handle.setTransform({ rotation: [180, 0, 0] }); + + const nextBrush = host.querySelector(".polycss-mesh > b") as HTMLElement | null; + expect(nextBrush).not.toBeNull(); + expect(nextBrush!.style.color).toMatch(/^(#00ff00|rgb\(0, 255, 0\))$/); + expect(host.querySelectorAll(".polycss-mesh > b").length).toBe(1); + }); + + it("updates direct voxel side brushes when mesh z-rotation swaps front and back faces", () => { + scene = makeScene(host, { + directionalLight: { direction: [0, 0, 1], intensity: 0 }, + ambientLight: { color: "#ffffff", intensity: 1 }, + }, { rotX: 65, rotY: 45 }); + const handle = scene.add(makeTwoSidedVoxelSideParseResult()); + const firstBrush = host.querySelector(".polycss-mesh > b") as HTMLElement | null; + expect(firstBrush).not.toBeNull(); + expect(firstBrush!.style.color).toMatch(/^(#ff0000|rgb\(255, 0, 0\))$/); + expect(host.querySelectorAll(".polycss-mesh > b").length).toBe(1); + + handle.setTransform({ rotation: [0, 0, 180] }); + + const nextBrush = host.querySelector(".polycss-mesh > b") as HTMLElement | null; + expect(nextBrush).not.toBeNull(); + expect(nextBrush!.style.color).toMatch(/^(#00ff00|rgb\(0, 255, 0\))$/); + expect(host.querySelectorAll(".polycss-mesh > b").length).toBe(1); + }); + + it("redraws direct voxel brushes on mesh rotation even when visible faces stay the same", () => { + scene = makeScene(host, { + directionalLight: { direction: [0, 0, 1], intensity: 0 }, + ambientLight: { color: "#ffffff", intensity: 1 }, + }, { rotX: 0, rotY: 0 }); + const handle = scene.add(makeTwoTopVoxelExactParseResult()); + const brushes = () => Array.from(host.querySelectorAll(".polycss-mesh > b")) as HTMLElement[]; + expect(brushes().map((brush) => brush.style.color)).toEqual(["#ff0000", "#00ff00"]); + + handle.setTransform({ rotation: [0, 0, 180] }); + + expect(brushes().map((brush) => brush.style.color)).toEqual(["#00ff00", "#ff0000"]); + expect(brushes().length).toBe(2); + }); + it("does not remount culling leaves when camera rotation keeps the same visible normal set", () => { scene = makeScene(host, {}, { rotX: 0, rotY: 0 }); scene.add(makeParseResult([triangle(), backTriangle()]), { merge: false }); @@ -949,6 +1440,17 @@ describe("createPolyScene", () => { expect(handle.element.querySelectorAll("i,b,s,u").length).toBe(leafCount); }); + it("keeps replacement high-normal meshes non-cullable after setPolygons", () => { + scene = makeScene(host, {}, { rotX: 65, rotY: 45 }); + const handle = scene.add(makeParseResult(highNormalTrianglePairs()), { merge: false }); + const initialLeafCount = handle.element.querySelectorAll("i,b,s,u").length; + expect(initialLeafCount).toBe(handle.polygons.length); + + handle.setPolygons(highNormalTrianglePairs(), { merge: false }); + + expect(handle.element.querySelectorAll("i,b,s,u").length).toBe(handle.polygons.length); + }); + // Perf-fix tests: setOptions used to call recomputeAutoCenter() on every // call, which is O(N polys) and would be paid 60×/sec by an autorotate // loop. The smart-diff version only recomputes when `autoCenter` itself diff --git a/packages/polycss/src/api/createPolyScene.ts b/packages/polycss/src/api/createPolyScene.ts index 9a4849a5..34e118ba 100644 --- a/packages/polycss/src/api/createPolyScene.ts +++ b/packages/polycss/src/api/createPolyScene.ts @@ -37,11 +37,13 @@ import type { import { BASE_TILE, CAMERA_BACKFACE_CULL_EPS, - cameraCullNormalGroups, + VOXEL_CAMERA_CULL_NORMAL_LIMIT, + cameraCullNormalKey, cameraCullVisibleSignature, computeSceneBbox, findOverlappingPolygonDuplicates, inverseRotateVec3, + isAxisAlignedSurfaceNormal, isVoxelCameraCullableNormalGroups, mergePolygons, normalFacesCamera, @@ -283,6 +285,31 @@ export interface PolySceneHandle { const DEFAULT_ZOOM = 1; const DEFAULT_TILE = BASE_TILE; +// Sentinel that keeps broad camera DOM culling disabled once a mesh proves +// it has non-voxel normals; callers never inspect group contents directly. +const NON_CULLABLE_CAMERA_GROUP: CameraCullNormalGroup = { + key: "non-cullable", + normal: [1, 1, 0], +}; + +function nonCullableCameraGroups(): CameraCullNormalGroup[] { + return [NON_CULLABLE_CAMERA_GROUP]; +} + +interface InternalSetPolygonsOptions { + merge?: boolean; + stableDom?: boolean; + recomputeAutoCenter?: boolean; + stableTriangleDebug?: "transform-only" | "plan-only"; + stableTriangleUpdateMode?: "full" | "transform-only" | "color-only"; + stableTriangleColorPolicy?: "cadence" | "adaptive"; + stableTriangleColorSteps?: number; + stableTriangleColorFreezeFrames?: number; + stableTriangleColorBudget?: number; + stableTriangleColorMaxAge?: number; + stableTriangleColorMaxStep?: number; + stableTriangleMatrixDecimals?: number; +} function strategiesEqual( a: PolyRenderStrategiesOption | undefined, @@ -511,11 +538,13 @@ export function createPolyScene( voxelSource: ParseResult["voxelSource"]; disposed: boolean; stableDom: boolean; + hasBuckets: boolean; excludeFromAutoCenter: boolean; castShadow: boolean; cameraCullGroups: CameraCullNormalGroup[]; cameraCullSignature: string; lightOverrideSignature: string; + stableTriangleColorFrame: number; /** Rotation snapshot used by the baked atlas baker. Advances only when * `rebakeAtlas()` is called — not on every `setTransform`. */ bakedRotation: Vec3; @@ -610,7 +639,7 @@ export function createPolyScene( disposeRendered(entry.rendered, entry.disposeAtlas); entry.disposeAtlas = undefined; entry.rendered.length = 0; - entry.cameraCullGroups.length = 0; + entry.cameraCullGroups = []; entry.cameraCullSignature = ""; clearShadowLeaves(entry); for (const child of Array.from(entry.wrapper.children)) { @@ -618,6 +647,7 @@ export function createPolyScene( child.remove(); } } + entry.hasBuckets = false; } function firstPreservedChild(entry: MeshEntry): ChildNode | null { @@ -640,12 +670,6 @@ export function createPolyScene( } } - function hasDirectBucket(wrapper: HTMLDivElement): boolean { - return Array.from(wrapper.children).some( - (child) => child instanceof HTMLElement && child.classList.contains("polycss-bucket"), - ); - } - function clearShadowLeaves(entry: MeshEntry): void { for (const el of entry.shadowRendered) { if (el.parentNode) el.parentNode.removeChild(el); @@ -667,6 +691,7 @@ export function createPolyScene( child.remove(); } } + entry.hasBuckets = false; for (const item of entry.rendered) { if (item.element.parentNode) item.element.parentNode.removeChild(item.element); } @@ -705,9 +730,28 @@ export function createPolyScene( } function recomputeCameraCullGroups(entry: MeshEntry): void { - entry.cameraCullGroups = cameraCullNormalGroups( - entry.rendered.map((item) => normalForRendered(entry, item)), - ); + if (entry.excludeFromAutoCenter) { + entry.cameraCullGroups = []; + return; + } + const groups = new Map(); + for (const item of entry.rendered) { + const normal = normalForRendered(entry, item); + if (!normal) continue; + if (!isAxisAlignedSurfaceNormal(normal)) { + entry.cameraCullGroups = nonCullableCameraGroups(); + return; + } + const key = cameraCullNormalKey(normal); + if (!groups.has(key)) { + groups.set(key, normal); + if (groups.size > VOXEL_CAMERA_CULL_NORMAL_LIMIT) { + entry.cameraCullGroups = nonCullableCameraGroups(); + return; + } + } + } + entry.cameraCullGroups = Array.from(groups, ([key, normal]) => ({ key, normal })); } function cameraCullSignature(entry: MeshEntry): string { @@ -809,7 +853,8 @@ export function createPolyScene( function syncMountedRenderedForCameraChange(entry: MeshEntry, force = false): void { if (entry.voxelRenderer) { - entry.voxelRenderer.syncCamera(cameraCullRotation(entry)); + if (force) entry.voxelRenderer.render(cameraCullRotation(entry)); + else entry.voxelRenderer.syncCamera(cameraCullRotation(entry)); entry.cameraCullSignature = "voxel-direct"; return; } @@ -821,7 +866,7 @@ export function createPolyScene( return; } - if (hasDirectBucket(entry.wrapper)) { + if (entry.hasBuckets) { remountEntryIfCullSignatureChanged(entry, force); return; } @@ -857,6 +902,7 @@ export function createPolyScene( function syncMountedRendered(entry: MeshEntry): void { clearMountedRendered(entry); + entry.hasBuckets = false; const fragment = doc.createDocumentFragment(); // Lambert-bucketing only pays off in dynamic mode, where the cascade @@ -906,6 +952,7 @@ export function createPolyScene( } const bucketEl = doc.createElement("div"); bucketEl.className = "polycss-bucket"; + entry.hasBuckets = true; bucketEl.style.setProperty("--pnx", String(group.vec[0])); bucketEl.style.setProperty("--pny", String(group.vec[1])); bucketEl.style.setProperty("--pnz", String(group.vec[2])); @@ -1350,11 +1397,13 @@ export function createPolyScene( voxelSource: parseResult.voxelSource, disposed: false, stableDom: stableDomOnUpdate, + hasBuckets: false, excludeFromAutoCenter: !!transformIn.excludeFromAutoCenter, castShadow: !!transformIn.castShadow, cameraCullGroups: [], cameraCullSignature: "", lightOverrideSignature: "clear", + stableTriangleColorFrame: 0, bakedRotation: (transformIn.rotation ? [...transformIn.rotation] : [0, 0, 0]) as Vec3, }; @@ -1372,11 +1421,7 @@ export function createPolyScene( recomputeAutoCenter(); recomputeShadowGround(); }, - setPolygons(polygons: Polygon[], options?: { - merge?: boolean; - stableDom?: boolean; - recomputeAutoCenter?: boolean; - }) { + setPolygons(polygons: Polygon[], options?: InternalSetPolygonsOptions) { polygonUpdateVersion++; mergeOnUpdate = options?.merge ?? mergeOnUpdate; stableDomOnUpdate = options?.stableDom ?? stableDomOnUpdate; @@ -1384,9 +1429,14 @@ export function createPolyScene( entry.voxelSource = undefined; entry.polygons = preparePolygons(polygons, mergeOnUpdate); handle.polygons = entry.polygons; - applyTransformOrigin(entry.polygons); const shouldRecomputeAutoCenter = options?.recomputeAutoCenter ?? true; - if (entry.stableDom && !hasDirectBucket(entry.wrapper)) { + const colorOnlyStableTriangleUpdate = + options?.stableTriangleUpdateMode === "color-only"; + entry.stableTriangleColorFrame++; + const shouldSkipTransformOrigin = + entry.stableDom && !shouldRecomputeAutoCenter; + if (!shouldSkipTransformOrigin) applyTransformOrigin(entry.polygons); + if (entry.stableDom && !entry.hasBuckets) { const renderOptions = { doc, directionalLight: currentOptions.directionalLight, @@ -1394,21 +1444,58 @@ export function createPolyScene( textureLighting: currentOptions.textureLighting, textureQuality: currentOptions.textureQuality, }; - const solidPaintDefaults = getSolidPaintDefaults(entry.polygons, renderOptions); - applySolidPaintVars(entry.wrapper, solidPaintDefaults); + const allStableTriangles = + entry.rendered.length === entry.polygons.length && + entry.rendered.every((item) => item.kind === "triangle"); + const optimizeStableTopology = + entry.stableDom && !shouldRecomputeAutoCenter; + const solidPaintDefaults = allStableTriangles || optimizeStableTopology + ? {} + : getSolidPaintDefaults(entry.polygons, renderOptions); + if (!allStableTriangles) applySolidPaintVars(entry.wrapper, solidPaintDefaults); + const stableTopologyOptions = { + ...renderOptions, + solidPaintDefaults, + optimizeStableTriangleStyle: true, + stableTriangleDebug: options?.stableTriangleDebug, + stableTriangleUpdateMode: options?.stableTriangleUpdateMode, + stableTriangleColorPolicy: options?.stableTriangleColorPolicy, + stableTriangleColorSteps: options?.stableTriangleColorSteps, + stableTriangleColorFreezeFrames: options?.stableTriangleColorFreezeFrames, + stableTriangleColorBudget: options?.stableTriangleColorBudget, + stableTriangleColorMaxAge: options?.stableTriangleColorMaxAge, + stableTriangleColorMaxStep: options?.stableTriangleColorMaxStep, + stableTriangleMatrixDecimals: options?.stableTriangleMatrixDecimals, + stableTriangleColorFrame: entry.stableTriangleColorFrame, + } as Parameters[2] & { + optimizeStableTriangleStyle: boolean; + stableTriangleDebug?: "transform-only" | "plan-only"; + stableTriangleUpdateMode?: "full" | "transform-only" | "color-only"; + stableTriangleColorPolicy?: "cadence" | "adaptive"; + stableTriangleColorSteps?: number; + stableTriangleColorFreezeFrames?: number; + stableTriangleColorBudget?: number; + stableTriangleColorMaxAge?: number; + stableTriangleColorMaxStep?: number; + stableTriangleMatrixDecimals?: number; + stableTriangleColorFrame?: number; + }; if ( updatePolygonsWithStableTopology( entry.rendered, entry.polygons, - { ...renderOptions, solidPaintDefaults }, + stableTopologyOptions, ) ) { - recomputeCameraCullGroups(entry); - syncMountedRenderedForCameraChange(entry, true); - if (shouldRecomputeAutoCenter) recomputeAutoCenter(); + if (!colorOnlyStableTriangleUpdate) { + recomputeCameraCullGroups(entry); + syncMountedRenderedForCameraChange(entry, true); + if (shouldRecomputeAutoCenter) recomputeAutoCenter(); + } return; } } + if (shouldSkipTransformOrigin) applyTransformOrigin(entry.polygons); renderEntry(entry); if (shouldRecomputeAutoCenter) recomputeAutoCenter(); }, @@ -1450,7 +1537,7 @@ export function createPolyScene( const css2 = buildMeshTransform(transform); wrapper.style.transform = css2 ?? ""; applyMeshLightVarOverride(entry, transform.rotation); - if (t.rotation !== undefined) syncMountedRenderedForCameraChange(entry); + if (t.rotation !== undefined) syncMountedRenderedForCameraChange(entry, true); if (entry.castShadow !== prevCastShadow) { emitShadowLeaves(entry); recomputeShadowGround(); diff --git a/packages/polycss/src/index.ts b/packages/polycss/src/index.ts index d62cf156..0ed78618 100644 --- a/packages/polycss/src/index.ts +++ b/packages/polycss/src/index.ts @@ -91,6 +91,14 @@ export type { TextureQuality, } from "./render/textureAtlas"; +// ── Render diagnostics ─────────────────────────────────────────── +export { collectPolyRenderStats } from "./render/renderStats"; +export type { + PolyRenderStats, + PolyRenderStatsOptions, + PolyRenderSurfaceLeafCounts, +} from "./render/renderStats"; + // ── Primitive shape factories ───────────────────────────────────── export { createPolyBox, diff --git a/packages/polycss/src/render/renderStats.test.ts b/packages/polycss/src/render/renderStats.test.ts new file mode 100644 index 00000000..b9c09936 --- /dev/null +++ b/packages/polycss/src/render/renderStats.test.ts @@ -0,0 +1,63 @@ +import { describe, expect, it } from "vitest"; +import { collectPolyRenderStats } from "./renderStats"; + +describe("collectPolyRenderStats", () => { + it("returns an empty snapshot for a missing root", () => { + expect(collectPolyRenderStats(null)).toEqual({ + polygonCount: 0, + mountedPolygonLeafCount: 0, + shadowLeafCount: 0, + surfaceLeafCounts: { quad: 0, clippedSolid: 0, atlas: 0, stableTriangle: 0 }, + bucketCount: 0, + }); + }); + + it("counts mounted polygon leaves, shadows, and buckets", () => { + const root = document.createElement("div"); + root.innerHTML = ` +
+ + + + + +
+
+ `; + + expect(collectPolyRenderStats(root, { polygonCount: 12 })).toEqual({ + polygonCount: 12, + mountedPolygonLeafCount: 6, + shadowLeafCount: 1, + surfaceLeafCounts: { quad: 2, clippedSolid: 1, atlas: 2, stableTriangle: 1 }, + bucketCount: 1, + }); + }); + + it("can scope counts to model subtrees", () => { + const root = document.createElement("div"); + root.innerHTML = ` +
+
+ `; + + expect(collectPolyRenderStats(root, { scopeSelector: ".dn-model-mesh" })).toEqual({ + polygonCount: 2, + mountedPolygonLeafCount: 2, + shadowLeafCount: 1, + surfaceLeafCounts: { quad: 1, clippedSolid: 0, atlas: 1, stableTriangle: 0 }, + bucketCount: 0, + }); + }); + + it("includes the root when it matches the scope selector", () => { + const root = document.createElement("div"); + root.className = "dn-model-mesh"; + root.innerHTML = ""; + + expect(collectPolyRenderStats(root, { scopeSelector: ".dn-model-mesh" })).toMatchObject({ + mountedPolygonLeafCount: 2, + surfaceLeafCounts: { quad: 1, clippedSolid: 1, atlas: 0, stableTriangle: 0 }, + }); + }); +}); diff --git a/packages/polycss/src/render/renderStats.ts b/packages/polycss/src/render/renderStats.ts new file mode 100644 index 00000000..88311177 --- /dev/null +++ b/packages/polycss/src/render/renderStats.ts @@ -0,0 +1,105 @@ +export interface PolyRenderSurfaceLeafCounts { + quad: number; + clippedSolid: number; + atlas: number; + stableTriangle: number; +} + +export interface PolyRenderStats { + polygonCount: number; + mountedPolygonLeafCount: number; + shadowLeafCount: number; + surfaceLeafCounts: PolyRenderSurfaceLeafCounts; + bucketCount: number; +} + +export interface PolyRenderStatsOptions { + polygonCount?: number; + /** + * Optional subtree selector for diagnostics that only want model leaves and + * not helpers/floors/gizmos sharing the same scene root. + */ + scopeSelector?: string; +} + +const ZERO_SURFACE_LEAF_COUNTS: PolyRenderSurfaceLeafCounts = { + quad: 0, + clippedSolid: 0, + atlas: 0, + stableTriangle: 0, +}; + +const EMPTY_POLY_RENDER_STATS: PolyRenderStats = { + polygonCount: 0, + mountedPolygonLeafCount: 0, + shadowLeafCount: 0, + surfaceLeafCounts: ZERO_SURFACE_LEAF_COUNTS, + bucketCount: 0, +}; + +function asOptions( + optionsOrPolygonCount?: number | PolyRenderStatsOptions, +): PolyRenderStatsOptions { + if (typeof optionsOrPolygonCount === "number") { + return { polygonCount: optionsOrPolygonCount }; + } + return optionsOrPolygonCount ?? {}; +} + +function queryCount(scope: ParentNode, selector: string): number { + return scope.querySelectorAll(selector).length; +} + +function matchesSelector(root: ParentNode, selector: string): boolean { + const candidate = root as ParentNode & { matches?: (selector: string) => boolean }; + return typeof candidate.matches === "function" && candidate.matches(selector); +} + +function collectScopes(root: ParentNode, selector: string | undefined): ParentNode[] { + if (!selector) return [root]; + const scopes: ParentNode[] = []; + if (matchesSelector(root, selector)) scopes.push(root); + scopes.push(...Array.from(root.querySelectorAll(selector))); + return scopes; +} + +export function collectPolyRenderStats( + root: ParentNode | null | undefined, + optionsOrPolygonCount?: number | PolyRenderStatsOptions, +): PolyRenderStats { + const options = asOptions(optionsOrPolygonCount); + if (!root) { + return { + ...EMPTY_POLY_RENDER_STATS, + surfaceLeafCounts: { ...ZERO_SURFACE_LEAF_COUNTS }, + polygonCount: options.polygonCount ?? 0, + }; + } + + const scopes = collectScopes(root, options.scopeSelector); + const surfaceLeafCounts: PolyRenderSurfaceLeafCounts = { ...ZERO_SURFACE_LEAF_COUNTS }; + let shadowLeafCount = 0; + let bucketCount = 0; + + for (const scope of scopes) { + surfaceLeafCounts.quad += queryCount(scope, "b"); + surfaceLeafCounts.clippedSolid += queryCount(scope, "i"); + surfaceLeafCounts.atlas += queryCount(scope, "s"); + surfaceLeafCounts.stableTriangle += queryCount(scope, "u"); + shadowLeafCount += queryCount(scope, "q"); + bucketCount += queryCount(scope, ".polycss-bucket"); + } + + const mountedPolygonLeafCount = + surfaceLeafCounts.quad + + surfaceLeafCounts.clippedSolid + + surfaceLeafCounts.atlas + + surfaceLeafCounts.stableTriangle; + return { + polygonCount: options.polygonCount ?? mountedPolygonLeafCount, + mountedPolygonLeafCount, + shadowLeafCount, + surfaceLeafCounts, + bucketCount, + }; +} diff --git a/packages/polycss/src/render/textureAtlas.ts b/packages/polycss/src/render/textureAtlas.ts index 1be407d6..325fae94 100644 --- a/packages/polycss/src/render/textureAtlas.ts +++ b/packages/polycss/src/render/textureAtlas.ts @@ -33,6 +33,7 @@ const AUTO_ATLAS_MAX_BITMAP_SIDE = 2048; const AUTO_ATLAS_MAX_DECODED_BYTES_MOBILE = 4 * 1024 * 1024; const AUTO_ATLAS_MAX_DECODED_BYTES_DESKTOP = 16 * 1024 * 1024; const AUTO_ATLAS_SCALE_GUARD = 0.995; +const COLOR_PARSE_CACHE_MAX = 512; export type TextureQuality = number | "auto"; @@ -47,6 +48,7 @@ export interface PolyRenderStrategiesOption { interface RGB { r: number; g: number; b: number; } interface RGBFactors { r: number; g: number; b: number; } +type PureColorParseResult = ReturnType; interface UvAffine { a: number; @@ -123,10 +125,46 @@ interface PackedAtlas { pages: PackedPage[]; } -interface SolidTrianglePlan { +interface SolidTriangleColorPlan { index: number; polygon: Polygon; + colorComputed: boolean; + bakedColor?: string; + bakedRgb?: RGB; + bakedAlpha?: number; + dynamicVars?: string; +} + +interface SolidTrianglePlan extends SolidTriangleColorPlan { styleText: string; + transformText: string; + basis: SolidTriangleBasis; +} + +interface SolidTriangleBasis { + a: number; + b: number; + c: number; +} + +interface SolidTriangleComputeOptions { + basis?: SolidTriangleBasis; + includeColor?: boolean; +} + +interface SolidTriangleElement extends HTMLElement { + __polycssSolidTriangleBasis?: SolidTriangleBasis; + __polycssSolidTriangleColor?: string; + __polycssSolidTriangleColorRgb?: RGB; + __polycssSolidTriangleColorAlpha?: number; + __polycssSolidTriangleColorFrame?: number; +} + +interface StableTriangleColorState { + updatesDisabled: boolean; + freezeFrames: number; + colorFrame: number; + maxStep: number; } export interface SolidPaintDefaults { @@ -205,6 +243,20 @@ export interface RenderTextureAtlasOptions { strategies?: PolyRenderStrategiesOption; } +interface InternalRenderTextureAtlasOptions extends RenderTextureAtlasOptions { + optimizeStableTriangleStyle?: boolean; + stableTriangleDebug?: "transform-only" | "plan-only"; + stableTriangleUpdateMode?: "full" | "transform-only" | "color-only"; + stableTriangleColorPolicy?: "cadence" | "adaptive"; + stableTriangleColorSteps?: number; + stableTriangleColorFreezeFrames?: number; + stableTriangleColorBudget?: number; + stableTriangleColorMaxAge?: number; + stableTriangleColorMaxStep?: number; + stableTriangleColorFrame?: number; + stableTriangleMatrixDecimals?: number; +} + export interface RenderedPoly { polygonIndex: number; element: HTMLElement; @@ -223,6 +275,7 @@ export interface RenderTextureAtlasAsyncResult extends RenderTextureAtlasResult } const TEXTURE_IMAGE_CACHE = new Map>(); +const PURE_COLOR_CACHE = new Map(); const ELEMENT_DATA_KEYS = new WeakMap(); const RECT_EPS = 1e-3; const BASIS_EPS = 1e-9; @@ -237,6 +290,7 @@ const SOLID_TRIANGLE_BLEED = 0.75; const DEFAULT_MATRIX_DECIMALS = 3; const DEFAULT_BORDER_SHAPE_DECIMALS = 2; const DEFAULT_ATLAS_CSS_DECIMALS = 4; +const DECIMAL_SCALES = [1, 10, 100, 1000, 10000, 100000, 1000000]; const SOLID_QUAD_CANONICAL_SIZE = 64; const SOLID_TRIANGLE_CANONICAL_SIZE = 64; const ATLAS_CANONICAL_SIZE_EXPLICIT = 64; @@ -316,17 +370,38 @@ function normalizeAtlasScale(scale: number | string | undefined): number { } function roundDecimal(value: number, decimals: number): string { - const next = value.toFixed(decimals).replace(/\.?0+$/, ""); - return Object.is(Number(next), -0) ? "0" : next; + if (Object.is(value, 0) || Object.is(value, -0)) return "0"; + if (value === 1) return "1"; + if (value === -1) return "-1"; + const scale = DECIMAL_SCALES[decimals] ?? 10 ** decimals; + const next = Math.round(value * scale) / scale; + if (Object.is(next, 0) || Object.is(next, -0)) return "0"; + return String(next); } function formatCssLength(value: number, decimals = DEFAULT_ATLAS_CSS_DECIMALS): string { const next = roundDecimal(value, decimals); - return Number(next) === 0 || Object.is(Number(next), -0) ? "0" : `${next}px`; + return next === "0" ? "0" : `${next}px`; } function formatMatrix3dValues(values: readonly number[], decimals = DEFAULT_MATRIX_DECIMALS): string { - return values.map((value) => roundDecimal(value, decimals)).join(","); + if (values.length === 0) return ""; + let text = roundDecimal(values[0], decimals); + for (let i = 1; i < values.length; i++) text += `,${roundDecimal(values[i], decimals)}`; + return text; +} + +function formatAffineMatrix3dColumns( + xCol: Vec3, + yCol: Vec3, + zCol: Vec3, + txCol: Vec3, + decimals = DEFAULT_MATRIX_DECIMALS, +): string { + return `${roundDecimal(xCol[0], decimals)},${roundDecimal(xCol[1], decimals)},${roundDecimal(xCol[2], decimals)},0,` + + `${roundDecimal(yCol[0], decimals)},${roundDecimal(yCol[1], decimals)},${roundDecimal(yCol[2], decimals)},0,` + + `${roundDecimal(zCol[0], decimals)},${roundDecimal(zCol[1], decimals)},${roundDecimal(zCol[2], decimals)},0,` + + `${roundDecimal(txCol[0], decimals)},${roundDecimal(txCol[1], decimals)},${roundDecimal(txCol[2], decimals)},1`; } function isConvexPolygonPoints(points: Array<[number, number]>): boolean { @@ -374,6 +449,29 @@ function intersect2DLines( return [a0[0] + t * rx, a0[1] + t * ry]; } +function intersect2DLinesRaw( + a0x: number, + a0y: number, + a1x: number, + a1y: number, + b0x: number, + b0y: number, + b1x: number, + b1y: number, +): Vec2 | null { + const rx = a1x - a0x; + const ry = a1y - a0y; + const sx = b1x - b0x; + const sy = b1y - b0y; + const det = rx * sy - ry * sx; + if (Math.abs(det) <= BASIS_EPS) return null; + + const qpx = b0x - a0x; + const qpy = b0y - a0y; + const t = (qpx * sy - qpy * sx) / det; + return [a0x + t * rx, a0y + t * ry]; +} + function offsetConvexPolygonPoints(points: number[], amount: number): number[] { if (points.length < 6 || points.length % 2 !== 0 || amount <= 0) return points; const q: Array<[number, number]> = []; @@ -423,6 +521,60 @@ function offsetConvexPolygonPoints(points: number[], amount: number): number[] { return expanded; } +function offsetTrianglePoints( + x0: number, + y0: number, + x1: number, + y1: number, + x2: number, + y2: number, + amount: number, +): number[] { + if (amount <= 0) return [x0, y0, x1, y1, x2, y2]; + const area = (x0 * y1 - y0 * x1 + x1 * y2 - y1 * x2 + x2 * y0 - y2 * x0) / 2; + if (Math.abs(area) <= BASIS_EPS) return expandClipPoints([x0, y0, x1, y1, x2, y2], amount); + + const outwardSign = area > 0 ? 1 : -1; + const line = (ax: number, ay: number, bx: number, by: number) => { + const dx = bx - ax; + const dy = by - ay; + const length = Math.hypot(dx, dy); + if (length <= BASIS_EPS) return null; + const ox = outwardSign * (dy / length) * amount; + const oy = outwardSign * (-dx / length) * amount; + return { + ax: ax + ox, + ay: ay + oy, + bx: bx + ox, + by: by + oy, + }; + }; + + const l0 = line(x0, y0, x1, y1); + const l1 = line(x1, y1, x2, y2); + const l2 = line(x2, y2, x0, y0); + if (!l0 || !l1 || !l2) return expandClipPoints([x0, y0, x1, y1, x2, y2], amount); + + const p0 = intersect2DLinesRaw(l2.ax, l2.ay, l2.bx, l2.by, l0.ax, l0.ay, l0.bx, l0.by); + const p1 = intersect2DLinesRaw(l0.ax, l0.ay, l0.bx, l0.by, l1.ax, l1.ay, l1.bx, l1.by); + const p2 = intersect2DLinesRaw(l1.ax, l1.ay, l1.bx, l1.by, l2.ax, l2.ay, l2.bx, l2.by); + if (!p0 || !p1 || !p2) return expandClipPoints([x0, y0, x1, y1, x2, y2], amount); + + const maxMiter = Math.max(2, amount * 4); + const clamp = (px: number, py: number, ox: number, oy: number): Vec2 => { + const dx = px - ox; + const dy = py - oy; + const miter = Math.hypot(dx, dy); + return miter > maxMiter + ? [ox + (dx / miter) * maxMiter, oy + (dy / miter) * maxMiter] + : [px, py]; + }; + const c0 = clamp(p0[0], p0[1], x0, y0); + const c1 = clamp(p1[0], p1[1], x1, y1); + const c2 = clamp(p2[0], p2[1], x2, y2); + return [c0[0], c0[1], c1[0], c1[1], c2[0], c2[1]]; +} + function finiteNumber(value: unknown, fallback: number): number { return typeof value === "number" && Number.isFinite(value) ? value : fallback; } @@ -780,7 +932,7 @@ function parseHex(hex: string): RGB { // Tolerate any CSS color string the renderer hands us — hex, rgb(), // or rgba(). createTransformControls passes rgba() colors to fade // arrows on hover/drag. - const parsed = parsePureColor(hex); + const parsed = cachedParsePureColor(hex); if (!parsed) return { r: 255, g: 255, b: 255 }; return { r: parsed.rgb[0], g: parsed.rgb[1], b: parsed.rgb[2] }; } @@ -791,7 +943,7 @@ function rgbKey({ r, g, b }: RGB): string { /** Returns the parsed alpha for a color string (1.0 default). */ function parseAlpha(input: string): number { - return parsePureColor(input)?.alpha ?? 1; + return cachedParsePureColor(input)?.alpha ?? 1; } function rgbToHex({ r, g, b }: RGB): string { @@ -825,6 +977,17 @@ function removeInlineStyleProperty(el: HTMLElement, property: string): void { else el.removeAttribute("style"); } +function cachedParsePureColor(input: string): PureColorParseResult { + if (PURE_COLOR_CACHE.has(input)) { + const cached = PURE_COLOR_CACHE.get(input); + return cached === undefined ? null : cached; + } + const parsed = parsePureColor(input); + if (PURE_COLOR_CACHE.size >= COLOR_PARSE_CACHE_MAX) PURE_COLOR_CACHE.clear(); + PURE_COLOR_CACHE.set(input, parsed); + return parsed; +} + function shadePolygon( baseColor: string, directScale: number, @@ -850,6 +1013,88 @@ function shadePolygon( : rgbToHex({ r, g, b }); } +function quantizeCssColor(input: string, steps: number): string { + if (!Number.isFinite(steps) || steps <= 1) return input; + const parsed = cachedParsePureColor(input); + if (!parsed) return input; + const channelStep = 255 / Math.max(1, Math.round(steps) - 1); + const quantize = (value: number) => + Math.max(0, Math.min(255, Math.round(Math.round(value / channelStep) * channelStep))); + const rgb = { + r: quantize(parsed.rgb[0]), + g: quantize(parsed.rgb[1]), + b: quantize(parsed.rgb[2]), + }; + return parsed.alpha < 1 + ? `rgba(${rgb.r}, ${rgb.g}, ${rgb.b}, ${parsed.alpha})` + : rgbToHex(rgb); +} + +function rgbEqual(a: RGB | undefined, b: RGB | undefined): boolean { + return !!a && !!b && a.r === b.r && a.g === b.g && a.b === b.b; +} + +function stepRgbToward(current: RGB, target: RGB, maxStep: number): RGB { + const step = (from: number, to: number) => { + const delta = to - from; + if (Math.abs(delta) <= maxStep) return to; + return from + Math.sign(delta) * maxStep; + }; + return { + r: step(current.r, target.r), + g: step(current.g, target.g), + b: step(current.b, target.b), + }; +} + +function rgbToCss(rgb: RGB, alpha = 1): string { + return alpha < 1 + ? `rgba(${rgb.r}, ${rgb.g}, ${rgb.b}, ${alpha})` + : rgbToHex(rgb); +} + +function colorErrorScore(current: string | undefined, next: string): number { + if (current === undefined) return Number.POSITIVE_INFINITY; + if (current === next) return 0; + const currentColor = cachedParsePureColor(current); + const nextColor = cachedParsePureColor(next); + if (!currentColor || !nextColor) return 1; + const dr = currentColor.rgb[0] - nextColor.rgb[0]; + const dg = currentColor.rgb[1] - nextColor.rgb[1]; + const db = currentColor.rgb[2] - nextColor.rgb[2]; + const da = (currentColor.alpha - nextColor.alpha) * 255; + return Math.sqrt(dr * dr + dg * dg + db * db + da * da) / 510; +} + +function stableTriangleColorState(options: InternalRenderTextureAtlasOptions): StableTriangleColorState { + return { + updatesDisabled: options.stableTriangleColorFreezeFrames === 0, + freezeFrames: Math.max(1, Math.floor(options.stableTriangleColorFreezeFrames ?? 1)), + colorFrame: Math.max(0, Math.floor(options.stableTriangleColorFrame ?? 0)), + maxStep: Math.max(0, Math.floor(options.stableTriangleColorMaxStep ?? 0)), + }; +} + +function stableTriangleColorAllowed(index: number, state: StableTriangleColorState): boolean { + return !state.updatesDisabled && + (state.freezeFrames <= 1 || (state.colorFrame + index) % state.freezeFrames === 0); +} + +function shouldComputeStableTriangleColor( + element: HTMLElement, + index: number, + optimizeTriangleStyle: boolean, + stableTriangleDebug: InternalRenderTextureAtlasOptions["stableTriangleDebug"], + stableTriangleColorPolicy: InternalRenderTextureAtlasOptions["stableTriangleColorPolicy"], + colorState: StableTriangleColorState, +): boolean { + if (!optimizeTriangleStyle) return true; + if (stableTriangleDebug === "plan-only" || stableTriangleDebug === "transform-only") return false; + if (stableTriangleColorPolicy === "adaptive") return true; + if ((element as SolidTriangleElement).__polycssSolidTriangleColor === undefined) return true; + return stableTriangleColorAllowed(index, colorState); +} + function textureTintFactors( directScale: number, lightColor: string, @@ -1662,45 +1907,187 @@ function computeTextureAtlasPlan( }; } +function computeSolidTriangleColorPlanFromNormal( + polygon: Polygon, + index: number, + normal: Vec3, + options: RenderTextureAtlasOptions, + includeColor: boolean, +): SolidTriangleColorPlan { + const internalOptions = options as InternalRenderTextureAtlasOptions; + let bakedColorValue = ""; + let bakedRgb: RGB | undefined; + let bakedAlpha: number | undefined; + let dynamicVars = ""; + if (includeColor) { + const directionalCfg = options.directionalLight; + const ambientCfg = options.ambientLight; + const lightDir = directionalCfg?.direction ?? DEFAULT_LIGHT_DIR; + const lightColor = directionalCfg?.color ?? DEFAULT_LIGHT_COLOR; + const lightIntensity = Math.max(0, directionalCfg?.intensity ?? DEFAULT_LIGHT_INTENSITY); + const ambientColor = ambientCfg?.color ?? DEFAULT_AMBIENT_COLOR; + const ambientIntensity = Math.max(0, ambientCfg?.intensity ?? DEFAULT_AMBIENT_INTENSITY); + const lLen = Math.hypot(lightDir[0], lightDir[1], lightDir[2]) || 1; + const lx = lightDir[0] / lLen, ly = lightDir[1] / lLen, lz = lightDir[2] / lLen; + const directScale = lightIntensity * Math.max(0, normal[0] * lx + normal[1] * ly + normal[2] * lz); + const shadedColorRaw = shadePolygon(polygon.color ?? "#cccccc", directScale, lightColor, ambientColor, ambientIntensity); + const textureLighting = options.textureLighting ?? "baked"; + const shadedColor = textureLighting === "baked" && internalOptions.stableTriangleColorSteps + ? quantizeCssColor(shadedColorRaw, internalOptions.stableTriangleColorSteps) + : shadedColorRaw; + const base = parseHex(polygon.color ?? "#cccccc"); + const useDefaultPaint = shadedColor === options.solidPaintDefaults?.paintColor; + const useDefaultDynamicColor = + textureLighting === "dynamic" && rgbKey(base) === options.solidPaintDefaults?.dynamicColorKey; + bakedColorValue = textureLighting === "dynamic" || useDefaultPaint + ? "" + : shadedColor; + bakedRgb = bakedColorValue ? parseHex(bakedColorValue) : undefined; + bakedAlpha = bakedColorValue ? parseAlpha(bakedColorValue) : undefined; + dynamicVars = textureLighting === "dynamic" + ? `--pnx:${normal[0].toFixed(4)};--pny:${normal[1].toFixed(4)};--pnz:${normal[2].toFixed(4)};` + + (useDefaultDynamicColor + ? "" + : `--psr:${(base.r / 255).toFixed(4)};--psg:${(base.g / 255).toFixed(4)};--psb:${(base.b / 255).toFixed(4)};`) + : ""; + } + return { + index, + polygon, + colorComputed: includeColor, + bakedColor: bakedColorValue || undefined, + bakedRgb, + bakedAlpha, + dynamicVars, + }; +} + +function computeSolidTriangleColorPlan( + polygon: Polygon, + index: number, + options: RenderTextureAtlasOptions, +): SolidTriangleColorPlan | null { + if (polygon.texture || polygon.vertices.length !== 3) return null; + const tile = options.tileSize ?? DEFAULT_TILE; + const elev = options.layerElevation ?? tile; + const v0 = polygon.vertices[0]; + const v1 = polygon.vertices[1]; + const v2 = polygon.vertices[2]; + const p0: Vec3 = [v0[1] * tile, v0[0] * tile, v0[2] * elev]; + const p1: Vec3 = [v1[1] * tile, v1[0] * tile, v1[2] * elev]; + const p2: Vec3 = [v2[1] * tile, v2[0] * tile, v2[2] * elev]; + const e10x = p1[0] - p0[0]; + const e10y = p1[1] - p0[1]; + const e10z = p1[2] - p0[2]; + const e20x = p2[0] - p0[0]; + const e20y = p2[1] - p0[1]; + const e20z = p2[2] - p0[2]; + let nx = -(e10y * e20z - e10z * e20y); + let ny = -(e10z * e20x - e10x * e20z); + let nz = -(e10x * e20y - e10y * e20x); + const nLen = Math.hypot(nx, ny, nz); + if (nLen <= BASIS_EPS) return null; + nx /= nLen; + ny /= nLen; + nz /= nLen; + return computeSolidTriangleColorPlanFromNormal(polygon, index, [nx, ny, nz], options, true); +} + function computeSolidTrianglePlan( polygon: Polygon, index: number, options: RenderTextureAtlasOptions, + computeOptions: SolidTriangleComputeOptions = {}, ): SolidTrianglePlan | null { if (polygon.texture || polygon.vertices.length !== 3) return null; + const internalOptions = options as InternalRenderTextureAtlasOptions; const tile = options.tileSize ?? DEFAULT_TILE; const elev = options.layerElevation ?? tile; - const pts = cssPoints(polygon.vertices, tile, elev); - const normal = computeSurfaceNormal(pts); - if (!normal) return null; + const v0 = polygon.vertices[0]; + const v1 = polygon.vertices[1]; + const v2 = polygon.vertices[2]; + const pts: Vec3[] = [ + [v0[1] * tile, v0[0] * tile, v0[2] * elev], + [v1[1] * tile, v1[0] * tile, v1[2] * elev], + [v2[1] * tile, v2[0] * tile, v2[2] * elev], + ]; + const e10x = pts[1][0] - pts[0][0]; + const e10y = pts[1][1] - pts[0][1]; + const e10z = pts[1][2] - pts[0][2]; + const e20x = pts[2][0] - pts[0][0]; + const e20y = pts[2][1] - pts[0][1]; + const e20z = pts[2][2] - pts[0][2]; + let nx = -(e10y * e20z - e10z * e20y); + let ny = -(e10z * e20x - e10x * e20z); + let nz = -(e10x * e20y - e10y * e20x); + const nLen = Math.hypot(nx, ny, nz); + if (nLen <= BASIS_EPS) return null; + nx /= nLen; + ny /= nLen; + nz /= nLen; + const normal: Vec3 = [nx, ny, nz]; + + let basisHint = computeOptions.basis; + let a = basisHint?.a ?? 0; + let b = basisHint?.b ?? 1; + let c = basisHint?.c ?? 2; + if ( + a < 0 || a > 2 || + b < 0 || b > 2 || + c < 0 || c > 2 || + a === b || a === c || b === c + ) { + basisHint = undefined; + a = 0; + b = 1; + c = 2; + } + const retryWithoutBasis = (): SolidTrianglePlan | null => + basisHint + ? computeSolidTrianglePlan(polygon, index, options, { + ...computeOptions, + basis: undefined, + }) + : null; - const edges = [ - { a: 0, b: 1, c: 2 }, - { a: 1, b: 2, c: 0 }, - { a: 2, b: 0, c: 1 }, - ].map((edge) => { - const av = pts[edge.a]; - const bv = pts[edge.b]; - return { - ...edge, - length: Math.hypot(bv[0] - av[0], bv[1] - av[1], bv[2] - av[2]), - }; - }).sort((a, b) => b.length - a.length); + if (!basisHint) { + const len01Sq = e10x * e10x + e10y * e10y + e10z * e10z; + const e21x = pts[2][0] - pts[1][0]; + const e21y = pts[2][1] - pts[1][1]; + const e21z = pts[2][2] - pts[1][2]; + const e02x = pts[0][0] - pts[2][0]; + const e02y = pts[0][1] - pts[2][1]; + const e02z = pts[0][2] - pts[2][2]; + const len12Sq = e21x * e21x + e21y * e21y + e21z * e21z; + const len20Sq = e02x * e02x + e02y * e02y + e02z * e02z; + let baseLengthSq = len01Sq; + if (len12Sq > baseLengthSq) { + a = 1; + b = 2; + c = 0; + baseLengthSq = len12Sq; + } + if (len20Sq > baseLengthSq) { + a = 2; + b = 0; + c = 1; + } + } - let a = edges[0].a; - let b = edges[0].b; - const c = edges[0].c; let av = pts[a]; let bv = pts[b]; const cv = pts[c]; - let baseLength = edges[0].length; - if (baseLength <= BASIS_EPS) return null; + let baseDx = bv[0] - av[0]; + let baseDy = bv[1] - av[1]; + let baseDz = bv[2] - av[2]; + let baseLength = Math.hypot(baseDx, baseDy, baseDz); + if (baseLength <= BASIS_EPS) return retryWithoutBasis(); let xAxis: Vec3 = [ - (bv[0] - av[0]) / baseLength, - (bv[1] - av[1]) / baseLength, - (bv[2] - av[2]) / baseLength, + baseDx / baseLength, + baseDy / baseLength, + baseDz / baseLength, ]; const ac: Vec3 = [cv[0] - av[0], cv[1] - av[1], cv[2] - av[2]]; let apexX = dotVec(ac, xAxis); @@ -1714,8 +2101,8 @@ function computeSolidTrianglePlan( foot[1] - cv[1], foot[2] - cv[2], ]; - const height = Math.hypot(yAxisRaw[0], yAxisRaw[1], yAxisRaw[2]); - if (height <= BASIS_EPS) return null; + let height = Math.hypot(yAxisRaw[0], yAxisRaw[1], yAxisRaw[2]); + if (height <= BASIS_EPS) return retryWithoutBasis(); let yAxis: Vec3 = [ yAxisRaw[0] / height, yAxisRaw[1] / height, @@ -1728,12 +2115,15 @@ function computeSolidTrianglePlan( a = nextA; av = pts[a]; bv = pts[b]; - baseLength = Math.hypot(bv[0] - av[0], bv[1] - av[1], bv[2] - av[2]); - if (baseLength <= BASIS_EPS) return null; + baseDx = bv[0] - av[0]; + baseDy = bv[1] - av[1]; + baseDz = bv[2] - av[2]; + baseLength = Math.hypot(baseDx, baseDy, baseDz); + if (baseLength <= BASIS_EPS) return retryWithoutBasis(); xAxis = [ - (bv[0] - av[0]) / baseLength, - (bv[1] - av[1]) / baseLength, - (bv[2] - av[2]) / baseLength, + baseDx / baseLength, + baseDy / baseLength, + baseDz / baseLength, ]; const nextAc: Vec3 = [cv[0] - av[0], cv[1] - av[1], cv[2] - av[2]]; apexX = dotVec(nextAc, xAxis); @@ -1747,22 +2137,23 @@ function computeSolidTrianglePlan( foot[1] - cv[1], foot[2] - cv[2], ]; - const nextHeight = Math.hypot(yAxisRaw[0], yAxisRaw[1], yAxisRaw[2]); - if (nextHeight <= BASIS_EPS) return null; + height = Math.hypot(yAxisRaw[0], yAxisRaw[1], yAxisRaw[2]); + if (height <= BASIS_EPS) return retryWithoutBasis(); yAxis = [ - yAxisRaw[0] / nextHeight, - yAxisRaw[1] / nextHeight, - yAxisRaw[2] / nextHeight, + yAxisRaw[0] / height, + yAxisRaw[1] / height, + yAxisRaw[2] / height, ]; } const left = Math.max(0, Math.min(baseLength, apexX)); const right = Math.max(0, baseLength - left); - const expanded = offsetConvexPolygonPoints([ + const expanded = offsetTrianglePoints( left, 0, 0, height, left + right, height, - ], SOLID_TRIANGLE_BLEED); + SOLID_TRIANGLE_BLEED, + ); const apex2: Vec2 = [expanded[0], expanded[1]]; const baseLeft2: Vec2 = [expanded[2], expanded[3]]; const baseRight2: Vec2 = [expanded[4], expanded[5]]; @@ -1776,41 +2167,39 @@ function computeSolidTrianglePlan( heightPx <= BASIS_EPS || !Number.isFinite(leftPx + rightPx + heightPx) ) { - return null; + return retryWithoutBasis(); } - const directionalCfg = options.directionalLight; - const ambientCfg = options.ambientLight; - const lightDir = directionalCfg?.direction ?? DEFAULT_LIGHT_DIR; - const lightColor = directionalCfg?.color ?? DEFAULT_LIGHT_COLOR; - const lightIntensity = Math.max(0, directionalCfg?.intensity ?? DEFAULT_LIGHT_INTENSITY); - const ambientColor = ambientCfg?.color ?? DEFAULT_AMBIENT_COLOR; - const ambientIntensity = Math.max(0, ambientCfg?.intensity ?? DEFAULT_AMBIENT_INTENSITY); - const lLen = Math.hypot(lightDir[0], lightDir[1], lightDir[2]) || 1; - const lx = lightDir[0] / lLen, ly = lightDir[1] / lLen, lz = lightDir[2] / lLen; - const directScale = lightIntensity * Math.max(0, normal[0] * lx + normal[1] * ly + normal[2] * lz); - const shadedColor = shadePolygon(polygon.color ?? "#cccccc", directScale, lightColor, ambientColor, ambientIntensity); - const textureLighting = options.textureLighting ?? "baked"; - const base = parseHex(polygon.color ?? "#cccccc"); - const useDefaultPaint = shadedColor === options.solidPaintDefaults?.paintColor; - const useDefaultDynamicColor = - textureLighting === "dynamic" && rgbKey(base) === options.solidPaintDefaults?.dynamicColorKey; - const bakedColor = textureLighting === "dynamic" || useDefaultPaint - ? "" - : `color:${shadedColor};`; - const dynamicVars = textureLighting === "dynamic" - ? `--pnx:${normal[0].toFixed(4)};--pny:${normal[1].toFixed(4)};--pnz:${normal[2].toFixed(4)};` + - (useDefaultDynamicColor - ? "" - : `--psr:${(base.r / 255).toFixed(4)};--psg:${(base.g / 255).toFixed(4)};--psb:${(base.b / 255).toFixed(4)};`) - : ""; - const worldPoint = ([x, y]: Vec2): Vec3 => [ - cv[0] + (x - left) * xAxis[0] + y * yAxis[0], - cv[1] + (x - left) * xAxis[1] + y * yAxis[1], - cv[2] + (x - left) * xAxis[2] + y * yAxis[2], + const colorPlan = computeSolidTriangleColorPlanFromNormal( + polygon, + index, + normal, + options, + computeOptions.includeColor ?? true, + ); + const bakedColor = colorPlan.bakedColor ? `color:${colorPlan.bakedColor};` : ""; + const dynamicVars = colorPlan.dynamicVars ?? ""; + const cv0 = cv[0]; + const cv1 = cv[1]; + const cv2 = cv[2]; + const apexOffsetX = apex2[0] - left; + const apexY = apex2[1]; + const baseLeftOffsetX = baseLeft2[0] - left; + const baseRightOffsetX = baseRight2[0] - left; + const apex: Vec3 = [ + cv0 + apexOffsetX * xAxis[0] + apexY * yAxis[0], + cv1 + apexOffsetX * xAxis[1] + apexY * yAxis[1], + cv2 + apexOffsetX * xAxis[2] + apexY * yAxis[2], + ]; + const baseLeft: Vec3 = [ + cv0 + baseLeftOffsetX * xAxis[0] + baseY * yAxis[0], + cv1 + baseLeftOffsetX * xAxis[1] + baseY * yAxis[1], + cv2 + baseLeftOffsetX * xAxis[2] + baseY * yAxis[2], + ]; + const baseRight: Vec3 = [ + cv0 + baseRightOffsetX * xAxis[0] + baseY * yAxis[0], + cv1 + baseRightOffsetX * xAxis[1] + baseY * yAxis[1], + cv2 + baseRightOffsetX * xAxis[2] + baseY * yAxis[2], ]; - const apex = worldPoint(apex2); - const baseLeft = worldPoint([baseLeft2[0], baseY]); - const baseRight = worldPoint([baseRight2[0], baseY]); const halfBase = SOLID_TRIANGLE_CANONICAL_SIZE / 2; const xCol: Vec3 = [ (baseRight[0] - baseLeft[0]) / SOLID_TRIANGLE_CANONICAL_SIZE, @@ -1827,20 +2216,31 @@ function computeSolidTrianglePlan( (baseLeft[1] - txCol[1]) / SOLID_TRIANGLE_CANONICAL_SIZE, (baseLeft[2] - txCol[2]) / SOLID_TRIANGLE_CANONICAL_SIZE, ]; - const canonicalMatrix = formatMatrix3dValues([ - xCol[0], xCol[1], xCol[2], 0, - yCol[0], yCol[1], yCol[2], 0, - normal[0], normal[1], normal[2], 0, - txCol[0], txCol[1], txCol[2], 1, - ]); - const styleText = `transform:matrix3d(${canonicalMatrix});` + - bakedColor + - dynamicVars; + const matrixDecimals = Math.max( + 0, + Math.min(6, Math.floor(internalOptions.stableTriangleMatrixDecimals ?? DEFAULT_MATRIX_DECIMALS)), + ); + const canonicalMatrix = formatAffineMatrix3dColumns(xCol, yCol, normal, txCol, matrixDecimals); + const transformText = `matrix3d(${canonicalMatrix})`; + const textureLighting = options.textureLighting ?? "baked"; + const optimizeStyleText = + internalOptions.optimizeStableTriangleStyle === true && + textureLighting === "baked"; + const styleText = optimizeStyleText + ? "" + : `transform:${transformText};` + bakedColor + dynamicVars; return { index, polygon, styleText, + transformText, + basis: { a, b, c }, + colorComputed: colorPlan.colorComputed, + bakedColor: colorPlan.bakedColor, + bakedRgb: colorPlan.bakedRgb, + bakedAlpha: colorPlan.bakedAlpha, + dynamicVars: colorPlan.dynamicVars, }; } @@ -2418,13 +2818,19 @@ function formatBorderShapeElementStyle(entry: TextureAtlasPlan): string { ].join(";"); } -// Stable topology can reuse the original atlas raster: keep the element's -// local 2D texture space fixed, and solve the new matrix from that space to -// the updated 3D triangle. -function stableMatrixFromPlan( +interface StablePlanBasis { + normal: Vec3; + xAxis: Vec3; + yAxis: Vec3; + tx: number; + ty: number; + tz: number; +} + +function stableBasisFromPlan( source: TextureAtlasPlan, polygon: Polygon, -): { matrix: string; normal: Vec3 } | null { +): StablePlanBasis | null { if (source.screenPts.length < 6 || polygon.vertices.length < 3) return null; const tile = source.tileSize; @@ -2466,6 +2872,27 @@ function stableMatrixFromPlan( const ty = p0[1] - xAxis[1] * sx0 - yAxis[1] * sy0; const tz = p0[2] - xAxis[2] * sx0 - yAxis[2] * sy0; + return { + normal, + xAxis, + yAxis, + tx, + ty, + tz, + }; +} + +// Stable topology can reuse the original atlas raster: keep the element's +// local 2D texture space fixed, and solve the new matrix from that space to +// the updated 3D triangle. +function stableMatrixFromPlan( + source: TextureAtlasPlan, + polygon: Polygon, +): { matrix: string; normal: Vec3 } | null { + const basis = stableBasisFromPlan(source, polygon); + if (!basis) return null; + const { normal, xAxis, yAxis, tx, ty, tz } = basis; + return { normal, matrix: formatMatrix3dValues([ @@ -2483,6 +2910,27 @@ function stableMatrixFromPlan( }; } +function stableProjectiveMatrixFromPlan( + source: TextureAtlasPlan, + polygon: Polygon, + guards: ProjectiveQuadGuardSettings, +): { matrix: string; normal: Vec3 } | null { + if (source.screenPts.length !== 8 || polygon.vertices.length !== 4) return null; + const basis = stableBasisFromPlan(source, polygon); + if (!basis) return null; + const matrix = computeProjectiveQuadMatrix( + source.screenPts, + basis.xAxis, + basis.yAxis, + basis.normal, + basis.tx, + basis.ty, + basis.tz, + guards, + ); + return matrix ? { matrix, normal: basis.normal } : null; +} + function updateAtlasElementWithStablePlan( el: HTMLElement, source: TextureAtlasPlan, @@ -2801,6 +3249,32 @@ function applySolidPaint( } } +function shadedSolidPlanForNormal( + source: TextureAtlasPlan, + polygon: Polygon, + normal: Vec3, + textureLighting: PolyTextureLightingMode, + options: RenderTextureAtlasOptions, +): TextureAtlasPlan { + if (textureLighting !== "baked") return { ...source, polygon, normal }; + const directionalCfg = options.directionalLight; + const ambientCfg = options.ambientLight; + const lightDir = directionalCfg?.direction ?? DEFAULT_LIGHT_DIR; + const lightColor = directionalCfg?.color ?? DEFAULT_LIGHT_COLOR; + const lightIntensity = Math.max(0, directionalCfg?.intensity ?? DEFAULT_LIGHT_INTENSITY); + const ambientColor = ambientCfg?.color ?? DEFAULT_AMBIENT_COLOR; + const ambientIntensity = Math.max(0, ambientCfg?.intensity ?? DEFAULT_AMBIENT_INTENSITY); + const lLen = Math.hypot(lightDir[0], lightDir[1], lightDir[2]) || 1; + const lx = lightDir[0] / lLen, ly = lightDir[1] / lLen, lz = lightDir[2] / lLen; + const directScale = lightIntensity * Math.max(0, normal[0] * lx + normal[1] * ly + normal[2] * lz); + return { + ...source, + polygon, + normal, + shadedColor: shadePolygon(polygon.color ?? "#cccccc", directScale, lightColor, ambientColor, ambientIntensity), + }; +} + function createSolidElement( entry: TextureAtlasPlan, textureLighting: PolyTextureLightingMode, @@ -2843,6 +3317,39 @@ function createProjectiveSolidElement( return el; } +function updateSolidElementWithStablePlan( + el: HTMLElement, + source: TextureAtlasPlan, + polygon: Polygon, + textureLighting: PolyTextureLightingMode, + options: RenderTextureAtlasOptions, + guards: ProjectiveQuadGuardSettings, + solidPaintDefaults?: SolidPaintDefaults, +): boolean { + const next = source.projectiveMatrix + ? stableProjectiveMatrixFromPlan(source, polygon, guards) + : stableMatrixFromPlan(source, polygon); + if (!next) return false; + const entry = shadedSolidPlanForNormal(source, polygon, next.normal, textureLighting, options); + el.style.visibility = ""; + el.style.transform = `matrix3d(${next.matrix})`; + applySolidPaint(el, entry, textureLighting, solidPaintDefaults); + applyPolygonDataAttrs(el, entry.polygon); + return true; +} + +function updateBorderShapeElementWithStablePlan( + el: HTMLElement, + entry: TextureAtlasPlan, + textureLighting: PolyTextureLightingMode, + solidPaintDefaults?: SolidPaintDefaults, +): void { + el.style.visibility = ""; + el.setAttribute("style", formatBorderShapeElementStyle(entry)); + applySolidPaint(el, entry, textureLighting, solidPaintDefaults); + applyPolygonDataAttrs(el, entry.polygon); +} + function createAtlasElement( entry: PackedTextureAtlasEntry, textureLighting: PolyTextureLightingMode, @@ -3088,17 +3595,6 @@ export async function renderPolygonsWithTextureAtlasAsync( }; } -function computeStableSolidTriangles( - polygons: Polygon[], - options: RenderTextureAtlasOptions, -): SolidTrianglePlan[] | null { - const plans = polygons.map((polygon, index) => - computeSolidTrianglePlan(polygon, index, options) - ); - if (plans.some((plan) => !plan)) return null; - return plans as SolidTrianglePlan[]; -} - function clearAtlasImageStyles(el: HTMLElement): void { el.style.backgroundImage = ""; el.style.backgroundPosition = ""; @@ -3119,11 +3615,158 @@ function applySolidTriangleElement( entry: SolidTrianglePlan, ): void { el.setAttribute("style", entry.styleText); + const triangleEl = el as SolidTriangleElement; + triangleEl.__polycssSolidTriangleBasis = entry.basis; + if (entry.colorComputed) { + triangleEl.__polycssSolidTriangleColor = entry.bakedColor ?? ""; + triangleEl.__polycssSolidTriangleColorRgb = entry.bakedRgb; + triangleEl.__polycssSolidTriangleColorAlpha = entry.bakedAlpha; + } + triangleEl.__polycssSolidTriangleColorFrame = undefined; + if (entry.polygon.data || ELEMENT_DATA_KEYS.get(el)?.length) { + applyPolygonDataAttrs(el, entry.polygon); + } +} + +function applySolidTriangleElementColor( + el: HTMLElement, + entry: SolidTriangleColorPlan, + colorState: StableTriangleColorState, + colorUpdateAllowed?: boolean, +): void { + if (!entry.colorComputed) { + if (entry.polygon.data || ELEMENT_DATA_KEYS.get(el)?.length) { + applyPolygonDataAttrs(el, entry.polygon); + } + return; + } + const triangleEl = el as SolidTriangleElement; + const nextColor = entry.bakedColor ?? ""; + const nextRgb = entry.bakedRgb; + const nextAlpha = entry.bakedAlpha ?? 1; + const currentColor = triangleEl.__polycssSolidTriangleColor; + const currentRgb = triangleEl.__polycssSolidTriangleColorRgb; + const currentAlpha = triangleEl.__polycssSolidTriangleColorAlpha ?? 1; + const shouldUpdateColor = colorUpdateAllowed ?? stableTriangleColorAllowed(entry.index, colorState); + if (!colorState.updatesDisabled && currentColor !== nextColor && (currentColor === undefined || shouldUpdateColor)) { + let writeColor = nextColor; + let writeRgb = nextRgb; + if ( + colorState.maxStep > 0 && + currentRgb && + nextRgb && + currentAlpha === nextAlpha + ) { + const steppedRgb = stepRgbToward(currentRgb, nextRgb, colorState.maxStep); + writeRgb = steppedRgb; + writeColor = rgbToCss(steppedRgb, nextAlpha); + } + if (writeColor !== currentColor || !rgbEqual(writeRgb, currentRgb)) { + el.style.color = writeColor; + } + triangleEl.__polycssSolidTriangleColor = writeColor; + triangleEl.__polycssSolidTriangleColorRgb = writeRgb; + triangleEl.__polycssSolidTriangleColorAlpha = nextAlpha; + triangleEl.__polycssSolidTriangleColorFrame = colorState.colorFrame; + } if (entry.polygon.data || ELEMENT_DATA_KEYS.get(el)?.length) { applyPolygonDataAttrs(el, entry.polygon); } } +function applySolidTriangleElementFast( + el: HTMLElement, + entry: SolidTrianglePlan, + colorState: StableTriangleColorState, + colorUpdateAllowed?: boolean, +): void { + const triangleEl = el as SolidTriangleElement; + triangleEl.__polycssSolidTriangleBasis = entry.basis; + if (el.style.visibility) el.style.visibility = ""; + el.style.transform = entry.transformText; + applySolidTriangleElementColor(el, entry, colorState, colorUpdateAllowed); +} + +function applySolidTriangleElementColorOnly( + el: HTMLElement, + entry: SolidTriangleColorPlan, + colorState: StableTriangleColorState, + colorUpdateAllowed?: boolean, +): void { + if (el.style.visibility) el.style.visibility = ""; + applySolidTriangleElementColor(el, entry, colorState, colorUpdateAllowed); +} + +function applySolidTriangleElementTransformOnly( + el: HTMLElement, + entry: SolidTrianglePlan, +): void { + const triangleEl = el as SolidTriangleElement; + triangleEl.__polycssSolidTriangleBasis = entry.basis; + if (el.style.visibility) el.style.visibility = ""; + el.style.transform = entry.transformText; + if (entry.polygon.data || ELEMENT_DATA_KEYS.get(el)?.length) { + applyPolygonDataAttrs(el, entry.polygon); + } +} + +function hideSolidTriangleElement(el: HTMLElement): void { + el.style.visibility = "hidden"; +} + +function stableTriangleColorBudgetCount( + total: number, + options: InternalRenderTextureAtlasOptions, +): number { + const configured = options.stableTriangleColorBudget; + if (typeof configured === "number" && Number.isFinite(configured) && configured > 0) { + return configured <= 1 + ? Math.max(1, Math.ceil(total * configured)) + : Math.max(1, Math.floor(configured)); + } + const freezeFrames = Math.max(1, Math.floor(options.stableTriangleColorFreezeFrames ?? 1)); + return Math.max(1, Math.ceil(total / freezeFrames)); +} + +function selectAdaptiveTriangleColorUpdates( + rendered: RenderedPoly[], + plans: Array, + options: InternalRenderTextureAtlasOptions, +): Set | null { + if (options.stableTriangleColorPolicy !== "adaptive") return null; + if (options.stableTriangleColorFreezeFrames === 0) return new Set(); + const colorFrame = Math.max(0, Math.floor(options.stableTriangleColorFrame ?? 0)); + const freezeFrames = Math.max(1, Math.floor(options.stableTriangleColorFreezeFrames ?? 1)); + if (freezeFrames <= 1 && !Number.isFinite(options.stableTriangleColorBudget)) return null; + const maxWrites = stableTriangleColorBudgetCount(rendered.length, options); + const maxAge = Math.max( + 1, + Math.floor(options.stableTriangleColorMaxAge ?? Math.max(2, freezeFrames * 2)), + ); + const candidates: Array<{ index: number; score: number }> = []; + + for (let i = 0; i < rendered.length; i++) { + const item = rendered[i]; + const plan = plans[i]; + if (item.kind !== "triangle" || !plan) continue; + const triangleEl = item.element as SolidTriangleElement; + const currentColor = triangleEl.__polycssSolidTriangleColor; + const nextColor = plan.bakedColor ?? ""; + if (currentColor === nextColor) continue; + const lastColorFrame = triangleEl.__polycssSolidTriangleColorFrame ?? 0; + const age = Math.max(0, colorFrame - lastColorFrame); + const error = colorErrorScore(currentColor, nextColor); + candidates.push({ + index: i, + score: (age >= maxAge ? 1_000_000 : 0) + error * 10_000 + age, + }); + } + + if (candidates.length === 0) return new Set(); + candidates.sort((a, b) => b.score - a.score); + return new Set(candidates.slice(0, maxWrites).map((candidate) => candidate.index)); +} + function createSolidTriangleElement( entry: SolidTrianglePlan, doc: Document, @@ -3135,6 +3778,17 @@ function createSolidTriangleElement( return el; } +function createHiddenSolidTriangleElement( + polygon: Polygon, + doc: Document, +): HTMLElement { + const el = doc.createElement("u"); + clearAtlasImageStyles(el); + hideSolidTriangleElement(el); + applyPolygonDataAttrs(el, polygon); + return el; +} + export function renderPolygonsWithStableTriangles( polygons: Polygon[], options: RenderTextureAtlasOptions = {}, @@ -3142,18 +3796,20 @@ export function renderPolygonsWithStableTriangles( const doc = options.doc ?? (typeof document !== "undefined" ? document : null); if (!doc) return { rendered: [], dispose: () => {} }; if (!solidTriangleSupported(doc)) return null; - - const plans = computeStableSolidTriangles(polygons, options); - if (!plans) return null; + if (polygons.some((polygon) => polygon.texture || polygon.vertices.length !== 3)) { + return null; + } const rendered: RenderedPoly[] = []; - for (const plan of plans) { - const element = createSolidTriangleElement(plan, doc); - rendered.push({ polygonIndex: plan.index, element, kind: "triangle", dispose: () => {} }); + for (let i = 0; i < polygons.length; i += 1) { + const polygon = polygons[i]; + const plan = computeSolidTrianglePlan(polygon, i, options); + const element = plan + ? createSolidTriangleElement(plan, doc) + : createHiddenSolidTriangleElement(polygon, doc); + rendered.push({ polygonIndex: i, element, kind: "triangle", dispose: () => {} }); } - rendered.sort((a, b) => a.polygonIndex - b.polygonIndex); - return { rendered, dispose() {}, @@ -3169,15 +3825,96 @@ export function updatePolygonsWithStableTriangles( if (!doc) return { rendered, dispose: () => {} }; if (!solidTriangleSupported(doc)) return null; if (rendered.some((item) => item.kind !== "triangle")) return null; + if (polygons.length !== rendered.length) return null; + for (let i = 0; i < rendered.length; i++) { + if (rendered[i].polygonIndex !== i) return null; + } - const plans = computeStableSolidTriangles(polygons, options); - if (!plans || plans.length !== rendered.length) return null; + const optimizeTriangleStyle = + (options as InternalRenderTextureAtlasOptions).optimizeStableTriangleStyle === true && + (options.textureLighting ?? "baked") === "baked"; + const internalOptions = options as InternalRenderTextureAtlasOptions; + const stableTriangleDebug = internalOptions.stableTriangleDebug; + const stableTriangleUpdateMode = internalOptions.stableTriangleUpdateMode ?? + (stableTriangleDebug === "plan-only" || stableTriangleDebug === "transform-only" + ? stableTriangleDebug + : "full"); + const colorOnly = optimizeTriangleStyle && stableTriangleUpdateMode === "color-only"; + const colorState = stableTriangleColorState(internalOptions); + const nextTrianglePlans: Array = new Array(rendered.length); + const nextTriangleColorPlans: Array = new Array(rendered.length); for (let i = 0; i < rendered.length; i++) { - if (rendered[i].polygonIndex !== plans[i].index) return null; + const element = rendered[i].element as SolidTriangleElement; + if (colorOnly) { + const shouldComputeColor = internalOptions.stableTriangleColorPolicy === "adaptive" || + shouldComputeStableTriangleColor( + element, + i, + optimizeTriangleStyle, + stableTriangleDebug, + internalOptions.stableTriangleColorPolicy, + colorState, + ); + nextTriangleColorPlans[i] = shouldComputeColor + ? computeSolidTriangleColorPlan(polygons[i], i, options) + : null; + continue; + } + nextTrianglePlans[i] = computeSolidTrianglePlan(polygons[i], i, options, { + basis: element.__polycssSolidTriangleBasis, + includeColor: stableTriangleUpdateMode !== "plan-only" && + stableTriangleUpdateMode !== "transform-only" && + shouldComputeStableTriangleColor( + element, + i, + optimizeTriangleStyle, + stableTriangleDebug, + internalOptions.stableTriangleColorPolicy, + colorState, + ), + }); } + const adaptiveColorUpdates = optimizeTriangleStyle && stableTriangleUpdateMode !== "plan-only" && + stableTriangleUpdateMode !== "transform-only" + ? selectAdaptiveTriangleColorUpdates( + rendered, + colorOnly ? nextTriangleColorPlans : nextTrianglePlans, + internalOptions, + ) + : null; for (let i = 0; i < rendered.length; i++) { - applySolidTriangleElement(rendered[i].element, plans[i]); + if (colorOnly) { + const plan = nextTriangleColorPlans[i]; + if (plan) { + applySolidTriangleElementColorOnly( + rendered[i].element, + plan, + colorState, + adaptiveColorUpdates?.has(i), + ); + } + continue; + } + const plan = nextTrianglePlans[i]; + if (!plan) { + hideSolidTriangleElement(rendered[i].element); + continue; + } + if (optimizeTriangleStyle && stableTriangleUpdateMode === "plan-only") { + continue; + } else if (optimizeTriangleStyle && stableTriangleUpdateMode === "transform-only") { + applySolidTriangleElementTransformOnly(rendered[i].element, plan); + } else if (optimizeTriangleStyle) { + applySolidTriangleElementFast( + rendered[i].element, + plan, + colorState, + adaptiveColorUpdates?.has(i), + ); + } else { + applySolidTriangleElement(rendered[i].element, plan); + } } return { @@ -3192,14 +3929,41 @@ export function updatePolygonsWithStableTopology( options: RenderTextureAtlasOptions = {}, ): boolean { if (rendered.length !== polygons.length) return false; + const doc = options.doc ?? (typeof document !== "undefined" ? document : null); const textureLighting = options.textureLighting ?? "baked"; - const nextTrianglePlans: SolidTrianglePlan[] = []; + const disabled = new Set(options.strategies?.disable ?? []); + const useFullRectSolid = !disabled.has("b"); + const useProjectiveQuad = useFullRectSolid; + const useBorderShape = !!doc && !disabled.has("i") && borderShapeSupported(doc); + const projectiveQuadGuards = doc + ? resolveProjectiveQuadGuards(doc) + : { + denomEps: PROJECTIVE_QUAD_DENOM_EPS, + maxWeightRatio: PROJECTIVE_QUAD_MAX_WEIGHT_RATIO, + bleed: PROJECTIVE_QUAD_BLEED, + disableGuards: false, + }; + const optimizeTriangleStyle = + (options as InternalRenderTextureAtlasOptions).optimizeStableTriangleStyle === true && + textureLighting === "baked"; + const internalOptions = options as InternalRenderTextureAtlasOptions; + const stableTriangleDebug = internalOptions.stableTriangleDebug; + const stableTriangleUpdateMode = internalOptions.stableTriangleUpdateMode ?? + (stableTriangleDebug === "plan-only" || stableTriangleDebug === "transform-only" + ? stableTriangleDebug + : "full"); + const colorOnly = optimizeTriangleStyle && stableTriangleUpdateMode === "color-only"; + const colorState = stableTriangleColorState(internalOptions); + const nextTrianglePlans: Array = []; + const nextTriangleColorPlans: Array = []; + const nextTexturePlans: Array = []; for (let i = 0; i < rendered.length; i++) { const item = rendered[i]; if (item.polygonIndex !== i) return false; const polygon = polygons[i]; if (!polygon) return false; + if (colorOnly && item.kind !== "triangle") return false; if (item.kind === "atlas") { if ( !item.plan || @@ -3210,20 +3974,127 @@ export function updatePolygonsWithStableTopology( continue; } if (item.kind === "triangle") { - const plan = computeSolidTrianglePlan(polygon, i, options); - if (!plan) return false; + const element = item.element as SolidTriangleElement; + if (colorOnly) { + const shouldComputeColor = internalOptions.stableTriangleColorPolicy === "adaptive" || + shouldComputeStableTriangleColor( + element, + i, + optimizeTriangleStyle, + stableTriangleDebug, + internalOptions.stableTriangleColorPolicy, + colorState, + ); + nextTriangleColorPlans[i] = shouldComputeColor + ? computeSolidTriangleColorPlan(polygon, i, options) + : null; + continue; + } + const plan = computeSolidTrianglePlan(polygon, i, options, { + basis: element.__polycssSolidTriangleBasis, + includeColor: stableTriangleUpdateMode !== "plan-only" && + stableTriangleUpdateMode !== "transform-only" && + shouldComputeStableTriangleColor( + element, + i, + optimizeTriangleStyle, + stableTriangleDebug, + internalOptions.stableTriangleColorPolicy, + colorState, + ), + }); + if (!plan) { + nextTrianglePlans[i] = null; + continue; + } nextTrianglePlans[i] = plan; continue; } + if (item.kind === "solid") { + if (!item.plan || polygon.texture || polygon.vertices.length !== item.plan.polygon.vertices.length) return false; + nextTexturePlans[i] = item.plan; + continue; + } + if (item.kind === "border") { + const plan = computeTextureAtlasPlan(polygon, i, options, projectiveQuadGuards); + if ( + !plan || + plan.texture || + !useBorderShape || + (useFullRectSolid && isFullRectSolid(plan)) || + (useProjectiveQuad && isProjectiveQuadPlan(plan)) + ) { + return false; + } + nextTexturePlans[i] = plan; + continue; + } return false; } + const adaptiveColorUpdates = optimizeTriangleStyle && stableTriangleUpdateMode !== "plan-only" && + stableTriangleUpdateMode !== "transform-only" + ? selectAdaptiveTriangleColorUpdates( + rendered, + colorOnly ? nextTriangleColorPlans : nextTrianglePlans, + internalOptions, + ) + : null; + for (let i = 0; i < rendered.length; i++) { const item = rendered[i]; if (item.kind === "triangle") { + if (colorOnly) { + const plan = nextTriangleColorPlans[i]; + if (plan) { + applySolidTriangleElementColorOnly( + item.element, + plan, + colorState, + adaptiveColorUpdates?.has(i), + ); + } + continue; + } const plan = nextTrianglePlans[i]; + if (!plan) { + hideSolidTriangleElement(item.element); + continue; + } + if (optimizeTriangleStyle && stableTriangleUpdateMode === "plan-only") { + continue; + } else if (optimizeTriangleStyle && stableTriangleUpdateMode === "transform-only") { + applySolidTriangleElementTransformOnly(item.element, plan); + } else if (optimizeTriangleStyle) { + applySolidTriangleElementFast( + item.element, + plan, + colorState, + adaptiveColorUpdates?.has(i), + ); + } else { + applySolidTriangleElement(item.element, plan); + } + } else if (item.kind === "solid") { + const plan = nextTexturePlans[i]; + if (!plan) return false; + if ( + !updateSolidElementWithStablePlan( + item.element, + plan, + polygons[i], + textureLighting, + options, + projectiveQuadGuards, + internalOptions.solidPaintDefaults, + ) + ) { + return false; + } + } else if (item.kind === "border") { + const plan = nextTexturePlans[i]; if (!plan) return false; - applySolidTriangleElement(item.element, plan); + updateBorderShapeElementWithStablePlan(item.element, plan, textureLighting, internalOptions.solidPaintDefaults); } } diff --git a/packages/react/README.md b/packages/react/README.md index c59d3229..ad8a69f3 100644 --- a/packages/react/README.md +++ b/packages/react/README.md @@ -1,174 +1,207 @@ -> **Status: pre-1.0. APIs may still change before a stable 1.0 release.** +

+ polycss +

-# @layoutit/polycss-react +# polycss -Declarative React components for CSS-based polygon mesh rendering. Loads OBJ, glTF, GLB, and MagicaVoxel `.vox` files; renders each polygon as a real DOM element (atlas-backed `` for both textured and flat-color faces) positioned with `transform: matrix3d(...)`. No WebGL, no canvas-as-scene. +A CSS polygon mesh engine. A 3D renderer for the DOM. Renders OBJ, glTF, GLB, MagicaVoxel `.vox`, and generated primitives as real HTML elements transformed with CSS `matrix3d(...)`. Supports colors, textures, lighting, shadows, controls, selection, animation, and per-polygon interaction. Works with React, Vue, custom elements, or plain JavaScript. -## Install +Visit [polycss.com](https://polycss.com) for docs and model examples. + +polycss scene + +## Installation ```bash +# React npm install @layoutit/polycss-react + +# Vue +npm install @layoutit/polycss-vue + +# Vanilla / custom elements +npm install @layoutit/polycss +``` + +You can also load polycss directly from a CDN. Here is a minimal custom-element scene: + +```html + + + + + + + + ``` -Requires React 18 or 19 as a peer dependency. +## Framework Components -## Quickstart +React and Vue expose the same component model. `` owns the viewpoint, `` owns lighting and atlas options, and `` loads or receives polygon data. ```tsx -import { PolyCamera, PolyScene, PolyMesh } from "@layoutit/polycss-react"; +import { PolyCamera, PolyScene, PolyOrbitControls, PolyMesh } from "@layoutit/polycss-react"; -export function App() { +export default function App() { return ( - - - + + + + ); } ``` -Every polygon in the mesh is a real DOM element: inspect it in DevTools, style it with CSS, attach event handlers. +The Vue package mirrors the same names and props with Vue casing: + +```vue + + + +``` -## Component reference +## API Reference -### `` +### PolyCamera -Root of every React polycss render tree. Renders polygons and meshes inside a `` context, and owns scene-level lighting and atlas options. +- `rotX`, `rotY` control the orbit angle in degrees. +- `zoom` scales the projected scene. +- `target` pans the camera target in world coordinates. +- `distance` adds dolly pull-back. +- `PolyCamera` is the orthographic default. Use `PolyPerspectiveCamera` when you want perspective depth. -| Prop | Type | Default | Description | -|---|---|---|---| -| `directionalLight` | `PolyDirectionalLight` | None | Directional light config | -| `ambientLight` | `PolyAmbientLight` | None | Ambient light config | -| `textureLighting` | `"baked" \| "dynamic"` | `"baked"` | Texture lighting mode | -| `textureQuality` | `number \| "auto"` | `"auto"` | Atlas bitmap budget and compositor sprite size | -| `polygons` | `Polygon[]` | None | Static polygon array (composes with `children`) | -| `children` | `ReactNode` | None | ``, ``, and/or `` | +### PolyScene -For pointer drag, wheel zoom, and autorotate, mount `` (or `` for pan-first map-style input) inside ``: it receives the camera context. Mirrors Three.js's split between camera state and input. +- `polygons` renders a static `Polygon[]` directly. +- `directionalLight` and `ambientLight` control scene lighting. +- `textureLighting` chooses `"baked"` or `"dynamic"`. +- `textureQuality` controls atlas raster budget. +- `strategies` can disable selected render strategies for diagnostics. +- `autoCenter` rotates around the rendered mesh bounds instead of world origin. -### `` +### PolyMesh -Loads a mesh from a URL and renders its polygons. Manages blob-URL lifecycle automatically. +- `src` loads `.obj`, `.gltf`, `.glb`, or `.vox` files. +- `mtl` loads companion OBJ materials. +- `polygons` accepts pre-parsed geometry. +- `position`, `scale`, and `rotation` transform the mesh wrapper. +- `autoCenter` shifts the mesh bbox center to local origin. +- `meshResolution` chooses `"lossy"` (default) or `"lossless"` optimization. +- `castShadow` emits CSS-projected shadows in dynamic lighting mode. -| Prop | Type | Description | -|---|---|---| -| `src` | `string` | URL to `.obj`, `.glb`, `.gltf`, or `.vox` | -| `polygons` | `Polygon[]` | Pre-parsed polygons (alternative to `src`) | -| `position` | `Vec3` | `[x, y, z]` offset in scene space | -| `scale` | `number \| Vec3` | Uniform or per-axis scale | -| `rotation` | `Vec3` | Euler angles in degrees `[x, y, z]` | -| `textureQuality` | `number \| "auto"` | Atlas bitmap budget and compositor sprite size | -| `autoCenter` | `boolean` | Shift mesh so its bbox center is at origin | -| `mtl` | `string` | Companion `.mtl` URL for OBJ models | -| `parseOptions` | `UseMeshOptions` | Forwarded to `loadMesh`; `meshResolution` defaults to `"lossy"` | -| `fallback` | `ReactNode` | Rendered while loading | -| `errorFallback` | `(error: Error) => ReactNode` | Rendered on parse failure | -| `children` | `((polygon, index) => ReactNode) \| ReactNode` | Per-polygon render prop override, or static children mounted inside the mesh wrapper | +### Controls -### `` +- `` adds drag orbit, shift-drag pan, wheel zoom, and optional auto-rotate. +- `` uses pan-first map-style input. +- `` provides keyboard and pointer-look navigation. +- `` adds translate/rotate gizmos for selected mesh handles. -Single polygon. The atomic primitive: renders one atlas-backed `` for UV-textured and flat-color faces. Forwards all standard DOM props. +### Polygon Data Model -| Prop | Type | Description | -|---|---|---| -| `vertices` | `Vec3[]` | Required: 3+ `[x, y, z]` points | -| `color` | `string` | CSS color; used when no texture is set | -| `texture` | `string` | Image URL for UV-mapped rendering | -| `uvs` | `Vec2[]` | UV coordinates, one per vertex | -| `data` | `Record` | Reflected as `data-*` DOM attributes | -| `position` | `Vec3` | Local offset | -| `scale` | `number \| Vec3` | Scale | -| `rotation` | `Vec3` | Euler rotation in degrees | -| `textureQuality` | `number \| "auto"` | Atlas bitmap budget and compositor sprite size | -| `onClick` | `MouseEventHandler` | Standard DOM event handler | -| `onMouseEnter` | `MouseEventHandler` | | -| `className` | `string` | CSS class | -| `style` | `CSSProperties` | Inline style | -| `aria-label` | `string` | ARIA label | +Each polygon describes one renderable face: -### `` +```ts +const polygons = [ + { + vertices: [[0, 0, 0], [60, 0, 0], [0, 60, 0]], + color: "#f97316", + }, + { + vertices: [[0, 0, 0], [60, 0, 0], [60, 60, 0], [0, 60, 0]], + texture: "/texture.png", + uvs: [[0, 0], [1, 0], [1, 1], [0, 1]], + }, +]; +``` -Camera wrapper for perspective, rotation, zoom, target, and dolly distance. React scenes must render inside `` (or `` / ``) so controls and scenes share camera state. +Render polygons directly when you need per-face DOM events or custom styling: -### Hooks +```tsx + + + {polygons.map((polygon, index) => ( + console.log("clicked polygon", index)} + className="my-polygon" + /> + ))} + + +``` -| Hook | Description | -|---|---| -| `usePolyCamera(options)` | Internal camera integration hook (used by ``) | -| `usePolySceneContext(polygons, options)` | Lower-level hook for building custom scene wrappers | -| `usePolyMesh(src, options?)` | Fetch + parse a mesh. Returns `{ polygons, loading, error, warnings, dispose }`. Manages blob-URL lifecycle: safe across rapid src changes and unmounts. | +## Loading Mesh Files -### Utility +Use `loadMesh()` from `@layoutit/polycss`, `@layoutit/polycss-react`, or `@layoutit/polycss-vue` to parse supported model formats: -| Export | Description | -|---|---| -| `injectPolyBaseStyles(doc?)` | Inject polycss base CSS into the document. Idempotent. Called automatically by ``; manual call only needed for custom scene hosts. Polygon defaults are scoped to `.polycss-scene`. | +```ts +import { createPolyCamera, createPolyScene, loadMesh } from "@layoutit/polycss"; -## Re-exports from `@layoutit/polycss-core` +const host = document.getElementById("polycss")!; +const camera = createPolyCamera({ rotX: 65, rotY: 45 }); +const scene = createPolyScene(host, { camera }); -All types and core functions are re-exported for convenience, so you never need to add `@layoutit/polycss-core` to your dependencies: +const mesh = await loadMesh("https://polycss.com/gallery/obj/cottage.obj", { + mtlUrl: "https://polycss.com/gallery/obj/cottage.mtl", +}); -```ts -import type { Polygon, Vec2, Vec3, PolyDirectionalLight, PolyAmbientLight, ParseResult } from "@layoutit/polycss-react"; -import { parseObj, parseGltf, parseVox, loadMesh, normalizePolygons, mergePolygons } from "@layoutit/polycss-react"; +scene.add(mesh); ``` -## Per-polygon interactivity example +Supported formats: -```tsx -import { useState } from "react"; -import { PolyCamera, PolyScene, Poly } from "@layoutit/polycss-react"; -import type { Polygon } from "@layoutit/polycss-react"; +- OBJ + MTL, including `map_Kd` textures and UV coordinates. +- glTF / GLB, including embedded images and `TEXCOORD_0`. +- MagicaVoxel `.vox`, with direct voxel fast paths when eligible. +- Generated primitives: box, plane, ring, sphere, torus, cylinder, cone, and Platonic solids. -export function InteractiveMesh({ polygons }: { polygons: Polygon[] }) { - const [hoveredId, setHoveredId] = useState(null); +## Performance - return ( - - - {polygons.map((p, i) => ( - alert(`clicked polygon ${i}`)} - onMouseEnter={() => setHoveredId(i)} - onMouseLeave={() => setHoveredId(null)} - className={hoveredId === i ? "highlight" : ""} - style={{ transition: "filter 0.2s" }} - /> - ))} - - - ); -} -``` +polycss renders in the DOM, so performance is mostly determined by how many polygons are mounted and how much texture atlas area they consume. The renderer uses several CSS strategies so simple surfaces stay cheap and textured or irregular surfaces fall back to atlas slices. -```css -.highlight { filter: brightness(1.5); } -``` +- One visible polygon becomes one leaf DOM element. +- Flat rectangles and stable quads use solid CSS leaves. +- Textured polygons are packed into generated texture atlases. +- Dynamic lighting runs through CSS custom properties instead of per-frame JavaScript. +- Voxel-shaped meshes mount only camera-facing leaves when the mesh is eligible. +- `meshResolution: "lossy"` can merge compatible polygons to reduce DOM node count. -## `usePolyMesh`: imperative loading +For diagnostics, all renderer packages export `collectPolyRenderStats(root)`, which returns mounted polygon leaf counts, shadow counts, surface categories, and bucket counts for an already-rendered scene. -```tsx -import { PolyCamera, PolyScene, Poly, usePolyMesh } from "@layoutit/polycss-react"; +## Packages -function Viewer() { - const { polygons, loading, error } = usePolyMesh("/cottage.glb"); +| Package | Description | +|---|---| +| `@layoutit/polycss-core` | Pure math, parsers, lighting, camera helpers, mesh optimization. Zero browser globals. | +| `@layoutit/polycss` | Vanilla custom elements and imperative `createPolyScene` API. | +| `@layoutit/polycss-react` | React components, hooks, controls, and core re-exports. | +| `@layoutit/polycss-vue` | Vue 3 components, composables, controls, and core re-exports. | - if (loading) return
Loading…
; - if (error) return
Error: {error.message}
; +## Made with polycss - return ( - - - {polygons.map((p, i) => )} - - - ); -} -``` +[Layoutit Voxels](https://voxels.layoutit.com) +-> A CSS Voxel editor + +layoutit-voxels + +[Layoutit Terra](https://terra.layoutit.com) +-> A CSS Terrain Generator + +layoutit-terra -## Docs +## License -Full documentation at [polycss.com](https://polycss.com). +MIT. diff --git a/packages/react/package.json b/packages/react/package.json index 74887ab2..dd2348d8 100644 --- a/packages/react/package.json +++ b/packages/react/package.json @@ -31,6 +31,7 @@ "build": "tsup", "test": "vitest run", "test:coverage": "vitest run --coverage", + "prepack": "node ../../tools/sync-package-readmes.mjs", "prepublishOnly": "npm run build" }, "publishConfig": { diff --git a/packages/react/src/index.ts b/packages/react/src/index.ts index ef3183b7..46782684 100644 --- a/packages/react/src/index.ts +++ b/packages/react/src/index.ts @@ -98,6 +98,13 @@ export type { export { injectPolyBaseStyles } from "./styles"; +export { collectPolyRenderStats } from "./renderStats"; +export type { + PolyRenderStats, + PolyRenderStatsOptions, + PolyRenderSurfaceLeafCounts, +} from "./renderStats"; + export { usePolyAnimation } from "./animation/usePolyAnimation"; export type { UsePolyAnimationResult } from "./animation/usePolyAnimation"; @@ -160,6 +167,7 @@ export type { CameraCullRotation, ApproximateMergeOptions, OptimizeMeshPolygonsOptions, + OptimizeAnimatedMeshPolygonsOptions, } from "@layoutit/polycss-core"; export { CAMERA_BACKFACE_CULL_EPS, @@ -219,6 +227,7 @@ export { DEFAULT_PROJECTION, normalizeInvertMultiplier, createPolyAnimationMixer, + optimizeAnimatedMeshPolygons, LoopOnce, LoopRepeat, LoopPingPong, diff --git a/packages/react/src/renderStats.ts b/packages/react/src/renderStats.ts new file mode 100644 index 00000000..efb557ce --- /dev/null +++ b/packages/react/src/renderStats.ts @@ -0,0 +1,103 @@ +export interface PolyRenderSurfaceLeafCounts { + quad: number; + clippedSolid: number; + atlas: number; + stableTriangle: number; +} + +export interface PolyRenderStats { + polygonCount: number; + mountedPolygonLeafCount: number; + shadowLeafCount: number; + surfaceLeafCounts: PolyRenderSurfaceLeafCounts; + bucketCount: number; +} + +export interface PolyRenderStatsOptions { + polygonCount?: number; + /** + * Optional subtree selector for diagnostics that only want model leaves and + * not helpers/floors/gizmos sharing the same scene root. + */ + scopeSelector?: string; +} + +const ZERO_SURFACE_LEAF_COUNTS: PolyRenderSurfaceLeafCounts = { + quad: 0, + clippedSolid: 0, + atlas: 0, + stableTriangle: 0, +}; + +const EMPTY_POLY_RENDER_STATS: PolyRenderStats = { + polygonCount: 0, + mountedPolygonLeafCount: 0, + shadowLeafCount: 0, + surfaceLeafCounts: ZERO_SURFACE_LEAF_COUNTS, + bucketCount: 0, +}; + +function asOptions(optionsOrPolygonCount?: number | PolyRenderStatsOptions): PolyRenderStatsOptions { + if (typeof optionsOrPolygonCount === "number") { + return { polygonCount: optionsOrPolygonCount }; + } + return optionsOrPolygonCount ?? {}; +} + +function queryCount(scope: ParentNode, selector: string): number { + return scope.querySelectorAll(selector).length; +} + +function matchesSelector(root: ParentNode, selector: string): boolean { + const candidate = root as ParentNode & { matches?: (selector: string) => boolean }; + return typeof candidate.matches === "function" && candidate.matches(selector); +} + +function collectScopes(root: ParentNode, selector: string | undefined): ParentNode[] { + if (!selector) return [root]; + const scopes: ParentNode[] = []; + if (matchesSelector(root, selector)) scopes.push(root); + scopes.push(...Array.from(root.querySelectorAll(selector))); + return scopes; +} + +export function collectPolyRenderStats( + root: ParentNode | null | undefined, + optionsOrPolygonCount?: number | PolyRenderStatsOptions, +): PolyRenderStats { + const options = asOptions(optionsOrPolygonCount); + if (!root) { + return { + ...EMPTY_POLY_RENDER_STATS, + surfaceLeafCounts: { ...ZERO_SURFACE_LEAF_COUNTS }, + polygonCount: options.polygonCount ?? 0, + }; + } + + const scopes = collectScopes(root, options.scopeSelector); + const surfaceLeafCounts: PolyRenderSurfaceLeafCounts = { ...ZERO_SURFACE_LEAF_COUNTS }; + let shadowLeafCount = 0; + let bucketCount = 0; + + for (const scope of scopes) { + surfaceLeafCounts.quad += queryCount(scope, "b"); + surfaceLeafCounts.clippedSolid += queryCount(scope, "i"); + surfaceLeafCounts.atlas += queryCount(scope, "s"); + surfaceLeafCounts.stableTriangle += queryCount(scope, "u"); + shadowLeafCount += queryCount(scope, "q"); + bucketCount += queryCount(scope, ".polycss-bucket"); + } + + const mountedPolygonLeafCount = + surfaceLeafCounts.quad + + surfaceLeafCounts.clippedSolid + + surfaceLeafCounts.atlas + + surfaceLeafCounts.stableTriangle; + return { + polygonCount: options.polygonCount ?? mountedPolygonLeafCount, + mountedPolygonLeafCount, + shadowLeafCount, + surfaceLeafCounts, + bucketCount, + }; +} diff --git a/packages/react/src/scene/PolyMesh.test.tsx b/packages/react/src/scene/PolyMesh.test.tsx index 7adb7401..ff1e05e8 100644 --- a/packages/react/src/scene/PolyMesh.test.tsx +++ b/packages/react/src/scene/PolyMesh.test.tsx @@ -455,6 +455,20 @@ describe("PolyMesh — updatePolygon", () => { expect(ref.current!.getPolygons()[0].color).toBe("#ff0000"); }); + it("setPolygons updates stable triangle leaves without remounting", () => { + const p0: Polygon = { vertices: [[0, 0, 0], [1, 0, 0], [0, 1, 0]], color: "#ff0000" }; + const p1: Polygon = { vertices: [[0, 0, 0], [2, 0, 0], [0, 1, 0]], color: "#00ff00" }; + const { ref, container } = mountMesh([p0]); + const leafBefore = container.querySelector("u"); + expect(leafBefore).toBeTruthy(); + const transformBefore = (leafBefore as HTMLElement).style.transform; + act(() => { ref.current!.setPolygons([p1]); }); + const leafAfter = container.querySelector("u"); + expect(leafAfter).toBe(leafBefore); + expect(ref.current!.getPolygons()[0]).toBe(p1); + expect((leafAfter as HTMLElement).style.transform).not.toBe(transformBefore); + }); + it("merges partial fields — untouched fields are preserved", () => { const poly: Polygon = { vertices: [[0, 0, 0], [1, 0, 0], [0, 1, 0]], color: "#ff0000" }; const { ref } = mountMesh([poly]); diff --git a/packages/react/src/scene/PolyMesh.tsx b/packages/react/src/scene/PolyMesh.tsx index 816fb33b..2613186f 100644 --- a/packages/react/src/scene/PolyMesh.tsx +++ b/packages/react/src/scene/PolyMesh.tsx @@ -46,6 +46,7 @@ import { TextureBorderShapePoly, TextureAtlasPoly, TextureTrianglePoly, + updateStableTriangleDom, useTextureAtlas, } from "./textureAtlas"; import { usePolySceneContext } from "./sceneContext"; @@ -292,6 +293,8 @@ export const PolyMesh = forwardRef(function PolyM // `rotation`) from the atlas baker, so we don't re-bake every frame // during a drag. const [bakedRotation, setBakedRotation] = useState(rotation); + const stableTriangleColorFrameRef = useRef(0); + const setPolygonsImplRef = useRef<(next: Polygon[]) => void>(() => {}); const handle = useMemo(() => ({ get element() { return wrapperRef.current; }, @@ -300,6 +303,9 @@ export const PolyMesh = forwardRef(function PolyM getRotation: () => propsRef.current.rotation, getScale: () => propsRef.current.scale, getPolygons: () => polygonsRef.current, + setPolygons(nextPolygons: Polygon[]) { + setPolygonsImplRef.current(nextPolygons); + }, rebakeAtlas: () => setBakedRotation(propsRef.current.rotation), updatePolygon(target: Polygon | number, partial: Partial) { const current = polygonsRef.current; @@ -615,6 +621,27 @@ export const PolyMesh = forwardRef(function PolyM return leaves; }, [castShadow, effectiveTextureLighting, renderPolygon, polygons, atlasPlans, sceneCtx?.shadow]); + setPolygonsImplRef.current = (nextPolygons: Polygon[]) => { + const nextRenderedPolygons = autoCenter ? recenterPolygons(nextPolygons) : nextPolygons; + polygonsRef.current = nextRenderedPolygons; + const root = wrapperRef.current; + if ( + root && + !renderPolygon && + updateStableTriangleDom(root, nextRenderedPolygons, { + directionalLight: bakedDirectional, + ambientLight: effectiveAmbient, + textureLighting: effectiveTextureLighting, + colorFrame: ++stableTriangleColorFrameRef.current, + colorFreezeFrames: 12, + colorMaxStep: 8, + }) + ) { + return; + } + setLocalPolygons([...nextPolygons]); + }; + const wrapperStyle: CSSProperties = { transform, ...(transformOrigin ? { transformOrigin } : null), diff --git a/packages/react/src/scene/events.test.ts b/packages/react/src/scene/events.test.ts index df01d921..a3223031 100644 --- a/packages/react/src/scene/events.test.ts +++ b/packages/react/src/scene/events.test.ts @@ -23,6 +23,9 @@ function makeHandle(el: HTMLDivElement, id?: string): PolyMeshHandle { getRotation: () => undefined, getScale: () => undefined, getPolygons: (): Polygon[] => [], + setPolygons: () => {}, + rebakeAtlas: () => {}, + updatePolygon: () => {}, }; } diff --git a/packages/react/src/scene/events.ts b/packages/react/src/scene/events.ts index 3585649c..ca65ab51 100644 --- a/packages/react/src/scene/events.ts +++ b/packages/react/src/scene/events.ts @@ -37,6 +37,12 @@ export interface PolyMeshHandle { getScale(): number | Vec3 | undefined; /** Polygons currently being rendered (post-autoCenter). */ getPolygons(): Polygon[]; + /** + * Replace the mesh polygons imperatively. Animated solid-triangle meshes use + * a stable DOM update path that mutates mounted leaves directly; unsupported + * topology falls back to the normal framework render path. + */ + setPolygons(polygons: Polygon[]): void; /** * Snapshot the current `rotation` prop as the new "baked rotation" and * trigger an atlas re-rasterization with the directional light diff --git a/packages/react/src/scene/textureAtlas.tsx b/packages/react/src/scene/textureAtlas.tsx index 07093566..e0f9d3ab 100644 --- a/packages/react/src/scene/textureAtlas.tsx +++ b/packages/react/src/scene/textureAtlas.tsx @@ -58,6 +58,10 @@ export type TextureQuality = number | "auto"; interface RGB { r: number; g: number; b: number; } interface RGBFactors { r: number; g: number; b: number; } +interface StableTriangleDomElement extends HTMLElement { + __polycssStableTriangleColor?: string; + __polycssStableTriangleColorRgb?: RGB; +} interface UvAffine { a: number; @@ -1121,6 +1125,19 @@ function rgbToHex({ r, g, b }: RGB): string { return `#${f(r)}${f(g)}${f(b)}`; } +function stepRgbToward(current: RGB, target: RGB, maxStep: number): RGB { + const step = (from: number, to: number) => { + if (from === to) return from; + const delta = to - from; + return from + Math.sign(delta) * Math.min(Math.abs(delta), maxStep); + }; + return { + r: step(current.r, target.r), + g: step(current.g, target.g), + b: step(current.b, target.b), + }; +} + function shadePolygon( baseColor: string, directScale: number, @@ -1166,6 +1183,92 @@ function tintToCss({ r, g, b }: RGBFactors): string { return `rgb(${f(r)} ${f(g)} ${f(b)})`; } +export interface StableTriangleDomUpdateOptions { + directionalLight?: PolyDirectionalLight; + ambientLight?: PolyAmbientLight; + textureLighting?: PolyTextureLightingMode; + colorFrame?: number; + colorFreezeFrames?: number; + colorMaxStep?: number; +} + +function stableTriangleColorAllowed(index: number, colorFrame: number, freezeFrames: number): boolean { + return freezeFrames > 0 && (freezeFrames <= 1 || (colorFrame + index) % freezeFrames === 0); +} + +function applyStableTriangleColor( + el: StableTriangleDomElement, + index: number, + nextColor: string, + options: StableTriangleDomUpdateOptions, +): void { + const freezeFrames = Math.floor(options.colorFreezeFrames ?? 1); + if (freezeFrames === 0) return; + + const currentColor = el.__polycssStableTriangleColor; + const shouldWrite = currentColor === undefined || + stableTriangleColorAllowed( + index, + Math.max(0, Math.floor(options.colorFrame ?? 0)), + Math.max(1, freezeFrames), + ); + if (!shouldWrite || currentColor === nextColor) return; + + let writeColor = nextColor; + let writeRgb = nextColor ? parseHex(nextColor) : undefined; + const currentRgb = el.__polycssStableTriangleColorRgb; + const maxStep = Math.max(0, Math.floor(options.colorMaxStep ?? 0)); + if (maxStep > 0 && currentRgb && writeRgb && nextColor) { + writeRgb = stepRgbToward(currentRgb, writeRgb, maxStep); + writeColor = rgbToHex(writeRgb); + } + + el.style.color = writeColor; + el.__polycssStableTriangleColor = writeColor; + el.__polycssStableTriangleColorRgb = writeRgb; +} + +export function updateStableTriangleDom( + root: HTMLElement, + polygons: Polygon[], + options: StableTriangleDomUpdateOptions = {}, +): boolean { + if ((options.textureLighting ?? "baked") !== "baked") return false; + if (!solidTriangleSupported()) return false; + const leaves = Array.from(root.children).filter( + (child): child is StableTriangleDomElement => + child instanceof HTMLElement && child.localName === "u", + ); + if (leaves.length !== polygons.length) return false; + + const plans = polygons.map((polygon, index) => { + if (polygon.texture || polygon.vertices.length !== 3) return null; + const plan = computeTextureAtlasPlan(polygon, index, { + directionalLight: options.directionalLight, + ambientLight: options.ambientLight, + }); + if (!plan || !isSolidTrianglePlan(plan)) return null; + const style = solidTriangleStyle(plan, "baked", "auto", {}); + if (!style?.transform) return null; + return style; + }); + if (plans.some((plan) => !plan)) return false; + + for (let i = 0; i < leaves.length; i += 1) { + const style = plans[i]!; + const el = leaves[i]; + if (el.style.visibility) el.style.visibility = ""; + el.style.transform = String(style.transform); + applyStableTriangleColor( + el, + i, + typeof style.color === "string" ? style.color : "", + options, + ); + } + return true; +} + function applyTextureTint( ctx: CanvasRenderingContext2D, x: number, diff --git a/packages/react/src/select/Select.test.tsx b/packages/react/src/select/Select.test.tsx index 1f165263..8548d966 100644 --- a/packages/react/src/select/Select.test.tsx +++ b/packages/react/src/select/Select.test.tsx @@ -301,6 +301,9 @@ describe(" handleClick fallback (no PolyCamera)", () => { getRotation: () => undefined, getScale: () => undefined, getPolygons: () => [], + setPolygons: () => {}, + rebakeAtlas: () => {}, + updatePolygon: () => {}, }; } diff --git a/packages/vue/README.md b/packages/vue/README.md index 838f2302..ad8a69f3 100644 --- a/packages/vue/README.md +++ b/packages/vue/README.md @@ -1,188 +1,207 @@ -> **Status: pre-1.0. APIs may still change before a stable 1.0 release.** +

+ polycss +

-# @layoutit/polycss-vue +# polycss -Native Vue 3 components for CSS-based polygon mesh rendering. Loads OBJ, glTF, GLB, and MagicaVoxel `.vox` files; renders each polygon as a real DOM element (atlas-backed `` for both textured and flat-color faces) positioned with `transform: matrix3d(...)`. No WebGL, no canvas-as-scene. +A CSS polygon mesh engine. A 3D renderer for the DOM. Renders OBJ, glTF, GLB, MagicaVoxel `.vox`, and generated primitives as real HTML elements transformed with CSS `matrix3d(...)`. Supports colors, textures, lighting, shadows, controls, selection, animation, and per-polygon interaction. Works with React, Vue, custom elements, or plain JavaScript. -## Install +Visit [polycss.com](https://polycss.com) for docs and model examples. + +polycss scene + +## Installation ```bash +# React +npm install @layoutit/polycss-react + +# Vue npm install @layoutit/polycss-vue + +# Vanilla / custom elements +npm install @layoutit/polycss +``` + +You can also load polycss directly from a CDN. Here is a minimal custom-element scene: + +```html + + + + + + + + ``` -Requires Vue 3 as a peer dependency. +## Framework Components + +React and Vue expose the same component model. `` owns the viewpoint, `` owns lighting and atlas options, and `` loads or receives polygon data. -## Quickstart +```tsx +import { PolyCamera, PolyScene, PolyOrbitControls, PolyMesh } from "@layoutit/polycss-react"; + +export default function App() { + return ( + + + + + + + ); +} +``` + +The Vue package mirrors the same names and props with Vue casing: ```vue ``` -## Component reference - -### `` +## API Reference -Root of every Vue polycss render tree. Renders polygons and meshes inside a `` context, and owns scene-level lighting and atlas options. +### PolyCamera -| Prop | Type | Default | Description | -|---|---|---|---| -| `directional-light` | `PolyDirectionalLight` | None | Directional light config | -| `ambient-light` | `PolyAmbientLight` | None | Ambient light config | -| `texture-lighting` | `"baked" \| "dynamic"` | `"baked"` | Texture lighting mode | -| `textureQuality` | `number \| "auto"` | `"auto"` | Atlas bitmap budget and compositor sprite size | -| `polygons` | `Polygon[]` | None | Static polygon array (composes with slot) | +- `rotX`, `rotY` control the orbit angle in degrees. +- `zoom` scales the projected scene. +- `target` pans the camera target in world coordinates. +- `distance` adds dolly pull-back. +- `PolyCamera` is the orthographic default. Use `PolyPerspectiveCamera` when you want perspective depth. -For pointer drag, wheel zoom, and autorotate, mount `` (or `` for pan-first map-style input) inside ``: it receives the camera context. Mirrors Three.js's split between camera state and input. +### PolyScene -### `` +- `polygons` renders a static `Polygon[]` directly. +- `directionalLight` and `ambientLight` control scene lighting. +- `textureLighting` chooses `"baked"` or `"dynamic"`. +- `textureQuality` controls atlas raster budget. +- `strategies` can disable selected render strategies for diagnostics. +- `autoCenter` rotates around the rendered mesh bounds instead of world origin. -Loads a mesh from a URL and renders its polygons. Manages blob-URL lifecycle automatically. +### PolyMesh -| Prop | Type | Description | -|---|---|---| -| `src` | `string` | URL to `.obj`, `.glb`, `.gltf`, or `.vox` | -| `polygons` | `Polygon[]` | Pre-parsed polygons (alternative to `src`) | -| `position` | `Vec3` | `[x, y, z]` offset in scene space | -| `scale` | `number \| Vec3` | Uniform or per-axis scale | -| `rotation` | `Vec3` | Euler angles in degrees `[x, y, z]` | -| `textureQuality` | `number \| "auto"` | Atlas bitmap budget and compositor sprite size | -| `auto-center` | `boolean` | Shift mesh so its bbox center is at origin | -| `mtl` | `string` | Companion `.mtl` URL for OBJ models | +- `src` loads `.obj`, `.gltf`, `.glb`, or `.vox` files. +- `mtl` loads companion OBJ materials. +- `polygons` accepts pre-parsed geometry. +- `position`, `scale`, and `rotation` transform the mesh wrapper. +- `autoCenter` shifts the mesh bbox center to local origin. +- `meshResolution` chooses `"lossy"` (default) or `"lossless"` optimization. +- `castShadow` emits CSS-projected shadows in dynamic lighting mode. -Named slot: `#polygon="{ polygon, index }"`: per-polygon scoped slot for rendering overrides. The default slot is for static children inside the mesh wrapper. +### Controls -### `` +- `` adds drag orbit, shift-drag pan, wheel zoom, and optional auto-rotate. +- `` uses pan-first map-style input. +- `` provides keyboard and pointer-look navigation. +- `` adds translate/rotate gizmos for selected mesh handles. -Single polygon. Renders one atlas-backed `` for UV-textured and flat-color faces. Accepts standard Vue event bindings and class/style. +### Polygon Data Model -| Prop | Type | Description | -|---|---|---| -| `vertices` | `Vec3[]` | Required: 3+ `[x, y, z]` points | -| `color` | `string` | CSS color; used when no texture is set | -| `texture` | `string` | Image URL for UV-mapped rendering | -| `uvs` | `Vec2[]` | UV coordinates, one per vertex | -| `data` | `Record` | Reflected as `data-*` DOM attributes | -| `position` | `Vec3` | Local offset | -| `scale` | `number \| Vec3` | Scale | -| `rotation` | `Vec3` | Euler rotation in degrees | -| `textureQuality` | `number \| "auto"` | Atlas bitmap budget and compositor sprite size | +Each polygon describes one renderable face: -### `` +```ts +const polygons = [ + { + vertices: [[0, 0, 0], [60, 0, 0], [0, 60, 0]], + color: "#f97316", + }, + { + vertices: [[0, 0, 0], [60, 0, 0], [60, 60, 0], [0, 60, 0]], + texture: "/texture.png", + uvs: [[0, 0], [1, 0], [1, 1], [0, 1]], + }, +]; +``` -Camera wrapper for perspective, rotation, zoom, target, and dolly distance. Vue scenes must render inside `` (or `` / ``) so controls and scenes share camera state. +Render polygons directly when you need per-face DOM events or custom styling: -### Composables +```tsx + + + {polygons.map((polygon, index) => ( + console.log("clicked polygon", index)} + className="my-polygon" + /> + ))} + + +``` -| Composable | Description | -|---|---| -| `usePolyCamera(options)` | Internal camera integration composable | -| `usePolySceneContext(polygons, options)` | Lower-level hook for custom scene wrappers | -| `usePolyMesh(srcRef, options?)` | Reactive mesh loader. Returns reactive `{ polygons, loading, error, warnings, dispose }`. | +## Loading Mesh Files -### Utility +Use `loadMesh()` from `@layoutit/polycss`, `@layoutit/polycss-react`, or `@layoutit/polycss-vue` to parse supported model formats: -| Export | Description | -|---|---| -| `injectPolyBaseStyles(doc?)` | Inject polycss base CSS into the document. Idempotent. | +```ts +import { createPolyCamera, createPolyScene, loadMesh } from "@layoutit/polycss"; -## Re-exports from `@layoutit/polycss-core` +const host = document.getElementById("polycss")!; +const camera = createPolyCamera({ rotX: 65, rotY: 45 }); +const scene = createPolyScene(host, { camera }); -All types and core functions are re-exported: +const mesh = await loadMesh("https://polycss.com/gallery/obj/cottage.obj", { + mtlUrl: "https://polycss.com/gallery/obj/cottage.mtl", +}); -```ts -import type { Polygon, Vec2, Vec3, PolyDirectionalLight, PolyAmbientLight, ParseResult } from "@layoutit/polycss-vue"; -import { parseObj, parseGltf, parseVox, loadMesh, normalizePolygons, mergePolygons } from "@layoutit/polycss-vue"; +scene.add(mesh); ``` -## Examples +Supported formats: -### With lighting and multiple meshes +- OBJ + MTL, including `map_Kd` textures and UV coordinates. +- glTF / GLB, including embedded images and `TEXCOORD_0`. +- MagicaVoxel `.vox`, with direct voxel fast paths when eligible. +- Generated primitives: box, plane, ring, sphere, torus, cylinder, cone, and Platonic solids. -```vue - +## Performance - -``` +polycss renders in the DOM, so performance is mostly determined by how many polygons are mounted and how much texture atlas area they consume. The renderer uses several CSS strategies so simple surfaces stay cheap and textured or irregular surfaces fall back to atlas slices. -### Per-polygon interactive +- One visible polygon becomes one leaf DOM element. +- Flat rectangles and stable quads use solid CSS leaves. +- Textured polygons are packed into generated texture atlases. +- Dynamic lighting runs through CSS custom properties instead of per-frame JavaScript. +- Voxel-shaped meshes mount only camera-facing leaves when the mesh is eligible. +- `meshResolution: "lossy"` can merge compatible polygons to reduce DOM node count. -```vue - +For diagnostics, all renderer packages export `collectPolyRenderStats(root)`, which returns mounted polygon leaf counts, shadow counts, surface categories, and bucket counts for an already-rendered scene. - +| Package | Description | +|---|---| +| `@layoutit/polycss-core` | Pure math, parsers, lighting, camera helpers, mesh optimization. Zero browser globals. | +| `@layoutit/polycss` | Vanilla custom elements and imperative `createPolyScene` API. | +| `@layoutit/polycss-react` | React components, hooks, controls, and core re-exports. | +| `@layoutit/polycss-vue` | Vue 3 components, composables, controls, and core re-exports. | - -``` +## Made with polycss -### `PolyMesh` with scoped slot +[Layoutit Voxels](https://voxels.layoutit.com) +-> A CSS Voxel editor -```vue - +layoutit-voxels - -``` +[Layoutit Terra](https://terra.layoutit.com) +-> A CSS Terrain Generator + +layoutit-terra -## Docs +## License -Full documentation at [polycss.com](https://polycss.com). +MIT. diff --git a/packages/vue/package.json b/packages/vue/package.json index 28d13b0f..f67ad737 100644 --- a/packages/vue/package.json +++ b/packages/vue/package.json @@ -31,6 +31,7 @@ "build": "tsup", "test": "vitest run", "test:coverage": "vitest run --coverage", + "prepack": "node ../../tools/sync-package-readmes.mjs", "prepublishOnly": "npm run build" }, "publishConfig": { diff --git a/packages/vue/src/index.ts b/packages/vue/src/index.ts index a5f45b21..d8d6afbf 100644 --- a/packages/vue/src/index.ts +++ b/packages/vue/src/index.ts @@ -75,6 +75,13 @@ export type { export { injectPolyBaseStyles } from "./styles"; +export { collectPolyRenderStats } from "./renderStats"; +export type { + PolyRenderStats, + PolyRenderStatsOptions, + PolyRenderSurfaceLeafCounts, +} from "./renderStats"; + export { usePolyAnimation } from "./animation/usePolyAnimation"; export type { UsePolyAnimationResultVue } from "./animation/usePolyAnimation"; @@ -137,6 +144,7 @@ export type { CameraCullRotation, ApproximateMergeOptions, OptimizeMeshPolygonsOptions, + OptimizeAnimatedMeshPolygonsOptions, } from "@layoutit/polycss-core"; export { CAMERA_BACKFACE_CULL_EPS, @@ -196,6 +204,7 @@ export { DEFAULT_PROJECTION, normalizeInvertMultiplier, createPolyAnimationMixer, + optimizeAnimatedMeshPolygons, LoopOnce, LoopRepeat, LoopPingPong, diff --git a/packages/vue/src/renderStats.ts b/packages/vue/src/renderStats.ts new file mode 100644 index 00000000..efb557ce --- /dev/null +++ b/packages/vue/src/renderStats.ts @@ -0,0 +1,103 @@ +export interface PolyRenderSurfaceLeafCounts { + quad: number; + clippedSolid: number; + atlas: number; + stableTriangle: number; +} + +export interface PolyRenderStats { + polygonCount: number; + mountedPolygonLeafCount: number; + shadowLeafCount: number; + surfaceLeafCounts: PolyRenderSurfaceLeafCounts; + bucketCount: number; +} + +export interface PolyRenderStatsOptions { + polygonCount?: number; + /** + * Optional subtree selector for diagnostics that only want model leaves and + * not helpers/floors/gizmos sharing the same scene root. + */ + scopeSelector?: string; +} + +const ZERO_SURFACE_LEAF_COUNTS: PolyRenderSurfaceLeafCounts = { + quad: 0, + clippedSolid: 0, + atlas: 0, + stableTriangle: 0, +}; + +const EMPTY_POLY_RENDER_STATS: PolyRenderStats = { + polygonCount: 0, + mountedPolygonLeafCount: 0, + shadowLeafCount: 0, + surfaceLeafCounts: ZERO_SURFACE_LEAF_COUNTS, + bucketCount: 0, +}; + +function asOptions(optionsOrPolygonCount?: number | PolyRenderStatsOptions): PolyRenderStatsOptions { + if (typeof optionsOrPolygonCount === "number") { + return { polygonCount: optionsOrPolygonCount }; + } + return optionsOrPolygonCount ?? {}; +} + +function queryCount(scope: ParentNode, selector: string): number { + return scope.querySelectorAll(selector).length; +} + +function matchesSelector(root: ParentNode, selector: string): boolean { + const candidate = root as ParentNode & { matches?: (selector: string) => boolean }; + return typeof candidate.matches === "function" && candidate.matches(selector); +} + +function collectScopes(root: ParentNode, selector: string | undefined): ParentNode[] { + if (!selector) return [root]; + const scopes: ParentNode[] = []; + if (matchesSelector(root, selector)) scopes.push(root); + scopes.push(...Array.from(root.querySelectorAll(selector))); + return scopes; +} + +export function collectPolyRenderStats( + root: ParentNode | null | undefined, + optionsOrPolygonCount?: number | PolyRenderStatsOptions, +): PolyRenderStats { + const options = asOptions(optionsOrPolygonCount); + if (!root) { + return { + ...EMPTY_POLY_RENDER_STATS, + surfaceLeafCounts: { ...ZERO_SURFACE_LEAF_COUNTS }, + polygonCount: options.polygonCount ?? 0, + }; + } + + const scopes = collectScopes(root, options.scopeSelector); + const surfaceLeafCounts: PolyRenderSurfaceLeafCounts = { ...ZERO_SURFACE_LEAF_COUNTS }; + let shadowLeafCount = 0; + let bucketCount = 0; + + for (const scope of scopes) { + surfaceLeafCounts.quad += queryCount(scope, "b"); + surfaceLeafCounts.clippedSolid += queryCount(scope, "i"); + surfaceLeafCounts.atlas += queryCount(scope, "s"); + surfaceLeafCounts.stableTriangle += queryCount(scope, "u"); + shadowLeafCount += queryCount(scope, "q"); + bucketCount += queryCount(scope, ".polycss-bucket"); + } + + const mountedPolygonLeafCount = + surfaceLeafCounts.quad + + surfaceLeafCounts.clippedSolid + + surfaceLeafCounts.atlas + + surfaceLeafCounts.stableTriangle; + return { + polygonCount: options.polygonCount ?? mountedPolygonLeafCount, + mountedPolygonLeafCount, + shadowLeafCount, + surfaceLeafCounts, + bucketCount, + }; +} diff --git a/packages/vue/src/scene/PolyMesh.events.test.ts b/packages/vue/src/scene/PolyMesh.events.test.ts index 1c7b0f08..fbc99d10 100644 --- a/packages/vue/src/scene/PolyMesh.events.test.ts +++ b/packages/vue/src/scene/PolyMesh.events.test.ts @@ -399,6 +399,22 @@ describe("PolyMesh (Vue) — updatePolygon", () => { expect(handle.getPolygons()[0].color).toBe("#ff0000"); }); + it("setPolygons updates stable triangle leaves without remounting", async () => { + const p0: Polygon = { vertices: TRIANGLE.vertices, color: "#ff0000" }; + const p1: Polygon = { vertices: [[0, 0, 0], [2, 0, 0], [0, 1, 0]], color: "#00ff00" }; + const { container, getHandle } = mountMesh({ polygons: [p0] }); + const handle = getHandle()!; + const leafBefore = container.querySelector("u"); + expect(leafBefore).toBeTruthy(); + const transformBefore = (leafBefore as HTMLElement).style.transform; + handle.setPolygons([p1]); + await nextTick(); + const leafAfter = container.querySelector("u"); + expect(leafAfter).toBe(leafBefore); + expect(handle.getPolygons()[0]).toBe(p1); + expect((leafAfter as HTMLElement).style.transform).not.toBe(transformBefore); + }); + it("merges partial fields onto the polygon without touching other fields", async () => { const poly: Polygon = { vertices: TRIANGLE.vertices, color: "#ff0000" }; const { getHandle } = mountMesh({ polygons: [poly] }); diff --git a/packages/vue/src/scene/PolyMesh.ts b/packages/vue/src/scene/PolyMesh.ts index bb19b869..9ce16a1d 100644 --- a/packages/vue/src/scene/PolyMesh.ts +++ b/packages/vue/src/scene/PolyMesh.ts @@ -32,6 +32,7 @@ import { renderTextureBorderShapePoly, renderTextureAtlasPoly, renderTextureTrianglePoly, + updateStableTriangleDom, useTextureAtlas, } from "./textureAtlas"; import { usePolySceneContext } from "./sceneContext"; @@ -191,7 +192,11 @@ export const PolyMesh = defineComponent({ // is called. Reset to null whenever the upstream polygon source changes so // a fresh prop assignment or a completed src-fetch wins over stale edits. const polygonOverride = ref(null); - watch(propPolygons, () => { polygonOverride.value = null; }); + let imperativePolygons: Polygon[] | null = null; + watch(propPolygons, () => { + polygonOverride.value = null; + imperativePolygons = null; + }); const sourcePolygons = computed(() => polygonOverride.value ?? propPolygons.value @@ -245,20 +250,20 @@ export const PolyMesh = defineComponent({ // The visual wrapper uses the live `rotation` prop (smooth feedback); // the atlas uses bakedRotation (jumps to current rotation on release). const bakedRotation = ref(props.rotation); + const stableTriangleColorFrame = ref(0); + + const bakedDirectional = computed(() => { + const baseLight = atlasDirectional.value; + if (!baseLight || !bakedRotation.value) return baseLight; + return { ...baseLight, direction: inverseRotateVec3(baseLight.direction, bakedRotation.value) }; + }); const textureAtlasPlans = computed(() => { if (!atlasAutoRender) return []; - const baseLight = atlasDirectional.value; - // Inverse-rotate the world light into the mesh-local frame so the - // pre-multiplied Lambert term stays correct after the mesh rotates. - // dot(localNormal, localLight) === dot(worldNormal, worldLight). - const effectiveLight = baseLight && bakedRotation.value - ? { ...baseLight, direction: inverseRotateVec3(baseLight.direction, bakedRotation.value) } - : baseLight; const repairEdges = buildTextureEdgeRepairSets(polygons.value); return polygons.value.map((p, i) => computeTextureAtlasPlan(p, i, { - directionalLight: effectiveLight, + directionalLight: bakedDirectional.value, ambientLight: atlasAmbient.value, textureEdgeRepairEdges: repairEdges[i], }), @@ -354,9 +359,29 @@ export const PolyMesh = defineComponent({ getPosition: () => props.position, getRotation: () => props.rotation, getScale: () => props.scale, - getPolygons: () => polygons.value, + getPolygons: () => imperativePolygons ?? polygons.value, + setPolygons(nextPolygons: Polygon[]) { + const nextRenderedPolygons = props.autoCenter ? recenterPolygons(nextPolygons) : nextPolygons; + imperativePolygons = nextRenderedPolygons; + const root = wrapperRef.value; + if ( + root && + atlasAutoRender && + updateStableTriangleDom(root, nextRenderedPolygons, { + directionalLight: bakedDirectional.value, + ambientLight: atlasAmbient.value, + textureLighting: atlasTextureLighting.value, + colorFrame: ++stableTriangleColorFrame.value, + colorFreezeFrames: 12, + colorMaxStep: 8, + }) + ) { + return; + } + polygonOverride.value = nextPolygons.slice(); + }, updatePolygon(target: Polygon | number, partial: Partial) { - const current = polygons.value; + const current = imperativePolygons ?? polygons.value; const idx = typeof target === "number" ? target : current.indexOf(target); @@ -366,6 +391,7 @@ export const PolyMesh = defineComponent({ // re-renders the atlas (the polygon object itself is mutated // in place to preserve identity for callers holding a ref). polygonOverride.value = current.slice(); + imperativePolygons = null; }, rebakeAtlas: () => { bakedRotation.value = props.rotation; diff --git a/packages/vue/src/scene/events.test.ts b/packages/vue/src/scene/events.test.ts index 12ff975c..603a0323 100644 --- a/packages/vue/src/scene/events.test.ts +++ b/packages/vue/src/scene/events.test.ts @@ -43,6 +43,7 @@ function makeHandle(el: HTMLElement, id = "test"): PolyMeshHandle { getRotation: () => [0, 0, 0] as Vec3, getScale: () => 1, getPolygons: () => [] as Polygon[], + setPolygons: () => {}, rebakeAtlas: () => {}, }; } diff --git a/packages/vue/src/scene/events.ts b/packages/vue/src/scene/events.ts index a167ab09..c85b0312 100644 --- a/packages/vue/src/scene/events.ts +++ b/packages/vue/src/scene/events.ts @@ -24,6 +24,12 @@ export interface PolyMeshHandle { getRotation(): Vec3 | undefined; getScale(): number | Vec3 | undefined; getPolygons(): Polygon[]; + /** + * Replace the mesh polygons imperatively. Animated solid-triangle meshes use + * a stable DOM update path that mutates mounted leaves directly; unsupported + * topology falls back to the normal framework render path. + */ + setPolygons(polygons: Polygon[]): void; /** * Update a single polygon in place. `target` is either a polygon * reference (as returned by `getPolygons()`) or its index. `partial` diff --git a/packages/vue/src/scene/textureAtlas.ts b/packages/vue/src/scene/textureAtlas.ts index 9d1ab282..02dc4ae8 100644 --- a/packages/vue/src/scene/textureAtlas.ts +++ b/packages/vue/src/scene/textureAtlas.ts @@ -67,6 +67,10 @@ export interface PolyRenderStrategiesOption { interface RGB { r: number; g: number; b: number; } interface RGBFactors { r: number; g: number; b: number; } +interface StableTriangleDomElement extends HTMLElement { + __polycssStableTriangleColor?: string; + __polycssStableTriangleColorRgb?: RGB; +} interface UvAffine { a: number; @@ -1129,6 +1133,19 @@ function rgbToHex({ r, g, b }: RGB): string { return `#${f(r)}${f(g)}${f(b)}`; } +function stepRgbToward(current: RGB, target: RGB, maxStep: number): RGB { + const step = (from: number, to: number) => { + if (from === to) return from; + const delta = to - from; + return from + Math.sign(delta) * Math.min(Math.abs(delta), maxStep); + }; + return { + r: step(current.r, target.r), + g: step(current.g, target.g), + b: step(current.b, target.b), + }; +} + function shadePolygon( baseColor: string, directScale: number, @@ -1174,6 +1191,92 @@ function tintToCss({ r, g, b }: RGBFactors): string { return `rgb(${f(r)} ${f(g)} ${f(b)})`; } +export interface StableTriangleDomUpdateOptions { + directionalLight?: PolyDirectionalLight; + ambientLight?: PolyAmbientLight; + textureLighting?: PolyTextureLightingMode; + colorFrame?: number; + colorFreezeFrames?: number; + colorMaxStep?: number; +} + +function stableTriangleColorAllowed(index: number, colorFrame: number, freezeFrames: number): boolean { + return freezeFrames > 0 && (freezeFrames <= 1 || (colorFrame + index) % freezeFrames === 0); +} + +function applyStableTriangleColor( + el: StableTriangleDomElement, + index: number, + nextColor: string, + options: StableTriangleDomUpdateOptions, +): void { + const freezeFrames = Math.floor(options.colorFreezeFrames ?? 1); + if (freezeFrames === 0) return; + + const currentColor = el.__polycssStableTriangleColor; + const shouldWrite = currentColor === undefined || + stableTriangleColorAllowed( + index, + Math.max(0, Math.floor(options.colorFrame ?? 0)), + Math.max(1, freezeFrames), + ); + if (!shouldWrite || currentColor === nextColor) return; + + let writeColor = nextColor; + let writeRgb = nextColor ? parseHex(nextColor) : undefined; + const currentRgb = el.__polycssStableTriangleColorRgb; + const maxStep = Math.max(0, Math.floor(options.colorMaxStep ?? 0)); + if (maxStep > 0 && currentRgb && writeRgb && nextColor) { + writeRgb = stepRgbToward(currentRgb, writeRgb, maxStep); + writeColor = rgbToHex(writeRgb); + } + + el.style.color = writeColor; + el.__polycssStableTriangleColor = writeColor; + el.__polycssStableTriangleColorRgb = writeRgb; +} + +export function updateStableTriangleDom( + root: HTMLElement, + polygons: Polygon[], + options: StableTriangleDomUpdateOptions = {}, +): boolean { + if ((options.textureLighting ?? "baked") !== "baked") return false; + if (!solidTriangleSupported()) return false; + const leaves = Array.from(root.children).filter( + (child): child is StableTriangleDomElement => + child instanceof HTMLElement && child.localName === "u", + ); + if (leaves.length !== polygons.length) return false; + + const plans = polygons.map((polygon, index) => { + if (polygon.texture || polygon.vertices.length !== 3) return null; + const plan = computeTextureAtlasPlan(polygon, index, { + directionalLight: options.directionalLight, + ambientLight: options.ambientLight, + }); + if (!plan || !isSolidTrianglePlan(plan)) return null; + const style = solidTriangleStyle(plan, "baked", "auto", {}); + if (!style?.transform) return null; + return style; + }); + if (plans.some((plan) => !plan)) return false; + + for (let i = 0; i < leaves.length; i += 1) { + const style = plans[i]!; + const el = leaves[i]; + if (el.style.visibility) el.style.visibility = ""; + el.style.transform = String(style.transform); + applyStableTriangleColor( + el, + i, + typeof style.color === "string" ? style.color : "", + options, + ); + } + return true; +} + function applyTextureTint( ctx: CanvasRenderingContext2D, x: number, diff --git a/tools/sync-package-readmes.mjs b/tools/sync-package-readmes.mjs new file mode 100644 index 00000000..3111152d --- /dev/null +++ b/tools/sync-package-readmes.mjs @@ -0,0 +1,18 @@ +import { copyFileSync } from "node:fs"; +import { dirname, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; + +const repoRoot = resolve(dirname(fileURLToPath(import.meta.url)), ".."); +const source = resolve(repoRoot, "README.md"); +const targets = [ + "packages/core/README.md", + "packages/polycss/README.md", + "packages/react/README.md", + "packages/vue/README.md", +]; + +for (const target of targets) { + copyFileSync(source, resolve(repoRoot, target)); +} + +console.log(`[sync-package-readmes] copied README.md to ${targets.length} package READMEs`); diff --git a/website/public/gallery/glb/Bicycle.glb b/website/public/gallery/glb/Bicycle.glb deleted file mode 100644 index dadc6d66..00000000 Binary files a/website/public/gallery/glb/Bicycle.glb and /dev/null differ diff --git a/website/public/gallery/glb/city/Large Building-1bt4yYKmuK.glb b/website/public/gallery/glb/city/Large Building-1bt4yYKmuK.glb deleted file mode 100644 index 02352da5..00000000 Binary files a/website/public/gallery/glb/city/Large Building-1bt4yYKmuK.glb and /dev/null differ diff --git a/website/public/gallery/glb/city/Large Building-3IhrYZp6tP.glb b/website/public/gallery/glb/city/Large Building-3IhrYZp6tP.glb deleted file mode 100644 index 571700b7..00000000 Binary files a/website/public/gallery/glb/city/Large Building-3IhrYZp6tP.glb and /dev/null differ diff --git a/website/public/gallery/glb/city/Large Building-JgGLJH2iXj.glb b/website/public/gallery/glb/city/Large Building-JgGLJH2iXj.glb deleted file mode 100644 index e8b5dd53..00000000 Binary files a/website/public/gallery/glb/city/Large Building-JgGLJH2iXj.glb and /dev/null differ diff --git a/website/public/gallery/glb/city/Large Building-h7Jaq7bqMq.glb b/website/public/gallery/glb/city/Large Building-h7Jaq7bqMq.glb deleted file mode 100644 index 46ebe33d..00000000 Binary files a/website/public/gallery/glb/city/Large Building-h7Jaq7bqMq.glb and /dev/null differ diff --git a/website/public/gallery/glb/city/Large Building-ppwtREejXg.glb b/website/public/gallery/glb/city/Large Building-ppwtREejXg.glb deleted file mode 100644 index 711ecc2e..00000000 Binary files a/website/public/gallery/glb/city/Large Building-ppwtREejXg.glb and /dev/null differ diff --git a/website/public/gallery/glb/city/Large Building-sxXonOmtct.glb b/website/public/gallery/glb/city/Large Building-sxXonOmtct.glb deleted file mode 100644 index ddbca382..00000000 Binary files a/website/public/gallery/glb/city/Large Building-sxXonOmtct.glb and /dev/null differ diff --git a/website/public/gallery/glb/city/Low Building-4RoPd9BkSx.glb b/website/public/gallery/glb/city/Low Building-4RoPd9BkSx.glb deleted file mode 100644 index 89e67dfc..00000000 Binary files a/website/public/gallery/glb/city/Low Building-4RoPd9BkSx.glb and /dev/null differ diff --git a/website/public/gallery/glb/city/Low Building-9fEKMpTsAi.glb b/website/public/gallery/glb/city/Low Building-9fEKMpTsAi.glb deleted file mode 100644 index 09e1f99c..00000000 Binary files a/website/public/gallery/glb/city/Low Building-9fEKMpTsAi.glb and /dev/null differ diff --git a/website/public/gallery/glb/city/Low Building-AXFdNPAEc9.glb b/website/public/gallery/glb/city/Low Building-AXFdNPAEc9.glb deleted file mode 100644 index 189b0d84..00000000 Binary files a/website/public/gallery/glb/city/Low Building-AXFdNPAEc9.glb and /dev/null differ diff --git a/website/public/gallery/glb/city/Low Building-XsFOzw8E5N.glb b/website/public/gallery/glb/city/Low Building-XsFOzw8E5N.glb deleted file mode 100644 index e5e58a68..00000000 Binary files a/website/public/gallery/glb/city/Low Building-XsFOzw8E5N.glb and /dev/null differ diff --git a/website/public/gallery/glb/city/Low Building-dYEbYdPfJr.glb b/website/public/gallery/glb/city/Low Building-dYEbYdPfJr.glb deleted file mode 100644 index ceee5880..00000000 Binary files a/website/public/gallery/glb/city/Low Building-dYEbYdPfJr.glb and /dev/null differ diff --git a/website/public/gallery/glb/city/Low Building-sObKC8Mio2.glb b/website/public/gallery/glb/city/Low Building-sObKC8Mio2.glb deleted file mode 100644 index d7683e55..00000000 Binary files a/website/public/gallery/glb/city/Low Building-sObKC8Mio2.glb and /dev/null differ diff --git a/website/public/gallery/glb/city/Low Building-tuieC1Pj0a.glb b/website/public/gallery/glb/city/Low Building-tuieC1Pj0a.glb deleted file mode 100644 index 84e413e0..00000000 Binary files a/website/public/gallery/glb/city/Low Building-tuieC1Pj0a.glb and /dev/null differ diff --git a/website/public/gallery/glb/city/Low Building-zfjlejAxB7.glb b/website/public/gallery/glb/city/Low Building-zfjlejAxB7.glb deleted file mode 100644 index 5b25171a..00000000 Binary files a/website/public/gallery/glb/city/Low Building-zfjlejAxB7.glb and /dev/null differ diff --git a/website/public/gallery/glb/city/Low Building.glb b/website/public/gallery/glb/city/Low Building.glb deleted file mode 100644 index d0d61643..00000000 Binary files a/website/public/gallery/glb/city/Low Building.glb and /dev/null differ diff --git a/website/public/gallery/glb/city/Low Wide-DKgknsHjmr.glb b/website/public/gallery/glb/city/Low Wide-DKgknsHjmr.glb deleted file mode 100644 index 3958ef38..00000000 Binary files a/website/public/gallery/glb/city/Low Wide-DKgknsHjmr.glb and /dev/null differ diff --git a/website/public/gallery/glb/city/Low Wide.glb b/website/public/gallery/glb/city/Low Wide.glb deleted file mode 100644 index fc27f8f6..00000000 Binary files a/website/public/gallery/glb/city/Low Wide.glb and /dev/null differ diff --git a/website/public/gallery/glb/city/Sign Hospital.glb b/website/public/gallery/glb/city/Sign Hospital.glb deleted file mode 100644 index d4c778b0..00000000 Binary files a/website/public/gallery/glb/city/Sign Hospital.glb and /dev/null differ diff --git a/website/public/gallery/glb/city/Skyscraper-BwEXdOoUSO.glb b/website/public/gallery/glb/city/Skyscraper-BwEXdOoUSO.glb deleted file mode 100644 index 6e245146..00000000 Binary files a/website/public/gallery/glb/city/Skyscraper-BwEXdOoUSO.glb and /dev/null differ diff --git a/website/public/gallery/glb/city/Skyscraper-PsPe0MzK0E.glb b/website/public/gallery/glb/city/Skyscraper-PsPe0MzK0E.glb deleted file mode 100644 index 4f039831..00000000 Binary files a/website/public/gallery/glb/city/Skyscraper-PsPe0MzK0E.glb and /dev/null differ diff --git a/website/public/gallery/glb/city/Skyscraper-XST1j6kYsL.glb b/website/public/gallery/glb/city/Skyscraper-XST1j6kYsL.glb deleted file mode 100644 index d3455309..00000000 Binary files a/website/public/gallery/glb/city/Skyscraper-XST1j6kYsL.glb and /dev/null differ diff --git a/website/public/gallery/glb/city/Skyscraper-jIRx0AhYOR.glb b/website/public/gallery/glb/city/Skyscraper-jIRx0AhYOR.glb deleted file mode 100644 index 95b2a64b..00000000 Binary files a/website/public/gallery/glb/city/Skyscraper-jIRx0AhYOR.glb and /dev/null differ diff --git a/website/public/gallery/glb/city/Skyscraper-obYD8hWLTZ.glb b/website/public/gallery/glb/city/Skyscraper-obYD8hWLTZ.glb deleted file mode 100644 index b5267852..00000000 Binary files a/website/public/gallery/glb/city/Skyscraper-obYD8hWLTZ.glb and /dev/null differ diff --git a/website/public/gallery/glb/city/Small Building-QjL4Fo9dU9.glb b/website/public/gallery/glb/city/Small Building-QjL4Fo9dU9.glb deleted file mode 100644 index 299bb41f..00000000 Binary files a/website/public/gallery/glb/city/Small Building-QjL4Fo9dU9.glb and /dev/null differ diff --git a/website/public/gallery/glb/city/Small Building-Rq572hdKEz.glb b/website/public/gallery/glb/city/Small Building-Rq572hdKEz.glb deleted file mode 100644 index 0959c94e..00000000 Binary files a/website/public/gallery/glb/city/Small Building-Rq572hdKEz.glb and /dev/null differ diff --git a/website/public/gallery/glb/city/Small Building-gyjF60t7CG.glb b/website/public/gallery/glb/city/Small Building-gyjF60t7CG.glb deleted file mode 100644 index 738b10cd..00000000 Binary files a/website/public/gallery/glb/city/Small Building-gyjF60t7CG.glb and /dev/null differ diff --git a/website/public/gallery/glb/city/Small Building-t9j9Lof5ul.glb b/website/public/gallery/glb/city/Small Building-t9j9Lof5ul.glb deleted file mode 100644 index a2a76e17..00000000 Binary files a/website/public/gallery/glb/city/Small Building-t9j9Lof5ul.glb and /dev/null differ diff --git a/website/public/gallery/glb/city/Small Building-yLvnMqC9ZG.glb b/website/public/gallery/glb/city/Small Building-yLvnMqC9ZG.glb deleted file mode 100644 index f4566ec5..00000000 Binary files a/website/public/gallery/glb/city/Small Building-yLvnMqC9ZG.glb and /dev/null differ diff --git a/website/public/gallery/glb/khronos/animated-fox.glb b/website/public/gallery/glb/khronos/animated-fox.glb deleted file mode 100644 index 1ef5c0d0..00000000 Binary files a/website/public/gallery/glb/khronos/animated-fox.glb and /dev/null differ diff --git a/website/public/gallery/glb/medieval/Bag Open.glb b/website/public/gallery/glb/medieval/Bag Open.glb deleted file mode 100644 index 0c134129..00000000 Binary files a/website/public/gallery/glb/medieval/Bag Open.glb and /dev/null differ diff --git a/website/public/gallery/glb/medieval/Bag.glb b/website/public/gallery/glb/medieval/Bag.glb deleted file mode 100644 index 10c9fa9c..00000000 Binary files a/website/public/gallery/glb/medieval/Bag.glb and /dev/null differ diff --git a/website/public/gallery/glb/medieval/Bell Tower.glb b/website/public/gallery/glb/medieval/Bell Tower.glb deleted file mode 100644 index 8b394178..00000000 Binary files a/website/public/gallery/glb/medieval/Bell Tower.glb and /dev/null differ diff --git a/website/public/gallery/glb/medieval/Bench-7uSlZo3n9Y.glb b/website/public/gallery/glb/medieval/Bench-7uSlZo3n9Y.glb deleted file mode 100644 index cff40964..00000000 Binary files a/website/public/gallery/glb/medieval/Bench-7uSlZo3n9Y.glb and /dev/null differ diff --git a/website/public/gallery/glb/medieval/Bench.glb b/website/public/gallery/glb/medieval/Bench.glb deleted file mode 100644 index 2ae6b656..00000000 Binary files a/website/public/gallery/glb/medieval/Bench.glb and /dev/null differ diff --git a/website/public/gallery/glb/medieval/Blacksmith.glb b/website/public/gallery/glb/medieval/Blacksmith.glb deleted file mode 100644 index 83917c55..00000000 Binary files a/website/public/gallery/glb/medieval/Blacksmith.glb and /dev/null differ diff --git a/website/public/gallery/glb/medieval/Door Round.glb b/website/public/gallery/glb/medieval/Door Round.glb deleted file mode 100644 index a6011029..00000000 Binary files a/website/public/gallery/glb/medieval/Door Round.glb and /dev/null differ diff --git a/website/public/gallery/glb/medieval/Door Straight.glb b/website/public/gallery/glb/medieval/Door Straight.glb deleted file mode 100644 index 4e16d7ea..00000000 Binary files a/website/public/gallery/glb/medieval/Door Straight.glb and /dev/null differ diff --git a/website/public/gallery/glb/medieval/Fantasy Barracks.glb b/website/public/gallery/glb/medieval/Fantasy Barracks.glb deleted file mode 100644 index 8beb250a..00000000 Binary files a/website/public/gallery/glb/medieval/Fantasy Barracks.glb and /dev/null differ diff --git a/website/public/gallery/glb/medieval/Fantasy House-BH2XHWUNmF.glb b/website/public/gallery/glb/medieval/Fantasy House-BH2XHWUNmF.glb deleted file mode 100644 index b2733641..00000000 Binary files a/website/public/gallery/glb/medieval/Fantasy House-BH2XHWUNmF.glb and /dev/null differ diff --git a/website/public/gallery/glb/medieval/Fantasy House-dcPho4SUA3.glb b/website/public/gallery/glb/medieval/Fantasy House-dcPho4SUA3.glb deleted file mode 100644 index daaa54f0..00000000 Binary files a/website/public/gallery/glb/medieval/Fantasy House-dcPho4SUA3.glb and /dev/null differ diff --git a/website/public/gallery/glb/medieval/Fantasy House.glb b/website/public/gallery/glb/medieval/Fantasy House.glb deleted file mode 100644 index 74e71b5f..00000000 Binary files a/website/public/gallery/glb/medieval/Fantasy House.glb and /dev/null differ diff --git a/website/public/gallery/glb/medieval/Fantasy Inn.glb b/website/public/gallery/glb/medieval/Fantasy Inn.glb deleted file mode 100644 index 773f9184..00000000 Binary files a/website/public/gallery/glb/medieval/Fantasy Inn.glb and /dev/null differ diff --git a/website/public/gallery/glb/medieval/Fantasy Sawmill.glb b/website/public/gallery/glb/medieval/Fantasy Sawmill.glb deleted file mode 100644 index 61bb00a5..00000000 Binary files a/website/public/gallery/glb/medieval/Fantasy Sawmill.glb and /dev/null differ diff --git a/website/public/gallery/glb/medieval/Fantasy Stable.glb b/website/public/gallery/glb/medieval/Fantasy Stable.glb deleted file mode 100644 index 0a5d8cf3..00000000 Binary files a/website/public/gallery/glb/medieval/Fantasy Stable.glb and /dev/null differ diff --git a/website/public/gallery/glb/medieval/Fence.glb b/website/public/gallery/glb/medieval/Fence.glb deleted file mode 100644 index 562e389e..00000000 Binary files a/website/public/gallery/glb/medieval/Fence.glb and /dev/null differ diff --git a/website/public/gallery/glb/medieval/Gazebo.glb b/website/public/gallery/glb/medieval/Gazebo.glb deleted file mode 100644 index 7d96b269..00000000 Binary files a/website/public/gallery/glb/medieval/Gazebo.glb and /dev/null differ diff --git a/website/public/gallery/glb/medieval/Hay.glb b/website/public/gallery/glb/medieval/Hay.glb deleted file mode 100644 index f6b0f882..00000000 Binary files a/website/public/gallery/glb/medieval/Hay.glb and /dev/null differ diff --git a/website/public/gallery/glb/medieval/Market Stand-DGIM5HGISb.glb b/website/public/gallery/glb/medieval/Market Stand-DGIM5HGISb.glb deleted file mode 100644 index 3894530a..00000000 Binary files a/website/public/gallery/glb/medieval/Market Stand-DGIM5HGISb.glb and /dev/null differ diff --git a/website/public/gallery/glb/medieval/Market Stand.glb b/website/public/gallery/glb/medieval/Market Stand.glb deleted file mode 100644 index 049e04ad..00000000 Binary files a/website/public/gallery/glb/medieval/Market Stand.glb and /dev/null differ diff --git a/website/public/gallery/glb/medieval/Mill.glb b/website/public/gallery/glb/medieval/Mill.glb deleted file mode 100644 index bf421e0d..00000000 Binary files a/website/public/gallery/glb/medieval/Mill.glb and /dev/null differ diff --git a/website/public/gallery/glb/medieval/Round Window.glb b/website/public/gallery/glb/medieval/Round Window.glb deleted file mode 100644 index 7321b0c3..00000000 Binary files a/website/public/gallery/glb/medieval/Round Window.glb and /dev/null differ diff --git a/website/public/gallery/glb/medieval/Stairs.glb b/website/public/gallery/glb/medieval/Stairs.glb deleted file mode 100644 index 67ec6739..00000000 Binary files a/website/public/gallery/glb/medieval/Stairs.glb and /dev/null differ diff --git a/website/public/gallery/glb/poly-pizza/animated-enemy-small.glb b/website/public/gallery/glb/poly-pizza/animated-enemy-small.glb new file mode 100644 index 00000000..874e7db5 Binary files /dev/null and b/website/public/gallery/glb/poly-pizza/animated-enemy-small.glb differ diff --git a/website/public/gallery/glb/poly-pizza/animated-fox-quaternius.glb b/website/public/gallery/glb/poly-pizza/animated-fox-quaternius.glb new file mode 100644 index 00000000..55447e95 Binary files /dev/null and b/website/public/gallery/glb/poly-pizza/animated-fox-quaternius.glb differ diff --git a/website/public/gallery/glb/poly-pizza/animated-human-texture.png b/website/public/gallery/glb/poly-pizza/animated-human-texture.png new file mode 100644 index 00000000..9defc939 Binary files /dev/null and b/website/public/gallery/glb/poly-pizza/animated-human-texture.png differ diff --git a/website/public/gallery/glb/poly-pizza/animated-human.glb b/website/public/gallery/glb/poly-pizza/animated-human.glb index de56d83c..87f66e75 100644 Binary files a/website/public/gallery/glb/poly-pizza/animated-human.glb and b/website/public/gallery/glb/poly-pizza/animated-human.glb differ diff --git a/website/public/gallery/glb/poly-pizza/animated-husky.glb b/website/public/gallery/glb/poly-pizza/animated-husky.glb new file mode 100644 index 00000000..b27a3050 Binary files /dev/null and b/website/public/gallery/glb/poly-pizza/animated-husky.glb differ diff --git a/website/public/gallery/glb/poly-pizza/animated-shark.glb b/website/public/gallery/glb/poly-pizza/animated-shark.glb new file mode 100644 index 00000000..a837caf0 Binary files /dev/null and b/website/public/gallery/glb/poly-pizza/animated-shark.glb differ diff --git a/website/public/gallery/glb/poly-pizza/animated-shiba-inu.glb b/website/public/gallery/glb/poly-pizza/animated-shiba-inu.glb new file mode 100644 index 00000000..2adf5b3e Binary files /dev/null and b/website/public/gallery/glb/poly-pizza/animated-shiba-inu.glb differ diff --git a/website/public/gallery/glb/poly-pizza/animated-slime-enemy.glb b/website/public/gallery/glb/poly-pizza/animated-slime-enemy.glb new file mode 100644 index 00000000..c776795e Binary files /dev/null and b/website/public/gallery/glb/poly-pizza/animated-slime-enemy.glb differ diff --git a/website/public/gallery/glb/poly-pizza/animated-whale.glb b/website/public/gallery/glb/poly-pizza/animated-whale.glb new file mode 100644 index 00000000..3110841f Binary files /dev/null and b/website/public/gallery/glb/poly-pizza/animated-whale.glb differ diff --git a/website/public/gallery/glb/poly-pizza/bird.glb b/website/public/gallery/glb/poly-pizza/bird.glb deleted file mode 100644 index 036c4e83..00000000 Binary files a/website/public/gallery/glb/poly-pizza/bird.glb and /dev/null differ diff --git a/website/public/gallery/glb/poly-pizza/guard-tower.glb b/website/public/gallery/glb/poly-pizza/guard-tower.glb deleted file mode 100644 index 922f842b..00000000 Binary files a/website/public/gallery/glb/poly-pizza/guard-tower.glb and /dev/null differ diff --git a/website/public/gallery/glb/poly-pizza/house.glb b/website/public/gallery/glb/poly-pizza/house.glb deleted file mode 100644 index 067c3059..00000000 Binary files a/website/public/gallery/glb/poly-pizza/house.glb and /dev/null differ diff --git a/website/public/gallery/glb/poly-pizza/large-building.glb b/website/public/gallery/glb/poly-pizza/large-building.glb deleted file mode 100644 index 91058a40..00000000 Binary files a/website/public/gallery/glb/poly-pizza/large-building.glb and /dev/null differ diff --git a/website/public/gallery/glb/poly-pizza/skyscraper.glb b/website/public/gallery/glb/poly-pizza/skyscraper.glb deleted file mode 100644 index d3455309..00000000 Binary files a/website/public/gallery/glb/poly-pizza/skyscraper.glb and /dev/null differ diff --git a/website/public/gallery/glb/poly-pizza/tower.glb b/website/public/gallery/glb/poly-pizza/tower.glb deleted file mode 100644 index e21192a8..00000000 Binary files a/website/public/gallery/glb/poly-pizza/tower.glb and /dev/null differ diff --git a/website/public/gallery/glb/poly-pizza/two-story-house.glb b/website/public/gallery/glb/poly-pizza/two-story-house.glb deleted file mode 100644 index 5307eb69..00000000 Binary files a/website/public/gallery/glb/poly-pizza/two-story-house.glb and /dev/null differ diff --git a/website/public/gallery/glb/poly-pizza/watch-tower.glb b/website/public/gallery/glb/poly-pizza/watch-tower.glb deleted file mode 100644 index 773dd697..00000000 Binary files a/website/public/gallery/glb/poly-pizza/watch-tower.glb and /dev/null differ diff --git a/website/public/gallery/glb/poly-pizza/window-round.glb b/website/public/gallery/glb/poly-pizza/window-round.glb deleted file mode 100644 index e11fd5cf..00000000 Binary files a/website/public/gallery/glb/poly-pizza/window-round.glb and /dev/null differ diff --git a/website/public/gallery/glb/urban/Animated Woman-nIItLV9nxS.glb b/website/public/gallery/glb/urban/Animated Woman-nIItLV9nxS.glb deleted file mode 100644 index bdbe4689..00000000 Binary files a/website/public/gallery/glb/urban/Animated Woman-nIItLV9nxS.glb and /dev/null differ diff --git a/website/public/gallery/glb/urban/Animated Woman-qJ2gsTUBHL.glb b/website/public/gallery/glb/urban/Animated Woman-qJ2gsTUBHL.glb deleted file mode 100644 index 818ea386..00000000 Binary files a/website/public/gallery/glb/urban/Animated Woman-qJ2gsTUBHL.glb and /dev/null differ diff --git a/website/public/gallery/glb/urban/Bench.glb b/website/public/gallery/glb/urban/Bench.glb deleted file mode 100644 index 019c2242..00000000 Binary files a/website/public/gallery/glb/urban/Bench.glb and /dev/null differ diff --git a/website/public/gallery/glb/urban/Bicycle.glb b/website/public/gallery/glb/urban/Bicycle.glb deleted file mode 100644 index ebbb3bbe..00000000 Binary files a/website/public/gallery/glb/urban/Bicycle.glb and /dev/null differ diff --git a/website/public/gallery/glb/urban/Big Building.glb b/website/public/gallery/glb/urban/Big Building.glb deleted file mode 100644 index 40686ec5..00000000 Binary files a/website/public/gallery/glb/urban/Big Building.glb and /dev/null differ diff --git a/website/public/gallery/glb/urban/Brown Building.glb b/website/public/gallery/glb/urban/Brown Building.glb deleted file mode 100644 index b094c355..00000000 Binary files a/website/public/gallery/glb/urban/Brown Building.glb and /dev/null differ diff --git a/website/public/gallery/glb/urban/Building Green.glb b/website/public/gallery/glb/urban/Building Green.glb deleted file mode 100644 index 66afd46b..00000000 Binary files a/website/public/gallery/glb/urban/Building Green.glb and /dev/null differ diff --git a/website/public/gallery/glb/urban/Building Red Corner.glb b/website/public/gallery/glb/urban/Building Red Corner.glb deleted file mode 100644 index f16f824f..00000000 Binary files a/website/public/gallery/glb/urban/Building Red Corner.glb and /dev/null differ diff --git a/website/public/gallery/glb/urban/Building Red.glb b/website/public/gallery/glb/urban/Building Red.glb deleted file mode 100644 index d4d81014..00000000 Binary files a/website/public/gallery/glb/urban/Building Red.glb and /dev/null differ diff --git a/website/public/gallery/glb/urban/Bus Stop.glb b/website/public/gallery/glb/urban/Bus Stop.glb deleted file mode 100644 index 896d7f3a..00000000 Binary files a/website/public/gallery/glb/urban/Bus Stop.glb and /dev/null differ diff --git a/website/public/gallery/glb/urban/Bus stop sign.glb b/website/public/gallery/glb/urban/Bus stop sign.glb deleted file mode 100644 index 226bd067..00000000 Binary files a/website/public/gallery/glb/urban/Bus stop sign.glb and /dev/null differ diff --git a/website/public/gallery/glb/urban/Car-unqqkULtRU.glb b/website/public/gallery/glb/urban/Car-unqqkULtRU.glb deleted file mode 100644 index e9fae95f..00000000 Binary files a/website/public/gallery/glb/urban/Car-unqqkULtRU.glb and /dev/null differ diff --git a/website/public/gallery/glb/urban/Debris Papers.glb b/website/public/gallery/glb/urban/Debris Papers.glb deleted file mode 100644 index 3255788d..00000000 Binary files a/website/public/gallery/glb/urban/Debris Papers.glb and /dev/null differ diff --git a/website/public/gallery/glb/urban/Fence End.glb b/website/public/gallery/glb/urban/Fence End.glb deleted file mode 100644 index 70911659..00000000 Binary files a/website/public/gallery/glb/urban/Fence End.glb and /dev/null differ diff --git a/website/public/gallery/glb/urban/Fence Piece.glb b/website/public/gallery/glb/urban/Fence Piece.glb deleted file mode 100644 index 06c17fe2..00000000 Binary files a/website/public/gallery/glb/urban/Fence Piece.glb and /dev/null differ diff --git a/website/public/gallery/glb/urban/Fence.glb b/website/public/gallery/glb/urban/Fence.glb deleted file mode 100644 index 953f5f9c..00000000 Binary files a/website/public/gallery/glb/urban/Fence.glb and /dev/null differ diff --git a/website/public/gallery/glb/urban/Fire Exit.glb b/website/public/gallery/glb/urban/Fire Exit.glb deleted file mode 100644 index 8d2bc81a..00000000 Binary files a/website/public/gallery/glb/urban/Fire Exit.glb and /dev/null differ diff --git a/website/public/gallery/glb/urban/Floor Hole.glb b/website/public/gallery/glb/urban/Floor Hole.glb deleted file mode 100644 index 20094f82..00000000 Binary files a/website/public/gallery/glb/urban/Floor Hole.glb and /dev/null differ diff --git a/website/public/gallery/glb/urban/Flower Pot-Kgt363WkKd.glb b/website/public/gallery/glb/urban/Flower Pot-Kgt363WkKd.glb deleted file mode 100644 index 74e41374..00000000 Binary files a/website/public/gallery/glb/urban/Flower Pot-Kgt363WkKd.glb and /dev/null differ diff --git a/website/public/gallery/glb/urban/Flower Pot.glb b/website/public/gallery/glb/urban/Flower Pot.glb deleted file mode 100644 index 07327b36..00000000 Binary files a/website/public/gallery/glb/urban/Flower Pot.glb and /dev/null differ diff --git a/website/public/gallery/glb/urban/Greenhouse.glb b/website/public/gallery/glb/urban/Greenhouse.glb deleted file mode 100644 index 219614df..00000000 Binary files a/website/public/gallery/glb/urban/Greenhouse.glb and /dev/null differ diff --git a/website/public/gallery/glb/urban/Pizza Corner.glb b/website/public/gallery/glb/urban/Pizza Corner.glb deleted file mode 100644 index a76d8b27..00000000 Binary files a/website/public/gallery/glb/urban/Pizza Corner.glb and /dev/null differ diff --git a/website/public/gallery/glb/urban/Roof Exit.glb b/website/public/gallery/glb/urban/Roof Exit.glb deleted file mode 100644 index 42f89808..00000000 Binary files a/website/public/gallery/glb/urban/Roof Exit.glb and /dev/null differ diff --git a/website/public/gallery/glb/urban/Sports Car-Gzj704DXdr.glb b/website/public/gallery/glb/urban/Sports Car-Gzj704DXdr.glb deleted file mode 100644 index 92dd327e..00000000 Binary files a/website/public/gallery/glb/urban/Sports Car-Gzj704DXdr.glb and /dev/null differ diff --git a/website/public/gallery/glb/urban/Traffic Light.glb b/website/public/gallery/glb/urban/Traffic Light.glb deleted file mode 100644 index 4c362b2b..00000000 Binary files a/website/public/gallery/glb/urban/Traffic Light.glb and /dev/null differ diff --git a/website/public/gallery/glb/urban/Trash Can.glb b/website/public/gallery/glb/urban/Trash Can.glb deleted file mode 100644 index 15862b2e..00000000 Binary files a/website/public/gallery/glb/urban/Trash Can.glb and /dev/null differ diff --git a/website/public/gallery/glb/urban/Tree.glb b/website/public/gallery/glb/urban/Tree.glb deleted file mode 100644 index 216fe773..00000000 Binary files a/website/public/gallery/glb/urban/Tree.glb and /dev/null differ diff --git a/website/public/gallery/glb/urban/Washing Line.glb b/website/public/gallery/glb/urban/Washing Line.glb deleted file mode 100644 index 600e5918..00000000 Binary files a/website/public/gallery/glb/urban/Washing Line.glb and /dev/null differ diff --git a/website/public/gallery/glb/urban/trah bag grey.glb b/website/public/gallery/glb/urban/trah bag grey.glb deleted file mode 100644 index cd9fc746..00000000 Binary files a/website/public/gallery/glb/urban/trah bag grey.glb and /dev/null differ diff --git a/website/public/gallery/obj/opengameart/hay-bale/hay_bale.mtl b/website/public/gallery/obj/opengameart/hay-bale/hay_bale.mtl deleted file mode 100644 index e208751f..00000000 --- a/website/public/gallery/obj/opengameart/hay-bale/hay_bale.mtl +++ /dev/null @@ -1,13 +0,0 @@ -# Blender MTL File: 'hay_bale.blend' -# Material Count: 1 - -newmtl hay_material -Ns 225.000000 -Ka 1.000000 1.000000 1.000000 -Kd 0.800000 0.800000 0.800000 -Ks 0.500000 0.500000 0.500000 -Ke 0.000000 0.000000 0.000000 -Ni 1.450000 -d 1.000000 -illum 1 -map_Kd textures/hay.jpg diff --git a/website/public/gallery/obj/opengameart/hay-bale/hay_bale.obj b/website/public/gallery/obj/opengameart/hay-bale/hay_bale.obj deleted file mode 100644 index dfe0406e..00000000 --- a/website/public/gallery/obj/opengameart/hay-bale/hay_bale.obj +++ /dev/null @@ -1,249 +0,0 @@ -# Blender v3.0.0 OBJ File: 'hay_bale.blend' -# www.blender.org -mtllib hay_bale.mtl -o hay_poly_hay_mesh -v 2.308030 1.382565 1.379101 -v 1.661284 2.029311 1.379101 -v 2.308030 -1.382565 1.379101 -v 1.661284 -2.029311 1.379101 -v -2.308030 1.382565 1.379101 -v -1.661284 2.029311 1.379101 -v -2.308030 -1.382565 1.379101 -v -1.661284 -2.029311 1.379101 -v -2.516998 -1.507742 4.488970 -v -1.811696 -2.213043 4.488970 -v -1.811696 2.213043 4.488970 -v -2.516998 1.507742 4.488970 -v -1.811696 -2.213043 -4.488970 -v -2.516998 -1.507742 -4.488970 -v -2.516998 1.507742 -4.488970 -v -1.811696 2.213043 -4.488970 -v 1.811696 -2.213043 4.488970 -v 2.516998 -1.507742 4.488970 -v 2.516998 1.507742 4.488970 -v 1.811696 2.213043 4.488970 -v 2.516998 -1.507742 -4.488970 -v 1.811696 -2.213043 -4.488970 -v 1.811696 2.213043 -4.488970 -v 2.516998 1.507742 -4.488970 -v 1.661284 2.029311 -1.304280 -v 2.308030 -1.382565 -1.304280 -v -1.661284 -2.029311 -1.304280 -v -2.308030 1.382565 -1.304280 -v -2.308030 -1.382565 -1.304280 -v -1.661284 2.029311 -1.304280 -v 1.661284 -2.029311 -1.304280 -v 2.308030 1.382565 -1.304280 -v 1.716625 -2.162915 2.934035 -v -2.441634 -1.437906 2.934035 -v -1.716625 2.162915 2.934035 -v 2.441634 1.437906 2.934035 -v -1.716625 -2.162915 2.934035 -v -2.441634 1.437906 2.934035 -v 2.441634 -1.437906 2.934035 -v 1.716625 2.162915 2.934035 -v 1.720661 2.171893 0.037411 -v 2.459233 -1.461736 0.040284 -v -1.720390 -2.172005 0.037410 -v -2.450724 1.441671 0.037410 -v -2.450724 -1.441671 0.037410 -v -1.720390 2.172005 0.037410 -v 1.719616 -2.172324 0.037410 -v 2.460042 1.421930 0.040281 -v -2.510462 -1.466415 -2.872222 -v -1.745134 2.231743 -2.872222 -v 1.745134 -2.231743 -2.872222 -v 2.510462 1.466415 -2.872222 -v 1.745134 2.231743 -2.872222 -v 2.510462 -1.466415 -2.872222 -v -1.745134 -2.231743 -2.872222 -v -2.510462 1.466415 -2.872222 -vt 0.263187 0.805288 -vt 0.221268 0.847207 -vt 0.042048 0.847207 -vt 0.000130 0.805288 -vt 0.000130 0.589939 -vt 0.042048 0.548020 -vt 0.221269 0.548020 -vt 0.263187 0.589939 -vt 0.725766 0.092588 -vt 0.552987 0.093262 -vt 0.550864 0.001611 -vt 0.727008 0.000130 -vt 0.993142 0.271530 -vt 0.786003 0.272158 -vt 0.784760 0.189083 -vt 0.988718 0.188769 -vt 0.492201 0.094195 -vt 0.289371 0.096583 -vt 0.285158 0.006482 -vt 0.493413 0.003031 -vt 0.062918 0.439746 -vt 0.227895 0.444562 -vt 0.227403 0.536056 -vt 0.057722 0.531011 -vt 0.526504 0.805288 -vt 0.484585 0.847207 -vt 0.305365 0.847207 -vt 0.263447 0.805288 -vt 0.263447 0.589938 -vt 0.305365 0.548020 -vt 0.484585 0.548020 -vt 0.526504 0.589938 -vt 0.004775 0.106131 -vt 0.000130 0.017918 -vt 0.056534 0.013710 -vt 0.062827 0.102371 -vt 0.288965 0.444817 -vt 0.284208 0.537181 -vt 0.786856 0.452256 -vt 0.783730 0.547760 -vt 0.724570 0.547549 -vt 0.723520 0.452879 -vt 0.992408 0.097390 -vt 0.787150 0.092907 -vt 0.785502 0.000567 -vt 0.999870 0.005261 -vt 0.492662 0.271330 -vt 0.494277 0.189022 -vt 0.552031 0.189533 -vt 0.554050 0.271667 -vt 0.726624 0.188416 -vt 0.288630 0.191509 -vt 0.231978 0.190534 -vt 0.229643 0.097582 -vt 0.008982 0.270994 -vt 0.012419 0.194673 -vt 0.065645 0.193982 -vt 0.066711 0.270520 -vt 0.289750 0.271263 -vt 0.725768 0.271695 -vt 0.551771 0.449912 -vt 0.551083 0.354642 -vt 0.725773 0.354830 -vt 0.489369 0.449214 -vt 0.288165 0.351778 -vt 0.493140 0.353000 -vt 0.002019 0.436387 -vt 0.012311 0.347456 -vt 0.065534 0.349625 -vt 0.784443 0.355898 -vt 0.231669 0.349992 -vt 0.231140 0.271961 -vt 0.995453 0.450332 -vt 0.989547 0.354488 -vt 0.228358 0.007566 -vt 0.999870 0.546761 -vt 0.491108 0.542437 -vt 0.548877 0.543807 -vt 0.000838 0.528320 -vn -0.0000 0.0000 1.0000 -vn 0.9223 -0.3789 -0.0769 -vn 0.9223 0.3789 -0.0769 -vn 0.9200 0.3870 -0.0620 -vn 0.9200 -0.3870 -0.0620 -vn -0.3827 -0.9239 -0.0000 -vn 0.3777 -0.9259 -0.0000 -vn 0.3801 -0.9248 0.0133 -vn -0.3827 -0.9238 0.0113 -vn 0.3765 0.9236 -0.0723 -vn -0.3765 0.9236 -0.0723 -vn -0.3918 0.9185 -0.0536 -vn 0.3918 0.9185 -0.0536 -vn -0.9223 -0.3791 0.0749 -vn -0.9223 0.3791 0.0749 -vn -0.9217 0.3876 0.0136 -vn -0.9217 -0.3876 0.0136 -vn 0.0000 0.0000 -1.0000 -vn -0.3765 -0.9236 -0.0723 -vn -0.3918 -0.9185 -0.0536 -vn -0.9200 -0.3870 -0.0620 -vn -0.9223 -0.3789 -0.0769 -vn -0.3768 0.9236 0.0705 -vn -0.3922 0.9199 0.0055 -vn 0.3768 -0.9236 0.0705 -vn 0.3922 -0.9199 0.0055 -vn 0.9217 -0.3876 0.0136 -vn 0.9223 -0.3791 0.0749 -vn 0.3765 -0.9236 -0.0723 -vn 0.3918 -0.9185 -0.0536 -vn 0.3845 0.9231 -0.0000 -vn 0.3835 0.9235 0.0105 -vn 0.9234 0.3836 0.0124 -vn 0.9260 0.3774 0.0003 -vn 0.9233 -0.3839 0.0153 -vn -0.3827 0.9238 0.0112 -vn -0.9238 0.3827 0.0112 -vn -0.9223 0.3789 -0.0769 -vn -0.9238 -0.3827 0.0112 -vn -0.9239 -0.3827 -0.0000 -vn -0.3827 0.9239 0.0000 -vn 0.9208 -0.3900 0.0003 -vn 0.9223 0.3791 0.0749 -vn 0.9234 0.3836 0.0116 -vn 0.9234 -0.3838 0.0087 -vn 0.3768 0.9236 0.0705 -vn -0.3826 0.9238 0.0125 -vn 0.3836 0.9234 0.0133 -vn -0.3768 -0.9236 0.0705 -vn -0.3827 -0.9238 0.0125 -vn -0.9238 -0.3827 0.0125 -vn 0.3802 -0.9248 0.0105 -vn -0.9238 0.3827 0.0125 -vn -0.9239 0.3827 -0.0000 -vn -0.9200 0.3870 -0.0620 -vn -0.3922 -0.9199 0.0055 -vn 0.3922 0.9199 0.0055 -vn 0.9217 0.3876 0.0136 -usemtl hay_material -s 1 -f 17/1/1 18/2/1 19/3/1 20/4/1 11/5/1 12/6/1 9/7/1 10/8/1 -f 39/9/2 36/10/3 19/11/4 18/12/5 -f 43/13/6 47/14/7 4/15/8 8/16/9 -f 40/17/10 35/18/11 11/19/12 20/20/13 -f 49/21/14 56/22/15 15/23/16 14/24/17 -f 13/25/18 14/26/18 15/27/18 16/28/18 23/29/18 24/30/18 21/31/18 22/32/18 -f 37/33/19 10/34/20 9/35/21 34/36/22 -f 50/37/23 16/38/24 15/23/16 56/22/15 -f 51/39/25 22/40/26 21/41/27 54/42/28 -f 40/17/10 20/20/13 19/11/4 36/10/3 -f 37/43/19 33/44/29 17/45/30 10/46/20 -f 41/47/31 2/48/32 1/49/33 48/50/34 -f 33/44/29 4/15/8 3/51/35 39/9/2 -f 35/18/11 6/52/36 5/53/37 38/54/38 -f 43/55/6 8/56/9 7/57/39 45/58/40 -f 34/36/22 38/54/38 5/53/37 7/57/39 -f 41/47/31 46/59/41 6/52/36 2/48/32 -f 42/60/42 48/50/34 1/49/33 3/51/35 -f 54/42/28 52/61/43 32/62/44 26/63/45 -f 53/64/46 50/37/23 30/65/47 25/66/48 -f 55/67/49 27/68/50 29/69/51 49/21/14 -f 53/64/46 25/66/48 32/62/44 52/61/43 -f 47/14/7 31/70/52 26/63/45 42/60/42 -f 46/59/41 30/65/47 28/71/53 44/72/54 -f 45/58/40 44/72/54 28/71/53 29/69/51 -f 55/73/49 51/39/25 31/70/52 27/74/50 -f 9/35/21 12/75/55 38/54/38 34/36/22 -f 11/19/12 35/18/11 38/54/38 12/75/55 -f 17/45/30 33/44/29 39/9/2 18/12/5 -f 8/16/9 4/15/8 33/44/29 37/43/19 -f 2/48/32 40/17/10 36/10/3 1/49/33 -f 8/56/9 37/33/19 34/36/22 7/57/39 -f 2/48/32 6/52/36 35/18/11 40/17/10 -f 3/51/35 1/49/33 36/10/3 39/9/2 -f 7/57/39 5/53/37 44/72/54 45/58/40 -f 6/52/36 46/59/41 44/72/54 5/53/37 -f 4/15/8 47/14/7 42/60/42 3/51/35 -f 26/63/45 32/62/44 48/50/34 42/60/42 -f 25/66/48 30/65/47 46/59/41 41/47/31 -f 27/68/50 43/55/6 45/58/40 29/69/51 -f 25/66/48 41/47/31 48/50/34 32/62/44 -f 27/74/50 31/70/52 47/14/7 43/13/6 -f 13/76/56 22/40/26 51/39/25 55/73/49 -f 23/77/57 53/64/46 52/61/43 24/78/58 -f 13/79/56 55/67/49 49/21/14 14/24/17 -f 23/77/57 16/38/24 50/37/23 53/64/46 -f 21/41/27 24/78/58 52/61/43 54/42/28 -f 31/70/52 51/39/25 54/42/28 26/63/45 -f 30/65/47 50/37/23 56/22/15 28/71/53 -f 29/69/51 28/71/53 56/22/15 49/21/14 diff --git a/website/public/gallery/obj/opengameart/hay-bale/textures/hay.jpg b/website/public/gallery/obj/opengameart/hay-bale/textures/hay.jpg deleted file mode 100644 index 56663937..00000000 Binary files a/website/public/gallery/obj/opengameart/hay-bale/textures/hay.jpg and /dev/null differ diff --git a/website/public/gallery/vox/stairs.vox b/website/public/gallery/vox/stairs.vox deleted file mode 100644 index 2506f666..00000000 Binary files a/website/public/gallery/vox/stairs.vox and /dev/null differ diff --git a/website/src/components/BuilderWorkbench/scenes.ts b/website/src/components/BuilderWorkbench/scenes.ts index 5031536d..33174ee6 100644 --- a/website/src/components/BuilderWorkbench/scenes.ts +++ b/website/src/components/BuilderWorkbench/scenes.ts @@ -61,16 +61,16 @@ export const CITY_BLOCK: ScenePreset = { // Back row (north edge of the block) — taller anchors facing outward { presetId: glb("city/Skyscraper.glb"), position: [0, 10, 0], rotation: [0, 0, 180] }, { presetId: glb("city/Large Building.glb"), position: [-10, 10, 0], rotation: [0, 0, 180] }, - { presetId: glb("city/Large Building-3IhrYZp6tP.glb"), position: [10, 10, 0], rotation: [0, 0, 180] }, + { presetId: glb("city/Skyscraper.glb"), position: [10, 10, 0], rotation: [0, 0, 180] }, // Side rows — small/low buildings facing east and west { presetId: glb("city/Small Building.glb"), position: [-10, 0, 0], rotation: [0, 0, 90] }, - { presetId: glb("city/Small Building-QjL4Fo9dU9.glb"), position: [10, 0, 0], rotation: [0, 0, -90] }, + { presetId: glb("city/Small Building.glb"), position: [10, 0, 0], rotation: [0, 0, -90] }, // Front row (south edge) — mid-height buildings facing the camera default - { presetId: glb("city/Low Building.glb"), position: [-10, -10, 0] }, - { presetId: glb("city/Sign Hospital.glb"), position: [0, -10, 0] }, - { presetId: glb("city/Low Wide.glb"), position: [10, -10, 0] }, + { presetId: glb("city/Large Building.glb"), position: [-10, -10, 0] }, + { presetId: glb("city/Small Building.glb"), position: [0, -10, 0] }, + { presetId: glb("city/Large Building.glb"), position: [10, -10, 0] }, ], }; @@ -104,27 +104,6 @@ export const CITY_STREET: ScenePreset = { { presetId: glb("urban/Road Bits.glb"), position: [16, 0, 0], rotation: [0, 0, 90] }, { presetId: glb("urban/Road Bits.glb"), position: [24, 0, 0], rotation: [0, 0, 90] }, - // Trees in each quadrant — corners of the cross - { presetId: glb("urban/Tree.glb"), position: [-8, 8, 0] }, - { presetId: glb("urban/Tree.glb"), position: [-14, 12, 0] }, - { presetId: glb("urban/Tree.glb"), position: [-6, 16, 0] }, - { presetId: glb("urban/Tree.glb"), position: [-18, 6, 0] }, - - { presetId: glb("urban/Tree.glb"), position: [8, 8, 0] }, - { presetId: glb("urban/Tree.glb"), position: [14, 14, 0] }, - { presetId: glb("urban/Tree.glb"), position: [18, 6, 0] }, - { presetId: glb("urban/Tree.glb"), position: [6, 18, 0] }, - - { presetId: glb("urban/Tree.glb"), position: [-8, -8, 0] }, - { presetId: glb("urban/Tree.glb"), position: [-14, -14, 0] }, - { presetId: glb("urban/Tree.glb"), position: [-18, -6, 0] }, - { presetId: glb("urban/Tree.glb"), position: [-6, -16, 0] }, - - { presetId: glb("urban/Tree.glb"), position: [8, -8, 0] }, - { presetId: glb("urban/Tree.glb"), position: [16, -12, 0] }, - { presetId: glb("urban/Tree.glb"), position: [18, -18, 0] }, - { presetId: glb("urban/Tree.glb"), position: [6, -20, 0] }, - // A couple of cars on the road just for life { presetId: glb("urban/Car.glb"), position: [0, -12, 0] }, { presetId: glb("urban/Police Car.glb"), position: [0, 12, 0], rotation: [0, 0, 180] }, diff --git a/website/src/components/Dock/folders/useAnimationFolder.ts b/website/src/components/Dock/folders/useAnimationFolder.ts index f9e0989f..797a6b81 100644 --- a/website/src/components/Dock/folders/useAnimationFolder.ts +++ b/website/src/components/Dock/folders/useAnimationFolder.ts @@ -1,8 +1,8 @@ /** - * Animation folder — extracted from the legacy Dock.tsx mega-effect. + * Animation folder — extracted from the Dock.tsx mega-effect. * - * Owns three controllers (Sequence / Paused / Playback speed) plus the folder - * shell itself. When the model has no animation clips the whole folder is + * Owns the sequence, pause, and speed controllers plus the + * folder shell itself. When the model has no animation clips the whole folder is * hidden via lil-gui's `.hide()` and the three controllers are dimmed so any * direct DOM access doesn't fire stale onChange callbacks. The Sequence * dropdown's option list is refreshed at runtime whenever `animationOptions` @@ -23,7 +23,10 @@ export interface AnimationFolderInputs { onAnimationChange: (value: string) => void; onResetAnimatedPolygons: () => void; onSelectAnimationClear: () => void; - onUpdateScene: (partial: { animationPaused?: boolean; animationTimeScale?: number }) => void; + onUpdateScene: (partial: { + animationPaused?: boolean; + animationTimeScale?: number; + }) => void; } export function useAnimationFolder(parent: GUI | null, inputs: AnimationFolderInputs): void { @@ -100,5 +103,10 @@ export function useAnimationFolder(parent: GUI | null, inputs: AnimationFolderIn sequenceController?.setEnabled(enabled, { dim: true }); pausedController?.setEnabled(enabled, { dim: true }); speedController?.setEnabled(enabled, { dim: true }); - }, [animationClipCount, sequenceController, pausedController, speedController]); + }, [ + animationClipCount, + sequenceController, + pausedController, + speedController, + ]); } diff --git a/website/src/components/Dock/primitives.tsx b/website/src/components/Dock/primitives.tsx index 5fc45373..e04e23c5 100644 --- a/website/src/components/Dock/primitives.tsx +++ b/website/src/components/Dock/primitives.tsx @@ -237,22 +237,35 @@ export function useOption( if (!parent) return; const proxy = { value }; const raw = parent.add(proxy, "value", initialOptionsRef.current).name(label); + let activeRaw = raw; raw.onChange((v: T) => onChangeRef.current(v)); - const base = makeDockController(raw, proxy); const wrapper: DockOptionController = { - ...base, + get raw() { + return activeRaw; + }, + setValue(nextValue) { + proxy.value = nextValue; + activeRaw.updateDisplay(); + }, + setEnabled(enabled, opts) { + applyEnabled(activeRaw, enabled, opts?.dim ?? true); + }, + setVisible(visible) { + if (visible) activeRaw.show(); + else activeRaw.hide(); + }, setOptions(next) { // lil-gui's `.options(newOpts)` REPLACES the controller — the old - // `raw` reference is destroyed. Returned controller swaps in. - // Callers rarely need the new ref since `setValue/setEnabled` go - // through this wrapper's closure; we just rebind internally. - const replaced = (raw as unknown as { options: (o: Record) => Controller }).options(next); + // controller is destroyed and a new one is returned. Keep every + // wrapper method pointed at the live controller after that swap. + const replaced = (activeRaw as unknown as { options: (o: Record) => Controller }).options(next); + activeRaw = replaced.name(label); replaced.onChange((v: T) => onChangeRef.current(v)); }, }; setCtrl(wrapper); return () => { - raw.destroy(); + activeRaw.destroy(); setCtrl(null); }; // eslint-disable-next-line react-hooks/exhaustive-deps diff --git a/website/src/components/GalleryWorkbench/GalleryWorkbench.tsx b/website/src/components/GalleryWorkbench/GalleryWorkbench.tsx index 1bbf9522..6089d8d5 100644 --- a/website/src/components/GalleryWorkbench/GalleryWorkbench.tsx +++ b/website/src/components/GalleryWorkbench/GalleryWorkbench.tsx @@ -7,6 +7,7 @@ import type { import type { PolyMeshHandle as VanillaPolyMeshHandle, } from "@layoutit/polycss"; +import { optimizeAnimatedMeshPolygons } from "@layoutit/polycss"; import { Inspector as InspectorPanel, type InspectorColorGroup, @@ -67,6 +68,8 @@ import { import { useFpvHost } from "../fpv"; import type { ObjParseOptions, GltfParseOptions, VoxParseOptions } from "@layoutit/polycss"; +type AnimationClip = NonNullable["clips"][number]; + function presetPickerItem(preset: PresetModel, local = false) { return { id: preset.id, @@ -182,6 +185,35 @@ function polygonHasTextureData(polygon: Polygon): boolean { return Boolean(polygon.texture || polygon.uvs?.length || polygon.textureTriangles?.length); } +function displayAnimationName(name: string): string { + const localName = (name.split("|").pop() ?? name).trim(); + return localName + .replace(/^(Animal|Character|Fish|Human|Monster|Robot|Snake)[ _-]+/i, "") + .replace(/[_-]+/g, " ") + .replace(/\s+/g, " ") + .trim() || name; +} + +function animationOptionKey(name: string): string { + return displayAnimationName(name).toLowerCase().replace(/[^a-z0-9]+/g, " ").trim(); +} + +function animationNameHasArmaturePrefix(name: string): boolean { + return name.includes("|"); +} + +function dedupeAnimationClips(clips: AnimationClip[]): AnimationClip[] { + const byName = new Map(); + for (const clip of clips) { + const key = animationOptionKey(clip.name); + const existing = byName.get(key); + if (!existing || (animationNameHasArmaturePrefix(existing.name) && !animationNameHasArmaturePrefix(clip.name))) { + byName.set(key, clip); + } + } + return Array.from(byName.values()); +} + function resolveInitialPreset(): PresetModel { const id = routeInitialPresetId(ALL_PRESET_IDS); return (id ? PRESETS.find((p) => p.id === id) : null) ?? randomPreset(); @@ -382,22 +414,41 @@ export default function GalleryWorkbench() { const textureQuality = sceneOptions.textureQuality; const animationClips = loaded?.animation?.clips ?? []; + const selectableAnimationClips = useMemo( + () => dedupeAnimationClips(animationClips), + [animationClips], + ); const activeAnimation = useMemo( () => animationClips.find((clip) => String(clip.index) === selectedAnimation) ?? null, [animationClips, selectedAnimation], ); const hasActiveAnimation = activeAnimation !== null; + const renderLoaded = useMemo(() => { + if (!loaded || !activeAnimation || sceneOptions.meshResolution !== "lossy") return loaded; + const optimized = optimizeAnimatedMeshPolygons(loaded.parseResult, { + meshResolution: sceneOptions.meshResolution, + }); + if (optimized === loaded.parseResult) return loaded; + return { + ...loaded, + parseResult: optimized, + rawPolygons: optimized.polygons, + polygons: optimized.polygons, + animation: optimized.animation, + }; + }, [loaded, activeAnimation, sceneOptions.meshResolution]); const animation = useAnimationFrames({ - loaded, + loaded: renderLoaded, activeAnimation, renderer: sceneOptions.renderer, animationPaused: sceneOptions.animationPaused, animationTimeScale: sceneOptions.animationTimeScale, + reactMeshRef: meshRef, }); const { modelPolygons, interiorFillPolygons, scenePolygons, helperScale, helperTarget } = useScenePolygons({ - loaded, + loaded: renderLoaded, hasActiveAnimation, meshResolution: sceneOptions.meshResolution, renderer: sceneOptions.renderer, @@ -569,11 +620,11 @@ export default function GalleryWorkbench() { const animationOptions = useMemo(() => { const options: Record = { None: "" }; - for (const clip of animationClips) { - options[`${clip.name} (${clip.duration.toFixed(2)}s)`] = String(clip.index); + for (const clip of selectableAnimationClips) { + options[`${displayAnimationName(clip.name)} (${clip.duration.toFixed(2)}s)`] = String(clip.index); } return options; - }, [animationClips]); + }, [selectableAnimationClips]); const perspectiveMode = sceneOptions.perspective === false ? "orthographic" : "perspective"; const perspectivePx = sceneOptions.perspective === false ? 8000 : sceneOptions.perspective; @@ -676,7 +727,7 @@ export default function GalleryWorkbench() { animation.setReactAnimatedPolygons(null)} onSelectAnimationClear={() => setSelectedAnimation("")} diff --git a/website/src/components/GalleryWorkbench/gallery-workbench.css b/website/src/components/GalleryWorkbench/gallery-workbench.css index f0157f8e..5ed54a42 100644 --- a/website/src/components/GalleryWorkbench/gallery-workbench.css +++ b/website/src/components/GalleryWorkbench/gallery-workbench.css @@ -367,11 +367,7 @@ height: 100%; position: relative; overflow: hidden; - background: - linear-gradient(rgba(255, 255, 255, 0.035) 1px, transparent 1px), - linear-gradient(90deg, rgba(255, 255, 255, 0.035) 1px, transparent 1px), - #050607; - background-size: 48px 48px; + background: #000; } .dn-viewport--outline-polygons .polycss-scene i, diff --git a/website/src/components/GalleryWorkbench/helpers/domMetrics.ts b/website/src/components/GalleryWorkbench/helpers/domMetrics.ts index 09761920..32020bd3 100644 --- a/website/src/components/GalleryWorkbench/helpers/domMetrics.ts +++ b/website/src/components/GalleryWorkbench/helpers/domMetrics.ts @@ -1,4 +1,5 @@ import type { DomMetrics } from "../../types"; +import { collectPolyRenderStats } from "@layoutit/polycss"; import { cssUrlValue, cssPxValue, cssNumberValue, cssPaintAlpha, localElementSize } from "./cssValues"; export const DOM_OVERPAINT_CACHE_EVENT = "polycss:dom-overpaint-cache"; @@ -184,17 +185,16 @@ export function measureDom(root: HTMLElement | null): DomMetrics { const modelScopes = Array.from(root.querySelectorAll(".dn-model-mesh")); if (modelScopes.length === 0) return EMPTY_METRICS; const scopes = modelScopes; - const countInScopes = (selector: string): number => - scopes.reduce((sum, scope) => sum + scope.querySelectorAll(selector).length, 0); + const stats = collectPolyRenderStats(root, { scopeSelector: ".dn-model-mesh" }); const nodeCount = scopes.reduce((sum, scope) => sum + 1 + scope.querySelectorAll("*").length, 0); return { measuredAt: performance.now(), nodeCount, - sprites: countInScopes("s"), - rects: countInScopes("b"), - triangles: countInScopes("u"), - irregular: countInScopes("i"), + sprites: stats.surfaceLeafCounts.atlas, + rects: stats.surfaceLeafCounts.quad, + triangles: stats.surfaceLeafCounts.stableTriangle, + irregular: stats.surfaceLeafCounts.clippedSolid, overpaintPercent: measureDomOverpaintPercent(scopes), }; } diff --git a/website/src/components/GalleryWorkbench/hooks/useAnimationFrames.ts b/website/src/components/GalleryWorkbench/hooks/useAnimationFrames.ts index 2bb91b46..c767efc5 100644 --- a/website/src/components/GalleryWorkbench/hooks/useAnimationFrames.ts +++ b/website/src/components/GalleryWorkbench/hooks/useAnimationFrames.ts @@ -1,5 +1,5 @@ import { useEffect, useMemo, useRef, useState, type Dispatch, type RefObject, type SetStateAction } from "react"; -import type { Polygon } from "@layoutit/polycss-react"; +import type { Polygon, PolyMeshHandle } from "@layoutit/polycss-react"; import type { LoadedModel } from "../types"; export interface UseAnimationFramesOptions { @@ -8,6 +8,7 @@ export interface UseAnimationFramesOptions { renderer: "react" | "vanilla"; animationPaused: boolean; animationTimeScale: number; + reactMeshRef?: RefObject; } export interface UseAnimationFramesResult { @@ -24,6 +25,7 @@ export function useAnimationFrames({ renderer, animationPaused, animationTimeScale, + reactMeshRef, }: UseAnimationFramesOptions): UseAnimationFramesResult { const [reactAnimatedPolygons, setReactAnimatedPolygons] = useState(null); const animationPausedRef = useRef(animationPaused); @@ -38,6 +40,7 @@ export function useAnimationFrames({ let last = performance.now(); let elapsedSeconds = 0; let sampledSeconds: number | null = null; + let usingImperativeReactMesh = false; const tick = (now: number) => { const deltaSeconds = Math.max(0, (now - last) / 1000); @@ -47,14 +50,25 @@ export function useAnimationFrames({ } if (sampledSeconds !== elapsedSeconds) { sampledSeconds = elapsedSeconds; - setReactAnimatedPolygons(loaded.animation!.sample(activeAnimation.index, elapsedSeconds)); + const frame = loaded.animation!.sample(activeAnimation.index, elapsedSeconds); + const handle = reactMeshRef?.current; + if (handle) { + if (!usingImperativeReactMesh) { + usingImperativeReactMesh = true; + setReactAnimatedPolygons(null); + } + handle.setPolygons(frame); + } else { + usingImperativeReactMesh = false; + setReactAnimatedPolygons(frame); + } } raf = requestAnimationFrame(tick); }; raf = requestAnimationFrame(tick); return () => cancelAnimationFrame(raf); - }, [loaded, activeAnimation, renderer]); + }, [loaded, activeAnimation, renderer, reactMeshRef]); const vanillaAnimationFrameFactory = useMemo(() => { if (!loaded?.animation || !activeAnimation || renderer !== "vanilla") return undefined; diff --git a/website/src/components/GalleryWorkbench/presets/attributions.ts b/website/src/components/GalleryWorkbench/presets/attributions.ts index 6f4e795c..52eb2050 100644 --- a/website/src/components/GalleryWorkbench/presets/attributions.ts +++ b/website/src/components/GalleryWorkbench/presets/attributions.ts @@ -217,7 +217,6 @@ export const GLB_PRESET_ATTRIBUTIONS: Record = { "Saxophone.glb": POLY_PIZZA_SAXOPHONE_ATTRIBUTION, "Snail.glb": polyPizzaJeremyAttribution("abd7jfOGZ94"), "Zebra.glb": polyPizzaJeremyAttribution("cKi5RxMBUxO"), - "Bicycle.glb": polyPizzaJeremyAttribution("axc03j3xKfz"), "Dump truck.glb": polyPizzaJeremyAttribution("1BpGYg14QGD"), "Taxi.glb": polyPizzaJeremyAttribution("coQbjlCqWY9"), "Truck.glb": polyPizzaJeremyAttribution("cPVFA5uTr9l"), diff --git a/website/src/components/GalleryWorkbench/presets/presetFiles.ts b/website/src/components/GalleryWorkbench/presets/presetFiles.ts index 03a21776..84692451 100644 --- a/website/src/components/GalleryWorkbench/presets/presetFiles.ts +++ b/website/src/components/GalleryWorkbench/presets/presetFiles.ts @@ -1,6 +1,5 @@ import type { GalleryPresetFile, ObjGalleryPresetFile } from "../types"; import { - KHRONOS_FOX_ATTRIBUTION, MONOGON_ANCIENT_ENVIRONMENT_ATTRIBUTION, MINI_MIKES_METRO_MINIS_ATTRIBUTION, MONOGON_DESERT_TOWN_ATTRIBUTION, @@ -17,27 +16,21 @@ import { } from "./attributions"; export const GLB_PRESET_FILES: GalleryPresetFile[] = [ - { file: "FishAnimated.glb", label: "Animated Fish", category: "Animated" }, - { - file: "khronos/animated-fox.glb", - label: "Animated Fox", - category: "Animated", - attribution: KHRONOS_FOX_ATTRIBUTION, - }, + { file: "FishAnimated.glb", label: "Fish", category: "Animated" }, { file: "opengameart/animated-pliers.glb", - label: "Animated Pliers", + label: "Pliers", category: "Animated", attribution: openGameArtAttribution("LonesomeDucky", "tool-pack-2", 1452), }, { file: "opengameart/animated-utility-knife.glb", - label: "Animated Utility Knife", + label: "Utility Knife", category: "Animated", attribution: openGameArtAttribution("LonesomeDucky", "tool-pack-2", 576), }, - { file: "AnimatedMushnub.glb", label: "Animated Mushnub", category: "Animated" }, - { file: "AnimatedSnake.glb", label: "Animated Snake", category: "Animated" }, + { file: "AnimatedMushnub.glb", label: "Mushnub", category: "Animated" }, + { file: "AnimatedSnake.glb", label: "Snake", category: "Animated" }, { file: "Bat.glb", category: "Animals" }, { file: "Bear.glb", category: "Animals" }, { file: "Cat.glb", category: "Animals" }, @@ -67,7 +60,6 @@ export const GLB_PRESET_FILES: GalleryPresetFile[] = [ { file: "Snail.glb", category: "Animals" }, { file: "Wolf.glb", category: "Animals" }, { file: "Zebra.glb", category: "Animals" }, - { file: "Bicycle.glb", category: "Vehicles" }, { file: "Dump truck.glb", label: "Dump Truck", category: "Vehicles" }, { file: "Policecar.glb", label: "Police Car", category: "Vehicles" }, { file: "Taxi.glb", category: "Vehicles" }, @@ -91,117 +83,47 @@ export const GLB_PRESET_FILES: GalleryPresetFile[] = [ // Medieval Village Pack — used by the /builder Medieval Village scene // preset as well as standalone models. All share the same category so // they group cleanly in the sidebar. - { file: "medieval/Bag Open.glb", label: "Bag Open", category: "Medieval Village" }, - { file: "medieval/Bag.glb", category: "Medieval Village" }, { file: "medieval/Bags.glb", category: "Medieval Village" }, { file: "medieval/Barrel.glb", category: "Medieval Village" }, - { file: "medieval/Bell Tower.glb", label: "Bell Tower", category: "Medieval Village" }, { file: "medieval/Bell.glb", category: "Medieval Village" }, - { file: "medieval/Bench.glb", category: "Medieval Village" }, - { file: "medieval/Bench-7uSlZo3n9Y.glb", label: "Bench (Tall)", category: "Medieval Village" }, - { file: "medieval/Blacksmith.glb", category: "Medieval Village" }, { file: "medieval/Bonfire.glb", category: "Medieval Village" }, { file: "medieval/Cart.glb", category: "Medieval Village" }, { file: "medieval/Cauldron.glb", category: "Medieval Village" }, { file: "medieval/Crate.glb", category: "Medieval Village" }, - { file: "medieval/Door Round.glb", label: "Door Round", category: "Medieval Village" }, - { file: "medieval/Door Straight.glb", label: "Door Straight", category: "Medieval Village" }, - { file: "medieval/Fantasy Barracks.glb", label: "Fantasy Barracks", category: "Medieval Village" }, - { file: "medieval/Fantasy House.glb", label: "Fantasy House", category: "Medieval Village" }, - { file: "medieval/Fantasy House-BH2XHWUNmF.glb", label: "Fantasy House (Stone)", category: "Medieval Village" }, - { file: "medieval/Fantasy House-dcPho4SUA3.glb", label: "Fantasy House (Tall)", category: "Medieval Village" }, - { file: "medieval/Fantasy Inn.glb", label: "Fantasy Inn", category: "Medieval Village" }, - { file: "medieval/Fantasy Sawmill.glb", label: "Fantasy Sawmill", category: "Medieval Village" }, - { file: "medieval/Fantasy Stable.glb", label: "Fantasy Stable", category: "Medieval Village" }, - { file: "medieval/Fence.glb", category: "Medieval Village" }, - { file: "medieval/Gazebo.glb", category: "Medieval Village" }, - { file: "medieval/Hay.glb", category: "Medieval Village" }, - { file: "medieval/Market Stand.glb", label: "Market Stand", category: "Medieval Village" }, - { file: "medieval/Market Stand-DGIM5HGISb.glb", label: "Market Stand (Variant)", category: "Medieval Village" }, - { file: "medieval/Mill.glb", category: "Medieval Village" }, { file: "medieval/Package.glb", category: "Medieval Village" }, { file: "medieval/Package-kYvD6QCQRd.glb", label: "Package (Small)", category: "Medieval Village" }, { file: "medieval/Path Straight.glb", label: "Path Straight", category: "Medieval Village" }, { file: "medieval/Rocks.glb", category: "Medieval Village" }, - { file: "medieval/Round Window.glb", label: "Round Window", category: "Medieval Village" }, { file: "medieval/Sawmill Saw.glb", label: "Sawmill Saw", category: "Medieval Village" }, { file: "medieval/Smoke.glb", category: "Medieval Village" }, - { file: "medieval/Stairs.glb", category: "Medieval Village" }, { file: "medieval/Well.glb", category: "Medieval Village" }, { file: "medieval/Window.glb", category: "Medieval Village" }, { file: "medieval/Window-EY1zrFcme9.glb", label: "Window (Tall)", category: "Medieval Village" }, // City Kit — used by the /builder City Block scene preset and for - // standalone placement. 31 building variants across 6 archetypes - // (Skyscraper, Large/Low Wide/Low/Small Building, Sign Hospital). + // standalone placement. Keep a compact Solid gallery set instead of + // listing every building variant. { file: "city/Skyscraper.glb", category: "City Kit" }, - { file: "city/Skyscraper-BwEXdOoUSO.glb", label: "Skyscraper (A)", category: "City Kit" }, - { file: "city/Skyscraper-jIRx0AhYOR.glb", label: "Skyscraper (B)", category: "City Kit" }, - { file: "city/Skyscraper-obYD8hWLTZ.glb", label: "Skyscraper (C)", category: "City Kit" }, - { file: "city/Skyscraper-PsPe0MzK0E.glb", label: "Skyscraper (D)", category: "City Kit" }, - { file: "city/Skyscraper-XST1j6kYsL.glb", label: "Skyscraper (E)", category: "City Kit" }, { file: "city/Large Building.glb", label: "Large Building", category: "City Kit" }, - { file: "city/Large Building-1bt4yYKmuK.glb", label: "Large Building (A)", category: "City Kit" }, - { file: "city/Large Building-3IhrYZp6tP.glb", label: "Large Building (B)", category: "City Kit" }, - { file: "city/Large Building-h7Jaq7bqMq.glb", label: "Large Building (C)", category: "City Kit" }, - { file: "city/Large Building-JgGLJH2iXj.glb", label: "Large Building (D)", category: "City Kit" }, - { file: "city/Large Building-ppwtREejXg.glb", label: "Large Building (E)", category: "City Kit" }, - { file: "city/Large Building-sxXonOmtct.glb", label: "Large Building (F)", category: "City Kit" }, - { file: "city/Low Wide.glb", label: "Low Wide", category: "City Kit" }, - { file: "city/Low Wide-DKgknsHjmr.glb", label: "Low Wide (A)", category: "City Kit" }, - { file: "city/Low Building.glb", label: "Low Building", category: "City Kit" }, - { file: "city/Low Building-4RoPd9BkSx.glb", label: "Low Building (A)", category: "City Kit" }, - { file: "city/Low Building-9fEKMpTsAi.glb", label: "Low Building (B)", category: "City Kit" }, - { file: "city/Low Building-AXFdNPAEc9.glb", label: "Low Building (C)", category: "City Kit" }, - { file: "city/Low Building-dYEbYdPfJr.glb", label: "Low Building (D)", category: "City Kit" }, - { file: "city/Low Building-sObKC8Mio2.glb", label: "Low Building (E)", category: "City Kit" }, - { file: "city/Low Building-tuieC1Pj0a.glb", label: "Low Building (F)", category: "City Kit" }, - { file: "city/Low Building-XsFOzw8E5N.glb", label: "Low Building (G)", category: "City Kit" }, - { file: "city/Low Building-zfjlejAxB7.glb", label: "Low Building (H)", category: "City Kit" }, { file: "city/Small Building.glb", label: "Small Building", category: "City Kit" }, - { file: "city/Small Building-gyjF60t7CG.glb", label: "Small Building (A)", category: "City Kit" }, - { file: "city/Small Building-QjL4Fo9dU9.glb", label: "Small Building (B)", category: "City Kit" }, - { file: "city/Small Building-Rq572hdKEz.glb", label: "Small Building (C)", category: "City Kit" }, - { file: "city/Small Building-t9j9Lof5ul.glb", label: "Small Building (D)", category: "City Kit" }, - { file: "city/Small Building-yLvnMqC9ZG.glb", label: "Small Building (E)", category: "City Kit" }, - { file: "city/Sign Hospital.glb", label: "Sign Hospital", category: "City Kit" }, - // Urban Pack — buildings + cars + street furniture + a few characters. + // Urban Pack — cars + street furniture + a few characters. // Used by the /builder "City Street" scene plus ad-hoc placement. - { file: "urban/Big Building.glb", label: "Big Building", category: "Urban Pack" }, - { file: "urban/Brown Building.glb", label: "Brown Building", category: "Urban Pack" }, - { file: "urban/Building Green.glb", label: "Building (Green)", category: "Urban Pack" }, - { file: "urban/Building Red.glb", label: "Building (Red)", category: "Urban Pack" }, - { file: "urban/Building Red Corner.glb", label: "Building (Red Corner)", category: "Urban Pack" }, - { file: "urban/Greenhouse.glb", category: "Urban Pack" }, - { file: "urban/Pizza Corner.glb", label: "Pizza Corner", category: "Urban Pack" }, - { file: "urban/Road Bits.glb", label: "Road", category: "Urban Pack" }, - { file: "urban/Floor Hole.glb", label: "Floor Hole", category: "Urban Pack" }, { file: "urban/Manhole Cover.glb", label: "Manhole Cover", category: "Urban Pack" }, { file: "urban/Car.glb", category: "Urban Pack" }, - { file: "urban/Car-unqqkULtRU.glb", label: "Car (Variant)", category: "Urban Pack" }, { file: "urban/SUV.glb", category: "Urban Pack" }, { file: "urban/Van.glb", category: "Urban Pack" }, { file: "urban/Pickup Truck.glb", label: "Pickup Truck", category: "Urban Pack" }, { file: "urban/Bus.glb", category: "Urban Pack" }, { file: "urban/Sports Car.glb", label: "Sports Car", category: "Urban Pack" }, - { file: "urban/Sports Car-Gzj704DXdr.glb", label: "Sports Car (Variant)", category: "Urban Pack" }, { file: "urban/Police Car.glb", label: "Police Car", category: "Urban Pack" }, { file: "urban/Motorcycle.glb", category: "Urban Pack" }, - { file: "urban/Bicycle.glb", category: "Urban Pack" }, - - { file: "urban/Bus Stop.glb", label: "Bus Stop", category: "Urban Pack" }, - { file: "urban/Bus stop sign.glb", label: "Bus Stop Sign", category: "Urban Pack" }, { file: "urban/Stop sign.glb", label: "Stop Sign", category: "Urban Pack" }, - { file: "urban/Traffic Light.glb", label: "Traffic Light", category: "Urban Pack" }, { file: "urban/Billboard.glb", category: "Urban Pack" }, { file: "urban/Rock band poster.glb", label: "Poster", category: "Urban Pack" }, - { file: "urban/Bench.glb", category: "Urban Pack" }, - { file: "urban/Trash Can.glb", label: "Trash Can", category: "Urban Pack" }, - { file: "urban/trah bag grey.glb", label: "Trash Bag", category: "Urban Pack" }, { file: "urban/Dumpster.glb", category: "Urban Pack" }, { file: "urban/Mailbox.glb", category: "Urban Pack" }, { file: "urban/Fire hydrant.glb", label: "Fire Hydrant", category: "Urban Pack" }, @@ -210,23 +132,10 @@ export const GLB_PRESET_FILES: GalleryPresetFile[] = [ { file: "urban/Power Box.glb", label: "Power Box", category: "Urban Pack" }, { file: "urban/Air conditioner.glb", label: "Air Conditioner", category: "Urban Pack" }, { file: "urban/ATM.glb", category: "Urban Pack" }, - { file: "urban/Tree.glb", category: "Urban Pack" }, { file: "urban/Planter & Bushes.glb", label: "Planter & Bushes", category: "Urban Pack" }, - { file: "urban/Flower Pot.glb", label: "Flower Pot", category: "Urban Pack" }, - { file: "urban/Flower Pot-Kgt363WkKd.glb", label: "Flower Pot (Variant)", category: "Urban Pack" }, - { file: "urban/Fence.glb", category: "Urban Pack" }, - { file: "urban/Fence Piece.glb", label: "Fence Piece", category: "Urban Pack" }, - { file: "urban/Fence End.glb", label: "Fence End", category: "Urban Pack" }, - { file: "urban/Fire Exit.glb", label: "Fire Exit", category: "Urban Pack" }, - { file: "urban/Roof Exit.glb", label: "Roof Exit", category: "Urban Pack" }, - { file: "urban/Washing Line.glb", label: "Washing Line", category: "Urban Pack" }, - { file: "urban/Debris Papers.glb", label: "Debris", category: "Urban Pack" }, - { file: "urban/Man.glb", category: "Urban Pack" }, { file: "urban/Adventurer.glb", category: "Urban Pack" }, - { file: "urban/Animated Woman.glb", label: "Animated Woman", category: "Urban Pack" }, - { file: "urban/Animated Woman-nIItLV9nxS.glb", label: "Animated Woman (A)", category: "Urban Pack" }, - { file: "urban/Animated Woman-qJ2gsTUBHL.glb", label: "Animated Woman (B)", category: "Urban Pack" }, + { file: "urban/Animated Woman.glb", label: "Woman", category: "Urban Pack" }, ]; export const POLY_PIZZA_PRESET_FILES: GalleryPresetFile[] = [ @@ -263,20 +172,9 @@ export const POLY_PIZZA_PRESET_FILES: GalleryPresetFile[] = [ tris: 610, }, }, - { - file: "poly-pizza/large-building.glb", - label: "Large Building", - category: "Architecture", - attribution: { - creator: "Kenney", - license: "CC0 1.0", - sourceUrl: "https://poly.pizza/m/yKo7F36Qk2", - tris: 950, - }, - }, { file: "poly-pizza/animated-robot.glb", - label: "Animated Robot", + label: "Robot", category: "Animated", attribution: { creator: "Quaternius", @@ -286,15 +184,46 @@ export const POLY_PIZZA_PRESET_FILES: GalleryPresetFile[] = [ }, }, { - file: "poly-pizza/bird.glb", - label: "Bird", - category: "Animals", - attribution: { - creator: "Quaternius", - license: "CC0 1.0", - sourceUrl: "https://poly.pizza/m/gYYC0gYMnw", - tris: 1204, - }, + file: "poly-pizza/animated-fox-quaternius.glb", + label: "Fox", + category: "Animated", + attribution: quaterniusAttribution("https://poly.pizza/m/Bc97C66HKi", 1848), + }, + { + file: "poly-pizza/animated-husky.glb", + label: "Husky", + category: "Animated", + attribution: quaterniusAttribution("https://poly.pizza/m/wcWiuEqwzq", 1920), + }, + { + file: "poly-pizza/animated-shiba-inu.glb", + label: "Shiba Inu", + category: "Animated", + attribution: quaterniusAttribution("https://poly.pizza/m/y4wdQpg767", 1950), + }, + { + file: "poly-pizza/animated-whale.glb", + label: "Whale", + category: "Animated", + attribution: quaterniusAttribution("https://poly.pizza/m/JGFwp6xWgk", 444), + }, + { + file: "poly-pizza/animated-shark.glb", + label: "Shark", + category: "Animated", + attribution: quaterniusAttribution("https://poly.pizza/m/sZR8AMLMz5", 1310), + }, + { + file: "poly-pizza/animated-enemy-small.glb", + label: "Enemy Small", + category: "Animated", + attribution: quaterniusAttribution("https://poly.pizza/m/4LjT020LQh", 1572), + }, + { + file: "poly-pizza/animated-slime-enemy.glb", + label: "Slime Enemy", + category: "Animated", + attribution: quaterniusAttribution("https://poly.pizza/m/eSLKTsbl7F", 1968), }, { file: "poly-pizza/cow.glb", @@ -351,72 +280,6 @@ export const POLY_PIZZA_PRESET_FILES: GalleryPresetFile[] = [ tris: 644, }, }, - { - file: "poly-pizza/guard-tower.glb", - label: "Guard Tower", - category: "Architecture", - attribution: { - creator: "Quaternius", - license: "CC0 1.0", - sourceUrl: "https://poly.pizza/m/sbaM8I229r", - tris: 344, - }, - }, - { - file: "poly-pizza/house.glb", - label: "House", - category: "Architecture", - attribution: { - creator: "Kenney", - license: "CC0 1.0", - sourceUrl: "https://poly.pizza/m/7VSVwAg2T3", - tris: 381, - }, - }, - { - file: "poly-pizza/skyscraper.glb", - label: "Skyscraper", - category: "Architecture", - attribution: { - creator: "Kenney", - license: "CC0 1.0", - sourceUrl: "https://poly.pizza/m/XST1j6kYsL", - tris: 456, - }, - }, - { - file: "poly-pizza/tower.glb", - label: "Tower", - category: "Architecture", - attribution: { - creator: "Kenney", - license: "CC0 1.0", - sourceUrl: "https://poly.pizza/m/5lvG0WtuTU", - tris: 683, - }, - }, - { - file: "poly-pizza/two-story-house.glb", - label: "Two story house", - category: "Architecture", - attribution: { - creator: "Kenney", - license: "CC0 1.0", - sourceUrl: "https://poly.pizza/m/sGgL4Nt7I7", - tris: 630, - }, - }, - { - file: "poly-pizza/watch-tower.glb", - label: "Watch Tower", - category: "Architecture", - attribution: { - creator: "Quaternius", - license: "CC0 1.0", - sourceUrl: "https://poly.pizza/m/f2J0aSLVi4", - tris: 656, - }, - }, { file: "poly-pizza/bucket.glb", label: "Bucket", @@ -520,7 +383,7 @@ export const POLY_PIZZA_PRESET_FILES: GalleryPresetFile[] = [ }, { file: "poly-pizza/animated-human.glb", - label: "Animated Human", + label: "Human", category: "Characters", attribution: { creator: "Quaternius", @@ -531,7 +394,7 @@ export const POLY_PIZZA_PRESET_FILES: GalleryPresetFile[] = [ }, { file: "poly-pizza/character-animated.glb", - label: "Character Animated", + label: "Character", category: "Characters", attribution: { creator: "Quaternius", @@ -615,7 +478,6 @@ export const VOX_PRESET_FILES: GalleryPresetFile[] = [ { file: "house.vox", label: "House", category: "VOX", attribution: MINI_MIKES_METRO_MINIS_ATTRIBUTION }, { file: "pyramid.vox", label: "Pyramid", category: "VOX", attribution: MONOGON_ANCIENT_ENVIRONMENT_ATTRIBUTION }, { file: "skyscraper.vox", label: "Skyscraper", category: "VOX", attribution: MONOGON_CYBERPUNK_CITY_ATTRIBUTION }, - { file: "stairs.vox", label: "Stairs", category: "VOX", attribution: MONOGON_TINY_VOXEL_DUNGEON_ATTRIBUTION }, { file: "Plane_03.vox", label: "Plane 03", category: "VOX", attribution: MONOGON_VOXEL_PLANE_ATTRIBUTION }, { file: "bus.vox", label: "Bus", category: "VOX", attribution: MINI_MIKES_METRO_MINIS_ATTRIBUTION }, { file: "tank.vox", label: "Tank", category: "VOX", attribution: MINI_MIKES_METRO_MINIS_ATTRIBUTION }, @@ -661,14 +523,6 @@ export const OBJ_PRESET_FILES: ObjGalleryPresetFile[] = [ zoom: 0.45, attribution: openGameArtAttribution("Kutejnikov", "crate-5", 12), }, - { - file: "opengameart/hay-bale/hay_bale.obj", - label: "Hay Bale", - category: "Environment", - galleryBucket: "Textured", - zoom: 0.45, - attribution: openGameArtAttribution("Mish7913", "hay-bale-0", 108), - }, { file: "opengameart/low-poly-car/car.obj", label: "Low Poly Car", diff --git a/website/src/components/VanillaScene/VanillaScene.tsx b/website/src/components/VanillaScene/VanillaScene.tsx index 6b035b80..a6c3d95f 100644 --- a/website/src/components/VanillaScene/VanillaScene.tsx +++ b/website/src/components/VanillaScene/VanillaScene.tsx @@ -33,6 +33,122 @@ export type { GizmoMode, SceneOptionsState }; // Light helper world units → CSS pixels conversion (matches the helper // components in @layoutit/polycss-react and @layoutit/polycss-vue). const LIGHT_HELPER_TILE = 50; +const ANIMATION_STABLE_TRIANGLE_COLOR_POLICY = "cadence"; +const ANIMATION_STABLE_TRIANGLE_COLOR_STEPS = 0; +const ANIMATION_STABLE_TRIANGLE_COLOR_FREEZE_FRAMES = 12; +const ANIMATION_STABLE_TRIANGLE_COLOR_MAX_STEP = 8; +const ANIMATION_TRANSFORM_CACHE_FRAMES = 60; + +interface StableTriangleTransformFrameItem { + transform: string; + visibility: string; +} + +interface StableTriangleTransformCache { + frames: Array; + leaves: HTMLElement[] | null; + leafCount: number; + filled: number; + disabled: boolean; + lastAppliedFrameIndex: number; +} + +function createStableTriangleTransformCache(): StableTriangleTransformCache { + return { + frames: Array.from({ length: ANIMATION_TRANSFORM_CACHE_FRAMES }), + leaves: null, + leafCount: 0, + filled: 0, + disabled: false, + lastAppliedFrameIndex: -1, + }; +} + +function resetStableTriangleTransformCache(cache: StableTriangleTransformCache): void { + cache.frames.fill(undefined); + cache.leaves = null; + cache.leafCount = 0; + cache.filled = 0; + cache.lastAppliedFrameIndex = -1; +} + +function directStableTriangleLeaves(handle: VanillaPolyMeshHandle): HTMLElement[] { + return Array.from(handle.element.querySelectorAll(":scope > u")); +} + +function captureStableTriangleTransformFrame( + handle: VanillaPolyMeshHandle, + cache: StableTriangleTransformCache, + frameIndex: number, +): void { + if (cache.disabled) return; + const leaves = directStableTriangleLeaves(handle); + const expectedCount = handle.polygons.length; + if ( + expectedCount <= 0 || + leaves.length !== expectedCount + ) { + cache.disabled = true; + resetStableTriangleTransformCache(cache); + return; + } + if (cache.leafCount !== 0 && leaves.length !== cache.leafCount) { + resetStableTriangleTransformCache(cache); + } + cache.leaves = leaves; + cache.leafCount = leaves.length; + if (!cache.frames[frameIndex]) cache.filled += 1; + cache.frames[frameIndex] = leaves.map((leaf) => ({ + transform: leaf.style.transform || "none", + visibility: leaf.style.visibility === "hidden" ? "hidden" : "", + })); + cache.lastAppliedFrameIndex = frameIndex; +} + +function applyStableTriangleTransformFrame( + handle: VanillaPolyMeshHandle, + cache: StableTriangleTransformCache, + frameIndex: number, +): boolean { + if (cache.disabled) return false; + const frame = cache.frames[frameIndex]; + if (!frame) return false; + if ( + cache.lastAppliedFrameIndex === frameIndex && + cache.leaves && + cache.leafCount === frame.length + ) { + return true; + } + const leaves = cache.leaves ?? directStableTriangleLeaves(handle); + if (leaves.length !== frame.length) { + resetStableTriangleTransformCache(cache); + return false; + } + cache.leaves = leaves; + cache.leafCount = leaves.length; + for (let i = 0; i < leaves.length; i += 1) { + const leaf = leaves[i]; + const next = frame[i]; + leaf.style.transform = next.transform; + if (leaf.style.visibility !== next.visibility) { + leaf.style.visibility = next.visibility; + } + } + cache.lastAppliedFrameIndex = frameIndex; + return true; +} + +function animationCacheFrameIndex( + timeSeconds: number, + durationSeconds: number, +): number { + const duration = Math.max(0.001, durationSeconds); + const localTime = ((timeSeconds % duration) + duration) % duration; + return Math.floor( + (localTime / duration) * ANIMATION_TRANSFORM_CACHE_FRAMES, + ) % ANIMATION_TRANSFORM_CACHE_FRAMES; +} function lightHelperPosition( light: PolyDirectionalLight, @@ -63,6 +179,7 @@ export interface VanillaSceneProps { mergePolygonsForMesh: boolean; stableDomForMesh: boolean; animationKey?: string; + animationDurationSeconds?: number; animationFrameFactory?: (timeSeconds: number) => Polygon[]; onBuild: (ms: number) => void; onCameraChange?: (camera: { rotX: number; rotY: number; zoom: number; target?: ReactVec3 }) => void; @@ -90,6 +207,7 @@ export function VanillaScene({ mergePolygonsForMesh, stableDomForMesh, animationKey, + animationDurationSeconds, animationFrameFactory, onBuild, onCameraChange, @@ -413,6 +531,15 @@ export function VanillaScene({ let last = performance.now(); let elapsedSeconds = 0; let sampledSeconds: number | null = null; + let animationFrameCount = 0; + let lastAnimatedPolygonCount = 0; + const transformCache = + stableDomForMesh && + options.textureLighting === "baked" && + typeof animationDurationSeconds === "number" && + animationDurationSeconds > 0 + ? createStableTriangleTransformCache() + : null; const tick = (now: number) => { const deltaSeconds = Math.max(0, (now - last) / 1000); @@ -423,18 +550,112 @@ export function VanillaScene({ const handle = meshHandleRef.current; if (handle && sampledSeconds !== elapsedSeconds) { sampledSeconds = elapsedSeconds; - handle.setPolygons(animationFrameFactory(elapsedSeconds), { + animationFrameCount += 1; + const debugTarget = window as unknown as { + __polycssGalleryAnimationSamples?: Array<{ + t: number; + sampleMs: number; + setPolygonsMs: number; + polygons: number; + transformCacheHit?: boolean; + transformCacheFilled?: number; + transformCacheMs?: number; + }>; + __polycssStableTriangleDebug?: "transform-only" | "plan-only"; + }; + const stableTriangleDebug = debugTarget.__polycssStableTriangleDebug; + const setPolygonsOptions = { merge: false, stableDom: true, recomputeAutoCenter: false, - }); + ...(stableTriangleDebug === "transform-only" || stableTriangleDebug === "plan-only" + ? { stableTriangleDebug } + : {}), + stableTriangleColorSteps: ANIMATION_STABLE_TRIANGLE_COLOR_STEPS, + stableTriangleColorPolicy: ANIMATION_STABLE_TRIANGLE_COLOR_POLICY, + stableTriangleColorFreezeFrames: ANIMATION_STABLE_TRIANGLE_COLOR_FREEZE_FRAMES, + stableTriangleColorMaxStep: ANIMATION_STABLE_TRIANGLE_COLOR_MAX_STEP, + } satisfies Parameters[1] & { + stableTriangleDebug?: "transform-only" | "plan-only"; + stableTriangleUpdateMode?: "full" | "transform-only" | "color-only"; + stableTriangleColorPolicy: "cadence" | "adaptive"; + stableTriangleColorSteps: number; + stableTriangleColorFreezeFrames: number; + stableTriangleColorMaxStep: number; + }; + const canUseTransformCache = + transformCache && + !transformCache.disabled && + !stableTriangleDebug && + animationDurationSeconds !== undefined; + const frameIndex = canUseTransformCache + ? animationCacheFrameIndex(elapsedSeconds, animationDurationSeconds) + : -1; + const shouldRefreshColor = + canUseTransformCache && + animationFrameCount % ANIMATION_STABLE_TRIANGLE_COLOR_FREEZE_FRAMES === 0; + const samples = debugTarget.__polycssGalleryAnimationSamples; + let sampleMs = 0; + let setPolygonsMs = 0; + let transformCacheMs = 0; + let transformCacheHit = false; + if (canUseTransformCache) { + const cacheStart = samples ? performance.now() : 0; + transformCacheHit = applyStableTriangleTransformFrame( + handle, + transformCache, + frameIndex, + ); + transformCacheMs = samples ? performance.now() - cacheStart : 0; + } + if (transformCacheHit && shouldRefreshColor) { + const sampleStart = samples ? performance.now() : 0; + const animatedPolygons = animationFrameFactory(elapsedSeconds); + sampleMs = samples ? performance.now() - sampleStart : 0; + lastAnimatedPolygonCount = animatedPolygons.length; + const setStart = samples ? performance.now() : 0; + handle.setPolygons(animatedPolygons, { + ...setPolygonsOptions, + stableTriangleUpdateMode: "color-only", + }); + setPolygonsMs = samples ? performance.now() - setStart : 0; + } else if (!transformCacheHit) { + const sampleStart = samples ? performance.now() : 0; + const animatedPolygons = animationFrameFactory(elapsedSeconds); + sampleMs = samples ? performance.now() - sampleStart : 0; + lastAnimatedPolygonCount = animatedPolygons.length; + const setStart = samples ? performance.now() : 0; + handle.setPolygons(animatedPolygons, setPolygonsOptions); + setPolygonsMs = samples ? performance.now() - setStart : 0; + if (canUseTransformCache) { + captureStableTriangleTransformFrame(handle, transformCache, frameIndex); + } + } + if (samples) { + samples.push({ + t: now, + sampleMs, + setPolygonsMs, + polygons: lastAnimatedPolygonCount, + transformCacheHit, + transformCacheFilled: transformCache?.filled, + transformCacheMs, + }); + if (samples.length > 1800) samples.splice(0, samples.length - 1800); + } } raf = requestAnimationFrame(tick); }; raf = requestAnimationFrame(tick); return () => cancelAnimationFrame(raf); - }, [animationKey, animationFrameFactory]); + }, [ + animationKey, + animationFrameFactory, + animationDurationSeconds, + options.textureLighting, + stableDomForMesh, + ]); // Effect 2 — cheap: live transform + lighting updates via setOptions. // Sliding sliders only flows through this path. diff --git a/website/src/content/docs/api/headless.mdx b/website/src/content/docs/api/headless.mdx index b41eb5d6..6f035ebe 100644 --- a/website/src/content/docs/api/headless.mdx +++ b/website/src/content/docs/api/headless.mdx @@ -218,6 +218,26 @@ Anything that satisfies that subset works, so layered helpers can compose multip --- +## `collectPolyRenderStats(root, options?)` + +Reads an already-rendered polycss DOM subtree and returns a one-shot diagnostic snapshot. It counts mounted polygon leaves, shadow leaves, surface leaf categories, and bucket wrappers; it does not observe changes or mutate the scene. + +```ts +import { collectPolyRenderStats } from "@layoutit/polycss"; + +const stats = collectPolyRenderStats(document.querySelector(".polycss-scene"), { + polygonCount: polygons.length, +}); + +console.log(stats.mountedPolygonLeafCount, stats.surfaceLeafCounts); +``` + +`scopeSelector` narrows the count to matching subtrees, which is useful when helpers, floors, or gizmos share the same scene root. The same utility is exported from `@layoutit/polycss-react` and `@layoutit/polycss-vue`. + +**Options:** See [`PolyRenderStatsOptions`](/api/types/#polyrenderstatsoptions). + +--- + ## Custom elements (vanilla) Register ``, ``, ``, ``, ``, and `` custom elements by importing the side-effect entry point: @@ -241,8 +261,8 @@ See the [polycss README](https://github.com/LayoutitStudio/polycss/tree/main/pac | Import path | Contents | |---|---| -| `@layoutit/polycss-react` | React components: `PolyScene`, `PolyMesh`, `Poly`, `PolyCamera`, hooks | -| `@layoutit/polycss-vue` | Vue components: `PolyScene`, `PolyMesh`, `Poly`, `PolyCamera` | -| `@layoutit/polycss` | Vanilla imperative API + custom element classes | +| `@layoutit/polycss-react` | React components: `PolyScene`, `PolyMesh`, `Poly`, `PolyCamera`, hooks, render diagnostics | +| `@layoutit/polycss-vue` | Vue components: `PolyScene`, `PolyMesh`, `Poly`, `PolyCamera`, render diagnostics | +| `@layoutit/polycss` | Vanilla imperative API + custom element classes + render diagnostics | | `@layoutit/polycss/elements` | Side-effect: registers the polycss custom elements | | `@layoutit/polycss-core` | Pure parsers / math, zero DOM: `parseObj`, `parseGltf`, `parseVox`, `loadMesh`, types | diff --git a/website/src/content/docs/api/types.mdx b/website/src/content/docs/api/types.mdx index 24a487f1..97990d02 100644 --- a/website/src/content/docs/api/types.mdx +++ b/website/src/content/docs/api/types.mdx @@ -3,7 +3,7 @@ title: Core Types description: TypeScript type definitions for polygons, camera state, lighting, and parse results. --- -All types are exported from `@layoutit/polycss-react`, `@layoutit/polycss-vue`, `@layoutit/polycss`, and `@layoutit/polycss-core`. +Core parser and math types are exported from `@layoutit/polycss-react`, `@layoutit/polycss-vue`, `@layoutit/polycss`, and `@layoutit/polycss-core`. DOM render diagnostic types are exported from the renderer packages: `@layoutit/polycss-react`, `@layoutit/polycss-vue`, and `@layoutit/polycss`. ## `Polygon` @@ -88,6 +88,49 @@ type PolyTextureLightingMode = "baked" | "dynamic"; --- +## `PolyRenderStats` + +One-shot DOM diagnostic snapshot returned by `collectPolyRenderStats`. + +```ts +interface PolyRenderSurfaceLeafCounts { + quad: number; + clippedSolid: number; + atlas: number; + stableTriangle: number; +} + +interface PolyRenderStats { + /** Input polygon count when supplied, otherwise mounted polygon leaf count. */ + polygonCount: number; + /** Mounted surface leaves, equal to the sum of surfaceLeafCounts. */ + mountedPolygonLeafCount: number; + /** Mounted cast-shadow leaves. */ + shadowLeafCount: number; + /** Surface leaf categories used by the renderer. */ + surfaceLeafCounts: PolyRenderSurfaceLeafCounts; + /** Number of polycss bucket wrapper nodes under the measured scope. */ + bucketCount: number; +} +``` + +--- + +## `PolyRenderStatsOptions` + +Options for `collectPolyRenderStats`. + +```ts +interface PolyRenderStatsOptions { + /** Source polygon count to include in the returned snapshot. */ + polygonCount?: number; + /** Optional selector used to count only matching rendered subtrees. */ + scopeSelector?: string; +} +``` + +--- + ## `Vec2` A 2D point or UV coordinate. @@ -230,6 +273,8 @@ interface GltfParseOptions { defaultColor?: string; /** Override per-material colors. */ materialColors?: Record; + /** Override per-material textures (material name -> image URL). */ + materialTextures?: Record; /** Treat the model's up axis as Y or Z. Most GLB files use Y. */ upAxis?: "y" | "z"; /** Base URL for resolving external texture references in .gltf files. */ diff --git a/website/src/content/docs/guides/textures.mdx b/website/src/content/docs/guides/textures.mdx index fafcf44d..c85e07f1 100644 --- a/website/src/content/docs/guides/textures.mdx +++ b/website/src/content/docs/guides/textures.mdx @@ -120,6 +120,19 @@ Override material colors or textures without modifying the source files (React / /> ``` +For glTF/GLB files that preserved UVs but lost an external image reference, use the same material-name texture override under `gltfOptions`: + +```tsx + +``` + ## Imperative loading For programmatic loading with explicit lifecycle, use `loadMesh` from the core parser. This is the universal vanilla path; React adds a `usePolyMesh` hook on top: