Skip to content

Commit ed5b245

Browse files
Renderer gallery builder polish (#46)
* fix(renderer): gate projective quads by browser support * fix(renderer): honor disabled mesh solid strategies * feat(vue): mirror mesh scene and shape APIs * fix(vue): sync renderer style rules * feat(website): add configurable gallery ground * feat(website): persist gallery scene options * feat(website): place builder shapes on surfaces * feat(website): add poly pizza gallery presets --------- Co-authored-by: agustin-littlehat <minotopo@gmail.com>
1 parent 761e4a1 commit ed5b245

50 files changed

Lines changed: 1549 additions & 97 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

packages/core/src/atlas/strategy.test.ts

Lines changed: 41 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -163,8 +163,8 @@ describe("isProjectiveQuadPlan — projective quad detection", () => {
163163
// ---------------------------------------------------------------------------
164164

165165
const noDisable = new Set<"b" | "i" | "u">();
166-
const desktopEnv = { solidTriangleSupported: true, borderShapeSupported: false };
167-
const borderShapeEnv = { solidTriangleSupported: true, borderShapeSupported: true };
166+
const desktopEnv = { solidTriangleSupported: true, projectiveQuadSupported: true, borderShapeSupported: false };
167+
const borderShapeEnv = { solidTriangleSupported: true, projectiveQuadSupported: true, borderShapeSupported: true };
168168

169169
describe("filterAtlasPlans — full-rect solid exclusion", () => {
170170
it("full-rect plan is excluded from atlas when b is enabled", () => {
@@ -177,7 +177,11 @@ describe("filterAtlasPlans — full-rect solid exclusion", () => {
177177
const plan = computeTextureAtlasPlanPublic(FLAT_RECT, 0)!;
178178
const disabled = new Set<"b" | "i" | "u">(["b"]);
179179
// When b disabled and no border-shape, rect falls through to atlas
180-
const result = filterAtlasPlans([plan], "baked", disabled, { solidTriangleSupported: true, borderShapeSupported: false });
180+
const result = filterAtlasPlans([plan], "baked", disabled, {
181+
solidTriangleSupported: true,
182+
projectiveQuadSupported: true,
183+
borderShapeSupported: false,
184+
});
181185
expect(result[0]).not.toBeNull();
182186
});
183187
});
@@ -193,13 +197,21 @@ describe("filterAtlasPlans — triangle exclusion", () => {
193197
const plan = computeTextureAtlasPlanPublic(FLAT_TRIANGLE, 0)!;
194198
const disabled = new Set<"b" | "i" | "u">(["u"]);
195199
// u disabled and no border-shape → triangle goes to atlas
196-
const result = filterAtlasPlans([plan], "baked", disabled, { solidTriangleSupported: false, borderShapeSupported: false });
200+
const result = filterAtlasPlans([plan], "baked", disabled, {
201+
solidTriangleSupported: false,
202+
projectiveQuadSupported: true,
203+
borderShapeSupported: false,
204+
});
197205
expect(result[0]).not.toBeNull();
198206
});
199207

200208
it("triangle plan stays in atlas when solidTriangleSupported is false", () => {
201209
const plan = computeTextureAtlasPlanPublic(FLAT_TRIANGLE, 0)!;
202-
const result = filterAtlasPlans([plan], "baked", noDisable, { solidTriangleSupported: false, borderShapeSupported: false });
210+
const result = filterAtlasPlans([plan], "baked", noDisable, {
211+
solidTriangleSupported: false,
212+
projectiveQuadSupported: true,
213+
borderShapeSupported: false,
214+
});
203215
expect(result[0]).not.toBeNull();
204216
});
205217
});
@@ -250,6 +262,30 @@ describe("filterAtlasPlans — border-shape exclusion", () => {
250262
});
251263
});
252264

265+
describe("filterAtlasPlans — projective quad exclusion", () => {
266+
it("non-rect projective quads are excluded when projective b is supported", () => {
267+
const plan = computeTextureAtlasPlanPublic(NON_RECT_QUAD, 0)!;
268+
expect(isProjectiveQuadPlan(plan)).toBe(true);
269+
const result = filterAtlasPlans([plan], "baked", noDisable, {
270+
solidTriangleSupported: true,
271+
projectiveQuadSupported: true,
272+
borderShapeSupported: false,
273+
});
274+
expect(result[0]).toBeNull();
275+
});
276+
277+
it("non-rect projective quads stay in atlas when projective b is unsupported", () => {
278+
const plan = computeTextureAtlasPlanPublic(NON_RECT_QUAD, 0)!;
279+
expect(isProjectiveQuadPlan(plan)).toBe(true);
280+
const result = filterAtlasPlans([plan], "baked", noDisable, {
281+
solidTriangleSupported: true,
282+
projectiveQuadSupported: false,
283+
borderShapeSupported: false,
284+
});
285+
expect(result[0]).toBe(plan);
286+
});
287+
});
288+
253289
describe("filterAtlasPlans — output array length matches input", () => {
254290
it("length is preserved for mixed null/non-null arrays", () => {
255291
const plans = [

packages/core/src/atlas/strategy.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -87,6 +87,7 @@ export function dominantCountKey(map: Map<string, number>): string | undefined {
8787

8888
export interface FilterAtlasPlansEnv {
8989
solidTriangleSupported: boolean;
90+
projectiveQuadSupported: boolean;
9091
borderShapeSupported: boolean;
9192
}
9293

@@ -102,7 +103,7 @@ export function filterAtlasPlans(
102103
env: FilterAtlasPlansEnv,
103104
): Array<TextureAtlasPlan | null> {
104105
const useFullRectSolid = !disabled.has("b");
105-
const useProjectiveQuad = useFullRectSolid;
106+
const useProjectiveQuad = useFullRectSolid && env.projectiveQuadSupported;
106107
const useStableTriangle = !disabled.has("u") && env.solidTriangleSupported;
107108
const useBorderShape = !disabled.has("i") && textureLighting !== "dynamic" && env.borderShapeSupported;
108109
const disableB = disabled.has("b");

packages/polycss/src/render/atlas/strategy.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -194,8 +194,10 @@ export function filterAtlasPlans(
194194
disabled: ReadonlySet<PolyRenderStrategy>,
195195
doc?: Document | null,
196196
): Array<TextureAtlasPlan | null> {
197+
const resolvedDoc = doc ?? (typeof document !== "undefined" ? document : null);
197198
return filterAtlasPlansCore(plans, textureLighting, disabled, {
198199
solidTriangleSupported: isSolidTriangleSupported(doc),
200+
projectiveQuadSupported: resolvedDoc ? projectiveQuadSupported(resolvedDoc) : true,
199201
borderShapeSupported: isBorderShapeSupported(doc),
200202
});
201203
}

packages/react/src/scene/PolyMesh.test.tsx

Lines changed: 19 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -106,7 +106,10 @@ const OFFSET_TEXTURED_TRIANGLE: Polygon = {
106106
],
107107
};
108108

109-
function renderMesh(props: React.ComponentProps<typeof PolyMesh>): HTMLElement {
109+
function renderMesh(
110+
props: React.ComponentProps<typeof PolyMesh>,
111+
sceneProps: React.ComponentProps<typeof PolyScene> = {},
112+
): HTMLElement {
110113
const container = document.createElement("div");
111114
document.body.appendChild(container);
112115
const root = createRoot(container);
@@ -117,7 +120,7 @@ function renderMesh(props: React.ComponentProps<typeof PolyMesh>): HTMLElement {
117120
{},
118121
React.createElement(
119122
PolyScene,
120-
{},
123+
sceneProps,
121124
React.createElement(PolyMesh, props)
122125
)
123126
)
@@ -168,6 +171,20 @@ describe("PolyMesh — with polygons prop", () => {
168171
expect(polys.length).toBe(2);
169172
});
170173

174+
it("inherits scene strategies.disable b for auto-rendered rects", () => {
175+
vi.stubGlobal("CSS", {
176+
supports: vi.fn((property: string) => property === "border-shape"),
177+
});
178+
const container = renderMesh(
179+
{ polygons: [QUAD] },
180+
{ strategies: { disable: ["b"] } },
181+
);
182+
const poly = container.querySelector("i") as HTMLElement | null;
183+
expect(container.querySelector("b")).toBeNull();
184+
expect(poly).toBeTruthy();
185+
expect(poly!.style.getPropertyValue("border-shape")).toContain("polygon(");
186+
});
187+
171188
it("hoists repeated baked solid paint to the mesh wrapper", () => {
172189
const container = renderMesh({ polygons: [TRIANGLE, TRIANGLE] });
173190
const mesh = container.querySelector(".polycss-mesh") as HTMLElement;

packages/react/src/scene/PolyMesh.tsx

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -520,6 +520,10 @@ export const PolyMesh = forwardRef<PolyMeshHandle, PolyMeshProps>(function PolyM
520520
const sceneCtx = usePolySceneContext();
521521
const effectiveTextureLighting = textureLighting ?? sceneCtx?.textureLighting ?? "baked";
522522
const effectiveStrategies = sceneCtx?.strategies;
523+
const disabledStrategies = useMemo(
524+
() => effectiveStrategies?.disable?.length ? new Set(effectiveStrategies.disable) : undefined,
525+
[effectiveStrategies],
526+
);
523527
const effectiveSeamBleed = seamBleed ?? sceneCtx?.seamBleed ?? DEFAULT_SEAM_BLEED;
524528
const effectiveDirectional =
525529
effectiveTextureLighting === "dynamic" ? undefined : sceneCtx?.directionalLight;
@@ -884,6 +888,7 @@ export const PolyMesh = forwardRef<PolyMeshHandle, PolyMeshProps>(function PolyM
884888
key={plan.index}
885889
entry={plan}
886890
solidPaintDefaults={solidPaintDefaults}
891+
disabledStrategies={disabledStrategies}
887892
/>
888893
);
889894
});

packages/react/src/scene/PolyScene.test.tsx

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -163,6 +163,25 @@ describe("PolyScene — polygon rendering", () => {
163163
expect(style).not.toContain("border-shape");
164164
});
165165

166+
it("falls back to atlas for projective solid quads on Safari", () => {
167+
const nav = document.defaultView?.navigator ?? window.navigator;
168+
const userAgent = vi.spyOn(nav, "userAgent", "get").mockReturnValue(
169+
"Mozilla/5.0 AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.0 Safari/605.1.15",
170+
);
171+
vi.stubGlobal("CSS", { supports: () => false });
172+
173+
try {
174+
const container = renderScene({
175+
polygons: [NON_RECT_QUAD],
176+
});
177+
expect(container.querySelector("b")).toBeNull();
178+
expect(container.querySelector("s")).toBeTruthy();
179+
} finally {
180+
userAgent.mockRestore();
181+
vi.unstubAllGlobals();
182+
}
183+
});
184+
166185
it("renders multiple polygons", () => {
167186
const container = renderScene({ polygons: [TRIANGLE, QUAD] });
168187
const polys = container.querySelectorAll("i,b,s,u");
@@ -235,6 +254,18 @@ describe("PolyScene — autoCenter", () => {
235254
expect(transformOn).toContain("translate3d(-50px, -50px, -50px)");
236255
});
237256

257+
it("uses centerPolygons as the autoCenter bbox source without rendering them", async () => {
258+
const container = renderScene({
259+
polygons: [],
260+
centerPolygons: [QUAD],
261+
autoCenter: true,
262+
});
263+
await flushReactWork();
264+
const scene = container.querySelector(".polycss-scene") as HTMLElement;
265+
expect(container.querySelectorAll("i,b,s,u")).toHaveLength(0);
266+
expect(scene.style.transform).toContain("translate3d(-50px, -50px, -50px)");
267+
});
268+
238269
it("target and autoCenterOffset are independent: pan survives mesh bbox change", async () => {
239270
// Render with TRIANGLE (centroid ~[0.33, 0.33, 0]) centered.
240271
// Then switch to QUAD (centroid [1, 1, 1]) — the centering offset updates

packages/react/src/scene/atlas/filterPlans.test.ts

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@
66
*/
77
import { describe, it, expect } from "vitest";
88
import type { Polygon } from "@layoutit/polycss-core";
9-
import { computeTextureAtlasPlanPublic } from "@layoutit/polycss-core";
9+
import { computeTextureAtlasPlanPublic, isProjectiveQuadPlan } from "@layoutit/polycss-core";
1010
import { filterAtlasPlans } from "./filterPlans";
1111

1212
// ---------------------------------------------------------------------------
@@ -55,6 +55,11 @@ const FLAT_TRIANGLE: Polygon = {
5555
color: "#ff0000",
5656
};
5757

58+
const NON_RECT_QUAD: Polygon = {
59+
vertices: [[0, 0, 0], [2, 0, 0], [2, 1, 0], [0, 2, 0]],
60+
color: "#00ffff",
61+
};
62+
5863
const TEXTURED_QUAD: Polygon = {
5964
vertices: [[0, 0, 0], [1, 0, 0], [1, 1, 0], [0, 1, 0]],
6065
texture: "https://example.com/tex.png",
@@ -139,6 +144,14 @@ describe("filterAtlasPlans — strategy filter contracts", () => {
139144
expect(filtered[0]).not.toBeNull();
140145
});
141146

147+
it("projective solid quads stay in atlas on Safari", () => {
148+
const plan = computeTextureAtlasPlanPublic(NON_RECT_QUAD, 0);
149+
expect(plan && isProjectiveQuadPlan(plan)).toBe(true);
150+
const doc = makeDoc({ userAgent: SAFARI_UA, borderShape: false });
151+
const filtered = filterAtlasPlans([plan], "baked", noDisable, doc);
152+
expect(filtered[0]).toBe(plan);
153+
});
154+
142155
it("output length matches input length", () => {
143156
const plans = [
144157
computeTextureAtlasPlanPublic(FLAT_RECT, 0),

packages/react/src/scene/atlas/filterPlans.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import type {
77
} from "@layoutit/polycss-core";
88
import type { PolyTextureLightingMode } from "@layoutit/polycss-core";
99
import { isBorderShapeSupported, isSolidTriangleSupported } from "./detection";
10+
import { projectiveQuadSupported } from "./detection";
1011

1112
/**
1213
* Filter a plan array to the subset that needs atlas packing, given the active
@@ -21,6 +22,7 @@ export function filterAtlasPlans(
2122
): Array<TextureAtlasPlan | null> {
2223
return filterAtlasPlansCore(plans, textureLighting, disabled, {
2324
solidTriangleSupported: isSolidTriangleSupported(doc),
25+
projectiveQuadSupported: doc ? projectiveQuadSupported(doc) : true,
2426
borderShapeSupported: isBorderShapeSupported(doc),
2527
});
2628
}

packages/react/src/scene/atlas/useTextureAtlas.ts

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -47,7 +47,12 @@ export function useTextureAtlas(
4747
);
4848

4949
const atlasPlans = useMemo(
50-
() => filterAtlasPlans(plans, textureLighting, disabled),
50+
() => filterAtlasPlans(
51+
plans,
52+
textureLighting,
53+
disabled,
54+
typeof document !== "undefined" ? document : null,
55+
),
5156
[plans, textureLighting, disabled],
5257
);
5358

packages/react/src/shapes/Poly.test.tsx

Lines changed: 4 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -153,18 +153,17 @@ describe("Poly — non-horizontal geometry", () => {
153153
expect(poly.style.height).toBe("");
154154
});
155155

156-
it("renders solid non-rect quads as projective b on Safari", () => {
157-
const userAgent = vi.spyOn(window.navigator, "userAgent", "get").mockReturnValue(
156+
it("falls back to atlas for projective solid quads on Safari", () => {
157+
const nav = document.defaultView?.navigator ?? window.navigator;
158+
const userAgent = vi.spyOn(nav, "userAgent", "get").mockReturnValue(
158159
"Mozilla/5.0 AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.0 Safari/605.1.15",
159160
);
160161
vi.stubGlobal("CSS", { supports: () => false });
161162

162163
try {
163164
const container = renderPoly({ vertices: NON_RECT_QUAD_VERTS });
164165
const poly = getPoly(container);
165-
// Non-rect untextured quads are rendered as projective <b> regardless of
166-
// browser — the projective matrix path doesn't depend on CSS.supports.
167-
expect(poly.tagName.toLowerCase()).toBe("b");
166+
expect(poly.tagName.toLowerCase()).toBe("s");
168167
expect(poly.style.getPropertyValue("border-shape")).toBe("");
169168
} finally {
170169
userAgent.mockRestore();

0 commit comments

Comments
 (0)