From db37103fcc3aea08ad50e47d86318952db77ab7f Mon Sep 17 00:00:00 2001 From: agustin-littlehat Date: Mon, 25 May 2026 13:57:15 -0300 Subject: [PATCH 1/5] chore(agents): rename chrome trace skill --- .../chrome-capture-trace/agents/openai.yaml | 4 --- .../SKILL.md | 28 +++++++++---------- .../skills/chrome-trace/agents/openai.yaml | 4 +++ .../scripts/capture-trace.mjs | 4 +-- .../scripts/polycss-nonvoxel-drag-trace.mjs | 12 ++++---- .../scripts/polycss-trace-analysis.mjs | 23 +++++++++------ .../scripts/trace.mjs | 20 ++++++------- 7 files changed, 51 insertions(+), 44 deletions(-) delete mode 100644 .agents/skills/chrome-capture-trace/agents/openai.yaml rename .agents/skills/{chrome-capture-trace => chrome-trace}/SKILL.md (77%) create mode 100644 .agents/skills/chrome-trace/agents/openai.yaml rename .agents/skills/{chrome-capture-trace => chrome-trace}/scripts/capture-trace.mjs (99%) rename .agents/skills/{chrome-capture-trace => chrome-trace}/scripts/polycss-nonvoxel-drag-trace.mjs (97%) rename .agents/skills/{chrome-capture-trace => chrome-trace}/scripts/polycss-trace-analysis.mjs (97%) rename .agents/skills/{chrome-capture-trace => chrome-trace}/scripts/trace.mjs (94%) diff --git a/.agents/skills/chrome-capture-trace/agents/openai.yaml b/.agents/skills/chrome-capture-trace/agents/openai.yaml deleted file mode 100644 index f2989865..00000000 --- a/.agents/skills/chrome-capture-trace/agents/openai.yaml +++ /dev/null @@ -1,4 +0,0 @@ -interface: - display_name: "Chrome Capture Trace" - short_description: "Capture and analyze Chrome performance traces" - default_prompt: "Use $chrome-capture-trace to capture a Chrome trace around an interaction and identify where frame time is spent." diff --git a/.agents/skills/chrome-capture-trace/SKILL.md b/.agents/skills/chrome-trace/SKILL.md similarity index 77% rename from .agents/skills/chrome-capture-trace/SKILL.md rename to .agents/skills/chrome-trace/SKILL.md index 6c05eb6b..3476843f 100644 --- a/.agents/skills/chrome-capture-trace/SKILL.md +++ b/.agents/skills/chrome-trace/SKILL.md @@ -1,9 +1,9 @@ --- -name: chrome-capture-trace +name: chrome-trace description: Capture and analyze Chrome/Chromium performance traces with Playwright around a concrete browser interaction. Use when Codex needs to answer where frame time is spent during an update, drag, rotation, scroll, animation, camera movement, light movement, DOM/CSS render change, or other performance-sensitive UI action; especially when the right answer requires per-frame Chrome trace evidence instead of FPS-only guesses. --- -# Chrome Capture Trace +# Chrome Trace ## Core Workflow @@ -26,12 +26,12 @@ 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 +node .agents/skills/chrome-trace/scripts/trace.mjs motion --page nonvoxel --mesh glb:Elephant.glb --variant baseline --dom-samples --label elephant-baseline +node .agents/skills/chrome-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-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-trace/scripts/trace.mjs drag --mesh teapot --mode baked --frame-details --label teapot-drag +node .agents/skills/chrome-trace/scripts/trace.mjs motion --mesh garden --report --markdown-out bench/results/garden-trace.md +node .agents/skills/chrome-trace/scripts/trace.mjs compare bench/results/before.json bench/results/after.json --markdown-out bench/results/trace-compare.md ``` Use `trace.mjs motion` for steady bench motion across `perf` and `nonvoxel` pages, cadence buckets, DOM samples, render stats, and tag counts. @@ -65,7 +65,7 @@ Trace event durations are inclusive and often nested, especially GPU/viz and sch For arbitrary pages, use `trace.mjs generic`: ```bash -node .agents/skills/chrome-capture-trace/scripts/trace.mjs generic \ +node .agents/skills/chrome-trace/scripts/trace.mjs generic \ --url http://127.0.0.1:3000 \ --ready-js "window.appReady === true" \ --action drag \ @@ -80,9 +80,9 @@ node .agents/skills/chrome-capture-trace/scripts/trace.mjs generic \ Useful alternatives: ```bash -node .agents/skills/chrome-capture-trace/scripts/trace.mjs generic --url http://127.0.0.1:3000 --action wait --sample 3000 -node .agents/skills/chrome-capture-trace/scripts/trace.mjs generic --url http://127.0.0.1:3000 --action eval --eval "window.rotateScene?.(Math.PI / 2)" -node .agents/skills/chrome-capture-trace/scripts/trace.mjs generic --url http://127.0.0.1:3000 --action scroll --scroll "0,900" +node .agents/skills/chrome-trace/scripts/trace.mjs generic --url http://127.0.0.1:3000 --action wait --sample 3000 +node .agents/skills/chrome-trace/scripts/trace.mjs generic --url http://127.0.0.1:3000 --action eval --eval "window.rotateScene?.(Math.PI / 2)" +node .agents/skills/chrome-trace/scripts/trace.mjs generic --url http://127.0.0.1:3000 --action scroll --scroll "0,900" ``` ## Comparing Runs @@ -90,13 +90,13 @@ node .agents/skills/chrome-capture-trace/scripts/trace.mjs generic --url http:// Use `--report` on a runner to generate a Markdown report after capture, or use `trace.mjs report` on an existing summary JSON: ```bash -node .agents/skills/chrome-capture-trace/scripts/trace.mjs report bench/results/garden.json --markdown-out bench/results/garden.md +node .agents/skills/chrome-trace/scripts/trace.mjs report bench/results/garden.json --markdown-out bench/results/garden.md ``` Use `trace.mjs compare` on summary JSON files from any runner: ```bash -node .agents/skills/chrome-capture-trace/scripts/trace.mjs compare before.json after.json --markdown-out trace-compare.md +node .agents/skills/chrome-trace/scripts/trace.mjs compare before.json after.json --markdown-out trace-compare.md ``` Read positive deltas in `frame_time_*_ms` and trace group `ms/frame` as more expensive after the change. Read positive FPS deltas as better. diff --git a/.agents/skills/chrome-trace/agents/openai.yaml b/.agents/skills/chrome-trace/agents/openai.yaml new file mode 100644 index 00000000..c71ae322 --- /dev/null +++ b/.agents/skills/chrome-trace/agents/openai.yaml @@ -0,0 +1,4 @@ +interface: + display_name: "Chrome Trace" + short_description: "Capture and analyze Chrome performance traces" + default_prompt: "Use $chrome-trace to capture a Chrome trace around an interaction and identify where frame time is spent." diff --git a/.agents/skills/chrome-capture-trace/scripts/capture-trace.mjs b/.agents/skills/chrome-trace/scripts/capture-trace.mjs similarity index 99% rename from .agents/skills/chrome-capture-trace/scripts/capture-trace.mjs rename to .agents/skills/chrome-trace/scripts/capture-trace.mjs index bce638d0..925dca2e 100755 --- a/.agents/skills/chrome-capture-trace/scripts/capture-trace.mjs +++ b/.agents/skills/chrome-trace/scripts/capture-trace.mjs @@ -563,7 +563,7 @@ async function run() { : summarizeEvents(traceEvents, 0, -Infinity, Infinity, frames); const summary = { - kind: "chrome-capture-trace", + kind: "chrome-trace", url: URL, viewport, action, @@ -590,7 +590,7 @@ async function run() { traceEvents, displayTimeUnit: "ms", metadata: { - source: "chrome-capture-trace/scripts/capture-trace.mjs", + source: "chrome-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-trace/scripts/polycss-nonvoxel-drag-trace.mjs similarity index 97% rename from .agents/skills/chrome-capture-trace/scripts/polycss-nonvoxel-drag-trace.mjs rename to .agents/skills/chrome-trace/scripts/polycss-nonvoxel-drag-trace.mjs index a2a768a6..c2de0181 100755 --- a/.agents/skills/chrome-capture-trace/scripts/polycss-nonvoxel-drag-trace.mjs +++ b/.agents/skills/chrome-trace/scripts/polycss-nonvoxel-drag-trace.mjs @@ -7,11 +7,11 @@ * and writes both a Chrome trace file and a compact JSON summary. * * Usage: - * node .agents/skills/chrome-capture-trace/scripts/polycss-nonvoxel-drag-trace.mjs - * node .agents/skills/chrome-capture-trace/scripts/polycss-nonvoxel-drag-trace.mjs --mesh teapot --mode baked --label teapot-drag - * node .agents/skills/chrome-capture-trace/scripts/polycss-nonvoxel-drag-trace.mjs --degrees 360 --drag-ms 1500 --steps 120 - * node .agents/skills/chrome-capture-trace/scripts/polycss-nonvoxel-drag-trace.mjs --variant force-atlas --trace-out bench/results/teapot.trace.json - * node .agents/skills/chrome-capture-trace/scripts/polycss-nonvoxel-drag-trace.mjs --frame-details --no-print-json + * node .agents/skills/chrome-trace/scripts/polycss-nonvoxel-drag-trace.mjs + * node .agents/skills/chrome-trace/scripts/polycss-nonvoxel-drag-trace.mjs --mesh teapot --mode baked --label teapot-drag + * node .agents/skills/chrome-trace/scripts/polycss-nonvoxel-drag-trace.mjs --degrees 360 --drag-ms 1500 --steps 120 + * node .agents/skills/chrome-trace/scripts/polycss-nonvoxel-drag-trace.mjs --variant force-atlas --trace-out bench/results/teapot.trace.json + * node .agents/skills/chrome-trace/scripts/polycss-nonvoxel-drag-trace.mjs --frame-details --no-print-json */ import { createServer } from "node:http"; import { mkdirSync, writeFileSync } from "node:fs"; @@ -877,7 +877,7 @@ async function run() { traceEvents: events, displayTimeUnit: "ms", metadata: { - source: ".agents/skills/chrome-capture-trace/scripts/polycss-nonvoxel-drag-trace.mjs", + source: ".agents/skills/chrome-trace/scripts/polycss-nonvoxel-drag-trace.mjs", mesh: MESH, mode: MODE, variant: VARIANT, diff --git a/.agents/skills/chrome-capture-trace/scripts/polycss-trace-analysis.mjs b/.agents/skills/chrome-trace/scripts/polycss-trace-analysis.mjs similarity index 97% rename from .agents/skills/chrome-capture-trace/scripts/polycss-trace-analysis.mjs rename to .agents/skills/chrome-trace/scripts/polycss-trace-analysis.mjs index ffc1cceb..d5afba24 100644 --- a/.agents/skills/chrome-capture-trace/scripts/polycss-trace-analysis.mjs +++ b/.agents/skills/chrome-trace/scripts/polycss-trace-analysis.mjs @@ -6,11 +6,11 @@ * samples, and reports compositor/style/raster/script cost per cadence bucket. * * Usage: - * node .agents/skills/chrome-capture-trace/scripts/polycss-trace-analysis.mjs - * 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 + * node .agents/skills/chrome-trace/scripts/polycss-trace-analysis.mjs + * node .agents/skills/chrome-trace/scripts/polycss-trace-analysis.mjs --mesh ancient-crash-site --runs 3 --dom-samples + * node .agents/skills/chrome-trace/scripts/polycss-trace-analysis.mjs --mesh obj-house3 --renderer vanilla --label obj-house3-trace + * node .agents/skills/chrome-trace/scripts/polycss-trace-analysis.mjs --page nonvoxel --mesh glb:Elephant.glb --variant order-tile4 --no-trace + * node .agents/skills/chrome-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"; @@ -64,6 +64,7 @@ const MODE = optStr("mode", "baked"); const MOTION = optStr("motion", "rot"); const RENDERER = optStr("renderer", "vanilla"); const VARIANT = optStr("variant", "baseline"); +const DISABLE_STRATEGIES = optStr("disable-strategies", optStr("disableStrategies", "")); const WARMUP_MS = optNum("warmup", 1500); const SAMPLE_MS = optNum("sample", 6000); const RUNS = optNum("runs", 1); @@ -266,7 +267,7 @@ const KEY_EVENTS = [ ]; function printHelp() { - console.log(`Usage: node .agents/skills/chrome-capture-trace/scripts/polycss-trace-analysis.mjs [options] + console.log(`Usage: node .agents/skills/chrome-trace/scripts/polycss-trace-analysis.mjs [options] Options: --page perf | nonvoxel. Default: perf @@ -953,7 +954,13 @@ function pageUrl(port) { if (PAGE !== "perf") { throw new Error(`Unknown --page "${PAGE}". Expected "perf" or "nonvoxel".`); } - return `http://127.0.0.1:${port}/perf-${RENDERER}.html?mesh=${encodeURIComponent(MESH)}&mode=${encodeURIComponent(MODE)}&motion=${encodeURIComponent(MOTION)}`; + const params = new URLSearchParams({ + mesh: MESH, + mode: MODE, + motion: MOTION, + ...(DISABLE_STRATEGIES ? { disableStrategies: DISABLE_STRATEGIES } : {}), + }); + return `http://127.0.0.1:${port}/perf-${RENDERER}.html?${params.toString()}`; } function traceOutputPath(repeat) { @@ -1085,7 +1092,7 @@ async function runOnce(port, repeat) { traceEvents: events, displayTimeUnit: "ms", metadata: { - source: ".agents/skills/chrome-capture-trace/scripts/polycss-trace-analysis.mjs", + source: ".agents/skills/chrome-trace/scripts/polycss-trace-analysis.mjs", page: PAGE, mesh: MESH, renderer: RENDERER, diff --git a/.agents/skills/chrome-capture-trace/scripts/trace.mjs b/.agents/skills/chrome-trace/scripts/trace.mjs similarity index 94% rename from .agents/skills/chrome-capture-trace/scripts/trace.mjs rename to .agents/skills/chrome-trace/scripts/trace.mjs index 9751ddfd..451cac1f 100755 --- a/.agents/skills/chrome-capture-trace/scripts/trace.mjs +++ b/.agents/skills/chrome-trace/scripts/trace.mjs @@ -24,7 +24,7 @@ const RUNNERS = new Map([ function printHelp() { console.log(`Usage: - node .agents/skills/chrome-capture-trace/scripts/trace.mjs [options] + node .agents/skills/chrome-trace/scripts/trace.mjs [options] Commands: polycss-motion Steady perf/nonvoxel bench trace buckets. @@ -39,14 +39,14 @@ Aliases: capture -> generic 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 - node .agents/skills/chrome-capture-trace/scripts/trace.mjs report bench/results/garden.json --markdown-out bench/results/garden.md - node .agents/skills/chrome-capture-trace/scripts/trace.mjs compare before.json after.json --markdown-out report.md + node .agents/skills/chrome-trace/scripts/trace.mjs motion --page nonvoxel --mesh glb:Elephant.glb --dom-samples --label elephant + node .agents/skills/chrome-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-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-trace/scripts/trace.mjs drag --mesh teapot --degrees 360 --frame-details --label teapot-drag + node .agents/skills/chrome-trace/scripts/trace.mjs generic --url http://127.0.0.1:3000 --action drag --selector "#viewport" + node .agents/skills/chrome-trace/scripts/trace.mjs motion --report --markdown-out bench/results/garden.md + node .agents/skills/chrome-trace/scripts/trace.mjs report bench/results/garden.json --markdown-out bench/results/garden.md + node .agents/skills/chrome-trace/scripts/trace.mjs compare before.json after.json --markdown-out report.md `); } @@ -565,7 +565,7 @@ function ensureReportSummaryPath(cmd, args) { if (cmd === "polycss-motion" || cmd === "motion" || cmd === "polycss-buckets" || cmd === "buckets") { const label = argValue(args, "label"); if (label) return { childArgs: args, summaryPath: inferSummaryPath(cmd, args) }; - const file = resolve(tmpdir(), `chrome-capture-trace-${Date.now()}.json`); + const file = resolve(tmpdir(), `chrome-trace-${Date.now()}.json`); return { childArgs: appendArg(args, "summary-out", file), summaryPath: file }; } From 748142d2601754bb73203fc07bd50047aaa89a09 Mon Sep 17 00:00:00 2001 From: agustin-littlehat Date: Mon, 25 May 2026 13:57:23 -0300 Subject: [PATCH 2/5] perf(core): reuse gltf animation caches --- packages/core/src/parser/parseGltf.test.ts | 10 ++ packages/core/src/parser/parseGltf.ts | 115 +++++++++++++++++---- 2 files changed, 103 insertions(+), 22 deletions(-) diff --git a/packages/core/src/parser/parseGltf.test.ts b/packages/core/src/parser/parseGltf.test.ts index d8347643..3d430913 100644 --- a/packages/core/src/parser/parseGltf.test.ts +++ b/packages/core/src/parser/parseGltf.test.ts @@ -158,6 +158,16 @@ describe("parseGltf — animated fixture (FishAnimated.glb)", () => { expect(totalDelta).toBeGreaterThan(1); }); + it("does not mutate previously sampled polygons when reusing animation caches", () => { + const result = parseGltf(loadGlbFile("FishAnimated.glb")); + const first = result.animation!.sample(0, 0.125); + const firstVertexSnapshot = first[0]!.vertices.map((vertex) => [...vertex]); + + result.animation!.sample(0, 0.375); + + expect(first[0]!.vertices).toEqual(firstVertexSnapshot); + }); + it("keeps robot running samples aligned with rest-pose triangle filtering", () => { const result = parseGltf(loadGlbFile("poly-pizza", "animated-robot.glb"), { gridShift: 0, diff --git a/packages/core/src/parser/parseGltf.ts b/packages/core/src/parser/parseGltf.ts index 21d064f8..70998dfb 100644 --- a/packages/core/src/parser/parseGltf.ts +++ b/packages/core/src/parser/parseGltf.ts @@ -614,6 +614,10 @@ const IDENTITY4: Mat4 = [1,0,0,0, 0,1,0,0, 0,0,1,0, 0,0,0,1]; function mulMat4(a: Mat4, b: Mat4): Mat4 { const out = new Array(16) as Mat4; + return mulMat4Into(out, a, b); +} + +function mulMat4Into(out: Mat4, a: Mat4, b: Mat4): Mat4 { for (let row = 0; row < 4; row++) { for (let col = 0; col < 4; col++) { out[col * 4 + row] = @@ -657,14 +661,6 @@ function nodeLocalMatrix(n: GltfNode): Mat4 { return trsToMat4(n.translation, n.rotation, n.scale); } -function addVec3(a: Vec3, b: Vec3): Vec3 { - return [a[0] + b[0], a[1] + b[1], a[2] + b[2]]; -} - -function scaleVec3(v: Vec3, s: number): Vec3 { - return [v[0] * s, v[1] * s, v[2] * s]; -} - function lerpArray(a: number[], b: number[], t: number): number[] { const out = new Array(Math.min(a.length, b.length)); for (let i = 0; i < out.length; i++) out[i] = a[i] + (b[i] - a[i]) * t; @@ -1041,6 +1037,12 @@ function buildAnimationController( textureFlags: triangleFrameTextureFlags, solidTriangles: true, }; + const sourceWorldPositionCaches = sources.map( + (source) => new Array(source.positions.length), + ); + const skinJointMatrixCaches: Array> = skins.map((skin) => + skin.joints.map(() => new Array(16) as Mat4) + ); const sampleWorldMatrices = (clipRef: number | string, timeSecondsIn: number): Mat4[] | null => { const clip = typeof clipRef === "number" @@ -1076,12 +1078,23 @@ function buildAnimationController( }; const computeSourceWorldPositions = ( + sourceIndex: number, source: AnimatedPrimitiveSource, sourceMask: RuntimeSourceTriangleMask | undefined, worldMatrices: Mat4[], ): Array => { const activeVertices = sourceMask?.activeVertices; - const worldPositions: Array = new Array(source.positions.length); + const worldPositions = sourceWorldPositionCaches[sourceIndex]!; + const writeWorldPosition = (i: number, x: number, y: number, z: number): void => { + const out = worldPositions[i]; + if (out) { + out[0] = x; + out[1] = y; + out[2] = z; + } else { + worldPositions[i] = [x, y, z]; + } + }; if ( source.skinIndex !== undefined && source.joints && @@ -1091,9 +1104,21 @@ function buildAnimationController( const skin = skins[source.skinIndex]; const sourceJoints = source.joints; const sourceWeights = source.weights; + const jointMatrices = skinJointMatrixCaches[source.skinIndex] ?? []; + for (let jointSlot = 0; jointSlot < skin.joints.length; jointSlot++) { + const jointNode = skin.joints[jointSlot]!; + const jointWorld = worldMatrices[jointNode]; + const inverseBind = skin.inverseBindMatrices[jointSlot]; + const out = jointMatrices[jointSlot] ?? (jointMatrices[jointSlot] = new Array(16) as Mat4); + jointMatrices[jointSlot] = jointWorld && inverseBind + ? mulMat4Into(out, jointWorld, inverseBind) + : undefined; + } const skinVertex = (i: number): void => { const bindPosition = source.positions[i]; - let blended: Vec3 = [0, 0, 0]; + let blendedX = 0; + let blendedY = 0; + let blendedZ = 0; let weightSum = 0; const joints = sourceJoints[i] ?? []; const weights = sourceWeights[i] ?? []; @@ -1101,17 +1126,48 @@ function buildAnimationController( const weight = weights[j] ?? 0; if (weight <= 0) continue; const jointSlot = Math.round(joints[j] ?? 0); - const jointNode = skin.joints[jointSlot]; - const jointWorld = worldMatrices[jointNode]; - const inverseBind = skin.inverseBindMatrices[jointSlot]; - if (!jointWorld || !inverseBind) continue; - const jointMatrix = mulMat4(jointWorld, inverseBind); - blended = addVec3(blended, scaleVec3(transformPoint(jointMatrix, bindPosition), weight)); + const jointMatrix = jointMatrices[jointSlot]; + if (!jointMatrix) continue; + const x = + jointMatrix[0] * bindPosition[0] + + jointMatrix[4] * bindPosition[1] + + jointMatrix[8] * bindPosition[2] + + jointMatrix[12]; + const y = + jointMatrix[1] * bindPosition[0] + + jointMatrix[5] * bindPosition[1] + + jointMatrix[9] * bindPosition[2] + + jointMatrix[13]; + const z = + jointMatrix[2] * bindPosition[0] + + jointMatrix[6] * bindPosition[1] + + jointMatrix[10] * bindPosition[2] + + jointMatrix[14]; + blendedX += x * weight; + blendedY += y * weight; + blendedZ += z * weight; weightSum += weight; } - worldPositions[i] = weightSum > 0 - ? scaleVec3(blended, 1 / weightSum) - : transformPoint(source.meshBindWorld, bindPosition); + if (weightSum > 0) { + const invWeight = 1 / weightSum; + writeWorldPosition(i, blendedX * invWeight, blendedY * invWeight, blendedZ * invWeight); + } else { + writeWorldPosition( + i, + source.meshBindWorld[0] * bindPosition[0] + + source.meshBindWorld[4] * bindPosition[1] + + source.meshBindWorld[8] * bindPosition[2] + + source.meshBindWorld[12], + source.meshBindWorld[1] * bindPosition[0] + + source.meshBindWorld[5] * bindPosition[1] + + source.meshBindWorld[9] * bindPosition[2] + + source.meshBindWorld[13], + source.meshBindWorld[2] * bindPosition[0] + + source.meshBindWorld[6] * bindPosition[1] + + source.meshBindWorld[10] * bindPosition[2] + + source.meshBindWorld[14], + ); + } }; if (activeVertices) { for (const vertexIndex of activeVertices) skinVertex(vertexIndex); @@ -1123,7 +1179,22 @@ function buildAnimationController( ? (worldMatrices[source.meshNode] ?? source.meshBindWorld) : source.meshBindWorld; const transformVertex = (i: number): void => { - worldPositions[i] = transformPoint(meshWorld, source.positions[i]); + const sourcePosition = source.positions[i]; + writeWorldPosition( + i, + meshWorld[0] * sourcePosition[0] + + meshWorld[4] * sourcePosition[1] + + meshWorld[8] * sourcePosition[2] + + meshWorld[12], + meshWorld[1] * sourcePosition[0] + + meshWorld[5] * sourcePosition[1] + + meshWorld[9] * sourcePosition[2] + + meshWorld[13], + meshWorld[2] * sourcePosition[0] + + meshWorld[6] * sourcePosition[1] + + meshWorld[10] * sourcePosition[2] + + meshWorld[14], + ); }; if (activeVertices) { for (const vertexIndex of activeVertices) transformVertex(vertexIndex); @@ -1143,7 +1214,7 @@ function buildAnimationController( const source = sources[sourceIndex]!; const sourceMask = sourceMaskOverrides?.[sourceIndex]; const triangleMask = sourceMask?.triangleMask ?? source.triangleMask; - const worldPositions = computeSourceWorldPositions(source, sourceMask, worldMatrices); + const worldPositions = computeSourceWorldPositions(sourceIndex, source, sourceMask, worldMatrices); let triangleOrdinal = 0; for (let i = 0; i + 2 < source.indices.length; i += 3, triangleOrdinal++) { @@ -1203,7 +1274,7 @@ function buildAnimationController( const source = sources[sourceIndex]!; const sourceMask = sourceMaskOverrides?.[sourceIndex]; const triangleMask = sourceMask?.triangleMask ?? source.triangleMask; - const worldPositions = computeSourceWorldPositions(source, sourceMask, worldMatrices); + const worldPositions = computeSourceWorldPositions(sourceIndex, source, sourceMask, worldMatrices); let triangleOrdinal = 0; for (let i = 0; i + 2 < source.indices.length; i += 3, triangleOrdinal++) { From 5b7a5a3c59757de69ec4072c5c8f8ed092ab1fd5 Mon Sep 17 00:00:00 2001 From: agustin-littlehat Date: Mon, 25 May 2026 13:57:31 -0300 Subject: [PATCH 3/5] perf(polycss): optimize polygon leaf updates --- .../polycss/src/api/createPolyScene.test.ts | 87 ++++++++- packages/polycss/src/api/createPolyScene.ts | 183 +++++++++++++++--- packages/polycss/src/render/atlas/emit.ts | 158 ++++++++------- .../polycss/src/render/atlas/paintDefaults.ts | 27 +++ .../src/render/atlas/renderPolygons.ts | 47 +++-- .../src/render/atlas/stableTriangle.ts | 7 +- packages/polycss/src/render/atlas/types.ts | 2 + packages/polycss/src/styles/styles.ts | 13 ++ packages/react/src/styles/styles.ts | 13 ++ packages/vue/src/styles/styles.ts | 13 ++ 10 files changed, 429 insertions(+), 121 deletions(-) diff --git a/packages/polycss/src/api/createPolyScene.test.ts b/packages/polycss/src/api/createPolyScene.test.ts index efefeace..813ae552 100644 --- a/packages/polycss/src/api/createPolyScene.test.ts +++ b/packages/polycss/src/api/createPolyScene.test.ts @@ -1768,16 +1768,99 @@ describe("createPolyScene", () => { expect(handle.polygons[0].vertices).toBe(originalVerts); }); - it("re-renders the mesh DOM (leaf elements are fresh after update)", () => { + it("re-renders the mesh DOM for geometry updates", () => { scene = makeScene(host); const handle = scene.add(makeParseResult([triangle("#ff0000")]), { merge: false }); const before = host.querySelector("u, b, i, s") as HTMLElement; - handle.updatePolygon(0, { color: "#00ff00" }); + handle.updatePolygon(0, { + vertices: [ + [0, 0, 0], + [2, 0, 0], + [0, 1, 0], + ], + }); const after = host.querySelector("u, b, i, s") as HTMLElement; // renderEntry tears down and re-emits; the leaf is a fresh node. expect(after).not.toBe(before); }); + it("updates dynamic color-only changes without replacing the leaf", () => { + scene = makeScene(host, { textureLighting: "dynamic" }); + const handle = scene.add(makeParseResult([triangle("#ff0000")]), { merge: false }); + const before = host.querySelector("u, b, i, s") as HTMLElement; + + handle.updatePolygon(0, { color: "#0000ff" }); + + const after = host.querySelector("u, b, i, s") as HTMLElement; + expect(after).toBe(before); + expect(after.style.getPropertyValue("--psr")).toBe("0.0000"); + expect(after.style.getPropertyValue("--psg")).toBe("0.0000"); + expect(after.style.getPropertyValue("--psb")).toBe("1.0000"); + }); + + it("updates baked solid color-only changes without replacing the leaf", () => { + scene = makeScene(host, { textureLighting: "baked" }); + const handle = scene.add(makeParseResult([triangle("#ff0000")]), { merge: false }); + const before = host.querySelector("u, b, i, s") as HTMLElement; + + handle.updatePolygon(0, { color: "#0000ff" }); + + const after = host.querySelector("u, b, i, s") as HTMLElement; + expect(after).toBe(before); + expect(handle.polygons[0].color).toBe("#0000ff"); + expect(after.style.color).not.toBe(""); + }); + + it("updates data-only changes without replacing the leaf", () => { + scene = makeScene(host); + const poly = triangle("#ff0000"); + poly.data = { old: "1" }; + const handle = scene.add(makeParseResult([poly]), { merge: false }); + const before = host.querySelector("u, b, i, s") as HTMLElement; + + handle.updatePolygon(0, { data: { next: 2 } }); + + const after = host.querySelector("u, b, i, s") as HTMLElement; + expect(after).toBe(before); + expect(after.getAttribute("data-old")).toBeNull(); + expect(after.getAttribute("data-next")).toBe("2"); + }); + + it("does not rewrite unchanged data attributes", () => { + scene = makeScene(host); + const poly = triangle("#ff0000"); + poly.data = { stable: "1", changing: "a" }; + const handle = scene.add(makeParseResult([poly]), { merge: false }); + const before = host.querySelector("u, b, i, s") as HTMLElement; + const setAttribute = vi.spyOn(before, "setAttribute"); + const removeAttribute = vi.spyOn(before, "removeAttribute"); + + handle.updatePolygon(0, { data: { stable: "1", changing: "b" } }); + + expect(host.querySelector("u, b, i, s")).toBe(before); + expect(setAttribute).not.toHaveBeenCalledWith("data-stable", "1"); + expect(setAttribute).toHaveBeenCalledWith("data-changing", "b"); + expect(removeAttribute).not.toHaveBeenCalled(); + }); + + it("updates combined dynamic color and data changes without replacing the leaf", () => { + scene = makeScene(host, { textureLighting: "dynamic" }); + const poly = triangle("#ff0000"); + poly.data = { old: "1" }; + const handle = scene.add(makeParseResult([poly]), { merge: false }); + const before = host.querySelector("u, b, i, s") as HTMLElement; + + handle.updatePolygon(0, { color: "#0000ff", data: { next: 2 } }); + + const after = host.querySelector("u, b, i, s") as HTMLElement; + expect(after).toBe(before); + expect(after.style.getPropertyValue("--psr")).toBe("0.0000"); + expect(after.style.getPropertyValue("--psg")).toBe("0.0000"); + expect(after.style.getPropertyValue("--psb")).toBe("1.0000"); + expect(after.getAttribute("data-old")).toBeNull(); + expect(after.getAttribute("data-next")).toBe("2"); + }); + it("no-ops on a stale polygon reference (not in the current polygons array)", () => { scene = makeScene(host); const handle = scene.add(makeParseResult([triangle("#ff0000")]), { merge: false }); diff --git a/packages/polycss/src/api/createPolyScene.ts b/packages/polycss/src/api/createPolyScene.ts index 6ce7c9b9..4deb79b1 100644 --- a/packages/polycss/src/api/createPolyScene.ts +++ b/packages/polycss/src/api/createPolyScene.ts @@ -51,6 +51,7 @@ import { isVoxelCameraCullableNormalGroups, normalFacesCamera, optimizeMeshPolygons, + parseHex, parseHexColor, polygonCssSurfaceNormal, projectCssVertexToGround, @@ -69,6 +70,11 @@ import { type RenderedPoly, type SolidPaintDefaults, } from "../render/textureAtlas"; +import { applySolidPaint } from "../render/atlas/paintDefaults"; +import { + applyPolygonDataAttrs, + shadedSolidPlanForNormal, +} from "../render/atlas/emit"; import { createPolyVoxelRenderer, type PolyVoxelRenderer, @@ -613,6 +619,7 @@ export function createPolyScene( wrapper: HTMLDivElement; parseResult: ParseResult; rendered: RenderedPoly[]; + renderedByPolygonIndex: Array; /** Dynamic-mode shadow `` leaves, one per non-deduped casting * polygon. Empty in baked mode (which uses `shadowSvg` instead). */ shadowRendered: HTMLElement[]; @@ -623,6 +630,7 @@ export function createPolyScene( disposed: boolean; stableDom: boolean; hasBuckets: boolean; + skipBucketNormalCleanupOnce: boolean; excludeFromAutoCenter: boolean; castShadow: boolean; receiveShadow: boolean; @@ -772,6 +780,10 @@ export function createPolyScene( applyDynamicLightVars(el, opts); } + function applySceneCameraTransform(el: HTMLElement): void { + el.style.transform = buildSceneTransformFromCamera(camera, autoCenterOffset, layoutScale); + } + // Dynamic lighting cascade vars: PolyScene writes the directional + ambient // light setup to these custom properties on the scene root. Each polygon's // bakes its own normal directly into an inline calc() that reads these @@ -839,6 +851,7 @@ export function createPolyScene( disposeRendered(entry.rendered, entry.disposeAtlas); entry.disposeAtlas = undefined; entry.rendered.length = 0; + entry.renderedByPolygonIndex = []; entry.cameraCullGroups = []; entry.cameraCullSignature = ""; clearShadowLeaves(entry); @@ -892,6 +905,20 @@ export function createPolyScene( } } + function setRendered(entry: MeshEntry, rendered: RenderedPoly[], disposeAtlas?: () => void): void { + entry.rendered = rendered; + entry.renderedByPolygonIndex = []; + for (const item of rendered) { + entry.renderedByPolygonIndex[item.polygonIndex] = item; + } + entry.disposeAtlas = disposeAtlas; + } + + function renderedItemForPolygon(entry: MeshEntry, polygonIndex: number): RenderedPoly | undefined { + const item = entry.renderedByPolygonIndex[polygonIndex]; + return item?.polygonIndex === polygonIndex ? item : undefined; + } + function clearMountedRendered(entry: MeshEntry): void { for (const child of Array.from(entry.wrapper.children)) { if (child instanceof HTMLElement && child.classList.contains("polycss-bucket")) { @@ -1110,6 +1137,8 @@ export function createPolyScene( function syncMountedRendered(entry: MeshEntry): void { clearMountedRendered(entry); entry.hasBuckets = false; + const skipBucketNormalCleanup = entry.skipBucketNormalCleanupOnce; + entry.skipBucketNormalCleanupOnce = false; const fragment = doc.createDocumentFragment(); // Lambert-bucketing only pays off in dynamic mode, where the cascade @@ -1169,9 +1198,11 @@ export function createPolyScene( // dynamic-lighting path used by other consumers). Inside a bucket // those inline values are dead weight — the lambert is computed at // the wrapper and inherited. Strip them. - item.element.style.removeProperty("--pnx"); - item.element.style.removeProperty("--pny"); - item.element.style.removeProperty("--pnz"); + if (!skipBucketNormalCleanup || item.kind === "triangle") { + item.element.style.removeProperty("--pnx"); + item.element.style.removeProperty("--pny"); + item.element.style.removeProperty("--pnz"); + } } fragment.appendChild(bucketEl); } @@ -1250,7 +1281,7 @@ export function createPolyScene( function applySolidPaintVars(wrapper: HTMLDivElement, defaults: SolidPaintDefaults): void { if (defaults.paintColor) { wrapper.style.setProperty("--polycss-paint", defaults.paintColor); - } else { + } else if (wrapper.style.getPropertyValue("--polycss-paint")) { wrapper.style.removeProperty("--polycss-paint"); } @@ -1258,13 +1289,87 @@ export function createPolyScene( wrapper.style.setProperty("--psr", (defaults.dynamicColor.r / 255).toFixed(4)); wrapper.style.setProperty("--psg", (defaults.dynamicColor.g / 255).toFixed(4)); wrapper.style.setProperty("--psb", (defaults.dynamicColor.b / 255).toFixed(4)); - } else { + } else if ( + wrapper.style.getPropertyValue("--psr") || + wrapper.style.getPropertyValue("--psg") || + wrapper.style.getPropertyValue("--psb") + ) { wrapper.style.removeProperty("--psr"); wrapper.style.removeProperty("--psg"); wrapper.style.removeProperty("--psb"); } } + function applyDynamicColorVars(el: HTMLElement, color: string | undefined): void { + const rgb = parseHex(color ?? "#cccccc"); + el.style.setProperty("--psr", (rgb.r / 255).toFixed(4)); + el.style.setProperty("--psg", (rgb.g / 255).toFixed(4)); + el.style.setProperty("--psb", (rgb.b / 255).toFixed(4)); + } + + function applyBakedSolidColor(item: RenderedPoly, polygon: Polygon): boolean { + if (!item.plan || item.kind === "atlas" || item.plan.texture) return false; + const textureLighting: PolyTextureLightingMode = currentOptions.textureLighting ?? "baked"; + const renderOptions = { + directionalLight: currentOptions.directionalLight, + ambientLight: currentOptions.ambientLight, + textureLighting, + textureQuality: currentOptions.textureQuality, + seamBleed: currentOptions.seamBleed, + strategies: currentOptions.strategies, + }; + const shaded = shadedSolidPlanForNormal( + item.plan, + polygon, + item.plan.normal, + textureLighting, + renderOptions, + ); + applySolidPaint(item.element, shaded, textureLighting); + return true; + } + + function tryUpdatePolygonColorOnly(entry: MeshEntry, polygonIndex: number, color: string | undefined): boolean { + const polygon = entry.polygons[polygonIndex]; + if (!polygon) return false; + const item = renderedItemForPolygon(entry, polygonIndex); + if (!item) return false; + const textureLighting = currentOptions.textureLighting ?? "baked"; + if (textureLighting === "dynamic") { + applyDynamicColorVars(item.element, color); + return true; + } + if (textureLighting === "baked") { + return applyBakedSolidColor(item, polygon); + } + return false; + } + + function tryUpdatePolygonDataOnly(entry: MeshEntry, polygonIndex: number): boolean { + const polygon = entry.polygons[polygonIndex]; + if (!polygon) return false; + const item = renderedItemForPolygon(entry, polygonIndex); + if (!item) return false; + applyPolygonDataAttrs(item.element, polygon); + return true; + } + + function tryUpdatePolygonLeafOnly(entry: MeshEntry, polygonIndex: number, partialKeys: string[]): boolean { + if (partialKeys.length === 0 || !partialKeys.every((key) => key === "color" || key === "data")) { + return false; + } + if ( + partialKeys.includes("color") && + !tryUpdatePolygonColorOnly(entry, polygonIndex, entry.polygons[polygonIndex]?.color) + ) { + return false; + } + if (partialKeys.includes("data") && !tryUpdatePolygonDataOnly(entry, polygonIndex)) { + return false; + } + return true; + } + // Emits the per-mesh shadow ``. Same path for both lighting modes: // every casting polygon is projected to the ground on the CPU and // concatenated into a single compound `` (M…L…Z subpaths) under @@ -1913,19 +2018,32 @@ export function createPolyScene( seamBleed: currentOptions.seamBleed, strategies: currentOptions.strategies, }; - const solidPaintDefaults = getSolidPaintDefaults(entry.polygons, renderOptions); - applySolidPaintVars(entry.wrapper, solidPaintDefaults); - const renderOptionsWithDefaults = { - ...renderOptions, - solidPaintDefaults, - }; - const atlas = ( - entry.stableDom - ? renderPolygonsWithStableTriangles(entry.polygons, renderOptionsWithDefaults) - : null - ) ?? renderPolygonsWithTextureAtlas(entry.polygons, renderOptionsWithDefaults); - entry.rendered = atlas.rendered; - entry.disposeAtlas = atlas.dispose; + const atlas = entry.stableDom + ? (() => { + const solidPaintDefaults = getSolidPaintDefaults(entry.polygons, renderOptions); + applySolidPaintVars(entry.wrapper, solidPaintDefaults); + return renderPolygonsWithStableTriangles(entry.polygons, { + ...renderOptions, + solidPaintDefaults, + }) ?? renderPolygonsWithTextureAtlas(entry.polygons, { + ...renderOptions, + solidPaintDefaults, + }); + })() + : renderPolygonsWithTextureAtlas(entry.polygons, { + ...renderOptions, + computeSolidPaintDefaults: true, + skipDynamicNormalVars: currentOptions.textureLighting === "dynamic", + } as typeof renderOptions & { computeSolidPaintDefaults: true }); + if (!entry.stableDom) { + applySolidPaintVars( + entry.wrapper, + (atlas as { solidPaintDefaults?: SolidPaintDefaults }).solidPaintDefaults ?? {}, + ); + } + setRendered(entry, atlas.rendered, atlas.dispose); + entry.skipBucketNormalCleanupOnce = + currentOptions.textureLighting === "dynamic" && !entry.stableDom; recomputeCameraCullGroups(entry); syncMountedRendered(entry); emitShadowLeaves(entry); @@ -1999,8 +2117,7 @@ export function createPolyScene( if (atlas) { const solidPaintDefaults = getSolidPaintDefaults(entry.polygons, renderOptions); applySolidPaintVars(entry.wrapper, solidPaintDefaults); - entry.rendered = atlas.rendered; - entry.disposeAtlas = atlas.dispose; + setRendered(entry, atlas.rendered, atlas.dispose); recomputeCameraCullGroups(entry); syncMountedRendered(entry); emitShadowLeaves(entry); @@ -2009,7 +2126,10 @@ export function createPolyScene( const asyncAtlas = await renderPolygonsWithTextureAtlasAsync( entry.polygons, - renderOptions, + { + ...renderOptions, + skipDynamicNormalVars: currentOptions.textureLighting === "dynamic", + } as typeof renderOptions & { skipDynamicNormalVars: boolean }, shouldCancel, ); if (shouldCancel()) { @@ -2017,8 +2137,9 @@ export function createPolyScene( return false; } applySolidPaintVars(entry.wrapper, asyncAtlas.solidPaintDefaults); - entry.rendered = asyncAtlas.rendered; - entry.disposeAtlas = asyncAtlas.dispose; + setRendered(entry, asyncAtlas.rendered, asyncAtlas.dispose); + entry.skipBucketNormalCleanupOnce = + currentOptions.textureLighting === "dynamic" && !entry.stableDom; recomputeCameraCullGroups(entry); const mounted = await syncMountedRenderedChunked(entry, shouldCancel); if (mounted) emitShadowLeaves(entry); @@ -2118,12 +2239,14 @@ export function createPolyScene( wrapper, parseResult, rendered: [], + renderedByPolygonIndex: [], shadowRendered: [], polygons: sourcePolygons, voxelSource: parseResult.voxelSource, disposed: false, stableDom: stableDomOnUpdate, hasBuckets: false, + skipBucketNormalCleanupOnce: false, excludeFromAutoCenter: !!transformIn.excludeFromAutoCenter, castShadow: !!transformIn.castShadow, receiveShadow: !!transformIn.receiveShadow, @@ -2372,8 +2495,12 @@ export function createPolyScene( currentTriangleFrameVersion++; materializedTriangleFramePolygons = null; materializedTriangleFrameVersion = -1; - recomputeCameraCullGroups(entry); - syncMountedRenderedForCameraChange(entry, true); + if (canDomCullCamera(entry)) { + recomputeCameraCullGroups(entry); + syncMountedRenderedForCameraChange(entry, true); + } else { + syncCameraCullSignature(entry); + } return true; } } @@ -2419,6 +2546,10 @@ export function createPolyScene( clearCurrentTriangleFrame(); entry.voxelSource = undefined; Object.assign(entry.polygons[idx], partial); + const partialKeys = Object.keys(partial); + if (tryUpdatePolygonLeafOnly(entry, idx, partialKeys)) { + return; + } renderEntry(entry); }, async setPolygonsChunked(polygons: Polygon[], options?: { @@ -2582,7 +2713,7 @@ export function createPolyScene( } function applyCamera(): void { - applySceneStyle(sceneEl, currentOptions); + applySceneCameraTransform(sceneEl); for (const entry of meshes) syncMountedRenderedForCameraChange(entry); } diff --git a/packages/polycss/src/render/atlas/emit.ts b/packages/polycss/src/render/atlas/emit.ts index cb1ebc41..e21190e2 100644 --- a/packages/polycss/src/render/atlas/emit.ts +++ b/packages/polycss/src/render/atlas/emit.ts @@ -16,9 +16,8 @@ import { formatCssLength, formatMatrix3dValues, formatSolidQuadMatrix } from "@l import { shadePolygon } from "@layoutit/polycss-core"; import { setInlineStyleProperty, - removeInlineStyleProperty, applySolidPaint, - applyDynamicNormalVars, + formatInitialSolidPaintStyle, } from "./paintDefaults"; import { formatBorderShapeElementStyle, @@ -35,20 +34,36 @@ import { } from "@layoutit/polycss-core"; export const ELEMENT_DATA_KEYS = new WeakMap(); +const ELEMENT_DATA_VALUES = new WeakMap>(); export function applyPolygonDataAttrs(el: HTMLElement, polygon: Polygon): void { const previousDataKeys = ELEMENT_DATA_KEYS.get(el); - if (previousDataKeys) { - for (const key of previousDataKeys) el.removeAttribute(`data-${key}`); + const previousDataValues = ELEMENT_DATA_VALUES.get(el); + if (!polygon.data && (!previousDataKeys || previousDataKeys.length === 0)) { + (el as SolidTriangleElement).__polycssHasDataAttrs = false; + return; } - const nextDataKeys: string[] = []; + const nextDataValues = new Map(); if (polygon.data) { for (const [k, v] of Object.entries(polygon.data)) { - el.setAttribute(`data-${k}`, String(v)); - nextDataKeys.push(k); + nextDataValues.set(k, String(v)); + } + } + if (previousDataKeys) { + for (const key of previousDataKeys) { + if (!nextDataValues.has(key)) el.removeAttribute(`data-${key}`); } } - ELEMENT_DATA_KEYS.set(el, nextDataKeys); + for (const [key, value] of nextDataValues) { + if (previousDataValues?.get(key) !== value) { + el.setAttribute(`data-${key}`, value); + } + } + const nextDataKeys = Array.from(nextDataValues.keys()); + if (nextDataKeys.length > 0) ELEMENT_DATA_KEYS.set(el, nextDataKeys); + else ELEMENT_DATA_KEYS.delete(el); + if (nextDataValues.size > 0) ELEMENT_DATA_VALUES.set(el, nextDataValues); + else ELEMENT_DATA_VALUES.delete(el); (el as SolidTriangleElement).__polycssHasDataAttrs = nextDataKeys.length > 0; } @@ -61,6 +76,7 @@ export function applyAtlasBackground( page: TextureAtlasPage, textureLighting: PolyTextureLightingMode, entry: PackedTextureAtlasEntry, + preserveDynamicNormalVars = textureLighting === "dynamic", ): void { if (!page.url) return; const url = `url(${page.url})`; @@ -69,38 +85,33 @@ export function applyAtlasBackground( const atlasCanonicalSize = atlasCanonicalSizeForEntry(entry); const pos = `${formatCssLength((-entry.x / width) * atlasCanonicalSize)} ${formatCssLength((-entry.y / height) * atlasCanonicalSize)}`; const size = `${formatCssLength((page.width / width) * atlasCanonicalSize)} ${formatCssLength((page.height / height) * atlasCanonicalSize)}`; + const atlasBaseStyle = + `transform:matrix3d(${entry.atlasMatrix})` + + `;--polycss-atlas-size:${atlasCanonicalSize}px`; + const dynamicBaseStyle = + `${atlasBaseStyle}` + + `;--polycss-atlas-position:${pos}` + + `;--polycss-atlas-image-size:${size}`; if (textureLighting === "dynamic") { - setInlineStyleProperty(el, "background-image", url); - setInlineStyleProperty(el, "background-position", pos); - setInlineStyleProperty(el, "background-size", size); - } else { - setInlineStyleProperty(el, "background", `${url} ${pos} / ${size} no-repeat`); - } - // Dynamic mode also masks the entire by the atlas image so the - // background-color tint only paints inside the polygon shape (W3C - // multiply with transparent backdrop reduces to source). - if (textureLighting === "dynamic") { - setInlineStyleProperty(el, "mask-image", url); - setInlineStyleProperty(el, "mask-mode", "alpha"); - setInlineStyleProperty(el, "mask-position", pos); - setInlineStyleProperty(el, "mask-size", size); - setInlineStyleProperty(el, "mask-repeat", "no-repeat"); - // Vendor-prefixed twins for older Safari. setProperty avoids the - // deprecation warnings on the camelCase properties in lib.dom. - setInlineStyleProperty(el, "-webkit-mask-image", url); - setInlineStyleProperty(el, "-webkit-mask-position", pos); - setInlineStyleProperty(el, "-webkit-mask-size", size); - setInlineStyleProperty(el, "-webkit-mask-repeat", "no-repeat"); + const normalStyle = preserveDynamicNormalVars + ? `;--pnx:${entry.normal[0].toFixed(4)}` + + `;--pny:${entry.normal[1].toFixed(4)}` + + `;--pnz:${entry.normal[2].toFixed(4)}` + : ""; + // Dynamic mode masks the atlas image so the background-color tint only + // paints inside the polygon shape. + el.setAttribute( + "style", + dynamicBaseStyle + + `;--polycss-atlas-url:${url}` + + normalStyle, + ); } else { - removeInlineStyleProperty(el, "mask-image"); - removeInlineStyleProperty(el, "mask-mode"); - removeInlineStyleProperty(el, "mask-position"); - removeInlineStyleProperty(el, "mask-size"); - removeInlineStyleProperty(el, "mask-repeat"); - removeInlineStyleProperty(el, "-webkit-mask-image"); - removeInlineStyleProperty(el, "-webkit-mask-position"); - removeInlineStyleProperty(el, "-webkit-mask-size"); - removeInlineStyleProperty(el, "-webkit-mask-repeat"); + el.setAttribute( + "style", + atlasBaseStyle + + `;background:${url} ${pos} / ${size} no-repeat`, + ); } } @@ -132,21 +143,6 @@ export function updateAtlasElementWithStablePlan( return true; } -export function clearAtlasImageStyles(el: HTMLElement): void { - el.style.backgroundImage = ""; - el.style.backgroundPosition = ""; - el.style.backgroundSize = ""; - el.style.maskImage = ""; - el.style.maskMode = ""; - el.style.maskPosition = ""; - el.style.maskSize = ""; - el.style.maskRepeat = ""; - el.style.removeProperty("-webkit-mask-image"); - el.style.removeProperty("-webkit-mask-position"); - el.style.removeProperty("-webkit-mask-size"); - el.style.removeProperty("-webkit-mask-repeat"); -} - export function shadedSolidPlanForNormal( source: TextureAtlasPlan, polygon: Polygon, @@ -230,11 +226,15 @@ export function createSolidElement( textureLighting: PolyTextureLightingMode, doc: Document, solidPaintDefaults?: SolidPaintDefaults, + skipDynamicNormalVars = false, ): HTMLElement { const el = doc.createElement("b"); - el.setAttribute("style", `transform:matrix3d(${formatSolidQuadMatrix(entry)})`); + el.setAttribute( + "style", + `transform:matrix3d(${formatSolidQuadMatrix(entry)})` + + formatInitialSolidPaintStyle(entry, textureLighting, solidPaintDefaults, skipDynamicNormalVars), + ); applyPolygonDataAttrs(el, entry.polygon); - applySolidPaint(el, entry, textureLighting, solidPaintDefaults); return el; } @@ -244,11 +244,15 @@ export function createBorderShapeSolidElement( textureLighting: PolyTextureLightingMode, doc: Document, solidPaintDefaults?: SolidPaintDefaults, + skipDynamicNormalVars = false, ): HTMLElement { const el = doc.createElement("i"); - el.setAttribute("style", formatBorderShapeElementStyle(entry)); + el.setAttribute( + "style", + formatBorderShapeElementStyle(entry) + + formatInitialSolidPaintStyle(entry, textureLighting, solidPaintDefaults, skipDynamicNormalVars), + ); applyPolygonDataAttrs(el, entry.polygon); - applySolidPaint(el, entry, textureLighting, solidPaintDefaults); return el; } @@ -259,12 +263,15 @@ export function createCornerShapeSolidElement( textureLighting: PolyTextureLightingMode, doc: Document, solidPaintDefaults?: SolidPaintDefaults, + skipDynamicNormalVars = false, ): HTMLElement { const el = doc.createElement("u"); - el.setAttribute("style", formatCornerShapeElementStyle(entry, geometry)); + el.setAttribute( + "style", + formatCornerShapeElementStyle(entry, geometry) + + formatInitialSolidPaintStyle(entry, textureLighting, solidPaintDefaults, skipDynamicNormalVars), + ); applyPolygonDataAttrs(el, entry.polygon); - applySolidPaint(el, entry, textureLighting, solidPaintDefaults); - setInlineStyleProperty(el, "background", "currentColor"); return el; } @@ -274,11 +281,15 @@ export function createProjectiveSolidElement( textureLighting: PolyTextureLightingMode, doc: Document, solidPaintDefaults?: SolidPaintDefaults, + skipDynamicNormalVars = false, ): HTMLElement { const el = doc.createElement("b"); - el.setAttribute("style", `transform:matrix3d(${entry.projectiveMatrix})`); + el.setAttribute( + "style", + `transform:matrix3d(${entry.projectiveMatrix})` + + formatInitialSolidPaintStyle(entry, textureLighting, solidPaintDefaults, skipDynamicNormalVars), + ); applyPolygonDataAttrs(el, entry.polygon); - applySolidPaint(el, entry, textureLighting, solidPaintDefaults); return el; } @@ -334,17 +345,22 @@ export function createAtlasElement( entry: PackedTextureAtlasEntry, textureLighting: PolyTextureLightingMode, doc: Document, + skipDynamicNormalVars = false, ): HTMLElement { const el = doc.createElement("s"); - el.setAttribute("style", `transform:matrix3d(${entry.atlasMatrix})`); - applyPolygonDataAttrs(el, entry.polygon); - const width = entry.canvasW || 1; - const height = entry.canvasH || 1; const atlasCanonicalSize = atlasCanonicalSizeForEntry(entry); - setInlineStyleProperty(el, "--polycss-atlas-size", `${atlasCanonicalSize}px`); - setInlineStyleProperty(el, "background-position", `${formatCssLength((-entry.x / width) * atlasCanonicalSize)} ${formatCssLength((-entry.y / height) * atlasCanonicalSize)}`); - setInlineStyleProperty(el, "opacity", "0"); - - if (textureLighting === "dynamic") applyDynamicNormalVars(el, entry); + const dynamicNormalStyle = textureLighting === "dynamic" && !skipDynamicNormalVars + ? `;--pnx:${entry.normal[0].toFixed(4)}` + + `;--pny:${entry.normal[1].toFixed(4)}` + + `;--pnz:${entry.normal[2].toFixed(4)}` + : ""; + el.setAttribute( + "style", + `transform:matrix3d(${entry.atlasMatrix})` + + `;--polycss-atlas-size:${atlasCanonicalSize}px` + + `;opacity:0` + + dynamicNormalStyle, + ); + applyPolygonDataAttrs(el, entry.polygon); return el; } diff --git a/packages/polycss/src/render/atlas/paintDefaults.ts b/packages/polycss/src/render/atlas/paintDefaults.ts index be1800d9..7e3dfba2 100644 --- a/packages/polycss/src/render/atlas/paintDefaults.ts +++ b/packages/polycss/src/render/atlas/paintDefaults.ts @@ -22,6 +22,7 @@ export function setInlineStyleProperty(el: HTMLElement, property: string, value: export function removeInlineStyleProperty(el: HTMLElement, property: string): void { const current = el.getAttribute("style") ?? ""; if (!current) return; + if (!current.toLowerCase().includes(property.toLowerCase())) return; const escaped = property.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); const matcher = new RegExp(`^\\s*${escaped}\\s*:`, "i"); const next = current @@ -33,6 +34,32 @@ export function removeInlineStyleProperty(el: HTMLElement, property: string): vo else el.removeAttribute("style"); } +export function formatInitialSolidPaintStyle( + entry: TextureAtlasPlan, + textureLighting: PolyTextureLightingMode, + solidPaintDefaults?: SolidPaintDefaults, + skipDynamicNormalVars = false, +): string { + if (textureLighting === "dynamic") { + const base = parseHex(entry.polygon.color ?? "#cccccc"); + let style = skipDynamicNormalVars + ? "" + : `;--pnx:${entry.normal[0].toFixed(4)}` + + `;--pny:${entry.normal[1].toFixed(4)}` + + `;--pnz:${entry.normal[2].toFixed(4)}`; + if (rgbKey(base) !== solidPaintDefaults?.dynamicColorKey) { + style += + `;--psr:${(base.r / 255).toFixed(4)}` + + `;--psg:${(base.g / 255).toFixed(4)}` + + `;--psb:${(base.b / 255).toFixed(4)}`; + } + return style; + } + return entry.shadedColor && entry.shadedColor !== solidPaintDefaults?.paintColor + ? `;color:${entry.shadedColor}` + : ""; +} + export function applyDynamicNormalVars(el: HTMLElement, entry: TextureAtlasPlan): void { // Dynamic mode: emit ONLY the per-polygon normal vars inline. The // calc-driven background-color + background-blend-mode multiply live diff --git a/packages/polycss/src/render/atlas/renderPolygons.ts b/packages/polycss/src/render/atlas/renderPolygons.ts index 8ee9a311..a1aa3dcd 100644 --- a/packages/polycss/src/render/atlas/renderPolygons.ts +++ b/packages/polycss/src/render/atlas/renderPolygons.ts @@ -53,7 +53,7 @@ import { updateBorderShapeElementWithStablePlan, updateCornerShapeElementWithStablePlan, } from "./emit"; -import { removeInlineStyleProperty, setInlineStyleProperty } from "./paintDefaults"; +import { setInlineStyleProperty } from "./paintDefaults"; import { computeSolidTriangleColorPlan } from "@layoutit/polycss-core"; import { computeSolidTrianglePlan, @@ -153,6 +153,9 @@ export function renderPolygonsWithTextureAtlas( if (!doc) return { rendered: [], dispose: () => {} }; const textureLighting = options.textureLighting ?? "baked"; + const internalOptions = options as InternalRenderTextureAtlasOptions; + const skipDynamicNormalVars = + textureLighting === "dynamic" && internalOptions.skipDynamicNormalVars === true; const disabled = new Set(options.strategies?.disable ?? []); const useFullRectSolid = !disabled.has("b"); const useProjectiveQuad = useFullRectSolid && projectiveQuadSupported(doc); @@ -172,9 +175,16 @@ export function renderPolygonsWithTextureAtlas( basisHints[index], ) ); + const solidPaintDefaults = options.solidPaintDefaults ?? + (internalOptions.computeSolidPaintDefaults + ? getSolidPaintDefaultsForPlans(plans, textureLighting, doc, options.strategies) + : undefined); + const triangleOptions = solidPaintDefaults + ? { ...options, solidPaintDefaults } + : options; const trianglePlans = plans.map((plan) => plan && useStableTriangle && isSolidTrianglePlan(plan) - ? computeSolidTrianglePlan(plan.polygon, plan.index, seamTriangleOptions(plan, options), { + ? computeSolidTrianglePlan(plan.polygon, plan.index, seamTriangleOptions(plan, triangleOptions), { primitive: solidTrianglePrimitive ?? undefined, }) : null @@ -208,23 +218,23 @@ export function renderPolygonsWithTextureAtlas( const entry = packed.entries[i]; if (entry) { - const element = createAtlasElement(entry, textureLighting, doc); + const element = createAtlasElement(entry, textureLighting, doc, skipDynamicNormalVars); atlasElements.set(i, element); rendered.push({ polygonIndex: i, element, kind: "atlas", plan: entry, dispose: () => {} }); } else if (!plan.texture && useFullRectSolid && isFullRectSolid(plan)) { - const element = createSolidElement(plan, textureLighting, doc, options.solidPaintDefaults); + const element = createSolidElement(plan, textureLighting, doc, solidPaintDefaults, skipDynamicNormalVars); rendered.push({ polygonIndex: i, element, kind: "solid", plan, dispose: () => {} }); } else if (!plan.texture && trianglePlan) { const element = createSolidTriangleElement(trianglePlan, doc); rendered.push({ polygonIndex: i, element, kind: "triangle", plan, dispose: () => {} }); } else if (!plan.texture && useProjectiveQuad && isProjectiveQuadPlan(plan)) { - const element = createProjectiveSolidElement(plan, textureLighting, doc, options.solidPaintDefaults); + const element = createProjectiveSolidElement(plan, textureLighting, doc, solidPaintDefaults, skipDynamicNormalVars); rendered.push({ polygonIndex: i, element, kind: "solid", plan, dispose: () => {} }); } else if (!plan.texture && cornerShapePlan) { - const element = createCornerShapeSolidElement(plan, cornerShapePlan, textureLighting, doc, options.solidPaintDefaults); + const element = createCornerShapeSolidElement(plan, cornerShapePlan, textureLighting, doc, solidPaintDefaults, skipDynamicNormalVars); rendered.push({ polygonIndex: i, element, kind: "corner", plan, dispose: () => {} }); } else if (!plan.texture && useBorderShape) { - const element = createBorderShapeSolidElement(plan, textureLighting, doc, options.solidPaintDefaults); + const element = createBorderShapeSolidElement(plan, textureLighting, doc, solidPaintDefaults, skipDynamicNormalVars); rendered.push({ polygonIndex: i, element, kind: "border", plan, dispose: () => {} }); } } @@ -247,8 +257,7 @@ export function renderPolygonsWithTextureAtlas( for (const entry of page.entries) { const el = atlasElements.get(entry.index); if (!el || !built.url) continue; - applyAtlasBackground(el, built, textureLighting, entry); - removeInlineStyleProperty(el, "opacity"); + applyAtlasBackground(el, built, textureLighting, entry, !skipDynamicNormalVars); } } }) @@ -260,14 +269,16 @@ export function renderPolygonsWithTextureAtlas( } }); - return { + const result = { rendered, + solidPaintDefaults: solidPaintDefaults ?? {}, dispose() { cancelled = true; for (const url of urls) URL.revokeObjectURL(url); urls = []; }, }; + return result; } export async function renderPolygonsWithTextureAtlasAsync( @@ -281,6 +292,9 @@ export async function renderPolygonsWithTextureAtlasAsync( } const textureLighting = options.textureLighting ?? "baked"; + const internalOptions = options as InternalRenderTextureAtlasOptions; + const skipDynamicNormalVars = + textureLighting === "dynamic" && internalOptions.skipDynamicNormalVars === true; const disabled = new Set(options.strategies?.disable ?? []); const useFullRectSolid = !disabled.has("b"); const useProjectiveQuad = useFullRectSolid && projectiveQuadSupported(doc); @@ -351,23 +365,23 @@ export async function renderPolygonsWithTextureAtlasAsync( const entry = packed.entries[i]; if (entry) { - const element = createAtlasElement(entry, textureLighting, doc); + const element = createAtlasElement(entry, textureLighting, doc, skipDynamicNormalVars); atlasElements.set(i, element); rendered.push({ polygonIndex: i, element, kind: "atlas", plan: entry, dispose: () => {} }); } else if (!plan.texture && useFullRectSolid && isFullRectSolid(plan)) { - const element = createSolidElement(plan, textureLighting, doc, solidPaintDefaults); + const element = createSolidElement(plan, textureLighting, doc, solidPaintDefaults, skipDynamicNormalVars); rendered.push({ polygonIndex: i, element, kind: "solid", plan, dispose: () => {} }); } else if (!plan.texture && trianglePlan) { const element = createSolidTriangleElement(trianglePlan, doc); rendered.push({ polygonIndex: i, element, kind: "triangle", plan, dispose: () => {} }); } else if (!plan.texture && useProjectiveQuad && isProjectiveQuadPlan(plan)) { - const element = createProjectiveSolidElement(plan, textureLighting, doc, solidPaintDefaults); + const element = createProjectiveSolidElement(plan, textureLighting, doc, solidPaintDefaults, skipDynamicNormalVars); rendered.push({ polygonIndex: i, element, kind: "solid", plan, dispose: () => {} }); } else if (!plan.texture && cornerShapePlan) { - const element = createCornerShapeSolidElement(plan, cornerShapePlan, textureLighting, doc, solidPaintDefaults); + const element = createCornerShapeSolidElement(plan, cornerShapePlan, textureLighting, doc, solidPaintDefaults, skipDynamicNormalVars); rendered.push({ polygonIndex: i, element, kind: "corner", plan, dispose: () => {} }); } else if (!plan.texture && useBorderShape) { - const element = createBorderShapeSolidElement(plan, textureLighting, doc, solidPaintDefaults); + const element = createBorderShapeSolidElement(plan, textureLighting, doc, solidPaintDefaults, skipDynamicNormalVars); rendered.push({ polygonIndex: i, element, kind: "border", plan, dispose: () => {} }); } batchStarted = await yieldIfOverBudget(batchStarted); @@ -395,8 +409,7 @@ export async function renderPolygonsWithTextureAtlasAsync( for (const entry of page.entries) { const el = atlasElements.get(entry.index); if (!el || !built.url) continue; - applyAtlasBackground(el, built, textureLighting, entry); - removeInlineStyleProperty(el, "opacity"); + applyAtlasBackground(el, built, textureLighting, entry, !skipDynamicNormalVars); } } }) diff --git a/packages/polycss/src/render/atlas/stableTriangle.ts b/packages/polycss/src/render/atlas/stableTriangle.ts index 81e76592..69442522 100644 --- a/packages/polycss/src/render/atlas/stableTriangle.ts +++ b/packages/polycss/src/render/atlas/stableTriangle.ts @@ -32,7 +32,7 @@ import { computeSolidTrianglePlanFromCssPoints, } from "./solidTrianglePlan"; import { stableTriangleMatrixDecimals } from "@layoutit/polycss-core"; -import { applyPolygonDataAttrs, hasPolygonDataAttrs, clearAtlasImageStyles } from "./emit"; +import { applyPolygonDataAttrs, hasPolygonDataAttrs } from "./emit"; import { resolveSolidTrianglePrimitive } from "./strategy"; const DEFAULT_SOLID_SEAM_BLEED = 1.5; @@ -290,9 +290,7 @@ export function createSolidTriangleElement( doc: Document, ): HTMLElement { const el = doc.createElement("u"); - clearAtlasImageStyles(el); applySolidTriangleElement(el, entry); - applyPolygonDataAttrs(el, entry.polygon); return el; } @@ -301,9 +299,8 @@ export function createHiddenSolidTriangleElement( doc: Document, ): HTMLElement { const el = doc.createElement("u"); - clearAtlasImageStyles(el); hideSolidTriangleElement(el); - applyPolygonDataAttrs(el, polygon); + if (polygon.data) applyPolygonDataAttrs(el, polygon); return el; } diff --git a/packages/polycss/src/render/atlas/types.ts b/packages/polycss/src/render/atlas/types.ts index 44da5c4c..8c2cbcbf 100644 --- a/packages/polycss/src/render/atlas/types.ts +++ b/packages/polycss/src/render/atlas/types.ts @@ -27,6 +27,8 @@ export interface RenderTextureAtlasOptions { export interface InternalRenderTextureAtlasOptions extends RenderTextureAtlasOptions { seamBleed?: number; seamEdges?: Set; + computeSolidPaintDefaults?: boolean; + skipDynamicNormalVars?: boolean; optimizeStableTriangleStyle?: boolean; stableTriangleDebug?: "transform-only" | "plan-only"; stableTriangleUpdateMode?: "full" | "transform-only" | "color-only"; diff --git a/packages/polycss/src/styles/styles.ts b/packages/polycss/src/styles/styles.ts index 499b7711..431e216f 100644 --- a/packages/polycss/src/styles/styles.ts +++ b/packages/polycss/src/styles/styles.ts @@ -366,6 +366,19 @@ const CORE_BASE_STYLES = ` + var(--plb) * var(--pli) * var(--plam))) ); background-blend-mode: multiply; + background-image: var(--polycss-atlas-url); + background-position: var(--polycss-atlas-position); + background-repeat: no-repeat; + background-size: var(--polycss-atlas-image-size); + mask-image: var(--polycss-atlas-url); + mask-mode: alpha; + mask-position: var(--polycss-atlas-position); + mask-repeat: no-repeat; + mask-size: var(--polycss-atlas-image-size); + -webkit-mask-image: var(--polycss-atlas-url); + -webkit-mask-position: var(--polycss-atlas-position); + -webkit-mask-repeat: no-repeat; + -webkit-mask-size: var(--polycss-atlas-image-size); } .polycss-scene[data-polycss-lighting="dynamic"] b, diff --git a/packages/react/src/styles/styles.ts b/packages/react/src/styles/styles.ts index 4bd8f363..479915c4 100644 --- a/packages/react/src/styles/styles.ts +++ b/packages/react/src/styles/styles.ts @@ -287,6 +287,19 @@ const CORE_BASE_STYLES = ` var(--pnz) * var(--plz)))) ); background-blend-mode: multiply; + background-image: var(--polycss-atlas-url); + background-position: var(--polycss-atlas-position); + background-repeat: no-repeat; + background-size: var(--polycss-atlas-image-size); + mask-image: var(--polycss-atlas-url); + mask-mode: alpha; + mask-position: var(--polycss-atlas-position); + mask-repeat: no-repeat; + mask-size: var(--polycss-atlas-image-size); + -webkit-mask-image: var(--polycss-atlas-url); + -webkit-mask-position: var(--polycss-atlas-position); + -webkit-mask-repeat: no-repeat; + -webkit-mask-size: var(--polycss-atlas-image-size); } .polycss-scene[data-polycss-lighting="dynamic"] b, diff --git a/packages/vue/src/styles/styles.ts b/packages/vue/src/styles/styles.ts index 46d93d6b..278adb84 100644 --- a/packages/vue/src/styles/styles.ts +++ b/packages/vue/src/styles/styles.ts @@ -239,6 +239,19 @@ const CORE_BASE_STYLES = ` var(--pnz) * var(--plz)))) ); background-blend-mode: multiply; + background-image: var(--polycss-atlas-url); + background-position: var(--polycss-atlas-position); + background-repeat: no-repeat; + background-size: var(--polycss-atlas-image-size); + mask-image: var(--polycss-atlas-url); + mask-mode: alpha; + mask-position: var(--polycss-atlas-position); + mask-repeat: no-repeat; + mask-size: var(--polycss-atlas-image-size); + -webkit-mask-image: var(--polycss-atlas-url); + -webkit-mask-position: var(--polycss-atlas-position); + -webkit-mask-repeat: no-repeat; + -webkit-mask-size: var(--polycss-atlas-image-size); } .polycss-scene[data-polycss-lighting="dynamic"] b, From 0198e888547c9111c37094bbdb4e1ce2e73b95f2 Mon Sep 17 00:00:00 2001 From: agustin-littlehat Date: Mon, 25 May 2026 13:57:44 -0300 Subject: [PATCH 4/5] chore(bench): add renderer update benchmarks --- bench/async-scene-mount-bench.mjs | 160 +++++++++++++ bench/async-scene-mount.html | 56 +++++ bench/atlas-background-bench.mjs | 145 ++++++++++++ bench/atlas-background.html | 39 ++++ bench/build.mjs | 28 +++ bench/entries/asyncSceneMount.ts | 333 ++++++++++++++++++++++++++ bench/entries/atlasBackground.ts | 177 ++++++++++++++ bench/entries/htmlMount.ts | 375 ++++++++++++++++++++++++++++++ bench/entries/react.tsx | 6 +- bench/entries/syncSceneAdd.ts | 162 +++++++++++++ bench/entries/vue.ts | 4 + bench/html-mount-bench.mjs | 152 ++++++++++++ bench/html-mount.html | 53 +++++ bench/nonvoxel-vanilla.html | 89 ++++++- bench/notes/BENCH.md | 14 +- bench/notes/PERF_INVESTIGATION.md | 4 +- bench/perf-serve.mjs | 4 + bench/perf-shared.mjs | 5 + bench/perf-vanilla.html | 3 +- bench/sync-scene-add-bench.mjs | 152 ++++++++++++ bench/sync-scene-add.html | 52 +++++ package.json | 6 +- 22 files changed, 2006 insertions(+), 13 deletions(-) create mode 100644 bench/async-scene-mount-bench.mjs create mode 100644 bench/async-scene-mount.html create mode 100644 bench/atlas-background-bench.mjs create mode 100644 bench/atlas-background.html create mode 100644 bench/entries/asyncSceneMount.ts create mode 100644 bench/entries/atlasBackground.ts create mode 100644 bench/entries/htmlMount.ts create mode 100644 bench/entries/syncSceneAdd.ts create mode 100644 bench/html-mount-bench.mjs create mode 100644 bench/html-mount.html create mode 100644 bench/sync-scene-add-bench.mjs create mode 100644 bench/sync-scene-add.html diff --git a/bench/async-scene-mount-bench.mjs b/bench/async-scene-mount-bench.mjs new file mode 100644 index 00000000..fc4d9cad --- /dev/null +++ b/bench/async-scene-mount-bench.mjs @@ -0,0 +1,160 @@ +/** + * Benchmark createPolyScene's internal async setPolygonsChunked path. + * + * Usage: + * node bench/async-scene-mount-bench.mjs + * node bench/async-scene-mount-bench.mjs --count 50000 --repeats 5 --label async-scene-mount + */ +import { createServer } from "node:http"; +import { readFile, mkdir, writeFile } 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 benchDir = resolve(repoRoot, "bench"); + +const argv = process.argv.slice(2); +const flag = (name) => argv.indexOf(`--${name}`); +const optStr = (name, dflt = "") => { + const i = flag(name); + return i >= 0 ? argv[i + 1] : dflt; +}; +const optNum = (name, dflt) => { + const raw = optStr(name); + return raw ? Number(raw) : dflt; +}; +const hasFlag = (name) => flag(name) >= 0; +const 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 COUNT = optNum("count", 10000); +const REPEATS = optNum("repeats", 5); +const CHUNK_SIZE = optNum("chunk-size", optNum("chunkSize", 750)); +const MODE = optStr("mode", "baked"); +const SHAPE = optStr("shape", "quad"); +const REPLACE_EXISTING = hasFlag("replace-existing") || hasFlag("replaceExisting"); +const DISABLE_STRATEGIES = [ + ...optAll("disable-strategy"), + ...optAll("disable-strategies").flatMap((value) => value.split(",")), +].map((value) => value.trim()).filter(Boolean); +const LABEL = optStr("label"); +const HEADED = hasFlag("headed"); +const BROWSER_EXECUTABLE = optStr("browser-executable"); +const STRATEGIES = [ + ...optAll("strategy"), + ...optAll("strategies").flatMap((value) => value.split(",").filter(Boolean)), +]; +const CHROMIUM_ARGS = chromiumArgsWithGpuDefault([ + ...optAll("chromium-arg"), + ...optAll("chromium-args").flatMap((value) => value.split(/\s+/).filter(Boolean)), +]); + +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", +}; + +function startServer() { + return new Promise((resolveStart, rejectStart) => { + const server = createServer(async (req, res) => { + try { + const url = new URL(req.url, "http://localhost"); + const safe = url.pathname.replace(/\/+/g, "/"); + if (safe.includes("..")) { + res.writeHead(403); + res.end("forbidden"); + return; + } + const abs = resolve(benchDir, safe === "/" ? "async-scene-mount.html" : safe.slice(1)); + const data = await readFile(abs); + res.writeHead(200, { + "Content-Type": MIME[extname(abs).toLowerCase()] || "application/octet-stream", + "Cache-Control": "no-store", + }); + res.end(data); + } catch (err) { + res.writeHead(404); + res.end(String(err?.message ?? err)); + } + }); + server.on("error", rejectStart); + server.listen(0, "127.0.0.1", () => { + const addr = server.address(); + resolveStart({ server, port: typeof addr === "object" ? addr.port : 0 }); + }); + }); +} + +function stopServer(server) { + return new Promise((resolveStop) => server.close(resolveStop)); +} + +function printResult(result) { + console.log(`[async-scene-mount] count=${result.options.count} repeats=${result.options.repeats} chunk=${result.options.chunkSize} mode=${result.options.mode} shape=${result.options.shape} replaceExisting=${result.options.replaceExisting} disable=${result.options.disableStrategies.join(",")}`); + console.log(JSON.stringify(result.summary, null, 2)); + for (const row of result.rows) { + console.log( + `${row.strategy.padEnd(16)} #${row.repeat} ` + + `render=${row.renderMs.toFixed(3)}ms mount=${row.mountMs.toFixed(3)}ms update=${row.updateMs.toFixed(3)}ms ` + + `mounted=${row.mounted} leaves=${row.leafCount}`, + ); + } +} + +let server; +let browser; +try { + const started = await startServer(); + server = started.server; + const url = `http://127.0.0.1:${started.port}/async-scene-mount.html`; + console.log(`[async-scene-mount] ${url}`); + if (CHROMIUM_ARGS.length > 0) console.log(`[async-scene-mount] chromium args=${CHROMIUM_ARGS.join(" ")}`); + browser = await chromium.launch({ + headless: !HEADED, + executablePath: BROWSER_EXECUTABLE || undefined, + args: CHROMIUM_ARGS, + }); + const page = await browser.newPage({ viewport: { width: 1200, height: 900 } }); + await page.goto(url, { waitUntil: "load" }); + const result = await page.evaluate( + async ({ count, repeats, chunkSize, mode, shape, replaceExisting, strategies, disableStrategies }) => + window.runPolycssAsyncSceneMountBench({ count, repeats, chunkSize, mode, shape, replaceExisting, strategies, disableStrategies }), + { + count: COUNT, + repeats: REPEATS, + chunkSize: CHUNK_SIZE, + mode: MODE === "dynamic" ? "dynamic" : "baked", + shape: SHAPE === "triangle" ? "triangle" : "quad", + replaceExisting: REPLACE_EXISTING, + strategies: STRATEGIES, + disableStrategies: DISABLE_STRATEGIES.length > 0 ? DISABLE_STRATEGIES : undefined, + }, + ); + printResult(result); + if (LABEL) { + const dir = resolve(repoRoot, "bench/results"); + await mkdir(dir, { recursive: true }); + const file = resolve(dir, `${LABEL}.json`); + await writeFile(file, JSON.stringify(result, null, 2)); + console.log(`[async-scene-mount] wrote ${file}`); + } +} finally { + if (browser) await browser.close(); + if (server) await stopServer(server); +} diff --git a/bench/async-scene-mount.html b/bench/async-scene-mount.html new file mode 100644 index 00000000..429cb44e --- /dev/null +++ b/bench/async-scene-mount.html @@ -0,0 +1,56 @@ + + + + + polycss async scene mount bench + + + +
ready
+
+ + + diff --git a/bench/atlas-background-bench.mjs b/bench/atlas-background-bench.mjs new file mode 100644 index 00000000..9364c0e4 --- /dev/null +++ b/bench/atlas-background-bench.mjs @@ -0,0 +1,145 @@ +/** + * Benchmark atlas page reveal style application on existing leaves. + * + * Usage: + * node bench/atlas-background-bench.mjs + * node bench/atlas-background-bench.mjs --count 10000 --repeats 7 --mode baked --label atlas-bg + */ +import { createServer } from "node:http"; +import { readFile, mkdir, writeFile } 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 benchDir = resolve(repoRoot, "bench"); + +const argv = process.argv.slice(2); +const flag = (name) => argv.indexOf(`--${name}`); +const optStr = (name, dflt = "") => { + const i = flag(name); + return i >= 0 ? argv[i + 1] : dflt; +}; +const optNum = (name, dflt) => { + const raw = optStr(name); + return raw ? Number(raw) : dflt; +}; +const hasFlag = (name) => flag(name) >= 0; +const 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 COUNT = optNum("count", 10000); +const REPEATS = optNum("repeats", 5); +const MODE = optStr("mode", "baked"); +const SKIP_DYNAMIC_NORMALS = hasFlag("skip-dynamic-normals") || hasFlag("skipDynamicNormals"); +const LABEL = optStr("label"); +const HEADED = hasFlag("headed"); +const BROWSER_EXECUTABLE = optStr("browser-executable"); +const CHROMIUM_ARGS = chromiumArgsWithGpuDefault([ + ...optAll("chromium-arg"), + ...optAll("chromium-args").flatMap((value) => value.split(/\s+/).filter(Boolean)), +]); + +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", +}; + +function startServer() { + return new Promise((resolveStart, rejectStart) => { + const server = createServer(async (req, res) => { + try { + const url = new URL(req.url, "http://localhost"); + const safe = url.pathname.replace(/\/+/g, "/"); + if (safe.includes("..")) { + res.writeHead(403); + res.end("forbidden"); + return; + } + const abs = resolve(benchDir, safe === "/" ? "atlas-background.html" : safe.slice(1)); + const data = await readFile(abs); + res.writeHead(200, { + "Content-Type": MIME[extname(abs).toLowerCase()] || "application/octet-stream", + "Cache-Control": "no-store", + }); + res.end(data); + } catch (err) { + res.writeHead(404); + res.end(String(err?.message ?? err)); + } + }); + server.on("error", rejectStart); + server.listen(0, "127.0.0.1", () => { + const addr = server.address(); + resolveStart({ server, port: typeof addr === "object" ? addr.port : 0 }); + }); + }); +} + +function stopServer(server) { + return new Promise((resolveStop) => server.close(resolveStop)); +} + +function printResult(result) { + console.log(`[atlas-background] count=${result.options.count} repeats=${result.options.repeats} mode=${result.options.mode} skipDynamicNormals=${result.options.skipDynamicNormals}`); + console.log(JSON.stringify(result.summary, null, 2)); + for (const row of result.rows) { + console.log( + `#${row.repeat} apply=${row.applyMs.toFixed(3)}ms ` + + `leaves=${row.leafCount} styleChars=${row.inlineStyleChars}`, + ); + } +} + +let server; +let browser; +try { + const started = await startServer(); + server = started.server; + const url = `http://127.0.0.1:${started.port}/atlas-background.html`; + console.log(`[atlas-background] ${url}`); + if (CHROMIUM_ARGS.length > 0) console.log(`[atlas-background] chromium args=${CHROMIUM_ARGS.join(" ")}`); + browser = await chromium.launch({ + headless: !HEADED, + executablePath: BROWSER_EXECUTABLE || undefined, + args: CHROMIUM_ARGS, + }); + const page = await browser.newPage({ viewport: { width: 1200, height: 900 } }); + await page.goto(url, { waitUntil: "load" }); + const result = await page.evaluate( + async ({ count, repeats, mode, skipDynamicNormals }) => + window.runPolycssAtlasBackgroundBench({ count, repeats, mode, skipDynamicNormals }), + { + count: COUNT, + repeats: REPEATS, + mode: MODE === "dynamic" ? "dynamic" : "baked", + skipDynamicNormals: SKIP_DYNAMIC_NORMALS, + }, + ); + printResult(result); + if (LABEL) { + const dir = resolve(repoRoot, "bench/results"); + await mkdir(dir, { recursive: true }); + const file = resolve(dir, `${LABEL}.json`); + await writeFile(file, JSON.stringify(result, null, 2)); + console.log(`[atlas-background] wrote ${file}`); + } +} finally { + if (browser) await browser.close(); + if (server) await stopServer(server); +} diff --git a/bench/atlas-background.html b/bench/atlas-background.html new file mode 100644 index 00000000..590e3ca9 --- /dev/null +++ b/bench/atlas-background.html @@ -0,0 +1,39 @@ + + + + + polycss atlas background bench + + + +
+ + + diff --git a/bench/build.mjs b/bench/build.mjs index 587df57a..29a8efca 100644 --- a/bench/build.mjs +++ b/bench/build.mjs @@ -16,6 +16,14 @@ * bench/.generated/polycss-vue.js * ← Vue entry (bench/entries/vue.ts) bundled * with Vue 3 + @layoutit/polycss-vue + * bench/.generated/polycss-html-mount.js + * ← leaf HTML chunk mount benchmark entry + * bench/.generated/polycss-async-scene-mount.js + * ← internal async scene chunk mount benchmark entry + * bench/.generated/polycss-sync-scene-add.js + * ← synchronous scene.add renderer benchmark entry + * bench/.generated/polycss-atlas-background.js + * ← atlas page background reveal benchmark entry * * Why not reuse the published dists? The packages keep workspace-peer * imports as bare specifiers (e.g. `@layoutit/polycss-core`), which the browser @@ -92,6 +100,26 @@ const targets = [ entry: resolve(__dirname, "entries/vue.ts"), out: resolve(bundleDir, "polycss-vue.js"), }, + { + label: "HTML chunk mount bench entry", + entry: resolve(__dirname, "entries/htmlMount.ts"), + out: resolve(bundleDir, "polycss-html-mount.js"), + }, + { + label: "async scene mount bench entry", + entry: resolve(__dirname, "entries/asyncSceneMount.ts"), + out: resolve(bundleDir, "polycss-async-scene-mount.js"), + }, + { + label: "sync scene.add bench entry", + entry: resolve(__dirname, "entries/syncSceneAdd.ts"), + out: resolve(bundleDir, "polycss-sync-scene-add.js"), + }, + { + label: "atlas background bench entry", + entry: resolve(__dirname, "entries/atlasBackground.ts"), + out: resolve(bundleDir, "polycss-atlas-background.js"), + }, ]; const t0 = performance.now(); diff --git a/bench/entries/asyncSceneMount.ts b/bench/entries/asyncSceneMount.ts new file mode 100644 index 00000000..e3fde485 --- /dev/null +++ b/bench/entries/asyncSceneMount.ts @@ -0,0 +1,333 @@ +import { + createPolyOrthographicCamera, + createPolyScene, + injectPolyBaseStyles, + renderPolygonsWithTextureAtlasAsync, + type ParseResult, + type Polygon, + type PolyMeshHandle, + type RenderedPoly, + type PolyTextureLightingMode, +} from "@layoutit/polycss"; + +type AsyncSceneMountStrategy = + | "scene-production" + | "manual-fragment" + | "manual-append"; + +const DEFAULT_STRATEGIES: AsyncSceneMountStrategy[] = [ + "scene-production", + "manual-fragment", + "manual-append", +]; + +interface ChunkedMeshHandle extends PolyMeshHandle { + setPolygonsChunked(polygons: Polygon[], options?: { + merge?: boolean; + stableDom?: boolean; + recomputeAutoCenter?: boolean; + }): Promise; +} + +interface AsyncSceneMountBenchOptions { + count?: number; + repeats?: number; + chunkSize?: number; + mode?: PolyTextureLightingMode; + shape?: "quad" | "triangle"; + replaceExisting?: boolean; + strategies?: AsyncSceneMountStrategy[]; + disableStrategies?: Array<"b" | "i" | "u">; +} + +interface AsyncSceneMountBenchRow { + strategy: AsyncSceneMountStrategy; + repeat: number; + count: number; + renderMs: number; + mountMs: number; + updateMs: number; + leafCount: number; + mounted: boolean; +} + +interface AsyncSceneMountBenchSummary { + renderMedianMs: number; + mountMedianMs: number; + updateMedianMs: number; + updateP90Ms: number; + leafCount: number; +} + +function solidGrid(count: number, shape: "quad" | "triangle"): Polygon[] { + const side = Math.ceil(Math.sqrt(count)); + const polygons: Polygon[] = []; + for (let i = 0; i < count; i += 1) { + const x = i % side; + const y = Math.floor(i / side); + const color = `#${((i * 2654435761) & 0xffffff).toString(16).padStart(6, "0")}`; + const vertices: Polygon["vertices"] = shape === "triangle" + ? [ + [x, y, 0], + [x + 0.88, y, 0], + [x, y + 0.88, 0], + ] + : [ + [x, y, 0], + [x + 0.88, y, 0], + [x + 0.88, y + 0.88, 0], + [x, y + 0.88, 0], + ]; + polygons.push({ vertices, color }); + } + return polygons; +} + +function makeParseResult(polygons: Polygon[]): ParseResult { + return { + polygons, + objectUrls: [], + warnings: [], + dispose: () => {}, + }; +} + +function nextFrame(): Promise { + return new Promise((resolve) => requestAnimationFrame(() => resolve())); +} + +function yieldToBrowser(): Promise { + return new Promise((resolve) => setTimeout(resolve, 0)); +} + +function quantile(sorted: readonly number[], q: number): number { + if (sorted.length === 0) return 0; + const pos = (sorted.length - 1) * q; + const lo = Math.floor(pos); + const hi = Math.ceil(pos); + if (lo === hi) return sorted[lo] ?? 0; + return (sorted[lo] ?? 0) + ((sorted[hi] ?? 0) - (sorted[lo] ?? 0)) * (pos - lo); +} + +function summarizeRows(rows: readonly AsyncSceneMountBenchRow[]): AsyncSceneMountBenchSummary { + const renders = rows.map((row) => row.renderMs).sort((a, b) => a - b); + const mounts = rows.map((row) => row.mountMs).sort((a, b) => a - b); + const updates = rows.map((row) => row.updateMs).sort((a, b) => a - b); + return { + renderMedianMs: Number(quantile(renders, 0.5).toFixed(3)), + mountMedianMs: Number(quantile(mounts, 0.5).toFixed(3)), + updateMedianMs: Number(quantile(updates, 0.5).toFixed(3)), + updateP90Ms: Number(quantile(updates, 0.9).toFixed(3)), + leafCount: rows[rows.length - 1]?.leafCount ?? 0, + }; +} + +function summarize(rows: readonly AsyncSceneMountBenchRow[]): Record { + const byStrategy = new Map(); + for (const row of rows) { + const group = byStrategy.get(row.strategy); + if (group) group.push(row); + else byStrategy.set(row.strategy, [row]); + } + const out = {} as Record; + for (const [strategy, group] of byStrategy) out[strategy] = summarizeRows(group); + return out; +} + +function mountedLeafCount(target: HTMLElement): number { + return target.querySelectorAll(".polycss-mesh > b, .polycss-mesh > i, .polycss-mesh > s, .polycss-mesh > u").length; +} + +function createWrapper(target: HTMLElement, mode: PolyTextureLightingMode): HTMLElement { + injectPolyBaseStyles(document); + target.replaceChildren(); + const cameraEl = document.createElement("div"); + cameraEl.className = "polycss-camera"; + cameraEl.style.perspective = "1000000px"; + const sceneEl = document.createElement("div"); + sceneEl.className = "polycss-scene"; + sceneEl.dataset.polycssLighting = mode; + const wrapper = document.createElement("div"); + wrapper.className = "polycss-mesh"; + sceneEl.appendChild(wrapper); + cameraEl.appendChild(sceneEl); + target.appendChild(cameraEl); + return wrapper; +} + +async function mountFragmentChunks( + wrapper: HTMLElement, + rendered: readonly RenderedPoly[], + chunkSize: number, +): Promise { + let fragment = document.createDocumentFragment(); + for (let i = 0; i < rendered.length; i += 1) { + fragment.appendChild(rendered[i]!.element); + if ((i + 1) % chunkSize === 0) { + wrapper.appendChild(fragment); + fragment = document.createDocumentFragment(); + await yieldToBrowser(); + } + } + if (fragment.childNodes.length > 0) wrapper.appendChild(fragment); +} + +async function mountAppendBatches( + wrapper: HTMLElement, + rendered: readonly RenderedPoly[], + chunkSize: number, +): Promise { + let batch: HTMLElement[] = []; + for (let i = 0; i < rendered.length; i += 1) { + batch.push(rendered[i]!.element); + if ((i + 1) % chunkSize === 0) { + wrapper.append(...batch); + batch = []; + await yieldToBrowser(); + } + } + if (batch.length > 0) wrapper.append(...batch); +} + +async function runSceneProduction( + target: HTMLElement, + polygons: Polygon[], + repeat: number, + mode: PolyTextureLightingMode, + replaceExisting: boolean, + disableStrategies: Array<"b" | "i" | "u">, +): Promise { + target.replaceChildren(); + const scene = createPolyScene(target, { + camera: createPolyOrthographicCamera({ rotX: 65, rotY: 45, zoom: 1 }), + textureLighting: mode, + strategies: { disable: disableStrategies }, + }); + const handle = scene.add(makeParseResult(replaceExisting ? polygons : []), { + merge: false, + excludeFromAutoCenter: true, + }) as ChunkedMeshHandle; + await nextFrame(); + + performance.mark("polycss-async-scene-update-start"); + console.timeStamp("polycss-async-scene-update-start"); + const t0 = performance.now(); + await handle.setPolygonsChunked(polygons, { + merge: false, + stableDom: false, + recomputeAutoCenter: false, + }); + const t1 = performance.now(); + console.timeStamp("polycss-async-scene-update-end"); + performance.mark("polycss-async-scene-update-end"); + + const leafCount = mountedLeafCount(target); + const row: AsyncSceneMountBenchRow = { + strategy: "scene-production", + repeat, + count: polygons.length, + renderMs: 0, + mountMs: 0, + updateMs: Number((t1 - t0).toFixed(3)), + leafCount, + mounted: leafCount === polygons.length, + }; + + scene.destroy(); + target.replaceChildren(); + await nextFrame(); + return row; +} + +async function runManualStrategy( + target: HTMLElement, + polygons: Polygon[], + repeat: number, + mode: PolyTextureLightingMode, + chunkSize: number, + strategy: "manual-fragment" | "manual-append", + disableStrategies: Array<"b" | "i" | "u">, +): Promise { + const wrapper = createWrapper(target, mode); + let cancelled = false; + + performance.mark(`polycss-${strategy}-render-start`); + console.timeStamp(`polycss-${strategy}-render-start`); + const t0 = performance.now(); + const result = await renderPolygonsWithTextureAtlasAsync(polygons, { + doc: document, + textureLighting: mode, + strategies: { disable: disableStrategies }, + }, () => cancelled); + const t1 = performance.now(); + console.timeStamp(`polycss-${strategy}-render-end`); + performance.mark(`polycss-${strategy}-render-end`); + + performance.mark(`polycss-${strategy}-mount-start`); + console.timeStamp(`polycss-${strategy}-mount-start`); + if (strategy === "manual-fragment") await mountFragmentChunks(wrapper, result.rendered, chunkSize); + else await mountAppendBatches(wrapper, result.rendered, chunkSize); + const t2 = performance.now(); + console.timeStamp(`polycss-${strategy}-mount-end`); + performance.mark(`polycss-${strategy}-mount-end`); + + const leafCount = mountedLeafCount(target); + const row: AsyncSceneMountBenchRow = { + strategy, + repeat, + count: polygons.length, + renderMs: Number((t1 - t0).toFixed(3)), + mountMs: Number((t2 - t1).toFixed(3)), + updateMs: Number((t2 - t0).toFixed(3)), + leafCount, + mounted: leafCount === polygons.length, + }; + + cancelled = true; + result.dispose(); + target.replaceChildren(); + await nextFrame(); + return row; +} + +export async function runPolycssAsyncSceneMountBench(input: AsyncSceneMountBenchOptions = {}): Promise<{ + options: Required> & { strategies: AsyncSceneMountStrategy[] }; + rows: AsyncSceneMountBenchRow[]; + summary: Record; +}> { + const options = { + count: Math.max(1, Math.floor(input.count ?? 10000)), + repeats: Math.max(1, Math.floor(input.repeats ?? 5)), + chunkSize: Math.max(1, Math.floor(input.chunkSize ?? 750)), + mode: input.mode ?? "baked", + shape: input.shape ?? "quad", + replaceExisting: input.replaceExisting ?? false, + disableStrategies: input.disableStrategies ?? ["i", "u"], + strategies: input.strategies?.length ? input.strategies : DEFAULT_STRATEGIES, + }; + const target = document.getElementById("bench-target") ?? document.body.appendChild(document.createElement("div")); + target.id = "bench-target"; + const polygons = solidGrid(options.count, options.shape); + const rows: AsyncSceneMountBenchRow[] = []; + + for (let repeat = 0; repeat < options.repeats; repeat += 1) { + const strategies = repeat % 2 === 0 ? options.strategies : [...options.strategies].reverse(); + for (const strategy of strategies) { + if (strategy === "scene-production") { + rows.push(await runSceneProduction(target, polygons, repeat, options.mode, options.replaceExisting, options.disableStrategies)); + } else { + rows.push(await runManualStrategy(target, polygons, repeat, options.mode, options.chunkSize, strategy, options.disableStrategies)); + } + } + } + + return { + options, + rows, + summary: summarize(rows), + }; +} + +(window as unknown as { + runPolycssAsyncSceneMountBench: typeof runPolycssAsyncSceneMountBench; +}).runPolycssAsyncSceneMountBench = runPolycssAsyncSceneMountBench; diff --git a/bench/entries/atlasBackground.ts b/bench/entries/atlasBackground.ts new file mode 100644 index 00000000..46be90c9 --- /dev/null +++ b/bench/entries/atlasBackground.ts @@ -0,0 +1,177 @@ +import type { + PackedTextureAtlasEntry, + Polygon, + PolyTextureLightingMode, + TextureAtlasPage, +} from "@layoutit/polycss-core"; +import { + applyAtlasBackground, + createAtlasElement, +} from "../../packages/polycss/src/render/atlas/emit"; + +interface AtlasBackgroundBenchOptions { + count?: number; + repeats?: number; + mode?: PolyTextureLightingMode; + skipDynamicNormals?: boolean; +} + +interface AtlasBackgroundBenchRow { + repeat: number; + count: number; + applyMs: number; + leafCount: number; + inlineStyleChars: number; +} + +interface AtlasBackgroundBenchSummary { + applyMedianMs: number; + applyP90Ms: number; + leafCount: number; + inlineStyleChars: number; +} + +const DATA_URL = + "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADElEQVR42mP8z8BQDwAFgwJ/l3S1WQAAAABJRU5ErkJggg=="; + +function nextFrame(): Promise { + return new Promise((resolve) => requestAnimationFrame(() => resolve())); +} + +function quantile(sorted: readonly number[], q: number): number { + if (sorted.length === 0) return 0; + const pos = (sorted.length - 1) * q; + const lo = Math.floor(pos); + const hi = Math.ceil(pos); + if (lo === hi) return sorted[lo] ?? 0; + return (sorted[lo] ?? 0) + ((sorted[hi] ?? 0) - (sorted[lo] ?? 0)) * (pos - lo); +} + +function summarize(rows: readonly AtlasBackgroundBenchRow[]): AtlasBackgroundBenchSummary { + const applies = rows.map((row) => row.applyMs).sort((a, b) => a - b); + const last = rows[rows.length - 1]; + return { + applyMedianMs: Number(quantile(applies, 0.5).toFixed(3)), + applyP90Ms: Number(quantile(applies, 0.9).toFixed(3)), + leafCount: last?.leafCount ?? 0, + inlineStyleChars: last?.inlineStyleChars ?? 0, + }; +} + +function makeEntry(index: number): PackedTextureAtlasEntry { + const side = Math.ceil(Math.sqrt(index + 1)); + const x = (index % side) * 16; + const y = Math.floor(index / side) * 16; + const polygon: Polygon = { + vertices: [ + [x, y, 0], + [x + 1, y, 0], + [x + 1, y + 1, 0], + [x, y + 1, 0], + ], + color: "#66cc88", + }; + return { + index, + polygon, + texture: null, + x: (index % 64) * 16, + y: Math.floor(index / 64) * 16, + w: 16, + h: 16, + canvasW: 16, + canvasH: 16, + atlasMatrix: `1,0,0,0,0,1,0,0,0,0,1,0,${x},${y},0,1`, + normal: [0, 0, 1], + } as unknown as PackedTextureAtlasEntry; +} + +function makePage(entries: readonly PackedTextureAtlasEntry[]): TextureAtlasPage { + return { + width: 1024, + height: 1024, + entries, + url: DATA_URL, + } as unknown as TextureAtlasPage; +} + +function inlineStyleChars(root: ParentNode): number { + let total = 0; + for (const el of root.querySelectorAll("s")) { + total += el.getAttribute("style")?.length ?? 0; + } + return total; +} + +async function runOne( + target: HTMLElement, + entries: readonly PackedTextureAtlasEntry[], + repeat: number, + mode: PolyTextureLightingMode, + skipDynamicNormals: boolean, +): Promise { + target.replaceChildren(); + const wrapper = document.createElement("div"); + wrapper.className = "polycss-mesh"; + const fragment = document.createDocumentFragment(); + const elements: HTMLElement[] = []; + for (const entry of entries) { + const el = createAtlasElement(entry, mode, document, skipDynamicNormals); + elements.push(el); + fragment.appendChild(el); + } + wrapper.appendChild(fragment); + target.appendChild(wrapper); + await nextFrame(); + + const page = makePage(entries); + performance.mark("polycss-atlas-background-start"); + console.timeStamp("polycss-atlas-background-start"); + const t0 = performance.now(); + for (let i = 0; i < entries.length; i += 1) { + applyAtlasBackground(elements[i]!, page, mode, entries[i]!, !skipDynamicNormals); + } + const t1 = performance.now(); + console.timeStamp("polycss-atlas-background-end"); + performance.mark("polycss-atlas-background-end"); + + const row: AtlasBackgroundBenchRow = { + repeat, + count: entries.length, + applyMs: Number((t1 - t0).toFixed(3)), + leafCount: target.querySelectorAll("s").length, + inlineStyleChars: inlineStyleChars(target), + }; + target.replaceChildren(); + await nextFrame(); + return row; +} + +export async function runPolycssAtlasBackgroundBench(input: AtlasBackgroundBenchOptions = {}): Promise<{ + options: Required; + rows: AtlasBackgroundBenchRow[]; + summary: AtlasBackgroundBenchSummary; +}> { + const options = { + count: Math.max(1, Math.floor(input.count ?? 10000)), + repeats: Math.max(1, Math.floor(input.repeats ?? 5)), + mode: input.mode ?? "baked", + skipDynamicNormals: input.skipDynamicNormals ?? false, + }; + const target = document.getElementById("bench-target") ?? document.body.appendChild(document.createElement("div")); + target.id = "bench-target"; + const entries = Array.from({ length: options.count }, (_unused, index) => makeEntry(index)); + const rows: AtlasBackgroundBenchRow[] = []; + for (let repeat = 0; repeat < options.repeats; repeat += 1) { + rows.push(await runOne(target, entries, repeat, options.mode, options.skipDynamicNormals)); + } + return { + options, + rows, + summary: summarize(rows), + }; +} + +(window as unknown as { + runPolycssAtlasBackgroundBench: typeof runPolycssAtlasBackgroundBench; +}).runPolycssAtlasBackgroundBench = runPolycssAtlasBackgroundBench; diff --git a/bench/entries/htmlMount.ts b/bench/entries/htmlMount.ts new file mode 100644 index 00000000..eae725b9 --- /dev/null +++ b/bench/entries/htmlMount.ts @@ -0,0 +1,375 @@ +import type { Polygon, PolyTextureLightingMode } from "@layoutit/polycss-core"; +import type { RenderedPoly } from "@layoutit/polycss"; +import { renderPolygonsWithTextureAtlas } from "@layoutit/polycss"; + +type MountStrategy = + | "fragment-append" + | "fragment-replace" + | "detached-wrapper" + | "append-batches" + | "fragment-chunks-yield" + | "contextual-fragment" + | "insert-adjacent-html" + | "append-html-unsafe" + | "stream-append-html-unsafe" + | "stream-html-unsafe" + | "direct-inner-html" + | "reuse-style-update"; + +const STRATEGIES: MountStrategy[] = [ + "fragment-append", + "fragment-replace", + "detached-wrapper", + "append-batches", + "fragment-chunks-yield", + "contextual-fragment", + "insert-adjacent-html", + "append-html-unsafe", + "stream-append-html-unsafe", + "stream-html-unsafe", + "direct-inner-html", + "reuse-style-update", +]; + +interface HtmlMountBenchOptions { + count?: number; + repeats?: number; + chunkSize?: number; + mode?: PolyTextureLightingMode; + strategies?: MountStrategy[]; +} + +interface HtmlMountBenchRow { + strategy: MountStrategy; + repeat: number; + count: number; + rendered: number; + supported: boolean; + mounted: boolean; + renderMs: number; + prepareMs: number; + mountMs: number; + totalMs: number; + leafCount: number; +} + +interface StrategySummary { + supported: boolean; + mounted: boolean; + prepareMedianMs: number; + mountMedianMs: number; + totalMedianMs: number; + leafCount: number; +} + +type HtmlInsertionTarget = HTMLElement & { + appendHTMLUnsafe?: (html: string, options?: unknown) => void; + streamAppendHTMLUnsafe?: (options?: unknown) => WritableStream; + streamHTMLUnsafe?: (options?: unknown) => WritableStream; +}; + +function solidGrid(count: number): Polygon[] { + const side = Math.ceil(Math.sqrt(count)); + const polygons: Polygon[] = []; + for (let i = 0; i < count; i += 1) { + const x = i % side; + const y = Math.floor(i / side); + const color = `#${((i * 2654435761) & 0xffffff).toString(16).padStart(6, "0")}`; + polygons.push({ + vertices: [ + [x, y, 0], + [x + 0.88, y, 0], + [x + 0.88, y + 0.88, 0], + [x, y + 0.88, 0], + ], + color, + }); + } + return polygons; +} + +function resetTarget(target: HTMLElement): void { + target.replaceChildren(); +} + +function yieldToBrowser(): Promise { + const scheduler = (globalThis as unknown as { + scheduler?: { yield?: () => Promise }; + }).scheduler; + return scheduler?.yield + ? scheduler.yield() + : new Promise((resolve) => setTimeout(resolve, 0)); +} + +function mountFragment(target: HTMLElement, rendered: readonly RenderedPoly[]): void { + const fragment = document.createDocumentFragment(); + for (const item of rendered) fragment.appendChild(item.element); + target.appendChild(fragment); +} + +function replaceWithFragment(target: HTMLElement, rendered: readonly RenderedPoly[]): void { + const fragment = document.createDocumentFragment(); + for (const item of rendered) fragment.appendChild(item.element); + target.replaceChildren(fragment); +} + +function mountDetachedWrapper(target: HTMLElement, rendered: readonly RenderedPoly[]): void { + const wrapper = document.createElement("div"); + wrapper.className = "polycss-mesh"; + const fragment = document.createDocumentFragment(); + for (const item of rendered) fragment.appendChild(item.element); + wrapper.appendChild(fragment); + target.replaceChildren(wrapper); +} + +function mountAppendBatches(target: HTMLElement, rendered: readonly RenderedPoly[], chunkSize: number): void { + for (let start = 0; start < rendered.length; start += chunkSize) { + const nodes = rendered.slice(start, start + chunkSize).map((item) => item.element); + target.append(...nodes); + } +} + +async function mountFragmentChunksYield( + target: HTMLElement, + rendered: readonly RenderedPoly[], + chunkSize: number, +): Promise { + for (let start = 0; start < rendered.length; start += chunkSize) { + const fragment = document.createDocumentFragment(); + const end = Math.min(rendered.length, start + chunkSize); + for (let i = start; i < end; i += 1) fragment.appendChild(rendered[i]!.element); + target.appendChild(fragment); + await yieldToBrowser(); + } +} + +function htmlFromRendered(rendered: readonly RenderedPoly[], chunkSize: number): string[] { + const chunks: string[] = []; + for (let start = 0; start < rendered.length; start += chunkSize) { + let html = ""; + const end = Math.min(rendered.length, start + chunkSize); + for (let i = start; i < end; i += 1) html += rendered[i]!.element.outerHTML; + chunks.push(html); + } + return chunks; +} + +function directHtmlForGrid(count: number, chunkSize: number): string[] { + const side = Math.ceil(Math.sqrt(count)); + const chunks: string[] = []; + let html = ""; + for (let i = 0; i < count; i += 1) { + const x = i % side; + const y = Math.floor(i / side); + const color = `#${((i * 2654435761) & 0xffffff).toString(16).padStart(6, "0")}`; + const tx = Number((y * 50).toFixed(3)); + const ty = Number((x * 50).toFixed(3)); + html += ``; + if ((i + 1) % chunkSize === 0) { + chunks.push(html); + html = ""; + } + } + if (html) chunks.push(html); + return chunks; +} + +function mountContextualFragment(target: HTMLElement, htmlChunks: readonly string[]): void { + const range = document.createRange(); + range.selectNode(target); + for (const html of htmlChunks) { + target.appendChild(range.createContextualFragment(html)); + } + range.detach(); +} + +function mountInsertAdjacentHtml(target: HTMLElement, htmlChunks: readonly string[]): void { + for (const html of htmlChunks) target.insertAdjacentHTML("beforeend", html); +} + +function mountAppendHtmlUnsafe(target: HtmlInsertionTarget, htmlChunks: readonly string[]): boolean { + if (typeof target.appendHTMLUnsafe !== "function") return false; + for (const html of htmlChunks) target.appendHTMLUnsafe(html); + return true; +} + +async function mountHtmlStream( + target: HtmlInsertionTarget, + htmlChunks: readonly string[], + method: "streamAppendHTMLUnsafe" | "streamHTMLUnsafe", +): Promise { + const stream = target[method]?.(); + if (!stream) return false; + const writer = stream.getWriter(); + try { + for (const html of htmlChunks) await writer.write(html); + await writer.close(); + return true; + } catch { + try { await writer.abort(); } catch { /* ignore */ } + return false; + } +} + +function mutateMountedTransforms(rendered: readonly RenderedPoly[]): void { + for (let i = 0; i < rendered.length; i += 1) { + const element = rendered[i]!.element; + element.style.transform = `${element.style.transform} translateZ(0px)`; + } +} + +function quantile(sorted: readonly number[], q: number): number { + if (sorted.length === 0) return 0; + const pos = (sorted.length - 1) * q; + const lo = Math.floor(pos); + const hi = Math.ceil(pos); + if (lo === hi) return sorted[lo] ?? 0; + return (sorted[lo] ?? 0) + ((sorted[hi] ?? 0) - (sorted[lo] ?? 0)) * (pos - lo); +} + +function summarize(rows: HtmlMountBenchRow[]): Record { + const byStrategy = new Map(); + for (const row of rows) { + const group = byStrategy.get(row.strategy); + if (group) group.push(row); + else byStrategy.set(row.strategy, [row]); + } + const out = {} as Record; + for (const [strategy, group] of byStrategy) { + const prepare = group.map((row) => row.prepareMs).sort((a, b) => a - b); + const mount = group.map((row) => row.mountMs).sort((a, b) => a - b); + const total = group.map((row) => row.totalMs).sort((a, b) => a - b); + out[strategy] = { + supported: group.some((row) => row.supported), + mounted: group.every((row) => row.mounted), + prepareMedianMs: Number(quantile(prepare, 0.5).toFixed(3)), + mountMedianMs: Number(quantile(mount, 0.5).toFixed(3)), + totalMedianMs: Number(quantile(total, 0.5).toFixed(3)), + leafCount: group[group.length - 1]?.leafCount ?? 0, + }; + } + return out; +} + +function strategySupported(target: HtmlInsertionTarget, strategy: MountStrategy): boolean { + if (strategy === "append-html-unsafe") return typeof target.appendHTMLUnsafe === "function"; + if (strategy === "stream-append-html-unsafe") return typeof target.streamAppendHTMLUnsafe === "function"; + if (strategy === "stream-html-unsafe") return typeof target.streamHTMLUnsafe === "function"; + return true; +} + +async function runOne( + target: HTMLElement, + polygons: Polygon[], + strategy: MountStrategy, + repeat: number, + options: Required> & { strategies: MountStrategy[] }, +): Promise { + resetTarget(target); + const insertionTarget = target as HtmlInsertionTarget; + const supported = strategySupported(insertionTarget, strategy); + const t0 = performance.now(); + const result = strategy === "direct-inner-html" + ? { rendered: [] as RenderedPoly[], dispose: () => {} } + : renderPolygonsWithTextureAtlas(polygons, { + doc: document, + textureLighting: options.mode, + strategies: { disable: ["i", "u"] }, + }); + const t1 = performance.now(); + + let htmlChunks: string[] = []; + if ( + strategy === "contextual-fragment" || + strategy === "insert-adjacent-html" || + strategy === "append-html-unsafe" || + strategy === "stream-append-html-unsafe" || + strategy === "stream-html-unsafe" + ) { + htmlChunks = htmlFromRendered(result.rendered, options.chunkSize); + } else if (strategy === "direct-inner-html") { + htmlChunks = directHtmlForGrid(options.count, options.chunkSize); + } else if (strategy === "reuse-style-update") { + mountFragment(target, result.rendered); + await new Promise((resolve) => requestAnimationFrame(() => resolve(undefined))); + } + const t2 = performance.now(); + + let mounted = supported; + if (supported) { + if (strategy === "fragment-append") mountFragment(target, result.rendered); + else if (strategy === "fragment-replace") replaceWithFragment(target, result.rendered); + else if (strategy === "detached-wrapper") mountDetachedWrapper(target, result.rendered); + else if (strategy === "append-batches") mountAppendBatches(target, result.rendered, options.chunkSize); + else if (strategy === "fragment-chunks-yield") await mountFragmentChunksYield(target, result.rendered, options.chunkSize); + else if (strategy === "contextual-fragment") mountContextualFragment(target, htmlChunks); + else if (strategy === "insert-adjacent-html") mountInsertAdjacentHtml(target, htmlChunks); + else if (strategy === "append-html-unsafe") mounted = mountAppendHtmlUnsafe(insertionTarget, htmlChunks); + else if (strategy === "stream-append-html-unsafe") mounted = await mountHtmlStream(insertionTarget, htmlChunks, "streamAppendHTMLUnsafe"); + else if (strategy === "stream-html-unsafe") mounted = await mountHtmlStream(insertionTarget, htmlChunks, "streamHTMLUnsafe"); + else if (strategy === "direct-inner-html") { + target.innerHTML = htmlChunks.join(""); + } else if (strategy === "reuse-style-update") { + mutateMountedTransforms(result.rendered); + } + } + const t3 = performance.now(); + + const row: HtmlMountBenchRow = { + strategy, + repeat, + count: polygons.length, + rendered: strategy === "direct-inner-html" ? polygons.length : result.rendered.length, + supported, + mounted, + renderMs: Number((t1 - t0).toFixed(3)), + prepareMs: Number((t2 - t1).toFixed(3)), + mountMs: Number((t3 - t2).toFixed(3)), + totalMs: Number((t3 - t0).toFixed(3)), + leafCount: target.querySelectorAll("b,i,s,u").length, + }; + result.dispose(); + resetTarget(target); + await new Promise((resolve) => requestAnimationFrame(() => resolve(undefined))); + return row; +} + +export async function runPolycssHtmlMountBench(input: HtmlMountBenchOptions = {}): Promise<{ + supported: boolean; + options: Required> & { strategies: MountStrategy[] }; + rows: HtmlMountBenchRow[]; + summary: Record; +}> { + const options = { + count: Math.max(1, Math.floor(input.count ?? 10000)), + repeats: Math.max(1, Math.floor(input.repeats ?? 5)), + chunkSize: Math.max(1, Math.floor(input.chunkSize ?? 750)), + mode: input.mode ?? "baked", + strategies: input.strategies?.length ? input.strategies : STRATEGIES, + }; + const target = document.getElementById("bench-target") ?? document.body.appendChild(document.createElement("div")); + target.id = "bench-target"; + const polygons = solidGrid(options.count); + const rows: HtmlMountBenchRow[] = []; + const htmlTarget = target as HtmlInsertionTarget; + const supported = typeof htmlTarget.appendHTMLUnsafe === "function" || + typeof htmlTarget.streamAppendHTMLUnsafe === "function" || + typeof htmlTarget.streamHTMLUnsafe === "function"; + + for (let repeat = 0; repeat < options.repeats; repeat += 1) { + for (const strategy of options.strategies) { + rows.push(await runOne(target, polygons, strategy, repeat, options)); + } + } + + return { + supported, + options, + rows, + summary: summarize(rows), + }; +} + +(window as unknown as { + runPolycssHtmlMountBench: typeof runPolycssHtmlMountBench; +}).runPolycssHtmlMountBench = runPolycssHtmlMountBench; diff --git a/bench/entries/react.tsx b/bench/entries/react.tsx index 805465c3..29a987aa 100644 --- a/bench/entries/react.tsx +++ b/bench/entries/react.tsx @@ -25,7 +25,7 @@ import { getSynthMesh } from "../synth-mesh.mjs"; interface ParseResult { polygons: Polygon[]; dispose?: () => void } function PerfApp({ - meshId, mode, motion, az, el, preset, parseResult, + meshId, mode, motion, az, el, preset, parseResult, strategies, }: { meshId: string; mode: "dynamic" | "baked"; @@ -34,6 +34,7 @@ function PerfApp({ el: number; preset: { rotX: number; rotY: number; zoom: number; url: string | null; mtlUrl?: string }; parseResult: ParseResult | null; + strategies?: { disable: Array<"b" | "i" | "u"> }; }) { // Per-frame reactive state — React's render pipeline runs each tick. const [rotY, setRotY] = useState(preset.rotY); @@ -80,6 +81,7 @@ function PerfApp({ directionalLight={directionalLight} ambientLight={ambientLight} textureLighting={mode} + strategies={strategies} autoCenter > @@ -108,6 +110,7 @@ async function main(): Promise { az: number; el: number; isSynth: boolean; + strategies?: { disable: Array<"b" | "i" | "u"> }; preset: any; }; @@ -135,6 +138,7 @@ async function main(): Promise { el={params.el} preset={params.preset} parseResult={parseResult} + strategies={params.strategies} />, ); } diff --git a/bench/entries/syncSceneAdd.ts b/bench/entries/syncSceneAdd.ts new file mode 100644 index 00000000..e3934791 --- /dev/null +++ b/bench/entries/syncSceneAdd.ts @@ -0,0 +1,162 @@ +import { + createPolyOrthographicCamera, + createPolyScene, + type ParseResult, + type Polygon, + type PolyTextureLightingMode, +} from "@layoutit/polycss"; + +interface SyncSceneAddBenchOptions { + count?: number; + repeats?: number; + mode?: PolyTextureLightingMode; + palette?: "same" | "unique"; + shape?: "quad" | "triangle"; + disableStrategies?: Array<"b" | "i" | "u">; +} + +interface SyncSceneAddBenchRow { + repeat: number; + count: number; + addMs: number; + leafCount: number; + mounted: boolean; +} + +interface SyncSceneAddBenchSummary { + addMedianMs: number; + addP90Ms: number; + leafCount: number; +} + +function solidGrid(count: number, palette: "same" | "unique", shape: "quad" | "triangle"): Polygon[] { + const side = Math.ceil(Math.sqrt(count)); + const polygons: Polygon[] = []; + for (let i = 0; i < count; i += 1) { + const x = i % side; + const y = Math.floor(i / side); + const color = palette === "same" + ? "#66cc88" + : `#${((i * 2654435761) & 0xffffff).toString(16).padStart(6, "0")}`; + const vertices: Polygon["vertices"] = shape === "triangle" + ? [ + [x, y, 0], + [x + 0.88, y, 0], + [x, y + 0.88, 0], + ] + : [ + [x, y, 0], + [x + 0.88, y, 0], + [x + 0.88, y + 0.88, 0], + [x, y + 0.88, 0], + ]; + polygons.push({ vertices, color }); + } + return polygons; +} + +function makeParseResult(polygons: Polygon[]): ParseResult { + return { + polygons, + objectUrls: [], + warnings: [], + dispose: () => {}, + }; +} + +function nextFrame(): Promise { + return new Promise((resolve) => requestAnimationFrame(() => resolve())); +} + +function quantile(sorted: readonly number[], q: number): number { + if (sorted.length === 0) return 0; + const pos = (sorted.length - 1) * q; + const lo = Math.floor(pos); + const hi = Math.ceil(pos); + if (lo === hi) return sorted[lo] ?? 0; + return (sorted[lo] ?? 0) + ((sorted[hi] ?? 0) - (sorted[lo] ?? 0)) * (pos - lo); +} + +function summarize(rows: readonly SyncSceneAddBenchRow[]): SyncSceneAddBenchSummary { + const adds = rows.map((row) => row.addMs).sort((a, b) => a - b); + return { + addMedianMs: Number(quantile(adds, 0.5).toFixed(3)), + addP90Ms: Number(quantile(adds, 0.9).toFixed(3)), + leafCount: rows[rows.length - 1]?.leafCount ?? 0, + }; +} + +async function runOne( + target: HTMLElement, + polygons: Polygon[], + repeat: number, + mode: PolyTextureLightingMode, + disableStrategies: Array<"b" | "i" | "u">, +): Promise { + target.replaceChildren(); + const scene = createPolyScene(target, { + camera: createPolyOrthographicCamera({ rotX: 65, rotY: 45, zoom: 1 }), + textureLighting: mode, + strategies: { disable: disableStrategies }, + }); + await nextFrame(); + + performance.mark("polycss-sync-scene-add-start"); + console.timeStamp("polycss-sync-scene-add-start"); + const t0 = performance.now(); + const handle = scene.add(makeParseResult(polygons), { + merge: false, + excludeFromAutoCenter: true, + }); + const t1 = performance.now(); + console.timeStamp("polycss-sync-scene-add-end"); + performance.mark("polycss-sync-scene-add-end"); + + const leafCount = target.querySelectorAll(".polycss-mesh > b, .polycss-mesh > i, .polycss-mesh > s, .polycss-mesh > u").length; + const row: SyncSceneAddBenchRow = { + repeat, + count: polygons.length, + addMs: Number((t1 - t0).toFixed(3)), + leafCount, + mounted: leafCount === polygons.length, + }; + + handle.dispose(); + scene.destroy(); + target.replaceChildren(); + await nextFrame(); + return row; +} + +export async function runPolycssSyncSceneAddBench(input: SyncSceneAddBenchOptions = {}): Promise<{ + options: Required; + rows: SyncSceneAddBenchRow[]; + summary: SyncSceneAddBenchSummary; +}> { + const options = { + count: Math.max(1, Math.floor(input.count ?? 10000)), + repeats: Math.max(1, Math.floor(input.repeats ?? 5)), + mode: input.mode ?? "baked", + palette: input.palette ?? "same", + shape: input.shape ?? "quad", + disableStrategies: input.disableStrategies ?? ["i", "u"], + }; + const target = document.getElementById("bench-target") ?? document.body.appendChild(document.createElement("div")); + target.id = "bench-target"; + const polygons = solidGrid(options.count, options.palette, options.shape); + const rows: SyncSceneAddBenchRow[] = []; + + for (let repeat = 0; repeat < options.repeats; repeat += 1) { + rows.push(await runOne(target, polygons, repeat, options.mode, options.disableStrategies)); + } + + return { + options, + rows, + summary: summarize(rows), + }; +} + +(window as unknown as { + runPolycssSyncSceneAddBench: typeof runPolycssSyncSceneAddBench; +}).runPolycssSyncSceneAddBench = runPolycssSyncSceneAddBench; diff --git a/bench/entries/vue.ts b/bench/entries/vue.ts index bbdc1e72..08f09fd9 100644 --- a/bench/entries/vue.ts +++ b/bench/entries/vue.ts @@ -37,6 +37,7 @@ const PerfApp = defineComponent({ el: { type: Number, required: true }, preset: { type: Object as () => any, required: true }, parseResult: { type: Object as () => ParseResult | null, default: null }, + strategies: { type: Object as () => { disable: Array<"b" | "i" | "u"> } | undefined, default: undefined }, }, setup(props) { const rotY = ref(props.preset.rotY); @@ -90,6 +91,7 @@ const PerfApp = defineComponent({ directionalLight: directionalLight.value, ambientLight, textureLighting: props.mode, + strategies: props.strategies, autoCenter: true, }, { @@ -121,6 +123,7 @@ async function main(): Promise { az: number; el: number; isSynth: boolean; + strategies?: { disable: Array<"b" | "i" | "u"> }; preset: any; }; @@ -143,6 +146,7 @@ async function main(): Promise { el: params.el, preset: params.preset, parseResult, + strategies: params.strategies, }).mount(host); } diff --git a/bench/html-mount-bench.mjs b/bench/html-mount-bench.mjs new file mode 100644 index 00000000..e2dccbbe --- /dev/null +++ b/bench/html-mount-bench.mjs @@ -0,0 +1,152 @@ +/** + * Benchmark the experimental trusted HTML chunk mount helper against the + * existing DocumentFragment mount path. + * + * Usage: + * node bench/html-mount-bench.mjs + * node bench/html-mount-bench.mjs --count 20000 --repeats 7 --label html-mount + * node bench/html-mount-bench.mjs --no-experimental + */ +import { createServer } from "node:http"; +import { readFile, mkdir, writeFile } 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 benchDir = resolve(repoRoot, "bench"); + +const argv = process.argv.slice(2); +const flag = (name) => argv.indexOf(`--${name}`); +const optStr = (name, dflt = "") => { + const i = flag(name); + return i >= 0 ? argv[i + 1] : dflt; +}; +const optNum = (name, dflt) => { + const raw = optStr(name); + return raw ? Number(raw) : dflt; +}; +const hasFlag = (name) => flag(name) >= 0; +const 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 COUNT = optNum("count", 10000); +const REPEATS = optNum("repeats", 5); +const CHUNK_SIZE = optNum("chunk-size", optNum("chunkSize", 750)); +const MODE = optStr("mode", "baked"); +const LABEL = optStr("label"); +const HEADED = hasFlag("headed"); +const NO_EXPERIMENTAL = hasFlag("no-experimental"); +const YIELD_BETWEEN_CHUNKS = hasFlag("yield"); +const BROWSER_EXECUTABLE = optStr("browser-executable"); +const CHROMIUM_ARGS = chromiumArgsWithGpuDefault([ + ...(NO_EXPERIMENTAL ? [] : ["--enable-experimental-web-platform-features"]), + ...optAll("chromium-arg"), + ...optAll("chromium-args").flatMap((value) => value.split(/\s+/).filter(Boolean)), +]); + +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", +}; + +function startServer() { + return new Promise((resolveStart, rejectStart) => { + const server = createServer(async (req, res) => { + try { + const url = new URL(req.url, "http://localhost"); + const safe = url.pathname.replace(/\/+/g, "/"); + if (safe.includes("..")) { + res.writeHead(403); + res.end("forbidden"); + return; + } + const abs = resolve(benchDir, safe === "/" ? "html-mount.html" : safe.slice(1)); + const data = await readFile(abs); + res.writeHead(200, { + "Content-Type": MIME[extname(abs).toLowerCase()] || "application/octet-stream", + "Cache-Control": "no-store", + }); + res.end(data); + } catch (err) { + res.writeHead(404); + res.end(String(err?.message ?? err)); + } + }); + server.on("error", rejectStart); + server.listen(0, "127.0.0.1", () => { + const addr = server.address(); + resolveStart({ server, port: typeof addr === "object" ? addr.port : 0 }); + }); + }); +} + +function stopServer(server) { + return new Promise((resolveStop) => server.close(resolveStop)); +} + +function printResult(result) { + console.log(`[html-mount] supported=${result.supported} count=${result.options.count} repeats=${result.options.repeats} chunk=${result.options.chunkSize}`); + console.log(JSON.stringify(result.summary, null, 2)); + for (const row of result.rows) { + console.log( + `${row.strategy.padEnd(8)} #${row.repeat} ` + + `render=${row.renderMs.toFixed(3)}ms mount=${row.mountMs.toFixed(3)}ms ` + + `total=${row.totalMs.toFixed(3)}ms mounted=${row.mounted} leaves=${row.leafCount}`, + ); + } +} + +let server; +let browser; +try { + const started = await startServer(); + server = started.server; + const url = `http://127.0.0.1:${started.port}/html-mount.html`; + console.log(`[html-mount] ${url}`); + if (CHROMIUM_ARGS.length > 0) console.log(`[html-mount] chromium args=${CHROMIUM_ARGS.join(" ")}`); + browser = await chromium.launch({ + headless: !HEADED, + executablePath: BROWSER_EXECUTABLE || undefined, + args: CHROMIUM_ARGS, + }); + const page = await browser.newPage({ viewport: { width: 1200, height: 900 } }); + await page.goto(url, { waitUntil: "load" }); + const result = await page.evaluate( + async ({ count, repeats, chunkSize, mode, yieldBetweenChunks }) => + window.runPolycssHtmlMountBench({ count, repeats, chunkSize, mode, yieldBetweenChunks }), + { + count: COUNT, + repeats: REPEATS, + chunkSize: CHUNK_SIZE, + mode: MODE === "dynamic" ? "dynamic" : "baked", + yieldBetweenChunks: YIELD_BETWEEN_CHUNKS, + }, + ); + printResult(result); + if (LABEL) { + const dir = resolve(repoRoot, "bench/results"); + await mkdir(dir, { recursive: true }); + const file = resolve(dir, `${LABEL}.json`); + await writeFile(file, JSON.stringify(result, null, 2)); + console.log(`[html-mount] wrote ${file}`); + } +} finally { + if (browser) await browser.close(); + if (server) await stopServer(server); +} diff --git a/bench/html-mount.html b/bench/html-mount.html new file mode 100644 index 00000000..c1ce8954 --- /dev/null +++ b/bench/html-mount.html @@ -0,0 +1,53 @@ + + + + + polycss HTML chunk mount bench + + + +
ready
+
+ + + diff --git a/bench/nonvoxel-vanilla.html b/bench/nonvoxel-vanilla.html index 9ad63740..a9acd6a8 100644 --- a/bench/nonvoxel-vanilla.html +++ b/bench/nonvoxel-vanilla.html @@ -111,6 +111,14 @@ frameWorkSamples: () => frameWork.samples(), resetInteractionStats, cullStats: () => displayCullStats, + setMeshPosition(_position) {}, + setMeshRotation(_rotation) {}, + setMeshPolygonsSame() {}, + updateMeshColor(_index, _color) {}, + updateMeshData(_index, _data) {}, + updateMeshDataBatch(_start, _count, _frame) {}, + updateMeshColorData(_index, _color, _data) {}, + updateMeshColorDataBatch(_start, _count, _frame) {}, setRotY(rotY) { scene.camera.update({ rotY }); scene.applyCamera(); @@ -786,7 +794,57 @@ if (!rawParseResult) throw new Error(`Unknown synth mesh: ${meshId}`); const indexedParseResult = applyPolygonBenchIndexes(rawParseResult); const parseResult = applyPolygonOrder(indexedParseResult); - scene.add(parseResult); + const meshTransform = motion === "mesh-set-polygons-same" + ? { merge: false, stableDom: true } + : undefined; + const meshHandle = scene.add(parseResult, meshTransform); + window.__nonvoxelBench.setMeshPosition = (position) => { + meshHandle.setTransform({ position }); + }; + window.__nonvoxelBench.setMeshRotation = (rotation) => { + meshHandle.setTransform({ rotation }); + }; + window.__nonvoxelBench.setMeshPolygonsSame = () => { + meshHandle.setPolygons(parseResult.polygons, { + merge: false, + stableDom: true, + recomputeAutoCenter: false, + }); + }; + window.__nonvoxelBench.updateMeshColor = (index, color) => { + meshHandle.updatePolygon(index, { color }); + }; + window.__nonvoxelBench.updateMeshData = (index, data) => { + meshHandle.updatePolygon(index, { data }); + }; + window.__nonvoxelBench.updateMeshDataBatch = (start, count, frame) => { + for (let offset = 0; offset < count; offset += 1) { + const idx = (start + offset) % parseResult.polygons.length; + meshHandle.updatePolygon(idx, { + data: { + "bench-index": idx, + "bench-frame": frame, + }, + }); + } + }; + window.__nonvoxelBench.updateMeshColorData = (index, color, data) => { + meshHandle.updatePolygon(index, { color, data }); + }; + window.__nonvoxelBench.updateMeshColorDataBatch = (start, count, frame) => { + for (let offset = 0; offset < count; offset += 1) { + const idx = (start + offset) % parseResult.polygons.length; + const seed = (frame * 2654435761 + idx * 2246822519) >>> 0; + const color = `#${(seed & 0xffffff).toString(16).padStart(6, "0")}`; + meshHandle.updatePolygon(idx, { + color, + data: { + "bench-index": idx, + "bench-frame": frame, + }, + }); + } + }; applyDomOrder(indexedParseResult); applyIslandBuckets(indexedParseResult); applyNormalCullBuckets(indexedParseResult); @@ -814,6 +872,35 @@ scene.setOptions({ directionalLight: { direction: dirFromAzEl(azimuth, el), color: "#ffffff", intensity: 1 }, }); + } else if (motion === "mesh-position") { + const x = Math.sin(frameCount * 0.05) * 120; + const y = Math.cos(frameCount * 0.04) * 80; + window.__nonvoxelBench.setMeshPosition([x, y, 0]); + } else if (motion === "mesh-rotation") { + window.__nonvoxelBench.setMeshRotation([0, (frameCount * 0.5) % 360, 0]); + } else if (motion === "mesh-set-polygons-same") { + window.__nonvoxelBench.setMeshPolygonsSame(); + } else if (motion === "mesh-update-color" && window.__perf__.ready) { + const idx = frameCount % parseResult.polygons.length; + const color = `#${((frameCount * 2654435761) & 0xffffff).toString(16).padStart(6, "0")}`; + window.__nonvoxelBench.updateMeshColor(idx, color); + } else if (motion === "mesh-update-data" && window.__perf__.ready) { + const idx = frameCount % parseResult.polygons.length; + window.__nonvoxelBench.updateMeshData(idx, { + "bench-index": idx, + "bench-frame": frameCount, + }); + } else if (motion === "mesh-update-data-batch" && window.__perf__.ready) { + window.__nonvoxelBench.updateMeshDataBatch(frameCount * 257, 256, frameCount); + } else if (motion === "mesh-update-color-data" && window.__perf__.ready) { + const idx = frameCount % parseResult.polygons.length; + const color = `#${((frameCount * 2654435761) & 0xffffff).toString(16).padStart(6, "0")}`; + window.__nonvoxelBench.updateMeshColorData(idx, color, { + "bench-index": idx, + "bench-frame": frameCount, + }); + } else if (motion === "mesh-update-color-data-batch" && window.__perf__.ready) { + window.__nonvoxelBench.updateMeshColorDataBatch(frameCount * 257, 256, frameCount); } else if (motion === "rot") { const newRotY = ((preset.rotY + frameCount * 0.5) + 360) % 360; if (!cssDrivenRotation) { diff --git a/bench/notes/BENCH.md b/bench/notes/BENCH.md index 3eb61714..95e6d7e5 100644 --- a/bench/notes/BENCH.md +++ b/bench/notes/BENCH.md @@ -24,8 +24,8 @@ pnpm bench:visual # screenshot diff against bench/baselines/*.png pnpm bench:visual --record # capture new baselines (after intentional renderer changes) pnpm bench:build # just rebuild the bench bundles (rarely needed alone) node bench/nonvoxel-rotation-bench.mjs # non-voxel vanilla rotation probe -node .agents/skills/chrome-capture-trace/scripts/trace.mjs drag --label teapot-drag # pointer-drag trace, no auto-rotate -node .agents/skills/chrome-capture-trace/scripts/trace.mjs motion --page nonvoxel --no-trace # non-voxel rAF cadence buckets +node .agents/skills/chrome-trace/scripts/trace.mjs drag --label teapot-drag # pointer-drag trace, no auto-rotate +node .agents/skills/chrome-trace/scripts/trace.mjs motion --page nonvoxel --no-trace # non-voxel rAF cadence buckets node bench/nonvoxel-visual-compare.mjs # non-voxel variant visual parity ``` @@ -41,11 +41,11 @@ node bench/lossy-optimizer-bench.mjs --json bench/results/lossy-optimizer.json node bench/lossy-optimizer-bench.mjs --models ducky,shark,bicycle node bench/lossy-corpus-bench.mjs --root /tmp/polycss-model-corpus --json /tmp/polycss-temp-corpus.json node bench/lossy-corpus-bench.mjs --from-json bench/results/lossy-corpus.json --opportunities -node .agents/skills/chrome-capture-trace/scripts/trace.mjs motion --mesh garden --runs 3 --dom-samples --report --markdown-out bench/results/garden-trace.md +node .agents/skills/chrome-trace/scripts/trace.mjs motion --mesh garden --runs 3 --dom-samples --report --markdown-out bench/results/garden-trace.md node bench/perf-visual.mjs --mesh chicken --tolerance 0.005 node bench/nonvoxel-rotation-bench.mjs --models teapot,bicycle --variants baseline,order-tile4 --run-order round-robin -node .agents/skills/chrome-capture-trace/scripts/trace.mjs drag --mesh teapot --degrees 360 --drag-ms 1500 --label teapot-drag --frame-details --no-print-json -node .agents/skills/chrome-capture-trace/scripts/trace.mjs motion --page nonvoxel --mesh glb:Elephant.glb --variant baseline --no-trace +node .agents/skills/chrome-trace/scripts/trace.mjs drag --mesh teapot --degrees 360 --drag-ms 1500 --label teapot-drag --frame-details --no-print-json +node .agents/skills/chrome-trace/scripts/trace.mjs motion --page nonvoxel --mesh glb:Elephant.glb --variant baseline --no-trace node bench/nonvoxel-visual-compare.mjs --models bicycle,elephant,policecar --variants scene-split-target,scene-transform-perspective ``` @@ -148,12 +148,12 @@ tests above what the gallery's OBJs cover. Use `domOrder` for pure post-render DOM-order probes; `polygonOrder` changes the polygon array before render planning and is only for diagnostics. -`.agents/skills/chrome-capture-trace/scripts/trace.mjs motion` is the +`.agents/skills/chrome-trace/scripts/trace.mjs motion` is the steady-motion trace lane for perf and non-voxel pages. It aligns Chrome trace events to rAF samples and reports per-cadence-bucket compositor, style, raster, script, DOM, and tag-count costs. -`.agents/skills/chrome-capture-trace/scripts/trace.mjs drag` is the focused +`.agents/skills/chrome-trace/scripts/trace.mjs drag` is the focused user-input lane for the same page. It loads a non-voxel mesh (`teapot` by default), leaves OrbitControls auto-rotate off, performs real Playwright mouse drags until the requested diff --git a/bench/notes/PERF_INVESTIGATION.md b/bench/notes/PERF_INVESTIGATION.md index fd28f276..fcc8f691 100644 --- a/bench/notes/PERF_INVESTIGATION.md +++ b/bench/notes/PERF_INVESTIGATION.md @@ -52,7 +52,7 @@ pnpm bench:perf pnpm bench:visual pnpm bench:trace node bench/nonvoxel-rotation-bench.mjs --run-order random -node .agents/skills/chrome-capture-trace/scripts/trace.mjs motion --page nonvoxel --no-trace +node .agents/skills/chrome-trace/scripts/trace.mjs motion --page nonvoxel --no-trace node bench/nonvoxel-visual-compare.mjs ``` @@ -286,7 +286,7 @@ Next useful work: Next useful work: -1. Use `.agents/skills/chrome-capture-trace/scripts/trace.mjs motion --page nonvoxel --no-trace` +1. Use `.agents/skills/chrome-trace/scripts/trace.mjs motion --page nonvoxel --no-trace` to identify the slow cadence buckets first; trace only after a signal survives repeated clean runs. 2. Investigate Teapot-like dense curved solid meshes separately from broad diff --git a/bench/perf-serve.mjs b/bench/perf-serve.mjs index 838fc895..6de1d1fa 100644 --- a/bench/perf-serve.mjs +++ b/bench/perf-serve.mjs @@ -76,6 +76,10 @@ const INDEX_HTML = `
  • /perf-vanilla.html  imperative createPolyScene
  • /perf-react.html  React @layoutit/polycss-react
  • /perf-vue.html  Vue @layoutit/polycss-vue
  • +
  • /html-mount.html  experimental HTML chunk mount
  • +
  • /async-scene-mount.html  internal async scene mount
  • +
  • /sync-scene-add.html  synchronous scene.add render
  • +
  • /atlas-background.html  atlas background reveal style application
  • `; diff --git a/bench/perf-shared.mjs b/bench/perf-shared.mjs index c98e8b53..010c7de5 100644 --- a/bench/perf-shared.mjs +++ b/bench/perf-shared.mjs @@ -193,6 +193,10 @@ export function parseUrlParams() { const params = new URLSearchParams(window.location.search); const meshId = params.get("mesh") || "saucer"; const genericPreset = genericGalleryPreset(params, meshId); + const disabledStrategies = (params.get("disableStrategies") || "") + .split(",") + .map((value) => value.trim()) + .filter((value) => value === "b" || value === "i" || value === "u"); return { meshId, mode: params.get("mode") === "baked" ? "baked" : "dynamic", @@ -200,6 +204,7 @@ export function parseUrlParams() { az: parseFloat(params.get("az")) || 50, el: parseFloat(params.get("el")) || 45, isSynth: meshId.startsWith("synth-"), + strategies: disabledStrategies.length > 0 ? { disable: disabledStrategies } : undefined, preset: genericPreset ?? (meshId.startsWith("synth-") ? { url: null, options: {}, zoom: 0.2, rotX: 65, rotY: 45 } : (PRESETS[meshId] ?? PRESETS.saucer)), diff --git a/bench/perf-vanilla.html b/bench/perf-vanilla.html index 10c4dc7b..8a6d31e4 100644 --- a/bench/perf-vanilla.html +++ b/bench/perf-vanilla.html @@ -21,7 +21,7 @@ document.getElementById("overlay-css").textContent = PERF_OVERLAY_CSS; document.body.insertAdjacentHTML("beforeend", PERF_OVERLAY_HTML); - const { meshId, mode, motion, az, el, isSynth, preset } = parseUrlParams(); + const { meshId, mode, motion, az, el, isSynth, preset, strategies } = parseUrlParams(); let azimuth = az; const host = document.getElementById("host"); @@ -31,6 +31,7 @@ directionalLight: { direction: dirFromAzEl(azimuth, el), color: "#ffffff", intensity: 1 }, ambientLight: { color: "#ffffff", intensity: 0.4 }, textureLighting: mode, + strategies, autoCenter: true, }); // Drag + wheel for human inspection via perf-serve. Headless bench diff --git a/bench/sync-scene-add-bench.mjs b/bench/sync-scene-add-bench.mjs new file mode 100644 index 00000000..cd078265 --- /dev/null +++ b/bench/sync-scene-add-bench.mjs @@ -0,0 +1,152 @@ +/** + * Benchmark synchronous createPolyScene().add() render and mount cost. + * + * Usage: + * node bench/sync-scene-add-bench.mjs + * node bench/sync-scene-add-bench.mjs --count 50000 --repeats 5 --label sync-scene-add + */ +import { createServer } from "node:http"; +import { readFile, mkdir, writeFile } 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 benchDir = resolve(repoRoot, "bench"); + +const argv = process.argv.slice(2); +const flag = (name) => argv.indexOf(`--${name}`); +const optStr = (name, dflt = "") => { + const i = flag(name); + return i >= 0 ? argv[i + 1] : dflt; +}; +const optNum = (name, dflt) => { + const raw = optStr(name); + return raw ? Number(raw) : dflt; +}; +const hasFlag = (name) => flag(name) >= 0; +const 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 COUNT = optNum("count", 10000); +const REPEATS = optNum("repeats", 5); +const MODE = optStr("mode", "baked"); +const PALETTE = optStr("palette", "same"); +const SHAPE = optStr("shape", "quad"); +const DISABLE_STRATEGIES = [ + ...optAll("disable-strategy"), + ...optAll("disable-strategies").flatMap((value) => value.split(",")), +].map((value) => value.trim()).filter(Boolean); +const LABEL = optStr("label"); +const HEADED = hasFlag("headed"); +const BROWSER_EXECUTABLE = optStr("browser-executable"); +const CHROMIUM_ARGS = chromiumArgsWithGpuDefault([ + ...optAll("chromium-arg"), + ...optAll("chromium-args").flatMap((value) => value.split(/\s+/).filter(Boolean)), +]); + +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", +}; + +function startServer() { + return new Promise((resolveStart, rejectStart) => { + const server = createServer(async (req, res) => { + try { + const url = new URL(req.url, "http://localhost"); + const safe = url.pathname.replace(/\/+/g, "/"); + if (safe.includes("..")) { + res.writeHead(403); + res.end("forbidden"); + return; + } + const abs = resolve(benchDir, safe === "/" ? "sync-scene-add.html" : safe.slice(1)); + const data = await readFile(abs); + res.writeHead(200, { + "Content-Type": MIME[extname(abs).toLowerCase()] || "application/octet-stream", + "Cache-Control": "no-store", + }); + res.end(data); + } catch (err) { + res.writeHead(404); + res.end(String(err?.message ?? err)); + } + }); + server.on("error", rejectStart); + server.listen(0, "127.0.0.1", () => { + const addr = server.address(); + resolveStart({ server, port: typeof addr === "object" ? addr.port : 0 }); + }); + }); +} + +function stopServer(server) { + return new Promise((resolveStop) => server.close(resolveStop)); +} + +function printResult(result) { + console.log(`[sync-scene-add] count=${result.options.count} repeats=${result.options.repeats} mode=${result.options.mode} palette=${result.options.palette} shape=${result.options.shape} disable=${result.options.disableStrategies.join(",")}`); + console.log(JSON.stringify(result.summary, null, 2)); + for (const row of result.rows) { + console.log( + `#${row.repeat} add=${row.addMs.toFixed(3)}ms ` + + `mounted=${row.mounted} leaves=${row.leafCount}`, + ); + } +} + +let server; +let browser; +try { + const started = await startServer(); + server = started.server; + const url = `http://127.0.0.1:${started.port}/sync-scene-add.html`; + console.log(`[sync-scene-add] ${url}`); + if (CHROMIUM_ARGS.length > 0) console.log(`[sync-scene-add] chromium args=${CHROMIUM_ARGS.join(" ")}`); + browser = await chromium.launch({ + headless: !HEADED, + executablePath: BROWSER_EXECUTABLE || undefined, + args: CHROMIUM_ARGS, + }); + const page = await browser.newPage({ viewport: { width: 1200, height: 900 } }); + await page.goto(url, { waitUntil: "load" }); + const result = await page.evaluate( + async ({ count, repeats, mode, palette, shape, disableStrategies }) => + window.runPolycssSyncSceneAddBench({ count, repeats, mode, palette, shape, disableStrategies }), + { + count: COUNT, + repeats: REPEATS, + mode: MODE === "dynamic" ? "dynamic" : "baked", + palette: PALETTE === "unique" ? "unique" : "same", + shape: SHAPE === "triangle" ? "triangle" : "quad", + disableStrategies: DISABLE_STRATEGIES.length > 0 ? DISABLE_STRATEGIES : undefined, + }, + ); + printResult(result); + if (LABEL) { + const dir = resolve(repoRoot, "bench/results"); + await mkdir(dir, { recursive: true }); + const file = resolve(dir, `${LABEL}.json`); + await writeFile(file, JSON.stringify(result, null, 2)); + console.log(`[sync-scene-add] wrote ${file}`); + } +} finally { + if (browser) await browser.close(); + if (server) await stopServer(server); +} diff --git a/bench/sync-scene-add.html b/bench/sync-scene-add.html new file mode 100644 index 00000000..f29a9e1a --- /dev/null +++ b/bench/sync-scene-add.html @@ -0,0 +1,52 @@ + + + + + polycss sync scene.add bench + + + +
    ready
    +
    + + + diff --git a/package.json b/package.json index 616e3b31..00aeb4e2 100644 --- a/package.json +++ b/package.json @@ -30,7 +30,11 @@ "bench:serve": "node bench/perf-serve.mjs --port 4400", "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:trace": "node bench/build.mjs && node .agents/skills/chrome-trace/scripts/trace.mjs motion", + "bench:html-mount": "node bench/build.mjs && node bench/html-mount-bench.mjs", + "bench:async-scene-mount": "node bench/build.mjs && node bench/async-scene-mount-bench.mjs", + "bench:sync-scene-add": "node bench/build.mjs && node bench/sync-scene-add-bench.mjs", + "bench:atlas-background": "node bench/build.mjs && node bench/atlas-background-bench.mjs", "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", From 625b89b7ed744e29f40883ad2fbb5d45751fe71b Mon Sep 17 00:00:00 2001 From: agustin-littlehat Date: Mon, 25 May 2026 13:57:54 -0300 Subject: [PATCH 5/5] feat(website): overhaul builder workbench --- .../public/builder/shape-thumbnails/box.png | Bin 0 -> 5219 bytes .../public/builder/shape-thumbnails/cone.png | Bin 0 -> 8163 bytes .../builder/shape-thumbnails/cylinder.png | Bin 0 -> 6265 bytes .../builder/shape-thumbnails/dodecahedron.png | Bin 0 -> 6851 bytes .../builder/shape-thumbnails/icosahedron.png | Bin 0 -> 10405 bytes .../builder/shape-thumbnails/octahedron.png | Bin 0 -> 5571 bytes .../builder/shape-thumbnails/sphere.png | Bin 0 -> 19648 bytes .../builder/shape-thumbnails/tetrahedron.png | Bin 0 -> 4343 bytes .../public/builder/shape-thumbnails/torus.png | Bin 0 -> 18409 bytes .../gallery/glb/urban/Rock band poster.glb | Bin 12676 -> 0 bytes .../generate-builder-shape-thumbnails.mjs | 197 +++++++ .../BuilderWorkbench/BuilderWorkbench.tsx | 431 +++++++++++---- .../BuilderWorkbench/builder-workbench.css | 499 ++++++++++++++++-- .../components/BuilderCameraDragControls.tsx | 184 +++++++ .../components/BuilderDock.tsx | 100 +--- .../components/BuilderMeshPanel.tsx | 147 ++++++ .../components/BuilderScene.tsx | 490 ++++++++++++++++- .../components/BuilderSceneOutliner.tsx | 21 +- .../components/BuilderToolPalette.tsx | 1 - .../components/BuilderToolRibbon.tsx | 54 ++ .../components/ShapePicker.tsx | 81 +++ .../components/BuilderWorkbench/defaults.ts | 16 +- .../BuilderWorkbench/geometry/ghost.ts | 66 ++- .../BuilderWorkbench/geometry/grid.ts | 53 +- .../geometry/screenToWorld.ts | 20 +- .../BuilderWorkbench/geometry/snap.ts | 22 + .../hooks/usePlacementMode.ts | 5 +- .../BuilderWorkbench/hooks/usePlacements.ts | 103 +++- .../BuilderWorkbench/hooks/useSceneLoader.ts | 11 +- .../BuilderWorkbench/hooks/useSceneRender.ts | 26 +- .../BuilderWorkbench/hooks/useTerrain.ts | 8 +- .../components/BuilderWorkbench/sceneUrl.ts | 201 +++++++ .../BuilderWorkbench/shapePresets.ts | 71 +++ .../slots/BuilderDockSlots.tsx | 70 +++ .../src/components/BuilderWorkbench/types.ts | 10 + .../components/Dock/folders/useSceneFolder.ts | 40 +- website/src/components/DocsHeader.astro | 1 + .../GalleryWorkbench/GalleryWorkbench.tsx | 1 + .../GalleryWorkbench/presets/attributions.ts | 1 - .../presets/presetBuilders.ts | 7 +- .../GalleryWorkbench/presets/presetFiles.ts | 1 - website/src/components/types.ts | 4 + 42 files changed, 2570 insertions(+), 372 deletions(-) create mode 100644 website/public/builder/shape-thumbnails/box.png create mode 100644 website/public/builder/shape-thumbnails/cone.png create mode 100644 website/public/builder/shape-thumbnails/cylinder.png create mode 100644 website/public/builder/shape-thumbnails/dodecahedron.png create mode 100644 website/public/builder/shape-thumbnails/icosahedron.png create mode 100644 website/public/builder/shape-thumbnails/octahedron.png create mode 100644 website/public/builder/shape-thumbnails/sphere.png create mode 100644 website/public/builder/shape-thumbnails/tetrahedron.png create mode 100644 website/public/builder/shape-thumbnails/torus.png delete mode 100644 website/public/gallery/glb/urban/Rock band poster.glb create mode 100644 website/scripts/generate-builder-shape-thumbnails.mjs create mode 100644 website/src/components/BuilderWorkbench/components/BuilderCameraDragControls.tsx create mode 100644 website/src/components/BuilderWorkbench/components/BuilderMeshPanel.tsx create mode 100644 website/src/components/BuilderWorkbench/components/BuilderToolRibbon.tsx create mode 100644 website/src/components/BuilderWorkbench/components/ShapePicker.tsx create mode 100644 website/src/components/BuilderWorkbench/geometry/snap.ts create mode 100644 website/src/components/BuilderWorkbench/sceneUrl.ts create mode 100644 website/src/components/BuilderWorkbench/shapePresets.ts create mode 100644 website/src/components/BuilderWorkbench/slots/BuilderDockSlots.tsx diff --git a/website/public/builder/shape-thumbnails/box.png b/website/public/builder/shape-thumbnails/box.png new file mode 100644 index 0000000000000000000000000000000000000000..3809f41e2a6688cfc1ba11ab7b083ea371d4a982 GIT binary patch literal 5219 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 literal 0 HcmV?d00001 diff --git a/website/public/builder/shape-thumbnails/cone.png b/website/public/builder/shape-thumbnails/cone.png new file mode 100644 index 0000000000000000000000000000000000000000..0d3875dc6890c9a616a0e3e92b21bace8fe10cf1 GIT binary patch literal 8163 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 si+_s0SLol~m}0-Tye|;{JOBUy literal 0 HcmV?d00001 diff --git a/website/public/builder/shape-thumbnails/cylinder.png b/website/public/builder/shape-thumbnails/cylinder.png new file mode 100644 index 0000000000000000000000000000000000000000..d42496dbbc551672c32502232b45f71422eab10c GIT binary patch literal 6265 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 F{(s&IpKJgC literal 0 HcmV?d00001 diff --git a/website/public/builder/shape-thumbnails/dodecahedron.png b/website/public/builder/shape-thumbnails/dodecahedron.png new file mode 100644 index 0000000000000000000000000000000000000000..9c04f2b170d7c6d72fb34c79c29484d62d4f09f8 GIT binary patch literal 6851 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 k}9^uK|cwzf$kL z16rpsvA_wqXcb!SdNb-iPlJ7Sd}M({tjclBYLyL{+J1JHi{h9|&jFsyUY)!GF9i2Y z3fa7MvWSui*s4R8DJB&Qy*#68I(SbRDDQFO9=aoc0-vv&3R))pOpT=q3~9Kn)`d0MXGHU@0%(^zi!kPK9Lk4{D=Tk&|Ccb_ z^51@-K);+ZCO>zP9^$6!G%T6qkSXfx*fU%ryFY>3J2N-e%E~ywft69~Oj^~@Tk;ch z2-CP-Wh9M?n0dv7vS}C-7W>6|W~W1J+mK=`t2Qtw<;{ub93gS(UGrsqVFHUXuYCkE zYW*yt?UsRLv4aTC!o+FE^YeM+@}(k|p#j4i&2%RtJQ714Sw;vnSQ;j$77-m;TLZZE z4E^b&RciwOUiD#61Pnp&jRJ`}+}7|WJ{{ivYQkDcuIUh{!)0I&1xJ*xs8l(GGCk1Y zDxBEQFAk9^i+v}t)=G<_4?Bwc=xQoZ8Z&~VQ!yuVm+fK9AKW+P2c<<>5NjdN13Wqo za!Hk`%&?diThIe~_7p#7_1D@W!O#zmJrbMlCJ5l0tH&B5gjxj?@@6%k{D4spLH6FM zCXw81b-5_vP@eWAeqV0djKa0&Fs6O|>o&z1KGX1W+I)cfq0>w_=-G;n(w`?dRwJim zN@QfZO`^UgdZ-|C3s&Q1JV&C}_#90Ru)FHZeP*-iX%1fCn1pTXU-L|~db69oso7N0 z3p#$!;d1}!kJX9Y)sP1SNX|m|P2ra{XZqMjjQ)Vzdk~ z#llu5+PlfH?>b;1(Br`M<+3&AMGRU<4ao!UPC0Tmc`=5SJ;^g_V{GVg((`(uSeaN2 zSoCz=#7)ql$RipsM1B})km9fZwcrCz^OxH?!;8$X8s`hyRx?EeAaCCW|N8>5rJMwU{MZ&okjEGE=d0%T|XELPYY?l^v>7w zg6=#k_QU$Q^wC|(^tj?G2J(_#R8O2(S|U4On5IsrjbQzSidERUHI3tQyMeY%B17~3 zv%j+;*h3EjCRn06=u%gQ_L{>_K#x39n&CF?T(anyZc|3bh_G~on>*P zntcqjy~_M?f!sX*{X4j~stN~dmt^ZCQc?EpmXS9DJeX}a@yPm!v<9j1@aT3+iJm=? z(z)*Ny-80$vK#x{w{dBE^8nH?FaSUnz5kPe)K9Mh@$l3qy4n2d{vD2=JGhiiMoWvm zkJ>D@-7Eqcl2amGoF9fk=aNL12N)Mx@dT4&fe+tTEgZDs>~D2zn;@T*hxQ{vYqe1}xENf`-F}g&$u4FP`Z> J{iF(u{y*ZNzH|Tp literal 0 HcmV?d00001 diff --git a/website/public/builder/shape-thumbnails/icosahedron.png b/website/public/builder/shape-thumbnails/icosahedron.png new file mode 100644 index 0000000000000000000000000000000000000000..c3a354d6202ca7be584a3685342fa1c851ea0513 GIT binary patch literal 10405 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 J2$44r`#=3_%PRl? literal 0 HcmV?d00001 diff --git a/website/public/builder/shape-thumbnails/octahedron.png b/website/public/builder/shape-thumbnails/octahedron.png new file mode 100644 index 0000000000000000000000000000000000000000..6c0ac80948a97477b4d0c11c891f4bcce5f3a5ff GIT binary patch literal 5571 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 D!%%8M literal 0 HcmV?d00001 diff --git a/website/public/builder/shape-thumbnails/sphere.png b/website/public/builder/shape-thumbnails/sphere.png new file mode 100644 index 0000000000000000000000000000000000000000..01f12946882076b7a538727cbbb907fc2bc0fb0f GIT binary patch literal 19648 zcmd3uRZ|^I)2?xMw}mgSWyg1d)6aCdii*WgZY2<`-TcU`!)Q)ID;}yAU%B zv|MT0mxRG+-05<6f~!fk)awm4gfjn-i)F)$3869)(zybqX%j9#w`NYz0tx7npP)9c z`!_HQv!>3{pA3U3sL{gEP|0X0M4|I+by~hzYsvyOD#E<4_$;nAzt+Gh>Kc{1Q%`9w zpI03k?x1~P?+flfhiXwc^ty2Qs>L$19K|phlv2ovSjJH}%#v~e^r}r5^pK@8v*Q1M zAMBt8Xl;E>=C0aI8*-eE_zOJWN~9Fp_NJKr+V5ud{<0it;Pn`LBS^gHIdYd@8+RqW z7V^j6@=iN1Z=UTk>iIe>qmvr{@7csV+`%HJsQ1rPOchhBJ%l`CMBZhl4{u9zR!1E9 zgm>m84fvflwE+M_jyDT=7nna!Ei_krG|WjM@Yasi6HtXp4QH50oc&==L&&d~Bz zO!MP6Y_AoXb?v`9C!`K7(%GHS<1&pr-~}Ypdh8-Z-X26c-`M45)r(=Y;or}{UQ3Lo z!QR`*EX~L{w4`b#8r9bgmqkFkXXD0n?~Nx;J?#o}b=Y1LR_#Z_r-!G_2MM`Zod51Z zKXyMV3kWQMg;Orh{dgxa$ZSJIAQ7KT_~T8cjNQoaqkc?JH#ConF+)EWn~uSxifaw{ zQ@L1Y(%!_Li^$5EUnew>#~IGJ4Xf~I;~5k<+Y^j1?bb<^Ebvr9*Zs&DLi{9eI2yHo z-|eQQlNw0CGEQB2dQ|RNzq{*Mot;Oo1wW)!W$REh>-2Wt=GH?9pjpX3^Yo8EkO_6- zTf4#!T^&9p$SP`=kdC>rPSa0sAG2Q!@DNrqJ`=D!5fHu(Zg2Qc8>vOLQvrJ!9In3Z zE4I~gUiS5`Dk+q2;H;oT+r+&f5&B!h@`5w|O1->*KNM=^&F=!Q%v;TZnF#NiU@c%3 zw*!Sj%A;3U!zDb_p&eceZx#$p`p7=He;hkC_e;u=b~>kZTE1inU;8AUuHFRXRIpW{n6RHZKN{T`IKB{}TLk7; zInzen!3A@RItS4vWg+UN$>eP+J{vHU!7Tr@n6u<^?;8vP;k#JztSM?4u8PN84}1=% zM^gL5=m%SJzf(!njadBlv+EN={eHNNu$dNjI@A409Q-z=8ddqVTfFFa zUYQGgbKZo8gVstdaTG?FtmhnDDI~Bp&gKhtMxdAk@>{8X7AVv@Gq;&sEe4B%%w1>3 z57OyH=BFu>(!BlTsX*Xlg`cd#uKQI%v0&*q{CrxA7?rR7jJX1PpPfTxwWx+ftQ{zi zRe`S`y+mL6!Fun+G3u;utI4@SjBHw-(95G$7@Vx01MmeMHRQ9a5z9<2KD(xVEqCjeV8@#U+Yx3?5i!(tc@=I6Xt|93R0Pb1S(TgC1|eXXy$^t1DQUFa2N8d|m4 zsQeixW$~R~Lvyy!#NduR z&xtY*Fp_|d5DlHKs|lcO4jgk6x$yq9UFk5pnanlxAgztM%L8vC4X7wyMsSda<&8i0 z=(hns^I-zRoPcfH7L*cSFT_vVcVB8q;VBShkd)wMHrXw<4}sJZ(8{b(9VykKtOi7~ zJ(TJpyOb3b-j`t|;Egl4OW4v~8YnR8T9>|_KYC<80+nFBn9Q5zXE|y^pBBaX@d*6~ zq{ItSg@SqPx=8Lp8orJ)UfS;vl)np50QMv z!V7PQfr!BT2Qy@Dq7C}lMB32eI4p}I6LQ0kq~eFN?2p`iV%$0vh_Gtd)cv$!E%JU` zbqqI%Is$3**fL$y%HTybZ>!X$^zv7{+v0Eagm0sNI$u=eD?xV}%-Z1?ET8i{9y{y$ zOZlzID0S@^IT!w*24p|z(&ElQ&aAEJd18kc|V*A43v<;&P=)=>?4K zt1uqlDA}&HqH8l3=-n`K(PM9mn*)DBduQhl7B%MiRVtmsvm1*3jo^%v2)khI)9Xr* z-tR4A5vpI;57$&v@R`UTG$>1TVM%bxn@CM2qJd1ga zMB9(;B7XNzCvbhP%t4plBeed}HC|+G!<|ajnQWQ*%ZWA?S4grSeO8@W9(Llkgc%%|9L?nplGWqN~=3+5jI z=pVaVJ-^RLmYE^@d!F#GuRi#V1`daOF)YZB=>&@f{XyHUuS^K`>0k>UXY3DC^MSi~ z0l3Zd}y>w*$4PQDIG3+}{uD^YMHCXcOY{S`y)*!e)dz4QJ_U{C{1B8 z=ogT5)r7>_`#q7TkMvEpvn#z;O`n(Ky5#D4!ai_KI9^~3dAIMcpiSW(-BxwuaERu8 z`!5xSz3G92c5J^Ms=u{?H8}^1K#g%E@=iyEYEFzEsEE?_PKBR3s*Tlu&4y1cX||aY z8P0T@5vYNzD8mPZ=_e#=-L0Xkvhdhx3+xzMPEvx#2CFeyoA^{9jdaFCkHh!Jb@VRx ztEvh5cN2v7w{U4TqyES<_2#W0&8`M4{$rnrOVwHW{$JG-io9D7KCaR8 z6n<@$D!zOcgx2<>v%j@s+X&HSs17B2(q?DGC<*qlKn5!>FnKuOIm(cxgU-G0(Q_gW zGC~@%8Y*WgjK~2)yB*d?pB-k%HZ~ zGRn!K4dcx@BV#qi(dc$QJI-7^BU$LDze|4SzIbC2d=cr`$KP^AlJwYAKQnBgJkH}4 zx;{I<`O+E{#+n-9j6$kN%D>`EA9c4YWFX(8XS@O}6d-P2X+vAQIPFHw!BuMpB{Dg& z&fkDy#9DVnHy0477yU|@x?{&r@asiWMO_kc+?*a2F^E6S0-X*yvA*c^aTwlk^g+B5 z_#_&cHW2>1L>u0GxC>)JJdY|E8(Y?YGKYizZM;lAZTn`_LNlpAk*IrLnjMoJhKHGm z$0v+yW|01y2vv~YuFF3wuV`;>LlO-sWHh|xc!5}&pUlV^-wXJ%8qv;uxHA|4r5)fb zDNc0h5^bkOw#CFJ|B!52(1|32o;0ta0*zYW1TQs5M~0mA88qgn_8^mdZ@7!rj!+WI zIu38^EEI;^YzYV@=Uitc_7*zl47~M!$-er#PtEMeB+EOPYp?E|$><=bH*4fU@Me>L z)5RrmBdEv?HD41RqNd$QYIRKKe3Wpz*vO`76M*oS-L6t~mz}WCAjeA`f6rM5ehJPa zm!f96KB!19+nmkzGJry=Frc?&8vN*zh0Df63oA`&WYuP+_cy21(j@^jvceRW|BHT!}H5G40=7Up>%$*{}Hi|5!k+AhkK#rbwIwI!z79 z2*HcUubpcgt3LFPwhN)TAjG(kYA0x&?wt&W@AZIZPEtr0dlKhtMEQXP3-n-#T1MabWeZjrT0oDX}|&X$}DcHF0Evplfw zc&5Bx3-VraWn-if9SS3;x{M9uA%m?+1?QiYpR8(@F<(mPbpG~(SOFLuo?1rDX=YNS z`Yz32H6Um&y?ZCfu*WmyJ-Q~dFH}2lFKVMlwWRN+D7qTo$iPsBwk(VagL)e3ab6?) z9yn^y@pcjbjFJ(^ zSOeHDGmT6}H%x3vucJx6$QwPFnzC7Rw5ryVG?)BxoY?+5j~S2EV20$?Q%}N*fQ!l9 z9kL)x!GvQYv_?=)q;qiS?*}#*@d{JGo~Gu`+2S)mKSPpNO>4|Ssi=94xsvJC^yd^e zGmZF0<0Z;KzsdAb^KIwhlz|#W*S_%W_4y}^_a^7lRqfZFcgLMj{>TVW$HaEDgo=K; z+fTFqyt8iUgy99&?};t_j3I~xW}-*9<<$woW;#wb1_4tP zYk4tPf<)0GI<89Rj&cJ+nhMZW?&=|$eT@PF408FFQGPfntQcgZFaiR(*y3=XF#Cgv z>Z*c7$aduFj*jRu)K#>-F#QI`7mIlVt)5I<%kWl@{#lFMCNIxp4bJTcq#3F#z1m*5 z*RL|-t;%Ic&P(A(T4CM^7gw38XAzWr2XL;U9a~zJ+;YqXn{qP+UWVioI<`S=lTQl;9A303R4tEFY@ki=5nVBD|1;%o z+HPb>XZ6bKkyU+XydewFMV&-M*9h1#$+Od+PEw2`yvePa4oRa5BZiOuSuh&w%-CP+bZrl zJQKpcEBlP3+^$Cm=LqLv9B*n9h9_?9TWEhP^j9|U3VzyDj^-Vc@eH>b81 zni!?wSg56j;h^FKNBSu8zFdF^t1dQ5;?fSKn?PE?Ygow%>Sat_dIvu zNE-_#qHj=A!#fg;N`kRz94cw}eKnyNpUPBS4T+7=k%qu`>iC#FBHQ2^S&k_Fd4!}@ ziGRJn^yE*z?$4ZviKdB!EgamDk)k>eI1;lET@(-na(!?^%UtMQWpASaTB5gTc&Iaf z6bthpqT^UI=t*mwj{~m`ji6rmvEBb|kg+1Ro(o|Hyll)f#rP#ZqFr0(n;~fV$caU^ z#>OkTHxMn#l};C>ln~b|c)zJ?=ECw5ZMHVz3=?`TCN#`|-n|=Eas?M}WZL>Th8aiJ zB;!8~Nt0Kjhm_QYIiVC$EQJjtkuV*7cM>Ky`=_n?<#tB-oBv+E&q0}nc?TD?d@w|& zKl*)xdF}R2v{zas0~GZ)L0s{IirRpgAP2>5V3b_jRv3YlY z2NpY3fvpM~M5Haj#$Jl+q+%BGuL4v}gF1cpH(WH!B`FnpcyYlr{jd|fqre6kL&;Jzp3qHgRxXEs_|}v&faUj zR$*v$WXtLA=a{8ebrLoa=)7YOEP(na`^(m{|8qUr`A2Sk%fcZS{#qWy-Qe~hM7PTn zSxqQmN1AtCvOX=N<=*uCH5fH;A239Ht1T_iN2f_=_)Zd)n$m`0*GVVs#uv-8h7%TQ z1LAW-D+4$U*c8bSOGyG6%ZD6&$dc2oy3*N3erdu?i z#MiNZ$5o{p6m82waCBc)t+6u`!capFsdI)rih97-6oCuQq--W=MjkT8r(hr;_r27= z`-MS)MUl@Gwe=ypt_#uR%Sx~@xrvg{lH zC#JQW*if#3*K_#*-%+*kirAT@`rq!GPX+#nC#N-yJ@_}!$PS9o^b{#G=1_W{G5M~j zYUxmATqnriA4^b|6{jjkH4a~7Y;ozw^O*gO&dhoaXONxOj@4#(m0xsz8Esx8S*b$P z;=~*KHf)2xC7rJ$r%wV@2y$=U-;2R7*NM80$J?Rs2(2*}nbzBF^mN5g5@u_s5_l;w zH>(xDe!%{9BEAT00rqb8+`^9=J9Vd9IQFzPh1|iXred;_=^rvb-c8-BoRB#2;EjG~ z!w8JUp}_r6YG|}hq>IW>4`g)H=4-E6%BJGtcE@HAL_3jHo*K>!!y(x57S=D)AkjzwXMbQ!{dKUqxvl^J9U=QV;6h zd9!Qqh}oy+3cbp2PPPm#U>#wkVg(kHSm&OPAguR+@>dry1gtXI3BiwwLafh>DZj|m zKwLC82ut7>rp}`Ep9;n86y?|Cc0e2|9=`68=T3N=3>)k>-Qf*bhfi#g?o=4DI=q2c z>Pc>kVdVCWsRy&ig`15x{w4lgvhyx+2~(hX^vchg+hSW9s5`0(0CqJytO-u1Y z3|^Ub{kInZl)0q$ert1Dp>yeUd=FRYm7_{0b*E@HjCEagPKryxEK+U0<5%)3(a?qW zG(eb+$nKv$SZddtc|iIbt;Sm{o`vIT#en|p4nLfBs6I*2 zfE&C{0C8^B0P|nn4G>=(VO*AG|6ozhEL&B@$+h+ePvMwO@(8%K4^LC=sM|S@4vy#u zL?%^kP~0Em>8ID_xcfsV>KPwqp>B&9H450|MkuU|LbkyT6t3y{lPuA^+S;J zEaiCz^WKE-4HUkx)$-YoXjhl)$HoJ3Kxj(}uCdqxnzz)(uTVao(y?gb+S8A9mFVtS z*lExp$gVH~MVXpV?G_fMI4P6wDM-uM&7jLw?q1xz+WK%;V4c<&L)=aQU|Hok2Tg8R zvJY>r4=rMYRsp!o30mQ~>lWf8mtW{{3ia5zBJSg$NMv#C(By?oonxu~MKn@hyHx_f zcNT6VJs{HZZW`cmDR7EQz>0v;{5}RGDfNqvHG32kRhcPi7PjWxT~(;9g5PKvV}M{X zQ?I1KtsrWP+Dw}wq#rlg!9|01Z0vO>rMI>UpLB(mY z$+^Q2BbjQ8DTe#eIRb+!5yMUF`W-Y`J=K_wDkRca=~T<;OWcM}lN%=uzsvhM>o&{=S)HpjfJJ-WNwT(4)1uwjDoYDgx$xo#uWrwBSUXRF~r)u=WO2@Etf zP|rHoGz2No_zzDX7Ask<#(7($1P!%x`(%|0f-yKxC{YZP6GV}UCF)h}hh@wDVwGM| zs>WQntR@d1{N~X&Rj$!KS10xUQ=9?;hC>0^4}+pp71scND#H)$&}0@$X~6-kS9EDw z^*OT5wMXuSsdJH2?a`_0JiF)TUcyYlLu|xpg=(M+K3OPDxrcO5h=>q(XzBlj8IXK}J-W)p41Zcd4Tg;`46Z$`fkEOlpKem@(oOev+Yd zLW0hgT`eZbdb@5qUx95VsZr-AW3FUfWeUkzSa57)_H+~MS~T?QJ&5!w(Id5+=!|dE zYJ{?Q*uEr3GT|7dsKKc{3EeRx#YZRMHS?JToYhEOfWb30g4{p%gLT5PJAV|tiZ7@u z8`eSiuU+K1y;q9g9{i?N&|WAT63Y*MFWTSYVyp)*^Bn}Wghvs77);Hn{9;IUzoZz2 z7=O3zrl!C0K6B%@x>gy}$NZtnYDY65r)fstdQ;q6-2ah!l*@tHKZw6 zWI~wYF)xuaDn_gNu@MEO$U?KY>7{(;)w_OJToh+~JBKK;?5q3oH}x*Mm;`u;z!*W; zb9)JDTd2eJRZQoJ;M^t*r(?&_@OawuhA&V=HXO0~S;&10Z`v&K<;R@tRx7rSL0+A2 z1TzFkg=!`!46aw2f&{IBQeqdixuaDn3CGi`r{=({`=BCx5kuT?6TS%qp{&D)gwGAQX{r>lVEI=Lu6}`wohWx zeNaaBAEt;wD`_1bgVnzkM)kx0qs0RZccN%Qhj++Qv*n_fdk;hC*=#l0ZAD--MQ+4P1Q?6X~Z^=%+Tjafw2cW z1|iBv;xx-@>fNk>gjyC9rK4~J@S4oD8BrwSRVM{ieWRy?5Jt`>b)teAs>m7kf5_mG|7utJ@b(7Qk zVz;_amW9_p1sIZ$z0+#9J`= zO8?NuU+}OO^2T5vr?<8Cv=9)a_KxBbiAJE&lE7FHYr)s7(Y0q`U|^<|^XuchECjYC znKA>3!x^2P<(EMQ`Vjt#6O>JKIB6eBnAirBa=JlnFYLnc}TH1x@50m@| zAf({nC2q-I9%mdn70_&lEn{Qa%D!Vl%d%B##Q3}&GOC~q40 zk>b{VvmtJJ5fAp(98~^Qk!V-0ABTg3&*_lTb^j5BeJ546`=&uSrM^U6DrSj{hT!}1 zT0@wO5Ei81Q8=KsXBd$ew{X;UWcj`OLZXff`{I|K~F{@_mLx~KN@n)s{r zhm?mw{t(2b;0L2tR$Ol=(RUx7iW0uM8J_wws7rK%HCtB)6=L5kB4}(aFZ=C1?RtVs zFWwa6oKZMzD*S=|m4w)!Y}s_4V35I*@0|hNUahXAToBm`!Thb<9&$km`e*5Kr#rB1 zoU!!weNUB9%?{R;k@N$zlFH`1i(kon!#zjJ7{0KsX;reXF0x<35!mcyh=FVTeFDpQ zzWqK8S?hRmjyi@t+!Y4^!^=k&3cC^?jB4WeLy7>$Uv2?KVzE;TB-dHD{!LO{-#!gI zcUWT;Ic~wDD;KbOsc+JrBs^bEvpA$p<0Io5OoA@V#4tUX5TR~Rm#{@51w80mcwBu>>#Kx^%tZ8wm$fu6Nh`Q-zP z!YUHWiIwG(>v*%z;Uy|gw)l#5X|cCvCUdWNHmeG?@wKz_d_Op))A$|18Nz6(eyOCY zsI0Q7pa(lwfZ;nB4mjz$|V?UfAt;1`Lno(4aDr`!xJPauMK(eHbbXaset;uf0ZsDDoKgKfoGk0G#N(uo!F0DUDV@9bCZ7KDthpaH}Qqe<~>oqNb|_o?o~d z2Kz{-60pO$CsUfG8c*`nbJh$%3E5d1fZngs+22Cp8mpezW4KGGw-#tm?n9Opevd3jcYEc;&kfxWEZ=vC~{!mk*UiWfe`wnLAo6Gb# z-hCH17L6Nif0ltbbe$bJ?iD0y`m+vL)Z5c z!-PQU753MX>XLYY3=(^vwE3Avy)ITNZLuu!;C|UoYe#@}Pyq}Jj9H6meZqZ?6|%l8 z#2c(Ky|x6n$1rBZZN|%RO#Zt>->3TGnOwj~160};<6{Ueag;#$kR>1F7je(#g28-@&1)22mlW~KNht`&DxzG+H+Gg=oJHctF@A&kkACK~rOQA|KViZLqe8r*nm1@A=_FQn;+Rj1 z>)QUMA3VR-ru>Q)?BfP>8ptCShZpt7b8bF5uP?h-|N1#v;@|^OYOoU7l;8{RG|yzs z22>(a0mG`SXssw~&RZ-qP#rWfdsVkiheu3N<;ebf-Q}PcY?1BR0re5TP;uX|;9?AH z4HSC6YI(R}{YXb&!e#>bFbG&SqDM2Vyj}tNEWJPw{bG({pOe`e1Fh$P_K%`w8V*m7 zmH3~a9!%YN4j*6hY6DKZvnaWTDGkK8D72G_{!25+LCp}q@KindRiB6;w_MQJB92y% z?nBsVW#M2FJCn6L}c^o-Mwht(Y7jM}$NoK33Iq@><%cg{@C(>%@8h9nIK zl17_;NRR!~oK`)^as*^&y@Zn}ARKRirox2myot%0O&knq_L0iuO~G)OCF3@+eh^~7 zH*`0gA&X%kD@}orrr9Ch0=i^9pw2J7iHDOZc;+jF zYJzRK10`go+-VS|xi@+hL5Gaby=0$3=6od-QmSOu-O+{oV0Vv=bvUyT6|x z=fOlMi2jG4!cH&uNvmocnVA5hc(G=Y5JJL5kNa07UMf>Bo1_gvv-wY1YNa2t*1F$n zAk^S9jjeHTkRly_=IA@i>)^CeQSi61Od!x*WT45x5B)(H;Zr#3b9+C3cVo7Or-6i) zJDt_o1~51(hYYU_s52Vh0O?WmsT>q}`wZ^xyPy0zN)AQWf*~S5H9w;xD@n+Reh%O% ztbi*7Ei0lnD^v1AtZtV=))lqS0}_uSB+J^`Q*Cy`*vhUqO(G;E$h-eT@~B7eniyMI zEB4Fnriyls#zabOMySgsC1br)4o(WEP3pNDB>0AZ{~ zfx3T8wCX4%1@N5vg${k8gY5WFC%XQ#3`a*=d3k6KyG+&TXOvlV49Q>Jx6aTrXDqs! zLga0R5fbBzF`|Kw5oxA}==IhK;?15|CI6BU{kpjvUx@$K8XnhgXvG4gWOG-Df=96i zmSxyHUkaP?<5ZDnTS_qSps5?uO{k6Z9g0@MA`pvp%WY^$F$%|@1%6QaG9X1~@6hhk zAqavy8h-{`eOItl#86^(OqV-6!xcf4-N|EPB0Xfl@-!!tuk2W23>qz#myfOj(nESN)a!3%ePD$1M{*@qhwL(U|YJufHB( zN_$qG(5NX+_sEuFLdn0m_-R?Hz*t6rjZjAJev)K*X%(0n;d zsoZ`TJkfWnVo{P@oA%Z6l(T|LS&TWec+HPs&k_dqT8=Zi0mrALD~v{p3TjlR{sI6-&;3H?xY<7fW+O zt_52J(>PDi(AZQ*%VD(OG#D_mro#|nZ|XN^bMn{~;gNl#fz4LLhz(mA@5~r=GbUN^ z`jsYD1J&_~R+Mp{O?(L1=ew=W5cdVzUUe@Z=~ty5N@XBQLZAfKm$P+GWYm4hv2`EF za0p4+2DGsC3w~<_4E+{V!aedpWaG0NO7`(%c?xyp<~`H9ogUspiHMsLee^~7%I~}H z47u9~j823r%Fa<5bHv7KKk_m_>P#7VDix+(Hy@O<8Zf5v38UgiK_fC7wneUtL#$>r z9AYFofphG;QSpFBQB^i(C)WF+cM_XRU*@AU!F+MZMn{SeovzjU#m^>$4JYyiGd><) zfjUE~IRn2k|-35aB8D&fUN_@258fwK_q1VF zv?o;3AEKU}t-q5ct~ccq8akd~Nx%}eTkh~eQ&se7jW6!^82+`JZ&1u5>iw5O8!Ckn zA3yim^a2Gc#w_B6V{55$0H?Wmdf!827&AZN(|HH+DWG*LYTtg)f?id9mU;-aABx}q zBee}2Xjm28KSjr6c^u9vlP_t7wKtK1QVIEgEC9`cnlaQq`*#$_lbFal0{)~sc$1o^ zxJLKbN^*OCNg*t>UNk&eZ}&Z4?UjD*c;G*v&dONsfocP*IRm4a#g$a$=C%l=c!9G) zuFY14eIk_p8fdqo_?grd@KNG%ZT{YqTMQ4?XcYeYGNHs~pwRu7EPRQprWDmq2&*bh z2a3L|OGy|HX7gNQ!UsgO!u)&LoFP&7U%kfME|J^!3DWkZX6opZPAs_RE#R{jZPA=$ zKKE>An(Dthc(RZFjySdK-(Bwkst&bLo~hLzqvmf6tQ*jKzKFOQYTq&Be;>}QUz&x#H=CM4nW2>Pa8*k zsW78_W54QijN3=hTRp6+ux;ei?EKM=^oz>)??A>{(i-(BTov8px(49E2(tLv_HZby z9z&)EA9R(z8ZhmZxzVP&WpzW`o_{9E#Kvp*bNg(-_+=6(0{?deKwdfGWbjdFX79Sq z5c$?+I-6$mh|qo$`rE^k@5J(V4zyYQhw4a0G-`#08uMBhbe8Gv^k&f?=7U!J<$e<#drqLOTw`cq5;4_UB6uG!Y{TH)$B`Aj^(N zs0Cr9?*`eTZiFJ&fd365+RX_}J|Ck>d`Y}laqkJt&2y+l4TJUGyFm%dP z$G*6W!YSBvq^C-2ZsRopOK+4rV!d8FD^S4ET$^aSFgQ{LAQkSoonFP9>i@8Yvtdhm zJU!mT06iN>kKXlEOGh^rgm`Je+L1b*g}+O~d^-0GA;W%kdpRD+Xl~jqWgEtWES0R$ zc~r)NZGoc9fb1l^cRP~9*#FqqKrXiWz=)v^WcrH);i~igpPT38KW>r|Fy;7UpB`s< z^6~fO-X}QI*z+Stw|A~VlO1}v?;}H^A9_QKQSRD##da7+$*7}_dx#oF=XE=VH}cTB zKPY@r89T$=OT~dtn}}K@ITfy& zWH-CR7Y$k0rznZKO6N`sLsh8R^&h1)=DSpN7(Ja~jmZ(3$lQPvoKN2`t0b~sV|yiU zrufgU8pJ1!y$4W4*#4J1hcd8DN77QrNOsZ=96n5CEL}M+o9pJx%k3^&|HPzo12o|U zc3KFUX`JVt?tEv!ntFQ;W4LkzXwPHjGDshj+U2D>4$%oYRAnrZUMO&mZ9?+tiUe}N zt4?;Wd?>~VH3z{HRzGmg(t~71F`Uef(Zb^c|#=)va2btJ;qv9dsHuo!J+QkPNkwX5er{BNlCSc^Wp++SrA+4oMos#D` z7z7rTOJ_S^3*h!CAg3vDX$#s5N>FJ`R9mCk$?N|bvVZoXvL!3!k@~O7@T0H0qa*n% zyHk0rJwC}=h1oGLCNeN}b#2mtSVnPXLR=L~!LxNA9I!v?M?3_i2&-4dMa*>_X+svY zEKdLn{w5_^%&u7+APJ?lsl7{5wF2VRTzWQ;urRuPd@+DIv<78&3wgNaMg}Yq!Q<9| z+*T9%Xy#(_OQL`2LXPd4rZSoKedbs}>!f6hF4ow&S_kQeciC<+;T)OQFwOM3b_p4< zTh80zJHPRaat9sc(1q%C+u!*|KgQFmvR77C{lt=7u_ZzyDEB?Qza@!PgeFuYRkNhy zfLt0~m~$i>^9f;{J9f zyq3OBe>{Z~>UZl8&B(;zxO%GWjD6}5a;t!2!ySj&;`^oGKFM~nRAcnSxgZ^%9z?xI z0$b0qe`;{K%nq?FKY-s&+$gy_c;>5mHS)E(pOPBT!a|S2{Xc8VDq?5dU_efNK0jBH zngQ{*o9fUjL*BA9!O?$nFpC*EVI|uTi4}-i%5Zu0SY4d7<%flL!^AuR|)G&`wVr0srlit-(?zIdOiP8VKVo@q6a}ti+x425sZuF%mw~}9voHL&2Z#x9$p?E z86rs)eTC(j344C~dui^@sO-vY88XoShMw8<;#@=NZbF&#NN zG|dE#r5_Qk1Zjp)#h792{=z`sN_EC1opH;I@Agis=+`K6Cv&cY3QtH;^Cfxs!#xjjRB!xBYZgF->$| z;eb7oni6bwsOTV29Y63h$(p?deXhPZlsi_yOv(dmU*Bz)NroFK51~!C&Ye`nyBhW&wss}9lL+L8qR$|wOXT5`~}y4`UeE9&IBp= zSe$25Wb+)lWhIU*O%D>j;;BvdW^c)|R|)#V##=ofN=E zo$frbsztBOYD59&0Vk1v(EKElKWyo9Bvbp;kPdz@K6ONDhn2NKVK*9EqzYBTItgm| zR|d);C_^pA?_bFNvWZ$|*;werU8J_AX4mCbde4hQ%xNqfu3#Zt%_0ThDXssFg3!)- z-W}Tdl9Pv#uM}EMM)$jxC%Da*F+X*BlJ#n^2bM-fuhZbEM(Ujp_A`bBwc|J|k}SsV z2RN>5(s!cCar0tq;h_w07W0DiOM!+3^#`$1yE--cFn5_GHDt->D zKZ&ZBmW(7N>(f6#tA4{b>7KfFvh3LAj+l)dS{rLxmDyBR0mW6X#8zL`bpkgBE1Gha zdBO z5P8phc;?E~t|+o>GADn?rEtmZaXC_sM4e1z7t!u0T4C!&03TXA0*#SrpV(9b+ANRa zBgMXeki}7f4tNt`+8=425(0D7OQ=~(=G>U5LD}FviabNm}giFflDL}fpB?}IGr}-UYPb{y+O-tI$$NW;A)#IO zgRmgbgI56R=^;NZ7sTrA8N4E;z}$X%O(az?@@WvTq2>1mW|KyH6rF7=ElGA%^3;on zj$YU`gH~I>qYt^q)TEbMV)w-a1xII$FWMUjdspV!)6*3&iH+nYL2WyeHq`FSL(97< z3J)Q+;!0b=KV%Vt6RWDq3|n zXm*!xNMC8qRPRn01q`I#5&FrhVkCt}Y$MzbOZ!CpXBP|c-Zv_&G|y7<&G_$KM3Q}H z6?qPkgr(HCbkvEgtiUrcP8t^^Qk)QnHk0Jgvi3MF_yM!s)LOM%i@$qmxh?^8>05@6 zB|jYw3muw#DW6;WZbT@PrHHf(;8mEDP-aN$3MqkY@%D?2EbsGm>D45zYzH;%I<&gk zcJ0e&DwZul#_+dbBz#gbi!pgZ;ULg9t-TwP?1TQqC*a4BRb#SP2Uz0L5c&LaaqFJ> zJ6IBUrkw%J-yiMkSavOqG$yCll&j$QtXZ>`R8k)Nw@1X}nJ;6jkv%Rr`e~f;#S=&Bq}77g{j% zMgUdV^M=n$fMXRGUTeQBaF%{TV9$#|aq@eDHE5(eJh_s4F=xG90WZ78J#&Db{#*50 z0#Y|GPM-J0D7?s4^>J?dUi(hKJY<)YPe{6jH48~2DFNQGYEjY0{x@Bp}4!UzKnpS%u&-bB(m;a7H6ZeHJg#FQMaLevZ?l`7eXWlZLx)syQ_bg7g2 zBJ>7hJX7RejhHp|u=U+x5QIen{u>*BBK5y{0e`PJGX(v4TIUUH*dMXDA!RY?esN?D zq)yO9!&72^*grnNKJTh011JS_oSd+#4XbVC-M{skv^BK5l@!7d!^o|uDZ(=L*`VV2uS`zdr87j z7^ak%xVO;zL_4uRXAovD*4H5>;#SBWtfOhbueW#Jy+Wl7JS1~{htLe&Q;n>%a|TO> z!km=!v0Hjs4hKmjAC{V7Yq5tjbtcLL*$7?MR)hTNOn4>lT$TYqTup5MxkYn-YQQ6~ z#}I=`{;&2=CFw^OCqLv|5iMHbNi6B!N^UYKpYF0wOkvVU8l|Xe<>;O+;F8rpqVd-Y zav$jgi|U%1iKR=QqfX=?auRj{oiMObxA7DXgG^lYfJ4q@@7iFFXIsLKOqIvIpi5#Q z$`PRmM~lM7%Qd6L(VrEK(8_xn_O`4Nqc`LR3DA8)AXu$ag;w3z$DEDqQ%qHj*$-W* zg@*4g`{ylP+)Q@nQ-vu=gE^7jC>bt;KPk|}AK96a*5ULM)8?cK<8Jt%eatCPs2AVC zFdyTn{$3`sqbi&Gxh>^Ydci>BROUbd^?B9lkp$2$fwkMyt;GFDpij1}l1N$u%l_%m zjkzg%;NicSmC*XP%^7_!dG?=aBqH_zku$wwWA*20ov4-+R5wTA#Yh?c>sTRe!7TmS zy-7ctr7eG5$wP&~C(`nB+`PIsHRn9RQ@4v)e03$WEXY1Bg0KClC~;XobmrT#Zcas& z8_e!!T|bZ;Y7#|6h(XinDkRmS>)~RZyhNo=@bIy3wErI!@t3L5R~d;>$|JmKd!$7| zLH}HU=3`|>G9sAImR@K?&b7`jt0?~6=I-%T^Tk`BMcpY?FKGbx9D0F%p{rdp=T53~ zmq+&som4#seaRC(3|U-JbA7XzKyJxRMl!KXPa5lu5wT3xD0C>W^xlm-i71RAe@23X z1n?9v;4NXAyrguK*AXXdP?bKadMpark=t0hz16CCbA_th~84M1jHu zBD1^ixXoy1%j{tkjd?l(Ik*SPSUTjb?HtSy)4+<&h*Wqg@X55wqb&X(_gM!B(>KM` zzomj0|8QIDT+($&GaE&z1x|dVmdZ)tkhkVj1X29PW)ZSbJKVGG1{-sDxSp}Zh@Rom)Yr{hV_h?N# z=_Ydek$NvVvv*h1WNqFo{K>AqfAnJ9f6cGI8c2332#E4QaRQ5OX&$EGE3u1Q_R{fz{OXvLXDlLg3Ew@ zd@c|L1A7if>=|~@SG6_PpWd%HaHzU>zIdNg)pa@UqW$5O*?#69W{h*rNEf`OBXb2c zMfJ{3G4!jHFG@3qYc}<@sM3q-D*TF%rO0Y&5Aj^*srJY7l;NkVo&+RlKvP9xohBo8 zf%5M@Y3E_U$hC}g(SXKt+8#8#X4@`a38b9MtR^)7DGZlW``!{01~J2#dr+ml=2#Ag z$V_5*1KC10z})Xz;`DEd8EZs5eqOw7Y3yc7bZuf5GUyFQna{UGYb9W0^iPju-i<&h zRYa6PtFPH10=&*1LDAAzR`GHJN>6_}etx^b)2{42sH!+*WTL|pVP*+(?PObmlgH_L z?oETdA)E@^n4V-GE3O@cOV4*aPvy4dWheX@Z2(Pm$GK=j;DFqe`Qy*w)vjm9(d=6t z9R@ekUUPWNy&}@Z{iAr}aa+Dx(Shfygda5k&%&dq4E2mn0O-EdBR>Jj=pLg_%A{Ev z?+C*o6qto0Gld&sew@x@!Rbu<7s0H@AcbjB#ia% zT(}K#QAlq}@%Bv82|qLHc>K8`#Cq_^!HV7M*OK&NS$7Dzm{$~)!{=mxW^SeN5c^1c z(^etjOf}t}6-h7pkCszmlt^JC1t~AL>km}HYP<8su1B!n%}VNyv~>omdRr8}@60fd zE9Y=KCk8u1zyt^MfOnL|g_6n*4dP9cWZ>nSvUnJ~Tq~cwXJ;5&&2+-TF6f9{Fnq0A zwT%#vbV7hIwxh|h?bW!NvBRVcdaL+(Z~oTa$3Iz|Ux+YzJAy7(Jl1chylhcR5)WjW zm8v}^6R;u6qMvW=?ug1wb>r^(wlZhup%ynM!G2))34cksYrdZW-v&Z+FSB!pQ%QNp z7bhm!!C!OyF*@(Y$O6On)*hn*byAV>pY*5MJgNte3WJ1yRIgU3Gk#0%IT4l&a2r;a zYOTZ~h29&d!RJWz9&T4vo*oOXtCwe@e^@xS>=oHoTwpo@9YV)!WO{Y9Qr!N-oLm_D zrus9<&Ph&7w3aSpm2MBOJb%iPtuZ3EyU zGq6C&CXVU+xHzLo_5#Y$-wK|mTDi8K@_(6z)(3nTQj^~+h!|}sOPc?vaFk(eRv~5q z!$UR!r6uyC{1_UHBoXnCBDv^`H-}3gZnGCoRT6ShH5l7V5u=AoE3yM81qPZG0vZ<#(PT* z=+|wz#uV5fKK@|j_zMB`Rq+v}3IdFpe&TQ%C5=-?i56BWhEZbS-2K-KLL*dN)96k=IM${lA3&&mT1P n3x4eU90u3Rxp*-Cw@}d2olD}!(CyT`iwK>eo~bTg$0_Q6>AlEl literal 0 HcmV?d00001 diff --git a/website/public/builder/shape-thumbnails/tetrahedron.png b/website/public/builder/shape-thumbnails/tetrahedron.png new file mode 100644 index 0000000000000000000000000000000000000000..3bf87229b21890ca25bd876d30db3c36002e28f5 GIT binary patch literal 4343 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 URn6nsqs|$CzK*eWt)>&^Ka~sT!2kdN literal 0 HcmV?d00001 diff --git a/website/public/builder/shape-thumbnails/torus.png b/website/public/builder/shape-thumbnails/torus.png new file mode 100644 index 0000000000000000000000000000000000000000..4023481002593a2506f8613cab4a7682c989d2b8 GIT binary patch literal 18409 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 klWd8LOQ&ED;Ggpf3buX7SpWb4 literal 0 HcmV?d00001 diff --git a/website/public/gallery/glb/urban/Rock band poster.glb b/website/public/gallery/glb/urban/Rock band poster.glb deleted file mode 100644 index 2059d81021bd1cea29edb8007f80e9576b62ba3c..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 12676 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 shape.generatePolygons?.() ?? [], [shape]); + const meshRotation = shape.id === "builder-shape-dodecahedron" + ? [0, 45, 0] as [number, number, number] + : [0, 0, 0] as [number, number, number]; + return ( +
    + + + + + +
    + ); +} + +function App() { + return ( +
    + {BUILDER_SHAPE_PRESETS.map((shape) => )} +
    + ); +} + +createRoot(document.getElementById("root")).render(); +`, +); + +await writeFile( + htmlPath, + ` + + + + + + +
    + + + +`, +); + +await esbuild.build({ + entryPoints: [entryPath], + bundle: true, + outfile: bundlePath, + format: "esm", + platform: "browser", + jsx: "automatic", + absWorkingDir: websiteRoot, + sourcemap: false, + logLevel: "silent", +}); + +const server = createServer(async (request, response) => { + try { + const url = new URL(request.url ?? "/", "http://127.0.0.1"); + const pathname = url.pathname === "/" ? "/index.html" : url.pathname; + const filePath = resolve(tempDir, `.${decodeURIComponent(pathname)}`); + if (!filePath.startsWith(tempDir)) { + response.writeHead(403).end(); + return; + } + const contentType = filePath.endsWith(".js") ? "text/javascript; charset=utf-8" : "text/html; charset=utf-8"; + response.writeHead(200, { "Content-Type": contentType }); + response.end(await readFile(filePath)); + } catch { + response.writeHead(404).end(); + } +}); +await new Promise((resolveListen) => server.listen(0, "127.0.0.1", resolveListen)); +const address = server.address(); +if (!address || typeof address === "string") { + throw new Error("Could not start local thumbnail server"); +} + +const browser = await chromium.launch(); +const page = await browser.newPage({ viewport: { width: 560, height: 420 }, deviceScaleFactor: 2 }); +const pageMessages = []; +page.on("console", (message) => pageMessages.push(`${message.type()}: ${message.text()}`)); +page.on("pageerror", (error) => pageMessages.push(`pageerror: ${error.message}`)); + +try { + await page.goto(`http://127.0.0.1:${address.port}/index.html`, { waitUntil: "networkidle" }); + try { + await page.waitForSelector(".thumb .polycss-scene b, .thumb .polycss-scene i, .thumb .polycss-scene s, .thumb .polycss-scene u", { + state: "attached", + timeout: 10_000, + }); + } catch (error) { + const thumbCount = await page.locator(".thumb").count(); + const bodyText = await page.locator("body").textContent().catch(() => ""); + console.error({ thumbCount, bodyText: bodyText?.slice(0, 1_000), pageMessages }); + throw error; + } + await page.evaluate((targetSize) => { + for (const thumb of Array.from(document.querySelectorAll(".thumb"))) { + const leaves = Array.from(thumb.querySelectorAll(".polycss-scene b, .polycss-scene i, .polycss-scene s, .polycss-scene u")); + if (leaves.length === 0) continue; + let minX = Infinity; + let minY = Infinity; + let maxX = -Infinity; + let maxY = -Infinity; + for (const leaf of leaves) { + const rect = leaf.getBoundingClientRect(); + if (rect.width === 0 && rect.height === 0) continue; + minX = Math.min(minX, rect.left); + minY = Math.min(minY, rect.top); + maxX = Math.max(maxX, rect.right); + maxY = Math.max(maxY, rect.bottom); + } + const maxDim = Math.max(maxX - minX, maxY - minY); + const camera = thumb.querySelector(".polycss-camera"); + if (!camera || !Number.isFinite(maxDim) || maxDim <= 0) continue; + camera.style.transformOrigin = "center center"; + camera.style.transform = `scale(${targetSize / maxDim})`; + } + }, 86); + await page.waitForTimeout(250); + + const thumbs = await page.locator(".thumb").all(); + for (const thumb of thumbs) { + const file = await thumb.getAttribute("data-file"); + if (!file) continue; + await thumb.screenshot({ path: join(outDir, file), omitBackground: true }); + } +} finally { + await browser.close(); + await new Promise((resolveClose) => server.close(resolveClose)); + await rm(tempDir, { recursive: true, force: true }); +} diff --git a/website/src/components/BuilderWorkbench/BuilderWorkbench.tsx b/website/src/components/BuilderWorkbench/BuilderWorkbench.tsx index bcc8a81a..978fb7d9 100644 --- a/website/src/components/BuilderWorkbench/BuilderWorkbench.tsx +++ b/website/src/components/BuilderWorkbench/BuilderWorkbench.tsx @@ -1,35 +1,103 @@ -import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { useCallback, useEffect, useMemo, useRef, useState, type ChangeEvent } from "react"; import type { PolyFirstPersonControlsHandle, PolyMeshHandle, PolyTransformControlsObjectChangeEvent, } from "@layoutit/polycss-react"; import { directionalFromOptions, ambientFromOptions } from "../GalleryWorkbench/helpers/lighting"; -import { PRESETS } from "../GalleryWorkbench/presets"; +import { labelFromFile } from "../GalleryWorkbench/presets"; +import type { DroppedModelSource, PresetModel } from "../GalleryWorkbench/types"; import type { SceneOptionsState } from "../types"; -import { ModelsSidebar } from "../ModelsSidebar"; import { StatsOverlay } from "../StatsOverlay"; import "../GalleryWorkbench/gallery-workbench.css"; import "./builder-workbench.css"; -import { SCENE_PRESET_ID_PREFIX } from "./scenes"; -import { DEFAULT_SCENE } from "./defaults"; +import { BUILDER_MAX_CAMERA_ROT_X, DEFAULT_SCENE } from "./defaults"; import { usePlacements } from "./hooks/usePlacements"; -import { useSceneLoader } from "./hooks/useSceneLoader"; -import { usePlacementMode } from "./hooks/usePlacementMode"; import { useCameraShortcuts } from "./hooks/useCameraShortcuts"; import { useSceneRender } from "./hooks/useSceneRender"; -import { useSidebarItems } from "./hooks/useSidebarItems"; import { useTerrain } from "./hooks/useTerrain"; import { meshBbox } from "./geometry/meshBbox"; import { placeMeshOnFloor } from "./geometry/placement"; +import { snapWorldToCellCenter } from "./geometry/snap"; import { sampleTerrain, rotationForSlope, type TerrainVertices } from "./geometry/terrain"; import { BuilderScene } from "./components/BuilderScene"; import { BuilderSceneOutliner } from "./components/BuilderSceneOutliner"; import { BuilderCameraModePill } from "./components/BuilderCameraModePill"; -import { BuilderToolPalette } from "./components/BuilderToolPalette"; -import { BuilderTargetMode } from "./components/BuilderTargetMode"; import { BuilderDock } from "./components/BuilderDock"; -import type { PlacedItem, TargetMode, ToolMode } from "./types"; +import { BuilderToolRibbon } from "./components/BuilderToolRibbon"; +import { ShapePicker } from "./components/ShapePicker"; +import { BUILDER_SHAPE_PRESETS } from "./shapePresets"; +import { + readBuilderSceneFromUrl, + sceneOptionsFromSerialized, + serializeBuilderSceneToParam, + updateBuilderSceneUrl, +} from "./sceneUrl"; +import type { BuilderToolMode, PlacedItem, TargetMode, ToolMode } from "./types"; + +const TILE = 50; +const BUILDER_IMPORT_EXTENSIONS = new Set(["obj", "glb", "vox"]); +const BUILDER_IMPORT_DEFAULT_COLOR = "#8b95a1"; + +function clampBuilderCameraUpdate(partial: Partial): Partial { + const next = { ...partial }; + if (typeof next.rotX === "number") { + next.rotX = Math.min(BUILDER_MAX_CAMERA_ROT_X, Math.max(0, next.rotX)); + } + if (next.target) { + next.target = [next.target[0], next.target[1], Math.max(0, next.target[2])]; + } + return next; +} + +function fileListToArray(fileList: FileList | null): File[] { + const files: File[] = []; + if (!fileList) return files; + for (let i = 0; i < fileList.length; i += 1) { + const file = fileList.item(i); + if (file) files.push(file); + } + return files; +} + +function fileExtension(name: string): string { + const clean = name.split("?")[0].split("#")[0]; + const dot = clean.lastIndexOf("."); + return dot >= 0 ? clean.slice(dot + 1).toLowerCase() : ""; +} + +function importedKindForFile(file: File): DroppedModelSource["kind"] | null { + const ext = fileExtension(file.name); + if (ext === "obj" || ext === "glb" || ext === "vox") return ext; + return null; +} + +function importedSourceFromFiles(files: File[]): DroppedModelSource | null { + const primaryFile = files.find((file) => BUILDER_IMPORT_EXTENSIONS.has(fileExtension(file.name))); + if (!primaryFile) return null; + + const kind = importedKindForFile(primaryFile); + if (!kind) return null; + + const label = labelFromFile(primaryFile.name) || primaryFile.name; + const id = `builder-import-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`; + const preset: PresetModel = { + id, + label, + kind, + category: "Imported", + url: "", + options: { + targetSize: 60, + gridShift: kind === "vox" ? 0 : 1, + defaultColor: BUILDER_IMPORT_DEFAULT_COLOR, + }, + galleryBucket: kind === "vox" ? "Voxel" : "Solid", + attribution: { creator: "Local file" }, + }; + + return { id, label, kind, primaryFile, files, preset }; +} /** Re-anchor a placed item to the current terrain at its (worldX, worldY): * recomputes Z so the mesh's bottom sits on the sampled surface (with @@ -41,42 +109,47 @@ function snapPlacement( item: PlacedItem, terrainVertices: TerrainVertices, gridResolution: number, + snapToGrid: boolean, ): PlacedItem { if (!item.rawPolygons) return item; + const [worldX, worldY] = snapToGrid + ? snapWorldToCellCenter(item.worldX, item.worldY, gridResolution) + : [item.worldX, item.worldY]; const bbox = meshBbox(item.rawPolygons); - const sample = sampleTerrain(terrainVertices, gridResolution, item.worldX, item.worldY); - const position = placeMeshOnFloor(item.worldX, item.worldY, bbox, item.fitScale * item.scale, sample.z); + const fitScale = bbox.span > 0 && gridResolution > 0 ? gridResolution / bbox.span : item.fitScale; + const sample = sampleTerrain(terrainVertices, gridResolution, worldX, worldY); + const elevation = item.elevation ?? 0; + const position = placeMeshOnFloor(worldX, worldY, bbox, fitScale * item.scale, sample.z + elevation); const rotation = rotationForSlope(sample.slopeX, sample.slopeY); - return { ...item, position, rotation }; + return { ...item, worldX, worldY, elevation, fitScale, position, rotation }; } export default function BuilderWorkbench() { - const fileInputRef = useRef(null); // Imperative handle for PolyFirstPersonControls — read by useFpvCull to // pull the live camera origin without round-tripping through React state. const fpvControlsRef = useRef(null); + const importInputRef = useRef(null); const [sceneOptions, setSceneOptions] = useState(() => ({ ...DEFAULT_SCENE })); const updateScene = useCallback((partial: Partial) => { - setSceneOptions((prev) => ({ ...prev, ...partial })); + setSceneOptions((prev) => ({ ...prev, ...clampBuilderCameraUpdate(partial) })); }, []); const [gizmoDragging, setGizmoDragging] = useState(false); - const [gizmoMode, setGizmoMode] = useState<"translate" | "rotate">("translate"); - const [toolMode, setToolMode] = useState("pointer"); - // Default to "face" — raising a face turns the whole cell into a - // flat plateau, which reads more naturally as "stamping geometry" - // than vertex-target tent-shapes. The user can flip to vertex for - // finer control. - const [targetMode, setTargetMode] = useState("face"); + const toolMode: ToolMode = "pointer"; + const [builderTool, setBuilderTool] = useState("move"); + const [selectedShapeId, setSelectedShapeId] = useState(null); + const [placingShapeId, setPlacingShapeId] = useState(null); + const targetMode: TargetMode = "face"; const { placedItems, selectedId, setSelectedId, - placementCounter, buildPlacement, + buildDroppedPlacement, appendItems, + replaceItems, updateItem, mapItems, handleDeleteItem, @@ -84,55 +157,78 @@ export default function BuilderWorkbench() { getMeshRefCallback, selectedIdRef, handleDeleteSelectedRef, - } = usePlacements({ meshResolution: sceneOptions.meshResolution }); - - const { handleAddScene } = useSceneLoader({ - placedItems, - appendItems, - updateItem, - buildPlacement, - placementCounter, - dragMode: sceneOptions.dragMode, - fpvRenderDistance: sceneOptions.fpvRenderDistance, - targetWorld: sceneOptions.target, - fpvControlsRef, + } = usePlacements({ meshResolution: sceneOptions.meshResolution, - updateScene, + gridResolution: sceneOptions.gridResolution, }); // Terrain editor — engaged when toolMode is anything other than "pointer". - // Declared BEFORE usePlacementMode because placement reads the + // Declared before shape placement because placement reads the // heightmap to land meshes on raised terrain with the local slope // tilt. The grid polygons in useSceneRender also consume this so the // floor grid bends with the terrain — there's no separate solid-fill // mesh anymore, the grid IS the terrain. const { hoverPolygons, vertices: terrainVertices } = useTerrain({ toolMode, targetMode, sceneOptions }); - const { - placementDraft, - ghostPolygons, - handleAddPreset, - loadingPresetId, - } = usePlacementMode({ - sceneOptions, - appendItems, - setSelectedId, - placementCounter, - updateScene, - terrainVertices, - targetMode, - }); - useCameraShortcuts({ dragMode: sceneOptions.dragMode, updateScene }); + const [urlSyncReady, setUrlSyncReady] = useState(false); + const urlRestoreStartedRef = useRef(false); + + useEffect(() => { + if (urlRestoreStartedRef.current) return; + urlRestoreStartedRef.current = true; + const serialized = readBuilderSceneFromUrl(); + if (!serialized) { + setUrlSyncReady(true); + return; + } + + let cancelled = false; + const options = sceneOptionsFromSerialized(serialized); + const restoredOptions = { ...DEFAULT_SCENE, ...options }; + updateScene(options); + + void (async () => { + const restoredItems: PlacedItem[] = []; + for (const item of serialized.i) { + const preset = BUILDER_SHAPE_PRESETS.find((shape) => shape.id === item.p); + if (!preset) continue; + const placement = await buildPlacement(preset, item.x, item.y, { + scale: item.s ?? 1, + elevation: item.z ?? 0, + color: item.c, + rotation: item.r, + }); + if (!placement) continue; + restoredItems.push( + snapPlacement(placement, terrainVertices, restoredOptions.gridResolution, restoredOptions.snapToGrid), + ); + } + if (cancelled) return; + replaceItems(restoredItems); + setSelectedId(null); + setUrlSyncReady(true); + })(); + + return () => { + cancelled = true; + }; + }, [buildPlacement, replaceItems, setSelectedId, terrainVertices, updateScene]); + + useEffect(() => { + if (!urlSyncReady) return; + updateBuilderSceneUrl(serializeBuilderSceneToParam(placedItems, sceneOptions)); + }, [placedItems, sceneOptions, urlSyncReady]); + // Terrain-follow: when the heightmap changes, re-snap every placed // item to the current surface at its (worldX, worldY). Note: this // overwrites any user-applied gizmo rotation on the next terrain // edit, which mirrors what the original placement does on commit — // keep terrain shape stable when fine-tuning rotation. useEffect(() => { - mapItems((it) => snapPlacement(it, terrainVertices, sceneOptions.gridResolution)); - }, [terrainVertices, mapItems, sceneOptions.gridResolution]); + mapItems((it) => snapPlacement(it, terrainVertices, sceneOptions.gridResolution, sceneOptions.snapToGrid)); + }, [terrainVertices, mapItems, sceneOptions.gridResolution, sceneOptions.snapToGrid]); const { renderedPolygonsById, interiorShellPolygonsById, renderItems, gridPolygons } = useSceneRender({ placedItems, @@ -143,9 +239,6 @@ export default function BuilderWorkbench() { terrainVertices, }); - const { modelSearch, setModelSearch, modelCategories, modelTreeId, isCategoryOpen, handleToggleCategory } = - useSidebarItems(); - // Derived lighting + perspective mode for Dock + scene rendering. const directionalLight = useMemo( () => directionalFromOptions(sceneOptions), @@ -157,24 +250,98 @@ export default function BuilderWorkbench() { // eslint-disable-next-line react-hooks/exhaustive-deps [sceneOptions.ambientIntensity, sceneOptions.ambientColor], ); - const perspectiveMode = sceneOptions.perspective === false ? "orthographic" : "perspective"; - const perspectivePx = sceneOptions.perspective === false ? 8000 : sceneOptions.perspective; - const selected = useMemo( () => placedItems.find((it) => it.id === selectedId) ?? null, [placedItems, selectedId], ); - const handleSidebarClick = useCallback((id: string) => { - if (id.startsWith(SCENE_PRESET_ID_PREFIX)) { - void handleAddScene(id); - } else { - void handleAddPreset(id); + const handleShapeClick = useCallback((id: string) => { + setSelectedShapeId(id); + setBuilderTool("add"); + }, []); + + const handleBuilderToolChange = useCallback((mode: BuilderToolMode) => { + setBuilderTool(mode); + if (mode === "move") { + setSelectedShapeId(null); + } else if (mode === "add" && selectedShapeId === null) { + setSelectedShapeId(BUILDER_SHAPE_PRESETS[0]?.id ?? null); + } + }, [selectedShapeId]); + + const handleToggleGridTone = useCallback(() => { + updateScene({ gridTone: sceneOptions.gridTone === "gray" ? "dark" : "gray" }); + }, [sceneOptions.gridTone, updateScene]); + + const handleImportShape = useCallback(() => { + importInputRef.current?.click(); + }, []); + + const handleImportInputChange = useCallback((event: ChangeEvent) => { + const files = fileListToArray(event.currentTarget.files); + event.currentTarget.value = ""; + + const source = importedSourceFromFiles(files); + if (!source) { + console.warn("[builder] import ignored: choose a .vox, .obj, or .glb file"); + return; + } + + void (async () => { + const placement = await buildDroppedPlacement(source, sceneOptions.target[0], sceneOptions.target[1]); + if (!placement) return; + const snapped = snapPlacement(placement, terrainVertices, sceneOptions.gridResolution, sceneOptions.snapToGrid); + appendItems([snapped]); + setSelectedId(snapped.id); + setSelectedShapeId(null); + setBuilderTool("move"); + })(); + }, [ + appendItems, + buildDroppedPlacement, + sceneOptions.gridResolution, + sceneOptions.snapToGrid, + sceneOptions.target, + setSelectedId, + terrainVertices, + ]); + + const handleRestart = useCallback(() => { + replaceItems([]); + setSelectedId(null); + setSelectedShapeId(null); + setPlacingShapeId(null); + setBuilderTool("move"); + setSceneOptions({ ...DEFAULT_SCENE }); + }, [replaceItems, setSelectedId]); + + const handleAddShapeAt = useCallback(async (worldX: number, worldY: number) => { + if (placingShapeId) return; + const preset = BUILDER_SHAPE_PRESETS.find((shape) => shape.id === selectedShapeId); + if (!preset) return; + setPlacingShapeId(preset.id); + try { + const placement = await buildPlacement(preset, worldX, worldY); + if (!placement) return; + const snapped = snapPlacement(placement, terrainVertices, sceneOptions.gridResolution, sceneOptions.snapToGrid); + appendItems([snapped]); + setSelectedId(snapped.id); + } finally { + setPlacingShapeId(null); } - }, [handleAddPreset, handleAddScene]); + }, [ + appendItems, + buildPlacement, + placingShapeId, + sceneOptions.gridResolution, + sceneOptions.snapToGrid, + selectedShapeId, + setSelectedId, + terrainVertices, + ]); // Delete (or Backspace on Mac) removes the selected item. Ignored while - // focus is in a text input so it doesn't fire when typing in the search box. + // focus is in a text input so it doesn't fire while editing dock values. useEffect(() => { const onKey = (e: KeyboardEvent) => { if (!selectedIdRef.current) return; @@ -193,38 +360,74 @@ export default function BuilderWorkbench() { const first = handles[0] ?? null; if (!first) { setSelectedId(null); return; } const id = (first as unknown as { id?: string }).id; - if (typeof id === "string") setSelectedId(id); + setSelectedId(typeof id === "string" ? id : null); }, [setSelectedId]); const handleGizmoObjectChange = useCallback((event: PolyTransformControlsObjectChangeEvent) => { if (!selected) return; const nextPosition = event.position; if (nextPosition) { - const TILE = 50; const dxCss = nextPosition[1] - selected.position[1]; const dyCss = nextPosition[0] - selected.position[0]; - // Translate via the gizmo updates (worldX, worldY); the Z and tilt - // are re-derived from the terrain at the new XY so the dragged - // mesh follows the surface instead of floating off it. The Z arm - // of the gizmo therefore has no effect on a floor-anchored item — - // intentional (use scale to grow upward; floor stays the floor). + const dzCss = nextPosition[2] - selected.position[2]; + if (Math.abs(dzCss) > Math.max(Math.abs(dxCss), Math.abs(dyCss), 0.001)) { + const snapped = snapPlacement( + { ...selected, elevation: Math.max(0, (selected.elevation ?? 0) + dzCss / TILE) }, + terrainVertices, + sceneOptions.gridResolution, + sceneOptions.snapToGrid, + ); + updateItem(selected.id, { + elevation: snapped.elevation, + position: snapped.position, + rotation: snapped.rotation, + }); + return; + } + const newWorldX = selected.worldX + dxCss / TILE; const newWorldY = selected.worldY + dyCss / TILE; const snapped = snapPlacement( { ...selected, worldX: newWorldX, worldY: newWorldY }, terrainVertices, sceneOptions.gridResolution, + sceneOptions.snapToGrid, ); updateItem(selected.id, { - worldX: newWorldX, - worldY: newWorldY, + worldX: snapped.worldX, + worldY: snapped.worldY, position: snapped.position, rotation: snapped.rotation, }); } else if (event.rotation) { updateItem(selected.id, { rotation: event.rotation }); } - }, [selected, updateItem, terrainVertices, sceneOptions.gridResolution]); + }, [selected, updateItem, terrainVertices, sceneOptions.gridResolution, sceneOptions.snapToGrid]); + + const handleSelectedMeshDrag = useCallback((id: string, worldX: number, worldY: number) => { + mapItems((it) => + it.id === id + ? snapPlacement({ ...it, worldX, worldY }, terrainVertices, sceneOptions.gridResolution, sceneOptions.snapToGrid) + : it, + ); + }, [mapItems, terrainVertices, sceneOptions.gridResolution, sceneOptions.snapToGrid]); + + const handleStepSelectedElevation = useCallback((direction: 1 | -1) => { + mapItems((it) => + it.id === selectedIdRef.current + ? snapPlacement( + { ...it, elevation: Math.max(0, (it.elevation ?? 0) + direction * sceneOptions.gridResolution) }, + terrainVertices, + sceneOptions.gridResolution, + sceneOptions.snapToGrid, + ) + : it, + ); + }, [mapItems, terrainVertices, sceneOptions.gridResolution, sceneOptions.snapToGrid, selectedIdRef]); + + const handleDeleteSelected = useCallback(() => { + handleDeleteSelectedRef.current?.(); + }, [handleDeleteSelectedRef]); // Scale slider — apply new scale AND re-anchor the bottom of the mesh // to the surface. Without this, scaling around the bbox centre would @@ -232,58 +435,64 @@ export default function BuilderWorkbench() { const handleScaleSelected = useCallback((scale: number) => { mapItems((it) => it.id === selectedIdRef.current - ? snapPlacement({ ...it, scale }, terrainVertices, sceneOptions.gridResolution) + ? snapPlacement({ ...it, scale }, terrainVertices, sceneOptions.gridResolution, sceneOptions.snapToGrid) : it, ); - }, [mapItems, terrainVertices, sceneOptions.gridResolution, selectedIdRef]); + }, [mapItems, terrainVertices, sceneOptions.gridResolution, sceneOptions.snapToGrid, selectedIdRef]); + + const handleColorSelected = useCallback((color: string) => { + mapItems((it) => (it.id === selectedIdRef.current ? { ...it, color, colorOverride: true } : it)); + }, [mapItems, selectedIdRef]); const sceneFolderContent = ( ); return ( -
    - fileInputRef.current?.click()} - fileInputRef={fileInputRef} - onFileInputChange={() => {/* Drag-import not supported yet */}} - onRandomPreset={() => { - const rnd = PRESETS[Math.floor(Math.random() * PRESETS.length)]; - handleAddPreset(rnd.id); - }} - modelCategories={modelCategories} - isCategoryOpen={isCategoryOpen} - onToggleCategory={handleToggleCategory} - modelTreeId={modelTreeId} - presetId={loadingPresetId ?? ""} - onPresetClick={handleSidebarClick} +
    + + +
    -
    +
    - -
    @@ -302,12 +514,11 @@ export default function BuilderWorkbench() { diff --git a/website/src/components/BuilderWorkbench/builder-workbench.css b/website/src/components/BuilderWorkbench/builder-workbench.css index 905e05a8..646186c2 100644 --- a/website/src/components/BuilderWorkbench/builder-workbench.css +++ b/website/src/components/BuilderWorkbench/builder-workbench.css @@ -1,12 +1,297 @@ +@import url("https://fonts.googleapis.com/css2?family=Material+Symbols+Rounded:opsz,wght,FILL,GRAD@20,500,0,0"); + /* Placement mode — cursor feedback on the viewport */ .dn-viewport.is-placement-mode { cursor: crosshair; } -/* Floating tool palette — top-centre of the builder viewport. - Four buttons (Pointer / Raise / Lower / Smooth). The target-mode - toggle (Vertex / Face) sits in a sibling pill 8px below this one, - anchored at the same top region — same glass styling. */ +.dn-root.is-tool-add .dn-viewport { + cursor: crosshair; +} + +.dn-root.is-tool-remove .dn-viewport { + cursor: not-allowed; +} + +.dn-root.is-grid-gray .dn-viewport { + background: #f5f3ea; +} + +.dn-root.is-grid-dark .dn-viewport { + background: #05070b; +} + +.builder-tool-ribbon { + position: absolute; + top: var(--overlay-top); + left: var(--overlay-left); + z-index: 18; + display: inline-flex; + align-items: center; + gap: 4px; + padding: 4px; + border-radius: 8px; + background: rgba(17, 20, 26, 0.98); + border: 1px solid rgba(255, 255, 255, 0.08); + box-shadow: 0 14px 38px rgba(0, 0, 0, 0.35); +} + +.builder-tool-ribbon__button { + height: 30px; + min-width: 64px; + border: 0; + border-radius: 5px; + background: transparent; + color: rgba(232, 237, 242, 0.72); + cursor: pointer; + display: inline-flex; + align-items: center; + justify-content: center; + gap: 5px; + font: inherit; + font-size: 12px; + font-weight: 750; + padding: 0 10px; +} + +.builder-tool-ribbon__button:hover { + color: #e8edf2; + background: rgba(255, 255, 255, 0.05); +} + +.builder-tool-ribbon__button.is-active { + color: #071014; + background: #22d3ee; +} + +.builder-tool-ribbon__button--restart { + min-width: 82px; +} + +.builder-tool-ribbon__divider { + width: 1px; + height: 20px; + margin: 0 2px; + background: rgba(255, 255, 255, 0.1); +} + +.builder-tool-ribbon__icon { + font-family: "Material Symbols Rounded"; + font-weight: normal; + font-style: normal; + font-size: 16px; + line-height: 1; + letter-spacing: 0; + text-transform: none; + display: inline-block; + white-space: nowrap; + direction: ltr; + font-feature-settings: "liga"; + -webkit-font-feature-settings: "liga"; + -webkit-font-smoothing: antialiased; +} + +.shape-picker { + position: absolute; + top: calc(var(--overlay-top) + 46px); + left: var(--overlay-left); + width: clamp(260px, 21vw, 330px); + height: max-content; + max-height: max-content; + min-height: 0; + overflow: clip; + z-index: 15; + background: #11141a; + border-radius: 8px; + box-shadow: 0 18px 48px rgba(0, 0, 0, 0.45); + transform: translateZ(0); + backface-visibility: hidden; + -webkit-backface-visibility: hidden; + contain: paint; + isolation: isolate; +} + +.shape-picker__body { + box-sizing: border-box; + max-height: none; + min-height: 0; + display: flex; + flex-direction: column; + gap: 10px; + padding: 10px; + overflow: clip; +} + +.shape-picker__header { + flex: 0 0 auto; + display: flex; + align-items: center; +} + +.shape-picker__title { + margin: 0; + color: #e8edf2; + font-size: 13px; + font-weight: 750; + letter-spacing: 0; + line-height: 1; +} + +.shape-picker__grid { + flex: 0 0 auto; + min-height: 0; + display: grid; + grid-template-columns: repeat(auto-fill, minmax(108px, 1fr)); + align-content: start; + gap: 8px; + overflow-y: visible; + padding-right: 4px; +} + +.shape-picker__item { + min-width: 0; + min-height: 110px; + display: grid; + grid-template-rows: 74px auto; + align-items: center; + justify-items: center; + gap: 6px; + padding: 8px 6px; + border: 1px solid rgba(255, 255, 255, 0.06); + border-radius: 8px; + background: #0b0f18; + color: #e2e8f0; + font: inherit; + cursor: pointer; +} + +.shape-picker__item:hover:not(:disabled) { + background: #131a26; + border-color: rgba(125, 211, 252, 0.22); +} + +.shape-picker__item.is-active { + background: #142033; + border-color: rgba(34, 211, 238, 0.62); +} + +.shape-picker__item:disabled { + cursor: wait; + opacity: 0.58; +} + +.shape-picker__item.is-active:disabled { + opacity: 1; +} + +.shape-picker__item--import { + border-style: dashed; + background: #0d121b; +} + +.shape-picker__item--import:hover { + background: #141c29; +} + +.shape-picker__thumb { + width: 65px; + height: 65px; + display: grid; + place-items: center; + border-radius: 6px; + overflow: hidden; +} + +.shape-picker__thumb img { + width: 100%; + height: 100%; + display: block; + object-fit: cover; +} + +.shape-picker__thumb--import { + border: 0; +} + +.shape-picker__import-icon { + color: #dce4ed; + font-size: 30px; + line-height: 1; +} + +.shape-picker__label { + max-width: 100%; + color: #dce4ed; + font-size: 12px; + font-weight: 650; + line-height: 1.15; + text-align: center; + overflow-wrap: anywhere; +} + +.shape-picker__empty { + padding: 10px 4px; + color: #94a3b8; + font-weight: 600; +} + +.shape-picker__surface { + flex: 0 0 auto; + display: flex; + align-items: center; + justify-content: space-between; + gap: 10px; + padding: 8px 2px 0; + border-top: 1px solid rgba(255, 255, 255, 0.07); +} + +.shape-picker__surface-label { + color: rgba(226, 232, 240, 0.74); + font-size: 12px; + font-weight: 700; + line-height: 1; +} + +.shape-picker__surface-button { + height: 28px; + min-width: 78px; + display: inline-flex; + align-items: center; + justify-content: center; + gap: 6px; + border: 1px solid rgba(255, 255, 255, 0.08); + border-radius: 5px; + background: rgba(255, 255, 255, 0.04); + color: #e8edf2; + cursor: pointer; + font: inherit; + font-size: 12px; + font-weight: 750; + padding: 0 9px; +} + +.shape-picker__surface-button:hover { + background: rgba(255, 255, 255, 0.07); +} + +.shape-picker__surface-swatch { + width: 13px; + height: 13px; + box-sizing: border-box; + border-radius: 3px; + border: 1px solid rgba(255, 255, 255, 0.22); + box-shadow: inset 0 0 0 1px rgba(0, 0, 0, 0.18); +} + +.shape-picker__surface-swatch.is-gray { + background: #f5f3ea; +} + +.shape-picker__surface-swatch.is-dark { + background: #05070b; +} + +/* Floating tool palette — top-centre of the builder viewport. */ .builder-tool-palette { position: absolute; left: 50%; @@ -147,6 +432,12 @@ .builder-terrain-hover { pointer-events: none; } +.builder-ground-fill, +.builder-ground-fill *, +.builder-add-hover, +.builder-add-hover * { + pointer-events: none; +} .builder-terrain { pointer-events: none; } @@ -158,7 +449,8 @@ you only see the bottom face of the bbox. Bake transparency into the polygon color (rgba in GHOST_COLOR) instead. Same trap that's documented at the top of PolyTransformControls.tsx. */ -.builder-ghost { +.builder-ghost, +.builder-selection-wireframe { pointer-events: none; } @@ -196,15 +488,15 @@ .builder-ghost b, .builder-ghost i, .builder-ghost s, -.builder-ghost u { +.builder-ghost u, +.builder-selection-wireframe b, +.builder-selection-wireframe i, +.builder-selection-wireframe s, +.builder-selection-wireframe u { backface-visibility: visible !important; } -/* The Scene folder body is built in two halves inside one lil-gui folder: - a React block (this stylesheet) for the items list + gizmo button set, - then the lil-gui-native Scale slider below it. The styles below mimic the - Inspector panel idiom (.dn-mesh-* in gallery-workbench.css) so the rows - read consistently with the rest of the app. */ +/* Scene folder list rendered inside a lil-gui folder. */ .builder-placed.is-selected { outline: none; @@ -306,39 +598,186 @@ color: #fff; } -.builder-scene-folder__gizmo { +.builder-mesh-panel { + width: 360px; + max-width: 100%; + box-sizing: border-box; + padding: 10px; + border-radius: 8px; + background: rgba(17, 20, 26, 0.98); + border: 1px solid rgba(255, 255, 255, 0.08); + box-shadow: 0 18px 48px rgba(0, 0, 0, 0.35); + color: #e8edf2; +} + +.builder-mesh-panel__header { display: flex; - gap: 4px; - padding: 4px 8px 2px; + align-items: flex-start; + justify-content: space-between; + gap: 10px; + margin-bottom: 10px; } -.dn-scene-folder-content .builder-scene-folder__gizmo-btn { - flex: 1 1 0; - width: auto; +.builder-mesh-panel__title-group { min-width: 0; - background: rgba(17, 19, 22, 0.85); - border: 1px solid rgba(255, 255, 255, 0.06); - color: rgba(232, 237, 242, 0.75); - font: inherit; +} + +.builder-mesh-panel__title { + margin: 0 0 3px; + font-size: 12px; + font-weight: 800; + letter-spacing: 0; + line-height: 1; +} + +.builder-mesh-panel__name { + margin: 0; + color: rgba(232, 237, 242, 0.58); font-size: 11px; - padding: 5px 8px; + line-height: 1.2; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.builder-mesh-panel__delete { + flex: 0 0 auto; + width: 24px; + height: 24px; + display: grid; + place-items: center; + border: 0; border-radius: 4px; + background: rgba(255, 255, 255, 0.04); + color: rgba(232, 237, 242, 0.64); cursor: pointer; - height: auto; + font-size: 17px; + line-height: 1; + padding: 0; +} + +.builder-mesh-panel__delete:hover { + background: rgba(220, 50, 50, 0.65); + color: #fff; +} + +.builder-mesh-panel__field { + padding: 9px 0; + border-top: 1px solid rgba(255, 255, 255, 0.07); +} + +.builder-mesh-panel__field-row { + min-width: 0; + display: flex; + align-items: center; + justify-content: space-between; + gap: 10px; } -.dn-scene-folder-content .builder-scene-folder__gizmo-btn:hover:not(:disabled) { +.builder-mesh-panel__label { + color: rgba(232, 237, 242, 0.72); + font-size: 11px; + font-weight: 650; + line-height: 1; +} + +.builder-mesh-panel__number { + width: 74px; + height: 26px; + box-sizing: border-box; + border: 1px solid rgba(255, 255, 255, 0.08); + border-radius: 4px; + background: #090b10; color: #e8edf2; - border-color: rgba(255, 255, 255, 0.12); + font-size: 12px; + padding: 0 7px; +} + +.builder-mesh-panel__number:focus { + outline: 1px solid rgba(34, 211, 238, 0.55); + outline-offset: 0; +} + +.builder-mesh-panel__color { + width: 42px; + height: 28px; + box-sizing: border-box; + border: 1px solid rgba(255, 255, 255, 0.1); + border-radius: 4px; + background: #090b10; + cursor: pointer; + padding: 3px; +} + +.builder-mesh-panel__color::-webkit-color-swatch-wrapper { + padding: 0; +} + +.builder-mesh-panel__color::-webkit-color-swatch { + border: 0; + border-radius: 2px; } -.dn-scene-folder-content .builder-scene-folder__gizmo-btn.is-active { - background: rgba(34, 211, 238, 0.18); - border-color: rgba(34, 211, 238, 0.5); +.builder-mesh-panel__color-text { + width: 82px; + height: 28px; + box-sizing: border-box; + border: 1px solid rgba(255, 255, 255, 0.08); + border-radius: 4px; + background: #090b10; color: #e8edf2; + font-size: 12px; + padding: 0 7px; + text-transform: lowercase; +} + +.builder-mesh-panel__color-text:focus { + outline: 1px solid rgba(34, 211, 238, 0.55); + outline-offset: 0; +} + +.builder-mesh-panel__range { + width: 100%; + margin: 10px 0 0; + accent-color: #22d3ee; +} + +.builder-mesh-panel__stepper { + display: grid; + grid-template-columns: 28px 54px 28px; + align-items: center; + gap: 4px; +} + +.builder-mesh-panel__stepper button { + width: 28px; + height: 26px; + border: 0; + border-radius: 4px; + background: rgba(34, 211, 238, 0.14); + color: #dffbff; + cursor: pointer; + font-size: 15px; + line-height: 1; + padding: 0; +} + +.builder-mesh-panel__stepper button:hover:not(:disabled) { + background: rgba(34, 211, 238, 0.24); } -.dn-scene-folder-content .builder-scene-folder__gizmo-btn:disabled { - opacity: 0.4; +.builder-mesh-panel__stepper button:disabled { cursor: not-allowed; + opacity: 0.38; +} + +.builder-mesh-panel__stepper output { + min-width: 0; + height: 26px; + display: grid; + place-items: center; + border-radius: 4px; + background: #090b10; + color: #e8edf2; + font-size: 12px; } diff --git a/website/src/components/BuilderWorkbench/components/BuilderCameraDragControls.tsx b/website/src/components/BuilderWorkbench/components/BuilderCameraDragControls.tsx new file mode 100644 index 00000000..6b4ab964 --- /dev/null +++ b/website/src/components/BuilderWorkbench/components/BuilderCameraDragControls.tsx @@ -0,0 +1,184 @@ +import { useEffect, useRef } from "react"; +import { BASE_TILE, useCameraContext, type Vec3 } from "@layoutit/polycss-react"; + +type BuilderCameraMode = "orbit" | "pan"; + +export interface BuilderCameraDragControlsProps { + mode: BuilderCameraMode; + enabled: boolean; + maxRotX: number; + onInteractionEnd: (camera: { rotX: number; rotY: number; zoom: number; target: Vec3 }) => void; +} + +const POINTER_DRAG_SPEED = 4; + +function applyOrbit( + dx: number, + dy: number, + state: { rotX: number; rotY: number }, + maxRotX: number, +): { rotX: number; rotY: number } { + const dX = dx / POINTER_DRAG_SPEED; + const dY = dy / POINTER_DRAG_SPEED; + return { + rotX: Math.max(0, Math.min(maxRotX, state.rotX - dY)), + rotY: (((state.rotY - dX) % 360) + 360) % 360, + }; +} + +function applyPan( + dx: number, + dy: number, + state: { zoom: number; rotX: number; rotY: number; target: Vec3 }, +): Vec3 { + const z = Math.max(0.01, state.zoom); + const cosRotXRaw = Math.cos((state.rotX * Math.PI) / 180); + const cosRotX = cosRotXRaw >= 0 ? Math.max(0.1, cosRotXRaw) : Math.min(-0.1, cosRotXRaw); + const cZ = Math.cos((state.rotY * Math.PI) / 180); + const sZ = Math.sin((state.rotY * Math.PI) / 180); + const k = z * BASE_TILE; + const targetD0 = (dx * sZ - dy * cZ / cosRotX) / k; + const targetD1 = -(dx * cZ + dy * sZ / cosRotX) / k; + const target = state.target; + return [target[0] + targetD0, target[1] + targetD1, Math.max(0, target[2])]; +} + +export function BuilderCameraDragControls({ + mode, + enabled, + maxRotX, + onInteractionEnd, +}: BuilderCameraDragControlsProps): null { + const { store, cameraRef, cameraElRef, applyTransformDirect } = useCameraContext(); + const stateRef = useRef({ mode, enabled, maxRotX, onInteractionEnd }); + stateRef.current = { mode, enabled, maxRotX, onInteractionEnd }; + + useEffect(() => { + const element = cameraElRef.current; + if (!element) return; + + let activePointerId: number | null = null; + let pointer = { x: 0, y: 0 }; + let rightDragActive = false; + let rightPointer = { x: 0, y: 0 }; + + const snapshot = () => { + const state = cameraRef.current.state; + return { + rotX: state.rotX, + rotY: state.rotY, + zoom: state.zoom, + target: state.target, + }; + }; + + const applyDrag = (dx: number, dy: number, dragKind: BuilderCameraMode): void => { + const handle = cameraRef.current; + const state = handle.state; + if (dragKind === "orbit") { + handle.update(applyOrbit(dx, dy, state, stateRef.current.maxRotX)); + } else { + handle.update({ target: applyPan(dx, dy, state) }); + } + applyTransformDirect(); + store.updateCameraFromRef(handle); + }; + + const onDown = (event: PointerEvent): void => { + if (!stateRef.current.enabled) return; + if (activePointerId !== null) return; + if (event.isPrimary === false) return; + if (event.button !== 0) return; + event.preventDefault(); + activePointerId = event.pointerId; + pointer = { x: event.clientX, y: event.clientY }; + element.style.cursor = "grabbing"; + try { + (event.target as Element).setPointerCapture(event.pointerId); + } catch { + // Pointer capture can fail if the event target is already gone. + } + }; + + const onMove = (event: PointerEvent): void => { + if (!stateRef.current.enabled) return; + if (activePointerId === null || event.pointerId !== activePointerId) return; + event.preventDefault(); + const dx = event.clientX - pointer.x; + const dy = event.clientY - pointer.y; + pointer = { x: event.clientX, y: event.clientY }; + const isAlternate = event.shiftKey; + const dragKind = stateRef.current.mode === "pan" + ? (isAlternate ? "orbit" : "pan") + : (isAlternate ? "pan" : "orbit"); + applyDrag(dx, dy, dragKind); + }; + + const onUp = (event: PointerEvent): void => { + if (activePointerId !== event.pointerId) return; + activePointerId = null; + element.style.cursor = stateRef.current.enabled ? "grab" : ""; + try { + (event.target as Element).releasePointerCapture(event.pointerId); + } catch { + // Ignore release errors for stale event targets. + } + stateRef.current.onInteractionEnd(snapshot()); + }; + + const onContextMenu = (event: Event): void => { + event.preventDefault(); + }; + + const onMouseDown = (event: MouseEvent): void => { + if (!stateRef.current.enabled) return; + if (event.button !== 2) return; + rightDragActive = true; + rightPointer = { x: event.clientX, y: event.clientY }; + element.style.cursor = "grabbing"; + }; + + const onMouseMove = (event: MouseEvent): void => { + if (!stateRef.current.enabled || !rightDragActive) return; + const dx = event.clientX - rightPointer.x; + const dy = event.clientY - rightPointer.y; + rightPointer = { x: event.clientX, y: event.clientY }; + applyDrag(dx, dy, stateRef.current.mode === "pan" ? "orbit" : "pan"); + }; + + const onMouseUp = (event: MouseEvent): void => { + if (event.button !== 2 || !rightDragActive) return; + rightDragActive = false; + element.style.cursor = stateRef.current.enabled ? "grab" : ""; + stateRef.current.onInteractionEnd(snapshot()); + }; + + element.style.cursor = enabled ? "grab" : ""; + element.style.touchAction = "none"; + element.style.userSelect = "none"; + element.addEventListener("pointerdown", onDown); + element.addEventListener("pointermove", onMove); + element.addEventListener("pointerup", onUp); + element.addEventListener("pointercancel", onUp); + element.addEventListener("contextmenu", onContextMenu); + element.addEventListener("mousedown", onMouseDown); + element.addEventListener("mousemove", onMouseMove); + element.addEventListener("mouseup", onMouseUp); + + return () => { + element.removeEventListener("pointerdown", onDown); + element.removeEventListener("pointermove", onMove); + element.removeEventListener("pointerup", onUp); + element.removeEventListener("pointercancel", onUp); + element.removeEventListener("contextmenu", onContextMenu); + element.removeEventListener("mousedown", onMouseDown); + element.removeEventListener("mousemove", onMouseMove); + element.removeEventListener("mouseup", onMouseUp); + element.style.cursor = ""; + element.style.touchAction = ""; + element.style.userSelect = ""; + }; + }, [applyTransformDirect, cameraElRef, cameraRef, enabled, store]); + + return null; +} diff --git a/website/src/components/BuilderWorkbench/components/BuilderDock.tsx b/website/src/components/BuilderWorkbench/components/BuilderDock.tsx index 252b5cb4..6eb2c2ff 100644 --- a/website/src/components/BuilderWorkbench/components/BuilderDock.tsx +++ b/website/src/components/BuilderWorkbench/components/BuilderDock.tsx @@ -2,107 +2,53 @@ import type { ReactNode } from "react"; import { Dock, DockScene, - DockRendering, - DockCamera, - DockLighting, } from "../../Dock"; -import { defaultZoomForModel } from "../../GalleryWorkbench/helpers/smartDefaults"; -import type { PresetModel } from "../../GalleryWorkbench/types"; -import type { Polygon } from "@layoutit/polycss-react"; -import type { SceneOptionsState, PerspectiveMode } from "../../types"; +import type { SceneOptionsState } from "../../types"; import type { PlacedItem } from "../types"; -import { DockGrid } from "../slots/DockGrid"; +import { DockBuilderLighting } from "../slots/BuilderDockSlots"; +import { BuilderMeshPanel } from "./BuilderMeshPanel"; export interface BuilderDockProps { sceneOptions: SceneOptionsState; updateScene: (partial: Partial) => void; - placedItems: PlacedItem[]; - selectedId: string | null; - selectedScale: number; + selected: PlacedItem | null; onScaleChange: (scale: number) => void; - perspectiveMode: PerspectiveMode; - perspectivePx: number | false; + onColorChange: (color: string) => void; + onStepElevation: (direction: 1 | -1) => void; + onDeleteSelected: () => void; sceneFolderContent: ReactNode; } export function BuilderDock({ sceneOptions, updateScene, - selectedId, - selectedScale, + selected, onScaleChange, - perspectiveMode, - perspectivePx, + onColorChange, + onStepElevation, + onDeleteSelected, sceneFolderContent, }: BuilderDockProps) { - const stubPreset = { zoom: sceneOptions.zoom, rotX: sceneOptions.rotX, rotY: sceneOptions.rotY }; - return ( - - - - defaultZoomForModel(preset as PresetModel, polys as Polygon[])} - onUpdateScene={updateScene} - /> - + + {selected ? ( + + ) : null} ); } diff --git a/website/src/components/BuilderWorkbench/components/BuilderMeshPanel.tsx b/website/src/components/BuilderWorkbench/components/BuilderMeshPanel.tsx new file mode 100644 index 00000000..47ce9362 --- /dev/null +++ b/website/src/components/BuilderWorkbench/components/BuilderMeshPanel.tsx @@ -0,0 +1,147 @@ +import { useEffect, useState } from "react"; +import { stripParenthesizedText } from "../../GalleryWorkbench/presets"; +import type { PlacedItem } from "../types"; + +export interface BuilderMeshPanelProps { + selected: PlacedItem; + gridResolution: number; + onScaleChange: (scale: number) => void; + onColorChange: (color: string) => void; + onStepElevation: (direction: 1 | -1) => void; + onDelete: () => void; +} + +function clampScale(value: number): number { + if (!Number.isFinite(value)) return 1; + return Math.max(0.1, Math.min(5, value)); +} + +function zLevelLabel(elevation: number, gridResolution: number): string { + const level = gridResolution > 0 ? elevation / gridResolution : elevation; + return Number.isInteger(level) ? String(level) : level.toFixed(1); +} + +function normalizeHexColor(value: string): string | null { + const trimmed = value.trim(); + if (/^#[0-9a-fA-F]{6}$/.test(trimmed)) return trimmed.toLowerCase(); + if (/^[0-9a-fA-F]{6}$/.test(trimmed)) return `#${trimmed.toLowerCase()}`; + return null; +} + +export function BuilderMeshPanel({ + selected, + gridResolution, + onScaleChange, + onColorChange, + onStepElevation, + onDelete, +}: BuilderMeshPanelProps) { + const scale = clampScale(selected.scale); + const elevation = Math.max(0, selected.elevation ?? 0); + const [colorDraft, setColorDraft] = useState(selected.color); + + useEffect(() => { + setColorDraft(selected.color); + }, [selected.id, selected.color]); + + return ( +
    +
    +
    +

    Mesh

    +

    {stripParenthesizedText(selected.preset.label)}

    +
    + +
    + +
    +
    + + onScaleChange(clampScale(Number(event.currentTarget.value)))} + /> +
    + onScaleChange(clampScale(Number(event.currentTarget.value)))} + /> +
    + +
    +
    + + { + setColorDraft(event.currentTarget.value); + onColorChange(event.currentTarget.value); + }} + onInput={(event) => { + setColorDraft(event.currentTarget.value); + onColorChange(event.currentTarget.value); + }} + /> + { + setColorDraft(event.currentTarget.value); + const color = normalizeHexColor(event.currentTarget.value); + if (color) onColorChange(color); + }} + /> +
    +
    + +
    +
    + Z level +
    + + {zLevelLabel(elevation, gridResolution)} + +
    +
    +
    +
    + ); +} diff --git a/website/src/components/BuilderWorkbench/components/BuilderScene.tsx b/website/src/components/BuilderWorkbench/components/BuilderScene.tsx index ecb18832..62fa824c 100644 --- a/website/src/components/BuilderWorkbench/components/BuilderScene.tsx +++ b/website/src/components/BuilderWorkbench/components/BuilderScene.tsx @@ -1,4 +1,5 @@ import { + BASE_TILE, PolyAxesHelper, PolyDirectionalLightHelper, PolyFirstPersonControls, @@ -10,6 +11,7 @@ import { PolyScene, PolySelect, PolyTransformControls, + useCameraContext, } from "@layoutit/polycss-react"; import type { PolyAmbientLight, @@ -20,17 +22,27 @@ import type { Polygon, Vec3, } from "@layoutit/polycss-react"; -import { type RefObject } from "react"; -import { meshResolutionShowsMesh, type SceneOptionsState, type GizmoMode } from "../../types"; -import type { PlacedItem } from "../types"; +import { useEffect, useMemo, useRef, useState, type RefObject } from "react"; +import { meshResolutionShowsMesh, type SceneOptionsState } from "../../types"; +import { BUILDER_GROUND_SPAN, BUILDER_MAX_CAMERA_ROT_X } from "../defaults"; +import { buildSolidWireframePolygons } from "../geometry/ghost"; +import { meshBbox } from "../geometry/meshBbox"; +import { projectScreenToWorldGround } from "../geometry/screenToWorld"; +import { snapWorldToCellCenter, worldToGridCell } from "../geometry/snap"; +import type { BuilderToolMode, PlacedItem } from "../types"; +import { BuilderCameraDragControls } from "./BuilderCameraDragControls"; + +const GROUND_FILL_COLORS = { + gray: "#f5f3ea", + dark: "#05070b", +} as const; export interface BuilderSceneProps { sceneOptions: SceneOptionsState; updateScene: (partial: Partial) => void; directionalLight: PolyDirectionalLight; ambientLight: PolyAmbientLight; - /** Unified floor grid — also carries the terrain elevation. Lines - * bend at raised vertices instead of passing flat through hills. */ + /** One polygon per visible grid line, terrain-aware when raised. */ gridPolygons: Polygon[]; ghostPolygons: Polygon[]; /** Single-quad outline showing the vertex the terrain-tool cursor is @@ -41,7 +53,6 @@ export interface BuilderSceneProps { renderedPolygonsById: Map; interiorShellPolygonsById: Map; selectedId: string | null; - gizmoMode: GizmoMode; gizmoDragging: boolean; meshHandlesRef: RefObject>; getMeshRefCallback: (id: string) => (h: PolyMeshHandle | null) => void; @@ -49,9 +60,335 @@ export interface BuilderSceneProps { onSelectionChange: (handles: PolyMeshHandle[]) => void; onGizmoDraggingChanged: (dragging: boolean) => void; onGizmoObjectChange: (event: PolyTransformControlsObjectChangeEvent) => void; + onSelectedMeshDrag: (id: string, worldX: number, worldY: number) => void; + onStepSelectedElevation: (direction: 1 | -1) => void; + builderTool: BuilderToolMode; + onAddShapeAt: (worldX: number, worldY: number) => void; + onRemoveItem: (id: string) => void; selected: PlacedItem | null; } +function selectedSurfaceWorldZ(item: PlacedItem): number { + if (!item.rawPolygons) return item.elevation ?? 0; + const bbox = meshBbox(item.rawPolygons); + const scale = Math.max(item.fitScale * item.scale, 0.0001); + return item.position[2] / BASE_TILE + bbox.midZ * (1 - scale) + scale * bbox.minZ; +} + +function zArrowDirectionFromTarget(target: Element | null): 1 | -1 | null { + const arrow = target?.closest(".polycss-transform-arrow--z, .polycss-transform-arrow---z"); + if (!arrow) return null; + return arrow.classList.contains("polycss-transform-arrow---z") ? -1 : 1; +} + +function zArrowDirectionFromPoint(clientX: number, clientY: number): 1 | -1 | null { + for (const [selector, direction] of [ + [".polycss-transform-arrow--z", 1], + [".polycss-transform-arrow---z", -1], + ] as const) { + const arrow = document.querySelector(selector); + if (!arrow) continue; + const leaves = arrow.querySelectorAll("b,i,s,u,q,svg"); + for (const leaf of leaves) { + const rect = leaf.getBoundingClientRect(); + if (rect.width <= 1 || rect.height <= 1) continue; + if ( + clientX >= rect.left && + clientX <= rect.right && + clientY >= rect.top && + clientY <= rect.bottom + ) { + return direction; + } + } + } + return null; +} + +interface BuilderSelectedMeshInteractionControlsProps { + selected: PlacedItem | null; + sceneOptions: SceneOptionsState; + enabled: boolean; + onSelectedMeshDrag: (id: string, worldX: number, worldY: number) => void; + onDraggingChanged: (dragging: boolean) => void; + onStepSelectedElevation: (direction: 1 | -1) => void; +} + +function BuilderSelectedMeshInteractionControls({ + selected, + sceneOptions, + enabled, + onSelectedMeshDrag, + onDraggingChanged, + onStepSelectedElevation, +}: BuilderSelectedMeshInteractionControlsProps): null { + const { store, cameraElRef } = useCameraContext(); + const stateRef = useRef({ + selected, + sceneOptions, + enabled, + onSelectedMeshDrag, + onDraggingChanged, + onStepSelectedElevation, + }); + stateRef.current = { + selected, + sceneOptions, + enabled, + onSelectedMeshDrag, + onDraggingChanged, + onStepSelectedElevation, + }; + const dragRef = useRef<{ + id: string; + pointerId: number; + planeWorldZ: number; + offsetX: number; + offsetY: number; + startX: number; + startY: number; + } | null>(null); + + useEffect(() => { + const cameraEl = cameraElRef.current; + if (!cameraEl) return; + + const armClickSwallow = (): void => { + const swallow = (event: Event): void => { + event.stopPropagation(); + event.stopImmediatePropagation(); + }; + window.addEventListener("click", swallow, { capture: true, once: true }); + setTimeout(() => window.removeEventListener("click", swallow, true), 0); + }; + + const projectAt = (clientX: number, clientY: number, planeWorldZ: number): [number, number] | null => + projectScreenToWorldGround({ + clientX, + clientY, + cameraEl, + sceneOptions: stateRef.current.sceneOptions, + autoCenterOffset: store.getState().autoCenterOffset, + planeWorldZ, + }); + + const armZClickSwallow = (pointerId: number): void => { + const onUp = (event: PointerEvent): void => { + if (event.pointerId !== pointerId) return; + window.removeEventListener("pointerup", onUp, true); + window.removeEventListener("pointercancel", onUp, true); + armClickSwallow(); + }; + window.addEventListener("pointerup", onUp, true); + window.addEventListener("pointercancel", onUp, true); + }; + + const onPointerDown = (event: PointerEvent): void => { + if (!stateRef.current.enabled) return; + if (event.button !== 0 || event.isPrimary === false) return; + const target = event.target as Element | null; + + const zDirection = zArrowDirectionFromTarget(target) ?? zArrowDirectionFromPoint(event.clientX, event.clientY); + if (zDirection) { + event.preventDefault(); + event.stopPropagation(); + event.stopImmediatePropagation(); + stateRef.current.onStepSelectedElevation(zDirection); + armZClickSwallow(event.pointerId); + return; + } + + if (target?.closest(".polycss-transform-gizmo")) return; + const current = stateRef.current.selected; + if (!current) return; + const meshEl = target?.closest(".builder-placed.is-selected") as HTMLElement | null; + if (!meshEl || meshEl.dataset.polyMeshId !== current.id) return; + + const planeWorldZ = selectedSurfaceWorldZ(current); + const hit = projectAt(event.clientX, event.clientY, planeWorldZ); + if (!hit) return; + + event.preventDefault(); + event.stopPropagation(); + event.stopImmediatePropagation(); + dragRef.current = { + id: current.id, + pointerId: event.pointerId, + planeWorldZ, + offsetX: current.worldX - hit[0], + offsetY: current.worldY - hit[1], + startX: event.clientX, + startY: event.clientY, + }; + cameraEl.style.cursor = "grabbing"; + stateRef.current.onDraggingChanged(true); + }; + + const onPointerMove = (event: PointerEvent): void => { + const drag = dragRef.current; + if (!drag || event.pointerId !== drag.pointerId) return; + const hit = projectAt(event.clientX, event.clientY, drag.planeWorldZ); + if (!hit) return; + event.preventDefault(); + event.stopPropagation(); + event.stopImmediatePropagation(); + stateRef.current.onSelectedMeshDrag(drag.id, hit[0] + drag.offsetX, hit[1] + drag.offsetY); + }; + + const onPointerDone = (event: PointerEvent): void => { + const drag = dragRef.current; + if (!drag || event.pointerId !== drag.pointerId) return; + dragRef.current = null; + event.preventDefault(); + event.stopPropagation(); + event.stopImmediatePropagation(); + cameraEl.style.cursor = stateRef.current.sceneOptions.interactive ? "grab" : ""; + stateRef.current.onDraggingChanged(false); + armClickSwallow(); + }; + + cameraEl.addEventListener("pointerdown", onPointerDown, true); + window.addEventListener("pointermove", onPointerMove, true); + window.addEventListener("pointerup", onPointerDone, true); + window.addEventListener("pointercancel", onPointerDone, true); + + return () => { + cameraEl.removeEventListener("pointerdown", onPointerDown, true); + window.removeEventListener("pointermove", onPointerMove, true); + window.removeEventListener("pointerup", onPointerDone, true); + window.removeEventListener("pointercancel", onPointerDone, true); + if (dragRef.current) { + dragRef.current = null; + stateRef.current.onDraggingChanged(false); + } + }; + }, [cameraElRef, store]); + + return null; +} + +interface BuilderViewportToolControlsProps { + tool: BuilderToolMode; + sceneOptions: SceneOptionsState; + onAddShapeAt: (worldX: number, worldY: number) => void; + onRemoveItem: (id: string) => void; + onDraggingChanged: (dragging: boolean) => void; + onHoverCellChange: (cell: [number, number] | null) => void; +} + +function BuilderViewportToolControls({ + tool, + sceneOptions, + onAddShapeAt, + onRemoveItem, + onDraggingChanged, + onHoverCellChange, +}: BuilderViewportToolControlsProps): null { + const { store, cameraElRef } = useCameraContext(); + const stateRef = useRef({ tool, sceneOptions, onAddShapeAt, onRemoveItem, onDraggingChanged, onHoverCellChange }); + stateRef.current = { tool, sceneOptions, onAddShapeAt, onRemoveItem, onDraggingChanged, onHoverCellChange }; + const downRef = useRef<{ x: number; y: number; target: EventTarget | null } | null>(null); + const hoverCellRef = useRef<[number, number] | null>(null); + + useEffect(() => { + const cameraEl = cameraElRef.current; + if (!cameraEl) return; + + const isUiOverlay = (target: EventTarget | null): boolean => { + const el = target as HTMLElement | null; + if (!el?.closest) return false; + return Boolean(el.closest(".builder-tool-ribbon, .shape-picker, .builder-camera-mode, .dn-floating-controls")); + }; + + const projectAt = (clientX: number, clientY: number): [number, number] | null => { + const state = stateRef.current; + const hit = projectScreenToWorldGround({ + clientX, + clientY, + cameraEl, + sceneOptions: state.sceneOptions, + autoCenterOffset: store.getState().autoCenterOffset, + }); + if (!hit) return null; + if (!state.sceneOptions.snapToGrid || state.sceneOptions.gridResolution <= 0) return hit; + return snapWorldToCellCenter(hit[0], hit[1], state.sceneOptions.gridResolution); + }; + + const setHoverCell = (cell: [number, number] | null): void => { + const prev = hoverCellRef.current; + if (prev?.[0] === cell?.[0] && prev?.[1] === cell?.[1]) return; + hoverCellRef.current = cell; + stateRef.current.onHoverCellChange(cell); + }; + + const onPointerMove = (event: PointerEvent): void => { + const state = stateRef.current; + if (state.tool !== "add" || isUiOverlay(event.target)) { + setHoverCell(null); + return; + } + const hit = projectAt(event.clientX, event.clientY); + if (!hit) { + setHoverCell(null); + return; + } + setHoverCell(worldToGridCell(hit[0], hit[1], state.sceneOptions.gridResolution)); + }; + + const onPointerDown = (event: PointerEvent): void => { + if (stateRef.current.tool === "move") return; + if (event.button !== 0 || event.isPrimary === false) return; + if (isUiOverlay(event.target)) return; + downRef.current = { x: event.clientX, y: event.clientY, target: event.target }; + }; + + const onClick = (event: MouseEvent): void => { + const state = stateRef.current; + if (state.tool === "move") return; + if (isUiOverlay(event.target)) return; + const down = downRef.current; + downRef.current = null; + if (down && Math.hypot(event.clientX - down.x, event.clientY - down.y) > 8) return; + + if (state.tool === "remove") { + const el = event.target as Element | null; + const meshEl = el?.closest(".builder-placed") as HTMLElement | null; + const id = meshEl?.dataset.polyMeshId; + if (!id) return; + event.preventDefault(); + event.stopPropagation(); + event.stopImmediatePropagation(); + state.onRemoveItem(id); + return; + } + + const hit = projectAt(event.clientX, event.clientY); + if (!hit) return; + event.preventDefault(); + event.stopPropagation(); + event.stopImmediatePropagation(); + state.onAddShapeAt(hit[0], hit[1]); + }; + const onPointerLeave = (): void => setHoverCell(null); + + cameraEl.addEventListener("pointerdown", onPointerDown, true); + cameraEl.addEventListener("pointermove", onPointerMove, true); + cameraEl.addEventListener("pointerleave", onPointerLeave, true); + cameraEl.addEventListener("click", onClick, true); + return () => { + cameraEl.removeEventListener("pointerdown", onPointerDown, true); + cameraEl.removeEventListener("pointermove", onPointerMove, true); + cameraEl.removeEventListener("pointerleave", onPointerLeave, true); + cameraEl.removeEventListener("click", onClick, true); + downRef.current = null; + setHoverCell(null); + stateRef.current.onDraggingChanged(false); + }; + }, [cameraElRef, store]); + + return null; +} + export function BuilderScene({ sceneOptions, updateScene, @@ -65,7 +402,6 @@ export function BuilderScene({ renderedPolygonsById, interiorShellPolygonsById, selectedId, - gizmoMode, gizmoDragging, meshHandlesRef, getMeshRefCallback, @@ -73,10 +409,16 @@ export function BuilderScene({ onSelectionChange, onGizmoDraggingChanged, onGizmoObjectChange, + onSelectedMeshDrag, + onStepSelectedElevation, + builderTool, + onAddShapeAt, + onRemoveItem, selected, }: BuilderSceneProps) { const Cam = sceneOptions.perspective === false ? PolyOrthographicCamera : PolyPerspectiveCamera; const sceneKey = sceneOptions.meshResolution; + const [addHoverCell, setAddHoverCell] = useState<[number, number] | null>(null); 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 }; @@ -86,16 +428,94 @@ export function BuilderScene({ zoom: cam.zoom, ...(cam.target ? { target: cam.target } : {}), }); + const selectedWireframePolygons = useMemo(() => { + if (!selected?.rawPolygons) return []; + const polygons = renderedPolygonsById.get(selected.id) ?? selected.rawPolygons; + const bbox = meshBbox(polygons); + const fitScale = Math.max(selected.fitScale, 0.0001); + const combinedScale = Math.max(selected.fitScale * selected.scale, 0.0001); + const cellSize = sceneOptions.gridResolution > 0 ? sceneOptions.gridResolution : 10; + const edgeHalf = 0.07 / combinedScale; + return buildSolidWireframePolygons({ + worldX: bbox.midX, + worldY: bbox.midY, + hx: cellSize / (2 * fitScale), + hy: cellSize / (2 * fitScale), + height: cellSize / fitScale, + baseZ: bbox.minZ, + }, "#00d9ff", edgeHalf); + }, [renderedPolygonsById, sceneOptions.gridResolution, selected]); + const addHoverPolygons = useMemo(() => { + if (!addHoverCell || builderTool !== "add" || !sceneOptions.showGround) return []; + const [cellX, cellY] = addHoverCell; + const cellSize = sceneOptions.gridResolution > 0 ? sceneOptions.gridResolution : 10; + const x0 = cellX * cellSize; + const x1 = (cellX + 1) * cellSize; + const y0 = cellY * cellSize; + const y1 = (cellY + 1) * cellSize; + const z = 0.04; + const color = "rgba(34, 211, 238, 0.22)"; + return [{ + vertices: [ + [x0, y0, z], + [x1, y0, z], + [x1, y1, z], + [x0, y1, z], + ], + color, + }]; + }, [addHoverCell, builderTool, sceneOptions.gridResolution, sceneOptions.showGround]); + const groundFillPolygons = useMemo(() => { + const half = BUILDER_GROUND_SPAN / 2; + const [cx, cy] = sceneOptions.target; + return [{ + vertices: [ + [cx - half, cy - half, -0.03], + [cx + half, cy - half, -0.03], + [cx + half, cy + half, -0.03], + [cx - half, cy + half, -0.03], + ], + color: GROUND_FILL_COLORS[sceneOptions.gridTone], + }]; + }, [sceneOptions.gridTone, sceneOptions.target]); + + useEffect(() => { + if (builderTool !== "add") setAddHoverCell(null); + }, [builderTool]); return ( + + {sceneOptions.dragMode === "pan" ? ( - + <> + + + ) : sceneOptions.dragMode === "fpv" ? ( ) : ( - + <> + + + )} - {/* Unified floor + terrain grid — the gridlines themselves - carry the heightmap elevation, so raised vertices bend the - grid rather than peeking out from under a separate fill. */} - {sceneOptions.showGround && } + {sceneOptions.showGround && ( + <> + + + + )} + {addHoverPolygons.length > 0 && ( + + )} {/* Terrain hover ghost — small cyan marker over the vertex the next click will modify. */} {terrainHoverPolygons.length > 0 && ( @@ -180,15 +613,22 @@ export function BuilderScene({ {shell.length > 0 ? ( ) : null} + {it.id === selectedId && selectedWireframePolygons.length > 0 ? ( + + ) : null} ); })} - {selected && ( + {selected && builderTool === "move" && ( diff --git a/website/src/components/BuilderWorkbench/components/BuilderSceneOutliner.tsx b/website/src/components/BuilderWorkbench/components/BuilderSceneOutliner.tsx index 894b26fe..4a9102b4 100644 --- a/website/src/components/BuilderWorkbench/components/BuilderSceneOutliner.tsx +++ b/website/src/components/BuilderWorkbench/components/BuilderSceneOutliner.tsx @@ -1,28 +1,23 @@ import { stripParenthesizedText } from "../../GalleryWorkbench/presets"; -import type { GizmoMode } from "../../types"; import type { PlacedItem } from "../types"; export interface BuilderSceneOutlinerProps { placedItems: PlacedItem[]; selectedId: string | null; - gizmoMode: GizmoMode; onSelectItem: (id: string) => void; onDeleteItem: (id: string) => void; - onGizmoModeChange: (mode: GizmoMode) => void; } export function BuilderSceneOutliner({ placedItems, selectedId, - gizmoMode, onSelectItem, onDeleteItem, - onGizmoModeChange, }: BuilderSceneOutlinerProps) { return (
    {placedItems.length === 0 ? ( -

    Click a model on the left to add it.

    +

    No shapes placed.

    ) : (
      {placedItems.map((it) => ( @@ -50,20 +45,6 @@ export function BuilderSceneOutliner({ ))}
    )} -
    - - -
    ); } diff --git a/website/src/components/BuilderWorkbench/components/BuilderToolPalette.tsx b/website/src/components/BuilderWorkbench/components/BuilderToolPalette.tsx index b2a3a932..57a9d389 100644 --- a/website/src/components/BuilderWorkbench/components/BuilderToolPalette.tsx +++ b/website/src/components/BuilderWorkbench/components/BuilderToolPalette.tsx @@ -4,7 +4,6 @@ const TOOLS: Array<{ mode: ToolMode; glyph: string; label: string }> = [ { mode: "pointer", glyph: "↖", label: "Pointer" }, { mode: "raise", glyph: "↑", label: "Raise" }, { mode: "lower", glyph: "↓", label: "Lower" }, - { mode: "smooth", glyph: "~", label: "Smooth" }, ]; export interface BuilderToolPaletteProps { diff --git a/website/src/components/BuilderWorkbench/components/BuilderToolRibbon.tsx b/website/src/components/BuilderWorkbench/components/BuilderToolRibbon.tsx new file mode 100644 index 00000000..fc2ac06d --- /dev/null +++ b/website/src/components/BuilderWorkbench/components/BuilderToolRibbon.tsx @@ -0,0 +1,54 @@ +import type { BuilderToolMode } from "../types"; + +export interface BuilderToolRibbonProps { + mode: BuilderToolMode; + onChange: (mode: BuilderToolMode) => void; + hasActiveShape: boolean; + onRestart: () => void; +} + +const TOOLS: Array<{ id: BuilderToolMode; label: string; icon: string }> = [ + { id: "move", label: "Move", icon: "open_with" }, + { id: "add", label: "Add", icon: "add_box" }, + { id: "remove", label: "Remove", icon: "delete" }, +]; + +export function BuilderToolRibbon({ + mode, + onChange, + hasActiveShape, + onRestart, +}: BuilderToolRibbonProps) { + return ( +
    + {TOOLS.map((tool) => ( + + ))} +
    + ); +} diff --git a/website/src/components/BuilderWorkbench/components/ShapePicker.tsx b/website/src/components/BuilderWorkbench/components/ShapePicker.tsx new file mode 100644 index 00000000..d87631f0 --- /dev/null +++ b/website/src/components/BuilderWorkbench/components/ShapePicker.tsx @@ -0,0 +1,81 @@ +import type { BuilderShapePreset } from "../shapePresets"; +import type { BuilderGridTone } from "../../types"; + +export interface ShapePickerProps { + shapes: BuilderShapePreset[]; + activeShapeId: string | null; + onShapeClick: (id: string) => void; + gridTone: BuilderGridTone; + onToggleGridTone: () => void; + onImportClick: () => void; +} + +export function ShapePicker({ + shapes, + activeShapeId, + onShapeClick, + gridTone, + onToggleGridTone, + onImportClick, +}: ShapePickerProps) { + const nextGridTone = gridTone === "gray" ? "dark" : "gray"; + return ( + + ); +} diff --git a/website/src/components/BuilderWorkbench/defaults.ts b/website/src/components/BuilderWorkbench/defaults.ts index 3f60fe85..894644cd 100644 --- a/website/src/components/BuilderWorkbench/defaults.ts +++ b/website/src/components/BuilderWorkbench/defaults.ts @@ -3,9 +3,12 @@ import type { SceneOptionsState } from "../types"; export const BUILDER_KIT_CATEGORIES: string[] = ["City Kit", "Urban Pack", "Medieval Village"]; export const PARSER_DEFAULTS = { targetSize: 60, gridShift: 1, defaultColor: "#8b95a1" }; -export const NORMALIZED_MAX_DIM = 8; +export const NORMALIZED_MAX_DIM = 10; export const GRID_STEP = 10; export const GRID_COLS = 3; +export const BUILDER_GROUND_SPAN = 1600; +export const BUILDER_MAX_CAMERA_ROT_X = 80; +export const BUILDER_DEFAULT_GRID_RESOLUTION = 10; // Builder starts with the same scene defaults as the gallery's chicken preset // so the camera / lighting / strategies feel familiar to anyone coming from @@ -18,7 +21,7 @@ export const DEFAULT_SCENE: SceneOptionsState = { autoCenter: true, interactive: true, animate: false, - showAxes: true, + showAxes: false, // Selection is always on in the builder — picking a placed mesh is core // to its workflow. The Interaction folder is hidden in this surface so // there's no toggle to flip this off. @@ -35,18 +38,18 @@ export const DEFAULT_SCENE: SceneOptionsState = { lightColor: "#ffffff", ambientIntensity: 0.4, ambientColor: "#ffffff", - textureLighting: "baked", + textureLighting: "dynamic", textureQuality: "auto", solidMaterials: false, matrixPrecision: "exact", borderShapePrecision: "exact", meshResolution: "lossy", - interiorFill: false, + interiorFill: true, outlinePolygons: false, dragMode: "orbit", target: [0, 0, 0], disableStrategies: [], - castShadow: false, + castShadow: true, shadowMaxExtend: 2000, showGround: true, fpvLook: true, @@ -62,5 +65,6 @@ export const DEFAULT_SCENE: SceneOptionsState = { fpvInvertY: false, fpvRenderDistance: 40, snapToGrid: true, - gridResolution: 5, + gridResolution: BUILDER_DEFAULT_GRID_RESOLUTION, + gridTone: "gray", }; diff --git a/website/src/components/BuilderWorkbench/geometry/ghost.ts b/website/src/components/BuilderWorkbench/geometry/ghost.ts index eee0b965..2cc10c87 100644 --- a/website/src/components/BuilderWorkbench/geometry/ghost.ts +++ b/website/src/components/BuilderWorkbench/geometry/ghost.ts @@ -57,6 +57,12 @@ const DOT_LENGTH = 0.5; /** Approx gap between consecutive dots. */ const GAP_LENGTH = 0.5; +export interface WireframeStyle { + edgeHalf?: number; + dotLength?: number; + gapLength?: number; +} + /** Build the 6 axis-aligned face quads of an arbitrary cuboid using * axisBox's vertex labelling + CCW-from-outside winding. Each face's * surface normal points OUTWARD so polycss's basis chooser keeps the @@ -90,12 +96,12 @@ function cuboidFaces( * across edges of different lengths — short edges get fewer dots. * Dots always include both endpoints (so corners of the bbox always * have visible markers). */ -function dotSpans(length: number): Array<[number, number]> { - const pattern = DOT_LENGTH + GAP_LENGTH; +function dotSpans(length: number, dotLength: number = DOT_LENGTH, gapLength: number = GAP_LENGTH): Array<[number, number]> { + const pattern = dotLength + gapLength; const count = Math.max(2, Math.round(length / pattern)); // Distribute evenly: the centres of `count` dots sit at fractions // i/(count-1) of the edge for i=0..count-1. - const halfDot = DOT_LENGTH / 2; + const halfDot = dotLength / 2; const spans: Array<[number, number]> = []; for (let i = 0; i < count; i++) { const centre = (i / (count - 1)) * length; @@ -111,7 +117,11 @@ function dotSpans(length: number): Array<[number, number]> { * outline reads as a dashed bbox at the placement cursor. Closed * cuboids (not flat slabs) so each dot stays 3D regardless of the * camera angle, with axisBox winding for stable rendering. */ -export function buildGhostWireframePolygons(rect: GhostWorldRect, color: string = GHOST_COLOR): Polygon[] { +export function buildGhostWireframePolygons( + rect: GhostWorldRect, + color: string = GHOST_COLOR, + style: WireframeStyle = {}, +): Polygon[] { const { worldX, worldY, hx, hy, height } = rect; const x0 = worldX - hx; const x1 = worldX + hx; @@ -119,12 +129,14 @@ export function buildGhostWireframePolygons(rect: GhostWorldRect, color: string const y1 = worldY + hy; const z0 = rect.baseZ ?? 0; const z1 = z0 + height; - const t = EDGE_HALF; + const t = style.edgeHalf ?? EDGE_HALF; + const dotLength = style.dotLength ?? DOT_LENGTH; + const gapLength = style.gapLength ?? GAP_LENGTH; const polys: Polygon[] = []; // 4 X-direction edges — dot spans run along X. - const xSpans = dotSpans(x1 - x0); + const xSpans = dotSpans(x1 - x0, dotLength, gapLength); for (const y of [y0, y1]) { for (const z of [z0, z1]) { for (const [a, b] of xSpans) { @@ -133,7 +145,7 @@ export function buildGhostWireframePolygons(rect: GhostWorldRect, color: string } } // 4 Y-direction edges — dot spans run along Y. - const ySpans = dotSpans(y1 - y0); + const ySpans = dotSpans(y1 - y0, dotLength, gapLength); for (const x of [x0, x1]) { for (const z of [z0, z1]) { for (const [a, b] of ySpans) { @@ -142,7 +154,7 @@ export function buildGhostWireframePolygons(rect: GhostWorldRect, color: string } } // 4 Z-direction edges — dot spans run along Z. - const zSpans = dotSpans(z1 - z0); + const zSpans = dotSpans(z1 - z0, dotLength, gapLength); for (const x of [x0, x1]) { for (const y of [y0, y1]) { for (const [a, b] of zSpans) { @@ -154,6 +166,43 @@ export function buildGhostWireframePolygons(rect: GhostWorldRect, color: string return polys; } +/** Build one continuous cuboid stick for each bbox edge. This is used + * for selected-shape highlighting, where a solid cage is easier to see + * than the dotted placement preview. */ +export function buildSolidWireframePolygons( + rect: GhostWorldRect, + color: string = GHOST_COLOR, + edgeHalf: number = EDGE_HALF, +): Polygon[] { + const { worldX, worldY, hx, hy, height } = rect; + const x0 = worldX - hx; + const x1 = worldX + hx; + const y0 = worldY - hy; + const y1 = worldY + hy; + const z0 = rect.baseZ ?? 0; + const z1 = z0 + height; + const t = edgeHalf; + const polys: Polygon[] = []; + + for (const y of [y0, y1]) { + for (const z of [z0, z1]) { + polys.push(...cuboidFaces(x0, x1, y - t, y + t, z - t, z + t, color)); + } + } + for (const x of [x0, x1]) { + for (const z of [z0, z1]) { + polys.push(...cuboidFaces(x - t, x + t, y0, y1, z - t, z + t, color)); + } + } + for (const x of [x0, x1]) { + for (const y of [y0, y1]) { + polys.push(...cuboidFaces(x - t, x + t, y - t, y + t, z0, z1, color)); + } + } + + return polys; +} + /** * Rotate every vertex of every polygon around `pivot` so that the * resulting world-coord polygons, once rendered through `cssPoints`' @@ -266,4 +315,3 @@ export function ghostRectFromBbox( baseZ, }; } - diff --git a/website/src/components/BuilderWorkbench/geometry/grid.ts b/website/src/components/BuilderWorkbench/geometry/grid.ts index c184006f..2c0a5a55 100644 --- a/website/src/components/BuilderWorkbench/geometry/grid.ts +++ b/website/src/components/BuilderWorkbench/geometry/grid.ts @@ -1,34 +1,32 @@ /** * Editor floor grid for the /builder viewport — terrain-aware. * - * Each gridline is broken into per-cell segments whose endpoints sit at - * the heightmap's vertex elevations. Flat regions (every segment in a - * row has both endpoints at z = 0) collapse into one long slab so a - * pristine heightmap stays cheap (~80 polygons, same as before). Each - * raised vertex breaks the lines that pass through it into a short - * elevated segment + adjacent flat runs — the grid bends to meet the - * new bump. + * Flat rows/columns are emitted as one slab per visible line. Raised + * terrain vertices split only the affected line runs, so the normal flat + * grid stays cheap without relying on a transformed CSS background. */ import type { Polygon, Vec3 } from "@layoutit/polycss-core"; import { vertexKey, type TerrainVertices } from "./terrain"; export interface BuilderGridOptions { - /** Side length of the grid in world units. Default 200. */ + /** Side length of the mounted grid window in world units. Default 200. */ size?: number; - /** Distance between adjacent gridlines in world units. Default 5. */ + /** World-space center for the mounted grid window. Defaults to origin. */ + center?: [number, number]; + /** Distance between adjacent gridlines in world units. Default 10. */ spacing?: number; - /** Line width in world units. Default 0.05 — reads as a hairline at + /** Line width in world units. Default 0.16 — keeps the same grid + * style while reducing high-frequency shimmer at oblique angles. * orbit distance. */ thickness?: number; /** Color of each gridline. */ color?: string; - /** Heightmap. Empty map ⇒ flat grid (every line is one long slab). */ + /** Heightmap. Empty map ⇒ one polygon per visible grid line. */ vertices?: TerrainVertices; } /** Emit a flat slab between two vertex indices along a constant-Y row - * (X-direction line). Both endpoints are at z = 0 — used for flat - * runs that collapsed during scan. */ + * (X-direction line). Both endpoints are at z = 0 — used for flat runs. */ function flatXSlab( i0: number, i1: number, j: number, spacing: number, halfT: number, color: string, @@ -106,22 +104,29 @@ function ySegment( export function buildGridPolygons(options: BuilderGridOptions = {}): Polygon[] { const size = options.size ?? 200; - const spacing = options.spacing ?? 5; - const thickness = options.thickness ?? 0.05; - const color = options.color ?? "#3a4250"; + const spacing = options.spacing ?? 10; + const thickness = options.thickness ?? 0.16; + const color = options.color ?? "#2f3a49"; const vertices = options.vertices ?? new Map(); + const center = options.center ?? [0, 0]; const halfT = thickness / 2; const halfCells = Math.floor(size / 2 / spacing); + const centerI = Math.round(center[0] / spacing); + const centerJ = Math.round(center[1] / spacing); + const minI = centerI - halfCells; + const maxI = centerI + halfCells; + const minJ = centerJ - halfCells; + const maxJ = centerJ + halfCells; const getZ = (i: number, j: number): number => vertices.get(vertexKey(i, j)) ?? 0; const polys: Polygon[] = []; - // X-direction lines at each j. Walk i; collapse runs of flat - // segments into one long slab, emit elevated segments individually. - for (let j = -halfCells; j <= halfCells; j++) { + // X-direction lines at each j. Walk i; collapse flat segments into + // one slab per run, and emit elevated segments individually. + for (let j = minJ; j <= maxJ; j++) { let runStart: number | null = null; - for (let i = -halfCells; i < halfCells; i++) { + for (let i = minI; i < maxI; i++) { const zL = getZ(i, j); const zR = getZ(i + 1, j); const isFlat = zL === 0 && zR === 0; @@ -135,13 +140,13 @@ export function buildGridPolygons(options: BuilderGridOptions = {}): Polygon[] { polys.push(xSegment(i, j, zL, zR, spacing, halfT, color)); } } - if (runStart !== null) polys.push(flatXSlab(runStart, halfCells, j, spacing, halfT, color)); + if (runStart !== null) polys.push(flatXSlab(runStart, maxI, j, spacing, halfT, color)); } // Y-direction lines at each i. - for (let i = -halfCells; i <= halfCells; i++) { + for (let i = minI; i <= maxI; i++) { let runStart: number | null = null; - for (let j = -halfCells; j < halfCells; j++) { + for (let j = minJ; j < maxJ; j++) { const zL = getZ(i, j); const zU = getZ(i, j + 1); const isFlat = zL === 0 && zU === 0; @@ -155,7 +160,7 @@ export function buildGridPolygons(options: BuilderGridOptions = {}): Polygon[] { polys.push(ySegment(i, j, zL, zU, spacing, halfT, color)); } } - if (runStart !== null) polys.push(flatYSlab(i, runStart, halfCells, spacing, halfT, color)); + if (runStart !== null) polys.push(flatYSlab(i, runStart, maxJ, spacing, halfT, color)); } return polys; diff --git a/website/src/components/BuilderWorkbench/geometry/screenToWorld.ts b/website/src/components/BuilderWorkbench/geometry/screenToWorld.ts index b2ebbbca..8ec5cc02 100644 --- a/website/src/components/BuilderWorkbench/geometry/screenToWorld.ts +++ b/website/src/components/BuilderWorkbench/geometry/screenToWorld.ts @@ -1,6 +1,6 @@ /** - * Project a screen-space pointer position to a world-space point on the - * Z=0 ground plane. + * Project a screen-space pointer position to a world-space point on a + * horizontal world-Z plane. * * The polycss CSS transform stack (on the `.polycss-scene` element) is: * @@ -26,9 +26,9 @@ * 3. Apply M^-1 to bring both points into scene-local space. * M^-1 = translate(+cssTarget) * rotate(-rotY) * rotateX(-rotX) * scale(1/zoom) * Apply to eye and a far point (t=2) on the ray. - * 4. In scene-local space, CSS-Z = 0 IS world Z = 0 (because the + * 4. In scene-local space, CSS-Z = planeWorldZ * BASE_TILE (because the * polycss axis swap maps worldZ to cssZ). Parameterise the scene-local - * ray and solve for the t that gives cssZ = 0. + * ray and solve for the t that gives that cssZ. * 5. Read cssX, cssY at that t. Convert back: * worldX = cssY / BASE_TILE * worldY = cssX / BASE_TILE @@ -108,10 +108,12 @@ export interface ProjectScreenToWorldGroundArgs { sceneOptions: Pick; /** autoCenterOffset from the scene store — [worldX, worldY, worldZ]. */ autoCenterOffset: [number, number, number]; + /** World-space horizontal plane to project onto. Defaults to ground Z=0. */ + planeWorldZ?: number; } /** - * Returns [worldX, worldY] on the Z=0 ground plane for a pointer event, or + * Returns [worldX, worldY] on the requested horizontal plane for a pointer event, or * `null` if the ray is parallel to the ground (degenerate camera angle). */ export function projectScreenToWorldGround({ @@ -120,6 +122,7 @@ export function projectScreenToWorldGround({ cameraEl, sceneOptions, autoCenterOffset, + planeWorldZ = 0, }: ProjectScreenToWorldGroundArgs): [number, number] | null { const rect = cameraEl.getBoundingClientRect(); if (rect.width === 0 || rect.height === 0) return null; @@ -162,15 +165,16 @@ export function projectScreenToWorldGround({ rayFarScene = applyInverseTransform(far, zoom, rotX, rotY, cssX, cssY, cssZ); } - // In scene-local space, CSS-Z = 0 IS world Z = 0. + // In scene-local space, CSS-Z maps directly to world Z. // Ray: R(t) = rayOriginScene + t * (rayFarScene - rayOriginScene) - // Solve for t such that R(t)[2] = 0. + // Solve for t such that R(t)[2] = planeWorldZ * BASE_TILE. const dz = rayFarScene[2] - rayOriginScene[2]; if (Math.abs(dz) < 1e-10) { // Ray is parallel to the ground plane — can't intersect. return null; } - const t = -rayOriginScene[2] / dz; + const planeCssZ = planeWorldZ * BASE_TILE; + const t = (planeCssZ - rayOriginScene[2]) / dz; const hitCssX = rayOriginScene[0] + t * (rayFarScene[0] - rayOriginScene[0]); const hitCssY = rayOriginScene[1] + t * (rayFarScene[1] - rayOriginScene[1]); diff --git a/website/src/components/BuilderWorkbench/geometry/snap.ts b/website/src/components/BuilderWorkbench/geometry/snap.ts new file mode 100644 index 00000000..8b4e5d8f --- /dev/null +++ b/website/src/components/BuilderWorkbench/geometry/snap.ts @@ -0,0 +1,22 @@ +export function resolveGridStep(gridResolution: number, fallback = 10): number { + return Number.isFinite(gridResolution) && gridResolution > 0 ? gridResolution : fallback; +} + +export function worldToGridCell(worldX: number, worldY: number, gridResolution: number): [number, number] { + const step = resolveGridStep(gridResolution); + return [Math.floor(worldX / step), Math.floor(worldY / step)]; +} + +export function gridCellCenter(cellX: number, cellY: number, gridResolution: number): [number, number] { + const step = resolveGridStep(gridResolution); + return [cellX * step + step / 2, cellY * step + step / 2]; +} + +export function snapWorldToCellCenter( + worldX: number, + worldY: number, + gridResolution: number, +): [number, number] { + const [cellX, cellY] = worldToGridCell(worldX, worldY, gridResolution); + return gridCellCenter(cellX, cellY, gridResolution); +} diff --git a/website/src/components/BuilderWorkbench/hooks/usePlacementMode.ts b/website/src/components/BuilderWorkbench/hooks/usePlacementMode.ts index 45f0543c..a3ede4ea 100644 --- a/website/src/components/BuilderWorkbench/hooks/usePlacementMode.ts +++ b/website/src/components/BuilderWorkbench/hooks/usePlacementMode.ts @@ -9,6 +9,7 @@ import { placeMeshOnFloor } from "../geometry/placement"; import { buildGhostWireframePolygons, ghostRectFromBbox, GHOST_COLOR, rotatePolygonsAroundPivot } from "../geometry/ghost"; import type { Bbox } from "../geometry/ghost"; import { projectScreenToWorldGround } from "../geometry/screenToWorld"; +import { snapWorldToCellCenter } from "../geometry/snap"; import { sampleTerrain, rotationForSlope, type TerrainVertices } from "../geometry/terrain"; import type { PlacedItem, PlacementDraft, TargetMode } from "../types"; import { activeMeshResolution, type SceneOptionsState } from "../../types"; @@ -113,6 +114,8 @@ export function usePlacementMode({ position, rotation, scale: 1, + elevation: 0, + color: rawPolygons.find((polygon) => polygon.color)?.color ?? "#8b95a1", fitScale, worldX: wx, worldY: wy, @@ -194,7 +197,7 @@ export function usePlacementMode({ // Face target → snap to cell CENTRE (floor + ½ step). Vertex // target → snap to nearest grid intersection (round). if (targetMode === "face") { - return [Math.floor(hit[0] / step) * step + step / 2, Math.floor(hit[1] / step) * step + step / 2]; + return snapWorldToCellCenter(hit[0], hit[1], step); } return [Math.round(hit[0] / step) * step, Math.round(hit[1] / step) * step]; }; diff --git a/website/src/components/BuilderWorkbench/hooks/usePlacements.ts b/website/src/components/BuilderWorkbench/hooks/usePlacements.ts index 1875ffd2..7f24b604 100644 --- a/website/src/components/BuilderWorkbench/hooks/usePlacements.ts +++ b/website/src/components/BuilderWorkbench/hooks/usePlacements.ts @@ -1,8 +1,8 @@ -import { useCallback, useRef, useState, type RefObject } from "react"; +import { useCallback, useEffect, useRef, useState, type RefObject } from "react"; import { optimizeMeshPolygons } from "@layoutit/polycss-react"; import type { PolyMeshHandle, Vec3 } from "@layoutit/polycss-react"; -import type { PresetModel } from "../../GalleryWorkbench/types"; -import { loadPresetModel } from "../../GalleryWorkbench/helpers/loaders"; +import type { DroppedModelSource, PresetModel } from "../../GalleryWorkbench/types"; +import { loadDroppedModel, loadPresetModel } from "../../GalleryWorkbench/helpers/loaders"; import { PARSER_DEFAULTS, NORMALIZED_MAX_DIM } from "../defaults"; import { meshBbox } from "../geometry/meshBbox"; import { placeMeshOnFloor } from "../geometry/placement"; @@ -11,6 +11,7 @@ import { activeMeshResolution, type WorkbenchMeshResolution } from "../../types" export interface UsePlacementsOptions { meshResolution: WorkbenchMeshResolution; + gridResolution: number; } export interface UsePlacementsResult { @@ -22,9 +23,16 @@ export interface UsePlacementsResult { preset: PresetModel, worldX: number, worldY: number, - opts?: { rotation?: Vec3; scale?: number }, + opts?: { rotation?: Vec3; scale?: number; elevation?: number; color?: string }, + ) => Promise; + buildDroppedPlacement: ( + source: DroppedModelSource, + worldX: number, + worldY: number, + opts?: { rotation?: Vec3; scale?: number; elevation?: number; color?: string }, ) => Promise; appendItems: (items: PlacedItem[]) => void; + replaceItems: (items: PlacedItem[]) => void; updateItem: (id: string, partial: Partial) => void; mapItems: (updater: (item: PlacedItem) => PlacedItem) => void; handleDeleteItem: (id: string) => void; @@ -36,12 +44,17 @@ export interface UsePlacementsResult { meshHandlesTick: number; } -export function usePlacements({ meshResolution }: UsePlacementsOptions): UsePlacementsResult { +export function usePlacements({ meshResolution, gridResolution }: UsePlacementsOptions): UsePlacementsResult { const effectiveMeshResolution = activeMeshResolution(meshResolution); const [placedItems, setPlacedItems] = useState([]); const [selectedId, setSelectedId] = useState(null); const [meshHandlesTick, setMeshHandlesTick] = useState(0); const placementCounter = useRef(0); + const placedItemsRef = useRef([]); + + const disposeItem = (item: PlacedItem): void => { + item.dispose?.(); + }; // Per-item handles indexed by id. Populated by each PolyMesh's callback // ref on mount and updated/removed on unmount. Storing in a Map (instead of @@ -91,13 +104,14 @@ export function usePlacements({ meshResolution }: UsePlacementsOptions): UsePlac preset: PresetModel, worldX: number, worldY: number, - opts: { rotation?: Vec3; scale?: number } = {}, + opts: { rotation?: Vec3; scale?: number; elevation?: number; color?: string } = {}, ): Promise => { try { 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; + const targetSize = gridResolution > 0 ? gridResolution : NORMALIZED_MAX_DIM; + const fitScale = bbox.span > 0 ? targetSize / bbox.span : 1; const placement = placeMeshOnFloor(worldX, worldY, bbox, fitScale); const n = placementCounter.current++; return { @@ -107,22 +121,77 @@ export function usePlacements({ meshResolution }: UsePlacementsOptions): UsePlac position: placement, rotation: opts.rotation ?? [0, 0, 0], scale: opts.scale ?? 1, + elevation: opts.elevation ?? 0, + color: opts.color ?? loaded.rawPolygons.find((polygon) => polygon.color)?.color ?? "#8b95a1", + colorOverride: true, fitScale, worldX, worldY, + dispose: loaded.dispose, }; } catch (e) { console.error("[builder] failed to load preset", preset.id, e); return null; } }, - [effectiveMeshResolution], + [effectiveMeshResolution, gridResolution], + ); + + const buildDroppedPlacement = useCallback( + async ( + source: DroppedModelSource, + worldX: number, + worldY: number, + opts: { rotation?: Vec3; scale?: number; elevation?: number; color?: string } = {}, + ): Promise => { + let loaded: Awaited> | null = null; + try { + loaded = await loadDroppedModel(source, PARSER_DEFAULTS, effectiveMeshResolution); + const optimized = optimizeMeshPolygons(loaded.rawPolygons, { meshResolution: effectiveMeshResolution }); + const bbox = meshBbox(optimized); + const targetSize = gridResolution > 0 ? gridResolution : NORMALIZED_MAX_DIM; + const fitScale = bbox.span > 0 ? targetSize / bbox.span : 1; + const placement = placeMeshOnFloor(worldX, worldY, bbox, fitScale); + const n = placementCounter.current++; + return { + id: `placed-${Date.now()}-${n}`, + preset: source.preset, + rawPolygons: loaded.rawPolygons, + position: placement, + rotation: opts.rotation ?? [0, 0, 0], + scale: opts.scale ?? 1, + elevation: opts.elevation ?? 0, + color: opts.color ?? loaded.rawPolygons.find((polygon) => polygon.color)?.color ?? "#8b95a1", + colorOverride: Boolean(opts.color), + fitScale, + worldX, + worldY, + dispose: loaded.dispose, + }; + } catch (e) { + loaded?.dispose(); + console.error("[builder] failed to import model", source.primaryFile.name, e); + return null; + } + }, + [effectiveMeshResolution, gridResolution], ); const appendItems = useCallback((items: PlacedItem[]) => { setPlacedItems((prev) => [...prev, ...items]); }, []); + const replaceItems = useCallback((items: PlacedItem[]) => { + setPlacedItems((prev) => { + for (const item of prev) disposeItem(item); + return items; + }); + setSelectedId(null); + meshHandlesRef.current.clear(); + meshRefCallbacksRef.current.clear(); + setMeshHandlesTick((n) => n + 1); + }, []); + const updateItem = useCallback((id: string, partial: Partial) => { setPlacedItems((items) => items.map((it) => (it.id === id ? { ...it, ...partial } : it))); }, []); @@ -136,7 +205,11 @@ export function usePlacements({ meshResolution }: UsePlacementsOptions): UsePlac }, []); const handleDeleteItem = useCallback((id: string) => { - setPlacedItems((items) => items.filter((it) => it.id !== id)); + setPlacedItems((items) => { + const removed = items.find((it) => it.id === id); + if (removed) disposeItem(removed); + return items.filter((it) => it.id !== id); + }); setSelectedId((cur) => (cur === id ? null : cur)); meshRefCallbacksRef.current.delete(id); meshHandlesRef.current.delete(id); @@ -153,13 +226,25 @@ export function usePlacements({ meshResolution }: UsePlacementsOptions): UsePlac if (id) handleDeleteItem(id); }; + useEffect(() => { + placedItemsRef.current = placedItems; + }, [placedItems]); + + useEffect(() => { + return () => { + for (const item of placedItemsRef.current) disposeItem(item); + }; + }, []); + return { placedItems, selectedId, setSelectedId, placementCounter, buildPlacement, + buildDroppedPlacement, appendItems, + replaceItems, updateItem, mapItems, handleDeleteItem, diff --git a/website/src/components/BuilderWorkbench/hooks/useSceneLoader.ts b/website/src/components/BuilderWorkbench/hooks/useSceneLoader.ts index f2f127e7..687b5b66 100644 --- a/website/src/components/BuilderWorkbench/hooks/useSceneLoader.ts +++ b/website/src/components/BuilderWorkbench/hooks/useSceneLoader.ts @@ -88,6 +88,8 @@ export function useSceneLoader({ position: [0, 0, 0], rotation: item.rotation ?? [0, 0, 0], scale: item.scale ?? 1, + elevation: item.position[2] ?? 0, + color: "#8b95a1", fitScale: 1, worldX: item.position[0], worldY: item.position[1], @@ -122,8 +124,13 @@ export function useSceneLoader({ }); const bbox = meshBbox(optimized); const fitScale = bbox.span > 0 ? NORMALIZED_MAX_DIM / bbox.span : 1; - const placement = placeMeshOnFloor(item.worldX, item.worldY, bbox, fitScale); - updateItem(item.id, { rawPolygons: loaded.rawPolygons, fitScale, position: placement }); + const placement = placeMeshOnFloor(item.worldX, item.worldY, bbox, fitScale, item.elevation); + updateItem(item.id, { + rawPolygons: loaded.rawPolygons, + color: item.color ?? loaded.rawPolygons.find((polygon) => polygon.color)?.color ?? "#8b95a1", + fitScale, + position: placement, + }); } catch (e) { console.error("[builder] lazy load failed", item.preset.id, e); } finally { diff --git a/website/src/components/BuilderWorkbench/hooks/useSceneRender.ts b/website/src/components/BuilderWorkbench/hooks/useSceneRender.ts index 8b7d45d9..8e600067 100644 --- a/website/src/components/BuilderWorkbench/hooks/useSceneRender.ts +++ b/website/src/components/BuilderWorkbench/hooks/useSceneRender.ts @@ -4,10 +4,20 @@ import type { PolyFirstPersonControlsHandle, Polygon } from "@layoutit/polycss-r import { interiorShellPolygons } from "../../helpers/interiorShell"; import { useFpvHost, useFpvCull } from "../../fpv"; import { activeMeshResolution, type SceneOptionsState } from "../../types"; +import { BUILDER_GROUND_SPAN } from "../defaults"; import { buildGridPolygons } from "../geometry/grid"; import type { TerrainVertices } from "../geometry/terrain"; import type { PlacedItem } from "../types"; +const GRID_LINE_COLORS = { + gray: "#9aa3ad", + dark: "#1f2937", +} as const; + +function applySolidColor(polygons: Polygon[], color: string): Polygon[] { + return polygons.map((polygon) => ({ ...polygon, color })); +} + export interface UseSceneRenderOptions { placedItems: PlacedItem[]; selectedId: string | null; @@ -43,11 +53,7 @@ export function useSceneRender({ const optimized = optimizeMeshPolygons(it.rawPolygons, { meshResolution: effectiveMeshResolution, }); - if (it.preset.kind === "vox") { - out.set(it.id, optimized); - continue; - } - out.set(it.id, optimized); + out.set(it.id, it.colorOverride === false ? optimized : applySolidColor(optimized, it.color)); } return out; }, [ @@ -117,8 +123,14 @@ export function useSceneRender({ }, [placedItems, visibleIds]); const gridPolygons = useMemo( - () => buildGridPolygons({ spacing: sceneOptions.gridResolution, vertices: terrainVertices }), - [sceneOptions.gridResolution, terrainVertices], + () => buildGridPolygons({ + size: BUILDER_GROUND_SPAN, + spacing: sceneOptions.gridResolution, + center: [sceneOptions.target[0], sceneOptions.target[1]], + color: GRID_LINE_COLORS[sceneOptions.gridTone], + vertices: terrainVertices, + }), + [sceneOptions.gridResolution, sceneOptions.gridTone, sceneOptions.target, terrainVertices], ); return { renderedPolygonsById, interiorShellPolygonsById, renderItems, gridPolygons }; diff --git a/website/src/components/BuilderWorkbench/hooks/useTerrain.ts b/website/src/components/BuilderWorkbench/hooks/useTerrain.ts index e245d95c..06db9e1b 100644 --- a/website/src/components/BuilderWorkbench/hooks/useTerrain.ts +++ b/website/src/components/BuilderWorkbench/hooks/useTerrain.ts @@ -4,8 +4,8 @@ * When `toolMode` is anything other than "pointer", the user is editing * the heightmap rather than placing meshes. We capture pointermove (to * update the hover ghost) and click (to apply the active tool) on the - * viewport in CAPTURE phase, mirroring `usePlacementMode` so orbit - * drag / mesh selection don't double-fire. + * viewport in CAPTURE phase so orbit drag / mesh selection don't + * double-fire. * * Heightmap is VERTEX-based: clicks snap to the nearest grid vertex * and raise / lower / smooth that vertex. The 4 cells touching the @@ -41,8 +41,8 @@ export interface UseTerrainOptions { export interface UseTerrainResult { /** All vertices with non-zero elevation. Consumed by useSceneRender - * (to build the warped grid) and usePlacementMode (to land meshes - * on top of the terrain with the local slope tilt). */ + * to build the warped grid, and by BuilderWorkbench shape placement + * to land meshes on top of the terrain with the local slope tilt. */ vertices: TerrainVertices; /** Polygons for the hover vertex marker (empty when not editing). */ hoverPolygons: Polygon[]; diff --git a/website/src/components/BuilderWorkbench/sceneUrl.ts b/website/src/components/BuilderWorkbench/sceneUrl.ts new file mode 100644 index 00000000..71fb93bc --- /dev/null +++ b/website/src/components/BuilderWorkbench/sceneUrl.ts @@ -0,0 +1,201 @@ +import type { Vec3 } from "@layoutit/polycss-react"; +import type { SceneOptionsState } from "../types"; +import { DEFAULT_SCENE } from "./defaults"; +import type { PlacedItem } from "./types"; + +const SCENE_PARAM = "scene"; + +interface SerializedBuilderItem { + p: string; + x: number; + y: number; + s?: number; + z?: number; + c?: string; + r?: Vec3; +} + +export interface SerializedBuilderScene { + v: 1; + i: SerializedBuilderItem[]; + o?: { + g?: number; + snap?: boolean; + ground?: boolean; + shadow?: boolean; + helper?: boolean; + key?: number; + amb?: number; + zoom?: number; + rx?: number; + ry?: number; + t?: Vec3; + gt?: SceneOptionsState["gridTone"]; + }; +} + +function round(value: number): number { + return Number(value.toFixed(4)); +} + +function roundVec3(value: Vec3): Vec3 { + return [round(value[0]), round(value[1]), round(value[2])]; +} + +function isVec3(value: unknown): value is Vec3 { + return Array.isArray(value) && + value.length === 3 && + value.every((entry) => typeof entry === "number" && Number.isFinite(entry)); +} + +function isHexColor(value: unknown): value is string { + return typeof value === "string" && /^#[0-9a-fA-F]{6}$/.test(value); +} + +function isGridTone(value: unknown): value is SceneOptionsState["gridTone"] { + return value === "gray" || value === "dark"; +} + +function bytesToBase64Url(bytes: Uint8Array): string { + let binary = ""; + for (let i = 0; i < bytes.length; i += 0x8000) { + binary += String.fromCharCode(...bytes.slice(i, i + 0x8000)); + } + return btoa(binary).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/g, ""); +} + +function base64UrlToBytes(value: string): Uint8Array { + const padded = value.replace(/-/g, "+").replace(/_/g, "/").padEnd(Math.ceil(value.length / 4) * 4, "="); + const binary = atob(padded); + const bytes = new Uint8Array(binary.length); + for (let i = 0; i < binary.length; i++) bytes[i] = binary.charCodeAt(i); + return bytes; +} + +function encodeJson(value: unknown): string { + return bytesToBase64Url(new TextEncoder().encode(JSON.stringify(value))); +} + +function decodeJson(value: string): unknown { + return JSON.parse(new TextDecoder().decode(base64UrlToBytes(value))); +} + +function sceneOptionsPayload(sceneOptions: SceneOptionsState): SerializedBuilderScene["o"] { + const out: NonNullable = {}; + if (sceneOptions.gridResolution !== DEFAULT_SCENE.gridResolution) out.g = round(sceneOptions.gridResolution); + if (sceneOptions.gridTone !== DEFAULT_SCENE.gridTone) out.gt = sceneOptions.gridTone; + if (sceneOptions.snapToGrid !== DEFAULT_SCENE.snapToGrid) out.snap = sceneOptions.snapToGrid; + if (sceneOptions.showGround !== DEFAULT_SCENE.showGround) out.ground = sceneOptions.showGround; + if (sceneOptions.castShadow !== DEFAULT_SCENE.castShadow) out.shadow = sceneOptions.castShadow; + if (sceneOptions.showLight !== DEFAULT_SCENE.showLight) out.helper = sceneOptions.showLight; + if (sceneOptions.lightIntensity !== DEFAULT_SCENE.lightIntensity) out.key = round(sceneOptions.lightIntensity); + if (sceneOptions.ambientIntensity !== DEFAULT_SCENE.ambientIntensity) out.amb = round(sceneOptions.ambientIntensity); + if (sceneOptions.zoom !== DEFAULT_SCENE.zoom) out.zoom = round(sceneOptions.zoom); + if (sceneOptions.rotX !== DEFAULT_SCENE.rotX) out.rx = round(sceneOptions.rotX); + if (sceneOptions.rotY !== DEFAULT_SCENE.rotY) out.ry = round(sceneOptions.rotY); + if ( + sceneOptions.target[0] !== DEFAULT_SCENE.target[0] || + sceneOptions.target[1] !== DEFAULT_SCENE.target[1] || + sceneOptions.target[2] !== DEFAULT_SCENE.target[2] + ) { + out.t = roundVec3(sceneOptions.target); + } + return Object.keys(out).length > 0 ? out : undefined; +} + +function isSerializableBuilderItem(item: PlacedItem): boolean { + return item.preset.id.startsWith("builder-shape-"); +} + +export function serializeBuilderSceneToParam( + placedItems: PlacedItem[], + sceneOptions: SceneOptionsState, +): string | null { + const options = sceneOptionsPayload(sceneOptions); + const serializableItems = placedItems.filter(isSerializableBuilderItem); + if (serializableItems.length === 0 && !options) return null; + const payload: SerializedBuilderScene = { + v: 1, + i: serializableItems.map((item) => ({ + p: item.preset.id, + x: round(item.worldX), + y: round(item.worldY), + ...(item.scale !== 1 ? { s: round(item.scale) } : null), + ...(item.elevation !== 0 ? { z: round(item.elevation) } : null), + ...(item.color ? { c: item.color } : null), + ...(item.rotation[0] !== 0 || item.rotation[1] !== 0 || item.rotation[2] !== 0 + ? { r: roundVec3(item.rotation) } + : null), + })), + o: options, + }; + return encodeJson(payload); +} + +export function updateBuilderSceneUrl(param: string | null): void { + if (typeof window === "undefined") return; + const url = new URL(window.location.href); + if (param) url.searchParams.set(SCENE_PARAM, param); + else url.searchParams.delete(SCENE_PARAM); + const next = `${url.pathname}${url.search}${url.hash}`; + if (next !== `${window.location.pathname}${window.location.search}${window.location.hash}`) { + window.history.replaceState(null, "", next); + } +} + +export function readBuilderSceneFromUrl(): SerializedBuilderScene | null { + if (typeof window === "undefined") return null; + const value = new URLSearchParams(window.location.search).get(SCENE_PARAM); + if (!value) return null; + try { + const decoded = decodeJson(value); + if (!decoded || typeof decoded !== "object") return null; + const scene = decoded as Partial; + if (scene.v !== 1 || !Array.isArray(scene.i)) return null; + const items = scene.i.filter((item): item is SerializedBuilderItem => { + if (!item || typeof item !== "object") return false; + const entry = item as Partial; + return typeof entry.p === "string" && + typeof entry.x === "number" && + Number.isFinite(entry.x) && + typeof entry.y === "number" && + Number.isFinite(entry.y); + }); + return { + v: 1, + i: items.map((item) => ({ + p: item.p, + x: item.x, + y: item.y, + ...(typeof item.s === "number" && Number.isFinite(item.s) ? { s: item.s } : null), + ...(typeof item.z === "number" && Number.isFinite(item.z) ? { z: item.z } : null), + ...(isHexColor(item.c) ? { c: item.c.toLowerCase() } : null), + ...(isVec3(item.r) ? { r: item.r } : null), + })), + o: scene.o && typeof scene.o === "object" ? scene.o : undefined, + }; + } catch { + return null; + } +} + +export function sceneOptionsFromSerialized( + scene: SerializedBuilderScene, +): Partial { + const options = scene.o; + if (!options) return {}; + return { + ...(typeof options.g === "number" && Number.isFinite(options.g) ? { gridResolution: options.g } : null), + ...(isGridTone(options.gt) ? { gridTone: options.gt } : null), + ...(typeof options.snap === "boolean" ? { snapToGrid: options.snap } : null), + ...(typeof options.ground === "boolean" ? { showGround: options.ground } : null), + ...(typeof options.shadow === "boolean" ? { castShadow: options.shadow } : null), + ...(typeof options.helper === "boolean" ? { showLight: options.helper } : null), + ...(typeof options.key === "number" && Number.isFinite(options.key) ? { lightIntensity: options.key } : null), + ...(typeof options.amb === "number" && Number.isFinite(options.amb) ? { ambientIntensity: options.amb } : null), + ...(typeof options.zoom === "number" && Number.isFinite(options.zoom) ? { zoom: options.zoom } : null), + ...(typeof options.rx === "number" && Number.isFinite(options.rx) ? { rotX: options.rx } : null), + ...(typeof options.ry === "number" && Number.isFinite(options.ry) ? { rotY: options.ry } : null), + ...(isVec3(options.t) ? { target: options.t } : null), + }; +} diff --git a/website/src/components/BuilderWorkbench/shapePresets.ts b/website/src/components/BuilderWorkbench/shapePresets.ts new file mode 100644 index 00000000..e4bf4d10 --- /dev/null +++ b/website/src/components/BuilderWorkbench/shapePresets.ts @@ -0,0 +1,71 @@ +import { + boxPolygons, + conePolygons, + cylinderPolygons, + dodecahedronPolygons, + icosahedronPolygons, + octahedronPolygons, + spherePolygons, + tetrahedronPolygons, + torusPolygons, +} from "@layoutit/polycss"; +import type { PresetModel } from "../GalleryWorkbench/types"; +import { POLYCSS_GENERATED_PRIMITIVE_ATTRIBUTION } from "../GalleryWorkbench/presets/attributions"; + +export interface BuilderShapePreset extends PresetModel { + color: string; + thumbnailSrc: string; +} + +function primitiveShapePreset( + id: string, + label: string, + color: string, + generatePolygons: PresetModel["generatePolygons"], +): BuilderShapePreset { + const slug = id.replace(/^builder-shape-/, ""); + return { + id, + label, + color, + thumbnailSrc: `/builder/shape-thumbnails/${slug}.png?v=transparent3`, + category: "Shapes", + kind: "primitive", + galleryBucket: "Primitives", + zoom: 0.05, + rotX: 65, + rotY: 45, + attribution: POLYCSS_GENERATED_PRIMITIVE_ATTRIBUTION, + generatePolygons, + }; +} + +export const BUILDER_SHAPE_PRESETS: BuilderShapePreset[] = [ + primitiveShapePreset("builder-shape-box", "Box", "#ff7043", () => + boxPolygons({ size: 100, color: "#ff7043" }), + ), + primitiveShapePreset("builder-shape-octahedron", "Octahedron", "#f59e0b", () => + octahedronPolygons({ center: [0, 0, 0], size: 84, color: "#f59e0b" }), + ), + primitiveShapePreset("builder-shape-sphere", "Sphere", "#60a5fa", () => + spherePolygons({ radius: 50, subdivisions: 1, color: "#60a5fa" }), + ), + primitiveShapePreset("builder-shape-tetrahedron", "Tetrahedron", "#f472b6", () => + tetrahedronPolygons({ size: 86, color: "#f472b6" }), + ), + primitiveShapePreset("builder-shape-icosahedron", "Icosahedron", "#c084fc", () => + icosahedronPolygons({ size: 84, color: "#c084fc" }), + ), + primitiveShapePreset("builder-shape-dodecahedron", "Dodecahed.", "#34d399", () => + dodecahedronPolygons({ size: 84, color: "#34d399" }), + ), + primitiveShapePreset("builder-shape-cylinder", "Cylinder", "#22d3ee", () => + cylinderPolygons({ radius: 42, height: 100, radialSegments: 12, color: "#22d3ee" }), + ), + primitiveShapePreset("builder-shape-cone", "Cone", "#fb7185", () => + conePolygons({ radius: 50, height: 100, radialSegments: 12, color: "#fb7185" }), + ), + primitiveShapePreset("builder-shape-torus", "Torus", "#facc15", () => + torusPolygons({ radius: 48, tube: 14, radialSegments: 12, tubularSegments: 16, color: "#facc15" }), + ), +]; diff --git a/website/src/components/BuilderWorkbench/slots/BuilderDockSlots.tsx b/website/src/components/BuilderWorkbench/slots/BuilderDockSlots.tsx new file mode 100644 index 00000000..1a935e8b --- /dev/null +++ b/website/src/components/BuilderWorkbench/slots/BuilderDockSlots.tsx @@ -0,0 +1,70 @@ +import { useDockGui, useFolder, useOption, useSlider, useToggle } from "../../Dock"; +import type { WorkbenchMeshResolution } from "../../types"; + +const MESH_RESOLUTION_OPTIONS: Record = { + Lossy: "lossy", + Lossless: "lossless", + Disabled: "disabled", +}; + +export interface DockBuilderRenderingInputs { + meshResolution: WorkbenchMeshResolution; + interiorFill: boolean; + onUpdateScene: (partial: { + meshResolution?: WorkbenchMeshResolution; + interiorFill?: boolean; + }) => void; +} + +export function DockBuilderRendering(inputs: DockBuilderRenderingInputs): null { + const folder = useFolder(useDockGui(), "Rendering", { open: false }); + useOption(folder, "Mesh resolution", MESH_RESOLUTION_OPTIONS, inputs.meshResolution, (value) => + inputs.onUpdateScene({ meshResolution: value }), + ); + useToggle(folder, "Interior fill", inputs.interiorFill, (value) => + inputs.onUpdateScene({ interiorFill: value }), + ); + return null; +} + +export interface DockBuilderViewInputs { + autoCenter: boolean; + showAxes: boolean; + onUpdateScene: (partial: { + autoCenter?: boolean; + showAxes?: boolean; + }) => void; +} + +export function DockBuilderView(inputs: DockBuilderViewInputs): null { + const folder = useFolder(useDockGui(), "View", { open: false }); + useToggle(folder, "Axes", inputs.showAxes, (value) => inputs.onUpdateScene({ showAxes: value })); + useToggle(folder, "Auto center", inputs.autoCenter, (value) => inputs.onUpdateScene({ autoCenter: value })); + return null; +} + +export interface DockBuilderLightingInputs { + castShadow: boolean; + showLight: boolean; + lightIntensity: number; + ambientIntensity: number; + onUpdateScene: (partial: { + castShadow?: boolean; + showLight?: boolean; + lightIntensity?: number; + ambientIntensity?: number; + }) => void; +} + +export function DockBuilderLighting(inputs: DockBuilderLightingInputs): null { + const folder = useFolder(useDockGui(), "Lighting", { open: false }); + useToggle(folder, "Cast shadow", inputs.castShadow, (value) => inputs.onUpdateScene({ castShadow: value })); + useToggle(folder, "Light helper", inputs.showLight, (value) => inputs.onUpdateScene({ showLight: value })); + useSlider(folder, "Key", { min: 0, max: 2, step: 0.05 }, inputs.lightIntensity, (value) => + inputs.onUpdateScene({ lightIntensity: value }), + ); + useSlider(folder, "Ambient", { min: 0, max: 2, step: 0.05 }, inputs.ambientIntensity, (value) => + inputs.onUpdateScene({ ambientIntensity: value }), + ); + return null; +} diff --git a/website/src/components/BuilderWorkbench/types.ts b/website/src/components/BuilderWorkbench/types.ts index 0ed36c25..7a7c8a2d 100644 --- a/website/src/components/BuilderWorkbench/types.ts +++ b/website/src/components/BuilderWorkbench/types.ts @@ -17,6 +17,14 @@ export interface PlacedItem { rotation: Vec3; /** User-facing scale multiplier. 1× = normalized-fit size. */ scale: number; + /** Extra vertical offset above the sampled floor, in world units. */ + elevation: number; + /** Solid color override for builder primitives. */ + color: string; + /** Imported models keep source materials until the user edits color. */ + colorOverride?: boolean; + /** Cleanup for local imported model object URLs. */ + dispose?: () => void; /** Per-mesh normalization factor so different presets render at similar size. */ fitScale: number; /** World-space center of the placement (the bbox center after scale). @@ -39,6 +47,8 @@ export interface PlacementDraft { export type ToolMode = "pointer" | "raise" | "lower" | "smooth"; +export type BuilderToolMode = "move" | "add" | "remove"; + /** What a terrain-tool click targets. Independent of `ToolMode` so the * user can raise/lower/smooth either a single grid VERTEX (deforms 4 * adjacent cells around it) or a whole FACE (deforms all 4 of its diff --git a/website/src/components/Dock/folders/useSceneFolder.ts b/website/src/components/Dock/folders/useSceneFolder.ts index 9d88696a..2a5a50ae 100644 --- a/website/src/components/Dock/folders/useSceneFolder.ts +++ b/website/src/components/Dock/folders/useSceneFolder.ts @@ -1,11 +1,8 @@ /** * Scene folder for the Dock: builder-only outliner. * - * Hosts two pieces of UI inside a real lil-gui "Scene" folder: - * 1. A React portal mount point at the TOP of the folder body — the builder - * uses it for its placed-items list and gizmo button set. - * 2. A lil-gui "Scale" slider BELOW the portal div, bound to the selected - * item's scale. The slider is dimmed when nothing is selected. + * Hosts a React portal mount point inside a real lil-gui "Scene" folder. + * The builder uses it for its placed-items list. * * The portal target is held in React state; the returned ReactNode is a * `createPortal(content, portalEl)` the caller must render somewhere in its @@ -15,20 +12,12 @@ import { useEffect, useState, type ReactNode } from "react"; import { createPortal } from "react-dom"; import type { GUI } from "lil-gui"; -import { useFolder, useSlider } from "../primitives"; +import { useFolder } from "../primitives"; export interface SceneFolderInputs { /** React content rendered into a portal inside the Scene folder body - * (above the Scale slider). The builder uses this for its items list + - * gizmo button set. */ + * The builder uses this for its items list. */ content: ReactNode; - /** ID of the currently selected placed item. `null` disables the Scale - * slider. */ - selectedId: string | null; - /** Current scale of the selected item — drives the lil-gui slider. */ - selectedScale: number; - /** Fires when the user drags the slider. */ - onScaleChange: (next: number) => void; } /** Returns a JSX element you must include in the render output. Internally @@ -38,14 +27,6 @@ export function useSceneFolder(parent: GUI | null, inputs: SceneFolderInputs): R const folder = useFolder(parent, "Scene"); const [portalEl, setPortalEl] = useState(null); - // Insert the portal host into the folder body BEFORE the Scale slider so - // it ends up above the slider in DOM order. The slider is added via - // `useSlider` AFTER this effect (well, after this hook returns its - // folder) — but since `useSlider` runs in the same render pass and only - // appends on mount, ordering is guaranteed by call order in the - // setup effect: `useFolder` resolves first, this effect runs first - // (it depends only on `folder`), then `useSlider`'s mount effect - // appends the slider underneath. useEffect(() => { if (!folder) return; const children = (folder as unknown as { $children: HTMLElement }).$children; @@ -59,18 +40,5 @@ export function useSceneFolder(parent: GUI | null, inputs: SceneFolderInputs): R }; }, [folder]); - const slider = useSlider( - folder, - "Scale", - { min: 0.1, max: 5, step: 0.05 }, - inputs.selectedScale, - inputs.onScaleChange, - ); - - // Dim the slider when nothing is selected — drag has no target. - useEffect(() => { - if (slider) slider.setEnabled(inputs.selectedId != null, { dim: true }); - }, [slider, inputs.selectedId]); - return portalEl ? createPortal(inputs.content, portalEl) : null; } diff --git a/website/src/components/DocsHeader.astro b/website/src/components/DocsHeader.astro index 35d31fa0..dfb231a5 100644 --- a/website/src/components/DocsHeader.astro +++ b/website/src/components/DocsHeader.astro @@ -30,6 +30,7 @@ const topLinks = [ active: pathname.startsWith('/api'), }, { href: '/gallery', label: 'Gallery', active: pathname.startsWith('/gallery') }, + { href: '/builder', label: 'Builder', active: pathname.startsWith('/builder') }, ]; --- diff --git a/website/src/components/GalleryWorkbench/GalleryWorkbench.tsx b/website/src/components/GalleryWorkbench/GalleryWorkbench.tsx index 48a8b411..c870ccbf 100644 --- a/website/src/components/GalleryWorkbench/GalleryWorkbench.tsx +++ b/website/src/components/GalleryWorkbench/GalleryWorkbench.tsx @@ -144,6 +144,7 @@ const DEFAULT_SCENE: SceneOptionsState = { fpvRenderDistance: 40, snapToGrid: true, gridResolution: 5, + gridTone: "gray", }; const DEFAULT_PARSER: ParserOptionsState = { diff --git a/website/src/components/GalleryWorkbench/presets/attributions.ts b/website/src/components/GalleryWorkbench/presets/attributions.ts index 0939682e..312dc183 100644 --- a/website/src/components/GalleryWorkbench/presets/attributions.ts +++ b/website/src/components/GalleryWorkbench/presets/attributions.ts @@ -377,7 +377,6 @@ export const GLB_PRESET_ATTRIBUTIONS: Record = { "urban/Motorcycle.glb": polyPizzaCityPackAttribution("Poly by Google", "Creative Commons Attribution"), "urban/Stop sign.glb": polyPizzaCityPackAttribution("Poly by Google", "Creative Commons Attribution"), "urban/Billboard.glb": polyPizzaCityPackAttribution("Poly by Google", "Creative Commons Attribution"), - "urban/Rock band poster.glb": polyPizzaCityPackAttribution("jeremy", "Creative Commons Attribution"), "urban/Dumpster.glb": polyPizzaCityPackAttribution("Quaternius", "CC0 1.0"), "urban/Mailbox.glb": polyPizzaCityPackAttribution("J-Toastie", "Creative Commons Attribution"), "urban/Fire hydrant.glb": polyPizzaCityPackAttribution("Poly by Google", "Creative Commons Attribution"), diff --git a/website/src/components/GalleryWorkbench/presets/presetBuilders.ts b/website/src/components/GalleryWorkbench/presets/presetBuilders.ts index 7a7e8d31..dea450da 100644 --- a/website/src/components/GalleryWorkbench/presets/presetBuilders.ts +++ b/website/src/components/GalleryWorkbench/presets/presetBuilders.ts @@ -1,8 +1,13 @@ import type { GalleryPresetFile, ObjGalleryPresetFile, PresetModel } from "../types"; import { GLB_PRESET_ATTRIBUTIONS } from "./attributions"; +function encodeGallerySegment(segment: string): string { + // Astro/Vite serve public assets with a literal "&" in the path; "%26" misses the file. + return encodeURIComponent(segment).replace(/%26/g, "&"); +} + export function galleryFileUrl(folder: "glb" | "obj" | "vox", file: string): string { - return `/gallery/${folder}/${file.split("/").map(encodeURIComponent).join("/")}`; + return `/gallery/${folder}/${file.split("/").map(encodeGallerySegment).join("/")}`; } export function presetIdFromFile(prefix: string, file: string): string { diff --git a/website/src/components/GalleryWorkbench/presets/presetFiles.ts b/website/src/components/GalleryWorkbench/presets/presetFiles.ts index 402b4596..a4d8037e 100644 --- a/website/src/components/GalleryWorkbench/presets/presetFiles.ts +++ b/website/src/components/GalleryWorkbench/presets/presetFiles.ts @@ -349,7 +349,6 @@ export const GLB_PRESET_FILES: GalleryPresetFile[] = [ { file: "urban/Motorcycle.glb", category: "Urban Pack" }, { file: "urban/Stop sign.glb", label: "Stop Sign", category: "Urban Pack" }, { file: "urban/Billboard.glb", category: "Urban Pack", galleryBucket: "Textured" }, - { file: "urban/Rock band poster.glb", label: "Poster", category: "Urban Pack" }, { file: "urban/Dumpster.glb", category: "Urban Pack" }, { file: "urban/Mailbox.glb", category: "Urban Pack" }, diff --git a/website/src/components/types.ts b/website/src/components/types.ts index c39ef095..78dd271c 100644 --- a/website/src/components/types.ts +++ b/website/src/components/types.ts @@ -16,6 +16,8 @@ export type PerspectiveMode = "perspective" | "orthographic"; export type WorkbenchMeshResolution = MeshResolution | "disabled"; +export type BuilderGridTone = "gray" | "dark"; + export function activeMeshResolution(meshResolution: WorkbenchMeshResolution): MeshResolution { return meshResolution === "disabled" ? "lossy" : meshResolution; } @@ -92,4 +94,6 @@ export interface SceneOptionsState { /** Grid resolution in world units (cell side length). Drives both * the rendered grid and snap-to-grid rounding. */ gridResolution: number; + /** Builder-only grid line color preset. */ + gridTone: BuilderGridTone; }