diff --git a/AGENTS.md b/AGENTS.md index de554f3c..066c29d7 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -27,9 +27,9 @@ Public API is **mirrored** across React and Vue. Adding a hook on one side witho | Tag | Strategy | When chosen | Paint mechanism | Atlas memory | |---|---|---|---|---| | `` | **Quads** | Axis-aligned rectangle, or untextured convex quad when the homography passes stability guards | `background: currentColor`; canonical quads use a 1px rectangle mapped by `matrix3d` / projective `matrix3d` with tiny solid bleed to overlap antialias seams | None | -| `` | **Border-shape clipped solid** | Untextured non-rect on browsers with CSS `border-shape` (Chromium + `pointer:fine` + `hover:hover`) | `border-color: currentColor` on a fixed 16px border-shape primitive, clipped by `border-shape: polygon(...)`; polygon bbox scale is folded into `matrix3d` | None | +| `` | **Border-shape clipped solid** | Untextured non-rect on browsers with CSS `border-shape` (Chromium + `pointer:fine` + `hover:hover`) | `border-color: currentColor` on a fixed 16px border-shape primitive, clipped by `border-shape: polygon(...)`; polygon bbox scale and tiny solid bleed are folded into `matrix3d` | None | | `` | **Atlas slice** | Textured polygons, or untextured non-rect on browsers without `border-shape` | `background-image` slice of packed bitmap on a canonical 1px primitive; atlas position/size are normalized to the slice, scale lives in `matrix3d`, and shared textured edges get low-alpha atlas pixels repaired during atlas generation | Bounding-rect area | -| `` | **Stable solid triangle** | Opt-in for triangles via `renderPolygonsWithStableTriangles` | CSS border-color triangle trick with a fixed canonical 1px border triangle; scale lives in `matrix3d` | None | +| `` | **Stable solid triangle** | Opt-in for triangles via `renderPolygonsWithStableTriangles` | CSS border-color triangle trick with a fixed canonical 1px border triangle; tiny solid bleed is folded into `matrix3d` | None | | `` | **Cast shadow leaf** | Per casting polygon when `castShadow: true` and dynamic lighting mode. Applies regardless of caster strategy — ``/``/``/`` all produce a `` shadow because only the polygon's outline matters, not its surface. | Same `border-color: currentColor` + `border-shape: polygon(...)` as ``, but transform composes `var(--shadow-proj)` to project the polygon onto the ground plane along the CSS-space light direction | None | Strategies are ordered cheapest → most expensive. The mesher's job is to maximise `` / `` and minimise `` (see "Meshing implications" below). diff --git a/packages/polycss/src/render/polyDOM.test.ts b/packages/polycss/src/render/polyDOM.test.ts index 3a68afde..c818b898 100644 --- a/packages/polycss/src/render/polyDOM.test.ts +++ b/packages/polycss/src/render/polyDOM.test.ts @@ -41,6 +41,16 @@ const NON_RECT_QUAD: Polygon = { color: "#00ffff", }; +const MODERATE_PROJECTIVE_QUAD: Polygon = { + vertices: [ + [0, 0, 0], + [1, 0, 0], + [1, 1, 0], + [0, 6, 0], + ], + color: "#00ffcc", +}; + const UNSTABLE_PROJECTIVE_QUAD: Polygon = { vertices: [ [0, 0, 0], @@ -424,6 +434,33 @@ describe("renderPolygonsWithTextureAtlas", () => { result.dispose(); }); + it("border-shape default bleed expands the generated paint box", () => { + const doc = { + defaultView: { + CSS: { + supports: (property: string) => property === "border-shape", + }, + }, + createElement: (tagName: string) => document.createElement(tagName), + } as unknown as Document; + + const result = renderPolygonsWithTextureAtlas([NON_RECT_QUAD], { + doc, + tileSize: 1, + strategies: { disable: ["b"] }, + }); + const element = result.rendered[0].element; + const matrix = extractMatrix(element); + const xScale = Math.hypot(matrix[0], matrix[1], matrix[2]); + const yScale = Math.hypot(matrix[4], matrix[5], matrix[6]); + + expect(element.tagName.toLowerCase()).toBe("i"); + expect(xScale).toBeGreaterThan(2 / 16); + expect(yScale).toBeGreaterThan(2 / 16); + expect(element.style.getPropertyValue("border-shape")).toContain("polygon("); + result.dispose(); + }); + it("uses the atlas fallback for solid non-rect polygons on non-desktop pointers when projective quads are disabled", () => { const canvases: Array<{ width: number; height: number; getContext: () => null }> = []; const doc = { @@ -538,8 +575,10 @@ describe("renderPolygonsWithTextureAtlas", () => { ], color: "#ffffff", }; - - const result = renderPolygonsWithTextureAtlas([obliqueQuad], { tileSize: 1 }); + const result = renderPolygonsWithTextureAtlas([obliqueQuad], { + tileSize: 1, + strategies: { disable: ["b"] }, + }); const element = result.rendered[0].element; const matrix = extractMatrix(element); @@ -548,10 +587,10 @@ describe("renderPolygonsWithTextureAtlas", () => { expect(element.style.height).toBe(""); expect(element.style.getPropertyValue("--polycss-local-w")).toBe(""); expect(element.style.getPropertyValue("--polycss-local-h")).toBe(""); - expect(matrix[0]).toBeCloseTo(10 / 16, 3); + expect(matrix[0]).toBeGreaterThan(10 / 16); expect(matrix[1]).toBeCloseTo(0, 6); expect(matrix[4]).toBeCloseTo(0, 6); - expect(matrix[5]).toBeCloseTo(1 / 16, 2); + expect(matrix[5]).toBeGreaterThan(1 / 16); result.dispose(); }); @@ -1253,6 +1292,89 @@ describe("renderPolygonsWithTextureAtlas — strategies.disable", () => { result.dispose(); }); + it("moderately projective solid quads stay on the CSS matrix b path", () => { + const canvases: Array<{ width: number; height: number; getContext: () => null }> = []; + const doc = { + defaultView: { CSS: { supports: () => false } }, + createElement(tagName: string) { + if (tagName === "canvas") { + const canvas = { width: 0, height: 0, getContext: () => null }; + canvases.push(canvas); + return canvas; + } + return document.createElement(tagName); + }, + } as unknown as Document; + + const result = renderPolygonsWithTextureAtlas( + [MODERATE_PROJECTIVE_QUAD], + { doc }, + ); + const element = result.rendered[0].element; + expect(element.tagName.toLowerCase()).toBe("b"); + expect(result.rendered[0].kind).toBe("solid"); + expect(element.getAttribute("style") ?? "").toContain("transform:matrix3d("); + expect(canvases).toHaveLength(0); + result.dispose(); + }); + + it("projective guard overrides can tighten the CSS matrix b path", () => { + const canvases: Array<{ width: number; height: number; getContext: () => null }> = []; + const doc = { + defaultView: { + CSS: { supports: () => false }, + __polycssProjectiveQuadGuards: { maxWeightRatio: 4 }, + }, + createElement(tagName: string) { + if (tagName === "canvas") { + const canvas = { width: 0, height: 0, getContext: () => null }; + canvases.push(canvas); + return canvas; + } + return document.createElement(tagName); + }, + } as unknown as Document; + + const result = renderPolygonsWithTextureAtlas( + [MODERATE_PROJECTIVE_QUAD], + { doc }, + ); + const element = result.rendered[0].element; + expect(element.tagName.toLowerCase()).toBe("s"); + expect(result.rendered[0].kind).toBe("atlas"); + expect(canvases).toHaveLength(1); + result.dispose(); + }); + + it("projective guard overrides can disable guard fallback for inspection", () => { + const canvases: Array<{ width: number; height: number; getContext: () => null }> = []; + const doc = { + defaultView: { + CSS: { supports: () => false }, + __polycssProjectiveQuadGuards: { disableGuards: true }, + }, + createElement(tagName: string) { + if (tagName === "canvas") { + const canvas = { width: 0, height: 0, getContext: () => null }; + canvases.push(canvas); + return canvas; + } + return document.createElement(tagName); + }, + } as unknown as Document; + + const result = renderPolygonsWithTextureAtlas( + [UNSTABLE_PROJECTIVE_QUAD], + { doc }, + ); + const element = result.rendered[0].element; + expect(element.tagName.toLowerCase()).toBe("b"); + expect(result.rendered[0].kind).toBe("solid"); + expect(element.getAttribute("style") ?? "").toContain("transform:matrix3d("); + expect(canvases).toHaveLength(0); + result.dispose(); + }); + it("unstable projective quads fall back to border-shape by default", () => { const canvases: Array<{ width: number; height: number; getContext: () => null }> = []; const doc = { diff --git a/packages/polycss/src/render/textureAtlas.ts b/packages/polycss/src/render/textureAtlas.ts index 46169fc7..c25555a0 100644 --- a/packages/polycss/src/render/textureAtlas.ts +++ b/packages/polycss/src/render/textureAtlas.ts @@ -231,17 +231,48 @@ const TEXTURE_TRIANGLE_BLEED = 0.75; const TEXTURE_EDGE_REPAIR_ALPHA_MIN = 1; const TEXTURE_EDGE_REPAIR_SOURCE_ALPHA_MIN = 250; const TEXTURE_EDGE_REPAIR_RADIUS = 1.5; -const SOLID_TRIANGLE_BLEED = 0.6; +const SOLID_TRIANGLE_BLEED = 0.75; const DEFAULT_MATRIX_DECIMALS = 3; const DEFAULT_BORDER_SHAPE_DECIMALS = 2; const DEFAULT_ATLAS_CSS_DECIMALS = 4; const BORDER_SHAPE_CENTER_PERCENT = 50; const BORDER_SHAPE_POINT_EPS = 1e-7; const BORDER_SHAPE_CANONICAL_SIZE = 16; +const BORDER_SHAPE_BLEED = 0.9; const PROJECTIVE_QUAD_DENOM_EPS = 0.05; -const PROJECTIVE_QUAD_MAX_WEIGHT_RATIO = 4; +const PROJECTIVE_QUAD_MAX_WEIGHT_RATIO = Number.POSITIVE_INFINITY; const PROJECTIVE_QUAD_BLEED = 0.6; +interface BorderShapeBounds { + minX: number; + minY: number; + width: number; + height: number; +} + +interface BorderShapeGeometry { + bounds: BorderShapeBounds; + points: Array<[number, number]>; +} + +interface ProjectiveQuadGuardSettings { + denomEps: number; + maxWeightRatio: number; + bleed: number; + disableGuards: boolean; +} + +interface ProjectiveQuadGuardOverrides { + denomEps?: number; + maxWeightRatio?: number; + bleed?: number; + disableGuards?: boolean; +} + +interface ProjectiveQuadGuardGlobal { + __polycssProjectiveQuadGuards?: ProjectiveQuadGuardOverrides; +} + interface ProjectiveQuadCoefficients { g: number; h: number; @@ -386,8 +417,39 @@ function offsetConvexPolygonPoints(points: number[], amount: number): number[] { return expanded; } +function finiteNumber(value: unknown, fallback: number): number { + return typeof value === "number" && Number.isFinite(value) ? value : fallback; +} + +function resolveProjectiveQuadGuards(doc: Document): ProjectiveQuadGuardSettings { + const win = doc?.defaultView as (Window & ProjectiveQuadGuardGlobal) | null | undefined; + const overrides = win?.__polycssProjectiveQuadGuards; + const overrideMaxWeightRatio = overrides?.maxWeightRatio; + const denomEps = Math.max( + 0, + finiteNumber(overrides?.denomEps, PROJECTIVE_QUAD_DENOM_EPS), + ); + const maxWeightRatio = typeof overrideMaxWeightRatio === "number" && + Number.isFinite(overrideMaxWeightRatio) && + overrideMaxWeightRatio > 0 + ? Math.max(1, overrideMaxWeightRatio) + : PROJECTIVE_QUAD_MAX_WEIGHT_RATIO; + const bleed = Math.max( + 0, + finiteNumber(overrides?.bleed, PROJECTIVE_QUAD_BLEED), + ); + + return { + denomEps, + maxWeightRatio, + bleed, + disableGuards: overrides?.disableGuards === true, + }; +} + function computeProjectiveQuadCoefficients( q: Array<[number, number]>, + guards: ProjectiveQuadGuardSettings, ): ProjectiveQuadCoefficients | null { if (q.length !== 4 || !isConvexPolygonPoints(q)) return null; @@ -404,16 +466,19 @@ function computeProjectiveQuadCoefficients( const g = (sx * dy2 - sy * dx2) / det; const h = (dx1 * sy - dy1 * sx) / det; const weights = [1, 1 + g, 1 + g + h, 1 + h]; - if (weights.some((weight) => !Number.isFinite(weight) || weight <= PROJECTIVE_QUAD_DENOM_EPS)) { + if (weights.some((weight) => !Number.isFinite(weight))) { return null; } const minWeight = Math.min(...weights); const maxWeight = Math.max(...weights); - // Very large homogeneous-weight variation means the rectangle's vanishing - // line is too close to the primitive. Chrome can then tessellate the leaf - // visibly wrong; the clipped polygon path is steadier for those quads. - if (maxWeight / minWeight > PROJECTIVE_QUAD_MAX_WEIGHT_RATIO) return null; + if (!guards.disableGuards) { + if (minWeight <= guards.denomEps) return null; + // Very large homogeneous-weight variation means the rectangle's vanishing + // line is too close to the primitive. Chrome can then tessellate the leaf + // visibly wrong; the clipped polygon path is steadier for those quads. + if (maxWeight / minWeight > guards.maxWeightRatio) return null; + } return { g, @@ -431,6 +496,7 @@ function computeProjectiveQuadMatrix( tx: number, ty: number, tz: number, + guards: ProjectiveQuadGuardSettings, ): string | null { if (screenPts.length !== 8) return null; const rawQ: Array<[number, number]> = [ @@ -439,42 +505,34 @@ function computeProjectiveQuadMatrix( [screenPts[4], screenPts[5]], [screenPts[6], screenPts[7]], ]; - if (!computeProjectiveQuadCoefficients(rawQ)) return null; + if (!computeProjectiveQuadCoefficients(rawQ, guards)) return null; - const expandedPts = offsetConvexPolygonPoints(screenPts, PROJECTIVE_QUAD_BLEED); + const expandedPts = offsetConvexPolygonPoints(screenPts, guards.bleed); const q: Array<[number, number]> = [ [expandedPts[0], expandedPts[1]], [expandedPts[2], expandedPts[3]], [expandedPts[4], expandedPts[5]], [expandedPts[6], expandedPts[7]], ]; - const coeffs = computeProjectiveQuadCoefficients(q); + const coeffs = computeProjectiveQuadCoefficients(q, guards); if (!coeffs) return null; const { g, h, w1, w3 } = coeffs; const [q0, q1, , q3] = q; - const toCssPoint = ([x, y]: [number, number]): Vec3 => [ - tx + x * xAxis[0] + y * yAxis[0], - ty + x * xAxis[1] + y * yAxis[1], - tz + x * xAxis[2] + y * yAxis[2], + const p0: Vec3 = [ + tx + q0[0] * xAxis[0] + q0[1] * yAxis[0], + ty + q0[0] * xAxis[1] + q0[1] * yAxis[1], + tz + q0[0] * xAxis[2] + q0[1] * yAxis[2], ]; - const p0 = toCssPoint(q0); - const p1 = toCssPoint(q1); - const p3 = toCssPoint(q3); - const xCol: Vec3 = [ - p1[0] * w1 - p0[0], - p1[1] * w1 - p0[1], - p1[2] * w1 - p0[2], - ]; - const yCol: Vec3 = [ - p3[0] * w3 - p0[0], - p3[1] * w3 - p0[1], - p3[2] * w3 - p0[2], + const projectiveColumn = ([x, y]: Vec2, weight: number): Vec3 => [ + (weight - 1) * tx + (weight * x - q0[0]) * xAxis[0] + (weight * y - q0[1]) * yAxis[0], + (weight - 1) * ty + (weight * x - q0[0]) * xAxis[1] + (weight * y - q0[1]) * yAxis[1], + (weight - 1) * tz + (weight * x - q0[0]) * xAxis[2] + (weight * y - q0[1]) * yAxis[2], ]; return formatMatrix3dValues([ - xCol[0], xCol[1], xCol[2], g, - yCol[0], yCol[1], yCol[2], h, + ...projectiveColumn(q1, w1), g, + ...projectiveColumn(q3, w3), h, normal[0], normal[1], normal[2], 0, p0[0], p0[1], p0[2], 1, ]); @@ -1397,6 +1455,7 @@ function computeTextureAtlasPlan( polygon: Polygon, index: number, options: RenderTextureAtlasOptions, + projectiveQuadGuards: ProjectiveQuadGuardSettings, basisHint?: BasisHint, ): TextureAtlasPlan | null { const { vertices, texture, uvs } = polygon; @@ -1457,7 +1516,16 @@ function computeTextureAtlasPlan( tx, ty, tz, 1, ]); const projectiveMatrix = !texture && vertices.length === 4 - ? computeProjectiveQuadMatrix(screenPts, xAxis, yAxis, normal, tx, ty, tz) + ? computeProjectiveQuadMatrix( + screenPts, + xAxis, + yAxis, + normal, + tx, + ty, + tz, + projectiveQuadGuards, + ) : null; const directionalCfg = options.directionalLight; @@ -2223,32 +2291,49 @@ function formatScaledMatrixFromPlan( entry: TextureAtlasPlan, scaleX: number, scaleY: number, + offsetX = 0, + offsetY = 0, ): string { const values = entry.matrix.split(",").map((value) => Number(value)); if (values.length !== 16 || values.some((value) => !Number.isFinite(value))) { return entry.matrix; } + const x0 = values[0]; + const x1 = values[1]; + const x2 = values[2]; + const y0 = values[4]; + const y1 = values[5]; + const y2 = values[6]; values[0] *= scaleX; values[1] *= scaleX; values[2] *= scaleX; values[4] *= scaleY; values[5] *= scaleY; values[6] *= scaleY; + values[12] += offsetX * x0 + offsetY * y0; + values[13] += offsetX * x1 + offsetY * y1; + values[14] += offsetX * x2 + offsetY * y2; return formatMatrix3dValues(values); } -function formatBorderShapeMatrix(entry: TextureAtlasPlan): string { +function formatBorderShapeMatrix( + entry: TextureAtlasPlan, + bounds: BorderShapeBounds, +): string { return formatScaledMatrixFromPlan( entry, - (entry.canvasW || 1) / BORDER_SHAPE_CANONICAL_SIZE, - (entry.canvasH || 1) / BORDER_SHAPE_CANONICAL_SIZE, + bounds.width / BORDER_SHAPE_CANONICAL_SIZE, + bounds.height / BORDER_SHAPE_CANONICAL_SIZE, + bounds.minX, + bounds.minY, ); } function formatBorderShapeElementStyle(entry: TextureAtlasPlan): string { + const geometry = borderShapeGeometryForPlan(entry); return [ - `transform:matrix3d(${formatBorderShapeMatrix(entry)})`, - `border-shape:${cssBorderShapeForPlan(entry)}`, + `transform:matrix3d(${formatBorderShapeMatrix(entry, geometry.bounds)})`, + `border-shape:${cssBorderShapeForGeometry(geometry.points)}`, ].join(";"); } @@ -2508,8 +2593,9 @@ export function getSolidPaintDefaults( if (!doc) return {}; const disabled = new Set(options.strategies?.disable ?? []); const basisHints = buildBasisHints(polygons, options); + const projectiveQuadGuards = resolveProjectiveQuadGuards(doc); const plans = polygons.map((polygon, index) => - computeTextureAtlasPlan(polygon, index, options, basisHints[index]) + computeTextureAtlasPlan(polygon, index, options, projectiveQuadGuards, basisHints[index]) ); return getSolidPaintDefaultsForPlans( plans, @@ -2519,16 +2605,55 @@ export function getSolidPaintDefaults( ); } -function borderShapePointsForPlan(entry: TextureAtlasPlan): Array<[number, number]> { +function borderShapeBoundsFromPoints( + points: number[], + fallbackWidth: number, + fallbackHeight: number, +): BorderShapeBounds { + let minX = Number.POSITIVE_INFINITY; + let minY = Number.POSITIVE_INFINITY; + let maxX = Number.NEGATIVE_INFINITY; + let maxY = Number.NEGATIVE_INFINITY; + for (let i = 0; i < points.length; i += 2) { + const x = points[i]; + const y = points[i + 1]; + if (!Number.isFinite(x) || !Number.isFinite(y)) continue; + minX = Math.min(minX, x); + minY = Math.min(minY, y); + maxX = Math.max(maxX, x); + maxY = Math.max(maxY, y); + } + const width = maxX - minX; + const height = maxY - minY; + if ( + !Number.isFinite(minX) || + !Number.isFinite(minY) || + !Number.isFinite(width) || + !Number.isFinite(height) || + width <= BASIS_EPS || + height <= BASIS_EPS + ) { + return { minX: 0, minY: 0, width: fallbackWidth, height: fallbackHeight }; + } + return { minX, minY, width, height }; +} + +function borderShapeGeometryForPlan(entry: TextureAtlasPlan): BorderShapeGeometry { + const fallbackWidth = entry.canvasW || 1; + const fallbackHeight = entry.canvasH || 1; + const sourcePts = BORDER_SHAPE_BLEED > 0 + ? offsetConvexPolygonPoints(entry.screenPts, BORDER_SHAPE_BLEED) + : entry.screenPts; + const bounds = BORDER_SHAPE_BLEED > 0 + ? borderShapeBoundsFromPoints(sourcePts, fallbackWidth, fallbackHeight) + : { minX: 0, minY: 0, width: fallbackWidth, height: fallbackHeight }; const points: Array<[number, number]> = []; - const width = entry.canvasW || 1; - const height = entry.canvasH || 1; - for (let i = 0; i < entry.screenPts.length; i += 2) { - const x = Math.max(0, Math.min(100, (entry.screenPts[i] / width) * 100)); - const y = Math.max(0, Math.min(100, (entry.screenPts[i + 1] / height) * 100)); + for (let i = 0; i < sourcePts.length; i += 2) { + const x = Math.max(0, Math.min(100, ((sourcePts[i] - bounds.minX) / bounds.width) * 100)); + const y = Math.max(0, Math.min(100, ((sourcePts[i + 1] - bounds.minY) / bounds.height) * 100)); points.push([x, y]); } - return points; + return { bounds, points }; } function cssBorderShapePoint([x, y]: [number, number]): string { @@ -2554,11 +2679,14 @@ function cssCollapsedInnerShapeForPoints(points: Array<[number, number]>): strin return `circle(0 at ${x} ${y})`; } -export function cssBorderShapeForPlan(entry: TextureAtlasPlan): string { - const points = borderShapePointsForPlan(entry); +function cssBorderShapeForGeometry(points: Array<[number, number]>): string { return `${cssPolygonShapeForPoints(points)} ${cssCollapsedInnerShapeForPoints(points)}`; } +export function cssBorderShapeForPlan(entry: TextureAtlasPlan): string { + return cssBorderShapeForGeometry(borderShapeGeometryForPlan(entry).points); +} + function applySolidPaint( el: HTMLElement, entry: TextureAtlasPlan, @@ -2659,8 +2787,9 @@ export function renderPolygonsWithTextureAtlas( const useStableTriangle = !disabled.has("u"); const useBorderShape = !disabled.has("i") && borderShapeSupported(doc); const basisHints = buildBasisHints(polygons, options); + const projectiveQuadGuards = resolveProjectiveQuadGuards(doc); const plans = polygons.map((polygon, index) => - computeTextureAtlasPlan(polygon, index, options, basisHints[index]) + computeTextureAtlasPlan(polygon, index, options, projectiveQuadGuards, basisHints[index]) ); const trianglePlans = plans.map((plan) => plan && useStableTriangle && isSolidTrianglePlan(plan) @@ -2765,10 +2894,11 @@ export async function renderPolygonsWithTextureAtlasAsync( if (shouldCancel()) return { rendered: [], solidPaintDefaults: {}, dispose: () => {} }; const basisHints = buildBasisHints(polygons, options); + const projectiveQuadGuards = resolveProjectiveQuadGuards(doc); let batchStarted = performance.now(); const plans: Array = new Array(polygons.length); for (let i = 0; i < polygons.length; i++) { - plans[i] = computeTextureAtlasPlan(polygons[i], i, options, basisHints[i]); + plans[i] = computeTextureAtlasPlan(polygons[i], i, options, projectiveQuadGuards, basisHints[i]); batchStarted = await yieldIfOverBudget(batchStarted); if (shouldCancel()) return { rendered: [], solidPaintDefaults: {}, dispose: () => {} }; } diff --git a/website/src/components/Dock/Dock.tsx b/website/src/components/Dock/Dock.tsx index 702e97e5..82f0bfb3 100644 --- a/website/src/components/Dock/Dock.tsx +++ b/website/src/components/Dock/Dock.tsx @@ -115,7 +115,6 @@ export function Dock({ shapeRectangle: 0, shapeTriangle: 0, shapeIrregular: 0, - overpaintPercent: 0, // The Texture Quality row binds the slider to `textureQualityValue` // and the Auto toggle to `textureQualityAuto`. The effective option // passed to the scene is "auto" when textureQualityAuto is true, else @@ -223,9 +222,6 @@ export function Dock({ const bToggle = injectStrategyCheckbox(shapeRectangleController, "b"); const uToggle = injectStrategyCheckbox(shapeTriangleController, "u"); const iToggle = injectStrategyCheckbox(shapeIrregularController, "i"); - const overpaintPercentController = disableWithoutDisabledClass( - model.add(modelState, "overpaintPercent").name("Overpaint %"), - ); const rendering = gui.addFolder("Rendering"); rendering.open(); @@ -499,9 +495,6 @@ export function Dock({ meshResolutionController.disable(); meshInteriorFillController.disable(); } - if (hasSpriteLeaves) { - meshInteriorFillController.hide(); - } if (!sceneOptions.selection) { gizmoController.disable(); } @@ -524,7 +517,6 @@ export function Dock({ bToggle, uToggle, iToggle, - overpaintPercent: overpaintPercentController, meshResolution: meshResolutionController, meshInteriorFill: meshInteriorFillController, textureQuality: textureQualityController, @@ -614,7 +606,6 @@ export function Dock({ setCtrlValue("meshResolution", sceneOptions.meshResolution); setCtrlValue("meshInteriorFill", sceneOptions.meshInteriorFill); setCtrlValue("textureMode", textureModeForScene(sceneOptions)); - setVisible("meshInteriorFill", !hasSpriteLeaves); setVisible("textureMode", hasSpriteLeaves); setVisible("textureQuality", hasSpriteLeaves); setCtrlValue("domCount", metrics.nodeCount); @@ -628,7 +619,6 @@ export function Dock({ if (bToggleEl) bToggleEl.checked = !sceneOptions.disableStrategies.includes("b"); if (uToggleEl) uToggleEl.checked = !sceneOptions.disableStrategies.includes("u"); if (iToggleEl) iToggleEl.checked = !sceneOptions.disableStrategies.includes("i"); - setCtrlValue("overpaintPercent", metrics.overpaintPercent); const validAnimation = Object.values(animationOptions).includes(selectedAnimation); const nextAnimation = validAnimation ? selectedAnimation : ""; @@ -699,7 +689,7 @@ export function Dock({ setCtrlValue("ambientColor", sceneOptions.ambientColor); setEnabled("meshResolution", !hasActiveAnimation); - setEnabled("meshInteriorFill", !hasActiveAnimation && !hasSpriteLeaves); + setEnabled("meshInteriorFill", !hasActiveAnimation); setEnabled("gizmoMode", sceneOptions.selection); if (sceneOptions.perspective === false) { @@ -716,7 +706,6 @@ export function Dock({ shapeRectangle?: number; shapeTriangle?: number; shapeIrregular?: number; - overpaintPercent?: number; textureQualityValue?: number; textureQualityAuto?: boolean; textureMode?: TextureMode; @@ -729,7 +718,6 @@ export function Dock({ modelState.shapeRectangle = metrics.rects; modelState.shapeTriangle = metrics.triangles; modelState.shapeIrregular = metrics.irregular; - modelState.overpaintPercent = metrics.overpaintPercent; modelState.textureMode = textureModeForScene(sceneOptions); // Mirror external textureQuality changes back into the slider state. // Numeric → slider value + auto off (slider enabled); "auto" → keep diff --git a/website/src/components/GalleryWorkbench/GalleryWorkbench.tsx b/website/src/components/GalleryWorkbench/GalleryWorkbench.tsx index 178a26c6..93c9c02d 100644 --- a/website/src/components/GalleryWorkbench/GalleryWorkbench.tsx +++ b/website/src/components/GalleryWorkbench/GalleryWorkbench.tsx @@ -34,7 +34,6 @@ import { stripParenthesizedText, } from "./presets"; import { - DOM_OVERPAINT_CACHE_EVENT, EMPTY_METRICS, measureDom, } from "./helpers/domMetrics"; @@ -99,7 +98,7 @@ const DEFAULT_SCENE: SceneOptionsState = { matrixPrecision: "exact", borderShapePrecision: "exact", meshResolution: "lossy", - meshInteriorFill: false, + meshInteriorFill: true, outlinePolygons: false, dragMode: "orbit", target: [0, 0, 0], @@ -388,7 +387,7 @@ export default function GalleryWorkbench() { animationTimeScale: sceneOptions.animationTimeScale, }); - const { scenePolygons, helperScale, helperTarget } = useScenePolygons({ + const { modelPolygons, interiorFillPolygons, scenePolygons, helperScale, helperTarget } = useScenePolygons({ loaded, hasActiveAnimation, meshResolution: sceneOptions.meshResolution, @@ -396,11 +395,23 @@ export default function GalleryWorkbench() { reactAnimatedPolygons: animation.reactAnimatedPolygons, meshInteriorFill: sceneOptions.meshInteriorFill, }); - const renderPolygons = useMemo( + const renderModelPolygons = useMemo( + () => sceneOptions.solidMaterials + ? withSolidMaterials(modelPolygons, parserOptions.defaultColor) + : modelPolygons, + [modelPolygons, sceneOptions.solidMaterials, parserOptions.defaultColor], + ); + const renderInteriorFillPolygons = useMemo( () => sceneOptions.solidMaterials - ? withSolidMaterials(scenePolygons, parserOptions.defaultColor) - : scenePolygons, - [scenePolygons, sceneOptions.solidMaterials, parserOptions.defaultColor], + ? withSolidMaterials(interiorFillPolygons, parserOptions.defaultColor) + : interiorFillPolygons, + [interiorFillPolygons, sceneOptions.solidMaterials, parserOptions.defaultColor], + ); + const renderPolygons = useMemo( + () => renderInteriorFillPolygons.length > 0 + ? [...renderModelPolygons, ...renderInteriorFillPolygons] + : renderModelPolygons, + [renderModelPolygons, renderInteriorFillPolygons], ); const hasSpriteLeaves = useMemo( () => metrics.sprites > 0 || scenePolygons.some(polygonHasTextureData), @@ -480,10 +491,8 @@ export default function GalleryWorkbench() { attributes: true, attributeFilter: ["class", "style"], }); - window.addEventListener(DOM_OVERPAINT_CACHE_EVENT, schedule); return () => { observer.disconnect(); - window.removeEventListener(DOM_OVERPAINT_CACHE_EVENT, schedule); if (raf) cancelAnimationFrame(raf); }; }, []); @@ -564,15 +573,15 @@ export default function GalleryWorkbench() { const perspectivePx = sceneOptions.perspective === false ? 8000 : sceneOptions.perspective; // Inspector data — grouped by mesh, then by polygon color. Recomputed - // when renderPolygons or the loaded model change. Mutations to a + // when renderModelPolygons or the loaded model change. Mutations to a // polygon's color via the picker do NOT change the renderPolygons // reference, so this memo doesn't re-fire on each tweak and the swatch // local state stays in sync. const inspectorMeshes = useMemo(() => { - if (renderPolygons.length === 0) return []; + if (renderModelPolygons.length === 0) return []; const colorGroups = new Map(); const textured: Polygon[] = []; - for (const p of renderPolygons) { + for (const p of renderModelPolygons) { if (p.texture) { textured.push(p); continue; @@ -605,7 +614,7 @@ export default function GalleryWorkbench() { } const label = loaded?.label ?? "model"; return [{ id: label, label, groups }]; - }, [renderPolygons, loaded?.label]); + }, [renderModelPolygons, loaded?.label]); const handleInspectorColorChange = useCallback( ( @@ -619,9 +628,9 @@ export default function GalleryWorkbench() { // merged copy that doesn't see in-place edits. setPolygons without // an explicit merge flag reuses the mesh's current merge setting // (true for static models, false during animation playback). - if (handle) handle.setPolygons(renderPolygons); + if (handle) handle.setPolygons(renderModelPolygons); }, - [renderPolygons], + [renderModelPolygons], ); return ( @@ -673,7 +682,8 @@ export default function GalleryWorkbench() { {sceneOptions.renderer === "vanilla" ? ( (); -export const SPRITE_ALPHA_PENDING = new Set(); -export const SPRITE_ALPHA_IMAGE_CACHE = new Map>(); export const EMPTY_METRICS: DomMetrics = { measuredAt: 0, @@ -13,175 +7,11 @@ export const EMPTY_METRICS: DomMetrics = { rects: 0, triangles: 0, irregular: 0, - overpaintPercent: 0, }; -function clamp(value: number, min: number, max: number): number { - if (!Number.isFinite(value)) return min; - return Math.min(Math.max(value, min), max); -} - -export function loadAlphaImage(url: string): Promise { - let promise = SPRITE_ALPHA_IMAGE_CACHE.get(url); - if (promise) return promise; - - promise = new Promise((resolve, reject) => { - const img = new Image(); - img.decoding = "async"; - img.onload = () => resolve(img); - img.onerror = () => reject(new Error(`alpha image load failed: ${url}`)); - img.src = url; - }); - SPRITE_ALPHA_IMAGE_CACHE.set(url, promise); - return promise; -} - -export function emitOverpaintCacheUpdate(): void { - if (typeof window === "undefined") return; - window.dispatchEvent(new Event(DOM_OVERPAINT_CACHE_EVENT)); -} - -async function sampleSpriteAlpha( - key: string, - url: string, - cssX: number, - cssY: number, - cssW: number, - cssH: number, - cssBackgroundW: number, - cssBackgroundH: number, -): Promise { - try { - const img = await loadAlphaImage(url); - const scaleX = img.naturalWidth / cssBackgroundW; - const scaleY = img.naturalHeight / cssBackgroundH; - if (!Number.isFinite(scaleX) || !Number.isFinite(scaleY) || scaleX <= 0 || scaleY <= 0) return; - - const sx = Math.max(0, Math.round(cssX * scaleX)); - const sy = Math.max(0, Math.round(cssY * scaleY)); - const sw = Math.max(1, Math.min(img.naturalWidth - sx, Math.round(cssW * scaleX))); - const sh = Math.max(1, Math.min(img.naturalHeight - sy, Math.round(cssH * scaleY))); - if (sw <= 0 || sh <= 0) return; - - const canvas = document.createElement("canvas"); - canvas.width = sw; - canvas.height = sh; - const ctx = canvas.getContext("2d", { willReadFrequently: true }); - if (!ctx) return; - - ctx.drawImage(img, sx, sy, sw, sh, 0, 0, sw, sh); - const pixels = ctx.getImageData(0, 0, sw, sh).data; - let alpha = 0; - for (let i = 3; i < pixels.length; i += 4) alpha += pixels[i] / 255; - SPRITE_ALPHA_CACHE.set(key, alpha / (pixels.length / 4)); - } catch { - SPRITE_ALPHA_CACHE.set(key, 1); - } finally { - SPRITE_ALPHA_PENDING.delete(key); - emitOverpaintCacheUpdate(); - } -} - -export function spriteAtlasAlpha(element: HTMLElement): number | null { - const view = element.ownerDocument.defaultView; - if (!view || typeof Image === "undefined") return null; - - const style = view.getComputedStyle(element); - const url = cssUrlValue(style.backgroundImage) - ?? cssUrlValue(style.getPropertyValue("-webkit-mask-image")) - ?? cssUrlValue(style.getPropertyValue("mask-image")) - ?? cssUrlValue(style.background); - if (!url) return null; - - const width = cssPxValue(style.width) ?? element.offsetWidth; - const height = cssPxValue(style.height) ?? element.offsetHeight; - const positionX = cssNumberValue(style.backgroundPositionX) ?? 0; - const positionY = cssNumberValue(style.backgroundPositionY) ?? 0; - const maskPosition = style.getPropertyValue("-webkit-mask-position") || style.getPropertyValue("mask-position"); - const [maskPositionXRaw, maskPositionYRaw] = maskPosition.split(/\s+/); - const cssX = -(cssNumberValue(style.backgroundPositionX) ?? cssNumberValue(maskPositionXRaw) ?? positionX); - const cssY = -(cssNumberValue(style.backgroundPositionY) ?? cssNumberValue(maskPositionYRaw) ?? positionY); - const size = style.backgroundSize || style.getPropertyValue("-webkit-mask-size") || style.getPropertyValue("mask-size"); - const [backgroundWidthRaw, backgroundHeightRaw] = size.split(/\s+/); - const backgroundWidth = cssPxValue(backgroundWidthRaw); - const backgroundHeight = cssPxValue(backgroundHeightRaw); - if (!width || !height || !backgroundWidth || !backgroundHeight) return null; - - const key = [ - url, - cssX.toFixed(3), - cssY.toFixed(3), - width.toFixed(3), - height.toFixed(3), - backgroundWidth.toFixed(3), - backgroundHeight.toFixed(3), - ].join("|"); - - const cached = SPRITE_ALPHA_CACHE.get(key); - if (cached !== undefined) return cached; - - if (!SPRITE_ALPHA_PENDING.has(key)) { - SPRITE_ALPHA_PENDING.add(key); - void sampleSpriteAlpha(key, url, cssX, cssY, width, height, backgroundWidth, backgroundHeight); - } - - return null; -} - -export function elementPaintAlphaSample(element: HTMLElement): { alpha: number; area: number } | null { - const { width, height } = localElementSize(element); - const area = Math.max(1, width * height); - - if (element.tagName === "U") { - return { - alpha: 0.5 * cssPaintAlpha(element, ["border-bottom-color", "color", "--polycss-paint"]), - area, - }; - } - - if (element.tagName === "I") { - return { - alpha: cssPaintAlpha(element, ["border-bottom-color", "border-color", "color", "--polycss-paint"]), - area, - }; - } - - if (element.tagName === "S") { - const alpha = spriteAtlasAlpha(element) - ?? cssPaintAlpha(element, ["background-color", "background"]); - return { alpha, area }; - } - - if (element.tagName === "B") { - return { - alpha: cssPaintAlpha(element, ["background-color", "background", "color", "--polycss-paint"]), - area, - }; - } - - return null; -} - -export function measureDomOverpaintPercent(scopes: HTMLElement[]): number { - let weightedPaintAlpha = 0; - let totalArea = 0; - - for (const scope of scopes) { - const elements = scope.querySelectorAll("b, u, s, i"); - for (const element of elements) { - const sample = elementPaintAlphaSample(element); - if (!sample) continue; - weightedPaintAlpha += clamp(sample.alpha, 0, 1) * sample.area; - totalArea += sample.area; - } - } - - return totalArea > 0 ? Number(((1 - weightedPaintAlpha / totalArea) * 100).toFixed(1)) : 0; -} - export function measureDom(root: HTMLElement | null): DomMetrics { if (!root) return EMPTY_METRICS; - const modelScopes = Array.from(root.querySelectorAll(".dn-model-mesh")); + const modelScopes = Array.from(root.querySelectorAll(".dn-model-mesh, .dn-interior-fill-mesh")); if (modelScopes.length === 0) return EMPTY_METRICS; const scopes = modelScopes; const countInScopes = (selector: string): number => @@ -195,6 +25,5 @@ export function measureDom(root: HTMLElement | null): DomMetrics { rects: countInScopes("b"), triangles: countInScopes("u"), irregular: countInScopes("i"), - overpaintPercent: measureDomOverpaintPercent(scopes), }; } diff --git a/website/src/components/GalleryWorkbench/helpers/interiorFill.ts b/website/src/components/GalleryWorkbench/helpers/interiorFill.ts index 33556b64..3d5ca6f6 100644 --- a/website/src/components/GalleryWorkbench/helpers/interiorFill.ts +++ b/website/src/components/GalleryWorkbench/helpers/interiorFill.ts @@ -1,17 +1,15 @@ import type { Polygon, Vec3 } from "@layoutit/polycss"; import { solidColorToHex } from "./debugPrecision"; -// ── Types ──────────────────────────────────────────────────────────────────── +type AxisIndex = 0 | 1 | 2; +type Point2 = [number, number]; -export type AxisIndex = 0 | 1 | 2; -export type Point2 = [number, number]; - -export interface Segment2 { +interface Segment2 { a: Point2; b: Point2; } -export interface InteriorFillInterval { +interface InteriorFillInterval { row: number; y: number; x0: number; @@ -19,18 +17,18 @@ export interface InteriorFillInterval { length: number; } -export interface InteriorFillSlice { - points: Point2[]; +interface InteriorFillSlice { + fixedAxis: AxisIndex; + axisA: AxisIndex; + axisB: AxisIndex; planeValue: number; + points: Point2[]; area: number; - center: Point2; } -export interface InteriorFillPlaneSlice { - fixedAxis: AxisIndex; - axisA: AxisIndex; - axisB: AxisIndex; - slice: InteriorFillSlice; +interface InteriorFillComponent { + points: Point2[]; + area: number; } export interface PolygonBounds { @@ -42,38 +40,25 @@ export interface PolygonBounds { maxSpan: number; } -// ── Constants ──────────────────────────────────────────────────────────────── - -export const INTERIOR_FILL_MIN_MAX_SPAN = 8; -export const INTERIOR_FILL_MIN_DIAGONAL = 10; -export const INTERIOR_FILL_SOLID_COVERAGE_MIN = 0.2; -export const INTERIOR_FILL_MIN_PLANE_AREA_RATIO = 0.12; -export const INTERIOR_FILL_MIN_SLICE_AREA_RATIO = 0.01; -export const INTERIOR_FILL_SCAN_ROWS = 72; -export const INTERIOR_FILL_GRID_COLUMNS = 96; -export const INTERIOR_FILL_SLICE_SAMPLES = 31; -export const INTERIOR_FILL_SLICE_MARGIN = 0.08; -export const INTERIOR_FILL_EXTRA_SLICE_MIN_AREA_RATIO = 0.35; -export const INTERIOR_FILL_MIN_PLANE_SEPARATION_RATIO = 0.14; -export const INTERIOR_FILL_OPEN_RADIUS_RATIO = 0.06; -export const INTERIOR_FILL_END_TRIM_LENGTH_RATIO = 0.45; -export const INTERIOR_FILL_END_TRIM_MIN_ROWS = 6; -export const INTERIOR_FILL_SIDE_TRIM_WINDOW = 2; -export const INTERIOR_FILL_SIDE_TRIM_QUANTILE = 0.6; -export const INTERIOR_FILL_SIDE_TRIM_MIN_LENGTH_RATIO = 0.24; -export const INTERIOR_FILL_INTERVAL_MIN_LENGTH_RATIO = 0.28; -export const INTERIOR_FILL_INTERVAL_OVERLAP_RATIO = 0.08; -export const INTERIOR_FILL_MIN_INTERVAL_ROWS = 4; -export const INTERIOR_FILL_INSET_DISTANCE_RATIO = 0.025; -export const INTERIOR_FILL_INSET_MAX_DISTANCE_RATIO = 0.08; -export const INTERIOR_FILL_MAX_MITER_RATIO = 4; -export const INTERIOR_FILL_SECONDARY_COMPONENT_AREA_RATIO = 0.68; -export const INTERIOR_FILL_MAX_COMPONENTS_PER_SLICE = 2; -export const INTERIOR_FILL_MAX_PLANES = 6; - -// ── Primitive helpers ──────────────────────────────────────────────────────── - -export function clamp(value: number, min: number, max: number): number { +const INTERIOR_FILL_MIN_MAX_SPAN = 8; +const INTERIOR_FILL_MIN_DIAGONAL = 10; +const INTERIOR_FILL_SOLID_COVERAGE_MIN = 0.2; +const INTERIOR_FILL_MIN_PLANE_AREA_RATIO = 0.12; +const INTERIOR_FILL_MIN_SLICE_AREA_RATIO = 0.008; +const INTERIOR_FILL_SCAN_ROWS = 64; +const INTERIOR_FILL_SLICE_POSITIONS = [0.28, 0.5, 0.72] as const; +const INTERIOR_FILL_INTERVAL_MIN_LENGTH_RATIO = 0.18; +const INTERIOR_FILL_INTERVAL_OVERLAP_RATIO = 0.12; +const INTERIOR_FILL_MIN_INTERVAL_ROWS = 4; +const INTERIOR_FILL_MAX_COMPONENTS_PER_SLICE = 2; +const INTERIOR_FILL_INSET_RATIO = 0.025; +const INTERIOR_FILL_OVAL_SCALE = 0.82; +const INTERIOR_FILL_OVAL_MARGIN_RATIO = 0.025; +const INTERIOR_FILL_OVAL_SAMPLES = 9; +const INTERIOR_FILL_MAX_SLICES = 9; +const EPS = 1e-6; + +function clamp(value: number, min: number, max: number): number { if (!Number.isFinite(value)) return min; return Math.min(Math.max(value, min), max); } @@ -91,10 +76,11 @@ export function polygonArea(polygon: Polygon): number { const bx = b[0] - origin[0]; const by = b[1] - origin[1]; const bz = b[2] - origin[2]; - const cx = ay * bz - az * by; - const cy = az * bx - ax * bz; - const cz = ax * by - ay * bx; - area += Math.hypot(cx, cy, cz) * 0.5; + area += Math.hypot( + ay * bz - az * by, + az * bx - ax * bz, + ax * by - ay * bx, + ) * 0.5; } return area; } @@ -165,14 +151,9 @@ export function dominantSolidColor(polygons: Polygon[]): string | null { return bestColor; } -// ── Top-level entry points ─────────────────────────────────────────────────── - -export function withInteriorFillPolygons(polygons: Polygon[]): Polygon[] { - const fill = interiorFillPolygons(polygons); - return fill.length > 0 ? [...fill, ...polygons] : polygons; -} - -export function interiorFillPolygons(polygons: Polygon[]): Polygon[] { +export function interiorFillPolygons( + polygons: Polygon[], +): Polygon[] { const bounds = polygonBounds(polygons); if (!bounds) return []; if ( @@ -185,215 +166,100 @@ export function interiorFillPolygons(polygons: Polygon[]): Polygon[] { const color = dominantSolidColor(polygons); if (!color) return []; - let targetPlaneCount = automaticInteriorFillPlaneCount(polygons, bounds); - const candidates = [ - { fixedAxis: 2 as AxisIndex, axisA: 0 as AxisIndex, axisB: 1 as AxisIndex, area: bounds.span[0] * bounds.span[1] }, - { fixedAxis: 1 as AxisIndex, axisA: 0 as AxisIndex, axisB: 2 as AxisIndex, area: bounds.span[0] * bounds.span[2] }, - { fixedAxis: 0 as AxisIndex, axisA: 1 as AxisIndex, axisB: 2 as AxisIndex, area: bounds.span[1] * bounds.span[2] }, - ].sort((a, b) => b.area - a.area); - - const maxArea = candidates[0]?.area ?? 0; - const minArea = maxArea * INTERIOR_FILL_MIN_PLANE_AREA_RATIO; - const groups: InteriorFillPlaneSlice[][] = []; - for (const candidate of candidates) { - if (candidate.area <= minArea) continue; - const slices = interiorFillCandidateSlices( - polygons, - bounds, - candidate.fixedAxis, - candidate.axisA, - candidate.axisB, - candidate.area, - INTERIOR_FILL_MAX_PLANES, - ); - if (slices.length === 0) continue; - groups.push(slices.map((slice): InteriorFillPlaneSlice => ({ - fixedAxis: candidate.fixedAxis, - axisA: candidate.axisA, - axisB: candidate.axisB, - slice, - }))); - } - if (groups.some(hasComparableCoPlanarCavities)) { - targetPlaneCount = Math.min(INTERIOR_FILL_MAX_PLANES, targetPlaneCount + 1); - } - - const selected: InteriorFillPlaneSlice[] = []; - const selectedKeys = new Set(); - const addSlice = (candidate: InteriorFillPlaneSlice): void => { - const key = interiorFillPlaneSliceKey(candidate); - if (selectedKeys.has(key) || selected.length >= targetPlaneCount) return; - selectedKeys.add(key); - selected.push(candidate); - }; - - for (const group of groups) addSlice(group[0]); - while (selected.length < targetPlaneCount) { - let best: InteriorFillPlaneSlice | null = null; - for (const group of groups) { - for (let i = 1; i < group.length; i += 1) { - const candidate = group[i]; - const key = interiorFillPlaneSliceKey(candidate); - if (selectedKeys.has(key)) continue; - if (!best || candidate.slice.area > best.slice.area) best = candidate; - } + const planes = candidatePlanes(bounds); + const slices: InteriorFillSlice[] = []; + for (const plane of planes) { + const area = bounds.span[plane.axisA] * bounds.span[plane.axisB]; + for (const position of INTERIOR_FILL_SLICE_POSITIONS) { + const planeValue = bounds.min[plane.fixedAxis] + bounds.span[plane.fixedAxis] * position; + slices.push(...interiorFillSlicesAtPlane( + polygons, + bounds, + plane.fixedAxis, + plane.axisA, + plane.axisB, + planeValue, + area, + )); } - if (!best) break; - addSlice(best); } - const fill: Polygon[] = []; - for (const selectedSlice of selected) { - fill.push(...interiorFillPlaneFromSlice(bounds, selectedSlice, color)); - } - return fill; -} - -// ── Plane-slice helpers ────────────────────────────────────────────────────── - -export function hasComparableCoPlanarCavities(slices: InteriorFillPlaneSlice[]): boolean { - for (let i = 0; i < slices.length; i += 1) { - for (let j = i + 1; j < slices.length; j += 1) { - if (Math.abs(slices[i].slice.planeValue - slices[j].slice.planeValue) > 1e-5) continue; - if ( - Math.min(slices[i].slice.area, slices[j].slice.area) >= - Math.max(slices[i].slice.area, slices[j].slice.area) * INTERIOR_FILL_SECONDARY_COMPONENT_AREA_RATIO - ) { - return true; - } - } - } - return false; + slices.sort((a, b) => b.area - a.area); + return slices + .slice(0, INTERIOR_FILL_MAX_SLICES) + .flatMap((slice) => interiorFillPolygonsFromSlice(bounds, slice, color)); } -export function interiorFillPlaneSliceKey(candidate: InteriorFillPlaneSlice): string { - return [ - candidate.fixedAxis, - candidate.slice.planeValue.toFixed(5), - candidate.slice.center[0].toFixed(2), - candidate.slice.center[1].toFixed(2), - ].join(":"); +function candidatePlanes(bounds: PolygonBounds): Array<{ fixedAxis: AxisIndex; axisA: AxisIndex; axisB: AxisIndex }> { + const candidates = [ + { fixedAxis: 2 as AxisIndex, axisA: 0 as AxisIndex, axisB: 1 as AxisIndex, area: bounds.span[0] * bounds.span[1] }, + { fixedAxis: 1 as AxisIndex, axisA: 0 as AxisIndex, axisB: 2 as AxisIndex, area: bounds.span[0] * bounds.span[2] }, + { fixedAxis: 0 as AxisIndex, axisA: 1 as AxisIndex, axisB: 2 as AxisIndex, area: bounds.span[1] * bounds.span[2] }, + ].sort((a, b) => b.area - a.area); + const minArea = (candidates[0]?.area ?? 0) * INTERIOR_FILL_MIN_PLANE_AREA_RATIO; + return candidates.filter((candidate) => candidate.area > minArea); } -export function automaticInteriorFillPlaneCount(polygons: Polygon[], bounds: PolygonBounds): number { - const nonZeroSpans = bounds.span.filter((span) => span > 1e-6).sort((a, b) => a - b); - const minSpan = nonZeroSpans[0] ?? bounds.maxSpan; - const aspect = minSpan > 0 ? bounds.maxSpan / minSpan : 1; - let planes = 3; - if (bounds.diagonal >= 28 || polygons.length >= 160 || aspect >= 3) planes = 4; - if (bounds.diagonal >= 42 || polygons.length >= 360 || aspect >= 4.5) planes = 5; - if (bounds.diagonal >= 80 && polygons.length >= 1400 && aspect >= 3) planes = 6; - return Math.min(planes, INTERIOR_FILL_MAX_PLANES); +function interiorFillPolygonsFromSlice( + bounds: PolygonBounds, + slice: InteriorFillSlice, + color: string, +): Polygon[] { + const rect = interiorFillOval2D(slice.points); + return rect ? doubleSidedSlicePolygon(bounds, slice, rect, color) : []; } -export function interiorFillPlaneFromSlice( +function doubleSidedSlicePolygon( bounds: PolygonBounds, - plane: InteriorFillPlaneSlice, + slice: InteriorFillSlice, + points: Point2[], color: string, ): [Polygon, Polygon] { const point = ([a, b]: Point2): Vec3 => { const vertex = [...bounds.center] as Vec3; - vertex[plane.fixedAxis] = plane.slice.planeValue; - vertex[plane.axisA] = a; - vertex[plane.axisB] = b; + vertex[slice.fixedAxis] = slice.planeValue; + vertex[slice.axisA] = a; + vertex[slice.axisB] = b; return vertex; }; - const vertices = plane.slice.points.map(point); + const vertices = points.map(point); return [ { vertices, color }, { vertices: [...vertices].reverse(), color }, ]; } -export function interiorFillCandidateSlices( +function interiorFillSlicesAtPlane( polygons: Polygon[], bounds: PolygonBounds, fixedAxis: AxisIndex, axisA: AxisIndex, axisB: AxisIndex, - candidateArea: number, - maxSlices: number, -): InteriorFillSlice[] { - const span = bounds.span[fixedAxis]; - if (!Number.isFinite(span) || span <= 0) return []; - - const candidates: InteriorFillSlice[] = []; - const usableStart = bounds.min[fixedAxis] + span * INTERIOR_FILL_SLICE_MARGIN; - const usableSpan = span * (1 - INTERIOR_FILL_SLICE_MARGIN * 2); - for (let i = 0; i < INTERIOR_FILL_SLICE_SAMPLES; i++) { - const planeValue = usableStart + ((i + 0.5) / INTERIOR_FILL_SLICE_SAMPLES) * usableSpan; - const slices = interiorFillSlicePolygons( - polygons, - bounds, - fixedAxis, - axisA, - axisB, - candidateArea, - planeValue, - ); - candidates.push(...slices); - } - candidates.sort((a, b) => b.area - a.area); - if (candidates.length === 0) return []; - - const minArea = Math.max( - candidateArea * INTERIOR_FILL_MIN_SLICE_AREA_RATIO, - candidates[0].area * INTERIOR_FILL_EXTRA_SLICE_MIN_AREA_RATIO, - ); - const minSeparation = span * INTERIOR_FILL_MIN_PLANE_SEPARATION_RATIO; - const selected: InteriorFillSlice[] = []; - for (const slice of candidates) { - if (slice.area < minArea) continue; - if (selected.some((current) => - Math.abs(current.planeValue - slice.planeValue) < minSeparation && - distance2D(current.center, slice.center) < Math.sqrt(Math.max(current.area, slice.area)) * 0.35 - )) { - continue; - } - selected.push(slice); - if (selected.length >= maxSlices) break; - } - return selected; -} - -export function interiorFillSlicePolygons( - polygons: Polygon[], - bounds: PolygonBounds, - fixedAxis: AxisIndex, - axisA: AxisIndex, - axisB: AxisIndex, - candidateArea: number, planeValue: number, + candidateArea: number, ): InteriorFillSlice[] { const tolerance = Math.max(bounds.diagonal * 1e-5, 1e-4); const segments: Segment2[] = []; - for (const polygon of polygons) { const segment = slicePolygonAtAxis(polygon, fixedAxis, axisA, axisB, planeValue, tolerance); if (segment) segments.push(segment); } - if (segments.length < 3) return []; - const spanA = bounds.span[axisA]; - const spanB = bounds.span[axisB]; - const first = scanlineCavityPolygons(segments, spanA < spanB, candidateArea, tolerance); - const second = scanlineCavityPolygons(segments, spanA >= spanB, candidateArea, tolerance); - const points = first.length === 0 - ? second - : second.length === 0 - ? first - : totalLoopArea2D(second) > totalLoopArea2D(first) - ? second - : first; - return points.map((loop): InteriorFillSlice => ({ - points: loop, + + const primary = scanlineSliceComponents(segments, false, candidateArea, tolerance); + const secondary = scanlineSliceComponents(segments, true, candidateArea, tolerance); + const components = totalComponentArea(secondary) > totalComponentArea(primary) ? secondary : primary; + return components.map((component): InteriorFillSlice => ({ + fixedAxis, + axisA, + axisB, planeValue, - area: Math.abs(loopArea2D(loop)), - center: loopCentroid2D(loop), - })); + points: component.points, + area: component.area, + })).filter((slice) => slice.area >= candidateArea * INTERIOR_FILL_MIN_SLICE_AREA_RATIO); } -export function slicePolygonAtAxis( +function slicePolygonAtAxis( polygon: Polygon, fixedAxis: AxisIndex, axisA: AxisIndex, @@ -406,8 +272,9 @@ export function slicePolygonAtAxis( if (vertices.every((vertex) => Math.abs(vertex[fixedAxis] - planeValue) <= tolerance)) { return null; } + const hits: Point2[] = []; - for (let i = 0; i < vertices.length; i++) { + for (let i = 0; i < vertices.length; i += 1) { const a = vertices[i]; const b = vertices[(i + 1) % vertices.length]; const da = a[fixedAxis] - planeValue; @@ -429,628 +296,362 @@ export function slicePolygonAtAxis( a[axisB] + (b[axisB] - a[axisB]) * t, ]); } + const unique = uniquePoints2D(hits, tolerance); if (unique.length < 2) return null; - let a = unique[0]; - let b = unique[1]; - let bestDistance = distance2D(a, b); - for (let i = 0; i < unique.length; i++) { - for (let j = i + 1; j < unique.length; j++) { + let best: Segment2 | null = null; + let bestDistance = 0; + for (let i = 0; i < unique.length; i += 1) { + for (let j = i + 1; j < unique.length; j += 1) { const distance = distance2D(unique[i], unique[j]); if (distance > bestDistance) { - a = unique[i]; - b = unique[j]; + best = { a: unique[i], b: unique[j] }; bestDistance = distance; } } } - - return bestDistance > tolerance ? { a, b } : null; -} - -export function uniquePoints2D(points: Point2[], tolerance: number): Point2[] { - const cellSize = Math.max(tolerance, 1e-6); - const seen = new Map(); - for (const point of points) { - const key = `${Math.round(point[0] / cellSize)},${Math.round(point[1] / cellSize)}`; - if (!seen.has(key)) seen.set(key, point); - } - return [...seen.values()]; + return best && bestDistance > tolerance ? best : null; } -export function scanlineCavityPolygons( +function scanlineSliceComponents( segments: Segment2[], swapAxes: boolean, candidateArea: number, tolerance: number, -): Point2[][] { +): InteriorFillComponent[] { const oriented = segments.map((segment): Segment2 => ({ a: orientPoint2D(segment.a, swapAxes), b: orientPoint2D(segment.b, swapAxes), })); - const intervals = scanlineIntervals(oriented, tolerance); - if (intervals.length === 0) return []; - - const opened = morphologicalCavityPolygons(intervals, candidateArea, tolerance); - if (opened.length > 0) { - return opened.map((loop) => loop.map((point) => orientPoint2D(point, swapAxes))); - } - - const maxLength = Math.max(...intervals.map((interval) => interval.length)); - const minLength = Math.max(maxLength * INTERIOR_FILL_INTERVAL_MIN_LENGTH_RATIO, tolerance * 4); - const kept = intervals.filter((interval) => interval.length >= minLength); - if (kept.length < INTERIOR_FILL_MIN_INTERVAL_ROWS) return []; - - const selected = largestIntervalComponent(kept); - if (selected.length < INTERIOR_FILL_MIN_INTERVAL_ROWS) return []; - - const byRow = new Map(); - for (const interval of selected) { - const current = byRow.get(interval.row); - if (!current || interval.length > current.length) byRow.set(interval.row, interval); - } - const rows = [...byRow.values()].sort((a, b) => a.row - b.row); - if (rows.length < INTERIOR_FILL_MIN_INTERVAL_ROWS) return []; - - const rowStep = rows.length > 1 - ? Math.abs(rows[1].y - rows[0].y) - : Math.sqrt(candidateArea) / INTERIOR_FILL_SCAN_ROWS; - const estimatedArea = rows.reduce((sum, row) => sum + row.length * rowStep, 0); - if (estimatedArea < candidateArea * INTERIOR_FILL_MIN_SLICE_AREA_RATIO) return []; - - const loop = [ - ...rows.map((row): Point2 => [row.x0, row.y]), - ...rows.slice().reverse().map((row): Point2 => [row.x1, row.y]), - ].map((point) => orientPoint2D(point, swapAxes)); - const cleaned = cleanLoop2D(loop, tolerance); - if (cleaned.length < 3) return []; - return [insetLoop2D(cleaned, tolerance)]; -} - -export function morphologicalCavityPolygons( - intervals: InteriorFillInterval[], - candidateArea: number, - tolerance: number, -): Point2[][] { - let minX = Infinity; - let maxX = -Infinity; - let minY = Infinity; - let maxY = -Infinity; - for (const interval of intervals) { - minX = Math.min(minX, interval.x0); - maxX = Math.max(maxX, interval.x1); - minY = Math.min(minY, interval.y); - maxY = Math.max(maxY, interval.y); - } - if (!Number.isFinite(minX) || maxX - minX <= tolerance || maxY - minY <= tolerance) return []; - - const grid = Array.from({ length: INTERIOR_FILL_SCAN_ROWS }, () => - new Array(INTERIOR_FILL_GRID_COLUMNS).fill(false) - ); - const width = maxX - minX; - for (const interval of intervals) { - const row = interval.row; - if (!grid[row]) continue; - const start = Math.max(0, Math.floor(((interval.x0 - minX) / width) * INTERIOR_FILL_GRID_COLUMNS)); - const end = Math.min( - INTERIOR_FILL_GRID_COLUMNS - 1, - Math.ceil(((interval.x1 - minX) / width) * INTERIOR_FILL_GRID_COLUMNS) - 1, - ); - for (let col = start; col <= end; col++) grid[row][col] = true; - } - - const radius = Math.max( - 1, - Math.round(Math.min(INTERIOR_FILL_SCAN_ROWS, INTERIOR_FILL_GRID_COLUMNS) * INTERIOR_FILL_OPEN_RADIUS_RATIO), - ); - const eroded = erodeGrid(grid, radius); - const components = largestGridComponents(eroded); - if (components.length === 0) return []; - - const rowStep = (maxY - minY) / INTERIOR_FILL_SCAN_ROWS; - const loops: Point2[][] = []; - for (const component of components) { - const opened = dilateGrid(component, radius); - const rows = trimCavityRows(refinedGridRowsToIntervals(opened, intervals, minX, maxX, minY, maxY)); - if (rows.length < INTERIOR_FILL_MIN_INTERVAL_ROWS) continue; - - const estimatedArea = rows.reduce((sum, row) => sum + row.length * rowStep, 0); - if (estimatedArea < candidateArea * INTERIOR_FILL_MIN_SLICE_AREA_RATIO) continue; - - const loop = [ - ...rows.map((row): Point2 => [row.x0, row.y]), - ...rows.slice().reverse().map((row): Point2 => [row.x1, row.y]), - ]; - const cleaned = cleanLoop2D(loop, tolerance); - if (cleaned.length >= 3) loops.push(insetLoop2D(cleaned, tolerance)); - } - return loops; -} - -export function trimCavityRows(rows: InteriorFillInterval[]): InteriorFillInterval[] { - if (rows.length < INTERIOR_FILL_END_TRIM_MIN_ROWS) return rows; - - const maxLength = Math.max(...rows.map((row) => row.length)); - const minEndLength = maxLength * INTERIOR_FILL_END_TRIM_LENGTH_RATIO; - let start = 0; - let end = rows.length; - while (end - start > INTERIOR_FILL_MIN_INTERVAL_ROWS && rows[start].length < minEndLength) { - start += 1; - } - while (end - start > INTERIOR_FILL_MIN_INTERVAL_ROWS && rows[end - 1].length < minEndLength) { - end -= 1; - } - const trimmed = start === 0 && end === rows.length ? rows : rows.slice(start, end); - return trimCavityRowSides(trimmed); -} - -export function trimCavityRowSides(rows: InteriorFillInterval[]): InteriorFillInterval[] { - if (rows.length < INTERIOR_FILL_END_TRIM_MIN_ROWS) return rows; - - const maxLength = Math.max(...rows.map((row) => row.length)); - const minLength = maxLength * INTERIOR_FILL_SIDE_TRIM_MIN_LENGTH_RATIO; - return rows.map((row, index) => { - const start = Math.max(0, index - INTERIOR_FILL_SIDE_TRIM_WINDOW); - const end = Math.min(rows.length, index + INTERIOR_FILL_SIDE_TRIM_WINDOW + 1); - const neighbors = rows.slice(start, end).filter((_, neighborIndex) => start + neighborIndex !== index); - if (neighbors.length < 2) return row; - - const leftLimit = quantile(neighbors.map((neighbor) => neighbor.x0), INTERIOR_FILL_SIDE_TRIM_QUANTILE); - const rightLimit = quantile(neighbors.map((neighbor) => neighbor.x1), 1 - INTERIOR_FILL_SIDE_TRIM_QUANTILE); - const x0 = Math.max(row.x0, leftLimit); - const x1 = Math.min(row.x1, rightLimit); - const length = x1 - x0; - if (length < minLength) return row; - return { ...row, x0, x1, length }; + const intervals = scanlineIntervals(oriented, candidateArea, tolerance); + if (intervals.length < INTERIOR_FILL_MIN_INTERVAL_ROWS) return []; + + const components = intervalComponents(intervals) + .filter((component) => component.length >= INTERIOR_FILL_MIN_INTERVAL_ROWS) + .slice(0, INTERIOR_FILL_MAX_COMPONENTS_PER_SLICE); + + return components.flatMap((component) => { + const loop = loopFromIntervals(component); + const area = Math.abs(loopArea2D(loop)); + if (loop.length < 3 || area < candidateArea * INTERIOR_FILL_MIN_SLICE_AREA_RATIO) { + return []; + } + const points = scaleLoopTowardCentroid2D(loop, INTERIOR_FILL_INSET_RATIO) + .map((point) => orientPoint2D(point, swapAxes)); + return [{ points, area: Math.abs(loopArea2D(points)) }]; }); } -export function quantile(values: number[], q: number): number { - const sorted = [...values].sort((a, b) => a - b); - const index = clamp(Math.round((sorted.length - 1) * q), 0, sorted.length - 1); - return sorted[index] ?? 0; -} - -export function erodeGrid(grid: boolean[][], radius: number): boolean[][] { - return grid.map((row, y) => row.map((filled, x) => { - if (!filled) return false; - for (let dy = -radius; dy <= radius; dy++) { - for (let dx = -radius; dx <= radius; dx++) { - if (Math.abs(dx) + Math.abs(dy) > radius) continue; - if (!grid[y + dy]?.[x + dx]) return false; +function interiorFillOval2D(points: Point2[]): Point2[] | null { + const normal = interiorFillOvalRect2D(points); + const transposed = interiorFillOvalRect2D(points.map(transposePoint2D))?.map(transposePoint2D) ?? null; + if (!normal) return transposed; + if (!transposed) return normal; + return Math.abs(loopArea2D(transposed)) > Math.abs(loopArea2D(normal)) ? transposed : normal; +} + +function interiorFillOvalRect2D(points: Point2[]): Point2[] | null { + const bounds = bounds2D(points); + if (!bounds) return null; + const center = loopCentroid2D(points); + const centerY = clamp(center[1], bounds.minY + EPS, bounds.maxY - EPS); + const centerInterval = widestIntervalAtY2D(points, centerY); + if (!centerInterval) return null; + const centerX = clamp(center[0], centerInterval[0], centerInterval[1]); + + for (const scale of [INTERIOR_FILL_OVAL_SCALE, 0.68, 0.54, 0.4]) { + const halfHeight = bounds.height * scale * 0.5; + const halfWidth = bounds.width * scale * 0.5; + const y0 = clamp(centerY - halfHeight, bounds.minY, bounds.maxY); + const y1 = clamp(centerY + halfHeight, bounds.minY, bounds.maxY); + if (y1 - y0 <= EPS) continue; + + let x0 = centerX - halfWidth; + let x1 = centerX + halfWidth; + for (let i = 0; i < INTERIOR_FILL_OVAL_SAMPLES; i += 1) { + const t = INTERIOR_FILL_OVAL_SAMPLES === 1 ? 0.5 : i / (INTERIOR_FILL_OVAL_SAMPLES - 1); + const y = y0 + (y1 - y0) * t; + const interval = overlappingIntervalAtY2D(points, y, [x0, x1]) ?? widestIntervalAtY2D(points, y); + if (!interval) { + x1 = x0; + break; } + x0 = Math.max(x0, interval[0]); + x1 = Math.min(x1, interval[1]); } - return true; - })); -} - -export function dilateGrid(grid: boolean[][], radius: number): boolean[][] { - const out = grid.map((row) => row.map(() => false)); - for (let y = 0; y < grid.length; y++) { - for (let x = 0; x < grid[y].length; x++) { - if (!grid[y][x]) continue; - for (let dy = -radius; dy <= radius; dy++) { - for (let dx = -radius; dx <= radius; dx++) { - if (Math.abs(dx) + Math.abs(dy) > radius) continue; - const row = out[y + dy]; - if (row && x + dx >= 0 && x + dx < row.length) row[x + dx] = true; - } - } + if (x1 - x0 > EPS) { + const margin = Math.min(x1 - x0, y1 - y0) * INTERIOR_FILL_OVAL_MARGIN_RATIO; + const insetX = Math.min(margin, (x1 - x0) * 0.25); + const insetY = Math.min(margin, (y1 - y0) * 0.25); + if (x1 - x0 - insetX * 2 <= EPS || y1 - y0 - insetY * 2 <= EPS) continue; + return [ + [x0 + insetX, y0 + insetY], + [x1 - insetX, y0 + insetY], + [x1 - insetX, y1 - insetY], + [x0 + insetX, y1 - insetY], + ]; } } - return out; + return null; } -export function largestGridComponents(grid: boolean[][]): boolean[][][] { - const seen = grid.map((row) => row.map(() => false)); - const components: Point2[][] = []; - const directions = [[1, 0], [-1, 0], [0, 1], [0, -1]] as const; - - for (let y = 0; y < grid.length; y++) { - for (let x = 0; x < grid[y].length; x++) { - if (!grid[y][x] || seen[y][x]) continue; - const queue: Point2[] = [[x, y]]; - const component: Point2[] = []; - seen[y][x] = true; - for (let i = 0; i < queue.length; i++) { - const [cx, cy] = queue[i]; - component.push([cx, cy]); - for (const [dx, dy] of directions) { - const nx = cx + dx; - const ny = cy + dy; - if (!grid[ny]?.[nx] || seen[ny][nx]) continue; - seen[ny][nx] = true; - queue.push([nx, ny]); - } - } - components.push(component); - } +function widestIntervalAtY2D(points: Point2[], y: number): [number, number] | null { + const intervals = loopIntervalsAtY2D(points, y); + let best: [number, number] | null = null; + for (const interval of intervals) { + if (!best || interval[1] - interval[0] > best[1] - best[0]) best = interval; } - - components.sort((a, b) => b.length - a.length); - const largest = components[0]?.length ?? 0; - if (largest === 0) return []; - const minSize = largest * INTERIOR_FILL_SECONDARY_COMPONENT_AREA_RATIO; - return components - .filter((component) => component.length >= minSize) - .slice(0, INTERIOR_FILL_MAX_COMPONENTS_PER_SLICE) - .map((component) => { - const out = grid.map((row) => row.map(() => false)); - for (const [x, y] of component) out[y][x] = true; - return out; - }); + return best; } -export function gridRowsToIntervals( - grid: boolean[][], - minX: number, - maxX: number, - minY: number, - maxY: number, -): InteriorFillInterval[] { - const rows: InteriorFillInterval[] = []; - const width = maxX - minX; - const height = maxY - minY; - for (let row = 0; row < grid.length; row++) { - let start = -1; - let end = -1; - for (let col = 0; col < grid[row].length; col++) { - if (!grid[row][col]) continue; - if (start < 0) start = col; - end = col; +function overlappingIntervalAtY2D( + points: Point2[], + y: number, + target: [number, number], +): [number, number] | null { + const intervals = loopIntervalsAtY2D(points, y); + let best: [number, number] | null = null; + let bestOverlap = 0; + for (const interval of intervals) { + const overlap = Math.min(interval[1], target[1]) - Math.max(interval[0], target[0]); + if (overlap > bestOverlap) { + best = interval; + bestOverlap = overlap; } - if (start < 0) continue; - const x0 = minX + (start / INTERIOR_FILL_GRID_COLUMNS) * width; - const x1 = minX + ((end + 1) / INTERIOR_FILL_GRID_COLUMNS) * width; - const y = minY + ((row + 0.5) / INTERIOR_FILL_SCAN_ROWS) * height; - rows.push({ row, y, x0, x1, length: x1 - x0 }); } - return rows; + return bestOverlap > EPS ? best : null; } -export function refinedGridRowsToIntervals( - grid: boolean[][], - sourceIntervals: InteriorFillInterval[], - minX: number, - maxX: number, - minY: number, - maxY: number, -): InteriorFillInterval[] { - const rows = gridRowsToIntervals(grid, minX, maxX, minY, maxY); - if (rows.length === 0) return rows; - - const byRow = new Map(); - for (const interval of sourceIntervals) { - const current = byRow.get(interval.row); - if (current) current.push(interval); - else byRow.set(interval.row, [interval]); +function loopIntervalsAtY2D(points: Point2[], y: number): Array<[number, number]> { + const xs: number[] = []; + for (let i = 0; i < points.length; i += 1) { + const a = points[i]; + const b = points[(i + 1) % points.length]; + if (Math.abs(b[1] - a[1]) <= EPS) continue; + const low = Math.min(a[1], b[1]); + const high = Math.max(a[1], b[1]); + if (y < low || y >= high) continue; + const t = (y - a[1]) / (b[1] - a[1]); + xs.push(a[0] + (b[0] - a[0]) * t); + } + xs.sort((a, b) => a - b); + const intervals: Array<[number, number]> = []; + for (let i = 0; i + 1 < xs.length; i += 2) { + if (xs[i + 1] - xs[i] > EPS) intervals.push([xs[i], xs[i + 1]]); } + return intervals; +} - const cellWidth = (maxX - minX) / INTERIOR_FILL_GRID_COLUMNS; - return rows.map((row) => { - const expanded: InteriorFillInterval = { - ...row, - x0: row.x0 - cellWidth, - x1: row.x1 + cellWidth, - length: row.length + cellWidth * 2, - }; - let best: InteriorFillInterval | null = null; - let bestOverlap = 0; - for (const source of byRow.get(row.row) ?? []) { - const overlap = intervalOverlap(source, expanded); - if (overlap > bestOverlap) { - best = source; - bestOverlap = overlap; - } - } - if (!best || bestOverlap <= 0) return row; - - const x0 = Math.max(best.x0, expanded.x0); - const x1 = Math.min(best.x1, expanded.x1); - const length = x1 - x0; - return length > 0 ? { row: row.row, y: best.y, x0, x1, length } : row; - }); +function bounds2D(points: Point2[]): { + minX: number; + minY: number; + maxX: number; + maxY: number; + width: number; + height: number; +} | null { + let minX = Infinity; + let minY = Infinity; + let maxX = -Infinity; + let maxY = -Infinity; + for (const [x, y] of points) { + minX = Math.min(minX, x); + minY = Math.min(minY, y); + maxX = Math.max(maxX, x); + maxY = Math.max(maxY, y); + } + if (!Number.isFinite(minX) || maxX - minX <= EPS || maxY - minY <= EPS) { + return null; + } + return { minX, minY, maxX, maxY, width: maxX - minX, height: maxY - minY }; } -export function orientPoint2D(point: Point2, swapAxes: boolean): Point2 { - return swapAxes ? [point[1], point[0]] : point; +function transposePoint2D([x, y]: Point2): Point2 { + return [y, x]; } -export function scanlineIntervals(segments: Segment2[], tolerance: number): InteriorFillInterval[] { +function scanlineIntervals( + segments: Segment2[], + candidateArea: number, + tolerance: number, +): InteriorFillInterval[] { let minY = Infinity; let maxY = -Infinity; for (const segment of segments) { minY = Math.min(minY, segment.a[1], segment.b[1]); maxY = Math.max(maxY, segment.a[1], segment.b[1]); } - const spanY = maxY - minY; - if (!Number.isFinite(spanY) || spanY <= tolerance) return []; + if (!Number.isFinite(minY) || maxY - minY <= tolerance) return []; const intervals: InteriorFillInterval[] = []; - for (let row = 0; row < INTERIOR_FILL_SCAN_ROWS; row++) { - const y = minY + ((row + 0.5) / INTERIOR_FILL_SCAN_ROWS) * spanY; + for (let row = 0; row < INTERIOR_FILL_SCAN_ROWS; row += 1) { + const y = minY + ((row + 0.5) / INTERIOR_FILL_SCAN_ROWS) * (maxY - minY); const xs: number[] = []; for (const segment of segments) { const y0 = segment.a[1]; const y1 = segment.b[1]; - const dy = y1 - y0; - if (Math.abs(dy) <= tolerance) continue; - const t = (y - y0) / dy; - if (t < -tolerance || t > 1 + tolerance) continue; + if (Math.abs(y1 - y0) <= tolerance) continue; + const low = Math.min(y0, y1); + const high = Math.max(y0, y1); + if (y < low || y >= high) continue; + const t = (y - y0) / (y1 - y0); xs.push(segment.a[0] + (segment.b[0] - segment.a[0]) * t); } - const sorted = uniqueNumbers(xs.sort((a, b) => a - b), tolerance); - for (let i = 0; i + 1 < sorted.length; i += 2) { - const x0 = sorted[i]; - const x1 = sorted[i + 1]; + xs.sort((a, b) => a - b); + const uniqueXs = uniqueNumbers(xs, tolerance); + for (let i = 0; i + 1 < uniqueXs.length; i += 2) { + const x0 = uniqueXs[i]; + const x1 = uniqueXs[i + 1]; const length = x1 - x0; if (length <= tolerance) continue; intervals.push({ row, y, x0, x1, length }); } } - return intervals; -} - -export function uniqueNumbers(values: number[], tolerance: number): number[] { - const unique: number[] = []; - for (const value of values) { - if (unique.length === 0 || Math.abs(value - unique[unique.length - 1]) > tolerance) { - unique.push(value); - } - } - return unique; -} - -export function largestIntervalComponent(intervals: InteriorFillInterval[]): InteriorFillInterval[] { - const parent = intervals.map((_, index) => index); - const find = (index: number): number => { - while (parent[index] !== index) { - parent[index] = parent[parent[index]]; - index = parent[index]; - } - return index; - }; - const union = (a: number, b: number): void => { - const ar = find(a); - const br = find(b); - if (ar !== br) parent[br] = ar; - }; - for (let i = 0; i < intervals.length; i++) { - for (let j = i + 1; j < intervals.length; j++) { - if (Math.abs(intervals[i].row - intervals[j].row) > 1) continue; - const overlap = intervalOverlap(intervals[i], intervals[j]); - const required = Math.min(intervals[i].length, intervals[j].length) * INTERIOR_FILL_INTERVAL_OVERLAP_RATIO; - if (overlap >= required) union(i, j); + if (intervals.length === 0) return []; + const maxLength = Math.max(...intervals.map((interval) => interval.length)); + const minLength = Math.max( + maxLength * INTERIOR_FILL_INTERVAL_MIN_LENGTH_RATIO, + Math.sqrt(candidateArea) * 0.01, + tolerance * 4, + ); + return intervals.filter((interval) => interval.length >= minLength); +} + +function intervalComponents(intervals: InteriorFillInterval[]): InteriorFillInterval[][] { + const sorted = [...intervals].sort((a, b) => a.row - b.row || b.length - a.length); + const components: InteriorFillInterval[][] = []; + const active: Array<{ last: InteriorFillInterval; component: InteriorFillInterval[] }> = []; + + for (const interval of sorted) { + let best: { last: InteriorFillInterval; component: InteriorFillInterval[] } | null = null; + for (const current of active) { + if (interval.row - current.last.row > 1) continue; + const overlap = Math.min(interval.x1, current.last.x1) - Math.max(interval.x0, current.last.x0); + const required = Math.min(interval.length, current.last.length) * INTERIOR_FILL_INTERVAL_OVERLAP_RATIO; + if (overlap >= required && (!best || current.component.length > best.component.length)) { + best = current; + } } - } - const groups = new Map(); - for (let i = 0; i < intervals.length; i++) { - const root = find(i); - const group = groups.get(root); - if (group) { - group.intervals.push(intervals[i]); - group.score += intervals[i].length; + if (best) { + best.component.push(interval); + best.last = interval; } else { - groups.set(root, { intervals: [intervals[i]], score: intervals[i].length }); + const component = [interval]; + components.push(component); + active.push({ last: interval, component }); } - } - let best: { intervals: InteriorFillInterval[]; score: number } | null = null; - for (const group of groups.values()) { - if (!best || group.score > best.score) best = group; + for (let i = active.length - 1; i >= 0; i -= 1) { + if (interval.row - active[i].last.row > 1) active.splice(i, 1); + } } - return best?.intervals ?? []; -} -export function intervalOverlap(a: InteriorFillInterval, b: InteriorFillInterval): number { - return Math.max(0, Math.min(a.x1, b.x1) - Math.max(a.x0, b.x0)); + return components.sort((a, b) => componentArea(b) - componentArea(a)); } -export function cleanLoop2D(points: Point2[], tolerance: number): Point2[] { - const cleaned: Point2[] = []; - for (let i = 0; i < points.length; i++) { - const prev = points[(i - 1 + points.length) % points.length]; - const current = points[i]; - const next = points[(i + 1) % points.length]; - if (distance2D(prev, current) <= tolerance || distance2D(current, next) <= tolerance) continue; - if (Math.abs(cross2D(prev, current, next)) <= tolerance * tolerance) continue; - cleaned.push(current); +function loopFromIntervals(intervals: InteriorFillInterval[]): Point2[] { + const byRow = new Map(); + for (const interval of intervals) { + const current = byRow.get(interval.row); + if (!current || interval.length > current.length) byRow.set(interval.row, interval); } - return cleaned; + const rows = [...byRow.values()].sort((a, b) => a.row - b.row); + const loop = [ + ...rows.map((row): Point2 => [row.x0, row.y]), + ...rows.slice().reverse().map((row): Point2 => [row.x1, row.y]), + ]; + return cleanLoop2D(loop); } -export function insetLoop2D(points: Point2[], tolerance: number): Point2[] { - if (points.length < 3) return points; - let minX = Infinity; - let minY = Infinity; - let maxX = -Infinity; - let maxY = -Infinity; - for (const [x, y] of points) { - minX = Math.min(minX, x); - minY = Math.min(minY, y); - maxX = Math.max(maxX, x); - maxY = Math.max(maxY, y); - } - - const minSpan = Math.min(maxX - minX, maxY - minY); - const insetDistance = clamp( - minSpan * INTERIOR_FILL_INSET_DISTANCE_RATIO, - tolerance * 8, - minSpan * INTERIOR_FILL_INSET_MAX_DISTANCE_RATIO, - ); - if (!Number.isFinite(insetDistance) || insetDistance <= tolerance) return points; - - const area = loopArea2D(points); - const orientation = area >= 0 ? 1 : -1; - const normals: Point2[] = []; - for (let i = 0; i < points.length; i++) { - const current = points[i]; - const next = points[(i + 1) % points.length]; - const dx = next[0] - current[0]; - const dy = next[1] - current[1]; - const length = Math.hypot(dx, dy); - if (length <= tolerance) return scaleLoopTowardCentroid2D(points, insetDistance); - normals.push([ - (-dy / length) * orientation, - (dx / length) * orientation, - ]); - } - - const maxMiter = insetDistance * INTERIOR_FILL_MAX_MITER_RATIO + tolerance; - const inset = points.map((point, index): Point2 => { - const prevIndex = (index - 1 + points.length) % points.length; - const nextIndex = (index + 1) % points.length; - const prevNormal = normals[prevIndex]; - const currentNormal = normals[index]; - const prevPoint = points[prevIndex]; - const nextPoint = points[nextIndex]; - const previousLineA: Point2 = [ - prevPoint[0] + prevNormal[0] * insetDistance, - prevPoint[1] + prevNormal[1] * insetDistance, - ]; - const previousLineB: Point2 = [ - point[0] + prevNormal[0] * insetDistance, - point[1] + prevNormal[1] * insetDistance, - ]; - const currentLineA: Point2 = [ - point[0] + currentNormal[0] * insetDistance, - point[1] + currentNormal[1] * insetDistance, - ]; - const currentLineB: Point2 = [ - nextPoint[0] + currentNormal[0] * insetDistance, - nextPoint[1] + currentNormal[1] * insetDistance, - ]; - const fallback = averagedInsetPoint(point, prevNormal, currentNormal, insetDistance); - const intersection = lineIntersection2D(previousLineA, previousLineB, currentLineA, currentLineB, tolerance); - if (!intersection || distance2D(point, intersection) > maxMiter) return fallback; - return intersection; - }); - - const cleaned = cleanLoop2D(inset, tolerance); - if (cleaned.length < 3) return scaleLoopTowardCentroid2D(points, insetDistance); - const insetArea = loopArea2D(cleaned); - if ( - Math.sign(insetArea || area) !== Math.sign(area) || - Math.abs(insetArea) < Math.abs(area) * 0.05 || - !loopInsideLoop2D(cleaned, points, tolerance) - ) { - return scaleLoopTowardCentroid2D(points, insetDistance); +function cleanLoop2D(points: Point2[]): Point2[] { + const out: Point2[] = []; + for (const point of points) { + const previous = out[out.length - 1]; + if (!previous || distance2D(previous, point) > EPS) out.push(point); } - return cleaned; + if (out.length > 1 && distance2D(out[0], out[out.length - 1]) <= EPS) out.pop(); + return out; } -export function averagedInsetPoint(point: Point2, a: Point2, b: Point2, distance: number): Point2 { - const nx = a[0] + b[0]; - const ny = a[1] + b[1]; - const length = Math.hypot(nx, ny); - if (length <= 1e-8) return [point[0] + b[0] * distance, point[1] + b[1] * distance]; - return [ - point[0] + (nx / length) * distance, - point[1] + (ny / length) * distance, - ]; +function scaleLoopTowardCentroid2D(points: Point2[], amount: number): Point2[] { + const center = loopCentroid2D(points); + return points.map(([x, y]) => [ + center[0] + (x - center[0]) * (1 - amount), + center[1] + (y - center[1]) * (1 - amount), + ]); } -export function lineIntersection2D(a0: Point2, a1: Point2, b0: Point2, b1: Point2, tolerance: number): Point2 | null { - const rx = a1[0] - a0[0]; - const ry = a1[1] - a0[1]; - const sx = b1[0] - b0[0]; - const sy = b1[1] - b0[1]; - const denominator = rx * sy - ry * sx; - if (Math.abs(denominator) <= tolerance * tolerance) return null; - - const qpx = b0[0] - a0[0]; - const qpy = b0[1] - a0[1]; - const t = (qpx * sy - qpy * sx) / denominator; - return [a0[0] + rx * t, a0[1] + ry * t]; +function componentArea(component: InteriorFillInterval[]): number { + if (component.length === 0) return 0; + const rows = [...component].sort((a, b) => a.row - b.row); + const rowStep = rows.length > 1 + ? Math.abs(rows[1].y - rows[0].y) + : 1; + return rows.reduce((sum, row) => sum + row.length * rowStep, 0); } -export function loopInsideLoop2D(inner: Point2[], outer: Point2[], tolerance: number): boolean { - for (let i = 0; i < inner.length; i++) { - const a = inner[i]; - const b = inner[(i + 1) % inner.length]; - const mid: Point2 = [(a[0] + b[0]) / 2, (a[1] + b[1]) / 2]; - if (!pointInLoop2D(a, outer, tolerance) || !pointInLoop2D(mid, outer, tolerance)) return false; - } - return true; +function orientPoint2D([x, y]: Point2, swapAxes: boolean): Point2 { + return swapAxes ? [y, x] : [x, y]; } -export function pointInLoop2D(point: Point2, loop: Point2[], tolerance: number): boolean { - let inside = false; - for (let i = 0, j = loop.length - 1; i < loop.length; j = i++) { - const a = loop[i]; - const b = loop[j]; - if (pointNearSegment2D(point, a, b, tolerance)) return true; - const intersects = (a[1] > point[1]) !== (b[1] > point[1]) && - point[0] < ((b[0] - a[0]) * (point[1] - a[1])) / (b[1] - a[1]) + a[0]; - if (intersects) inside = !inside; +function uniquePoints2D(points: Point2[], tolerance: number): Point2[] { + const cellSize = Math.max(tolerance, 1e-6); + const seen = new Map(); + for (const point of points) { + const key = `${Math.round(point[0] / cellSize)},${Math.round(point[1] / cellSize)}`; + if (!seen.has(key)) seen.set(key, point); } - return inside; + return [...seen.values()]; } -export function pointNearSegment2D(point: Point2, a: Point2, b: Point2, tolerance: number): boolean { - const dx = b[0] - a[0]; - const dy = b[1] - a[1]; - const lengthSq = dx * dx + dy * dy; - if (lengthSq <= tolerance * tolerance) return distance2D(point, a) <= tolerance; - const t = clamp(((point[0] - a[0]) * dx + (point[1] - a[1]) * dy) / lengthSq, 0, 1); - const closest: Point2 = [a[0] + dx * t, a[1] + dy * t]; - return distance2D(point, closest) <= tolerance; +function uniqueNumbers(values: number[], tolerance: number): number[] { + const out: number[] = []; + for (const value of values) { + if (!out.some((current) => Math.abs(current - value) <= tolerance)) out.push(value); + } + return out; } -export function scaleLoopTowardCentroid2D(points: Point2[], distance: number): Point2[] { - let cx = 0; - let cy = 0; - for (const point of points) { - cx += point[0]; - cy += point[1]; - } - cx /= points.length; - cy /= points.length; - return points.map(([x, y]) => { - const dx = x - cx; - const dy = y - cy; - const length = Math.hypot(dx, dy); - if (length <= distance || length <= 1e-8) return [x, y]; - const scale = (length - distance) / length; - return [ - cx + dx * scale, - cy + dy * scale, - ]; - }); +function distance2D(a: Point2, b: Point2): number { + return Math.hypot(a[0] - b[0], a[1] - b[1]); } -export function loopArea2D(points: Point2[]): number { +function loopArea2D(points: Point2[]): number { let area = 0; - for (let i = 0; i < points.length; i++) { + for (let i = 0; i < points.length; i += 1) { const a = points[i]; const b = points[(i + 1) % points.length]; - area += a[0] * b[1] - b[0] * a[1]; + area += a[0] * b[1] - a[1] * b[0]; } return area / 2; } -export function totalLoopArea2D(loops: Point2[][]): number { - return loops.reduce((sum, loop) => sum + Math.abs(loopArea2D(loop)), 0); +function totalComponentArea(components: InteriorFillComponent[]): number { + return components.reduce((sum, component) => sum + component.area, 0); } -export function loopCentroid2D(points: Point2[]): Point2 { +function loopCentroid2D(points: Point2[]): Point2 { + const signedArea = loopArea2D(points); + if (Math.abs(signedArea) <= EPS) { + return [ + points.reduce((sum, point) => sum + point[0], 0) / Math.max(points.length, 1), + points.reduce((sum, point) => sum + point[1], 0) / Math.max(points.length, 1), + ]; + } + let cx = 0; let cy = 0; - for (const point of points) { - cx += point[0]; - cy += point[1]; + for (let i = 0; i < points.length; i += 1) { + const a = points[i]; + const b = points[(i + 1) % points.length]; + const cross = a[0] * b[1] - b[0] * a[1]; + cx += (a[0] + b[0]) * cross; + cy += (a[1] + b[1]) * cross; } - return points.length > 0 ? [cx / points.length, cy / points.length] : [0, 0]; -} - -export function cross2D(a: Point2, b: Point2, c: Point2): number { - return (b[0] - a[0]) * (c[1] - a[1]) - (b[1] - a[1]) * (c[0] - a[0]); -} - -export function distance2D(a: Point2, b: Point2): number { - return Math.hypot(a[0] - b[0], a[1] - b[1]); + const factor = 1 / (6 * signedArea); + return [cx * factor, cy * factor]; } diff --git a/website/src/components/GalleryWorkbench/hooks/useScenePolygons.ts b/website/src/components/GalleryWorkbench/hooks/useScenePolygons.ts index b3ccfea5..77b13448 100644 --- a/website/src/components/GalleryWorkbench/hooks/useScenePolygons.ts +++ b/website/src/components/GalleryWorkbench/hooks/useScenePolygons.ts @@ -2,7 +2,7 @@ import { useMemo } from "react"; import { optimizeMeshPolygons } from "@layoutit/polycss-react"; import type { Polygon } from "@layoutit/polycss-react"; import type { LoadedModel } from "../types"; -import { withInteriorFillPolygons } from "../helpers/interiorFill"; +import { interiorFillPolygons as buildInteriorFillPolygons } from "../helpers/interiorFill"; export interface UseScenePolygonsOptions { loaded: LoadedModel | null; @@ -15,6 +15,7 @@ export interface UseScenePolygonsOptions { export interface UseScenePolygonsResult { modelPolygons: Polygon[]; + interiorFillPolygons: Polygon[]; scenePolygons: Polygon[]; helperBbox: { minX: number; minY: number; minZ: number; maxX: number; maxY: number; maxZ: number } | null; helperScale: number; @@ -47,20 +48,24 @@ export function useScenePolygons({ reactAnimatedPolygons, ]); - const scenePolygons = useMemo(() => { - if ( - hasActiveAnimation || - !meshInteriorFill - ) { - return modelPolygons; + const interiorFillPolygons = useMemo(() => { + if (hasActiveAnimation || !meshInteriorFill) { + return []; } - return withInteriorFillPolygons(modelPolygons); + return buildInteriorFillPolygons(modelPolygons); }, [ hasActiveAnimation, modelPolygons, meshInteriorFill, ]); + const scenePolygons = useMemo( + () => interiorFillPolygons.length > 0 + ? [...modelPolygons, ...interiorFillPolygons] + : modelPolygons, + [modelPolygons, interiorFillPolygons], + ); + const helperBbox = useMemo(() => { const polygons = scenePolygons; if (polygons.length === 0) return null; @@ -95,5 +100,5 @@ export function useScenePolygons({ ]; }, [helperBbox]); - return { modelPolygons, scenePolygons, helperBbox, helperScale, helperTarget }; + return { modelPolygons, interiorFillPolygons, scenePolygons, helperBbox, helperScale, helperTarget }; } diff --git a/website/src/components/ReactScene/ReactScene.tsx b/website/src/components/ReactScene/ReactScene.tsx index 781c6759..ee6f5c7b 100644 --- a/website/src/components/ReactScene/ReactScene.tsx +++ b/website/src/components/ReactScene/ReactScene.tsx @@ -25,6 +25,7 @@ export interface ReactSceneProps { rendererDebugKey: string; sceneOptions: SceneOptionsState; scenePolygons: Polygon[]; + interiorFillPolygons: Polygon[]; directionalLight: PolyDirectionalLight; ambientLight: PolyAmbientLight; textureQuality: TextureQuality; @@ -50,6 +51,7 @@ export function ReactScene({ rendererDebugKey, sceneOptions, scenePolygons, + interiorFillPolygons, directionalLight, ambientLight, textureQuality, @@ -74,6 +76,14 @@ export function ReactScene({ const camProps = sceneOptions.perspective === false ? { zoom: sceneOptions.zoom, rotX: sceneOptions.rotX, rotY: sceneOptions.rotY, target: sceneOptions.target } : { zoom: sceneOptions.zoom, rotX: sceneOptions.rotX, rotY: sceneOptions.rotY, target: sceneOptions.target, perspective: sceneOptions.perspective }; + const fillMesh = interiorFillPolygons.length > 0 ? ( + + ) : null; return ( {sceneOptions.dragMode === "pan" ? ( @@ -128,12 +138,16 @@ export function ReactScene({ /> ) : null} + {sceneOptions.selection ? fillMesh : null} {!sceneOptions.selection ? ( - + <> + + {fillMesh} + ) : null} {sceneOptions.selection && selectedMeshes.length > 0 && ( (null); const controlsRef = useRef(null); const meshHandleRef = useRef(null); + const interiorFillHandleRef = useRef(null); const axesHandleRef = useRef(null); const lightHandleRef = useRef(null); const groundHandleRef = useRef(null); @@ -163,6 +166,7 @@ export function VanillaScene({ axesHandleRef.current = null; lightHandleRef.current = null; groundHandleRef.current = null; + interiorFillHandleRef.current = null; meshHandleRef.current = null; sceneRef.current = null; scene.destroy(); @@ -178,19 +182,55 @@ export function VanillaScene({ ]); // Effect 1.5 — replace geometry on the existing mesh. This is the path - // used by animated GLB playback. + // used by animated GLB playback. Interior fill lives in a trailing helper + // mesh so its oval CSS can be scoped without stamping every leaf. useEffect(() => { const handle = meshHandleRef.current; - if (!handle) return; + const scene = sceneRef.current; + if (!handle || !scene) return; const started = performance.now(); handle.setPolygons(polygons, { merge: mergePolygonsForMesh, stableDom: stableDomForMesh, }); + + let fillHandle = interiorFillHandleRef.current; + if (interiorFillPolygons.length === 0) { + fillHandle?.dispose(); + interiorFillHandleRef.current = null; + } else if (fillHandle) { + fillHandle.setPolygons(interiorFillPolygons, { + merge: false, + stableDom: stableDomForMesh, + recomputeAutoCenter: false, + }); + } else { + fillHandle = scene.add( + { + polygons: interiorFillPolygons, + objectUrls: [], + warnings: [], + dispose: () => {}, + }, + { + merge: false, + stableDom: stableDomForMesh, + excludeFromAutoCenter: true, + castShadow: false, + }, + ); + fillHandle.element.classList.add("dn-interior-fill-mesh"); + fillHandle.setTransform({ + position: handle.transform.position, + rotation: handle.transform.rotation, + }); + interiorFillHandleRef.current = fillHandle; + } + requestAnimationFrame(() => onBuildRef.current(performance.now() - started), ); - }, [polygons, mergePolygonsForMesh, stableDomForMesh]); + }, [polygons, interiorFillPolygons, mergePolygonsForMesh, stableDomForMesh]); // Effect 1.6 — live-toggle castShadow without rebuilding the scene. useEffect(() => { @@ -216,6 +256,13 @@ export function VanillaScene({ if (!scene) return; const tc = createTransformControls(scene, { mode: gizmoMode ?? "translate", + onObjectChange: (event) => { + if (event.object !== meshHandleRef.current) return; + interiorFillHandleRef.current?.setTransform({ + position: event.object.transform.position, + rotation: event.object.transform.rotation, + }); + }, }); transformControlsRef.current = tc; const select = createSelect(scene, { diff --git a/website/src/components/types.ts b/website/src/components/types.ts index e7238d47..08386079 100644 --- a/website/src/components/types.ts +++ b/website/src/components/types.ts @@ -21,7 +21,6 @@ export interface DomMetrics { rects: number; triangles: number; irregular: number; - overpaintPercent: number; } export interface SceneOptionsState {