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 @@ + + diff --git a/bench/real-shadow-shot.mjs b/bench/real-shadow-shot.mjs deleted file mode 100644 index a65cfe76..00000000 --- a/bench/real-shadow-shot.mjs +++ /dev/null @@ -1,173 +0,0 @@ -import { chromium } from "playwright"; -import { resolve, dirname } from "node:path"; -import { fileURLToPath } from "node:url"; -import { chromiumArgsWithGpuDefault } from "/Users/apresmoi/Documents/voxcss/bench/chromium-defaults.mjs"; - -const __dirname = dirname(fileURLToPath(import.meta.url)); -const browser = await chromium.launch({ - headless: true, - 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:4400/real-shadow.html?norayt", { waitUntil: "networkidle", timeout: 15000 }); - await page.waitForTimeout(1500); - await page.waitForTimeout(300); - const status = await page.evaluate(() => document.getElementById("status")?.textContent ?? ""); - console.log("status:", status); - // Primary screenshot (with shadows) — taken BEFORE any hiding. - await page.screenshot({ path: "/tmp/real-shadow.png", fullPage: false }); - // Rotate camera 180° to see the other side - await page.evaluate(() => { - // Drag the canvas to rotate via orbit controls — fake a drag event - const host = document.getElementById("host"); - const r = host.getBoundingClientRect(); - const cx = r.x + r.width / 2; - const cy = r.y + r.height / 2; - host.dispatchEvent(new PointerEvent("pointerdown", { clientX: cx, clientY: cy, button: 0, pointerType: "mouse", bubbles: true, pointerId: 1 })); - host.dispatchEvent(new PointerEvent("pointermove", { clientX: cx + 600, clientY: cy, button: 0, pointerType: "mouse", bubbles: true, pointerId: 1 })); - host.dispatchEvent(new PointerEvent("pointerup", { clientX: cx + 600, clientY: cy, button: 0, pointerType: "mouse", bubbles: true, pointerId: 1 })); - }); - await page.waitForTimeout(500); - await page.screenshot({ path: "/tmp/real-shadow-other-side.png", fullPage: false }); - console.log("/tmp/real-shadow-other-side.png"); - // Baseline screenshot with shadows hidden, for diff comparison. - await page.evaluate(() => { - document.querySelectorAll("svg.polycss-shadow").forEach((s) => s.style.display = "none"); - }); - await page.waitForTimeout(200); - await page.screenshot({ path: "/tmp/real-shadow-nopole.png", fullPage: false }); - console.log("/tmp/real-shadow-nopole.png"); - const meshState = await page.evaluate(() => { - const scene = document.querySelector(".polycss-scene"); - const meshes = scene?.querySelectorAll(".polycss-mesh") ?? []; - return Array.from(meshes).map((m, i) => ({ i, transform: m.style.transform.slice(0, 60) })); - }); - console.log("meshes:", JSON.stringify(meshState)); - const debug = await page.evaluate(() => { - const meshes = document.querySelectorAll(".polycss-mesh"); - const groundSvg = document.querySelector("svg.polycss-shadow:not(.polycss-shadow-receiver)"); - const d = groundSvg?.querySelector("path")?.getAttribute("d") ?? ""; - // Last subpath in d: - const subs = d.split("Z").filter(Boolean); - const last = subs[subs.length - 1] ?? ""; - // Dump each mesh's transform + receiveShadow state. Read via the - // public handle exposed on the bench global. - const handles = { - plane: window.planeHandle, - apple: window.appleHandle, - pole: window.poleHandle, - }; - const handleStates = {}; - for (const [k, h] of Object.entries(handles)) { - handleStates[k] = h ? { - receiveShadow: h.transform?.receiveShadow, - castShadow: h.transform?.castShadow, - position: h.transform?.position, - } : null; - } - return { - meshCount: meshes.length, - lastSubpath: last.slice(0, 200), - totalSubpaths: subs.length, - handleStates, - }; - }); - // Also dump receiver SVG content (apple receiver surface) - const receiverDump = await page.evaluate(() => { - const recvs = Array.from(document.querySelectorAll("svg.polycss-shadow-receiver")); - return { - count: recvs.length, - items: recvs.map((recv) => ({ - width: recv.getAttribute("width"), - height: recv.getAttribute("height"), - transform: recv.style.transform.slice(0, 120), - subpathCount: ((recv.querySelector("path")?.getAttribute("d") ?? "").match(/M/g) || []).length, - })), - }; - }); - const hullDbg = await page.evaluate(() => window.__hullDbg); - console.log("hullDbg:", JSON.stringify(hullDbg, null, 2)); - const allBounds = await page.evaluate(() => ({ apple: window.__appleBounds, pole: window.__poleBounds })); - console.log("bounds:", JSON.stringify(allBounds, null, 2)); - const handleBounds = await page.evaluate(() => { - const polys = window.__applePolys; - if (!polys) return null; - const verts = polys.flatMap((p) => p.vertices); - const b = (i) => ({ min: Math.min(...verts.map((v) => v[i])), max: Math.max(...verts.map((v) => v[i])) }); - return { polyCount: polys.length, x: b(0), y: b(1), z: b(2) }; - }); - console.log("apple bounds (post scene.add):", JSON.stringify(handleBounds, null, 2)); - console.log("receiver:", JSON.stringify(receiverDump, null, 2)); - console.log("debug:", JSON.stringify(debug, null, 2)); - const vcountHist = await page.evaluate(() => { - const scene = window.__polySnapshot; // hack — but easier: just look at mesh polygon data via handles - // Each polycss-mesh has its leaf elements; count vertices via the s/u/i element classes? No. - // Just dump rendered shadow path subpath vertex counts to histogram. - const groundSvg = document.querySelector("svg.polycss-shadow:not(.polycss-shadow-receiver)"); - if (!groundSvg) return {}; - const d = groundSvg.querySelector("path")?.getAttribute("d") ?? ""; - const sps = d.split("Z").filter(Boolean); - const hist = {}; - for (const sp of sps) { - const coords = sp.replace(/[ML]/g, ",").split(",").filter(Boolean); - const vc = coords.length / 2; - hist[vc] = (hist[vc] || 0) + 1; - } - return hist; - }); - console.log("vertex-count histogram:", JSON.stringify(vcountHist)); - const shadowDump = await page.evaluate(() => { - const groundSvg = document.querySelector("svg.polycss-shadow:not(.polycss-shadow-receiver)"); - if (!groundSvg) return { error: "no ground svg" }; - const path = groundSvg.querySelector("path"); - const d = path?.getAttribute("d") ?? ""; - // Split into subpaths and count CCW vs CW winding for each - const subpaths = d.split("Z").filter(Boolean); - const sample = subpaths.slice(0, 6).map((sp) => { - // Parse "Mx,yLx,yLx,y..." → vertex list - const coords = sp.replace(/[ML]/g, ",").split(",").filter(Boolean).map(Number); - const verts = []; - for (let i = 0; i + 1 < coords.length; i += 2) verts.push([coords[i], coords[i + 1]]); - // Signed area (positive = math-CCW = screen-CW in SVG) - 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 { vcount: verts.length, signedArea: a / 2 }; - }); - let ccwCount = 0, cwCount = 0, degenCount = 0; - const cwOffenders = []; - for (const sp of subpaths) { - const coords = sp.replace(/[ML]/g, ",").split(",").filter(Boolean).map(Number); - const verts = []; - for (let i = 0; i + 1 < coords.length; i += 2) verts.push([coords[i], coords[i + 1]]); - 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]; - } - if (a > 1) ccwCount++; - else if (a < -1) { cwCount++; if (cwOffenders.length < 3) cwOffenders.push({ area: a / 2, vcount: verts.length, verts }); } - else degenCount++; - } - return { - svgWidth: groundSvg.getAttribute("width"), - svgHeight: groundSvg.getAttribute("height"), - svgTransform: groundSvg.style.transform, - subpathCount: subpaths.length, - ccwCount, cwCount, degenCount, - cwOffenders, - }; - }); - console.log("shadow:", JSON.stringify(shadowDump, null, 2)); - console.log("/tmp/real-shadow.png"); -} finally { - await browser.close(); -} diff --git a/bench/scripts/shadow-regression.mjs b/bench/scripts/shadow-regression.mjs new file mode 100644 index 00000000..9cb68890 --- /dev/null +++ b/bench/scripts/shadow-regression.mjs @@ -0,0 +1,150 @@ +#!/usr/bin/env node +/** + * Shadow-regression fixture for the perf research loop. + * + * For each (scene, light-azimuth) pair, load perf-vanilla.html at a + * deterministic URL, settle 6s, and capture both a screenshot and a + * stats JSON (paths/SVGs/path-d-chars). The output goes under + * bench/results/shadow-regression/