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
48 changes: 48 additions & 0 deletions packages/core/src/merge/mergePolygons.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand All @@ -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++) {
Expand Down Expand Up @@ -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 ──────────────────────────────────────────────────────────────
Expand Down Expand Up @@ -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", () => {
Expand Down
9 changes: 5 additions & 4 deletions packages/core/src/merge/mergePolygons.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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[];
Expand Down Expand Up @@ -194,7 +195,7 @@ function mergeAlongEdge(
}
}
if (cleaned.length < 3) return null;
return { vertices: cleaned, uvs: cleanedUvs };
return rotateToNonCollinearStart(cleaned, cleanedUvs);
}

/**
Expand All @@ -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;
}
}
Expand Down Expand Up @@ -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;
Expand Down
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
44 changes: 44 additions & 0 deletions packages/polycss/src/render/polyDOM.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)));
}
Expand Down Expand Up @@ -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 = {
Expand Down
Loading