From 56b41c80a30c74913141a251cf78575a03f3174f Mon Sep 17 00:00:00 2001 From: agustin-littlehat Date: Mon, 25 May 2026 21:15:03 -0300 Subject: [PATCH 1/3] fix(renderer): stabilize firefox solid triangles --- AGENTS.md | 2 +- packages/core/src/atlas/constants.ts | 2 + packages/core/src/atlas/solidTriangle.test.ts | 48 +++++++++++++++++++ packages/core/src/atlas/solidTrianglePlan.ts | 10 ++-- packages/core/src/atlas/types.ts | 2 +- packages/core/src/index.ts | 2 + .../atlas/solidTrianglePrimitive.test.ts | 22 +++++++-- .../src/render/atlas/stableTriangle.ts | 2 + packages/polycss/src/render/atlas/strategy.ts | 11 ++++- packages/polycss/src/styles/styles.ts | 4 ++ packages/react/src/scene/atlas/index.test.tsx | 18 +++++++ .../src/scene/atlas/solidTriangleStyle.ts | 39 +++++++++++---- .../src/scene/atlas/stableTriangleDom.ts | 10 ++-- packages/react/src/styles/styles.ts | 4 ++ packages/vue/src/scene/atlas/index.test.ts | 18 +++++++ .../vue/src/scene/atlas/solidTriangleStyle.ts | 39 +++++++++++---- .../vue/src/scene/atlas/stableTriangleDom.ts | 10 ++-- packages/vue/src/styles/styles.ts | 4 ++ 18 files changed, 213 insertions(+), 34 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 98740cc5..19d585ea 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -34,7 +34,7 @@ Voxel-shaped meshes are the exception to "all polygons stay mounted": meshes wit | `` | **Quads** | Axis-aligned rectangle, or untextured convex quad when the homography passes stability guards on non-Safari engines | `background: currentColor` on a fixed 64px rectangle; affine and projective quads normalize their `matrix3d` to that primitive, with tiny solid bleed on projective quads to overlap antialias seams. Safari-family browsers skip the projective quad path and fall through because transformed projective rectangles composite incorrectly there. | None | | `` | **Border-shape clipped solid** | Untextured non-rect not caught by the exact corner-shape solid path, on browsers with CSS `border-shape` (Chromium + `pointer:fine` + `hover:hover`) | `border-color: currentColor` on a fixed 16px border-shape primitive, clipped by `border-shape: polygon(...)`; polygon bbox scale and tiny solid bleed are folded into `matrix3d` | None | | `` | **Atlas slice** | Textured polygons, or untextured non-rect on browsers without `border-shape` | `background-image` slice of packed bitmap on an auto-budgeted fixed primitive (128px for desktop-class `textureQuality="auto"`, 64px for mobile-class `auto` and explicit numeric quality); atlas position/size and `matrix3d` scale are normalized to the slice, shared textured edges get low-alpha atlas pixels repaired during atlas generation, and solid fallbacks get same-color edge bleed to avoid dark alpha fringes | Bounding-rect area | -| `` | **Stable solid triangle / corner-shape solid** | Triangles on non-WebKit engines; or untextured non-triangle polygons whose normalized outline is exactly a rectangle with one or more beveled corners on browsers with CSS `corner-shape` | Triangles use a 32px box with two beveled top corners and `background: currentColor` when CSS `corner-shape` support is present, progressively falling back to the CSS border-color triangle trick; exact corner-shape solids use a bare fixed 16px box with inline per-corner radii + `corner-*-shape: bevel` and `background: currentColor`. Tiny solid bleed is folded into `matrix3d`. WebKit/Safari falls through to `` for border triangles because transformed CSS border triangles composite incorrectly there. | None | +| `` | **Stable solid triangle / corner-shape solid** | Triangles on non-WebKit engines; or untextured non-triangle polygons whose normalized outline is exactly a rectangle with one or more beveled corners on browsers with CSS `corner-shape` | Triangles use a 32px box with two beveled top corners and `background: currentColor` when CSS `corner-shape` support is present, progressively falling back to the CSS border-color triangle trick. Firefox uses a 96px border-triangle primitive with proportionally smaller `matrix3d` scale to avoid large-perspective compositor banding. Exact corner-shape solids use a bare fixed 16px box with inline per-corner radii + `corner-*-shape: bevel` and `background: currentColor`. Tiny solid bleed is folded into `matrix3d`. WebKit/Safari falls through to `` for border triangles because transformed CSS border triangles composite incorrectly there. | None | | `` | **Cast shadow leaf** | Per casting polygon when `castShadow: true`, in either lighting mode. Applies regardless of caster strategy — ``/``/``/`` all produce a `` shadow because only the polygon's outline matters, not its surface. | Same `border-color: currentColor` + `border-shape: polygon(...)` as ``. Dynamic mode chains `var(--shadow-proj)` (driven by `--clx/y/z` + `--shadow-ground-cssz`) so the projection follows the live light vars. Baked mode CPU-bakes the projection into the leaf's inline `matrix3d(...)` and drops back-facing polys from the DOM entirely instead of opacity-gating them. | None | Strategies are ordered cheapest → most expensive. The mesher's job is to maximise `` / `` / `` and minimise `` (see "Meshing implications" below). diff --git a/packages/core/src/atlas/constants.ts b/packages/core/src/atlas/constants.ts index cdf2a45a..6a9266ef 100644 --- a/packages/core/src/atlas/constants.ts +++ b/packages/core/src/atlas/constants.ts @@ -46,7 +46,9 @@ export const DEFAULT_ATLAS_CSS_DECIMALS = 4; export const DECIMAL_SCALES = [1, 10, 100, 1000, 10000, 100000, 1000000]; export const SOLID_QUAD_CANONICAL_SIZE = 64; export const SOLID_TRIANGLE_CANONICAL_SIZE = 32; +export const SOLID_TRIANGLE_LARGE_BORDER_CANONICAL_SIZE = 96; export const SOLID_TRIANGLE_CORNER_CLASS = "polycss-corner-triangle"; +export const SOLID_TRIANGLE_LARGE_BORDER_CLASS = "polycss-large-border-triangle"; export const ATLAS_CANONICAL_SIZE_EXPLICIT = 64; export const ATLAS_CANONICAL_SIZE_AUTO_DESKTOP = 128; export const BORDER_SHAPE_CENTER_PERCENT = 50; diff --git a/packages/core/src/atlas/solidTriangle.test.ts b/packages/core/src/atlas/solidTriangle.test.ts index 7a11df22..51ba5bd2 100644 --- a/packages/core/src/atlas/solidTriangle.test.ts +++ b/packages/core/src/atlas/solidTriangle.test.ts @@ -11,6 +11,13 @@ */ import { describe, it, expect } from "vitest"; import type { Polygon } from "../types"; +import { + SOLID_TRIANGLE_CANONICAL_SIZE, + SOLID_TRIANGLE_LARGE_BORDER_CANONICAL_SIZE, +} from "./constants"; +import { + computeSolidTrianglePlan, +} from "./solidTrianglePlan"; import { computeSurfaceNormal, cssPoints, @@ -26,6 +33,12 @@ import { } from "./solidTriangle"; import { computeTextureAtlasPlanPublic } from "./plan"; +function matrixValues(transform: string): number[] { + const match = /^matrix3d\((.*)\)$/.exec(transform); + if (!match) return []; + return match[1].split(",").map((value) => Number(value)); +} + // --------------------------------------------------------------------------- // computeSurfaceNormal // --------------------------------------------------------------------------- @@ -306,6 +319,41 @@ describe("stableBasisFromPlan — stable triangle basis from atlas plan", () => }); }); +describe("computeSolidTrianglePlan — primitive sizing", () => { + const FLAT_TRIANGLE: Polygon = { + vertices: [[0, 0, 0], [1, 0, 0], [0, 1, 0]], + color: "#ff0000", + }; + + it("keeps border-large geometry stable by reducing x/y matrix scale", () => { + const border = computeSolidTrianglePlan( + FLAT_TRIANGLE, + 0, + {}, + { primitive: "border", matrixDecimals: 6 }, + )!; + const large = computeSolidTrianglePlan( + FLAT_TRIANGLE, + 0, + {}, + { primitive: "border-large", matrixDecimals: 6 }, + )!; + const ratio = SOLID_TRIANGLE_CANONICAL_SIZE / SOLID_TRIANGLE_LARGE_BORDER_CANONICAL_SIZE; + const borderValues = matrixValues(border.transformText); + const largeValues = matrixValues(large.transformText); + + expect(large.primitive).toBe("border-large"); + expect(borderValues).toHaveLength(16); + expect(largeValues).toHaveLength(16); + for (const index of [0, 1, 2, 4, 5, 6]) { + expect(largeValues[index]).toBeCloseTo(borderValues[index] * ratio, 6); + } + for (const index of [8, 9, 10, 12, 13, 14]) { + expect(largeValues[index]).toBeCloseTo(borderValues[index], 6); + } + }); +}); + // --------------------------------------------------------------------------- // offsetTrianglePoints — outward offset for a raw triangle // --------------------------------------------------------------------------- diff --git a/packages/core/src/atlas/solidTrianglePlan.ts b/packages/core/src/atlas/solidTrianglePlan.ts index 4f417d73..484837ec 100644 --- a/packages/core/src/atlas/solidTrianglePlan.ts +++ b/packages/core/src/atlas/solidTrianglePlan.ts @@ -10,6 +10,7 @@ import { BASIS_EPS, SOLID_TRIANGLE_BLEED, SOLID_TRIANGLE_CANONICAL_SIZE, + SOLID_TRIANGLE_LARGE_BORDER_CANONICAL_SIZE, } from "./constants"; import type { SolidTrianglePlan, @@ -398,7 +399,11 @@ export function computeSolidTrianglePlanFromCssPoints( dynamicVars = colorPlan.dynamicVars ?? ""; } const bakedColor = bakedColorValue ? `color:${bakedColorValue};` : ""; - const invCanonicalSize = 1 / SOLID_TRIANGLE_CANONICAL_SIZE; + const primitive = computeOptions.primitive ?? computeOptions.resolvedPrimitive ?? "border"; + const canonicalSize = primitive === "border-large" + ? SOLID_TRIANGLE_LARGE_BORDER_CANONICAL_SIZE + : SOLID_TRIANGLE_CANONICAL_SIZE; + const invCanonicalSize = 1 / canonicalSize; const baseWidthPx = leftPx + rightPx; const xScale = baseWidthPx * invCanonicalSize; const yXScale = (rightPx - leftPx) * 0.5 * invCanonicalSize; @@ -433,9 +438,6 @@ export function computeSolidTrianglePlanFromCssPoints( const basis = basisHint && basisHint.a === a && basisHint.b === b && basisHint.c === c ? basisHint : { a, b, c }; - // Use the pre-resolved primitive from computeOptions — the browser-global - // resolution that formerly happened here now happens in the PolyCSS wrapper. - const primitive = computeOptions.primitive ?? computeOptions.resolvedPrimitive ?? "border"; return { index, polygon, diff --git a/packages/core/src/atlas/types.ts b/packages/core/src/atlas/types.ts index 9f0100fa..9637ad13 100644 --- a/packages/core/src/atlas/types.ts +++ b/packages/core/src/atlas/types.ts @@ -87,7 +87,7 @@ export type PolySeamBleedEdges = | readonly (PolySeamBleedEdgeValue | undefined)[]; export type PolyRenderStrategy = "b" | "i" | "u"; -export type SolidTrianglePrimitive = "border" | "corner-bevel"; +export type SolidTrianglePrimitive = "border" | "border-large" | "corner-bevel"; export interface PolyRenderStrategiesOption { /** Strategies to skip; polygons that would normally use them fall through diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 20068843..9c1069db 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -233,7 +233,9 @@ export { DECIMAL_SCALES, SOLID_QUAD_CANONICAL_SIZE, SOLID_TRIANGLE_CANONICAL_SIZE, + SOLID_TRIANGLE_LARGE_BORDER_CANONICAL_SIZE, SOLID_TRIANGLE_CORNER_CLASS, + SOLID_TRIANGLE_LARGE_BORDER_CLASS, ATLAS_CANONICAL_SIZE_EXPLICIT, ATLAS_CANONICAL_SIZE_AUTO_DESKTOP, BORDER_SHAPE_CENTER_PERCENT, diff --git a/packages/polycss/src/render/atlas/solidTrianglePrimitive.test.ts b/packages/polycss/src/render/atlas/solidTrianglePrimitive.test.ts index 0701f921..e634a82c 100644 --- a/packages/polycss/src/render/atlas/solidTrianglePrimitive.test.ts +++ b/packages/polycss/src/render/atlas/solidTrianglePrimitive.test.ts @@ -21,15 +21,18 @@ function makeDoc(options: { cornerShape?: boolean; solidTriangleSupported?: boolean; borderShape?: boolean; + userAgent?: string; } = {}): Document { const solidTriangleOk = options.solidTriangleSupported !== false; + const userAgent = options.userAgent ?? ( + solidTriangleOk + ? "Mozilla/5.0 Chrome/120" + : "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" + ); return { defaultView: { navigator: { - // Safari UA → solid triangles NOT supported (compositing bug) - userAgent: solidTriangleOk - ? "Mozilla/5.0 Chrome/120" - : "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", + userAgent, }, CSS: { supports: (property: string, value?: string) => { @@ -83,6 +86,17 @@ describe("solid triangle primitive — corner-bevel vs border", () => { result!.dispose(); }); + it("Firefox UA → uses the large border triangle primitive", () => { + const doc = makeDoc({ + userAgent: "Mozilla/5.0 (Macintosh; Intel Mac OS X 10.15; rv:146.0) Gecko/20100101 Firefox/146.0", + }); + const result = renderPolygonsWithStableTriangles([TRIANGLE], { doc }); + expect(result).not.toBeNull(); + expect(result!.rendered[0].element.classList.contains("polycss-large-border-triangle")).toBe(true); + expect(result!.rendered[0].element.classList.contains("polycss-corner-triangle")).toBe(false); + result!.dispose(); + }); + it("Safari UA → renderPolygonsWithStableTriangles returns null (solid triangles unsupported)", () => { const doc = makeDoc({ solidTriangleSupported: false }); const result = renderPolygonsWithStableTriangles([TRIANGLE], { doc }); diff --git a/packages/polycss/src/render/atlas/stableTriangle.ts b/packages/polycss/src/render/atlas/stableTriangle.ts index 69442522..d4aa1c2c 100644 --- a/packages/polycss/src/render/atlas/stableTriangle.ts +++ b/packages/polycss/src/render/atlas/stableTriangle.ts @@ -3,6 +3,7 @@ import { buildSeamBleedPolygonEdges, DEFAULT_TILE, SOLID_TRIANGLE_CORNER_CLASS, + SOLID_TRIANGLE_LARGE_BORDER_CLASS, DEFAULT_MATRIX_DECIMALS, BASIS_EPS, } from "@layoutit/polycss-core"; @@ -105,6 +106,7 @@ export function applySolidTrianglePrimitive( const triangleEl = el as SolidTriangleElement; if (triangleEl.__polycssSolidTrianglePrimitive === primitive) return; el.classList.toggle(SOLID_TRIANGLE_CORNER_CLASS, primitive === "corner-bevel"); + el.classList.toggle(SOLID_TRIANGLE_LARGE_BORDER_CLASS, primitive === "border-large"); triangleEl.__polycssSolidTrianglePrimitive = primitive; } diff --git a/packages/polycss/src/render/atlas/strategy.ts b/packages/polycss/src/render/atlas/strategy.ts index 9938a6c2..f5471433 100644 --- a/packages/polycss/src/render/atlas/strategy.ts +++ b/packages/polycss/src/render/atlas/strategy.ts @@ -73,13 +73,20 @@ export function cornerTriangleSupported(doc: Document): boolean { !!css.supports("corner-top-right-shape", "bevel"); } +function firefoxNeedsLargeBorderTriangle(doc: Document): boolean { + const win = doc.defaultView ?? (typeof window !== "undefined" ? window : undefined); + const userAgent = win?.navigator?.userAgent ?? ""; + return /\bFirefox\//.test(userAgent); +} + export function resolveSolidTrianglePrimitive( doc: Document, strategies?: PolyRenderStrategiesOption, -): "border" | "corner-bevel" | null { +): "border" | "border-large" | "corner-bevel" | null { if (strategies?.disable?.includes("u")) return null; if (cornerTriangleSupported(doc)) return "corner-bevel"; - return solidTriangleSupported(doc) ? "border" : null; + if (!solidTriangleSupported(doc)) return null; + return firefoxNeedsLargeBorderTriangle(doc) ? "border-large" : "border"; } export function projectiveQuadSupported(doc: Document): boolean { diff --git a/packages/polycss/src/styles/styles.ts b/packages/polycss/src/styles/styles.ts index ff38a4c5..92559c80 100644 --- a/packages/polycss/src/styles/styles.ts +++ b/packages/polycss/src/styles/styles.ts @@ -155,6 +155,10 @@ const CORE_BASE_STYLES = ` border-width: 0 16px 32px 16px; } +.polycss-scene u.polycss-large-border-triangle { + border-width: 0 48px 96px 48px; +} + .polycss-scene u.polycss-corner-triangle { width: 32px; height: 32px; diff --git a/packages/react/src/scene/atlas/index.test.tsx b/packages/react/src/scene/atlas/index.test.tsx index c74d1ef6..d1671d32 100644 --- a/packages/react/src/scene/atlas/index.test.tsx +++ b/packages/react/src/scene/atlas/index.test.tsx @@ -5,6 +5,7 @@ import { buildTextureEdgeRepairSets, computeTextureAtlasPlan, isSolidTrianglePlan, + updateStableTriangleDom, useTextureAtlas, type TextureQuality, type TextureAtlasPlan, @@ -14,6 +15,7 @@ import type { Polygon } from "@layoutit/polycss-core"; const originalMatchMedia = window.matchMedia; const originalUserAgent = window.navigator.userAgent; +const FIREFOX_UA = "Mozilla/5.0 (Macintosh; Intel Mac OS X 10.15; rv:146.0) Gecko/20100101 Firefox/146.0"; const TEXTURED_QUAD_60: Polygon = { vertices: [ @@ -191,6 +193,22 @@ describe("isSolidTrianglePlan", () => { }); }); +describe("updateStableTriangleDom", () => { + it("applies the large border triangle primitive on Firefox", () => { + stubUserAgent(FIREFOX_UA); + const root = document.createElement("div"); + const leaf = document.createElement("u"); + root.append(leaf); + const tri: Polygon = { + vertices: [[0, 0, 0], [1, 0, 0], [0, 1, 0]], + color: "#ff0000", + }; + + expect(updateStableTriangleDom(root, [tri])).toBe(true); + expect(leaf.style.borderWidth).toBe("0px 48px 96px"); + }); +}); + describe("useTextureAtlas", () => { function buildSixFaceCrateScene(): TextureAtlasPlan[] { const polys = Array.from({ length: 6 }, () => ({ ...TEXTURED_QUAD_60 })); diff --git a/packages/react/src/scene/atlas/solidTriangleStyle.ts b/packages/react/src/scene/atlas/solidTriangleStyle.ts index 3ded0c9d..1e5dc0e2 100644 --- a/packages/react/src/scene/atlas/solidTriangleStyle.ts +++ b/packages/react/src/scene/atlas/solidTriangleStyle.ts @@ -27,6 +27,28 @@ export const DEFAULT_AMBIENT_INTENSITY = 0.4; export const BASIS_EPS = 1e-9; // Matches the canonical SOLID_TRIANGLE_BLEED constant. export const SOLID_TRIANGLE_BLEED = 0.75; +const SOLID_TRIANGLE_CANONICAL_SIZE = 32; +const SOLID_TRIANGLE_LARGE_BORDER_CANONICAL_SIZE = 96; +const SOLID_TRIANGLE_LARGE_BORDER_WIDTH = "0 48px 96px 48px"; +let cachedSolidTriangleUserAgent: string | undefined; +let cachedSolidTriangleCanonicalSize = SOLID_TRIANGLE_CANONICAL_SIZE; + +export function solidTriangleCanonicalSize(): number { + const ua = typeof navigator !== "undefined" ? navigator.userAgent : ""; + if (ua !== cachedSolidTriangleUserAgent) { + cachedSolidTriangleUserAgent = ua; + cachedSolidTriangleCanonicalSize = /\bFirefox\//.test(ua) + ? SOLID_TRIANGLE_LARGE_BORDER_CANONICAL_SIZE + : SOLID_TRIANGLE_CANONICAL_SIZE; + } + return cachedSolidTriangleCanonicalSize; +} + +export function solidTriangleBorderWidth(): string | undefined { + return solidTriangleCanonicalSize() === SOLID_TRIANGLE_LARGE_BORDER_CANONICAL_SIZE + ? SOLID_TRIANGLE_LARGE_BORDER_WIDTH + : undefined; +} export interface RGB { r: number; g: number; b: number; } @@ -436,7 +458,7 @@ export function solidTriangleStyle( yAxis = [yAxisRaw[0] / nextHeight, yAxisRaw[1] / nextHeight, yAxisRaw[2] / nextHeight]; } - const SOLID_TRIANGLE_CANONICAL_SIZE = 32; + const canonicalSize = solidTriangleCanonicalSize(); const left = Math.max(0, Math.min(baseLength, apexX)); const right = Math.max(0, baseLength - left); const screenPts = [left, 0, 0, height, left + right, height]; @@ -499,11 +521,11 @@ export function solidTriangleStyle( const baseLeft = worldPoint([baseLeft2[0], baseY]); const baseRight = worldPoint([baseRight2[0], baseY]); - const halfBase = SOLID_TRIANGLE_CANONICAL_SIZE / 2; + const halfBase = canonicalSize / 2; const xCol: Vec3 = [ - (baseRight[0] - baseLeft[0]) / SOLID_TRIANGLE_CANONICAL_SIZE, - (baseRight[1] - baseLeft[1]) / SOLID_TRIANGLE_CANONICAL_SIZE, - (baseRight[2] - baseLeft[2]) / SOLID_TRIANGLE_CANONICAL_SIZE, + (baseRight[0] - baseLeft[0]) / canonicalSize, + (baseRight[1] - baseLeft[1]) / canonicalSize, + (baseRight[2] - baseLeft[2]) / canonicalSize, ]; const txCol: Vec3 = [ apex[0] - xCol[0] * halfBase, @@ -511,9 +533,9 @@ export function solidTriangleStyle( apex[2] - xCol[2] * halfBase, ]; const yCol: Vec3 = [ - (baseLeft[0] - txCol[0]) / SOLID_TRIANGLE_CANONICAL_SIZE, - (baseLeft[1] - txCol[1]) / SOLID_TRIANGLE_CANONICAL_SIZE, - (baseLeft[2] - txCol[2]) / SOLID_TRIANGLE_CANONICAL_SIZE, + (baseLeft[0] - txCol[0]) / canonicalSize, + (baseLeft[1] - txCol[1]) / canonicalSize, + (baseLeft[2] - txCol[2]) / canonicalSize, ]; const canonicalMatrix = [ xCol[0], xCol[1], xCol[2], 0, @@ -523,6 +545,7 @@ export function solidTriangleStyle( ].map((v) => (Math.round(v * 1000) / 1000 || 0).toString()).join(","); return { transform: `matrix3d(${canonicalMatrix})`, + borderWidth: solidTriangleBorderWidth(), ...sharedStyle, }; } diff --git a/packages/react/src/scene/atlas/stableTriangleDom.ts b/packages/react/src/scene/atlas/stableTriangleDom.ts index af0abd73..390ca698 100644 --- a/packages/react/src/scene/atlas/stableTriangleDom.ts +++ b/packages/react/src/scene/atlas/stableTriangleDom.ts @@ -24,6 +24,8 @@ import { stepRgbToward, offsetConvexPolygonPoints, formatStableTriangleTransformScalars, + solidTriangleBorderWidth, + solidTriangleCanonicalSize, } from "./solidTriangleStyle"; import type { RGB } from "./solidTriangleStyle"; @@ -69,6 +71,7 @@ function isStableTriangleBasis(value: StableTriangleBasis | undefined): value is interface StableTriangleDomStyle { transform: string; + borderWidth?: string; color?: string; basis: StableTriangleBasis; } @@ -218,8 +221,8 @@ function computeStableTriangleDomStyle( return retryWithoutBasis(); } - const SOLID_TRIANGLE_CANONICAL_SIZE = 32; - const invCanonicalSize = 1 / SOLID_TRIANGLE_CANONICAL_SIZE; + const canonicalSize = solidTriangleCanonicalSize(); + const invCanonicalSize = 1 / canonicalSize; const baseWidthPx = leftPx + rightPx; const xScale = baseWidthPx * invCanonicalSize; const yXScale = (rightPx - leftPx) * 0.5 * invCanonicalSize; @@ -260,7 +263,7 @@ function computeStableTriangleDomStyle( ? quantizeCssColor(shadedColor, options.colorSteps) : shadedColor; } - return { transform, color, basis: { a, b, c } }; + return { transform, borderWidth: solidTriangleBorderWidth(), color, basis: { a, b, c } }; } function stableTriangleColorAllowed(index: number, colorFrame: number, freezeFrames: number): boolean { @@ -320,6 +323,7 @@ export function updateStableTriangleDom( if (el.style.visibility) el.style.visibility = ""; el.__polycssStableTriangleBasis = style.basis; el.style.transform = style.transform; + if (style.borderWidth !== undefined) el.style.borderWidth = style.borderWidth; if (style.color !== undefined) applyStableTriangleColor(el, i, style.color, options); } return true; diff --git a/packages/react/src/styles/styles.ts b/packages/react/src/styles/styles.ts index 564fb441..2f00aecd 100644 --- a/packages/react/src/styles/styles.ts +++ b/packages/react/src/styles/styles.ts @@ -156,6 +156,10 @@ const CORE_BASE_STYLES = ` border-width: 0 16px 32px 16px; } +.polycss-scene u.polycss-large-border-triangle { + border-width: 0 48px 96px 48px; +} + .polycss-scene u.polycss-corner-triangle { width: 32px; height: 32px; diff --git a/packages/vue/src/scene/atlas/index.test.ts b/packages/vue/src/scene/atlas/index.test.ts index 7c0f3f07..30d31c9d 100644 --- a/packages/vue/src/scene/atlas/index.test.ts +++ b/packages/vue/src/scene/atlas/index.test.ts @@ -5,11 +5,13 @@ import { useTextureAtlas, computeTextureAtlasPlan, isSolidTrianglePlan, + updateStableTriangleDom, type TextureAtlasPlan, } from "./index"; import type { Polygon } from "@layoutit/polycss-core"; const originalUserAgent = window.navigator.userAgent; +const FIREFOX_UA = "Mozilla/5.0 (Macintosh; Intel Mac OS X 10.15; rv:146.0) Gecko/20100101 Firefox/146.0"; const TEXTURED_QUAD_60: Polygon = { vertices: [ @@ -135,6 +137,22 @@ describe("isSolidTrianglePlan", () => { }); }); +describe("updateStableTriangleDom", () => { + it("applies the large border triangle primitive on Firefox", () => { + stubUserAgent(FIREFOX_UA); + const root = document.createElement("div"); + const leaf = document.createElement("u"); + root.append(leaf); + const tri: Polygon = { + vertices: [[0, 0, 0], [1, 0, 0], [0, 1, 0]], + color: "#ff0000", + }; + + expect(updateStableTriangleDom(root, [tri])).toBe(true); + expect(leaf.style.borderWidth).toBe("0px 48px 96px"); + }); +}); + describe("useTextureAtlas (auto textureQuality)", () => { function buildSixFaceCrateScene(): Polygon[] { return Array.from({ length: 6 }, () => ({ ...TEXTURED_QUAD_60 })); diff --git a/packages/vue/src/scene/atlas/solidTriangleStyle.ts b/packages/vue/src/scene/atlas/solidTriangleStyle.ts index 137ec40d..6114d391 100644 --- a/packages/vue/src/scene/atlas/solidTriangleStyle.ts +++ b/packages/vue/src/scene/atlas/solidTriangleStyle.ts @@ -28,6 +28,28 @@ export const BASIS_EPS = 1e-9; const RECT_EPS = 1e-3; // Matches the canonical SOLID_TRIANGLE_BLEED constant. export const SOLID_TRIANGLE_BLEED = 0.75; +const SOLID_TRIANGLE_CANONICAL_SIZE = 32; +const SOLID_TRIANGLE_LARGE_BORDER_CANONICAL_SIZE = 96; +const SOLID_TRIANGLE_LARGE_BORDER_WIDTH = "0 48px 96px 48px"; +let cachedSolidTriangleUserAgent: string | undefined; +let cachedSolidTriangleCanonicalSize = SOLID_TRIANGLE_CANONICAL_SIZE; + +export function solidTriangleCanonicalSize(): number { + const ua = typeof navigator !== "undefined" ? navigator.userAgent : ""; + if (ua !== cachedSolidTriangleUserAgent) { + cachedSolidTriangleUserAgent = ua; + cachedSolidTriangleCanonicalSize = /\bFirefox\//.test(ua) + ? SOLID_TRIANGLE_LARGE_BORDER_CANONICAL_SIZE + : SOLID_TRIANGLE_CANONICAL_SIZE; + } + return cachedSolidTriangleCanonicalSize; +} + +export function solidTriangleBorderWidth(): string | undefined { + return solidTriangleCanonicalSize() === SOLID_TRIANGLE_LARGE_BORDER_CANONICAL_SIZE + ? SOLID_TRIANGLE_LARGE_BORDER_WIDTH + : undefined; +} export interface RGB { r: number; g: number; b: number; } @@ -431,7 +453,7 @@ export function solidTriangleStyle( yAxis = [yAxisRaw[0] / nextHeight, yAxisRaw[1] / nextHeight, yAxisRaw[2] / nextHeight]; } - const SOLID_TRIANGLE_CANONICAL_SIZE = 32; + const canonicalSize = solidTriangleCanonicalSize(); const left = Math.max(0, Math.min(baseLength, apexX)); const right = Math.max(0, baseLength - left); const screenPts = [left, 0, 0, height, left + right, height]; @@ -494,11 +516,11 @@ export function solidTriangleStyle( const baseLeft = worldPoint([baseLeft2[0], baseY]); const baseRight = worldPoint([baseRight2[0], baseY]); - const halfBase = SOLID_TRIANGLE_CANONICAL_SIZE / 2; + const halfBase = canonicalSize / 2; const xCol: Vec3 = [ - (baseRight[0] - baseLeft[0]) / SOLID_TRIANGLE_CANONICAL_SIZE, - (baseRight[1] - baseLeft[1]) / SOLID_TRIANGLE_CANONICAL_SIZE, - (baseRight[2] - baseLeft[2]) / SOLID_TRIANGLE_CANONICAL_SIZE, + (baseRight[0] - baseLeft[0]) / canonicalSize, + (baseRight[1] - baseLeft[1]) / canonicalSize, + (baseRight[2] - baseLeft[2]) / canonicalSize, ]; const txCol: Vec3 = [ apex[0] - xCol[0] * halfBase, @@ -506,9 +528,9 @@ export function solidTriangleStyle( apex[2] - xCol[2] * halfBase, ]; const yCol: Vec3 = [ - (baseLeft[0] - txCol[0]) / SOLID_TRIANGLE_CANONICAL_SIZE, - (baseLeft[1] - txCol[1]) / SOLID_TRIANGLE_CANONICAL_SIZE, - (baseLeft[2] - txCol[2]) / SOLID_TRIANGLE_CANONICAL_SIZE, + (baseLeft[0] - txCol[0]) / canonicalSize, + (baseLeft[1] - txCol[1]) / canonicalSize, + (baseLeft[2] - txCol[2]) / canonicalSize, ]; const canonicalMatrix = [ xCol[0], xCol[1], xCol[2], 0, @@ -518,6 +540,7 @@ export function solidTriangleStyle( ].map((v) => (Math.round(v * 1000) / 1000 || 0).toString()).join(","); return { transform: `matrix3d(${canonicalMatrix})`, + borderWidth: solidTriangleBorderWidth(), ...sharedStyle, }; } diff --git a/packages/vue/src/scene/atlas/stableTriangleDom.ts b/packages/vue/src/scene/atlas/stableTriangleDom.ts index 5bd79359..725cb02e 100644 --- a/packages/vue/src/scene/atlas/stableTriangleDom.ts +++ b/packages/vue/src/scene/atlas/stableTriangleDom.ts @@ -23,6 +23,8 @@ import { quantizeCssColor, stepRgbToward, offsetConvexPolygonPoints, + solidTriangleBorderWidth, + solidTriangleCanonicalSize, } from "./solidTriangleStyle"; import type { RGB } from "./solidTriangleStyle"; @@ -68,6 +70,7 @@ function isStableTriangleBasis(value: StableTriangleBasis | undefined): value is interface StableTriangleDomStyle { transform: string; + borderWidth?: string; color?: string; basis: StableTriangleBasis; } @@ -243,8 +246,8 @@ function computeStableTriangleDomStyle( return retryWithoutBasis(); } - const SOLID_TRIANGLE_CANONICAL_SIZE = 32; - const invCanonicalSize = 1 / SOLID_TRIANGLE_CANONICAL_SIZE; + const canonicalSize = solidTriangleCanonicalSize(); + const invCanonicalSize = 1 / canonicalSize; const baseWidthPx = leftPx + rightPx; const xScale = baseWidthPx * invCanonicalSize; const yXScale = (rightPx - leftPx) * 0.5 * invCanonicalSize; @@ -285,7 +288,7 @@ function computeStableTriangleDomStyle( ? quantizeCssColor(shadedColor, options.colorSteps) : shadedColor; } - return { transform, color, basis: { a, b, c } }; + return { transform, borderWidth: solidTriangleBorderWidth(), color, basis: { a, b, c } }; } function stableTriangleColorAllowed(index: number, colorFrame: number, freezeFrames: number): boolean { @@ -345,6 +348,7 @@ export function updateStableTriangleDom( if (el.style.visibility) el.style.visibility = ""; el.__polycssStableTriangleBasis = style.basis; el.style.transform = style.transform; + if (style.borderWidth !== undefined) el.style.borderWidth = style.borderWidth; if (style.color !== undefined) applyStableTriangleColor(el, i, style.color, options); } return true; diff --git a/packages/vue/src/styles/styles.ts b/packages/vue/src/styles/styles.ts index 3f87c2e5..bd07ff0d 100644 --- a/packages/vue/src/styles/styles.ts +++ b/packages/vue/src/styles/styles.ts @@ -156,6 +156,10 @@ const CORE_BASE_STYLES = ` border-width: 0 16px 32px 16px; } +.polycss-scene u.polycss-large-border-triangle { + border-width: 0 48px 96px 48px; +} + .polycss-scene u.polycss-corner-triangle { width: 32px; height: 32px; From 7bbd7fd8db0453361dd66ffceeebb9431f5a4baa Mon Sep 17 00:00:00 2001 From: agustin-littlehat Date: Mon, 25 May 2026 21:15:11 -0300 Subject: [PATCH 2/3] chore(bench): add camera perspective comparison --- bench/nonvoxel-vanilla.html | 21 +++++++++++++++++++++ bench/nonvoxel-variants.mjs | 6 ++++++ bench/nonvoxel-visual-compare.mjs | 2 ++ 3 files changed, 29 insertions(+) diff --git a/bench/nonvoxel-vanilla.html b/bench/nonvoxel-vanilla.html index a9acd6a8..000bcbdd 100644 --- a/bench/nonvoxel-vanilla.html +++ b/bench/nonvoxel-vanilla.html @@ -21,6 +21,7 @@ const { meshId, mode, motion, az, el, isSynth, preset } = parseUrlParams(); const benchParams = new URLSearchParams(window.location.search); + const cameraPerspectiveMode = benchParams.get("cameraPerspective") || "default"; const sceneTransformMode = benchParams.get("sceneTransformMode") || "default"; const rotationDriver = benchParams.get("rotationDriver") || "js"; const polygonOrder = benchParams.get("polygonOrder") || "source"; @@ -111,6 +112,7 @@ frameWorkSamples: () => frameWork.samples(), resetInteractionStats, cullStats: () => displayCullStats, + cameraPerspective: () => cameraPerspective(), setMeshPosition(_position) {}, setMeshRotation(_rotation) {}, setMeshPolygonsSame() {}, @@ -127,6 +129,7 @@ }, }; + const cameraEl = host.querySelector(".polycss-camera"); const sceneEl = host.querySelector(".polycss-scene"); let splitShell = null; let displayCullController = null; @@ -141,6 +144,24 @@ return sceneEl?.style.perspective || getComputedStyle(sceneEl).perspective || "8000px"; } + function cameraPerspective() { + return cameraEl?.style.perspective || getComputedStyle(cameraEl).perspective || ""; + } + + function applyCameraPerspectiveMode() { + if (!(cameraEl instanceof HTMLElement) || cameraPerspectiveMode === "default") return; + if (cameraPerspectiveMode === "none") { + cameraEl.style.perspective = "none"; + return; + } + const px = Number(cameraPerspectiveMode); + if (Number.isFinite(px) && px > 0) { + cameraEl.style.perspective = `${px}px`; + } + } + + applyCameraPerspectiveMode(); + function createFrameWorkRecorder(enabled) { let current = {}; const frameSamples = []; diff --git a/bench/nonvoxel-variants.mjs b/bench/nonvoxel-variants.mjs index 686f7498..0af61286 100644 --- a/bench/nonvoxel-variants.mjs +++ b/bench/nonvoxel-variants.mjs @@ -5,6 +5,12 @@ export const NONVOXEL_VARIANTS = [ params: {}, hypothesis: "Current vanilla JS scene-root rotation.", }, + { + id: "camera-perspective-none", + label: "Camera Perspective None", + params: { cameraPerspective: "none" }, + hypothesis: "Use true orthographic perspective on the camera wrapper instead of the large finite stand-in.", + }, { id: "css-keyframes", label: "CSS Keyframes", diff --git a/bench/nonvoxel-visual-compare.mjs b/bench/nonvoxel-visual-compare.mjs index 821ebc1c..7d858b46 100644 --- a/bench/nonvoxel-visual-compare.mjs +++ b/bench/nonvoxel-visual-compare.mjs @@ -187,10 +187,12 @@ async function screenshotVariant(page, port, model, variant) { renderStats: await page.evaluate(() => window.__perf__?.renderStats ?? null), sceneTransform: await page.evaluate(() => { const scene = document.querySelector(".polycss-scene"); + const camera = document.querySelector(".polycss-camera"); const host = document.getElementById("host"); return { scene: scene instanceof HTMLElement ? scene.style.transform : "", scenePerspective: scene instanceof HTMLElement ? scene.style.perspective : "", + cameraPerspective: camera instanceof HTMLElement ? camera.style.perspective : "", hostPerspective: host instanceof HTMLElement ? host.style.perspective : "", shell: document.querySelector(".polycss-scene > div") instanceof HTMLElement ? document.querySelector(".polycss-scene > div").style.transform From 4da7214f2a66377c66ec6e74e69b1aa67e866f42 Mon Sep 17 00:00:00 2001 From: agustin-littlehat Date: Mon, 25 May 2026 21:15:19 -0300 Subject: [PATCH 3/3] docs(animation): add clownfish animation fixture --- packages/core/src/parser/parseGltf.test.ts | 1 + .../public/gallery/glb/ClownfishAnimated.glb | Bin 0 -> 81332 bytes .../GalleryWorkbench/presets/attributions.ts | 1 + .../GalleryWorkbench/presets/presetFiles.ts | 1 + website/src/content/docs/guides/animation.mdx | 138 +++++++++++++----- 5 files changed, 107 insertions(+), 34 deletions(-) create mode 100644 website/public/gallery/glb/ClownfishAnimated.glb diff --git a/packages/core/src/parser/parseGltf.test.ts b/packages/core/src/parser/parseGltf.test.ts index da04f869..e211fd84 100644 --- a/packages/core/src/parser/parseGltf.test.ts +++ b/packages/core/src/parser/parseGltf.test.ts @@ -96,6 +96,7 @@ describe("parseGltf — real fixture (tree.glb)", () => { describe("parseGltf — animated fixture (FishAnimated.glb)", () => { const animatedGalleryFixtures = [ ["FishAnimated.glb", 1], + ["ClownfishAnimated.glb", 1], ["AnimatedMushnub.glb", 9], ["AnimatedWizard.glb", 9], ["AnimatedSnake.glb", 4], diff --git a/website/public/gallery/glb/ClownfishAnimated.glb b/website/public/gallery/glb/ClownfishAnimated.glb new file mode 100644 index 0000000000000000000000000000000000000000..8497478b66cb9c3a1551113c218e9776555b6427 GIT binary patch literal 81332 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®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 mJ;zLUaf$YPl5wX;dqEe = { "FishAnimated.glb": QUATERNIUS_ANIMATED_FISH_ATTRIBUTION, + "ClownfishAnimated.glb": QUATERNIUS_ANIMATED_FISH_ATTRIBUTION, "AnimatedMushnub.glb": QUATERNIUS_ANIMATED_MONSTERS_ATTRIBUTION, "AnimatedSnake.glb": QUATERNIUS_EASY_ENEMIES_ATTRIBUTION, "AnimatedWizard.glb": QUATERNIUS_ANIMATED_MONSTERS_ATTRIBUTION, diff --git a/website/src/components/GalleryWorkbench/presets/presetFiles.ts b/website/src/components/GalleryWorkbench/presets/presetFiles.ts index a4d8037e..fe0a0c1a 100644 --- a/website/src/components/GalleryWorkbench/presets/presetFiles.ts +++ b/website/src/components/GalleryWorkbench/presets/presetFiles.ts @@ -228,6 +228,7 @@ const SMITHSONIAN_GLB_PRESET_FILES: GalleryPresetFile[] = [ export const GLB_PRESET_FILES: GalleryPresetFile[] = [ { file: "FishAnimated.glb", label: "Fish", category: "Animated" }, + { file: "ClownfishAnimated.glb", label: "Clownfish", category: "Animated" }, { file: "opengameart/animated-pliers.glb", label: "Pliers", diff --git a/website/src/content/docs/guides/animation.mdx b/website/src/content/docs/guides/animation.mdx index 95350f57..ebbe1371 100644 --- a/website/src/content/docs/guides/animation.mdx +++ b/website/src/content/docs/guides/animation.mdx @@ -5,12 +5,50 @@ description: Playing glTF and GLB animation clips with PolyCSS mesh handles. import { Tabs, TabItem } from '@astrojs/starlight/components'; -PolyCSS can sample usable glTF / GLB animation clips into fresh polygon frames. The parser exposes `ParseResult.animation`; the animation mixer drives a mesh handle by calling `setPolygons()` as clips play. +PolyCSS can play usable glTF / GLB animation clips by sampling them into polygon frames. The parser exposes `ParseResult.animation`; `usePolyAnimation` and `createPolyAnimationMixer` drive a mesh handle over time. -This is the main exception to the "no per-polygon JavaScript in the render loop" rule. Skinning changes polygon vertices independently, so animation samples in JavaScript while the renderer keeps the mounted DOM topology stable where possible. +## How It Works -## React +Animation in PolyCSS is a polygon-frame pipeline: the parser turns glTF / GLB animation data into a sampler, and the renderer receives ordinary `Polygon[]` frames. +When `loadMesh()` or `parseGltf()` finds usable animation clips, the returned `ParseResult.animation` exposes clip metadata and a `sample()` function. Sampling evaluates the source animation at a given time, applies the animated pose to the mesh, and returns polygons for that moment. + +`usePolyAnimation` and `createPolyAnimationMixer` sit on top of that sampler. They manage actions, looping, playback speed, fades, and cross-fades, then apply each sampled frame to a mesh handle. + +## Usage + + + +Use the core mixer directly. The mesh handle returned by `scene.add()` satisfies `PolyAnimationTarget`. + +```ts +import { + createPolyCamera, + createPolyScene, + createPolyAnimationMixer, + loadMesh, +} from "@layoutit/polycss"; + +const camera = createPolyCamera({ rotX: 65, rotY: 45 }); +const scene = createPolyScene(host, { camera }); +const result = await loadMesh("/character.glb", { meshResolution: "lossless" }); +const mesh = scene.add(result, { merge: false, stableDom: true }); + +if (result.animation?.clips.length) { + const mixer = createPolyAnimationMixer(mesh, result.animation); + mixer.clipAction(result.animation.clips[0].name).reset().play(); + + let last = performance.now(); + function tick(now: number) { + mixer.update((now - last) / 1000); + last = now; + requestAnimationFrame(tick); + } + requestAnimationFrame(tick); +} +``` + + `usePolyAnimation` mirrors drei's `useAnimations`: it returns `clips`, `names`, `actions`, `mixer`, and a `ref`. Load the mesh yourself when you need access to both `polygons` and the parser's animation controller. ```tsx @@ -71,43 +109,75 @@ export function AnimatedModel() { ); } ``` - -## Vanilla - -Use the core mixer directly. The mesh handle returned by `scene.add()` satisfies `PolyAnimationTarget`. - -```ts + + + +The Vue composable exposes the same concepts as the React hook, but `clips`, `names`, `actions`, and `mixer` are Vue computed refs. + +```vue + + + ``` + + -## Notes +## Practical Notes -- `meshResolution="lossless"` or `merge: false` keeps authored topology predictable for animated meshes. -- Solid triangle animation keeps each mounted triangle's baked color stable while vertices move, avoiding low-poly lighting flicker from rapidly changing face normals. +- `usePolyAnimation` owns its `requestAnimationFrame` loop. Vanilla callers own the loop and call `mixer.update(deltaSeconds)` themselves. - `usePolyAnimation` and the core mixer expose familiar action methods: `play`, `stop`, `reset`, `fadeIn`, `fadeOut`, `crossFadeTo`, `setLoop`, `setEffectiveTimeScale`, and `setEffectiveWeight`. +- Cross-fading assumes the sampled clips share matching polygon counts and vertex order. That is true for clips from the same parsed mesh. - `LoopOnce`, `LoopRepeat`, and `LoopPingPong` match the three.js numeric constants. - `dispose()` still matters: parser-created blob URLs should be revoked when the model is no longer used.