From 0c0d95e5c0bb8d9d57c40e1e967e99dd07c62b2b Mon Sep 17 00:00:00 2001 From: agustin-littlehat Date: Mon, 18 May 2026 18:12:57 -0300 Subject: [PATCH] feat: add voxel slice renderer fast path --- AGENTS.md | 8 +- bench/perf-shared.mjs | 5 + packages/core/README.md | 2 +- packages/core/src/index.ts | 17 + packages/core/src/parser/loadMesh.ts | 4 + packages/core/src/parser/parseVox.test.ts | 29 + packages/core/src/parser/parseVox.ts | 41 +- packages/core/src/parser/types.ts | 22 + packages/core/src/voxel/voxelSlicePlanner.ts | 2117 +++++++++++++++++ .../polycss/src/api/createPolyScene.test.ts | 102 + packages/polycss/src/api/createPolyScene.ts | 83 +- .../polycss/src/render/voxelSliceRenderer.ts | 478 ++++ packages/polycss/src/styles/styles.ts | 30 + .../GalleryWorkbench/GalleryWorkbench.tsx | 1 + .../GalleryWorkbench/helpers/loaders.ts | 25 +- .../hooks/useScenePolygons.ts | 1 + .../src/components/GalleryWorkbench/types.ts | 3 +- .../components/VanillaScene/VanillaScene.tsx | 79 +- website/src/content/docs/api/types.mdx | 2 +- website/src/content/docs/guides/textures.mdx | 2 +- 20 files changed, 3005 insertions(+), 46 deletions(-) create mode 100644 packages/core/src/voxel/voxelSlicePlanner.ts create mode 100644 packages/polycss/src/render/voxelSliceRenderer.ts diff --git a/AGENTS.md b/AGENTS.md index 72987723..278245cf 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -22,6 +22,8 @@ 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`; `border-shape` uses a larger fixed primitive because its 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`. Eligible vanilla meshes render that source through three axis hosts plus absolutely positioned rectangular brush leaves, using the voxcss `mergeVoxels: "3d"` slice planner rather than one `matrix3d` per polygon. `.vox` normalization snaps to the nearest integer CSS cell size so brush rectangles 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, 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. ### Tag-as-strategy table @@ -38,12 +40,14 @@ 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 strategies fall through the chain (`b → i → s`, `u → i → s`, `i → s`). `` is the universal fallback and cannot be disabled. +The voxel slice-brush fast path emits plain `` quad elements inside axis hosts. They intentionally reuse the cheap quad tag, but they are absolutely positioned brush rectangles rather than polygon strategy leaves and do not use one `matrix3d` per polygon. + ### Lighting modes (`PolyTextureLightingMode = "baked" | "dynamic"`) - **Baked.** Lambert is computed once on the CPU per polygon, multiplied into the inline `color` (for ``/``/``) or into the rasterised atlas pixels (for ``). Moving a light requires re-rasterising affected polys. - **Dynamic.** Scene root carries the light setup as custom properties (`--plx/y/z`, `--plr/g/b`, `--pli`, `--par/g/b`, `--pai`). Each leaf embeds its surface normal (`--pnx/y/z`) and base color (`--psr/g/b`) inline. CSS `calc()` resolves the Lambert dot product and per-channel tint at paint time. Moving a light mutates one var on the scene root — zero JS, no atlas redraw. -All solid/atlas tags work in both modes. The full coverage matrix is in `packages/polycss/src/styles/styles.ts`. +All solid/atlas tags work in both modes. The `.vox` slice-brush fast path is baked-only for now; dynamic mode uses the polygon path so lighting semantics stay correct. The full coverage matrix is in `packages/polycss/src/styles/styles.ts`. ### Meshing implications (what generators must respect) @@ -77,7 +81,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`, `PolyControls`. - **Types:** `PolyDirectionalLight`, `PolyAmbientLight`, `PolyTextureLightingMode`, `PolyAnimationMixer`. -- **Functions:** `findPolyMeshHandle`, `injectPolyBaseStyles`. +- **Functions:** `findPolyMeshHandle`, `injectPolyBaseStyles`, `buildPolyVoxelFaceData`, `buildPolyVoxelSlicePlan`. - **Vanilla factories:** `create*` names stay as-is (`createPolyScene`, `createPolyControls`, `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/perf-shared.mjs b/bench/perf-shared.mjs index 7937f2f2..f75024bb 100644 --- a/bench/perf-shared.mjs +++ b/bench/perf-shared.mjs @@ -57,6 +57,11 @@ export const PRESETS = { options: { targetSize: 60 }, zoom: 0.4, rotX: 65, rotY: 45, }, + "apoc-car": { + url: "/gallery/glb/apocalypse/car.glb", + options: { targetSize: 60 }, + zoom: 0.4, rotX: 65, rotY: 45, + }, crate: { url: "/gallery/obj/opengameart/crate/Box.obj", mtlUrl: "/gallery/obj/opengameart/crate/Box.mtl", diff --git a/packages/core/README.md b/packages/core/README.md index 0f3bf23f..5a97a666 100644 --- a/packages/core/README.md +++ b/packages/core/README.md @@ -53,7 +53,7 @@ npm install @layoutit/polycss-core | `parseObj(text, options?)` | Parses OBJ text into `ParseResult`. Supports UV (`vt`), materials, `map_Kd` textures. | | `parseMtl(text)` | Parses MTL text into `{ colors, textures }`. | | `parseGltf(buffer, options?)` | Parses GLB or glTF `ArrayBuffer` into `ParseResult`. Extracts embedded textures as blob URLs. | -| `parseVox(buffer, options?)` | Parses MagicaVoxel `.vox` `ArrayBuffer` into `ParseResult`. Face-culls interior voxel faces and fan-triangulates exposed quads. | +| `parseVox(buffer, options?)` | Parses MagicaVoxel `.vox` `ArrayBuffer` into `ParseResult`. Face-culls interior voxel faces and fan-triangulates exposed quads. `targetSize` snaps to integer voxel CSS cells for the slice-brush renderer. | | `loadMesh(url, options?)` | Fetches a URL, dispatches to the right parser by extension (`.obj`, `.glb`, `.gltf`, `.vox`). Returns `Promise` and defaults to `meshResolution: "lossy"`. | | `parseColor(input)` | Parse any CSS color string to `{ r, g, b, a }`. | | `shadeColor(input, lambert, ...)` | Apply Lambert shading factor to a color. | diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index fd4b01ca..5863c874 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -140,6 +140,8 @@ export type { export type { ParseAnimationClip, ParseAnimationController, + PolyVoxelCell, + PolyVoxelSource, ParseResult, } from "./parser/types"; export { parseObj } from "./parser/parseObj"; @@ -155,5 +157,20 @@ 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/parser/loadMesh.ts b/packages/core/src/parser/loadMesh.ts index a76e7fdb..2311d942 100644 --- a/packages/core/src/parser/loadMesh.ts +++ b/packages/core/src/parser/loadMesh.ts @@ -63,6 +63,10 @@ export interface LoadMeshOptions { const FETCH_NAME = "loadMesh"; function withMeshResolution(result: ParseResult, options?: LoadMeshOptions): ParseResult { + // parseVox already emits greedy axis-aligned quads, and voxel fast paths + // need load-time latency dominated by the raw voxel source rather than a + // second generic polygon optimizer pass with marginal fallback savings. + if (result.voxelSource) return result; const polygons = optimizeMeshPolygons(result.polygons, { meshResolution: options?.meshResolution, }); diff --git a/packages/core/src/parser/parseVox.test.ts b/packages/core/src/parser/parseVox.test.ts index 681f2d8a..987fccbe 100644 --- a/packages/core/src/parser/parseVox.test.ts +++ b/packages/core/src/parser/parseVox.test.ts @@ -310,6 +310,35 @@ describe("parseVox — minimal synthetic buffer", () => { expect(result.polygons.length).toBe(6); }); + it("preserves normalized raw voxel source for slice-brush rendering", () => { + const buf = buildVoxBuffer([3, 2, 1], [{ x: 2, y: 1, z: 0, colorIndex: 1 }]); + const result = parseVox(buf, { targetSize: 30, gridShift: 2 }); + expect(result.voxelSource).toEqual({ + kind: "magica-vox", + cells: [{ x: 0, y: 0, z: 0, color: "#ffffff" }], + rows: 1, + cols: 1, + depth: 1, + scale: 30, + gridShift: 2, + sourceBytes: buf.byteLength, + }); + }); + + it("snaps voxel scale to integer CSS cell sizes", () => { + const buf = buildVoxBuffer( + [80, 1, 1], + [ + { x: 0, y: 0, z: 0, colorIndex: 1 }, + { x: 79, y: 0, z: 0, colorIndex: 1 }, + ], + ); + const result = parseVox(buf, { targetSize: 70, gridShift: 0 }); + expect(result.voxelSource?.scale).toBe(0.88); + const xs = result.polygons.flatMap((p) => p.vertices.map((v) => v[0])); + expect(Math.max(...xs) - Math.min(...xs)).toBeCloseTo(70.4, 3); + }); + it("two adjacent voxels share one face — greedy-meshed to 6 polys", () => { // Two voxels side by side on X: (0,0,0) and (1,0,0). Same material → // greedy mesh runs each long face as a single 2×1 rectangle: diff --git a/packages/core/src/parser/parseVox.ts b/packages/core/src/parser/parseVox.ts index 1ee592a3..6bdcb14b 100644 --- a/packages/core/src/parser/parseVox.ts +++ b/packages/core/src/parser/parseVox.ts @@ -22,16 +22,19 @@ * swap). Voxel coordinates are always non-negative (origin at 0), so no * shift is required by default. * - * Output mesh is uniformly scaled to fit `targetSize` units along the - * longest bbox axis. + * Output mesh is uniformly scaled near `targetSize` units along the longest + * bbox axis, snapped to the nearest integer CSS cell so voxel slice renderers + * can avoid fractional brush coordinates without adding scale transforms. */ import type { Polygon, Vec3 } from "../types"; -import type { ParseResult } from "./types"; +import { BASE_TILE } from "../camera/camera"; +import type { ParseResult, PolyVoxelSource } from "./types"; export interface VoxParseOptions { /** - * Largest mesh extent (in scene-space units). The mesh is uniformly - * scaled so its longest bbox dimension equals this. Default: 60. + * Largest mesh extent (in scene-space units). For `.vox`, the requested + * extent is snapped to the nearest integer CSS cell size to keep voxel + * slice brushes on integer pixel coordinates. Default: 60. */ targetSize?: number; /** @@ -406,7 +409,26 @@ export function parseVox(buffer: ArrayBuffer, options?: VoxParseOptions): ParseR } } const maxDim = Math.max(maxX - minX, maxY - minY, maxZ - minZ); - const scale = maxDim > 0 ? targetSize / maxDim : 1; + const rawScale = maxDim > 0 ? targetSize / maxDim : 1; + const targetCellPx = rawScale * BASE_TILE; + const scale = Number.isFinite(targetCellPx) && targetCellPx > 0 + ? Math.max(1, Math.round(targetCellPx)) / BASE_TILE + : rawScale; + const voxelSource: PolyVoxelSource = { + kind: "magica-vox", + cells: voxels.map((v) => ({ + x: v.x - minX, + y: v.y - minY, + z: v.z - minZ, + color: resolveColor(v.colorIndex), + })), + rows: Math.max(0, maxX - minX), + cols: Math.max(0, maxY - minY), + depth: Math.max(0, maxZ - minZ), + scale, + gridShift, + sourceBytes, + }; const round = (n: number): number => Math.round(n * 1000) / 1000; const project = (v: Vec3): Vec3 => [ @@ -422,18 +444,15 @@ export function parseVox(buffer: ArrayBuffer, options?: VoxParseOptions): ParseR return { polygons, + voxelSource, objectUrls: [], dispose: () => { /* no-op: parseVox has no minted blob URLs */ }, warnings: [], metadata: { triangleCount: polygons.length, sourceBytes, - // voxelCount is a vox-specific extension to the base metadata shape. - // Cast as any to avoid the structural type mismatch — we keep it in - // metadata so callers can access it without polluting the ParseResult type. - // eslint-disable-next-line @typescript-eslint/no-explicit-any voxelCount: voxels.length, - } as ParseResult["metadata"], + }, }; } diff --git a/packages/core/src/parser/types.ts b/packages/core/src/parser/types.ts index 60149e42..081ecaa2 100644 --- a/packages/core/src/parser/types.ts +++ b/packages/core/src/parser/types.ts @@ -11,6 +11,24 @@ */ import type { Polygon } from "../types"; +export interface PolyVoxelCell { + x: number; + y: number; + z: number; + color: string; +} + +export interface PolyVoxelSource { + kind: "magica-vox"; + cells: PolyVoxelCell[]; + rows: number; + cols: number; + depth: number; + scale: number; + gridShift: number; + sourceBytes: number; +} + export interface ParseAnimationClip { /** Stable numeric index in the source file's animation array. */ index: number; @@ -35,6 +53,8 @@ export interface ParseAnimationController { export interface ParseResult { /** The mesh, as a flat polygon list. Already vertex-permuted to polycss space. */ polygons: Polygon[]; + /** Optional raw voxel source for `.vox` fast paths; polygon fallback remains authoritative. */ + voxelSource?: PolyVoxelSource; /** Optional animation sampler for formats that carry timeline data. */ animation?: ParseAnimationController; /** @@ -66,5 +86,7 @@ export interface ParseResult { animations?: ParseAnimationClip[]; /** Source file size in bytes (for diagnostics). */ sourceBytes?: number; + /** Voxel count for `.vox` sources. */ + voxelCount?: number; }; } diff --git a/packages/core/src/voxel/voxelSlicePlanner.ts b/packages/core/src/voxel/voxelSlicePlanner.ts new file mode 100644 index 00000000..8c4bb85b --- /dev/null +++ b/packages/core/src/voxel/voxelSlicePlanner.ts @@ -0,0 +1,2117 @@ +/* Pure voxel slice planning - zero DOM dependencies. + * The rectangle optimizer is ported from voxcss mergeVoxels="3d"; polycss + * feeds it the raw MagicaVoxel cell source and renders the plans in + * packages/polycss. + */ +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 ce17fd09..cf486b1c 100644 --- a/packages/polycss/src/api/createPolyScene.test.ts +++ b/packages/polycss/src/api/createPolyScene.test.ts @@ -93,6 +93,18 @@ function texturedTriangle(): Polygon { }; } +function topQuad(color = "#123456"): Polygon { + return { + vertices: [ + [0, 0, 1], + [1, 0, 1], + [1, 1, 1], + [0, 1, 1], + ], + color, + }; +} + function makeParseResult(polygons: Polygon[] = [triangle()]): ParseResult { let disposed = false; return { @@ -108,6 +120,38 @@ function makeParseResult(polygons: Polygon[] = [triangle()]): ParseResult { } as ParseResult & { readonly _disposed: boolean }; } +function makeVoxelParseResult(): ParseResult { + return { + ...makeParseResult([triangle()]), + 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 makeVoxelExactParseResult(): ParseResult { + return { + ...makeParseResult([topQuad("#123456")]), + voxelSource: { + kind: "magica-vox", + cells: [{ x: 0, y: 0, z: 0, color: "#ff0000" }], + rows: 1, + cols: 1, + depth: 1, + scale: 1, + gridShift: 0, + sourceBytes: 64, + }, + }; +} + function getSceneEl(host: HTMLElement): HTMLElement { const sceneEl = host.querySelector(".polycss-scene") as HTMLElement | null; expect(sceneEl).not.toBeNull(); @@ -252,6 +296,64 @@ describe("createPolyScene", () => { expect(handle.polygons.length).toBe(2); }); + it("routes raw vox sources through the voxel slice-brush renderer", () => { + scene = createPolyScene(host); + scene.add(makeVoxelParseResult(), { merge: false }); + const voxelRoot = host.querySelector(".polycss-voxel-host-z"); + const voxelBrushes = Array.from(host.querySelectorAll(".polycss-voxel-host b")); + expect(voxelRoot).not.toBeNull(); + expect(host.querySelector(".polycss-voxel-slice")).toBeNull(); + expect(voxelBrushes.length).toBeGreaterThan(0); + expect(voxelBrushes.every((el) => el.tagName === "B")).toBe(true); + const firstBrush = voxelBrushes[0] as HTMLElement; + expect(firstBrush.style.left).toMatch(/px$/); + expect(firstBrush.style.top).toMatch(/px$/); + expect(firstBrush.style.width).toMatch(/px$/); + expect(firstBrush.style.height).toMatch(/px$/); + expect(firstBrush.style.gridArea).toBe(""); + expect(firstBrush.style.transform).toContain("translateZ("); + expect(firstBrush.style.getPropertyValue("--polycss-voxel-z")).toBe(""); + expect(voxelBrushes.every((el) => el.className === "")).toBe(true); + }); + + it("applies baked lighting to voxel slice-brush quads", () => { + scene = createPolyScene(host, { + rotX: 0, + rotY: 0, + directionalLight: { direction: [0, 0, -1], color: "#ffffff", intensity: 1 }, + ambientLight: { color: "#ffffff", intensity: 0 }, + }); + scene.add(makeVoxelParseResult(), { merge: false }); + const brush = host.querySelector(".polycss-voxel-host b") as HTMLElement | null; + expect(brush).not.toBeNull(); + expect(brush!.style.color).toMatch(/^(#000000|rgb\\(0, 0, 0\\))$/); + }); + + it("uses exact parsed voxel polygons before falling back to merged source brushes", () => { + scene = createPolyScene(host, { + rotX: 65, + rotY: 45, + directionalLight: { direction: [0, 0, 1], color: "#ffffff", intensity: 0 }, + ambientLight: { color: "#ffffff", intensity: 1 }, + }); + scene.add(makeVoxelExactParseResult(), { merge: false }); + const brush = host.querySelector(".polycss-voxel-host b") as HTMLElement | null; + expect(brush).not.toBeNull(); + expect(brush!.style.color).toMatch(/^(#123456|rgb\\(18, 52, 86\\))$/); + expect(brush!.style.width).toBe("50px"); + expect(brush!.style.height).toBe("50px"); + }); + + it("falls back to polygon rendering after setPolygons replaces vox source geometry", () => { + scene = createPolyScene(host); + const handle = scene.add(makeVoxelParseResult(), { merge: false }); + expect(host.querySelector(".polycss-voxel-host-z")).not.toBeNull(); + handle.setPolygons([triangle()], { merge: false }); + expect(host.querySelector(".polycss-voxel-host-z")).toBeNull(); + expect(host.querySelector(".polycss-voxel-host b")).toBeNull(); + expect(host.querySelector("i,b,s,u")).not.toBeNull(); + }); + it("hoists the repeated baked solid paint to the mesh wrapper", () => { scene = createPolyScene(host); scene.add(makeParseResult([triangle(), triangle()]), { merge: false }); diff --git a/packages/polycss/src/api/createPolyScene.ts b/packages/polycss/src/api/createPolyScene.ts index 1bf3410c..f105bb83 100644 --- a/packages/polycss/src/api/createPolyScene.ts +++ b/packages/polycss/src/api/createPolyScene.ts @@ -55,6 +55,10 @@ import { type RenderedPoly, type SolidPaintDefaults, } from "../render/textureAtlas"; +import { + createPolyVoxelSliceRenderer, + type PolyVoxelSliceRenderer, +} from "../render/voxelSliceRenderer"; import { injectPolyBaseStyles } from "../styles/styles"; // Used only by the internal async mesh update path. Batching DOM insertion @@ -484,14 +488,17 @@ export function createPolyScene( * separate from `rendered` so they can be removed independently when * castShadow is toggled or lighting mode changes. */ shadowRendered: HTMLElement[]; + voxelRenderer?: PolyVoxelSliceRenderer; disposeAtlas?: () => void; polygons: Polygon[]; + voxelSource: ParseResult["voxelSource"]; disposed: boolean; stableDom: boolean; excludeFromAutoCenter: boolean; castShadow: boolean; cameraCullGroups: CameraCullNormalGroup[]; cameraCullSignature: string; + lightOverrideSignature: string; /** Rotation snapshot used by the baked atlas baker. Advances only when * `rebakeAtlas()` is called — not on every `setTransform`. */ bakedRotation: Vec3; @@ -583,6 +590,8 @@ export function createPolyScene( } function clearRendered(entry: MeshEntry): void { + entry.voxelRenderer?.dispose(); + entry.voxelRenderer = undefined; disposeRendered(entry.rendered, entry.disposeAtlas); entry.disposeAtlas = undefined; entry.rendered.length = 0; @@ -784,6 +793,12 @@ export function createPolyScene( } function syncMountedRenderedForCameraChange(entry: MeshEntry, force = false): void { + if (entry.voxelRenderer) { + entry.voxelRenderer.syncCamera(cameraCullRotation(entry)); + entry.cameraCullSignature = "voxel-slice"; + return; + } + if (!canDomCullCamera(entry)) { const wasCulled = entry.cameraCullSignature !== "all"; entry.cameraCullSignature = "all"; @@ -936,23 +951,31 @@ export function createPolyScene( // wrapper element, computed by inverse-rotating the world-space light into the // mesh's local frame. The cascade means these override the scene-level vars // only for polygons inside this wrapper. Cleared when conditions are not met. - function applyMeshLightVarOverride(wrapper: HTMLDivElement, rotation: Vec3 | undefined): void { + function applyMeshLightVarOverride(entry: MeshEntry, rotation: Vec3 | undefined): void { const isDynamic = currentOptions.textureLighting === "dynamic"; const dir = currentOptions.directionalLight?.direction; const hasNonZeroRotation = rotation && (rotation[0] !== 0 || rotation[1] !== 0 || rotation[2] !== 0); if (!isDynamic || !hasNonZeroRotation || !dir) { - wrapper.style.removeProperty("--plx"); - wrapper.style.removeProperty("--ply"); - wrapper.style.removeProperty("--plz"); + if (entry.lightOverrideSignature === "clear") return; + entry.wrapper.style.removeProperty("--plx"); + entry.wrapper.style.removeProperty("--ply"); + entry.wrapper.style.removeProperty("--plz"); + entry.lightOverrideSignature = "clear"; return; } const localDir = inverseRotateVec3(dir as Vec3, rotation as Vec3); const len = Math.hypot(localDir[0], localDir[1], localDir[2]) || 1; - wrapper.style.setProperty("--plx", (localDir[0] / len).toFixed(4)); - wrapper.style.setProperty("--ply", (localDir[1] / len).toFixed(4)); - wrapper.style.setProperty("--plz", (localDir[2] / len).toFixed(4)); + const plx = (localDir[0] / len).toFixed(4); + const ply = (localDir[1] / len).toFixed(4); + const plz = (localDir[2] / len).toFixed(4); + const signature = `${plx}|${ply}|${plz}`; + if (entry.lightOverrideSignature === signature) return; + entry.wrapper.style.setProperty("--plx", plx); + entry.wrapper.style.setProperty("--ply", ply); + entry.wrapper.style.setProperty("--plz", plz); + entry.lightOverrideSignature = signature; } function applySolidPaintVars(wrapper: HTMLDivElement, defaults: SolidPaintDefaults): void { @@ -1066,17 +1089,44 @@ export function createPolyScene( } function remountEntry(entry: MeshEntry): void { + if (entry.voxelRenderer) { + entry.voxelRenderer.render(cameraCullRotation(entry)); + entry.cameraCullSignature = "voxel-slice"; + return; + } clearShadowLeaves(entry); syncMountedRendered(entry); emitShadowLeaves(entry); } + function canRenderVoxelSlice(entry: MeshEntry): boolean { + return !!entry.voxelSource && + currentOptions.textureLighting !== "dynamic" && + !entry.stableDom && + !entry.castShadow; + } + function renderEntry(entry: MeshEntry, lightDirectionOverride?: Vec3): void { clearRendered(entry); const baseDirLight = currentOptions.directionalLight; const directionalLight: typeof baseDirLight = lightDirectionOverride ? { ...baseDirLight, direction: lightDirectionOverride } : baseDirLight; + if (canRenderVoxelSlice(entry) && entry.voxelSource) { + const renderer = createPolyVoxelSliceRenderer({ + doc, + wrapper: entry.wrapper, + source: entry.voxelSource, + polygons: entry.parseResult.polygons, + directionalLight, + ambientLight: currentOptions.ambientLight, + }); + entry.voxelRenderer = renderer; + renderer.render(cameraCullRotation(entry)); + entry.cameraCullSignature = "voxel-slice"; + return; + } + const renderOptions = { doc, directionalLight, @@ -1274,12 +1324,14 @@ export function createPolyScene( rendered: [], shadowRendered: [], polygons: sourcePolygons, + voxelSource: parseResult.voxelSource, disposed: false, stableDom: stableDomOnUpdate, excludeFromAutoCenter: !!transformIn.excludeFromAutoCenter, castShadow: !!transformIn.castShadow, cameraCullGroups: [], cameraCullSignature: "", + lightOverrideSignature: "clear", bakedRotation: (transformIn.rotation ? [...transformIn.rotation] : [0, 0, 0]) as Vec3, }; @@ -1306,6 +1358,7 @@ export function createPolyScene( mergeOnUpdate = options?.merge ?? mergeOnUpdate; stableDomOnUpdate = options?.stableDom ?? stableDomOnUpdate; entry.stableDom = stableDomOnUpdate; + entry.voxelSource = undefined; entry.polygons = preparePolygons(polygons, mergeOnUpdate); handle.polygons = entry.polygons; applyTransformOrigin(entry.polygons); @@ -1341,6 +1394,7 @@ export function createPolyScene( ? target : entry.polygons.indexOf(target); if (idx < 0 || idx >= entry.polygons.length) return; + entry.voxelSource = undefined; Object.assign(entry.polygons[idx], partial); renderEntry(entry); }, @@ -1353,6 +1407,7 @@ export function createPolyScene( mergeOnUpdate = options?.merge ?? mergeOnUpdate; stableDomOnUpdate = options?.stableDom ?? stableDomOnUpdate; entry.stableDom = stableDomOnUpdate; + entry.voxelSource = undefined; entry.polygons = preparePolygons(polygons, mergeOnUpdate); handle.polygons = entry.polygons; applyTransformOrigin(entry.polygons); @@ -1371,7 +1426,7 @@ export function createPolyScene( transform = { ...transform, ...t }; const css2 = buildMeshTransform(transform); wrapper.style.transform = css2 ?? ""; - applyMeshLightVarOverride(wrapper, transform.rotation); + applyMeshLightVarOverride(entry, transform.rotation); if (t.rotation !== undefined) syncMountedRenderedForCameraChange(entry); if (entry.castShadow !== prevCastShadow) { emitShadowLeaves(entry); @@ -1411,7 +1466,7 @@ export function createPolyScene( entry.handle = handle; meshes.add(entry); renderEntry(entry); - applyMeshLightVarOverride(wrapper, transform.rotation); + applyMeshLightVarOverride(entry, transform.rotation); recomputeAutoCenter(); recomputeShadowGround(); return handle; @@ -1429,7 +1484,7 @@ export function createPolyScene( // Re-evaluate per-mesh light overrides when lighting settings change — // textureLighting or directionalLight may have changed. for (const entry of meshes) { - applyMeshLightVarOverride(entry.wrapper, entry.handle.transform.rotation); + applyMeshLightVarOverride(entry, entry.handle.transform.rotation); } // `strategies` controls which leaf tags the renderer emits. A change // means we have to re-render every mesh against the new constraint. @@ -1453,7 +1508,13 @@ export function createPolyScene( const textureLightingChanged = partial.textureLighting !== undefined && prevTextureLighting !== currentOptions.textureLighting; if (textureLightingChanged) { - for (const entry of meshes) emitShadowLeaves(entry); + for (const entry of meshes) { + if (!strategiesChanged && (entry.voxelSource || entry.voxelRenderer)) { + renderEntry(entry); + } else { + emitShadowLeaves(entry); + } + } recomputeShadowGround(); } } diff --git a/packages/polycss/src/render/voxelSliceRenderer.ts b/packages/polycss/src/render/voxelSliceRenderer.ts new file mode 100644 index 00000000..a15a7a9c --- /dev/null +++ b/packages/polycss/src/render/voxelSliceRenderer.ts @@ -0,0 +1,478 @@ +import type { + CameraCullRotation, + PolyAmbientLight, + PolyDirectionalLight, + PolyVoxelFace, + PolyVoxelSource, + PolyVoxelSlicePlan, + Polygon, + Vec3, +} from "@layoutit/polycss-core"; +import { + BASE_TILE, + buildPolyVoxelFaceData, + buildPolyVoxelSlicePlan, + POLY_VOXEL_NEXT_LAYER_STEP, + normalFacesCamera, + parsePureColor, +} from "@layoutit/polycss-core"; + +type Axis = "x" | "y" | "z"; + +interface BrushState { + left?: string; + top?: string; + width?: string; + height?: string; + color?: string; + zOffset?: string; +} + +type BrushElement = HTMLElement & { + __polycssVoxelBrushState?: BrushState; +}; + +export interface PolyVoxelSliceRenderer { + readonly element: HTMLElement; + readonly brushCount: number; + render(rotation: CameraCullRotation): void; + syncCamera(rotation: CameraCullRotation): void; + dispose(): void; +} + +export interface PolyVoxelSliceRendererOptions { + doc: Document; + wrapper: HTMLElement; + source: PolyVoxelSource; + polygons?: readonly Polygon[]; + directionalLight?: PolyDirectionalLight; + ambientLight?: PolyAmbientLight; +} + +interface RGB { r: number; g: number; b: number; alpha: number; } + +interface BrushPlan { + axis: Axis; + face: PolyVoxelFace; + brushes: Array<{ + left: number; + top: number; + width: number; + height: number; + z: number; + baseColor: string; + }>; +} + +const DEFAULT_LIGHT_DIR: Vec3 = [0.4, -0.7, 0.59]; +const DEFAULT_LIGHT_COLOR = "#ffffff"; +const DEFAULT_LIGHT_INTENSITY = 1; +const DEFAULT_AMBIENT_COLOR = "#ffffff"; +const DEFAULT_AMBIENT_INTENSITY = 0.4; + +const FACE_NORMALS: Record = { + t: [0, 0, 1], + b: [0, 0, -1], + fl: [0, 1, 0], + br: [0, -1, 0], + fr: [1, 0, 0], + bl: [-1, 0, 0], +}; + +const FACE_ORDER: PolyVoxelFace[] = ["t", "b", "bl", "br", "fr", "fl"]; + +const FACE_BY_NORMAL = new Map([ + ["0,0,1", "t"], + ["0,0,-1", "b"], + ["0,1,0", "fl"], + ["0,-1,0", "br"], + ["1,0,0", "fr"], + ["-1,0,0", "bl"], +]); + +function visibleFaceSignature(rotation: CameraCullRotation): string { + const visible: string[] = []; + for (const face of FACE_ORDER) { + if (normalFacesCamera(FACE_NORMALS[face], rotation)) visible.push(face); + } + return visible.join("|"); +} + +function applyBrush( + el: BrushElement, + left: string, + top: string, + width: string, + height: string, + color: string, + zOffset: string, +): void { + const state = (el.__polycssVoxelBrushState ??= {}); + if (state.left !== left) { + el.style.left = left; + state.left = left; + } + if (state.top !== top) { + el.style.top = top; + state.top = top; + } + if (state.width !== width) { + el.style.width = width; + state.width = width; + } + if (state.height !== height) { + el.style.height = height; + state.height = height; + } + if (state.color !== color) { + el.style.color = color; + state.color = color; + } + if (state.zOffset !== zOffset) { + el.style.transform = `translateZ(${zOffset})`; + state.zOffset = zOffset; + } +} + +function planBrushZ(plan: PolyVoxelSlicePlan, cellPx: number): string { + const plane = plan.key.plane * cellPx; + return plan.key.axis === "z" ? `${plane}px` : `${-plane}px`; +} + +function cssNormalForPolygon(polygon: Polygon): Vec3 | null { + const vertices = polygon.vertices; + if (vertices.length < 3) return null; + const v0 = vertices[0]; + let nx = 0; + let ny = 0; + let nz = 0; + for (let i = 1; i + 1 < vertices.length; i += 1) { + const v1 = vertices[i]; + const v2 = vertices[i + 1]; + const e1x = v1[1] - v0[1]; + const e1y = v1[0] - v0[0]; + const e1z = v1[2] - v0[2]; + const e2x = v2[1] - v0[1]; + const e2y = v2[0] - v0[0]; + const e2z = v2[2] - v0[2]; + nx -= e1y * e2z - e1z * e2y; + ny -= e1z * e2x - e1x * e2z; + nz -= e1x * e2y - e1y * e2x; + } + const len = Math.hypot(nx, ny, nz); + if (len <= 1e-9) return null; + return [ + Math.round(nx / len), + Math.round(ny / len), + Math.round(nz / len), + ]; +} + +function polygonBrush(polygon: Polygon): (BrushPlan["brushes"][number] & { + axis: Axis; + face: PolyVoxelFace; +}) | null { + if (polygon.texture || polygon.material || polygon.uvs || polygon.textureTriangles) return null; + if (polygon.vertices.length !== 4) return null; + const normal = cssNormalForPolygon(polygon); + const face = normal ? FACE_BY_NORMAL.get(normal.join(",")) : undefined; + if (!face) return null; + + let minX = Infinity; + let minY = Infinity; + let minZ = Infinity; + let maxX = -Infinity; + let maxY = -Infinity; + let maxZ = -Infinity; + for (const v of polygon.vertices) { + minX = Math.min(minX, v[0]); + minY = Math.min(minY, v[1]); + minZ = Math.min(minZ, v[2]); + maxX = Math.max(maxX, v[0]); + maxY = Math.max(maxY, v[1]); + maxZ = Math.max(maxZ, v[2]); + } + + const eps = 1e-6; + const baseColor = polygon.color || "#cccccc"; + if (Math.abs(maxZ - minZ) <= eps) { + return { + axis: "z", + face, + left: minY * BASE_TILE, + top: minX * BASE_TILE, + width: Math.max(0, (maxY - minY) * BASE_TILE), + height: Math.max(0, (maxX - minX) * BASE_TILE), + z: minZ * BASE_TILE, + baseColor, + }; + } + if (Math.abs(maxX - minX) <= eps) { + return { + axis: "x", + face, + left: minY * BASE_TILE, + top: minZ * BASE_TILE, + width: Math.max(0, (maxY - minY) * BASE_TILE), + height: Math.max(0, (maxZ - minZ) * BASE_TILE), + z: -minX * BASE_TILE, + baseColor, + }; + } + if (Math.abs(maxY - minY) <= eps) { + return { + axis: "y", + face, + left: minZ * BASE_TILE, + top: minX * BASE_TILE, + width: Math.max(0, (maxZ - minZ) * BASE_TILE), + height: Math.max(0, (maxX - minX) * BASE_TILE), + z: -minY * BASE_TILE, + baseColor, + }; + } + return null; +} + +function parseColor(input: string): RGB { + const parsed = parsePureColor(input); + if (!parsed) return { r: 255, g: 255, b: 255, alpha: 1 }; + return { + r: parsed.rgb[0], + g: parsed.rgb[1], + b: parsed.rgb[2], + alpha: parsed.alpha, + }; +} + +function rgbToHex({ r, g, b }: RGB): string { + const f = (n: number) => + Math.round(Math.max(0, Math.min(255, n))).toString(16).padStart(2, "0"); + return `#${f(r)}${f(g)}${f(b)}`; +} + +function clampChannel(value: number): number { + return Math.round(Math.max(0, Math.min(255, value))); +} + +function shadeBrushColor( + normal: Vec3, + baseColor: string, + directionalLight: PolyDirectionalLight | undefined, + ambientLight: PolyAmbientLight | undefined, +): string { + const base = parseColor(baseColor); + const light = parseColor(directionalLight?.color ?? DEFAULT_LIGHT_COLOR); + const ambient = parseColor(ambientLight?.color ?? DEFAULT_AMBIENT_COLOR); + const lightDir = directionalLight?.direction ?? DEFAULT_LIGHT_DIR; + const lightLen = Math.hypot(lightDir[0], lightDir[1], lightDir[2]) || 1; + const lx = lightDir[0] / lightLen; + const ly = lightDir[1] / lightLen; + const lz = lightDir[2] / lightLen; + const directScale = Math.max(0, directionalLight?.intensity ?? DEFAULT_LIGHT_INTENSITY) * + Math.max(0, normal[0] * lx + normal[1] * ly + normal[2] * lz); + const ambientIntensity = Math.max(0, ambientLight?.intensity ?? DEFAULT_AMBIENT_INTENSITY); + const tintR = (ambient.r / 255) * ambientIntensity + (light.r / 255) * directScale; + const tintG = (ambient.g / 255) * ambientIntensity + (light.g / 255) * directScale; + const tintB = (ambient.b / 255) * ambientIntensity + (light.b / 255) * directScale; + const shaded: RGB = { + r: base.r * tintR, + g: base.g * tintG, + b: base.b * tintB, + alpha: base.alpha, + }; + return shaded.alpha < 1 + ? `rgba(${clampChannel(shaded.r)}, ${clampChannel(shaded.g)}, ${clampChannel(shaded.b)}, ${shaded.alpha})` + : rgbToHex(shaded); +} + +function buildMergedPlans(source: PolyVoxelSource, cellPx: number): BrushPlan[] { + const faces = buildPolyVoxelFaceData(source); + const faceIndex = new Map(); + for (const face of faces) { + faceIndex.set(`${face.key.axis}:${face.key.plane}:${face.key.face}`, face); + } + return faces.map((face): BrushPlan => { + const nextPlane = face.key.plane + POLY_VOXEL_NEXT_LAYER_STEP[face.key.face]; + const nextFace = faceIndex.get(`${face.key.axis}:${nextPlane}:${face.key.face}`); + const plan = buildPolyVoxelSlicePlan(face, nextFace?.buffer ?? null); + const z = Number.parseFloat(planBrushZ(plan, cellPx)); + return { + axis: plan.key.axis, + face: plan.key.face, + brushes: plan.brushes.map((brush) => ({ + left: (plan.buffer.minCol + brush.c0) * cellPx, + top: (plan.buffer.minRow + brush.r0) * cellPx, + width: (brush.c1 - brush.c0) * cellPx, + height: (brush.r1 - brush.r0) * cellPx, + z, + baseColor: brush.baseColor, + })), + }; + }); +} + +function buildPolygonPlans(polygons: readonly Polygon[] | undefined): BrushPlan[] { + if (!polygons?.length) return []; + const plans = new Map(); + let accepted = 0; + for (const polygon of polygons) { + const brush = polygonBrush(polygon); + if (!brush || brush.width <= 0 || brush.height <= 0) continue; + accepted += 1; + const key = `${brush.axis}:${brush.face}`; + let plan = plans.get(key); + if (!plan) { + plan = { axis: brush.axis, face: brush.face, brushes: [] }; + plans.set(key, plan); + } + plan.brushes.push({ + left: brush.left, + top: brush.top, + width: brush.width, + height: brush.height, + z: brush.z, + baseColor: brush.baseColor, + }); + } + return accepted === polygons.length ? Array.from(plans.values()) : []; +} + +function configureHost( + host: HTMLElement, + width: number, + height: number, +): void { + host.style.width = `${width}px`; + host.style.height = `${height}px`; +} + +export function createPolyVoxelSliceRenderer( + options: PolyVoxelSliceRendererOptions, +): PolyVoxelSliceRenderer { + const { doc, wrapper, source, polygons, directionalLight, ambientLight } = options; + const cellPx = Math.max(1, Math.round(source.scale * BASE_TILE)); + const polygonPlans = buildPolygonPlans(polygons); + const plans = polygonPlans.length > 0 + ? polygonPlans + : buildMergedPlans(source, cellPx); + const shiftPx = polygonPlans.length > 0 ? 0 : source.gridShift * BASE_TILE; + const colorCache = new Map(); + + const hosts: Record = { + z: doc.createElement("div"), + x: doc.createElement("div"), + y: doc.createElement("div"), + }; + hosts.z.className = "polycss-voxel-host polycss-voxel-host-z"; + hosts.x.className = "polycss-voxel-host polycss-voxel-host-x"; + hosts.y.className = "polycss-voxel-host polycss-voxel-host-y"; + const shiftTransform = shiftPx !== 0 + ? `translate3d(${shiftPx}px, ${shiftPx}px, ${shiftPx}px) ` + : ""; + hosts.z.style.transform = shiftTransform.trim(); + hosts.x.style.transform = `${shiftTransform}rotateX(90deg)`; + hosts.y.style.transform = `${shiftTransform}rotateY(-90deg)`; + wrapper.append(hosts.z, hosts.x, hosts.y); + + configureHost( + hosts.z, + source.cols * cellPx, + source.rows * cellPx, + ); + configureHost( + hosts.x, + source.cols * cellPx, + source.depth * cellPx, + ); + configureHost( + hosts.y, + source.depth * cellPx, + source.rows * cellPx, + ); + + const pools: Record = { z: [], x: [], y: [] }; + let lastSignature = ""; + let mountedBrushCount = 0; + + const nextBrush = (axis: Axis, index: number): BrushElement => { + let el = pools[axis][index]; + if (!el) { + el = doc.createElement("b") as BrushElement; + pools[axis][index] = el; + } + if (el.parentElement !== hosts[axis]) hosts[axis].appendChild(el); + return el; + }; + + const shadedColor = (face: PolyVoxelFace, baseColor: string): string => { + const key = `${face}|${baseColor}`; + const cached = colorCache.get(key); + if (cached) return cached; + const shaded = shadeBrushColor(FACE_NORMALS[face], baseColor, directionalLight, ambientLight); + colorCache.set(key, shaded); + return shaded; + }; + + const draw = (signature: string): void => { + const visibleFaces = new Set(signature.split("|").filter(Boolean) as PolyVoxelFace[]); + const used: Record = { z: 0, x: 0, y: 0 }; + mountedBrushCount = 0; + + for (const plan of plans) { + const axis = plan.axis; + if (!visibleFaces.has(plan.face)) continue; + for (const brush of plan.brushes) { + const left = `${brush.left}px`; + const top = `${brush.top}px`; + const width = `${brush.width}px`; + const height = `${brush.height}px`; + const zOffset = `${brush.z}px`; + const el = nextBrush(axis, used[axis]); + used[axis] += 1; + applyBrush( + el, + left, + top, + width, + height, + shadedColor(plan.face, brush.baseColor), + zOffset, + ); + mountedBrushCount += 1; + } + } + + for (const axis of Object.keys(pools) as Axis[]) { + const pool = pools[axis]; + for (let i = used[axis]; i < pool.length; i += 1) pool[i]?.remove(); + } + }; + + const renderer: PolyVoxelSliceRenderer = { + element: hosts.z, + get brushCount() { return mountedBrushCount; }, + render(rotation: CameraCullRotation) { + lastSignature = visibleFaceSignature(rotation); + draw(lastSignature); + }, + syncCamera(rotation: CameraCullRotation) { + const nextSignature = visibleFaceSignature(rotation); + if (nextSignature === lastSignature) return; + lastSignature = nextSignature; + draw(nextSignature); + }, + dispose() { + hosts.z.remove(); + hosts.x.remove(); + hosts.y.remove(); + pools.x.length = 0; + pools.y.length = 0; + pools.z.length = 0; + mountedBrushCount = 0; + lastSignature = ""; + }, + }; + + return renderer; +} diff --git a/packages/polycss/src/styles/styles.ts b/packages/polycss/src/styles/styles.ts index cb05566b..10bee340 100644 --- a/packages/polycss/src/styles/styles.ts +++ b/packages/polycss/src/styles/styles.ts @@ -150,6 +150,36 @@ const CORE_BASE_STYLES = ` content: none; } +/* ── Voxel slice-brush fast path ────────────────────────────────────────── */ + +.polycss-voxel-host { + position: absolute; + top: 0; + left: 0; + transform-origin: 0 0; + transform-style: preserve-3d; + pointer-events: none; +} + +.polycss-voxel-host-x { + transform: rotateX(90deg); +} + +.polycss-voxel-host-y { + transform: rotateY(-90deg); +} + +.polycss-voxel-host b { + position: absolute; + display: block; + overflow: visible; + transform-origin: 0 0; + transform-style: preserve-3d; + backface-visibility: visible; + pointer-events: none; + background-repeat: no-repeat; +} + /* ── Gizmo override (createTransformControls) ───────────────────────────── */ /* diff --git a/website/src/components/GalleryWorkbench/GalleryWorkbench.tsx b/website/src/components/GalleryWorkbench/GalleryWorkbench.tsx index 119c638d..1bbf9522 100644 --- a/website/src/components/GalleryWorkbench/GalleryWorkbench.tsx +++ b/website/src/components/GalleryWorkbench/GalleryWorkbench.tsx @@ -676,6 +676,7 @@ export default function GalleryWorkbench() { { + if (disposed) return; + disposed = true; + parsed.dispose(); + for (const url of objectUrls) URL.revokeObjectURL(url); + }, + }; return { label: source.label, kind: "obj", + parseResult, rawPolygons: parsed.polygons, polygons: parsed.polygons, sourcePolygons: parsed.polygons.length, sourceBytes, - warnings: [...(parsed.warnings ?? []), ...warnings], + warnings: parseResult.warnings, parseMs: performance.now() - started, - dispose: () => { - if (disposed) return; - disposed = true; - parsed.dispose(); - for (const url of objectUrls) URL.revokeObjectURL(url); - }, + dispose: parseResult.dispose, }; } @@ -226,6 +235,7 @@ export async function loadDroppedModel( return { label: source.label, kind: "vox", + parseResult: parsed, rawPolygons: parsed.polygons, polygons: parsed.polygons, sourcePolygons: parsed.polygons.length, @@ -241,6 +251,7 @@ export async function loadDroppedModel( return { label: source.label, kind: "glb", + parseResult: parsed, rawPolygons: parsed.polygons, polygons: parsed.polygons, sourcePolygons: parsed.polygons.length, diff --git a/website/src/components/GalleryWorkbench/hooks/useScenePolygons.ts b/website/src/components/GalleryWorkbench/hooks/useScenePolygons.ts index 77b13448..38b512e0 100644 --- a/website/src/components/GalleryWorkbench/hooks/useScenePolygons.ts +++ b/website/src/components/GalleryWorkbench/hooks/useScenePolygons.ts @@ -37,6 +37,7 @@ export function useScenePolygons({ ? reactAnimatedPolygons : loaded.rawPolygons; } + if (loaded.parseResult.voxelSource) return loaded.rawPolygons; return optimizeMeshPolygons(loaded.rawPolygons, { meshResolution, }); diff --git a/website/src/components/GalleryWorkbench/types.ts b/website/src/components/GalleryWorkbench/types.ts index c71ea676..8531f120 100644 --- a/website/src/components/GalleryWorkbench/types.ts +++ b/website/src/components/GalleryWorkbench/types.ts @@ -2,7 +2,7 @@ // type declarations that flow between subfolders (presets/, helpers/, the // component itself) live here. Component-internal types stay local. -import type { ObjParseOptions, GltfParseOptions, VoxParseOptions, Polygon, ParseAnimationController } from "@layoutit/polycss"; +import type { ObjParseOptions, GltfParseOptions, VoxParseOptions, ParseResult, Polygon, ParseAnimationController } from "@layoutit/polycss"; export type Renderer = "react" | "vanilla"; export type ModelKind = "obj" | "glb" | "gltf" | "vox"; @@ -44,6 +44,7 @@ export interface DroppedModelSource { export interface LoadedModel { label: string; kind: ModelKind; + parseResult: ParseResult; rawPolygons: Polygon[]; polygons: Polygon[]; sourcePolygons: number; diff --git a/website/src/components/VanillaScene/VanillaScene.tsx b/website/src/components/VanillaScene/VanillaScene.tsx index 4382eddb..c6fa97cc 100644 --- a/website/src/components/VanillaScene/VanillaScene.tsx +++ b/website/src/components/VanillaScene/VanillaScene.tsx @@ -14,6 +14,8 @@ import type { PolyControlsHandle, PolyFirstPersonControlsHandle, PolyMeshHandle as VanillaPolyMeshHandle, + PolyMeshTransform, + ParseResult, PolySceneOptions, PolySceneHandle, PolySelectionHandle, @@ -44,6 +46,7 @@ function lightHelperPosition( export interface VanillaSceneProps { polygons: Polygon[]; + parseResult?: ParseResult; interiorFillPolygons: Polygon[]; options: SceneOptionsState; directionalLight: PolyDirectionalLight; @@ -70,6 +73,7 @@ export interface VanillaSceneProps { export function VanillaScene({ polygons, + parseResult, interiorFillPolygons, options, directionalLight, @@ -117,6 +121,12 @@ export function VanillaScene({ animationPausedRef.current = options.animationPaused; const animationTimeScaleRef = useRef(options.animationTimeScale); animationTimeScaleRef.current = options.animationTimeScale; + const mountedModelRef = useRef<{ + handle: VanillaPolyMeshHandle; + polygons: Polygon[]; + merge: boolean; + stableDom: boolean; + } | null>(null); const mountInteriorFillInsideModel = useCallback(() => { const modelEl = meshHandleRef.current?.element; @@ -155,15 +165,34 @@ export function VanillaScene({ autoCenter: options.autoCenter, textureQuality: options.textureQuality, strategies: { disable: options.disableStrategies }, - }; + } as PolySceneOptions; const scene = createPolyScene(host, sceneOptions); sceneRef.current = scene; - meshHandleRef.current = scene.add({ + const meshTransform = { + merge: mergePolygonsForMesh, + stableDom: stableDomForMesh, + id: meshId, + castShadow: options.castShadow, + } as PolyMeshTransform; + const modelParseResult: ParseResult = parseResult + ? { + ...parseResult, + polygons, + dispose: () => {}, + } + : { + polygons, + objectUrls: [], + warnings: [], + dispose: () => {}, + }; + meshHandleRef.current = scene.add(modelParseResult, meshTransform); + mountedModelRef.current = { + handle: meshHandleRef.current, polygons, - objectUrls: [], - warnings: [], - dispose: () => {}, - }, { merge: mergePolygonsForMesh, stableDom: stableDomForMesh, id: meshId, castShadow: options.castShadow }); + merge: mergePolygonsForMesh, + stableDom: stableDomForMesh, + }; meshHandleRef.current.element.classList.add("dn-model-mesh"); onMeshHandleChangeRef.current?.(meshHandleRef.current); return () => { @@ -176,6 +205,7 @@ export function VanillaScene({ lightHandleRef.current = null; groundHandleRef.current = null; interiorFillHandleRef.current = null; + mountedModelRef.current = null; meshHandleRef.current = null; sceneRef.current = null; scene.destroy(); @@ -188,6 +218,7 @@ export function VanillaScene({ stableDirectionalForRebuild, stableAmbientForRebuild, stableDomForMesh, + parseResult, ]); // Effect 1.5 — replace geometry on the existing mesh. This is the path @@ -199,10 +230,24 @@ export function VanillaScene({ const scene = sceneRef.current; if (!handle || !scene) return; const started = performance.now(); - handle.setPolygons(polygons, { - merge: mergePolygonsForMesh, - stableDom: stableDomForMesh, - }); + const mounted = mountedModelRef.current; + const modelAlreadyMounted = + mounted?.handle === handle && + mounted.polygons === polygons && + mounted.merge === mergePolygonsForMesh && + mounted.stableDom === stableDomForMesh; + if (!modelAlreadyMounted) { + handle.setPolygons(polygons, { + merge: mergePolygonsForMesh, + stableDom: stableDomForMesh, + }); + mountedModelRef.current = { + handle, + polygons, + merge: mergePolygonsForMesh, + stableDom: stableDomForMesh, + }; + } let fillHandle = interiorFillHandleRef.current; if (interiorFillPolygons.length === 0) { @@ -238,7 +283,13 @@ export function VanillaScene({ requestAnimationFrame(() => onBuildRef.current(performance.now() - started), ); - }, [polygons, interiorFillPolygons, mergePolygonsForMesh, stableDomForMesh, mountInteriorFillInsideModel]); + }, [ + polygons, + interiorFillPolygons, + mergePolygonsForMesh, + stableDomForMesh, + mountInteriorFillInsideModel, + ]); // Effect 1.6 — live-toggle castShadow without rebuilding the scene. useEffect(() => { @@ -293,6 +344,7 @@ export function VanillaScene({ stableDirectionalForRebuild, stableAmbientForRebuild, stableDomForMesh, + parseResult, ]); // Forward gizmo mode changes to the live PolyTransformControls handle. @@ -339,6 +391,7 @@ export function VanillaScene({ stableDirectionalForRebuild, stableAmbientForRebuild, stableDomForMesh, + parseResult, ]); useEffect(() => { @@ -472,6 +525,7 @@ export function VanillaScene({ stableDirectionalForRebuild, stableAmbientForRebuild, stableDomForMesh, + parseResult, ]); // Effect 2.6 — live-update FPV options (booleans + numerics) without @@ -544,6 +598,7 @@ export function VanillaScene({ options.perspective, stableDirectionalForRebuild, stableAmbientForRebuild, + parseResult, ]); // Effect 3.5 — ground receiver. A flat quad in the XY plane (Z is "up" @@ -612,6 +667,7 @@ export function VanillaScene({ options.perspective, stableDirectionalForRebuild, stableAmbientForRebuild, + parseResult, ]); // Effect 4 — light helper. Octahedron at LOCAL origin so polygons stay @@ -660,6 +716,7 @@ export function VanillaScene({ options.perspective, stableDirectionalForRebuild, stableAmbientForRebuild, + parseResult, ]); // Effect 5 — slide the light helper to the new orbit position whenever diff --git a/website/src/content/docs/api/types.mdx b/website/src/content/docs/api/types.mdx index 264d4862..24a487f1 100644 --- a/website/src/content/docs/api/types.mdx +++ b/website/src/content/docs/api/types.mdx @@ -207,7 +207,7 @@ Options for `parseVox`. ```ts interface VoxParseOptions { - /** Scale the model so its longest axis is this many world units. Default: 60. */ + /** Scale near this longest-axis size; snapped to integer voxel CSS cells. Default: 60. */ targetSize?: number; /** Shift all vertices by this amount after scaling. Default: 0. */ gridShift?: number; diff --git a/website/src/content/docs/guides/textures.mdx b/website/src/content/docs/guides/textures.mdx index 59b9667c..016bc271 100644 --- a/website/src/content/docs/guides/textures.mdx +++ b/website/src/content/docs/guides/textures.mdx @@ -160,7 +160,7 @@ Generated atlas blob URLs are revoked on unmount (call `dispose()` or let `PolyM ## Tips -- **`targetSize`**: scale the model so its longest axis fits this many world units (default: `60`). Set it to roughly match the scale of your other scene content. +- **`targetSize`**: scale the model so its longest axis fits this many world units (default: `60`). `.vox` models snap to the nearest integer voxel CSS cell size, so the final size may differ slightly to keep voxel slice brushes on integer pixel coordinates. - **`textureQuality`**: leave at `"auto"` for workload-based bitmap caps, or set a numeric scale for explicit quality. `0.5` uses about one quarter of the atlas bitmap memory of `1`. - Shared textured edges are repaired automatically during atlas generation. Geometry stays unchanged; only low-alpha atlas pixels at those shared edges are filled from nearby opaque texels. - **`baseUrl`**: for OBJ/glTF files with external texture paths, pass the file's URL so relative paths resolve correctly.