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
10 changes: 6 additions & 4 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -31,10 +31,10 @@ Voxel-shaped meshes are the exception to "all polygons stay mounted": meshes wit

| Tag | Strategy | When chosen | Paint mechanism | Atlas memory |
|---|---|---|---|---|
| `<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 |
| `<b>` | **Quads** | Axis-aligned rectangle, or untextured convex quad when the homography passes stability guards on non-Safari engines | `background: currentColor` on a fixed 256px 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 256px 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. 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 |
| `<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 256px 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 the same 256px border-triangle primitive to avoid large-perspective compositor banding. Exact corner-shape solids use a bare fixed 256px 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 Expand Up @@ -86,7 +86,7 @@ If you find yourself wanting a `requestAnimationFrame` loop to update many DOM n
- **Hooks/composables:** `usePolyCamera`, `usePolyMesh`, `usePolySceneContext`, `usePolySelect`, `usePolySelectionApi`, `usePolyAnimation`.
- **Components:** `PolyPerspectiveCamera`, `PolyOrthographicCamera`, `PolyOrbitControls`, `PolyMapControls`, `PolyTransformControls`, `PolySelect`, `PolyAxesHelper`, `PolyDirectionalLightHelper`.
- **Types:** `PolyDirectionalLight`, `PolyAmbientLight`, `PolyTextureLightingMode`, `PolyAnimationMixer`, `PolyRenderStats`.
- **Functions:** `findPolyMeshHandle`, `injectPolyBaseStyles`, `collectPolyRenderStats`.
- **Functions:** `findPolyMeshHandle`, `injectPolyBaseStyles`, `collectPolyRenderStats`, `exportPolySceneSnapshot`.
- **Vanilla factories:** `create*` names stay as-is (`createPolyScene`, `createTransformControls`, `createSelect`).
- **HTML custom elements:** `poly-` prefix + kebab-case. Existing tags: `<poly-scene>`, `<poly-mesh>`, `<poly-polygon>`, `<poly-perspective-camera>`, `<poly-orthographic-camera>`, `<poly-axes-helper>`, `<poly-directional-light-helper>`. Any new element follows the same shape (e.g. `<poly-transform-controls>`, `<poly-select>`).
- **Leaf DOM tags (`<b>`, `<i>`, `<s>`, `<u>`):** internal render-strategy tags. Not part of the public API and not user-facing — do not document them as such.
Expand All @@ -98,6 +98,8 @@ The React and Vue packages are mirror images. **Any public API change in one mus

When you change `packages/polycss` or `packages/core` in a way that affects the public surface (new option, renamed export, changed default), the React and Vue bindings update in the same PR. Don't ship a PolyCSS change that leaves the bindings stale.

The DOM snapshot exporter is the current exception to mirrored React/Vue public exports: `exportPolySceneSnapshot` lives in `@layoutit/polycss` because it is browser DOM serialization, not component API. React/Vue callers import it from `@layoutit/polycss` and pass the rendered `.polycss-camera` / `.polycss-scene` element.

**Renderer-owned browser glue.** The canvas atlas pipeline (`buildAtlasPages` + helpers), browser-feature detection (`isBorderShapeSupported`, `isSolidTriangleSupported`, `resolveSolidTrianglePrimitive`), direct voxel renderer (`voxelRenderer.ts`), and injected `.polycss-scene` / `.polycss-camera` base styles exist as **independent copies** across the three renderers. This includes `packages/polycss/src/render/atlas/`, `packages/react/src/scene/atlas/`, `packages/vue/src/scene/atlas/`, the three renderer-local `voxelRenderer.ts` files, and the three sibling `styles.ts` files. This is deliberate — each renderer is self-contained on its dep graph (React/Vue do not import from the `polycss` package). The trade-off is that a bug fix in any of these files MUST be mirrored into the other two. Coverage is pinned per copy by the co-located test files.

Before opening a PR:
Expand Down
12 changes: 12 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -93,6 +93,18 @@ export default function App() {
- `<PolyFirstPersonControls>` provides keyboard and pointer-look navigation.
- `<PolyTransformControls>` adds translate/rotate gizmos for selected mesh handles.

### Snapshot Export

The vanilla package exports `exportPolySceneSnapshot(target)`. It clones the current rendered `.polycss-camera` / `.polycss-scene` DOM, injects only the PolyCSS CSS needed by that snapshot, inlines CSS `url(...)` image assets as `data:image/...;base64,...`, strips scripts and inline event handlers, and returns a standalone HTML document string with no PolyCSS runtime import. It works with rendered React/Vue scenes too; import it from `@layoutit/polycss` and pass the rendered camera or scene element.

```ts
import { exportPolySceneSnapshot } from "@layoutit/polycss";

const html = await exportPolySceneSnapshot(scene.host);
```

If any referenced asset cannot be inlined, the function throws `PolySceneSnapshotError` with `code: "ASSET_INLINE_FAILED"`.

### Polygon Data Model

Each polygon describes one renderable face:
Expand Down
10 changes: 4 additions & 6 deletions packages/core/src/atlas/constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -44,16 +44,14 @@ export const DEFAULT_MATRIX_DECIMALS = 3;
export const DEFAULT_BORDER_SHAPE_DECIMALS = 2;
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 SOLID_QUAD_CANONICAL_SIZE = 256;
export const SOLID_TRIANGLE_CANONICAL_SIZE = 256;
export const SOLID_TRIANGLE_LARGE_BORDER_CANONICAL_SIZE = 256;
export const ATLAS_CANONICAL_SIZE_EXPLICIT = 64;
export const ATLAS_CANONICAL_SIZE_AUTO_DESKTOP = 128;
export const BORDER_SHAPE_CENTER_PERCENT = 50;
export const BORDER_SHAPE_POINT_EPS = 1e-7;
export const BORDER_SHAPE_CANONICAL_SIZE = 16;
export const BORDER_SHAPE_CANONICAL_SIZE = 256;
export const BORDER_SHAPE_BLEED = 0.9;
export const CORNER_SHAPE_POINT_EPS = 0.75;
export const CORNER_SHAPE_DUPLICATE_EPS = 0.2;
Expand Down
9 changes: 4 additions & 5 deletions packages/core/src/atlas/matrix.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -116,7 +116,7 @@ const FLAT_RECT: Polygon = {
color: "#00ff00",
};

describe("formatSolidQuadEntryMatrix — canonical 64px quad wrap", () => {
describe("formatSolidQuadEntryMatrix — canonical quad wrap", () => {
it("returns a matrix3d(...) wrapped string", () => {
const plan = computeTextureAtlasPlanPublic(FLAT_RECT, 0)!;
const result = formatSolidQuadEntryMatrix(plan);
Expand Down Expand Up @@ -147,7 +147,7 @@ describe("formatSolidQuadEntryMatrix — canonical 64px quad wrap", () => {
});

// ---------------------------------------------------------------------------
// formatBorderShapeEntryMatrix — canonical 16px border-shape wrap
// formatBorderShapeEntryMatrix — canonical border-shape wrap
// ---------------------------------------------------------------------------

const NON_RECT_POLYGON: Polygon = {
Expand All @@ -160,7 +160,7 @@ const NON_RECT_POLYGON: Polygon = {
color: "#0000ff",
};

describe("formatBorderShapeEntryMatrix — canonical 16px border-shape wrap", () => {
describe("formatBorderShapeEntryMatrix — canonical border-shape wrap", () => {
it("returns a matrix3d(...) wrapped string", () => {
const plan = computeTextureAtlasPlanPublic(NON_RECT_POLYGON, 0)!;
const result = formatBorderShapeEntryMatrix(plan);
Expand All @@ -183,11 +183,10 @@ describe("formatBorderShapeEntryMatrix — canonical 16px border-shape wrap", ()
expect(values.every(Number.isFinite)).toBe(true);
});

it("solid-quad and border-shape matrices differ due to different canonical sizes (64px vs 16px)", () => {
it("solid-quad and border-shape matrices differ because border-shape uses clipped bounds", () => {
const plan = computeTextureAtlasPlanPublic(NON_RECT_POLYGON, 0)!;
const quadMatrix = formatSolidQuadEntryMatrix(plan);
const borderMatrix = formatBorderShapeEntryMatrix(plan);
// Border-shape canonical size is 16, solid-quad is 64 — scale differs by 4x
expect(quadMatrix).not.toBe(borderMatrix);
});
});
Expand Down
2 changes: 1 addition & 1 deletion packages/core/src/atlas/matrix.ts
Original file line number Diff line number Diff line change
Expand Up @@ -221,7 +221,7 @@ export function formatCssLengthPx(value: number, decimals = DEFAULT_ATLAS_CSS_DE

/**
* Produce the CSS matrix3d transform for a solid-quad (`<b>`) leaf, including
* the canonical 64px primitive scale.
* the canonical primitive scale.
*/
export function formatSolidQuadEntryMatrix(entry: TextureAtlasPlan): string {
return `matrix3d(${formatSolidQuadMatrix(entry)})`;
Expand Down
2 changes: 0 additions & 2 deletions packages/core/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -234,8 +234,6 @@ export {
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
12 changes: 12 additions & 0 deletions packages/polycss/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -109,6 +109,18 @@ import { PolyCamera, PolyScene, PolyOrbitControls, PolyMesh } from "@layoutit/po
- `<PolyFirstPersonControls>` provides keyboard and pointer-look navigation.
- `<PolyTransformControls>` adds translate/rotate gizmos for selected mesh handles.

### Snapshot Export

The vanilla package exports `exportPolySceneSnapshot(target)`. It clones the current rendered `.polycss-camera` / `.polycss-scene` DOM, injects only the PolyCSS CSS needed by that snapshot, inlines CSS `url(...)` image assets as `data:image/...;base64,...`, strips scripts and inline event handlers, and returns a standalone HTML document string with no PolyCSS runtime import. It works with rendered React/Vue scenes too; import it from `@layoutit/polycss` and pass the rendered camera or scene element.

```ts
import { exportPolySceneSnapshot } from "@layoutit/polycss";

const html = await exportPolySceneSnapshot(scene.host);
```

If any referenced asset cannot be inlined, the function throws `PolySceneSnapshotError` with `code: "ASSET_INLINE_FAILED"`.

### Polygon Data Model

Each polygon describes one renderable face:
Expand Down
4 changes: 2 additions & 2 deletions packages/polycss/src/api/createPolyCamera.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,9 +17,9 @@ describe("createPolyPerspectiveCamera", () => {
expect(cam.type).toBe("perspective");
});

it("returns default perspectiveStyle of '8000px'", () => {
it("returns default perspectiveStyle of '32000px'", () => {
const cam = createPolyPerspectiveCamera();
expect(cam.perspectiveStyle).toBe("8000px");
expect(cam.perspectiveStyle).toBe("32000px");
});

it("accepts a custom perspective value", () => {
Expand Down
6 changes: 3 additions & 3 deletions packages/polycss/src/api/createPolyCamera.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@
import { createIsometricCamera } from "@layoutit/polycss-core";
import type { CameraHandle, CameraState, CameraStyleInput, Vec3 } from "@layoutit/polycss-core";

const DEFAULT_PERSPECTIVE = 8000;
const DEFAULT_PERSPECTIVE = 32000;

export interface PolyCameraOptions {
zoom?: number;
Expand All @@ -25,7 +25,7 @@ export interface PolyCameraOptions {
}

export interface PolyPerspectiveCameraOptions extends PolyCameraOptions {
/** CSS perspective distance in pixels. Default 8000. */
/** CSS perspective distance in pixels. Default 32000. */
perspective?: number;
}

Expand All @@ -47,7 +47,7 @@ export interface PolyOrthographicCameraHandle extends CameraHandle {
/**
* Creates a perspective camera handle. The `perspectiveStyle` property
* returns the CSS value to apply to the camera container's `perspective`
* property (default `"8000px"`).
* property (default `"32000px"`).
*/
export function createPolyPerspectiveCamera(
options: PolyPerspectiveCameraOptions = {},
Expand Down
25 changes: 15 additions & 10 deletions packages/polycss/src/api/createPolyScene.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -398,8 +398,9 @@ describe("createPolyScene", () => {
const cameraEl = host.querySelector(".polycss-camera") as HTMLElement;
const sceneEl = host.querySelector(".polycss-scene") as HTMLElement;
const transform = sceneEl.style.transform;
// Perspective lives on the .polycss-camera wrapper, not on .polycss-scene.
expect(cameraEl.style.perspective).toBe("750px");
// Perspective stays the configured camera depth; CSS zoom only affects
// the scene geometry transform compensation.
expect(cameraEl.style.perspective).toBe("1500px");
expect(sceneEl.style.getPropertyValue("zoom")).toBe("2");
expect(transform).toContain("translateZ(-50px)");
expect(transform).toContain("scale(1)");
Expand All @@ -425,11 +426,11 @@ describe("createPolyScene", () => {
expect(styleEl?.textContent).toContain("transform-origin: 0 0");
expect(styleEl?.textContent).toContain("backface-visibility: hidden");
expect(styleEl?.textContent).toContain("background-repeat: no-repeat");
expect(styleEl?.textContent).toContain("width: 64px;");
expect(styleEl?.textContent).toContain("height: 64px;");
expect(styleEl?.textContent).toContain("width: 256px;");
expect(styleEl?.textContent).toContain("height: 256px;");
expect(styleEl?.textContent).toContain("width: var(--polycss-atlas-size, 64px);");
expect(styleEl?.textContent).toContain("height: var(--polycss-atlas-size, 64px);");
expect(styleEl?.textContent).toContain("border-width: 0 16px 32px 16px;");
expect(styleEl?.textContent).toContain("border-width: 0 128px 256px 128px;");
expect(styleEl?.textContent).toContain("width: 0;");
expect(styleEl?.textContent).toContain("height: 0;");
});
Expand Down Expand Up @@ -496,7 +497,9 @@ describe("createPolyScene", () => {
expect(brush!.style.color).toMatch(/^(#123456|rgb\\(18, 52, 86\\))$/);
expect(brush!.style.width).toBe("");
expect(brush!.style.height).toBe("");
expect(brush!.style.transform).toContain("matrix3d(50,0,0,0,0,50");
const matrix = matrixValues(brush!);
expect(matrix[0]).toBeCloseTo(50, 3);
expect(matrix[5]).toBeCloseTo(50, 3);
});

it("adds tiny overscan to same-color shared direct voxel edges", () => {
Expand All @@ -513,7 +516,7 @@ describe("createPolyScene", () => {
expect(brushes.length).toBeGreaterThan(0);
const matrices = brushes.map(matrixValues);
expect(matrices.some((values) =>
values.some((value) => Math.abs(value - 50.6) <= 1e-6)
values.some((value) => Math.abs(value - 50.6) <= 1e-4)
)).toBe(true);
});

Expand All @@ -531,7 +534,7 @@ describe("createPolyScene", () => {
expect(brushes.length).toBeGreaterThan(0);
const matrices = brushes.map(matrixValues);
expect(matrices.some((values) =>
values.some((value) => Math.abs(value - 50.6) <= 1e-6)
values.some((value) => Math.abs(value - 50.6) <= 1e-4)
)).toBe(true);
});

Expand Down Expand Up @@ -584,7 +587,9 @@ describe("createPolyScene", () => {
expect(wrapper!.style.getPropertyValue("--polycss-voxel-primitive")).toBe("8px");
expect(brush!.style.width).toBe("");
expect(brush!.style.height).toBe("");
expect(brush!.style.transform).toContain("matrix3d(6.25,0,0,0,0,6.25");
const matrix = matrixValues(brush!);
expect(matrix[0]).toBeCloseTo(6.25, 3);
expect(matrix[5]).toBeCloseTo(6.25, 3);
} finally {
Object.defineProperty(window, "matchMedia", {
configurable: true,
Expand Down Expand Up @@ -1097,7 +1102,7 @@ describe("createPolyScene", () => {
expect(poly).not.toBeNull();
expect(poly.tagName.toLowerCase()).toBe("u");
expect(poly.style.transform).toContain("matrix3d(");
expect(poly.style.borderBottomWidth).toBe("");
expect(poly.className).toBe("");
});

describe("rebakeAtlas", () => {
Expand Down
Loading
Loading