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 }; } 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", 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++) { 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, diff --git a/website/public/builder/shape-thumbnails/box.png b/website/public/builder/shape-thumbnails/box.png new file mode 100644 index 00000000..3809f41e Binary files /dev/null and b/website/public/builder/shape-thumbnails/box.png differ diff --git a/website/public/builder/shape-thumbnails/cone.png b/website/public/builder/shape-thumbnails/cone.png new file mode 100644 index 00000000..0d3875dc Binary files /dev/null and b/website/public/builder/shape-thumbnails/cone.png differ diff --git a/website/public/builder/shape-thumbnails/cylinder.png b/website/public/builder/shape-thumbnails/cylinder.png new file mode 100644 index 00000000..d42496db Binary files /dev/null and b/website/public/builder/shape-thumbnails/cylinder.png differ diff --git a/website/public/builder/shape-thumbnails/dodecahedron.png b/website/public/builder/shape-thumbnails/dodecahedron.png new file mode 100644 index 00000000..9c04f2b1 Binary files /dev/null and b/website/public/builder/shape-thumbnails/dodecahedron.png differ diff --git a/website/public/builder/shape-thumbnails/icosahedron.png b/website/public/builder/shape-thumbnails/icosahedron.png new file mode 100644 index 00000000..c3a354d6 Binary files /dev/null and b/website/public/builder/shape-thumbnails/icosahedron.png differ diff --git a/website/public/builder/shape-thumbnails/octahedron.png b/website/public/builder/shape-thumbnails/octahedron.png new file mode 100644 index 00000000..6c0ac809 Binary files /dev/null and b/website/public/builder/shape-thumbnails/octahedron.png differ diff --git a/website/public/builder/shape-thumbnails/sphere.png b/website/public/builder/shape-thumbnails/sphere.png new file mode 100644 index 00000000..01f12946 Binary files /dev/null and b/website/public/builder/shape-thumbnails/sphere.png differ diff --git a/website/public/builder/shape-thumbnails/tetrahedron.png b/website/public/builder/shape-thumbnails/tetrahedron.png new file mode 100644 index 00000000..3bf87229 Binary files /dev/null and b/website/public/builder/shape-thumbnails/tetrahedron.png differ diff --git a/website/public/builder/shape-thumbnails/torus.png b/website/public/builder/shape-thumbnails/torus.png new file mode 100644 index 00000000..40234810 Binary files /dev/null and b/website/public/builder/shape-thumbnails/torus.png differ 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 2059d810..00000000 Binary files a/website/public/gallery/glb/urban/Rock band poster.glb and /dev/null differ diff --git a/website/scripts/generate-builder-shape-thumbnails.mjs b/website/scripts/generate-builder-shape-thumbnails.mjs new file mode 100644 index 00000000..0a068a00 --- /dev/null +++ b/website/scripts/generate-builder-shape-thumbnails.mjs @@ -0,0 +1,197 @@ +import { mkdir, readFile, rm, writeFile } from "node:fs/promises"; +import { createServer } from "node:http"; +import { dirname, join, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; +import * as esbuild from "esbuild"; +import { chromium } from "playwright"; + +const scriptDir = dirname(fileURLToPath(import.meta.url)); +const websiteRoot = resolve(scriptDir, ".."); +const tempDir = join(websiteRoot, ".tmp-builder-shape-thumbnails"); +const outDir = join(websiteRoot, "public", "builder", "shape-thumbnails"); +const entryPath = join(tempDir, "entry.tsx"); +const bundlePath = join(tempDir, "bundle.js"); +const htmlPath = join(tempDir, "index.html"); + +await rm(tempDir, { recursive: true, force: true }); +await mkdir(tempDir, { recursive: true }); +await mkdir(outDir, { recursive: true }); + +await writeFile( + entryPath, + ` +import React from "react"; +import { createRoot } from "react-dom/client"; +import { PolyMesh, PolyOrthographicCamera, PolyScene } from "@layoutit/polycss-react"; +import { BUILDER_SHAPE_PRESETS } from "../src/components/BuilderWorkbench/shapePresets"; + +const light = { direction: [0.38, -0.48, 0.78], color: "#ffffff", intensity: 0.32 }; +const ambient = { color: "#ffffff", intensity: 0.52 }; + +function ShapeThumb({ shape }) { + const polygons = React.useMemo(() => 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; }