From 63194898ed0b604e8e5e0256919b409bb0e34046 Mon Sep 17 00:00:00 2001 From: agustin-littlehat Date: Sat, 16 May 2026 21:37:33 -0300 Subject: [PATCH 1/4] fix: normalize atlas slice primitive --- AGENTS.md | 2 +- packages/polycss/src/render/polyDOM.test.ts | 31 +++++++++++++++--- packages/polycss/src/render/textureAtlas.ts | 35 ++++++++++++++++----- packages/polycss/src/styles/styles.ts | 4 +-- packages/react/src/scene/textureAtlas.tsx | 19 ++++++++--- packages/react/src/shapes/Poly.tsx | 7 +++-- packages/react/src/styles/styles.ts | 4 +-- packages/vue/src/scene/textureAtlas.ts | 19 ++++++++--- packages/vue/src/shapes/Poly.ts | 7 +++-- packages/vue/src/styles/styles.ts | 4 +-- 10 files changed, 96 insertions(+), 36 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 832184ed..5b8243d5 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -28,7 +28,7 @@ Public API is **mirrored** across React and Vue. Adding a hook on one side witho |---|---|---|---|---| | `` | **Quads** | Axis-aligned rectangle, or untextured convex quad when the homography passes stability guards | `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 | None | | `` | **Border-shape clipped solid** | Untextured non-rect on browsers with CSS `border-shape` (Chromium + `pointer:fine` + `hover:hover`) | `border-color: currentColor` on a fixed 64px 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 a canonical 1px primitive; atlas position/size are normalized to the slice, scale lives in `matrix3d`, and shared textured edges get low-alpha atlas pixels repaired during atlas generation | Bounding-rect area | +| `` | **Atlas slice** | Textured polygons, or untextured non-rect on browsers without `border-shape` | `background-image` slice of packed bitmap on a fixed 128px primitive; atlas position/size and `matrix3d` scale are normalized to the slice, and shared textured edges get low-alpha atlas pixels repaired during atlas generation | Bounding-rect area | | `` | **Stable solid triangle** | Opt-in for triangles via `renderPolygonsWithStableTriangles` | CSS border-color triangle trick with a fixed canonical 64px border triangle; tiny solid bleed is folded into `matrix3d` | None | | `` | **Cast shadow leaf** | Per casting polygon when `castShadow: true` and dynamic 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 ``, but transform composes `var(--shadow-proj)` to project the polygon onto the ground plane along the CSS-space light direction | None | diff --git a/packages/polycss/src/render/polyDOM.test.ts b/packages/polycss/src/render/polyDOM.test.ts index 690cee1d..529047e1 100644 --- a/packages/polycss/src/render/polyDOM.test.ts +++ b/packages/polycss/src/render/polyDOM.test.ts @@ -62,6 +62,7 @@ const UNSTABLE_PROJECTIVE_QUAD: Polygon = { }; const QUAD_CANONICAL_SIZE = 64; +const ATLAS_SLICE_CANONICAL_SIZE = 128; const OFFAXIS_TRIANGLE: Polygon = { vertices: [ @@ -182,6 +183,26 @@ function computeExpectedQuadMatrix( ]; } +function computeExpectedAtlasMatrix( + vertices: [number, number, number][], + tileSize = 50, + elev = tileSize, +): number[] { + const { matrix, canvasW, canvasH } = computeExpectedPlan(vertices, tileSize, elev); + return [ + matrix[0] * canvasW / ATLAS_SLICE_CANONICAL_SIZE, + matrix[1] * canvasW / ATLAS_SLICE_CANONICAL_SIZE, + matrix[2] * canvasW / ATLAS_SLICE_CANONICAL_SIZE, + 0, + matrix[4] * canvasH / ATLAS_SLICE_CANONICAL_SIZE, + matrix[5] * canvasH / ATLAS_SLICE_CANONICAL_SIZE, + matrix[6] * canvasH / ATLAS_SLICE_CANONICAL_SIZE, + 0, + matrix[8], matrix[9], matrix[10], 0, + matrix[12], matrix[13], matrix[14], 1, + ]; +} + function expectColumnDirection(actual: number[], expected: number[], start: 0 | 4): void { const actualLen = Math.hypot(actual[start], actual[start + 1], actual[start + 2]); const expectedLen = Math.hypot(expected[start], expected[start + 1], expected[start + 2]); @@ -632,7 +653,7 @@ describe("renderPolygonsWithTextureAtlas", () => { const result = renderPolygonsWithTextureAtlas([obliqueTriangle], { tileSize: 1 }); const element = result.rendered[0].element; const matrix = extractMatrix(element); - const expected = roundedMatrix(computeExpectedMatrix(obliqueTriangle.vertices as [number, number, number][], 1, 1)); + const expected = roundedMatrix(computeExpectedAtlasMatrix(obliqueTriangle.vertices as [number, number, number][], 1, 1), 6); expect(element.style.width).toBe(""); expect(element.style.height).toBe(""); @@ -699,7 +720,7 @@ describe("renderPolygonsWithTextureAtlas", () => { const isolated = renderPolygonsWithTextureAtlas([bladeFace], { tileSize: 1 }); const shared = renderPolygonsWithTextureAtlas([bladeFace, bevelFace], { tileSize: 1 }); const sharedMatrix = extractMatrix(shared.rendered[0].element); - const sharedEdgeMatrix = roundedMatrix(computeExpectedMatrix(bladeFace.vertices as [number, number, number][], 1, 1)); + const sharedEdgeMatrix = roundedMatrix(computeExpectedAtlasMatrix(bladeFace.vertices as [number, number, number][], 1, 1), 6); const isolatedMatrix = extractMatrix(isolated.rendered[0].element); expectColumnDirection(isolatedMatrix, sharedEdgeMatrix, 0); @@ -739,7 +760,7 @@ describe("renderPolygonsWithTextureAtlas", () => { expect(repaired.rendered[0].element.style.height).toBe(""); expectMatrixClose( extractMatrix(repaired.rendered[0].element), - roundedMatrix(computeExpectedMatrix(left.vertices as [number, number, number][], 1, 1)), + roundedMatrix(computeExpectedAtlasMatrix(left.vertices as [number, number, number][], 1, 1), 6), ); expect(repaired.rendered[0].plan?.textureEdgeRepair).toBe(true); @@ -777,11 +798,11 @@ describe("renderPolygonsWithTextureAtlas", () => { expect(repaired.rendered[1].element.style.height).toBe(""); expectMatrixClose( extractMatrix(repaired.rendered[0].element), - roundedMatrix(computeExpectedMatrix(floor.vertices as [number, number, number][], 1, 1)), + roundedMatrix(computeExpectedAtlasMatrix(floor.vertices as [number, number, number][], 1, 1), 6), ); expectMatrixClose( extractMatrix(repaired.rendered[1].element), - roundedMatrix(computeExpectedMatrix(wall.vertices as [number, number, number][], 1, 1)), + roundedMatrix(computeExpectedAtlasMatrix(wall.vertices as [number, number, number][], 1, 1), 6), ); expect(repaired.rendered[0].plan?.textureEdgeRepair).toBe(true); diff --git a/packages/polycss/src/render/textureAtlas.ts b/packages/polycss/src/render/textureAtlas.ts index 66a38d02..55678dd7 100644 --- a/packages/polycss/src/render/textureAtlas.ts +++ b/packages/polycss/src/render/textureAtlas.ts @@ -240,6 +240,7 @@ const BORDER_SHAPE_POINT_EPS = 1e-7; const BORDER_SHAPE_CANONICAL_SIZE = 64; const BORDER_SHAPE_BLEED = 0.9; const QUAD_CANONICAL_SIZE = 64; +const ATLAS_SLICE_CANONICAL_SIZE = 128; const SOLID_TRIANGLE_CANONICAL_SIZE = 64; const PROJECTIVE_QUAD_DENOM_EPS = 0.05; const PROJECTIVE_QUAD_MAX_WEIGHT_RATIO = Number.POSITIVE_INFINITY; @@ -1513,11 +1514,17 @@ function computeTextureAtlasPlan( tx, ty, tz, 1, ]); const canonicalMatrix = formatMatrix3dValues([ - xAxis[0] * canvasW, xAxis[1] * canvasW, xAxis[2] * canvasW, 0, - yAxis[0] * canvasH, yAxis[1] * canvasH, yAxis[2] * canvasH, 0, + xAxis[0] * canvasW / ATLAS_SLICE_CANONICAL_SIZE, + xAxis[1] * canvasW / ATLAS_SLICE_CANONICAL_SIZE, + xAxis[2] * canvasW / ATLAS_SLICE_CANONICAL_SIZE, + 0, + yAxis[0] * canvasH / ATLAS_SLICE_CANONICAL_SIZE, + yAxis[1] * canvasH / ATLAS_SLICE_CANONICAL_SIZE, + yAxis[2] * canvasH / ATLAS_SLICE_CANONICAL_SIZE, + 0, normal[0], normal[1], normal[2], 0, tx, ty, tz, 1, - ]); + ], 6); const projectiveMatrix = !texture && vertices.length === 4 ? computeProjectiveQuadMatrix( screenPts, @@ -2231,8 +2238,10 @@ function applyAtlasBackground( const url = `url(${page.url})`; const width = entry.canvasW || 1; const height = entry.canvasH || 1; - const pos = `${formatCssLength(-entry.x / width)} ${formatCssLength(-entry.y / height)}`; - const size = `${formatCssLength(page.width / width)} ${formatCssLength(page.height / height)}`; + const scaleX = ATLAS_SLICE_CANONICAL_SIZE / width; + const scaleY = ATLAS_SLICE_CANONICAL_SIZE / height; + const pos = `${formatCssLength(-entry.x * scaleX)} ${formatCssLength(-entry.y * scaleY)}`; + const size = `${formatCssLength(page.width * scaleX)} ${formatCssLength(page.height * scaleY)}`; if (textureLighting === "dynamic") { setInlineStyleProperty(el, "background-image", url); setInlineStyleProperty(el, "background-position", pos); @@ -2412,8 +2421,14 @@ function stableMatrixFromPlan( return { normal, matrix: formatMatrix3dValues([ - xAxis[0], xAxis[1], xAxis[2], 0, - yAxis[0], yAxis[1], yAxis[2], 0, + xAxis[0] * (source.canvasW || 1) / ATLAS_SLICE_CANONICAL_SIZE, + xAxis[1] * (source.canvasW || 1) / ATLAS_SLICE_CANONICAL_SIZE, + xAxis[2] * (source.canvasW || 1) / ATLAS_SLICE_CANONICAL_SIZE, + 0, + yAxis[0] * (source.canvasH || 1) / ATLAS_SLICE_CANONICAL_SIZE, + yAxis[1] * (source.canvasH || 1) / ATLAS_SLICE_CANONICAL_SIZE, + yAxis[2] * (source.canvasH || 1) / ATLAS_SLICE_CANONICAL_SIZE, + 0, normal[0], normal[1], normal[2], 0, tx, ty, tz, 1, ]), @@ -2779,7 +2794,11 @@ function createAtlasElement( applyPlanElementBase(el, entry); const width = entry.canvasW || 1; const height = entry.canvasH || 1; - setInlineStyleProperty(el, "background-position", `${formatCssLength(-entry.x / width)} ${formatCssLength(-entry.y / height)}`); + setInlineStyleProperty( + el, + "background-position", + `${formatCssLength(-entry.x * ATLAS_SLICE_CANONICAL_SIZE / width)} ${formatCssLength(-entry.y * ATLAS_SLICE_CANONICAL_SIZE / height)}`, + ); setInlineStyleProperty(el, "opacity", "0"); if (textureLighting === "dynamic") applyDynamicNormalVars(el, entry); diff --git a/packages/polycss/src/styles/styles.ts b/packages/polycss/src/styles/styles.ts index 1904fe31..3f7ee59f 100644 --- a/packages/polycss/src/styles/styles.ts +++ b/packages/polycss/src/styles/styles.ts @@ -89,8 +89,8 @@ const CORE_BASE_STYLES = ` } .polycss-scene s { - width: 1px; - height: 1px; + width: 128px; + height: 128px; } .polycss-scene u { diff --git a/packages/react/src/scene/textureAtlas.tsx b/packages/react/src/scene/textureAtlas.tsx index caadb6d3..6e6fd3eb 100644 --- a/packages/react/src/scene/textureAtlas.tsx +++ b/packages/react/src/scene/textureAtlas.tsx @@ -44,6 +44,7 @@ const BORDER_SHAPE_CENTER_PERCENT = 50; const BORDER_SHAPE_POINT_EPS = 1e-7; const BORDER_SHAPE_CANONICAL_SIZE = 64; const QUAD_CANONICAL_SIZE = 64; +const ATLAS_SLICE_CANONICAL_SIZE = 128; const SOLID_TRIANGLE_CANONICAL_SIZE = 64; const PROJECTIVE_QUAD_DENOM_EPS = 0.05; const PROJECTIVE_QUAD_MAX_WEIGHT_RATIO = 4; @@ -1603,8 +1604,14 @@ export function computeTextureAtlasPlan( tx, ty, tz, 1, ].join(","); const canonicalMatrix = [ - xAxis[0] * canvasW, xAxis[1] * canvasW, xAxis[2] * canvasW, 0, - yAxis[0] * canvasH, yAxis[1] * canvasH, yAxis[2] * canvasH, 0, + xAxis[0] * canvasW / ATLAS_SLICE_CANONICAL_SIZE, + xAxis[1] * canvasW / ATLAS_SLICE_CANONICAL_SIZE, + xAxis[2] * canvasW / ATLAS_SLICE_CANONICAL_SIZE, + 0, + yAxis[0] * canvasH / ATLAS_SLICE_CANONICAL_SIZE, + yAxis[1] * canvasH / ATLAS_SLICE_CANONICAL_SIZE, + yAxis[2] * canvasH / ATLAS_SLICE_CANONICAL_SIZE, + 0, nx, ny, nz, 0, tx, ty, tz, 1, ].join(","); @@ -2143,11 +2150,13 @@ export function TextureAtlasPoly({ const dynamic = textureLighting === "dynamic"; const atlasWidth = entry.canvasW || 1; const atlasHeight = entry.canvasH || 1; + const atlasScaleX = ATLAS_SLICE_CANONICAL_SIZE / atlasWidth; + const atlasScaleY = ATLAS_SLICE_CANONICAL_SIZE / atlasHeight; const atlasPosition = page - ? `${formatCssLength(-entry.x / atlasWidth)} ${formatCssLength(-entry.y / atlasHeight)}` + ? `${formatCssLength(-entry.x * atlasScaleX)} ${formatCssLength(-entry.y * atlasScaleY)}` : undefined; const atlasSize = page - ? `${formatCssLength(page.width / atlasWidth)} ${formatCssLength(page.height / atlasHeight)}` + ? `${formatCssLength(page.width * atlasScaleX)} ${formatCssLength(page.height * atlasScaleY)}` : undefined; // Dynamic mode: emit ONLY the per-polygon surface normal vars + the @@ -2163,7 +2172,7 @@ export function TextureAtlasPoly({ : undefined; const style: CSSProperties = { - transform: formatMatrix3d(entry.canonicalMatrix), + transform: formatMatrix3d(entry.canonicalMatrix, 6), background, backgroundImage: dynamic && page?.url ? `url(${page.url})` : undefined, backgroundPosition: dynamic ? atlasPosition : undefined, diff --git a/packages/react/src/shapes/Poly.tsx b/packages/react/src/shapes/Poly.tsx index b84ebad2..ecd9f6dc 100644 --- a/packages/react/src/shapes/Poly.tsx +++ b/packages/react/src/shapes/Poly.tsx @@ -18,6 +18,7 @@ import { // ── Material / direct render path ──────────────────────────────────────────── const DIRECT_TEXTURE_CSS_DECIMALS = 4; +const DIRECT_TEXTURE_CANONICAL_SIZE = 128; function formatCssLength(value: number, decimals = DIRECT_TEXTURE_CSS_DECIMALS): string { const next = value.toFixed(decimals).replace(/\.?0+$/, ""); @@ -96,8 +97,8 @@ function MaterialDirectPoly({ const style: CSSProperties = { transform: `matrix3d(${plan.canonicalMatrix})`, backgroundImage: `url(${material.texture})`, - backgroundSize: `${formatCssLength(sourceW)} ${formatCssLength(sourceH)}`, - backgroundPosition: `${formatCssLength(-offsetX)} ${formatCssLength(-offsetY)}`, + backgroundSize: `${formatCssLength(sourceW * DIRECT_TEXTURE_CANONICAL_SIZE)} ${formatCssLength(sourceH * DIRECT_TEXTURE_CANONICAL_SIZE)}`, + backgroundPosition: `${formatCssLength(-offsetX * DIRECT_TEXTURE_CANONICAL_SIZE)} ${formatCssLength(-offsetY * DIRECT_TEXTURE_CANONICAL_SIZE)}`, pointerEvents: pointerEvents === "none" ? "none" : undefined, ...styleProp, }; @@ -110,7 +111,7 @@ function MaterialDirectPoly({ const elementClassName = className?.trim() || undefined; return ( - Date: Sat, 16 May 2026 21:50:54 -0300 Subject: [PATCH 2/4] fix: bleed solid atlas edges --- AGENTS.md | 2 +- packages/polycss/src/render/textureAtlas.ts | 28 ++++++++---- packages/react/src/scene/textureAtlas.tsx | 47 +++++++++++++++------ packages/vue/src/scene/textureAtlas.ts | 47 +++++++++++++++------ 4 files changed, 89 insertions(+), 35 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 5b8243d5..b1100b25 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -28,7 +28,7 @@ Public API is **mirrored** across React and Vue. Adding a hook on one side witho |---|---|---|---|---| | `` | **Quads** | Axis-aligned rectangle, or untextured convex quad when the homography passes stability guards | `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 | None | | `` | **Border-shape clipped solid** | Untextured non-rect on browsers with CSS `border-shape` (Chromium + `pointer:fine` + `hover:hover`) | `border-color: currentColor` on a fixed 64px 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 a fixed 128px primitive; atlas position/size and `matrix3d` scale are normalized to the slice, and shared textured edges get low-alpha atlas pixels repaired during atlas generation | Bounding-rect area | +| `` | **Atlas slice** | Textured polygons, or untextured non-rect on browsers without `border-shape` | `background-image` slice of packed bitmap on a fixed 128px primitive; 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** | Opt-in for triangles via `renderPolygonsWithStableTriangles` | CSS border-color triangle trick with a fixed canonical 64px border triangle; tiny solid bleed is folded into `matrix3d` | None | | `` | **Cast shadow leaf** | Per casting polygon when `castShadow: true` and dynamic 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 ``, but transform composes `var(--shadow-proj)` to project the polygon onto the ground plane along the CSS-space light direction | None | diff --git a/packages/polycss/src/render/textureAtlas.ts b/packages/polycss/src/render/textureAtlas.ts index 55678dd7..aa55d9f9 100644 --- a/packages/polycss/src/render/textureAtlas.ts +++ b/packages/polycss/src/render/textureAtlas.ts @@ -232,6 +232,7 @@ const TEXTURE_EDGE_REPAIR_ALPHA_MIN = 1; const TEXTURE_EDGE_REPAIR_SOURCE_ALPHA_MIN = 250; const TEXTURE_EDGE_REPAIR_RADIUS = 1.5; const SOLID_TRIANGLE_BLEED = 0.75; +const SOLID_ATLAS_EDGE_BLEED = 0.9; const DEFAULT_MATRIX_DECIMALS = 3; const DEFAULT_BORDER_SHAPE_DECIMALS = 2; const DEFAULT_ATLAS_CSS_DECIMALS = 4; @@ -1876,17 +1877,30 @@ function paintSolidAtlasEntry( textureLighting: PolyTextureLightingMode, atlasScale: number, ): void { - setCssTransform(ctx, atlasScale); - ctx.beginPath(); - tracePolygonPath(ctx, entry.x, entry.y, entry.screenPts); - ctx.clip(); - setCssTransform(ctx, atlasScale); // Dynamic mode multiplies the tint at render time via background-blend-mode, // so the atlas keeps the polygon's unshaded base color. - ctx.fillStyle = textureLighting === "dynamic" + const paintColor = textureLighting === "dynamic" ? (entry.polygon.color ?? "#cccccc") : entry.shadedColor; + + ctx.save(); + setCssTransform(ctx, atlasScale); + ctx.beginPath(); + tracePolygonPath(ctx, entry.x, entry.y, entry.screenPts); + ctx.clip(); + ctx.fillStyle = paintColor; ctx.fillRect(entry.x, entry.y, entry.canvasW, entry.canvasH); + ctx.restore(); + + ctx.save(); + setCssTransform(ctx, atlasScale); + ctx.beginPath(); + tracePolygonPath(ctx, entry.x, entry.y, entry.screenPts); + ctx.strokeStyle = paintColor; + ctx.lineWidth = SOLID_ATLAS_EDGE_BLEED * 2; + ctx.lineJoin = "round"; + ctx.stroke(); + ctx.restore(); } function clampSourceCoord(value: number, max: number): number { @@ -2172,9 +2186,7 @@ async function buildAtlasPage( for (const entry of page.entries) { const srcImg = entry.texture ? loaded.get(entry.texture) : null; if (!entry.texture) { - ctx.save(); paintSolidAtlasEntry(ctx, entry, textureLighting, atlasScale); - ctx.restore(); continue; } diff --git a/packages/react/src/scene/textureAtlas.tsx b/packages/react/src/scene/textureAtlas.tsx index 6e6fd3eb..b76254c2 100644 --- a/packages/react/src/scene/textureAtlas.tsx +++ b/packages/react/src/scene/textureAtlas.tsx @@ -51,6 +51,7 @@ const PROJECTIVE_QUAD_MAX_WEIGHT_RATIO = 4; const PROJECTIVE_QUAD_BLEED = 0.6; const BASIS_EPS = 1e-9; const SOLID_TRIANGLE_BLEED = 0.75; +const SOLID_ATLAS_EDGE_BLEED = 0.9; export type TextureQuality = number | "auto"; @@ -1126,6 +1127,38 @@ function drawImageCover( ctx.drawImage(img, x + (width - drawW) / 2, y + (height - drawH) / 2, drawW, drawH); } +function paintSolidAtlasEntry( + ctx: CanvasRenderingContext2D, + entry: PackedTextureAtlasEntry, + textureLighting: PolyTextureLightingMode, + atlasScale: number, +): void { + // Dynamic mode multiplies the tint at render time via background-blend-mode, + // so the atlas keeps the polygon's unshaded base color. + const paintColor = textureLighting === "dynamic" + ? (entry.polygon.color ?? "#cccccc") + : entry.shadedColor; + + ctx.save(); + setCssTransform(ctx, atlasScale); + ctx.beginPath(); + tracePolygonPath(ctx, entry.x, entry.y, entry.screenPts); + ctx.clip(); + ctx.fillStyle = paintColor; + ctx.fillRect(entry.x, entry.y, entry.canvasW, entry.canvasH); + ctx.restore(); + + ctx.save(); + setCssTransform(ctx, atlasScale); + ctx.beginPath(); + tracePolygonPath(ctx, entry.x, entry.y, entry.screenPts); + ctx.strokeStyle = paintColor; + ctx.lineWidth = SOLID_ATLAS_EDGE_BLEED * 2; + ctx.lineJoin = "round"; + ctx.stroke(); + ctx.restore(); +} + function computeUvAffine(points: Vec2[], uvs: Vec2[]): UvAffine | null { if (points.length < 3 || uvs.length < 3) return null; const [p0, p1, p2] = points; @@ -1803,19 +1836,7 @@ async function buildAtlasPage( for (const entry of page.entries) { const srcImg = entry.texture ? loaded.get(entry.texture) : null; if (!entry.texture) { - ctx.save(); - setCssTransform(ctx, atlasScale); - ctx.beginPath(); - tracePolygonPath(ctx, entry.x, entry.y, entry.screenPts); - ctx.clip(); - // Dynamic mode multiplies the tint at render time via - // background-blend-mode, so the atlas keeps the polygon's unshaded - // base color. Baked bakes the JS-computed shadedColor. - ctx.fillStyle = textureLighting === "dynamic" - ? (entry.polygon.color ?? "#cccccc") - : entry.shadedColor; - ctx.fillRect(entry.x, entry.y, entry.canvasW, entry.canvasH); - ctx.restore(); + paintSolidAtlasEntry(ctx, entry, textureLighting, atlasScale); continue; } diff --git a/packages/vue/src/scene/textureAtlas.ts b/packages/vue/src/scene/textureAtlas.ts index d3032ecb..6b4cc8d7 100644 --- a/packages/vue/src/scene/textureAtlas.ts +++ b/packages/vue/src/scene/textureAtlas.ts @@ -51,6 +51,7 @@ const PROJECTIVE_QUAD_MAX_WEIGHT_RATIO = 4; const PROJECTIVE_QUAD_BLEED = 0.6; const BASIS_EPS = 1e-9; const SOLID_TRIANGLE_BLEED = 0.75; +const SOLID_ATLAS_EDGE_BLEED = 0.9; export type TextureQuality = number | "auto"; @@ -1134,6 +1135,38 @@ function drawImageCover( ctx.drawImage(img, x + (width - drawW) / 2, y + (height - drawH) / 2, drawW, drawH); } +function paintSolidAtlasEntry( + ctx: CanvasRenderingContext2D, + entry: PackedTextureAtlasEntry, + textureLighting: PolyTextureLightingMode, + atlasScale: number, +): void { + // Dynamic mode multiplies the tint at render time via background-blend-mode, + // so the atlas keeps the polygon's unshaded base color. + const paintColor = textureLighting === "dynamic" + ? (entry.polygon.color ?? "#cccccc") + : entry.shadedColor; + + ctx.save(); + setCssTransform(ctx, atlasScale); + ctx.beginPath(); + tracePolygonPath(ctx, entry.x, entry.y, entry.screenPts); + ctx.clip(); + ctx.fillStyle = paintColor; + ctx.fillRect(entry.x, entry.y, entry.canvasW, entry.canvasH); + ctx.restore(); + + ctx.save(); + setCssTransform(ctx, atlasScale); + ctx.beginPath(); + tracePolygonPath(ctx, entry.x, entry.y, entry.screenPts); + ctx.strokeStyle = paintColor; + ctx.lineWidth = SOLID_ATLAS_EDGE_BLEED * 2; + ctx.lineJoin = "round"; + ctx.stroke(); + ctx.restore(); +} + function computeUvAffine(points: Vec2[], uvs: Vec2[]): UvAffine | null { if (points.length < 3 || uvs.length < 3) return null; const [p0, p1, p2] = points; @@ -1811,19 +1844,7 @@ async function buildAtlasPage( for (const entry of page.entries) { const srcImg = entry.texture ? loaded.get(entry.texture) : null; if (!entry.texture) { - ctx.save(); - setCssTransform(ctx, atlasScale); - ctx.beginPath(); - tracePolygonPath(ctx, entry.x, entry.y, entry.screenPts); - ctx.clip(); - // Dynamic mode multiplies the tint at render time via - // background-blend-mode, so the atlas keeps the polygon's unshaded - // base color. Baked bakes the JS-computed shadedColor. - ctx.fillStyle = textureLighting === "dynamic" - ? (entry.polygon.color ?? "#cccccc") - : entry.shadedColor; - ctx.fillRect(entry.x, entry.y, entry.canvasW, entry.canvasH); - ctx.restore(); + paintSolidAtlasEntry(ctx, entry, textureLighting, atlasScale); continue; } From 5d1771c416d082e4e928b70b679ff0f4896cf5e6 Mon Sep 17 00:00:00 2001 From: agustin-littlehat Date: Sat, 16 May 2026 21:54:51 -0300 Subject: [PATCH 3/4] fix: keep gallery animation controls selectable --- website/src/components/Dock/Dock.tsx | 19 +++++++++++++++++-- .../GalleryWorkbench/GalleryWorkbench.tsx | 4 +++- .../GalleryWorkbench/helpers/domMetrics.ts | 10 ++++++++++ 3 files changed, 30 insertions(+), 3 deletions(-) diff --git a/website/src/components/Dock/Dock.tsx b/website/src/components/Dock/Dock.tsx index 82f0bfb3..d20ede21 100644 --- a/website/src/components/Dock/Dock.tsx +++ b/website/src/components/Dock/Dock.tsx @@ -8,6 +8,17 @@ import type { GizmoMode, SceneOptionsState, DomMetrics, DragMode, PerspectiveMod type GuiControllerMap = Record; type TextureMode = "disabled" | PolyTextureLightingMode; +function stringRecordEqual( + a: Record | undefined, + b: Record, +): boolean { + if (!a) return false; + const aKeys = Object.keys(a); + const bKeys = Object.keys(b); + if (aKeys.length !== bKeys.length) return false; + return bKeys.every((key) => a[key] === b[key]); +} + function textureModeForScene(sceneOptions: SceneOptionsState): TextureMode { return sceneOptions.solidMaterials ? "disabled" : sceneOptions.textureLighting; } @@ -561,6 +572,7 @@ export function Dock({ ambientColor: ambientColorController, modelState, animationState, + animationOptions, animationFolder: animation, interactionState, cameraState, @@ -622,9 +634,12 @@ export function Dock({ const validAnimation = Object.values(animationOptions).includes(selectedAnimation); const nextAnimation = validAnimation ? selectedAnimation : ""; - setCtrlValue("animation", nextAnimation); const animationController = controllers.animation as { options: (opts: Record) => void } | undefined; - animationController?.options(animationOptions); + if (animationController && !stringRecordEqual(controllers.animationOptions, animationOptions)) { + animationController.options(animationOptions); + controllers.animationOptions = animationOptions; + } + setCtrlValue("animation", nextAnimation); const animationFolder = controllers.animationFolder as { show: (show?: boolean) => void } | undefined; animationFolder?.show(animationClipCount > 0); if (animationController) { diff --git a/website/src/components/GalleryWorkbench/GalleryWorkbench.tsx b/website/src/components/GalleryWorkbench/GalleryWorkbench.tsx index 93c9c02d..f4e6305c 100644 --- a/website/src/components/GalleryWorkbench/GalleryWorkbench.tsx +++ b/website/src/components/GalleryWorkbench/GalleryWorkbench.tsx @@ -35,6 +35,7 @@ import { } from "./presets"; import { EMPTY_METRICS, + domMetricCountsEqual, measureDom, } from "./helpers/domMetrics"; import { @@ -478,7 +479,8 @@ export default function GalleryWorkbench() { let raf = 0; const update = () => { raf = 0; - setMetrics(measureDom(root)); + const next = measureDom(root); + setMetrics((current) => domMetricCountsEqual(current, next) ? current : next); }; const schedule = () => { if (!raf) raf = requestAnimationFrame(update); diff --git a/website/src/components/GalleryWorkbench/helpers/domMetrics.ts b/website/src/components/GalleryWorkbench/helpers/domMetrics.ts index ca1f38c9..34bb0cb2 100644 --- a/website/src/components/GalleryWorkbench/helpers/domMetrics.ts +++ b/website/src/components/GalleryWorkbench/helpers/domMetrics.ts @@ -27,3 +27,13 @@ export function measureDom(root: HTMLElement | null): DomMetrics { irregular: countInScopes("i"), }; } + +export function domMetricCountsEqual(a: DomMetrics, b: DomMetrics): boolean { + return ( + a.nodeCount === b.nodeCount && + a.sprites === b.sprites && + a.rects === b.rects && + a.triangles === b.triangles && + a.irregular === b.irregular + ); +} From 89e7686e87904cd895a7dfbf17c68ba758d09662 Mon Sep 17 00:00:00 2001 From: agustin-littlehat Date: Sat, 16 May 2026 21:57:27 -0300 Subject: [PATCH 4/4] fix: prevent mesh text selection --- packages/polycss/src/styles/styles.ts | 6 ++++++ packages/react/src/styles/styles.test.ts | 1 + packages/react/src/styles/styles.ts | 9 +++++++++ packages/vue/src/styles/styles.test.ts | 1 + packages/vue/src/styles/styles.ts | 9 +++++++++ 5 files changed, 26 insertions(+) diff --git a/packages/polycss/src/styles/styles.ts b/packages/polycss/src/styles/styles.ts index 3f7ee59f..df62a390 100644 --- a/packages/polycss/src/styles/styles.ts +++ b/packages/polycss/src/styles/styles.ts @@ -47,6 +47,8 @@ const CORE_BASE_STYLES = ` position: absolute; transform-style: preserve-3d; transform-origin: var(--origin); + -webkit-user-select: none; + user-select: none; } /* ── Polygon leaf element ───────────────────────────────────────────────── */ @@ -68,6 +70,8 @@ const CORE_BASE_STYLES = ` text-decoration: none; backface-visibility: hidden; background-repeat: no-repeat; + -webkit-user-select: none; + user-select: none; } .polycss-scene b, @@ -128,6 +132,8 @@ const CORE_BASE_STYLES = ` border-color: currentColor; pointer-events: none; will-change: transform; + -webkit-user-select: none; + user-select: none; } .polycss-scene q::before, .polycss-scene q::after { diff --git a/packages/react/src/styles/styles.test.ts b/packages/react/src/styles/styles.test.ts index 598c4bf7..3dfff2f4 100644 --- a/packages/react/src/styles/styles.test.ts +++ b/packages/react/src/styles/styles.test.ts @@ -48,6 +48,7 @@ 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("user-select: none"); 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 d82e03b2..7f377e11 100644 --- a/packages/react/src/styles/styles.ts +++ b/packages/react/src/styles/styles.ts @@ -48,6 +48,11 @@ const CORE_BASE_STYLES = ` position: absolute; } +.polycss-mesh { + -webkit-user-select: none; + user-select: none; +} + /* ── Polygon leaf element ───────────────────────────────────────────────── */ /* @@ -73,6 +78,8 @@ const CORE_BASE_STYLES = ` text-decoration: none; backface-visibility: hidden; background-repeat: no-repeat; + -webkit-user-select: none; + user-select: none; } .polycss-scene b, @@ -249,6 +256,8 @@ const CORE_BASE_STYLES = ` border-color: currentColor; pointer-events: none; will-change: transform; + -webkit-user-select: none; + user-select: none; } .polycss-scene q::before, .polycss-scene q::after { diff --git a/packages/vue/src/styles/styles.test.ts b/packages/vue/src/styles/styles.test.ts index 3d8fd3e5..5ea831be 100644 --- a/packages/vue/src/styles/styles.test.ts +++ b/packages/vue/src/styles/styles.test.ts @@ -47,6 +47,7 @@ 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("user-select: none"); 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 2e80905f..809d9d61 100644 --- a/packages/vue/src/styles/styles.ts +++ b/packages/vue/src/styles/styles.ts @@ -48,6 +48,11 @@ const CORE_BASE_STYLES = ` position: absolute; } +.polycss-mesh { + -webkit-user-select: none; + user-select: none; +} + /* ── Polygon leaf element ───────────────────────────────────────────────── */ /* @@ -73,6 +78,8 @@ const CORE_BASE_STYLES = ` text-decoration: none; backface-visibility: hidden; background-repeat: no-repeat; + -webkit-user-select: none; + user-select: none; } .polycss-scene b, @@ -230,6 +237,8 @@ const CORE_BASE_STYLES = ` backface-visibility: visible; border-color: currentColor; pointer-events: none; + -webkit-user-select: none; + user-select: none; } .polycss-scene q::before, .polycss-scene q::after {