diff --git a/AGENTS.md b/AGENTS.md index cc84c030..b4226375 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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 diff --git a/packages/core/src/atlas/strategy.test.ts b/packages/core/src/atlas/strategy.test.ts index 5d85e5e7..b2065cab 100644 --- a/packages/core/src/atlas/strategy.test.ts +++ b/packages/core/src/atlas/strategy.test.ts @@ -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); diff --git a/packages/core/src/atlas/strategy.ts b/packages/core/src/atlas/strategy.ts index 9fd2c408..64d30d6b 100644 --- a/packages/core/src/atlas/strategy.ts +++ b/packages/core/src/atlas/strategy.ts @@ -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, key: string): void { diff --git a/packages/morph/README.md b/packages/morph/README.md index 95d18435..82e92dcc 100644 --- a/packages/morph/README.md +++ b/packages/morph/README.md @@ -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 diff --git a/packages/morph/src/render/mount.test.ts b/packages/morph/src/render/mount.test.ts index c0bcb670..6bc054a8 100644 --- a/packages/morph/src/render/mount.test.ts +++ b/packages/morph/src/render/mount.test.ts @@ -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; @@ -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 }; @@ -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"; @@ -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()) diff --git a/packages/morph/src/render/mount.ts b/packages/morph/src/render/mount.ts index 4118570e..e7eaaf94 100644 --- a/packages/morph/src/render/mount.ts +++ b/packages/morph/src/render/mount.ts @@ -5,6 +5,7 @@ import { formatMatrix3dValues, injectPolyBaseStyles, isSolidTriangleSupported, + safariCssProjectiveUnsupported, } from "@layoutit/polycss"; import { validatePolyMorphModel, @@ -30,6 +31,8 @@ const TAG_BY_STRATEGY = { "solid-triangle": "u", } as const; +const PROJECTIVE_MATRIX_EPSILON = 1e-12; + function fail(code: string, path: string, message: string): never { throw new PolyMorphRenderError(code, path, message); } @@ -45,8 +48,34 @@ function matrix(value: unknown, path: string): PolyMorphMat4 { return value as unknown as PolyMorphMat4; } -function matrixText(value: PolyMorphMat4): string { - return `matrix3d(${formatMatrix3dValues(value)})`; +function exactMatrix3dValues(value: PolyMorphMat4): string { + return value.map((component) => + String(Object.is(component, -0) ? 0 : component)).join(","); +} + +function matrixText(value: PolyMorphMat4, exact = false): string { + return `matrix3d(${exact + ? exactMatrix3dValues(value) + : formatMatrix3dValues(value)})`; +} + +function resolvedLeafDimensions( + leaf: PolyMorphRenderLeaf, + useFallback: boolean, +): readonly [number, number] { + return useFallback && leaf.fallback + ? [leaf.fallback.width, leaf.fallback.height] + : [leaf.width, leaf.height]; +} + +function hasProjectiveLeafTerms( + value: PolyMorphMat4, + leaf: PolyMorphRenderLeaf, + useFallback: boolean, +): boolean { + const [width, height] = resolvedLeafDimensions(leaf, useFallback); + return Math.abs(value[3] * width) > PROJECTIVE_MATRIX_EPSILON + || Math.abs(value[7] * height) > PROJECTIVE_MATRIX_EPSILON; } function multiply(left: PolyMorphMat4, right: PolyMorphMat4): PolyMorphMat4 { @@ -73,6 +102,30 @@ function resolvedLeafMatrix( : value; } +function resolvedLeafTransform( + leaf: PolyMorphRenderLeaf, + value: PolyMorphMat4, + useFallback: boolean, +): { readonly matrix: PolyMorphMat4; readonly projective: boolean } { + const resolved = resolvedLeafMatrix(leaf, value, useFallback); + if (resolved.some((component) => !Number.isFinite(component))) { + fail("invalid-matrix", `${leaf.id}.matrix`, "resolved leaf matrix must remain finite"); + } + return { + matrix: resolved, + projective: hasProjectiveLeafTerms(resolved, leaf, useFallback), + }; +} + +function leafMatrixText( + leaf: PolyMorphRenderLeaf, + value: PolyMorphMat4, + useFallback: boolean, +): string { + const resolved = resolvedLeafTransform(leaf, value, useFallback); + return matrixText(resolved.matrix, resolved.projective); +} + function colorText(color: readonly [number, number, number, number]): string { const [red, green, blue, alpha] = color; return `rgba(${Math.round(red * 255)}, ${Math.round(green * 255)}, ${Math.round(blue * 255)}, ${alpha})`; @@ -232,9 +285,7 @@ function createLeaf( element.dataset.polyMorphResolvedStrategy = fallback ? "atlas-slice" : leaf.strategy; - element.style.transform = matrixText( - resolvedLeafMatrix(leaf, leaf.matrix, fallback !== null), - ); + element.style.transform = leafMatrixText(leaf, leaf.matrix, fallback !== null); element.style.color = colorText(materialColor); element.style.backfaceVisibility = "visible"; element.style.backgroundRepeat = "no-repeat"; @@ -275,6 +326,8 @@ export function mountPolyMorphModel( } const model = validatePolyMorphModel(modelInput); const doc = host.ownerDocument; + const userAgent = doc.defaultView?.navigator?.userAgent ?? ""; + const supportsProjectiveQuads = !safariCssProjectiveUnsupported(userAgent); const useSolidTriangleFallback = model.render.leaves.some( (leaf) => leaf.strategy === "solid-triangle", ) && !isSolidTriangleSupported(doc); @@ -323,6 +376,19 @@ export function mountPolyMorphModel( }; try { for (const leaf of model.render.leaves) { + const useFallback = useSolidTriangleFallback + && leaf.strategy === "solid-triangle"; + const resolved = resolvedLeafTransform(leaf, leaf.matrix, useFallback); + if ( + !supportsProjectiveQuads + && resolved.projective + ) { + fail( + "unsupported-projective-quad", + `${leaf.id}.matrix`, + "this browser does not support projective leaf matrices", + ); + } if (leaf.atlas) { resolveVerifiedImageUrl(leaf.atlas.resourcePath, leaf.id); } @@ -408,11 +474,11 @@ export function mountPolyMorphModel( shapeElement.appendChild(element); leafHandles.set(leaf.id, { id: leaf.id, plan: leaf, element }); leafStates.set(leaf.id, { - transform: matrixText(resolvedLeafMatrix( + transform: leafMatrixText( leaf, leaf.matrix, useSolidTriangleFallback && leaf.strategy === "solid-triangle", - )), + ), visible: true, opacity: 1, atlasRow: 0, @@ -467,6 +533,20 @@ export function mountPolyMorphModel( if (!handle || !state) { fail("unknown-leaf", leaf.leafId, "no retained leaf handle"); } + const useFallback = useSolidTriangleFallback + && handle.plan.strategy === "solid-triangle"; + let transform: string | undefined; + if (leaf.matrix) { + const resolved = resolvedLeafTransform(handle.plan, leaf.matrix, useFallback); + if (!supportsProjectiveQuads && resolved.projective) { + fail( + "unsupported-projective-quad", + `${leaf.leafId}.matrix`, + "this browser does not support projective leaf matrices", + ); + } + transform = matrixText(resolved.matrix, resolved.projective); + } let atlasPosition: string | undefined; if (leaf.atlasRow !== undefined) { const atlas = handle.plan.atlas; @@ -477,7 +557,7 @@ export function mountPolyMorphModel( } atlasPosition = `${-atlas.x}px ${-y}px`; } - return { leaf, handle, state, atlasPosition }; + return { leaf, handle, state, atlasPosition, transform }; }); assertStableDomIdentity(); let modelTransformWrites = 0; @@ -502,13 +582,9 @@ export function mountPolyMorphModel( shapeTransformWrites += 1; } } - for (const { leaf, handle, state, atlasPosition } of resolvedLeaves) { - if (leaf.matrix) { - const next = matrixText(resolvedLeafMatrix( - handle.plan, - leaf.matrix, - useSolidTriangleFallback && handle.plan.strategy === "solid-triangle", - )); + for (const { leaf, handle, state, atlasPosition, transform } of resolvedLeaves) { + if (transform) { + const next = transform; if (state.transform !== next) { state.transform = next; handle.element.style.transform = next; diff --git a/packages/morph/src/runtime/deformation/deformation.test.ts b/packages/morph/src/runtime/deformation/deformation.test.ts index c36dd012..76db2dfb 100644 --- a/packages/morph/src/runtime/deformation/deformation.test.ts +++ b/packages/morph/src/runtime/deformation/deformation.test.ts @@ -8,6 +8,38 @@ import { createPolyMorphRuntimeFixture } from "../../testing/runtimeFixture.js"; import { PolyMorphRuntimeError } from "../runtimeError.js"; import { createPolyMorphDeformationRuntime } from "./index.js"; +function createProjectiveQuadFixture(zDelta = 0) { + const fixture = clonePolyMorphFixture(createPolyMorphModelFixture("morph-regions")); + 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 = 1; + fixture.render.leaves[0]!.height = 1; + if (fixture.deformation.kind !== "morph-regions") { + throw new TypeError("expected morph fixture"); + } + fixture.deformation.targets[0]!.deltas = [{ + vertexIndex: 3, + position: [1, 0, zDelta], + normal: null, + }]; + return fixture; +} + +function applyMatrix( + matrix: readonly number[], + [x, y]: readonly [number, number], +): readonly [number, 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, + (matrix[2]! * x + matrix[6]! * y + matrix[14]!) / w, + ]; +} + describe("PolyMorph deformation runtime", () => { it("emits no writes for static steps", () => { const runtime = createPolyMorphDeformationRuntime(createPolyMorphModelFixture()); @@ -84,6 +116,110 @@ describe("PolyMorph deformation runtime", () => { }).dirtyLeavesVisited).toBe(0); }); + it("maps a deformed planar trapezoid through one projective quad matrix", () => { + const runtime = createPolyMorphDeformationRuntime(createProjectiveQuadFixture()); + const frame = runtime.sample({ tick: 1, morphWeights: { stretch: 0.5 } }); + const matrix = frame.leafUpdates[0]!.matrix!; + const corners = [[0, 0], [1, 0], [1, 1], [0, 1]] as const; + const vertexIndices = [0, 1, 3, 2] as const; + expect(Math.abs(matrix[3]!) + Math.abs(matrix[7]!)).toBeGreaterThan(0); + for (const [index, corner] of corners.entries()) { + const actual = applyMatrix(matrix, corner); + const expected = frame.positions[vertexIndices[index]!]!; + for (const [axis, value] of actual.entries()) { + expect(value).toBeCloseTo(expected[axis]!, 10); + } + } + }); + + it("keeps projective quad acceptance invariant under uniform model scale", () => { + const scale = 1e-5; + const fixture = createProjectiveQuadFixture(); + for (const vertex of fixture.topology.vertices) { + vertex[0] *= scale; + vertex[1] *= scale; + vertex[2] *= scale; + } + fixture.render.leaves[0]!.width = scale; + fixture.render.leaves[0]!.height = scale; + fixture.render.modelMatrix[0] = 1e6; + fixture.render.modelMatrix[5] = 1e6; + fixture.render.modelMatrix[10] = 1e6; + if (fixture.deformation.kind !== "morph-regions") { + throw new TypeError("expected morph fixture"); + } + fixture.deformation.targets[0]!.deltas[0]!.position = [scale, 0, 0]; + + const runtime = createPolyMorphDeformationRuntime(fixture); + const frame = runtime.sample({ tick: 1, morphWeights: { stretch: 1 } }); + const matrix = frame.leafUpdates[0]!.matrix!; + const corners = [[0, 0], [scale, 0], [scale, scale], [0, scale]] as const; + const vertexIndices = [0, 1, 3, 2] as const; + for (const [index, corner] of corners.entries()) { + const actual = applyMatrix(matrix, corner); + const expected = frame.positions[vertexIndices[index]!]!; + for (const [axis, value] of actual.entries()) { + expect(value).toBeCloseTo(expected[axis]!, 12); + } + } + }); + + it("rejects non-finite projective output without committing runtime state", () => { + const fixture = createProjectiveQuadFixture(); + fixture.topology.vertices[1] = [1e308, 0, 0]; + fixture.topology.vertices[2] = [0, 1e-308, 0]; + fixture.topology.vertices[3] = [1e308, 1e-308, 0]; + if (fixture.deformation.kind !== "morph-regions") { + throw new TypeError("expected morph fixture"); + } + fixture.deformation.targets[0]!.deltas[0]!.position = [0, -5e-309, 0]; + const runtime = createPolyMorphDeformationRuntime(fixture); + + expect(() => runtime.sample({ tick: 1, morphWeights: { stretch: 1 } })) + .toThrowError(PolyMorphRuntimeError); + expect(() => runtime.sample({ tick: 2, morphWeights: { stretch: 1 } })) + .toThrowError(PolyMorphRuntimeError); + expect(runtime.sample({ tick: 3, morphWeights: { stretch: 0 } }).dirtyLeafIds) + .toEqual([]); + }); + + it("fails closed when a planar quad folds across its projective denominator", () => { + const fixture = createProjectiveQuadFixture(); + if (fixture.deformation.kind !== "morph-regions") { + throw new TypeError("expected morph fixture"); + } + fixture.deformation.targets[0]!.deltas = [ + { + vertexIndex: 1, + position: [0, 1, 0], + normal: null, + }, + { + vertexIndex: 3, + position: [0, -1, 0], + normal: null, + }, + ]; + const runtime = createPolyMorphDeformationRuntime(fixture); + + expect(() => runtime.sample({ tick: 1, morphWeights: { stretch: 1 } })) + .toThrowError(PolyMorphRuntimeError); + expect(runtime.sample({ tick: 2, morphWeights: { stretch: 0 } }).dirtyLeafIds) + .toEqual([]); + }); + + it("rejects compositor-unstable projective weight ratios", () => { + const fixture = createProjectiveQuadFixture(); + if (fixture.deformation.kind !== "morph-regions") { + throw new TypeError("expected morph fixture"); + } + fixture.deformation.targets[0]!.deltas[0]!.position = [999, 0, 0]; + const runtime = createPolyMorphDeformationRuntime(fixture); + + expect(() => runtime.sample({ tick: 1, morphWeights: { stretch: 1 } })) + .toThrowError(PolyMorphRuntimeError); + }); + it("fails closed on unknown ids, out-of-range values, and unsupported profiles", () => { const runtime = createPolyMorphDeformationRuntime(createPolyMorphRuntimeFixture()); expect(() => runtime.sample({ @@ -114,16 +250,7 @@ describe("PolyMorph deformation runtime", () => { }); it("does not publish failed quad geometry into the next sample", () => { - const fixture = clonePolyMorphFixture( - createPolyMorphModelFixture("morph-regions"), - ); - 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 = 1; - fixture.render.leaves[0]!.height = 1; + const fixture = createProjectiveQuadFixture(1); const runtime = createPolyMorphDeformationRuntime(fixture); expect(() => runtime.sample({ diff --git a/packages/morph/src/runtime/deformation/deformation.ts b/packages/morph/src/runtime/deformation/deformation.ts index 4af9486a..e563bc40 100644 --- a/packages/morph/src/runtime/deformation/deformation.ts +++ b/packages/morph/src/runtime/deformation/deformation.ts @@ -1,5 +1,7 @@ import { + computeProjectiveQuadCoefficients, computeSolidTrianglePlanFromCssPoints, + resolveProjectiveQuadGuards, SOLID_TRIANGLE_CANONICAL_SIZE, type Polygon, type SolidTriangleBasis, @@ -29,6 +31,8 @@ export interface PolyMorphCompiledLeaf { type MutableVec3 = [number, number, number]; +const PROJECTIVE_QUAD_GUARDS = resolveProjectiveQuadGuards(undefined); + function fail(code: string, path: string, message: string): never { throw new PolyMorphRuntimeError(code, path, message); } @@ -102,44 +106,132 @@ function quadMatrix( const p1 = positions[i1!]!; const p2 = positions[i2!]!; const p3 = positions[i3!]!; - const predicted = [ - p1[0] + p3[0] - p0[0], - p1[1] + p3[1] - p0[1], - p1[2] + p3[2] - p0[2], - ] as const; - if (Math.hypot( - predicted[0] - p2[0], - predicted[1] - p2[1], - predicted[2] - p2[2], - ) > 1e-6) { - fail("non-affine-polygon", compiled.leaf.id, "deformed quad is not a parallelogram"); + const edgeX = [ + p1[0] - p0[0], + p1[1] - p0[1], + p1[2] - p0[2], + ] as PolyMorphVec3; + const edgeY = [ + p3[0] - p0[0], + p3[1] - p0[1], + p3[2] - p0[2], + ] as PolyMorphVec3; + const edgeXLength = Math.hypot(...edgeX); + const edgeYLength = Math.hypot(...edgeY); + if ( + !Number.isFinite(edgeXLength) + || !Number.isFinite(edgeYLength) + || edgeXLength === 0 + || edgeYLength === 0 + ) { + fail("degenerate-polygon", compiled.leaf.id, "deformed quad has no area"); } + const unitEdgeX = [ + edgeX[0] / edgeXLength, + edgeX[1] / edgeXLength, + edgeX[2] / edgeXLength, + ] as PolyMorphVec3; + const unitEdgeY = [ + edgeY[0] / edgeYLength, + edgeY[1] / edgeYLength, + edgeY[2] / edgeYLength, + ] as PolyMorphVec3; + const cross = [ + unitEdgeX[1] * unitEdgeY[2] - unitEdgeX[2] * unitEdgeY[1], + unitEdgeX[2] * unitEdgeY[0] - unitEdgeX[0] * unitEdgeY[2], + unitEdgeX[0] * unitEdgeY[1] - unitEdgeX[1] * unitEdgeY[0], + ] as PolyMorphVec3; + const crossLength = Math.hypot(...cross); + if (!Number.isFinite(crossLength) || crossLength <= 1e-12) { + fail("degenerate-polygon", compiled.leaf.id, "deformed quad has no area"); + } + const z = [ + cross[0] / crossLength, + cross[1] / crossLength, + cross[2] / crossLength, + ] as PolyMorphVec3; + const diagonal = [ + p2[0] - p0[0], + p2[1] - p0[1], + p2[2] - p0[2], + ] as PolyMorphVec3; + const diagonalLength = Math.hypot(...diagonal); + const distance = Math.abs( + z[0] * diagonal[0] + + z[1] * diagonal[1] + + z[2] * diagonal[2], + ); + const quadScale = Math.max(edgeXLength, edgeYLength, diagonalLength); + if ( + !Number.isFinite(distance) + || !Number.isFinite(quadScale) + || distance / quadScale > 1e-6 + ) { + fail("non-planar-polygon", compiled.leaf.id, "deformed quad is not coplanar"); + } + const basis = ([[0, 1], [0, 2], [1, 2]] as const) + .map(([first, second]) => ({ + first, + second, + determinant: unitEdgeX[first] * unitEdgeY[second] + - unitEdgeX[second] * unitEdgeY[first], + })) + .sort((left, right) => + Math.abs(right.determinant) - Math.abs(left.determinant))[0]!; + const projected = [p0, p1, p2, p3].map((point): [number, number] => [ + point[basis.first], + point[basis.second], + ]); + const [projectedOrigin] = projected; + const projectedScaleX = Math.max(...projected.map((point) => + Math.abs(point[0] - projectedOrigin![0]))); + const projectedScaleY = Math.max(...projected.map((point) => + Math.abs(point[1] - projectedOrigin![1]))); + if ( + !Number.isFinite(projectedScaleX) + || !Number.isFinite(projectedScaleY) + || projectedScaleX === 0 + || projectedScaleY === 0 + ) { + fail("degenerate-polygon", compiled.leaf.id, "deformed quad has no area"); + } + const normalizedProjected = projected.map((point): [number, number] => [ + (point[0] - projectedOrigin![0]) / projectedScaleX, + (point[1] - projectedOrigin![1]) / projectedScaleY, + ]); + const coefficients = computeProjectiveQuadCoefficients( + normalizedProjected, + PROJECTIVE_QUAD_GUARDS, + ); + if (!coefficients) { + fail( + "degenerate-polygon", + compiled.leaf.id, + "deformed quad has no stable convex projective transform", + ); + } + const { g, h } = coefficients; const x = [ - (p1[0] - p0[0]) / compiled.leaf.width, - (p1[1] - p0[1]) / compiled.leaf.width, - (p1[2] - p0[2]) / compiled.leaf.width, + ((1 + g) * p1[0] - p0[0]) / compiled.leaf.width, + ((1 + g) * p1[1] - p0[1]) / compiled.leaf.width, + ((1 + g) * p1[2] - p0[2]) / compiled.leaf.width, ] as const; const y = [ - (p3[0] - p0[0]) / compiled.leaf.height, - (p3[1] - p0[1]) / compiled.leaf.height, - (p3[2] - p0[2]) / compiled.leaf.height, + ((1 + h) * p3[0] - p0[0]) / compiled.leaf.height, + ((1 + h) * p3[1] - p0[1]) / compiled.leaf.height, + ((1 + h) * p3[2] - p0[2]) / compiled.leaf.height, ] as const; - const cross = [ - x[1] * y[2] - x[2] * y[1], - x[2] * y[0] - x[0] * y[2], - x[0] * y[1] - x[1] * y[0], - ] as PolyMorphVec3; - const z = normalize(cross); - if (Math.hypot(...z) <= 1e-12) { - fail("degenerate-polygon", compiled.leaf.id, "deformed quad has no area"); + const matrix = [ + x[0], x[1], x[2], g / compiled.leaf.width, + y[0], y[1], y[2], h / compiled.leaf.height, + z[0], z[1], z[2], 0, + p0[0], p0[1], p0[2], 1, + ] as PolyMorphMat4; + if (matrix.some((component) => !Number.isFinite(component))) { + fail("invalid-transform", compiled.leaf.id, "deformed quad produced a non-finite matrix"); } return { - matrix: [ - x[0], x[1], x[2], 0, - y[0], y[1], y[2], 0, - z[0], z[1], z[2], 0, - p0[0], p0[1], p0[2], 1, - ], + matrix, visible: true, }; } @@ -153,7 +245,7 @@ export function computePolyMorphPreparedLeafMatrix( fail( "unsupported-deformation", compiled.leaf.id, - "caller-driven deformation supports prepared triangles and affine quads", + "caller-driven deformation supports prepared triangles and planar quads", ); } diff --git a/packages/morph/src/runtime/skinning/skinning.test.ts b/packages/morph/src/runtime/skinning/skinning.test.ts index dc6cbb47..53e73508 100644 --- a/packages/morph/src/runtime/skinning/skinning.test.ts +++ b/packages/morph/src/runtime/skinning/skinning.test.ts @@ -137,7 +137,7 @@ describe("PolyMorph joint skinning", () => { expect(() => runtime.sample({ tick: 1, - jointTransforms: new Map([["tip", { translation: [0, 1, 0] }]]), + jointTransforms: new Map([["tip", { translation: [0, 1, 1] }]]), })).toThrowError(PolyMorphRuntimeError); const recovered = runtime.sample({ tick: 2 }); expect(recovered.positions).toEqual(fixture.topology.vertices); diff --git a/packages/polycss/src/render/atlas/strategy.test.ts b/packages/polycss/src/render/atlas/strategy.test.ts index 900515e9..aa590382 100644 --- a/packages/polycss/src/render/atlas/strategy.test.ts +++ b/packages/polycss/src/render/atlas/strategy.test.ts @@ -20,6 +20,7 @@ import { isFullRectSolid, isProjectiveQuadPlan, isSolidTrianglePlan, + projectiveQuadSupported, } from "./strategy"; import { computeTextureAtlasPlanPublic } from "@layoutit/polycss-core"; import { renderPolygonsWithTextureAtlas } from "./renderPolygons"; @@ -32,11 +33,12 @@ function makeDoc(options: { borderShape?: boolean; cornerShape?: boolean; pointer?: "fine" | "coarse"; + userAgent?: string; }): Document { const pointer = options.pointer ?? "fine"; return { defaultView: { - navigator: { userAgent: "Mozilla/5.0 Chrome/120" }, + navigator: { userAgent: options.userAgent ?? "Mozilla/5.0 Chrome/120" }, CSS: { supports: (property: string, value?: string) => { if (property === "border-shape") return options.borderShape === true; @@ -134,6 +136,18 @@ describe("plan predicate functions", () => { }); }); +describe("projectiveQuadSupported", () => { + it("returns false for an iOS AppleWebKit browser shell", () => { + const userAgent = "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(projectiveQuadSupported(makeDoc({ userAgent }))).toBe(false); + }); + + it("returns true for desktop Chromium", () => { + const userAgent = "Mozilla/5.0 AppleWebKit/537.36 Chrome/128.0.0.0 Safari/537.36"; + expect(projectiveQuadSupported(makeDoc({ userAgent }))).toBe(true); + }); +}); + // --------------------------------------------------------------------------- // filterAtlasPlans — strategy disable combinations // --------------------------------------------------------------------------- diff --git a/packages/react/src/scene/atlas/detection.test.ts b/packages/react/src/scene/atlas/detection.test.ts index c9ff93cd..589891ac 100644 --- a/packages/react/src/scene/atlas/detection.test.ts +++ b/packages/react/src/scene/atlas/detection.test.ts @@ -16,6 +16,7 @@ import { borderShapeSupported, solidTriangleSupported, cornerShapeSupported, + projectiveQuadSupported, } from "./detection"; import { isMobileDocument } from "./packing"; @@ -52,6 +53,8 @@ function makeDoc(options: { const SAFARI_UA = "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.0 Safari/605.1.15"; +const IOS_WEBKIT_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 CHROME_UA = "Mozilla/5.0 Chrome/120"; // --------------------------------------------------------------------------- @@ -90,6 +93,11 @@ describe("solidTriangleSupported — direct doc variant", () => { expect(solidTriangleSupported(doc)).toBe(false); }); + it("returns false for an iOS AppleWebKit browser shell", () => { + const doc = makeDoc({ userAgent: IOS_WEBKIT_UA }); + expect(solidTriangleSupported(doc)).toBe(false); + }); + it("returns true for Safari when corner-shape triangles are supported", () => { const doc = makeDoc({ userAgent: SAFARI_UA, cornerShape: true }); expect(solidTriangleSupported(doc)).toBe(true); @@ -101,6 +109,16 @@ describe("solidTriangleSupported — direct doc variant", () => { }); }); +describe("projectiveQuadSupported", () => { + it("returns false for an iOS AppleWebKit browser shell", () => { + expect(projectiveQuadSupported(makeDoc({ userAgent: IOS_WEBKIT_UA }))).toBe(false); + }); + + it("returns true for desktop Chromium", () => { + expect(projectiveQuadSupported(makeDoc({ userAgent: CHROME_UA }))).toBe(true); + }); +}); + // --------------------------------------------------------------------------- // cornerShapeSupported // --------------------------------------------------------------------------- diff --git a/packages/vue/src/scene/atlas/detection.test.ts b/packages/vue/src/scene/atlas/detection.test.ts index e45ff5a2..4142b9ea 100644 --- a/packages/vue/src/scene/atlas/detection.test.ts +++ b/packages/vue/src/scene/atlas/detection.test.ts @@ -12,6 +12,7 @@ import { borderShapeSupported, solidTriangleSupported, cornerShapeSupported, + projectiveQuadSupported, } from "./detection"; import { isMobileDocument } from "./packing"; @@ -48,6 +49,8 @@ function makeDoc(options: { const SAFARI_UA = "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.0 Safari/605.1.15"; +const IOS_WEBKIT_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 CHROME_UA = "Mozilla/5.0 Chrome/120"; // --------------------------------------------------------------------------- @@ -86,6 +89,11 @@ describe("solidTriangleSupported — direct doc variant", () => { expect(solidTriangleSupported(doc)).toBe(false); }); + it("returns false for an iOS AppleWebKit browser shell", () => { + const doc = makeDoc({ userAgent: IOS_WEBKIT_UA }); + expect(solidTriangleSupported(doc)).toBe(false); + }); + it("returns true for Safari when corner-shape triangles are supported", () => { const doc = makeDoc({ userAgent: SAFARI_UA, cornerShape: true }); expect(solidTriangleSupported(doc)).toBe(true); @@ -97,6 +105,16 @@ describe("solidTriangleSupported — direct doc variant", () => { }); }); +describe("projectiveQuadSupported", () => { + it("returns false for an iOS AppleWebKit browser shell", () => { + expect(projectiveQuadSupported(makeDoc({ userAgent: IOS_WEBKIT_UA }))).toBe(false); + }); + + it("returns true for desktop Chromium", () => { + expect(projectiveQuadSupported(makeDoc({ userAgent: CHROME_UA }))).toBe(true); + }); +}); + // --------------------------------------------------------------------------- // cornerShapeSupported // --------------------------------------------------------------------------- diff --git a/website/src/content/docs/guides/morph.mdx b/website/src/content/docs/guides/morph.mdx index 3a28500b..0d35dc6b 100644 --- a/website/src/content/docs/guides/morph.mdx +++ b/website/src/content/docs/guides/morph.mdx @@ -110,6 +110,12 @@ function update(spherify: number, twist: number) { Morph owns no scheduler. Applications decide whether weights come from direct input, state, an animation sampler, or a spring. +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. + ## Profiles | Profile | Behavior |