diff --git a/packages/polycss/src/api/createPolyScene.test.ts b/packages/polycss/src/api/createPolyScene.test.ts index 76f19b1e..bde533a5 100644 --- a/packages/polycss/src/api/createPolyScene.test.ts +++ b/packages/polycss/src/api/createPolyScene.test.ts @@ -136,6 +136,26 @@ describe("createPolyScene", () => { expect(transform).toContain("rotate(60deg)"); }); + it("folds host CSS zoom into the emitted scene transform", () => { + host.style.setProperty("zoom", "0.5"); + scene = createPolyScene(host, { + distance: 100, + perspective: 1500, + rotX: 30, + rotY: 60, + zoom: 2, + }); + const sceneEl = host.querySelector(".polycss-scene") as HTMLElement; + const transform = sceneEl.style.transform; + expect(sceneEl.style.perspective).toBe("750px"); + expect(sceneEl.style.getPropertyValue("zoom")).toBe("2"); + expect(transform).toContain("translateZ(-50px)"); + expect(transform).toContain("scale(1)"); + expect(transform).toContain("rotateX(30deg)"); + expect(transform).toContain("rotate(60deg)"); + expect(scene.getOptions().zoom).toBe(2); + }); + it("inlines a large finite perspective when perspective is false (orthographic)", () => { scene = createPolyScene(host, { perspective: false }); const sceneEl = host.querySelector(".polycss-scene") as HTMLElement; diff --git a/packages/polycss/src/api/createPolyScene.ts b/packages/polycss/src/api/createPolyScene.ts index 7daff6ed..c0945a60 100644 --- a/packages/polycss/src/api/createPolyScene.ts +++ b/packages/polycss/src/api/createPolyScene.ts @@ -53,6 +53,7 @@ import { injectPolyBaseStyles } from "../styles/styles"; // keeps large gallery meshes below Chrome's long-task warning threshold // without changing the synchronous public setPolygons() contract. const ASYNC_MOUNT_BATCH_SIZE = 750; +const DEFAULT_SCENE_PERSPECTIVE = 8000; export interface PolySceneOptions { perspective?: number | false; @@ -302,11 +303,12 @@ function buildMeshTransform(t: PolyMeshTransform): string | undefined { function buildSceneTransform( opts: PolySceneOptions, autoCenterOffset: Vec3 = [0, 0, 0], + layoutScale = 1, ): string { const rotX = opts.rotX ?? DEFAULT_ROT_X; const rotY = opts.rotY ?? DEFAULT_ROT_Y; - const zoom = opts.zoom ?? DEFAULT_ZOOM; - const distance = opts.distance ?? 0; + const zoom = (opts.zoom ?? DEFAULT_ZOOM) * layoutScale; + const distance = (opts.distance ?? 0) * layoutScale; const target = opts.target ?? [0, 0, 0]; // World→CSS axis swap: world[0]→CSS Y, world[1]→CSS X, world[2]→CSS Z. // Negate so the scene moves such that `target + autoCenterOffset` appears @@ -332,6 +334,41 @@ function buildSceneTransform( return `${distancePart}scale(${zoom}) rotateX(${rotX}deg) rotate(${rotY}deg) translate3d(${-cssX}px, ${-cssY}px, ${-cssZ}px)`; } +function parseCssZoom(value: string): number { + const text = value.trim(); + if (!text || text === "normal") return 1; + const numeric = text.endsWith("%") + ? Number(text.slice(0, -1)) / 100 + : Number(text); + return Number.isFinite(numeric) && numeric > 0 ? numeric : 1; +} + +function effectiveCssZoom(element: HTMLElement): number { + const win = element.ownerDocument?.defaultView; + if (!win) return 1; + + let zoom = 1; + for (let current: HTMLElement | null = element; current; current = current.parentElement) { + zoom *= parseCssZoom(win.getComputedStyle(current).getPropertyValue("zoom")); + } + return Number.isFinite(zoom) && zoom > 0 ? zoom : 1; +} + +function scaledCssPixels(value: number, scale: number): number { + return scale === 1 ? value : value * scale; +} + +function applyCssZoomCompensation(el: HTMLElement, scale: number): void { + // Chromium's CSS zoom can scale layout metrics without scaling the + // preserve-3d rasterization path consistently. Neutralize zoom on the scene + // root, then fold the same scale into the matrix/perspective explicitly. + if (Math.abs(scale - 1) < 1e-6) { + el.style.removeProperty("zoom"); + } else { + el.style.setProperty("zoom", String(1 / scale)); + } +} + // ─── Lambert-bucket grouping ──────────────────────────────────────────────── // For dynamic-mode scenes: group polygons by quantized face normal + color // into wrapper divs. The wrapper has the bucket's normal as inline CSS @@ -451,9 +488,11 @@ export function createPolyScene( const meshes = new Set(); function applySceneStyle(el: HTMLElement, opts: PolySceneOptions): void { - el.style.transform = buildSceneTransform(opts, autoCenterOffset); + const layoutScale = effectiveCssZoom(host); + applyCssZoomCompensation(el, layoutScale); + el.style.transform = buildSceneTransform(opts, autoCenterOffset, layoutScale); if (typeof opts.perspective === "number") { - el.style.perspective = `${opts.perspective}px`; + el.style.perspective = `${scaledCssPixels(opts.perspective, layoutScale)}px`; } else if (opts.perspective === false) { // Orthographic projection — true `perspective: none` triggers a Chrome // compositor fast path that mis-rasterizes border-triangle leaves @@ -461,9 +500,13 @@ export function createPolyScene( // at initial paint. A very large finite perspective is visually // indistinguishable from orthographic (no perceptible foreshortening at // this distance) but routes Chrome through the normal compositor path. - el.style.perspective = "1000000px"; + el.style.perspective = `${scaledCssPixels(1000000, layoutScale)}px`; } else { - el.style.removeProperty("perspective"); + if (Math.abs(layoutScale - 1) < 1e-6) { + el.style.removeProperty("perspective"); + } else { + el.style.perspective = `${scaledCssPixels(DEFAULT_SCENE_PERSPECTIVE, layoutScale)}px`; + } } applyDynamicLightVars(el, opts); }