Skip to content
This repository was archived by the owner on May 19, 2026. It is now read-only.
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@ Public API is **mirrored** across React and Vue. Adding a hook on one side witho
|---|---|---|---|---|
| `<b>` | **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 |
| `<i>` | **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 |
| `<s>` | **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 |
| `<s>` | **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 |
| `<u>` | **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 |
| `<q>` | **Cast shadow leaf** | Per casting polygon when `castShadow: true` and dynamic lighting mode. Applies regardless of caster strategy — `<b>`/`<i>`/`<s>`/`<u>` all produce a `<q>` shadow because only the polygon's outline matters, not its surface. | Same `border-color: currentColor` + `border-shape: polygon(...)` as `<i>`, but transform composes `var(--shadow-proj)` to project the polygon onto the ground plane along the CSS-space light direction | None |

Expand Down
31 changes: 26 additions & 5 deletions packages/polycss/src/render/polyDOM.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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: [
Expand Down Expand Up @@ -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]);
Expand Down Expand Up @@ -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("");
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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);

Expand Down Expand Up @@ -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);

Expand Down
63 changes: 47 additions & 16 deletions packages/polycss/src/render/textureAtlas.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -240,6 +241,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;
Expand Down Expand Up @@ -1513,11 +1515,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,
Expand Down Expand Up @@ -1869,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 {
Expand Down Expand Up @@ -2165,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;
}

Expand Down Expand Up @@ -2231,8 +2250,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);
Expand Down Expand Up @@ -2412,8 +2433,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,
]),
Expand Down Expand Up @@ -2779,7 +2806,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);
Expand Down
10 changes: 8 additions & 2 deletions packages/polycss/src/styles/styles.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 ───────────────────────────────────────────────── */
Expand All @@ -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,
Expand All @@ -89,8 +93,8 @@ const CORE_BASE_STYLES = `
}

.polycss-scene s {
width: 1px;
height: 1px;
width: 128px;
height: 128px;
}

.polycss-scene u {
Expand Down Expand Up @@ -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 {
Expand Down
66 changes: 48 additions & 18 deletions packages/react/src/scene/textureAtlas.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -44,12 +44,14 @@ 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;
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";

Expand Down Expand Up @@ -1125,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;
Expand Down Expand Up @@ -1603,8 +1637,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(",");
Expand Down Expand Up @@ -1796,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;
}

Expand Down Expand Up @@ -2143,11 +2171,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
Expand All @@ -2163,7 +2193,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,
Expand Down
7 changes: 4 additions & 3 deletions packages/react/src/shapes/Poly.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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+$/, "");
Expand Down Expand Up @@ -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,
};
Expand All @@ -110,7 +111,7 @@ function MaterialDirectPoly({
const elementClassName = className?.trim() || undefined;

return (
<i
<s
className={elementClassName}
style={style}
{...domEventHandlers}
Expand Down
Loading
Loading