diff --git a/packages/polycss/src/api/createPolyScene.test.ts b/packages/polycss/src/api/createPolyScene.test.ts index 486679a3..2237f19c 100644 --- a/packages/polycss/src/api/createPolyScene.test.ts +++ b/packages/polycss/src/api/createPolyScene.test.ts @@ -2165,6 +2165,32 @@ describe("createPolyScene", () => { expect((d.match(/Z/g) || []).length).toBe(1); }); + it("clips low-angle ground shadow path coordinates to the capped SVG box", () => { + scene = makeScene(host, { + textureLighting: "baked", + directionalLight: { direction: [1, 0, 0.01] }, + shadow: { maxExtend: 20 }, + }); + scene.add(makeParseResult([sideTriangle()]), { castShadow: true, merge: false }); + + const shadow = host.querySelector(".polycss-shadow") as SVGSVGElement; + const width = Number(shadow.getAttribute("width")); + const height = Number(shadow.getAttribute("height")); + const d = shadow.querySelector("path")?.getAttribute("d") ?? ""; + const values = (d.match(/-?\d+(?:\.\d+)?/g) ?? []).map(Number); + + expect(width).toBeGreaterThan(0); + expect(width).toBeLessThanOrEqual(40); + expect(height).toBeGreaterThan(0); + expect(values.length).toBeGreaterThan(0); + for (let i = 0; i < values.length; i += 2) { + expect(values[i]).toBeGreaterThanOrEqual(-0.001); + expect(values[i]).toBeLessThanOrEqual(width + 0.001); + expect(values[i + 1]).toBeGreaterThanOrEqual(-0.001); + expect(values[i + 1]).toBeLessThanOrEqual(height + 0.001); + } + }); + 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 diff --git a/packages/polycss/src/api/createPolyScene.ts b/packages/polycss/src/api/createPolyScene.ts index aac9f36f..aceb9d4a 100644 --- a/packages/polycss/src/api/createPolyScene.ts +++ b/packages/polycss/src/api/createPolyScene.ts @@ -1759,15 +1759,24 @@ export function createPolyScene( const height = by1 - by0; if (!(width > 0) || !(height > 0)) return false; + const clipBounds: Array<[number, number]> = [ + [bx0, by0], + [bx1, by0], + [bx1, by1], + [bx0, by1], + ]; let d = ""; for (const verts of polyProjections) { - const ccw = ensureCcw2D(verts); + const clipped = clipPolygonToConvex2D(ensureCcw2D(verts), clipBounds); + if (clipped.length < 3) continue; + const ccw = ensureCcw2D(clipped); 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"; } + if (!d) return false; // (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 diff --git a/website/src/components/GalleryWorkbench/GalleryWorkbench.tsx b/website/src/components/GalleryWorkbench/GalleryWorkbench.tsx index 41940768..220ff3e8 100644 --- a/website/src/components/GalleryWorkbench/GalleryWorkbench.tsx +++ b/website/src/components/GalleryWorkbench/GalleryWorkbench.tsx @@ -164,6 +164,8 @@ const LIGHT_HELPER_SELECTOR = ".dn-light-helper"; const RESPONSIVE_ZOOM_BREAKPOINT = 900; const RESPONSIVE_ZOOM_BOTTOM_RESERVE = 72; const RESPONSIVE_ZOOM_MIN_SCALE = 0.42; +const RESPONSIVE_SHADOW_EXTEND_BASE = 1600; +const RESPONSIVE_SHADOW_EXTEND_MIN = 600; function clamp(value: number, min: number, max: number): number { if (!Number.isFinite(value)) return min; @@ -184,6 +186,15 @@ function responsiveZoomScaleForViewport(width: number, height: number): number { return clamp(Math.min(widthScale, heightScale), RESPONSIVE_ZOOM_MIN_SCALE, 1); } +function responsiveShadowMaxExtend(value: number, viewportScale: number): number { + if (viewportScale >= 0.995) return value; + const cap = Math.max( + RESPONSIVE_SHADOW_EXTEND_MIN, + Math.round(RESPONSIVE_SHADOW_EXTEND_BASE * viewportScale), + ); + return Math.min(value, cap); +} + function initialResponsiveZoomScale(): number { if (typeof window === "undefined") return 1; return responsiveZoomScaleForViewport(window.innerWidth, window.innerHeight); @@ -806,10 +817,12 @@ export default function GalleryWorkbench() { const { handleCameraChange } = useGuiCameraSync({ setSceneOptions }); const responsiveZoomScale = useResponsiveViewportZoomScale(viewportRef); const renderSceneOptions = useMemo(() => { - if (responsiveZoomScale === 1) return sceneOptions; + const shadowMaxExtend = responsiveShadowMaxExtend(sceneOptions.shadowMaxExtend, responsiveZoomScale); + if (responsiveZoomScale === 1 && shadowMaxExtend === sceneOptions.shadowMaxExtend) return sceneOptions; return { ...sceneOptions, zoom: sceneOptions.zoom * responsiveZoomScale, + shadowMaxExtend, }; }, [sceneOptions, responsiveZoomScale]); const handleRenderCameraChange = useCallback(