From 53721582d4a51bddff69ac02015b6a91472e55a6 Mon Sep 17 00:00:00 2001 From: agustin-littlehat Date: Wed, 27 May 2026 22:02:21 -0300 Subject: [PATCH 1/7] fix(renderer): normalize solid primitive sizes --- AGENTS.md | 6 +++--- packages/core/src/atlas/constants.ts | 8 ++++---- packages/core/src/atlas/matrix.test.ts | 9 ++++----- packages/core/src/atlas/matrix.ts | 2 +- .../polycss/src/api/createPolyScene.test.ts | 18 +++++++++++------- packages/polycss/src/render/polyDOM.test.ts | 10 +++++----- packages/polycss/src/styles/styles.ts | 16 ++++++++-------- packages/react/src/scene/atlas/index.test.tsx | 2 +- .../src/scene/atlas/solidTriangleStyle.ts | 9 +++++---- packages/react/src/styles/styles.test.ts | 6 +++--- packages/react/src/styles/styles.ts | 16 ++++++++-------- packages/vue/src/scene/atlas/index.test.ts | 2 +- .../vue/src/scene/atlas/solidTriangleStyle.ts | 9 +++++---- packages/vue/src/styles/styles.test.ts | 6 +++--- packages/vue/src/styles/styles.ts | 16 ++++++++-------- 15 files changed, 70 insertions(+), 65 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 19d585ea..8e5c5c10 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -31,10 +31,10 @@ Voxel-shaped meshes are the exception to "all polygons stay mounted": meshes wit | Tag | Strategy | When chosen | Paint mechanism | Atlas memory | |---|---|---|---|---| -| `` | **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 | +| `` | **Quads** | Axis-aligned rectangle, or untextured convex quad when the homography passes stability guards on non-Safari engines | `background: currentColor` on a fixed 256px 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 256px 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. 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 | +| `` | **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 256px 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 the same 256px border-triangle primitive to avoid large-perspective compositor banding. Exact corner-shape solids use a bare fixed 256px 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 6a9266ef..928667f0 100644 --- a/packages/core/src/atlas/constants.ts +++ b/packages/core/src/atlas/constants.ts @@ -44,16 +44,16 @@ export const DEFAULT_MATRIX_DECIMALS = 3; export const DEFAULT_BORDER_SHAPE_DECIMALS = 2; 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_QUAD_CANONICAL_SIZE = 256; +export const SOLID_TRIANGLE_CANONICAL_SIZE = 256; +export const SOLID_TRIANGLE_LARGE_BORDER_CANONICAL_SIZE = 256; 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; export const BORDER_SHAPE_POINT_EPS = 1e-7; -export const BORDER_SHAPE_CANONICAL_SIZE = 16; +export const BORDER_SHAPE_CANONICAL_SIZE = 256; export const BORDER_SHAPE_BLEED = 0.9; export const CORNER_SHAPE_POINT_EPS = 0.75; export const CORNER_SHAPE_DUPLICATE_EPS = 0.2; diff --git a/packages/core/src/atlas/matrix.test.ts b/packages/core/src/atlas/matrix.test.ts index 7e5011c7..91386dbf 100644 --- a/packages/core/src/atlas/matrix.test.ts +++ b/packages/core/src/atlas/matrix.test.ts @@ -116,7 +116,7 @@ const FLAT_RECT: Polygon = { color: "#00ff00", }; -describe("formatSolidQuadEntryMatrix — canonical 64px quad wrap", () => { +describe("formatSolidQuadEntryMatrix — canonical quad wrap", () => { it("returns a matrix3d(...) wrapped string", () => { const plan = computeTextureAtlasPlanPublic(FLAT_RECT, 0)!; const result = formatSolidQuadEntryMatrix(plan); @@ -147,7 +147,7 @@ describe("formatSolidQuadEntryMatrix — canonical 64px quad wrap", () => { }); // --------------------------------------------------------------------------- -// formatBorderShapeEntryMatrix — canonical 16px border-shape wrap +// formatBorderShapeEntryMatrix — canonical border-shape wrap // --------------------------------------------------------------------------- const NON_RECT_POLYGON: Polygon = { @@ -160,7 +160,7 @@ const NON_RECT_POLYGON: Polygon = { color: "#0000ff", }; -describe("formatBorderShapeEntryMatrix — canonical 16px border-shape wrap", () => { +describe("formatBorderShapeEntryMatrix — canonical border-shape wrap", () => { it("returns a matrix3d(...) wrapped string", () => { const plan = computeTextureAtlasPlanPublic(NON_RECT_POLYGON, 0)!; const result = formatBorderShapeEntryMatrix(plan); @@ -183,11 +183,10 @@ describe("formatBorderShapeEntryMatrix — canonical 16px border-shape wrap", () expect(values.every(Number.isFinite)).toBe(true); }); - it("solid-quad and border-shape matrices differ due to different canonical sizes (64px vs 16px)", () => { + it("solid-quad and border-shape matrices differ because border-shape uses clipped bounds", () => { const plan = computeTextureAtlasPlanPublic(NON_RECT_POLYGON, 0)!; const quadMatrix = formatSolidQuadEntryMatrix(plan); const borderMatrix = formatBorderShapeEntryMatrix(plan); - // Border-shape canonical size is 16, solid-quad is 64 — scale differs by 4x expect(quadMatrix).not.toBe(borderMatrix); }); }); diff --git a/packages/core/src/atlas/matrix.ts b/packages/core/src/atlas/matrix.ts index b9403b33..891cfdd2 100644 --- a/packages/core/src/atlas/matrix.ts +++ b/packages/core/src/atlas/matrix.ts @@ -221,7 +221,7 @@ export function formatCssLengthPx(value: number, decimals = DEFAULT_ATLAS_CSS_DE /** * Produce the CSS matrix3d transform for a solid-quad (``) leaf, including - * the canonical 64px primitive scale. + * the canonical primitive scale. */ export function formatSolidQuadEntryMatrix(entry: TextureAtlasPlan): string { return `matrix3d(${formatSolidQuadMatrix(entry)})`; diff --git a/packages/polycss/src/api/createPolyScene.test.ts b/packages/polycss/src/api/createPolyScene.test.ts index 813ae552..626c7b79 100644 --- a/packages/polycss/src/api/createPolyScene.test.ts +++ b/packages/polycss/src/api/createPolyScene.test.ts @@ -425,11 +425,11 @@ describe("createPolyScene", () => { expect(styleEl?.textContent).toContain("transform-origin: 0 0"); expect(styleEl?.textContent).toContain("backface-visibility: hidden"); expect(styleEl?.textContent).toContain("background-repeat: no-repeat"); - expect(styleEl?.textContent).toContain("width: 64px;"); - expect(styleEl?.textContent).toContain("height: 64px;"); + expect(styleEl?.textContent).toContain("width: 256px;"); + expect(styleEl?.textContent).toContain("height: 256px;"); expect(styleEl?.textContent).toContain("width: var(--polycss-atlas-size, 64px);"); expect(styleEl?.textContent).toContain("height: var(--polycss-atlas-size, 64px);"); - expect(styleEl?.textContent).toContain("border-width: 0 16px 32px 16px;"); + expect(styleEl?.textContent).toContain("border-width: 0 128px 256px 128px;"); expect(styleEl?.textContent).toContain("width: 0;"); expect(styleEl?.textContent).toContain("height: 0;"); }); @@ -496,7 +496,9 @@ describe("createPolyScene", () => { expect(brush!.style.color).toMatch(/^(#123456|rgb\\(18, 52, 86\\))$/); expect(brush!.style.width).toBe(""); expect(brush!.style.height).toBe(""); - expect(brush!.style.transform).toContain("matrix3d(50,0,0,0,0,50"); + const matrix = matrixValues(brush!); + expect(matrix[0]).toBeCloseTo(50, 3); + expect(matrix[5]).toBeCloseTo(50, 3); }); it("adds tiny overscan to same-color shared direct voxel edges", () => { @@ -513,7 +515,7 @@ describe("createPolyScene", () => { expect(brushes.length).toBeGreaterThan(0); const matrices = brushes.map(matrixValues); expect(matrices.some((values) => - values.some((value) => Math.abs(value - 50.6) <= 1e-6) + values.some((value) => Math.abs(value - 50.6) <= 1e-4) )).toBe(true); }); @@ -531,7 +533,7 @@ describe("createPolyScene", () => { expect(brushes.length).toBeGreaterThan(0); const matrices = brushes.map(matrixValues); expect(matrices.some((values) => - values.some((value) => Math.abs(value - 50.6) <= 1e-6) + values.some((value) => Math.abs(value - 50.6) <= 1e-4) )).toBe(true); }); @@ -584,7 +586,9 @@ describe("createPolyScene", () => { expect(wrapper!.style.getPropertyValue("--polycss-voxel-primitive")).toBe("8px"); expect(brush!.style.width).toBe(""); expect(brush!.style.height).toBe(""); - expect(brush!.style.transform).toContain("matrix3d(6.25,0,0,0,0,6.25"); + const matrix = matrixValues(brush!); + expect(matrix[0]).toBeCloseTo(6.25, 3); + expect(matrix[5]).toBeCloseTo(6.25, 3); } finally { Object.defineProperty(window, "matchMedia", { configurable: true, diff --git a/packages/polycss/src/render/polyDOM.test.ts b/packages/polycss/src/render/polyDOM.test.ts index 9c8351dc..17d14396 100644 --- a/packages/polycss/src/render/polyDOM.test.ts +++ b/packages/polycss/src/render/polyDOM.test.ts @@ -15,7 +15,7 @@ import { const ATLAS_CANONICAL_SIZE_EXPLICIT = 64; const ATLAS_CANONICAL_SIZE_AUTO_DESKTOP = 128; -const SOLID_QUAD_CANONICAL_SIZE = 64; +const SOLID_QUAD_CANONICAL_SIZE = 256; const CORNER_SHAPE_CORPUS = [ ["Bear.glb"], @@ -786,8 +786,8 @@ describe("renderPolygonsWithTextureAtlas", () => { const yScale = Math.hypot(matrix[4], matrix[5], matrix[6]); expect(element.tagName.toLowerCase()).toBe("i"); - expect(xScale).toBeGreaterThan(2 / 16); - expect(yScale).toBeGreaterThan(2 / 16); + expect(xScale).toBeGreaterThan(2 / 256); + expect(yScale).toBeGreaterThan(2 / 256); expect(element.style.getPropertyValue("border-shape")).toContain("polygon("); result.dispose(); }); @@ -1032,10 +1032,10 @@ describe("renderPolygonsWithTextureAtlas", () => { expect(element.style.height).toBe(""); expect(element.style.getPropertyValue("--polycss-local-w")).toBe(""); expect(element.style.getPropertyValue("--polycss-local-h")).toBe(""); - expect(matrix[0]).toBeGreaterThan(10 / 16); + expect(matrix[0]).toBeGreaterThan(10 / 256); expect(matrix[1]).toBeCloseTo(0, 6); expect(matrix[4]).toBeCloseTo(0, 6); - expect(matrix[5]).toBeGreaterThan(1 / 16); + expect(matrix[5]).toBeGreaterThan(1 / 256); result.dispose(); }); diff --git a/packages/polycss/src/styles/styles.ts b/packages/polycss/src/styles/styles.ts index 92559c80..04782d43 100644 --- a/packages/polycss/src/styles/styles.ts +++ b/packages/polycss/src/styles/styles.ts @@ -105,8 +105,8 @@ const CORE_BASE_STYLES = ` .polycss-scene b { background: currentColor; - width: 64px; - height: 64px; + width: 256px; + height: 256px; } .polycss-mesh.polycss-voxel-mesh > .polycss-voxel-face { @@ -135,8 +135,8 @@ const CORE_BASE_STYLES = ` } .polycss-scene i { - width: 16px; - height: 16px; + width: 256px; + height: 256px; border-color: currentColor; } @@ -152,16 +152,16 @@ const CORE_BASE_STYLES = ` box-sizing: content-box; border: 0 solid transparent; border-color: transparent transparent currentColor transparent; - border-width: 0 16px 32px 16px; + border-width: 0 128px 256px 128px; } .polycss-scene u.polycss-large-border-triangle { - border-width: 0 48px 96px 48px; + border-width: 0 128px 256px 128px; } .polycss-scene u.polycss-corner-triangle { - width: 32px; - height: 32px; + width: 256px; + height: 256px; background: currentColor; border: 0; border-top-left-radius: 50% 100%; diff --git a/packages/react/src/scene/atlas/index.test.tsx b/packages/react/src/scene/atlas/index.test.tsx index d1671d32..ba8e95fc 100644 --- a/packages/react/src/scene/atlas/index.test.tsx +++ b/packages/react/src/scene/atlas/index.test.tsx @@ -205,7 +205,7 @@ describe("updateStableTriangleDom", () => { }; expect(updateStableTriangleDom(root, [tri])).toBe(true); - expect(leaf.style.borderWidth).toBe("0px 48px 96px"); + expect(leaf.style.borderWidth).toBe("0px 128px 256px"); }); }); diff --git a/packages/react/src/scene/atlas/solidTriangleStyle.ts b/packages/react/src/scene/atlas/solidTriangleStyle.ts index 1e5dc0e2..3cee9e53 100644 --- a/packages/react/src/scene/atlas/solidTriangleStyle.ts +++ b/packages/react/src/scene/atlas/solidTriangleStyle.ts @@ -27,9 +27,9 @@ 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"; +const SOLID_TRIANGLE_CANONICAL_SIZE = 256; +const SOLID_TRIANGLE_LARGE_BORDER_CANONICAL_SIZE = 256; +const SOLID_TRIANGLE_LARGE_BORDER_WIDTH = "0 128px 256px 128px"; let cachedSolidTriangleUserAgent: string | undefined; let cachedSolidTriangleCanonicalSize = SOLID_TRIANGLE_CANONICAL_SIZE; @@ -45,7 +45,8 @@ export function solidTriangleCanonicalSize(): number { } export function solidTriangleBorderWidth(): string | undefined { - return solidTriangleCanonicalSize() === SOLID_TRIANGLE_LARGE_BORDER_CANONICAL_SIZE + const ua = typeof navigator !== "undefined" ? navigator.userAgent : ""; + return /\bFirefox\//.test(ua) ? SOLID_TRIANGLE_LARGE_BORDER_WIDTH : undefined; } diff --git a/packages/react/src/styles/styles.test.ts b/packages/react/src/styles/styles.test.ts index 3d0b6238..3f568e96 100644 --- a/packages/react/src/styles/styles.test.ts +++ b/packages/react/src/styles/styles.test.ts @@ -48,11 +48,11 @@ describe("injectPolyBaseStyles", () => { expect(el.textContent).toContain("transform-origin: 0 0"); expect(el.textContent).toContain("backface-visibility: hidden"); expect(el.textContent).toContain("background-repeat: no-repeat"); - expect(el.textContent).toContain("width: 64px;"); - expect(el.textContent).toContain("height: 64px;"); + expect(el.textContent).toContain("width: 256px;"); + expect(el.textContent).toContain("height: 256px;"); expect(el.textContent).toContain("width: var(--polycss-atlas-size, 64px);"); expect(el.textContent).toContain("height: var(--polycss-atlas-size, 64px);"); - expect(el.textContent).toContain("border-width: 0 16px 32px 16px;"); + expect(el.textContent).toContain("border-width: 0 128px 256px 128px;"); expect(el.textContent).toContain("width: 0;"); expect(el.textContent).toContain("height: 0;"); }); diff --git a/packages/react/src/styles/styles.ts b/packages/react/src/styles/styles.ts index 2f00aecd..4f7fdd44 100644 --- a/packages/react/src/styles/styles.ts +++ b/packages/react/src/styles/styles.ts @@ -106,8 +106,8 @@ const CORE_BASE_STYLES = ` .polycss-scene b { background: currentColor; - width: 64px; - height: 64px; + width: 256px; + height: 256px; } .polycss-mesh.polycss-voxel-mesh > .polycss-voxel-face { @@ -136,8 +136,8 @@ const CORE_BASE_STYLES = ` } .polycss-scene i { - width: 16px; - height: 16px; + width: 256px; + height: 256px; border-color: currentColor; } @@ -153,16 +153,16 @@ const CORE_BASE_STYLES = ` box-sizing: content-box; border: 0 solid transparent; border-color: transparent transparent currentColor transparent; - border-width: 0 16px 32px 16px; + border-width: 0 128px 256px 128px; } .polycss-scene u.polycss-large-border-triangle { - border-width: 0 48px 96px 48px; + border-width: 0 128px 256px 128px; } .polycss-scene u.polycss-corner-triangle { - width: 32px; - height: 32px; + width: 256px; + height: 256px; background: currentColor; border: 0; border-top-left-radius: 50% 100%; diff --git a/packages/vue/src/scene/atlas/index.test.ts b/packages/vue/src/scene/atlas/index.test.ts index 30d31c9d..1513c3a8 100644 --- a/packages/vue/src/scene/atlas/index.test.ts +++ b/packages/vue/src/scene/atlas/index.test.ts @@ -149,7 +149,7 @@ describe("updateStableTriangleDom", () => { }; expect(updateStableTriangleDom(root, [tri])).toBe(true); - expect(leaf.style.borderWidth).toBe("0px 48px 96px"); + expect(leaf.style.borderWidth).toBe("0px 128px 256px"); }); }); diff --git a/packages/vue/src/scene/atlas/solidTriangleStyle.ts b/packages/vue/src/scene/atlas/solidTriangleStyle.ts index 6114d391..a63d9080 100644 --- a/packages/vue/src/scene/atlas/solidTriangleStyle.ts +++ b/packages/vue/src/scene/atlas/solidTriangleStyle.ts @@ -28,9 +28,9 @@ 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"; +const SOLID_TRIANGLE_CANONICAL_SIZE = 256; +const SOLID_TRIANGLE_LARGE_BORDER_CANONICAL_SIZE = 256; +const SOLID_TRIANGLE_LARGE_BORDER_WIDTH = "0 128px 256px 128px"; let cachedSolidTriangleUserAgent: string | undefined; let cachedSolidTriangleCanonicalSize = SOLID_TRIANGLE_CANONICAL_SIZE; @@ -46,7 +46,8 @@ export function solidTriangleCanonicalSize(): number { } export function solidTriangleBorderWidth(): string | undefined { - return solidTriangleCanonicalSize() === SOLID_TRIANGLE_LARGE_BORDER_CANONICAL_SIZE + const ua = typeof navigator !== "undefined" ? navigator.userAgent : ""; + return /\bFirefox\//.test(ua) ? SOLID_TRIANGLE_LARGE_BORDER_WIDTH : undefined; } diff --git a/packages/vue/src/styles/styles.test.ts b/packages/vue/src/styles/styles.test.ts index d163db85..93a4b722 100644 --- a/packages/vue/src/styles/styles.test.ts +++ b/packages/vue/src/styles/styles.test.ts @@ -47,11 +47,11 @@ describe("injectPolyBaseStyles", () => { expect(el.textContent).toContain("transform-origin: 0 0"); expect(el.textContent).toContain("backface-visibility: hidden"); expect(el.textContent).toContain("background-repeat: no-repeat"); - expect(el.textContent).toContain("width: 64px;"); - expect(el.textContent).toContain("height: 64px;"); + expect(el.textContent).toContain("width: 256px;"); + expect(el.textContent).toContain("height: 256px;"); expect(el.textContent).toContain("width: var(--polycss-atlas-size, 64px);"); expect(el.textContent).toContain("height: var(--polycss-atlas-size, 64px);"); - expect(el.textContent).toContain("border-width: 0 16px 32px 16px;"); + expect(el.textContent).toContain("border-width: 0 128px 256px 128px;"); expect(el.textContent).toContain("width: 0;"); expect(el.textContent).toContain("height: 0;"); }); diff --git a/packages/vue/src/styles/styles.ts b/packages/vue/src/styles/styles.ts index bd07ff0d..bab3ad05 100644 --- a/packages/vue/src/styles/styles.ts +++ b/packages/vue/src/styles/styles.ts @@ -106,8 +106,8 @@ const CORE_BASE_STYLES = ` .polycss-scene b { background: currentColor; - width: 64px; - height: 64px; + width: 256px; + height: 256px; } .polycss-mesh.polycss-voxel-mesh > .polycss-voxel-face { @@ -136,8 +136,8 @@ const CORE_BASE_STYLES = ` } .polycss-scene i { - width: 16px; - height: 16px; + width: 256px; + height: 256px; border-color: currentColor; } @@ -153,16 +153,16 @@ const CORE_BASE_STYLES = ` box-sizing: content-box; border: 0 solid transparent; border-color: transparent transparent currentColor transparent; - border-width: 0 16px 32px 16px; + border-width: 0 128px 256px 128px; } .polycss-scene u.polycss-large-border-triangle { - border-width: 0 48px 96px 48px; + border-width: 0 128px 256px 128px; } .polycss-scene u.polycss-corner-triangle { - width: 32px; - height: 32px; + width: 256px; + height: 256px; background: currentColor; border: 0; border-top-left-radius: 50% 100%; From e70fda1c002c1858aa802c06155294d68ae93386 Mon Sep 17 00:00:00 2001 From: agustin-littlehat Date: Wed, 27 May 2026 22:02:32 -0300 Subject: [PATCH 2/7] fix(website): isolate fpv perspective camera --- .../BuilderWorkbench/components/BuilderScene.tsx | 8 +++++--- website/src/components/BuilderWorkbench/defaults.ts | 2 +- website/src/components/Dock/folders/useCameraFolder.ts | 1 + .../src/components/GalleryWorkbench/GalleryWorkbench.tsx | 4 ++-- website/src/components/VanillaScene/VanillaScene.tsx | 6 ++++-- website/src/components/fpv/index.ts | 2 +- website/src/components/fpv/useFpvSpawn.ts | 6 +++++- 7 files changed, 19 insertions(+), 10 deletions(-) diff --git a/website/src/components/BuilderWorkbench/components/BuilderScene.tsx b/website/src/components/BuilderWorkbench/components/BuilderScene.tsx index 62fa824c..ecf0ac81 100644 --- a/website/src/components/BuilderWorkbench/components/BuilderScene.tsx +++ b/website/src/components/BuilderWorkbench/components/BuilderScene.tsx @@ -24,6 +24,7 @@ import type { } from "@layoutit/polycss-react"; import { useEffect, useMemo, useRef, useState, type RefObject } from "react"; import { meshResolutionShowsMesh, type SceneOptionsState } from "../../types"; +import { FPV_PERSPECTIVE } from "../../fpv"; import { BUILDER_GROUND_SPAN, BUILDER_MAX_CAMERA_ROT_X } from "../defaults"; import { buildSolidWireframePolygons } from "../geometry/ghost"; import { meshBbox } from "../geometry/meshBbox"; @@ -416,12 +417,13 @@ export function BuilderScene({ onRemoveItem, selected, }: BuilderSceneProps) { - const Cam = sceneOptions.perspective === false ? PolyOrthographicCamera : PolyPerspectiveCamera; + const perspective = sceneOptions.dragMode === "fpv" ? FPV_PERSPECTIVE : sceneOptions.perspective; + const Cam = perspective === false ? PolyOrthographicCamera : PolyPerspectiveCamera; const sceneKey = sceneOptions.meshResolution; const [addHoverCell, setAddHoverCell] = useState<[number, number] | null>(null); - const camProps = sceneOptions.perspective === false + const camProps = perspective === false ? { zoom: sceneOptions.zoom, rotX: sceneOptions.rotX, rotY: sceneOptions.rotY, target: sceneOptions.target } - : { zoom: sceneOptions.zoom, rotX: sceneOptions.rotX, rotY: sceneOptions.rotY, target: sceneOptions.target, perspective: sceneOptions.perspective }; + : { zoom: sceneOptions.zoom, rotX: sceneOptions.rotX, rotY: sceneOptions.rotY, target: sceneOptions.target, perspective }; const handleCameraChange = (cam: { rotX: number; rotY: number; zoom: number; target?: Vec3 }) => updateScene({ rotX: cam.rotX, rotY: cam.rotY, diff --git a/website/src/components/BuilderWorkbench/defaults.ts b/website/src/components/BuilderWorkbench/defaults.ts index 8468f7ca..2d5d518c 100644 --- a/website/src/components/BuilderWorkbench/defaults.ts +++ b/website/src/components/BuilderWorkbench/defaults.ts @@ -31,7 +31,7 @@ export const DEFAULT_SCENE: SceneOptionsState = { zoom: 0.3, rotX: 65, rotY: 45, - perspective: false, + perspective: 10000, lightAzimuth: 50, lightElevation: 45, lightIntensity: 1, diff --git a/website/src/components/Dock/folders/useCameraFolder.ts b/website/src/components/Dock/folders/useCameraFolder.ts index 4f4984b8..7565d81e 100644 --- a/website/src/components/Dock/folders/useCameraFolder.ts +++ b/website/src/components/Dock/folders/useCameraFolder.ts @@ -82,6 +82,7 @@ const PERSPECTIVE_PX_OPTIONS: Record = { "2000 px": 2000, "4000 px": 4000, "8000 px": 8000, + "10000 px": 10000, "16000 px": 16000, "32000 px": 32000, "64000 px": 64000, diff --git a/website/src/components/GalleryWorkbench/GalleryWorkbench.tsx b/website/src/components/GalleryWorkbench/GalleryWorkbench.tsx index 1a997aaf..40a68ea2 100644 --- a/website/src/components/GalleryWorkbench/GalleryWorkbench.tsx +++ b/website/src/components/GalleryWorkbench/GalleryWorkbench.tsx @@ -109,7 +109,7 @@ const DEFAULT_SCENE: SceneOptionsState = { zoom: PRESETS[0].zoom ?? 0.35, rotX: PRESETS[0].rotX ?? 65, rotY: PRESETS[0].rotY ?? 45, - perspective: false, + perspective: 10000, lightAzimuth: 50, lightElevation: 45, lightIntensity: 1, @@ -1062,7 +1062,7 @@ export default function GalleryWorkbench() { return options; }, [selectableAnimationClips]); const perspectiveMode = sceneOptions.perspective === false ? "orthographic" : "perspective"; - const perspectivePx = sceneOptions.perspective === false ? 8000 : sceneOptions.perspective; + const perspectivePx = sceneOptions.perspective === false ? 10000 : sceneOptions.perspective; // Materials data — grouped by mesh, then by canonical polygon color. const inspectorMeshes = useMemo(() => { diff --git a/website/src/components/VanillaScene/VanillaScene.tsx b/website/src/components/VanillaScene/VanillaScene.tsx index 3d6c02e2..9f73a9d5 100644 --- a/website/src/components/VanillaScene/VanillaScene.tsx +++ b/website/src/components/VanillaScene/VanillaScene.tsx @@ -27,6 +27,7 @@ import type { Vec3, } from "@layoutit/polycss"; import { meshResolutionShowsMesh, type GizmoMode, type SceneOptionsState } from "../types"; +import { FPV_PERSPECTIVE } from "../fpv"; export type { GizmoMode, SceneOptionsState }; @@ -296,8 +297,9 @@ export function VanillaScene({ zoom: options.zoom, target: options.target as Vec3 | undefined, }; - const camera = options.perspective - ? createPolyPerspectiveCamera({ ...cameraOpts, perspective: options.perspective }) + const perspective = options.dragMode === "fpv" ? FPV_PERSPECTIVE : options.perspective; + const camera = perspective + ? createPolyPerspectiveCamera({ ...cameraOpts, perspective }) : createPolyOrthographicCamera(cameraOpts); cameraRef.current = camera; const sceneOptions: PolySceneOptions = { diff --git a/website/src/components/fpv/index.ts b/website/src/components/fpv/index.ts index 20a5e8e0..db48e37f 100644 --- a/website/src/components/fpv/index.ts +++ b/website/src/components/fpv/index.ts @@ -1,6 +1,6 @@ export { useFpvHost } from "./useFpvHost"; export type { UseFpvHostOptions } from "./useFpvHost"; -export { useFpvSpawn } from "./useFpvSpawn"; +export { FPV_PERSPECTIVE, useFpvSpawn } from "./useFpvSpawn"; export type { UseFpvSpawnOptions } from "./useFpvSpawn"; export { useFpvCull } from "./useFpvCull"; export type { FpvCullItem, UseFpvCullOptions } from "./useFpvCull"; diff --git a/website/src/components/fpv/useFpvSpawn.ts b/website/src/components/fpv/useFpvSpawn.ts index 9663d495..878ff1c1 100644 --- a/website/src/components/fpv/useFpvSpawn.ts +++ b/website/src/components/fpv/useFpvSpawn.ts @@ -2,6 +2,9 @@ import { useEffect, useRef } from "react"; import type { Polygon } from "@layoutit/polycss-react"; import type { SceneOptionsState } from "../types"; +// FPV uses its own first-person projection instead of inheriting the orbit/gallery camera. +export const FPV_PERSPECTIVE = 2000; + export interface UseFpvSpawnOptions { dragMode: SceneOptionsState["dragMode"]; autoCenter: boolean; @@ -47,6 +50,7 @@ export function useFpvSpawn({ } const partial: Partial = { autoCenter: false, + perspective: FPV_PERSPECTIVE, }; if (Number.isFinite(minZ)) { // Three.js-style spawn: place the CAMERA ORIGIN outside the mesh, @@ -84,5 +88,5 @@ export function useFpvSpawn({ } if (Object.keys(restored).length > 0) updateScene(restored); } - }, [dragMode, autoCenter, scenePolygons, updateScene]); + }, [dragMode, autoCenter, perspective, rotY, scenePolygons, updateScene]); } From 50a168b13001fabe13a03da57cc883b87ae44061 Mon Sep 17 00:00:00 2001 From: agustin-littlehat Date: Thu, 28 May 2026 10:25:04 -0300 Subject: [PATCH 3/7] fix(renderer): inline solid triangle primitive paint --- packages/core/src/atlas/constants.ts | 2 - packages/core/src/index.ts | 2 - .../polycss/src/api/createPolyScene.test.ts | 2 +- .../src/elements/PolyPolygonElement.test.ts | 2 +- .../atlas/solidTrianglePrimitive.test.ts | 28 +++++---- .../src/render/atlas/stableTriangle.test.ts | 11 ++-- .../src/render/atlas/stableTriangle.ts | 25 ++++++-- packages/polycss/src/render/atlas/strategy.ts | 6 ++ packages/polycss/src/render/polyDOM.test.ts | 25 +++++--- packages/polycss/src/styles/styles.ts | 15 ----- packages/react/src/scene/PolyScene.test.tsx | 2 +- .../react/src/scene/atlas/detection.test.ts | 10 ++++ packages/react/src/scene/atlas/detection.ts | 6 ++ packages/react/src/scene/atlas/index.test.tsx | 25 ++++++++ .../src/scene/atlas/solidTriangleStyle.ts | 59 ++++++++++++++++++- .../src/scene/atlas/stableTriangleDom.ts | 2 + packages/react/src/shapes/Poly.test.tsx | 1 - packages/react/src/styles/styles.ts | 15 ----- packages/vue/src/scene/PolyScene.test.ts | 2 +- .../vue/src/scene/atlas/detection.test.ts | 10 ++++ packages/vue/src/scene/atlas/detection.ts | 6 ++ packages/vue/src/scene/atlas/index.test.ts | 25 ++++++++ .../vue/src/scene/atlas/solidTriangleStyle.ts | 59 ++++++++++++++++++- .../vue/src/scene/atlas/stableTriangleDom.ts | 2 + packages/vue/src/shapes/Poly.test.ts | 1 - packages/vue/src/styles/styles.ts | 15 ----- 26 files changed, 270 insertions(+), 88 deletions(-) diff --git a/packages/core/src/atlas/constants.ts b/packages/core/src/atlas/constants.ts index 928667f0..ba863b5d 100644 --- a/packages/core/src/atlas/constants.ts +++ b/packages/core/src/atlas/constants.ts @@ -47,8 +47,6 @@ export const DECIMAL_SCALES = [1, 10, 100, 1000, 10000, 100000, 1000000]; export const SOLID_QUAD_CANONICAL_SIZE = 256; export const SOLID_TRIANGLE_CANONICAL_SIZE = 256; export const SOLID_TRIANGLE_LARGE_BORDER_CANONICAL_SIZE = 256; -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/index.ts b/packages/core/src/index.ts index 9c1069db..653ca2ab 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -234,8 +234,6 @@ export { 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/api/createPolyScene.test.ts b/packages/polycss/src/api/createPolyScene.test.ts index 626c7b79..0c09c12c 100644 --- a/packages/polycss/src/api/createPolyScene.test.ts +++ b/packages/polycss/src/api/createPolyScene.test.ts @@ -1101,7 +1101,7 @@ describe("createPolyScene", () => { expect(poly).not.toBeNull(); expect(poly.tagName.toLowerCase()).toBe("u"); expect(poly.style.transform).toContain("matrix3d("); - expect(poly.style.borderBottomWidth).toBe(""); + expect(poly.className).toBe(""); }); describe("rebakeAtlas", () => { diff --git a/packages/polycss/src/elements/PolyPolygonElement.test.ts b/packages/polycss/src/elements/PolyPolygonElement.test.ts index 425beb1a..917731b9 100644 --- a/packages/polycss/src/elements/PolyPolygonElement.test.ts +++ b/packages/polycss/src/elements/PolyPolygonElement.test.ts @@ -130,7 +130,7 @@ describe("PolyPolygonElement — inside poly-scene", () => { const rendered = sceneEl.querySelector("u"); expect(rendered).toBeTruthy(); - expect(rendered?.className === "" || rendered?.className === "polycss-corner-triangle").toBe(true); + expect(rendered?.className).toBe(""); cleanup(); }); diff --git a/packages/polycss/src/render/atlas/solidTrianglePrimitive.test.ts b/packages/polycss/src/render/atlas/solidTrianglePrimitive.test.ts index e634a82c..467f2dab 100644 --- a/packages/polycss/src/render/atlas/solidTrianglePrimitive.test.ts +++ b/packages/polycss/src/render/atlas/solidTrianglePrimitive.test.ts @@ -2,8 +2,8 @@ * Feature tests: solid triangle primitive dispatch (border vs corner-bevel) * * Covers the resolveSolidTrianglePrimitive observable result via the public - * renderPolygonsWithStableTriangles API — whether the element gets the - * polycss-corner-triangle class (corner-bevel) or not (border). + * renderPolygonsWithStableTriangles API — whether the element gets inline + * corner-shape paint (corner-bevel) or keeps border-triangle paint (border). * * We also verify the dispatch from renderPolygonsWithTextureAtlas: * triangles use the cheapest supported primitive and fall through correctly. @@ -70,30 +70,35 @@ const TRIANGLE_2: Polygon = { // --------------------------------------------------------------------------- describe("solid triangle primitive — corner-bevel vs border", () => { - it("corner-shape supported → polycss-corner-triangle class is present", () => { + it("corner-shape supported → corner paint is inline and classless", () => { const doc = makeDoc({ cornerShape: true }); const result = renderPolygonsWithStableTriangles([TRIANGLE], { doc }); expect(result).not.toBeNull(); - expect(result!.rendered[0].element.classList.contains("polycss-corner-triangle")).toBe(true); + const element = result!.rendered[0].element; + expect(element.className).toBe(""); + expect(element.style.getPropertyValue("corner-top-left-shape")).toBe("bevel"); result!.dispose(); }); - it("corner-shape NOT supported → polycss-corner-triangle class is absent", () => { + it("corner-shape NOT supported → border paint is classless", () => { const doc = makeDoc({ cornerShape: false }); const result = renderPolygonsWithStableTriangles([TRIANGLE], { doc }); expect(result).not.toBeNull(); - expect(result!.rendered[0].element.classList.contains("polycss-corner-triangle")).toBe(false); + const element = result!.rendered[0].element; + expect(element.className).toBe(""); + expect(element.style.getPropertyValue("corner-top-left-shape")).toBe(""); result!.dispose(); }); - it("Firefox UA → uses the large border triangle primitive", () => { + it("Firefox UA → uses the large border triangle primitive inline", () => { 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); + const element = result!.rendered[0].element; + expect(element.className).toBe(""); + expect(element.style.borderWidth).toBe("0px 128px 256px"); result!.dispose(); }); @@ -165,12 +170,13 @@ describe("solid triangle primitive — strategy disable interactions", () => { expect(result).toBeNull(); }); - it("multiple triangles: all get the same primitive class consistently", () => { + it("multiple triangles: all get the same inline primitive consistently", () => { const doc = makeDoc({ cornerShape: true }); const result = renderPolygonsWithStableTriangles([TRIANGLE, TRIANGLE_2], { doc }); expect(result).not.toBeNull(); for (const { element } of result!.rendered) { - expect(element.classList.contains("polycss-corner-triangle")).toBe(true); + expect(element.className).toBe(""); + expect(element.style.getPropertyValue("corner-top-left-shape")).toBe("bevel"); } result!.dispose(); }); diff --git a/packages/polycss/src/render/atlas/stableTriangle.test.ts b/packages/polycss/src/render/atlas/stableTriangle.test.ts index 803186f7..4fe76e1e 100644 --- a/packages/polycss/src/render/atlas/stableTriangle.test.ts +++ b/packages/polycss/src/render/atlas/stableTriangle.test.ts @@ -139,20 +139,23 @@ describe("renderPolygonsWithStableTriangles — initial render", () => { bleed.dispose(); }); - it("adds polycss-corner-triangle class when corner-shape is supported", () => { + it("applies corner-shape triangle paint inline when supported", () => { const doc = makeDoc({ cornerShape: true }); const result = renderPolygonsWithStableTriangles([TRIANGLE_A], { doc }); expect(result).not.toBeNull(); const el = result!.rendered[0].element; - expect(el.classList.contains("polycss-corner-triangle")).toBe(true); + expect(el.className).toBe(""); + expect(el.style.getPropertyValue("corner-top-left-shape")).toBe("bevel"); + expect(el.style.backgroundColor).toBe("currentcolor"); result!.dispose(); }); - it("does NOT add polycss-corner-triangle class when corner-shape is unsupported", () => { + it("keeps border-triangle paint classless when corner-shape is unsupported", () => { const doc = makeDoc({ cornerShape: false }); const result = renderPolygonsWithStableTriangles([TRIANGLE_A], { doc }); const el = result!.rendered[0].element; - expect(el.classList.contains("polycss-corner-triangle")).toBe(false); + expect(el.className).toBe(""); + expect(el.style.getPropertyValue("corner-top-left-shape")).toBe(""); result!.dispose(); }); }); diff --git a/packages/polycss/src/render/atlas/stableTriangle.ts b/packages/polycss/src/render/atlas/stableTriangle.ts index d4aa1c2c..9f4d8a6b 100644 --- a/packages/polycss/src/render/atlas/stableTriangle.ts +++ b/packages/polycss/src/render/atlas/stableTriangle.ts @@ -2,8 +2,7 @@ import type { Polygon } from "@layoutit/polycss-core"; import { buildSeamBleedPolygonEdges, DEFAULT_TILE, - SOLID_TRIANGLE_CORNER_CLASS, - SOLID_TRIANGLE_LARGE_BORDER_CLASS, + SOLID_TRIANGLE_CANONICAL_SIZE, DEFAULT_MATRIX_DECIMALS, BASIS_EPS, } from "@layoutit/polycss-core"; @@ -37,6 +36,7 @@ import { applyPolygonDataAttrs, hasPolygonDataAttrs } from "./emit"; import { resolveSolidTrianglePrimitive } from "./strategy"; const DEFAULT_SOLID_SEAM_BLEED = 1.5; +const SOLID_TRIANGLE_BORDER_WIDTH = "0 128px 256px 128px"; type RenderTextureAtlasOptionsWithSeams = RenderTextureAtlasOptions & { seamBleed?: number; @@ -105,8 +105,25 @@ export function applySolidTrianglePrimitive( ): void { 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"); + if (primitive === "corner-bevel") { + el.style.width = `${SOLID_TRIANGLE_CANONICAL_SIZE}px`; + el.style.height = `${SOLID_TRIANGLE_CANONICAL_SIZE}px`; + el.style.backgroundColor = "currentColor"; + el.style.borderWidth = "0"; + el.style.borderTopLeftRadius = "50% 100%"; + el.style.borderTopRightRadius = "50% 100%"; + el.style.setProperty("corner-top-left-shape", "bevel"); + el.style.setProperty("corner-top-right-shape", "bevel"); + } else { + el.style.width = ""; + el.style.height = ""; + el.style.backgroundColor = ""; + el.style.borderTopLeftRadius = ""; + el.style.borderTopRightRadius = ""; + el.style.removeProperty("corner-top-left-shape"); + el.style.removeProperty("corner-top-right-shape"); + el.style.borderWidth = primitive === "border-large" ? SOLID_TRIANGLE_BORDER_WIDTH : ""; + } triangleEl.__polycssSolidTrianglePrimitive = primitive; } diff --git a/packages/polycss/src/render/atlas/strategy.ts b/packages/polycss/src/render/atlas/strategy.ts index f5471433..0bbc1010 100644 --- a/packages/polycss/src/render/atlas/strategy.ts +++ b/packages/polycss/src/render/atlas/strategy.ts @@ -52,6 +52,7 @@ export function borderShapeSupported(doc: Document): boolean { } export function solidTriangleSupported(doc: Document): boolean { + if (cornerTriangleSupported(doc)) return true; const win = doc.defaultView ?? (typeof window !== "undefined" ? window : undefined); const userAgent = win?.navigator?.userAgent ?? ""; if (!userAgent) return true; @@ -168,6 +169,11 @@ export function isBorderShapeSupported(doc?: Document | null): boolean { export function isSolidTriangleSupported(doc?: Document | null): boolean { const d = doc ?? (typeof document !== "undefined" ? document : null); if (!d) { + const css = typeof CSS !== "undefined" ? CSS : undefined; + if ( + !!css?.supports?.("corner-top-left-shape", "bevel") && + !!css.supports("corner-top-right-shape", "bevel") + ) return true; const userAgent = (typeof navigator !== "undefined" ? navigator : globalThis.navigator)?.userAgent ?? ""; if (!userAgent) return true; const isChromiumFamily = /\b(?:Chrome|HeadlessChrome|Chromium|Edg|OPR)\//.test(userAgent); diff --git a/packages/polycss/src/render/polyDOM.test.ts b/packages/polycss/src/render/polyDOM.test.ts index 17d14396..c9e8efaf 100644 --- a/packages/polycss/src/render/polyDOM.test.ts +++ b/packages/polycss/src/render/polyDOM.test.ts @@ -387,7 +387,9 @@ describe("renderPoly — degenerate inputs", () => { describe("renderPoly — matrix math parity", () => { it("flat triangles use a finite border-triangle matrix", () => { - const result = renderPoly(FLAT_TRIANGLE)!; + const result = renderPoly(FLAT_TRIANGLE, { + doc: supportDoc({ borderShape: false, cornerShape: false }), + })!; const actual = extractMatrix(result.element); expect(actual.length).toBe(16); expect(actual.every(Number.isFinite)).toBe(true); @@ -407,7 +409,9 @@ describe("renderPoly — matrix math parity", () => { }); it("off-axis triangles use a finite border-triangle matrix", () => { - const result = renderPoly(OFFAXIS_TRIANGLE)!; + const result = renderPoly(OFFAXIS_TRIANGLE, { + doc: supportDoc({ borderShape: false, cornerShape: false }), + })!; const actual = extractMatrix(result.element); expect(actual.length).toBe(16); expect(actual.every(Number.isFinite)).toBe(true); @@ -439,7 +443,9 @@ describe("renderPolygonsWithTextureAtlas", () => { }); it("uses canonical u geometry by default", () => { - const result = renderPolygonsWithTextureAtlas([FLAT_TRIANGLE]); + const result = renderPolygonsWithTextureAtlas([FLAT_TRIANGLE], { + doc: supportDoc({ borderShape: false, cornerShape: false }), + }); const element = result.rendered[0].element; const styleText = element.getAttribute("style") ?? ""; expect(element.tagName.toLowerCase()).toBe("u"); @@ -1399,7 +1405,6 @@ describe("renderPolygonsWithTextureAtlas", () => { it("does not emit dynamic style hooks in baked mode", () => { const result = renderPolygonsWithTextureAtlas([FLAT_TRIANGLE], { textureLighting: "baked" }); const element = result.rendered[0].element; - expect(element.style.backgroundColor).toBe(""); expect(element.style.backgroundBlendMode).toBe(""); expect(element.style.getPropertyValue("--pnx")).toBe(""); result.dispose(); @@ -1627,7 +1632,7 @@ describe("renderPolygonsWithTextureAtlas — strategies.disable", () => { result.dispose(); }); - it("paints solid triangles with the corner class when corner-shape is supported", () => { + it("paints solid triangles with inline corner-shape when supported", () => { const doc = { defaultView: { CSS: { @@ -1645,7 +1650,8 @@ describe("renderPolygonsWithTextureAtlas — strategies.disable", () => { ); const element = result.rendered[0].element; expect(element.tagName.toLowerCase()).toBe("u"); - expect(element.classList.contains("polycss-corner-triangle")).toBe(true); + expect(element.className).toBe(""); + expect(element.style.getPropertyValue("corner-top-left-shape")).toBe("bevel"); result.dispose(); }); @@ -1661,7 +1667,8 @@ describe("renderPolygonsWithTextureAtlas — strategies.disable", () => { ); const element = result.rendered[0].element; expect(element.tagName.toLowerCase()).toBe("u"); - expect(element.classList.contains("polycss-corner-triangle")).toBe(false); + expect(element.className).toBe(""); + expect(element.style.getPropertyValue("corner-top-left-shape")).toBe(""); result.dispose(); }); @@ -1986,7 +1993,9 @@ describe("renderPolygonsWithTextureAtlas — strategies.disable", () => { }); it("omitting strategies uses canonical geometry defaults", () => { - const result = renderPolygonsWithTextureAtlas([FLAT_TRIANGLE]); + const result = renderPolygonsWithTextureAtlas([FLAT_TRIANGLE], { + doc: supportDoc({ borderShape: false, cornerShape: false }), + }); const element = result.rendered[0].element; expect(element.tagName.toLowerCase()).toBe("u"); expect(element.getAttribute("style")).not.toContain("border-width:"); diff --git a/packages/polycss/src/styles/styles.ts b/packages/polycss/src/styles/styles.ts index 04782d43..43df34d4 100644 --- a/packages/polycss/src/styles/styles.ts +++ b/packages/polycss/src/styles/styles.ts @@ -155,21 +155,6 @@ const CORE_BASE_STYLES = ` border-width: 0 128px 256px 128px; } -.polycss-scene u.polycss-large-border-triangle { - border-width: 0 128px 256px 128px; -} - -.polycss-scene u.polycss-corner-triangle { - width: 256px; - height: 256px; - background: currentColor; - border: 0; - border-top-left-radius: 50% 100%; - border-top-right-radius: 50% 100%; - corner-top-left-shape: bevel; - corner-top-right-shape: bevel; -} - /* — dedicated shadow leaf. Same border-shape rendering trick as (border-color: currentColor fills the polygon outline) but with its own tag so we don't have to thread :not(.polycss-shadow) exclusions diff --git a/packages/react/src/scene/PolyScene.test.tsx b/packages/react/src/scene/PolyScene.test.tsx index e722d344..8a5fa52c 100644 --- a/packages/react/src/scene/PolyScene.test.tsx +++ b/packages/react/src/scene/PolyScene.test.tsx @@ -136,7 +136,7 @@ describe("PolyScene — polygon rendering", () => { }); const poly = container.querySelector("u"); const style = poly?.getAttribute("style") ?? ""; - expect(style).not.toContain("border-width"); + expect(poly?.className).toBe(""); expect(style).not.toContain("background: linear-gradient"); }); diff --git a/packages/react/src/scene/atlas/detection.test.ts b/packages/react/src/scene/atlas/detection.test.ts index 5647c1ba..c9ff93cd 100644 --- a/packages/react/src/scene/atlas/detection.test.ts +++ b/packages/react/src/scene/atlas/detection.test.ts @@ -90,6 +90,11 @@ describe("solidTriangleSupported — direct doc variant", () => { 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); + }); + it("returns true when userAgent string is empty (unknown UA → optimistic)", () => { const doc = makeDoc({ userAgent: "" }); expect(solidTriangleSupported(doc)).toBe(true); @@ -147,6 +152,11 @@ describe("isSolidTriangleSupported — wrapper", () => { const doc = makeDoc({ userAgent: SAFARI_UA }); expect(isSolidTriangleSupported(doc)).toBe(false); }); + + it("returns true when doc has Safari UA with corner-shape triangle support", () => { + const doc = makeDoc({ userAgent: SAFARI_UA, cornerShape: true }); + expect(isSolidTriangleSupported(doc)).toBe(true); + }); }); // --------------------------------------------------------------------------- diff --git a/packages/react/src/scene/atlas/detection.ts b/packages/react/src/scene/atlas/detection.ts index eee466ac..581eb0ca 100644 --- a/packages/react/src/scene/atlas/detection.ts +++ b/packages/react/src/scene/atlas/detection.ts @@ -32,6 +32,7 @@ export function borderShapeSupported(doc: Document): boolean { } export function solidTriangleSupported(doc: Document): boolean { + if (cornerTriangleSupported(doc)) return true; const win = doc.defaultView ?? (typeof window !== "undefined" ? window : undefined); const userAgent = win?.navigator?.userAgent ?? ""; if (!userAgent) return true; @@ -124,6 +125,11 @@ export function isBorderShapeSupported(doc?: Document | null): boolean { export function isSolidTriangleSupported(doc?: Document | null): boolean { const d = doc ?? (typeof document !== "undefined" ? document : null); if (!d) { + const css = typeof CSS !== "undefined" ? CSS : undefined; + if ( + !!css?.supports?.("corner-top-left-shape", "bevel") && + !!css.supports("corner-top-right-shape", "bevel") + ) return true; const userAgent = (typeof navigator !== "undefined" ? navigator : globalThis.navigator)?.userAgent ?? ""; if (!userAgent) return true; const isChromiumFamily = /\b(?:Chrome|HeadlessChrome|Chromium|Edg|OPR)\//.test(userAgent); diff --git a/packages/react/src/scene/atlas/index.test.tsx b/packages/react/src/scene/atlas/index.test.tsx index ba8e95fc..7287c95c 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, + solidTriangleStyle, updateStableTriangleDom, useTextureAtlas, type TextureQuality, @@ -93,6 +94,14 @@ function stubBorderShapeUnsupported(): void { vi.stubGlobal("CSS", { supports: () => false }); } +function stubCornerTriangleSupported(): void { + vi.stubGlobal("CSS", { + supports: (property: string, value?: string) => + value === "bevel" && + (property === "corner-top-left-shape" || property === "corner-top-right-shape"), + }); +} + afterEach(() => { Object.defineProperty(window, "matchMedia", { configurable: true, @@ -194,7 +203,23 @@ describe("isSolidTrianglePlan", () => { }); describe("updateStableTriangleDom", () => { + it("applies corner triangle paint inline when corner-shape is supported", () => { + stubCornerTriangleSupported(); + const tri: Polygon = { + vertices: [[0, 0, 0], [1, 0, 0], [0, 1, 0]], + color: "#ff0000", + }; + const plan = planFor(tri)!; + + const style = solidTriangleStyle(plan, "baked", "auto")!; + + expect(style.borderWidth).toBe("0"); + expect(style.backgroundColor).toBe("currentColor"); + expect((style as Record).cornerTopLeftShape).toBe("bevel"); + }); + it("applies the large border triangle primitive on Firefox", () => { + stubBorderShapeUnsupported(); stubUserAgent(FIREFOX_UA); const root = document.createElement("div"); const leaf = document.createElement("u"); diff --git a/packages/react/src/scene/atlas/solidTriangleStyle.ts b/packages/react/src/scene/atlas/solidTriangleStyle.ts index 3cee9e53..0035ea20 100644 --- a/packages/react/src/scene/atlas/solidTriangleStyle.ts +++ b/packages/react/src/scene/atlas/solidTriangleStyle.ts @@ -9,6 +9,7 @@ import type { TextureAtlasPlan, PolyTextureLightingMode, SolidPaintDefaults, + SolidTrianglePrimitive, Vec2, Vec3, } from "@layoutit/polycss-core"; @@ -30,9 +31,31 @@ export const SOLID_TRIANGLE_BLEED = 0.75; const SOLID_TRIANGLE_CANONICAL_SIZE = 256; const SOLID_TRIANGLE_LARGE_BORDER_CANONICAL_SIZE = 256; const SOLID_TRIANGLE_LARGE_BORDER_WIDTH = "0 128px 256px 128px"; +const CORNER_TRIANGLE_STYLE = { + width: "256px", + height: "256px", + backgroundColor: "currentColor", + borderWidth: "0", + borderTopLeftRadius: "50% 100%", + borderTopRightRadius: "50% 100%", + cornerTopLeftShape: "bevel", + cornerTopRightShape: "bevel", +} as CSSProperties; let cachedSolidTriangleUserAgent: string | undefined; let cachedSolidTriangleCanonicalSize = SOLID_TRIANGLE_CANONICAL_SIZE; +function cornerTriangleSupported(): boolean { + const css = typeof CSS !== "undefined" ? CSS : undefined; + return !!css?.supports?.("corner-top-left-shape", "bevel") && + !!css.supports("corner-top-right-shape", "bevel"); +} + +function solidTrianglePrimitive(): SolidTrianglePrimitive { + if (cornerTriangleSupported()) return "corner-bevel"; + const ua = typeof navigator !== "undefined" ? navigator.userAgent : ""; + return /\bFirefox\//.test(ua) ? "border-large" : "border"; +} + export function solidTriangleCanonicalSize(): number { const ua = typeof navigator !== "undefined" ? navigator.userAgent : ""; if (ua !== cachedSolidTriangleUserAgent) { @@ -45,12 +68,41 @@ export function solidTriangleCanonicalSize(): number { } export function solidTriangleBorderWidth(): string | undefined { - const ua = typeof navigator !== "undefined" ? navigator.userAgent : ""; - return /\bFirefox\//.test(ua) + return solidTrianglePrimitive() === "border-large" ? SOLID_TRIANGLE_LARGE_BORDER_WIDTH : undefined; } +export function solidTrianglePaintStyle(): CSSProperties | undefined { + const primitive = solidTrianglePrimitive(); + if (primitive === "corner-bevel") return CORNER_TRIANGLE_STYLE; + const borderWidth = primitive === "border-large" ? SOLID_TRIANGLE_LARGE_BORDER_WIDTH : undefined; + return borderWidth ? { borderWidth } : undefined; +} + +export function applySolidTrianglePaintStyle(el: HTMLElement): void { + const primitive = solidTrianglePrimitive(); + if (primitive === "corner-bevel") { + el.style.width = "256px"; + el.style.height = "256px"; + el.style.backgroundColor = "currentColor"; + el.style.borderWidth = "0"; + el.style.borderTopLeftRadius = "50% 100%"; + el.style.borderTopRightRadius = "50% 100%"; + el.style.setProperty("corner-top-left-shape", "bevel"); + el.style.setProperty("corner-top-right-shape", "bevel"); + } else { + el.style.width = ""; + el.style.height = ""; + el.style.backgroundColor = ""; + el.style.borderTopLeftRadius = ""; + el.style.borderTopRightRadius = ""; + el.style.removeProperty("corner-top-left-shape"); + el.style.removeProperty("corner-top-right-shape"); + el.style.borderWidth = primitive === "border-large" ? SOLID_TRIANGLE_LARGE_BORDER_WIDTH : ""; + } +} + export interface RGB { r: number; g: number; b: number; } export function parseHex(hex: string): RGB { @@ -544,9 +596,10 @@ export function solidTriangleStyle( normal[0], normal[1], normal[2], 0, txCol[0], txCol[1], txCol[2], 1, ].map((v) => (Math.round(v * 1000) / 1000 || 0).toString()).join(","); + const primitiveStyle = solidTrianglePaintStyle(); return { transform: `matrix3d(${canonicalMatrix})`, - borderWidth: solidTriangleBorderWidth(), + ...(primitiveStyle ?? {}), ...sharedStyle, }; } diff --git a/packages/react/src/scene/atlas/stableTriangleDom.ts b/packages/react/src/scene/atlas/stableTriangleDom.ts index 390ca698..309b93da 100644 --- a/packages/react/src/scene/atlas/stableTriangleDom.ts +++ b/packages/react/src/scene/atlas/stableTriangleDom.ts @@ -24,6 +24,7 @@ import { stepRgbToward, offsetConvexPolygonPoints, formatStableTriangleTransformScalars, + applySolidTrianglePaintStyle, solidTriangleBorderWidth, solidTriangleCanonicalSize, } from "./solidTriangleStyle"; @@ -322,6 +323,7 @@ export function updateStableTriangleDom( const el = leaves[i]; if (el.style.visibility) el.style.visibility = ""; el.__polycssStableTriangleBasis = style.basis; + applySolidTrianglePaintStyle(el); el.style.transform = style.transform; if (style.borderWidth !== undefined) el.style.borderWidth = style.borderWidth; if (style.color !== undefined) applyStableTriangleColor(el, i, style.color, options); diff --git a/packages/react/src/shapes/Poly.test.tsx b/packages/react/src/shapes/Poly.test.tsx index 2b3fb552..1a768a7a 100644 --- a/packages/react/src/shapes/Poly.test.tsx +++ b/packages/react/src/shapes/Poly.test.tsx @@ -408,7 +408,6 @@ describe("Poly — dynamic lighting", () => { vertices: FLAT_TRIANGLE_VERTS, }); const poly = getPoly(container); - expect(poly.style.backgroundColor).toBe(""); expect(poly.style.backgroundBlendMode).toBe(""); expect(poly.style.getPropertyValue("--pnx")).toBe(""); expect(poly.getAttribute("style") ?? "").not.toContain("mask-image"); diff --git a/packages/react/src/styles/styles.ts b/packages/react/src/styles/styles.ts index 4f7fdd44..fc97b0a4 100644 --- a/packages/react/src/styles/styles.ts +++ b/packages/react/src/styles/styles.ts @@ -156,21 +156,6 @@ const CORE_BASE_STYLES = ` border-width: 0 128px 256px 128px; } -.polycss-scene u.polycss-large-border-triangle { - border-width: 0 128px 256px 128px; -} - -.polycss-scene u.polycss-corner-triangle { - width: 256px; - height: 256px; - background: currentColor; - border: 0; - border-top-left-radius: 50% 100%; - border-top-right-radius: 50% 100%; - corner-top-left-shape: bevel; - corner-top-right-shape: bevel; -} - /* ── Gizmo override ─────────────────────────────────────────────────────── */ /* diff --git a/packages/vue/src/scene/PolyScene.test.ts b/packages/vue/src/scene/PolyScene.test.ts index 9613fa44..6d506907 100644 --- a/packages/vue/src/scene/PolyScene.test.ts +++ b/packages/vue/src/scene/PolyScene.test.ts @@ -125,7 +125,7 @@ describe("PolyScene (Vue) — polygon rendering", () => { }); const poly = container.querySelector("u"); const style = poly?.getAttribute("style") ?? ""; - expect(style).not.toContain("border-width"); + expect(poly?.className).toBe(""); expect(style).not.toContain("background: linear-gradient"); }); diff --git a/packages/vue/src/scene/atlas/detection.test.ts b/packages/vue/src/scene/atlas/detection.test.ts index 96b81307..e45ff5a2 100644 --- a/packages/vue/src/scene/atlas/detection.test.ts +++ b/packages/vue/src/scene/atlas/detection.test.ts @@ -86,6 +86,11 @@ describe("solidTriangleSupported — direct doc variant", () => { 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); + }); + it("returns true when userAgent string is empty (unknown UA → optimistic)", () => { const doc = makeDoc({ userAgent: "" }); expect(solidTriangleSupported(doc)).toBe(true); @@ -143,6 +148,11 @@ describe("isSolidTriangleSupported — wrapper", () => { const doc = makeDoc({ userAgent: SAFARI_UA }); expect(isSolidTriangleSupported(doc)).toBe(false); }); + + it("returns true when doc has Safari UA with corner-shape triangle support", () => { + const doc = makeDoc({ userAgent: SAFARI_UA, cornerShape: true }); + expect(isSolidTriangleSupported(doc)).toBe(true); + }); }); // --------------------------------------------------------------------------- diff --git a/packages/vue/src/scene/atlas/detection.ts b/packages/vue/src/scene/atlas/detection.ts index eee466ac..581eb0ca 100644 --- a/packages/vue/src/scene/atlas/detection.ts +++ b/packages/vue/src/scene/atlas/detection.ts @@ -32,6 +32,7 @@ export function borderShapeSupported(doc: Document): boolean { } export function solidTriangleSupported(doc: Document): boolean { + if (cornerTriangleSupported(doc)) return true; const win = doc.defaultView ?? (typeof window !== "undefined" ? window : undefined); const userAgent = win?.navigator?.userAgent ?? ""; if (!userAgent) return true; @@ -124,6 +125,11 @@ export function isBorderShapeSupported(doc?: Document | null): boolean { export function isSolidTriangleSupported(doc?: Document | null): boolean { const d = doc ?? (typeof document !== "undefined" ? document : null); if (!d) { + const css = typeof CSS !== "undefined" ? CSS : undefined; + if ( + !!css?.supports?.("corner-top-left-shape", "bevel") && + !!css.supports("corner-top-right-shape", "bevel") + ) return true; const userAgent = (typeof navigator !== "undefined" ? navigator : globalThis.navigator)?.userAgent ?? ""; if (!userAgent) return true; const isChromiumFamily = /\b(?:Chrome|HeadlessChrome|Chromium|Edg|OPR)\//.test(userAgent); diff --git a/packages/vue/src/scene/atlas/index.test.ts b/packages/vue/src/scene/atlas/index.test.ts index 1513c3a8..d1a6a97d 100644 --- a/packages/vue/src/scene/atlas/index.test.ts +++ b/packages/vue/src/scene/atlas/index.test.ts @@ -5,6 +5,7 @@ import { useTextureAtlas, computeTextureAtlasPlan, isSolidTrianglePlan, + solidTriangleStyle, updateStableTriangleDom, type TextureAtlasPlan, } from "./index"; @@ -39,6 +40,14 @@ function stubBorderShapeUnsupported(): void { vi.stubGlobal("CSS", { supports: () => false }); } +function stubCornerTriangleSupported(): void { + vi.stubGlobal("CSS", { + supports: (property: string, value?: string) => + value === "bevel" && + (property === "corner-top-left-shape" || property === "corner-top-right-shape"), + }); +} + afterEach(() => { Object.defineProperty(window.navigator, "userAgent", { configurable: true, @@ -138,7 +147,23 @@ describe("isSolidTrianglePlan", () => { }); describe("updateStableTriangleDom", () => { + it("applies corner triangle paint inline when corner-shape is supported", () => { + stubCornerTriangleSupported(); + const tri: Polygon = { + vertices: [[0, 0, 0], [1, 0, 0], [0, 1, 0]], + color: "#ff0000", + }; + const plan = planFor(tri)!; + + const style = solidTriangleStyle(plan, "baked", "auto")!; + + expect(style.borderWidth).toBe("0"); + expect(style.backgroundColor).toBe("currentColor"); + expect((style as Record)["corner-top-left-shape"]).toBe("bevel"); + }); + it("applies the large border triangle primitive on Firefox", () => { + stubBorderShapeUnsupported(); stubUserAgent(FIREFOX_UA); const root = document.createElement("div"); const leaf = document.createElement("u"); diff --git a/packages/vue/src/scene/atlas/solidTriangleStyle.ts b/packages/vue/src/scene/atlas/solidTriangleStyle.ts index a63d9080..ac8fa3d7 100644 --- a/packages/vue/src/scene/atlas/solidTriangleStyle.ts +++ b/packages/vue/src/scene/atlas/solidTriangleStyle.ts @@ -9,6 +9,7 @@ import type { TextureAtlasPlan, PolyTextureLightingMode, SolidPaintDefaults, + SolidTrianglePrimitive, Vec2, Vec3, } from "@layoutit/polycss-core"; @@ -31,9 +32,31 @@ export const SOLID_TRIANGLE_BLEED = 0.75; const SOLID_TRIANGLE_CANONICAL_SIZE = 256; const SOLID_TRIANGLE_LARGE_BORDER_CANONICAL_SIZE = 256; const SOLID_TRIANGLE_LARGE_BORDER_WIDTH = "0 128px 256px 128px"; +const CORNER_TRIANGLE_STYLE = { + width: "256px", + height: "256px", + backgroundColor: "currentColor", + borderWidth: "0", + borderTopLeftRadius: "50% 100%", + borderTopRightRadius: "50% 100%", + "corner-top-left-shape": "bevel", + "corner-top-right-shape": "bevel", +} as CSSProperties; let cachedSolidTriangleUserAgent: string | undefined; let cachedSolidTriangleCanonicalSize = SOLID_TRIANGLE_CANONICAL_SIZE; +function cornerTriangleSupported(): boolean { + const css = typeof CSS !== "undefined" ? CSS : undefined; + return !!css?.supports?.("corner-top-left-shape", "bevel") && + !!css.supports("corner-top-right-shape", "bevel"); +} + +function solidTrianglePrimitive(): SolidTrianglePrimitive { + if (cornerTriangleSupported()) return "corner-bevel"; + const ua = typeof navigator !== "undefined" ? navigator.userAgent : ""; + return /\bFirefox\//.test(ua) ? "border-large" : "border"; +} + export function solidTriangleCanonicalSize(): number { const ua = typeof navigator !== "undefined" ? navigator.userAgent : ""; if (ua !== cachedSolidTriangleUserAgent) { @@ -46,12 +69,41 @@ export function solidTriangleCanonicalSize(): number { } export function solidTriangleBorderWidth(): string | undefined { - const ua = typeof navigator !== "undefined" ? navigator.userAgent : ""; - return /\bFirefox\//.test(ua) + return solidTrianglePrimitive() === "border-large" ? SOLID_TRIANGLE_LARGE_BORDER_WIDTH : undefined; } +export function solidTrianglePaintStyle(): CSSProperties | undefined { + const primitive = solidTrianglePrimitive(); + if (primitive === "corner-bevel") return CORNER_TRIANGLE_STYLE; + const borderWidth = primitive === "border-large" ? SOLID_TRIANGLE_LARGE_BORDER_WIDTH : undefined; + return borderWidth ? { borderWidth } : undefined; +} + +export function applySolidTrianglePaintStyle(el: HTMLElement): void { + const primitive = solidTrianglePrimitive(); + if (primitive === "corner-bevel") { + el.style.width = "256px"; + el.style.height = "256px"; + el.style.backgroundColor = "currentColor"; + el.style.borderWidth = "0"; + el.style.borderTopLeftRadius = "50% 100%"; + el.style.borderTopRightRadius = "50% 100%"; + el.style.setProperty("corner-top-left-shape", "bevel"); + el.style.setProperty("corner-top-right-shape", "bevel"); + } else { + el.style.width = ""; + el.style.height = ""; + el.style.backgroundColor = ""; + el.style.borderTopLeftRadius = ""; + el.style.borderTopRightRadius = ""; + el.style.removeProperty("corner-top-left-shape"); + el.style.removeProperty("corner-top-right-shape"); + el.style.borderWidth = primitive === "border-large" ? SOLID_TRIANGLE_LARGE_BORDER_WIDTH : ""; + } +} + export interface RGB { r: number; g: number; b: number; } export function parseHex(hex: string): RGB { @@ -539,9 +591,10 @@ export function solidTriangleStyle( normal[0], normal[1], normal[2], 0, txCol[0], txCol[1], txCol[2], 1, ].map((v) => (Math.round(v * 1000) / 1000 || 0).toString()).join(","); + const primitiveStyle = solidTrianglePaintStyle(); return { transform: `matrix3d(${canonicalMatrix})`, - borderWidth: solidTriangleBorderWidth(), + ...(primitiveStyle ?? {}), ...sharedStyle, }; } diff --git a/packages/vue/src/scene/atlas/stableTriangleDom.ts b/packages/vue/src/scene/atlas/stableTriangleDom.ts index 725cb02e..1421636f 100644 --- a/packages/vue/src/scene/atlas/stableTriangleDom.ts +++ b/packages/vue/src/scene/atlas/stableTriangleDom.ts @@ -23,6 +23,7 @@ import { quantizeCssColor, stepRgbToward, offsetConvexPolygonPoints, + applySolidTrianglePaintStyle, solidTriangleBorderWidth, solidTriangleCanonicalSize, } from "./solidTriangleStyle"; @@ -347,6 +348,7 @@ export function updateStableTriangleDom( const el = leaves[i]; if (el.style.visibility) el.style.visibility = ""; el.__polycssStableTriangleBasis = style.basis; + applySolidTrianglePaintStyle(el); el.style.transform = style.transform; if (style.borderWidth !== undefined) el.style.borderWidth = style.borderWidth; if (style.color !== undefined) applyStableTriangleColor(el, i, style.color, options); diff --git a/packages/vue/src/shapes/Poly.test.ts b/packages/vue/src/shapes/Poly.test.ts index b68a43e8..668d02ad 100644 --- a/packages/vue/src/shapes/Poly.test.ts +++ b/packages/vue/src/shapes/Poly.test.ts @@ -310,7 +310,6 @@ describe("Poly (Vue) — dynamic lighting", () => { vertices: FLAT_TRIANGLE, }); const poly = getPoly(container); - expect(poly.style.backgroundColor).toBe(""); expect(poly.style.backgroundBlendMode).toBe(""); expect(poly.style.getPropertyValue("--pnx")).toBe(""); expect(poly.getAttribute("style") ?? "").not.toContain("mask-image"); diff --git a/packages/vue/src/styles/styles.ts b/packages/vue/src/styles/styles.ts index bab3ad05..07ad4bfa 100644 --- a/packages/vue/src/styles/styles.ts +++ b/packages/vue/src/styles/styles.ts @@ -156,21 +156,6 @@ const CORE_BASE_STYLES = ` border-width: 0 128px 256px 128px; } -.polycss-scene u.polycss-large-border-triangle { - border-width: 0 128px 256px 128px; -} - -.polycss-scene u.polycss-corner-triangle { - width: 256px; - height: 256px; - background: currentColor; - border: 0; - border-top-left-radius: 50% 100%; - border-top-right-radius: 50% 100%; - corner-top-left-shape: bevel; - corner-top-right-shape: bevel; -} - /* ── Dynamic lighting cascade vars (scene root → polygons) ─────────────── */ /* From a6ef23d529f272a08a2d5875f63018317334b2d8 Mon Sep 17 00:00:00 2001 From: agustin-littlehat Date: Thu, 28 May 2026 10:26:03 -0300 Subject: [PATCH 4/7] feat(camera): raise default perspective depth --- .../polycss/src/api/createPolyCamera.test.ts | 4 ++-- packages/polycss/src/api/createPolyCamera.ts | 6 ++--- packages/polycss/src/api/createPolyScene.ts | 4 ++-- .../src/elements/PolyNewElements.test.ts | 4 ++-- .../elements/PolyPerspectiveCameraElement.ts | 2 +- .../src/camera/PolyPerspectiveCamera.test.tsx | 4 ++-- .../src/camera/PolyPerspectiveCamera.tsx | 4 ++-- .../src/controls/PolyFirstPersonControls.tsx | 2 +- packages/react/src/styles/styles.ts | 2 +- .../src/camera/PolyPerspectiveCamera.test.ts | 4 ++-- .../vue/src/camera/PolyPerspectiveCamera.ts | 6 ++--- .../src/controls/PolyFirstPersonControls.ts | 2 +- packages/vue/src/styles/styles.ts | 2 +- .../components/BuilderScene.tsx | 8 ++++++- .../components/BuilderWorkbench/defaults.ts | 2 +- .../Dock/folders/useCameraFolder.ts | 17 ++++++++++---- .../GalleryWorkbench/GalleryWorkbench.tsx | 7 ++++-- website/src/components/PolyDemo.astro | 23 +++++++++++-------- .../src/components/ReactScene/ReactScene.tsx | 8 ++++++- .../components/VanillaScene/VanillaScene.tsx | 8 ++++--- website/src/components/fpv/useFpvHost.ts | 2 +- website/src/components/fpv/useFpvSpawn.ts | 4 ++-- website/src/components/types.ts | 4 +++- .../content/docs/components/poly-camera.mdx | 2 +- .../src/content/docs/guides/projections.mdx | 6 ++--- website/src/pages/index.astro | 1 - 26 files changed, 85 insertions(+), 53 deletions(-) diff --git a/packages/polycss/src/api/createPolyCamera.test.ts b/packages/polycss/src/api/createPolyCamera.test.ts index 9bc1a6da..a8d4a3a5 100644 --- a/packages/polycss/src/api/createPolyCamera.test.ts +++ b/packages/polycss/src/api/createPolyCamera.test.ts @@ -17,9 +17,9 @@ describe("createPolyPerspectiveCamera", () => { expect(cam.type).toBe("perspective"); }); - it("returns default perspectiveStyle of '8000px'", () => { + it("returns default perspectiveStyle of '32000px'", () => { const cam = createPolyPerspectiveCamera(); - expect(cam.perspectiveStyle).toBe("8000px"); + expect(cam.perspectiveStyle).toBe("32000px"); }); it("accepts a custom perspective value", () => { diff --git a/packages/polycss/src/api/createPolyCamera.ts b/packages/polycss/src/api/createPolyCamera.ts index c4b1c565..856f0fa9 100644 --- a/packages/polycss/src/api/createPolyCamera.ts +++ b/packages/polycss/src/api/createPolyCamera.ts @@ -13,7 +13,7 @@ import { createIsometricCamera } from "@layoutit/polycss-core"; import type { CameraHandle, CameraState, CameraStyleInput, Vec3 } from "@layoutit/polycss-core"; -const DEFAULT_PERSPECTIVE = 8000; +const DEFAULT_PERSPECTIVE = 32000; export interface PolyCameraOptions { zoom?: number; @@ -25,7 +25,7 @@ export interface PolyCameraOptions { } export interface PolyPerspectiveCameraOptions extends PolyCameraOptions { - /** CSS perspective distance in pixels. Default 8000. */ + /** CSS perspective distance in pixels. Default 32000. */ perspective?: number; } @@ -47,7 +47,7 @@ export interface PolyOrthographicCameraHandle extends CameraHandle { /** * Creates a perspective camera handle. The `perspectiveStyle` property * returns the CSS value to apply to the camera container's `perspective` - * property (default `"8000px"`). + * property (default `"32000px"`). */ export function createPolyPerspectiveCamera( options: PolyPerspectiveCameraOptions = {}, diff --git a/packages/polycss/src/api/createPolyScene.ts b/packages/polycss/src/api/createPolyScene.ts index 7e7af732..6a3043a4 100644 --- a/packages/polycss/src/api/createPolyScene.ts +++ b/packages/polycss/src/api/createPolyScene.ts @@ -85,7 +85,7 @@ import { injectPolyBaseStyles } from "../styles/styles"; // keeps large gallery meshes below Chrome's long-task warning threshold // without changing the synchronous public setPolygons() contract. const ASYNC_MOUNT_BATCH_SIZE = 750; -const DEFAULT_SCENE_PERSPECTIVE = 8000; +const DEFAULT_SCENE_PERSPECTIVE = 32000; function normalizeSceneOptions>>(options: T): T { if (!Object.prototype.hasOwnProperty.call(options, "seamBleed") || options.seamBleed !== undefined) { @@ -766,7 +766,7 @@ export function createPolyScene( if (perspStyle === "none") { el.style.perspective = `${scaledCssPixels(1000000, layoutScale)}px`; } else { - // perspStyle is e.g. "8000px" — strip "px", scale, re-apply. + // perspStyle is e.g. "32000px" — strip "px", scale, re-apply. const px = parseFloat(perspStyle); if (Number.isFinite(px)) { el.style.perspective = `${scaledCssPixels(px, layoutScale)}px`; diff --git a/packages/polycss/src/elements/PolyNewElements.test.ts b/packages/polycss/src/elements/PolyNewElements.test.ts index 55ecd1fb..75c8062d 100644 --- a/packages/polycss/src/elements/PolyNewElements.test.ts +++ b/packages/polycss/src/elements/PolyNewElements.test.ts @@ -180,11 +180,11 @@ describe("PolyPerspectiveCameraElement", () => { expect(wrapper).not.toBeNull(); }); - it("applies default perspective of 8000px to wrapper", () => { + it("applies default perspective of 32000px to wrapper", () => { const el = document.createElement("poly-perspective-camera") as PolyPerspectiveCameraElement; host.appendChild(el); const wrapper = el.querySelector(".polycss-camera") as HTMLElement; - expect(wrapper.style.perspective).toBe("8000px"); + expect(wrapper.style.perspective).toBe("32000px"); }); it("applies custom perspective attribute", () => { diff --git a/packages/polycss/src/elements/PolyPerspectiveCameraElement.ts b/packages/polycss/src/elements/PolyPerspectiveCameraElement.ts index 7743bb4b..0f090b30 100644 --- a/packages/polycss/src/elements/PolyPerspectiveCameraElement.ts +++ b/packages/polycss/src/elements/PolyPerspectiveCameraElement.ts @@ -7,7 +7,7 @@ * CSS `perspective` property set. * * Attributes (all optional): - * perspective — number, CSS perspective in pixels (default 8000) + * perspective — number, CSS perspective in pixels (default 32000) * zoom — number * rot-x — number, degrees (default 65) * rot-y — number, degrees (default 45) diff --git a/packages/react/src/camera/PolyPerspectiveCamera.test.tsx b/packages/react/src/camera/PolyPerspectiveCamera.test.tsx index 46da9668..f8cdbbdc 100644 --- a/packages/react/src/camera/PolyPerspectiveCamera.test.tsx +++ b/packages/react/src/camera/PolyPerspectiveCamera.test.tsx @@ -20,14 +20,14 @@ describe("PolyPerspectiveCamera", () => { expect(container.querySelector(".polycss-camera")).toBeTruthy(); }); - it("applies default perspective of 8000px", () => { + it("applies default perspective of 32000px", () => { const container = renderToDiv(
); const camera = container.querySelector(".polycss-camera") as HTMLElement; - expect(camera.style.perspective).toBe("8000px"); + expect(camera.style.perspective).toBe("32000px"); }); it("applies a custom numeric perspective value", () => { diff --git a/packages/react/src/camera/PolyPerspectiveCamera.tsx b/packages/react/src/camera/PolyPerspectiveCamera.tsx index 93373c11..fffb95cc 100644 --- a/packages/react/src/camera/PolyPerspectiveCamera.tsx +++ b/packages/react/src/camera/PolyPerspectiveCamera.tsx @@ -11,14 +11,14 @@ export interface PolyPerspectiveCameraProps { rotY?: number; /** Camera pull-back in CSS pixels (dolly). Default 0. */ distance?: number; - /** CSS perspective distance in pixels. Defaults to 8000. */ + /** CSS perspective distance in pixels. Defaults to 32000. */ perspective?: number; children?: ReactNode; className?: string; style?: React.CSSProperties; } -const DEFAULT_PERSPECTIVE = 8000; +const DEFAULT_PERSPECTIVE = 32000; function PolyPerspectiveCameraInner({ zoom, diff --git a/packages/react/src/controls/PolyFirstPersonControls.tsx b/packages/react/src/controls/PolyFirstPersonControls.tsx index 6d28b4dc..8aef33b1 100644 --- a/packages/react/src/controls/PolyFirstPersonControls.tsx +++ b/packages/react/src/controls/PolyFirstPersonControls.tsx @@ -324,7 +324,7 @@ export const PolyFirstPersonControls = forwardRef< const host = cameraElRef.current; const perspStr = host ? getComputedStyle(host).perspective : ""; const n = parseFloat(perspStr); - return (Number.isFinite(n) && n > 0 ? n : 8000) / BASE_TILE; + return (Number.isFinite(n) && n > 0 ? n : 32000) / BASE_TILE; } function deriveTarget(): [number, number, number] { diff --git a/packages/react/src/styles/styles.ts b/packages/react/src/styles/styles.ts index fc97b0a4..37d3ed8c 100644 --- a/packages/react/src/styles/styles.ts +++ b/packages/react/src/styles/styles.ts @@ -42,7 +42,7 @@ const CORE_BASE_STYLES = ` width: 100%; justify-content: center; align-items: center; - perspective: 8000px; + perspective: 32000px; min-height: inherit; height: 100%; position: relative; diff --git a/packages/vue/src/camera/PolyPerspectiveCamera.test.ts b/packages/vue/src/camera/PolyPerspectiveCamera.test.ts index 2fdb452a..3fa11358 100644 --- a/packages/vue/src/camera/PolyPerspectiveCamera.test.ts +++ b/packages/vue/src/camera/PolyPerspectiveCamera.test.ts @@ -26,10 +26,10 @@ describe("PolyPerspectiveCamera (Vue)", () => { expect(container.querySelector(".polycss-camera")).toBeTruthy(); }); - it("applies default perspective of 8000px", () => { + it("applies default perspective of 32000px", () => { const container = renderCamera(); const camera = container.querySelector(".polycss-camera") as HTMLElement; - expect(camera.style.perspective).toBe("8000px"); + expect(camera.style.perspective).toBe("32000px"); }); it("applies a custom numeric perspective value", () => { diff --git a/packages/vue/src/camera/PolyPerspectiveCamera.ts b/packages/vue/src/camera/PolyPerspectiveCamera.ts index 1595a0cb..76cd7296 100644 --- a/packages/vue/src/camera/PolyPerspectiveCamera.ts +++ b/packages/vue/src/camera/PolyPerspectiveCamera.ts @@ -2,7 +2,7 @@ * PolyPerspectiveCamera — Vue camera component with CSS perspective projection. * Mirrors React's PolyPerspectiveCamera. * - * Uses `perspective: px` on the wrapper element (defaults to 8000px). + * Uses `perspective: px` on the wrapper element (defaults to 32000px). * Prefer this over the generic PolyCamera alias for explicit three.js-style naming. */ import { defineComponent, h, provide, computed } from "vue"; @@ -11,7 +11,7 @@ import type { Vec3 } from "@layoutit/polycss-core"; import { usePolyCamera } from "./useCamera"; import { PolyCameraContextKey } from "./context"; -const DEFAULT_PERSPECTIVE = 8000; +const DEFAULT_PERSPECTIVE = 32000; export interface PolyPerspectiveCameraProps { zoom?: number; @@ -20,7 +20,7 @@ export interface PolyPerspectiveCameraProps { rotY?: number; /** Camera pull-back in CSS pixels (dolly). Default 0. */ distance?: number; - /** CSS perspective distance in pixels. Defaults to 8000. */ + /** CSS perspective distance in pixels. Defaults to 32000. */ perspective?: number; class?: string; } diff --git a/packages/vue/src/controls/PolyFirstPersonControls.ts b/packages/vue/src/controls/PolyFirstPersonControls.ts index cb29acd4..f1bf12c3 100644 --- a/packages/vue/src/controls/PolyFirstPersonControls.ts +++ b/packages/vue/src/controls/PolyFirstPersonControls.ts @@ -266,7 +266,7 @@ export const PolyFirstPersonControls = defineComponent({ const host = cameraElRef.value; const perspStr = host ? getComputedStyle(host).perspective : ""; const n = parseFloat(perspStr); - return (Number.isFinite(n) && n > 0 ? n : 8000) / BASE_TILE; + return (Number.isFinite(n) && n > 0 ? n : 32000) / BASE_TILE; } function deriveTarget(): [number, number, number] { diff --git a/packages/vue/src/styles/styles.ts b/packages/vue/src/styles/styles.ts index 07ad4bfa..93c1c1f0 100644 --- a/packages/vue/src/styles/styles.ts +++ b/packages/vue/src/styles/styles.ts @@ -42,7 +42,7 @@ const CORE_BASE_STYLES = ` width: 100%; justify-content: center; align-items: center; - perspective: 8000px; + perspective: 32000px; min-height: inherit; height: 100%; position: relative; diff --git a/website/src/components/BuilderWorkbench/components/BuilderScene.tsx b/website/src/components/BuilderWorkbench/components/BuilderScene.tsx index ecf0ac81..f517057a 100644 --- a/website/src/components/BuilderWorkbench/components/BuilderScene.tsx +++ b/website/src/components/BuilderWorkbench/components/BuilderScene.tsx @@ -423,7 +423,13 @@ export function BuilderScene({ const [addHoverCell, setAddHoverCell] = useState<[number, number] | null>(null); const camProps = perspective === false ? { zoom: sceneOptions.zoom, rotX: sceneOptions.rotX, rotY: sceneOptions.rotY, target: sceneOptions.target } - : { zoom: sceneOptions.zoom, rotX: sceneOptions.rotX, rotY: sceneOptions.rotY, target: sceneOptions.target, perspective }; + : { + zoom: sceneOptions.zoom, + rotX: sceneOptions.rotX, + rotY: sceneOptions.rotY, + target: sceneOptions.target, + ...(typeof perspective === "number" ? { perspective } : {}), + }; const handleCameraChange = (cam: { rotX: number; rotY: number; zoom: number; target?: Vec3 }) => updateScene({ rotX: cam.rotX, rotY: cam.rotY, diff --git a/website/src/components/BuilderWorkbench/defaults.ts b/website/src/components/BuilderWorkbench/defaults.ts index 2d5d518c..c91c79e0 100644 --- a/website/src/components/BuilderWorkbench/defaults.ts +++ b/website/src/components/BuilderWorkbench/defaults.ts @@ -31,7 +31,7 @@ export const DEFAULT_SCENE: SceneOptionsState = { zoom: 0.3, rotX: 65, rotY: 45, - perspective: 10000, + perspective: undefined, lightAzimuth: 50, lightElevation: 45, lightIntensity: 1, diff --git a/website/src/components/Dock/folders/useCameraFolder.ts b/website/src/components/Dock/folders/useCameraFolder.ts index 7565d81e..4dcc075a 100644 --- a/website/src/components/Dock/folders/useCameraFolder.ts +++ b/website/src/components/Dock/folders/useCameraFolder.ts @@ -19,7 +19,12 @@ import { useSlider, useToggle, } from "../primitives"; -import type { DragMode, PerspectiveMode, SceneOptionsState } from "../../types"; +import { + POLYCSS_DEFAULT_PERSPECTIVE, + type DragMode, + type PerspectiveMode, + type SceneOptionsState, +} from "../../types"; interface PresetModelMinimal { zoom?: number; @@ -50,7 +55,7 @@ export interface CameraFolderInputs { fpvRenderDistance: number; perspectiveMode: PerspectiveMode; perspectivePx: number; - perspective: number | false; + perspective: number | false | undefined; zoom: number; rotX: number; rotY: number; @@ -236,7 +241,9 @@ export function useCameraFolder(parent: GUI | null, inputs: CameraFolderInputs): perspectiveMode, (value) => onUpdateScene({ - perspective: value === "perspective" ? perspectivePxRef.current : false, + perspective: value === "perspective" + ? (perspectivePxRef.current === POLYCSS_DEFAULT_PERSPECTIVE ? undefined : perspectivePxRef.current) + : false, }), ); const perspectivePxCtrl = useOption( @@ -244,7 +251,9 @@ export function useCameraFolder(parent: GUI | null, inputs: CameraFolderInputs): "Perspective px", PERSPECTIVE_PX_OPTIONS, perspectivePx, - (value) => onUpdateScene({ perspective: value }), + (value) => onUpdateScene({ + perspective: value === POLYCSS_DEFAULT_PERSPECTIVE ? undefined : value, + }), ); useSlider(folder, "Zoom", { min: 0.05, max: 2.5, step: 0.01 }, zoom, (value) => diff --git a/website/src/components/GalleryWorkbench/GalleryWorkbench.tsx b/website/src/components/GalleryWorkbench/GalleryWorkbench.tsx index 40a68ea2..6002a8fa 100644 --- a/website/src/components/GalleryWorkbench/GalleryWorkbench.tsx +++ b/website/src/components/GalleryWorkbench/GalleryWorkbench.tsx @@ -26,6 +26,7 @@ import { DropOverlay } from "../DropOverlay"; import { StatsOverlay } from "../StatsOverlay"; import { activeMeshResolution, + POLYCSS_DEFAULT_PERSPECTIVE, type GizmoMode, type SceneOptionsState, type DomMetrics, @@ -109,7 +110,7 @@ const DEFAULT_SCENE: SceneOptionsState = { zoom: PRESETS[0].zoom ?? 0.35, rotX: PRESETS[0].rotX ?? 65, rotY: PRESETS[0].rotY ?? 45, - perspective: 10000, + perspective: undefined, lightAzimuth: 50, lightElevation: 45, lightIntensity: 1, @@ -1062,7 +1063,9 @@ export default function GalleryWorkbench() { return options; }, [selectableAnimationClips]); const perspectiveMode = sceneOptions.perspective === false ? "orthographic" : "perspective"; - const perspectivePx = sceneOptions.perspective === false ? 10000 : sceneOptions.perspective; + const perspectivePx = typeof sceneOptions.perspective === "number" + ? sceneOptions.perspective + : POLYCSS_DEFAULT_PERSPECTIVE; // Materials data — grouped by mesh, then by canonical polygon color. const inspectorMeshes = useMemo(() => { diff --git a/website/src/components/PolyDemo.astro b/website/src/components/PolyDemo.astro index 2b249ea7..ce033724 100644 --- a/website/src/components/PolyDemo.astro +++ b/website/src/components/PolyDemo.astro @@ -345,12 +345,13 @@ const { id, model, mtl, generator, generatorParams, controls, defaults, showStat // ambientColor? }) — `undefined` falls through to the renderer's // default (ambient 0.35, key direction [0.4,-0.7,0.59], white). Demos // bump ambient to brighten dim assets like trees / dark GLBs. - const rendererDefaultPerspective = 8000; + const rendererDefaultPerspective = 32000; + const hasPerspectiveControl = controlList.includes("perspective"); const state: Record = { zoom: defaults.zoom ?? 1, rotX: defaults.rotX ?? 65, rotY: defaults.rotY ?? 45, - perspective: defaults.perspective ?? false, + perspective: defaults.perspective ?? (hasPerspectiveControl ? undefined : false), interactive: defaults.interactive ?? false, animate: defaults.animate ?? false, light: defaults.light, @@ -543,9 +544,11 @@ const { id, model, mtl, generator, generatorParams, controls, defaults, showStat const cameraOpts = { rotX: state.rotX, rotY: state.rotY, zoom: state.zoom }; const perspective = perspectiveOverride(); - const camera = perspective !== undefined - ? createPolyPerspectiveCamera({ ...cameraOpts, perspective }) - : createPolyOrthographicCamera(cameraOpts); + const camera = state.perspective === false + ? createPolyOrthographicCamera(cameraOpts) + : createPolyPerspectiveCamera( + perspective !== undefined ? { ...cameraOpts, perspective } : cameraOpts, + ); const sceneOptions: Record = { camera, autoCenter: true }; if (state.light) sceneOptions.directionalLight = state.light; @@ -621,16 +624,18 @@ const { id, model, mtl, generator, generatorParams, controls, defaults, showStat function updateCode() { // Camera props — zoom/rotX/rotY always on camera; perspective on // PolyPerspectiveCamera only (omitted entirely for ortho). - // state.perspective === false → orthographic; number → perspective. + // state.perspective === false → orthographic; undefined → perspective default. const isOrtho = state.perspective === false; - const perspectivePx = isOrtho ? null : (state.perspective as number); + const perspectivePx = isOrtho + ? null + : (typeof state.perspective === "number" ? state.perspective : rendererDefaultPerspective); // Only include non-default camera props in snippets to keep them tidy. const camProps: Record = {}; if (state.zoom !== 1) camProps.zoom = state.zoom.toFixed(2); if (state.rotX !== 65) camProps.rotX = String(state.rotX); if (state.rotY !== 45) camProps.rotY = String(state.rotY); - // Perspective is emitted only when non-default (8000 is the default). + // Perspective is emitted only when non-default. if (!isOrtho && perspectivePx !== rendererDefaultPerspective) { camProps.perspective = String(perspectivePx); } @@ -922,7 +927,7 @@ const { id, model, mtl, generator, generatorParams, controls, defaults, showStat zoom: () => makeRange("zoom", 0.1, 3, 0.01, state.zoom, (v) => { state.zoom = v; updateCamera(); }), rotX: () => makeRange("rotX", 0, 90, 1, state.rotX, (v) => { state.rotX = v; updateCamera(); }, "°"), rotY: () => makeRange("rotY", 0, 360, 1, state.rotY, (v) => { state.rotY = v; animRotY = v; updateCamera(); }, "°"), - perspective: () => makeRange("perspective", 200, 12000, 100, state.perspective === false ? rendererDefaultPerspective : state.perspective, (v) => { state.perspective = v; updateCamera(); }, "px"), + perspective: () => makeRange("perspective", 200, 64000, 100, state.perspective === false || state.perspective === undefined ? rendererDefaultPerspective : state.perspective, (v) => { state.perspective = v === rendererDefaultPerspective ? undefined : v; updateCamera(); }, "px"), interactive: () => makeSwitch("interactive", state.interactive, (v) => { state.interactive = v; updateCamera(); }), animate: () => makeSwitch("animate", state.animate, (v) => { state.animate = v; updateCamera(); }), // Sphere-specific controls diff --git a/website/src/components/ReactScene/ReactScene.tsx b/website/src/components/ReactScene/ReactScene.tsx index bbc022dd..cf26cf5f 100644 --- a/website/src/components/ReactScene/ReactScene.tsx +++ b/website/src/components/ReactScene/ReactScene.tsx @@ -131,7 +131,13 @@ export function ReactScene({ const Cam = sceneOptions.perspective === false ? PolyOrthographicCamera : PolyPerspectiveCamera; const camProps = sceneOptions.perspective === false ? { zoom: sceneOptions.zoom, rotX: sceneOptions.rotX, rotY: sceneOptions.rotY, target: sceneOptions.target } - : { zoom: sceneOptions.zoom, rotX: sceneOptions.rotX, rotY: sceneOptions.rotY, target: sceneOptions.target, perspective: sceneOptions.perspective }; + : { + zoom: sceneOptions.zoom, + rotX: sceneOptions.rotX, + rotY: sceneOptions.rotY, + target: sceneOptions.target, + ...(typeof sceneOptions.perspective === "number" ? { perspective: sceneOptions.perspective } : {}), + }; const orbitCameraDrag = sceneOptions.interactive && !gizmoDragging; const mapCameraDrag = sceneOptions.interactive && !gizmoDragging; const centerPolygons = scenePolygons; diff --git a/website/src/components/VanillaScene/VanillaScene.tsx b/website/src/components/VanillaScene/VanillaScene.tsx index 9f73a9d5..2153de9f 100644 --- a/website/src/components/VanillaScene/VanillaScene.tsx +++ b/website/src/components/VanillaScene/VanillaScene.tsx @@ -298,9 +298,11 @@ export function VanillaScene({ target: options.target as Vec3 | undefined, }; const perspective = options.dragMode === "fpv" ? FPV_PERSPECTIVE : options.perspective; - const camera = perspective - ? createPolyPerspectiveCamera({ ...cameraOpts, perspective }) - : createPolyOrthographicCamera(cameraOpts); + const camera = perspective === false + ? createPolyOrthographicCamera(cameraOpts) + : createPolyPerspectiveCamera( + typeof perspective === "number" ? { ...cameraOpts, perspective } : cameraOpts, + ); cameraRef.current = camera; const sceneOptions: PolySceneOptions = { camera, diff --git a/website/src/components/fpv/useFpvHost.ts b/website/src/components/fpv/useFpvHost.ts index 8974d6d9..5330db98 100644 --- a/website/src/components/fpv/useFpvHost.ts +++ b/website/src/components/fpv/useFpvHost.ts @@ -18,7 +18,7 @@ import { useFpvSpawn } from "./useFpvSpawn"; export interface UseFpvHostOptions { dragMode: SceneOptionsState["dragMode"]; autoCenter: boolean; - perspective: number | false; + perspective: number | false | undefined; rotY: number; /** World-space polygons used to compute the spawn bbox. Caller is * responsible for applying per-mesh transforms (position/scale) before diff --git a/website/src/components/fpv/useFpvSpawn.ts b/website/src/components/fpv/useFpvSpawn.ts index 878ff1c1..d8c0db5b 100644 --- a/website/src/components/fpv/useFpvSpawn.ts +++ b/website/src/components/fpv/useFpvSpawn.ts @@ -8,7 +8,7 @@ export const FPV_PERSPECTIVE = 2000; export interface UseFpvSpawnOptions { dragMode: SceneOptionsState["dragMode"]; autoCenter: boolean; - perspective: number | false; + perspective: number | false | undefined; rotY: number; scenePolygons: Polygon[]; updateScene: (partial: Partial) => void; @@ -24,7 +24,7 @@ export function useFpvSpawn({ }: UseFpvSpawnOptions): void { const prevDragModeRef = useRef(dragMode); const fpvSavedAutoCenterRef = useRef(null); - const fpvSavedPerspectiveRef = useRef(null); + const fpvSavedPerspectiveRef = useRef(null); useEffect(() => { const prev = prevDragModeRef.current; diff --git a/website/src/components/types.ts b/website/src/components/types.ts index 78dd271c..72876d1b 100644 --- a/website/src/components/types.ts +++ b/website/src/components/types.ts @@ -14,6 +14,8 @@ export type DragMode = "orbit" | "pan" | "fpv"; export type PerspectiveMode = "perspective" | "orthographic"; +export const POLYCSS_DEFAULT_PERSPECTIVE = 32000; + export type WorkbenchMeshResolution = MeshResolution | "disabled"; export type BuilderGridTone = "gray" | "dark"; @@ -50,7 +52,7 @@ export interface SceneOptionsState { zoom: number; rotX: number; rotY: number; - perspective: number | false; + perspective: number | false | undefined; lightAzimuth: number; lightElevation: number; lightIntensity: number; diff --git a/website/src/content/docs/components/poly-camera.mdx b/website/src/content/docs/components/poly-camera.mdx index 248b827a..8851822f 100644 --- a/website/src/content/docs/components/poly-camera.mdx +++ b/website/src/content/docs/components/poly-camera.mdx @@ -44,7 +44,7 @@ All props from `PolyCamera` above, plus: | Prop | Type | Default | Description | |------|------|---------|-------------| -| `perspective` | `number` | `8000` | CSS perspective depth in pixels. Higher values feel flatter (more isometric); lower values exaggerate depth. | +| `perspective` | `number` | `32000` | CSS perspective depth in pixels. Higher values feel flatter (more isometric); lower values exaggerate depth. | ## Usage diff --git a/website/src/content/docs/guides/projections.mdx b/website/src/content/docs/guides/projections.mdx index 71c89e9c..19752566 100644 --- a/website/src/content/docs/guides/projections.mdx +++ b/website/src/content/docs/guides/projections.mdx @@ -16,7 +16,7 @@ Use the sliders below to explore how `perspective`, `rotX`, and `rotY` interact. id="projections-demo" model="/gallery/glb/tree.glb" controls='["perspective","rotX","rotY","zoom","animate"]' - defaults='{"rotX":65,"animate": true,"rotY":45,"perspective":8000,"zoom":0.1,"light":{"ambient":0.7}}' + defaults='{"rotX":65,"animate": true,"rotY":45,"zoom":0.1,"light":{"ambient":0.7}}' /> ## Perspective depth @@ -43,7 +43,7 @@ Higher `perspective` values feel flatter (more isometric-like); lower values exa - + @@ -69,7 +69,7 @@ import { PolyCamera, PolyPerspectiveCamera, PolyScene, PolyIcosahedron } from "@ // Very flat / near-isometric perspective - + diff --git a/website/src/pages/index.astro b/website/src/pages/index.astro index 72480bdd..ddaea880 100644 --- a/website/src/pages/index.astro +++ b/website/src/pages/index.astro @@ -301,7 +301,6 @@ const ldJson = { rotX: 74.4, rotY: 301.6, zoom: 0.3, - perspective: 100000, }); const scene = createPolyScene(host, { camera, autoCenter: true }); scene.add(parseResult); From 8e0223732689c53101eeaeec5db7bac2e303d895 Mon Sep 17 00:00:00 2001 From: agustin-littlehat Date: Thu, 28 May 2026 10:26:18 -0300 Subject: [PATCH 5/7] feat(snapshot): export standalone scene snapshots --- AGENTS.md | 4 +- README.md | 12 + packages/polycss/README.md | 12 + packages/polycss/src/index.ts | 9 + .../snapshot/exportPolySceneSnapshot.test.ts | 216 +++++ .../src/snapshot/exportPolySceneSnapshot.ts | 877 ++++++++++++++++++ website/src/content/docs/api/headless.mdx | 14 + 7 files changed, 1143 insertions(+), 1 deletion(-) create mode 100644 packages/polycss/src/snapshot/exportPolySceneSnapshot.test.ts create mode 100644 packages/polycss/src/snapshot/exportPolySceneSnapshot.ts diff --git a/AGENTS.md b/AGENTS.md index 8e5c5c10..f84f3904 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -86,7 +86,7 @@ If you find yourself wanting a `requestAnimationFrame` loop to update many DOM n - **Hooks/composables:** `usePolyCamera`, `usePolyMesh`, `usePolySceneContext`, `usePolySelect`, `usePolySelectionApi`, `usePolyAnimation`. - **Components:** `PolyPerspectiveCamera`, `PolyOrthographicCamera`, `PolyOrbitControls`, `PolyMapControls`, `PolyTransformControls`, `PolySelect`, `PolyAxesHelper`, `PolyDirectionalLightHelper`. - **Types:** `PolyDirectionalLight`, `PolyAmbientLight`, `PolyTextureLightingMode`, `PolyAnimationMixer`, `PolyRenderStats`. -- **Functions:** `findPolyMeshHandle`, `injectPolyBaseStyles`, `collectPolyRenderStats`. +- **Functions:** `findPolyMeshHandle`, `injectPolyBaseStyles`, `collectPolyRenderStats`, `exportPolySceneSnapshot`. - **Vanilla factories:** `create*` names stay as-is (`createPolyScene`, `createTransformControls`, `createSelect`). - **HTML custom elements:** `poly-` prefix + kebab-case. Existing tags: ``, ``, ``, ``, ``, ``, ``. Any new element follows the same shape (e.g. ``, ``). - **Leaf DOM tags (``, ``, ``, ``):** internal render-strategy tags. Not part of the public API and not user-facing — do not document them as such. @@ -98,6 +98,8 @@ The React and Vue packages are mirror images. **Any public API change in one mus When you change `packages/polycss` or `packages/core` in a way that affects the public surface (new option, renamed export, changed default), the React and Vue bindings update in the same PR. Don't ship a PolyCSS change that leaves the bindings stale. +The DOM snapshot exporter is the current exception to mirrored React/Vue public exports: `exportPolySceneSnapshot` lives in `@layoutit/polycss` because it is browser DOM serialization, not component API. React/Vue callers import it from `@layoutit/polycss` and pass the rendered `.polycss-camera` / `.polycss-scene` element. + **Renderer-owned browser glue.** The canvas atlas pipeline (`buildAtlasPages` + helpers), browser-feature detection (`isBorderShapeSupported`, `isSolidTriangleSupported`, `resolveSolidTrianglePrimitive`), direct voxel renderer (`voxelRenderer.ts`), and injected `.polycss-scene` / `.polycss-camera` base styles exist as **independent copies** across the three renderers. This includes `packages/polycss/src/render/atlas/`, `packages/react/src/scene/atlas/`, `packages/vue/src/scene/atlas/`, the three renderer-local `voxelRenderer.ts` files, and the three sibling `styles.ts` files. This is deliberate — each renderer is self-contained on its dep graph (React/Vue do not import from the `polycss` package). The trade-off is that a bug fix in any of these files MUST be mirrored into the other two. Coverage is pinned per copy by the co-located test files. Before opening a PR: diff --git a/README.md b/README.md index 6e96bab7..3591bfa7 100644 --- a/README.md +++ b/README.md @@ -93,6 +93,18 @@ export default function App() { - `` provides keyboard and pointer-look navigation. - `` adds translate/rotate gizmos for selected mesh handles. +### Snapshot Export + +The vanilla package exports `exportPolySceneSnapshot(target)`. It clones the current rendered `.polycss-camera` / `.polycss-scene` DOM, injects only the PolyCSS CSS needed by that snapshot, inlines CSS `url(...)` image assets as `data:image/...;base64,...`, strips scripts and inline event handlers, and returns a standalone HTML document string with no PolyCSS runtime import. It works with rendered React/Vue scenes too; import it from `@layoutit/polycss` and pass the rendered camera or scene element. + +```ts +import { exportPolySceneSnapshot } from "@layoutit/polycss"; + +const html = await exportPolySceneSnapshot(scene.host); +``` + +If any referenced asset cannot be inlined, the function throws `PolySceneSnapshotError` with `code: "ASSET_INLINE_FAILED"`. + ### Polygon Data Model Each polygon describes one renderable face: diff --git a/packages/polycss/README.md b/packages/polycss/README.md index 5d571c90..821d3741 100644 --- a/packages/polycss/README.md +++ b/packages/polycss/README.md @@ -109,6 +109,18 @@ import { PolyCamera, PolyScene, PolyOrbitControls, PolyMesh } from "@layoutit/po - `` provides keyboard and pointer-look navigation. - `` adds translate/rotate gizmos for selected mesh handles. +### Snapshot Export + +The vanilla package exports `exportPolySceneSnapshot(target)`. It clones the current rendered `.polycss-camera` / `.polycss-scene` DOM, injects only the PolyCSS CSS needed by that snapshot, inlines CSS `url(...)` image assets as `data:image/...;base64,...`, strips scripts and inline event handlers, and returns a standalone HTML document string with no PolyCSS runtime import. It works with rendered React/Vue scenes too; import it from `@layoutit/polycss` and pass the rendered camera or scene element. + +```ts +import { exportPolySceneSnapshot } from "@layoutit/polycss"; + +const html = await exportPolySceneSnapshot(scene.host); +``` + +If any referenced asset cannot be inlined, the function throws `PolySceneSnapshotError` with `code: "ASSET_INLINE_FAILED"`. + ### Polygon Data Model Each polygon describes one renderable face: diff --git a/packages/polycss/src/index.ts b/packages/polycss/src/index.ts index d90e964f..60042139 100644 --- a/packages/polycss/src/index.ts +++ b/packages/polycss/src/index.ts @@ -138,6 +138,15 @@ export type { PolyRenderSurfaceLeafCounts, } from "./render/renderStats"; +// ── Standalone scene snapshots ─────────────────────────────────── +export { + exportPolySceneSnapshot, + PolySceneSnapshotError, +} from "./snapshot/exportPolySceneSnapshot"; +export type { + PolySceneSnapshotErrorCode, +} from "./snapshot/exportPolySceneSnapshot"; + // ── Primitive shape factories ───────────────────────────────────── export { createPolyBox, diff --git a/packages/polycss/src/snapshot/exportPolySceneSnapshot.test.ts b/packages/polycss/src/snapshot/exportPolySceneSnapshot.test.ts new file mode 100644 index 00000000..2c13b468 --- /dev/null +++ b/packages/polycss/src/snapshot/exportPolySceneSnapshot.test.ts @@ -0,0 +1,216 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; +import { + exportPolySceneSnapshot, + PolySceneSnapshotError, +} from "./exportPolySceneSnapshot"; + +function makeRenderedScene(style: string): { host: HTMLDivElement; leaf: HTMLElement } { + const host = document.createElement("div"); + const camera = document.createElement("div"); + const scene = document.createElement("div"); + const leaf = document.createElement("s"); + const script = document.createElement("script"); + + camera.className = "polycss-camera"; + camera.setAttribute("onclick", "evil()"); + scene.className = "polycss-scene"; + leaf.setAttribute("style", style); + leaf.setAttribute("onpointerdown", "evil()"); + script.type = "application/json"; + script.textContent = '{"evil":true}'; + + scene.append(leaf, script); + camera.append(scene); + host.append(camera); + document.body.append(host); + + return { host, leaf }; +} + +function makeSolidScene(): { host: HTMLDivElement; leaf: HTMLElement } { + const host = document.createElement("div"); + const camera = document.createElement("div"); + const scene = document.createElement("div"); + const mesh = document.createElement("div"); + const leaf = document.createElement("b"); + + camera.className = "polycss-camera"; + scene.className = "polycss-scene"; + mesh.className = "polycss-mesh"; + mesh.style.setProperty("--origin", "10px 20px 30px"); + mesh.style.setProperty("--polycss-paint", "#ff0000"); + leaf.setAttribute("style", "transform:matrix3d(1,0,0,0,0,1,0,0,0,0,1,0,0,0,0,1)"); + + mesh.append(leaf); + scene.append(mesh); + camera.append(scene); + host.append(camera); + document.body.append(host); + + return { host, leaf }; +} + +function makeDynamicScene(): { host: HTMLDivElement; leaf: HTMLElement } { + const host = document.createElement("div"); + const camera = document.createElement("div"); + const scene = document.createElement("div"); + const leaf = document.createElement("b"); + + camera.className = "polycss-camera"; + scene.className = "polycss-scene"; + scene.dataset.polycssLighting = "dynamic"; + scene.style.setProperty("--plx", "0.1"); + scene.style.setProperty("--ply", "0.2"); + scene.style.setProperty("--plz", "0.3"); + scene.style.setProperty("--clx", "0.1"); + scene.style.setProperty("--cly", "0.2"); + scene.style.setProperty("--clz", "0.3"); + leaf.setAttribute( + "style", + "transform:matrix3d(1,0,0,0,0,1,0,0,0,0,1,0,0,0,0,1);--pnx:0;--pny:0;--pnz:1;--psr:1;--psg:0;--psb:0", + ); + + scene.append(leaf); + camera.append(scene); + host.append(camera); + document.body.append(host); + + return { host, leaf }; +} + +function countOccurrences(value: string, pattern: string): number { + return value.split(pattern).length - 1; +} + +describe("exportPolySceneSnapshot", () => { + afterEach(() => { + document.body.innerHTML = ""; + document.title = ""; + vi.unstubAllGlobals(); + }); + + it("serializes the current rendered camera subtree and inlines CSS image URLs", async () => { + const fetchMock = vi.fn(async () => + new Response(new Blob(["atlas"], { type: "image/png" })), + ); + vi.stubGlobal("fetch", fetchMock); + document.title = "PolyCSS "; + const { leaf } = makeRenderedScene( + 'background: url("blob:atlas") 0 0 / 64px 64px no-repeat; --polycss-atlas-url: url(blob:atlas); --polycss-atlas-size: 128px;', + ); + + const html = await exportPolySceneSnapshot(leaf); + + expect(html.startsWith("")).toBe(true); + expect(html).toContain("PolyCSS <snapshot>"); + expect(html).toContain('class="polycss-camera"'); + expect(html).toContain('class="polycss-scene"'); + expect(html).toContain("data:image/png;base64,YXRsYXM="); + expect(html).toContain("data-polycss-snapshot-bg"); + expect(html).toContain(".polycss-scene"); + expect(html).toContain(".polycss-scene s"); + expect(html).toContain("width: 128px"); + expect(html).toContain("height: 128px"); + expect(html).not.toContain("blob:atlas"); + expect(html).not.toContain("--polycss-atlas-size"); + expect(html).not.toContain("--polycss-atlas-url"); + expect(html).not.toContain("@property"); + expect(html).not.toContain("--shadow-proj"); + expect(html).not.toContain(".polycss-scene q"); + expect(html).not.toContain("polycss-transform-ring"); + expect(html).not.toContain(" { + const fetchMock = vi.fn(async () => + new Response(new Blob(["atlas"], { type: "image/png" })), + ); + vi.stubGlobal("fetch", fetchMock); + const { leaf } = makeRenderedScene( + 'background: url("blob:atlas") 0 0 / 64px 64px no-repeat; --polycss-atlas-size: 64px;', + ); + const secondLeaf = leaf.cloneNode() as HTMLElement; + leaf.after(secondLeaf); + + const html = await exportPolySceneSnapshot(leaf); + + expect(countOccurrences(html, "data:image/png;base64,YXRsYXM=")).toBe(1); + expect(html.match(/]*data-polycss-snapshot-bg="a0"/g)?.length).toBe(2); + expect(fetchMock).toHaveBeenCalledTimes(1); + }); + + it("does not fetch already-inline data URLs", async () => { + const fetchMock = vi.fn(); + vi.stubGlobal("fetch", fetchMock); + const { host } = makeRenderedScene( + "background-image: url(data:image/png;base64,abc123);", + ); + + const html = await exportPolySceneSnapshot(host); + + expect(html).toContain("data:image/png;base64,abc123"); + expect(fetchMock).not.toHaveBeenCalled(); + }); + + it("freezes static mesh vars into inline declarations", async () => { + const { leaf } = makeSolidScene(); + + const html = await exportPolySceneSnapshot(leaf); + + expect(html).toContain("transform-origin: 10px 20px 30px"); + expect(html).toContain("color: #ff0000"); + expect(html).not.toContain("--origin"); + expect(html).not.toContain("--polycss-paint"); + expect(html).not.toContain("@property"); + expect(html).not.toContain("--shadow-proj"); + }); + + it("includes dynamic lighting CSS only for dynamic snapshots", async () => { + const { leaf } = makeDynamicScene(); + + const html = await exportPolySceneSnapshot(leaf); + + expect(html).toContain('@property --plx { syntax: ""'); + expect(html).toContain('data-polycss-lighting="dynamic"'); + expect(html).toContain('.polycss-scene[data-polycss-lighting="dynamic"] :not(.polycss-bucket) > b'); + expect(html).not.toContain("@property --shadow-ground-cssz"); + expect(html).not.toContain("--shadow-proj"); + expect(html).not.toContain(".polycss-scene q"); + expect(html).not.toContain("--clx"); + }); + + it("throws a clear error when an asset cannot be inlined", async () => { + vi.stubGlobal("fetch", vi.fn(async () => { + throw new Error("blocked"); + })); + const { leaf } = makeRenderedScene( + 'background-image: url("https://cdn.example/texture.png");', + ); + + let thrown: unknown; + try { + await exportPolySceneSnapshot(leaf); + } catch (error) { + thrown = error; + } + + expect(thrown).toBeInstanceOf(PolySceneSnapshotError); + expect(thrown).toMatchObject({ + code: "ASSET_INLINE_FAILED", + url: "https://cdn.example/texture.png", + }); + }); + + it("rejects invalid targets and targets without a rendered scene", async () => { + await expect( + exportPolySceneSnapshot(null as unknown as Element), + ).rejects.toMatchObject({ code: "INVALID_TARGET" }); + + await expect( + exportPolySceneSnapshot(document.createElement("div")), + ).rejects.toMatchObject({ code: "SCENE_NOT_FOUND" }); + }); +}); diff --git a/packages/polycss/src/snapshot/exportPolySceneSnapshot.ts b/packages/polycss/src/snapshot/exportPolySceneSnapshot.ts new file mode 100644 index 00000000..f8bb1ab6 --- /dev/null +++ b/packages/polycss/src/snapshot/exportPolySceneSnapshot.ts @@ -0,0 +1,877 @@ +export type PolySceneSnapshotErrorCode = + | "INVALID_TARGET" + | "SCENE_NOT_FOUND" + | "ASSET_INLINE_FAILED"; + +export class PolySceneSnapshotError extends Error { + readonly code: PolySceneSnapshotErrorCode; + readonly url?: string; + + constructor(code: PolySceneSnapshotErrorCode, message: string, url?: string) { + super(message); + this.name = "PolySceneSnapshotError"; + this.code = code; + this.url = url; + Object.setPrototypeOf(this, new.target.prototype); + } +} + +interface InlineContext { + baseUrl: string; + cache: Map>; +} + +interface SnapshotAssetRule { + attr: string; + value: string; + property: "background-image" | "--polycss-atlas-url"; + dataUrl: string; +} + +const SNAPSHOT_DOCUMENT_CSS = ` +html, +body { + width: 100%; + height: 100%; + margin: 0; +} + +body { + position: relative; + overflow: hidden; +} +`; + +type PolyLeafTag = "b" | "i" | "s" | "u"; + +interface SnapshotCssFeatures { + hasCamera: boolean; + hasScene: boolean; + hasMesh: boolean; + hasFpvHost: boolean; + hasBucket: boolean; + hasVoxelMesh: boolean; + hasTransformGizmo: boolean; + hasTransformRing: boolean; + hasDynamicLighting: boolean; + hasQ: boolean; + leafTags: PolyLeafTag[]; + solidLeafTags: PolyLeafTag[]; +} + +const POLY_LEAF_TAGS: PolyLeafTag[] = ["b", "i", "s", "u"]; +const SOLID_POLY_LEAF_TAGS: PolyLeafTag[] = ["b", "i", "u"]; +const LIGHTING_CUSTOM_PROPS = [ + "--plx", "--ply", "--plz", + "--plr", "--plg", "--plb", "--pli", + "--par", "--pag", "--pab", "--pai", + "--pnx", "--pny", "--pnz", + "--psr", "--psg", "--psb", + "--plam", +] as const; +const SHADOW_CUSTOM_PROPS = [ + "--clx", "--cly", "--clz", "--shadow-ground-cssz", +] as const; +const ATLAS_CUSTOM_PROPS = [ + "--polycss-atlas-size", + "--polycss-atlas-url", + "--polycss-atlas-position", + "--polycss-atlas-image-size", +] as const; + +function isElement(value: unknown): value is Element { + return !!value + && typeof (value as Element).querySelector === "function" + && typeof (value as Element).cloneNode === "function"; +} + +function findPolySnapshotRoot(target: Element): Element | null { + const closestCamera = target.closest(".polycss-camera"); + if (closestCamera) return closestCamera; + + const closestScene = target.closest(".polycss-scene"); + if (closestScene) return closestScene.closest(".polycss-camera") ?? closestScene; + + return target.querySelector(".polycss-camera") + ?? target.querySelector(".polycss-scene"); +} + +function allElements(root: Element): Element[] { + return [root, ...Array.from(root.querySelectorAll("*"))]; +} + +function matchesOrContains(root: Element, selector: string): boolean { + return root.matches(selector) || !!root.querySelector(selector); +} + +function containsTag(root: Element, tagName: string): boolean { + return root.tagName.toLowerCase() === tagName || !!root.querySelector(tagName); +} + +function collectSnapshotCssFeatures(root: Element): SnapshotCssFeatures { + const leafTags = POLY_LEAF_TAGS.filter((tag) => containsTag(root, tag)); + const solidLeafTags = SOLID_POLY_LEAF_TAGS.filter((tag) => leafTags.includes(tag)); + return { + hasCamera: matchesOrContains(root, ".polycss-camera"), + hasScene: matchesOrContains(root, ".polycss-scene"), + hasMesh: matchesOrContains(root, ".polycss-mesh"), + hasFpvHost: matchesOrContains(root, ".polycss-fpv-host"), + hasBucket: matchesOrContains(root, ".polycss-bucket"), + hasVoxelMesh: matchesOrContains(root, ".polycss-voxel-mesh"), + hasTransformGizmo: matchesOrContains(root, ".polycss-transform-gizmo"), + hasTransformRing: matchesOrContains(root, ".polycss-transform-ring"), + hasDynamicLighting: matchesOrContains(root, '.polycss-scene[data-polycss-lighting="dynamic"]'), + hasQ: containsTag(root, "q"), + leafTags, + solidLeafTags, + }; +} + +function selectorList(tags: readonly string[], prefix = ".polycss-scene "): string { + return tags.map((tag) => `${prefix}${tag}`).join(",\n"); +} + +function directLeafSelectorList(tags: readonly string[]): string { + return tags + .map((tag) => `.polycss-scene[data-polycss-lighting="dynamic"] :not(.polycss-bucket) > ${tag}`) + .join(",\n"); +} + +function dynamicPropertyCss(includeShadowProjection: boolean): string { + const props = [ + '@property --plx { syntax: ""; inherits: true; initial-value: 0; }', + '@property --ply { syntax: ""; inherits: true; initial-value: 0; }', + '@property --plz { syntax: ""; inherits: true; initial-value: 1; }', + '@property --plr { syntax: ""; inherits: true; initial-value: 1; }', + '@property --plg { syntax: ""; inherits: true; initial-value: 1; }', + '@property --plb { syntax: ""; inherits: true; initial-value: 1; }', + '@property --pli { syntax: ""; inherits: true; initial-value: 1; }', + '@property --par { syntax: ""; inherits: true; initial-value: 1; }', + '@property --pag { syntax: ""; inherits: true; initial-value: 1; }', + '@property --pab { syntax: ""; inherits: true; initial-value: 1; }', + '@property --pai { syntax: ""; inherits: true; initial-value: 0.4; }', + '@property --pnx { syntax: ""; inherits: true; initial-value: 0; }', + '@property --pny { syntax: ""; inherits: true; initial-value: 0; }', + '@property --pnz { syntax: ""; inherits: true; initial-value: 1; }', + '@property --psr { syntax: ""; inherits: true; initial-value: 1; }', + '@property --psg { syntax: ""; inherits: true; initial-value: 1; }', + '@property --psb { syntax: ""; inherits: true; initial-value: 1; }', + '@property --plam { syntax: ""; inherits: true; initial-value: 0; }', + ]; + if (includeShadowProjection) { + props.push( + '@property --clx { syntax: ""; inherits: true; initial-value: 0.01; }', + '@property --cly { syntax: ""; inherits: true; initial-value: 0; }', + '@property --clz { syntax: ""; inherits: true; initial-value: 1; }', + '@property --shadow-ground-cssz { syntax: ""; inherits: true; initial-value: 0; }', + ); + } + return props.join("\n"); +} + +function buildDynamicLightingCss(features: SnapshotCssFeatures): string { + const parts = [dynamicPropertyCss(features.hasQ)]; + + if (features.hasBucket) { + parts.push(` +.polycss-scene[data-polycss-lighting="dynamic"] .polycss-bucket { + --plam: max(0, calc( + var(--pnx) * var(--plx) + + var(--pny) * var(--ply) + + var(--pnz) * var(--plz) + )); +} +`); + } + + if (features.leafTags.length > 0) { + parts.push(` +${directLeafSelectorList(features.leafTags)} { + --plam: max(0, calc( + var(--pnx) * var(--plx) + + var(--pny) * var(--ply) + + var(--pnz) * var(--plz) + )); +} +`); + } + + if (features.leafTags.includes("s")) { + parts.push(` +.polycss-scene[data-polycss-lighting="dynamic"] s { + contain: strict; + background-color: rgb( + calc(255 * (var(--par) * var(--pai) + + var(--plr) * var(--pli) * var(--plam))) + calc(255 * (var(--pag) * var(--pai) + + var(--plg) * var(--pli) * var(--plam))) + calc(255 * (var(--pab) * var(--pai) + + var(--plb) * var(--pli) * var(--plam))) + ); + background-blend-mode: multiply; + background-image: var(--polycss-atlas-url); + background-position: var(--polycss-atlas-position); + background-repeat: no-repeat; + background-size: var(--polycss-atlas-image-size); + mask-image: var(--polycss-atlas-url); + mask-mode: alpha; + mask-position: var(--polycss-atlas-position); + mask-repeat: no-repeat; + mask-size: var(--polycss-atlas-image-size); + -webkit-mask-image: var(--polycss-atlas-url); + -webkit-mask-position: var(--polycss-atlas-position); + -webkit-mask-repeat: no-repeat; + -webkit-mask-size: var(--polycss-atlas-image-size); +} +`); + } + + if (features.solidLeafTags.length > 0) { + parts.push(` +${selectorList(features.solidLeafTags)} { + color: rgb( + calc(255 * var(--psr) * (var(--par) * var(--pai) + + var(--plr) * var(--pli) * var(--plam))) + calc(255 * var(--psg) * (var(--pag) * var(--pai) + + var(--plg) * var(--pli) * var(--plam))) + calc(255 * var(--psb) * (var(--pab) * var(--pai) + + var(--plb) * var(--pli) * var(--plam))) + ); +} +`); + } + + if (features.hasQ) { + parts.push(` +.polycss-scene[data-polycss-lighting="dynamic"] { + --shadow-proj: matrix3d( + 1, 0, 0, 0, + 0, 1, 0, 0, + calc(-1 * var(--clx) / var(--clz)), + calc(-1 * var(--cly) / var(--clz)), + 0.01, + 0, + calc(var(--shadow-ground-cssz) * var(--clx) / var(--clz)), + calc(var(--shadow-ground-cssz) * var(--cly) / var(--clz)), + calc(var(--shadow-ground-cssz) * 0.99), + 1 + ); +} + +.polycss-scene[data-polycss-lighting="dynamic"] q { + opacity: clamp(0, calc((var(--pnx) * var(--clx) + var(--pny) * var(--cly) + var(--pnz) * var(--clz)) * 10), 1); +} +`); + } + + return parts.join("\n"); +} + +function buildPolySnapshotCss(features: SnapshotCssFeatures): string { + const parts = [SNAPSHOT_DOCUMENT_CSS]; + + if (features.hasScene) { + parts.push(` +.polycss-scene, +.polycss-scene *, +.polycss-scene *::before, +.polycss-scene *::after { + box-sizing: border-box; +} + +.polycss-scene { + position: absolute; + top: 50%; + left: 50%; + width: 0; + height: 0; + transform-style: preserve-3d; +} +`); + } + + if (features.hasCamera) { + parts.push(` +.polycss-camera { + position: relative; + display: block; + width: 100%; + height: 100%; +} +`); + } + + if (features.hasFpvHost) { + parts.push(` +.polycss-fpv-host { + perspective: var(--polycss-fpv-perspective, 2000px) !important; + transform-style: preserve-3d !important; +} +`); + } + + if (features.hasMesh) { + parts.push(` +.polycss-mesh { + position: absolute; + transform-style: preserve-3d; +} +`); + } + + if (features.hasBucket) { + parts.push(` +.polycss-bucket { + position: absolute; + transform-style: preserve-3d; +} +`); + } + + if (features.leafTags.length > 0) { + parts.push(` +${selectorList(features.leafTags)} { + position: absolute; + display: block; + transform-origin: 0 0; + transform-style: preserve-3d; + margin: 0; + padding: 0; + font: inherit; + font-weight: normal; + font-style: normal; + line-height: 0; + text-decoration: none; + backface-visibility: hidden; + background-repeat: no-repeat; +} +`); + } + + if (features.solidLeafTags.length > 0 && !features.hasDynamicLighting) { + parts.push(` +${selectorList(features.solidLeafTags)} { + color: currentColor; +} +`); + } + + if (features.leafTags.includes("b")) { + parts.push(` +.polycss-scene b { + background: currentColor; + width: 256px; + height: 256px; +} +`); + } + + if (features.hasVoxelMesh) { + parts.push(` +.polycss-mesh.polycss-voxel-mesh > .polycss-voxel-face { + position: absolute; + display: block; + top: 0; + left: 0; + width: 0; + height: 0; + transform-style: preserve-3d; + transform-origin: 0 0; + margin: 0; + padding: 0; + font: inherit; + line-height: 0; + pointer-events: none; +} + +.polycss-mesh.polycss-voxel-mesh > .polycss-voxel-face > b { + top: 0; + left: 0; + width: var(--polycss-voxel-primitive, 1px); + height: var(--polycss-voxel-primitive, 1px); + backface-visibility: visible; + pointer-events: none; +} +`); + } + + if (features.leafTags.includes("i")) { + parts.push(` +.polycss-scene i { + width: 256px; + height: 256px; + border-color: currentColor; +} +`); + } + + if (features.leafTags.includes("s")) { + parts.push(` +.polycss-scene s { + width: 64px; + height: 64px; +} +`); + } + + if (features.leafTags.includes("u")) { + parts.push(` +.polycss-scene u { + width: 0; + height: 0; + background: transparent; + box-sizing: content-box; + border: 0 solid transparent; + border-color: transparent transparent currentColor transparent; + border-width: 0 128px 256px 128px; +} +`); + } + + if (features.hasQ) { + parts.push(` +.polycss-scene q { + position: absolute; + display: block; + transform-origin: 0 0; + transform-style: preserve-3d; + margin: 0; + padding: 0; + font: inherit; + font-weight: normal; + font-style: normal; + line-height: 0; + text-decoration: none; + backface-visibility: visible; + border-color: currentColor; + pointer-events: none; +} + +.polycss-scene q::before, +.polycss-scene q::after { + content: none; +} +`); + } + + if (features.hasTransformGizmo) { + const tags = features.leafTags.length > 0 ? features.leafTags : POLY_LEAF_TAGS; + parts.push(` +${selectorList(tags, ".polycss-mesh.polycss-transform-gizmo ")} { + backface-visibility: visible; + transition: color 150ms ease-out, border-color 150ms ease-out, background-color 150ms ease-out; +} +`); + } + + if (features.hasTransformRing) { + const tags = features.leafTags.length > 0 ? features.leafTags : POLY_LEAF_TAGS; + parts.push(` +${selectorList(tags, ".polycss-mesh.polycss-transform-ring ")} { + --ring-inner-r: calc(var(--ring-inner-ratio, 0.92) * 50%); + --ring-outer-r: calc(var(--ring-outer-ratio, 1) * 50%); + -webkit-mask: radial-gradient(circle at 50% 50%, + transparent 0%, + transparent var(--ring-inner-r), + black var(--ring-inner-r), + black var(--ring-outer-r), + transparent var(--ring-outer-r)); + mask: radial-gradient(circle at 50% 50%, + transparent 0%, + transparent var(--ring-inner-r), + black var(--ring-inner-r), + black var(--ring-outer-r), + transparent var(--ring-outer-r)); +} +`); + } + + if (features.hasDynamicLighting) { + parts.push(buildDynamicLightingCss(features)); + } + + return parts.map((part) => part.trim()).filter(Boolean).join("\n\n"); +} + +function inlineStyleFor(el: Element): CSSStyleDeclaration | null { + const value = (el as unknown as ElementCSSInlineStyle).style; + return value && typeof value.getPropertyValue === "function" ? value : null; +} + +function cleanupEmptyStyleAttr(el: Element): void { + const style = el.getAttribute("style"); + if (style !== null && style.trim() === "") el.removeAttribute("style"); +} + +function inheritedInlineCustomProperty(el: Element, property: string): string { + let current: Element | null = el; + while (current) { + const value = inlineStyleFor(current)?.getPropertyValue(property).trim(); + if (value) return value; + current = current.parentElement; + } + return ""; +} + +function inlineSnapshotStaticStyleHints( + sourceRoot: Element, + cloneRoot: Element, + features: SnapshotCssFeatures, +): void { + const sourceElements = allElements(sourceRoot); + const cloneElements = allElements(cloneRoot); + for (let i = 0; i < cloneElements.length; i++) { + const sourceEl = sourceElements[i]; + const cloneEl = cloneElements[i]; + if (!sourceEl || !cloneEl) continue; + const cloneStyle = inlineStyleFor(cloneEl); + if (!cloneStyle) continue; + + if (cloneEl.classList.contains("polycss-mesh")) { + const origin = cloneStyle.getPropertyValue("--origin").trim(); + if (origin) { + cloneStyle.setProperty("transform-origin", origin); + cloneStyle.removeProperty("--origin"); + } + } + + const tag = cloneEl.tagName.toLowerCase(); + if (tag === "s") { + const atlasSize = cloneStyle.getPropertyValue("--polycss-atlas-size").trim(); + if (atlasSize) { + cloneStyle.setProperty("width", atlasSize); + cloneStyle.setProperty("height", atlasSize); + } + if (!features.hasDynamicLighting) { + for (const property of ATLAS_CUSTOM_PROPS) { + cloneStyle.removeProperty(property); + } + } else { + cloneStyle.removeProperty("--polycss-atlas-size"); + } + } else if (tag === "b" || tag === "i" || tag === "u") { + const paint = inheritedInlineCustomProperty(sourceEl, "--polycss-paint"); + if (paint && !cloneStyle.getPropertyValue("color")) { + cloneStyle.setProperty("color", paint); + } + } + + if (!features.hasDynamicLighting) { + for (const property of LIGHTING_CUSTOM_PROPS) { + cloneStyle.removeProperty(property); + } + } + if (!features.hasQ) { + for (const property of SHADOW_CUSTOM_PROPS) { + cloneStyle.removeProperty(property); + } + } + cloneStyle.removeProperty("will-change"); + cloneStyle.removeProperty("--polycss-paint"); + cleanupEmptyStyleAttr(cloneEl); + } +} + +function sanitizeClone(root: Element): void { + for (const script of Array.from(root.querySelectorAll("script"))) { + script.remove(); + } + + for (const el of allElements(root)) { + for (const attr of Array.from(el.attributes)) { + if (attr.name.toLowerCase().startsWith("on")) { + el.removeAttribute(attr.name); + } + } + } +} + +function isInlineOrLocalReference(url: string): boolean { + const value = url.trim(); + return value === "" || value.startsWith("#") || /^data:/i.test(value); +} + +function inlineAssetKey(rawUrl: string, ctx: InlineContext): string { + if (/^data:/i.test(rawUrl.trim())) return rawUrl.trim(); + return resolveAssetUrl(rawUrl, ctx.baseUrl); +} + +function extractCssUrl(value: string): string | null { + const match = /url\(\s*(?:(["'])(.*?)\1|([^)]*?))\s*\)/i.exec(value); + return (match?.[2] ?? match?.[3] ?? "").trim() || null; +} + +function escapeCssString(value: string): string { + return value.replace(/\\/g, "\\\\").replace(/"/g, '\\"').replace(/\n/g, "\\a "); +} + +function resolveAssetUrl(url: string, baseUrl: string): string { + try { + return new URL(url, baseUrl).href; + } catch { + throw new PolySceneSnapshotError( + "ASSET_INLINE_FAILED", + `exportPolySceneSnapshot: could not resolve asset URL "${url}".`, + url, + ); + } +} + +function inferMimeType(url: string): string { + const pathname = (() => { + try { + return new URL(url).pathname.toLowerCase(); + } catch { + return url.toLowerCase(); + } + })(); + + if (pathname.endsWith(".jpg") || pathname.endsWith(".jpeg")) return "image/jpeg"; + if (pathname.endsWith(".png")) return "image/png"; + if (pathname.endsWith(".webp")) return "image/webp"; + if (pathname.endsWith(".gif")) return "image/gif"; + if (pathname.endsWith(".svg")) return "image/svg+xml"; + if (pathname.endsWith(".avif")) return "image/avif"; + return "application/octet-stream"; +} + +function arrayBufferToBase64(buffer: ArrayBuffer): string { + const bytes = new Uint8Array(buffer); + const chunkSize = 0x8000; + let binary = ""; + for (let i = 0; i < bytes.length; i += chunkSize) { + const chunk = bytes.subarray(i, i + chunkSize); + binary += String.fromCharCode(...chunk); + } + return btoa(binary); +} + +async function inlineAssetUrl(rawUrl: string, ctx: InlineContext): Promise { + if (isInlineOrLocalReference(rawUrl)) return rawUrl; + + const resolvedUrl = resolveAssetUrl(rawUrl, ctx.baseUrl); + const cached = ctx.cache.get(resolvedUrl); + if (cached) return cached; + + const next = (async () => { + try { + if (typeof fetch !== "function") { + throw new Error("fetch is not available"); + } + + const response = await fetch(resolvedUrl, { credentials: "same-origin" }); + if (!response.ok) { + throw new Error(`HTTP ${response.status}`); + } + + const blob = await response.blob(); + const mime = blob.type || inferMimeType(resolvedUrl); + const base64 = arrayBufferToBase64(await blob.arrayBuffer()); + return `data:${mime};base64,${base64}`; + } catch { + throw new PolySceneSnapshotError( + "ASSET_INLINE_FAILED", + `exportPolySceneSnapshot: could not inline asset "${resolvedUrl}".`, + resolvedUrl, + ); + } + })(); + + ctx.cache.set(resolvedUrl, next); + return next; +} + +async function inlineCssUrls(cssText: string, ctx: InlineContext): Promise { + const urlPattern = /url\(\s*(?:(["'])(.*?)\1|([^)]*?))\s*\)/gi; + let output = ""; + let lastIndex = 0; + let match: RegExpExecArray | null; + + while ((match = urlPattern.exec(cssText)) !== null) { + output += cssText.slice(lastIndex, match.index); + const rawUrl = (match[2] ?? match[3] ?? "").trim(); + const inlinedUrl = await inlineAssetUrl(rawUrl, ctx); + output += isInlineOrLocalReference(rawUrl) ? match[0] : `url("${inlinedUrl}")`; + lastIndex = urlPattern.lastIndex; + } + + return output + cssText.slice(lastIndex); +} + +async function inlineCloneStyleUrls(root: Element, ctx: InlineContext): Promise { + for (const el of allElements(root)) { + const style = el.getAttribute("style"); + if (style) { + el.setAttribute("style", await inlineCssUrls(style, ctx)); + } + } + + for (const styleEl of Array.from(root.querySelectorAll("style"))) { + styleEl.textContent = await inlineCssUrls(styleEl.textContent ?? "", ctx); + } +} + +function backgroundLonghands(style: CSSStyleDeclaration): { + position: string; + size: string; + repeat: string; +} { + return { + position: style.getPropertyValue("background-position").trim(), + size: style.getPropertyValue("background-size").trim(), + repeat: style.getPropertyValue("background-repeat").trim(), + }; +} + +function restoreBackgroundLonghands( + style: CSSStyleDeclaration, + values: ReturnType, +): void { + if (values.position) style.setProperty("background-position", values.position); + if (values.size) style.setProperty("background-size", values.size); + if (values.repeat) style.setProperty("background-repeat", values.repeat); +} + +async function compactSharedInlineAssets(root: Element, ctx: InlineContext): Promise { + const rules: SnapshotAssetRule[] = []; + const assetIds = new Map(); + const assetDataUrls = new Map(); + + const assetIdFor = async (rawUrl: string): Promise => { + const key = inlineAssetKey(rawUrl, ctx); + const existing = assetIds.get(key); + if (existing) return existing; + const id = `a${assetIds.size}`; + assetIds.set(key, id); + assetDataUrls.set(key, await inlineAssetUrl(rawUrl, ctx)); + return id; + }; + + const dataUrlForId = (id: string): string => { + for (const [key, candidateId] of assetIds) { + if (candidateId === id) return assetDataUrls.get(key) ?? ""; + } + return ""; + }; + + for (const el of allElements(root)) { + const style = inlineStyleFor(el); + if (!style || el.tagName.toLowerCase() !== "s") continue; + + const atlasUrl = extractCssUrl(style.getPropertyValue("--polycss-atlas-url")); + if (atlasUrl && !atlasUrl.startsWith("#")) { + const id = await assetIdFor(atlasUrl); + const attr = "data-polycss-snapshot-atlas"; + el.setAttribute(attr, id); + style.removeProperty("--polycss-atlas-url"); + rules.push({ + attr, + value: id, + property: "--polycss-atlas-url", + dataUrl: dataUrlForId(id), + }); + } + + const backgroundUrl = extractCssUrl(style.getPropertyValue("background-image")); + if (!backgroundUrl || backgroundUrl.startsWith("#")) continue; + const id = await assetIdFor(backgroundUrl); + const attr = "data-polycss-snapshot-bg"; + const longhands = backgroundLonghands(style); + el.setAttribute(attr, id); + style.removeProperty("background"); + style.removeProperty("background-image"); + restoreBackgroundLonghands(style, longhands); + rules.push({ + attr, + value: id, + property: "background-image", + dataUrl: dataUrlForId(id), + }); + cleanupEmptyStyleAttr(el); + } + + const seenRules = new Set(); + return rules + .filter((rule) => { + const key = `${rule.attr}|${rule.value}|${rule.property}`; + if (seenRules.has(key)) return false; + seenRules.add(key); + return true; + }) + .map((rule) => ( + `.polycss-scene [${rule.attr}="${rule.value}"] {\n` + + ` ${rule.property}: url("${escapeCssString(rule.dataUrl)}");\n` + + "}" + )) + .join("\n\n"); +} + +function escapeHtmlText(text: string): string { + return text + .replace(/&/g, "&") + .replace(//g, ">"); +} + +function escapeStyleText(cssText: string): string { + return cssText.replace(/<\/style/gi, "<\\/style"); +} + +function snapshotBaseUrl(doc: Document): string { + return doc.baseURI || doc.URL || doc.defaultView?.location.href || "http://localhost/"; +} + +/** + * Serialize the current rendered PolyCSS DOM into a standalone HTML document. + * The snapshot is intentionally static: it preserves the scene's current DOM, + * inlines CSS image assets, and emits no scripts or PolyCSS runtime imports. + */ +export async function exportPolySceneSnapshot(target: Element): Promise { + if (!isElement(target)) { + throw new PolySceneSnapshotError( + "INVALID_TARGET", + "exportPolySceneSnapshot: target must be an Element.", + ); + } + + const root = findPolySnapshotRoot(target); + if (!root) { + throw new PolySceneSnapshotError( + "SCENE_NOT_FOUND", + "exportPolySceneSnapshot: target is not inside and does not contain a rendered PolyCSS scene.", + ); + } + + const doc = target.ownerDocument; + const ctx: InlineContext = { + baseUrl: snapshotBaseUrl(doc), + cache: new Map(), + }; + const clone = root.cloneNode(true) as Element; + const features = collectSnapshotCssFeatures(clone); + + inlineSnapshotStaticStyleHints(root, clone, features); + sanitizeClone(clone); + const assetCss = await compactSharedInlineAssets(clone, ctx); + await inlineCloneStyleUrls(clone, ctx); + + const baseCss = await inlineCssUrls( + [buildPolySnapshotCss(features), assetCss].filter(Boolean).join("\n\n"), + ctx, + ); + const title = doc.title ? `${escapeHtmlText(doc.title)}` : ""; + + return [ + "", + "", + "", + '', + title, + ``, + "", + "", + clone.outerHTML, + "", + "", + ].filter(Boolean).join(""); +} diff --git a/website/src/content/docs/api/headless.mdx b/website/src/content/docs/api/headless.mdx index 71779d3e..fd22f6a4 100644 --- a/website/src/content/docs/api/headless.mdx +++ b/website/src/content/docs/api/headless.mdx @@ -97,6 +97,20 @@ dispose(); // always call on cleanup --- +## `exportPolySceneSnapshot(target)` + +Serializes an already-rendered PolyCSS scene to a standalone HTML document string. The function is exported by `@layoutit/polycss`. Pass the camera wrapper, scene element, host, or any descendant of the rendered scene. It can snapshot scenes rendered by the React and Vue packages because it operates on the final DOM. + +```ts +import { exportPolySceneSnapshot } from "@layoutit/polycss"; + +const html = await exportPolySceneSnapshot(scene.host); +``` + +The snapshot clones the current rendered `.polycss-camera` / `.polycss-scene` DOM, injects only the PolyCSS CSS needed by that snapshot, inlines CSS `url(...)` image assets as `data:image/...;base64,...`, strips scripts and inline event handlers, and emits no PolyCSS runtime import. If an asset cannot be inlined, it throws `PolySceneSnapshotError` with `code: "ASSET_INLINE_FAILED"` and the failing `url`. + +--- + ## `normalizePolygons(polygons)` Validates a polygon array. Drops degenerate polygons (collinear, zero-area), auto-triangulates non-coplanar N-gons via fan decomposition, and strips UV arrays that don't match `vertices.length`. Returns the validated polygons plus a list of human-readable warnings describing every change made. From 30f6affd2783a5ac946aef74875753a415f5da15 Mon Sep 17 00:00:00 2001 From: agustin-littlehat Date: Thu, 28 May 2026 10:26:31 -0300 Subject: [PATCH 6/7] feat(website): add CodePen snapshot export --- .../GalleryWorkbench/GalleryWorkbench.tsx | 72 ++++++++++++++++++- .../GalleryWorkbench/gallery-workbench.css | 60 ++++++++++++++++ 2 files changed, 131 insertions(+), 1 deletion(-) diff --git a/website/src/components/GalleryWorkbench/GalleryWorkbench.tsx b/website/src/components/GalleryWorkbench/GalleryWorkbench.tsx index 6002a8fa..d9a61a1d 100644 --- a/website/src/components/GalleryWorkbench/GalleryWorkbench.tsx +++ b/website/src/components/GalleryWorkbench/GalleryWorkbench.tsx @@ -7,7 +7,7 @@ import type { import type { PolyMeshHandle as VanillaPolyMeshHandle, } from "@layoutit/polycss"; -import { optimizeAnimatedMeshPolygons, parsePureColor } from "@layoutit/polycss"; +import { exportPolySceneSnapshot, optimizeAnimatedMeshPolygons, parsePureColor } from "@layoutit/polycss"; import type { InspectorColorGroup, InspectorMesh } from "../Inspector"; import { VanillaScene } from "../VanillaScene"; import { ReactScene } from "../ReactScene"; @@ -604,6 +604,39 @@ function resolveInitialPreset(): PresetModel { return (id ? PRESETS.find((p) => p.id === id) : null) ?? randomPreset(); } +function codePenPayload(snapshotHtml: string, title: string): string { + const parsed = new DOMParser().parseFromString(snapshotHtml, "text/html"); + const css = Array.from(parsed.querySelectorAll("style")) + .map((style) => style.textContent ?? "") + .filter(Boolean) + .join("\n\n"); + const html = parsed.body.innerHTML.trim() || snapshotHtml; + return JSON.stringify({ + title, + html, + css, + editors: "100", + layout: "left", + }); +} + +function openCodePen(html: string, title: string, target: string): void { + const form = document.createElement("form"); + const input = document.createElement("input"); + form.action = "https://codepen.io/pen/define"; + form.method = "POST"; + form.target = target; + form.setAttribute("rel", "noopener noreferrer"); + form.style.display = "none"; + input.type = "hidden"; + input.name = "data"; + input.value = codePenPayload(html, title); + form.appendChild(input); + document.body.appendChild(form); + form.submit(); + form.remove(); +} + export default function GalleryWorkbench() { const [initialPreset] = useState(resolveInitialPreset); const [sceneOptions, setSceneOptions] = useState(() => sceneDefaultsFor(initialPreset)); @@ -618,6 +651,8 @@ export default function GalleryWorkbench() { const [modelSearch, setModelSearch] = useState(""); const [openModelCategory, setOpenModelCategory] = useState(null); const [mobilePanel, setMobilePanel] = useState(null); + const [codePenState, setCodePenState] = useState<"idle" | "exporting">("idle"); + const [codePenError, setCodePenError] = useState(null); const viewportRef = useRef(null); const autoZoomPresetRef = useRef(null); const autoAmbientPresetRef = useRef(null); @@ -952,6 +987,28 @@ export default function GalleryWorkbench() { setMobilePanel(null); }, [resetToPreset]); + const handleOpenCodePen = useCallback(async () => { + const viewport = viewportRef.current; + if (!viewport || codePenState === "exporting") return; + setCodePenState("exporting"); + setCodePenError(null); + try { + const html = await exportPolySceneSnapshot(viewport); + openCodePen( + html, + `PolyCSS Gallery - ${loaded?.label ?? selectedPreset.label}`, + "_blank", + ); + } catch (error) { + const message = error instanceof Error + ? error.message + : "Could not create a CodePen snapshot."; + setCodePenError(message); + } finally { + setCodePenState("idle"); + } + }, [codePenState, loaded?.label, selectedPreset.label]); + useEffect(() => { if (!mobilePanel) return; const handleKeyDown = (event: KeyboardEvent) => { @@ -1223,6 +1280,19 @@ export default function GalleryWorkbench() { {loadError}
) : null} +
+ + {codePenError ? ( +
{codePenError}
+ ) : null} +
diff --git a/website/src/components/GalleryWorkbench/gallery-workbench.css b/website/src/components/GalleryWorkbench/gallery-workbench.css index 4a7c767d..952d9432 100644 --- a/website/src/components/GalleryWorkbench/gallery-workbench.css +++ b/website/src/components/GalleryWorkbench/gallery-workbench.css @@ -404,6 +404,62 @@ overflow-wrap: anywhere; } +.dn-codepen { + position: absolute; + left: var(--overlay-left); + bottom: var(--overlay-bottom); + z-index: 24; + display: grid; + justify-items: start; + gap: 6px; + pointer-events: none; +} + +.dn-codepen__button { + pointer-events: auto; + min-height: 34px; + max-width: min(190px, calc(100vw - 32px)); + border: 1px solid rgba(125, 211, 252, 0.24); + border-radius: 8px; + padding: 0 12px; + background: rgba(13, 19, 28, 0.9); + color: #e0f2fe; + font: 600 12px/1 Inter, system-ui, sans-serif; + cursor: pointer; + box-shadow: 0 14px 36px rgba(0, 0, 0, 0.34); + backdrop-filter: blur(6px); +} + +.dn-codepen__button:hover:not(:disabled), +.dn-codepen__button:focus-visible { + border-color: rgba(125, 211, 252, 0.5); + background: rgba(15, 28, 43, 0.96); +} + +.dn-codepen__button:focus-visible { + outline: 2px solid rgba(56, 189, 248, 0.7); + outline-offset: 2px; +} + +.dn-codepen__button:disabled { + cursor: wait; + opacity: 0.72; +} + +.dn-codepen__error { + pointer-events: none; + width: min(320px, calc(100vw - 32px)); + padding: 8px 10px; + border-radius: 8px; + border: 1px solid rgba(248, 113, 113, 0.36); + background: rgba(24, 17, 21, 0.94); + color: #fecaca; + font-size: 12px; + line-height: 1.35; + overflow-wrap: anywhere; + box-shadow: 0 14px 36px rgba(0, 0, 0, 0.34); +} + .dn-light-helper, .dn-light-helper * { cursor: grab; @@ -1396,6 +1452,10 @@ padding: 0 4px; } + .dn-root--gallery .dn-codepen { + bottom: calc(var(--overlay-bottom) + var(--mobile-tabs-height) + 8px); + } + .dn-mobile-tabs { position: absolute; right: var(--overlay-right); From 808ba2242f3d130803a61e0fc8b1825c5b793c66 Mon Sep 17 00:00:00 2001 From: agustin-littlehat Date: Thu, 28 May 2026 10:31:35 -0300 Subject: [PATCH 7/7] fix(renderer): keep perspective independent of css zoom --- packages/polycss/src/api/createPolyScene.test.ts | 5 +++-- packages/polycss/src/api/createPolyScene.ts | 12 ++++-------- 2 files changed, 7 insertions(+), 10 deletions(-) diff --git a/packages/polycss/src/api/createPolyScene.test.ts b/packages/polycss/src/api/createPolyScene.test.ts index 0c09c12c..a9b9b65b 100644 --- a/packages/polycss/src/api/createPolyScene.test.ts +++ b/packages/polycss/src/api/createPolyScene.test.ts @@ -398,8 +398,9 @@ describe("createPolyScene", () => { const cameraEl = host.querySelector(".polycss-camera") as HTMLElement; const sceneEl = host.querySelector(".polycss-scene") as HTMLElement; const transform = sceneEl.style.transform; - // Perspective lives on the .polycss-camera wrapper, not on .polycss-scene. - expect(cameraEl.style.perspective).toBe("750px"); + // Perspective stays the configured camera depth; CSS zoom only affects + // the scene geometry transform compensation. + expect(cameraEl.style.perspective).toBe("1500px"); expect(sceneEl.style.getPropertyValue("zoom")).toBe("2"); expect(transform).toContain("translateZ(-50px)"); expect(transform).toContain("scale(1)"); diff --git a/packages/polycss/src/api/createPolyScene.ts b/packages/polycss/src/api/createPolyScene.ts index 6a3043a4..ff42247c 100644 --- a/packages/polycss/src/api/createPolyScene.ts +++ b/packages/polycss/src/api/createPolyScene.ts @@ -479,14 +479,10 @@ function effectiveCssZoom(element: HTMLElement): number { return Number.isFinite(zoom) && zoom > 0 ? zoom : 1; } -function scaledCssPixels(value: number, scale: number): number { - return scale === 1 ? value : value * scale; -} - function applyCssZoomCompensation(el: HTMLElement, scale: number): void { // Chromium's CSS zoom can scale layout metrics without scaling the // preserve-3d rasterization path consistently. Neutralize zoom on the scene - // root, then fold the same scale into the matrix/perspective explicitly. + // root, then fold the same scale into scene geometry transforms explicitly. if (Math.abs(scale - 1) < 1e-6) { el.style.removeProperty("zoom"); } else { @@ -764,12 +760,12 @@ export function createPolyScene( // orthographic but routes Chrome through the normal compositor path. const perspStyle = camera.perspectiveStyle; if (perspStyle === "none") { - el.style.perspective = `${scaledCssPixels(1000000, layoutScale)}px`; + el.style.perspective = "1000000px"; } else { - // perspStyle is e.g. "32000px" — strip "px", scale, re-apply. + // perspStyle is e.g. "32000px" — strip "px", normalize, re-apply. const px = parseFloat(perspStyle); if (Number.isFinite(px)) { - el.style.perspective = `${scaledCssPixels(px, layoutScale)}px`; + el.style.perspective = `${px}px`; } } }