@bobfrankston/wallplater 1.0.6 → 1.0.7

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 CHANGED
@@ -14,6 +14,11 @@ separate, individually colourable objects) or per-part STLs.
14
14
  The manifold engine has no system dependency beyond Node and is the path
15
15
  forward; `-scad` is kept for parity/verification.
16
16
 
17
+ If the `-scad` engine can't find OpenSCAD (neither the `openscad` config path nor
18
+ `openscad` on `PATH`), it offers to install it for you — `winget` on Windows,
19
+ `brew --cask` on macOS, `apt-get` on Linux — then re-checks. Decline (or a
20
+ non-interactive stdin) exits with a hint to install manually or drop `-scad`.
21
+
17
22
  ## Build / install
18
23
 
19
24
  TypeScript compiled to co-located `.js` / `.d.ts` / `.map` (no `src`/`dist`
@@ -49,6 +54,13 @@ without manual assignment — provided the project has ≥2 filaments. The label
49
54
  objects sit flush in the plate's front-face pockets (printed front-face-down), so
50
55
  they look hidden in the viewport until coloured.
51
56
 
57
+ The 3MF also embeds a **preview thumbnail** (`Metadata/thumbnail.png`, wired via
58
+ the OPC thumbnail relationship) so Windows Explorer and slicers show an image of
59
+ the plate. It's a pure-JS orthographic render of the front face in the configured
60
+ `plateColor` / `accentColor` — no extra dependency, no STL/mesh rendering by the
61
+ shell. (Without it, the file previews blank: the shell never renders the geometry
62
+ itself, it only displays this embedded PNG.)
63
+
52
64
  ## Config
53
65
 
54
66
  Each specific config is **deep-merged over `defaults.json`** (in this
@@ -87,6 +99,9 @@ suffix) is treated as millimetres. Angles (e.g. `cskAngle`) are plain numbers.
87
99
 
88
100
  - `gangs[].type`: `"decora"` | `"duplex"` | `"blank"` | `"button"`.
89
101
  - `gangs[].screws`: `false` to omit that gang's screw holes (default true).
102
+ - `gangs[].filler`: on a **blank** gang, marks it as backed by a filler/insert
103
+ plate, so its cover screws sit on the Decora line (wider spacing) instead of
104
+ the box-ear line.
90
105
  - `gangs[].legend`: single label centred above the gang's opening (positioned
91
106
  clear of the screw holes; `legendY` / `legendSize`).
92
107
  - `gangs[].breaker`: per-gang breaker/circuit label, lower-right of the opening
@@ -163,6 +178,27 @@ See `../decora_wallplate_spec.md` for the dimensional reference and print notes
163
178
  (print front-face-down; PETG/ASA recommended for heat near devices; keep plate
164
179
  and labels the same material family — see the chat note on ABS+PLA).
165
180
 
181
+ ### Appearance, output & engine
182
+
183
+ Top-level keys (all have defaults in `defaults.json`):
184
+
185
+ | Key | Default | Meaning |
186
+ |---|---|---|
187
+ | `output` | `"3mf"` | `"3mf"` (multi-object + thumbnail), `"stl"` (one binary STL per part), or `"scad"` (write the `.scad` only, no render — implies the scad engine). |
188
+ | `engine` | `"manifold"` | `"manifold"` (direct) or `"scad"` (OpenSCAD). `-scad` on the CLI forces scad. |
189
+ | `plateColor` | `"#35589f"` | Plate (filament 1) colour — used for the embedded preview and the scad-engine preview. |
190
+ | `accentColor` | `"#101010"` | Label (filament 2) colour, same uses. |
191
+ | `printReady` | `true` | Flip the model front-face-down (rotate 180° about X) for printing. The preview camera follows the flip, so labels stay upright either way. |
192
+ | `fontFile` | Arial Bold | Path to a `.ttf`/`.otf` for the **manifold** engine's text. |
193
+ | `font` | Liberation Sans Bold | OpenSCAD fontconfig name for the **scad** engine's text. |
194
+ | `openscad` | Program Files path | OpenSCAD executable; only used by the scad engine (auto-install offered if missing). |
195
+ | `bevel` | `1.2mm` | Front-edge chamfer width. |
196
+ | `labelDepth` | `0.6mm` | Recess depth of the label pockets / height of the label solids. |
197
+
198
+ Plate geometry, opening sizes, screw spacing, etc. live under the `dims` object
199
+ (see `defaults.json` for the full list, all unit-suffixed lengths). Override any
200
+ single dim by deep-merge, e.g. `"dims": { "depth": "5mm" }`.
201
+
166
202
  ## Files
167
203
 
168
204
  - `index.ts` — CLI entry / engine dispatch / arg validation
package/index.js CHANGED
@@ -15,9 +15,21 @@
15
15
  import { writeFileSync, unlinkSync } from "fs";
16
16
  import { basename, dirname, join } from "path";
17
17
  import { loadConfig } from "./config.js";
18
- import { generateScad, runOpenscad, readStl } from "./scad.js";
18
+ import { generateScad, runOpenscad, readStl, ensureOpenscad } from "./scad.js";
19
19
  import { buildMeshes } from "./geometry.js";
20
20
  import { build3mf, writeBinaryStl } from "./threemf.js";
21
+ // Preview-PNG view: both engines flip the model front-face-down when printReady
22
+ // (rotate 180 about X -> front normal -Z, world-up -Y), so aim the thumbnail
23
+ // camera at the readable front either way.
24
+ function thumbView(cfg) {
25
+ const f = cfg.printReady;
26
+ return {
27
+ front: [0, 0, f ? -1 : 1],
28
+ up: [0, f ? -1 : 1, 0],
29
+ plateColor: cfg.plateColor,
30
+ accentColor: cfg.accentColor,
31
+ };
32
+ }
21
33
  // Bambu object-list name: "<config> <part>"; the accent text object reads "labels".
22
34
  function partName(cfgName, label) {
23
35
  return `${cfgName} ${label === "legend" ? "labels" : label}`;
@@ -42,20 +54,21 @@ function runScadEngine(cfg, dir) {
42
54
  console.log("wrote", basename(scadPath));
43
55
  if (cfg.output === "scad")
44
56
  return;
57
+ const exe = ensureOpenscad(cfg.openscad); // verify/install OpenSCAD before any export
45
58
  const parts = partList(cfg);
46
59
  if (cfg.output === "stl") {
47
60
  for (const p of parts)
48
- runOpenscad(cfg.openscad, scadPath, join(dir, `${cfg.name}_${p.label}.stl`), p.mode);
61
+ runOpenscad(exe, scadPath, join(dir, `${cfg.name}_${p.label}.stl`), p.mode);
49
62
  }
50
63
  else {
51
64
  const tmp = [];
52
65
  const objects = parts.map((p) => {
53
66
  const stl = join(dir, `_${cfg.name}_${p.label}.stl`);
54
- runOpenscad(cfg.openscad, scadPath, stl, p.mode);
67
+ runOpenscad(exe, scadPath, stl, p.mode);
55
68
  tmp.push(stl);
56
69
  return { name: partName(cfg.name, p.label), mesh: readStl(stl), extruder: partExtruder(p.label) };
57
70
  });
58
- writeFileSync(join(dir, `${cfg.name}.3mf`), build3mf(objects));
71
+ writeFileSync(join(dir, `${cfg.name}.3mf`), build3mf(objects, thumbView(cfg)));
59
72
  for (const t of tmp)
60
73
  unlinkSync(t);
61
74
  console.log(" wrote", `${cfg.name}.3mf`, `(${objects.length} objects: ${objects.map((o) => o.name).join(", ")})`);
@@ -75,7 +88,7 @@ async function runManifoldEngine(cfg, dir) {
75
88
  }
76
89
  else {
77
90
  const objects = meshes.map((m) => ({ name: partName(cfg.name, m.label), mesh: m.mesh, extruder: partExtruder(m.label) }));
78
- writeFileSync(join(dir, `${cfg.name}.3mf`), build3mf(objects));
91
+ writeFileSync(join(dir, `${cfg.name}.3mf`), build3mf(objects, thumbView(cfg)));
79
92
  console.log(" wrote", `${cfg.name}.3mf`, `(${objects.length} objects: ${objects.map((o) => o.name).join(", ")})`);
80
93
  }
81
94
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bobfrankston/wallplater",
3
- "version": "1.0.6",
3
+ "version": "1.0.7",
4
4
  "type": "module",
5
5
  "description": "JSON-driven wall-plate generator (Decora / duplex / blank). Direct-to-3MF via manifold-3d, with an optional OpenSCAD engine.",
6
6
  "main": "index.js",
package/scad.d.ts CHANGED
@@ -1,6 +1,7 @@
1
1
  import { type Resolved } from "./config.js";
2
2
  import type { Mesh } from "./threemf.js";
3
3
  export declare function generateScad(cfg: Resolved): string;
4
+ export declare function ensureOpenscad(exe: string): string;
4
5
  export declare function runOpenscad(exe: string, scadPath: string, outPath: string, mode: string): void;
5
6
  export declare function readStl(path: string): Mesh;
6
7
  //# sourceMappingURL=scad.d.ts.map
package/scad.js CHANGED
@@ -4,8 +4,8 @@
4
4
  * one STL per colour part, and reads STLs back into the common Mesh shape.
5
5
  * Note: rocker/header labels are manifold-engine only and are not emitted here.
6
6
  */
7
- import { readFileSync } from "fs";
8
- import { execFileSync } from "child_process";
7
+ import { readFileSync, existsSync, readSync } from "fs";
8
+ import { execFileSync, spawnSync } from "child_process";
9
9
  import { basename } from "path";
10
10
  import { gangType } from "./config.js";
11
11
  function num(n) {
@@ -163,6 +163,68 @@ if (MODE == "preview") {
163
163
  else if (MODE == "legend") oriented() legend_solids(LABEL_DEPTH);
164
164
  else if (MODE == "breaker") oriented() breaker_solid(LABEL_DEPTH);
165
165
  `;
166
+ // ----- OpenSCAD discovery + optional install -----
167
+ // Return a runnable OpenSCAD command: the configured path if it exists, else a
168
+ // bare "openscad(.exe)" if that's on PATH, else null.
169
+ function findOpenscad(exe) {
170
+ if (exe && existsSync(exe))
171
+ return exe;
172
+ const probe = process.platform === "win32" ? "openscad.exe" : "openscad";
173
+ const r = spawnSync(probe, ["--version"], { stdio: "ignore" });
174
+ if (!r.error && r.status === 0)
175
+ return probe;
176
+ return null;
177
+ }
178
+ // Synchronous y/N prompt (CLI is otherwise synchronous). Non-TTY/EOF -> no.
179
+ function promptYesNo(q) {
180
+ process.stdout.write(q);
181
+ const buf = Buffer.alloc(64);
182
+ try {
183
+ const n = readSync(0, buf, 0, buf.length, null);
184
+ const ans = buf.toString("utf8", 0, n).trim().toLowerCase();
185
+ return ans === "y" || ans === "yes";
186
+ }
187
+ catch {
188
+ return false; // stdin not readable (piped/CI) -> decline
189
+ }
190
+ }
191
+ // Platform install command for OpenSCAD: [exe, args].
192
+ function installCmd() {
193
+ switch (process.platform) {
194
+ case "win32": return ["winget", ["install", "--id", "OpenSCAD.OpenSCAD", "-e",
195
+ "--accept-package-agreements", "--accept-source-agreements"]];
196
+ case "darwin": return ["brew", ["install", "--cask", "openscad"]];
197
+ default: return ["sudo", ["apt-get", "install", "-y", "openscad"]];
198
+ }
199
+ }
200
+ // Ensure OpenSCAD is available; offer to install it if not. Returns the command
201
+ // to invoke, or exits the process if it can't be found/installed.
202
+ export function ensureOpenscad(exe) {
203
+ const found = findOpenscad(exe);
204
+ if (found)
205
+ return found;
206
+ const [cmd, args] = installCmd();
207
+ const shown = `${cmd} ${args.join(" ")}`;
208
+ console.error(`OpenSCAD not found (looked for "${exe}" and "openscad" on PATH).`);
209
+ if (!promptYesNo(`Install it now with:\n ${shown}\n? [y/N] `)) {
210
+ console.error("The -scad engine needs OpenSCAD. Install it, or drop -scad to use the default manifold engine.");
211
+ process.exit(1);
212
+ }
213
+ try {
214
+ execFileSync(cmd, args, { stdio: "inherit" });
215
+ }
216
+ catch {
217
+ console.error("Install failed. Install OpenSCAD manually: https://openscad.org/downloads.html");
218
+ process.exit(1);
219
+ }
220
+ const after = findOpenscad(exe);
221
+ if (!after) {
222
+ console.error('OpenSCAD installed but not yet found — open a new terminal (so PATH refreshes), or set "openscad" in your config to the full exe path.');
223
+ process.exit(1);
224
+ }
225
+ console.log("OpenSCAD installed.");
226
+ return after;
227
+ }
166
228
  export function runOpenscad(exe, scadPath, outPath, mode) {
167
229
  const fmt = outPath.endsWith(".3mf") ? "3mf" : outPath.endsWith(".png") ? "png" : "binstl";
168
230
  execFileSync(exe, [`-D`, `MODE="${mode}"`, "--export-format", fmt, "-o", outPath, scadPath], { stdio: ["ignore", "ignore", "inherit"] });
package/threemf.d.ts CHANGED
@@ -7,6 +7,14 @@ export interface Object3mf {
7
7
  mesh: Mesh;
8
8
  extruder?: number;
9
9
  }
10
- export declare function build3mf(objects: Object3mf[]): Buffer;
10
+ export interface ViewOpts {
11
+ front?: number[];
12
+ up?: number[];
13
+ plateColor?: string;
14
+ accentColor?: string;
15
+ size?: number;
16
+ }
17
+ export declare function build3mf(objects: Object3mf[], view?: ViewOpts): Buffer;
18
+ export declare function renderThumbnail(objects: Object3mf[], opts?: ViewOpts): Buffer;
11
19
  export declare function writeBinaryStl(mesh: Mesh): Buffer;
12
20
  //# sourceMappingURL=threemf.d.ts.map
package/threemf.js CHANGED
@@ -5,12 +5,12 @@
5
5
  * individually colourable objects), packed into the OPC zip by hand.
6
6
  * - writeBinaryStl(): a single binary STL (used for the manifold STL path).
7
7
  */
8
- import { deflateRawSync } from "zlib";
8
+ import { deflateRawSync, deflateSync } from "zlib";
9
9
  function xmlEscape(s) {
10
10
  return s.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;");
11
11
  }
12
12
  // ---------- multi-object 3MF (core spec, no slicer settings) ----------
13
- export function build3mf(objects) {
13
+ export function build3mf(objects, view = {}) {
14
14
  // separate, named top-level objects (each individually selectable/colourable
15
15
  // in Bambu), all at the same transform so the labels stay seated in the
16
16
  // plate's pockets.
@@ -43,15 +43,21 @@ ${objects.map((o, i) => ` <object id="${i + 1}">
43
43
  </object>`).join("\n")}
44
44
  </config>
45
45
  `;
46
+ // Package thumbnail (PNG). Windows Explorer / slicers display this embedded
47
+ // image as the file's preview; they do NOT render the mesh themselves. It is
48
+ // wired up via the OPC thumbnail relationship below + the png content type.
49
+ const thumb = renderThumbnail(objects, view);
46
50
  const ct = `<?xml version="1.0" encoding="UTF-8"?>
47
51
  <Types xmlns="http://schemas.openxmlformats.org/package/2006/content-types">
48
52
  <Default Extension="rels" ContentType="application/vnd.openxmlformats-package.relationships+xml"/>
49
53
  <Default Extension="model" ContentType="application/vnd.ms-package.3dmanufacturing-3dmodel+xml"/>
54
+ <Default Extension="png" ContentType="image/png"/>
50
55
  </Types>
51
56
  `;
52
57
  const rels = `<?xml version="1.0" encoding="UTF-8"?>
53
58
  <Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">
54
59
  <Relationship Target="/3D/3dmodel.model" Id="rel0" Type="http://schemas.microsoft.com/3dmanufacturing/2013/01/3dmodel"/>
60
+ <Relationship Target="/Metadata/thumbnail.png" Id="rel-thumb" Type="http://schemas.openxmlformats.org/package/2006/relationships/metadata/thumbnail"/>
55
61
  </Relationships>
56
62
  `;
57
63
  return zip([
@@ -59,8 +65,137 @@ ${objects.map((o, i) => ` <object id="${i + 1}">
59
65
  { name: "_rels/.rels", data: Buffer.from(rels) },
60
66
  { name: "3D/3dmodel.model", data: Buffer.from(model) },
61
67
  { name: "Metadata/model_settings.config", data: Buffer.from(settings) },
68
+ { name: "Metadata/thumbnail.png", data: thumb },
62
69
  ]);
63
70
  }
71
+ // ---------- preview thumbnail (orthographic, pure-JS, no deps) ----------
72
+ function hexRgb(s, fallback) {
73
+ const m = /^#?([0-9a-fA-F]{6})$/.exec(s ?? "");
74
+ if (!m)
75
+ return fallback;
76
+ const n = parseInt(m[1], 16);
77
+ return [(n >> 16) & 255, (n >> 8) & 255, n & 255];
78
+ }
79
+ function vcross(a, b) {
80
+ return [a[1] * b[2] - a[2] * b[1], a[2] * b[0] - a[0] * b[2], a[0] * b[1] - a[1] * b[0]];
81
+ }
82
+ function vdot(a, b) { return a[0] * b[0] + a[1] * b[1] + a[2] * b[2]; }
83
+ function vnorm(a) { const l = Math.hypot(a[0], a[1], a[2]) || 1; return [a[0] / l, a[1] / l, a[2] / l]; }
84
+ // Render a top-down (front-facing) orthographic preview of all objects to a PNG.
85
+ // Plate-extruder parts take plateColor, accent parts accentColor; a fixed light
86
+ // gives the flat face a little form. Background is transparent.
87
+ export function renderThumbnail(objects, opts = {}) {
88
+ const S = opts.size ?? 320;
89
+ const front = vnorm(opts.front ?? [0, 0, 1]);
90
+ const up = vnorm(opts.up ?? [0, 1, 0]);
91
+ const right = vnorm(vcross(up, front)); // right,up,front right-handed -> non-mirrored
92
+ const camUp = up;
93
+ const plateC = hexRgb(opts.plateColor, [120, 140, 200]);
94
+ const accentC = hexRgb(opts.accentColor, [20, 20, 24]);
95
+ // light from upper-left-front in screen space, expressed in world coords
96
+ const light = vnorm([
97
+ -0.4 * right[0] + 0.5 * camUp[0] + 0.9 * front[0],
98
+ -0.4 * right[1] + 0.5 * camUp[1] + 0.9 * front[1],
99
+ -0.4 * right[2] + 0.5 * camUp[2] + 0.9 * front[2],
100
+ ]);
101
+ const AMBIENT = 0.45;
102
+ const tris = [];
103
+ let uMin = Infinity, uMax = -Infinity, vMin = Infinity, vMax = -Infinity;
104
+ for (const o of objects) {
105
+ const col = (o.extruder ?? 1) === 1 ? plateC : accentC;
106
+ const pv = o.mesh.verts.map((p) => [vdot(p, right), vdot(p, camUp), vdot(p, front)]);
107
+ for (const p of pv) {
108
+ if (p[0] < uMin)
109
+ uMin = p[0];
110
+ if (p[0] > uMax)
111
+ uMax = p[0];
112
+ if (p[1] < vMin)
113
+ vMin = p[1];
114
+ if (p[1] > vMax)
115
+ vMax = p[1];
116
+ }
117
+ for (const t of o.mesh.tris) {
118
+ const a = o.mesh.verts[t[0]], b = o.mesh.verts[t[1]], c = o.mesh.verts[t[2]];
119
+ const n = vnorm(vcross([b[0] - a[0], b[1] - a[1], b[2] - a[2]], [c[0] - a[0], c[1] - a[1], c[2] - a[2]]));
120
+ if (vdot(n, front) <= 0)
121
+ continue; // cull faces pointing away from viewer
122
+ const inten = AMBIENT + (1 - AMBIENT) * Math.max(0, vdot(n, light));
123
+ const shade = [
124
+ Math.min(255, col[0] * inten), Math.min(255, col[1] * inten), Math.min(255, col[2] * inten),
125
+ ];
126
+ const A = pv[t[0]], B = pv[t[1]], C = pv[t[2]];
127
+ tris.push({ x: [A[0], B[0], C[0]], y: [A[1], B[1], C[1]], z: [A[2], B[2], C[2]], col: shade });
128
+ }
129
+ }
130
+ const pad = Math.round(S * 0.08);
131
+ const span = Math.max(uMax - uMin, vMax - vMin, 1e-6);
132
+ const scale = (S - 2 * pad) / span;
133
+ const ox = pad + (S - 2 * pad - (uMax - uMin) * scale) / 2;
134
+ const oy = pad + (S - 2 * pad - (vMax - vMin) * scale) / 2;
135
+ const sx = (u) => ox + (u - uMin) * scale;
136
+ const sy = (v) => S - (oy + (v - vMin) * scale); // flip: image row 0 is top
137
+ const rgba = Buffer.alloc(S * S * 4); // transparent background
138
+ const zbuf = new Float32Array(S * S).fill(-Infinity);
139
+ for (const tr of tris) {
140
+ const px = [sx(tr.x[0]), sx(tr.x[1]), sx(tr.x[2])];
141
+ const py = [sy(tr.y[0]), sy(tr.y[1]), sy(tr.y[2])];
142
+ const area = (px[1] - px[0]) * (py[2] - py[0]) - (px[2] - px[0]) * (py[1] - py[0]);
143
+ if (Math.abs(area) < 1e-9)
144
+ continue;
145
+ const minX = Math.max(0, Math.floor(Math.min(px[0], px[1], px[2])));
146
+ const maxX = Math.min(S - 1, Math.ceil(Math.max(px[0], px[1], px[2])));
147
+ const minY = Math.max(0, Math.floor(Math.min(py[0], py[1], py[2])));
148
+ const maxY = Math.min(S - 1, Math.ceil(Math.max(py[0], py[1], py[2])));
149
+ for (let yy = minY; yy <= maxY; yy++) {
150
+ for (let xx = minX; xx <= maxX; xx++) {
151
+ const cx = xx + 0.5, cy = yy + 0.5;
152
+ const w0 = ((px[1] - cx) * (py[2] - cy) - (px[2] - cx) * (py[1] - cy)) / area;
153
+ const w1 = ((px[2] - cx) * (py[0] - cy) - (px[0] - cx) * (py[2] - cy)) / area;
154
+ const w2 = 1 - w0 - w1;
155
+ if (w0 < 0 || w1 < 0 || w2 < 0)
156
+ continue;
157
+ const z = w0 * tr.z[0] + w1 * tr.z[1] + w2 * tr.z[2];
158
+ const idx = yy * S + xx;
159
+ if (z <= zbuf[idx])
160
+ continue;
161
+ zbuf[idx] = z;
162
+ const o4 = idx * 4;
163
+ rgba[o4] = tr.col[0];
164
+ rgba[o4 + 1] = tr.col[1];
165
+ rgba[o4 + 2] = tr.col[2];
166
+ rgba[o4 + 3] = 255;
167
+ }
168
+ }
169
+ }
170
+ return encodePng(S, S, rgba);
171
+ }
172
+ // minimal 8-bit RGBA PNG (single IDAT, no filtering)
173
+ function encodePng(width, height, rgba) {
174
+ const chunk = (type, data) => {
175
+ const t = Buffer.from(type, "latin1");
176
+ const len = Buffer.alloc(4);
177
+ len.writeUInt32BE(data.length, 0);
178
+ const crc = Buffer.alloc(4);
179
+ crc.writeUInt32BE(crc32(Buffer.concat([t, data])), 0);
180
+ return Buffer.concat([len, t, data, crc]);
181
+ };
182
+ const ihdr = Buffer.alloc(13);
183
+ ihdr.writeUInt32BE(width, 0);
184
+ ihdr.writeUInt32BE(height, 4);
185
+ ihdr[8] = 8;
186
+ ihdr[9] = 6;
187
+ ihdr[10] = 0;
188
+ ihdr[11] = 0;
189
+ ihdr[12] = 0; // 8-bit, RGBA
190
+ const row = width * 4;
191
+ const raw = Buffer.alloc(height * (1 + row));
192
+ for (let y = 0; y < height; y++) {
193
+ raw[y * (1 + row)] = 0; // filter: none
194
+ rgba.copy(raw, y * (1 + row) + 1, y * row, (y + 1) * row);
195
+ }
196
+ const sig = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]);
197
+ return Buffer.concat([sig, chunk("IHDR", ihdr), chunk("IDAT", deflateSync(raw)), chunk("IEND", Buffer.alloc(0))]);
198
+ }
64
199
  // ---------- binary STL (single object) ----------
65
200
  export function writeBinaryStl(mesh) {
66
201
  const buf = Buffer.alloc(84 + mesh.tris.length * 50);