diff --git a/bench/baked-shadow-diagnose.mjs b/bench/baked-shadow-diagnose.mjs
deleted file mode 100644
index 6a5bc9a3..00000000
--- a/bench/baked-shadow-diagnose.mjs
+++ /dev/null
@@ -1,139 +0,0 @@
-#!/usr/bin/env node
-/**
- * Visual + structural diagnostic for the baked-mode cast-shadow path.
- *
- * Renders the same minimal cube-on-a-ground scene in four configurations
- * (baked/dynamic × castShadow on/off) and reports:
- * - element counts (leaves, shadow leaves, mesh wrappers)
- * - scene-root state (`--shadow-ground-cssz`, `--clx`, data-polycss-lighting)
- * - inline transform on the first few shadow leaves
- * - a screenshot of each variant
- *
- * Usage:
- * node bench/baked-shadow-diagnose.mjs # headless, all variants
- * node bench/baked-shadow-diagnose.mjs --headed # open browser
- * node bench/baked-shadow-diagnose.mjs --port=4400
- *
- * Requires the bench bundle to be built first (`node bench/build.mjs` or
- * `pnpm bench:build`).
- */
-import { chromium } from "playwright";
-import { spawn } from "node:child_process";
-import { mkdir, writeFile } from "node:fs/promises";
-import { resolve, dirname } from "node:path";
-import { fileURLToPath } from "node:url";
-import { chromiumArgsWithGpuDefault } from "./chromium-defaults.mjs";
-
-const __dirname = dirname(fileURLToPath(import.meta.url));
-const repoRoot = resolve(__dirname, "..");
-
-const argv = process.argv.slice(2);
-const optStr = (name, dflt = "") => {
- const i = argv.indexOf(`--${name}`);
- if (i >= 0) return argv[i + 1] ?? dflt;
- const eq = argv.find((a) => a.startsWith(`--${name}=`));
- return eq ? eq.slice(name.length + 3) : dflt;
-};
-const hasFlag = (name) => argv.includes(`--${name}`) || argv.includes(`--${name}=true`);
-
-const PORT = Number(optStr("port", "4400"));
-const HEADED = hasFlag("headed");
-
-// Start the perf-serve static server so the .generated/polycss.js bundle
-// resolves under the same origin as the HTML page.
-const serverProc = spawn(
- "node",
- ["bench/perf-serve.mjs", "--port", String(PORT)],
- { cwd: repoRoot, stdio: ["ignore", "pipe", "pipe"] },
-);
-await new Promise((resolveReady) => {
- const onLine = (data) => {
- if (String(data).includes("[perf-serve] index")) {
- serverProc.stdout.off("data", onLine);
- resolveReady();
- }
- };
- serverProc.stdout.on("data", onLine);
-});
-
-const outDir = resolve(repoRoot, "bench/results/baked-shadow");
-await mkdir(outDir, { recursive: true });
-
-const browser = await chromium.launch({
- headless: !HEADED,
- args: chromiumArgsWithGpuDefault([], { softwareBackend: false }),
-});
-
-const variants = [
- { name: "baked-cast", query: "?mode=baked&cast=1" },
- { name: "baked-nocast", query: "?mode=baked&cast=0" },
- { name: "dynamic-cast", query: "?mode=dynamic&cast=1" },
- { name: "dynamic-nocast",query: "?mode=dynamic&cast=0" },
-];
-
-const report = {};
-
-try {
- const ctx = await browser.newContext({ viewport: { width: 800, height: 600 } });
- for (const v of variants) {
- const page = await ctx.newPage();
- const url = `http://localhost:${PORT}/baked-shadow.html${v.query}`;
- const consoleMsgs = [];
- page.on("console", (msg) => {
- if (msg.type() === "error" || msg.type() === "warning") {
- consoleMsgs.push(`[${msg.type()}] ${msg.text()}`);
- }
- });
- page.on("pageerror", (err) => {
- consoleMsgs.push(`[pageerror] ${err.message}`);
- });
-
- await page.goto(url, { waitUntil: "networkidle", timeout: 10000 });
- // Give the scene a tick to render.
- await page.waitForTimeout(200);
-
- const snapshot = await page.evaluate(() => window.__polySnapshot());
-
- const shotPath = resolve(outDir, `${v.name}.png`);
- await page.screenshot({ path: shotPath, fullPage: false });
-
- report[v.name] = {
- url,
- snapshot,
- consoleMsgs,
- screenshot: shotPath.slice(repoRoot.length + 1),
- };
-
- await page.close();
- }
-} finally {
- await browser.close();
- serverProc.kill();
-}
-
-const summaryPath = resolve(outDir, "report.json");
-await writeFile(summaryPath, JSON.stringify(report, null, 2));
-
-console.log("\n──── baked-shadow diagnose report ────\n");
-for (const [name, r] of Object.entries(report)) {
- console.log(`▷ ${name} (${r.url})`);
- const s = r.snapshot;
- console.log(` mode=${s.mode} cast=${s.castShadow} data-polycss-lighting=${s.lightingAttr}`);
- console.log(` meshes=${s.meshCount} leaves=${s.leafCount} shadows=${s.shadowCount}`);
- console.log(` --shadow-ground-cssz=${s.groundCssZ_var} --clx=${s.clx_var}`);
- if (s.sample.length > 0) {
- console.log(` shadow leaf samples:`);
- for (const sa of s.sample) {
- const t = sa.transform.length > 100 ? sa.transform.slice(0, 100) + "…" : sa.transform;
- console.log(` transform: ${t}`);
- console.log(` width=${sa.width} height=${sa.height} color=${sa.color}`);
- }
- }
- if (r.consoleMsgs.length > 0) {
- console.log(` !! console:`);
- for (const m of r.consoleMsgs) console.log(` ${m}`);
- }
- console.log(` screenshot: ${r.screenshot}\n`);
-}
-
-console.log(`Full report: ${summaryPath.slice(repoRoot.length + 1)}`);
diff --git a/bench/baked-shadow.html b/bench/baked-shadow.html
deleted file mode 100644
index 8ba602cd..00000000
--- a/bench/baked-shadow.html
+++ /dev/null
@@ -1,127 +0,0 @@
-
-
-
-
- polycss baked-shadow diagnose
-
-
-
-
-
-
-
-
-
diff --git a/bench/bat-shadow-diagnose.mjs b/bench/bat-shadow-diagnose.mjs
deleted file mode 100644
index 22204ba8..00000000
--- a/bench/bat-shadow-diagnose.mjs
+++ /dev/null
@@ -1,151 +0,0 @@
-#!/usr/bin/env node
-/**
- * Visits the gallery at the user-provided model URL, enables castShadow,
- * and dumps:
- * - the shadow SVG outerHTML (truncated)
- * - the subpath count + winding signs for each M…L…Z block
- * - a screenshot of the result
- *
- * Goal: figure out why the bat model gets holes in its shadow even after
- * the per-polygon CCW normalization. Suspects: degenerate (near-zero
- * area) projections, self-intersecting non-convex merged polys.
- */
-import { chromium } from "playwright";
-import { mkdir, writeFile } from "node:fs/promises";
-import { resolve, dirname } from "node:path";
-import { fileURLToPath } from "node:url";
-import { chromiumArgsWithGpuDefault } from "./chromium-defaults.mjs";
-
-const __dirname = dirname(fileURLToPath(import.meta.url));
-const repoRoot = resolve(__dirname, "..");
-
-const argv = process.argv.slice(2);
-const optStr = (name, dflt = "") => {
- const i = argv.indexOf(`--${name}`);
- if (i >= 0) return argv[i + 1] ?? dflt;
- const eq = argv.find((a) => a.startsWith(`--${name}=`));
- return eq ? eq.slice(name.length + 3) : dflt;
-};
-const hasFlag = (name) => argv.includes(`--${name}`) || argv.includes(`--${name}=true`);
-
-const URL_STR = optStr("url", "http://localhost:4321/gallery?model=922117102");
-const HEADED = hasFlag("headed");
-
-const outDir = resolve(repoRoot, "bench/results/bat-shadow");
-await mkdir(outDir, { recursive: true });
-
-const browser = await chromium.launch({
- headless: !HEADED,
- args: chromiumArgsWithGpuDefault([], { softwareBackend: false }),
-});
-
-try {
- const ctx = await browser.newContext({ viewport: { width: 1400, height: 900 } });
- const page = await ctx.newPage();
- page.on("pageerror", (err) => console.log(`[pageerror] ${err.message}`));
-
- await page.goto(URL_STR, { waitUntil: "networkidle", timeout: 30000 });
- await page.waitForFunction(
- () => !!document.querySelector(".polycss-mesh"),
- { timeout: 30000 },
- );
- await page.waitForTimeout(1500);
-
- // Toggle Cast shadow + Show ground via the tweakpane labels.
- await page.evaluate(() => {
- const clickToggle = (labelText) => {
- const all = Array.from(document.querySelectorAll("div, span, label"));
- const labelEl = all.find((el) => (el.textContent || "").trim() === labelText);
- if (!labelEl) return false;
- let parent = labelEl.parentElement;
- for (let i = 0; i < 8 && parent; i++) {
- const cb = parent.querySelector('input[type="checkbox"]');
- if (cb) {
- if (!cb.checked) cb.click();
- return true;
- }
- parent = parent.parentElement;
- }
- return false;
- };
- clickToggle("Cast shadow");
- clickToggle("Show ground");
- });
- await page.waitForTimeout(1000);
-
- // Save the raw shadow SVG outerHTML so we can render it standalone on a
- // white background — the gallery's dark background hides any actual holes.
- const rawSvg = await page.evaluate(() => {
- const svg = document.querySelector("svg.polycss-shadow");
- return svg ? svg.outerHTML : null;
- });
- if (rawSvg) {
- await writeFile(resolve(outDir, "shadow-extracted.html"),
- `
-
- ${rawSvg.replace(/transform:[^"]*"/, 'transform:translate(0,0)"')}
-
`);
- }
-
- // Snapshot the shadow SVG and analyze its compound path.
- const snapshot = await page.evaluate(() => {
- const svg = document.querySelector("svg.polycss-shadow");
- if (!svg) return { found: false };
- const paths = svg.querySelectorAll("path");
- const all = Array.from(paths).map((path) => {
- const d = path.getAttribute("d") || "";
- // Split d into M…Z subpaths.
- const subpaths = d.split("Z").filter((s) => s.trim().length > 0);
- const analyzed = subpaths.map((sub) => {
- const cleaned = sub.replace(/^M/, "");
- // Each token is "x,y" separated by "L".
- const tokens = cleaned.split("L");
- const verts = tokens.map((t) => t.split(",").map(Number));
- // Signed area (positive = CCW in math; negative = CW).
- let a = 0;
- for (let i = 0; i < verts.length; i++) {
- const p = verts[i];
- const q = verts[(i + 1) % verts.length];
- a += p[0] * q[1] - q[0] * p[1];
- }
- return {
- n: verts.length,
- signedArea: a / 2,
- };
- });
- // Per-subpath winding summary.
- const ccw = analyzed.filter((s) => s.signedArea > 0).length;
- const cw = analyzed.filter((s) => s.signedArea < 0).length;
- const zero = analyzed.filter((s) => Math.abs(s.signedArea) < 1e-6).length;
- const minArea = analyzed.length > 0 ? Math.min(...analyzed.map((s) => Math.abs(s.signedArea))) : 0;
- const maxArea = analyzed.length > 0 ? Math.max(...analyzed.map((s) => Math.abs(s.signedArea))) : 0;
- return {
- subpathCount: subpaths.length,
- ccw, cw, zero,
- minArea,
- maxArea,
- fillRule: path.getAttribute("fill-rule"),
- opacity: path.getAttribute("opacity"),
- dLength: d.length,
- };
- });
- return {
- found: true,
- svgClass: svg.getAttribute("class"),
- svgWidth: svg.getAttribute("width"),
- svgHeight: svg.getAttribute("height"),
- svgTransform: svg.style.transform.slice(0, 100),
- pathCount: paths.length,
- paths: all,
- };
- });
-
- console.log(JSON.stringify(snapshot, null, 2));
-
- const shotPath = resolve(outDir, "shadow.png");
- await page.screenshot({ path: shotPath, fullPage: false });
- console.log(`Screenshot: ${shotPath}`);
- await writeFile(resolve(outDir, "report.json"), JSON.stringify(snapshot, null, 2));
-} finally {
- await browser.close();
-}
diff --git a/bench/composite-shadow-diagnose.mjs b/bench/composite-shadow-diagnose.mjs
deleted file mode 100644
index fc68e26a..00000000
--- a/bench/composite-shadow-diagnose.mjs
+++ /dev/null
@@ -1,81 +0,0 @@
-#!/usr/bin/env node
-/**
- * Loads bench/composite-shadow.html (caster pole on receiver cube) and
- * dumps the resulting shadow SVG structure + a screenshot. Used to
- * validate the experimental per-receiver-face shadow projection in
- * polycss createPolyScene.
- */
-import { chromium } from "playwright";
-import { spawn } from "node:child_process";
-import { mkdir } from "node:fs/promises";
-import { resolve, dirname } from "node:path";
-import { fileURLToPath } from "node:url";
-import { chromiumArgsWithGpuDefault } from "./chromium-defaults.mjs";
-
-const __dirname = dirname(fileURLToPath(import.meta.url));
-const repoRoot = resolve(__dirname, "..");
-
-const PORT = 4400;
-const HEADED = process.argv.includes("--headed");
-
-const outDir = resolve(repoRoot, "bench/results/composite-shadow");
-await mkdir(outDir, { recursive: true });
-
-const server = spawn(
- "node",
- ["bench/perf-serve.mjs", "--port", String(PORT)],
- { cwd: repoRoot, stdio: ["ignore", "pipe", "pipe"] },
-);
-await new Promise((ok) => {
- const onLine = (data) => {
- if (String(data).includes("[perf-serve] index")) {
- server.stdout.off("data", onLine);
- ok();
- }
- };
- server.stdout.on("data", onLine);
-});
-
-const browser = await chromium.launch({
- headless: !HEADED,
- args: chromiumArgsWithGpuDefault([], { softwareBackend: false }),
-});
-
-try {
- const ctx = await browser.newContext({ viewport: { width: 1200, height: 900 } });
- const page = await ctx.newPage();
- page.on("pageerror", (err) => console.log(`[pageerror] ${err.message}`));
- page.on("console", (msg) => {
- if (msg.type() === "error") console.log(`[console.error] ${msg.text()}`);
- });
-
- await page.goto(`http://localhost:${PORT}/composite-shadow.html`, {
- waitUntil: "networkidle",
- timeout: 10000,
- });
- await page.waitForTimeout(500);
-
- const snap = await page.evaluate(() => {
- const shadows = document.querySelectorAll("svg.polycss-shadow");
- const receiverShadows = document.querySelectorAll("svg.polycss-shadow-receiver");
- return {
- shadowCount: shadows.length,
- receiverShadowCount: receiverShadows.length,
- shadowOuter: Array.from(shadows).slice(0, 4).map((svg) => ({
- classes: svg.getAttribute("class"),
- width: svg.getAttribute("width"),
- height: svg.getAttribute("height"),
- transform: svg.style.transform.slice(0, 120),
- pathD: svg.querySelector("path")?.getAttribute("d")?.slice(0, 120),
- })),
- };
- });
-
- console.log(JSON.stringify(snap, null, 2));
-
- await page.screenshot({ path: resolve(outDir, "composite.png"), fullPage: false });
- console.log(`Screenshot: ${outDir}/composite.png`);
-} finally {
- await browser.close();
- server.kill();
-}
diff --git a/bench/composite-shadow.html b/bench/composite-shadow.html
deleted file mode 100644
index 13b30913..00000000
--- a/bench/composite-shadow.html
+++ /dev/null
@@ -1,192 +0,0 @@
-
-
-
-
- polycss composite shadow — caster on receiver
-
-
-
-
-
-
-
-
60°
-
-
-
45°
-
-
-
0
-
-
-
0
-
-
-
0
-
-
-
-
Drag the canvas to orbit · scroll to zoom
-
-
-
-
-
-
diff --git a/bench/count-svgs.mjs b/bench/count-svgs.mjs
deleted file mode 100644
index c9f58f9e..00000000
--- a/bench/count-svgs.mjs
+++ /dev/null
@@ -1,40 +0,0 @@
-import { chromium } from "playwright";
-import { chromiumArgsWithGpuDefault } from "/Users/apresmoi/Documents/voxcss/bench/chromium-defaults.mjs";
-const browser = await chromium.launch({ headless: true, args: chromiumArgsWithGpuDefault([], { softwareBackend: false }) });
-const ctx = await browser.newContext({ viewport: { width: 1200, height: 900 } });
-const page = await ctx.newPage();
-await page.goto("http://localhost:4400/real-shadow.html?norayt", { waitUntil: "networkidle", timeout: 15000 });
-await page.waitForTimeout(2000);
-// Take a few samples across drag.
-const samples = [];
-for (const az of [60, 120, 180, 240, 300]) {
- await page.evaluate((v) => {
- const el = document.getElementById("az");
- el.value = String(v);
- el.dispatchEvent(new Event("input"));
- }, az);
- await page.waitForTimeout(50);
- const stats = await page.evaluate(() => {
- const allSvgs = document.querySelectorAll("svg.polycss-shadow");
- const groundSvgs = document.querySelectorAll("svg.polycss-shadow:not(.polycss-shadow-receiver)");
- const recvSvgs = document.querySelectorAll("svg.polycss-shadow-receiver");
- const paths = document.querySelectorAll("svg.polycss-shadow path");
- let subpaths = 0, dlen = 0;
- paths.forEach(p => {
- const d = p.getAttribute("d") || "";
- subpaths += (d.match(/M/g) || []).length;
- dlen += d.length;
- });
- return {
- total: allSvgs.length,
- ground: groundSvgs.length,
- receivers: recvSvgs.length,
- paths: paths.length,
- subpaths,
- dlenKB: (dlen / 1024).toFixed(1),
- };
- });
- samples.push({ az, ...stats });
-}
-console.log(JSON.stringify(samples, null, 2));
-await browser.close();
diff --git a/bench/entries/react.tsx b/bench/entries/react.tsx
index 29a987aa..2d3f8d66 100644
--- a/bench/entries/react.tsx
+++ b/bench/entries/react.tsx
@@ -5,6 +5,10 @@
* Mounts a tree and drives
* per-frame state via React useState updates from a shared rAF loop.
* Measures the React reconciliation cost on top of the polycss renderer.
+ *
+ * Supports the full parity-quad URL param set + the postMessage protocol
+ * (`?sync=1`). See bench/perf-shared.mjs `parseUrlParams` and
+ * `installParitySync`.
*/
import { useEffect, useMemo, useState } from "react";
import { createRoot } from "react-dom/client";
@@ -13,134 +17,202 @@ import {
PolyScene,
PolyOrbitControls,
PolyMesh,
- Poly,
} from "@layoutit/polycss-react";
import type { Polygon } from "@layoutit/polycss-core";
import { loadMesh } from "@layoutit/polycss-core";
// @ts-expect-error — sibling .mjs without types
-import { parseUrlParams, dirFromAzEl, createPerfRecorder, PERF_OVERLAY_HTML, PERF_OVERLAY_CSS } from "../perf-shared.mjs";
+import { parseUrlParams, dirFromAzEl, createPerfRecorder, buildFloorPolygons, installParitySync, PERF_OVERLAY_HTML, PERF_OVERLAY_CSS } from "../perf-shared.mjs";
// @ts-expect-error — sibling .mjs without types
import { getSynthMesh } from "../synth-mesh.mjs";
-interface ParseResult { polygons: Polygon[]; dispose?: () => void }
+interface ParseResult { polygons: Polygon[]; voxelSource?: unknown; dispose?: () => void }
-function PerfApp({
- meshId, mode, motion, az, el, preset, parseResult, strategies,
-}: {
+interface CfgShape {
meshId: string;
mode: "dynamic" | "baked";
motion: "light" | "rot" | "none";
az: number;
el: number;
- preset: { rotX: number; rotY: number; zoom: number; url: string | null; mtlUrl?: string };
- parseResult: ParseResult | null;
+ isSynth: boolean;
strategies?: { disable: Array<"b" | "i" | "u"> };
-}) {
+ castShadow: boolean;
+ selfShadow: boolean;
+ floorVisible: boolean;
+ floorReceives: boolean;
+ autoCenter: boolean;
+ hideOverlay: boolean;
+ sync: boolean;
+ obj: { x: number; y: number; z: number; scale: number; rx: number; ry: number; rz: number };
+ dir: { x: number | null; y: number | null; z: number | null; intensity: number; color: string };
+ amb: { intensity: number; color: string };
+ shadow: { opacity: number; lift: number | null };
+ preset: { rotX: number; rotY: number; zoom: number; url: string | null; mtlUrl?: string; options?: any };
+}
+
+function PerfApp({ cfg, parseResult }: { cfg: CfgShape; parseResult: ParseResult | null }) {
+ const haveDirVec = cfg.dir.x !== null && cfg.dir.y !== null && cfg.dir.z !== null;
+ const initialDir: [number, number, number] = haveDirVec
+ ? [cfg.dir.x as number, cfg.dir.y as number, cfg.dir.z as number]
+ : dirFromAzEl(cfg.az, cfg.el);
+ const initialLift = cfg.shadow.lift !== null ? cfg.shadow.lift : 1 / Math.max(1, cfg.preset.zoom);
+
// Per-frame reactive state — React's render pipeline runs each tick.
- const [rotY, setRotY] = useState(preset.rotY);
- const [lightDir, setLightDir] = useState<[number, number, number]>(() => dirFromAzEl(az, el));
+ const [rotX, setRotX] = useState(cfg.preset.rotX);
+ const [rotY, setRotY] = useState(cfg.preset.rotY);
+ const [zoom, setZoom] = useState(cfg.preset.zoom);
+ const [lightDir, setLightDir] = useState<[number, number, number]>(initialDir);
+ const [dirIntensity, setDirIntensity] = useState(cfg.dir.intensity);
+ const [dirColor, setDirColor] = useState(cfg.dir.color);
+ const [ambIntensity, setAmbIntensity] = useState(cfg.amb.intensity);
+ const [ambColor, setAmbColor] = useState(cfg.amb.color);
+ const [shadowOpacity, setShadowOpacity] = useState(cfg.shadow.opacity);
+ const [shadowLift, setShadowLift] = useState(initialLift);
+ const [objPosition, setObjPosition] = useState<[number, number, number]>([cfg.obj.x, cfg.obj.y, cfg.obj.z]);
+ const [objScale, setObjScale] = useState(cfg.obj.scale);
+ const [objRotation, setObjRotation] = useState<[number, number, number]>([cfg.obj.rx, cfg.obj.ry, cfg.obj.rz]);
useEffect(() => {
const polyCount = parseResult?.polygons?.length ?? 0;
const recorder = createPerfRecorder({
rendererLabel: "react",
- meshId, mode, motion, polyCount,
+ meshId: cfg.meshId, mode: cfg.mode, motion: cfg.motion, polyCount,
polygons: parseResult?.polygons ?? [],
});
- let azimuth = az;
+ let azimuth = cfg.az;
let frameCount = 0;
let raf: number | null = null;
const tick = (now: number) => {
recorder.onFrame(now);
frameCount += 1;
- if (motion === "light") {
+ if (cfg.motion === "light") {
azimuth = (azimuth + 0.5) % 360;
- setLightDir(dirFromAzEl(azimuth, el));
- } else if (motion === "rot") {
- setRotY((((preset.rotY + frameCount * 0.5) % 360) + 360) % 360);
+ setLightDir(dirFromAzEl(azimuth, cfg.el));
+ } else if (cfg.motion === "rot") {
+ setRotY((((cfg.preset.rotY + frameCount * 0.5) % 360) + 360) % 360);
}
raf = requestAnimationFrame(tick);
};
raf = requestAnimationFrame(tick);
return () => { if (raf !== null) cancelAnimationFrame(raf); };
- }, [meshId, mode, motion, az, el, preset.rotX, preset.rotY, parseResult]);
+ }, [cfg, parseResult]);
+
+ // Parity-quad sync: install postMessage listener that updates reactive
+ // state, which re-renders the scene via PolyScene/PolyMesh props.
+ useEffect(() => {
+ if (!cfg.sync) return;
+ installParitySync({
+ applyCamera: ({ rotX: rx, rotY: ry, zoom: z }: { rotX: number | null; rotY: number | null; zoom: number | null }) => {
+ if (rx != null) setRotX(rx);
+ if (ry != null) setRotY(ry);
+ if (z != null) setZoom(z);
+ },
+ applyLight: ({ dir, intensity, color }: { dir: [number, number, number] | null; intensity: number | null; color: string | null }) => {
+ if (dir) setLightDir(dir);
+ if (intensity != null) setDirIntensity(intensity);
+ if (color != null) setDirColor(color);
+ },
+ applyAmbient: ({ intensity, color }: { intensity: number | null; color: string | null }) => {
+ if (intensity != null) setAmbIntensity(intensity);
+ if (color != null) setAmbColor(color);
+ },
+ applyObject: ({ position, scale, rotation }: { position: [number, number, number] | null; scale: number | null; rotation: [number, number, number] | null }) => {
+ if (position) setObjPosition(position);
+ if (scale != null) setObjScale(scale);
+ if (rotation) setObjRotation(rotation);
+ },
+ applyShadow: ({ opacity, lift }: { opacity: number | null; lift: number | null }) => {
+ if (opacity != null) setShadowOpacity(opacity);
+ if (lift != null) setShadowLift(lift);
+ },
+ reportCamera: () => {
+ // Drag report is wired inline via below.
+ },
+ });
+ }, [cfg.sync]);
const directionalLight = useMemo(
- () => ({ direction: lightDir, color: "#ffffff", intensity: 1 }),
- [lightDir],
+ () => ({ direction: lightDir, color: dirColor, intensity: dirIntensity }),
+ [lightDir, dirColor, dirIntensity],
);
const ambientLight = useMemo(
- () => ({ color: "#ffffff", intensity: 0.4 }),
- [],
+ () => ({ color: ambColor, intensity: ambIntensity }),
+ [ambColor, ambIntensity],
+ );
+ const shadow = useMemo(
+ () => ({ opacity: shadowOpacity, lift: shadowLift }),
+ [shadowOpacity, shadowLift],
);
+ // Include the floor polygons in centerPolygons when the floor is on, so
+ // React/Vue autoCenter mirrors vanilla's joint-bbox-of-all-meshes calc.
+ const centerPolys = useMemo(() => {
+ if (!parseResult) return undefined;
+ if (!cfg.floorVisible) return parseResult.polygons;
+ return [...parseResult.polygons, ...buildFloorPolygons()];
+ }, [parseResult, cfg.floorVisible]);
+
return (
-
+
-
+ {
+ if (typeof snap.rotX === "number") setRotX(snap.rotX);
+ if (typeof snap.rotY === "number") setRotY(snap.rotY);
+ if (typeof snap.zoom === "number") setZoom(snap.zoom);
+ if (window.parent !== window) {
+ window.parent.postMessage({ kind: "camera-changed", rotX: snap.rotX, rotY: snap.rotY, zoom: snap.zoom }, "*");
+ }
+ } : undefined}
+ />
{parseResult
- ? parseResult.polygons.map((p, i) => )
- : preset.url
- ?
+ ?
+ : cfg.preset.url
+ ?
: null}
+ {cfg.floorVisible && (
+
+ )}
);
}
async function main(): Promise {
+ const cfg = parseUrlParams() as CfgShape;
+
// Inject the shared overlay first so meta-renderer, etc. exist when
- // createPerfRecorder fires inside PerfApp's effect.
+ // createPerfRecorder fires inside PerfApp's effect. Skipped when
+ // `?nohud=1` (clean screenshot capture).
const css = document.createElement("style");
css.textContent = PERF_OVERLAY_CSS;
document.head.appendChild(css);
- document.body.insertAdjacentHTML("beforeend", PERF_OVERLAY_HTML);
-
- const params = parseUrlParams() as {
- meshId: string;
- mode: "dynamic" | "baked";
- motion: "light" | "rot" | "none";
- az: number;
- el: number;
- isSynth: boolean;
- strategies?: { disable: Array<"b" | "i" | "u"> };
- preset: any;
- };
+ if (!cfg.hideOverlay) document.body.insertAdjacentHTML("beforeend", PERF_OVERLAY_HTML);
- // For React the component renders with `polygons` directly when a
- // parseResult is available (synth + OBJ both go through the same path
- // for honesty — measures rendering N children, not the imperative
- // PolyMesh wrapper). For OBJ we load via @layoutit/polycss-core's loadMesh.
let parseResult: ParseResult | null = null;
- if (params.isSynth) {
- parseResult = getSynthMesh(params.meshId);
- } else if (params.preset.url) {
- parseResult = await loadMesh(params.preset.url, {
- ...(params.preset.mtlUrl ? { mtlUrl: params.preset.mtlUrl } : {}),
- objOptions: params.preset.options,
+ if (cfg.isSynth) {
+ parseResult = getSynthMesh(cfg.meshId);
+ } else if (cfg.preset.url) {
+ parseResult = await loadMesh(cfg.preset.url, {
+ ...(cfg.preset.mtlUrl ? { mtlUrl: cfg.preset.mtlUrl } : {}),
+ objOptions: cfg.preset.options,
});
}
const host = document.getElementById("host")!;
- createRoot(host).render(
- ,
- );
+ createRoot(host).render();
}
main().catch((err) => {
diff --git a/bench/entries/vue.ts b/bench/entries/vue.ts
index 08f09fd9..ddf550bb 100644
--- a/bench/entries/vue.ts
+++ b/bench/entries/vue.ts
@@ -4,11 +4,10 @@
*
* Mounts a tree and drives
* per-frame state via reactive ref() updates from a shared rAF loop.
- * Measures Vue's reactivity flush + render cost on top of the polycss
- * renderer.
*
- * Uses defineComponent + render functions (not SFC templates) so the
- * bundler doesn't need a Vue template compiler.
+ * Supports the full parity-quad URL param set + the postMessage protocol
+ * (`?sync=1`). See bench/perf-shared.mjs `parseUrlParams` and
+ * `installParitySync`.
*/
import { createApp, defineComponent, h, onMounted, onBeforeUnmount, ref, computed } from "vue";
import {
@@ -16,62 +15,74 @@ import {
PolyScene,
PolyOrbitControls,
PolyMesh,
- Poly,
} from "@layoutit/polycss-vue";
import type { Polygon } from "@layoutit/polycss-core";
import { loadMesh } from "@layoutit/polycss-core";
// @ts-expect-error — sibling .mjs without types
-import { parseUrlParams, dirFromAzEl, createPerfRecorder, PERF_OVERLAY_HTML, PERF_OVERLAY_CSS } from "../perf-shared.mjs";
+import { parseUrlParams, dirFromAzEl, createPerfRecorder, buildFloorPolygons, installParitySync, PERF_OVERLAY_HTML, PERF_OVERLAY_CSS } from "../perf-shared.mjs";
// @ts-expect-error — sibling .mjs without types
import { getSynthMesh } from "../synth-mesh.mjs";
-interface ParseResult { polygons: Polygon[]; dispose?: () => void }
+interface ParseResult { polygons: Polygon[]; voxelSource?: unknown; dispose?: () => void }
const PerfApp = defineComponent({
name: "PerfApp",
props: {
- meshId: { type: String, required: true },
- mode: { type: String as () => "dynamic" | "baked", required: true },
- motion: { type: String as () => "light" | "rot" | "none", required: true },
- az: { type: Number, required: true },
- el: { type: Number, required: true },
- preset: { type: Object as () => any, required: true },
+ cfg: { 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);
- const lightDir = ref<[number, number, number]>(dirFromAzEl(props.az, props.el));
+ const cfg = props.cfg;
+ const haveDirVec = cfg.dir.x !== null && cfg.dir.y !== null && cfg.dir.z !== null;
+ const initialDir: [number, number, number] = haveDirVec
+ ? [cfg.dir.x, cfg.dir.y, cfg.dir.z]
+ : dirFromAzEl(cfg.az, cfg.el);
+ const initialLift = cfg.shadow.lift !== null ? cfg.shadow.lift : 1 / Math.max(1, cfg.preset.zoom);
+
+ const rotX = ref(cfg.preset.rotX);
+ const rotY = ref(cfg.preset.rotY);
+ const zoom = ref(cfg.preset.zoom);
+ const lightDir = ref<[number, number, number]>(initialDir);
+ const dirIntensity = ref(cfg.dir.intensity);
+ const dirColor = ref(cfg.dir.color);
+ const ambIntensity = ref(cfg.amb.intensity);
+ const ambColor = ref(cfg.amb.color);
+ const shadowOpacity = ref(cfg.shadow.opacity);
+ const shadowLift = ref(initialLift);
+ const objPosition = ref<[number, number, number]>([cfg.obj.x, cfg.obj.y, cfg.obj.z]);
+ const objScale = ref(cfg.obj.scale);
+ const objRotation = ref<[number, number, number]>([cfg.obj.rx, cfg.obj.ry, cfg.obj.rz]);
const directionalLight = computed(() => ({
direction: lightDir.value,
- color: "#ffffff",
- intensity: 1,
+ color: dirColor.value,
+ intensity: dirIntensity.value,
+ }));
+ const ambientLight = computed(() => ({
+ color: ambColor.value,
+ intensity: ambIntensity.value,
}));
- const ambientLight = { color: "#ffffff", intensity: 0.4 };
+ const shadow = computed(() => ({ opacity: shadowOpacity.value, lift: shadowLift.value }));
let raf: number | null = null;
onMounted(() => {
const polyCount = props.parseResult?.polygons?.length ?? 0;
const recorder = createPerfRecorder({
rendererLabel: "vue",
- meshId: props.meshId,
- mode: props.mode,
- motion: props.motion,
- polyCount,
+ meshId: cfg.meshId, mode: cfg.mode, motion: cfg.motion, polyCount,
polygons: props.parseResult?.polygons ?? [],
});
- let azimuth = props.az;
+ let azimuth = cfg.az;
let frameCount = 0;
const tick = (now: number): void => {
recorder.onFrame(now);
frameCount += 1;
- if (props.motion === "light") {
+ if (cfg.motion === "light") {
azimuth = (azimuth + 0.5) % 360;
- lightDir.value = dirFromAzEl(azimuth, props.el);
- } else if (props.motion === "rot") {
- rotY.value = (((props.preset.rotY + frameCount * 0.5) % 360) + 360) % 360;
+ lightDir.value = dirFromAzEl(azimuth, cfg.el);
+ } else if (cfg.motion === "rot") {
+ rotY.value = (((cfg.preset.rotY + frameCount * 0.5) % 360) + 360) % 360;
}
raf = requestAnimationFrame(tick);
};
@@ -81,27 +92,96 @@ const PerfApp = defineComponent({
if (raf !== null) cancelAnimationFrame(raf);
});
+ // Parity-quad sync: install postMessage listener that mutates reactive
+ // refs, which Vue re-renders into PolyScene/PolyMesh props.
+ onMounted(() => {
+ if (!cfg.sync) return;
+ installParitySync({
+ applyCamera: ({ rotX: rx, rotY: ry, zoom: z }: { rotX: number | null; rotY: number | null; zoom: number | null }) => {
+ if (rx != null) rotX.value = rx;
+ if (ry != null) rotY.value = ry;
+ if (z != null) zoom.value = z;
+ },
+ applyLight: ({ dir, intensity, color }: { dir: [number, number, number] | null; intensity: number | null; color: string | null }) => {
+ if (dir) lightDir.value = dir;
+ if (intensity != null) dirIntensity.value = intensity;
+ if (color != null) dirColor.value = color;
+ },
+ applyAmbient: ({ intensity, color }: { intensity: number | null; color: string | null }) => {
+ if (intensity != null) ambIntensity.value = intensity;
+ if (color != null) ambColor.value = color;
+ },
+ applyObject: ({ position, scale, rotation }: { position: [number, number, number] | null; scale: number | null; rotation: [number, number, number] | null }) => {
+ if (position) objPosition.value = position;
+ if (scale != null) objScale.value = scale;
+ if (rotation) objRotation.value = rotation;
+ },
+ applyShadow: ({ opacity, lift }: { opacity: number | null; lift: number | null }) => {
+ if (opacity != null) shadowOpacity.value = opacity;
+ if (lift != null) shadowLift.value = lift;
+ },
+ reportCamera: () => {}, // handled inline via onChange below
+ });
+ });
+
+ function onOrbitChange(snap: { rotX?: number; rotY?: number; zoom?: number }): void {
+ if (!cfg.sync) return;
+ if (typeof snap.rotX === "number") rotX.value = snap.rotX;
+ if (typeof snap.rotY === "number") rotY.value = snap.rotY;
+ if (typeof snap.zoom === "number") zoom.value = snap.zoom;
+ if (window.parent !== window) {
+ window.parent.postMessage({ kind: "camera-changed", rotX: snap.rotX, rotY: snap.rotY, zoom: snap.zoom }, "*");
+ }
+ }
+
+ const centerPolys = computed(() => {
+ if (!props.parseResult) return undefined;
+ if (!cfg.floorVisible) return props.parseResult.polygons;
+ return [...props.parseResult.polygons, ...buildFloorPolygons()];
+ });
+
return () => h(
PolyCamera,
- { rotX: props.preset.rotX, rotY: rotY.value, zoom: props.preset.zoom },
+ { rotX: rotX.value, rotY: rotY.value, zoom: zoom.value },
{
default: () => h(
PolyScene,
{
directionalLight: directionalLight.value,
- ambientLight,
- textureLighting: props.mode,
- strategies: props.strategies,
- autoCenter: true,
+ ambientLight: ambientLight.value,
+ shadow: shadow.value,
+ textureLighting: cfg.mode,
+ strategies: cfg.strategies,
+ autoCenter: cfg.autoCenter,
+ centerPolygons: centerPolys.value,
},
{
default: () => [
- h(PolyOrbitControls, { drag: true, wheel: true, animate: false }),
+ h(PolyOrbitControls, { drag: true, wheel: true, animate: false, onChange: cfg.sync ? onOrbitChange : undefined }),
props.parseResult
- ? props.parseResult.polygons.map((p, i) => h(Poly, { key: i, ...p }))
- : props.preset.url
- ? h(PolyMesh, { src: props.preset.url, mtlUrl: props.preset.mtlUrl })
+ ? h(PolyMesh, {
+ polygons: props.parseResult.polygons,
+ voxelSource: props.parseResult.voxelSource,
+ castShadow: cfg.castShadow,
+ receiveShadow: cfg.selfShadow,
+ position: objPosition.value,
+ scale: objScale.value,
+ rotation: objRotation.value,
+ })
+ : cfg.preset.url
+ ? h(PolyMesh, {
+ src: cfg.preset.url,
+ mtlUrl: cfg.preset.mtlUrl,
+ castShadow: cfg.castShadow,
+ receiveShadow: cfg.selfShadow,
+ position: objPosition.value,
+ scale: objScale.value,
+ rotation: objRotation.value,
+ })
: null,
+ cfg.floorVisible
+ ? h(PolyMesh, { polygons: buildFloorPolygons(), receiveShadow: cfg.floorReceives })
+ : null,
],
},
),
@@ -111,43 +191,25 @@ const PerfApp = defineComponent({
});
async function main(): Promise {
+ const cfg = parseUrlParams() as any;
+
const css = document.createElement("style");
css.textContent = PERF_OVERLAY_CSS;
document.head.appendChild(css);
- document.body.insertAdjacentHTML("beforeend", PERF_OVERLAY_HTML);
-
- const params = parseUrlParams() as {
- meshId: string;
- mode: "dynamic" | "baked";
- motion: "light" | "rot" | "none";
- az: number;
- el: number;
- isSynth: boolean;
- strategies?: { disable: Array<"b" | "i" | "u"> };
- preset: any;
- };
+ if (!cfg.hideOverlay) document.body.insertAdjacentHTML("beforeend", PERF_OVERLAY_HTML);
let parseResult: ParseResult | null = null;
- if (params.isSynth) {
- parseResult = getSynthMesh(params.meshId);
- } else if (params.preset.url) {
- parseResult = await loadMesh(params.preset.url, {
- ...(params.preset.mtlUrl ? { mtlUrl: params.preset.mtlUrl } : {}),
- objOptions: params.preset.options,
+ if (cfg.isSynth) {
+ parseResult = getSynthMesh(cfg.meshId);
+ } else if (cfg.preset.url) {
+ parseResult = await loadMesh(cfg.preset.url, {
+ ...(cfg.preset.mtlUrl ? { mtlUrl: cfg.preset.mtlUrl } : {}),
+ objOptions: cfg.preset.options,
});
}
const host = document.getElementById("host")!;
- createApp(PerfApp, {
- meshId: params.meshId,
- mode: params.mode,
- motion: params.motion,
- az: params.az,
- el: params.el,
- preset: params.preset,
- parseResult,
- strategies: params.strategies,
- }).mount(host);
+ createApp(PerfApp, { cfg, parseResult }).mount(host);
}
main().catch((err) => {
diff --git a/bench/flicker-diagnose.mjs b/bench/flicker-diagnose.mjs
deleted file mode 100644
index 7dc50639..00000000
--- a/bench/flicker-diagnose.mjs
+++ /dev/null
@@ -1,231 +0,0 @@
-#!/usr/bin/env node
-/**
- * Flicker diagnostic: records every style/attribute/childList mutation on
- * polycss elements over ~3 seconds of orbit animation, then prints a summary
- * grouped by element + attribute showing which elements mutate most.
- *
- * Also reports key CSS properties (will-change, transformStyle) on the scene
- * element — missing `will-change: transform` on .polycss-scene is the known
- * root cause of baked-shapes flicker in React/Vue (the scene re-rasterizes
- * every leaf on each frame instead of compositing a cached GPU layer).
- *
- * Usage:
- * node bench/flicker-diagnose.mjs --port=5175
- * node bench/flicker-diagnose.mjs --port=5173 # html baseline
- * node bench/flicker-diagnose.mjs --port=5174 # vanilla baseline
- * node bench/flicker-diagnose.mjs --port=5176 # vue
- *
- * Output: mutation counts/sec per element, top mutated attributes, CSS
- * property snapshot, and a sample of value transitions for high-frequency
- * mutated elements.
- */
-import { chromium } from "playwright";
-import { chromiumArgsWithGpuDefault } from "./chromium-defaults.mjs";
-
-const argv = process.argv.slice(2);
-const optStr = (name, dflt = "") => {
- const i = argv.indexOf(`--${name}`);
- if (i >= 0) return argv[i + 1] ?? dflt;
- const eq = argv.find((a) => a.startsWith(`--${name}=`));
- return eq ? eq.slice(name.length + 3) : dflt;
-};
-const hasFlag = (name) => argv.includes(`--${name}`) || argv.includes(`--${name}=true`);
-
-const PORT = optStr("port", "5175");
-const OBSERVE_MS = 3000;
-const WARMUP_MS = 2000;
-const HEADED = hasFlag("headed");
-const TOP_N = 10;
-
-const url = `http://localhost:${PORT}/baked-shapes/`;
-console.log(`[flicker-diagnose] target: ${url}`);
-console.log(`[flicker-diagnose] warmup: ${WARMUP_MS}ms observe: ${OBSERVE_MS}ms`);
-
-const browser = await chromium.launch({
- headless: !HEADED,
- args: chromiumArgsWithGpuDefault([], { softwareBackend: false }),
-});
-
-try {
- const ctx = await browser.newContext({ viewport: { width: 1280, height: 800 } });
- const page = await ctx.newPage();
-
- await page.goto(url, { waitUntil: "networkidle", timeout: 30000 });
-
- // Wait for mesh children to appear (up to 15s)
- await page.waitForFunction(
- () => {
- const mesh = document.querySelector(".polycss-mesh");
- return mesh && mesh.children.length > 0;
- },
- null,
- { timeout: 15000 },
- );
-
- // Let orbit animation run for warmup before we start recording
- await page.waitForTimeout(WARMUP_MS);
-
- // Snapshot key CSS properties before observing
- const cssSnapshot = await page.evaluate(() => {
- const scene = document.querySelector(".polycss-scene");
- if (!scene) return null;
- const cs = window.getComputedStyle(scene);
- return {
- willChange: cs.willChange,
- transformStyle: cs.transformStyle,
- perspective: cs.perspective,
- };
- });
-
- // Inject MutationObserver and collect records
- const records = await page.evaluate(
- ({ observeMs }) =>
- new Promise((resolve) => {
- const log = [];
- const t0 = performance.now();
-
- const observer = new MutationObserver((mutations) => {
- const ts = performance.now() - t0;
- for (const m of mutations) {
- const el = m.target;
- const tag = el.tagName?.toLowerCase() ?? "?";
- const cls = el.className ? String(el.className).trim().split(/\s+/).join(".") : "";
- const id = `${tag}${cls ? "." + cls : ""}`;
- if (m.type === "attributes") {
- log.push({
- ts,
- type: "attr",
- id,
- attr: m.attributeName ?? "",
- oldValue: m.oldValue ?? null,
- newValue: el.getAttribute(m.attributeName ?? "") ?? null,
- });
- } else if (m.type === "childList") {
- log.push({
- ts,
- type: "childList",
- id,
- added: m.addedNodes.length,
- removed: m.removedNodes.length,
- });
- }
- }
- });
-
- // Observe from document.body to catch all mutations including the scene element
- observer.observe(document.body, {
- subtree: true,
- childList: true,
- attributes: true,
- attributeOldValue: true,
- attributeFilter: ["style", "class", "transform"],
- });
-
- setTimeout(() => {
- observer.disconnect();
- resolve(log);
- }, observeMs);
- }),
- { observeMs: OBSERVE_MS },
- );
-
- await ctx.close();
-
- // ── CSS snapshot ──────────────────────────────────────────────────────────
- console.log("\n[flicker-diagnose] .polycss-scene CSS snapshot:");
- if (cssSnapshot) {
- const wc = cssSnapshot.willChange;
- const ok = wc === "transform";
- console.log(` will-change: ${wc} ${ok ? "(OK — GPU layer promoted)" : "(MISSING — re-rasterizes every frame; FLICKER SOURCE)"}`);
- console.log(` transform-style: ${cssSnapshot.transformStyle}`);
- console.log(` perspective: ${cssSnapshot.perspective}`);
- } else {
- console.log(" (scene element not found)");
- }
-
- // ── Analysis ──────────────────────────────────────────────────────────────
-
- const totalMs = OBSERVE_MS;
- const totalMutations = records.length;
- const mutPerSec = (totalMutations / (totalMs / 1000)).toFixed(1);
-
- // Filter out the orbit animation's per-frame scene transform update — that's
- // expected (1 write/frame) and is NOT the flicker source.
- const SCENE_STYLE_ORBIT_PATTERN = /scale\(.+?\) rotateX/;
- const interestingRecords = records.filter((r) => {
- if (r.type === "attr" && r.attr === "style" && r.id.includes("polycss-scene")) {
- // Only flag if the new value does NOT look like a normal orbit transform
- return !SCENE_STYLE_ORBIT_PATTERN.test(r.newValue ?? "");
- }
- return true;
- });
-
- console.log(`\n[flicker-diagnose] port=${PORT} total mutations: ${totalMutations} (${mutPerSec}/sec)`);
- console.log(` (orbit transform updates: ${totalMutations - interestingRecords.length}, interesting: ${interestingRecords.length})\n`);
-
- // Group by element id + attribute
- const byKey = new Map();
- for (const rec of records) {
- if (rec.type === "attr") {
- const key = `${rec.id} [${rec.attr}]`;
- if (!byKey.has(key)) byKey.set(key, { count: 0, values: new Map(), transitions: [] });
- const entry = byKey.get(key);
- entry.count++;
- const vNew = rec.newValue ?? "(null)";
- const vOld = rec.oldValue ?? "(null)";
- entry.values.set(vNew, (entry.values.get(vNew) ?? 0) + 1);
- if (entry.transitions.length < 5) entry.transitions.push({ from: vOld, to: vNew });
- } else {
- const key = `${rec.id} [childList +${rec.added}/-${rec.removed}]`;
- if (!byKey.has(key)) byKey.set(key, { count: 0, values: new Map(), transitions: [] });
- byKey.get(key).count++;
- }
- }
-
- const sorted = [...byKey.entries()].sort((a, b) => b[1].count - a[1].count);
-
- if (sorted.length === 0) {
- console.log(" No mutations recorded.\n");
- } else {
- console.log(` Top ${Math.min(TOP_N, sorted.length)} most-mutated element+attribute combos:`);
- console.log(" " + "─".repeat(80));
- for (const [key, data] of sorted.slice(0, TOP_N)) {
- const rate = (data.count / (totalMs / 1000)).toFixed(1);
- console.log(` ${key}`);
- console.log(` mutations: ${data.count} (${rate}/sec)`);
- const uniqueVals = [...data.values.entries()].sort((a, b) => b[1] - a[1]).slice(0, 3);
- if (uniqueVals.length > 0) {
- const preview = uniqueVals
- .map(([v, n]) => `"${v.length > 60 ? v.slice(0, 57) + "..." : v}" (×${n})`)
- .join(", ");
- console.log(` unique values: ${uniqueVals.length} top: ${preview}`);
- }
- if (data.transitions.length > 0) {
- console.log(" sample transitions:");
- for (const t of data.transitions.slice(0, 2)) {
- const f = t.from?.length > 60 ? t.from.slice(0, 57) + "..." : t.from;
- const to = t.to?.length > 60 ? t.to.slice(0, 57) + "..." : t.to;
- console.log(` ${f} → ${to}`);
- }
- }
- console.log();
- }
- }
-
- // Group by just element tag to see which tags flicker most
- const byTag = new Map();
- for (const rec of records) {
- const tag = rec.id.split("[")[0].trim();
- byTag.set(tag, (byTag.get(tag) ?? 0) + 1);
- }
- const tagsSorted = [...byTag.entries()].sort((a, b) => b[1] - a[1]);
- console.log(" Mutations by element tag:");
- for (const [tag, count] of tagsSorted) {
- const rate = (count / (totalMs / 1000)).toFixed(1);
- console.log(` ${tag.padEnd(40)} ${count.toString().padStart(6)} mutations (${rate}/sec)`);
- }
-
- console.log();
-} finally {
- await browser.close();
-}
diff --git a/bench/gallery-shadow-compare.mjs b/bench/gallery-shadow-compare.mjs
deleted file mode 100644
index 1a5e484d..00000000
--- a/bench/gallery-shadow-compare.mjs
+++ /dev/null
@@ -1,138 +0,0 @@
-#!/usr/bin/env node
-/**
- * Same as gallery-shadow-diagnose, but takes two captures: baked+shadow
- * and dynamic+shadow, so we can tell whether the shadow weirdness is a
- * regression from the new baked path or pre-existing in dynamic too.
- */
-import { chromium } from "playwright";
-import { mkdir, writeFile } from "node:fs/promises";
-import { resolve, dirname } from "node:path";
-import { fileURLToPath } from "node:url";
-import { chromiumArgsWithGpuDefault } from "./chromium-defaults.mjs";
-
-const __dirname = dirname(fileURLToPath(import.meta.url));
-const repoRoot = resolve(__dirname, "..");
-
-const argv = process.argv.slice(2);
-const optStr = (name, dflt = "") => {
- const i = argv.indexOf(`--${name}`);
- if (i >= 0) return argv[i + 1] ?? dflt;
- const eq = argv.find((a) => a.startsWith(`--${name}=`));
- return eq ? eq.slice(name.length + 3) : dflt;
-};
-const hasFlag = (name) => argv.includes(`--${name}`) || argv.includes(`--${name}=true`);
-
-const PORT = Number(optStr("port", "4321"));
-const HEADED = hasFlag("headed");
-
-const outDir = resolve(repoRoot, "bench/results/gallery-shadow");
-await mkdir(outDir, { recursive: true });
-
-const browser = await chromium.launch({
- headless: !HEADED,
- args: chromiumArgsWithGpuDefault([], { softwareBackend: false }),
-});
-
-async function snapshot(page) {
- return await page.evaluate(() => {
- const scene = document.querySelector(".polycss-scene");
- const shadows = document.querySelectorAll(".polycss-shadow");
- return {
- shadowCount: shadows.length,
- sceneLighting: scene?.dataset.polycssLighting || "(unset)",
- groundCssZ: scene?.style.getPropertyValue("--shadow-ground-cssz") || "(unset)",
- shadowSample: Array.from(shadows).slice(0, 3).map((el) => ({
- transform: el.style.transform.slice(0, 220),
- width: el.style.width,
- height: el.style.height,
- })),
- };
- });
-}
-
-async function toggle(page, label, desiredChecked) {
- return await page.evaluate(({ label, desiredChecked }) => {
- const all = Array.from(document.querySelectorAll("div, span, label"));
- const labelEl = all.find((el) => (el.textContent || "").trim() === label);
- if (!labelEl) return { found: false };
- let parent = labelEl.parentElement;
- for (let i = 0; i < 8 && parent; i++) {
- const cb = parent.querySelector('input[type="checkbox"]');
- if (cb) {
- if (cb.checked !== desiredChecked) cb.click();
- return { found: true, after: cb.checked };
- }
- // tweakpane sometimes uses select/dropdown — return the select element handle name
- const sel = parent.querySelector("select");
- if (sel) return { found: true, isSelect: true };
- parent = parent.parentElement;
- }
- return { found: true, hasCheckbox: false };
- }, { label, desiredChecked });
-}
-
-async function selectDropdown(page, label, valueText) {
- return await page.evaluate(({ label, valueText }) => {
- const all = Array.from(document.querySelectorAll("div, span, label"));
- const labelEl = all.find((el) => (el.textContent || "").trim() === label);
- if (!labelEl) return { found: false };
- let parent = labelEl.parentElement;
- for (let i = 0; i < 8 && parent; i++) {
- const sel = parent.querySelector("select");
- if (sel) {
- const opt = Array.from(sel.options).find((o) => o.text === valueText || o.value === valueText);
- if (!opt) return { found: true, hasSelect: true, options: Array.from(sel.options).map((o) => o.text) };
- sel.value = opt.value;
- sel.dispatchEvent(new Event("change", { bubbles: true }));
- return { found: true, set: opt.value };
- }
- parent = parent.parentElement;
- }
- return { found: true, hasSelect: false };
- }, { label, valueText });
-}
-
-const ctx = await browser.newContext({ viewport: { width: 1400, height: 900 } });
-const errors = [];
-try {
- const page = await ctx.newPage();
- page.on("console", (msg) => {
- if (msg.type() === "error") errors.push(`[console.error] ${msg.text()}`);
- });
- page.on("pageerror", (err) => errors.push(`[pageerror] ${err.message}`));
-
- await page.goto(`http://localhost:${PORT}/gallery`, { waitUntil: "networkidle", timeout: 30000 });
- await page.waitForFunction(
- () => !!document.querySelector(".polycss-mesh"),
- { timeout: 30000 },
- );
- await page.waitForTimeout(800);
-
- // 1. Enable castShadow in baked mode (default).
- const tog1 = await toggle(page, "Cast shadow", true);
- await page.waitForTimeout(500);
- const baked = await snapshot(page);
- await page.screenshot({ path: resolve(outDir, "compare-baked.png"), fullPage: false });
-
- // 2. Switch lighting to dynamic, keep castShadow on.
- const sel = await selectDropdown(page, "Texture mode", "dynamic");
- await page.waitForTimeout(500);
- const dynamic = await snapshot(page);
- await page.screenshot({ path: resolve(outDir, "compare-dynamic.png"), fullPage: false });
-
- const report = { castToggle: tog1, modeSelect: sel, baked, dynamic, errors };
- await writeFile(resolve(outDir, "compare.json"), JSON.stringify(report, null, 2));
-
- console.log("\n──── baked vs dynamic with castShadow=true ────\n");
- console.log("Toggle:", tog1, "Mode select:", sel);
- console.log("\nBAKED:");
- console.log(JSON.stringify(baked, null, 2));
- console.log("\nDYNAMIC:");
- console.log(JSON.stringify(dynamic, null, 2));
- if (errors.length) {
- console.log("\nErrors:");
- errors.forEach((e) => console.log(` ${e}`));
- }
-} finally {
- await browser.close();
-}
diff --git a/bench/gallery-shadow-diagnose.mjs b/bench/gallery-shadow-diagnose.mjs
deleted file mode 100644
index e6c3268d..00000000
--- a/bench/gallery-shadow-diagnose.mjs
+++ /dev/null
@@ -1,152 +0,0 @@
-#!/usr/bin/env node
-/**
- * Targets the website's gallery (http://localhost:4321/gallery) and
- * captures before/after state when the user toggles castShadow with
- * the scene in baked mode — the exact failure scenario the user is
- * reporting ("UI disappears, shadows generally break").
- *
- * Usage:
- * node bench/gallery-shadow-diagnose.mjs
- * node bench/gallery-shadow-diagnose.mjs --headed
- * node bench/gallery-shadow-diagnose.mjs --port=4321
- */
-import { chromium } from "playwright";
-import { mkdir, writeFile } from "node:fs/promises";
-import { resolve, dirname } from "node:path";
-import { fileURLToPath } from "node:url";
-import { chromiumArgsWithGpuDefault } from "./chromium-defaults.mjs";
-
-const __dirname = dirname(fileURLToPath(import.meta.url));
-const repoRoot = resolve(__dirname, "..");
-
-const argv = process.argv.slice(2);
-const optStr = (name, dflt = "") => {
- const i = argv.indexOf(`--${name}`);
- if (i >= 0) return argv[i + 1] ?? dflt;
- const eq = argv.find((a) => a.startsWith(`--${name}=`));
- return eq ? eq.slice(name.length + 3) : dflt;
-};
-const hasFlag = (name) => argv.includes(`--${name}`) || argv.includes(`--${name}=true`);
-
-const PORT = Number(optStr("port", "4321"));
-const HEADED = hasFlag("headed");
-
-const outDir = resolve(repoRoot, "bench/results/gallery-shadow");
-await mkdir(outDir, { recursive: true });
-
-const browser = await chromium.launch({
- headless: !HEADED,
- args: chromiumArgsWithGpuDefault([], { softwareBackend: false }),
-});
-
-async function snapshot(page) {
- return await page.evaluate(() => {
- const scene = document.querySelector(".polycss-scene");
- const meshes = document.querySelectorAll(".polycss-mesh");
- const shadows = document.querySelectorAll(".polycss-shadow");
- const leafSel = ".polycss-scene b, .polycss-scene i, .polycss-scene s, .polycss-scene u";
- const leaves = document.querySelectorAll(leafSel);
- return {
- meshCount: meshes.length,
- leafCount: leaves.length,
- shadowCount: shadows.length,
- sceneStyle: scene
- ? {
- transform: scene.style.transform.slice(0, 100),
- groundCssZ: scene.style.getPropertyValue("--shadow-ground-cssz") || "(unset)",
- clx: scene.style.getPropertyValue("--clx") || "(unset)",
- lighting: scene.dataset.polycssLighting || "(unset)",
- }
- : null,
- shadowSample: Array.from(shadows).slice(0, 2).map((el) => ({
- transform: el.style.transform.slice(0, 200),
- width: el.style.width,
- height: el.style.height,
- })),
- };
- });
-}
-
-const errors = [];
-
-try {
- const ctx = await browser.newContext({ viewport: { width: 1400, height: 900 } });
- const page = await ctx.newPage();
- page.on("console", (msg) => {
- if (msg.type() === "error") errors.push(`[console.error] ${msg.text()}`);
- if (msg.type() === "warning") errors.push(`[console.warn] ${msg.text()}`);
- });
- page.on("pageerror", (err) => errors.push(`[pageerror] ${err.message}`));
-
- await page.goto(`http://localhost:${PORT}/gallery`, { waitUntil: "networkidle", timeout: 30000 });
- // Wait for the gallery scene to fully mount and a mesh to render.
- await page.waitForFunction(
- () => {
- const mesh = document.querySelector(".polycss-mesh");
- const sceneChildren = document.querySelectorAll(".polycss-scene > *");
- return !!mesh && sceneChildren.length > 0;
- },
- { timeout: 30000 },
- );
- await page.waitForTimeout(800);
-
- const before = await snapshot(page);
- await page.screenshot({ path: resolve(outDir, "01-baseline.png"), fullPage: false });
-
- // Try to find and click the "Cast shadow" toggle inside the tweakpane dock.
- // tweakpane renders checkboxes as . We look for the
- // label text "Cast shadow" and click its associated control.
- const beforeClickErrors = errors.length;
- const clicked = await page.evaluate(() => {
- // Tweakpane wraps each control with a label div. Walk every element
- // whose text reads "Cast shadow" and find a sibling/descendant input.
- const all = Array.from(document.querySelectorAll("div, span, label"));
- const labelEl = all.find((el) => (el.textContent || "").trim() === "Cast shadow");
- if (!labelEl) return { found: false };
- // Walk up looking for the row that contains the checkbox.
- let parent = labelEl.parentElement;
- for (let i = 0; i < 8 && parent; i++) {
- const cb = parent.querySelector('input[type="checkbox"]');
- if (cb) {
- const beforeChecked = cb.checked;
- cb.click();
- return { found: true, hasCheckbox: true, before: beforeChecked, after: cb.checked };
- }
- parent = parent.parentElement;
- }
- return { found: true, hasCheckbox: false };
- });
-
- await page.waitForTimeout(600);
- const after = await snapshot(page);
- await page.screenshot({ path: resolve(outDir, "02-cast-shadow.png"), fullPage: false });
-
- const clickErrors = errors.slice(beforeClickErrors);
-
- const report = {
- clickedToggle: clicked,
- before,
- after,
- errorsAfterToggle: clickErrors,
- allErrors: errors,
- };
- await writeFile(resolve(outDir, "report.json"), JSON.stringify(report, null, 2));
-
- console.log("\n──── gallery-shadow diagnose ────\n");
- console.log("Toggle click result:", clicked);
- console.log("\nBefore toggle:");
- console.log(JSON.stringify(before, null, 2));
- console.log("\nAfter toggle:");
- console.log(JSON.stringify(after, null, 2));
- if (clickErrors.length) {
- console.log("\n⚠️ Errors after toggle:");
- clickErrors.forEach((e) => console.log(` ${e}`));
- }
- if (errors.length && !clickErrors.length) {
- console.log("\n(Page errors before toggle, possibly unrelated):");
- errors.forEach((e) => console.log(` ${e}`));
- }
- console.log(`\nScreenshots: ${outDir.slice(repoRoot.length + 1)}/`);
-} finally {
- await browser.close();
-}
diff --git a/bench/notes/H11B_OBB_PROXY_DESIGN.md b/bench/notes/H11B_OBB_PROXY_DESIGN.md
new file mode 100644
index 00000000..f070d1ad
--- /dev/null
+++ b/bench/notes/H11B_OBB_PROXY_DESIGN.md
@@ -0,0 +1,128 @@
+# H11b — silhouette projection onto OBB / averaged-plane proxy receiver
+
+## The leftover problem after H9b + H11 negative
+
+Post-H9b state: teapot-self drag emits 242 receiver SVGs at az50, 143 at
+az130, 104 at az220. Each SVG is a small silhouette path. compositorMain
+is ~358 ms/frame, ~proportional to receiver-SVG count.
+
+H11 tried to reduce SVG count by relaxing `RECEIVER_NORMAL_TOL` so
+adjacent near-coplanar receiver faces merge. NEGATIVE: the actual gate
+is `RECEIVER_OFFSET_TOL = 0.5`, which on a curved mesh is overshot by
+tens of px between any two adjacent triangles. Loosening OFFSET_TOL
+breaks unrelated-plane-merging (floor and ceiling collapse).
+
+## H11b: instead of merging receiver FACES, replace them with a proxy
+
+For the caster=receiver (self-shadow) case specifically:
+
+1. **Don't decompose the receiver mesh into per-face planes.** That's
+ what produces 242 SVGs on the teapot.
+
+2. **Instead, choose ONE-TO-FEW receiver-PROXY planes per mesh, region.**
+ Options:
+ - **OBB (oriented bounding box):** 6 axis-aligned faces of the
+ mesh's oriented bounding box. The silhouette projects onto each
+ OBB face that the light could cast onto. Typically 3 OBB faces are
+ visible to the camera + receive light at a given pose.
+ - **Mean-plane per camera-facing cluster:** k-means cluster the
+ mesh polygons by face normal (k=4 or 6), one plane per cluster.
+
+3. **Per proxy plane, project the H9b silhouette loop, clip to mesh
+ member-poly union of polys mapped to that proxy.** So the shadow
+ still appears only where the real mesh sits — no shadows in
+ thin air.
+
+## Expected effect
+
+| count | now (per-face) | H11b OBB | H11b mean-plane k=4 |
+| --- | ---: | ---: | ---: |
+| receiver SVGs for teapot-self | 138-242 | 3-6 | 2-4 |
+
+If receiver-SVG count drops 40-80×, compositorMain should drop the same
+proportion (it's roughly per-SVG-layer). teapot-self frame_p50 ~342 ms
+would drop to perhaps ~50-80 ms — competitive with the floor-only case.
+
+## Risk: visual
+
+The silhouette projects onto an AVERAGED plane. For mesh regions whose
+real normals differ from the proxy normal, the projected shadow
+position differs from the "true" per-face shadow. Pixel-space error:
+~`depth_offset × tan(angle_to_proxy)`. For a teapot at typical zoom
+and ~10° proxy-to-real normal cone, error is ~3-5 px.
+
+Mitigation: clip each proxy projection to the convex hull of the
+polygons that map to that proxy. The shadow only appears on real mesh
+regions — visible drift happens only inside those regions.
+
+## Risk: light visibility / occlusion semantics
+
+Currently per-face receiver decomposition gives each face an
+INDIVIDUAL Lambert + ambient calculation. With proxy planes, the
+proxy gets ONE Lambert factor based on the proxy normal, applied to
+all member polys. Lighting accuracy could shift on mesh regions whose
+real normals differ from the proxy.
+
+For dynamic mode: leaf colors come from the per-leaf `--pnx/y/z` CSS
+vars (per-polygon normals), NOT the receiver-plane Lambert. So
+dynamic-mode lighting is unaffected. Only the SHADOW tint on each
+receiver leaf might shift if it's computed from receiver-face Lambert.
+
+Need to check: does the receiver-plane Lambert affect the per-leaf
+SHADOW color in `computeReceiverShadowFaces` (line ~600+)? Looking at
+the source:
+```
+const groupColor = receiverPolygons[groupPolyIdx]?.color ?? "#cccccc";
+const fillColor = receiverHasTexture
+ ? userShadowColor
+ : shadePolygon(groupColor, 0, "#000000", ambColor, ambIntensity);
+```
+The shadow fill is `shadePolygon(baseColor, 0, ...)` — directScale is
+0, so it's ambient-only. Independent of receiver normal. Good.
+
+But the OPACITY calc (~line 595-605):
+```
+if (receiverHasTexture) {
+ const direct = dirIntensity * Math.max(0, Ldotn);
+ ...
+}
+```
+Uses `Ldotn` where n is receiver-face normal. For a proxy plane Ldotn
+differs from the real per-face value. Shadows on textured receivers
+would have slightly wrong opacity per region. For non-textured
+receivers (the typical case for self-shadow on solid GLBs), shadow
+opacity is constant — no impact.
+
+## Implementation plan
+
+1. Detect when `caster === receiver` AND `receiverEntry.polygons.length
+ >= PROXY_MIN_POLYS` (e.g. 60). Otherwise per-face path stays.
+2. Compute proxy planes per mesh ONCE (cached, invalidates on mesh
+ transform change):
+ - Cluster polygons by normal direction (k=4 or k=6, axis-aligned k=6
+ for OBB).
+ - For each cluster: compute centroid + averaged normal + the union
+ of member polygons projected to the proxy plane (for clipping).
+3. In `computeReceiverShadowFaces`, when self-shadow proxy path is
+ active, skip the per-face loop and emit one ReceiverShadowFaceSpec
+ per visible proxy plane.
+4. Visual diff acceptance: shadows on teapot self-shadow should not
+ visibly detach from the surface. If 3-5 px max drift is too much,
+ bump to k=8 or k=12.
+
+## Effort estimate
+
+Bigger than H9/H9b. Touches:
+- Core: new proxy-plane generation function
+- Core: dispatch in computeReceiverShadowFaces
+- Vanilla/React/Vue: cache plumbing for proxy planes
+- Tests: silhouette-onto-proxy unit tests
+
+Worth dispatching as a separate iteration. Not blocking other H.
+
+## Alternative: simpler, less ambitious — opacity of the per-face SVG
+
+Instead of replacing per-face with proxy, give the existing receiver
+SVGs CSS contain:strict so the browser can compositor-cull off-screen
+ones cheaply. Won't reduce JS work but might reduce compositor cost.
+File as H12 if H11b is too complex.
diff --git a/bench/notes/H9_SILHOUETTE_DESIGN.md b/bench/notes/H9_SILHOUETTE_DESIGN.md
new file mode 100644
index 00000000..d03c1b04
--- /dev/null
+++ b/bench/notes/H9_SILHOUETTE_DESIGN.md
@@ -0,0 +1,168 @@
+# H9 — caster silhouette extraction design
+
+## Goal
+
+Replace the per-caster-polygon SH-clip loop with a per-caster-MESH
+silhouette projection. For a closed solid mesh, the projected silhouette
+is the boundary between front-facing and back-facing polygons relative to
+the light direction. Drawing one closed polygon per caster mesh per
+receiver face instead of N triangles drops DOM mutation by ~100× for the
+teapot-floor case (2182 sub-paths → ~1 closed polygon).
+
+## Inputs (already cached per frame)
+
+- `caster.items: CasterPolyItem[]` — each has `wv: Vec3[]` (world verts)
+ + `planeN: Vec3 | null`
+- `sharedEdgeMap` per caster (already built for self-shadow seam cull,
+ `buildSharedEdgeMap` in core)
+- Light direction `L` in CSS frame (already normalised)
+
+## Algorithm
+
+For each caster mesh per frame:
+
+### Step 1 — classify polygons by light-facing
+
+```
+const facing: boolean[] = caster.items.map((item) => {
+ const n = item.planeN;
+ if (!n) return true; // degenerate; treat as facing so it's not lost
+ return (n[0]*Lx + n[1]*Ly + n[2]*Lz) < -EPS;
+});
+```
+
+(The recently-landed light-backface cull already drops back-facing polys
+from the SH-clip path. For silhouette extraction we KEEP them in the
+classification — they bound the silhouette.)
+
+### Step 2 — find silhouette edges
+
+Walk every polygon's edges. An edge is "silhouette" iff its two adjacent
+polygons disagree on `facing`. For polygons with no neighbour (open mesh
+boundary), the edge is always silhouette.
+
+Use the existing edge-adjacency from `buildSharedEdgeMap` extended to
+return edge → (polyA, polyB) instead of poly → set-of-adj-polys. This
+needs a small change in core (new variant or extra return value).
+
+Concretely:
+```
+const edgeOwners: Map = ...
+
+const silhouetteEdges: Array<[Vec3, Vec3]> = [];
+for (const [key, owners] of edgeOwners) {
+ const a = facing[owners.polyA];
+ const b = owners.polyB === null ? !a : facing[owners.polyB];
+ if (a !== b) silhouetteEdges.push([owners.vertA, owners.vertB]);
+}
+```
+
+### Step 3 — walk edges into closed loops
+
+The silhouette edges form one or more closed loops in 3D (for a
+manifold mesh). Build a vertex→edges multi-map, then traverse:
+- Start at any unvisited edge.
+- Walk to the next edge sharing the current vertex, prefer the edge that
+ KEEPS the front-side polygon on the SAME side (orientation continuity).
+- Mark each edge visited; close the loop when we return to the start.
+- Repeat for remaining unvisited edges.
+
+For closed convex meshes: 1 loop. For concave/genus>0: multiple loops.
+Inner loops become SVG holes via fill-rule:evenodd.
+
+### Step 4 — project loops into (u,v) per receiver face
+
+For each receiver face, project each silhouette loop vertex using the
+existing `projectOntoPlane` (line ~442 of `computeReceiverShadows.ts`).
+SH-clip the resulting closed polygon against the receiver outline + each
+member polygon (same as today's per-poly code, but on the silhouette
+polygon instead of per-triangle).
+
+### Step 5 — emit path
+
+Each receiver face emits ONE sub-path per caster mesh per silhouette
+loop. For most scenes this is 1 sub-path per caster mesh per face.
+
+## Compatibility
+
+- **Light-facing classification matches the recent light-backface cull**
+ exactly. The cull skips back-facing-to-light polygons from the SH-clip
+ loop; the silhouette extraction uses the same `facing` predicate to
+ bound them. They compose: we'd remove the per-polygon loop entirely
+ and replace with per-mesh silhouette loop.
+- **Self-shadow seam cull** still applies — silhouette edges between
+ self-shadowing adjacent polygons need the same treatment. Since the
+ silhouette loop IS the boundary set, self-shadow seam culling
+ essentially becomes "don't add silhouette edges where both adjacent
+ polys belong to a receiver face's member set."
+- **Coplanar caster cull** stays per-poly (it's a pre-step that decides
+ if a polygon contributes at all).
+
+## Risk / failure modes
+
+1. **Open meshes (cottage windows, half-apple cutaways).** Edges with
+ only one adjacent triangle ALWAYS show as silhouette. For a cottage,
+ the window outline becomes a silhouette loop. That's actually
+ geometrically correct — the window frame casts shadow. But it might
+ produce more loops than the user expects.
+
+2. **Non-manifold meshes (badly authored GLBs).** Edges shared by 3+
+ triangles. Need to define behaviour — probably treat as "ambiguous
+ silhouette" and emit anyway. Tested via the `flight-system-support`
+ GLB which is known messy.
+
+3. **Concave silhouettes from convex-hull-ish meshes.** Some meshes
+ (apple, teapot) are convex but their silhouette can have small
+ concavities at the spout/leaf. Loop walking has to handle figure-8
+ loops correctly (don't fuse, leave as separate loops).
+
+4. **Performance regression for very simple meshes.** For a crate
+ (12 triangles), the silhouette extraction overhead (build edge map,
+ walk loops) might exceed the per-poly SH-clip cost. Add a heuristic:
+ skip silhouette extraction when polygon count < threshold (e.g. 40).
+ Use the per-poly path for those.
+
+5. **Receiver-face shadow opacity calculation.** Today, per-poly path
+ produces N overlapping sub-shadows; the opacity is one constant per
+ receiver face (computed once). Silhouette loop produces 1 shadow with
+ the same constant opacity. No change.
+
+## Implementation plan (when dispatched)
+
+1. Extend `buildSharedEdgeMap` (or add `buildEdgeOwners`) in core to
+ return edge → (polyA, polyB | null, vertA, vertB).
+2. Add `extractSilhouetteLoops(items, edgeOwners, L)` in core. Returns
+ `Array` (each loop is a closed vertex sequence in world coords).
+3. In `computeReceiverShadowFaces`, gate per-caster: if `items.length >=
+ SILHOUETTE_MIN_POLYS` AND silhouette extraction succeeded (manifold,
+ ≥1 loop), use silhouette path. Otherwise fall through to the
+ existing per-poly path.
+4. The silhouette path is: project each loop to (u,v), SH-clip against
+ outline + member polys (using existing `clipPolygonToConvex2D`), push
+ into `bucket.verts` as a single sub-path per receiver.
+5. Add a unit test in core with a known mesh (e.g. axis-aligned cube)
+ asserting the silhouette is 4 vertices for a side-on light.
+
+## Why not "compute polygon UNION via Boolean lib"
+
+- Adds a heavy dependency (martinez or polygon-clipping is ~30 KB).
+- Doesn't exploit the 3D structure of the mesh — works in 2D after
+ projection.
+- O(N log N) for N input polygons; silhouette extraction is O(E) in mesh
+ edges, which is roughly O(N).
+- The 3D silhouette approach is what GPU shadow-volume / stencil-shadow
+ algorithms have done for 20 years.
+
+## Test scenes
+
+Use the existing regression fixture (teapot-self, teapot-floor,
+castle-floor, crate-floor). Add: apple (closed convex GLB), cottage
+(open-mesh edge cases), flight-system-support (heavy + messy).
+
+Expected wins on path-d chars:
+- teapot-floor 87,869 → ~150 (~580×)
+- castle-floor 23,000 → ~500 (~46×)
+- crate-floor 215 → ~80 (~2.5×; minimal due to already-simple silhouette)
+- teapot-self complex; per-receiver-face still applies. Probably 5-10×
+ per receiver SVG.
diff --git a/bench/notes/SHADOW_PERF_LOG.md b/bench/notes/SHADOW_PERF_LOG.md
new file mode 100644
index 00000000..d92e7c70
--- /dev/null
+++ b/bench/notes/SHADOW_PERF_LOG.md
@@ -0,0 +1,867 @@
+# Shadow + lighting perf research
+
+Living log of explorations to make the per-frame receiver-shadow + lighting
+system cheaper. Append-only journal of branches tried, what worked, what
+didn't, and the metrics. The "best" wins get cherry-picked back to
+`feat/three-parity`; failed experiments stay on their own branches for
+traceability.
+
+## TL;DR — cumulative loop wins on `feat/three-parity`
+
+Worst-case scene = teapot-self self-shadow drag (perf-vanilla, dynamic mode,
+0.5°/frame light azimuth motion).
+
+| state | frame_p50 | fps_p50 | script ms/f | recv SVGs |
+| --- | ---: | ---: | ---: | ---: |
+| pre-loop (commit `5dff12d`) | 449 ms | 2.2 | ~535 | 309 |
+| +H9 silhouette (floor only) | 449 | 2.2 | (gated out) | 309 |
+| +H3 light-key quantize | 442 | 2.3 | 533 | 309 |
+| +H9b silhouette self-shadow | 342 | 2.9 | 465 | 138-242 |
+| +H10 CSS-var quantize | 117 | 8.6 | 126 | 138-242 |
+| ~~H11b OBB-proxy~~ REVERTED (broke gallery self-shadow visual) | – | – | – | – |
+| ~~H9b silhouette self-shadow~~ REVERTED (broke coliseum self-shadow contrast) | – | – | – | – |
+| **current HEAD** | similar to pre-loop on self-shadow scenes | – | – | matches pre-loop |
+
+**Wins that LANDED safely** (after the H9b + H11b reverts):
+
+- **H9 silhouette extraction for NON-self-shadow (caster ≠ receiver)** —
+ floor case dropped from 90KB d-chars / 17.8ms script → 5KB / 7.1ms
+ (-94% / -60%). Visual byte-identical to pre-loop. Three.js parity
+ preserved.
+- **H3 light-key quantize** — emit skip when light direction key matches
+ the previous frame; halved emitter call count on slow drag, ~28% script
+ reduction during light motion.
+- **H10 CSS-var quantize (--plx/y/z + --clx/cly/clz → toFixed(2))** —
+ stops per-frame style recalc on dynamic-Lambert leaves when light
+ crosses sub-quantum increments. ~38% mean FPS during light drag.
+ Mirrored to React + Vue.
+
+**Wins that REVERTED** (visual correctness regressions):
+
+- **H9b silhouette for self-shadow** — replaced per-poly seam-cull path
+ with mesh-level silhouette extraction. Bench teapot scene looked fine
+ but on real meshes (apple, coliseum) the silhouette emit produced
+ paths with wrong shape/coverage, making self-shadow contrast disappear
+ or appear in wrong places. Reverted `ae523ce`. Per-poly path restored.
+- **H11b OBB-proxy receiver** — replaced per-face receiver decomposition
+ with bounding-box proxy planes. Bench teapot looked fine but on the
+ gallery apple the proxy plane sat outside the actual mesh surface,
+ dropping all visible self-shadow. Reverted `3f8ef23` + `307c553`.
+
+**H11b regression note**: the OBB-proxy approach correctly reduced
+receiver-SVG count from 138-242 → 1 on the bench teapot-self scene, but
+in the gallery (e.g. apple model with Self-shadow toggle on) it emitted
+ONE proxy SVG on the mesh's bounding-box face that didn't intersect the
+actual mesh surface, so all visible self-shadow content disappeared. The
+agent's "visually identical" verdict was on the bench only, didn't catch
+this. Reverted as 307c553 + 3f8ef23.
+
+Floor-case (teapot-floor, just `castShadow:true` on a floor receiver) is
+even cleaner: pre-loop 66 ms / 17 fps → post-H11b ~58 ms with all of the
+ground-shadow JS work (SH-clip + per-poly fan) replaced by one silhouette
+loop projected once, dChars down 90,000→5,000 (-94%).
+
+## Wins landed (chronological)
+
+| commit | iter | hypothesis | effect |
+| --- | --- | --- | --- |
+| `feb4ea7` | 2 | H9 silhouette floor | -94% dChars / -57% script (floor) |
+| `5337ae4` | 3 | H3 light-key quantize | -28% script during drag |
+| `e9cf56c` | 4 | H9b silhouette self | -51-60% dChars / -18% script (self) |
+| `77f3206` | 7 | H10 CSS-var quantize | +38% mean FPS (lighting recalc floor) |
+| ~~`2187a1e`~~ | ~~9~~ | ~~H11b OBB-proxy receiver~~ | **REVERTED — gallery self-shadow visual regression** |
+| `774c45e` | 10 | H10 mirror React + Vue | parity (cross-package discipline) |
+| `307c553` + `3f8ef23` | 12 | revert H11b | restore gallery self-shadow + object-on-object visibility |
+
+## Discarded for traceability (branches preserved)
+
+| branch | iter | hypothesis | why |
+| --- | --- | --- | --- |
+| `perf/shadow-path-simplify` | 1 | H2 DP simplify | triangles can't be simplified |
+| `perf/shadow-face-coalesce` | 6 | H11 NORMAL_TOL relax | OFFSET_TOL was the actual gate |
+| `perf/shadow-memoize-d-v2` | 8 | H8 d= memoize | 54% hit rate but savings below noise |
+
+## Setup
+
+- **Source of truth**: this file, plus `bench/results/shadow-regression/`
+ for screenshots + summary JSON per branch.
+- **Cadence**: triggered by the `/loop 30m` user prompt — each iteration
+ picks one hypothesis, branches it, measures, journals, then re-evaluates.
+- **Tooling**: `node .claude/skills/chrome-capture-trace/scripts/trace.mjs
+ motion --page shadow --mesh --mode dynamic --dom-samples --label
+ …` for trace runs; playwright probe under
+ `bench/scripts/shadow-regression.mjs` (created in iteration 1) for the
+ visual fixture.
+- **Reference frame for "did we break something"**: three.js parity is
+ measured via the existing bench/three-parity.html. We compare PolyCSS
+ vs three.js shadow shape + opacity at fixed light positions. No
+ perf-only optimization is allowed to regress that.
+- **Baseline to beat**: the current `feat/three-parity` HEAD at the start
+ of the loop (commit `5dff12d`). All deltas are reported vs that ref.
+
+## Diagnosed cost breakdown (recap of pre-loop bench)
+
+From `bench/results/shadow-teapot-dynamic.json` (light rotating
+0.5°/frame, dynamic mode, teapot self-shadow ON):
+
+| stage | ms/frame |
+| --- | ---: |
+| script (computeReceiverShadowFaces + DOM mutation) | ~535 |
+| style recalc (CSS calc() on 2281 leaves for dynamic Lambert) | ~54 |
+| layout + prePaint + paint | ~5 combined |
+| compositorMain | ~400 |
+| compositorImpl | ~14 |
+| **frame_p50** | **~325 ms** (after light-backface cull) |
+
+**Frame-time bottlenecks**, ranked:
+
+1. **Per-frame SH-clip + projection in core**
+ (`computeReceiverShadowFaces`) — ~300+ ms with self-shadow on smooth
+ GLBs. Dominates everything.
+2. **SVG path mutation** — 147 receiver SVGs × ~800 char `d=` strings
+ re-emitted every frame. Browser repaints the entire shadow layer.
+3. **CSS style recalc for dynamic Lambert** — `calc(--plx*--pnx + …)`
+ on every leaf invalidates when scene-root vars change. ~54 ms/frame
+ for ~2300 leaves.
+4. **compositorMain** — Big number (~400ms) but mostly downstream of (1)
+ and (2). Investigate after (1) and (2) shrink.
+
+## Shadow vs lighting cost split (iter 2 diagnostic)
+
+Same scene (teapot, dynamic mode, motion=light), one with shadows on
+(cs=1 ss=1 fv=1), one with all shadows off (cs=0 ss=0 fv=0):
+
+| group | no-shadow ms/f | with-shadow ms/f | shadow Δ |
+| --- | ---: | ---: | ---: |
+| style (calc-Lambert recalc) | 53.4 | 56.0 | +3 |
+| script (SH-clip + DOM mut) | 4.6 | 764.3 | +760 |
+| compositorMain (SVG layers) | 120.1 | 527.0 | +407 |
+| frame_p50 | 59.9 | 449.9 | +390 |
+
+**Shadow path = 95% of the with-shadows cost.** Dynamic-Lambert style
+recalc (53 ms/frame for 2300 leaves) is the floor in dynamic mode and
+is independent of shadows — investigate as a separate H once the shadow
+script + compositorMain numbers shrink. **H9 attacks the right cost.**
+
+## H8 hit-rate prediction (iter 8 pre-flight)
+
+Direct measurement on a self-shadow drag (teapot az50, motion=light, 4s
+sample): of 6,986 (face_id, frame) tuples observed in the receiver-SVG
+set after H9b landed, **75.2 % (5,033 / 6,695 non-first-sight)
+emitted a `d=` byte-identical to the previous frame**. (291 unique
+receiver faces × ~24 visited frames.)
+
+This is the H8 prediction signal: per-frame SVG `d=` mutation has a
+~75 % byte-equal redundancy that the existing `MountedFace` cache
+doesn't catch (it memoizes width/height/matrix, not `d`). Before H9b
+(per-poly fan shadows) the hit rate was ~0 % — silhouette geometry is
+dramatically more stable frame-to-frame than fan triangulations.
+
+## Hypothesis backlog
+
+In rough priority order. Each entry gets its own branch and journal
+section below when explored.
+
+- **[H1] Dynamic-during-drag, full-quality on release.** Detect "user is
+ dragging the light" (rapid setOptions calls), switch to a coarser
+ shadow path (e.g. skip seam-cull, skip member-clip, use convex hull
+ silhouette), then run the full algorithm once when drag stops. Pure
+ perceived-FPS win; geometry-correct freeze frame.
+- **[H2] Path simplification on emitted SVG `d=`.** Douglas-Peucker on
+ the projected polygon vertices before `toFixed(1)` rounding. Reduces
+ DOM mutation cost + SVG repaint cost. Tunable threshold.
+- **[H3] Mounted-path memoization keyed by quantized light direction.**
+ Round the light direction to e.g. 1° steps; if the rounded value
+ matches the cached frame, skip recompute entirely. Trades 1° "jitter"
+ on slow drag for free skip.
+- **[H4] CSS `filter: drop-shadow` per casting mesh as an alternative
+ rendering primitive.** Browser-native shadow — no per-frame DOM. Won't
+ match per-face SVG semantically (single direction-blurred drop, no
+ receiver-plane projection) but might be acceptable for a "fast" mode.
+- **[H5] Canvas-rasterised shadow buffer.** Render shadow geometry to a
+ single 2D canvas per receiver face, set it as `background-image`. One
+ bitmap mutation per frame instead of ~300 SVG path mutations.
+- **[H6] Worker-thread SH-clip.** Move
+ `computeReceiverShadowFaces` to a `Worker`. Main thread only mutates
+ DOM. Doesn't reduce total work; removes it from main-thread budget.
+- **[H7] Cull at the caster-cluster level.** Spatial hash receiver
+ planes by (u,v) bbox; per caster, only test receivers in its swept
+ shadow volume. Already-partially-done by per-poly bbox prefilter;
+ worth checking whether broader caster-AABB → receiver-cluster
+ prefilter gets more than the per-poly one.
+- **[H8] Skip per-frame receiver-face SVG re-emission when nothing
+ changed.** Even on light drag, MANY shadow paths are byte-identical
+ frame-over-frame in the projected (u,v) frame. We already memoize
+ width/height/matrix; extending to `d=` was tried as "memoize-d=" and
+ came out flat (~0% hit rate at 0.5°/frame). Combined with (H3)
+ quantization, the hit rate should jump.
+- **[H9] Caster-mesh silhouette extraction.** Dissection of the
+ teapot-floor 90 KB merged path (`_dissect-shadow-path.mjs`) revealed
+ it's **2,182 sub-paths, each a triangle** (median 3 verts). The cost
+ is the number of caster polygons projected, not per-polygon vertex
+ count. The 2000+ triangles get unioned into a single silhouette by
+ fill-rule:nonzero at paint time — meaning the browser is doing the
+ silhouette work AFTER we've already paid the JS+DOM cost of emitting
+ them. Instead: compute the silhouette EDGE on the CPU per caster mesh
+ per frame (edges where one adjacent triangle faces the light and the
+ other doesn't), project only the silhouette polygon. For a teapot,
+ silhouette is ~20-50 edges → 50 vertices vs 6553 today (~130× fewer
+ vertices). Trade-off: complexity. Need edge-adjacency map (already
+ exists for self-shadow seam cull, see `buildSharedEdgeMap` in core).
+ For concave silhouettes there are interior loops; SVG `d=` supports
+ multiple subpaths with fill-rule:evenodd to make holes work. **This
+ dwarfs H2 in potential impact for the merge-collapse case.**
+- **[H11b] Silhouette onto OBB/averaged-plane proxy for self-shadow.**
+ Follow-up to H11 NEGATIVE result. Per-face receiver decomposition
+ can't be coalesced on a smooth curved mesh without also relaxing
+ OFFSET_TOL (which would merge unrelated planes). Instead: when
+ caster === receiver, replace the per-face receiver hull with a
+ single proxy plane per camera-facing region (oriented-bounding-box
+ face or k-means averaged plane). Project the H9b silhouette onto N
+ proxy planes instead of 242 actual planes → ~10× fewer SVGs without
+ needing OFFSET_TOL changes. Visual risk: shadow lands on an averaged
+ plane that can detach from real geometry; mitigate by clipping each
+ proxy's projection to the per-face member polygons it represents.
+
+## Iteration journal
+
+(append-only; newest at top)
+
+### Iteration 8 — H8 (re-test) memoize d= per face (NEGATIVE)
+
+**Hypothesis recap.** H8's first attempt (pre-H9b) cached `d=` on the
+single floor SVG and saw ~0% hit rate at 0.5°/frame motion: a single
+constantly-changing silhouette is the worst case for byte-identical
+frames. H9b changed the picture — teapot-self now mounts 138-242
+receiver SVGs, many of them for faces far from the light terminator
+whose shadow content barely (or never) changes between H3-quantized
+emits. The bet was that those faces would hit the cache often enough
+to save real DOM mutation cost.
+
+**Implementation.** Branch `perf/shadow-memoize-d-v2`. Added
+`lastPathDs: string[]` to `MountedFace` in
+`packages/polycss/src/api/scene/receiverShadow.ts`. Before each
+`path.setAttribute("d", p.d)`, compare against the cached string; only
+write on miss. Truncate the cache when the path-array length shrinks
+so a regrown index doesn't see a stale string and false-hit. React/Vue
+render receiver shadows declaratively via JSX/h() — the framework's
+own diff already short-circuits identical props, so no mirror is
+needed.
+
+**Cache-hit probe** (temporary `__polycssShadowDCacheStats`, 5s sample
+after 2s warmup, motion=light):
+
+| scene | skipped | written | hit rate |
+| --- | ---: | ---: | ---: |
+| teapot-self | 4125 | 3535 | **53.85%** |
+| teapot-floor | 0 | 78 | 0.00% |
+
+Hit rate is real on self-shadow (the H9b receiver-mount explosion gives
+the cache something to bite into) and zero on floor (single SVG with
+ever-changing silhouette, exactly as iter-0 H8 found).
+
+**Metrics (motion=light, 5s sample, x4_plus heavy frames):**
+
+| scene | h10 baseline | h8v2 r1 | h8v2 r2 | Δ |
+| --- | ---: | ---: | ---: | ---: |
+| teapot-self dt p50 (ms) | 117.10 | 124.65 | 124.90 | +6-8 (NOISE) |
+| teapot-self script (ms) | 124.86 | 127.20 | 127.77 | +2-3 (NOISE) |
+| teapot-floor dt p50 (ms) | 58.30 | 58.30 | 58.40 | ~0 |
+| teapot-floor script (ms) | 6.77 | 6.86 | 7.14 | +0.1-0.4 (NOISE) |
+
+**Why the hit rate doesn't translate.** ~47 setAttribute("d", …) calls
+skipped per frame on teapot-self vs ~78 still written. Each skipped
+setAttribute saves ~10-100 µs; ~47 of them is ~0.5-5 ms — below the
+±2-3 ms run-to-run noise floor on a 125 ms frame. The script-side
+bottleneck for self-shadow is `computeReceiverShadowFaces` (per-frame
+SH-clip + projection); DOM mutation is single-digit-percent of total
+script time. The cache is correct but addresses a non-bottleneck.
+
+**Visual.** Regression script byte-identical to h10-merged for all 12
+captures. Three.js parity byte-identical for all 12 poses.
+
+**Recommendation: DISCARD.** The cache is harmless and the hit rate is
+genuinely 50%+ on self-shadow, but the absolute time saved sits below
+measurement noise. Cherry-picking would add 17 lines of code + one
+string-array allocation per receiver face for no observable win. Re-
+visit only if a future iteration moves SVG mutation into the critical
+path (e.g. a script-side optimization halves SH-clip cost, making DOM
+mutation a larger share of remaining script time). Better next target:
+move into the compositor — `compositorMain` is the bigger share of
+remaining frame time after H10.
+
+
+
+**Hypothesis recap.** Earlier H10 attempt (iter 5) quantized only
+`--plx/y/z` and saw flat style cost. The recalc trigger was still
+firing on every frame even with those vars frozen → I concluded the
+trigger was outside lighting writes. WRONG diagnosis.
+
+**Discovery (iter 7 probe).** Quick A/B with bench `motion=none`
+(no setOptions per frame) showed style at 0.004 ms/frame and 120 FPS.
+Adding a `motion=light-noop` knob that calls `scene.setOptions({})`
+every frame (empty object) ALSO showed 0 style cost. So setOptions
+itself isn't the trigger — it's specifically what fires when
+`directionalLight` is in the partial.
+
+Re-tested H10 with --plx/y/z AND --clx/cly/clz frozen → **frame_p50
+8.3 ms, style 0.04 ms, 120 FPS**. The original H10 missed --clx/cly/clz
+(shadow-projection up-axis vars derived from light direction inside
+`applyLightingVars`). Those were the actual recalc trigger.
+
+**Real fix.** Changed `lx.toFixed(4)` → `lx.toFixed(2)` for ALL six
+direction-derived light vars in `packages/polycss/src/api/scene/lightingVars.ts`
+(--plx/y/z + --clx/cly/clz). 0.57° quantization matches the H3 emit-
+level quantize key; values only differ between frames when the rounded
+component flips a 0.01 boundary.
+
+**Metrics (perf-vanilla teapot, dynamic, no-self-shadow, motion=light):**
+
+| variant | x1 frames | x4_plus frames | total frames | mean fps |
+| --- | ---: | ---: | ---: | ---: |
+| H9+H3+H9b (pre-H10) | 0 | 47 (~58ms each) | 82 in 5s | 16.4 |
+| H10 (toFixed 2 on all 6 vars) | 31 | 75 | 113 in 5s | **22.6** |
+
+**+38% mean FPS.** fps_p50 stays at 17 because the slow frames (light
+crosses a quantize boundary) still cost ~52 ms style each — that's the
+unavoidable per-frame recalc when a var actually changes. But 38 out of
+113 frames now skip recalc entirely.
+
+**Visual.** Regression script byte-identical to H9b for all 12 captures.
+At 0.57° quantize, shadow position shifts <1 px even at extreme zoom.
+Three.js parity unchanged.
+
+**Recommendation: LANDED on `feat/three-parity` as 77f3206.**
+
+### Iteration 6 — H11 receiver-face coalesce (NEGATIVE)
+
+**Hypothesis recap.** After H9b, teapot-self is compositor-bound at
+frame_p50 ≈ 342 ms with 138-242 receiver SVGs per azimuth. Compositor
+cost is ~proportional to SVG count. Relax `RECEIVER_NORMAL_TOL` in
+`packages/core/src/shadow/receiverFaceGroups.ts` from 0.001 (~2.5° cone)
+to 0.02 (~11.5° cone) so adjacent smooth-shaded teapot triangles whose
+normals differ by 1-5° collapse into one face plane → one SVG. Keep
+`RECEIVER_OFFSET_TOL = 0.5` because it's a world-unit distance.
+
+**Implementation.** Branch `perf/shadow-face-coalesce`. One-line change:
+`RECEIVER_NORMAL_TOL: 0.001 → 0.02`. Built core + polycss + react + vue
++ bench bundles. Verified bundle contains `RECEIVER_NORMAL_TOL = 0.02`.
+
+**Metrics (shadow-regression fixture, teapot-self vs h9b-merged baseline).**
+
+| az | recv SVGs Δ | paths Δ | dChars Δ |
+| --- | ---: | ---: | ---: |
+| 50 | 0 (242→242) | 0 | 0 |
+| 130 | 0 (143→143) | 0 | 0 |
+| 220 | 0 (104→104) | 0 | 0 |
+
+teapot-floor / castle-floor / crate-floor: byte-identical, expected
+(those scenes already collapse to 1 SVG per receiver plane).
+
+**Why the win didn't materialize.** Bisection probes confirmed the
+plane-bucket pass has TWO filters AND'd together: `(1 - dot < NORMAL_TOL)
+&& (|Δoffset| < OFFSET_TOL)`. Plane offset is `n · O` where O is a face
+vertex, so when adjacent triangles' normals drift even 1-2° the resulting
+plane-offset values drift by `|O| × |Δn|` — for a teapot at typical
+world scale (vertices ~100s of CSS px from origin), that's tens of px,
+far beyond the 0.5 px OFFSET_TOL. The NORMAL filter therefore never
+actually gates the merge; OFFSET does, and NORMAL is dead code at any
+value > 0.001. Empirical probes (bench/results/shadow-regression/):
+
+| probe | teapot-self az50 recv SVGs |
+| --- | ---: |
+| baseline (0.001 / 0.5) | 242 |
+| H11 prescribed (0.02 / 0.5) | 242 |
+| 0.05 / 0.5 | 242 |
+| 2.0 / 0.5 (normal off) | 242 |
+| 0.05 / 2.0 | 241 |
+| 0.05 / 20.0 | 213 |
+| 2.0 / 1e6 (both off) | 2 |
+
+To actually coalesce a smooth-mesh receiver you'd need to ALSO raise
+OFFSET_TOL by 1-2 orders of magnitude — which is exactly the
+"parallel-but-far-apart walls merge into one" bug the hypothesis
+explicitly warned against. At OFFSET_TOL=20 you'd merge floor tiles
+with similar-normal ceiling tiles in modest-height rooms; not
+acceptable.
+
+**Trace / parity.** Trace skipped — the SVG-count delta is 0 across all
+scenes, so script/compositorMain ms/frame are determined to be
+unchanged by definition (same DOM mutation work, same path payload).
+Three.js parity captures `bench/results/threejs-parity/h11-coalesce/`
+all 12 PNGs md5-identical to the `h9b-merged` baseline. Visual
+inspection of `teapot-self-az{50,130,220}` PNGs vs h9b shows no
+detached shadows (no merges happened to detach).
+
+**Recommendation: DISCARD.** The hypothesis identified the right SYMPTOM
+(receiver-SVG count limits dt_p50) but the wrong LEVER. The
+plane-grouping pass can't coalesce smooth-curved-mesh receivers without
+also breaking the offset-distance invariant. To attack receiver-SVG
+count on curved self-shadow casters you need a different approach:
+either project the silhouette onto an averaged-plane proxy receiver per
+mesh region (not per face), or skip the per-face receiver decomposition
+entirely on caster == receiver and use a single oriented-bounding-box
+proxy receiver. Park as `[H11b] silhouette-onto-OBB-proxy` in the
+backlog. Branch `perf/shadow-face-coalesce` stays for traceability but
+contains no code change to cherry-pick — just this log entry. Files
+`bench/results/shadow-regression/h11-coalesce/`,
+`bench/results/shadow-regression/h11-probe-*/`,
+`bench/results/threejs-parity/h11-coalesce/` document the probe runs.
+
+### Iteration 5 — H10 CSS-var quantize for style-recalc floor (NEGATIVE)
+
+**Hypothesis.** With H9 + H3 landed, the dominant remaining cost in
+dynamic mode is the 53 ms/frame style recalc on 2,300 leaves whose
+`background-color` uses calc(--plx*--pnx + …). Theory: `setOptions`
+writes new --plx/y/z strings every tick via setStylePropertyIfChanged;
+coarsening precision (toFixed 4 → 2 → 1 → constants) would let many
+frames hit the existing "same-string → skip" path and freeze the recalc.
+
+**Implementation.** Local branch `perf/lighting-vars-quantize` (deleted
+after test). Three variants of `applyLightingVars` in
+`packages/polycss/src/api/scene/lightingVars.ts`: `.toFixed(2)`,
+`.toFixed(1)`, then literal `"0.0"/"0.0"/"1.0"` constants.
+
+**Trace results** (perf-vanilla teapot, dynamic, no-self-shadow, light motion):
+
+| variant | style ms/f x4_plus |
+| --- | ---: |
+| H9+H3 head | 52.7 |
+| `--plx/y/z` → `.toFixed(2)` | 52.6 |
+| `--plx/y/z` → `.toFixed(1)` | 54.4 |
+| `--plx/y/z` literally frozen constants | 52.5 |
+
+**Conclusion: NEGATIVE.** Frozen lighting vars → identical 52 ms recalc.
+The trigger is not the lighting var writes. Top suspect: `el.style.transform =
+buildSceneTransformFromCamera(...)` inside `applySceneStyle`, which
+fires on every setOptions. Need a deeper diagnostic before trying H10
+again. The 53 ms is the dynamic-Lambert floor for now.
+
+**Recommendation: DISCARD this approach.** Filed as H10 follow-up: probe
+which write actually triggers the per-frame recalc.
+
+**Probe follow-up (same iteration).** Also tried gating
+`el.style.transform = ...` in `applySceneStyle` so it only writes when
+the value changes. Same 53.7 ms style recalc — transform isn't the
+trigger either. With BOTH lighting vars + scene transform writes gated
+to no-ops, the recalc still fires every frame. The cost is intrinsic to
+"calc()-driven `background-color` on 2300 leaves under a CSS scene with
+any kind of per-frame activity" — possibly the browser's implicit
+recalc when ANY style-related event fires, regardless of whether the
+event materially changed anything visible. **No clean lever** to drop
+the 53 ms floor without redesigning dynamic mode (e.g. JS-set inline
+colors, or reducing leaf count). Park H10 here; pursue other H if any.
+
+### Iteration 4 — H9b silhouette self-shadow (branch `perf/shadow-silhouette-self`)
+
+**Hypothesis recap.** H9 cherry-pick at HEAD explicitly gates self-shadow
+(caster === receiver) OUT of the silhouette path because the per-poly
+branch uses `selfShadowEdgeMap` to drop adjacent-triangle projections
+(would otherwise show as a spiderweb of seam streaks on smooth GLBs).
+Drop the gate. The silhouette IS the geometric boundary of the lit
+region, which naturally excludes interior adjacent-triangle projections
+without needing per-edge seam culling — same 5× per-receiver-face
+script speedup we got on the floor case, applied across the 138-SVG
+self-shadow set on the teapot.
+
+**Implementation.** Removed the `if (casterEntry.selfShadowEdgeMap)
+return null;` early-out from `computeReceiverShadowFaces`
+(`packages/core/src/shadow/computeReceiverShadows.ts`) and the matching
+`caster !== receiverEntry` / `!isSelf` gate from each of the three
+caller files (`packages/polycss/src/api/scene/receiverShadow.ts`,
+`packages/react/src/scene/PolyMesh.tsx`, `packages/vue/src/scene/PolyMesh.ts`)
+so `edgeOwners` now gets prepared for self-shadow casters too. The
+`selfShadowEdgeMap` is still threaded through — small (<40 polys) self-
+shadow meshes keep falling through to the per-poly path and still get
+seam culling. Comment updates only; no new options.
+
+**Metrics (shadow-regression fixture, teapot-self scene).**
+
+| az | recv SVGs Δ | paths Δ | dChars baseline (h3) | dChars h9b | Δ % |
+| --- | ---: | ---: | ---: | ---: | ---:|
+| 50 | +104 | +104 | 42,479 | 16,731 | -60.6% |
+| 130 | +73 | +73 | 23,986 | 11,560 | -51.8% |
+| 220 | +52 | +52 | 26,000 | 11,134 | -57.2% |
+
+teapot-floor / castle-floor / crate-floor: byte-identical (silhouette
+path on non-self casters unchanged by this iteration).
+
+Note the +recv-SVG count: silhouette path emits shadows on MORE
+receiver faces than the per-poly path. The per-poly path's per-face
+seam cull was so aggressive on smooth-GLB self-shadow that many faces
+ended up with `totalClipped === 0` and got skipped. The silhouette
+loop, being a single closed outline that doesn't get seam-culled,
+populates those faces too. Net path-d chars still drops 51-60% per
+azimuth because each receiver-face now emits one short silhouette
+sub-path instead of 10-13 fan-triangle sub-paths.
+
+**Trace deltas** (perf-vanilla.html, page=shadow mesh=teapot mode=dynamic
+motion=light, 5s sample, file `h9b-teapot-self.json`):
+
+| bucket | frames | dt_p50 (ms) | script ms/f | compositorMain ms/f |
+| --- | ---: | ---: | ---: | ---: |
+| x3 h3 self | 3 | 58.0 | (n/a, x4-bound) | 120.0 |
+| x3 h9b self | 3 | 58.0 | 1.9 | 120.0 |
+| x4_plus h3 self | 14 | 342.5 | 570.5 | 411.6 |
+| x4_plus h9b self| 16 | 341.7 | 465.2 | 358.5 |
+
+`x4_plus` script ms/frame **drops 570.5 → 465.2 (-18%)**. compositorMain
+also drops 411.6 → 358.5 (-13%) because the SVG path payload is much
+smaller per receiver-face. dt_p50 is essentially unchanged (~342ms) —
+the self-shadow case is compositor-bound with ~155 receiver SVGs being
+composited each frame, and the silhouette only changes path CONTENT,
+not the receiver SVG count. To unlock dt_p50 we would need to also
+reduce the number of receiver SVGs (next-hypothesis territory).
+
+**Visual verdict (z=3.0 probe captures vs h3 baseline at same zoom).**
+
+- `teapot-self-az50-z3`: H9b matches H3 closely. A faint floating-dot
+ artifact off the teapot's left side in the H3 capture is GONE in
+ H9b — silhouette path doesn't manufacture stray sub-pixel sub-paths
+ the way the per-poly fan can on adjacent thin triangles.
+- `teapot-self-az130-z3`: Visually identical between H3 and H9b. Cast
+ shadow on floor stretches the same direction, teapot facets shade
+ the same way.
+- `teapot-self-az220-z3`: Visually identical between H3 and H9b. Dark
+ side facing camera reads the same; spout/handle/lid shade
+ consistently; floor cast shadow preserved.
+- **No spiderweb seam streaks** appear in any of the three H9b
+ azimuths. The per-poly path's `selfShadowEdgeMap` was protecting
+ against those, and the silhouette path's geometric-boundary semantic
+ is equivalent protection without needing the explicit cull.
+- The dark-side shadow region on the teapot body (spout, handle area)
+ reads correctly in both H3 and H9b — silhouette projection picks up
+ concave regions as expected for a closed-mesh silhouette.
+
+**Three.js parity.** All 12 parity shots (4 meshes × 3 light poses)
+**byte-identical to baseline** (md5 verified). Parity scenes don't
+toggle Self-shadow so they exercise H9 only, not H9b — but the byte-
+identical result confirms H9b didn't regress the non-self path.
+
+**Recommendation: cherry-pick.** Hypothesis confirmed — 51-60%
+reduction in path-d chars on teapot-self, 18% script ms/frame drop in
+the heavy bucket, 13% compositorMain ms/frame drop, no spiderweb
+artifacts, three.js parity preserved on non-self casters. The frame_p50
+ceiling stays compositor-bound because the per-receiver-face SVG count
+roughly DOUBLED (silhouette is less aggressive than seam-cull, so more
+faces emit shadows) — that's a feature, not a bug, but it limits the
+dt_p50 win until we attack receiver-SVG count separately. Surface
+change is small: one early-out removed in core, three caller gates
+loosened, no API additions. Ready for merge into `feat/three-parity`.
+
+### Iteration 3 — H3 light quantization (branch `perf/shadow-light-quantize`)
+
+**Hypothesis recap.** At ~0.5°/frame drag speed, consecutive shadow re-emits
+are doing nearly-identical work. Snap the directional light to a coarse
+angular grid (normalized components rounded to 0.01 ≈ 0.57°) and short-circuit
+`emitSceneShadows()` when the rounded key matches the cached frame. The
+visible "stair-step" jitter at 0.57° is below perception during active drag.
+
+**Implementation.** Added `quantizeLightDirKey()` + a closure-scope
+`lastEmittedShadowLightKey` cache to `packages/polycss/src/api/createPolyScene.ts`.
+`emitSceneShadows(lightDirectionOverride?)` computes the key from the
+already-CSS-frame `lightDir` and early-returns on match. An
+`invalidateShadowLightCache()` helper is called from every code path that
+mutates caster/receiver geometry or shadow appearance: `emitShadowLeaves`
+(the geometry-change funnel for `setPolygons` / `castShadow` toggle / chunked
+render / remount), `recomputeShadowGround` (ground/lift change), `setTransform`
+(receiveShadow toggle, position/scale on shadow-participating meshes), `add`
+(new receiver), `setOptions` (lighting mode / shadow color/opacity/lift), and
+`clearBakedSolidLightingPreview` (preview teardown). Light-direction-only
+changes through `setOptions` deliberately do NOT bust the cache — the quant
+key already discriminates by direction. Single-renderer change in
+`packages/polycss` only; no API surface change so React/Vue mirrors are not
+needed.
+
+**Metrics (trace, perf-vanilla page=shadow mesh=teapot mode=dynamic motion=light,
+5s sample, vs `shadow-teapot-dynamic-backface*` baseline references).**
+
+| variant | fps_p50 | frame_p50 (ms) | x4_plus script (ms) | style (ms) | compositorMain (ms) |
+| --- | ---: | ---: | ---: | ---: | ---: |
+| baseline floor (no-self) | 15.0 | 66.6 | 11.56 | 53.5 | 131.2 |
+| h3 floor (no-self) | 17.1 | 58.4 | 8.35 | 52.3 | 126.5 |
+| baseline teapot-self | 3.08 | 325.1 | 535.1 | 53.8 | 399.0 |
+| h3 teapot-self | 2.92 | 342.5 | 570.5 | 51.7 | 411.6 |
+
+Floor (silhouette-eligible) wins: **frame_p50 -12% (66.6 → 58.4ms)**,
+**fps +14% (15.0 → 17.1)**, **script -28% (11.56 → 8.35)**. Self-shadow
+is statistical noise (14-15 frames over 5s, ±5% wobble).
+
+**domSamples consecutive-identical rate** (frames where `shadow.paths +
+pathDChars` snapshot is byte-identical to the previous frame → emit was
+skipped, leaving the previous SVG content mounted):
+
+| variant | samples | consec-identical | rate |
+| --- | ---: | ---: | ---: |
+| baseline floor | 78 | 1 | 1.3% |
+| h3 floor | 83 | 15 | 18.3% |
+| baseline self | 17 | 1 | 6.3% |
+| h3 self | 16 | 0 | 0.0% |
+
+Floor shows a clear 14× rise in skip rate (1.3% → 18.3%) — the cache is
+firing. Self-shadow at this sample size (16 frames) is noise-bound; per-poly
+self-shadow content varies even at quantized direction because the receiver
+loop touches every face's plane, so any tiny camera/normal numeric drift
+shows. Self-shadow does not regress on a per-frame-script basis beyond
+noise.
+
+**Shadow-regression fixture (4 scenes × 3 azimuths).** All 12 captures
+byte-identical to **both** baseline AND h9-merged in PNG md5; path-d
+characters match h9-merged exactly (137,131 chars vs baseline's 685,841 —
+inherited from h9's silhouette extraction in the underlying renderer). The
+quantization cache does not engage on the regression fixture because each
+capture sets a fresh azimuth and the cache is busted on lightDir change in
+setOptions (the deltas are not in the per-frame light-rotate path).
+
+**Three.js parity.** All 12 three.js parity shots **byte-identical** to
+baseline by md5 (cube/E/cottage/castle × topdown/sideish/low-angle). Static
+shadow shape is unchanged, which is expected — the quantization only affects
+WHICH frame's emit gets skipped during drag, not the math.
+
+**Visual verdict.** Static screenshots show no regression. The trade-off
+lives in slow-drag temporal aliasing — the shadow snaps to ~0.57° buckets
+during active drag rather than smoothly tracking. At the 14% frame_p50 win
+on the typical floor case, the trade is favorable. If a user pauses
+mid-drag the cached frame matches the held quantum so there's no stale
+display either.
+
+**Recommendation: cherry-pick.** Floor scenes (the common case — any
+ground-receiver setup) get a measurable smoothness win (fps 15 → 17,
+frame_p50 -12%) with zero visual regression on static captures, byte-identical
+three.js parity, and a 14× rise in cache-skipped frames. Self-shadow doesn't
+benefit from this alone but doesn't regress either — it remains gated on
+H9's silhouette extraction (already merged) and a follow-up "self-shadow
+skip when receiver loop has no light-dependent output" hypothesis. The
+implementation surface is small (~30 LOC, single renderer, no API change)
+and the invalidation discipline is co-located with the existing
+shadow-emission call graph.
+
+### Iteration 2 — H9 silhouette extraction (branch `perf/shadow-silhouette`)
+
+**Hypothesis recap.** Replace the per-caster-polygon SH-clip loop with a
+per-caster-MESH silhouette projection. For a closed solid mesh, the
+projected silhouette is the boundary between front- and back-facing
+polygons relative to the light. Drawing ONE closed polygon per caster
+per receiver face instead of N triangles should drop DOM mutation by
+~100× on the teapot-floor case (2,182 sub-paths → ~10 closed loops).
+
+**Implementation.** New `SILHOUETTE_MIN_POLYS = 40` gate inside
+`computeReceiverShadowFaces` (`packages/core/src/shadow/computeReceiverShadows.ts`).
+For each caster, before the per-receiver-face loop, the algorithm
+classifies polygons as facing/not-facing via the pre-cached `edgeOwners`
+map (`buildEdgeOwners` from `silhouette.ts`) and `classifyFacing`
++ `extractSilhouetteLoops` (already shipped in the parent commit, plus a
+new `prepareCasterEdgeOwners` core helper that walks the world-CSS
+transform once per caster and caches it). Per-receiver-face, the
+silhouette branch 3D-clips each loop against the plane half-space (new
+`clipLoopAbovePlane` Sutherland-Hodgman helper), projects to (u,v), then
+runs the same `reachRect → outlineUv → memberPolysUv` clip pipeline as
+the per-poly branch. Result: ONE sub-path per loop per member-poly
+instead of one per fan-triangulated caster triangle. Self-shadow
+(caster === receiver) and meshes with < 40 polygons fall through to the
+per-poly path — silhouette infra overhead exceeds the per-poly cost on
+small meshes, and the per-poly self-shadow path has different per-face
+contribution semantics. WeakMap caches for `edgeOwners` plumbed through
+the vanilla `receiverShadow.ts`, React `PolyMesh.tsx`, and Vue
+`PolyMesh.ts` callers, with the bust key matching the existing
+`casterItemsCache` key (position/scale/rotation) so the world-frame edge
+owners stay coherent with their matching `CasterPolyItem[]`. New
+`ReceiverCasterInput.edgeOwners` + `casterPolygonCount` fields; new
+core exports: `prepareCasterEdgeOwners`, `buildEdgeOwners`,
+`classifyFacing`, `extractSilhouetteLoops`, `EdgeOwners` type.
+
+**Metrics (shadow-regression fixture, 4 scenes × 3 azimuths).**
+
+| scene | baseline avg dChars | h9 avg dChars | Δ avg dChars | Δ % |
+| --- | ---: | ---: | ---: | ---:|
+| teapot-self | 115,241 | 30,822 | -84,419 | -73.3% |
+| teapot-floor | 89,816 | 5,396 | -84,420 | -94.0% |
+| castle-floor | 23,344 | 9,280 | -14,064 | -60.2% |
+| crate-floor | 212 | 212 | 0 | 0.0% |
+
+Sub-path counts:
+- teapot-floor: 2,182 → ~10 (≈200× drop, matches the H2 prediction).
+- castle-floor: similar order-of-magnitude collapse to one loop per
+ silhouette.
+- teapot-self: floor receiver collapses to silhouette; the teapot
+ receiver still uses per-poly (self-shadow gate) so 138 receiver
+ SVGs are preserved but the floor sub-path collapsed massively.
+- crate-floor: 12 polys < 40 → gate skipped → per-poly path → unchanged
+ (expected; threshold is the whole point).
+
+Receiver SVG count Δ = 0 across all 12 captures (silhouette doesn't
+change WHICH faces emit, only the path content).
+
+**Trace deltas** (perf-vanilla.html, page=perf mesh=teapot mode=dynamic
+motion=light, 5s sample, vs `shadow-teapot-dynamic-backface-noself.json`
+baseline reference):
+
+| bucket | frames | dt_p50 (ms) | script_ms |
+| --- | ---: | ---: | ---: |
+| x3 baseline | 12 | 58.3 | 9.80 |
+| x3 h9 | 50 | 58.20 | 1.63 |
+| x4_plus baseline | 64 | 66.70 | 11.56 |
+| x4_plus h9 | 37 | 66.72 | 4.81 |
+
+frame_p50 stays roughly flat (compositor-dominated, ~115ms gpuViz +
+compositorMain across both runs) but **script_ms drops 6× in the heavy
+x3 bucket and 2.4× in the x4_plus bucket**. The per-frame distribution
+shifts toward lighter buckets (x3 frame share grew 12 → 50; x4_plus
+shrank 64 → 37), so the user-perceived smoothness wins more than the
+median frame-time number suggests. The compositor doesn't get any
+faster because the SVG it composites is the same shape, just authored
+from fewer sub-paths.
+
+**Visual verdict.** All 12 PNG pairs (4 scenes × 3 azimuths) are
+**byte-identical to baseline** by MD5. The silhouette extraction is
+mathematically equivalent to fill-rule:nonzero union of all
+front-facing-poly projections, so the rendered pixels don't shift even
+sub-pixel.
+
+**Three.js parity.** All 12 three.js parity shots (4 meshes × 3 light
+poses) byte-identical to baseline. Since baseline already matched
+three.js, h9 inherits the parity.
+
+**Recommendation: cherry-pick.** Hypothesis confirmed — 94% reduction
+in path-d chars on teapot-floor, 60-73% reductions elsewhere, all
+visually identical, three.js parity preserved, ~5× drop in script_ms.
+The architectural surface change is minimal (two extra fields on
+`ReceiverCasterInput`, one new `prepareCasterEdgeOwners` helper, three
+mirrored caller patches) and the silhouette path is gated so smaller
+meshes (< 40 polys) and self-shadow keep the existing per-poly path
+untouched. Ready for review and merge into `feat/three-parity`.
+
+### Iteration 1 — H2 path simplify (branch `perf/shadow-path-simplify`)
+
+**Hypothesis recap.** Douglas-Peucker simplification on each per-frame
+projected polygon clip (ε = 1 CSS px) before stringifying into SVG
+`d=`, expected 5-10× drop in `pathDChars` on the teapot-floor case
+(~90k chars compound path).
+
+**Implementation.** Added `simplifyPolylineDP` + `simplifyPolygonRingDP`
+helpers and a `SHADOW_PATH_SIMPLIFY_EPS` const to
+`packages/core/src/shadow/computeReceiverShadows.ts`; invoked on
+`memberClip` immediately before the existing
+`bucket.verts.push(memberClip)` site, so the simplified ring is what
+gets written into the SVG path. Static `outlineUv` /
+`memberPolysUv` are left untouched (they're per-mesh clip subjects,
+not per-frame outputs).
+
+**Metrics (shadow-regression fixture, 4 scenes × 3 azimuths).**
+
+| scene | baseline avg dChars | h2 avg dChars | Δ avg dChars | Δ % |
+| --- | ---: | ---: | ---: | ---:|
+| teapot-self | 115,241 | 113,937 | -1,303 | -1.1% |
+| teapot-floor | 89,816 | 89,816 | 0 | 0.0% |
+| castle-floor | 23,178 | 23,154 | -190 | -0.8% |
+| crate-floor | 215 | 188 | -27 | -12.7% |
+
+Receiver SVG count Δ = 0 across all 12 captures (expected — DP changes
+ring vertex counts only, not which faces emit a path).
+
+Trace (`shadow-h2-teapot-self`, page=shadow mesh=teapot mode=dynamic
+motion=light, 5s sample): frame_p50 = 325.00ms, script_ms = 533.81 in
+x4_plus bucket. Baseline reference
+(`bench/results/shadow-teapot-dynamic-backface.json` on parent
+`feat/three-parity`): frame_p50 = 325.10ms, script_ms = 535.08.
+Wall-time delta ≈ 0 (within noise).
+
+Live trace `domSamples` cross-check: avg pathDChars dropped from
+~120,034 (baseline) to ~118,252 (h2) — confirms the 1.5% reduction
+on the live light-rotation path.
+
+**Visual verdict (per scene).** Compared PNG pairs via Read + raw byte
+diff:
+
+- `teapot-self` az50/130: byte-identical. az220: 3-byte PNG-stream
+ diff (compression-level noise, visually unchanged).
+- `teapot-floor` az50/130/220: byte-identical.
+- `castle-floor` az50/130/220: byte-identical.
+- `crate-floor` az50: byte-identical.
+
+Verdict: visually indistinguishable. DP simplification at ε=1 does not
+perturb the rendered shadow at the regression fixture's render scale.
+
+**Why the hypothesis underperformed.** Each per-frame member-clip is
+the SH-clip of a single fan-triangulated caster tri against the
+receiver outline + member polygon — already 3-7 vertices in the
+common case. The teapot-floor "~90k chars" comes from ~6-7k MOVE/LINE
+tokens emitted by hundreds of small clip polygons, not from a few
+high-vertex-count clips. DP can't simplify a clip that's already
+near-minimal. The 1-2% wins on dense scenes come from the small share
+of clips that DID have 6-8 nearly colinear vertices.
+
+**Recommendation: discard for the H2 default, but keep ε as a knob.**
+The simplification is a no-op in the worst case (~0 saving on
+teapot-floor's compound path; flat frame_p50). The real path-length
+bottleneck is *number of subpaths*, not vertices per subpath; the win
+lives upstream (cluster adjacent caster contributions into a single
+union polygon before SH-clip, or move the bottleneck up to H3
+quantize-skip / H1 drag-coarse). Recommend NOT cherry-picking onto
+`feat/three-parity` as-is. Worth revisiting at a coarser ε (3-5 px)
+only if combined with an upstream merge pass that produces fewer,
+larger compound shapes.
+
+### Iteration 0 — baseline lock-in (commit 5dff12d)
+
+Captured `bench/results/shadow-regression/baseline-.{png,json}`
+for the regression set (see Fixture). Recorded the cost breakdown
+above. No code changes.
+
+Baseline shadow.paths × shadow.pathDChars:
+
+| scene | recvSVGs | sub-paths | path-d chars |
+| --- | ---: | ---: | ---: |
+| teapot-self az50 | 138 | 138 | 125,046 |
+| teapot-self az130 | 70 | 70 | 108,627 |
+| teapot-self az220 | 52 | 52 | 112,050 |
+| teapot-floor az50 | 1 | 2,182 | 87,869 |
+| teapot-floor az130 | 1 | 2,182 | 89,982 |
+| teapot-floor az220 | 1 | 2,182 | 91,596 |
+| castle-floor | 1 | ~600 | ~23,000 |
+| crate-floor | 1 | 12 | ~210 |
+
+Dissection finding (`_dissect-shadow-path.mjs`): the teapot-floor
+shadow is **1 SVG path containing 2,182 sub-paths, each a
+triangle/quad** (median 3 vertices). The browser unions them via
+fill-rule:nonzero at paint time. That makes H2 (Douglas-Peucker
+per-poly simplification) likely a no-op for this case — triangles can't
+be DP'd — but it makes H9 (caster-mesh silhouette extraction) a
+potential 130× DOM reduction.
+
+## Fixture
+
+`bench/scripts/shadow-regression.mjs` captures the following scene ×
+light-pose matrix from a deterministic perf-vanilla.html URL. Each
+capture stores: (a) screenshot PNG, (b) JSON with shadow.paths /
+shadow.pathDChars / receiver SVG count / frame_p50 / fps_p50.
+
+| scene | mesh | castShadow | self-shadow | floor | meaning |
+| --- | --- | :-: | :-: | :-: | --- |
+| `teapot-self` | teapot | ✓ | ✓ | ✓ | the worst self-shadow stress case |
+| `teapot-floor` | teapot | ✓ | ✗ | ✓ | typical "cast on ground" path |
+| `castle-floor` | castle | ✓ | ✗ | ✓ | complex outline + many casters |
+| `cube-floor` | synth-cube | ✓ | ✗ | ✓ | trivial silhouette baseline |
+
+Each captured at three light azimuths (50°, 130°, 220°) × one elevation
+(45°), so we see how shadows behave across the rotation range without
+combinatorial blowup.
+
+A run is "good" iff:
+- pixel-diff vs baseline screenshot under ~1% of pixels OR shape is
+ visually equivalent (judged by the loop).
+- frame_p50 is strictly lower OR shadow.pathDChars is strictly lower
+ with no other regression.
+
+## Open questions / things to verify later
+
+- For (H4) drop-shadow: does it interact correctly with receiver-mesh
+ geometry, or does it always project onto the page plane? Almost
+ certainly the latter — would only be acceptable as a "ground only"
+ fast path, not for receiver-mesh shadows.
+- For (H5) canvas raster: how big is the per-frame canvas budget at our
+ receiver SVG sizes (3406×3394 for the apple gallery scene)? Need to
+ estimate before prototyping.
+- For (H6) worker: serialization cost of the per-frame inputs (caster
+ items, receiver planes) might dominate compute savings for small
+ scenes. Mostly a win for heavy GLB scenes.
diff --git a/bench/parity-quad.html b/bench/parity-quad.html
new file mode 100644
index 00000000..c8d3b324
--- /dev/null
+++ b/bench/parity-quad.html
@@ -0,0 +1,330 @@
+
+
+
+
+ polycss parity quad — vanilla / React / Vue / HTML
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/bench/perf-html.html b/bench/perf-html.html
index 75eac97e..dbcbf3b0 100644
--- a/bench/perf-html.html
+++ b/bench/perf-html.html
@@ -12,7 +12,7 @@
+