diff --git a/.agents/skills/compat-hunter/scripts/compat-hunter.mjs b/.agents/skills/compat-hunter/scripts/compat-hunter.mjs index a7ccc480..46f8203c 100755 --- a/.agents/skills/compat-hunter/scripts/compat-hunter.mjs +++ b/.agents/skills/compat-hunter/scripts/compat-hunter.mjs @@ -209,7 +209,14 @@ function summarizeReport(file) { function isKnownWarning(warning) { return /^Skipped primitives with unsupported mode \d+ \((POINTS|LINES|LINE_LOOP|LINE_STRIP)\)$/.test(warning) || warning === "Skipped primitives with unsupported required extension KHR_draco_mesh_compression" - || warning === "Skipped primitives with unsupported required extension EXT_meshopt_compression"; + || /^Skipped primitives with unsupported required extension (EXT|KHR)_meshopt_compression$/.test(warning) + || /^Skipped recursive node reference \d+ in glTF scene graph$/.test(warning) + || warning === "No glTF meshes found" + || warning === "No non-degenerate glTF triangles remained after normalization" + || /^Mesh .+: skipped mesh with non-array primitives$/.test(warning) + || warning === "Skipped OBJ point elements; PolyCSS only renders face polygons" + || warning === "Skipped OBJ line elements; PolyCSS only renders face polygons" + || warning === "Skipped MagicaVoxel scene graph transforms; models were flattened into one grid"; } function isKnownError(message) { diff --git a/AGENTS.md b/AGENTS.md index 559d7415..d0c745ff 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -36,18 +36,19 @@ Voxel-shaped meshes are the exception to "all polygons stay mounted": meshes wit | `` | **Border-shape clipped solid** | Untextured non-rect not caught by the exact corner-shape solid path, on browsers with CSS `border-shape` (Chromium + `pointer:fine` + `hover:hover`) | `border-color: currentColor` on a fixed 16px border-shape primitive, clipped by `border-shape: polygon(...)`; polygon bbox scale and tiny solid bleed are folded into `matrix3d` | None | | `` | **Atlas slice** | Textured polygons, or untextured non-rect on browsers without `border-shape` | `background-image` slice of packed bitmap on an auto-budgeted fixed primitive (128px for desktop-class `textureQuality="auto"`, 64px for mobile-class `auto` and explicit numeric quality); atlas position/size and `matrix3d` scale are normalized to the slice, shared textured edges get low-alpha atlas pixels repaired during atlas generation, and solid fallbacks get same-color edge bleed to avoid dark alpha fringes | Bounding-rect area | | `` | **Stable solid triangle / corner-shape solid** | Triangles on non-WebKit engines; or untextured non-triangle polygons whose normalized outline is exactly a rectangle with one or more beveled corners on browsers with CSS `corner-shape` | Triangles use a 32px box with two beveled top corners and `background: currentColor` when CSS `corner-shape` support is present, progressively falling back to the CSS border-color triangle trick. Firefox uses a 96px border-triangle primitive to avoid large-perspective compositor banding. Exact corner-shape solids use a bare fixed 32px box with inline per-corner radii + `corner-*-shape: bevel` and `background: currentColor`. Tiny solid bleed is folded into `matrix3d`. WebKit/Safari falls through to `` for border triangles because transformed CSS border triangles composite incorrectly there. | None | -| `` | **Cast shadow leaf** | Per casting polygon when `castShadow: true`, in either lighting mode. Applies regardless of caster strategy — ``/``/``/`` all produce a `` shadow because only the polygon's outline matters, not its surface. | Same `border-color: currentColor` + `border-shape: polygon(...)` as ``. Dynamic mode chains `var(--shadow-proj)` (driven by `--clx/y/z` + `--shadow-ground-cssz`) so the projection follows the live light vars. Baked mode CPU-bakes the projection into the leaf's inline `matrix3d(...)` and drops back-facing polys from the DOM entirely instead of opacity-gating them. | None | Strategies are ordered cheapest → most expensive. The mesher's job is to maximise `` / `` / `` and minimise `` (see "Meshing implications" below). Callers can opt out of specific strategies via `strategies: { disable: ["b" | "i" | "u"] }` on `RenderTextureAtlasOptions`. Disabled or unsupported strategies fall through the chain (`b → i → s`, `u → i → s`, `i → s`). Disabling `"i"` also disables the exact corner-shape solid branch even though that branch emits a bare ``, because it belongs to the non-triangle clipped-solid family. `` is the universal fallback and cannot be disabled. Solid seam bleed is internal: detected shared solid edges get up to `1.5` CSS px of per-edge overscan, fitted to the polygon plan, rather than inflating every side of each participating polygon. It is not exposed as a scene, mesh, custom-element, or atlas renderer option. +Cast shadows are not a render-strategy leaf tag. Meshes with `castShadow: true` project casting polygons on the CPU into SVG shadow surfaces: one aggregate path for the ground plane in vanilla, per-mesh SVG paths in React/Vue, plus scene-level receiver surfaces where `receiveShadow` is enabled. Overlapping projections are merged into compound paths so shadows do not alpha-stack polygon-by-polygon, and back-facing / duplicate projections are dropped before emission. Moving a light or changing caster/receiver geometry re-emits the shadow SVGs; this is DOM/SVG work only and does not redraw texture atlases. + The `.vox` fast path emits plain `` elements inside `.polycss-voxel-face` wrappers. They intentionally reuse the cheap quad tag; each visible quad has one `matrix3d(...)`, with same-color shared-edge overscan folded into the local left/top/width/height before matrix generation. The face wrappers are grouping nodes for cheap add/remove and are not render-strategy leaves. Desktop-class documents use a canonical 1px primitive for the cheapest transform shape; mobile-class documents (`pointer: coarse` or `hover: none`) use an 8px primitive and divide the in-plane matrix scale by 8 to preserve identical CSS-space geometry while avoiding large GPU filtering gaps. ### Lighting modes (`PolyTextureLightingMode = "baked" | "dynamic"`) -- **Baked.** Lambert is computed once on the CPU per polygon, multiplied into the inline `color` (for ``/``/``) or into the rasterised atlas pixels (for ``). Moving a light requires explicit re-rasterising of affected polys via `mesh.rebakeAtlas()`; cast-shadow `` leaves auto re-emit with a fresh CPU-baked `matrix3d` (DOM-only, no atlas redraw) so shadows can still follow the light interactively even when the lit-side shading stays frozen. -- **Dynamic.** Scene root carries the light setup as custom properties (`--plx/y/z`, `--plr/g/b`, `--pli`, `--par/g/b`, `--pai`). Each leaf embeds its surface normal (`--pnx/y/z`) and base color (`--psr/g/b`) inline. CSS `calc()` resolves the Lambert dot product and per-channel tint at paint time. Moving a light mutates one var on the scene root — zero JS, no atlas redraw. Cast shadows project via `--shadow-proj` and gate back-facing polys with a CSS opacity calc. +- **Baked.** Lambert is computed once on the CPU per polygon, multiplied into the inline `color` (for ``/``/``) or into the rasterised atlas pixels (for ``). Moving a light requires explicit re-rasterising of affected lit polys via `mesh.rebakeAtlas()`. Cast shadows are independent SVG projections and can re-emit without atlas redraw, so shadows can follow the light interactively even when lit-side shading stays frozen. +- **Dynamic.** Scene root carries the light setup as custom properties (`--plx/y/z`, `--plr/g/b`, `--pli`, `--par/g/b`, `--pai`). Each leaf embeds its surface normal (`--pnx/y/z`) and base color (`--psr/g/b`) inline. CSS `calc()` resolves the Lambert dot product and per-channel tint at paint time. Moving a light mutates scene-root vars for surface lighting — zero JS, no atlas redraw. Cast shadows still use CPU-projected SVG paths and re-emit when the directional light changes. All solid/atlas tags work in both modes. The `.vox` direct-matrix fast path is baked-only for now; dynamic mode uses the polygon path so lighting semantics stay correct. The full coverage matrix is in `packages/polycss/src/styles/styles.ts`. diff --git a/bench/mask-polygon-bench.mjs b/bench/mask-polygon-bench.mjs new file mode 100644 index 00000000..278af37a --- /dev/null +++ b/bench/mask-polygon-bench.mjs @@ -0,0 +1,423 @@ +#!/usr/bin/env node +/** + * Synthetic browser bench for solid non-rect polygon primitives: + * + * node bench/mask-polygon-bench.mjs + * node bench/mask-polygon-bench.mjs --counts 1000,3000 --variants border16,solid64,mask16,atlas64 --label mask-poly + * node bench/mask-polygon-bench.mjs --variants border16,border16:ellipse0,border16:inset50,border16:xywh0 + * node bench/mask-polygon-bench.mjs --variants border16,solid64,clip16,svgpoly16,svgpath16 + * node bench/mask-polygon-bench.mjs --variants border16,borderclass16,bordervar16,bordercontainpaint16,bordernowill16 + */ +import { createServer } from "node:http"; +import { readFile, mkdir, writeFile } 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 argv = process.argv.slice(2); +const flag = (name) => argv.indexOf(`--${name}`); +const hasFlag = (name) => flag(name) >= 0 || argv.includes(`--${name}=true`); +const optStr = (name, dflt = "") => { + const exact = flag(name); + if (exact >= 0) return argv[exact + 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, String(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 COUNTS = parseNumberList(optStr("counts", "1000,3000,6000")); +const VARIANTS = parseVariants(optStr("variants", "border16,mask16,mask64,atlas64")); +const REPEATS = Math.max(1, Math.round(optNum("repeats", 2))); +const WARMUP_MS = Math.max(0, optNum("warmup", 1200)); +const SAMPLE_MS = Math.max(250, optNum("sample", 2500)); +const SHAPE = optStr("shape", "hex"); +const MOTION = optStr("motion", "orbit"); +const TARGET = optNum("target", 64); +const LABEL = optStr("label"); +const JSON_PATH = optStr("json"); +const HEADED = hasFlag("headed"); +const BROWSER_EXECUTABLE = optStr("browser-executable"); +const BORDER_SHAPE_FLAG = "--enable-blink-features=CSSBorderShape"; +const CHROMIUM_ARGS = chromiumArgsWithGpuDefault([ + ...(!hasFlag("no-border-shape-flag") ? [BORDER_SHAPE_FLAG] : []), + ...optAll("chromium-arg"), + ...optAll("chromium-args").flatMap((value) => value.split(/\s+/).filter(Boolean)), +]); + +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", + ".png": "image/png", +}; + +function parseNumberList(value) { + return String(value) + .split(",") + .map((part) => Number(part.trim())) + .filter((n) => Number.isFinite(n) && n > 0) + .map((n) => Math.round(n)); +} + +function parseVariants(value) { + return String(value) + .split(",") + .map((part) => part.trim()) + .filter(Boolean) + .map((id) => { + const [baseId, borderInnerRaw] = id.split(":"); + const samePathMatch = /^border(class|var|containpaint|containstrict|containlayout|nowill|isolate|solidstyle)(\d+)?$/i.exec(baseId); + if (samePathMatch) { + const mode = samePathMatch[1].toLowerCase(); + const primitive = Number(samePathMatch[2] ?? 16); + return { + id: `${baseId}${borderInnerRaw ? `:${borderInnerRaw.toLowerCase()}` : ""}`, + strategy: "border", + borderFunction: "polygon", + borderInner: borderInnerRaw?.toLowerCase(), + borderShapeSource: mode === "class" ? "class" : mode === "var" ? "var" : "inline", + leafContain: mode === "containpaint" + ? "paint" + : mode === "containstrict" + ? "strict" + : mode === "containlayout" + ? "layout" + : undefined, + leafWillChange: mode === "nowill" ? "auto" : undefined, + leafIsolation: mode === "isolate" ? "isolate" : undefined, + borderStyle: mode === "solidstyle" ? "solid" : undefined, + primitive, + }; + } + const borderMatch = /^border(?:(polygonplain|polygon|path|shape|circle|ellipse|xywh|inset|rect))?(\d+)?$/i.exec(baseId); + if (borderMatch) { + const borderFunction = (borderMatch[1] ?? "polygon").toLowerCase(); + const primitive = Number(borderMatch[2] ?? 16); + const borderInner = borderInnerRaw?.toLowerCase(); + const baseVariantId = borderFunction === "polygon" ? `border${primitive}` : `border${borderFunction}${primitive}`; + return { + id: borderInner ? `${baseVariantId}:${borderInner}` : baseVariantId, + strategy: "border", + borderFunction, + borderInner, + primitive, + }; + } + const clipMatch = /^clip(\d+)?$/i.exec(id); + if (clipMatch) { + const primitive = Number(clipMatch[1] ?? 16); + return { id: `clip${primitive}`, strategy: "clip", primitive }; + } + const svgMatch = /^svg(?:(poly|polygon|path))?(\d+)?$/i.exec(id); + if (svgMatch) { + const svgMode = (svgMatch[1] ?? "poly").toLowerCase() === "path" ? "path" : "polygon"; + const primitive = Number(svgMatch[2] ?? 16); + return { + id: `svg${svgMode === "path" ? "path" : "poly"}${primitive}`, + strategy: "svg", + svgMode, + primitive, + }; + } + const solidMatch = /^solid(\d+)?$/i.exec(id); + if (solidMatch) { + const primitive = Number(solidMatch[1] ?? 64); + return { id: `solid${primitive}`, strategy: "solid", primitive }; + } + const match = /^(mask|atlas)(\d+)?$/i.exec(id); + if (!match) { + throw new Error( + `Unknown variant "${id}". Use border16, borderclass16, bordervar16, bordercontainpaint16, borderpolygonplain16, borderpath16, bordershape16, solid64, clip16, svgpoly16, svgpath16, mask16, mask64, atlas64, etc.`, + ); + } + const strategy = match[1].toLowerCase(); + const primitive = Number(match[2] ?? (strategy === "atlas" ? 64 : 16)); + return { id: `${strategy}${primitive}`, strategy, primitive }; + }); +} + +function startServer() { + return new Promise((resolveStart, rejectStart) => { + const server = createServer(async (req, res) => { + try { + const url = new URL(req.url, "http://localhost"); + const safe = url.pathname.replace(/\/+/g, "/"); + if (safe.includes("..")) { + res.writeHead(403); + res.end("forbidden"); + return; + } + const abs = resolve(benchDir, safe === "/" ? "mask-polygon.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 (err) { + res.writeHead(404); + res.end(String(err?.message ?? err)); + } + }); + 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 index = (sorted.length - 1) * q; + const lo = Math.floor(index); + const hi = Math.ceil(index); + if (lo === hi) return sorted[lo]; + return sorted[lo] + (sorted[hi] - sorted[lo]) * (index - lo); +} + +function median(values) { + return quantile(values, 0.5); +} + +function summarizeSamples(samples) { + const dts = samples + .map((sample) => Number(sample?.dt)) + .filter((dt) => Number.isFinite(dt) && dt > 0 && dt < 1000); + const p50 = quantile(dts, 0.5); + const p95 = quantile(dts, 0.95); + const p99 = quantile(dts, 0.99); + return { + sampleCount: dts.length, + fpsP50: p50 > 0 ? 1000 / p50 : 0, + frameTimeP50Ms: p50, + frameTimeP95Ms: p95, + frameTimeP99Ms: p99, + over20Ms: dts.filter((dt) => dt > 20).length, + over33Ms: dts.filter((dt) => dt > 33.333).length, + }; +} + +function supported(row) { + if (row.strategy === "border") { + const functionSupported = row.support.borderFunctions?.[row.borderFunction ?? "polygon"] ?? row.support.borderShapeFunction ?? row.support.borderShape; + const innerSupported = row.borderInner ? row.support.borderInners?.[row.borderInner] : true; + return functionSupported && innerSupported; + } + if (row.strategy === "mask") return row.support.maskComposite || row.support.webkitMaskComposite; + if (row.strategy === "clip") return row.support.clipPathPolygon; + return true; +} + +function runUrl(baseUrl, variant, count) { + const url = new URL("/mask-polygon.html", baseUrl); + url.searchParams.set("strategy", variant.strategy); + url.searchParams.set("primitive", String(variant.primitive)); + if (variant.borderFunction) url.searchParams.set("borderFunction", variant.borderFunction); + if (variant.borderInner) url.searchParams.set("borderInner", variant.borderInner); + if (variant.borderShapeSource) url.searchParams.set("borderShapeSource", variant.borderShapeSource); + if (variant.leafContain) url.searchParams.set("leafContain", variant.leafContain); + if (variant.leafWillChange) url.searchParams.set("leafWillChange", variant.leafWillChange); + if (variant.leafIsolation) url.searchParams.set("leafIsolation", variant.leafIsolation); + if (variant.borderStyle) url.searchParams.set("borderStyle", variant.borderStyle); + if (variant.svgMode) url.searchParams.set("svgMode", variant.svgMode); + url.searchParams.set("count", String(count)); + url.searchParams.set("shape", SHAPE); + url.searchParams.set("motion", MOTION); + url.searchParams.set("target", String(TARGET)); + return url.href; +} + +async function runOne(browser, baseUrl, variant, count, repeat) { + const page = await browser.newPage({ viewport: { width: 1280, height: 820 } }); + try { + const url = runUrl(baseUrl, variant, count); + await page.goto(url, { waitUntil: "load" }); + await page.waitForFunction(() => window.__maskPolyBench?.ready === true, null, { timeout: 30000 }); + const result = await page.evaluate( + (options) => window.runMaskPolygonBench(options), + { warmupMs: WARMUP_MS, sampleMs: SAMPLE_MS }, + ); + const frames = summarizeSamples(result.samples); + return { + repeat, + variant: variant.id, + strategy: variant.strategy, + borderFunction: variant.borderFunction, + borderInner: variant.borderInner, + borderShapeSource: variant.borderShapeSource, + leafContain: variant.leafContain, + leafWillChange: variant.leafWillChange, + leafIsolation: variant.leafIsolation, + borderStyle: variant.borderStyle, + svgMode: variant.svgMode, + primitive: variant.primitive, + count, + shape: SHAPE, + motion: MOTION, + target: TARGET, + support: result.support, + supported: supported({ + strategy: variant.strategy, + borderFunction: variant.borderFunction, + borderInner: variant.borderInner, + borderShapeSource: variant.borderShapeSource, + leafContain: variant.leafContain, + leafWillChange: variant.leafWillChange, + leafIsolation: variant.leafIsolation, + borderStyle: variant.borderStyle, + svgMode: variant.svgMode, + support: result.support, + }), + mountMs: result.mountMs, + styleBytes: result.styleBytes, + ...frames, + url, + }; + } finally { + await page.close(); + } +} + +function aggregate(rows) { + const groups = new Map(); + for (const row of rows) { + const key = `${row.count}:${row.variant}`; + const list = groups.get(key) ?? []; + list.push(row); + groups.set(key, list); + } + return [...groups.values()].map((list) => { + const first = list[0]; + return { + count: first.count, + variant: first.variant, + strategy: first.strategy, + borderFunction: first.borderFunction, + borderInner: first.borderInner, + borderShapeSource: first.borderShapeSource, + leafContain: first.leafContain, + leafWillChange: first.leafWillChange, + leafIsolation: first.leafIsolation, + borderStyle: first.borderStyle, + svgMode: first.svgMode, + primitive: first.primitive, + supported: list.every((row) => row.supported), + fpsP50: median(list.map((row) => row.fpsP50)), + frameTimeP50Ms: median(list.map((row) => row.frameTimeP50Ms)), + frameTimeP95Ms: median(list.map((row) => row.frameTimeP95Ms)), + frameTimeP99Ms: median(list.map((row) => row.frameTimeP99Ms)), + mountMs: median(list.map((row) => row.mountMs)), + styleBytes: median(list.map((row) => row.styleBytes)), + samples: list.reduce((sum, row) => sum + row.sampleCount, 0), + }; + }).sort((a, b) => a.count - b.count || a.variant.localeCompare(b.variant)); +} + +function fmt(value, digits = 1) { + return Number.isFinite(value) ? value.toFixed(digits) : "n/a"; +} + +function printRows(rows, aggregates) { + console.log(`[mask-polygon] counts=${COUNTS.join(",")} variants=${VARIANTS.map((v) => v.id).join(",")} repeats=${REPEATS} warmup=${WARMUP_MS}ms sample=${SAMPLE_MS}ms`); + if (CHROMIUM_ARGS.length > 0) console.log(`[mask-polygon] chromium args=${CHROMIUM_ARGS.join(" ")}`); + for (const row of rows) { + console.log( + `#${row.repeat} count=${String(row.count).padStart(5)} ${row.variant.padEnd(8)} ` + + `supported=${row.supported ? "yes" : "no "} fps50=${fmt(row.fpsP50).padStart(5)} ` + + `p95=${fmt(row.frameTimeP95Ms).padStart(5)}ms p99=${fmt(row.frameTimeP99Ms).padStart(5)}ms ` + + `mount=${fmt(row.mountMs, 2).padStart(7)}ms style=${Math.round(row.styleBytes / 1024)}KiB`, + ); + } + console.log("[mask-polygon] aggregate medians"); + for (const row of aggregates) { + console.log( + `count=${String(row.count).padStart(5)} ${row.variant.padEnd(8)} ` + + `supported=${row.supported ? "yes" : "no "} fps50=${fmt(row.fpsP50).padStart(5)} ` + + `p50=${fmt(row.frameTimeP50Ms).padStart(5)}ms p95=${fmt(row.frameTimeP95Ms).padStart(5)}ms ` + + `p99=${fmt(row.frameTimeP99Ms).padStart(5)}ms mount=${fmt(row.mountMs, 2).padStart(7)}ms ` + + `style=${Math.round(row.styleBytes / 1024)}KiB`, + ); + } +} + +let server; +let browser; +try { + if (COUNTS.length === 0) throw new Error("No counts selected."); + if (VARIANTS.length === 0) throw new Error("No variants selected."); + + const started = await startServer(); + server = started.server; + const baseUrl = `http://127.0.0.1:${started.port}`; + browser = await chromium.launch({ + headless: !HEADED, + executablePath: BROWSER_EXECUTABLE || undefined, + args: CHROMIUM_ARGS, + }); + + const rows = []; + for (const count of COUNTS) { + for (const variant of VARIANTS) { + for (let repeat = 1; repeat <= REPEATS; repeat += 1) { + rows.push(await runOne(browser, baseUrl, variant, count, repeat)); + } + } + } + const aggregates = aggregate(rows); + const result = { + kind: "mask-polygon-bench", + options: { + counts: COUNTS, + variants: VARIANTS, + repeats: REPEATS, + warmupMs: WARMUP_MS, + sampleMs: SAMPLE_MS, + shape: SHAPE, + motion: MOTION, + target: TARGET, + chromiumArgs: CHROMIUM_ARGS, + }, + rows, + aggregates, + }; + printRows(rows, aggregates); + + const outputPath = JSON_PATH || (LABEL ? resolve(repoRoot, "bench/results", `${LABEL}.json`) : ""); + if (outputPath) { + await mkdir(dirname(resolve(outputPath)), { recursive: true }); + await writeFile(resolve(outputPath), `${JSON.stringify(result, null, 2)}\n`); + console.log(`[mask-polygon] wrote ${resolve(outputPath)}`); + } +} finally { + if (browser) await browser.close(); + if (server) await stopServer(server); +} diff --git a/bench/mask-polygon.html b/bench/mask-polygon.html new file mode 100644 index 00000000..3d1afd33 --- /dev/null +++ b/bench/mask-polygon.html @@ -0,0 +1,558 @@ + + + + + + PolyCSS mask polygon bench + + + +
+
+ + + diff --git a/bench/nonvoxel-rotation-bench.mjs b/bench/nonvoxel-rotation-bench.mjs index 0c57c0fd..db36d955 100644 --- a/bench/nonvoxel-rotation-bench.mjs +++ b/bench/nonvoxel-rotation-bench.mjs @@ -168,6 +168,9 @@ function selectedModels() { const selected = requested.map((id) => { const known = byId.get(id); if (known) return known; + if (id.startsWith("synth-")) { + return { id, label: id, mesh: id }; + } if (id.startsWith("glb:") || id.startsWith("obj:")) { return { id: id.replace(/[^a-z0-9]+/gi, "-").replace(/^-|-$/g, ""), label: id, mesh: id }; } @@ -344,6 +347,7 @@ function aggregateRuns(runs) { sample_count_filtered: median(runs.map((run) => run.sample_count_filtered)), polyCount: runs[0]?.polyCount ?? 0, renderStats: runs[0]?.renderStats ?? null, + borderShapeStats: runs[0]?.borderShapeStats ?? null, runs, }; } @@ -375,6 +379,7 @@ async function runOnce(port, model, variant, mode) { samples: window.__perf__.samples.slice(from), polyCount: window.__perf__.polyCount, renderStats: window.__perf__.renderStats ?? null, + borderShapeStats: window.__nonvoxelBench?.borderShapeStats?.() ?? null, }), startIdx); await ctx.close(); @@ -384,6 +389,7 @@ async function runOnce(port, model, variant, mode) { ...summarizeFrameTimes(dts, rawDts.length), polyCount: pageResult.polyCount, renderStats: pageResult.renderStats, + borderShapeStats: pageResult.borderShapeStats, }; } finally { await browser.close(); @@ -477,6 +483,7 @@ try { sample_count_filtered: result.sample_count_filtered, polyCount: result.polyCount, renderStats: result.renderStats, + borderShapeStats: result.borderShapeStats, runs: result.runs, }; rows.push(row); diff --git a/bench/nonvoxel-vanilla.html b/bench/nonvoxel-vanilla.html index 000bcbdd..90c8d103 100644 --- a/bench/nonvoxel-vanilla.html +++ b/bench/nonvoxel-vanilla.html @@ -34,6 +34,7 @@ const displayCullBias = Number(benchParams.get("displayCullBias") || 0); const displayCullDecimals = Number(benchParams.get("displayCullDecimals") || 1); const displayCullMinBucketSize = Number(benchParams.get("displayCullMinBucketSize") || 2); + const borderShapeMode = benchParams.get("borderShapeMode") || "inline"; const recordFrameWork = benchParams.get("frameWork") === "1"; const disabledStrategies = (benchParams.get("disableStrategies") || "") .split(",") @@ -112,6 +113,7 @@ frameWorkSamples: () => frameWork.samples(), resetInteractionStats, cullStats: () => displayCullStats, + borderShapeStats: () => borderShapeStats, cameraPerspective: () => cameraPerspective(), setMeshPosition(_position) {}, setMeshRotation(_rotation) {}, @@ -134,6 +136,7 @@ let splitShell = null; let displayCullController = null; let displayCullStats = null; + let borderShapeStats = null; function formatMatrixTransform(transform) { const matrix = new DOMMatrix(transform || "none"); @@ -480,6 +483,62 @@ } } + function applyBorderShapeMode() { + if (borderShapeMode === "inline") return; + const leaves = Array.from(host.querySelectorAll(".polycss-mesh i")) + .filter((leaf) => leaf instanceof HTMLElement); + const withBorderShape = leaves + .map((leaf) => [leaf, leaf.style.getPropertyValue("border-shape").trim()]) + .filter(([, borderShape]) => borderShape); + if (withBorderShape.length === 0) { + borderShapeStats = { mode: borderShapeMode, leaves: leaves.length, changed: 0, unique: 0, cssBytes: 0 }; + return; + } + + const style = document.createElement("style"); + style.setAttribute("data-polycss-bench-border-shape-mode", borderShapeMode); + let cssText = ""; + if (borderShapeMode === "class") { + const classByShape = new Map(); + for (const [leaf, borderShape] of withBorderShape) { + let className = classByShape.get(borderShape); + if (!className) { + className = `polycss-bench-bs-${classByShape.size}`; + classByShape.set(borderShape, className); + cssText += `.${className}{border-shape:${borderShape}}\n`; + } + leaf.classList.add(className); + leaf.style.removeProperty("border-shape"); + } + borderShapeStats = { + mode: borderShapeMode, + leaves: leaves.length, + changed: withBorderShape.length, + unique: classByShape.size, + cssBytes: cssText.length, + }; + } else if (borderShapeMode === "var") { + cssText = `.polycss-bench-bs-var{border-shape:var(--polycss-bench-border-shape)}\n`; + for (const [leaf, borderShape] of withBorderShape) { + leaf.classList.add("polycss-bench-bs-var"); + leaf.style.setProperty("--polycss-bench-border-shape", borderShape); + leaf.style.removeProperty("border-shape"); + } + borderShapeStats = { + mode: borderShapeMode, + leaves: leaves.length, + changed: withBorderShape.length, + unique: new Set(withBorderShape.map(([, borderShape]) => borderShape)).size, + cssBytes: cssText.length, + }; + } + + if (cssText) { + style.textContent = cssText; + document.head.appendChild(style); + } + } + function brushKey(polygon) { return polygon.material?.key ?? polygon.material?.name ?? polygon.color ?? polygon.texture ?? "none"; } @@ -870,6 +929,7 @@ applyIslandBuckets(indexedParseResult); applyNormalCullBuckets(indexedParseResult); applyLeafBuckets(); + applyBorderShapeMode(); applySceneTransformMode(); displayCullController = createDisplayCullController(indexedParseResult.polygons); displayCullController?.update(scene.camera.state.rotY ?? preset.rotY, scene.camera.state.rotX ?? preset.rotX); diff --git a/bench/nonvoxel-variants.mjs b/bench/nonvoxel-variants.mjs index 0af61286..232e194e 100644 --- a/bench/nonvoxel-variants.mjs +++ b/bench/nonvoxel-variants.mjs @@ -41,6 +41,48 @@ export const NONVOXEL_VARIANTS = [ params: { disableStrategies: "i" }, hypothesis: "Force irregular solids to atlas slices instead of border-shape leaves.", }, + { + id: "border-shape-classes", + label: "Border Shape Classes", + params: { borderShapeMode: "class" }, + hypothesis: "Keep leaves but move repeated border-shape declarations from inline style into generated CSS classes.", + }, + { + id: "border-shape-vars", + label: "Border Shape Vars", + params: { borderShapeMode: "var" }, + hypothesis: "Keep leaves but route border-shape through a per-leaf CSS variable.", + }, + { + id: "no-stable-tri-border-shape-classes", + label: "No Stable Triangles + Border Shape Classes", + params: { disableStrategies: "u", borderShapeMode: "class" }, + hypothesis: "Force triangles through while moving repeated border-shape declarations into generated CSS classes.", + }, + { + id: "no-stable-tri-border-shape-vars", + label: "No Stable Triangles + Border Shape Vars", + params: { disableStrategies: "u", borderShapeMode: "var" }, + hypothesis: "Force triangles through while routing border-shape through a per-leaf CSS variable.", + }, + { + id: "all-border-shape", + label: "All Solids Via Border Shape", + params: { disableStrategies: "b,u" }, + hypothesis: "Force solid quads and triangles through the same border-shape renderer path.", + }, + { + id: "all-border-shape-classes", + label: "All Solids Via Border Shape Classes", + params: { disableStrategies: "b,u", borderShapeMode: "class" }, + hypothesis: "Force solid quads and triangles through , with repeated border-shape declarations hoisted into generated CSS classes.", + }, + { + id: "all-border-shape-vars", + label: "All Solids Via Border Shape Vars", + params: { disableStrategies: "b,u", borderShapeMode: "var" }, + hypothesis: "Force solid quads and triangles through , with the border-shape payload routed through per-leaf CSS variables.", + }, { id: "no-stable-tri", label: "No Stable Triangles", diff --git a/packages/core/src/camera/camera.ts b/packages/core/src/camera/camera.ts index a470d5d4..ea39bc0d 100644 --- a/packages/core/src/camera/camera.ts +++ b/packages/core/src/camera/camera.ts @@ -29,7 +29,7 @@ export type AutoRotateOption = boolean | number | AutoRotateConfig; * `distance` is the camera's pull-back from the target in CSS pixels. * Increasing distance moves the camera farther from the target along the * view axis (dolly out) — analogous to three.js's spherical radius. - * Default 0 keeps the legacy behaviour unchanged. + * Default 0 keeps orthographic/isometric scenes flat. */ export interface CameraState { target: Vec3; diff --git a/packages/core/src/color/lighting.ts b/packages/core/src/color/lighting.ts index 4177b2af..52f9287b 100644 --- a/packages/core/src/color/lighting.ts +++ b/packages/core/src/color/lighting.ts @@ -1,15 +1,10 @@ -/* Shared lighting helpers for polycss polygons. +/* Shared lighting helpers for PolyCSS polygons. * Pure module — zero DOM dependencies. * - * Voxcss carried per-cube-face shading helpers (`shadeCubeFace`, - * `shadeWallFace`, `getCubeFaceLightDelta`) and a shape-rotation-based - * `computeShapeLighting(shape, rotation, baseColor)`. All of that's gone - * with cube removal in Phase 2. - * - * The new `computeShapeLighting(normal, baseColor, light?)` is a per-polygon - * Lambert shader. The renderer (`Poly.tsx`) may keep its Lambert math inline - * for performance, but the helper exists for users who want to shade - * polygons outside the renderer (e.g. SSR, validators, alternate backends). + * `computeShapeLighting(normal, baseColor, light?)` is a per-polygon Lambert + * shader. Renderers may keep Lambert math inline for performance, but this + * helper supports users who shade polygons outside the renderer, such as SSR, + * validators, or alternate backends. */ import type { PolyAmbientLight, PolyDirectionalLight, Vec3 } from "../types"; import { diff --git a/packages/core/src/cull/cullInteriorPolygons.ts b/packages/core/src/cull/cullInteriorPolygons.ts index 52f25498..939f32a4 100644 --- a/packages/core/src/cull/cullInteriorPolygons.ts +++ b/packages/core/src/cull/cullInteriorPolygons.ts @@ -504,7 +504,7 @@ export function cullInteriorPolygons( const offY = RAY_ORIGIN_OFFSET * ny; const offZ = RAY_ORIGIN_OFFSET * nz; - // Phase 1 — cheap pre-test: cast a single ray along +normal. + // Step 1 — cheap pre-test: cast a single ray along +normal. { const ox1 = p.centroid[0] + offX; const oy1 = p.centroid[1] + offY; @@ -514,7 +514,7 @@ export function cullInteriorPolygons( } } - // Phase 2 — multi-origin K-sample hemisphere test. + // Step 2 — multi-origin K-sample hemisphere test. const { ux, uy, uz, vx, vy, vz } = basis(p.normal); const cx0 = p.centroid[0], cy0 = p.centroid[1], cz0 = p.centroid[2]; const verts = p.vertices; diff --git a/packages/core/src/merge/dedupeOverlappingPolygons.ts b/packages/core/src/merge/dedupeOverlappingPolygons.ts index 4a76377c..10ef773d 100644 --- a/packages/core/src/merge/dedupeOverlappingPolygons.ts +++ b/packages/core/src/merge/dedupeOverlappingPolygons.ts @@ -5,10 +5,9 @@ * Why this exists: modelers (and importers) often emit redundant geometry * for the same visible surface — a doubled face on a wall, an inner shell * coincident with an outer shell, or two N-gons that fan-triangulate the - * same region. Each duplicate is a real `` element at render time: - * it costs DOM, Lambert math, atlas budget, AND it produces stacked - * shadow leaves that visibly multiply on the receiver (overlapping dark - * patches on the ground). + * same region. Each duplicate is a real render leaf at render time: + * it costs DOM, Lambert math, atlas budget, and redundant shadow projection + * work. * * This is a separate concern from `cullInteriorPolygons` (which removes * polygons fully *enclosed* by other geometry, conservative against @@ -314,9 +313,8 @@ function facesInward(meta: PolyMeta, meshCentroid: Vec3): boolean { * larger area as a tiebreaker. * * Exposed for callers that want to act on the index set directly — - * e.g. shadow casting can use a looser tolerance to skip shadow leaves - * for redundant casters without removing them from the renderable - * polygon set. */ + * e.g. shadow casting can use a looser tolerance to skip redundant caster + * projections without removing them from the renderable polygon set. */ export function findOverlappingPolygonDuplicates( input: Polygon[], options?: DedupeOverlappingPolygonsOptions, diff --git a/packages/core/src/merge/mergePolygons.ts b/packages/core/src/merge/mergePolygons.ts index 07c81bff..fe551b18 100644 --- a/packages/core/src/merge/mergePolygons.ts +++ b/packages/core/src/merge/mergePolygons.ts @@ -1,9 +1,8 @@ /** * Merge coplanar same-color adjacent triangles into N-vertex polygons. * - * Each polygon is rendered as one atlas-backed DOM element — so a mesh whose - * triangles came from quads or pentagons collapses back into its original - * face count. + * Each visible polygon renders as one DOM leaf — so a mesh whose triangles + * came from quads or pentagons collapses back into its original face count. * * - Geodesic spheres: ~half the triangles came from quad subdivisions * - OBJ imports: many were quads/n-gons fan-triangulated by the importer diff --git a/packages/core/src/parser/parseGltf.test.ts b/packages/core/src/parser/parseGltf.test.ts index 0eafd6d7..cdf4057b 100644 --- a/packages/core/src/parser/parseGltf.test.ts +++ b/packages/core/src/parser/parseGltf.test.ts @@ -900,71 +900,74 @@ describe("parseGltf", () => { expect(result.warnings.some((warning) => warning.includes("KHR_draco_mesh_compression"))).toBe(true); }); - it("skips required meshopt-compressed bufferView primitives before reading extension fallback buffers", () => { - const doc = { - asset: { version: "2.0" }, - extensionsRequired: ["EXT_meshopt_compression"], - scene: 0, - scenes: [{ nodes: [0] }], - nodes: [{ mesh: 0 }], - meshes: [{ - name: "MeshoptCompressed", - primitives: [{ - attributes: { POSITION: 0 }, - indices: 1, - mode: 4, + it.each(["EXT_meshopt_compression", "KHR_meshopt_compression"])( + "skips required %s bufferView primitives before reading extension fallback buffers", + (extensionName) => { + const doc = { + asset: { version: "2.0" }, + extensionsRequired: [extensionName], + scene: 0, + scenes: [{ nodes: [0] }], + nodes: [{ mesh: 0 }], + meshes: [{ + name: "MeshoptCompressed", + primitives: [{ + attributes: { POSITION: 0 }, + indices: 1, + mode: 4, + }], }], - }], - accessors: [ - { bufferView: 0, componentType: 5126, count: 3, type: "VEC3" }, - { bufferView: 1, componentType: 5123, count: 3, type: "SCALAR" }, - ], - bufferViews: [ - { - buffer: 1, - byteOffset: 0, - byteLength: 36, - byteStride: 12, - extensions: { - EXT_meshopt_compression: { - buffer: 0, - byteOffset: 0, - byteLength: 4, - byteStride: 12, - mode: "ATTRIBUTES", - count: 3, + accessors: [ + { bufferView: 0, componentType: 5126, count: 3, type: "VEC3" }, + { bufferView: 1, componentType: 5123, count: 3, type: "SCALAR" }, + ], + bufferViews: [ + { + buffer: 1, + byteOffset: 0, + byteLength: 36, + byteStride: 12, + extensions: { + [extensionName]: { + buffer: 0, + byteOffset: 0, + byteLength: 4, + byteStride: 12, + mode: "ATTRIBUTES", + count: 3, + }, }, }, - }, - { - buffer: 1, - byteOffset: 36, - byteLength: 6, - extensions: { - EXT_meshopt_compression: { - buffer: 0, - byteOffset: 0, - byteLength: 4, - byteStride: 2, - mode: "TRIANGLES", - count: 3, + { + buffer: 1, + byteOffset: 36, + byteLength: 6, + extensions: { + [extensionName]: { + buffer: 0, + byteOffset: 0, + byteLength: 4, + byteStride: 2, + mode: "TRIANGLES", + count: 3, + }, }, }, - }, - ], - buffers: [ - { byteLength: 4 }, - { - byteLength: 42, - extensions: { EXT_meshopt_compression: { fallback: true } }, - }, - ], - }; - const result = parseGltf(buildGlb({ doc, binData: new Uint8Array(4) })); + ], + buffers: [ + { byteLength: 4 }, + { + byteLength: 42, + extensions: { [extensionName]: { fallback: true } }, + }, + ], + }; + const result = parseGltf(buildGlb({ doc, binData: new Uint8Array(4) })); - expect(result.polygons).toEqual([]); - expect(result.warnings.some((warning) => warning.includes("EXT_meshopt_compression"))).toBe(true); - }); + expect(result.polygons).toEqual([]); + expect(result.warnings.some((warning) => warning.includes(extensionName))).toBe(true); + }, + ); }); describe("material color", () => { @@ -1215,6 +1218,164 @@ describe("parseGltf", () => { const result = parseGltf(glb); expect(result.polygons).toHaveLength(1); }); + + it("skips recursive scene graph references without overflowing the stack", () => { + const doc = { + asset: { version: "2.0" }, + scene: 0, + scenes: [{ nodes: [0] }], + nodes: [ + { children: [1] }, + { children: [0] }, + ], + }; + const result = parseGltf(buildGlb({ doc })); + + expect(result.polygons).toEqual([]); + expect(result.warnings).toEqual([ + "Skipped recursive node reference 0 in glTF scene graph", + ]); + }); + + it("warns when a glTF has no meshes to emit", () => { + const result = parseGltf(buildGlb({ doc: { asset: { version: "2.0" }, scene: 0 } })); + + expect(result.polygons).toEqual([]); + expect(result.warnings).toEqual(["No glTF meshes found"]); + }); + + it("warns and skips a mesh whose primitives field is not an array", () => { + const doc = { + asset: { version: "2.0" }, + scene: 0, + scenes: [{ nodes: [0] }], + nodes: [{ mesh: 0 }], + meshes: [{ name: "Mesh", primitives: { mode: 4, attributes: { POSITION: 0 } } }], + }; + const result = parseGltf(buildGlb({ doc })); + + expect(result.polygons).toEqual([]); + expect(result.warnings).toEqual([ + "Mesh Mesh: skipped mesh with non-array primitives", + ]); + }); + + it("warns when normalization collapses every raw triangle", () => { + const positions = [0, 0, 0, 1, 0, 0, 0, 1, 0]; + const bin = new Uint8Array(positions.length * 4); + const view = new DataView(bin.buffer); + for (let i = 0; i < positions.length; i++) view.setFloat32(i * 4, positions[i], true); + + const doc = { + asset: { version: "2.0" }, + scene: 0, + scenes: [{ nodes: [0, 1] }], + nodes: [ + { mesh: 0 }, + { mesh: 0, translation: [0, -1_000_000_000, 0] }, + ], + meshes: [{ primitives: [{ attributes: { POSITION: 0 }, mode: 4 }] }], + accessors: [{ bufferView: 0, byteOffset: 0, componentType: 5126, count: 3, type: "VEC3" }], + bufferViews: [{ buffer: 0, byteOffset: 0, byteLength: bin.length }], + buffers: [{ byteLength: bin.length }], + }; + const result = parseGltf(buildGlb({ doc, binData: bin })); + + expect(result.polygons).toEqual([]); + expect(result.warnings).toEqual([ + "No non-degenerate glTF triangles remained after normalization", + ]); + }); + + it("applies default POSITION morph target weights before node transforms", () => { + const positions = [0, 0, 0, 1, 0, 0, 0, 1, 0]; + const deltas = [0, 0, 0, 0, 1, 0, 0, 0, 0]; + const posBytes = positions.length * 4; + const deltaBytes = deltas.length * 4; + const bin = new Uint8Array(posBytes + deltaBytes); + const view = new DataView(bin.buffer); + for (let i = 0; i < positions.length; i++) view.setFloat32(i * 4, positions[i], true); + for (let i = 0; i < deltas.length; i++) view.setFloat32(posBytes + i * 4, deltas[i], true); + + const doc = { + asset: { version: "2.0" }, + scene: 0, + scenes: [{ nodes: [0] }], + nodes: [{ mesh: 0, weights: [1] }], + meshes: [{ + weights: [0], + primitives: [{ + attributes: { POSITION: 0 }, + targets: [{ POSITION: 1 }], + mode: 4, + }], + }], + accessors: [ + { bufferView: 0, byteOffset: 0, componentType: 5126, count: 3, type: "VEC3" }, + { bufferView: 1, byteOffset: 0, componentType: 5126, count: 3, type: "VEC3" }, + ], + bufferViews: [ + { buffer: 0, byteOffset: 0, byteLength: posBytes }, + { buffer: 0, byteOffset: posBytes, byteLength: deltaBytes }, + ], + buffers: [{ byteLength: bin.length }], + }; + const result = parseGltf(buildGlb({ doc, binData: bin }), { + upAxis: "z", + targetSize: 10, + gridShift: 0, + }); + + expect(result.polygons).toHaveLength(1); + expect(result.polygons[0].vertices).toEqual([[0, 0, 0], [10, 10, 0], [0, 10, 0]]); + }); + + it("seeds polygons from animation when the bind pose is fully collapsed", () => { + const positions = [0, 0, 0, 1, 0, 0, 0, 1, 0]; + const times = [0, 1]; + const scales = [0, 0, 0, 1, 1, 1]; + const posBytes = positions.length * 4; + const timeBytes = times.length * 4; + const scaleBytes = scales.length * 4; + const scaleOffset = posBytes + timeBytes; + const bin = new Uint8Array(posBytes + timeBytes + scaleBytes); + const view = new DataView(bin.buffer); + for (let i = 0; i < positions.length; i++) view.setFloat32(i * 4, positions[i], true); + for (let i = 0; i < times.length; i++) view.setFloat32(posBytes + i * 4, times[i], true); + for (let i = 0; i < scales.length; i++) view.setFloat32(scaleOffset + i * 4, scales[i], true); + + const doc = { + asset: { version: "2.0" }, + scene: 0, + scenes: [{ nodes: [0] }], + nodes: [{ mesh: 0, scale: [0, 0, 0] }], + meshes: [{ primitives: [{ attributes: { POSITION: 0 }, mode: 4 }] }], + accessors: [ + { bufferView: 0, byteOffset: 0, componentType: 5126, count: 3, type: "VEC3" }, + { bufferView: 1, byteOffset: 0, componentType: 5126, count: 2, type: "SCALAR" }, + { bufferView: 2, byteOffset: 0, componentType: 5126, count: 2, type: "VEC3" }, + ], + bufferViews: [ + { buffer: 0, byteOffset: 0, byteLength: posBytes }, + { buffer: 0, byteOffset: posBytes, byteLength: timeBytes }, + { buffer: 0, byteOffset: scaleOffset, byteLength: scaleBytes }, + ], + buffers: [{ byteLength: bin.length }], + animations: [{ + samplers: [{ input: 1, output: 2 }], + channels: [{ sampler: 0, target: { node: 0, path: "scale" } }], + }], + }; + const result = parseGltf(buildGlb({ doc, binData: bin }), { + upAxis: "z", + targetSize: 10, + gridShift: 0, + }); + + expect(result.polygons).toHaveLength(1); + expect(result.polygons[0].vertices).toEqual([[0, 0, 0], [10, 0, 0], [0, 10, 0]]); + expect(result.animation?.sample(0, 1)).toHaveLength(1); + }); }); describe("multiple meshes and primitives", () => { @@ -1686,7 +1847,6 @@ describe("parseGltf", () => { describe("POSITION non-float fallback", () => { it("POSITION accessor with non-Float32Array is skipped", () => { // Use UNSIGNED_SHORT (5123) for POSITION — readAccessor returns Uint16Array - // emitMesh: if (!(posArr instanceof Float32Array)) continue; const positions = [0, 0, 0, 1, 0, 0, 0, 1, 0]; const bin = new Uint8Array(positions.length * 2); // uint16 each const bv = new DataView(bin.buffer); @@ -1708,9 +1868,42 @@ describe("parseGltf", () => { }; const glb = buildGlb({ doc, binData: bin }); const result = parseGltf(glb); - // The primitive is skipped because posArr is not Float32Array expect(result.polygons).toHaveLength(0); }); + + it("accepts KHR_mesh_quantization POSITION accessors with integer component types", () => { + const positions = [0, 0, 0, 100, 0, 0, 0, 50, 0]; + const bin = new Uint8Array(positions.length * 2); + const bv = new DataView(bin.buffer); + for (let i = 0; i < positions.length; i++) bv.setUint16(i * 2, positions[i], true); + + const doc = { + asset: { version: "2.0" }, + extensionsUsed: ["KHR_mesh_quantization"], + extensionsRequired: ["KHR_mesh_quantization"], + scene: 0, + scenes: [{ nodes: [0] }], + nodes: [{ mesh: 0, scale: [0.01, 0.01, 0.01] }], + meshes: [{ primitives: [{ attributes: { POSITION: 0 }, mode: 4 }] }], + accessors: [{ + bufferView: 0, + byteOffset: 0, + componentType: 5123, + count: 3, + type: "VEC3", + min: [0, 0, 0], + max: [100, 50, 0], + }], + bufferViews: [{ buffer: 0, byteOffset: 0, byteLength: bin.length }], + buffers: [{ byteLength: bin.length }], + }; + const glb = buildGlb({ doc, binData: bin }); + const result = parseGltf(glb, { upAxis: "z", targetSize: 10, gridShift: 0 }); + + expect(result.polygons).toHaveLength(1); + expect(result.polygons[0].vertices).toEqual([[0, 0, 0], [10, 0, 0], [0, 5, 0]]); + expect(result.warnings).toEqual([]); + }); }); describe("UINT32 index accessor", () => { diff --git a/packages/core/src/parser/parseGltf.ts b/packages/core/src/parser/parseGltf.ts index efffb842..b1b7b716 100644 --- a/packages/core/src/parser/parseGltf.ts +++ b/packages/core/src/parser/parseGltf.ts @@ -2,8 +2,9 @@ * Minimal glTF 2.0 / GLB loader — extracts triangle meshes (positions + * indices + per-material color) into polycss polygons. Also exposes a * lightweight animation sampler for node TRS animation and simple skinned - * meshes. Skips PBR extras and morph targets: the goal is still to render - * polycss polygons, not be a complete glTF runtime. + * meshes. Applies static POSITION morph target weights, but skips PBR extras + * and morph animation: the goal is still to render polycss polygons, not be a + * complete glTF runtime. * * Supports both .glb (binary container with magic "glTF") and .gltf (JSON * with separate .bin) — for .gltf the caller must supply the buffers via @@ -100,6 +101,20 @@ const PRIMITIVE_MODE_NAMES: Record = { 6: "TRIANGLE_FAN", }; +const MESHOPT_COMPRESSION_EXTENSIONS = [ + "EXT_meshopt_compression", + "KHR_meshopt_compression", +] as const; + +function isQuantizedPositionComponentType(componentType: number): boolean { + return ( + componentType === 5120 || + componentType === 5121 || + componentType === 5122 || + componentType === 5123 + ); +} + interface GltfAccessor { bufferView?: number; byteOffset?: number; @@ -163,18 +178,21 @@ interface GltfPrimitive { indices?: number; material?: number; extensions?: Record; + targets?: Array<{ POSITION?: number;[k: string]: number | undefined }>; /** glTF mode: 4 = TRIANGLES, 5 = TRIANGLE_STRIP, 6 = TRIANGLE_FAN. */ mode?: number; } interface GltfMesh { name?: string; primitives: GltfPrimitive[]; + weights?: number[]; } interface GltfNode { name?: string; mesh?: number; skin?: number; children?: number[]; + weights?: number[]; /** TRS — PolyCSS reads either matrix or these three components. */ matrix?: number[]; translation?: number[]; @@ -304,7 +322,10 @@ function resolveBuffers( resolveBuffer?: (uri: string) => Uint8Array | Promise, ): Uint8Array[] { const specs = doc.buffers ?? []; - const canSkipMeshoptFallbackBuffers = (doc.extensionsRequired ?? []).includes("EXT_meshopt_compression"); + const requiredExtensions = new Set(doc.extensionsRequired ?? []); + const canSkipMeshoptFallbackBuffers = MESHOPT_COMPRESSION_EXTENSIONS.some((extensionName) => + requiredExtensions.has(extensionName) + ); return specs.map((buffer, index) => { const uri = buffer.uri; if (uri) { @@ -317,7 +338,10 @@ function resolveBuffers( throw new Error(`parseGltf: external buffer URI "${uri}" — provide options.resolveBuffer`); } if (index === 0 && glbBin) return glbBin; - if (canSkipMeshoptFallbackBuffers && buffer.extensions?.EXT_meshopt_compression) { + if ( + canSkipMeshoptFallbackBuffers && + MESHOPT_COMPRESSION_EXTENSIONS.some((extensionName) => buffer.extensions?.[extensionName]) + ) { return new Uint8Array(0); } throw new Error(`parseGltf: buffer[${index}] has no uri and no GLB BIN chunk`); @@ -754,13 +778,17 @@ function computeWorldMatrices(doc: GltfDoc, localMatrices: Mat4[]): Mat4[] { const nodes = doc.nodes ?? []; const worlds: Mat4[] = new Array(nodes.length); const visited = new Set(); + const visiting = new Set(); const walk = (nodeIdx: number, parentWorld: Mat4): void => { if (nodeIdx < 0 || nodeIdx >= nodes.length) return; + if (visiting.has(nodeIdx)) return; const world = mulMat4(parentWorld, localMatrices[nodeIdx] ?? IDENTITY4); worlds[nodeIdx] = world; visited.add(nodeIdx); + visiting.add(nodeIdx); for (const child of nodes[nodeIdx].children ?? []) walk(child, world); + visiting.delete(nodeIdx); }; const roots = collectSceneRoots(doc); @@ -929,27 +957,10 @@ function sampleAnimationChannel(sampler: RuntimeAnimationSampler, timeSeconds: n return lerpArray(a, b, amount); } -function buildAnimationController( - doc: GltfDoc, - buffers: Uint8Array[], - sources: AnimatedPrimitiveSource[], - polygonRefs: Array, - project: (v: Vec3) => Vec3, - projectFrameVertex: (v: Vec3, out: Float64Array, offset: number) => void, -): ParseAnimationController | undefined { - const animations = doc.animations ?? []; - if (animations.length === 0 || sources.length === 0) return undefined; - - const basePoses = (doc.nodes ?? []).map((node) => poseFromNode(node)); - const baseLocalMatrices = basePoses.map(poseLocalMatrix); - const bindWorldMatrices = computeWorldMatrices(doc, baseLocalMatrices); - const skins = (doc.skins ?? []).map((skin) => ({ - joints: skin.joints ?? [], - inverseBindMatrices: readMat4Array(doc, buffers, skin.inverseBindMatrices, skin.joints?.length ?? 0), - })); +function buildRuntimeAnimationClips(doc: GltfDoc, buffers: Uint8Array[]): RuntimeAnimationClip[] { const runtimeClips: RuntimeAnimationClip[] = []; - for (let i = 0; i < animations.length; i++) { - const animation = animations[i]; + for (let i = 0; i < (doc.animations?.length ?? 0); i++) { + const animation = doc.animations![i]!; const runtimeSamplers = (animation.samplers ?? []).map((sampler): RuntimeAnimationSampler => { const input = readAccessorComponents(doc, buffers, sampler.input); const output = readAccessorComponents(doc, buffers, sampler.output); @@ -984,6 +995,59 @@ function buildAnimationController( channels, }); } + return runtimeClips; +} + +function sampleRuntimeClipWorldMatrices( + doc: GltfDoc, + basePoses: NodePose[], + clip: RuntimeAnimationClip, + timeSecondsIn: number, +): Mat4[] { + const duration = clip.info.duration; + const timeSeconds = duration > 0 + ? ((timeSecondsIn % duration) + duration) % duration + : Math.max(0, timeSecondsIn); + + const poses = basePoses.map((pose): NodePose => ({ + translation: pose.translation.slice(), + rotation: pose.rotation.slice(), + scale: pose.scale.slice(), + matrix: pose.matrix ? pose.matrix.slice() as Mat4 : undefined, + })); + + for (const channel of clip.channels) { + const pose = poses[channel.targetNode]; + if (!pose) continue; + const value = sampleAnimationChannel(channel.sampler, timeSeconds, channel.path); + pose.matrix = undefined; + if (channel.path === "translation") pose.translation = value.slice(0, 3); + else if (channel.path === "rotation") pose.rotation = normalizeQuat(value.slice(0, 4)); + else if (channel.path === "scale") pose.scale = value.slice(0, 3); + } + + return computeWorldMatrices(doc, poses.map(poseLocalMatrix)); +} + +function buildAnimationController( + doc: GltfDoc, + buffers: Uint8Array[], + sources: AnimatedPrimitiveSource[], + polygonRefs: Array, + project: (v: Vec3) => Vec3, + projectFrameVertex: (v: Vec3, out: Float64Array, offset: number) => void, +): ParseAnimationController | undefined { + const animations = doc.animations ?? []; + if (animations.length === 0 || sources.length === 0) return undefined; + + const basePoses = (doc.nodes ?? []).map((node) => poseFromNode(node)); + const baseLocalMatrices = basePoses.map(poseLocalMatrix); + const bindWorldMatrices = computeWorldMatrices(doc, baseLocalMatrices); + const skins = (doc.skins ?? []).map((skin) => ({ + joints: skin.joints ?? [], + inverseBindMatrices: readMat4Array(doc, buffers, skin.inverseBindMatrices, skin.joints?.length ?? 0), + })); + const runtimeClips = buildRuntimeAnimationClips(doc, buffers); const clips = runtimeClips.map((clip) => clip.info); if (clips.length === 0) return undefined; @@ -1076,32 +1140,7 @@ function buildAnimationController( ? runtimeClips[clipRef] : runtimeClips.find((candidate) => candidate.info.name === clipRef); if (!clip) return null; - const duration = clip.info.duration; - const timeSeconds = duration > 0 - ? ((timeSecondsIn % duration) + duration) % duration - : Math.max(0, timeSecondsIn); - - const poses = basePoses.map((pose): NodePose => ({ - translation: pose.translation.slice(), - rotation: pose.rotation.slice(), - scale: pose.scale.slice(), - matrix: pose.matrix ? pose.matrix.slice() as Mat4 : undefined, - })); - - for (const channel of clip.channels) { - const pose = poses[channel.targetNode]; - if (!pose) continue; - const value = sampleAnimationChannel(channel.sampler, timeSeconds, channel.path); - // Animated TRS channels override matrix-based locals per glTF's node - // animation model; converting arbitrary matrices to TRS is intentionally - // out of scope for this minimal runtime. - pose.matrix = undefined; - if (channel.path === "translation") pose.translation = value.slice(0, 3); - else if (channel.path === "rotation") pose.rotation = normalizeQuat(value.slice(0, 4)); - else if (channel.path === "scale") pose.scale = value.slice(0, 3); - } - - return computeWorldMatrices(doc, poses.map(poseLocalMatrix)); + return sampleRuntimeClipWorldMatrices(doc, basePoses, clip, timeSecondsIn); }; const computeSourceWorldPositions = ( @@ -1383,6 +1422,9 @@ export function parseGltf(input: ArrayBuffer | Uint8Array, options?: GltfParseOp const warnings: string[] = []; const warningKeys = new Set(); const requiredExtensions = new Set(doc.extensionsRequired ?? []); + const usesMeshQuantization = + requiredExtensions.has("KHR_mesh_quantization") || + (doc.extensionsUsed ?? []).includes("KHR_mesh_quantization"); function pushWarningOnce(key: string, warning: string): void { if (warningKeys.has(key)) return; @@ -1404,15 +1446,82 @@ export function parseGltf(input: ArrayBuffer | Uint8Array, options?: GltfParseOp return false; } - function primitiveUsesRequiredMeshopt(prim: GltfPrimitive): boolean { - if (!requiredExtensions.has("EXT_meshopt_compression")) return false; - if (prim.extensions?.EXT_meshopt_compression) return true; - if (accessorUsesBufferViewExtension(prim.indices, "EXT_meshopt_compression")) return true; - return Object.values(prim.attributes ?? {}).some((accessorIdx) => - accessorUsesBufferViewExtension(accessorIdx, "EXT_meshopt_compression") + function primitiveUsesRequiredMeshopt(prim: GltfPrimitive): string | undefined { + for (const extensionName of MESHOPT_COMPRESSION_EXTENSIONS) { + if (!requiredExtensions.has(extensionName)) continue; + if (prim.extensions?.[extensionName]) return extensionName; + if (accessorUsesBufferViewExtension(prim.indices, extensionName)) return extensionName; + if (Object.values(prim.attributes ?? {}).some((accessorIdx) => + accessorUsesBufferViewExtension(accessorIdx, extensionName) + )) { + return extensionName; + } + } + return undefined; + } + + function canUsePositionAccessor(accessorIdx: number, array: AccessorArray): boolean { + if (array instanceof Float32Array) return true; + const acc = doc.accessors?.[accessorIdx]; + return ( + usesMeshQuantization && + acc?.type === "VEC3" && + isQuantizedPositionComponentType(acc.componentType) ); } + function morphWeightsFor(mesh: GltfMesh, meshNode: number | null): number[] { + const nodeWeights = meshNode !== null ? doc.nodes?.[meshNode]?.weights : undefined; + return nodeWeights ?? mesh.weights ?? []; + } + + function buildMorphPositionOffsets( + mesh: GltfMesh, + meshIdx: number, + prim: GltfPrimitive, + meshNode: number | null, + vertCount: number, + ): Vec3[] | undefined { + const targets = prim.targets; + if (!targets || targets.length === 0) return undefined; + const weights = morphWeightsFor(mesh, meshNode); + if (!weights.some((weight) => Number.isFinite(weight) && weight !== 0)) return undefined; + + const offsets: Vec3[] = Array.from({ length: vertCount }, () => [0, 0, 0]); + let touched = false; + for (let targetIndex = 0; targetIndex < targets.length; targetIndex++) { + const weight = weights[targetIndex] ?? 0; + if (!Number.isFinite(weight) || weight === 0) continue; + const positionAccessor = targets[targetIndex]?.POSITION; + if (positionAccessor === undefined) continue; + const { array, count, componentCount } = readAccessor(doc, buffers, positionAccessor); + if (count !== vertCount || componentCount < 3) { + pushWarningOnce( + `morph-position-count:${meshIdx}:${targetIndex}`, + `Mesh ${mesh.name ?? meshIdx}: skipped morph target ${targetIndex} POSITION with mismatched count`, + ); + continue; + } + if (!canUsePositionAccessor(positionAccessor, array)) { + pushWarningOnce( + `morph-position-type:${meshIdx}:${targetIndex}`, + `Mesh ${mesh.name ?? meshIdx}: skipped morph target ${targetIndex} POSITION with unsupported accessor type`, + ); + continue; + } + touched = true; + for (let i = 0; i < vertCount; i++) { + const base = i * componentCount; + const offset = offsets[i]!; + offset[0] += Number(array[base]) * weight; + offset[1] += Number(array[base + 1]) * weight; + offset[2] += Number(array[base + 2]) * weight; + } + } + + return touched ? offsets : undefined; + } + interface RawTri { v0: Vec3; v1: Vec3; @@ -1462,6 +1571,13 @@ export function parseGltf(input: ArrayBuffer | Uint8Array, options?: GltfParseOp function emitMesh(meshIdx: number, world: Mat4, meshNode: number | null): void { const mesh = doc.meshes?.[meshIdx]; if (!mesh) return; + if (!Array.isArray(mesh.primitives)) { + pushWarningOnce( + `malformed-primitives:${meshIdx}`, + `Mesh ${mesh.name ?? meshIdx}: skipped mesh with non-array primitives`, + ); + return; + } for (const prim of mesh.primitives) { const mode = prim.mode ?? 4; if (mode !== 4 && mode !== 5 && mode !== 6) { @@ -1479,10 +1595,11 @@ export function parseGltf(input: ArrayBuffer | Uint8Array, options?: GltfParseOp ); continue; } - if (primitiveUsesRequiredMeshopt(prim)) { + const meshoptExtension = primitiveUsesRequiredMeshopt(prim); + if (meshoptExtension) { pushWarningOnce( - "EXT_meshopt_compression", - "Skipped primitives with unsupported required extension EXT_meshopt_compression", + meshoptExtension, + `Skipped primitives with unsupported required extension ${meshoptExtension}`, ); continue; } @@ -1518,12 +1635,24 @@ export function parseGltf(input: ArrayBuffer | Uint8Array, options?: GltfParseOp : undefined; const textureTexCoord = texture ? materialTextureInfo?.texCoord ?? 0 : 0; - const { array: posArr, count: vertCount } = readAccessor(doc, buffers, prim.attributes.POSITION); - if (!(posArr instanceof Float32Array)) continue; + const positionAccessor = prim.attributes.POSITION; + const { + array: posArr, + count: vertCount, + componentCount: positionComponentCount, + } = readAccessor(doc, buffers, positionAccessor); + if (!canUsePositionAccessor(positionAccessor, posArr)) continue; + const morphPositionOffsets = buildMorphPositionOffsets(mesh, meshIdx, prim, meshNode, vertCount); const localPositions: Vec3[] = []; const positions: Vec3[] = []; for (let i = 0; i < vertCount; i++) { - const local: Vec3 = [posArr[i * 3], posArr[i * 3 + 1], posArr[i * 3 + 2]]; + const base = i * positionComponentCount; + const morphOffset = morphPositionOffsets?.[i]; + const local: Vec3 = [ + Number(posArr[base]) + (morphOffset?.[0] ?? 0), + Number(posArr[base + 1]) + (morphOffset?.[1] ?? 0), + Number(posArr[base + 2]) + (morphOffset?.[2] ?? 0), + ]; localPositions.push(local); positions.push(transformPoint(world, local)); } @@ -1608,21 +1737,121 @@ export function parseGltf(input: ArrayBuffer | Uint8Array, options?: GltfParseOp } } - function walkNode(nodeIdx: number, parentWorld: Mat4): void { + function walkNode(nodeIdx: number, parentWorld: Mat4, ancestors: Set): void { + if (ancestors.has(nodeIdx)) { + pushWarningOnce( + `recursive-node:${nodeIdx}`, + `Skipped recursive node reference ${nodeIdx} in glTF scene graph`, + ); + return; + } const node = doc.nodes?.[nodeIdx]; if (!node) return; const world = mulMat4(parentWorld, nodeLocalMatrix(node)); if (typeof node.mesh === "number") emitMesh(node.mesh, world, nodeIdx); - for (const child of node.children ?? []) walkNode(child, world); + ancestors.add(nodeIdx); + for (const child of node.children ?? []) walkNode(child, world, ancestors); + ancestors.delete(nodeIdx); + } + + function seedTimesForClip(clip: RuntimeAnimationClip): number[] { + const times = new Set(); + const duration = clip.info.duration; + for (const channel of clip.channels) { + for (const time of channel.sampler.input) { + if (!Number.isFinite(time) || time <= 0) continue; + if (duration > 0 && time >= duration) continue; + times.add(time); + } + } + if (duration > 0) { + times.add(duration * 0.25); + times.add(duration * 0.5); + times.add(duration * 0.75); + } + return Array.from(times).sort((a, b) => a - b).slice(0, 128); + } + + function animatedRawTriFrame( + tri: RawTri, + worldMatrices: Mat4[], + ): { v0: Vec3; v1: Vec3; v2: Vec3 } | undefined { + const source = tri.source; + if (!source || tri.sourceTriangleIndex === undefined || source.skinIndex !== undefined) return undefined; + const offset = tri.sourceTriangleIndex * 3; + const i0 = source.indices[offset]; + const i1 = source.indices[offset + 1]; + const i2 = source.indices[offset + 2]; + const p0 = i0 !== undefined ? source.positions[i0] : undefined; + const p1 = i1 !== undefined ? source.positions[i1] : undefined; + const p2 = i2 !== undefined ? source.positions[i2] : undefined; + if (!p0 || !p1 || !p2) return undefined; + const meshWorld = source.meshNode !== null + ? (worldMatrices[source.meshNode] ?? source.meshBindWorld) + : source.meshBindWorld; + return { + v0: transformPoint(meshWorld, p0), + v1: transformPoint(meshWorld, p1), + v2: transformPoint(meshWorld, p2), + }; + } + + function sameWorldVertex(a: Vec3, b: Vec3): boolean { + return a[0] === b[0] && a[1] === b[1] && a[2] === b[2]; + } + + function isDegenerateRawTriFrame(frame: { v0: Vec3; v1: Vec3; v2: Vec3 }): boolean { + return ( + sameWorldVertex(frame.v0, frame.v1) || + sameWorldVertex(frame.v0, frame.v2) || + sameWorldVertex(frame.v1, frame.v2) + ); + } + + function hasNonDegenerateRawTri(): boolean { + return rawTris.some((tri) => !isDegenerateRawTriFrame(tri)); + } + + function seedCollapsedRawTrisFromAnimation(): void { + if (rawTris.length === 0 || (doc.animations?.length ?? 0) === 0 || hasNonDegenerateRawTri()) return; + const runtimeClips = buildRuntimeAnimationClips(doc, buffers); + if (runtimeClips.length === 0) return; + const basePoses = (doc.nodes ?? []).map((node) => poseFromNode(node)); + const seededFrames: Array<{ v0: Vec3; v1: Vec3; v2: Vec3 } | undefined> = + new Array(rawTris.length); + + for (const clip of runtimeClips) { + for (const time of seedTimesForClip(clip)) { + const worldMatrices = sampleRuntimeClipWorldMatrices(doc, basePoses, clip, time); + for (let i = 0; i < rawTris.length; i++) { + if (seededFrames[i]) continue; + const frame = animatedRawTriFrame(rawTris[i]!, worldMatrices); + if (frame && !isDegenerateRawTriFrame(frame)) seededFrames[i] = frame; + } + } + } + + if (!seededFrames.some(Boolean)) return; + for (let i = 0; i < rawTris.length; i++) { + const frame = seededFrames[i]; + if (!frame) continue; + rawTris[i]!.v0 = frame.v0; + rawTris[i]!.v1 = frame.v1; + rawTris[i]!.v2 = frame.v2; + } } const sceneIdx = doc.scene ?? 0; const sceneRoots = doc.scenes?.[sceneIdx]?.nodes; if (sceneRoots && sceneRoots.length > 0) { - for (const r of sceneRoots) walkNode(r, IDENTITY4); + for (const r of sceneRoots) walkNode(r, IDENTITY4, new Set()); } else { + if ((doc.meshes?.length ?? 0) === 0) { + pushWarningOnce("no-gltf-meshes", "No glTF meshes found"); + } for (let i = 0; i < (doc.meshes?.length ?? 0); i++) emitMesh(i, IDENTITY4, null); } + seedCollapsedRawTrisFromAnimation(); const dispose = makeDispose(objectUrls); @@ -1678,6 +1907,7 @@ export function parseGltf(input: ArrayBuffer | Uint8Array, options?: GltfParseOp out[offset + 2] = round((v[1] - minY) * scale + gridShift); }; const polygons: Polygon[] = []; + let projectedDegenerateCount = 0; for (const t of rawTris) { const v0 = project(t.v0); const v1 = project(t.v1); @@ -1686,7 +1916,10 @@ export function parseGltf(input: ArrayBuffer | Uint8Array, options?: GltfParseOp if (t.source && t.sourceTriangleIndex !== undefined) { t.source.triangleMask[t.sourceTriangleIndex] = !degenerate; } - if (degenerate) continue; + if (degenerate) { + projectedDegenerateCount++; + continue; + } const p: Polygon = { vertices: [v0, v1, v2], color: t.color, @@ -1703,6 +1936,12 @@ export function parseGltf(input: ArrayBuffer | Uint8Array, options?: GltfParseOp : undefined, ); } + if (polygons.length === 0 && projectedDegenerateCount > 0) { + pushWarningOnce( + "all-projected-triangles-degenerate", + "No non-degenerate glTF triangles remained after normalization", + ); + } const animation = buildAnimationController( doc, buffers, diff --git a/packages/core/src/parser/parseObj.test.ts b/packages/core/src/parser/parseObj.test.ts index 305f7d63..a1154ecc 100644 --- a/packages/core/src/parser/parseObj.test.ts +++ b/packages/core/src/parser/parseObj.test.ts @@ -146,6 +146,23 @@ describe("parseObj — empty input", () => { it("vertices without faces → empty result (faces are what produces polygons)", () => { const r = parseObj("v 0 0 0\nv 1 0 0\nv 0 1 0\n"); expect(r.polygons).toHaveLength(0); + expect(r.warnings).toEqual([]); + }); + + it("point elements are skipped with a warning", () => { + const r = parseObj("v 0 0 0\nv 1 0 0\np 1 2\n"); + expect(r.polygons).toHaveLength(0); + expect(r.warnings).toEqual([ + "Skipped OBJ point elements; PolyCSS only renders face polygons", + ]); + }); + + it("line elements are skipped with a warning", () => { + const r = parseObj("v 0 0 0\nv 1 0 0\nl 1 2\n"); + expect(r.polygons).toHaveLength(0); + expect(r.warnings).toEqual([ + "Skipped OBJ line elements; PolyCSS only renders face polygons", + ]); }); it("objectUrls is always empty (parseObj never mints blob URLs)", () => { @@ -168,6 +185,12 @@ describe("parseObj — fan triangulation", () => { expect(r.polygons).toHaveLength(3); }); + it("backslash-continued face lines parse as one logical line", () => { + const obj = `v 0 0 0\nv 1 0 0\nv 1 1 0\nv 0 1 0\nf 1 2 \\\n 3 4`; + const r = parseObj(obj); + expect(r.polygons).toHaveLength(2); + }); + it("fan vertex (index 0) is shared across all emitted triangles", () => { const obj = `v 0 0 0\nv 1 0 0\nv 1 1 0\nv 0 1 0\nf 1 2 3 4`; const r = parseObj(obj); diff --git a/packages/core/src/parser/parseObj.ts b/packages/core/src/parser/parseObj.ts index 3e3fab6e..4f965403 100644 --- a/packages/core/src/parser/parseObj.ts +++ b/packages/core/src/parser/parseObj.ts @@ -79,6 +79,22 @@ const DEFAULT_PALETTE = [ "#a855f7", "#06b6d4", "#f97316", "#ec4899", ]; +function logicalObjLines(text: string): string[] { + const out: string[] = []; + let pending = ""; + for (const raw of text.split("\n")) { + const line = raw.endsWith("\r") ? raw.slice(0, -1) : raw; + if (line.endsWith("\\")) { + pending += `${line.slice(0, -1).trimEnd()} `; + continue; + } + out.push(pending + line); + pending = ""; + } + if (pending) out.push(pending.trimEnd()); + return out; +} + export function parseObj(text: string, options?: ObjParseOptions): ParseResult { const targetSize = options?.targetSize ?? 60; const gridShift = options?.gridShift ?? 1; @@ -92,6 +108,8 @@ export function parseObj(text: string, options?: ObjParseOptions): ParseResult { const rawFaces: { idx: number[]; uvIdx: (number | null)[]; color: string; texture: string | undefined }[] = []; const materialOrder: string[] = []; const materialColor = new Map(); + const warnings: string[] = []; + const warningKeys = new Set(); let currentColor = defaultColor; let currentTexture: string | undefined = undefined; @@ -115,13 +133,19 @@ export function parseObj(text: string, options?: ObjParseOptions): ParseResult { return materialColor.get(name)!; }; + const pushWarningOnce = (key: string, warning: string): void => { + if (warningKeys.has(key)) return; + warningKeys.add(key); + warnings.push(warning); + }; + const resolveIndex = (rawIndex: string, length: number): number => { const index = parseInt(rawIndex, 10); if (!Number.isFinite(index)) return NaN; return index < 0 ? length + index : index - 1; }; - const lines = text.split("\n"); + const lines = logicalObjLines(text); for (const raw of lines) { if (raw.length === 0 || raw.charCodeAt(0) === 35) continue; // skip "" and "#" if (raw.startsWith("v ")) { @@ -136,6 +160,20 @@ export function parseObj(text: string, options?: ObjParseOptions): ParseResult { const matName = raw.trim().split(/\s+/)[1]; currentColor = colorFor(matName); currentTexture = materialTextures[matName]; + } else if (raw.startsWith("p ")) { + if (objectAllowed()) { + pushWarningOnce( + "unsupported-point-elements", + "Skipped OBJ point elements; PolyCSS only renders face polygons", + ); + } + } else if (raw.startsWith("l ")) { + if (objectAllowed()) { + pushWarningOnce( + "unsupported-line-elements", + "Skipped OBJ line elements; PolyCSS only renders face polygons", + ); + } } else if (raw.startsWith("f ")) { if (!objectAllowed()) continue; const parts = raw.trim().split(/\s+/).slice(1); @@ -157,7 +195,7 @@ export function parseObj(text: string, options?: ObjParseOptions): ParseResult { } if (verts.length === 0 || rawFaces.length === 0) { - return makeEmptyResult(materialOrder, text.length); + return makeEmptyResult(materialOrder, text.length, warnings); } // Bounding box — only count vertices actually referenced by surviving @@ -225,7 +263,7 @@ export function parseObj(text: string, options?: ObjParseOptions): ParseResult { polygons, objectUrls: [], dispose: () => { /* no-op: parseObj has no minted blob URLs */ }, - warnings: [], + warnings, metadata: { triangleCount: polygons.length, materials: materialOrder, @@ -234,12 +272,12 @@ export function parseObj(text: string, options?: ObjParseOptions): ParseResult { }; } -function makeEmptyResult(materials: string[], sourceBytes: number): ParseResult { +function makeEmptyResult(materials: string[], sourceBytes: number, warnings: string[] = []): ParseResult { return { polygons: [], objectUrls: [], dispose: () => { /* no-op */ }, - warnings: [], + warnings, metadata: { triangleCount: 0, materials, diff --git a/packages/core/src/parser/parseVox.test.ts b/packages/core/src/parser/parseVox.test.ts index d675dee6..aab6b1cb 100644 --- a/packages/core/src/parser/parseVox.test.ts +++ b/packages/core/src/parser/parseVox.test.ts @@ -32,15 +32,20 @@ function buildVoxBuffer( size: [number, number, number], voxels: VoxelInput[], palette?: [number, number, number, number][], // 256 RGBA entries + extraChunks: Array<{ id: string; content?: number[] }> = [], ): ArrayBuffer { // SIZE chunk: 12 header + 12 content const sizeChunkBytes = 12 + 12; // XYZI chunk: 12 header + 4 (count) + voxels.length * 4 const xyziChunkBytes = 12 + 4 + voxels.length * 4; + const extraChunkBytes = extraChunks.reduce( + (sum, chunk) => sum + 12 + (chunk.content?.length ?? 0), + 0, + ); // RGBA chunk: 12 header + 256 * 4 = 12 + 1024 const rgbaChunkBytes = palette ? 12 + 1024 : 0; - const childrenSize = sizeChunkBytes + xyziChunkBytes + rgbaChunkBytes; + const childrenSize = sizeChunkBytes + xyziChunkBytes + extraChunkBytes + rgbaChunkBytes; // total = 8 (header) + 12 (MAIN) + childrenSize const totalBytes = 8 + 12 + childrenSize; @@ -85,6 +90,14 @@ function buildVoxBuffer( writeU8(v.colorIndex); } + for (const chunk of extraChunks) { + const content = chunk.content ?? []; + writeId(chunk.id); + writeU32(content.length); + writeU32(0); + for (const byte of content) writeU8(byte); + } + // RGBA chunk (optional) if (palette) { writeId("RGBA"); @@ -335,8 +348,25 @@ describe("parseVox — minimal synthetic buffer", () => { ); const result = parseVox(buf, { targetSize: 70, gridShift: 0 }); expect(result.voxelSource?.scale).toBe(0.88); - const xs = result.polygons.flatMap((p) => p.vertices.map((v) => v[0])); - expect(Math.max(...xs) - Math.min(...xs)).toBeCloseTo(70.4, 3); + const ys = result.polygons.flatMap((p) => p.vertices.map((v) => v[1])); + expect(Math.max(...ys) - Math.min(...ys)).toBeCloseTo(70.4, 3); + }); + + it("maps MagicaVoxel front (-Y) to PolyCSS +X", () => { + const buf = buildVoxBuffer( + [1, 2, 1], + [ + { x: 0, y: 0, z: 0, colorIndex: 1 }, + { x: 0, y: 1, z: 0, colorIndex: 37 }, + ], + ); + const result = parseVox(buf, { targetSize: 2, gridShift: 0 }); + expect(result.voxelSource?.cells).toEqual([ + { x: 1, y: 0, z: 0, color: "#ffffff" }, + { x: 0, y: 0, z: 0, color: "#ccffff" }, + ]); + expect(result.voxelSource?.rows).toBe(2); + expect(result.voxelSource?.cols).toBe(1); }); it("two adjacent voxels share one face — greedy-meshed to 6 polys", () => { @@ -660,6 +690,25 @@ describe("parseVox — empty and malformed input", () => { expect(result.warnings).toEqual([]); }); + it("warns when MagicaVoxel scene-graph chunks are flattened", () => { + const buf = buildVoxBuffer( + [1, 1, 1], + [{ x: 0, y: 0, z: 0, colorIndex: 1 }], + undefined, + [ + { id: "nTRN" }, + { id: "nSHP" }, + ], + ); + + const result = parseVox(buf); + + expect(result.polygons.length).toBe(6); + expect(result.warnings).toEqual([ + "Skipped MagicaVoxel scene graph transforms; models were flattened into one grid", + ]); + }); + it("dispose() is idempotent on empty result", () => { const result = parseVox(new ArrayBuffer(0)); expect(() => result.dispose()).not.toThrow(); diff --git a/packages/core/src/parser/parseVox.ts b/packages/core/src/parser/parseVox.ts index a9b4efdf..913b0fa3 100644 --- a/packages/core/src/parser/parseVox.ts +++ b/packages/core/src/parser/parseVox.ts @@ -7,7 +7,8 @@ * - SIZE: voxel grid dimensions (sx, sy, sz). * - XYZI: per-voxel (x, y, z, colorIndex) — colorIndex is 1-based. * Files may contain multiple SIZE/XYZI model chunks; coordinates are - * flattened into one occupancy grid, matching current voxcss behavior. + * flattened into one occupancy grid because scene-graph transforms are + * not represented in `ParseResult`. * - RGBA: 256×4 bytes custom palette (r, g, b, a). Falls back to the * built-in default palette when this chunk is absent. * @@ -17,10 +18,11 @@ * Winding follows CCW-from-outside convention, consistent with PolyCSS's * backface culling. * - * Coordinate system: MagicaVoxel is Z-up — same as PolyCSS — so no axis - * permutation is needed (unlike OBJ/glTF which are Y-up and need a cyclic - * swap). Voxel coordinates are always non-negative (origin at 0), so no - * shift is required by default. + * Coordinate system: MagicaVoxel is Z-up like PolyCSS, but authored front + * faces point toward source -Y. The parser rotates the horizontal plane so + * source -Y becomes PolyCSS +X and source +X becomes PolyCSS +Y. Voxel + * coordinates are always non-negative (origin at 0), so no shift is required + * by default. * * Output mesh is uniformly scaled near `targetSize` units along the longest * bbox axis, snapped to the nearest integer CSS cell so voxel renderers can @@ -363,6 +365,8 @@ function cleanupFacePlaneRegions( } const VOX_MAGIC = 0x20584f56; // "VOX " as little-endian uint32 +const VOX_SCENE_GRAPH_CHUNKS = new Set(["nTRN", "nGRP", "nSHP"]); +const VOX_SCENE_GRAPH_WARNING = "Skipped MagicaVoxel scene graph transforms; models were flattened into one grid"; // ── Face winding quads ─────────────────────────────────────────────────────── // CCW-from-outside winding for each of the 6 cube faces. @@ -436,8 +440,13 @@ export function parseVox(buffer: ArrayBuffer, options?: VoxParseOptions): ParseR const sizeChunks: SizeChunk[] = []; const xyziChunks: VoxelEntry[][] = []; + const warnings: string[] = []; let customPalette: string[] | null = null; + const warnOnce = (warning: string): void => { + if (!warnings.includes(warning)) warnings.push(warning); + }; + while (offset < mainChildrenEnd && offset + 12 <= buffer.byteLength) { const chunkId = readChunkId(dv, offset); const contentSize = dv.getUint32(offset + 4, true); @@ -482,6 +491,9 @@ export function parseVox(buffer: ArrayBuffer, options?: VoxParseOptions): ParseR } } } + if (VOX_SCENE_GRAPH_CHUNKS.has(chunkId)) { + warnOnce(VOX_SCENE_GRAPH_WARNING); + } // Skip PACK and any unknown chunks. offset = chunkEnd; @@ -489,7 +501,7 @@ export function parseVox(buffer: ArrayBuffer, options?: VoxParseOptions): ParseR // Flatten all XYZI chunks. Multi-model VOX files can carry several // SIZE/XYZI pairs; without scene-graph transform support the least - // surprising behavior is the same flattened occupancy grid voxcss uses. + // surprising behavior is one flattened occupancy grid. // If chunks overlap, keep the first voxel/color at that coordinate. const voxels: VoxelEntry[] = []; const occupied = new Set(); @@ -504,7 +516,7 @@ export function parseVox(buffer: ArrayBuffer, options?: VoxParseOptions): ParseR } if (voxels.length === 0) { - return makeEmptyResult(sourceBytes, []); + return makeEmptyResult(sourceBytes, warnings); } // 3. Build color lookup. @@ -671,7 +683,7 @@ export function parseVox(buffer: ArrayBuffer, options?: VoxParseOptions): ParseR } if (rawPolygons.length === 0) { - return makeEmptyResult(sourceBytes, []); + return makeEmptyResult(sourceBytes, warnings); } // 6. Compute bbox from raw voxel coords and scale. @@ -693,13 +705,13 @@ export function parseVox(buffer: ArrayBuffer, options?: VoxParseOptions): ParseR const voxelSource: PolyVoxelSource = { kind: "magica-vox", cells: voxels.map((v) => ({ - x: v.x - minX, - y: v.y - minY, + x: maxY - (v.y + 1), + y: v.x - minX, z: v.z - minZ, color: resolveColor(v.colorIndex), })), - rows: Math.max(0, maxX - minX), - cols: Math.max(0, maxY - minY), + rows: Math.max(0, maxY - minY), + cols: Math.max(0, maxX - minX), depth: Math.max(0, maxZ - minZ), scale, gridShift, @@ -708,8 +720,8 @@ export function parseVox(buffer: ArrayBuffer, options?: VoxParseOptions): ParseR const round = (n: number): number => Math.round(n * 1000) / 1000; const project = (v: Vec3): Vec3 => [ + round((maxY - v[1]) * scale + gridShift), round((v[0] - minX) * scale + gridShift), - round((v[1] - minY) * scale + gridShift), round((v[2] - minZ) * scale + gridShift), ]; @@ -723,7 +735,7 @@ export function parseVox(buffer: ArrayBuffer, options?: VoxParseOptions): ParseR voxelSource, objectUrls: [], dispose: () => { /* no-op: parseVox has no minted blob URLs */ }, - warnings: [], + warnings, metadata: { triangleCount: polygons.length, sourceBytes, diff --git a/packages/core/src/scene/context.ts b/packages/core/src/scene/context.ts index 401eae3d..6655a2ff 100644 --- a/packages/core/src/scene/context.ts +++ b/packages/core/src/scene/context.ts @@ -3,8 +3,8 @@ * (already normalized) and returns the data the framework wrappers need * to render. * - * No cube grid, no per-Z layer bucketing, no wall mask, no neighbor-based - * occlusion. Just a polygon list and a scene bbox. + * The renderer consumes a flat polygon list plus a scene bbox; higher-level + * culling or mesh optimization happens before this context is built. */ import type { Polygon, Vec3 } from "../types"; import { normalizePolygons, type NormalizeResult } from "./normalize"; diff --git a/packages/core/src/scene/polygonGeometry.ts b/packages/core/src/scene/polygonGeometry.ts index eea0b438..1dd47d2a 100644 --- a/packages/core/src/scene/polygonGeometry.ts +++ b/packages/core/src/scene/polygonGeometry.ts @@ -1,9 +1,7 @@ /** * Polygon geometry helpers — pure math operating on Polygon vertices. - * - * After cube removal in Phase 2, this module carries small polygon-level - * helpers for downstream consumers (lighting, debug metrics, etc.). The - * cube / ramp / wedge / spike face emitters lived here in voxcss; they're gone. + * These helpers feed lighting, diagnostics, and renderer metrics without + * depending on browser APIs. */ import type { Polygon, Vec2, Vec3 } from "../types"; @@ -48,8 +46,7 @@ const METRIC_EPS = 1e-9; /** * Surface a polygon as a single face. The returned array always has length 1; - * the indirection exists so callers that historically iterated faces (e.g. - * the manifold check, the canvas validator) can keep their loop shape. + * the indirection lets face-oriented consumers keep a common loop shape. * * Returns an empty array for degenerate polygons (< 3 vertices). */ diff --git a/packages/core/src/shadow/projection.ts b/packages/core/src/shadow/projection.ts index 1da5a6ad..f706f98d 100644 --- a/packages/core/src/shadow/projection.ts +++ b/packages/core/src/shadow/projection.ts @@ -1,13 +1,8 @@ -// Pure-math helpers for baked-mode shadow projection. +// Pure-math helpers for CPU shadow projection. // -// Dynamic mode keeps the shadow projection in CSS (--shadow-proj on the -// scene root, driven by --clx/--cly/--clz + --shadow-ground-cssz) so the -// browser recomputes it whenever the light moves. Baked mode skips that -// machinery entirely: the light is fixed, so the projection matrix can -// be CPU-computed once at scene build time and reused inline on every -// shadow leaf. No CSS vars, no @property dependency, no per-paint calc -// chain — and back-facing polygons are dropped from the DOM instead of -// being emitted with opacity 0. +// Current renderers use these helpers to project caster outlines into SVG +// shadow paths. The same matrix shape is still exposed for callers that need +// a CSS `matrix3d(...)` projection rather than projected 2D vertices. import type { Vec3 } from "../types"; /** Tiny non-zero scale collapsed into the projection's Z column to keep @@ -26,10 +21,9 @@ export const BAKED_SHADOW_MIN_UP = 0.01; /** * Build the CSS-space shadow projection matrix for a fixed light + ground - * plane. The 16-element output mirrors the matrix3d expression in the - * dynamic-mode `--shadow-proj` CSS custom property, but with literal - * numbers — ready to be formatted into a single `matrix3d(...)` per - * shadow leaf. + * plane. The 16-element output mirrors the retained `--shadow-proj` CSS + * custom property, but with literal numbers — ready to be formatted into a + * single `matrix3d(...)`. * * `lightDir` is the direction the light TRAVELS (e.g. `[0, 0, -1]` is * straight down). PolyCSS world Z is up, and the world→CSS axis swap @@ -151,11 +145,9 @@ export function ensureCcw2D( /** * Projects a single CSS-3D vertex onto the shadow ground plane, returning - * the resulting 2D point in CSS coordinates. Mirrors the per-element - * matrix3d that the dynamic-mode `--shadow-proj` builds, but evaluated on - * the CPU for a fixed light + ground — handy when many projected vertices - * are needed at once (e.g. rendering shadow outlines into a single SVG - * per mesh instead of one DOM leaf per casting polygon). + * the resulting 2D point in CSS coordinates. Mirrors the retained + * `--shadow-proj` matrix, but evaluated on the CPU for a fixed light + ground + * so many projected vertices can be merged into one SVG shadow path. * * `cssVertex` is a 3D point that has already been through the world→CSS * axis swap and unit scale (so its components are dimensionless CSS-space diff --git a/packages/polycss/src/api/createPolyScene.test.ts b/packages/polycss/src/api/createPolyScene.test.ts index 45169a97..b1deb67a 100644 --- a/packages/polycss/src/api/createPolyScene.test.ts +++ b/packages/polycss/src/api/createPolyScene.test.ts @@ -2204,7 +2204,7 @@ describe("createPolyScene", () => { expect(host.querySelectorAll(".polycss-shadow").length).toBe(1); }); - it("shadow leaves have the polycss-shadow class", () => { + it("shadow SVGs have the polycss-shadow class", () => { scene = makeScene(host, dynOpts); scene.add(makeParseResult([triangle()]), { castShadow: true }); const shadows = host.querySelectorAll(".polycss-shadow"); @@ -2244,7 +2244,7 @@ describe("createPolyScene", () => { expect(host.querySelectorAll(".polycss-shadow").length).toBe(1); }); - it("toggling castShadow via setTransform adds/removes shadow leaves", () => { + it("toggling castShadow via setTransform adds/removes shadow SVGs", () => { scene = makeScene(host, dynOpts); const handle = scene.add(makeParseResult([triangle()]), { castShadow: false }); expect(host.querySelectorAll(".polycss-shadow").length).toBe(0); @@ -2279,12 +2279,12 @@ describe("createPolyScene", () => { expect(dynamicShadow.style.transform).toMatch(/^translate3d\(/); }); - it("textured polygons (s) ALSO emit shadow leaves", () => { + it("textured polygons (s) ALSO emit shadow SVGs", () => { scene = makeScene(host, dynOpts); scene.add(makeParseResult([texturedTriangle()]), { castShadow: true }); // Shadows depend only on the polygon's outline, not its texture // content. Atlas () polygons cast shadows the same way as - // // — a flat projected onto the ground. Otherwise + // solid strategy tags: as part of the mesh's SVG silhouette. Otherwise // fully textured meshes (e.g. Frog Guy) get no shadow at all. expect(host.querySelectorAll(".polycss-shadow").length).toBe(1); }); diff --git a/packages/polycss/src/api/createPolyScene.ts b/packages/polycss/src/api/createPolyScene.ts index fcba80eb..f3e74d1f 100644 --- a/packages/polycss/src/api/createPolyScene.ts +++ b/packages/polycss/src/api/createPolyScene.ts @@ -2,15 +2,15 @@ * createPolyScene — imperative scene API. The vanilla counterpart to * `` in React / Vue. * - * Per §API freeze: takes a host element + scene options, returns a - * `PolySceneHandle` whose `add(parseResult, transform?)` mounts a mesh under - * the scene root and returns a removable `PolyMeshHandle`. + * Takes a host element + scene options and returns a `PolySceneHandle` whose + * `add(parseResult, transform?)` mounts a mesh under the scene root and returns + * a removable `PolyMeshHandle`. * * Implementation: * - Inserts a `
` into the host. - * - Each `add(...)` creates a `
` with the - * mesh transform; mounts every valid polygon as an atlas-backed - * background sprite. + * - Each `add(...)` creates a `
` with the mesh + * transform; mounts every valid polygon using the cheapest supported + * render-strategy leaf. * - `destroy()` removes the scene element and disposes every mesh * (which in turn disposes generated atlas blob URLs). * @@ -136,18 +136,16 @@ export interface PolySceneOptions { */ strategies?: PolyRenderStrategiesOption; /** - * When `true`, rotation pivots around the union bbox of all added meshes - * instead of world (0,0,0). The scene wraps polygons in an inner div - * translated by `-bboxCenter`. Updates whenever a mesh is added/removed - * or `setOptions` is called. Mirrors React's ``. + * When `true`, the camera target is offset by the union bbox center of all + * added meshes, so rotation pivots around that visible center instead of + * world (0,0,0). Updates whenever a mesh is added/removed or `setOptions` + * is called. Mirrors React's ``. */ autoCenter?: boolean; /** - * Shadow appearance for meshes with `castShadow: true`. Works in both - * lighting modes — dynamic mode projects via CSS vars so shadows - * follow a moving light, baked mode CPU-bakes the projection into - * each leaf's inline `matrix3d` and drops back-facing polys from the - * DOM entirely. Defaults: `{ color: "#000000", opacity: 0.25, lift: 0.05, maxExtend: 2000 }`. + * Shadow appearance for meshes with `castShadow: true`. Shadows are emitted + * as scene-level SVG paths in both lighting modes; moving a light reprojects + * the shadow geometry. Defaults: `{ color: "#000000", opacity: 0.25, lift: 0.05, maxExtend: 2000 }`. */ shadow?: { /** Shadow color as a CSS hex string. Default: `"#000000"`. */ @@ -633,8 +631,8 @@ export function createPolyScene( parseResult: ParseResult; rendered: RenderedPoly[]; renderedByPolygonIndex: Array; - /** Dynamic-mode shadow `` leaves, one per non-deduped casting - * polygon. Empty in baked mode (which uses `shadowSvg` instead). */ + /** Internal compatibility storage for retained per-polygon shadow leaves. + * Current shadow emission is scene-level SVG based. */ shadowRendered: HTMLElement[]; voxelRenderer?: PolyVoxelRenderer; disposeAtlas?: () => void; @@ -659,11 +657,10 @@ export function createPolyScene( } const meshes = new Set(); - // Cached CSS-Z of the shadow ground plane. Set by `recomputeShadowGround`. - // In dynamic mode this also flows into the `--shadow-ground-cssz` CSS var - // that drives `--shadow-proj`. In baked mode it's read by `emitShadowLeaves` - // to bake the per-leaf inline projection matrix on the CPU. `null` means - // no casting mesh exists yet, so no shadow leaves should be emitted. + // Cached CSS-Z of the shadow ground plane. Set by `recomputeShadowGround` + // and used by SVG shadow projection for the ground surface. In dynamic mode + // this is also mirrored into `--shadow-ground-cssz` for the retained internal + // `` shadow CSS path. `null` means no casting mesh exists yet. let currentGroundCssZ: number | null = null; // Scene-level shadow SVGs. One per surface (ground + each receiver @@ -959,8 +956,8 @@ export function createPolyScene( } function clearShadowLeaves(entry: MeshEntry): void { - // Per-entry `` leaves (dynamic-mode chain + legacy callers) still - // hang off the mesh and must be cleared individually. + // Current shadows are scene-level SVGs, but retained internal `` leaves + // can still be present during cleanup of already-mounted entries. for (const el of entry.shadowRendered) { if (el.parentNode) el.parentNode.removeChild(el); } @@ -1608,23 +1605,9 @@ export function createPolyScene( return true; } - // Emits the per-mesh shadow ``. Same path for both lighting modes: - // every casting polygon is projected to the ground on the CPU and - // concatenated into a single compound `` (M…L…Z subpaths) under - // fill-rule=nonzero. Overlapping outlines composite as one filled - // silhouette without alpha stacking; gaps between subpaths remain as - // gaps (silhouette holes are preserved); back-facing polys are dropped - // up front. One SVG element per mesh regardless of polygon count. - // - // Trade-off vs. the old dynamic-mode per-`` CSS path: live light - // updates now require a JS re-projection pass (`setOptions` triggers - // re-emit when directionalLight.direction changes) instead of being - // free CSS variable updates. The visual upside (no alpha stacking, - // preserved holes, fewer DOM nodes) is worth the JS cost for typical - // scenes — huge meshes during light-slider drag can profile if needed. - // Per-entry trigger: callers pass the entry that changed, but emission - // is scene-wide. Drop the arg here so any change rebuilds the whole - // shadow set in one shot — every surface aggregates every caster. + // Refreshes scene-level shadow SVGs for both lighting modes. Callers pass the + // entry that changed, but emission is scene-wide because every receiving + // surface aggregates every caster into one compound path. function emitShadowLeaves(_entry: MeshEntry): void { emitSceneShadows(); } @@ -2301,8 +2284,7 @@ export function createPolyScene( let minWorldZ = Infinity; // If any receivers exist, anchor the ground plane to the lowest // receiver bottom — that's the actual scene floor. Otherwise fall - // back to the lowest caster bottom (legacy behavior, used when no - // receiver mesh is registered). + // back to the lowest caster bottom when no receiver mesh is registered. let hasReceiver = false; for (const m of meshes) if (!m.disposed && m.receiveShadow) { hasReceiver = true; break; } for (const m of meshes) { diff --git a/packages/polycss/src/render/renderStats.test.ts b/packages/polycss/src/render/renderStats.test.ts index 08b91d1f..1401b18e 100644 --- a/packages/polycss/src/render/renderStats.test.ts +++ b/packages/polycss/src/render/renderStats.test.ts @@ -22,6 +22,7 @@ describe("collectPolyRenderStats", () => { +
`; @@ -29,7 +30,7 @@ describe("collectPolyRenderStats", () => { expect(collectPolyRenderStats(root, { polygonCount: 12 })).toEqual({ polygonCount: 12, mountedPolygonLeafCount: 7, - shadowLeafCount: 1, + shadowLeafCount: 2, surfaceLeafCounts: { quad: 2, clippedSolid: 1, atlas: 2, stableTriangle: 2 }, bucketCount: 1, }); @@ -38,14 +39,14 @@ describe("collectPolyRenderStats", () => { 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, + shadowLeafCount: 2, surfaceLeafCounts: { quad: 1, clippedSolid: 0, atlas: 1, stableTriangle: 0 }, bucketCount: 0, }); diff --git a/packages/polycss/src/render/renderStats.ts b/packages/polycss/src/render/renderStats.ts index 88311177..d994844a 100644 --- a/packages/polycss/src/render/renderStats.ts +++ b/packages/polycss/src/render/renderStats.ts @@ -86,7 +86,7 @@ export function collectPolyRenderStats( surfaceLeafCounts.clippedSolid += queryCount(scope, "i"); surfaceLeafCounts.atlas += queryCount(scope, "s"); surfaceLeafCounts.stableTriangle += queryCount(scope, "u"); - shadowLeafCount += queryCount(scope, "q"); + shadowLeafCount += queryCount(scope, "q, .polycss-shadow-svg"); bucketCount += queryCount(scope, ".polycss-bucket"); } diff --git a/packages/polycss/src/styles/styles.ts b/packages/polycss/src/styles/styles.ts index 11166ed8..2c8d8c0d 100644 --- a/packages/polycss/src/styles/styles.ts +++ b/packages/polycss/src/styles/styles.ts @@ -155,15 +155,9 @@ const CORE_BASE_STYLES = ` border-width: 0 16px 32px 16px; } -/* — dedicated shadow leaf. Same border-shape rendering trick as - (border-color: currentColor fills the polygon outline) but with its - own tag so we don't have to thread :not(.polycss-shadow) exclusions - through every dynamic-mode color rule. backface-visibility must be - visible because the projection matrix is near-rank-deficient and the - resulting plane's normal can read as back-facing under some camera - angles; the leaf is intentionally always painted. Strip the UA's - default ::before/::after open-/close-quote so the element is just a - styled box. */ +/* Reserved internal shadow element rules. Current shadow emission uses SVG + surfaces; these rules keep any retained markup styled as a plain + border-shape leaf instead of inheriting UA quote styling. */ .polycss-scene q { position: absolute; display: block; @@ -383,11 +377,12 @@ const CORE_BASE_STYLES = ` ); } -/* ── Cast shadow projection (dynamic-mode CSS path) ────────────────────── */ +/* ── Retained shadow projection (dynamic-mode CSS path) ─────────────── */ /* - * Shadow projection matrix. Projects any 3D point P onto the horizontal - * ground plane (cssZ ≈ G) along the CSS-space light direction (--clx/y/z). + * Projection matrix for retained internal shadow leaves. Projects any + * 3D point P onto the horizontal ground plane (cssZ ≈ G) along the CSS-space + * light direction (--clx/y/z). * * In PolyCSS's world convention world Z is up (red-green plane is the * floor in the axes helper). After the world→CSS swap (Y↔X), world Z stays @@ -400,7 +395,7 @@ const CORE_BASE_STYLES = ` * has a valid layout box. The fix: collapse along z by a near-zero * scale (Z_SQUASH = 0.01) instead of exactly zero — output.z is then * approximately G with ~1% drift from the input, full-rank and renderable. - * The shadow still looks flat to the eye (the drift is sub-pixel for + * The result still looks flat to the eye (the drift is sub-pixel for * any realistic scene size). * * out.cssX = P.cssX - (--clx/--clz) * (P.cssZ - G) @@ -428,18 +423,9 @@ const CORE_BASE_STYLES = ` ); } -/* shadow leaf — Lambert-gated opacity. Polygons facing the light cast - full shadow; polygons facing away cast zero shadow (their projection - would stack inside the silhouette and produce ugly overdraw). The - * 10 multiplier sharpens the cutoff so small positive Lambert values - jump quickly to 1, giving a near-binary visibility decision with a - smooth edge transition. Pure CSS calc — no JS at light-change time. - - Scoped to dynamic mode: baked-mode shadow leaves are dropped from the - DOM up-front by isBakedShadowCaster() and don't carry --pnx/--pny/--pnz, - so an unscoped gate would silently zero them via the @property - initial values. The base layout / positioning / pseudo-element-strip - rules for live in the polygon-leaf section above. */ +/* Retained opacity gate. Polygons facing the light cast full shadow; + polygons facing away cast zero shadow. The * 10 multiplier sharpens the + cutoff so small positive Lambert values jump quickly to 1. */ .polycss-scene[data-polycss-lighting="dynamic"] q { opacity: clamp(0, calc((var(--pnx) * var(--clx) + var(--pny) * var(--cly) + var(--pnz) * var(--clz)) * 10), 1); } diff --git a/packages/react/src/renderStats.ts b/packages/react/src/renderStats.ts index efb557ce..31f669c1 100644 --- a/packages/react/src/renderStats.ts +++ b/packages/react/src/renderStats.ts @@ -84,7 +84,7 @@ export function collectPolyRenderStats( surfaceLeafCounts.clippedSolid += queryCount(scope, "i"); surfaceLeafCounts.atlas += queryCount(scope, "s"); surfaceLeafCounts.stableTriangle += queryCount(scope, "u"); - shadowLeafCount += queryCount(scope, "q"); + shadowLeafCount += queryCount(scope, "q, .polycss-shadow-svg"); bucketCount += queryCount(scope, ".polycss-bucket"); } diff --git a/packages/react/src/scene/PolyGround.tsx b/packages/react/src/scene/PolyGround.tsx index 57c7c293..c4504357 100644 --- a/packages/react/src/scene/PolyGround.tsx +++ b/packages/react/src/scene/PolyGround.tsx @@ -1,8 +1,8 @@ /** - * `` — a flat ground-plane quad that shadow-casting meshes can - * render their `` shadows onto. Pure convenience over ``: - * generates a 4-vertex polygon in the world XY plane at `z` and renders it - * with `castShadow: false` (the floor doesn't cast onto itself). + * `` — a flat ground-plane quad for receiving SVG shadows from + * shadow-casting meshes. Pure convenience over ``: generates a + * 4-vertex polygon in the world XY plane at `z` and renders it with + * `castShadow: false` (the floor doesn't cast onto itself). * * * @@ -24,8 +24,8 @@ export interface PolyGroundProps { z?: number; /** World-space XY center. Default `[0, 0]`. */ center?: [number, number]; - /** Fill color. Default `#7d848e` — medium gray, chosen so the 25% black - * `` shadow leaves on top have visible contrast against it. */ + /** Fill color. Default `#7d848e` — medium gray, chosen so the default + * 25% black SVG shadow has visible contrast against it. */ color?: string; className?: string; } diff --git a/packages/react/src/scene/PolyMesh.castShadow.test.tsx b/packages/react/src/scene/PolyMesh.castShadow.test.tsx index 89a88725..e6d1d3ab 100644 --- a/packages/react/src/scene/PolyMesh.castShadow.test.tsx +++ b/packages/react/src/scene/PolyMesh.castShadow.test.tsx @@ -4,11 +4,10 @@ * * Covers: * - default (no castShadow) → no .polycss-shadow elements - * - castShadow + dynamic → 1 shadow per non-duplicate polygon - * - castShadow + baked → 0 shadows - * - shadow tag is - * - transform contains `var(--shadow-proj)` then `matrix3d` - * - --shadow-ground-cssz is set on the scene element when a casting mesh is added + * - castShadow + dynamic → per-mesh SVG shadow + * - castShadow + baked → per-mesh SVG shadow + * - shadow tag is + * - shadow transform is a translated SVG surface * - toggling castShadow reactively adds/removes shadows * - textured polygons ALSO cast shadows (Frog Guy regression) * - --clx/--cly/--clz are set on the scene element in dynamic mode @@ -163,7 +162,7 @@ describe("PolyMesh — castShadow", () => { } }); - it("toggling castShadow via prop updates adds/removes shadow leaves", () => { + it("toggling castShadow via prop updates adds/removes shadow SVGs", () => { const { container, root } = renderScene(DYN_SCENE_PROPS, { polygons: [TRIANGLE], castShadow: false, @@ -193,7 +192,7 @@ describe("PolyMesh — castShadow", () => { expect(after.style.transform).toMatch(/^translate3d\(/); }); - it("textured polygons (s) ALSO emit shadow leaves (Frog Guy regression)", async () => { + it("textured polygons (s) ALSO emit shadow SVGs (Frog Guy regression)", async () => { // Shadows depend only on the polygon outline, not the texture content. // Fully textured meshes must cast shadows or the Frog Guy gets no shadow. const { container } = renderScene(DYN_SCENE_PROPS, { diff --git a/packages/react/src/scene/PolyMesh.tsx b/packages/react/src/scene/PolyMesh.tsx index 3f9bd56e..7f5dab7a 100644 --- a/packages/react/src/scene/PolyMesh.tsx +++ b/packages/react/src/scene/PolyMesh.tsx @@ -1,12 +1,12 @@ /** * PolyMesh — load a mesh URL (or accept a polygons array) and render its * polygons inside a `.polycss-mesh` wrapper that carries the mesh-wide - * position/scale/rotation transform. Per §API freeze and §Design.4c. + * position/scale/rotation transform. * * Uses nested DOM (preserve-3d) so the wrapper transform composes with each * atlas polygon's vertex matrix3d via CSS without JS doing the matrix math. * - * Render-prop semantics (per §2a "Render-prop semantics"): + * Render-prop semantics: * - `children(polygon, index)` is called once per parsed polygon. * - Returned elements render INSIDE the .polycss-mesh wrapper, so they * inherit the mesh transform automatically. Don't re-apply position @@ -134,11 +134,10 @@ export interface PolyMeshProps extends TransformProps, InteractionProps { * when both are present. */ meshResolution?: MeshResolution; /** - * When `true` and the scene is in dynamic lighting mode, emits a flat - * shadow leaf (``) sibling for each polygon. - * The shadow is projected onto the ground plane along the CSS-space light - * direction via `--shadow-proj` (a CSS var on the scene root). Zero JS in - * the render loop — projection is pure `calc()`. Defaults to `false`. + * When `true`, emits a per-mesh SVG shadow path in both lighting modes. + * Each casting polygon projects onto the scene ground plane along the + * directional light; overlapping outlines are merged into one silhouette. + * Defaults to `false`. */ castShadow?: boolean; className?: string; diff --git a/packages/react/src/scene/PolyScene.tsx b/packages/react/src/scene/PolyScene.tsx index aebd4332..feac92a1 100644 --- a/packages/react/src/scene/PolyScene.tsx +++ b/packages/react/src/scene/PolyScene.tsx @@ -80,17 +80,16 @@ export interface PolySceneProps extends TransformProps { autoCenter?: boolean; /** * Shadow appearance for meshes with `castShadow={true}`. Works in both - * lighting modes — dynamic mode projects via CSS vars so shadows - * follow a moving light, baked mode CPU-bakes the projection into - * each leaf's inline `matrix3d` and drops back-facing polys from the - * DOM entirely. Defaults: `{ color: "#000000", opacity: 0.25, lift: 0.05, maxExtend: 2000 }`. + * lighting modes. Shadows emit as SVG paths and reproject when light, + * ground, or mesh geometry changes. Defaults: + * `{ color: "#000000", opacity: 0.25, lift: 0.05, maxExtend: 2000 }`. */ shadow?: ShadowOptions; className?: string; style?: CSSProperties; children?: ReactNode; - // Debug toggles. Cube-only `debugShowOccluded` was removed in Phase 4. + // Debug toggles retained for external tooling. debugShowLabels?: boolean; debugShowBackfaces?: boolean; } @@ -263,9 +262,9 @@ function PolySceneInner({ // // Also emits --clx/--cly/--clz: the light direction in CSS coordinate space // (matches the convention in vanilla's applyDynamicLightVars — NO axis swap - // relative to --plx/--ply/--plz). Used by the --shadow-proj matrix in - // styles.ts. --clz is clamped away from zero to avoid divide-by-zero in - // the projection when the light is near-horizontal. + // relative to --plx/--ply/--plz). Retained internal projection CSS uses + // these vars. --clz is clamped away from zero to avoid divide-by-zero when + // the light is near-horizontal. const dynamicLightVars = useMemo(() => { if (textureLighting !== "dynamic") return null; const dir = directionalLight?.direction ?? [0.4, -0.7, 0.59]; @@ -337,9 +336,9 @@ function PolySceneInner({ } }, [sceneElRef, textureLighting, recomputeGroundCssZ]); - // Re-sync the CSS var on lighting-mode swaps. Dynamic mode needs the var - // (the --shadow-proj calc reads it); baked mode strips it so a stale - // value can't accidentally drive --shadow-proj for legacy leaves. + // Re-sync the retained CSS shadow projection var on lighting-mode swaps. + // Current React shadows are SVG-based, but retained internal leaves still + // use --shadow-proj when present. useEffect(() => { const el = sceneElRef.current; if (!el) return; diff --git a/packages/react/src/scene/sceneContext.ts b/packages/react/src/scene/sceneContext.ts index a3f6a813..be1b1101 100644 --- a/packages/react/src/scene/sceneContext.ts +++ b/packages/react/src/scene/sceneContext.ts @@ -41,10 +41,10 @@ export interface PolySceneContextValue { registerShadowCaster?: (meshId: symbol, polygons: Polygon[] | null) => void; /** * Computed CSS-Z of the shadow ground plane (= min world Z across all - * casting meshes + scene.shadow.lift, in CSS pixels). Dynamic mode also - * mirrors this into the `--shadow-ground-cssz` CSS var. Baked-mode - * mesh code reads it directly to bake the inline `matrix3d(...)` on - * each shadow leaf. `null` means there are no caster meshes yet. + * casting meshes + scene.shadow.lift, in CSS pixels). Mesh shadow SVGs + * read it directly when projecting caster geometry. Dynamic mode also + * mirrors this into `--shadow-ground-cssz` for the retained internal + * `` shadow CSS path. `null` means there are no caster meshes yet. */ groundCssZ?: number | null; } diff --git a/packages/react/src/scene/useMesh.ts b/packages/react/src/scene/useMesh.ts index 325789b7..b14e9b19 100644 --- a/packages/react/src/scene/useMesh.ts +++ b/packages/react/src/scene/useMesh.ts @@ -1,6 +1,6 @@ /** * useMesh — fetch + parse a mesh URL into a polygon list, with race-safe - * blob-URL lifecycle. Per §API freeze and §Phase 4.3g. + * blob-URL lifecycle. * * Each `src` change: * 1. Aborts any in-flight fetch (no race — late responses are dropped). diff --git a/packages/react/src/scene/useSceneContext.ts b/packages/react/src/scene/useSceneContext.ts index d72a8ffb..05984412 100644 --- a/packages/react/src/scene/useSceneContext.ts +++ b/packages/react/src/scene/useSceneContext.ts @@ -20,7 +20,7 @@ export interface UseSceneContextResult { * normalizePolygons → mergePolygons by default → bbox compute. * * Returns the processed polygons + the scene-wide axis-aligned bbox. Memoized - * on input identity + the few options that affect output. Per §Design.6. + * on input identity + the few options that affect output. */ export function usePolySceneContext( polygons: Polygon[], diff --git a/packages/react/src/shapes/Poly.tsx b/packages/react/src/shapes/Poly.tsx index 50a63b9f..cd4d0a5a 100644 --- a/packages/react/src/shapes/Poly.tsx +++ b/packages/react/src/shapes/Poly.tsx @@ -121,11 +121,12 @@ function MaterialDirectPoly({ } /** - * Poly — renders one polygon as an atlas-backed DOM sprite. + * Poly — renders one polygon as a render-strategy DOM leaf. * * Public API: `{ vertices, color?, texture?, uvs?, data? }` plus DOM - * passthrough props. The atlas renderer handles both textured and solid-color - * faces, so `` never emits SVG in the normal render path. + * passthrough props. Solid faces use the cheapest supported solid strategy; + * textured faces use the atlas path. `` never emits SVG in the normal + * render path. * * Wrapped in React.memo so parent re-renders (e.g. camera rotation updating * rotY state) do not re-render stable polygon children. The shallow-equality diff --git a/packages/react/src/shapes/types.ts b/packages/react/src/shapes/types.ts index 96bc6804..d1021e74 100644 --- a/packages/react/src/shapes/types.ts +++ b/packages/react/src/shapes/types.ts @@ -16,12 +16,7 @@ import type { TextureQuality } from "../scene/atlas"; // ── TransformProps ────────────────────────────────────────────────────────── -/** - * Three.js-style transform props accepted by every polycss component. - * In Phase 3, position/scale/rotation are accepted but not yet applied — - * the rendered transform comes from vertices in scene-root space. - * Phase 4 wires these into the matrix3d composition with parent PolyMesh. - */ +/** Three.js-style transform props accepted by every PolyCSS component. */ export interface TransformProps { position?: Vec3; scale?: number | Vec3; @@ -32,7 +27,7 @@ export interface TransformProps { /** * DOM event handlers, ARIA, and style props forwarded to the rendered - * element (atlas-backed
) by every Poly component. + * element by every Poly component. * * This is the DOM-native pitch: polygons are real DOM nodes you can * target with CSS, attach event handlers to, and inspect in DevTools. @@ -75,7 +70,7 @@ export interface DOMPassthroughProps { * Props for the `` component — the atomic polygon primitive. * * Extends TransformProps + DOMPassthroughProps with the polygon's own fields. - * This is the canonical polycss v0.1.0 Poly component API per §API freeze. + * This is the canonical Poly component API. */ export interface PolyProps extends TransformProps, DOMPassthroughProps { // Polygon fields (from Polygon type) diff --git a/packages/react/src/styles/styles.ts b/packages/react/src/styles/styles.ts index 7c50b112..5557ab40 100644 --- a/packages/react/src/styles/styles.ts +++ b/packages/react/src/styles/styles.ts @@ -312,15 +312,9 @@ const CORE_BASE_STYLES = ` ); } -/* — dedicated shadow leaf. Same border-shape rendering trick as - (border-color: currentColor fills the polygon outline) but with its - own tag so we don't have to thread :not(.polycss-shadow) exclusions - through every dynamic-mode color rule. backface-visibility must be - visible because the projection matrix is near-rank-deficient and the - resulting plane's normal can read as back-facing under some camera - angles; the leaf is intentionally always painted. Strip the UA's - default ::before/::after open-/close-quote so the element is just a - styled box. */ +/* Reserved internal shadow element rules. Current shadow emission uses SVG + surfaces; these rules keep any retained markup styled as a plain + border-shape leaf instead of inheriting UA quote styling. */ .polycss-scene q { position: absolute; display: block; @@ -343,11 +337,12 @@ const CORE_BASE_STYLES = ` content: none; } -/* ── Cast shadow projection (dynamic-mode CSS path) ────────────────────── */ +/* ── Retained shadow projection (dynamic-mode CSS path) ─────────────── */ /* - * Shadow projection matrix. Projects any 3D point P onto the horizontal - * ground plane (cssZ ≈ G) along the CSS-space light direction (--clx/y/z). + * Projection matrix for retained internal shadow leaves. Projects any + * 3D point P onto the horizontal ground plane (cssZ ≈ G) along the CSS-space + * light direction (--clx/y/z). * * In PolyCSS's world convention world Z is up (red-green plane is the * floor in the axes helper). After the world→CSS swap (Y↔X), world Z stays @@ -360,7 +355,7 @@ const CORE_BASE_STYLES = ` * has a valid layout box. The fix: collapse along z by a near-zero * scale (Z_SQUASH = 0.01) instead of exactly zero — output.z is then * approximately G with ~1% drift from the input, full-rank and renderable. - * The shadow still looks flat to the eye (the drift is sub-pixel for + * The result still looks flat to the eye (the drift is sub-pixel for * any realistic scene size). * * out.cssX = P.cssX - (--clx/--clz) * (P.cssZ - G) @@ -388,17 +383,9 @@ const CORE_BASE_STYLES = ` ); } -/* shadow leaf — Lambert-gated opacity. Polygons facing the light cast - full shadow; polygons facing away cast zero shadow (their projection - would stack inside the silhouette and produce ugly overdraw). The - * 10 multiplier sharpens the cutoff so small positive Lambert values - jump quickly to 1, giving a near-binary visibility decision with a - smooth edge transition. Pure CSS calc — no JS at light-change time. - - Scoped to dynamic mode: baked-mode shadow leaves are dropped up-front - by isBakedShadowCaster() and don't carry --pnx/--pny/--pnz, so an - unscoped gate would silently zero them via the @property initial - values. */ +/* Retained opacity gate. Polygons facing the light cast full shadow; + polygons facing away cast zero shadow. The * 10 multiplier sharpens the + cutoff so small positive Lambert values jump quickly to 1. */ .polycss-scene[data-polycss-lighting="dynamic"] q { opacity: clamp(0, calc((var(--pnx) * var(--clx) + var(--pny) * var(--cly) + var(--pnz) * var(--clz)) * 10), 1); } diff --git a/packages/vue/src/renderStats.ts b/packages/vue/src/renderStats.ts index efb557ce..31f669c1 100644 --- a/packages/vue/src/renderStats.ts +++ b/packages/vue/src/renderStats.ts @@ -84,7 +84,7 @@ export function collectPolyRenderStats( surfaceLeafCounts.clippedSolid += queryCount(scope, "i"); surfaceLeafCounts.atlas += queryCount(scope, "s"); surfaceLeafCounts.stableTriangle += queryCount(scope, "u"); - shadowLeafCount += queryCount(scope, "q"); + shadowLeafCount += queryCount(scope, "q, .polycss-shadow-svg"); bucketCount += queryCount(scope, ".polycss-bucket"); } diff --git a/packages/vue/src/scene/PolyGround.ts b/packages/vue/src/scene/PolyGround.ts index 8820edff..7a1e6dfb 100644 --- a/packages/vue/src/scene/PolyGround.ts +++ b/packages/vue/src/scene/PolyGround.ts @@ -1,7 +1,7 @@ /** - * `` (Vue) — flat ground-plane quad that shadow-casting meshes - * render their `` shadows onto. Convenience over `` — generates - * a 4-vertex polygon in the world XY plane at `z` and renders it with + * `` (Vue) — flat ground-plane quad for receiving SVG shadows + * from shadow-casting meshes. Convenience over `` — generates a + * 4-vertex polygon in the world XY plane at `z` and renders it with * `castShadow: false` (the floor doesn't cast onto itself). Mirrors the React * `` API surface 1:1. * @@ -19,8 +19,8 @@ export interface PolyGroundProps { z?: number; /** World-space XY center. Default `[0, 0]`. */ center?: [number, number]; - /** Fill color. Default `#7d848e` — medium gray, chosen so 25% black `` - * shadow leaves on top have visible contrast against it. */ + /** Fill color. Default `#7d848e` — medium gray, chosen so the default + * 25% black SVG shadow has visible contrast against it. */ color?: string; class?: string; } diff --git a/packages/vue/src/scene/PolyMesh.castShadow.test.ts b/packages/vue/src/scene/PolyMesh.castShadow.test.ts index cfb76220..7c62bec2 100644 --- a/packages/vue/src/scene/PolyMesh.castShadow.test.ts +++ b/packages/vue/src/scene/PolyMesh.castShadow.test.ts @@ -3,11 +3,10 @@ * * Required cases: * - default → no .polycss-shadow elements - * - castShadow + dynamic → 1 shadow per non-duplicate polygon - * - castShadow + baked → 0 shadows - * - shadow tag is - * - transform contains var(--shadow-proj) then matrix3d - * - --shadow-ground-cssz is set on the scene element when a casting mesh is added + * - castShadow + dynamic → per-mesh SVG shadow + * - castShadow + baked → per-mesh SVG shadow + * - shadow tag is + * - shadow transform is a translated SVG surface * - toggling castShadow reactively adds/removes shadows * - textured polygons ALSO cast shadows */ @@ -166,7 +165,7 @@ describe("PolyMesh (Vue) — castShadow", () => { expect(sceneEl.style.getPropertyValue("--shadow-ground-cssz")).toBe(""); }); - it("toggling castShadow reactively adds and removes shadow leaves", async () => { + it("toggling castShadow reactively adds and removes shadow SVGs", async () => { const container = document.createElement("div"); document.body.appendChild(container); @@ -196,7 +195,7 @@ describe("PolyMesh (Vue) — castShadow", () => { expect(container.querySelectorAll(".polycss-shadow").length).toBe(0); }); - it("textured polygons (s) ALSO emit shadow leaves", async () => { + it("textured polygons (s) ALSO emit shadow SVGs", async () => { const { container } = mount(DYNAMIC_SCENE_PROPS, { polygons: [TEXTURED_TRIANGLE], castShadow: true, diff --git a/packages/vue/src/scene/PolyMesh.ts b/packages/vue/src/scene/PolyMesh.ts index 0bee8ee9..cd67f003 100644 --- a/packages/vue/src/scene/PolyMesh.ts +++ b/packages/vue/src/scene/PolyMesh.ts @@ -1,7 +1,7 @@ /** * PolyMesh — load a mesh URL (or accept a polygons array) and render its * polygons inside a `.polycss-mesh` wrapper that carries the mesh-wide - * position/scale/rotation transform. Per §API freeze and §Design.4c. + * position/scale/rotation transform. * * Uses nested DOM (preserve-3d) so the wrapper transform composes with each * atlas polygon's vertex matrix3d via CSS without JS doing the matrix math. @@ -13,8 +13,8 @@ * - Named slot `fallback`: rendered while loading. * - Named slot `error({ error })`: rendered on parse failure. * - * When no `polygon` slot is provided, atlas-backed polygon i elements are rendered - * automatically for each polygon. + * When no `polygon` slot is provided, each polygon is rendered automatically + * using the cheapest supported render-strategy leaf. */ import { defineComponent, h, computed, inject, onMounted, onBeforeUnmount, ref, watch, watchEffect } from "vue"; import type { PropType, VNode, CSSProperties } from "vue"; @@ -94,9 +94,17 @@ export interface PolyMeshProps extends InteractionProps { /** Solid seam overscan. `"auto"` computes a fitted per-edge amount from the polygon plan. */ seamBleed?: PolySeamBleed; /** - * When `true` and the scene is in dynamic lighting mode, the renderer emits - * a flat shadow leaf sibling for each non-duplicate polygon. The shadow is - * projected onto the ground plane along the CSS-space light direction. + * Hold the previous frame until the next atlas is decoded, then swap + * atomically. Best for discrete geometry edits where a partial texture + * frame would be visible. + */ + atomicAtlas?: boolean; + /** Fires when the displayed atlas frame swaps to a ready one in atomic mode. */ + onFrameReady?: () => void; + /** + * When `true`, emits a per-mesh SVG shadow path in both lighting modes. + * Each casting polygon projects onto the scene ground plane along the + * directional light; overlapping outlines are merged into one silhouette. * Defaults to `false`. */ castShadow?: boolean; diff --git a/packages/vue/src/scene/PolyScene.ts b/packages/vue/src/scene/PolyScene.ts index 7fe55bf3..0a1a279b 100644 --- a/packages/vue/src/scene/PolyScene.ts +++ b/packages/vue/src/scene/PolyScene.ts @@ -4,7 +4,7 @@ * * Renders a polycss-scene wrapper containing all polygons and children. * Transform (position/scale/rotation) compose with PolyCamera's camera - * transform via CSS preserve-3d nested DOM (§Design.4c). + * transform via CSS preserve-3d nested DOM. */ import { defineComponent, @@ -76,10 +76,9 @@ export interface PolySceneProps { autoCenter?: boolean; /** * Shadow appearance for meshes with `castShadow: true`. Works in both - * lighting modes — dynamic mode projects via CSS vars so shadows - * follow a moving light, baked mode CPU-bakes the projection into - * each leaf's inline `matrix3d` and drops back-facing polys from the - * DOM entirely. Defaults: `{ color: "#000000", opacity: 0.25, lift: 0.05, maxExtend: 2000 }`. + * lighting modes. Shadows emit as SVG paths and reproject when light, + * ground, or mesh geometry changes. Defaults: + * `{ color: "#000000", opacity: 0.25, lift: 0.05, maxExtend: 2000 }`. */ shadow?: PolyShadowOptions; class?: string; diff --git a/packages/vue/src/scene/sceneContext.ts b/packages/vue/src/scene/sceneContext.ts index 174866c7..4d570c96 100644 --- a/packages/vue/src/scene/sceneContext.ts +++ b/packages/vue/src/scene/sceneContext.ts @@ -48,10 +48,10 @@ export interface PolySceneContextValue { shadowRegistry?: PolyShadowRegistry; /** * Computed CSS-Z of the shadow ground plane (= min world Z across all - * casting meshes + scene.shadow.lift, in CSS pixels). Dynamic mode also - * mirrors this into `--shadow-ground-cssz`. Baked mode reads it here to - * bake the inline `matrix3d(...)` on each shadow leaf. `null` when no - * casting meshes are registered. + * casting meshes + scene.shadow.lift, in CSS pixels). Mesh shadow SVGs + * read it directly when projecting caster geometry. Dynamic mode also + * mirrors this into `--shadow-ground-cssz` for the retained internal + * `` shadow CSS path. `null` when no casting meshes are registered. */ groundCssZ?: number | null; } diff --git a/packages/vue/src/shapes/Poly.ts b/packages/vue/src/shapes/Poly.ts index c93e4c0d..aed5bc33 100644 --- a/packages/vue/src/shapes/Poly.ts +++ b/packages/vue/src/shapes/Poly.ts @@ -1,8 +1,9 @@ /** * Poly — Vue 3 equivalent of React's Poly component. * - * Renders one polygon as an atlas-backed DOM sprite. The atlas handles both - * textured and solid-color faces, so normal rendering never emits SVG. + * Renders one polygon as a render-strategy DOM leaf. Solid faces use the + * cheapest supported solid strategy; textured faces use the atlas path. + * Normal polygon rendering never emits SVG. */ import { computed, diff --git a/packages/vue/src/styles/styles.ts b/packages/vue/src/styles/styles.ts index 43c6e0c8..a21e0062 100644 --- a/packages/vue/src/styles/styles.ts +++ b/packages/vue/src/styles/styles.ts @@ -285,17 +285,11 @@ const CORE_BASE_STYLES = ` ); } -/* ── Cast shadow projection (dynamic-mode CSS path) ────────────────────── */ - -/* — dedicated shadow leaf. Same border-shape rendering trick as - (border-color: currentColor fills the polygon outline) but with its - own tag so we don't have to thread :not(.polycss-shadow) exclusions - through every dynamic-mode color rule. backface-visibility must be - visible because the projection matrix is near-rank-deficient and the - resulting plane's normal can read as back-facing under some camera - angles; the leaf is intentionally always painted. Strip the UA's - default ::before/::after open-/close-quote so the element is just a - styled box. */ +/* ── Retained shadow projection (dynamic-mode CSS path) ─────────────── */ + +/* Reserved internal shadow element rules. Current shadow emission uses SVG + surfaces; these rules keep any retained markup styled as a plain + border-shape leaf instead of inheriting UA quote styling. */ .polycss-scene q { position: absolute; display: block; @@ -344,8 +338,9 @@ const CORE_BASE_STYLES = ` transparent var(--ring-outer-r)); } -/* Shadow projection matrix. Projects any 3D point P onto the horizontal - ground plane (cssZ ≈ G) along the CSS-space light direction (--clx/y/z). +/* Projection matrix for retained internal shadow leaves. Projects any + 3D point P onto the horizontal ground plane (cssZ ≈ G) along the CSS-space + light direction (--clx/y/z). The strict projection would set m22=0 (output.z is a constant G, flat). Chromium SKIPS rendering elements whose composed transform is singular. @@ -367,12 +362,9 @@ const CORE_BASE_STYLES = ` ); } -/* shadow leaf — Lambert-gated opacity. Polygons facing the light cast - full shadow; polygons facing away cast zero shadow. The * 10 multiplier - sharpens the cutoff so small positive Lambert values jump quickly to 1, - giving a near-binary visibility decision with a smooth edge transition. - Scoped to dynamic mode: baked-mode shadow leaves are dropped up-front - by isBakedShadowCaster() and don't carry --pnx/--pny/--pnz. */ +/* Retained opacity gate. Polygons facing the light cast full shadow; + polygons facing away cast zero shadow. The * 10 multiplier sharpens the + cutoff so small positive Lambert values jump quickly to 1. */ .polycss-scene[data-polycss-lighting="dynamic"] q { opacity: clamp(0, calc((var(--pnx) * var(--clx) + var(--pny) * var(--cly) + var(--pnz) * var(--clz)) * 10), 1); } diff --git a/website/astro.config.mjs b/website/astro.config.mjs index bc22b0ad..1d012b41 100644 --- a/website/astro.config.mjs +++ b/website/astro.config.mjs @@ -103,6 +103,7 @@ export default defineConfig({ items: [ { label: 'Headless API', slug: 'api/headless' }, { label: 'Core Types', slug: 'api/types' }, + { label: 'Fonts API', slug: 'api/fonts' }, ], }, ], diff --git a/website/src/content/docs/api/fonts.mdx b/website/src/content/docs/api/fonts.mdx new file mode 100644 index 00000000..842b86c6 --- /dev/null +++ b/website/src/content/docs/api/fonts.mdx @@ -0,0 +1,200 @@ +--- +title: Fonts API +description: TypeScript API for turning TrueType fonts and text into PolyCSS polygon meshes. +--- + +`@layoutit/polycss-fonts` turns font outlines and text strings into plain `Polygon[]` meshes. The pure path (`parseFont`, `textPolygons`, `composeText`) has no browser globals; browser helpers handle Google font loading and canvas-backed fill textures. + +## `parseFont(data, defaultCurveSteps?)` + +Parses an uncompressed TrueType font (`.ttf`, `glyf` outlines) into a `ParsedFont`. + +```ts +import { parseFont } from "@layoutit/polycss-fonts"; + +const bytes = await fetch("/fonts/display.ttf").then((r) => r.arrayBuffer()); +const font = parseFont(bytes); +``` + +Unsupported formats throw clear errors: CFF/OpenType (`.otf`) and `woff` / `woff2` wrappers are not unpacked. + +```ts +interface FontGlyph { + contours: Vec2[][]; + advanceWidth: number; +} + +interface ParsedFont { + unitsPerEm: number; + ascender: number; + descender: number; + lineGap: number; + glyph(codePoint: number, curveSteps?: number): FontGlyph; +} +``` + +## `textPolygons(font, text, options?)` + +Extrudes a single line of text into a PolyCSS mesh. + +```ts +import { textPolygons } from "@layoutit/polycss-fonts"; + +const polygons = textPolygons(font, "PolyCSS", { + size: 100, + depth: 24, + profile: "bevel", + color: "#ffd166", + sideColor: "#b7791f", +}); +``` + +```ts +interface TextPolygonsOptions { + size?: number; + depth?: number; + curveSteps?: number; + letterSpacing?: number; + color?: string; + sideColor?: string; + profile?: "flat" | "round" | "bevel" | "custom"; + profileSegments?: number; +} +``` + +## `composeText(font, text, options?)` + +Composes styled, multi-line WordArt-style text. It adds alignment, line height, underline/strike bars, envelope warps, custom profiles, face materials, and optional outlines. + +```ts +import { composeText, resolveFace } from "@layoutit/polycss-fonts"; + +const polygons = composeText(font, "Poly\nCSS", { + size: 90, + depth: 28, + align: "center", + lineHeight: 1.1, + warp: { shape: "arch", amount: 0.4 }, + profile: { edge: "round", raised: true }, + faces: { + front: resolveFace({ kind: "gradient", from: "#fef08a", to: "#f97316" }), + sides: { color: "#92400e" }, + back: { color: "#451a03", offset: [8, -8] }, + }, + outline: { color: "#111827", width: 4 }, +}); +``` + +```ts +type WarpShape = + | "none" + | "arch" + | "archDown" + | "arc" + | "wave" + | "bulge" + | "cone" + | "slantUp" + | "slantDown"; + +interface WarpOptions { + shape: WarpShape; + amount?: number; +} + +interface Face { + color?: string; + texture?: string; + tile?: number; +} + +interface BackFace extends Face { + offset?: [number, number]; +} + +interface FaceStop extends Face { + at: number; +} + +type Profile = + | "flat" + | { edge: "bevel" | "round"; raised?: boolean; segments?: number } + | { curve: CubicBezier; segments?: number }; +``` + +## Fills + +`composeText` stays pure by accepting already-resolved face textures. Browser helpers convert higher-level fill specs into `Face` objects. + +```ts +import { makeFillTexture, resolveFace } from "@layoutit/polycss-fonts"; + +const gradientUrl = makeFillTexture({ + type: "gradient", + from: "#22d3ee", + to: "#2563eb", + angle: 90, +}); + +const face = resolveFace({ + kind: "texture", + color: "#ffffff", + url: "/textures/bricks.svg", + tile: 24, +}); +``` + +```ts +type FillSpec = + | { type: "solid" } + | { type: "gradient"; from: string; to: string; angle?: number } + | { type: "rainbow"; angle?: number } + | { type: "image"; src: string }; + +type FaceFillSpec = + | { kind: "solid"; color: string } + | { kind: "gradient"; color?: string; from: string; to: string; angle?: number } + | { kind: "rainbow"; color?: string; angle?: number } + | { kind: "texture"; color?: string; url: string; tile?: number } + | { kind: "image"; color?: string; src: string }; +``` + +## Google Font Helpers + +These browser helpers use the Fontsource API/CDN to fetch plain `.ttf` files with open CORS. + +```ts +import { + listGoogleFonts, + pickWeight, + googleFontUrl, + loadFont, + loadGoogleFont, +} from "@layoutit/polycss-fonts"; + +const fonts = await listGoogleFonts(); +const entry = fonts.find((font) => font.family === "Bungee")!; +const weight = pickWeight(entry, 700); +const url = googleFontUrl(entry, weight); +const font = await loadGoogleFont(entry, weight); +const sameFont = await loadFont(url); +``` + +```ts +interface FontEntry { + id: string; + family: string; + weights: number[]; + styles: string[]; + subsets: string[]; + defSubset: string; + category: string; + type: string; +} + +type FontStyle = "normal" | "italic"; +``` + +## Utility Exports + +`cssCubicBezier([x1, y1, x2, y2])` returns an easing function for custom edge profiles. Related exported types are `CubicBezier`, `ExtrudeProfile`, `MaterialStop`, `ComposeTextOptions`, `Profile`, `Face`, `BackFace`, `FaceStop`, `WarpShape`, `WarpOptions`, `FillSpec`, `FaceFillSpec`, `ParsedFont`, `FontGlyph`, `TextPolygonsOptions`, `FontEntry`, and `FontStyle`. diff --git a/website/src/content/docs/api/headless.mdx b/website/src/content/docs/api/headless.mdx index 5685baa7..af0a3cf2 100644 --- a/website/src/content/docs/api/headless.mdx +++ b/website/src/content/docs/api/headless.mdx @@ -49,7 +49,7 @@ dispose(); // always call on cleanup ## `parseVox(buffer, options?)` -Parses a MagicaVoxel `.vox` file into greedy colored polygon quads. The result also carries `voxelSource` metadata used by the baked-mode voxel fast path across vanilla, React, and Vue when the mesh remains eligible. +Parses a MagicaVoxel `.vox` file into greedy colored polygon quads. The result also carries `voxelSource` metadata used by the baked-mode voxel fast path across vanilla, React, and Vue when the mesh remains eligible. VOX imports stay Z-up and rotate MagicaVoxel front (`-Y`) to PolyCSS forward (`+X`). ```ts import { parseVox } from "@layoutit/polycss-core"; @@ -143,6 +143,20 @@ console.log(`${polygons.length} polygons → ${merged.length} after merge`); --- +## Polygon, Color, and Geometry Helpers + +For custom tooling, core also exports helpers for polygon faces, texture paint bounds, CSS color parsing/formatting, and mesh-transform Euler rotation. + +```ts +import { polygonFaces, parseHexColor, rotateVec3 } from "@layoutit/polycss-core"; + +const faces = polygonFaces(polygons); +const color = parseHexColor("#ffcc00"); +const rotated = rotateVec3([1, 0, 0], [0, 45, 0]); +``` + +--- + ## `optimizeMeshPolygons(polygons, options?)` Runs the shared mesh-resolution optimizer. It defaults to `meshResolution: "lossy"`. `meshResolution: "lossless"` uses exact candidates; `"lossy"` also tries bounded approximate merge candidates and chooses the lowest estimated DOM render cost. Wider lossy candidates are accepted only when they clear a minimum render-cost win and do not worsen whole-mesh seam diagnostics. @@ -175,6 +189,59 @@ const cam = createIsometricCamera({ --- +## `createPolyCamera(options?)` + +Creates the vanilla camera handle used by `createPolyScene`. `createPolyCamera()` is the ergonomic orthographic default and is equivalent to `createPolyOrthographicCamera()`. Use `createPolyPerspectiveCamera()` when the scene needs CSS perspective, first-person controls, or stronger depth foreshortening. + +```ts +import { + createPolyCamera, + createPolyOrthographicCamera, + createPolyPerspectiveCamera, +} from "@layoutit/polycss"; + +const camera = createPolyCamera({ rotX: 65, rotY: 45, zoom: 0.8 }); +const ortho = createPolyOrthographicCamera({ target: [0, 0, 0] }); +const perspective = createPolyPerspectiveCamera({ perspective: 1200, distance: 300 }); +``` + +All camera handles expose `state`, `update(partial)`, and `getStyle()`. The React/Vue equivalents are `` / `` and ``. + +--- + +## `createPolyScene(host, options)` + +Creates a vanilla imperative scene inside `host`. It injects the PolyCSS base styles, creates a `.polycss-camera` wrapper and `.polycss-scene` root, and returns a `PolySceneHandle` for adding meshes, updating scene-level options, and tearing down the scene. + +```ts +import { createPolyCamera, createPolyScene, loadMesh } from "@layoutit/polycss"; + +const camera = createPolyCamera({ rotX: 65, rotY: 45 }); +const scene = createPolyScene(host, { + camera, + textureLighting: "dynamic", + directionalLight: { direction: [0.4, -0.6, 1], intensity: 1 }, + shadow: { opacity: 0.28, maxExtend: 2000 }, +}); + +const result = await loadMesh("/model.glb"); +const mesh = scene.add(result, { + id: "asset", + position: [0, 0, 0], + castShadow: true, + meshResolution: "lossy", +}); + +mesh.setTransform({ rotation: [0, 30, 0] }); +mesh.rebakeAtlas(); +scene.setOptions({ textureLighting: "baked" }); +scene.destroy(); +``` + +`scene.add(mesh, opts?)` accepts a `ParseResult` from the parsers, `loadMesh`, or a primitive shape factory. See [`PolySceneOptions`, `PolyMeshTransform`, `PolySceneHandle`, and `PolyMeshHandle`](/api/types/#vanilla-scene-handle-types) for the exact handle and option shapes. + +--- + ## `createPolyOrbitControls(scene, options?)` Attach pointer drag, wheel zoom, and an optional autorotate loop to a scene returned by `createPolyScene`. Pure additive layer: the renderer stays free of input concerns. Modelled on Three.js `OrbitControls`. @@ -318,7 +385,7 @@ transform.detach(); ## `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. +Reads an already-rendered PolyCSS DOM subtree and returns a one-shot diagnostic snapshot. It counts mounted polygon leaves, shadow nodes, surface leaf categories, and bucket wrappers; it does not observe changes or mutate the scene. ```ts import { collectPolyRenderStats } from "@layoutit/polycss"; @@ -336,6 +403,56 @@ console.log(stats.mountedPolygonLeafCount, stats.surfaceLeafCounts); --- +## `injectPolyBaseStyles(doc?)` + +Injects the shared PolyCSS base stylesheet into a `Document` once. Scenes and framework components call this automatically; use it directly when rendering PolyCSS DOM yourself or when preparing a custom document before mounting. + +```ts +import { injectPolyBaseStyles } from "@layoutit/polycss"; + +injectPolyBaseStyles(document); +``` + +The same helper is exported from `@layoutit/polycss-react` and `@layoutit/polycss-vue`. + +--- + +## Low-level renderer utilities + +The vanilla package also exposes renderer and atlas building blocks for diagnostics, custom renderers, and tests. Most applications should use `createPolyScene`, framework components, or custom elements. + +
+Advanced renderer exports + +- Atlas planning/rendering: `computeTextureAtlasPlanPublic`, `buildAtlasPages`, `renderPolygonsWithTextureAtlas`, `renderPolygonsWithTextureAtlasAsync`, `filterAtlasPlans`, `packTextureAtlasPlansWithScale`, `buildTextureEdgeRepairSets`. +- Stable DOM animation: `renderPolygonsWithStableTriangles`, `updatePolygonsWithStableTopology`, `updateStableTriangleFrame`. +- Strategy and CSS helpers: `getSolidPaintDefaults`, `getSolidPaintDefaultsFromPlans`, `isBorderShapeSupported`, `isSolidTriangleSupported`, `isFullRectSolid`, `isSolidTrianglePlan`, `isProjectiveQuadPlan`, `cssBorderShapeForPlan`, `formatMatrix3d`, `formatCssLengthPx`, `formatSolidQuadEntryMatrix`, `formatBorderShapeEntryMatrix`. +- Related types: `TextureAtlasPlan`, `PackedTextureAtlasEntry`, `PackedAtlas`, `PackedPage`, `TextureAtlasPage`, `SolidPaintDefaults`, `SolidTriangleFrame`, `PolygonBasisInfo`. + +
+ +--- + +## Primitive shape factories + +The vanilla package exports ParseResult-compatible factories for built-in shapes. Each one wraps the matching core polygon generator and can be passed directly to `scene.add(...)`. + +```ts +import { createPolyCamera, createPolyScene, createPolyBox, createPolyTorus } from "@layoutit/polycss"; + +const camera = createPolyCamera(); +const scene = createPolyScene(host, { camera }); + +scene.add(createPolyBox({ size: 80, color: "#ffd166" })); +scene.add(createPolyTorus({ radius: 1.2, tube: 0.35, color: "#4ecdc4" }), { + position: [100, 0, 0], +}); +``` + +Available factories: `createPolyBox`, `createPolyPlane`, `createPolyRing`, `createPolyOctahedron`, `createPolySphere`, `createPolyTetrahedron`, `createPolyIcosahedron`, `createPolyDodecahedron`, `createPolyCylinder`, `createPolyCone`, and `createPolyTorus`. + +--- + ## Custom elements (vanilla) Register the custom elements by importing the side-effect entry point: @@ -361,6 +478,15 @@ Registered element families include: - Helpers: ``, ``. - Shapes: ``, ``, ``, ``, ``, ``, ``, ``, ``, ``, ``. +The root package exports the matching element classes for manual registration or subclassing. + +
+Custom element class exports + +`PolySceneElement`, `PolyMeshElement`, `PolyPolygonElement`, `PolyCameraElement`, `PolyOrthographicCameraElement`, `PolyPerspectiveCameraElement`, `PolyOrbitControlsElement`, `PolyMapControlsElement`, `PolyFirstPersonControlsElement`, `PolyTransformControlsElement`, `PolySelectElement`, `PolyAxesHelperElement`, `PolyDirectionalLightHelperElement`, `PolyBoxElement`, `PolyPlaneElement`, `PolyRingElement`, `PolyOctahedronElement`, `PolySphereElement`, `PolyTetrahedronElement`, `PolyIcosahedronElement`, `PolyDodecahedronElement`, `PolyCylinderElement`, `PolyConeElement`, and `PolyTorusElement`. + +
+ --- ## Package Exports @@ -372,3 +498,4 @@ Registered element families include: | `@layoutit/polycss` | Vanilla imperative API + custom element classes + controls + selection + 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 | +| `@layoutit/polycss-fonts` | Font parsing, Google font loading, and text-to-polygon mesh generation | diff --git a/website/src/content/docs/api/types.mdx b/website/src/content/docs/api/types.mdx index 7b7366b5..f999657f 100644 --- a/website/src/content/docs/api/types.mdx +++ b/website/src/content/docs/api/types.mdx @@ -18,11 +18,19 @@ interface Polygon { /** Image URL for UV-mapped rendering. When set with `uvs`, the renderer * applies an affine UV transform. Without `uvs`, single-tile fill. */ texture?: string; + /** Imported texture wrap mode for UVs outside [0, 1]. */ + textureWrap?: PolyTextureWrap; + /** Imported glTF texture alpha interpretation. */ + textureAlphaMode?: PolyTextureAlphaMode; /** Shared material. `material.texture` takes precedence over `texture`. */ material?: PolyMaterial; /** UV coordinates: one per vertex. Must match vertices.length. * Mismatched arrays are stripped during normalizePolygons(). */ uvs?: [number, number][]; + /** Source UV triangles preserved by import/merge passes for atlas rasterization. */ + textureTriangles?: TextureTriangle[]; + /** Source material requested two-sided rendering. */ + doubleSided?: boolean; /** User-controlled metadata. Reflected to the DOM as data-* attributes * when rendering via . Keys must have string, number, or boolean values. */ data?: Record; @@ -46,6 +54,28 @@ interface PolyMaterial { --- +## Texture Metadata + +Importer-preserved texture fields used by atlas planning and rendering. + +```ts +interface TextureTriangle { + vertices: [Vec3, Vec3, Vec3]; + uvs: [Vec2, Vec2, Vec2]; +} + +type PolyTextureWrapMode = "repeat" | "clamp-to-edge" | "mirrored-repeat"; + +interface PolyTextureWrap { + s: PolyTextureWrapMode; + t: PolyTextureWrapMode; +} + +type PolyTextureAlphaMode = "opaque" | "mask" | "blend"; +``` + +--- + ## `PolyDirectionalLight` Controls the directional light for the scene. @@ -88,6 +118,37 @@ type PolyTextureLightingMode = "baked" | "dynamic"; --- +## `MeshResolution` + +Controls mesh post-processing intent. + +```ts +type MeshResolution = "lossless" | "lossy"; +``` + +`"lossless"` preserves the authored surface while applying exact reductions. `"lossy"` allows bounded geometric approximation when it reduces rendered polygon/DOM count. + +--- + +## `TextureQuality` and `PolySeamBleed` + +Renderer quality controls accepted by scene, mesh, and atlas APIs. + +```ts +type TextureQuality = number | "auto"; +type PolySeamBleed = number | "auto"; + +type PolySeamBleedEdgeValue = ReadonlySet | ReadonlyMap; + +type PolySeamBleedEdges = + | ReadonlyMap + | readonly (PolySeamBleedEdgeValue | undefined)[]; +``` + +`TextureQuality` controls atlas bitmap budget and CSS sprite size. `PolySeamBleed` controls solid-primitive overscan for detected shared seam edges. + +--- + ## `PolyRenderStrategiesOption` Diagnostic render-strategy override accepted by scenes and atlas renderers. Disabled strategies fall through to the atlas path; `` is the universal fallback and cannot be disabled. @@ -119,7 +180,7 @@ interface PolyRenderStats { polygonCount: number; /** Mounted surface leaves, equal to the sum of surfaceLeafCounts. */ mountedPolygonLeafCount: number; - /** Mounted cast-shadow leaves. */ + /** Mounted shadow nodes: SVG shadow surfaces plus retained compatibility shadow nodes. */ shadowLeafCount: number; /** Surface leaf categories used by the renderer. */ surfaceLeafCounts: PolyRenderSurfaceLeafCounts; @@ -145,6 +206,96 @@ interface PolyRenderStatsOptions { --- +## Vanilla Scene Handle Types + +Core imperative handles returned by `createPolyScene()` and `scene.add()`. + +```ts +interface PolySceneOptions { + camera: PolyPerspectiveCameraHandle | PolyOrthographicCameraHandle; + directionalLight?: PolyDirectionalLight; + ambientLight?: PolyAmbientLight; + textureLighting?: PolyTextureLightingMode; + textureQuality?: TextureQuality; + seamBleed?: PolySeamBleed; + strategies?: PolyRenderStrategiesOption; + autoCenter?: boolean; + shadow?: { + color?: string; + opacity?: number; + lift?: number; + maxExtend?: number; + }; +} + +interface PolyMeshTransform { + id?: string; + position?: Vec3; + scale?: number | Vec3; + rotation?: Vec3; + merge?: boolean; + meshResolution?: MeshResolution; + stableDom?: boolean; + excludeFromAutoCenter?: boolean; + castShadow?: boolean; + receiveShadow?: boolean; +} + +interface PolySceneHandle { + add(mesh: ParseResult, opts?: PolyMeshTransform): PolyMeshHandle; + setOptions(partial: Partial>): void; + getOptions(): Readonly>; + applyCamera(): void; + meshes(): readonly PolyMeshHandle[]; + findMeshByElement(element: Element | null): PolyMeshHandle | null; + destroy(): void; + readonly host: HTMLElement; + readonly cameraEl: HTMLElement; + readonly camera: PolyPerspectiveCameraHandle | PolyOrthographicCameraHandle; +} + +interface PolyMeshHandle { + readonly element: HTMLElement; + readonly id?: string; + readonly transform: PolyMeshTransform; + polygons: Polygon[]; + setTransform(transform: Partial): void; + setPolygons(polygons: Polygon[], options?: { + merge?: boolean; + stableDom?: boolean; + recomputeAutoCenter?: boolean; + }): void; + updatePolygon(target: Polygon | number, partial: Partial): void; + rebakeAtlas(): void; + getPosition(): Vec3 | undefined; + getRotation(): Vec3 | undefined; + getScale(): number | Vec3 | undefined; + getPolygons(): Polygon[]; + remove(): void; + dispose(): void; +} +``` + +--- + +## Framework Type Exports + +React and Vue export their public prop, event, context, and handle types next to the runtime components/composables. Vue mirrors the React names where applicable, with Vue-specific context/composable names such as `PolyCameraContextKey`, `PolySelectionContextKey`, `PolyContext`, and `UsePolyAnimationResultVue`. + +Runtime selection helpers are exported from the framework packages: `findPolyMeshHandle`, `pointInMeshElement`, and `findMeshUnderPoint`. + +
+Framework type inventory + +- Component props: `PolyCameraProps`, `PolyOrthographicCameraProps`, `PolyPerspectiveCameraProps`, `PolySceneProps`, `PolyMeshProps`, `PolyGroundProps`, `PolyProps`, `PolyBoxProps`, `PolyPlaneProps`, `PolyRingProps`, `PolyOctahedronProps`, `PolySphereProps`, `PolyTetrahedronProps`, `PolyIcosahedronProps`, `PolyDodecahedronProps`, `PolyCylinderProps`, `PolyConeProps`, `PolyTorusProps`, `PolyAxesHelperProps`, `PolyDirectionalLightHelperProps`. +- Control and selection props/handles: `PolyOrbitControlsProps`, `PolyMapControlsProps`, `PolyFirstPersonControlsProps`, `PolyTransformControlsProps`, `PolySelectProps`, `PolyControlsAnimateOptions`, `PolyControlsBaseOptions`, `PolyControlsCamera`, `PolyControlsHandle`, `PolyControlsChangeEvent`, `PolyControlsInteractionEvent`, `PolyControlsEvent`, `PolyControlsListener`, `PolyOrbitControlsCamera`, `PolyMapControlsCamera`, `PolyFirstPersonControlsOptions`, `PolyFirstPersonControlsHandle`, `PolyOrbitControlsOptions`, `PolyOrbitControlsHandle`, `PolyMapControlsOptions`, `PolyMapControlsHandle`, `PolyTransformControlsOptions`, `PolyTransformControlsHandle`, `PolySelectOptions`, `PolySelectionHandle`. +- Hooks, events, and contexts: `UseCameraOptions`, `UseCameraResult`, `UseSceneContextOptions`, `UseSceneContextResult`, `UseMeshOptions`, `UseMeshResult`, `UsePolyAnimationResult`, `PolyPointerEvent`, `PolyMouseEvent`, `PolyWheelEvent`, `PolyEventHandler`, `InteractionProps`, `PolySelectionApi`, `PolyCameraContext`, `PolyCameraContextValue`. +- Utility types: `PolyTransformControlsObject`, `PolyTransformControlsObjectChangeEvent`, `PolySceneSnapshotErrorCode`, `PolyShapeResult`, `TransformProps`, `DOMPassthroughProps`. + +
+ +--- + ## `Vec2` A 2D point or UV coordinate. @@ -253,6 +404,8 @@ interface PolyAnimationTarget { setPolygons(polygons: Polygon[]): void; } +type PolyAnimationClip = ParseAnimationClip; + interface PolyAnimationAction { play(): PolyAnimationAction; stop(): PolyAnimationAction; @@ -458,3 +611,48 @@ interface CameraState { distance: number; // dolly pull-back in pixels (default 0); adds translateZ(-distance)px } ``` + +## Camera Handles + +Vanilla camera handles returned by `createPolyCamera`, `createPolyOrthographicCamera`, and `createPolyPerspectiveCamera`. + +```ts +interface PolyCameraOptions { + zoom?: number; + target?: Vec3; + rotX?: number; + rotY?: number; + distance?: number; +} + +interface PolyPerspectiveCameraOptions extends PolyCameraOptions { + perspective?: number; +} + +interface PolyOrthographicCameraOptions extends PolyCameraOptions {} + +interface CameraStyleInput { + rows?: number; + cols?: number; +} + +interface CameraHandle { + readonly state: CameraState; + update(next: Partial): void; + getStyle(input?: CameraStyleInput): { + transform: string; + width: string; + height: string; + }; +} + +interface PolyPerspectiveCameraHandle extends CameraHandle { + readonly type: "perspective"; + readonly perspectiveStyle: string; +} + +interface PolyOrthographicCameraHandle extends CameraHandle { + readonly type: "orthographic"; + readonly perspectiveStyle: "none"; +} +``` diff --git a/website/src/content/docs/components/poly-scene.mdx b/website/src/content/docs/components/poly-scene.mdx index 2998738d..c787312a 100644 --- a/website/src/content/docs/components/poly-scene.mdx +++ b/website/src/content/docs/components/poly-scene.mdx @@ -13,6 +13,8 @@ It's available as a custom element (``), via the imperative `createP (React / Vue prop names use camelCase; the `` custom element accepts the kebab-case form, e.g. `textureQuality` → `texture-quality`.) +The React/Vue components and `createPolyScene()` support the full table. The `` custom element supports `directional-*`, `ambient-*`, `texture-lighting`, `texture-quality`, `auto-center`, and implicit camera attributes; use the imperative API for options such as `shadow`, `seamBleed`, and `strategies` in vanilla. + | Prop | Type | Default | Description | |------|------|---------|-------------| | `directionalLight` | `PolyDirectionalLight` | None | Directional light source. | @@ -23,7 +25,7 @@ It's available as a custom element (``), via the imperative `createP | `strategies` | `{ disable?: ("b" \| "i" \| "u")[] }` | None | Diagnostic override for render strategy selection. Disabled solid strategies fall through to `` atlas slices; `` cannot be disabled. | | `autoCenter` | `boolean` | `false` | Rotate around the content bbox center instead of world origin. Polygon data is not mutated. | | `centerPolygons` | `Polygon[]` | None | (Framework only.) Bbox source for `autoCenter` when renderable polygons live inside child meshes. | -| `shadow` | `{ color?, opacity?, lift? }` | `{ color:"#000000", opacity:0.25, lift:0.05 }` | Appearance for dynamic-lighting cast shadows emitted by meshes with `castShadow`. | +| `shadow` | `{ color?, opacity?, lift?, maxExtend? }` | `{ color:"#000000", opacity:0.25, lift:0.05, maxExtend:2000 }` | Appearance and SVG extent cap for cast shadows emitted by meshes with `castShadow`. | | `polygons` | `Polygon[]` | None | (Framework only.) Flat array of polygon objects rendered as direct children. Composes with JSX/slot children. | | `children` | None | None | Meshes, polygons, controls, helpers, selection wrappers, and transform controls. | @@ -31,7 +33,7 @@ It's available as a custom element (``), via the imperative `createP ## Mesh props / attributes -(`` accepts the same fields as ``; kebab-case attributes map to camelCase props.) +React/Vue `` supports the full table. The `` custom element currently supports `src`, `mtl`, `mesh-resolution`, `position`, `scale`, `rotation`, and `auto-center`; use `scene.add(result, opts)` for advanced vanilla mesh options such as `castShadow`. | Prop | Type | Description | |------|------|-------------| @@ -43,15 +45,30 @@ It's available as a custom element (``), via the imperative `createP | `rotation` | `Vec3` | Euler rotation in degrees `[x, y, z]`. | | `textureLighting` | `"baked" \| "dynamic"` | Per-mesh lighting mode override. Defaults to the scene value. | | `textureQuality` | `number \| "auto"` | Atlas bitmap budget and compositor sprite size. React / Vue only; vanilla meshes inherit the scene's `texture-quality`. | +| `seamBleed` | `number \| "auto"` | Per-mesh solid seam overscan. React / Vue only; vanilla meshes inherit the scene setting. | +| `atomicAtlas` | `boolean` | Hold the previous atlas frame until the next frame is decoded, then swap atomically. React / Vue only. | +| `onFrameReady` | `() => void` | Fires when an atomic atlas frame swaps to a ready one. React / Vue only. | | `autoCenter` | `boolean` | Shift the loaded mesh so its bounding-box center sits at the local origin before applying `position`. Useful when assets aren't centered in their file coordinates. | | `mtl` | `string` | Companion `.mtl` URL for OBJ models. | | `parseOptions` | `UseMeshOptions` | Parser options forwarded to `loadMesh`; `meshResolution` defaults to `"lossy"`. | | `meshResolution` | `"lossless" \| "lossy"` | Top-level optimizer intent. Wins over `parseOptions.meshResolution`; defaults to `"lossy"`. | -| `castShadow` | `boolean` | Emit one `` shadow leaf per non-duplicate polygon when the scene uses `textureLighting="dynamic"`. | +| `castShadow` | `boolean` | Emit SVG cast shadows in both lighting modes; projections update when light, ground, or mesh geometry changes. | | `fallback` | `ReactNode` | Rendered while `src` is loading. (React / Vue only.) | | `errorFallback` | `(error: Error) => ReactNode` | Rendered if parse fails. (React / Vue only.) | | `children` | `(polygon, index) => ReactNode` | Per-polygon render prop / scoped slot. (React / Vue only.) | +## Ground Props + +`PolyGround` is a React/Vue convenience component for a flat shadow-receiving plane. + +| Prop | Type | Default | Description | +|------|------|---------|-------------| +| `size` | `number` | `6` | Side length in world units. | +| `z` | `number` | `0` | World-space floor height. | +| `center` | `[number, number]` | `[0,0]` | Ground center in world X/Y. | +| `color` | `string` | `"#7d848e"` | Ground fill color. | +| `className` / `class` | `string` | None | Additional CSS class. | + ## PolyDirectionalLight ```ts @@ -67,6 +84,15 @@ interface PolyAmbientLight { } ``` +## Scene Helpers + +Helpers render as ordinary scene children and are available in vanilla custom elements plus React/Vue components. + +| Helper | Props | Description | +|--------|-------|-------------| +| `` / `PolyAxesHelper` | `size`, `thickness`, `negative`, `xColor`, `yColor`, `zColor` | Draws red/green/blue world axes from the origin. | +| `` / `PolyDirectionalLightHelper` | React/Vue: `light`, `target`, `distance`, `size`, `color`. Vanilla: `direction`, `target`, `distance`, `size`, `color`. | Draws a small marker along a directional light vector. | + ## Usage ### Basic Scene @@ -133,9 +159,9 @@ import { PolyPerspectiveCamera, PolyScene, PolyTorus, PolyBox } from "@layoutit/
``` -### Dynamic Shadows +### Cast Shadows -Shadows are CSS-projected leaves. They are emitted only in dynamic lighting mode. +Shadows are SVG-projected surfaces. They work in both lighting modes; `textureLighting` controls surface shading, while shadow geometry reprojects when the light or scene geometry changes. ```tsx import { PolyCamera, PolyScene, PolyGround, PolyMesh } from "@layoutit/polycss-react"; diff --git a/website/src/content/docs/core-concepts.mdx b/website/src/content/docs/core-concepts.mdx index 391cf7af..29111dc1 100644 --- a/website/src/content/docs/core-concepts.mdx +++ b/website/src/content/docs/core-concepts.mdx @@ -114,9 +114,8 @@ The internal leaf tag is a strategy, not public API: | `` | Stable triangle / corner-shape solid | Solid triangles and exact beveled-corner solids. | | `` | Border-shape clipped solid | Solid non-rect polygons on browsers with `border-shape`. | | `` | Atlas slice | Textured polygons and fallback solids. | -| `` | Cast shadow | Dynamic-lighting shadow leaves for meshes with `castShadow`. | -You normally do not target these tags directly; use `Poly`, `PolyMesh`, classes, data attributes, or render stats. +You normally do not target these tags directly; use `Poly`, `PolyMesh`, classes, data attributes, or render stats. Cast shadows are separate SVG shadow surfaces, not render-strategy leaves; meshes with `castShadow` project onto the scene ground or receiver surfaces in both lighting modes. ## Automatic Polygon Merge diff --git a/website/src/content/docs/guides/performance.mdx b/website/src/content/docs/guides/performance.mdx index e2d262be..6ecc4ff8 100644 --- a/website/src/content/docs/guides/performance.mdx +++ b/website/src/content/docs/guides/performance.mdx @@ -47,7 +47,7 @@ Simple polygons are cheaper than textured or irregular polygons. The renderer us - Solid triangles and exact corner-shape solids use `` leaves when supported. - Other supported solid clipped polygons use `` leaves. - Textured polygons and fallbacks use atlas `` leaves. -- Dynamic shadows use `` leaves only for meshes with `castShadow`. +- Cast shadows use SVG shadow surfaces for meshes with `castShadow`; light changes reproject the path without atlas redraw. Use `collectPolyRenderStats(root)` to inspect the mounted leaf mix. For diagnostics, `strategies={{ disable: ["b", "i", "u"] }}` forces fallback atlas rendering so you can compare output or isolate browser compositor bugs. diff --git a/website/src/content/docs/guides/shapes.mdx b/website/src/content/docs/guides/shapes.mdx index 99f25346..85ee2067 100644 --- a/website/src/content/docs/guides/shapes.mdx +++ b/website/src/content/docs/guides/shapes.mdx @@ -36,6 +36,26 @@ const polygons = boxPolygons({ }); ``` +## Primitive shape exports + +PolyCSS ships built-in shape components for common primitives. React and Vue export `PolyBox`, `PolyPlane`, `PolyRing`, `PolyOctahedron`, `PolySphere`, `PolyTetrahedron`, `PolyIcosahedron`, `PolyDodecahedron`, `PolyCylinder`, `PolyCone`, and `PolyTorus`. The vanilla package exports matching ParseResult factories: `createPolyBox`, `createPolyPlane`, `createPolyRing`, `createPolyOctahedron`, `createPolySphere`, `createPolyTetrahedron`, `createPolyIcosahedron`, `createPolyDodecahedron`, `createPolyCylinder`, `createPolyCone`, and `createPolyTorus`. + +Shape components accept their geometry options plus the common mesh props (`position`, `scale`, `rotation`, `autoCenter`, `id`, and event props where supported). + +When you need raw polygon arrays, use the core generators directly: `boxPolygons`, `planePolygons`, `ringPolygons`, `octahedronPolygons`, `spherePolygons`, `tetrahedronPolygons`, `icosahedronPolygons`, `dodecahedronPolygons`, `cylinderPolygons`, `conePolygons`, `torusPolygons`, `axesHelperPolygons`, and `arrowPolygons`. + +```tsx +import { PolyCamera, PolyScene, PolyBox, PolySphere, PolyTorus } from "@layoutit/polycss-react"; + + + + + + + + +``` + ## The polygon primitive `` (vanilla) and `` (React / Vue) render a single polygon as one internal leaf element. The renderer picks the cheapest strategy for that polygon: solid CSS primitives where possible, atlas slices for textured or irregular faces. They forward standard DOM props (`onclick`, `class`, `style`, `aria-*`, etc.). @@ -61,6 +81,7 @@ const polygons = boxPolygons({ ```tsx import { PolyCamera, PolyScene, Poly } from "@layoutit/polycss-react"; +import type { Vec3 } from "@layoutit/polycss-react"; const triangle: Vec3[] = [[0,0,0], [1,0,0], [0,1,0]]; @@ -177,6 +198,26 @@ const onClickPoly = (i: number) => alert(`clicked polygon ${i}`); +## Shared materials + +Use `material` when multiple polygons share the same texture identity. React and Vue export `usePolyMaterial` to keep that material object stable across rerenders, which is useful with memoized polygon lists or `` children. + +```tsx +import { usePolyMaterial, Poly } from "@layoutit/polycss-react"; + +const material = usePolyMaterial({ texture: "/stone.png", key: "stone" }); + +; +``` + +```vue + +``` + ## Per-polygon override (mesh + custom render) To customize specific polygons inside a loaded mesh, use: @@ -298,6 +339,8 @@ export function SelectAndMove() { Use `usePolySelect()` to read the current selection inside a React subtree and `usePolySelectionApi()` when a nested toolbar needs to call `set`, `add`, `remove`, `toggle`, or `clear`. +For lower-level DOM tools, React and Vue also export `findPolyMeshHandle(el)`, `pointInMeshElement(meshEl, clientX, clientY)`, and `findMeshUnderPoint(clientX, clientY, filter?)`. They resolve rendered DOM hits back to `PolyMeshHandle`s and use the same bounding-rect fallback that selection and transform controls use for clipped polygon leaves. + ## Imperative loading Load a mesh programmatically when you need control over loading state. The vanilla `loadMesh` is the universal path; React adds a `usePolyMesh` hook on top that auto-disposes on unmount.