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
46 changes: 41 additions & 5 deletions packages/core/src/atlas/strategy.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -163,8 +163,8 @@ describe("isProjectiveQuadPlan — projective quad detection", () => {
// ---------------------------------------------------------------------------

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

describe("filterAtlasPlans — full-rect solid exclusion", () => {
it("full-rect plan is excluded from atlas when b is enabled", () => {
Expand All @@ -177,7 +177,11 @@ describe("filterAtlasPlans — full-rect solid exclusion", () => {
const plan = computeTextureAtlasPlanPublic(FLAT_RECT, 0)!;
const disabled = new Set<"b" | "i" | "u">(["b"]);
// When b disabled and no border-shape, rect falls through to atlas
const result = filterAtlasPlans([plan], "baked", disabled, { solidTriangleSupported: true, borderShapeSupported: false });
const result = filterAtlasPlans([plan], "baked", disabled, {
solidTriangleSupported: true,
projectiveQuadSupported: true,
borderShapeSupported: false,
});
expect(result[0]).not.toBeNull();
});
});
Expand All @@ -193,13 +197,21 @@ describe("filterAtlasPlans — triangle exclusion", () => {
const plan = computeTextureAtlasPlanPublic(FLAT_TRIANGLE, 0)!;
const disabled = new Set<"b" | "i" | "u">(["u"]);
// u disabled and no border-shape → triangle goes to atlas
const result = filterAtlasPlans([plan], "baked", disabled, { solidTriangleSupported: false, borderShapeSupported: false });
const result = filterAtlasPlans([plan], "baked", disabled, {
solidTriangleSupported: false,
projectiveQuadSupported: true,
borderShapeSupported: false,
});
expect(result[0]).not.toBeNull();
});

it("triangle plan stays in atlas when solidTriangleSupported is false", () => {
const plan = computeTextureAtlasPlanPublic(FLAT_TRIANGLE, 0)!;
const result = filterAtlasPlans([plan], "baked", noDisable, { solidTriangleSupported: false, borderShapeSupported: false });
const result = filterAtlasPlans([plan], "baked", noDisable, {
solidTriangleSupported: false,
projectiveQuadSupported: true,
borderShapeSupported: false,
});
expect(result[0]).not.toBeNull();
});
});
Expand Down Expand Up @@ -250,6 +262,30 @@ describe("filterAtlasPlans — border-shape exclusion", () => {
});
});

describe("filterAtlasPlans — projective quad exclusion", () => {
it("non-rect projective quads are excluded when projective b is supported", () => {
const plan = computeTextureAtlasPlanPublic(NON_RECT_QUAD, 0)!;
expect(isProjectiveQuadPlan(plan)).toBe(true);
const result = filterAtlasPlans([plan], "baked", noDisable, {
solidTriangleSupported: true,
projectiveQuadSupported: true,
borderShapeSupported: false,
});
expect(result[0]).toBeNull();
});

it("non-rect projective quads stay in atlas when projective b is unsupported", () => {
const plan = computeTextureAtlasPlanPublic(NON_RECT_QUAD, 0)!;
expect(isProjectiveQuadPlan(plan)).toBe(true);
const result = filterAtlasPlans([plan], "baked", noDisable, {
solidTriangleSupported: true,
projectiveQuadSupported: false,
borderShapeSupported: false,
});
expect(result[0]).toBe(plan);
});
});

describe("filterAtlasPlans — output array length matches input", () => {
it("length is preserved for mixed null/non-null arrays", () => {
const plans = [
Expand Down
3 changes: 2 additions & 1 deletion packages/core/src/atlas/strategy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -87,6 +87,7 @@ export function dominantCountKey(map: Map<string, number>): string | undefined {

export interface FilterAtlasPlansEnv {
solidTriangleSupported: boolean;
projectiveQuadSupported: boolean;
borderShapeSupported: boolean;
}

Expand All @@ -102,7 +103,7 @@ export function filterAtlasPlans(
env: FilterAtlasPlansEnv,
): Array<TextureAtlasPlan | null> {
const useFullRectSolid = !disabled.has("b");
const useProjectiveQuad = useFullRectSolid;
const useProjectiveQuad = useFullRectSolid && env.projectiveQuadSupported;
const useStableTriangle = !disabled.has("u") && env.solidTriangleSupported;
const useBorderShape = !disabled.has("i") && textureLighting !== "dynamic" && env.borderShapeSupported;
const disableB = disabled.has("b");
Expand Down
2 changes: 2 additions & 0 deletions packages/polycss/src/render/atlas/strategy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -194,8 +194,10 @@ export function filterAtlasPlans(
disabled: ReadonlySet<PolyRenderStrategy>,
doc?: Document | null,
): Array<TextureAtlasPlan | null> {
const resolvedDoc = doc ?? (typeof document !== "undefined" ? document : null);
return filterAtlasPlansCore(plans, textureLighting, disabled, {
solidTriangleSupported: isSolidTriangleSupported(doc),
projectiveQuadSupported: resolvedDoc ? projectiveQuadSupported(resolvedDoc) : true,
borderShapeSupported: isBorderShapeSupported(doc),
});
}
Expand Down
21 changes: 19 additions & 2 deletions packages/react/src/scene/PolyMesh.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -106,7 +106,10 @@ const OFFSET_TEXTURED_TRIANGLE: Polygon = {
],
};

function renderMesh(props: React.ComponentProps<typeof PolyMesh>): HTMLElement {
function renderMesh(
props: React.ComponentProps<typeof PolyMesh>,
sceneProps: React.ComponentProps<typeof PolyScene> = {},
): HTMLElement {
const container = document.createElement("div");
document.body.appendChild(container);
const root = createRoot(container);
Expand All @@ -117,7 +120,7 @@ function renderMesh(props: React.ComponentProps<typeof PolyMesh>): HTMLElement {
{},
React.createElement(
PolyScene,
{},
sceneProps,
React.createElement(PolyMesh, props)
)
)
Expand Down Expand Up @@ -168,6 +171,20 @@ describe("PolyMesh — with polygons prop", () => {
expect(polys.length).toBe(2);
});

it("inherits scene strategies.disable b for auto-rendered rects", () => {
vi.stubGlobal("CSS", {
supports: vi.fn((property: string) => property === "border-shape"),
});
const container = renderMesh(
{ polygons: [QUAD] },
{ strategies: { disable: ["b"] } },
);
const poly = container.querySelector("i") as HTMLElement | null;
expect(container.querySelector("b")).toBeNull();
expect(poly).toBeTruthy();
expect(poly!.style.getPropertyValue("border-shape")).toContain("polygon(");
});

it("hoists repeated baked solid paint to the mesh wrapper", () => {
const container = renderMesh({ polygons: [TRIANGLE, TRIANGLE] });
const mesh = container.querySelector(".polycss-mesh") as HTMLElement;
Expand Down
5 changes: 5 additions & 0 deletions packages/react/src/scene/PolyMesh.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -520,6 +520,10 @@ export const PolyMesh = forwardRef<PolyMeshHandle, PolyMeshProps>(function PolyM
const sceneCtx = usePolySceneContext();
const effectiveTextureLighting = textureLighting ?? sceneCtx?.textureLighting ?? "baked";
const effectiveStrategies = sceneCtx?.strategies;
const disabledStrategies = useMemo(
() => effectiveStrategies?.disable?.length ? new Set(effectiveStrategies.disable) : undefined,
[effectiveStrategies],
);
const effectiveSeamBleed = seamBleed ?? sceneCtx?.seamBleed ?? DEFAULT_SEAM_BLEED;
const effectiveDirectional =
effectiveTextureLighting === "dynamic" ? undefined : sceneCtx?.directionalLight;
Expand Down Expand Up @@ -884,6 +888,7 @@ export const PolyMesh = forwardRef<PolyMeshHandle, PolyMeshProps>(function PolyM
key={plan.index}
entry={plan}
solidPaintDefaults={solidPaintDefaults}
disabledStrategies={disabledStrategies}
/>
);
});
Expand Down
31 changes: 31 additions & 0 deletions packages/react/src/scene/PolyScene.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -163,6 +163,25 @@ describe("PolyScene — polygon rendering", () => {
expect(style).not.toContain("border-shape");
});

it("falls back to atlas for projective solid quads on Safari", () => {
const nav = document.defaultView?.navigator ?? window.navigator;
const userAgent = vi.spyOn(nav, "userAgent", "get").mockReturnValue(
"Mozilla/5.0 AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.0 Safari/605.1.15",
);
vi.stubGlobal("CSS", { supports: () => false });

try {
const container = renderScene({
polygons: [NON_RECT_QUAD],
});
expect(container.querySelector("b")).toBeNull();
expect(container.querySelector("s")).toBeTruthy();
} finally {
userAgent.mockRestore();
vi.unstubAllGlobals();
}
});

it("renders multiple polygons", () => {
const container = renderScene({ polygons: [TRIANGLE, QUAD] });
const polys = container.querySelectorAll("i,b,s,u");
Expand Down Expand Up @@ -235,6 +254,18 @@ describe("PolyScene — autoCenter", () => {
expect(transformOn).toContain("translate3d(-50px, -50px, -50px)");
});

it("uses centerPolygons as the autoCenter bbox source without rendering them", async () => {
const container = renderScene({
polygons: [],
centerPolygons: [QUAD],
autoCenter: true,
});
await flushReactWork();
const scene = container.querySelector(".polycss-scene") as HTMLElement;
expect(container.querySelectorAll("i,b,s,u")).toHaveLength(0);
expect(scene.style.transform).toContain("translate3d(-50px, -50px, -50px)");
});

it("target and autoCenterOffset are independent: pan survives mesh bbox change", async () => {
// Render with TRIANGLE (centroid ~[0.33, 0.33, 0]) centered.
// Then switch to QUAD (centroid [1, 1, 1]) — the centering offset updates
Expand Down
15 changes: 14 additions & 1 deletion packages/react/src/scene/atlas/filterPlans.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@
*/
import { describe, it, expect } from "vitest";
import type { Polygon } from "@layoutit/polycss-core";
import { computeTextureAtlasPlanPublic } from "@layoutit/polycss-core";
import { computeTextureAtlasPlanPublic, isProjectiveQuadPlan } from "@layoutit/polycss-core";
import { filterAtlasPlans } from "./filterPlans";

// ---------------------------------------------------------------------------
Expand Down Expand Up @@ -55,6 +55,11 @@ const FLAT_TRIANGLE: Polygon = {
color: "#ff0000",
};

const NON_RECT_QUAD: Polygon = {
vertices: [[0, 0, 0], [2, 0, 0], [2, 1, 0], [0, 2, 0]],
color: "#00ffff",
};

const TEXTURED_QUAD: Polygon = {
vertices: [[0, 0, 0], [1, 0, 0], [1, 1, 0], [0, 1, 0]],
texture: "https://example.com/tex.png",
Expand Down Expand Up @@ -139,6 +144,14 @@ describe("filterAtlasPlans — strategy filter contracts", () => {
expect(filtered[0]).not.toBeNull();
});

it("projective solid quads stay in atlas on Safari", () => {
const plan = computeTextureAtlasPlanPublic(NON_RECT_QUAD, 0);
expect(plan && isProjectiveQuadPlan(plan)).toBe(true);
const doc = makeDoc({ userAgent: SAFARI_UA, borderShape: false });
const filtered = filterAtlasPlans([plan], "baked", noDisable, doc);
expect(filtered[0]).toBe(plan);
});

it("output length matches input length", () => {
const plans = [
computeTextureAtlasPlanPublic(FLAT_RECT, 0),
Expand Down
2 changes: 2 additions & 0 deletions packages/react/src/scene/atlas/filterPlans.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import type {
} from "@layoutit/polycss-core";
import type { PolyTextureLightingMode } from "@layoutit/polycss-core";
import { isBorderShapeSupported, isSolidTriangleSupported } from "./detection";
import { projectiveQuadSupported } from "./detection";

/**
* Filter a plan array to the subset that needs atlas packing, given the active
Expand All @@ -21,6 +22,7 @@ export function filterAtlasPlans(
): Array<TextureAtlasPlan | null> {
return filterAtlasPlansCore(plans, textureLighting, disabled, {
solidTriangleSupported: isSolidTriangleSupported(doc),
projectiveQuadSupported: doc ? projectiveQuadSupported(doc) : true,
borderShapeSupported: isBorderShapeSupported(doc),
});
}
7 changes: 6 additions & 1 deletion packages/react/src/scene/atlas/useTextureAtlas.ts
Original file line number Diff line number Diff line change
Expand Up @@ -47,7 +47,12 @@ export function useTextureAtlas(
);

const atlasPlans = useMemo(
() => filterAtlasPlans(plans, textureLighting, disabled),
() => filterAtlasPlans(
plans,
textureLighting,
disabled,
typeof document !== "undefined" ? document : null,
),
[plans, textureLighting, disabled],
);

Expand Down
9 changes: 4 additions & 5 deletions packages/react/src/shapes/Poly.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -153,18 +153,17 @@ describe("Poly — non-horizontal geometry", () => {
expect(poly.style.height).toBe("");
});

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

try {
const container = renderPoly({ vertices: NON_RECT_QUAD_VERTS });
const poly = getPoly(container);
// Non-rect untextured quads are rendered as projective <b> regardless of
// browser — the projective matrix path doesn't depend on CSS.supports.
expect(poly.tagName.toLowerCase()).toBe("b");
expect(poly.tagName.toLowerCase()).toBe("s");
expect(poly.style.getPropertyValue("border-shape")).toBe("");
} finally {
userAgent.mockRestore();
Expand Down
9 changes: 9 additions & 0 deletions packages/react/src/styles/styles.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,15 @@ describe("injectPolyBaseStyles", () => {
expect(el.textContent).not.toContain("polycss-solid-triangle");
});

it("includes transform-controls and shadow performance rules", () => {
injectPolyBaseStyles(document);
const el = document.getElementById("polycss-styles")!;
expect(el.textContent).toContain(".polycss-transform-controls i");
expect(el.textContent).toContain("transition: color 150ms ease-out");
expect(el.textContent).toContain(".polycss-scene q");
expect(el.textContent).toContain("will-change: transform");
});

it("does nothing when doc is null-ish", () => {
// Should not throw
expect(() =>
Expand Down
13 changes: 13 additions & 0 deletions packages/vue/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,19 @@ export {
PolyCone,
PolyTorus,
} from "./shapes";
export type {
PolyBoxProps,
PolyPlaneProps,
PolyRingProps,
PolyOctahedronProps,
PolySphereProps,
PolyTetrahedronProps,
PolyIcosahedronProps,
PolyDodecahedronProps,
PolyCylinderProps,
PolyConeProps,
PolyTorusProps,
} from "./shapes";

export { PolyOrbitControls, PolyMapControls, PolyTransformControls, PolyFirstPersonControls } from "./controls";
export type {
Expand Down
20 changes: 18 additions & 2 deletions packages/vue/src/scene/PolyMesh.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -113,7 +113,8 @@ function mockFetchVox(): void {

function renderMesh(
meshProps: Record<string, unknown> = {},
slots: Record<string, () => VNode | VNode[]> = {}
slots: Record<string, () => VNode | VNode[]> = {},
sceneProps: Record<string, unknown> = {},
): { container: HTMLElement; app: ReturnType<typeof createApp> } {
const container = document.createElement("div");
document.body.appendChild(container);
Expand All @@ -122,7 +123,7 @@ function renderMesh(
return () =>
h(PolyCamera, {}, {
default: () =>
h(PolyScene, {}, {
h(PolyScene, sceneProps, {
default: () => h(PolyMesh, meshProps, slots),
}),
});
Expand Down Expand Up @@ -151,6 +152,21 @@ describe("PolyMesh (Vue) — with polygons prop", () => {
expect(polys.length).toBe(2);
});

it("inherits scene strategies.disable b for auto-rendered rects", () => {
vi.stubGlobal("CSS", {
supports: vi.fn((property: string) => property === "border-shape"),
});
const { container } = renderMesh(
{ polygons: [QUAD] },
{},
{ strategies: { disable: ["b"] } },
);
const poly = container.querySelector("i") as HTMLElement | null;
expect(container.querySelector("b")).toBeNull();
expect(poly).toBeTruthy();
expect(poly!.style.getPropertyValue("border-shape")).toContain("polygon(");
});

it("hoists repeated baked solid paint to the mesh wrapper", () => {
const { container } = renderMesh({ polygons: [TRIANGLE, TRIANGLE] });
const mesh = container.querySelector(".polycss-mesh") as HTMLElement;
Expand Down
Loading
Loading