Skip to content
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 @@ -34,7 +34,7 @@ Voxel-shaped meshes are the exception to "all polygons stay mounted": meshes wit
| `<b>` | **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 |
| `<i>` | **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 |
| `<s>` | **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 |
| `<u>` | **Stable solid triangle / corner-shape solid** | Triangles on non-WebKit engines; or untextured non-triangle polygons whose normalized outline is exactly a rectangle with one or more beveled corners on browsers with CSS `corner-shape` | Triangles use a 32px box with two beveled top corners and `background: currentColor` when CSS `corner-shape` support is present, progressively falling back to the CSS border-color triangle trick; exact corner-shape solids use a bare fixed 16px box with inline per-corner radii + `corner-*-shape: bevel` and `background: currentColor`. Tiny solid bleed is folded into `matrix3d`. WebKit/Safari falls through to `<s>` for border triangles because transformed CSS border triangles composite incorrectly there. | None |
| `<u>` | **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 `<s>` for border triangles because transformed CSS border triangles composite incorrectly there. | None |
| `<q>` | **Cast shadow leaf** | Per casting polygon when `castShadow: true`, in either 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>`. 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 `<b>` / `<u>` / `<i>` and minimise `<s>` (see "Meshing implications" below).
Expand Down
21 changes: 21 additions & 0 deletions bench/nonvoxel-vanilla.html
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@

const { meshId, mode, motion, az, el, isSynth, preset } = parseUrlParams();
const benchParams = new URLSearchParams(window.location.search);
const cameraPerspectiveMode = benchParams.get("cameraPerspective") || "default";
const sceneTransformMode = benchParams.get("sceneTransformMode") || "default";
const rotationDriver = benchParams.get("rotationDriver") || "js";
const polygonOrder = benchParams.get("polygonOrder") || "source";
Expand Down Expand Up @@ -111,6 +112,7 @@
frameWorkSamples: () => frameWork.samples(),
resetInteractionStats,
cullStats: () => displayCullStats,
cameraPerspective: () => cameraPerspective(),
setMeshPosition(_position) {},
setMeshRotation(_rotation) {},
setMeshPolygonsSame() {},
Expand All @@ -127,6 +129,7 @@
},
};

const cameraEl = host.querySelector(".polycss-camera");
const sceneEl = host.querySelector(".polycss-scene");
let splitShell = null;
let displayCullController = null;
Expand All @@ -141,6 +144,24 @@
return sceneEl?.style.perspective || getComputedStyle(sceneEl).perspective || "8000px";
}

function cameraPerspective() {
return cameraEl?.style.perspective || getComputedStyle(cameraEl).perspective || "";
}

function applyCameraPerspectiveMode() {
if (!(cameraEl instanceof HTMLElement) || cameraPerspectiveMode === "default") return;
if (cameraPerspectiveMode === "none") {
cameraEl.style.perspective = "none";
return;
}
const px = Number(cameraPerspectiveMode);
if (Number.isFinite(px) && px > 0) {
cameraEl.style.perspective = `${px}px`;
}
}

applyCameraPerspectiveMode();

function createFrameWorkRecorder(enabled) {
let current = {};
const frameSamples = [];
Expand Down
6 changes: 6 additions & 0 deletions bench/nonvoxel-variants.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,12 @@ export const NONVOXEL_VARIANTS = [
params: {},
hypothesis: "Current vanilla JS scene-root rotation.",
},
{
id: "camera-perspective-none",
label: "Camera Perspective None",
params: { cameraPerspective: "none" },
hypothesis: "Use true orthographic perspective on the camera wrapper instead of the large finite stand-in.",
},
{
id: "css-keyframes",
label: "CSS Keyframes",
Expand Down
2 changes: 2 additions & 0 deletions bench/nonvoxel-visual-compare.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -187,10 +187,12 @@ async function screenshotVariant(page, port, model, variant) {
renderStats: await page.evaluate(() => window.__perf__?.renderStats ?? null),
sceneTransform: await page.evaluate(() => {
const scene = document.querySelector(".polycss-scene");
const camera = document.querySelector(".polycss-camera");
const host = document.getElementById("host");
return {
scene: scene instanceof HTMLElement ? scene.style.transform : "",
scenePerspective: scene instanceof HTMLElement ? scene.style.perspective : "",
cameraPerspective: camera instanceof HTMLElement ? camera.style.perspective : "",
hostPerspective: host instanceof HTMLElement ? host.style.perspective : "",
shell: document.querySelector(".polycss-scene > div") instanceof HTMLElement
? document.querySelector(".polycss-scene > div").style.transform
Expand Down
2 changes: 2 additions & 0 deletions packages/core/src/atlas/constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -46,7 +46,9 @@ export const DEFAULT_ATLAS_CSS_DECIMALS = 4;
export const DECIMAL_SCALES = [1, 10, 100, 1000, 10000, 100000, 1000000];
export const SOLID_QUAD_CANONICAL_SIZE = 64;
export const SOLID_TRIANGLE_CANONICAL_SIZE = 32;
export const SOLID_TRIANGLE_LARGE_BORDER_CANONICAL_SIZE = 96;
export const SOLID_TRIANGLE_CORNER_CLASS = "polycss-corner-triangle";
export const SOLID_TRIANGLE_LARGE_BORDER_CLASS = "polycss-large-border-triangle";
export const ATLAS_CANONICAL_SIZE_EXPLICIT = 64;
export const ATLAS_CANONICAL_SIZE_AUTO_DESKTOP = 128;
export const BORDER_SHAPE_CENTER_PERCENT = 50;
Expand Down
48 changes: 48 additions & 0 deletions packages/core/src/atlas/solidTriangle.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,13 @@
*/
import { describe, it, expect } from "vitest";
import type { Polygon } from "../types";
import {
SOLID_TRIANGLE_CANONICAL_SIZE,
SOLID_TRIANGLE_LARGE_BORDER_CANONICAL_SIZE,
} from "./constants";
import {
computeSolidTrianglePlan,
} from "./solidTrianglePlan";
import {
computeSurfaceNormal,
cssPoints,
Expand All @@ -26,6 +33,12 @@ import {
} from "./solidTriangle";
import { computeTextureAtlasPlanPublic } from "./plan";

function matrixValues(transform: string): number[] {
const match = /^matrix3d\((.*)\)$/.exec(transform);
if (!match) return [];
return match[1].split(",").map((value) => Number(value));
}

// ---------------------------------------------------------------------------
// computeSurfaceNormal
// ---------------------------------------------------------------------------
Expand Down Expand Up @@ -306,6 +319,41 @@ describe("stableBasisFromPlan — stable triangle basis from atlas plan", () =>
});
});

describe("computeSolidTrianglePlan — primitive sizing", () => {
const FLAT_TRIANGLE: Polygon = {
vertices: [[0, 0, 0], [1, 0, 0], [0, 1, 0]],
color: "#ff0000",
};

it("keeps border-large geometry stable by reducing x/y matrix scale", () => {
const border = computeSolidTrianglePlan(
FLAT_TRIANGLE,
0,
{},
{ primitive: "border", matrixDecimals: 6 },
)!;
const large = computeSolidTrianglePlan(
FLAT_TRIANGLE,
0,
{},
{ primitive: "border-large", matrixDecimals: 6 },
)!;
const ratio = SOLID_TRIANGLE_CANONICAL_SIZE / SOLID_TRIANGLE_LARGE_BORDER_CANONICAL_SIZE;
const borderValues = matrixValues(border.transformText);
const largeValues = matrixValues(large.transformText);

expect(large.primitive).toBe("border-large");
expect(borderValues).toHaveLength(16);
expect(largeValues).toHaveLength(16);
for (const index of [0, 1, 2, 4, 5, 6]) {
expect(largeValues[index]).toBeCloseTo(borderValues[index] * ratio, 6);
}
for (const index of [8, 9, 10, 12, 13, 14]) {
expect(largeValues[index]).toBeCloseTo(borderValues[index], 6);
}
});
});

// ---------------------------------------------------------------------------
// offsetTrianglePoints — outward offset for a raw triangle
// ---------------------------------------------------------------------------
Expand Down
10 changes: 6 additions & 4 deletions packages/core/src/atlas/solidTrianglePlan.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import {
BASIS_EPS,
SOLID_TRIANGLE_BLEED,
SOLID_TRIANGLE_CANONICAL_SIZE,
SOLID_TRIANGLE_LARGE_BORDER_CANONICAL_SIZE,
} from "./constants";
import type {
SolidTrianglePlan,
Expand Down Expand Up @@ -398,7 +399,11 @@ export function computeSolidTrianglePlanFromCssPoints(
dynamicVars = colorPlan.dynamicVars ?? "";
}
const bakedColor = bakedColorValue ? `color:${bakedColorValue};` : "";
const invCanonicalSize = 1 / SOLID_TRIANGLE_CANONICAL_SIZE;
const primitive = computeOptions.primitive ?? computeOptions.resolvedPrimitive ?? "border";
const canonicalSize = primitive === "border-large"
? SOLID_TRIANGLE_LARGE_BORDER_CANONICAL_SIZE
: SOLID_TRIANGLE_CANONICAL_SIZE;
const invCanonicalSize = 1 / canonicalSize;
const baseWidthPx = leftPx + rightPx;
const xScale = baseWidthPx * invCanonicalSize;
const yXScale = (rightPx - leftPx) * 0.5 * invCanonicalSize;
Expand Down Expand Up @@ -433,9 +438,6 @@ export function computeSolidTrianglePlanFromCssPoints(
const basis = basisHint && basisHint.a === a && basisHint.b === b && basisHint.c === c
? basisHint
: { a, b, c };
// Use the pre-resolved primitive from computeOptions — the browser-global
// resolution that formerly happened here now happens in the PolyCSS wrapper.
const primitive = computeOptions.primitive ?? computeOptions.resolvedPrimitive ?? "border";
return {
index,
polygon,
Expand Down
2 changes: 1 addition & 1 deletion packages/core/src/atlas/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -87,7 +87,7 @@ export type PolySeamBleedEdges =
| readonly (PolySeamBleedEdgeValue | undefined)[];

export type PolyRenderStrategy = "b" | "i" | "u";
export type SolidTrianglePrimitive = "border" | "corner-bevel";
export type SolidTrianglePrimitive = "border" | "border-large" | "corner-bevel";

export interface PolyRenderStrategiesOption {
/** Strategies to skip; polygons that would normally use them fall through
Expand Down
2 changes: 2 additions & 0 deletions packages/core/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -233,7 +233,9 @@ export {
DECIMAL_SCALES,
SOLID_QUAD_CANONICAL_SIZE,
SOLID_TRIANGLE_CANONICAL_SIZE,
SOLID_TRIANGLE_LARGE_BORDER_CANONICAL_SIZE,
SOLID_TRIANGLE_CORNER_CLASS,
SOLID_TRIANGLE_LARGE_BORDER_CLASS,
ATLAS_CANONICAL_SIZE_EXPLICIT,
ATLAS_CANONICAL_SIZE_AUTO_DESKTOP,
BORDER_SHAPE_CENTER_PERCENT,
Expand Down
1 change: 1 addition & 0 deletions packages/core/src/parser/parseGltf.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -96,6 +96,7 @@ describe("parseGltf — real fixture (tree.glb)", () => {
describe("parseGltf — animated fixture (FishAnimated.glb)", () => {
const animatedGalleryFixtures = [
["FishAnimated.glb", 1],
["ClownfishAnimated.glb", 1],
["AnimatedMushnub.glb", 9],
["AnimatedWizard.glb", 9],
["AnimatedSnake.glb", 4],
Expand Down
22 changes: 18 additions & 4 deletions packages/polycss/src/render/atlas/solidTrianglePrimitive.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,15 +21,18 @@ function makeDoc(options: {
cornerShape?: boolean;
solidTriangleSupported?: boolean;
borderShape?: boolean;
userAgent?: string;
} = {}): Document {
const solidTriangleOk = options.solidTriangleSupported !== false;
const userAgent = options.userAgent ?? (
solidTriangleOk
? "Mozilla/5.0 Chrome/120"
: "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.0 Safari/605.1.15"
);
return {
defaultView: {
navigator: {
// Safari UA → solid triangles NOT supported (compositing bug)
userAgent: solidTriangleOk
? "Mozilla/5.0 Chrome/120"
: "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.0 Safari/605.1.15",
userAgent,
},
CSS: {
supports: (property: string, value?: string) => {
Expand Down Expand Up @@ -83,6 +86,17 @@ describe("solid triangle primitive — corner-bevel vs border", () => {
result!.dispose();
});

it("Firefox UA → uses the large border triangle primitive", () => {
const doc = makeDoc({
userAgent: "Mozilla/5.0 (Macintosh; Intel Mac OS X 10.15; rv:146.0) Gecko/20100101 Firefox/146.0",
});
const result = renderPolygonsWithStableTriangles([TRIANGLE], { doc });
expect(result).not.toBeNull();
expect(result!.rendered[0].element.classList.contains("polycss-large-border-triangle")).toBe(true);
expect(result!.rendered[0].element.classList.contains("polycss-corner-triangle")).toBe(false);
result!.dispose();
});

it("Safari UA → renderPolygonsWithStableTriangles returns null (solid triangles unsupported)", () => {
const doc = makeDoc({ solidTriangleSupported: false });
const result = renderPolygonsWithStableTriangles([TRIANGLE], { doc });
Expand Down
2 changes: 2 additions & 0 deletions packages/polycss/src/render/atlas/stableTriangle.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import {
buildSeamBleedPolygonEdges,
DEFAULT_TILE,
SOLID_TRIANGLE_CORNER_CLASS,
SOLID_TRIANGLE_LARGE_BORDER_CLASS,
DEFAULT_MATRIX_DECIMALS,
BASIS_EPS,
} from "@layoutit/polycss-core";
Expand Down Expand Up @@ -105,6 +106,7 @@ export function applySolidTrianglePrimitive(
const triangleEl = el as SolidTriangleElement;
if (triangleEl.__polycssSolidTrianglePrimitive === primitive) return;
el.classList.toggle(SOLID_TRIANGLE_CORNER_CLASS, primitive === "corner-bevel");
el.classList.toggle(SOLID_TRIANGLE_LARGE_BORDER_CLASS, primitive === "border-large");
triangleEl.__polycssSolidTrianglePrimitive = primitive;
}

Expand Down
11 changes: 9 additions & 2 deletions packages/polycss/src/render/atlas/strategy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -73,13 +73,20 @@ export function cornerTriangleSupported(doc: Document): boolean {
!!css.supports("corner-top-right-shape", "bevel");
}

function firefoxNeedsLargeBorderTriangle(doc: Document): boolean {
const win = doc.defaultView ?? (typeof window !== "undefined" ? window : undefined);
const userAgent = win?.navigator?.userAgent ?? "";
return /\bFirefox\//.test(userAgent);
}

export function resolveSolidTrianglePrimitive(
doc: Document,
strategies?: PolyRenderStrategiesOption,
): "border" | "corner-bevel" | null {
): "border" | "border-large" | "corner-bevel" | null {
if (strategies?.disable?.includes("u")) return null;
if (cornerTriangleSupported(doc)) return "corner-bevel";
return solidTriangleSupported(doc) ? "border" : null;
if (!solidTriangleSupported(doc)) return null;
return firefoxNeedsLargeBorderTriangle(doc) ? "border-large" : "border";
}

export function projectiveQuadSupported(doc: Document): boolean {
Expand Down
4 changes: 4 additions & 0 deletions packages/polycss/src/styles/styles.ts
Original file line number Diff line number Diff line change
Expand Up @@ -155,6 +155,10 @@ const CORE_BASE_STYLES = `
border-width: 0 16px 32px 16px;
}

.polycss-scene u.polycss-large-border-triangle {
border-width: 0 48px 96px 48px;
}

.polycss-scene u.polycss-corner-triangle {
width: 32px;
height: 32px;
Expand Down
18 changes: 18 additions & 0 deletions packages/react/src/scene/atlas/index.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import {
buildTextureEdgeRepairSets,
computeTextureAtlasPlan,
isSolidTrianglePlan,
updateStableTriangleDom,
useTextureAtlas,
type TextureQuality,
type TextureAtlasPlan,
Expand All @@ -14,6 +15,7 @@ import type { Polygon } from "@layoutit/polycss-core";

const originalMatchMedia = window.matchMedia;
const originalUserAgent = window.navigator.userAgent;
const FIREFOX_UA = "Mozilla/5.0 (Macintosh; Intel Mac OS X 10.15; rv:146.0) Gecko/20100101 Firefox/146.0";

const TEXTURED_QUAD_60: Polygon = {
vertices: [
Expand Down Expand Up @@ -191,6 +193,22 @@ describe("isSolidTrianglePlan", () => {
});
});

describe("updateStableTriangleDom", () => {
it("applies the large border triangle primitive on Firefox", () => {
stubUserAgent(FIREFOX_UA);
const root = document.createElement("div");
const leaf = document.createElement("u");
root.append(leaf);
const tri: Polygon = {
vertices: [[0, 0, 0], [1, 0, 0], [0, 1, 0]],
color: "#ff0000",
};

expect(updateStableTriangleDom(root, [tri])).toBe(true);
expect(leaf.style.borderWidth).toBe("0px 48px 96px");
});
});

describe("useTextureAtlas", () => {
function buildSixFaceCrateScene(): TextureAtlasPlan[] {
const polys = Array.from({ length: 6 }, () => ({ ...TEXTURED_QUAD_60 }));
Expand Down
Loading
Loading