Skip to content
This repository was archived by the owner on May 19, 2026. It is now read-only.
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 20 additions & 0 deletions packages/polycss/src/api/createPolyScene.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
55 changes: 49 additions & 6 deletions packages/polycss/src/api/createPolyScene.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -451,19 +488,25 @@ export function createPolyScene(
const meshes = new Set<MeshEntry>();

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 <u> border-triangle leaves
// (0×0 layout box with asymmetric borders): holes and dropped fragments
// 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);
}
Expand Down
Loading