diff --git a/AGENTS.md b/AGENTS.md index 7eb8460d..47828261 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -39,7 +39,7 @@ Voxel-shaped meshes are the exception to "all polygons stay mounted": meshes wit Strategies are ordered cheapest → most expensive. The mesher's job is to maximise `` / `` / `` and minimise `` (see "Meshing implications" below). -Callers can opt out of specific strategies via `strategies: { disable: ["b" | "i" | "u"] }` on `RenderTextureAtlasOptions`. Disabled or unsupported strategies fall through the chain (`b → i → s`, `u → i → s`, `i → s`). Disabling `"i"` also disables the exact corner-shape solid branch even though that branch emits a bare ``, because it belongs to the non-triangle clipped-solid family. `` is the universal fallback and cannot be disabled. +Callers can opt out of specific strategies via `strategies: { disable: ["b" | "i" | "u"] }` on `RenderTextureAtlasOptions`. Disabled or unsupported strategies fall through the chain (`b → i → s`, `u → i → s`, `i → s`). Disabling `"i"` also disables the exact corner-shape solid branch even though that branch emits a bare ``, because it belongs to the non-triangle clipped-solid family. `` is the universal fallback and cannot be disabled. Solid seam bleed defaults to `1.5` CSS px on detected shared edges; callers can set `seamBleed`, where `"auto"` computes a fitted amount from each polygon plan and numeric values clamp the per-side CSS-pixel overscan. The renderer applies bleed only to detected shared seam edges of solid primitives, rather than inflating every side of each participating polygon. The `.vox` fast path emits plain `` elements directly inside the mesh wrapper. They intentionally reuse the cheap quad tag, but they are exact voxel quads with one `matrix3d(...)` per visible quad, ordered by projected tile4 scanline order. Desktop-class documents use a canonical 1px primitive for the cheapest transform shape; mobile-class documents (`pointer: coarse` or `hover: none`) use an 8px primitive and divide the in-plane matrix scale by 8 to preserve identical CSS-space geometry while avoiding large GPU filtering gaps. @@ -53,6 +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. - **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/README.md b/README.md index 81cca276..4a722b51 100644 --- a/README.md +++ b/README.md @@ -71,6 +71,7 @@ export default function App() { - `directionalLight` and `ambientLight` control scene lighting. - `textureLighting` chooses `"baked"` or `"dynamic"`. - `textureQuality` controls atlas raster budget. +- Seam bleed defaults to `1.5` CSS px on detected shared solid edges; `seamBleed="auto"` fits the amount from each polygon plan. - `strategies` can disable selected render strategies for diagnostics. - `autoCenter` rotates around the rendered mesh bounds instead of world origin. @@ -160,7 +161,7 @@ polycss renders in the DOM, so performance is mostly determined by how many poly - Textured polygons are packed into generated texture atlases. - Dynamic lighting runs through CSS custom properties instead of per-frame JavaScript. - Voxel-shaped meshes mount only camera-facing leaves when the mesh is eligible. -- `meshResolution: "lossy"` can merge compatible polygons to reduce DOM node count. +- `meshResolution: "lossy"` merges compatible polygons, then may spend a small split budget to repair high-risk seams. ## Packages diff --git a/bench/seam-gap-bench.mjs b/bench/seam-gap-bench.mjs new file mode 100644 index 00000000..08e7d403 --- /dev/null +++ b/bench/seam-gap-bench.mjs @@ -0,0 +1,845 @@ +#!/usr/bin/env node +import { readFileSync, writeFileSync } from "node:fs"; +import { createRequire } from "node:module"; +import { dirname, relative, resolve } from "node:path"; +import { fileURLToPath, pathToFileURL } from "node:url"; +import { transform } from "esbuild"; +import { + optimizeMeshPolygons, + parseGltf, + parseMtl, + parseObj, +} from "../packages/core/dist/index.js"; + +const repoRoot = resolve(dirname(fileURLToPath(import.meta.url)), ".."); +const requireFromWebsite = createRequire(resolve(repoRoot, "website/package.json")); +const DEFAULT_AMOUNT_PX = 2; +const DEFAULT_LIMIT = 6; +const DEFAULT_RENDER_URL = "http://localhost:4322/gallery/"; +const BASE_TILE = 50; + +const FIXTURES = [ + { + id: "chicken", + label: "Chicken", + kind: "obj", + file: "website/public/gallery/obj/chicken.obj", + mtl: "website/public/gallery/obj/chicken.mtl", + options: { targetSize: 60, gridShift: 1, defaultColor: "#cccccc" }, + }, + { + id: "bear", + label: "Bear", + kind: "glb", + file: "website/public/gallery/glb/Bear.glb", + options: { targetSize: 60, gridShift: 1, defaultColor: "#8b95a1" }, + }, + { + id: "cheetah", + label: "Cheetah", + kind: "glb", + file: "website/public/gallery/glb/Cheetah.glb", + options: { targetSize: 60, gridShift: 1, defaultColor: "#8b95a1" }, + }, + { + id: "apple", + label: "Apple", + kind: "glb", + file: "website/public/gallery/glb/apple.glb", + options: { targetSize: 60, gridShift: 1, defaultColor: "#8b95a1" }, + }, + { + id: "atm", + label: "ATM", + kind: "glb", + file: "website/public/gallery/glb/urban/ATM.glb", + options: { targetSize: 60, gridShift: 1, defaultColor: "#8b95a1" }, + }, + { + id: "dog", + label: "Dog", + kind: "glb", + file: "website/public/gallery/glb/Dog.glb", + options: { targetSize: 60, gridShift: 1, defaultColor: "#8b95a1" }, + }, + { + id: "bags", + label: "Bags", + kind: "glb", + file: "website/public/gallery/glb/medieval/Bags.glb", + options: { targetSize: 60, gridShift: 1, defaultColor: "#8b95a1" }, + }, +]; + +const EXPECTED = { + chicken: { beforeTrueGaps: 2, afterTrueGaps: 0, changedPolygons: 4 }, + bear: { beforeTrueGaps: 0, afterTrueGaps: 0, changedPolygons: 0 }, + cheetah: { beforeTrueGaps: 0, afterTrueGaps: 0, changedPolygons: 0 }, +}; + +function parseArgs(argv) { + const args = { + amountPx: DEFAULT_AMOUNT_PX, + limit: DEFAULT_LIMIT, + models: null, + json: "", + assert: true, + render: false, + renderSplit: false, + renderUrl: DEFAULT_RENDER_URL, + help: false, + }; + + for (let i = 0; i < argv.length; i += 1) { + const arg = argv[i]; + if (arg === "--") continue; + if (arg === "--help" || arg === "-h") args.help = true; + else if (arg === "--amount") args.amountPx = Number(argv[++i]); + else if (arg.startsWith("--amount=")) args.amountPx = Number(arg.slice("--amount=".length)); + else if (arg === "--limit") args.limit = Number(argv[++i]); + else if (arg.startsWith("--limit=")) args.limit = Number(arg.slice("--limit=".length)); + else if (arg === "--models") args.models = splitList(argv[++i]); + else if (arg.startsWith("--models=")) args.models = splitList(arg.slice("--models=".length)); + else if (arg === "--json") args.json = argv[++i] ?? ""; + else if (arg.startsWith("--json=")) args.json = arg.slice("--json=".length); + else if (arg === "--render") args.render = true; + else if (arg === "--render-split") args.renderSplit = true; + else if (arg === "--render-url") args.renderUrl = argv[++i] ?? DEFAULT_RENDER_URL; + else if (arg.startsWith("--render-url=")) args.renderUrl = arg.slice("--render-url=".length); + else if (arg === "--no-assert") args.assert = false; + else throw new Error(`Unknown option ${arg}`); + } + + if (!Number.isFinite(args.amountPx) || args.amountPx < 0) { + throw new Error("--amount must be a non-negative number"); + } + args.limit = Number.isFinite(args.limit) ? Math.max(0, Math.floor(args.limit)) : DEFAULT_LIMIT; + return args; +} + +function splitList(value = "") { + return value.split(",").map((item) => item.trim().toLowerCase()).filter(Boolean); +} + +function printHelp() { + console.log(`Usage: node bench/seam-gap-bench.mjs [--models chicken,cheetah,bear] [--json file] + +Measures object-space seam gap candidates before and after the current seam +repair helper. It separates real disconnected same-material gaps from connected +facet edges and material-boundary false positives. + +Options: + --amount Repair request in CSS px. Default: ${DEFAULT_AMOUNT_PX} + --models Comma-separated fixture ids/labels. Default: chicken,bear,cheetah + --limit Candidate rows to print per model. Default: ${DEFAULT_LIMIT} + --json Write full machine-readable rows. + --render Also measure rendered exact-edge cracks in a browser. + --render-split Alias for --render; the gallery now always uses the default seam path. + --render-url Gallery URL for --render. Default: ${DEFAULT_RENDER_URL} + --no-assert Do not fail when fixture expectations drift. +`); +} + +function readBytes(path) { + const bytes = readFileSync(path); + return bytes.buffer.slice(bytes.byteOffset, bytes.byteOffset + bytes.byteLength); +} + +async function loadSeamHelper() { + const sourcePath = resolve(repoRoot, "packages/core/src/merge/seamRepair.ts"); + const source = readFileSync(sourcePath, "utf8"); + const { code } = await transform(source, { + loader: "ts", + format: "esm", + target: "node22", + }); + return import(`data:text/javascript;base64,${Buffer.from(code).toString("base64")}`); +} + +function selectedFixtures(models) { + if (!models?.length) return FIXTURES; + return FIXTURES.filter((fixture) => { + const haystack = `${fixture.id} ${fixture.label}`.toLowerCase(); + return models.some((needle) => haystack.includes(needle)); + }); +} + +function loadFixture(fixture) { + const file = resolve(repoRoot, fixture.file); + if (fixture.kind === "obj") { + const objText = readFileSync(file, "utf8"); + const mtl = fixture.mtl + ? parseMtl(readFileSync(resolve(repoRoot, fixture.mtl), "utf8")) + : { colors: {}, textures: {} }; + return parseObj(objText, { + ...fixture.options, + materialColors: mtl.colors, + }).polygons; + } + + return parseGltf(readBytes(file), { + ...fixture.options, + baseUrl: pathToFileURL(file).href, + }).polygons; +} + +function cssPoint(vertex) { + return [vertex[1] * BASE_TILE, vertex[0] * BASE_TILE, vertex[2] * BASE_TILE]; +} + +function pointKey(point) { + return `${point[0]},${point[1]},${point[2]}`; +} + +function edgeKey(a, b) { + const ak = pointKey(a); + const bk = pointKey(b); + return ak < bk ? `${ak}|${bk}` : `${bk}|${ak}`; +} + +function materialKey(polygon) { + return polygon.material?.key ?? polygon.color ?? ""; +} + +function hasTexture(polygon) { + return !!(polygon.texture || polygon.material?.texture || polygon.textureTriangles?.length); +} + +function exactSharedSameMaterialEdges(polygons) { + const ownersByKey = new Map(); + for (let polygonIndex = 0; polygonIndex < polygons.length; polygonIndex += 1) { + const polygon = polygons[polygonIndex]; + if (hasTexture(polygon)) continue; + const points = polygon.vertices.map(cssPoint); + for (let edgeIndex = 0; edgeIndex < points.length; edgeIndex += 1) { + const a = points[edgeIndex]; + const b = points[(edgeIndex + 1) % points.length]; + const key = edgeKey(a, b); + const owners = ownersByKey.get(key); + const owner = { + polygon: polygonIndex, + edge: edgeIndex, + color: polygon.color, + materialKey: materialKey(polygon), + a, + b, + }; + if (owners) owners.push(owner); + else ownersByKey.set(key, [owner]); + } + } + + const edges = []; + for (const owners of ownersByKey.values()) { + if (owners.length !== 2) continue; + const [a, b] = owners; + if (a.materialKey !== b.materialKey || a.color !== b.color) continue; + edges.push({ + aPolygon: a.polygon, + aEdge: a.edge, + bPolygon: b.polygon, + bEdge: b.edge, + color: a.color, + materialKey: a.materialKey, + p0: a.a, + p1: a.b, + }); + } + return edges; +} + +function edgePairPlanKey(aPolygon, aEdge, bPolygon, bEdge) { + const a = `${aPolygon}:${aEdge}`; + const b = `${bPolygon}:${bEdge}`; + return a < b ? `${a}|${b}` : `${b}|${a}`; +} + +function splitPlanLookup(report) { + const out = new Map(); + for (const candidate of report?.candidates ?? []) { + out.set( + edgePairPlanKey(candidate.aPolygon, candidate.aEdge, candidate.bPolygon, candidate.bEdge), + candidate, + ); + } + return out; +} + +function countChangedPolygons(before, after) { + let changed = 0; + for (let i = 0; i < before.length; i += 1) { + if (before[i] !== after[i]) changed += 1; + } + return changed; +} + +function vertexDelta(before, after) { + let added = 0; + let removed = 0; + for (let i = 0; i < before.length; i += 1) { + const delta = (after[i]?.vertices.length ?? 0) - before[i].vertices.length; + if (delta > 0) added += delta; + else removed -= delta; + } + return { added, removed }; +} + +function candidatesByKind(report, kind) { + return report.candidates.filter((candidate) => candidate.kind === kind); +} + +function summarizeReport(report) { + const trueGaps = candidatesByKind(report, "true-gap"); + const connectedFacets = candidatesByKind(report, "connected-facet"); + const materialBoundaries = candidatesByKind(report, "material-boundary"); + return { + exactPairs: report.diagnostics.exactPairs, + trueGapPairs: trueGaps.length, + connectedFacetPairs: connectedFacets.length, + materialBoundaryPairs: materialBoundaries.length, + maxTrueGapPx: maxOf(trueGaps, "gapPx"), + maxResidualGapPx: maxOf(trueGaps, "residualGapPx"), + maxAppliedClosurePx: maxOf(trueGaps, "appliedClosurePx"), + trueGapSpanPx: sumOf(trueGaps, "spanPx"), + }; +} + +function maxOf(items, key) { + return items.reduce((max, item) => Math.max(max, item[key] ?? 0), 0); +} + +function sumOf(items, key) { + return items.reduce((sum, item) => sum + (item[key] ?? 0), 0); +} + +function analyzeFixture(fixture, seam, amountPx, limit) { + const raw = loadFixture(fixture); + const optimized = optimizeMeshPolygons(raw, { meshResolution: "lossless" }); + const baselineReport = seam.seamOverlapReport(optimized, amountPx); + const repaired = seam.repairMeshSeams + ? seam.repairMeshSeams(optimized) + : seam.seamOverlapPolygons(seam.seamFacetSplitPolygons(optimized, amountPx), amountPx); + const repairedReport = seam.seamOverlapReport(repaired, amountPx); + const changedPolygons = countChangedPolygons(optimized, repaired); + const vertices = vertexDelta(optimized, repaired); + + const baseline = summarizeReport(baselineReport); + const after = summarizeReport(repairedReport); + return { + id: fixture.id, + label: fixture.label, + file: relative(repoRoot, resolve(repoRoot, fixture.file)), + sourcePolygons: raw.length, + polygons: optimized.length, + changedPolygons, + vertexDelta: vertices, + baseline, + after, + topTrueGaps: topCandidates(candidatesByKind(baselineReport, "true-gap"), limit), + topConnectedFacets: topCandidates(candidatesByKind(baselineReport, "connected-facet"), limit), + topMaterialBoundaries: topCandidates(candidatesByKind(baselineReport, "material-boundary"), limit), + }; +} + +function topCandidates(candidates, limit) { + return [...candidates] + .sort((a, b) => b.gapPx - a.gapPx || b.spanPx - a.spanPx) + .slice(0, limit) + .map((candidate) => ({ + kind: candidate.kind, + gapPx: round(candidate.gapPx), + spanPx: round(candidate.spanPx), + appliedClosurePx: round(candidate.appliedClosurePx), + residualGapPx: round(candidate.residualGapPx), + a: `${candidate.aPolygon}:${candidate.aEdge}`, + b: `${candidate.bPolygon}:${candidate.bEdge}`, + colors: candidate.aColor === candidate.bColor + ? candidate.aColor ?? "" + : `${candidate.aColor ?? ""}/${candidate.bColor ?? ""}`, + })); +} + +function round(value, digits = 3) { + return Number.isFinite(value) ? Number(value.toFixed(digits)) : 0; +} + +function fmt(value, digits = 2) { + return Number.isFinite(value) ? value.toFixed(digits) : "0.00"; +} + +function printReport(rows, amountPx, limit, failures) { + console.log("# Seam Gap Bench\n"); + console.log(`Repair amount: ${fmt(amountPx, 2)} CSS px`); + console.log("Coordinates are measured in the same CSS-space units the renderer uses for polygon plans.\n"); + console.log("| Model | Polys | Exact shared | True gaps before -> after | Max true gap px | Rejects connected/material | Changed polys | Added verts |"); + console.log("| --- | ---: | ---: | ---: | ---: | ---: | ---: | ---: |"); + for (const row of rows) { + console.log([ + `| ${row.label}`, + row.polygons, + row.baseline.exactPairs, + `${row.baseline.trueGapPairs} -> ${row.after.trueGapPairs}`, + `${fmt(row.baseline.maxTrueGapPx)} -> ${fmt(row.after.maxTrueGapPx)}`, + `${row.baseline.connectedFacetPairs}/${row.baseline.materialBoundaryPairs}`, + row.changedPolygons, + `${row.vertexDelta.added} |`, + ].join(" | ")); + } + + for (const row of rows) { + console.log(`\n## ${row.label}`); + printCandidateSection("True gaps", row.topTrueGaps, limit); + printCandidateSection("Connected facet rejects", row.topConnectedFacets, limit); + printCandidateSection("Material-boundary rejects", row.topMaterialBoundaries, limit); + } + + if (failures.length > 0) { + console.log("\n## Assertion Failures"); + for (const failure of failures) console.log(`- ${failure}`); + } +} + +function printCandidateSection(title, candidates, limit) { + if (limit === 0) return; + console.log(`\n${title}:`); + if (candidates.length === 0) { + console.log("- none"); + return; + } + for (const candidate of candidates) { + console.log( + `- ${candidate.a} <-> ${candidate.b} gap=${fmt(candidate.gapPx)}px span=${fmt(candidate.spanPx)}px ` + + `applied=${fmt(candidate.appliedClosurePx)}px residual=${fmt(candidate.residualGapPx)}px color=${candidate.colors}`, + ); + } +} + +async function runRenderBench(fixtures, seam, amountPx, renderUrl, limit, mode = "bleed") { + const { chromium } = await import("playwright"); + const browser = await chromium.launch({ headless: true }); + try { + const rows = []; + for (const fixture of fixtures) { + rows.push(await measureRenderedFixture(browser, fixture, seam, amountPx, renderUrl, limit, mode)); + } + return rows; + } finally { + await browser.close(); + } +} + +async function measureRenderedFixture(browser, fixture, seam, amountPx, renderUrl, limit, mode) { + const raw = loadFixture(fixture); + const optimized = optimizeMeshPolygons(raw, { meshResolution: "lossless" }); + const repaired = optimizeMeshPolygons(raw, { meshResolution: "lossy" }); + const splitPlan = seam.seamFacetSplitReport + ? splitPlanLookup(seam.seamFacetSplitReport(optimized, amountPx)) + : undefined; + const page = await browser.newPage({ viewport: { width: 768, height: 768 }, deviceScaleFactor: 1 }); + try { + await openGalleryModel(page, renderUrl, fixture.label); + const rendered = await measureRenderedState(page, repaired, limit, splitPlan); + return { + id: fixture.id, + label: fixture.label, + mode: "default", + rendered, + }; + } finally { + await page.close(); + } +} + +async function openGalleryModel(page, renderUrl, label) { + await page.goto(renderUrl, { waitUntil: "load" }); + await page.waitForTimeout(1800); + await page.getByPlaceholder("Search models").fill(label); + await page.waitForTimeout(300); + await page.getByRole("button", { name: label, exact: true }).click(); + await page.waitForTimeout(1800); +} + +async function measureRenderedState(page, polygons, limit, splitPlan) { + await page.waitForTimeout(1200); + const edges = exactSharedSameMaterialEdges(polygons); + const tags = await renderedLeafTags(page); + const screenEdges = await projectEdges(page, edges, tags); + const screenshot = await page.screenshot({ fullPage: false }); + const image = await decodeScreenshot(screenshot); + return { + ...measureScreenEdges(screenEdges, image, limit, splitPlan), + }; +} + +async function renderedLeafTags(page) { + return page.evaluate(() => + [...document.querySelectorAll(".polycss-scene b,.polycss-scene i,.polycss-scene u,.polycss-scene s")] + .map((el) => el.tagName.toLowerCase()) + ); +} + +async function projectEdges(page, edges, tags) { + const pointIndex = new Map(); + const points = []; + const indexFor = (point) => { + const key = pointKey(point); + const current = pointIndex.get(key); + if (current !== undefined) return current; + const next = points.length; + pointIndex.set(key, next); + points.push(point); + return next; + }; + const edgeRefs = edges.map((edge) => ({ + edge, + p0: indexFor(edge.p0), + p1: indexFor(edge.p1), + })); + + const projected = await page.evaluate((pts) => { + const host = document.querySelector(".polycss-mesh.dn-model-mesh") ?? + document.querySelector(".polycss-scene"); + if (!host) throw new Error("No rendered mesh host found"); + + const markers = pts.map((point) => { + const marker = document.createElement("em"); + marker.style.position = "absolute"; + marker.style.left = "0"; + marker.style.top = "0"; + marker.style.width = "1px"; + marker.style.height = "1px"; + marker.style.transformOrigin = "0 0"; + marker.style.transformStyle = "preserve-3d"; + marker.style.pointerEvents = "none"; + marker.style.background = "transparent"; + marker.style.transform = `translate3d(${point[0]}px,${point[1]}px,${point[2]}px)`; + host.appendChild(marker); + return marker; + }); + + const out = markers.map((marker) => { + const rect = marker.getBoundingClientRect(); + marker.remove(); + return { + x: rect.left + rect.width / 2, + y: rect.top + rect.height / 2, + }; + }); + return out; + }, points); + + return edgeRefs.map(({ edge, p0, p1 }) => ({ + ...edge, + tagA: tags[edge.aPolygon] ?? "?", + tagB: tags[edge.bPolygon] ?? "?", + s0: projected[p0], + s1: projected[p1], + })); +} + +async function decodeScreenshot(buffer) { + const sharp = requireFromWebsite("sharp"); + const { data, info } = await sharp(buffer).ensureAlpha().raw().toBuffer({ resolveWithObject: true }); + return { data, width: info.width, height: info.height, channels: info.channels }; +} + +function measureScreenEdges(edges, image, limit, splitPlan) { + const measured = []; + const selectedClean = []; + let visibleEdges = 0; + let crackedEdges = 0; + let crackSamples = 0; + let visibleSamples = 0; + let maxRunPx = 0; + let selectedVisibleEdges = 0; + let selectedCrackedEdges = 0; + let selectedCleanEdges = 0; + let selectedCrackSamples = 0; + let selectedVisibleSamples = 0; + const crackedTagPairs = new Map(); + + for (const edge of edges) { + const result = measureScreenEdge(edge, image); + if (result.visibleSamples === 0) continue; + const plan = splitPlan?.get(edgePairPlanKey(edge.aPolygon, edge.aEdge, edge.bPolygon, edge.bEdge)); + visibleEdges += 1; + visibleSamples += result.visibleSamples; + crackSamples += result.crackSamples; + maxRunPx = Math.max(maxRunPx, result.maxRunPx); + if (plan?.selected) { + selectedVisibleEdges += 1; + selectedVisibleSamples += result.visibleSamples; + selectedCrackSamples += result.crackSamples; + if (result.crackSamples > 0) selectedCrackedEdges += 1; + else { + selectedCleanEdges += 1; + selectedClean.push({ ...edge, ...result, splitPlan: plan }); + } + } + if (result.crackSamples > 0) { + crackedEdges += 1; + const tagPair = [edge.tagA, edge.tagB].sort().join("/"); + const group = crackedTagPairs.get(tagPair) ?? { edges: 0, crackSamples: 0 }; + group.edges += 1; + group.crackSamples += result.crackSamples; + crackedTagPairs.set(tagPair, group); + measured.push({ ...edge, ...result, splitPlan: plan }); + } + } + + measured.sort((a, b) => b.crackSamples - a.crackSamples || b.maxRunPx - a.maxRunPx); + selectedClean.sort((a, b) => (b.splitPlan?.score ?? 0) - (a.splitPlan?.score ?? 0)); + return { + exactSameMaterialEdges: edges.length, + visibleEdges, + crackedEdges, + crackSamples, + visibleSamples, + crackRatio: visibleSamples > 0 ? crackSamples / visibleSamples : 0, + maxRunPx, + crackedTagPairs: [...crackedTagPairs.entries()] + .map(([tagPair, group]) => ({ tagPair, ...group })) + .sort((a, b) => b.crackSamples - a.crackSamples || b.edges - a.edges), + selectedPlan: { + visibleEdges: selectedVisibleEdges, + crackedEdges: selectedCrackedEdges, + cleanEdges: selectedCleanEdges, + crackSamples: selectedCrackSamples, + visibleSamples: selectedVisibleSamples, + crackRatio: round(selectedVisibleSamples > 0 ? selectedCrackSamples / selectedVisibleSamples : 0, 4), + }, + topCracks: measured.slice(0, limit).map((edge) => ({ + a: `${edge.aPolygon}:${edge.aEdge}`, + b: `${edge.bPolygon}:${edge.bEdge}`, + tags: [edge.tagA, edge.tagB].join("/"), + color: edge.color ?? "", + lengthPx: round(edge.lengthPx), + crackSamples: edge.crackSamples, + visibleSamples: edge.visibleSamples, + crackRatio: round(edge.visibleSamples > 0 ? edge.crackSamples / edge.visibleSamples : 0, 4), + maxRunPx: edge.maxRunPx, + splitPlan: edge.splitPlan + ? { + selected: edge.splitPlan.selected, + reason: edge.splitPlan.reason, + component: edge.splitPlan.component, + cost: edge.splitPlan.marginalCost, + score: round(edge.splitPlan.score), + } + : undefined, + })), + topSelectedClean: selectedClean.slice(0, limit).map((edge) => ({ + a: `${edge.aPolygon}:${edge.aEdge}`, + b: `${edge.bPolygon}:${edge.bEdge}`, + tags: [edge.tagA, edge.tagB].join("/"), + color: edge.color ?? "", + lengthPx: round(edge.lengthPx), + visibleSamples: edge.visibleSamples, + splitPlan: edge.splitPlan + ? { + reason: edge.splitPlan.reason, + component: edge.splitPlan.component, + cost: edge.splitPlan.marginalCost, + score: round(edge.splitPlan.score), + } + : undefined, + })), + }; +} + +function measureScreenEdge(edge, image) { + const dx = edge.s1.x - edge.s0.x; + const dy = edge.s1.y - edge.s0.y; + const lengthPx = Math.hypot(dx, dy); + if (lengthPx < 6) { + return { lengthPx, visibleSamples: 0, crackSamples: 0, maxRunPx: 0 }; + } + + const nx = -dy / lengthPx; + const ny = dx / lengthPx; + let visibleSamples = 0; + let crackSamples = 0; + let currentRun = 0; + let maxRunPx = 0; + const start = 2; + const end = Math.max(start, lengthPx - 2); + + for (let distance = start; distance <= end; distance += 1) { + const x = edge.s0.x + (dx * distance) / lengthPx; + const y = edge.s0.y + (dy * distance) / lengthPx; + const samples = [-3, -2, -1, 0, 1, 2, 3].map((offset) => + samplePixel(image, x + nx * offset, y + ny * offset) + ); + if (samples.some((sample) => sample === null)) { + currentRun = 0; + continue; + } + + const sideA = isFilled(samples[0]) || isFilled(samples[1]); + const sideB = isFilled(samples[5]) || isFilled(samples[6]); + if (!sideA || !sideB) { + currentRun = 0; + continue; + } + + visibleSamples += 1; + const darkCenter = isBlack(samples[2]) || isBlack(samples[3]) || isBlack(samples[4]); + if (darkCenter) { + crackSamples += 1; + currentRun += 1; + maxRunPx = Math.max(maxRunPx, currentRun); + } else { + currentRun = 0; + } + } + + return { lengthPx, visibleSamples, crackSamples, maxRunPx }; +} + +function samplePixel(image, x, y) { + const px = Math.round(x); + const py = Math.round(y); + if (px < 0 || py < 0 || px >= image.width || py >= image.height) return null; + const i = (py * image.width + px) * image.channels; + return { + r: image.data[i], + g: image.data[i + 1], + b: image.data[i + 2], + a: image.data[i + 3], + }; +} + +function isBlack(pixel) { + return pixel.a > 240 && pixel.r < 34 && pixel.g < 34 && pixel.b < 34; +} + +function isFilled(pixel) { + return pixel.a > 240 && Math.max(pixel.r, pixel.g, pixel.b) > 55 && !isBlack(pixel); +} + +function printRenderReport(rows) { + console.log("\n# Rendered Seam Pixel Bench (Default Seam Path)\n"); + console.log("This samples exact shared same-material edges after browser projection; a crack sample is a black center pixel with filled pixels on both sides.\n"); + console.log("| Model | Exact edges | Visible edges | Cracked edges | Crack samples | Max run |"); + console.log("| --- | ---: | ---: | ---: | ---: | ---: |"); + for (const row of rows) { + const rendered = row.rendered; + console.log([ + `| ${row.label}`, + rendered.exactSameMaterialEdges, + rendered.visibleEdges, + rendered.crackedEdges, + rendered.crackSamples, + `${rendered.maxRunPx} |`, + ].join(" | ")); + } + + for (const row of rows) { + const rendered = row.rendered; + console.log(`\n## ${row.label} Rendered Cracks`); + printTagPairSection("Strategy pairs", rendered.crackedTagPairs); + printSelectedPlanSection("Selected split plan", rendered.selectedPlan); + printRenderedCrackSection("Cracks", rendered.topCracks); + printSelectedCleanSection("Selected clean", rendered.topSelectedClean); + } +} + +function printTagPairSection(title, pairs) { + console.log(`\n${title}:`); + if (pairs.length === 0) { + console.log("- none"); + return; + } + for (const pair of pairs.slice(0, 8)) { + console.log(`- ${pair.tagPair}: edges=${pair.edges} samples=${pair.crackSamples}`); + } +} + +function printRenderedCrackSection(title, cracks) { + console.log(`\n${title}:`); + if (cracks.length === 0) { + console.log("- none"); + return; + } + for (const crack of cracks) { + const plan = crack.splitPlan + ? ` plan=${crack.splitPlan.selected ? "selected" : "skipped"}/${crack.splitPlan.reason}` + + ` c=${crack.splitPlan.component} cost=${crack.splitPlan.cost} score=${fmt(crack.splitPlan.score)}` + : ""; + console.log( + `- ${crack.a} <-> ${crack.b} crack=${crack.crackSamples}/${crack.visibleSamples} ` + + `run=${crack.maxRunPx}px len=${fmt(crack.lengthPx)}px tags=${crack.tags} color=${crack.color}${plan}`, + ); + } +} + +function printSelectedPlanSection(title, plan) { + if (!plan || plan.visibleEdges === 0) return; + console.log(`\n${title}:`); + console.log( + `- visible=${plan.visibleEdges} cracked=${plan.crackedEdges} clean=${plan.cleanEdges} ` + + `samples=${plan.crackSamples}/${plan.visibleSamples} ratio=${fmt(plan.crackRatio)}`, + ); +} + +function printSelectedCleanSection(title, cracks) { + if (!cracks?.length) return; + console.log(`\n${title}:`); + for (const crack of cracks) { + const plan = crack.splitPlan + ? ` plan=${crack.splitPlan.reason} c=${crack.splitPlan.component} cost=${crack.splitPlan.cost} score=${fmt(crack.splitPlan.score)}` + : ""; + console.log( + `- ${crack.a} <-> ${crack.b} visible=${crack.visibleSamples} ` + + `len=${fmt(crack.lengthPx)}px tags=${crack.tags} color=${crack.color}${plan}`, + ); + } +} + +function assertionFailures(rows) { + const failures = []; + for (const row of rows) { + const expected = EXPECTED[row.id]; + if (!expected) continue; + if (row.baseline.trueGapPairs !== expected.beforeTrueGaps) { + failures.push(`${row.label}: expected ${expected.beforeTrueGaps} baseline true gaps, got ${row.baseline.trueGapPairs}`); + } + if (row.after.trueGapPairs !== expected.afterTrueGaps) { + failures.push(`${row.label}: expected ${expected.afterTrueGaps} post-repair true gaps, got ${row.after.trueGapPairs}`); + } + if (row.changedPolygons !== expected.changedPolygons) { + failures.push(`${row.label}: expected ${expected.changedPolygons} changed polygons, got ${row.changedPolygons}`); + } + } + return failures; +} + +const args = parseArgs(process.argv.slice(2)); +if (args.help) { + printHelp(); + process.exit(0); +} + +const fixtures = selectedFixtures(args.models); +if (fixtures.length === 0) { + throw new Error("No seam fixtures matched --models"); +} + +const seam = await loadSeamHelper(); +const rows = fixtures.map((fixture) => analyzeFixture(fixture, seam, args.amountPx, args.limit)); +const failures = args.assert ? assertionFailures(rows) : []; +printReport(rows, args.amountPx, args.limit, failures); +const renderRows = args.render + ? await runRenderBench(fixtures, seam, args.amountPx, args.renderUrl, args.limit, "bleed") + : args.renderSplit + ? await runRenderBench(fixtures, seam, args.amountPx, args.renderUrl, args.limit, "split") + : []; +if (args.render || args.renderSplit) printRenderReport(renderRows); + +if (args.json) { + const path = resolve(repoRoot, args.json); + writeFileSync(path, `${JSON.stringify({ amountPx: args.amountPx, rows, renderRows }, null, 2)}\n`); + console.log(`\nWrote ${relative(repoRoot, path)}`); +} + +if (failures.length > 0) process.exitCode = 1; diff --git a/package.json b/package.json index f82a0320..87079288 100644 --- a/package.json +++ b/package.json @@ -34,6 +34,8 @@ "bench:trace": "node bench/build.mjs && node bench/trace-analysis.mjs", "bench:lossy": "pnpm --filter @layoutit/polycss-core build && node bench/lossy-optimizer-bench.mjs", "bench:lossy:corpus": "pnpm --filter @layoutit/polycss-core build && node bench/lossy-corpus-bench.mjs", + "bench:seams": "pnpm --filter @layoutit/polycss-core build && node bench/seam-gap-bench.mjs", + "bench:seams:render": "pnpm --filter @layoutit/polycss-core build && node bench/seam-gap-bench.mjs --render", "bench:voxel-report": "node bench/voxel-report.mjs", "bench:visual": "node bench/build.mjs && node bench/perf-visual.mjs" }, diff --git a/packages/core/README.md b/packages/core/README.md index ad8a69f3..6af2b1e9 100644 --- a/packages/core/README.md +++ b/packages/core/README.md @@ -88,6 +88,7 @@ import { PolyCamera, PolyScene, PolyOrbitControls, PolyMesh } from "@layoutit/po - `directionalLight` and `ambientLight` control scene lighting. - `textureLighting` chooses `"baked"` or `"dynamic"`. - `textureQuality` controls atlas raster budget. +- `seamBleed="auto"` computes solid-primitive overscan from each polygon plan; numeric values clamp detected shared seam edges. - `strategies` can disable selected render strategies for diagnostics. - `autoCenter` rotates around the rendered mesh bounds instead of world origin. @@ -98,7 +99,7 @@ import { PolyCamera, PolyScene, PolyOrbitControls, PolyMesh } from "@layoutit/po - `polygons` accepts pre-parsed geometry. - `position`, `scale`, and `rotation` transform the mesh wrapper. - `autoCenter` shifts the mesh bbox center to local origin. -- `meshResolution` chooses `"lossy"` (default) or `"lossless"` optimization. +- `meshResolution` chooses `"lossy"` (default) or `"lossless"` optimization. Lossy also applies bounded seam repair. - `castShadow` emits CSS-projected shadows in dynamic lighting mode. ### Controls @@ -177,7 +178,7 @@ polycss renders in the DOM, so performance is mostly determined by how many poly - Textured polygons are packed into generated texture atlases. - Dynamic lighting runs through CSS custom properties instead of per-frame JavaScript. - Voxel-shaped meshes mount only camera-facing leaves when the mesh is eligible. -- `meshResolution: "lossy"` can merge compatible polygons to reduce DOM node count. +- `meshResolution: "lossy"` merges compatible polygons, then may spend a small split budget to repair high-risk seams. For diagnostics, all renderer packages export `collectPolyRenderStats(root)`, which returns mounted polygon leaf counts, shadow counts, surface categories, and bucket counts for an already-rendered scene. diff --git a/packages/core/src/atlas/constants.ts b/packages/core/src/atlas/constants.ts index 9b11a091..5bd2e306 100644 --- a/packages/core/src/atlas/constants.ts +++ b/packages/core/src/atlas/constants.ts @@ -58,3 +58,4 @@ export const CORNER_SHAPE_DUPLICATE_EPS = 0.2; export const PROJECTIVE_QUAD_DENOM_EPS = 0.05; export const PROJECTIVE_QUAD_MAX_WEIGHT_RATIO = Number.POSITIVE_INFINITY; export const PROJECTIVE_QUAD_BLEED = 0.6; +export const DEFAULT_SEAM_BLEED = 1.5; diff --git a/packages/core/src/atlas/edgeRepair.ts b/packages/core/src/atlas/edgeRepair.ts index 3f63be11..cd7390bb 100644 --- a/packages/core/src/atlas/edgeRepair.ts +++ b/packages/core/src/atlas/edgeRepair.ts @@ -1,5 +1,8 @@ import type { Polygon } from "../types"; import type { Vec3 } from "../types"; +import { DEFAULT_TILE, RECT_EPS } from "./constants"; +import type { PolySeamBleed, SeamBleedInsets } from "./types"; +import { computeSurfaceNormal, cssPoints } from "./solidTriangle"; function pointKey(point: Vec3): string { return `${point[0]},${point[1]},${point[2]}`; @@ -36,3 +39,234 @@ export function buildTextureEdgeRepairSets(polygons: Polygon[]): Array edges.size > 0 ? edges : undefined); } + +export function resolveSeamBleed(value: unknown, fallback: number): number { + return value === "auto" + ? fallback + : typeof value === "number" && Number.isFinite(value) + ? Math.max(0, value) + : fallback; +} + +export function normalizedSeamBleed(value: unknown): PolySeamBleed | undefined { + if (value === "auto") return "auto"; + return typeof value === "number" && Number.isFinite(value) && value > 0 + ? value + : undefined; +} + +export function safePlanSeamBleedAmount( + screenPts: number[], + edgeIndex: number, + requested: number, +): number { + if (requested <= 0 || screenPts.length < 6 || screenPts.length % 2 !== 0) return 0; + const count = screenPts.length / 2; + if (edgeIndex < 0 || edgeIndex >= count) return 0; + const points: Array<[number, number]> = []; + for (let i = 0; i < screenPts.length; i += 2) { + points.push([screenPts[i], screenPts[i + 1]]); + } + const [ax, ay] = points[edgeIndex]; + const [bx, by] = points[(edgeIndex + 1) % count]; + const dx = bx - ax; + const dy = by - ay; + const length = Math.hypot(dx, dy); + if (length <= RECT_EPS) return 0; + + const limits: number[] = []; + let oppositeClearance = Number.POSITIVE_INFINITY; + for (let i = 0; i < count; i += 1) { + if (i === edgeIndex || i === (edgeIndex + 1) % count) continue; + const [px, py] = points[i]; + const distance = Math.abs((px - ax) * dy - (py - ay) * dx) / length; + if (distance > RECT_EPS) oppositeClearance = Math.min(oppositeClearance, distance); + } + if (Number.isFinite(oppositeClearance)) limits.push(oppositeClearance * 0.5); + + const previous = points[(edgeIndex + count - 1) % count]; + const next = points[(edgeIndex + 2) % count]; + const previousDx = previous[0] - ax; + const previousDy = previous[1] - ay; + const nextDx = next[0] - bx; + const nextDy = next[1] - by; + const previousLength = Math.hypot(previousDx, previousDy); + const nextLength = Math.hypot(nextDx, nextDy); + if (previousLength > RECT_EPS) { + const sin = Math.abs(dx * previousDy - dy * previousDx) / (length * previousLength); + if (sin > RECT_EPS) limits.push(previousLength * sin); + } + if (nextLength > RECT_EPS) { + const sin = Math.abs((-dx) * nextDy - (-dy) * nextDx) / (length * nextLength); + if (sin > RECT_EPS) limits.push(nextLength * sin); + } + + const finiteLimits = limits.filter((limit) => Number.isFinite(limit) && limit > 0); + if (finiteLimits.length === 0) return Number.isFinite(requested) ? Math.max(0, requested) : 0; + const fit = Math.min(...finiteLimits); + return Math.max(0, Math.min(Number.isFinite(requested) ? requested : fit, fit)); +} + +export function computePlanSeamBleedEdgeAmounts( + screenPts: number[], + seamEdges: ReadonlySet | undefined, + seamBleed: PolySeamBleed | undefined, +): Map | undefined { + if (!seamEdges?.size || seamBleed === undefined) return undefined; + const amounts = new Map(); + const request = seamBleed === "auto" ? Number.POSITIVE_INFINITY : seamBleed; + for (const edgeIndex of seamEdges) { + const amount = safePlanSeamBleedAmount(screenPts, edgeIndex, request); + if (amount > 0) amounts.set(edgeIndex, amount); + } + return amounts.size > 0 ? amounts : undefined; +} + +export function seamBleedAmountArray( + vertexCount: number, + edgeAmounts: ReadonlyMap | undefined, +): number[] | null { + if (vertexCount < 3 || !edgeAmounts?.size) return null; + return Array.from({ length: vertexCount }, (_, edgeIndex) => + Math.max(0, edgeAmounts.get(edgeIndex) ?? 0) + ); +} + +export function computeSeamBleedInsets( + screenPts: number[], + edgeAmounts: ReadonlyMap | undefined, +): SeamBleedInsets | undefined { + if (!edgeAmounts?.size || screenPts.length < 6 || screenPts.length % 2 !== 0) { + return undefined; + } + + const count = screenPts.length / 2; + let minX = Number.POSITIVE_INFINITY; + let minY = Number.POSITIVE_INFINITY; + let maxX = Number.NEGATIVE_INFINITY; + let maxY = Number.NEGATIVE_INFINITY; + for (let i = 0; i < screenPts.length; i += 2) { + const x = screenPts[i]; + const y = screenPts[i + 1]; + minX = Math.min(minX, x); + minY = Math.min(minY, y); + maxX = Math.max(maxX, x); + maxY = Math.max(maxY, y); + } + if (!Number.isFinite(minX + minY + maxX + maxY)) return undefined; + + const tol = Math.max(RECT_EPS * 8, 0.25); + const insets: SeamBleedInsets = { left: 0, right: 0, top: 0, bottom: 0 }; + for (const [edgeIndex, edgeBleed] of edgeAmounts) { + const bleed = Math.max(0, edgeBleed); + if (bleed <= 0) continue; + if (edgeIndex < 0 || edgeIndex >= count) continue; + const aOffset = edgeIndex * 2; + const bOffset = ((edgeIndex + 1) % count) * 2; + const ax = screenPts[aOffset]; + const ay = screenPts[aOffset + 1]; + const bx = screenPts[bOffset]; + const by = screenPts[bOffset + 1]; + if (Math.abs(ax - bx) <= tol) { + if (Math.abs(ax - minX) <= tol && Math.abs(bx - minX) <= tol) { + insets.left = Math.max(insets.left, bleed); + } else if (Math.abs(ax - maxX) <= tol && Math.abs(bx - maxX) <= tol) { + insets.right = Math.max(insets.right, bleed); + } + } else if (Math.abs(ay - by) <= tol) { + if (Math.abs(ay - minY) <= tol && Math.abs(by - minY) <= tol) { + insets.top = Math.max(insets.top, bleed); + } else if (Math.abs(ay - maxY) <= tol && Math.abs(by - maxY) <= tol) { + insets.bottom = Math.max(insets.bottom, bleed); + } + } + } + + return insets.left || insets.right || insets.top || insets.bottom + ? insets + : undefined; +} + +interface SeamBleedSurfaceInfo { + normal: Vec3; + planeD: number; +} + +interface SeamBleedDetectionOptions { + tileSize?: number; + layerElevation?: number; + directionalLight?: unknown; + ambientLight?: unknown; +} + +function dotVec(a: Vec3, b: Vec3): number { + return a[0] * b[0] + a[1] * b[1] + a[2] * b[2]; +} + +function seamBleedSurfaceInfo( + polygon: Polygon, + tile: number, + elev: number, +): SeamBleedSurfaceInfo | null { + if (!polygon.vertices || polygon.vertices.length < 3) return null; + const pts = cssPoints(polygon.vertices, tile, elev); + const normal = computeSurfaceNormal(pts); + if (!normal) return null; + return { normal, planeD: dotVec(normal, pts[0]) }; +} + +function compatibleSeamBleedMaterials(a: Polygon, b: Polygon): boolean { + return (a.material?.key ?? a.color ?? "") === (b.material?.key ?? b.color ?? "") && + a.color === b.color; +} + +export function buildSeamBleedPolygonSet( + polygons: Polygon[], + options: SeamBleedDetectionOptions = {}, +): Set { + return new Set(buildSeamBleedPolygonEdges(polygons, options).keys()); +} + +export function buildSeamBleedPolygonEdges( + polygons: Polygon[], + options: SeamBleedDetectionOptions = {}, +): Map> { + const tile = options.tileSize ?? DEFAULT_TILE; + const elev = options.layerElevation ?? tile; + const infos = polygons.map((polygon) => seamBleedSurfaceInfo(polygon, tile, elev)); + const edgeOwners = new Map>(); + for (let polygonIndex = 0; polygonIndex < polygons.length; polygonIndex++) { + const vertices = polygons[polygonIndex].vertices; + if (!vertices || vertices.length < 3) continue; + for (let edgeIndex = 0; edgeIndex < vertices.length; edgeIndex++) { + const key = edgeKey(vertices[edgeIndex], vertices[(edgeIndex + 1) % vertices.length]); + const owners = edgeOwners.get(key); + const owner = { polygon: polygonIndex, edge: edgeIndex }; + if (owners) owners.push(owner); + else edgeOwners.set(key, [owner]); + } + } + + const seamEdges = new Map>(); + const addSeamEdge = (polygonIndex: number, edgeIndex: number): void => { + const edges = seamEdges.get(polygonIndex); + if (edges) edges.add(edgeIndex); + else seamEdges.set(polygonIndex, new Set([edgeIndex])); + }; + for (const owners of edgeOwners.values()) { + if (owners.length < 2) continue; + for (let i = 0; i < owners.length; i++) { + for (let j = i + 1; j < owners.length; j++) { + const aOwner = owners[i]; + const bOwner = owners[j]; + const a = aOwner.polygon; + const b = bOwner.polygon; + if (!infos[a] || !infos[b]) continue; + if (!compatibleSeamBleedMaterials(polygons[a], polygons[b])) continue; + addSeamEdge(a, aOwner.edge); + addSeamEdge(b, bOwner.edge); + } + } + } + return seamEdges; +} diff --git a/packages/core/src/atlas/matrix.ts b/packages/core/src/atlas/matrix.ts index e0fb6024..b9403b33 100644 --- a/packages/core/src/atlas/matrix.ts +++ b/packages/core/src/atlas/matrix.ts @@ -171,10 +171,15 @@ export function formatBorderShapeMatrix( } export function formatSolidQuadMatrix(entry: TextureAtlasPlan): string { + const bleed = entry.seamBleedInsets ?? { left: 0, right: 0, top: 0, bottom: 0 }; + const canvasW = entry.canvasW || 1; + const canvasH = entry.canvasH || 1; return formatScaledMatrixFromPlan( entry, - (entry.canvasW || 1) / SOLID_QUAD_CANONICAL_SIZE, - (entry.canvasH || 1) / SOLID_QUAD_CANONICAL_SIZE, + (canvasW + bleed.left + bleed.right) / SOLID_QUAD_CANONICAL_SIZE, + (canvasH + bleed.top + bleed.bottom) / SOLID_QUAD_CANONICAL_SIZE, + -bleed.left, + -bleed.top, ); } diff --git a/packages/core/src/atlas/plan.ts b/packages/core/src/atlas/plan.ts index 88fe6006..f284f3d9 100644 --- a/packages/core/src/atlas/plan.ts +++ b/packages/core/src/atlas/plan.ts @@ -44,9 +44,16 @@ import { computeSurfaceNormal, isConvexPolygonPoints, offsetConvexPolygonPoints, + offsetConvexPolygonPointsByEdgeAmounts, stableBasisFromPlan, } from "./solidTriangle"; import { textureTintFactors, shadePolygon } from "./paintDefaults"; +import { + computePlanSeamBleedEdgeAmounts, + computeSeamBleedInsets, + seamBleedAmountArray, + normalizedSeamBleed, +} from "./edgeRepair"; function finiteNumber(value: unknown, fallback: number): number { return typeof value === "number" && Number.isFinite(value) ? value : fallback; @@ -126,6 +133,7 @@ export function computeProjectiveQuadMatrix( ty: number, tz: number, guards: ProjectiveQuadGuardSettings, + seamBleedEdgeAmounts?: ReadonlyMap, ): string | null { if (screenPts.length !== 8) return null; const rawQ: Array<[number, number]> = [ @@ -136,7 +144,10 @@ export function computeProjectiveQuadMatrix( ]; if (!computeProjectiveQuadCoefficients(rawQ, guards)) return null; - const expandedPts = offsetConvexPolygonPoints(screenPts, guards.bleed); + const edgeAmounts = seamBleedAmountArray(4, seamBleedEdgeAmounts); + const expandedPts = edgeAmounts + ? offsetConvexPolygonPointsByEdgeAmounts(screenPts, edgeAmounts) + : offsetConvexPolygonPoints(screenPts, guards.bleed); const q: Array<[number, number]> = [ [expandedPts[0], expandedPts[1]], [expandedPts[2], expandedPts[3]], @@ -771,6 +782,19 @@ export function computeTextureAtlasPlan( normal[0], normal[1], normal[2], 0, tx, ty, tz, 1, ]); + const seamBleedRequest = normalizedSeamBleed(options.seamBleed); + const seamBleedEdgeAmounts = computePlanSeamBleedEdgeAmounts( + screenPts, + options.seamEdges ?? basisHint?.seamEdges, + seamBleedRequest, + ); + const seamBleedEdges = seamBleedEdgeAmounts + ? new Set(seamBleedEdgeAmounts.keys()) + : undefined; + const seamBleed = seamBleedEdgeAmounts + ? Math.max(...seamBleedEdgeAmounts.values()) + : undefined; + const seamBleedInsets = computeSeamBleedInsets(screenPts, seamBleedEdgeAmounts); const projectiveMatrix = !texture && vertices.length === 4 ? computeProjectiveQuadMatrix( screenPts, @@ -781,6 +805,7 @@ export function computeTextureAtlasPlan( ty, tz, projectiveQuadGuards, + seamBleedEdgeAmounts, ) : null; @@ -834,6 +859,10 @@ export function computeTextureAtlasPlan( textureTriangles, textureEdgeRepairEdges, textureEdgeRepair, + seamBleed, + seamBleedEdges, + seamBleedEdgeAmounts, + seamBleedInsets, normal, textureTint, shadedColor, @@ -861,8 +890,11 @@ export function computeTextureAtlasPlanPublic( projectiveQuadOverrides?: ProjectiveQuadGuardOverrides, ): TextureAtlasPlan | null { const projectiveQuadGuards = resolveProjectiveQuadGuards(projectiveQuadOverrides); - const basisHint: BasisHint | undefined = options.textureEdgeRepairEdges?.size - ? { seamEdges: new Set(), textureEdgeRepairEdges: options.textureEdgeRepairEdges } + const basisHint: BasisHint | undefined = options.textureEdgeRepairEdges?.size || options.seamEdges?.size + ? { + seamEdges: options.seamEdges ?? new Set(), + textureEdgeRepairEdges: options.textureEdgeRepairEdges, + } : undefined; return computeTextureAtlasPlan(polygon, index, options, projectiveQuadGuards, basisHint); } diff --git a/packages/core/src/atlas/solidTriangle.ts b/packages/core/src/atlas/solidTriangle.ts index 080bd489..3e90ac69 100644 --- a/packages/core/src/atlas/solidTriangle.ts +++ b/packages/core/src/atlas/solidTriangle.ts @@ -177,6 +177,72 @@ export function offsetConvexPolygonPoints(points: number[], amount: number): num return expanded; } +export function offsetConvexPolygonPointsByEdgeAmounts( + points: number[], + amounts: readonly number[], +): number[] { + if (points.length < 6 || points.length % 2 !== 0) return points; + const count = points.length / 2; + if (amounts.length !== count) return points; + const cleanAmounts = amounts.map((amount) => + typeof amount === "number" && Number.isFinite(amount) + ? Math.max(0, amount) + : 0 + ); + const maxAmount = Math.max(0, ...cleanAmounts); + if (maxAmount <= 0) return points; + if (cleanAmounts.every((amount) => Math.abs(amount - cleanAmounts[0]) <= BASIS_EPS)) { + return offsetConvexPolygonPoints(points, cleanAmounts[0]); + } + + const q: Array<[number, number]> = []; + for (let i = 0; i < points.length; i += 2) q.push([points[i], points[i + 1]]); + if (!isConvexPolygonPoints(q)) return points; + + const area = signedArea2D(q); + if (Math.abs(area) <= BASIS_EPS) return points; + const outwardSign = area > 0 ? 1 : -1; + const offsetLines: Array<{ a: [number, number]; b: [number, number] }> = []; + for (let i = 0; i < q.length; i++) { + const a = q[i]; + const b = q[(i + 1) % q.length]; + const dx = b[0] - a[0]; + const dy = b[1] - a[1]; + const length = Math.hypot(dx, dy); + if (length <= BASIS_EPS) return points; + const amount = cleanAmounts[i]; + const ox = outwardSign * (dy / length) * amount; + const oy = outwardSign * (-dx / length) * amount; + offsetLines.push({ + a: [a[0] + ox, a[1] + oy], + b: [b[0] + ox, b[1] + oy], + }); + } + + const expanded: number[] = []; + const maxMiter = Math.max(2, maxAmount * 4); + for (let i = 0; i < q.length; i++) { + const prev = offsetLines[(i + q.length - 1) % q.length]; + const next = offsetLines[i]; + const intersection = intersect2DLines(prev.a, prev.b, next.a, next.b); + if (!intersection) return points; + + const original = q[i]; + const dx = intersection[0] - original[0]; + const dy = intersection[1] - original[1]; + const miter = Math.hypot(dx, dy); + if (miter > maxMiter) { + expanded.push( + original[0] + (dx / miter) * maxMiter, + original[1] + (dy / miter) * maxMiter, + ); + } else { + expanded.push(intersection[0], intersection[1]); + } + } + return expanded; +} + export function offsetTrianglePoints( x0: number, y0: number, diff --git a/packages/core/src/atlas/solidTrianglePlan.ts b/packages/core/src/atlas/solidTrianglePlan.ts index 7a523aad..8f1ed1fc 100644 --- a/packages/core/src/atlas/solidTrianglePlan.ts +++ b/packages/core/src/atlas/solidTrianglePlan.ts @@ -29,11 +29,43 @@ import { } from "./paintDefaults"; import { cssPoints, + offsetConvexPolygonPointsByEdgeAmounts, offsetStableTrianglePoints, stableTriangleMatrixDecimals, } from "./solidTriangle"; +import { + resolveSeamBleed, + safePlanSeamBleedAmount, +} from "./edgeRepair"; import { formatAffineMatrix3dTransformScalars } from "./matrix"; +function triangleEdgeIndexForPair(a: number, b: number): number | undefined { + if ((a + 1) % 3 === b) return a; + if ((b + 1) % 3 === a) return b; + return undefined; +} + +function stableTriangleEdgeAmounts( + seamEdges: ReadonlySet | undefined, + seamBleed: SolidTrianglePlanOptions["seamBleed"], + fallback: number, + a: number, + b: number, + c: number, + screenPts: number[], +): number[] | null { + if (!seamEdges?.size) return null; + const seamAmount = seamBleed === "auto" + ? Number.POSITIVE_INFINITY + : resolveSeamBleed(seamBleed, fallback); + const edgePairs: Array<[number, number]> = [[c, a], [a, b], [b, c]]; + return edgePairs.map(([from, to], localEdgeIndex) => { + const edgeIndex = triangleEdgeIndexForPair(from, to); + const requested = edgeIndex !== undefined && seamEdges.has(edgeIndex) ? seamAmount : 0; + return safePlanSeamBleedAmount(screenPts, localEdgeIndex, requested); + }); +} + export function computeSolidTriangleColorPlanFromNormal( polygon: Polygon, index: number, @@ -308,7 +340,24 @@ export function computeSolidTrianglePlanFromCssPoints( const left = Math.max(0, Math.min(baseLength, apexX)); const right = Math.max(0, baseLength - left); - const expanded = offsetStableTrianglePoints(left, right, height, SOLID_TRIANGLE_BLEED); + const screenPts = [left, 0, 0, height, left + right, height]; + const edgeAmounts = stableTriangleEdgeAmounts( + options.seamEdges, + options.seamBleed, + SOLID_TRIANGLE_BLEED, + a, + b, + c, + screenPts, + ); + const expanded = edgeAmounts + ? offsetConvexPolygonPointsByEdgeAmounts(screenPts, edgeAmounts) + : offsetStableTrianglePoints( + left, + right, + height, + resolveSeamBleed(options.seamBleed, SOLID_TRIANGLE_BLEED), + ); const apex2x = expanded[0]; const apex2y = expanded[1]; const baseLeft2x = expanded[2]; diff --git a/packages/core/src/atlas/types.ts b/packages/core/src/atlas/types.ts index 279ae249..9f0100fa 100644 --- a/packages/core/src/atlas/types.ts +++ b/packages/core/src/atlas/types.ts @@ -44,6 +44,10 @@ export interface TextureAtlasPlan { textureTriangles: TextureTrianglePlan[] | null; textureEdgeRepairEdges: Set | null; textureEdgeRepair: boolean; + seamBleed?: number; + seamBleedEdges?: Set; + seamBleedEdgeAmounts?: Map; + seamBleedInsets?: SeamBleedInsets; /** World-space surface normal — stable across light changes, used by dynamic mode. */ normal: Vec3; textureTint: RGBFactors; @@ -76,6 +80,11 @@ export interface CornerShapeGeometry { } export type TextureQuality = number | "auto"; +export type PolySeamBleed = number | "auto"; +export type PolySeamBleedEdgeValue = ReadonlySet | ReadonlyMap; +export type PolySeamBleedEdges = + | ReadonlyMap + | readonly (PolySeamBleedEdgeValue | undefined)[]; export type PolyRenderStrategy = "b" | "i" | "u"; export type SolidTrianglePrimitive = "border" | "corner-bevel"; @@ -87,6 +96,13 @@ export interface PolyRenderStrategiesOption { disable?: readonly PolyRenderStrategy[]; } +export interface SeamBleedInsets { + left: number; + right: number; + top: number; + bottom: number; +} + export interface PackedTextureAtlasEntry extends TextureAtlasPlan { pageIndex: number; x: number; @@ -259,6 +275,8 @@ export interface SolidTrianglePlanOptions { textureLighting?: import("../types").PolyTextureLightingMode; solidPaintDefaults?: SolidPaintDefaults; strategies?: PolyRenderStrategiesOption; + seamBleed?: PolySeamBleed; + seamEdges?: Set; } /** Internal solid-triangle plan options (extends SolidTrianglePlanOptions). */ @@ -276,4 +294,6 @@ export interface ComputeTextureAtlasPlanOptions { ambientLight?: import("../types").PolyAmbientLight; /** Shared-edge set returned by {@link buildTextureEdgeRepairSets}. */ textureEdgeRepairEdges?: Set; + seamBleed?: PolySeamBleed; + seamEdges?: Set; } diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 92b162ad..085d0ef3 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -95,6 +95,26 @@ export type { ApproximateMergeOptions, OptimizeMeshPolygonsOptions, } from "./merge/optimizePolygons"; +export { + DEFAULT_SEAM_FACET_SPLIT_OPTIONS, + DEFAULT_SEAM_OVERLAP_OPTIONS, + repairMeshSeams, + seamFacetSplitPolygons, + seamFacetSplitReport, + seamOverlapDiagnostics, + seamOverlapPolygons, + seamOverlapReport, +} from "./merge/seamRepair"; +export type { + SeamFacetSplitCandidate, + SeamFacetSplitCandidateReason, + SeamFacetSplitOptions, + SeamFacetSplitReport, + SeamOverlapCandidate, + SeamOverlapCandidateKind, + SeamOverlapDiagnostics, + SeamOverlapOptions, +} from "./merge/seamRepair"; export { cullInteriorPolygons } from "./cull/cullInteriorPolygons"; export type { CullInteriorOptions } from "./cull/cullInteriorPolygons"; export { @@ -225,6 +245,7 @@ export { PROJECTIVE_QUAD_DENOM_EPS, PROJECTIVE_QUAD_MAX_WEIGHT_RATIO, PROJECTIVE_QUAD_BLEED, + DEFAULT_SEAM_BLEED, } from "./atlas/constants"; export type { RGB, @@ -240,9 +261,13 @@ export type { CornerShapeRadius, CornerShapeGeometry, TextureQuality, + PolySeamBleed, + PolySeamBleedEdgeValue, + PolySeamBleedEdges, PolyRenderStrategy, SolidTrianglePrimitive, PolyRenderStrategiesOption, + SeamBleedInsets, PackedTextureAtlasEntry, PackedPage, PackingShelf, @@ -286,7 +311,17 @@ export { formatCssLengthPx, formatSolidQuadEntryMatrix, } from "./atlas/matrix"; -export { buildTextureEdgeRepairSets } from "./atlas/edgeRepair"; +export { + buildTextureEdgeRepairSets, + resolveSeamBleed, + normalizedSeamBleed, + safePlanSeamBleedAmount, + computePlanSeamBleedEdgeAmounts, + seamBleedAmountArray, + computeSeamBleedInsets, + buildSeamBleedPolygonSet, + buildSeamBleedPolygonEdges, +} from "./atlas/edgeRepair"; export { cachedParsePureColor, parseHex, @@ -326,6 +361,7 @@ export { intersect2DLinesRaw, expandClipPoints, offsetConvexPolygonPoints, + offsetConvexPolygonPointsByEdgeAmounts, offsetTrianglePoints, offsetStableTrianglePoints, stableBasisFromPlan, diff --git a/packages/core/src/merge/optimizePolygons.test.ts b/packages/core/src/merge/optimizePolygons.test.ts index 1c3c67fd..73f554a0 100644 --- a/packages/core/src/merge/optimizePolygons.test.ts +++ b/packages/core/src/merge/optimizePolygons.test.ts @@ -6,6 +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 { optimizeMeshPolygons } from "./optimizePolygons"; function rect(x0: number, y0: number, x1: number, y1: number): Polygon[] { @@ -380,10 +381,10 @@ describe("optimizeMeshPolygons", () => { const lossy = optimizeMeshPolygons(raw, { meshResolution: "lossy" }); expect(lossless).toHaveLength(10); - expect(lossy.length).toBeLessThanOrEqual(lossless.length); + expect(renderCost(lossy)).toBeLessThanOrEqual(renderCost(lossless) + 1e-9); }); - it("keeps automatic lossy quality fallback under the exact lossless floor", async () => { + it("keeps automatic lossy seam repair within the split budget over the exact lossless floor", async () => { installSolidTextureEnv([10, 20, 30, 255]); for (const file of ["poly-pizza/arrow.glb", "poly-pizza/bucket.glb"]) { @@ -395,8 +396,9 @@ describe("optimizeMeshPolygons", () => { const lossless = optimizeMeshPolygons(baked.polygons, { meshResolution: "lossless" }); const lossy = optimizeMeshPolygons(baked.polygons, { meshResolution: "lossy" }); - expect(lossy.length, file).toBeLessThanOrEqual(lossless.length); - expect(renderCost(lossy), file).toBeLessThanOrEqual(renderCost(lossless) + 1e-9); + expect(lossy.length, file).toBeLessThanOrEqual( + lossless.length + DEFAULT_SEAM_FACET_SPLIT_OPTIONS.budget, + ); } }); @@ -439,8 +441,9 @@ describe("optimizeMeshPolygons", () => { const automatic = optimizeMeshPolygons(raw, { meshResolution: "lossy" }); expect(forced.length).toBeLessThan(lossless.length); - expect(automatic.length).toBeGreaterThan(forced.length); expect(automatic.length).toBeLessThan(lossless.length); + expect(renderCost(automatic)).toBeLessThan(renderCost(lossless)); + expect(polygonSignature(automatic)).not.toEqual(polygonSignature(forced)); }); it("keeps lossy pair-merge neighbor seams on shared geometry", () => { diff --git a/packages/core/src/merge/optimizePolygons.ts b/packages/core/src/merge/optimizePolygons.ts index 96920e35..67c79ce1 100644 --- a/packages/core/src/merge/optimizePolygons.ts +++ b/packages/core/src/merge/optimizePolygons.ts @@ -3,6 +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"; const NORMALIZE_MAX_ANGLE_DEG = 3; const NORMALIZE_MAX_PLANE_DISPLACEMENT = 0.03; @@ -253,6 +254,8 @@ export function optimizeMeshPolygons( 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; @@ -277,13 +280,13 @@ export function optimizeMeshPolygons( if (losslessRectCovered !== baseline) acceptCandidate(losslessRectCovered); } const exactCostCeiling = bestCost; - if (meshResolution === "lossy" && (best.length <= 1 || bestCost <= 1 + 1e-9)) return best; + if (meshResolution === "lossy" && (best.length <= 1 || bestCost <= 1 + 1e-9)) return finish(best); if ( meshResolution === "lossy" && options.approximateMerge === undefined && polygons.length >= LOSSY_AUTOMATIC_LOW_VALUE_SOURCE_MIN_POLYGONS && bestCost <= LOSSY_AUTOMATIC_APPROXIMATE_LOW_VALUE_EXACT_COST - ) return best; + ) return finish(best); if (meshResolution === "lossy" && options.approximateMerge !== false) { const qualityCandidates: LossyQualityCandidate[] = []; @@ -505,7 +508,7 @@ export function optimizeMeshPolygons( colorQuantizeCandidates.length === 0 && shouldUseRectangulatedFastExit(baseline) ) { - return best; + return finish(best); } if (automaticApproximate) { @@ -684,7 +687,7 @@ export function optimizeMeshPolygons( } } - return best; + return finish(best); } function lossyColorQuantizeCandidates(polygons: Polygon[], baseline?: Polygon[]): Polygon[][] { diff --git a/packages/core/src/merge/seamRepair.ts b/packages/core/src/merge/seamRepair.ts new file mode 100644 index 00000000..41514acf --- /dev/null +++ b/packages/core/src/merge/seamRepair.ts @@ -0,0 +1,1706 @@ +import type { Polygon, Vec3 } from "../types"; + +type Vec2 = [number, number]; + +interface LocalBasis { + origin: Vec3; + xAxis: Vec3; + yAxis: Vec3; + local: Vec2[]; + area: number; +} + +interface PolygonMeta { + basis: LocalBasis | null; + cssPoints: Vec3[]; + capacities: number[]; + patchable: boolean; +} + +interface EdgeRecord { + index: number; + polygon: number; + edge: number; + key: string; + a: Vec3; + b: Vec3; + length: number; + dir: Vec3; + normal: Vec3; + outward: Vec3; + capacity: number; + materialKey: string; + color?: string; +} + +interface NearSeamInfo { + gap: number; + facingA: number; + facingB: number; + aStart: number; + aEnd: number; + bStart: number; + bEnd: number; + a0: Vec3; + a1: Vec3; + b0: Vec3; + b1: Vec3; +} + +export type SeamOverlapCandidateKind = "true-gap" | "connected-facet" | "material-boundary"; + +export interface SeamOverlapCandidate { + kind: SeamOverlapCandidateKind; + aPolygon: number; + aEdge: number; + bPolygon: number; + bEdge: number; + aColor?: string; + bColor?: string; + aMaterialKey: string; + bMaterialKey: string; + gapPx: number; + spanPx: number; + aStartPx: number; + aEndPx: number; + bStartPx: number; + bEndPx: number; + targetClosurePx: number; + appliedClosurePx: number; + residualGapPx: number; + residualTargetPx: number; +} + +export interface SeamOverlapDiagnostics { + exactPairs: number; + nearPairs: number; + patchedPolygons: number; + patchedEdges: number; + maxMeasuredGapPx: number; + maxAppliedAmountPx: number; + unclosedPairs: number; + maxResidualGapPx: number; +} + +interface SeamBuildResult { + edgeRepairs: Array>; + diagnostics: SeamOverlapDiagnostics; + candidates?: SeamOverlapCandidate[]; +} + +interface EdgeRepairSegment { + start: number; + end: number; + amount: number; +} + +export interface SeamOverlapOptions { + overlapPx?: number; + maxGapPx?: number; + capacityScale?: number; +} + +export interface SeamFacetSplitOptions { + rotX?: number; + rotY?: number; + viewAware?: boolean; + passes?: number; + budget?: number; +} + +export type SeamFacetSplitCandidateReason = + | "component-anchor" + | "global-outlier" + | "local-follow-up" + | "shared-polygon" + | "below-threshold"; + +export interface SeamFacetSplitCandidate { + key: string; + aPolygon: number; + aEdge: number; + bPolygon: number; + bEdge: number; + color?: string; + materialKey: string; + lengthPx: number; + projectedLengthPx: number; + score: number; + normalRisk: number; + shapeRisk: number; + viewRisk: number; + component: number; + marginalCost: number; + selected: boolean; + reason: SeamFacetSplitCandidateReason; +} + +export interface SeamFacetSplitReport { + candidates: SeamFacetSplitCandidate[]; + selectedPolygons: number; + selectedEdges: number; + addedPolygons: number; +} + +interface ResolvedSeamFacetSplitOptions { + rotX: number; + rotY: number; + viewAware: boolean; + passes: number; + budget: number; +} + +interface ResolvedSeamOverlapOptions { + overlapPx: number; + maxGapPx: number; + capacityScale: number; +} + +const DEFAULT_TILE = 50; +const DEFAULT_SEAM_OVERLAP_PX = 2; +const MAX_NEAR_SEAM_GAP_PX = 14; +const MIN_RESIDUAL_PATCH_GAP_PX = 0.25; +const SEAM_SPLIT_INTERNAL_OVERLAP_PX = 1.25; +const SEAM_SPLIT_EDGE_OVERLAP_PX = 0.75; +const DEFAULT_SEAM_SPLIT_BUDGET = 20; +const MIN_PARALLEL_DOT = 0.985; +const MIN_FACING_DOT = 0.5; +const TRUE_GAP_OVERLAP_AMOUNT_RATIO = 0.175; +const DEFAULT_TRUE_GAP_OVERLAP_PX = 1.25; +const EPS = 1e-6; +const TOPOLOGY_EPS = 1e-4; + +export const DEFAULT_SEAM_OVERLAP_OPTIONS = { + overlapPx: DEFAULT_TRUE_GAP_OVERLAP_PX, + maxGapPx: MAX_NEAR_SEAM_GAP_PX, + capacityScale: 1, +} as const satisfies SeamOverlapOptions; + +export const DEFAULT_SEAM_FACET_SPLIT_OPTIONS = { + budget: DEFAULT_SEAM_SPLIT_BUDGET, +} as const satisfies SeamFacetSplitOptions; + +function finiteOr(value: number | undefined, fallback: number): number { + return typeof value === "number" && Number.isFinite(value) ? value : fallback; +} + +function resolveSeamOverlapOptions( + options?: number | SeamOverlapOptions, +): ResolvedSeamOverlapOptions { + if (typeof options === "number") { + return { + overlapPx: Math.max(0, options) * TRUE_GAP_OVERLAP_AMOUNT_RATIO, + maxGapPx: MAX_NEAR_SEAM_GAP_PX, + capacityScale: 1, + }; + } + + return { + overlapPx: Math.max(0, finiteOr(options?.overlapPx, DEFAULT_TRUE_GAP_OVERLAP_PX)), + maxGapPx: Math.max(0, finiteOr(options?.maxGapPx, MAX_NEAR_SEAM_GAP_PX)), + capacityScale: Math.max(0, finiteOr(options?.capacityScale, 1)), + }; +} + +function seamOverlapEnabled( + options: ResolvedSeamOverlapOptions, + rawOptions?: number | SeamOverlapOptions, +): boolean { + if (typeof rawOptions === "number" && rawOptions <= 0) return false; + return options.maxGapPx > EPS && options.capacityScale > EPS; +} + +function resolveSeamFacetSplitOptions(options?: SeamFacetSplitOptions): ResolvedSeamFacetSplitOptions { + return { + rotX: finiteOr(options?.rotX, 65), + rotY: finiteOr(options?.rotY, 45), + viewAware: options?.viewAware !== false, + passes: Math.max(1, Math.min(3, Math.round(finiteOr(options?.passes, 2)))), + budget: Math.max(0, Math.min(256, Math.round(finiteOr(options?.budget, DEFAULT_SEAM_SPLIT_BUDGET)))), + }; +} + +function cssPoints(vertices: Vec3[]): Vec3[] { + return vertices.map((v): Vec3 => [v[1] * DEFAULT_TILE, v[0] * DEFAULT_TILE, v[2] * DEFAULT_TILE]); +} + +function fromCssPoint(point: Vec3): Vec3 { + return [point[1] / DEFAULT_TILE, point[0] / DEFAULT_TILE, point[2] / DEFAULT_TILE]; +} + +function pointKey(point: Vec3): string { + return `${point[0]},${point[1]},${point[2]}`; +} + +function edgeKey(a: Vec3, b: Vec3): string { + const ak = pointKey(a); + const bk = pointKey(b); + return ak < bk ? `${ak}|${bk}` : `${bk}|${ak}`; +} + +function add(a: Vec3, b: Vec3): Vec3 { + return [a[0] + b[0], a[1] + b[1], a[2] + b[2]]; +} + +function sub(a: Vec3, b: Vec3): Vec3 { + return [a[0] - b[0], a[1] - b[1], a[2] - b[2]]; +} + +function scale(a: Vec3, value: number): Vec3 { + return [a[0] * value, a[1] * value, a[2] * value]; +} + +function length(a: Vec3): number { + return Math.hypot(a[0], a[1], a[2]); +} + +function normalize(a: Vec3): Vec3 | null { + const len = length(a); + return len > EPS ? [a[0] / len, a[1] / len, a[2] / len] : null; +} + +function dot(a: Vec3, b: Vec3): number { + return a[0] * b[0] + a[1] * b[1] + a[2] * b[2]; +} + +function rotateCssVec3(v: Vec3, rxDeg: number, ryDeg: number, rzDeg: number): Vec3 { + const dx = (rxDeg * Math.PI) / 180; + const dy = (ryDeg * Math.PI) / 180; + const dz = (rzDeg * Math.PI) / 180; + let [x, y, z] = v; + if (dz !== 0) { + const c = Math.cos(dz), s = Math.sin(dz); + [x, y] = [x * c - y * s, x * s + y * c]; + } + if (dy !== 0) { + const c = Math.cos(dy), s = Math.sin(dy); + [x, z] = [x * c + z * s, -x * s + z * c]; + } + if (dx !== 0) { + const c = Math.cos(dx), s = Math.sin(dx); + [y, z] = [y * c - z * s, y * s + z * c]; + } + return [x, y, z]; +} + +function cross(a: Vec3, b: Vec3): Vec3 { + return [ + a[1] * b[2] - a[2] * b[1], + a[2] * b[0] - a[0] * b[2], + a[0] * b[1] - a[1] * b[0], + ]; +} + +function surfaceNormal(points: Vec3[]): Vec3 | null { + if (points.length < 3) return null; + const origin = points[0]; + const normal: Vec3 = [0, 0, 0]; + for (let i = 1; i + 1 < points.length; i += 1) { + const a = sub(points[i], origin); + const b = sub(points[i + 1], origin); + const n = cross(a, b); + normal[0] -= n[0]; + normal[1] -= n[1]; + normal[2] -= n[2]; + } + return normalize(normal); +} + +function localBasis(points: Vec3[]): LocalBasis | null { + const origin = points[0]; + const normal = surfaceNormal(points); + if (!normal) return null; + + let rawXAxis: Vec3 | null = null; + for (let i = 0; i < points.length; i += 1) { + const edge = sub(points[(i + 1) % points.length], points[i]); + if (length(edge) > EPS) { + rawXAxis = edge; + break; + } + } + if (!rawXAxis) return null; + + const projected = dot(rawXAxis, normal); + const xRaw: Vec3 = [ + rawXAxis[0] - projected * normal[0], + rawXAxis[1] - projected * normal[1], + rawXAxis[2] - projected * normal[2], + ]; + const xAxis = normalize(xRaw); + if (!xAxis) return null; + const yAxis = normalize(cross(normal, xAxis)); + if (!yAxis) return null; + + const local = points.map((point): Vec2 => { + const d = sub(point, origin); + return [dot(d, xAxis), dot(d, yAxis)]; + }); + const area = signedArea(local); + return Math.abs(area) > EPS ? { origin, xAxis, yAxis, local, area } : null; +} + +function signedArea(points: Vec2[]): number { + let area = 0; + for (let i = 0; i < points.length; i += 1) { + const a = points[i]; + const b = points[(i + 1) % points.length]; + area += a[0] * b[1] - a[1] * b[0]; + } + return area / 2; +} + +function isWeaklyConvex(points: Vec2[]): boolean { + if (points.length < 3) return false; + let sign = 0; + for (let i = 0; i < points.length; i += 1) { + const a = points[i]; + const b = points[(i + 1) % points.length]; + const c = points[(i + 2) % points.length]; + const crossValue = (b[0] - a[0]) * (c[1] - b[1]) - (b[1] - a[1]) * (c[0] - b[0]); + if (Math.abs(crossValue) <= EPS) continue; + const nextSign = Math.sign(crossValue); + if (sign === 0) sign = nextSign; + else if (nextSign !== sign) return false; + } + return true; +} + +function edgeOutward2D(points: Vec2[], area: number, edgeIndex: number): Vec2 | null { + const a = points[edgeIndex]; + const b = points[(edgeIndex + 1) % points.length]; + const dx = b[0] - a[0]; + const dy = b[1] - a[1]; + const edgeLength = Math.hypot(dx, dy); + if (edgeLength <= EPS) return null; + const outwardSign = area > 0 ? 1 : -1; + return [ + outwardSign * (dy / edgeLength), + outwardSign * (-dx / edgeLength), + ]; +} + +function edgeOutward3D(basis: LocalBasis, edgeIndex: number): Vec3 | null { + const outward = edgeOutward2D(basis.local, basis.area, edgeIndex); + if (!outward) return null; + return normalize([ + basis.xAxis[0] * outward[0] + basis.yAxis[0] * outward[1], + basis.xAxis[1] * outward[0] + basis.yAxis[1] * outward[1], + basis.xAxis[2] * outward[0] + basis.yAxis[2] * outward[1], + ]); +} + +function edgeSafeCapacity(points: Vec2[], edgeIndex: number): number { + const count = points.length; + const a = points[edgeIndex]; + const b = points[(edgeIndex + 1) % count]; + const dx = b[0] - a[0]; + const dy = b[1] - a[1]; + const edgeLength = Math.hypot(dx, dy); + if (edgeLength <= EPS) return 0; + + const limits = [edgeLength * 0.24]; + let oppositeClearance = Infinity; + for (let i = 0; i < count; i += 1) { + if (i === edgeIndex || i === (edgeIndex + 1) % count) continue; + const p = points[i]; + const distance = Math.abs((p[0] - a[0]) * dy - (p[1] - a[1]) * dx) / edgeLength; + if (distance > EPS) oppositeClearance = Math.min(oppositeClearance, distance); + } + if (Number.isFinite(oppositeClearance)) limits.push(oppositeClearance * 0.32); + + const adjacentChecks: Array<{ origin: Vec2; point: Vec2 }> = [ + { origin: a, point: points[(edgeIndex + count - 1) % count] }, + { origin: b, point: points[(edgeIndex + 2) % count] }, + ]; + for (const adjacent of adjacentChecks) { + const adx = adjacent.point[0] - adjacent.origin[0]; + const ady = adjacent.point[1] - adjacent.origin[1]; + const adjacentLength = Math.hypot(adx, ady); + if (adjacentLength <= EPS) continue; + const sin = Math.abs(dx * ady - dy * adx) / (edgeLength * adjacentLength); + if (sin > EPS) limits.push(adjacentLength * sin * 0.32); + } + + return Math.max(0, Math.min(...limits.filter((value) => Number.isFinite(value) && value > 0))); +} + +function buildPolygonMetas(polygons: Polygon[]): PolygonMeta[] { + return polygons.map((polygon): PolygonMeta => { + const css = cssPoints(polygon.vertices); + const basis = localBasis(css); + const patchable = !hasTexture(polygon) && !!basis && isWeaklyConvex(basis.local); + const capacities = patchable + ? basis!.local.map((_, edgeIndex) => edgeSafeCapacity(basis!.local, edgeIndex)) + : []; + return { + basis, + cssPoints: css, + capacities, + patchable, + }; + }); +} + +function hasTexture(polygon: Polygon): boolean { + return !!(polygon.texture || polygon.material?.texture || polygon.textureTriangles?.length); +} + +function materialKey(polygon: Polygon): string { + return polygon.material?.key ?? polygon.color ?? ""; +} + +function polygonSurfaceArea(polygon: Polygon): number { + if (polygon.vertices.length < 3) return 0; + const origin = polygon.vertices[0]; + let area = 0; + for (let i = 1; i + 1 < polygon.vertices.length; i += 1) { + area += length(cross(sub(polygon.vertices[i], origin), sub(polygon.vertices[i + 1], origin))) * 0.5; + } + return area; +} + +function dominantSolidColor(polygons: Polygon[]): string | undefined { + const weights = new Map(); + for (const polygon of polygons) { + if (hasTexture(polygon) || !polygon.color) continue; + weights.set(polygon.color, (weights.get(polygon.color) ?? 0) + Math.max(polygonSurfaceArea(polygon), EPS)); + } + let bestColor: string | undefined; + let bestWeight = 0; + for (const [color, weight] of weights) { + if (weight > bestWeight) { + bestColor = color; + bestWeight = weight; + } + } + return bestColor; +} + +function buildEdgeRecords( + polygons: Polygon[], + metas: PolygonMeta[], + capacityScale: number, +): EdgeRecord[] { + const records: EdgeRecord[] = []; + for (let polygonIndex = 0; polygonIndex < metas.length; polygonIndex += 1) { + const meta = metas[polygonIndex]; + if (!meta.patchable || !meta.basis) continue; + for (let edgeIndex = 0; edgeIndex < meta.cssPoints.length; edgeIndex += 1) { + const a = meta.cssPoints[edgeIndex]; + const b = meta.cssPoints[(edgeIndex + 1) % meta.cssPoints.length]; + const edge = sub(b, a); + const edgeLength = length(edge); + const dir = normalize(edge); + const outward = edgeOutward3D(meta.basis, edgeIndex); + const normal = surfaceNormal(meta.cssPoints); + const capacity = (meta.capacities[edgeIndex] ?? 0) * capacityScale; + if (!dir || !outward || !normal || capacity <= EPS || edgeLength <= EPS) continue; + records.push({ + index: records.length, + polygon: polygonIndex, + edge: edgeIndex, + key: edgeKey(a, b), + a, + b, + length: edgeLength, + dir, + normal, + outward, + capacity, + materialKey: materialKey(polygons[polygonIndex]), + color: polygons[polygonIndex].color, + }); + } + } + return records; +} + +function compatibleRepairMaterials(a: EdgeRecord, b: EdgeRecord): boolean { + return a.materialKey === b.materialKey && a.color === b.color; +} + +function sameTopologyPoint(a: Vec3, b: Vec3): boolean { + return length(sub(a, b)) <= TOPOLOGY_EPS; +} + +function edgeRecordsTouch(a: EdgeRecord, b: EdgeRecord): boolean { + return sameTopologyPoint(a.a, b.a) || + sameTopologyPoint(a.a, b.b) || + sameTopologyPoint(a.b, b.a) || + sameTopologyPoint(a.b, b.b); +} + +function classifyCandidate(a: EdgeRecord, b: EdgeRecord): SeamOverlapCandidateKind { + if (!compatibleRepairMaterials(a, b)) return "material-boundary"; + return edgeRecordsTouch(a, b) ? "connected-facet" : "true-gap"; +} + +function seamOverlapCandidate( + kind: SeamOverlapCandidateKind, + a: EdgeRecord, + b: EdgeRecord, + info: NearSeamInfo, + targetClosurePx: number, + appliedClosurePx: number, +): SeamOverlapCandidate { + return { + kind, + aPolygon: a.polygon, + aEdge: a.edge, + bPolygon: b.polygon, + bEdge: b.edge, + aColor: a.color, + bColor: b.color, + aMaterialKey: a.materialKey, + bMaterialKey: b.materialKey, + gapPx: info.gap, + spanPx: info.aEnd - info.aStart, + aStartPx: info.aStart, + aEndPx: info.aEnd, + bStartPx: info.bStart, + bEndPx: info.bEnd, + targetClosurePx, + appliedClosurePx, + residualGapPx: Math.max(0, info.gap - appliedClosurePx), + residualTargetPx: Math.max(0, targetClosurePx - appliedClosurePx), + }; +} + +function addEdgeRepair( + repairs: Array>, + record: EdgeRecord, + start: number, + end: number, + amount: number, +): void { + const clippedStart = Math.max(0, Math.min(record.length, Math.min(start, end))); + const clippedEnd = Math.max(0, Math.min(record.length, Math.max(start, end))); + if (amount <= EPS || clippedEnd - clippedStart <= EPS) return; + const current = repairs[record.polygon].get(record.edge); + const segment = { start: clippedStart, end: clippedEnd, amount }; + if (current) current.push(segment); + else repairs[record.polygon].set(record.edge, [segment]); +} + +function emptyDiagnostics(): SeamOverlapDiagnostics { + return { + exactPairs: 0, + nearPairs: 0, + patchedPolygons: 0, + patchedEdges: 0, + maxMeasuredGapPx: 0, + maxAppliedAmountPx: 0, + unclosedPairs: 0, + maxResidualGapPx: 0, + }; +} + +function finishDiagnostics( + repairs: Array>, + diagnostics: SeamOverlapDiagnostics, +): SeamOverlapDiagnostics { + let maxAppliedAmountPx = 0; + let patchedEdges = 0; + let patchedPolygons = 0; + for (const polygonRepairs of repairs) { + if (polygonRepairs.size > 0) patchedPolygons += 1; + patchedEdges += polygonRepairs.size; + for (const segments of polygonRepairs.values()) { + for (const segment of segments) { + maxAppliedAmountPx = Math.max(maxAppliedAmountPx, segment.amount); + } + } + } + return { + ...diagnostics, + patchedPolygons, + patchedEdges, + maxAppliedAmountPx, + }; +} + +function buildSeamEdgeAmounts( + polygons: Polygon[], + options: ResolvedSeamOverlapOptions, + collectCandidates = false, +): SeamBuildResult { + const metas = buildPolygonMetas(polygons); + const records = buildEdgeRecords(polygons, metas, options.capacityScale); + const repairs = polygons.map(() => new Map()); + const diagnostics = emptyDiagnostics(); + const candidates = collectCandidates ? [] as SeamOverlapCandidate[] : undefined; + if (records.length === 0) { + return { edgeRepairs: repairs, diagnostics, candidates }; + } + + const edgeOwners = new Map(); + for (const record of records) { + const owners = edgeOwners.get(record.key); + if (owners) owners.push(record); + else edgeOwners.set(record.key, [record]); + } + + for (const owners of edgeOwners.values()) { + if (owners.length < 2) continue; + diagnostics.exactPairs += owners.length === 2 ? 1 : owners.length; + } + + buildNearSeamEdgeAmounts(records, edgeOwners, repairs, diagnostics, options, candidates); + return { + edgeRepairs: repairs, + diagnostics: finishDiagnostics(repairs, diagnostics), + candidates, + }; +} + +function buildNearSeamEdgeAmounts( + records: EdgeRecord[], + edgeOwners: ReadonlyMap, + repairs: Array>, + diagnostics: SeamOverlapDiagnostics, + options: ResolvedSeamOverlapOptions, + candidates: SeamOverlapCandidate[] | undefined, +): void { + const maxGap = options.maxGapPx; + const cellSize = Math.max(MAX_NEAR_SEAM_GAP_PX * 2, maxGap * 2); + const cells = new Map(); + for (const record of records) { + for (const key of segmentCellKeys(record, cellSize, maxGap)) { + const bucket = cells.get(key); + if (bucket) bucket.push(record); + else cells.set(key, [record]); + } + } + + for (const record of records) { + const seen = new Set(); + for (const queryKey of segmentCellKeys(record, cellSize, maxGap)) { + const bucket = cells.get(queryKey); + if (!bucket) continue; + for (const candidate of bucket) { + if (seen.has(candidate.index)) continue; + seen.add(candidate.index); + if (candidate.index <= record.index) continue; + if (candidate.polygon === record.polygon) continue; + if (record.key === candidate.key && (edgeOwners.get(record.key)?.length ?? 0) > 1) continue; + const info = nearSeamInfo(record, candidate, maxGap); + if (!info) continue; + const kind = classifyCandidate(record, candidate); + const targetClosure = info.gap + options.overlapPx; + if (kind === "material-boundary") { + candidates?.push(seamOverlapCandidate(kind, record, candidate, info, targetClosure, 0)); + continue; + } + + let remainingClosure = targetClosure; + if (remainingClosure <= MIN_RESIDUAL_PATCH_GAP_PX) { + candidates?.push(seamOverlapCandidate(kind, record, candidate, info, targetClosure, 0)); + continue; + } + + const maxClosureA = record.capacity * info.facingA; + const maxClosureB = candidate.capacity * info.facingB; + let closureA = Math.min(maxClosureA, remainingClosure / 2); + let closureB = Math.min(maxClosureB, remainingClosure / 2); + remainingClosure -= closureA + closureB; + if (remainingClosure > EPS) { + const extraA = Math.min(maxClosureA - closureA, remainingClosure); + closureA += extraA; + remainingClosure -= extraA; + } + if (remainingClosure > EPS) { + const extraB = Math.min(maxClosureB - closureB, remainingClosure); + closureB += extraB; + remainingClosure -= extraB; + } + + const appliedClosure = closureA + closureB; + candidates?.push(seamOverlapCandidate(kind, record, candidate, info, targetClosure, appliedClosure)); + if (closureA > EPS) addEdgeRepair(repairs, record, info.aStart, info.aEnd, closureA / info.facingA); + if (closureB > EPS) addEdgeRepair(repairs, candidate, info.bStart, info.bEnd, closureB / info.facingB); + diagnostics.nearPairs += 1; + diagnostics.maxMeasuredGapPx = Math.max(diagnostics.maxMeasuredGapPx, info.gap); + if (remainingClosure > MIN_RESIDUAL_PATCH_GAP_PX) { + diagnostics.unclosedPairs += 1; + diagnostics.maxResidualGapPx = Math.max(diagnostics.maxResidualGapPx, remainingClosure); + } + } + } + } +} + +function cellCoords(point: Vec3, cellSize: number): [number, number, number] { + return [ + Math.floor(point[0] / cellSize), + Math.floor(point[1] / cellSize), + Math.floor(point[2] / cellSize), + ]; +} + +function segmentCellKeys(record: EdgeRecord, cellSize: number, padding: number): string[] { + const minX = Math.min(record.a[0], record.b[0]) - padding; + const minY = Math.min(record.a[1], record.b[1]) - padding; + const minZ = Math.min(record.a[2], record.b[2]) - padding; + const maxX = Math.max(record.a[0], record.b[0]) + padding; + const maxY = Math.max(record.a[1], record.b[1]) + padding; + const maxZ = Math.max(record.a[2], record.b[2]) + padding; + const [minCx, minCy, minCz] = cellCoords([minX, minY, minZ], cellSize); + const [maxCx, maxCy, maxCz] = cellCoords([maxX, maxY, maxZ], cellSize); + const keys: string[] = []; + for (let x = minCx; x <= maxCx; x += 1) { + for (let y = minCy; y <= maxCy; y += 1) { + for (let z = minCz; z <= maxCz; z += 1) { + keys.push(`${x},${y},${z}`); + } + } + } + return keys; +} + +function nearSeamInfo(a: EdgeRecord, b: EdgeRecord, maxGap: number): NearSeamInfo | null { + if (Math.abs(dot(a.dir, b.dir)) < MIN_PARALLEL_DOT) return null; + + const bStart = dot(sub(b.a, a.a), a.dir); + const bEnd = dot(sub(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 overlap = overlapEnd - overlapStart; + const minLength = Math.min(a.length, b.length); + if (overlap < Math.max(0.75, minLength * 0.12)) return null; + + const a0 = add(a.a, scale(a.dir, overlapStart)); + const a1 = add(a.a, scale(a.dir, overlapEnd)); + const aPoint = add(a.a, scale(a.dir, (overlapStart + overlapEnd) / 2)); + const bT = Math.max(0, Math.min(b.length, dot(sub(aPoint, b.a), b.dir))); + const bPoint = add(b.a, scale(b.dir, bT)); + const gapVector = sub(bPoint, aPoint); + const gap = length(gapVector); + if (gap <= EPS) return null; + if (gap > maxGap) return null; + if (gap > Math.min(maxGap, Math.max(4, minLength * 0.28))) return null; + + const gapDir = scale(gapVector, 1 / gap); + const facingA = dot(a.outward, gapDir); + const facingB = dot(b.outward, scale(gapDir, -1)); + if (facingA < MIN_FACING_DOT || facingB < MIN_FACING_DOT) return null; + const b0T = Math.max(0, Math.min(b.length, dot(sub(a0, b.a), b.dir))); + const b1T = Math.max(0, Math.min(b.length, dot(sub(a1, b.a), b.dir))); + const b0 = add(b.a, scale(b.dir, b0T)); + const b1 = add(b.a, scale(b.dir, b1T)); + return { + gap, + facingA, + facingB, + aStart: overlapStart, + aEnd: overlapEnd, + bStart: Math.min(b0T, b1T), + bEnd: Math.max(b0T, b1T), + a0, + a1, + b0, + b1, + }; +} + +function repairPoint(a: Vec2, b: Vec2, edgeLength: number, distance: number): Vec2 { + const t = edgeLength <= EPS ? 0 : Math.max(0, Math.min(1, distance / edgeLength)); + return [ + a[0] + (b[0] - a[0]) * t, + a[1] + (b[1] - a[1]) * t, + ]; +} + +function pushUniquePoint(points: Vec2[], point: Vec2): void { + const last = points[points.length - 1]; + if (last && Math.hypot(last[0] - point[0], last[1] - point[1]) <= EPS) return; + points.push(point); +} + +function normalizeRepairSegments( + segments: EdgeRepairSegment[] | undefined, + edgeLength: number, + capacity: number, +): EdgeRepairSegment[] { + if (!segments?.length || edgeLength <= EPS || capacity <= EPS) return []; + const stops = new Set([0, edgeLength]); + for (const segment of segments) { + const start = Math.max(0, Math.min(edgeLength, segment.start)); + const end = Math.max(0, Math.min(edgeLength, segment.end)); + if (end - start <= EPS || segment.amount <= EPS) continue; + stops.add(start); + stops.add(end); + } + + const sortedStops = Array.from(stops).sort((a, b) => a - b); + const normalized: EdgeRepairSegment[] = []; + for (let i = 0; i + 1 < sortedStops.length; i += 1) { + const start = sortedStops[i]; + const end = sortedStops[i + 1]; + if (end - start <= EPS) continue; + const mid = (start + end) / 2; + let amount = 0; + for (const segment of segments) { + if (mid + EPS < segment.start || mid - EPS > segment.end) continue; + amount = Math.max(amount, segment.amount); + } + const safeAmount = Math.min(capacity, amount); + if (safeAmount > EPS) normalized.push({ start, end, amount: safeAmount }); + } + return normalized; +} + +function isValidRepairedPolygon(source: Vec2[], repaired: Vec2[]): boolean { + if (repaired.length < 3) return false; + const sourceArea = signedArea(source); + const repairedArea = signedArea(repaired); + return Math.sign(repairedArea) === Math.sign(sourceArea) && Math.abs(repairedArea) >= Math.abs(sourceArea) - EPS; +} + +function patchPolygon( + polygon: Polygon, + edgeRepairs: ReadonlyMap, + capacityScale: number, +): Polygon { + if (edgeRepairs.size === 0 || polygon.vertices.length < 3) return polygon; + if (hasTexture(polygon)) return polygon; + + const points = cssPoints(polygon.vertices); + const basis = localBasis(points); + if (!basis || !isWeaklyConvex(basis.local)) return polygon; + + const repaired: Vec2[] = []; + for (let edgeIndex = 0; edgeIndex < basis.local.length; edgeIndex += 1) { + const a = basis.local[edgeIndex]; + const b = basis.local[(edgeIndex + 1) % basis.local.length]; + const edgeLength = Math.hypot(b[0] - a[0], b[1] - a[1]); + const outward = edgeOutward2D(basis.local, basis.area, edgeIndex); + if (!outward || edgeLength <= EPS) return polygon; + const segments = normalizeRepairSegments( + edgeRepairs.get(edgeIndex), + edgeLength, + edgeSafeCapacity(basis.local, edgeIndex) * capacityScale, + ); + + pushUniquePoint(repaired, a); + let cursor = 0; + for (const segment of segments) { + if (segment.start > cursor + EPS) { + pushUniquePoint(repaired, repairPoint(a, b, edgeLength, segment.start)); + } + + const start = repairPoint(a, b, edgeLength, segment.start); + const end = repairPoint(a, b, edgeLength, segment.end); + pushUniquePoint(repaired, [ + start[0] + outward[0] * segment.amount, + start[1] + outward[1] * segment.amount, + ]); + pushUniquePoint(repaired, [ + end[0] + outward[0] * segment.amount, + end[1] + outward[1] * segment.amount, + ]); + if (segment.end < edgeLength - EPS) pushUniquePoint(repaired, end); + cursor = segment.end; + } + } + + const first = repaired[0]; + const last = repaired[repaired.length - 1]; + if (first && last && Math.hypot(first[0] - last[0], first[1] - last[1]) <= EPS) repaired.pop(); + if (!isValidRepairedPolygon(basis.local, repaired)) return polygon; + + const vertices = repaired.map(([x, y]) => fromCssPoint([ + basis.origin[0] + x * basis.xAxis[0] + y * basis.yAxis[0], + basis.origin[1] + x * basis.xAxis[1] + y * basis.yAxis[1], + basis.origin[2] + x * basis.xAxis[2] + y * basis.yAxis[2], + ])); + return { ...polygon, vertices }; +} + +function isRefinableSeamQuad(polygon: Polygon): boolean { + return polygon.vertices.length === 4 && !hasTexture(polygon); +} + +function quadShapeRisk(meta: PolygonMeta): number { + const local = meta.basis?.local; + if (!local || local.length !== 4) return 1; + let minX = Infinity; + let minY = Infinity; + let maxX = -Infinity; + let maxY = -Infinity; + for (const [x, y] of local) { + minX = Math.min(minX, x); + minY = Math.min(minY, y); + maxX = Math.max(maxX, x); + maxY = Math.max(maxY, y); + } + const bboxArea = Math.max(EPS, (maxX - minX) * (maxY - minY)); + const fillRatio = Math.max(0, Math.min(1, Math.abs(meta.basis?.area ?? 0) / bboxArea)); + return 0.75 + (1 - fillRatio) * 2.5; +} + +function cssDistance(a: Vec3, b: Vec3): number { + return length(sub(cssPoints([a])[0], cssPoints([b])[0])); +} + +function triangleQuality(a: Vec3, b: Vec3, c: Vec3): number { + const ab = cssDistance(a, b); + const bc = cssDistance(b, c); + const ca = cssDistance(c, a); + const longest = Math.max(ab, bc, ca); + if (longest <= EPS) return 0; + const area = length(cross(sub(cssPoints([b])[0], cssPoints([a])[0]), sub(cssPoints([c])[0], cssPoints([a])[0]))) * 0.5; + return area / (longest * longest); +} + +function intersectLocalLines(a0: Vec2, a1: Vec2, b0: Vec2, b1: Vec2): Vec2 | null { + const rx = a1[0] - a0[0]; + const ry = a1[1] - a0[1]; + const sx = b1[0] - b0[0]; + const sy = b1[1] - b0[1]; + const det = rx * sy - ry * sx; + if (Math.abs(det) <= EPS) return null; + const qpx = b0[0] - a0[0]; + const qpy = b0[1] - a0[1]; + const t = (qpx * sy - qpy * sx) / det; + return [a0[0] + t * rx, a0[1] + t * ry]; +} + +function offsetLocalConvexPointsByEdgeAmounts(points: Vec2[], amounts: number[]): Vec2[] | null { + if (points.length < 3 || points.length !== amounts.length) return null; + const maxAmount = Math.max(0, ...amounts); + if (maxAmount <= EPS) return points; + const area = signedArea(points); + if (Math.abs(area) <= EPS) return null; + const outwardSign = area > 0 ? 1 : -1; + const offsetLines: Array<{ a: Vec2; b: Vec2 }> = []; + for (let i = 0; i < points.length; i += 1) { + const a = points[i]; + const b = points[(i + 1) % points.length]; + const dx = b[0] - a[0]; + const dy = b[1] - a[1]; + const edgeLength = Math.hypot(dx, dy); + if (edgeLength <= EPS) return null; + const amount = Math.max(0, amounts[i]); + const ox = outwardSign * (dy / edgeLength) * amount; + const oy = outwardSign * (-dx / edgeLength) * amount; + offsetLines.push({ + a: [a[0] + ox, a[1] + oy], + b: [b[0] + ox, b[1] + oy], + }); + } + + const expanded: Vec2[] = []; + const maxMiter = Math.max(2, maxAmount * 4); + for (let i = 0; i < points.length; i += 1) { + const prev = offsetLines[(i + points.length - 1) % points.length]; + const next = offsetLines[i]; + const intersection = intersectLocalLines(prev.a, prev.b, next.a, next.b); + if (!intersection) return null; + const original = points[i]; + const dx = intersection[0] - original[0]; + const dy = intersection[1] - original[1]; + const miter = Math.hypot(dx, dy); + expanded.push( + miter > maxMiter + ? [original[0] + (dx / miter) * maxMiter, original[1] + (dy / miter) * maxMiter] + : intersection, + ); + } + return expanded; +} + +function cssPointFromLocal(basis: LocalBasis, point: Vec2): Vec3 { + return [ + basis.origin[0] + basis.xAxis[0] * point[0] + basis.yAxis[0] * point[1], + basis.origin[1] + basis.xAxis[1] * point[0] + basis.yAxis[1] * point[1], + basis.origin[2] + basis.xAxis[2] * point[0] + basis.yAxis[2] * point[1], + ]; +} + +function offsetTriangleVertices(vertices: Vec3[], amounts: [number, number, number]): Vec3[] { + const basis = localBasis(cssPoints(vertices)); + if (!basis) return vertices; + const expanded = offsetLocalConvexPointsByEdgeAmounts(basis.local, amounts); + return expanded + ? expanded.map((point) => fromCssPoint(cssPointFromLocal(basis, point))) + : vertices; +} + +function splitQuadIntoTriangles(polygon: Polygon, edges: ReadonlySet): Polygon[] { + const solid: Polygon = { ...polygon, uvs: undefined, textureTriangles: undefined }; + const [a, b, c, d] = polygon.vertices; + const acQuality = Math.min(triangleQuality(a, b, c), triangleQuality(a, c, d)); + const bdQuality = Math.min(triangleQuality(a, b, d), triangleQuality(b, c, d)); + const seamAmount = (edge: number): number => edges.has(edge) ? SEAM_SPLIT_EDGE_OVERLAP_PX : 0; + return acQuality >= bdQuality + ? [ + { ...solid, vertices: offsetTriangleVertices([a, b, c], [seamAmount(0), seamAmount(1), SEAM_SPLIT_INTERNAL_OVERLAP_PX]) }, + { ...solid, vertices: offsetTriangleVertices([a, c, d], [SEAM_SPLIT_INTERNAL_OVERLAP_PX, seamAmount(2), seamAmount(3)]) }, + ] + : [ + { ...solid, vertices: offsetTriangleVertices([a, b, d], [seamAmount(0), SEAM_SPLIT_INTERNAL_OVERLAP_PX, seamAmount(3)]) }, + { ...solid, vertices: offsetTriangleVertices([b, c, d], [seamAmount(1), seamAmount(2), SEAM_SPLIT_INTERNAL_OVERLAP_PX]) }, + ]; +} + +interface SeamFacetSplitCandidateInternal { + key: string; + owners: EdgeRecord[]; + records: EdgeRecord[]; + length: number; + rankLength: number; + projectedLength: number; + score: number; + normalRisk: number; + shapeRisk: number; + viewRisk: number; + component: number; + selected: boolean; + reason: SeamFacetSplitCandidateReason; + marginalCost: number; +} + +interface SeamFacetSplitPlan { + selected: Map>; + candidates: SeamFacetSplitCandidateInternal[]; + budgetLimit: number; +} + +interface SeamFacetSplitFollowUpContext { + endpoints: ReadonlySet; +} + +type SeamFacetSplitSelectionMode = "primary" | "follow-up"; + +interface SeamFacetSplitBudgetPlan { + budgetLimit: number; + componentBudgets: ReadonlyMap | null; +} + +interface NumericDistribution { + q1: number; + median: number; + q3: number; + p70: number; + p80: number; + p88: number; + p95: number; + p97: number; + max: number; +} + +function numericDistribution(values: number[]): NumericDistribution { + if (values.length === 0) { + return { + q1: Infinity, + median: Infinity, + q3: Infinity, + p70: Infinity, + p80: Infinity, + p88: Infinity, + p95: Infinity, + p97: Infinity, + max: Infinity, + }; + } + const sorted = [...values].sort((a, b) => a - b); + const at = (ratio: number) => sorted[Math.min(sorted.length - 1, Math.floor((sorted.length - 1) * ratio))] ?? 0; + return { + q1: at(0.25), + median: at(0.5), + q3: at(0.75), + p70: at(0.7), + p80: at(0.8), + p88: at(0.88), + p95: at(0.95), + p97: at(0.97), + max: sorted[sorted.length - 1] ?? 0, + }; +} + +class UnionFind { + private parents: number[]; + + constructor(size: number) { + this.parents = Array.from({ length: size }, (_, index) => index); + } + + find(index: number): number { + const parent = this.parents[index]; + if (parent === index) return index; + const root = this.find(parent); + this.parents[index] = root; + return root; + } + + union(a: number, b: number): void { + const rootA = this.find(a); + const rootB = this.find(b); + if (rootA !== rootB) this.parents[rootB] = rootA; + } +} + +function splitCandidateEndpointKeys(candidate: SeamFacetSplitCandidateInternal): string[] { + const first = candidate.records[0]; + if (!first) return []; + return [pointKey(first.a), pointKey(first.b)]; +} + +function splitPlanFollowUpContext(candidates: SeamFacetSplitCandidateInternal[]): SeamFacetSplitFollowUpContext { + const endpoints = new Set(); + const components = new Set(); + for (const candidate of candidates) { + if (!candidate.selected) continue; + components.add(candidate.component); + } + for (const candidate of candidates) { + if (!components.has(candidate.component)) continue; + for (const endpoint of splitCandidateEndpointKeys(candidate)) endpoints.add(endpoint); + } + return { endpoints }; +} + +function splitCandidateTouchesFollowUp( + candidate: SeamFacetSplitCandidateInternal, + context: SeamFacetSplitFollowUpContext | undefined, +): boolean { + if (!context) return true; + return splitCandidateEndpointKeys(candidate).some((endpoint) => context.endpoints.has(endpoint)); +} + +function buildSeamFacetSplitCandidates( + polygons: Polygon[], + metas: PolygonMeta[], + edgeOwners: ReadonlyMap, + splitOptions: ResolvedSeamFacetSplitOptions, +): SeamFacetSplitCandidateInternal[] { + const candidates: SeamFacetSplitCandidateInternal[] = []; + for (const [key, owners] of edgeOwners) { + if (owners.length !== 2) continue; + const [a, b] = owners; + if (!compatibleRepairMaterials(a, b)) continue; + const refinable = owners.filter((record) => isRefinableSeamQuad(polygons[record.polygon])); + if (refinable.length === 0) continue; + + const normalRisk = 1 - Math.min(1, Math.abs(dot(a.normal, b.normal))); + const seamLength = Math.max(a.length, b.length); + if (normalRisk < 0.01 && seamLength < 64) continue; + + const shapeRisk = Math.max(...refinable.map((record) => quadShapeRisk(metas[record.polygon]))); + const bothSidesRefinable = refinable.length === 2 ? 1.18 : 1; + const seamRisk = Math.max(0.08, normalRisk); + const projected = splitOptions.viewAware + ? splitCandidateViewMetrics(a, b, splitOptions.rotX, splitOptions.rotY) + : { projectedLength: seamLength, viewRisk: 1 }; + const rankLength = splitOptions.viewAware ? Math.max(projected.projectedLength, seamLength * 0.12) : seamLength; + const viewWeight = splitOptions.viewAware ? 0.18 + projected.viewRisk * 1.82 : 1; + candidates.push({ + key, + owners, + records: refinable, + length: seamLength, + rankLength, + projectedLength: projected.projectedLength, + score: rankLength * (0.58 + seamRisk * 2.8) * shapeRisk * bothSidesRefinable * viewWeight, + normalRisk, + shapeRisk, + viewRisk: projected.viewRisk, + component: -1, + selected: false, + reason: "below-threshold", + marginalCost: refinable.length, + }); + } + + const union = new UnionFind(candidates.length); + const byEndpoint = new Map(); + for (let index = 0; index < candidates.length; index += 1) { + for (const endpoint of splitCandidateEndpointKeys(candidates[index])) { + const previous = byEndpoint.get(endpoint); + if (previous !== undefined) union.union(previous, index); + else byEndpoint.set(endpoint, index); + } + } + + const componentIds = new Map(); + for (let index = 0; index < candidates.length; index += 1) { + const root = union.find(index); + let component = componentIds.get(root); + if (component === undefined) { + component = componentIds.size; + componentIds.set(root, component); + } + candidates[index].component = component; + } + + return candidates; +} + +function splitCandidateViewMetrics( + a: EdgeRecord, + b: EdgeRecord, + rotX: number, + rotY: number, +): { projectedLength: number; viewRisk: number } { + const p0 = rotateCssVec3(a.a, rotX, 0, rotY); + const p1 = rotateCssVec3(a.b, rotX, 0, rotY); + const projectedLength = Math.hypot(p1[0] - p0[0], p1[1] - p0[1]); + const depthA = rotateCssVec3(a.normal, rotX, 0, rotY)[2]; + const depthB = rotateCssVec3(b.normal, rotX, 0, rotY)[2]; + const frontness = Math.max(0, depthA, depthB); + const silhouette = Math.min(1, Math.abs(depthA - depthB)); + return { + projectedLength, + viewRisk: Math.max(0.04, Math.min(1, frontness * 0.85 + silhouette * 0.45)), + }; +} + +function strongestCandidatesByComponent( + candidates: SeamFacetSplitCandidateInternal[], +): Set { + const strongest = new Map(); + for (const candidate of candidates) { + const current = strongest.get(candidate.component); + if (!current || candidate.score > current.score || (candidate.score === current.score && candidate.length > current.length)) { + strongest.set(candidate.component, candidate); + } + } + return new Set(strongest.values()); +} + +function splitCandidateBudgetPriority(candidate: SeamFacetSplitCandidateInternal): number { + const coplanar = Math.max(0, 1 - Math.min(1, candidate.normalRisk)); + const coplanarWeight = 0.35 + coplanar * coplanar * coplanar * 3.65; + const oneSidedWeight = candidate.records.length === 1 ? 1.08 : 1; + return (candidate.rankLength * coplanarWeight * oneSidedWeight) / Math.sqrt(Math.max(1, candidate.records.length)); +} + +function splitCandidateLikelyCrackPriority(candidate: SeamFacetSplitCandidateInternal): number { + const coplanar = Math.max(0, 1 - Math.min(1, candidate.normalRisk)); + const visibleWeight = 0.35 + Math.min(1, candidate.viewRisk) * 1.65; + const shapeWeight = 0.7 + Math.min(2.6, candidate.shapeRisk) * 0.3; + const oneSidedWeight = candidate.records.length === 1 ? 1.1 : 1; + const visiblePriority = ( + candidate.rankLength * + (0.6 + coplanar * coplanar * 1.4) * + visibleWeight * + shapeWeight * + oneSidedWeight + ) / Math.sqrt(Math.max(1, candidate.records.length)); + return Math.max(visiblePriority, splitCandidateBudgetPriority(candidate) * 0.75); +} + +function uniqueSplitPolygonCost(candidates: SeamFacetSplitCandidateInternal[]): number { + const polygons = new Set(); + for (const candidate of candidates) { + for (const record of candidate.records) polygons.add(record.polygon); + } + return polygons.size; +} + +function splitCandidateLikelyCrack(candidate: SeamFacetSplitCandidateInternal, lengthStats: NumericDistribution): boolean { + return candidate.normalRisk <= 0.25 && + candidate.rankLength >= lengthStats.median * 1.08 && + candidate.projectedLength >= 24; +} + +function splitCandidateGlobalSeed( + candidate: SeamFacetSplitCandidateInternal, + strongScore: number, + strongLength: number, +): boolean { + return candidate.score >= strongScore || candidate.rankLength >= strongLength; +} + +function seamFacetSplitBudgetPlan( + candidates: SeamFacetSplitCandidateInternal[], + mode: SeamFacetSplitSelectionMode, + maxBudget: number, + scoreStats: NumericDistribution, + lengthStats: NumericDistribution, + strongScore: number, + strongLength: number, +): SeamFacetSplitBudgetPlan { + const budgetLimit = Math.max(0, Math.floor(maxBudget)); + if (budgetLimit <= 0 || candidates.length === 0 || mode === "follow-up") { + return { budgetLimit, componentBudgets: null }; + } + + const byComponent = new Map(); + for (const candidate of candidates) { + const group = byComponent.get(candidate.component); + if (group) group.push(candidate); + else byComponent.set(candidate.component, [candidate]); + } + + const componentBudgets = new Map(); + const scoreSpread = Math.max(EPS, scoreStats.q3 - scoreStats.q1); + const fallbackSeedScore = Math.min(scoreStats.max, Math.max(scoreStats.p95, scoreStats.q3 + scoreSpread)); + let total = 0; + for (const [component, group] of byComponent) { + const primary = group.filter((candidate) => + splitCandidateGlobalSeed(candidate, strongScore, strongLength) || + candidate.score >= fallbackSeedScore + ); + if (primary.length === 0) continue; + + const likely = group.filter((candidate) => splitCandidateLikelyCrack(candidate, lengthStats)); + const primaryCost = uniqueSplitPolygonCost(primary); + const likelyCost = uniqueSplitPolygonCost(likely); + const followUpAllowance = Math.ceil(Math.sqrt(Math.max(0, likelyCost - primaryCost)) * 3); + const componentBudget = Math.min( + uniqueSplitPolygonCost(group), + primaryCost + followUpAllowance, + ); + if (componentBudget <= 0) continue; + componentBudgets.set(component, componentBudget); + total += componentBudget; + } + + if (componentBudgets.size === 0) return { budgetLimit: 0, componentBudgets }; + return { + budgetLimit: Math.min(budgetLimit, total), + componentBudgets, + }; +} + +function selectSeamFacetSplitCandidates( + candidates: SeamFacetSplitCandidateInternal[], + mode: SeamFacetSplitSelectionMode, + budget: number, +): { selected: Map>; budgetLimit: number } { + const selected = new Map>(); + if (candidates.length === 0 || budget <= 0) return { selected, budgetLimit: 0 }; + + const scoreStats = numericDistribution(candidates.map((candidate) => candidate.score)); + const lengthStats = numericDistribution(candidates.map((candidate) => candidate.rankLength)); + const scoreSpread = Math.max(EPS, scoreStats.q3 - scoreStats.q1); + const strongScore = Math.min(scoreStats.max, Math.max(scoreStats.p95, scoreStats.q3 + scoreSpread * 0.85)); + const anchorScore = Math.min(scoreStats.max, Math.max(scoreStats.p80, scoreStats.q3 + scoreSpread * 0.15)); + const sharedPolygonScore = Math.min(scoreStats.max, Math.max(scoreStats.p70, scoreStats.q3)); + const strongLength = Math.max(lengthStats.p97, lengthStats.median * 1.8); + const anchorLength = Math.max(lengthStats.p88, lengthStats.median * 1.35); + const budgetPlan = seamFacetSplitBudgetPlan( + candidates, + mode, + budget, + scoreStats, + lengthStats, + strongScore, + strongLength, + ); + let remainingBudget = budgetPlan.budgetLimit; + if (remainingBudget <= 0) return { selected, budgetLimit: 0 }; + + const anchors = strongestCandidatesByComponent(candidates); + const alreadySplit = new Set(); + const selectedEndpoints = new Set(); + const selectedComponents = new Set(); + const componentSpend = new Map(); + const localReserve = mode === "primary" + ? Math.min(Math.max(0, budgetPlan.budgetLimit - 1), Math.max(2, Math.floor(budgetPlan.budgetLimit * 0.28))) + : 0; + + const sorted = [...candidates].sort((a, b) => + splitCandidateBudgetPriority(b) - splitCandidateBudgetPriority(a) || + (b.score / Math.max(1, b.records.length)) - (a.score / Math.max(1, a.records.length)) || + b.length - a.length + ); + const likelyCrackSorted = [...candidates].sort((a, b) => + splitCandidateLikelyCrackPriority(b) - splitCandidateLikelyCrackPriority(a) || + splitCandidateBudgetPriority(b) - splitCandidateBudgetPriority(a) || + b.length - a.length + ); + + const selectCandidate = (candidate: SeamFacetSplitCandidateInternal, reason: SeamFacetSplitCandidateReason): void => { + candidate.selected = true; + candidate.reason = reason; + selectedComponents.add(candidate.component); + for (const endpoint of splitCandidateEndpointKeys(candidate)) selectedEndpoints.add(endpoint); + for (const record of candidate.records) { + alreadySplit.add(record.polygon); + const edges = selected.get(record.polygon); + if (edges) edges.add(record.edge); + else selected.set(record.polygon, new Set([record.edge])); + } + }; + + const withinComponentBudget = (candidate: SeamFacetSplitCandidateInternal, marginalCost: number): boolean => { + const componentBudgets = budgetPlan.componentBudgets; + if (marginalCost <= 0 || !componentBudgets) return true; + const componentBudget = componentBudgets.get(candidate.component) ?? 0; + return (componentSpend.get(candidate.component) ?? 0) + marginalCost <= componentBudget; + }; + + const recordComponentSpend = (candidate: SeamFacetSplitCandidateInternal, marginalCost: number): void => { + if (marginalCost <= 0 || !budgetPlan.componentBudgets) return; + componentSpend.set(candidate.component, (componentSpend.get(candidate.component) ?? 0) + marginalCost); + }; + + for (const candidate of likelyCrackSorted) { + const marginalCost = candidate.records.reduce( + (total, record) => total + (alreadySplit.has(record.polygon) ? 0 : 1), + 0, + ); + candidate.marginalCost = marginalCost; + if (marginalCost > remainingBudget) continue; + if (!withinComponentBudget(candidate, marginalCost)) continue; + if (marginalCost > 0 && remainingBudget - marginalCost < localReserve) continue; + const isLikelyCrack = splitCandidateLikelyCrack(candidate, lengthStats); + if (!isLikelyCrack) continue; + selectCandidate(candidate, "global-outlier"); + recordComponentSpend(candidate, marginalCost); + remainingBudget -= marginalCost; + if (remainingBudget <= 0) return { selected, budgetLimit: budgetPlan.budgetLimit }; + } + + for (const candidate of sorted) { + if (candidate.selected) continue; + const marginalCost = candidate.records.reduce( + (total, record) => total + (alreadySplit.has(record.polygon) ? 0 : 1), + 0, + ); + candidate.marginalCost = marginalCost; + if (marginalCost > remainingBudget) continue; + if (!withinComponentBudget(candidate, marginalCost)) continue; + if (marginalCost > 0 && remainingBudget - marginalCost < localReserve) continue; + + const twoSided = candidate.records.length === 2; + const isAnchor = anchors.has(candidate) && ( + mode === "primary" || + (mode === "follow-up" && candidate.score >= anchorScore * 1.08 && candidate.rankLength >= anchorLength) + ) && ( + twoSided + ? candidate.score >= anchorScore || candidate.rankLength >= anchorLength + : candidate.score >= anchorScore * 1.18 || candidate.rankLength >= anchorLength * 1.12 + ); + const isGlobalOutlier = twoSided + ? candidate.score >= strongScore || candidate.rankLength >= strongLength + : candidate.score >= strongScore * 1.35 || candidate.rankLength >= strongLength * 1.2; + const isSharedPolygonFollowUp = marginalCost === 0 && ( + twoSided + ? candidate.score >= sharedPolygonScore || candidate.rankLength >= anchorLength + : candidate.score >= sharedPolygonScore * 1.25 || candidate.rankLength >= anchorLength * 1.12 + ); + if (!isAnchor && !isGlobalOutlier && !isSharedPolygonFollowUp) continue; + + selectCandidate(candidate, isAnchor + ? "component-anchor" + : isGlobalOutlier + ? "global-outlier" + : "shared-polygon"); + recordComponentSpend(candidate, marginalCost); + remainingBudget -= marginalCost; + if (remainingBudget <= 0) return { selected, budgetLimit: budgetPlan.budgetLimit }; + } + + for (const candidate of sorted) { + if (candidate.selected) continue; + const marginalCost = candidate.records.reduce( + (total, record) => total + (alreadySplit.has(record.polygon) ? 0 : 1), + 0, + ); + candidate.marginalCost = marginalCost; + if (marginalCost > 1 || marginalCost > remainingBudget) continue; + if (!withinComponentBudget(candidate, marginalCost)) continue; + const touchesSelectedPolygon = candidate.records.some((record) => alreadySplit.has(record.polygon)); + const touchesSelectedEndpoint = splitCandidateEndpointKeys(candidate).some((endpoint) => selectedEndpoints.has(endpoint)); + const isOneSidedTail = mode === "primary" && + candidate.records.length === 1 && + marginalCost === 1 && + (selectedComponents.has(candidate.component) || touchesSelectedEndpoint || touchesSelectedPolygon) && + candidate.rankLength >= anchorLength * 1.05 && + candidate.normalRisk >= 0.16 && + candidate.viewRisk <= 0.32; + if (isOneSidedTail) { + selectCandidate(candidate, "local-follow-up"); + recordComponentSpend(candidate, marginalCost); + remainingBudget -= marginalCost; + if (remainingBudget <= 0) return { selected, budgetLimit: budgetPlan.budgetLimit }; + continue; + } + if (candidate.records.length !== 2) continue; + const isProjectedNeighbor = touchesSelectedPolygon && + marginalCost === 1 && + candidate.projectedLength >= anchorLength * 1.45 && + candidate.viewRisk <= 0.18; + if (!touchesSelectedEndpoint && !isProjectedNeighbor) continue; + const isLocalFollowUp = ( + candidate.score >= sharedPolygonScore * 0.42 || + candidate.rankLength >= anchorLength * 0.62 + ) && candidate.score <= sharedPolygonScore * 1.1 && candidate.projectedLength >= 24; + if (isLocalFollowUp) { + selectCandidate(candidate, "local-follow-up"); + recordComponentSpend(candidate, marginalCost); + remainingBudget -= marginalCost; + if (remainingBudget <= 0) return { selected, budgetLimit: budgetPlan.budgetLimit }; + } + } + + return { selected, budgetLimit: budgetPlan.budgetLimit }; +} + +function seamFacetSplitPlan( + polygons: Polygon[], + seamOptions: ResolvedSeamOverlapOptions, + splitOptions: ResolvedSeamFacetSplitOptions, + budget: number, + followUpContext?: SeamFacetSplitFollowUpContext, +): SeamFacetSplitPlan { + const metas = buildPolygonMetas(polygons); + const records = buildEdgeRecords(polygons, metas, seamOptions.capacityScale); + const edgeOwners = new Map(); + for (const record of records) { + const owners = edgeOwners.get(record.key); + if (owners) owners.push(record); + else edgeOwners.set(record.key, [record]); + } + + const candidates = buildSeamFacetSplitCandidates(polygons, metas, edgeOwners, splitOptions) + .filter((candidate) => splitCandidateTouchesFollowUp(candidate, followUpContext)); + const selection = selectSeamFacetSplitCandidates(candidates, followUpContext ? "follow-up" : "primary", budget); + return { selected: selection.selected, candidates, budgetLimit: selection.budgetLimit }; +} + +function seamFacetSplitSelection( + polygons: Polygon[], + seamOptions: ResolvedSeamOverlapOptions, + splitOptions: ResolvedSeamFacetSplitOptions, +): Map> { + return seamFacetSplitPlan(polygons, seamOptions, splitOptions, splitOptions.budget).selected; +} + +function applySeamFacetSplitSelection( + polygons: Polygon[], + selected: ReadonlyMap>, +): Polygon[] { + const out: Polygon[] = []; + for (let i = 0; i < polygons.length; i += 1) { + const polygon = polygons[i]; + const edges = selected.get(i); + if (edges && isRefinableSeamQuad(polygon)) out.push(...splitQuadIntoTriangles(polygon, edges)); + else out.push(polygon); + } + return out; +} + +function seamFacetSplitPublicCandidate(candidate: SeamFacetSplitCandidateInternal): SeamFacetSplitCandidate { + const [a, b] = candidate.owners; + const first = a ?? b; + const second = b ?? a; + return { + key: candidate.key, + aPolygon: first?.polygon ?? -1, + aEdge: first?.edge ?? -1, + bPolygon: second?.polygon ?? -1, + bEdge: second?.edge ?? -1, + color: first?.color ?? second?.color, + materialKey: first?.materialKey ?? second?.materialKey ?? "", + lengthPx: candidate.length, + projectedLengthPx: candidate.projectedLength, + score: candidate.score, + normalRisk: candidate.normalRisk, + shapeRisk: candidate.shapeRisk, + viewRisk: candidate.viewRisk, + component: candidate.component, + marginalCost: candidate.marginalCost, + selected: candidate.selected, + reason: candidate.reason, + }; +} + +export function seamFacetSplitPolygons( + polygons: Polygon[], + seamOptions?: number | SeamOverlapOptions, + splitOptions?: SeamFacetSplitOptions, +): Polygon[] { + const resolved = resolveSeamOverlapOptions(seamOptions); + const split = resolveSeamFacetSplitOptions(splitOptions); + if (!seamOverlapEnabled(resolved, seamOptions) || polygons.length === 0) return polygons; + let current = polygons; + let followUpContext: SeamFacetSplitFollowUpContext | undefined; + let remainingBudget = split.budget; + for (let pass = 0; pass < split.passes; pass += 1) { + const plan = seamFacetSplitPlan(current, resolved, split, remainingBudget, followUpContext); + if (pass === 0) remainingBudget = Math.min(remainingBudget, plan.budgetLimit); + if (plan.selected.size === 0) break; + const next = applySeamFacetSplitSelection(current, plan.selected); + if (next.length === current.length) break; + remainingBudget -= plan.selected.size; + if (remainingBudget <= 0) { + current = next; + break; + } + followUpContext = splitPlanFollowUpContext(plan.candidates); + current = next; + } + return current; +} + +export function seamFacetSplitReport( + polygons: Polygon[], + seamOptions?: number | SeamOverlapOptions, + splitOptions?: SeamFacetSplitOptions, +): SeamFacetSplitReport { + const resolved = resolveSeamOverlapOptions(seamOptions); + const split = resolveSeamFacetSplitOptions(splitOptions); + if (!seamOverlapEnabled(resolved, seamOptions) || polygons.length === 0) { + return { candidates: [], selectedPolygons: 0, selectedEdges: 0, addedPolygons: 0 }; + } + + const plan = seamFacetSplitPlan(polygons, resolved, split, split.budget); + let selectedEdges = 0; + for (const edges of plan.selected.values()) selectedEdges += edges.size; + return { + candidates: plan.candidates.map(seamFacetSplitPublicCandidate), + selectedPolygons: plan.selected.size, + selectedEdges, + addedPolygons: plan.selected.size, + }; +} + +export function seamOverlapPolygons( + polygons: Polygon[], + options?: number | SeamOverlapOptions, +): Polygon[] { + const resolved = resolveSeamOverlapOptions(options); + if (!seamOverlapEnabled(resolved, options) || polygons.length === 0) return polygons; + const { edgeRepairs } = buildSeamEdgeAmounts(polygons, resolved); + if (!edgeRepairs.some((edges) => edges.size > 0)) return polygons; + return polygons.map((polygon, index) => patchPolygon(polygon, edgeRepairs[index], resolved.capacityScale)); +} + +export function repairMeshSeams( + polygons: Polygon[], + seamOptions: number | SeamOverlapOptions = DEFAULT_SEAM_OVERLAP_OPTIONS, + splitOptions: SeamFacetSplitOptions = DEFAULT_SEAM_FACET_SPLIT_OPTIONS, +): Polygon[] { + if (polygons.length === 0) return polygons; + const split = seamFacetSplitPolygons(polygons, seamOptions, splitOptions); + return seamOverlapPolygons(split, seamOptions); +} + +export function seamOverlapDiagnostics( + polygons: Polygon[], + options?: number | SeamOverlapOptions, +): SeamOverlapDiagnostics { + const resolved = resolveSeamOverlapOptions(options); + if (!seamOverlapEnabled(resolved, options) || polygons.length === 0) return emptyDiagnostics(); + return buildSeamEdgeAmounts(polygons, resolved).diagnostics; +} + +export function seamOverlapReport( + polygons: Polygon[], + options?: number | SeamOverlapOptions, +): { diagnostics: SeamOverlapDiagnostics; candidates: SeamOverlapCandidate[] } { + const resolved = resolveSeamOverlapOptions(options); + if (!seamOverlapEnabled(resolved, options) || polygons.length === 0) { + return { diagnostics: emptyDiagnostics(), candidates: [] }; + } + const result = buildSeamEdgeAmounts(polygons, resolved, true); + return { + diagnostics: result.diagnostics, + candidates: result.candidates ?? [], + }; +} diff --git a/packages/core/src/parser/loadMesh.ts b/packages/core/src/parser/loadMesh.ts index b038d1e1..b0f7edf2 100644 --- a/packages/core/src/parser/loadMesh.ts +++ b/packages/core/src/parser/loadMesh.ts @@ -24,7 +24,6 @@ import { parseGltf } from "./parseGltf"; import { parseMtl } from "./parseMtl"; import { parseVox } from "./parseVox"; import { bakeSolidTextureSamples, type SolidTextureSampleOptions } from "./solidTextureSamples"; -import { mergePolygons } from "../merge/mergePolygons"; import { optimizeMeshPolygons } from "../merge/optimizePolygons"; export interface LoadMeshOptions { @@ -71,16 +70,8 @@ function withMeshResolution(result: ParseResult, options?: LoadMeshOptions): Par const optimized = optimizeMeshPolygons(result.polygons, { meshResolution: options?.meshResolution, }); - // Final canonicalising merge so every caller — vanilla scene.add(), React - // , Vue , custom-element — receives the - // same merged polygon list. optimizeMeshPolygons runs mergePolygons on its - // baseline, but lossy candidates (rect cover, approximate merge, color - // quantize) can pick a different polygon set that still has merge-eligible - // pairs; running mergePolygons one more time closes those without - // affecting already-canonical baselines (idempotent). - const polygons = mergePolygons(optimized); - if (polygons === result.polygons) return result; - return { ...result, polygons }; + if (optimized === result.polygons) return result; + return { ...result, polygons: optimized }; } async function withSolidTextureSamples(result: ParseResult, options?: LoadMeshOptions): Promise { diff --git a/packages/polycss/README.md b/packages/polycss/README.md index ad8a69f3..11437ba3 100644 --- a/packages/polycss/README.md +++ b/packages/polycss/README.md @@ -88,6 +88,7 @@ import { PolyCamera, PolyScene, PolyOrbitControls, PolyMesh } from "@layoutit/po - `directionalLight` and `ambientLight` control scene lighting. - `textureLighting` chooses `"baked"` or `"dynamic"`. - `textureQuality` controls atlas raster budget. +- Seam bleed defaults to `1.5` CSS px on detected shared solid edges; `seamBleed="auto"` fits the amount from each polygon plan. - `strategies` can disable selected render strategies for diagnostics. - `autoCenter` rotates around the rendered mesh bounds instead of world origin. @@ -98,7 +99,7 @@ import { PolyCamera, PolyScene, PolyOrbitControls, PolyMesh } from "@layoutit/po - `polygons` accepts pre-parsed geometry. - `position`, `scale`, and `rotation` transform the mesh wrapper. - `autoCenter` shifts the mesh bbox center to local origin. -- `meshResolution` chooses `"lossy"` (default) or `"lossless"` optimization. +- `meshResolution` chooses `"lossy"` (default) or `"lossless"` optimization. Lossy also applies bounded seam repair. - `castShadow` emits CSS-projected shadows in dynamic lighting mode. ### Controls @@ -177,7 +178,7 @@ polycss renders in the DOM, so performance is mostly determined by how many poly - Textured polygons are packed into generated texture atlases. - Dynamic lighting runs through CSS custom properties instead of per-frame JavaScript. - Voxel-shaped meshes mount only camera-facing leaves when the mesh is eligible. -- `meshResolution: "lossy"` can merge compatible polygons to reduce DOM node count. +- `meshResolution: "lossy"` merges compatible polygons, then may spend a small split budget to repair high-risk seams. For diagnostics, all renderer packages export `collectPolyRenderStats(root)`, which returns mounted polygon leaf counts, shadow counts, surface categories, and bucket counts for an already-rendered scene. diff --git a/packages/polycss/src/api/createPolyScene.test.ts b/packages/polycss/src/api/createPolyScene.test.ts index 461a9f9d..c8c43b5b 100644 --- a/packages/polycss/src/api/createPolyScene.test.ts +++ b/packages/polycss/src/api/createPolyScene.test.ts @@ -5,6 +5,7 @@ */ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import type { ParseResult, Polygon } from "@layoutit/polycss-core"; +import { DEFAULT_SEAM_BLEED } from "@layoutit/polycss-core"; import { createPolyScene, type PolySceneOptions, @@ -413,7 +414,7 @@ describe("createPolyScene", () => { describe("add / remove mesh", () => { it("adds a .polycss-mesh wrapper with one polygon leaf element per polygon", () => { scene = makeScene(host); - const handle = scene.add(makeParseResult([triangle(), triangle("#00ff00")])); + const handle = scene.add(makeParseResult([triangle(), triangle("#00ff00")]), { merge: false }); const wrappers = host.querySelectorAll(".polycss-mesh"); expect(wrappers.length).toBe(1); const polys = host.querySelectorAll("i,b,s,u"); @@ -1277,6 +1278,32 @@ describe("createPolyScene", () => { expect(host.querySelector("i, s")).toBe(firstLeaf); }); + it("re-renders meshes when seamBleed is set and explicitly unset", () => { + scene = makeScene(host, { seamBleed: 0 }); + scene.add(makeParseResult([ + triangle(), + { + vertices: [ + [1, 0, 0], + [1, 1, 0], + [0, 1, 0], + ], + color: "#ff0000", + }, + ]), { merge: false }); + const leaf = host.querySelector(".polycss-mesh u") as HTMLElement; + const baseTransform = leaf.style.transform; + + scene.setOptions({ seamBleed: DEFAULT_SEAM_BLEED }); + const bleedLeaf = host.querySelector(".polycss-mesh u") as HTMLElement; + expect(bleedLeaf.style.transform).not.toBe(baseTransform); + const bleedTransform = bleedLeaf.style.transform; + + scene.setOptions({ seamBleed: undefined }); + const resetLeaf = host.querySelector(".polycss-mesh u") as HTMLElement; + expect(resetLeaf.style.transform).toBe(bleedTransform); + }); + it("mounts only camera-facing voxel leaves by default", () => { scene = makeScene(host, {}, { rotX: 0, rotY: 0 }); const handle = scene.add(makeParseResult([triangle(), backTriangle()]), { merge: false }); diff --git a/packages/polycss/src/api/createPolyScene.ts b/packages/polycss/src/api/createPolyScene.ts index d0cd0892..8e1bf72f 100644 --- a/packages/polycss/src/api/createPolyScene.ts +++ b/packages/polycss/src/api/createPolyScene.ts @@ -37,6 +37,7 @@ import type { import { BASE_TILE, CAMERA_BACKFACE_CULL_EPS, + DEFAULT_SEAM_BLEED, VOXEL_CAMERA_CULL_NORMAL_LIMIT, cameraCullNormalKey, cameraCullVisibleSignature, @@ -45,7 +46,6 @@ import { inverseRotateVec3, isAxisAlignedSurfaceNormal, isVoxelCameraCullableNormalGroups, - mergePolygons, normalFacesCamera, optimizeMeshPolygons, parseHexColor, @@ -60,6 +60,7 @@ import { updateStableTriangleFrame, updatePolygonsWithStableTopology, type TextureQuality, + type PolySeamBleed, type PolyRenderStrategiesOption, type RenderedPoly, type SolidPaintDefaults, @@ -76,6 +77,13 @@ import { injectPolyBaseStyles } from "../styles/styles"; const ASYNC_MOUNT_BATCH_SIZE = 750; const DEFAULT_SCENE_PERSPECTIVE = 8000; +function normalizeSceneOptions>>(options: T): T { + if (!Object.prototype.hasOwnProperty.call(options, "seamBleed") || options.seamBleed !== undefined) { + return options; + } + return { ...options, seamBleed: DEFAULT_SEAM_BLEED }; +} + export interface PolySceneOptions { /** * Camera handle created by `createPolyCamera`, `createPolyOrthographicCamera`, @@ -92,6 +100,8 @@ export interface PolySceneOptions { * desktop/mobile sprite sizing. Numeric values 0.1..1 force an explicit * raster scale and the 64px sprite. */ textureQuality?: TextureQuality; + /** Solid seam overscan. `"auto"` computes a fitted per-edge amount from the polygon plan. */ + seamBleed?: PolySeamBleed; /** * Skip specific render-strategy tags. Polygons that would normally use a * disabled tag fall through the chain (b → i → s, u → i → s, i → s). @@ -143,8 +153,6 @@ export interface PolyMeshTransform { * Mesh optimization intent. Defaults to `"lossy"` (bounded geometric * approximation when it reduces polygon count). Set `"lossless"` to preserve * the authored surface — only exact coplanar merges are applied. - * When set, the optimizer runs with this resolution and supersedes the - * plain `mergePolygons` pass. */ meshResolution?: MeshResolution; /** @@ -526,7 +534,10 @@ export function createPolyScene( // currentOptions holds non-camera scene options only. // eslint-disable-next-line @typescript-eslint/no-unused-vars const { camera: _cameraOption, ...nonCameraOptions } = options; - let currentOptions: Omit = { ...nonCameraOptions }; + let currentOptions: Omit = normalizeSceneOptions({ + seamBleed: DEFAULT_SEAM_BLEED, + ...nonCameraOptions, + }); const layoutScale = effectiveCssZoom(host); // Bbox-center of all live meshes (helpers opt out). Auto-managed by @@ -1233,6 +1244,7 @@ export function createPolyScene( ambientLight: currentOptions.ambientLight, textureLighting: currentOptions.textureLighting, textureQuality: currentOptions.textureQuality, + seamBleed: currentOptions.seamBleed, strategies: currentOptions.strategies, }; const solidPaintDefaults = getSolidPaintDefaults(entry.polygons, renderOptions); @@ -1300,6 +1312,7 @@ export function createPolyScene( ambientLight: currentOptions.ambientLight, textureLighting: currentOptions.textureLighting, textureQuality: currentOptions.textureQuality, + seamBleed: currentOptions.seamBleed, strategies: currentOptions.strategies, }; const atlas = entry.stableDom @@ -1374,14 +1387,12 @@ export function createPolyScene( const css = buildMeshTransform(transform); if (css) wrapper.style.transform = css; - // When meshResolution is explicitly set, run the full optimizer (which - // subsumes mergePolygons) with that resolution intent. Otherwise fall back - // to the plain merge pass for backward compatibility. + // Static meshes use the full optimizer by default; meshResolution selects + // the quality intent. Explicit merge:false remains the escape hatch for + // animated topology updates and helper meshes that are already prepared. const preparePolygons = (polygons: Polygon[], merge: boolean): Polygon[] => { - if (transformIn.meshResolution !== undefined) { - return optimizeMeshPolygons(polygons, { meshResolution: transformIn.meshResolution }); - } - return merge ? mergePolygons(polygons) : polygons; + if (parseResult.voxelSource || !merge) return polygons; + return optimizeMeshPolygons(polygons, { meshResolution: transformIn.meshResolution }); }; const sourcePolygons = preparePolygons(parseResult.polygons, mergeOnUpdate); @@ -1501,6 +1512,7 @@ export function createPolyScene( ambientLight: currentOptions.ambientLight, textureLighting: currentOptions.textureLighting, textureQuality: currentOptions.textureQuality, + seamBleed: currentOptions.seamBleed, }; const allStableTriangles = entry.rendered.length === entry.polygons.length && @@ -1631,6 +1643,7 @@ export function createPolyScene( ambientLight: currentOptions.ambientLight, textureLighting: currentOptions.textureLighting, textureQuality: currentOptions.textureQuality, + seamBleed: currentOptions.seamBleed, solidPaintDefaults: {}, optimizeStableTriangleStyle: true, stableTriangleDebug: options?.stableTriangleDebug, @@ -1801,8 +1814,10 @@ export function createPolyScene( function setOptions(partial: Partial>): void { const prevAutoCenter = !!currentOptions.autoCenter; const prevStrategies = currentOptions.strategies; + const prevSeamBleed = currentOptions.seamBleed; const prevTextureLighting = currentOptions.textureLighting; - currentOptions = { ...currentOptions, ...partial }; + const normalizedPartial = normalizeSceneOptions(partial); + currentOptions = { ...currentOptions, ...normalizedPartial }; applySceneStyle(sceneEl, currentOptions); const nextAutoCenter = !!currentOptions.autoCenter; // Re-evaluate per-mesh light overrides when lighting settings change — @@ -1817,7 +1832,9 @@ export function createPolyScene( // updates) don't blow up the atlas every frame. const strategiesChanged = partial.strategies !== undefined && !strategiesEqual(partial.strategies, prevStrategies); - if (strategiesChanged) { + const seamBleedChanged = Object.prototype.hasOwnProperty.call(partial, "seamBleed") && + normalizedPartial.seamBleed !== prevSeamBleed; + if (strategiesChanged || seamBleedChanged) { for (const entry of meshes) renderEntry(entry); } if (prevAutoCenter !== nextAutoCenter) recomputeAutoCenter(); @@ -1827,7 +1844,7 @@ export function createPolyScene( prevTextureLighting !== currentOptions.textureLighting; if (textureLightingChanged) { for (const entry of meshes) { - if (!strategiesChanged && (entry.voxelSource || entry.voxelRenderer)) { + if (!strategiesChanged && !seamBleedChanged && (entry.voxelSource || entry.voxelRenderer)) { renderEntry(entry); } else { emitShadowLeaves(entry); diff --git a/packages/polycss/src/elements/PolyAxesHelperElement.ts b/packages/polycss/src/elements/PolyAxesHelperElement.ts index 080a6637..ca756a10 100644 --- a/packages/polycss/src/elements/PolyAxesHelperElement.ts +++ b/packages/polycss/src/elements/PolyAxesHelperElement.ts @@ -84,7 +84,7 @@ export class PolyAxesHelperElement extends ELEMENT_BASE { dispose: () => {}, warnings: [], }; - this._handle = scene.add(parsed, { excludeFromAutoCenter: true }); + this._handle = scene.add(parsed, { excludeFromAutoCenter: true, merge: false }); } private _remount(): void { diff --git a/packages/polycss/src/elements/PolyDirectionalLightHelperElement.ts b/packages/polycss/src/elements/PolyDirectionalLightHelperElement.ts index 1bf0f141..49a7dca9 100644 --- a/packages/polycss/src/elements/PolyDirectionalLightHelperElement.ts +++ b/packages/polycss/src/elements/PolyDirectionalLightHelperElement.ts @@ -113,6 +113,7 @@ export class PolyDirectionalLightHelperElement extends ELEMENT_BASE { this._handle = scene.add(parsed, { position: this._meshPosition(), excludeFromAutoCenter: true, + merge: false, }); } diff --git a/packages/polycss/src/elements/PolySceneElement.test.ts b/packages/polycss/src/elements/PolySceneElement.test.ts index 68ebfcf4..6df64249 100644 --- a/packages/polycss/src/elements/PolySceneElement.test.ts +++ b/packages/polycss/src/elements/PolySceneElement.test.ts @@ -3,6 +3,7 @@ * connect/disconnect lifecycle, attribute changes. */ import { afterEach, beforeAll, beforeEach, describe, expect, it } from "vitest"; +import { DEFAULT_SEAM_BLEED } from "@layoutit/polycss-core"; import { PolySceneElement } from "./PolySceneElement"; beforeAll(() => { @@ -35,6 +36,7 @@ describe("PolySceneElement", () => { expect(observed).toContain("rot-y"); expect(observed).toContain("zoom"); expect(observed).toContain("texture-quality"); + expect(observed).toContain("seam-bleed"); expect(observed).toContain("directional-direction"); expect(observed).toContain("directional-color"); expect(observed).toContain("directional-intensity"); @@ -163,6 +165,22 @@ describe("PolySceneElement", () => { expect(el.getScene()?.getOptions().autoCenter).toBe(true); }); + it("resets seam-bleed to the default when the attribute is removed", () => { + const el = document.createElement("poly-scene") as PolySceneElement; + el.setAttribute("seam-bleed", "2"); + host.appendChild(el); + expect(el.getScene()?.getOptions().seamBleed).toBe(2); + el.removeAttribute("seam-bleed"); + expect(el.getScene()?.getOptions().seamBleed).toBe(DEFAULT_SEAM_BLEED); + }); + + it("parses seam-bleed auto", () => { + const el = document.createElement("poly-scene") as PolySceneElement; + el.setAttribute("seam-bleed", "auto"); + host.appendChild(el); + expect(el.getScene()?.getOptions().seamBleed).toBe("auto"); + }); + }); describe("attributeChangedCallback", () => { diff --git a/packages/polycss/src/elements/PolySceneElement.ts b/packages/polycss/src/elements/PolySceneElement.ts index 8eece21f..8c852fbe 100644 --- a/packages/polycss/src/elements/PolySceneElement.ts +++ b/packages/polycss/src/elements/PolySceneElement.ts @@ -48,6 +48,7 @@ const OBSERVED_ATTRS = [ "ambient-intensity", "texture-lighting", "texture-quality", + "seam-bleed", "auto-center", ] as const; @@ -81,6 +82,11 @@ function parseTextureQuality(value: string | null): PolySceneOptions["textureQua return parseNumber(value); } +function parseSeamBleed(value: string | null): PolySceneOptions["seamBleed"] | undefined { + if (value === "auto") return "auto"; + return parseNumber(value); +} + type CameraElement = { getCamera(): PolyPerspectiveCameraHandle | PolyOrthographicCameraHandle | null; }; @@ -150,6 +156,8 @@ export class PolySceneElement extends ELEMENT_BASE { opts.textureLighting = parseTextureLighting(this.getAttribute("texture-lighting")) ?? "baked"; const textureQuality = parseTextureQuality(this.getAttribute("texture-quality")); if (textureQuality !== undefined) opts.textureQuality = textureQuality; + const seamBleed = parseSeamBleed(this.getAttribute("seam-bleed")); + opts.seamBleed = seamBleed; opts.autoCenter = this.hasAttribute("auto-center"); if (directionalLight) opts.directionalLight = directionalLight; if (ambientLight) opts.ambientLight = ambientLight; diff --git a/packages/polycss/src/elements/PolyShapeElements.ts b/packages/polycss/src/elements/PolyShapeElements.ts index 0f9cd953..5119051a 100644 --- a/packages/polycss/src/elements/PolyShapeElements.ts +++ b/packages/polycss/src/elements/PolyShapeElements.ts @@ -125,6 +125,7 @@ abstract class PolyShapeElement extends ELEMENT_BASE { return Number.isFinite(n) ? n : undefined; })(), rotation: parseVec3Attr(this, "rotation"), + merge: false, }, ); } diff --git a/packages/polycss/src/index.ts b/packages/polycss/src/index.ts index bd7195cf..65d20ea2 100644 --- a/packages/polycss/src/index.ts +++ b/packages/polycss/src/index.ts @@ -88,6 +88,7 @@ export { PolySelectElement } from "./elements/PolySelectElement"; export type { PolyRenderStrategy, PolyRenderStrategiesOption, + PolySeamBleed, TextureQuality, TextureAtlasPlan, PackedTextureAtlasEntry, diff --git a/packages/polycss/src/render/atlas/index.ts b/packages/polycss/src/render/atlas/index.ts index 07bb2335..309dfc6a 100644 --- a/packages/polycss/src/render/atlas/index.ts +++ b/packages/polycss/src/render/atlas/index.ts @@ -10,6 +10,7 @@ export type { SolidPaintDefaults, TextureAtlasPage, ComputeTextureAtlasPlanOptions, + PolySeamBleed, } from "@layoutit/polycss-core"; export type { RenderTextureAtlasOptions, @@ -18,7 +19,7 @@ export type { RenderTextureAtlasAsyncResult, } from "./types"; export { packTextureAtlasPlansWithScale } from "./packing"; -export { buildTextureEdgeRepairSets } from "@layoutit/polycss-core"; +export { buildTextureEdgeRepairSets, buildSeamBleedPolygonEdges, buildSeamBleedPolygonSet } from "@layoutit/polycss-core"; export { buildAtlasPages } from "./rasterise"; export { isFullRectSolid, diff --git a/packages/polycss/src/render/atlas/renderPolygons.ts b/packages/polycss/src/render/atlas/renderPolygons.ts index 79cef5cc..b3f4df52 100644 --- a/packages/polycss/src/render/atlas/renderPolygons.ts +++ b/packages/polycss/src/render/atlas/renderPolygons.ts @@ -21,8 +21,13 @@ import { PROJECTIVE_QUAD_DENOM_EPS, PROJECTIVE_QUAD_MAX_WEIGHT_RATIO, PROJECTIVE_QUAD_BLEED, + DEFAULT_SEAM_BLEED, +} from "@layoutit/polycss-core"; +import { + buildBasisHints, + buildSeamBleedPolygonEdges, + computeTextureAtlasPlan, } from "@layoutit/polycss-core"; -import { buildBasisHints, computeTextureAtlasPlan } from "@layoutit/polycss-core"; import { resolveProjectiveQuadGuards } from "./plan"; import { getSolidPaintDefaultsForPlans, @@ -70,6 +75,10 @@ import { } from "./stableTriangle"; import { stableTriangleMatrixDecimals } from "@layoutit/polycss-core"; +type RenderTextureAtlasOptionsWithSeams = RenderTextureAtlasOptions & { + seamEdges?: Set; +}; + function yieldToMainThread(): Promise { return new Promise((resolve) => setTimeout(resolve, 0)); } @@ -80,6 +89,59 @@ async function yieldIfOverBudget(started: number): Promise { return performance.now(); } +function seamTriangleOptions( + plan: TextureAtlasPlan, + options: RenderTextureAtlasOptions, +): RenderTextureAtlasOptionsWithSeams { + const seamBleed = effectiveSeamBleed(options); + return plan.seamBleedEdges?.size + ? { ...options, seamBleed, seamEdges: plan.seamBleedEdges } + : { ...options, seamBleed: undefined, seamEdges: undefined }; +} + +function effectiveSeamBleed(options: RenderTextureAtlasOptions): RenderTextureAtlasOptions["seamBleed"] { + return Object.prototype.hasOwnProperty.call(options, "seamBleed") + ? options.seamBleed + : DEFAULT_SEAM_BLEED; +} + +function shouldApplySeamBleed(seamBleed: RenderTextureAtlasOptions["seamBleed"]): boolean { + return seamBleed === "auto" || ( + typeof seamBleed === "number" && + Number.isFinite(seamBleed) && + seamBleed > 0 + ); +} + +function buildRenderSeamBleedEdges( + polygons: Polygon[], + options: RenderTextureAtlasOptions, +): Map> | null { + return shouldApplySeamBleed(effectiveSeamBleed(options)) + ? buildSeamBleedPolygonEdges(polygons, { + tileSize: options.tileSize, + layerElevation: options.layerElevation, + directionalLight: options.directionalLight, + ambientLight: options.ambientLight, + }) + : null; +} + +function seamAtlasOptions( + index: number, + seamBleedEdges: Map> | null, + options: RenderTextureAtlasOptions, +): RenderTextureAtlasOptionsWithSeams { + const seamBleed = effectiveSeamBleed(options); + return seamBleedEdges + ? { + ...options, + seamBleed: seamBleedEdges.has(index) ? seamBleed : undefined, + seamEdges: seamBleedEdges.get(index), + } + : options; +} + export function getSolidPaintDefaults( polygons: Polygon[], options: RenderTextureAtlasOptions = {}, @@ -116,12 +178,19 @@ export function renderPolygonsWithTextureAtlas( const useBorderShape = !disabled.has("i") && borderShapeSupported(doc); const basisHints = buildBasisHints(polygons, options); const projectiveQuadGuards = resolveProjectiveQuadGuards(doc); + const seamBleedEdges = buildRenderSeamBleedEdges(polygons, options); const plans = polygons.map((polygon, index) => - computeTextureAtlasPlan(polygon, index, options, projectiveQuadGuards, basisHints[index]) + computeTextureAtlasPlan( + polygon, + index, + seamAtlasOptions(index, seamBleedEdges, options), + projectiveQuadGuards, + basisHints[index], + ) ); const trianglePlans = plans.map((plan) => plan && useStableTriangle && isSolidTrianglePlan(plan) - ? computeSolidTrianglePlan(plan.polygon, plan.index, options, { + ? computeSolidTrianglePlan(plan.polygon, plan.index, seamTriangleOptions(plan, options), { primitive: solidTrianglePrimitive ?? undefined, }) : null @@ -240,10 +309,17 @@ export async function renderPolygonsWithTextureAtlasAsync( const basisHints = buildBasisHints(polygons, options); const projectiveQuadGuards = resolveProjectiveQuadGuards(doc); + const seamBleedEdges = buildRenderSeamBleedEdges(polygons, options); let batchStarted = performance.now(); const plans: Array = new Array(polygons.length); for (let i = 0; i < polygons.length; i++) { - plans[i] = computeTextureAtlasPlan(polygons[i], i, options, projectiveQuadGuards, basisHints[i]); + plans[i] = computeTextureAtlasPlan( + polygons[i], + i, + seamAtlasOptions(i, seamBleedEdges, options), + projectiveQuadGuards, + basisHints[i], + ); batchStarted = await yieldIfOverBudget(batchStarted); if (shouldCancel()) return { rendered: [], solidPaintDefaults: {}, dispose: () => {} }; } @@ -256,7 +332,7 @@ export async function renderPolygonsWithTextureAtlasAsync( for (let i = 0; i < plans.length; i++) { const plan = plans[i]; const trianglePlan = plan && useStableTriangle && isSolidTrianglePlan(plan) - ? computeSolidTrianglePlan(plan.polygon, plan.index, { ...options, solidPaintDefaults }, { + ? computeSolidTrianglePlan(plan.polygon, plan.index, seamTriangleOptions(plan, { ...options, solidPaintDefaults }), { primitive: solidTrianglePrimitive ?? undefined, }) : null; diff --git a/packages/polycss/src/render/atlas/stableTriangle.test.ts b/packages/polycss/src/render/atlas/stableTriangle.test.ts index 1ef5b12c..457ec740 100644 --- a/packages/polycss/src/render/atlas/stableTriangle.test.ts +++ b/packages/polycss/src/render/atlas/stableTriangle.test.ts @@ -54,6 +54,11 @@ const TRIANGLE_B: Polygon = { color: "#00ff00", }; +const ADJACENT_TRIANGLE_A: Polygon = { + vertices: [[1, 0, 0], [1, 1, 0], [0, 1, 0]], + color: "#ff0000", +}; + const MOVED_TRIANGLE_A: Polygon = { vertices: [[0.1, 0, 0], [1.1, 0, 0], [0.1, 1, 0]], color: "#ff0000", @@ -120,6 +125,21 @@ describe("renderPolygonsWithStableTriangles — initial render", () => { result!.dispose(); }); + it("applies seamBleed to detected shared triangle edges", () => { + const doc = makeDoc(); + const base = renderPolygonsWithStableTriangles([TRIANGLE_A, ADJACENT_TRIANGLE_A], { doc })!; + const bleed = renderPolygonsWithStableTriangles([TRIANGLE_A, ADJACENT_TRIANGLE_A], { + doc, + seamBleed: 2, + })!; + expect(bleed.rendered[0].element.style.transform) + .not.toBe(base.rendered[0].element.style.transform); + expect(bleed.rendered[1].element.style.transform) + .not.toBe(base.rendered[1].element.style.transform); + base.dispose(); + bleed.dispose(); + }); + it("adds polycss-corner-triangle class when corner-shape is supported", () => { const doc = makeDoc({ cornerShape: true }); const result = renderPolygonsWithStableTriangles([TRIANGLE_A], { doc }); diff --git a/packages/polycss/src/render/atlas/stableTriangle.ts b/packages/polycss/src/render/atlas/stableTriangle.ts index 878e65c4..981ca007 100644 --- a/packages/polycss/src/render/atlas/stableTriangle.ts +++ b/packages/polycss/src/render/atlas/stableTriangle.ts @@ -1,9 +1,11 @@ import type { Polygon } from "@layoutit/polycss-core"; import { + buildSeamBleedPolygonEdges, DEFAULT_TILE, SOLID_TRIANGLE_CORNER_CLASS, DEFAULT_MATRIX_DECIMALS, BASIS_EPS, + DEFAULT_SEAM_BLEED, } from "@layoutit/polycss-core"; import type { SolidTrianglePlan, @@ -34,6 +36,53 @@ import { stableTriangleMatrixDecimals } from "@layoutit/polycss-core"; import { applyPolygonDataAttrs, hasPolygonDataAttrs, clearAtlasImageStyles } from "./emit"; import { resolveSolidTrianglePrimitive } from "./strategy"; +type RenderTextureAtlasOptionsWithSeams = RenderTextureAtlasOptions & { + seamEdges?: Set; +}; + +function effectiveSeamBleed(options: RenderTextureAtlasOptions): RenderTextureAtlasOptions["seamBleed"] { + return Object.prototype.hasOwnProperty.call(options, "seamBleed") + ? options.seamBleed + : DEFAULT_SEAM_BLEED; +} + +function shouldApplySeamBleed(seamBleed: RenderTextureAtlasOptions["seamBleed"]): boolean { + return seamBleed === "auto" || ( + typeof seamBleed === "number" && + Number.isFinite(seamBleed) && + seamBleed > 0 + ); +} + +function buildStableTriangleSeamEdges( + polygons: Polygon[], + options: RenderTextureAtlasOptions, +): Map> | null { + return shouldApplySeamBleed(effectiveSeamBleed(options)) + ? buildSeamBleedPolygonEdges(polygons, { + tileSize: options.tileSize, + layerElevation: options.layerElevation, + directionalLight: options.directionalLight, + ambientLight: options.ambientLight, + }) + : null; +} + +function stableTriangleSeamOptions( + index: number, + seamBleedEdges: Map> | null, + options: RenderTextureAtlasOptions, +): RenderTextureAtlasOptionsWithSeams { + const seamBleed = effectiveSeamBleed(options); + return seamBleedEdges + ? { + ...options, + seamBleed: seamBleedEdges.has(index) ? seamBleed : undefined, + seamEdges: seamBleedEdges.get(index), + } + : options; +} + export function stableTriangleColorState(options: InternalRenderTextureAtlasOptions): StableTriangleColorState { return { updatesDisabled: options.stableTriangleColorFreezeFrames === 0, @@ -292,6 +341,7 @@ export function updateStableTriangleElementsStreaming( if (item.kind !== "triangle" || item.polygonIndex !== i || !polygons[i]) return false; } + const seamBleedEdges = buildStableTriangleSeamEdges(polygons, options); for (let i = 0; i < rendered.length; i++) { const element = rendered[i].element as SolidTriangleElement; const polygon = polygons[i]; @@ -317,7 +367,7 @@ export function updateStableTriangleElementsStreaming( continue; } - const plan = computeSolidTrianglePlan(polygon, i, options, { + const plan = computeSolidTrianglePlan(polygon, i, stableTriangleSeamOptions(i, seamBleedEdges, options), { basis: element.__polycssSolidTriangleBasis, matrixDecimals, includeColor: stableTriangleUpdateMode !== "plan-only" && @@ -393,6 +443,7 @@ export function captureStableTriangleTransformFrame( } const values = frame.vertices; + const seamBleedEdges = buildStableTriangleSeamEdges(polygons, options); for (let i = 0; i < rendered.length; i++) { const element = rendered[i].element as SolidTriangleElement; const polygon = polygons[i]!; @@ -409,7 +460,7 @@ export function captureStableTriangleTransformFrame( const plan = computeSolidTrianglePlanFromCssPoints( polygon, i, - options, + stableTriangleSeamOptions(i, seamBleedEdges, options), { basis: element.__polycssSolidTriangleBasis, matrixDecimals, @@ -464,10 +515,11 @@ export function renderPolygonsWithStableTriangles( } const matrixDecimals = stableTriangleMatrixDecimals((options as InternalRenderTextureAtlasOptions).stableTriangleMatrixDecimals); const rendered: RenderedPoly[] = []; + const seamBleedEdges = buildStableTriangleSeamEdges(polygons, options); for (let i = 0; i < polygons.length; i += 1) { const polygon = polygons[i]; - const plan = computeSolidTrianglePlan(polygon, i, options, { + const plan = computeSolidTrianglePlan(polygon, i, stableTriangleSeamOptions(i, seamBleedEdges, options), { matrixDecimals, primitive: solidTrianglePrimitive, }); @@ -527,6 +579,7 @@ export function updatePolygonsWithStableTriangles( } const nextTrianglePlans: Array = new Array(rendered.length); const nextTriangleColorPlans: Array = new Array(rendered.length); + const seamBleedEdges = buildStableTriangleSeamEdges(polygons, options); for (let i = 0; i < rendered.length; i++) { const element = rendered[i].element as SolidTriangleElement; if (colorOnly) { @@ -544,7 +597,7 @@ export function updatePolygonsWithStableTriangles( : null; continue; } - nextTrianglePlans[i] = computeSolidTrianglePlan(polygons[i], i, options, { + nextTrianglePlans[i] = computeSolidTrianglePlan(polygons[i], i, stableTriangleSeamOptions(i, seamBleedEdges, options), { basis: element.__polycssSolidTriangleBasis, matrixDecimals, includeColor: stableTriangleUpdateMode !== "plan-only" && diff --git a/packages/polycss/src/render/atlas/types.ts b/packages/polycss/src/render/atlas/types.ts index 591c64f4..583259af 100644 --- a/packages/polycss/src/render/atlas/types.ts +++ b/packages/polycss/src/render/atlas/types.ts @@ -22,6 +22,7 @@ export interface RenderTextureAtlasOptions { textureQuality?: import("@layoutit/polycss-core").TextureQuality; solidPaintDefaults?: import("@layoutit/polycss-core").SolidPaintDefaults; strategies?: import("@layoutit/polycss-core").PolyRenderStrategiesOption; + seamBleed?: import("@layoutit/polycss-core").PolySeamBleed; } export interface InternalRenderTextureAtlasOptions extends RenderTextureAtlasOptions { @@ -65,4 +66,3 @@ export interface RenderTextureAtlasResult { export interface RenderTextureAtlasAsyncResult extends RenderTextureAtlasResult { solidPaintDefaults: import("@layoutit/polycss-core").SolidPaintDefaults; } - diff --git a/packages/polycss/src/render/polyDOM.test.ts b/packages/polycss/src/render/polyDOM.test.ts index 78451770..105732ec 100644 --- a/packages/polycss/src/render/polyDOM.test.ts +++ b/packages/polycss/src/render/polyDOM.test.ts @@ -42,6 +42,24 @@ const SECOND_FLAT_TRIANGLE: Polygon = { color: "#ff0000", }; +const ADJACENT_FLAT_TRIANGLE: Polygon = { + vertices: [ + [1, 0, 0], + [1, 1, 0], + [0, 1, 0], + ], + color: "#ff0000", +}; + +const HINGED_TRIANGLE: Polygon = { + vertices: [ + [1, 0, 0], + [1, 1, 1], + [0, 1, 0], + ], + color: "#ff0000", +}; + const VERTICAL_QUAD: Polygon = { vertices: [ [0, 0, 0], @@ -52,6 +70,16 @@ const VERTICAL_QUAD: Polygon = { color: "#00ff00", }; +const ADJACENT_VERTICAL_QUAD: Polygon = { + vertices: [ + [1, 0, 0], + [2, 0, 0], + [2, 0, 1], + [1, 0, 1], + ], + color: "#00ff00", +}; + const NON_RECT_QUAD: Polygon = { vertices: [ [0, 0, 0], @@ -426,6 +454,91 @@ describe("renderPolygonsWithTextureAtlas", () => { result.dispose(); }); + it("applies seamBleed only to solid primitives with valid shared edges", () => { + const baseQuad = renderPolygonsWithTextureAtlas([VERTICAL_QUAD], { tileSize: 1, seamBleed: 0 }); + const bleedQuad = renderPolygonsWithTextureAtlas([VERTICAL_QUAD], { tileSize: 1, seamBleed: 2 }); + const baseTriangle = renderPolygonsWithTextureAtlas([FLAT_TRIANGLE, ADJACENT_FLAT_TRIANGLE], { + tileSize: 1, + seamBleed: 0, + }); + const bleedTriangle = renderPolygonsWithTextureAtlas([FLAT_TRIANGLE, ADJACENT_FLAT_TRIANGLE], { tileSize: 1, seamBleed: 2 }); + const autoTriangle = renderPolygonsWithTextureAtlas([FLAT_TRIANGLE, ADJACENT_FLAT_TRIANGLE], { + tileSize: 1, + seamBleed: "auto", + }); + + expect(bleedQuad.rendered).toHaveLength(baseQuad.rendered.length); + expect(bleedTriangle.rendered).toHaveLength(baseTriangle.rendered.length); + expect(bleedQuad.rendered[0].element.tagName.toLowerCase()).toBe("b"); + expect(bleedTriangle.rendered[0].element.tagName.toLowerCase()).toBe("u"); + + const baseQuadMatrix = extractMatrix(baseQuad.rendered[0].element); + const bleedQuadMatrix = extractMatrix(bleedQuad.rendered[0].element); + const baseTriangleMatrix = extractMatrix(baseTriangle.rendered[0].element); + const bleedTriangleMatrix = extractMatrix(bleedTriangle.rendered[0].element); + const baseTriangleX = Math.hypot(baseTriangleMatrix[0], baseTriangleMatrix[1], baseTriangleMatrix[2]); + const baseTriangleY = Math.hypot(baseTriangleMatrix[4], baseTriangleMatrix[5], baseTriangleMatrix[6]); + const bleedTriangleX = Math.hypot(bleedTriangleMatrix[0], bleedTriangleMatrix[1], bleedTriangleMatrix[2]); + const bleedTriangleY = Math.hypot(bleedTriangleMatrix[4], bleedTriangleMatrix[5], bleedTriangleMatrix[6]); + expect(bleedQuad.rendered[0].element.style.transform).toBe(baseQuad.rendered[0].element.style.transform); + expect(Math.hypot(bleedQuadMatrix[0], bleedQuadMatrix[1], bleedQuadMatrix[2])) + .toBe(Math.hypot(baseQuadMatrix[0], baseQuadMatrix[1], baseQuadMatrix[2])); + expect(bleedTriangle.rendered[0].element.style.transform) + .not.toBe(baseTriangle.rendered[0].element.style.transform); + expect( + Math.abs(bleedTriangleX - baseTriangleX) > 1e-6 || + Math.abs(bleedTriangleY - baseTriangleY) > 1e-6, + ).toBe(true); + expect(autoTriangle.rendered[0].element.style.transform) + .not.toBe(baseTriangle.rendered[0].element.style.transform); + + baseQuad.dispose(); + bleedQuad.dispose(); + baseTriangle.dispose(); + bleedTriangle.dispose(); + autoTriangle.dispose(); + }); + + it("applies seamBleed only along shared sides for rectangular solids", () => { + const base = renderPolygonsWithTextureAtlas([VERTICAL_QUAD, ADJACENT_VERTICAL_QUAD], { + tileSize: 10, + seamBleed: 0, + }); + const bleed = renderPolygonsWithTextureAtlas([VERTICAL_QUAD, ADJACENT_VERTICAL_QUAD], { tileSize: 10, seamBleed: 2 }); + + for (let i = 0; i < 2; i += 1) { + expect(bleed.rendered[i].element.tagName.toLowerCase()).toBe("b"); + const baseMatrix = extractMatrix(base.rendered[i].element); + const bleedMatrix = extractMatrix(bleed.rendered[i].element); + const baseX = Math.hypot(baseMatrix[0], baseMatrix[1], baseMatrix[2]); + const baseY = Math.hypot(baseMatrix[4], baseMatrix[5], baseMatrix[6]); + const bleedX = Math.hypot(bleedMatrix[0], bleedMatrix[1], bleedMatrix[2]); + const bleedY = Math.hypot(bleedMatrix[4], bleedMatrix[5], bleedMatrix[6]); + const xChanged = bleedX > baseX + 1e-6; + const yChanged = bleedY > baseY + 1e-6; + expect([xChanged, yChanged].filter(Boolean)).toHaveLength(1); + if (xChanged) expect(bleedY).toBeCloseTo(baseY, 6); + else expect(bleedX).toBeCloseTo(baseX, 6); + } + + base.dispose(); + bleed.dispose(); + }); + + it("applies seamBleed to both sides of non-coplanar shared edges", () => { + const base = renderPolygonsWithTextureAtlas([FLAT_TRIANGLE, HINGED_TRIANGLE], { tileSize: 1, seamBleed: 0 }); + const bleed = renderPolygonsWithTextureAtlas([FLAT_TRIANGLE, HINGED_TRIANGLE], { tileSize: 1, seamBleed: 2 }); + + expect(bleed.rendered).toHaveLength(base.rendered.length); + expect(bleed.rendered[0].element.tagName.toLowerCase()).toBe("u"); + expect(bleed.rendered[1].element.tagName.toLowerCase()).toBe("u"); + expect(bleed.rendered[0].element.style.transform).not.toBe(base.rendered[0].element.style.transform); + expect(bleed.rendered[1].element.style.transform).not.toBe(base.rendered[1].element.style.transform); + + base.dispose(); + bleed.dispose(); + }); + it("falls back to atlas for solid triangles on WebKit", () => { const canvases: Array<{ width: number; height: number; getContext: () => null }> = []; const doc = { diff --git a/packages/polycss/src/render/textureAtlas.ts b/packages/polycss/src/render/textureAtlas.ts index 123c25ae..4fa004f9 100644 --- a/packages/polycss/src/render/textureAtlas.ts +++ b/packages/polycss/src/render/textureAtlas.ts @@ -9,6 +9,7 @@ export type { SolidTriangleFrame, SolidPaintDefaults, TextureAtlasPage, + PolySeamBleed, RenderTextureAtlasOptions, RenderedPoly, RenderTextureAtlasResult, @@ -18,6 +19,8 @@ export type { export { packTextureAtlasPlansWithScale, buildTextureEdgeRepairSets, + buildSeamBleedPolygonEdges, + buildSeamBleedPolygonSet, buildAtlasPages, isFullRectSolid, isSolidTrianglePlan, diff --git a/packages/react/README.md b/packages/react/README.md index ad8a69f3..11437ba3 100644 --- a/packages/react/README.md +++ b/packages/react/README.md @@ -88,6 +88,7 @@ import { PolyCamera, PolyScene, PolyOrbitControls, PolyMesh } from "@layoutit/po - `directionalLight` and `ambientLight` control scene lighting. - `textureLighting` chooses `"baked"` or `"dynamic"`. - `textureQuality` controls atlas raster budget. +- Seam bleed defaults to `1.5` CSS px on detected shared solid edges; `seamBleed="auto"` fits the amount from each polygon plan. - `strategies` can disable selected render strategies for diagnostics. - `autoCenter` rotates around the rendered mesh bounds instead of world origin. @@ -98,7 +99,7 @@ import { PolyCamera, PolyScene, PolyOrbitControls, PolyMesh } from "@layoutit/po - `polygons` accepts pre-parsed geometry. - `position`, `scale`, and `rotation` transform the mesh wrapper. - `autoCenter` shifts the mesh bbox center to local origin. -- `meshResolution` chooses `"lossy"` (default) or `"lossless"` optimization. +- `meshResolution` chooses `"lossy"` (default) or `"lossless"` optimization. Lossy also applies bounded seam repair. - `castShadow` emits CSS-projected shadows in dynamic lighting mode. ### Controls @@ -177,7 +178,7 @@ polycss renders in the DOM, so performance is mostly determined by how many poly - Textured polygons are packed into generated texture atlases. - Dynamic lighting runs through CSS custom properties instead of per-frame JavaScript. - Voxel-shaped meshes mount only camera-facing leaves when the mesh is eligible. -- `meshResolution: "lossy"` can merge compatible polygons to reduce DOM node count. +- `meshResolution: "lossy"` merges compatible polygons, then may spend a small split budget to repair high-risk seams. For diagnostics, all renderer packages export `collectPolyRenderStats(root)`, which returns mounted polygon leaf counts, shadow counts, surface categories, and bucket counts for an already-rendered scene. diff --git a/packages/react/src/index.ts b/packages/react/src/index.ts index 46782684..16fcf9ff 100644 --- a/packages/react/src/index.ts +++ b/packages/react/src/index.ts @@ -39,6 +39,7 @@ export type { InteractionProps, PolyRenderStrategy, PolyRenderStrategiesOption, + PolySeamBleed, } from "./scene"; export { Poly } from "./shapes"; @@ -163,6 +164,14 @@ export type { TexturePaintMetricsOptions, CoverPlanarPolygonsOptions, CullInteriorOptions, + SeamFacetSplitCandidate, + SeamFacetSplitCandidateReason, + SeamFacetSplitOptions, + SeamFacetSplitReport, + SeamOverlapCandidate, + SeamOverlapCandidateKind, + SeamOverlapDiagnostics, + SeamOverlapOptions, CameraCullNormalGroup, CameraCullRotation, ApproximateMergeOptions, @@ -177,6 +186,12 @@ export { mergePolygons, coverPlanarPolygons, optimizeMeshPolygons, + repairMeshSeams, + seamFacetSplitPolygons, + seamFacetSplitReport, + seamOverlapDiagnostics, + seamOverlapPolygons, + seamOverlapReport, cullInteriorPolygons, cameraCullNormalGroups, cameraCullNormalGroupsFromPolygons, @@ -223,11 +238,14 @@ export { buildSceneContext, computeSceneBbox, BASE_TILE, + DEFAULT_SEAM_BLEED, DEFAULT_CAMERA_STATE, DEFAULT_PROJECTION, normalizeInvertMultiplier, createPolyAnimationMixer, optimizeAnimatedMeshPolygons, + DEFAULT_SEAM_FACET_SPLIT_OPTIONS, + DEFAULT_SEAM_OVERLAP_OPTIONS, LoopOnce, LoopRepeat, LoopPingPong, diff --git a/packages/react/src/scene/PolyMesh.tsx b/packages/react/src/scene/PolyMesh.tsx index 31bee6b1..27c67ba3 100644 --- a/packages/react/src/scene/PolyMesh.tsx +++ b/packages/react/src/scene/PolyMesh.tsx @@ -31,10 +31,17 @@ import type { PolyTextureLightingMode, Vec3, } from "@layoutit/polycss-core"; -import { computeSceneBbox, findOverlappingPolygonDuplicates, inverseRotateVec3, parseHexColor } from "@layoutit/polycss-core"; +import { + computeSceneBbox, + DEFAULT_SEAM_BLEED, + findOverlappingPolygonDuplicates, + inverseRotateVec3, + parseHexColor, +} from "@layoutit/polycss-core"; import type { TransformProps } from "../shapes/types"; import { usePolyMesh, type UseMeshOptions } from "./useMesh"; import { + buildSeamBleedPolygonEdges, buildTextureEdgeRepairSets, computeTextureAtlasPlan, cssBorderShapeForPlan, @@ -43,6 +50,7 @@ import { isSolidTrianglePlan, type TextureAtlasPlan, type TextureQuality, + type PolySeamBleed, type SolidPaintDefaults, TextureBorderShapePoly, TextureAtlasPoly, @@ -97,6 +105,8 @@ export interface PolyMeshProps extends TransformProps, InteractionProps { * desktop/mobile sprite sizing. Numeric values 0.1..1 force an explicit * raster scale and the 64px sprite. */ textureQuality?: TextureQuality; + /** Solid seam overscan. `"auto"` computes a fitted per-edge amount from the polygon plan. */ + seamBleed?: PolySeamBleed; /** Per-polygon override render, or static children mounted inside the mesh wrapper. */ children?: ((polygon: Polygon, index: number) => ReactNode) | ReactNode; /** Loading slot — rendered while `src` is being fetched/parsed. */ @@ -176,6 +186,7 @@ export const PolyMesh = forwardRef(function PolyM autoCenter, textureLighting, textureQuality, + seamBleed, castShadow, children, fallback, @@ -502,6 +513,7 @@ export const PolyMesh = forwardRef(function PolyM const sceneCtx = usePolySceneContext(); const effectiveTextureLighting = textureLighting ?? sceneCtx?.textureLighting ?? "baked"; const effectiveStrategies = sceneCtx?.strategies; + const effectiveSeamBleed = seamBleed ?? sceneCtx?.seamBleed ?? DEFAULT_SEAM_BLEED; const effectiveDirectional = effectiveTextureLighting === "dynamic" ? undefined : sceneCtx?.directionalLight; const effectiveAmbient = @@ -547,13 +559,25 @@ export const PolyMesh = forwardRef(function PolyM () => { if (renderPolygon) return []; const repairEdges = buildTextureEdgeRepairSets(polygons); + const seamBleedEdges = effectiveSeamBleed === "auto" || ( + typeof effectiveSeamBleed === "number" && + Number.isFinite(effectiveSeamBleed) && + effectiveSeamBleed > 0 + ) + ? buildSeamBleedPolygonEdges(polygons, { + directionalLight: bakedDirectional, + ambientLight: effectiveAmbient, + }) + : null; return polygons.map((p, i) => computeTextureAtlasPlan(p, i, { directionalLight: bakedDirectional, ambientLight: effectiveAmbient, + seamBleed: seamBleedEdges?.has(i) ? effectiveSeamBleed : undefined, + seamEdges: seamBleedEdges?.get(i), textureEdgeRepairEdges: repairEdges[i], })); }, - [renderPolygon, polygons, bakedDirectional, effectiveAmbient], + [renderPolygon, polygons, bakedDirectional, effectiveAmbient, effectiveSeamBleed], ); const textureAtlas = useTextureAtlas( atlasPlans, @@ -637,6 +661,7 @@ export const PolyMesh = forwardRef(function PolyM ambientLight: effectiveAmbient, textureLighting: effectiveTextureLighting, strategies: effectiveStrategies, + seamBleed: effectiveSeamBleed, colorFrame: ++stableTriangleColorFrameRef.current, colorSteps: 8, colorFreezeFrames: 12, diff --git a/packages/react/src/scene/PolyScene.tsx b/packages/react/src/scene/PolyScene.tsx index 42bf7542..35de8042 100644 --- a/packages/react/src/scene/PolyScene.tsx +++ b/packages/react/src/scene/PolyScene.tsx @@ -6,18 +6,20 @@ import type { PolyAmbientLight, PolyTextureLightingMode, } from "@layoutit/polycss-core"; -import { BASE_TILE, parseHexColor } from "@layoutit/polycss-core"; +import { BASE_TILE, DEFAULT_SEAM_BLEED, parseHexColor } from "@layoutit/polycss-core"; import type { ShadowOptions } from "./sceneContext"; import { useCameraContext } from "../camera/context"; import { usePolySceneContext } from "./useSceneContext"; import { injectPolyBaseStyles } from "../styles/styles"; import type { TransformProps } from "../shapes/types"; import { + buildSeamBleedPolygonEdges, buildTextureEdgeRepairSets, computeTextureAtlasPlan, isProjectiveQuadPlan, isSolidTrianglePlan, type TextureQuality, + type PolySeamBleed, type PolyRenderStrategiesOption, TextureBorderShapePoly, TextureAtlasPoly, @@ -57,6 +59,8 @@ export interface PolySceneProps extends TransformProps { * desktop/mobile sprite sizing. Numeric values 0.1..1 force an explicit * raster scale and the 64px sprite. */ textureQuality?: TextureQuality; + /** Solid seam overscan. `"auto"` computes a fitted per-edge amount from the polygon plan. */ + seamBleed?: PolySeamBleed; /** * Render strategy overrides. Use `{ disable: ["u"] }` to force solid * triangles through the atlas path (``), or `{ disable: ["b", "i", "u"] }` @@ -100,6 +104,7 @@ function PolySceneInner({ ambientLight, textureLighting = "baked", textureQuality, + seamBleed = DEFAULT_SEAM_BLEED, strategies, autoCenter = false, shadow, @@ -182,8 +187,9 @@ function PolySceneInner({ directionalLight: directionalForAtlas, ambientLight: ambientForAtlas, textureLighting, + seamBleed, }; - }, [directionalForAtlas, ambientForAtlas, textureLighting]); + }, [directionalForAtlas, ambientForAtlas, textureLighting, seamBleed]); // Bbox center of all auto-centerable meshes in world coords. Kept as a Vec3 // so it can be added to `target` inside the scene transform — same @@ -224,12 +230,26 @@ function PolySceneInner({ const textureAtlasPlans = useMemo( () => { const repairEdges = buildTextureEdgeRepairSets(polygons); + const seamBleedEdges = seamBleed === "auto" || ( + typeof seamBleed === "number" && + Number.isFinite(seamBleed) && + seamBleed > 0 + ) + ? buildSeamBleedPolygonEdges(polygons, { + tileSize: polyContext.tileSize, + layerElevation: polyContext.layerElevation, + directionalLight: directionalForAtlas, + ambientLight: ambientForAtlas, + }) + : null; return polygons.map((p, i) => computeTextureAtlasPlan(p, i, { ...polyContext, + seamBleed: seamBleedEdges?.has(i) ? seamBleed : undefined, + seamEdges: seamBleedEdges?.get(i), textureEdgeRepairEdges: repairEdges[i], })); }, - [polygons, polyContext], + [polygons, polyContext, seamBleed, directionalForAtlas, ambientForAtlas], ); const textureAtlas = useTextureAtlas(textureAtlasPlans, textureLighting, textureQuality, strategies); @@ -369,10 +389,11 @@ function PolySceneInner({ directionalLight, ambientLight, strategies, + seamBleed, shadow, registerShadowCaster, }), - [textureLighting, directionalLight, ambientLight, strategies, shadow, registerShadowCaster], + [textureLighting, directionalLight, ambientLight, strategies, seamBleed, shadow, registerShadowCaster], ); return ( diff --git a/packages/react/src/scene/atlas/index.tsx b/packages/react/src/scene/atlas/index.tsx index 65f6e0ed..14be813e 100644 --- a/packages/react/src/scene/atlas/index.tsx +++ b/packages/react/src/scene/atlas/index.tsx @@ -6,12 +6,15 @@ export type { SolidPaintDefaults, PolyRenderStrategy, PolyRenderStrategiesOption, + PolySeamBleed, TextureQuality, } from "@layoutit/polycss-core"; export { isSolidTrianglePlan, isProjectiveQuadPlan, buildTextureEdgeRepairSets, + buildSeamBleedPolygonEdges, + buildSeamBleedPolygonSet, cssBorderShapeForPlan, } from "@layoutit/polycss-core"; diff --git a/packages/react/src/scene/atlas/solidTriangleStyle.ts b/packages/react/src/scene/atlas/solidTriangleStyle.ts index 5e627f26..3ded0c9d 100644 --- a/packages/react/src/scene/atlas/solidTriangleStyle.ts +++ b/packages/react/src/scene/atlas/solidTriangleStyle.ts @@ -1,4 +1,10 @@ -import { parsePureColor } from "@layoutit/polycss-core"; +import { + isSolidTrianglePlan, + offsetConvexPolygonPointsByEdgeAmounts, + parsePureColor, + resolveSeamBleed, + safePlanSeamBleedAmount, +} from "@layoutit/polycss-core"; import type { TextureAtlasPlan, PolyTextureLightingMode, @@ -6,7 +12,6 @@ import type { Vec2, Vec3, } from "@layoutit/polycss-core"; -import { isSolidTrianglePlan } from "@layoutit/polycss-core"; import type { CSSProperties } from "react"; // --------------------------------------------------------------------------- @@ -297,6 +302,34 @@ function offsetStableTrianglePoints( return [apexX, apexY, baseLeftX, baseLeftY, baseRightX, baseRightY]; } +function triangleEdgeIndexForPair(a: number, b: number): number | undefined { + if ((a + 1) % 3 === b) return a; + if ((b + 1) % 3 === a) return b; + return undefined; +} + +function stableTriangleEdgeAmounts( + entry: TextureAtlasPlan, + a: number, + b: number, + c: number, + screenPts: number[], +): number[] | null { + const seamEdges = entry.seamBleedEdges; + if (!seamEdges?.size) return null; + const seamAmount = entry.seamBleed === undefined + ? SOLID_TRIANGLE_BLEED + : entry.seamBleed; + const edgePairs: Array<[number, number]> = [[c, a], [a, b], [b, c]]; + return edgePairs.map(([from, to], localEdgeIndex) => { + const edgeIndex = triangleEdgeIndexForPair(from, to); + const requested = edgeIndex !== undefined && seamEdges.has(edgeIndex) + ? entry.seamBleedEdgeAmounts?.get(edgeIndex) ?? resolveSeamBleed(seamAmount, SOLID_TRIANGLE_BLEED) + : 0; + return safePlanSeamBleedAmount(screenPts, localEdgeIndex, requested); + }); +} + export function formatStableTriangleTransformScalars( x0: number, x1: number, x2: number, y0: number, y1: number, y2: number, @@ -406,7 +439,16 @@ export function solidTriangleStyle( const SOLID_TRIANGLE_CANONICAL_SIZE = 32; const left = Math.max(0, Math.min(baseLength, apexX)); const right = Math.max(0, baseLength - left); - const expanded = offsetConvexPolygonPoints([left, 0, 0, height, left + right, height], SOLID_TRIANGLE_BLEED); + const screenPts = [left, 0, 0, height, left + right, height]; + const edgeAmounts = stableTriangleEdgeAmounts(entry, a, b, c, screenPts); + const expanded = edgeAmounts + ? offsetConvexPolygonPointsByEdgeAmounts(screenPts, edgeAmounts) + : offsetStableTrianglePoints( + left, + right, + height, + resolveSeamBleed(entry.seamBleed, SOLID_TRIANGLE_BLEED), + ); const apex2: Vec2 = [expanded[0], expanded[1]]; const baseLeft2: Vec2 = [expanded[2], expanded[3]]; const baseRight2: Vec2 = [expanded[4], expanded[5]]; diff --git a/packages/react/src/scene/atlas/stableTriangleDom.ts b/packages/react/src/scene/atlas/stableTriangleDom.ts index 7f268158..a39f7461 100644 --- a/packages/react/src/scene/atlas/stableTriangleDom.ts +++ b/packages/react/src/scene/atlas/stableTriangleDom.ts @@ -4,6 +4,7 @@ import type { PolyAmbientLight, PolyTextureLightingMode, PolyRenderStrategiesOption, + PolySeamBleed, } from "@layoutit/polycss-core"; import { isSolidTriangleSupported } from "./detection"; import { @@ -37,6 +38,7 @@ export interface StableTriangleDomUpdateOptions { ambientLight?: PolyAmbientLight; textureLighting?: PolyTextureLightingMode; strategies?: PolyRenderStrategiesOption; + seamBleed?: PolySeamBleed; colorFrame?: number; colorSteps?: number; colorFreezeFrames?: number; diff --git a/packages/react/src/scene/index.ts b/packages/react/src/scene/index.ts index 3c35f653..563609f8 100644 --- a/packages/react/src/scene/index.ts +++ b/packages/react/src/scene/index.ts @@ -1,6 +1,6 @@ export { PolyScene } from "./PolyScene"; export type { PolySceneProps } from "./PolyScene"; -export type { PolyRenderStrategy, PolyRenderStrategiesOption } from "./atlas"; +export type { PolyRenderStrategy, PolyRenderStrategiesOption, PolySeamBleed } from "./atlas"; export { PolyMesh } from "./PolyMesh"; export type { PolyMeshProps } from "./PolyMesh"; export { PolyGround } from "./PolyGround"; diff --git a/packages/react/src/scene/sceneContext.ts b/packages/react/src/scene/sceneContext.ts index 9ec86ae2..b2460ce8 100644 --- a/packages/react/src/scene/sceneContext.ts +++ b/packages/react/src/scene/sceneContext.ts @@ -12,7 +12,7 @@ import type { PolyTextureLightingMode, Polygon, } from "@layoutit/polycss-core"; -import type { PolyRenderStrategiesOption } from "./atlas"; +import type { PolyRenderStrategiesOption, PolySeamBleed } from "./atlas"; export interface ShadowOptions { color?: string; @@ -25,6 +25,7 @@ export interface PolySceneContextValue { directionalLight?: PolyDirectionalLight; ambientLight?: PolyAmbientLight; strategies?: PolyRenderStrategiesOption; + seamBleed?: PolySeamBleed; shadow?: ShadowOptions; /** * Called by PolyMesh to register/unregister itself as a shadow caster. diff --git a/packages/vue/README.md b/packages/vue/README.md index ad8a69f3..11437ba3 100644 --- a/packages/vue/README.md +++ b/packages/vue/README.md @@ -88,6 +88,7 @@ import { PolyCamera, PolyScene, PolyOrbitControls, PolyMesh } from "@layoutit/po - `directionalLight` and `ambientLight` control scene lighting. - `textureLighting` chooses `"baked"` or `"dynamic"`. - `textureQuality` controls atlas raster budget. +- Seam bleed defaults to `1.5` CSS px on detected shared solid edges; `seamBleed="auto"` fits the amount from each polygon plan. - `strategies` can disable selected render strategies for diagnostics. - `autoCenter` rotates around the rendered mesh bounds instead of world origin. @@ -98,7 +99,7 @@ import { PolyCamera, PolyScene, PolyOrbitControls, PolyMesh } from "@layoutit/po - `polygons` accepts pre-parsed geometry. - `position`, `scale`, and `rotation` transform the mesh wrapper. - `autoCenter` shifts the mesh bbox center to local origin. -- `meshResolution` chooses `"lossy"` (default) or `"lossless"` optimization. +- `meshResolution` chooses `"lossy"` (default) or `"lossless"` optimization. Lossy also applies bounded seam repair. - `castShadow` emits CSS-projected shadows in dynamic lighting mode. ### Controls @@ -177,7 +178,7 @@ polycss renders in the DOM, so performance is mostly determined by how many poly - Textured polygons are packed into generated texture atlases. - Dynamic lighting runs through CSS custom properties instead of per-frame JavaScript. - Voxel-shaped meshes mount only camera-facing leaves when the mesh is eligible. -- `meshResolution: "lossy"` can merge compatible polygons to reduce DOM node count. +- `meshResolution: "lossy"` merges compatible polygons, then may spend a small split budget to repair high-risk seams. For diagnostics, all renderer packages export `collectPolyRenderStats(root)`, which returns mounted polygon leaf counts, shadow counts, surface categories, and bucket counts for an already-rendered scene. diff --git a/packages/vue/src/index.ts b/packages/vue/src/index.ts index 4eedaf5b..1f3d82b3 100644 --- a/packages/vue/src/index.ts +++ b/packages/vue/src/index.ts @@ -13,7 +13,7 @@ export type { PolyCameraContextValue } from "./camera"; export { PolyScene } from "./scene"; export type { PolySceneProps } from "./scene"; -export type { PolyRenderStrategy, PolyRenderStrategiesOption } from "@layoutit/polycss-core"; +export type { PolyRenderStrategy, PolyRenderStrategiesOption, PolySeamBleed } from "@layoutit/polycss-core"; export { PolyMesh } from "./scene"; export type { PolyMeshProps } from "./scene"; export { PolyGround } from "./scene"; @@ -140,6 +140,14 @@ export type { TexturePaintMetricsOptions, CoverPlanarPolygonsOptions, CullInteriorOptions, + SeamFacetSplitCandidate, + SeamFacetSplitCandidateReason, + SeamFacetSplitOptions, + SeamFacetSplitReport, + SeamOverlapCandidate, + SeamOverlapCandidateKind, + SeamOverlapDiagnostics, + SeamOverlapOptions, CameraCullNormalGroup, CameraCullRotation, ApproximateMergeOptions, @@ -154,6 +162,12 @@ export { mergePolygons, coverPlanarPolygons, optimizeMeshPolygons, + repairMeshSeams, + seamFacetSplitPolygons, + seamFacetSplitReport, + seamOverlapDiagnostics, + seamOverlapPolygons, + seamOverlapReport, cullInteriorPolygons, cameraCullNormalGroups, cameraCullNormalGroupsFromPolygons, @@ -200,11 +214,14 @@ export { buildSceneContext, computeSceneBbox, BASE_TILE, + DEFAULT_SEAM_BLEED, DEFAULT_CAMERA_STATE, DEFAULT_PROJECTION, normalizeInvertMultiplier, createPolyAnimationMixer, optimizeAnimatedMeshPolygons, + DEFAULT_SEAM_FACET_SPLIT_OPTIONS, + DEFAULT_SEAM_OVERLAP_OPTIONS, LoopOnce, LoopRepeat, LoopPingPong, diff --git a/packages/vue/src/scene/PolyMesh.ts b/packages/vue/src/scene/PolyMesh.ts index a4bbb8a3..397fa411 100644 --- a/packages/vue/src/scene/PolyMesh.ts +++ b/packages/vue/src/scene/PolyMesh.ts @@ -19,9 +19,16 @@ import { defineComponent, h, computed, inject, onMounted, onBeforeUnmount, ref, watch } from "vue"; import type { PropType, VNode, CSSProperties } from "vue"; import type { MeshResolution, Polygon, PolyTextureLightingMode, Vec3 } from "@layoutit/polycss-core"; -import { computeSceneBbox, inverseRotateVec3, findOverlappingPolygonDuplicates, parseHexColor } from "@layoutit/polycss-core"; +import { + computeSceneBbox, + DEFAULT_SEAM_BLEED, + inverseRotateVec3, + findOverlappingPolygonDuplicates, + parseHexColor, +} from "@layoutit/polycss-core"; import { usePolyMesh } from "./useMesh"; import { + buildSeamBleedPolygonEdges, buildTextureEdgeRepairSets, computeTextureAtlasPlan, cssBorderShapeForPlan, @@ -29,6 +36,7 @@ import { isProjectiveQuadPlan, isSolidTrianglePlan, type TextureQuality, + type PolySeamBleed, type SolidPaintDefaults, renderTextureBorderShapePoly, renderTextureAtlasPoly, @@ -79,6 +87,8 @@ export interface PolyMeshProps extends InteractionProps { * desktop/mobile sprite sizing. Numeric values 0.1..1 force an explicit * raster scale and the 64px sprite. */ textureQuality?: TextureQuality; + /** Solid seam overscan. `"auto"` computes a fitted per-edge amount from the polygon plan. */ + seamBleed?: PolySeamBleed; /** * When `true` and the scene is in dynamic lighting mode, the renderer emits * a flat shadow leaf sibling for each non-duplicate polygon. The shadow is @@ -153,6 +163,7 @@ export const PolyMesh = defineComponent({ autoCenter: { type: Boolean, default: false }, textureLighting: { type: String as PropType, default: undefined }, textureQuality: { type: [Number, String] as PropType, default: undefined }, + seamBleed: { type: [Number, String] as PropType, default: undefined }, castShadow: { type: Boolean as PropType, default: false }, meshResolution: { type: String as PropType, default: undefined }, class: { type: String }, @@ -218,6 +229,7 @@ export const PolyMesh = defineComponent({ () => props.textureLighting ?? sceneCtx?.value.textureLighting ?? "baked", ); const atlasStrategies = computed(() => sceneCtx?.value.strategies); + const atlasSeamBleed = computed(() => props.seamBleed ?? sceneCtx?.value.seamBleed ?? DEFAULT_SEAM_BLEED); const atlasDirectional = computed(() => atlasTextureLighting.value === "dynamic" ? undefined : sceneCtx?.value.directionalLight, ); @@ -264,10 +276,22 @@ export const PolyMesh = defineComponent({ const textureAtlasPlans = computed(() => { if (!atlasAutoRender) return []; const repairEdges = buildTextureEdgeRepairSets(polygons.value); + const seamBleedEdges = atlasSeamBleed.value === "auto" || ( + typeof atlasSeamBleed.value === "number" && + Number.isFinite(atlasSeamBleed.value) && + atlasSeamBleed.value > 0 + ) + ? buildSeamBleedPolygonEdges(polygons.value, { + directionalLight: bakedDirectional.value, + ambientLight: atlasAmbient.value, + }) + : null; return polygons.value.map((p, i) => computeTextureAtlasPlan(p, i, { directionalLight: bakedDirectional.value, ambientLight: atlasAmbient.value, + seamBleed: seamBleedEdges?.has(i) ? atlasSeamBleed.value : undefined, + seamEdges: seamBleedEdges?.get(i), textureEdgeRepairEdges: repairEdges[i], }), ); @@ -375,6 +399,7 @@ export const PolyMesh = defineComponent({ ambientLight: atlasAmbient.value, textureLighting: atlasTextureLighting.value, strategies: atlasStrategies.value, + seamBleed: atlasSeamBleed.value, colorFrame: ++stableTriangleColorFrame.value, colorSteps: 8, colorFreezeFrames: 12, diff --git a/packages/vue/src/scene/PolyScene.ts b/packages/vue/src/scene/PolyScene.ts index e12442e7..a9fef9fe 100644 --- a/packages/vue/src/scene/PolyScene.ts +++ b/packages/vue/src/scene/PolyScene.ts @@ -27,17 +27,19 @@ import type { PolyTextureLightingMode, Vec3, } from "@layoutit/polycss-core"; -import { parseHexColor } from "@layoutit/polycss-core"; +import { DEFAULT_SEAM_BLEED, parseHexColor } from "@layoutit/polycss-core"; import { PolyCameraContextKey } from "../camera"; import { usePolySceneContext } from "./useSceneContext"; import { injectPolyBaseStyles } from "../styles"; import { PolySceneContextKey, type PolyShadowOptions, type PolyShadowRegistry } from "./sceneContext"; import { + buildSeamBleedPolygonEdges, buildTextureEdgeRepairSets, computeTextureAtlasPlan, isProjectiveQuadPlan, isSolidTrianglePlan, type TextureQuality, + type PolySeamBleed, type PolyRenderStrategiesOption, renderTextureBorderShapePoly, renderTextureAtlasPoly, @@ -60,6 +62,8 @@ export interface PolySceneProps { * desktop/mobile sprite sizing. Numeric values 0.1..1 force an explicit * raster scale and the 64px sprite. */ textureQuality?: TextureQuality; + /** Solid seam overscan. `"auto"` computes a fitted per-edge amount from the polygon plan. */ + seamBleed?: PolySeamBleed; /** Opt out of specific render strategies. Disabled strategies fall through the chain (b→i→s, u→i→s, i→s). `` cannot be disabled. */ strategies?: PolyRenderStrategiesOption; /** @@ -107,6 +111,7 @@ export const PolyScene = defineComponent({ default: "baked", }, textureQuality: { type: [Number, String] as PropType, default: undefined }, + seamBleed: { type: [Number, String] as PropType, default: undefined }, strategies: { type: Object as PropType, default: undefined }, autoCenter: { type: Boolean, default: false }, shadow: { type: Object as PropType, default: undefined }, @@ -159,6 +164,7 @@ export const PolyScene = defineComponent({ directionalLight: props.directionalLight, ambientLight: props.ambientLight, strategies: props.strategies, + seamBleed: props.seamBleed ?? DEFAULT_SEAM_BLEED, shadow: props.shadow, shadowRegistry, })); @@ -244,12 +250,27 @@ export const PolyScene = defineComponent({ const directionalForAtlas = dynamic ? undefined : props.directionalLight; const ambientForAtlas = dynamic ? undefined : props.ambientLight; const repairEdges = buildTextureEdgeRepairSets(sceneResult.value.polygons); + const seamBleed = props.seamBleed ?? DEFAULT_SEAM_BLEED; + const seamBleedEdges = seamBleed === "auto" || ( + typeof seamBleed === "number" && + Number.isFinite(seamBleed) && + seamBleed > 0 + ) + ? buildSeamBleedPolygonEdges(sceneResult.value.polygons, { + tileSize: polyContext.value.tileSize, + layerElevation: polyContext.value.layerElevation, + directionalLight: directionalForAtlas, + ambientLight: ambientForAtlas, + }) + : null; return sceneResult.value.polygons.map((p, i) => computeTextureAtlasPlan(p, i, { tileSize: polyContext.value.tileSize, layerElevation: polyContext.value.layerElevation, directionalLight: directionalForAtlas, ambientLight: ambientForAtlas, + seamBleed: seamBleedEdges?.has(i) ? seamBleed : undefined, + seamEdges: seamBleedEdges?.get(i), textureEdgeRepairEdges: repairEdges[i], }) ); diff --git a/packages/vue/src/scene/atlas/index.ts b/packages/vue/src/scene/atlas/index.ts index 7193f89e..20b5bccc 100644 --- a/packages/vue/src/scene/atlas/index.ts +++ b/packages/vue/src/scene/atlas/index.ts @@ -6,12 +6,15 @@ export type { SolidPaintDefaults, PolyRenderStrategy, PolyRenderStrategiesOption, + PolySeamBleed, TextureQuality, } from "@layoutit/polycss-core"; export { isSolidTrianglePlan, isProjectiveQuadPlan, buildTextureEdgeRepairSets, + buildSeamBleedPolygonEdges, + buildSeamBleedPolygonSet, cssBorderShapeForPlan, } from "@layoutit/polycss-core"; diff --git a/packages/vue/src/scene/atlas/solidTriangleStyle.ts b/packages/vue/src/scene/atlas/solidTriangleStyle.ts index 0c4e4372..137ec40d 100644 --- a/packages/vue/src/scene/atlas/solidTriangleStyle.ts +++ b/packages/vue/src/scene/atlas/solidTriangleStyle.ts @@ -1,4 +1,10 @@ -import { parsePureColor } from "@layoutit/polycss-core"; +import { + isSolidTrianglePlan, + offsetConvexPolygonPointsByEdgeAmounts, + parsePureColor, + resolveSeamBleed, + safePlanSeamBleedAmount, +} from "@layoutit/polycss-core"; import type { TextureAtlasPlan, PolyTextureLightingMode, @@ -6,7 +12,6 @@ import type { Vec2, Vec3, } from "@layoutit/polycss-core"; -import { isSolidTrianglePlan } from "@layoutit/polycss-core"; import type { CSSProperties } from "vue"; // --------------------------------------------------------------------------- @@ -250,6 +255,97 @@ export function offsetConvexPolygonPoints(points: number[], amount: number): num return expanded; } +function offsetStableTrianglePoints( + left: number, + right: number, + height: number, + amount: number, +): number[] { + const baseWidth = left + right; + if ( + amount <= 0 || + height <= BASIS_EPS || + baseWidth <= BASIS_EPS || + !Number.isFinite(left + right + height + amount) + ) { + return offsetConvexPolygonPoints([left, 0, 0, height, baseWidth, height], amount); + } + const leftLen = Math.sqrt(left * left + height * height); + const rightLen = Math.sqrt(right * right + height * height); + if (leftLen <= BASIS_EPS || rightLen <= BASIS_EPS) { + return offsetConvexPolygonPoints([left, 0, 0, height, baseWidth, height], amount); + } + const leftOffsetX = -amount * height / leftLen; + const leftOffsetY = -amount * left / leftLen; + const rightOffsetX = amount * height / rightLen; + const rightOffsetY = -amount * right / rightLen; + const apexLineLeftX = left + leftOffsetX; + const apexLineLeftY = leftOffsetY; + const apexLineRightX = baseWidth + rightOffsetX; + const apexLineRightY = height + rightOffsetY; + const det = -height * baseWidth; + if (Math.abs(det) <= BASIS_EPS) { + return offsetConvexPolygonPoints([left, 0, 0, height, baseWidth, height], amount); + } + const qx = apexLineLeftX - apexLineRightX; + const qy = apexLineLeftY - apexLineRightY; + const t = (qx * height + qy * left) / det; + let apexX = apexLineRightX - t * right; + let apexY = apexLineRightY - t * height; + let baseLeftX = -amount * (left + leftLen) / height; + let baseLeftY = height + amount; + let baseRightX = baseWidth + amount * (right + rightLen) / height; + let baseRightY = baseLeftY; + const maxMiter = Math.max(2, amount * 4); + const apexDx = apexX - left; + const apexDy = apexY; + const apexMiter = Math.sqrt(apexDx * apexDx + apexDy * apexDy); + if (apexMiter > maxMiter) { + apexX = left + (apexDx / apexMiter) * maxMiter; + apexY = (apexDy / apexMiter) * maxMiter; + } + const leftMiter = Math.sqrt(baseLeftX * baseLeftX + amount * amount); + if (leftMiter > maxMiter) { + baseLeftX = (baseLeftX / leftMiter) * maxMiter; + baseLeftY = height + (amount / leftMiter) * maxMiter; + } + const rightDx = baseRightX - baseWidth; + const rightMiter = Math.sqrt(rightDx * rightDx + amount * amount); + if (rightMiter > maxMiter) { + baseRightX = baseWidth + (rightDx / rightMiter) * maxMiter; + baseRightY = height + (amount / rightMiter) * maxMiter; + } + return [apexX, apexY, baseLeftX, baseLeftY, baseRightX, baseRightY]; +} + +function triangleEdgeIndexForPair(a: number, b: number): number | undefined { + if ((a + 1) % 3 === b) return a; + if ((b + 1) % 3 === a) return b; + return undefined; +} + +function stableTriangleEdgeAmounts( + entry: TextureAtlasPlan, + a: number, + b: number, + c: number, + screenPts: number[], +): number[] | null { + const seamEdges = entry.seamBleedEdges; + if (!seamEdges?.size) return null; + const seamAmount = entry.seamBleed === undefined + ? SOLID_TRIANGLE_BLEED + : entry.seamBleed; + const edgePairs: Array<[number, number]> = [[c, a], [a, b], [b, c]]; + return edgePairs.map(([from, to], localEdgeIndex) => { + const edgeIndex = triangleEdgeIndexForPair(from, to); + const requested = edgeIndex !== undefined && seamEdges.has(edgeIndex) + ? entry.seamBleedEdgeAmounts?.get(edgeIndex) ?? resolveSeamBleed(seamAmount, SOLID_TRIANGLE_BLEED) + : 0; + return safePlanSeamBleedAmount(screenPts, localEdgeIndex, requested); + }); +} + function cssPoints(vertices: Vec3[], tile: number, elev: number): Vec3[] { return vertices.map((v) => [v[1] * tile, v[0] * tile, v[2] * elev]); } @@ -338,7 +434,16 @@ export function solidTriangleStyle( const SOLID_TRIANGLE_CANONICAL_SIZE = 32; const left = Math.max(0, Math.min(baseLength, apexX)); const right = Math.max(0, baseLength - left); - const expanded = offsetConvexPolygonPoints([left, 0, 0, height, left + right, height], SOLID_TRIANGLE_BLEED); + const screenPts = [left, 0, 0, height, left + right, height]; + const edgeAmounts = stableTriangleEdgeAmounts(entry, a, b, c, screenPts); + const expanded = edgeAmounts + ? offsetConvexPolygonPointsByEdgeAmounts(screenPts, edgeAmounts) + : offsetStableTrianglePoints( + left, + right, + height, + resolveSeamBleed(entry.seamBleed, SOLID_TRIANGLE_BLEED), + ); const apex2: Vec2 = [expanded[0], expanded[1]]; const baseLeft2: Vec2 = [expanded[2], expanded[3]]; const baseRight2: Vec2 = [expanded[4], expanded[5]]; diff --git a/packages/vue/src/scene/atlas/stableTriangleDom.ts b/packages/vue/src/scene/atlas/stableTriangleDom.ts index 8f7ff559..797a491b 100644 --- a/packages/vue/src/scene/atlas/stableTriangleDom.ts +++ b/packages/vue/src/scene/atlas/stableTriangleDom.ts @@ -4,6 +4,7 @@ import type { PolyAmbientLight, PolyTextureLightingMode, PolyRenderStrategiesOption, + PolySeamBleed, } from "@layoutit/polycss-core"; import { isSolidTriangleSupported } from "./detection"; import { @@ -36,6 +37,7 @@ export interface StableTriangleDomUpdateOptions { ambientLight?: PolyAmbientLight; textureLighting?: PolyTextureLightingMode; strategies?: PolyRenderStrategiesOption; + seamBleed?: PolySeamBleed; colorFrame?: number; colorSteps?: number; colorFreezeFrames?: number; diff --git a/packages/vue/src/scene/sceneContext.ts b/packages/vue/src/scene/sceneContext.ts index de730102..4555c24c 100644 --- a/packages/vue/src/scene/sceneContext.ts +++ b/packages/vue/src/scene/sceneContext.ts @@ -12,7 +12,7 @@ import type { PolyTextureLightingMode, Polygon, } from "@layoutit/polycss-core"; -import type { PolyRenderStrategiesOption } from "./atlas"; +import type { PolyRenderStrategiesOption, PolySeamBleed } from "./atlas"; export interface PolyShadowOptions { color?: string; @@ -36,6 +36,7 @@ export interface PolySceneContextValue { directionalLight?: PolyDirectionalLight; ambientLight?: PolyAmbientLight; strategies?: PolyRenderStrategiesOption; + seamBleed?: PolySeamBleed; shadow?: PolyShadowOptions; shadowRegistry?: PolyShadowRegistry; } diff --git a/website/src/components/BuilderWorkbench/BuilderWorkbench.tsx b/website/src/components/BuilderWorkbench/BuilderWorkbench.tsx index a90e4a03..bcc8a81a 100644 --- a/website/src/components/BuilderWorkbench/BuilderWorkbench.tsx +++ b/website/src/components/BuilderWorkbench/BuilderWorkbench.tsx @@ -134,7 +134,7 @@ export default function BuilderWorkbench() { mapItems((it) => snapPlacement(it, terrainVertices, sceneOptions.gridResolution)); }, [terrainVertices, mapItems, sceneOptions.gridResolution]); - const { renderedPolygonsById, renderItems, gridPolygons } = useSceneRender({ + const { renderedPolygonsById, interiorShellPolygonsById, renderItems, gridPolygons } = useSceneRender({ placedItems, selectedId, sceneOptions, @@ -281,6 +281,7 @@ export default function BuilderWorkbench() { placementDraft={!!placementDraft} renderItems={renderItems} renderedPolygonsById={renderedPolygonsById} + interiorShellPolygonsById={interiorShellPolygonsById} selectedId={selectedId} gizmoMode={gizmoMode} gizmoDragging={gizmoDragging} diff --git a/website/src/components/BuilderWorkbench/builder-workbench.css b/website/src/components/BuilderWorkbench/builder-workbench.css index 0b4fd29c..fda84852 100644 --- a/website/src/components/BuilderWorkbench/builder-workbench.css +++ b/website/src/components/BuilderWorkbench/builder-workbench.css @@ -162,6 +162,30 @@ pointer-events: none; } +.dn-interior-shell-mesh, +.dn-interior-shell-mesh *, +.dn-seam-patches-mesh, +.dn-seam-patches-mesh * { + pointer-events: none !important; +} + +.dn-seam-patches-mesh b, +.dn-seam-patches-mesh i, +.dn-seam-patches-mesh s, +.dn-seam-patches-mesh u { + backface-visibility: visible !important; + -webkit-backface-visibility: visible !important; +} + +.builder-placed.is-mesh-hidden > b, +.builder-placed.is-mesh-hidden > i, +.builder-placed.is-mesh-hidden > s, +.builder-placed.is-mesh-hidden > u, +.builder-placed.is-mesh-hidden > q, +.builder-placed.is-mesh-hidden > .polycss-bucket { + display: none !important; +} + /* Force both faces of every wireframe edge to render. polycss applies `backface-visibility: hidden` to polygon leaves via a rule `.polycss-scene b/i/s/u {...}` that gets injected at runtime AFTER diff --git a/website/src/components/BuilderWorkbench/components/BuilderDock.tsx b/website/src/components/BuilderWorkbench/components/BuilderDock.tsx index 752da64f..f495b065 100644 --- a/website/src/components/BuilderWorkbench/components/BuilderDock.tsx +++ b/website/src/components/BuilderWorkbench/components/BuilderDock.tsx @@ -53,7 +53,7 @@ export function BuilderDock({ /> ; renderedPolygonsById: Map; + interiorShellPolygonsById: Map; selectedId: string | null; gizmoMode: GizmoMode; gizmoDragging: boolean; @@ -61,6 +62,7 @@ export function BuilderScene({ placementDraft, renderItems, renderedPolygonsById, + interiorShellPolygonsById, selectedId, gizmoMode, gizmoDragging, @@ -73,12 +75,13 @@ export function BuilderScene({ selected, }: BuilderSceneProps) { const Cam = sceneOptions.perspective === false ? PolyOrthographicCamera : PolyPerspectiveCamera; + const sceneKey = sceneOptions.meshResolution; const camProps = sceneOptions.perspective === false ? { zoom: sceneOptions.zoom, rotX: sceneOptions.rotX, rotY: sceneOptions.rotY, target: sceneOptions.target } : { zoom: sceneOptions.zoom, rotX: sceneOptions.rotX, rotY: sceneOptions.rotY, target: sceneOptions.target, perspective: sceneOptions.perspective }; return ( - + {sceneOptions.dragMode === "pan" ? ( )} - {renderItems.map((it) => ( - - ))} + {renderItems.map((it) => { + const shell = interiorShellPolygonsById.get(it.id) ?? []; + return ( + + {shell.length > 0 ? ( + + ) : null} + + ); + })} {selected && ( 0 ? NORMALIZED_MAX_DIM / bboxResult.span : 1; diff --git a/website/src/components/BuilderWorkbench/hooks/usePlacements.ts b/website/src/components/BuilderWorkbench/hooks/usePlacements.ts index aabe54f9..536a57c0 100644 --- a/website/src/components/BuilderWorkbench/hooks/usePlacements.ts +++ b/website/src/components/BuilderWorkbench/hooks/usePlacements.ts @@ -1,15 +1,16 @@ import { useCallback, useRef, useState, type RefObject } from "react"; import { optimizeMeshPolygons } from "@layoutit/polycss-react"; -import type { MeshResolution, PolyMeshHandle, Vec3 } from "@layoutit/polycss-react"; +import type { PolyMeshHandle, Vec3 } from "@layoutit/polycss-react"; import type { PresetModel } from "../../GalleryWorkbench/types"; import { loadPresetModel } from "../../GalleryWorkbench/helpers/loaders"; import { PARSER_DEFAULTS, NORMALIZED_MAX_DIM } from "../defaults"; import { meshBbox } from "../geometry/meshBbox"; import { placeMeshOnFloor } from "../geometry/placement"; import type { PlacedItem } from "../types"; +import { activeMeshResolution, type WorkbenchMeshResolution } from "../../types"; export interface UsePlacementsOptions { - meshResolution: MeshResolution; + meshResolution: WorkbenchMeshResolution; } export interface UsePlacementsResult { @@ -36,6 +37,7 @@ export interface UsePlacementsResult { } export function usePlacements({ meshResolution }: UsePlacementsOptions): UsePlacementsResult { + const effectiveMeshResolution = activeMeshResolution(meshResolution); const [placedItems, setPlacedItems] = useState([]); const [selectedId, setSelectedId] = useState(null); const [meshHandlesTick, setMeshHandlesTick] = useState(0); @@ -93,7 +95,7 @@ export function usePlacements({ meshResolution }: UsePlacementsOptions): UsePlac ): Promise => { try { const loaded = await loadPresetModel(preset, PARSER_DEFAULTS); - const optimized = optimizeMeshPolygons(loaded.rawPolygons, { meshResolution }); + const optimized = optimizeMeshPolygons(loaded.rawPolygons, { meshResolution: effectiveMeshResolution }); const bbox = meshBbox(optimized); const fitScale = bbox.span > 0 ? NORMALIZED_MAX_DIM / bbox.span : 1; const placement = placeMeshOnFloor(worldX, worldY, bbox, fitScale); @@ -114,7 +116,7 @@ export function usePlacements({ meshResolution }: UsePlacementsOptions): UsePlac return null; } }, - [meshResolution], + [effectiveMeshResolution], ); const appendItems = useCallback((items: PlacedItem[]) => { diff --git a/website/src/components/BuilderWorkbench/hooks/useSceneLoader.ts b/website/src/components/BuilderWorkbench/hooks/useSceneLoader.ts index bc16edb5..40f6a9ec 100644 --- a/website/src/components/BuilderWorkbench/hooks/useSceneLoader.ts +++ b/website/src/components/BuilderWorkbench/hooks/useSceneLoader.ts @@ -1,6 +1,6 @@ import { useCallback, useEffect, useRef, type RefObject } from "react"; import { optimizeMeshPolygons } from "@layoutit/polycss-react"; -import type { MeshResolution, PolyFirstPersonControlsHandle, Vec3 } from "@layoutit/polycss-react"; +import type { PolyFirstPersonControlsHandle, Vec3 } from "@layoutit/polycss-react"; import type { PresetModel } from "../../GalleryWorkbench/types"; import { loadPresetModel } from "../../GalleryWorkbench/helpers/loaders"; import { PARSER_DEFAULTS, NORMALIZED_MAX_DIM } from "../defaults"; @@ -9,7 +9,11 @@ import { placeMeshOnFloor } from "../geometry/placement"; import { SCENE_PRESETS } from "../scenes"; import { PRESETS } from "../../GalleryWorkbench/presets"; import type { PlacedItem } from "../types"; -import type { SceneOptionsState } from "../../types"; +import { + activeMeshResolution, + type SceneOptionsState, + type WorkbenchMeshResolution, +} from "../../types"; import type { DragMode } from "../../types"; export interface UseSceneLoaderOptions { @@ -27,7 +31,7 @@ export interface UseSceneLoaderOptions { fpvRenderDistance: number; targetWorld: Vec3; fpvControlsRef: RefObject; - meshResolution: MeshResolution; + meshResolution: WorkbenchMeshResolution; updateScene: (partial: Partial) => void; } @@ -47,8 +51,8 @@ export function useSceneLoader({ meshResolution, updateScene, }: UseSceneLoaderOptions): UseSceneLoaderResult { - const meshResolutionRef = useRef(meshResolution); - meshResolutionRef.current = meshResolution; + const meshResolutionRef = useRef(activeMeshResolution(meshResolution)); + meshResolutionRef.current = activeMeshResolution(meshResolution); // Dedupe in-flight loads so the same item can't kick off twice between // the setState callback and the next effect tick. diff --git a/website/src/components/BuilderWorkbench/hooks/useSceneRender.ts b/website/src/components/BuilderWorkbench/hooks/useSceneRender.ts index 29dc3488..8b7d45d9 100644 --- a/website/src/components/BuilderWorkbench/hooks/useSceneRender.ts +++ b/website/src/components/BuilderWorkbench/hooks/useSceneRender.ts @@ -1,9 +1,9 @@ import { useMemo, type RefObject } from "react"; import { optimizeMeshPolygons } from "@layoutit/polycss-react"; import type { PolyFirstPersonControlsHandle, Polygon } from "@layoutit/polycss-react"; -import { interiorFillPolygons } from "../../GalleryWorkbench/helpers/interiorFill"; +import { interiorShellPolygons } from "../../helpers/interiorShell"; import { useFpvHost, useFpvCull } from "../../fpv"; -import type { SceneOptionsState } from "../../types"; +import { activeMeshResolution, type SceneOptionsState } from "../../types"; import { buildGridPolygons } from "../geometry/grid"; import type { TerrainVertices } from "../geometry/terrain"; import type { PlacedItem } from "../types"; @@ -22,6 +22,7 @@ export interface UseSceneRenderOptions { export interface UseSceneRenderResult { renderedPolygonsById: Map; + interiorShellPolygonsById: Map; renderItems: Array; gridPolygons: Polygon[]; } @@ -34,17 +35,43 @@ export function useSceneRender({ updateScene, terrainVertices, }: UseSceneRenderOptions): UseSceneRenderResult { + const effectiveMeshResolution = activeMeshResolution(sceneOptions.meshResolution); const renderedPolygonsById = useMemo(() => { const out = new Map(); for (const it of placedItems) { if (it.rawPolygons === null) continue; const optimized = optimizeMeshPolygons(it.rawPolygons, { - meshResolution: sceneOptions.meshResolution, + meshResolution: effectiveMeshResolution, }); - out.set(it.id, sceneOptions.meshInteriorFill ? [...optimized, ...interiorFillPolygons(optimized)] : optimized); + if (it.preset.kind === "vox") { + out.set(it.id, optimized); + continue; + } + out.set(it.id, optimized); + } + 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); } return out; - }, [placedItems, sceneOptions.meshResolution, sceneOptions.meshInteriorFill]); + }, [ + placedItems, + sceneOptions.interiorFill, + effectiveMeshResolution, + ]); // World-space polygons for FPV bbox sampling. `useFpvHost` only reads // vertex extents when `dragMode` transitions to "fpv". @@ -94,5 +121,5 @@ export function useSceneRender({ [sceneOptions.gridResolution, terrainVertices], ); - return { renderedPolygonsById, renderItems, gridPolygons }; + return { renderedPolygonsById, interiorShellPolygonsById, renderItems, gridPolygons }; } diff --git a/website/src/components/BuilderWorkbench/types.ts b/website/src/components/BuilderWorkbench/types.ts index d17f418e..0ed36c25 100644 --- a/website/src/components/BuilderWorkbench/types.ts +++ b/website/src/components/BuilderWorkbench/types.ts @@ -6,8 +6,8 @@ export interface PlacedItem { id: string; preset: PresetModel; /** Pre-optimization polygons from the parser. Stored so we can re-apply - * `optimizeMeshPolygons` + interior-fill at render time when the Dock's - * meshResolution / meshInteriorFill change without re-fetching the asset. + * `optimizeMeshPolygons` at render time when the Dock's meshResolution + * changes without re-fetching the asset. * `null` means the item is placed but its model hasn't been fetched yet — * scene-preset items load lazily on proximity (see the lazy-load effect * below). Pending items have placeholder `position` + `fitScale` until diff --git a/website/src/components/Dock/folders/useRenderingFolder.ts b/website/src/components/Dock/folders/useRenderingFolder.ts index 96f74aa7..b56a3a0b 100644 --- a/website/src/components/Dock/folders/useRenderingFolder.ts +++ b/website/src/components/Dock/folders/useRenderingFolder.ts @@ -1,9 +1,10 @@ /** * "Rendering" folder of the Dock GUI. * - * Mesh resolution + Interior fill toggles, plus a single "Texture mode" - * dropdown that collapses the old separate Solid-materials toggle and - * Texture-lighting selector into one row (disabled | baked | dynamic). + * Mesh resolution + interior fill, plus a single + * "Texture mode" dropdown that collapses the old separate Solid-materials + * toggle and Texture-lighting selector into one row (disabled | baked | + * dynamic). * * Texture quality is a slider with an Auto checkbox injected inside the * slider's `.widget`. Auto handling: the React-side `textureQuality` value @@ -18,17 +19,17 @@ import { useEffect, useRef } from "react"; import type { GUI } from "lil-gui"; import type { - MeshResolution, PolyTextureLightingMode, } from "@layoutit/polycss-react"; import { useFolder, useOption, useSlider, useToggle } from "../primitives"; +import type { WorkbenchMeshResolution } from "../../types"; export type TextureMode = "disabled" | PolyTextureLightingMode; export interface RenderingFolderInputs { - meshResolution: MeshResolution; - meshInteriorFill: boolean; + meshResolution: WorkbenchMeshResolution; + interiorFill: boolean; solidMaterials: boolean; textureLighting: PolyTextureLightingMode; /** Either "auto" or a number in [0.1, 1]. */ @@ -36,17 +37,18 @@ export interface RenderingFolderInputs { hasActiveAnimation: boolean; hasSpriteLeaves: boolean; onUpdateScene: (partial: { - meshResolution?: MeshResolution; - meshInteriorFill?: boolean; + meshResolution?: WorkbenchMeshResolution; + interiorFill?: boolean; solidMaterials?: boolean; textureLighting?: PolyTextureLightingMode; textureQuality?: "auto" | number; }) => void; } -const MESH_RESOLUTION_OPTIONS: Record = { +const MESH_RESOLUTION_OPTIONS: Record = { Lossless: "lossless", Lossy: "lossy", + Disabled: "disabled", }; const TEXTURE_MODE_OPTIONS: Record = { @@ -62,7 +64,7 @@ function textureModeFor(solidMaterials: boolean, textureLighting: PolyTextureLig export function useRenderingFolder(parent: GUI | null, inputs: RenderingFolderInputs): void { const { meshResolution, - meshInteriorFill, + interiorFill, solidMaterials, textureLighting, textureQuality, @@ -87,8 +89,8 @@ export function useRenderingFolder(parent: GUI | null, inputs: RenderingFolderIn (value) => onUpdateScene({ meshResolution: value }), ); - const meshInteriorFillCtrl = useToggle(folder, "Interior fill", meshInteriorFill, (value) => - onUpdateScene({ meshInteriorFill: value }), + const interiorFillCtrl = useToggle(folder, "Interior fill", interiorFill, (value) => + onUpdateScene({ interiorFill: value }), ); const textureMode = textureModeFor(solidMaterials, textureLighting); @@ -122,8 +124,12 @@ export function useRenderingFolder(parent: GUI | null, inputs: RenderingFolderIn useEffect(() => { meshResolutionCtrl?.setEnabled(!hasActiveAnimation); - meshInteriorFillCtrl?.setEnabled(!hasActiveAnimation); - }, [meshResolutionCtrl, meshInteriorFillCtrl, hasActiveAnimation]); + interiorFillCtrl?.setEnabled(!hasActiveAnimation); + }, [ + meshResolutionCtrl, + interiorFillCtrl, + hasActiveAnimation, + ]); useEffect(() => { textureModeCtrl?.setVisible(hasSpriteLeaves); diff --git a/website/src/components/Dock/primitives.tsx b/website/src/components/Dock/primitives.tsx index e04e23c5..324af181 100644 --- a/website/src/components/Dock/primitives.tsx +++ b/website/src/components/Dock/primitives.tsx @@ -204,15 +204,22 @@ export function useSlider( range: { min: number; max: number; step?: number }, value: number, onChange: (next: number) => void, + options?: { commit?: "change" | "finish" }, ): DockController | null { // Range captured at mount — changing it would require destroying and re- // adding the controller. None of our uses change range at runtime. const rangeRef = useRef(range); rangeRef.current = range; + const commitRef = useRef(options?.commit ?? "change"); + commitRef.current = options?.commit ?? "change"; return useControllerLifecycle(parent, label, value, onChange, (folder, proxy, cb) => { const r = rangeRef.current; const ctrl = folder.add(proxy, "value", r.min, r.max, r.step); - ctrl.onChange((v: number) => cb(v)); + if (commitRef.current === "finish") { + ctrl.onFinishChange((v: number) => cb(v)); + } else { + ctrl.onChange((v: number) => cb(v)); + } return ctrl; }); } diff --git a/website/src/components/DocsHeader.astro b/website/src/components/DocsHeader.astro index 0d9f541f..35d31fa0 100644 --- a/website/src/components/DocsHeader.astro +++ b/website/src/components/DocsHeader.astro @@ -30,7 +30,6 @@ const topLinks = [ active: pathname.startsWith('/api'), }, { href: '/gallery', label: 'Gallery', active: pathname.startsWith('/gallery') }, - { href: '/builder', label: 'Builder', active: isBuilder }, ]; --- diff --git a/website/src/components/GalleryWorkbench/GalleryWorkbench.tsx b/website/src/components/GalleryWorkbench/GalleryWorkbench.tsx index 9750695b..dc3fb10e 100644 --- a/website/src/components/GalleryWorkbench/GalleryWorkbench.tsx +++ b/website/src/components/GalleryWorkbench/GalleryWorkbench.tsx @@ -27,7 +27,12 @@ import { import { ModelsSidebar } from "../ModelsSidebar"; import { DropOverlay } from "../DropOverlay"; import { StatsOverlay } from "../StatsOverlay"; -import type { GizmoMode, SceneOptionsState, DomMetrics } from "../types"; +import { + activeMeshResolution, + type GizmoMode, + type SceneOptionsState, + type DomMetrics, +} from "../types"; import "./gallery-workbench.css"; import type { PresetModel, @@ -119,7 +124,7 @@ const DEFAULT_SCENE: SceneOptionsState = { matrixPrecision: "exact", borderShapePrecision: "exact", meshResolution: "lossy", - meshInteriorFill: false, + interiorFill: false, outlinePolygons: false, dragMode: "orbit", target: [0, 0, 0], @@ -433,10 +438,11 @@ export default function GalleryWorkbench() { [animationClips, selectedAnimation], ); const hasActiveAnimation = activeAnimation !== null; + const effectiveMeshResolution = activeMeshResolution(sceneOptions.meshResolution); const renderLoaded = useMemo(() => { - if (!loaded || !activeAnimation || sceneOptions.meshResolution !== "lossy") return loaded; + if (!loaded || !activeAnimation || effectiveMeshResolution !== "lossy") return loaded; const optimized = optimizeAnimatedMeshPolygons(loaded.parseResult, { - meshResolution: sceneOptions.meshResolution, + meshResolution: effectiveMeshResolution, }); if (optimized === loaded.parseResult) return loaded; return { @@ -446,7 +452,7 @@ export default function GalleryWorkbench() { polygons: optimized.polygons, animation: optimized.animation, }; - }, [loaded, activeAnimation, sceneOptions.meshResolution]); + }, [loaded, activeAnimation, effectiveMeshResolution]); const animation = useAnimationFrames({ loaded: renderLoaded, @@ -457,13 +463,19 @@ export default function GalleryWorkbench() { reactMeshRef: meshRef, }); - const { modelPolygons, interiorFillPolygons, scenePolygons, helperScale, helperTarget } = useScenePolygons({ + const { + modelPolygons, + interiorShellPolygons, + scenePolygons, + helperScale, + helperTarget, + } = useScenePolygons({ loaded: renderLoaded, hasActiveAnimation, meshResolution: sceneOptions.meshResolution, renderer: sceneOptions.renderer, reactAnimatedPolygons: animation.reactAnimatedPolygons, - meshInteriorFill: sceneOptions.meshInteriorFill, + interiorFill: sceneOptions.interiorFill, }); const renderModelPolygons = useMemo( () => sceneOptions.solidMaterials @@ -471,17 +483,9 @@ export default function GalleryWorkbench() { : modelPolygons, [modelPolygons, sceneOptions.solidMaterials, parserOptions.defaultColor], ); - const renderInteriorFillPolygons = useMemo( - () => sceneOptions.solidMaterials - ? withSolidMaterials(interiorFillPolygons, parserOptions.defaultColor) - : interiorFillPolygons, - [interiorFillPolygons, sceneOptions.solidMaterials, parserOptions.defaultColor], - ); const renderPolygons = useMemo( - () => renderInteriorFillPolygons.length > 0 - ? [...renderModelPolygons, ...renderInteriorFillPolygons] - : renderModelPolygons, - [renderModelPolygons, renderInteriorFillPolygons], + () => renderModelPolygons, + [renderModelPolygons], ); const hasSpriteLeaves = useMemo( () => metrics.sprites > 0 || scenePolygons.some(polygonHasTextureData), @@ -611,6 +615,7 @@ export default function GalleryWorkbench() { sceneOptions.textureLighting, sceneOptions.textureQuality, sceneOptions.solidMaterials ? "solid-materials" : "authored-materials", + sceneOptions.interiorFill ? "interior-fill" : "no-interior-fill", sceneOptions.autoCenter, sceneOptions.perspective === false ? "none" : sceneOptions.perspective, loaded?.label ?? "none", @@ -622,6 +627,7 @@ export default function GalleryWorkbench() { sceneOptions.textureLighting, sceneOptions.textureQuality, sceneOptions.solidMaterials, + sceneOptions.interiorFill, sceneOptions.autoCenter, sceneOptions.perspective, loaded?.label, @@ -737,8 +743,8 @@ export default function GalleryWorkbench() { b, +.dn-model-mesh.is-mesh-hidden > i, +.dn-model-mesh.is-mesh-hidden > s, +.dn-model-mesh.is-mesh-hidden > u, +.dn-model-mesh.is-mesh-hidden > q, +.dn-model-mesh.is-mesh-hidden > .polycss-bucket { + display: none !important; +} + .polycss-mesh i, .polycss-mesh b, .polycss-mesh s, diff --git a/website/src/components/GalleryWorkbench/hooks/useScenePolygons.ts b/website/src/components/GalleryWorkbench/hooks/useScenePolygons.ts index 38b512e0..c3a3d8ee 100644 --- a/website/src/components/GalleryWorkbench/hooks/useScenePolygons.ts +++ b/website/src/components/GalleryWorkbench/hooks/useScenePolygons.ts @@ -1,21 +1,22 @@ import { useMemo } from "react"; import { optimizeMeshPolygons } from "@layoutit/polycss-react"; import type { Polygon } from "@layoutit/polycss-react"; +import { interiorShellPolygons as buildInteriorShellPolygons } from "../../helpers/interiorShell"; +import { activeMeshResolution, type WorkbenchMeshResolution } from "../../types"; import type { LoadedModel } from "../types"; -import { interiorFillPolygons as buildInteriorFillPolygons } from "../helpers/interiorFill"; export interface UseScenePolygonsOptions { loaded: LoadedModel | null; hasActiveAnimation: boolean; - meshResolution: "lossy" | "lossless"; + meshResolution: WorkbenchMeshResolution; renderer: "react" | "vanilla"; reactAnimatedPolygons: Polygon[] | null; - meshInteriorFill: boolean; + interiorFill: boolean; } export interface UseScenePolygonsResult { modelPolygons: Polygon[]; - interiorFillPolygons: Polygon[]; + interiorShellPolygons: Polygon[]; scenePolygons: Polygon[]; helperBbox: { minX: number; minY: number; minZ: number; maxX: number; maxY: number; maxZ: number } | null; helperScale: number; @@ -28,8 +29,9 @@ export function useScenePolygons({ meshResolution, renderer, reactAnimatedPolygons, - meshInteriorFill, + interiorFill, }: UseScenePolygonsOptions): UseScenePolygonsResult { + const effectiveMeshResolution = activeMeshResolution(meshResolution); const modelPolygons = useMemo(() => { if (!loaded) return []; if (hasActiveAnimation) { @@ -39,36 +41,30 @@ export function useScenePolygons({ } if (loaded.parseResult.voxelSource) return loaded.rawPolygons; return optimizeMeshPolygons(loaded.rawPolygons, { - meshResolution, + meshResolution: effectiveMeshResolution, }); }, [ loaded, hasActiveAnimation, - meshResolution, + effectiveMeshResolution, renderer, reactAnimatedPolygons, ]); - const interiorFillPolygons = useMemo(() => { - if (hasActiveAnimation || !meshInteriorFill) { - return []; - } - return buildInteriorFillPolygons(modelPolygons); - }, [ - hasActiveAnimation, - modelPolygons, - meshInteriorFill, - ]); - - const scenePolygons = useMemo( - () => interiorFillPolygons.length > 0 - ? [...modelPolygons, ...interiorFillPolygons] - : modelPolygons, - [modelPolygons, interiorFillPolygons], + const interiorShellPolygons = useMemo( + () => interiorFill && !hasActiveAnimation && !loaded?.parseResult.voxelSource + ? buildInteriorShellPolygons(modelPolygons) + : [], + [ + modelPolygons, + interiorFill, + hasActiveAnimation, + loaded, + ], ); const helperBbox = useMemo(() => { - const polygons = scenePolygons; + const polygons = modelPolygons; if (polygons.length === 0) return null; let minX = Infinity, minY = Infinity, minZ = Infinity; let maxX = -Infinity, maxY = -Infinity, maxZ = -Infinity; @@ -80,7 +76,7 @@ export function useScenePolygons({ } } return { minX, minY, minZ, maxX, maxY, maxZ }; - }, [scenePolygons]); + }, [modelPolygons]); const helperScale = useMemo(() => { if (!helperBbox) return 30; @@ -101,5 +97,12 @@ export function useScenePolygons({ ]; }, [helperBbox]); - return { modelPolygons, interiorFillPolygons, scenePolygons, helperBbox, helperScale, helperTarget }; + return { + modelPolygons, + interiorShellPolygons, + scenePolygons: modelPolygons, + helperBbox, + helperScale, + helperTarget, + }; } diff --git a/website/src/components/ReactScene/ReactScene.tsx b/website/src/components/ReactScene/ReactScene.tsx index 13ea2ac5..2f774a0a 100644 --- a/website/src/components/ReactScene/ReactScene.tsx +++ b/website/src/components/ReactScene/ReactScene.tsx @@ -24,7 +24,7 @@ import { polygonFacesCamera, type TextureQuality, } from "@layoutit/polycss"; -import type { GizmoMode, SceneOptionsState } from "../types"; +import { meshResolutionShowsMesh, type GizmoMode, type SceneOptionsState } from "../types"; function canCullCameraBackfaces(polygons: Polygon[]): boolean { return isVoxelCameraCullableNormalGroups(cameraCullNormalGroupsFromPolygons(polygons)); @@ -43,7 +43,7 @@ export interface ReactSceneProps { rendererDebugKey: string; sceneOptions: SceneOptionsState; scenePolygons: Polygon[]; - interiorFillPolygons: Polygon[]; + interiorShellPolygons: Polygon[]; directionalLight: PolyDirectionalLight; ambientLight: PolyAmbientLight; textureQuality: TextureQuality; @@ -69,7 +69,7 @@ export function ReactScene({ rendererDebugKey, sceneOptions, scenePolygons, - interiorFillPolygons, + interiorShellPolygons, directionalLight, ambientLight, textureQuality, @@ -94,39 +94,29 @@ export function ReactScene({ const camProps = sceneOptions.perspective === false ? { zoom: sceneOptions.zoom, rotX: sceneOptions.rotX, rotY: sceneOptions.rotY, target: sceneOptions.target } : { zoom: sceneOptions.zoom, rotX: sceneOptions.rotX, rotY: sceneOptions.rotY, target: sceneOptions.target, perspective: sceneOptions.perspective }; - const centerPolygons = useMemo( - () => interiorFillPolygons.length > 0 - ? [...scenePolygons, ...interiorFillPolygons] - : scenePolygons, - [scenePolygons, interiorFillPolygons], - ); + const centerPolygons = scenePolygons; const effectiveMeshRotation = sceneOptions.selection ? meshRotation : undefined; const canCullScenePolygons = useMemo( () => canCullCameraBackfaces(scenePolygons), [scenePolygons], ); - const canCullInteriorFillPolygons = useMemo( - () => canCullCameraBackfaces(interiorFillPolygons), - [interiorFillPolygons], - ); const visibleScenePolygons = useMemo( () => canCullScenePolygons ? cullCameraBackfaces(scenePolygons, sceneOptions.rotX, sceneOptions.rotY, effectiveMeshRotation) : scenePolygons, [scenePolygons, canCullScenePolygons, sceneOptions.rotX, sceneOptions.rotY, effectiveMeshRotation], ); - const visibleInteriorFillPolygons = useMemo( - () => canCullInteriorFillPolygons - ? cullCameraBackfaces(interiorFillPolygons, sceneOptions.rotX, sceneOptions.rotY, effectiveMeshRotation) - : interiorFillPolygons, - [interiorFillPolygons, canCullInteriorFillPolygons, sceneOptions.rotX, sceneOptions.rotY, effectiveMeshRotation], - ); - const fillMesh = visibleInteriorFillPolygons.length > 0 ? ( + const shellMesh = interiorShellPolygons.length > 0 ? ( ) : null; + const modelClassName = [ + "dn-model-mesh", + !meshResolutionShowsMesh(sceneOptions.meshResolution) ? "is-mesh-hidden" : "", + sceneOptions.hoverEffects && hoveredMeshId === (loaded?.label ?? "model") ? "is-hovered" : "", + ].filter(Boolean).join(" "); return ( {sceneOptions.dragMode === "pan" ? ( @@ -164,11 +154,7 @@ export function ReactScene({ polygons={visibleScenePolygons} position={meshPosition} rotation={meshRotation} - className={ - sceneOptions.hoverEffects && hoveredMeshId === (loaded?.label ?? "model") - ? "dn-model-mesh is-hovered" - : "dn-model-mesh" - } + className={modelClassName} style={sceneOptions.hoverEffects ? { cursor: "pointer" } : undefined} onPointerOver={ sceneOptions.hoverEffects @@ -179,7 +165,7 @@ export function ReactScene({ sceneOptions.hoverEffects ? () => setHoveredMeshId(null) : undefined } > - {fillMesh} + {shellMesh} ) : null} @@ -187,9 +173,9 @@ export function ReactScene({ - {fillMesh} + {shellMesh} ) : null} {sceneOptions.selection && selectedMeshes.length > 0 && ( diff --git a/website/src/components/VanillaScene/VanillaScene.tsx b/website/src/components/VanillaScene/VanillaScene.tsx index 5f76d427..b81bf183 100644 --- a/website/src/components/VanillaScene/VanillaScene.tsx +++ b/website/src/components/VanillaScene/VanillaScene.tsx @@ -26,7 +26,7 @@ import type { PolyTransformControlsHandle, Vec3, } from "@layoutit/polycss"; -import type { GizmoMode, SceneOptionsState } from "../types"; +import { meshResolutionShowsMesh, type GizmoMode, type SceneOptionsState } from "../types"; export type { GizmoMode, SceneOptionsState }; @@ -180,8 +180,8 @@ function lightHelperPosition( export interface VanillaSceneProps { polygons: Polygon[]; + interiorShellPolygons: Polygon[]; parseResult?: ParseResult; - interiorFillPolygons: Polygon[]; options: SceneOptionsState; directionalLight: PolyDirectionalLight; ambientLight: PolyAmbientLight; @@ -208,8 +208,8 @@ export interface VanillaSceneProps { export function VanillaScene({ polygons, + interiorShellPolygons, parseResult, - interiorFillPolygons, options, directionalLight, ambientLight, @@ -238,7 +238,7 @@ export function VanillaScene({ const cameraRef = useRef(null); const controlsRef = useRef(null); const meshHandleRef = useRef(null); - const interiorFillHandleRef = useRef(null); + const interiorShellHandleRef = useRef(null); const axesHandleRef = useRef(null); const lightHandleRef = useRef(null); const groundHandleRef = useRef(null); @@ -265,12 +265,12 @@ export function VanillaScene({ stableDom: boolean; } | null>(null); - const mountInteriorFillInsideModel = useCallback(() => { + const mountChildMeshInsideModel = useCallback((child: VanillaPolyMeshHandle | null) => { const modelEl = meshHandleRef.current?.element; - const fillEl = interiorFillHandleRef.current?.element; - if (!modelEl || !fillEl) return; - if (fillEl.parentElement !== modelEl || fillEl.nextSibling !== null) { - modelEl.appendChild(fillEl); + const childEl = child?.element; + if (!modelEl || !childEl) return; + if (childEl.parentElement !== modelEl || childEl.nextSibling !== null) { + modelEl.appendChild(childEl); } }, []); @@ -338,6 +338,7 @@ export function VanillaScene({ stableDom: stableDomForMesh, }; meshHandleRef.current.element.classList.add("dn-model-mesh"); + meshHandleRef.current.element.classList.toggle("is-mesh-hidden", !meshResolutionShowsMesh(options.meshResolution)); onMeshHandleChangeRef.current?.(meshHandleRef.current); return () => { // Tear controls down BEFORE destroying the scene — otherwise the @@ -348,7 +349,7 @@ export function VanillaScene({ axesHandleRef.current = null; lightHandleRef.current = null; groundHandleRef.current = null; - interiorFillHandleRef.current = null; + interiorShellHandleRef.current = null; mountedModelRef.current = null; meshHandleRef.current = null; sceneRef.current = null; @@ -366,10 +367,15 @@ export function VanillaScene({ parseResult, ]); + useEffect(() => { + meshHandleRef.current?.element.classList.toggle("is-mesh-hidden", !meshResolutionShowsMesh(options.meshResolution)); + }, [options.meshResolution]); + // Effect 1.5 — replace geometry on the existing mesh. This is the path - // used by animated GLB playback. Interior fill remains a non-shadow mesh, - // but its wrapper is mounted inside the model mesh wrapper so it inherits - // the same mesh transform. + // used by animated GLB playback; seam bleed is already baked into the + // polygon list before the mesh reaches this renderer. The interior shell + // is a separate non-shadow mesh mounted inside the model wrapper so it + // inherits the same mesh transform without changing auto-centering. useEffect(() => { const handle = meshHandleRef.current; const scene = sceneRef.current; @@ -394,21 +400,21 @@ export function VanillaScene({ }; } - let fillHandle = interiorFillHandleRef.current; - if (interiorFillPolygons.length === 0) { - fillHandle?.dispose(); - interiorFillHandleRef.current = null; - } else if (fillHandle) { - fillHandle.setPolygons(interiorFillPolygons, { + let shellHandle = interiorShellHandleRef.current; + if (interiorShellPolygons.length === 0) { + shellHandle?.dispose(); + interiorShellHandleRef.current = null; + } else if (shellHandle) { + shellHandle.setPolygons(interiorShellPolygons, { merge: false, stableDom: stableDomForMesh, recomputeAutoCenter: false, }); - mountInteriorFillInsideModel(); + mountChildMeshInsideModel(shellHandle); } else { - fillHandle = scene.add( + shellHandle = scene.add( { - polygons: interiorFillPolygons, + polygons: interiorShellPolygons, objectUrls: [], warnings: [], dispose: () => {}, @@ -420,9 +426,9 @@ export function VanillaScene({ castShadow: false, }, ); - fillHandle.element.classList.add("dn-interior-fill-mesh"); - interiorFillHandleRef.current = fillHandle; - mountInteriorFillInsideModel(); + shellHandle.element.classList.add("dn-interior-shell-mesh"); + interiorShellHandleRef.current = shellHandle; + mountChildMeshInsideModel(shellHandle); } requestAnimationFrame(() => @@ -430,10 +436,10 @@ export function VanillaScene({ ); }, [ polygons, - interiorFillPolygons, + interiorShellPolygons, mergePolygonsForMesh, stableDomForMesh, - mountInteriorFillInsideModel, + mountChildMeshInsideModel, ]); // Effect 1.6 — live-toggle castShadow without rebuilding the scene. @@ -700,7 +706,7 @@ export function VanillaScene({ ambientLight, ]); - // Effect 2b — render strategy toggles. Kept separate from Effect 2 because + // Effect 2b — render strategy controls. Kept separate from Effect 2 because // these options trigger a full mesh re-render in // createPolyScene; folding it into the camera/lighting effect would // re-render on every rotation/zoom tick. diff --git a/website/src/components/GalleryWorkbench/helpers/interiorFill.ts b/website/src/components/helpers/interiorShell.ts similarity index 57% rename from website/src/components/GalleryWorkbench/helpers/interiorFill.ts rename to website/src/components/helpers/interiorShell.ts index 3d5ca6f6..a3adea9f 100644 --- a/website/src/components/GalleryWorkbench/helpers/interiorFill.ts +++ b/website/src/components/helpers/interiorShell.ts @@ -1,5 +1,4 @@ -import type { Polygon, Vec3 } from "@layoutit/polycss"; -import { solidColorToHex } from "./debugPrecision"; +import { parsePureColor, type Polygon, type Vec3 } from "@layoutit/polycss-react"; type AxisIndex = 0 | 1 | 2; type Point2 = [number, number]; @@ -9,7 +8,7 @@ interface Segment2 { b: Point2; } -interface InteriorFillInterval { +interface InteriorShellInterval { row: number; y: number; x0: number; @@ -17,7 +16,7 @@ interface InteriorFillInterval { length: number; } -interface InteriorFillSlice { +interface InteriorShellSlice { fixedAxis: AxisIndex; axisA: AxisIndex; axisB: AxisIndex; @@ -26,12 +25,17 @@ interface InteriorFillSlice { area: number; } -interface InteriorFillComponent { +interface InteriorShellComponent { points: Point2[]; area: number; } -export interface PolygonBounds { +interface DominantSolidSurface { + color: string; + polygons: Polygon[]; +} + +interface PolygonBounds { min: Vec3; max: Vec3; center: Vec3; @@ -40,30 +44,43 @@ export interface PolygonBounds { maxSpan: number; } -const INTERIOR_FILL_MIN_MAX_SPAN = 8; -const INTERIOR_FILL_MIN_DIAGONAL = 10; -const INTERIOR_FILL_SOLID_COVERAGE_MIN = 0.2; -const INTERIOR_FILL_MIN_PLANE_AREA_RATIO = 0.12; -const INTERIOR_FILL_MIN_SLICE_AREA_RATIO = 0.008; -const INTERIOR_FILL_SCAN_ROWS = 64; -const INTERIOR_FILL_SLICE_POSITIONS = [0.28, 0.5, 0.72] as const; -const INTERIOR_FILL_INTERVAL_MIN_LENGTH_RATIO = 0.18; -const INTERIOR_FILL_INTERVAL_OVERLAP_RATIO = 0.12; -const INTERIOR_FILL_MIN_INTERVAL_ROWS = 4; -const INTERIOR_FILL_MAX_COMPONENTS_PER_SLICE = 2; -const INTERIOR_FILL_INSET_RATIO = 0.025; -const INTERIOR_FILL_OVAL_SCALE = 0.82; -const INTERIOR_FILL_OVAL_MARGIN_RATIO = 0.025; -const INTERIOR_FILL_OVAL_SAMPLES = 9; -const INTERIOR_FILL_MAX_SLICES = 9; +export interface InteriorShellOptions { + maxSlices?: number; + spread?: number; + inset?: number; + maxLoopPoints?: number; +} + +export const DEFAULT_INTERIOR_SHELL_OPTIONS = { + maxSlices: 4, + spread: 0.12, + inset: 0.12, + maxLoopPoints: 32, +} satisfies Required; + +const MIN_MAX_SPAN = 8; +const MIN_DIAGONAL = 10; +const MIN_SOLID_COVERAGE = 0.2; +const MIN_PLANE_AREA_RATIO = 0.12; +const MIN_SLICE_AREA_RATIO = 0.01; +const SCAN_ROWS = 96; +const INTERVAL_MIN_LENGTH_RATIO = 0.18; +const INTERVAL_OVERLAP_RATIO = 0.12; +const INTERVAL_SUPPORT_RADIUS_ROWS = 2; +const MIN_INTERVAL_ROWS = 4; +const MAX_COMPONENTS_PER_SLICE = 1; const EPS = 1e-6; -function clamp(value: number, min: number, max: number): number { - if (!Number.isFinite(value)) return min; - return Math.min(Math.max(value, min), max); +function solidColorToHex(value: string): string | null { + const parsed = parsePureColor(value); + if (!parsed || parsed.alpha < 1) return null; + const hex = parsed.rgb + .map((channel) => Math.max(0, Math.min(255, Math.round(channel))).toString(16).padStart(2, "0")) + .join(""); + return `#${hex}`; } -export function polygonArea(polygon: Polygon): number { +function polygonArea(polygon: Pick): number { const [origin] = polygon.vertices; if (!origin || polygon.vertices.length < 3) return 0; let area = 0; @@ -85,7 +102,7 @@ export function polygonArea(polygon: Polygon): number { return area; } -export function polygonBounds(polygons: Polygon[]): PolygonBounds | null { +function polygonBounds(polygons: Polygon[]): PolygonBounds | null { let minX = Infinity; let minY = Infinity; let minZ = Infinity; @@ -120,10 +137,11 @@ export function polygonBounds(polygons: Polygon[]): PolygonBounds | null { }; } -export function dominantSolidColor(polygons: Polygon[]): string | null { +function dominantSolidSurface(polygons: Polygon[]): DominantSolidSurface | null { let totalWeight = 0; let solidWeight = 0; const weights = new Map(); + const polygonsByColor = new Map(); for (const polygon of polygons) { const weight = Math.max(polygonArea(polygon), 1e-4); @@ -134,11 +152,12 @@ export function dominantSolidColor(polygons: Polygon[]): string | null { if (!color) continue; solidWeight += weight; weights.set(color, (weights.get(color) ?? 0) + weight); + const sameColor = polygonsByColor.get(color); + if (sameColor) sameColor.push(polygon); + else polygonsByColor.set(color, [polygon]); } - if (totalWeight <= 0 || solidWeight / totalWeight < INTERIOR_FILL_SOLID_COVERAGE_MIN) { - return null; - } + if (totalWeight <= 0 || solidWeight / totalWeight < MIN_SOLID_COVERAGE) return null; let bestColor: string | null = null; let bestWeight = 0; @@ -148,88 +167,100 @@ export function dominantSolidColor(polygons: Polygon[]): string | null { bestWeight = weight; } } - return bestColor; + return bestColor + ? { color: bestColor, polygons: polygonsByColor.get(bestColor) ?? [] } + : null; } -export function interiorFillPolygons( - polygons: Polygon[], -): Polygon[] { - const bounds = polygonBounds(polygons); - if (!bounds) return []; - if ( - bounds.maxSpan < INTERIOR_FILL_MIN_MAX_SPAN || - bounds.diagonal < INTERIOR_FILL_MIN_DIAGONAL - ) { - return []; - } +export function interiorShellPolygons(polygons: Polygon[], options: InteriorShellOptions = {}): Polygon[] { + const surface = dominantSolidSurface(polygons); + if (!surface || surface.polygons.length === 0) return []; - const color = dominantSolidColor(polygons); - if (!color) return []; + const bounds = polygonBounds(surface.polygons); + if (!bounds) return []; + if (bounds.maxSpan < MIN_MAX_SPAN || bounds.diagonal < MIN_DIAGONAL) return []; - const planes = candidatePlanes(bounds); - const slices: InteriorFillSlice[] = []; - for (const plane of planes) { + const resolved = resolveInteriorShellOptions(options); + const slices: InteriorShellSlice[] = []; + for (const plane of candidatePlanes(bounds)) { const area = bounds.span[plane.axisA] * bounds.span[plane.axisB]; - for (const position of INTERIOR_FILL_SLICE_POSITIONS) { + for (const position of slicePositions(resolved.spread)) { const planeValue = bounds.min[plane.fixedAxis] + bounds.span[plane.fixedAxis] * position; - slices.push(...interiorFillSlicesAtPlane( - polygons, + slices.push(...interiorShellSlicesAtPlane( + surface.polygons, bounds, plane.fixedAxis, plane.axisA, plane.axisB, planeValue, area, + resolved.inset, )); } } - slices.sort((a, b) => b.area - a.area); - return slices - .slice(0, INTERIOR_FILL_MAX_SLICES) - .flatMap((slice) => interiorFillPolygonsFromSlice(bounds, slice, color)); + return selectInteriorShellSlices(slices, resolved.maxSlices) + .flatMap((slice) => polygonFromSlice(bounds, slice, surface.color, resolved.maxLoopPoints)); } -function candidatePlanes(bounds: PolygonBounds): Array<{ fixedAxis: AxisIndex; axisA: AxisIndex; axisB: AxisIndex }> { +function resolveInteriorShellOptions(options: InteriorShellOptions): Required { + return { + maxSlices: Math.max(1, Math.min(8, Math.round(options.maxSlices ?? DEFAULT_INTERIOR_SHELL_OPTIONS.maxSlices))), + spread: Math.max(0, Math.min(0.34, options.spread ?? DEFAULT_INTERIOR_SHELL_OPTIONS.spread)), + inset: Math.max(0, Math.min(0.18, options.inset ?? DEFAULT_INTERIOR_SHELL_OPTIONS.inset)), + maxLoopPoints: Math.max(4, Math.min(64, Math.round(options.maxLoopPoints ?? DEFAULT_INTERIOR_SHELL_OPTIONS.maxLoopPoints))), + }; +} + +function slicePositions(spread: number): number[] { + if (spread <= EPS) return [0.5]; + return [0.5, 0.5 - spread, 0.5 + spread]; +} + +function candidatePlanes(bounds: PolygonBounds): Array<{ fixedAxis: AxisIndex; axisA: AxisIndex; axisB: AxisIndex; area: number }> { const candidates = [ { fixedAxis: 2 as AxisIndex, axisA: 0 as AxisIndex, axisB: 1 as AxisIndex, area: bounds.span[0] * bounds.span[1] }, { fixedAxis: 1 as AxisIndex, axisA: 0 as AxisIndex, axisB: 2 as AxisIndex, area: bounds.span[0] * bounds.span[2] }, { fixedAxis: 0 as AxisIndex, axisA: 1 as AxisIndex, axisB: 2 as AxisIndex, area: bounds.span[1] * bounds.span[2] }, ].sort((a, b) => b.area - a.area); - const minArea = (candidates[0]?.area ?? 0) * INTERIOR_FILL_MIN_PLANE_AREA_RATIO; + const minArea = (candidates[0]?.area ?? 0) * MIN_PLANE_AREA_RATIO; return candidates.filter((candidate) => candidate.area > minArea); } -function interiorFillPolygonsFromSlice( - bounds: PolygonBounds, - slice: InteriorFillSlice, - color: string, -): Polygon[] { - const rect = interiorFillOval2D(slice.points); - return rect ? doubleSidedSlicePolygon(bounds, slice, rect, color) : []; +function selectInteriorShellSlices(slices: InteriorShellSlice[], maxSlices: number): InteriorShellSlice[] { + const selected: InteriorShellSlice[] = []; + const sorted = [...slices].sort((a, b) => b.area - a.area); + for (const slice of sorted) { + if (selected.length >= maxSlices) break; + const axisCount = selected.filter((current) => current.fixedAxis === slice.fixedAxis).length; + if (axisCount >= 2) continue; + selected.push(slice); + } + return selected; } -function doubleSidedSlicePolygon( +function polygonFromSlice( bounds: PolygonBounds, - slice: InteriorFillSlice, - points: Point2[], + slice: InteriorShellSlice, color: string, -): [Polygon, Polygon] { - const point = ([a, b]: Point2): Vec3 => { + maxLoopPoints: number, +): Polygon[] { + const points = simplifyLoop2D(slice.points, maxLoopPoints); + if (points.length < 3) return []; + const vertices = points.map(([a, b]): Vec3 => { const vertex = [...bounds.center] as Vec3; vertex[slice.fixedAxis] = slice.planeValue; vertex[slice.axisA] = a; vertex[slice.axisB] = b; return vertex; - }; - const vertices = points.map(point); + }); return [ { vertices, color }, { vertices: [...vertices].reverse(), color }, ]; } -function interiorFillSlicesAtPlane( +function interiorShellSlicesAtPlane( polygons: Polygon[], bounds: PolygonBounds, fixedAxis: AxisIndex, @@ -237,7 +268,8 @@ function interiorFillSlicesAtPlane( axisB: AxisIndex, planeValue: number, candidateArea: number, -): InteriorFillSlice[] { + inset: number, +): InteriorShellSlice[] { const tolerance = Math.max(bounds.diagonal * 1e-5, 1e-4); const segments: Segment2[] = []; for (const polygon of polygons) { @@ -246,17 +278,17 @@ function interiorFillSlicesAtPlane( } if (segments.length < 3) return []; - const primary = scanlineSliceComponents(segments, false, candidateArea, tolerance); - const secondary = scanlineSliceComponents(segments, true, candidateArea, tolerance); + const primary = scanlineSliceComponents(segments, false, candidateArea, tolerance, inset); + const secondary = scanlineSliceComponents(segments, true, candidateArea, tolerance, inset); const components = totalComponentArea(secondary) > totalComponentArea(primary) ? secondary : primary; - return components.map((component): InteriorFillSlice => ({ + return components.map((component): InteriorShellSlice => ({ fixedAxis, axisA, axisB, planeValue, points: component.points, area: component.area, - })).filter((slice) => slice.area >= candidateArea * INTERIOR_FILL_MIN_SLICE_AREA_RATIO); + })).filter((slice) => slice.area >= candidateArea * MIN_SLICE_AREA_RATIO); } function slicePolygonAtAxis( @@ -269,9 +301,7 @@ function slicePolygonAtAxis( ): Segment2 | null { const vertices = polygon.vertices; if (vertices.length < 3) return null; - if (vertices.every((vertex) => Math.abs(vertex[fixedAxis] - planeValue) <= tolerance)) { - return null; - } + if (vertices.every((vertex) => Math.abs(vertex[fixedAxis] - planeValue) <= tolerance)) return null; const hits: Point2[] = []; for (let i = 0; i < vertices.length; i += 1) { @@ -319,163 +349,101 @@ function scanlineSliceComponents( swapAxes: boolean, candidateArea: number, tolerance: number, -): InteriorFillComponent[] { + inset: number, +): InteriorShellComponent[] { const oriented = segments.map((segment): Segment2 => ({ a: orientPoint2D(segment.a, swapAxes), b: orientPoint2D(segment.b, swapAxes), })); const intervals = scanlineIntervals(oriented, candidateArea, tolerance); - if (intervals.length < INTERIOR_FILL_MIN_INTERVAL_ROWS) return []; + if (intervals.length < MIN_INTERVAL_ROWS) return []; const components = intervalComponents(intervals) - .filter((component) => component.length >= INTERIOR_FILL_MIN_INTERVAL_ROWS) - .slice(0, INTERIOR_FILL_MAX_COMPONENTS_PER_SLICE); + .filter((component) => component.length >= MIN_INTERVAL_ROWS) + .slice(0, MAX_COMPONENTS_PER_SLICE); return components.flatMap((component) => { - const loop = loopFromIntervals(component); + const safeComponent = insetIntervalComponent(component, inset, candidateArea, tolerance); + if (safeComponent.length < MIN_INTERVAL_ROWS) return []; + const loop = loopFromIntervals(safeComponent); const area = Math.abs(loopArea2D(loop)); - if (loop.length < 3 || area < candidateArea * INTERIOR_FILL_MIN_SLICE_AREA_RATIO) { - return []; - } - const points = scaleLoopTowardCentroid2D(loop, INTERIOR_FILL_INSET_RATIO) + if (loop.length < 3 || area < candidateArea * MIN_SLICE_AREA_RATIO) return []; + const points = scaleLoopTowardCentroid2D(loop, inset * 0.75) .map((point) => orientPoint2D(point, swapAxes)); return [{ points, area: Math.abs(loopArea2D(points)) }]; }); } -function interiorFillOval2D(points: Point2[]): Point2[] | null { - const normal = interiorFillOvalRect2D(points); - const transposed = interiorFillOvalRect2D(points.map(transposePoint2D))?.map(transposePoint2D) ?? null; - if (!normal) return transposed; - if (!transposed) return normal; - return Math.abs(loopArea2D(transposed)) > Math.abs(loopArea2D(normal)) ? transposed : normal; -} - -function interiorFillOvalRect2D(points: Point2[]): Point2[] | null { - const bounds = bounds2D(points); - if (!bounds) return null; - const center = loopCentroid2D(points); - const centerY = clamp(center[1], bounds.minY + EPS, bounds.maxY - EPS); - const centerInterval = widestIntervalAtY2D(points, centerY); - if (!centerInterval) return null; - const centerX = clamp(center[0], centerInterval[0], centerInterval[1]); - - for (const scale of [INTERIOR_FILL_OVAL_SCALE, 0.68, 0.54, 0.4]) { - const halfHeight = bounds.height * scale * 0.5; - const halfWidth = bounds.width * scale * 0.5; - const y0 = clamp(centerY - halfHeight, bounds.minY, bounds.maxY); - const y1 = clamp(centerY + halfHeight, bounds.minY, bounds.maxY); - if (y1 - y0 <= EPS) continue; - - let x0 = centerX - halfWidth; - let x1 = centerX + halfWidth; - for (let i = 0; i < INTERIOR_FILL_OVAL_SAMPLES; i += 1) { - const t = INTERIOR_FILL_OVAL_SAMPLES === 1 ? 0.5 : i / (INTERIOR_FILL_OVAL_SAMPLES - 1); - const y = y0 + (y1 - y0) * t; - const interval = overlappingIntervalAtY2D(points, y, [x0, x1]) ?? widestIntervalAtY2D(points, y); - if (!interval) { - x1 = x0; - break; - } - x0 = Math.max(x0, interval[0]); - x1 = Math.min(x1, interval[1]); - } - if (x1 - x0 > EPS) { - const margin = Math.min(x1 - x0, y1 - y0) * INTERIOR_FILL_OVAL_MARGIN_RATIO; - const insetX = Math.min(margin, (x1 - x0) * 0.25); - const insetY = Math.min(margin, (y1 - y0) * 0.25); - if (x1 - x0 - insetX * 2 <= EPS || y1 - y0 - insetY * 2 <= EPS) continue; - return [ - [x0 + insetX, y0 + insetY], - [x1 - insetX, y0 + insetY], - [x1 - insetX, y1 - insetY], - [x0 + insetX, y1 - insetY], - ]; - } - } - return null; -} +function insetIntervalComponent( + component: InteriorShellInterval[], + inset: number, + candidateArea: number, + tolerance: number, +): InteriorShellInterval[] { + const rows = [...component].sort((a, b) => a.row - b.row || b.length - a.length); + if (rows.length < MIN_INTERVAL_ROWS) return []; + if (inset <= EPS) return rows; + + const trim = Math.min( + Math.floor((rows.length - MIN_INTERVAL_ROWS) / 2), + Math.max(1, Math.ceil(rows.length * inset * 0.35)), + ); + const trimmed = rows.slice(trim, rows.length - trim); + const absoluteMargin = Math.max(Math.sqrt(candidateArea) * 0.004, tolerance * 3); + + const insetRows = trimmed.flatMap((interval): InteriorShellInterval[] => { + const margin = Math.max(interval.length * inset * 0.22, absoluteMargin); + const x0 = interval.x0 + margin; + const x1 = interval.x1 - margin; + if (x1 - x0 <= tolerance * 4) return []; + return [{ + ...interval, + x0, + x1, + length: x1 - x0, + }]; + }); -function widestIntervalAtY2D(points: Point2[], y: number): [number, number] | null { - const intervals = loopIntervalsAtY2D(points, y); - let best: [number, number] | null = null; - for (const interval of intervals) { - if (!best || interval[1] - interval[0] > best[1] - best[0]) best = interval; - } - return best; + return supportedIntervalComponent(insetRows, tolerance); } -function overlappingIntervalAtY2D( - points: Point2[], - y: number, - target: [number, number], -): [number, number] | null { - const intervals = loopIntervalsAtY2D(points, y); - let best: [number, number] | null = null; - let bestOverlap = 0; - for (const interval of intervals) { - const overlap = Math.min(interval[1], target[1]) - Math.max(interval[0], target[0]); - if (overlap > bestOverlap) { - best = interval; - bestOverlap = overlap; +function supportedIntervalComponent( + component: InteriorShellInterval[], + tolerance: number, +): InteriorShellInterval[] { + const rows = [...component].sort((a, b) => a.row - b.row || b.length - a.length); + if (rows.length < MIN_INTERVAL_ROWS) return []; + const radius = Math.min(INTERVAL_SUPPORT_RADIUS_ROWS, Math.floor((rows.length - 1) / 2)); + if (radius <= 0) return rows; + + const supported: InteriorShellInterval[] = []; + for (let i = 0; i < rows.length; i += 1) { + const from = Math.max(0, i - radius); + const to = Math.min(rows.length - 1, i + radius); + let x0 = rows[i].x0; + let x1 = rows[i].x1; + for (let j = from; j <= to; j += 1) { + x0 = Math.max(x0, rows[j].x0); + x1 = Math.min(x1, rows[j].x1); } + if (x1 - x0 <= tolerance * 4) continue; + supported.push({ + ...rows[i], + x0, + x1, + length: x1 - x0, + }); } - return bestOverlap > EPS ? best : null; -} -function loopIntervalsAtY2D(points: Point2[], y: number): Array<[number, number]> { - const xs: number[] = []; - for (let i = 0; i < points.length; i += 1) { - const a = points[i]; - const b = points[(i + 1) % points.length]; - if (Math.abs(b[1] - a[1]) <= EPS) continue; - const low = Math.min(a[1], b[1]); - const high = Math.max(a[1], b[1]); - if (y < low || y >= high) continue; - const t = (y - a[1]) / (b[1] - a[1]); - xs.push(a[0] + (b[0] - a[0]) * t); - } - xs.sort((a, b) => a - b); - const intervals: Array<[number, number]> = []; - for (let i = 0; i + 1 < xs.length; i += 2) { - if (xs[i + 1] - xs[i] > EPS) intervals.push([xs[i], xs[i + 1]]); - } - return intervals; -} - -function bounds2D(points: Point2[]): { - minX: number; - minY: number; - maxX: number; - maxY: number; - width: number; - height: number; -} | null { - let minX = Infinity; - let minY = Infinity; - let maxX = -Infinity; - let maxY = -Infinity; - for (const [x, y] of points) { - minX = Math.min(minX, x); - minY = Math.min(minY, y); - maxX = Math.max(maxX, x); - maxY = Math.max(maxY, y); - } - if (!Number.isFinite(minX) || maxX - minX <= EPS || maxY - minY <= EPS) { - return null; - } - return { minX, minY, maxX, maxY, width: maxX - minX, height: maxY - minY }; -} - -function transposePoint2D([x, y]: Point2): Point2 { - return [y, x]; + return intervalComponents(supported) + .filter((candidate) => candidate.length >= MIN_INTERVAL_ROWS)[0] ?? []; } function scanlineIntervals( segments: Segment2[], candidateArea: number, tolerance: number, -): InteriorFillInterval[] { +): InteriorShellInterval[] { let minY = Infinity; let maxY = -Infinity; for (const segment of segments) { @@ -484,9 +452,9 @@ function scanlineIntervals( } if (!Number.isFinite(minY) || maxY - minY <= tolerance) return []; - const intervals: InteriorFillInterval[] = []; - for (let row = 0; row < INTERIOR_FILL_SCAN_ROWS; row += 1) { - const y = minY + ((row + 0.5) / INTERIOR_FILL_SCAN_ROWS) * (maxY - minY); + const intervals: InteriorShellInterval[] = []; + for (let row = 0; row < SCAN_ROWS; row += 1) { + const y = minY + ((row + 0.5) / SCAN_ROWS) * (maxY - minY); const xs: number[] = []; for (const segment of segments) { const y0 = segment.a[1]; @@ -513,24 +481,24 @@ function scanlineIntervals( if (intervals.length === 0) return []; const maxLength = Math.max(...intervals.map((interval) => interval.length)); const minLength = Math.max( - maxLength * INTERIOR_FILL_INTERVAL_MIN_LENGTH_RATIO, + maxLength * INTERVAL_MIN_LENGTH_RATIO, Math.sqrt(candidateArea) * 0.01, tolerance * 4, ); return intervals.filter((interval) => interval.length >= minLength); } -function intervalComponents(intervals: InteriorFillInterval[]): InteriorFillInterval[][] { +function intervalComponents(intervals: InteriorShellInterval[]): InteriorShellInterval[][] { const sorted = [...intervals].sort((a, b) => a.row - b.row || b.length - a.length); - const components: InteriorFillInterval[][] = []; - const active: Array<{ last: InteriorFillInterval; component: InteriorFillInterval[] }> = []; + const components: InteriorShellInterval[][] = []; + const active: Array<{ last: InteriorShellInterval; component: InteriorShellInterval[] }> = []; for (const interval of sorted) { - let best: { last: InteriorFillInterval; component: InteriorFillInterval[] } | null = null; + let best: { last: InteriorShellInterval; component: InteriorShellInterval[] } | null = null; for (const current of active) { if (interval.row - current.last.row > 1) continue; const overlap = Math.min(interval.x1, current.last.x1) - Math.max(interval.x0, current.last.x0); - const required = Math.min(interval.length, current.last.length) * INTERIOR_FILL_INTERVAL_OVERLAP_RATIO; + const required = Math.min(interval.length, current.last.length) * INTERVAL_OVERLAP_RATIO; if (overlap >= required && (!best || current.component.length > best.component.length)) { best = current; } @@ -553,8 +521,8 @@ function intervalComponents(intervals: InteriorFillInterval[]): InteriorFillInte return components.sort((a, b) => componentArea(b) - componentArea(a)); } -function loopFromIntervals(intervals: InteriorFillInterval[]): Point2[] { - const byRow = new Map(); +function loopFromIntervals(intervals: InteriorShellInterval[]): Point2[] { + const byRow = new Map(); for (const interval of intervals) { const current = byRow.get(interval.row); if (!current || interval.length > current.length) byRow.set(interval.row, interval); @@ -577,6 +545,38 @@ function cleanLoop2D(points: Point2[]): Point2[] { return out; } +function simplifyLoop2D(points: Point2[], maxPoints: number): Point2[] { + const cleaned = cleanLoop2D(points); + if (cleaned.length <= maxPoints) return cleaned; + + const out = [...cleaned]; + while (out.length > maxPoints) { + let removeIndex = -1; + let removeScore = Infinity; + for (let i = 0; i < out.length; i += 1) { + const previous = out[(i + out.length - 1) % out.length]; + const current = out[i]; + const next = out[(i + 1) % out.length]; + const score = triangleArea2D(previous, current, next); + if (score < removeScore) { + removeScore = score; + removeIndex = i; + } + } + if (removeIndex < 0) break; + out.splice(removeIndex, 1); + } + return cleanLoop2D(out); +} + +function triangleArea2D(a: Point2, b: Point2, c: Point2): number { + return Math.abs( + (a[0] * (b[1] - c[1]) + + b[0] * (c[1] - a[1]) + + c[0] * (a[1] - b[1])) * 0.5, + ); +} + function scaleLoopTowardCentroid2D(points: Point2[], amount: number): Point2[] { const center = loopCentroid2D(points); return points.map(([x, y]) => [ @@ -585,12 +585,10 @@ function scaleLoopTowardCentroid2D(points: Point2[], amount: number): Point2[] { ]); } -function componentArea(component: InteriorFillInterval[]): number { +function componentArea(component: InteriorShellInterval[]): number { if (component.length === 0) return 0; const rows = [...component].sort((a, b) => a.row - b.row); - const rowStep = rows.length > 1 - ? Math.abs(rows[1].y - rows[0].y) - : 1; + const rowStep = rows.length > 1 ? Math.abs(rows[1].y - rows[0].y) : 1; return rows.reduce((sum, row) => sum + row.length * rowStep, 0); } @@ -630,7 +628,7 @@ function loopArea2D(points: Point2[]): number { return area / 2; } -function totalComponentArea(components: InteriorFillComponent[]): number { +function totalComponentArea(components: InteriorShellComponent[]): number { return components.reduce((sum, component) => sum + component.area, 0); } @@ -648,9 +646,9 @@ function loopCentroid2D(points: Point2[]): Point2 { for (let i = 0; i < points.length; i += 1) { const a = points[i]; const b = points[(i + 1) % points.length]; - const cross = a[0] * b[1] - b[0] * a[1]; - cx += (a[0] + b[0]) * cross; - cy += (a[1] + b[1]) * cross; + const crossValue = a[0] * b[1] - b[0] * a[1]; + cx += (a[0] + b[0]) * crossValue; + cy += (a[1] + b[1]) * crossValue; } const factor = 1 / (6 * signedArea); return [cx * factor, cy * factor]; diff --git a/website/src/components/types.ts b/website/src/components/types.ts index bec4f649..1c412899 100644 --- a/website/src/components/types.ts +++ b/website/src/components/types.ts @@ -14,6 +14,16 @@ export type DragMode = "orbit" | "pan" | "fpv"; export type PerspectiveMode = "perspective" | "orthographic"; +export type WorkbenchMeshResolution = MeshResolution | "disabled"; + +export function activeMeshResolution(meshResolution: WorkbenchMeshResolution): MeshResolution { + return meshResolution === "disabled" ? "lossy" : meshResolution; +} + +export function meshResolutionShowsMesh(meshResolution: WorkbenchMeshResolution): boolean { + return meshResolution !== "disabled"; +} + export interface DomMetrics { measuredAt: number; nodeCount: number; @@ -50,8 +60,8 @@ export interface SceneOptionsState { solidMaterials: boolean; matrixPrecision: "exact" | "2" | "3" | "4" | "5" | "6"; borderShapePrecision: "exact" | "2" | "3" | "4" | "5" | "6"; - meshResolution: MeshResolution; - meshInteriorFill: boolean; + meshResolution: WorkbenchMeshResolution; + interiorFill: boolean; outlinePolygons: boolean; dragMode: "orbit" | "pan" | "fpv"; target: ReactVec3; diff --git a/website/src/content/docs/api/headless.mdx b/website/src/content/docs/api/headless.mdx index 6f035ebe..7c050008 100644 --- a/website/src/content/docs/api/headless.mdx +++ b/website/src/content/docs/api/headless.mdx @@ -113,7 +113,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 returns the smallest accepted polygon list. +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. ```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 c3bfe20d..8157fff4 100644 --- a/website/src/content/docs/api/types.mdx +++ b/website/src/content/docs/api/types.mdx @@ -199,7 +199,7 @@ interface LoadMeshOptions { gltfOptions?: GltfParseOptions; /** Forwarded to parseVox. */ voxOptions?: VoxParseOptions; - /** Shared mesh-resolution optimizer. Defaults to "lossy". */ + /** Shared mesh-resolution optimizer. Defaults to "lossy", including bounded seam repair. */ meshResolution?: "lossless" | "lossy"; } ``` diff --git a/website/src/content/docs/components/poly-scene.mdx b/website/src/content/docs/components/poly-scene.mdx index 392c566a..77a3e791 100644 --- a/website/src/content/docs/components/poly-scene.mdx +++ b/website/src/content/docs/components/poly-scene.mdx @@ -19,6 +19,7 @@ It's available as a custom element (``), via the imperative `createP | `ambientLight` | `PolyAmbientLight` | None | Ambient fill light. | | `textureLighting` | `"baked" \| "dynamic"` | `"baked"` | Whether texture lighting is rasterized into atlases or computed with CSS variables. | | `textureQuality` | `number \| "auto"` | `"auto"` | Atlas bitmap budget and compositor sprite size. Auto caps large runtime bitmaps and uses a larger desktop sprite to avoid Safari/Firefox flattening artifacts; lower numeric values reduce texture memory and detail. | +| `seamBleed` | `number \| "auto"` | `1.5` | Solid-primitive overscan for detected shared seam edges. `"auto"` fits each edge from the polygon plan; numbers clamp the CSS-pixel amount. | | `polygons` | `Polygon[]` | None | (Framework only.) Flat array of polygon objects rendered as direct children. Composes with JSX/slot children. | | `children` | None | None | `` / `` / `` (vanilla) or `` / `` / `` (React / Vue). | @@ -36,6 +37,7 @@ It's available as a custom element (``), via the imperative `createP | `scale` | `number \| Vec3` | Uniform or per-axis scale. | | `rotation` | `Vec3` | Euler rotation in degrees `[x, y, z]`. | | `textureQuality` | `number \| "auto"` | Atlas bitmap budget and compositor sprite size. React / Vue only; vanilla meshes inherit the scene's `texture-quality`. | +| `seamBleed` | `number \| "auto"` | Solid-primitive overscan for detected shared seam edges. Defaults to the scene value (`1.5`). `"auto"` fits each edge from the polygon plan; numbers clamp the CSS-pixel amount. React / Vue only; vanilla meshes inherit the scene's `seam-bleed`. | | `autoCenter` | `boolean` | Shift the loaded mesh so its bounding-box center sits at the local origin before applying `position`. Useful when assets aren't centered in their file coordinates. | | `mtl` | `string` | Companion `.mtl` URL for OBJ models. | | `parseOptions` | `UseMeshOptions` | Parser options forwarded to `loadMesh`; `meshResolution` defaults to `"lossy"`. |