diff --git a/.agents/skills/chrome-capture-trace/SKILL.md b/.agents/skills/chrome-capture-trace/SKILL.md index b11e9b7b..6c05eb6b 100644 --- a/.agents/skills/chrome-capture-trace/SKILL.md +++ b/.agents/skills/chrome-capture-trace/SKILL.md @@ -27,6 +27,8 @@ Use `scripts/trace.mjs` as the front door: ```bash pnpm bench:build node .agents/skills/chrome-capture-trace/scripts/trace.mjs motion --page nonvoxel --mesh glb:Elephant.glb --variant baseline --dom-samples --label elephant-baseline +node .agents/skills/chrome-capture-trace/scripts/trace.mjs motion --page nonvoxel --mesh teapot --variant baseline --dom-samples --frame-details --layer-details --gpu-details --trace-out bench/results/teapot.trace.json --label teapot-enriched --report +node .agents/skills/chrome-capture-trace/scripts/trace.mjs motion --page nonvoxel --mesh teapot --variant baseline --gpu-details full --trace-out bench/results/teapot-full-gpu.trace.json --label teapot-full-gpu node .agents/skills/chrome-capture-trace/scripts/trace.mjs drag --mesh teapot --mode baked --frame-details --label teapot-drag node .agents/skills/chrome-capture-trace/scripts/trace.mjs motion --mesh garden --report --markdown-out bench/results/garden-trace.md node .agents/skills/chrome-capture-trace/scripts/trace.mjs compare bench/results/before.json bench/results/after.json --markdown-out bench/results/trace-compare.md @@ -34,6 +36,12 @@ node .agents/skills/chrome-capture-trace/scripts/trace.mjs compare bench/results Use `trace.mjs motion` for steady bench motion across `perf` and `nonvoxel` pages, cadence buckets, DOM samples, render stats, and tag counts. +Add `--frame-details` to motion traces when you need slowest/fastest frame attribution instead of only bucket averages. On `nonvoxel` pages this also enables page-work samples for `camera.update`, `scene.applyCamera`, and input/control callbacks when available. Add `--layer-details` when compositor/layer shape is part of the question; it records LayerTree counts, layer aggregates by DOM tag/class (`leaf:b`, `leaf:u`, `polycss-camera`, etc.), largest layers, and compositing reasons. Add `--trace-out` when the raw Chrome trace should be preserved for DevTools. + +Add `--gpu-details` when render pass timing is the current question and the trace still needs to stay reasonably sized. Light mode keeps the normal GPU/viz timeline categories and adds only `disabled-by-default-viz.gpu_composite_time`; render-pass attribution still comes from base events such as `DirectRenderer::DrawFrame` and `DirectRenderer::DrawRenderPass`. It intentionally avoids per-quad, Skia command, and GPU service spam. + +Use `--deep-gpu` or `--gpu-details full` only for rare forensic runs that truly need per-quad/Skia/overdraw detail. Full mode also enables `disabled-by-default-viz.quads`, `disabled-by-default-viz.triangles`, `disabled-by-default-viz.overdraw`, `disabled-by-default-gpu.debug`, and `disabled-by-default-skia.gpu`; raw traces can become hundreds of MB and timing can be heavily perturbed. + Use `trace.mjs drag` for real `PolyOrbitControls` pointer-drag traces on `nonvoxel-vanilla.html`. This runner knows the non-voxel readiness hooks, camera state, interaction stats, and per-frame page-work samples. Use `trace.mjs generic` for arbitrary pages and interactions that are not covered by a polycss bench page. @@ -45,8 +53,13 @@ When interpreting polycss traces, map the result back to the render model: - `Layout`: layout; should stay low for transform/CSS-var-driven motion. - `PrePaint`, `Paint`, `PaintArtifactCompositor::Update`, `Layerize`: paint/compositing setup. - `LayerTreeImpl::UpdateDrawProperties`, `draw_property_utils::ComputeDrawPropertiesOfVisibleLayers`, `LayerTreeHostImpl::PrepareToDraw`, `MainFrame.Draw`, `SubmitCompositorFrame`: compositor-side cost. +- `Graphics.Pipeline`, `DisplayScheduler::DrawAndSwap`, `DirectRenderer::DrawFrame`, `DirectRenderer::DrawRenderPass`: GPU/viz drawing pipeline. Treat these as browser output work, and compare them against layer details before changing app JS. +- `gpuVizRenderPass`, `gpuVizTiles`, `gpuVizGpuService`: opt-in `--gpu-details` attribution buckets for render pass, coarse tile/raster playback, and GPU service events. +- `gpuVizQuads` and `gpuVizSkia`: usually require `--gpu-details full` / `--deep-gpu`; treat them as high-overhead forensic signals. - `RasterTask`, image decode events: raster/bitmap work, usually atlas or tile work. +Trace event durations are inclusive and often nested, especially GPU/viz and scheduler events. Use group `ms/frame` as attribution evidence and for before/after deltas, not as exclusive slices that must add up to frame time. + ## Generic Capture For arbitrary pages, use `trace.mjs generic`: diff --git a/.agents/skills/chrome-capture-trace/scripts/capture-trace.mjs b/.agents/skills/chrome-capture-trace/scripts/capture-trace.mjs index 42fdcdd8..bce638d0 100755 --- a/.agents/skills/chrome-capture-trace/scripts/capture-trace.mjs +++ b/.agents/skills/chrome-capture-trace/scripts/capture-trace.mjs @@ -27,6 +27,13 @@ function optNum(name, dflt) { return Number.isFinite(value) ? value : dflt; } +function optFlagValue(name, dflt = "") { + const exact = flag(name); + if (exact >= 0 && argv[exact + 1] && !argv[exact + 1].startsWith("--")) return argv[exact + 1]; + const prefixed = argv.find((arg) => arg.startsWith(`--${name}=`)); + return prefixed ? prefixed.slice(name.length + 3) : dflt; +} + function optAll(name) { const values = []; for (let i = 0; i < argv.length; i += 1) { @@ -57,20 +64,59 @@ const VIEWPORT = optStr("viewport", "1280x800"); const TRACE_OUT = optStr("trace-out", "chrome-trace.json"); const SUMMARY_OUT = optStr("summary-out", "chrome-trace-summary.json"); const BROWSER_EXECUTABLE = optStr("browser-executable"); +const GPU_DETAILS_MODE = resolveGpuDetailsMode(); +const GPU_DETAILS = GPU_DETAILS_MODE !== "off"; const HEADLESS = !hasFlag("headed"); const CHROMIUM_ARGS = optAll("chromium-arg"); const MARK_START = "__chrome_capture_trace_start__"; const MARK_END = "__chrome_capture_trace_end__"; -const TRACE_CATEGORIES = [ +function resolveGpuDetailsMode() { + const raw = (optFlagValue("gpu-details") || optFlagValue("gpu-viz-details")).toLowerCase(); + if (raw === "off" || raw === "false" || raw === "no" || raw === "0") return "off"; + if (hasFlag("deep-gpu") || raw === "full" || raw === "deep" || raw === "heavy") return "full"; + if (hasFlag("gpu-details") || hasFlag("gpu-viz-details") || raw === "light" || raw === "summary") return "light"; + return "off"; +} + +const BASE_TRACE_CATEGORIES = [ "devtools.timeline", "disabled-by-default-devtools.timeline", + "benchmark", "blink", + "blink.console", "blink.user_timing", "cc", "gpu", "viz", + "v8.console", "renderer.scheduler", +]; + +const GPU_DETAIL_TRACE_CATEGORIES = [ + "disabled-by-default-viz.gpu_composite_time", +]; + +const DEEP_GPU_TRACE_CATEGORIES = [ + ...GPU_DETAIL_TRACE_CATEGORIES, + "disabled-by-default-devtools.timeline.picture", + "disabled-by-default-cc.debug", + "disabled-by-default-cc.debug.display_items", + "disabled-by-default-cc.debug.picture", + "disabled-by-default-gpu.debug", + "disabled-by-default-skia", + "disabled-by-default-skia.gpu", + "disabled-by-default-skia.gpu.cache", + "disabled-by-default-viz.debug.overlay_planes", + "disabled-by-default-viz.overdraw", + "disabled-by-default-viz.quads", + "disabled-by-default-viz.triangles", +]; + +const TRACE_CATEGORIES = [ + ...BASE_TRACE_CATEGORIES, + ...(GPU_DETAILS_MODE === "light" ? GPU_DETAIL_TRACE_CATEGORIES : []), + ...(GPU_DETAILS_MODE === "full" ? DEEP_GPU_TRACE_CATEGORIES : []), ].join(","); const EVENT_GROUPS = { @@ -98,12 +144,61 @@ const EVENT_GROUPS = { ], gpuViz: [ "Graphics.Pipeline", + "DisplayScheduler::OnBeginFrameDeadline", "DisplayScheduler::DrawAndSwap", + "Display::DrawAndSwap", "DirectRenderer::DrawFrame", "DirectRenderer::DrawRenderPass", + "SoftwareRenderer::DoDrawQuad", + "SkiaOutputSurfaceImplOnGpu::SwapBuffers", + ], +}; + +const EVENT_GROUP_PATTERNS = { + gpuVizRenderPass: [ + /RenderPass/i, + /CalculateRenderPass/i, + /DrawFrame/i, + /DrawAndSwap/i, + ], + gpuVizQuads: [ + /Quad/i, + /AppendQuads/i, + ], + gpuVizTiles: [ + /Tile/i, + /RasterTask/i, + /RasterBuffer/i, + /RasterSource/i, + /PlaybackToMemory/i, + ], + gpuVizSkia: [ + /Skia/i, + /GrContext/i, + /Graphite/i, + ], + gpuVizGpuService: [ + /SwapBuffers/i, + /CommandBuffer/i, + /SharedImage/i, + /Gpu/i, + /Metal/i, ], }; +const EXACT_EVENT_GROUPS = new Map(); +for (const [group, names] of Object.entries(EVENT_GROUPS)) { + for (const name of names) { + const groups = EXACT_EVENT_GROUPS.get(name) ?? []; + groups.push(group); + EXACT_EVENT_GROUPS.set(name, groups); + } +} + +function allGroupNames() { + return [...Object.keys(EVENT_GROUPS), ...Object.keys(EVENT_GROUP_PATTERNS)]; +} + function printHelp() { console.log(`Usage: node scripts/capture-trace.mjs --url [options] @@ -125,6 +220,8 @@ Options: --eval JavaScript body for action=eval. --trace-out Raw trace output. Default: chrome-trace.json --summary-out Summary JSON output. Default: chrome-trace-summary.json + --gpu-details [light|full] Include GPU/viz detail categories. Default when present: light. + --deep-gpu Alias for --gpu-details full; much larger and timing-intrusive. --browser-executable Use a specific Chrome/Chromium executable. --chromium-arg Extra Chromium arg, repeatable. --headed Run headed. @@ -162,6 +259,20 @@ function addDuration(map, name, durationMs) { map.set(name, entry); } +function eventGroups(eventName) { + const out = new Set(EXACT_EVENT_GROUPS.get(eventName) ?? []); + for (const [group, patterns] of Object.entries(EVENT_GROUP_PATTERNS)) { + if (patterns.some((pattern) => pattern.test(eventName))) out.add(group); + } + return [...out]; +} + +function addEventGroups(map, eventName, durationMs) { + for (const group of eventGroups(eventName)) { + addDuration(map, group, durationMs); + } +} + function serializeTotals(map, limit = 20) { return [...map.entries()] .map(([name, entry]) => ({ @@ -224,11 +335,6 @@ function summarizeFrames(frames) { } function summarizeEvents(events, tracePerfOffsetMs, startPerfNow, endPerfNow, frames) { - const eventToGroup = new Map(); - for (const [group, names] of Object.entries(EVENT_GROUPS)) { - for (const name of names) eventToGroup.set(name, group); - } - const groupTotals = new Map(); const eventTotals = new Map(); let completeEventCount = 0; @@ -242,14 +348,13 @@ function summarizeEvents(events, tracePerfOffsetMs, startPerfNow, endPerfNow, fr completeEventCount += 1; completeDurationMs += durationMs; addDuration(eventTotals, event.name, durationMs); - const group = eventToGroup.get(event.name); - if (group) addDuration(groupTotals, group, durationMs); + addEventGroups(groupTotals, event.name, durationMs); const frameIndex = frameIndexAt(frames, perfNow); if (frameIndex >= 0) { const frame = frames[frameIndex]; addDuration(frame.events, event.name, durationMs); - if (group) addDuration(frame.groups, group, durationMs); + addEventGroups(frame.groups, event.name, durationMs); } } @@ -260,7 +365,7 @@ function summarizeEvents(events, tracePerfOffsetMs, startPerfNow, endPerfNow, fr end_ms: +frame.end.toFixed(3), dt_ms: +frame.dt.toFixed(3), groups: Object.fromEntries( - Object.keys(EVENT_GROUPS).map((group) => [group, +(frame.groups.get(group)?.durationMs ?? 0).toFixed(4)]), + allGroupNames().map((group) => [group, +(frame.groups.get(group)?.durationMs ?? 0).toFixed(4)]), ), topEvents: serializeTotals(frame.events, 8), })) @@ -271,7 +376,7 @@ function summarizeEvents(events, tracePerfOffsetMs, startPerfNow, endPerfNow, fr complete_event_count: completeEventCount, complete_duration_ms: +completeDurationMs.toFixed(3), groups: Object.fromEntries( - Object.keys(EVENT_GROUPS).map((group) => { + allGroupNames().map((group) => { const total = groupTotals.get(group); return [group, { count: total?.count ?? 0, @@ -464,6 +569,7 @@ async function run() { action, warmup_ms: WARMUP_MS, settle_ms: SETTLE_MS, + gpuDetails: GPU_DETAILS_MODE, trace_aligned_to_marks: aligned, trace_perf_offset_ms: aligned ? +tracePerfOffsetMs.toFixed(3) : null, action_window_ms: +(alignedEndPerfNow - alignedStartPerfNow).toFixed(3), @@ -487,6 +593,7 @@ async function run() { source: "chrome-capture-trace/scripts/capture-trace.mjs", url: URL, action, + gpuDetails: GPU_DETAILS_MODE, }, })); diff --git a/.agents/skills/chrome-capture-trace/scripts/polycss-nonvoxel-drag-trace.mjs b/.agents/skills/chrome-capture-trace/scripts/polycss-nonvoxel-drag-trace.mjs index bbb0513f..a2a768a6 100755 --- a/.agents/skills/chrome-capture-trace/scripts/polycss-nonvoxel-drag-trace.mjs +++ b/.agents/skills/chrome-capture-trace/scripts/polycss-nonvoxel-drag-trace.mjs @@ -41,6 +41,12 @@ const optNum = (name, dflt) => { const value = Number(raw); return Number.isFinite(value) ? value : dflt; }; +const optFlagValue = (name, dflt = "") => { + const i = flag(name); + if (i >= 0 && argv[i + 1] && !argv[i + 1].startsWith("--")) return argv[i + 1]; + const prefixed = argv.find((arg) => arg.startsWith(`--${name}=`)); + return prefixed ? prefixed.slice(name.length + 3) : dflt; +}; const optAll = (name) => { const values = []; for (let i = 0; i < argv.length; i += 1) { @@ -74,6 +80,8 @@ const FRAME_DETAILS = hasFlag("frame-details"); const FRAME_DETAILS_LIMIT = Math.max(0, Math.round(optNum("frame-details-limit", 24))); const PRINT_JSON = !hasFlag("no-print-json"); const TRACE = !hasFlag("no-trace"); +const GPU_DETAILS_MODE = resolveGpuDetailsMode(); +const GPU_DETAILS = GPU_DETAILS_MODE !== "off"; const HEADED = hasFlag("headed"); const BROWSER_EXECUTABLE = optStr("browser-executable"); const SOFTWARE_BACKEND = hasFlag("software-backend"); @@ -82,6 +90,14 @@ const CHROMIUM_ARGS = chromiumArgsWithGpuDefault([ ...optAll("chromium-args").flatMap((value) => value.split(/\s+/).filter(Boolean)), ], { softwareBackend: SOFTWARE_BACKEND }); +function resolveGpuDetailsMode() { + const raw = (optFlagValue("gpu-details") || optFlagValue("gpu-viz-details")).toLowerCase(); + if (raw === "off" || raw === "false" || raw === "no" || raw === "0") return "off"; + if (hasFlag("deep-gpu") || raw === "full" || raw === "deep" || raw === "heavy") return "full"; + if (hasFlag("gpu-details") || hasFlag("gpu-viz-details") || raw === "light" || raw === "summary") return "light"; + return "off"; +} + if (MODE !== "baked" && MODE !== "dynamic") { throw new Error(`--mode must be baked or dynamic; got "${MODE}"`); } @@ -110,15 +126,44 @@ const MIME = { ".vox": "application/octet-stream", }; -const TRACE_CATEGORIES = [ +const BASE_TRACE_CATEGORIES = [ "devtools.timeline", "disabled-by-default-devtools.timeline", + "benchmark", "blink", + "blink.console", "blink.user_timing", "cc", "gpu", "viz", + "v8.console", "renderer.scheduler", +]; + +const GPU_DETAIL_TRACE_CATEGORIES = [ + "disabled-by-default-viz.gpu_composite_time", +]; + +const DEEP_GPU_TRACE_CATEGORIES = [ + ...GPU_DETAIL_TRACE_CATEGORIES, + "disabled-by-default-devtools.timeline.picture", + "disabled-by-default-cc.debug", + "disabled-by-default-cc.debug.display_items", + "disabled-by-default-cc.debug.picture", + "disabled-by-default-gpu.debug", + "disabled-by-default-skia", + "disabled-by-default-skia.gpu", + "disabled-by-default-skia.gpu.cache", + "disabled-by-default-viz.debug.overlay_planes", + "disabled-by-default-viz.overdraw", + "disabled-by-default-viz.quads", + "disabled-by-default-viz.triangles", +]; + +const TRACE_CATEGORIES = [ + ...BASE_TRACE_CATEGORIES, + ...(GPU_DETAILS_MODE === "light" ? GPU_DETAIL_TRACE_CATEGORIES : []), + ...(GPU_DETAILS_MODE === "full" ? DEEP_GPU_TRACE_CATEGORIES : []), ].join(","); const EVENT_GROUPS = { @@ -144,8 +189,63 @@ const EVENT_GROUPS = { "MainFrame.Draw", "SubmitCompositorFrame", ], + gpuViz: [ + "Graphics.Pipeline", + "DisplayScheduler::OnBeginFrameDeadline", + "DisplayScheduler::DrawAndSwap", + "Display::DrawAndSwap", + "DirectRenderer::DrawFrame", + "DirectRenderer::DrawRenderPass", + "SoftwareRenderer::DoDrawQuad", + "SkiaOutputSurfaceImplOnGpu::SwapBuffers", + ], +}; + +const EVENT_GROUP_PATTERNS = { + gpuVizRenderPass: [ + /RenderPass/i, + /CalculateRenderPass/i, + /DrawFrame/i, + /DrawAndSwap/i, + ], + gpuVizQuads: [ + /Quad/i, + /AppendQuads/i, + ], + gpuVizTiles: [ + /Tile/i, + /RasterTask/i, + /RasterBuffer/i, + /RasterSource/i, + /PlaybackToMemory/i, + ], + gpuVizSkia: [ + /Skia/i, + /GrContext/i, + /Graphite/i, + ], + gpuVizGpuService: [ + /SwapBuffers/i, + /CommandBuffer/i, + /SharedImage/i, + /Gpu/i, + /Metal/i, + ], }; +const EXACT_EVENT_GROUPS = new Map(); +for (const [group, names] of Object.entries(EVENT_GROUPS)) { + for (const name of names) { + const groups = EXACT_EVENT_GROUPS.get(name) ?? []; + groups.push(group); + EXACT_EVENT_GROUPS.set(name, groups); + } +} + +function allGroupNames() { + return [...Object.keys(EVENT_GROUPS), ...Object.keys(EVENT_GROUP_PATTERNS)]; +} + const KEY_EVENTS = [ "EventDispatch", "FunctionCall", @@ -165,8 +265,23 @@ const KEY_EVENTS = [ "MainFrame.Draw", "SubmitCompositorFrame", "RasterTask", + "Graphics.Pipeline", + "DisplayScheduler::OnBeginFrameDeadline", + "DisplayScheduler::DrawAndSwap", + "Display::DrawAndSwap", "DirectRenderer::DrawFrame", "DirectRenderer::DrawRenderPass", + "SoftwareRenderer::DoDrawQuad", + "SkiaRenderer::DoDrawQuad", + "GLRenderer::DoDrawQuad", + "SkiaOutputSurfaceImplOnGpu::SwapBuffers", + "LayerTreeHostImpl::CalculateRenderPasses", + "PictureLayerImpl::AppendQuads", + "TileManager::PrepareTiles", + "TileManager::AssignGpuMemoryToTiles", + "TileTaskManagerImpl::ScheduleTasks", + "RasterTaskImpl::RunOnWorkerThread", + "RasterBufferProvider::PlaybackToMemory", ]; function startServer() { @@ -298,6 +413,20 @@ function addDuration(map, name, durationMs) { map.set(name, entry); } +function eventGroups(eventName) { + const out = new Set(EXACT_EVENT_GROUPS.get(eventName) ?? []); + for (const [group, patterns] of Object.entries(EVENT_GROUP_PATTERNS)) { + if (patterns.some((pattern) => pattern.test(eventName))) out.add(group); + } + return [...out]; +} + +function addEventGroups(map, eventName, durationMs) { + for (const group of eventGroups(eventName)) { + addDuration(map, group, durationMs); + } +} + function addAggregate(map, name, count, durationMs) { const entry = map.get(name) ?? { count: 0, duration_ms: 0 }; entry.count += count; @@ -347,11 +476,6 @@ function summarizeFrameDetails(events, samples, frameWorkSamples, startPerfNow, const alignedStartPerfNow = startMark.args.data.startTime || startPerfNow; const alignedEndPerfNow = endMark.args.data.startTime || endPerfNow; const frames = makeFrameWindows(samples, alignedStartPerfNow, alignedEndPerfNow); - const groupByEvent = new Map(); - for (const [group, names] of Object.entries(EVENT_GROUPS)) { - for (const name of names) groupByEvent.set(name, group); - } - const frameTotals = frames.map((frame) => ({ ...frame, groups: new Map(), @@ -376,8 +500,7 @@ function summarizeFrameDetails(events, samples, frameWorkSamples, startPerfNow, const frame = frameTotals[index]; frame.completeEventMs += durationMs; addDuration(frame.events, event.name, durationMs); - const group = groupByEvent.get(event.name); - if (group) addDuration(frame.groups, group, durationMs); + addEventGroups(frame.groups, event.name, durationMs); } const serializeMap = (map, keys) => { @@ -385,7 +508,7 @@ function summarizeFrameDetails(events, samples, frameWorkSamples, startPerfNow, for (const key of keys) out[key] = +((map.get(key)?.duration_ms ?? 0)).toFixed(4); return out; }; - const serializeGroups = (map) => serializeMap(map, Object.keys(EVENT_GROUPS)); + const serializeGroups = (map) => serializeMap(map, allGroupNames()); const serializeKeyEvents = (map) => serializeMap(map, KEY_EVENTS); const topEvents = (map) => [...map.entries()] .map(([event, total]) => ({ @@ -465,21 +588,23 @@ function summarizeTraceEvents(events) { byName.set(event.name, prev); } - const group = (names) => { - let count = 0; - let durationUs = 0; - for (const name of names) { - const entry = byName.get(name); - if (!entry) continue; - count += entry.count; - durationUs += entry.durationUs; + const groupTotals = new Map(); + for (const [eventName, entry] of byName.entries()) { + for (const groupName of eventGroups(eventName)) { + const total = groupTotals.get(groupName) ?? { count: 0, durationUs: 0 }; + total.count += entry.count; + total.durationUs += entry.durationUs; + groupTotals.set(groupName, total); } - return { count, duration_ms: +(durationUs / 1000).toFixed(3) }; - }; + } - const groups = Object.fromEntries( - Object.entries(EVENT_GROUPS).map(([name, names]) => [name, group(names)]), - ); + const groups = Object.fromEntries(allGroupNames().map((name) => { + const total = groupTotals.get(name); + return [name, { + count: total?.count ?? 0, + duration_ms: +((total?.durationUs ?? 0) / 1000).toFixed(3), + }]; + })); const topEvents = [...byName.entries()] .sort((a, b) => b[1].durationUs - a[1].durationUs) @@ -626,7 +751,7 @@ async function run() { const paths = outputPaths(); const { server, port } = await startServer(); console.log(`[drag-trace] server :${port}`); - console.log(`[drag-trace] mesh=${MESH} mode=${MODE} variant=${VARIANT} degrees=${DEGREES} warmup=${WARMUP_MS}ms drag=${DRAG_MS}ms settle=${SETTLE_MS}ms`); + console.log(`[drag-trace] mesh=${MESH} mode=${MODE} variant=${VARIANT} degrees=${DEGREES} warmup=${WARMUP_MS}ms drag=${DRAG_MS}ms settle=${SETTLE_MS}ms gpuDetails=${GPU_DETAILS_MODE}`); if (BROWSER_EXECUTABLE) console.log(`[drag-trace] browser=${BROWSER_EXECUTABLE}`); if (SOFTWARE_BACKEND) console.log("[drag-trace] software backend=on"); if (CHROMIUM_ARGS.length > 0) console.log(`[drag-trace] chromium args=${CHROMIUM_ARGS.join(" ")}`); @@ -716,6 +841,7 @@ async function run() { browserExecutable: BROWSER_EXECUTABLE || null, chromiumArgs: CHROMIUM_ARGS, softwareBackend: SOFTWARE_BACKEND, + gpuDetails: GPU_DETAILS_MODE, drag: { ...dragPlan, gestures: dragPlan.gestures.map((gesture) => ({ @@ -758,6 +884,7 @@ async function run() { requestedDegrees: DEGREES, dragMs: DRAG_MS, steps: STEPS, + gpuDetails: GPU_DETAILS_MODE, }, }; mkdirSync(dirname(paths.trace), { recursive: true }); diff --git a/.agents/skills/chrome-capture-trace/scripts/polycss-trace-analysis.mjs b/.agents/skills/chrome-capture-trace/scripts/polycss-trace-analysis.mjs index 221a2664..ffc1cceb 100644 --- a/.agents/skills/chrome-capture-trace/scripts/polycss-trace-analysis.mjs +++ b/.agents/skills/chrome-capture-trace/scripts/polycss-trace-analysis.mjs @@ -10,6 +10,7 @@ * node .agents/skills/chrome-capture-trace/scripts/polycss-trace-analysis.mjs --mesh ancient-crash-site --runs 3 --dom-samples * node .agents/skills/chrome-capture-trace/scripts/polycss-trace-analysis.mjs --mesh obj-house3 --renderer vanilla --label obj-house3-trace * node .agents/skills/chrome-capture-trace/scripts/polycss-trace-analysis.mjs --page nonvoxel --mesh glb:Elephant.glb --variant order-tile4 --no-trace + * node .agents/skills/chrome-capture-trace/scripts/polycss-trace-analysis.mjs --page nonvoxel --mesh teapot --frame-details --layer-details */ import { createServer } from "node:http"; import { mkdirSync, writeFileSync } from "node:fs"; @@ -48,6 +49,12 @@ const optNum = (name, dflt) => { const v = optStr(name); return v ? Number(v) : dflt; }; +const optFlagValue = (name, dflt = "") => { + const i = flag(name); + if (i >= 0 && argv[i + 1] && !argv[i + 1].startsWith("--")) return argv[i + 1]; + const prefixed = argv.find((arg) => arg.startsWith(`--${name}=`)); + return prefixed ? prefixed.slice(name.length + 3) : dflt; +}; const hasFlag = (name) => flag(name) >= 0; const HELP = argv.includes("--help") || argv.includes("-h"); @@ -62,10 +69,17 @@ const SAMPLE_MS = optNum("sample", 6000); const RUNS = optNum("runs", 1); const LABEL = optStr("label"); const SUMMARY_PATH = optStr("summary-out"); +const TRACE_PATH = optStr("trace-out"); const HEADED = hasFlag("headed"); const JSON_ONLY = hasFlag("json"); const TRACE = !hasFlag("no-trace"); const DOM_SAMPLES = hasFlag("dom-samples"); +const FRAME_DETAILS = hasFlag("frame-details"); +const FRAME_DETAILS_LIMIT = Math.max(0, Math.round(optNum("frame-details-limit", 24))); +const LAYER_DETAILS = hasFlag("layer-details") || hasFlag("layers"); +const LAYER_DETAILS_LIMIT = Math.max(0, Math.round(optNum("layer-details-limit", 80))); +const GPU_DETAILS_MODE = resolveGpuDetailsMode(); +const GPU_DETAILS = GPU_DETAILS_MODE !== "off"; const BROWSER_EXECUTABLE = optStr("browser-executable"); const SOFTWARE_BACKEND = hasFlag("software-backend"); const CHROMIUM_ARGS = chromiumArgsWithGpuDefault([ @@ -73,6 +87,14 @@ const CHROMIUM_ARGS = chromiumArgsWithGpuDefault([ ...optAll("chromium-args").flatMap((value) => value.split(/\s+/).filter(Boolean)), ], { softwareBackend: SOFTWARE_BACKEND }); +function resolveGpuDetailsMode() { + const raw = (optFlagValue("gpu-details") || optFlagValue("gpu-viz-details")).toLowerCase(); + if (raw === "off" || raw === "false" || raw === "no" || raw === "0") return "off"; + if (hasFlag("deep-gpu") || raw === "full" || raw === "deep" || raw === "heavy") return "full"; + if (hasFlag("gpu-details") || hasFlag("gpu-viz-details") || raw === "light" || raw === "summary") return "light"; + return "off"; +} + const MIME = { ".html": "text/html; charset=utf-8", ".js": "application/javascript; charset=utf-8", @@ -89,15 +111,44 @@ const MIME = { ".vox": "application/octet-stream", }; -const TRACE_CATEGORIES = [ +const BASE_TRACE_CATEGORIES = [ "devtools.timeline", "disabled-by-default-devtools.timeline", + "benchmark", "blink", + "blink.console", "blink.user_timing", "cc", "gpu", "viz", + "v8.console", "renderer.scheduler", +]; + +const GPU_DETAIL_TRACE_CATEGORIES = [ + "disabled-by-default-viz.gpu_composite_time", +]; + +const DEEP_GPU_TRACE_CATEGORIES = [ + ...GPU_DETAIL_TRACE_CATEGORIES, + "disabled-by-default-devtools.timeline.picture", + "disabled-by-default-cc.debug", + "disabled-by-default-cc.debug.display_items", + "disabled-by-default-cc.debug.picture", + "disabled-by-default-gpu.debug", + "disabled-by-default-skia", + "disabled-by-default-skia.gpu", + "disabled-by-default-skia.gpu.cache", + "disabled-by-default-viz.debug.overlay_planes", + "disabled-by-default-viz.overdraw", + "disabled-by-default-viz.quads", + "disabled-by-default-viz.triangles", +]; + +const TRACE_CATEGORIES = [ + ...BASE_TRACE_CATEGORIES, + ...(GPU_DETAILS_MODE === "light" ? GPU_DETAIL_TRACE_CATEGORIES : []), + ...(GPU_DETAILS_MODE === "full" ? DEEP_GPU_TRACE_CATEGORIES : []), ].join(","); const EVENT_GROUPS = { @@ -123,8 +174,63 @@ const EVENT_GROUPS = { "MainFrame.Draw", "SubmitCompositorFrame", ], + gpuViz: [ + "Graphics.Pipeline", + "DisplayScheduler::OnBeginFrameDeadline", + "DisplayScheduler::DrawAndSwap", + "Display::DrawAndSwap", + "DirectRenderer::DrawFrame", + "DirectRenderer::DrawRenderPass", + "SoftwareRenderer::DoDrawQuad", + "SkiaOutputSurfaceImplOnGpu::SwapBuffers", + ], +}; + +const EVENT_GROUP_PATTERNS = { + gpuVizRenderPass: [ + /RenderPass/i, + /CalculateRenderPass/i, + /DrawFrame/i, + /DrawAndSwap/i, + ], + gpuVizQuads: [ + /Quad/i, + /AppendQuads/i, + ], + gpuVizTiles: [ + /Tile/i, + /RasterTask/i, + /RasterBuffer/i, + /RasterSource/i, + /PlaybackToMemory/i, + ], + gpuVizSkia: [ + /Skia/i, + /GrContext/i, + /Graphite/i, + ], + gpuVizGpuService: [ + /SwapBuffers/i, + /CommandBuffer/i, + /SharedImage/i, + /Gpu/i, + /Metal/i, + ], }; +const EXACT_EVENT_GROUPS = new Map(); +for (const [group, names] of Object.entries(EVENT_GROUPS)) { + for (const name of names) { + const groups = EXACT_EVENT_GROUPS.get(name) ?? []; + groups.push(group); + EXACT_EVENT_GROUPS.set(name, groups); + } +} + +function allGroupNames() { + return [...Object.keys(EVENT_GROUPS), ...Object.keys(EVENT_GROUP_PATTERNS)]; +} + const KEY_EVENTS = [ "FireAnimationFrame", "FunctionCall", @@ -139,10 +245,22 @@ const KEY_EVENTS = [ "MainFrame.Draw", "SubmitCompositorFrame", "Graphics.Pipeline", + "DisplayScheduler::OnBeginFrameDeadline", "DisplayScheduler::DrawAndSwap", + "Display::DrawAndSwap", "DirectRenderer::DrawFrame", "DirectRenderer::DrawRenderPass", "SoftwareRenderer::DoDrawQuad", + "SkiaRenderer::DoDrawQuad", + "GLRenderer::DoDrawQuad", + "SkiaOutputSurfaceImplOnGpu::SwapBuffers", + "LayerTreeHostImpl::CalculateRenderPasses", + "PictureLayerImpl::AppendQuads", + "TileManager::PrepareTiles", + "TileManager::AssignGpuMemoryToTiles", + "TileTaskManagerImpl::ScheduleTasks", + "RasterTaskImpl::RunOnWorkerThread", + "RasterBufferProvider::PlaybackToMemory", "RunTask", "RasterTask", ]; @@ -162,8 +280,15 @@ Options: --sample Trace sample window. Default: 6000 --label Write bench/results/.json --summary-out Write summary JSON to an explicit file + --trace-out Write raw Chrome trace JSON. For --runs > 1, adds .rN before .json --no-trace Collect rAF bucket stats without Chrome tracing --dom-samples Sample mounted leaf/tag counts by rAF frame + --frame-details Include slowest/fastest frame event and page-work details + --frame-details-limit Frames to include per details section. Default: 24 + --layer-details Include CDP LayerTree summary and compositing reasons + --layer-details-limit Layers to inspect for reasons. Default: 80 + --gpu-details [light|full] Include GPU/viz detail categories. Default when present: light + --deep-gpu Alias for --gpu-details full; much larger and timing-intrusive --headed Run headed Chromium --browser-executable Use a specific Chromium/Chrome executable --software-backend Force the old software/stress backend @@ -328,6 +453,146 @@ function addDuration(map, name, durationMs) { map.set(name, entry); } +function eventGroups(eventName) { + const out = new Set(EXACT_EVENT_GROUPS.get(eventName) ?? []); + for (const [group, patterns] of Object.entries(EVENT_GROUP_PATTERNS)) { + if (patterns.some((pattern) => pattern.test(eventName))) out.add(group); + } + return [...out]; +} + +function addEventGroups(map, eventName, durationMs) { + for (const group of eventGroups(eventName)) { + addDuration(map, group, durationMs); + } +} + +function addAggregate(map, name, count, durationMs) { + const entry = map.get(name) ?? { count: 0, duration_ms: 0 }; + entry.count += count; + entry.duration_ms += durationMs; + map.set(name, entry); +} + +function frameWorkForFrame(frameWorkSamples, frame, startIndex) { + if (!Array.isArray(frameWorkSamples) || frameWorkSamples.length === 0) { + return { sample: null, nextIndex: startIndex }; + } + + let index = Math.max(0, startIndex); + while (index + 1 < frameWorkSamples.length && frameWorkSamples[index + 1].t <= frame.end + 1) { + index += 1; + } + const sample = frameWorkSamples[index]; + if (sample && sample.t >= frame.start - 1 && sample.t <= frame.end + 1) { + return { sample, nextIndex: index }; + } + return { sample: null, nextIndex: index }; +} + +function serializePageOps(ops) { + const out = {}; + if (!ops || typeof ops !== "object") return out; + for (const [name, entry] of Object.entries(ops)) { + out[name] = { + count: entry.count ?? 0, + duration_ms: +((entry.duration_ms ?? 0)).toFixed(4), + }; + } + return out; +} + +function serializeDurationMap(map, keys) { + const out = {}; + for (const key of keys) out[key] = +((map.get(key)?.duration_ms ?? 0)).toFixed(4); + return out; +} + +function topDurationEntries(map, keyName, limit = 12) { + return [...map.entries()] + .map(([name, total]) => ({ + [keyName]: name, + count: total.count, + duration_ms: +total.duration_ms.toFixed(4), + })) + .sort((a, b) => b.duration_ms - a.duration_ms) + .slice(0, limit); +} + +function serializeFrameDetailsFrame(frame) { + return { + index: frame.index, + bucket: frame.bucket, + start_ms: +frame.start.toFixed(3), + end_ms: +frame.end.toFixed(3), + dt_ms: +frame.dt.toFixed(3), + leaves: Number.isFinite(frame.leaves) ? frame.leaves : null, + tags: frame.tags ?? null, + bucketCount: Number.isFinite(frame.bucketCount) ? frame.bucketCount : null, + inlineStyleChars: Number.isFinite(frame.inlineStyleChars) ? frame.inlineStyleChars : null, + complete_event_ms: +frame.completeEventMs.toFixed(3), + page_ops: frame.pageOps, + groups_ms: serializeDurationMap(frame.groups, allGroupNames()), + key_events_ms: serializeDurationMap(frame.events, KEY_EVENTS), + topEvents: topDurationEntries(frame.events, "event"), + }; +} + +function summarizeFrameDetails(events, frames, tracePerfOffsetMs, frameWorkSamples) { + if (!FRAME_DETAILS || FRAME_DETAILS_LIMIT === 0) return null; + const frameTotals = frames.map((frame) => ({ + ...frame, + groups: new Map(), + events: new Map(), + completeEventMs: 0, + pageOps: {}, + })); + + let frameWorkIndex = 0; + for (const frame of frameTotals) { + const result = frameWorkForFrame(frameWorkSamples, frame, frameWorkIndex); + frameWorkIndex = result.nextIndex; + frame.pageOps = serializePageOps(result.sample?.ops); + } + + for (const event of events) { + if (event?.ph !== "X" || typeof event.dur !== "number" || !Number.isFinite(event.ts)) continue; + const durationMs = event.dur / 1000; + const midpointPerfNow = ((event.ts + event.dur / 2) / 1000) - tracePerfOffsetMs; + const index = frameIndexAt(frameTotals, midpointPerfNow); + if (index < 0) continue; + const frame = frameTotals[index]; + frame.completeEventMs += durationMs; + addDuration(frame.events, event.name, durationMs); + addEventGroups(frame.groups, event.name, durationMs); + } + + const eventTotals = new Map(); + const groupTotals = new Map(); + const pageTotals = new Map(); + for (const frame of frameTotals) { + for (const [event, total] of frame.events) addAggregate(eventTotals, event, total.count, total.duration_ms); + for (const [group, total] of frame.groups) addAggregate(groupTotals, group, total.count, total.duration_ms); + for (const [operation, total] of Object.entries(frame.pageOps)) { + addAggregate(pageTotals, operation, total.count ?? 0, total.duration_ms ?? 0); + } + } + + const byDtDesc = (a, b) => b.dt - a.dt || b.completeEventMs - a.completeEventMs; + const byDtAsc = (a, b) => a.dt - b.dt || b.completeEventMs - a.completeEventMs; + const serializeFrames = (selected) => selected.map(serializeFrameDetailsFrame); + + return { + frameCount: frameTotals.length, + includedFrames: Math.min(FRAME_DETAILS_LIMIT, frameTotals.length), + slowestFrames: serializeFrames(frameTotals.slice().sort(byDtDesc).slice(0, FRAME_DETAILS_LIMIT)), + fastestFrames: serializeFrames(frameTotals.slice().sort(byDtAsc).slice(0, FRAME_DETAILS_LIMIT)), + topPageOps: topDurationEntries(pageTotals, "operation"), + topGroups: topDurationEntries(groupTotals, "group"), + topEvents: topDurationEntries(eventTotals, "event"), + }; +} + function summarizeBuckets(events, frames, tracePerfOffsetMs, renderStats) { const fallbackTags = renderStats?.dom?.tags ?? null; const fallbackLeaves = renderStats?.dom?.leafCount ?? null; @@ -356,11 +621,6 @@ function summarizeBuckets(events, frames, tracePerfOffsetMs, renderStats) { buckets.set(frame.bucket, bucket); } - const groupByEvent = new Map(); - for (const [group, names] of Object.entries(EVENT_GROUPS)) { - for (const name of names) groupByEvent.set(name, group); - } - for (const event of events) { if (event?.ph !== "X" || typeof event.dur !== "number" || !Number.isFinite(event.ts)) continue; const durationMs = event.dur / 1000; @@ -370,8 +630,7 @@ function summarizeBuckets(events, frames, tracePerfOffsetMs, renderStats) { const bucket = buckets.get(frames[index].bucket); if (!bucket) continue; addDuration(bucket.eventTotals, event.name, durationMs); - const group = groupByEvent.get(event.name); - if (group) addDuration(bucket.groupTotals, group, durationMs); + addEventGroups(bucket.groupTotals, event.name, durationMs); } return ["x1", "x2", "x3", "x4_plus"] @@ -384,7 +643,7 @@ function summarizeBuckets(events, frames, tracePerfOffsetMs, renderStats) { events_ms_per_frame[event] = +((bucket.eventTotals.get(event)?.duration_ms ?? 0) / frameCount).toFixed(4); } const groups_ms_per_frame = {}; - for (const group of Object.keys(EVENT_GROUPS)) { + for (const group of allGroupNames()) { groups_ms_per_frame[group] = +((bucket.groupTotals.get(group)?.duration_ms ?? 0) / frameCount).toFixed(4); } const tags_p50 = {}; @@ -459,6 +718,223 @@ async function stopTrace(cdp) { }); } +function performanceMetric(metrics, name) { + return metrics?.metrics?.find((metric) => metric.name === name)?.value; +} + +async function traceClockOffset(cdp, page) { + try { + const [metrics, perfNow] = await Promise.all([ + cdp.send("Performance.getMetrics"), + page.evaluate(() => performance.now()), + ]); + const timestampSeconds = performanceMetric(metrics, "Timestamp"); + return Number.isFinite(timestampSeconds) ? (timestampSeconds * 1000) - perfNow : 0; + } catch { + return 0; + } +} + +async function startLayerTracking(cdp) { + const state = { layers: [], errors: [] }; + cdp.on("LayerTree.layerTreeDidChange", (payload) => { + if (Array.isArray(payload?.layers)) state.layers = payload.layers; + }); + try { + await cdp.send("DOM.enable"); + await cdp.send("LayerTree.enable"); + } catch (error) { + state.errors.push(String(error?.message ?? error)); + } + return state; +} + +function attrValue(attrs, name) { + if (!Array.isArray(attrs)) return ""; + for (let i = 0; i < attrs.length; i += 2) { + if (attrs[i] === name) return attrs[i + 1] ?? ""; + } + return ""; +} + +async function describeLayerNode(cdp, backendNodeId) { + if (!backendNodeId) return null; + try { + const { node } = await cdp.send("DOM.describeNode", { backendNodeId }); + return { + nodeName: node?.nodeName ?? "", + id: attrValue(node?.attributes, "id"), + className: attrValue(node?.attributes, "class"), + }; + } catch { + return null; + } +} + +async function compositingReasons(cdp, layerId) { + try { + const result = await cdp.send("LayerTree.compositingReasons", { layerId }); + return result?.compositingReasons ?? []; + } catch { + return []; + } +} + +async function mapLimit(items, limit, fn) { + const out = new Array(items.length); + let next = 0; + const workers = Array.from({ length: Math.min(limit, items.length) }, async () => { + while (next < items.length) { + const index = next; + next += 1; + out[index] = await fn(items[index], index); + } + }); + await Promise.all(workers); + return out; +} + +function nodeLayerGroup(node) { + if (!node) return "unknown"; + const nodeName = String(node.nodeName || "").toLowerCase(); + const classNames = String(node.className || "").split(/\s+/).filter(Boolean); + const hasClass = (name) => classNames.includes(name); + + if (["b", "i", "s", "u", "q"].includes(nodeName)) return `leaf:${nodeName}`; + if (hasClass("polycss-scene")) return "polycss-scene"; + if (hasClass("polycss-camera")) return "polycss-camera"; + if (hasClass("polycss-mesh")) return "polycss-mesh"; + if (hasClass("polycss-bucket")) return "polycss-bucket"; + if (hasClass("polycss-voxel-face")) return "polycss-voxel-face"; + if (node.id === "fps") return "overlay:fps"; + if (nodeName) return nodeName; + return "unknown"; +} + +function addReasonCounts(map, reasons) { + for (const reason of reasons ?? []) { + map.set(reason, (map.get(reason) ?? 0) + 1); + } +} + +function serializeReasonCounts(map, limit = 12) { + return [...map.entries()] + .map(([reason, count]) => ({ reason, count })) + .sort((a, b) => b.count - a.count || a.reason.localeCompare(b.reason)) + .slice(0, limit); +} + +function addLayerAggregate(map, info) { + const group = nodeLayerGroup(info.node); + const area = (info.width ?? 0) * (info.height ?? 0); + const aggregate = map.get(group) ?? { + group, + layerCount: 0, + drawsContentCount: 0, + invisibleCount: 0, + totalArea: 0, + maxArea: 0, + paintCountTotal: 0, + reasonCounts: new Map(), + sampleNodes: new Map(), + }; + + aggregate.layerCount += 1; + if (info.drawsContent) aggregate.drawsContentCount += 1; + if (info.invisible) aggregate.invisibleCount += 1; + aggregate.totalArea += area; + aggregate.maxArea = Math.max(aggregate.maxArea, area); + aggregate.paintCountTotal += Number(info.paintCount) || 0; + addReasonCounts(aggregate.reasonCounts, info.compositingReasons); + if (info.node) { + const key = [ + info.node.nodeName, + info.node.id ? `#${info.node.id}` : "", + info.node.className ? `.${String(info.node.className).split(/\s+/).filter(Boolean).join(".")}` : "", + ].join(""); + if (key) aggregate.sampleNodes.set(key, (aggregate.sampleNodes.get(key) ?? 0) + 1); + } + + map.set(group, aggregate); +} + +function serializeLayerAggregate(aggregate) { + const sampleNodes = [...aggregate.sampleNodes.entries()] + .map(([node, count]) => ({ node, count })) + .sort((a, b) => b.count - a.count || a.node.localeCompare(b.node)) + .slice(0, 6); + return { + group: aggregate.group, + layerCount: aggregate.layerCount, + drawsContentCount: aggregate.drawsContentCount, + invisibleCount: aggregate.invisibleCount, + totalArea: +aggregate.totalArea.toFixed(3), + maxArea: +aggregate.maxArea.toFixed(3), + paintCountTotal: +aggregate.paintCountTotal.toFixed(3), + reasonCounts: serializeReasonCounts(aggregate.reasonCounts, 8), + sampleNodes, + }; +} + +async function collectLayerDetails(cdp, state) { + if (!LAYER_DETAILS) return null; + await new Promise((resolveWait) => setTimeout(resolveWait, 50)); + const layers = Array.isArray(state?.layers) ? state.layers : []; + const reasonCounts = new Map(); + const aggregateMap = new Map(); + const layerInfos = await mapLimit(layers, 16, async (layer) => { + const [reasons, node] = await Promise.all([ + compositingReasons(cdp, layer.layerId), + describeLayerNode(cdp, layer.backendNodeId), + ]); + const info = { + layerId: layer.layerId, + parentLayerId: layer.parentLayerId ?? null, + backendNodeId: layer.backendNodeId ?? null, + drawsContent: Boolean(layer.drawsContent), + invisible: Boolean(layer.invisible), + width: layer.width ?? 0, + height: layer.height ?? 0, + area: +(((layer.width ?? 0) * (layer.height ?? 0))).toFixed(3), + paintCount: layer.paintCount ?? null, + node, + compositingReasons: reasons, + }; + addReasonCounts(reasonCounts, reasons); + addLayerAggregate(aggregateMap, info); + return info; + }); + + const layerAreas = layers.map((layer) => (layer.width ?? 0) * (layer.height ?? 0)); + const topLayers = layerInfos + .slice() + .sort((a, b) => b.area - a.area) + .slice(0, LAYER_DETAILS_LIMIT); + const layerAggregates = [...aggregateMap.values()] + .map(serializeLayerAggregate) + .sort((a, b) => + b.layerCount - a.layerCount || + b.drawsContentCount - a.drawsContentCount || + b.totalArea - a.totalArea || + a.group.localeCompare(b.group) + ); + + return { + enabled: true, + layerCount: layers.length, + aggregatedLayerCount: layerInfos.length, + inspectedLayerCount: topLayers.length, + drawsContentCount: layers.filter((layer) => layer.drawsContent).length, + invisibleCount: layers.filter((layer) => layer.invisible).length, + totalArea: +layerAreas.reduce((sum, area) => sum + area, 0).toFixed(3), + maxArea: +(Math.max(0, ...layerAreas)).toFixed(3), + reasonCounts: serializeReasonCounts(reasonCounts, 24), + layerAggregates, + topLayers, + errors: state?.errors ?? [], + }; +} + function pageUrl(port) { if (PAGE === "nonvoxel") { const variantParams = getNonVoxelVariantParams(VARIANT); @@ -469,6 +945,7 @@ function pageUrl(port) { mesh: MESH, mode: MODE, motion: MOTION, + ...(FRAME_DETAILS ? { frameWork: "1" } : {}), ...variantParams, }); return `http://127.0.0.1:${port}/nonvoxel-vanilla.html?${params.toString()}`; @@ -479,6 +956,15 @@ function pageUrl(port) { return `http://127.0.0.1:${port}/perf-${RENDERER}.html?mesh=${encodeURIComponent(MESH)}&mode=${encodeURIComponent(MODE)}&motion=${encodeURIComponent(MOTION)}`; } +function traceOutputPath(repeat) { + if (!TRACE_PATH) return ""; + const abs = resolve(TRACE_PATH); + if (RUNS <= 1) return abs; + return abs.endsWith(".json") + ? abs.replace(/\.json$/, `.r${repeat}.json`) + : `${abs}.r${repeat}.json`; +} + async function runOnce(port, repeat) { const launchOptions = { headless: !HEADED, args: CHROMIUM_ARGS }; if (BROWSER_EXECUTABLE) launchOptions.executablePath = BROWSER_EXECUTABLE; @@ -493,7 +979,7 @@ async function runOnce(port, repeat) { page.on("pageerror", (error) => { pageDiagnostics.push(`[pageerror] ${error?.stack || error?.message || error}`); }); - const cdp = TRACE ? await ctx.newCDPSession(page) : null; + const cdp = (TRACE || LAYER_DETAILS) ? await ctx.newCDPSession(page) : null; const url = pageUrl(port); await page.goto(url, { waitUntil: "load" }); try { @@ -502,11 +988,16 @@ async function runOnce(port, repeat) { const details = pageDiagnostics.length ? `\n${pageDiagnostics.join("\n")}` : ""; throw new Error(`Perf page did not become ready for ${url}.${details}`, { cause: error }); } + const layerState = LAYER_DETAILS && cdp ? await startLayerTracking(cdp) : null; await page.waitForTimeout(WARMUP_MS); const events = TRACE ? await startTrace(cdp) : []; + const fallbackTracePerfOffsetMs = TRACE ? await traceClockOffset(cdp, page) : 0; const startIdx = await page.evaluate(() => window.__perf__.samples.length); - const startPerfNow = await page.evaluate(({ traceEnabled, domSamples }) => { + const startPerfNow = await page.evaluate(({ traceEnabled, domSamples, frameDetails }) => { + if (frameDetails) { + window.__nonvoxelBench?.resetInteractionStats?.(); + } if (domSamples) { window.__polycssDomSamples = []; window.__polycssDomSampling = true; @@ -540,7 +1031,7 @@ async function runOnce(port, repeat) { console.timeStamp("__polycss_trace_analysis_start__"); } return performance.now(); - }, { traceEnabled: TRACE, domSamples: DOM_SAMPLES }); + }, { traceEnabled: TRACE, domSamples: DOM_SAMPLES, frameDetails: FRAME_DETAILS }); await page.waitForTimeout(SAMPLE_MS); const endPerfNow = await page.evaluate((traceEnabled) => { if (traceEnabled) { @@ -557,27 +1048,60 @@ async function runOnce(port, repeat) { polyCount: window.__perf__.polyCount, renderStats: window.__perf__.renderStats ?? null, domSamples: window.__polycssDomSamples ?? null, + frameWorkSamples: window.__nonvoxelBench?.frameWorkSamples?.() ?? null, }), startIdx); + const layerDetails = LAYER_DETAILS && cdp ? await collectLayerDetails(cdp, layerState) : null; await ctx.close(); let tracePerfOffsetMs = 0; let alignedStartPerfNow = startPerfNow; let alignedEndPerfNow = endPerfNow; + let traceAligned = !TRACE; + let traceAlignmentSource = TRACE ? "none" : "disabled"; if (TRACE) { const startMark = findTraceMark(events, "__polycss_trace_analysis_start__"); const endMark = findTraceMark(events, "__polycss_trace_analysis_end__"); - if (!startMark?.args?.data?.startTime || !endMark?.args?.data?.startTime) { - throw new Error("Trace markers were not captured; cannot align trace to rAF samples."); + if (startMark?.args?.data?.startTime && endMark?.args?.data?.startTime) { + tracePerfOffsetMs = (startMark.ts / 1000) - startMark.args.data.startTime; + alignedStartPerfNow = startMark.args.data.startTime; + alignedEndPerfNow = endMark.args.data.startTime; + traceAligned = true; + traceAlignmentSource = "trace-markers"; + } else { + tracePerfOffsetMs = fallbackTracePerfOffsetMs; + traceAlignmentSource = "performance-metrics-fallback"; } - tracePerfOffsetMs = (startMark.ts / 1000) - startMark.args.data.startTime; - alignedStartPerfNow = startMark.args.data.startTime; - alignedEndPerfNow = endMark.args.data.startTime; } const { frames, baseFrameMs } = makeFrames(pageResult.samples, alignedStartPerfNow, alignedEndPerfNow); attachDomSamples(frames, pageResult.domSamples); const frameStats = summarizeFrameTimes(frames); const buckets = summarizeBuckets(events, frames, tracePerfOffsetMs, pageResult.renderStats); const eventTotals = aggregateEventTotals(events, tracePerfOffsetMs, alignedStartPerfNow, alignedEndPerfNow); + const frameDetails = summarizeFrameDetails(events, frames, tracePerfOffsetMs, pageResult.frameWorkSamples); + const traceFile = TRACE ? traceOutputPath(repeat) : ""; + if (traceFile) { + mkdirSync(dirname(traceFile), { recursive: true }); + writeFileSync(traceFile, JSON.stringify({ + traceEvents: events, + displayTimeUnit: "ms", + metadata: { + source: ".agents/skills/chrome-capture-trace/scripts/polycss-trace-analysis.mjs", + page: PAGE, + mesh: MESH, + renderer: RENDERER, + variant: PAGE === "nonvoxel" ? VARIANT : undefined, + mode: MODE, + motion: MOTION, + repeat, + warmupMs: WARMUP_MS, + sampleMs: SAMPLE_MS, + gpuDetails: GPU_DETAILS_MODE, + traceAligned, + traceAlignmentSource, + }, + })); + if (!JSON_ONLY) console.log(`[trace-analysis] wrote ${traceFile}`); + } return { repeat, @@ -588,7 +1112,10 @@ async function runOnce(port, repeat) { mode: MODE, motion: MOTION, trace: TRACE, + traceAligned, + traceAlignmentSource, domSamples: DOM_SAMPLES, + gpuDetails: GPU_DETAILS_MODE, warmup_ms: WARMUP_MS, sample_ms: SAMPLE_MS, baseFrameMs: +baseFrameMs.toFixed(3), @@ -596,8 +1123,13 @@ async function runOnce(port, repeat) { polyCount: pageResult.polyCount, renderStats: pageResult.renderStats, domSamples: DOM_SAMPLES ? pageResult.domSamples : undefined, + frameDetails, + layerDetails, buckets, eventTotals, + outputFiles: { + ...(traceFile ? { trace: traceFile } : {}), + }, }; } finally { await browser.close(); @@ -612,8 +1144,8 @@ function printRun(run) { console.log( `[trace-analysis] ${run.mesh}${run.variant ? ` ${run.variant}` : ""} r${run.repeat} p50=${fmt(run.fps_p50, 1)} p95=${fmt(run.fps_p95, 1)} p99=${fmt(run.frame_time_p99_ms, 1)}ms base=${fmt(run.baseFrameMs, 3)}ms ${bucketText}`, ); - console.log("| Bucket | Frames | Leaves p50 | Tags p50 | dt p50 | dt p95 | style | prePaint | PAC | layerize | drawProps | visible | prepareDraw | draw | raster | script |"); - console.log("| --- | ---: | ---: | --- | ---: | ---: | ---: | ---: | ---: | ---: | ---: | ---: | ---: | ---: | ---: | ---: |"); + console.log("| Bucket | Frames | Leaves p50 | Tags p50 | dt p50 | dt p95 | gpuViz | compositorMain | compositorImpl | style | prePaint | PAC | layerize | drawProps | visible | prepareDraw | draw | raster | script |"); + console.log("| --- | ---: | ---: | --- | ---: | ---: | ---: | ---: | ---: | ---: | ---: | ---: | ---: | ---: | ---: | ---: | ---: | ---: | ---: |"); for (const bucket of run.buckets) { if (bucket.frameCount === 0) continue; const e = bucket.events_ms_per_frame; @@ -625,6 +1157,9 @@ function printRun(run) { tagText(bucket.tags_p50), fmt(bucket.frame_time_p50_ms), fmt(bucket.frame_time_p95_ms), + fmt(g.gpuViz, 4), + fmt(g.compositorMain, 4), + fmt(g.compositorImpl, 4), fmt(g.style, 4), fmt(g.prePaint, 4), fmt(e["PaintArtifactCompositor::Update"], 4), @@ -648,7 +1183,7 @@ const { server, port } = await startServer(); try { if (!JSON_ONLY) { console.log(`[trace-analysis] server :${port}`); - console.log(`[trace-analysis] page=${PAGE} mesh=${MESH} renderer=${RENDERER} variant=${PAGE === "nonvoxel" ? VARIANT : "n/a"} mode=${MODE} motion=${MOTION} trace=${TRACE ? "on" : "off"} domSamples=${DOM_SAMPLES ? "on" : "off"} runs=${RUNS} warmup=${WARMUP_MS}ms sample=${SAMPLE_MS}ms`); + console.log(`[trace-analysis] page=${PAGE} mesh=${MESH} renderer=${RENDERER} variant=${PAGE === "nonvoxel" ? VARIANT : "n/a"} mode=${MODE} motion=${MOTION} trace=${TRACE ? "on" : "off"} domSamples=${DOM_SAMPLES ? "on" : "off"} frameDetails=${FRAME_DETAILS ? "on" : "off"} layerDetails=${LAYER_DETAILS ? "on" : "off"} gpuDetails=${GPU_DETAILS_MODE} runs=${RUNS} warmup=${WARMUP_MS}ms sample=${SAMPLE_MS}ms`); } const runs = []; for (let repeat = 1; repeat <= RUNS; repeat += 1) { @@ -666,6 +1201,8 @@ try { motion: MOTION, trace: TRACE, domSamples: DOM_SAMPLES, + gpuDetails: GPU_DETAILS_MODE, + traceAligned: runs.every((run) => run.traceAligned !== false), browserExecutable: BROWSER_EXECUTABLE || null, chromiumArgs: CHROMIUM_ARGS, softwareBackend: SOFTWARE_BACKEND, diff --git a/.agents/skills/chrome-capture-trace/scripts/trace.mjs b/.agents/skills/chrome-capture-trace/scripts/trace.mjs index bf1a9515..9751ddfd 100755 --- a/.agents/skills/chrome-capture-trace/scripts/trace.mjs +++ b/.agents/skills/chrome-capture-trace/scripts/trace.mjs @@ -40,6 +40,8 @@ Aliases: Examples: node .agents/skills/chrome-capture-trace/scripts/trace.mjs motion --page nonvoxel --mesh glb:Elephant.glb --dom-samples --label elephant + node .agents/skills/chrome-capture-trace/scripts/trace.mjs motion --page nonvoxel --mesh teapot --dom-samples --frame-details --layer-details --gpu-details --trace-out bench/results/teapot.trace.json --label teapot-enriched --report + node .agents/skills/chrome-capture-trace/scripts/trace.mjs motion --page nonvoxel --mesh teapot --gpu-details full --trace-out bench/results/teapot-full-gpu.trace.json --label teapot-full-gpu node .agents/skills/chrome-capture-trace/scripts/trace.mjs drag --mesh teapot --degrees 360 --frame-details --label teapot-drag node .agents/skills/chrome-capture-trace/scripts/trace.mjs generic --url http://127.0.0.1:3000 --action drag --selector "#viewport" node .agents/skills/chrome-capture-trace/scripts/trace.mjs motion --report --markdown-out bench/results/garden.md @@ -183,6 +185,11 @@ function groupHint(group) { compositorMain: "main-thread compositing setup", compositorImpl: "compositor impl-thread work", gpuViz: "GPU/viz drawing pipeline", + gpuVizRenderPass: "GPU/viz render pass drawing", + gpuVizQuads: "GPU/viz quad emission or drawing", + gpuVizTiles: "tile/raster scheduling and playback", + gpuVizSkia: "Skia/GPU drawing backend work", + gpuVizGpuService: "GPU service or swap-buffer work", }; return hints[group] ?? "trace event work"; } @@ -205,17 +212,50 @@ function quickRead(summary) { function topEvents(summary, limit = 12) { const run = firstRun(summary); const raw = summary.trace?.topEvents ?? run.trace?.topEvents ?? run.eventTotals ?? []; + const frames = frameMetrics(summary).frame_count ?? 0; return raw .map((event) => ({ name: event.name ?? event.event ?? "", count: numberOrNull(event.count), duration_ms: numberOrNull(event.duration_ms), - ms_per_frame: numberOrNull(event.ms_per_frame), + ms_per_frame: numberOrNull(event.ms_per_frame) ?? + (Number.isFinite(event.duration_ms) && frames > 0 ? Number(event.duration_ms) / frames : null), })) .filter((event) => event.name) .slice(0, limit); } +function frameDetails(summary) { + const run = firstRun(summary); + if (summary.frameDetails) return summary.frameDetails; + if (run.frameDetails) return run.frameDetails; + if (summary.trace?.slowestFrames) { + return { + frameCount: summary.frames?.count ?? null, + slowestFrames: summary.trace.slowestFrames, + topEvents: summary.trace.topEvents, + }; + } + return null; +} + +function layerDetails(summary) { + const run = firstRun(summary); + return summary.layerDetails ?? run.layerDetails ?? null; +} + +function frameGroups(frame) { + return frame.groups_ms ?? frame.groups ?? {}; +} + +function frameTopEvent(frame) { + const event = frame.topEvents?.[0]; + if (!event) return ""; + const name = event.name ?? event.event ?? ""; + const duration = fmt(numberOrNull(event.duration_ms), 3); + return duration ? `${name} (${duration}ms)` : name; +} + function summaryMetadata(summary) { const run = firstRun(summary); const action = summary.action?.kind ? `${summary.action.kind}` : ""; @@ -227,6 +267,9 @@ function summaryMetadata(summary) { variant: summary.variant ?? run.variant ?? "", mode: summary.mode ?? run.mode ?? "", motion: summary.motion ?? run.motion ?? "", + gpuDetails: summary.gpuDetails ?? run.gpuDetails ?? "", + traceAligned: summary.traceAligned ?? run.traceAligned ?? "", + traceAlignmentSource: run.traceAlignmentSource ?? summary.traceAlignmentSource ?? "", action, url: summary.url ?? run.url ?? "", }; @@ -253,8 +296,14 @@ function summaryMarkdown(summary, file = "") { const frames = frameMetrics(summary); const groups = groupMsPerFrame(summary); const events = topEvents(summary); + const details = frameDetails(summary); + const layers = layerDetails(summary); const contextRows = Object.entries(meta).filter(([, value]) => value); const groupRows = Object.entries(groups).sort((a, b) => b[1] - a[1]); + const slowestFrames = details?.slowestFrames?.slice?.(0, 12) ?? []; + const topPageOps = details?.topPageOps?.slice?.(0, 12) ?? []; + const topLayers = layers?.topLayers?.slice?.(0, 12) ?? []; + const layerAggregates = layers?.layerAggregates?.slice?.(0, 16) ?? []; const lines = [ "# Chrome Trace Report", @@ -284,6 +333,8 @@ function summaryMarkdown(summary, file = "") { "", "## Trace Groups", "", + "Chrome trace durations are inclusive and can contain nested events, so group totals can exceed wall-clock frame time. Use them for attribution and deltas, not as exclusive time slices.", + "", "| Group | ms/frame |", "| --- | ---: |", ...groupRows.map(([group, value]) => `| ${group} | ${fmt(value, 4)} |`), @@ -296,6 +347,107 @@ function summaryMarkdown(summary, file = "") { `| ${event.name.replaceAll("|", "\\|")} | ${fmt(event.count, 0)} | ${fmt(event.duration_ms)} | ${fmt(event.ms_per_frame, 4)} |` ), "", + ...(details ? [ + "## Frame Details", + "", + `Frame details captured: ${fmt(details.frameCount, 0) || "unknown"} frames.`, + "", + ...(topPageOps.length ? [ + "### Page Work", + "", + "| Operation | Count | Duration ms | ms/frame |", + "| --- | ---: | ---: | ---: |", + ...topPageOps.map((op) => { + const name = op.operation ?? op.name ?? ""; + const duration = numberOrNull(op.duration_ms); + const count = numberOrNull(op.count); + const perFrame = Number.isFinite(duration) && Number.isFinite(details.frameCount) && details.frameCount > 0 + ? duration / details.frameCount + : null; + return `| ${String(name).replaceAll("|", "\\|")} | ${fmt(count, 0)} | ${fmt(duration)} | ${fmt(perFrame, 4)} |`; + }), + "", + ] : []), + ...(slowestFrames.length ? [ + "### Slowest Frames", + "", + "| Frame | Bucket | dt ms | script | compositorMain | compositorImpl | gpuViz | top event |", + "| ---: | --- | ---: | ---: | ---: | ---: | ---: | --- |", + ...slowestFrames.map((frame) => { + const fg = frameGroups(frame); + return [ + `| ${fmt(numberOrNull(frame.index), 0)}`, + frame.bucket ?? "", + fmt(numberOrNull(frame.dt_ms)), + fmt(numberOrNull(fg.script), 4), + fmt(numberOrNull(fg.compositorMain), 4), + fmt(numberOrNull(fg.compositorImpl), 4), + fmt(numberOrNull(fg.gpuViz), 4), + `${frameTopEvent(frame).replaceAll("|", "\\|")} |`, + ].join(" | "); + }), + "", + ] : []), + ] : []), + ...(layers ? [ + "## Layer Details", + "", + "| Metric | Value |", + "| --- | ---: |", + `| layerCount | ${fmt(numberOrNull(layers.layerCount), 0)} |`, + `| aggregatedLayerCount | ${fmt(numberOrNull(layers.aggregatedLayerCount), 0)} |`, + `| inspectedLayerCount | ${fmt(numberOrNull(layers.inspectedLayerCount), 0)} |`, + `| drawsContentCount | ${fmt(numberOrNull(layers.drawsContentCount), 0)} |`, + `| invisibleCount | ${fmt(numberOrNull(layers.invisibleCount), 0)} |`, + `| maxArea | ${fmt(numberOrNull(layers.maxArea), 0)} |`, + "", + ...(layerAggregates.length ? [ + "### Layer Aggregates", + "", + "| Group | Layers | Draws | Total area | Max area | Paints | Top reasons |", + "| --- | ---: | ---: | ---: | ---: | ---: | --- |", + ...layerAggregates.map((entry) => { + const reasons = (entry.reasonCounts ?? []) + .slice(0, 3) + .map((reason) => `${reason.reason} (${reason.count})`) + .join("; "); + return [ + `| ${String(entry.group).replaceAll("|", "\\|")}`, + fmt(numberOrNull(entry.layerCount), 0), + fmt(numberOrNull(entry.drawsContentCount), 0), + fmt(numberOrNull(entry.totalArea), 0), + fmt(numberOrNull(entry.maxArea), 0), + fmt(numberOrNull(entry.paintCountTotal), 0), + `${reasons.replaceAll("|", "\\|")} |`, + ].join(" | "); + }), + "", + ] : []), + ...(layers.reasonCounts?.length ? [ + "### Compositing Reasons", + "", + "| Reason | Count |", + "| --- | ---: |", + ...layers.reasonCounts.slice(0, 12).map((entry) => + `| ${String(entry.reason).replaceAll("|", "\\|")} | ${fmt(numberOrNull(entry.count), 0)} |` + ), + "", + ] : []), + ...(topLayers.length ? [ + "### Largest Layers", + "", + "| Layer | Node | Size | Draws | Paints | Reasons |", + "| --- | --- | ---: | --- | ---: | --- |", + ...topLayers.map((layer) => { + const node = layer.node + ? `${layer.node.nodeName}${layer.node.id ? `#${layer.node.id}` : ""}${layer.node.className ? `.${String(layer.node.className).split(/\s+/).filter(Boolean).join(".")}` : ""}` + : ""; + const reasons = (layer.compositingReasons ?? []).slice(0, 3).join("; "); + return `| ${layer.layerId} | ${node.replaceAll("|", "\\|")} | ${fmt(numberOrNull(layer.width), 0)}x${fmt(numberOrNull(layer.height), 0)} | ${layer.drawsContent ? "yes" : "no"} | ${fmt(numberOrNull(layer.paintCount), 0)} | ${reasons.replaceAll("|", "\\|")} |`; + }), + "", + ] : []), + ] : []), ]; return `${lines.join("\n")}\n`; diff --git a/AGENTS.md b/AGENTS.md index 40f22d9a..270cc098 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -23,7 +23,7 @@ Public API is **mirrored** across React and Vue. Adding a hook on one side witho **One visible `Polygon` → one leaf DOM element.** Leaves use canonical CSS primitives where possible and move scale into `matrix3d`; clipped solids use fixed primitives because their paint geometry becomes unstable when collapsed to 1px. Textured polygons still pack their local-2D bounding rect (`canvasW × canvasH`) into the atlas. The HTML tag *is* the render strategy — the renderer picks one tag per polygon based on its shape and material. -Raw MagicaVoxel `.vox` sources have a narrower baked-mode fast path: `parseVox` still returns the polygon mesh for bounds, fallback rendering, and public handles, but also preserves a `PolyVoxelSource` marker. Eligible vanilla meshes render visible voxel quads as `` leaves inside persistent signed-face wrappers (`t`, `b`, `fl`, `br`, `fr`, `bl`), with canonical `matrix3d(...)` transforms and projected tile4 scanline order inside each mounted face. Camera-facing culling mounts/removes those face wrappers instead of removing thousands of live brush children from the mesh root. `.vox` normalization snaps to the nearest integer CSS cell size so direct voxel matrices use integer pixel coordinates without any scale wrapper; same-color shared voxel edges get a tiny matrix-space overscan to hide compositor seams without fattening exterior silhouette edges. Brush colors still receive baked Lambert shading from the scene lights. Dynamic lighting, shadows, stable DOM animation, non-exact voxel geometry, and geometry replaced via `setPolygons` fall back to the polygon renderer. +Raw MagicaVoxel `.vox` sources have a narrower baked-mode fast path: `parseVox` still returns the polygon mesh for bounds, fallback rendering, and public handles, but also preserves a `PolyVoxelSource` marker. Eligible vanilla, React, and Vue meshes render visible voxel quads as `` leaves inside persistent signed-face wrappers (`t`, `b`, `fl`, `br`, `fr`, `bl`), with canonical `matrix3d(...)` transforms and projected tile4 scanline order inside each mounted face. Camera-facing culling mounts/removes those face wrappers instead of removing thousands of live brush children from the mesh root. `.vox` normalization snaps to the nearest integer CSS cell size so direct voxel matrices use integer pixel coordinates without any scale wrapper; same-color shared voxel edges get a tiny matrix-space overscan to hide compositor seams without fattening exterior silhouette edges. Brush colors still receive baked Lambert shading from the scene lights. Callers may opt into lossy `.vox` palette merging and small local face-region cleanup before greedy meshing when authored palettes contain visually redundant colors; gallery and builder route this through Mesh resolution so `Lossy` may simplify palettes while `Lossless` keeps palette colors exact. Dynamic lighting, shadows, stable DOM animation, non-exact voxel geometry, and geometry replaced via `setPolygons` fall back to the polygon renderer. Voxel-shaped meshes are the exception to "all polygons stay mounted": meshes with at most the six axis-aligned face normals, excluding helpers/auto-center-exempt meshes, automatically mount only camera-facing leaves and patch the mounted set when the camera or mesh rotation crosses a visible-normal boundary. Non-voxel meshes keep the full leaf DOM mounted; broad camera-dependent DOM culling is not worth the mutation cost. @@ -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. 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. +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 is internal: detected shared solid edges get up to `1.5` CSS px of per-edge overscan, fitted to the polygon plan, rather than inflating every side of each participating polygon. It is not exposed as a scene, mesh, custom-element, or atlas renderer option. The `.vox` fast path emits plain `` elements inside `.polycss-voxel-face` wrappers. They intentionally reuse the cheap quad tag; each visible quad has one `matrix3d(...)`, with same-color shared-edge overscan folded into the local left/top/width/height before matrix generation. The face wrappers are grouping nodes for cheap add/remove and are not render-strategy leaves. 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. @@ -66,7 +66,7 @@ All solid/atlas tags work in both modes. The `.vox` direct-matrix fast path is b This is the load-bearing constraint behind the whole engine. **JavaScript should not run per-frame to paint polygons when the motion can be expressed as a scene, mesh, camera, or light update.** Once the scene is built and the atlas is rasterised, the browser drives most rendering through CSS — `matrix3d` transforms, `calc()`-driven custom properties, `background-blend-mode`, `border-shape`, etc. -The current exception is imported skeletal animation. glTF/GLB skinning changes each polygon independently, so the vanilla stable-DOM animation path samples the active clip in JS, keeps the leaf set mounted, caches baked stable-triangle transform frames, and refreshes baked color on a cadence. On WebKit/Safari, where stable CSS triangles fall through to solid atlas `` leaves, same-topology animation updates keep the existing atlas elements and bitmap URLs mounted, cache transform frames once warmed, and hide briefly degenerate atlas triangles only until the next valid frame. That optimized path is the default; do not add a user-facing "baseline vs optimized" toggle or maintain a legacy slow path in product UI. +The current exception is imported skeletal animation. glTF/GLB skinning changes each polygon independently, so the vanilla stable-DOM animation path samples the active clip in JS, keeps the leaf set mounted, caches baked stable-triangle transform frames, and pins each mounted triangle's baked color while transforms animate. Recomputing Lambert from every deformed low-poly face normal creates visible color pumping, so color refresh is internal opt-in rather than the default animation behavior. On WebKit/Safari, where stable CSS triangles fall through to solid atlas `` leaves, same-topology animation updates keep the existing atlas elements and bitmap URLs mounted, cache transform frames once warmed, and hide briefly degenerate atlas triangles only until the next valid frame. That optimized path is the default; do not add a user-facing "baseline vs optimized" toggle or maintain a legacy slow path in product UI. | Where JS runs | Where JS does NOT run | |---|---| @@ -97,7 +97,7 @@ The React and Vue packages are mirror images. **Any public API change in one mus When you change `packages/polycss` or `packages/core` in a way that affects the public surface (new option, renamed export, changed default), the React and Vue bindings update in the same PR. Don't ship a polycss change that leaves the bindings stale. -**Renderer-owned browser glue.** The canvas atlas pipeline (`buildAtlasPages` + helpers), browser-feature detection (`isBorderShapeSupported`, `isSolidTriangleSupported`, `resolveSolidTrianglePrimitive`), and the injected `.polycss-scene` / `.polycss-camera` base styles exist as **independent copies** in three places: `packages/polycss/src/render/atlas/`, `packages/react/src/scene/atlas/`, `packages/vue/src/scene/atlas/` (plus three sibling `styles.ts` files). This is deliberate — each renderer is self-contained on its dep graph (React/Vue do not import from polycss). The trade-off is that a bug fix in any of these files MUST be mirrored into the other two. Coverage is pinned per copy by the co-located test files. +**Renderer-owned browser glue.** The canvas atlas pipeline (`buildAtlasPages` + helpers), browser-feature detection (`isBorderShapeSupported`, `isSolidTriangleSupported`, `resolveSolidTrianglePrimitive`), direct voxel renderer (`voxelRenderer.ts`), and injected `.polycss-scene` / `.polycss-camera` base styles exist as **independent copies** across the three renderers. This includes `packages/polycss/src/render/atlas/`, `packages/react/src/scene/atlas/`, `packages/vue/src/scene/atlas/`, the three renderer-local `voxelRenderer.ts` files, and the three sibling `styles.ts` files. This is deliberate — each renderer is self-contained on its dep graph (React/Vue do not import from polycss). The trade-off is that a bug fix in any of these files MUST be mirrored into the other two. Coverage is pinned per copy by the co-located test files. Before opening a PR: @@ -105,7 +105,7 @@ Before opening a PR: - [ ] If I touched a Vue component/composable, the React component/hook matches. - [ ] If I added an option to a `polycss` factory, both bindings expose it. - [ ] If I renamed a `core` export, every package that imports it is updated. -- [ ] If I touched the canvas atlas pipeline (`rasterise.ts` / `buildAtlasPages.ts`) or browser-feature detection in ONE renderer, the same fix lands in the other two renderers (polycss + react + vue) in this PR. +- [ ] If I touched the canvas atlas pipeline (`rasterise.ts` / `buildAtlasPages.ts`), browser-feature detection, or direct voxel renderer in ONE renderer, the same fix lands in the other two renderers (polycss + react + vue) in this PR. - [ ] If I touched any of the three `styles.ts` (`packages/polycss/src/styles/styles.ts`, `packages/react/src/styles/styles.ts`, `packages/vue/src/styles/styles.ts`), the other two are consistent — CSS rules cover every emitted tag for both lighting modes, and shared properties like `will-change: transform` on `.polycss-scene` exist in all three. - [ ] Website docs (`website/src/content/docs/**`) and READMEs reflect any user-visible change. - [ ] If I changed a render strategy, lighting mode, naming convention, or the JS-in-render-loop rules, `AGENTS.md` reflects the new state in this same PR. diff --git a/README.md b/README.md index 79624af8..e46116dd 100644 --- a/README.md +++ b/README.md @@ -72,7 +72,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. +- Solid seam bleed is automatic on detected shared solid edges. - `strategies` can disable selected render strategies for diagnostics. - `autoCenter` rotates around the rendered mesh bounds instead of world origin. @@ -164,6 +164,15 @@ PolyCSS renders in the DOM, so performance is mostly determined by how many poly - Voxel-shaped meshes mount only camera-facing leaves when the mesh is eligible. - `meshResolution: "lossy"` merges compatible polygons, then may spend a small split budget to repair high-risk seams. +Renderer internals: + +Each visible polygon is emitted as one leaf element; the renderer chooses the least expensive CSS primitive that can represent the polygon, then uses `matrix3d(...)` to place that primitive in 3D space. + +- `` uses `background: currentColor` on a fixed box for solid rectangles and stable quads. +- `` uses `corner-shape` for stable triangles and beveled-corner solids, with a `border-width` triangle fallback when needed. +- `` clips solid polygons with `border-shape: polygon(...)` when the browser supports it. +- `` maps a packed texture-atlas slice with `background-image`, and is the fallback for textured or unsupported shapes. + ## Packages | Package | Description | diff --git a/bench/animated-human-bench.mjs b/bench/animated-human-bench.mjs index 8b391768..e63592ed 100644 --- a/bench/animated-human-bench.mjs +++ b/bench/animated-human-bench.mjs @@ -88,7 +88,7 @@ const ANIMATION_FRAME_CACHE_FRAMES = optNum( const KEYFRAME_SAMPLES = optNum("keyframe-samples", optNum("keyframeSamples", 24)); const DEFAULT_STABLE_TRIANGLE_COLOR_STEPS = 8; const DEFAULT_STABLE_TRIANGLE_COLOR_POLICY = "cadence"; -const DEFAULT_STABLE_TRIANGLE_COLOR_FREEZE_FRAMES = 12; +const DEFAULT_STABLE_TRIANGLE_COLOR_FREEZE_FRAMES = 0; const DEFAULT_STABLE_TRIANGLE_COLOR_BUDGET = 0.16; const DEFAULT_STABLE_TRIANGLE_COLOR_MAX_AGE = 8; const DEFAULT_STABLE_TRIANGLE_COLOR_MAX_STEP = 8; diff --git a/bench/animated-human.html b/bench/animated-human.html index c99c8289..4e05f913 100644 --- a/bench/animated-human.html +++ b/bench/animated-human.html @@ -94,7 +94,7 @@ stableTriangleColorPolicyRaw === "adaptive" ? "adaptive" : "cadence"; const stableTriangleColorFreezeFrames = params.has("stableTriangleColorFreezeFrames") ? numberParam("stableTriangleColorFreezeFrames", 1) - : 12; + : 0; const stableTriangleColorBudget = params.has("stableTriangleColorBudget") ? numberParam("stableTriangleColorBudget", 0) : (stableTriangleColorPolicy === "adaptive" ? 0.16 : 0); @@ -297,7 +297,9 @@ disabled: false, lastAppliedFrameIndex: -1, skippedFrameApplies: 0, - colorRefreshFrames: Math.max(1, stableTriangleColorFreezeFrames || 12), + colorRefreshFrames: stableTriangleColorFreezeFrames > 0 + ? Math.max(1, stableTriangleColorFreezeFrames) + : 0, }; } @@ -528,6 +530,7 @@ (elapsedClipTime / clip.duration) * progressiveStyleCache.frames.length, ) % progressiveStyleCache.frames.length; const shouldRefreshColor = + progressiveStyleCache.colorRefreshFrames > 0 && animationFrameCount % progressiveStyleCache.colorRefreshFrames === 0; let cacheHit = false; let cacheMs = 0; diff --git a/bench/minecraft-movement-bench.mjs b/bench/minecraft-movement-bench.mjs new file mode 100644 index 00000000..0ce8181b --- /dev/null +++ b/bench/minecraft-movement-bench.mjs @@ -0,0 +1,737 @@ +#!/usr/bin/env node +import { createServer } from "node:http"; +import { mkdirSync, writeFileSync } from "node:fs"; +import { readFile, stat } from "node:fs/promises"; +import { dirname, extname, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; +import { chromium } from "playwright"; +import { chromiumArgsWithGpuDefault } from "./chromium-defaults.mjs"; + +const __dirname = dirname(fileURLToPath(import.meta.url)); +const repoRoot = resolve(__dirname, ".."); +const vanillaDistDir = resolve(repoRoot, "examples/vanilla/dist"); + +const argv = process.argv.slice(2); + +function flag(name) { + return argv.indexOf(`--${name}`); +} + +function hasFlag(name) { + return flag(name) >= 0 || argv.includes(`--${name}=true`); +} + +function optStr(name, dflt = "") { + const exact = flag(name); + if (exact >= 0) return argv[exact + 1] ?? dflt; + const prefixed = argv.find((arg) => arg.startsWith(`--${name}=`)); + return prefixed ? prefixed.slice(name.length + 3) : dflt; +} + +function optNum(name, dflt) { + const raw = optStr(name); + if (!raw) return dflt; + const value = Number(raw); + return Number.isFinite(value) ? value : dflt; +} + +function optAll(name) { + const values = []; + for (let i = 0; i < argv.length; i += 1) { + const arg = argv[i]; + if (arg === `--${name}` && argv[i + 1]) { + values.push(argv[i + 1]); + i += 1; + } else if (arg.startsWith(`--${name}=`)) { + values.push(arg.slice(name.length + 3)); + } + } + return values; +} + +const HELP = hasFlag("help") || hasFlag("h"); +const TARGET_URL = optStr("url"); +const MOTION = optStr("motion", "walk-look"); +const WARMUP_MS = optNum("warmup", 1500); +const SAMPLE_MS = optNum("sample", 2500); +const SETTLE_MS = optNum("settle", 300); +const STEPS = Math.max(1, Math.round(optNum("steps", 120))); +const VIEWPORT = optStr("viewport", "1280x800"); +const TRACE_OUT = optStr("trace-out", "bench/results/minecraft-movement-trace.json"); +const SUMMARY_OUT = optStr("summary-out", "bench/results/minecraft-movement-summary.json"); +const MARKDOWN_OUT = optStr("markdown-out", "bench/results/minecraft-movement-report.md"); +const BROWSER_EXECUTABLE = optStr("browser-executable"); +const HEADLESS = !hasFlag("headed"); +const SOFTWARE_BACKEND = hasFlag("software-backend"); +const CHROMIUM_ARGS = chromiumArgsWithGpuDefault([ + ...optAll("chromium-arg"), + ...optAll("chromium-args").flatMap((value) => value.split(/\s+/).filter(Boolean)), +], { softwareBackend: SOFTWARE_BACKEND }); + +const MARK_START = "__minecraft_movement_start__"; +const MARK_END = "__minecraft_movement_end__"; +const WORLD_MESH_SELECTOR = '[data-poly-mesh-id="polycraft-world"], [data-poly-mesh-id^="polycraft-world-"]'; + +const TRACE_CATEGORIES = [ + "devtools.timeline", + "disabled-by-default-devtools.timeline", + "blink", + "blink.user_timing", + "cc", + "gpu", + "viz", + "renderer.scheduler", +].join(","); + +const EVENT_GROUPS = { + style: ["UpdateLayoutTree", "RecalculateStyles"], + layout: ["Layout"], + prePaint: ["PrePaint"], + paint: ["Paint"], + raster: ["RasterTask", "ImageDecodeTask", "Decode Image"], + script: ["FunctionCall", "EvaluateScript", "EventDispatch", "TimerFire", "FireAnimationFrame"], + compositorMain: [ + "ProxyMain::BeginMainFrame", + "WebFrameWidgetImpl::UpdateLifecycle", + "PaintArtifactCompositor::Update", + "Layerize", + "Commit", + "ProxyImpl::ReadyToCommit", + ], + compositorImpl: [ + "LayerTreeImpl::UpdateDrawProperties", + "LayerTreeImpl::UpdateDrawProperties::CalculateDrawProperties", + "draw_property_utils::ComputeDrawPropertiesOfVisibleLayers", + "LayerTreeHostImpl::PrepareToDraw", + "MainFrame.Draw", + "SubmitCompositorFrame", + ], + gpuViz: [ + "Graphics.Pipeline", + "DisplayScheduler::DrawAndSwap", + "DirectRenderer::DrawFrame", + "DirectRenderer::DrawRenderPass", + ], +}; + +const MIME = { + ".html": "text/html; charset=utf-8", + ".js": "application/javascript; charset=utf-8", + ".mjs": "application/javascript; charset=utf-8", + ".css": "text/css; charset=utf-8", + ".json": "application/json; charset=utf-8", + ".png": "image/png", + ".jpg": "image/jpeg", + ".jpeg": "image/jpeg", + ".svg": "image/svg+xml", + ".woff": "font/woff", + ".woff2": "font/woff2", +}; + +function printHelp() { + console.log(`Usage: + pnpm bench:minecraft-movement + node bench/minecraft-movement-bench.mjs [options] + +Options: + --url Trace an existing server instead of serving examples/vanilla/dist. + --motion wait | walk | look | walk-look. Default: walk-look + --warmup Warmup before tracing. Default: 1500 + --sample Movement window. Default: 2500 + --settle Trace settle time after movement. Default: 300 + --steps Mouse movement steps. Default: 120 + --viewport Browser viewport. Default: 1280x800 + --trace-out Raw Chrome trace JSON. Default: bench/results/minecraft-movement-trace.json + --summary-out Summary JSON. Default: bench/results/minecraft-movement-summary.json + --markdown-out Markdown report. Default: bench/results/minecraft-movement-report.md + --browser-executable Use a specific Chrome/Chromium executable. + --software-backend Disable the default GPU Chromium args. + --chromium-arg Extra Chromium arg, repeatable. + --headed Run headed. +`); +} + +function parseViewport(value) { + const match = /^(\d+)x(\d+)$/i.exec(value); + if (!match) return { width: 1280, height: 800 }; + return { width: Number(match[1]), height: Number(match[2]) }; +} + +function quantile(values, q) { + const sorted = values.filter(Number.isFinite).sort((a, b) => a - b); + if (sorted.length === 0) return null; + const index = (sorted.length - 1) * q; + const lo = Math.floor(index); + const hi = Math.ceil(index); + if (lo === hi) return sorted[lo]; + return sorted[lo] + (sorted[hi] - sorted[lo]) * (index - lo); +} + +function addDuration(map, name, durationMs) { + const entry = map.get(name) ?? { count: 0, durationMs: 0 }; + entry.count += 1; + entry.durationMs += durationMs; + map.set(name, entry); +} + +function serializeTotals(map, limit = 20) { + return [...map.entries()] + .map(([name, entry]) => ({ + name, + count: entry.count, + duration_ms: +entry.durationMs.toFixed(4), + })) + .sort((a, b) => b.duration_ms - a.duration_ms) + .slice(0, limit); +} + +function findTraceMark(events, name) { + return events.find((event) => event?.name === name && Number.isFinite(event?.args?.data?.startTime)) ?? + events.find((event) => event?.name === "TimeStamp" && event?.args?.data?.message === name); +} + +function eventPerfNow(event, tracePerfOffsetMs) { + return ((event.ts + (event.dur ?? 0) / 2) / 1000) - tracePerfOffsetMs; +} + +function framesFromSamples(samples, startPerfNow, endPerfNow) { + return samples + .filter((sample) => Number.isFinite(sample?.dt) && sample.dt > 0 && sample.dt < 2000) + .filter((sample) => sample.t - sample.dt >= startPerfNow && sample.t <= endPerfNow) + .map((sample, index) => ({ + index, + start: sample.t - sample.dt, + end: sample.t, + dt: sample.dt, + groups: new Map(), + events: new Map(), + })); +} + +function frameIndexAt(frames, perfNow) { + let lo = 0; + let hi = frames.length - 1; + while (lo <= hi) { + const mid = (lo + hi) >> 1; + const frame = frames[mid]; + if (perfNow < frame.start) hi = mid - 1; + else if (perfNow > frame.end) lo = mid + 1; + else return mid; + } + return -1; +} + +function summarizeFrames(frames, actionWindowMs) { + const dts = frames.map((frame) => frame.dt); + const p50 = quantile(dts, 0.5) ?? 0; + const p95 = quantile(dts, 0.95) ?? 0; + const p99 = quantile(dts, 0.99) ?? 0; + const over16 = dts.filter((dt) => dt > 16.7).length; + const over33 = dts.filter((dt) => dt > 33.3).length; + return { + count: frames.length, + fps_mean: actionWindowMs > 0 ? +(frames.length / (actionWindowMs / 1000)).toFixed(2) : 0, + fps_p50: p50 > 0 ? +(1000 / p50).toFixed(2) : 0, + frame_time_p50_ms: +p50.toFixed(3), + frame_time_p95_ms: +p95.toFixed(3), + frame_time_p99_ms: +p99.toFixed(3), + frames_over_16_7_ms: over16, + frames_over_33_3_ms: over33, + }; +} + +function summarizeEvents(events, tracePerfOffsetMs, startPerfNow, endPerfNow, frames) { + const eventToGroup = new Map(); + for (const [group, names] of Object.entries(EVENT_GROUPS)) { + for (const name of names) eventToGroup.set(name, group); + } + + const groupTotals = new Map(); + const eventTotals = new Map(); + let completeEventCount = 0; + let completeDurationMs = 0; + + for (const event of events) { + if (event?.ph !== "X" || typeof event.dur !== "number" || !Number.isFinite(event.ts)) continue; + const perfNow = eventPerfNow(event, tracePerfOffsetMs); + if (perfNow < startPerfNow || perfNow > endPerfNow) continue; + const durationMs = event.dur / 1000; + completeEventCount += 1; + completeDurationMs += durationMs; + addDuration(eventTotals, event.name, durationMs); + const group = eventToGroup.get(event.name); + if (group) addDuration(groupTotals, group, durationMs); + + const frameIndex = frameIndexAt(frames, perfNow); + if (frameIndex >= 0) { + const frame = frames[frameIndex]; + addDuration(frame.events, event.name, durationMs); + if (group) addDuration(frame.groups, group, durationMs); + } + } + + const slowestFrames = frames + .map((frame) => ({ + index: frame.index, + start_ms: +frame.start.toFixed(3), + end_ms: +frame.end.toFixed(3), + dt_ms: +frame.dt.toFixed(3), + groups: Object.fromEntries( + Object.keys(EVENT_GROUPS).map((group) => [group, +(frame.groups.get(group)?.durationMs ?? 0).toFixed(4)]), + ), + topEvents: serializeTotals(frame.events, 8), + })) + .sort((a, b) => b.dt_ms - a.dt_ms) + .slice(0, 12); + + return { + complete_event_count: completeEventCount, + complete_duration_ms: +completeDurationMs.toFixed(3), + groups: Object.fromEntries( + Object.keys(EVENT_GROUPS).map((group) => { + const total = groupTotals.get(group); + return [group, { + count: total?.count ?? 0, + duration_ms: +(total?.durationMs ?? 0).toFixed(4), + ms_per_frame: frames.length ? +((total?.durationMs ?? 0) / frames.length).toFixed(4) : null, + }]; + }), + ), + topEvents: serializeTotals(eventTotals, 30), + slowestFrames, + }; +} + +function resolveOutputPath(path) { + return resolve(repoRoot, path); +} + +function safeStaticPath(pathname) { + const decoded = decodeURIComponent(pathname); + if (decoded.includes("\0")) return null; + const normalized = decoded.replace(/\/+/g, "/"); + const filePath = normalized.endsWith("/") ? `${normalized}index.html` : normalized; + const absolute = resolve(vanillaDistDir, `.${filePath}`); + if (!absolute.startsWith(`${vanillaDistDir}/`) && absolute !== vanillaDistDir) return null; + return absolute; +} + +async function assertBuiltMinecraftExample() { + try { + await stat(resolve(vanillaDistDir, "minecraft/index.html")); + } catch { + throw new Error("Missing examples/vanilla/dist/minecraft/index.html. Run `pnpm --filter @layoutit/polycss-examples-vanilla build` first, or pass --url."); + } +} + +function startStaticServer() { + return new Promise((resolveStart, rejectStart) => { + const server = createServer(async (req, res) => { + try { + const requestUrl = new URL(req.url ?? "/", "http://127.0.0.1"); + const absolute = safeStaticPath(requestUrl.pathname); + if (!absolute) { + res.writeHead(403); + res.end("Forbidden"); + return; + } + const data = await readFile(absolute); + res.writeHead(200, { + "Content-Type": MIME[extname(absolute).toLowerCase()] || "application/octet-stream", + "Cache-Control": "no-store", + }); + res.end(data); + } catch (error) { + res.writeHead(404); + res.end(String(error?.message ?? error)); + } + }); + server.on("error", rejectStart); + server.listen(0, "127.0.0.1", () => { + const address = server.address(); + resolveStart({ server, port: typeof address === "object" ? address.port : 0 }); + }); + }); +} + +function stopServer(server) { + return new Promise((resolveStop) => server.close(() => resolveStop())); +} + +async function startTrace(cdp) { + const events = []; + cdp.on("Tracing.dataCollected", (payload) => { + if (Array.isArray(payload.value)) events.push(...payload.value); + }); + await cdp.send("Performance.enable"); + await cdp.send("Tracing.start", { + transferMode: "ReportEvents", + categories: TRACE_CATEGORIES, + }); + return events; +} + +async function stopTrace(cdp) { + const done = new Promise((resolveDone) => cdp.once("Tracing.tracingComplete", resolveDone)); + await cdp.send("Tracing.end"); + await done; +} + +async function mark(page, name) { + return page.evaluate((markName) => { + performance.mark(markName); + console.timeStamp(markName); + return performance.now(); + }, name); +} + +async function startRafSampler(page) { + await page.evaluate(() => { + window.__minecraftBenchSamples = []; + window.__minecraftBenchSampling = true; + let last = performance.now(); + const tick = (now) => { + window.__minecraftBenchSamples.push({ t: now, dt: now - last }); + last = now; + if (window.__minecraftBenchSampling) requestAnimationFrame(tick); + }; + requestAnimationFrame(tick); + }); +} + +async function stopRafSampler(page) { + return page.evaluate(() => { + window.__minecraftBenchSampling = false; + return window.__minecraftBenchSamples ?? []; + }); +} + +async function startWorldMutationObserver(page) { + return page.evaluate((worldSelector) => { + const worlds = [...document.querySelectorAll(worldSelector)]; + const host = document.querySelector("#host"); + if (!worlds.length || !host) return { ok: false }; + const isWorldNode = (node) => { + return node instanceof Element && + (node.matches(worldSelector) || Boolean(node.closest(worldSelector))); + }; + window.__minecraftBenchWorldMutations = { + records: 0, + addedNodes: 0, + removedNodes: 0, + attributeChanges: 0, + characterDataChanges: 0, + }; + window.__minecraftBenchWorldObserver?.disconnect?.(); + window.__minecraftBenchWorldObserver = new MutationObserver((records) => { + for (const record of records) { + const targetIsWorld = isWorldNode(record.target); + const addedNodes = [...record.addedNodes].filter(isWorldNode).length; + const removedNodes = [...record.removedNodes].filter(isWorldNode).length; + if (!targetIsWorld && addedNodes === 0 && removedNodes === 0) continue; + window.__minecraftBenchWorldMutations.records += 1; + window.__minecraftBenchWorldMutations.addedNodes += addedNodes; + window.__minecraftBenchWorldMutations.removedNodes += removedNodes; + if (record.type === "attributes") window.__minecraftBenchWorldMutations.attributeChanges += 1; + if (record.type === "characterData") window.__minecraftBenchWorldMutations.characterDataChanges += 1; + } + }); + window.__minecraftBenchWorldObserver.observe(host, { + attributes: false, + childList: true, + subtree: true, + characterData: false, + }); + return { + ok: true, + meshCount: worlds.length, + leafCount: worlds.reduce((count, world) => count + world.querySelectorAll("b,i,s,u,q").length, 0), + }; + }, WORLD_MESH_SELECTOR); +} + +async function stopWorldMutationObserver(page) { + return page.evaluate(() => { + window.__minecraftBenchWorldObserver?.disconnect?.(); + return window.__minecraftBenchWorldMutations ?? null; + }); +} + +async function collectPageMetrics(page) { + return page.evaluate((worldSelector) => { + const worlds = [...document.querySelectorAll(worldSelector)]; + const outline = document.querySelector('[data-poly-mesh-id="polycraft-outline"]'); + const hand = document.querySelector("#hand"); + const statsOverlay = document.querySelector("#stats-js-overlay"); + const camera = document.querySelector(".polycss-camera"); + const leafSelector = "b,i,s,u,q"; + return { + playing: document.body.hasAttribute("data-playing"), + statsText: document.querySelector("#stats")?.textContent?.trim() ?? "", + worldMeshCount: worlds.length, + worldLeafCount: worlds.reduce((count, world) => count + world.querySelectorAll(leafSelector).length, 0), + outlineLeafCount: outline?.querySelectorAll(leafSelector).length ?? 0, + handLeafCount: hand?.querySelectorAll(leafSelector).length ?? 0, + statsOverlayPanels: statsOverlay?.children.length ?? 0, + bodyCursor: getComputedStyle(document.body).cursor, + hostCursor: getComputedStyle(document.querySelector("#host")).cursor, + cameraTransformLength: camera?.getAttribute("style")?.length ?? 0, + }; + }, WORLD_MESH_SELECTOR); +} + +async function waitForMinecraftReady(page) { + await page.waitForSelector("#prompt", { state: "attached", timeout: 30000 }); + await page.waitForSelector("#host", { state: "attached", timeout: 30000 }); + await page.waitForSelector(WORLD_MESH_SELECTOR, { state: "attached", timeout: 30000 }); + await page.waitForFunction((worldSelector) => { + const worlds = [...document.querySelectorAll(worldSelector)]; + return worlds.some((world) => world.querySelectorAll("b,i,s,u,q").length > 0); + }, WORLD_MESH_SELECTOR, { timeout: 30000 }); + await page.locator("#prompt").click({ force: true, timeout: 30000 }); + await page.waitForFunction(() => document.body.hasAttribute("data-playing"), null, { timeout: 30000 }); +} + +async function performMovement(page, motion, sampleMs, steps) { + if (!["wait", "walk", "look", "walk-look"].includes(motion)) { + throw new Error(`Unknown --motion "${motion}". Expected wait, walk, look, or walk-look.`); + } + + const box = await page.locator("#host").boundingBox(); + if (!box) throw new Error("Could not find #host bounding box."); + + const center = { + x: box.x + box.width / 2, + y: box.y + box.height / 2, + }; + const lookX = Math.min(320, box.width * 0.24); + const lookY = Math.min(72, box.height * 0.09); + const delayMs = sampleMs / steps; + const doesWalk = motion === "walk" || motion === "walk-look"; + const doesLook = motion === "look" || motion === "walk-look"; + + await page.mouse.move(center.x, center.y); + if (motion === "wait") { + await page.waitForTimeout(sampleMs); + return { kind: motion, sample_ms: sampleMs }; + } + + try { + if (doesWalk) await page.keyboard.down("w"); + if (doesLook) { + for (let step = 1; step <= steps; step += 1) { + const t = step / steps; + const x = center.x + Math.sin(t * Math.PI * 2) * lookX; + const y = center.y + Math.sin(t * Math.PI * 4) * lookY; + await page.mouse.move(x, y); + if (delayMs > 0) await page.waitForTimeout(delayMs); + } + } else { + await page.waitForTimeout(sampleMs); + } + } finally { + if (doesWalk) await page.keyboard.up("w").catch(() => undefined); + } + + return { + kind: motion, + sample_ms: sampleMs, + steps: doesLook ? steps : 0, + target: "#host", + start: { x: +center.x.toFixed(3), y: +center.y.toFixed(3) }, + look_amplitude: doesLook ? { x: +lookX.toFixed(3), y: +lookY.toFixed(3) } : null, + }; +} + +function dominantGroups(groups) { + return Object.entries(groups) + .map(([name, value]) => ({ name, ...value })) + .filter((group) => group.duration_ms > 0) + .sort((a, b) => b.duration_ms - a.duration_ms) + .slice(0, 6); +} + +function renderMarkdown(summary) { + const groups = dominantGroups(summary.trace.groups); + const topEvents = summary.trace.topEvents.slice(0, 10); + const lines = [ + "# Minecraft Movement Bench", + "", + `- URL: ${summary.url}`, + `- Viewport: ${summary.viewport.width}x${summary.viewport.height}`, + `- Motion: ${summary.action.kind}`, + `- Warmup: ${summary.warmup_ms} ms`, + `- Sample: ${summary.action.sample_ms} ms`, + `- Settle traced: ${summary.settle_ms} ms`, + `- Trace aligned to marks: ${summary.trace_aligned_to_marks ? "yes" : "no"}`, + "", + "## FPS", + "", + `- Mean FPS: ${summary.frames.fps_mean}`, + `- P50 FPS: ${summary.frames.fps_p50}`, + `- P50 frame: ${summary.frames.frame_time_p50_ms} ms`, + `- P95 frame: ${summary.frames.frame_time_p95_ms} ms`, + `- P99 frame: ${summary.frames.frame_time_p99_ms} ms`, + `- Frames over 16.7 ms: ${summary.frames.frames_over_16_7_ms}`, + `- Frames over 33.3 ms: ${summary.frames.frames_over_33_3_ms}`, + "", + "## Trace Groups", + "", + ...groups.map((group) => `- ${group.name}: ${group.duration_ms} ms (${group.ms_per_frame} ms/frame, ${group.count} events)`), + "", + "## Top Events", + "", + ...topEvents.map((event) => `- ${event.name}: ${event.duration_ms} ms (${event.count} events)`), + "", + "## Page Metrics", + "", + `- World meshes: ${summary.page.after.worldMeshCount}`, + `- World leaves: ${summary.page.after.worldLeafCount}`, + `- Hand leaves: ${summary.page.after.handLeafCount}`, + `- Stats panels: ${summary.page.after.statsOverlayPanels}`, + `- World mutation records during movement plus settle: ${summary.page.worldMutations?.records ?? "n/a"}`, + "", + "## Artifacts", + "", + `- Trace: ${summary.outputFiles.trace}`, + `- Summary: ${summary.outputFiles.summary}`, + ]; + return `${lines.join("\n")}\n`; +} + +async function run() { + if (HELP) { + printHelp(); + return; + } + + let server = null; + let url = TARGET_URL; + if (!url) { + await assertBuiltMinecraftExample(); + const started = await startStaticServer(); + server = started.server; + url = `http://127.0.0.1:${started.port}/minecraft/`; + } + + const viewport = parseViewport(VIEWPORT); + const launchOptions = { headless: HEADLESS, args: CHROMIUM_ARGS }; + if (BROWSER_EXECUTABLE) launchOptions.executablePath = BROWSER_EXECUTABLE; + + const browser = await chromium.launch(launchOptions); + try { + const context = await browser.newContext({ viewport }); + const page = await context.newPage(); + const diagnostics = []; + page.on("console", (message) => { + if (message.type() === "error" || message.type() === "warning") { + diagnostics.push(`[console:${message.type()}] ${message.text()}`); + } + }); + page.on("pageerror", (error) => { + diagnostics.push(`[pageerror] ${error?.stack || error?.message || error}`); + }); + + await page.goto(url, { waitUntil: "load" }); + try { + await waitForMinecraftReady(page); + } catch (error) { + const bodyPreview = await page.locator("body").textContent().catch(() => ""); + const htmlPreview = await page.content().catch(() => ""); + throw new Error( + `Minecraft page did not become ready at ${page.url()}. Body: ${bodyPreview?.trim().slice(0, 220) || htmlPreview.slice(0, 220)}`, + { cause: error }, + ); + } + await page.mouse.move(viewport.width / 2, viewport.height / 2); + await page.waitForTimeout(WARMUP_MS); + + const beforePageMetrics = await collectPageMetrics(page); + const observerStart = await startWorldMutationObserver(page); + if (!observerStart.ok) throw new Error("Could not attach world mutation observer."); + + const cdp = await context.newCDPSession(page); + const traceEvents = await startTrace(cdp); + await startRafSampler(page); + const startPerfNow = await mark(page, MARK_START); + const action = await performMovement(page, MOTION, SAMPLE_MS, STEPS); + const endPerfNow = await mark(page, MARK_END); + await page.waitForTimeout(SETTLE_MS); + const worldMutations = await stopWorldMutationObserver(page); + const samples = await stopRafSampler(page); + await stopTrace(cdp); + const afterPageMetrics = await collectPageMetrics(page); + + const startMark = findTraceMark(traceEvents, MARK_START); + const endMark = findTraceMark(traceEvents, MARK_END); + const aligned = Boolean(startMark?.args?.data?.startTime && endMark?.args?.data?.startTime); + const tracePerfOffsetMs = aligned ? (startMark.ts / 1000) - startMark.args.data.startTime : 0; + const alignedStartPerfNow = aligned ? startMark.args.data.startTime : startPerfNow; + const alignedEndPerfNow = aligned ? endMark.args.data.startTime : endPerfNow; + const actionWindowMs = alignedEndPerfNow - alignedStartPerfNow; + const frames = framesFromSamples(samples, alignedStartPerfNow, alignedEndPerfNow); + const eventSummary = aligned + ? summarizeEvents(traceEvents, tracePerfOffsetMs, alignedStartPerfNow, alignedEndPerfNow, frames) + : summarizeEvents(traceEvents, 0, -Infinity, Infinity, frames); + + const summary = { + kind: "minecraft-movement-bench", + url, + viewport, + action, + warmup_ms: WARMUP_MS, + settle_ms: SETTLE_MS, + trace_aligned_to_marks: aligned, + trace_perf_offset_ms: aligned ? +tracePerfOffsetMs.toFixed(3) : null, + action_window_ms: +actionWindowMs.toFixed(3), + frames: summarizeFrames(frames, actionWindowMs), + trace: { + event_count: traceEvents.length, + ...eventSummary, + }, + page: { + before: beforePageMetrics, + after: afterPageMetrics, + worldMutations, + }, + outputFiles: { + trace: resolveOutputPath(TRACE_OUT), + summary: resolveOutputPath(SUMMARY_OUT), + markdown: MARKDOWN_OUT ? resolveOutputPath(MARKDOWN_OUT) : null, + }, + diagnostics, + }; + + const traceOutPath = resolveOutputPath(TRACE_OUT); + mkdirSync(dirname(traceOutPath), { recursive: true }); + writeFileSync(traceOutPath, JSON.stringify({ + traceEvents, + displayTimeUnit: "ms", + metadata: { + source: "bench/minecraft-movement-bench.mjs", + url, + action, + }, + })); + + const summaryOutPath = resolveOutputPath(SUMMARY_OUT); + mkdirSync(dirname(summaryOutPath), { recursive: true }); + writeFileSync(summaryOutPath, `${JSON.stringify(summary, null, 2)}\n`); + + if (MARKDOWN_OUT) { + const markdownOutPath = resolveOutputPath(MARKDOWN_OUT); + mkdirSync(dirname(markdownOutPath), { recursive: true }); + writeFileSync(markdownOutPath, renderMarkdown(summary)); + } + + console.log(JSON.stringify(summary, null, 2)); + } finally { + await browser.close(); + if (server) await stopServer(server); + } +} + +run().catch((error) => { + console.error(error); + process.exit(1); +}); diff --git a/bench/nonvoxel-vanilla.html b/bench/nonvoxel-vanilla.html index 27cc0fd8..9ad63740 100644 --- a/bench/nonvoxel-vanilla.html +++ b/bench/nonvoxel-vanilla.html @@ -26,6 +26,13 @@ const polygonOrder = benchParams.get("polygonOrder") || "source"; const domOrder = benchParams.get("domOrder") || "source"; const leafBucketSize = Number(benchParams.get("leafBucketSize") || 0); + const islandBucketMode = benchParams.get("islandBucket") || "none"; + const islandBucketSize = Number(benchParams.get("islandBucketSize") || 128); + const islandBucketSurface = benchParams.get("islandBucketSurface") || "none"; + const displayCullMode = benchParams.get("displayCull") || "none"; + const displayCullBias = Number(benchParams.get("displayCullBias") || 0); + const displayCullDecimals = Number(benchParams.get("displayCullDecimals") || 1); + const displayCullMinBucketSize = Number(benchParams.get("displayCullMinBucketSize") || 2); const recordFrameWork = benchParams.get("frameWork") === "1"; const disabledStrategies = (benchParams.get("disableStrategies") || "") .split(",") @@ -103,10 +110,19 @@ interactionStats: () => ({ ...interactionStats }), frameWorkSamples: () => frameWork.samples(), resetInteractionStats, + cullStats: () => displayCullStats, + setRotY(rotY) { + scene.camera.update({ rotY }); + scene.applyCamera(); + applySceneTransformMode(); + displayCullController?.update(rotY, scene.camera.state.rotX ?? preset.rotX); + }, }; const sceneEl = host.querySelector(".polycss-scene"); let splitShell = null; + let displayCullController = null; + let displayCullStats = null; function formatMatrixTransform(transform) { const matrix = new DOMMatrix(transform || "none"); @@ -266,18 +282,25 @@ } function polygonNormalZ(polygon) { + return polygonNormal(polygon)[2]; + } + + function polygonNormal(polygon) { const vertices = polygon.vertices; - if (vertices.length < 3) return 0; + if (vertices.length < 3) return [0, 0, 0]; const v0 = vertices[0]; - let nz = 0; + let nx = 0, ny = 0, nz = 0; for (let i = 1; i + 1 < vertices.length; i++) { const v1 = vertices[i]; const v2 = vertices[i + 1]; const e1x = v1[1] - v0[1], e1y = v1[0] - v0[0], e1z = v1[2] - v0[2]; const e2x = v2[1] - v0[1], e2y = v2[0] - v0[0], e2z = v2[2] - v0[2]; + nx -= e1y * e2z - e1z * e2y; + ny -= e1z * e2x - e1x * e2z; nz -= e1x * e2y - e1y * e2x; } - return nz; + const len = Math.hypot(nx, ny, nz) || 1; + return [nx / len, ny / len, nz / len]; } function projectedCentroid(polygon) { @@ -428,6 +451,285 @@ } } + function brushKey(polygon) { + return polygon.material?.key ?? polygon.material?.name ?? polygon.color ?? polygon.texture ?? "none"; + } + + function vertexKey(vertex) { + return `${vertex[0].toFixed(5)},${vertex[1].toFixed(5)},${vertex[2].toFixed(5)}`; + } + + function connectedComponentIds(polygons) { + const parent = polygons.map((_polygon, index) => index); + const find = (index) => { + while (parent[index] !== index) { + parent[index] = parent[parent[index]]; + index = parent[index]; + } + return index; + }; + const union = (a, b) => { + const ar = find(a); + const br = find(b); + if (ar !== br) parent[br] = ar; + }; + const byVertex = new Map(); + polygons.forEach((polygon, index) => { + const seen = new Set(); + for (const vertex of polygon.vertices) { + const key = vertexKey(vertex); + if (seen.has(key)) continue; + seen.add(key); + const prev = byVertex.get(key); + if (prev === undefined) byVertex.set(key, index); + else union(index, prev); + } + }); + return polygons.map((_polygon, index) => find(index)); + } + + function splitSpatialItems(items, maxSize) { + if (items.length <= maxSize) return [items]; + const bounds = [ + [Infinity, -Infinity], + [Infinity, -Infinity], + [Infinity, -Infinity], + ]; + for (const item of items) { + const c = item.centroid; + for (let axis = 0; axis < 3; axis += 1) { + bounds[axis][0] = Math.min(bounds[axis][0], c[axis]); + bounds[axis][1] = Math.max(bounds[axis][1], c[axis]); + } + } + let axis = 0; + if (bounds[1][1] - bounds[1][0] > bounds[axis][1] - bounds[axis][0]) axis = 1; + if (bounds[2][1] - bounds[2][0] > bounds[axis][1] - bounds[axis][0]) axis = 2; + const sorted = items.slice().sort((a, b) => a.centroid[axis] - b.centroid[axis] || a.index - b.index); + const mid = Math.ceil(sorted.length / 2); + return [ + ...splitSpatialItems(sorted.slice(0, mid), maxSize), + ...splitSpatialItems(sorted.slice(mid), maxSize), + ]; + } + + function islandGroups(polygons) { + if (islandBucketMode === "none") return []; + const maxSize = Math.max(2, Math.floor(Number.isFinite(islandBucketSize) ? islandBucketSize : 128)); + const componentIds = islandBucketMode.includes("connected") + ? connectedComponentIds(polygons) + : polygons.map(() => 0); + const baseGroups = new Map(); + polygons.forEach((polygon, index) => { + const keyParts = []; + if (islandBucketMode.includes("brush")) keyParts.push(brushKey(polygon)); + if (islandBucketMode.includes("connected")) keyParts.push(componentIds[index]); + if (keyParts.length === 0) keyParts.push("all"); + const key = keyParts.join("|"); + const item = { + index, + centroid: centroid(polygon), + }; + const group = baseGroups.get(key); + if (group) group.push(item); + else baseGroups.set(key, [item]); + }); + + const out = []; + for (const group of baseGroups.values()) { + if (islandBucketMode.includes("spatial")) { + out.push(...splitSpatialItems(group, maxSize)); + } else { + out.push(group); + } + } + return out.filter((group) => group.length > 1); + } + + function applyIslandBuckets(parseResult) { + const groups = islandGroups(parseResult.polygons); + if (groups.length === 0) return; + const leafByIndex = new Map(); + for (const leaf of host.querySelectorAll(".polycss-mesh b,.polycss-mesh i,.polycss-mesh s,.polycss-mesh u")) { + const index = Number(leaf.getAttribute("data-bench-index")); + if (Number.isFinite(index)) leafByIndex.set(index, leaf); + } + for (const group of groups) { + const leaves = group + .map((item) => leafByIndex.get(item.index)) + .filter((leaf) => leaf instanceof HTMLElement && leaf.parentElement); + if (leaves.length < 2) continue; + leaves.sort((a, b) => { + const position = a.compareDocumentPosition(b); + if (position & Node.DOCUMENT_POSITION_FOLLOWING) return -1; + if (position & Node.DOCUMENT_POSITION_PRECEDING) return 1; + return 0; + }); + const parent = leaves[0].parentElement; + if (!parent) continue; + const bucket = document.createElement("div"); + bucket.className = "polycss-bucket polycss-bench-island-bucket"; + bucket.style.position = "absolute"; + bucket.style.transformStyle = islandBucketSurface === "flat" ? "flat" : "preserve-3d"; + if (islandBucketSurface === "opacity") { + bucket.style.opacity = "0.999"; + bucket.style.willChange = "transform"; + } else if (islandBucketSurface === "contain") { + bucket.style.contain = "paint"; + bucket.style.willChange = "transform"; + } + parent.insertBefore(bucket, leaves[0]); + for (const leaf of leaves) bucket.appendChild(leaf); + } + } + + function rotateNormal(normal, rotXDeg, rotYDeg) { + let [x, y, z] = normal; + const rz = (rotYDeg * Math.PI) / 180; + const cosZ = Math.cos(rz); + const sinZ = Math.sin(rz); + const zx = x * cosZ - y * sinZ; + const zy = x * sinZ + y * cosZ; + x = zx; + y = zy; + const rx = (rotXDeg * Math.PI) / 180; + const cosX = Math.cos(rx); + const sinX = Math.sin(rx); + return [x, y * cosX - z * sinX, y * sinX + z * cosX]; + } + + function normalBucketKey(normal) { + const decimals = Math.max(0, Math.min(4, Math.floor(Number.isFinite(displayCullDecimals) ? displayCullDecimals : 1))); + return normal.map((value) => value.toFixed(decimals)).join(","); + } + + function applyNormalCullBuckets(parseResult) { + if (!displayCullMode.startsWith("normal-bucket")) return; + const minBucketSize = Math.max(2, Math.floor(Number.isFinite(displayCullMinBucketSize) ? displayCullMinBucketSize : 2)); + const groups = new Map(); + parseResult.polygons.forEach((polygon, index) => { + const normal = polygonNormal(polygon); + const key = normalBucketKey(normal); + const group = groups.get(key) ?? { normal: [0, 0, 0], indexes: [] }; + group.normal[0] += normal[0]; + group.normal[1] += normal[1]; + group.normal[2] += normal[2]; + group.indexes.push(index); + groups.set(key, group); + }); + const leafByIndex = new Map(); + for (const leaf of host.querySelectorAll(".polycss-mesh b,.polycss-mesh i,.polycss-mesh s,.polycss-mesh u")) { + const index = Number(leaf.getAttribute("data-bench-index")); + if (Number.isFinite(index)) leafByIndex.set(index, leaf); + } + for (const group of groups.values()) { + if (group.indexes.length < minBucketSize) continue; + const len = Math.hypot(group.normal[0], group.normal[1], group.normal[2]) || 1; + const normal = group.normal.map((value) => value / len); + const leaves = group.indexes + .map((index) => leafByIndex.get(index)) + .filter((leaf) => leaf instanceof HTMLElement && leaf.parentElement); + if (leaves.length < 2) continue; + leaves.sort((a, b) => { + const position = a.compareDocumentPosition(b); + if (position & Node.DOCUMENT_POSITION_FOLLOWING) return -1; + if (position & Node.DOCUMENT_POSITION_PRECEDING) return 1; + return 0; + }); + const parent = leaves[0].parentElement; + if (!parent) continue; + const bucket = document.createElement("div"); + bucket.className = "polycss-bucket polycss-bench-normal-cull-bucket"; + bucket.style.cssText = "position:absolute;transform-style:preserve-3d"; + bucket.dataset.cullNormal = normal.join(","); + parent.insertBefore(bucket, leaves[0]); + for (const leaf of leaves) bucket.appendChild(leaf); + } + } + + function createDisplayCullController(polygons) { + if (displayCullMode === "none") return null; + const visibleWhenPositive = displayCullMode.endsWith("positive"); + if (displayCullMode.startsWith("normal-bucket")) { + const entries = Array.from(host.querySelectorAll(".polycss-bench-normal-cull-bucket")) + .map((bucket) => { + const normal = (bucket.dataset.cullNormal ?? "") + .split(",") + .map((value) => Number(value)); + return normal.length === 3 && normal.every(Number.isFinite) + ? { leaf: bucket, normal, size: bucket.querySelectorAll("b,i,s,u").length, visible: true } + : null; + }) + .filter(Boolean); + return { + update(rotY, rotX) { + let visibleCount = 0; + let visibleLeaves = 0; + let changed = 0; + for (const entry of entries) { + const z = rotateNormal(entry.normal, rotX, rotY)[2]; + const visible = visibleWhenPositive ? z >= displayCullBias : z <= -displayCullBias; + if (visible) { + visibleCount += 1; + visibleLeaves += entry.size; + } + if (entry.visible !== visible) { + entry.leaf.style.display = visible ? "" : "none"; + entry.visible = visible; + changed += 1; + } + } + displayCullStats = { + kind: "normal-bucket", + total: entries.length, + visible: visibleCount, + hidden: entries.length - visibleCount, + leaves: entries.reduce((sum, entry) => sum + entry.size, 0), + visibleLeaves, + hiddenLeaves: entries.reduce((sum, entry) => sum + entry.size, 0) - visibleLeaves, + changed, + }; + return displayCullStats; + }, + }; + } + const entries = []; + for (const polygon of polygons) { + const index = polygon.data?.["bench-index"]; + const leaf = index == null ? null : host.querySelector(`[data-bench-index="${index}"]`); + if (!(leaf instanceof HTMLElement)) continue; + entries.push({ + leaf, + normal: polygonNormal(polygon), + visible: true, + }); + } + return { + update(rotY, rotX) { + let visibleCount = 0; + let changed = 0; + for (const entry of entries) { + const z = rotateNormal(entry.normal, rotX, rotY)[2]; + const visible = visibleWhenPositive ? z >= displayCullBias : z <= -displayCullBias; + if (visible) visibleCount += 1; + if (entry.visible !== visible) { + entry.leaf.style.display = visible ? "" : "none"; + entry.visible = visible; + changed += 1; + } + } + displayCullStats = { + kind: "leaf", + total: entries.length, + visible: visibleCount, + hidden: entries.length - visibleCount, + changed, + }; + return displayCullStats; + }, + }; + } + function offsetRotate(transform, offsetDeg) { return transform.replace( /(^|\s)rotate\((-?\d+(?:\.\d+)?)deg\)/, @@ -486,8 +788,12 @@ const parseResult = applyPolygonOrder(indexedParseResult); scene.add(parseResult); applyDomOrder(indexedParseResult); + applyIslandBuckets(indexedParseResult); + applyNormalCullBuckets(indexedParseResult); applyLeafBuckets(); applySceneTransformMode(); + displayCullController = createDisplayCullController(indexedParseResult.polygons); + displayCullController?.update(scene.camera.state.rotY ?? preset.rotY, scene.camera.state.rotX ?? preset.rotX); const cssDrivenRotation = installCssKeyframeRotation(); const recorder = createPerfRecorder({ @@ -514,6 +820,7 @@ scene.camera.update({ rotY: newRotY }); scene.applyCamera(); applySceneTransformMode(); + displayCullController?.update(newRotY, scene.camera.state.rotX ?? preset.rotX); } } requestAnimationFrame(tick); diff --git a/bench/nonvoxel-variants.mjs b/bench/nonvoxel-variants.mjs index a1764683..686f7498 100644 --- a/bench/nonvoxel-variants.mjs +++ b/bench/nonvoxel-variants.mjs @@ -41,6 +41,12 @@ export const NONVOXEL_VARIANTS = [ params: { disableStrategies: "u" }, hypothesis: "Avoid CSS border-triangle compositing for solid triangles.", }, + { + id: "no-stable-tri-no-border-shape", + label: "No Stable Triangles + No Border Shape", + params: { disableStrategies: "u,i" }, + hypothesis: "Keep cheap quads, but force triangles and irregular solids to atlas slices.", + }, { id: "force-atlas", label: "Force Atlas", @@ -71,6 +77,66 @@ export const NONVOXEL_VARIANTS = [ params: { leafBucketSize: "256" }, hypothesis: "Test a low-wrapper baked subtree shape.", }, + { + id: "island-connected-128", + label: "Connected Islands 128", + params: { islandBucket: "brush-connected-spatial", islandBucketSize: "128" }, + hypothesis: "Split the mesh into brush-aware connected/spatial preserve-3D subtrees.", + }, + { + id: "island-connected-surface-128", + label: "Connected Island Surfaces 128", + params: { islandBucket: "brush-connected-spatial", islandBucketSize: "128", islandBucketSurface: "opacity" }, + hypothesis: "Force connected/spatial islands into render surfaces to reduce global 3D BSP pressure.", + }, + { + id: "island-spatial-surface-128", + label: "Spatial Island Surfaces 128", + params: { islandBucket: "brush-spatial", islandBucketSize: "128", islandBucketSurface: "opacity" }, + hypothesis: "Use brush-aware spatial render surfaces without connected-component detection.", + }, + { + id: "display-cull-negative", + label: "Display Cull Negative Normals", + params: { displayCull: "normal-negative" }, + hypothesis: "Actually unmount camera-backfacing leaves before Chromium builds draw quads.", + }, + { + id: "display-cull-positive", + label: "Display Cull Positive Normals", + params: { displayCull: "normal-positive" }, + hypothesis: "Opposite winding probe for display-based camera-facing leaf culling.", + }, + { + id: "normal-bucket-cull-positive", + label: "Normal Bucket Cull Positive", + params: { displayCull: "normal-bucket-positive", displayCullDecimals: "1" }, + hypothesis: "Cull camera-backfacing same-normal wrappers instead of mutating each leaf.", + }, + { + id: "normal-bucket-cull-d0", + label: "Normal Bucket Cull D0", + params: { displayCull: "normal-bucket-positive", displayCullDecimals: "0" }, + hypothesis: "Coarser normal culling with fewer wrappers.", + }, + { + id: "normal-bucket-cull-d1-min4", + label: "Normal Bucket Cull D1 Min4", + params: { displayCull: "normal-bucket-positive", displayCullDecimals: "1", displayCullMinBucketSize: "4" }, + hypothesis: "Skip tiny normal buckets to reduce wrapper churn.", + }, + { + id: "normal-bucket-cull-d1-min8", + label: "Normal Bucket Cull D1 Min8", + params: { displayCull: "normal-bucket-positive", displayCullDecimals: "1", displayCullMinBucketSize: "8" }, + hypothesis: "Cull only larger normal buckets.", + }, + { + id: "normal-bucket-cull-d1-min16", + label: "Normal Bucket Cull D1 Min16", + params: { displayCull: "normal-bucket-positive", displayCullDecimals: "1", displayCullMinBucketSize: "16" }, + hypothesis: "Cull only high-value normal buckets.", + }, { id: "scene-matrix3d", label: "Scene Matrix3d", diff --git a/package.json b/package.json index c304da1e..616e3b31 100644 --- a/package.json +++ b/package.json @@ -31,6 +31,7 @@ "bench:perf": "node bench/build.mjs && node bench/perf-bench.mjs", "bench:animated-human": "node bench/build.mjs && node bench/animated-human-bench.mjs", "bench:trace": "node bench/build.mjs && node .agents/skills/chrome-capture-trace/scripts/trace.mjs motion", + "bench:minecraft-movement": "pnpm --filter @layoutit/polycss-examples-vanilla build && node bench/minecraft-movement-bench.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", diff --git a/packages/core/README.md b/packages/core/README.md index 6af2b1e9..dfee357d 100644 --- a/packages/core/README.md +++ b/packages/core/README.md @@ -88,7 +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. +- Solid seam bleed is automatic on detected shared solid edges. - `strategies` can disable selected render strategies for diagnostics. - `autoCenter` rotates around the rendered mesh bounds instead of world origin. @@ -180,6 +180,15 @@ polycss renders in the DOM, so performance is mostly determined by how many poly - Voxel-shaped meshes mount only camera-facing leaves when the mesh is eligible. - `meshResolution: "lossy"` merges compatible polygons, then may spend a small split budget to repair high-risk seams. +Renderer internals: + +Each visible polygon is emitted as one leaf element; the renderer chooses the least expensive CSS primitive that can represent the polygon, then uses `matrix3d(...)` to place that primitive in 3D space. + +- `` uses `background: currentColor` on a fixed box for solid rectangles and stable quads. +- `` uses `corner-shape` for stable triangles and beveled-corner solids, with a `border-width` triangle fallback when needed. +- `` clips solid polygons with `border-shape: polygon(...)` when the browser supports it. +- `` maps a packed texture-atlas slice with `background-image`, and is the fallback for textured or unsupported shapes. + 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. ## Packages diff --git a/packages/core/src/atlas/edgeRepair.ts b/packages/core/src/atlas/edgeRepair.ts index cd7390bb..fb1f73ac 100644 --- a/packages/core/src/atlas/edgeRepair.ts +++ b/packages/core/src/atlas/edgeRepair.ts @@ -1,7 +1,7 @@ import type { Polygon } from "../types"; import type { Vec3 } from "../types"; import { DEFAULT_TILE, RECT_EPS } from "./constants"; -import type { PolySeamBleed, SeamBleedInsets } from "./types"; +import type { SeamBleedInsets } from "./types"; import { computeSurfaceNormal, cssPoints } from "./solidTriangle"; function pointKey(point: Vec3): string { @@ -41,15 +41,12 @@ export function buildTextureEdgeRepairSets(polygons: Polygon[]): Array 0 ? value : undefined; @@ -110,13 +107,12 @@ export function safePlanSeamBleedAmount( export function computePlanSeamBleedEdgeAmounts( screenPts: number[], seamEdges: ReadonlySet | undefined, - seamBleed: PolySeamBleed | undefined, + seamBleed: number | 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); + const amount = safePlanSeamBleedAmount(screenPts, edgeIndex, seamBleed); if (amount > 0) amounts.set(edgeIndex, amount); } return amounts.size > 0 ? amounts : undefined; diff --git a/packages/core/src/atlas/plan.ts b/packages/core/src/atlas/plan.ts index f284f3d9..205afa86 100644 --- a/packages/core/src/atlas/plan.ts +++ b/packages/core/src/atlas/plan.ts @@ -35,6 +35,7 @@ import type { ProjectiveQuadGuardOverrides, ProjectiveQuadCoefficients, SolidTrianglePlanOptions, + InternalSolidTrianglePlanOptions, StablePlanBasis, ComputeTextureAtlasPlanOptions, } from "./types"; @@ -713,6 +714,7 @@ export function computeTextureAtlasPlan( projectiveQuadGuards: ProjectiveQuadGuardSettings, basisHint?: BasisHint, ): TextureAtlasPlan | null { + const internalOptions = options as InternalSolidTrianglePlanOptions; const { vertices, texture, uvs } = polygon; if (!vertices || vertices.length < 3) return null; @@ -782,10 +784,10 @@ export function computeTextureAtlasPlan( normal[0], normal[1], normal[2], 0, tx, ty, tz, 1, ]); - const seamBleedRequest = normalizedSeamBleed(options.seamBleed); + const seamBleedRequest = normalizedSeamBleed(internalOptions.seamBleed); const seamBleedEdgeAmounts = computePlanSeamBleedEdgeAmounts( screenPts, - options.seamEdges ?? basisHint?.seamEdges, + internalOptions.seamEdges ?? basisHint?.seamEdges, seamBleedRequest, ); const seamBleedEdges = seamBleedEdgeAmounts @@ -890,13 +892,14 @@ export function computeTextureAtlasPlanPublic( projectiveQuadOverrides?: ProjectiveQuadGuardOverrides, ): TextureAtlasPlan | null { const projectiveQuadGuards = resolveProjectiveQuadGuards(projectiveQuadOverrides); - const basisHint: BasisHint | undefined = options.textureEdgeRepairEdges?.size || options.seamEdges?.size + const internalOptions = options as ComputeTextureAtlasPlanOptions & InternalSolidTrianglePlanOptions; + const basisHint: BasisHint | undefined = options.textureEdgeRepairEdges?.size || internalOptions.seamEdges?.size ? { - seamEdges: options.seamEdges ?? new Set(), + seamEdges: internalOptions.seamEdges ?? new Set(), textureEdgeRepairEdges: options.textureEdgeRepairEdges, } : undefined; - return computeTextureAtlasPlan(polygon, index, options, projectiveQuadGuards, basisHint); + return computeTextureAtlasPlan(polygon, index, internalOptions, projectiveQuadGuards, basisHint); } // Re-export from solidTriangle so callers that import from plan continue to work. diff --git a/packages/core/src/atlas/solidTrianglePlan.ts b/packages/core/src/atlas/solidTrianglePlan.ts index 8f1ed1fc..bc9b01a1 100644 --- a/packages/core/src/atlas/solidTrianglePlan.ts +++ b/packages/core/src/atlas/solidTrianglePlan.ts @@ -47,7 +47,7 @@ function triangleEdgeIndexForPair(a: number, b: number): number | undefined { function stableTriangleEdgeAmounts( seamEdges: ReadonlySet | undefined, - seamBleed: SolidTrianglePlanOptions["seamBleed"], + seamBleed: InternalSolidTrianglePlanOptions["seamBleed"], fallback: number, a: number, b: number, @@ -55,9 +55,7 @@ function stableTriangleEdgeAmounts( screenPts: number[], ): number[] | null { if (!seamEdges?.size) return null; - const seamAmount = seamBleed === "auto" - ? Number.POSITIVE_INFINITY - : resolveSeamBleed(seamBleed, fallback); + const seamAmount = 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); @@ -342,8 +340,8 @@ export function computeSolidTrianglePlanFromCssPoints( const right = Math.max(0, baseLength - left); const screenPts = [left, 0, 0, height, left + right, height]; const edgeAmounts = stableTriangleEdgeAmounts( - options.seamEdges, - options.seamBleed, + internalOptions.seamEdges, + internalOptions.seamBleed, SOLID_TRIANGLE_BLEED, a, b, @@ -356,7 +354,7 @@ export function computeSolidTrianglePlanFromCssPoints( left, right, height, - resolveSeamBleed(options.seamBleed, SOLID_TRIANGLE_BLEED), + resolveSeamBleed(internalOptions.seamBleed, SOLID_TRIANGLE_BLEED), ); const apex2x = expanded[0]; const apex2y = expanded[1]; diff --git a/packages/core/src/merge/seamRepair.ts b/packages/core/src/merge/seamRepair.ts index 26e742e9..76d06b57 100644 --- a/packages/core/src/merge/seamRepair.ts +++ b/packages/core/src/merge/seamRepair.ts @@ -1,166 +1,24 @@ import type { Polygon, Vec3 } from "../types"; +import type { SeamFacetSplitCandidateReason as SplitReason, SeamFacetSplitOptions as SplitOptions, SeamFacetSplitReport as SplitReport, SeamOverlapCandidate as OverlapCandidate, SeamOverlapCandidateKind as OverlapKind, SeamOverlapDiagnostics as OverlapDiagnostics, SeamOverlapOptions as OverlapOptions } from "./seamRepairTypes"; +export type * from "./seamRepairTypes"; -type Vec2 = [number, number]; - -interface LocalBasis { - origin: Vec3; - normal: 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; - minX: number; - minY: number; - minZ: number; - maxX: number; - maxY: number; - maxZ: number; - 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; -} - -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; -} +type Vec2=[number,number]; + +type Basis={origin:Vec3;normal:Vec3;xAxis:Vec3;yAxis:Vec3;local:Vec2[];area:number}; + +type Meta={basis:Basis|null;cssPoints:Vec3[];capacities:number[];patchable:boolean}; + +type Edge={index:number;polygon:number;edge:number;key:string;a:Vec3;b:Vec3;minX:number;minY:number;minZ:number;maxX:number;maxY:number;maxZ:number;length:number;dir:Vec3;normal:Vec3;outward:Vec3;capacity:number;materialKey:string;color?:string}; + +type Near={gap:number;facingA:number;facingB:number;aStart:number;aEnd:number;bStart:number;bEnd:number}; + +type Repair={start:number;end:number;amount:number}; + +type Split={rotX:number;rotY:number;viewAware:boolean;passes:number;budget:number}; + +type Overlap={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; @@ -182,19 +40,19 @@ export const DEFAULT_SEAM_OVERLAP_OPTIONS = { overlapPx: DEFAULT_TRUE_GAP_OVERLAP_PX, maxGapPx: MAX_NEAR_SEAM_GAP_PX, capacityScale: 1, -} as const satisfies SeamOverlapOptions; +} as const satisfies OverlapOptions; export const DEFAULT_SEAM_FACET_SPLIT_OPTIONS = { budget: DEFAULT_SEAM_SPLIT_BUDGET, -} as const satisfies SeamFacetSplitOptions; +} as const satisfies SplitOptions; -function finiteOr(value: number | undefined, fallback: number): number { +function finiteOr(value: number | undefined, fallback: number) { return typeof value === "number" && Number.isFinite(value) ? value : fallback; } function resolveSeamOverlapOptions( - options?: number | SeamOverlapOptions, -): ResolvedSeamOverlapOptions { + options?: number | OverlapOptions, +) { if (typeof options === "number") { return { overlapPx: Math.max(0, options) * TRUE_GAP_OVERLAP_AMOUNT_RATIO, @@ -211,14 +69,13 @@ function resolveSeamOverlapOptions( } function seamOverlapEnabled( - options: ResolvedSeamOverlapOptions, - rawOptions?: number | SeamOverlapOptions, -): boolean { - if (typeof rawOptions === "number" && rawOptions <= 0) return false; - return options.maxGapPx > EPS && options.capacityScale > EPS; + options: Overlap, + rawOptions?: number | OverlapOptions, +) { + return !(typeof rawOptions === "number" && rawOptions <= 0) && options.maxGapPx > EPS && options.capacityScale > EPS; } -function resolveSeamFacetSplitOptions(options?: SeamFacetSplitOptions): ResolvedSeamFacetSplitOptions { +function resolveSeamFacetSplitOptions(options?: SplitOptions) { return { rotX: finiteOr(options?.rotX, 65), rotY: finiteOr(options?.rotY, 45), @@ -236,29 +93,21 @@ function fromCssPoint(point: Vec3): Vec3 { return [point[1] / DEFAULT_TILE, point[0] / DEFAULT_TILE, point[2] / DEFAULT_TILE]; } -function pointKey(point: Vec3): string { +function pointKey(point: Vec3) { return `${point[0]},${point[1]},${point[2]}`; } -function edgeKey(a: Vec3, b: Vec3): string { +function edgeKey(a: Vec3, b: Vec3) { 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 { +function length(a: Vec3) { return Math.hypot(a[0], a[1], a[2]); } @@ -267,7 +116,7 @@ function normalize(a: Vec3): Vec3 | null { return len > EPS ? [a[0] / len, a[1] / len, a[2] / len] : null; } -function dot(a: Vec3, b: Vec3): number { +function dot(a: Vec3, b: Vec3) { return a[0] * b[0] + a[1] * b[1] + a[2] * b[2]; } @@ -314,7 +163,7 @@ function surfaceNormal(points: Vec3[]): Vec3 | null { return normalize(normal); } -function localBasis(points: Vec3[]): LocalBasis | null { +function localBasis(points: Vec3[]): Basis | null { const origin = points[0]; const normal = surfaceNormal(points); if (!normal) return null; @@ -348,7 +197,7 @@ function localBasis(points: Vec3[]): LocalBasis | null { return Math.abs(area) > EPS ? { origin, normal, xAxis, yAxis, local, area } : null; } -function signedArea(points: Vec2[]): number { +function signedArea(points: Vec2[]) { let area = 0; for (let i = 0; i < points.length; i += 1) { const a = points[i]; @@ -358,7 +207,7 @@ function signedArea(points: Vec2[]): number { return area / 2; } -function isWeaklyConvex(points: Vec2[]): boolean { +function isWeaklyConvex(points: Vec2[]) { if (points.length < 3) return false; let sign = 0; for (let i = 0; i < points.length; i += 1) { @@ -388,7 +237,7 @@ function edgeOutward2D(points: Vec2[], area: number, edgeIndex: number): Vec2 | ]; } -function edgeOutward3D(basis: LocalBasis, edgeIndex: number): Vec3 | null { +function edgeOutward3D(basis: Basis, edgeIndex: number): Vec3 | null { const outward = edgeOutward2D(basis.local, basis.area, edgeIndex); if (!outward) return null; return normalize([ @@ -398,7 +247,7 @@ function edgeOutward3D(basis: LocalBasis, edgeIndex: number): Vec3 | null { ]); } -function edgeSafeCapacity(points: Vec2[], edgeIndex: number): number { +function edgeSafeCapacity(points: Vec2[], edgeIndex: number) { const count = points.length; const a = points[edgeIndex]; const b = points[(edgeIndex + 1) % count]; @@ -430,11 +279,11 @@ function edgeSafeCapacity(points: Vec2[], edgeIndex: number): number { if (sin > EPS) limits.push(adjacentLength * sin * 0.32); } - return Math.max(0, Math.min(...limits.filter((value) => Number.isFinite(value) && value > 0))); + return Math.min(...limits); } -function buildPolygonMetas(polygons: Polygon[]): PolygonMeta[] { - return polygons.map((polygon): PolygonMeta => { +function buildPolygonMetas(polygons: Polygon[]) { + return polygons.map((polygon): Meta => { const css = cssPoints(polygon.vertices); const basis = localBasis(css); const patchable = !hasTexture(polygon) && !!basis && isWeaklyConvex(basis.local); @@ -450,47 +299,20 @@ function buildPolygonMetas(polygons: Polygon[]): PolygonMeta[] { }); } -function hasTexture(polygon: Polygon): boolean { +function hasTexture(polygon: Polygon) { return !!(polygon.texture || polygon.material?.texture || polygon.textureTriangles?.length); } -function materialKey(polygon: Polygon): string { +function materialKey(polygon: Polygon) { 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[], + metas: Meta[], capacityScale: number, -): EdgeRecord[] { - const records: EdgeRecord[] = []; +) { + const records: Edge[] = []; for (let polygonIndex = 0; polygonIndex < metas.length; polygonIndex += 1) { const meta = metas[polygonIndex]; if (!meta.patchable || !meta.basis) continue; @@ -501,9 +323,8 @@ function buildEdgeRecords( const edgeLength = length(edge); const dir = normalize(edge); const outward = edgeOutward3D(meta.basis, edgeIndex); - const normal = meta.basis.normal; const capacity = (meta.capacities[edgeIndex] ?? 0) * capacityScale; - if (!dir || !outward || !normal || capacity <= EPS || edgeLength <= EPS) continue; + if (!dir || !outward || capacity <= EPS || edgeLength <= EPS) continue; records.push({ index: records.length, polygon: polygonIndex, @@ -519,7 +340,7 @@ function buildEdgeRecords( maxZ: Math.max(a[2], b[2]), length: edgeLength, dir, - normal, + normal: meta.basis.normal, outward, capacity, materialKey: materialKey(polygons[polygonIndex]), @@ -530,34 +351,35 @@ function buildEdgeRecords( return records; } -function compatibleRepairMaterials(a: EdgeRecord, b: EdgeRecord): boolean { +function compatibleRepairMaterials(a: Edge, b: Edge) { return a.materialKey === b.materialKey && a.color === b.color; } -function sameTopologyPoint(a: Vec3, b: Vec3): boolean { +function sameTopologyPoint(a: Vec3, b: Vec3) { return length(sub(a, b)) <= TOPOLOGY_EPS; } -function edgeRecordsTouch(a: EdgeRecord, b: EdgeRecord): boolean { +function edgeRecordsTouch(a: Edge, b: Edge) { 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 classifyCandidate(a: Edge, b: Edge) { + return compatibleRepairMaterials(a, b) + ? edgeRecordsTouch(a, b) ? "connected-facet" : "true-gap" + : "material-boundary"; } function seamOverlapCandidate( - kind: SeamOverlapCandidateKind, - a: EdgeRecord, - b: EdgeRecord, - info: NearSeamInfo, + kind: OverlapKind, + a: Edge, + b: Edge, + info: Near, targetClosurePx: number, appliedClosurePx: number, -): SeamOverlapCandidate { +) { return { kind, aPolygon: a.polygon, @@ -582,12 +404,12 @@ function seamOverlapCandidate( } function addEdgeRepair( - repairs: Array>, - record: EdgeRecord, + repairs: Array>, + record: Edge, 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; @@ -597,7 +419,7 @@ function addEdgeRepair( else repairs[record.polygon].set(record.edge, [segment]); } -function emptyDiagnostics(): SeamOverlapDiagnostics { +function emptyDiagnostics() { return { exactPairs: 0, nearPairs: 0, @@ -611,9 +433,9 @@ function emptyDiagnostics(): SeamOverlapDiagnostics { } function finishDiagnostics( - repairs: Array>, - diagnostics: SeamOverlapDiagnostics, -): SeamOverlapDiagnostics { + repairs: Array>, + diagnostics: OverlapDiagnostics, +) { let maxAppliedAmountPx = 0; let patchedEdges = 0; let patchedPolygons = 0; @@ -636,19 +458,19 @@ function finishDiagnostics( function buildSeamEdgeAmounts( polygons: Polygon[], - options: ResolvedSeamOverlapOptions, + options: Overlap, collectCandidates = false, -): SeamBuildResult { +) { const metas = buildPolygonMetas(polygons); const records = buildEdgeRecords(polygons, metas, options.capacityScale); - const repairs = polygons.map(() => new Map()); + const repairs = polygons.map(() => new Map()); const diagnostics = emptyDiagnostics(); - const candidates = collectCandidates ? [] as SeamOverlapCandidate[] : undefined; + const candidates = collectCandidates ? [] as OverlapCandidate[] : undefined; if (records.length === 0) { return { edgeRepairs: repairs, diagnostics, candidates }; } - const edgeOwners = new Map(); + const edgeOwners = new Map(); for (const record of records) { const owners = edgeOwners.get(record.key); if (owners) owners.push(record); @@ -669,20 +491,20 @@ function buildSeamEdgeAmounts( } function buildNearSeamEdgeAmounts( - records: EdgeRecord[], - edgeOwners: ReadonlyMap, - repairs: Array>, - diagnostics: SeamOverlapDiagnostics, - options: ResolvedSeamOverlapOptions, - candidates: SeamOverlapCandidate[] | undefined, -): void { + records: Edge[], + edgeOwners: ReadonlyMap, + repairs: Array>, + diagnostics: OverlapDiagnostics, + options: Overlap, + candidates: OverlapCandidate[] | undefined, +) { const maxGap = options.maxGapPx; const exactSharedKeys = new Set(); for (const [key, owners] of edgeOwners) { if (owners.length > 1) exactSharedKeys.add(key); } - const measurePair = (first: EdgeRecord, second: EdgeRecord): void => { + const measurePair = (first: Edge, second: Edge) => { const record = first.index < second.index ? first : second; const candidate = first.index < second.index ? second : first; if (candidate.polygon === record.polygon) return; @@ -722,8 +544,7 @@ function buildNearSeamEdgeAmounts( remainingClosure -= extraB; } - const appliedClosure = closureA + closureB; - candidates?.push(seamOverlapCandidate(kind, record, candidate, info, targetClosure, appliedClosure)); + candidates?.push(seamOverlapCandidate(kind, record, candidate, info, targetClosure, closureA + closureB)); if (closureA > EPS) addEdgeRepair(repairs, record, info.aStart, info.aEnd, closureA / info.facingA); if (closureB > EPS) addEdgeRepair(repairs, candidate, info.bStart, info.bEnd, closureB / info.facingB); diagnostics.nearPairs += 1; @@ -765,7 +586,7 @@ function buildNearSeamEdgeAmounts( } } - const cells = new Map(); + const cells = new Map(); for (const record of records) { addRecordToSegmentCells(cells, record, cellSize, maxGap); } @@ -788,7 +609,7 @@ function buildNearSeamEdgeAmounts( } } -function edgeBoundsCouldOverlap(a: EdgeRecord, b: EdgeRecord, maxGap: number): boolean { +function edgeBoundsCouldOverlap(a: Edge, b: Edge, maxGap: number) { return a.minX <= b.maxX + maxGap && b.minX <= a.maxX + maxGap && a.minY <= b.maxY + maxGap && @@ -806,19 +627,12 @@ function cellCoords(point: Vec3, cellSize: number): [number, number, number] { } function addRecordToSegmentCells( - cells: Map, - record: EdgeRecord, + cells: Map, + record: Edge, cellSize: number, padding: number, -): void { - const minX = record.minX - padding; - const minY = record.minY - padding; - const minZ = record.minZ - padding; - const maxX = record.maxX + padding; - const maxY = record.maxY + padding; - const maxZ = record.maxZ + padding; - const [minCx, minCy, minCz] = cellCoords([minX, minY, minZ], cellSize); - const [maxCx, maxCy, maxCz] = cellCoords([maxX, maxY, maxZ], cellSize); +) { + const [minCx, minCy, minCz, maxCx, maxCy, maxCz] = recordSegmentCellBounds(record, cellSize, padding); for (let x = minCx; x <= maxCx; x += 1) { for (let y = minCy; y <= maxCy; y += 1) { for (let z = minCz; z <= maxCz; z += 1) { @@ -831,23 +645,22 @@ function addRecordToSegmentCells( } } -function recordSegmentCellCount(record: EdgeRecord, cellSize: number, padding: number): number { - const minX = record.minX - padding; - const minY = record.minY - padding; - const minZ = record.minZ - padding; - const maxX = record.maxX + padding; - const maxY = record.maxY + padding; - const maxZ = record.maxZ + padding; - const [minCx, minCy, minCz] = cellCoords([minX, minY, minZ], cellSize); - const [maxCx, maxCy, maxCz] = cellCoords([maxX, maxY, maxZ], cellSize); +function recordSegmentCellBounds(record: Edge, cellSize: number, padding: number): [number, number, number, number, number, number] { + const [minCx, minCy, minCz] = cellCoords([record.minX - padding, record.minY - padding, record.minZ - padding], cellSize); + const [maxCx, maxCy, maxCz] = cellCoords([record.maxX + padding, record.maxY + padding, record.maxZ + padding], cellSize); + return [minCx, minCy, minCz, maxCx, maxCy, maxCz]; +} + +function recordSegmentCellCount(record: Edge, cellSize: number, padding: number) { + const [minCx, minCy, minCz, maxCx, maxCy, maxCz] = recordSegmentCellBounds(record, cellSize, padding); return (maxCx - minCx + 1) * (maxCy - minCy + 1) * (maxCz - minCz + 1); } function shouldUseExtendedSweep( - records: EdgeRecord[], + records: Edge[], cellSize: number, padding: number, -): boolean { +) { let totalCells = 0; let maxCells = 0; for (const record of records) { @@ -859,7 +672,7 @@ function shouldUseExtendedSweep( return totalCells / Math.max(1, records.length) > NEAR_SEAM_GRID_AVG_CELLS_PER_RECORD_LIMIT; } -function dotFromPointToEdge(point: Vec3, edgeOrigin: Vec3, edgeDir: Vec3): number { +function dotFromPointToEdge(point: Vec3, edgeOrigin: Vec3, edgeDir: Vec3) { return ( (point[0] - edgeOrigin[0]) * edgeDir[0] + (point[1] - edgeOrigin[1]) * edgeDir[1] + @@ -867,7 +680,7 @@ function dotFromPointToEdge(point: Vec3, edgeOrigin: Vec3, edgeDir: Vec3): numbe ); } -function dotEdgePointToEdge(source: EdgeRecord, distance: number, target: EdgeRecord): number { +function dotEdgePointToEdge(source: Edge, distance: number, target: Edge) { return ( (source.a[0] + source.dir[0] * distance - target.a[0]) * target.dir[0] + (source.a[1] + source.dir[1] * distance - target.a[1]) * target.dir[1] + @@ -875,7 +688,7 @@ function dotEdgePointToEdge(source: EdgeRecord, distance: number, target: EdgeRe ); } -function nearSeamInfo(a: EdgeRecord, b: EdgeRecord, maxGap: number): NearSeamInfo | null { +function nearSeamInfo(a: Edge, b: Edge, maxGap: number) { const bStart = dotFromPointToEdge(b.a, a.a, a.dir); const bEnd = dotFromPointToEdge(b.b, a.a, a.dir); const overlapStart = Math.max(0, Math.min(bStart, bEnd)); @@ -930,17 +743,17 @@ function repairPoint(a: Vec2, b: Vec2, edgeLength: number, distance: number): Ve ]; } -function pushUniquePoint(points: Vec2[], point: Vec2): void { +function pushUniquePoint(points: Vec2[], point: Vec2) { 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, + segments: Repair[] | 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) { @@ -952,7 +765,7 @@ function normalizeRepairSegments( } const sortedStops = Array.from(stops).sort((a, b) => a - b); - const normalized: EdgeRepairSegment[] = []; + const normalized: Repair[] = []; for (let i = 0; i + 1 < sortedStops.length; i += 1) { const start = sortedStops[i]; const end = sortedStops[i + 1]; @@ -969,9 +782,8 @@ function normalizeRepairSegments( return normalized; } -function isValidRepairedPolygon(source: Vec2[], repaired: Vec2[]): boolean { - if (repaired.length < 3) return false; - if (!isWeaklyConvex(repaired)) return false; +function isValidRepairedPolygon(source: Vec2[], repaired: Vec2[]) { + if (repaired.length < 3 || !isWeaklyConvex(repaired)) return false; const sourceArea = signedArea(source); const repairedArea = signedArea(repaired); return Math.sign(repairedArea) === Math.sign(sourceArea) && Math.abs(repairedArea) >= Math.abs(sourceArea) - EPS; @@ -979,11 +791,10 @@ function isValidRepairedPolygon(source: Vec2[], repaired: Vec2[]): boolean { function patchPolygon( polygon: Polygon, - edgeRepairs: ReadonlyMap, + edgeRepairs: ReadonlyMap, capacityScale: number, -): Polygon { - if (edgeRepairs.size === 0 || polygon.vertices.length < 3) return polygon; - if (hasTexture(polygon)) return polygon; +) { + if (edgeRepairs.size === 0 || polygon.vertices.length < 3 || hasTexture(polygon)) return polygon; const points = cssPoints(polygon.vertices); const basis = localBasis(points); @@ -1037,11 +848,11 @@ function patchPolygon( return { ...polygon, vertices }; } -function isRefinableSeamQuad(polygon: Polygon): boolean { +function isRefinableSeamQuad(polygon: Polygon) { return polygon.vertices.length === 4 && !hasTexture(polygon); } -function quadShapeRisk(meta: PolygonMeta): number { +function quadShapeRisk(meta: Meta) { const local = meta.basis?.local; if (!local || local.length !== 4) return 1; let minX = Infinity; @@ -1055,15 +866,15 @@ function quadShapeRisk(meta: PolygonMeta): number { 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)); + const fillRatio = Math.max(0, Math.min(1, Math.abs(meta.basis!.area) / bboxArea)); return 0.75 + (1 - fillRatio) * 2.5; } -function cssDistance(a: Vec3, b: Vec3): number { +function cssDistance(a: Vec3, b: Vec3) { return length(sub(cssPoints([a])[0], cssPoints([b])[0])); } -function triangleQuality(a: Vec3, b: Vec3, c: Vec3): number { +function triangleQuality(a: Vec3, b: Vec3, c: Vec3) { const ab = cssDistance(a, b); const bc = cssDistance(b, c); const ca = cssDistance(c, a); @@ -1086,7 +897,7 @@ function intersectLocalLines(a0: Vec2, a1: Vec2, b0: Vec2, b1: Vec2): Vec2 | nul return [a0[0] + t * rx, a0[1] + t * ry]; } -function offsetLocalConvexPointsByEdgeAmounts(points: Vec2[], amounts: number[]): Vec2[] | null { +function offsetLocalConvexPointsByEdgeAmounts(points: Vec2[], amounts: number[]) { if (points.length < 3 || points.length !== amounts.length) return null; const maxAmount = Math.max(0, ...amounts); if (maxAmount <= EPS) return points; @@ -1130,7 +941,7 @@ function offsetLocalConvexPointsByEdgeAmounts(points: Vec2[], amounts: number[]) return expanded; } -function cssPointFromLocal(basis: LocalBasis, point: Vec2): Vec3 { +function cssPointFromLocal(basis: Basis, 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], @@ -1138,7 +949,7 @@ function cssPointFromLocal(basis: LocalBasis, point: Vec2): Vec3 { ]; } -function offsetTriangleVertices(vertices: Vec3[], amounts: [number, number, number]): Vec3[] { +function offsetTriangleVertices(vertices: Vec3[], amounts: [number, number, number]) { const basis = localBasis(cssPoints(vertices)); if (!basis) return vertices; const expanded = offsetLocalConvexPointsByEdgeAmounts(basis.local, amounts); @@ -1147,12 +958,12 @@ function offsetTriangleVertices(vertices: Vec3[], amounts: [number, number, numb : vertices; } -function splitQuadIntoTriangles(polygon: Polygon, edges: ReadonlySet): Polygon[] { +function splitQuadIntoTriangles(polygon: Polygon, edges: ReadonlySet) { 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; + const seamAmount = (edge: 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]) }, @@ -1164,53 +975,15 @@ function splitQuadIntoTriangles(polygon: Polygon, edges: ReadonlySet): P ]; } -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; -} +type Cand={key:string;e:[string,string];o:Edge[];r:Edge[];len:number;rank:number;proj:number;s:number;nr:number;sr:number;vr:number;comp:number;sel:boolean;why:SplitReason;cost:number}; -interface SeamFacetSplitPlan { - selected: Map>; - candidates: SeamFacetSplitCandidateInternal[]; - budgetLimit: number; -} +type FollowUp={endpoints:ReadonlySet}; -interface SeamFacetSplitFollowUpContext { - endpoints: ReadonlySet; -} +type SplitMode="primary"|"follow-up"; -type SeamFacetSplitSelectionMode = "primary" | "follow-up"; +type Dist={q1:number;median:number;q3:number;p70:number;p80:number;p88:number;p95:number;p97:number;max:number}; -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 { +function numericDistribution(values: number[]) { if (values.length === 0) { return { q1: Infinity, @@ -1239,69 +1012,50 @@ function numericDistribution(values: number[]): NumericDistribution { }; } -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 unionFindRoot(parents: number[], index: number): number { + const parent = parents[index]; + if (parent === index) return index; + const root = unionFindRoot(parents, parent); + parents[index] = root; + return root; } -function splitPlanFollowUpContext(candidates: SeamFacetSplitCandidateInternal[]): SeamFacetSplitFollowUpContext { +function splitPlanFollowUpContext(candidates: Cand[]) { const endpoints = new Set(); const components = new Set(); for (const candidate of candidates) { - if (!candidate.selected) continue; - components.add(candidate.component); + if (!candidate.sel) continue; + components.add(candidate.comp); } for (const candidate of candidates) { - if (!components.has(candidate.component)) continue; - for (const endpoint of splitCandidateEndpointKeys(candidate)) endpoints.add(endpoint); + if (!components.has(candidate.comp)) continue; + for (const endpoint of candidate.e) endpoints.add(endpoint); } return { endpoints }; } function splitCandidateTouchesFollowUp( - candidate: SeamFacetSplitCandidateInternal, - context: SeamFacetSplitFollowUpContext | undefined, -): boolean { + candidate: Cand, + context: FollowUp | undefined, +) { if (!context) return true; - return splitCandidateEndpointKeys(candidate).some((endpoint) => context.endpoints.has(endpoint)); + return candidate.e.some((endpoint) => context.endpoints.has(endpoint)); } function buildSeamFacetSplitCandidates( polygons: Polygon[], - metas: PolygonMeta[], - edgeOwners: ReadonlyMap, - splitOptions: ResolvedSeamFacetSplitOptions, -): SeamFacetSplitCandidateInternal[] { - const candidates: SeamFacetSplitCandidateInternal[] = []; + metas: Meta[], + edgeOwners: ReadonlyMap, + splitOptions: Split, +) { + const candidates: Cand[] = []; 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 firstRefinable = refinable[0]; const normalRisk = 1 - Math.min(1, Math.abs(dot(a.normal, b.normal))); const seamLength = Math.max(a.length, b.length); @@ -1312,57 +1066,63 @@ function buildSeamFacetSplitCandidates( 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; + : { proj: seamLength, vr: 1 }; + const rankLength = splitOptions.viewAware ? Math.max(projected.proj, seamLength * 0.12) : seamLength; + const viewWeight = splitOptions.viewAware ? 0.18 + projected.vr * 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, + e: [pointKey(firstRefinable.a), pointKey(firstRefinable.b)], + o: owners, + r: refinable, + len: seamLength, + rank: rankLength, + proj: projected.proj, + s: rankLength * (0.58 + seamRisk * 2.8) * shapeRisk * bothSidesRefinable * viewWeight, + nr: normalRisk, + sr: shapeRisk, + vr: projected.vr, + comp: -1, + sel: false, + why: "below-threshold", + cost: refinable.length, }); } - const union = new UnionFind(candidates.length); + const parents = Array.from({ length: candidates.length }, (_, index) => index); const byEndpoint = new Map(); for (let index = 0; index < candidates.length; index += 1) { - for (const endpoint of splitCandidateEndpointKeys(candidates[index])) { + for (const endpoint of candidates[index].e) { const previous = byEndpoint.get(endpoint); - if (previous !== undefined) union.union(previous, index); - else byEndpoint.set(endpoint, index); + if (previous === undefined) { + byEndpoint.set(endpoint, index); + } else { + const rootA = unionFindRoot(parents, previous); + const rootB = unionFindRoot(parents, index); + if (rootA !== rootB) parents[rootB] = rootA; + } } } const componentIds = new Map(); for (let index = 0; index < candidates.length; index += 1) { - const root = union.find(index); + const root = unionFindRoot(parents, index); let component = componentIds.get(root); if (component === undefined) { component = componentIds.size; componentIds.set(root, component); } - candidates[index].component = component; + candidates[index].comp = component; } return candidates; } function splitCandidateViewMetrics( - a: EdgeRecord, - b: EdgeRecord, + a: Edge, + b: Edge, 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]); @@ -1371,87 +1131,87 @@ function splitCandidateViewMetrics( 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)), + proj: projectedLength, + vr: Math.max(0.04, Math.min(1, frontness * 0.85 + silhouette * 0.45)), }; } function strongestCandidatesByComponent( - candidates: SeamFacetSplitCandidateInternal[], -): Set { - const strongest = new Map(); + candidates: Cand[], +) { + 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); + const current = strongest.get(candidate.comp); + if (!current || candidate.s > current.s || (candidate.s === current.s && candidate.len > current.len)) { + strongest.set(candidate.comp, candidate); } } return new Set(strongest.values()); } -function splitCandidateBudgetPriority(candidate: SeamFacetSplitCandidateInternal): number { - const coplanar = Math.max(0, 1 - Math.min(1, candidate.normalRisk)); +function splitCandidateBudgetPriority(candidate: Cand) { + const coplanar = Math.max(0, 1 - Math.min(1, candidate.nr)); 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)); + const oneSidedWeight = candidate.r.length === 1 ? 1.08 : 1; + return (candidate.rank * coplanarWeight * oneSidedWeight) / Math.sqrt(Math.max(1, candidate.r.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; +function splitCandidateLikelyCrackPriority(candidate: Cand) { + const coplanar = Math.max(0, 1 - Math.min(1, candidate.nr)); + const visibleWeight = 0.35 + Math.min(1, candidate.vr) * 1.65; + const shapeWeight = 0.7 + Math.min(2.6, candidate.sr) * 0.3; + const oneSidedWeight = candidate.r.length === 1 ? 1.1 : 1; const visiblePriority = ( - candidate.rankLength * + candidate.rank * (0.6 + coplanar * coplanar * 1.4) * visibleWeight * shapeWeight * oneSidedWeight - ) / Math.sqrt(Math.max(1, candidate.records.length)); + ) / Math.sqrt(Math.max(1, candidate.r.length)); return Math.max(visiblePriority, splitCandidateBudgetPriority(candidate) * 0.75); } -function uniqueSplitPolygonCost(candidates: SeamFacetSplitCandidateInternal[]): number { +function uniqueSplitPolygonCost(candidates: Cand[]) { const polygons = new Set(); for (const candidate of candidates) { - for (const record of candidate.records) polygons.add(record.polygon); + for (const record of candidate.r) 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 splitCandidateLikelyCrack(candidate: Cand, lengthStats: Dist) { + return candidate.nr <= 0.25 && + candidate.rank >= lengthStats.median * 1.08 && + candidate.proj >= 24; } function splitCandidateGlobalSeed( - candidate: SeamFacetSplitCandidateInternal, + candidate: Cand, strongScore: number, strongLength: number, -): boolean { - return candidate.score >= strongScore || candidate.rankLength >= strongLength; +) { + return candidate.s >= strongScore || candidate.rank >= strongLength; } function seamFacetSplitBudgetPlan( - candidates: SeamFacetSplitCandidateInternal[], - mode: SeamFacetSplitSelectionMode, + candidates: Cand[], + mode: SplitMode, maxBudget: number, - scoreStats: NumericDistribution, - lengthStats: NumericDistribution, + scoreStats: Dist, + lengthStats: Dist, 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(); + const byComponent = new Map(); for (const candidate of candidates) { - const group = byComponent.get(candidate.component); + const group = byComponent.get(candidate.comp); if (group) group.push(candidate); - else byComponent.set(candidate.component, [candidate]); + else byComponent.set(candidate.comp, [candidate]); } const componentBudgets = new Map(); @@ -1461,7 +1221,7 @@ function seamFacetSplitBudgetPlan( for (const [component, group] of byComponent) { const primary = group.filter((candidate) => splitCandidateGlobalSeed(candidate, strongScore, strongLength) || - candidate.score >= fallbackSeedScore + candidate.s >= fallbackSeedScore ); if (primary.length === 0) continue; @@ -1486,15 +1246,15 @@ function seamFacetSplitBudgetPlan( } function selectSeamFacetSplitCandidates( - candidates: SeamFacetSplitCandidateInternal[], - mode: SeamFacetSplitSelectionMode, + candidates: Cand[], + mode: SplitMode, 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 scoreStats = numericDistribution(candidates.map((candidate) => candidate.s)); + const lengthStats = numericDistribution(candidates.map((candidate) => candidate.rank)); 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)); @@ -1524,21 +1284,21 @@ function selectSeamFacetSplitCandidates( 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 + (b.s / Math.max(1, b.r.length)) - (a.s / Math.max(1, a.r.length)) || + b.len - a.len ); const likelyCrackSorted = [...candidates].sort((a, b) => splitCandidateLikelyCrackPriority(b) - splitCandidateLikelyCrackPriority(a) || splitCandidateBudgetPriority(b) - splitCandidateBudgetPriority(a) || - b.length - a.length + b.len - a.len ); - 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) { + const selectCandidate = (candidate: Cand, reason: SplitReason) => { + candidate.sel = true; + candidate.why = reason; + selectedComponents.add(candidate.comp); + for (const endpoint of candidate.e) selectedEndpoints.add(endpoint); + for (const record of candidate.r) { alreadySplit.add(record.polygon); const edges = selected.get(record.polygon); if (edges) edges.add(record.edge); @@ -1546,24 +1306,24 @@ function selectSeamFacetSplitCandidates( } }; - const withinComponentBudget = (candidate: SeamFacetSplitCandidateInternal, marginalCost: number): boolean => { + const withinComponentBudget = (candidate: Cand, marginalCost: number) => { 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 componentBudget = componentBudgets.get(candidate.comp) ?? 0; + return (componentSpend.get(candidate.comp) ?? 0) + marginalCost <= componentBudget; }; - const recordComponentSpend = (candidate: SeamFacetSplitCandidateInternal, marginalCost: number): void => { + const recordComponentSpend = (candidate: Cand, marginalCost: number) => { if (marginalCost <= 0 || !budgetPlan.componentBudgets) return; - componentSpend.set(candidate.component, (componentSpend.get(candidate.component) ?? 0) + marginalCost); + componentSpend.set(candidate.comp, (componentSpend.get(candidate.comp) ?? 0) + marginalCost); }; for (const candidate of likelyCrackSorted) { - const marginalCost = candidate.records.reduce( + const marginalCost = candidate.r.reduce( (total, record) => total + (alreadySplit.has(record.polygon) ? 0 : 1), 0, ); - candidate.marginalCost = marginalCost; + candidate.cost = marginalCost; if (marginalCost > remainingBudget) continue; if (!withinComponentBudget(candidate, marginalCost)) continue; if (marginalCost > 0 && remainingBudget - marginalCost < localReserve) continue; @@ -1576,32 +1336,32 @@ function selectSeamFacetSplitCandidates( } for (const candidate of sorted) { - if (candidate.selected) continue; - const marginalCost = candidate.records.reduce( + if (candidate.sel) continue; + const marginalCost = candidate.r.reduce( (total, record) => total + (alreadySplit.has(record.polygon) ? 0 : 1), 0, ); - candidate.marginalCost = marginalCost; + candidate.cost = marginalCost; if (marginalCost > remainingBudget) continue; if (!withinComponentBudget(candidate, marginalCost)) continue; if (marginalCost > 0 && remainingBudget - marginalCost < localReserve) continue; - const twoSided = candidate.records.length === 2; + const twoSided = candidate.r.length === 2; const isAnchor = anchors.has(candidate) && ( mode === "primary" || - (mode === "follow-up" && candidate.score >= anchorScore * 1.08 && candidate.rankLength >= anchorLength) + (mode === "follow-up" && candidate.s >= anchorScore * 1.08 && candidate.rank >= anchorLength) ) && ( twoSided - ? candidate.score >= anchorScore || candidate.rankLength >= anchorLength - : candidate.score >= anchorScore * 1.18 || candidate.rankLength >= anchorLength * 1.12 + ? candidate.s >= anchorScore || candidate.rank >= anchorLength + : candidate.s >= anchorScore * 1.18 || candidate.rank >= anchorLength * 1.12 ); const isGlobalOutlier = twoSided - ? candidate.score >= strongScore || candidate.rankLength >= strongLength - : candidate.score >= strongScore * 1.35 || candidate.rankLength >= strongLength * 1.2; + ? candidate.s >= strongScore || candidate.rank >= strongLength + : candidate.s >= strongScore * 1.35 || candidate.rank >= strongLength * 1.2; const isSharedPolygonFollowUp = marginalCost === 0 && ( twoSided - ? candidate.score >= sharedPolygonScore || candidate.rankLength >= anchorLength - : candidate.score >= sharedPolygonScore * 1.25 || candidate.rankLength >= anchorLength * 1.12 + ? candidate.s >= sharedPolygonScore || candidate.rank >= anchorLength + : candidate.s >= sharedPolygonScore * 1.25 || candidate.rank >= anchorLength * 1.12 ); if (!isAnchor && !isGlobalOutlier && !isSharedPolygonFollowUp) continue; @@ -1616,23 +1376,23 @@ function selectSeamFacetSplitCandidates( } for (const candidate of sorted) { - if (candidate.selected) continue; - const marginalCost = candidate.records.reduce( + if (candidate.sel) continue; + const marginalCost = candidate.r.reduce( (total, record) => total + (alreadySplit.has(record.polygon) ? 0 : 1), 0, ); - candidate.marginalCost = marginalCost; + candidate.cost = 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 touchesSelectedPolygon = candidate.r.some((record) => alreadySplit.has(record.polygon)); + const touchesSelectedEndpoint = candidate.e.some((endpoint) => selectedEndpoints.has(endpoint)); const isOneSidedTail = mode === "primary" && - candidate.records.length === 1 && + candidate.r.length === 1 && marginalCost === 1 && - (selectedComponents.has(candidate.component) || touchesSelectedEndpoint || touchesSelectedPolygon) && - candidate.rankLength >= anchorLength * 1.05 && - candidate.normalRisk >= 0.16 && - candidate.viewRisk <= 0.32; + (selectedComponents.has(candidate.comp) || touchesSelectedEndpoint || touchesSelectedPolygon) && + candidate.rank >= anchorLength * 1.05 && + candidate.nr >= 0.16 && + candidate.vr <= 0.32; if (isOneSidedTail) { selectCandidate(candidate, "local-follow-up"); recordComponentSpend(candidate, marginalCost); @@ -1640,16 +1400,16 @@ function selectSeamFacetSplitCandidates( if (remainingBudget <= 0) return { selected, budgetLimit: budgetPlan.budgetLimit }; continue; } - if (candidate.records.length !== 2) continue; + if (candidate.r.length !== 2) continue; const isProjectedNeighbor = touchesSelectedPolygon && marginalCost === 1 && - candidate.projectedLength >= anchorLength * 1.45 && - candidate.viewRisk <= 0.18; + candidate.proj >= anchorLength * 1.45 && + candidate.vr <= 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; + candidate.s >= sharedPolygonScore * 0.42 || + candidate.rank >= anchorLength * 0.62 + ) && candidate.s <= sharedPolygonScore * 1.1 && candidate.proj >= 24; if (isLocalFollowUp) { selectCandidate(candidate, "local-follow-up"); recordComponentSpend(candidate, marginalCost); @@ -1663,14 +1423,14 @@ function selectSeamFacetSplitCandidates( function seamFacetSplitPlan( polygons: Polygon[], - seamOptions: ResolvedSeamOverlapOptions, - splitOptions: ResolvedSeamFacetSplitOptions, + seamOptions: Overlap, + splitOptions: Split, budget: number, - followUpContext?: SeamFacetSplitFollowUpContext, -): SeamFacetSplitPlan { + followUpContext?: FollowUp, +) { const metas = buildPolygonMetas(polygons); const records = buildEdgeRecords(polygons, metas, seamOptions.capacityScale); - const edgeOwners = new Map(); + const edgeOwners = new Map(); for (const record of records) { const owners = edgeOwners.get(record.key); if (owners) owners.push(record); @@ -1683,18 +1443,10 @@ function seamFacetSplitPlan( 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]; @@ -1705,8 +1457,8 @@ function applySeamFacetSplitSelection( return out; } -function seamFacetSplitPublicCandidate(candidate: SeamFacetSplitCandidateInternal): SeamFacetSplitCandidate { - const [a, b] = candidate.owners; +function seamFacetSplitPublicCandidate(candidate: Cand) { + const [a, b] = candidate.o; const first = a ?? b; const second = b ?? a; return { @@ -1717,29 +1469,29 @@ function seamFacetSplitPublicCandidate(candidate: SeamFacetSplitCandidateInterna 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, + lengthPx: candidate.len, + projectedLengthPx: candidate.proj, + score: candidate.s, + normalRisk: candidate.nr, + shapeRisk: candidate.sr, + viewRisk: candidate.vr, + component: candidate.comp, + marginalCost: candidate.cost, + selected: candidate.sel, + reason: candidate.why, }; } export function seamFacetSplitPolygons( polygons: Polygon[], - seamOptions?: number | SeamOverlapOptions, - splitOptions?: SeamFacetSplitOptions, -): Polygon[] { + seamOptions?: number | OverlapOptions, + splitOptions?: SplitOptions, +) { 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 followUpContext: FollowUp | undefined; let remainingBudget = split.budget; for (let pass = 0; pass < split.passes; pass += 1) { const plan = seamFacetSplitPlan(current, resolved, split, remainingBudget, followUpContext); @@ -1760,9 +1512,9 @@ export function seamFacetSplitPolygons( export function seamFacetSplitReport( polygons: Polygon[], - seamOptions?: number | SeamOverlapOptions, - splitOptions?: SeamFacetSplitOptions, -): SeamFacetSplitReport { + seamOptions?: number | OverlapOptions, + splitOptions?: SplitOptions, +): SplitReport { const resolved = resolveSeamOverlapOptions(seamOptions); const split = resolveSeamFacetSplitOptions(splitOptions); if (!seamOverlapEnabled(resolved, seamOptions) || polygons.length === 0) { @@ -1782,8 +1534,8 @@ export function seamFacetSplitReport( export function seamOverlapPolygons( polygons: Polygon[], - options?: number | SeamOverlapOptions, -): Polygon[] { + options?: number | OverlapOptions, +) { const resolved = resolveSeamOverlapOptions(options); if (!seamOverlapEnabled(resolved, options) || polygons.length === 0) return polygons; const { edgeRepairs } = buildSeamEdgeAmounts(polygons, resolved); @@ -1793,9 +1545,9 @@ export function seamOverlapPolygons( export function repairMeshSeams( polygons: Polygon[], - seamOptions: number | SeamOverlapOptions = DEFAULT_SEAM_OVERLAP_OPTIONS, - splitOptions: SeamFacetSplitOptions = DEFAULT_SEAM_FACET_SPLIT_OPTIONS, -): Polygon[] { + seamOptions: number | OverlapOptions = DEFAULT_SEAM_OVERLAP_OPTIONS, + splitOptions: SplitOptions = DEFAULT_SEAM_FACET_SPLIT_OPTIONS, +) { if (polygons.length === 0) return polygons; const split = seamFacetSplitPolygons(polygons, seamOptions, splitOptions); return seamOverlapPolygons(split, seamOptions); @@ -1803,8 +1555,8 @@ export function repairMeshSeams( export function seamOverlapDiagnostics( polygons: Polygon[], - options?: number | SeamOverlapOptions, -): SeamOverlapDiagnostics { + options?: number | OverlapOptions, +): OverlapDiagnostics { const resolved = resolveSeamOverlapOptions(options); if (!seamOverlapEnabled(resolved, options) || polygons.length === 0) return emptyDiagnostics(); return buildSeamEdgeAmounts(polygons, resolved).diagnostics; @@ -1812,8 +1564,8 @@ export function seamOverlapDiagnostics( export function seamOverlapReport( polygons: Polygon[], - options?: number | SeamOverlapOptions, -): { diagnostics: SeamOverlapDiagnostics; candidates: SeamOverlapCandidate[] } { + options?: number | OverlapOptions, +): { diagnostics: OverlapDiagnostics; candidates: OverlapCandidate[] } { const resolved = resolveSeamOverlapOptions(options); if (!seamOverlapEnabled(resolved, options) || polygons.length === 0) { return { diagnostics: emptyDiagnostics(), candidates: [] }; diff --git a/packages/core/src/merge/seamRepairTypes.ts b/packages/core/src/merge/seamRepairTypes.ts new file mode 100644 index 00000000..943c833d --- /dev/null +++ b/packages/core/src/merge/seamRepairTypes.ts @@ -0,0 +1,15 @@ +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; } + +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; } diff --git a/packages/core/src/parser/parseVox.test.ts b/packages/core/src/parser/parseVox.test.ts index f8f4a46a..d675dee6 100644 --- a/packages/core/src/parser/parseVox.test.ts +++ b/packages/core/src/parser/parseVox.test.ts @@ -450,6 +450,103 @@ describe("parseVox — default palette", () => { }); }); +describe("parseVox — palette merging", () => { + it("folds nearby opaque colors before greedy meshing", () => { + const palette: [number, number, number, number][] = Array.from( + { length: 256 }, + () => [0, 0, 0, 255] as [number, number, number, number], + ); + palette[0] = [100, 100, 100, 255]; + palette[1] = [110, 110, 110, 255]; + const buf = buildVoxBuffer( + [2, 1, 1], + [ + { x: 0, y: 0, z: 0, colorIndex: 1 }, + { x: 1, y: 0, z: 0, colorIndex: 2 }, + ], + palette, + ); + + const exact = parseVox(buf); + const merged = parseVox(buf, { paletteMergeDistance: 20 }); + + expect(new Set(exact.polygons.map((p) => p.color))).toEqual(new Set(["#646464", "#6e6e6e"])); + expect(exact.polygons.length).toBe(10); + expect(new Set(merged.polygons.map((p) => p.color))).toEqual(new Set(["#646464"])); + expect(merged.polygons.length).toBe(6); + }); + + it("keeps hue-incompatible nearby colors separate", () => { + const palette: [number, number, number, number][] = Array.from( + { length: 256 }, + () => [0, 0, 0, 255] as [number, number, number, number], + ); + palette[0] = [16, 16, 16, 255]; + palette[1] = [32, 0, 0, 255]; + const buf = buildVoxBuffer( + [2, 1, 1], + [ + { x: 0, y: 0, z: 0, colorIndex: 1 }, + { x: 1, y: 0, z: 0, colorIndex: 2 }, + ], + palette, + ); + + const merged = parseVox(buf, { paletteMergeDistance: 40 }); + + expect(new Set(merged.polygons.map((p) => p.color))).toEqual(new Set(["#101010", "#200000"])); + expect(merged.polygons.length).toBe(10); + }); +}); + +describe("parseVox — color region merging", () => { + it("recolors small face-plane regions before greedy meshing", () => { + const palette: [number, number, number, number][] = Array.from( + { length: 256 }, + () => [0, 0, 0, 255] as [number, number, number, number], + ); + palette[0] = [100, 100, 100, 255]; + palette[1] = [110, 110, 110, 255]; + const buf = buildVoxBuffer( + [3, 1, 1], + [ + { x: 0, y: 0, z: 0, colorIndex: 1 }, + { x: 1, y: 0, z: 0, colorIndex: 1 }, + { x: 2, y: 0, z: 0, colorIndex: 2 }, + ], + palette, + ); + + const exact = parseVox(buf); + const cleaned = parseVox(buf, { colorRegionMergeDistance: 20 }); + + expect(new Set(cleaned.polygons.map((p) => p.color))).toEqual(new Set(["#646464", "#6e6e6e"])); + expect(cleaned.polygons.length).toBeLessThan(exact.polygons.length); + }); + + it("keeps equal-size close color boundaries in lossless local cleanup", () => { + const palette: [number, number, number, number][] = Array.from( + { length: 256 }, + () => [0, 0, 0, 255] as [number, number, number, number], + ); + palette[0] = [100, 100, 100, 255]; + palette[1] = [110, 110, 110, 255]; + const buf = buildVoxBuffer( + [2, 1, 1], + [ + { x: 0, y: 0, z: 0, colorIndex: 1 }, + { x: 1, y: 0, z: 0, colorIndex: 2 }, + ], + palette, + ); + + const cleaned = parseVox(buf, { colorRegionMergeDistance: 20 }); + + expect(new Set(cleaned.polygons.map((p) => p.color))).toEqual(new Set(["#646464", "#6e6e6e"])); + expect(cleaned.polygons.length).toBe(10); + }); +}); + describe("parseVox — custom RGBA palette", () => { it("custom palette overrides default — colorIndex 1 uses custom entry 0", () => { // Build a custom palette where entry 0 (= colorIndex 1) is pure red diff --git a/packages/core/src/parser/parseVox.ts b/packages/core/src/parser/parseVox.ts index afdf9a7a..588acf74 100644 --- a/packages/core/src/parser/parseVox.ts +++ b/packages/core/src/parser/parseVox.ts @@ -43,6 +43,19 @@ export interface VoxParseOptions { * non-negative integers so zero makes sensible default. */ gridShift?: number; + /** + * Optional lossy palette simplification. When > 0, opaque, hue-compatible + * palette colors within this RGB distance are folded into the most-used + * nearby color before greedy voxel meshing. Default: disabled. + */ + paletteMergeDistance?: number; + /** + * Optional lossy local cleanup. When > 0, small face-plane color islands + * and thin streaks are recolored to a neighboring dominant hue-compatible + * color within this RGB distance before greedy voxel meshing. Default: + * disabled. + */ + colorRegionMergeDistance?: number; } // ── Default MagicaVoxel palette ────────────────────────────────────────────── @@ -118,6 +131,237 @@ function colorFromRgba(r: number, g: number, b: number, a: number): string { return `rgba(${r}, ${g}, ${b}, ${alpha})`; } +interface ResolvedVoxColor { + rgb: [number, number, number]; + alpha: number; +} + +interface HsvColor { + hue: number; + saturation: number; + value: number; +} + +function parseResolvedVoxColor(color: string): ResolvedVoxColor | null { + const hex = color.match(/^#([0-9a-f]{6})$/i); + if (hex) { + const value = hex[1]; + return { + rgb: [ + parseInt(value.slice(0, 2), 16), + parseInt(value.slice(2, 4), 16), + parseInt(value.slice(4, 6), 16), + ], + alpha: 1, + }; + } + const rgba = color.match(/^rgba\((\d+),\s*(\d+),\s*(\d+),\s*([\d.]+)\)$/i); + if (!rgba) return null; + return { + rgb: [Number(rgba[1]), Number(rgba[2]), Number(rgba[3])], + alpha: Number(rgba[4]), + }; +} + +function colorDistance(a: ResolvedVoxColor, b: ResolvedVoxColor): number { + return Math.hypot( + a.rgb[0] - b.rgb[0], + a.rgb[1] - b.rgb[1], + a.rgb[2] - b.rgb[2], + ); +} + +function hsvFromColor(color: ResolvedVoxColor): HsvColor { + const r = color.rgb[0] / 255; + const g = color.rgb[1] / 255; + const b = color.rgb[2] / 255; + const max = Math.max(r, g, b); + const min = Math.min(r, g, b); + const delta = max - min; + let hue = 0; + if (delta !== 0) { + if (max === r) hue = 60 * (((g - b) / delta) % 6); + else if (max === g) hue = 60 * ((b - r) / delta + 2); + else hue = 60 * ((r - g) / delta + 4); + } + if (hue < 0) hue += 360; + return { + hue, + saturation: max === 0 ? 0 : delta / max, + value: max, + }; +} + +function hueDistance(a: number, b: number): number { + const delta = Math.abs(a - b) % 360; + return delta > 180 ? 360 - delta : delta; +} + +function canMergeVoxColors(a: ResolvedVoxColor, b: ResolvedVoxColor): boolean { + if (a.alpha < 1 || b.alpha < 1) return false; + const ah = hsvFromColor(a); + const bh = hsvFromColor(b); + const aNeutral = ah.saturation < 0.08; + const bNeutral = bh.saturation < 0.08; + if (aNeutral || bNeutral) return aNeutral === bNeutral; + const tolerance = Math.min(ah.value, bh.value) < 0.16 ? 35 : 24; + return hueDistance(ah.hue, bh.hue) <= tolerance; +} + +function buildPaletteMergeMap( + colorCounts: Map, + paletteMergeDistance: number | undefined, +): Map | null { + const maxDistance = paletteMergeDistance ?? 0; + if (!Number.isFinite(maxDistance) || maxDistance <= 0) return null; + const parsedColors = new Map(); + for (const color of colorCounts.keys()) { + const parsed = parseResolvedVoxColor(color); + if (parsed) parsedColors.set(color, parsed); + } + const representatives: Array<{ color: string; parsed: ResolvedVoxColor }> = []; + const remap = new Map(); + const entries = Array.from(colorCounts.entries()) + .sort((a, b) => b[1] - a[1] || a[0].localeCompare(b[0])); + for (const [color] of entries) { + const parsed = parsedColors.get(color); + if (!parsed) { + remap.set(color, color); + continue; + } + let best: { color: string; distance: number } | null = null; + for (const representative of representatives) { + if (!canMergeVoxColors(parsed, representative.parsed)) continue; + const distance = colorDistance(parsed, representative.parsed); + if (distance > maxDistance) continue; + if (!best || distance < best.distance) { + best = { color: representative.color, distance }; + } + } + if (best) { + remap.set(color, best.color); + } else { + representatives.push({ color, parsed }); + remap.set(color, color); + } + } + return remap; +} + +const FACE_REGION_NEIGHBORS: ReadonlyArray = [ + [1, 0], + [-1, 0], + [0, 1], + [0, -1], +]; + +function cellKey(u: number, v: number): string { + return `${u},${v}`; +} + +function parseCellKey(key: string): [number, number] { + const comma = key.indexOf(","); + return [Number(key.slice(0, comma)), Number(key.slice(comma + 1))]; +} + +function isCleanupRegion(size: number, width: number, height: number): boolean { + if (size <= 4) return true; + if ((width === 1 || height === 1) && size <= 12) return true; + return Math.min(width, height) <= 2 && Math.max(width, height) >= 4 && size <= 18; +} + +function cleanupFacePlaneRegions( + facePlanes: Map>, + colorRegionMergeDistance: number | undefined, +): void { + const maxDistance = colorRegionMergeDistance ?? 0; + if (!Number.isFinite(maxDistance) || maxDistance <= 0) return; + const parsedColors = new Map(); + const parsedColor = (color: string): ResolvedVoxColor | null => { + if (!parsedColors.has(color)) parsedColors.set(color, parseResolvedVoxColor(color)); + return parsedColors.get(color) ?? null; + }; + + for (const cells of facePlanes.values()) { + if (cells.size < 3) continue; + const planeColorCounts = new Map(); + for (const color of cells.values()) { + planeColorCounts.set(color, (planeColorCounts.get(color) ?? 0) + 1); + } + const visited = new Set(); + const updates: Array<[string, string]> = []; + + for (const [startKey, sourceColor] of cells) { + if (visited.has(startKey)) continue; + const stack = [startKey]; + const component: string[] = []; + const neighborContacts = new Map(); + let minU = Infinity, maxU = -Infinity, minV = Infinity, maxV = -Infinity; + visited.add(startKey); + + while (stack.length > 0) { + const key = stack.pop()!; + component.push(key); + const [u, v] = parseCellKey(key); + minU = Math.min(minU, u); + maxU = Math.max(maxU, u); + minV = Math.min(minV, v); + maxV = Math.max(maxV, v); + + for (const [du, dv] of FACE_REGION_NEIGHBORS) { + const nextKey = cellKey(u + du, v + dv); + const nextColor = cells.get(nextKey); + if (!nextColor) continue; + if (nextColor === sourceColor) { + if (!visited.has(nextKey)) { + visited.add(nextKey); + stack.push(nextKey); + } + continue; + } + neighborContacts.set(nextColor, (neighborContacts.get(nextColor) ?? 0) + 1); + } + } + + const width = maxU - minU + 1; + const height = maxV - minV + 1; + if (!isCleanupRegion(component.length, width, height) || neighborContacts.size === 0) continue; + const source = parsedColor(sourceColor); + if (!source) continue; + + let best: { color: string; contacts: number; distance: number; count: number } | null = null; + for (const [targetColor, contacts] of neighborContacts) { + const targetCount = planeColorCounts.get(targetColor) ?? 0; + if (targetCount <= component.length) continue; + const target = parsedColor(targetColor); + if (!target || !canMergeVoxColors(source, target)) continue; + const distance = colorDistance(source, target); + if (distance > maxDistance) continue; + const candidate = { color: targetColor, contacts, distance, count: targetCount }; + if ( + !best || + candidate.contacts > best.contacts || + (candidate.contacts === best.contacts && candidate.count > best.count) || + (candidate.contacts === best.contacts && candidate.count === best.count && candidate.distance < best.distance) || + ( + candidate.contacts === best.contacts && + candidate.count === best.count && + candidate.distance === best.distance && + candidate.color.localeCompare(best.color) < 0 + ) + ) { + best = candidate; + } + } + + if (!best) continue; + for (const key of component) updates.push([key, best.color]); + } + + for (const [key, color] of updates) cells.set(key, color); + } +} + const VOX_MAGIC = 0x20584f56; // "VOX " as little-endian uint32 // ── Face winding quads ─────────────────────────────────────────────────────── @@ -267,15 +511,34 @@ export function parseVox(buffer: ArrayBuffer, options?: VoxParseOptions): ParseR // colorIndex in XYZI is 1-based → palette[colorIndex - 1]. // Custom RGBA palette: entry[0] is palette[0] (for colorIndex 1), etc. // Default palette: index 0 is unused dummy, index 1 = first real color. - const resolveColor = (colorIndex: number): string => { + const colorCache = new Map(); + const exactColorFor = (colorIndex: number): string => { + const cached = colorCache.get(colorIndex); + if (cached) return cached; const idx = colorIndex - 1; // 0-based + let color: string; if (customPalette !== null) { - return customPalette[idx] ?? "#888888"; + color = customPalette[idx] ?? "#888888"; + } else { + // Default palette is a uint32 array; entry 0 unused. + const packed = DEFAULT_PALETTE_RGBA[colorIndex] ?? 0; + const [r, g, b] = rgbFromPacked(packed); + color = colorFromRgb(r, g, b); } - // Default palette is a uint32 array; entry 0 unused. - const packed = DEFAULT_PALETTE_RGBA[colorIndex] ?? 0; - const [r, g, b] = rgbFromPacked(packed); - return colorFromRgb(r, g, b); + colorCache.set(colorIndex, color); + return color; + }; + + const colorCounts = new Map(); + for (const voxel of voxels) { + const color = exactColorFor(voxel.colorIndex); + colorCounts.set(color, (colorCounts.get(color) ?? 0) + 1); + } + const paletteMergeMap = buildPaletteMergeMap(colorCounts, options?.paletteMergeDistance); + + const resolveColor = (colorIndex: number): string => { + const exact = exactColorFor(colorIndex); + return paletteMergeMap?.get(exact) ?? exact; }; // 4. Use the deduped occupancy set for face-culling. @@ -292,14 +555,15 @@ export function parseVox(buffer: ArrayBuffer, options?: VoxParseOptions): ParseR // +X / -X: plane = x, in-plane = (y, z) // +Y / -Y: plane = y, in-plane = (x, z) // +Z / -Z: plane = z, in-plane = (x, y) + type PlaneKey = string; // `${dir}:${plane}` type GroupKey = string; // `${dir}:${plane}:${color}` - const groups = new Map>(); // key → set of "u,v" cells + const facePlanes = new Map>(); // key → "u,v" cell color const addCell = (dir: number, plane: number, u: number, v: number, color: string): void => { - const key = `${dir}:${plane}:${color}`; - let cells = groups.get(key); - if (!cells) { cells = new Set(); groups.set(key, cells); } - cells.add(`${u},${v}`); + const key = `${dir}:${plane}`; + let cells = facePlanes.get(key); + if (!cells) { cells = new Map(); facePlanes.set(key, cells); } + cells.set(cellKey(u, v), color); }; for (const v of voxels) { @@ -314,6 +578,18 @@ export function parseVox(buffer: ArrayBuffer, options?: VoxParseOptions): ParseR if (!hasNeighbor(x, y, z - 1)) addCell(5, z, x, y, color); // -Z } + cleanupFacePlaneRegions(facePlanes, options?.colorRegionMergeDistance); + + const groups = new Map>(); + for (const [planeKey, cells] of facePlanes) { + for (const [key, color] of cells) { + const groupKey = `${planeKey}:${color}`; + let group = groups.get(groupKey); + if (!group) { group = new Set(); groups.set(groupKey, group); } + group.add(key); + } + } + // Greedy-mesh a 2D occupancy set into maximal axis-aligned rectangles. // Standard algorithm: scan in (v, u) order; for each unvisited cell, // extend right (u++) until a gap, then extend down (v++) while the diff --git a/packages/polycss/README.md b/packages/polycss/README.md index 11437ba3..dfee357d 100644 --- a/packages/polycss/README.md +++ b/packages/polycss/README.md @@ -88,7 +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. +- Solid seam bleed is automatic on detected shared solid edges. - `strategies` can disable selected render strategies for diagnostics. - `autoCenter` rotates around the rendered mesh bounds instead of world origin. @@ -180,6 +180,15 @@ polycss renders in the DOM, so performance is mostly determined by how many poly - Voxel-shaped meshes mount only camera-facing leaves when the mesh is eligible. - `meshResolution: "lossy"` merges compatible polygons, then may spend a small split budget to repair high-risk seams. +Renderer internals: + +Each visible polygon is emitted as one leaf element; the renderer chooses the least expensive CSS primitive that can represent the polygon, then uses `matrix3d(...)` to place that primitive in 3D space. + +- `` uses `background: currentColor` on a fixed box for solid rectangles and stable quads. +- `` uses `corner-shape` for stable triangles and beveled-corner solids, with a `border-width` triangle fallback when needed. +- `` clips solid polygons with `border-shape: polygon(...)` when the browser supports it. +- `` maps a packed texture-atlas slice with `background-image`, and is the fallback for textured or unsupported shapes. + 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. ## Packages diff --git a/packages/polycss/src/api/createPolyAnimationMixer.test.ts b/packages/polycss/src/api/createPolyAnimationMixer.test.ts index 84da4f03..39fa6ffc 100644 --- a/packages/polycss/src/api/createPolyAnimationMixer.test.ts +++ b/packages/polycss/src/api/createPolyAnimationMixer.test.ts @@ -8,6 +8,22 @@ import { createPolyAnimationMixer, LoopOnce } from "@layoutit/polycss-core"; import type { ParseAnimationController, ParseAnimationClip, Polygon } from "@layoutit/polycss-core"; import { createPolyOrthographicCamera } from "./createPolyCamera"; +const POLY_ANIMATION_TRIANGLE_FRAME_SOURCE = Symbol.for("polycss.animation.triangleFrameSource"); + +interface PolyAnimationTriangleFrame { + polygonCount: number; + vertices: Float64Array; + colors?: readonly (string | undefined)[]; + solidTriangles?: boolean; +} + +interface PolyAnimationTriangleFrameSource { + [POLY_ANIMATION_TRIANGLE_FRAME_SOURCE]?: ( + clip: number | string, + timeSeconds: number, + ) => PolyAnimationTriangleFrame | null | undefined; +} + const TRI: Polygon = { vertices: [[0, 0, 0], [1, 0, 0], [0, 1, 0]], color: "#ff0000", @@ -32,6 +48,18 @@ function makeController( }; } +function frameVertices(polygon: Polygon): Float64Array { + const vertices = new Float64Array(9); + for (let vertexIndex = 0; vertexIndex < 3; vertexIndex++) { + const vertex = polygon.vertices[vertexIndex]!; + const offset = vertexIndex * 3; + vertices[offset] = vertex[0]; + vertices[offset + 1] = vertex[1]; + vertices[offset + 2] = vertex[2]; + } + return vertices; +} + describe("createPolyAnimationMixer with PolyMeshHandle", () => { let host: HTMLElement; @@ -114,6 +142,53 @@ describe("createPolyAnimationMixer with PolyMeshHandle", () => { scene.destroy(); }); + it("keeps stable triangle baked color pinned on the triangle-frame fast path", () => { + const scene = createPolyScene(host, { + camera: createPolyOrthographicCamera(), + directionalLight: { direction: [0, 0, 1], color: "#ffffff", intensity: 1 }, + ambientLight: { color: "#ffffff", intensity: 0 }, + }); + const restTriangle: Polygon = { + vertices: [[0, 0, 0], [1, 0, 0], [0, 1, 1]], + color: "#ff0000", + }; + const animatedTriangle: Polygon = { + vertices: [[0, 0, 0], [2, 0, 0], [0, 1, 1]], + color: "#ff0000", + }; + const parseResult = { + polygons: [restTriangle], + objectUrls: [], + dispose: () => {}, + warnings: [], + }; + const mesh = scene.add(parseResult, { merge: false, stableDom: true }); + const leaf = host.querySelector("u") as HTMLElement; + const initialTransform = leaf.style.transform; + const initialColor = leaf.style.color; + const clip = makeClip(0, "bend"); + const ctrl = { + clips: [clip], + sample: () => [animatedTriangle], + [POLY_ANIMATION_TRIANGLE_FRAME_SOURCE]: () => ({ + polygonCount: 1, + vertices: frameVertices(animatedTriangle), + colors: [animatedTriangle.color], + solidTriangles: true, + }), + } satisfies ParseAnimationController & PolyAnimationTriangleFrameSource; + const mixer = createPolyAnimationMixer(mesh, ctrl); + + mixer.clipAction("bend").play(); + mixer.update(0.1); + + expect(leaf.style.transform).not.toBe(initialTransform); + expect(leaf.style.color).toBe(initialColor); + + mesh.dispose(); + scene.destroy(); + }); + it("stopAllAction stops mesh updates", () => { const scene = createPolyScene(host, { camera: createPolyOrthographicCamera() }); const parseResult = { diff --git a/packages/polycss/src/api/createPolyScene.test.ts b/packages/polycss/src/api/createPolyScene.test.ts index 90acb12a..efefeace 100644 --- a/packages/polycss/src/api/createPolyScene.test.ts +++ b/packages/polycss/src/api/createPolyScene.test.ts @@ -5,7 +5,6 @@ */ 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, @@ -518,6 +517,24 @@ describe("createPolyScene", () => { )).toBe(true); }); + it("normalizes direct voxel seam colors before matching shared edges", () => { + scene = makeScene(host, { + directionalLight: { direction: [0, 0, 1], color: "#ffffff", intensity: 0 }, + ambientLight: { color: "#ffffff", intensity: 1 }, + }, { rotX: 65, rotY: 45 }); + scene.add(makeVoxelExactPolygonsParseResult([ + topQuad("#123456"), + sideQuad("rgb(18, 52, 86)"), + ]), { merge: false }); + + const brushes = Array.from(host.querySelectorAll(DIRECT_VOXEL_BRUSH_SELECTOR)) as HTMLElement[]; + expect(brushes.length).toBeGreaterThan(0); + const matrices = brushes.map(matrixValues); + expect(matrices.some((values) => + values.some((value) => Math.abs(value - 50.6) <= 1e-6) + )).toBe(true); + }); + it("keeps different-color shared direct voxel edges exact", () => { scene = makeScene(host, { directionalLight: { direction: [0, 0, 1], color: "#ffffff", intensity: 0 }, @@ -1345,32 +1362,6 @@ 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 8dde9ec7..6ce7c9b9 100644 --- a/packages/polycss/src/api/createPolyScene.ts +++ b/packages/polycss/src/api/createPolyScene.ts @@ -2333,7 +2333,9 @@ export function createPolyScene( stableTriangleUpdateMode: options?.stableTriangleUpdateMode, stableTriangleColorPolicy: options?.stableTriangleColorPolicy, stableTriangleColorSteps: options?.stableTriangleColorSteps, - stableTriangleColorFreezeFrames: options?.stableTriangleColorFreezeFrames, + // Triangle-frame animation updates transforms directly; pin baked + // color unless an internal caller opts into color refresh. + stableTriangleColorFreezeFrames: options?.stableTriangleColorFreezeFrames ?? 0, stableTriangleColorBudget: options?.stableTriangleColorBudget, stableTriangleColorMaxAge: options?.stableTriangleColorMaxAge, stableTriangleColorMaxStep: options?.stableTriangleColorMaxStep, diff --git a/packages/polycss/src/elements/PolySceneElement.test.ts b/packages/polycss/src/elements/PolySceneElement.test.ts index 6df64249..68ebfcf4 100644 --- a/packages/polycss/src/elements/PolySceneElement.test.ts +++ b/packages/polycss/src/elements/PolySceneElement.test.ts @@ -3,7 +3,6 @@ * 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(() => { @@ -36,7 +35,6 @@ 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"); @@ -165,22 +163,6 @@ 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 8c852fbe..8eece21f 100644 --- a/packages/polycss/src/elements/PolySceneElement.ts +++ b/packages/polycss/src/elements/PolySceneElement.ts @@ -48,7 +48,6 @@ const OBSERVED_ATTRS = [ "ambient-intensity", "texture-lighting", "texture-quality", - "seam-bleed", "auto-center", ] as const; @@ -82,11 +81,6 @@ 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; }; @@ -156,8 +150,6 @@ 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/index.ts b/packages/polycss/src/index.ts index 65d20ea2..bd7195cf 100644 --- a/packages/polycss/src/index.ts +++ b/packages/polycss/src/index.ts @@ -88,7 +88,6 @@ export { PolySelectElement } from "./elements/PolySelectElement"; export type { PolyRenderStrategy, PolyRenderStrategiesOption, - PolySeamBleed, TextureQuality, TextureAtlasPlan, PackedTextureAtlasEntry, diff --git a/packages/polycss/src/render/atlas/renderPolygons.ts b/packages/polycss/src/render/atlas/renderPolygons.ts index b3f4df52..8ee9a311 100644 --- a/packages/polycss/src/render/atlas/renderPolygons.ts +++ b/packages/polycss/src/render/atlas/renderPolygons.ts @@ -21,7 +21,6 @@ import { PROJECTIVE_QUAD_DENOM_EPS, PROJECTIVE_QUAD_MAX_WEIGHT_RATIO, PROJECTIVE_QUAD_BLEED, - DEFAULT_SEAM_BLEED, } from "@layoutit/polycss-core"; import { buildBasisHints, @@ -75,7 +74,10 @@ import { } from "./stableTriangle"; import { stableTriangleMatrixDecimals } from "@layoutit/polycss-core"; +const DEFAULT_SOLID_SEAM_BLEED = 1.5; + type RenderTextureAtlasOptionsWithSeams = RenderTextureAtlasOptions & { + seamBleed?: number; seamEdges?: Set; }; @@ -93,38 +95,21 @@ function seamTriangleOptions( plan: TextureAtlasPlan, options: RenderTextureAtlasOptions, ): RenderTextureAtlasOptionsWithSeams { - const seamBleed = effectiveSeamBleed(options); return plan.seamBleedEdges?.size - ? { ...options, seamBleed, seamEdges: plan.seamBleedEdges } + ? { ...options, seamBleed: DEFAULT_SOLID_SEAM_BLEED, 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; + return buildSeamBleedPolygonEdges(polygons, { + tileSize: options.tileSize, + layerElevation: options.layerElevation, + directionalLight: options.directionalLight, + ambientLight: options.ambientLight, + }); } function seamAtlasOptions( @@ -132,11 +117,10 @@ function seamAtlasOptions( seamBleedEdges: Map> | null, options: RenderTextureAtlasOptions, ): RenderTextureAtlasOptionsWithSeams { - const seamBleed = effectiveSeamBleed(options); return seamBleedEdges ? { ...options, - seamBleed: seamBleedEdges.has(index) ? seamBleed : undefined, + seamBleed: seamBleedEdges.has(index) ? DEFAULT_SOLID_SEAM_BLEED : undefined, seamEdges: seamBleedEdges.get(index), } : options; @@ -462,6 +446,10 @@ export function updateStableTriangleFrame( const colorState = stableTriangleColorState(internalOptions); const tile = options.tileSize ?? DEFAULT_TILE; const elev = options.layerElevation ?? tile; + const doc = options.doc ?? (typeof document !== "undefined" ? document : null); + const solidTrianglePrimitive = doc + ? resolveSolidTrianglePrimitive(doc, options.strategies) ?? "border" + : "border"; for (let i = 0; i < rendered.length; i++) { const item = rendered[i]; @@ -499,6 +487,7 @@ export function updateStableTriangleFrame( { basis: element.__polycssSolidTriangleBasis, matrixDecimals, + primitive: solidTrianglePrimitive, color: frame.colors?.[i], includeColor: stableTriangleUpdateMode !== "plan-only" && stableTriangleUpdateMode !== "transform-only" && @@ -570,6 +559,9 @@ export function updatePolygonsWithStableTopology( const colorOnly = optimizeTriangleStyle && stableTriangleUpdateMode === "color-only"; const colorState = stableTriangleColorState(internalOptions); const matrixDecimals = stableTriangleMatrixDecimals(internalOptions.stableTriangleMatrixDecimals); + const solidTrianglePrimitive = doc + ? resolveSolidTrianglePrimitive(doc, options.strategies) ?? "border" + : "border"; if ( updateStableTriangleElementsStreaming( rendered, @@ -621,6 +613,7 @@ export function updatePolygonsWithStableTopology( const plan = computeSolidTrianglePlan(polygon, i, options, { basis: element.__polycssSolidTriangleBasis, matrixDecimals, + primitive: solidTrianglePrimitive, includeColor: stableTriangleUpdateMode !== "plan-only" && stableTriangleUpdateMode !== "transform-only" && shouldComputeStableTriangleColor( diff --git a/packages/polycss/src/render/atlas/stableTriangle.test.ts b/packages/polycss/src/render/atlas/stableTriangle.test.ts index 457ec740..803186f7 100644 --- a/packages/polycss/src/render/atlas/stableTriangle.test.ts +++ b/packages/polycss/src/render/atlas/stableTriangle.test.ts @@ -125,18 +125,17 @@ describe("renderPolygonsWithStableTriangles — initial render", () => { result!.dispose(); }); - it("applies seamBleed to detected shared triangle edges", () => { + it("applies internal seam bleed 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, - })!; + const baseA = renderPolygonsWithStableTriangles([TRIANGLE_A], { doc })!; + const baseB = renderPolygonsWithStableTriangles([ADJACENT_TRIANGLE_A], { doc })!; + const bleed = renderPolygonsWithStableTriangles([TRIANGLE_A, ADJACENT_TRIANGLE_A], { doc })!; expect(bleed.rendered[0].element.style.transform) - .not.toBe(base.rendered[0].element.style.transform); + .not.toBe(baseA.rendered[0].element.style.transform); expect(bleed.rendered[1].element.style.transform) - .not.toBe(base.rendered[1].element.style.transform); - base.dispose(); + .not.toBe(baseB.rendered[0].element.style.transform); + baseA.dispose(); + baseB.dispose(); bleed.dispose(); }); diff --git a/packages/polycss/src/render/atlas/stableTriangle.ts b/packages/polycss/src/render/atlas/stableTriangle.ts index 981ca007..81e76592 100644 --- a/packages/polycss/src/render/atlas/stableTriangle.ts +++ b/packages/polycss/src/render/atlas/stableTriangle.ts @@ -5,7 +5,6 @@ import { SOLID_TRIANGLE_CORNER_CLASS, DEFAULT_MATRIX_DECIMALS, BASIS_EPS, - DEFAULT_SEAM_BLEED, } from "@layoutit/polycss-core"; import type { SolidTrianglePlan, @@ -36,36 +35,23 @@ import { stableTriangleMatrixDecimals } from "@layoutit/polycss-core"; import { applyPolygonDataAttrs, hasPolygonDataAttrs, clearAtlasImageStyles } from "./emit"; import { resolveSolidTrianglePrimitive } from "./strategy"; +const DEFAULT_SOLID_SEAM_BLEED = 1.5; + type RenderTextureAtlasOptionsWithSeams = RenderTextureAtlasOptions & { + seamBleed?: number; 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; + return buildSeamBleedPolygonEdges(polygons, { + tileSize: options.tileSize, + layerElevation: options.layerElevation, + directionalLight: options.directionalLight, + ambientLight: options.ambientLight, + }); } function stableTriangleSeamOptions( @@ -73,11 +59,10 @@ function stableTriangleSeamOptions( seamBleedEdges: Map> | null, options: RenderTextureAtlasOptions, ): RenderTextureAtlasOptionsWithSeams { - const seamBleed = effectiveSeamBleed(options); return seamBleedEdges ? { ...options, - seamBleed: seamBleedEdges.has(index) ? seamBleed : undefined, + seamBleed: seamBleedEdges.has(index) ? DEFAULT_SOLID_SEAM_BLEED : undefined, seamEdges: seamBleedEdges.get(index), } : options; @@ -106,6 +91,7 @@ export function shouldComputeStableTriangleColor( colorState: StableTriangleColorState, ): boolean { if (!optimizeTriangleStyle) return true; + if (colorState.updatesDisabled) return false; if (stableTriangleDebug === "plan-only" || stableTriangleDebug === "transform-only") return false; if (stableTriangleColorPolicy === "adaptive") return true; if ((element as SolidTriangleElement).__polycssSolidTriangleColor === undefined) return true; @@ -335,6 +321,10 @@ export function updateStableTriangleElementsStreaming( if (internalOptions.stableTriangleColorPolicy === "adaptive") return false; if (rendered.length !== polygons.length) return false; const matrixDecimals = stableTriangleMatrixDecimals(internalOptions.stableTriangleMatrixDecimals); + const doc = options.doc ?? (typeof document !== "undefined" ? document : null); + const solidTrianglePrimitive = doc + ? resolveSolidTrianglePrimitive(doc, options.strategies) ?? "border" + : "border"; for (let i = 0; i < rendered.length; i++) { const item = rendered[i]; @@ -370,6 +360,7 @@ export function updateStableTriangleElementsStreaming( const plan = computeSolidTrianglePlan(polygon, i, stableTriangleSeamOptions(i, seamBleedEdges, options), { basis: element.__polycssSolidTriangleBasis, matrixDecimals, + primitive: solidTrianglePrimitive, includeColor: stableTriangleUpdateMode !== "plan-only" && stableTriangleUpdateMode !== "transform-only" && shouldComputeStableTriangleColor( @@ -426,6 +417,10 @@ export function captureStableTriangleTransformFrame( const colorState = stableTriangleColorState(internalOptions); const tile = options.tileSize ?? DEFAULT_TILE; const elev = options.layerElevation ?? tile; + const doc = options.doc ?? (typeof document !== "undefined" ? document : null); + const solidTrianglePrimitive = doc + ? resolveSolidTrianglePrimitive(doc, options.strategies) ?? "border" + : "border"; for (let i = 0; i < rendered.length; i++) { const item = rendered[i]; @@ -464,6 +459,7 @@ export function captureStableTriangleTransformFrame( { basis: element.__polycssSolidTriangleBasis, matrixDecimals, + primitive: solidTrianglePrimitive, color: frame.colors?.[i], includeColor: stableTriangleUpdateMode !== "plan-only" && stableTriangleUpdateMode !== "transform-only" && @@ -600,6 +596,7 @@ export function updatePolygonsWithStableTriangles( nextTrianglePlans[i] = computeSolidTrianglePlan(polygons[i], i, stableTriangleSeamOptions(i, seamBleedEdges, options), { basis: element.__polycssSolidTriangleBasis, matrixDecimals, + primitive: solidTrianglePrimitive, includeColor: stableTriangleUpdateMode !== "plan-only" && stableTriangleUpdateMode !== "transform-only" && shouldComputeStableTriangleColor( diff --git a/packages/polycss/src/render/atlas/types.ts b/packages/polycss/src/render/atlas/types.ts index 583259af..44da5c4c 100644 --- a/packages/polycss/src/render/atlas/types.ts +++ b/packages/polycss/src/render/atlas/types.ts @@ -22,10 +22,11 @@ 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 { + seamBleed?: number; + seamEdges?: Set; optimizeStableTriangleStyle?: boolean; stableTriangleDebug?: "transform-only" | "plan-only"; stableTriangleUpdateMode?: "full" | "transform-only" | "color-only"; diff --git a/packages/polycss/src/render/polyDOM.test.ts b/packages/polycss/src/render/polyDOM.test.ts index f7e277af..9c8351dc 100644 --- a/packages/polycss/src/render/polyDOM.test.ts +++ b/packages/polycss/src/render/polyDOM.test.ts @@ -454,61 +454,40 @@ 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", - }); + it("applies internal seam bleed to detected shared triangle edges", () => { + const isolatedTriangle = renderPolygonsWithTextureAtlas([FLAT_TRIANGLE], { tileSize: 1 }); + const sharedTriangle = renderPolygonsWithTextureAtlas([FLAT_TRIANGLE, ADJACENT_FLAT_TRIANGLE], { tileSize: 1 }); - 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"); + expect(sharedTriangle.rendered).toHaveLength(2); + expect(sharedTriangle.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 baseTriangleMatrix = extractMatrix(isolatedTriangle.rendered[0].element); + const bleedTriangleMatrix = extractMatrix(sharedTriangle.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(sharedTriangle.rendered[0].element.style.transform) + .not.toBe(isolatedTriangle.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(); + isolatedTriangle.dispose(); + sharedTriangle.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 }); + it("applies internal seam bleed only along shared sides for rectangular solids", () => { + const base = [ + renderPolygonsWithTextureAtlas([VERTICAL_QUAD], { tileSize: 10 }), + renderPolygonsWithTextureAtlas([ADJACENT_VERTICAL_QUAD], { tileSize: 10 }), + ]; + const bleed = renderPolygonsWithTextureAtlas([VERTICAL_QUAD, ADJACENT_VERTICAL_QUAD], { tileSize: 10 }); 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 baseMatrix = extractMatrix(base[i].rendered[0].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]); @@ -521,21 +500,23 @@ describe("renderPolygonsWithTextureAtlas", () => { else expect(bleedX).toBeCloseTo(baseX, 6); } - base.dispose(); + for (const result of base) result.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 }); + it("applies internal seam bleed to both sides of non-coplanar shared edges", () => { + const baseA = renderPolygonsWithTextureAtlas([FLAT_TRIANGLE], { tileSize: 1 }); + const baseB = renderPolygonsWithTextureAtlas([HINGED_TRIANGLE], { tileSize: 1 }); + const bleed = renderPolygonsWithTextureAtlas([FLAT_TRIANGLE, HINGED_TRIANGLE], { tileSize: 1 }); - expect(bleed.rendered).toHaveLength(base.rendered.length); + expect(bleed.rendered).toHaveLength(2); 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); + expect(bleed.rendered[0].element.style.transform).not.toBe(baseA.rendered[0].element.style.transform); + expect(bleed.rendered[1].element.style.transform).not.toBe(baseB.rendered[0].element.style.transform); - base.dispose(); + baseA.dispose(); + baseB.dispose(); bleed.dispose(); }); diff --git a/packages/polycss/src/render/voxelRenderer.ts b/packages/polycss/src/render/voxelRenderer.ts index 0d690c24..dbec3ba7 100644 --- a/packages/polycss/src/render/voxelRenderer.ts +++ b/packages/polycss/src/render/voxelRenderer.ts @@ -187,7 +187,7 @@ function polygonBrush(polygon: Polygon): Omit | } const eps = 1e-6; - const baseColor = polygon.color || "#cccccc"; + const baseColor = canonicalBrushColor(polygon.color); if (Math.abs(maxZ - minZ) <= eps) { return { axis: "z", @@ -349,6 +349,23 @@ function parseColor(input: string): RGB { }; } +function canonicalBrushColor(input: string | undefined): string { + if (!input) return "#cccccc"; + const parsed = parsePureColor(input); + if (!parsed) return input; + const rgb: RGB = { + r: parsed.rgb[0], + g: parsed.rgb[1], + b: parsed.rgb[2], + alpha: parsed.alpha, + }; + if (rgb.alpha < 1) { + const alpha = Math.round(Math.max(0, rgb.alpha) * 1000) / 1000; + return `rgba(${clampChannel(rgb.r)}, ${clampChannel(rgb.g)}, ${clampChannel(rgb.b)}, ${alpha})`; + } + return rgbToHex(rgb); +} + function rgbToHex({ r, g, b }: RGB): string { const f = (n: number) => Math.round(Math.max(0, Math.min(255, n))).toString(16).padStart(2, "0"); diff --git a/packages/react/README.md b/packages/react/README.md index 11437ba3..dfee357d 100644 --- a/packages/react/README.md +++ b/packages/react/README.md @@ -88,7 +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. +- Solid seam bleed is automatic on detected shared solid edges. - `strategies` can disable selected render strategies for diagnostics. - `autoCenter` rotates around the rendered mesh bounds instead of world origin. @@ -180,6 +180,15 @@ polycss renders in the DOM, so performance is mostly determined by how many poly - Voxel-shaped meshes mount only camera-facing leaves when the mesh is eligible. - `meshResolution: "lossy"` merges compatible polygons, then may spend a small split budget to repair high-risk seams. +Renderer internals: + +Each visible polygon is emitted as one leaf element; the renderer chooses the least expensive CSS primitive that can represent the polygon, then uses `matrix3d(...)` to place that primitive in 3D space. + +- `` uses `background: currentColor` on a fixed box for solid rectangles and stable quads. +- `` uses `corner-shape` for stable triangles and beveled-corner solids, with a `border-width` triangle fallback when needed. +- `` clips solid polygons with `border-shape: polygon(...)` when the browser supports it. +- `` maps a packed texture-atlas slice with `background-image`, and is the fallback for textured or unsupported shapes. + 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. ## Packages diff --git a/packages/react/src/index.ts b/packages/react/src/index.ts index ab67e7ed..28fd33a2 100644 --- a/packages/react/src/index.ts +++ b/packages/react/src/index.ts @@ -39,7 +39,6 @@ export type { InteractionProps, PolyRenderStrategy, PolyRenderStrategiesOption, - PolySeamBleed, } from "./scene"; export { Poly } from "./shapes"; @@ -237,7 +236,6 @@ export { buildSceneContext, computeSceneBbox, BASE_TILE, - DEFAULT_SEAM_BLEED, DEFAULT_CAMERA_STATE, DEFAULT_PROJECTION, normalizeInvertMultiplier, diff --git a/packages/react/src/scene/PolyMesh.castShadow.test.tsx b/packages/react/src/scene/PolyMesh.castShadow.test.tsx index a9ed79cd..89a88725 100644 --- a/packages/react/src/scene/PolyMesh.castShadow.test.tsx +++ b/packages/react/src/scene/PolyMesh.castShadow.test.tsx @@ -95,6 +95,13 @@ function rerender( ); } +async function flushReactWork(): Promise { + await act(async () => { + await Promise.resolve(); + await new Promise((resolve) => setTimeout(resolve, 0)); + }); +} + describe("PolyMesh — castShadow", () => { afterEach(() => { document.body.innerHTML = ""; @@ -186,13 +193,14 @@ describe("PolyMesh — castShadow", () => { expect(after.style.transform).toMatch(/^translate3d\(/); }); - it("textured polygons (s) ALSO emit shadow leaves (Frog Guy regression)", () => { + it("textured polygons (s) ALSO emit shadow leaves (Frog Guy regression)", async () => { // Shadows depend only on the polygon outline, not the texture content. // Fully textured meshes must cast shadows or the Frog Guy gets no shadow. const { container } = renderScene(DYN_SCENE_PROPS, { polygons: [TEXTURED_TRIANGLE], castShadow: true, }); + await flushReactWork(); expect(container.querySelectorAll(".polycss-shadow").length).toBe(1); }); diff --git a/packages/react/src/scene/PolyMesh.test.tsx b/packages/react/src/scene/PolyMesh.test.tsx index ff1e05e8..a93693c1 100644 --- a/packages/react/src/scene/PolyMesh.test.tsx +++ b/packages/react/src/scene/PolyMesh.test.tsx @@ -26,6 +26,66 @@ const QUAD: Polygon = { color: "#00ff00", }; +interface VoxelInput { + x: number; + y: number; + z: number; + colorIndex: number; +} + +function buildVoxBuffer(size: [number, number, number], voxels: VoxelInput[]): ArrayBuffer { + const sizeChunkBytes = 12 + 12; + const xyziChunkBytes = 12 + 4 + voxels.length * 4; + const childrenSize = sizeChunkBytes + xyziChunkBytes; + const buf = new ArrayBuffer(8 + 12 + childrenSize); + const dv = new DataView(buf); + const u8 = new Uint8Array(buf); + let off = 0; + const writeId = (id: string) => { + for (let i = 0; i < 4; i += 1) u8[off++] = id.charCodeAt(i); + }; + const writeU32 = (value: number) => { + dv.setUint32(off, value, true); + off += 4; + }; + const writeU8 = (value: number) => { + u8[off++] = value; + }; + + writeId("VOX "); + writeU32(150); + writeId("MAIN"); + writeU32(0); + writeU32(childrenSize); + writeId("SIZE"); + writeU32(12); + writeU32(0); + writeU32(size[0]); + writeU32(size[1]); + writeU32(size[2]); + writeId("XYZI"); + writeU32(4 + voxels.length * 4); + writeU32(0); + writeU32(voxels.length); + for (const voxel of voxels) { + writeU8(voxel.x); + writeU8(voxel.y); + writeU8(voxel.z); + writeU8(voxel.colorIndex); + } + return buf; +} + +function mockFetchVox(): void { + const buffer = buildVoxBuffer([1, 1, 1], [{ x: 0, y: 0, z: 0, colorIndex: 1 }]); + vi.stubGlobal("fetch", vi.fn().mockResolvedValue({ + ok: true, + status: 200, + text: () => Promise.resolve(""), + arrayBuffer: () => Promise.resolve(buffer), + })); +} + const OFFSET_TEXTURED_TRIANGLE: Polygon = { vertices: [ [10, 0, 0], @@ -364,6 +424,46 @@ describe("PolyMesh — loading and error states (with src)", () => { }); }); +describe("PolyMesh — direct voxel fast path", () => { + afterEach(() => { + vi.restoreAllMocks(); + vi.unstubAllGlobals(); + document.body.innerHTML = ""; + }); + + it("routes eligible .vox src meshes through face-wrapper direct brushes", async () => { + mockFetchVox(); + const container = renderMesh({ src: "https://example.com/model.vox" }); + + await act(async () => { + await new Promise((resolve) => setTimeout(resolve, 100)); + }); + + const mesh = container.querySelector(".polycss-mesh") as HTMLElement | null; + const faceHosts = container.querySelectorAll(".polycss-mesh > .polycss-voxel-face"); + const brushes = container.querySelectorAll(".polycss-mesh > .polycss-voxel-face > b"); + expect(mesh?.classList.contains("polycss-voxel-mesh")).toBe(true); + expect(faceHosts.length).toBeGreaterThan(0); + expect(brushes.length).toBeGreaterThan(0); + expect(container.querySelector(".polycss-mesh > b")).toBeNull(); + }); + + it("falls back to polygon rendering for .vox src meshes in dynamic lighting", async () => { + mockFetchVox(); + const container = renderMesh({ + src: "https://example.com/model.vox", + textureLighting: "dynamic", + }); + + await act(async () => { + await new Promise((resolve) => setTimeout(resolve, 100)); + }); + + expect(container.querySelector(".polycss-mesh > .polycss-voxel-face")).toBeNull(); + expect(container.querySelector(".polycss-mesh > b,.polycss-mesh > i,.polycss-mesh > s,.polycss-mesh > u")).not.toBeNull(); + }); +}); + describe("PolyMesh — rebakeAtlas", () => { afterEach(() => { vi.restoreAllMocks(); diff --git a/packages/react/src/scene/PolyMesh.tsx b/packages/react/src/scene/PolyMesh.tsx index 2c0609b5..63db5a71 100644 --- a/packages/react/src/scene/PolyMesh.tsx +++ b/packages/react/src/scene/PolyMesh.tsx @@ -20,6 +20,7 @@ import { useContext, useEffect, useImperativeHandle, + useLayoutEffect, useMemo, useRef, useState, @@ -64,6 +65,7 @@ import { } from "./atlas"; import { usePolySceneContext } from "./sceneContext"; import { PolyCameraContext } from "../camera/context"; +import { createPolyVoxelRenderer, type PolyVoxelRenderer } from "./voxelRenderer"; import { findPolyMeshHandle, registerMeshElement, @@ -235,6 +237,7 @@ export const PolyMesh = forwardRef(function PolyM const fetched = usePolyMesh(src ?? "", mergedOptions); const externalPolygons = src ? fetched.polygons : (polygonsProp ?? []); + const externalVoxelSource = src ? fetched.voxelSource : undefined; // Local override array written by updatePolygon(). Null means no // imperative edits have been applied — the external source is used as-is. @@ -256,6 +259,7 @@ export const PolyMesh = forwardRef(function PolyM ? children as (polygon: Polygon, index: number) => ReactNode : null; const staticChildren: ReactNode = hasRenderProp ? null : children as ReactNode; + const hasStaticChildren = staticChildren !== null && staticChildren !== undefined && staticChildren !== false; // Re-center vertices into mesh-local space if autoCenter is set. Done // once per polygon-list identity — bake into vertices, not per frame. @@ -522,6 +526,15 @@ export const PolyMesh = forwardRef(function PolyM const effectiveAmbient = effectiveTextureLighting === "dynamic" ? undefined : sceneCtx?.ambientLight; + const directVoxelEnabled = Boolean( + externalVoxelSource && + localPolygons === null && + !renderPolygon && + !hasStaticChildren && + effectiveTextureLighting === "baked" && + !castShadow, + ); + // Dynamic-mode rotation fix: when the mesh has a non-zero rotation the // world-space light vars cascaded from are wrong for the // per-polygon Lambert calc (which uses mesh-local normals). Override @@ -560,7 +573,7 @@ export const PolyMesh = forwardRef(function PolyM const atlasPlans = useMemo( () => { - if (renderPolygon) return []; + if (renderPolygon || directVoxelEnabled) return []; const repairEdges = buildTextureEdgeRepairSets(polygons); const seamBleedEdges = effectiveSeamBleed === "auto" || ( typeof effectiveSeamBleed === "number" && @@ -580,7 +593,7 @@ export const PolyMesh = forwardRef(function PolyM textureEdgeRepairEdges: repairEdges[i], })); }, - [renderPolygon, polygons, bakedDirectional, effectiveAmbient, effectiveSeamBleed], + [renderPolygon, directVoxelEnabled, polygons, bakedDirectional, effectiveAmbient, effectiveSeamBleed], ); const textureAtlas = useTextureAtlas( atlasPlans, @@ -761,9 +774,9 @@ export const PolyMesh = forwardRef(function PolyM strategies: effectiveStrategies, seamBleed: effectiveSeamBleed, colorFrame: ++stableTriangleColorFrameRef.current, - colorSteps: 8, - colorFreezeFrames: 12, - colorMaxStep: 8, + // Animated low-poly triangles can swing face normals sharply; keep the + // mounted baked color pinned and animate transforms only. + colorFreezeFrames: 0, }) ) { return; @@ -771,6 +784,51 @@ export const PolyMesh = forwardRef(function PolyM setLocalPolygons([...nextPolygons]); }; + const voxelRendererRef = useRef(null); + useLayoutEffect(() => { + const root = wrapperRef.current; + voxelRendererRef.current?.dispose(); + voxelRendererRef.current = null; + if (!directVoxelEnabled || !root) return; + + const renderer = createPolyVoxelRenderer({ + doc: root.ownerDocument, + wrapper: root, + polygons, + directionalLight: bakedDirectional, + ambientLight: effectiveAmbient, + }); + if (!renderer) return; + + const cameraRotation = () => { + const cameraState = cameraCtx?.store.getState().cameraState; + return { + rotX: cameraState?.rotX ?? 65, + rotY: cameraState?.rotY ?? 45, + meshRotation: rotation, + }; + }; + + voxelRendererRef.current = renderer; + renderer.render(cameraRotation()); + const unsubscribe = cameraCtx?.store.subscribe(() => { + renderer.syncCamera(cameraRotation()); + }); + + return () => { + unsubscribe?.(); + renderer.dispose(); + if (voxelRendererRef.current === renderer) voxelRendererRef.current = null; + }; + }, [ + directVoxelEnabled, + polygons, + bakedDirectional, + effectiveAmbient, + cameraCtx?.store, + rotation, + ]); + const wrapperStyle: CSSProperties = { transform, ...(transformOrigin ? { transformOrigin } : null), @@ -864,7 +922,7 @@ export const PolyMesh = forwardRef(function PolyM
@@ -889,4 +947,3 @@ function RenderPropPolygon({ }) { return <>{children(polygon, index)}; } - diff --git a/packages/react/src/scene/PolyScene.test.tsx b/packages/react/src/scene/PolyScene.test.tsx index f6b44d76..e722d344 100644 --- a/packages/react/src/scene/PolyScene.test.tsx +++ b/packages/react/src/scene/PolyScene.test.tsx @@ -63,6 +63,13 @@ function renderScene( return container; } +async function flushReactWork(): Promise { + await act(async () => { + await Promise.resolve(); + await new Promise((resolve) => setTimeout(resolve, 0)); + }); +} + describe("PolyScene — basic rendering", () => { afterEach(() => { vi.restoreAllMocks(); @@ -208,7 +215,7 @@ describe("PolyScene — autoCenter", () => { expect(container.querySelector(".polycss-offset")).toBeNull(); }); - it("autoCenter contributes a non-zero translate3d inside scene transform for off-center polygons", () => { + it("autoCenter contributes a non-zero translate3d inside scene transform for off-center polygons", async () => { // With autoCenter=false the translate3d for QUAD at centroid (1,1,1) reflects // only target=[0,0,0], so it is translate3d(0px, 0px, 0px). const containerOff = renderScene({ polygons: [QUAD], autoCenter: false }); @@ -222,12 +229,13 @@ describe("PolyScene — autoCenter", () => { // CSS: cssX = worldY*50 = 50, cssY = worldX*50 = 50, cssZ = worldZ*50 = 50. // Expected translate3d(-50px, -50px, -50px). const containerOn = renderScene({ polygons: [QUAD], autoCenter: true }); + await flushReactWork(); const sceneOn = containerOn.querySelector(".polycss-scene") as HTMLElement; const transformOn = sceneOn.style.transform; expect(transformOn).toContain("translate3d(-50px, -50px, -50px)"); }); - it("target and autoCenterOffset are independent: pan survives mesh bbox change", () => { + it("target and autoCenterOffset are independent: pan survives mesh bbox change", async () => { // Render with TRIANGLE (centroid ~[0.33, 0.33, 0]) centered. // Then switch to QUAD (centroid [1, 1, 1]) — the centering offset updates // but any user pan delta in target remains unaffected. The two contributions @@ -236,6 +244,7 @@ describe("PolyScene — autoCenter", () => { // We verify this at the level of what matters: the scene element's // scene transform includes the bbox contribution without a wrapper div. const containerA = renderScene({ polygons: [QUAD], autoCenter: true }); + await flushReactWork(); const sceneA = containerA.querySelector(".polycss-scene") as HTMLElement; const tA = sceneA.style.transform; // QUAD centroid contributes (-50px, -50px, -50px) diff --git a/packages/react/src/scene/PolyScene.tsx b/packages/react/src/scene/PolyScene.tsx index 76f2c050..aebd4332 100644 --- a/packages/react/src/scene/PolyScene.tsx +++ b/packages/react/src/scene/PolyScene.tsx @@ -209,9 +209,10 @@ function PolySceneInner({ // Push the current autoCenterOffset into the store so applyTransformDirect // (called by controls during drag, bypassing React render) uses the same offset. - useEffect(() => { + useLayoutEffect(() => { store.setAutoCenterOffset(autoCenterOffset); - }, [store, autoCenterOffset]); + applyTransformDirect(); + }, [store, autoCenterOffset, applyTransformDirect]); // Scene transform is applied imperatively via applyTransformDirect (below), // not via React's style prop. This prevents Concurrent Mode from committing diff --git a/packages/react/src/scene/atlas/stableTriangleDom.ts b/packages/react/src/scene/atlas/stableTriangleDom.ts index a39f7461..af0abd73 100644 --- a/packages/react/src/scene/atlas/stableTriangleDom.ts +++ b/packages/react/src/scene/atlas/stableTriangleDom.ts @@ -69,7 +69,7 @@ function isStableTriangleBasis(value: StableTriangleBasis | undefined): value is interface StableTriangleDomStyle { transform: string; - color: string; + color?: string; basis: StableTriangleBasis; } @@ -235,28 +235,31 @@ function computeStableTriangleDomStyle( cvz + x2 * txXOffset + y2 * txYOffset, ); - const directionalCfg = options.directionalLight; - const ambientCfg = options.ambientLight; - const lightDir = directionalCfg?.direction ?? DEFAULT_LIGHT_DIR; - const lightColor = directionalCfg?.color ?? DEFAULT_LIGHT_COLOR; - const lightIntensity = Math.max(0, directionalCfg?.intensity ?? DEFAULT_LIGHT_INTENSITY); - const ambientColor = ambientCfg?.color ?? DEFAULT_AMBIENT_COLOR; - const ambientIntensity = Math.max(0, ambientCfg?.intensity ?? DEFAULT_AMBIENT_INTENSITY); - const lLen = Math.sqrt( - lightDir[0] * lightDir[0] + lightDir[1] * lightDir[1] + lightDir[2] * lightDir[2], - ) || 1; - const lx = lightDir[0] / lLen, ly = lightDir[1] / lLen, lz = lightDir[2] / lLen; - const directScale = lightIntensity * Math.max(0, nx * lx + ny * ly + nz * lz); - const shadedColor = shadePolygon( - polygon.color ?? "#cccccc", - directScale, - lightColor, - ambientColor, - ambientIntensity, - ); - const color = options.colorSteps - ? quantizeCssColor(shadedColor, options.colorSteps) - : shadedColor; + let color: string | undefined; + if (Math.floor(options.colorFreezeFrames ?? 1) !== 0) { + const directionalCfg = options.directionalLight; + const ambientCfg = options.ambientLight; + const lightDir = directionalCfg?.direction ?? DEFAULT_LIGHT_DIR; + const lightColor = directionalCfg?.color ?? DEFAULT_LIGHT_COLOR; + const lightIntensity = Math.max(0, directionalCfg?.intensity ?? DEFAULT_LIGHT_INTENSITY); + const ambientColor = ambientCfg?.color ?? DEFAULT_AMBIENT_COLOR; + const ambientIntensity = Math.max(0, ambientCfg?.intensity ?? DEFAULT_AMBIENT_INTENSITY); + const lLen = Math.sqrt( + lightDir[0] * lightDir[0] + lightDir[1] * lightDir[1] + lightDir[2] * lightDir[2], + ) || 1; + const lx = lightDir[0] / lLen, ly = lightDir[1] / lLen, lz = lightDir[2] / lLen; + const directScale = lightIntensity * Math.max(0, nx * lx + ny * ly + nz * lz); + const shadedColor = shadePolygon( + polygon.color ?? "#cccccc", + directScale, + lightColor, + ambientColor, + ambientIntensity, + ); + color = options.colorSteps + ? quantizeCssColor(shadedColor, options.colorSteps) + : shadedColor; + } return { transform, color, basis: { a, b, c } }; } @@ -317,7 +320,7 @@ export function updateStableTriangleDom( if (el.style.visibility) el.style.visibility = ""; el.__polycssStableTriangleBasis = style.basis; el.style.transform = style.transform; - applyStableTriangleColor(el, i, style.color, options); + if (style.color !== undefined) applyStableTriangleColor(el, i, style.color, options); } return true; } diff --git a/packages/react/src/scene/atlas/useTextureAtlas.ts b/packages/react/src/scene/atlas/useTextureAtlas.ts index 20f734e6..5e1ce83c 100644 --- a/packages/react/src/scene/atlas/useTextureAtlas.ts +++ b/packages/react/src/scene/atlas/useTextureAtlas.ts @@ -19,6 +19,17 @@ export interface TextureAtlasResult { ready: boolean; } +function pageShells(pages: readonly { width: number; height: number }[]): TextureAtlasPage[] { + return pages.map((page) => ({ width: page.width, height: page.height, url: null })); +} + +function textureAtlasPagesEqual(a: readonly TextureAtlasPage[], b: readonly TextureAtlasPage[]): boolean { + return a.length === b.length && a.every((page, index) => { + const other = b[index]; + return page.width === other.width && page.height === other.height && page.url === other.url; + }); +} + // --------------------------------------------------------------------------- // useTextureAtlas — React hook that packs plans into atlas pages with blob URLs // --------------------------------------------------------------------------- @@ -50,13 +61,14 @@ export function useTextureAtlas( ); const [pages, setPages] = useState( - () => packed.pages.map((page) => ({ width: page.width, height: page.height, url: null })), + () => pageShells(packed.pages), ); useEffect(() => { let cancelled = false; let urls: string[] = []; - setPages(packed.pages.map((page) => ({ width: page.width, height: page.height, url: null }))); + const nextPageShells = pageShells(packed.pages); + setPages((prev) => textureAtlasPagesEqual(prev, nextPageShells) ? prev : nextPageShells); if (packed.pages.length === 0 || typeof document === "undefined") { return () => {}; @@ -71,11 +83,11 @@ export function useTextureAtlas( return; } urls = nextPages.flatMap((page) => page.url?.startsWith("blob:") ? [page.url] : []); - setPages(nextPages); + setPages((prev) => textureAtlasPagesEqual(prev, nextPages) ? prev : nextPages); }) .catch(() => { if (!cancelled) { - setPages(packed.pages.map((page) => ({ width: page.width, height: page.height, url: null }))); + setPages((prev) => textureAtlasPagesEqual(prev, nextPageShells) ? prev : nextPageShells); } }); diff --git a/packages/react/src/scene/index.ts b/packages/react/src/scene/index.ts index 563609f8..3c35f653 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, PolySeamBleed } from "./atlas"; +export type { PolyRenderStrategy, PolyRenderStrategiesOption } from "./atlas"; export { PolyMesh } from "./PolyMesh"; export type { PolyMeshProps } from "./PolyMesh"; export { PolyGround } from "./PolyGround"; diff --git a/packages/react/src/scene/useMesh.ts b/packages/react/src/scene/useMesh.ts index 837df6b7..325789b7 100644 --- a/packages/react/src/scene/useMesh.ts +++ b/packages/react/src/scene/useMesh.ts @@ -26,6 +26,7 @@ export type UseMeshOptions = LoadMeshOptions; export interface UseMeshResult { polygons: Polygon[]; + voxelSource: ParseResult["voxelSource"]; loading: boolean; error: Error | null; warnings: string[]; @@ -39,11 +40,13 @@ const EMPTY_WARNINGS: string[] = []; export function usePolyMesh(src: string, options?: UseMeshOptions): UseMeshResult { const [state, setState] = useState<{ polygons: Polygon[]; + voxelSource: ParseResult["voxelSource"]; loading: boolean; error: Error | null; warnings: string[]; }>({ polygons: EMPTY_POLYGONS, + voxelSource: undefined, loading: !!src, error: null, warnings: EMPTY_WARNINGS, @@ -72,6 +75,7 @@ export function usePolyMesh(src: string, options?: UseMeshOptions): UseMeshResul dispose(); setState({ polygons: EMPTY_POLYGONS, + voxelSource: undefined, loading: false, error: null, warnings: EMPTY_WARNINGS, @@ -112,6 +116,7 @@ export function usePolyMesh(src: string, options?: UseMeshOptions): UseMeshResul activeResultRef.current = result; setState({ polygons: result.polygons, + voxelSource: result.voxelSource, loading: false, error: null, warnings: result.warnings ?? EMPTY_WARNINGS, @@ -125,6 +130,7 @@ export function usePolyMesh(src: string, options?: UseMeshOptions): UseMeshResul const error = err instanceof Error ? err : new Error(String(err)); setState((prev) => ({ polygons: prev.polygons, + voxelSource: prev.voxelSource, loading: false, error, warnings: prev.warnings, @@ -143,6 +149,7 @@ export function usePolyMesh(src: string, options?: UseMeshOptions): UseMeshResul return { polygons: state.polygons, + voxelSource: state.voxelSource, loading: state.loading, error: state.error, warnings: state.warnings, diff --git a/packages/react/src/scene/voxelRenderer.ts b/packages/react/src/scene/voxelRenderer.ts new file mode 100644 index 00000000..dbec3ba7 --- /dev/null +++ b/packages/react/src/scene/voxelRenderer.ts @@ -0,0 +1,830 @@ +import type { + CameraCullRotation, + PolyAmbientLight, + PolyDirectionalLight, + PolyVoxelFace, + Polygon, + Vec3, +} from "@layoutit/polycss-core"; +import { + BASE_TILE, + computeProjectiveQuadMatrix, + normalFacesCamera, + parsePureColor, + PROJECTIVE_QUAD_BLEED, + resolveProjectiveQuadGuards, + rotateVec3, + SOLID_QUAD_CANONICAL_SIZE, +} from "@layoutit/polycss-core"; + +type Axis = "x" | "y" | "z"; +type VoxelSeamSide = "left" | "right" | "top" | "bottom"; +type WorldAxisIndex = 0 | 1 | 2; + +interface VoxelSeamBleed { + left: number; + right: number; + top: number; + bottom: number; +} + +interface BrushState { + color?: string; + transform?: string; +} + +type BrushElement = HTMLElement & { + __polycssVoxelBrushState?: BrushState; +}; + +type FaceHostElement = HTMLElement & { + __polycssVoxelFaceHost?: true; +}; + +export interface PolyVoxelRenderer { + readonly brushCount: number; + render(rotation: CameraCullRotation): void; + syncCamera(rotation: CameraCullRotation): void; + dispose(): void; +} + +export interface PolyVoxelRendererOptions { + doc: Document; + wrapper: HTMLElement; + polygons?: readonly Polygon[]; + directionalLight?: PolyDirectionalLight; + ambientLight?: PolyAmbientLight; +} + +interface RGB { r: number; g: number; b: number; alpha: number; } + +interface DirectMatrixItem { + axis: Axis; + face: PolyVoxelFace; + left: number; + top: number; + width: number; + height: number; + z: number; + baseColor: string; + sourceIndex: number; + bleed: VoxelSeamBleed; +} + +interface VoxelSeamSegment { + item: DirectMatrixItem; + side: VoxelSeamSide; + variableAxis: WorldAxisIndex; + fixed: [number, number, number]; + start: number; + end: number; +} + +const DEFAULT_LIGHT_DIR: Vec3 = [0.4, -0.7, 0.59]; +const DEFAULT_LIGHT_COLOR = "#ffffff"; +const DEFAULT_LIGHT_INTENSITY = 1; +const DEFAULT_AMBIENT_COLOR = "#ffffff"; +const DEFAULT_AMBIENT_INTENSITY = 0.4; +const DESKTOP_PRIMITIVE_SIZE = 1; +const MOBILE_PRIMITIVE_SIZE = 8; +const VOXEL_SEAM_BLEED = PROJECTIVE_QUAD_BLEED; +const VOXEL_SEAM_EPS = 1e-6; +const VOXEL_PROJECTIVE_QUAD_GUARDS = resolveProjectiveQuadGuards({ bleed: 0 }); + +const FACE_NORMALS: Record = { + t: [0, 0, 1], + b: [0, 0, -1], + fl: [0, 1, 0], + br: [0, -1, 0], + fr: [1, 0, 0], + bl: [-1, 0, 0], +}; + +const FACE_ORDER: PolyVoxelFace[] = ["t", "b", "bl", "br", "fr", "fl"]; + +const FACE_BY_NORMAL = new Map([ + ["0,0,1", "t"], + ["0,0,-1", "b"], + ["0,1,0", "fl"], + ["0,-1,0", "br"], + ["1,0,0", "fr"], + ["-1,0,0", "bl"], +]); + +function visibleFaceSignature(rotation: CameraCullRotation): string { + const visible: string[] = []; + for (const face of FACE_ORDER) { + if (normalFacesCamera(FACE_NORMALS[face], rotation)) visible.push(face); + } + return visible.join("|"); +} + +function applyBrush( + el: BrushElement, + color: string, + transform: string, +): void { + const state = (el.__polycssVoxelBrushState ??= {}); + if (state.color !== color) { + el.style.color = color; + state.color = color; + } + if (state.transform !== transform) { + el.style.transform = transform; + state.transform = transform; + } +} + +function cssNormalForPolygon(polygon: Polygon): Vec3 | null { + const vertices = polygon.vertices; + if (vertices.length < 3) return null; + const v0 = vertices[0]; + let nx = 0; + let ny = 0; + let nz = 0; + for (let i = 1; i + 1 < vertices.length; i += 1) { + const v1 = vertices[i]; + const v2 = vertices[i + 1]; + const e1x = v1[1] - v0[1]; + const e1y = v1[0] - v0[0]; + const e1z = v1[2] - v0[2]; + const e2x = v2[1] - v0[1]; + const e2y = v2[0] - v0[0]; + const e2z = v2[2] - v0[2]; + nx -= e1y * e2z - e1z * e2y; + ny -= e1z * e2x - e1x * e2z; + nz -= e1x * e2y - e1y * e2x; + } + const len = Math.hypot(nx, ny, nz); + if (len <= 1e-9) return null; + return [ + Math.round(nx / len), + Math.round(ny / len), + Math.round(nz / len), + ]; +} + +function polygonBrush(polygon: Polygon): Omit | null { + if (polygon.texture || polygon.material || polygon.uvs || polygon.textureTriangles) return null; + if (polygon.vertices.length !== 4) return null; + const normal = cssNormalForPolygon(polygon); + const face = normal ? FACE_BY_NORMAL.get(normal.join(",")) : undefined; + if (!face) return null; + + let minX = Infinity; + let minY = Infinity; + let minZ = Infinity; + let maxX = -Infinity; + let maxY = -Infinity; + let maxZ = -Infinity; + for (const v of polygon.vertices) { + minX = Math.min(minX, v[0]); + minY = Math.min(minY, v[1]); + minZ = Math.min(minZ, v[2]); + maxX = Math.max(maxX, v[0]); + maxY = Math.max(maxY, v[1]); + maxZ = Math.max(maxZ, v[2]); + } + + const eps = 1e-6; + const baseColor = canonicalBrushColor(polygon.color); + if (Math.abs(maxZ - minZ) <= eps) { + return { + axis: "z", + face, + left: minY * BASE_TILE, + top: minX * BASE_TILE, + width: Math.max(0, (maxY - minY) * BASE_TILE), + height: Math.max(0, (maxX - minX) * BASE_TILE), + z: minZ * BASE_TILE, + baseColor, + bleed: zeroVoxelSeamBleed(), + }; + } + if (Math.abs(maxX - minX) <= eps) { + return { + axis: "x", + face, + left: minY * BASE_TILE, + top: minZ * BASE_TILE, + width: Math.max(0, (maxY - minY) * BASE_TILE), + height: Math.max(0, (maxZ - minZ) * BASE_TILE), + z: -minX * BASE_TILE, + baseColor, + bleed: zeroVoxelSeamBleed(), + }; + } + if (Math.abs(maxY - minY) <= eps) { + return { + axis: "y", + face, + left: minZ * BASE_TILE, + top: minX * BASE_TILE, + width: Math.max(0, (maxZ - minZ) * BASE_TILE), + height: Math.max(0, (maxX - minX) * BASE_TILE), + z: -minY * BASE_TILE, + baseColor, + bleed: zeroVoxelSeamBleed(), + }; + } + return null; +} + +function zeroVoxelSeamBleed(): VoxelSeamBleed { + return { left: 0, right: 0, top: 0, bottom: 0 }; +} + +function worldLineKey(segment: VoxelSeamSegment): string { + const coordKey = (value: number): string => String(Number(value.toFixed(6))); + let key = `${segment.item.baseColor}|${segment.variableAxis}`; + for (let axis = 0; axis < 3; axis += 1) { + if (axis === segment.variableAxis) continue; + key += `|${axis}:${coordKey(segment.fixed[axis])}`; + } + return key; +} + +function cssPointForVertex(v: Vec3): Vec3 { + return [v[1] * BASE_TILE, v[0] * BASE_TILE, v[2] * BASE_TILE]; +} + +function localPointForItem(item: DirectMatrixItem, p: Vec3): [number, number] { + if (item.axis === "x") return [p[0], p[2]]; + if (item.axis === "y") return [p[2], p[1]]; + return [p[0], p[1]]; +} + +function sideForLocalEdge( + item: DirectMatrixItem, + a: [number, number], + b: [number, number], +): VoxelSeamSide | null { + const left = item.left; + const right = item.left + item.width; + const top = item.top; + const bottom = item.top + item.height; + if (Math.abs(a[0] - b[0]) <= VOXEL_SEAM_EPS) { + if (Math.abs(a[0] - left) <= VOXEL_SEAM_EPS) return "left"; + if (Math.abs(a[0] - right) <= VOXEL_SEAM_EPS) return "right"; + } + if (Math.abs(a[1] - b[1]) <= VOXEL_SEAM_EPS) { + if (Math.abs(a[1] - top) <= VOXEL_SEAM_EPS) return "top"; + if (Math.abs(a[1] - bottom) <= VOXEL_SEAM_EPS) return "bottom"; + } + return null; +} + +function variableAxisForSegment(a: Vec3, b: Vec3): WorldAxisIndex | null { + let axis: WorldAxisIndex | null = null; + for (let i = 0; i < 3; i += 1) { + if (Math.abs(a[i] - b[i]) <= VOXEL_SEAM_EPS) continue; + if (axis !== null) return null; + axis = i as WorldAxisIndex; + } + return axis; +} + +function voxelSeamSegmentForEdge( + item: DirectMatrixItem, + polygon: Polygon, + edgeIndex: number, +): VoxelSeamSegment | null { + const vertices = polygon.vertices; + const a = cssPointForVertex(vertices[edgeIndex]); + const b = cssPointForVertex(vertices[(edgeIndex + 1) % vertices.length]); + const side = sideForLocalEdge(item, localPointForItem(item, a), localPointForItem(item, b)); + if (!side) return null; + const variableAxis = variableAxisForSegment(a, b); + if (variableAxis === null) return null; + const start = Math.min(a[variableAxis], b[variableAxis]); + const end = Math.max(a[variableAxis], b[variableAxis]); + return end - start > VOXEL_SEAM_EPS + ? { item, side, variableAxis, fixed: a, start, end } + : null; +} + +function markVoxelSeam(segment: VoxelSeamSegment): void { + segment.item.bleed[segment.side] = Math.max(segment.item.bleed[segment.side], VOXEL_SEAM_BLEED); +} + +function applyVoxelSeamBleed(polygons: readonly Polygon[], items: DirectMatrixItem[]): void { + const groups = new Map(); + for (const item of items) { + const polygon = polygons[item.sourceIndex]; + if (!polygon) continue; + for (let edgeIndex = 0; edgeIndex < polygon.vertices.length; edgeIndex += 1) { + const segment = voxelSeamSegmentForEdge(item, polygon, edgeIndex); + if (!segment) continue; + const key = worldLineKey(segment); + const group = groups.get(key); + if (group) group.push(segment); + else groups.set(key, [segment]); + } + } + + for (const segments of groups.values()) { + if (segments.length < 2) continue; + segments.sort((a, b) => a.start - b.start || a.end - b.end); + let active: VoxelSeamSegment[] = []; + for (const segment of segments) { + active = active.filter((candidate) => candidate.end > segment.start + VOXEL_SEAM_EPS); + for (const candidate of active) { + if (candidate.item.sourceIndex === segment.item.sourceIndex) continue; + markVoxelSeam(candidate); + markVoxelSeam(segment); + } + active.push(segment); + } + } +} + +function parseColor(input: string): RGB { + const parsed = parsePureColor(input); + if (!parsed) return { r: 255, g: 255, b: 255, alpha: 1 }; + return { + r: parsed.rgb[0], + g: parsed.rgb[1], + b: parsed.rgb[2], + alpha: parsed.alpha, + }; +} + +function canonicalBrushColor(input: string | undefined): string { + if (!input) return "#cccccc"; + const parsed = parsePureColor(input); + if (!parsed) return input; + const rgb: RGB = { + r: parsed.rgb[0], + g: parsed.rgb[1], + b: parsed.rgb[2], + alpha: parsed.alpha, + }; + if (rgb.alpha < 1) { + const alpha = Math.round(Math.max(0, rgb.alpha) * 1000) / 1000; + return `rgba(${clampChannel(rgb.r)}, ${clampChannel(rgb.g)}, ${clampChannel(rgb.b)}, ${alpha})`; + } + return rgbToHex(rgb); +} + +function rgbToHex({ r, g, b }: RGB): string { + const f = (n: number) => + Math.round(Math.max(0, Math.min(255, n))).toString(16).padStart(2, "0"); + return `#${f(r)}${f(g)}${f(b)}`; +} + +function clampChannel(value: number): number { + return Math.round(Math.max(0, Math.min(255, value))); +} + +function shadeBrushColor( + normal: Vec3, + baseColor: string, + directionalLight: PolyDirectionalLight | undefined, + ambientLight: PolyAmbientLight | undefined, +): string { + const base = parseColor(baseColor); + const light = parseColor(directionalLight?.color ?? DEFAULT_LIGHT_COLOR); + const ambient = parseColor(ambientLight?.color ?? DEFAULT_AMBIENT_COLOR); + const lightDir = directionalLight?.direction ?? DEFAULT_LIGHT_DIR; + const lightLen = Math.hypot(lightDir[0], lightDir[1], lightDir[2]) || 1; + const lx = lightDir[0] / lightLen; + const ly = lightDir[1] / lightLen; + const lz = lightDir[2] / lightLen; + const directScale = Math.max(0, directionalLight?.intensity ?? DEFAULT_LIGHT_INTENSITY) * + Math.max(0, normal[0] * lx + normal[1] * ly + normal[2] * lz); + const ambientIntensity = Math.max(0, ambientLight?.intensity ?? DEFAULT_AMBIENT_INTENSITY); + const tintR = (ambient.r / 255) * ambientIntensity + (light.r / 255) * directScale; + const tintG = (ambient.g / 255) * ambientIntensity + (light.g / 255) * directScale; + const tintB = (ambient.b / 255) * ambientIntensity + (light.b / 255) * directScale; + const shaded: RGB = { + r: base.r * tintR, + g: base.g * tintG, + b: base.b * tintB, + alpha: base.alpha, + }; + return shaded.alpha < 1 + ? `rgba(${clampChannel(shaded.r)}, ${clampChannel(shaded.g)}, ${clampChannel(shaded.b)}, ${shaded.alpha})` + : rgbToHex(shaded); +} + +function buildDirectMatrixItems(polygons: readonly Polygon[] | undefined): DirectMatrixItem[] { + if (!polygons?.length) return []; + const items: DirectMatrixItem[] = []; + for (let sourceIndex = 0; sourceIndex < polygons.length; sourceIndex += 1) { + const polygon = polygons[sourceIndex]; + const brush = polygonBrush(polygon); + if (!brush || brush.width <= 0 || brush.height <= 0) return []; + items.push({ + ...brush, + sourceIndex, + }); + } + applyVoxelSeamBleed(polygons, items); + return items; +} + +function voxelProjectiveBasis(item: DirectMatrixItem): { + xAxis: Vec3; + yAxis: Vec3; + normal: Vec3; + tx: number; + ty: number; + tz: number; +} { + if (item.axis === "x") { + return { + xAxis: [1, 0, 0], + yAxis: [0, 0, 1], + normal: [0, -1, 0], + tx: 0, + ty: -item.z, + tz: 0, + }; + } + if (item.axis === "y") { + return { + xAxis: [0, 0, 1], + yAxis: [0, 1, 0], + normal: [-1, 0, 0], + tx: -item.z, + ty: 0, + tz: 0, + }; + } + return { + xAxis: [1, 0, 0], + yAxis: [0, 1, 0], + normal: [0, 0, 1], + tx: 0, + ty: 0, + tz: item.z, + }; +} + +function voxelScreenPts(item: DirectMatrixItem): number[] { + const left = item.left; + const top = item.top; + const right = item.left + item.width; + const bottom = item.top + item.height; + return [ + left, top, + right, top, + right, bottom, + left, bottom, + ]; +} + +function voxelSeamEdgeAmounts(item: DirectMatrixItem): Map { + return new Map([ + [0, item.bleed.top], + [1, item.bleed.right], + [2, item.bleed.bottom], + [3, item.bleed.left], + ]); +} + +function rescaleProjectiveMatrix(matrix: string, primitiveSize: number): string | null { + const values = matrix.split(",").map(Number); + if (values.length !== 16 || values.some((value) => !Number.isFinite(value))) return null; + const scale = SOLID_QUAD_CANONICAL_SIZE / primitiveSize; + for (let i = 0; i < 8; i += 1) values[i] *= scale; + return `matrix3d(${values.map((value) => Number(value.toFixed(6))).join(",")})`; +} + +function affineVoxelMatrix(item: DirectMatrixItem, primitiveSize: number): string { + const left = item.left - item.bleed.left; + const top = item.top - item.bleed.top; + const width = item.width + item.bleed.left + item.bleed.right; + const height = item.height + item.bleed.top + item.bleed.bottom; + const zOffset = item.z; + const scaleX = width / primitiveSize; + const scaleY = height / primitiveSize; + const values = item.axis === "x" + ? [ + scaleX, 0, 0, 0, + 0, 0, scaleY, 0, + 0, -1, 0, 0, + left, -zOffset, top, 1, + ] + : item.axis === "y" + ? [ + 0, 0, scaleX, 0, + 0, scaleY, 0, 0, + -1, 0, 0, 0, + -zOffset, top, left, 1, + ] + : [ + scaleX, 0, 0, 0, + 0, scaleY, 0, 0, + 0, 0, 1, 0, + left, top, zOffset, 1, + ]; + return `matrix3d(${values.map((value) => Number(value.toFixed(6))).join(",")})`; +} + +function directMatrix(item: DirectMatrixItem, primitiveSize: number): string { + const { xAxis, yAxis, normal, tx, ty, tz } = voxelProjectiveBasis(item); + const projective = computeProjectiveQuadMatrix( + voxelScreenPts(item), + xAxis, + yAxis, + normal, + tx, + ty, + tz, + VOXEL_PROJECTIVE_QUAD_GUARDS, + voxelSeamEdgeAmounts(item), + ); + return projective + ? rescaleProjectiveMatrix(projective, primitiveSize) ?? affineVoxelMatrix(item, primitiveSize) + : affineVoxelMatrix(item, primitiveSize); +} + +function isMobileDocument(doc: Document): boolean { + const media = doc.defaultView?.matchMedia; + if (!media) return false; + return media("(pointer: coarse)").matches || media("(hover: none)").matches; +} + +function primitiveSizeForDocument(doc: Document): number { + return isMobileDocument(doc) ? MOBILE_PRIMITIVE_SIZE : DESKTOP_PRIMITIVE_SIZE; +} + +function itemCenter(item: DirectMatrixItem): Vec3 { + if (item.axis === "x") { + return [item.left + item.width / 2, -item.z, item.top + item.height / 2]; + } + if (item.axis === "y") { + return [-item.z, item.top + item.height / 2, item.left + item.width / 2]; + } + return [item.left + item.width / 2, item.top + item.height / 2, item.z]; +} + +function projectedPoint(item: DirectMatrixItem, rotation: CameraCullRotation): { x: number; y: number } { + let center = itemCenter(item); + const meshRotation = rotation.meshRotation; + if (meshRotation) { + center = rotateVec3(center, meshRotation[0] ?? 0, meshRotation[1] ?? 0, meshRotation[2] ?? 0); + } + const [x, y] = rotateVec3(center, rotation.rotX, 0, rotation.rotY); + return { x, y }; +} + +function orderDirectMatrixItems( + items: readonly DirectMatrixItem[], + visibleFaces: Set, + rotation: CameraCullRotation, +): DirectMatrixItem[] { + const entries = items + .filter((item) => visibleFaces.has(item.face)) + .map((item) => ({ item, ...projectedPoint(item, rotation) })); + if (entries.length === 0) return []; + + let minX = Infinity; + let maxX = -Infinity; + let minY = Infinity; + let maxY = -Infinity; + for (const entry of entries) { + minX = Math.min(minX, entry.x); + maxX = Math.max(maxX, entry.x); + minY = Math.min(minY, entry.y); + maxY = Math.max(maxY, entry.y); + } + + const tileCount = 4; + const spanX = Math.max(1e-6, maxX - minX); + const spanY = Math.max(1e-6, maxY - minY); + const tiles = new Map(); + for (const entry of entries) { + const tx = Math.min( + tileCount - 1, + Math.max(0, Math.floor(((entry.x - minX) / spanX) * tileCount)), + ); + const ty = Math.min( + tileCount - 1, + Math.max(0, Math.floor(((entry.y - minY) / spanY) * tileCount)), + ); + const key = `${tx}:${ty}`; + let tile = tiles.get(key); + if (!tile) { + tile = { tx, ty, sourceIndex: entry.item.sourceIndex, items: [] }; + tiles.set(key, tile); + } + tile.items.push(entry.item); + tile.sourceIndex = Math.min(tile.sourceIndex, entry.item.sourceIndex); + } + + return Array.from(tiles.values()) + .sort((a, b) => (a.ty - b.ty) || (a.tx - b.tx) || a.sourceIndex - b.sourceIndex) + .flatMap((tile) => tile.items); +} + +export function createPolyVoxelRenderer( + options: PolyVoxelRendererOptions, +): PolyVoxelRenderer | null { + const { doc, wrapper, polygons, directionalLight, ambientLight } = options; + const directMatrixItems = buildDirectMatrixItems(polygons); + if (directMatrixItems.length === 0) return null; + const itemFaces = new Set(directMatrixItems.map((item) => item.face)); + wrapper.classList.add("polycss-voxel-mesh"); + const primitiveSize = primitiveSizeForDocument(doc); + if (primitiveSize !== DESKTOP_PRIMITIVE_SIZE) { + wrapper.style.setProperty("--polycss-voxel-primitive", `${primitiveSize}px`); + } + + const colorCache = new Map(); + const shadedColor = (face: PolyVoxelFace, baseColor: string): string => { + const key = `${face}|${baseColor}`; + const cached = colorCache.get(key); + if (cached) return cached; + const shaded = shadeBrushColor(FACE_NORMALS[face], baseColor, directionalLight, ambientLight); + colorCache.set(key, shaded); + return shaded; + }; + + const elementBySourceIndex = new Map(); + const hostByFace = new Map(); + const faceOrderKeys = new Map(); + const directMatrixItemsByFace = new Map(); + for (const item of directMatrixItems) { + let faceItems = directMatrixItemsByFace.get(item.face); + if (!faceItems) { + faceItems = []; + directMatrixItemsByFace.set(item.face, faceItems); + } + faceItems.push(item); + } + let lastSignature = ""; + let mountedBrushCount = 0; + let mountedFaces = new Set(); + + const brushForItem = (item: DirectMatrixItem): BrushElement => { + let el = elementBySourceIndex.get(item.sourceIndex); + if (!el) { + el = doc.createElement("b") as BrushElement; + elementBySourceIndex.set(item.sourceIndex, el); + applyBrush( + el, + shadedColor(item.face, item.baseColor), + directMatrix(item, primitiveSize), + ); + } + return el; + }; + + const hostForFace = (face: PolyVoxelFace): FaceHostElement => { + let host = hostByFace.get(face); + if (!host) { + host = doc.createElement("span") as FaceHostElement; + host.className = `polycss-voxel-face polycss-voxel-face-${face}`; + host.dataset.polycssVoxelFace = face; + host.__polycssVoxelFaceHost = true; + hostByFace.set(face, host); + } + return host; + }; + + const firstPreservedChild = (): ChildNode | null => { + for (const child of Array.from(wrapper.childNodes)) { + if ((child as FaceHostElement).__polycssVoxelFaceHost) continue; + return child; + } + return null; + }; + + const syncFaceHost = (face: PolyVoxelFace, items: readonly DirectMatrixItem[]): void => { + const nextOrderKey = items.map((item) => item.sourceIndex).join(","); + if (faceOrderKeys.get(face) === nextOrderKey) return; + const host = hostForFace(face); + const fragment = doc.createDocumentFragment(); + for (const item of items) fragment.appendChild(brushForItem(item)); + host.replaceChildren(fragment); + faceOrderKeys.set(face, nextOrderKey); + }; + + const prebuildFaceHosts = (): void => { + for (const face of FACE_ORDER) { + if (!itemFaces.has(face)) continue; + syncFaceHost(face, directMatrixItemsByFace.get(face) ?? []); + } + }; + + const facesForSignature = (signature: string): Set => + new Set(signature.split("|").filter(Boolean) as PolyVoxelFace[]); + + const faceOrderForSignature = (signature: string): PolyVoxelFace[] => { + const visibleFaces = facesForSignature(signature); + return FACE_ORDER.filter((face) => visibleFaces.has(face) && itemFaces.has(face)); + }; + + const countBrushesForFaces = (faces: Iterable): number => { + let count = 0; + for (const face of faces) count += directMatrixItemsByFace.get(face)?.length ?? 0; + return count; + }; + + const itemsByFaceOrder = ( + orderedItems: readonly DirectMatrixItem[], + ): { orderedFaces: PolyVoxelFace[]; itemsByFace: Map } => { + const seen = new Set(); + const orderedFaces: PolyVoxelFace[] = []; + const itemsByFace = new Map(); + for (const item of orderedItems) { + if (!seen.has(item.face)) { + seen.add(item.face); + orderedFaces.push(item.face); + } + let faceItems = itemsByFace.get(item.face); + if (!faceItems) { + faceItems = []; + itemsByFace.set(item.face, faceItems); + } + faceItems.push(item); + } + return { orderedFaces, itemsByFace }; + }; + + const mountFaceHosts = (orderedFaces: readonly PolyVoxelFace[], reorderMountedFaces: boolean): void => { + const nextFaces = new Set(orderedFaces); + for (const face of mountedFaces) { + if (nextFaces.has(face)) continue; + const host = hostByFace.get(face); + if (host?.parentNode === wrapper) wrapper.removeChild(host); + } + + if (reorderMountedFaces) { + const fragment = doc.createDocumentFragment(); + for (const face of orderedFaces) fragment.appendChild(hostForFace(face)); + wrapper.insertBefore(fragment, firstPreservedChild()); + mountedFaces = nextFaces; + return; + } + + for (let i = 0; i < orderedFaces.length; i += 1) { + const face = orderedFaces[i]; + const host = hostForFace(face); + if (host.parentNode === wrapper) continue; + let reference: ChildNode | null = null; + for (let j = i + 1; j < orderedFaces.length; j += 1) { + const nextHost = hostByFace.get(orderedFaces[j]); + if (nextHost?.parentNode === wrapper) { + reference = nextHost; + break; + } + } + wrapper.insertBefore(host, reference ?? firstPreservedChild()); + } + mountedFaces = nextFaces; + }; + + const draw = (signature: string, rotation: CameraCullRotation, syncOrder: boolean): void => { + if (!syncOrder) { + const orderedFaces = faceOrderForSignature(signature); + mountFaceHosts(orderedFaces, false); + mountedBrushCount = countBrushesForFaces(orderedFaces); + return; + } + + const visibleFaces = new Set(signature.split("|").filter(Boolean) as PolyVoxelFace[]); + const orderedItems = orderDirectMatrixItems(directMatrixItems, visibleFaces, rotation); + const { orderedFaces, itemsByFace } = itemsByFaceOrder(orderedItems); + + for (const face of orderedFaces) { + syncFaceHost(face, itemsByFace.get(face) ?? []); + } + mountFaceHosts(orderedFaces, true); + mountedBrushCount = orderedItems.length; + }; + + prebuildFaceHosts(); + + return { + get brushCount() { return mountedBrushCount; }, + render(rotation: CameraCullRotation) { + lastSignature = visibleFaceSignature(rotation); + draw(lastSignature, rotation, true); + }, + syncCamera(rotation: CameraCullRotation) { + const nextSignature = visibleFaceSignature(rotation); + if (nextSignature === lastSignature) return; + lastSignature = nextSignature; + draw(nextSignature, rotation, false); + }, + dispose() { + for (const host of hostByFace.values()) host.remove(); + wrapper.classList.remove("polycss-voxel-mesh"); + wrapper.style.removeProperty("--polycss-voxel-primitive"); + elementBySourceIndex.clear(); + hostByFace.clear(); + faceOrderKeys.clear(); + mountedBrushCount = 0; + mountedFaces = new Set(); + lastSignature = ""; + }, + }; +} diff --git a/packages/react/vitest.config.ts b/packages/react/vitest.config.ts index bd4fcc5d..2346f9c6 100644 --- a/packages/react/vitest.config.ts +++ b/packages/react/vitest.config.ts @@ -5,6 +5,7 @@ export default defineConfig({ test: { include: ["src/**/*.test.tsx", "src/**/*.test.ts"], environment: "happy-dom", + setupFiles: ["./vitest.setup.ts"], coverage: { provider: "v8", reporter: ["text", "html", "json-summary"], diff --git a/packages/react/vitest.setup.ts b/packages/react/vitest.setup.ts new file mode 100644 index 00000000..e737bc16 --- /dev/null +++ b/packages/react/vitest.setup.ts @@ -0,0 +1,2 @@ +// React checks this flag before accepting act() calls in non-Jest test runners. +(globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true; diff --git a/packages/vue/README.md b/packages/vue/README.md index 11437ba3..dfee357d 100644 --- a/packages/vue/README.md +++ b/packages/vue/README.md @@ -88,7 +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. +- Solid seam bleed is automatic on detected shared solid edges. - `strategies` can disable selected render strategies for diagnostics. - `autoCenter` rotates around the rendered mesh bounds instead of world origin. @@ -180,6 +180,15 @@ polycss renders in the DOM, so performance is mostly determined by how many poly - Voxel-shaped meshes mount only camera-facing leaves when the mesh is eligible. - `meshResolution: "lossy"` merges compatible polygons, then may spend a small split budget to repair high-risk seams. +Renderer internals: + +Each visible polygon is emitted as one leaf element; the renderer chooses the least expensive CSS primitive that can represent the polygon, then uses `matrix3d(...)` to place that primitive in 3D space. + +- `` uses `background: currentColor` on a fixed box for solid rectangles and stable quads. +- `` uses `corner-shape` for stable triangles and beveled-corner solids, with a `border-width` triangle fallback when needed. +- `` clips solid polygons with `border-shape: polygon(...)` when the browser supports it. +- `` maps a packed texture-atlas slice with `background-image`, and is the fallback for textured or unsupported shapes. + 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. ## Packages diff --git a/packages/vue/src/index.ts b/packages/vue/src/index.ts index fd57cfcc..62df33a9 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, PolySeamBleed } from "@layoutit/polycss-core"; +export type { PolyRenderStrategy, PolyRenderStrategiesOption } from "@layoutit/polycss-core"; export { PolyMesh } from "./scene"; export type { PolyMeshProps } from "./scene"; export { PolyGround } from "./scene"; @@ -213,7 +213,6 @@ export { buildSceneContext, computeSceneBbox, BASE_TILE, - DEFAULT_SEAM_BLEED, DEFAULT_CAMERA_STATE, DEFAULT_PROJECTION, normalizeInvertMultiplier, diff --git a/packages/vue/src/scene/PolyMesh.test.ts b/packages/vue/src/scene/PolyMesh.test.ts index fd2503f4..ec1b791f 100644 --- a/packages/vue/src/scene/PolyMesh.test.ts +++ b/packages/vue/src/scene/PolyMesh.test.ts @@ -51,6 +51,66 @@ const QUAD: Polygon = { color: "#00ff00", }; +interface VoxelInput { + x: number; + y: number; + z: number; + colorIndex: number; +} + +function buildVoxBuffer(size: [number, number, number], voxels: VoxelInput[]): ArrayBuffer { + const sizeChunkBytes = 12 + 12; + const xyziChunkBytes = 12 + 4 + voxels.length * 4; + const childrenSize = sizeChunkBytes + xyziChunkBytes; + const buf = new ArrayBuffer(8 + 12 + childrenSize); + const dv = new DataView(buf); + const u8 = new Uint8Array(buf); + let off = 0; + const writeId = (id: string) => { + for (let i = 0; i < 4; i += 1) u8[off++] = id.charCodeAt(i); + }; + const writeU32 = (value: number) => { + dv.setUint32(off, value, true); + off += 4; + }; + const writeU8 = (value: number) => { + u8[off++] = value; + }; + + writeId("VOX "); + writeU32(150); + writeId("MAIN"); + writeU32(0); + writeU32(childrenSize); + writeId("SIZE"); + writeU32(12); + writeU32(0); + writeU32(size[0]); + writeU32(size[1]); + writeU32(size[2]); + writeId("XYZI"); + writeU32(4 + voxels.length * 4); + writeU32(0); + writeU32(voxels.length); + for (const voxel of voxels) { + writeU8(voxel.x); + writeU8(voxel.y); + writeU8(voxel.z); + writeU8(voxel.colorIndex); + } + return buf; +} + +function mockFetchVox(): void { + const buffer = buildVoxBuffer([1, 1, 1], [{ x: 0, y: 0, z: 0, colorIndex: 1 }]); + vi.stubGlobal("fetch", vi.fn().mockResolvedValue({ + ok: true, + status: 200, + text: () => Promise.resolve(""), + arrayBuffer: () => Promise.resolve(buffer), + })); +} + function renderMesh( meshProps: Record = {}, slots: Record VNode | VNode[]> = {} @@ -361,6 +421,46 @@ describe("PolyMesh (Vue) — loading and error states", () => { }); }); +describe("PolyMesh (Vue) — direct voxel fast path", () => { + afterEach(() => { + vi.restoreAllMocks(); + vi.unstubAllGlobals(); + document.body.innerHTML = ""; + }); + + it("routes eligible .vox src meshes through face-wrapper direct brushes", async () => { + mockFetchVox(); + const { container } = renderMesh({ src: "https://example.com/model.vox" }); + + await nextTick(); + await new Promise((resolve) => setTimeout(resolve, 100)); + await nextTick(); + + const mesh = container.querySelector(".polycss-mesh") as HTMLElement | null; + const faceHosts = container.querySelectorAll(".polycss-mesh > .polycss-voxel-face"); + const brushes = container.querySelectorAll(".polycss-mesh > .polycss-voxel-face > b"); + expect(mesh?.classList.contains("polycss-voxel-mesh")).toBe(true); + expect(faceHosts.length).toBeGreaterThan(0); + expect(brushes.length).toBeGreaterThan(0); + expect(container.querySelector(".polycss-mesh > b")).toBeNull(); + }); + + it("falls back to polygon rendering for .vox src meshes in dynamic lighting", async () => { + mockFetchVox(); + const { container } = renderMesh({ + src: "https://example.com/model.vox", + textureLighting: "dynamic", + }); + + await nextTick(); + await new Promise((resolve) => setTimeout(resolve, 100)); + await nextTick(); + + expect(container.querySelector(".polycss-mesh > .polycss-voxel-face")).toBeNull(); + expect(container.querySelector(".polycss-mesh > b,.polycss-mesh > i,.polycss-mesh > s,.polycss-mesh > u")).not.toBeNull(); + }); +}); + describe("PolyMesh (Vue) — meshResolution prop", () => { afterEach(() => { vi.restoreAllMocks(); diff --git a/packages/vue/src/scene/PolyMesh.ts b/packages/vue/src/scene/PolyMesh.ts index a6f3ddc8..9fb2f933 100644 --- a/packages/vue/src/scene/PolyMesh.ts +++ b/packages/vue/src/scene/PolyMesh.ts @@ -16,7 +16,7 @@ * When no `polygon` slot is provided, atlas-backed polygon i elements are rendered * automatically for each polygon. */ -import { defineComponent, h, computed, inject, onMounted, onBeforeUnmount, ref, watch } from "vue"; +import { defineComponent, h, computed, inject, onMounted, onBeforeUnmount, ref, watch, watchEffect } from "vue"; import type { PropType, VNode, CSSProperties } from "vue"; import type { MeshResolution, Polygon, PolyTextureLightingMode, Vec3 } from "@layoutit/polycss-core"; import { @@ -49,6 +49,7 @@ import { useTextureAtlas, } from "./atlas"; import { usePolySceneContext } from "./sceneContext"; +import { createPolyVoxelRenderer, type PolyVoxelRenderer } from "./voxelRenderer"; import { PolyCameraContextKey } from "../camera"; import { findPolyMeshHandle, @@ -239,6 +240,15 @@ export const PolyMesh = defineComponent({ const atlasAmbient = computed(() => atlasTextureLighting.value === "dynamic" ? undefined : sceneCtx?.value.ambientLight, ); + const directVoxelEnabled = computed(() => Boolean( + props.src && + fetched.voxelSource.value && + polygonOverride.value === null && + !slots.polygon && + !slots.default && + atlasTextureLighting.value === "baked" && + !props.castShadow, + )); // Dynamic lighting override: when textureLighting is "dynamic" AND the // mesh has a non-zero rotation, we emit overridden --plx/ly/lz @@ -277,7 +287,7 @@ export const PolyMesh = defineComponent({ }); const textureAtlasPlans = computed(() => { - if (!atlasAutoRender) return []; + if (!atlasAutoRender || directVoxelEnabled.value) return []; const repairEdges = buildTextureEdgeRepairSets(polygons.value); const seamBleedEdges = atlasSeamBleed.value === "auto" || ( typeof atlasSeamBleed.value === "number" && @@ -480,9 +490,9 @@ export const PolyMesh = defineComponent({ strategies: atlasStrategies.value, seamBleed: atlasSeamBleed.value, colorFrame: ++stableTriangleColorFrame.value, - colorSteps: 8, - colorFreezeFrames: 12, - colorMaxStep: 8, + // Animated low-poly triangles can swing face normals sharply; keep the + // mounted baked color pinned and animate transforms only. + colorFreezeFrames: 0, }) ) { return; @@ -522,6 +532,44 @@ export const PolyMesh = defineComponent({ // mesh stacked under the pointer; `pointer` is NDC against the // camera viewport (falls back to (0,0) outside a ). const cameraCtx = inject(PolyCameraContextKey, null); + const voxelRenderer = ref(null); + watchEffect((onCleanup) => { + const root = wrapperRef.value; + voxelRenderer.value?.dispose(); + voxelRenderer.value = null; + if (!directVoxelEnabled.value || !root) return; + + const renderer = createPolyVoxelRenderer({ + doc: root.ownerDocument, + wrapper: root, + polygons: polygons.value, + directionalLight: bakedDirectional.value, + ambientLight: atlasAmbient.value, + }); + if (!renderer) return; + + const cameraRotation = () => { + const cameraState = cameraCtx?.store.getState().cameraState; + return { + rotX: cameraState?.rotX ?? 65, + rotY: cameraState?.rotY ?? 45, + meshRotation: props.rotation, + }; + }; + + voxelRenderer.value = renderer; + renderer.render(cameraRotation()); + const unsubscribe = cameraCtx?.store.subscribe(() => { + renderer.syncCamera(cameraRotation()); + }); + + onCleanup(() => { + unsubscribe?.(); + renderer.dispose(); + if (voxelRenderer.value === renderer) voxelRenderer.value = null; + }); + }); + let pointerDownAt: { x: number; y: number } | null = null; function makeEvent( @@ -614,7 +662,7 @@ export const PolyMesh = defineComponent({ Object.entries(attrs).filter(([k]) => k !== "style" && k !== "class") ); - const wrapperClass = `polycss-mesh${props.class ? ` ${props.class}` : ""}`; + const wrapperClass = `polycss-mesh${directVoxelEnabled.value ? " polycss-voxel-mesh" : ""}${props.class ? ` ${props.class}` : ""}`; // Build the union of DOM handlers we need to attach. Each // registered prop becomes a `onXxx` attr on the wrapper div; @@ -725,6 +773,8 @@ export const PolyMesh = defineComponent({ // Build polygon nodes: use `polygon` scoped slot if provided, else auto-render atlas elements. const polyNodes: Array = slots.polygon ? polys.map((p, i) => h("template", { key: i }, slots.polygon?.({ polygon: p, index: i }))) + : directVoxelEnabled.value + ? [] : textureAtlas.entries.value.map((entry, index) => { if (entry) { return renderTextureAtlasPoly({ diff --git a/packages/vue/src/scene/PolyScene.test.ts b/packages/vue/src/scene/PolyScene.test.ts index d6909ef0..9613fa44 100644 --- a/packages/vue/src/scene/PolyScene.test.ts +++ b/packages/vue/src/scene/PolyScene.test.ts @@ -348,12 +348,17 @@ describe("PolyScene (Vue) — strategies", () => { describe("PolyScene (Vue) — error (no camera context)", () => { it("throws when used outside PolyCamera", () => { const container = document.createElement("div"); + const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {}); const app = createApp({ setup() { return () => h(PolyScene, {}); }, }); - expect(() => app.mount(container)).toThrow(); - document.body.innerHTML = ""; + try { + expect(() => app.mount(container)).toThrow(); + } finally { + warnSpy.mockRestore(); + document.body.innerHTML = ""; + } }); }); diff --git a/packages/vue/src/scene/atlas/stableTriangleDom.ts b/packages/vue/src/scene/atlas/stableTriangleDom.ts index 797a491b..5bd79359 100644 --- a/packages/vue/src/scene/atlas/stableTriangleDom.ts +++ b/packages/vue/src/scene/atlas/stableTriangleDom.ts @@ -68,7 +68,7 @@ function isStableTriangleBasis(value: StableTriangleBasis | undefined): value is interface StableTriangleDomStyle { transform: string; - color: string; + color?: string; basis: StableTriangleBasis; } @@ -260,28 +260,31 @@ function computeStableTriangleDomStyle( cvz + x2 * txXOffset + y2 * txYOffset, ); - const directionalCfg = options.directionalLight; - const ambientCfg = options.ambientLight; - const lightDir = directionalCfg?.direction ?? DEFAULT_LIGHT_DIR; - const lightColor = directionalCfg?.color ?? DEFAULT_LIGHT_COLOR; - const lightIntensity = Math.max(0, directionalCfg?.intensity ?? DEFAULT_LIGHT_INTENSITY); - const ambientColor = ambientCfg?.color ?? DEFAULT_AMBIENT_COLOR; - const ambientIntensity = Math.max(0, ambientCfg?.intensity ?? DEFAULT_AMBIENT_INTENSITY); - const lLen = Math.sqrt( - lightDir[0] * lightDir[0] + lightDir[1] * lightDir[1] + lightDir[2] * lightDir[2], - ) || 1; - const lx = lightDir[0] / lLen, ly = lightDir[1] / lLen, lz = lightDir[2] / lLen; - const directScale = lightIntensity * Math.max(0, nx * lx + ny * ly + nz * lz); - const shadedColor = shadePolygon( - polygon.color ?? "#cccccc", - directScale, - lightColor, - ambientColor, - ambientIntensity, - ); - const color = options.colorSteps - ? quantizeCssColor(shadedColor, options.colorSteps) - : shadedColor; + let color: string | undefined; + if (Math.floor(options.colorFreezeFrames ?? 1) !== 0) { + const directionalCfg = options.directionalLight; + const ambientCfg = options.ambientLight; + const lightDir = directionalCfg?.direction ?? DEFAULT_LIGHT_DIR; + const lightColor = directionalCfg?.color ?? DEFAULT_LIGHT_COLOR; + const lightIntensity = Math.max(0, directionalCfg?.intensity ?? DEFAULT_LIGHT_INTENSITY); + const ambientColor = ambientCfg?.color ?? DEFAULT_AMBIENT_COLOR; + const ambientIntensity = Math.max(0, ambientCfg?.intensity ?? DEFAULT_AMBIENT_INTENSITY); + const lLen = Math.sqrt( + lightDir[0] * lightDir[0] + lightDir[1] * lightDir[1] + lightDir[2] * lightDir[2], + ) || 1; + const lx = lightDir[0] / lLen, ly = lightDir[1] / lLen, lz = lightDir[2] / lLen; + const directScale = lightIntensity * Math.max(0, nx * lx + ny * ly + nz * lz); + const shadedColor = shadePolygon( + polygon.color ?? "#cccccc", + directScale, + lightColor, + ambientColor, + ambientIntensity, + ); + color = options.colorSteps + ? quantizeCssColor(shadedColor, options.colorSteps) + : shadedColor; + } return { transform, color, basis: { a, b, c } }; } @@ -342,7 +345,7 @@ export function updateStableTriangleDom( if (el.style.visibility) el.style.visibility = ""; el.__polycssStableTriangleBasis = style.basis; el.style.transform = style.transform; - applyStableTriangleColor(el, i, style.color, options); + if (style.color !== undefined) applyStableTriangleColor(el, i, style.color, options); } return true; } diff --git a/packages/vue/src/scene/atlas/useTextureAtlas.ts b/packages/vue/src/scene/atlas/useTextureAtlas.ts index 66e57c2f..bba04b97 100644 --- a/packages/vue/src/scene/atlas/useTextureAtlas.ts +++ b/packages/vue/src/scene/atlas/useTextureAtlas.ts @@ -1,6 +1,7 @@ import { computed, - onBeforeUnmount, + getCurrentScope, + onScopeDispose, ref, watch, } from "vue"; @@ -116,10 +117,12 @@ export function useTextureAtlas( { immediate: true }, ); - onBeforeUnmount(() => { - revokeUrls(activeUrls); - activeUrls = []; - }); + if (getCurrentScope()) { + onScopeDispose(() => { + revokeUrls(activeUrls); + activeUrls = []; + }); + } return { entries: computed(() => atlasState.value.packed.entries), diff --git a/packages/vue/src/scene/useMesh.ts b/packages/vue/src/scene/useMesh.ts index 440b490a..c1c9530e 100644 --- a/packages/vue/src/scene/useMesh.ts +++ b/packages/vue/src/scene/useMesh.ts @@ -25,6 +25,7 @@ export type UseMeshOptions = LoadMeshOptions; export interface UseMeshResult { polygons: Ref; + voxelSource: Ref; loading: Ref; error: Ref; warnings: Ref; @@ -37,6 +38,7 @@ const EMPTY_WARNINGS: string[] = []; export function usePolyMesh(src: Ref, options?: UseMeshOptions): UseMeshResult { const polygons = ref(EMPTY_POLYGONS); + const voxelSource = ref(undefined); const loading = ref(!!src.value); const error = ref(null); const warnings = ref(EMPTY_WARNINGS); @@ -63,6 +65,7 @@ export function usePolyMesh(src: Ref, options?: UseMeshOptions): UseMesh // No src — clear any prior result and reset to idle. dispose(); polygons.value = EMPTY_POLYGONS; + voxelSource.value = undefined; loading.value = false; error.value = null; warnings.value = EMPTY_WARNINGS; @@ -91,6 +94,7 @@ export function usePolyMesh(src: Ref, options?: UseMeshOptions): UseMesh } activeResult = result; polygons.value = result.polygons; + voxelSource.value = result.voxelSource; loading.value = false; error.value = null; warnings.value = result.warnings ?? EMPTY_WARNINGS; @@ -116,6 +120,7 @@ export function usePolyMesh(src: Ref, options?: UseMeshOptions): UseMesh return { polygons: polygons as Ref, + voxelSource: voxelSource as Ref, loading, error, warnings: warnings as Ref, diff --git a/packages/vue/src/scene/voxelRenderer.ts b/packages/vue/src/scene/voxelRenderer.ts new file mode 100644 index 00000000..dbec3ba7 --- /dev/null +++ b/packages/vue/src/scene/voxelRenderer.ts @@ -0,0 +1,830 @@ +import type { + CameraCullRotation, + PolyAmbientLight, + PolyDirectionalLight, + PolyVoxelFace, + Polygon, + Vec3, +} from "@layoutit/polycss-core"; +import { + BASE_TILE, + computeProjectiveQuadMatrix, + normalFacesCamera, + parsePureColor, + PROJECTIVE_QUAD_BLEED, + resolveProjectiveQuadGuards, + rotateVec3, + SOLID_QUAD_CANONICAL_SIZE, +} from "@layoutit/polycss-core"; + +type Axis = "x" | "y" | "z"; +type VoxelSeamSide = "left" | "right" | "top" | "bottom"; +type WorldAxisIndex = 0 | 1 | 2; + +interface VoxelSeamBleed { + left: number; + right: number; + top: number; + bottom: number; +} + +interface BrushState { + color?: string; + transform?: string; +} + +type BrushElement = HTMLElement & { + __polycssVoxelBrushState?: BrushState; +}; + +type FaceHostElement = HTMLElement & { + __polycssVoxelFaceHost?: true; +}; + +export interface PolyVoxelRenderer { + readonly brushCount: number; + render(rotation: CameraCullRotation): void; + syncCamera(rotation: CameraCullRotation): void; + dispose(): void; +} + +export interface PolyVoxelRendererOptions { + doc: Document; + wrapper: HTMLElement; + polygons?: readonly Polygon[]; + directionalLight?: PolyDirectionalLight; + ambientLight?: PolyAmbientLight; +} + +interface RGB { r: number; g: number; b: number; alpha: number; } + +interface DirectMatrixItem { + axis: Axis; + face: PolyVoxelFace; + left: number; + top: number; + width: number; + height: number; + z: number; + baseColor: string; + sourceIndex: number; + bleed: VoxelSeamBleed; +} + +interface VoxelSeamSegment { + item: DirectMatrixItem; + side: VoxelSeamSide; + variableAxis: WorldAxisIndex; + fixed: [number, number, number]; + start: number; + end: number; +} + +const DEFAULT_LIGHT_DIR: Vec3 = [0.4, -0.7, 0.59]; +const DEFAULT_LIGHT_COLOR = "#ffffff"; +const DEFAULT_LIGHT_INTENSITY = 1; +const DEFAULT_AMBIENT_COLOR = "#ffffff"; +const DEFAULT_AMBIENT_INTENSITY = 0.4; +const DESKTOP_PRIMITIVE_SIZE = 1; +const MOBILE_PRIMITIVE_SIZE = 8; +const VOXEL_SEAM_BLEED = PROJECTIVE_QUAD_BLEED; +const VOXEL_SEAM_EPS = 1e-6; +const VOXEL_PROJECTIVE_QUAD_GUARDS = resolveProjectiveQuadGuards({ bleed: 0 }); + +const FACE_NORMALS: Record = { + t: [0, 0, 1], + b: [0, 0, -1], + fl: [0, 1, 0], + br: [0, -1, 0], + fr: [1, 0, 0], + bl: [-1, 0, 0], +}; + +const FACE_ORDER: PolyVoxelFace[] = ["t", "b", "bl", "br", "fr", "fl"]; + +const FACE_BY_NORMAL = new Map([ + ["0,0,1", "t"], + ["0,0,-1", "b"], + ["0,1,0", "fl"], + ["0,-1,0", "br"], + ["1,0,0", "fr"], + ["-1,0,0", "bl"], +]); + +function visibleFaceSignature(rotation: CameraCullRotation): string { + const visible: string[] = []; + for (const face of FACE_ORDER) { + if (normalFacesCamera(FACE_NORMALS[face], rotation)) visible.push(face); + } + return visible.join("|"); +} + +function applyBrush( + el: BrushElement, + color: string, + transform: string, +): void { + const state = (el.__polycssVoxelBrushState ??= {}); + if (state.color !== color) { + el.style.color = color; + state.color = color; + } + if (state.transform !== transform) { + el.style.transform = transform; + state.transform = transform; + } +} + +function cssNormalForPolygon(polygon: Polygon): Vec3 | null { + const vertices = polygon.vertices; + if (vertices.length < 3) return null; + const v0 = vertices[0]; + let nx = 0; + let ny = 0; + let nz = 0; + for (let i = 1; i + 1 < vertices.length; i += 1) { + const v1 = vertices[i]; + const v2 = vertices[i + 1]; + const e1x = v1[1] - v0[1]; + const e1y = v1[0] - v0[0]; + const e1z = v1[2] - v0[2]; + const e2x = v2[1] - v0[1]; + const e2y = v2[0] - v0[0]; + const e2z = v2[2] - v0[2]; + nx -= e1y * e2z - e1z * e2y; + ny -= e1z * e2x - e1x * e2z; + nz -= e1x * e2y - e1y * e2x; + } + const len = Math.hypot(nx, ny, nz); + if (len <= 1e-9) return null; + return [ + Math.round(nx / len), + Math.round(ny / len), + Math.round(nz / len), + ]; +} + +function polygonBrush(polygon: Polygon): Omit | null { + if (polygon.texture || polygon.material || polygon.uvs || polygon.textureTriangles) return null; + if (polygon.vertices.length !== 4) return null; + const normal = cssNormalForPolygon(polygon); + const face = normal ? FACE_BY_NORMAL.get(normal.join(",")) : undefined; + if (!face) return null; + + let minX = Infinity; + let minY = Infinity; + let minZ = Infinity; + let maxX = -Infinity; + let maxY = -Infinity; + let maxZ = -Infinity; + for (const v of polygon.vertices) { + minX = Math.min(minX, v[0]); + minY = Math.min(minY, v[1]); + minZ = Math.min(minZ, v[2]); + maxX = Math.max(maxX, v[0]); + maxY = Math.max(maxY, v[1]); + maxZ = Math.max(maxZ, v[2]); + } + + const eps = 1e-6; + const baseColor = canonicalBrushColor(polygon.color); + if (Math.abs(maxZ - minZ) <= eps) { + return { + axis: "z", + face, + left: minY * BASE_TILE, + top: minX * BASE_TILE, + width: Math.max(0, (maxY - minY) * BASE_TILE), + height: Math.max(0, (maxX - minX) * BASE_TILE), + z: minZ * BASE_TILE, + baseColor, + bleed: zeroVoxelSeamBleed(), + }; + } + if (Math.abs(maxX - minX) <= eps) { + return { + axis: "x", + face, + left: minY * BASE_TILE, + top: minZ * BASE_TILE, + width: Math.max(0, (maxY - minY) * BASE_TILE), + height: Math.max(0, (maxZ - minZ) * BASE_TILE), + z: -minX * BASE_TILE, + baseColor, + bleed: zeroVoxelSeamBleed(), + }; + } + if (Math.abs(maxY - minY) <= eps) { + return { + axis: "y", + face, + left: minZ * BASE_TILE, + top: minX * BASE_TILE, + width: Math.max(0, (maxZ - minZ) * BASE_TILE), + height: Math.max(0, (maxX - minX) * BASE_TILE), + z: -minY * BASE_TILE, + baseColor, + bleed: zeroVoxelSeamBleed(), + }; + } + return null; +} + +function zeroVoxelSeamBleed(): VoxelSeamBleed { + return { left: 0, right: 0, top: 0, bottom: 0 }; +} + +function worldLineKey(segment: VoxelSeamSegment): string { + const coordKey = (value: number): string => String(Number(value.toFixed(6))); + let key = `${segment.item.baseColor}|${segment.variableAxis}`; + for (let axis = 0; axis < 3; axis += 1) { + if (axis === segment.variableAxis) continue; + key += `|${axis}:${coordKey(segment.fixed[axis])}`; + } + return key; +} + +function cssPointForVertex(v: Vec3): Vec3 { + return [v[1] * BASE_TILE, v[0] * BASE_TILE, v[2] * BASE_TILE]; +} + +function localPointForItem(item: DirectMatrixItem, p: Vec3): [number, number] { + if (item.axis === "x") return [p[0], p[2]]; + if (item.axis === "y") return [p[2], p[1]]; + return [p[0], p[1]]; +} + +function sideForLocalEdge( + item: DirectMatrixItem, + a: [number, number], + b: [number, number], +): VoxelSeamSide | null { + const left = item.left; + const right = item.left + item.width; + const top = item.top; + const bottom = item.top + item.height; + if (Math.abs(a[0] - b[0]) <= VOXEL_SEAM_EPS) { + if (Math.abs(a[0] - left) <= VOXEL_SEAM_EPS) return "left"; + if (Math.abs(a[0] - right) <= VOXEL_SEAM_EPS) return "right"; + } + if (Math.abs(a[1] - b[1]) <= VOXEL_SEAM_EPS) { + if (Math.abs(a[1] - top) <= VOXEL_SEAM_EPS) return "top"; + if (Math.abs(a[1] - bottom) <= VOXEL_SEAM_EPS) return "bottom"; + } + return null; +} + +function variableAxisForSegment(a: Vec3, b: Vec3): WorldAxisIndex | null { + let axis: WorldAxisIndex | null = null; + for (let i = 0; i < 3; i += 1) { + if (Math.abs(a[i] - b[i]) <= VOXEL_SEAM_EPS) continue; + if (axis !== null) return null; + axis = i as WorldAxisIndex; + } + return axis; +} + +function voxelSeamSegmentForEdge( + item: DirectMatrixItem, + polygon: Polygon, + edgeIndex: number, +): VoxelSeamSegment | null { + const vertices = polygon.vertices; + const a = cssPointForVertex(vertices[edgeIndex]); + const b = cssPointForVertex(vertices[(edgeIndex + 1) % vertices.length]); + const side = sideForLocalEdge(item, localPointForItem(item, a), localPointForItem(item, b)); + if (!side) return null; + const variableAxis = variableAxisForSegment(a, b); + if (variableAxis === null) return null; + const start = Math.min(a[variableAxis], b[variableAxis]); + const end = Math.max(a[variableAxis], b[variableAxis]); + return end - start > VOXEL_SEAM_EPS + ? { item, side, variableAxis, fixed: a, start, end } + : null; +} + +function markVoxelSeam(segment: VoxelSeamSegment): void { + segment.item.bleed[segment.side] = Math.max(segment.item.bleed[segment.side], VOXEL_SEAM_BLEED); +} + +function applyVoxelSeamBleed(polygons: readonly Polygon[], items: DirectMatrixItem[]): void { + const groups = new Map(); + for (const item of items) { + const polygon = polygons[item.sourceIndex]; + if (!polygon) continue; + for (let edgeIndex = 0; edgeIndex < polygon.vertices.length; edgeIndex += 1) { + const segment = voxelSeamSegmentForEdge(item, polygon, edgeIndex); + if (!segment) continue; + const key = worldLineKey(segment); + const group = groups.get(key); + if (group) group.push(segment); + else groups.set(key, [segment]); + } + } + + for (const segments of groups.values()) { + if (segments.length < 2) continue; + segments.sort((a, b) => a.start - b.start || a.end - b.end); + let active: VoxelSeamSegment[] = []; + for (const segment of segments) { + active = active.filter((candidate) => candidate.end > segment.start + VOXEL_SEAM_EPS); + for (const candidate of active) { + if (candidate.item.sourceIndex === segment.item.sourceIndex) continue; + markVoxelSeam(candidate); + markVoxelSeam(segment); + } + active.push(segment); + } + } +} + +function parseColor(input: string): RGB { + const parsed = parsePureColor(input); + if (!parsed) return { r: 255, g: 255, b: 255, alpha: 1 }; + return { + r: parsed.rgb[0], + g: parsed.rgb[1], + b: parsed.rgb[2], + alpha: parsed.alpha, + }; +} + +function canonicalBrushColor(input: string | undefined): string { + if (!input) return "#cccccc"; + const parsed = parsePureColor(input); + if (!parsed) return input; + const rgb: RGB = { + r: parsed.rgb[0], + g: parsed.rgb[1], + b: parsed.rgb[2], + alpha: parsed.alpha, + }; + if (rgb.alpha < 1) { + const alpha = Math.round(Math.max(0, rgb.alpha) * 1000) / 1000; + return `rgba(${clampChannel(rgb.r)}, ${clampChannel(rgb.g)}, ${clampChannel(rgb.b)}, ${alpha})`; + } + return rgbToHex(rgb); +} + +function rgbToHex({ r, g, b }: RGB): string { + const f = (n: number) => + Math.round(Math.max(0, Math.min(255, n))).toString(16).padStart(2, "0"); + return `#${f(r)}${f(g)}${f(b)}`; +} + +function clampChannel(value: number): number { + return Math.round(Math.max(0, Math.min(255, value))); +} + +function shadeBrushColor( + normal: Vec3, + baseColor: string, + directionalLight: PolyDirectionalLight | undefined, + ambientLight: PolyAmbientLight | undefined, +): string { + const base = parseColor(baseColor); + const light = parseColor(directionalLight?.color ?? DEFAULT_LIGHT_COLOR); + const ambient = parseColor(ambientLight?.color ?? DEFAULT_AMBIENT_COLOR); + const lightDir = directionalLight?.direction ?? DEFAULT_LIGHT_DIR; + const lightLen = Math.hypot(lightDir[0], lightDir[1], lightDir[2]) || 1; + const lx = lightDir[0] / lightLen; + const ly = lightDir[1] / lightLen; + const lz = lightDir[2] / lightLen; + const directScale = Math.max(0, directionalLight?.intensity ?? DEFAULT_LIGHT_INTENSITY) * + Math.max(0, normal[0] * lx + normal[1] * ly + normal[2] * lz); + const ambientIntensity = Math.max(0, ambientLight?.intensity ?? DEFAULT_AMBIENT_INTENSITY); + const tintR = (ambient.r / 255) * ambientIntensity + (light.r / 255) * directScale; + const tintG = (ambient.g / 255) * ambientIntensity + (light.g / 255) * directScale; + const tintB = (ambient.b / 255) * ambientIntensity + (light.b / 255) * directScale; + const shaded: RGB = { + r: base.r * tintR, + g: base.g * tintG, + b: base.b * tintB, + alpha: base.alpha, + }; + return shaded.alpha < 1 + ? `rgba(${clampChannel(shaded.r)}, ${clampChannel(shaded.g)}, ${clampChannel(shaded.b)}, ${shaded.alpha})` + : rgbToHex(shaded); +} + +function buildDirectMatrixItems(polygons: readonly Polygon[] | undefined): DirectMatrixItem[] { + if (!polygons?.length) return []; + const items: DirectMatrixItem[] = []; + for (let sourceIndex = 0; sourceIndex < polygons.length; sourceIndex += 1) { + const polygon = polygons[sourceIndex]; + const brush = polygonBrush(polygon); + if (!brush || brush.width <= 0 || brush.height <= 0) return []; + items.push({ + ...brush, + sourceIndex, + }); + } + applyVoxelSeamBleed(polygons, items); + return items; +} + +function voxelProjectiveBasis(item: DirectMatrixItem): { + xAxis: Vec3; + yAxis: Vec3; + normal: Vec3; + tx: number; + ty: number; + tz: number; +} { + if (item.axis === "x") { + return { + xAxis: [1, 0, 0], + yAxis: [0, 0, 1], + normal: [0, -1, 0], + tx: 0, + ty: -item.z, + tz: 0, + }; + } + if (item.axis === "y") { + return { + xAxis: [0, 0, 1], + yAxis: [0, 1, 0], + normal: [-1, 0, 0], + tx: -item.z, + ty: 0, + tz: 0, + }; + } + return { + xAxis: [1, 0, 0], + yAxis: [0, 1, 0], + normal: [0, 0, 1], + tx: 0, + ty: 0, + tz: item.z, + }; +} + +function voxelScreenPts(item: DirectMatrixItem): number[] { + const left = item.left; + const top = item.top; + const right = item.left + item.width; + const bottom = item.top + item.height; + return [ + left, top, + right, top, + right, bottom, + left, bottom, + ]; +} + +function voxelSeamEdgeAmounts(item: DirectMatrixItem): Map { + return new Map([ + [0, item.bleed.top], + [1, item.bleed.right], + [2, item.bleed.bottom], + [3, item.bleed.left], + ]); +} + +function rescaleProjectiveMatrix(matrix: string, primitiveSize: number): string | null { + const values = matrix.split(",").map(Number); + if (values.length !== 16 || values.some((value) => !Number.isFinite(value))) return null; + const scale = SOLID_QUAD_CANONICAL_SIZE / primitiveSize; + for (let i = 0; i < 8; i += 1) values[i] *= scale; + return `matrix3d(${values.map((value) => Number(value.toFixed(6))).join(",")})`; +} + +function affineVoxelMatrix(item: DirectMatrixItem, primitiveSize: number): string { + const left = item.left - item.bleed.left; + const top = item.top - item.bleed.top; + const width = item.width + item.bleed.left + item.bleed.right; + const height = item.height + item.bleed.top + item.bleed.bottom; + const zOffset = item.z; + const scaleX = width / primitiveSize; + const scaleY = height / primitiveSize; + const values = item.axis === "x" + ? [ + scaleX, 0, 0, 0, + 0, 0, scaleY, 0, + 0, -1, 0, 0, + left, -zOffset, top, 1, + ] + : item.axis === "y" + ? [ + 0, 0, scaleX, 0, + 0, scaleY, 0, 0, + -1, 0, 0, 0, + -zOffset, top, left, 1, + ] + : [ + scaleX, 0, 0, 0, + 0, scaleY, 0, 0, + 0, 0, 1, 0, + left, top, zOffset, 1, + ]; + return `matrix3d(${values.map((value) => Number(value.toFixed(6))).join(",")})`; +} + +function directMatrix(item: DirectMatrixItem, primitiveSize: number): string { + const { xAxis, yAxis, normal, tx, ty, tz } = voxelProjectiveBasis(item); + const projective = computeProjectiveQuadMatrix( + voxelScreenPts(item), + xAxis, + yAxis, + normal, + tx, + ty, + tz, + VOXEL_PROJECTIVE_QUAD_GUARDS, + voxelSeamEdgeAmounts(item), + ); + return projective + ? rescaleProjectiveMatrix(projective, primitiveSize) ?? affineVoxelMatrix(item, primitiveSize) + : affineVoxelMatrix(item, primitiveSize); +} + +function isMobileDocument(doc: Document): boolean { + const media = doc.defaultView?.matchMedia; + if (!media) return false; + return media("(pointer: coarse)").matches || media("(hover: none)").matches; +} + +function primitiveSizeForDocument(doc: Document): number { + return isMobileDocument(doc) ? MOBILE_PRIMITIVE_SIZE : DESKTOP_PRIMITIVE_SIZE; +} + +function itemCenter(item: DirectMatrixItem): Vec3 { + if (item.axis === "x") { + return [item.left + item.width / 2, -item.z, item.top + item.height / 2]; + } + if (item.axis === "y") { + return [-item.z, item.top + item.height / 2, item.left + item.width / 2]; + } + return [item.left + item.width / 2, item.top + item.height / 2, item.z]; +} + +function projectedPoint(item: DirectMatrixItem, rotation: CameraCullRotation): { x: number; y: number } { + let center = itemCenter(item); + const meshRotation = rotation.meshRotation; + if (meshRotation) { + center = rotateVec3(center, meshRotation[0] ?? 0, meshRotation[1] ?? 0, meshRotation[2] ?? 0); + } + const [x, y] = rotateVec3(center, rotation.rotX, 0, rotation.rotY); + return { x, y }; +} + +function orderDirectMatrixItems( + items: readonly DirectMatrixItem[], + visibleFaces: Set, + rotation: CameraCullRotation, +): DirectMatrixItem[] { + const entries = items + .filter((item) => visibleFaces.has(item.face)) + .map((item) => ({ item, ...projectedPoint(item, rotation) })); + if (entries.length === 0) return []; + + let minX = Infinity; + let maxX = -Infinity; + let minY = Infinity; + let maxY = -Infinity; + for (const entry of entries) { + minX = Math.min(minX, entry.x); + maxX = Math.max(maxX, entry.x); + minY = Math.min(minY, entry.y); + maxY = Math.max(maxY, entry.y); + } + + const tileCount = 4; + const spanX = Math.max(1e-6, maxX - minX); + const spanY = Math.max(1e-6, maxY - minY); + const tiles = new Map(); + for (const entry of entries) { + const tx = Math.min( + tileCount - 1, + Math.max(0, Math.floor(((entry.x - minX) / spanX) * tileCount)), + ); + const ty = Math.min( + tileCount - 1, + Math.max(0, Math.floor(((entry.y - minY) / spanY) * tileCount)), + ); + const key = `${tx}:${ty}`; + let tile = tiles.get(key); + if (!tile) { + tile = { tx, ty, sourceIndex: entry.item.sourceIndex, items: [] }; + tiles.set(key, tile); + } + tile.items.push(entry.item); + tile.sourceIndex = Math.min(tile.sourceIndex, entry.item.sourceIndex); + } + + return Array.from(tiles.values()) + .sort((a, b) => (a.ty - b.ty) || (a.tx - b.tx) || a.sourceIndex - b.sourceIndex) + .flatMap((tile) => tile.items); +} + +export function createPolyVoxelRenderer( + options: PolyVoxelRendererOptions, +): PolyVoxelRenderer | null { + const { doc, wrapper, polygons, directionalLight, ambientLight } = options; + const directMatrixItems = buildDirectMatrixItems(polygons); + if (directMatrixItems.length === 0) return null; + const itemFaces = new Set(directMatrixItems.map((item) => item.face)); + wrapper.classList.add("polycss-voxel-mesh"); + const primitiveSize = primitiveSizeForDocument(doc); + if (primitiveSize !== DESKTOP_PRIMITIVE_SIZE) { + wrapper.style.setProperty("--polycss-voxel-primitive", `${primitiveSize}px`); + } + + const colorCache = new Map(); + const shadedColor = (face: PolyVoxelFace, baseColor: string): string => { + const key = `${face}|${baseColor}`; + const cached = colorCache.get(key); + if (cached) return cached; + const shaded = shadeBrushColor(FACE_NORMALS[face], baseColor, directionalLight, ambientLight); + colorCache.set(key, shaded); + return shaded; + }; + + const elementBySourceIndex = new Map(); + const hostByFace = new Map(); + const faceOrderKeys = new Map(); + const directMatrixItemsByFace = new Map(); + for (const item of directMatrixItems) { + let faceItems = directMatrixItemsByFace.get(item.face); + if (!faceItems) { + faceItems = []; + directMatrixItemsByFace.set(item.face, faceItems); + } + faceItems.push(item); + } + let lastSignature = ""; + let mountedBrushCount = 0; + let mountedFaces = new Set(); + + const brushForItem = (item: DirectMatrixItem): BrushElement => { + let el = elementBySourceIndex.get(item.sourceIndex); + if (!el) { + el = doc.createElement("b") as BrushElement; + elementBySourceIndex.set(item.sourceIndex, el); + applyBrush( + el, + shadedColor(item.face, item.baseColor), + directMatrix(item, primitiveSize), + ); + } + return el; + }; + + const hostForFace = (face: PolyVoxelFace): FaceHostElement => { + let host = hostByFace.get(face); + if (!host) { + host = doc.createElement("span") as FaceHostElement; + host.className = `polycss-voxel-face polycss-voxel-face-${face}`; + host.dataset.polycssVoxelFace = face; + host.__polycssVoxelFaceHost = true; + hostByFace.set(face, host); + } + return host; + }; + + const firstPreservedChild = (): ChildNode | null => { + for (const child of Array.from(wrapper.childNodes)) { + if ((child as FaceHostElement).__polycssVoxelFaceHost) continue; + return child; + } + return null; + }; + + const syncFaceHost = (face: PolyVoxelFace, items: readonly DirectMatrixItem[]): void => { + const nextOrderKey = items.map((item) => item.sourceIndex).join(","); + if (faceOrderKeys.get(face) === nextOrderKey) return; + const host = hostForFace(face); + const fragment = doc.createDocumentFragment(); + for (const item of items) fragment.appendChild(brushForItem(item)); + host.replaceChildren(fragment); + faceOrderKeys.set(face, nextOrderKey); + }; + + const prebuildFaceHosts = (): void => { + for (const face of FACE_ORDER) { + if (!itemFaces.has(face)) continue; + syncFaceHost(face, directMatrixItemsByFace.get(face) ?? []); + } + }; + + const facesForSignature = (signature: string): Set => + new Set(signature.split("|").filter(Boolean) as PolyVoxelFace[]); + + const faceOrderForSignature = (signature: string): PolyVoxelFace[] => { + const visibleFaces = facesForSignature(signature); + return FACE_ORDER.filter((face) => visibleFaces.has(face) && itemFaces.has(face)); + }; + + const countBrushesForFaces = (faces: Iterable): number => { + let count = 0; + for (const face of faces) count += directMatrixItemsByFace.get(face)?.length ?? 0; + return count; + }; + + const itemsByFaceOrder = ( + orderedItems: readonly DirectMatrixItem[], + ): { orderedFaces: PolyVoxelFace[]; itemsByFace: Map } => { + const seen = new Set(); + const orderedFaces: PolyVoxelFace[] = []; + const itemsByFace = new Map(); + for (const item of orderedItems) { + if (!seen.has(item.face)) { + seen.add(item.face); + orderedFaces.push(item.face); + } + let faceItems = itemsByFace.get(item.face); + if (!faceItems) { + faceItems = []; + itemsByFace.set(item.face, faceItems); + } + faceItems.push(item); + } + return { orderedFaces, itemsByFace }; + }; + + const mountFaceHosts = (orderedFaces: readonly PolyVoxelFace[], reorderMountedFaces: boolean): void => { + const nextFaces = new Set(orderedFaces); + for (const face of mountedFaces) { + if (nextFaces.has(face)) continue; + const host = hostByFace.get(face); + if (host?.parentNode === wrapper) wrapper.removeChild(host); + } + + if (reorderMountedFaces) { + const fragment = doc.createDocumentFragment(); + for (const face of orderedFaces) fragment.appendChild(hostForFace(face)); + wrapper.insertBefore(fragment, firstPreservedChild()); + mountedFaces = nextFaces; + return; + } + + for (let i = 0; i < orderedFaces.length; i += 1) { + const face = orderedFaces[i]; + const host = hostForFace(face); + if (host.parentNode === wrapper) continue; + let reference: ChildNode | null = null; + for (let j = i + 1; j < orderedFaces.length; j += 1) { + const nextHost = hostByFace.get(orderedFaces[j]); + if (nextHost?.parentNode === wrapper) { + reference = nextHost; + break; + } + } + wrapper.insertBefore(host, reference ?? firstPreservedChild()); + } + mountedFaces = nextFaces; + }; + + const draw = (signature: string, rotation: CameraCullRotation, syncOrder: boolean): void => { + if (!syncOrder) { + const orderedFaces = faceOrderForSignature(signature); + mountFaceHosts(orderedFaces, false); + mountedBrushCount = countBrushesForFaces(orderedFaces); + return; + } + + const visibleFaces = new Set(signature.split("|").filter(Boolean) as PolyVoxelFace[]); + const orderedItems = orderDirectMatrixItems(directMatrixItems, visibleFaces, rotation); + const { orderedFaces, itemsByFace } = itemsByFaceOrder(orderedItems); + + for (const face of orderedFaces) { + syncFaceHost(face, itemsByFace.get(face) ?? []); + } + mountFaceHosts(orderedFaces, true); + mountedBrushCount = orderedItems.length; + }; + + prebuildFaceHosts(); + + return { + get brushCount() { return mountedBrushCount; }, + render(rotation: CameraCullRotation) { + lastSignature = visibleFaceSignature(rotation); + draw(lastSignature, rotation, true); + }, + syncCamera(rotation: CameraCullRotation) { + const nextSignature = visibleFaceSignature(rotation); + if (nextSignature === lastSignature) return; + lastSignature = nextSignature; + draw(nextSignature, rotation, false); + }, + dispose() { + for (const host of hostByFace.values()) host.remove(); + wrapper.classList.remove("polycss-voxel-mesh"); + wrapper.style.removeProperty("--polycss-voxel-primitive"); + elementBySourceIndex.clear(); + hostByFace.clear(); + faceOrderKeys.clear(); + mountedBrushCount = 0; + mountedFaces = new Set(); + lastSignature = ""; + }, + }; +} diff --git a/website/astro.config.mjs b/website/astro.config.mjs index 0c8bf8c3..824bf417 100644 --- a/website/astro.config.mjs +++ b/website/astro.config.mjs @@ -44,6 +44,7 @@ export default defineConfig({ starlight({ title: 'Polycss', description: 'A CSS polygon mesh engine. DOM-native 3D rendering.', + disable404Route: true, components: { Header: './src/components/DocsHeader.astro', ThemeSelect: './src/components/EmptyThemeSelect.astro', @@ -67,7 +68,7 @@ export default defineConfig({ items: [ { label: 'PolyScene', slug: 'components/poly-scene' }, { label: 'PolyCamera', slug: 'components/poly-camera' }, - { label: 'PolyOrbitControls / PolyMapControls', slug: 'components/poly-controls' }, + { label: 'Controls', slug: 'components/poly-controls' }, ], }, { @@ -77,6 +78,7 @@ export default defineConfig({ { label: 'Per-polygon Interaction', slug: 'guides/shapes' }, { label: 'Performance', slug: 'guides/performance' }, { label: 'Projections', slug: 'guides/projections' }, + { label: 'Animation', slug: 'guides/animation' }, ], }, { diff --git a/website/public/gallery/glb/khronos/avocado.glb b/website/public/gallery/glb/khronos/avocado.glb new file mode 100644 index 00000000..dd79cb80 Binary files /dev/null and b/website/public/gallery/glb/khronos/avocado.glb differ diff --git a/website/src/components/BuilderWorkbench/builder-workbench.css b/website/src/components/BuilderWorkbench/builder-workbench.css index fda84852..905e05a8 100644 --- a/website/src/components/BuilderWorkbench/builder-workbench.css +++ b/website/src/components/BuilderWorkbench/builder-workbench.css @@ -182,7 +182,8 @@ .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 { +.builder-placed.is-mesh-hidden > .polycss-bucket, +.builder-placed.is-mesh-hidden > .polycss-normal-cull-bucket { display: none !important; } diff --git a/website/src/components/BuilderWorkbench/components/BuilderScene.tsx b/website/src/components/BuilderWorkbench/components/BuilderScene.tsx index fb326134..ecb18832 100644 --- a/website/src/components/BuilderWorkbench/components/BuilderScene.tsx +++ b/website/src/components/BuilderWorkbench/components/BuilderScene.tsx @@ -18,6 +18,7 @@ import type { PolyMeshHandle, PolyTransformControlsObjectChangeEvent, Polygon, + Vec3, } from "@layoutit/polycss-react"; import { type RefObject } from "react"; import { meshResolutionShowsMesh, type SceneOptionsState, type GizmoMode } from "../../types"; @@ -79,6 +80,12 @@ export function BuilderScene({ 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 handleCameraChange = (cam: { rotX: number; rotY: number; zoom: number; target?: Vec3 }) => updateScene({ + rotX: cam.rotX, + rotY: cam.rotY, + zoom: cam.zoom, + ...(cam.target ? { target: cam.target } : {}), + }); return ( @@ -87,12 +94,7 @@ export function BuilderScene({ drag={sceneOptions.interactive && !gizmoDragging} wheel={sceneOptions.interactive && !gizmoDragging} animate={sceneOptions.animate ? { speed: 0.35, axis: "y", pauseOnInteraction: true } : false} - onInteractionEnd={(cam) => updateScene({ - rotX: cam.rotX, - rotY: cam.rotY, - zoom: cam.zoom, - ...(cam.target ? { target: cam.target } : {}), - })} + onInteractionEnd={handleCameraChange} /> ) : sceneOptions.dragMode === "fpv" ? ( updateScene({ - rotX: cam.rotX, - rotY: cam.rotY, - zoom: cam.zoom, - ...(cam.target ? { target: cam.target } : {}), - })} + onInteractionEnd={handleCameraChange} /> )} => { try { - const loaded = await loadPresetModel(preset, PARSER_DEFAULTS); + const loaded = await loadPresetModel(preset, PARSER_DEFAULTS, effectiveMeshResolution); const optimized = optimizeMeshPolygons(loaded.rawPolygons, { meshResolution: effectiveMeshResolution }); const bbox = meshBbox(optimized); const fitScale = bbox.span > 0 ? NORMALIZED_MAX_DIM / bbox.span : 1; diff --git a/website/src/components/BuilderWorkbench/hooks/useSceneLoader.ts b/website/src/components/BuilderWorkbench/hooks/useSceneLoader.ts index 40f6a9ec..f2f127e7 100644 --- a/website/src/components/BuilderWorkbench/hooks/useSceneLoader.ts +++ b/website/src/components/BuilderWorkbench/hooks/useSceneLoader.ts @@ -53,6 +53,8 @@ export function useSceneLoader({ }: UseSceneLoaderOptions): UseSceneLoaderResult { const meshResolutionRef = useRef(activeMeshResolution(meshResolution)); meshResolutionRef.current = activeMeshResolution(meshResolution); + const loadMeshResolutionRef = useRef(meshResolutionRef.current); + loadMeshResolutionRef.current = meshResolutionRef.current; // Dedupe in-flight loads so the same item can't kick off twice between // the setState callback and the next effect tick. @@ -114,7 +116,7 @@ export function useSceneLoader({ const loadOne = async (item: PlacedItem): Promise => { try { - const loaded = await loadPresetModel(item.preset, PARSER_DEFAULTS); + const loaded = await loadPresetModel(item.preset, PARSER_DEFAULTS, loadMeshResolutionRef.current); const optimized = optimizeMeshPolygons(loaded.rawPolygons, { meshResolution: meshResolutionRef.current, }); diff --git a/website/src/components/Dock/Dock.tsx b/website/src/components/Dock/Dock.tsx index e8ad540d..30868011 100644 --- a/website/src/components/Dock/Dock.tsx +++ b/website/src/components/Dock/Dock.tsx @@ -3,6 +3,8 @@ import { useGui } from "./primitives"; import { DockGuiContext } from "./slots"; export interface DockProps { + id?: string; + className?: string; children?: ReactNode; loading?: boolean; loadError?: string | null; @@ -19,12 +21,15 @@ export interface DockProps { * the GUI (status notes for model loading), which is Dock-level UI * rather than per-folder state. */ -export function Dock({ children, loading, loadError }: DockProps) { +export function Dock({ id, className, children, loading, loadError }: DockProps) { const hostRef = useRef(null); const gui = useGui(hostRef); return ( -
-
+
+
{children} diff --git a/website/src/components/GalleryWorkbench/GalleryWorkbench.tsx b/website/src/components/GalleryWorkbench/GalleryWorkbench.tsx index 782ad3c3..48a8b411 100644 --- a/website/src/components/GalleryWorkbench/GalleryWorkbench.tsx +++ b/website/src/components/GalleryWorkbench/GalleryWorkbench.tsx @@ -71,6 +71,7 @@ import { useFpvHost } from "../fpv"; import type { ObjParseOptions, GltfParseOptions, VoxParseOptions } from "@layoutit/polycss"; type AnimationClip = NonNullable["clips"][number]; +type MobileGalleryPanel = "models" | "controls" | null; function presetPickerItem(preset: PresetModel, local = false) { const label = local ? `Dropped: ${stripParenthesizedText(preset.label)}` : stripParenthesizedText(preset.label); @@ -424,6 +425,51 @@ function inspectorColorKey(color: string): string { .join("")}`; } +interface InspectorColorSortKey { + bucket: number; + hue: number; + saturation: number; + value: number; + label: string; +} + +function inspectorColorSortKey(color: string): InspectorColorSortKey { + const parsed = parsePureColor(color); + if (!parsed) return { bucket: 2, hue: 0, saturation: 0, value: 0, label: color }; + const [r, g, b] = parsed.rgb.map((channel) => Math.max(0, Math.min(255, channel)) / 255); + const max = Math.max(r, g, b); + const min = Math.min(r, g, b); + const delta = max - min; + const saturation = max === 0 ? 0 : delta / max; + let hue = 0; + if (delta !== 0) { + if (max === r) hue = 60 * (((g - b) / delta) % 6); + else if (max === g) hue = 60 * ((b - r) / delta + 2); + else hue = 60 * ((r - g) / delta + 4); + } + if (hue < 0) hue += 360; + const neutral = saturation < 0.08; + return { + bucket: neutral ? 0 : 1, + hue: neutral ? 0 : hue, + saturation: neutral ? 0 : saturation, + value: max, + label: color, + }; +} + +function compareInspectorColors(a: string, b: string): number { + const ak = inspectorColorSortKey(a); + const bk = inspectorColorSortKey(b); + return ( + ak.bucket - bk.bucket || + ak.hue - bk.hue || + bk.saturation - ak.saturation || + ak.value - bk.value || + ak.label.localeCompare(bk.label) + ); +} + function displayAnimationName(name: string): string { const localName = (name.split("|").pop() ?? name).trim(); return localName @@ -453,6 +499,37 @@ function dedupeAnimationClips(clips: AnimationClip[]): AnimationClip[] { return Array.from(byName.values()); } +function animationClipValue(clip: AnimationClip): string { + return String(clip.index); +} + +function animationSearchText(name: string): string { + return `${name} ${displayAnimationName(name)}` + .replace(/([a-z])([A-Z])/g, "$1 $2") + .toLowerCase() + .replace(/[^a-z0-9]+/g, " ") + .trim(); +} + +function isWalkingAnimationClip(clip: AnimationClip): boolean { + return /\bwalk(?:ing)?\b/.test(animationSearchText(clip.name)); +} + +function isIdleAnimationClip(clip: AnimationClip): boolean { + return /\bidle\b/.test(animationSearchText(clip.name)); +} + +function firstSelectableAnimationValue(model: LoadedModel): string { + const clips = dedupeAnimationClips(model.animation?.clips ?? []); + const preferred = clips.find(isWalkingAnimationClip) ?? clips.find((clip) => !isIdleAnimationClip(clip)) ?? clips[0]; + return preferred ? animationClipValue(preferred) : ""; +} + +function hasAnimationValue(model: LoadedModel, value: string): boolean { + if (value === "") return true; + return dedupeAnimationClips(model.animation?.clips ?? []).some((clip) => animationClipValue(clip) === value); +} + function resolveInitialPreset(): PresetModel { const id = routeInitialPresetId(ALL_PRESET_IDS); return (id ? PRESETS.find((p) => p.id === id) : null) ?? randomPreset(); @@ -471,10 +548,12 @@ export default function GalleryWorkbench() { const [vanillaBuildMs, setVanillaBuildMs] = useState(0); const [modelSearch, setModelSearch] = useState(""); const [openModelCategory, setOpenModelCategory] = useState(null); + const [mobilePanel, setMobilePanel] = useState(null); const viewportRef = useRef(null); const autoZoomPresetRef = useRef(null); const autoAmbientPresetRef = useRef(null); const autoKeyPresetRef = useRef(null); + const loadedModelKeyRef = useRef(null); // Selection + drag state for the React renderer's wrapper. // Lives at this level so a model swap can reset both — the gizmo @@ -519,6 +598,7 @@ export default function GalleryWorkbench() { autoKeyPresetRef.current = null; setRoutePresetId(null); setPresetId(source.id); + if (loadedModelKeyRef.current !== source.id) loadedModelKeyRef.current = null; setSelectedAnimation(""); setParserOptions((current) => ({ ...current, @@ -543,6 +623,19 @@ export default function GalleryWorkbench() { ); const selectedPreset = availablePresets.find((preset) => preset.id === presetId) ?? PRESETS[0]; const selectedDroppedSource = dropped.droppedSource?.id === selectedPreset.id ? dropped.droppedSource : null; + const loadMeshResolution = activeMeshResolution(sceneOptions.meshResolution); + const handleLoaded = useCallback((model: LoadedModel) => { + const modelKey = selectedPreset.id; + const modelChanged = loadedModelKeyRef.current !== modelKey; + loadedModelKeyRef.current = modelKey; + setLoaded(model); + setSelectedAnimation((current) => { + const first = firstSelectableAnimationValue(model); + if (!first) return ""; + if (!modelChanged && hasAnimationValue(model, current)) return current; + return first; + }); + }, [selectedPreset.id]); const selectedPresetPickerCategory = pickerItems.find((preset) => preset.id === selectedPreset.id)?.category ?? galleryBucketForPreset(selectedPreset); @@ -603,7 +696,8 @@ export default function GalleryWorkbench() { selectedPreset, selectedDroppedSource, parserOptions, - onLoaded: setLoaded, + meshResolution: loadMeshResolution, + onLoaded: handleLoaded, onLoadError: (msg) => { setLoaded(null); setLoadError(msg || null); @@ -742,6 +836,7 @@ export default function GalleryWorkbench() { autoAmbientPresetRef.current = null; autoKeyPresetRef.current = null; setPresetId(id); + if (loadedModelKeyRef.current !== id) loadedModelKeyRef.current = null; setSelectedAnimation(""); animation.setReactAnimatedPolygons(null); if (!next) return; @@ -763,8 +858,23 @@ export default function GalleryWorkbench() { const handleRandomPreset = useCallback(() => { const next = randomPreset(); resetToPreset(next.id, { updateRoute: true }); + setMobilePanel(null); }, [resetToPreset]); + const handlePresetClick = useCallback((id: string) => { + resetToPreset(id, { updateRoute: true }); + setMobilePanel(null); + }, [resetToPreset]); + + useEffect(() => { + if (!mobilePanel) return; + const handleKeyDown = (event: KeyboardEvent) => { + if (event.key === "Escape") setMobilePanel(null); + }; + window.addEventListener("keydown", handleKeyDown); + return () => window.removeEventListener("keydown", handleKeyDown); + }, [mobilePanel]); + useRouteSync({ presetId, presetIds: ALL_PRESET_IDS, @@ -890,7 +1000,7 @@ export default function GalleryWorkbench() { } if (colorGroups.size === 0 && textured.length === 0) return []; const sortedColors = [...colorGroups.entries()] - .sort((a, b) => b[1].length - a[1].length) + .sort((a, b) => compareInspectorColors(a[0], b[0]) || b[1].length - a[1].length) .map(([color, polys]) => ({ color, count: polys.length, @@ -930,13 +1040,19 @@ export default function GalleryWorkbench() { return (
dropped.fileInputRef.current?.click()} @@ -948,7 +1064,7 @@ export default function GalleryWorkbench() { onToggleCategory={handleToggleCategory} modelTreeId={modelTreeId} presetId={presetId} - onPresetClick={(id) => resetToPreset(id, { updateRoute: true })} + onPresetClick={handlePresetClick} attribution={selectedPreset.attribution} /> @@ -1019,7 +1135,12 @@ export default function GalleryWorkbench() { - + + +
); } diff --git a/website/src/components/GalleryWorkbench/gallery-workbench.css b/website/src/components/GalleryWorkbench/gallery-workbench.css index 0e1ccf36..ab935a77 100644 --- a/website/src/components/GalleryWorkbench/gallery-workbench.css +++ b/website/src/components/GalleryWorkbench/gallery-workbench.css @@ -491,7 +491,8 @@ .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 { +.dn-model-mesh.is-mesh-hidden > .polycss-bucket, +.dn-model-mesh.is-mesh-hidden > .polycss-normal-cull-bucket { display: none !important; } @@ -1236,6 +1237,10 @@ color: #64748b; } +.dn-mobile-tabs { + display: none; +} + @media (max-width: 1100px) { .dn-floating-controls { width: fit-content; @@ -1274,4 +1279,128 @@ left: 8px; max-height: calc(100% - 16px); } + + .dn-root--gallery { + --mobile-tabs-height: 52px; + --mobile-panel-bottom: calc(var(--overlay-bottom) + var(--mobile-tabs-height) + 8px); + } + + .dn-root--gallery .models-sidebar, + .dn-root--gallery .dn-floating-controls { + display: none; + top: auto; + right: var(--overlay-right); + bottom: var(--mobile-panel-bottom); + left: var(--overlay-left); + width: auto; + max-width: none; + z-index: 25; + } + + .dn-root--gallery .models-sidebar { + height: min(58vh, calc(100% - var(--overlay-top) - var(--mobile-panel-bottom) - 8px)); + max-height: none; + } + + .dn-root--gallery .models-sidebar.is-mobile-open { + display: block; + } + + .dn-root--gallery .models-sidebar__body { + height: 100%; + max-height: none; + } + + .dn-root--gallery .models-sidebar__header { + align-items: stretch; + gap: 6px; + } + + .dn-root--gallery .models-sidebar .model-search { + height: 34px; + } + + .dn-root--gallery .control-btn { + min-height: 34px; + padding-inline: 8px; + } + + .dn-root--gallery .model-button-list { + max-height: none; + } + + .dn-root--gallery .dn-floating-controls { + height: min(66vh, calc(100% - var(--overlay-top) - var(--mobile-panel-bottom) - 8px)); + max-height: none; + } + + .dn-root--gallery .dn-floating-controls.is-mobile-open { + display: flex; + } + + .dn-root--gallery .dn-floating-controls .dn-lil-gui-host, + .dn-root--gallery .dn-floating-controls .dn-lil-gui-host > .lil-gui.root { + width: 100% !important; + max-width: 100%; + } + + .dn-root--gallery .dn-floating-controls .dn-lil-gui-host { + flex: 1 1 auto; + max-height: inherit; + min-height: 0; + overflow: hidden; + } + + .dn-root--gallery .dn-floating-controls .dn-lil-gui-host > .lil-gui.root { + box-sizing: border-box; + height: 100% !important; + max-height: inherit; + } + + .dn-root--gallery .dn-floating-controls .dn-note { + margin: 4px 0 0; + padding: 0 4px; + } + + .dn-mobile-tabs { + position: absolute; + right: var(--overlay-right); + bottom: var(--overlay-bottom); + left: var(--overlay-left); + z-index: 30; + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: 8px; + pointer-events: auto; + } + + .dn-mobile-tabs__button { + min-width: 0; + min-height: 44px; + padding: 0 10px; + border: 1px solid rgba(255, 255, 255, 0.1); + border-radius: 8px; + background: rgba(17, 19, 22, 0.92); + color: #d8e2ec; + font: 700 13px/1 Inter, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; + cursor: pointer; + backdrop-filter: blur(8px); + -webkit-backdrop-filter: blur(8px); + } + + .dn-mobile-tabs__button.is-active { + border-color: rgba(34, 211, 238, 0.55); + background: #12313a; + color: #ecfeff; + } + + .dn-mobile-tabs__button--random { + border-color: rgba(74, 222, 128, 0.55); + background: #14532d; + color: #dcfce7; + } + + .dn-stats-overlay { + display: none !important; + } } diff --git a/website/src/components/GalleryWorkbench/helpers/loaders.ts b/website/src/components/GalleryWorkbench/helpers/loaders.ts index 0cb3e5cc..046ac200 100644 --- a/website/src/components/GalleryWorkbench/helpers/loaders.ts +++ b/website/src/components/GalleryWorkbench/helpers/loaders.ts @@ -12,8 +12,29 @@ import type { ParserOptionsState, PresetModel, } from "../types"; +import { activeMeshResolution, type WorkbenchMeshResolution } from "../../types"; +import { cleanupLossyBakedTextureColors } from "./lossyColorCleanup"; import { mergeParserOptions } from "./parserOptions"; +const VOX_LOSSY_PALETTE_MERGE_DISTANCE = 28; +const VOX_LOSSY_COLOR_REGION_MERGE_DISTANCE = 36; + +function mergeVoxParserOptions( + base: PresetModel["options"], + parser: ParserOptionsState, + meshResolution: WorkbenchMeshResolution, +) { + const options = mergeParserOptions(base, parser); + if (activeMeshResolution(meshResolution) === "lossless") { + delete options.paletteMergeDistance; + delete options.colorRegionMergeDistance; + return options; + } + options.paletteMergeDistance ??= VOX_LOSSY_PALETTE_MERGE_DISTANCE; + options.colorRegionMergeDistance ??= VOX_LOSSY_COLOR_REGION_MERGE_DISTANCE; + return options; +} + /** * Find every .mtl file referenced by an OBJ via its `mtllib` directives. * Returns dropped File objects that match (case-insensitive basename). @@ -60,6 +81,7 @@ function findDroppedFile(index: Map, path: string): File | undefin export async function loadPresetModel( model: PresetModel, parser: ParserOptionsState, + meshResolution: WorkbenchMeshResolution = "lossy", ): Promise { const started = performance.now(); if (model.kind === "primitive") { @@ -114,7 +136,8 @@ export async function loadPresetModel( ...((model.options as ObjParseOptions | undefined)?.materialTextures ?? {}), }, }); - const parsed = await bakeSolidTextureSamples(parsedObj); + const baked = await bakeSolidTextureSamples(parsedObj); + const parsed = cleanupLossyBakedTextureColors(parsedObj, baked, { meshResolution }); return { label: model.label, kind: "obj", @@ -135,7 +158,7 @@ export async function loadPresetModel( }); if (model.kind === "vox") { - const parsed = parseVox(buf, mergeParserOptions(model.options, parser)); + const parsed = parseVox(buf, mergeVoxParserOptions(model.options, parser, meshResolution)); return { label: model.label, kind: "vox", @@ -154,7 +177,8 @@ export async function loadPresetModel( ...mergeParserOptions(model.options, parser), baseUrl: new URL(url, window.location.href).href, }); - const parsed = await bakeSolidTextureSamples(parsedGltf); + const baked = await bakeSolidTextureSamples(parsedGltf); + const parsed = cleanupLossyBakedTextureColors(parsedGltf, baked, { meshResolution }); return { label: model.label, kind: model.kind, @@ -173,6 +197,7 @@ export async function loadPresetModel( export async function loadDroppedModel( source: DroppedModelSource, parser: ParserOptionsState, + meshResolution: WorkbenchMeshResolution = "lossy", ): Promise { const started = performance.now(); const options = mergeParserOptions(source.preset.options, parser); @@ -223,7 +248,8 @@ export async function loadDroppedModel( ...(presetOptions?.materialTextures ?? {}), }, }); - const parsed = await bakeSolidTextureSamples(parsedObj); + const baked = await bakeSolidTextureSamples(parsedObj); + const parsed = cleanupLossyBakedTextureColors(parsedObj, baked, { meshResolution }); let disposed = false; const parseResult = { ...parsed, @@ -252,7 +278,7 @@ export async function loadDroppedModel( const buf = await source.primaryFile.arrayBuffer(); if (source.kind === "vox") { - const parsed = parseVox(buf, options); + const parsed = parseVox(buf, mergeVoxParserOptions(source.preset.options, parser, meshResolution)); return { label: source.label, kind: "vox", @@ -268,7 +294,8 @@ export async function loadDroppedModel( } const parsedGltf = parseGltf(buf, options); - const parsed = await bakeSolidTextureSamples(parsedGltf); + const baked = await bakeSolidTextureSamples(parsedGltf); + const parsed = cleanupLossyBakedTextureColors(parsedGltf, baked, { meshResolution }); return { label: source.label, kind: "glb", diff --git a/website/src/components/GalleryWorkbench/helpers/lossyColorCleanup.ts b/website/src/components/GalleryWorkbench/helpers/lossyColorCleanup.ts new file mode 100644 index 00000000..ec10bad6 --- /dev/null +++ b/website/src/components/GalleryWorkbench/helpers/lossyColorCleanup.ts @@ -0,0 +1,171 @@ +import { parsePureColor } from "@layoutit/polycss"; +import type { ParseResult, Polygon } from "@layoutit/polycss"; +import { activeMeshResolution, type WorkbenchMeshResolution } from "../../types"; + +interface RgbColor { + rgb: [number, number, number]; + alpha: number; +} + +interface HsvColor { + hue: number; + saturation: number; + value: number; +} + +export interface LossyBakedTextureColorOptions { + meshResolution: WorkbenchMeshResolution; + distance?: number; +} + +const DEFAULT_DISTANCE = 36; + +function hasTexturePaint(polygon: Polygon): boolean { + return Boolean( + polygon.texture || + polygon.material?.texture || + polygon.uvs?.length || + polygon.textureTriangles?.length + ); +} + +function colorKey(color: string): string { + const parsed = parsePureColor(color); + if (!parsed || parsed.alpha < 1) return color.trim().toLowerCase(); + return `#${parsed.rgb + .map((channel) => Math.max(0, Math.min(255, Math.round(channel))).toString(16).padStart(2, "0")) + .join("")}`; +} + +function parseColor(color: string): RgbColor | null { + const parsed = parsePureColor(color); + if (!parsed) return null; + return { + rgb: [ + Math.max(0, Math.min(255, Math.round(parsed.rgb[0]))), + Math.max(0, Math.min(255, Math.round(parsed.rgb[1]))), + Math.max(0, Math.min(255, Math.round(parsed.rgb[2]))), + ], + alpha: parsed.alpha, + }; +} + +function colorDistance(a: RgbColor, b: RgbColor): number { + return Math.hypot( + a.rgb[0] - b.rgb[0], + a.rgb[1] - b.rgb[1], + a.rgb[2] - b.rgb[2], + ); +} + +function hsvFromColor(color: RgbColor): HsvColor { + const r = color.rgb[0] / 255; + const g = color.rgb[1] / 255; + const b = color.rgb[2] / 255; + const max = Math.max(r, g, b); + const min = Math.min(r, g, b); + const delta = max - min; + let hue = 0; + if (delta !== 0) { + if (max === r) hue = 60 * (((g - b) / delta) % 6); + else if (max === g) hue = 60 * ((b - r) / delta + 2); + else hue = 60 * ((r - g) / delta + 4); + } + if (hue < 0) hue += 360; + return { + hue, + saturation: max === 0 ? 0 : delta / max, + value: max, + }; +} + +function hueDistance(a: number, b: number): number { + const delta = Math.abs(a - b) % 360; + return delta > 180 ? 360 - delta : delta; +} + +function compatibleColors(a: RgbColor, b: RgbColor): boolean { + if (a.alpha < 1 || b.alpha < 1) return false; + const ah = hsvFromColor(a); + const bh = hsvFromColor(b); + const aNeutral = ah.saturation < 0.08; + const bNeutral = bh.saturation < 0.08; + if (aNeutral || bNeutral) return aNeutral === bNeutral; + const tolerance = Math.min(ah.value, bh.value) < 0.18 ? 32 : 18; + return hueDistance(ah.hue, bh.hue) <= tolerance; +} + +function buildColorMergeMap(colors: Map, distance: number): Map { + const parsed = new Map(); + for (const color of colors.keys()) { + const value = parseColor(color); + if (value) parsed.set(color, value); + } + const representatives: Array<{ color: string; parsed: RgbColor }> = []; + const remap = new Map(); + const entries = Array.from(colors.entries()) + .sort((a, b) => b[1] - a[1] || a[0].localeCompare(b[0])); + + for (const [color] of entries) { + const value = parsed.get(color); + if (!value) { + remap.set(color, color); + continue; + } + let best: { color: string; distance: number } | null = null; + for (const representative of representatives) { + if (!compatibleColors(value, representative.parsed)) continue; + const candidateDistance = colorDistance(value, representative.parsed); + if (candidateDistance > distance) continue; + if (!best || candidateDistance < best.distance) { + best = { color: representative.color, distance: candidateDistance }; + } + } + if (best) { + remap.set(color, best.color); + } else { + representatives.push({ color, parsed: value }); + remap.set(color, color); + } + } + + return remap; +} + +export function cleanupLossyBakedTextureColors( + source: ParseResult, + baked: ParseResult, + options: LossyBakedTextureColorOptions, +): ParseResult { + if (activeMeshResolution(options.meshResolution) !== "lossy") return baked; + if (baked.animation) return baked; + const distance = options.distance ?? DEFAULT_DISTANCE; + if (!Number.isFinite(distance) || distance <= 0) return baked; + + const candidateColors = new Map(); + const candidateIndices: number[] = []; + for (let index = 0; index < baked.polygons.length; index += 1) { + const before = source.polygons[index]; + const after = baked.polygons[index]; + if (!before || !after || before === after || !after.color) continue; + if (!hasTexturePaint(before) || hasTexturePaint(after)) continue; + const key = colorKey(after.color); + candidateIndices.push(index); + candidateColors.set(key, (candidateColors.get(key) ?? 0) + 1); + } + + if (candidateColors.size < 2) return baked; + const remap = buildColorMergeMap(candidateColors, distance); + let changed = false; + const polygons = baked.polygons.slice(); + for (const index of candidateIndices) { + const polygon = polygons[index]!; + const sourceColor = colorKey(polygon.color!); + const nextColor = remap.get(sourceColor) ?? sourceColor; + if (nextColor === sourceColor) continue; + polygons[index] = { ...polygon, color: nextColor }; + changed = true; + } + + return changed ? { ...baked, polygons } : baked; +} diff --git a/website/src/components/GalleryWorkbench/hooks/usePresetLoader.ts b/website/src/components/GalleryWorkbench/hooks/usePresetLoader.ts index 6920647e..6eb724ce 100644 --- a/website/src/components/GalleryWorkbench/hooks/usePresetLoader.ts +++ b/website/src/components/GalleryWorkbench/hooks/usePresetLoader.ts @@ -1,5 +1,6 @@ import { useEffect, useRef, type RefObject } from "react"; import type { DroppedModelSource, LoadedModel, ParserOptionsState, PresetModel } from "../types"; +import type { WorkbenchMeshResolution } from "../../types"; import { loadPresetModel, loadDroppedModel } from "../helpers/loaders"; import { smartAmbientForModel, @@ -11,6 +12,7 @@ export interface UsePresetLoaderOptions { selectedPreset: PresetModel; selectedDroppedSource: DroppedModelSource | null; parserOptions: ParserOptionsState; + meshResolution: WorkbenchMeshResolution; onLoaded: (model: LoadedModel) => void; onLoadError: (message: string) => void; onLoadingChange: (loading: boolean) => void; @@ -24,6 +26,7 @@ export function usePresetLoader({ selectedPreset, selectedDroppedSource, parserOptions, + meshResolution, onLoaded, onLoadError, onLoadingChange, @@ -45,8 +48,8 @@ export function usePresetLoader({ disposeRef.current?.(); disposeRef.current = null; const next = selectedDroppedSource - ? await loadDroppedModel(selectedDroppedSource, parserOptions) - : await loadPresetModel(presetForLoad, parserOptions); + ? await loadDroppedModel(selectedDroppedSource, parserOptions, meshResolution) + : await loadPresetModel(presetForLoad, parserOptions, meshResolution); if (cancelled) { next.dispose(); return; @@ -81,7 +84,7 @@ export function usePresetLoader({ return () => { cancelled = true; }; - }, [selectedPreset, selectedDroppedSource, parserOptions]); + }, [selectedPreset, selectedDroppedSource, parserOptions, meshResolution]); useEffect(() => { return () => { diff --git a/website/src/components/GalleryWorkbench/presets/attributions.ts b/website/src/components/GalleryWorkbench/presets/attributions.ts index 8d490c86..0939682e 100644 --- a/website/src/components/GalleryWorkbench/presets/attributions.ts +++ b/website/src/components/GalleryWorkbench/presets/attributions.ts @@ -25,6 +25,13 @@ export const KHRONOS_FOX_ATTRIBUTION: ModelAttribution = { tris: 576, }; +export const KHRONOS_AVOCADO_ATTRIBUTION: ModelAttribution = { + creator: "Microsoft", + license: "CC0 1.0", + sourceUrl: "https://github.com/KhronosGroup/glTF-Sample-Assets/tree/main/Models/Avocado", + tris: 682, +}; + export const QUATERNIUS_ULTIMATE_SPACESHIPS_ATTRIBUTION: ModelAttribution = { creator: "Quaternius", license: "CC0 1.0", diff --git a/website/src/components/GalleryWorkbench/presets/presetFiles.ts b/website/src/components/GalleryWorkbench/presets/presetFiles.ts index 9757ae6a..402b4596 100644 --- a/website/src/components/GalleryWorkbench/presets/presetFiles.ts +++ b/website/src/components/GalleryWorkbench/presets/presetFiles.ts @@ -11,6 +11,7 @@ import { MONOGON_VOXEL_SPACESHIPS_ATTRIBUTION, SONA_SAR_VOXEL_ANIMALS_ITEMS_ATTRIBUTION, GOOGLE_POLY_ASTRONAUT_ATTRIBUTION, + KHRONOS_AVOCADO_ATTRIBUTION, MAGICAVOXEL_TEST_MODELS_ATTRIBUTION, OPENGAMEART_VOXEL_BUILDINGS_ATTRIBUTION, OPENHV_VOXELS_ATTRIBUTION, @@ -289,6 +290,13 @@ export const GLB_PRESET_FILES: GalleryPresetFile[] = [ { file: "Violin.glb", category: "Instruments" }, ...SMITHSONIAN_GLB_PRESET_FILES, { file: "apple.glb", label: "Apple", category: "Food & Drink" }, + { + file: "khronos/avocado.glb", + label: "Avocado", + category: "Food & Drink", + galleryBucket: "Textured", + attribution: KHRONOS_AVOCADO_ATTRIBUTION, + }, { file: "BottleChampagne.glb", label: "Champagne Bottle", category: "Food & Drink" }, { file: "Eggplant.glb", category: "Food & Drink" }, { file: "Grapes.glb", category: "Food & Drink" }, diff --git a/website/src/components/ModelsSidebar/ModelsSidebar.tsx b/website/src/components/ModelsSidebar/ModelsSidebar.tsx index bd35bde4..a8aa1b38 100644 --- a/website/src/components/ModelsSidebar/ModelsSidebar.tsx +++ b/website/src/components/ModelsSidebar/ModelsSidebar.tsx @@ -20,6 +20,8 @@ export interface ModelCategory { } export interface ModelsSidebarProps { + id?: string; + className?: string; modelSearch: string; onModelSearchChange: (value: string) => void; onImportClick: () => void; @@ -60,6 +62,8 @@ function AttributionCredit({ attribution }: { attribution?: ModelAttribution }) } export function ModelsSidebar({ + id, + className, modelSearch, onModelSearchChange, onImportClick, @@ -75,7 +79,11 @@ export function ModelsSidebar({ attribution, }: ModelsSidebarProps) { return ( -