@chestnutlabs/gcode-model-renderer 0.7.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 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
+ ![Two presentation thumbnails: a neutral gray STL part (materials: unavailable) beside a red/amber/green three-object 3MF (materials: known)](https://raw.githubusercontent.com/ChestnutLabs/gcode-preview/dev/docs/media/model-render-stl-3mf.png)
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';
@@ -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;AAoBD;;;;GAIG;AACH,wBAAsB,gBAAgB,CACpC,MAAM,EAAE,WAAW,EACnB,OAAO,EAAE,uBAAuB,GAC/B,OAAO,CAAC,sBAAsB,CAAC,CAiCjC"}
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,
@@ -1,4 +1,13 @@
1
1
  import { type ModelScene } from './scene-model.js';
2
2
  import { type ModelLimits } from './limits.js';
3
- export declare function parse3mf(source: Uint8Array | ArrayBuffer, limits?: ModelLimits): Promise<ModelScene>;
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>;
4
13
  //# sourceMappingURL=three-mf.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"three-mf.d.ts","sourceRoot":"","sources":["../src/three-mf.ts"],"names":[],"mappings":"AAoBA,OAAO,EAKL,KAAK,UAAU,EAEhB,MAAM,kBAAkB,CAAC;AAC1B,OAAO,EAAkC,KAAK,WAAW,EAAuB,MAAM,aAAa,CAAC;AA8PpG,wBAAsB,QAAQ,CAAC,MAAM,EAAE,UAAU,GAAG,WAAW,EAAE,MAAM,CAAC,EAAE,WAAW,GAAG,OAAO,CAAC,UAAU,CAAC,CAkH1G"}
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
@@ -12,7 +12,7 @@
12
12
  * `<components><component>` (with `p:path`), and `<build><item>` transforms. Textures and non-color
13
13
  * material properties are ignored (disclosed via capability honesty), never fetched (no network).
14
14
  */
15
- import { readDirectory, extractEntry, DEFAULT_CONTAINER_LIMITS } from '@chestnutlabs/gcode-containers';
15
+ import { readDirectory, extractEntry, filamentColoursFromSettings, DEFAULT_CONTAINER_LIMITS } from '@chestnutlabs/gcode-containers';
16
16
  import { IDENTITY_MAT4 } from './scene-model.js';
17
17
  import { ModelParseError, resolveLimits } from './limits.js';
18
18
  /** millimeter is the 3MF default; convert others to mm. */
@@ -56,6 +56,75 @@ function srgbHexToLinear(hex) {
56
56
  const b = to(4);
57
57
  return Number.isNaN(r) || Number.isNaN(g) || Number.isNaN(b) ? null : [r, g, b];
58
58
  }
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);
83
+ }
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
+ };
105
+ try {
106
+ node(0);
107
+ }
108
+ catch {
109
+ return null;
110
+ }
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
+ }
59
128
  function parseTransform(s) {
60
129
  if (s === undefined)
61
130
  return null;
@@ -125,7 +194,15 @@ function parseModelPart(xml, lim) {
125
194
  else {
126
195
  const a = attrs(raw);
127
196
  curObjId = a.id ?? null;
128
- curObj = { vertices: [], tris: [], triColorIdx: [], triPid: [], components: [], name: a.name };
197
+ curObj = {
198
+ vertices: [],
199
+ tris: [],
200
+ triColorIdx: [],
201
+ triPid: [],
202
+ triPaint: [],
203
+ components: [],
204
+ name: a.name
205
+ };
129
206
  if (a.pid !== undefined)
130
207
  curObj.objPid = a.pid;
131
208
  if (a.pindex !== undefined)
@@ -160,6 +237,7 @@ function parseModelPart(xml, lim) {
160
237
  }
161
238
  const pid = a.pid ?? curObj.objPid ?? null;
162
239
  curObj.triPid.push(pid);
240
+ curObj.triPaint.push(a.paint_color ?? null);
163
241
  if (a.p1 !== undefined) {
164
242
  const p1 = Number(a.p1);
165
243
  const p2 = a.p2 !== undefined ? Number(a.p2) : p1;
@@ -195,7 +273,26 @@ function bakeMesh(obj, part, chain, id, st, lim) {
195
273
  const positions = new Float32Array(triCount * 9);
196
274
  let colors = null;
197
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);
198
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;
291
+ }
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.
199
296
  const pid = obj.triPid[ti];
200
297
  const palette = pid !== null ? part.palettes.get(pid) : undefined;
201
298
  const cidx = obj.triColorIdx[ti];
@@ -218,16 +315,14 @@ function bakeMesh(obj, part, chain, id, st, lim) {
218
315
  st.max[1] = y;
219
316
  if (z > st.max[2])
220
317
  st.max[2] = z;
221
- if (palette !== undefined && cidx !== null) {
222
- const col = palette[cidx[k]] ?? palette[0];
223
- if (col !== undefined) {
224
- if (colors === null)
225
- colors = new Float32Array(triCount * 9);
226
- colors[o] = col[0];
227
- colors[o + 1] = col[1];
228
- colors[o + 2] = col[2];
229
- objHasColor = true;
230
- }
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;
231
326
  }
232
327
  }
233
328
  }
@@ -239,7 +334,8 @@ function bakeMesh(obj, part, chain, id, st, lim) {
239
334
  built.name = obj.name;
240
335
  st.built.push(built);
241
336
  }
242
- export async function parse3mf(source, limits) {
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) {
243
339
  const lim = resolveLimits(limits);
244
340
  const bytes = source instanceof Uint8Array ? source : new Uint8Array(source);
245
341
  if (bytes.byteLength === 0)
@@ -297,10 +393,50 @@ export async function parse3mf(source, limits) {
297
393
  throw e;
298
394
  throw new ModelParseError('E_MODEL_PARSE', `3MF unzip failed: ${e.message}`);
299
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;
428
+ }
429
+ catch {
430
+ defaultExtruderIdx = 0;
431
+ }
432
+ }
300
433
  const st = {
301
434
  built: [],
302
435
  anyColor: false,
303
436
  anyTransform: false,
437
+ anyApprox: false,
438
+ paintPalette,
439
+ defaultExtruderIdx,
304
440
  min: [Infinity, Infinity, Infinity],
305
441
  max: [-Infinity, -Infinity, -Infinity],
306
442
  totalTris: 0
@@ -343,7 +479,8 @@ export async function parse3mf(source, limits) {
343
479
  objects: st.built,
344
480
  bounds,
345
481
  capabilities: {
346
- materials: st.anyColor ? 'known' : 'unavailable',
482
+ // `approximated` when any colour came from flattening a subdivided `paint_color` facet.
483
+ materials: st.anyApprox ? 'approximated' : st.anyColor ? 'known' : 'unavailable',
347
484
  transforms: st.anyTransform ? 'known' : 'unavailable',
348
485
  multiObject: st.built.length > 1 ? 'known' : 'unavailable'
349
486
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@chestnutlabs/gcode-model-renderer",
3
- "version": "0.7.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.7.0",
55
- "@chestnutlabs/gcode-renderer-three": "0.7.0",
56
- "@chestnutlabs/toolpath-core": "0.7.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"