From 6345586f63c25c43e4086ea3f3e7752bffc480ce Mon Sep 17 00:00:00 2001 From: agustin-littlehat Date: Mon, 1 Jun 2026 19:26:20 -0300 Subject: [PATCH 1/7] feat(core): optimize parsed meshes by default --- AGENTS.md | 2 +- packages/core/src/index.ts | 8 + .../core/src/merge/optimizePolygons.test.ts | 10 + packages/core/src/merge/optimizePolygons.ts | 22 + .../src/merge/simplifyTriangleMesh.test.ts | 183 +++++ .../core/src/merge/simplifyTriangleMesh.ts | 665 ++++++++++++++++++ packages/core/src/parser/loadMesh.ts | 30 +- .../parser/optimizeMeshParseResult.test.ts | 130 ++++ .../src/parser/optimizeMeshParseResult.ts | 288 ++++++++ packages/core/src/parser/parseGltf.test.ts | 14 + packages/core/src/parser/parseGltf.ts | 23 +- .../src/parser/solidTextureSamples.test.ts | 44 ++ .../core/src/parser/solidTextureSamples.ts | 9 + packages/core/src/types.ts | 14 + packages/react/src/index.ts | 4 + packages/vue/src/index.ts | 4 + 16 files changed, 1428 insertions(+), 22 deletions(-) create mode 100644 packages/core/src/merge/simplifyTriangleMesh.test.ts create mode 100644 packages/core/src/merge/simplifyTriangleMesh.ts create mode 100644 packages/core/src/parser/optimizeMeshParseResult.test.ts create mode 100644 packages/core/src/parser/optimizeMeshParseResult.ts diff --git a/AGENTS.md b/AGENTS.md index d0c745ff..458f0372 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -55,7 +55,7 @@ All solid/atlas tags work in both modes. The `.vox` direct-matrix fast path is b ### Meshing implications (what generators must respect) - **Polygon count is the dominant cost.** Each polygon is one DOM node, one `matrix3d`, one paint. Halving the polygon count is almost always worth a more complex mesher. -- **Lossy optimization favors low DOM render cost.** The default `"lossy"` path scores exact and approximate merge candidates by estimated render cost and keeps the cheapest direct candidate. It can also try a more aggressive triangle-pair merge candidate inside the same boundary displacement budget, but accepts it only when the render-cost win is material and whole-mesh seam diagnostics do not get worse. It avoids per-candidate seam-repair passes in the import path; targeted seam repair remains a lower-level helper for explicit repair workflows. +- **Lossy optimization favors low DOM render cost.** The default `"lossy"` `loadMesh` / core import path first bakes solid texture swatches, merges visually redundant baked swatch colors, and tries endpoint-preserving static triangle simplification for eligible non-animated meshes. It then scores exact and approximate merge candidates by estimated render cost and keeps the cheapest direct candidate. Static simplification has a relaxed seam-key pass plus a stricter source-vertex fallback, and is accepted only when the final optimized DOM leaf count is lower than the baseline optimizer result. The polygon optimizer can also try a more aggressive triangle-pair merge candidate inside the same boundary displacement budget, but accepts it only when the render-cost win is material and whole-mesh seam diagnostics do not get worse. It avoids per-candidate seam-repair passes in the import path; targeted seam repair remains a lower-level helper for explicit repair workflows. - **Fill ratio matters.** A textured polygon's atlas slice equals its local-2D bounding rect. Empty space inside that slice is wasted bitmap pixels. Prefer shapes with high `area / boundingRect.area`: - axis-aligned rectangle = 1.0 (and hits the fastest path) - right-isosceles triangle = 0.5 diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 653ca2ab..88fbec1a 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -97,6 +97,14 @@ export { optimizeMeshPolygons } from "./merge/optimizePolygons"; export type { OptimizeMeshPolygonsOptions, } from "./merge/optimizePolygons"; +export { optimizeMeshParseResult } from "./parser/optimizeMeshParseResult"; +export type { + OptimizeMeshParseResultOptions, +} from "./parser/optimizeMeshParseResult"; +export { simplifyTriangleMeshPolygons } from "./merge/simplifyTriangleMesh"; +export type { + SimplifyTriangleMeshPolygonsOptions, +} from "./merge/simplifyTriangleMesh"; export { DEFAULT_SEAM_FACET_SPLIT_OPTIONS, DEFAULT_SEAM_OVERLAP_OPTIONS, diff --git a/packages/core/src/merge/optimizePolygons.test.ts b/packages/core/src/merge/optimizePolygons.test.ts index a13bd712..fd9dc803 100644 --- a/packages/core/src/merge/optimizePolygons.test.ts +++ b/packages/core/src/merge/optimizePolygons.test.ts @@ -255,6 +255,16 @@ describe("optimizeMeshPolygons", () => { expect(optimizeMeshPolygons(input)).toHaveLength(1); }); + it("stops once the current best result reaches the requested polygon count", () => { + const input: Polygon[] = [ + { vertices: [[0, 0, 0], [1, 0, 0], [1, 1, 0]], color: "#f00" }, + { vertices: [[0, 0, 0], [1, 1, 0], [0, 1, 0.08]], color: "#f00" }, + ]; + + expect(optimizeMeshPolygons(input, { stopAtPolygonCount: 2 })).toHaveLength(2); + expect(optimizeMeshPolygons(input, { stopAtPolygonCount: 1 })).toHaveLength(1); + }); + it("allows lossy approximate merge for same-texture UV polygons", () => { const input: Polygon[] = [ { diff --git a/packages/core/src/merge/optimizePolygons.ts b/packages/core/src/merge/optimizePolygons.ts index 3a38bce3..b51835b4 100644 --- a/packages/core/src/merge/optimizePolygons.ts +++ b/packages/core/src/merge/optimizePolygons.ts @@ -19,6 +19,12 @@ interface LossyApproximateOptions { export interface OptimizeMeshPolygonsOptions { /** Public quality/resolution intent. Defaults to "lossy". */ meshResolution?: MeshResolution; + /** + * Return as soon as the optimizer finds a result with at most this many + * polygons. Useful for candidate comparisons where the caller already knows + * the maximum DOM leaf count it can accept. + */ + stopAtPolygonCount?: number; /** * Run the planar cover pass as an exact candidate for untextured coplanar * regions. Defaults to true. @@ -227,10 +233,17 @@ export function optimizeMeshPolygons( options: OptimizeMeshPolygonsOptions = {}, ): Polygon[] { const meshResolution = options.meshResolution ?? "lossy"; + const stopAtPolygonCount = Number.isFinite(options.stopAtPolygonCount) + ? Math.max(0, Math.floor(options.stopAtPolygonCount!)) + : undefined; const preprocessCache: PreprocessCache = {}; const baseline = preprocessModelPolygons(polygons, false, preprocessCache); let best = baseline; let bestCost = polygonRenderCost(best); + const shouldStop = (): boolean => ( + stopAtPolygonCount !== undefined && + best.length <= stopAtPolygonCount + ); let bestDiagnostics: BestSafetyDiagnostics = { polygons: best }; const resetBestDiagnostics = (seam?: SeamOverlapDiagnostics): void => { bestDiagnostics = { polygons: best, seam }; @@ -283,28 +296,34 @@ export function optimizeMeshPolygons( const initialRectCover = meshResolution === "lossy" && options.rectCover === undefined ? automaticLossyRectCoverOptions(baseline) : options.rectCover; + if (shouldStop()) return best; const rectCovered = applyRectCoverCandidate(baseline, initialRectCover); if (rectCovered !== baseline) acceptCandidate(rectCovered); + if (shouldStop()) return best; if ( meshResolution === "lossy" && options.rectCover === undefined ) { const losslessRectCovered = applyRectCoverCandidate(baseline, undefined); if (losslessRectCovered !== baseline) acceptCandidate(losslessRectCovered); + if (shouldStop()) return best; } if (meshResolution === "lossy" && (best.length <= 1 || bestCost <= 1 + 1e-9)) return best; if (meshResolution === "lossy") { const approximate = preprocessModelPolygons(polygons, DEFAULT_LOSSY_APPROXIMATE_OPTIONS, preprocessCache); acceptCandidate(approximate); + if (shouldStop()) return best; if ( options.rectCover === undefined && polygons.length >= AUTOMATIC_APPROXIMATE_RECT_COVER_MIN_SOURCE_POLYGONS && polygons.length <= AUTOMATIC_APPROXIMATE_RECT_COVER_MAX_SOURCE_POLYGONS ) { acceptCandidate(applyRectCoverCandidate(approximate, AUTOMATIC_APPROXIMATE_RECT_COVER_OPTIONS)); + if (shouldStop()) return best; } if (options.rectCover !== undefined && options.rectCover !== false) { acceptCandidate(applyRectCoverCandidate(approximate, options.rectCover)); + if (shouldStop()) return best; } let acceptedBaseAggressive = false; for (let variantIndex = 0; variantIndex < AGGRESSIVE_LOSSY_APPROXIMATE_VARIANTS.length; variantIndex += 1) { @@ -331,10 +350,12 @@ export function optimizeMeshPolygons( } const accepted = acceptSeamSafeCandidate(aggressiveCandidate, polygons.length); if (variantIndex === 0 && accepted) acceptedBaseAggressive = true; + if (shouldStop()) return best; } if (options.rectCover === undefined) { const largeRectCovered = applyRectCoverCandidate(best, automaticLargeLossyRectCoverCandidate(best)); if (largeRectCovered !== best) acceptSeamSafeCandidate(largeRectCovered, best.length); + if (shouldStop()) return best; } } @@ -715,6 +736,7 @@ function automaticLossyRectCoverOptions(polygons: Polygon[]): CoverPlanarPolygon function automaticLargeLossyRectCoverCandidate(polygons: Polygon[]): CoverPlanarPolygonsOptions | false { if (polygons.length < LARGE_LOSSY_RECT_COVER_MIN_POLYGONS) return false; if (polygons.length > LARGE_LOSSY_RECT_COVER_MAX_POLYGONS) return false; + if (maxPolygonVertexCount(polygons) <= 12) return false; if (polygonBoundaryEdgeCount(polygons) > LARGE_LOSSY_RECT_COVER_MAX_BOUNDARY_EDGES) return false; return LARGE_LOSSY_RECT_COVER_OPTIONS; } diff --git a/packages/core/src/merge/simplifyTriangleMesh.test.ts b/packages/core/src/merge/simplifyTriangleMesh.test.ts new file mode 100644 index 00000000..a318e015 --- /dev/null +++ b/packages/core/src/merge/simplifyTriangleMesh.test.ts @@ -0,0 +1,183 @@ +import { describe, expect, it } from "vitest"; +import type { Polygon, Vec3 } from "../types"; +import { simplifyTriangleMeshPolygons } from "./simplifyTriangleMesh"; + +function grid(size: number): Polygon[] { + const polygons: Polygon[] = []; + for (let y = 0; y < size; y += 1) { + for (let x = 0; x < size; x += 1) { + const a: Vec3 = [x, y, 0]; + const b: Vec3 = [x + 1, y, 0]; + const c: Vec3 = [x + 1, y + 1, 0]; + const d: Vec3 = [x, y + 1, 0]; + polygons.push( + { vertices: [a, b, c], color: "#fff" }, + { vertices: [a, c, d], color: "#fff" }, + ); + } + } + return polygons; +} + +function vertexKeys(polygons: Polygon[]): Set { + const keys = new Set(); + for (const polygon of polygons) { + for (const vertex of polygon.vertices) keys.add(vertex.join(",")); + } + return keys; +} + +function withSeamLayer(polygons: Polygon[], layer: string): Polygon[] { + return polygons.map((polygon) => ({ + ...polygon, + vertices: polygon.vertices.map((vertex) => [...vertex] as Vec3), + simplifyVertexKeys: polygon.vertices.map((vertex) => `${layer}:${vertex.join(",")}`), + })); +} + +function translate(polygons: Polygon[], offset: Vec3): Polygon[] { + return polygons.map((polygon) => ({ + ...polygon, + vertices: polygon.vertices.map(([x, y, z]) => [ + x + offset[0], + y + offset[1], + z + offset[2], + ] as Vec3), + })); +} + +describe("simplifyTriangleMeshPolygons", () => { + it("reduces eligible solid triangle groups", () => { + const source = grid(10); + const simplified = simplifyTriangleMeshPolygons(source, { + minGroupTriangles: 3, + ratio: 0.5, + }); + + expect(simplified.length).toBeLessThan(source.length); + expect(simplified.every((polygon) => polygon.vertices.length === 3)).toBe(true); + }); + + it("preserves original vertex positions by default", () => { + const source = grid(10); + const sourceKeys = vertexKeys(source); + const simplified = simplifyTriangleMeshPolygons(source, { + minGroupTriangles: 3, + ratio: 0.5, + }); + + expect(simplified.length).toBeLessThan(source.length); + for (const polygon of simplified) { + for (const vertex of polygon.vertices) { + expect(sourceKeys.has(vertex.join(","))).toBe(true); + } + } + }); + + it("skips non-manifold triangle groups", () => { + const source: Polygon[] = []; + const a: Vec3 = [0, 0, 0]; + const b: Vec3 = [1, 0, 0]; + for (let index = 0; index < 40; index += 1) { + const angle = (index / 40) * Math.PI * 2; + const c: Vec3 = [0.5, Math.cos(angle), Math.sin(angle)]; + source.push({ vertices: [a, b, c], color: "#fff" }); + } + + const simplified = simplifyTriangleMeshPolygons(source, { + minGroupTriangles: 3, + ratio: 0.5, + }); + + expect(simplified).toBe(source); + }); + + it("locks non-manifold vertices without skipping separate manifold regions", () => { + const fan: Polygon[] = []; + const a: Vec3 = [0, 0, 0]; + const b: Vec3 = [1, 0, 0]; + for (let index = 0; index < 40; index += 1) { + const angle = (index / 40) * Math.PI * 2; + const c: Vec3 = [0.5, Math.cos(angle), Math.sin(angle)]; + fan.push({ vertices: [a, b, c], color: "#fff" }); + } + const source = [ + ...fan, + ...translate(grid(8), [4, 0, 0]), + ]; + + const simplified = simplifyTriangleMeshPolygons(source, { + minGroupTriangles: 3, + ratio: 0.5, + }); + + expect(simplified).not.toBe(source); + expect(simplified.length).toBeLessThan(source.length); + }); + + it("uses simplifier seam keys to keep overlapping islands manifold", () => { + const source = [ + ...withSeamLayer(grid(8), "a"), + ...withSeamLayer(grid(8), "b"), + ]; + const simplified = simplifyTriangleMeshPolygons(source, { + minGroupTriangles: 3, + ratio: 0.5, + }); + + expect(simplified).not.toBe(source); + expect(simplified.length).toBeLessThan(source.length); + }); + + it("can switch to stricter source vertex keys", () => { + const source = grid(8).map((polygon) => ({ + ...polygon, + simplifyVertexKeys: polygon.vertices.map((vertex) => `shared:${vertex.join(",")}`), + simplifySourceVertexKeys: polygon.vertices.map((vertex) => `source:${vertex.join(",")}`), + })); + const relaxed = simplifyTriangleMeshPolygons(source, { + minGroupTriangles: 3, + ratio: 0.5, + }); + const sourceKeyed = simplifyTriangleMeshPolygons(source, { + minGroupTriangles: 3, + ratio: 0.5, + vertexKeyMode: "source", + }); + + expect(relaxed.length).toBeLessThan(source.length); + expect(sourceKeyed.length).toBeLessThan(source.length); + }); + + it("keeps textured polygons out of the collapse graph", () => { + const source = grid(10).map((polygon) => ({ + ...polygon, + texture: "/texture.png", + uvs: [[0, 0], [1, 0], [1, 1]], + })); + const simplified = simplifyTriangleMeshPolygons(source, { + minGroupTriangles: 3, + ratio: 0.5, + }); + + expect(simplified).toBe(source); + }); + + it("does not simplify across color boundaries", () => { + const source = [ + ...grid(7).map((polygon) => ({ ...polygon, color: "#fff" })), + ...grid(7).map((polygon) => ({ + ...polygon, + vertices: polygon.vertices.map(([x, y, z]) => [x + 20, y, z] as Vec3), + color: "#000", + })), + ]; + const simplified = simplifyTriangleMeshPolygons(source, { + minGroupTriangles: 3, + ratio: 0.5, + }); + + expect(simplified.some((polygon) => polygon.color === "#fff")).toBe(true); + expect(simplified.some((polygon) => polygon.color === "#000")).toBe(true); + }); +}); diff --git a/packages/core/src/merge/simplifyTriangleMesh.ts b/packages/core/src/merge/simplifyTriangleMesh.ts new file mode 100644 index 00000000..ae4ab311 --- /dev/null +++ b/packages/core/src/merge/simplifyTriangleMesh.ts @@ -0,0 +1,665 @@ +import type { Polygon, Vec3 } from "../types"; + +export interface SimplifyTriangleMeshPolygonsOptions { + /** Target triangle ratio per eligible connected material group. Default 0.7. */ + ratio?: number; + /** Maximum accepted local plane displacement in scene units. Default 0.18. */ + maxError?: number; + /** Reject collapses that rotate any affected face beyond this angle. Default 65. */ + maxNormalAngleDeg?: number; + /** Only collapse vertices onto existing endpoint positions. Default true. */ + preserveVertices?: boolean; + /** Use stricter importer source-vertex keys instead of relaxed seam keys. Default "relaxed". */ + vertexKeyMode?: "relaxed" | "source"; + /** Keep topological/material boundary vertices fixed. Default true. */ + lockBoundary?: boolean; + /** Skip small groups where decimation is unlikely to repay the mutation cost. Default 80. */ + minGroupTriangles?: number; + /** Hard cap for rebuild passes. Default 12. */ + maxPasses?: number; +} + +interface ResolvedSimplifyTriangleMeshPolygonsOptions { + ratio: number; + maxError: number; + maxNormalDot: number; + preserveVertices: boolean; + vertexKeyMode: "relaxed" | "source"; + lockBoundary: boolean; + minGroupTriangles: number; + maxPasses: number; +} + +interface TriangleGroup { + indices: number[]; + polygons: Polygon[]; +} + +interface MeshTriangle { + a: number; + b: number; + c: number; + polygon: Polygon; + normal: Vec3; +} + +interface MutableMesh { + vertices: Vec3[]; + triangles: MeshTriangle[]; + alive: boolean[]; + vertexTriangles: Set[]; + activeCount: number; +} + +interface MeshEdge { + a: number; + b: number; + triangles: number[]; +} + +interface CollapseCandidate { + a: number; + b: number; + target: Vec3; + error: number; + length: number; +} + +type Quadric = [ + number, number, number, number, + number, number, number, + number, number, + number, +]; + +const DEFAULT_OPTIONS: ResolvedSimplifyTriangleMeshPolygonsOptions = { + ratio: 0.7, + maxError: 0.18, + maxNormalDot: Math.cos((65 * Math.PI) / 180), + preserveVertices: true, + vertexKeyMode: "relaxed", + lockBoundary: true, + minGroupTriangles: 80, + maxPasses: 12, +}; + +function resolveOptions( + options: SimplifyTriangleMeshPolygonsOptions = {}, +): ResolvedSimplifyTriangleMeshPolygonsOptions { + const ratio = Number.isFinite(options.ratio) + ? Math.max(0.05, Math.min(0.98, options.ratio!)) + : DEFAULT_OPTIONS.ratio; + const maxError = Number.isFinite(options.maxError) && options.maxError! > 0 + ? options.maxError! + : DEFAULT_OPTIONS.maxError; + const maxNormalAngleDeg = Number.isFinite(options.maxNormalAngleDeg) + ? Math.max(0, Math.min(89, options.maxNormalAngleDeg!)) + : 65; + return { + ratio, + maxError, + maxNormalDot: Math.cos((maxNormalAngleDeg * Math.PI) / 180), + preserveVertices: options.preserveVertices ?? DEFAULT_OPTIONS.preserveVertices, + vertexKeyMode: options.vertexKeyMode ?? DEFAULT_OPTIONS.vertexKeyMode, + lockBoundary: options.lockBoundary ?? DEFAULT_OPTIONS.lockBoundary, + minGroupTriangles: Math.max(3, Math.floor(options.minGroupTriangles ?? DEFAULT_OPTIONS.minGroupTriangles)), + maxPasses: Math.max(1, Math.floor(options.maxPasses ?? DEFAULT_OPTIONS.maxPasses)), + }; +} + +function hasTexturePaint(polygon: Polygon): boolean { + return Boolean( + polygon.texture || + polygon.material?.texture || + polygon.uvs?.length || + polygon.textureTriangles?.length + ); +} + +function eligibleTriangle(polygon: Polygon): boolean { + return polygon.vertices.length === 3 && !hasTexturePaint(polygon); +} + +function materialKey(polygon: Polygon): string { + return [ + polygon.color ?? "", + polygon.doubleSided === true ? "2" : "1", + polygon.material?.key ?? "", + ].join("\u0000"); +} + +function vertexKey(vertex: Vec3): string { + return `${Math.round(vertex[0] * 100000)},${Math.round(vertex[1] * 100000)},${Math.round(vertex[2] * 100000)}`; +} + +function simplifyVertexKey( + polygon: Polygon, + vertex: Vec3, + vertexIndex: number, + options: ResolvedSimplifyTriangleMeshPolygonsOptions, +): string { + const seamKey = options.vertexKeyMode === "source" + ? polygon.simplifySourceVertexKeys?.[vertexIndex] ?? polygon.simplifyVertexKeys?.[vertexIndex] + : polygon.simplifyVertexKeys?.[vertexIndex]; + return seamKey ? `${vertexKey(vertex)}|${seamKey}` : vertexKey(vertex); +} + +function addVec(a: Vec3, b: Vec3): Vec3 { + return [a[0] + b[0], a[1] + b[1], a[2] + b[2]]; +} + +function subVec(a: Vec3, b: Vec3): Vec3 { + return [a[0] - b[0], a[1] - b[1], a[2] - b[2]]; +} + +function mulVec(a: Vec3, scale: number): Vec3 { + return [a[0] * scale, a[1] * scale, a[2] * scale]; +} + +function dotVec(a: Vec3, b: Vec3): number { + return a[0] * b[0] + a[1] * b[1] + a[2] * b[2]; +} + +function crossVec(a: Vec3, b: Vec3): Vec3 { + return [ + a[1] * b[2] - a[2] * b[1], + a[2] * b[0] - a[0] * b[2], + a[0] * b[1] - a[1] * b[0], + ]; +} + +function lengthVec(a: Vec3): number { + return Math.hypot(a[0], a[1], a[2]); +} + +function distanceVec(a: Vec3, b: Vec3): number { + return lengthVec(subVec(a, b)); +} + +function normalizeVec(a: Vec3): Vec3 { + const length = lengthVec(a); + return length > 1e-12 ? [a[0] / length, a[1] / length, a[2] / length] : [0, 0, 1]; +} + +function triangleNormal(vertices: Vec3[], a: number, b: number, c: number): Vec3 { + return normalizeVec(crossVec( + subVec(vertices[b]!, vertices[a]!), + subVec(vertices[c]!, vertices[a]!), + )); +} + +function triangleArea(vertices: Vec3[], a: number, b: number, c: number): number { + return lengthVec(crossVec( + subVec(vertices[b]!, vertices[a]!), + subVec(vertices[c]!, vertices[a]!), + )) / 2; +} + +function emptyQuadric(): Quadric { + return [0, 0, 0, 0, 0, 0, 0, 0, 0, 0]; +} + +function addQuadric(into: Quadric, other: Quadric): void { + for (let i = 0; i < into.length; i += 1) into[i] += other[i]!; +} + +function planeQuadric(normal: Vec3, point: Vec3, weight: number): Quadric { + const a = normal[0]; + const b = normal[1]; + const c = normal[2]; + const d = -dotVec(normal, point); + return [ + a * a * weight, + a * b * weight, + a * c * weight, + a * d * weight, + b * b * weight, + b * c * weight, + b * d * weight, + c * c * weight, + c * d * weight, + d * d * weight, + ]; +} + +function quadricError(q: Quadric, p: Vec3): number { + const x = p[0]; + const y = p[1]; + const z = p[2]; + return ( + q[0] * x * x + + 2 * q[1] * x * y + + 2 * q[2] * x * z + + 2 * q[3] * x + + q[4] * y * y + + 2 * q[5] * y * z + + 2 * q[6] * y + + q[7] * z * z + + 2 * q[8] * z + + q[9] + ); +} + +function solve3x3( + a00: number, a01: number, a02: number, + a10: number, a11: number, a12: number, + a20: number, a21: number, a22: number, + b0: number, b1: number, b2: number, +): Vec3 | null { + const det = + a00 * (a11 * a22 - a12 * a21) - + a01 * (a10 * a22 - a12 * a20) + + a02 * (a10 * a21 - a11 * a20); + if (Math.abs(det) < 1e-10) return null; + const inv = 1 / det; + const dx = + b0 * (a11 * a22 - a12 * a21) - + a01 * (b1 * a22 - a12 * b2) + + a02 * (b1 * a21 - a11 * b2); + const dy = + a00 * (b1 * a22 - a12 * b2) - + b0 * (a10 * a22 - a12 * a20) + + a02 * (a10 * b2 - b1 * a20); + const dz = + a00 * (a11 * b2 - b1 * a21) - + a01 * (a10 * b2 - b1 * a20) + + b0 * (a10 * a21 - a11 * a20); + const out: Vec3 = [dx * inv, dy * inv, dz * inv]; + return out.every(Number.isFinite) ? out : null; +} + +function optimalPoint(q: Quadric, a: Vec3, b: Vec3, preserveVertices: boolean): Vec3 { + const solved = solve3x3( + q[0], q[1], q[2], + q[1], q[4], q[5], + q[2], q[5], q[7], + -q[3], -q[6], -q[8], + ); + const midpoint = mulVec(addVec(a, b), 0.5); + const maxDistance = distanceVec(a, b) * 1.35; + const candidates = preserveVertices + ? [a, b] + : [ + a, + b, + midpoint, + ...(solved && distanceVec(solved, midpoint) <= maxDistance ? [solved] : []), + ]; + let best = candidates[0]!; + let bestError = quadricError(q, best); + for (let i = 1; i < candidates.length; i += 1) { + const candidate = candidates[i]!; + const error = quadricError(q, candidate); + if (error < bestError) { + best = candidate; + bestError = error; + } + } + return best; +} + +function edgeKey(a: number, b: number): string { + return a < b ? `${a}|${b}` : `${b}|${a}`; +} + +function triangleKey(a: number, b: number, c: number): string { + return `${a}|${b}|${c}`; +} + +function buildGroupMesh( + polygons: Polygon[], + options: ResolvedSimplifyTriangleMeshPolygonsOptions, +): { vertices: Vec3[]; triangles: MeshTriangle[] } { + const vertexByKey = new Map(); + const vertices: Vec3[] = []; + const triangles: MeshTriangle[] = []; + for (const polygon of polygons) { + const indices: number[] = []; + for (let vertexIndex = 0; vertexIndex < polygon.vertices.length; vertexIndex += 1) { + const vertex = polygon.vertices[vertexIndex]!; + const key = simplifyVertexKey(polygon, vertex, vertexIndex, options); + let index = vertexByKey.get(key); + if (index === undefined) { + index = vertices.length; + vertexByKey.set(key, index); + vertices.push([...vertex]); + } + indices.push(index); + } + const [a, b, c] = indices; + if (a === undefined || b === undefined || c === undefined) continue; + if (a === b || a === c || b === c) continue; + triangles.push({ + a, + b, + c, + polygon, + normal: triangleNormal(vertices, a, b, c), + }); + } + return { vertices, triangles }; +} + +function createMutableMesh( + polygons: Polygon[], + options: ResolvedSimplifyTriangleMeshPolygonsOptions, +): MutableMesh { + const mesh = buildGroupMesh(polygons, options); + const vertexTriangles = Array.from({ length: mesh.vertices.length }, () => new Set()); + const alive = Array.from({ length: mesh.triangles.length }, () => true); + for (let index = 0; index < mesh.triangles.length; index += 1) { + const triangle = mesh.triangles[index]!; + vertexTriangles[triangle.a]!.add(index); + vertexTriangles[triangle.b]!.add(index); + vertexTriangles[triangle.c]!.add(index); + } + return { + vertices: mesh.vertices, + triangles: mesh.triangles, + alive, + vertexTriangles, + activeCount: mesh.triangles.length, + }; +} + +function collectEdges(mesh: MutableMesh): Map { + const edges = new Map(); + const add = (a: number, b: number, triangle: number): void => { + const key = edgeKey(a, b); + const current = edges.get(key); + if (current) { + current.triangles.push(triangle); + } else { + edges.set(key, { + a: Math.min(a, b), + b: Math.max(a, b), + triangles: [triangle], + }); + } + }; + for (let i = 0; i < mesh.triangles.length; i += 1) { + if (!mesh.alive[i]) continue; + const triangle = mesh.triangles[i]!; + add(triangle.a, triangle.b, i); + add(triangle.b, triangle.c, i); + add(triangle.c, triangle.a, i); + } + return edges; +} + +function collectQuadrics(mesh: MutableMesh): Quadric[] { + const quadrics = Array.from({ length: mesh.vertices.length }, () => emptyQuadric()); + for (let index = 0; index < mesh.triangles.length; index += 1) { + if (!mesh.alive[index]) continue; + const triangle = mesh.triangles[index]!; + const area = Math.max(1e-6, triangleArea(mesh.vertices, triangle.a, triangle.b, triangle.c)); + const q = planeQuadric(triangle.normal, mesh.vertices[triangle.a]!, Math.sqrt(area)); + addQuadric(quadrics[triangle.a]!, q); + addQuadric(quadrics[triangle.b]!, q); + addQuadric(quadrics[triangle.c]!, q); + } + return quadrics; +} + +function collectAffectedTriangleIndices(mesh: MutableMesh, a: number, b: number): number[] { + const out = new Set(); + const add = (vertex: number): void => { + const triangles = mesh.vertexTriangles[vertex]; + if (!triangles) return; + for (const triangle of triangles) { + if (mesh.alive[triangle]) out.add(triangle); + } + }; + add(a); + add(b); + return Array.from(out).sort((left, right) => left - right); +} + +function activeEdgeTriangleCount(mesh: MutableMesh, a: number, b: number): number { + const aTriangles = mesh.vertexTriangles[a]; + const bTriangles = mesh.vertexTriangles[b]; + if (!aTriangles || !bTriangles) return 0; + const [small, large] = aTriangles.size <= bTriangles.size + ? [aTriangles, bTriangles] + : [bTriangles, aTriangles]; + let count = 0; + for (const triangle of small) { + if (mesh.alive[triangle] && large.has(triangle)) count += 1; + } + return count; +} + +function replacedTriangle(triangle: MeshTriangle, a: number, b: number): [number, number, number] { + return [ + triangle.a === b ? a : triangle.a, + triangle.b === b ? a : triangle.b, + triangle.c === b ? a : triangle.c, + ]; +} + +function candidateIsValid( + mesh: MutableMesh, + candidate: CollapseCandidate, + options: ResolvedSimplifyTriangleMeshPolygonsOptions, +): number[] | null { + if (activeEdgeTriangleCount(mesh, candidate.a, candidate.b) !== 2) return null; + const affected = collectAffectedTriangleIndices(mesh, candidate.a, candidate.b); + if (affected.length === 0) return null; + + const previous = mesh.vertices[candidate.a]!; + mesh.vertices[candidate.a] = candidate.target; + let kept = 0; + let valid = true; + for (const triangleIndex of affected) { + const triangle = mesh.triangles[triangleIndex]!; + const [a, b, c] = replacedTriangle(triangle, candidate.a, candidate.b); + if (a === b || a === c || b === c) continue; + const area = triangleArea(mesh.vertices, a, b, c); + if (area <= 1e-8) { + valid = false; + break; + } + const nextNormal = triangleNormal(mesh.vertices, a, b, c); + if (dotVec(triangle.normal, nextNormal) < options.maxNormalDot) { + valid = false; + break; + } + const planePoint = triangle.a === candidate.a ? previous : mesh.vertices[triangle.a]!; + const planeOffset = Math.abs(dotVec(triangle.normal, subVec(candidate.target, planePoint))); + if (planeOffset > options.maxError) { + valid = false; + break; + } + kept += 1; + } + mesh.vertices[candidate.a] = previous; + return valid && kept > 0 ? affected : null; +} + +function removeTriangleFromVertices(mesh: MutableMesh, triangleIndex: number, triangle: MeshTriangle): void { + mesh.vertexTriangles[triangle.a]?.delete(triangleIndex); + mesh.vertexTriangles[triangle.b]?.delete(triangleIndex); + mesh.vertexTriangles[triangle.c]?.delete(triangleIndex); +} + +function addTriangleToVertices(mesh: MutableMesh, triangleIndex: number, triangle: MeshTriangle): void { + mesh.vertexTriangles[triangle.a]!.add(triangleIndex); + mesh.vertexTriangles[triangle.b]!.add(triangleIndex); + mesh.vertexTriangles[triangle.c]!.add(triangleIndex); +} + +function collapseCandidateInPlace( + mesh: MutableMesh, + candidate: CollapseCandidate, + affected: number[], +): void { + mesh.vertices[candidate.a] = candidate.target; + const seen = new Set(); + for (const triangleIndex of affected) { + if (!mesh.alive[triangleIndex]) continue; + const triangle = mesh.triangles[triangleIndex]!; + const [a, b, c] = replacedTriangle(triangle, candidate.a, candidate.b); + removeTriangleFromVertices(mesh, triangleIndex, triangle); + if (a === b || a === c || b === c) { + mesh.alive[triangleIndex] = false; + mesh.activeCount -= 1; + continue; + } + const key = triangleKey(a, b, c); + if (seen.has(key)) { + mesh.alive[triangleIndex] = false; + mesh.activeCount -= 1; + continue; + } + seen.add(key); + const next = { + ...triangle, + a, + b, + c, + normal: triangleNormal(mesh.vertices, a, b, c), + }; + mesh.triangles[triangleIndex] = next; + addTriangleToVertices(mesh, triangleIndex, next); + } +} + +function buildCandidates( + mesh: MutableMesh, + options: ResolvedSimplifyTriangleMeshPolygonsOptions, +): CollapseCandidate[] { + const edges = collectEdges(mesh); + const lockedVertices = new Set(); + for (const edge of edges.values()) { + if (edge.triangles.length > 2) { + lockedVertices.add(edge.a); + lockedVertices.add(edge.b); + } + } + if (options.lockBoundary) { + for (const edge of edges.values()) { + if (edge.triangles.length !== 1) continue; + lockedVertices.add(edge.a); + lockedVertices.add(edge.b); + } + } + + const quadrics = collectQuadrics(mesh); + const maxQuadricError = options.maxError * options.maxError; + const candidates: CollapseCandidate[] = []; + for (const edge of edges.values()) { + if (edge.triangles.length !== 2) continue; + if (lockedVertices.has(edge.a) || lockedVertices.has(edge.b)) continue; + const q = [...quadrics[edge.a]!] as Quadric; + addQuadric(q, quadrics[edge.b]!); + const target = optimalPoint(q, mesh.vertices[edge.a]!, mesh.vertices[edge.b]!, options.preserveVertices); + const error = Math.max(0, quadricError(q, target)); + if (error > maxQuadricError * Math.max(1, edge.triangles.length)) continue; + const length = distanceVec(mesh.vertices[edge.a]!, mesh.vertices[edge.b]!); + candidates.push({ a: edge.a, b: edge.b, target, error, length }); + } + candidates.sort((a, b) => a.error - b.error || a.length - b.length); + return candidates; +} + +function activeTriangles(mesh: MutableMesh): MeshTriangle[] { + const out: MeshTriangle[] = []; + for (let index = 0; index < mesh.triangles.length; index += 1) { + if (mesh.alive[index]) out.push(mesh.triangles[index]!); + } + return out; +} + +function simplifyGroup( + polygons: Polygon[], + options: ResolvedSimplifyTriangleMeshPolygonsOptions, +): Polygon[] { + if (polygons.length < options.minGroupTriangles) return polygons; + const mesh = createMutableMesh(polygons, options); + const sourceCount = mesh.activeCount; + if (sourceCount < options.minGroupTriangles) return polygons; + const targetCount = Math.max(1, Math.floor(sourceCount * options.ratio)); + if (targetCount >= sourceCount) return polygons; + + let accepted = 0; + for (let pass = 0; pass < options.maxPasses && mesh.activeCount > targetCount; pass += 1) { + const candidates = buildCandidates(mesh, options); + if (candidates.length === 0) break; + let passAccepted = 0; + for (const candidate of candidates) { + if (mesh.activeCount <= targetCount) break; + const affected = candidateIsValid(mesh, candidate, options); + if (!affected) continue; + const beforeCount = mesh.activeCount; + collapseCandidateInPlace(mesh, candidate, affected); + if (mesh.activeCount >= beforeCount) continue; + accepted += 1; + passAccepted += 1; + } + if (passAccepted === 0) break; + } + if (accepted === 0 || mesh.activeCount >= sourceCount) return polygons; + + return activeTriangles(mesh).map((triangle) => ({ + ...triangle.polygon, + vertices: [ + [...mesh.vertices[triangle.a]!] as Vec3, + [...mesh.vertices[triangle.b]!] as Vec3, + [...mesh.vertices[triangle.c]!] as Vec3, + ], + })); +} + +function collectGroups(polygons: Polygon[]): Map { + const groups = new Map(); + for (let index = 0; index < polygons.length; index += 1) { + const polygon = polygons[index]!; + if (!eligibleTriangle(polygon)) continue; + const key = materialKey(polygon); + const group = groups.get(key); + if (group) { + group.indices.push(index); + group.polygons.push(polygon); + } else { + groups.set(key, { indices: [index], polygons: [polygon] }); + } + } + return groups; +} + +/** + * Import-time triangle decimation for already-solid meshes. + * + * This is deliberately conservative: textured polygons and material/color + * boundaries are kept out of the collapse graph, endpoint-preserving collapses + * mirror meshoptimizer's index-buffer simplification shape by default, and + * callers can cheaply compare the returned candidate against their normal + * render-cost optimizer before accepting it. + */ +export function simplifyTriangleMeshPolygons( + polygons: Polygon[], + options?: SimplifyTriangleMeshPolygonsOptions, +): Polygon[] { + const resolved = resolveOptions(options); + const groups = collectGroups(polygons); + if (groups.size === 0) return polygons; + + const replacements = new Map(); + let changed = false; + for (const group of groups.values()) { + const simplified = simplifyGroup(group.polygons, resolved); + if (simplified === group.polygons || simplified.length >= group.polygons.length) continue; + replacements.set(group.indices[0]!, simplified); + for (let i = 1; i < group.indices.length; i += 1) replacements.set(group.indices[i]!, []); + changed = true; + } + if (!changed) return polygons; + + const out: Polygon[] = []; + for (let index = 0; index < polygons.length; index += 1) { + const replacement = replacements.get(index); + if (replacement) out.push(...replacement); + else if (replacement === undefined) out.push(polygons[index]!); + } + return out; +} diff --git a/packages/core/src/parser/loadMesh.ts b/packages/core/src/parser/loadMesh.ts index b0f7edf2..40d98991 100644 --- a/packages/core/src/parser/loadMesh.ts +++ b/packages/core/src/parser/loadMesh.ts @@ -24,7 +24,7 @@ import { parseGltf } from "./parseGltf"; import { parseMtl } from "./parseMtl"; import { parseVox } from "./parseVox"; import { bakeSolidTextureSamples, type SolidTextureSampleOptions } from "./solidTextureSamples"; -import { optimizeMeshPolygons } from "../merge/optimizePolygons"; +import { optimizeMeshParseResult } from "./optimizeMeshParseResult"; export interface LoadMeshOptions { /** @@ -62,18 +62,6 @@ 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 optimized = optimizeMeshPolygons(result.polygons, { - meshResolution: options?.meshResolution, - }); - if (optimized === result.polygons) return result; - return { ...result, polygons: optimized }; -} - async function withSolidTextureSamples(result: ParseResult, options?: LoadMeshOptions): Promise { const setting = options?.solidTextureSamples; if (setting === false) return result; @@ -155,7 +143,11 @@ export async function loadMesh(url: string, options?: LoadMeshOptions): Promise< } const parsed = parseObj(text, objOptions); - return withMeshResolution(await withSolidTextureSamples(parsed, options), options); + const baked = await withSolidTextureSamples(parsed, options); + return optimizeMeshParseResult(baked, { + meshResolution: options?.meshResolution, + source: parsed, + }); } if (ext === "glb" || ext === "gltf") { @@ -163,14 +155,20 @@ export async function loadMesh(url: string, options?: LoadMeshOptions): Promise< if (!res.ok) throw new Error(`${FETCH_NAME}: ${url} → ${res.status}`); const buf = await res.arrayBuffer(); const parsed = parseGltf(buf, { baseUrl, ...(options?.gltfOptions ?? {}) }); - return withMeshResolution(await withSolidTextureSamples(parsed, options), options); + const baked = await withSolidTextureSamples(parsed, options); + return optimizeMeshParseResult(baked, { + meshResolution: options?.meshResolution, + source: parsed, + }); } if (ext === "vox") { const res = await fetchFn(url); if (!res.ok) throw new Error(`${FETCH_NAME}: ${url} → ${res.status}`); const buf = await res.arrayBuffer(); - return withMeshResolution(parseVox(buf, options?.voxOptions), options); + return optimizeMeshParseResult(parseVox(buf, options?.voxOptions), { + meshResolution: options?.meshResolution, + }); } throw new Error(`${FETCH_NAME}: unsupported extension ".${ext}" (supported: obj, glb, gltf, vox)`); diff --git a/packages/core/src/parser/optimizeMeshParseResult.test.ts b/packages/core/src/parser/optimizeMeshParseResult.test.ts new file mode 100644 index 00000000..b8ff2720 --- /dev/null +++ b/packages/core/src/parser/optimizeMeshParseResult.test.ts @@ -0,0 +1,130 @@ +import { describe, expect, it } from "vitest"; +import type { ParseResult } from "./types"; +import type { Polygon, Vec3 } from "../types"; +import { optimizeMeshPolygons } from "../merge/optimizePolygons"; +import { optimizeMeshParseResult } from "./optimizeMeshParseResult"; + +function parseResult(polygons: Polygon[]): ParseResult { + return { + polygons, + objectUrls: [], + dispose() {}, + warnings: [], + }; +} + +function grid(size: number): Polygon[] { + const polygons: Polygon[] = []; + for (let y = 0; y < size; y += 1) { + for (let x = 0; x < size; x += 1) { + const a: Vec3 = [x, y, 0]; + const b: Vec3 = [x + 1, y, 0]; + const c: Vec3 = [x + 1, y + 1, 0]; + const d: Vec3 = [x, y + 1, 0]; + polygons.push( + { vertices: [a, b, c], color: "#fff" }, + { vertices: [a, c, d], color: "#fff" }, + ); + } + } + return polygons; +} + +function texturedGrid(size: number): Polygon[] { + return grid(size).map((polygon) => ({ + ...polygon, + texture: "texture.png", + uvs: [[0, 0], [1, 0], [1, 1]], + })); +} + +describe("optimizeMeshParseResult", () => { + it("runs the core mesh optimizer for parse results", () => { + const source = parseResult(grid(2)); + const optimized = optimizeMeshParseResult(source, { meshResolution: "lossless" }); + + expect(optimized.polygons.length).toBeLessThan(source.polygons.length); + expect(mergeCountStable(optimized.polygons)).toBe(true); + }); + + it("merges visually redundant baked swatch colors in lossy mode", () => { + const vertices: [Vec3, Vec3, Vec3, Vec3] = [ + [0, 0, 0], + [1, 0, 0], + [1, 1, 0], + [0, 1, 0], + ]; + const before = parseResult([ + { + vertices: [vertices[0], vertices[1], vertices[2]], + color: "#ffffff", + texture: "swatch.png", + uvs: [[0, 0], [1, 0], [1, 1]], + }, + { + vertices: [vertices[0], vertices[2], vertices[3]], + color: "#ffffff", + texture: "swatch.png", + uvs: [[0, 0], [1, 1], [0, 1]], + }, + ]); + const baked = parseResult([ + { vertices: [vertices[0], vertices[1], vertices[2]], color: "#feca4a" }, + { vertices: [vertices[0], vertices[2], vertices[3]], color: "#fec144" }, + ]); + + const optimized = optimizeMeshParseResult(baked, { + meshResolution: "lossy", + source: before, + }); + + expect(optimized.polygons).toHaveLength(1); + expect(["#feca4a", "#fec144"]).toContain(optimized.polygons[0].color); + }); + + it("leaves animated parse results structurally unchanged", () => { + const source = parseResult(grid(4)); + const animated: ParseResult = { + ...source, + animation: { + clips: [{ index: 0, name: "move", duration: 1, channelCount: 1 }], + sample: () => source.polygons, + }, + }; + + const optimized = optimizeMeshParseResult(animated); + + expect(optimized).toBe(animated); + }); + + it("skips static triangle simplification for texture-dominant parse results", () => { + const source = parseResult([ + ...grid(8), + ...texturedGrid(8), + ]); + + const optimized = optimizeMeshParseResult(source, { meshResolution: "lossy" }); + const baseline = optimizeMeshParseResult(source, { + meshResolution: "lossy", + simplifyTriangleMeshes: false, + }); + + expect(optimized.polygons).toHaveLength(baseline.polygons.length); + }); + + it("skips static triangle simplification when baseline merging already collapses the source", () => { + const source = parseResult(grid(8)); + + const optimized = optimizeMeshParseResult(source, { meshResolution: "lossy" }); + const baseline = optimizeMeshParseResult(source, { + meshResolution: "lossy", + simplifyTriangleMeshes: false, + }); + + expect(optimized.polygons).toHaveLength(baseline.polygons.length); + }); +}); + +function mergeCountStable(polygons: Polygon[]): boolean { + return optimizeMeshPolygons(polygons, { meshResolution: "lossless" }).length === polygons.length; +} diff --git a/packages/core/src/parser/optimizeMeshParseResult.ts b/packages/core/src/parser/optimizeMeshParseResult.ts new file mode 100644 index 00000000..e3ce7c48 --- /dev/null +++ b/packages/core/src/parser/optimizeMeshParseResult.ts @@ -0,0 +1,288 @@ +import { parsePureColor } from "../color/color"; +import { optimizeMeshPolygons } from "../merge/optimizePolygons"; +import { + simplifyTriangleMeshPolygons, + type SimplifyTriangleMeshPolygonsOptions, +} from "../merge/simplifyTriangleMesh"; +import type { MeshResolution, Polygon } from "../types"; +import type { ParseResult } from "./types"; + +interface RgbColor { + rgb: [number, number, number]; + alpha: number; +} + +interface HsvColor { + hue: number; + saturation: number; + value: number; +} + +export interface OptimizeMeshParseResultOptions { + /** Render-cost optimization intent. Defaults to "lossy". */ + meshResolution?: MeshResolution; + /** Parse result before solid texture baking, used to identify baked swatch faces. */ + source?: ParseResult; + /** Merge near-identical baked swatch colors in lossy mode. Default 36. */ + bakedTextureColorMergeDistance?: number; + /** Try static triangle simplification before final polygon optimization. Default true. */ + simplifyTriangleMeshes?: boolean; + /** Options for the static triangle simplifier. */ + simplifyTriangleMeshOptions?: SimplifyTriangleMeshPolygonsOptions; + /** Candidate optimizer early-stop drop target. Default 0.15. */ + simplifyEarlyStopDropRatio?: number; +} + +const DEFAULT_BAKED_TEXTURE_COLOR_MERGE_DISTANCE = 36; +const DEFAULT_SIMPLIFY_EARLY_STOP_DROP_RATIO = 0.15; +const MAX_STATIC_SIMPLIFY_TEXTURED_RATIO = 0.25; +const MIN_STATIC_SIMPLIFY_BASELINE_SOURCE_RATIO = 0.23; +const SOURCE_FIRST_MIN_BASELINE_POLYGONS = 2000; +const SOURCE_FIRST_MAX_RELAXED_RAW_DROP = 16; + +function hasTexturePaint(polygon: Polygon): boolean { + return Boolean( + polygon.texture || + polygon.material?.texture || + polygon.uvs?.length || + polygon.textureTriangles?.length + ); +} + +function colorKey(color: string): string { + const parsed = parsePureColor(color); + if (!parsed || parsed.alpha < 1) return color.trim().toLowerCase(); + return `#${parsed.rgb + .map((channel) => Math.max(0, Math.min(255, Math.round(channel))).toString(16).padStart(2, "0")) + .join("")}`; +} + +function parseRgbColor(color: string): RgbColor | null { + const parsed = parsePureColor(color); + if (!parsed) return null; + return { + rgb: [ + Math.max(0, Math.min(255, Math.round(parsed.rgb[0]))), + Math.max(0, Math.min(255, Math.round(parsed.rgb[1]))), + Math.max(0, Math.min(255, Math.round(parsed.rgb[2]))), + ], + alpha: parsed.alpha, + }; +} + +function colorDistance(a: RgbColor, b: RgbColor): number { + return Math.hypot( + a.rgb[0] - b.rgb[0], + a.rgb[1] - b.rgb[1], + a.rgb[2] - b.rgb[2], + ); +} + +function hsvFromColor(color: RgbColor): HsvColor { + const r = color.rgb[0] / 255; + const g = color.rgb[1] / 255; + const b = color.rgb[2] / 255; + const max = Math.max(r, g, b); + const min = Math.min(r, g, b); + const delta = max - min; + let hue = 0; + if (delta !== 0) { + if (max === r) hue = 60 * (((g - b) / delta) % 6); + else if (max === g) hue = 60 * ((b - r) / delta + 2); + else hue = 60 * ((r - g) / delta + 4); + } + if (hue < 0) hue += 360; + return { + hue, + saturation: max === 0 ? 0 : delta / max, + value: max, + }; +} + +function hueDistance(a: number, b: number): number { + const delta = Math.abs(a - b) % 360; + return delta > 180 ? 360 - delta : delta; +} + +function compatibleColors(a: RgbColor, b: RgbColor): boolean { + if (a.alpha < 1 || b.alpha < 1) return false; + const ah = hsvFromColor(a); + const bh = hsvFromColor(b); + const aNeutral = ah.saturation < 0.08; + const bNeutral = bh.saturation < 0.08; + if (aNeutral || bNeutral) return aNeutral === bNeutral; + const tolerance = Math.min(ah.value, bh.value) < 0.18 ? 32 : 18; + return hueDistance(ah.hue, bh.hue) <= tolerance; +} + +function buildColorMergeMap(colors: Map, distance: number): Map { + const parsed = new Map(); + for (const color of colors.keys()) { + const value = parseRgbColor(color); + if (value) parsed.set(color, value); + } + + const representatives: Array<{ color: string; parsed: RgbColor }> = []; + const remap = new Map(); + const entries = Array.from(colors.entries()) + .sort((a, b) => b[1] - a[1] || a[0].localeCompare(b[0])); + + for (const [color] of entries) { + const value = parsed.get(color); + if (!value) { + remap.set(color, color); + continue; + } + + let best: { color: string; distance: number } | null = null; + for (const representative of representatives) { + if (!compatibleColors(value, representative.parsed)) continue; + const candidateDistance = colorDistance(value, representative.parsed); + if (candidateDistance > distance) continue; + if (!best || candidateDistance < best.distance) { + best = { color: representative.color, distance: candidateDistance }; + } + } + if (best) { + remap.set(color, best.color); + } else { + representatives.push({ color, parsed: value }); + remap.set(color, color); + } + } + + return remap; +} + +function cleanupLossyBakedTextureColors( + result: ParseResult, + options: OptimizeMeshParseResultOptions, + meshResolution: MeshResolution, +): ParseResult { + if (meshResolution !== "lossy" || result.animation) return result; + const source = options.source; + if (!source || source.polygons === result.polygons) return result; + const distance = options.bakedTextureColorMergeDistance ?? DEFAULT_BAKED_TEXTURE_COLOR_MERGE_DISTANCE; + if (!Number.isFinite(distance) || distance <= 0) return result; + + const candidateColors = new Map(); + const candidateIndices: number[] = []; + for (let index = 0; index < result.polygons.length; index += 1) { + const before = source.polygons[index]; + const after = result.polygons[index]; + if (!before || !after || before === after || !after.color) continue; + if (!hasTexturePaint(before) || hasTexturePaint(after)) continue; + const key = colorKey(after.color); + candidateIndices.push(index); + candidateColors.set(key, (candidateColors.get(key) ?? 0) + 1); + } + + if (candidateColors.size < 2) return result; + const remap = buildColorMergeMap(candidateColors, distance); + let changed = false; + const polygons = result.polygons.slice(); + for (const index of candidateIndices) { + const polygon = polygons[index]!; + const sourceColor = colorKey(polygon.color!); + const nextColor = remap.get(sourceColor) ?? sourceColor; + if (nextColor === sourceColor) continue; + polygons[index] = { ...polygon, color: nextColor }; + changed = true; + } + + return changed ? { ...result, polygons } : result; +} + +function earlyStopTarget(count: number, options: OptimizeMeshParseResultOptions): number { + const ratio = Number.isFinite(options.simplifyEarlyStopDropRatio) + ? Math.max(0, options.simplifyEarlyStopDropRatio!) + : DEFAULT_SIMPLIFY_EARLY_STOP_DROP_RATIO; + return Math.max(0, count - Math.max(1, Math.ceil(count * ratio))); +} + +function hasSourceVertexKeys(polygons: Polygon[]): boolean { + return polygons.some((polygon) => polygon.simplifySourceVertexKeys?.length === polygon.vertices.length); +} + +function shouldTryStaticSimplification(polygons: Polygon[], baselineOptimized: Polygon[]): boolean { + let textured = 0; + for (const polygon of polygons) { + if (hasTexturePaint(polygon)) textured += 1; + } + return polygons.length === 0 || ( + textured / polygons.length <= MAX_STATIC_SIMPLIFY_TEXTURED_RATIO && + baselineOptimized.length / polygons.length > MIN_STATIC_SIMPLIFY_BASELINE_SOURCE_RATIO + ); +} + +function simplifiedOptimizedPolygons( + polygons: Polygon[], + baselineOptimized: Polygon[], + options: OptimizeMeshParseResultOptions, +): Polygon[] | null { + const simplify = (vertexKeyMode?: "relaxed" | "source"): Polygon[] | null => { + const candidate = simplifyTriangleMeshPolygons(polygons, { + ...options.simplifyTriangleMeshOptions, + ...(vertexKeyMode ? { vertexKeyMode } : {}), + }); + if (candidate === polygons || candidate.length >= polygons.length) return null; + return candidate; + }; + + const optimize = (candidate: Polygon[]): Polygon[] => ( + optimizeMeshPolygons(candidate, { + meshResolution: "lossy", + stopAtPolygonCount: earlyStopTarget(baselineOptimized.length, options), + }) + ); + + const relaxed = simplify(); + if (!relaxed) return null; + + let sourceKeyed: Polygon[] | null | undefined; + let sourceOptimized: Polygon[] | null | undefined; + const hasSourceKeys = hasSourceVertexKeys(polygons); + const relaxedRawDrop = polygons.length - relaxed.length; + const shouldTrySourceKeys = ( + hasSourceKeys && + baselineOptimized.length >= SOURCE_FIRST_MIN_BASELINE_POLYGONS && + relaxedRawDrop <= SOURCE_FIRST_MAX_RELAXED_RAW_DROP + ); + if (shouldTrySourceKeys) { + sourceKeyed = simplify("source"); + if (sourceKeyed) { + sourceOptimized = optimize(sourceKeyed); + if (sourceOptimized.length < baselineOptimized.length) return sourceOptimized; + } + } + + const relaxedOptimized = optimize(relaxed); + if (relaxedOptimized.length < baselineOptimized.length) return relaxedOptimized; + + sourceKeyed ??= shouldTrySourceKeys ? simplify("source") : null; + if (sourceKeyed) { + sourceOptimized ??= optimize(sourceKeyed); + if (sourceOptimized.length < baselineOptimized.length) return sourceOptimized; + } + return null; +} + +export function optimizeMeshParseResult( + result: ParseResult, + options: OptimizeMeshParseResultOptions = {}, +): ParseResult { + if (result.voxelSource || result.animation) return result; + const meshResolution = options.meshResolution ?? "lossy"; + const cleaned = cleanupLossyBakedTextureColors(result, options, meshResolution); + const baselineOptimized = optimizeMeshPolygons(cleaned.polygons, { meshResolution }); + const shouldSimplify = ( + meshResolution === "lossy" && + options.simplifyTriangleMeshes !== false && + !cleaned.animation && + shouldTryStaticSimplification(cleaned.polygons, baselineOptimized) + ); + const polygons = shouldSimplify + ? simplifiedOptimizedPolygons(cleaned.polygons, baselineOptimized, options) ?? baselineOptimized + : baselineOptimized; + return polygons === cleaned.polygons ? cleaned : { ...cleaned, polygons }; +} diff --git a/packages/core/src/parser/parseGltf.test.ts b/packages/core/src/parser/parseGltf.test.ts index cdf4057b..7151b014 100644 --- a/packages/core/src/parser/parseGltf.test.ts +++ b/packages/core/src/parser/parseGltf.test.ts @@ -1479,6 +1479,20 @@ describe("parseGltf", () => { expect(poly.uvs).toHaveLength(3); }); + it("preserves source vertex identity for simplifier fallback", () => { + const { glb } = buildTriangleGlb({ + includeTexcoord: true, + textureUrl: "texture.png", + }); + const result = parseGltf(glb, { baseUrl: "https://example.com/" }); + + expect(result.polygons[0].simplifySourceVertexKeys).toEqual([ + "0:0:0:0", + "0:0:0:1", + "0:0:0:2", + ]); + }); + it("preserves glTF default repeat wrapping and opaque alpha mode", () => { const { glb } = buildTriangleGlb({ includeTexcoord: true, diff --git a/packages/core/src/parser/parseGltf.ts b/packages/core/src/parser/parseGltf.ts index b1b7b716..6cda57ea 100644 --- a/packages/core/src/parser/parseGltf.ts +++ b/packages/core/src/parser/parseGltf.ts @@ -1532,6 +1532,7 @@ export function parseGltf(input: ArrayBuffer | Uint8Array, options?: GltfParseOp textureAlphaMode?: PolyTextureAlphaMode; doubleSided?: boolean; uvs?: Vec2[]; + simplifySourceVertexKeys?: string[]; source?: AnimatedPrimitiveSource; sourceIndex?: number; sourceTriangleIndex?: number; @@ -1578,7 +1579,9 @@ export function parseGltf(input: ArrayBuffer | Uint8Array, options?: GltfParseOp ); return; } - for (const prim of mesh.primitives) { + for (let primitiveIndex = 0; primitiveIndex < mesh.primitives.length; primitiveIndex += 1) { + const prim = mesh.primitives[primitiveIndex]; + if (!prim) continue; const mode = prim.mode ?? 4; if (mode !== 4 && mode !== 5 && mode !== 6) { const modeName = PRIMITIVE_MODE_NAMES[mode] ?? `mode ${mode}`; @@ -1708,17 +1711,21 @@ export function parseGltf(input: ArrayBuffer | Uint8Array, options?: GltfParseOp } for (let i = 0; i + 2 < indices.length; i += 3) { + const i0 = indices[i]!; + const i1 = indices[i + 1]!; + const i2 = indices[i + 2]!; const sourceTriangleIndex = animatedSource ? animatedSource.triangleMask.length : undefined; if (animatedSource) animatedSource.triangleMask.push(false); - const v0 = positions[indices[i]]; - const v1 = positions[indices[i + 1]]; - const v2 = positions[indices[i + 2]]; + const v0 = positions[i0]; + const v1 = positions[i1]; + const v2 = positions[i2]; if (!v0 || !v1 || !v2) continue; let triUvs: Vec2[] | undefined; if (uvs && texture) { - const u0 = uvs[indices[i]], u1 = uvs[indices[i + 1]], u2 = uvs[indices[i + 2]]; + const u0 = uvs[i0], u1 = uvs[i1], u2 = uvs[i2]; if (u0 && u1 && u2) triUvs = [u0, u1, u2]; } + const keyPrefix = `${meshIdx}:${primitiveIndex}:${meshNode ?? "root"}`; rawTris.push({ v0, v1, @@ -1729,6 +1736,11 @@ export function parseGltf(input: ArrayBuffer | Uint8Array, options?: GltfParseOp textureAlphaMode, doubleSided, uvs: triUvs, + simplifySourceVertexKeys: [ + `${keyPrefix}:${i0}`, + `${keyPrefix}:${i1}`, + `${keyPrefix}:${i2}`, + ], source: animatedSource, sourceIndex: animatedSource?.sourceIndex, sourceTriangleIndex, @@ -1929,6 +1941,7 @@ export function parseGltf(input: ArrayBuffer | Uint8Array, options?: GltfParseOp if (t.texture && t.textureAlphaMode) p.textureAlphaMode = t.textureAlphaMode; if (t.doubleSided) p.doubleSided = true; if (t.uvs) p.uvs = t.uvs; + if (t.simplifySourceVertexKeys) p.simplifySourceVertexKeys = t.simplifySourceVertexKeys; polygons.push(p); animatedPolygonRefs.push( t.sourceIndex !== undefined && t.sourceTriangleIndex !== undefined diff --git a/packages/core/src/parser/solidTextureSamples.test.ts b/packages/core/src/parser/solidTextureSamples.test.ts index b6fa7c68..ba11c0cb 100644 --- a/packages/core/src/parser/solidTextureSamples.test.ts +++ b/packages/core/src/parser/solidTextureSamples.test.ts @@ -137,6 +137,50 @@ describe("bakeSolidTextureSamples", () => { expect(baked.polygons.slice(0, 3).every((polygon) => polygon.texture === undefined)).toBe(true); }); + it("preserves UV-derived simplifier seam keys when baking solid textures", async () => { + installSolidTextureEnv([10, 20, 30, 255]); + const polygon = texturedTriangle([[0, 0, 0], [1, 0, 0], [0, 1, 0]]); + const result: ParseResult = { + polygons: [polygon], + objectUrls: [], + dispose() {}, + warnings: [], + }; + + const baked = await bakeSolidTextureSamples(result); + + expect(baked.polygons[0].texture).toBeUndefined(); + expect(baked.polygons[0].uvs).toBeUndefined(); + expect(baked.polygons[0].textureTriangles).toBeUndefined(); + expect(baked.polygons[0].simplifyVertexKeys).toEqual([ + "uv:0,0", + "uv:100000,0", + "uv:0,100000", + ]); + }); + + it("preserves source simplifier keys when appending baked UV seam identity", async () => { + installSolidTextureEnv([10, 20, 30, 255]); + const polygon: Polygon = { + ...texturedTriangle([[0, 0, 0], [1, 0, 0], [0, 1, 0]]), + simplifySourceVertexKeys: ["source:0", "source:1", "source:2"], + }; + const result: ParseResult = { + polygons: [polygon], + objectUrls: [], + dispose() {}, + warnings: [], + }; + + const baked = await bakeSolidTextureSamples(result); + + expect(baked.polygons[0].simplifySourceVertexKeys).toEqual([ + "source:0|uv:0,0", + "source:1|uv:100000,0", + "source:2|uv:0,100000", + ]); + }); + it("uses texture wrap when baking repeated UVs into solid colors", async () => { installSolidTextureDataEnv(2, 1, [ 10, 20, 30, 255, diff --git a/packages/core/src/parser/solidTextureSamples.ts b/packages/core/src/parser/solidTextureSamples.ts index e42f8001..8905b623 100644 --- a/packages/core/src/parser/solidTextureSamples.ts +++ b/packages/core/src/parser/solidTextureSamples.ts @@ -475,9 +475,18 @@ function bakePolygon(polygon: Polygon, color: string): Polygon { textureTriangles: _textureTriangles, ...rest } = polygon; + const uvKeys = polygon.uvs?.length === polygon.vertices.length + ? polygon.uvs.map((uv) => `uv:${Math.round(uv[0] * 100000)},${Math.round(uv[1] * 100000)}`) + : undefined; + const simplifyVertexKeys = uvKeys ?? polygon.simplifyVertexKeys; + const simplifySourceVertexKeys = polygon.simplifySourceVertexKeys?.length === polygon.vertices.length + ? polygon.simplifySourceVertexKeys.map((key, index) => uvKeys ? `${key}|${uvKeys[index]}` : key) + : polygon.simplifySourceVertexKeys; return { ...rest, color, + ...(simplifyVertexKeys ? { simplifyVertexKeys } : {}), + ...(simplifySourceVertexKeys ? { simplifySourceVertexKeys } : {}), }; } diff --git a/packages/core/src/types.ts b/packages/core/src/types.ts index ff036809..5c5c7fb8 100644 --- a/packages/core/src/types.ts +++ b/packages/core/src/types.ts @@ -160,6 +160,20 @@ export interface Polygon { * @internal */ textureTriangles?: TextureTriangle[]; + /** + * Import-time simplifier seam keys retained after solid texture baking. + * Renderers ignore this; mesh optimization uses it to avoid welding vertices + * that shared a position but came from different texture/attribute seams. + * @internal + */ + simplifyVertexKeys?: string[]; + /** + * Stricter import-time simplifier keys that preserve source vertex identity. + * Renderers ignore this; candidate simplification uses it only for fallback + * passes where relaxed seam welding loses after render-cost optimization. + * @internal + */ + simplifySourceVertexKeys?: string[]; /** * Source material requested two-sided rendering. Importers use this so * optimization passes do not collapse intentional reverse-wound faces. diff --git a/packages/react/src/index.ts b/packages/react/src/index.ts index 28fd33a2..3cf7dbb9 100644 --- a/packages/react/src/index.ts +++ b/packages/react/src/index.ts @@ -174,7 +174,9 @@ export type { CameraCullNormalGroup, CameraCullRotation, OptimizeMeshPolygonsOptions, + OptimizeMeshParseResultOptions, OptimizeAnimatedMeshPolygonsOptions, + SimplifyTriangleMeshPolygonsOptions, } from "@layoutit/polycss-core"; export { CAMERA_BACKFACE_CULL_EPS, @@ -184,6 +186,8 @@ export { mergePolygons, coverPlanarPolygons, optimizeMeshPolygons, + optimizeMeshParseResult, + simplifyTriangleMeshPolygons, repairMeshSeams, seamFacetSplitPolygons, seamFacetSplitReport, diff --git a/packages/vue/src/index.ts b/packages/vue/src/index.ts index 7e676fd5..f04f8556 100644 --- a/packages/vue/src/index.ts +++ b/packages/vue/src/index.ts @@ -164,7 +164,9 @@ export type { CameraCullNormalGroup, CameraCullRotation, OptimizeMeshPolygonsOptions, + OptimizeMeshParseResultOptions, OptimizeAnimatedMeshPolygonsOptions, + SimplifyTriangleMeshPolygonsOptions, } from "@layoutit/polycss-core"; export { CAMERA_BACKFACE_CULL_EPS, @@ -174,6 +176,8 @@ export { mergePolygons, coverPlanarPolygons, optimizeMeshPolygons, + optimizeMeshParseResult, + simplifyTriangleMeshPolygons, repairMeshSeams, seamFacetSplitPolygons, seamFacetSplitReport, From 66ded2adb178bcdd9756c73659cd7e3d1a0ea3a2 Mon Sep 17 00:00:00 2001 From: agustin-littlehat Date: Mon, 1 Jun 2026 19:26:29 -0300 Subject: [PATCH 2/7] refactor(website): use core mesh optimization --- .../GalleryWorkbench/helpers/loaders.ts | 51 +++++- .../helpers/lossyColorCleanup.ts | 171 ------------------ .../hooks/useScenePolygons.ts | 6 + .../src/components/GalleryWorkbench/types.ts | 2 + website/src/content/docs/api/headless.mdx | 46 +++++ website/src/content/docs/api/types.mdx | 19 ++ website/src/content/docs/core-concepts.mdx | 2 +- .../src/content/docs/guides/performance.mdx | 2 +- 8 files changed, 116 insertions(+), 183 deletions(-) delete mode 100644 website/src/components/GalleryWorkbench/helpers/lossyColorCleanup.ts diff --git a/website/src/components/GalleryWorkbench/helpers/loaders.ts b/website/src/components/GalleryWorkbench/helpers/loaders.ts index 82255f9e..4ccf570d 100644 --- a/website/src/components/GalleryWorkbench/helpers/loaders.ts +++ b/website/src/components/GalleryWorkbench/helpers/loaders.ts @@ -1,5 +1,6 @@ import { bakeSolidTextureSamples, + optimizeMeshParseResult, parseGltf, parseMtl, parseObj, @@ -14,7 +15,6 @@ import type { } from "../types"; import { activeMeshResolution, type WorkbenchMeshResolution } from "../../types"; import { assertImportedRenderedPolygonLimit } from "./importLimits"; -import { cleanupLossyBakedTextureColors } from "./lossyColorCleanup"; import { mergeParserOptions } from "./parserOptions"; const VOX_LOSSY_PALETTE_MERGE_DISTANCE = 28; @@ -138,14 +138,21 @@ export async function loadPresetModel( }, }); const baked = await bakeSolidTextureSamples(parsedObj); - const parsed = cleanupLossyBakedTextureColors(parsedObj, baked, { meshResolution }); + const sourcePolygonCount = baked.polygons.length; + const effectiveMeshResolution = activeMeshResolution(meshResolution); + const parsed = optimizeMeshParseResult(baked, { + meshResolution: effectiveMeshResolution, + source: parsedObj, + }); return { label: model.label, kind: "obj", parseResult: parsed, rawPolygons: parsed.polygons, polygons: parsed.polygons, - sourcePolygons: parsed.polygons.length, + optimizedPolygons: parsed.polygons, + optimizedMeshResolution: effectiveMeshResolution, + sourcePolygons: sourcePolygonCount, sourceBytes: objText.length + (mtlText?.length ?? 0), warnings: parsed.warnings ?? [], parseMs: performance.now() - started, @@ -174,19 +181,28 @@ export async function loadPresetModel( }; } + const gltfUrl = new URL(url, window.location.href).href; const parsedGltf = parseGltf(buf, { ...mergeParserOptions(model.options, parser), - baseUrl: new URL(url, window.location.href).href, + baseUrl: gltfUrl, }); const baked = await bakeSolidTextureSamples(parsedGltf); - const parsed = cleanupLossyBakedTextureColors(parsedGltf, baked, { meshResolution }); + const sourcePolygonCount = baked.polygons.length; + const effectiveMeshResolution = activeMeshResolution(meshResolution); + const parsed = optimizeMeshParseResult(baked, { + meshResolution: effectiveMeshResolution, + source: parsedGltf, + }); + const optimizedStatic = !parsed.animation && !parsed.voxelSource; return { label: model.label, kind: model.kind, parseResult: parsed, rawPolygons: parsed.polygons, polygons: parsed.polygons, - sourcePolygons: parsed.polygons.length, + optimizedPolygons: optimizedStatic ? parsed.polygons : undefined, + optimizedMeshResolution: optimizedStatic ? effectiveMeshResolution : undefined, + sourcePolygons: sourcePolygonCount, sourceBytes: buf.byteLength, warnings: parsed.warnings ?? [], parseMs: performance.now() - started, @@ -250,7 +266,12 @@ export async function loadDroppedModel( }, }); const baked = await bakeSolidTextureSamples(parsedObj); - const parsed = cleanupLossyBakedTextureColors(parsedObj, baked, { meshResolution }); + const sourcePolygonCount = baked.polygons.length; + const effectiveMeshResolution = activeMeshResolution(meshResolution); + const parsed = optimizeMeshParseResult(baked, { + meshResolution: effectiveMeshResolution, + source: parsedObj, + }); let disposed = false; const parseResult = { ...parsed, @@ -274,7 +295,9 @@ export async function loadDroppedModel( parseResult, rawPolygons: parsed.polygons, polygons: parsed.polygons, - sourcePolygons: parsed.polygons.length, + optimizedPolygons: parsed.polygons, + optimizedMeshResolution: effectiveMeshResolution, + sourcePolygons: sourcePolygonCount, sourceBytes, warnings: parseResult.warnings, parseMs: performance.now() - started, @@ -308,7 +331,13 @@ export async function loadDroppedModel( const parsedGltf = parseGltf(buf, options); const baked = await bakeSolidTextureSamples(parsedGltf); - const parsed = cleanupLossyBakedTextureColors(parsedGltf, baked, { meshResolution }); + const sourcePolygonCount = baked.polygons.length; + const effectiveMeshResolution = activeMeshResolution(meshResolution); + const parsed = optimizeMeshParseResult(baked, { + meshResolution: effectiveMeshResolution, + source: parsedGltf, + }); + const optimizedStatic = !parsed.animation && !parsed.voxelSource; try { assertImportedRenderedPolygonLimit(parsed, meshResolution, source.label); } catch (error) { @@ -321,7 +350,9 @@ export async function loadDroppedModel( parseResult: parsed, rawPolygons: parsed.polygons, polygons: parsed.polygons, - sourcePolygons: parsed.polygons.length, + optimizedPolygons: optimizedStatic ? parsed.polygons : undefined, + optimizedMeshResolution: optimizedStatic ? effectiveMeshResolution : undefined, + sourcePolygons: sourcePolygonCount, sourceBytes, warnings: parsed.warnings ?? [], parseMs: performance.now() - started, diff --git a/website/src/components/GalleryWorkbench/helpers/lossyColorCleanup.ts b/website/src/components/GalleryWorkbench/helpers/lossyColorCleanup.ts deleted file mode 100644 index ec10bad6..00000000 --- a/website/src/components/GalleryWorkbench/helpers/lossyColorCleanup.ts +++ /dev/null @@ -1,171 +0,0 @@ -import { parsePureColor } from "@layoutit/polycss"; -import type { ParseResult, Polygon } from "@layoutit/polycss"; -import { activeMeshResolution, type WorkbenchMeshResolution } from "../../types"; - -interface RgbColor { - rgb: [number, number, number]; - alpha: number; -} - -interface HsvColor { - hue: number; - saturation: number; - value: number; -} - -export interface LossyBakedTextureColorOptions { - meshResolution: WorkbenchMeshResolution; - distance?: number; -} - -const DEFAULT_DISTANCE = 36; - -function hasTexturePaint(polygon: Polygon): boolean { - return Boolean( - polygon.texture || - polygon.material?.texture || - polygon.uvs?.length || - polygon.textureTriangles?.length - ); -} - -function colorKey(color: string): string { - const parsed = parsePureColor(color); - if (!parsed || parsed.alpha < 1) return color.trim().toLowerCase(); - return `#${parsed.rgb - .map((channel) => Math.max(0, Math.min(255, Math.round(channel))).toString(16).padStart(2, "0")) - .join("")}`; -} - -function parseColor(color: string): RgbColor | null { - const parsed = parsePureColor(color); - if (!parsed) return null; - return { - rgb: [ - Math.max(0, Math.min(255, Math.round(parsed.rgb[0]))), - Math.max(0, Math.min(255, Math.round(parsed.rgb[1]))), - Math.max(0, Math.min(255, Math.round(parsed.rgb[2]))), - ], - alpha: parsed.alpha, - }; -} - -function colorDistance(a: RgbColor, b: RgbColor): number { - return Math.hypot( - a.rgb[0] - b.rgb[0], - a.rgb[1] - b.rgb[1], - a.rgb[2] - b.rgb[2], - ); -} - -function hsvFromColor(color: RgbColor): HsvColor { - const r = color.rgb[0] / 255; - const g = color.rgb[1] / 255; - const b = color.rgb[2] / 255; - const max = Math.max(r, g, b); - const min = Math.min(r, g, b); - const delta = max - min; - let hue = 0; - if (delta !== 0) { - if (max === r) hue = 60 * (((g - b) / delta) % 6); - else if (max === g) hue = 60 * ((b - r) / delta + 2); - else hue = 60 * ((r - g) / delta + 4); - } - if (hue < 0) hue += 360; - return { - hue, - saturation: max === 0 ? 0 : delta / max, - value: max, - }; -} - -function hueDistance(a: number, b: number): number { - const delta = Math.abs(a - b) % 360; - return delta > 180 ? 360 - delta : delta; -} - -function compatibleColors(a: RgbColor, b: RgbColor): boolean { - if (a.alpha < 1 || b.alpha < 1) return false; - const ah = hsvFromColor(a); - const bh = hsvFromColor(b); - const aNeutral = ah.saturation < 0.08; - const bNeutral = bh.saturation < 0.08; - if (aNeutral || bNeutral) return aNeutral === bNeutral; - const tolerance = Math.min(ah.value, bh.value) < 0.18 ? 32 : 18; - return hueDistance(ah.hue, bh.hue) <= tolerance; -} - -function buildColorMergeMap(colors: Map, distance: number): Map { - const parsed = new Map(); - for (const color of colors.keys()) { - const value = parseColor(color); - if (value) parsed.set(color, value); - } - const representatives: Array<{ color: string; parsed: RgbColor }> = []; - const remap = new Map(); - const entries = Array.from(colors.entries()) - .sort((a, b) => b[1] - a[1] || a[0].localeCompare(b[0])); - - for (const [color] of entries) { - const value = parsed.get(color); - if (!value) { - remap.set(color, color); - continue; - } - let best: { color: string; distance: number } | null = null; - for (const representative of representatives) { - if (!compatibleColors(value, representative.parsed)) continue; - const candidateDistance = colorDistance(value, representative.parsed); - if (candidateDistance > distance) continue; - if (!best || candidateDistance < best.distance) { - best = { color: representative.color, distance: candidateDistance }; - } - } - if (best) { - remap.set(color, best.color); - } else { - representatives.push({ color, parsed: value }); - remap.set(color, color); - } - } - - return remap; -} - -export function cleanupLossyBakedTextureColors( - source: ParseResult, - baked: ParseResult, - options: LossyBakedTextureColorOptions, -): ParseResult { - if (activeMeshResolution(options.meshResolution) !== "lossy") return baked; - if (baked.animation) return baked; - const distance = options.distance ?? DEFAULT_DISTANCE; - if (!Number.isFinite(distance) || distance <= 0) return baked; - - const candidateColors = new Map(); - const candidateIndices: number[] = []; - for (let index = 0; index < baked.polygons.length; index += 1) { - const before = source.polygons[index]; - const after = baked.polygons[index]; - if (!before || !after || before === after || !after.color) continue; - if (!hasTexturePaint(before) || hasTexturePaint(after)) continue; - const key = colorKey(after.color); - candidateIndices.push(index); - candidateColors.set(key, (candidateColors.get(key) ?? 0) + 1); - } - - if (candidateColors.size < 2) return baked; - const remap = buildColorMergeMap(candidateColors, distance); - let changed = false; - const polygons = baked.polygons.slice(); - for (const index of candidateIndices) { - const polygon = polygons[index]!; - const sourceColor = colorKey(polygon.color!); - const nextColor = remap.get(sourceColor) ?? sourceColor; - if (nextColor === sourceColor) continue; - polygons[index] = { ...polygon, color: nextColor }; - changed = true; - } - - return changed ? { ...baked, polygons } : baked; -} diff --git a/website/src/components/GalleryWorkbench/hooks/useScenePolygons.ts b/website/src/components/GalleryWorkbench/hooks/useScenePolygons.ts index 2a39e5cc..1014cabf 100644 --- a/website/src/components/GalleryWorkbench/hooks/useScenePolygons.ts +++ b/website/src/components/GalleryWorkbench/hooks/useScenePolygons.ts @@ -43,6 +43,12 @@ export function useScenePolygons({ if (loaded.kind === "primitive") { return optimizeMeshPolygons(loaded.rawPolygons, { meshResolution: "lossless" }); } + if ( + loaded.optimizedPolygons && + loaded.optimizedMeshResolution === effectiveMeshResolution + ) { + return loaded.optimizedPolygons; + } return optimizeMeshPolygons(loaded.rawPolygons, { meshResolution: effectiveMeshResolution, }); diff --git a/website/src/components/GalleryWorkbench/types.ts b/website/src/components/GalleryWorkbench/types.ts index baca56df..8c2e96b5 100644 --- a/website/src/components/GalleryWorkbench/types.ts +++ b/website/src/components/GalleryWorkbench/types.ts @@ -49,6 +49,8 @@ export interface LoadedModel { parseResult: ParseResult; rawPolygons: Polygon[]; polygons: Polygon[]; + optimizedPolygons?: Polygon[]; + optimizedMeshResolution?: "lossless" | "lossy"; sourcePolygons: number; sourceBytes: number; warnings: string[]; diff --git a/website/src/content/docs/api/headless.mdx b/website/src/content/docs/api/headless.mdx index af0a3cf2..a180d43c 100644 --- a/website/src/content/docs/api/headless.mdx +++ b/website/src/content/docs/api/headless.mdx @@ -83,6 +83,8 @@ const { colors, textures } = parseMtl(mtlText); High-level convenience function: fetches a URL, detects OBJ vs glTF/GLB/VOX by extension (`.obj`, `.gltf`, `.glb`, `.vox`), parses it, and returns a `ParseResult`. Mesh optimization defaults to `meshResolution: "lossy"`; pass `"lossless"` for exact planar candidates only. +In lossy mode, `loadMesh()` runs the full core import optimization path after parsing: solid texture swatch baking, near-identical baked swatch color cleanup, static triangle simplification for eligible non-animated meshes, and final DOM-cost mesh optimization. Animated glTF results keep their source polygon topology so animation sampling remains index-stable. + ```ts import { loadMesh } from "@layoutit/polycss-core"; @@ -169,6 +171,50 @@ const polygons = optimizeMeshPolygons(rawPolygons, { }); ``` +Pass `stopAtPolygonCount` when comparing candidates and you only need a result below a known DOM-leaf budget; the default optimizer still runs all candidates. + +--- + +## `optimizeMeshParseResult(result, options?)` + +Runs the same parse-result import optimization used by `loadMesh()`. Use it when you call low-level parsers manually and still want the core default post-parse path. + +```ts +import { + bakeSolidTextureSamples, + optimizeMeshParseResult, + parseGltf, +} from "@layoutit/polycss-core"; + +const parsed = parseGltf(bytes, { baseUrl }); +const baked = await bakeSolidTextureSamples(parsed); +const optimized = optimizeMeshParseResult(baked, { + meshResolution: "lossy", + source: parsed, +}); +``` + +Pass `source` when the result has gone through solid texture baking; it lets the optimizer merge near-identical baked swatch colors only on faces that were texture-backed in the source. Static triangle simplification is enabled by default for lossy non-animated parse results and is accepted only when the final optimized polygon count is lower than the baseline optimizer result. + +--- + +## `simplifyTriangleMeshPolygons(polygons, options?)` + +Runs endpoint-preserving triangle decimation for solid untextured triangle groups. Collapses land on existing vertex positions, textured polygons are skipped, material/color boundaries are kept separate, and non-manifold edge vertices are locked so one bad edge does not force the whole group to be skipped. + +```ts +import { simplifyTriangleMeshPolygons } from "@layoutit/polycss-core"; + +const candidate = simplifyTriangleMeshPolygons(rawPolygons, { + ratio: 0.7, + preserveVertices: true, +}); +``` + +Compare the candidate with `optimizeMeshPolygons()` before accepting it when DOM count is the deciding constraint. + +For imported glTF meshes that preserve source vertex identity, `vertexKeyMode: "source"` can be used as a conservative fallback candidate after the default relaxed seam-key pass. `optimizeMeshParseResult()` handles that fallback automatically. + --- ## `createIsometricCamera(initial?)` diff --git a/website/src/content/docs/api/types.mdx b/website/src/content/docs/api/types.mdx index f999657f..e2a5622a 100644 --- a/website/src/content/docs/api/types.mdx +++ b/website/src/content/docs/api/types.mdx @@ -463,6 +463,25 @@ interface LoadMeshOptions { } ``` +`loadMesh()` applies `optimizeMeshParseResult()` internally. In default lossy mode this includes baked swatch color cleanup, static triangle simplification for eligible non-animated meshes, and final DOM-cost mesh optimization. + +--- + +## `OptimizeMeshParseResultOptions` + +Options for `optimizeMeshParseResult()`, the core post-parse optimization path used by `loadMesh()`. + +```ts +interface OptimizeMeshParseResultOptions { + meshResolution?: "lossless" | "lossy"; + source?: ParseResult; + bakedTextureColorMergeDistance?: number; + simplifyTriangleMeshes?: boolean; + simplifyTriangleMeshOptions?: SimplifyTriangleMeshPolygonsOptions; + simplifyEarlyStopDropRatio?: number; +} +``` + --- ## `SolidTextureSampleOptions` diff --git a/website/src/content/docs/core-concepts.mdx b/website/src/content/docs/core-concepts.mdx index 29111dc1..6014cc51 100644 --- a/website/src/content/docs/core-concepts.mdx +++ b/website/src/content/docs/core-concepts.mdx @@ -119,7 +119,7 @@ You normally do not target these tags directly; use `Poly`, `PolyMesh`, classes, ## Automatic Polygon Merge -Before rendering, PolyCSS automatically optimizes loaded meshes. `meshResolution: "lossless"` keeps exact planar candidates only; the default `"lossy"` mode can also merge near-coplanar candidates within a bounded displacement budget, choosing the lowest estimated DOM render cost. Larger lossy candidates are accepted only when the DOM win is meaningful and whole-mesh seam diagnostics do not regress. This keeps DOM element counts low for flat surfaces without changing the intended rendered shape. +Before rendering, PolyCSS automatically optimizes loaded meshes. `meshResolution: "lossless"` keeps exact planar candidates only; the default `"lossy"` mode also bakes solid texture swatches, merges visually redundant baked swatch colors, tries static triangle simplification for eligible non-animated imports, and can merge near-coplanar candidates within a bounded displacement budget. Candidates are accepted only when the final DOM win is meaningful and whole-mesh seam diagnostics do not regress. This keeps DOM element counts low for flat surfaces without changing the intended rendered shape. Per-polygon DOM identity is preserved for polygons that cannot merge; polygons inside a merged flat region become one rendered element. diff --git a/website/src/content/docs/guides/performance.mdx b/website/src/content/docs/guides/performance.mdx index 6ecc4ff8..ab9b9a0e 100644 --- a/website/src/content/docs/guides/performance.mdx +++ b/website/src/content/docs/guides/performance.mdx @@ -30,7 +30,7 @@ Confirmed during benchmarking on a 10k-triangle mesh with auto-rotate: ## Automatic mesh optimization -PolyCSS automatically optimizes loaded meshes before rendering. The default `meshResolution: "lossy"` path merges compatible polygons and can use bounded geometric approximation when that lowers estimated DOM render cost. Wider lossy candidates are gated by whole-mesh seam diagnostics and a minimum render-cost win. `meshResolution: "lossless"` keeps exact planar candidates only. +PolyCSS automatically optimizes loaded meshes before rendering. The default `meshResolution: "lossy"` path bakes solid texture swatches, merges visually redundant baked swatch colors, tries static triangle simplification for eligible non-animated imports, merges compatible polygons, and can use bounded geometric approximation when that lowers estimated DOM render cost. Wider lossy candidates are gated by whole-mesh seam diagnostics and a minimum render-cost win. `meshResolution: "lossless"` keeps exact planar candidates only. This is most useful for architectural meshes with large flat surfaces: walls, floors, ceilings, voxel faces, and other areas where many same-material triangles can collapse without visual change. From 11effba7f891aa64b12d939e9494d74f26c6737b Mon Sep 17 00:00:00 2001 From: agustin-littlehat Date: Mon, 1 Jun 2026 19:26:33 -0300 Subject: [PATCH 3/7] chore(bench): add gltf simplifier corpus benchmark --- bench/gltf-simplifier-corpus-bench.mjs | 528 +++++++++++++++++++++++++ 1 file changed, 528 insertions(+) create mode 100644 bench/gltf-simplifier-corpus-bench.mjs diff --git a/bench/gltf-simplifier-corpus-bench.mjs b/bench/gltf-simplifier-corpus-bench.mjs new file mode 100644 index 00000000..1d159183 --- /dev/null +++ b/bench/gltf-simplifier-corpus-bench.mjs @@ -0,0 +1,528 @@ +#!/usr/bin/env node +/** + * Compare baseline gallery GLB/GLTF imports against the PolyCSS-owned + * simplifier. Optionally includes meshoptimizer as an external comparator + * when installed outside the repo. + * + * Usage: + * node bench/gltf-simplifier-corpus-bench.mjs --json bench/results/poly-simplifier.json + * node bench/gltf-simplifier-corpus-bench.mjs --models flying-saucer,space-shuttle --progress 1 + * node bench/gltf-simplifier-corpus-bench.mjs --meshoptimizer --meshoptimizer-package /tmp/polycss-meshopt/package.json + */ +import { createRequire } from "node:module"; +import { existsSync, mkdirSync, readdirSync, readFileSync, writeFileSync } from "node:fs"; +import { dirname, extname, join, relative, resolve } from "node:path"; +import { fileURLToPath, pathToFileURL } from "node:url"; +import { performance } from "node:perf_hooks"; +import { + bakeSolidTextureSamples, + optimizeMeshParseResult, + parseGltf, +} from "../packages/core/dist/index.js"; + +const repoRoot = resolve(dirname(fileURLToPath(import.meta.url)), ".."); +const galleryGlbRoot = resolve(repoRoot, "website/public/gallery/glb"); +const requireFromWebsite = createRequire(resolve(repoRoot, "website/package.json")); + +const argv = process.argv.slice(2); +const flag = (name) => argv.indexOf(`--${name}`); +const hasFlag = (name) => flag(name) >= 0; +const optStr = (name, dflt = "") => { + const index = flag(name); + return index >= 0 ? argv[index + 1] : dflt; +}; + +if (hasFlag("help")) { + console.log(`Usage: node bench/gltf-simplifier-corpus-bench.mjs [--json file] + +Options: + --models Comma-separated case-insensitive path substrings. + --root Scan GLB/GLTF files under this root. Default: website/public/gallery/glb. + --json Write full summary + rows as JSON. + --progress Print progress to stderr every n scanned files. + --limit Number of rows to print in win/regression tables. + --file-offset Skip first n selected files. + --file-limit Scan at most n selected files. + --stop-drop-pct Candidate early-stop target drop percentage. Default: 15. + --simplify-ratio Triangle simplifier target ratio. Default: core default. + --simplify-max-error Triangle simplifier max displacement. Default: core default. + --simplify-max-normal-angle Triangle simplifier max normal angle in degrees. Default: core default. + --meshoptimizer Include external meshoptimizer comparator. + --meshoptimizer-package package.json whose node_modules contains @gltf-transform/* + meshoptimizer. +`); + process.exit(0); +} + +const PRINT_LIMIT = Math.max(1, Number(optStr("limit", "16")) || 16); +const FILE_OFFSET = Math.max(0, Number(optStr("file-offset", "0")) || 0); +const FILE_LIMIT = Math.max(0, Number(optStr("file-limit", "0")) || 0); +const PROGRESS_EVERY = Math.max(0, Number(optStr("progress", "0")) || 0); +const stopDropPctValue = Number(optStr("stop-drop-pct", "15")); +const STOP_DROP_PCT = Number.isFinite(stopDropPctValue) ? Math.max(0, stopDropPctValue) : 15; +const INCLUDE_MESHOPTIMIZER = hasFlag("meshoptimizer"); +const sourceRoot = optStr("root") ? resolve(repoRoot, optStr("root")) : galleryGlbRoot; +const SIMPLIFY_OPTIONS = {}; +const simplifyRatio = Number(optStr("simplify-ratio", "NaN")); +const simplifyMaxError = Number(optStr("simplify-max-error", "NaN")); +const simplifyMaxNormalAngle = Number(optStr("simplify-max-normal-angle", "NaN")); +if (Number.isFinite(simplifyRatio)) SIMPLIFY_OPTIONS.ratio = simplifyRatio; +if (Number.isFinite(simplifyMaxError)) SIMPLIFY_OPTIONS.maxError = simplifyMaxError; +if (Number.isFinite(simplifyMaxNormalAngle)) SIMPLIFY_OPTIONS.maxNormalAngleDeg = simplifyMaxNormalAngle; + +let textureSamplingReady = false; +let textureSamplingUnavailable = false; +let meshoptimizerDepsPromise = null; + +function walk(dir, exts) { + const out = []; + if (!existsSync(dir)) return out; + for (const entry of readdirSync(dir, { withFileTypes: true })) { + const path = join(dir, entry.name); + if (entry.isDirectory()) out.push(...walk(path, exts)); + else if (exts.has(extname(entry.name).toLowerCase())) out.push(path); + } + return out.sort((a, b) => a.localeCompare(b)); +} + +function selectedFiles() { + const needles = optStr("models") + .split(",") + .map((value) => value.trim().toLowerCase()) + .filter(Boolean); + let files = walk(sourceRoot, new Set([".glb", ".gltf"])); + if (needles.length > 0) { + files = files.filter((file) => { + const label = relative(sourceRoot, file).toLowerCase(); + return needles.some((needle) => label.includes(needle)); + }); + } + const start = Math.min(FILE_OFFSET, files.length); + const end = FILE_LIMIT > 0 ? Math.min(files.length, start + FILE_LIMIT) : files.length; + return files.slice(start, end); +} + +function readBytes(path) { + const bytes = readFileSync(path); + return bytes.buffer.slice(bytes.byteOffset, bytes.byteOffset + bytes.byteLength); +} + +function resolveGltfBuffer(modelPath, uri) { + if (/^file:\/\//i.test(uri)) return readFileSync(fileURLToPath(uri)); + return readFileSync(resolve(dirname(modelPath), uri)); +} + +async function readImageBytes(url) { + if (/^(blob:|data:|https?:)/.test(url)) { + const response = await fetch(url); + if (!response.ok) throw new Error(`image fetch failed: ${response.status}`); + return Buffer.from(await response.arrayBuffer()); + } + return readFileSync(url.startsWith("file://") ? fileURLToPath(url) : url); +} + +function installTextureSamplingEnv() { + if (textureSamplingReady) return true; + if (textureSamplingUnavailable) return false; + + let sharp; + try { + sharp = requireFromWebsite("sharp"); + } catch { + textureSamplingUnavailable = true; + return false; + } + + class BenchImage { + onload = null; + onerror = null; + decoding = "async"; + width = 0; + height = 0; + naturalWidth = 0; + naturalHeight = 0; + data = null; + #decodePromise = null; + #src = ""; + + set src(value) { + this.#src = value; + this.#decodePromise = (async () => { + const input = await readImageBytes(value); + const { data, info } = await sharp(input).ensureAlpha().raw().toBuffer({ resolveWithObject: true }); + this.width = this.naturalWidth = info.width; + this.height = this.naturalHeight = info.height; + this.data = data; + this.onload?.(); + })().catch((error) => { + this.onerror?.(); + throw error; + }); + } + + get src() { + return this.#src; + } + + decode() { + return this.#decodePromise ?? Promise.resolve(); + } + } + + class BenchCanvas { + width = 0; + height = 0; + image = null; + + getContext() { + return { + drawImage: (image) => { + this.image = image; + }, + getImageData: () => ({ + data: this.image?.data ?? new Uint8ClampedArray(this.width * this.height * 4), + }), + }; + } + } + + globalThis.Image = BenchImage; + globalThis.document = { + createElement: (tagName) => tagName === "canvas" ? new BenchCanvas() : {}, + }; + textureSamplingReady = true; + return true; +} + +async function applyGalleryTexturePrepass(result) { + if (!installTextureSamplingEnv()) return result; + return bakeSolidTextureSamples(result); +} + +function hasTexturePaint(polygon) { + return Boolean( + polygon.texture || + polygon.material?.texture || + polygon.uvs?.length || + polygon.textureTriangles?.length + ); +} + +async function parsePrepared(modelPath, bytes) { + const parsed = parseGltf(bytes, { + baseUrl: pathToFileURL(modelPath).href, + resolveBuffer: (uri) => resolveGltfBuffer(modelPath, uri), + }); + const baked = await applyGalleryTexturePrepass(parsed); + return { + parsed, + baked, + }; +} + +function polygonStats(polygons) { + let triangles = 0; + let quads = 0; + let textured = 0; + let maxVertices = 0; + for (const polygon of polygons) { + if (polygon.vertices.length === 3) triangles += 1; + else if (polygon.vertices.length === 4) quads += 1; + if (hasTexturePaint(polygon)) textured += 1; + maxVertices = Math.max(maxVertices, polygon.vertices.length); + } + return { + count: polygons.length, + triangles, + quads, + textured, + maxVertices, + }; +} + +function pctDrop(after, before) { + return before > 0 ? ((before - after) / before) * 100 : 0; +} + +async function importFromPackage(requireFrom, name) { + return import(pathToFileURL(requireFrom.resolve(name)).href); +} + +async function loadMeshoptimizerDeps() { + if (!INCLUDE_MESHOPTIMIZER) return null; + meshoptimizerDepsPromise ??= (async () => { + const packageJson = optStr("meshoptimizer-package", "website/package.json"); + const requireFrom = createRequire(resolve(repoRoot, packageJson)); + const [ + { Logger, NodeIO }, + { ALL_EXTENSIONS }, + { dedup, flatten, join, prune, simplify, weld }, + { MeshoptDecoder, MeshoptEncoder, MeshoptSimplifier }, + ] = await Promise.all([ + importFromPackage(requireFrom, "@gltf-transform/core"), + importFromPackage(requireFrom, "@gltf-transform/extensions"), + importFromPackage(requireFrom, "@gltf-transform/functions"), + importFromPackage(requireFrom, "meshoptimizer"), + ]); + await Promise.all([ + MeshoptDecoder.ready, + MeshoptEncoder.ready, + MeshoptSimplifier.ready, + ]); + return { + Logger, + NodeIO, + ALL_EXTENSIONS, + dedup, + flatten, + join, + prune, + simplify, + weld, + MeshoptDecoder, + MeshoptEncoder, + MeshoptSimplifier, + }; + })(); + return meshoptimizerDepsPromise; +} + +async function simplifyWithMeshoptimizer(modelPath) { + const deps = await loadMeshoptimizerDeps(); + if (!deps) return null; + const io = new deps.NodeIO() + .setLogger(new deps.Logger(deps.Logger.Verbosity.ERROR)) + .registerExtensions(deps.ALL_EXTENSIONS) + .registerDependencies({ + "meshopt.decoder": deps.MeshoptDecoder, + "meshopt.encoder": deps.MeshoptEncoder, + }); + const document = await io.read(modelPath); + await document.transform( + deps.dedup(), + deps.flatten(), + deps.join(), + deps.weld(), + deps.simplify({ + simplifier: deps.MeshoptSimplifier, + ratio: 0.7, + error: 0.003, + lockBorder: true, + }), + deps.prune(), + ); + const out = await io.writeBinary(document); + return out.buffer.slice(out.byteOffset, out.byteOffset + out.byteLength); +} + +async function timed(fn) { + const started = performance.now(); + const value = await fn(); + return { + value, + ms: Number((performance.now() - started).toFixed(1)), + }; +} + +function optimizeCoreParseResult(prepared, options = {}) { + return optimizeMeshParseResult(prepared.baked, { + meshResolution: "lossy", + source: prepared.parsed, + ...options, + }); +} + +async function summarizeModel(modelPath) { + const label = relative(sourceRoot, modelPath); + const started = performance.now(); + const originalBytes = readBytes(modelPath); + const baselinePrepared = await timed(() => parsePrepared(modelPath, originalBytes)); + const baselineSourcePolygons = baselinePrepared.value.baked.polygons; + const baselineOptimizedPolygons = optimizeCoreParseResult(baselinePrepared.value, { + simplifyTriangleMeshes: false, + }).polygons; + const baselineOptimized = polygonStats(baselineOptimizedPolygons); + const animated = (baselinePrepared.value.parsed.animation?.clips?.length ?? 0) > 0; + + const coreOptimized = await timed(() => Promise.resolve(optimizeCoreParseResult(baselinePrepared.value, { + simplifyTriangleMeshOptions: SIMPLIFY_OPTIONS, + simplifyEarlyStopDropRatio: STOP_DROP_PCT / 100, + }))); + const polyOptimizedPolygons = coreOptimized.value.polygons; + const polyOptimized = polygonStats(polyOptimizedPolygons); + const polyAccepted = polyOptimized.count < baselineOptimized.count; + + let meshoptimizer = null; + if (INCLUDE_MESHOPTIMIZER) { + const meshoptBytes = await timed(() => simplifyWithMeshoptimizer(modelPath)); + const meshoptPrepared = await timed(() => parsePrepared(modelPath, meshoptBytes.value)); + const meshoptOptimized = optimizeCoreParseResult(meshoptPrepared.value, { + simplifyTriangleMeshes: false, + }).polygons; + meshoptimizer = { + raw: polygonStats(meshoptPrepared.value.baked.polygons), + lossy: polygonStats(meshoptOptimized), + bytes: meshoptBytes.value.byteLength, + timings: { + transformMs: meshoptBytes.ms, + parseMs: meshoptPrepared.ms, + }, + }; + } + + return { + model: label, + ext: extname(modelPath).slice(1).toLowerCase(), + animated, + baseline: { + raw: polygonStats(baselineSourcePolygons), + lossy: baselineOptimized, + bytes: originalBytes.byteLength, + }, + polycss: { + accepted: polyAccepted, + variant: polyAccepted ? "core-default" : "none", + raw: polygonStats(polyOptimizedPolygons), + candidateRaw: polygonStats(polyOptimizedPolygons), + lossy: polyOptimized, + candidateLossy: polyOptimized, + timings: { + simplifyMs: coreOptimized.ms, + }, + }, + meshoptimizer, + deltas: { + polycssLossy: polyOptimized.count - baselineOptimized.count, + polycssLossyDropPct: Number(pctDrop(polyOptimized.count, baselineOptimized.count).toFixed(1)), + meshoptimizerLossy: meshoptimizer ? meshoptimizer.lossy.count - baselineOptimized.count : null, + meshoptimizerLossyDropPct: meshoptimizer + ? Number(pctDrop(meshoptimizer.lossy.count, baselineOptimized.count).toFixed(1)) + : null, + }, + timings: { + baselineParseMs: baselinePrepared.ms, + totalMs: Number((performance.now() - started).toFixed(1)), + }, + }; +} + +function summarizeRows(rows, errors, elapsedMs) { + const total = (selector) => rows.reduce((sum, row) => sum + selector(row), 0); + const baselineLossy = total((row) => row.baseline.lossy.count); + const polyLossy = total((row) => row.polycss.lossy.count); + const meshRows = rows.filter((row) => row.meshoptimizer); + const meshLossy = meshRows.length > 0 ? total((row) => row.meshoptimizer?.lossy.count ?? 0) : null; + return { + scanned: rows.length, + errors: errors.length, + animated: rows.filter((row) => row.animated).length, + elapsedMs: Number(elapsedMs.toFixed(1)), + baseline: { + raw: total((row) => row.baseline.raw.count), + lossy: baselineLossy, + bytes: total((row) => row.baseline.bytes), + }, + polycss: { + accepted: rows.filter((row) => row.polycss.accepted).length, + raw: total((row) => row.polycss.raw.count), + lossy: polyLossy, + delta: polyLossy - baselineLossy, + dropPct: Number(pctDrop(polyLossy, baselineLossy).toFixed(1)), + wins: rows.filter((row) => row.deltas.polycssLossy < 0).length, + unchanged: rows.filter((row) => row.deltas.polycssLossy === 0).length, + regressions: rows.filter((row) => row.deltas.polycssLossy > 0).length, + simplifyMs: Number(total((row) => row.polycss.timings.simplifyMs).toFixed(1)), + }, + meshoptimizer: meshRows.length > 0 && meshLossy !== null + ? { + raw: total((row) => row.meshoptimizer?.raw.count ?? 0), + lossy: meshLossy, + delta: meshLossy - baselineLossy, + dropPct: Number(pctDrop(meshLossy, baselineLossy).toFixed(1)), + wins: meshRows.filter((row) => row.deltas.meshoptimizerLossy < 0).length, + unchanged: meshRows.filter((row) => row.deltas.meshoptimizerLossy === 0).length, + regressions: meshRows.filter((row) => row.deltas.meshoptimizerLossy > 0).length, + transformMs: Number(total((row) => row.meshoptimizer?.timings.transformMs ?? 0).toFixed(1)), + } + : null, + }; +} + +function printSummary(output) { + const { summary } = output; + console.log("gltf simplifier corpus benchmark"); + console.log(`models=${summary.scanned} errors=${summary.errors} animated=${summary.animated} elapsedMs=${summary.elapsedMs}`); + console.log(`baseline lossy leaves=${summary.baseline.lossy} raw=${summary.baseline.raw}`); + console.log(`polycss lossy leaves ${summary.baseline.lossy}->${summary.polycss.lossy} delta=${summary.polycss.delta} drop=${summary.polycss.dropPct}% accepted=${summary.polycss.accepted} wins=${summary.polycss.wins} unchanged=${summary.polycss.unchanged} regressions=${summary.polycss.regressions} simplifyMs=${summary.polycss.simplifyMs}`); + if (summary.meshoptimizer) { + console.log(`meshoptimizer lossy leaves ${summary.baseline.lossy}->${summary.meshoptimizer.lossy} delta=${summary.meshoptimizer.delta} drop=${summary.meshoptimizer.dropPct}% wins=${summary.meshoptimizer.wins} unchanged=${summary.meshoptimizer.unchanged} regressions=${summary.meshoptimizer.regressions} transformMs=${summary.meshoptimizer.transformMs}`); + } + + const polyWins = [...output.rows] + .filter((row) => row.deltas.polycssLossy < 0) + .sort((a, b) => a.deltas.polycssLossy - b.deltas.polycssLossy); + if (polyWins.length > 0) { + console.log(""); + console.log("largest PolyCSS wins"); + for (const row of polyWins.slice(0, PRINT_LIMIT)) { + console.log(`${row.model}: ${row.baseline.lossy.count}->${row.polycss.lossy.count} delta=${row.deltas.polycssLossy} drop=${row.deltas.polycssLossyDropPct}% raw=${row.baseline.raw.count}->${row.polycss.raw.count}`); + } + } + + const meshWins = [...output.rows] + .filter((row) => row.deltas.meshoptimizerLossy !== null && row.deltas.meshoptimizerLossy < 0) + .sort((a, b) => a.deltas.meshoptimizerLossy - b.deltas.meshoptimizerLossy); + if (meshWins.length > 0) { + console.log(""); + console.log("largest meshoptimizer wins"); + for (const row of meshWins.slice(0, PRINT_LIMIT)) { + console.log(`${row.model}: ${row.baseline.lossy.count}->${row.meshoptimizer.lossy.count} delta=${row.deltas.meshoptimizerLossy} drop=${row.deltas.meshoptimizerLossyDropPct}% raw=${row.baseline.raw.count}->${row.meshoptimizer.raw.count}`); + } + } +} + +async function runCorpus() { + const started = performance.now(); + const rows = []; + const errors = []; + const files = selectedFiles(); + let scanned = 0; + for (const file of files) { + scanned += 1; + try { + rows.push(await summarizeModel(file)); + } catch (error) { + errors.push({ + model: relative(sourceRoot, file), + error: error instanceof Error ? error.message : String(error), + }); + } + if (PROGRESS_EVERY > 0 && (scanned % PROGRESS_EVERY === 0 || scanned === files.length)) { + const elapsedMs = Number((performance.now() - started).toFixed(1)); + console.error(`gltf-simplifier progress ${scanned}/${files.length} rows=${rows.length} errors=${errors.length} elapsedMs=${elapsedMs}`); + } + } + return { + summary: summarizeRows(rows, errors, performance.now() - started), + rows, + errors, + options: { + root: sourceRoot, + models: optStr("models").trim() || null, + fileOffset: FILE_OFFSET, + fileLimit: FILE_LIMIT || null, + meshoptimizer: INCLUDE_MESHOPTIMIZER, + meshoptimizerPackage: optStr("meshoptimizer-package") || null, + }, + }; +} + +const output = await runCorpus(); +printSummary(output); + +const jsonPath = optStr("json"); +if (jsonPath) { + const outputPath = resolve(repoRoot, jsonPath); + mkdirSync(dirname(outputPath), { recursive: true }); + writeFileSync(outputPath, `${JSON.stringify(output, null, 2)}\n`); + console.log(`wrote ${jsonPath}`); +} From 27cdfa4215bb1d07d119d27ef003b246d3024245 Mon Sep 17 00:00:00 2001 From: agustin-littlehat Date: Tue, 2 Jun 2026 13:01:43 -0300 Subject: [PATCH 4/7] perf(core): speed up parse mesh optimization --- .../core/src/cull/cullInteriorPolygons.ts | 45 +- packages/core/src/merge/mergePolygons.ts | 36 +- packages/core/src/merge/optimizePolygons.ts | 1004 ++++++++++++++--- packages/core/src/merge/seamRepair.ts | 22 +- .../src/parser/optimizeMeshParseResult.ts | 139 +-- 5 files changed, 955 insertions(+), 291 deletions(-) diff --git a/packages/core/src/cull/cullInteriorPolygons.ts b/packages/core/src/cull/cullInteriorPolygons.ts index 939f32a4..369d3ddb 100644 --- a/packages/core/src/cull/cullInteriorPolygons.ts +++ b/packages/core/src/cull/cullInteriorPolygons.ts @@ -36,6 +36,9 @@ interface PolyMeta { triFlat: Float64Array; /** Bounding sphere center + radius² */ bcx: number; bcy: number; bcz: number; br2: number; + /** Tangent basis for hemisphere sample rotation. */ + ux: number; uy: number; uz: number; + vx: number; vy: number; vz: number; /** AABB */ minX: number; minY: number; minZ: number; maxX: number; maxY: number; maxZ: number; @@ -71,6 +74,25 @@ function hasLargeOpenTopology(polygons: Polygon[]): boolean { ); } +function tangentBasis(nx: number, ny: number, nz: number): { + ux: number; uy: number; uz: number; + vx: number; vy: number; vz: number; +} { + const ax = Math.abs(nx) > 0.9 ? 0 : 1; + const ay = Math.abs(nx) > 0.9 ? 1 : 0; + let ux = ay * nz; + let uy = -ax * nz; + let uz = ax * ny - ay * nx; + const uLen = Math.hypot(ux, uy, uz); + ux /= uLen; uy /= uLen; uz /= uLen; + return { + ux, uy, uz, + vx: ny * uz - nz * uy, + vy: nz * ux - nx * uz, + vz: nx * uy - ny * ux, + }; +} + function precompute(p: Polygon): PolyMeta | null { const verts = p.vertices; if (!verts || verts.length < 3) return null; @@ -89,6 +111,7 @@ function precompute(p: Polygon): PolyMeta | null { const nLen = Math.hypot(nx, ny, nz); if (nLen < PARALLEL_EPS) return null; nx /= nLen; ny /= nLen; nz /= nLen; + const { ux, uy, uz, vx, vy, vz } = tangentBasis(nx, ny, nz); const nTri = verts.length - 2; const triFlat = new Float64Array(nTri * 9); @@ -118,6 +141,7 @@ function precompute(p: Polygon): PolyMeta | null { vertices: verts, triFlat, bcx: cx, bcy: cy, bcz: cz, br2, + ux, uy, uz, vx, vy, vz, minX, minY, minZ, maxX, maxY, maxZ, }; } @@ -184,7 +208,7 @@ function rayHitsPolygon( const BVH_STRIDE = 9; const BVH_LEAF_SIZE = 6; -const SAH_BUCKETS = 12; +const SAH_BUCKETS = 8; interface BVH { data: Float64Array; @@ -443,6 +467,8 @@ function rayHitsAnyInBVH( * Layout: [lx0, ly0, lz0, lx1, ly1, lz1, ...] */ function hemisphereSamplesFlat(k: number): Float64Array { + const cached = hemisphereSampleCache.get(k); + if (cached) return cached; const phi = (1 + Math.sqrt(5)) / 2; const out = new Float64Array(k * 3); for (let i = 0; i < k; i++) { @@ -453,22 +479,11 @@ function hemisphereSamplesFlat(k: number): Float64Array { out[i * 3 + 1] = r * Math.sin(theta); out[i * 3 + 2] = z; } + hemisphereSampleCache.set(k, out); return out; } -function basis(n: Vec3): { ux: number; uy: number; uz: number; vx: number; vy: number; vz: number } { - const ax = Math.abs(n[0]) > 0.9 ? 0 : 1; - const ay = Math.abs(n[0]) > 0.9 ? 1 : 0; - let ux = ay * n[2]; - let uy = -ax * n[2]; - let uz = ax * n[1] - ay * n[0]; - const uLen = Math.hypot(ux, uy, uz); - ux /= uLen; uy /= uLen; uz /= uLen; - const vx = n[1] * uz - n[2] * uy; - const vy = n[2] * ux - n[0] * uz; - const vz = n[0] * uy - n[1] * ux; - return { ux, uy, uz, vx, vy, vz }; -} +const hemisphereSampleCache = new Map(); export interface CullInteriorOptions { /** Hemisphere ray samples per polygon. Higher = fewer false positives, slower. Default 8. */ @@ -515,7 +530,7 @@ export function cullInteriorPolygons( } // Step 2 — multi-origin K-sample hemisphere test. - const { ux, uy, uz, vx, vy, vz } = basis(p.normal); + const { ux, uy, uz, vx, vy, vz } = p; const cx0 = p.centroid[0], cy0 = p.centroid[1], cz0 = p.centroid[2]; const verts = p.vertices; const vCount = verts.length; diff --git a/packages/core/src/merge/mergePolygons.ts b/packages/core/src/merge/mergePolygons.ts index fe551b18..d7691e4f 100644 --- a/packages/core/src/merge/mergePolygons.ts +++ b/packages/core/src/merge/mergePolygons.ts @@ -52,7 +52,7 @@ interface PolyState { normal: Vec3; /** Plane offset: distance from origin along the normal. */ d: number; - directedEdges: Set; + edgeDirections: Map; alive: boolean; doubleSided?: boolean; /** Original Polygon's `data` field, preserved through the merge. */ @@ -88,18 +88,6 @@ function edgeKey(a: Vec3, b: Vec3): string { return ka < kb ? `${ka}|${kb}` : `${kb}|${ka}`; } -function directedEdgeKey(a: Vec3, b: Vec3): string { - return `${vertexKey(a)}>${vertexKey(b)}`; -} - -function directedEdgeSet(vertices: Vec3[]): Set { - const edges = new Set(); - for (let k = 0; k < vertices.length; k++) { - edges.add(directedEdgeKey(vertices[k], vertices[(k + 1) % vertices.length])); - } - return edges; -} - function planeOf(vertices: Vec3[]): { normal: Vec3; d: number } | null { if (vertices.length < 3) return null; const e1 = sub(vertices[1], vertices[0]); @@ -330,12 +318,14 @@ export function mergePolygons(input: Polygon[]): Polygon[] { const kb = cachedVertexKey(b); return ka < kb ? `${ka}|${kb}` : `${kb}|${ka}`; }; - const cachedDirectedEdgeKey = (a: Vec3, b: Vec3): string => - `${cachedVertexKey(a)}>${cachedVertexKey(b)}`; - const cachedDirectedEdgeSet = (vertices: Vec3[]): Set => { - const edges = new Set(); + const canonicalEdgeDirection = (a: Vec3, b: Vec3): 1 | -1 => + cachedVertexKey(a) < cachedVertexKey(b) ? 1 : -1; + const cachedEdgeDirectionMap = (vertices: Vec3[]): Map => { + const edges = new Map(); for (let k = 0; k < vertices.length; k++) { - edges.add(cachedDirectedEdgeKey(vertices[k], vertices[(k + 1) % vertices.length])); + const a = vertices[k]; + const b = vertices[(k + 1) % vertices.length]; + edges.set(cachedEdgeKey(a, b), canonicalEdgeDirection(a, b)); } return edges; }; @@ -383,7 +373,7 @@ export function mergePolygons(input: Polygon[]): Polygon[] { textureTriangles, normal: plane.normal, d: plane.d, - directedEdges: cachedDirectedEdgeSet(verts), + edgeDirections: cachedEdgeDirectionMap(verts), alive: true, doubleSided: polygon.doubleSided === true, data: polygon.data, @@ -426,9 +416,9 @@ export function mergePolygons(input: Polygon[]): Polygon[] { let mergedThisPass = false; const edgeDirection = (poly: PolyState, e0: Vec3, e1: Vec3): 1 | -1 | 0 => { - if (poly.directedEdges.has(cachedDirectedEdgeKey(e0, e1))) return 1; - if (poly.directedEdges.has(cachedDirectedEdgeKey(e1, e0))) return -1; - return 0; + const direction = poly.edgeDirections.get(cachedEdgeKey(e0, e1)); + if (!direction) return 0; + return direction === canonicalEdgeDirection(e0, e1) ? 1 : -1; }; for (const edge of edgeIndex.values()) { @@ -474,7 +464,7 @@ export function mergePolygons(input: Polygon[]): Polygon[] { a.vertices = merged.vertices; a.uvs = merged.uvs; - a.directedEdges = cachedDirectedEdgeSet(merged.vertices); + a.edgeDirections = cachedEdgeDirectionMap(merged.vertices); a.textureTriangles = hasTexture ? [...(a.textureTriangles ?? []), ...(b.textureTriangles ?? [])] : undefined; diff --git a/packages/core/src/merge/optimizePolygons.ts b/packages/core/src/merge/optimizePolygons.ts index b51835b4..79e18f48 100644 --- a/packages/core/src/merge/optimizePolygons.ts +++ b/packages/core/src/merge/optimizePolygons.ts @@ -3,7 +3,11 @@ import { findOverlappingPolygonDuplicates } from "./dedupeOverlappingPolygons"; import type { MeshResolution, Polygon, TextureTriangle, Vec2, Vec3 } from "../types"; import { coverPlanarPolygons, type CoverPlanarPolygonsOptions } from "./coverPlanarPolygons"; import { mergePolygons } from "./mergePolygons"; -import { seamOverlapDiagnostics, type SeamOverlapDiagnostics } from "./seamRepair"; +import { seamOverlapSafetyDiagnostics, type SeamOverlapDiagnostics } from "./seamRepair"; +import { + simplifyTriangleMeshPolygons, + type SimplifyTriangleMeshPolygonsOptions, +} from "./simplifyTriangleMesh"; const NORMALIZE_MAX_ANGLE_DEG = 3; const NORMALIZE_MAX_PLANE_DISPLACEMENT = 0.03; @@ -120,16 +124,18 @@ interface TopologyEdgeStats { boundarySegments: TopologySegment[]; internalSegments: TopologySegment[]; boundaryLength: number; + internalIndex?: TopologySegmentIndex; + internalIndexTolerance?: number; +} + +interface TopologySegmentIndex { + cellSize: number; + cells: Map; } interface TopologyGapDiagnostics { - exactInternalEdges: number; - nearInternalEdges: number; - exposedInternalLength: number; - maxInternalOffset: number; tJunctionPairs: number; tJunctionLength: number; - excessBoundaryLength: number; } interface SegmentOverlapInfo { @@ -145,6 +151,8 @@ interface BestSafetyDiagnostics { } interface PreprocessCache { + skipInteriorCull?: boolean; + reuseSnappedInteriorCull?: boolean; baseline?: Polygon[]; deduped?: Polygon[]; dedupedIndices?: IndexFilter; @@ -153,9 +161,73 @@ interface PreprocessCache { snapped?: Polygon[]; snappedInterior?: Polygon[]; snappedInteriorIndices?: IndexFilter; + snappedInteriorUsesBaselineFilter?: boolean; + snappedInteriorExact?: Polygon[]; + snappedInteriorExactIndices?: IndexFilter; trianglePairSource?: TrianglePairSourceCache; } +interface OptimizeMeshPolygonsRunOptions { + requiredMaxPolygonCount?: number; + skipInteriorCull?: boolean; + skipExactRectCover?: boolean; + simplifiedCandidate?: boolean; + captureVisiblePolygons?: boolean; +} + +interface OptimizeMeshPolygonsRunResult { + polygons: Polygon[]; + visiblePolygons?: Polygon[]; +} + +type MeshOptimizationStep = + | { kind: "baselineCandidates" } + | { kind: "initialLossyCandidates" } + | { kind: "finalLossyCandidates" } + | { + kind: "staticSimplificationCandidates"; + options: OptimizeStaticSimplificationOptions; + stopOnAccept: boolean; + }; + +interface MeshOptimizationPlan { + steps: readonly MeshOptimizationStep[]; +} + +type MeshCandidateAcceptanceMode = "cost" | "dom" | "seamSafe"; + +interface MeshCandidateSubmission { + mode: MeshCandidateAcceptanceMode; + polygons: Polygon[]; + cost?: number; + sourcePolygonCount?: number; + maxPolygonCount?: number; +} + +interface MeshCandidateEngineConfig { + acceptor?: MeshCandidateAcceptor; + costCandidateMode?: Extract; + seamCandidateMode?: Extract; + offerBaseline?: boolean; +} + +export interface OptimizeStaticSimplificationOptions { + simplifyTriangleMeshOptions?: SimplifyTriangleMeshPolygonsOptions; + earlyStopDropRatio?: number; +} + +export interface OptimizeParseMeshPolygonsOptions extends OptimizeMeshPolygonsOptions { + staticSimplification?: OptimizeStaticSimplificationOptions | false; + useCandidateFirst?: boolean; +} + +interface StaticSimplificationPlan { + source: Polygon[]; + vertexKeyMode?: "relaxed" | "source"; + precomputed?: Polygon[] | null; + skipInteriorCull?: boolean; +} + type IndexFilter = number[] | null; const DEFAULT_NORMALIZE_OPTIONS: ResolvedGeometryNormalizeOptions = { @@ -200,6 +272,22 @@ const PREPARED_PAIR_MAX_ANGLE_DEG = 60; const PREPARED_PAIR_MAX_BOUNDARY_DISPLACEMENT = 0.06; const AGGRESSIVE_LOSSY_MIN_RENDER_COST_GAIN = 4; const AGGRESSIVE_LOSSY_MIN_SOURCE_GAIN_RATIO = 0.003; +const LARGE_BASELINE_AGGRESSIVE_SKIP_MIN_POLYGONS = 4000; +const LARGE_BASELINE_AGGRESSIVE_SKIP_MAX_BEST_POLYGONS = 3000; +const SIMPLIFIED_CANDIDATE_AGGRESSIVE_MAX_POLYGONS = 4500; +const SIMPLIFIED_CANDIDATE_CHAINED_AGGRESSIVE_MIN_POLYGONS = 2400; +const SIMPLIFIED_CANDIDATE_REUSE_CULL_MAX_POLYGONS = 4200; +const REUSED_SNAPPED_CULL_ALWAYS_MAX_BASELINE_POLYGONS = 1800; +const REUSED_SNAPPED_CULL_EXTENDED_MAX_BASELINE_POLYGONS = 5000; +const REUSED_SNAPPED_CULL_EXTENDED_MIN_BASELINE_SOURCE_RATIO = 0.28; +const DEFAULT_STATIC_SIMPLIFY_EARLY_STOP_DROP_RATIO = 0.15; +const MIN_STATIC_SIMPLIFY_BASELINE_SOURCE_RATIO = 0.23; +const SOURCE_FIRST_MIN_BASELINE_POLYGONS = 2000; +const SOURCE_FIRST_MAX_RELAXED_RAW_DROP = 16; +const VISIBLE_FIRST_MIN_SOURCE_POLYGONS = 4000; +const VISIBLE_FIRST_MIN_CULLED_POLYGONS = 900; +const VISIBLE_FIRST_MAX_RELAXED_RAW_DROP = 16; +const VISIBLE_FIRST_MIN_CULL_TO_RELAXED_DROP_RATIO = 1.5; const TOPOLOGY_GAP_TOLERANCE = 0.045; const TOPOLOGY_MIN_PARALLEL_DOT = 0.999; const TOPOLOGY_MIN_OVERLAP = 1e-5; @@ -228,138 +316,611 @@ const LARGE_LOSSY_RECT_COVER_OPTIONS: CoverPlanarPolygonsOptions = { maxCandidateAxes: 2, }; +const FULL_OPTIMIZATION_PLAN: MeshOptimizationPlan = { + steps: [ + { kind: "baselineCandidates" }, + { kind: "initialLossyCandidates" }, + { kind: "finalLossyCandidates" }, + ], +}; + export function optimizeMeshPolygons( polygons: Polygon[], options: OptimizeMeshPolygonsOptions = {}, ): Polygon[] { - const meshResolution = options.meshResolution ?? "lossy"; - const stopAtPolygonCount = Number.isFinite(options.stopAtPolygonCount) - ? Math.max(0, Math.floor(options.stopAtPolygonCount!)) - : undefined; - const preprocessCache: PreprocessCache = {}; - const baseline = preprocessModelPolygons(polygons, false, preprocessCache); - let best = baseline; - let bestCost = polygonRenderCost(best); - const shouldStop = (): boolean => ( - stopAtPolygonCount !== undefined && - best.length <= stopAtPolygonCount - ); - let bestDiagnostics: BestSafetyDiagnostics = { polygons: best }; - const resetBestDiagnostics = (seam?: SeamOverlapDiagnostics): void => { - bestDiagnostics = { polygons: best, seam }; - }; - const bestSeamDiagnostics = (): SeamOverlapDiagnostics => { - if (bestDiagnostics.polygons !== best) resetBestDiagnostics(); - if (!bestDiagnostics.seam) bestDiagnostics.seam = seamOverlapDiagnostics(best); - return bestDiagnostics.seam; - }; - const bestTopologyEdges = (): TopologyEdgeStats => { - if (bestDiagnostics.polygons !== best) resetBestDiagnostics(); - if (!bestDiagnostics.topologyEdges) bestDiagnostics.topologyEdges = collectTopologyEdgeStats(best); - return bestDiagnostics.topologyEdges; + return optimizeMeshPolygonsInternal(polygons, options).polygons; +} + +export function optimizeParseMeshPolygons( + polygons: Polygon[], + options: OptimizeParseMeshPolygonsOptions = {}, +): Polygon[] { + const optimizeOptions: OptimizeMeshPolygonsOptions = { + meshResolution: options.meshResolution, + stopAtPolygonCount: options.stopAtPolygonCount, + rectCover: options.rectCover, }; - const bestTopologySelfDiagnostics = (): TopologyGapDiagnostics => { - if (bestDiagnostics.polygons !== best) resetBestDiagnostics(); - if (!bestDiagnostics.topologySelf) { - bestDiagnostics.topologySelf = topologySelfDiagnostics(bestTopologyEdges(), TOPOLOGY_GAP_TOLERANCE); + const graph = new MeshOptimizationArtifactGraph(); + return graph.workspaceFor(polygons, { captureVisiblePolygons: true }) + .createRun(optimizeOptions, { captureVisiblePolygons: true }) + .optimizeParse({ + staticSimplification: options.staticSimplification, + useCandidateFirst: options.useCandidateFirst === true, + }) + .polygons; +} + +function optimizeMeshPolygonsInternal( + polygons: Polygon[], + options: OptimizeMeshPolygonsOptions = {}, + runOptions: OptimizeMeshPolygonsRunOptions = {}, +): OptimizeMeshPolygonsRunResult { + const graph = new MeshOptimizationArtifactGraph(); + return graph.workspaceFor(polygons, runOptions) + .createRun(options, runOptions) + .optimize(); +} + +class MeshOptimizationArtifactGraph { + private readonly workspaces = new Map>(); + + workspaceFor(source: Polygon[], runOptions: OptimizeMeshPolygonsRunOptions): MeshOptimizationWorkspace { + const key = workspaceCacheKey(runOptions); + let workspacesBySource = this.workspaces.get(key); + if (!workspacesBySource) { + workspacesBySource = new WeakMap(); + this.workspaces.set(key, workspacesBySource); } - return bestDiagnostics.topologySelf; - }; - const acceptCandidate = (candidate: Polygon[], cost = polygonRenderCost(candidate)): boolean => { - if (cost >= bestCost) return false; - best = candidate; - bestCost = cost; - resetBestDiagnostics(); - return true; - }; - const acceptSeamSafeCandidate = ( - candidate: Polygon[], - sourcePolygonCount: number, - cost = polygonRenderCost(candidate), - ): boolean => { - const gain = bestCost - cost; - if (gain <= 0) return false; - if (gain < aggressiveLossyMinRenderCostGain(sourcePolygonCount)) return false; - const candidateSeam = seamOverlapDiagnostics(candidate); - if (seamDiagnosticsWorse(candidateSeam, bestSeamDiagnostics())) return false; - if (topologyGapDiagnosticsWorse( - bestTopologyEdges(), - bestTopologySelfDiagnostics(), - candidate, - )) return false; - best = candidate; - bestCost = cost; - resetBestDiagnostics(candidateSeam); - return true; - }; + let workspace = workspacesBySource.get(source); + if (!workspace) { + workspace = new MeshOptimizationWorkspace(source, runOptions, this); + workspacesBySource.set(source, workspace); + } + return workspace; + } +} - const initialRectCover = meshResolution === "lossy" && options.rectCover === undefined - ? automaticLossyRectCoverOptions(baseline) - : options.rectCover; - if (shouldStop()) return best; - const rectCovered = applyRectCoverCandidate(baseline, initialRectCover); - if (rectCovered !== baseline) acceptCandidate(rectCovered); - if (shouldStop()) return best; - if ( - meshResolution === "lossy" && - options.rectCover === undefined +function workspaceCacheKey(runOptions: OptimizeMeshPolygonsRunOptions): string { + return [ + runOptions.skipInteriorCull === true ? "skip-cull" : "cull", + runOptions.simplifiedCandidate === true ? "simplified" : "source", + ].join(":"); +} + +class MeshOptimizationWorkspace { + private readonly source: Polygon[]; + private readonly preprocessCache: PreprocessCache; + private readonly graph: MeshOptimizationArtifactGraph; + + constructor( + source: Polygon[], + runOptions: OptimizeMeshPolygonsRunOptions, + graph: MeshOptimizationArtifactGraph, + ) { + this.source = source; + this.graph = graph; + this.preprocessCache = { + skipInteriorCull: runOptions.skipInteriorCull === true, + }; + } + + polygons(): Polygon[] { + return this.source; + } + + preprocess(normalizeGeometry: boolean | LossyApproximateOptions): Polygon[] { + return preprocessModelPolygons(this.source, normalizeGeometry, this.preprocessCache); + } + + configureSnappedCullReuse(baseline: Polygon[], runOptions: OptimizeMeshPolygonsRunOptions): void { + const baselineSourceRatio = baseline.length / Math.max(1, this.source.length); + this.preprocessCache.reuseSnappedInteriorCull = runOptions.simplifiedCandidate === true + ? this.source.length <= SIMPLIFIED_CANDIDATE_REUSE_CULL_MAX_POLYGONS + : baseline.length <= REUSED_SNAPPED_CULL_ALWAYS_MAX_BASELINE_POLYGONS || + ( + baseline.length <= REUSED_SNAPPED_CULL_EXTENDED_MAX_BASELINE_POLYGONS && + baselineSourceRatio >= REUSED_SNAPPED_CULL_EXTENDED_MIN_BASELINE_SOURCE_RATIO + ); + } + + visiblePolygons(): Polygon[] { + return this.preprocessCache.interior ?? this.preprocessCache.deduped ?? this.source; + } + + createRun( + options: OptimizeMeshPolygonsOptions, + runOptions: OptimizeMeshPolygonsRunOptions, + ): MeshCandidateEngine { + return new MeshCandidateEngine(this, options, runOptions); + } + + workspaceFor(source: Polygon[], runOptions: OptimizeMeshPolygonsRunOptions): MeshOptimizationWorkspace { + return this.graph.workspaceFor(source, runOptions); + } +} + +class MeshCandidateEngine { + private readonly workspace: MeshOptimizationWorkspace; + private readonly source: Polygon[]; + private readonly options: OptimizeMeshPolygonsOptions; + private readonly runOptions: OptimizeMeshPolygonsRunOptions; + private readonly meshResolution: MeshResolution; + private readonly stopAtPolygonCount?: number; + private readonly requiredMaxPolygonCount?: number; + private readonly baseline: Polygon[]; + private readonly visiblePolygons?: Polygon[]; + private readonly acceptor: MeshCandidateAcceptor; + private readonly costCandidateMode: Extract; + private readonly seamCandidateMode: Extract; + private readonly offerBaseline: boolean; + private baselineCandidatesComplete = false; + private initialLossyCandidatesComplete = false; + private finalLossyCandidatesComplete = false; + + constructor( + workspace: MeshOptimizationWorkspace, + options: OptimizeMeshPolygonsOptions, + runOptions: OptimizeMeshPolygonsRunOptions, + config: MeshCandidateEngineConfig = {}, ) { - const losslessRectCovered = applyRectCoverCandidate(baseline, undefined); - if (losslessRectCovered !== baseline) acceptCandidate(losslessRectCovered); - if (shouldStop()) return best; - } - if (meshResolution === "lossy" && (best.length <= 1 || bestCost <= 1 + 1e-9)) return best; - if (meshResolution === "lossy") { - const approximate = preprocessModelPolygons(polygons, DEFAULT_LOSSY_APPROXIMATE_OPTIONS, preprocessCache); - acceptCandidate(approximate); - if (shouldStop()) return best; + this.workspace = workspace; + this.source = workspace.polygons(); + this.options = options; + this.runOptions = runOptions; + this.costCandidateMode = config.costCandidateMode ?? "cost"; + this.seamCandidateMode = config.seamCandidateMode ?? "seamSafe"; + this.offerBaseline = config.offerBaseline === true; + this.meshResolution = options.meshResolution ?? "lossy"; + this.stopAtPolygonCount = Number.isFinite(options.stopAtPolygonCount) + ? Math.max(0, Math.floor(options.stopAtPolygonCount!)) + : undefined; + this.requiredMaxPolygonCount = Number.isFinite(runOptions.requiredMaxPolygonCount) + ? Math.max(0, Math.floor(runOptions.requiredMaxPolygonCount!)) + : undefined; + this.baseline = this.preprocess(false); + this.workspace.configureSnappedCullReuse(this.baseline, runOptions); + this.visiblePolygons = runOptions.captureVisiblePolygons + ? this.workspace.visiblePolygons() + : undefined; + this.acceptor = config.acceptor ?? new MeshCandidateAcceptor(this.baseline, this.requiredMaxPolygonCount); + } + + optimize(): OptimizeMeshPolygonsRunResult { + return this.runPlan(FULL_OPTIMIZATION_PLAN); + } + + optimizeParse(options: { + staticSimplification?: OptimizeStaticSimplificationOptions | false; + useCandidateFirst: boolean; + }): OptimizeMeshPolygonsRunResult { + const staticSimplification = options.staticSimplification === false + ? null + : options.staticSimplification ?? {}; + const steps: MeshOptimizationStep[] = [ + { kind: "baselineCandidates" }, + { kind: "initialLossyCandidates" }, + ]; + if (!options.useCandidateFirst) steps.push({ kind: "finalLossyCandidates" }); + if (staticSimplification) { + steps.push({ + kind: "staticSimplificationCandidates", + options: staticSimplification, + stopOnAccept: options.useCandidateFirst, + }); + } + if (options.useCandidateFirst) steps.push({ kind: "finalLossyCandidates" }); + return this.runPlan({ steps }); + } + + private get best(): Polygon[] { + return this.acceptor.polygons; + } + + private get bestCost(): number { + return this.acceptor.cost; + } + + private runStaticSimplificationCandidates(options: OptimizeStaticSimplificationOptions = {}): boolean { + if (this.meshResolution !== "lossy") return false; + if (!shouldTryStaticSimplification(this.source, this.best)) return false; + const rawRelaxed = simplifyTriangleMeshCandidate(this.source, options); + if (!rawRelaxed) return false; + + for (const plan of this.staticSimplificationPlans(rawRelaxed)) { + const simplified = resolveStaticSimplificationPlan(plan, options); + if (!simplified) continue; + if (this.generateStaticSimplificationCandidate( + simplified, + options, + plan.skipInteriorCull, + )) return true; + } + return false; + } + + private staticSimplificationPlans(rawRelaxed: Polygon[]): StaticSimplificationPlan[] { + const relaxedRawDrop = this.source.length - rawRelaxed.length; + const plans: StaticSimplificationPlan[] = []; + const visibleCullDrop = this.visiblePolygons && this.visiblePolygons !== this.source + ? this.source.length - this.visiblePolygons.length + : 0; + if ( - options.rectCover === undefined && - polygons.length >= AUTOMATIC_APPROXIMATE_RECT_COVER_MIN_SOURCE_POLYGONS && - polygons.length <= AUTOMATIC_APPROXIMATE_RECT_COVER_MAX_SOURCE_POLYGONS + this.visiblePolygons && + this.visiblePolygons !== this.source && + this.source.length >= VISIBLE_FIRST_MIN_SOURCE_POLYGONS && + visibleCullDrop >= VISIBLE_FIRST_MIN_CULLED_POLYGONS && + ( + relaxedRawDrop <= VISIBLE_FIRST_MAX_RELAXED_RAW_DROP || + visibleCullDrop >= relaxedRawDrop * VISIBLE_FIRST_MIN_CULL_TO_RELAXED_DROP_RATIO + ) ) { - acceptCandidate(applyRectCoverCandidate(approximate, AUTOMATIC_APPROXIMATE_RECT_COVER_OPTIONS)); - if (shouldStop()) return best; + plans.push({ + source: this.visiblePolygons, + skipInteriorCull: true, + }); } - if (options.rectCover !== undefined && options.rectCover !== false) { - acceptCandidate(applyRectCoverCandidate(approximate, options.rectCover)); - if (shouldStop()) return best; + + if ( + this.best.length >= SOURCE_FIRST_MIN_BASELINE_POLYGONS && + relaxedRawDrop <= SOURCE_FIRST_MAX_RELAXED_RAW_DROP && + hasSourceVertexKeys(this.source) + ) { + plans.push({ + source: this.source, + vertexKeyMode: "source", + }); } + + plans.push({ + source: this.source, + precomputed: rawRelaxed, + }); + return plans; + } + + private generateStaticSimplificationCandidate( + candidate: Polygon[], + options: OptimizeStaticSimplificationOptions, + skipInteriorCull = false, + ): boolean { + const runOptions: OptimizeMeshPolygonsRunOptions = { + requiredMaxPolygonCount: this.best.length - 1, + skipExactRectCover: true, + skipInteriorCull, + simplifiedCandidate: true, + }; + const workspace = this.workspace.workspaceFor(candidate, runOptions); + const before = this.best; + new MeshCandidateEngine( + workspace, + { + meshResolution: "lossy", + stopAtPolygonCount: staticSimplificationEarlyStopTarget(this.best.length, options), + }, + runOptions, + { + acceptor: this.acceptor, + costCandidateMode: "dom", + seamCandidateMode: "dom", + offerBaseline: true, + }, + ).optimize(); + return this.best !== before; + } + + private runPlan(plan: MeshOptimizationPlan): OptimizeMeshPolygonsRunResult { + for (const step of plan.steps) { + let stopAfterStep = false; + if (step.kind === "baselineCandidates") { + this.runBaselineCandidates(); + } else if (step.kind === "staticSimplificationCandidates") { + stopAfterStep = this.runStaticSimplificationCandidates(step.options) && step.stopOnAccept; + } else { + if (!this.shouldRunLossyPipeline()) break; + if (step.kind === "initialLossyCandidates") this.runInitialLossyPipeline(); + else this.runFinalLossyPipeline(); + } + if (stopAfterStep || this.shouldStop()) break; + } + return this.result(); + } + + private runBaselineCandidates(): void { + if (this.baselineCandidatesComplete) return; + this.baselineCandidatesComplete = true; + + if (this.offerBaseline) { + this.acceptCandidate(this.baseline); + if (this.shouldStop()) return; + } + + const initialRectCover = this.runOptions.skipExactRectCover === true + ? false + : this.meshResolution === "lossy" && this.options.rectCover === undefined + ? automaticLossyRectCoverOptions(this.baseline) + : this.options.rectCover; + if (this.shouldStop()) return; + + const rectCovered = applyRectCoverCandidate(this.baseline, initialRectCover); + if (rectCovered !== this.baseline) this.acceptCandidate(rectCovered); + if (this.shouldStop()) return; + + if ( + this.meshResolution === "lossy" && + this.options.rectCover === undefined && + this.runOptions.skipExactRectCover !== true + ) { + const losslessRectCovered = applyRectCoverCandidate(this.baseline, undefined); + if (losslessRectCovered !== this.baseline) this.acceptCandidate(losslessRectCovered); + } + } + + private shouldRunLossyPipeline(): boolean { + return this.meshResolution === "lossy" && + !this.shouldStop() && + this.best.length > 1 && + this.bestCost > 1 + 1e-9; + } + + private runInitialLossyPipeline(): void { + if (this.initialLossyCandidatesComplete) return; + this.initialLossyCandidatesComplete = true; + + const approximate = this.preprocess(DEFAULT_LOSSY_APPROXIMATE_OPTIONS); + this.acceptCandidate(approximate); + if (this.shouldStop()) return; + + if ( + this.options.rectCover === undefined && + this.source.length >= AUTOMATIC_APPROXIMATE_RECT_COVER_MIN_SOURCE_POLYGONS && + this.source.length <= AUTOMATIC_APPROXIMATE_RECT_COVER_MAX_SOURCE_POLYGONS + ) { + this.acceptCandidate(applyRectCoverCandidate(approximate, AUTOMATIC_APPROXIMATE_RECT_COVER_OPTIONS)); + if (this.shouldStop()) return; + } + + if (this.options.rectCover !== undefined && this.options.rectCover !== false) { + this.acceptCandidate(applyRectCoverCandidate(approximate, this.options.rectCover)); + if (this.shouldStop()) return; + } + } + + private runFinalLossyPipeline(): void { + if (this.finalLossyCandidatesComplete) return; + this.finalLossyCandidatesComplete = true; + + if ( + this.runOptions.simplifiedCandidate === true && + this.source.length > SIMPLIFIED_CANDIDATE_AGGRESSIVE_MAX_POLYGONS + ) { + return; + } + + this.runAggressiveLossyVariants(); + if (this.shouldStop()) return; + this.runLargeRectCoverCandidate(); + } + + private runAggressiveLossyVariants(): void { + const skipLargeBaselineAggressive = ( + this.stopAtPolygonCount === undefined && + this.source.length >= LARGE_BASELINE_AGGRESSIVE_SKIP_MIN_POLYGONS && + this.best.length <= LARGE_BASELINE_AGGRESSIVE_SKIP_MAX_BEST_POLYGONS + ); let acceptedBaseAggressive = false; - for (let variantIndex = 0; variantIndex < AGGRESSIVE_LOSSY_APPROXIMATE_VARIANTS.length; variantIndex += 1) { + for ( + let variantIndex = skipLargeBaselineAggressive ? AGGRESSIVE_LOSSY_APPROXIMATE_VARIANTS.length : 0; + variantIndex < AGGRESSIVE_LOSSY_APPROXIMATE_VARIANTS.length; + variantIndex += 1 + ) { if ( variantIndex === 1 && !acceptedBaseAggressive ) continue; + if ( + variantIndex === 1 && + this.runOptions.simplifiedCandidate === true && + this.source.length < SIMPLIFIED_CANDIDATE_CHAINED_AGGRESSIVE_MIN_POLYGONS + ) continue; if ( variantIndex === 2 && - !automaticWideLossyVariantCandidate(polygons) + !automaticWideLossyVariantCandidate(this.source) ) continue; - const aggressiveOptions = AGGRESSIVE_LOSSY_APPROXIMATE_VARIANTS[variantIndex]; - const aggressive = preprocessModelPolygons(polygons, aggressiveOptions, preprocessCache); + const aggressive = this.preprocess(AGGRESSIVE_LOSSY_APPROXIMATE_VARIANTS[variantIndex]); let aggressiveCandidate = aggressive; if ( - options.rectCover === undefined && - polygons.length >= AUTOMATIC_APPROXIMATE_RECT_COVER_MIN_SOURCE_POLYGONS && - polygons.length <= AUTOMATIC_APPROXIMATE_RECT_COVER_MAX_SOURCE_POLYGONS + this.options.rectCover === undefined && + this.source.length >= AUTOMATIC_APPROXIMATE_RECT_COVER_MIN_SOURCE_POLYGONS && + this.source.length <= AUTOMATIC_APPROXIMATE_RECT_COVER_MAX_SOURCE_POLYGONS ) { aggressiveCandidate = applyRectCoverCandidate(aggressive, AUTOMATIC_APPROXIMATE_RECT_COVER_OPTIONS); } - if (options.rectCover !== undefined && options.rectCover !== false) { - aggressiveCandidate = applyRectCoverCandidate(aggressive, options.rectCover); + if (this.options.rectCover !== undefined && this.options.rectCover !== false) { + aggressiveCandidate = applyRectCoverCandidate(aggressive, this.options.rectCover); } - const accepted = acceptSeamSafeCandidate(aggressiveCandidate, polygons.length); + const accepted = this.acceptSeamSafeCandidate(aggressiveCandidate, this.source.length); if (variantIndex === 0 && accepted) acceptedBaseAggressive = true; - if (shouldStop()) return best; + if (this.shouldStop()) return; } - if (options.rectCover === undefined) { - const largeRectCovered = applyRectCoverCandidate(best, automaticLargeLossyRectCoverCandidate(best)); - if (largeRectCovered !== best) acceptSeamSafeCandidate(largeRectCovered, best.length); - if (shouldStop()) return best; + } + + private runLargeRectCoverCandidate(): void { + if (this.options.rectCover !== undefined) return; + const largeRectCovered = applyRectCoverCandidate(this.best, automaticLargeLossyRectCoverCandidate(this.best)); + if (largeRectCovered !== this.best) { + this.acceptSeamSafeCandidate(largeRectCovered, this.best.length); } } - return best; + private preprocess(normalizeGeometry: boolean | LossyApproximateOptions): Polygon[] { + return this.workspace.preprocess(normalizeGeometry); + } + + private result(): OptimizeMeshPolygonsRunResult { + return { polygons: this.best, visiblePolygons: this.visiblePolygons }; + } + + private shouldStop(): boolean { + return this.stopAtPolygonCount !== undefined && + this.best.length <= this.stopAtPolygonCount; + } + + private acceptCandidate(candidate: Polygon[], cost = polygonRenderCost(candidate)): boolean { + return this.emitCandidate({ + mode: this.costCandidateMode, + polygons: candidate, + cost, + }); + } + + private acceptDomCandidate(candidate: Polygon[]): boolean { + return this.emitCandidate({ + mode: "dom", + polygons: candidate, + }); + } + + private acceptSeamSafeCandidate( + candidate: Polygon[], + sourcePolygonCount: number, + cost?: number, + ): boolean { + return this.emitCandidate({ + mode: this.seamCandidateMode, + polygons: candidate, + sourcePolygonCount, + cost, + }); + } + + private emitCandidate(candidate: MeshCandidateSubmission): boolean { + return this.acceptor.accept({ + ...candidate, + maxPolygonCount: candidate.maxPolygonCount ?? this.requiredMaxPolygonCount, + }); + } +} + +class MeshCandidateAcceptor { + private best: Polygon[]; + private bestCost: number; + private bestDiagnostics: BestSafetyDiagnostics; + private readonly requiredMaxPolygonCount?: number; + + constructor(baseline: Polygon[], requiredMaxPolygonCount?: number) { + this.best = baseline; + this.bestCost = polygonRenderCost(baseline); + this.bestDiagnostics = { polygons: baseline }; + this.requiredMaxPolygonCount = requiredMaxPolygonCount; + } + + get polygons(): Polygon[] { + return this.best; + } + + get cost(): number { + return this.bestCost; + } + + accept(candidate: MeshCandidateSubmission): boolean { + if (candidate.mode === "cost") { + return this.acceptCostCandidate(candidate.polygons, candidate.cost); + } + if (candidate.mode === "dom") { + return this.acceptDomCandidate(candidate.polygons, candidate.cost, candidate.maxPolygonCount); + } + return this.acceptSeamSafeCandidate( + candidate.polygons, + candidate.sourcePolygonCount ?? candidate.polygons.length, + candidate.cost, + candidate.maxPolygonCount, + ); + } + + private acceptCostCandidate( + candidate: Polygon[], + cost = polygonRenderCost(candidate), + ): boolean { + if (cost >= this.bestCost) return false; + this.commit(candidate, cost); + return true; + } + + private acceptDomCandidate( + candidate: Polygon[], + cost = polygonRenderCost(candidate), + maxPolygonCount?: number, + ): boolean { + if (candidate.length >= this.best.length) return false; + if (!this.withinMaxPolygonCount(candidate, maxPolygonCount)) return false; + this.commit(candidate, cost); + return true; + } + + private acceptSeamSafeCandidate( + candidate: Polygon[], + sourcePolygonCount: number, + cost?: number, + maxPolygonCount?: number, + ): boolean { + if (candidate.length >= this.best.length) return false; + if (!this.withinMaxPolygonCount(candidate, maxPolygonCount)) return false; + const minGain = aggressiveLossyMinRenderCostGain(sourcePolygonCount); + if (this.bestCost - candidate.length < minGain) return false; + const candidateCost = cost ?? polygonRenderCost(candidate); + const gain = this.bestCost - candidateCost; + if (gain <= 0) return false; + if (gain < minGain) return false; + const candidateSeam = seamOverlapSafetyDiagnostics(candidate); + if (seamDiagnosticsWorse(candidateSeam, this.bestSeamDiagnostics())) return false; + if (topologyGapDiagnosticsWorse( + this.bestTopologyEdges(), + this.bestTopologySelfDiagnostics(), + candidate, + )) return false; + this.commit(candidate, candidateCost, candidateSeam); + return true; + } + + private withinMaxPolygonCount(candidate: Polygon[], maxPolygonCount?: number): boolean { + const limit = maxPolygonCount ?? this.requiredMaxPolygonCount; + return limit === undefined || candidate.length <= limit; + } + + private commit(candidate: Polygon[], cost: number, seam?: SeamOverlapDiagnostics): void { + this.best = candidate; + this.bestCost = cost; + this.bestDiagnostics = { polygons: candidate, seam }; + } + + private resetBestDiagnostics(seam?: SeamOverlapDiagnostics): void { + this.bestDiagnostics = { polygons: this.best, seam }; + } + + private bestSeamDiagnostics(): SeamOverlapDiagnostics { + if (this.bestDiagnostics.polygons !== this.best) this.resetBestDiagnostics(); + if (!this.bestDiagnostics.seam) { + this.bestDiagnostics.seam = seamOverlapSafetyDiagnostics(this.best); + } + return this.bestDiagnostics.seam; + } + + private bestTopologyEdges(): TopologyEdgeStats { + if (this.bestDiagnostics.polygons !== this.best) this.resetBestDiagnostics(); + if (!this.bestDiagnostics.topologyEdges) { + this.bestDiagnostics.topologyEdges = collectTopologyEdgeStats(this.best); + } + return this.bestDiagnostics.topologyEdges; + } + + private bestTopologySelfDiagnostics(): TopologyGapDiagnostics { + if (this.bestDiagnostics.polygons !== this.best) this.resetBestDiagnostics(); + if (!this.bestDiagnostics.topologySelf) { + this.bestDiagnostics.topologySelf = topologySelfDiagnostics(this.bestTopologyEdges(), TOPOLOGY_GAP_TOLERANCE); + } + return this.bestDiagnostics.topologySelf; + } } function polygonRenderCost(polygons: Polygon[]): number { @@ -388,61 +949,79 @@ function topologyGapDiagnosticsWorse( referenceDiagnostics: TopologyGapDiagnostics, candidate: Polygon[], ): boolean { - const candidateDiagnostics = topologyGapDiagnostics( - referenceEdges, - collectTopologyEdgeStats(candidate), + const candidateEdges = collectTopologyEdgeStats(candidate); + if (topologyExposesReferenceInternalEdge(referenceEdges, candidateEdges, TOPOLOGY_GAP_TOLERANCE)) return true; + return boundaryTJunctionDiagnosticsWorse( + candidateEdges.boundarySegments, TOPOLOGY_GAP_TOLERANCE, + referenceDiagnostics, ); - if (candidateDiagnostics.exactInternalEdges > 0 || candidateDiagnostics.nearInternalEdges > 0) { - return true; - } - return candidateDiagnostics.tJunctionPairs > referenceDiagnostics.tJunctionPairs || - candidateDiagnostics.tJunctionLength > referenceDiagnostics.tJunctionLength + 1e-9; } -function topologyGapDiagnostics( +function topologyExposesReferenceInternalEdge( referenceEdges: TopologyEdgeStats, candidateEdges: TopologyEdgeStats, tolerance: number, -): TopologyGapDiagnostics { - const internalIndex = buildTopologySegmentIndex(referenceEdges.internalSegments, tolerance); - const diagnostics: TopologyGapDiagnostics = { - exactInternalEdges: 0, - nearInternalEdges: 0, - exposedInternalLength: 0, - maxInternalOffset: 0, - tJunctionPairs: 0, - tJunctionLength: 0, - excessBoundaryLength: Math.max(0, candidateEdges.boundaryLength - referenceEdges.boundaryLength), - }; +): boolean { + if (referenceEdges.internalSegments.length === 0) return false; + const internalIndex = topologyInternalSegmentIndex(referenceEdges, tolerance); for (const segment of candidateEdges.boundarySegments) { if (referenceEdges.boundaryKeys.has(segment.key)) continue; - if (referenceEdges.internalKeys.has(segment.key)) { - diagnostics.exactInternalEdges += 1; - diagnostics.exposedInternalLength += segment.length; - continue; - } - const overlap = findOverlappingSegment(segment, internalIndex, tolerance); - if (!overlap) continue; - diagnostics.nearInternalEdges += 1; - diagnostics.exposedInternalLength += overlap.overlapLength; - diagnostics.maxInternalOffset = Math.max(diagnostics.maxInternalOffset, overlap.offset); + if (referenceEdges.internalKeys.has(segment.key)) return true; + if (hasOverlappingSegment(segment, internalIndex, tolerance)) return true; } - addBoundaryTJunctionDiagnostics(diagnostics, candidateEdges.boundarySegments, tolerance); - return diagnostics; + return false; +} + +function topologyInternalSegmentIndex( + edges: TopologyEdgeStats, + tolerance: number, +): TopologySegmentIndex { + if (!edges.internalIndex || edges.internalIndexTolerance !== tolerance) { + edges.internalIndex = buildTopologySegmentIndex(edges.internalSegments, tolerance); + edges.internalIndexTolerance = tolerance; + } + return edges.internalIndex; +} + +function boundaryTJunctionDiagnosticsWorse( + boundarySegments: TopologySegment[], + tolerance: number, + referenceDiagnostics: TopologyGapDiagnostics, +): boolean { + const boundaryIndex = buildTopologySegmentIndex(boundarySegments, tolerance); + const seenPairs = new Set(); + let pairStride = 0; + let tJunctionPairs = 0; + let tJunctionLength = 0; + for (const segment of boundarySegments) pairStride = Math.max(pairStride, segment.index + 1); + for (const segment of boundarySegments) { + for (const other of overlappingSegmentCandidates(segment, boundaryIndex, tolerance)) { + if (other === segment || other.polygon === segment.polygon || other.key === segment.key) continue; + const pairKey = segment.index < other.index + ? segment.index * pairStride + other.index + : other.index * pairStride + segment.index; + if (seenPairs.has(pairKey)) continue; + seenPairs.add(pairKey); + const overlap = segmentOverlap(segment, other, tolerance); + if (!overlap) continue; + tJunctionPairs += 1; + tJunctionLength += overlap.overlapLength; + if ( + tJunctionPairs > referenceDiagnostics.tJunctionPairs || + tJunctionLength > referenceDiagnostics.tJunctionLength + 1e-9 + ) return true; + } + } + return false; } function topologySelfDiagnostics(edges: TopologyEdgeStats, tolerance: number): TopologyGapDiagnostics { const diagnostics: TopologyGapDiagnostics = { - exactInternalEdges: 0, - nearInternalEdges: 0, - exposedInternalLength: 0, - maxInternalOffset: 0, tJunctionPairs: 0, tJunctionLength: 0, - excessBoundaryLength: 0, }; addBoundaryTJunctionDiagnostics(diagnostics, edges.boundarySegments, tolerance); return diagnostics; @@ -538,7 +1117,7 @@ function topologySegment( }; } -function buildTopologySegmentIndex(segments: TopologySegment[], tolerance: number) { +function buildTopologySegmentIndex(segments: TopologySegment[], tolerance: number): TopologySegmentIndex { const cellSize = Math.max(tolerance * 8, 0.5); const cells = new Map(); for (const segment of segments) { @@ -612,23 +1191,16 @@ function overlappingSegmentCandidates( return out; } -function findOverlappingSegment( +function hasOverlappingSegment( segment: TopologySegment, index: ReturnType, tolerance: number, -): SegmentOverlapInfo | null { - let best: SegmentOverlapInfo | null = null; +): boolean { for (const candidate of overlappingSegmentCandidates(segment, index, tolerance)) { const overlap = segmentOverlap(segment, candidate, tolerance); - if (!overlap) continue; - if (!best || - overlap.overlapLength > best.overlapLength || - (overlap.overlapLength === best.overlapLength && overlap.offset < best.offset) - ) { - best = overlap; - } + if (overlap) return true; } - return best; + return false; } function segmentOverlap( @@ -679,6 +1251,46 @@ function aggressiveLossyMinRenderCostGain(sourcePolygonCount: number): number { ); } +function staticSimplificationEarlyStopTarget( + count: number, + options: OptimizeStaticSimplificationOptions, +): number { + const ratio = Number.isFinite(options.earlyStopDropRatio) + ? Math.max(0, options.earlyStopDropRatio!) + : DEFAULT_STATIC_SIMPLIFY_EARLY_STOP_DROP_RATIO; + return Math.max(0, count - Math.max(1, Math.ceil(count * ratio))); +} + +function hasSourceVertexKeys(polygons: Polygon[]): boolean { + return polygons.some((polygon) => polygon.simplifySourceVertexKeys?.length === polygon.vertices.length); +} + +function shouldTryStaticSimplification(polygons: Polygon[], baselineOptimized: Polygon[]): boolean { + return polygons.length === 0 || + baselineOptimized.length / polygons.length > MIN_STATIC_SIMPLIFY_BASELINE_SOURCE_RATIO; +} + +function simplifyTriangleMeshCandidate( + source: Polygon[], + options: OptimizeStaticSimplificationOptions, + vertexKeyMode?: "relaxed" | "source", +): Polygon[] | null { + const candidate = simplifyTriangleMeshPolygons(source, { + ...options.simplifyTriangleMeshOptions, + ...(vertexKeyMode ? { vertexKeyMode } : {}), + }); + if (candidate === source || candidate.length >= source.length) return null; + return candidate; +} + +function resolveStaticSimplificationPlan( + plan: StaticSimplificationPlan, + options: OptimizeStaticSimplificationOptions, +): Polygon[] | null { + if (plan.precomputed !== undefined) return plan.precomputed; + return simplifyTriangleMeshCandidate(plan.source, options, plan.vertexKeyMode); +} + function applyRectCoverCandidate( polygons: Polygon[], setting: OptimizeMeshPolygonsOptions["rectCover"], @@ -817,6 +1429,7 @@ function dedupedPolygonsForMerge(polygons: Polygon[], cache?: PreprocessCache): } function interiorPolygonsForMerge(polygons: Polygon[], cache?: PreprocessCache): Polygon[] { + if (cache?.skipInteriorCull) return polygons; if (cache?.interior) return cache.interior; let filter = cache?.interiorIndices; if (filter === undefined) { @@ -849,10 +1462,24 @@ function preprocessModelPolygons( : resolveNormalizeOptions(normalizeGeometry); if (options.isolatedPairs) { const paired = mergeIsolatedTrianglePairs(snappedInteriorPolygonsForMerge(deduped, cache), options, cache); - const mergedPaired = mergePolygons(paired); + let mergedPaired = mergePolygons(paired); + if ( + cache?.snappedInteriorUsesBaselineFilter && + shouldRecheckSnappedInteriorCull(baseline, mergedPaired) + ) { + const exactPaired = mergeIsolatedTrianglePairs( + snappedInteriorPolygonsForMerge(deduped, cache, true), + options, + cache, + ); + const exactMergedPaired = mergePolygons(exactPaired); + if (exactMergedPaired.length < mergedPaired.length) mergedPaired = exactMergedPaired; + } return mergedPaired.length < baseline.length ? mergedPaired : baseline; } - const normalized = mergePolygons(cullInteriorPolygons(normalizeGeometryForMerge(deduped, options, cache))); + const normalizedGeometry = normalizeGeometryForMerge(deduped, options, cache); + const normalizedInterior = cullInteriorPolygons(normalizedGeometry); + const normalized = mergePolygons(normalizedInterior); return normalized.length < baseline.length ? normalized : baseline; } @@ -862,14 +1489,48 @@ function snappedPolygonsForMerge(polygons: Polygon[], cache?: PreprocessCache): return cache.snapped; } -function snappedInteriorPolygonsForMerge(polygons: Polygon[], cache?: PreprocessCache): Polygon[] { +function snappedInteriorPolygonsForMerge( + polygons: Polygon[], + cache?: PreprocessCache, + exactCull = false, +): Polygon[] { if (!cache) return cullInteriorPolygons(snapGeometryForMerge(polygons)); + if (exactCull && cache.snappedInteriorExact) return cache.snappedInteriorExact; + if (!exactCull && cache.snappedInterior) return cache.snappedInterior; + + const snapped = snappedPolygonsForMerge(polygons, cache); + if (exactCull) { + if (snapped === polygons && cache.deduped === polygons && cache.interior) { + cache.snappedInteriorExactIndices = cache.interiorIndices; + cache.snappedInteriorExact = cache.interior; + return cache.snappedInteriorExact; + } + const kept = cullInteriorPolygons(snapped); + cache.snappedInteriorExactIndices = keptIndexFilter(snapped, kept); + cache.snappedInteriorExact = kept; + return cache.snappedInteriorExact; + } + if (!cache.snappedInterior) { - const snapped = snappedPolygonsForMerge(polygons, cache); + if (snapped === polygons && cache.deduped === polygons && cache.interior) { + cache.snappedInteriorIndices = cache.interiorIndices; + cache.snappedInterior = cache.interior; + cache.snappedInteriorUsesBaselineFilter = false; + return cache.snappedInterior; + } + if (cache.reuseSnappedInteriorCull !== false && cache.interiorIndices !== undefined) { + cache.snappedInteriorIndices = cache.interiorIndices; + cache.snappedInterior = applyIndexFilter(snapped, cache.snappedInteriorIndices); + cache.snappedInteriorUsesBaselineFilter = true; + return cache.snappedInterior; + } if (cache.snappedInteriorIndices === undefined) { const kept = cullInteriorPolygons(snapped); cache.snappedInteriorIndices = keptIndexFilter(snapped, kept); cache.snappedInterior = kept; + cache.snappedInteriorExactIndices = cache.snappedInteriorIndices; + cache.snappedInteriorExact = kept; + cache.snappedInteriorUsesBaselineFilter = false; } else { cache.snappedInterior = applyIndexFilter(snapped, cache.snappedInteriorIndices); } @@ -877,6 +1538,10 @@ function snappedInteriorPolygonsForMerge(polygons: Polygon[], cache?: Preprocess return cache.snappedInterior; } +function shouldRecheckSnappedInteriorCull(baseline: Polygon[], candidate: Polygon[]): boolean { + return candidate.length >= baseline.length; +} + function resolveNormalizeOptions(options: LossyApproximateOptions): ResolvedGeometryNormalizeOptions { return { maxAngleDeg: options.maxAngleDeg ?? DEFAULT_NORMALIZE_OPTIONS.maxAngleDeg, @@ -1423,20 +2088,32 @@ function snapGeometryForMerge(polygons: Polygon[]): Polygon[] { const vertices = createVec3Snapper(geometryEpsilon); const uvs = createVec2Snapper(uvEpsilon); + let anyChanged = false; - return polygons.map((polygon) => { - const snapVertex = (vertex: Vec3): Vec3 => vertices.snap(vertex); + const snapped = polygons.map((polygon) => { + let changed = false; + const snapVertex = (vertex: Vec3): Vec3 => { + const next = vertices.snap(vertex); + if (!eqVec(next, vertex)) changed = true; + return next; + }; const snappedVertices = polygon.vertices.map(snapVertex); const snappedUvs = polygon.uvs && polygon.uvs.length === polygon.vertices.length - ? polygon.uvs.map((uv) => uvs.snap(uv)) + ? polygon.uvs.map((uv) => { + const next = uvs.snap(uv); + if (!eqUv(next, uv)) changed = true; + return next; + }) : undefined; const snappedTextureTriangles = mapTextureTriangleVertices(polygon.textureTriangles, snapVertex); + if (!changed && !polygon.texture) return polygon; const snappedPolygon: Polygon = { ...polygon, vertices: snappedVertices, ...(snappedUvs ? { uvs: snappedUvs } : {}), ...(snappedTextureTriangles ? { textureTriangles: snappedTextureTriangles } : {}), }; + anyChanged = true; return { ...snappedPolygon, ...(snappedPolygon.texture @@ -1444,6 +2121,7 @@ function snapGeometryForMerge(polygons: Polygon[]): Polygon[] { : {}), }; }); + return anyChanged ? snapped : polygons; } function textureTrianglesForPolygon(polygon: Polygon): TextureTriangle[] | undefined { diff --git a/packages/core/src/merge/seamRepair.ts b/packages/core/src/merge/seamRepair.ts index 76d06b57..a552df7c 100644 --- a/packages/core/src/merge/seamRepair.ts +++ b/packages/core/src/merge/seamRepair.ts @@ -460,10 +460,11 @@ function buildSeamEdgeAmounts( polygons: Polygon[], options: Overlap, collectCandidates = false, + diagnosticsOnly = false, ) { const metas = buildPolygonMetas(polygons); const records = buildEdgeRecords(polygons, metas, options.capacityScale); - const repairs = polygons.map(() => new Map()); + const repairs = diagnosticsOnly ? [] : polygons.map(() => new Map()); const diagnostics = emptyDiagnostics(); const candidates = collectCandidates ? [] as OverlapCandidate[] : undefined; if (records.length === 0) { @@ -485,7 +486,7 @@ function buildSeamEdgeAmounts( buildNearSeamEdgeAmounts(records, edgeOwners, repairs, diagnostics, options, candidates); return { edgeRepairs: repairs, - diagnostics: finishDiagnostics(repairs, diagnostics), + diagnostics: diagnosticsOnly ? diagnostics : finishDiagnostics(repairs, diagnostics), candidates, }; } @@ -545,8 +546,12 @@ function buildNearSeamEdgeAmounts( } candidates?.push(seamOverlapCandidate(kind, record, candidate, info, targetClosure, closureA + closureB)); - if (closureA > EPS) addEdgeRepair(repairs, record, info.aStart, info.aEnd, closureA / info.facingA); - if (closureB > EPS) addEdgeRepair(repairs, candidate, info.bStart, info.bEnd, closureB / info.facingB); + if (repairs.length > 0 && closureA > EPS) { + addEdgeRepair(repairs, record, info.aStart, info.aEnd, closureA / info.facingA); + } + if (repairs.length > 0 && closureB > EPS) { + addEdgeRepair(repairs, candidate, info.bStart, info.bEnd, closureB / info.facingB); + } diagnostics.nearPairs += 1; diagnostics.maxMeasuredGapPx = Math.max(diagnostics.maxMeasuredGapPx, info.gap); if (remainingClosure > MIN_RESIDUAL_PATCH_GAP_PX) { @@ -1562,6 +1567,15 @@ export function seamOverlapDiagnostics( return buildSeamEdgeAmounts(polygons, resolved).diagnostics; } +export function seamOverlapSafetyDiagnostics( + polygons: Polygon[], + options?: number | OverlapOptions, +): OverlapDiagnostics { + const resolved = resolveSeamOverlapOptions(options); + if (!seamOverlapEnabled(resolved, options) || polygons.length === 0) return emptyDiagnostics(); + return buildSeamEdgeAmounts(polygons, resolved, false, true).diagnostics; +} + export function seamOverlapReport( polygons: Polygon[], options?: number | OverlapOptions, diff --git a/packages/core/src/parser/optimizeMeshParseResult.ts b/packages/core/src/parser/optimizeMeshParseResult.ts index e3ce7c48..b87db0cc 100644 --- a/packages/core/src/parser/optimizeMeshParseResult.ts +++ b/packages/core/src/parser/optimizeMeshParseResult.ts @@ -1,9 +1,6 @@ import { parsePureColor } from "../color/color"; -import { optimizeMeshPolygons } from "../merge/optimizePolygons"; -import { - simplifyTriangleMeshPolygons, - type SimplifyTriangleMeshPolygonsOptions, -} from "../merge/simplifyTriangleMesh"; +import { optimizeParseMeshPolygons } from "../merge/optimizePolygons"; +import type { SimplifyTriangleMeshPolygonsOptions } from "../merge/simplifyTriangleMesh"; import type { MeshResolution, Polygon } from "../types"; import type { ParseResult } from "./types"; @@ -35,10 +32,10 @@ export interface OptimizeMeshParseResultOptions { const DEFAULT_BAKED_TEXTURE_COLOR_MERGE_DISTANCE = 36; const DEFAULT_SIMPLIFY_EARLY_STOP_DROP_RATIO = 0.15; +const CANDIDATE_FIRST_EARLY_STOP_DROP_RATIO = 0.2; +const CANDIDATE_FIRST_MIN_SOURCE_POLYGONS = 650; +const CANDIDATE_FIRST_MAX_SOURCE_POLYGONS = 15000; const MAX_STATIC_SIMPLIFY_TEXTURED_RATIO = 0.25; -const MIN_STATIC_SIMPLIFY_BASELINE_SOURCE_RATIO = 0.23; -const SOURCE_FIRST_MIN_BASELINE_POLYGONS = 2000; -const SOURCE_FIRST_MAX_RELAXED_RAW_DROP = 16; function hasTexturePaint(polygon: Polygon): boolean { return Boolean( @@ -193,96 +190,66 @@ function cleanupLossyBakedTextureColors( return changed ? { ...result, polygons } : result; } -function earlyStopTarget(count: number, options: OptimizeMeshParseResultOptions): number { - const ratio = Number.isFinite(options.simplifyEarlyStopDropRatio) - ? Math.max(0, options.simplifyEarlyStopDropRatio!) - : DEFAULT_SIMPLIFY_EARLY_STOP_DROP_RATIO; - return Math.max(0, count - Math.max(1, Math.ceil(count * ratio))); -} - -function hasSourceVertexKeys(polygons: Polygon[]): boolean { - return polygons.some((polygon) => polygon.simplifySourceVertexKeys?.length === polygon.vertices.length); -} - -function shouldTryStaticSimplification(polygons: Polygon[], baselineOptimized: Polygon[]): boolean { +function staticSimplificationTextureEligible(polygons: Polygon[]): boolean { let textured = 0; for (const polygon of polygons) { if (hasTexturePaint(polygon)) textured += 1; } - return polygons.length === 0 || ( - textured / polygons.length <= MAX_STATIC_SIMPLIFY_TEXTURED_RATIO && - baselineOptimized.length / polygons.length > MIN_STATIC_SIMPLIFY_BASELINE_SOURCE_RATIO - ); + return polygons.length === 0 || textured / polygons.length <= MAX_STATIC_SIMPLIFY_TEXTURED_RATIO; } -function simplifiedOptimizedPolygons( - polygons: Polygon[], - baselineOptimized: Polygon[], +function staticSimplificationEarlyStopDropRatio( options: OptimizeMeshParseResultOptions, -): Polygon[] | null { - const simplify = (vertexKeyMode?: "relaxed" | "source"): Polygon[] | null => { - const candidate = simplifyTriangleMeshPolygons(polygons, { - ...options.simplifyTriangleMeshOptions, - ...(vertexKeyMode ? { vertexKeyMode } : {}), - }); - if (candidate === polygons || candidate.length >= polygons.length) return null; - return candidate; - }; - - const optimize = (candidate: Polygon[]): Polygon[] => ( - optimizeMeshPolygons(candidate, { - meshResolution: "lossy", - stopAtPolygonCount: earlyStopTarget(baselineOptimized.length, options), - }) - ); - - const relaxed = simplify(); - if (!relaxed) return null; - - let sourceKeyed: Polygon[] | null | undefined; - let sourceOptimized: Polygon[] | null | undefined; - const hasSourceKeys = hasSourceVertexKeys(polygons); - const relaxedRawDrop = polygons.length - relaxed.length; - const shouldTrySourceKeys = ( - hasSourceKeys && - baselineOptimized.length >= SOURCE_FIRST_MIN_BASELINE_POLYGONS && - relaxedRawDrop <= SOURCE_FIRST_MAX_RELAXED_RAW_DROP + useCandidateFirst: boolean, +): number | undefined { + if (!useCandidateFirst) return options.simplifyEarlyStopDropRatio; + return Math.max( + options.simplifyEarlyStopDropRatio ?? DEFAULT_SIMPLIFY_EARLY_STOP_DROP_RATIO, + CANDIDATE_FIRST_EARLY_STOP_DROP_RATIO, ); - if (shouldTrySourceKeys) { - sourceKeyed = simplify("source"); - if (sourceKeyed) { - sourceOptimized = optimize(sourceKeyed); - if (sourceOptimized.length < baselineOptimized.length) return sourceOptimized; - } - } - - const relaxedOptimized = optimize(relaxed); - if (relaxedOptimized.length < baselineOptimized.length) return relaxedOptimized; - - sourceKeyed ??= shouldTrySourceKeys ? simplify("source") : null; - if (sourceKeyed) { - sourceOptimized ??= optimize(sourceKeyed); - if (sourceOptimized.length < baselineOptimized.length) return sourceOptimized; - } - return null; } export function optimizeMeshParseResult( result: ParseResult, options: OptimizeMeshParseResultOptions = {}, ): ParseResult { - if (result.voxelSource || result.animation) return result; - const meshResolution = options.meshResolution ?? "lossy"; - const cleaned = cleanupLossyBakedTextureColors(result, options, meshResolution); - const baselineOptimized = optimizeMeshPolygons(cleaned.polygons, { meshResolution }); - const shouldSimplify = ( - meshResolution === "lossy" && - options.simplifyTriangleMeshes !== false && - !cleaned.animation && - shouldTryStaticSimplification(cleaned.polygons, baselineOptimized) - ); - const polygons = shouldSimplify - ? simplifiedOptimizedPolygons(cleaned.polygons, baselineOptimized, options) ?? baselineOptimized - : baselineOptimized; - return polygons === cleaned.polygons ? cleaned : { ...cleaned, polygons }; + return new ParseOptimizationContext(result, options).optimize(); +} + +class ParseOptimizationContext { + private readonly result: ParseResult; + private readonly options: OptimizeMeshParseResultOptions; + private readonly meshResolution: MeshResolution; + + constructor(result: ParseResult, options: OptimizeMeshParseResultOptions) { + this.result = result; + this.options = options; + this.meshResolution = options.meshResolution ?? "lossy"; + } + + optimize(): ParseResult { + if (this.result.voxelSource || this.result.animation) return this.result; + const cleaned = cleanupLossyBakedTextureColors(this.result, this.options, this.meshResolution); + const canSimplify = this.canSimplify(cleaned.polygons); + const useCandidateFirst = canSimplify && + cleaned.polygons.length >= CANDIDATE_FIRST_MIN_SOURCE_POLYGONS && + cleaned.polygons.length <= CANDIDATE_FIRST_MAX_SOURCE_POLYGONS; + const polygons = optimizeParseMeshPolygons(cleaned.polygons, { + meshResolution: this.meshResolution, + useCandidateFirst, + staticSimplification: canSimplify + ? { + simplifyTriangleMeshOptions: this.options.simplifyTriangleMeshOptions, + earlyStopDropRatio: staticSimplificationEarlyStopDropRatio(this.options, useCandidateFirst), + } + : false, + }); + return polygons === cleaned.polygons ? cleaned : { ...cleaned, polygons }; + } + + private canSimplify(polygons: Polygon[]): boolean { + return this.meshResolution === "lossy" && + this.options.simplifyTriangleMeshes !== false && + staticSimplificationTextureEligible(polygons); + } } From 1680de5e19dc8c42165c667f5d5fdce134e17485 Mon Sep 17 00:00:00 2001 From: agustin-littlehat Date: Tue, 2 Jun 2026 13:07:02 -0300 Subject: [PATCH 5/7] fix(renderer): move corner-shape triangle paint to base CSS --- AGENTS.md | 2 +- packages/polycss/src/render/atlas/emit.ts | 4 +++ .../atlas/solidTrianglePrimitive.test.ts | 10 ++++--- .../src/render/atlas/stableTriangle.test.ts | 7 +++-- .../src/render/atlas/stableTriangle.ts | 17 +++++------ packages/polycss/src/render/polyDOM.test.ts | 10 ++++--- .../src/snapshot/exportPolySceneSnapshot.ts | 30 +++++++++++++++++++ packages/polycss/src/styles/styles.ts | 30 +++++++++++++++++++ packages/react/src/scene/atlas/index.test.tsx | 8 ++--- .../src/scene/atlas/solidTriangleStyle.ts | 28 ++++++----------- packages/react/src/styles/styles.ts | 30 +++++++++++++++++++ packages/vue/src/scene/atlas/index.test.ts | 8 ++--- .../vue/src/scene/atlas/solidTriangleStyle.ts | 28 ++++++----------- packages/vue/src/styles/styles.ts | 30 +++++++++++++++++++ 14 files changed, 175 insertions(+), 67 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 458f0372..9e7fa824 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -35,7 +35,7 @@ Voxel-shaped meshes are the exception to "all polygons stay mounted": meshes wit | `` | **Quads** | Axis-aligned rectangle, or untextured convex quad when the homography passes stability guards on non-Safari engines | `background: currentColor` on a fixed 64px rectangle; affine and projective quads normalize their `matrix3d` to that primitive, with tiny solid bleed on projective quads to overlap antialias seams. Safari-family browsers skip the projective quad path and fall through because transformed projective rectangles composite incorrectly there. | None | | `` | **Border-shape clipped solid** | Untextured non-rect not caught by the exact corner-shape solid path, on browsers with CSS `border-shape` (Chromium + `pointer:fine` + `hover:hover`) | `border-color: currentColor` on a fixed 16px border-shape primitive, clipped by `border-shape: polygon(...)`; polygon bbox scale and tiny solid bleed are folded into `matrix3d` | None | | `` | **Atlas slice** | Textured polygons, or untextured non-rect on browsers without `border-shape` | `background-image` slice of packed bitmap on an auto-budgeted fixed primitive (128px for desktop-class `textureQuality="auto"`, 64px for mobile-class `auto` and explicit numeric quality); atlas position/size and `matrix3d` scale are normalized to the slice, shared textured edges get low-alpha atlas pixels repaired during atlas generation, and solid fallbacks get same-color edge bleed to avoid dark alpha fringes | Bounding-rect area | -| `` | **Stable solid triangle / corner-shape solid** | Triangles on non-WebKit engines; or untextured non-triangle polygons whose normalized outline is exactly a rectangle with one or more beveled corners on browsers with CSS `corner-shape` | Triangles use a 32px box with two beveled top corners and `background: currentColor` when CSS `corner-shape` support is present, progressively falling back to the CSS border-color triangle trick. Firefox uses a 96px border-triangle primitive to avoid large-perspective compositor banding. Exact corner-shape solids use a bare fixed 32px box with inline per-corner radii + `corner-*-shape: bevel` and `background: currentColor`. Tiny solid bleed is folded into `matrix3d`. WebKit/Safari falls through to `` for border triangles because transformed CSS border triangles composite incorrectly there. | None | +| `` | **Stable solid triangle / corner-shape solid** | Triangles on non-WebKit engines; or untextured non-triangle polygons whose normalized outline is exactly a rectangle with one or more beveled corners on browsers with CSS `corner-shape` | Triangles use a 32px box with two beveled top corners and `background: currentColor` when CSS `corner-shape` support is present, progressively falling back to the CSS border-color triangle trick. Firefox uses a 96px border-triangle primitive to avoid large-perspective compositor banding. Exact corner-shape solids use a fixed 16px classed box with inline per-corner radii + `corner-*-shape: bevel` and `background: currentColor`. Tiny solid bleed is folded into `matrix3d`. WebKit/Safari falls through to `` for border triangles because transformed CSS border triangles composite incorrectly there. | None | 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/atlas/emit.ts b/packages/polycss/src/render/atlas/emit.ts index e21190e2..8dae2f83 100644 --- a/packages/polycss/src/render/atlas/emit.ts +++ b/packages/polycss/src/render/atlas/emit.ts @@ -33,6 +33,8 @@ import { DEFAULT_AMBIENT_INTENSITY, } from "@layoutit/polycss-core"; +const CORNER_SHAPE_SOLID_CLASS = "polycss-corner-shape-solid"; + export const ELEMENT_DATA_KEYS = new WeakMap(); const ELEMENT_DATA_VALUES = new WeakMap>(); @@ -266,6 +268,7 @@ export function createCornerShapeSolidElement( skipDynamicNormalVars = false, ): HTMLElement { const el = doc.createElement("u"); + el.className = CORNER_SHAPE_SOLID_CLASS; el.setAttribute( "style", formatCornerShapeElementStyle(entry, geometry) + @@ -335,6 +338,7 @@ export function updateCornerShapeElementWithStablePlan( solidPaintDefaults?: SolidPaintDefaults, ): void { el.style.visibility = ""; + el.className = CORNER_SHAPE_SOLID_CLASS; el.setAttribute("style", formatCornerShapeElementStyle(entry, geometry)); applySolidPaint(el, entry, textureLighting, solidPaintDefaults); setInlineStyleProperty(el, "background", "currentColor"); diff --git a/packages/polycss/src/render/atlas/solidTrianglePrimitive.test.ts b/packages/polycss/src/render/atlas/solidTrianglePrimitive.test.ts index 8143f59c..798637c6 100644 --- a/packages/polycss/src/render/atlas/solidTrianglePrimitive.test.ts +++ b/packages/polycss/src/render/atlas/solidTrianglePrimitive.test.ts @@ -70,13 +70,14 @@ const TRIANGLE_2: Polygon = { // --------------------------------------------------------------------------- describe("solid triangle primitive — corner-bevel vs border", () => { - it("corner-shape supported → corner paint is inline and classless", () => { + it("corner-shape supported → corner paint is classless and CSS-owned", () => { const doc = makeDoc({ cornerShape: true }); const result = renderPolygonsWithStableTriangles([TRIANGLE], { doc }); expect(result).not.toBeNull(); const element = result!.rendered[0].element; expect(element.className).toBe(""); - expect(element.style.getPropertyValue("corner-top-left-shape")).toBe("bevel"); + expect(element.style.getPropertyValue("corner-top-left-shape")).toBe(""); + expect(element.style.borderWidth).toBe(""); result!.dispose(); }); @@ -170,13 +171,14 @@ describe("solid triangle primitive — strategy disable interactions", () => { expect(result).toBeNull(); }); - it("multiple triangles: all get the same inline primitive consistently", () => { + it("multiple triangles: all use the CSS-owned primitive consistently", () => { const doc = makeDoc({ cornerShape: true }); const result = renderPolygonsWithStableTriangles([TRIANGLE, TRIANGLE_2], { doc }); expect(result).not.toBeNull(); for (const { element } of result!.rendered) { expect(element.className).toBe(""); - expect(element.style.getPropertyValue("corner-top-left-shape")).toBe("bevel"); + expect(element.style.getPropertyValue("corner-top-left-shape")).toBe(""); + expect(element.style.borderWidth).toBe(""); } result!.dispose(); }); diff --git a/packages/polycss/src/render/atlas/stableTriangle.test.ts b/packages/polycss/src/render/atlas/stableTriangle.test.ts index 4fe76e1e..1238f917 100644 --- a/packages/polycss/src/render/atlas/stableTriangle.test.ts +++ b/packages/polycss/src/render/atlas/stableTriangle.test.ts @@ -139,14 +139,15 @@ describe("renderPolygonsWithStableTriangles — initial render", () => { bleed.dispose(); }); - it("applies corner-shape triangle paint inline when supported", () => { + it("leaves corner-shape triangle paint to base CSS when supported", () => { const doc = makeDoc({ cornerShape: true }); const result = renderPolygonsWithStableTriangles([TRIANGLE_A], { doc }); expect(result).not.toBeNull(); const el = result!.rendered[0].element; expect(el.className).toBe(""); - expect(el.style.getPropertyValue("corner-top-left-shape")).toBe("bevel"); - expect(el.style.backgroundColor).toBe("currentcolor"); + expect(el.style.getPropertyValue("corner-top-left-shape")).toBe(""); + expect(el.style.backgroundColor).toBe(""); + expect(el.style.borderWidth).toBe(""); result!.dispose(); }); diff --git a/packages/polycss/src/render/atlas/stableTriangle.ts b/packages/polycss/src/render/atlas/stableTriangle.ts index 374b0db8..b7639582 100644 --- a/packages/polycss/src/render/atlas/stableTriangle.ts +++ b/packages/polycss/src/render/atlas/stableTriangle.ts @@ -2,7 +2,6 @@ import type { Polygon } from "@layoutit/polycss-core"; import { buildSeamBleedPolygonEdges, DEFAULT_TILE, - SOLID_TRIANGLE_CANONICAL_SIZE, DEFAULT_MATRIX_DECIMALS, BASIS_EPS, } from "@layoutit/polycss-core"; @@ -106,14 +105,14 @@ export function applySolidTrianglePrimitive( const triangleEl = el as SolidTriangleElement; if (triangleEl.__polycssSolidTrianglePrimitive === primitive) return; if (primitive === "corner-bevel") { - el.style.width = `${SOLID_TRIANGLE_CANONICAL_SIZE}px`; - el.style.height = `${SOLID_TRIANGLE_CANONICAL_SIZE}px`; - el.style.backgroundColor = "currentColor"; - el.style.borderWidth = "0"; - el.style.borderTopLeftRadius = "50% 100%"; - el.style.borderTopRightRadius = "50% 100%"; - el.style.setProperty("corner-top-left-shape", "bevel"); - el.style.setProperty("corner-top-right-shape", "bevel"); + el.style.width = ""; + el.style.height = ""; + el.style.backgroundColor = ""; + el.style.borderWidth = ""; + el.style.borderTopLeftRadius = ""; + el.style.borderTopRightRadius = ""; + el.style.removeProperty("corner-top-left-shape"); + el.style.removeProperty("corner-top-right-shape"); } else { el.style.width = ""; el.style.height = ""; diff --git a/packages/polycss/src/render/polyDOM.test.ts b/packages/polycss/src/render/polyDOM.test.ts index f3521a6d..a70c8bc1 100644 --- a/packages/polycss/src/render/polyDOM.test.ts +++ b/packages/polycss/src/render/polyDOM.test.ts @@ -791,13 +791,13 @@ describe("renderPolygonsWithTextureAtlas", () => { result.dispose(); }); - it("uses bare u corner-shape boxes for exact chamfered solids", () => { + it("uses u corner-shape boxes for exact chamfered solids", () => { const doc = supportDoc({ borderShape: true, cornerShape: true }); const chamfered = renderPolygonsWithTextureAtlas([CHAMFERED_SOLID], { doc }); const chamferedElement = chamfered.rendered[0].element; expect(chamferedElement.tagName.toLowerCase()).toBe("u"); - expect(chamferedElement.className).toBe(""); + expect(chamferedElement.className).toBe("polycss-corner-shape-solid"); expect(chamferedElement.getAttribute("style") ?? "").toMatch(/corner-[a-z-]+-shape:bevel/); expect(chamferedElement.style.getPropertyValue("border-shape")).toBe(""); chamfered.dispose(); @@ -1602,7 +1602,7 @@ describe("renderPolygonsWithTextureAtlas — strategies.disable", () => { result.dispose(); }); - it("paints solid triangles with inline corner-shape when supported", () => { + it("leaves solid triangle corner-shape paint to base CSS when supported", () => { const doc = { defaultView: { CSS: { @@ -1621,7 +1621,9 @@ describe("renderPolygonsWithTextureAtlas — strategies.disable", () => { const element = result.rendered[0].element; expect(element.tagName.toLowerCase()).toBe("u"); expect(element.className).toBe(""); - expect(element.style.getPropertyValue("corner-top-left-shape")).toBe("bevel"); + expect(element.style.getPropertyValue("corner-top-left-shape")).toBe(""); + expect(element.style.backgroundColor).toBe(""); + expect(element.style.borderWidth).toBe(""); result.dispose(); }); diff --git a/packages/polycss/src/snapshot/exportPolySceneSnapshot.ts b/packages/polycss/src/snapshot/exportPolySceneSnapshot.ts index e0d60516..e0edde38 100644 --- a/packages/polycss/src/snapshot/exportPolySceneSnapshot.ts +++ b/packages/polycss/src/snapshot/exportPolySceneSnapshot.ts @@ -429,6 +429,36 @@ ${selectorList(features.solidLeafTags)} { border-color: transparent transparent currentColor transparent; border-width: 0 16px 32px 16px; } + +@supports (corner-top-left-shape: bevel) and (corner-top-right-shape: bevel) { + .polycss-scene > u, + .polycss-mesh > u, + .polycss-bucket > u { + border-width: 0; + width: 32px; + height: 32px; + background-color: currentColor; + border-top-left-radius: 50% 100%; + border-top-right-radius: 50% 100%; + corner-top-left-shape: bevel; + corner-top-right-shape: bevel; + } + + .polycss-scene > u.polycss-corner-shape-solid, + .polycss-mesh > u.polycss-corner-shape-solid, + .polycss-bucket > u.polycss-corner-shape-solid { + width: 16px; + height: 16px; + box-sizing: border-box; + border: 0; + background: currentColor; + border-radius: 0; + corner-top-left-shape: initial; + corner-top-right-shape: initial; + corner-bottom-right-shape: initial; + corner-bottom-left-shape: initial; + } +} `); } diff --git a/packages/polycss/src/styles/styles.ts b/packages/polycss/src/styles/styles.ts index 2c8d8c0d..4f66e55f 100644 --- a/packages/polycss/src/styles/styles.ts +++ b/packages/polycss/src/styles/styles.ts @@ -155,6 +155,36 @@ const CORE_BASE_STYLES = ` border-width: 0 16px 32px 16px; } +@supports (corner-top-left-shape: bevel) and (corner-top-right-shape: bevel) { + .polycss-scene > u, + .polycss-mesh > u, + .polycss-bucket > u { + border-width: 0; + width: 32px; + height: 32px; + background-color: currentColor; + border-top-left-radius: 50% 100%; + border-top-right-radius: 50% 100%; + corner-top-left-shape: bevel; + corner-top-right-shape: bevel; + } + + .polycss-scene > u.polycss-corner-shape-solid, + .polycss-mesh > u.polycss-corner-shape-solid, + .polycss-bucket > u.polycss-corner-shape-solid { + width: 16px; + height: 16px; + box-sizing: border-box; + border: 0; + background: currentColor; + border-radius: 0; + corner-top-left-shape: initial; + corner-top-right-shape: initial; + corner-bottom-right-shape: initial; + corner-bottom-left-shape: initial; + } +} + /* Reserved internal shadow element rules. Current shadow emission uses SVG surfaces; these rules keep any retained markup styled as a plain border-shape leaf instead of inheriting UA quote styling. */ diff --git a/packages/react/src/scene/atlas/index.test.tsx b/packages/react/src/scene/atlas/index.test.tsx index ad84435e..dd43b06d 100644 --- a/packages/react/src/scene/atlas/index.test.tsx +++ b/packages/react/src/scene/atlas/index.test.tsx @@ -203,7 +203,7 @@ describe("isSolidTrianglePlan", () => { }); describe("updateStableTriangleDom", () => { - it("applies corner triangle paint inline when corner-shape is supported", () => { + it("leaves corner triangle paint to base CSS when corner-shape is supported", () => { stubCornerTriangleSupported(); const tri: Polygon = { vertices: [[0, 0, 0], [1, 0, 0], [0, 1, 0]], @@ -213,9 +213,9 @@ describe("updateStableTriangleDom", () => { const style = solidTriangleStyle(plan, "baked", "auto")!; - expect(style.borderWidth).toBe("0"); - expect(style.backgroundColor).toBe("currentColor"); - expect((style as Record).cornerTopLeftShape).toBe("bevel"); + expect(style.borderWidth).toBeUndefined(); + expect(style.backgroundColor).toBeUndefined(); + expect((style as Record).cornerTopLeftShape).toBeUndefined(); }); it("applies the large border triangle primitive on Firefox", () => { diff --git a/packages/react/src/scene/atlas/solidTriangleStyle.ts b/packages/react/src/scene/atlas/solidTriangleStyle.ts index d737c568..45ecc257 100644 --- a/packages/react/src/scene/atlas/solidTriangleStyle.ts +++ b/packages/react/src/scene/atlas/solidTriangleStyle.ts @@ -31,16 +31,6 @@ export const SOLID_TRIANGLE_BLEED = 0.75; const SOLID_TRIANGLE_CANONICAL_SIZE = 32; const SOLID_TRIANGLE_LARGE_BORDER_CANONICAL_SIZE = 96; const SOLID_TRIANGLE_LARGE_BORDER_WIDTH = "0 48px 96px 48px"; -const CORNER_TRIANGLE_STYLE = { - width: "32px", - height: "32px", - backgroundColor: "currentColor", - borderWidth: "0", - borderTopLeftRadius: "50% 100%", - borderTopRightRadius: "50% 100%", - cornerTopLeftShape: "bevel", - cornerTopRightShape: "bevel", -} as CSSProperties; let cachedSolidTriangleUserAgent: string | undefined; let cachedSolidTriangleCanonicalSize = SOLID_TRIANGLE_CANONICAL_SIZE; @@ -75,7 +65,7 @@ export function solidTriangleBorderWidth(): string | undefined { export function solidTrianglePaintStyle(): CSSProperties | undefined { const primitive = solidTrianglePrimitive(); - if (primitive === "corner-bevel") return CORNER_TRIANGLE_STYLE; + if (primitive === "corner-bevel") return undefined; const borderWidth = primitive === "border-large" ? SOLID_TRIANGLE_LARGE_BORDER_WIDTH : undefined; return borderWidth ? { borderWidth } : undefined; } @@ -83,14 +73,14 @@ export function solidTrianglePaintStyle(): CSSProperties | undefined { export function applySolidTrianglePaintStyle(el: HTMLElement): void { const primitive = solidTrianglePrimitive(); if (primitive === "corner-bevel") { - el.style.width = "32px"; - el.style.height = "32px"; - el.style.backgroundColor = "currentColor"; - el.style.borderWidth = "0"; - el.style.borderTopLeftRadius = "50% 100%"; - el.style.borderTopRightRadius = "50% 100%"; - el.style.setProperty("corner-top-left-shape", "bevel"); - el.style.setProperty("corner-top-right-shape", "bevel"); + el.style.width = ""; + el.style.height = ""; + el.style.backgroundColor = ""; + el.style.borderWidth = ""; + el.style.borderTopLeftRadius = ""; + el.style.borderTopRightRadius = ""; + el.style.removeProperty("corner-top-left-shape"); + el.style.removeProperty("corner-top-right-shape"); } else { el.style.width = ""; el.style.height = ""; diff --git a/packages/react/src/styles/styles.ts b/packages/react/src/styles/styles.ts index 5557ab40..964ac236 100644 --- a/packages/react/src/styles/styles.ts +++ b/packages/react/src/styles/styles.ts @@ -156,6 +156,36 @@ const CORE_BASE_STYLES = ` border-width: 0 16px 32px 16px; } +@supports (corner-top-left-shape: bevel) and (corner-top-right-shape: bevel) { + .polycss-scene > u, + .polycss-mesh > u, + .polycss-bucket > u { + border-width: 0; + width: 32px; + height: 32px; + background-color: currentColor; + border-top-left-radius: 50% 100%; + border-top-right-radius: 50% 100%; + corner-top-left-shape: bevel; + corner-top-right-shape: bevel; + } + + .polycss-scene > u.polycss-corner-shape-solid, + .polycss-mesh > u.polycss-corner-shape-solid, + .polycss-bucket > u.polycss-corner-shape-solid { + width: 16px; + height: 16px; + box-sizing: border-box; + border: 0; + background: currentColor; + border-radius: 0; + corner-top-left-shape: initial; + corner-top-right-shape: initial; + corner-bottom-right-shape: initial; + corner-bottom-left-shape: initial; + } +} + /* ── Gizmo override ─────────────────────────────────────────────────────── */ /* diff --git a/packages/vue/src/scene/atlas/index.test.ts b/packages/vue/src/scene/atlas/index.test.ts index 881a1673..87af7cf8 100644 --- a/packages/vue/src/scene/atlas/index.test.ts +++ b/packages/vue/src/scene/atlas/index.test.ts @@ -147,7 +147,7 @@ describe("isSolidTrianglePlan", () => { }); describe("updateStableTriangleDom", () => { - it("applies corner triangle paint inline when corner-shape is supported", () => { + it("leaves corner triangle paint to base CSS when corner-shape is supported", () => { stubCornerTriangleSupported(); const tri: Polygon = { vertices: [[0, 0, 0], [1, 0, 0], [0, 1, 0]], @@ -157,9 +157,9 @@ describe("updateStableTriangleDom", () => { const style = solidTriangleStyle(plan, "baked", "auto")!; - expect(style.borderWidth).toBe("0"); - expect(style.backgroundColor).toBe("currentColor"); - expect((style as Record)["corner-top-left-shape"]).toBe("bevel"); + expect(style.borderWidth).toBeUndefined(); + expect(style.backgroundColor).toBeUndefined(); + expect((style as Record)["corner-top-left-shape"]).toBeUndefined(); }); it("applies the large border triangle primitive on Firefox", () => { diff --git a/packages/vue/src/scene/atlas/solidTriangleStyle.ts b/packages/vue/src/scene/atlas/solidTriangleStyle.ts index b16eeed3..757c39eb 100644 --- a/packages/vue/src/scene/atlas/solidTriangleStyle.ts +++ b/packages/vue/src/scene/atlas/solidTriangleStyle.ts @@ -31,16 +31,6 @@ export const SOLID_TRIANGLE_BLEED = 0.75; const SOLID_TRIANGLE_CANONICAL_SIZE = 32; const SOLID_TRIANGLE_LARGE_BORDER_CANONICAL_SIZE = 96; const SOLID_TRIANGLE_LARGE_BORDER_WIDTH = "0 48px 96px 48px"; -const CORNER_TRIANGLE_STYLE = { - width: "32px", - height: "32px", - backgroundColor: "currentColor", - borderWidth: "0", - borderTopLeftRadius: "50% 100%", - borderTopRightRadius: "50% 100%", - "corner-top-left-shape": "bevel", - "corner-top-right-shape": "bevel", -} as CSSProperties; let cachedSolidTriangleUserAgent: string | undefined; let cachedSolidTriangleCanonicalSize = SOLID_TRIANGLE_CANONICAL_SIZE; @@ -75,7 +65,7 @@ export function solidTriangleBorderWidth(): string | undefined { export function solidTrianglePaintStyle(): CSSProperties | undefined { const primitive = solidTrianglePrimitive(); - if (primitive === "corner-bevel") return CORNER_TRIANGLE_STYLE; + if (primitive === "corner-bevel") return undefined; const borderWidth = primitive === "border-large" ? SOLID_TRIANGLE_LARGE_BORDER_WIDTH : undefined; return borderWidth ? { borderWidth } : undefined; } @@ -83,14 +73,14 @@ export function solidTrianglePaintStyle(): CSSProperties | undefined { export function applySolidTrianglePaintStyle(el: HTMLElement): void { const primitive = solidTrianglePrimitive(); if (primitive === "corner-bevel") { - el.style.width = "32px"; - el.style.height = "32px"; - el.style.backgroundColor = "currentColor"; - el.style.borderWidth = "0"; - el.style.borderTopLeftRadius = "50% 100%"; - el.style.borderTopRightRadius = "50% 100%"; - el.style.setProperty("corner-top-left-shape", "bevel"); - el.style.setProperty("corner-top-right-shape", "bevel"); + el.style.width = ""; + el.style.height = ""; + el.style.backgroundColor = ""; + el.style.borderWidth = ""; + el.style.borderTopLeftRadius = ""; + el.style.borderTopRightRadius = ""; + el.style.removeProperty("corner-top-left-shape"); + el.style.removeProperty("corner-top-right-shape"); } else { el.style.width = ""; el.style.height = ""; diff --git a/packages/vue/src/styles/styles.ts b/packages/vue/src/styles/styles.ts index a21e0062..f2611ba1 100644 --- a/packages/vue/src/styles/styles.ts +++ b/packages/vue/src/styles/styles.ts @@ -156,6 +156,36 @@ const CORE_BASE_STYLES = ` border-width: 0 16px 32px 16px; } +@supports (corner-top-left-shape: bevel) and (corner-top-right-shape: bevel) { + .polycss-scene > u, + .polycss-mesh > u, + .polycss-bucket > u { + border-width: 0; + width: 32px; + height: 32px; + background-color: currentColor; + border-top-left-radius: 50% 100%; + border-top-right-radius: 50% 100%; + corner-top-left-shape: bevel; + corner-top-right-shape: bevel; + } + + .polycss-scene > u.polycss-corner-shape-solid, + .polycss-mesh > u.polycss-corner-shape-solid, + .polycss-bucket > u.polycss-corner-shape-solid { + width: 16px; + height: 16px; + box-sizing: border-box; + border: 0; + background: currentColor; + border-radius: 0; + corner-top-left-shape: initial; + corner-top-right-shape: initial; + corner-bottom-right-shape: initial; + corner-bottom-left-shape: initial; + } +} + /* ── Gizmo override ─────────────────────────────────────────────────────── */ /* From 07187c9a6f9d4d1e08226d997b56083bc995f350 Mon Sep 17 00:00:00 2001 From: agustin-littlehat Date: Tue, 2 Jun 2026 13:07:11 -0300 Subject: [PATCH 6/7] fix(polycss): share mesh hit testing --- .../polycss/src/api/createPolyScene.test.ts | 14 ++++++++ packages/polycss/src/api/createPolyScene.ts | 13 ++++--- packages/polycss/src/api/createSelect.ts | 35 ++++--------------- .../src/api/createTransformControls.ts | 18 +--------- packages/polycss/src/api/meshHitTest.ts | 35 +++++++++++++++++++ 5 files changed, 66 insertions(+), 49 deletions(-) create mode 100644 packages/polycss/src/api/meshHitTest.ts diff --git a/packages/polycss/src/api/createPolyScene.test.ts b/packages/polycss/src/api/createPolyScene.test.ts index b1deb67a..52fa25d7 100644 --- a/packages/polycss/src/api/createPolyScene.test.ts +++ b/packages/polycss/src/api/createPolyScene.test.ts @@ -451,6 +451,20 @@ describe("createPolyScene", () => { expect(handle.polygons.length).toBe(2); }); + it("resolves polygon leaves back to their owning mesh", () => { + scene = makeScene(host); + const first = scene.add(makeParseResult([triangle()]), { id: "first", merge: false }); + const second = scene.add(makeParseResult([triangle("#00ff00")]), { id: "second", merge: false }); + const leaf = second.element.querySelector("i,b,s,u") as HTMLElement | null; + expect(leaf).not.toBeNull(); + expect(scene.findMeshByElement(leaf)).toBe(second); + expect(scene.findMeshByElement(first.element)).toBe(first); + + second.remove(); + expect(scene.findMeshByElement(leaf)).toBeNull(); + expect(scene.findMeshByElement(second.element)).toBeNull(); + }); + it("routes exact raw vox sources through the direct voxel renderer", () => { scene = makeScene(host); scene.add(makeVoxelExactParseResult(), { merge: false }); diff --git a/packages/polycss/src/api/createPolyScene.ts b/packages/polycss/src/api/createPolyScene.ts index f3e74d1f..47ff41a1 100644 --- a/packages/polycss/src/api/createPolyScene.ts +++ b/packages/polycss/src/api/createPolyScene.ts @@ -656,6 +656,7 @@ export function createPolyScene( bakedRotation: Vec3; } const meshes = new Set(); + const meshByElement = new WeakMap(); // Cached CSS-Z of the shadow ground plane. Set by `recomputeShadowGround` // and used by SVG shadow projection for the ground surface. In dynamic mode @@ -2609,6 +2610,7 @@ export function createPolyScene( // Removing from DOM doesn't auto-dispose generated atlas/blob URLs. clearRendered(entry); meshes.delete(entry); + meshByElement.delete(wrapper); clearReceiverShadowCache(entry); clearCasterItemsCache(entry); recomputeAutoCenter(); @@ -2831,6 +2833,7 @@ export function createPolyScene( clearRendered(entry); try { parseResult.dispose(); } catch { /* ignore */ } meshes.delete(entry); + meshByElement.delete(wrapper); recomputeAutoCenter(); recomputeShadowGround(); }, @@ -2854,6 +2857,7 @@ export function createPolyScene( }; entry.handle = handle; + meshByElement.set(wrapper, entry); meshes.add(entry); renderEntry(entry); applyMeshLightVarOverride(entry, transform.rotation); @@ -2948,11 +2952,12 @@ export function createPolyScene( function findMeshByElement(el: Element | null): PolyMeshHandle | null { let cur: Element | null = el; while (cur) { - if (cur instanceof HTMLElement && cur.classList.contains("polycss-mesh")) { - for (const entry of meshes) { - if (entry.wrapper === cur) return entry.handle; + if (cur instanceof HTMLElement) { + const entry = meshByElement.get(cur); + if (entry && !entry.disposed) return entry.handle; + if (cur.classList.contains("polycss-mesh")) { + return null; } - return null; } cur = cur.parentElement; } diff --git a/packages/polycss/src/api/createSelect.ts b/packages/polycss/src/api/createSelect.ts index dc3cbe85..ef8208bd 100644 --- a/packages/polycss/src/api/createSelect.ts +++ b/packages/polycss/src/api/createSelect.ts @@ -15,6 +15,7 @@ * select.destroy(); // remove listeners */ import type { PolyMeshHandle, PolySceneHandle } from "./createPolyScene"; +import { findMeshUnderPoint as findMeshUnderPointInMeshes } from "./meshHitTest"; export interface PolySelectOptions { /** Allow multiple meshes selected at once. Default false. */ @@ -51,29 +52,6 @@ export interface PolySelectionHandle { destroy(): void; } -/** Test whether `(clientX, clientY)` falls inside any polygon leaf - * child of `meshEl`'s post-3D bounding rect. */ -function pointInMeshElement( - meshEl: HTMLElement, - clientX: number, - clientY: number, -): boolean { - const polys = Array.from(meshEl.querySelectorAll("i,b,s,u")) as HTMLElement[]; - for (const p of polys) { - const r = p.getBoundingClientRect(); - if (r.width <= 0 || r.height <= 0) continue; - if ( - clientX >= r.left && - clientX <= r.right && - clientY >= r.top && - clientY <= r.bottom - ) { - return true; - } - } - return false; -} - export function createSelect( scene: PolySceneHandle, options: PolySelectOptions = {}, @@ -135,15 +113,16 @@ export function createSelect( } function findMeshUnderPoint(clientX: number, clientY: number): PolyMeshHandle | null { - for (const mesh of scene.meshes()) { + return findMeshUnderPointInMeshes( + scene.meshes(), + clientX, + clientY, // Skip gizmo meshes — they're managed by transform-controls and // shouldn't resolve as user-selectable content. The shared // `polycss-transform-gizmo` class is set on every gizmo mesh // (translate arrows + rotate rings). - if (mesh.element.classList.contains("polycss-transform-gizmo")) continue; - if (pointInMeshElement(mesh.element, clientX, clientY)) return mesh; - } - return null; + (mesh) => !mesh.element.classList.contains("polycss-transform-gizmo"), + ); } // Click delegation on the scene host. Matches the React equivalent diff --git a/packages/polycss/src/api/createTransformControls.ts b/packages/polycss/src/api/createTransformControls.ts index 4176188c..2ad302a2 100644 --- a/packages/polycss/src/api/createTransformControls.ts +++ b/packages/polycss/src/api/createTransformControls.ts @@ -28,6 +28,7 @@ import { } from "@layoutit/polycss-core"; import type { Polygon, Vec3 } from "@layoutit/polycss-core"; import type { PolyMeshHandle, PolySceneHandle } from "./createPolyScene"; +import { pointInMeshElement } from "./meshHitTest"; type Mode = "translate" | "rotate"; @@ -192,23 +193,6 @@ function gizmoLengthForMesh(polygons: Polygon[]): number { return extent * SCENE_TILE_SIZE * SHAFT_LENGTH_RATIO; } -function pointInMeshElement(meshEl: HTMLElement, clientX: number, clientY: number): boolean { - const polys = Array.from(meshEl.querySelectorAll("i,b,s,u")) as HTMLElement[]; - for (const p of polys) { - const r = p.getBoundingClientRect(); - if (r.width <= 0 || r.height <= 0) continue; - if ( - clientX >= r.left && - clientX <= r.right && - clientY >= r.top && - clientY <= r.bottom - ) { - return true; - } - } - return false; -} - export interface PolyTransformControlsObjectChangeEvent { object: PolyMeshHandle; position?: Vec3; diff --git a/packages/polycss/src/api/meshHitTest.ts b/packages/polycss/src/api/meshHitTest.ts new file mode 100644 index 00000000..7656658a --- /dev/null +++ b/packages/polycss/src/api/meshHitTest.ts @@ -0,0 +1,35 @@ +import type { PolyMeshHandle } from "./createPolyScene"; + +export function pointInMeshElement( + meshEl: HTMLElement, + clientX: number, + clientY: number, +): boolean { + const polys = Array.from(meshEl.querySelectorAll("i,b,s,u")) as HTMLElement[]; + for (const p of polys) { + const r = p.getBoundingClientRect(); + if (r.width <= 0 || r.height <= 0) continue; + if ( + clientX >= r.left && + clientX <= r.right && + clientY >= r.top && + clientY <= r.bottom + ) { + return true; + } + } + return false; +} + +export function findMeshUnderPoint( + meshes: Iterable, + clientX: number, + clientY: number, + filter?: (mesh: PolyMeshHandle) => boolean, +): PolyMeshHandle | null { + for (const mesh of meshes) { + if (filter && !filter(mesh)) continue; + if (pointInMeshElement(mesh.element, clientX, clientY)) return mesh; + } + return null; +} From c1cf00353c635c6593f2e04cbde49d036242716e Mon Sep 17 00:00:00 2001 From: agustin-littlehat Date: Tue, 2 Jun 2026 13:07:20 -0300 Subject: [PATCH 7/7] bench(renderer): add rect-cover optimizer variants --- bench/nonvoxel-vanilla.html | 31 ++++++++++++++++++++++++++++--- bench/nonvoxel-variants.mjs | 30 ++++++++++++++++++++++++++++++ 2 files changed, 58 insertions(+), 3 deletions(-) diff --git a/bench/nonvoxel-vanilla.html b/bench/nonvoxel-vanilla.html index 90c8d103..027e4e59 100644 --- a/bench/nonvoxel-vanilla.html +++ b/bench/nonvoxel-vanilla.html @@ -9,7 +9,7 @@