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
16 changes: 16 additions & 0 deletions .agents/skills/compat-hunter/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,8 @@ Use this skill when the user wants to keep digging for parser compatibility issu
3. Treat these as known non-actionable unless the user asks to support them:
- glTF POINTS/LINES/LINE_LOOP/LINE_STRIP primitives.
- Required Draco or meshopt compressed primitives skipped with a warning.
- STL source-quality diagnostics that the parser already handles: degenerate triangles, repaired winding, component orientation, non-manifold/shared-edge topology, supplied-normal mismatches, malformed normals/facets, overdeclared binary triangle counts, trailing binary bytes, and ignored non-Magics binary attribute bytes.
- Empty/corrupt STL containers with no complete triangle records or no valid ASCII facets.

4. Stop and inspect anything classified as:
- `throw`
Expand Down Expand Up @@ -80,6 +82,20 @@ Keep known-warning files too:
pnpm compat-hunter -- --keep-known --max-models 500
```

For STL hunts, `--keep-known` keeps warning-only models under `known/` and the report includes `warningCategoriesByKind` plus `stlDiagnostics` on retained rows. Unknown STL warning text, throws, zero-polygon outputs, and suspicious DOM collapses remain `interesting/`.

Avoid repeating the same shuffled queue:

```bash
pnpm compat-hunter -- --sources thingi10k --exts stl --max-models 5000 --seed "$(date +%s)" --queue-offset 5000
```

Skip models already attempted by prior reports:

```bash
pnpm compat-hunter -- --sources thingi10k --exts stl --max-models 5000 --skip-report bench/results/<previous-run>/report.json
```

Continue after interesting cases:

```bash
Expand Down
410 changes: 385 additions & 25 deletions .agents/skills/compat-hunter/scripts/compat-hunter.mjs

Large diffs are not rendered by default.

4 changes: 2 additions & 2 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -55,7 +55,7 @@ All solid/atlas tags work in both modes. The `.vox` direct-matrix fast path is b
### Meshing implications (what generators must respect)

- **Polygon count is the dominant cost.** Each polygon is one DOM node, one `matrix3d`, one paint. Halving the polygon count is almost always worth a more complex mesher.
- **Lossy optimization favors low DOM render cost.** The default `"lossy"` `loadMesh` / core import path first bakes solid texture swatches, merges visually redundant baked swatch colors, and tries endpoint-preserving static triangle simplification for eligible non-animated meshes. It then scores exact and approximate merge candidates by estimated render cost and keeps the cheapest direct candidate. Static simplification has a relaxed seam-key pass plus a stricter source-vertex fallback, and is accepted only when the final optimized DOM leaf count is lower than the baseline optimizer result. The polygon optimizer can also try a more aggressive triangle-pair merge candidate inside the same boundary displacement budget, but accepts it only when the render-cost win is material and whole-mesh seam diagnostics do not get worse. It avoids per-candidate seam-repair passes in the import path; targeted seam repair remains a lower-level helper for explicit repair workflows.
- **Lossy optimization favors low DOM render cost.** The default `"lossy"` `loadMesh` / core import path first bakes solid texture swatches, merges visually redundant baked swatch colors, and tries endpoint-preserving static triangle simplification for eligible non-animated meshes. It then scores exact and approximate merge candidates by estimated render cost and keeps the cheapest direct candidate. Static simplification has a relaxed seam-key pass plus a stricter source-vertex fallback, and is accepted only when the final optimized DOM leaf count is lower than the baseline optimizer result. The polygon optimizer can also try a more aggressive triangle-pair merge candidate inside the same boundary displacement budget, but accepts it only when the render-cost win is material and whole-mesh seam diagnostics do not get worse. STL parse results are the conservative exception: they keep the lossless optimizer path and skip ray-based interior culling because public CAD/STL corpora frequently contain shell, winding, or topology quirks where false-positive culling is a visible data-loss bug. It avoids per-candidate seam-repair passes in the import path; targeted seam repair remains a lower-level helper for explicit repair workflows.
- **Fill ratio matters.** A textured polygon's atlas slice equals its local-2D bounding rect. Empty space inside that slice is wasted bitmap pixels. Prefer shapes with high `area / boundingRect.area`:
- axis-aligned rectangle = 1.0 (and hits the fastest path)
- right-isosceles triangle = 0.5
Expand All @@ -73,7 +73,7 @@ The current exception is imported skeletal animation. glTF/GLB skinning changes
| Where JS runs | Where JS does NOT run |
|---|---|
| Scene construction (`createPolyScene`, mesh ops, vertex snapping) | Per-frame polygon paint |
| OBJ/glTF/GLB import, mesh optimisation, coplanar merging | Per-frame Lambert evaluation (dynamic mode is pure CSS) |
| OBJ/STL/glTF/GLB import, mesh optimisation, coplanar merging | Per-frame Lambert evaluation (dynamic mode is pure CSS) |
| Atlas planning + rasterisation (one-shot to `<canvas>`, then `toBlob`) | Per-frame atlas redraw (only on baked-mode light changes) |
| Control input handling (`PolyOrbitControls`, `PolyMapControls`, `PolyTransformControls`) | Per-frame transform recomputation of every polygon for camera/mesh motion — only the scene-root or mesh-root transform changes |
| Camera math (matrix4 product → scene-root `transform` CSS var) | Per-polygon JS in any hot path |
Expand Down
5 changes: 3 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
# PolyCSS

A CSS polygon mesh library. A 3D engine for the DOM. Renders OBJ/MTL, GLB and VOX as real HTML elements transformed with CSS `matrix3d(...)`. Supports colors, textures, lighting, shadows, shapes and animations. Works with React, Vue or plain JavaScript.
A CSS polygon mesh library. A 3D engine for the DOM. Renders OBJ/MTL, STL, glTF/GLB, and VOX as real HTML elements transformed with CSS `matrix3d(...)`. Supports colors, textures, lighting, shadows, shapes and animations. Works with React, Vue or plain JavaScript.

Visit [polycss.com](https://polycss.com) for docs and model examples.

Expand Down Expand Up @@ -82,7 +82,7 @@ export default function App() {
- `polygons` accepts pre-parsed geometry.
- `position`, `scale`, and `rotation` transform the mesh wrapper.
- `autoCenter` shifts the mesh bbox center to local origin.
- `meshResolution` chooses `"lossy"` (default) or `"lossless"` optimization.
- `meshResolution` chooses `"lossy"` (default) or `"lossless"` optimization. STL imports use the conservative lossless path in both modes.
- `castShadow` emits CSS-projected shadows in dynamic lighting mode.

### Controls
Expand Down Expand Up @@ -160,6 +160,7 @@ scene.add(mesh);
Supported formats:

- OBJ + MTL, including `map_Kd` textures and UV coordinates.
- STL triangle meshes, including binary Magics face colors. STL has no standard units, textures, UVs, or hierarchy, so imports skip lossy simplification and ray-based interior culling.
- glTF / GLB, including embedded images and `TEXCOORD_0`.
- MagicaVoxel `.vox`, with direct voxel fast paths when eligible.
- Generated primitives: box, plane, ring, sphere, torus, cylinder, cone, and Platonic solids.
Expand Down
76 changes: 76 additions & 0 deletions bench/stl-corpus-sanity.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
#!/usr/bin/env node
import { existsSync, readFileSync } from "node:fs";
import { resolve } from "node:path";
import { pathToFileURL } from "node:url";

const root = resolve(import.meta.dirname, "..");
const manifestPath = resolve(root, "bench/results/stl-samples/manifest.json");
const coreDistPath = resolve(root, "packages/core/dist/index.js");

if (!existsSync(manifestPath)) {
throw new Error("Missing bench/results/stl-samples/manifest.json. Download the STL sample set first.");
}

if (!existsSync(coreDistPath)) {
throw new Error("Missing packages/core/dist/index.js. Run `pnpm --filter @layoutit/polycss-core build` first.");
}

const { parseStl, optimizeMeshParseResult } = await import(pathToFileURL(coreDistPath).href);
const manifest = JSON.parse(readFileSync(manifestPath, "utf8"));

function detectStlFormat(bytes) {
if (bytes.byteLength >= 84) {
const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength);
const triangleCount = view.getUint32(80, true);
if (84 + triangleCount * 50 === bytes.byteLength) return "binary";
}
return "ascii";
}

function disposableResult(polygons, warnings, metadata) {
return {
polygons,
objectUrls: [],
dispose() {},
warnings,
metadata,
};
}

const rows = [];
for (const item of manifest) {
const filePath = resolve(root, "bench/results/stl-samples", item.file);
const bytes = readFileSync(filePath);
const input = bytes.buffer.slice(bytes.byteOffset, bytes.byteOffset + bytes.byteLength);
const parsed = parseStl(input);
const lossless = optimizeMeshParseResult(
disposableResult(parsed.polygons, parsed.warnings, parsed.metadata),
{ meshResolution: "lossless" },
);
const lossy = optimizeMeshParseResult(
disposableResult(parsed.polygons, parsed.warnings, parsed.metadata),
{ meshResolution: "lossy" },
);

rows.push({
file: item.file,
format: detectStlFormat(bytes),
sourceFacets: item.num_facets,
emittedPolygons: parsed.polygons.length,
losslessLeaves: lossless.polygons.length,
lossyLeaves: lossy.polygons.length,
warnings: parsed.warnings,
});
}

console.table(rows.map((row) => ({
file: row.file,
format: row.format,
facets: row.sourceFacets,
emitted: row.emittedPolygons,
lossless: row.losslessLeaves,
lossy: row.lossyLeaves,
warnings: row.warnings.length,
})));

console.log(JSON.stringify(rows, null, 2));
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@
"bench:minecraft-movement": "pnpm --filter @layoutit/polycss-examples-vanilla build && node bench/minecraft-movement-bench.mjs",
"bench:lossy": "pnpm --filter @layoutit/polycss-core build && node bench/lossy-optimizer-bench.mjs",
"bench:lossy:corpus": "pnpm --filter @layoutit/polycss-core build && node bench/lossy-corpus-bench.mjs",
"bench:stl-samples": "pnpm --filter @layoutit/polycss-core build && node bench/stl-corpus-sanity.mjs",
"compat-hunter": "pnpm --filter @layoutit/polycss-core build && node .agents/skills/compat-hunter/scripts/compat-hunter.mjs",
"bench:seams": "pnpm --filter @layoutit/polycss-core build && node bench/seam-gap-bench.mjs",
"bench:seams:render": "pnpm --filter @layoutit/polycss-core build && node bench/seam-gap-bench.mjs --render",
Expand Down
5 changes: 3 additions & 2 deletions packages/core/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@

# PolyCSS

A CSS polygon mesh engine. A 3D renderer for the DOM. Renders OBJ, glTF, GLB, MagicaVoxel `.vox`, and generated primitives as real HTML elements transformed with CSS `matrix3d(...)`. Supports colors, textures, lighting, shadows, controls, selection, animation, and per-polygon interaction. Works with React, Vue, custom elements, or plain JavaScript.
A CSS polygon mesh engine. A 3D renderer for the DOM. Renders OBJ, STL, glTF, GLB, MagicaVoxel `.vox`, and generated primitives as real HTML elements transformed with CSS `matrix3d(...)`. Supports colors, textures, lighting, shadows, controls, selection, animation, and per-polygon interaction. Works with React, Vue, custom elements, or plain JavaScript.

Visit [polycss.com](https://polycss.com) for docs and model examples.

Expand Down Expand Up @@ -99,7 +99,7 @@ import { PolyCamera, PolyScene, PolyOrbitControls, PolyMesh } from "@layoutit/po
- `polygons` accepts pre-parsed geometry.
- `position`, `scale`, and `rotation` transform the mesh wrapper.
- `autoCenter` shifts the mesh bbox center to local origin.
- `meshResolution` chooses `"lossy"` (default) or `"lossless"` optimization. Lossy also applies bounded seam repair.
- `meshResolution` chooses `"lossy"` (default) or `"lossless"` optimization. Lossy also applies bounded seam repair; STL imports use the conservative lossless path in both modes.
- `castShadow` emits CSS-projected shadows in dynamic lighting mode.

### Controls
Expand Down Expand Up @@ -165,6 +165,7 @@ scene.add(mesh);
Supported formats:

- OBJ + MTL, including `map_Kd` textures and UV coordinates.
- STL triangle meshes, including binary Magics face colors. STL has no standard units, textures, UVs, or hierarchy, so imports skip lossy simplification and ray-based interior culling.
- glTF / GLB, including embedded images and `TEXCOORD_0`.
- MagicaVoxel `.vox`, with direct voxel fast paths when eligible.
- Generated primitives: box, plane, ring, sphere, torus, cylinder, cone, and Platonic solids.
Expand Down
5 changes: 5 additions & 0 deletions packages/core/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -187,6 +187,9 @@ export type {
ParseAnimationController,
PolyVoxelCell,
PolyVoxelSource,
ParseStlColor,
ParseStlSolid,
ParseStlTopology,
ParseResult,
} from "./parser/types";
export { parseObj } from "./parser/parseObj";
Expand All @@ -202,6 +205,8 @@ export {
export type { SolidTextureSampleOptions } from "./parser/solidTextureSamples";
export { parseVox } from "./parser/parseVox";
export type { VoxParseOptions } from "./parser/parseVox";
export { parseStl } from "./parser/parseStl";
export type { StlParseOptions } from "./parser/parseStl";
export { loadMesh } from "./parser/loadMesh";
export type { LoadMeshOptions } from "./parser/loadMesh";

Expand Down
31 changes: 25 additions & 6 deletions packages/core/src/merge/optimizePolygons.ts
Original file line number Diff line number Diff line change
Expand Up @@ -219,6 +219,7 @@ export interface OptimizeStaticSimplificationOptions {
export interface OptimizeParseMeshPolygonsOptions extends OptimizeMeshPolygonsOptions {
staticSimplification?: OptimizeStaticSimplificationOptions | false;
useCandidateFirst?: boolean;
skipInteriorCull?: boolean;
}

interface StaticSimplificationPlan {
Expand Down Expand Up @@ -341,8 +342,12 @@ export function optimizeParseMeshPolygons(
rectCover: options.rectCover,
};
const graph = new MeshOptimizationArtifactGraph();
return graph.workspaceFor(polygons, { captureVisiblePolygons: true })
.createRun(optimizeOptions, { captureVisiblePolygons: true })
const runOptions: OptimizeMeshPolygonsRunOptions = {
captureVisiblePolygons: true,
skipInteriorCull: options.skipInteriorCull === true,
};
return graph.workspaceFor(polygons, runOptions)
.createRun(optimizeOptions, runOptions)
.optimizeParse({
staticSimplification: options.staticSimplification,
useCandidateFirst: options.useCandidateFirst === true,
Expand Down Expand Up @@ -872,8 +877,11 @@ class MeshCandidateAcceptor {
const gain = this.bestCost - candidateCost;
if (gain <= 0) return false;
if (gain < minGain) return false;
const candidateSeam = seamOverlapSafetyDiagnostics(candidate);
if (seamDiagnosticsWorse(candidateSeam, this.bestSeamDiagnostics())) return false;
const candidateSeam = trySeamOverlapSafetyDiagnostics(candidate);
if (!candidateSeam) return false;
const baselineSeam = this.bestSeamDiagnostics();
if (!baselineSeam) return false;
if (seamDiagnosticsWorse(candidateSeam, baselineSeam)) return false;
if (topologyGapDiagnosticsWorse(
this.bestTopologyEdges(),
this.bestTopologySelfDiagnostics(),
Expand All @@ -898,10 +906,12 @@ class MeshCandidateAcceptor {
this.bestDiagnostics = { polygons: this.best, seam };
}

private bestSeamDiagnostics(): SeamOverlapDiagnostics {
private bestSeamDiagnostics(): SeamOverlapDiagnostics | null {
if (this.bestDiagnostics.polygons !== this.best) this.resetBestDiagnostics();
if (!this.bestDiagnostics.seam) {
this.bestDiagnostics.seam = seamOverlapSafetyDiagnostics(this.best);
const seam = trySeamOverlapSafetyDiagnostics(this.best);
if (!seam) return null;
this.bestDiagnostics.seam = seam;
}
return this.bestDiagnostics.seam;
}
Expand Down Expand Up @@ -934,6 +944,15 @@ function polygonRenderCost(polygons: Polygon[]): number {
return cost;
}

function trySeamOverlapSafetyDiagnostics(polygons: Polygon[]): SeamOverlapDiagnostics | null {
try {
return seamOverlapSafetyDiagnostics(polygons);
} catch (error) {
if (error instanceof RangeError && error.message === "Set maximum size exceeded") return null;
throw error;
}
}

function seamDiagnosticsWorse(
candidate: SeamOverlapDiagnostics,
baseline: SeamOverlapDiagnostics,
Expand Down
60 changes: 59 additions & 1 deletion packages/core/src/parser/loadMesh.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -177,6 +177,28 @@ function buildMinimalGlb(): ArrayBuffer {
return buf;
}

function buildMinimalStl(): ArrayBuffer {
const buf = new ArrayBuffer(84 + 50);
const view = new DataView(buf);
const bytes = new Uint8Array(buf);
const header = "solid minimal binary stl";
for (let i = 0; i < header.length; i += 1) bytes[i] = header.charCodeAt(i);
view.setUint32(80, 1, true);
let off = 84;
for (const value of [0, 0, 1]) {
view.setFloat32(off, value, true);
off += 4;
}
for (const vertex of [[0, 0, 0], [1, 0, 0], [0, 1, 0]]) {
for (const value of vertex) {
view.setFloat32(off, value, true);
off += 4;
}
}
view.setUint16(off, 0, true);
return buf;
}

afterEach(() => {
vi.unstubAllGlobals();
});
Expand Down Expand Up @@ -506,6 +528,42 @@ describe("loadMesh", () => {
});
});

describe(".stl dispatch", () => {
it("fetches .stl URL as arrayBuffer and dispatches to parseStl", async () => {
const stlBuf = buildMinimalStl();
const fetchMock = makeMockFetch({ arrayBuffer: stlBuf });
vi.stubGlobal("fetch", fetchMock);

const result = await loadMesh("model.stl");
expect(fetchMock).toHaveBeenCalledWith("model.stl");
expect(result).toHaveProperty("polygons");
expect(result).toHaveProperty("dispose");
expect(result.polygons.length).toBeGreaterThan(0);
});

it("passes stlOptions to parseStl", async () => {
vi.stubGlobal("fetch", makeMockFetch({ arrayBuffer: buildMinimalStl() }));

const result = await loadMesh("model.stl", {
stlOptions: { targetSize: 10, gridShift: 2, defaultColor: "#123456" },
meshResolution: "lossless",
});

expect(result.polygons).toHaveLength(1);
expect(result.polygons[0].vertices).toEqual([
[2, 2, 2],
[12, 2, 2],
[2, 12, 2],
]);
expect(result.polygons[0].color).toBe("#123456");
});

it("throws when fetch returns !ok for .stl", async () => {
vi.stubGlobal("fetch", makeMockFetch({ ok: false, status: 404 }));
await expect(loadMesh("missing.stl")).rejects.toThrow("404");
});
});

describe(".mtl rejection", () => {
it("throws for .mtl URLs without fetching", async () => {
const fetchMock = vi.fn();
Expand All @@ -521,7 +579,7 @@ describe("loadMesh", () => {
describe("unknown extension", () => {
it("throws for unknown extension", async () => {
vi.stubGlobal("fetch", makeMockFetch({}));
await expect(loadMesh("model.stl")).rejects.toThrow("unsupported extension");
await expect(loadMesh("model.ply")).rejects.toThrow("unsupported extension");
});

it("throws for extension-less URL", async () => {
Expand Down
Loading
Loading