Skip to content

Commit 201c8bf

Browse files
authored
Improve solid primitives and snapshot export (#41)
* fix(renderer): normalize solid primitive sizes * fix(website): isolate fpv perspective camera * fix(renderer): inline solid triangle primitive paint * feat(camera): raise default perspective depth * feat(snapshot): export standalone scene snapshots * feat(website): add CodePen snapshot export * fix(renderer): keep perspective independent of css zoom --------- Co-authored-by: agustin-littlehat <minotopo@gmail.com>
1 parent 0aed504 commit 201c8bf

63 files changed

Lines changed: 1705 additions & 208 deletions

Some content is hidden

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

AGENTS.md

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -31,10 +31,10 @@ Voxel-shaped meshes are the exception to "all polygons stay mounted": meshes wit
3131

3232
| Tag | Strategy | When chosen | Paint mechanism | Atlas memory |
3333
|---|---|---|---|---|
34-
| `<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 |
35-
| `<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 |
34+
| `<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 |
35+
| `<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 |
3636
| `<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 |
37-
| `<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 |
37+
| `<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 |
3838
| `<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 |
3939

4040
Strategies are ordered cheapest → most expensive. The mesher's job is to maximise `<b>` / `<u>` / `<i>` and minimise `<s>` (see "Meshing implications" below).
@@ -86,7 +86,7 @@ If you find yourself wanting a `requestAnimationFrame` loop to update many DOM n
8686
- **Hooks/composables:** `usePolyCamera`, `usePolyMesh`, `usePolySceneContext`, `usePolySelect`, `usePolySelectionApi`, `usePolyAnimation`.
8787
- **Components:** `PolyPerspectiveCamera`, `PolyOrthographicCamera`, `PolyOrbitControls`, `PolyMapControls`, `PolyTransformControls`, `PolySelect`, `PolyAxesHelper`, `PolyDirectionalLightHelper`.
8888
- **Types:** `PolyDirectionalLight`, `PolyAmbientLight`, `PolyTextureLightingMode`, `PolyAnimationMixer`, `PolyRenderStats`.
89-
- **Functions:** `findPolyMeshHandle`, `injectPolyBaseStyles`, `collectPolyRenderStats`.
89+
- **Functions:** `findPolyMeshHandle`, `injectPolyBaseStyles`, `collectPolyRenderStats`, `exportPolySceneSnapshot`.
9090
- **Vanilla factories:** `create*` names stay as-is (`createPolyScene`, `createTransformControls`, `createSelect`).
9191
- **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>`).
9292
- **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.
@@ -98,6 +98,8 @@ The React and Vue packages are mirror images. **Any public API change in one mus
9898

9999
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.
100100

101+
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.
102+
101103
**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.
102104

103105
Before opening a PR:

README.md

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -93,6 +93,18 @@ export default function App() {
9393
- `<PolyFirstPersonControls>` provides keyboard and pointer-look navigation.
9494
- `<PolyTransformControls>` adds translate/rotate gizmos for selected mesh handles.
9595

96+
### Snapshot Export
97+
98+
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.
99+
100+
```ts
101+
import { exportPolySceneSnapshot } from "@layoutit/polycss";
102+
103+
const html = await exportPolySceneSnapshot(scene.host);
104+
```
105+
106+
If any referenced asset cannot be inlined, the function throws `PolySceneSnapshotError` with `code: "ASSET_INLINE_FAILED"`.
107+
96108
### Polygon Data Model
97109

98110
Each polygon describes one renderable face:

packages/core/src/atlas/constants.ts

Lines changed: 4 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -44,16 +44,14 @@ export const DEFAULT_MATRIX_DECIMALS = 3;
4444
export const DEFAULT_BORDER_SHAPE_DECIMALS = 2;
4545
export const DEFAULT_ATLAS_CSS_DECIMALS = 4;
4646
export const DECIMAL_SCALES = [1, 10, 100, 1000, 10000, 100000, 1000000];
47-
export const SOLID_QUAD_CANONICAL_SIZE = 64;
48-
export const SOLID_TRIANGLE_CANONICAL_SIZE = 32;
49-
export const SOLID_TRIANGLE_LARGE_BORDER_CANONICAL_SIZE = 96;
50-
export const SOLID_TRIANGLE_CORNER_CLASS = "polycss-corner-triangle";
51-
export const SOLID_TRIANGLE_LARGE_BORDER_CLASS = "polycss-large-border-triangle";
47+
export const SOLID_QUAD_CANONICAL_SIZE = 256;
48+
export const SOLID_TRIANGLE_CANONICAL_SIZE = 256;
49+
export const SOLID_TRIANGLE_LARGE_BORDER_CANONICAL_SIZE = 256;
5250
export const ATLAS_CANONICAL_SIZE_EXPLICIT = 64;
5351
export const ATLAS_CANONICAL_SIZE_AUTO_DESKTOP = 128;
5452
export const BORDER_SHAPE_CENTER_PERCENT = 50;
5553
export const BORDER_SHAPE_POINT_EPS = 1e-7;
56-
export const BORDER_SHAPE_CANONICAL_SIZE = 16;
54+
export const BORDER_SHAPE_CANONICAL_SIZE = 256;
5755
export const BORDER_SHAPE_BLEED = 0.9;
5856
export const CORNER_SHAPE_POINT_EPS = 0.75;
5957
export const CORNER_SHAPE_DUPLICATE_EPS = 0.2;

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

Lines changed: 4 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -116,7 +116,7 @@ const FLAT_RECT: Polygon = {
116116
color: "#00ff00",
117117
};
118118

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

149149
// ---------------------------------------------------------------------------
150-
// formatBorderShapeEntryMatrix — canonical 16px border-shape wrap
150+
// formatBorderShapeEntryMatrix — canonical border-shape wrap
151151
// ---------------------------------------------------------------------------
152152

153153
const NON_RECT_POLYGON: Polygon = {
@@ -160,7 +160,7 @@ const NON_RECT_POLYGON: Polygon = {
160160
color: "#0000ff",
161161
};
162162

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

186-
it("solid-quad and border-shape matrices differ due to different canonical sizes (64px vs 16px)", () => {
186+
it("solid-quad and border-shape matrices differ because border-shape uses clipped bounds", () => {
187187
const plan = computeTextureAtlasPlanPublic(NON_RECT_POLYGON, 0)!;
188188
const quadMatrix = formatSolidQuadEntryMatrix(plan);
189189
const borderMatrix = formatBorderShapeEntryMatrix(plan);
190-
// Border-shape canonical size is 16, solid-quad is 64 — scale differs by 4x
191190
expect(quadMatrix).not.toBe(borderMatrix);
192191
});
193192
});

packages/core/src/atlas/matrix.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -221,7 +221,7 @@ export function formatCssLengthPx(value: number, decimals = DEFAULT_ATLAS_CSS_DE
221221

222222
/**
223223
* Produce the CSS matrix3d transform for a solid-quad (`<b>`) leaf, including
224-
* the canonical 64px primitive scale.
224+
* the canonical primitive scale.
225225
*/
226226
export function formatSolidQuadEntryMatrix(entry: TextureAtlasPlan): string {
227227
return `matrix3d(${formatSolidQuadMatrix(entry)})`;

packages/core/src/index.ts

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -234,8 +234,6 @@ export {
234234
SOLID_QUAD_CANONICAL_SIZE,
235235
SOLID_TRIANGLE_CANONICAL_SIZE,
236236
SOLID_TRIANGLE_LARGE_BORDER_CANONICAL_SIZE,
237-
SOLID_TRIANGLE_CORNER_CLASS,
238-
SOLID_TRIANGLE_LARGE_BORDER_CLASS,
239237
ATLAS_CANONICAL_SIZE_EXPLICIT,
240238
ATLAS_CANONICAL_SIZE_AUTO_DESKTOP,
241239
BORDER_SHAPE_CENTER_PERCENT,

packages/polycss/README.md

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -109,6 +109,18 @@ import { PolyCamera, PolyScene, PolyOrbitControls, PolyMesh } from "@layoutit/po
109109
- `<PolyFirstPersonControls>` provides keyboard and pointer-look navigation.
110110
- `<PolyTransformControls>` adds translate/rotate gizmos for selected mesh handles.
111111

112+
### Snapshot Export
113+
114+
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.
115+
116+
```ts
117+
import { exportPolySceneSnapshot } from "@layoutit/polycss";
118+
119+
const html = await exportPolySceneSnapshot(scene.host);
120+
```
121+
122+
If any referenced asset cannot be inlined, the function throws `PolySceneSnapshotError` with `code: "ASSET_INLINE_FAILED"`.
123+
112124
### Polygon Data Model
113125

114126
Each polygon describes one renderable face:

packages/polycss/src/api/createPolyCamera.test.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -17,9 +17,9 @@ describe("createPolyPerspectiveCamera", () => {
1717
expect(cam.type).toBe("perspective");
1818
});
1919

20-
it("returns default perspectiveStyle of '8000px'", () => {
20+
it("returns default perspectiveStyle of '32000px'", () => {
2121
const cam = createPolyPerspectiveCamera();
22-
expect(cam.perspectiveStyle).toBe("8000px");
22+
expect(cam.perspectiveStyle).toBe("32000px");
2323
});
2424

2525
it("accepts a custom perspective value", () => {

packages/polycss/src/api/createPolyCamera.ts

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@
1313
import { createIsometricCamera } from "@layoutit/polycss-core";
1414
import type { CameraHandle, CameraState, CameraStyleInput, Vec3 } from "@layoutit/polycss-core";
1515

16-
const DEFAULT_PERSPECTIVE = 8000;
16+
const DEFAULT_PERSPECTIVE = 32000;
1717

1818
export interface PolyCameraOptions {
1919
zoom?: number;
@@ -25,7 +25,7 @@ export interface PolyCameraOptions {
2525
}
2626

2727
export interface PolyPerspectiveCameraOptions extends PolyCameraOptions {
28-
/** CSS perspective distance in pixels. Default 8000. */
28+
/** CSS perspective distance in pixels. Default 32000. */
2929
perspective?: number;
3030
}
3131

@@ -47,7 +47,7 @@ export interface PolyOrthographicCameraHandle extends CameraHandle {
4747
/**
4848
* Creates a perspective camera handle. The `perspectiveStyle` property
4949
* returns the CSS value to apply to the camera container's `perspective`
50-
* property (default `"8000px"`).
50+
* property (default `"32000px"`).
5151
*/
5252
export function createPolyPerspectiveCamera(
5353
options: PolyPerspectiveCameraOptions = {},

packages/polycss/src/api/createPolyScene.test.ts

Lines changed: 15 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -398,8 +398,9 @@ describe("createPolyScene", () => {
398398
const cameraEl = host.querySelector(".polycss-camera") as HTMLElement;
399399
const sceneEl = host.querySelector(".polycss-scene") as HTMLElement;
400400
const transform = sceneEl.style.transform;
401-
// Perspective lives on the .polycss-camera wrapper, not on .polycss-scene.
402-
expect(cameraEl.style.perspective).toBe("750px");
401+
// Perspective stays the configured camera depth; CSS zoom only affects
402+
// the scene geometry transform compensation.
403+
expect(cameraEl.style.perspective).toBe("1500px");
403404
expect(sceneEl.style.getPropertyValue("zoom")).toBe("2");
404405
expect(transform).toContain("translateZ(-50px)");
405406
expect(transform).toContain("scale(1)");
@@ -425,11 +426,11 @@ describe("createPolyScene", () => {
425426
expect(styleEl?.textContent).toContain("transform-origin: 0 0");
426427
expect(styleEl?.textContent).toContain("backface-visibility: hidden");
427428
expect(styleEl?.textContent).toContain("background-repeat: no-repeat");
428-
expect(styleEl?.textContent).toContain("width: 64px;");
429-
expect(styleEl?.textContent).toContain("height: 64px;");
429+
expect(styleEl?.textContent).toContain("width: 256px;");
430+
expect(styleEl?.textContent).toContain("height: 256px;");
430431
expect(styleEl?.textContent).toContain("width: var(--polycss-atlas-size, 64px);");
431432
expect(styleEl?.textContent).toContain("height: var(--polycss-atlas-size, 64px);");
432-
expect(styleEl?.textContent).toContain("border-width: 0 16px 32px 16px;");
433+
expect(styleEl?.textContent).toContain("border-width: 0 128px 256px 128px;");
433434
expect(styleEl?.textContent).toContain("width: 0;");
434435
expect(styleEl?.textContent).toContain("height: 0;");
435436
});
@@ -496,7 +497,9 @@ describe("createPolyScene", () => {
496497
expect(brush!.style.color).toMatch(/^(#123456|rgb\\(18, 52, 86\\))$/);
497498
expect(brush!.style.width).toBe("");
498499
expect(brush!.style.height).toBe("");
499-
expect(brush!.style.transform).toContain("matrix3d(50,0,0,0,0,50");
500+
const matrix = matrixValues(brush!);
501+
expect(matrix[0]).toBeCloseTo(50, 3);
502+
expect(matrix[5]).toBeCloseTo(50, 3);
500503
});
501504

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

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

@@ -584,7 +587,9 @@ describe("createPolyScene", () => {
584587
expect(wrapper!.style.getPropertyValue("--polycss-voxel-primitive")).toBe("8px");
585588
expect(brush!.style.width).toBe("");
586589
expect(brush!.style.height).toBe("");
587-
expect(brush!.style.transform).toContain("matrix3d(6.25,0,0,0,0,6.25");
590+
const matrix = matrixValues(brush!);
591+
expect(matrix[0]).toBeCloseTo(6.25, 3);
592+
expect(matrix[5]).toBeCloseTo(6.25, 3);
588593
} finally {
589594
Object.defineProperty(window, "matchMedia", {
590595
configurable: true,
@@ -1097,7 +1102,7 @@ describe("createPolyScene", () => {
10971102
expect(poly).not.toBeNull();
10981103
expect(poly.tagName.toLowerCase()).toBe("u");
10991104
expect(poly.style.transform).toContain("matrix3d(");
1100-
expect(poly.style.borderBottomWidth).toBe("");
1105+
expect(poly.className).toBe("");
11011106
});
11021107

11031108
describe("rebakeAtlas", () => {

0 commit comments

Comments
 (0)