Skip to content
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
6 changes: 6 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -128,6 +128,12 @@ React or Vue wrappers.
prepared packages, mounts one retained PolyCSS graph, and exposes
caller-driven runtimes for morphs, controls, springs, animation, skinning,
and prepared playback.
- Sparse deformation supports retained solid triangles and affine solid quads.
Browsers where PolyCSS enables projective quad compositing also accept planar
projective solid quads. Quad updates recompute one CSS `matrix3d(...)` per
dirty leaf; deformation rejects non-coplanar, non-convex, or
compositor-unstable geometry, and the retained mount rejects projective quad
matrices on unsupported Safari-family browsers before DOM writes.
- `createPolyMorphPreparedDomTarget` adopts a caller-owned retained graph as
source-ordered model, shape, and leaf targets. It tracks requested values for
sparse write deduplication, preserves element identity, invalidates writers
Expand Down
10 changes: 10 additions & 0 deletions packages/core/src/atlas/strategy.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -342,6 +342,16 @@ describe("safariCssProjectiveUnsupported — UA sniff", () => {
expect(safariCssProjectiveUnsupported(ua)).toBe(true);
});

it("returns true for iOS Chrome because it still identifies as AppleWebKit", () => {
const ua = "Mozilla/5.0 (iPhone; CPU iPhone OS 18_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/128.0.6613.98 Mobile/15E148 Safari/604.1";
expect(safariCssProjectiveUnsupported(ua)).toBe(true);
});

it("returns true for an embedded WKWebView user agent", () => {
const ua = "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko)";
expect(safariCssProjectiveUnsupported(ua)).toBe(true);
});

it("returns false for Edge (Chromium-based) UA", () => {
const ua = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36 Edg/120.0.0.0";
expect(safariCssProjectiveUnsupported(ua)).toBe(false);
Expand Down
5 changes: 4 additions & 1 deletion packages/core/src/atlas/strategy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -74,7 +74,10 @@ export function isProjectiveQuadPlan(entry: TextureAtlasPlan): entry is TextureA
export function safariCssProjectiveUnsupported(userAgent: string): boolean {
const isChromiumFamily = /\b(?:Chrome|HeadlessChrome|Chromium|Edg|OPR)\//.test(userAgent);
const isSafariFamily = /\bVersion\/[\d.]+.*\bSafari\//.test(userAgent);
return isSafariFamily && !isChromiumFamily;
const isApplePlatform = /\b(?:Macintosh|iPad|iPhone|iPod)\b/.test(userAgent);
const isAppleWebKitFamily = isApplePlatform
&& /\bAppleWebKit\/[\d.]+/.test(userAgent);
return (isSafariFamily || isAppleWebKitFamily) && !isChromiumFamily;
}

export function incrementCount(map: Map<string, number>, key: string): void {
Expand Down
6 changes: 6 additions & 0 deletions packages/morph/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -94,6 +94,12 @@ mounted model keeps the same leaf elements for its lifetime. Runtime updates do
not rebuild topology, add or remove leaves, construct image resources, or
redraw prepared image resources.

Sparse deformation supports retained solid triangles and affine solid quads.
Planar projective solid quads are available where PolyCSS enables projective
quad compositing. Deformation rejects non-coplanar, non-convex, or
compositor-unstable geometry, and the retained mount rejects projective quad
matrices on unsupported Safari-family browsers before DOM writes.

Morph chooses the triangle paint path once when it mounts. It uses
`corner-shape` where available, a larger CSS border triangle in Firefox, and
each leaf's prepared polygon-sized atlas slice in WebKit/Safari. Mount creates
Expand Down
200 changes: 194 additions & 6 deletions packages/morph/src/render/mount.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,50 @@ function createTwoLeafFixture() {
return fixture;
}

function createSolidQuadFixture() {
const fixture = clonePolyMorphFixture(createPolyMorphModelFixture());
fixture.topology.vertices.push([1, 1, 0]);
fixture.topology.normals.push([0, 0, 1]);
fixture.topology.polygons[0]!.vertexIndices = [0, 1, 3, 2];
fixture.topology.polygons[0]!.normalIndices = [0, 1, 3, 2];
fixture.render.leaves[0]!.strategy = "solid-quad";
fixture.render.leaves[0]!.width = 64;
fixture.render.leaves[0]!.height = 64;
return fixture;
}

function projectiveMatrix() {
const value = [...POLY_MORPH_IDENTITY_MATRIX] as number[];
value[3] = 0.25 / 64;
return value as typeof POLY_MORPH_IDENTITY_MATRIX;
}

const SAFARI_UA =
"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.0 Safari/605.1.15";
const IOS_CHROME_UA =
"Mozilla/5.0 (iPhone; CPU iPhone OS 18_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/128.0.6613.98 Mobile/15E148 Safari/604.1";
const WKWEBVIEW_UA =
"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko)";
const CHROME_UA =
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/128.0.0.0 Safari/537.36";

function parsedMatrix(element: HTMLElement): number[] {
const match = /^matrix3d\(([^)]+)\)$/u.exec(element.style.transform);
if (!match) throw new TypeError("expected matrix3d transform");
return match[1]!.split(",").map(Number);
}

function applyMatrix(
matrix: readonly number[],
[x, y]: readonly [number, number],
): readonly [number, number] {
const w = matrix[3]! * x + matrix[7]! * y + matrix[15]!;
return [
(matrix[0]! * x + matrix[4]! * y + matrix[12]!) / w,
(matrix[1]! * x + matrix[5]! * y + matrix[13]!) / w,
];
}

describe("mountPolyMorphModel", () => {
let host: HTMLElement;

Expand Down Expand Up @@ -112,6 +156,154 @@ describe("mountPolyMorphModel", () => {
});
});

it("applies projective solid quad matrices on supported browsers", () => {
const restoreUserAgent = overrideUserAgent(CHROME_UA);
try {
const mounted = mountPolyMorphModel(host, createSolidQuadFixture());
const element = mounted.leafHandles.get("gem-panel-leaf")!.element;

expect(mounted.apply({
leaves: [{ leafId: "gem-panel-leaf", matrix: projectiveMatrix() }],
}).leafTransformWrites).toBe(1);
expect(element.style.transform).toContain("0.00390625");
} finally {
restoreUserAgent();
}
});

it("preserves projective terms smaller than six decimal places", () => {
const restoreUserAgent = overrideUserAgent(CHROME_UA);
try {
const fixture = createSolidQuadFixture();
const leaf = fixture.render.leaves[0]!;
const size = 10_000;
const h = -40 / 10_040;
leaf.width = size;
leaf.height = size;
const value = [...POLY_MORPH_IDENTITY_MATRIX] as number[];
value[5] = 1 + h;
value[7] = h / size;
const mounted = mountPolyMorphModel(host, fixture);
const element = mounted.leafHandles.get("gem-panel-leaf")!.element;

mounted.apply({
leaves: [{
leafId: "gem-panel-leaf",
matrix: value as typeof POLY_MORPH_IDENTITY_MATRIX,
}],
});

const emitted = parsedMatrix(element);
expect(emitted[7]).toBe(value[7]);
const corner = applyMatrix(emitted, [size, size]);
expect(corner[0]).toBeCloseTo(10_040, 10);
expect(corner[1]).toBeCloseTo(10_000, 10);
} finally {
restoreUserAgent();
}
});

it.each([
["Safari", SAFARI_UA],
["iOS Chrome", IOS_CHROME_UA],
["WKWebView", WKWEBVIEW_UA],
])("fails before mounting projective solid quads on %s", (_name, userAgent) => {
const restoreUserAgent = overrideUserAgent(userAgent);
try {
const fixture = createSolidQuadFixture();
fixture.render.leaves[0]!.matrix = projectiveMatrix();

expect(() => mountPolyMorphModel(host, fixture))
.toThrowError(PolyMorphRenderError);
expect(host.childElementCount).toBe(0);
} finally {
restoreUserAgent();
}
});

it("rejects projective solid quad updates on Safari before writes", () => {
const restoreUserAgent = overrideUserAgent(SAFARI_UA);
try {
const mounted = mountPolyMorphModel(host, createSolidQuadFixture());
const element = mounted.leafHandles.get("gem-panel-leaf")!.element;
const transform = element.style.transform;
const applyCount = mounted.stats.applyCount;

expect(() => mounted.apply({
leaves: [{ leafId: "gem-panel-leaf", matrix: projectiveMatrix() }],
})).toThrowError(PolyMorphRenderError);
expect(element.style.transform).toBe(transform);
expect(mounted.stats.applyCount).toBe(applyCount);
} finally {
restoreUserAgent();
}
});

it("rejects projective leaves on Safari regardless of topology arity", () => {
const restoreUserAgent = overrideUserAgent(SAFARI_UA);
try {
const initial = createTwoLeafFixture();
initial.render.leaves[1]!.matrix = projectiveMatrix();
expect(() => mountPolyMorphModel(host, initial))
.toThrowError(PolyMorphRenderError);
expect(host.childElementCount).toBe(0);

const mounted = mountPolyMorphModel(host, createTwoLeafFixture());
const element = mounted.leafHandles.get("accent-panel-leaf")!.element;
const transform = element.style.transform;
const applyCount = mounted.stats.applyCount;
expect(() => mounted.apply({
leaves: [{ leafId: "accent-panel-leaf", matrix: projectiveMatrix() }],
})).toThrowError(PolyMorphRenderError);
expect(element.style.transform).toBe(transform);
expect(mounted.stats.applyCount).toBe(applyCount);
} finally {
restoreUserAgent();
}
});

it("checks the resolved fallback matrix before mounting on Safari", () => {
const view = document.defaultView as Window & {
CSS?: { supports(property: string, value: string): boolean };
};
const priorCss = view.CSS;
Object.defineProperty(view, "CSS", {
configurable: true,
value: { supports: () => false },
});
const restoreUserAgent = overrideUserAgent(SAFARI_UA);
try {
const fixture = createSolidQuadFixture();
const leaf = fixture.render.leaves[0]!;
leaf.strategy = "solid-triangle";
leaf.fallback = {
width: 64,
height: 64,
matrixFromLeaf: projectiveMatrix(),
atlas: {
resourcePath: "assets/projective-fallback.png",
x: 0,
y: 0,
width: 64,
height: 64,
pageWidth: 64,
pageHeight: 64,
},
};

expect(() => mountPolyMorphModel(host, fixture))
.toThrowError(PolyMorphRenderError);
expect(host.childElementCount).toBe(0);
expect(document.defaultView!.URL.createObjectURL).not.toHaveBeenCalled();
} finally {
restoreUserAgent();
Object.defineProperty(view, "CSS", {
configurable: true,
value: priorCss,
});
}
});

it("mounts polygon-sized slices across prepared pages when corner triangles are unavailable", () => {
const view = document.defaultView as Window & {
CSS?: { supports(property: string, value: string): boolean };
Expand All @@ -121,9 +313,7 @@ describe("mountPolyMorphModel", () => {
configurable: true,
value: { supports: () => false },
});
const restoreUserAgent = overrideUserAgent(
"Mozilla/5.0 Version/18.0 Safari/605.1.15",
);
const restoreUserAgent = overrideUserAgent(SAFARI_UA);
try {
const fixture = createTwoLeafFixture();
fixture.render.leaves[1]!.strategy = "solid-triangle";
Expand Down Expand Up @@ -219,9 +409,7 @@ describe("mountPolyMorphModel", () => {
configurable: true,
value: { supports: () => false },
});
const restoreUserAgent = overrideUserAgent(
"Mozilla/5.0 Version/18.0 Safari/605.1.15",
);
const restoreUserAgent = overrideUserAgent(SAFARI_UA);
try {
expect(() =>
mountPolyMorphModel(host, createPolyMorphModelFixture())
Expand Down
Loading
Loading