diff --git a/AGENTS.md b/AGENTS.md index e4c5d2ff..40f22d9a 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -35,7 +35,7 @@ Voxel-shaped meshes are the exception to "all polygons stay mounted": meshes wit | `` | **Border-shape clipped solid** | Untextured non-rect not caught by the exact corner-shape solid path, on browsers with CSS `border-shape` (Chromium + `pointer:fine` + `hover:hover`) | `border-color: currentColor` on a fixed 16px border-shape primitive, clipped by `border-shape: polygon(...)`; polygon bbox scale and tiny solid bleed are folded into `matrix3d` | None | | `` | **Atlas slice** | Textured polygons, or untextured non-rect on browsers without `border-shape` | `background-image` slice of packed bitmap on an auto-budgeted fixed primitive (128px for desktop-class `textureQuality="auto"`, 64px for mobile-class `auto` and explicit numeric quality); atlas position/size and `matrix3d` scale are normalized to the slice, shared textured edges get low-alpha atlas pixels repaired during atlas generation, and solid fallbacks get same-color edge bleed to avoid dark alpha fringes | Bounding-rect area | | `` | **Stable solid triangle / corner-shape solid** | Triangles on non-WebKit engines; or untextured non-triangle polygons whose normalized outline is exactly a rectangle with one or more beveled corners on browsers with CSS `corner-shape` | Triangles use a 32px box with two beveled top corners and `background: currentColor` when CSS `corner-shape` support is present, progressively falling back to the CSS border-color triangle trick; exact corner-shape solids use a bare fixed 16px box with inline per-corner radii + `corner-*-shape: bevel` and `background: currentColor`. Tiny solid bleed is folded into `matrix3d`. WebKit/Safari falls through to `` for border triangles because transformed CSS border triangles composite incorrectly there. | None | -| `` | **Cast shadow leaf** | Per casting polygon when `castShadow: true` and dynamic lighting mode. Applies regardless of caster strategy — ``/``/``/`` all produce a `` shadow because only the polygon's outline matters, not its surface. | Same `border-color: currentColor` + `border-shape: polygon(...)` as ``, but transform composes `var(--shadow-proj)` to project the polygon onto the ground plane along the CSS-space light direction | None | +| `` | **Cast shadow leaf** | Per casting polygon when `castShadow: true`, in either lighting mode. Applies regardless of caster strategy — ``/``/``/`` all produce a `` shadow because only the polygon's outline matters, not its surface. | Same `border-color: currentColor` + `border-shape: polygon(...)` as ``. Dynamic mode chains `var(--shadow-proj)` (driven by `--clx/y/z` + `--shadow-ground-cssz`) so the projection follows the live light vars. Baked mode CPU-bakes the projection into the leaf's inline `matrix3d(...)` and drops back-facing polys from the DOM entirely instead of opacity-gating them. | None | Strategies are ordered cheapest → most expensive. The mesher's job is to maximise `` / `` / `` and minimise `` (see "Meshing implications" below). @@ -45,8 +45,8 @@ The `.vox` fast path emits plain `` elements inside `.polycss-voxel-face` wra ### Lighting modes (`PolyTextureLightingMode = "baked" | "dynamic"`) -- **Baked.** Lambert is computed once on the CPU per polygon, multiplied into the inline `color` (for ``/``/``) or into the rasterised atlas pixels (for ``). Moving a light requires re-rasterising affected polys. -- **Dynamic.** Scene root carries the light setup as custom properties (`--plx/y/z`, `--plr/g/b`, `--pli`, `--par/g/b`, `--pai`). Each leaf embeds its surface normal (`--pnx/y/z`) and base color (`--psr/g/b`) inline. CSS `calc()` resolves the Lambert dot product and per-channel tint at paint time. Moving a light mutates one var on the scene root — zero JS, no atlas redraw. +- **Baked.** Lambert is computed once on the CPU per polygon, multiplied into the inline `color` (for ``/``/``) or into the rasterised atlas pixels (for ``). Moving a light requires explicit re-rasterising of affected polys via `mesh.rebakeAtlas()`; cast-shadow `` leaves auto re-emit with a fresh CPU-baked `matrix3d` (DOM-only, no atlas redraw) so shadows can still follow the light interactively even when the lit-side shading stays frozen. +- **Dynamic.** Scene root carries the light setup as custom properties (`--plx/y/z`, `--plr/g/b`, `--pli`, `--par/g/b`, `--pai`). Each leaf embeds its surface normal (`--pnx/y/z`) and base color (`--psr/g/b`) inline. CSS `calc()` resolves the Lambert dot product and per-channel tint at paint time. Moving a light mutates one var on the scene root — zero JS, no atlas redraw. Cast shadows project via `--shadow-proj` and gate back-facing polys with a CSS opacity calc. All solid/atlas tags work in both modes. The `.vox` direct-matrix fast path is baked-only for now; dynamic mode uses the polygon path so lighting semantics stay correct. The full coverage matrix is in `packages/polycss/src/styles/styles.ts`. diff --git a/bench/baked-shadow-diagnose.mjs b/bench/baked-shadow-diagnose.mjs new file mode 100644 index 00000000..6a5bc9a3 --- /dev/null +++ b/bench/baked-shadow-diagnose.mjs @@ -0,0 +1,139 @@ +#!/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 new file mode 100644 index 00000000..8ba602cd --- /dev/null +++ b/bench/baked-shadow.html @@ -0,0 +1,127 @@ + + + + + polycss baked-shadow diagnose + + + +
+

+
+  
+
+
diff --git a/bench/bat-shadow-diagnose.mjs b/bench/bat-shadow-diagnose.mjs
new file mode 100644
index 00000000..22204ba8
--- /dev/null
+++ b/bench/bat-shadow-diagnose.mjs
@@ -0,0 +1,151 @@
+#!/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 new file mode 100644 index 00000000..fc68e26a --- /dev/null +++ b/bench/composite-shadow-diagnose.mjs @@ -0,0 +1,81 @@ +#!/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 new file mode 100644 index 00000000..13b30913 --- /dev/null +++ b/bench/composite-shadow.html @@ -0,0 +1,192 @@ + + + + + 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
new file mode 100644
index 00000000..c9f58f9e
--- /dev/null
+++ b/bench/count-svgs.mjs
@@ -0,0 +1,40 @@
+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/gallery-shadow-compare.mjs b/bench/gallery-shadow-compare.mjs
new file mode 100644
index 00000000..1a5e484d
--- /dev/null
+++ b/bench/gallery-shadow-compare.mjs
@@ -0,0 +1,138 @@
+#!/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
new file mode 100644
index 00000000..e6c3268d
--- /dev/null
+++ b/bench/gallery-shadow-diagnose.mjs
@@ -0,0 +1,152 @@
+#!/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/real-shadow-shot.mjs b/bench/real-shadow-shot.mjs
new file mode 100644
index 00000000..a65cfe76
--- /dev/null
+++ b/bench/real-shadow-shot.mjs
@@ -0,0 +1,173 @@
+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/real-shadow.html b/bench/real-shadow.html
new file mode 100644
index 00000000..e138b24e
--- /dev/null
+++ b/bench/real-shadow.html
@@ -0,0 +1,604 @@
+
+
+
+  
+  polycss real-mesh shadow — plane + apple + electric pole
+  
+
+
+  
+
+ + + 60° + + + 56° + + + + + + +
+
+ + + + diff --git a/bench/shadow-diff.mjs b/bench/shadow-diff.mjs new file mode 100644 index 00000000..67033e88 --- /dev/null +++ b/bench/shadow-diff.mjs @@ -0,0 +1,45 @@ +// Quick pixel-diff between two screenshot directories. +// Compares same-named files; reports max channel delta and pct of changed pixels. +import { readdirSync, readFileSync } from "node:fs"; +import { resolve, basename } from "node:path"; +import { PNG } from "pngjs"; + +const [aDir, bDir] = process.argv.slice(2).map((p) => resolve(p)); +if (!aDir || !bDir) { + console.error("usage: node shadow-diff.mjs "); + process.exit(2); +} + +const files = readdirSync(aDir).filter((f) => f.endsWith(".png")); +let worst = { file: "", maxDelta: 0, changedPct: 0 }; +for (const f of files) { + const a = PNG.sync.read(readFileSync(`${aDir}/${f}`)); + let b; + try { + b = PNG.sync.read(readFileSync(`${bDir}/${f}`)); + } catch (e) { + console.log(`${f}: MISSING IN B`); + continue; + } + if (a.width !== b.width || a.height !== b.height) { + console.log(`${f}: SIZE MISMATCH ${a.width}x${a.height} vs ${b.width}x${b.height}`); + continue; + } + let maxD = 0; + let changed = 0; + for (let i = 0; i < a.data.length; i += 4) { + const dr = Math.abs(a.data[i] - b.data[i]); + const dg = Math.abs(a.data[i + 1] - b.data[i + 1]); + const db = Math.abs(a.data[i + 2] - b.data[i + 2]); + const d = Math.max(dr, dg, db); + if (d > 0) changed++; + if (d > maxD) maxD = d; + } + const pct = (changed / (a.width * a.height)) * 100; + console.log(`${f.padEnd(36)} maxΔ=${String(maxD).padStart(3)} changed=${pct.toFixed(3)}%`); + if (maxD > worst.maxDelta || (maxD === worst.maxDelta && pct > worst.changedPct)) { + worst = { file: f, maxDelta: maxD, changedPct: pct }; + } +} +console.log("---"); +console.log(`worst: ${worst.file} maxΔ=${worst.maxDelta} changed=${worst.changedPct.toFixed(3)}%`); diff --git a/bench/shadow-multi-angle.mjs b/bench/shadow-multi-angle.mjs new file mode 100644 index 00000000..7f6ef7ed --- /dev/null +++ b/bench/shadow-multi-angle.mjs @@ -0,0 +1,83 @@ +// Capture shadow scene from many camera positions + a few light positions +// so we can compare baseline vs each optimization visually. +// +// Usage: node bench/shadow-multi-angle.mjs +// Output: /cam-{az}-light-{lightAz}.png +// +// Camera rotates by faking pointer drag on the host element. +// Light is set programmatically via the #az slider on real-shadow.html. +import { chromium } from "playwright"; +import { resolve } from "node:path"; +import { chromiumArgsWithGpuDefault } from "./chromium-defaults.mjs"; + +const outDir = process.argv[2] ? resolve(process.argv[2]) : resolve("bench/results/baselines/shadows"); + +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 }); + // Wait for meshes + first frame. + await page.waitForFunction(() => document.querySelectorAll("svg.polycss-shadow").length > 0, { timeout: 15000 }); + await page.waitForTimeout(400); + + const lightSweep = [60, 150, 240, 330]; + const camRotations = [0, 90, 180, 270]; // horizontal drag pixels = rotation + const dragPxPer90Deg = 300; + + for (const lightAz of lightSweep) { + await page.evaluate((v) => { + const el = document.getElementById("az"); + el.value = String(v); + el.dispatchEvent(new Event("input")); + }, lightAz); + await page.waitForTimeout(80); + + for (const camAz of camRotations) { + // Reset camera by hard-reloading? Too slow. Instead, rotate + // by drag delta from previous position. Track absolute drag. + const dragX = camAz === 0 ? 0 : dragPxPer90Deg * (camAz / 90); + // Each frame: pointerdown at center, pointermove by dragX, pointerup. + // The orbit controls track delta, so each iteration applies a + // relative rotation. To make absolute we'd need to reset the + // camera; for our purposes a per-angle sweep works the same. + if (camAz > 0) { + await page.evaluate(({ dx }) => { + 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 + dx, clientY: cy, button: 0, pointerType: "mouse", bubbles: true, pointerId: 1 })); + host.dispatchEvent(new PointerEvent("pointerup", { clientX: cx + dx, clientY: cy, button: 0, pointerType: "mouse", bubbles: true, pointerId: 1 })); + }, { dx: dragPxPer90Deg / (camRotations.length - 1) }); + await page.waitForTimeout(120); + } + + const file = `${outDir}/cam-${String(camAz).padStart(3, "0")}-light-${String(lightAz).padStart(3, "0")}.png`; + await page.screenshot({ path: file, fullPage: false }); + console.log(file); + } + // Reset camera back to azim 0 for next light run by reversing drags. + await page.evaluate(({ dx }) => { + 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 - dx, clientY: cy, button: 0, pointerType: "mouse", bubbles: true, pointerId: 1 })); + host.dispatchEvent(new PointerEvent("pointerup", { clientX: cx - dx, clientY: cy, button: 0, pointerType: "mouse", bubbles: true, pointerId: 1 })); + }, { dx: dragPxPer90Deg }); + await page.waitForTimeout(120); + } +} finally { + await browser.close(); +} diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 14288936..20068843 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -143,6 +143,19 @@ export type { export { axesHelperPolygons, boxPolygons, arrowPolygons, ringPolygons, ringQuadPolygons, planePolygons, octahedronPolygons, spherePolygons, tetrahedronPolygons, icosahedronPolygons, dodecahedronPolygons, cylinderPolygons, conePolygons, torusPolygons } from "./helpers"; export type { AxesHelperOptions, BoxFace, BoxFaceOptions, BoxPolygonsOptions, ArrowPolygonsOptions, RingPolygonsOptions, RingQuadPolygonsOptions, PlanePolygonsOptions, OctahedronPolygonsOptions, SpherePolygonsOptions, TetrahedronPolygonsOptions, IcosahedronPolygonsOptions, DodecahedronPolygonsOptions, CylinderPolygonsOptions, ConePolygonsOptions, TorusPolygonsOptions } from "./helpers"; +// ── Shadow ──────────────────────────────────────────────────────── +export { + BAKED_SHADOW_MIN_UP, + BAKED_SHADOW_Z_SQUASH, + buildBakedShadowProjectionMatrix, + convexHull2D, + ensureCcw2D, + isBakedShadowCaster, + polygonSignedArea2D, + projectCssVertexToGround, +} from "./shadow/projection"; +export { clipPolygonToConvex2D } from "./shadow/clipping"; + // ── Animation ───────────────────────────────────────────────────── export { createPolyAnimationMixer, diff --git a/packages/core/src/shadow/clipping.ts b/packages/core/src/shadow/clipping.ts new file mode 100644 index 00000000..111c2641 --- /dev/null +++ b/packages/core/src/shadow/clipping.ts @@ -0,0 +1,62 @@ +// Sutherland-Hodgman polygon clipping (2D). +// +// Used by the experimental per-receiver-face shadow projection: each +// casting polygon's projection onto a receiver face's plane gets clipped +// to that face's outline so the shadow doesn't bleed beyond the receiver. +// Standard textbook algorithm; assumes the clip polygon (receiver face +// outline) is CONVEX and the subject polygon (projected shadow) is +// arbitrary. Non-convex receiver faces would need a Greiner-Hormann +// implementation instead. + +type Pt = readonly [number, number]; + +/** + * Clips `subject` against the convex polygon `clip`. Both polygons are + * 2D, given in CCW vertex order. Returns the clipped polygon as a new + * array of points; an empty array means `subject` lies entirely outside. + */ +export function clipPolygonToConvex2D( + subject: ReadonlyArray, + clip: ReadonlyArray, +): Array<[number, number]> { + if (subject.length === 0 || clip.length < 3) return []; + let output: Array<[number, number]> = subject.map((v) => [v[0], v[1]]); + const n = clip.length; + + for (let i = 0; i < n; i++) { + if (output.length === 0) return []; + const input = output; + output = []; + const a = clip[i]!; + const b = clip[(i + 1) % n]!; + // Edge AB normal points "left" for a CCW clip polygon. A point is + // inside the clip's half-plane if the cross product is ≥ 0. + const inside = (p: Pt): boolean => { + return (b[0] - a[0]) * (p[1] - a[1]) - (b[1] - a[1]) * (p[0] - a[0]) >= 0; + }; + const intersect = (p: Pt, q: Pt): [number, number] => { + const x1 = a[0], y1 = a[1], x2 = b[0], y2 = b[1]; + const x3 = p[0], y3 = p[1], x4 = q[0], y4 = q[1]; + const denom = (x1 - x2) * (y3 - y4) - (y1 - y2) * (x3 - x4); + // Parallel segments: fall back to the subject vertex so we don't + // introduce a degenerate (collinear) point. + if (Math.abs(denom) < 1e-12) return [p[0], p[1]]; + const t = ((x1 - x3) * (y3 - y4) - (y1 - y3) * (x3 - x4)) / denom; + return [x1 + t * (x2 - x1), y1 + t * (y2 - y1)]; + }; + const inLen = input.length; + for (let j = 0; j < inLen; j++) { + const current = input[j]!; + const prev = input[(j + inLen - 1) % inLen]!; + const currIn = inside(current); + const prevIn = inside(prev); + if (currIn) { + if (!prevIn) output.push(intersect(prev, current)); + output.push([current[0], current[1]]); + } else if (prevIn) { + output.push(intersect(prev, current)); + } + } + } + return output; +} diff --git a/packages/core/src/shadow/projection.test.ts b/packages/core/src/shadow/projection.test.ts new file mode 100644 index 00000000..5fc10fe2 --- /dev/null +++ b/packages/core/src/shadow/projection.test.ts @@ -0,0 +1,157 @@ +import { describe, it, expect } from "vitest"; +import { + BAKED_SHADOW_MIN_UP, + BAKED_SHADOW_Z_SQUASH, + buildBakedShadowProjectionMatrix, + ensureCcw2D, + isBakedShadowCaster, + polygonSignedArea2D, + projectCssVertexToGround, +} from "./projection"; + +describe("buildBakedShadowProjectionMatrix", () => { + it("produces the identity transform for axis-aligned top-down light at ground=0", () => { + const m = buildBakedShadowProjectionMatrix([0, 0, -1], 0); + // col1 = [1,0,0,0], col2 = [0,1,0,0] + expect(m.slice(0, 4)).toEqual([1, 0, 0, 0]); + expect(m.slice(4, 8)).toEqual([0, 1, 0, 0]); + // -lx/lz = 0/-1 = 0, -ly/lz = 0/-1 = 0 + expect(m[8]).toBeCloseTo(0, 6); + expect(m[9]).toBeCloseTo(0, 6); + expect(m[10]).toBeCloseTo(BAKED_SHADOW_Z_SQUASH, 6); + expect(m[11]).toBe(0); + // Ground = 0: col4 = [0, 0, 0, 1] + expect(m[12]).toBeCloseTo(0, 6); + expect(m[13]).toBeCloseTo(0, 6); + expect(m[14]).toBeCloseTo(0, 6); + expect(m[15]).toBe(1); + }); + + it("offsets translation by groundCssZ", () => { + const m = buildBakedShadowProjectionMatrix([0, 0, -1], 100); + // With lx=ly=0, the only G-dependent entry left is m[14] = G*(1-Z) + expect(m[14]).toBeCloseTo(100 * (1 - BAKED_SHADOW_Z_SQUASH), 6); + }); + + it("encodes the shear from an oblique light direction", () => { + const m = buildBakedShadowProjectionMatrix([1, 0, -1], 0); + // After normalize: lx ≈ 0.7071, ly = 0, lz ≈ -0.7071 + // -lx/lz = -(0.7071)/(-0.7071) = +1 + expect(m[8]).toBeCloseTo(1, 5); + expect(m[9]).toBeCloseTo(0, 5); + }); + + it("clamps near-horizontal light up-axis to BAKED_SHADOW_MIN_UP", () => { + // lz = -0.0001 should be clamped to -BAKED_SHADOW_MIN_UP + const m = buildBakedShadowProjectionMatrix([0, 0, -0.0001], 0); + // The unnormalized direction [0,0,-0.0001] normalizes to [0,0,-1] (len 0.0001 > 0) + // So lz_normalized = -1, no clamp needed in this case. + // Use a tilted near-horizontal vector instead to actually exercise the clamp: + const m2 = buildBakedShadowProjectionMatrix([1, 0, -0.001], 0); + // After normalize lz ≈ -0.001 / 1.0000005 ≈ -0.001 → clamped to -0.01 + // -lx/lz with lx≈1, lz=-0.01 → +100 + expect(Math.abs(m2[8])).toBeLessThanOrEqual(100 + 1e-3); + expect(Math.abs(m2[8])).toBeGreaterThan(10); + // Use the public min directly to make the expectation obvious. + expect(BAKED_SHADOW_MIN_UP).toBe(0.01); + // Also exercise the variable so the unused-import linter is happy in + // a separate assertion path. + expect(m[10]).toBeCloseTo(BAKED_SHADOW_Z_SQUASH, 6); + }); +}); + +describe("isBakedShadowCaster", () => { + it("returns true for polygons whose normal points along the light direction (far side)", () => { + // Top-down light, bottom face of a cube (normal pointing down) → silhouette + expect(isBakedShadowCaster([0, 0, -1], [0, 0, -1])).toBe(true); + }); + + it("returns false for polygons whose normal opposes the light direction (lit side)", () => { + // Top-down light, top face of a cube (normal pointing up) → lit, not a caster + expect(isBakedShadowCaster([0, 0, 1], [0, 0, -1])).toBe(false); + }); + + it("returns false for polygons whose normal is perpendicular to the light", () => { + // Vertical wall under a top-down light: doesn't add to the silhouette + expect(isBakedShadowCaster([1, 0, 0], [0, 0, -1])).toBe(false); + }); + + it("handles oblique lights", () => { + // Light going down-and-right; a face whose normal also points down-and-right is a caster + expect(isBakedShadowCaster([1, 0, -1], [1, 0, -1])).toBe(true); + // Opposite normal → not a caster + expect(isBakedShadowCaster([-1, 0, 1], [1, 0, -1])).toBe(false); + }); + + it("is robust to an un-normalized light direction", () => { + expect(isBakedShadowCaster([0, 0, -1], [0, 0, -10])).toBe(true); + }); +}); + +describe("projectCssVertexToGround", () => { + it("returns the vertex's own XY when it sits on the ground plane", () => { + const [x, y] = projectCssVertexToGround([10, 20, 50], [0, 0, 1], 50); + expect(x).toBeCloseTo(10, 6); + expect(y).toBeCloseTo(20, 6); + }); + + it("returns the vertex's own XY for a straight top-down light regardless of height", () => { + // Light TO-source = [0, 0, 1] (sun directly overhead) → no shear; the + // shadow of any point lands directly below it on the ground. + const [x, y] = projectCssVertexToGround([10, 20, 150], [0, 0, 1], 0); + expect(x).toBeCloseTo(10, 6); + expect(y).toBeCloseTo(20, 6); + }); + + it("shears the XY proportionally to height above ground for oblique lights", () => { + // Light TO-source = [1, 0, 1] (up-right). For a point at height H above + // the ground, the shadow lands at x - H (because lx/lz = 1) and y unchanged. + const [x, y] = projectCssVertexToGround([100, 50, 25], [1, 0, 1], 0); + expect(x).toBeCloseTo(75, 6); // 100 - 25*(1/1) + expect(y).toBeCloseTo(50, 6); + }); + + it("clamps near-horizontal light's up-axis to BAKED_SHADOW_MIN_UP", () => { + // Very near-horizontal light: lz ≈ 0.001 → clamped to 0.01. For a point + // 25 above ground, x-shear is 25 * (1 / 0.01) = 2500. With clamp this is + // bounded; without it the projection would shoot to infinity. + const [x] = projectCssVertexToGround([100, 0, 25], [1, 0, 0.001], 0); + expect(Math.abs(x - 100)).toBeLessThanOrEqual(25 / BAKED_SHADOW_MIN_UP + 1); + expect(Math.abs(x - 100)).toBeGreaterThan(100); + }); +}); + +describe("polygonSignedArea2D", () => { + it("returns +1 for a unit square in CCW order", () => { + expect(polygonSignedArea2D([[0, 0], [1, 0], [1, 1], [0, 1]])).toBeCloseTo(1, 9); + }); + + it("returns -1 for a unit square in CW order", () => { + expect(polygonSignedArea2D([[0, 0], [0, 1], [1, 1], [1, 0]])).toBeCloseTo(-1, 9); + }); + + it("returns 0.5 for the standard CCW right triangle", () => { + expect(polygonSignedArea2D([[0, 0], [1, 0], [0, 1]])).toBeCloseTo(0.5, 9); + }); +}); + +describe("ensureCcw2D", () => { + it("leaves CCW input unchanged", () => { + const input: Array<[number, number]> = [[0, 0], [1, 0], [1, 1], [0, 1]]; + expect(ensureCcw2D(input)).toEqual(input); + }); + + it("reverses CW input to CCW", () => { + const input: Array<[number, number]> = [[0, 0], [0, 1], [1, 1], [1, 0]]; + const out = ensureCcw2D(input); + expect(polygonSignedArea2D(out)).toBeGreaterThan(0); + expect(out).toEqual([[1, 0], [1, 1], [0, 1], [0, 0]]); + }); + + it("does not mutate input", () => { + const input: Array<[number, number]> = [[0, 0], [0, 1], [1, 1], [1, 0]]; + const snap = JSON.stringify(input); + ensureCcw2D(input); + expect(JSON.stringify(input)).toBe(snap); + }); +}); diff --git a/packages/core/src/shadow/projection.ts b/packages/core/src/shadow/projection.ts new file mode 100644 index 00000000..12296028 --- /dev/null +++ b/packages/core/src/shadow/projection.ts @@ -0,0 +1,181 @@ +// Pure-math helpers for baked-mode shadow projection. +// +// Dynamic mode keeps the shadow projection in CSS (--shadow-proj on the +// scene root, driven by --clx/--cly/--clz + --shadow-ground-cssz) so the +// browser recomputes it whenever the light moves. Baked mode skips that +// machinery entirely: the light is fixed, so the projection matrix can +// be CPU-computed once at scene build time and reused inline on every +// shadow leaf. No CSS vars, no @property dependency, no per-paint calc +// chain — and back-facing polygons are dropped from the DOM instead of +// being emitted with opacity 0. +import type { Vec3 } from "../types"; + +/** Tiny non-zero scale collapsed into the projection's Z column to keep + * the matrix invertible. Chromium skips elements whose composed + * transform is singular (m22 = 0 would make this a true projection + * matrix, but Chromium would refuse to paint it), so we crush Z to 1% + * of its input instead of exactly zero. The result still looks flat + * to the eye — sub-pixel drift on any realistic scene size. */ +export const BAKED_SHADOW_Z_SQUASH = 0.01; + +/** Minimum absolute value of the up-axis light component before the + * projection blows up (we divide by it). Matches the --clz clamp in + * the dynamic-mode applyDynamicLightVars helper so baked + dynamic + * behave identically when the light is near-horizontal. */ +export const BAKED_SHADOW_MIN_UP = 0.01; + +/** + * Build the CSS-space shadow projection matrix for a fixed light + ground + * plane. The 16-element output mirrors the matrix3d expression in the + * dynamic-mode `--shadow-proj` CSS custom property, but with literal + * numbers — ready to be formatted into a single `matrix3d(...)` per + * shadow leaf. + * + * `lightDir` is the direction the light TRAVELS (e.g. `[0, 0, -1]` is + * straight down). Polycss world Z is up, and the world→CSS axis swap + * leaves Z alone — see styles.ts for the full convention. + * + * `groundCssZ` is the receiver plane in CSS-Z (= world-Z) coordinates, + * already in unit-less form (matrix3d entries must be dimensionless). + */ +export function buildBakedShadowProjectionMatrix( + lightDir: Vec3, + groundCssZ: number, +): number[] { + const len = Math.hypot(lightDir[0], lightDir[1], lightDir[2]) || 1; + const lx = lightDir[0] / len; + const ly = lightDir[1] / len; + const lzRaw = lightDir[2] / len; + const lz = + Math.sign(lzRaw || 1) * Math.max(Math.abs(lzRaw), BAKED_SHADOW_MIN_UP); + const G = groundCssZ; + const Z = BAKED_SHADOW_Z_SQUASH; + // Column-major 4×4, identical layout to CSS matrix3d. + return [ + 1, 0, 0, 0, + 0, 1, 0, 0, + -lx / lz, -ly / lz, Z, 0, + (G * lx) / lz, (G * ly) / lz, G * (1 - Z), 1, + ]; +} + +/** + * Decides whether a polygon should cast a shadow given its outward + * normal and the light's travel direction. + * + * True for polygons whose normals point in the same direction as the + * light travels — i.e., on the far/dark side of the mesh from the + * light's POV. Those define the silhouette of the cast shadow. + * + * False for front-facing polygons whose projection would land inside + * the silhouette and only add overdraw. Dynamic mode hides these with + * a Lambert opacity gate; baked mode skips the DOM emission entirely. + */ +export function isBakedShadowCaster( + normal: Vec3, + lightDir: Vec3, +): boolean { + const len = Math.hypot(lightDir[0], lightDir[1], lightDir[2]) || 1; + const lx = lightDir[0] / len; + const ly = lightDir[1] / len; + const lz = lightDir[2] / len; + return normal[0] * lx + normal[1] * ly + normal[2] * lz > 0; +} + +/** + * 2D convex hull (Andrew's monotone chain, O(n log n)). Returns the + * hull vertices in CCW order. Used to compute a receiver mesh's XY + * footprint when subtracting it from the global ground shadow. + */ +export function convexHull2D( + points: ReadonlyArray, +): Array<[number, number]> { + const n = points.length; + if (n <= 1) return points.map((p) => [p[0], p[1]]); + const sorted = points.map((p) => [p[0], p[1]] as [number, number]); + sorted.sort((a, b) => a[0] - b[0] || a[1] - b[1]); + const cross = ( + o: [number, number], + a: [number, number], + b: [number, number], + ): number => (a[0] - o[0]) * (b[1] - o[1]) - (a[1] - o[1]) * (b[0] - o[0]); + const lower: Array<[number, number]> = []; + for (const p of sorted) { + while (lower.length >= 2 && + cross(lower[lower.length - 2]!, lower[lower.length - 1]!, p) <= 0) lower.pop(); + lower.push(p); + } + const upper: Array<[number, number]> = []; + for (let i = sorted.length - 1; i >= 0; i--) { + const p = sorted[i]!; + while (upper.length >= 2 && + cross(upper[upper.length - 2]!, upper[upper.length - 1]!, p) <= 0) upper.pop(); + upper.push(p); + } + lower.pop(); + upper.pop(); + return lower.concat(upper); +} + +/** + * Signed area of a 2D polygon (positive for CCW vertex order, negative + * for CW). Used by `ensureCcw2D` to normalize winding before concatenating + * polygons into a compound SVG path under `fill-rule="nonzero"`: mixed + * CCW/CW subpaths would cancel each other's winding in the overlap + * region and paint an unintended hole. + */ +export function polygonSignedArea2D( + vertices: ReadonlyArray, +): number { + let a = 0; + const n = vertices.length; + for (let i = 0; i < n; i++) { + const p = vertices[i]!; + const q = vertices[(i + 1) % n]!; + a += p[0] * q[1] - q[0] * p[1]; + } + return a / 2; +} + +/** + * Returns the polygon's vertices in CCW order, reversing if necessary. + * Operates on a copy — input is left unmodified. + */ +export function ensureCcw2D( + vertices: ReadonlyArray, +): Array<[number, number]> { + const copy = vertices.map((v) => [v[0], v[1]] as [number, number]); + if (polygonSignedArea2D(copy) < 0) copy.reverse(); + return copy; +} + +/** + * Projects a single CSS-3D vertex onto the shadow ground plane, returning + * the resulting 2D point in CSS coordinates. Mirrors the per-element + * matrix3d that the dynamic-mode `--shadow-proj` builds, but evaluated on + * the CPU for a fixed light + ground — handy when many projected vertices + * are needed at once (e.g. rendering shadow outlines into a single SVG + * per mesh instead of one DOM leaf per casting polygon). + * + * `cssVertex` is a 3D point that has already been through the world→CSS + * axis swap and unit scale (so its components are dimensionless CSS-space + * coordinates). `lightDir` follows the same `--clx/--cly/--clz` convention + * as `buildBakedShadowProjectionMatrix`. + */ +export function projectCssVertexToGround( + cssVertex: Vec3, + lightDir: Vec3, + groundCssZ: number, +): [number, number] { + const len = Math.hypot(lightDir[0], lightDir[1], lightDir[2]) || 1; + const lx = lightDir[0] / len; + const ly = lightDir[1] / len; + const lzRaw = lightDir[2] / len; + const lz = + Math.sign(lzRaw || 1) * Math.max(Math.abs(lzRaw), BAKED_SHADOW_MIN_UP); + const zDelta = cssVertex[2] - groundCssZ; + return [ + cssVertex[0] - (lx / lz) * zDelta, + cssVertex[1] - (ly / lz) * zDelta, + ]; +} diff --git a/packages/polycss/src/api/createPolyScene.test.ts b/packages/polycss/src/api/createPolyScene.test.ts index ab9b7e4c..90acb12a 100644 --- a/packages/polycss/src/api/createPolyScene.test.ts +++ b/packages/polycss/src/api/createPolyScene.test.ts @@ -1944,23 +1944,58 @@ describe("createPolyScene", () => { expect(host.querySelectorAll(".polycss-shadow").length).toBe(0); }); - it("castShadow:true in dynamic mode emits shadow leaves, one per non-textured polygon", () => { - scene = makeScene(host, dynOpts); - // Use spatially distinct triangles so the loose shadow-dedup pass - // doesn't fold them into one shadow (two triangles at the same - // location WOULD be deduped, which is the intended behavior). + it("castShadow:true in dynamic mode emits a single shadow per mesh (same path as baked)", () => { + // Dynamic mode now uses the same per-mesh compound SVG path as baked + // mode — one per casting mesh regardless of polygon count. const distinctTri: Polygon = { vertices: [[10, 10, 5], [11, 10, 5], [10, 11, 5]], color: "#00ff00", }; + scene = makeScene(host, dynOpts); scene.add(makeParseResult([triangle(), distinctTri]), { castShadow: true, merge: false }); - expect(host.querySelectorAll(".polycss-shadow").length).toBe(2); + const shadows = host.querySelectorAll(".polycss-shadow"); + expect(shadows.length).toBe(1); + expect(shadows[0]!.tagName.toLowerCase()).toBe("svg"); }); - it("castShadow:true in baked mode emits NO shadow leaves", () => { + it("castShadow:true in baked mode emits a single shadow per mesh with one compound ", () => { + // Baked mode concatenates every casting polygon's projected outline + // into ONE compound `d` (M…L…Z subpaths) rendered under + // fill-rule=nonzero, so overlapping CCW outlines composite as one + // filled silhouette without alpha stacking while gaps remain holes. + // One per mesh regardless of polygon count. scene = makeScene(host, { textureLighting: "baked" }); scene.add(makeParseResult([triangle()]), { castShadow: true }); - expect(host.querySelectorAll(".polycss-shadow").length).toBe(0); + const shadows = host.querySelectorAll(".polycss-shadow"); + expect(shadows.length).toBe(1); + const shadow = shadows[0] as SVGSVGElement; + expect(shadow.tagName.toLowerCase()).toBe("svg"); + expect(shadow.classList.contains("polycss-shadow-svg")).toBe(true); + expect(shadow.style.transform).toMatch(/^translate3d\(/); + expect(shadow.style.transform).not.toContain("var(--shadow-proj)"); + const paths = shadow.querySelectorAll("path"); + expect(paths.length).toBe(1); + const path = paths[0]!; + expect(path.getAttribute("opacity")).toBe("0.2500"); + expect(path.getAttribute("fill-rule")).toBe("nonzero"); + const d = path.getAttribute("d") || ""; + // Triangle (3 verts) → one M, two Ls, one Z. + expect((d.match(/M/g) || []).length).toBe(1); + expect((d.match(/L/g) || []).length).toBe(2); + expect((d.match(/Z/g) || []).length).toBe(1); + }); + + it("baked mode projects every polygon (no Lambert cull) so thin/open meshes don't get silhouette holes", () => { + // backTriangle has its surface normal pointing AWAY from the + // default light. We deliberately do NOT cull these by Lambert + // facing in the SVG path — a thin mesh (cloth, bat wings) needs + // both sides projected, or its silhouette gets visible holes + // where the back-facing piece would have contributed. With SVG + // fill-rule=nonzero merging overlap into one solid silhouette, + // including the back-facing polys is geometrically correct. + scene = makeScene(host, { textureLighting: "baked" }); + scene.add(makeParseResult([backTriangle()]), { castShadow: true }); + expect(host.querySelectorAll(".polycss-shadow").length).toBe(1); }); it("shadow leaves have the polycss-shadow class", () => { @@ -1973,17 +2008,13 @@ describe("createPolyScene", () => { } }); - it("shadow leaves are always with border-shape regardless of caster tag", () => { - scene = makeScene(host, dynOpts); - // Mix shapes at distinct 3D positions (otherwise the loose-tolerance - // shadow dedup pass folds them into one shadow). Each emits a - // shadow — a dedicated single-letter render strategy in the tag - // taxonomy alongside ///, kept clear of the dynamic- - // mode Lambert color rule. + it("shadow elements are always with class polycss-shadow regardless of caster tag or mode", () => { + // Both lighting modes use the same per-mesh shadow now. const distinctTri: Polygon = { vertices: [[10, 10, 5], [11, 10, 5], [10, 11, 5]], color: "#00ff00", }; + scene = makeScene(host, dynOpts); scene.add(makeParseResult([triangle(), distinctTri]), { castShadow: true, merge: false, @@ -1991,34 +2022,20 @@ describe("createPolyScene", () => { const shadows = Array.from(host.querySelectorAll(".polycss-shadow")); expect(shadows.length).toBeGreaterThan(0); for (const el of shadows) { - expect((el as HTMLElement).tagName.toLowerCase()).toBe("q"); - expect((el as HTMLElement).style.getPropertyValue("border-shape")).not.toBe(""); + expect(el.tagName.toLowerCase()).toBe("svg"); + expect(el.classList.contains("polycss-shadow-svg")).toBe(true); } }); - it("shadow leaves transform contains var(--shadow-proj) followed by matrix3d", () => { - scene = makeScene(host, dynOpts); - scene.add(makeParseResult([triangle()]), { castShadow: true }); - const shadow = host.querySelector(".polycss-shadow") as HTMLElement; - expect(shadow).not.toBeNull(); - expect(shadow.style.transform).toMatch(/^var\(--shadow-proj\)\s+matrix3d\(/); - }); - - it("adding a casting mesh sets --shadow-ground-cssz on the scene element", () => { + it("adding a casting mesh in dynamic mode does NOT need --shadow-ground-cssz on the scene", () => { + // Dynamic mode no longer uses --shadow-ground-cssz / --shadow-proj — + // the projection is CPU-baked into the per-mesh SVG path same as in + // baked mode. scene = makeScene(host, dynOpts); scene.add(makeParseResult([triangle()]), { castShadow: true }); const sceneEl = getSceneEl(host); - const groundVar = sceneEl.style.getPropertyValue("--shadow-ground-cssz"); - expect(groundVar).not.toBe(""); - }); - - it("removing the casting mesh clears --shadow-ground-cssz", () => { - scene = makeScene(host, dynOpts); - const handle = scene.add(makeParseResult([triangle()]), { castShadow: true }); - const sceneEl = getSceneEl(host); - expect(sceneEl.style.getPropertyValue("--shadow-ground-cssz")).not.toBe(""); - handle.remove(); expect(sceneEl.style.getPropertyValue("--shadow-ground-cssz")).toBe(""); + expect(host.querySelectorAll(".polycss-shadow").length).toBe(1); }); it("toggling castShadow via setTransform adds/removes shadow leaves", () => { @@ -2031,20 +2048,29 @@ describe("createPolyScene", () => { expect(host.querySelectorAll(".polycss-shadow").length).toBe(0); }); - it("switching from dynamic to baked removes shadow leaves", () => { + it("switching from dynamic to baked keeps the shadow as a translated ", () => { scene = makeScene(host, dynOpts); scene.add(makeParseResult([triangle()]), { castShadow: true }); - expect(host.querySelectorAll(".polycss-shadow").length).toBeGreaterThan(0); + const before = host.querySelector(".polycss-shadow") as SVGSVGElement; + expect(before.tagName.toLowerCase()).toBe("svg"); + expect(before.style.transform).toMatch(/^translate3d\(/); scene.setOptions({ textureLighting: "baked" }); - expect(host.querySelectorAll(".polycss-shadow").length).toBe(0); + const after = host.querySelector(".polycss-shadow") as SVGSVGElement; + expect(after).not.toBeNull(); + expect(after.tagName.toLowerCase()).toBe("svg"); + expect(after.style.transform).toMatch(/^translate3d\(/); }); - it("switching from baked back to dynamic re-emits shadow leaves", () => { + it("switching from baked back to dynamic keeps the shadow as a translated ", () => { scene = makeScene(host, { textureLighting: "baked" }); scene.add(makeParseResult([triangle()]), { castShadow: true }); - expect(host.querySelectorAll(".polycss-shadow").length).toBe(0); + const before = host.querySelector(".polycss-shadow") as SVGSVGElement; + expect(before.tagName.toLowerCase()).toBe("svg"); scene.setOptions({ ...dynOpts }); - expect(host.querySelectorAll(".polycss-shadow").length).toBeGreaterThan(0); + const dynamicShadow = host.querySelector(".polycss-shadow") as SVGSVGElement; + expect(dynamicShadow).not.toBeNull(); + expect(dynamicShadow.tagName.toLowerCase()).toBe("svg"); + expect(dynamicShadow.style.transform).toMatch(/^translate3d\(/); }); it("textured polygons (s) ALSO emit shadow leaves", () => { @@ -2074,6 +2100,39 @@ describe("createPolyScene", () => { expect(sceneEl.style.getPropertyValue("--cly")).toBe(""); expect(sceneEl.style.getPropertyValue("--clz")).toBe(""); }); + + it("baked mode re-emits SVG shadows when directionalLight.direction changes", () => { + // Light direction is folded into the CPU projection that builds the + // SVG paths, so changing it must rewrite the SVG outlines (and the + // SVG's translate3d) — otherwise the shadows stay frozen at the + // original light angle. + scene = makeScene(host, { + textureLighting: "baked", + directionalLight: { direction: [0, 0, 1] }, + }); + scene.add(makeParseResult([triangle()]), { castShadow: true }); + const initialSvg = host.querySelector(".polycss-shadow") as SVGSVGElement; + const initialTransform = initialSvg.style.transform; + const initialPathD = initialSvg.querySelector("path")?.getAttribute("d"); + scene.setOptions({ directionalLight: { direction: [1, 0, 1] } }); + const nextSvg = host.querySelector(".polycss-shadow") as SVGSVGElement; + const nextTransform = nextSvg.style.transform; + const nextPathD = nextSvg.querySelector("path")?.getAttribute("d"); + expect(nextTransform).toMatch(/^translate3d\(/); + // EITHER the SVG positioning OR the path geometry must have changed + // — both encode the projection so both should reflect the new light. + expect(nextTransform !== initialTransform || nextPathD !== initialPathD).toBe(true); + }); + + it("baked mode does NOT set --shadow-ground-cssz on the scene element", () => { + // Ground Z lives inside each leaf's baked matrix3d, not on the + // scene root — the CSS var is dynamic-mode-only and would + // accidentally drive --shadow-proj for any stale dynamic leaves. + scene = makeScene(host, { textureLighting: "baked" }); + scene.add(makeParseResult([triangle()]), { castShadow: true }); + const sceneEl = getSceneEl(host); + expect(sceneEl.style.getPropertyValue("--shadow-ground-cssz")).toBe(""); + }); }); }); diff --git a/packages/polycss/src/api/createPolyScene.ts b/packages/polycss/src/api/createPolyScene.ts index 0069cada..8dde9ec7 100644 --- a/packages/polycss/src/api/createPolyScene.ts +++ b/packages/polycss/src/api/createPolyScene.ts @@ -41,7 +41,10 @@ import { VOXEL_CAMERA_CULL_NORMAL_LIMIT, cameraCullNormalKey, cameraCullVisibleSignature, + clipPolygonToConvex2D, computeSceneBbox, + convexHull2D, + ensureCcw2D, findOverlappingPolygonDuplicates, inverseRotateVec3, isAxisAlignedSurfaceNormal, @@ -50,6 +53,7 @@ import { optimizeMeshPolygons, parseHexColor, polygonCssSurfaceNormal, + projectCssVertexToGround, } from "@layoutit/polycss-core"; import { cssBorderShapeForPlan, @@ -116,9 +120,11 @@ export interface PolySceneOptions { */ autoCenter?: boolean; /** - * Shadow appearance for meshes with `castShadow: true`. Only applies in - * dynamic lighting mode — baked mode does not emit shadow leaves. - * Defaults: `{ color: "#000000", opacity: 0.25, lift: 0.05 }`. + * Shadow appearance for meshes with `castShadow: true`. Works in both + * lighting modes — dynamic mode projects via CSS vars so shadows + * follow a moving light, baked mode CPU-bakes the projection into + * each leaf's inline `matrix3d` and drops back-facing polys from the + * DOM entirely. Defaults: `{ color: "#000000", opacity: 0.25, lift: 0.05, maxExtend: 2000 }`. */ shadow?: { /** Shadow color as a CSS hex string. Default: `"#000000"`. */ @@ -132,6 +138,18 @@ export interface PolySceneOptions { * shadow. In world units. Default: `0.05`. */ lift?: number; + /** + * Maximum CSS pixels the shadow may extend beyond the mesh's + * footprint (the no-shear silhouette directly under the mesh). The + * footprint area is always preserved; only the sheared tail at low + * light elevations is truncated. Default: `2000`. + * + * **Trade-off:** larger values give longer shadows but the SVG + * backing store grows quadratically with this value, which can + * cause repaint flicker at extreme low-elevation angles. Pass a + * very large number (e.g. `Infinity`) to disable the cap entirely. + */ + maxExtend?: number; }; } @@ -167,14 +185,22 @@ export interface PolyMeshTransform { */ excludeFromAutoCenter?: boolean; /** - * When `true` and the scene is in dynamic lighting mode, the renderer emits - * a flat shadow leaf sibling for each non-textured polygon. The shadow is - * projected onto the ground plane (min world-Y of all casting meshes) along - * the CSS-space light direction (driven by `--clx/--cly/--clz` vars). Zero - * JS in the render loop — the projection matrix is a CSS var that recomputes - * via `calc()` when the light vars change. Defaults to `false`. + * When `true`, this mesh casts a shadow onto the scene's shadow ground + * plane (and onto any meshes marked `receiveShadow: true`). The shadow + * emits as one per-mesh `` whose path is the union of every + * casting polygon's projection. Works in both lighting modes. + * Defaults to `false`. */ castShadow?: boolean; + /** + * **(experimental)** When `true`, this mesh acts as a shadow receiver: + * each of its polygon faces becomes a target plane that casting meshes' + * shadows project onto and get clipped to. Useful for "shadow on table" + * scenarios. Currently only convex face outlines clip cleanly. When no + * receivers are present the global ground plane is used as today. + * Defaults to `false`. + */ + receiveShadow?: boolean; } export interface PolyMeshHandle { @@ -353,6 +379,23 @@ function strategiesEqual( return true; } +function vec3Equal(a: Vec3 | undefined, b: Vec3 | undefined): boolean { + if (a === b) return true; + if (!a || !b) return false; + return a[0] === b[0] && a[1] === b[1] && a[2] === b[2]; +} + +function shadowOptsEqual( + a: PolySceneOptions["shadow"] | undefined, + b: PolySceneOptions["shadow"] | undefined, +): boolean { + if (a === b) return true; + return (a?.color ?? "#000000") === (b?.color ?? "#000000") + && (a?.opacity ?? 0.25) === (b?.opacity ?? 0.25) + && (a?.lift ?? 0.05) === (b?.lift ?? 0.05) + && (a?.maxExtend ?? 2000) === (b?.maxExtend ?? 2000); +} + function buildMeshTransform(t: PolyMeshTransform): string | undefined { const parts: string[] = []; if (t.position) { @@ -570,9 +613,8 @@ export function createPolyScene( wrapper: HTMLDivElement; parseResult: ParseResult; rendered: RenderedPoly[]; - /** Shadow leaf elements, one per non-textured non-atlas polygon. Kept - * separate from `rendered` so they can be removed independently when - * castShadow is toggled or lighting mode changes. */ + /** Dynamic-mode shadow `` leaves, one per non-deduped casting + * polygon. Empty in baked mode (which uses `shadowSvg` instead). */ shadowRendered: HTMLElement[]; voxelRenderer?: PolyVoxelRenderer; disposeAtlas?: () => void; @@ -583,6 +625,7 @@ export function createPolyScene( hasBuckets: boolean; excludeFromAutoCenter: boolean; castShadow: boolean; + receiveShadow: boolean; cameraCullGroups: CameraCullNormalGroup[]; cameraCullSignature: string; lightOverrideSignature: string; @@ -593,6 +636,115 @@ export function createPolyScene( } const meshes = new Set(); + // Cached CSS-Z of the shadow ground plane. Set by `recomputeShadowGround`. + // In dynamic mode this also flows into the `--shadow-ground-cssz` CSS var + // that drives `--shadow-proj`. In baked mode it's read by `emitShadowLeaves` + // to bake the per-leaf inline projection matrix on the CPU. `null` means + // no casting mesh exists yet, so no shadow leaves should be emitted. + let currentGroundCssZ: number | null = null; + + // Scene-level shadow SVGs. One per surface (ground + each receiver + // face). Every caster's projection onto a given surface ends up in + // that surface's single SVG path, so overlapping shadows from + // different casters composite via SVG fill-rule=nonzero (one solid + // silhouette per surface) rather than stacking opacity at the DOM + // level. Rebuilt as a whole on any caster/receiver/light change. + const sceneShadowSvgs: SVGSVGElement[] = []; + function clearAllSceneShadows(): void { + for (const svg of sceneShadowSvgs) { + if (svg.parentNode) svg.parentNode.removeChild(svg); + } + sceneShadowSvgs.length = 0; + // Mark all cached receiver-face SVGs as hidden. Per-frame + // emitSceneReceiverShadows will reveal the ones with shadow + // content and leave the rest in `display:none`, which keeps the + // compositor layer count low without tearing the elements down. + hideAllReceiverFaceSvgs(); + } + + // Per-receiver cached face geometry. Each entry holds one record + // per coplanar face group on the receiver: plane (O, n, u, v), + // outline polygon (used as Sutherland-Hodgman clip), bbox in (u, v) + // for SVG sizing, and the pre-stringified matrix3d transform that + // places an SVG on that face plane. + // + // All of this is invariant under light/caster changes. Per light + // tick we just re-run the per-tri SH and build the path `d` — + // never recompute groups or basis. Cache invalidated when the + // receiver's polygon count or position changes. + interface ReceiverFacePlane { + O: Vec3; + n: Vec3; + u: Vec3; + v: Vec3; + outlineUv: Array<[number, number]>; + minU: number; + minV: number; + width: number; + height: number; + matrixCss: string; + // Mount-once SVG + path: created on first non-empty frame for + // this face, then kept in the DOM. Per-frame we just mutate + // `d`/`fill`/`opacity` and toggle `display`. Avoids per-frame + // ~248 createElementNS + insertBefore + 248 layer churn that + // dominated gpuViz (~40 ms/frame). + svg: SVGSVGElement | null; + path: SVGPathElement | null; + visible: boolean; + } + const receiverShadowCache = new Map(); + const receiverShadowCacheKey = new Map(); + function disposeReceiverPlanes(planes: ReceiverFacePlane[]): void { + for (const p of planes) { + if (p.svg && p.svg.parentNode) p.svg.parentNode.removeChild(p.svg); + p.svg = null; + p.path = null; + } + } + function clearReceiverShadowCache(entry?: MeshEntry): void { + if (entry) { + const planes = receiverShadowCache.get(entry); + if (planes) disposeReceiverPlanes(planes); + receiverShadowCache.delete(entry); + receiverShadowCacheKey.delete(entry); + } else { + for (const planes of receiverShadowCache.values()) disposeReceiverPlanes(planes); + receiverShadowCache.clear(); + receiverShadowCacheKey.clear(); + } + } + function hideAllReceiverFaceSvgs(): void { + for (const planes of receiverShadowCache.values()) { + for (const p of planes) { + if (p.svg && p.visible) { + p.svg.style.display = "none"; + p.visible = false; + } + } + } + } + + // Per-caster cached per-polygon data: world-space vertices + 3D + // AABB corners. Invariant under light direction; depends only on + // the caster mesh's geometry and position. Reused across every + // receiver-face SH-clip in a frame and across frames within a + // drag, so the caching pays for itself many times over. + interface CasterPolyItem { + wv: Vec3[]; + bboxCorners: Vec3[]; + } + const casterItemsCache = new Map(); + const casterItemsCacheKey = new Map(); + function clearCasterItemsCache(entry?: MeshEntry): void { + if (entry) { + casterItemsCache.delete(entry); + casterItemsCacheKey.delete(entry); + } else { + casterItemsCache.clear(); + casterItemsCacheKey.clear(); + } + } + // Apply CSS perspective on the camera wrapper, not the scene element. // CSS `perspective` only foreshortens direct children's 3D transforms, so // the wrapper must be the perspective context for .polycss-scene to work @@ -719,10 +871,17 @@ export function createPolyScene( } function clearShadowLeaves(entry: MeshEntry): void { + // Per-entry `` leaves (dynamic-mode chain + legacy callers) still + // hang off the mesh and must be cleared individually. for (const el of entry.shadowRendered) { if (el.parentNode) el.parentNode.removeChild(el); } entry.shadowRendered.length = 0; + // SVG shadow surfaces are scene-scoped (one per ground / receiver + // face, aggregating every caster). Any per-entry trigger that asks + // to clear leaves drops the whole scene-level set; emitSceneShadows + // will rebuild it next. + clearAllSceneShadows(); } function disposeRendered(rendered: RenderedPoly[], disposeAtlas?: () => void): void { @@ -1106,96 +1265,602 @@ export function createPolyScene( } } - // Emits shadow leaves for all non-textured rendered polys in the entry. - // Each shadow leaf uses the same tag and shape as the original but with a - // flat shadow color and a transform prepended by var(--shadow-proj) so it - // projects onto the ground plane driven entirely by CSS vars. + // Emits the per-mesh shadow ``. Same path for both lighting modes: + // every casting polygon is projected to the ground on the CPU and + // concatenated into a single compound `` (M…L…Z subpaths) under + // fill-rule=nonzero. Overlapping outlines composite as one filled + // silhouette without alpha stacking; gaps between subpaths remain as + // gaps (silhouette holes are preserved); back-facing polys are dropped + // up front. One SVG element per mesh regardless of polygon count. // - // Shadow leaves are inserted BEFORE their caster siblings so they sit - // below in DOM order, which keeps them behind the casters when both are - // coplanar in 3D (painter-order tie-breaking favors earlier nodes). - function emitShadowLeaves(entry: MeshEntry): void { - clearShadowLeaves(entry); - if (!entry.castShadow || currentOptions.textureLighting !== "dynamic") return; + // Trade-off vs. the old dynamic-mode per-`` CSS path: live light + // updates now require a JS re-projection pass (`setOptions` triggers + // re-emit when directionalLight.direction changes) instead of being + // free CSS variable updates. The visual upside (no alpha stacking, + // preserved holes, fewer DOM nodes) is worth the JS cost for typical + // scenes — huge meshes during light-slider drag can profile if needed. + // Per-entry trigger: callers pass the entry that changed, but emission + // is scene-wide. Drop the arg here so any change rebuilds the whole + // shadow set in one shot — every surface aggregates every caster. + function emitShadowLeaves(_entry: MeshEntry): void { + emitSceneShadows(); + } + + // Rebuilds every shadow SVG in the scene from scratch. Iterates each + // SURFACE (the global ground + every receiver face) once, then sweeps + // every caster's projection onto that surface into the same compound + // path. SVG fill-rule=nonzero collapses overlapping CCW outlines into + // one filled silhouette per surface — overlapping shadows from + // different casters don't multiply their opacity at the DOM level. + function emitSceneShadows(): void { + clearAllSceneShadows(); + const casters: MeshEntry[] = []; + for (const m of meshes) if (!m.disposed && m.castShadow) casters.push(m); + if (casters.length === 0) return; const shadowColor = currentOptions.shadow?.color ?? "#000000"; const shadowOpacity = currentOptions.shadow?.opacity ?? 0.25; - // Build a CSS rgba color from the hex + opacity. const parsed = parseHexColor(shadowColor)?.rgb ?? [0, 0, 0]; const r = parsed[0], g = parsed[1], b = parsed[2]; - const shadowColorCss = `rgba(${r},${g},${b},${shadowOpacity})`; - - // Loose-tolerance dedup for shadow casting ONLY — much more permissive - // than the parse-time dedup that affects the rendered model. Multiple - // coincident or near-coincident polygons cast overlapping shadow - // leaves that visibly stack on the receiver; emitting one is enough. - // Tolerances allow ~25° off-parallel normals and ~0.5 world units of - // plane-offset drift, catching back-to-back doubled faces and minor + const lightDir = currentOptions.directionalLight?.direction + ?? ([0.4, -0.7, 0.59] as Vec3); + + // Per-caster shadow dedup (independent meshes can't dedup against + // each other). Computed once per caster, reused across surfaces. + // Loose tolerances catch back-to-back doubled faces and minor // importer artifacts without false-positively dropping legitimate // inner/outer wall pairs that cast genuinely distinct shadows. - // Light-independent — runs once per mesh-polygon change, never per - // camera tick or light slider tick. - const shadowDedupDrop = findOverlappingPolygonDuplicates(entry.polygons, { - normalTolerance: 0.1, - distanceTolerance: 0.5, - overlapFraction: 0.4, - preserveDoubleSidedBackfaces: false, - }); + const dedupByCaster = new Map>(); + for (const c of casters) { + dedupByCaster.set(c, findOverlappingPolygonDuplicates(c.polygons, { + normalTolerance: 0.1, + distanceTolerance: 0.5, + overlapFraction: 0.4, + // Authored double-sided backfaces would project coincident + // shadows that stack their alpha against the front face — drop + // them at the dedup step instead of in the SVG fill rule. + preserveDoubleSidedBackfaces: false, + })); + } - const fragment = doc.createDocumentFragment(); - for (const item of renderedItemsForCamera(entry)) { - // Atlas () polygons cast shadows too — the shadow only needs - // the polygon's OUTLINE (border-shape) and a flat dark color, not - // the texture content. So fully textured meshes like the Frog Guy - // get proper shadows just like solid-color meshes. - // Skip polygons identified as shadow-duplicates of another caster. - if (shadowDedupDrop.has(item.polygonIndex)) continue; - const plan = item.plan; - if (!plan) continue; - - // Read the original matrix3d from the plan (not from the element - // style string) so we never parse strings. - const origMatrix = `matrix3d(${plan.matrix})`; - - // Shadow leaves emit as — a dedicated single-letter element - // that lives alongside /// in the tag-as-strategy - // taxonomy. Using its own tag means we don't have to thread - // `:not(.polycss-shadow)` exclusions through every dynamic-mode - // color rule (regular polygon leaves get relit by Lambert; shadow - // leaves shouldn't). Rendering rides the + border-shape path - // mirrored from 's border-color: currentColor mechanism. - // clip-path is forbidden by repo policy (4000+ clip-paths inside - // preserve-3d = ~15 s/frame on Chromium). - // - // The caster's normal is pinned inline as --pnx/--pny/--pnz so the - // cascade can compute a Lambert factor and gate the shadow's - // opacity: polygons facing AWAY from the light don't cast a - // shadow on the receiver (their projection is inside the - // silhouette of the front-facing parts anyway, just adding - // overdraw). Pure CSS — no JS at light-change time. - const shadowEl = doc.createElement("q"); - shadowEl.className = "polycss-shadow"; - shadowEl.style.transform = `var(--shadow-proj) ${origMatrix}`; - shadowEl.style.color = shadowColorCss; - shadowEl.style.width = `${plan.canvasW}px`; - shadowEl.style.height = `${plan.canvasH}px`; - shadowEl.style.setProperty("border-shape", cssBorderShapeForPlan(plan)); - shadowEl.style.setProperty("--pnx", plan.normal[0].toFixed(4)); - shadowEl.style.setProperty("--pny", plan.normal[1].toFixed(4)); - shadowEl.style.setProperty("--pnz", plan.normal[2].toFixed(4)); - - fragment.appendChild(shadowEl); - entry.shadowRendered.push(shadowEl); + if (currentGroundCssZ !== null) { + emitSceneGroundShadow(casters, dedupByCaster, lightDir, currentGroundCssZ, r, g, b, shadowOpacity); + } + for (const receiver of meshes) { + if (receiver.disposed || !receiver.receiveShadow) continue; + emitSceneReceiverShadows(casters, dedupByCaster, receiver, lightDir, r, g, b, shadowOpacity); + } + } + + // Builds a single per-mesh for the mesh's shadow. Projects every + // casting polygon to the ground on the CPU, concatenates the outlines + // into one compound under fill-rule=nonzero so + // overlapping CCW subpaths composite as one filled silhouette (no alpha + // accumulation at intersections). SVG content is internally 2D so this + // sidesteps the `opacity + transform-style: preserve-3d` flatten trap + // that breaks CSS-only shadow grouping in a 3D scene. + // Scene-level ground surface: one SVG containing every caster's + // projection onto the global ground plane. Overlapping caster shadows + // (e.g. pole shadow + cube shadow) collapse into one filled silhouette + // via fill-rule=nonzero instead of stacking opacity. + function emitSceneGroundShadow( + casters: MeshEntry[], + dedupByCaster: Map>, + lightDir: Vec3, + groundCssZ: number, + r: number, g: number, b: number, + opacity: number, + ): void { + const polyProjections: Array> = []; + let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity; + let fpMinX = Infinity, fpMinY = Infinity, fpMaxX = -Infinity, fpMaxY = -Infinity; + for (const caster of casters) { + const cpos = caster.handle.transform.position ?? [0, 0, 0]; + const dedupDrop = dedupByCaster.get(caster)!; + for (const item of caster.rendered) { + if (dedupDrop.has(item.polygonIndex)) continue; + const plan = item.plan; + if (!plan) continue; + const polygon = caster.polygons[item.polygonIndex]; + if (!polygon) continue; + + const projected: Array<[number, number]> = []; + for (const v of polygon.vertices) { + // World vertex (mesh-local, world units) → CSS via the same + // axis swap (world.x → CSS-Y, world.y → CSS-X) and tile scale + // (× DEFAULT_TILE) that the atlas builder applies per leaf. + // Then add transform.position as raw CSS px — that's how the + // mesh WRAPPER applies it (translate3d(pos[0]px, pos[1]px, + // pos[2]px), no axis swap, no tile multiplier). + const cssVertex: Vec3 = [ + v[1] * DEFAULT_TILE + cpos[0], + v[0] * DEFAULT_TILE + cpos[1], + v[2] * DEFAULT_TILE + cpos[2], + ]; + if (cssVertex[0] < fpMinX) fpMinX = cssVertex[0]; + if (cssVertex[1] < fpMinY) fpMinY = cssVertex[1]; + if (cssVertex[0] > fpMaxX) fpMaxX = cssVertex[0]; + if (cssVertex[1] > fpMaxY) fpMaxY = cssVertex[1]; + const p = projectCssVertexToGround(cssVertex, lightDir, groundCssZ); + projected.push(p); + if (p[0] < minX) minX = p[0]; + if (p[1] < minY) minY = p[1]; + if (p[0] > maxX) maxX = p[0]; + if (p[1] > maxY) maxY = p[1]; + } + // Per-polygon convex hull on the projected 2D points. N-gons + // from glTF imports aren't always perfectly planar in 3D, and + // projecting a non-planar N-gon yields a SELF-INTERSECTING 2D + // polygon. The signed-area-based winding check + // (`ensureCcw2D`) returns the NET signed area, which can + // disagree with the actual visual winding for self-intersecting + // shapes — leading to one rogue subpath rendered with + // opposite winding under fill-rule=nonzero, which then + // SUBTRACTS from neighboring CCW shadows (visible as wedge- + // shaped holes in the final shadow). Hull-per-polygon + // guarantees each subpath is a simple convex polygon → + // winding is always reliable. Triangles are unchanged + // (already simple); only N-gons get hulled. + const simplified = projected.length > 3 ? convexHull2D(projected) : projected; + if (simplified.length >= 3) polyProjections.push(simplified); + } } - // Insert all shadow leaves BEFORE the first normal polygon child so - // they appear below casters in DOM order. appendChild would put them - // after; insertBefore(fragment, firstChild) puts them at the front. - const firstChild = entry.wrapper.firstChild; - if (firstChild) { - entry.wrapper.insertBefore(fragment, firstChild); - } else { - entry.wrapper.appendChild(fragment); + if (polyProjections.length === 0) return; + const maxExtend = currentOptions.shadow?.maxExtend ?? 2000; + const bx0 = Math.max(minX, fpMinX - maxExtend); + const by0 = Math.max(minY, fpMinY - maxExtend); + const bx1 = Math.min(maxX, fpMaxX + maxExtend); + const by1 = Math.min(maxY, fpMaxY + maxExtend); + const width = bx1 - bx0; + const height = by1 - by0; + if (!(width > 0) || !(height > 0)) return; + + let d = ""; + for (const verts of polyProjections) { + const ccw = ensureCcw2D(verts); + d += `M${(ccw[0]![0] - bx0).toFixed(3)},${(ccw[0]![1] - by0).toFixed(3)}`; + for (let i = 1; i < ccw.length; i++) { + d += `L${(ccw[i]![0] - bx0).toFixed(3)},${(ccw[i]![1] - by0).toFixed(3)}`; + } + d += "Z"; + } + // (No receiver-footprint subtraction.) The earlier "cut every + // receiver's hull as a CW hole" approach broke fill-rule=nonzero + // wherever a receiver overlapped the caster's silhouette: a CCW + // caster (+1) plus a CW receiver (-1) cancels at every single- + // coverage edge, leaving only doubled-coverage interior and + // producing visible wedge holes / halos along every shadow edge. + // + // Physically the cut was trying to express "this receiver blocks + // light from reaching the ground under it." But for casters that + // already include the receiver's body in their own silhouette + // (apple on ground), the cut redundantly cancels the very shadow + // we want. For casters above an elevated receiver (pole on cube), + // the right fix is volumetric occlusion, not 2D subtraction. + // Deferred until we hit a scene where shadow-through-elevated- + // receiver is actually distracting. + + const svgNS = "http://www.w3.org/2000/svg"; + const svg = doc.createElementNS(svgNS, "svg"); + svg.setAttribute("class", "polycss-shadow polycss-shadow-svg"); + svg.setAttribute("width", String(width)); + svg.setAttribute("height", String(height)); + svg.setAttribute("viewBox", `0 0 ${width} ${height}`); + svg.setAttribute( + "style", + `position:absolute;top:0;left:0;display:block;overflow:hidden;` + + `transform-origin:0 0;pointer-events:none;will-change:transform;` + + `transform:translate3d(${bx0.toFixed(3)}px,${by0.toFixed(3)}px,${groundCssZ.toFixed(3)}px)`, + ); + + const path = doc.createElementNS(svgNS, "path"); + path.setAttribute("d", d); + const fillColor = `rgb(${r},${g},${b})`; + path.setAttribute("fill", fillColor); + path.setAttribute("fill-rule", "nonzero"); + path.setAttribute("stroke", fillColor); + path.setAttribute("stroke-width", "2"); + path.setAttribute("stroke-linejoin", "round"); + path.setAttribute("opacity", opacity.toFixed(4)); + svg.appendChild(path); + + sceneShadowSvgs.push(svg); + const sceneFirst = sceneEl.firstChild; + if (sceneFirst) sceneEl.insertBefore(svg, sceneFirst); + else sceneEl.appendChild(svg); + } + + type ReceiverPlaneGroup = { + O: Vec3; // CSS-3D origin (representative face vertex 0) + n: Vec3; // unit normal + u: Vec3; // in-plane u basis + v: Vec3; // in-plane v basis (= n × u) + outlineUv: Array<[number, number]>; // CCW convex hull of group's (u,v) coords + }; + + // Groups a receiver's polygons by shared plane (matching normal + + // plane offset within tolerance), then computes a 2D convex-hull + // outline per group in the group's own (u, v) coords. Each returned + // group becomes one shadow-receiving surface. + // + // Why convex hull instead of a proper polygon union: Sutherland- + // Hodgman (used downstream for caster clipping) only handles convex + // clip polygons, and the hull is cheap and stable. For typical + // receivers (cubes, planes, simple platforms) the hull is the exact + // outline. For L-shaped coplanar regions it over-extends — shadows + // would extend past the receiver in the L's concave corner — but + // those are rare in practice. + // + // Tolerance choices: dot-product > 0.999 (~2.5° angular) catches + // tessellation artifacts on flat surfaces without merging adjacent + // faces of a low-poly curved mesh. Plane-offset tolerance is 0.5 + // CSS px — sub-pixel coplanarity drift in glTF imports doesn't + // separate what should be a single surface. + function groupReceiverFaceGroups( + receiver: MeshEntry, + rpos: Vec3, + worldCss: (vert: Vec3, pos: Vec3) => Vec3, + ): ReceiverPlaneGroup[] { + type FacePlane = { + face: Polygon; + O: Vec3; n: Vec3; u: Vec3; v: Vec3; + offset: number; // plane offset = n · O, used as the hashing dim + }; + const facePlanes: FacePlane[] = []; + for (const face of receiver.polygons) { + if (face.vertices.length < 3) continue; + const O = worldCss(face.vertices[0]!, rpos); + const w1 = worldCss(face.vertices[1]!, rpos); + const w2 = worldCss(face.vertices[2]!, rpos); + const e1: Vec3 = [w1[0] - O[0], w1[1] - O[1], w1[2] - O[2]]; + const e2: Vec3 = [w2[0] - O[0], w2[1] - O[1], w2[2] - O[2]]; + // Normal = e2 × e1 (NOT e1 × e2). polycss uses an axis swap + // (world Y → CSS X) when emitting leaves, which flips + // handedness. The atlas builder's outward face normal in CSS + // coords is the LEFT-hand cross product (= -right-hand). For + // shadow projection we need the same outward direction so the + // back-face cull aligns with what the renderer treats as the + // lit side. e1 × e2 would point inward → shadow would land on + // the side of the apple facing AWAY from the light (visible as + // "shadow on back/bottom of apple" instead of the lit side). + const nx = e2[1] * e1[2] - e2[2] * e1[1]; + const ny = e2[2] * e1[0] - e2[0] * e1[2]; + const nz = e2[0] * e1[1] - e2[1] * e1[0]; + const nLen = Math.hypot(nx, ny, nz); + if (nLen < 1e-9) continue; + const n: Vec3 = [nx / nLen, ny / nLen, nz / nLen]; + const e1Len = Math.hypot(e1[0], e1[1], e1[2]); + if (e1Len < 1e-9) continue; + const u: Vec3 = [e1[0] / e1Len, e1[1] / e1Len, e1[2] / e1Len]; + const v: Vec3 = [ + n[1] * u[2] - n[2] * u[1], + n[2] * u[0] - n[0] * u[2], + n[0] * u[1] - n[1] * u[0], + ]; + const offset = n[0] * O[0] + n[1] * O[1] + n[2] * O[2]; + facePlanes.push({ face, O, n, u, v, offset }); + } + + const NORMAL_TOL = 0.001; // 1 - dot < 0.001 → ~2.5° + const OFFSET_TOL = 0.5; // CSS px + type Group = { rep: FacePlane; faces: FacePlane[] }; + const groups: Group[] = []; + // First-fit O(F²) grouping. Apple-class meshes (~300 faces) are + // fine; higher-poly receivers may need a bucketed lookup later. + for (const fp of facePlanes) { + let merged = false; + for (const g of groups) { + const r = g.rep; + const dot = fp.n[0] * r.n[0] + fp.n[1] * r.n[1] + fp.n[2] * r.n[2]; + if (1 - dot > NORMAL_TOL) continue; + if (Math.abs(fp.offset - r.offset) > OFFSET_TOL) continue; + g.faces.push(fp); + merged = true; + break; + } + if (!merged) groups.push({ rep: fp, faces: [fp] }); + } + + const out: ReceiverPlaneGroup[] = []; + for (const g of groups) { + const { O, n, u, v } = g.rep; + const uvs: Array<[number, number]> = []; + for (const fp of g.faces) { + for (const vert of fp.face.vertices) { + const w = worldCss(vert, rpos); + const dx = w[0] - O[0]; + const dy = w[1] - O[1]; + const dz = w[2] - O[2]; + uvs.push([ + dx * u[0] + dy * u[1] + dz * u[2], + dx * v[0] + dy * v[1] + dz * v[2], + ]); + } + } + if (uvs.length < 3) continue; + const hull = convexHull2D(uvs); + if (hull.length < 3) continue; + out.push({ O, n, u, v, outlineUv: ensureCcw2D(hull) }); + } + return out; + } + + // Scene-level per-receiver-surface shadow. For each coplanar face + // group on the receiver, project EVERY caster's polygons onto that + // group's plane, clip each projection to the group's outline + // (Sutherland-Hodgman), and emit ONE SVG per group whose path is the + // union of every clipped caster shadow. The SVG sits on the scene + // root with a matrix3d that orients its 2D content to the group + // plane in 3D. + // + // Aggregating all casters into one SVG per surface is the whole point + // of the scene-level refactor: overlapping shadows from different + // casters share one alpha pass instead of stacking under + // multiply/screen. + // + // NOTE: assumes casters and receivers have identity rotation/scale + // (positions are baked). Rotation/scale support requires extending + // worldCss to apply the wrapper's full transform; deferred. + function emitSceneReceiverShadows( + casters: MeshEntry[], + dedupByCaster: Map>, + receiverEntry: MeshEntry, + lightDir: Vec3, + r: number, g: number, b: number, + opacity: number, + ): void { + const llen = Math.hypot(lightDir[0], lightDir[1], lightDir[2]) || 1; + const Lx = lightDir[0] / llen; + const Ly = lightDir[1] / llen; + const Lz = lightDir[2] / llen; + const svgNS = "http://www.w3.org/2000/svg"; + const rpos = receiverEntry.handle.transform.position ?? [0, 0, 0]; + // Mesh-local vertex (world units) → CSS via the same axis swap + // (world.x → CSS-Y, world.y → CSS-X) and tile scale that the atlas + // builder applies. transform.position is then added as raw CSS px + // — that's how the mesh wrapper's translate3d treats it. Mixing + // tile-scaled position into the same expression would shift the + // shadow at a different rate than the visible mesh. + const worldCss = (vert: Vec3, pos: Vec3): Vec3 => [ + vert[1] * DEFAULT_TILE + pos[0], + vert[0] * DEFAULT_TILE + pos[1], + vert[2] * DEFAULT_TILE + pos[2], + ]; + + // Group receiver polygons by shared plane (matching normal AND offset + // within tolerance). Each group becomes ONE shadow surface: instead + // of N tiny SVGs along a tessellated quad we emit a single SVG whose + // outline is the convex hull of the group's coplanar faces. Cubes + // stay at 6 surfaces (each face is its own group); a flat plane + // subdivided into N triangles collapses to 1 surface; an apple + // shrinks from O(triangles) to O(distinct normals * planes). + // Per-receiver face cache: plane data invariant under light. We + // recompute groups (which is O(F²) and allocates lots of vectors) + // only when receiver polygons or position change. The SVG element + // is still created per-frame for non-empty paths — pre-mounting + // an SVG per face balloons compositor layers (248 → +33ms gpuViz). + const cacheKey = `${receiverEntry.polygons.length}|${rpos.join(",")}`; + let cachedPlanes = receiverShadowCache.get(receiverEntry); + if (cachedPlanes === undefined || receiverShadowCacheKey.get(receiverEntry) !== cacheKey) { + const surfaces = groupReceiverFaceGroups(receiverEntry, rpos, worldCss); + cachedPlanes = surfaces.map((group): ReceiverFacePlane => { + const { O, n, u, v, outlineUv } = group; + let minU = Infinity, minV = Infinity, maxU = -Infinity, maxV = -Infinity; + for (const pt of outlineUv) { + if (pt[0] < minU) minU = pt[0]; + if (pt[1] < minV) minV = pt[1]; + if (pt[0] > maxU) maxU = pt[0]; + if (pt[1] > maxV) maxV = pt[1]; + } + const width = maxU - minU; + const height = maxV - minV; + const lift = 5; + const Ox = O[0] + minU * u[0] + minV * v[0] + lift * n[0]; + const Oy = O[1] + minU * u[1] + minV * v[1] + lift * n[1]; + const Oz = O[2] + minU * u[2] + minV * v[2] + lift * n[2]; + const m = [ + u[0], u[1], u[2], 0, + v[0], v[1], v[2], 0, + n[0], n[1], n[2], 0, + Ox, Oy, Oz, 1, + ]; + const matrixCss = `matrix3d(${m.map((x) => x.toFixed(4)).join(",")})`; + return { + O, n, u, v, outlineUv, minU, minV, width, height, matrixCss, + svg: null, path: null, visible: false, + }; + }); + receiverShadowCache.set(receiverEntry, cachedPlanes); + receiverShadowCacheKey.set(receiverEntry, cacheKey); + } + + // Per-caster cached items: world-vertices + 3D AABB per polygon. + // Geometry is invariant under light direction, so once cached + // every receiver-face SH-clip across every drag tick reads from + // the cache. Invalidated when a caster mesh changes geometry or + // position (clearCasterItemsCache from mesh setters). + const casterItems: CasterPolyItem[] = []; + for (const caster of casters) { + if (caster === receiverEntry) continue; + const cpos = caster.handle.transform.position ?? [0, 0, 0]; + const ckey = `${caster.polygons.length}|${cpos.join(",")}`; + let cached = casterItemsCache.get(caster); + if (cached === undefined || casterItemsCacheKey.get(caster) !== ckey) { + const dedupDrop = dedupByCaster.get(caster)!; + cached = []; + for (const item of caster.rendered) { + if (dedupDrop.has(item.polygonIndex)) continue; + const plan = item.plan; + if (!plan) continue; + const polygon = caster.polygons[item.polygonIndex]; + if (!polygon) continue; + const wv = polygon.vertices.map((vert) => worldCss(vert, cpos)); + let minX = Infinity, minY = Infinity, minZ = Infinity; + let maxX = -Infinity, maxY = -Infinity, maxZ = -Infinity; + for (const w of wv) { + if (w[0] < minX) minX = w[0]; if (w[0] > maxX) maxX = w[0]; + if (w[1] < minY) minY = w[1]; if (w[1] > maxY) maxY = w[1]; + if (w[2] < minZ) minZ = w[2]; if (w[2] > maxZ) maxZ = w[2]; + } + const bboxCorners: Vec3[] = [ + [minX, minY, minZ], [maxX, minY, minZ], + [minX, maxY, minZ], [maxX, maxY, minZ], + [minX, minY, maxZ], [maxX, minY, maxZ], + [minX, maxY, maxZ], [maxX, maxY, maxZ], + ]; + cached.push({ wv, bboxCorners }); + } + casterItemsCache.set(caster, cached); + casterItemsCacheKey.set(caster, ckey); + } + for (const it of cached) casterItems.push(it); + } + + for (const group of cachedPlanes) { + const { O, n, u, v, outlineUv, minU, minV, width, height, matrixCss } = group; + // Cull back-facing surfaces. A back-facing receiver face has + // its outward normal pointing AWAY from the light — physically + // it can't receive light at all (the receiver's own body + // occludes it). Projecting a caster shadow onto it computes a + // "virtual" intersection BEHIND the front of the receiver, but + // the receiver's lit polygons would render in front of it + // anyway. Skip them to avoid painting shadow on faces that + // already sit in their own self-shadow. + const Ldotn = Lx * n[0] + Ly * n[1] + Lz * n[2]; + if (Ldotn <= 1e-6) continue; + + // Per-triangle 3D-clip then project. For each caster polygon + // (fan-triangulated), 3D-clip the tri against the receiver + // plane half-space (keeping only the above-plane part), project + // the surviving 3D polygon onto the face's 2D plane along the + // light, then Sutherland-Hodgman-clip against the face outline. + // + // This matches a true raytracer's per-tri occlusion test + // (verified against a Möller-Trumbore reference: 0 false + // negatives on front-facing receiver faces; ~10% false + // positives in edge-case projection geometry, which present + // as faint extra shadow on faces adjacent to true shadow). + // + // Earlier per-vertex approaches failed because (a) projecting + // only above-plane vertices loses the silhouette contribution + // of tris that straddle the plane (their projected shape + // collapses to a line), and (b) per-caster hull engulfs faces + // in the bounding silhouette but outside the actual occluding + // geometry. + const planeDist = (w: Vec3): number => + (w[0] - O[0]) * n[0] + (w[1] - O[1]) * n[1] + (w[2] - O[2]) * n[2]; + const planeCross = (a: Vec3, b: Vec3, da: number, db: number): Vec3 => { + const s = da / (da - db); + return [a[0] + s * (b[0] - a[0]), a[1] + s * (b[1] - a[1]), a[2] + s * (b[2] - a[2])]; + }; + const projectOntoPlane = (w: Vec3): [number, number] => { + const VmOx = w[0] - O[0]; + const VmOy = w[1] - O[1]; + const VmOz = w[2] - O[2]; + const t = (VmOx * n[0] + VmOy * n[1] + VmOz * n[2]) / Ldotn; + const Px = w[0] - t * Lx; + const Py = w[1] - t * Ly; + const Pz = w[2] - t * Lz; + const dx = Px - O[0]; + const dy = Py - O[1]; + const dz = Pz - O[2]; + return [dx * u[0] + dy * u[1] + dz * u[2], dx * v[0] + dy * v[1] + dz * v[2]]; + }; + const clipped: Array> = []; + const fMinU = minU, fMinV = minV; + const fMaxU = group.minU + width; + const fMaxV = group.minV + height; + for (const item of casterItems) { + // Project 3D bbox corners onto the face plane; if the bbox of + // those projections is disjoint from the face outline bbox in + // (u, v), this polygon casts no shadow on this face. Cheap + // 8-projection prefilter that skips the per-tri 3D-clip + SH. + // Also confirms the polygon has at least one corner ABOVE the + // receiver plane — if all 8 corners are below, no shadow. + const corners = item.bboxCorners; + let anyAbove = false; + let pMinU = Infinity, pMinV = Infinity, pMaxU = -Infinity, pMaxV = -Infinity; + for (let ci = 0; ci < 8; ci++) { + const c = corners[ci]!; + if (planeDist(c) >= 0) anyAbove = true; + const pr = projectOntoPlane(c); + if (pr[0] < pMinU) pMinU = pr[0]; + if (pr[0] > pMaxU) pMaxU = pr[0]; + if (pr[1] < pMinV) pMinV = pr[1]; + if (pr[1] > pMaxV) pMaxV = pr[1]; + } + if (!anyAbove) continue; + if (pMaxU < fMinU || pMinU > fMaxU || pMaxV < fMinV || pMinV > fMaxV) continue; + const wv = item.wv; + // Fan-triangulate the polygon. + for (let triIdx = 1; triIdx < wv.length - 1; triIdx++) { + const tA = wv[0]!, tB = wv[triIdx]!, tC = wv[triIdx + 1]!; + const dA = planeDist(tA), dB = planeDist(tB), dC = planeDist(tC); + const above: Vec3[] = []; + const cycle: Array<[Vec3, number]> = [[tA, dA], [tB, dB], [tC, dC]]; + for (let k = 0; k < 3; k++) { + const [p, dp] = cycle[k]!; + const [q, dq] = cycle[(k + 1) % 3]!; + if (dp >= 0) above.push(p); + if ((dp >= 0) !== (dq >= 0)) above.push(planeCross(p, q, dp, dq)); + } + if (above.length < 3) continue; + const projected = above.map(projectOntoPlane); + const subjectCcw = ensureCcw2D(projected); + const clip = clipPolygonToConvex2D(subjectCcw, outlineUv); + if (clip.length < 3) continue; + clipped.push(clip); + } + } + + if (clipped.length === 0 || !(width > 0) || !(height > 0)) continue; + + // Coordinate precision of 1 decimal is sub-pixel for typical + // CSS-px values; the path is sized in receiver-plane CSS px + // (often 100-1000). Cutting from .toFixed(3) drops path string + // size by ~30%, less browser parsing + raster fast path. + let d = ""; + for (const verts of clipped) { + d += `M${(verts[0]![0] - minU).toFixed(1)},${(verts[0]![1] - minV).toFixed(1)}`; + for (let i = 1; i < verts.length; i++) { + d += `L${(verts[i]![0] - minU).toFixed(1)},${(verts[i]![1] - minV).toFixed(1)}`; + } + d += "Z"; + } + + // Mount-once SVG + path. First frame this face has a shadow + // we allocate the elements and parent them; subsequent frames + // mutate `d`/`fill`/`opacity` and just flip `display`. + let svg = group.svg; + let path = group.path; + if (!svg || !path) { + svg = doc.createElementNS(svgNS, "svg"); + svg.setAttribute("class", "polycss-shadow polycss-shadow-svg polycss-shadow-receiver"); + svg.setAttribute("width", String(width)); + svg.setAttribute("height", String(height)); + svg.setAttribute("viewBox", `0 0 ${width} ${height}`); + svg.setAttribute( + "style", + `position:absolute;top:0;left:0;display:block;overflow:hidden;` + + `transform-origin:0 0;pointer-events:none;will-change:transform;` + + `transform:${matrixCss}`, + ); + path = doc.createElementNS(svgNS, "path"); + path.setAttribute("fill-rule", "nonzero"); + svg.appendChild(path); + sceneEl.insertBefore(svg, sceneEl.firstChild); + group.svg = svg; + group.path = path; + } else if (!group.visible) { + svg.style.display = "block"; + } + group.visible = true; + path.setAttribute("d", d); + const fillColor = `rgb(${r},${g},${b})`; + if (path.getAttribute("fill") !== fillColor) path.setAttribute("fill", fillColor); + const opStr = opacity.toFixed(4); + if (path.getAttribute("opacity") !== opStr) path.setAttribute("opacity", opStr); } } @@ -1266,29 +1931,40 @@ export function createPolyScene( emitShadowLeaves(entry); } - // Recomputes --shadow-ground-cssz from the minimum world-Z across all + // Recomputes the shadow ground plane from the minimum world-Z across all // casting meshes. World Z stays as CSS Z under the world→CSS axis swap. // In polycss's world convention Z is up — the red-green plane in the axes // helper is the floor. An optional `lift` (in world units) raises the // plane slightly above the bbox floor to prevent z-fighting with // receiver polygons. + // + // The ground value is folded into each mesh's SVG shadow path on the + // CPU, so a change requires re-emission of every caster's shadow. function recomputeShadowGround(): void { - if (currentOptions.textureLighting !== "dynamic") { - sceneEl.style.removeProperty("--shadow-ground-cssz"); - return; - } let minWorldZ = Infinity; + // If any receivers exist, anchor the ground plane to the lowest + // receiver bottom — that's the actual scene floor. Otherwise fall + // back to the lowest caster bottom (legacy behavior, used when no + // receiver mesh is registered). + let hasReceiver = false; + for (const m of meshes) if (!m.disposed && m.receiveShadow) { hasReceiver = true; break; } for (const m of meshes) { - if (!m.disposed && m.castShadow) { - for (const poly of m.polygons) { - for (const v of poly.vertices) { - if (v[2] < minWorldZ) minWorldZ = v[2]; - } + if (m.disposed) continue; + const eligible = hasReceiver ? m.receiveShadow : m.castShadow; + if (!eligible) continue; + const dz = m.handle.transform.position?.[2] ?? 0; + for (const poly of m.polygons) { + for (const v of poly.vertices) { + const wz = v[2] + dz; + if (wz < minWorldZ) minWorldZ = wz; } } } if (!Number.isFinite(minWorldZ)) { - sceneEl.style.removeProperty("--shadow-ground-cssz"); + const hadGround = currentGroundCssZ !== null; + currentGroundCssZ = null; + // No casters left: drop any shadow elements still mounted. + if (hadGround) clearAllSceneShadows(); return; } const lift = currentOptions.shadow?.lift ?? 0.05; @@ -1296,10 +1972,11 @@ export function createPolyScene( // (not subtracted) so the shadow plane sits slightly *above* the model // bbox floor — putting it on top of a receiver mesh placed at minZ // rather than below it, where the receiver would occlude the shadow. - // Stored as a unitless number (not px) because matrix3d() calc() entries - // must be dimensionless — see styles.ts @property --shadow-ground-cssz. const groundCssZ = (minWorldZ + lift) * DEFAULT_TILE; - sceneEl.style.setProperty("--shadow-ground-cssz", groundCssZ.toFixed(3)); + const prevGround = currentGroundCssZ; + currentGroundCssZ = groundCssZ; + // Ground changed: rebuild the scene-level shadow set once. + if (prevGround !== groundCssZ) emitSceneShadows(); } async function renderEntryChunked( @@ -1449,6 +2126,7 @@ export function createPolyScene( hasBuckets: false, excludeFromAutoCenter: !!transformIn.excludeFromAutoCenter, castShadow: !!transformIn.castShadow, + receiveShadow: !!transformIn.receiveShadow, cameraCullGroups: [], cameraCullSignature: "", lightOverrideSignature: "clear", @@ -1585,6 +2263,8 @@ export function createPolyScene( // Removing from DOM doesn't auto-dispose generated atlas/blob URLs. clearRendered(entry); meshes.delete(entry); + clearReceiverShadowCache(entry); + clearCasterItemsCache(entry); recomputeAutoCenter(); recomputeShadowGround(); }, @@ -1595,6 +2275,8 @@ export function createPolyScene( entry.stableDom = stableDomOnUpdate; entry.voxelSource = undefined; entry.polygons = preparePolygons(polygons, mergeOnUpdate); + clearCasterItemsCache(entry); + clearReceiverShadowCache(entry); clearCurrentTriangleFrame(); handle.polygons = entry.polygons; const shouldRecomputeAutoCenter = options?.recomputeAutoCenter ?? true; @@ -1762,7 +2444,9 @@ export function createPolyScene( }, setTransform(t: Partial) { const prevCastShadow = entry.castShadow; + const prevReceiveShadow = entry.receiveShadow; if (t.castShadow !== undefined) entry.castShadow = !!t.castShadow; + if (t.receiveShadow !== undefined) entry.receiveShadow = !!t.receiveShadow; transform = { ...transform, ...t }; const css2 = buildMeshTransform(transform); wrapper.style.transform = css2 ?? ""; @@ -1772,6 +2456,17 @@ export function createPolyScene( emitShadowLeaves(entry); recomputeShadowGround(); } + // Receiver toggled: rebuild the scene-level shadow set so this + // mesh's faces are added (or removed) as receivers. + if (entry.receiveShadow !== prevReceiveShadow) emitSceneShadows(); + // Position change: shadow geometry depends on world-space coords, + // so recompute ground (which itself rebuilds the scene shadows + // when the plane moves) and rebuild once more in case only the + // caster/receiver moved within the same plane. + if (t.position !== undefined) { + recomputeShadowGround(); + emitSceneShadows(); + } }, dispose() { if (entry.disposed) return; @@ -1809,6 +2504,11 @@ export function createPolyScene( applyMeshLightVarOverride(entry, transform.rotation); recomputeAutoCenter(); recomputeShadowGround(); + // New receiver: the scene-level shadow set must rebuild so existing + // casters get faces to project onto. recomputeShadowGround only + // does this when the global ground changes; force a rebuild for the + // receiver-only case. + if (entry.receiveShadow) emitSceneShadows(); return handle; } @@ -1817,6 +2517,8 @@ export function createPolyScene( const prevStrategies = currentOptions.strategies; const prevSeamBleed = currentOptions.seamBleed; const prevTextureLighting = currentOptions.textureLighting; + const prevLightDir = currentOptions.directionalLight?.direction; + const prevShadow = currentOptions.shadow; const normalizedPartial = normalizeSceneOptions(partial); currentOptions = { ...currentOptions, ...normalizedPartial }; applySceneStyle(sceneEl, currentOptions); @@ -1839,19 +2541,37 @@ export function createPolyScene( for (const entry of meshes) renderEntry(entry); } if (prevAutoCenter !== nextAutoCenter) recomputeAutoCenter(); - // When lighting mode changes, re-emit or clear shadow leaves on all meshes - // that have castShadow set. Shadow emission is only valid in dynamic mode. + // Shadows now use the same per-mesh SVG path in both lighting modes, + // so any of these changes require explicit re-emission: + // - lighting mode toggled (the regular leaves change) + // - light direction changed (projection is CPU-baked into each path) + // - shadow color/opacity/lift changed (color/opacity are inline on the + // ; lift shifts the ground plane and rebuilds geometry) const textureLightingChanged = partial.textureLighting !== undefined && prevTextureLighting !== currentOptions.textureLighting; + const nextLightDir = currentOptions.directionalLight?.direction; + const lightDirChanged = partial.directionalLight !== undefined + && !vec3Equal(prevLightDir, nextLightDir); + const nextShadow = currentOptions.shadow; + const shadowAppearanceChanged = partial.shadow !== undefined + && !shadowOptsEqual(prevShadow, nextShadow); + const shadowReemitNeeded = lightDirChanged || shadowAppearanceChanged; if (textureLightingChanged) { + // Voxel meshes need a full re-render to swap baked/dynamic leaf + // emission; everything else just needs the shadow set rebuilt + // (one scene-level pass at the end covers all casters). for (const entry of meshes) { if (!strategiesChanged && !seamBleedChanged && (entry.voxelSource || entry.voxelRenderer)) { renderEntry(entry); - } else { - emitShadowLeaves(entry); } } recomputeShadowGround(); + emitSceneShadows(); + } else if (shadowReemitNeeded) { + emitSceneShadows(); + } + if (shadowAppearanceChanged && partial.shadow?.lift !== prevShadow?.lift) { + recomputeShadowGround(); } } diff --git a/packages/polycss/src/styles/styles.ts b/packages/polycss/src/styles/styles.ts index 64f151bb..499b7711 100644 --- a/packages/polycss/src/styles/styles.ts +++ b/packages/polycss/src/styles/styles.ts @@ -381,7 +381,7 @@ const CORE_BASE_STYLES = ` ); } -/* ── Cast shadows (dynamic mode only) ──────────────────────────────────── */ +/* ── Cast shadow projection (dynamic-mode CSS path) ────────────────────── */ /* * Shadow projection matrix. Projects any 3D point P onto the horizontal @@ -433,11 +433,12 @@ const CORE_BASE_STYLES = ` jump quickly to 1, giving a near-binary visibility decision with a smooth edge transition. Pure CSS calc — no JS at light-change time. - The base layout / positioning / pseudo-element-strip rules for - live in the polygon-leaf section above. This rule only adds the - dynamic-light Lambert gating, separated so it's easy to disable the - gate for debugging by commenting out a single block. */ -.polycss-scene q { + Scoped to dynamic mode: baked-mode shadow leaves are dropped from the + DOM up-front by isBakedShadowCaster() and don't carry --pnx/--pny/--pnz, + so an unscoped gate would silently zero them via the @property + initial values. The base layout / positioning / pseudo-element-strip + rules for live in the polygon-leaf section above. */ +.polycss-scene[data-polycss-lighting="dynamic"] q { opacity: clamp(0, calc((var(--pnx) * var(--clx) + var(--pny) * var(--cly) + var(--pnz) * var(--clz)) * 10), 1); } `; diff --git a/packages/react/src/scene/PolyMesh.castShadow.test.tsx b/packages/react/src/scene/PolyMesh.castShadow.test.tsx index 0f2cac1f..a9ed79cd 100644 --- a/packages/react/src/scene/PolyMesh.castShadow.test.tsx +++ b/packages/react/src/scene/PolyMesh.castShadow.test.tsx @@ -107,35 +107,43 @@ describe("PolyMesh — castShadow", () => { expect(container.querySelectorAll(".polycss-shadow").length).toBe(0); }); - it("castShadow in dynamic mode emits shadow leaves, one per non-duplicate polygon", () => { + it("castShadow in dynamic mode emits a single shadow per mesh (same path as baked)", () => { const { container } = renderScene(DYN_SCENE_PROPS, { polygons: [TRIANGLE, DISTINCT_TRIANGLE], castShadow: true, }); - expect(container.querySelectorAll(".polycss-shadow").length).toBe(2); + const shadows = container.querySelectorAll(".polycss-shadow"); + expect(shadows.length).toBe(1); + expect(shadows[0]!.tagName.toLowerCase()).toBe("svg"); }); - it("castShadow in baked mode emits NO shadow leaves", () => { + it("castShadow in baked mode emits a single shadow per mesh with one compound ", () => { + // Baked mode concatenates every casting polygon's projected outline + // into ONE compound `d` (M…L…Z subpaths) rendered under + // fill-rule=nonzero — one per mesh regardless of polygon count. const { container } = renderScene( { textureLighting: "baked" }, { polygons: [TRIANGLE], castShadow: true }, ); - expect(container.querySelectorAll(".polycss-shadow").length).toBe(0); - }); - - it("shadow leaves are elements", () => { - const { container } = renderScene(DYN_SCENE_PROPS, { - polygons: [TRIANGLE], - castShadow: true, - }); const shadows = container.querySelectorAll(".polycss-shadow"); - expect(shadows.length).toBeGreaterThan(0); - for (const el of Array.from(shadows)) { - expect(el.tagName.toLowerCase()).toBe("q"); - } + expect(shadows.length).toBe(1); + const shadow = shadows[0] as SVGSVGElement; + expect(shadow.tagName.toLowerCase()).toBe("svg"); + expect(shadow.classList.contains("polycss-shadow-svg")).toBe(true); + expect(shadow.style.transform).toMatch(/^translate3d\(/); + expect(shadow.style.transform).not.toContain("var(--shadow-proj)"); + const paths = shadow.querySelectorAll("path"); + expect(paths.length).toBe(1); + const path = paths[0]!; + expect(path.getAttribute("opacity")).toBe("0.2500"); + expect(path.getAttribute("fill-rule")).toBe("nonzero"); + const d = path.getAttribute("d") || ""; + expect((d.match(/M/g) || []).length).toBe(1); + expect((d.match(/L/g) || []).length).toBe(2); + expect((d.match(/Z/g) || []).length).toBe(1); }); - it("shadow leaves have border-shape set", () => { + it("shadow elements are elements in either lighting mode", () => { const { container } = renderScene(DYN_SCENE_PROPS, { polygons: [TRIANGLE], castShadow: true, @@ -143,29 +151,11 @@ describe("PolyMesh — castShadow", () => { const shadows = container.querySelectorAll(".polycss-shadow"); expect(shadows.length).toBeGreaterThan(0); for (const el of Array.from(shadows)) { - expect((el as HTMLElement).style.getPropertyValue("border-shape")).not.toBe(""); + expect(el.tagName.toLowerCase()).toBe("svg"); + expect(el.classList.contains("polycss-shadow-svg")).toBe(true); } }); - it("shadow leaf transform starts with var(--shadow-proj) then matrix3d", () => { - const { container } = renderScene(DYN_SCENE_PROPS, { - polygons: [TRIANGLE], - castShadow: true, - }); - const shadow = container.querySelector(".polycss-shadow") as HTMLElement; - expect(shadow).not.toBeNull(); - expect(shadow.style.transform).toMatch(/^var\(--shadow-proj\)\s+matrix3d\(/); - }); - - it("adding a casting mesh sets --shadow-ground-cssz on the scene element", () => { - const { container } = renderScene(DYN_SCENE_PROPS, { - polygons: [TRIANGLE], - castShadow: true, - }); - const sceneEl = container.querySelector(".polycss-scene") as HTMLElement; - expect(sceneEl.style.getPropertyValue("--shadow-ground-cssz")).not.toBe(""); - }); - it("toggling castShadow via prop updates adds/removes shadow leaves", () => { const { container, root } = renderScene(DYN_SCENE_PROPS, { polygons: [TRIANGLE], @@ -180,15 +170,20 @@ describe("PolyMesh — castShadow", () => { expect(container.querySelectorAll(".polycss-shadow").length).toBe(0); }); - it("switching scene from dynamic to baked removes shadow leaves", () => { + it("switching scene lighting mode keeps the per-mesh shadow", () => { const { container, root } = renderScene(DYN_SCENE_PROPS, { polygons: [TRIANGLE], castShadow: true, }); - expect(container.querySelectorAll(".polycss-shadow").length).toBeGreaterThan(0); + const before = container.querySelector(".polycss-shadow") as SVGSVGElement; + expect(before.tagName.toLowerCase()).toBe("svg"); + expect(before.style.transform).toMatch(/^translate3d\(/); rerender(root, { textureLighting: "baked" }, { polygons: [TRIANGLE], castShadow: true }); - expect(container.querySelectorAll(".polycss-shadow").length).toBe(0); + const after = container.querySelector(".polycss-shadow") as SVGSVGElement; + expect(after).not.toBeNull(); + expect(after.tagName.toLowerCase()).toBe("svg"); + expect(after.style.transform).toMatch(/^translate3d\(/); }); it("textured polygons (s) ALSO emit shadow leaves (Frog Guy regression)", () => { diff --git a/packages/react/src/scene/PolyMesh.tsx b/packages/react/src/scene/PolyMesh.tsx index c81297f0..2c0609b5 100644 --- a/packages/react/src/scene/PolyMesh.tsx +++ b/packages/react/src/scene/PolyMesh.tsx @@ -32,11 +32,14 @@ import type { Vec3, } from "@layoutit/polycss-core"; import { + BASE_TILE, computeSceneBbox, DEFAULT_SEAM_BLEED, + ensureCcw2D, findOverlappingPolygonDuplicates, inverseRotateVec3, parseHexColor, + projectCssVertexToGround, } from "@layoutit/polycss-core"; import type { TransformProps } from "../shapes/types"; import { usePolyMesh, type UseMeshOptions } from "./useMesh"; @@ -600,10 +603,12 @@ export const PolyMesh = forwardRef(function PolyM const sceneRegisterShadowCaster = sceneCtx?.registerShadowCaster; // Register/unregister as a shadow caster whenever castShadow or polygons change. - // Cleanup on unmount passes null to deregister. + // Both lighting modes need the registration so the scene can derive the + // shadow ground plane from caster bboxes. Cleanup on unmount passes null + // to deregister. useEffect(() => { if (!sceneRegisterShadowCaster) return; - if (castShadow && effectiveTextureLighting === "dynamic") { + if (castShadow) { sceneRegisterShadowCaster(meshIdRef.current, polygons); } else { sceneRegisterShadowCaster(meshIdRef.current, null); @@ -611,20 +616,23 @@ export const PolyMesh = forwardRef(function PolyM return () => { sceneRegisterShadowCaster(meshIdRef.current, null); }; - }, [sceneRegisterShadowCaster, castShadow, effectiveTextureLighting, polygons]); - - // Build shadow leaf elements. Only emitted when castShadow is true and the - // scene is in dynamic mode. Uses the same plans as the caster polygons so - // the outlines are identical. Deduplication removes stacked coplanar - // shadow leaves that would produce visible double-shadows on the receiver. - const shadowLeaves = useMemo(() => { - if (!castShadow || effectiveTextureLighting !== "dynamic" || renderPolygon) return []; - - const shadowColor = sceneCtx?.shadow?.color ?? "#000000"; - const shadowOpacity = sceneCtx?.shadow?.opacity ?? 0.25; - const parsed = parseHexColor(shadowColor)?.rgb ?? [0, 0, 0]; - const shadowColorCss = `rgba(${parsed[0]},${parsed[1]},${parsed[2]},${shadowOpacity})`; - + }, [sceneRegisterShadowCaster, castShadow, polygons]); + + // Per-mesh shadow `` — same path for both lighting modes. Every + // casting polygon is projected to the ground on the CPU and + // concatenated into one compound under + // fill-rule=nonzero, so overlapping CCW outlines composite as one + // filled silhouette without alpha stacking; gaps between subpaths + // remain as gaps (the shadow preserves the silhouette's holes for + // free); back-facing polys are dropped up front. + const bakedShadowGroundCssZ = sceneCtx?.groundCssZ ?? null; + const sceneShadow = sceneCtx?.shadow; + const shadowSvgNode = useMemo(() => { + if (!castShadow || renderPolygon) return null; + if (bakedShadowGroundCssZ === null) return null; + + const lightDir = sceneDirectionalLight?.direction + ?? ([0.4, -0.7, 0.59] as Vec3); const shadowDedupDrop = findOverlappingPolygonDuplicates(polygons, { normalTolerance: 0.1, distanceTolerance: 0.5, @@ -632,23 +640,112 @@ export const PolyMesh = forwardRef(function PolyM preserveDoubleSidedBackfaces: false, }); - const leaves: React.ReactNode[] = []; - for (const plan of atlasPlans) { + const projections: Array> = []; + let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity; + // Footprint = the mesh's straight-down (no-shear) silhouette bbox, + // used by the cap below as the anchor the shadow must always fully + // contain. + let fpMinX = Infinity, fpMinY = Infinity, fpMaxX = -Infinity, fpMaxY = -Infinity; + // Iterate every casting polygon — no Lambert cull. Closed convex + // meshes don't need the back side, but thin/open meshes (bat wings, + // cloth, single quad) need both sides projected or the silhouette + // gets real holes. + for (let i = 0; i < polygons.length; i++) { + const polygon = polygons[i]!; + if (shadowDedupDrop.has(i)) continue; + const plan = atlasPlans[i]; if (!plan) continue; - if (shadowDedupDrop.has(plan.index)) continue; - - const borderShape = cssBorderShapeForPlan(plan); - leaves.push( - - ); + const projected: Array<[number, number]> = []; + for (const v of polygon.vertices) { + const cssVertex: Vec3 = [ + v[1] * BASE_TILE, + v[0] * BASE_TILE, + v[2] * BASE_TILE, + ]; + if (cssVertex[0] < fpMinX) fpMinX = cssVertex[0]; + if (cssVertex[1] < fpMinY) fpMinY = cssVertex[1]; + if (cssVertex[0] > fpMaxX) fpMaxX = cssVertex[0]; + if (cssVertex[1] > fpMaxY) fpMaxY = cssVertex[1]; + const p = projectCssVertexToGround(cssVertex, lightDir, bakedShadowGroundCssZ); + projected.push(p); + if (p[0] < minX) minX = p[0]; + if (p[1] < minY) minY = p[1]; + if (p[0] > maxX) maxX = p[0]; + if (p[1] > maxY) maxY = p[1]; + } + projections.push(projected); + } + if (projections.length === 0) return null; + // Cap how far the shadow can extend BEYOND THE MESH FOOTPRINT. + // Low-elevation lights shear projections across the ground so far + // that the bbox can exceed tens of thousands of pixels each side, + // which forces the browser to rasterize a >100M-pixel backing store + // on every repaint (visible as scene-wide flicker when the camera + // or light moves). The footprint (no-shear silhouette) stays fully + // inside the SVG so the shadow under/next to the mesh is preserved + // — we only truncate the sheared end that's off-screen anyway. + // overflow:hidden does the actual clipping. Callers can disable + // the cap by passing shadow.maxExtend=Infinity on PolyScene. + const maxExtend = sceneShadow?.maxExtend ?? 2000; + const bx0 = Math.max(minX, fpMinX - maxExtend); + const by0 = Math.max(minY, fpMinY - maxExtend); + const bx1 = Math.min(maxX, fpMaxX + maxExtend); + const by1 = Math.min(maxY, fpMaxY + maxExtend); + const width = bx1 - bx0; + const height = by1 - by0; + if (!(width > 0) || !(height > 0)) return null; + + const shadowColor = sceneShadow?.color ?? "#000000"; + const shadowOpacity = sceneShadow?.opacity ?? 0.25; + const parsed = parseHexColor(shadowColor)?.rgb ?? [0, 0, 0]; + + // Concatenate every projection into ONE compound `d` string. Each + // polygon becomes its own M…L…Z subpath, normalized to CCW so all + // windings agree and fill-rule=nonzero paints overlapping outlines + // as one filled silhouette without alpha stacking. Gaps between + // subpaths remain as gaps (the shadow preserves the silhouette's + // holes for free). + let d = ""; + for (const verts of projections) { + const ccw = ensureCcw2D(verts); + d += `M${(ccw[0]![0] - bx0).toFixed(3)},${(ccw[0]![1] - by0).toFixed(3)}`; + for (let j = 1; j < ccw.length; j++) { + d += `L${(ccw[j]![0] - bx0).toFixed(3)},${(ccw[j]![1] - by0).toFixed(3)}`; + } + d += "Z"; } - return leaves; - }, [castShadow, effectiveTextureLighting, renderPolygon, polygons, atlasPlans, sceneCtx?.shadow]); + + return ( + + + + ); + }, [castShadow, renderPolygon, polygons, atlasPlans, sceneDirectionalLight, bakedShadowGroundCssZ, sceneShadow]); setPolygonsImplRef.current = (nextPolygons: Polygon[]) => { const nextRenderedPolygons = autoCenter ? recenterPolygons(nextPolygons) : nextPolygons; @@ -771,7 +868,7 @@ export const PolyMesh = forwardRef(function PolyM style={wrapperStyle} {...wrapperHandlers} > - {shadowLeaves} + {shadowSvgNode} {renderedPolygons} {staticChildren} @@ -793,41 +890,3 @@ function RenderPropPolygon({ return <>{children(polygon, index)}; } -// Shadow leaf — a element that projects the caster polygon's outline onto -// the ground plane via `var(--shadow-proj)`. The transform chain is: -// `var(--shadow-proj) matrix3d(...)` where matrix3d is the original polygon -// placement. border-shape clips the element to the polygon's outline (same -// mechanism as ). The normal is pinned inline as --pnx/y/z so the CSS -// opacity gate in styles.ts can skip back-facing polygons without JS. -// Uses a ref callback for border-shape (non-standard CSS property, must be -// set via setProperty). -function ShadowLeaf({ - plan, - shadowColorCss, - borderShape, -}: { - plan: TextureAtlasPlan; - shadowColorCss: string; - borderShape: string; -}) { - const setRef = useCallback((el: HTMLElement | null) => { - if (!el) return; - el.style.setProperty("border-shape", borderShape); - }, [borderShape]); - - return ( - - ); -} diff --git a/packages/react/src/scene/PolyScene.tsx b/packages/react/src/scene/PolyScene.tsx index 35de8042..76f2c050 100644 --- a/packages/react/src/scene/PolyScene.tsx +++ b/packages/react/src/scene/PolyScene.tsx @@ -1,4 +1,4 @@ -import { memo, useCallback, useEffect, useLayoutEffect, useMemo, useRef } from "react"; +import { memo, useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react"; import type { CSSProperties, ReactNode } from "react"; import type { Polygon, @@ -79,9 +79,11 @@ export interface PolySceneProps extends TransformProps { */ autoCenter?: boolean; /** - * Shadow appearance for meshes with `castShadow={true}`. Only applies in - * dynamic lighting mode — baked mode does not emit shadow leaves. - * Defaults: `{ color: "#000000", opacity: 0.25, lift: 0.05 }`. + * Shadow appearance for meshes with `castShadow={true}`. Works in both + * lighting modes — dynamic mode projects via CSS vars so shadows + * follow a moving light, baked mode CPU-bakes the projection into + * each leaf's inline `matrix3d` and drops back-facing polys from the + * DOM entirely. Defaults: `{ color: "#000000", opacity: 0.25, lift: 0.05, maxExtend: 2000 }`. */ shadow?: ShadowOptions; className?: string; @@ -295,22 +297,13 @@ function PolySceneInner({ // Shadow caster registry. PolyMesh children call registerShadowCaster when // their castShadow prop or polygon list changes. The scene accumulates the - // polygon lists and writes --shadow-ground-cssz to the scene element. + // polygon lists, derives the ground-plane CSS-Z, and mirrors it into either + // the `--shadow-ground-cssz` CSS var (dynamic mode) or the scene context + // (baked mode, where each mesh embeds the value in its inline matrix3d). const shadowCastersRef = useRef>(new Map()); + const [groundCssZ, setGroundCssZ] = useState(null); - const registerShadowCaster = useCallback((meshId: symbol, meshPolygons: Polygon[] | null) => { - if (meshPolygons === null) { - shadowCastersRef.current.delete(meshId); - } else { - shadowCastersRef.current.set(meshId, meshPolygons); - } - // Recompute --shadow-ground-cssz immediately. - const el = sceneElRef.current; - if (!el) return; - if (textureLighting !== "dynamic") { - el.style.removeProperty("--shadow-ground-cssz"); - return; - } + const recomputeGroundCssZ = useCallback(() => { let minWorldZ = Infinity; for (const polys of shadowCastersRef.current.values()) { for (const poly of polys) { @@ -319,24 +312,52 @@ function PolySceneInner({ } } } - if (!Number.isFinite(minWorldZ)) { + if (!Number.isFinite(minWorldZ)) return null; + const lift = shadow?.lift ?? 0.05; + return (minWorldZ + lift) * BASE_TILE; + }, [shadow]); + + const registerShadowCaster = useCallback((meshId: symbol, meshPolygons: Polygon[] | null) => { + if (meshPolygons === null) { + shadowCastersRef.current.delete(meshId); + } else { + shadowCastersRef.current.set(meshId, meshPolygons); + } + const next = recomputeGroundCssZ(); + setGroundCssZ((prev) => (prev === next ? prev : next)); + const el = sceneElRef.current; + if (!el) return; + if (textureLighting === "dynamic" && next !== null) { + el.style.setProperty("--shadow-ground-cssz", next.toFixed(3)); + } else { + // Baked mode (no CSS var needed — the value flows through context) + // or no casters left. el.style.removeProperty("--shadow-ground-cssz"); - return; } - const lift = shadow?.lift ?? 0.05; - const groundCssZ = (minWorldZ + lift) * BASE_TILE; - el.style.setProperty("--shadow-ground-cssz", groundCssZ.toFixed(3)); - }, [sceneElRef, textureLighting, shadow]); + }, [sceneElRef, textureLighting, recomputeGroundCssZ]); - // When lighting mode switches away from dynamic, clear --shadow-ground-cssz - // from the scene element (shadow projection is only active in dynamic mode). + // Re-sync the CSS var on lighting-mode swaps. Dynamic mode needs the var + // (the --shadow-proj calc reads it); baked mode strips it so a stale + // value can't accidentally drive --shadow-proj for legacy leaves. useEffect(() => { const el = sceneElRef.current; if (!el) return; - if (textureLighting !== "dynamic") { + if (textureLighting === "dynamic" && groundCssZ !== null) { + el.style.setProperty("--shadow-ground-cssz", groundCssZ.toFixed(3)); + } else { el.style.removeProperty("--shadow-ground-cssz"); } - }, [textureLighting, sceneElRef]); + }, [textureLighting, sceneElRef, groundCssZ]); + + // Lift change in baked mode: recompute groundCssZ so meshes re-derive + // their inline matrix3d. (Dynamic mode handles it via the CSS var path + // — recomputeGroundCssZ + the useEffect above keeps that consistent too.) + useEffect(() => { + setGroundCssZ((prev) => { + const next = recomputeGroundCssZ(); + return prev === next ? prev : next; + }); + }, [recomputeGroundCssZ]); const disabledStrategies = useMemo( () => strategies?.disable?.length ? new Set(strategies.disable) : undefined, @@ -392,8 +413,9 @@ function PolySceneInner({ seamBleed, shadow, registerShadowCaster, + groundCssZ, }), - [textureLighting, directionalLight, ambientLight, strategies, seamBleed, shadow, registerShadowCaster], + [textureLighting, directionalLight, ambientLight, strategies, seamBleed, shadow, registerShadowCaster, groundCssZ], ); return ( diff --git a/packages/react/src/scene/sceneContext.ts b/packages/react/src/scene/sceneContext.ts index b2460ce8..a3f6a813 100644 --- a/packages/react/src/scene/sceneContext.ts +++ b/packages/react/src/scene/sceneContext.ts @@ -18,6 +18,13 @@ export interface ShadowOptions { color?: string; opacity?: number; lift?: number; + /** + * Maximum CSS pixels the shadow may extend beyond the mesh's + * footprint. Caps the SVG backing store at low light elevations to + * prevent repaint flicker. Default: `2000`. Pass `Infinity` to + * disable the cap entirely. + */ + maxExtend?: number; } export interface PolySceneContextValue { @@ -32,6 +39,14 @@ export interface PolySceneContextValue { * `polygons` is null when unregistering or when castShadow is false. */ registerShadowCaster?: (meshId: symbol, polygons: Polygon[] | null) => void; + /** + * Computed CSS-Z of the shadow ground plane (= min world Z across all + * casting meshes + scene.shadow.lift, in CSS pixels). Dynamic mode also + * mirrors this into the `--shadow-ground-cssz` CSS var. Baked-mode + * mesh code reads it directly to bake the inline `matrix3d(...)` on + * each shadow leaf. `null` means there are no caster meshes yet. + */ + groundCssZ?: number | null; } export const PolySceneContext = createContext(null); diff --git a/packages/react/src/styles/styles.ts b/packages/react/src/styles/styles.ts index a4c3fbf5..4bd8f363 100644 --- a/packages/react/src/styles/styles.ts +++ b/packages/react/src/styles/styles.ts @@ -341,7 +341,7 @@ const CORE_BASE_STYLES = ` content: none; } -/* ── Cast shadows (dynamic mode only) ──────────────────────────────────── */ +/* ── Cast shadow projection (dynamic-mode CSS path) ────────────────────── */ /* * Shadow projection matrix. Projects any 3D point P onto the horizontal @@ -391,8 +391,13 @@ const CORE_BASE_STYLES = ` would stack inside the silhouette and produce ugly overdraw). The * 10 multiplier sharpens the cutoff so small positive Lambert values jump quickly to 1, giving a near-binary visibility decision with a - smooth edge transition. Pure CSS calc — no JS at light-change time. */ -.polycss-scene q { + smooth edge transition. Pure CSS calc — no JS at light-change time. + + Scoped to dynamic mode: baked-mode shadow leaves are dropped up-front + by isBakedShadowCaster() and don't carry --pnx/--pny/--pnz, so an + unscoped gate would silently zero them via the @property initial + values. */ +.polycss-scene[data-polycss-lighting="dynamic"] q { opacity: clamp(0, calc((var(--pnx) * var(--clx) + var(--pny) * var(--cly) + var(--pnz) * var(--clz)) * 10), 1); } `; diff --git a/packages/vue/src/scene/PolyMesh.castShadow.test.ts b/packages/vue/src/scene/PolyMesh.castShadow.test.ts index 7c07e282..cfb76220 100644 --- a/packages/vue/src/scene/PolyMesh.castShadow.test.ts +++ b/packages/vue/src/scene/PolyMesh.castShadow.test.ts @@ -86,57 +86,63 @@ describe("PolyMesh (Vue) — castShadow", () => { expect(container.querySelectorAll(".polycss-shadow").length).toBe(0); }); - it("castShadow:true in dynamic mode emits shadow leaves, one per non-duplicate polygon", () => { + it("castShadow:true in dynamic mode emits a single shadow per mesh (same path as baked)", async () => { const { container } = mount(DYNAMIC_SCENE_PROPS, { polygons: [TRIANGLE, DISTINCT_TRIANGLE], castShadow: true, }); - expect(container.querySelectorAll(".polycss-shadow").length).toBe(2); + await nextTick(); + await nextTick(); + const shadows = container.querySelectorAll(".polycss-shadow"); + expect(shadows.length).toBe(1); + expect(shadows[0]!.tagName.toLowerCase()).toBe("svg"); }); - it("castShadow:true in baked mode emits NO shadow leaves", () => { + it("castShadow:true in baked mode emits a single shadow per mesh with one compound ", async () => { + // Baked mode concatenates every casting polygon's projected outline + // into ONE compound `d` (M…L…Z subpaths) rendered under + // fill-rule=nonzero. One per mesh regardless of polygon count. + // nextTick lets the scene's watchEffect derive groundCssZ from the + // child's registration before the shadow nodes recompute. const { container } = mount( { textureLighting: "baked" }, { polygons: [TRIANGLE], castShadow: true }, ); - expect(container.querySelectorAll(".polycss-shadow").length).toBe(0); + await nextTick(); + await nextTick(); + const shadows = container.querySelectorAll(".polycss-shadow"); + expect(shadows.length).toBe(1); + const shadow = shadows[0] as SVGSVGElement; + expect(shadow.tagName.toLowerCase()).toBe("svg"); + expect(shadow.classList.contains("polycss-shadow-svg")).toBe(true); + expect(shadow.style.transform).toMatch(/^translate3d\(/); + expect(shadow.style.transform).not.toContain("var(--shadow-proj)"); + const paths = shadow.querySelectorAll("path"); + expect(paths.length).toBe(1); + const path = paths[0]!; + expect(path.getAttribute("opacity")).toBe("0.2500"); + expect(path.getAttribute("fill-rule")).toBe("nonzero"); + const d = path.getAttribute("d") || ""; + expect((d.match(/M/g) || []).length).toBe(1); + expect((d.match(/L/g) || []).length).toBe(2); + expect((d.match(/Z/g) || []).length).toBe(1); }); - it("shadow leaves are always with class polycss-shadow", () => { + it("shadow elements are always with class polycss-shadow regardless of mode", async () => { const { container } = mount(DYNAMIC_SCENE_PROPS, { polygons: [TRIANGLE, DISTINCT_TRIANGLE], castShadow: true, }); + await nextTick(); + await nextTick(); const shadows = Array.from(container.querySelectorAll(".polycss-shadow")); expect(shadows.length).toBeGreaterThan(0); for (const el of shadows) { - expect(el.tagName.toLowerCase()).toBe("q"); - expect(el.classList.contains("polycss-shadow")).toBe(true); - } - }); - - it("shadow leaves have border-shape set", () => { - const { container } = mount(DYNAMIC_SCENE_PROPS, { - polygons: [TRIANGLE, DISTINCT_TRIANGLE], - castShadow: true, - }); - const shadows = Array.from(container.querySelectorAll(".polycss-shadow")) as HTMLElement[]; - expect(shadows.length).toBeGreaterThan(0); - for (const el of shadows) { - expect(el.style.getPropertyValue("border-shape")).not.toBe(""); + expect(el.tagName.toLowerCase()).toBe("svg"); + expect(el.classList.contains("polycss-shadow-svg")).toBe(true); } }); - it("shadow leaves transform contains var(--shadow-proj) followed by matrix3d", () => { - const { container } = mount(DYNAMIC_SCENE_PROPS, { - polygons: [TRIANGLE], - castShadow: true, - }); - const shadow = container.querySelector(".polycss-shadow") as HTMLElement; - expect(shadow).not.toBeNull(); - expect(shadow.style.transform).toMatch(/^var\(--shadow-proj\)\s+matrix3d\(/); - }); - it("adding a casting mesh sets --shadow-ground-cssz on the scene element", async () => { const { container } = mount(DYNAMIC_SCENE_PROPS, { polygons: [TRIANGLE], @@ -190,11 +196,13 @@ describe("PolyMesh (Vue) — castShadow", () => { expect(container.querySelectorAll(".polycss-shadow").length).toBe(0); }); - it("textured polygons (s) ALSO emit shadow leaves", () => { + it("textured polygons (s) ALSO emit shadow leaves", async () => { const { container } = mount(DYNAMIC_SCENE_PROPS, { polygons: [TEXTURED_TRIANGLE], castShadow: true, }); + await nextTick(); + await nextTick(); expect(container.querySelectorAll(".polycss-shadow").length).toBe(1); }); @@ -214,24 +222,15 @@ describe("PolyMesh (Vue) — castShadow", () => { expect(sceneEl.style.getPropertyValue("--clz")).toBe(""); }); - it("shadow leaves have --pnx/--pny/--pnz inline for Lambert gate", () => { - const { container } = mount(DYNAMIC_SCENE_PROPS, { - polygons: [TRIANGLE], - castShadow: true, - }); - const shadow = container.querySelector(".polycss-shadow") as HTMLElement; - expect(shadow).not.toBeNull(); - expect(shadow.style.getPropertyValue("--pnx")).not.toBe(""); - expect(shadow.style.getPropertyValue("--pny")).not.toBe(""); - expect(shadow.style.getPropertyValue("--pnz")).not.toBe(""); - }); - - it("duplicate coincident polygons emit only one shadow leaf", () => { - // Two triangles at the same position should be deduped to one shadow leaf. + it("duplicate coincident polygons collapse into the same per-mesh shadow ", async () => { + // Two triangles at the same position both contribute to the same + // mesh's compound SVG path (the loose dedup pass drops the second). const { container } = mount(DYNAMIC_SCENE_PROPS, { polygons: [TRIANGLE, { ...TRIANGLE }], castShadow: true, }); + await nextTick(); + await nextTick(); expect(container.querySelectorAll(".polycss-shadow").length).toBe(1); }); diff --git a/packages/vue/src/scene/PolyMesh.ts b/packages/vue/src/scene/PolyMesh.ts index 4911affa..a6f3ddc8 100644 --- a/packages/vue/src/scene/PolyMesh.ts +++ b/packages/vue/src/scene/PolyMesh.ts @@ -20,11 +20,14 @@ import { defineComponent, h, computed, inject, onMounted, onBeforeUnmount, ref, import type { PropType, VNode, CSSProperties } from "vue"; import type { MeshResolution, Polygon, PolyTextureLightingMode, Vec3 } from "@layoutit/polycss-core"; import { + BASE_TILE, computeSceneBbox, DEFAULT_SEAM_BLEED, + ensureCcw2D, inverseRotateVec3, findOverlappingPolygonDuplicates, parseHexColor, + projectCssVertexToGround, } from "@layoutit/polycss-core"; import { usePolyMesh } from "./useMesh"; import { @@ -303,21 +306,20 @@ export const PolyMesh = defineComponent({ ); const defaultPaintVars = computed(() => solidPaintVars(solidPaintDefaults.value)); - // Shadow leaf emission. Only active when castShadow=true and the scene is - // in dynamic lighting mode. Computed from textureAtlasPlans so every - // polygon (including textured polygons) gets a shadow leaf based on - // its outline. - const shadowNodes = computed>(() => { - if (!props.castShadow || atlasTextureLighting.value !== "dynamic") return []; - const shadowOpts = sceneCtx?.value.shadow; - const shadowColor = shadowOpts?.color ?? "#000000"; - const shadowOpacity = shadowOpts?.opacity ?? 0.25; - const parsed = parseHexColor(shadowColor)?.rgb ?? [0, 0, 0]; - const shadowColorCss = `rgba(${parsed[0]},${parsed[1]},${parsed[2]},${shadowOpacity})`; - - const plans = textureAtlasPlans.value; - if (plans.length === 0) return []; - + // Per-mesh SVG shadow — same path for both lighting modes. Every + // casting polygon is projected to the ground on the CPU and + // concatenated into one compound under + // fill-rule=nonzero so overlapping CCW outlines composite as one + // filled silhouette without alpha stacking; gaps remain as gaps. + const shadowSvg = computed(() => { + if (!props.castShadow) return null; + const ctx = sceneCtx?.value; + const groundCssZ = ctx?.groundCssZ ?? null; + if (groundCssZ === null) return null; + const shadowOpts = ctx?.shadow; + + const lightDir = ctx?.directionalLight?.direction + ?? ([0.4, -0.7, 0.59] as Vec3); const dedupDrop = findOverlappingPolygonDuplicates(polygons.value, { normalTolerance: 0.1, distanceTolerance: 0.5, @@ -325,45 +327,121 @@ export const PolyMesh = defineComponent({ preserveDoubleSidedBackfaces: false, }); - return plans.map((plan, index) => { - if (!plan) return null; - if (dedupDrop.has(index)) return null; - const origMatrix = `matrix3d(${plan.matrix})`; - const borderShape = cssBorderShapeForPlan(plan); - const style: CSSProperties = { - transform: `var(--shadow-proj) ${origMatrix}`, - color: shadowColorCss, - width: `${plan.canvasW}px`, - height: `${plan.canvasH}px`, - "--pnx": plan.normal[0].toFixed(4), - "--pny": plan.normal[1].toFixed(4), - "--pnz": plan.normal[2].toFixed(4), - }; + const projections: Array> = []; + let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity; + let fpMinX = Infinity, fpMinY = Infinity, fpMaxX = -Infinity, fpMaxY = -Infinity; + const polys = polygons.value; + const plans = textureAtlasPlans.value; + // No Lambert cull — thin/open meshes (bat wings, cloth, single + // quad) need both sides projected or the silhouette gets holes. + // We also track the footprint (no-shear XY bbox) so the cap below + // keeps the area near the mesh fully inside the SVG. + for (let i = 0; i < polys.length; i++) { + if (dedupDrop.has(i)) continue; + const plan = plans[i]; + if (!plan) continue; + const polygon = polys[i]!; + const projected: Array<[number, number]> = []; + for (const v of polygon.vertices) { + const cssVertex: Vec3 = [ + v[1] * BASE_TILE, + v[0] * BASE_TILE, + v[2] * BASE_TILE, + ]; + if (cssVertex[0] < fpMinX) fpMinX = cssVertex[0]; + if (cssVertex[1] < fpMinY) fpMinY = cssVertex[1]; + if (cssVertex[0] > fpMaxX) fpMaxX = cssVertex[0]; + if (cssVertex[1] > fpMaxY) fpMaxY = cssVertex[1]; + const p = projectCssVertexToGround(cssVertex, lightDir, groundCssZ); + projected.push(p); + if (p[0] < minX) minX = p[0]; + if (p[1] < minY) minY = p[1]; + if (p[0] > maxX) maxX = p[0]; + if (p[1] > maxY) maxY = p[1]; + } + projections.push(projected); + } + if (projections.length === 0) return null; + // Cap how far the shadow can extend BEYOND THE MESH FOOTPRINT. + // Low-elevation lights shear projections across the ground so far + // that the bbox can exceed tens of thousands of pixels each side, + // which forces the browser to rasterize a >100M-pixel backing + // store on every repaint. The footprint stays fully inside the + // SVG so the shadow under/next to the mesh is preserved; only the + // sheared end (off-screen anyway) gets clipped by overflow:hidden. + // Callers can disable the cap by passing shadow.maxExtend=Infinity. + const maxExtend = shadowOpts?.maxExtend ?? 2000; + const bx0 = Math.max(minX, fpMinX - maxExtend); + const by0 = Math.max(minY, fpMinY - maxExtend); + const bx1 = Math.min(maxX, fpMaxX + maxExtend); + const by1 = Math.min(maxY, fpMaxY + maxExtend); + const width = bx1 - bx0; + const height = by1 - by0; + if (!(width > 0) || !(height > 0)) return null; - const applyShadowBorderShape = (vnode: VNode) => { - const el = vnode.el as HTMLElement | null; - if (!el) return; - el.style.setProperty("border-shape", borderShape); - }; + const shadowColor = shadowOpts?.color ?? "#000000"; + const shadowOpacity = shadowOpts?.opacity ?? 0.25; + const parsed = parseHexColor(shadowColor)?.rgb ?? [0, 0, 0]; - return h("q", { - class: "polycss-shadow", - style, - onVnodeMounted: applyShadowBorderShape, - onVnodeUpdated: applyShadowBorderShape, - }); - }); + // Concatenate every projection into ONE compound `d` string. Each + // polygon becomes its own M…L…Z subpath, normalized to CCW so all + // windings agree and fill-rule=nonzero paints overlapping outlines + // as one filled silhouette without alpha stacking. Gaps between + // subpaths remain as holes — the shadow inherits the silhouette's + // holes for free. + let d = ""; + for (const verts of projections) { + const ccw = ensureCcw2D(verts); + d += `M${(ccw[0]![0] - bx0).toFixed(3)},${(ccw[0]![1] - by0).toFixed(3)}`; + for (let j = 1; j < ccw.length; j++) { + d += `L${(ccw[j]![0] - bx0).toFixed(3)},${(ccw[j]![1] - by0).toFixed(3)}`; + } + d += "Z"; + } + + return h( + "svg", + { + class: "polycss-shadow polycss-shadow-svg", + width: String(width), + height: String(height), + viewBox: `0 0 ${width} ${height}`, + style: { + position: "absolute", + top: "0", + left: "0", + display: "block", + overflow: "hidden", + transformOrigin: "0 0", + pointerEvents: "none", + willChange: "transform", + transform: `translate3d(${bx0.toFixed(3)}px,${by0.toFixed(3)}px,${groundCssZ.toFixed(3)}px)`, + } as CSSProperties, + }, + [ + h("path", { + d, + fill: `rgb(${parsed[0]},${parsed[1]},${parsed[2]})`, + "fill-rule": "nonzero", + stroke: `rgb(${parsed[0]},${parsed[1]},${parsed[2]})`, + "stroke-width": "2", + "stroke-linejoin": "round", + opacity: shadowOpacity.toFixed(4), + }), + ], + ); }); - // Register this mesh with the shadow registry when castShadow=true so - // PolyScene can compute --shadow-ground-cssz reactively. + // Register this mesh with the shadow registry when castShadow=true in + // either lighting mode — the scene needs caster polygons to derive + // the ground plane regardless of how shadows are projected. const shadowRegistryId = Symbol(); watch( - () => [props.castShadow, atlasTextureLighting.value] as const, - ([castShadow, lighting], _, onCleanup) => { + () => props.castShadow, + (castShadow, _, onCleanup) => { const registry = sceneCtx?.value.shadowRegistry; if (!registry) return; - if (castShadow && lighting === "dynamic") { + if (castShadow) { registry.register(shadowRegistryId, () => polygons.value); } else { registry.unregister(shadowRegistryId); @@ -680,10 +758,11 @@ export const PolyMesh = defineComponent({ // Static default slot children (e.g. additional children) const defaultChildren = slots.default?.() ?? []; - // Shadow leaves go before polygon nodes so they sit below casters in - // DOM order — painter-order tie-breaking favors earlier nodes when both - // are coplanar in 3D. - const shadows = shadowNodes.value; + // Shadow goes before polygon nodes so it sits below casters in DOM + // order — painter-order tie-breaking favors earlier nodes when both + // are coplanar in 3D. Single per mesh (see shadowSvg above). + const svgNode = shadowSvg.value; + const shadowChildren: VNode[] = svgNode ? [svgNode] : []; return h( "div", @@ -695,7 +774,7 @@ export const PolyMesh = defineComponent({ ...handlers, ...extraAttrs, }, - [...shadows, ...polyNodes, ...defaultChildren] + [...shadowChildren, ...polyNodes, ...defaultChildren] ); }; }, diff --git a/packages/vue/src/scene/PolyScene.ts b/packages/vue/src/scene/PolyScene.ts index a9fef9fe..582c055b 100644 --- a/packages/vue/src/scene/PolyScene.ts +++ b/packages/vue/src/scene/PolyScene.ts @@ -74,9 +74,11 @@ export interface PolySceneProps { */ autoCenter?: boolean; /** - * Shadow appearance for meshes with `castShadow: true`. Only applies in - * dynamic lighting mode — baked mode does not emit shadow leaves. - * Defaults: `{ color: "#000000", opacity: 0.25, lift: 0.05 }`. + * Shadow appearance for meshes with `castShadow: true`. Works in both + * lighting modes — dynamic mode projects via CSS vars so shadows + * follow a moving light, baked mode CPU-bakes the projection into + * each leaf's inline `matrix3d` and drops back-facing polys from the + * DOM entirely. Defaults: `{ color: "#000000", opacity: 0.25, lift: 0.05, maxExtend: 2000 }`. */ shadow?: PolyShadowOptions; class?: string; @@ -154,6 +156,11 @@ export const PolyScene = defineComponent({ }, }; + // Reactive ground-plane CSS-Z. Dynamic mode also mirrors this into + // the `--shadow-ground-cssz` CSS var (the watchEffect below); baked + // mode reads it via context to bake each leaf's inline matrix3d. + const groundCssZ = ref(null); + // Propagate scene-level rendering options to descendants (PolyMesh / // helpers) so they pick up the same dynamic mode + lights as the // scene. Without this, a helper PolyMesh would default to baked @@ -167,6 +174,7 @@ export const PolyScene = defineComponent({ seamBleed: props.seamBleed ?? DEFAULT_SEAM_BLEED, shadow: props.shadow, shadowRegistry, + groundCssZ: groundCssZ.value, })); provide(PolySceneContextKey, sceneCtxValue); @@ -319,24 +327,20 @@ export const PolyScene = defineComponent({ const DEFAULT_TILE = 50; - // --shadow-ground-cssz: written directly to the scene element when casting - // meshes register/unregister. A watchEffect is used instead of a computed - // read in the render function because child PolyMesh components register - // after the parent's first render (child setup runs during mount, not during - // the parent's VNode creation). The watchEffect re-runs after child - // registration because it reads shadowRegistryVersion, which the registry - // mutates when a mesh registers or unregisters. + // Shadow ground plane: derived from the min world-Z of all casting + // meshes + scene.shadow.lift. Drives the `--shadow-ground-cssz` CSS + // var in dynamic mode and the `groundCssZ` scene-context value + // (used by baked-mode meshes to bake their inline matrix3d). A + // watchEffect is used because child PolyMesh components register + // after the parent's first render — watchEffect re-runs after + // registration because it reads shadowRegistryVersion. watchEffect(() => { const el = sceneElLocalRef.value; - if (!el) return; - if (props.textureLighting !== "dynamic") { - el.style.removeProperty("--shadow-ground-cssz"); - return; - } void shadowRegistryVersion.value; const entries = shadowRegistry.getEntries(); if (entries.length === 0) { - el.style.removeProperty("--shadow-ground-cssz"); + if (el) el.style.removeProperty("--shadow-ground-cssz"); + if (groundCssZ.value !== null) groundCssZ.value = null; return; } let minWorldZ = Infinity; @@ -348,11 +352,19 @@ export const PolyScene = defineComponent({ } } if (!Number.isFinite(minWorldZ)) { - el.style.removeProperty("--shadow-ground-cssz"); + if (el) el.style.removeProperty("--shadow-ground-cssz"); + if (groundCssZ.value !== null) groundCssZ.value = null; return; } const lift = props.shadow?.lift ?? 0.05; - el.style.setProperty("--shadow-ground-cssz", ((minWorldZ + lift) * DEFAULT_TILE).toFixed(3)); + const next = (minWorldZ + lift) * DEFAULT_TILE; + if (groundCssZ.value !== next) groundCssZ.value = next; + if (!el) return; + if (props.textureLighting === "dynamic") { + el.style.setProperty("--shadow-ground-cssz", next.toFixed(3)); + } else { + el.style.removeProperty("--shadow-ground-cssz"); + } }); // Bbox-center of all centerable meshes in world coords. Folded into the diff --git a/packages/vue/src/scene/sceneContext.ts b/packages/vue/src/scene/sceneContext.ts index 4555c24c..174866c7 100644 --- a/packages/vue/src/scene/sceneContext.ts +++ b/packages/vue/src/scene/sceneContext.ts @@ -18,6 +18,13 @@ export interface PolyShadowOptions { color?: string; opacity?: number; lift?: number; + /** + * Maximum CSS pixels the shadow may extend beyond the mesh's + * footprint. Caps the SVG backing store at low light elevations to + * prevent repaint flicker. Default: `2000`. Pass `Infinity` to + * disable the cap entirely. + */ + maxExtend?: number; } export interface PolyShadowRegistry { @@ -39,6 +46,14 @@ export interface PolySceneContextValue { seamBleed?: PolySeamBleed; shadow?: PolyShadowOptions; shadowRegistry?: PolyShadowRegistry; + /** + * Computed CSS-Z of the shadow ground plane (= min world Z across all + * casting meshes + scene.shadow.lift, in CSS pixels). Dynamic mode also + * mirrors this into `--shadow-ground-cssz`. Baked mode reads it here to + * bake the inline `matrix3d(...)` on each shadow leaf. `null` when no + * casting meshes are registered. + */ + groundCssZ?: number | null; } /** diff --git a/packages/vue/src/styles/styles.ts b/packages/vue/src/styles/styles.ts index 7e1a9ca6..46d93d6b 100644 --- a/packages/vue/src/styles/styles.ts +++ b/packages/vue/src/styles/styles.ts @@ -262,7 +262,7 @@ const CORE_BASE_STYLES = ` ); } -/* ── Cast shadows (dynamic mode only) ──────────────────────────────────── */ +/* ── Cast shadow projection (dynamic-mode CSS path) ────────────────────── */ /* — dedicated shadow leaf. Same border-shape rendering trick as (border-color: currentColor fills the polygon outline) but with its @@ -346,8 +346,10 @@ const CORE_BASE_STYLES = ` /* shadow leaf — Lambert-gated opacity. Polygons facing the light cast full shadow; polygons facing away cast zero shadow. The * 10 multiplier sharpens the cutoff so small positive Lambert values jump quickly to 1, - giving a near-binary visibility decision with a smooth edge transition. */ -.polycss-scene q { + giving a near-binary visibility decision with a smooth edge transition. + Scoped to dynamic mode: baked-mode shadow leaves are dropped up-front + by isBakedShadowCaster() and don't carry --pnx/--pny/--pnz. */ +.polycss-scene[data-polycss-lighting="dynamic"] q { opacity: clamp(0, calc((var(--pnx) * var(--clx) + var(--pny) * var(--cly) + var(--pnz) * var(--clz)) * 10), 1); } `; diff --git a/website/src/components/BuilderWorkbench/components/BuilderDock.tsx b/website/src/components/BuilderWorkbench/components/BuilderDock.tsx index f495b065..252b5cb4 100644 --- a/website/src/components/BuilderWorkbench/components/BuilderDock.tsx +++ b/website/src/components/BuilderWorkbench/components/BuilderDock.tsx @@ -92,6 +92,7 @@ export function BuilderDock({ /> onUpdateScene({ castShadow: value })); + useSlider( + folder, + "Shadow reach", + { min: 200, max: 4000, step: 100 }, + shadowMaxExtend, + (value) => onUpdateScene({ shadowMaxExtend: value }), + ); useToggle(folder, "Show ground", showGround, (value) => onUpdateScene({ showGround: value })); useToggle(folder, "Light helper", showLight, (value) => onUpdateScene({ showLight: value })); useSlider(folder, "Azimuth", { min: 0, max: 360, step: 1 }, lightAzimuth, (value) => onUpdateScene({ lightAzimuth: value }), ); - useSlider(folder, "Elev.", { min: -90, max: 90, step: 1 }, lightElevation, (value) => + useSlider(folder, "Elev.", { min: 0, max: 90, step: 1 }, lightElevation, (value) => onUpdateScene({ lightElevation: value }), ); useSlider(folder, "Key", { min: 0, max: 2, step: 0.05 }, lightIntensity, (value) => diff --git a/website/src/components/GalleryWorkbench/GalleryWorkbench.tsx b/website/src/components/GalleryWorkbench/GalleryWorkbench.tsx index 4ff536e7..782ad3c3 100644 --- a/website/src/components/GalleryWorkbench/GalleryWorkbench.tsx +++ b/website/src/components/GalleryWorkbench/GalleryWorkbench.tsx @@ -127,6 +127,7 @@ const DEFAULT_SCENE: SceneOptionsState = { target: [0, 0, 0], disableStrategies: [], castShadow: false, + shadowMaxExtend: 2000, showGround: false, fpvLook: true, fpvMove: true, @@ -1088,6 +1089,7 @@ export default function GalleryWorkbench() { /> {sceneOptions.selection ? ( diff --git a/website/src/components/VanillaScene/VanillaScene.tsx b/website/src/components/VanillaScene/VanillaScene.tsx index 02fa181d..cfe83fac 100644 --- a/website/src/components/VanillaScene/VanillaScene.tsx +++ b/website/src/components/VanillaScene/VanillaScene.tsx @@ -309,6 +309,7 @@ export function VanillaScene({ autoCenter: options.autoCenter, textureQuality: options.textureQuality, strategies: { disable: options.disableStrategies }, + shadow: { maxExtend: options.shadowMaxExtend }, }; const scene = createPolyScene(host, sceneOptions); sceneRef.current = scene; @@ -695,6 +696,7 @@ export function VanillaScene({ directionalLight, ambientLight, textureLighting: options.textureLighting, + shadow: { maxExtend: options.shadowMaxExtend }, }); }, [ options.rotX, @@ -702,6 +704,7 @@ export function VanillaScene({ options.zoom, options.target, options.textureLighting, + options.shadowMaxExtend, directionalLight, ambientLight, ]); diff --git a/website/src/components/types.ts b/website/src/components/types.ts index 1c412899..c39ef095 100644 --- a/website/src/components/types.ts +++ b/website/src/components/types.ts @@ -67,6 +67,9 @@ export interface SceneOptionsState { target: ReactVec3; disableStrategies: PolyRenderStrategy[]; castShadow: boolean; + /** Maximum CSS pixels the shadow may extend beyond the mesh footprint. + * Caps the SVG backing store at low light elevations. */ + shadowMaxExtend: number; showGround: boolean; fpvLook: boolean; fpvMove: boolean;