diff --git a/AGENTS.md b/AGENTS.md index 9b01a47b..5bef8b4e 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -53,7 +53,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 includes bounded seam repair.** The default `"lossy"` path merges compatible polygons, then repairs high-risk seams with targeted overlap and a small split budget. This can add a few triangles back when it prevents visible cracks; the goal is lower visible seam risk, not a strict guarantee that lossy always has fewer polygons than lossless. +- **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. - **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/bench/lossy-corpus-bench.mjs b/bench/lossy-corpus-bench.mjs index 2d4749c3..7f774693 100644 --- a/bench/lossy-corpus-bench.mjs +++ b/bench/lossy-corpus-bench.mjs @@ -311,6 +311,34 @@ function distanceVec(a, b) { return Math.hypot(a[0] - b[0], a[1] - b[1], a[2] - b[2]); } +function subVec(a, b) { + return [a[0] - b[0], a[1] - b[1], a[2] - b[2]]; +} + +function dotVec(a, b) { + return a[0] * b[0] + a[1] * b[1] + a[2] * b[2]; +} + +function makeSegment(key, a, b, index = -1) { + const delta = subVec(b, a); + const length = Math.hypot(delta[0], delta[1], delta[2]); + if (length <= 1e-10) return null; + return { + index, + key, + a, + b, + length, + dir: [delta[0] / length, delta[1] / length, delta[2] / length], + minX: Math.min(a[0], b[0]), + minY: Math.min(a[1], b[1]), + minZ: Math.min(a[2], b[2]), + maxX: Math.max(a[0], b[0]), + maxY: Math.max(a[1], b[1]), + maxZ: Math.max(a[2], b[2]), + }; +} + function collectEdgeStats(polygons) { const edges = new Map(); for (const polygon of polygons) { @@ -320,7 +348,10 @@ function collectEdgeStats(polygons) { const key = edgeKey(a, b); const current = edges.get(key); if (current) current.count += 1; - else edges.set(key, { count: 1, a, b }); + else { + const segment = makeSegment(key, a, b); + if (segment) edges.set(key, { count: 1, segment }); + } } } @@ -329,8 +360,10 @@ function collectEdgeStats(polygons) { const boundarySegments = []; const internalSegments = []; let boundaryLength = 0; + let index = 0; for (const [key, edge] of edges) { - const segment = { a: edge.a, b: edge.b }; + const segment = { ...edge.segment, index }; + index += 1; if (edge.count === 1) { boundaryKeys.add(key); boundarySegments.push(segment); @@ -376,43 +409,143 @@ function cellKey(x, y, z) { } function buildSegmentIndex(segments, tolerance) { - const cellSize = Math.max(tolerance * 2, 1e-6); + const cellSize = Math.max(tolerance * 8, 0.5); const cells = new Map(); for (const segment of segments) { - const [cx, cy, cz] = segmentCell(segment, cellSize); - const key = cellKey(cx, cy, cz); - const bucket = cells.get(key); - if (bucket) bucket.push(segment); - else cells.set(key, [segment]); + addSegmentToCells(cells, segment, cellSize, tolerance); } return { cellSize, cells }; } -function segmentEndpointGap(a, b) { - return Math.min( - Math.max(distanceVec(a.a, b.a), distanceVec(a.b, b.b)), - Math.max(distanceVec(a.a, b.b), distanceVec(a.b, b.a)), +function addSegmentToCells(cells, segment, cellSize, padding) { + const [minX, minY, minZ] = cellCoords( + [segment.minX - padding, segment.minY - padding, segment.minZ - padding], + cellSize, ); + const [maxX, maxY, maxZ] = cellCoords( + [segment.maxX + padding, segment.maxY + padding, segment.maxZ + padding], + cellSize, + ); + for (let x = minX; x <= maxX; x++) { + for (let y = minY; y <= maxY; y++) { + for (let z = minZ; z <= maxZ; z++) { + const key = cellKey(x, y, z); + const bucket = cells.get(key); + if (bucket) bucket.push(segment); + else cells.set(key, [segment]); + } + } + } } -function indexedInternalEdgeGap(segment, index, tolerance) { - const [cx, cy, cz] = segmentCell(segment, index.cellSize); - let best = null; - for (let dx = -1; dx <= 1; dx++) { - for (let dy = -1; dy <= 1; dy++) { - for (let dz = -1; dz <= 1; dz++) { - const bucket = index.cells.get(cellKey(cx + dx, cy + dy, cz + dz)); +function cellCoords(point, cellSize) { + return [ + Math.floor(point[0] / cellSize), + Math.floor(point[1] / cellSize), + Math.floor(point[2] / cellSize), + ]; +} + +function indexedSegmentCandidates(segment, index, tolerance) { + const out = []; + const seen = new Set(); + const [minX, minY, minZ] = cellCoords( + [segment.minX - tolerance, segment.minY - tolerance, segment.minZ - tolerance], + index.cellSize, + ); + const [maxX, maxY, maxZ] = cellCoords( + [segment.maxX + tolerance, segment.maxY + tolerance, segment.maxZ + tolerance], + index.cellSize, + ); + for (let x = minX; x <= maxX; x++) { + for (let y = minY; y <= maxY; y++) { + for (let z = minZ; z <= maxZ; z++) { + const bucket = index.cells.get(cellKey(x, y, z)); if (!bucket) continue; for (const candidate of bucket) { - const gap = segmentEndpointGap(segment, candidate); - if (gap <= tolerance) best = best === null ? gap : Math.min(best, gap); + if (seen.has(candidate)) continue; + seen.add(candidate); + out.push(candidate); } } } } + return out; +} + +function segmentBoundsOverlap(a, b, tolerance) { + return a.minX <= b.maxX + tolerance && + b.minX <= a.maxX + tolerance && + a.minY <= b.maxY + tolerance && + b.minY <= a.maxY + tolerance && + a.minZ <= b.maxZ + tolerance && + b.minZ <= a.maxZ + tolerance; +} + +function overlappingSegmentInfo(a, b, tolerance) { + if (!segmentBoundsOverlap(a, b, tolerance)) return null; + if (Math.abs(dotVec(a.dir, b.dir)) < 0.999) return null; + const bStart = dotVec(subVec(b.a, a.a), a.dir); + const bEnd = dotVec(subVec(b.b, a.a), a.dir); + const overlapStart = Math.max(0, Math.min(bStart, bEnd)); + const overlapEnd = Math.min(a.length, Math.max(bStart, bEnd)); + const overlapLength = overlapEnd - overlapStart; + if (overlapLength <= Math.max(1e-5, Math.min(a.length, b.length) * 1e-4)) return null; + + const midT = (overlapStart + overlapEnd) / 2; + const mid = [ + a.a[0] + a.dir[0] * midT, + a.a[1] + a.dir[1] * midT, + a.a[2] + a.dir[2] * midT, + ]; + const projected = Math.max(0, Math.min(b.length, dotVec(subVec(mid, b.a), b.dir))); + const closest = [ + b.a[0] + b.dir[0] * projected, + b.a[1] + b.dir[1] * projected, + b.a[2] + b.dir[2] * projected, + ]; + const gap = distanceVec(mid, closest); + return gap <= tolerance ? { gap, overlapLength } : null; +} + +function indexedInternalEdgeGap(segment, index, tolerance) { + let best = null; + for (const candidate of indexedSegmentCandidates(segment, index, tolerance)) { + const overlap = overlappingSegmentInfo(segment, candidate, tolerance); + if (!overlap) continue; + if ( + best === null || + overlap.overlapLength > best.overlapLength || + (overlap.overlapLength === best.overlapLength && overlap.gap < best.gap) + ) { + best = overlap; + } + } return best; } +function boundaryTJunctionMetrics(boundarySegments, tolerance) { + const index = buildSegmentIndex(boundarySegments, tolerance); + const seenPairs = new Set(); + let pairs = 0; + let length = 0; + for (const segment of boundarySegments) { + for (const candidate of indexedSegmentCandidates(segment, index, tolerance)) { + if (candidate === segment || candidate.key === segment.key) continue; + const pairKey = segment.index < candidate.index + ? `${segment.index}:${candidate.index}` + : `${candidate.index}:${segment.index}`; + if (seenPairs.has(pairKey)) continue; + seenPairs.add(pairKey); + const overlap = overlappingSegmentInfo(segment, candidate, tolerance); + if (!overlap) continue; + pairs += 1; + length += overlap.overlapLength; + } + } + return { pairs, length }; +} + function crackTolerances(polygons, maxBoundaryDisplacement = LOSSY_CRACK_DIAGNOSTIC_BOUNDARY) { const diagonal = modelDiagonal(polygons); const baseTolerance = diagonal > 0 ? Math.min(0.08, Math.max(0.001, diagonal * 0.001)) : 0; @@ -429,6 +562,8 @@ function crackMetrics(sourcePolygons, candidatePolygons, maxBoundaryDisplacement const metrics = { maxGap: 0, internalBoundaryLength: 0, + exactInternal: 0, + nearInternalLength: 0, excessBoundaryLength: Math.max(0, candidateEdges.boundaryLength - sourceEdges.boundaryLength), baseTolerance, tolerance, @@ -437,25 +572,33 @@ function crackMetrics(sourcePolygons, candidatePolygons, maxBoundaryDisplacement over04: 0, over08: 0, over12: 0, + tJunctionPairs: 0, + tJunctionLength: 0, }; for (const edge of candidateEdges.boundarySegments) { const key = edgeKey(edge.a, edge.b); if (sourceEdges.boundaryKeys.has(key)) continue; if (sourceEdges.internalKeys.has(key)) { + metrics.exactInternal += 1; metrics.nearInternal += 1; - metrics.internalBoundaryLength += distanceVec(edge.a, edge.b); + metrics.internalBoundaryLength += edge.length; continue; } - const gap = indexedInternalEdgeGap(edge, index, searchTolerance); - if (gap === null) continue; + const overlap = indexedInternalEdgeGap(edge, index, searchTolerance); + if (overlap === null) continue; metrics.nearInternal += 1; - metrics.maxGap = Math.max(metrics.maxGap, gap); - metrics.internalBoundaryLength += distanceVec(edge.a, edge.b); - if (gap > 0.04) metrics.over04 += 1; - if (gap > 0.08) metrics.over08 += 1; - if (gap > 0.12) metrics.over12 += 1; + metrics.maxGap = Math.max(metrics.maxGap, overlap.gap); + metrics.internalBoundaryLength += overlap.overlapLength; + metrics.nearInternalLength += overlap.overlapLength; + if (overlap.gap > 0.04) metrics.over04 += 1; + if (overlap.gap > 0.08) metrics.over08 += 1; + if (overlap.gap > 0.12) metrics.over12 += 1; } + + const tJunctions = boundaryTJunctionMetrics(candidateEdges.boundarySegments, searchTolerance); + metrics.tJunctionPairs = tJunctions.pairs; + metrics.tJunctionLength = tJunctions.length; return metrics; } @@ -463,6 +606,8 @@ function compactCrackMetrics(metrics) { return { maxGap: Number(metrics.maxGap.toFixed(6)), internalBoundaryLength: Number(metrics.internalBoundaryLength.toFixed(2)), + exactInternal: metrics.exactInternal, + nearInternalLength: Number(metrics.nearInternalLength.toFixed(2)), excessBoundaryLength: Number(metrics.excessBoundaryLength.toFixed(2)), baseTolerance: Number(metrics.baseTolerance.toFixed(6)), tolerance: Number(metrics.tolerance.toFixed(6)), @@ -471,6 +616,8 @@ function compactCrackMetrics(metrics) { over04: metrics.over04, over08: metrics.over08, over12: metrics.over12, + tJunctionPairs: metrics.tJunctionPairs, + tJunctionLength: Number(metrics.tJunctionLength.toFixed(2)), }; } @@ -478,17 +625,21 @@ function crackDelta(current, reference) { return { maxGap: Number((current.maxGap - reference.maxGap).toFixed(6)), internalBoundaryLength: Number((current.internalBoundaryLength - reference.internalBoundaryLength).toFixed(2)), + exactInternal: current.exactInternal - reference.exactInternal, + nearInternalLength: Number((current.nearInternalLength - reference.nearInternalLength).toFixed(2)), excessBoundaryLength: Number((current.excessBoundaryLength - reference.excessBoundaryLength).toFixed(2)), nearInternal: current.nearInternal - reference.nearInternal, over04: current.over04 - reference.over04, over08: current.over08 - reference.over08, over12: current.over12 - reference.over12, + tJunctionPairs: current.tJunctionPairs - reference.tJunctionPairs, + tJunctionLength: Number((current.tJunctionLength - reference.tJunctionLength).toFixed(2)), }; } -function crackReport(sourcePolygons, losslessPolygons, currentPolygons) { - const lossless = compactCrackMetrics(crackMetrics(sourcePolygons, losslessPolygons)); - const current = compactCrackMetrics(crackMetrics(sourcePolygons, currentPolygons)); +function crackReport(_sourcePolygons, losslessPolygons, currentPolygons) { + const lossless = compactCrackMetrics(crackMetrics(losslessPolygons, losslessPolygons)); + const current = compactCrackMetrics(crackMetrics(losslessPolygons, currentPolygons)); return { lossless, current, @@ -628,7 +779,7 @@ function summarizeModelInWorker(modelPath) { function summarizeRows(rows, errors, elapsedMs) { const byClass = {}; for (const row of rows) byClass[row.classification] = (byClass[row.classification] ?? 0) + 1; - const total = (field) => rows.reduce((sum, row) => sum + row[field].count, 0); + const total = (field) => rows.reduce((sum, row) => sum + (row[field]?.count ?? 0), 0); const crackRows = rows.filter((row) => row.cracks); return { scanned: rows.length, @@ -647,8 +798,13 @@ function summarizeRows(rows, errors, elapsedMs) { maxCurrentInternalBoundaryLength: Math.max(...crackRows.map((row) => row.cracks.current.internalBoundaryLength)), totalCurrentInternalBoundaryLength: Number(crackRows.reduce((sum, row) => sum + row.cracks.current.internalBoundaryLength, 0).toFixed(2)), totalInternalBoundaryDelta: Number(crackRows.reduce((sum, row) => sum + row.cracks.delta.internalBoundaryLength, 0).toFixed(2)), + totalExactInternalDelta: crackRows.reduce((sum, row) => sum + row.cracks.delta.exactInternal, 0), totalOver04Delta: crackRows.reduce((sum, row) => sum + row.cracks.delta.over04, 0), totalOver08Delta: crackRows.reduce((sum, row) => sum + row.cracks.delta.over08, 0), + totalTJunctionPairs: crackRows.reduce((sum, row) => sum + row.cracks.current.tJunctionPairs, 0), + totalTJunctionLength: Number(crackRows.reduce((sum, row) => sum + row.cracks.current.tJunctionLength, 0).toFixed(2)), + totalTJunctionPairDelta: crackRows.reduce((sum, row) => sum + row.cracks.delta.tJunctionPairs, 0), + totalTJunctionLengthDelta: Number(crackRows.reduce((sum, row) => sum + row.cracks.delta.tJunctionLength, 0).toFixed(2)), } : null, }; @@ -659,7 +815,7 @@ function readCorpusJson(path) { } function totalCost(rows, field) { - return Number(rows.reduce((sum, row) => sum + row[field].cost, 0).toFixed(2)); + return Number(rows.reduce((sum, row) => sum + (row[field]?.cost ?? 0), 0).toFixed(2)); } function totalTiming(rows, field) { @@ -673,7 +829,7 @@ function printCorpusSummary(output) { console.log(`classes=${JSON.stringify(summary.byClass)}`); console.log(`aggregate raw=${summary.aggregate.raw} lossless=${summary.aggregate.lossless} current=${summary.aggregate.current}`); if (summary.cracks) { - console.log(`cracks measured=${summary.cracks.measured} maxGap=${summary.cracks.maxCurrentGap} totalInternal=${summary.cracks.totalCurrentInternalBoundaryLength} deltaInternal=${summary.cracks.totalInternalBoundaryDelta} deltaOver04=${summary.cracks.totalOver04Delta} deltaOver08=${summary.cracks.totalOver08Delta}`); + console.log(`cracks measured=${summary.cracks.measured} maxGap=${summary.cracks.maxCurrentGap} totalInternal=${summary.cracks.totalCurrentInternalBoundaryLength} deltaInternal=${summary.cracks.totalInternalBoundaryDelta} deltaExactInternal=${summary.cracks.totalExactInternalDelta} deltaOver04=${summary.cracks.totalOver04Delta} deltaOver08=${summary.cracks.totalOver08Delta} tJunctionPairs=${summary.cracks.totalTJunctionPairs} tJunctionDelta=${summary.cracks.totalTJunctionPairDelta} tJunctionLengthDelta=${summary.cracks.totalTJunctionLengthDelta}`); } if (output.rows?.length) { console.log(`costs lossless=${totalCost(output.rows, "lossless")} current=${totalCost(output.rows, "current")}`); @@ -696,7 +852,7 @@ function printOpportunityReport(output, limit = PRINT_LIMIT) { console.log(""); console.log("largest current crack deltas"); for (const row of crackRows.slice(0, limit)) { - console.log(`${row.model}: maxGap=${row.cracks.current.maxGap} internalDelta=${row.cracks.delta.internalBoundaryLength} over04Delta=${row.cracks.delta.over04} current=${row.current.count} lossless=${row.lossless.count}`); + console.log(`${row.model}: maxGap=${row.cracks.current.maxGap} losslessInternalDelta=${row.cracks.delta.internalBoundaryLength} exactInternalDelta=${row.cracks.delta.exactInternal} tJunctionDelta=${row.cracks.delta.tJunctionPairs} over04Delta=${row.cracks.delta.over04} current=${row.current.count} lossless=${row.lossless.count}`); } } @@ -725,6 +881,18 @@ function printCompareReport(current, baseline, limit = PRINT_LIMIT) { currentMsDelta: row.timings?.currentMs !== undefined && before.timings?.currentMs !== undefined ? Number((row.timings.currentMs - before.timings.currentMs).toFixed(1)) : null, + internalBoundaryDelta: row.cracks && before.cracks + ? Number((row.cracks.current.internalBoundaryLength - before.cracks.current.internalBoundaryLength).toFixed(2)) + : null, + exactInternalDelta: row.cracks && before.cracks + ? row.cracks.current.exactInternal - before.cracks.current.exactInternal + : null, + tJunctionPairDelta: row.cracks && before.cracks + ? row.cracks.current.tJunctionPairs - before.cracks.current.tJunctionPairs + : null, + tJunctionLengthDelta: row.cracks && before.cracks + ? Number((row.cracks.current.tJunctionLength - before.cracks.current.tJunctionLength).toFixed(2)) + : null, beforeClass: before.classification, afterClass: row.classification, beforeCost: before.current.cost, @@ -735,13 +903,33 @@ function printCompareReport(current, baseline, limit = PRINT_LIMIT) { const totalCountDelta = deltas.reduce((sum, row) => sum + row.currentCountDelta, 0); const timingDeltas = deltas.filter((row) => row.currentMsDelta !== null); const totalMsDelta = Number(timingDeltas.reduce((sum, row) => sum + row.currentMsDelta, 0).toFixed(1)); + const gapDeltas = deltas.filter((row) => row.internalBoundaryDelta !== null); + const totalInternalBoundaryDelta = Number(gapDeltas.reduce((sum, row) => sum + row.internalBoundaryDelta, 0).toFixed(2)); + const totalExactInternalDelta = gapDeltas.reduce((sum, row) => sum + row.exactInternalDelta, 0); + const totalTJunctionPairDelta = gapDeltas.reduce((sum, row) => sum + row.tJunctionPairDelta, 0); + const totalTJunctionLengthDelta = Number(gapDeltas.reduce((sum, row) => sum + row.tJunctionLengthDelta, 0).toFixed(2)); console.log(""); console.log("compare report"); console.log(`matched=${deltas.length} currentCostDelta=${totalCostDelta} currentCountDelta=${totalCountDelta} currentMsDelta=${timingDeltas.length > 0 ? totalMsDelta : "n/a"}`); + if (gapDeltas.length > 0) { + console.log(`gapDeltas internalBoundary=${totalInternalBoundaryDelta} exactInternal=${totalExactInternalDelta} tJunctionPairs=${totalTJunctionPairDelta} tJunctionLength=${totalTJunctionLengthDelta}`); + } const improved = [...deltas].filter((row) => row.currentCostDelta < 0).sort((a, b) => a.currentCostDelta - b.currentCostDelta); const regressed = [...deltas].filter((row) => row.currentCostDelta > 0).sort((a, b) => b.currentCostDelta - a.currentCostDelta); const slower = [...timingDeltas].filter((row) => row.currentMsDelta > 0).sort((a, b) => b.currentMsDelta - a.currentMsDelta); + const gapRegressions = [...gapDeltas] + .filter((row) => + row.internalBoundaryDelta > 0 || + row.exactInternalDelta > 0 || + row.tJunctionPairDelta > 0 || + row.tJunctionLengthDelta > 0 + ) + .sort((a, b) => + b.internalBoundaryDelta - a.internalBoundaryDelta || + b.tJunctionPairDelta - a.tJunctionPairDelta || + b.exactInternalDelta - a.exactInternalDelta + ); const classChanges = deltas.filter((row) => row.beforeClass !== row.afterClass); const printRows = (title, rows, format) => { if (rows.length === 0) return; @@ -758,6 +946,9 @@ function printCompareReport(current, baseline, limit = PRINT_LIMIT) { printRows("largest currentMs increases", slower, (row) => `${row.model}: currentMsDelta=+${row.currentMsDelta} costDelta=${row.currentCostDelta}` ); + printRows("largest gap metric regressions", gapRegressions, (row) => + `${row.model}: internalBoundaryDelta=${row.internalBoundaryDelta} exactInternalDelta=${row.exactInternalDelta} tJunctionPairDelta=${row.tJunctionPairDelta} tJunctionLengthDelta=${row.tJunctionLengthDelta} costDelta=${row.currentCostDelta}` + ); printRows("classification changes", classChanges, (row) => `${row.model}: ${row.beforeClass}->${row.afterClass} costDelta=${row.currentCostDelta}` ); diff --git a/packages/core/src/animation/optimizeAnimatedMeshPolygons.test.ts b/packages/core/src/animation/optimizeAnimatedMeshPolygons.test.ts new file mode 100644 index 00000000..693d9751 --- /dev/null +++ b/packages/core/src/animation/optimizeAnimatedMeshPolygons.test.ts @@ -0,0 +1,147 @@ +import { describe, expect, it } from "vitest"; +import type { ParseAnimationController, ParseResult } from "../parser/types"; +import type { Polygon } from "../types"; +import { cullInteriorPolygons } from "../cull/cullInteriorPolygons"; +import { optimizeAnimatedMeshPolygons } from "./optimizeAnimatedMeshPolygons"; + +function axisQuad( + cx: number, + cy: number, + cz: number, + normalAxis: "x" | "y" | "z", + sign: 1 | -1, + size = 1, +): Polygon { + const h = size / 2; + const color = "#88aacc"; + if (normalAxis === "x") { + return sign > 0 + ? { + vertices: [ + [cx, cy - h, cz - h], + [cx, cy + h, cz - h], + [cx, cy + h, cz + h], + [cx, cy - h, cz + h], + ], + color, + } + : { + vertices: [ + [cx, cy - h, cz - h], + [cx, cy - h, cz + h], + [cx, cy + h, cz + h], + [cx, cy + h, cz - h], + ], + color, + }; + } + if (normalAxis === "y") { + return sign > 0 + ? { + vertices: [ + [cx - h, cy, cz - h], + [cx - h, cy, cz + h], + [cx + h, cy, cz + h], + [cx + h, cy, cz - h], + ], + color, + } + : { + vertices: [ + [cx - h, cy, cz - h], + [cx + h, cy, cz - h], + [cx + h, cy, cz + h], + [cx - h, cy, cz + h], + ], + color, + }; + } + return sign > 0 + ? { + vertices: [ + [cx - h, cy - h, cz], + [cx + h, cy - h, cz], + [cx + h, cy + h, cz], + [cx - h, cy + h, cz], + ], + color, + } + : { + vertices: [ + [cx - h, cy - h, cz], + [cx - h, cy + h, cz], + [cx + h, cy + h, cz], + [cx + h, cy - h, cz], + ], + color, + }; +} + +function cubeOutward(cx: number, cy: number, cz: number, size = 1): Polygon[] { + const h = size / 2; + return [ + axisQuad(cx + h, cy, cz, "x", 1, size), + axisQuad(cx - h, cy, cz, "x", -1, size), + axisQuad(cx, cy + h, cz, "y", 1, size), + axisQuad(cx, cy - h, cz, "y", -1, size), + axisQuad(cx, cy, cz + h, "z", 1, size), + axisQuad(cx, cy, cz - h, "z", -1, size), + ]; +} + +function translateFrame(polygons: Polygon[], x: number): Polygon[] { + return polygons.map((polygon) => ({ + ...polygon, + vertices: polygon.vertices.map((vertex): [number, number, number] => [ + vertex[0] + x, + vertex[1], + vertex[2], + ]), + })); +} + +function makeAnimatedParseResult(polygons: Polygon[]): ParseResult { + const clips = [{ index: 0, name: "run", duration: 1, channelCount: 1 }]; + const animation: ParseAnimationController = { + clips, + sample: (_clip, timeSeconds) => translateFrame(polygons, timeSeconds), + }; + return { + polygons, + animation, + objectUrls: [], + dispose() {}, + warnings: [], + metadata: { triangleCount: polygons.length, animations: clips }, + }; +} + +describe("optimizeAnimatedMeshPolygons", () => { + it("filters animated frames through the culled rest-pose plan", () => { + const outer = cubeOutward(0, 0, 0, 10); + const interior = axisQuad(0, 0, 0, "z", 1, 0.1); + const result = makeAnimatedParseResult([...outer, interior]); + const kept = cullInteriorPolygons(result.polygons); + const keptSet = new Set(kept); + const keptIndices = result.polygons.flatMap((polygon, index) => + keptSet.has(polygon) ? [index] : [] + ); + + const optimized = optimizeAnimatedMeshPolygons(result, { meshResolution: "lossy" }); + const running = optimized.animation?.clips.find((clip) => clip.name === "run"); + + expect(running).toBeDefined(); + expect(optimized.polygons.length).toBeLessThan(result.polygons.length); + expect(optimized.polygons).toHaveLength(keptIndices.length); + + for (const time of [0, 0.25, running!.duration / 2]) { + const fullFrame = result.animation!.sample(running!.name, time); + const frame = optimized.animation!.sample(running!.name, time); + expect(frame).toHaveLength(optimized.polygons.length); + for (let i = 0; i < frame.length; i += 1) { + expect(frame[i].vertices).toEqual(fullFrame[keptIndices[i]!]!.vertices); + } + } + }); + +}); diff --git a/packages/core/src/merge/optimizePolygons.test.ts b/packages/core/src/merge/optimizePolygons.test.ts index c18f12af..a13bd712 100644 --- a/packages/core/src/merge/optimizePolygons.test.ts +++ b/packages/core/src/merge/optimizePolygons.test.ts @@ -6,7 +6,7 @@ import type { Polygon, Vec3 } from "../types"; import { parseGltf } from "../parser/parseGltf"; import { parseObj } from "../parser/parseObj"; import { bakeSolidTextureSamples } from "../parser/solidTextureSamples"; -import { DEFAULT_SEAM_FACET_SPLIT_OPTIONS } from "./seamRepair"; +import { seamOverlapDiagnostics } from "./seamRepair"; import { optimizeMeshPolygons } from "./optimizePolygons"; function rect(x0: number, y0: number, x1: number, y1: number): Polygon[] { @@ -16,6 +16,24 @@ function rect(x0: number, y0: number, x1: number, y1: number): Polygon[] { ]; } +function shallowFoldedTrianglePairs(count: number): Polygon[] { + const polygons: Polygon[] = []; + for (let i = 0; i < count; i += 1) { + const x = i * 2; + polygons.push( + { + vertices: [[x, 0, 0], [x + 1, 0, 0], [x + 1, 1, 0]], + color: "#6688aa", + }, + { + vertices: [[x, 0, 0], [x + 1, 1, 0], [x, 1, 0.04]], + color: "#6688aa", + }, + ); + } + return polygons; +} + function polygonSignature(polygons: Polygon[]): string[] { return polygons.map((polygon) => `${polygon.color ?? ""}:${polygon.vertices.map((vertex) => vertex.join(",")).join(";")}` @@ -410,7 +428,7 @@ describe("optimizeMeshPolygons", () => { expect(renderCost(lossy)).toBeLessThanOrEqual(renderCost(lossless) + 1e-9); }); - it("keeps default lossy seam repair within the split budget over the exact lossless floor", async () => { + it("keeps default lossy solid-texture fixtures no more expensive than lossless", async () => { installSolidTextureEnv([10, 20, 30, 255]); for (const file of ["poly-pizza/arrow.glb", "poly-pizza/bucket.glb"]) { @@ -422,23 +440,79 @@ describe("optimizeMeshPolygons", () => { const lossless = optimizeMeshPolygons(baked.polygons, { meshResolution: "lossless" }); const lossy = optimizeMeshPolygons(baked.polygons, { meshResolution: "lossy" }); - expect(lossy.length, file).toBeLessThanOrEqual( - lossless.length + DEFAULT_SEAM_FACET_SPLIT_OPTIONS.budget, - ); + expect(renderCost(lossy), file).toBeLessThanOrEqual(renderCost(lossless) + 1e-9); } }); - it("keeps lossy seam repair tractable on long mechanical NASA geometry", () => { - const raw = parseGltf(loadGlbGalleryFile("nasa/opportunity.glb"), { targetSize: 60 }).polygons; + it("keeps lossy approximate wins on a long strip of shallow folds", () => { + const raw = shallowFoldedTrianglePairs(80); const lossless = optimizeMeshPolygons(raw, { meshResolution: "lossless" }); const lossy = optimizeMeshPolygons(raw, { meshResolution: "lossy" }); - expect(lossy.length).toBeLessThanOrEqual( - lossless.length + DEFAULT_SEAM_FACET_SPLIT_OPTIONS.budget, - ); + expect(lossy.length).toBeLessThan(lossless.length); + expect(renderCost(lossy)).toBeLessThan(renderCost(lossless)); + }); + + it("keeps default lossy cheaper than lossless on the Large Building fixture", () => { + const raw = parseGltf(loadGlbGalleryFile("city/Large Building.glb"), { targetSize: 60 }).polygons; + + const lossless = optimizeMeshPolygons(raw, { meshResolution: "lossless" }); + const lossy = optimizeMeshPolygons(raw, { meshResolution: "lossy" }); + + expect(lossy.length).toBeLessThanOrEqual(lossless.length); + expect(renderCost(lossy)).toBeLessThanOrEqual(renderCost(lossless)); + }, 10_000); + + it("uses the gated aggressive approximate pass without adding unclosed seams", () => { + const raw = parseGltf(loadGlbGalleryFile("poly-pizza/animated-shark.glb"), { targetSize: 60 }).polygons; + + const lossy = optimizeMeshPolygons(raw, { meshResolution: "lossy" }); + const seamDiagnostics = seamOverlapDiagnostics(lossy); + + expect(lossy).toHaveLength(634); + expect(seamDiagnostics.unclosedPairs).toBe(0); + expect(seamDiagnostics.maxResidualGapPx).toBe(0); + }); + + it("rejects large-model rect-cover candidates when topology gap diagnostics are not clean", () => { + const raw = parseGltf(loadGlbGalleryFile("nasa/opportunity.glb"), { targetSize: 60 }).polygons; + + const lossless = optimizeMeshPolygons(raw, { meshResolution: "lossless" }); + const lossy = optimizeMeshPolygons(raw, { meshResolution: "lossy" }); + const losslessSeams = seamOverlapDiagnostics(lossless); + const lossySeams = seamOverlapDiagnostics(lossy); + + expect(lossless).toHaveLength(1895); + expect(lossy).toHaveLength(1667); + expect(renderCost(lossy)).toBeLessThanOrEqual(renderCost(lossless)); + expect(lossySeams.unclosedPairs).toBeLessThanOrEqual(losslessSeams.unclosedPairs); + expect(lossySeams.maxResidualGapPx).toBeLessThanOrEqual(losslessSeams.maxResidualGapPx); }, 10_000); + it("keeps small seam-risk fixtures seam-safe under gated lossy candidates", () => { + const rock = parseGltf(loadGlbGalleryFile("poly-pizza/rock.glb"), { targetSize: 60 }).polygons; + const hauntedHouse = parseObj( + loadObjGalleryFile("opengameart/haunted-house/hauntedhouse.obj"), + { targetSize: 60 }, + ).polygons; + const chest = parseObj(loadObjGalleryFile("quaternius/dungeon/Chest_gold.obj"), { + targetSize: 60, + }).polygons; + + expect(optimizeMeshPolygons(rock, { meshResolution: "lossy" })).toHaveLength(58); + expect(optimizeMeshPolygons(hauntedHouse, { meshResolution: "lossy" })).toHaveLength(184); + const chestLossless = optimizeMeshPolygons(chest, { meshResolution: "lossless" }); + const chestLossy = optimizeMeshPolygons(chest, { meshResolution: "lossy" }); + const losslessSeams = seamOverlapDiagnostics(chestLossless); + const lossySeams = seamOverlapDiagnostics(chestLossy); + + expect(chestLossless).toHaveLength(258); + expect(chestLossy).toHaveLength(250); + expect(lossySeams.unclosedPairs).toBeLessThanOrEqual(losslessSeams.unclosedPairs); + expect(lossySeams.maxResidualGapPx).toBeLessThanOrEqual(losslessSeams.maxResidualGapPx); + }); + it("keeps lossless optimization from culling open spacecraft geometry", () => { const raw = parseGltf(loadGlbGalleryFile("nasa/cubesat-1u.glb"), { targetSize: 60 }).polygons; @@ -448,7 +522,7 @@ describe("optimizeMeshPolygons", () => { expect(lossless.length).toBeGreaterThan(1500); }, 10_000); - it("does not turn castle seam overlap repairs into concave render polygons", () => { + it("does not turn castle lossy output into concave render polygons", () => { const raw = parseObj(loadObjGalleryFile("castle.obj"), { targetSize: 60 }).polygons; const lossless = optimizeMeshPolygons(raw, { meshResolution: "lossless" }); @@ -492,15 +566,13 @@ describe("optimizeMeshPolygons", () => { expect(renderCost(lossy)).toBeLessThan(renderCost(lossless)); }); - it("keeps default lossy wins on the coliseum fixture", () => { - const raw = parseObj(loadObjGalleryFile("coliseum.obj"), { - targetSize: 80, - palette: ["#c9a876", "#a78760", "#8b6f47", "#6b5538"], - }).polygons; + it("keeps default lossy wins on repeated shallow triangle folds", () => { + const raw = shallowFoldedTrianglePairs(48); const lossless = optimizeMeshPolygons(raw, { meshResolution: "lossless" }); const lossy = optimizeMeshPolygons(raw, { meshResolution: "lossy" }); - expect(lossless.length - lossy.length).toBeGreaterThanOrEqual(400); + expect(lossless.length - lossy.length).toBeGreaterThanOrEqual(40); + expect(renderCost(lossy)).toBeLessThan(renderCost(lossless)); }); }); diff --git a/packages/core/src/merge/optimizePolygons.ts b/packages/core/src/merge/optimizePolygons.ts index 39b52378..3a38bce3 100644 --- a/packages/core/src/merge/optimizePolygons.ts +++ b/packages/core/src/merge/optimizePolygons.ts @@ -3,7 +3,7 @@ import { findOverlappingPolygonDuplicates } from "./dedupeOverlappingPolygons"; import type { MeshResolution, Polygon, TextureTriangle, Vec2, Vec3 } from "../types"; import { coverPlanarPolygons, type CoverPlanarPolygonsOptions } from "./coverPlanarPolygons"; import { mergePolygons } from "./mergePolygons"; -import { repairMeshSeams } from "./seamRepair"; +import { seamOverlapDiagnostics, type SeamOverlapDiagnostics } from "./seamRepair"; const NORMALIZE_MAX_ANGLE_DEG = 3; const NORMALIZE_MAX_PLANE_DISPLACEMENT = 0.03; @@ -78,6 +78,66 @@ interface PlaneGroupReplacements { vertexMoves: VertexPositionMove[]; } +interface TrianglePairSourceCache { + polygons: Polygon[]; + metas: Array; + edgeOwnerPairs: Array<[number, number]>; + preparedCandidates?: PreparedPairCandidate[]; +} + +interface PreparedPairCandidate { + candidate: PairCandidate; + normalDot: number; + maxDistance: number; +} + +interface TopologySegment { + index: number; + key: string; + polygon: number; + edge: number; + a: Vec3; + b: Vec3; + dir: Vec3; + length: number; + minX: number; + minY: number; + minZ: number; + maxX: number; + maxY: number; + maxZ: number; +} + +interface TopologyEdgeStats { + boundaryKeys: Set; + internalKeys: Set; + boundarySegments: TopologySegment[]; + internalSegments: TopologySegment[]; + boundaryLength: number; +} + +interface TopologyGapDiagnostics { + exactInternalEdges: number; + nearInternalEdges: number; + exposedInternalLength: number; + maxInternalOffset: number; + tJunctionPairs: number; + tJunctionLength: number; + excessBoundaryLength: number; +} + +interface SegmentOverlapInfo { + overlapLength: number; + offset: number; +} + +interface BestSafetyDiagnostics { + polygons: Polygon[]; + seam?: SeamOverlapDiagnostics; + topologyEdges?: TopologyEdgeStats; + topologySelf?: TopologyGapDiagnostics; +} + interface PreprocessCache { baseline?: Polygon[]; deduped?: Polygon[]; @@ -87,6 +147,7 @@ interface PreprocessCache { snapped?: Polygon[]; snappedInterior?: Polygon[]; snappedInteriorIndices?: IndexFilter; + trianglePairSource?: TrianglePairSourceCache; } type IndexFilter = number[] | null; @@ -105,8 +166,37 @@ const DEFAULT_LOSSY_APPROXIMATE_OPTIONS: Required = { isolatedPairs: true, }; +const AGGRESSIVE_LOSSY_APPROXIMATE_VARIANTS: ReadonlyArray> = [ + { + ...DEFAULT_LOSSY_APPROXIMATE_OPTIONS, + maxAngleDeg: 30, + }, + { + ...DEFAULT_LOSSY_APPROXIMATE_OPTIONS, + maxAngleDeg: 45, + }, + { + ...DEFAULT_LOSSY_APPROXIMATE_OPTIONS, + maxAngleDeg: 60, + maxBoundaryDisplacement: 0.06, + }, +]; + const AUTOMATIC_RECT_COVER_MAX_POLYGONS = 1800; const AUTOMATIC_RECT_COVER_MIN_TRIANGLE_RATIO = 0.65; +const AUTOMATIC_APPROXIMATE_RECT_COVER_MIN_SOURCE_POLYGONS = 500; +const AUTOMATIC_APPROXIMATE_RECT_COVER_MAX_SOURCE_POLYGONS = 2200; +const LARGE_LOSSY_RECT_COVER_MIN_POLYGONS = 1000; +const LARGE_LOSSY_RECT_COVER_MAX_POLYGONS = 2200; +const LARGE_LOSSY_RECT_COVER_MAX_BOUNDARY_EDGES = 350; +const WIDE_LOSSY_VARIANT_MAX_SOURCE_POLYGONS = 700; +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 TOPOLOGY_GAP_TOLERANCE = 0.045; +const TOPOLOGY_MIN_PARALLEL_DOT = 0.999; +const TOPOLOGY_MIN_OVERLAP = 1e-5; const DEFAULT_RECT_COVER_SMALL_AUTOMATIC_SKIP_MIN_POLYGONS = 24; const DEFAULT_RECT_COVER_SMALL_AUTOMATIC_SKIP_MAX_POLYGONS = 50; const DEFAULT_RECT_COVER_MAX_AUTOMATIC_POLYGONS = 1000; @@ -122,21 +212,71 @@ const AUTOMATIC_LOSSY_RECT_COVER_OPTIONS: CoverPlanarPolygonsOptions = { maxCandidateAxes: 1, }; +const AUTOMATIC_APPROXIMATE_RECT_COVER_OPTIONS: CoverPlanarPolygonsOptions = { + ...DEFAULT_RECT_COVER_OPTIONS, + maxCandidateAxes: 2, +}; + +const LARGE_LOSSY_RECT_COVER_OPTIONS: CoverPlanarPolygonsOptions = { + ...DEFAULT_RECT_COVER_OPTIONS, + maxCandidateAxes: 2, +}; + export function optimizeMeshPolygons( polygons: Polygon[], options: OptimizeMeshPolygonsOptions = {}, ): Polygon[] { const meshResolution = options.meshResolution ?? "lossy"; - const finish = (candidate: Polygon[]): Polygon[] => - meshResolution === "lossy" ? repairMeshSeams(candidate) : candidate; const preprocessCache: PreprocessCache = {}; const baseline = preprocessModelPolygons(polygons, false, preprocessCache); let best = baseline; - let bestCost = polygonRenderCost(baseline); + let bestCost = polygonRenderCost(best); + 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; + }; + const bestTopologySelfDiagnostics = (): TopologyGapDiagnostics => { + if (bestDiagnostics.polygons !== best) resetBestDiagnostics(); + if (!bestDiagnostics.topologySelf) { + bestDiagnostics.topologySelf = topologySelfDiagnostics(bestTopologyEdges(), TOPOLOGY_GAP_TOLERANCE); + } + 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; }; @@ -152,16 +292,53 @@ export function optimizeMeshPolygons( const losslessRectCovered = applyRectCoverCandidate(baseline, undefined); if (losslessRectCovered !== baseline) acceptCandidate(losslessRectCovered); } - if (meshResolution === "lossy" && (best.length <= 1 || bestCost <= 1 + 1e-9)) return finish(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 ( + 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 (options.rectCover !== undefined && options.rectCover !== false) { acceptCandidate(applyRectCoverCandidate(approximate, options.rectCover)); } + let acceptedBaseAggressive = false; + for (let variantIndex = 0; variantIndex < AGGRESSIVE_LOSSY_APPROXIMATE_VARIANTS.length; variantIndex += 1) { + if ( + variantIndex === 1 && + !acceptedBaseAggressive + ) continue; + if ( + variantIndex === 2 && + !automaticWideLossyVariantCandidate(polygons) + ) continue; + const aggressiveOptions = AGGRESSIVE_LOSSY_APPROXIMATE_VARIANTS[variantIndex]; + const aggressive = preprocessModelPolygons(polygons, aggressiveOptions, preprocessCache); + 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 + ) { + aggressiveCandidate = applyRectCoverCandidate(aggressive, AUTOMATIC_APPROXIMATE_RECT_COVER_OPTIONS); + } + if (options.rectCover !== undefined && options.rectCover !== false) { + aggressiveCandidate = applyRectCoverCandidate(aggressive, options.rectCover); + } + const accepted = acceptSeamSafeCandidate(aggressiveCandidate, polygons.length); + if (variantIndex === 0 && accepted) acceptedBaseAggressive = true; + } + if (options.rectCover === undefined) { + const largeRectCovered = applyRectCoverCandidate(best, automaticLargeLossyRectCoverCandidate(best)); + if (largeRectCovered !== best) acceptSeamSafeCandidate(largeRectCovered, best.length); + } } - return finish(best); + return best; } function polygonRenderCost(polygons: Polygon[]): number { @@ -175,6 +352,312 @@ function polygonRenderCost(polygons: Polygon[]): number { return cost; } +function seamDiagnosticsWorse( + candidate: SeamOverlapDiagnostics, + baseline: SeamOverlapDiagnostics, +): boolean { + return candidate.nearPairs > baseline.nearPairs || + candidate.unclosedPairs > baseline.unclosedPairs || + candidate.maxMeasuredGapPx > baseline.maxMeasuredGapPx + 1e-9 || + candidate.maxResidualGapPx > baseline.maxResidualGapPx + 1e-9; +} + +function topologyGapDiagnosticsWorse( + referenceEdges: TopologyEdgeStats, + referenceDiagnostics: TopologyGapDiagnostics, + candidate: Polygon[], +): boolean { + const candidateDiagnostics = topologyGapDiagnostics( + referenceEdges, + collectTopologyEdgeStats(candidate), + TOPOLOGY_GAP_TOLERANCE, + ); + if (candidateDiagnostics.exactInternalEdges > 0 || candidateDiagnostics.nearInternalEdges > 0) { + return true; + } + return candidateDiagnostics.tJunctionPairs > referenceDiagnostics.tJunctionPairs || + candidateDiagnostics.tJunctionLength > referenceDiagnostics.tJunctionLength + 1e-9; +} + +function topologyGapDiagnostics( + 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), + }; + + 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); + } + + addBoundaryTJunctionDiagnostics(diagnostics, candidateEdges.boundarySegments, tolerance); + return diagnostics; +} + +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; +} + +function addBoundaryTJunctionDiagnostics( + diagnostics: TopologyGapDiagnostics, + boundarySegments: TopologySegment[], + tolerance: number, +): void { + const boundaryIndex = buildTopologySegmentIndex(boundarySegments, tolerance); + const seenPairs = new Set(); + let pairStride = 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; + diagnostics.tJunctionPairs += 1; + diagnostics.tJunctionLength += overlap.overlapLength; + } + } +} + +function collectTopologyEdgeStats(polygons: Polygon[]): TopologyEdgeStats { + const edges = new Map(); + for (let polygonIndex = 0; polygonIndex < polygons.length; polygonIndex += 1) { + const vertices = polygons[polygonIndex].vertices; + for (let edgeIndex = 0; edgeIndex < vertices.length; edgeIndex += 1) { + const a = vertices[edgeIndex]; + const b = vertices[(edgeIndex + 1) % vertices.length]; + const segment = topologySegment(edgeKey(a, b), polygonIndex, edgeIndex, a, b); + if (!segment) continue; + const current = edges.get(segment.key); + if (current) current.count += 1; + else edges.set(segment.key, { count: 1, segment }); + } + } + + const boundaryKeys = new Set(); + const internalKeys = new Set(); + const boundarySegments: TopologySegment[] = []; + const internalSegments: TopologySegment[] = []; + let boundaryLength = 0; + let index = 0; + for (const edge of edges.values()) { + const segment = { ...edge.segment, index }; + index += 1; + if (edge.count === 1) { + boundaryKeys.add(segment.key); + boundarySegments.push(segment); + boundaryLength += segment.length; + } else { + internalKeys.add(segment.key); + internalSegments.push(segment); + } + } + return { boundaryKeys, internalKeys, boundarySegments, internalSegments, boundaryLength }; +} + +function topologySegment( + key: string, + polygon: number, + edge: number, + a: Vec3, + b: Vec3, +): TopologySegment | null { + const delta = subVec(b, a); + const length = Math.hypot(delta[0], delta[1], delta[2]); + if (length <= 1e-10) return null; + const dir: Vec3 = [delta[0] / length, delta[1] / length, delta[2] / length]; + return { + index: -1, + key, + polygon, + edge, + a, + b, + dir, + length, + minX: Math.min(a[0], b[0]), + minY: Math.min(a[1], b[1]), + minZ: Math.min(a[2], b[2]), + maxX: Math.max(a[0], b[0]), + maxY: Math.max(a[1], b[1]), + maxZ: Math.max(a[2], b[2]), + }; +} + +function buildTopologySegmentIndex(segments: TopologySegment[], tolerance: number) { + const cellSize = Math.max(tolerance * 8, 0.5); + const cells = new Map(); + for (const segment of segments) { + addTopologySegmentToCells(cells, segment, cellSize, tolerance); + } + return { cellSize, cells }; +} + +function addTopologySegmentToCells( + cells: Map, + segment: TopologySegment, + cellSize: number, + padding: number, +) { + const [minX, minY, minZ] = topologyCellCoords( + [segment.minX - padding, segment.minY - padding, segment.minZ - padding], + cellSize, + ); + const [maxX, maxY, maxZ] = topologyCellCoords( + [segment.maxX + padding, segment.maxY + padding, segment.maxZ + padding], + cellSize, + ); + for (let x = minX; x <= maxX; x += 1) { + for (let y = minY; y <= maxY; y += 1) { + for (let z = minZ; z <= maxZ; z += 1) { + const key = `${x},${y},${z}`; + const bucket = cells.get(key); + if (bucket) bucket.push(segment); + else cells.set(key, [segment]); + } + } + } +} + +function topologyCellCoords(point: Vec3, cellSize: number): [number, number, number] { + return [ + Math.floor(point[0] / cellSize), + Math.floor(point[1] / cellSize), + Math.floor(point[2] / cellSize), + ]; +} + +function overlappingSegmentCandidates( + segment: TopologySegment, + index: ReturnType, + tolerance: number, +): TopologySegment[] { + const out: TopologySegment[] = []; + const seen = new Set(); + const [minX, minY, minZ] = topologyCellCoords( + [segment.minX - tolerance, segment.minY - tolerance, segment.minZ - tolerance], + index.cellSize, + ); + const [maxX, maxY, maxZ] = topologyCellCoords( + [segment.maxX + tolerance, segment.maxY + tolerance, segment.maxZ + tolerance], + index.cellSize, + ); + for (let x = minX; x <= maxX; x += 1) { + for (let y = minY; y <= maxY; y += 1) { + for (let z = minZ; z <= maxZ; z += 1) { + const bucket = index.cells.get(`${x},${y},${z}`); + if (!bucket) continue; + for (const candidate of bucket) { + if (seen.has(candidate)) continue; + seen.add(candidate); + out.push(candidate); + } + } + } + } + return out; +} + +function findOverlappingSegment( + segment: TopologySegment, + index: ReturnType, + tolerance: number, +): SegmentOverlapInfo | null { + let best: SegmentOverlapInfo | null = null; + 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; + } + } + return best; +} + +function segmentOverlap( + a: TopologySegment, + b: TopologySegment, + tolerance: number, +): SegmentOverlapInfo | null { + if (!topologySegmentBoundsOverlap(a, b, tolerance)) return null; + if (Math.abs(dotVec(a.dir, b.dir)) < TOPOLOGY_MIN_PARALLEL_DOT) return null; + + const bStart = dotVec(subVec(b.a, a.a), a.dir); + const bEnd = dotVec(subVec(b.b, a.a), a.dir); + const overlapStart = Math.max(0, Math.min(bStart, bEnd)); + const overlapEnd = Math.min(a.length, Math.max(bStart, bEnd)); + const overlapLength = overlapEnd - overlapStart; + if (overlapLength <= Math.max(TOPOLOGY_MIN_OVERLAP, Math.min(a.length, b.length) * 1e-4)) { + return null; + } + + const mid = [ + a.a[0] + a.dir[0] * ((overlapStart + overlapEnd) / 2), + a.a[1] + a.dir[1] * ((overlapStart + overlapEnd) / 2), + a.a[2] + a.dir[2] * ((overlapStart + overlapEnd) / 2), + ] as Vec3; + const projected = Math.max(0, Math.min(b.length, dotVec(subVec(mid, b.a), b.dir))); + const closest: Vec3 = [ + b.a[0] + b.dir[0] * projected, + b.a[1] + b.dir[1] * projected, + b.a[2] + b.dir[2] * projected, + ]; + const offset = distanceVec(mid, closest); + return offset <= tolerance ? { overlapLength, offset } : null; +} + +function topologySegmentBoundsOverlap(a: TopologySegment, b: TopologySegment, tolerance: number): boolean { + return a.minX <= b.maxX + tolerance && + b.minX <= a.maxX + tolerance && + a.minY <= b.maxY + tolerance && + b.minY <= a.maxY + tolerance && + a.minZ <= b.maxZ + tolerance && + b.minZ <= a.maxZ + tolerance; +} + +function aggressiveLossyMinRenderCostGain(sourcePolygonCount: number): number { + return Math.max( + AGGRESSIVE_LOSSY_MIN_RENDER_COST_GAIN, + sourcePolygonCount * AGGRESSIVE_LOSSY_MIN_SOURCE_GAIN_RATIO, + ); +} + function applyRectCoverCandidate( polygons: Polygon[], setting: OptimizeMeshPolygonsOptions["rectCover"], @@ -229,6 +712,17 @@ function automaticLossyRectCoverOptions(polygons: Polygon[]): CoverPlanarPolygon return AUTOMATIC_LOSSY_RECT_COVER_OPTIONS; } +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 (polygonBoundaryEdgeCount(polygons) > LARGE_LOSSY_RECT_COVER_MAX_BOUNDARY_EDGES) return false; + return LARGE_LOSSY_RECT_COVER_OPTIONS; +} + +function automaticWideLossyVariantCandidate(polygons: Polygon[]): boolean { + return polygons.length <= WIDE_LOSSY_VARIANT_MAX_SOURCE_POLYGONS; +} + function polygonTriangleCount(polygons: Polygon[]): number { let triangles = 0; for (const polygon of polygons) { @@ -332,7 +826,7 @@ function preprocessModelPolygons( ? DEFAULT_NORMALIZE_OPTIONS : resolveNormalizeOptions(normalizeGeometry); if (options.isolatedPairs) { - const paired = mergeIsolatedTrianglePairs(snappedInteriorPolygonsForMerge(deduped, cache), options); + const paired = mergeIsolatedTrianglePairs(snappedInteriorPolygonsForMerge(deduped, cache), options, cache); const mergedPaired = mergePolygons(paired); return mergedPaired.length < baseline.length ? mergedPaired : baseline; } @@ -373,7 +867,28 @@ function resolveNormalizeOptions(options: LossyApproximateOptions): ResolvedGeom function mergeIsolatedTrianglePairs( polygons: Polygon[], options: ResolvedGeometryNormalizeOptions, + cache?: PreprocessCache, ): Polygon[] { + const source = trianglePairSourceFor(polygons, cache); + + const candidates: PairCandidate[] = []; + for (const prepared of preparedPairCandidatesFor(polygons, source)) { + if (preparedPairCandidateMatchesOptions(prepared, options)) { + candidates.push(prepared.candidate); + } + } + const selected = choosePairCandidates(candidates); + if (selected.length === 0) return polygons; + + return buildIsolatedTrianglePairOutput(polygons, selected); +} + +function trianglePairSourceFor( + polygons: Polygon[], + cache?: PreprocessCache, +): TrianglePairSourceCache { + if (cache?.trianglePairSource?.polygons === polygons) return cache.trianglePairSource; + const metas = polygons.map((polygon): PlaneNormalizeMeta | null => { const plane = planeOfPolygon(polygon); if (!plane) return null; @@ -396,17 +911,37 @@ function mergeIsolatedTrianglePairs( } } - const candidates: PairCandidate[] = []; + const edgeOwnerPairs: Array<[number, number]> = []; for (const owners of edgeOwners.values()) { - if (owners.length !== 2) continue; - const [a, b] = owners; - const candidate = approximateTrianglePairCandidate(a, b, polygons, metas, options); - if (candidate) candidates.push(candidate); + if (owners.length === 2) edgeOwnerPairs.push([owners[0], owners[1]]); } - const selected = choosePairCandidates(candidates); - if (selected.length === 0) return polygons; - return buildIsolatedTrianglePairOutput(polygons, selected); + const source = { polygons, metas, edgeOwnerPairs }; + if (cache) cache.trianglePairSource = source; + return source; +} + +function preparedPairCandidatesFor( + polygons: Polygon[], + source: TrianglePairSourceCache, +): PreparedPairCandidate[] { + if (source.preparedCandidates) return source.preparedCandidates; + const prepared: PreparedPairCandidate[] = []; + for (const [a, b] of source.edgeOwnerPairs) { + const candidate = prepareTrianglePairCandidate(a, b, polygons, source.metas); + if (candidate) prepared.push(candidate); + } + source.preparedCandidates = prepared; + return prepared; +} + +function preparedPairCandidateMatchesOptions( + prepared: PreparedPairCandidate, + options: ResolvedGeometryNormalizeOptions, +): boolean { + const minNormalDot = Math.cos((options.maxAngleDeg * Math.PI) / 180); + return prepared.normalDot >= minNormalDot && + prepared.maxDistance <= Math.min(options.maxPlaneDisplacement, options.maxBoundaryDisplacement); } function buildIsolatedTrianglePairOutput( @@ -669,13 +1204,12 @@ function applyVertexPositionMoves(polygons: Polygon[], moves: Map) }); } -function approximateTrianglePairCandidate( +function prepareTrianglePairCandidate( aIndex: number, bIndex: number, polygons: Polygon[], metas: Array, - options: ResolvedGeometryNormalizeOptions, -): PairCandidate | null { +): PreparedPairCandidate | null { const a = polygons[aIndex]; const b = polygons[bIndex]; const aMeta = metas[aIndex]; @@ -691,7 +1225,7 @@ function approximateTrianglePairCandidate( if (bGoesSameDirection) return null; const normalDot = Math.abs(dotVec(aMeta.normal, bMeta.normal)); - const minNormalDot = Math.cos((options.maxAngleDeg * Math.PI) / 180); + const minNormalDot = Math.cos((PREPARED_PAIR_MAX_ANGLE_DEG * Math.PI) / 180); if (normalDot < minNormalDot) return null; const aThird = (ai1 + 1) % a.vertices.length; @@ -712,7 +1246,7 @@ function approximateTrianglePairCandidate( maxDistance = Math.max(maxDistance, distance); squaredDistance += distance * distance; } - if (maxDistance > Math.min(options.maxPlaneDisplacement, options.maxBoundaryDisplacement)) return null; + if (maxDistance > PREPARED_PAIR_MAX_BOUNDARY_DISPLACEMENT) return null; const projected = ring.map((vertex) => projectVecToPlane(vertex, fit)); if (!isConvexPolygon(projected, fit.normal)) return null; @@ -745,17 +1279,21 @@ function approximateTrianglePairCandidate( } return { - a: aIndex, - b: bIndex, - polygon, - vertexMoves: [ - ...ring.map((vertex, index) => ({ - key: vertexKey(vertex), - target: projected[index], - })), - ...textureTriangleVertexProjectionMoves([a, b], fit), - ], - score: squaredDistance / ring.length + maxDistance * 0.25 + (1 - normalDot) * 0.1, + normalDot, + maxDistance, + candidate: { + a: aIndex, + b: bIndex, + polygon, + vertexMoves: [ + ...ring.map((vertex, index) => ({ + key: vertexKey(vertex), + target: projected[index], + })), + ...textureTriangleVertexProjectionMoves([a, b], fit), + ], + score: squaredDistance / ring.length + maxDistance * 0.25 + (1 - normalDot) * 0.1, + }, }; } diff --git a/packages/core/src/parser/parseGltf.test.ts b/packages/core/src/parser/parseGltf.test.ts index e211fd84..0eafd6d7 100644 --- a/packages/core/src/parser/parseGltf.test.ts +++ b/packages/core/src/parser/parseGltf.test.ts @@ -2,8 +2,6 @@ import { describe, it, expect, vi, afterEach } from "vitest"; import { readFileSync } from "fs"; import { resolve } from "path"; import { filterGltfAnimationController, parseGltf } from "./parseGltf"; -import { bakeSolidTextureSamples } from "./solidTextureSamples"; -import { optimizeAnimatedMeshPolygons } from "../animation/optimizeAnimatedMeshPolygons"; import { cullInteriorPolygons } from "../cull/cullInteriorPolygons"; // ── Real GLB fixture (lead test — matches voxcss parseMagicaVoxel pattern) ─ @@ -201,36 +199,6 @@ describe("parseGltf — animated fixture (FishAnimated.glb)", () => { expect(filtered!.sample(running!.name, 0.25)).toHaveLength(keptIndices.length); }); - it("can reduce robot animation with a stable animated mesh plan", async () => { - const parsed = parseGltf(loadGlbFile("poly-pizza", "animated-robot.glb"), { - gridShift: 0, - targetSize: 72, - }); - const baked = await bakeSolidTextureSamples(parsed); - const optimized = optimizeAnimatedMeshPolygons(baked, { - meshResolution: "lossy", - }); - const running = optimized.animation?.clips.find((clip) => /running/i.test(clip.name)); - const kept = cullInteriorPolygons(baked.polygons); - const keptSet = new Set(kept); - const keptIndices = baked.polygons.flatMap((polygon, index) => - keptSet.has(polygon) ? [index] : [] - ); - expect(running).toBeDefined(); - expect(optimized.polygons.length).toBeLessThan(baked.polygons.length); - expect(optimized.polygons.length).toBeGreaterThan(0); - expect(optimized.polygons).toHaveLength(keptIndices.length); - for (const time of [0, 0.25, running!.duration / 2]) { - const fullFrame = baked.animation!.sample(running!.name, time); - const frame = optimized.animation!.sample(running!.name, time); - expect(frame).toHaveLength(optimized.polygons.length); - expect(frame.some((polygon) => polygon.texture)).toBe(false); - expect(frame[0].color).toBe(optimized.polygons[0].color); - for (let i = 0; i < frame.length; i++) { - expect(frame[i].vertices).toEqual(fullFrame[keptIndices[i]!]!.vertices); - } - } - }); }); // ── GLB / glTF binary builder helpers ───────────────────────────────────── diff --git a/packages/core/src/parser/parseGltf.ts b/packages/core/src/parser/parseGltf.ts index e0196f40..efffb842 100644 --- a/packages/core/src/parser/parseGltf.ts +++ b/packages/core/src/parser/parseGltf.ts @@ -69,7 +69,7 @@ export interface GltfParseOptions { * (`doc.images[i].uri = "Textures/foo.png"`) against the GLB/glTF's * location. Without this, relative URIs would resolve against the page, * which 404s. Pass the same URL you fetched the file from. - */ + */ baseUrl?: string; } @@ -196,7 +196,7 @@ interface GltfAnimationChannel { sampler: number; target: { node?: number; - path?: "translation" | "rotation" | "scale" | "weights" | string; + path?: "translation" | "rotation" | "scale" | string; }; } interface GltfAnimation { @@ -947,7 +947,6 @@ function buildAnimationController( joints: skin.joints ?? [], inverseBindMatrices: readMat4Array(doc, buffers, skin.inverseBindMatrices, skin.joints?.length ?? 0), })); - const runtimeClips: RuntimeAnimationClip[] = []; for (let i = 0; i < animations.length; i++) { const animation = animations[i]; @@ -967,7 +966,8 @@ function buildAnimationController( const targetNode = channel.target.node; const path = channel.target.path; const sampler = runtimeSamplers[channel.sampler]; - if (targetNode === undefined || !path || !sampler || path === "weights") continue; + if (targetNode === undefined || !path || !sampler) continue; + if (path !== "translation" && path !== "rotation" && path !== "scale") continue; channels.push({ sampler, targetNode, path }); } const duration = channels.reduce((max, channel) => { @@ -1068,7 +1068,10 @@ function buildAnimationController( skin.joints.map(() => new Array(16) as Mat4) ); - const sampleWorldMatrices = (clipRef: number | string, timeSecondsIn: number): Mat4[] | null => { + const sampleWorldMatrices = ( + clipRef: number | string, + timeSecondsIn: number, + ): Mat4[] | null => { const clip = typeof clipRef === "number" ? runtimeClips[clipRef] : runtimeClips.find((candidate) => candidate.info.name === clipRef); @@ -1238,7 +1241,12 @@ function buildAnimationController( const source = sources[sourceIndex]!; const sourceMask = sourceMaskOverrides?.[sourceIndex]; const triangleMask = sourceMask?.triangleMask ?? source.triangleMask; - const worldPositions = computeSourceWorldPositions(sourceIndex, source, sourceMask, worldMatrices); + const worldPositions = computeSourceWorldPositions( + sourceIndex, + source, + sourceMask, + worldMatrices, + ); let triangleOrdinal = 0; for (let i = 0; i + 2 < source.indices.length; i += 3, triangleOrdinal++) { @@ -1298,7 +1306,12 @@ function buildAnimationController( const source = sources[sourceIndex]!; const sourceMask = sourceMaskOverrides?.[sourceIndex]; const triangleMask = sourceMask?.triangleMask ?? source.triangleMask; - const worldPositions = computeSourceWorldPositions(sourceIndex, source, sourceMask, worldMatrices); + const worldPositions = computeSourceWorldPositions( + sourceIndex, + source, + sourceMask, + worldMatrices, + ); let triangleOrdinal = 0; for (let i = 0; i + 2 < source.indices.length; i += 3, triangleOrdinal++) { diff --git a/packages/polycss/src/render/polyDOM.test.ts b/packages/polycss/src/render/polyDOM.test.ts index cb69d771..f3521a6d 100644 --- a/packages/polycss/src/render/polyDOM.test.ts +++ b/packages/polycss/src/render/polyDOM.test.ts @@ -1,5 +1,3 @@ -import { readFileSync } from "fs"; -import { resolve } from "path"; import { describe, it, expect, vi, afterEach } from "vitest"; import { renderPoly } from "./polyDOM"; import { @@ -7,24 +5,13 @@ import { renderPolygonsWithTextureAtlasAsync, updatePolygonsWithStableTopology, } from "./textureAtlas"; -import { - optimizeMeshPolygons, - parseGltf, - type Polygon, -} from "@layoutit/polycss-core"; +import type { Polygon } from "@layoutit/polycss-core"; const ATLAS_CANONICAL_SIZE_EXPLICIT = 64; const ATLAS_CANONICAL_SIZE_AUTO_DESKTOP = 128; const SOLID_QUAD_CANONICAL_SIZE = 64; const BORDER_SHAPE_CANONICAL_SIZE = 16; -const CORNER_SHAPE_CORPUS = [ - ["Bear.glb"], - ["urban", "Car.glb"], - ["urban", "Fire hydrant.glb"], - ["city", "Large Building.glb"], -] as const; - const FLAT_TRIANGLE: Polygon = { vertices: [ [0, 0, 0], @@ -122,6 +109,17 @@ const CHAMFERED_SOLID: Polygon = { color: "#0f766e", }; +const IRREGULAR_SOLID: Polygon = { + vertices: [ + [0, 0, 0], + [0, 2, 0], + [1, 1.4, 0], + [2, 2, 0], + [2, 0, 0], + ], + color: "#f59e0b", +}; + const OFFAXIS_TRIANGLE: Polygon = { vertices: [ [0, 0, 0], @@ -156,12 +154,6 @@ function roundedMatrix(values: number[], decimals = 3): number[] { return values.map((value) => Number(value.toFixed(decimals))); } -function loadGalleryGlb(...parts: string[]): ArrayBuffer { - const filePath = resolve(__dirname, "../../../../website/public/gallery/glb", ...parts); - const buffer = readFileSync(filePath); - return buffer.buffer.slice(buffer.byteOffset, buffer.byteOffset + buffer.byteLength); -} - function supportDoc(options: { borderShape?: boolean; cornerShape?: boolean }): Document { return { defaultView: { @@ -818,17 +810,7 @@ describe("renderPolygonsWithTextureAtlas", () => { }); it("keeps non-chamfered n-gons on border-shape when corner-shape is supported", () => { - const irregularSolid: Polygon = { - vertices: [ - [0, 0, 0], - [0, 2, 0], - [1, 1.4, 0], - [2, 2, 0], - [2, 0, 0], - ], - color: "#f59e0b", - }; - const result = renderPolygonsWithTextureAtlas([irregularSolid], { + const result = renderPolygonsWithTextureAtlas([IRREGULAR_SOLID], { doc: supportDoc({ borderShape: true, cornerShape: true }), }); const element = result.rendered[0].element; @@ -837,41 +819,28 @@ describe("renderPolygonsWithTextureAtlas", () => { result.dispose(); }); - it("replaces a subset of border-shape leaves with u leaves across a gallery corpus", () => { + it("replaces only exact corner-shape solids with u leaves", () => { const beforeDoc = supportDoc({ borderShape: true, cornerShape: false }); const afterDoc = supportDoc({ borderShape: true, cornerShape: true }); - let beforeI = 0; - let beforeU = 0; - let afterI = 0; - let afterU = 0; - - expect(CORNER_SHAPE_CORPUS).toHaveLength(4); - - for (const fixture of CORNER_SHAPE_CORPUS) { - const parsed = parseGltf(loadGalleryGlb(...fixture)); - const polygons = optimizeMeshPolygons(parsed.polygons, { meshResolution: "lossless" }); - parsed.dispose(); - - const before = renderPolygonsWithTextureAtlas(polygons, { doc: beforeDoc }); - const after = renderPolygonsWithTextureAtlas(polygons, { doc: afterDoc }); - const beforeCounts = leafTagCounts(before); - const afterCounts = leafTagCounts(after); - - beforeI += beforeCounts.i; - beforeU += beforeCounts.u; - afterI += afterCounts.i; - afterU += afterCounts.u; - expect(afterCounts.i + afterCounts.u).toBe(beforeCounts.i + beforeCounts.u); - - before.dispose(); - after.dispose(); - } - const replaced = beforeI - afterI; - expect(beforeI).toBeGreaterThan(0); - expect(replaced).toBeGreaterThan(0); - expect(afterU - beforeU).toBe(replaced); - }, 15000); + const before = renderPolygonsWithTextureAtlas([CHAMFERED_SOLID, IRREGULAR_SOLID], { + doc: beforeDoc, + }); + const after = renderPolygonsWithTextureAtlas([CHAMFERED_SOLID, IRREGULAR_SOLID], { + doc: afterDoc, + }); + const beforeCounts = leafTagCounts(before); + const afterCounts = leafTagCounts(after); + + expect(beforeCounts.i).toBe(2); + expect(beforeCounts.u).toBe(0); + expect(afterCounts.i).toBe(1); + expect(afterCounts.u).toBe(1); + expect(afterCounts.i + afterCounts.u).toBe(beforeCounts.i + beforeCounts.u); + + before.dispose(); + after.dispose(); + }); it("uses the atlas fallback for solid non-rect polygons on non-desktop pointers when projective quads are disabled", () => { const canvases: Array<{ width: number; height: number; getContext: () => null }> = []; diff --git a/packages/react/src/controls/TransformControls.test.tsx b/packages/react/src/controls/TransformControls.test.tsx index f3f48438..526b5918 100644 --- a/packages/react/src/controls/TransformControls.test.tsx +++ b/packages/react/src/controls/TransformControls.test.tsx @@ -137,25 +137,28 @@ describe("", () => { expect(arrows.map(axisKeyOf)).toEqual(["y", "-y"]); }); - it("pointer down on a beam fires onMouseDown + onDraggingChanged(true); up fires onMouseUp + (false)", () => { + it("translate drag emits lifecycle events, updates position, and does not rebake", () => { withFakeLayout(2, () => { const onMouseDown = vi.fn(); const onMouseUp = vi.fn(); const onDraggingChanged = vi.fn(); + const onObjectChange = vi.fn(); const ref = createRef(); const container = mount( - + , ); + const rebakeSpy = vi.spyOn(ref.current!, "rebakeAtlas"); const xBeam = container.querySelector('.polycss-transform-arrow--x') as HTMLElement; act(() => { xBeam.dispatchEvent( @@ -164,42 +167,18 @@ describe("", () => { }); expect(onMouseDown).toHaveBeenCalledOnce(); expect(onDraggingChanged).toHaveBeenLastCalledWith(true); + act(() => { + window.dispatchEvent(new PointerEvent("pointermove", { clientX: 10, clientY: 0, pointerId: 1 })); + }); + expect(onObjectChange).toHaveBeenCalledOnce(); + expect(onObjectChange.mock.calls[0][0].position).toEqual([105, 200, 0]); + expect(onObjectChange.mock.calls[0][0].object).toBe(ref.current); act(() => { window.dispatchEvent(new PointerEvent("pointerup", { clientX: 10, clientY: 0, pointerId: 1 })); }); expect(onMouseUp).toHaveBeenCalledOnce(); expect(onDraggingChanged).toHaveBeenLastCalledWith(false); - }); - }); - - it("dragging projects pointer screen-delta onto the X axis (newPos = startPos + t * X)", () => { - withFakeLayout(2, () => { - const onObjectChange = vi.fn(); - const ref = createRef(); - const container = mount( - - - - - - , - ); - const xBeam = container.querySelector('.polycss-transform-arrow--x') as HTMLElement; - act(() => { - xBeam.dispatchEvent( - new PointerEvent("pointerdown", { bubbles: true, clientX: 0, clientY: 0, pointerId: 1 }), - ); - }); - // Camera scale 2, axis [1,0,0], so screen-axis = (2, 0), |s|² = 4. - // Pointer delta (10, 0) → t = (10*2 + 0*0)/4 = 5. - // newPos = [100,200,0] + 5*[1,0,0] = [105,200,0]. - act(() => { - window.dispatchEvent(new PointerEvent("pointermove", { clientX: 10, clientY: 0, pointerId: 1 })); - }); - expect(onObjectChange).toHaveBeenCalledOnce(); - const event = onObjectChange.mock.calls[0][0]; - expect(event.position).toEqual([105, 200, 0]); - expect(event.object).toBe(ref.current); + expect(rebakeSpy).not.toHaveBeenCalled(); }); }); @@ -413,19 +392,27 @@ describe("", () => { }); }); - it("rotate Y ring pointerdown fires onMouseDown and drag changes rotation[1]", () => { + it("rotate drag emits lifecycle events, updates rotation, and rebakes on release", () => { withFakeLayout(2, () => { const onObjectChange = vi.fn(); const onMouseDown = vi.fn(); + const onDraggingChanged = vi.fn(); const ref = createRef(); const container = mount( - + , ); + const rebakeSpy = vi.spyOn(ref.current!, "rebakeAtlas"); const yRing = container.querySelector('.polycss-transform-ring--y') as HTMLElement; expect(yRing).not.toBeNull(); // The ring is a single quad masked to a donut via CSS; the JS hit-test @@ -447,17 +434,20 @@ describe("", () => { }); leaf.getBoundingClientRect = origLeafRect; expect(onMouseDown).toHaveBeenCalledOnce(); + expect(onDraggingChanged).toHaveBeenLastCalledWith(true); // Move pointer to accumulate angle change act(() => { window.dispatchEvent(new PointerEvent("pointermove", { clientX: 0, clientY: 100, pointerId: 1 })); }); - if (onObjectChange.mock.calls.length > 0) { - expect(onObjectChange.mock.calls[0][0].rotation).toBeDefined(); - expect(onObjectChange.mock.calls[0][0].rotation[1]).not.toBe(0); - } + expect(onObjectChange).toHaveBeenCalled(); + expect(onObjectChange.mock.calls[0][0].rotation).toBeDefined(); + expect(onObjectChange.mock.calls[0][0].rotation[1]).not.toBe(0); + expect(rebakeSpy).not.toHaveBeenCalled(); act(() => { window.dispatchEvent(new PointerEvent("pointerup", { pointerId: 1 })); }); + expect(onDraggingChanged).toHaveBeenLastCalledWith(false); + expect(rebakeSpy).toHaveBeenCalledOnce(); }); }); @@ -629,108 +619,4 @@ describe("", () => { }); }); - it("RotateRing.onPointerDown fires when enabled and finds the wrapper", () => { - withFakeLayout(2, () => { - const onMouseDown = vi.fn(); - const onDraggingChanged = vi.fn(); - const ref = createRef(); - const container = mount( - - - - - - , - ); - const xRing = container.querySelector('.polycss-transform-ring--x') as HTMLElement; - // Patch the leaf bbox so the click at (100, 0) is at the right edge of - // the bbox — passes the donut-shaped hit-test (clicks at the bbox - // center would land in the inner hole and be rejected). - const leaf = xRing.querySelector("i,b,s,u") as HTMLElement; - leaf.getBoundingClientRect = () => ({ - left: 0, top: -50, right: 100, bottom: 50, width: 100, height: 100, x: 0, y: -50, - toJSON() { return this; }, - } as DOMRect); - act(() => { - xRing.dispatchEvent( - new PointerEvent("pointerdown", { bubbles: true, clientX: 100, clientY: 0, pointerId: 1 }), - ); - }); - expect(onMouseDown).toHaveBeenCalledOnce(); - expect(onDraggingChanged).toHaveBeenCalledWith(true); - act(() => { - window.dispatchEvent(new PointerEvent("pointerup", { clientX: 100, clientY: 0, pointerId: 1 })); - }); - expect(onDraggingChanged).toHaveBeenCalledWith(false); - }); - }); - - it("rotate-ring pointerdown→up calls rebakeAtlas() exactly once on the target mesh", () => { - withFakeLayout(2, () => { - const ref = createRef(); - const container = mount( - - - - - - , - ); - // Spy on the handle's rebakeAtlas after mount. - const handle = ref.current!; - const rebakeSpy = vi.spyOn(handle, "rebakeAtlas"); - - const xRing = container.querySelector('.polycss-transform-ring--x') as HTMLElement; - // Patch the leaf bbox so click at (100, 0) hits the donut band. - const leaf = xRing.querySelector("i,b,s,u") as HTMLElement; - leaf.getBoundingClientRect = () => ({ - left: 0, top: -50, right: 100, bottom: 50, width: 100, height: 100, x: 0, y: -50, - toJSON() { return this; }, - } as DOMRect); - act(() => { - xRing.dispatchEvent( - new PointerEvent("pointerdown", { bubbles: true, clientX: 100, clientY: 0, pointerId: 1 }), - ); - }); - // Not called yet — only fires on release. - expect(rebakeSpy).not.toHaveBeenCalled(); - act(() => { - window.dispatchEvent(new PointerEvent("pointerup", { clientX: 100, clientY: 0, pointerId: 1 })); - }); - expect(rebakeSpy).toHaveBeenCalledOnce(); - }); - }); - - it("translate-mode drag (startAxisDrag) does NOT call rebakeAtlas()", () => { - withFakeLayout(2, () => { - const ref = createRef(); - const container = mount( - - - - - - , - ); - const handle = ref.current!; - const rebakeSpy = vi.spyOn(handle, "rebakeAtlas"); - - const xBeam = container.querySelector('.polycss-transform-arrow--x') as HTMLElement; - act(() => { - xBeam.dispatchEvent( - new PointerEvent("pointerdown", { bubbles: true, clientX: 0, clientY: 0, pointerId: 1 }), - ); - }); - act(() => { - window.dispatchEvent(new PointerEvent("pointerup", { clientX: 10, clientY: 0, pointerId: 1 })); - }); - // Translation changes position, not normals — rebakeAtlas must not be called. - expect(rebakeSpy).not.toHaveBeenCalled(); - }); - }); }); diff --git a/packages/vue/src/controls/PolyTransformControls.test.ts b/packages/vue/src/controls/PolyTransformControls.test.ts index fcb50bfe..125a9059 100644 --- a/packages/vue/src/controls/PolyTransformControls.test.ts +++ b/packages/vue/src/controls/PolyTransformControls.test.ts @@ -180,19 +180,24 @@ describe("PolyTransformControls (Vue)", () => { expect(arrows.map(axisKeyOf)).toEqual(["y", "-y"]); }); - it("pointerdown on X arrow emits mouseDown + draggingChanged(true); pointerup emits mouseUp + draggingChanged(false)", async () => { + it("translate drag emits lifecycle events, updates position, and does not rebake", async () => { const onMouseDown = vi.fn(); const onMouseUp = vi.fn(); const onDraggingChanged = vi.fn(); + const onObjectChange = vi.fn(); let container!: HTMLElement; + let handle!: PolyMeshHandle | null; await withFakeLayoutAsync(2, async () => { const result = await mount( - { polygons: [TRIANGLE] }, - (meshRef) => ({ object: meshRef, onMouseDown, onMouseUp, onDraggingChanged }), + { polygons: [TRIANGLE], position: [100, 200, 0] }, + (meshRef) => ({ object: meshRef, onMouseDown, onMouseUp, onDraggingChanged, onObjectChange }), ); container = result.container; + handle = result.getMeshHandle(); }); + expect(handle).not.toBeNull(); + const rebakeSpy = vi.spyOn(handle!, "rebakeAtlas"); const xBeam = container.querySelector(".polycss-transform-arrow--x") as HTMLElement; expect(xBeam).not.toBeNull(); @@ -201,37 +206,19 @@ describe("PolyTransformControls (Vue)", () => { xBeam.dispatchEvent( new PointerEvent("pointerdown", { bubbles: true, clientX: 0, clientY: 0, pointerId: 1 }), ); + window.dispatchEvent(new PointerEvent("pointermove", { clientX: 10, clientY: 0, pointerId: 1 })); }); expect(onMouseDown).toHaveBeenCalledOnce(); expect(onDraggingChanged).toHaveBeenLastCalledWith(true); + expect(onObjectChange).toHaveBeenCalledOnce(); + expect(onObjectChange.mock.calls[0][0].position).toEqual([105, 200, 0]); window.dispatchEvent(new PointerEvent("pointerup", { clientX: 10, clientY: 0, pointerId: 1 })); expect(onMouseUp).toHaveBeenCalledOnce(); expect(onDraggingChanged).toHaveBeenLastCalledWith(false); - }); - - it("dragging X axis: pointer (10,0) with cameraScale=2 → position shifts +5 on X", async () => { - const onObjectChange = vi.fn(); - - const { container } = await mount( - { polygons: [TRIANGLE], position: [100, 200, 0] }, - (meshRef) => ({ object: meshRef, onObjectChange }), - ); - - const xBeam = container.querySelector(".polycss-transform-arrow--x") as HTMLElement; - expect(xBeam).not.toBeNull(); - - withFakeLayout(2, () => { - xBeam.dispatchEvent( - new PointerEvent("pointerdown", { bubbles: true, clientX: 0, clientY: 0, pointerId: 1 }), - ); - window.dispatchEvent(new PointerEvent("pointermove", { clientX: 10, clientY: 0, pointerId: 1 })); - }); - - expect(onObjectChange).toHaveBeenCalledOnce(); - expect(onObjectChange.mock.calls[0][0].position).toEqual([105, 200, 0]); + expect(rebakeSpy).not.toHaveBeenCalled(); }); it("dragging Y axis only changes y; perpendicular pointer motion has no effect", async () => { @@ -344,13 +331,26 @@ describe("PolyTransformControls (Vue)", () => { expect(onObjectChange).not.toHaveBeenCalled(); }); - it("dragging Y ring: rotation[1] changes after pointer move", async () => { + it("rotate drag emits lifecycle events, updates rotation, and rebakes on release", async () => { const onObjectChange = vi.fn(); + const onMouseDown = vi.fn(); + const onMouseUp = vi.fn(); + const onDraggingChanged = vi.fn(); - const { container } = await mount( + const { container, getMeshHandle } = await mount( { polygons: [TRIANGLE], rotation: [0, 0, 0] }, - (meshRef) => ({ object: meshRef, mode: "rotate", onObjectChange }), + (meshRef) => ({ + object: meshRef, + mode: "rotate", + onObjectChange, + onMouseDown, + onMouseUp, + onDraggingChanged, + }), ); + const handle = getMeshHandle(); + expect(handle).not.toBeNull(); + const rebakeSpy = vi.spyOn(handle!, "rebakeAtlas"); const yRing = container.querySelector(".polycss-transform-ring--y") as HTMLElement; expect(yRing).not.toBeNull(); @@ -366,12 +366,21 @@ describe("PolyTransformControls (Vue)", () => { window.dispatchEvent(new PointerEvent("pointermove", { clientX: 100, clientY: 10, pointerId: 1 })); }); + expect(onMouseDown).toHaveBeenCalledOnce(); + expect(onDraggingChanged).toHaveBeenLastCalledWith(true); expect(onObjectChange).toHaveBeenCalled(); const event = onObjectChange.mock.calls[0][0]; expect(typeof event.rotation[1]).toBe("number"); expect(event.rotation[1]).not.toBe(0); expect(Math.abs(event.rotation[0])).toBeLessThan(1e-6); expect(Math.abs(event.rotation[2])).toBeLessThan(1e-6); + expect(rebakeSpy).not.toHaveBeenCalled(); + + window.dispatchEvent(new PointerEvent("pointerup", { clientX: 100, clientY: 10, pointerId: 1 })); + + expect(onMouseUp).toHaveBeenCalledOnce(); + expect(onDraggingChanged).toHaveBeenLastCalledWith(false); + expect(rebakeSpy).toHaveBeenCalledOnce(); }); it("dragging Z ring: rotation[2] changes after pointer move", async () => { @@ -425,35 +434,6 @@ describe("PolyTransformControls (Vue)", () => { expect(degrees % 15).toBeCloseTo(0, 5); }); - it("rotate Y ring: pointerdown emits mouseDown + draggingChanged(true); pointerup emits mouseUp + draggingChanged(false)", async () => { - const onMouseDown = vi.fn(); - const onMouseUp = vi.fn(); - const onDraggingChanged = vi.fn(); - - const { container } = await mount( - { polygons: [TRIANGLE] }, - (meshRef) => ({ object: meshRef, mode: "rotate", onMouseDown, onMouseUp, onDraggingChanged }), - ); - - const yRing = container.querySelector(".polycss-transform-ring--y") as HTMLElement; - expect(yRing).not.toBeNull(); - patchRingLeafBboxForDonut(yRing, 100, 0); - - withFakeLayout(2, () => { - yRing.dispatchEvent( - new PointerEvent("pointerdown", { bubbles: true, clientX: 100, clientY: 0, pointerId: 1 }), - ); - }); - - expect(onMouseDown).toHaveBeenCalledOnce(); - expect(onDraggingChanged).toHaveBeenLastCalledWith(true); - - window.dispatchEvent(new PointerEvent("pointerup", { clientX: 0, clientY: 100, pointerId: 1 })); - - expect(onMouseUp).toHaveBeenCalledOnce(); - expect(onDraggingChanged).toHaveBeenLastCalledWith(false); - }); - it("enabled=false: pointerdown on Y ring ignored", async () => { const onMouseDown = vi.fn(); @@ -540,57 +520,6 @@ describe("PolyTransformControls (Vue)", () => { expect(onDraggingChanged).toHaveBeenLastCalledWith(false); }); - it("rebakeAtlas is called exactly once on rotate-ring pointerdown→up", async () => { - const { container, getMeshHandle } = await mount( - { polygons: [TRIANGLE], rotation: [0, 0, 0] }, - (meshRef) => ({ object: meshRef, mode: "rotate" }), - ); - - const handle = getMeshHandle(); - expect(handle).not.toBeNull(); - const rebakeSpy = vi.spyOn(handle!, "rebakeAtlas"); - - const yRing = container.querySelector(".polycss-transform-ring--y") as HTMLElement; - expect(yRing).not.toBeNull(); - patchRingLeafBboxForDonut(yRing, 100, 0); - - withFakeLayout(2, () => { - yRing.dispatchEvent( - new PointerEvent("pointerdown", { bubbles: true, clientX: 100, clientY: 0, pointerId: 1 }), - ); - }); - - expect(rebakeSpy).not.toHaveBeenCalled(); - - window.dispatchEvent(new PointerEvent("pointerup", { clientX: 0, clientY: 100, pointerId: 1 })); - - expect(rebakeSpy).toHaveBeenCalledOnce(); - }); - - it("rebakeAtlas is NOT called after an axis (translate) drag ends", async () => { - const { container, getMeshHandle } = await mount( - { polygons: [TRIANGLE], position: [0, 0, 0] }, - (meshRef) => ({ object: meshRef, mode: "translate" }), - ); - - const handle = getMeshHandle(); - expect(handle).not.toBeNull(); - const rebakeSpy = vi.spyOn(handle!, "rebakeAtlas"); - - const xBeam = container.querySelector(".polycss-transform-arrow--x") as HTMLElement; - expect(xBeam).not.toBeNull(); - - withFakeLayout(2, () => { - xBeam.dispatchEvent( - new PointerEvent("pointerdown", { bubbles: true, clientX: 0, clientY: 0, pointerId: 1 }), - ); - }); - - window.dispatchEvent(new PointerEvent("pointerup", { clientX: 10, clientY: 0, pointerId: 1 })); - - expect(rebakeSpy).not.toHaveBeenCalled(); - }); - it("switching mode from translate to rotate replaces 6 arrows with 3 rings", async () => { const container = document.createElement("div"); document.body.appendChild(container); diff --git a/website/src/components/BuilderWorkbench/BuilderWorkbench.tsx b/website/src/components/BuilderWorkbench/BuilderWorkbench.tsx index 015b4cdc..efc53551 100644 --- a/website/src/components/BuilderWorkbench/BuilderWorkbench.tsx +++ b/website/src/components/BuilderWorkbench/BuilderWorkbench.tsx @@ -170,7 +170,7 @@ export default function BuilderWorkbench() { // tilt. The grid polygons in useSceneRender also consume this so the // floor grid bends with the terrain — there's no separate solid-fill // mesh anymore, the grid IS the terrain. - const { hoverPolygons, vertices: terrainVertices } = useTerrain({ toolMode, targetMode, sceneOptions }); + const { vertices: terrainVertices } = useTerrain({ toolMode, targetMode, sceneOptions }); useCameraShortcuts({ dragMode: sceneOptions.dragMode, updateScene }); @@ -220,8 +220,9 @@ export default function BuilderWorkbench() { useEffect(() => { if (!urlSyncReady) return; + if (gizmoDragging) return; updateBuilderSceneUrl(serializeBuilderSceneToParam(placedItems, sceneOptions)); - }, [placedItems, sceneOptions, urlSyncReady]); + }, [gizmoDragging, placedItems, sceneOptions, urlSyncReady]); // Terrain-follow: when the heightmap changes, re-snap every placed // item to the current surface at its (worldX, worldY). Note: this @@ -493,7 +494,6 @@ export default function BuilderWorkbench() { ambientLight={ambientLight} gridPolygons={gridPolygons} ghostPolygons={[]} - terrainHoverPolygons={hoverPolygons} placementDraft={false} renderItems={renderItems} renderedPolygonsById={renderedPolygonsById} diff --git a/website/src/components/BuilderWorkbench/builder-workbench.css b/website/src/components/BuilderWorkbench/builder-workbench.css index 0315f668..2e467707 100644 --- a/website/src/components/BuilderWorkbench/builder-workbench.css +++ b/website/src/components/BuilderWorkbench/builder-workbench.css @@ -21,6 +21,13 @@ background: #05070b; } +.dn-root:not(.dn-root--gallery) .lil-gui.root { + border-color: #252b36; + box-shadow: none; + backdrop-filter: none; + -webkit-backdrop-filter: none; +} + .builder-tool-ribbon { position: absolute; top: var(--overlay-top); @@ -31,9 +38,9 @@ gap: 4px; padding: 4px; border-radius: 8px; - background: rgba(17, 20, 26, 0.98); - border: 1px solid rgba(255, 255, 255, 0.08); - box-shadow: 0 14px 38px rgba(0, 0, 0, 0.35); + background: #11141a; + border: 1px solid #252b36; + box-shadow: none; } .builder-tool-ribbon__button { @@ -41,8 +48,8 @@ min-width: 64px; border: 0; border-radius: 5px; - background: transparent; - color: rgba(232, 237, 242, 0.72); + background: #11141a; + color: #b8c1ca; cursor: pointer; display: inline-flex; align-items: center; @@ -54,11 +61,6 @@ padding: 0 10px; } -.builder-tool-ribbon__button:hover { - color: #e8edf2; - background: rgba(255, 255, 255, 0.05); -} - .builder-tool-ribbon__button.is-active { color: #071014; background: #22d3ee; @@ -72,7 +74,7 @@ width: 1px; height: 20px; margin: 0 2px; - background: rgba(255, 255, 255, 0.1); + background: #2a3039; } .builder-tool-ribbon__icon { @@ -102,8 +104,9 @@ overflow: clip; z-index: 15; background: #11141a; + border: 1px solid #252b36; border-radius: 8px; - box-shadow: 0 18px 48px rgba(0, 0, 0, 0.45); + box-shadow: none; transform: translateZ(0); backface-visibility: hidden; -webkit-backface-visibility: hidden; @@ -157,7 +160,7 @@ justify-items: center; gap: 6px; padding: 8px 6px; - border: 1px solid rgba(255, 255, 255, 0.06); + border: 1px solid #202938; border-radius: 8px; background: #0b0f18; color: #e2e8f0; @@ -165,23 +168,19 @@ cursor: pointer; } -.shape-picker__item:hover:not(:disabled) { - background: #131a26; - border-color: rgba(125, 211, 252, 0.22); -} - .shape-picker__item.is-active { background: #142033; - border-color: rgba(34, 211, 238, 0.62); + border-color: #22d3ee; } .shape-picker__item:disabled { cursor: wait; - opacity: 0.58; + color: #7f8b97; + background: #0b0f18; } .shape-picker__item.is-active:disabled { - opacity: 1; + color: #e2e8f0; } .shape-picker__item--import { @@ -189,10 +188,6 @@ background: #0d121b; } -.shape-picker__item--import:hover { - background: #141c29; -} - .shape-picker__thumb { width: 65px; height: 65px; @@ -242,11 +237,11 @@ justify-content: space-between; gap: 10px; padding: 8px 2px 0; - border-top: 1px solid rgba(255, 255, 255, 0.07); + border-top: 1px solid #252b36; } .shape-picker__surface-label { - color: rgba(226, 232, 240, 0.74); + color: #b6c0cb; font-size: 12px; font-weight: 700; line-height: 1; @@ -259,9 +254,9 @@ align-items: center; justify-content: center; gap: 6px; - border: 1px solid rgba(255, 255, 255, 0.08); + border: 1px solid #28313d; border-radius: 5px; - background: rgba(255, 255, 255, 0.04); + background: #171c24; color: #e8edf2; cursor: pointer; font: inherit; @@ -270,17 +265,13 @@ padding: 0 9px; } -.shape-picker__surface-button:hover { - background: rgba(255, 255, 255, 0.07); -} - .shape-picker__surface-swatch { width: 13px; height: 13px; box-sizing: border-box; border-radius: 3px; - border: 1px solid rgba(255, 255, 255, 0.22); - box-shadow: inset 0 0 0 1px rgba(0, 0, 0, 0.18); + border: 1px solid #475363; + box-shadow: none; } .shape-picker__surface-swatch.is-gray { @@ -301,40 +292,30 @@ align-items: center; gap: 4px; padding: 4px; - background: rgba(17, 19, 22, 0.85); - border: 1px solid rgba(255, 255, 255, 0.08); + background: #111316; + border: 1px solid #252b36; border-radius: 999px; - backdrop-filter: blur(8px); - -webkit-backdrop-filter: blur(8px); user-select: none; z-index: 5; } .builder-tool-palette__btn { appearance: none; border: 0; - background: transparent; - color: rgba(220, 224, 232, 0.6); + background: #111316; + color: #8c98a6; padding: 4px 12px; font: inherit; font-size: 12px; border-radius: 999px; cursor: pointer; - transition: background-color 120ms ease-out, color 120ms ease-out; -} -.builder-tool-palette__btn:hover { - color: rgba(220, 224, 232, 0.9); - background: rgba(255, 255, 255, 0.04); } .builder-tool-palette__btn.is-active { color: #0a0b0d; - background: rgba(34, 211, 238, 0.9); -} -.builder-tool-palette__btn.is-active:hover { - background: rgba(34, 211, 238, 1); + background: #22d3ee; } /* Target-mode pill — sits below the tool palette so the two pills - read as a paired toolbar. Same glass treatment as the rest. */ + read as a paired toolbar. */ .builder-target-mode { position: absolute; left: 50%; @@ -344,36 +325,26 @@ align-items: center; gap: 4px; padding: 4px; - background: rgba(17, 19, 22, 0.85); - border: 1px solid rgba(255, 255, 255, 0.08); + background: #111316; + border: 1px solid #252b36; border-radius: 999px; - backdrop-filter: blur(8px); - -webkit-backdrop-filter: blur(8px); user-select: none; z-index: 5; } .builder-target-mode__btn { appearance: none; border: 0; - background: transparent; - color: rgba(220, 224, 232, 0.6); + background: #111316; + color: #8c98a6; padding: 3px 10px; font: inherit; font-size: 11px; border-radius: 999px; cursor: pointer; - transition: background-color 120ms ease-out, color 120ms ease-out; -} -.builder-target-mode__btn:hover { - color: rgba(220, 224, 232, 0.9); - background: rgba(255, 255, 255, 0.04); } .builder-target-mode__btn.is-active { color: #0a0b0d; - background: rgba(34, 211, 238, 0.9); -} -.builder-target-mode__btn.is-active:hover { - background: rgba(34, 211, 238, 1); + background: #22d3ee; } /* Floating camera-mode pill — bottom-centre of the builder viewport. @@ -387,68 +358,45 @@ align-items: center; gap: 4px; padding: 4px; - background: rgba(17, 19, 22, 0.85); - border: 1px solid rgba(255, 255, 255, 0.08); + background: #111316; + border: 1px solid #252b36; border-radius: 999px; - backdrop-filter: blur(8px); - -webkit-backdrop-filter: blur(8px); user-select: none; z-index: 5; } .builder-camera-mode__btn { appearance: none; border: 0; - background: transparent; - color: rgba(220, 224, 232, 0.6); + background: #111316; + color: #8c98a6; padding: 4px 12px; font: inherit; font-size: 12px; border-radius: 999px; cursor: pointer; - transition: background-color 120ms ease-out, color 120ms ease-out; -} -.builder-camera-mode__btn:hover { - color: rgba(220, 224, 232, 0.9); - background: rgba(255, 255, 255, 0.04); } .builder-camera-mode__btn.is-active { color: #0a0b0d; - background: rgba(34, 211, 238, 0.9); -} -.builder-camera-mode__btn.is-active:hover { - background: rgba(34, 211, 238, 1); + background: #22d3ee; } .builder-camera-mode__hint { - color: rgba(220, 224, 232, 0.4); + color: #707b87; font-size: 11px; padding: 0 10px 0 6px; - border-left: 1px solid rgba(255, 255, 255, 0.08); + border-left: 1px solid #2a3039; margin-left: 2px; } -/* Terrain hover preview — translucent cyan tile over the cell the next - click will modify. Same opacity caveat as `.builder-ghost`: alpha - lives in the polygon's color (rgba), never on a CSS wrapper. */ -.builder-terrain-hover { - pointer-events: none; -} .builder-ground-fill, -.builder-ground-fill *, -.builder-add-hover, -.builder-add-hover * { +.builder-ground-fill * { pointer-events: none; } .builder-terrain { pointer-events: none; } -/* Ghost mesh — rendered during placement hover. - IMPORTANT: do NOT set `opacity` on this element. CSS opacity creates - a flattened stacking context that breaks transform-style: preserve-3d - on all descendants — every polygon collapses into the XY plane and - you only see the bottom face of the bbox. Bake transparency into the - polygon color (rgba in GHOST_COLOR) instead. Same trap that's - documented at the top of PolyTransformControls.tsx. */ +/* Ghost mesh — rendered during placement preview. Do not set CSS opacity + on this element; it flattens the 3D context. */ .builder-ghost, .builder-selection-wireframe { pointer-events: none; @@ -514,7 +462,7 @@ padding: 4px 12px 6px; font-size: 11px; font-style: italic; - color: rgba(232, 237, 242, 0.45); + color: #7b8794; } .builder-scene-folder__list { @@ -536,12 +484,8 @@ position: relative; } -.builder-scene-folder__row:hover { - background: rgba(255, 255, 255, 0.04); -} - .builder-scene-folder__row.is-selected { - background: rgba(34, 211, 238, 0.12); + background: #12343c; } /* lil-gui forces `.lil-gui button { width: 100% }`, which throws off the @@ -584,8 +528,8 @@ width: 18px; height: 18px; border: 0; - background: transparent; - color: rgba(232, 237, 242, 0.45); + background: #11141a; + color: #7b8794; border-radius: 3px; cursor: pointer; font-size: 14px; @@ -593,20 +537,15 @@ padding: 0; } -.dn-scene-folder-content .builder-scene-folder__remove:hover { - background: rgba(220, 50, 50, 0.6); - color: #fff; -} - .builder-mesh-panel { width: 360px; max-width: 100%; box-sizing: border-box; padding: 10px; border-radius: 8px; - background: rgba(17, 20, 26, 0.98); - border: 1px solid rgba(255, 255, 255, 0.08); - box-shadow: 0 18px 48px rgba(0, 0, 0, 0.35); + background: #11141a; + border: 1px solid #252b36; + box-shadow: none; color: #e8edf2; } @@ -632,7 +571,7 @@ .builder-mesh-panel__name { margin: 0; - color: rgba(232, 237, 242, 0.58); + color: #9aa5b1; font-size: 11px; line-height: 1.2; overflow: hidden; @@ -648,22 +587,17 @@ place-items: center; border: 0; border-radius: 4px; - background: rgba(255, 255, 255, 0.04); - color: rgba(232, 237, 242, 0.64); + background: #171c24; + color: #b8c1ca; cursor: pointer; font-size: 17px; line-height: 1; padding: 0; } -.builder-mesh-panel__delete:hover { - background: rgba(220, 50, 50, 0.65); - color: #fff; -} - .builder-mesh-panel__field { padding: 9px 0; - border-top: 1px solid rgba(255, 255, 255, 0.07); + border-top: 1px solid #252b36; } .builder-mesh-panel__field-row { @@ -675,7 +609,7 @@ } .builder-mesh-panel__label { - color: rgba(232, 237, 242, 0.72); + color: #b8c1ca; font-size: 11px; font-weight: 650; line-height: 1; @@ -685,7 +619,7 @@ width: 74px; height: 26px; box-sizing: border-box; - border: 1px solid rgba(255, 255, 255, 0.08); + border: 1px solid #28313d; border-radius: 4px; background: #090b10; color: #e8edf2; @@ -694,7 +628,7 @@ } .builder-mesh-panel__number:focus { - outline: 1px solid rgba(34, 211, 238, 0.55); + outline: 1px solid #22d3ee; outline-offset: 0; } @@ -702,7 +636,7 @@ width: 42px; height: 28px; box-sizing: border-box; - border: 1px solid rgba(255, 255, 255, 0.1); + border: 1px solid #28313d; border-radius: 4px; background: #090b10; cursor: pointer; @@ -722,7 +656,7 @@ width: 82px; height: 28px; box-sizing: border-box; - border: 1px solid rgba(255, 255, 255, 0.08); + border: 1px solid #28313d; border-radius: 4px; background: #090b10; color: #e8edf2; @@ -732,7 +666,7 @@ } .builder-mesh-panel__color-text:focus { - outline: 1px solid rgba(34, 211, 238, 0.55); + outline: 1px solid #22d3ee; outline-offset: 0; } @@ -754,7 +688,7 @@ height: 26px; border: 0; border-radius: 4px; - background: rgba(34, 211, 238, 0.14); + background: #12343c; color: #dffbff; cursor: pointer; font-size: 15px; @@ -762,13 +696,10 @@ padding: 0; } -.builder-mesh-panel__stepper button:hover:not(:disabled) { - background: rgba(34, 211, 238, 0.24); -} - .builder-mesh-panel__stepper button:disabled { cursor: not-allowed; - opacity: 0.38; + background: #1a2029; + color: #6e7985; } .builder-mesh-panel__stepper output { diff --git a/website/src/components/BuilderWorkbench/components/BuilderScene.tsx b/website/src/components/BuilderWorkbench/components/BuilderScene.tsx index f517057a..d7e350dc 100644 --- a/website/src/components/BuilderWorkbench/components/BuilderScene.tsx +++ b/website/src/components/BuilderWorkbench/components/BuilderScene.tsx @@ -22,14 +22,14 @@ import type { Polygon, Vec3, } from "@layoutit/polycss-react"; -import { useEffect, useMemo, useRef, useState, type RefObject } from "react"; +import { useEffect, useMemo, useRef, type RefObject } from "react"; import { meshResolutionShowsMesh, type SceneOptionsState } from "../../types"; import { FPV_PERSPECTIVE } from "../../fpv"; import { BUILDER_GROUND_SPAN, BUILDER_MAX_CAMERA_ROT_X } from "../defaults"; import { buildSolidWireframePolygons } from "../geometry/ghost"; import { meshBbox } from "../geometry/meshBbox"; import { projectScreenToWorldGround } from "../geometry/screenToWorld"; -import { snapWorldToCellCenter, worldToGridCell } from "../geometry/snap"; +import { snapWorldToCellCenter } from "../geometry/snap"; import type { BuilderToolMode, PlacedItem } from "../types"; import { BuilderCameraDragControls } from "./BuilderCameraDragControls"; @@ -46,9 +46,6 @@ export interface BuilderSceneProps { /** One polygon per visible grid line, terrain-aware when raised. */ gridPolygons: Polygon[]; ghostPolygons: Polygon[]; - /** Single-quad outline showing the vertex the terrain-tool cursor is - * currently over. Empty when no terrain tool is active. */ - terrainHoverPolygons: Polygon[]; placementDraft: boolean; renderItems: Array; renderedPolygonsById: Map; @@ -274,7 +271,6 @@ interface BuilderViewportToolControlsProps { onAddShapeAt: (worldX: number, worldY: number) => void; onRemoveItem: (id: string) => void; onDraggingChanged: (dragging: boolean) => void; - onHoverCellChange: (cell: [number, number] | null) => void; } function BuilderViewportToolControls({ @@ -283,13 +279,11 @@ function BuilderViewportToolControls({ onAddShapeAt, onRemoveItem, onDraggingChanged, - onHoverCellChange, }: BuilderViewportToolControlsProps): null { const { store, cameraElRef } = useCameraContext(); - const stateRef = useRef({ tool, sceneOptions, onAddShapeAt, onRemoveItem, onDraggingChanged, onHoverCellChange }); - stateRef.current = { tool, sceneOptions, onAddShapeAt, onRemoveItem, onDraggingChanged, onHoverCellChange }; + const stateRef = useRef({ tool, sceneOptions, onAddShapeAt, onRemoveItem, onDraggingChanged }); + stateRef.current = { tool, sceneOptions, onAddShapeAt, onRemoveItem, onDraggingChanged }; const downRef = useRef<{ x: number; y: number; target: EventTarget | null } | null>(null); - const hoverCellRef = useRef<[number, number] | null>(null); useEffect(() => { const cameraEl = cameraElRef.current; @@ -315,27 +309,6 @@ function BuilderViewportToolControls({ return snapWorldToCellCenter(hit[0], hit[1], state.sceneOptions.gridResolution); }; - const setHoverCell = (cell: [number, number] | null): void => { - const prev = hoverCellRef.current; - if (prev?.[0] === cell?.[0] && prev?.[1] === cell?.[1]) return; - hoverCellRef.current = cell; - stateRef.current.onHoverCellChange(cell); - }; - - const onPointerMove = (event: PointerEvent): void => { - const state = stateRef.current; - if (state.tool !== "add" || isUiOverlay(event.target)) { - setHoverCell(null); - return; - } - const hit = projectAt(event.clientX, event.clientY); - if (!hit) { - setHoverCell(null); - return; - } - setHoverCell(worldToGridCell(hit[0], hit[1], state.sceneOptions.gridResolution)); - }; - const onPointerDown = (event: PointerEvent): void => { if (stateRef.current.tool === "move") return; if (event.button !== 0 || event.isPrimary === false) return; @@ -370,19 +343,13 @@ function BuilderViewportToolControls({ event.stopImmediatePropagation(); state.onAddShapeAt(hit[0], hit[1]); }; - const onPointerLeave = (): void => setHoverCell(null); cameraEl.addEventListener("pointerdown", onPointerDown, true); - cameraEl.addEventListener("pointermove", onPointerMove, true); - cameraEl.addEventListener("pointerleave", onPointerLeave, true); cameraEl.addEventListener("click", onClick, true); return () => { cameraEl.removeEventListener("pointerdown", onPointerDown, true); - cameraEl.removeEventListener("pointermove", onPointerMove, true); - cameraEl.removeEventListener("pointerleave", onPointerLeave, true); cameraEl.removeEventListener("click", onClick, true); downRef.current = null; - setHoverCell(null); stateRef.current.onDraggingChanged(false); }; }, [cameraElRef, store]); @@ -397,7 +364,6 @@ export function BuilderScene({ ambientLight, gridPolygons, ghostPolygons, - terrainHoverPolygons, placementDraft, renderItems, renderedPolygonsById, @@ -420,7 +386,6 @@ export function BuilderScene({ const perspective = sceneOptions.dragMode === "fpv" ? FPV_PERSPECTIVE : sceneOptions.perspective; const Cam = perspective === false ? PolyOrthographicCamera : PolyPerspectiveCamera; const sceneKey = sceneOptions.meshResolution; - const [addHoverCell, setAddHoverCell] = useState<[number, number] | null>(null); const camProps = perspective === false ? { zoom: sceneOptions.zoom, rotX: sceneOptions.rotX, rotY: sceneOptions.rotY, target: sceneOptions.target } : { @@ -453,26 +418,6 @@ export function BuilderScene({ baseZ: bbox.minZ, }, "#00d9ff", edgeHalf); }, [renderedPolygonsById, sceneOptions.gridResolution, selected]); - const addHoverPolygons = useMemo(() => { - if (!addHoverCell || builderTool !== "add" || !sceneOptions.showGround) return []; - const [cellX, cellY] = addHoverCell; - const cellSize = sceneOptions.gridResolution > 0 ? sceneOptions.gridResolution : 10; - const x0 = cellX * cellSize; - const x1 = (cellX + 1) * cellSize; - const y0 = cellY * cellSize; - const y1 = (cellY + 1) * cellSize; - const z = 0.04; - const color = "rgba(34, 211, 238, 0.22)"; - return [{ - vertices: [ - [x0, y0, z], - [x1, y0, z], - [x1, y1, z], - [x0, y1, z], - ], - color, - }]; - }, [addHoverCell, builderTool, sceneOptions.gridResolution, sceneOptions.showGround]); const groundFillPolygons = useMemo(() => { const half = BUILDER_GROUND_SPAN / 2; const [cx, cy] = sceneOptions.target; @@ -487,10 +432,6 @@ export function BuilderScene({ }]; }, [sceneOptions.gridTone, sceneOptions.target]); - useEffect(() => { - if (builderTool !== "add") setAddHoverCell(null); - }, [builderTool]); - return ( {sceneOptions.dragMode === "pan" ? ( <> @@ -563,6 +503,7 @@ export function BuilderScene({ textureLighting={sceneOptions.textureLighting} textureQuality={sceneOptions.textureQuality} strategies={{ disable: sceneOptions.disableStrategies }} + shadow={{ maxExtend: sceneOptions.shadowMaxExtend }} > {sceneOptions.showGround && ( <> @@ -570,14 +511,6 @@ export function BuilderScene({ )} - {addHoverPolygons.length > 0 && ( - - )} - {/* Terrain hover ghost — small cyan marker over the vertex the - next click will modify. */} - {terrainHoverPolygons.length > 0 && ( - - )} {sceneOptions.showAxes && } {sceneOptions.showLight && ( ([]); const [selectedId, setSelectedId] = useState(null); @@ -109,7 +113,9 @@ export function usePlacements({ meshResolution, gridResolution, onImportError }: ): Promise => { try { const loaded = await loadPresetModel(preset, PARSER_DEFAULTS, effectiveMeshResolution); - const optimized = optimizeMeshPolygons(loaded.rawPolygons, { meshResolution: effectiveMeshResolution }); + const optimized = optimizeMeshPolygons(loaded.rawPolygons, { + meshResolution: effectiveMeshResolution, + }); const bbox = meshBbox(optimized); const targetSize = gridResolution > 0 ? gridResolution : NORMALIZED_MAX_DIM; const fitScale = bbox.span > 0 ? targetSize / bbox.span : 1; @@ -148,7 +154,9 @@ export function usePlacements({ meshResolution, gridResolution, onImportError }: let loaded: Awaited> | null = null; try { loaded = await loadDroppedModel(source, PARSER_DEFAULTS, effectiveMeshResolution); - const optimized = optimizeMeshPolygons(loaded.rawPolygons, { meshResolution: effectiveMeshResolution }); + const optimized = optimizeMeshPolygons(loaded.rawPolygons, { + meshResolution: effectiveMeshResolution, + }); const bbox = meshBbox(optimized); const targetSize = gridResolution > 0 ? gridResolution : NORMALIZED_MAX_DIM; const fitScale = bbox.span > 0 ? targetSize / bbox.span : 1; diff --git a/website/src/components/BuilderWorkbench/hooks/useSceneRender.ts b/website/src/components/BuilderWorkbench/hooks/useSceneRender.ts index 8e600067..d543e243 100644 --- a/website/src/components/BuilderWorkbench/hooks/useSceneRender.ts +++ b/website/src/components/BuilderWorkbench/hooks/useSceneRender.ts @@ -1,4 +1,4 @@ -import { useMemo, type RefObject } from "react"; +import { useMemo, useRef, type RefObject } from "react"; import { optimizeMeshPolygons } from "@layoutit/polycss-react"; import type { PolyFirstPersonControlsHandle, Polygon } from "@layoutit/polycss-react"; import { interiorShellPolygons } from "../../helpers/interiorShell"; @@ -18,6 +18,62 @@ function applySolidColor(polygons: Polygon[], color: string): Polygon[] { return polygons.map((polygon) => ({ ...polygon, color })); } +function hasRawPolygons(item: PlacedItem): item is PlacedItem & { rawPolygons: Polygon[] } { + return item.rawPolygons !== null; +} + +type ActiveMeshResolution = ReturnType; + +interface CachedRenderGeometry { + rawPolygons: Polygon[]; + meshResolution: ActiveMeshResolution; + optimized: Polygon[]; + rendered: Polygon[]; + renderedMode: "source" | "solid"; + renderedColor: string | null; + interiorShell: Polygon[] | null; +} + +function cachedGeometryFor( + cache: Map, + item: PlacedItem & { rawPolygons: Polygon[] }, + meshResolution: ActiveMeshResolution, +): CachedRenderGeometry { + const cached = cache.get(item.id); + if ( + cached?.rawPolygons === item.rawPolygons && + cached.meshResolution === meshResolution + ) { + return cached; + } + + const optimized = optimizeMeshPolygons(item.rawPolygons, { meshResolution }); + const entry: CachedRenderGeometry = { + rawPolygons: item.rawPolygons, + meshResolution, + optimized, + rendered: optimized, + renderedMode: "source", + renderedColor: null, + interiorShell: null, + }; + cache.set(item.id, entry); + return entry; +} + +function renderedPolygonsFor(entry: CachedRenderGeometry, item: PlacedItem): Polygon[] { + const renderedMode = item.colorOverride === false ? "source" : "solid"; + const renderedColor = renderedMode === "solid" ? item.color : null; + if (entry.renderedMode !== renderedMode || entry.renderedColor !== renderedColor) { + entry.rendered = renderedMode === "source" + ? entry.optimized + : applySolidColor(entry.optimized, item.color); + entry.renderedMode = renderedMode; + entry.renderedColor = renderedColor; + } + return entry.rendered; +} + export interface UseSceneRenderOptions { placedItems: PlacedItem[]; selectedId: string | null; @@ -46,37 +102,39 @@ export function useSceneRender({ terrainVertices, }: UseSceneRenderOptions): UseSceneRenderResult { const effectiveMeshResolution = activeMeshResolution(sceneOptions.meshResolution); - const renderedPolygonsById = useMemo(() => { - const out = new Map(); + const geometryCacheRef = useRef(new Map()); + const { renderedPolygonsById, interiorShellPolygonsById } = useMemo(() => { + const cache = geometryCacheRef.current; + const liveIds = new Set(); + const rendered = new Map(); + const interior = new Map(); + for (const it of placedItems) { - if (it.rawPolygons === null) continue; - const optimized = optimizeMeshPolygons(it.rawPolygons, { - meshResolution: effectiveMeshResolution, - }); - out.set(it.id, it.colorOverride === false ? optimized : applySolidColor(optimized, it.color)); + if (!hasRawPolygons(it)) continue; + liveIds.add(it.id); + const entry = cachedGeometryFor(cache, it, effectiveMeshResolution); + rendered.set(it.id, renderedPolygonsFor(entry, it)); + + if (sceneOptions.interiorFill && it.preset.kind !== "vox") { + if (entry.interiorShell === null) { + entry.interiorShell = interiorShellPolygons(entry.optimized); + } + if (entry.interiorShell.length > 0) interior.set(it.id, entry.interiorShell); + } } - return out; - }, [ - placedItems, - effectiveMeshResolution, - ]); - const interiorShellPolygonsById = useMemo(() => { - const out = new Map(); - if (!sceneOptions.interiorFill) return out; - for (const it of placedItems) { - if (it.rawPolygons === null || it.preset.kind === "vox") continue; - const optimized = optimizeMeshPolygons(it.rawPolygons, { - meshResolution: effectiveMeshResolution, - }); - const shell = interiorShellPolygons(optimized); - if (shell.length > 0) out.set(it.id, shell); + for (const id of cache.keys()) { + if (!liveIds.has(id)) cache.delete(id); } - return out; + + return { + renderedPolygonsById: rendered, + interiorShellPolygonsById: interior, + }; }, [ placedItems, - sceneOptions.interiorFill, effectiveMeshResolution, + sceneOptions.interiorFill, ]); // World-space polygons for FPV bbox sampling. `useFpvHost` only reads @@ -116,9 +174,7 @@ export function useSceneRender({ }); const renderItems = useMemo(() => { - const loaded = placedItems.filter( - (it): it is PlacedItem & { rawPolygons: Polygon[] } => it.rawPolygons !== null, - ); + const loaded = placedItems.filter(hasRawPolygons); return visibleIds === null ? loaded : loaded.filter((it) => visibleIds.has(it.id)); }, [placedItems, visibleIds]); diff --git a/website/src/components/BuilderWorkbench/hooks/useTerrain.ts b/website/src/components/BuilderWorkbench/hooks/useTerrain.ts index 06db9e1b..d03a4f45 100644 --- a/website/src/components/BuilderWorkbench/hooks/useTerrain.ts +++ b/website/src/components/BuilderWorkbench/hooks/useTerrain.ts @@ -2,10 +2,8 @@ * Terrain editor state + viewport pointer capture. * * When `toolMode` is anything other than "pointer", the user is editing - * the heightmap rather than placing meshes. We capture pointermove (to - * update the hover ghost) and click (to apply the active tool) on the - * viewport in CAPTURE phase so orbit drag / mesh selection don't - * double-fire. + * the heightmap rather than placing meshes. We capture click on the + * viewport in CAPTURE phase so orbit drag / mesh selection don't double-fire. * * Heightmap is VERTEX-based: clicks snap to the nearest grid vertex * and raise / lower / smooth that vertex. The 4 cells touching the @@ -13,17 +11,14 @@ * surrounding terrain reads as a smooth warp instead of a stamped * box. See `geometry/terrain.ts` for the rendering model. */ -import { useCallback, useEffect, useMemo, useRef, useState } from "react"; -import type { Polygon } from "@layoutit/polycss-react"; +import { useCallback, useEffect, useRef, useState } from "react"; import type { SceneOptionsState } from "../../types"; import type { TargetMode, ToolMode } from "../types"; import { projectScreenToWorldGround } from "../geometry/screenToWorld"; import { - buildHoverGhostPolygons, vertexKey, worldToCell, worldToVertex, - type HoverTarget, type TerrainVertices, } from "../geometry/terrain"; @@ -44,15 +39,14 @@ export interface UseTerrainResult { * to build the warped grid, and by BuilderWorkbench shape placement * to land meshes on top of the terrain with the local slope tilt. */ vertices: TerrainVertices; - /** Polygons for the hover vertex marker (empty when not editing). */ - hoverPolygons: Polygon[]; } +type TerrainEditTarget = + | { kind: "vertex"; i: number; j: number } + | { kind: "face"; i: number; j: number }; + export function useTerrain({ toolMode, targetMode, sceneOptions }: UseTerrainOptions): UseTerrainResult { const [vertices, setVertices] = useState(() => new Map()); - // Single hover target descriptor that captures vertex OR face — the - // hover-ghost builder picks the right rendering off this discriminator. - const [hoverTarget, setHoverTarget] = useState(null); // Pointerdown coords for drag-vs-click discrimination. Kept in a ref // so they survive useEffect re-runs (sceneOptions changes between @@ -66,7 +60,7 @@ export function useTerrain({ toolMode, targetMode, sceneOptions }: UseTerrainOpt // mode) or all 4 corners of a face (face mode). Tiny residuals are // dropped so vertices returning to flat leave the sparse map. const applyTool = useCallback( - (target: HoverTarget): void => { + (target: TerrainEditTarget): void => { setVertices((prev) => { const next = new Map(prev); @@ -121,10 +115,7 @@ export function useTerrain({ toolMode, targetMode, sceneOptions }: UseTerrainOpt // active. Capture phase + stopPropagation keeps the click out of // orbit drag / mesh selection. useEffect(() => { - if (toolMode === "pointer") { - setHoverTarget(null); - return; - } + if (toolMode === "pointer") return; const viewport = document.querySelector(".dn-viewport") as HTMLElement | null; const cameraEl = document.querySelector(".polycss-camera") as HTMLElement | null; if (!viewport || !cameraEl) return; @@ -162,7 +153,7 @@ export function useTerrain({ toolMode, targetMode, sceneOptions }: UseTerrainOpt downRef.current = { x: e.clientX, y: e.clientY }; }; - const worldToTarget = (world: [number, number]): HoverTarget => { + const worldToTarget = (world: [number, number]): TerrainEditTarget => { if (targetMode === "face") { const [i, j] = worldToCell(world[0], world[1], cellSize); return { kind: "face", i, j }; @@ -171,15 +162,6 @@ export function useTerrain({ toolMode, targetMode, sceneOptions }: UseTerrainOpt return { kind: "vertex", i, j }; }; - const onMove = (e: PointerEvent) => { - if (isUiOverlay(e.target)) return; - const world = projectAt(e.clientX, e.clientY); - if (!world) return; - const next = worldToTarget(world); - setHoverTarget((prev) => - prev && prev.kind === next.kind && prev.i === next.i && prev.j === next.j ? prev : next, - ); - }; const onClick = (e: MouseEvent) => { if (isUiOverlay(e.target)) return; const dx = e.clientX - downRef.current.x; @@ -193,24 +175,14 @@ export function useTerrain({ toolMode, targetMode, sceneOptions }: UseTerrainOpt e.stopPropagation(); applyTool(worldToTarget(world)); }; - const onLeave = () => setHoverTarget(null); viewport.addEventListener("pointerdown", onDown, true); - viewport.addEventListener("pointermove", onMove, true); - viewport.addEventListener("pointerleave", onLeave, true); viewport.addEventListener("click", onClick, true); return () => { viewport.removeEventListener("pointerdown", onDown, true); - viewport.removeEventListener("pointermove", onMove, true); - viewport.removeEventListener("pointerleave", onLeave, true); viewport.removeEventListener("click", onClick, true); }; }, [toolMode, targetMode, sceneOptions, cellSize, applyTool]); - const hoverPolygons = useMemo(() => { - if (toolMode === "pointer" || !hoverTarget) return []; - return buildHoverGhostPolygons({ target: hoverTarget, cellSize, vertices }); - }, [toolMode, hoverTarget, vertices, cellSize]); - - return { vertices, hoverPolygons }; + return { vertices }; } diff --git a/website/src/components/BuilderWorkbench/shapePresets.ts b/website/src/components/BuilderWorkbench/shapePresets.ts index e4bf4d10..2dd2819a 100644 --- a/website/src/components/BuilderWorkbench/shapePresets.ts +++ b/website/src/components/BuilderWorkbench/shapePresets.ts @@ -42,30 +42,30 @@ function primitiveShapePreset( export const BUILDER_SHAPE_PRESETS: BuilderShapePreset[] = [ primitiveShapePreset("builder-shape-box", "Box", "#ff7043", () => - boxPolygons({ size: 100, color: "#ff7043" }), + boxPolygons({ size: 10, color: "#ff7043" }), ), primitiveShapePreset("builder-shape-octahedron", "Octahedron", "#f59e0b", () => - octahedronPolygons({ center: [0, 0, 0], size: 84, color: "#f59e0b" }), + octahedronPolygons({ center: [0, 0, 0], size: 8.4, color: "#f59e0b" }), ), primitiveShapePreset("builder-shape-sphere", "Sphere", "#60a5fa", () => - spherePolygons({ radius: 50, subdivisions: 1, color: "#60a5fa" }), + spherePolygons({ radius: 5, subdivisions: 1, color: "#60a5fa" }), ), primitiveShapePreset("builder-shape-tetrahedron", "Tetrahedron", "#f472b6", () => - tetrahedronPolygons({ size: 86, color: "#f472b6" }), + tetrahedronPolygons({ size: 8.6, color: "#f472b6" }), ), primitiveShapePreset("builder-shape-icosahedron", "Icosahedron", "#c084fc", () => - icosahedronPolygons({ size: 84, color: "#c084fc" }), + icosahedronPolygons({ size: 8.4, color: "#c084fc" }), ), primitiveShapePreset("builder-shape-dodecahedron", "Dodecahed.", "#34d399", () => - dodecahedronPolygons({ size: 84, color: "#34d399" }), + dodecahedronPolygons({ size: 8.4, color: "#34d399" }), ), primitiveShapePreset("builder-shape-cylinder", "Cylinder", "#22d3ee", () => - cylinderPolygons({ radius: 42, height: 100, radialSegments: 12, color: "#22d3ee" }), + cylinderPolygons({ radius: 4.2, height: 10, radialSegments: 12, color: "#22d3ee" }), ), primitiveShapePreset("builder-shape-cone", "Cone", "#fb7185", () => - conePolygons({ radius: 50, height: 100, radialSegments: 12, color: "#fb7185" }), + conePolygons({ radius: 5, height: 10, radialSegments: 12, color: "#fb7185" }), ), primitiveShapePreset("builder-shape-torus", "Torus", "#facc15", () => - torusPolygons({ radius: 48, tube: 14, radialSegments: 12, tubularSegments: 16, color: "#facc15" }), + torusPolygons({ radius: 4.8, tube: 1.4, radialSegments: 12, tubularSegments: 16, color: "#facc15" }), ), ]; diff --git a/website/src/components/BuilderWorkbench/types.ts b/website/src/components/BuilderWorkbench/types.ts index 7a7c8a2d..1b94e841 100644 --- a/website/src/components/BuilderWorkbench/types.ts +++ b/website/src/components/BuilderWorkbench/types.ts @@ -35,7 +35,7 @@ export interface PlacedItem { worldY: number; } -/** Transient state while the user is hovering a preset over the floor. */ +/** Transient state while the user is positioning a preset over the floor. */ export interface PlacementDraft { preset: PresetModel; rawPolygons: Polygon[]; diff --git a/website/src/components/Dock/folders/useRenderingFolder.ts b/website/src/components/Dock/folders/useRenderingFolder.ts index b56a3a0b..75d3e9a2 100644 --- a/website/src/components/Dock/folders/useRenderingFolder.ts +++ b/website/src/components/Dock/folders/useRenderingFolder.ts @@ -92,7 +92,6 @@ export function useRenderingFolder(parent: GUI | null, inputs: RenderingFolderIn const interiorFillCtrl = useToggle(folder, "Interior fill", interiorFill, (value) => onUpdateScene({ interiorFill: value }), ); - const textureMode = textureModeFor(solidMaterials, textureLighting); const textureModeCtrl = useOption( folder, diff --git a/website/src/components/GalleryWorkbench/GalleryWorkbench.tsx b/website/src/components/GalleryWorkbench/GalleryWorkbench.tsx index dad6f8e5..aa2c2e5d 100644 --- a/website/src/components/GalleryWorkbench/GalleryWorkbench.tsx +++ b/website/src/components/GalleryWorkbench/GalleryWorkbench.tsx @@ -1132,6 +1132,7 @@ export default function GalleryWorkbench() { sceneOptions.textureLighting, sceneOptions.textureQuality, sceneOptions.solidMaterials ? "solid-materials" : "authored-materials", + sceneOptions.meshResolution, sceneOptions.interiorFill ? "interior-fill" : "no-interior-fill", sceneOptions.autoCenter, sceneOptions.perspective === false ? "none" : sceneOptions.perspective, @@ -1144,6 +1145,7 @@ export default function GalleryWorkbench() { sceneOptions.textureLighting, sceneOptions.textureQuality, sceneOptions.solidMaterials, + sceneOptions.meshResolution, sceneOptions.interiorFill, sceneOptions.autoCenter, sceneOptions.perspective, diff --git a/website/src/components/StatsOverlay/StatsOverlay.tsx b/website/src/components/StatsOverlay/StatsOverlay.tsx index 04758cd5..cd3f53f3 100644 --- a/website/src/components/StatsOverlay/StatsOverlay.tsx +++ b/website/src/components/StatsOverlay/StatsOverlay.tsx @@ -48,7 +48,8 @@ export function StatsOverlay(): null { statsContainer.style.display = "flex"; statsContainer.style.alignItems = "flex-end"; statsContainer.style.gap = "2px"; - statsContainer.style.opacity = "0.9"; + statsContainer.style.background = "#000"; + statsContainer.style.opacity = "1"; statsContainer.style.pointerEvents = "none"; fpsPanel = new Stats.Panel("FPS", "#0ff", "#002"); diff --git a/website/src/content/docs/api/headless.mdx b/website/src/content/docs/api/headless.mdx index fd22f6a4..5685baa7 100644 --- a/website/src/content/docs/api/headless.mdx +++ b/website/src/content/docs/api/headless.mdx @@ -145,7 +145,7 @@ console.log(`${polygons.length} polygons → ${merged.length} after merge`); ## `optimizeMeshPolygons(polygons, options?)` -Runs the shared mesh-resolution optimizer. It defaults to `meshResolution: "lossy"`. `meshResolution: "lossless"` uses exact candidates; `"lossy"` also tries bounded approximate merge candidates and applies bounded seam repair, so it can add a small number of triangles back to reduce visible crack risk. +Runs the shared mesh-resolution optimizer. It defaults to `meshResolution: "lossy"`. `meshResolution: "lossless"` uses exact candidates; `"lossy"` also tries bounded approximate merge candidates and chooses the lowest estimated DOM render cost. Wider lossy candidates are accepted only when they clear a minimum render-cost win and do not worsen whole-mesh seam diagnostics. ```ts import { optimizeMeshPolygons } from "@layoutit/polycss-core"; diff --git a/website/src/content/docs/api/types.mdx b/website/src/content/docs/api/types.mdx index 882fffee..7b7366b5 100644 --- a/website/src/content/docs/api/types.mdx +++ b/website/src/content/docs/api/types.mdx @@ -305,7 +305,7 @@ interface LoadMeshOptions { voxOptions?: VoxParseOptions; /** Convert uniform texture-backed faces into solid-color polygons before optimization. */ solidTextureSamples?: boolean | SolidTextureSampleOptions; - /** Shared mesh-resolution optimizer. Defaults to "lossy", including bounded seam repair. */ + /** Shared mesh-resolution optimizer. Defaults to "lossy". */ meshResolution?: "lossless" | "lossy"; } ``` diff --git a/website/src/content/docs/core-concepts.mdx b/website/src/content/docs/core-concepts.mdx index c85757f7..391cf7af 100644 --- a/website/src/content/docs/core-concepts.mdx +++ b/website/src/content/docs/core-concepts.mdx @@ -120,7 +120,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 merge near-coplanar candidates within a bounded displacement budget and then spend a small repair budget on high-risk seams. 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 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. 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 bca54bb1..e2d262be 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, can use bounded geometric approximation, and may add a small repair split budget to reduce high-risk visible seams. `meshResolution: "lossless"` keeps exact planar candidates only. +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. 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.