From bfb17f2c37f4a433541f3ab90e7af10961656eae Mon Sep 17 00:00:00 2001 From: agustin-littlehat Date: Sat, 16 May 2026 20:09:38 -0300 Subject: [PATCH 1/2] fix: compensate scene transforms for css zoom --- .../polycss/src/api/createPolyScene.test.ts | 20 +++++++ packages/polycss/src/api/createPolyScene.ts | 55 +++++++++++++++++-- 2 files changed, 69 insertions(+), 6 deletions(-) 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); } From f98de98bbe403e29e27e5b24d93b234dcd1e5f8c Mon Sep 17 00:00:00 2001 From: agustin-littlehat Date: Sat, 16 May 2026 20:35:47 -0300 Subject: [PATCH 2/2] fix: reject non-renderable quad merges --- packages/core/src/merge/mergePolygons.test.ts | 48 +++++++++++++++++++ packages/core/src/merge/mergePolygons.ts | 9 ++-- packages/polycss/src/render/polyDOM.test.ts | 44 +++++++++++++++++ 3 files changed, 97 insertions(+), 4 deletions(-) diff --git a/packages/core/src/merge/mergePolygons.test.ts b/packages/core/src/merge/mergePolygons.test.ts index ec36414a..57b4821a 100644 --- a/packages/core/src/merge/mergePolygons.test.ts +++ b/packages/core/src/merge/mergePolygons.test.ts @@ -2,6 +2,7 @@ import { describe, it, expect } from "vitest"; import { readFileSync } from "fs"; import { resolve } from "path"; import { mergePolygons } from "./mergePolygons"; +import { parseGltf } from "../parser/parseGltf"; import { parseObj } from "../parser/parseObj"; import { parseMtl } from "../parser/parseMtl"; import type { Polygon, Vec2, Vec3 } from "../types"; @@ -15,6 +16,31 @@ function loadObjGalleryFile(name: string): string { ); } +function loadGlbGalleryFile(...parts: string[]): ArrayBuffer { + const buffer = readFileSync( + resolve(__dirname, "../../../../website/public/gallery/glb", ...parts), + ); + return buffer.buffer.slice(buffer.byteOffset, buffer.byteOffset + buffer.byteLength); +} + +const subVec = (a: Vec3, b: Vec3): Vec3 => [a[0] - b[0], a[1] - b[1], a[2] - b[2]]; +const crossVec = (a: Vec3, b: Vec3): Vec3 => [ + a[1] * b[2] - a[2] * b[1], + a[2] * b[0] - a[0] * b[2], + a[0] * b[1] - a[1] * b[0], +]; +const dotVec = (a: Vec3, b: Vec3): number => a[0] * b[0] + a[1] * b[1] + a[2] * b[2]; + +function maxPlaneDeviation(verts: Vec3[]): number { + if (verts.length < 4) return 0; + const normal = crossVec(subVec(verts[1], verts[0]), subVec(verts[2], verts[0])); + const len = Math.hypot(normal[0], normal[1], normal[2]); + if (len <= 1e-12) return Number.POSITIVE_INFINITY; + const unit: Vec3 = [normal[0] / len, normal[1] / len, normal[2] / len]; + const d = dotVec(unit, verts[0]); + return Math.max(...verts.map((vertex) => Math.abs(dotVec(unit, vertex) - d))); +} + function fanArea(verts: Vec3[]): number { let total = 0; for (let i = 1; i < verts.length - 1; i++) { @@ -90,6 +116,19 @@ describe("mergePolygons — real fixture (chicken.obj)", () => { expect(firstTriangleArea(polygon.vertices)).toBeGreaterThan(1e-9); } }); + + it("does not merge the Apple GLB into non-renderable solid quads", () => { + const parsed = parseGltf(loadGlbGalleryFile("apple.glb"), { + targetSize: 60, + defaultColor: "#cccccc", + }); + const merged = mergePolygons(parsed.polygons); + const solidQuads = merged.filter((polygon) => !polygon.texture && polygon.vertices.length === 4); + + expect(solidQuads.length).toBeGreaterThan(0); + expect(Math.max(...solidQuads.map((polygon) => maxPlaneDeviation(polygon.vertices)))) + .toBeLessThanOrEqual(1e-3); + }); }); // ── Fixtures ────────────────────────────────────────────────────────────── @@ -190,6 +229,15 @@ describe("mergePolygons", () => { const result = mergePolygons([TRI_A, TRI_B, TRI_C, TRI_D]); expect(result).toHaveLength(2); }); + + it("near-coplanar triangles are not merged into a non-renderable solid quad", () => { + const a: Polygon = { vertices: [[0,0,0],[1,0,0],[1,1,0]], color: "#ff0000" }; + const b: Polygon = { vertices: [[0,0,0],[1,1,0],[0,1,0.01]], color: "#ff0000" }; + + const result = mergePolygons([a, b]); + expect(result).toHaveLength(2); + expect(result.every((polygon) => polygon.vertices.length === 3)).toBe(true); + }); }); describe("texture matching", () => { diff --git a/packages/core/src/merge/mergePolygons.ts b/packages/core/src/merge/mergePolygons.ts index f4981bdd..b1308b80 100644 --- a/packages/core/src/merge/mergePolygons.ts +++ b/packages/core/src/merge/mergePolygons.ts @@ -30,6 +30,7 @@ import type { Polygon, TextureTriangle, Vec2, Vec3 } from "../types"; const EPS_NORMAL = 1e-3; // dot product tolerance for "same plane" const EPS_DISTANCE = 0.05; // signed-distance tolerance (in scene-space units) const EPS_TEXTURE_DISTANCE = 1e-3; +const EPS_RENDER_DISTANCE = 1e-3; interface PolyState { vertices: Vec3[]; @@ -194,7 +195,7 @@ function mergeAlongEdge( } } if (cleaned.length < 3) return null; - return { vertices: cleaned, uvs: cleanedUvs }; + return rotateToNonCollinearStart(cleaned, cleanedUvs); } /** @@ -220,12 +221,12 @@ function isConvex(vertices: Vec3[], normal: Vec3): boolean { return true; } -function texturedMergeIsPlanar(vertices: Vec3[]): boolean { +function mergeIsPlanar(vertices: Vec3[], epsilon: number): boolean { if (vertices.length < 3) return false; const plane = planeOf(vertices); if (!plane) return false; for (const vertex of vertices) { - if (Math.abs(dot(plane.normal, vertex) - plane.d) > EPS_TEXTURE_DISTANCE) { + if (Math.abs(dot(plane.normal, vertex) - plane.d) > epsilon) { return false; } } @@ -390,7 +391,7 @@ export function mergePolygons(input: Polygon[]): Polygon[] { const merged = mergeAlongEdge(a, b, e0, e1); if (!merged) continue; - if (hasTexture && !texturedMergeIsPlanar(merged.vertices)) continue; + if (!mergeIsPlanar(merged.vertices, hasTexture ? EPS_TEXTURE_DISTANCE : EPS_RENDER_DISTANCE)) continue; if (!isConvex(merged.vertices, a.normal)) continue; a.vertices = merged.vertices; diff --git a/packages/polycss/src/render/polyDOM.test.ts b/packages/polycss/src/render/polyDOM.test.ts index c818b898..3af9691e 100644 --- a/packages/polycss/src/render/polyDOM.test.ts +++ b/packages/polycss/src/render/polyDOM.test.ts @@ -76,6 +76,21 @@ function extractMatrix(el: HTMLElement): number[] { return match[1].split(",").map(Number); } +function transformMatrixPoint(matrix: number[], x: number, y: number, z = 0): [number, number, number] { + const w = matrix[3] * x + matrix[7] * y + matrix[11] * z + matrix[15]; + return [ + (matrix[0] * x + matrix[4] * y + matrix[8] * z + matrix[12]) / w, + (matrix[1] * x + matrix[5] * y + matrix[9] * z + matrix[13]) / w, + (matrix[2] * x + matrix[6] * y + matrix[10] * z + matrix[14]) / w, + ]; +} + +function expectPointClose(actual: [number, number, number], expected: [number, number, number]): void { + expect(actual[0]).toBeCloseTo(expected[0], 2); + expect(actual[1]).toBeCloseTo(expected[1], 2); + expect(actual[2]).toBeCloseTo(expected[2], 2); +} + function roundedMatrix(values: number[], decimals = 3): number[] { return values.map((value) => Number(value.toFixed(decimals))); } @@ -1292,6 +1307,35 @@ describe("renderPolygonsWithTextureAtlas — strategies.disable", () => { result.dispose(); }); + it("projective b matrix maps planar quad corners to their CSS-space vertices", () => { + const doc = { + defaultView: { + CSS: { supports: () => false }, + __polycssProjectiveQuadGuards: { bleed: 0 }, + }, + createElement(tagName: string) { + return document.createElement(tagName); + }, + } as unknown as Document; + + const result = renderPolygonsWithTextureAtlas( + [NON_RECT_QUAD], + { doc }, + ); + const matrix = extractMatrix(result.rendered[0].element); + const expected = NON_RECT_QUAD.vertices.map((vertex): [number, number, number] => [ + vertex[1] * 50, + vertex[0] * 50, + vertex[2] * 50, + ]); + + expectPointClose(transformMatrixPoint(matrix, 0, 0), expected[0]); + expectPointClose(transformMatrixPoint(matrix, 1, 0), expected[1]); + expectPointClose(transformMatrixPoint(matrix, 1, 1), expected[2]); + expectPointClose(transformMatrixPoint(matrix, 0, 1), expected[3]); + result.dispose(); + }); + it("moderately projective solid quads stay on the CSS matrix b path", () => { const canvases: Array<{ width: number; height: number; getContext: () => null }> = []; const doc = {