From 988acd64a834980670b413012889f327728e6596 Mon Sep 17 00:00:00 2001 From: agustin-littlehat Date: Sat, 23 May 2026 21:09:46 -0300 Subject: [PATCH] perf: stabilize voxel face culling --- AGENTS.md | 6 +- bench/notes/BENCH.md | 4 - bench/notes/PERF_INVESTIGATION.md | 59 +- bench/voxel-report.mjs | 303 --- package.json | 1 - packages/core/src/index.ts | 15 - packages/core/src/voxel/voxelSlicePlanner.ts | 2117 ----------------- .../polycss/src/api/createPolyScene.test.ts | 99 +- packages/polycss/src/render/voxelRenderer.ts | 419 +++- packages/polycss/src/styles/styles.ts | 18 +- packages/react/src/styles/styles.ts | 25 + packages/vue/src/styles/styles.ts | 25 + 12 files changed, 555 insertions(+), 2536 deletions(-) delete mode 100644 bench/voxel-report.mjs delete mode 100644 packages/core/src/voxel/voxelSlicePlanner.ts diff --git a/AGENTS.md b/AGENTS.md index b3440104..e4c5d2ff 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -23,7 +23,7 @@ Public API is **mirrored** across React and Vue. Adding a hook on one side witho **One visible `Polygon` → one leaf DOM element.** Leaves use canonical CSS primitives where possible and move scale into `matrix3d`; clipped solids use fixed primitives because their paint geometry becomes unstable when collapsed to 1px. Textured polygons still pack their local-2D bounding rect (`canvasW × canvasH`) into the atlas. The HTML tag *is* the render strategy — the renderer picks one tag per polygon based on its shape and material. -Raw MagicaVoxel `.vox` sources have a narrower baked-mode fast path: `parseVox` still returns the polygon mesh for bounds, fallback rendering, and public handles, but also preserves a `PolyVoxelSource` marker. Eligible vanilla meshes render exact visible voxel quads as hostless `` leaves with canonical `matrix3d(...)` transforms and projected tile4 scanline DOM order. `.vox` normalization snaps to the nearest integer CSS cell size so direct voxel matrices use integer pixel coordinates without any scale wrapper. Brush colors still receive baked Lambert shading from the scene lights. Dynamic lighting, shadows, stable DOM animation, non-exact voxel geometry, and geometry replaced via `setPolygons` fall back to the polygon renderer. +Raw MagicaVoxel `.vox` sources have a narrower baked-mode fast path: `parseVox` still returns the polygon mesh for bounds, fallback rendering, and public handles, but also preserves a `PolyVoxelSource` marker. Eligible vanilla meshes render visible voxel quads as `` leaves inside persistent signed-face wrappers (`t`, `b`, `fl`, `br`, `fr`, `bl`), with canonical `matrix3d(...)` transforms and projected tile4 scanline order inside each mounted face. Camera-facing culling mounts/removes those face wrappers instead of removing thousands of live brush children from the mesh root. `.vox` normalization snaps to the nearest integer CSS cell size so direct voxel matrices use integer pixel coordinates without any scale wrapper; same-color shared voxel edges get a tiny matrix-space overscan to hide compositor seams without fattening exterior silhouette edges. Brush colors still receive baked Lambert shading from the scene lights. Dynamic lighting, shadows, stable DOM animation, non-exact voxel geometry, and geometry replaced via `setPolygons` fall back to the polygon renderer. Voxel-shaped meshes are the exception to "all polygons stay mounted": meshes with at most the six axis-aligned face normals, excluding helpers/auto-center-exempt meshes, automatically mount only camera-facing leaves and patch the mounted set when the camera or mesh rotation crosses a visible-normal boundary. Non-voxel meshes keep the full leaf DOM mounted; broad camera-dependent DOM culling is not worth the mutation cost. @@ -41,7 +41,7 @@ Strategies are ordered cheapest → most expensive. The mesher's job is to maxim Callers can opt out of specific strategies via `strategies: { disable: ["b" | "i" | "u"] }` on `RenderTextureAtlasOptions`. Disabled or unsupported strategies fall through the chain (`b → i → s`, `u → i → s`, `i → s`). Disabling `"i"` also disables the exact corner-shape solid branch even though that branch emits a bare ``, because it belongs to the non-triangle clipped-solid family. `` is the universal fallback and cannot be disabled. Solid seam bleed defaults to `1.5` CSS px on detected shared edges; callers can set `seamBleed`, where `"auto"` computes a fitted amount from each polygon plan and numeric values clamp the per-side CSS-pixel overscan. The renderer applies bleed only to detected shared seam edges of solid primitives, rather than inflating every side of each participating polygon. -The `.vox` fast path emits plain `` elements directly inside the mesh wrapper. They intentionally reuse the cheap quad tag, but they are exact voxel quads with one `matrix3d(...)` per visible quad, ordered by projected tile4 scanline order. Desktop-class documents use a canonical 1px primitive for the cheapest transform shape; mobile-class documents (`pointer: coarse` or `hover: none`) use an 8px primitive and divide the in-plane matrix scale by 8 to preserve identical CSS-space geometry while avoiding large GPU filtering gaps. +The `.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"`) @@ -85,7 +85,7 @@ If you find yourself wanting a `requestAnimationFrame` loop to update many DOM n - **Hooks/composables:** `usePolyCamera`, `usePolyMesh`, `usePolySceneContext`, `usePolySelect`, `usePolySelectionApi`, `usePolyAnimation`. - **Components:** `PolyPerspectiveCamera`, `PolyOrthographicCamera`, `PolyOrbitControls`, `PolyMapControls`, `PolyTransformControls`, `PolySelect`, `PolyAxesHelper`, `PolyDirectionalLightHelper`. - **Types:** `PolyDirectionalLight`, `PolyAmbientLight`, `PolyTextureLightingMode`, `PolyAnimationMixer`, `PolyRenderStats`. -- **Functions:** `findPolyMeshHandle`, `injectPolyBaseStyles`, `collectPolyRenderStats`, `buildPolyVoxelFaceData`, `buildPolyVoxelSlicePlan`. +- **Functions:** `findPolyMeshHandle`, `injectPolyBaseStyles`, `collectPolyRenderStats`. - **Vanilla factories:** `create*` names stay as-is (`createPolyScene`, `createTransformControls`, `createSelect`). - **HTML custom elements:** `poly-` prefix + kebab-case. Existing tags: ``, ``, ``, ``, ``, ``, ``. Any new element follows the same shape (e.g. ``, ``). - **Leaf DOM tags (``, ``, ``, ``):** internal render-strategy tags. Not part of the public API and not user-facing — do not document them as such. diff --git a/bench/notes/BENCH.md b/bench/notes/BENCH.md index b78e395b..3eb61714 100644 --- a/bench/notes/BENCH.md +++ b/bench/notes/BENCH.md @@ -20,7 +20,6 @@ pnpm bench:animated-human # build bundles + run the animated human run bench pnpm bench:trace # build bundles + run the trace analysis bucket profiler pnpm bench:lossy # compare lossless / current lossy counts pnpm bench:lossy:corpus # scan gallery GLB/OBJ lossy counts + crack diagnostics -pnpm bench:voxel-report # summarize voxel cadence results pnpm bench:visual # screenshot diff against bench/baselines/*.png pnpm bench:visual --record # capture new baselines (after intentional renderer changes) pnpm bench:build # just rebuild the bench bundles (rarely needed alone) @@ -42,7 +41,6 @@ node bench/lossy-optimizer-bench.mjs --json bench/results/lossy-optimizer.json node bench/lossy-optimizer-bench.mjs --models ducky,shark,bicycle node bench/lossy-corpus-bench.mjs --root /tmp/polycss-model-corpus --json /tmp/polycss-temp-corpus.json node bench/lossy-corpus-bench.mjs --from-json bench/results/lossy-corpus.json --opportunities -node bench/voxel-report.mjs all node .agents/skills/chrome-capture-trace/scripts/trace.mjs motion --mesh garden --runs 3 --dom-samples --report --markdown-out bench/results/garden-trace.md node bench/perf-visual.mjs --mesh chicken --tolerance 0.005 node bench/nonvoxel-rotation-bench.mjs --models teapot,bicycle --variants baseline,order-tile4 --run-order round-robin @@ -229,8 +227,6 @@ bench/ de-dupes a single instance. perf-bench.mjs Playwright runner. Fresh chromium per scenario, ephemeral port, structured JSON output. - voxel-report.mjs Consolidated voxel cadence/browser report over - existing bench/results JSON. animated-human-bench.mjs GPU-default Playwright runner for the animated human run sequence. Reports FPS, mixer/update cost, diff --git a/bench/notes/PERF_INVESTIGATION.md b/bench/notes/PERF_INVESTIGATION.md index c2b3e47f..fd28f276 100644 --- a/bench/notes/PERF_INVESTIGATION.md +++ b/bench/notes/PERF_INVESTIGATION.md @@ -51,7 +51,6 @@ Useful commands are documented in `bench/notes/BENCH.md`; the common ones are: pnpm bench:perf pnpm bench:visual pnpm bench:trace -pnpm bench:voxel-report node bench/nonvoxel-rotation-bench.mjs --run-order random node .agents/skills/chrome-capture-trace/scripts/trace.mjs motion --page nonvoxel --no-trace node bench/nonvoxel-visual-compare.mjs @@ -85,25 +84,26 @@ Eligible baked `.vox` meshes render through the dedicated voxel path. | --- | --- | | Scene | One transform/perspective scene root. | | Mesh | One `.polycss-mesh` wrapper. | -| Voxel hosts | None. | -| Leaves | Plain hostless `` direct-matrix exact voxel quads. | +| Voxel hosts | Persistent signed-face `.polycss-voxel-face-*` wrappers (`t`, `b`, `fl`, `br`, `fr`, `bl`). | +| Leaves | Plain `` direct-matrix exact voxel quads inside the mounted signed-face wrappers. | | Primitive | 1px on desktop-class documents; 8px on mobile-class documents with matrix scale divided by 8. | | Matrix | One canonical non-degenerate `matrix3d(...)` per mounted quad, including an exact `+/-1` normal column. | -| DOM order | Projected screen-space `tile4-scanline-forward`. | -| Culling | Only camera-facing face directions are mounted. | +| DOM order | Projected screen-space tile4 scanline order inside each face wrapper. | +| Culling | Only camera-facing signed-face wrappers are mounted; camera flips patch entering/departing wrappers only. | Accepted voxel decisions: | ID | Decision | Why it stays | | --- | --- | --- | | D1 | Preserve `PolyVoxelSource` and route eligible `.vox` meshes through the dedicated path. | It avoids the general polygon path for exact voxel quads and keeps public polygon handles for fallback/bounds. | -| D2 | Keep hostless direct-matrix leaves as the default voxel shape. | Removing axis hosts and folding orientation/depth/scale into the leaf matrix is the strongest visual-correct one-shape baseline. | -| D3 | Keep `tile4-scanline-forward` as the default DOM order. | It is the broadest validated one-policy order; nearby tile sizes, traversal orders, depth orders, and face orders all have hard counterexamples. | +| D2 | Keep direct-matrix `` leaves grouped by signed face. | The leaf still carries the exact surface transform, while culling mutates six coarse groups instead of thousands of brush nodes. | +| D3 | Keep tile4 scanline order inside each mounted face wrapper. | It is the current validated one-policy order for exact raw `.vox` quads. | | D4 | Use exact parsed voxel quads, not source overpaint, for default rendering. | Source variants reduced nodes in places but did not reliably win and carry visual risk. | -| D5 | Keep camera-facing culling. | Mounting all six faces costs more than it saves. | +| D5 | Keep camera-facing culling at the signed-face-wrapper level. | Mounting all six faces costs more than it saves; patching wrappers avoids the old per-brush removal stutter. | | D6 | Keep integer CSS cell snapping during `.vox` normalization. | It preserves direct integer matrix coordinates without adding a scale wrapper. | | D7 | Normalize visual fit before comparing voxel FPS. | Fixed zoom can crop or resize large voxel scenes enough to change the benchmark. | | D8 | Treat GPU as the default bench lane and software as an explicit stress lane. | Software-renderer ceilings produced false bottlenecks on medium scenes. | +| D9 | Keep same-color shared-edge matrix overscan for direct voxel quads. | It repairs compositor seams without adding atlas work or fattening isolated exterior edges. | ### Non-Voxel Polygon Path @@ -240,40 +240,13 @@ plane count, especially `AncientCrashSite`, `skyscraper`, and long-window These are the few historical tables worth keeping inline because they prevent old ideas from being re-argued. -### Voxel Order Baseline +### Voxel Direct Path -Validation that made `tile4-scanline-forward` the one-strategy default: - -| Model | Prior slice p95 | Tile4 scanline p95 | Read | -| --- | ---: | ---: | --- | -| `obj_house3.vox` | 59.9 | 113.6 | Rescues the face/locality counterexample. | -| `obj_house5.vox` | 59.9 | 113.4 | Validated win. | -| `desert2.vox` | 59.5 | 113.6 | Validated win. | -| `house.vox` | 59.9 | 114.7 | Validated win. | -| `scene_mechanic2.vox` | 40.0 | 113.5 | Validated win. | -| `Treasure.vox` | 30.5 | 58.5 | Moves into the about-60 FPS class. | -| `army.vox` | 39.8 | 42.1 | Weak; still needs better interval analysis. | -| `AncientCrashSite.vox` | 39.8 | 39.8 | Neutral; remains hard. | -| `skyscraper.vox` | 23.7 | 29.9 | Modest; still hard. | - -Rejected replacements after this result: `tile4-depth-front`, tile3/tile5/tile6 -scanlines, tile4 serpentine, Morton traversal, full face order, full depth -order, dense-tile face order, and centered/lookahead interval phases. - -### Old Matrix-Vs-Slice Selector - -Do not revive the old `.vox` matrix-vs-slice router without new GPU-hard proof. - -The 86-model cadence corpus found matrix p95 wins on `desert2`, -`scene_hazmat`, `scene_house`, `scene_mechanic2`, `scene_sidewalk`, and -`Treasure`; slice p95 wins on `AncientCrashSite`, `armchair`, -`christmas_tree`, `ff1`, `mailbox`, `obj_house3`, `obj_house8`, -`obj_trashcan4`, `pyramid`, and `scene_park`; 66 models were flat or capped. - -`visibleShadedColors >= 52 && visiblePlanes < 200` was the safest partial -gate. It caught several high-shaded matrix wins and avoided known p99 risk, but -it missed `desert2`, hit many capped models, and changed with browser mode. -Hostless direct matrix plus tile4 order superseded the router direction. +The current voxel path is one implementation: exact raw `.vox` quads render as +direct-matrix `` leaves inside persistent signed-face wrappers. There is no +runtime matrix-vs-alternate renderer selector, no source-overpaint planner, and +no alternate voxel planner. Experiments that did not become this path should stay +out of product code and out of benchmark scripts. ### Non-Voxel Rotation @@ -354,14 +327,14 @@ visual-correct implementation that changes the premise. | Root transform spelling changes (`matrix3d`, perspective placement, inner target shell, transform-function perspective) | Longer validation was flat or worse. | | CSS variables, registered variables, or individual `rotate` for interactive camera motion | They still hit PAC/layerize like normal JS transform mutation. | | JS-scrubbed WAAPI or scroll-timeline camera controls | Scrubbing from JS still hit PAC once per frame. | -| Leaf `transform-style: flat` | Catastrophic regressions, including hostless direct-matrix voxel leaves. | +| Leaf `transform-style: flat` | Catastrophic regressions on direct-matrix voxel leaves. | | Leaf or host `backface-visibility: hidden` | Fast-looking variants either failed visual checks or lost the win once oriented correctly. | ### Voxel DOM Shape | Direction | Why closed | | --- | --- | -| Axis hosts, host+brush matrix, or voxel slice hosts as the main shape | Hostless direct canonical matrix leaves are the transferable win. | +| Axis hosts or host+brush matrix as the main shape | The current path uses signed-face hosts only as culling groups; each brush remains a direct canonical matrix leaf. | | Mount all six face directions | Extra active DOM dominates mutation savings. | | Hide pooled leaves instead of removing unused faces | Flat to worse. | | Split large brushes or source-overpaint planners | More leaves or visual risk without reliable p95/p99 wins. | diff --git a/bench/voxel-report.mjs b/bench/voxel-report.mjs deleted file mode 100644 index e5bf2899..00000000 --- a/bench/voxel-report.mjs +++ /dev/null @@ -1,303 +0,0 @@ -#!/usr/bin/env node -import { readdir, readFile, stat } from "node:fs/promises"; -import { basename, join } from "node:path"; - -const DEFAULT_RESULT_DIR = "bench/results"; -const DEFAULT_BROWSER_PREFIXES = ["browser-chrome", "browser-canary", "browser-chrome-headed"]; -const CADENCE_FILE_PATTERNS = [ - [/^cadence-clean-(.*)-rotation-compare\.json$/, 1, "clean"], - [/^cadence-wide-(.*)-rotation-compare\.json$/, 2, "wide"], - [/^cadence-extended-(.*)-rotation-compare\.json$/, 3, "extended"], - [/^cadence-validate-(.*)-rotation-compare\.json$/, 4, "validated"], -]; - -function parseArgs(argv) { - const args = { - mode: "cadence", - resultDir: DEFAULT_RESULT_DIR, - browserPrefixes: DEFAULT_BROWSER_PREFIXES, - }; - - const positional = []; - for (let i = 0; i < argv.length; i += 1) { - const arg = argv[i]; - if (arg === "--help" || arg === "-h") args.help = true; - else if (arg === "--mode") args.mode = argv[++i] ?? args.mode; - else if (arg.startsWith("--mode=")) args.mode = arg.slice("--mode=".length); - else if (arg === "--result-dir") args.resultDir = argv[++i] ?? args.resultDir; - else if (arg.startsWith("--result-dir=")) args.resultDir = arg.slice("--result-dir=".length); - else if (arg === "--prefixes") args.browserPrefixes = splitList(argv[++i]); - else if (arg.startsWith("--prefixes=")) args.browserPrefixes = splitList(arg.slice("--prefixes=".length)); - else if (arg.startsWith("-")) throw new Error(`Unknown option ${arg}`); - else positional.push(arg); - } - - if (positional[0] && ["cadence", "browser", "all"].includes(positional[0])) { - args.mode = positional.shift(); - } - if (positional[0]) args.resultDir = positional.shift(); - if (positional.length > 0) args.browserPrefixes = positional; - - if (!["cadence", "browser", "all"].includes(args.mode)) { - throw new Error(`Unknown mode "${args.mode}"`); - } - return args; -} - -function splitList(value = "") { - return value.split(",").map((item) => item.trim()).filter(Boolean); -} - -function printHelp() { - console.log(`Usage: node bench/voxel-report.mjs [mode] [resultDir] [browserPrefix...] - -Modes: - cadence Summarize cadence rotation-compare runs. This is the default. - browser Compare browser/canary/headed result prefixes. - all Print cadence and browser reports. - -Options: - --mode Same as positional mode. - --result-dir Result directory. Default: ${DEFAULT_RESULT_DIR} - --prefixes Browser prefixes for browser/all mode. - -Examples: - node bench/voxel-report.mjs - node bench/voxel-report.mjs all - node bench/voxel-report.mjs browser --prefixes browser-chrome,browser-canary -`); -} - -function matchCadenceFile(file) { - for (const [pattern, priority, label] of CADENCE_FILE_PATTERNS) { - const match = pattern.exec(file); - if (match) return { key: match[1], priority, label }; - } - return null; -} - -function quantile(values, q) { - const sorted = values.filter(Number.isFinite).sort((a, b) => a - b); - if (sorted.length === 0) return null; - const i = (sorted.length - 1) * q; - const lo = Math.floor(i); - const hi = Math.ceil(i); - if (lo === hi) return sorted[lo]; - return sorted[lo] + (sorted[hi] - sorted[lo]) * (i - lo); -} - -function median(values) { - return quantile(values, 0.5); -} - -function fmt(value, digits = 1) { - return Number.isFinite(value) ? value.toFixed(digits) : ""; -} - -function cadence4Plus(run) { - const pct = run.cadence?.vsync_pct; - if (!pct) return null; - return (pct.x4 ?? 0) + (pct.x5 ?? 0) + (pct.x6_plus ?? 0); -} - -function winnerFor(deltaP95, deltaP99) { - if (Math.abs(deltaP95) >= 8) return deltaP95 > 0 ? "matrix" : "slice"; - if (Math.abs(deltaP99) >= 8) return deltaP99 > 0 ? "matrix-p99" : "slice-p99"; - return "flat"; -} - -async function loadCadenceRows(resultDir) { - const selected = new Map(); - for (const file of await readdir(resultDir)) { - const match = matchCadenceFile(file); - if (!match) continue; - const path = join(resultDir, file); - const mtime = (await stat(path)).mtimeMs; - const current = selected.get(match.key); - if ( - !current || - match.priority > current.priority || - (match.priority === current.priority && mtime > current.mtime) - ) { - selected.set(match.key, { ...match, path, mtime }); - } - } - - const rows = []; - for (const entry of selected.values()) { - const data = JSON.parse(await readFile(entry.path, "utf8")); - const row = summarizeRotationCompare(data, basename(entry.path), entry.label); - if (row) rows.push(row); - } - return rows.sort((a, b) => a.model.localeCompare(b.model)); -} - -function summarizeRotationCompare(data, fallbackName, source = "") { - const matrixRuns = data.cases?.["polycss-matrix"]?.runs ?? []; - const sliceRuns = data.cases?.["polycss-baked-voxzoom"]?.runs ?? []; - if (matrixRuns.length === 0 || sliceRuns.length === 0) return null; - - const slice = sliceRuns[0]; - const meta = slice.metadata ?? {}; - const brush = slice.brushMetrics ?? {}; - const matrixP95 = median(matrixRuns.map((run) => run.fps_p95)); - const sliceP95 = median(sliceRuns.map((run) => run.fps_p95)); - const matrixP50 = median(matrixRuns.map((run) => run.fps_p50)); - const sliceP50 = median(sliceRuns.map((run) => run.fps_p50)); - const matrixP99 = median(matrixRuns.map((run) => run.frame_time_p99_ms)); - const sliceP99 = median(sliceRuns.map((run) => run.frame_time_p99_ms)); - - return { - model: String(data.model ?? fallbackName).replace(/\.vox$/, ""), - source, - runs: Math.min(matrixRuns.length, sliceRuns.length), - winner: winnerFor(matrixP95 - sliceP95, sliceP99 - matrixP99), - matrixP50, - sliceP50, - matrixP95, - sliceP95, - matrixP99, - sliceP99, - deltaP95: matrixP95 - sliceP95, - deltaP99: sliceP99 - matrixP99, - matrix4Plus: median(matrixRuns.map(cadence4Plus)), - slice4Plus: median(sliceRuns.map(cadence4Plus)), - activeLeaves: slice.dom?.leaves ?? null, - matrixNodes: median(matrixRuns.map((run) => run.performanceMetrics?.Nodes)), - sliceNodes: median(sliceRuns.map((run) => run.performanceMetrics?.Nodes)), - planes: brush.depthPlaneCount ?? null, - colors: brush.colorCount ?? null, - areaK: Number.isFinite(brush.localAreaTotal) ? Math.round(brush.localAreaTotal / 1000) : null, - fill: brush.localPlaneFillRatio ?? null, - screenFill: brush.screenFillRatio ?? null, - dimensions: [meta.rows, meta.cols, meta.depth].every(Number.isFinite) - ? `${meta.rows}x${meta.cols}x${meta.depth}` - : "", - browser: matrixRuns[0]?.browserVersion ?? "", - }; -} - -function printCadenceReport(rows) { - const groups = new Map(); - for (const row of rows) { - const list = groups.get(row.winner) ?? []; - list.push(row); - groups.set(row.winner, list); - } - - console.log("# Voxel Cadence Corpus\n"); - console.log(`Models: ${rows.length}\n`); - for (const key of ["matrix", "slice", "matrix-p99", "slice-p99", "flat"]) { - const list = groups.get(key) ?? []; - console.log(`- ${key}: ${list.length}${list.length ? ` (${list.map((row) => row.model).join(", ")})` : ""}`); - } - - console.log("\n## Strong Winners\n"); - console.log("| Model | Winner | Source | Runs | Matrix p95 | Slice p95 | Matrix p99 | Slice p99 | Matrix 4x+ | Slice 4x+ | Leaves | Nodes M/S | Planes | Colors | AreaK | Fill | Screen | Dims |"); - console.log("| --- | --- | --- | ---: | ---: | ---: | ---: | ---: | ---: | ---: | ---: | --- | ---: | ---: | ---: | ---: | ---: | --- |"); - for (const row of rows - .filter((candidate) => candidate.winner === "matrix" || candidate.winner === "slice") - .sort((a, b) => Math.abs(b.deltaP95) - Math.abs(a.deltaP95))) { - console.log([ - `| ${row.model}`, - row.winner, - row.source, - row.runs, - fmt(row.matrixP95), - fmt(row.sliceP95), - fmt(row.matrixP99), - fmt(row.sliceP99), - fmt(row.matrix4Plus), - fmt(row.slice4Plus), - row.activeLeaves ?? "", - `${fmt(row.matrixNodes, 0)}/${fmt(row.sliceNodes, 0)}`, - row.planes ?? "", - row.colors ?? "", - row.areaK ?? "", - fmt(row.fill, 3), - fmt(row.screenFill, 3), - `${row.dimensions} |`, - ].join(" | ")); - } - - console.log("\n## P50/P99 Splits Within Flat p95\n"); - console.log("| Model | Source | Runs | Matrix p50 | Slice p50 | Matrix p95 | Slice p95 | Matrix p99 | Slice p99 | Leaves | Screen |"); - console.log("| --- | --- | ---: | ---: | ---: | ---: | ---: | ---: | ---: | ---: | ---: |"); - for (const row of rows - .filter((candidate) => - candidate.winner === "flat" && - (Math.abs(candidate.matrixP50 - candidate.sliceP50) >= 20 || Math.abs(candidate.deltaP99) >= 5) - ) - .sort((a, b) => Math.abs(b.deltaP99) - Math.abs(a.deltaP99))) { - console.log([ - `| ${row.model}`, - row.source, - row.runs, - fmt(row.matrixP50), - fmt(row.sliceP50), - fmt(row.matrixP95), - fmt(row.sliceP95), - fmt(row.matrixP99), - fmt(row.sliceP99), - row.activeLeaves ?? "", - `${fmt(row.screenFill, 3)} |`, - ].join(" | ")); - } -} - -function matchesBrowserPrefix(file, prefix, selectedPrefixes) { - if (!file.startsWith(`${prefix}-`) || !file.endsWith("-rotation-compare.json")) return false; - return !selectedPrefixes.some((other) => - other !== prefix && - other.startsWith(`${prefix}-`) && - file.startsWith(`${other}-`) - ); -} - -async function printBrowserReport(resultDir, prefixes) { - const files = await readdir(resultDir); - for (const prefix of prefixes) { - const rows = []; - for (const file of files) { - if (!matchesBrowserPrefix(file, prefix, prefixes)) continue; - const data = JSON.parse(await readFile(join(resultDir, file), "utf8")); - const row = summarizeRotationCompare(data, file); - if (row) rows.push(row); - } - - rows.sort((a, b) => a.model.localeCompare(b.model)); - console.log(`\n## ${prefix}${rows[0]?.browser ? ` (${rows[0].browser})` : ""}\n`); - console.log("| Model | Winner | Runs | Matrix p95 | Slice p95 | Matrix p99 | Slice p99 |"); - console.log("| --- | --- | ---: | ---: | ---: | ---: | ---: |"); - for (const row of rows) { - console.log([ - `| ${row.model}`, - row.winner, - row.runs, - fmt(row.matrixP95), - fmt(row.sliceP95), - fmt(row.matrixP99), - `${fmt(row.sliceP99)} |`, - ].join(" | ")); - } - } -} - -try { - const args = parseArgs(process.argv.slice(2)); - if (args.help) { - printHelp(); - process.exit(0); - } - - if (args.mode === "cadence" || args.mode === "all") { - printCadenceReport(await loadCadenceRows(args.resultDir)); - } - if (args.mode === "browser" || args.mode === "all") { - if (args.mode === "all") console.log("\n---\n"); - await printBrowserReport(args.resultDir, args.browserPrefixes); - } -} catch (error) { - console.error(`[voxel-report] ${error.message}`); - process.exit(1); -} diff --git a/package.json b/package.json index 201718f9..2e0e7174 100644 --- a/package.json +++ b/package.json @@ -35,7 +35,6 @@ "bench:lossy:corpus": "pnpm --filter @layoutit/polycss-core build && node bench/lossy-corpus-bench.mjs", "bench:seams": "pnpm --filter @layoutit/polycss-core build && node bench/seam-gap-bench.mjs", "bench:seams:render": "pnpm --filter @layoutit/polycss-core build && node bench/seam-gap-bench.mjs --render", - "bench:voxel-report": "node bench/voxel-report.mjs", "bench:visual": "node bench/build.mjs && node bench/perf-visual.mjs" }, "pnpm": { diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 578266ef..14288936 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -181,21 +181,6 @@ export { export type { SolidTextureSampleOptions } from "./parser/solidTextureSamples"; export { parseVox } from "./parser/parseVox"; export type { VoxParseOptions } from "./parser/parseVox"; -export { - buildFaceDataFromVoxelSource as buildPolyVoxelFaceData, - buildSlicePlan as buildPolyVoxelSlicePlan, - NEXT_LAYER_STEP as POLY_VOXEL_NEXT_LAYER_STEP, -} from "./voxel/voxelSlicePlanner"; -export type { - Brush as PolyVoxelBrush, - FaceBuffer as PolyVoxelFaceBuffer, - FaceData as PolyVoxelFaceData, - FaceKey as PolyVoxelFaceKey, - PlaneAxis as PolyVoxelPlaneAxis, - PolyVoxelFace, - PolyVoxelWallsMask, - SlicePlan as PolyVoxelSlicePlan, -} from "./voxel/voxelSlicePlanner"; export { loadMesh } from "./parser/loadMesh"; export type { LoadMeshOptions } from "./parser/loadMesh"; diff --git a/packages/core/src/voxel/voxelSlicePlanner.ts b/packages/core/src/voxel/voxelSlicePlanner.ts deleted file mode 100644 index 20565b39..00000000 --- a/packages/core/src/voxel/voxelSlicePlanner.ts +++ /dev/null @@ -1,2117 +0,0 @@ -/* Pure voxel slice planning - zero DOM dependencies. - * The rectangle optimizer is ported from voxcss mergeVoxels="3d"; polycss - * keeps it available as a low-level planning utility, though the current - * vanilla `.vox` fast path renders exact direct-matrix quads instead. - */ -import type { PolyVoxelSource } from "../parser/types"; - -// --------------------------------------------------------------------------- -// Types -// --------------------------------------------------------------------------- - -export type PlaneAxis = "x" | "y" | "z"; -export type PolyVoxelFace = "t" | "b" | "bl" | "br" | "fr" | "fl"; - -export interface PolyVoxelWallsMask { - t: boolean; - b: boolean; - bl: boolean; - br: boolean; - fl: boolean; - fr: boolean; -} - -const CUBE_FACES = ["t", "b", "bl", "br", "fr", "fl"] as const; - -export interface FaceKey { axis: PlaneAxis; plane: number; face: PolyVoxelFace; } - -export interface FaceBuffer { - width: number; - height: number; - minRow: number; - minCol: number; - ids: Uint32Array; - mask: Uint8Array; - filledCount: number; - palette: string[]; -} - -export interface FaceData { key: FaceKey; buffer: FaceBuffer; } - -export type Brush = { - r0: number; - c0: number; - r1: number; - c1: number; - baseColor: string; -}; - -export type SlicePlan = { - key: FaceKey; - buffer: FaceBuffer; - brushes: Brush[]; -}; - -/** Half-open bounds: [r0, r1) x [c0, c1) */ -interface Rect { - r0: number; - c0: number; - r1: number; - c1: number; -} - -type HoleFill = { - mask: Uint8Array; - filledCount: number; - allowMask: Uint8Array | null; -}; - -type SpanMergeCandidate = { - first: number; - second: number; - replacement: Brush; - extraArea: number; -}; - -// --------------------------------------------------------------------------- -// Constants -// --------------------------------------------------------------------------- - -export const SLICE_RENDERER_VERSION = 1; -export const AXIS_ORDER: Record = { x: 0, y: 1, z: 2 }; -export const FACE_ORDER = new Map(CUBE_FACES.map((face, index) => [face, index] as const)); -export const NEXT_LAYER_STEP: Record = { - t: 1, fr: 1, fl: 1, - b: -1, bl: -1, br: -1 -}; - -export const wallsToSig = (walls: PolyVoxelWallsMask): number => - (walls.t ? 1 : 0) | - (walls.b ? 2 : 0) | - (walls.bl ? 4 : 0) | - (walls.br ? 8 : 0) | - (walls.fl ? 16 : 0) | - (walls.fr ? 32 : 0); - -export const buildSliceCacheKey = (face: FaceData): string => { - const { axis, plane, face: faceKey } = face.key; - return `slice:${SLICE_RENDERER_VERSION}:${axis}:${plane}:${faceKey}`; -}; - -export const buffersEqual = (a: FaceBuffer | null, b: FaceBuffer | null): boolean => { - if (a === b) return true; - if (!a || !b) return false; - if (a.width !== b.width || a.height !== b.height) return false; - if (a.minRow !== b.minRow || a.minCol !== b.minCol) return false; - if (a.filledCount !== b.filledCount) return false; - const paletteA = a.palette; - const paletteB = b.palette; - if (paletteA.length !== paletteB.length) return false; - for (let i = 0; i < paletteA.length; i += 1) { - if (paletteA[i] !== paletteB[i]) return false; - } - const idsA = a.ids; - const idsB = b.ids; - if (idsA.length !== idsB.length) return false; - for (let i = 0; i < idsA.length; i += 1) { - if (idsA[i] !== idsB[i]) return false; - } - return true; -}; - -// --------------------------------------------------------------------------- -// Rectangle decomposition -// --------------------------------------------------------------------------- - -export const holeFillVariants = (buffer: FaceBuffer, nextLayer: FaceBuffer | null): HoleFill[] => { - const out: HoleFill[] = [{ mask: buffer.mask, filledCount: buffer.filledCount, allowMask: null }]; - if (!nextLayer) return out; - - const { width, height } = buffer; - const allowMask = new Uint8Array(width * height); - - const rowOffset = nextLayer.minRow - buffer.minRow; - const colOffset = nextLayer.minCol - buffer.minCol; - - for (let nr = 0; nr < nextLayer.height; nr += 1) { - const r = nr + rowOffset; - if (r < 0 || r >= height) continue; - const rowBase = r * width; - const nextRowBase = nr * nextLayer.width; - for (let nc = 0; nc < nextLayer.width; nc += 1) { - if (!nextLayer.mask[nextRowBase + nc]) continue; - const c = nc + colOffset; - if (c < 0 || c >= width) continue; - const idx = rowBase + c; - if (!buffer.mask[idx]) allowMask[idx] = 1; - } - } - - let added = 0; - for (let i = 0; i < allowMask.length; i += 1) added += allowMask[i] ? 1 : 0; - if (!added) return out; - - const filledMask = buffer.mask.slice(); - for (let i = 0; i < allowMask.length; i += 1) if (allowMask[i]) filledMask[i] = 1; - - out.push({ mask: filledMask, filledCount: buffer.filledCount + added, allowMask }); - return out; -}; - -export const runRects = (mask: Uint8Array, width: number, bounds: Rect, byColumn: boolean): Rect[] => { - const { r0, c0, r1, c1 } = bounds; - if (r1 <= r0 || c1 <= c0) return []; - - const rects: Rect[] = []; - - if (!byColumn) { - for (let r = r0; r < r1; r += 1) { - const rowBase = r * width; - let c = c0; - while (c < c1) { - while (c < c1 && !mask[rowBase + c]) c += 1; - if (c >= c1) break; - const start = c; - while (c < c1 && mask[rowBase + c]) c += 1; - rects.push({ r0: r, c0: start, r1: r + 1, c1: c }); - } - } - return rects; - } - - for (let c = c0; c < c1; c += 1) { - let r = r0; - while (r < r1) { - while (r < r1 && !mask[r * width + c]) r += 1; - if (r >= r1) break; - const start = r; - while (r < r1 && mask[r * width + c]) r += 1; - rects.push({ r0: start, c0: c, r1: r, c1: c + 1 }); - } - } - return rects; -}; - -export const mergeAlignedRects = (rects: T[]): T[] => { - if (rects.length < 2) return rects; - - rects.sort((a, b) => a.r0 - b.r0 || a.r1 - b.r1 || a.c0 - b.c0 || a.c1 - b.c1); - const horiz: T[] = []; - for (const rect of rects) { - const last = horiz[horiz.length - 1]; - if (last && rect.r0 === last.r0 && rect.r1 === last.r1 && rect.c0 === last.c1) { - last.c1 = rect.c1; - continue; - } - horiz.push(rect); - } - - horiz.sort((a, b) => a.c0 - b.c0 || a.c1 - b.c1 || a.r0 - b.r0 || a.r1 - b.r1); - const vert: T[] = []; - for (const rect of horiz) { - const last = vert[vert.length - 1]; - if (last && rect.c0 === last.c0 && rect.c1 === last.c1 && rect.r0 === last.r1) { - last.r1 = rect.r1; - continue; - } - vert.push(rect); - } - - return vert; -}; - -const pickRectsForMask = (mask: Uint8Array, width: number, height: number): Rect[] => { - const bounds = { r0: 0, c0: 0, r1: height, c1: width }; - - const row = mergeAlignedRects(runRects(mask, width, bounds, false)); - const col = mergeAlignedRects(runRects(mask, width, bounds, true)); - - if (!row.length) return col; - if (col.length && col.length < row.length) return col; - return row; -}; - -const findLargestFilledRect = ( - mask: Uint8Array, - width: number, - height: number, - heights: Int32Array, - stackStarts: Int32Array, - stackHeights: Int32Array -): (Rect & { area: number }) | null => { - heights.fill(0); - let best: (Rect & { area: number }) | null = null; - - for (let r = 0; r < height; r += 1) { - const rowBase = r * width; - for (let c = 0; c < width; c += 1) { - heights[c] = mask[rowBase + c] ? heights[c] + 1 : 0; - } - - let stackLength = 0; - for (let c = 0; c <= width; c += 1) { - const currentHeight = c < width ? heights[c] : 0; - let start = c; - - while (stackLength > 0 && stackHeights[stackLength - 1] > currentHeight) { - stackLength -= 1; - const rectHeight = stackHeights[stackLength]; - const rectStart = stackStarts[stackLength]; - const area = rectHeight * (c - rectStart); - if (!best || area > best.area) { - best = { - r0: r - rectHeight + 1, - c0: rectStart, - r1: r + 1, - c1: c, - area - }; - } - start = rectStart; - } - - if (currentHeight > 0 && (stackLength === 0 || stackHeights[stackLength - 1] < currentHeight)) { - stackStarts[stackLength] = start; - stackHeights[stackLength] = currentHeight; - stackLength += 1; - } - } - } - - return best; -}; - -const greedyRectsForMask = (mask: Uint8Array, width: number, height: number, limit: number): Rect[] | null => { - if (limit <= 1) return null; - - const heights = new Int32Array(width); - const stackStarts = new Int32Array(width + 1); - const stackHeights = new Int32Array(width + 1); - const rects: Rect[] = []; - - while (rects.length < limit) { - const rect = findLargestFilledRect(mask, width, height, heights, stackStarts, stackHeights); - if (!rect || rect.area <= 0) return rects; - - rects.push({ r0: rect.r0, c0: rect.c0, r1: rect.r1, c1: rect.c1 }); - if (rects.length >= limit) return null; - - for (let r = rect.r0; r < rect.r1; r += 1) { - const rowBase = r * width; - for (let c = rect.c0; c < rect.c1; c += 1) mask[rowBase + c] = 0; - } - } - - return null; -}; - -const greedyRectsForRuns = (runs: Rect[], width: number, height: number, limit: number): Rect[] | null => { - if (runs.length < 2 || limit <= 1) return null; - - let minR = height; - let minC = width; - let maxR = -1; - let maxC = -1; - let filled = 0; - - for (const run of runs) { - if (run.r0 < minR) minR = run.r0; - if (run.c0 < minC) minC = run.c0; - if (run.r1 > maxR) maxR = run.r1; - if (run.c1 > maxC) maxC = run.c1; - filled += (run.r1 - run.r0) * (run.c1 - run.c0); - } - - const localW = maxC - minC; - const localH = maxR - minR; - if (localW <= 0 || localH <= 0) return null; - if (filled === localW * localH) { - return limit > 1 ? [{ r0: minR, c0: minC, r1: maxR, c1: maxC }] : null; - } - - const localMask = new Uint8Array(localW * localH); - for (const run of runs) { - for (let r = run.r0; r < run.r1; r += 1) { - const rowBase = (r - minR) * localW; - for (let c = run.c0; c < run.c1; c += 1) localMask[rowBase + c - minC] = 1; - } - } - - const localRects = greedyRectsForMask(localMask, localW, localH, limit); - if (!localRects) return null; - - return localRects.map((rect) => ({ - r0: rect.r0 + minR, - c0: rect.c0 + minC, - r1: rect.r1 + minR, - c1: rect.c1 + minC - })); -}; - -const componentRectsForMask = (mask: Uint8Array, width: number, height: number): Rect[] => { - const bounds = { r0: 0, c0: 0, r1: height, c1: width }; - const rowRuns = runRects(mask, width, bounds, false); - if (rowRuns.length < 2) return rowRuns; - - const parent = new Int32Array(rowRuns.length); - for (let i = 0; i < parent.length; i += 1) parent[i] = i; - - const find = (index: number): number => { - let root = index; - while (parent[root] !== root) root = parent[root]; - while (parent[index] !== index) { - const next = parent[index]; - parent[index] = root; - index = next; - } - return root; - }; - - const union = (a: number, b: number): void => { - const rootA = find(a); - const rootB = find(b); - if (rootA !== rootB) parent[rootB] = rootA; - }; - - let previousStart = 0; - let previousEnd = 0; - let currentRow = -1; - let currentStart = 0; - - for (let i = 0; i < rowRuns.length; i += 1) { - const run = rowRuns[i]; - if (!run) continue; - - if (run.r0 !== currentRow) { - if (run.r0 === currentRow + 1) { - previousStart = currentStart; - previousEnd = i; - } else { - previousStart = i; - previousEnd = i; - } - currentRow = run.r0; - currentStart = i; - } - - for (let previousIndex = previousStart; previousIndex < previousEnd; previousIndex += 1) { - const previous = rowRuns[previousIndex]; - if (!previous) continue; - if (previous.c1 <= run.c0) continue; - if (previous.c0 >= run.c1) break; - union(i, previousIndex); - } - } - - const rootToGroup = new Int32Array(rowRuns.length); - rootToGroup.fill(-1); - const groups: number[][] = []; - for (let i = 0; i < rowRuns.length; i += 1) { - const root = find(i); - let groupIndex = rootToGroup[root] ?? -1; - if (groupIndex < 0) { - groupIndex = groups.length; - rootToGroup[root] = groupIndex; - groups.push([]); - } - groups[groupIndex]?.push(i); - } - - if (groups.length < 2) return rowRuns; - - const out: Rect[] = []; - - for (const group of groups) { - let minR = height; - let minC = width; - let maxR = -1; - let maxC = -1; - let filled = 0; - - for (const index of group) { - const run = rowRuns[index]; - if (!run) continue; - if (run.r0 < minR) minR = run.r0; - if (run.c0 < minC) minC = run.c0; - if (run.r1 > maxR) maxR = run.r1; - if (run.c1 > maxC) maxC = run.c1; - filled += run.c1 - run.c0; - } - - const localW = maxC - minC; - const localH = maxR - minR; - if (filled === localW * localH) { - out.push({ r0: minR, c0: minC, r1: maxR, c1: maxC }); - continue; - } - - const localMask = new Uint8Array(localW * localH); - for (const index of group) { - const run = rowRuns[index]; - if (!run) continue; - const localRow = (run.r0 - minR) * localW; - for (let c = run.c0; c < run.c1; c += 1) { - localMask[localRow + c - minC] = 1; - } - } - - const localRects = pickRectsForMask(localMask, localW, localH); - for (const rect of localRects) { - out.push({ - r0: rect.r0 + minR, - c0: rect.c0 + minC, - r1: rect.r1 + minR, - c1: rect.c1 + minC - }); - } - } - - return out; -}; - -const emitHost = (host: Rect, buffer: FaceBuffer): Brush[] => { - const { width, ids, palette } = buffer; - - const counts = new Map(); - const localW = host.c1 - host.c0; - const localH = host.r1 - host.r0; - - for (let r = host.r0; r < host.r1; r += 1) { - const rowBase = r * width; - for (let c = host.c0; c < host.c1; c += 1) { - const id = ids[rowBase + c]; - if (!id) continue; - const next = (counts.get(id) ?? 0) + 1; - counts.set(id, next); - } - } - - if (!counts.size) return []; - const colorIds = Array.from(counts.keys()).sort((a, b) => a - b); - const localMask = new Uint8Array(localW * localH); - const colorRects: { colorId: number; rects: Rect[] }[] = []; - - let baseId = colorIds[0] ?? 0; - let baseRectCount = -1; - let baseCellCount = -1; - - for (const colorId of colorIds) { - localMask.fill(0); - - for (let r = host.r0; r < host.r1; r += 1) { - const rowBase = r * width; - const localRow = (r - host.r0) * localW; - for (let c = host.c0; c < host.c1; c += 1) { - if (ids[rowBase + c] === colorId) localMask[localRow + (c - host.c0)] = 1; - } - } - - const rects = pickRectsForMask(localMask, localW, localH); - colorRects.push({ colorId, rects }); - - const cellCount = counts.get(colorId) ?? 0; - if ( - rects.length > baseRectCount - || (rects.length === baseRectCount && cellCount > baseCellCount) - || (rects.length === baseRectCount && cellCount === baseCellCount && colorId < baseId) - ) { - baseId = colorId; - baseRectCount = rects.length; - baseCellCount = cellCount; - } - } - - const baseFill = palette[baseId] ?? ""; - if (!baseFill) return []; - - const out: Brush[] = [{ ...host, baseColor: baseFill }]; - - for (const { colorId, rects } of colorRects) { - if (colorId === baseId || !rects.length) continue; - - const fill = palette[colorId] ?? ""; - if (!fill) continue; - - for (const r of rects) { - out.push({ - r0: r.r0 + host.r0, - c0: r.c0 + host.c0, - r1: r.r1 + host.r0, - c1: r.c1 + host.c0, - baseColor: fill - }); - } - } - - return out; -}; - -export const verify = (brushes: Brush[], buffer: FaceBuffer, allowMask: Uint8Array | null, paletteIds: Map): boolean => { - const { width, height } = buffer; - const scratch = new Uint32Array(width * height); - - for (const brush of brushes) { - const colorId = paletteIds.get(brush.baseColor); - if (!colorId) return false; - - const r0 = Math.max(0, brush.r0); - const c0 = Math.max(0, brush.c0); - const r1 = Math.min(height, brush.r1); - const c1 = Math.min(width, brush.c1); - - for (let r = r0; r < r1; r += 1) { - const rowBase = r * width; - for (let c = c0; c < c1; c += 1) scratch[rowBase + c] = colorId; - } - } - - const expected = buffer.ids; - for (let i = 0; i < scratch.length; i += 1) { - if (scratch[i] === expected[i]) continue; - if (allowMask && !expected[i] && allowMask[i]) continue; - return false; - } - return true; -}; - -const mergeAligned = (brushes: Brush[]): Brush[] => { - if (brushes.length < 2) return brushes; - - const byColor = new Map(); - for (const b of brushes) { - const list = byColor.get(b.baseColor); - if (list) list.push(b); - else byColor.set(b.baseColor, [b]); - } - - const out: Brush[] = []; - for (const [, list] of byColor) { - out.push(...mergeAlignedRects(list.map((b) => ({ ...b })))); - } - return out; -}; - -const rectArea = (rect: Rect): number => - Math.max(0, rect.r1 - rect.r0) * Math.max(0, rect.c1 - rect.c0); - -const rectsOverlap = (a: Rect, b: Rect): boolean => - a.r0 < b.r1 && a.r1 > b.r0 && a.c0 < b.c1 && a.c1 > b.c0; - -const rangesOverlap = (a0: number, a1: number, b0: number, b1: number): boolean => - a0 < b1 && a1 > b0; - -const mergedBounds = (a: Brush, b: Brush): Brush => ({ - r0: Math.min(a.r0, b.r0), - c0: Math.min(a.c0, b.c0), - r1: Math.max(a.r1, b.r1), - c1: Math.max(a.c1, b.c1), - baseColor: a.baseColor -}); - -const buildLastPaintIndices = ( - brushes: Brush[], - buffer: FaceBuffer, - paletteIds: Map -): { lastPaint: Int32Array; previousPaint: Int32Array; brushColorIds: Int32Array } | null => { - const { width, height } = buffer; - const lastPaint = new Int32Array(width * height); - const previousPaint = new Int32Array(width * height); - const brushColorIds = new Int32Array(brushes.length); - lastPaint.fill(-1); - previousPaint.fill(-1); - for (let i = 0; i < brushes.length; i += 1) { - const brush = brushes[i]; - const colorId = paletteIds.get(brush.baseColor); - if (!colorId) return null; - brushColorIds[i] = colorId; - - const r0 = Math.max(0, brush.r0); - const c0 = Math.max(0, brush.c0); - const r1 = Math.min(height, brush.r1); - const c1 = Math.min(width, brush.c1); - for (let r = r0; r < r1; r += 1) { - const rowBase = r * width; - for (let c = c0; c < c1; c += 1) { - const index = rowBase + c; - previousPaint[index] = lastPaint[index] ?? -1; - lastPaint[index] = i; - } - } - } - return { lastPaint, previousPaint, brushColorIds }; -}; - -const verifySpanMergeByLastPaint = ( - buffer: FaceBuffer, - allowMask: Uint8Array | null, - paletteIds: Map, - lastPaint: Int32Array, - previousPaint: Int32Array, - brushColorIds: Int32Array, - firstIndex: number, - secondIndex: number, - replacement: Brush, - atFirstIndex: boolean -): boolean => { - const colorId = paletteIds.get(replacement.baseColor); - if (!colorId) return false; - - const expected = buffer.ids; - const paintIndex = atFirstIndex ? firstIndex : secondIndex; - const removedIndex = atFirstIndex ? secondIndex : firstIndex; - for (let r = replacement.r0; r < replacement.r1; r += 1) { - const expectedRowBase = r * buffer.width; - for (let c = replacement.c0; c < replacement.c1; c += 1) { - const expectedIndex = expectedRowBase + c; - if (expected[expectedIndex] === colorId) { - if (atFirstIndex && lastPaint[expectedIndex] === removedIndex) { - const previousIndex = previousPaint[expectedIndex] ?? -1; - if (previousIndex > paintIndex && brushColorIds[previousIndex] !== colorId) return false; - } - continue; - } - if (allowMask && !expected[expectedIndex] && allowMask[expectedIndex]) continue; - const lastIndex = lastPaint[expectedIndex] ?? -1; - if (lastIndex > paintIndex && lastIndex !== removedIndex) continue; - return false; - } - } - return true; -}; - -const collectAdjacentSpanMergeCandidates = (brushes: Brush[]): SpanMergeCandidate[] => { - const rowGroups = new Map(); - const colGroups = new Map(); - - for (let i = 0; i < brushes.length; i += 1) { - const brush = brushes[i]; - const rowKey = `${brush.baseColor}|r|${brush.r0}|${brush.r1}`; - const colKey = `${brush.baseColor}|c|${brush.c0}|${brush.c1}`; - const rowGroup = rowGroups.get(rowKey); - if (rowGroup) rowGroup.push(i); - else rowGroups.set(rowKey, [i]); - const colGroup = colGroups.get(colKey); - if (colGroup) colGroup.push(i); - else colGroups.set(colKey, [i]); - } - - const seen = new Set(); - const out: SpanMergeCandidate[] = []; - const addPair = (a: number, b: number): void => { - const first = Math.min(a, b); - const second = Math.max(a, b); - if (first === second) return; - const key = `${first}:${second}`; - if (seen.has(key)) return; - seen.add(key); - - const brushA = brushes[first]; - const brushB = brushes[second]; - if (!brushA || !brushB || brushA.baseColor !== brushB.baseColor) return; - - const replacement = mergedBounds(brushA, brushB); - const extraArea = rectArea(replacement) - rectArea(brushA) - rectArea(brushB); - if (extraArea < 0) return; - out.push({ first, second, replacement, extraArea }); - }; - - for (const group of rowGroups.values()) { - if (group.length < 2) continue; - group.sort((a, b) => brushes[a].c0 - brushes[b].c0 || brushes[a].c1 - brushes[b].c1); - for (let i = 0; i < group.length - 1; i += 1) addPair(group[i], group[i + 1]); - } - - for (const group of colGroups.values()) { - if (group.length < 2) continue; - group.sort((a, b) => brushes[a].r0 - brushes[b].r0 || brushes[a].r1 - brushes[b].r1); - for (let i = 0; i < group.length - 1; i += 1) addPair(group[i], group[i + 1]); - } - - out.sort((a, b) => a.extraArea - b.extraArea || a.first - b.first || a.second - b.second); - return out; -}; - -const collectOverlappingSpanMergeCandidates = (brushes: Brush[]): SpanMergeCandidate[] => { - const colorGroups = new Map(); - for (let i = 0; i < brushes.length; i += 1) { - const brush = brushes[i]; - const group = colorGroups.get(brush.baseColor); - if (group) group.push(i); - else colorGroups.set(brush.baseColor, [i]); - } - - const out: SpanMergeCandidate[] = []; - for (const group of colorGroups.values()) { - if (group.length < 2) continue; - for (let a = 0; a < group.length - 1; a += 1) { - const first = group[a]; - const brushA = brushes[first]; - if (!brushA) continue; - for (let b = a + 1; b < group.length; b += 1) { - const second = group[b]; - const brushB = brushes[second]; - if (!brushB) continue; - if ( - !rangesOverlap(brushA.r0, brushA.r1, brushB.r0, brushB.r1) - && !rangesOverlap(brushA.c0, brushA.c1, brushB.c0, brushB.c1) - ) { - continue; - } - - const replacement = mergedBounds(brushA, brushB); - const extraArea = rectArea(replacement) - rectArea(brushA) - rectArea(brushB); - out.push({ first, second, replacement, extraArea }); - } - } - } - - out.sort((a, b) => a.extraArea - b.extraArea || a.first - b.first || a.second - b.second); - return out; -}; - -const applySpanMergeBatch = ( - current: Brush[], - candidates: SpanMergeCandidate[], - buffer: FaceBuffer, - allowMask: Uint8Array | null, - paletteIds: Map -): Brush[] | null => { - if (!candidates.length) return null; - - const paintState = buildLastPaintIndices(current, buffer, paletteIds); - if (!paintState) return null; - const { lastPaint, previousPaint, brushColorIds } = paintState; - const used = new Uint8Array(current.length); - const remove = new Uint8Array(current.length); - const replace: (Brush | undefined)[] = []; - let replaceCount = 0; - const replacementBounds: Rect[] = []; - - for (const candidate of candidates) { - const { first, second, replacement } = candidate; - if (used[first] || used[second]) continue; - let overlaps = false; - for (const bounds of replacementBounds) { - if (rectsOverlap(bounds, replacement)) { - overlaps = true; - break; - } - } - if (overlaps) continue; - - const firstBrush = current[first]; - const secondBrush = current[second]; - if (!firstBrush || !secondBrush || firstBrush.baseColor !== secondBrush.baseColor) continue; - - if ( - verifySpanMergeByLastPaint( - buffer, - allowMask, - paletteIds, - lastPaint, - previousPaint, - brushColorIds, - first, - second, - replacement, - true - ) - ) { - used[first] = 1; - used[second] = 1; - replace[first] = replacement; - replaceCount += 1; - remove[second] = 1; - replacementBounds.push(replacement); - continue; - } - - if ( - verifySpanMergeByLastPaint( - buffer, - allowMask, - paletteIds, - lastPaint, - previousPaint, - brushColorIds, - first, - second, - replacement, - false - ) - ) { - used[first] = 1; - used[second] = 1; - remove[first] = 1; - replace[second] = replacement; - replaceCount += 1; - replacementBounds.push(replacement); - } - } - - if (!replaceCount) return null; - - const accepted: Brush[] = []; - for (let i = 0; i < current.length; i += 1) { - if (remove[i]) continue; - accepted.push(replace[i] ?? current[i]); - } - - return accepted; -}; - -const optimizeSpanOverdraw = ( - brushes: Brush[], - buffer: FaceBuffer, - allowMask: Uint8Array | null, - paletteIds: Map -): Brush[] => { - if (brushes.length < 2) return brushes; - - let current = brushes; - let changed = false; - - for (;;) { - const adjacent = applySpanMergeBatch( - current, - collectAdjacentSpanMergeCandidates(current), - buffer, - allowMask, - paletteIds - ); - if (adjacent) { - current = adjacent; - changed = true; - continue; - } - - const overlapping = applySpanMergeBatch( - current, - collectOverlappingSpanMergeCandidates(current), - buffer, - allowMask, - paletteIds - ); - if (!overlapping) break; - - current = overlapping; - changed = true; - } - - return changed && current.length < brushes.length && verify(current, buffer, allowMask, paletteIds) - ? current - : brushes; -}; - -const canDropBrush = ( - brush: Brush, - brushIndex: number, - buffer: FaceBuffer, - lastPaint: Int32Array, - previousPaint: Int32Array, - brushColorIds: Int32Array -): boolean => { - const colorId = brushColorIds[brushIndex] ?? 0; - if (!colorId) return false; - - for (let r = brush.r0; r < brush.r1; r += 1) { - const rowBase = r * buffer.width; - for (let c = brush.c0; c < brush.c1; c += 1) { - const index = rowBase + c; - if (lastPaint[index] !== brushIndex) continue; - - const previousIndex = previousPaint[index] ?? -1; - if (previousIndex < 0 || brushColorIds[previousIndex] !== colorId) return false; - } - } - - return true; -}; - -const dropRedundantBrushes = ( - brushes: Brush[], - buffer: FaceBuffer, - allowMask: Uint8Array | null, - paletteIds: Map -): Brush[] => { - if (brushes.length < 2) return brushes; - - let current = brushes; - let changed = false; - - for (;;) { - const paintState = buildLastPaintIndices(current, buffer, paletteIds); - if (!paintState) break; - - const { lastPaint, previousPaint, brushColorIds } = paintState; - let removedIndex = -1; - for (let i = 0; i < current.length; i += 1) { - const brush = current[i]; - if (!brush) continue; - if (canDropBrush(brush, i, buffer, lastPaint, previousPaint, brushColorIds)) { - removedIndex = i; - break; - } - } - - if (removedIndex < 0) break; - - const next: Brush[] = []; - for (let i = 0; i < current.length; i += 1) { - if (i !== removedIndex) next.push(current[i]); - } - current = next; - changed = true; - } - - return changed && current.length < brushes.length && verify(current, buffer, allowMask, paletteIds) - ? current - : brushes; -}; - -const collectColorIds = (buffer: FaceBuffer): number[] => { - const seen = new Set(); - for (const id of buffer.ids) { - if (id) seen.add(id); - } - return Array.from(seen).sort((a, b) => a - b); -}; - -type ReverseRunRect = Rect & { - colorId: number; - gain: number; - area: number; -}; - -const findBestReverseRunRect = ( - buffer: FaceBuffer, - safe: Uint8Array, - colors: readonly number[], - allowMask: Uint8Array | null -): ReverseRunRect | null => { - const { width, height, ids } = buffer; - let best: ReverseRunRect | null = null; - - const canCover = (index: number, colorId: number): boolean => - !!safe[index] || ids[index] === colorId || (!!allowMask && !ids[index] && !!allowMask[index]); - - const consider = (colorId: number, r0: number, c0: number, r1: number, c1: number): void => { - let gain = 0; - for (let r = r0; r < r1; r += 1) { - const rowBase = r * width; - for (let c = c0; c < c1; c += 1) { - const index = rowBase + c; - const id = ids[index]; - if (!canCover(index, colorId)) return; - if (!safe[index] && id === colorId) gain += 1; - } - } - if (!gain) return; - const area = (r1 - r0) * (c1 - c0); - if (!best || gain > best.gain || (gain === best.gain && area > best.area)) { - best = { colorId, r0, c0, r1, c1, gain, area }; - } - }; - - for (const colorId of colors) { - for (let r = 0; r < height; r += 1) { - const rowBase = r * width; - let c = 0; - while (c < width) { - while (c < width && (safe[rowBase + c] || ids[rowBase + c] !== colorId)) c += 1; - const start = c; - while (c < width && !safe[rowBase + c] && ids[rowBase + c] === colorId) c += 1; - if (start === c) continue; - - let r0 = r; - growUp: - while (r0 > 0) { - const nextRow = (r0 - 1) * width; - for (let x = start; x < c; x += 1) { - const index = nextRow + x; - if (!canCover(index, colorId)) break growUp; - } - r0 -= 1; - } - - let r1 = r + 1; - growDown: - while (r1 < height) { - const nextRow = r1 * width; - for (let x = start; x < c; x += 1) { - const index = nextRow + x; - if (!canCover(index, colorId)) break growDown; - } - r1 += 1; - } - - consider(colorId, r0, start, r1, c); - } - } - - for (let c = 0; c < width; c += 1) { - let r = 0; - while (r < height) { - while (r < height && (safe[r * width + c] || ids[r * width + c] !== colorId)) r += 1; - const start = r; - while (r < height && !safe[r * width + c] && ids[r * width + c] === colorId) r += 1; - if (start === r) continue; - - let c0 = c; - growLeft: - while (c0 > 0) { - for (let y = start; y < r; y += 1) { - const index = y * width + c0 - 1; - if (!canCover(index, colorId)) break growLeft; - } - c0 -= 1; - } - - let c1 = c + 1; - growRight: - while (c1 < width) { - for (let y = start; y < r; y += 1) { - const index = y * width + c1; - if (!canCover(index, colorId)) break growRight; - } - c1 += 1; - } - - consider(colorId, start, c0, r, c1); - } - } - } - - return best; -}; - -const evaluateReverseRunVariant = ( - buffer: FaceBuffer, - paletteIds: Map, - limit: number, - allowMask: Uint8Array | null = null -): Brush[] | null => { - const colorCount = buffer.palette.length - 1; - const area = buffer.width * buffer.height; - let maxColors = allowMask ? 4 : 3; - if (allowMask) { - if (limit < 20 || limit > 64 || colorCount < 2 || colorCount > 4) return null; - } else { - const compactMultiColor = limit >= 45 - && limit <= 64 - && colorCount >= 4 - && colorCount <= 5 - && buffer.filledCount <= 650 - && area <= 1200; - if (compactMultiColor) maxColors = 5; - else if (limit < 150 || colorCount < 2 || colorCount > 3) return null; - } - - const colors = collectColorIds(buffer); - if (colors.length < 2 || colors.length > maxColors) return null; - - const safe = new Uint8Array(buffer.ids.length); - let remaining = buffer.filledCount; - const reverse: Brush[] = []; - - while (remaining > 0) { - if (reverse.length >= limit - 1) return null; - - const rect = findBestReverseRunRect(buffer, safe, colors, allowMask); - if (!rect) return null; - - const fill = buffer.palette[rect.colorId] ?? ""; - if (!fill) return null; - reverse.push({ r0: rect.r0, c0: rect.c0, r1: rect.r1, c1: rect.c1, baseColor: fill }); - - for (let r = rect.r0; r < rect.r1; r += 1) { - const rowBase = r * buffer.width; - for (let c = rect.c0; c < rect.c1; c += 1) { - const index = rowBase + c; - if (!safe[index] && buffer.ids[index] === rect.colorId) { - safe[index] = 1; - remaining -= 1; - } - } - } - } - - if (reverse.length >= limit) return null; - - const brushes = reverse.reverse(); - return verify(brushes, buffer, allowMask, paletteIds) ? brushes : null; -}; - -const evaluateSingleColorGreedyVariant = ( - buffer: FaceBuffer, - holeFill: HoleFill, - paletteIds: Map, - limit: number -): Brush[] | null => { - if (buffer.palette.length !== 2 || limit <= 2 || limit > 32) return null; - - const fill = buffer.palette[1] ?? ""; - if (!fill) return null; - - const rects = greedyRectsForMask(holeFill.mask.slice(), buffer.width, buffer.height, limit); - if (!rects || rects.length >= limit) return null; - - const brushes = rects.map((rect) => ({ ...rect, baseColor: fill })); - return verify(brushes, buffer, holeFill.allowMask, paletteIds) ? brushes : null; -}; - -type SetCoverRect = Rect & { - bits: bigint; - count: number; - area: number; -}; - -const SINGLE_COLOR_SET_COVER_MAX_LIMIT = 42; -const SINGLE_COLOR_SET_COVER_MAX_TARGETS = 400; -const SINGLE_COLOR_SET_COVER_MAX_CANDIDATES = 5200; -const SINGLE_COLOR_SET_COVER_MIN_HOLE_FILL = 20; -const SINGLE_COLOR_SET_COVER_MAX_SOLID_AREA = 128; -const SINGLE_COLOR_SET_COVER_MAX_SOLID_TARGETS = 80; -const TWO_COLOR_SET_COVER_MAX_LIMIT = 32; -const TWO_COLOR_SET_COVER_MAX_TARGETS = 160; -const TWO_COLOR_SET_COVER_MAX_AREA = 1600; -const SINGLE_COLOR_SET_COVER_CACHE_MAX = 256; -const SINGLE_COLOR_SET_COVER_MISS: Rect[] = []; -const singleColorSetCoverCache = new Map(); - -const bitCountBigInt = (rawBits: bigint): number => { - let bits = rawBits; - let count = 0; - while (bits) { - bits &= bits - 1n; - count += 1; - } - return count; -}; - -const generateSetCoverRects = ( - mask: Uint8Array, - width: number, - height: number, - targetBitsByCell: bigint[] -): SetCoverRect[] | null => { - const rectsByBits = new Map(); - const columnAllowed = new Uint8Array(width); - const columnBits: bigint[] = new Array(width).fill(0n); - - const addRect = (bits: bigint, r0: number, c0: number, r1: number, c1: number): boolean => { - if (!bits) return true; - - const area = (r1 - r0) * (c1 - c0); - const existing = rectsByBits.get(bits); - if ( - !existing - || area < existing.area - || (area === existing.area && (r1 - r0) > (existing.r1 - existing.r0)) - ) { - rectsByBits.set(bits, { - r0, - c0, - r1, - c1, - bits, - count: bitCountBigInt(bits), - area - }); - if (rectsByBits.size > SINGLE_COLOR_SET_COVER_MAX_CANDIDATES) return false; - } - - return true; - }; - - for (let r0 = 0; r0 < height; r0 += 1) { - columnAllowed.fill(1); - columnBits.fill(0n); - - for (let r1 = r0; r1 < height; r1 += 1) { - const rowBase = r1 * width; - for (let c = 0; c < width; c += 1) { - if (!columnAllowed[c]) continue; - const index = rowBase + c; - if (!mask[index]) { - columnAllowed[c] = 0; - columnBits[c] = 0n; - continue; - } - columnBits[c] |= targetBitsByCell[index] ?? 0n; - } - - let c0 = 0; - while (c0 < width) { - while (c0 < width && !columnAllowed[c0]) c0 += 1; - if (c0 >= width) break; - - let bits = 0n; - let c1 = c0; - while (c1 < width && columnAllowed[c1]) { - bits |= columnBits[c1] ?? 0n; - c1 += 1; - if (!addRect(bits, r0, c0, r1 + 1, c1)) return null; - } - - c0 = c1 + 1; - } - } - } - - const rects = Array.from(rectsByBits.values()); - rects.sort((a, b) => b.count - a.count || a.area - b.area || a.r0 - b.r0 || a.c0 - b.c0); - return rects; -}; - -const greedySetCoverRects = ( - mask: Uint8Array, - width: number, - height: number, - targetBitsByCell: bigint[], - fullBits: bigint, - limit: number -): Rect[] | null => { - const rects = generateSetCoverRects(mask, width, height, targetBitsByCell); - if (!rects || !rects.length) return null; - - const greedySolution: number[] = []; - let greedyUncovered = fullBits; - while (greedyUncovered && greedySolution.length < limit) { - let bestRectIndex = -1; - let bestGain = 0; - - for (let rectIndex = 0; rectIndex < rects.length; rectIndex += 1) { - const rect = rects[rectIndex]; - if (!rect) continue; - if (rect.count <= bestGain) break; - const gain = bitCountBigInt(rect.bits & greedyUncovered); - if (gain > bestGain) { - bestGain = gain; - bestRectIndex = rectIndex; - } - } - - if (bestRectIndex < 0 || !bestGain) break; - greedySolution.push(bestRectIndex); - greedyUncovered &= ~(rects[bestRectIndex]?.bits ?? 0n); - } - - if (greedyUncovered || greedySolution.length >= limit) return null; - - return greedySolution.map((rectIndex) => { - const rect = rects[rectIndex] as SetCoverRect; - return { r0: rect.r0, c0: rect.c0, r1: rect.r1, c1: rect.c1 }; - }); -}; - -const appendMaskRuns = (parts: string[], mask: Uint8Array): void => { - if (!mask.length) { - parts.push("e"); - return; - } - - let current = mask[0] ? 1 : 0; - let count = 1; - for (let i = 1; i < mask.length; i += 1) { - const next = mask[i] ? 1 : 0; - if (next === current) { - count += 1; - continue; - } - parts.push(current ? "1" : "0", count.toString(36), ","); - current = next; - count = 1; - } - parts.push(current ? "1" : "0", count.toString(36)); -}; - -const singleColorSetCoverCacheKey = (buffer: FaceBuffer, holeFill: HoleFill, limit: number): string => { - const parts = [buffer.width.toString(36), "x", buffer.height.toString(36), ":", limit.toString(36), ":"]; - appendMaskRuns(parts, buffer.mask); - parts.push(":"); - appendMaskRuns(parts, holeFill.mask); - return parts.join(""); -}; - -const rememberSingleColorSetCover = (key: string, rects: Rect[] | null): void => { - if (singleColorSetCoverCache.size >= SINGLE_COLOR_SET_COVER_CACHE_MAX) singleColorSetCoverCache.clear(); - singleColorSetCoverCache.set(key, rects ?? SINGLE_COLOR_SET_COVER_MISS); -}; - -const evaluateSingleColorSetCoverVariant = ( - buffer: FaceBuffer, - holeFill: HoleFill, - paletteIds: Map, - limit: number -): Brush[] | null => { - if (buffer.palette.length !== 2 || limit <= 2 || limit > SINGLE_COLOR_SET_COVER_MAX_LIMIT) return null; - if (buffer.filledCount > SINGLE_COLOR_SET_COVER_MAX_TARGETS) return null; - - if (holeFill.allowMask) { - if (holeFill.filledCount - buffer.filledCount < SINGLE_COLOR_SET_COVER_MIN_HOLE_FILL) return null; - } else if ( - buffer.width * buffer.height > SINGLE_COLOR_SET_COVER_MAX_SOLID_AREA - || buffer.filledCount > SINGLE_COLOR_SET_COVER_MAX_SOLID_TARGETS - ) { - return null; - } - - const fill = buffer.palette[1] ?? ""; - if (!fill) return null; - - const cacheKey = singleColorSetCoverCacheKey(buffer, holeFill, limit); - const cachedRects = singleColorSetCoverCache.get(cacheKey); - if (cachedRects) { - if (cachedRects === SINGLE_COLOR_SET_COVER_MISS) return null; - const brushes = cachedRects.map((rect) => ({ ...rect, baseColor: fill })); - return verify(brushes, buffer, holeFill.allowMask, paletteIds) ? brushes : null; - } - - const targetBitsByCell = new Array(buffer.ids.length).fill(0n); - let targetCount = 0; - let fullBits = 0n; - - for (let i = 0; i < buffer.ids.length; i += 1) { - if (buffer.ids[i] !== 1) continue; - const bit = 1n << BigInt(targetCount); - targetBitsByCell[i] = bit; - targetCount += 1; - fullBits |= bit; - } - - if (!fullBits || targetCount > SINGLE_COLOR_SET_COVER_MAX_TARGETS) { - rememberSingleColorSetCover(cacheKey, null); - return null; - } - - const rects = greedySetCoverRects( - holeFill.mask, - buffer.width, - buffer.height, - targetBitsByCell, - fullBits, - limit - ); - rememberSingleColorSetCover(cacheKey, rects); - if (!rects) return null; - - const brushes = rects.map((rect) => ({ ...rect, baseColor: fill })); - - return verify(brushes, buffer, holeFill.allowMask, paletteIds) ? brushes : null; -}; - -const evaluateTwoColorSetCoverVariant = ( - buffer: FaceBuffer, - holeFill: HoleFill, - paletteIds: Map, - limit: number -): Brush[] | null => { - if (limit <= 2 || limit > TWO_COLOR_SET_COVER_MAX_LIMIT) return null; - if (buffer.filledCount > TWO_COLOR_SET_COVER_MAX_TARGETS) return null; - - const area = buffer.width * buffer.height; - if (area > TWO_COLOR_SET_COVER_MAX_AREA) return null; - - const shortSide = Math.max(1, Math.min(buffer.width, buffer.height)); - const longSide = Math.max(buffer.width, buffer.height); - if (holeFill.allowMask) { - if (area > 800) return null; - if (area > 64 && (area < 700 || buffer.filledCount < 140)) return null; - } else if ( - area > 64 - && (limit < 20 || buffer.filledCount < 100 || longSide < shortSide * 4) - ) { - return null; - } - - const colors = collectColorIds(buffer); - if (colors.length !== 2) return null; - - let best: Brush[] | null = null; - const orders = [colors, [colors[1] ?? 0, colors[0] ?? 0]]; - - for (const order of orders) { - const brushes: Brush[] = []; - for (let orderIndex = 0; orderIndex < order.length; orderIndex += 1) { - const paintColorId = order[orderIndex] ?? 0; - const futureBrushMinimum = order.length - orderIndex - 1; - const remainingLimit = limit - brushes.length - futureBrushMinimum; - if (remainingLimit <= 1) break; - - const active = new Set(order.slice(orderIndex)); - const mask = holeFill.allowMask ? holeFill.allowMask.slice() : new Uint8Array(buffer.ids.length); - const targetBitsByCell = new Array(buffer.ids.length).fill(0n); - let fullBits = 0n; - let targetCount = 0; - - for (let i = 0; i < buffer.ids.length; i += 1) { - const id = buffer.ids[i]; - if (!active.has(id)) continue; - mask[i] = 1; - const bit = 1n << BigInt(targetCount); - targetBitsByCell[i] = bit; - targetCount += 1; - fullBits |= bit; - } - - if (!fullBits || targetCount > TWO_COLOR_SET_COVER_MAX_TARGETS) { - brushes.length = limit; - break; - } - - const rects = greedySetCoverRects( - mask, - buffer.width, - buffer.height, - targetBitsByCell, - fullBits, - remainingLimit - ); - if (!rects) { - brushes.length = limit; - break; - } - - const fill = buffer.palette[paintColorId] ?? ""; - if (!fill) { - brushes.length = limit; - break; - } - for (const rect of rects) brushes.push({ ...rect, baseColor: fill }); - if (brushes.length >= limit) break; - } - - if (brushes.length < limit && verify(brushes, buffer, holeFill.allowMask, paletteIds)) { - if (!best || brushes.length < best.length) best = brushes; - } - } - - return best; -}; - -const withoutColor = (colors: readonly number[], colorId: number): number[] => { - const out: number[] = []; - for (const id of colors) { - if (id !== colorId) out.push(id); - } - return out; -}; - -const buildOrderedMask = (buffer: FaceBuffer, colors: readonly number[], allowMask: Uint8Array | null): Uint8Array => { - const active = new Set(colors); - const mask = allowMask ? allowMask.slice() : new Uint8Array(buffer.ids.length); - const ids = buffer.ids; - for (let i = 0; i < ids.length; i += 1) { - if (active.has(ids[i])) mask[i] = 1; - } - return mask; -}; - -const allowMaskCanBridgeColorRuns = (buffer: FaceBuffer, allowMask: Uint8Array): boolean => { - const { width, height, ids } = buffer; - - for (let r = 0; r < height; r += 1) { - const rowBase = r * width; - let c = 0; - while (c < width) { - while (c < width && !allowMask[rowBase + c]) c += 1; - const start = c; - while (c < width && allowMask[rowBase + c]) c += 1; - if (start === c) continue; - - const left = start > 0 ? ids[rowBase + start - 1] : 0; - const right = c < width ? ids[rowBase + c] : 0; - if (left && left === right) return true; - } - } - - for (let c = 0; c < width; c += 1) { - let r = 0; - while (r < height) { - while (r < height && !allowMask[r * width + c]) r += 1; - const start = r; - while (r < height && allowMask[r * width + c]) r += 1; - if (start === r) continue; - - const above = start > 0 ? ids[(start - 1) * width + c] : 0; - const below = r < height ? ids[r * width + c] : 0; - if (above && above === below) return true; - } - } - - return false; -}; - -const orderedRectsForColors = ( - buffer: FaceBuffer, - colors: readonly number[], - allowMask: Uint8Array | null, - cache: Map -): Rect[] => { - const key = colors.join(","); - const cached = cache.get(key); - if (cached) return cached; - - const mask = buildOrderedMask(buffer, colors, allowMask); - const rects = pickRectsForMask(mask, buffer.width, buffer.height); - cache.set(key, rects); - return rects; -}; - -const evaluateDenseOrderedVariant = ( - buffer: FaceBuffer, - holeFill: HoleFill, - paletteIds: Map, - limit: number -): Brush[] | null => { - const colorCount = buffer.palette.length - 1; - if (colorCount < 3 || limit <= 1) return null; - - const area = holeFill.mask.length; - if (holeFill.filledCount * (colorCount + 2) < area * colorCount) return null; - - const currentRects = pickRectsForMask(holeFill.mask, buffer.width, buffer.height); - if (currentRects.length + colorCount - 1 >= limit) return null; - - const colors = collectColorIds(buffer); - const cache = new Map(); - cache.set(colors.join(","), currentRects); - const remaining = colors.slice(); - const brushes: Brush[] = []; - - while (remaining.length) { - const currentRects = orderedRectsForColors(buffer, remaining, holeFill.allowMask, cache); - const minimumFutureBrushes = remaining.length - 1; - if (brushes.length + currentRects.length + minimumFutureBrushes >= limit) return null; - - let chosen = remaining[0] ?? 0; - let bestNextCount = Number.MAX_SAFE_INTEGER; - let bestExactCount = -1; - - for (const colorId of remaining) { - const next = withoutColor(remaining, colorId); - const nextCount = next.length ? orderedRectsForColors(buffer, next, holeFill.allowMask, cache).length : 0; - const exactCount = orderedRectsForColors(buffer, [colorId], holeFill.allowMask, cache).length; - if ( - nextCount < bestNextCount - || (nextCount === bestNextCount && exactCount > bestExactCount) - || (nextCount === bestNextCount && exactCount === bestExactCount && colorId < chosen) - ) { - chosen = colorId; - bestNextCount = nextCount; - bestExactCount = exactCount; - } - } - - if (brushes.length + currentRects.length + bestNextCount + Math.max(0, remaining.length - 2) >= limit) return null; - - const fill = buffer.palette[chosen] ?? ""; - if (!fill) return null; - for (const rect of currentRects) brushes.push({ ...rect, baseColor: fill }); - - const nextRemaining = withoutColor(remaining, chosen); - remaining.length = 0; - remaining.push(...nextRemaining); - } - - return brushes.length < limit && verify(brushes, buffer, holeFill.allowMask, paletteIds) ? brushes : null; -}; - -const evaluateExactColorVariant = ( - buffer: FaceBuffer, - paletteIds: Map, - limit: number, - allowMask: Uint8Array | null = null -): Brush[] | null => { - const colorCount = buffer.palette.length - 1; - if (colorCount < (allowMask ? 1 : 2) || colorCount >= limit) return null; - - const { width, height, ids, palette } = buffer; - const rowRectsByColor: Rect[][] = []; - const colRectsByColor: Rect[][] = []; - const rowRuns: Rect[] = []; - const rowRunColorIds: number[] = []; - const parent: number[] = []; - const brushes: Brush[] = []; - const componentCountsByColor: number[] = []; - let componentCount = 0; - - const find = (index: number): number => { - let root = index; - while (parent[root] !== root) root = parent[root] ?? root; - while (parent[index] !== index) { - const next = parent[index] ?? index; - parent[index] = root; - index = next; - } - return root; - }; - - const union = (a: number, b: number): void => { - const rootA = find(a); - const rootB = find(b); - if (rootA !== rootB) { - parent[rootB] = rootA; - componentCount -= 1; - const colorId = rowRunColorIds[rootA] ?? rowRunColorIds[rootB] ?? 0; - componentCountsByColor[colorId] = Math.max(0, (componentCountsByColor[colorId] ?? 1) - 1); - } - }; - - let previousStart = 0; - let previousEnd = 0; - let currentRow = -1; - let currentStart = 0; - - for (let r = 0; r < height; r += 1) { - const rowBase = r * width; - let c = 0; - while (c < width) { - const colorId = ids[rowBase + c]; - if (!colorId) { - c += 1; - continue; - } - - const c0 = c; - c += 1; - while (c < width && (ids[rowBase + c] === colorId || (allowMask && allowMask[rowBase + c]))) c += 1; - const rect = { r0: r, c0, r1: r + 1, c1: c }; - const runIndex = rowRuns.length; - rowRuns.push(rect); - rowRunColorIds.push(colorId); - parent.push(runIndex); - componentCount += 1; - componentCountsByColor[colorId] = (componentCountsByColor[colorId] ?? 0) + 1; - const rowRects = rowRectsByColor[colorId]; - if (rowRects) rowRects.push(rect); - else rowRectsByColor[colorId] = [rect]; - - if (r !== currentRow) { - if (r === currentRow + 1) { - previousStart = currentStart; - previousEnd = runIndex; - } else { - previousStart = runIndex; - previousEnd = runIndex; - } - currentRow = r; - currentStart = runIndex; - } - - for (let previousIndex = previousStart; previousIndex < previousEnd; previousIndex += 1) { - const previous = rowRuns[previousIndex]; - if (!previous) continue; - if (previous.c1 <= c0) continue; - if (previous.c0 >= c) break; - if (rowRunColorIds[previousIndex] === colorId) union(runIndex, previousIndex); - } - } - } - - if (componentCount >= limit) return null; - - for (let c = 0; c < width; c += 1) { - let r = 0; - while (r < height) { - const colorId = ids[r * width + c]; - if (!colorId) { - r += 1; - continue; - } - - const r0 = r; - r += 1; - while (r < height && (ids[r * width + c] === colorId || (allowMask && allowMask[r * width + c]))) r += 1; - const colRects = colRectsByColor[colorId]; - const rect = { r0, c0: c, r1: r, c1: c + 1 }; - if (colRects) colRects.push(rect); - else colRectsByColor[colorId] = [rect]; - } - } - - const colorCandidates: { - colorId: number; - fill: string; - rects: Rect[]; - rowRects: Rect[]; - componentCount: number; - }[] = []; - let totalRects = 0; - - for (let colorId = 1; colorId < palette.length; colorId += 1) { - const fill = palette[colorId] ?? ""; - if (!fill) return null; - - const rowRects = mergeAlignedRects(rowRectsByColor[colorId] ?? []); - const colRects = mergeAlignedRects(colRectsByColor[colorId] ?? []); - if (!rowRects.length && !colRects.length) continue; - - const rects = colRects.length && colRects.length < rowRects.length ? colRects : rowRects; - const colorComponentCount = componentCountsByColor[colorId] ?? rects.length; - totalRects += rects.length; - colorCandidates.push({ colorId, fill, rects, rowRects, componentCount: colorComponentCount }); - } - - if (totalRects < limit) { - for (const candidate of colorCandidates) { - if (candidate.rects.length <= candidate.componentCount) continue; - - const greedyRects = greedyRectsForRuns( - candidate.rowRects, - width, - height, - candidate.rects.length - ); - if (!greedyRects || greedyRects.length >= candidate.rects.length) continue; - - totalRects += greedyRects.length - candidate.rects.length; - candidate.rects = greedyRects; - } - } - - colorCandidates.sort((a, b) => { - const savingsA = a.rects.length - a.componentCount; - const savingsB = b.rects.length - b.componentCount; - return savingsB - savingsA || b.rects.length - a.rects.length; - }); - - if (totalRects >= limit) return null; - - for (const candidate of colorCandidates) { - for (const rect of candidate.rects) brushes.push({ ...rect, baseColor: candidate.fill }); - } - - return brushes.length < limit && verify(brushes, buffer, allowMask, paletteIds) ? brushes : null; -}; - -const evaluateVariant = (buffer: FaceBuffer, holeFill: HoleFill, paletteIds: Map): Brush[] | null => { - const bounds = { r0: 0, c0: 0, r1: buffer.height, c1: buffer.width }; - - let best: Brush[] | null = null; - let firstHostRectCount = -1; - let hasHostAxisConflict = false; - - for (const byColumn of [false, true]) { - const rects = mergeAlignedRects(runRects(holeFill.mask, buffer.width, bounds, byColumn)); - if (firstHostRectCount < 0) firstHostRectCount = rects.length; - else if (rects.length !== firstHostRectCount) hasHostAxisConflict = true; - - const brushes: Brush[] = []; - for (const host of rects) brushes.push(...emitHost(host, buffer)); - - if (!verify(brushes, buffer, holeFill.allowMask, paletteIds)) continue; - - let bestHere = brushes; - const aligned = mergeAligned(brushes); - if (aligned.length < bestHere.length && verify(aligned, buffer, holeFill.allowMask, paletteIds)) bestHere = aligned; - - if (!best || bestHere.length < best.length) best = bestHere; - } - - if (hasHostAxisConflict) { - const componentRects = componentRectsForMask(holeFill.mask, buffer.width, buffer.height); - if (!best || componentRects.length < best.length) { - const componentBrushes: Brush[] = []; - for (const host of componentRects) componentBrushes.push(...emitHost(host, buffer)); - - if (verify(componentBrushes, buffer, holeFill.allowMask, paletteIds)) { - let bestHere = componentBrushes; - const aligned = mergeAligned(componentBrushes); - if (aligned.length < bestHere.length && verify(aligned, buffer, holeFill.allowMask, paletteIds)) bestHere = aligned; - if (!best || bestHere.length < best.length) best = bestHere; - } - } - } - - return best; -}; - -export const buildSlicePlan = (faceData: FaceData, nextLayer: FaceBuffer | null): SlicePlan => { - const buffer = faceData.buffer; - const paletteIds = new Map(); - for (let i = 1; i < buffer.palette.length; i += 1) paletteIds.set(buffer.palette[i], i); - - const refineBrushes = (brushes: Brush[], allowMask: Uint8Array | null): Brush[] => { - let refined = brushes; - const optimized = optimizeSpanOverdraw(refined, buffer, allowMask, paletteIds); - if (optimized.length < refined.length) { - refined = optimized; - } - - const pruned = dropRedundantBrushes(refined, buffer, allowMask, paletteIds); - if (pruned.length < refined.length) refined = pruned; - - return refined; - }; - - let best: Brush[] | null = null; - let bestAllowMask: Uint8Array | null = null; - - const holeFills = holeFillVariants(buffer, nextLayer); - for (const holeFill of holeFills) { - const candidate = evaluateVariant(buffer, holeFill, paletteIds); - if (candidate && (!best || candidate.length < best.length)) { - best = candidate; - bestAllowMask = holeFill.allowMask; - } - - const orderedCandidate = evaluateDenseOrderedVariant( - buffer, - holeFill, - paletteIds, - best?.length ?? Number.MAX_SAFE_INTEGER - ); - if (orderedCandidate && (!best || orderedCandidate.length < best.length)) { - best = orderedCandidate; - bestAllowMask = holeFill.allowMask; - } - - } - - if (best) best = refineBrushes(best, bestAllowMask); - - let acceptedBridgedColorCandidate = false; - for (const holeFill of holeFills) { - if (!holeFill.allowMask) continue; - if (!allowMaskCanBridgeColorRuns(buffer, holeFill.allowMask)) continue; - const bridgedColorCandidate = evaluateExactColorVariant( - buffer, - paletteIds, - best?.length ?? Number.MAX_SAFE_INTEGER, - holeFill.allowMask - ); - if (bridgedColorCandidate && (!best || bridgedColorCandidate.length < best.length)) { - const refinedColorCandidate = refineBrushes(bridgedColorCandidate, holeFill.allowMask); - if (!best || refinedColorCandidate.length < best.length) { - best = refinedColorCandidate; - bestAllowMask = holeFill.allowMask; - acceptedBridgedColorCandidate = true; - } - } - } - - if (!acceptedBridgedColorCandidate) { - const colorCandidate = evaluateExactColorVariant(buffer, paletteIds, best?.length ?? Number.MAX_SAFE_INTEGER); - if (colorCandidate && (!best || colorCandidate.length < best.length)) { - const refinedColorCandidate = refineBrushes(colorCandidate, null); - if (!best || refinedColorCandidate.length < best.length) { - best = refinedColorCandidate; - bestAllowMask = null; - } - } - } - - if (best) { - const reverseRunCandidate = evaluateReverseRunVariant(buffer, paletteIds, best.length); - if (reverseRunCandidate && reverseRunCandidate.length < best.length) { - const refinedReverseRunCandidate = refineBrushes(reverseRunCandidate, null); - best = refinedReverseRunCandidate.length < reverseRunCandidate.length ? refinedReverseRunCandidate : reverseRunCandidate; - bestAllowMask = null; - } - - if (faceData.key.axis === "z" && faceData.key.face === "t") { - for (const holeFill of holeFills) { - if (!holeFill.allowMask) continue; - if (holeFill.filledCount - buffer.filledCount < 20) continue; - const reverseRunCandidate = evaluateReverseRunVariant(buffer, paletteIds, best.length, holeFill.allowMask); - if (reverseRunCandidate && reverseRunCandidate.length < best.length) { - const refinedReverseRunCandidate = refineBrushes(reverseRunCandidate, holeFill.allowMask); - best = refinedReverseRunCandidate.length < reverseRunCandidate.length ? refinedReverseRunCandidate : reverseRunCandidate; - bestAllowMask = holeFill.allowMask; - } - } - } - } - - if (best) { - for (const holeFill of holeFills) { - const twoColorSetCoverCandidate = evaluateTwoColorSetCoverVariant( - buffer, - holeFill, - paletteIds, - best.length - ); - if (twoColorSetCoverCandidate && twoColorSetCoverCandidate.length < best.length) { - best = twoColorSetCoverCandidate; - bestAllowMask = holeFill.allowMask; - } - - if (!holeFill.allowMask) { - const singleColorSetCoverCandidate = evaluateSingleColorSetCoverVariant( - buffer, - holeFill, - paletteIds, - best.length - ); - if (singleColorSetCoverCandidate && singleColorSetCoverCandidate.length < best.length) { - best = singleColorSetCoverCandidate; - bestAllowMask = holeFill.allowMask; - } - } - - if (faceData.key.axis === "z") { - const singleColorGreedyCandidate = evaluateSingleColorGreedyVariant( - buffer, - holeFill, - paletteIds, - best.length - ); - if (singleColorGreedyCandidate && singleColorGreedyCandidate.length < best.length) { - best = singleColorGreedyCandidate; - bestAllowMask = holeFill.allowMask; - } - - if (holeFill.allowMask) { - const singleColorSetCoverCandidate = evaluateSingleColorSetCoverVariant( - buffer, - holeFill, - paletteIds, - best.length - ); - if (singleColorSetCoverCandidate && singleColorSetCoverCandidate.length < best.length) { - best = singleColorSetCoverCandidate; - bestAllowMask = holeFill.allowMask; - } - } - } - } - } - - return { key: faceData.key, buffer, brushes: best ?? [] }; -}; - -// --------------------------------------------------------------------------- -// Face data extraction from polycss voxel source -// --------------------------------------------------------------------------- - -export const buildFaceDataFromVoxelSource = (source: PolyVoxelSource): FaceData[] => { - const rows = Math.max(0, Math.floor(source.rows)); - const cols = Math.max(0, Math.floor(source.cols)); - const depth = Math.max(0, Math.floor(source.depth)); - if (rows <= 0 || cols <= 0 || depth <= 0 || source.cells.length === 0) return []; - - const strideXY = rows * cols; - const occupancy = new Int32Array(strideXY * depth); - const occupiedIndices: number[] = []; - const cellsByIndex = new Map(); - - for (const cell of source.cells) { - const x = Math.floor(cell.x); - const y = Math.floor(cell.y); - const z = Math.floor(cell.z); - if (x < 0 || x >= rows || y < 0 || y >= cols || z < 0 || z >= depth) continue; - const index = z * strideXY + x * cols + y; - if (occupancy[index]) continue; - occupancy[index] = 1; - occupiedIndices.push(index); - cellsByIndex.set(index, { color: cell.color || "#cccccc" }); - } - - type Builder = { - key: FaceKey; - minRow: number; - minCol: number; - maxRow: number; - maxCol: number; - cells: Array<{ row: number; col: number; color: string }>; - }; - - const builders = new Map(); - const addCell = ( - axis: PlaneAxis, - plane: number, - face: PolyVoxelFace, - color: string, - row: number, - col: number, - ): void => { - const keyStr = `${axis}:${plane}:${face}`; - let builder = builders.get(keyStr); - if (!builder) { - builder = { key: { axis, plane, face }, minRow: row, minCol: col, maxRow: row, maxCol: col, cells: [] }; - builders.set(keyStr, builder); - } - builder.cells.push({ row, col, color }); - if (row < builder.minRow) builder.minRow = row; - if (col < builder.minCol) builder.minCol = col; - if (row > builder.maxRow) builder.maxRow = row; - if (col > builder.maxCol) builder.maxCol = col; - }; - - const hasNeighbor = (x: number, y: number, z: number): boolean => - x >= 0 && x < rows && y >= 0 && y < cols && z >= 0 && z < depth && - occupancy[z * strideXY + x * cols + y] !== 0; - - for (const index of occupiedIndices) { - const z = Math.floor(index / strideXY); - const rem = index - z * strideXY; - const x = Math.floor(rem / cols); - const y = rem - x * cols; - const color = cellsByIndex.get(index)?.color ?? "#cccccc"; - - if (!hasNeighbor(x, y, z + 1)) addCell("z", z + 1, "t", color, x, y); - if (!hasNeighbor(x, y, z - 1)) addCell("z", z, "b", color, x, y); - if (!hasNeighbor(x, y - 1, z)) addCell("y", y, "bl", color, x, z); - if (!hasNeighbor(x, y + 1, z)) addCell("y", y + 1, "fr", color, x, z); - if (!hasNeighbor(x - 1, y, z)) addCell("x", x, "br", color, z, y); - if (!hasNeighbor(x + 1, y, z)) addCell("x", x + 1, "fl", color, z, y); - } - - const buildersList = Array.from(builders.values()).sort((a, b) => - AXIS_ORDER[a.key.axis] - AXIS_ORDER[b.key.axis] - || a.key.plane - b.key.plane - || (FACE_ORDER.get(a.key.face) ?? 0) - (FACE_ORDER.get(b.key.face) ?? 0) - ); - - const faces: FaceData[] = []; - for (const builder of buildersList) { - if (builder.cells.length > 1) { - builder.cells.sort((a, b) => (a.row !== b.row ? a.row - b.row : a.col - b.col)); - } - const width = builder.maxCol - builder.minCol + 1; - const height = builder.maxRow - builder.minRow + 1; - if (width <= 0 || height <= 0) continue; - - const ids = new Uint32Array(width * height); - const palette: string[] = [""]; - const colorIndex = new Map(); - let filledCount = 0; - - for (const cell of builder.cells) { - const rowOffset = cell.row - builder.minRow; - const colOffset = cell.col - builder.minCol; - if (rowOffset < 0 || colOffset < 0 || rowOffset >= height || colOffset >= width) continue; - const bufferIndex = rowOffset * width + colOffset; - let colorId = colorIndex.get(cell.color); - if (colorId === undefined) { - colorId = palette.length; - colorIndex.set(cell.color, colorId); - palette.push(cell.color); - } - if (!ids[bufferIndex]) filledCount += 1; - ids[bufferIndex] = colorId; - } - - if (filledCount === 0) continue; - const mask = new Uint8Array(ids.length); - for (let i = 0; i < ids.length; i += 1) if (ids[i]) mask[i] = 1; - - faces.push({ - key: builder.key, - buffer: { - width, - height, - minRow: builder.minRow, - minCol: builder.minCol, - ids, - mask, - filledCount, - palette, - }, - }); - } - - return faces; -}; diff --git a/packages/polycss/src/api/createPolyScene.test.ts b/packages/polycss/src/api/createPolyScene.test.ts index c8c43b5b..ab9b7e4c 100644 --- a/packages/polycss/src/api/createPolyScene.test.ts +++ b/packages/polycss/src/api/createPolyScene.test.ts @@ -29,6 +29,9 @@ function makeScene( return createPolyScene(host, { camera: makeCamera(cameraOpts), ...sceneOpts }); } +const DIRECT_VOXEL_FACE_SELECTOR = ".polycss-mesh > .polycss-voxel-face"; +const DIRECT_VOXEL_BRUSH_SELECTOR = `${DIRECT_VOXEL_FACE_SELECTOR} > b`; + function triangle(color = "#ff0000"): Polygon { return { vertices: [ @@ -219,6 +222,22 @@ function makeVoxelExactParseResult(): ParseResult { }; } +function makeVoxelExactPolygonsParseResult(polygons: Polygon[]): ParseResult { + return { + ...makeParseResult(polygons), + voxelSource: { + kind: "magica-vox", + cells: [{ x: 0, y: 0, z: 0, color: "#ff0000" }], + rows: 1, + cols: 1, + depth: 1, + scale: 1, + gridShift: 0, + sourceBytes: 64, + }, + }; +} + function makeTwoSidedVoxelExactParseResult(): ParseResult { return { ...makeParseResult([topQuad("#ff0000"), backTopQuad("#00ff00")]), @@ -279,6 +298,12 @@ function getSceneEl(host: HTMLElement): HTMLElement { return sceneEl!; } +function matrixValues(el: HTMLElement): number[] { + const match = el.style.transform.match(/^matrix3d\(([^)]+)\)$/); + expect(match).not.toBeNull(); + return match![1].split(",").map(Number); +} + /** Extract the innermost translate3d(...) from the scene transform value. */ function getSceneTranslatePart(host: HTMLElement): string { const t = getSceneEl(host).style.transform; @@ -429,8 +454,11 @@ describe("createPolyScene", () => { it("routes exact raw vox sources through the direct voxel renderer", () => { scene = makeScene(host); scene.add(makeVoxelExactParseResult(), { merge: false }); - const voxelBrushes = Array.from(host.querySelectorAll(".polycss-mesh > b")); + const voxelBrushes = Array.from(host.querySelectorAll(DIRECT_VOXEL_BRUSH_SELECTOR)); + const faceHosts = Array.from(host.querySelectorAll(DIRECT_VOXEL_FACE_SELECTOR)); expect(host.querySelector(".polycss-voxel-host-z")).toBeNull(); + expect(faceHosts.length).toBeGreaterThan(0); + expect(host.querySelector(".polycss-mesh > b")).toBeNull(); expect(voxelBrushes.length).toBeGreaterThan(0); expect(voxelBrushes.every((el) => el.tagName === "B")).toBe(true); expect(host.querySelector(".polycss-mesh")?.classList.contains("polycss-voxel-mesh")).toBe(true); @@ -453,7 +481,7 @@ describe("createPolyScene", () => { ambientLight: { color: "#ffffff", intensity: 0 }, }, { rotX: 0, rotY: 0 }); scene.add(makeVoxelExactParseResult(), { merge: false }); - const brush = host.querySelector(".polycss-mesh > b") as HTMLElement | null; + const brush = host.querySelector(DIRECT_VOXEL_BRUSH_SELECTOR) as HTMLElement | null; expect(brush).not.toBeNull(); expect(brush!.style.color).toMatch(/^(#000000|rgb\\(0, 0, 0\\))$/); }); @@ -464,7 +492,7 @@ describe("createPolyScene", () => { ambientLight: { color: "#ffffff", intensity: 1 }, }, { rotX: 65, rotY: 45 }); scene.add(makeVoxelExactParseResult(), { merge: false }); - const brush = host.querySelector(".polycss-mesh > b") as HTMLElement | null; + const brush = host.querySelector(DIRECT_VOXEL_BRUSH_SELECTOR) as HTMLElement | null; expect(brush).not.toBeNull(); expect(brush!.style.color).toMatch(/^(#123456|rgb\\(18, 52, 86\\))$/); expect(brush!.style.width).toBe(""); @@ -472,6 +500,45 @@ describe("createPolyScene", () => { expect(brush!.style.transform).toContain("matrix3d(50,0,0,0,0,50"); }); + it("adds tiny overscan to same-color shared direct voxel edges", () => { + scene = makeScene(host, { + directionalLight: { direction: [0, 0, 1], color: "#ffffff", intensity: 0 }, + ambientLight: { color: "#ffffff", intensity: 1 }, + }, { rotX: 65, rotY: 45 }); + scene.add(makeVoxelExactPolygonsParseResult([ + topQuad("#123456"), + sideQuad("#123456"), + ]), { merge: false }); + + const brushes = Array.from(host.querySelectorAll(DIRECT_VOXEL_BRUSH_SELECTOR)) as HTMLElement[]; + expect(brushes.length).toBeGreaterThan(0); + const matrices = brushes.map(matrixValues); + expect(matrices.some((values) => + values.some((value) => Math.abs(value - 50.6) <= 1e-6) + )).toBe(true); + }); + + it("keeps different-color shared direct voxel edges exact", () => { + scene = makeScene(host, { + directionalLight: { direction: [0, 0, 1], color: "#ffffff", intensity: 0 }, + ambientLight: { color: "#ffffff", intensity: 1 }, + }, { rotX: 65, rotY: 45 }); + scene.add(makeVoxelExactPolygonsParseResult([ + topQuad("#123456"), + sideQuad("#654321"), + ]), { merge: false }); + + const brushes = Array.from(host.querySelectorAll(DIRECT_VOXEL_BRUSH_SELECTOR)) as HTMLElement[]; + expect(brushes.length).toBeGreaterThan(0); + const matrices = brushes.map(matrixValues); + expect(matrices.every((values) => + values.every((value) => Math.abs(value - 50.6) > 1e-6) + )).toBe(true); + expect(matrices.every((values) => + values.every((value) => Math.abs(value + 0.6) > 1e-6) + )).toBe(true); + }); + it("uses a larger direct voxel primitive on mobile-class documents", () => { const originalMatchMedia = window.matchMedia; Object.defineProperty(window, "matchMedia", { @@ -494,7 +561,7 @@ describe("createPolyScene", () => { }, { rotX: 65, rotY: 45 }); scene.add(makeVoxelExactParseResult(), { merge: false }); const wrapper = host.querySelector(".polycss-mesh") as HTMLElement | null; - const brush = host.querySelector(".polycss-mesh > b") as HTMLElement | null; + const brush = host.querySelector(DIRECT_VOXEL_BRUSH_SELECTOR) as HTMLElement | null; expect(wrapper).not.toBeNull(); expect(brush).not.toBeNull(); expect(wrapper!.style.getPropertyValue("--polycss-voxel-primitive")).toBe("8px"); @@ -513,17 +580,17 @@ describe("createPolyScene", () => { scene = makeScene(host); scene.add(makeVoxelParseResult(), { merge: false }); expect(host.querySelector(".polycss-voxel-host-z")).toBeNull(); - expect(host.querySelector(".polycss-mesh > b")).toBeNull(); + expect(host.querySelector(DIRECT_VOXEL_BRUSH_SELECTOR)).toBeNull(); expect(host.querySelector("i,b,s,u")).not.toBeNull(); }); it("falls back to polygon rendering after setPolygons replaces vox source geometry", () => { scene = makeScene(host); const handle = scene.add(makeVoxelExactParseResult(), { merge: false }); - expect(host.querySelector(".polycss-mesh > b")).not.toBeNull(); + expect(host.querySelector(DIRECT_VOXEL_BRUSH_SELECTOR)).not.toBeNull(); handle.setPolygons([triangle()], { merge: false }); expect(host.querySelector(".polycss-voxel-host-z")).toBeNull(); - expect(host.querySelector(".polycss-mesh > b")).toBeNull(); + expect(host.querySelector(DIRECT_VOXEL_BRUSH_SELECTOR)).toBeNull(); expect(host.querySelector(".polycss-mesh")?.classList.contains("polycss-voxel-mesh")).toBe(false); expect(host.querySelector("i,b,s,u")).not.toBeNull(); }); @@ -1342,17 +1409,17 @@ describe("createPolyScene", () => { ambientLight: { color: "#ffffff", intensity: 1 }, }, { rotX: 0, rotY: 0 }); const handle = scene.add(makeTwoSidedVoxelExactParseResult()); - const firstBrush = host.querySelector(".polycss-mesh > b") as HTMLElement | null; + const firstBrush = host.querySelector(DIRECT_VOXEL_BRUSH_SELECTOR) as HTMLElement | null; expect(firstBrush).not.toBeNull(); expect(firstBrush!.style.color).toMatch(/^(#ff0000|rgb\(255, 0, 0\))$/); - expect(host.querySelectorAll(".polycss-mesh > b").length).toBe(1); + expect(host.querySelectorAll(DIRECT_VOXEL_BRUSH_SELECTOR).length).toBe(1); handle.setTransform({ rotation: [180, 0, 0] }); - const nextBrush = host.querySelector(".polycss-mesh > b") as HTMLElement | null; + const nextBrush = host.querySelector(DIRECT_VOXEL_BRUSH_SELECTOR) as HTMLElement | null; expect(nextBrush).not.toBeNull(); expect(nextBrush!.style.color).toMatch(/^(#00ff00|rgb\(0, 255, 0\))$/); - expect(host.querySelectorAll(".polycss-mesh > b").length).toBe(1); + expect(host.querySelectorAll(DIRECT_VOXEL_BRUSH_SELECTOR).length).toBe(1); }); it("updates direct voxel side brushes when mesh z-rotation swaps front and back faces", () => { @@ -1361,17 +1428,17 @@ describe("createPolyScene", () => { ambientLight: { color: "#ffffff", intensity: 1 }, }, { rotX: 65, rotY: 45 }); const handle = scene.add(makeTwoSidedVoxelSideParseResult()); - const firstBrush = host.querySelector(".polycss-mesh > b") as HTMLElement | null; + const firstBrush = host.querySelector(DIRECT_VOXEL_BRUSH_SELECTOR) as HTMLElement | null; expect(firstBrush).not.toBeNull(); expect(firstBrush!.style.color).toMatch(/^(#ff0000|rgb\(255, 0, 0\))$/); - expect(host.querySelectorAll(".polycss-mesh > b").length).toBe(1); + expect(host.querySelectorAll(DIRECT_VOXEL_BRUSH_SELECTOR).length).toBe(1); handle.setTransform({ rotation: [0, 0, 180] }); - const nextBrush = host.querySelector(".polycss-mesh > b") as HTMLElement | null; + const nextBrush = host.querySelector(DIRECT_VOXEL_BRUSH_SELECTOR) as HTMLElement | null; expect(nextBrush).not.toBeNull(); expect(nextBrush!.style.color).toMatch(/^(#00ff00|rgb\(0, 255, 0\))$/); - expect(host.querySelectorAll(".polycss-mesh > b").length).toBe(1); + expect(host.querySelectorAll(DIRECT_VOXEL_BRUSH_SELECTOR).length).toBe(1); }); it("redraws direct voxel brushes on mesh rotation even when visible faces stay the same", () => { @@ -1380,7 +1447,7 @@ describe("createPolyScene", () => { ambientLight: { color: "#ffffff", intensity: 1 }, }, { rotX: 0, rotY: 0 }); const handle = scene.add(makeTwoTopVoxelExactParseResult()); - const brushes = () => Array.from(host.querySelectorAll(".polycss-mesh > b")) as HTMLElement[]; + const brushes = () => Array.from(host.querySelectorAll(DIRECT_VOXEL_BRUSH_SELECTOR)) as HTMLElement[]; expect(brushes().map((brush) => brush.style.color)).toEqual(["#ff0000", "#00ff00"]); handle.setTransform({ rotation: [0, 0, 180] }); diff --git a/packages/polycss/src/render/voxelRenderer.ts b/packages/polycss/src/render/voxelRenderer.ts index bdabf4ef..0d690c24 100644 --- a/packages/polycss/src/render/voxelRenderer.ts +++ b/packages/polycss/src/render/voxelRenderer.ts @@ -8,12 +8,25 @@ import type { } from "@layoutit/polycss-core"; import { BASE_TILE, + computeProjectiveQuadMatrix, normalFacesCamera, parsePureColor, + PROJECTIVE_QUAD_BLEED, + resolveProjectiveQuadGuards, rotateVec3, + SOLID_QUAD_CANONICAL_SIZE, } from "@layoutit/polycss-core"; type Axis = "x" | "y" | "z"; +type VoxelSeamSide = "left" | "right" | "top" | "bottom"; +type WorldAxisIndex = 0 | 1 | 2; + +interface VoxelSeamBleed { + left: number; + right: number; + top: number; + bottom: number; +} interface BrushState { color?: string; @@ -24,8 +37,11 @@ type BrushElement = HTMLElement & { __polycssVoxelBrushState?: BrushState; }; +type FaceHostElement = HTMLElement & { + __polycssVoxelFaceHost?: true; +}; + export interface PolyVoxelRenderer { - readonly element: HTMLElement; readonly brushCount: number; render(rotation: CameraCullRotation): void; syncCamera(rotation: CameraCullRotation): void; @@ -52,6 +68,16 @@ interface DirectMatrixItem { z: number; baseColor: string; sourceIndex: number; + bleed: VoxelSeamBleed; +} + +interface VoxelSeamSegment { + item: DirectMatrixItem; + side: VoxelSeamSide; + variableAxis: WorldAxisIndex; + fixed: [number, number, number]; + start: number; + end: number; } const DEFAULT_LIGHT_DIR: Vec3 = [0.4, -0.7, 0.59]; @@ -61,6 +87,9 @@ const DEFAULT_AMBIENT_COLOR = "#ffffff"; const DEFAULT_AMBIENT_INTENSITY = 0.4; const DESKTOP_PRIMITIVE_SIZE = 1; const MOBILE_PRIMITIVE_SIZE = 8; +const VOXEL_SEAM_BLEED = PROJECTIVE_QUAD_BLEED; +const VOXEL_SEAM_EPS = 1e-6; +const VOXEL_PROJECTIVE_QUAD_GUARDS = resolveProjectiveQuadGuards({ bleed: 0 }); const FACE_NORMALS: Record = { t: [0, 0, 1], @@ -169,6 +198,7 @@ function polygonBrush(polygon: Polygon): Omit | height: Math.max(0, (maxX - minX) * BASE_TILE), z: minZ * BASE_TILE, baseColor, + bleed: zeroVoxelSeamBleed(), }; } if (Math.abs(maxX - minX) <= eps) { @@ -181,6 +211,7 @@ function polygonBrush(polygon: Polygon): Omit | height: Math.max(0, (maxZ - minZ) * BASE_TILE), z: -minX * BASE_TILE, baseColor, + bleed: zeroVoxelSeamBleed(), }; } if (Math.abs(maxY - minY) <= eps) { @@ -193,11 +224,120 @@ function polygonBrush(polygon: Polygon): Omit | height: Math.max(0, (maxX - minX) * BASE_TILE), z: -minY * BASE_TILE, baseColor, + bleed: zeroVoxelSeamBleed(), }; } return null; } +function zeroVoxelSeamBleed(): VoxelSeamBleed { + return { left: 0, right: 0, top: 0, bottom: 0 }; +} + +function worldLineKey(segment: VoxelSeamSegment): string { + const coordKey = (value: number): string => String(Number(value.toFixed(6))); + let key = `${segment.item.baseColor}|${segment.variableAxis}`; + for (let axis = 0; axis < 3; axis += 1) { + if (axis === segment.variableAxis) continue; + key += `|${axis}:${coordKey(segment.fixed[axis])}`; + } + return key; +} + +function cssPointForVertex(v: Vec3): Vec3 { + return [v[1] * BASE_TILE, v[0] * BASE_TILE, v[2] * BASE_TILE]; +} + +function localPointForItem(item: DirectMatrixItem, p: Vec3): [number, number] { + if (item.axis === "x") return [p[0], p[2]]; + if (item.axis === "y") return [p[2], p[1]]; + return [p[0], p[1]]; +} + +function sideForLocalEdge( + item: DirectMatrixItem, + a: [number, number], + b: [number, number], +): VoxelSeamSide | null { + const left = item.left; + const right = item.left + item.width; + const top = item.top; + const bottom = item.top + item.height; + if (Math.abs(a[0] - b[0]) <= VOXEL_SEAM_EPS) { + if (Math.abs(a[0] - left) <= VOXEL_SEAM_EPS) return "left"; + if (Math.abs(a[0] - right) <= VOXEL_SEAM_EPS) return "right"; + } + if (Math.abs(a[1] - b[1]) <= VOXEL_SEAM_EPS) { + if (Math.abs(a[1] - top) <= VOXEL_SEAM_EPS) return "top"; + if (Math.abs(a[1] - bottom) <= VOXEL_SEAM_EPS) return "bottom"; + } + return null; +} + +function variableAxisForSegment(a: Vec3, b: Vec3): WorldAxisIndex | null { + let axis: WorldAxisIndex | null = null; + for (let i = 0; i < 3; i += 1) { + if (Math.abs(a[i] - b[i]) <= VOXEL_SEAM_EPS) continue; + if (axis !== null) return null; + axis = i as WorldAxisIndex; + } + return axis; +} + +function voxelSeamSegmentForEdge( + item: DirectMatrixItem, + polygon: Polygon, + edgeIndex: number, +): VoxelSeamSegment | null { + const vertices = polygon.vertices; + const a = cssPointForVertex(vertices[edgeIndex]); + const b = cssPointForVertex(vertices[(edgeIndex + 1) % vertices.length]); + const side = sideForLocalEdge(item, localPointForItem(item, a), localPointForItem(item, b)); + if (!side) return null; + const variableAxis = variableAxisForSegment(a, b); + if (variableAxis === null) return null; + const start = Math.min(a[variableAxis], b[variableAxis]); + const end = Math.max(a[variableAxis], b[variableAxis]); + return end - start > VOXEL_SEAM_EPS + ? { item, side, variableAxis, fixed: a, start, end } + : null; +} + +function markVoxelSeam(segment: VoxelSeamSegment): void { + segment.item.bleed[segment.side] = Math.max(segment.item.bleed[segment.side], VOXEL_SEAM_BLEED); +} + +function applyVoxelSeamBleed(polygons: readonly Polygon[], items: DirectMatrixItem[]): void { + const groups = new Map(); + for (const item of items) { + const polygon = polygons[item.sourceIndex]; + if (!polygon) continue; + for (let edgeIndex = 0; edgeIndex < polygon.vertices.length; edgeIndex += 1) { + const segment = voxelSeamSegmentForEdge(item, polygon, edgeIndex); + if (!segment) continue; + const key = worldLineKey(segment); + const group = groups.get(key); + if (group) group.push(segment); + else groups.set(key, [segment]); + } + } + + for (const segments of groups.values()) { + if (segments.length < 2) continue; + segments.sort((a, b) => a.start - b.start || a.end - b.end); + let active: VoxelSeamSegment[] = []; + for (const segment of segments) { + active = active.filter((candidate) => candidate.end > segment.start + VOXEL_SEAM_EPS); + for (const candidate of active) { + if (candidate.item.sourceIndex === segment.item.sourceIndex) continue; + markVoxelSeam(candidate); + markVoxelSeam(segment); + } + active.push(segment); + } + } +} + function parseColor(input: string): RGB { const parsed = parsePureColor(input); if (!parsed) return { r: 255, g: 255, b: 255, alpha: 1 }; @@ -262,28 +402,94 @@ function buildDirectMatrixItems(polygons: readonly Polygon[] | undefined): Direc sourceIndex, }); } + applyVoxelSeamBleed(polygons, items); return items; } -function directMatrix( - axis: Axis, - left: number, - top: number, - width: number, - height: number, - zOffset: number, - primitiveSize: number, -): string { +function voxelProjectiveBasis(item: DirectMatrixItem): { + xAxis: Vec3; + yAxis: Vec3; + normal: Vec3; + tx: number; + ty: number; + tz: number; +} { + if (item.axis === "x") { + return { + xAxis: [1, 0, 0], + yAxis: [0, 0, 1], + normal: [0, -1, 0], + tx: 0, + ty: -item.z, + tz: 0, + }; + } + if (item.axis === "y") { + return { + xAxis: [0, 0, 1], + yAxis: [0, 1, 0], + normal: [-1, 0, 0], + tx: -item.z, + ty: 0, + tz: 0, + }; + } + return { + xAxis: [1, 0, 0], + yAxis: [0, 1, 0], + normal: [0, 0, 1], + tx: 0, + ty: 0, + tz: item.z, + }; +} + +function voxelScreenPts(item: DirectMatrixItem): number[] { + const left = item.left; + const top = item.top; + const right = item.left + item.width; + const bottom = item.top + item.height; + return [ + left, top, + right, top, + right, bottom, + left, bottom, + ]; +} + +function voxelSeamEdgeAmounts(item: DirectMatrixItem): Map { + return new Map([ + [0, item.bleed.top], + [1, item.bleed.right], + [2, item.bleed.bottom], + [3, item.bleed.left], + ]); +} + +function rescaleProjectiveMatrix(matrix: string, primitiveSize: number): string | null { + const values = matrix.split(",").map(Number); + if (values.length !== 16 || values.some((value) => !Number.isFinite(value))) return null; + const scale = SOLID_QUAD_CANONICAL_SIZE / primitiveSize; + for (let i = 0; i < 8; i += 1) values[i] *= scale; + return `matrix3d(${values.map((value) => Number(value.toFixed(6))).join(",")})`; +} + +function affineVoxelMatrix(item: DirectMatrixItem, primitiveSize: number): string { + const left = item.left - item.bleed.left; + const top = item.top - item.bleed.top; + const width = item.width + item.bleed.left + item.bleed.right; + const height = item.height + item.bleed.top + item.bleed.bottom; + const zOffset = item.z; const scaleX = width / primitiveSize; const scaleY = height / primitiveSize; - const values = axis === "x" + const values = item.axis === "x" ? [ scaleX, 0, 0, 0, 0, 0, scaleY, 0, 0, -1, 0, 0, left, -zOffset, top, 1, ] - : axis === "y" + : item.axis === "y" ? [ 0, 0, scaleX, 0, 0, scaleY, 0, 0, @@ -299,6 +505,24 @@ function directMatrix( return `matrix3d(${values.map((value) => Number(value.toFixed(6))).join(",")})`; } +function directMatrix(item: DirectMatrixItem, primitiveSize: number): string { + const { xAxis, yAxis, normal, tx, ty, tz } = voxelProjectiveBasis(item); + const projective = computeProjectiveQuadMatrix( + voxelScreenPts(item), + xAxis, + yAxis, + normal, + tx, + ty, + tz, + VOXEL_PROJECTIVE_QUAD_GUARDS, + voxelSeamEdgeAmounts(item), + ); + return projective + ? rescaleProjectiveMatrix(projective, primitiveSize) ?? affineVoxelMatrix(item, primitiveSize) + : affineVoxelMatrix(item, primitiveSize); +} + function isMobileDocument(doc: Document): boolean { const media = doc.defaultView?.matchMedia; if (!media) return false; @@ -389,6 +613,7 @@ export function createPolyVoxelRenderer( const { doc, wrapper, polygons, directionalLight, ambientLight } = options; const directMatrixItems = buildDirectMatrixItems(polygons); if (directMatrixItems.length === 0) return null; + const itemFaces = new Set(directMatrixItems.map((item) => item.face)); wrapper.classList.add("polycss-voxel-mesh"); const primitiveSize = primitiveSizeForDocument(doc); if (primitiveSize !== DESKTOP_PRIMITIVE_SIZE) { @@ -405,55 +630,183 @@ export function createPolyVoxelRenderer( return shaded; }; - const pool: BrushElement[] = []; + const elementBySourceIndex = new Map(); + const hostByFace = new Map(); + const faceOrderKeys = new Map(); + const directMatrixItemsByFace = new Map(); + for (const item of directMatrixItems) { + let faceItems = directMatrixItemsByFace.get(item.face); + if (!faceItems) { + faceItems = []; + directMatrixItemsByFace.set(item.face, faceItems); + } + faceItems.push(item); + } let lastSignature = ""; let mountedBrushCount = 0; + let mountedFaces = new Set(); - const nextBrush = (index: number): BrushElement => { - let el = pool[index]; + const brushForItem = (item: DirectMatrixItem): BrushElement => { + let el = elementBySourceIndex.get(item.sourceIndex); if (!el) { el = doc.createElement("b") as BrushElement; - pool[index] = el; + elementBySourceIndex.set(item.sourceIndex, el); + applyBrush( + el, + shadedColor(item.face, item.baseColor), + directMatrix(item, primitiveSize), + ); } - if (el.parentElement !== wrapper) wrapper.appendChild(el); return el; }; - const draw = (signature: string, rotation: CameraCullRotation): void => { + const hostForFace = (face: PolyVoxelFace): FaceHostElement => { + let host = hostByFace.get(face); + if (!host) { + host = doc.createElement("span") as FaceHostElement; + host.className = `polycss-voxel-face polycss-voxel-face-${face}`; + host.dataset.polycssVoxelFace = face; + host.__polycssVoxelFaceHost = true; + hostByFace.set(face, host); + } + return host; + }; + + const firstPreservedChild = (): ChildNode | null => { + for (const child of Array.from(wrapper.childNodes)) { + if ((child as FaceHostElement).__polycssVoxelFaceHost) continue; + return child; + } + return null; + }; + + const syncFaceHost = (face: PolyVoxelFace, items: readonly DirectMatrixItem[]): void => { + const nextOrderKey = items.map((item) => item.sourceIndex).join(","); + if (faceOrderKeys.get(face) === nextOrderKey) return; + const host = hostForFace(face); + const fragment = doc.createDocumentFragment(); + for (const item of items) fragment.appendChild(brushForItem(item)); + host.replaceChildren(fragment); + faceOrderKeys.set(face, nextOrderKey); + }; + + const prebuildFaceHosts = (): void => { + for (const face of FACE_ORDER) { + if (!itemFaces.has(face)) continue; + syncFaceHost(face, directMatrixItemsByFace.get(face) ?? []); + } + }; + + const facesForSignature = (signature: string): Set => + new Set(signature.split("|").filter(Boolean) as PolyVoxelFace[]); + + const faceOrderForSignature = (signature: string): PolyVoxelFace[] => { + const visibleFaces = facesForSignature(signature); + return FACE_ORDER.filter((face) => visibleFaces.has(face) && itemFaces.has(face)); + }; + + const countBrushesForFaces = (faces: Iterable): number => { + let count = 0; + for (const face of faces) count += directMatrixItemsByFace.get(face)?.length ?? 0; + return count; + }; + + const itemsByFaceOrder = ( + orderedItems: readonly DirectMatrixItem[], + ): { orderedFaces: PolyVoxelFace[]; itemsByFace: Map } => { + const seen = new Set(); + const orderedFaces: PolyVoxelFace[] = []; + const itemsByFace = new Map(); + for (const item of orderedItems) { + if (!seen.has(item.face)) { + seen.add(item.face); + orderedFaces.push(item.face); + } + let faceItems = itemsByFace.get(item.face); + if (!faceItems) { + faceItems = []; + itemsByFace.set(item.face, faceItems); + } + faceItems.push(item); + } + return { orderedFaces, itemsByFace }; + }; + + const mountFaceHosts = (orderedFaces: readonly PolyVoxelFace[], reorderMountedFaces: boolean): void => { + const nextFaces = new Set(orderedFaces); + for (const face of mountedFaces) { + if (nextFaces.has(face)) continue; + const host = hostByFace.get(face); + if (host?.parentNode === wrapper) wrapper.removeChild(host); + } + + if (reorderMountedFaces) { + const fragment = doc.createDocumentFragment(); + for (const face of orderedFaces) fragment.appendChild(hostForFace(face)); + wrapper.insertBefore(fragment, firstPreservedChild()); + mountedFaces = nextFaces; + return; + } + + for (let i = 0; i < orderedFaces.length; i += 1) { + const face = orderedFaces[i]; + const host = hostForFace(face); + if (host.parentNode === wrapper) continue; + let reference: ChildNode | null = null; + for (let j = i + 1; j < orderedFaces.length; j += 1) { + const nextHost = hostByFace.get(orderedFaces[j]); + if (nextHost?.parentNode === wrapper) { + reference = nextHost; + break; + } + } + wrapper.insertBefore(host, reference ?? firstPreservedChild()); + } + mountedFaces = nextFaces; + }; + + const draw = (signature: string, rotation: CameraCullRotation, syncOrder: boolean): void => { + if (!syncOrder) { + const orderedFaces = faceOrderForSignature(signature); + mountFaceHosts(orderedFaces, false); + mountedBrushCount = countBrushesForFaces(orderedFaces); + return; + } + const visibleFaces = new Set(signature.split("|").filter(Boolean) as PolyVoxelFace[]); const orderedItems = orderDirectMatrixItems(directMatrixItems, visibleFaces, rotation); - mountedBrushCount = 0; - for (const item of orderedItems) { - const el = nextBrush(mountedBrushCount); - applyBrush( - el, - shadedColor(item.face, item.baseColor), - directMatrix(item.axis, item.left, item.top, item.width, item.height, item.z, primitiveSize), - ); - mountedBrushCount += 1; + const { orderedFaces, itemsByFace } = itemsByFaceOrder(orderedItems); + + for (const face of orderedFaces) { + syncFaceHost(face, itemsByFace.get(face) ?? []); } - for (let i = mountedBrushCount; i < pool.length; i += 1) pool[i]?.remove(); + mountFaceHosts(orderedFaces, true); + mountedBrushCount = orderedItems.length; }; + prebuildFaceHosts(); + return { - element: wrapper, get brushCount() { return mountedBrushCount; }, render(rotation: CameraCullRotation) { lastSignature = visibleFaceSignature(rotation); - draw(lastSignature, rotation); + draw(lastSignature, rotation, true); }, syncCamera(rotation: CameraCullRotation) { const nextSignature = visibleFaceSignature(rotation); if (nextSignature === lastSignature) return; lastSignature = nextSignature; - draw(nextSignature, rotation); + draw(nextSignature, rotation, false); }, dispose() { - for (const el of pool) el.remove(); + for (const host of hostByFace.values()) host.remove(); wrapper.classList.remove("polycss-voxel-mesh"); wrapper.style.removeProperty("--polycss-voxel-primitive"); - pool.length = 0; + elementBySourceIndex.clear(); + hostByFace.clear(); + faceOrderKeys.clear(); mountedBrushCount = 0; + mountedFaces = new Set(); lastSignature = ""; }, }; diff --git a/packages/polycss/src/styles/styles.ts b/packages/polycss/src/styles/styles.ts index c66b0f09..64f151bb 100644 --- a/packages/polycss/src/styles/styles.ts +++ b/packages/polycss/src/styles/styles.ts @@ -109,7 +109,23 @@ const CORE_BASE_STYLES = ` height: 64px; } -.polycss-mesh.polycss-voxel-mesh > b { +.polycss-mesh.polycss-voxel-mesh > .polycss-voxel-face { + position: absolute; + display: block; + top: 0; + left: 0; + width: 0; + height: 0; + transform-style: preserve-3d; + transform-origin: 0 0; + margin: 0; + padding: 0; + font: inherit; + line-height: 0; + pointer-events: none; +} + +.polycss-mesh.polycss-voxel-mesh > .polycss-voxel-face > b { top: 0; left: 0; width: var(--polycss-voxel-primitive, 1px); diff --git a/packages/react/src/styles/styles.ts b/packages/react/src/styles/styles.ts index ad103493..a4c3fbf5 100644 --- a/packages/react/src/styles/styles.ts +++ b/packages/react/src/styles/styles.ts @@ -110,6 +110,31 @@ const CORE_BASE_STYLES = ` height: 64px; } +.polycss-mesh.polycss-voxel-mesh > .polycss-voxel-face { + position: absolute; + display: block; + top: 0; + left: 0; + width: 0; + height: 0; + transform-style: preserve-3d; + transform-origin: 0 0; + margin: 0; + padding: 0; + font: inherit; + line-height: 0; + pointer-events: none; +} + +.polycss-mesh.polycss-voxel-mesh > .polycss-voxel-face > b { + top: 0; + left: 0; + width: var(--polycss-voxel-primitive, 1px); + height: var(--polycss-voxel-primitive, 1px); + backface-visibility: visible; + pointer-events: none; +} + .polycss-scene i { width: 16px; height: 16px; diff --git a/packages/vue/src/styles/styles.ts b/packages/vue/src/styles/styles.ts index 0a32a7ac..7e1a9ca6 100644 --- a/packages/vue/src/styles/styles.ts +++ b/packages/vue/src/styles/styles.ts @@ -110,6 +110,31 @@ const CORE_BASE_STYLES = ` height: 64px; } +.polycss-mesh.polycss-voxel-mesh > .polycss-voxel-face { + position: absolute; + display: block; + top: 0; + left: 0; + width: 0; + height: 0; + transform-style: preserve-3d; + transform-origin: 0 0; + margin: 0; + padding: 0; + font: inherit; + line-height: 0; + pointer-events: none; +} + +.polycss-mesh.polycss-voxel-mesh > .polycss-voxel-face > b { + top: 0; + left: 0; + width: var(--polycss-voxel-primitive, 1px); + height: var(--polycss-voxel-primitive, 1px); + backface-visibility: visible; + pointer-events: none; +} + .polycss-scene i { width: 16px; height: 16px;