Skip to content

Commit 12a015b

Browse files
authored
Add STL mesh parsing and gallery imports (#59)
* feat(core): optimize parsed meshes by default * refactor(website): use core mesh optimization * chore(bench): add gltf simplifier corpus benchmark * perf(core): speed up parse mesh optimization * fix(renderer): move corner-shape triangle paint to base CSS * fix(polycss): share mesh hit testing * bench(renderer): add rect-cover optimizer variants * feat(core): add STL mesh parsing * fix(controls): allow vertical orbit rollover * feat(website): add STL gallery imports * docs: document STL mesh support * chore(compat): extend hunter to STL corpora * chore(website): remove oversized STL presets --------- Co-authored-by: agustin-littlehat <minotopo@gmail.com>
1 parent 64434db commit 12a015b

65 files changed

Lines changed: 110359 additions & 113 deletions

Some content is hidden

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

.agents/skills/compat-hunter/SKILL.md

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,8 @@ Use this skill when the user wants to keep digging for parser compatibility issu
2727
3. Treat these as known non-actionable unless the user asks to support them:
2828
- glTF POINTS/LINES/LINE_LOOP/LINE_STRIP primitives.
2929
- Required Draco or meshopt compressed primitives skipped with a warning.
30+
- 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.
31+
- Empty/corrupt STL containers with no complete triangle records or no valid ASCII facets.
3032

3133
4. Stop and inspect anything classified as:
3234
- `throw`
@@ -80,6 +82,20 @@ Keep known-warning files too:
8082
pnpm compat-hunter -- --keep-known --max-models 500
8183
```
8284

85+
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/`.
86+
87+
Avoid repeating the same shuffled queue:
88+
89+
```bash
90+
pnpm compat-hunter -- --sources thingi10k --exts stl --max-models 5000 --seed "$(date +%s)" --queue-offset 5000
91+
```
92+
93+
Skip models already attempted by prior reports:
94+
95+
```bash
96+
pnpm compat-hunter -- --sources thingi10k --exts stl --max-models 5000 --skip-report bench/results/<previous-run>/report.json
97+
```
98+
8399
Continue after interesting cases:
84100

85101
```bash

.agents/skills/compat-hunter/scripts/compat-hunter.mjs

Lines changed: 385 additions & 25 deletions
Large diffs are not rendered by default.

AGENTS.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -55,7 +55,7 @@ All solid/atlas tags work in both modes. The `.vox` direct-matrix fast path is b
5555
### Meshing implications (what generators must respect)
5656

5757
- **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.
58-
- **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.
58+
- **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.
5959
- **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`:
6060
- axis-aligned rectangle = 1.0 (and hits the fastest path)
6161
- right-isosceles triangle = 0.5
@@ -73,7 +73,7 @@ The current exception is imported skeletal animation. glTF/GLB skinning changes
7373
| Where JS runs | Where JS does NOT run |
7474
|---|---|
7575
| Scene construction (`createPolyScene`, mesh ops, vertex snapping) | Per-frame polygon paint |
76-
| OBJ/glTF/GLB import, mesh optimisation, coplanar merging | Per-frame Lambert evaluation (dynamic mode is pure CSS) |
76+
| OBJ/STL/glTF/GLB import, mesh optimisation, coplanar merging | Per-frame Lambert evaluation (dynamic mode is pure CSS) |
7777
| Atlas planning + rasterisation (one-shot to `<canvas>`, then `toBlob`) | Per-frame atlas redraw (only on baked-mode light changes) |
7878
| 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 |
7979
| Camera math (matrix4 product → scene-root `transform` CSS var) | Per-polygon JS in any hot path |

README.md

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
# PolyCSS
22

3-
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.
3+
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.
44

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

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

8888
### Controls
@@ -160,6 +160,7 @@ scene.add(mesh);
160160
Supported formats:
161161

162162
- OBJ + MTL, including `map_Kd` textures and UV coordinates.
163+
- 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.
163164
- glTF / GLB, including embedded images and `TEXCOORD_0`.
164165
- MagicaVoxel `.vox`, with direct voxel fast paths when eligible.
165166
- Generated primitives: box, plane, ring, sphere, torus, cylinder, cone, and Platonic solids.

bench/stl-corpus-sanity.mjs

Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,76 @@
1+
#!/usr/bin/env node
2+
import { existsSync, readFileSync } from "node:fs";
3+
import { resolve } from "node:path";
4+
import { pathToFileURL } from "node:url";
5+
6+
const root = resolve(import.meta.dirname, "..");
7+
const manifestPath = resolve(root, "bench/results/stl-samples/manifest.json");
8+
const coreDistPath = resolve(root, "packages/core/dist/index.js");
9+
10+
if (!existsSync(manifestPath)) {
11+
throw new Error("Missing bench/results/stl-samples/manifest.json. Download the STL sample set first.");
12+
}
13+
14+
if (!existsSync(coreDistPath)) {
15+
throw new Error("Missing packages/core/dist/index.js. Run `pnpm --filter @layoutit/polycss-core build` first.");
16+
}
17+
18+
const { parseStl, optimizeMeshParseResult } = await import(pathToFileURL(coreDistPath).href);
19+
const manifest = JSON.parse(readFileSync(manifestPath, "utf8"));
20+
21+
function detectStlFormat(bytes) {
22+
if (bytes.byteLength >= 84) {
23+
const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength);
24+
const triangleCount = view.getUint32(80, true);
25+
if (84 + triangleCount * 50 === bytes.byteLength) return "binary";
26+
}
27+
return "ascii";
28+
}
29+
30+
function disposableResult(polygons, warnings, metadata) {
31+
return {
32+
polygons,
33+
objectUrls: [],
34+
dispose() {},
35+
warnings,
36+
metadata,
37+
};
38+
}
39+
40+
const rows = [];
41+
for (const item of manifest) {
42+
const filePath = resolve(root, "bench/results/stl-samples", item.file);
43+
const bytes = readFileSync(filePath);
44+
const input = bytes.buffer.slice(bytes.byteOffset, bytes.byteOffset + bytes.byteLength);
45+
const parsed = parseStl(input);
46+
const lossless = optimizeMeshParseResult(
47+
disposableResult(parsed.polygons, parsed.warnings, parsed.metadata),
48+
{ meshResolution: "lossless" },
49+
);
50+
const lossy = optimizeMeshParseResult(
51+
disposableResult(parsed.polygons, parsed.warnings, parsed.metadata),
52+
{ meshResolution: "lossy" },
53+
);
54+
55+
rows.push({
56+
file: item.file,
57+
format: detectStlFormat(bytes),
58+
sourceFacets: item.num_facets,
59+
emittedPolygons: parsed.polygons.length,
60+
losslessLeaves: lossless.polygons.length,
61+
lossyLeaves: lossy.polygons.length,
62+
warnings: parsed.warnings,
63+
});
64+
}
65+
66+
console.table(rows.map((row) => ({
67+
file: row.file,
68+
format: row.format,
69+
facets: row.sourceFacets,
70+
emitted: row.emittedPolygons,
71+
lossless: row.losslessLeaves,
72+
lossy: row.lossyLeaves,
73+
warnings: row.warnings.length,
74+
})));
75+
76+
console.log(JSON.stringify(rows, null, 2));

package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,7 @@
3838
"bench:minecraft-movement": "pnpm --filter @layoutit/polycss-examples-vanilla build && node bench/minecraft-movement-bench.mjs",
3939
"bench:lossy": "pnpm --filter @layoutit/polycss-core build && node bench/lossy-optimizer-bench.mjs",
4040
"bench:lossy:corpus": "pnpm --filter @layoutit/polycss-core build && node bench/lossy-corpus-bench.mjs",
41+
"bench:stl-samples": "pnpm --filter @layoutit/polycss-core build && node bench/stl-corpus-sanity.mjs",
4142
"compat-hunter": "pnpm --filter @layoutit/polycss-core build && node .agents/skills/compat-hunter/scripts/compat-hunter.mjs",
4243
"bench:seams": "pnpm --filter @layoutit/polycss-core build && node bench/seam-gap-bench.mjs",
4344
"bench:seams:render": "pnpm --filter @layoutit/polycss-core build && node bench/seam-gap-bench.mjs --render",

packages/core/README.md

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44

55
# PolyCSS
66

7-
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.
7+
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.
88

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

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

105105
### Controls
@@ -165,6 +165,7 @@ scene.add(mesh);
165165
Supported formats:
166166

167167
- OBJ + MTL, including `map_Kd` textures and UV coordinates.
168+
- 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.
168169
- glTF / GLB, including embedded images and `TEXCOORD_0`.
169170
- MagicaVoxel `.vox`, with direct voxel fast paths when eligible.
170171
- Generated primitives: box, plane, ring, sphere, torus, cylinder, cone, and Platonic solids.

packages/core/src/index.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -187,6 +187,9 @@ export type {
187187
ParseAnimationController,
188188
PolyVoxelCell,
189189
PolyVoxelSource,
190+
ParseStlColor,
191+
ParseStlSolid,
192+
ParseStlTopology,
190193
ParseResult,
191194
} from "./parser/types";
192195
export { parseObj } from "./parser/parseObj";
@@ -202,6 +205,8 @@ export {
202205
export type { SolidTextureSampleOptions } from "./parser/solidTextureSamples";
203206
export { parseVox } from "./parser/parseVox";
204207
export type { VoxParseOptions } from "./parser/parseVox";
208+
export { parseStl } from "./parser/parseStl";
209+
export type { StlParseOptions } from "./parser/parseStl";
205210
export { loadMesh } from "./parser/loadMesh";
206211
export type { LoadMeshOptions } from "./parser/loadMesh";
207212

packages/core/src/merge/optimizePolygons.ts

Lines changed: 25 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -219,6 +219,7 @@ export interface OptimizeStaticSimplificationOptions {
219219
export interface OptimizeParseMeshPolygonsOptions extends OptimizeMeshPolygonsOptions {
220220
staticSimplification?: OptimizeStaticSimplificationOptions | false;
221221
useCandidateFirst?: boolean;
222+
skipInteriorCull?: boolean;
222223
}
223224

224225
interface StaticSimplificationPlan {
@@ -341,8 +342,12 @@ export function optimizeParseMeshPolygons(
341342
rectCover: options.rectCover,
342343
};
343344
const graph = new MeshOptimizationArtifactGraph();
344-
return graph.workspaceFor(polygons, { captureVisiblePolygons: true })
345-
.createRun(optimizeOptions, { captureVisiblePolygons: true })
345+
const runOptions: OptimizeMeshPolygonsRunOptions = {
346+
captureVisiblePolygons: true,
347+
skipInteriorCull: options.skipInteriorCull === true,
348+
};
349+
return graph.workspaceFor(polygons, runOptions)
350+
.createRun(optimizeOptions, runOptions)
346351
.optimizeParse({
347352
staticSimplification: options.staticSimplification,
348353
useCandidateFirst: options.useCandidateFirst === true,
@@ -872,8 +877,11 @@ class MeshCandidateAcceptor {
872877
const gain = this.bestCost - candidateCost;
873878
if (gain <= 0) return false;
874879
if (gain < minGain) return false;
875-
const candidateSeam = seamOverlapSafetyDiagnostics(candidate);
876-
if (seamDiagnosticsWorse(candidateSeam, this.bestSeamDiagnostics())) return false;
880+
const candidateSeam = trySeamOverlapSafetyDiagnostics(candidate);
881+
if (!candidateSeam) return false;
882+
const baselineSeam = this.bestSeamDiagnostics();
883+
if (!baselineSeam) return false;
884+
if (seamDiagnosticsWorse(candidateSeam, baselineSeam)) return false;
877885
if (topologyGapDiagnosticsWorse(
878886
this.bestTopologyEdges(),
879887
this.bestTopologySelfDiagnostics(),
@@ -898,10 +906,12 @@ class MeshCandidateAcceptor {
898906
this.bestDiagnostics = { polygons: this.best, seam };
899907
}
900908

901-
private bestSeamDiagnostics(): SeamOverlapDiagnostics {
909+
private bestSeamDiagnostics(): SeamOverlapDiagnostics | null {
902910
if (this.bestDiagnostics.polygons !== this.best) this.resetBestDiagnostics();
903911
if (!this.bestDiagnostics.seam) {
904-
this.bestDiagnostics.seam = seamOverlapSafetyDiagnostics(this.best);
912+
const seam = trySeamOverlapSafetyDiagnostics(this.best);
913+
if (!seam) return null;
914+
this.bestDiagnostics.seam = seam;
905915
}
906916
return this.bestDiagnostics.seam;
907917
}
@@ -934,6 +944,15 @@ function polygonRenderCost(polygons: Polygon[]): number {
934944
return cost;
935945
}
936946

947+
function trySeamOverlapSafetyDiagnostics(polygons: Polygon[]): SeamOverlapDiagnostics | null {
948+
try {
949+
return seamOverlapSafetyDiagnostics(polygons);
950+
} catch (error) {
951+
if (error instanceof RangeError && error.message === "Set maximum size exceeded") return null;
952+
throw error;
953+
}
954+
}
955+
937956
function seamDiagnosticsWorse(
938957
candidate: SeamOverlapDiagnostics,
939958
baseline: SeamOverlapDiagnostics,

packages/core/src/parser/loadMesh.test.ts

Lines changed: 59 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -177,6 +177,28 @@ function buildMinimalGlb(): ArrayBuffer {
177177
return buf;
178178
}
179179

180+
function buildMinimalStl(): ArrayBuffer {
181+
const buf = new ArrayBuffer(84 + 50);
182+
const view = new DataView(buf);
183+
const bytes = new Uint8Array(buf);
184+
const header = "solid minimal binary stl";
185+
for (let i = 0; i < header.length; i += 1) bytes[i] = header.charCodeAt(i);
186+
view.setUint32(80, 1, true);
187+
let off = 84;
188+
for (const value of [0, 0, 1]) {
189+
view.setFloat32(off, value, true);
190+
off += 4;
191+
}
192+
for (const vertex of [[0, 0, 0], [1, 0, 0], [0, 1, 0]]) {
193+
for (const value of vertex) {
194+
view.setFloat32(off, value, true);
195+
off += 4;
196+
}
197+
}
198+
view.setUint16(off, 0, true);
199+
return buf;
200+
}
201+
180202
afterEach(() => {
181203
vi.unstubAllGlobals();
182204
});
@@ -506,6 +528,42 @@ describe("loadMesh", () => {
506528
});
507529
});
508530

531+
describe(".stl dispatch", () => {
532+
it("fetches .stl URL as arrayBuffer and dispatches to parseStl", async () => {
533+
const stlBuf = buildMinimalStl();
534+
const fetchMock = makeMockFetch({ arrayBuffer: stlBuf });
535+
vi.stubGlobal("fetch", fetchMock);
536+
537+
const result = await loadMesh("model.stl");
538+
expect(fetchMock).toHaveBeenCalledWith("model.stl");
539+
expect(result).toHaveProperty("polygons");
540+
expect(result).toHaveProperty("dispose");
541+
expect(result.polygons.length).toBeGreaterThan(0);
542+
});
543+
544+
it("passes stlOptions to parseStl", async () => {
545+
vi.stubGlobal("fetch", makeMockFetch({ arrayBuffer: buildMinimalStl() }));
546+
547+
const result = await loadMesh("model.stl", {
548+
stlOptions: { targetSize: 10, gridShift: 2, defaultColor: "#123456" },
549+
meshResolution: "lossless",
550+
});
551+
552+
expect(result.polygons).toHaveLength(1);
553+
expect(result.polygons[0].vertices).toEqual([
554+
[2, 2, 2],
555+
[12, 2, 2],
556+
[2, 12, 2],
557+
]);
558+
expect(result.polygons[0].color).toBe("#123456");
559+
});
560+
561+
it("throws when fetch returns !ok for .stl", async () => {
562+
vi.stubGlobal("fetch", makeMockFetch({ ok: false, status: 404 }));
563+
await expect(loadMesh("missing.stl")).rejects.toThrow("404");
564+
});
565+
});
566+
509567
describe(".mtl rejection", () => {
510568
it("throws for .mtl URLs without fetching", async () => {
511569
const fetchMock = vi.fn();
@@ -521,7 +579,7 @@ describe("loadMesh", () => {
521579
describe("unknown extension", () => {
522580
it("throws for unknown extension", async () => {
523581
vi.stubGlobal("fetch", makeMockFetch({}));
524-
await expect(loadMesh("model.stl")).rejects.toThrow("unsupported extension");
582+
await expect(loadMesh("model.ply")).rejects.toThrow("unsupported extension");
525583
});
526584

527585
it("throws for extension-less URL", async () => {

0 commit comments

Comments
 (0)