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/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 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/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/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; diff --git a/website/public/gallery/glb/ClownfishAnimated.glb b/website/public/gallery/glb/ClownfishAnimated.glb new file mode 100644 index 00000000..8497478b Binary files /dev/null and b/website/public/gallery/glb/ClownfishAnimated.glb differ diff --git a/website/src/components/GalleryWorkbench/presets/attributions.ts b/website/src/components/GalleryWorkbench/presets/attributions.ts index 0a194521..43b8fc55 100644 --- a/website/src/components/GalleryWorkbench/presets/attributions.ts +++ b/website/src/components/GalleryWorkbench/presets/attributions.ts @@ -291,6 +291,7 @@ export function smithsonianOpenAccessAttribution(sourceUrl: string, tris: number export const GLB_PRESET_ATTRIBUTIONS: Record = { "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.