@chestnutlabs/gcode-model-renderer 0.6.0 → 0.8.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +61 -0
- package/dist/index.d.ts +1 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/render-model-still.d.ts +6 -0
- package/dist/render-model-still.d.ts.map +1 -1
- package/dist/render-model-still.js +4 -4
- package/dist/three-mf.d.ts +10 -2
- package/dist/three-mf.d.ts.map +1 -1
- package/dist/three-mf.js +337 -137
- package/package.json +4 -4
package/README.md
ADDED
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
# @chestnutlabs/gcode-model-renderer
|
|
2
|
+
|
|
3
|
+
Three.js **presentation** renderer for source models — draws an `.stl` or `.3mf` mesh as a clean,
|
|
4
|
+
studio-lit thumbnail. It answers *"what object is this?"*, and is deliberately **separate** from the
|
|
5
|
+
toolpath renderer, which answers *"how does this print/cut run?"* (design: DD-018).
|
|
6
|
+
|
|
7
|
+
> **Peer dependency:** `three` (supported range `^0.178.0`) — install it alongside this package.
|
|
8
|
+
|
|
9
|
+

|
|
10
|
+
|
|
11
|
+
Use it for file-browser thumbnails, library cards, and "what's in this file" previews — the picture a
|
|
12
|
+
slicer shows of a part, not the toolpath. For inspecting the actual moves (layers, travel, seams,
|
|
13
|
+
color modes, live progress), use [`@chestnutlabs/gcode-renderer-three`](../gcode-renderer-three)
|
|
14
|
+
instead.
|
|
15
|
+
|
|
16
|
+
- **STL and 3MF** — a bare STL is a single object with no declared material; a 3MF brings its own
|
|
17
|
+
multi-object structure, per-object transforms, and per-object / per-triangle material colors,
|
|
18
|
+
including files that use the 3MF **Production Extension** (`p:path` external parts).
|
|
19
|
+
- **Production multicolor** — real Bambu Studio / OrcaSlicer files paint per-region color with a
|
|
20
|
+
proprietary `paint_color` facet attribute and keep the palette in `project_settings.config`, not in
|
|
21
|
+
standard 3MF materials. The renderer decodes that facet-paint format (clean-room, see
|
|
22
|
+
[RR-005](../../docs/research/RR-005-3mf-paint-color-facet-format.md)) and reads the `filament_colour`
|
|
23
|
+
palette itself, so a designer's multicolor model renders in its true colors without slicing.
|
|
24
|
+
- **Capability-honest color** — when the source declares colors — standard 3MF `basematerials`, or
|
|
25
|
+
`paint_color` + a filament palette — the render uses them and reports `materials: 'known'`
|
|
26
|
+
(`'approximated'` when a handful of multi-color facets are flattened). When it declares none — a
|
|
27
|
+
plain STL, or a paint format with no palette present — it draws a neutral default and reports
|
|
28
|
+
`materials: 'unavailable'`. It never invents a source color.
|
|
29
|
+
- **Fixed presentation pose** — framed at a 3/4 angle on the shared render "stage" from
|
|
30
|
+
`@chestnutlabs/gcode-renderer-three`, under a neutral studio light rig, on a transparent (default)
|
|
31
|
+
or solid background you can composite onto a card.
|
|
32
|
+
- **Headless still** — `renderModelStill` mirrors the toolpath side's `renderStill`: hand it bytes,
|
|
33
|
+
get back a canvas plus a stable `cacheKey` and the `materials` confidence for that render. Runs in
|
|
34
|
+
any Chromium-class WebGL2 context (an `OffscreenCanvas` in a Worker, or headless Chromium).
|
|
35
|
+
- **Three-free public types** — `ModelScene` / `ModelObject` / `MeshGeometry` are plain typed arrays,
|
|
36
|
+
so the package's surface never leaks `three`; the renderer builds three meshes internally.
|
|
37
|
+
|
|
38
|
+
```ts
|
|
39
|
+
import { renderModelStill } from '@chestnutlabs/gcode-model-renderer';
|
|
40
|
+
|
|
41
|
+
// In a Worker with an OffscreenCanvas, or headless Chromium:
|
|
42
|
+
const { canvas, objectCount, materials, cacheKey } = await renderModelStill(
|
|
43
|
+
{ kind: '3mf', bytes }, // or { kind: 'stl', bytes }
|
|
44
|
+
{ canvas: new OffscreenCanvas(512, 512), background: 'transparent' }
|
|
45
|
+
);
|
|
46
|
+
// materials === 'known' → the render used colors the file declared
|
|
47
|
+
// materials === 'unavailable' → the file carried none; a neutral default was used (never faked)
|
|
48
|
+
const blob = await canvas.convertToBlob();
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
Already hold a corrected or richer filament palette (e.g. re-rendering a sliced file)? Pass
|
|
52
|
+
`filamentPalette` (hex per 0-based slot) to `renderModelStill` / `parse3mf` to override the one read
|
|
53
|
+
from `project_settings.config`. Optional — the renderer reads the file's own palette without it.
|
|
54
|
+
|
|
55
|
+
Want to build your own scene or drive the renderer interactively? `parseStl` / `parse3mf` return a
|
|
56
|
+
three-free `ModelScene`, and `ModelRenderer` renders one onto a canvas you own.
|
|
57
|
+
|
|
58
|
+
Determinism: same input + same environment ⇒ identical output. Cross-GPU/driver pixel identity is not
|
|
59
|
+
promised — cache by the returned `cacheKey`.
|
|
60
|
+
|
|
61
|
+
Part of [Chestnut Labs G-code Preview](../../README.md) · MIT
|
package/dist/index.d.ts
CHANGED
|
@@ -10,6 +10,7 @@ export { renderModelStill } from './render-model-still.js';
|
|
|
10
10
|
export type { ModelSource, RenderModelStillOptions, RenderModelStillResult } from './render-model-still.js';
|
|
11
11
|
export { parseStl } from './stl.js';
|
|
12
12
|
export { parse3mf } from './three-mf.js';
|
|
13
|
+
export type { Parse3mfOptions } from './three-mf.js';
|
|
13
14
|
export { computeCacheKey, defaultEnvId } from './cache-key.js';
|
|
14
15
|
export { DEFAULT_LIMITS, resolveLimits, ModelParseError } from './limits.js';
|
|
15
16
|
export type { ModelLimits, ResolvedLimits } from './limits.js';
|
package/dist/index.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA;;;;;GAKG;AACH,OAAO,EAAE,aAAa,EAAE,sBAAsB,EAAE,MAAM,qBAAqB,CAAC;AAC5E,YAAY,EAAE,oBAAoB,EAAE,eAAe,EAAE,gBAAgB,EAAE,MAAM,qBAAqB,CAAC;AACnG,OAAO,EAAE,gBAAgB,EAAE,MAAM,yBAAyB,CAAC;AAC3D,YAAY,EAAE,WAAW,EAAE,uBAAuB,EAAE,sBAAsB,EAAE,MAAM,yBAAyB,CAAC;AAC5G,OAAO,EAAE,QAAQ,EAAE,MAAM,UAAU,CAAC;AACpC,OAAO,EAAE,QAAQ,EAAE,MAAM,eAAe,CAAC;AACzC,OAAO,EAAE,eAAe,EAAE,YAAY,EAAE,MAAM,gBAAgB,CAAC;AAC/D,OAAO,EAAE,cAAc,EAAE,aAAa,EAAE,eAAe,EAAE,MAAM,aAAa,CAAC;AAC7E,YAAY,EAAE,WAAW,EAAE,cAAc,EAAE,MAAM,aAAa,CAAC;AAC/D,OAAO,EAAE,aAAa,EAAE,MAAM,kBAAkB,CAAC;AACjD,YAAY,EAAE,UAAU,EAAE,WAAW,EAAE,aAAa,EAAE,YAAY,EAAE,WAAW,EAAE,GAAG,EAAE,IAAI,EAAE,MAAM,kBAAkB,CAAC"}
|
|
1
|
+
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA;;;;;GAKG;AACH,OAAO,EAAE,aAAa,EAAE,sBAAsB,EAAE,MAAM,qBAAqB,CAAC;AAC5E,YAAY,EAAE,oBAAoB,EAAE,eAAe,EAAE,gBAAgB,EAAE,MAAM,qBAAqB,CAAC;AACnG,OAAO,EAAE,gBAAgB,EAAE,MAAM,yBAAyB,CAAC;AAC3D,YAAY,EAAE,WAAW,EAAE,uBAAuB,EAAE,sBAAsB,EAAE,MAAM,yBAAyB,CAAC;AAC5G,OAAO,EAAE,QAAQ,EAAE,MAAM,UAAU,CAAC;AACpC,OAAO,EAAE,QAAQ,EAAE,MAAM,eAAe,CAAC;AACzC,YAAY,EAAE,eAAe,EAAE,MAAM,eAAe,CAAC;AACrD,OAAO,EAAE,eAAe,EAAE,YAAY,EAAE,MAAM,gBAAgB,CAAC;AAC/D,OAAO,EAAE,cAAc,EAAE,aAAa,EAAE,eAAe,EAAE,MAAM,aAAa,CAAC;AAC7E,YAAY,EAAE,WAAW,EAAE,cAAc,EAAE,MAAM,aAAa,CAAC;AAC/D,OAAO,EAAE,aAAa,EAAE,MAAM,kBAAkB,CAAC;AACjD,YAAY,EAAE,UAAU,EAAE,WAAW,EAAE,aAAa,EAAE,YAAY,EAAE,WAAW,EAAE,GAAG,EAAE,IAAI,EAAE,MAAM,kBAAkB,CAAC"}
|
|
@@ -33,6 +33,12 @@ export interface RenderModelStillOptions {
|
|
|
33
33
|
view?: PresentationView;
|
|
34
34
|
createRenderer?: (canvas: RenderTargetCanvas) => GLRendererLike;
|
|
35
35
|
limits?: ModelLimits;
|
|
36
|
+
/**
|
|
37
|
+
* Override the 3MF source-model filament palette (hex `#RRGGBB` per 0-based slot) used to colour
|
|
38
|
+
* `paint_color` facets, instead of the file's own `project_settings.config`. Niche: for consumers
|
|
39
|
+
* re-rendering a file with a corrected/richer palette. Ignored for STL and pre-built scenes.
|
|
40
|
+
*/
|
|
41
|
+
filamentPalette?: readonly (string | undefined)[];
|
|
36
42
|
/** Override the environment id folded into the cache key (default: the `three` revision). */
|
|
37
43
|
envId?: string;
|
|
38
44
|
}
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"render-model-still.d.ts","sourceRoot":"","sources":["../src/render-model-still.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;GAWG;AACH,OAAO,KAAK,EAAE,UAAU,EAAE,MAAM,6BAA6B,CAAC;AAC9D,OAAO,KAAK,EAAE,cAAc,EAAE,kBAAkB,EAAE,MAAM,oCAAoC,CAAC;AAC7F,OAAO,EAAiB,KAAK,eAAe,EAAE,KAAK,gBAAgB,EAAE,MAAM,qBAAqB,CAAC;AAGjG,OAAO,KAAK,EAAE,UAAU,EAAE,MAAM,kBAAkB,CAAC;AAEnD,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,aAAa,CAAC;AAE/C,yEAAyE;AACzE,MAAM,MAAM,WAAW,GACnB;IAAE,IAAI,EAAE,KAAK,CAAC;IAAC,KAAK,EAAE,UAAU,GAAG,WAAW,CAAA;CAAE,GAChD;IAAE,IAAI,EAAE,KAAK,CAAC;IAAC,KAAK,EAAE,UAAU,GAAG,WAAW,CAAA;CAAE,GAChD,UAAU,CAAC;AAEf,MAAM,WAAW,uBAAuB;IACtC,MAAM,EAAE,kBAAkB,CAAC;IAC3B,KAAK,CAAC,EAAE,MAAM,CAAC;IACf,MAAM,CAAC,EAAE,MAAM,CAAC;IAChB,mGAAmG;IACnG,UAAU,CAAC,EAAE,eAAe,CAAC;IAC7B,qCAAqC;IACrC,IAAI,CAAC,EAAE,gBAAgB,CAAC;IACxB,cAAc,CAAC,EAAE,CAAC,MAAM,EAAE,kBAAkB,KAAK,cAAc,CAAC;IAChE,MAAM,CAAC,EAAE,WAAW,CAAC;IACrB,6FAA6F;IAC7F,KAAK,CAAC,EAAE,MAAM,CAAC;CAChB;AAED,MAAM,WAAW,sBAAsB;IACrC,MAAM,EAAE,kBAAkB,CAAC;IAC3B,KAAK,EAAE,MAAM,CAAC;IACd,MAAM,EAAE,MAAM,CAAC;IACf,WAAW,EAAE,MAAM,CAAC;IACpB,sGAAsG;IACtG,SAAS,EAAE,UAAU,CAAC;IACtB,qEAAqE;IACrE,QAAQ,EAAE,MAAM,CAAC;CAClB;
|
|
1
|
+
{"version":3,"file":"render-model-still.d.ts","sourceRoot":"","sources":["../src/render-model-still.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;GAWG;AACH,OAAO,KAAK,EAAE,UAAU,EAAE,MAAM,6BAA6B,CAAC;AAC9D,OAAO,KAAK,EAAE,cAAc,EAAE,kBAAkB,EAAE,MAAM,oCAAoC,CAAC;AAC7F,OAAO,EAAiB,KAAK,eAAe,EAAE,KAAK,gBAAgB,EAAE,MAAM,qBAAqB,CAAC;AAGjG,OAAO,KAAK,EAAE,UAAU,EAAE,MAAM,kBAAkB,CAAC;AAEnD,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,aAAa,CAAC;AAE/C,yEAAyE;AACzE,MAAM,MAAM,WAAW,GACnB;IAAE,IAAI,EAAE,KAAK,CAAC;IAAC,KAAK,EAAE,UAAU,GAAG,WAAW,CAAA;CAAE,GAChD;IAAE,IAAI,EAAE,KAAK,CAAC;IAAC,KAAK,EAAE,UAAU,GAAG,WAAW,CAAA;CAAE,GAChD,UAAU,CAAC;AAEf,MAAM,WAAW,uBAAuB;IACtC,MAAM,EAAE,kBAAkB,CAAC;IAC3B,KAAK,CAAC,EAAE,MAAM,CAAC;IACf,MAAM,CAAC,EAAE,MAAM,CAAC;IAChB,mGAAmG;IACnG,UAAU,CAAC,EAAE,eAAe,CAAC;IAC7B,qCAAqC;IACrC,IAAI,CAAC,EAAE,gBAAgB,CAAC;IACxB,cAAc,CAAC,EAAE,CAAC,MAAM,EAAE,kBAAkB,KAAK,cAAc,CAAC;IAChE,MAAM,CAAC,EAAE,WAAW,CAAC;IACrB;;;;OAIG;IACH,eAAe,CAAC,EAAE,SAAS,CAAC,MAAM,GAAG,SAAS,CAAC,EAAE,CAAC;IAClD,6FAA6F;IAC7F,KAAK,CAAC,EAAE,MAAM,CAAC;CAChB;AAED,MAAM,WAAW,sBAAsB;IACrC,MAAM,EAAE,kBAAkB,CAAC;IAC3B,KAAK,EAAE,MAAM,CAAC;IACd,MAAM,EAAE,MAAM,CAAC;IACf,WAAW,EAAE,MAAM,CAAC;IACpB,sGAAsG;IACtG,SAAS,EAAE,UAAU,CAAC;IACtB,qEAAqE;IACrE,QAAQ,EAAE,MAAM,CAAC;CAClB;AAqBD;;;;GAIG;AACH,wBAAsB,gBAAgB,CACpC,MAAM,EAAE,WAAW,EACnB,OAAO,EAAE,uBAAuB,GAC/B,OAAO,CAAC,sBAAsB,CAAC,CAiCjC"}
|
|
@@ -5,14 +5,14 @@ import { computeCacheKey } from './cache-key.js';
|
|
|
5
5
|
function isModelScene(s) {
|
|
6
6
|
return s.objects !== undefined && s.kind === undefined;
|
|
7
7
|
}
|
|
8
|
-
async function toScene(source, limits) {
|
|
8
|
+
async function toScene(source, limits, filamentPalette) {
|
|
9
9
|
if (isModelScene(source)) {
|
|
10
10
|
// No raw bytes for a pre-built scene: key off a stable structural summary instead.
|
|
11
11
|
const summary = JSON.stringify({ n: source.objects.length, b: source.bounds, c: source.capabilities });
|
|
12
12
|
return { scene: source, sourceBytes: new TextEncoder().encode(summary) };
|
|
13
13
|
}
|
|
14
14
|
const bytes = source.bytes instanceof Uint8Array ? source.bytes : new Uint8Array(source.bytes);
|
|
15
|
-
const scene = source.kind === '3mf' ? await parse3mf(bytes, limits) : parseStl(bytes, limits);
|
|
15
|
+
const scene = source.kind === '3mf' ? await parse3mf(bytes, limits, { filamentPalette }) : parseStl(bytes, limits);
|
|
16
16
|
return { scene, sourceBytes: bytes };
|
|
17
17
|
}
|
|
18
18
|
/**
|
|
@@ -26,7 +26,7 @@ export async function renderModelStill(source, options) {
|
|
|
26
26
|
const height = options.height ?? canvas.height;
|
|
27
27
|
const background = options.background ?? 'transparent';
|
|
28
28
|
const view = options.view ?? 'iso';
|
|
29
|
-
const { scene, sourceBytes } = await toScene(source, options.limits);
|
|
29
|
+
const { scene, sourceBytes } = await toScene(source, options.limits, options.filamentPalette);
|
|
30
30
|
const renderer = new ModelRenderer({
|
|
31
31
|
canvas,
|
|
32
32
|
background,
|
|
@@ -37,7 +37,7 @@ export async function renderModelStill(source, options) {
|
|
|
37
37
|
renderer.resize(width, height);
|
|
38
38
|
renderer.setScene(scene);
|
|
39
39
|
renderer.render();
|
|
40
|
-
const optionsJson = JSON.stringify({ width, height, background, view });
|
|
40
|
+
const optionsJson = JSON.stringify({ width, height, background, view, palette: options.filamentPalette });
|
|
41
41
|
const cacheKey = computeCacheKey(sourceBytes, optionsJson, options.envId);
|
|
42
42
|
return {
|
|
43
43
|
canvas,
|
package/dist/three-mf.d.ts
CHANGED
|
@@ -1,5 +1,13 @@
|
|
|
1
1
|
import { type ModelScene } from './scene-model.js';
|
|
2
2
|
import { type ModelLimits } from './limits.js';
|
|
3
|
-
|
|
4
|
-
|
|
3
|
+
export interface Parse3mfOptions {
|
|
4
|
+
/**
|
|
5
|
+
* Override the source-model filament palette (hex `#RRGGBB` per 0-based slot) used to colour
|
|
6
|
+
* `paint_color` facets, instead of reading it from `project_settings.config`. For consumers that
|
|
7
|
+
* already hold a corrected/richer palette (e.g. re-rendering a sliced `.gcode.3mf`). Ignored when
|
|
8
|
+
* empty; the file's own palette is still read.
|
|
9
|
+
*/
|
|
10
|
+
filamentPalette?: readonly (string | undefined)[];
|
|
11
|
+
}
|
|
12
|
+
export declare function parse3mf(source: Uint8Array | ArrayBuffer, limits?: ModelLimits, opts?: Parse3mfOptions): Promise<ModelScene>;
|
|
5
13
|
//# sourceMappingURL=three-mf.d.ts.map
|
package/dist/three-mf.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"three-mf.d.ts","sourceRoot":"","sources":["../src/three-mf.ts"],"names":[],"mappings":"
|
|
1
|
+
{"version":3,"file":"three-mf.d.ts","sourceRoot":"","sources":["../src/three-mf.ts"],"names":[],"mappings":"AAqBA,OAAO,EAKL,KAAK,UAAU,EAEhB,MAAM,kBAAkB,CAAC;AAC1B,OAAO,EAAkC,KAAK,WAAW,EAAuB,MAAM,aAAa,CAAC;AA4VpG,MAAM,WAAW,eAAe;IAC9B;;;;;OAKG;IACH,eAAe,CAAC,EAAE,SAAS,CAAC,MAAM,GAAG,SAAS,CAAC,EAAE,CAAC;CACnD;AAKD,wBAAsB,QAAQ,CAC5B,MAAM,EAAE,UAAU,GAAG,WAAW,EAChC,MAAM,CAAC,EAAE,WAAW,EACpB,IAAI,CAAC,EAAE,eAAe,GACrB,OAAO,CAAC,UAAU,CAAC,CAwJrB"}
|
package/dist/three-mf.js
CHANGED
|
@@ -1,16 +1,18 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* 3MF → {@link ModelScene} (DD-018 Phase 2). Multi-object,
|
|
3
|
-
* build-item transforms
|
|
2
|
+
* 3MF → {@link ModelScene} (DD-018 Phase 2, + production-extension follow-up). Multi-object,
|
|
3
|
+
* per-object/per-triangle **solid** colors, build-item transforms, and the **3MF Production Extension**
|
|
4
|
+
* (Bambu/MakerWorld default): `<components>` whose `<component p:path=…>` reference external
|
|
5
|
+
* `/3D/Objects/*.model` parts. A multicolor model becomes a multicolor thumbnail without slicing.
|
|
4
6
|
*
|
|
5
7
|
* 3MF is an OPC (ZIP) package; the ZIP is opened with the hardened, zero-dep reader from
|
|
6
|
-
* `@chestnutlabs/gcode-containers` (DD-005 §7 — zip-bomb / traversal / size caps reused verbatim).
|
|
7
|
-
*
|
|
8
|
-
*
|
|
8
|
+
* `@chestnutlabs/gcode-containers` (DD-005 §7 — zip-bomb / traversal / size caps reused verbatim). Each
|
|
9
|
+
* model part is a small XML document parsed with a minimal, worker-safe scan (no `DOMParser`, which Web
|
|
10
|
+
* Workers lack; no dependency). Only the subset needed for a presentation render is read:
|
|
9
11
|
* `<vertices>/<triangles>`, object/triangle material refs, `<basematerials>`/`<m:colorgroup>` palettes,
|
|
10
|
-
* and `<build><item>` transforms. Textures and non-color
|
|
11
|
-
* capability honesty), never fetched (no network).
|
|
12
|
+
* `<components><component>` (with `p:path`), and `<build><item>` transforms. Textures and non-color
|
|
13
|
+
* material properties are ignored (disclosed via capability honesty), never fetched (no network).
|
|
12
14
|
*/
|
|
13
|
-
import { readDirectory, extractEntry, DEFAULT_CONTAINER_LIMITS } from '@chestnutlabs/gcode-containers';
|
|
15
|
+
import { readDirectory, extractEntry, filamentColoursFromSettings, DEFAULT_CONTAINER_LIMITS } from '@chestnutlabs/gcode-containers';
|
|
14
16
|
import { IDENTITY_MAT4 } from './scene-model.js';
|
|
15
17
|
import { ModelParseError, resolveLimits } from './limits.js';
|
|
16
18
|
/** millimeter is the 3MF default; convert others to mm. */
|
|
@@ -22,6 +24,8 @@ const UNIT_TO_MM = {
|
|
|
22
24
|
inch: 25.4,
|
|
23
25
|
foot: 304.8
|
|
24
26
|
};
|
|
27
|
+
/** Max component-resolution depth (guards cyclic / pathological assemblies). */
|
|
28
|
+
const MAX_DEPTH = 50;
|
|
25
29
|
const TAG_RE = /<(\/?)([\w:.-]+)((?:[^>"']|"[^"]*"|'[^']*')*?)(\/?)>/g;
|
|
26
30
|
const ATTR_RE = /([\w:.-]+)\s*=\s*(?:"([^"]*)"|'([^']*)')/g;
|
|
27
31
|
function localName(tag) {
|
|
@@ -52,48 +56,100 @@ function srgbHexToLinear(hex) {
|
|
|
52
56
|
const b = to(4);
|
|
53
57
|
return Number.isNaN(r) || Number.isNaN(g) || Number.isNaN(b) ? null : [r, g, b];
|
|
54
58
|
}
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
const bytes = source instanceof Uint8Array ? source : new Uint8Array(source);
|
|
80
|
-
if (bytes.byteLength === 0)
|
|
81
|
-
throw new ModelParseError('E_MODEL_EMPTY', '3MF source is empty');
|
|
82
|
-
if (bytes.byteLength > lim.maxSourceBytes) {
|
|
83
|
-
throw new ModelParseError('E_MODEL_TOO_LARGE', `3MF source ${bytes.byteLength} bytes exceeds limit ${lim.maxSourceBytes}`);
|
|
59
|
+
/**
|
|
60
|
+
* Decode a Bambu/Orca `paint_color` facet-paint attribute (clean-room from the observed format —
|
|
61
|
+
* see RR-005, NOT from Orca/PrusaSlicer GPL source). The value is a **little-endian** stream of 4-bit
|
|
62
|
+
* nibble tokens forming a recursive triangle-split tree:
|
|
63
|
+
*
|
|
64
|
+
* - `token & 0b11` = split-sides: `0` ⇒ a **leaf**, otherwise the triangle is split into
|
|
65
|
+
* `split_sides + 1` children (2/3/4) that follow, decoded depth-first (`token >> 2` is the split
|
|
66
|
+
* side — irrelevant to a presentation render).
|
|
67
|
+
* - a leaf's state is `token >> 2`, with `3` escaping into the following nibble (`3 + next`).
|
|
68
|
+
*
|
|
69
|
+
* **State 0 = the object's default extruder (unpainted); state s ≥ 1 = filament index s − 1.**
|
|
70
|
+
* Returns the dominant leaf state plus whether the facet was subdivided (so the caller can flatten it
|
|
71
|
+
* to one colour and disclose `approximated`), or `null` if the value is malformed/truncated.
|
|
72
|
+
*/
|
|
73
|
+
function decodePaintState(hex) {
|
|
74
|
+
const clean = hex.trim();
|
|
75
|
+
if (clean.length === 0)
|
|
76
|
+
return null;
|
|
77
|
+
const nibbles = [];
|
|
78
|
+
for (let i = clean.length - 1; i >= 0; i--) {
|
|
79
|
+
const n = parseInt(clean[i], 16);
|
|
80
|
+
if (Number.isNaN(n))
|
|
81
|
+
return null;
|
|
82
|
+
nibbles.push(n);
|
|
84
83
|
}
|
|
85
|
-
let
|
|
84
|
+
let pos = 0;
|
|
85
|
+
const states = [];
|
|
86
|
+
const next = () => {
|
|
87
|
+
if (pos >= nibbles.length)
|
|
88
|
+
throw new ModelParseError('E_MODEL_PARSE', 'paint_color underflow');
|
|
89
|
+
return nibbles[pos++];
|
|
90
|
+
};
|
|
91
|
+
const node = (depth) => {
|
|
92
|
+
if (depth > 16)
|
|
93
|
+
throw new ModelParseError('E_MODEL_PARSE', 'paint_color too deep');
|
|
94
|
+
const t = next();
|
|
95
|
+
const ss = t & 0b11;
|
|
96
|
+
if (ss === 0) {
|
|
97
|
+
const s2 = t >> 2;
|
|
98
|
+
states.push(s2 === 3 ? 3 + next() : s2);
|
|
99
|
+
}
|
|
100
|
+
else {
|
|
101
|
+
for (let i = 0; i <= ss; i++)
|
|
102
|
+
node(depth + 1);
|
|
103
|
+
}
|
|
104
|
+
};
|
|
86
105
|
try {
|
|
87
|
-
|
|
106
|
+
node(0);
|
|
88
107
|
}
|
|
89
|
-
catch
|
|
90
|
-
|
|
91
|
-
throw e;
|
|
92
|
-
throw new ModelParseError('E_MODEL_PARSE', `3MF unzip failed: ${e.message}`);
|
|
108
|
+
catch {
|
|
109
|
+
return null;
|
|
93
110
|
}
|
|
94
|
-
|
|
111
|
+
if (states.length === 0)
|
|
112
|
+
return null;
|
|
113
|
+
// Dominant leaf state — subdivided facets are a tiny fraction of any real model, so flattening them
|
|
114
|
+
// to their most-common state (and reporting `approximated`) is a faithful presentation trade-off.
|
|
115
|
+
const counts = new Map();
|
|
116
|
+
let best = states[0];
|
|
117
|
+
let bestN = 0;
|
|
118
|
+
for (const s of states) {
|
|
119
|
+
const c = (counts.get(s) ?? 0) + 1;
|
|
120
|
+
counts.set(s, c);
|
|
121
|
+
if (c > bestN) {
|
|
122
|
+
bestN = c;
|
|
123
|
+
best = s;
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
return { state: best, subdivided: states.length > 1 };
|
|
127
|
+
}
|
|
128
|
+
function parseTransform(s) {
|
|
129
|
+
if (s === undefined)
|
|
130
|
+
return null;
|
|
131
|
+
const v = s.trim().split(/\s+/).map(Number);
|
|
132
|
+
return v.length === 12 && v.every((n) => !Number.isNaN(n)) ? v : null;
|
|
133
|
+
}
|
|
134
|
+
/** Apply a 3MF row-vector affine (12 values) to a point. */
|
|
135
|
+
function applyTransform(v, x, y, z) {
|
|
136
|
+
return [
|
|
137
|
+
v[0] * x + v[3] * y + v[6] * z + v[9],
|
|
138
|
+
v[1] * x + v[4] * y + v[7] * z + v[10],
|
|
139
|
+
v[2] * x + v[5] * y + v[8] * z + v[11]
|
|
140
|
+
];
|
|
141
|
+
}
|
|
142
|
+
/** Apply a chain of transforms in order (innermost first): comp → … → build item. */
|
|
143
|
+
function applyChain(chain, x, y, z) {
|
|
144
|
+
let p = [x, y, z];
|
|
145
|
+
for (const t of chain)
|
|
146
|
+
p = applyTransform(t, p[0], p[1], p[2]);
|
|
147
|
+
return p;
|
|
148
|
+
}
|
|
149
|
+
/** Parse ONE model part's XML into objects/palettes/items (no cross-part resolution). */
|
|
150
|
+
function parseModelPart(xml, lim) {
|
|
95
151
|
let unitScale = 1;
|
|
96
|
-
const palettes = new Map();
|
|
152
|
+
const palettes = new Map();
|
|
97
153
|
const objects = new Map();
|
|
98
154
|
const items = [];
|
|
99
155
|
let curObj = null;
|
|
@@ -138,7 +194,15 @@ export async function parse3mf(source, limits) {
|
|
|
138
194
|
else {
|
|
139
195
|
const a = attrs(raw);
|
|
140
196
|
curObjId = a.id ?? null;
|
|
141
|
-
curObj = {
|
|
197
|
+
curObj = {
|
|
198
|
+
vertices: [],
|
|
199
|
+
tris: [],
|
|
200
|
+
triColorIdx: [],
|
|
201
|
+
triPid: [],
|
|
202
|
+
triPaint: [],
|
|
203
|
+
components: [],
|
|
204
|
+
name: a.name
|
|
205
|
+
};
|
|
142
206
|
if (a.pid !== undefined)
|
|
143
207
|
curObj.objPid = a.pid;
|
|
144
208
|
if (a.pindex !== undefined)
|
|
@@ -149,6 +213,18 @@ export async function parse3mf(source, limits) {
|
|
|
149
213
|
}
|
|
150
214
|
}
|
|
151
215
|
}
|
|
216
|
+
else if (name === 'component' && curObj !== null) {
|
|
217
|
+
// Production extension: a sub-object, possibly in an external part via `p:path` (any prefix).
|
|
218
|
+
const a = attrs(raw);
|
|
219
|
+
const path = a['p:path'] ?? a.path;
|
|
220
|
+
if (a.objectid !== undefined) {
|
|
221
|
+
curObj.components.push({
|
|
222
|
+
...(path !== undefined ? { path } : {}),
|
|
223
|
+
objectid: a.objectid,
|
|
224
|
+
transform: parseTransform(a.transform)
|
|
225
|
+
});
|
|
226
|
+
}
|
|
227
|
+
}
|
|
152
228
|
else if (name === 'vertex' && curObj !== null) {
|
|
153
229
|
const a = attrs(raw);
|
|
154
230
|
curObj.vertices.push(Number(a.x) || 0, Number(a.y) || 0, Number(a.z) || 0);
|
|
@@ -161,7 +237,7 @@ export async function parse3mf(source, limits) {
|
|
|
161
237
|
}
|
|
162
238
|
const pid = a.pid ?? curObj.objPid ?? null;
|
|
163
239
|
curObj.triPid.push(pid);
|
|
164
|
-
|
|
240
|
+
curObj.triPaint.push(a.paint_color ?? null);
|
|
165
241
|
if (a.p1 !== undefined) {
|
|
166
242
|
const p1 = Number(a.p1);
|
|
167
243
|
const p2 = a.p2 !== undefined ? Number(a.p2) : p1;
|
|
@@ -182,107 +258,231 @@ export async function parse3mf(source, limits) {
|
|
|
182
258
|
else if (name === 'item' && inBuild && !closing) {
|
|
183
259
|
const a = attrs(raw);
|
|
184
260
|
if (a.objectid !== undefined)
|
|
185
|
-
items.push({ objectid: a.objectid, transform: a.transform });
|
|
261
|
+
items.push({ objectid: a.objectid, transform: parseTransform(a.transform) });
|
|
186
262
|
}
|
|
187
263
|
}
|
|
188
|
-
return
|
|
189
|
-
}
|
|
190
|
-
function parseTransform(s) {
|
|
191
|
-
if (s === undefined)
|
|
192
|
-
return null;
|
|
193
|
-
const v = s.trim().split(/\s+/).map(Number);
|
|
194
|
-
return v.length === 12 && v.every((n) => !Number.isNaN(n)) ? v : null;
|
|
195
|
-
}
|
|
196
|
-
/** Apply a 3MF row-vector affine (12 values) to a point. */
|
|
197
|
-
function applyTransform(v, x, y, z) {
|
|
198
|
-
return [
|
|
199
|
-
v[0] * x + v[3] * y + v[6] * z + v[9],
|
|
200
|
-
v[1] * x + v[4] * y + v[7] * z + v[10],
|
|
201
|
-
v[2] * x + v[5] * y + v[8] * z + v[11]
|
|
202
|
-
];
|
|
264
|
+
return { objects, palettes, unitScale, items };
|
|
203
265
|
}
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
const
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
const
|
|
212
|
-
let
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
const pid = obj.triPid[ti];
|
|
230
|
-
const palette = pid !== null ? palettes.get(pid) : undefined;
|
|
231
|
-
const cidx = obj.triColorIdx[ti];
|
|
232
|
-
for (let k = 0; k < 3; k++) {
|
|
233
|
-
const vi = obj.tris[ti * 3 + k] * 3;
|
|
234
|
-
let x = obj.vertices[vi] * unitScale;
|
|
235
|
-
let y = obj.vertices[vi + 1] * unitScale;
|
|
236
|
-
let z = obj.vertices[vi + 2] * unitScale;
|
|
237
|
-
if (xform !== null)
|
|
238
|
-
[x, y, z] = applyTransform(xform, x, y, z);
|
|
239
|
-
const o = (ti * 3 + k) * 3;
|
|
240
|
-
positions[o] = x;
|
|
241
|
-
positions[o + 1] = y;
|
|
242
|
-
positions[o + 2] = z;
|
|
243
|
-
if (x < min[0])
|
|
244
|
-
min[0] = x;
|
|
245
|
-
if (y < min[1])
|
|
246
|
-
min[1] = y;
|
|
247
|
-
if (z < min[2])
|
|
248
|
-
min[2] = z;
|
|
249
|
-
if (x > max[0])
|
|
250
|
-
max[0] = x;
|
|
251
|
-
if (y > max[1])
|
|
252
|
-
max[1] = y;
|
|
253
|
-
if (z > max[2])
|
|
254
|
-
max[2] = z;
|
|
255
|
-
if (palette !== undefined && cidx !== null) {
|
|
256
|
-
const col = palette[cidx[k]] ?? palette[0];
|
|
257
|
-
if (col !== undefined) {
|
|
258
|
-
if (colors === null)
|
|
259
|
-
colors = new Float32Array(triCount * 9);
|
|
260
|
-
colors[o] = col[0];
|
|
261
|
-
colors[o + 1] = col[1];
|
|
262
|
-
colors[o + 2] = col[2];
|
|
263
|
-
objHasColor = true;
|
|
264
|
-
}
|
|
266
|
+
/** Bake one mesh object (its own triangles) into a `ModelObject`, applying the transform chain. */
|
|
267
|
+
function bakeMesh(obj, part, chain, id, st, lim) {
|
|
268
|
+
const triCount = obj.tris.length / 3;
|
|
269
|
+
st.totalTris += triCount;
|
|
270
|
+
if (st.totalTris > lim.maxTriangles) {
|
|
271
|
+
throw new ModelParseError('E_MODEL_TOO_MANY_TRIANGLES', `3MF exceeds triangle limit ${lim.maxTriangles}`);
|
|
272
|
+
}
|
|
273
|
+
const positions = new Float32Array(triCount * 9);
|
|
274
|
+
let colors = null;
|
|
275
|
+
let objHasColor = false;
|
|
276
|
+
// A mesh painted with Bambu/Orca `paint_color` (only meaningful when we have a filament palette).
|
|
277
|
+
const objHasPaint = st.paintPalette.length > 0 && obj.triPaint.some((p) => p !== null);
|
|
278
|
+
for (let ti = 0; ti < triCount; ti++) {
|
|
279
|
+
// Paint path: one filament colour for the whole triangle. Painted facets decode to a state; the
|
|
280
|
+
// unpainted facets of a painted mesh take the object's default-extruder colour (never left blank).
|
|
281
|
+
let paintCol;
|
|
282
|
+
if (objHasPaint) {
|
|
283
|
+
let idx = st.defaultExtruderIdx;
|
|
284
|
+
const paint = obj.triPaint[ti];
|
|
285
|
+
if (paint !== null) {
|
|
286
|
+
const dec = decodePaintState(paint);
|
|
287
|
+
if (dec !== null) {
|
|
288
|
+
idx = dec.state === 0 ? st.defaultExtruderIdx : dec.state - 1;
|
|
289
|
+
if (dec.subdivided)
|
|
290
|
+
st.anyApprox = true;
|
|
265
291
|
}
|
|
266
292
|
}
|
|
293
|
+
paintCol = st.paintPalette[idx] ?? st.paintPalette[st.defaultExtruderIdx];
|
|
294
|
+
}
|
|
295
|
+
// Standard 3MF material path (basematerials / colorgroup + pid) — fallback when there's no paint.
|
|
296
|
+
const pid = obj.triPid[ti];
|
|
297
|
+
const palette = pid !== null ? part.palettes.get(pid) : undefined;
|
|
298
|
+
const cidx = obj.triColorIdx[ti];
|
|
299
|
+
for (let k = 0; k < 3; k++) {
|
|
300
|
+
const vi = obj.tris[ti * 3 + k] * 3;
|
|
301
|
+
const [x, y, z] = applyChain(chain, obj.vertices[vi] * part.unitScale, obj.vertices[vi + 1] * part.unitScale, obj.vertices[vi + 2] * part.unitScale);
|
|
302
|
+
const o = (ti * 3 + k) * 3;
|
|
303
|
+
positions[o] = x;
|
|
304
|
+
positions[o + 1] = y;
|
|
305
|
+
positions[o + 2] = z;
|
|
306
|
+
if (x < st.min[0])
|
|
307
|
+
st.min[0] = x;
|
|
308
|
+
if (y < st.min[1])
|
|
309
|
+
st.min[1] = y;
|
|
310
|
+
if (z < st.min[2])
|
|
311
|
+
st.min[2] = z;
|
|
312
|
+
if (x > st.max[0])
|
|
313
|
+
st.max[0] = x;
|
|
314
|
+
if (y > st.max[1])
|
|
315
|
+
st.max[1] = y;
|
|
316
|
+
if (z > st.max[2])
|
|
317
|
+
st.max[2] = z;
|
|
318
|
+
const col = paintCol ?? (palette !== undefined && cidx !== null ? (palette[cidx[k]] ?? palette[0]) : undefined);
|
|
319
|
+
if (col !== undefined) {
|
|
320
|
+
if (colors === null)
|
|
321
|
+
colors = new Float32Array(triCount * 9);
|
|
322
|
+
colors[o] = col[0];
|
|
323
|
+
colors[o + 1] = col[1];
|
|
324
|
+
colors[o + 2] = col[2];
|
|
325
|
+
objHasColor = true;
|
|
326
|
+
}
|
|
327
|
+
}
|
|
328
|
+
}
|
|
329
|
+
if (objHasColor)
|
|
330
|
+
st.anyColor = true;
|
|
331
|
+
const geometry = colors !== null ? { positions, colors } : { positions };
|
|
332
|
+
const built = { id, geometry, transform: IDENTITY_MAT4 };
|
|
333
|
+
if (obj.name !== undefined)
|
|
334
|
+
built.name = obj.name;
|
|
335
|
+
st.built.push(built);
|
|
336
|
+
}
|
|
337
|
+
const hexPaletteToLinear = (hexes) => hexes.map((hex) => (hex !== undefined ? (srgbHexToLinear(hex) ?? [0.8, 0.8, 0.82]) : [0.8, 0.8, 0.82]));
|
|
338
|
+
export async function parse3mf(source, limits, opts) {
|
|
339
|
+
const lim = resolveLimits(limits);
|
|
340
|
+
const bytes = source instanceof Uint8Array ? source : new Uint8Array(source);
|
|
341
|
+
if (bytes.byteLength === 0)
|
|
342
|
+
throw new ModelParseError('E_MODEL_EMPTY', '3MF source is empty');
|
|
343
|
+
if (bytes.byteLength > lim.maxSourceBytes) {
|
|
344
|
+
throw new ModelParseError('E_MODEL_TOO_LARGE', `3MF source ${bytes.byteLength} bytes exceeds limit ${lim.maxSourceBytes}`);
|
|
345
|
+
}
|
|
346
|
+
const climits = DEFAULT_CONTAINER_LIMITS;
|
|
347
|
+
let dir;
|
|
348
|
+
try {
|
|
349
|
+
dir = readDirectory(bytes, climits);
|
|
350
|
+
}
|
|
351
|
+
catch (e) {
|
|
352
|
+
throw new ModelParseError('E_MODEL_PARSE', `3MF unzip failed: ${e.message}`);
|
|
353
|
+
}
|
|
354
|
+
const entryByName = (nameLower) => dir.entries.find((e) => e.name.toLowerCase() === nameLower);
|
|
355
|
+
// Locate the primary model part: conventional path, then OPC StartPart, then any *.model.
|
|
356
|
+
let mainEntry = entryByName('3d/3dmodel.model');
|
|
357
|
+
if (mainEntry === undefined) {
|
|
358
|
+
const rels = entryByName('_rels/.rels');
|
|
359
|
+
if (rels !== undefined) {
|
|
360
|
+
const relXml = new TextDecoder().decode(await extractEntry(bytes, rels, climits.maxMetadataBytes));
|
|
361
|
+
const target = /Target="\/?([^"]+\.model)"/i.exec(relXml)?.[1];
|
|
362
|
+
if (target !== undefined)
|
|
363
|
+
mainEntry = entryByName(target.toLowerCase());
|
|
364
|
+
}
|
|
365
|
+
}
|
|
366
|
+
if (mainEntry === undefined)
|
|
367
|
+
mainEntry = dir.entries.find((e) => e.name.toLowerCase().endsWith('.model'));
|
|
368
|
+
if (mainEntry === undefined)
|
|
369
|
+
throw new ModelParseError('E_MODEL_PARSE', '3MF contains no 3D model part');
|
|
370
|
+
// Lazily parse model parts by absolute OPC path (production-extension components reference them).
|
|
371
|
+
const partCache = new Map();
|
|
372
|
+
const parseEntry = async (entry) => {
|
|
373
|
+
const key = entry.name.toLowerCase();
|
|
374
|
+
const cached = partCache.get(key);
|
|
375
|
+
if (cached !== undefined)
|
|
376
|
+
return cached;
|
|
377
|
+
const xml = new TextDecoder().decode(await extractEntry(bytes, entry, climits.maxExpandedBytesPerEntry));
|
|
378
|
+
const part = parseModelPart(xml, lim);
|
|
379
|
+
partCache.set(key, part);
|
|
380
|
+
return part;
|
|
381
|
+
};
|
|
382
|
+
const getPartByPath = async (path) => {
|
|
383
|
+
const norm = path.replace(/^\//, '').toLowerCase();
|
|
384
|
+
const entry = entryByName(norm);
|
|
385
|
+
return entry === undefined ? null : parseEntry(entry);
|
|
386
|
+
};
|
|
387
|
+
let mainPart;
|
|
388
|
+
try {
|
|
389
|
+
mainPart = await parseEntry(mainEntry);
|
|
390
|
+
}
|
|
391
|
+
catch (e) {
|
|
392
|
+
if (e instanceof ModelParseError)
|
|
393
|
+
throw e;
|
|
394
|
+
throw new ModelParseError('E_MODEL_PARSE', `3MF unzip failed: ${e.message}`);
|
|
395
|
+
}
|
|
396
|
+
// Source-model filament palette for Bambu/Orca `paint_color`: it lives in `project_settings.config`
|
|
397
|
+
// (`filament_colour`), NOT the model XML, so the render is self-contained and needs no slicer output.
|
|
398
|
+
let paintPalette = [];
|
|
399
|
+
const override = opts?.filamentPalette;
|
|
400
|
+
if (override !== undefined && override.length > 0) {
|
|
401
|
+
// Consumer-supplied palette wins over the file's own (e.g. a corrected slicer palette).
|
|
402
|
+
paintPalette = hexPaletteToLinear(override);
|
|
403
|
+
}
|
|
404
|
+
else {
|
|
405
|
+
const settingsEntry = entryByName('metadata/project_settings.config');
|
|
406
|
+
if (settingsEntry !== undefined) {
|
|
407
|
+
try {
|
|
408
|
+
const json = new TextDecoder().decode(await extractEntry(bytes, settingsEntry, climits.maxMetadataBytes));
|
|
409
|
+
const settings = JSON.parse(json);
|
|
410
|
+
paintPalette = hexPaletteToLinear(filamentColoursFromSettings(settings));
|
|
411
|
+
}
|
|
412
|
+
catch {
|
|
413
|
+
// Malformed/oversized settings → no palette; paint stays capability-honest `unavailable`.
|
|
414
|
+
paintPalette = [];
|
|
415
|
+
}
|
|
416
|
+
}
|
|
417
|
+
}
|
|
418
|
+
// Default extruder (1-based) for a painted mesh's unpainted facets; from `model_settings.config`, else 1.
|
|
419
|
+
let defaultExtruderIdx = 0;
|
|
420
|
+
const modelSettingsEntry = entryByName('metadata/model_settings.config');
|
|
421
|
+
if (modelSettingsEntry !== undefined) {
|
|
422
|
+
try {
|
|
423
|
+
const xml = new TextDecoder().decode(await extractEntry(bytes, modelSettingsEntry, climits.maxMetadataBytes));
|
|
424
|
+
const m = /key="extruder"\s+value="(\d+)"/i.exec(xml);
|
|
425
|
+
const n = m !== null ? parseInt(m[1], 10) : NaN;
|
|
426
|
+
if (Number.isFinite(n) && n >= 1)
|
|
427
|
+
defaultExtruderIdx = n - 1;
|
|
267
428
|
}
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
429
|
+
catch {
|
|
430
|
+
defaultExtruderIdx = 0;
|
|
431
|
+
}
|
|
432
|
+
}
|
|
433
|
+
const st = {
|
|
434
|
+
built: [],
|
|
435
|
+
anyColor: false,
|
|
436
|
+
anyTransform: false,
|
|
437
|
+
anyApprox: false,
|
|
438
|
+
paintPalette,
|
|
439
|
+
defaultExtruderIdx,
|
|
440
|
+
min: [Infinity, Infinity, Infinity],
|
|
441
|
+
max: [-Infinity, -Infinity, -Infinity],
|
|
442
|
+
totalTris: 0
|
|
443
|
+
};
|
|
444
|
+
// Resolve an object (mesh and/or components) within `part`, applying the transform chain.
|
|
445
|
+
const resolve = async (part, objectid, chain, id, depth) => {
|
|
446
|
+
if (depth > MAX_DEPTH)
|
|
447
|
+
return;
|
|
448
|
+
const obj = part.objects.get(objectid);
|
|
449
|
+
if (obj === undefined)
|
|
450
|
+
return;
|
|
451
|
+
if (obj.tris.length > 0)
|
|
452
|
+
bakeMesh(obj, part, chain, id, st, lim);
|
|
453
|
+
for (let ci = 0; ci < obj.components.length; ci++) {
|
|
454
|
+
const comp = obj.components[ci];
|
|
455
|
+
const targetPart = comp.path !== undefined ? await getPartByPath(comp.path) : part;
|
|
456
|
+
if (targetPart === null)
|
|
457
|
+
continue;
|
|
458
|
+
// Apply the component transform FIRST (child space → this object), then the existing chain.
|
|
459
|
+
const childChain = comp.transform !== null ? [comp.transform, ...chain] : chain;
|
|
460
|
+
if (comp.transform !== null)
|
|
461
|
+
st.anyTransform = true;
|
|
462
|
+
await resolve(targetPart, comp.objectid, childChain, `${id}.${ci}`, depth + 1);
|
|
463
|
+
}
|
|
464
|
+
};
|
|
465
|
+
// With no <build>, resolve every declared object at identity (some minimal 3MFs omit build).
|
|
466
|
+
const instances = mainPart.items.length > 0
|
|
467
|
+
? mainPart.items
|
|
468
|
+
: [...mainPart.objects.keys()].map((objectid) => ({ objectid, transform: null }));
|
|
469
|
+
for (const inst of instances) {
|
|
470
|
+
const chain = inst.transform !== null ? [inst.transform] : [];
|
|
471
|
+
if (inst.transform !== null)
|
|
472
|
+
st.anyTransform = true;
|
|
473
|
+
await resolve(mainPart, inst.objectid, chain, inst.objectid, 0);
|
|
275
474
|
}
|
|
276
|
-
if (built.length === 0)
|
|
475
|
+
if (st.built.length === 0)
|
|
277
476
|
throw new ModelParseError('E_MODEL_EMPTY', '3MF contains no renderable geometry');
|
|
278
|
-
const bounds = { min, max };
|
|
477
|
+
const bounds = { min: st.min, max: st.max };
|
|
279
478
|
return {
|
|
280
|
-
objects: built,
|
|
479
|
+
objects: st.built,
|
|
281
480
|
bounds,
|
|
282
481
|
capabilities: {
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
482
|
+
// `approximated` when any colour came from flattening a subdivided `paint_color` facet.
|
|
483
|
+
materials: st.anyApprox ? 'approximated' : st.anyColor ? 'known' : 'unavailable',
|
|
484
|
+
transforms: st.anyTransform ? 'known' : 'unavailable',
|
|
485
|
+
multiObject: st.built.length > 1 ? 'known' : 'unavailable'
|
|
286
486
|
}
|
|
287
487
|
};
|
|
288
488
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@chestnutlabs/gcode-model-renderer",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.8.1",
|
|
4
4
|
"description": "Three.js presentation renderer for source models (STL/3MF) — clean thumbnails/cards, distinct from toolpath inspection (DD-018).",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"3d-printing",
|
|
@@ -51,9 +51,9 @@
|
|
|
51
51
|
"test": "vitest run"
|
|
52
52
|
},
|
|
53
53
|
"dependencies": {
|
|
54
|
-
"@chestnutlabs/gcode-containers": "0.
|
|
55
|
-
"@chestnutlabs/gcode-renderer-three": "0.
|
|
56
|
-
"@chestnutlabs/toolpath-core": "0.
|
|
54
|
+
"@chestnutlabs/gcode-containers": "0.8.1",
|
|
55
|
+
"@chestnutlabs/gcode-renderer-three": "0.8.1",
|
|
56
|
+
"@chestnutlabs/toolpath-core": "0.8.1"
|
|
57
57
|
},
|
|
58
58
|
"devDependencies": {
|
|
59
59
|
"@types/three": "0.178.0"
|