diff --git a/packages/polycss/src/api/createPolyScene.test.ts b/packages/polycss/src/api/createPolyScene.test.ts index a9b9b65b..c4e74dcb 100644 --- a/packages/polycss/src/api/createPolyScene.test.ts +++ b/packages/polycss/src/api/createPolyScene.test.ts @@ -1331,6 +1331,92 @@ describe("createPolyScene", () => { expect(sceneEl.dataset.polycssLighting).toBe("baked"); }); + it("can preview baked solid lighting through CSS vars without switching scene mode", () => { + scene = makeScene(host, { textureLighting: "baked" }); + scene.add(makeParseResult([triangle("#336699")]), { merge: false }); + const sceneEl = host.querySelector(".polycss-scene") as HTMLElement; + const leaf = host.querySelector("b, i, u") as HTMLElement; + const triangleLeaf = host.querySelector("u") as HTMLElement; + const initialColor = leaf.style.color; + const initialLeafStyle = leaf.getAttribute("style"); + const initialTriangleBackgroundColor = triangleLeaf.style.backgroundColor; + const previewScene = scene as PolySceneHandle & { + previewBakedSolidLighting(next: Pick): boolean; + clearBakedSolidLightingPreview(): void; + }; + + expect(previewScene.previewBakedSolidLighting({ + directionalLight: { direction: [0, 0, 1], color: "#ffffff", intensity: 1 }, + ambientLight: { color: "#ffffff", intensity: 0.4 }, + })).toBe(true); + + expect(sceneEl.dataset.polycssLighting).toBe("baked"); + expect(sceneEl.style.getPropertyValue("--plz")).toBe("1.0000"); + expect(sceneEl.style.getPropertyValue("--polycss-light-preview-active")).toBe("1"); + expect(leaf.style.getPropertyValue("--pnz")).not.toBe(""); + expect(leaf.style.getPropertyValue("--plam")).toContain("var(--plz, 1)"); + expect(leaf.style.getPropertyValue("--polycss-preview-r")).toContain("var(--plam, 0)"); + expect(leaf.style.getPropertyValue("--polycss-paint")).toContain("var(--polycss-light-preview-active"); + expect(leaf.style.color).toBe(initialColor); + expect(leaf.getAttribute("style")).toBe(initialLeafStyle); + expect(triangleLeaf.style.backgroundColor).toBe(initialTriangleBackgroundColor); + + previewScene.clearBakedSolidLightingPreview(); + + expect(sceneEl.style.getPropertyValue("--plz")).toBe(""); + expect(sceneEl.style.getPropertyValue("--polycss-light-preview-active")).toBe(""); + expect(leaf.style.color).toBe(initialColor); + expect(leaf.style.getPropertyValue("--plam")).toContain("var(--plz, 1)"); + expect(leaf.style.getPropertyValue("--polycss-preview-r")).toContain("var(--plam, 0)"); + expect(triangleLeaf.style.backgroundColor).toBe(initialTriangleBackgroundColor); + }); + + it("can commit baked solid lighting in place after a preview", () => { + scene = makeScene(host, { textureLighting: "baked" }); + scene.add(makeParseResult([triangle("#336699")]), { merge: false }); + const leaf = host.querySelector("b, i, u") as HTMLElement; + const triangleLeaf = host.querySelector("u") as HTMLElement; + const initialLeaf = leaf; + const initialTriangleBackgroundColor = triangleLeaf.style.backgroundColor; + const previewScene = scene as PolySceneHandle & { + previewBakedSolidLighting(next: Pick): boolean; + commitBakedSolidLighting(): boolean; + }; + + previewScene.previewBakedSolidLighting({ + directionalLight: { direction: [0, 0, 1], color: "#ffffff", intensity: 1 }, + ambientLight: { color: "#ffffff", intensity: 0.4 }, + }); + scene.setOptions({ + directionalLight: { direction: [0, 0, 1], color: "#ffffff", intensity: 1 }, + ambientLight: { color: "#ffffff", intensity: 0.4 }, + }); + expect(previewScene.commitBakedSolidLighting()).toBe(true); + + expect(host.querySelector("b, i, u")).toBe(initialLeaf); + expect(leaf.style.color).toBe(""); + expect(leaf.style.getPropertyValue("--polycss-paint")).toContain("var(--polycss-light-preview-active"); + expect(leaf.style.getPropertyValue("--polycss-preview-r")).toContain("var(--plam, 0)"); + expect(leaf.style.getPropertyValue("--plam")).toContain("var(--plz, 1)"); + expect(triangleLeaf.style.backgroundColor).toBe(initialTriangleBackgroundColor); + }); + + it("rebakes atlas leaves when committing baked lighting", () => { + scene = makeScene(host, { textureLighting: "baked" }); + scene.add(makeParseResult([texturedTriangle()]), { merge: false }); + const initialLeaf = host.querySelector("s") as HTMLElement; + const previewScene = scene as PolySceneHandle & { + commitBakedSolidLighting(): boolean; + }; + + scene.setOptions({ + directionalLight: { direction: [0, 0, 1], color: "#ffffff", intensity: 1 }, + }); + + expect(previewScene.commitBakedSolidLighting()).toBe(true); + expect(host.querySelector("s")).not.toBe(initialLeaf); + }); + it("honors strategies.disable at creation time", () => { scene = makeScene(host, { strategies: { disable: ["u"] } }); scene.add(makeParseResult([triangle()])); @@ -1813,7 +1899,8 @@ describe("createPolyScene", () => { const after = host.querySelector("u, b, i, s") as HTMLElement; expect(after).toBe(before); expect(handle.polygons[0].color).toBe("#0000ff"); - expect(after.style.color).not.toBe(""); + expect(after.style.getPropertyValue("--polycss-paint")).not.toBe(""); + expect(after.style.getPropertyValue("--psb")).toBe("1.0000"); }); it("updates data-only changes without replacing the leaf", () => { @@ -2180,29 +2267,93 @@ describe("createPolyScene", () => { expect(sceneEl.style.getPropertyValue("--clz")).toBe(""); }); - it("baked mode re-emits SVG shadows when directionalLight.direction changes", () => { + it("baked mode rewrites the same SVG shadow 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. + // SVG paths, so changing it must rewrite the existing SVG outline + // and translate3d without tearing down the mounted shadow node. scene = makeScene(host, { textureLighting: "baked", directionalLight: { direction: [0, 0, 1] }, }); scene.add(makeParseResult([triangle()]), { castShadow: true }); + const sceneEl = getSceneEl(host); const initialSvg = host.querySelector(".polycss-shadow") as SVGSVGElement; const initialTransform = initialSvg.style.transform; const initialPathD = initialSvg.querySelector("path")?.getAttribute("d"); + const observer = new MutationObserver(() => undefined); + observer.observe(sceneEl, { childList: true }); scene.setOptions({ directionalLight: { direction: [1, 0, 1] } }); + const records = observer.takeRecords(); + observer.disconnect(); const nextSvg = host.querySelector(".polycss-shadow") as SVGSVGElement; const nextTransform = nextSvg.style.transform; const nextPathD = nextSvg.querySelector("path")?.getAttribute("d"); + expect(nextSvg).toBe(initialSvg); + expect(records.some((record) => record.addedNodes.length > 0 || record.removedNodes.length > 0)).toBe(false); 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 preview rewrites the existing SVG shadow without changing scene options", () => { + 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"); + const initialDirection = scene.getOptions().directionalLight?.direction; + const previewScene = scene as PolySceneHandle & { + previewBakedSolidLighting(next: Pick): boolean; + clearBakedSolidLightingPreview(): void; + }; + + previewScene.previewBakedSolidLighting({ + 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(nextSvg).toBe(initialSvg); + expect(nextTransform !== initialTransform || nextPathD !== initialPathD).toBe(true); + expect(scene.getOptions().directionalLight?.direction).toBe(initialDirection); + + previewScene.clearBakedSolidLightingPreview(); + + expect(initialSvg.style.transform).toBe(initialTransform); + expect(initialSvg.querySelector("path")?.getAttribute("d")).toBe(initialPathD); + }); + + it("non-shadow helper movement does not overwrite baked preview shadows", () => { + scene = makeScene(host, { + textureLighting: "baked", + directionalLight: { direction: [0, 0, 1] }, + }); + scene.add(makeParseResult([triangle()]), { castShadow: true }); + const helper = scene.add(makeParseResult([triangle()])); + const initialSvg = host.querySelector(".polycss-shadow") as SVGSVGElement; + const initialTransform = initialSvg.style.transform; + const initialPathD = initialSvg.querySelector("path")?.getAttribute("d"); + const previewScene = scene as PolySceneHandle & { + previewBakedSolidLighting(next: Pick): boolean; + }; + + previewScene.previewBakedSolidLighting({ + directionalLight: { direction: [1, 0, 1] }, + }); + const previewTransform = initialSvg.style.transform; + const previewPathD = initialSvg.querySelector("path")?.getAttribute("d"); + helper.setTransform({ position: [10, 20, 30] }); + + expect(previewTransform !== initialTransform || previewPathD !== initialPathD).toBe(true); + expect(initialSvg.style.transform).toBe(previewTransform); + expect(initialSvg.querySelector("path")?.getAttribute("d")).toBe(previewPathD); + }); + 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 diff --git a/packages/polycss/src/api/createPolyScene.ts b/packages/polycss/src/api/createPolyScene.ts index ff42247c..53a35c7b 100644 --- a/packages/polycss/src/api/createPolyScene.ts +++ b/packages/polycss/src/api/createPolyScene.ts @@ -55,6 +55,8 @@ import { parseHexColor, polygonCssSurfaceNormal, projectCssVertexToGround, + parsePureColor, + shadePolygon, } from "@layoutit/polycss-core"; import { cssBorderShapeForPlan, @@ -86,6 +88,22 @@ import { injectPolyBaseStyles } from "../styles/styles"; // without changing the synchronous public setPolygons() contract. const ASYNC_MOUNT_BATCH_SIZE = 750; const DEFAULT_SCENE_PERSPECTIVE = 32000; +const BAKED_SOLID_PREVIEW_ACTIVE_VAR = "--polycss-light-preview-active"; +const BAKED_SOLID_PREVIEW_ACTIVE = `var(${BAKED_SOLID_PREVIEW_ACTIVE_VAR}, 0)`; +const BAKED_SOLID_PREVIEW_LAMBERT = + "max(0, calc(var(--pnx, 0) * var(--plx, 0) + var(--pny, 0) * var(--ply, 0) + var(--pnz, 1) * var(--plz, 1)))"; +const BAKED_SOLID_PREVIEW_R = + "calc(255 * var(--psr, 1) * (var(--par, 1) * var(--pai, 0.4) + var(--plr, 1) * var(--pli, 1) * var(--plam, 0)))"; +const BAKED_SOLID_PREVIEW_G = + "calc(255 * var(--psg, 1) * (var(--pag, 1) * var(--pai, 0.4) + var(--plg, 1) * var(--pli, 1) * var(--plam, 0)))"; +const BAKED_SOLID_PREVIEW_B = + "calc(255 * var(--psb, 1) * (var(--pab, 1) * var(--pai, 0.4) + var(--plb, 1) * var(--pli, 1) * var(--plam, 0)))"; +const LIGHTING_VAR_NAMES = [ + "--plx", "--ply", "--plz", + "--plr", "--plg", "--plb", "--pli", + "--par", "--pag", "--pab", "--pai", + "--clx", "--cly", "--clz", +] as const; function normalizeSceneOptions>>(options: T): T { if (!Object.prototype.hasOwnProperty.call(options, "seamBleed") || options.seamBleed !== undefined) { @@ -634,6 +652,8 @@ export function createPolyScene( cameraCullSignature: string; lightOverrideSignature: string; stableTriangleColorFrame: number; + solidLightingPreviewPrepared: boolean; + solidLightingPreviewActive: boolean; /** Rotation snapshot used by the baked atlas baker. Advances only when * `rebakeAtlas()` is called — not on every `setTransform`. */ bakedRotation: Vec3; @@ -652,13 +672,61 @@ export function createPolyScene( // 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[] = []; + // level. Surface elements are reused across light changes; only the + // SVG attributes/path data change. + let groundShadowSvg: SVGSVGElement | null = null; + let groundShadowPath: SVGPathElement | null = null; + let groundShadowVisible = false; + function disposeGroundShadow(): void { + if (groundShadowSvg?.parentNode) groundShadowSvg.parentNode.removeChild(groundShadowSvg); + groundShadowSvg = null; + groundShadowPath = null; + groundShadowVisible = false; + } + function hideGroundShadow(): void { + if (groundShadowSvg && groundShadowVisible) { + groundShadowSvg.style.display = "none"; + groundShadowVisible = false; + } + } + function ensureGroundShadow(): { svg: SVGSVGElement; path: SVGPathElement } { + const svgNS = "http://www.w3.org/2000/svg"; + let svg = groundShadowSvg; + let path = groundShadowPath; + if (!svg || !path) { + svg = doc.createElementNS(svgNS, "svg"); + svg.setAttribute("class", "polycss-shadow polycss-shadow-svg"); + svg.style.position = "absolute"; + svg.style.top = "0"; + svg.style.left = "0"; + svg.style.display = "block"; + svg.style.overflow = "hidden"; + svg.style.transformOrigin = "0 0"; + svg.style.pointerEvents = "none"; + svg.style.willChange = "transform"; + path = doc.createElementNS(svgNS, "path"); + path.setAttribute("fill-rule", "nonzero"); + path.setAttribute("stroke-width", "2"); + path.setAttribute("stroke-linejoin", "round"); + svg.appendChild(path); + groundShadowSvg = svg; + groundShadowPath = path; + const sceneFirst = sceneEl.firstChild; + if (sceneFirst) sceneEl.insertBefore(svg, sceneFirst); + else sceneEl.appendChild(svg); + } else if (!svg.parentNode) { + const sceneFirst = sceneEl.firstChild; + if (sceneFirst) sceneEl.insertBefore(svg, sceneFirst); + else sceneEl.appendChild(svg); + } + if (!groundShadowVisible) { + svg.style.display = "block"; + groundShadowVisible = true; + } + return { svg, path }; + } function clearAllSceneShadows(): void { - for (const svg of sceneShadowSvgs) { - if (svg.parentNode) svg.parentNode.removeChild(svg); - } - sceneShadowSvgs.length = 0; + disposeGroundShadow(); // 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 @@ -794,19 +862,19 @@ export function createPolyScene( // in the same frame there; the shadow projection works against 3D positions // that have already been through the axis swap, so it needs the light in // that same swapped frame. - function applyDynamicLightVars(el: HTMLElement, opts: Omit): void { - const dynamic = opts.textureLighting === "dynamic"; - el.dataset.polycssLighting = opts.textureLighting ?? "baked"; - const vars = [ - "--plx", "--ply", "--plz", - "--plr", "--plg", "--plb", "--pli", - "--par", "--pag", "--pab", "--pai", - "--clx", "--cly", "--clz", - ] as const; - if (!dynamic) { - for (const v of vars) el.style.removeProperty(v); - return; + function clearLightingVars(el: HTMLElement): void { + for (const v of LIGHTING_VAR_NAMES) { + if (el.style.getPropertyValue(v)) el.style.removeProperty(v); } + } + + function setStylePropertyIfChanged(el: HTMLElement, name: string, value: string): boolean { + if (el.style.getPropertyValue(name) === value) return false; + el.style.setProperty(name, value); + return true; + } + + function applyLightingVars(el: HTMLElement, opts: Omit): void { const dir = opts.directionalLight?.direction ?? [0.4, -0.7, 0.59]; const len = Math.hypot(dir[0], dir[1], dir[2]) || 1; const lx = dir[0] / len, ly = dir[1] / len, lz = dir[2] / len; @@ -815,17 +883,17 @@ export function createPolyScene( const lightIntensity = opts.directionalLight?.intensity ?? 1; const ambientIntensity = opts.ambientLight?.intensity ?? 0.4; const ch = (n: number) => (n / 255).toFixed(4); - el.style.setProperty("--plx", lx.toFixed(4)); - el.style.setProperty("--ply", ly.toFixed(4)); - el.style.setProperty("--plz", lz.toFixed(4)); - el.style.setProperty("--plr", ch(lightRgb[0])); - el.style.setProperty("--plg", ch(lightRgb[1])); - el.style.setProperty("--plb", ch(lightRgb[2])); - el.style.setProperty("--pli", lightIntensity.toFixed(4)); - el.style.setProperty("--par", ch(ambRgb[0])); - el.style.setProperty("--pag", ch(ambRgb[1])); - el.style.setProperty("--pab", ch(ambRgb[2])); - el.style.setProperty("--pai", ambientIntensity.toFixed(4)); + setStylePropertyIfChanged(el, "--plx", lx.toFixed(4)); + setStylePropertyIfChanged(el, "--ply", ly.toFixed(4)); + setStylePropertyIfChanged(el, "--plz", lz.toFixed(4)); + setStylePropertyIfChanged(el, "--plr", ch(lightRgb[0])); + setStylePropertyIfChanged(el, "--plg", ch(lightRgb[1])); + setStylePropertyIfChanged(el, "--plb", ch(lightRgb[2])); + setStylePropertyIfChanged(el, "--pli", lightIntensity.toFixed(4)); + setStylePropertyIfChanged(el, "--par", ch(ambRgb[0])); + setStylePropertyIfChanged(el, "--pag", ch(ambRgb[1])); + setStylePropertyIfChanged(el, "--pab", ch(ambRgb[2])); + setStylePropertyIfChanged(el, "--pai", ambientIntensity.toFixed(4)); // Light direction vars for the shadow projection. These match the // axis convention used by Lambert (`--plx/--ply/--plz`) where the // X and Y component naming follows the user-facing light direction @@ -836,9 +904,19 @@ export function createPolyScene( // shadows to infinity. const rawClz = lz; const clz = Math.sign(rawClz || 1) * Math.max(Math.abs(rawClz), 0.01); - el.style.setProperty("--clx", lx.toFixed(4)); - el.style.setProperty("--cly", ly.toFixed(4)); - el.style.setProperty("--clz", clz.toFixed(4)); + setStylePropertyIfChanged(el, "--clx", lx.toFixed(4)); + setStylePropertyIfChanged(el, "--cly", ly.toFixed(4)); + setStylePropertyIfChanged(el, "--clz", clz.toFixed(4)); + } + + function applyDynamicLightVars(el: HTMLElement, opts: Omit): void { + const dynamic = opts.textureLighting === "dynamic"; + el.dataset.polycssLighting = opts.textureLighting ?? "baked"; + if (!dynamic) { + clearLightingVars(el); + return; + } + applyLightingVars(el, opts); } function clearRendered(entry: MeshEntry): void { @@ -850,6 +928,8 @@ export function createPolyScene( entry.renderedByPolygonIndex = []; entry.cameraCullGroups = []; entry.cameraCullSignature = ""; + entry.solidLightingPreviewPrepared = false; + entry.solidLightingPreviewActive = false; clearShadowLeaves(entry); for (const child of Array.from(entry.wrapper.children)) { if (child instanceof HTMLElement && child.classList.contains("polycss-bucket")) { @@ -908,6 +988,8 @@ export function createPolyScene( entry.renderedByPolygonIndex[item.polygonIndex] = item; } entry.disposeAtlas = disposeAtlas; + entry.solidLightingPreviewPrepared = false; + prepareBakedSolidLightingPreview(entry); } function renderedItemForPolygon(entry: MeshEntry, polygonIndex: number): RenderedPoly | undefined { @@ -1321,10 +1403,174 @@ export function createPolyScene( textureLighting, renderOptions, ); + if (textureLighting === "baked") { + return applyBakedSolidPreviewPaint(item, polygon, shaded.shadedColor); + } applySolidPaint(item.element, shaded, textureLighting); return true; } + function bakedSolidPreviewPaintColor(bakedColor: string): string { + const parsed = parsePureColor(bakedColor) ?? { rgb: [255, 255, 255] as [number, number, number], alpha: 1 }; + const [r, g, b] = parsed.rgb; + const mix = (baked: number, previewVar: string) => + `calc(${baked} * (1 - ${BAKED_SOLID_PREVIEW_ACTIVE}) + var(${previewVar}, ${baked}) * ${BAKED_SOLID_PREVIEW_ACTIVE})`; + const alpha = parsed.alpha < 1 ? ` / ${parsed.alpha}` : ""; + return `rgb(${mix(r, "--polycss-preview-r")} ${mix(g, "--polycss-preview-g")} ${mix(b, "--polycss-preview-b")}${alpha})`; + } + + function applyBakedSolidPreviewPaint(item: RenderedPoly, polygon: Polygon, bakedColor: string): boolean { + if (!item.plan || item.kind === "atlas" || item.plan.texture) return false; + const el = item.element; + const normal = item.plan.normal; + const rgb = parseHex(polygon.color ?? "#cccccc"); + let changed = false; + changed = setStylePropertyIfChanged(el, "--pnx", normal[0].toFixed(4)) || changed; + changed = setStylePropertyIfChanged(el, "--pny", normal[1].toFixed(4)) || changed; + changed = setStylePropertyIfChanged(el, "--pnz", normal[2].toFixed(4)) || changed; + changed = setStylePropertyIfChanged(el, "--psr", (rgb.r / 255).toFixed(4)) || changed; + changed = setStylePropertyIfChanged(el, "--psg", (rgb.g / 255).toFixed(4)) || changed; + changed = setStylePropertyIfChanged(el, "--psb", (rgb.b / 255).toFixed(4)) || changed; + changed = setStylePropertyIfChanged(el, "--plam", BAKED_SOLID_PREVIEW_LAMBERT) || changed; + changed = setStylePropertyIfChanged(el, "--polycss-preview-r", BAKED_SOLID_PREVIEW_R) || changed; + changed = setStylePropertyIfChanged(el, "--polycss-preview-g", BAKED_SOLID_PREVIEW_G) || changed; + changed = setStylePropertyIfChanged(el, "--polycss-preview-b", BAKED_SOLID_PREVIEW_B) || changed; + changed = setStylePropertyIfChanged(el, "--polycss-paint", bakedSolidPreviewPaintColor(bakedColor)) || changed; + if (el.style.getPropertyValue("color")) { + el.style.removeProperty("color"); + changed = true; + } + return changed; + } + + function prepareBakedSolidLightingPreview(entry: MeshEntry): boolean { + if ((currentOptions.textureLighting ?? "baked") !== "baked") return false; + let prepared = false; + for (const item of entry.rendered) { + if (!item.plan || item.kind === "atlas" || item.plan.texture) continue; + const polygon = entry.polygons[item.polygonIndex]; + if (!polygon) continue; + applyBakedSolidPreviewPaint(item, polygon, item.plan.shadedColor); + prepared = true; + } + entry.solidLightingPreviewPrepared = prepared; + return prepared; + } + + function installBakedSolidLightingPreview(entry: MeshEntry): boolean { + if ((currentOptions.textureLighting ?? "baked") !== "baked") return false; + if (!entry.solidLightingPreviewPrepared && !prepareBakedSolidLightingPreview(entry)) return false; + entry.solidLightingPreviewActive = true; + return true; + } + + function bakedSolidColorForPlan(item: RenderedPoly, polygon: Polygon): string { + const directionalCfg = currentOptions.directionalLight; + const ambientCfg = currentOptions.ambientLight; + const lightDir = directionalCfg?.direction ?? [0.4, -0.7, 0.59] as Vec3; + const lightColor = directionalCfg?.color ?? "#ffffff"; + const lightIntensity = Math.max(0, directionalCfg?.intensity ?? 1); + const ambientColor = ambientCfg?.color ?? "#ffffff"; + const ambientIntensity = Math.max(0, ambientCfg?.intensity ?? 0.4); + const lLen = Math.hypot(lightDir[0], lightDir[1], lightDir[2]) || 1; + const normal = item.plan!.normal; + const directScale = lightIntensity * Math.max( + 0, + normal[0] * (lightDir[0] / lLen) + + normal[1] * (lightDir[1] / lLen) + + normal[2] * (lightDir[2] / lLen), + ); + return shadePolygon( + polygon.color ?? "#cccccc", + directScale, + lightColor, + ambientColor, + ambientIntensity, + ); + } + + function needsBakedAtlasCommit(item: RenderedPoly): boolean { + return item.kind === "atlas" || !!item.plan?.texture; + } + + function commitBakedSolidLighting(): boolean { + if ((currentOptions.textureLighting ?? "baked") !== "baked") return false; + let updated = false; + for (const entry of meshes) { + if (entry.rendered.some(needsBakedAtlasCommit)) { + renderEntry(entry); + updated = true; + continue; + } + for (const item of entry.rendered) { + if (!item.plan || item.kind === "atlas" || item.plan.texture) continue; + const polygon = entry.polygons[item.polygonIndex]; + if (!polygon) continue; + const color = bakedSolidColorForPlan(item, polygon); + updated = applyBakedSolidPreviewPaint(item, polygon, color) || updated; + } + } + sceneEl.style.removeProperty(BAKED_SOLID_PREVIEW_ACTIVE_VAR); + for (const entry of meshes) { + clearLightingVars(entry.wrapper); + entry.solidLightingPreviewActive = false; + } + clearLightingVars(sceneEl); + return updated; + } + + function clearBakedSolidLightingPreview(): void { + sceneEl.style.removeProperty(BAKED_SOLID_PREVIEW_ACTIVE_VAR); + for (const entry of meshes) { + if (!entry.solidLightingPreviewActive) continue; + clearLightingVars(entry.wrapper); + entry.solidLightingPreviewActive = false; + } + if ((currentOptions.textureLighting ?? "baked") !== "dynamic") { + clearLightingVars(sceneEl); + emitSceneShadows(); + } + } + + function applyPreviewMeshLightVars( + entry: MeshEntry, + next: Pick, "directionalLight" | "ambientLight">, + ): void { + const rotation = entry.handle.transform.rotation; + const dir = next.directionalLight?.direction ?? currentOptions.directionalLight?.direction; + const hasNonZeroRotation = rotation && (rotation[0] !== 0 || rotation[1] !== 0 || rotation[2] !== 0); + if (!hasNonZeroRotation || !dir) { + clearLightingVars(entry.wrapper); + return; + } + const localDir = inverseRotateVec3(dir as Vec3, rotation as Vec3); + applyLightingVars(entry.wrapper, { + ...currentOptions, + ...next, + directionalLight: { + ...currentOptions.directionalLight, + ...next.directionalLight, + direction: localDir, + }, + }); + } + + function previewBakedSolidLighting( + next: Pick, "directionalLight" | "ambientLight">, + ): boolean { + if ((currentOptions.textureLighting ?? "baked") !== "baked") return false; + applyLightingVars(sceneEl, { ...currentOptions, ...next }); + if (next.directionalLight?.direction) emitSceneShadows(next.directionalLight.direction as Vec3); + let installed = false; + for (const entry of meshes) { + applyPreviewMeshLightVars(entry, next); + installed = installBakedSolidLightingPreview(entry) || installed; + } + if (installed) setStylePropertyIfChanged(sceneEl, BAKED_SOLID_PREVIEW_ACTIVE_VAR, "1"); + else sceneEl.style.removeProperty(BAKED_SOLID_PREVIEW_ACTIVE_VAR); + return installed; + } + function tryUpdatePolygonColorOnly(entry: MeshEntry, polygonIndex: number, color: string | undefined): boolean { const polygon = entry.polygons[polygonIndex]; if (!polygon) return false; @@ -1387,23 +1633,26 @@ export function createPolyScene( 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(); + // Refreshes every shadow SVG in the scene. Iterates each SURFACE (the + // global ground + every receiver face) once, then sweeps every caster's + // projection onto that surface into the same compound path. Mounted SVG + // elements are reused across light changes; fill-rule=nonzero collapses + // overlapping CCW outlines into one filled silhouette per surface. + function emitSceneShadows(lightDirectionOverride?: Vec3): void { const casters: MeshEntry[] = []; for (const m of meshes) if (!m.disposed && m.castShadow) casters.push(m); - if (casters.length === 0) return; + if (casters.length === 0) { + clearAllSceneShadows(); + return; + } + hideAllReceiverFaceSvgs(); const shadowColor = currentOptions.shadow?.color ?? "#000000"; const shadowOpacity = currentOptions.shadow?.opacity ?? 0.25; const parsed = parseHexColor(shadowColor)?.rgb ?? [0, 0, 0]; const r = parsed[0], g = parsed[1], b = parsed[2]; - const lightDir = currentOptions.directionalLight?.direction + const lightDir = lightDirectionOverride + ?? currentOptions.directionalLight?.direction ?? ([0.4, -0.7, 0.59] as Vec3); // Per-caster shadow dedup (independent meshes can't dedup against @@ -1425,7 +1674,8 @@ export function createPolyScene( } if (currentGroundCssZ !== null) { - emitSceneGroundShadow(casters, dedupByCaster, lightDir, currentGroundCssZ, r, g, b, shadowOpacity); + const emittedGround = emitSceneGroundShadow(casters, dedupByCaster, lightDir, currentGroundCssZ, r, g, b, shadowOpacity); + if (!emittedGround) hideGroundShadow(); } for (const receiver of meshes) { if (receiver.disposed || !receiver.receiveShadow) continue; @@ -1451,7 +1701,7 @@ export function createPolyScene( groundCssZ: number, r: number, g: number, b: number, opacity: number, - ): void { + ): boolean { const polyProjections: Array> = []; let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity; let fpMinX = Infinity, fpMinY = Infinity, fpMaxX = -Infinity, fpMaxY = -Infinity; @@ -1507,7 +1757,7 @@ export function createPolyScene( } } - if (polyProjections.length === 0) return; + if (polyProjections.length === 0) return false; const maxExtend = currentOptions.shadow?.maxExtend ?? 2000; const bx0 = Math.max(minX, fpMinX - maxExtend); const by0 = Math.max(minY, fpMinY - maxExtend); @@ -1515,7 +1765,7 @@ export function createPolyScene( const by1 = Math.min(maxY, fpMaxY + maxExtend); const width = bx1 - bx0; const height = by1 - by0; - if (!(width > 0) || !(height > 0)) return; + if (!(width > 0) || !(height > 0)) return false; let d = ""; for (const verts of polyProjections) { @@ -1542,34 +1792,22 @@ export function createPolyScene( // 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"); + const { svg, path } = ensureGroundShadow(); + const widthStr = String(width); + const heightStr = String(height); + const viewBox = `0 0 ${width} ${height}`; + if (svg.getAttribute("width") !== widthStr) svg.setAttribute("width", widthStr); + if (svg.getAttribute("height") !== heightStr) svg.setAttribute("height", heightStr); + if (svg.getAttribute("viewBox") !== viewBox) svg.setAttribute("viewBox", viewBox); + const transform = `translate3d(${bx0.toFixed(3)}px,${by0.toFixed(3)}px,${groundCssZ.toFixed(3)}px)`; + if (svg.style.transform !== transform) svg.style.transform = transform; 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); + if (path.getAttribute("fill") !== fillColor) path.setAttribute("fill", fillColor); + if (path.getAttribute("stroke") !== fillColor) path.setAttribute("stroke", fillColor); + const opStr = opacity.toFixed(4); + if (path.getAttribute("opacity") !== opStr) path.setAttribute("opacity", opStr); + return true; } type ReceiverPlaneGroup = { @@ -1738,9 +1976,9 @@ export function createPolyScene( // 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). + // only when receiver polygons or position change. SVGs are created + // lazily the first time a face has shadow content, then hidden when + // later light positions do not project onto that face. const cacheKey = `${receiverEntry.polygons.length}|${rpos.join(",")}`; let cachedPlanes = receiverShadowCache.get(receiverEntry); if (cachedPlanes === undefined || receiverShadowCacheKey.get(receiverEntry) !== cacheKey) { @@ -2250,6 +2488,8 @@ export function createPolyScene( cameraCullSignature: "", lightOverrideSignature: "clear", stableTriangleColorFrame: 0, + solidLightingPreviewPrepared: false, + solidLightingPreviewActive: false, bakedRotation: (transformIn.rotation ? [...transformIn.rotation] : [0, 0, 0]) as Vec3, }; @@ -2589,10 +2829,9 @@ export function createPolyScene( // 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) { + // but non-shadow helpers (e.g. the light helper) must not overwrite + // transient preview shadows with the committed light. + if (t.position !== undefined && (entry.castShadow || entry.receiveShadow)) { recomputeShadowGround(); emitSceneShadows(); } @@ -2745,5 +2984,20 @@ export function createPolyScene( if (cameraEl.parentNode) cameraEl.parentNode.removeChild(cameraEl); } - return { add, setOptions, destroy, host, camera, cameraEl, applyCamera, getOptions, meshes: listMeshes, findMeshByElement }; + const handle = { + add, + setOptions, + destroy, + host, + camera, + cameraEl, + applyCamera, + getOptions, + meshes: listMeshes, + findMeshByElement, + previewBakedSolidLighting, + commitBakedSolidLighting, + clearBakedSolidLightingPreview, + }; + return handle; } diff --git a/packages/polycss/src/snapshot/exportPolySceneSnapshot.ts b/packages/polycss/src/snapshot/exportPolySceneSnapshot.ts index f8bb1ab6..b21df459 100644 --- a/packages/polycss/src/snapshot/exportPolySceneSnapshot.ts +++ b/packages/polycss/src/snapshot/exportPolySceneSnapshot.ts @@ -68,6 +68,10 @@ const LIGHTING_CUSTOM_PROPS = [ "--pnx", "--pny", "--pnz", "--psr", "--psg", "--psb", "--plam", + "--polycss-light-preview-active", + "--polycss-preview-r", + "--polycss-preview-g", + "--polycss-preview-b", ] as const; const SHADOW_CUSTOM_PROPS = [ "--clx", "--cly", "--clz", "--shadow-ground-cssz", @@ -551,7 +555,12 @@ function inlineSnapshotStaticStyleHints( } } else if (tag === "b" || tag === "i" || tag === "u") { const paint = inheritedInlineCustomProperty(sourceEl, "--polycss-paint"); - if (paint && !cloneStyle.getPropertyValue("color")) { + const computedColor = !features.hasDynamicLighting + ? sourceEl.ownerDocument.defaultView?.getComputedStyle(sourceEl).color + : ""; + if (computedColor) { + cloneStyle.setProperty("color", computedColor); + } else if (paint && !cloneStyle.getPropertyValue("color")) { cloneStyle.setProperty("color", paint); } } diff --git a/website/src/components/Dock/folders/useLightingFolder.ts b/website/src/components/Dock/folders/useLightingFolder.ts index d2b50ac9..c96af06f 100644 --- a/website/src/components/Dock/folders/useLightingFolder.ts +++ b/website/src/components/Dock/folders/useLightingFolder.ts @@ -52,7 +52,12 @@ export function useLightingFolder(parent: GUI | null, inputs: LightingFolderInpu const folder = useFolder(parent, "Lighting", { open: true }); - useToggle(folder, "Cast shadow", castShadow, (value) => onUpdateScene({ castShadow: value })); + useToggle(folder, "Cast shadow", castShadow, (value) => + onUpdateScene({ + castShadow: value, + ...(value ? { showGround: true } : null), + }), + ); useSlider( folder, "Shadow reach", diff --git a/website/src/components/GalleryWorkbench/GalleryWorkbench.tsx b/website/src/components/GalleryWorkbench/GalleryWorkbench.tsx index d9a61a1d..dad6f8e5 100644 --- a/website/src/components/GalleryWorkbench/GalleryWorkbench.tsx +++ b/website/src/components/GalleryWorkbench/GalleryWorkbench.tsx @@ -9,7 +9,7 @@ import type { } from "@layoutit/polycss"; import { exportPolySceneSnapshot, optimizeAnimatedMeshPolygons, parsePureColor } from "@layoutit/polycss"; import type { InspectorColorGroup, InspectorMesh } from "../Inspector"; -import { VanillaScene } from "../VanillaScene"; +import { VanillaScene, type VanillaSceneTransientHandle } from "../VanillaScene"; import { ReactScene } from "../ReactScene"; import { Dock, @@ -327,21 +327,36 @@ function elementScreenCenter(element: HTMLElement): ScreenPoint { return { x: (minX + maxX) / 2, y: (minY + maxY) / 2 }; } +function applyGalleryDebugDom(root: HTMLElement, options: SceneOptionsState): void { + applyDebugMatrixPrecision(root, options.matrixPrecision); + applyDebugBorderShapePrecision(root, options.borderShapePrecision); + applyDebugTriangleBrushPrecision(root); + applyDebugSolidColorHex(root); + applyDebugInlineStyleOrder(root); + applyDebugInlineStyleMinify(root); +} + function useLightRotationDrag( viewportRef: RefObject, sceneOptions: SceneOptionsState, helperScale: number, gizmoDragging: boolean, - onUpdateScene: (partial: Partial) => void, + onCommitScene: (partial: Partial) => void, + canPreviewSceneOptions: (options: SceneOptionsState) => boolean, + onPreviewSceneOptions: (options: SceneOptionsState) => void, ): void { const sceneOptionsRef = useRef(sceneOptions); const helperScaleRef = useRef(helperScale); const gizmoDraggingRef = useRef(gizmoDragging); - const onUpdateSceneRef = useRef(onUpdateScene); + const onCommitSceneRef = useRef(onCommitScene); + const canPreviewSceneOptionsRef = useRef(canPreviewSceneOptions); + const onPreviewSceneOptionsRef = useRef(onPreviewSceneOptions); sceneOptionsRef.current = sceneOptions; helperScaleRef.current = helperScale; gizmoDraggingRef.current = gizmoDragging; - onUpdateSceneRef.current = onUpdateScene; + onCommitSceneRef.current = onCommitScene; + canPreviewSceneOptionsRef.current = canPreviewSceneOptions; + onPreviewSceneOptionsRef.current = onPreviewSceneOptions; useEffect(() => { const viewport = viewportRef.current; @@ -351,6 +366,28 @@ function useLightRotationDrag( let helperTargetScreen = { x: 0, y: 0 }; let helperGrabOffset = { x: 0, y: 0 }; let helperRadiusCss = 1; + let previewRaf = 0; + let dragOptions: SceneOptionsState | null = null; + let pendingPreviewOptions: SceneOptionsState | null = null; + let pendingCommit: Pick | null = null; + + const flushPreview = (): void => { + previewRaf = 0; + const options = pendingPreviewOptions; + pendingPreviewOptions = null; + if (options) onPreviewSceneOptionsRef.current(options); + }; + + const schedulePreview = (options: SceneOptionsState): void => { + pendingPreviewOptions = options; + if (!previewRaf) previewRaf = requestAnimationFrame(flushPreview); + }; + + const cancelPreview = (): void => { + if (previewRaf) cancelAnimationFrame(previewRaf); + previewRaf = 0; + pendingPreviewOptions = null; + }; const helperDragEnabled = (): boolean => { const options = sceneOptionsRef.current; @@ -359,7 +396,16 @@ function useLightRotationDrag( const stopDrag = (event: PointerEvent): void => { if (activePointerId !== event.pointerId) return; + const commit = pendingCommit; + if (pendingPreviewOptions) { + if (previewRaf) cancelAnimationFrame(previewRaf); + flushPreview(); + } + if (commit) onCommitSceneRef.current(commit); activePointerId = null; + dragOptions = null; + pendingCommit = null; + cancelPreview(); viewport.classList.remove("is-light-rotating"); try { viewport.releasePointerCapture(event.pointerId); } catch { /* ignore */ } }; @@ -376,6 +422,8 @@ function useLightRotationDrag( event.stopPropagation(); activePointerId = event.pointerId; const options = sceneOptionsRef.current; + dragOptions = options; + pendingCommit = null; helperRadiusCss = Math.max(1, helperScaleRef.current * 0.7 * LIGHT_HELPER_TILE); const helperCenter = elementScreenCenter(helper); const currentOffset = projectLightDirectionToScreen( @@ -406,14 +454,25 @@ function useLightRotationDrag( x: event.clientX - helperGrabOffset.x, y: event.clientY - helperGrabOffset.y, }; - onUpdateSceneRef.current(lightAnglesFromScreenOffset( + const baseOptions = dragOptions ?? sceneOptionsRef.current; + const nextAngles = lightAnglesFromScreenOffset( { x: helperCenter.x - helperTargetScreen.x, y: helperCenter.y - helperTargetScreen.y, }, - sceneOptionsRef.current, + baseOptions, helperRadiusCss, - )); + ); + if (!canPreviewSceneOptionsRef.current(baseOptions)) { + onCommitSceneRef.current(nextAngles); + dragOptions = { ...baseOptions, ...nextAngles }; + pendingCommit = null; + return; + } + const nextOptions = { ...baseOptions, ...nextAngles }; + dragOptions = nextOptions; + pendingCommit = nextAngles; + schedulePreview(nextOptions); }; viewport.addEventListener("pointerdown", onPointerDown, { capture: true }); @@ -426,6 +485,7 @@ function useLightRotationDrag( viewport.removeEventListener("pointerup", stopDrag); viewport.removeEventListener("pointercancel", stopDrag); viewport.classList.remove("is-light-rotating"); + cancelPreview(); }; }, [viewportRef]); } @@ -647,7 +707,6 @@ export default function GalleryWorkbench() { const [loading, setLoading] = useState(false); const [selectedAnimation, setSelectedAnimation] = useState(""); const [metrics, setMetrics] = useState(EMPTY_METRICS); - const [vanillaBuildMs, setVanillaBuildMs] = useState(0); const [modelSearch, setModelSearch] = useState(""); const [openModelCategory, setOpenModelCategory] = useState(null); const [mobilePanel, setMobilePanel] = useState(null); @@ -682,16 +741,45 @@ export default function GalleryWorkbench() { // Inspector folder uses this to push color-group edits back into the // scene via setPolygons. Set by VanillaScene's onMeshHandleChange. const activeMeshHandleRef = useRef(null); + const transientSceneHandleRef = useRef(null); const [materialEditVersion, setMaterialEditVersion] = useState(0); // Vanilla selection state — kept separate from React's // `selectedMeshes` because vanilla MeshHandles aren't comparable to // React PolyMeshHandles. Stored as IDs since that's what both paths // can agree on for the toolbar display. const [, setVanillaSelectedIds] = useState([]); + const sceneOptionsRef = useRef(sceneOptions); + sceneOptionsRef.current = sceneOptions; + const domRefreshRafRef = useRef(0); + + const requestGalleryDomRefresh = useCallback(() => { + if (domRefreshRafRef.current) return; + domRefreshRafRef.current = requestAnimationFrame(() => { + domRefreshRafRef.current = 0; + const root = viewportRef.current; + if (!root) return; + applyGalleryDebugDom(root, sceneOptionsRef.current); + setMetrics(measureDom(root)); + }); + }, []); + + useEffect(() => { + return () => { + if (domRefreshRafRef.current) cancelAnimationFrame(domRefreshRafRef.current); + }; + }, []); const updateScene = useCallback((partial: Partial) => { setSceneOptions((current) => ({ ...current, ...partial })); }, []); + const canPreviewSceneOptions = useCallback( + (options: SceneOptionsState) => + options.renderer === "vanilla" && transientSceneHandleRef.current !== null, + [], + ); + const previewSceneOptions = useCallback((options: SceneOptionsState) => { + transientSceneHandleRef.current?.applyLightOptions(options); + }, []); const { handleCameraChange } = useGuiCameraSync({ setSceneOptions }); const responsiveZoomScale = useResponsiveViewportZoomScale(viewportRef); @@ -916,17 +1004,21 @@ export default function GalleryWorkbench() { reactAnimatedPolygons: animation.reactAnimatedPolygons, interiorFill: sceneOptions.interiorFill, }); - useLightRotationDrag(viewportRef, renderSceneOptions, helperScale, gizmoDragging, updateScene); + useLightRotationDrag( + viewportRef, + renderSceneOptions, + helperScale, + gizmoDragging, + updateScene, + canPreviewSceneOptions, + previewSceneOptions, + ); const renderModelPolygons = useMemo( () => sceneOptions.solidMaterials ? withSolidMaterials(modelPolygons, parserOptions.defaultColor) : modelPolygons, [modelPolygons, sceneOptions.solidMaterials, parserOptions.defaultColor], ); - const renderPolygons = useMemo( - () => renderModelPolygons, - [renderModelPolygons], - ); const hasSpriteLeaves = useMemo( () => metrics.sprites > 0 || scenePolygons.some(polygonHasTextureData), [metrics.sprites, scenePolygons], @@ -1025,64 +1117,11 @@ export default function GalleryWorkbench() { }); useEffect(() => { - const root = viewportRef.current; - if (!root) return; - let raf = 0; - const update = () => { - raf = 0; - setMetrics(measureDom(root)); - }; - const schedule = () => { - if (!raf) raf = requestAnimationFrame(update); - }; - schedule(); - const observer = new MutationObserver(schedule); - observer.observe(root, { - childList: true, - subtree: true, - }); - return () => { - observer.disconnect(); - if (raf) cancelAnimationFrame(raf); - }; - }, []); - - useEffect(() => { - const root = viewportRef.current; - if (!root) return; - let raf = 0; - const apply = () => { - raf = 0; - applyDebugMatrixPrecision(root, sceneOptions.matrixPrecision); - applyDebugBorderShapePrecision(root, sceneOptions.borderShapePrecision); - applyDebugTriangleBrushPrecision(root); - applyDebugSolidColorHex(root); - applyDebugInlineStyleOrder(root); - applyDebugInlineStyleMinify(root); - }; - const schedule = () => { - if (!raf) raf = requestAnimationFrame(apply); - }; - schedule(); - const observer = new MutationObserver(schedule); - observer.observe(root, { - childList: true, - subtree: true, - }); - return () => { - observer.disconnect(); - if (raf) cancelAnimationFrame(raf); - }; + requestGalleryDomRefresh(); }, [ + requestGalleryDomRefresh, sceneOptions.matrixPrecision, sceneOptions.borderShapePrecision, - sceneOptions.renderer, - sceneOptions.textureLighting, - sceneOptions.textureQuality, - sceneOptions.solidMaterials, - scenePolygons, - renderPolygons, - vanillaBuildMs, ]); const rendererDebugKey = useMemo( @@ -1178,9 +1217,10 @@ export default function GalleryWorkbench() { // an explicit merge flag reuses the mesh's current merge setting // (true for static models, false during animation playback). if (handle) handle.setPolygons(renderModelPolygons); + requestGalleryDomRefresh(); setMaterialEditVersion((version) => version + 1); }, - [renderModelPolygons], + [renderModelPolygons, requestGalleryDomRefresh], ); return ( @@ -1237,7 +1277,7 @@ export default function GalleryWorkbench() { animationKey={activeAnimation ? `${selectedAnimation}:${renderLoaded?.label ?? ""}` : undefined} animationDurationSeconds={activeAnimation?.duration} animationFrameFactory={vanillaAnimationFrameFactory} - onBuild={setVanillaBuildMs} + onSceneDomChange={requestGalleryDomRefresh} onCameraChange={handleRenderCameraChange} enableSelection={sceneOptions.selection} meshId={renderLoaded?.label ?? "model"} @@ -1246,6 +1286,7 @@ export default function GalleryWorkbench() { enableHover={sceneOptions.hoverEffects} onHoverChange={setHoveredMeshId} onMeshHandleChange={(h) => { activeMeshHandleRef.current = h; }} + onTransientHandleChange={(h) => { transientSceneHandleRef.current = h; }} /> ) : ( )} {loadError ? ( diff --git a/website/src/components/ReactScene/ReactScene.tsx b/website/src/components/ReactScene/ReactScene.tsx index cf26cf5f..b38070c6 100644 --- a/website/src/components/ReactScene/ReactScene.tsx +++ b/website/src/components/ReactScene/ReactScene.tsx @@ -1,4 +1,4 @@ -import { useMemo, type RefObject } from "react"; +import { useEffect, useMemo, type RefObject } from "react"; import { PolyAxesHelper, PolyOrthographicCamera, @@ -101,6 +101,7 @@ export interface ReactSceneProps { gizmoMode: GizmoMode; helperScale: number; helperTarget: [number, number, number]; + onSceneDomChange?: () => void; } export function ReactScene({ @@ -127,6 +128,7 @@ export function ReactScene({ gizmoMode, helperScale, helperTarget, + onSceneDomChange, }: ReactSceneProps) { const Cam = sceneOptions.perspective === false ? PolyOrthographicCamera : PolyPerspectiveCamera; const camProps = sceneOptions.perspective === false @@ -163,6 +165,23 @@ export function ReactScene({ !meshResolutionShowsMesh(sceneOptions.meshResolution) ? "is-mesh-hidden" : "", sceneOptions.hoverEffects && hoveredMeshId === (loaded?.label ?? "model") ? "is-hovered" : "", ].filter(Boolean).join(" "); + useEffect(() => { + onSceneDomChange?.(); + }, [ + onSceneDomChange, + rendererDebugKey, + visibleScenePolygons, + interiorShellPolygons, + sceneOptions.disableStrategies, + sceneOptions.meshResolution, + sceneOptions.selection, + selectedMeshes.length, + sceneOptions.showAxes, + sceneOptions.showLight, + helperScale, + directionalLight.color, + textureQuality, + ]); return ( {sceneOptions.dragMode === "pan" ? ( diff --git a/website/src/components/StatsOverlay/StatsOverlay.tsx b/website/src/components/StatsOverlay/StatsOverlay.tsx index ba3ade18..04758cd5 100644 --- a/website/src/components/StatsOverlay/StatsOverlay.tsx +++ b/website/src/components/StatsOverlay/StatsOverlay.tsx @@ -1,13 +1,22 @@ import { useEffect, useRef } from "react"; import Stats from "stats-js/src/Stats.js"; +const FPS_SAMPLE_MS = 1000; +const MS_SAMPLE_MS = 500; + export function StatsOverlay(): null { const frameRef = useRef(null); useEffect(() => { const mobileQuery = window.matchMedia("(max-width: 760px)"); let statsContainer: HTMLDivElement | null = null; - let stats: Stats[] = []; + let fpsPanel: Stats.StatsPanel | null = null; + let msPanel: Stats.StatsPanel | null = null; + let lastFrame = 0; + let fpsSampleStart = 0; + let msSampleStart = 0; + let fpsFrameCount = 0; + let maxFrameMs = 0; const stop = () => { if (frameRef.current !== null) { @@ -16,13 +25,20 @@ export function StatsOverlay(): null { } statsContainer?.remove(); statsContainer = null; - stats = []; + fpsPanel = null; + msPanel = null; }; const start = () => { if (statsContainer) return; + lastFrame = performance.now(); + fpsSampleStart = lastFrame; + msSampleStart = lastFrame; + fpsFrameCount = 0; + maxFrameMs = 0; statsContainer = document.createElement("div"); statsContainer.className = "dn-stats-overlay"; + statsContainer.setAttribute("aria-hidden", "true"); statsContainer.style.position = "fixed"; statsContainer.style.right = "12px"; statsContainer.style.bottom = "12px"; @@ -31,21 +47,35 @@ export function StatsOverlay(): null { statsContainer.style.left = "auto"; statsContainer.style.display = "flex"; statsContainer.style.alignItems = "flex-end"; + statsContainer.style.gap = "2px"; + statsContainer.style.opacity = "0.9"; + statsContainer.style.pointerEvents = "none"; - stats = [0, 1, 2].map((mode) => { - const stat = new Stats(); - stat.setMode(mode); - stat.dom.style.position = "static"; - stat.dom.style.pointerEvents = "none"; - statsContainer!.appendChild(stat.dom); - return stat; - }); + fpsPanel = new Stats.Panel("FPS", "#0ff", "#002"); + msPanel = new Stats.Panel("MS", "#0f0", "#020"); + statsContainer.append(fpsPanel.dom, msPanel.dom); document.body.appendChild(statsContainer); - const tick = () => { - for (const stat of stats) { - stat.update(); + const tick = (now: number) => { + const frameMs = Math.max(0, now - lastFrame); + lastFrame = now; + fpsFrameCount += 1; + if (frameMs > maxFrameMs) maxFrameMs = frameMs; + + const msElapsed = now - msSampleStart; + if (msElapsed >= MS_SAMPLE_MS && statsContainer) { + msPanel?.update(maxFrameMs, 200); + msSampleStart = now; + maxFrameMs = 0; + } + + const fpsElapsed = now - fpsSampleStart; + if (fpsElapsed >= FPS_SAMPLE_MS && statsContainer) { + const fps = (fpsFrameCount * 1000) / fpsElapsed; + fpsPanel?.update(fps, 100); + fpsSampleStart = now; + fpsFrameCount = 0; } frameRef.current = requestAnimationFrame(tick); }; diff --git a/website/src/components/StatsOverlay/stats-js.d.ts b/website/src/components/StatsOverlay/stats-js.d.ts index d7c1d7b9..9270cbd0 100644 --- a/website/src/components/StatsOverlay/stats-js.d.ts +++ b/website/src/components/StatsOverlay/stats-js.d.ts @@ -1,18 +1,21 @@ declare module "stats-js/src/Stats.js" { - interface StatsPanel { - dom: HTMLCanvasElement; - update(value: number, maxValue: number): void; + namespace Stats { + interface StatsPanel { + dom: HTMLCanvasElement; + update(value: number, maxValue: number): void; + } } - export default class Stats { - REVISION: number; + interface StatsInstance { dom: HTMLDivElement; - domElement: HTMLDivElement; - addPanel(panel: StatsPanel): StatsPanel; - showPanel(id: number): void; - setMode(id: number): void; - begin(): void; - end(): number; update(): void; } + + interface StatsConstructor { + new (): StatsInstance; + Panel: new (name: string, fg: string, bg: string) => Stats.StatsPanel; + } + + const Stats: StatsConstructor; + export default Stats; } diff --git a/website/src/components/VanillaScene/VanillaScene.tsx b/website/src/components/VanillaScene/VanillaScene.tsx index 2153de9f..838f2aac 100644 --- a/website/src/components/VanillaScene/VanillaScene.tsx +++ b/website/src/components/VanillaScene/VanillaScene.tsx @@ -34,6 +34,13 @@ export type { GizmoMode, SceneOptionsState }; // Light helper world units → CSS pixels conversion (matches the helper // components in @layoutit/polycss-react and @layoutit/polycss-vue). const LIGHT_HELPER_TILE = 50; +// Keep the visible ground just below the model floor; coplanar ground/car +// faces z-fight during repaint-heavy light drags. +const GROUND_Z_OFFSET = -0.04; +// The shadow plane should sit above the visible ground, not above the model +// floor, otherwise large live-updated SVG shadows can intersect low geometry. +const SHADOW_GROUND_LIFT = 0.01; +const GALLERY_SHADOW_LIFT = GROUND_Z_OFFSET + SHADOW_GROUND_LIFT; const ANIMATION_STABLE_TRIANGLE_COLOR_POLICY = "cadence"; // Deforming low-poly triangles can swing face normals sharply; keep the // mounted baked color pinned and animate transforms only. @@ -179,6 +186,54 @@ function lightHelperPosition( ]; } +function directionalFromOptions(options: SceneOptionsState): PolyDirectionalLight { + const az = (options.lightAzimuth * Math.PI) / 180; + const el = (options.lightElevation * Math.PI) / 180; + const cosEl = Math.cos(el); + return { + direction: [ + cosEl * Math.sin(az), + cosEl * Math.cos(az), + Math.sin(el), + ], + color: options.lightColor, + intensity: options.lightIntensity, + }; +} + +function ambientFromOptions(options: SceneOptionsState): PolyAmbientLight { + return { + color: options.ambientColor, + intensity: options.ambientIntensity, + }; +} + +function bakedLightingSignature( + directionalLight: PolyDirectionalLight, + ambientLight: PolyAmbientLight, +): string { + return [ + directionalLight.direction.map((value) => value.toFixed(4)).join(","), + directionalLight.color ?? "", + directionalLight.intensity ?? "", + ambientLight.color ?? "", + ambientLight.intensity ?? "", + ].join("|"); +} + +export interface VanillaSceneTransientHandle { + applySceneOptions(options: SceneOptionsState): void; + applyLightOptions(options: SceneOptionsState): void; +} + +type BakedSolidLightingPreviewSceneHandle = PolySceneHandle & { + previewBakedSolidLighting?: (next: { + directionalLight?: PolyDirectionalLight; + ambientLight?: PolyAmbientLight; + }) => boolean; + commitBakedSolidLighting?: () => boolean; +}; + export interface VanillaSceneProps { polygons: Polygon[]; interiorShellPolygons: Polygon[]; @@ -196,7 +251,7 @@ export interface VanillaSceneProps { animationKey?: string; animationDurationSeconds?: number; animationFrameFactory?: (timeSeconds: number) => Polygon[]; - onBuild: (ms: number) => void; + onBuild?: (ms: number) => void; onCameraChange?: (camera: { rotX: number; rotY: number; zoom: number; target?: ReactVec3 }) => void; enableSelection?: boolean; meshId?: string; @@ -205,6 +260,8 @@ export interface VanillaSceneProps { enableHover?: boolean; onHoverChange?: (id: string | null) => void; onMeshHandleChange?: (handle: VanillaPolyMeshHandle | null) => void; + onTransientHandleChange?: (handle: VanillaSceneTransientHandle | null) => void; + onSceneDomChange?: () => void; } export function VanillaScene({ @@ -233,6 +290,8 @@ export function VanillaScene({ enableHover, onHoverChange, onMeshHandleChange, + onTransientHandleChange, + onSceneDomChange, }: VanillaSceneProps) { const hostRef = useRef(null); const sceneRef = useRef(null); @@ -245,6 +304,7 @@ export function VanillaScene({ const groundHandleRef = useRef(null); const selectionRef = useRef(null); const transformControlsRef = useRef(null); + const committedBakedLightingRef = useRef(""); const onBuildRef = useRef(onBuild); onBuildRef.current = onBuild; const onCameraChangeRef = useRef(onCameraChange); @@ -255,6 +315,17 @@ export function VanillaScene({ onHoverChangeRef.current = onHoverChange; const onMeshHandleChangeRef = useRef(onMeshHandleChange); onMeshHandleChangeRef.current = onMeshHandleChange; + const onTransientHandleChangeRef = useRef(onTransientHandleChange); + onTransientHandleChangeRef.current = onTransientHandleChange; + const onSceneDomChangeRef = useRef(onSceneDomChange); + onSceneDomChangeRef.current = onSceneDomChange; + const helperScaleRef = useRef(helperScale); + helperScaleRef.current = helperScale; + const helperTargetRef = useRef(helperTarget); + helperTargetRef.current = helperTarget; + const notifySceneDomChange = useCallback(() => { + onSceneDomChangeRef.current?.(); + }, []); const animationPausedRef = useRef(options.animationPaused); animationPausedRef.current = options.animationPaused; const animationTimeScaleRef = useRef(options.animationTimeScale); @@ -274,15 +345,67 @@ export function VanillaScene({ } }, []); - // Split things into "structural" (require destroying the scene) vs - // "incremental" (can be applied via setOptions / setTransform). In - // dynamic mode the chicken's atlas is light-independent, so we drop the - // light from the structural deps — sliding the light then only flows - // through the cheap setOptions effect, no flicker. - const stableDirectionalForRebuild = - options.textureLighting === "dynamic" ? null : directionalLight; - const stableAmbientForRebuild = - options.textureLighting === "dynamic" ? null : ambientLight; + const applyTransientLightOptions = useCallback((nextOptions: SceneOptionsState): void => { + const scene = sceneRef.current; + if (!scene) return; + const nextDirectionalLight = directionalFromOptions(nextOptions); + if (nextOptions.textureLighting === "dynamic") { + scene.setOptions({ directionalLight: nextDirectionalLight }); + } else { + (scene as BakedSolidLightingPreviewSceneHandle).previewBakedSolidLighting?.({ + directionalLight: nextDirectionalLight, + ambientLight: ambientFromOptions(nextOptions), + }); + } + lightHandleRef.current?.setTransform({ + position: lightHelperPosition( + nextDirectionalLight, + helperTargetRef.current, + helperScaleRef.current * 0.7, + ), + }); + }, []); + + const applyTransientSceneOptions = useCallback((nextOptions: SceneOptionsState): void => { + const scene = sceneRef.current; + const camera = cameraRef.current; + if (!scene || !camera) return; + const nextDirectionalLight = directionalFromOptions(nextOptions); + camera.update({ + rotX: nextOptions.rotX, + rotY: nextOptions.rotY, + zoom: nextOptions.zoom, + target: nextOptions.target as Vec3, + }); + scene.applyCamera(); + scene.setOptions({ + directionalLight: nextDirectionalLight, + ambientLight: ambientFromOptions(nextOptions), + textureLighting: nextOptions.textureLighting, + shadow: { maxExtend: nextOptions.shadowMaxExtend, lift: GALLERY_SHADOW_LIFT }, + }); + lightHandleRef.current?.setTransform({ + position: lightHelperPosition( + nextDirectionalLight, + helperTargetRef.current, + helperScaleRef.current * 0.7, + ), + }); + }, []); + + useEffect(() => { + onTransientHandleChangeRef.current?.({ + applySceneOptions: applyTransientSceneOptions, + applyLightOptions: applyTransientLightOptions, + }); + return () => onTransientHandleChangeRef.current?.(null); + }, [applyTransientLightOptions, applyTransientSceneOptions]); + + // Split structural options (require destroying the scene) from incremental + // lighting/camera options. Baked light changes commit solid colors in place; + // rebuilding the whole mesh on mouseup causes visible polygon flicker. + const stableDirectionalForRebuild = null; + const stableAmbientForRebuild = null; // Effect 1 — heavy: create the scene + add the current polygons once. // Polygon replacement is handled by Effect 1.5 so animation frames do not @@ -312,10 +435,11 @@ export function VanillaScene({ autoCenter: options.autoCenter, textureQuality: options.textureQuality, strategies: { disable: options.disableStrategies }, - shadow: { maxExtend: options.shadowMaxExtend }, + shadow: { maxExtend: options.shadowMaxExtend, lift: GALLERY_SHADOW_LIFT }, }; const scene = createPolyScene(host, sceneOptions); sceneRef.current = scene; + committedBakedLightingRef.current = bakedLightingSignature(directionalLight, ambientLight); const meshTransform = { merge: mergePolygonsForMesh, stableDom: stableDomForMesh, @@ -344,6 +468,7 @@ export function VanillaScene({ meshHandleRef.current.element.classList.add("dn-model-mesh"); meshHandleRef.current.element.classList.toggle("is-mesh-hidden", !meshResolutionShowsMesh(options.meshResolution)); onMeshHandleChangeRef.current?.(meshHandleRef.current); + notifySceneDomChange(); return () => { // Tear controls down BEFORE destroying the scene — otherwise the // controls' rAF tick could fire one more time against a stale handle. @@ -369,6 +494,7 @@ export function VanillaScene({ stableAmbientForRebuild, stableDomForMesh, parseResult, + notifySceneDomChange, ]); useEffect(() => { @@ -436,14 +562,16 @@ export function VanillaScene({ } requestAnimationFrame(() => - onBuildRef.current(performance.now() - started), + onBuildRef.current?.(performance.now() - started), ); + notifySceneDomChange(); }, [ polygons, interiorShellPolygons, mergePolygonsForMesh, stableDomForMesh, mountChildMeshInsideModel, + notifySceneDomChange, ]); // Effect 1.6 — live-toggle castShadow without rebuilding the scene. @@ -464,6 +592,7 @@ export function VanillaScene({ transformControlsRef.current?.destroy(); transformControlsRef.current = null; onSelectionChangeRef.current?.([]); + notifySceneDomChange(); return; } const scene = sceneRef.current; @@ -482,11 +611,13 @@ export function VanillaScene({ }, }); selectionRef.current = select; + notifySceneDomChange(); return () => { select.destroy(); tc.destroy(); selectionRef.current = null; transformControlsRef.current = null; + notifySceneDomChange(); }; }, [ enableSelection, @@ -500,6 +631,7 @@ export function VanillaScene({ stableAmbientForRebuild, stableDomForMesh, parseResult, + notifySceneDomChange, ]); // Forward gizmo mode changes to the live PolyTransformControls handle. @@ -696,8 +828,18 @@ export function VanillaScene({ directionalLight, ambientLight, textureLighting: options.textureLighting, - shadow: { maxExtend: options.shadowMaxExtend }, + shadow: { maxExtend: options.shadowMaxExtend, lift: GALLERY_SHADOW_LIFT }, }); + const nextLightingSignature = bakedLightingSignature(directionalLight, ambientLight); + if ( + options.textureLighting === "baked" && + committedBakedLightingRef.current !== nextLightingSignature + ) { + (scene as BakedSolidLightingPreviewSceneHandle).commitBakedSolidLighting?.(); + committedBakedLightingRef.current = nextLightingSignature; + } else if (options.textureLighting !== "baked") { + committedBakedLightingRef.current = nextLightingSignature; + } }, [ options.rotX, options.rotY, @@ -719,7 +861,8 @@ export function VanillaScene({ scene.setOptions({ strategies: { disable: options.disableStrategies }, }); - }, [options.disableStrategies]); + notifySceneDomChange(); + }, [options.disableStrategies, notifySceneDomChange]); // Effect 2.5 — vanilla controls. The React renderer wires interactive + // animate through ; the vanilla path uses createPolyOrbitControls. @@ -835,8 +978,11 @@ export function VanillaScene({ const scene = sceneRef.current; if (!scene) return; if (!showAxes) { - axesHandleRef.current?.dispose(); - axesHandleRef.current = null; + if (axesHandleRef.current) { + axesHandleRef.current.dispose(); + axesHandleRef.current = null; + notifySceneDomChange(); + } return; } axesHandleRef.current = scene.add( @@ -848,9 +994,11 @@ export function VanillaScene({ }, { excludeFromAutoCenter: true }, ); + notifySceneDomChange(); return () => { axesHandleRef.current?.dispose(); axesHandleRef.current = null; + notifySceneDomChange(); }; }, [ showAxes, @@ -863,6 +1011,7 @@ export function VanillaScene({ stableDirectionalForRebuild, stableAmbientForRebuild, parseResult, + notifySceneDomChange, ]); // Effect 3.5 — ground receiver. A flat quad in the XY plane (Z is "up" @@ -875,8 +1024,11 @@ export function VanillaScene({ const scene = sceneRef.current; if (!scene) return; if (!showGround || polygons.length === 0) { - groundHandleRef.current?.dispose(); - groundHandleRef.current = null; + if (groundHandleRef.current) { + groundHandleRef.current.dispose(); + groundHandleRef.current = null; + notifySceneDomChange(); + } return; } let minX = Infinity, minY = Infinity, minZ = Infinity; @@ -889,15 +1041,18 @@ export function VanillaScene({ } } if (!Number.isFinite(minZ)) { - groundHandleRef.current?.dispose(); - groundHandleRef.current = null; + if (groundHandleRef.current) { + groundHandleRef.current.dispose(); + groundHandleRef.current = null; + notifySceneDomChange(); + } return; } const span = Math.max(maxX - minX, maxY - minY, 1); const pad = span * 1.5; const cx = (minX + maxX) / 2; const cy = (minY + maxY) / 2; - const z = minZ; + const z = minZ + GROUND_Z_OFFSET; const groundPoly: Polygon = { vertices: [ [cx - pad, cy - pad, z], @@ -918,9 +1073,11 @@ export function VanillaScene({ }, { excludeFromAutoCenter: true, castShadow: false }, ); + notifySceneDomChange(); return () => { groundHandleRef.current?.dispose(); groundHandleRef.current = null; + notifySceneDomChange(); }; }, [ showGround, @@ -932,6 +1089,7 @@ export function VanillaScene({ stableDirectionalForRebuild, stableAmbientForRebuild, parseResult, + notifySceneDomChange, ]); // Effect 4 — light helper. Octahedron at LOCAL origin so polygons stay @@ -941,8 +1099,11 @@ export function VanillaScene({ const scene = sceneRef.current; if (!scene) return; if (!showLight) { - lightHandleRef.current?.dispose(); - lightHandleRef.current = null; + if (lightHandleRef.current) { + lightHandleRef.current.dispose(); + lightHandleRef.current = null; + notifySceneDomChange(); + } return; } const swatch = directionalLight.color ?? "#ffd54a"; @@ -964,9 +1125,11 @@ export function VanillaScene({ ); handle.element.classList.add("dn-light-helper"); lightHandleRef.current = handle; + notifySceneDomChange(); return () => { lightHandleRef.current?.dispose(); lightHandleRef.current = null; + notifySceneDomChange(); }; // directionalLight.color triggers a remount because the swatch is // baked into polygon data; direction is handled by Effect 5 below. @@ -983,6 +1146,7 @@ export function VanillaScene({ stableDirectionalForRebuild, stableAmbientForRebuild, parseResult, + notifySceneDomChange, ]); // Effect 5 — slide the light helper to the new orbit position whenever