@bobfrankston/wallplater 1.0.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,101 @@
1
+ # @bobfrankston/wallplater
2
+
3
+ JSON-driven wall-plate generator (Decora / duplex / blank gangs). Reads a small
4
+ JSON config and emits a multi-object 3MF (plate + labels as separate,
5
+ individually colourable objects) or per-part STLs.
6
+
7
+ ## Engines
8
+
9
+ | Engine | Flag | Needs OpenSCAD? | Speed | Notes |
10
+ |---|---|---|---|---|
11
+ | **manifold** (default) | — | no | ~1 s | Direct geometry via [manifold-3d](https://github.com/elalish/manifold) (Clipper2 + robust CSG). Text via [opentype.js](https://github.com/opentypejs/opentype.js). |
12
+ | **scad** (legacy) | `-scad` or `"engine":"scad"` | yes | ~30 s | Writes a `.scad` and shells out to OpenSCAD. Reference / fallback. Does **not** render rocker/header labels. |
13
+
14
+ The manifold engine has no system dependency beyond Node and is the path
15
+ forward; `-scad` is kept for parity/verification.
16
+
17
+ ## Usage
18
+
19
+ Requires Node 24+ (native TypeScript + ESM — run `.ts` directly). `tsc` is the
20
+ global install; `npm run typecheck` runs `tsc --noEmit` (type-check only — this
21
+ project runs `.ts` and never emits `.js`).
22
+
23
+ ```sh
24
+ node index.ts ../KitchenBack.json # manifold engine -> KitchenBack.3mf
25
+ node index.ts ../KitchenBack.json -scad # OpenSCAD engine (same result)
26
+ ```
27
+
28
+ - Outputs are written next to the **config file**, named after the config's `name`.
29
+ - Unknown flags are rejected. `-scad` (or `--scad`) selects the OpenSCAD engine.
30
+
31
+ ## Config
32
+
33
+ Each specific config is **deep-merged over `defaults.json`** (in this
34
+ directory), so a specific file carries only what differs — often just `name`,
35
+ `gangs`, and a `breaker.switch`. Every default lives in `defaults.json`, not in
36
+ code.
37
+
38
+ Lengths are written **CSS-style with a unit suffix**: `"0.375in"`, `"5mm"`,
39
+ `"-42mm"`. A bare number is treated as millimetres. Angles are plain numbers.
40
+
41
+ ```jsonc
42
+ // KitchenBack.json — only what differs from defaults.json
43
+ {
44
+ "name": "KitchenBack",
45
+ "gangs": [
46
+ { "type": "decora", "legend": "Disposal" },
47
+ { "type": "blank", "screws": false }, // middle blank, no screw holes
48
+ { "type": "decora" }
49
+ ]
50
+ // add later: "breaker": { "switch": "A8" }
51
+ }
52
+ ```
53
+
54
+ - `gangs[].type`: `"decora"` | `"duplex"` | `"blank"`.
55
+ - `gangs[].screws`: `false` to omit that gang's screw holes (default true).
56
+ - `gangs[].legend`: single label centred below the gang.
57
+ - `breaker`: `{ "switch": "A8", "size": "0.375in", "inset": "0.25in" }` — the
58
+ lower-right breaker/circuit label. `switch` is the text; `size`/`inset` come
59
+ from `defaults.json`, so a config usually sets only `breaker.switch`.
60
+
61
+ ### Stacked rocker labels (Leviton 1755 triple rocker, etc.)
62
+
63
+ The 1755 is three rockers stacked in one **standard Decora opening**, so the
64
+ opening is unchanged — only the labelling differs. Per gang:
65
+
66
+ ```jsonc
67
+ {
68
+ "type": "decora",
69
+ "header": "ON / OFF", // centred above the opening
70
+ "rockers": [ // top -> bottom (1-3+ entries)
71
+ { "left": "1", "right": "FAN" }, // a label each side of every rocker
72
+ { "left": "2", "right": "LIGHT" },
73
+ { "left": "3", "right": "HEAT" }
74
+ ]
75
+ }
76
+ ```
77
+
78
+ Rockers are spaced evenly down the opening; `left` labels are right-aligned snug
79
+ to the opening, `right` labels left-aligned. `legend` / `header` / `rockers`
80
+ all print in the accent colour (the `legend` object). Relevant defaults:
81
+ `rockerSize` (3.5mm), `rockerGap` (1.5mm), `headerSize` (4mm), `headerGap`
82
+ (5mm). Keep side labels short on multi-gang plates — the side margin is ~18 mm
83
+ on an end/single gang but only ~6 mm between interior gangs; long labels can
84
+ overflow. See `../triple_demo.json` for a worked single-gang example.
85
+
86
+ > Rocker/header labels are rendered by the **manifold engine only**. The
87
+ > `-scad` engine ignores them (it warns).
88
+
89
+ See `../decora_wallplate_spec.md` for the dimensional reference and print notes
90
+ (print front-face-down; PETG/ASA recommended for heat near devices; keep plate
91
+ and labels the same material family — see the chat note on ABS+PLA).
92
+
93
+ ## Files
94
+
95
+ - `index.ts` — CLI entry / engine dispatch / arg validation
96
+ - `config.ts` — schema, unit parsing, defaults.json merge, loader
97
+ - `defaults.json` — all default values (merged under each specific config)
98
+ - `geometry.ts` — manifold engine (direct geometry)
99
+ - `text.ts` — glyph outlines -> filled contours (opentype.js)
100
+ - `scad.ts` — legacy OpenSCAD engine (`.scad` generation + STL readback)
101
+ - `threemf.ts` — multi-object 3MF writer, binary STL writer
package/config.ts ADDED
@@ -0,0 +1,171 @@
1
+ /*
2
+ * config.ts — config schema, defaults, and loading.
3
+ *
4
+ * A specific config (e.g. KitchenBack.json) is deep-merged over defaults.json
5
+ * (in this directory), so a specific file need only carry what differs — often
6
+ * just `name`, `gangs`, and a `breaker.switch`.
7
+ *
8
+ * Lengths are written CSS-style with a unit suffix: "0.375in", "5mm", "-42mm".
9
+ * A bare number is treated as millimetres. Angles are plain numbers (degrees).
10
+ */
11
+
12
+ import { readFileSync } from "fs";
13
+ import { join } from "path";
14
+
15
+ export const IN = 25.4;
16
+
17
+ export type GangType = "blank" | "decora" | "duplex";
18
+ export type Engine = "manifold" | "scad";
19
+
20
+ /** A length: CSS-style unit string ("0.375in" / "5mm") or a bare number (mm). */
21
+ export type Len = string | number;
22
+
23
+ /** Parse a length to millimetres. */
24
+ export function mm(v: Len): number {
25
+ if (typeof v === "number") return v;
26
+ const m = /^\s*(-?[\d.]+)\s*(mm|in)?\s*$/i.exec(v);
27
+ if (!m) throw new Error(`invalid length "${v}" (use e.g. "0.375in" or "5mm")`);
28
+ const n = parseFloat(m[1]);
29
+ return m[2] && m[2].toLowerCase() === "in" ? n * IN : n;
30
+ }
31
+
32
+ export interface RockerCfg {
33
+ left?: string; // label in the left margin, beside this rocker
34
+ right?: string; // label in the right margin, beside this rocker
35
+ }
36
+
37
+ export interface GangCfg {
38
+ type?: GangType; // "blank" | "decora" | "duplex"
39
+ opening?: boolean; // legacy: true->decora, false->blank
40
+ legend?: string; // small label under this gang
41
+ filler?: boolean; // blank gang backed by a filler/insert plate ->
42
+ // cover screws sit on the Decora line, not the box-ear line
43
+ screws?: boolean; // default true; set false to omit this gang's screw holes
44
+ rockers?: RockerCfg[]; // top -> bottom, for stacked multi-rocker devices (e.g. Leviton 1755
45
+ // triple rocker). Labels sit beside each rocker; the opening is unchanged.
46
+ header?: string; // text centred above this gang's opening, e.g. "ON / OFF" / "OFF / ON"
47
+ }
48
+
49
+ export interface BreakerCfg {
50
+ switch?: string; // breaker/circuit label in the lower-right corner, e.g. "A8"
51
+ size?: Len; // text size
52
+ inset?: Len; // inset from the right and bottom edges
53
+ }
54
+
55
+ export interface DimsCfg {
56
+ gangPitch?: Len; oneGangWidth?: Len; height?: Len; depth?: Len;
57
+ faceT?: Len; rimW?: Len; cornerR?: Len;
58
+ openW?: Len; openH?: Len; openR?: Len;
59
+ duplexW?: Len; duplexH?: Len; duplexSpacing?: Len;
60
+ screwSpacing?: Len; blankScrewSpacing?: Len;
61
+ screwClearD?: Len; screwHeadD?: Len; cskAngle?: number;
62
+ }
63
+
64
+ export interface Config {
65
+ name?: string;
66
+ gangs: GangCfg[]; // left -> right; length = number of gangs
67
+ breaker?: BreakerCfg;
68
+ legendSize?: Len; legendY?: Len; // per-gang legend (below the opening)
69
+ rockerSize?: Len; rockerGap?: Len; // per-rocker side labels
70
+ headerSize?: Len; headerGap?: Len; // per-gang on/off header
71
+ bevel?: Len; labelDepth?: Len;
72
+ blankScrews?: boolean; // blank gangs get a screw pair on the Decora line
73
+ font?: string; // OpenSCAD fontconfig name (scad engine)
74
+ fontFile?: string; // path to a .ttf/.otf (manifold engine)
75
+ plateColor?: string;
76
+ accentColor?: string;
77
+ printReady?: boolean;
78
+ dims?: DimsCfg;
79
+ output?: "scad" | "stl" | "3mf";
80
+ engine?: Engine; // "manifold" (direct) | "scad" (OpenSCAD)
81
+ openscad?: string;
82
+ }
83
+
84
+ /** Resolved breaker: lengths reduced to millimetres. */
85
+ export interface Breaker { switch: string; size: number; inset: number; }
86
+
87
+ /** Resolved dimensions, all in millimetres (cskAngle in degrees). */
88
+ export interface Dims {
89
+ gangPitch: number; oneGangWidth: number; height: number; depth: number;
90
+ faceT: number; rimW: number; cornerR: number;
91
+ openW: number; openH: number; openR: number;
92
+ duplexW: number; duplexH: number; duplexSpacing: number;
93
+ screwSpacing: number; blankScrewSpacing: number;
94
+ screwClearD: number; screwHeadD: number; cskAngle: number;
95
+ }
96
+
97
+ /** Fully resolved config: every length reduced to millimetres. */
98
+ export interface Resolved {
99
+ name: string;
100
+ gangs: GangCfg[];
101
+ breaker: Breaker;
102
+ legendSize: number; legendY: number;
103
+ rockerSize: number; rockerGap: number;
104
+ headerSize: number; headerGap: number;
105
+ bevel: number; labelDepth: number;
106
+ blankScrews: boolean;
107
+ font: string; fontFile: string;
108
+ plateColor: string; accentColor: string;
109
+ printReady: boolean;
110
+ dims: Dims;
111
+ output: "scad" | "stl" | "3mf";
112
+ engine: Engine;
113
+ openscad: string;
114
+ }
115
+
116
+ // JSON.parse returns dynamic shape; merged then validated below.
117
+ function readJson(path: string): any {
118
+ return JSON.parse(readFileSync(path, "utf8"));
119
+ }
120
+
121
+ /** Deep-merge plain objects; arrays and scalars from `over` replace `base`. */
122
+ function deepMerge(base: any, over: any): any {
123
+ if (over === null || typeof over !== "object" || Array.isArray(over)) return over;
124
+ const isObj = (v: any): boolean => v !== null && typeof v === "object" && !Array.isArray(v);
125
+ const out: any = { ...base };
126
+ for (const k of Object.keys(over))
127
+ out[k] = isObj(base?.[k]) && isObj(over[k]) ? deepMerge(base[k], over[k]) : over[k];
128
+ return out;
129
+ }
130
+
131
+ export function loadConfig(path: string): Resolved {
132
+ const defaults = readJson(join(import.meta.dirname, "defaults.json"));
133
+ const raw = readJson(path) as Config & { code?: unknown };
134
+ if (raw.code !== undefined) // renamed 2026-06-08; don't silently ignore old configs
135
+ console.warn(" ! config field 'code' is now 'breaker': { switch }. The 'code' value is ignored.");
136
+ // every default lives in defaults.json; the merge guarantees presence
137
+ const c = deepMerge(defaults, raw) as Config;
138
+ if (!c.gangs || c.gangs.length === 0) throw new Error("config needs a non-empty 'gangs' array");
139
+
140
+ const d = c.dims;
141
+ const dims: Dims = {
142
+ gangPitch: mm(d.gangPitch), oneGangWidth: mm(d.oneGangWidth), height: mm(d.height), depth: mm(d.depth),
143
+ faceT: mm(d.faceT), rimW: mm(d.rimW), cornerR: mm(d.cornerR),
144
+ openW: mm(d.openW), openH: mm(d.openH), openR: mm(d.openR),
145
+ duplexW: mm(d.duplexW), duplexH: mm(d.duplexH), duplexSpacing: mm(d.duplexSpacing),
146
+ screwSpacing: mm(d.screwSpacing), blankScrewSpacing: mm(d.blankScrewSpacing),
147
+ screwClearD: mm(d.screwClearD), screwHeadD: mm(d.screwHeadD), cskAngle: d.cskAngle,
148
+ };
149
+ const b = c.breaker;
150
+ return {
151
+ name: c.name,
152
+ gangs: c.gangs,
153
+ breaker: { switch: b.switch ?? "", size: mm(b.size), inset: mm(b.inset) },
154
+ legendSize: mm(c.legendSize), legendY: mm(c.legendY),
155
+ rockerSize: mm(c.rockerSize), rockerGap: mm(c.rockerGap),
156
+ headerSize: mm(c.headerSize), headerGap: mm(c.headerGap),
157
+ bevel: mm(c.bevel), labelDepth: mm(c.labelDepth),
158
+ blankScrews: c.blankScrews,
159
+ font: c.font, fontFile: c.fontFile,
160
+ plateColor: c.plateColor, accentColor: c.accentColor,
161
+ printReady: c.printReady,
162
+ dims,
163
+ output: c.output, engine: c.engine, openscad: c.openscad,
164
+ };
165
+ }
166
+
167
+ export function gangType(g: GangCfg): GangType {
168
+ if (g.type) return g.type;
169
+ if (g.opening === true) return "decora";
170
+ return "blank";
171
+ }
package/defaults.json ADDED
@@ -0,0 +1,41 @@
1
+ {
2
+ "name": "wallplate",
3
+ "breaker": { "switch": "", "size": "0.375in", "inset": "0.25in" },
4
+ "legendSize": "5mm",
5
+ "legendY": "-42mm",
6
+ "rockerSize": "3.5mm",
7
+ "rockerGap": "1.5mm",
8
+ "headerSize": "4mm",
9
+ "headerGap": "5mm",
10
+ "bevel": "1.2mm",
11
+ "labelDepth": "0.6mm",
12
+ "blankScrews": true,
13
+ "font": "Liberation Sans:style=Bold",
14
+ "fontFile": "C:/Windows/Fonts/arialbd.ttf",
15
+ "plateColor": "#35589f",
16
+ "accentColor": "#101010",
17
+ "printReady": true,
18
+ "output": "3mf",
19
+ "engine": "manifold",
20
+ "openscad": "C:/Program Files/OpenSCAD/openscad.exe",
21
+ "dims": {
22
+ "gangPitch": "1.8125in",
23
+ "oneGangWidth": "2.75in",
24
+ "height": "4.5in",
25
+ "depth": "4mm",
26
+ "faceT": "2.2mm",
27
+ "rimW": "3mm",
28
+ "cornerR": "5mm",
29
+ "openW": "1.3125in",
30
+ "openH": "2.625in",
31
+ "openR": "3mm",
32
+ "duplexW": "1.34375in",
33
+ "duplexH": "1.125in",
34
+ "duplexSpacing": "1.5in",
35
+ "screwSpacing": "3.8125in",
36
+ "blankScrewSpacing": "3.281in",
37
+ "screwClearD": "4mm",
38
+ "screwHeadD": "0.3125in",
39
+ "cskAngle": 82
40
+ }
41
+ }
package/geometry.ts ADDED
@@ -0,0 +1,161 @@
1
+ /*
2
+ * geometry.ts — the manifold engine (direct, no OpenSCAD).
3
+ *
4
+ * Rebuilds the SCAD model with manifold-3d (Clipper2 for 2D, robust CSG for
5
+ * 3D). Produces one Mesh per colour part (plate / legend / code), already
6
+ * print-oriented, ready for build3mf() or writeBinaryStl().
7
+ */
8
+
9
+ import Module from "manifold-3d";
10
+ import type { ManifoldToplevel, Manifold } from "manifold-3d";
11
+ import { type Resolved, gangType } from "./config.ts";
12
+ import type { Mesh } from "./threemf.ts";
13
+ import { loadFont, textToContours, type Contours, type HAlign } from "./text.ts";
14
+
15
+ const SEG = 96; // circular segments (matches the SCAD $fn = 96)
16
+
17
+ let wasm: ManifoldToplevel | null = null;
18
+ async function getWasm(): Promise<ManifoldToplevel> {
19
+ if (!wasm) {
20
+ wasm = await Module();
21
+ wasm.setup();
22
+ wasm.setCircularSegments(SEG);
23
+ }
24
+ return wasm;
25
+ }
26
+
27
+ export interface LabeledMesh { label: string; mesh: Mesh; }
28
+
29
+ function toMesh(m: Manifold): Mesh {
30
+ const g = m.getMesh();
31
+ const np = g.numProp;
32
+ const vp = g.vertProperties;
33
+ const nv = vp.length / np;
34
+ const verts: number[][] = new Array(nv);
35
+ for (let i = 0; i < nv; i++) verts[i] = [vp[i * np], vp[i * np + 1], vp[i * np + 2]];
36
+ const tv = g.triVerts;
37
+ const nt = tv.length / 3;
38
+ const tris: number[][] = new Array(nt);
39
+ for (let t = 0; t < nt; t++) tris[t] = [tv[t * 3], tv[t * 3 + 1], tv[t * 3 + 2]];
40
+ return { verts, tris };
41
+ }
42
+
43
+ export async function buildMeshes(cfg: Resolved): Promise<LabeledMesh[]> {
44
+ const w = await getWasm();
45
+ const { Manifold, CrossSection } = w;
46
+ const d = cfg.dims;
47
+
48
+ const CSK_DEPTH = (d.screwHeadD - d.screwClearD) / 2 / Math.tan((d.cskAngle / 2) * Math.PI / 180);
49
+ const N = cfg.gangs.length;
50
+ const PLATE_W = d.oneGangWidth + (N - 1) * d.gangPitch;
51
+ const PLATE_H = d.height;
52
+ const DEPTH = d.depth, FACE_T = d.faceT, RIM_W = d.rimW, CORNER_R = d.cornerR, BEVEL = cfg.bevel;
53
+ const LABEL_DEPTH = cfg.labelDepth;
54
+ const gangX = (i: number): number => (i - (N - 1) / 2) * d.gangPitch;
55
+
56
+ // rounded rectangle: square inset by r, inflated by r with round corners
57
+ const rrect = (wd: number, ht: number, r: number) =>
58
+ CrossSection.square([wd - 2 * r, ht - 2 * r], true).offset(r, "Round", 2, SEG);
59
+
60
+ // ----- plate body: rim frame + face slab + front bevel -----
61
+ const outline = () => rrect(PLATE_W, PLATE_H, CORNER_R);
62
+ const frame = rrect(PLATE_W, PLATE_H, CORNER_R)
63
+ .subtract(rrect(PLATE_W - 2 * RIM_W, PLATE_H - 2 * RIM_W, CORNER_R - RIM_W));
64
+ let body = frame.extrude(DEPTH - FACE_T);
65
+ body = body.add(outline().extrude(FACE_T - BEVEL + 0.001).translate([0, 0, DEPTH - FACE_T]));
66
+ if (BEVEL > 0) {
67
+ // SCAD does a hull(offset(-BEVEL)); a top-scale extrude is the close,
68
+ // robust manifold equivalent for a thin perimeter chamfer.
69
+ const topScale: [number, number] = [(PLATE_W - 2 * BEVEL) / PLATE_W, (PLATE_H - 2 * BEVEL) / PLATE_H];
70
+ body = body.add(outline().extrude(BEVEL, 0, 0, topScale).translate([0, 0, DEPTH - BEVEL]));
71
+ }
72
+
73
+ // ----- cutters: openings + screw holes -----
74
+ const cutters: Manifold[] = [];
75
+ const screw = (x: number, y: number): Manifold => {
76
+ const shaft = Manifold.cylinder(DEPTH + 2, d.screwClearD / 2, d.screwClearD / 2, SEG).translate([x, y, -1]);
77
+ const csk = Manifold.cylinder(CSK_DEPTH + 0.01, d.screwClearD / 2, d.screwHeadD / 2, SEG).translate([x, y, DEPTH - CSK_DEPTH]);
78
+ return shaft.add(csk);
79
+ };
80
+ const screwsPair = (x: number, sp: number): void => { cutters.push(screw(x, sp / 2), screw(x, -sp / 2)); };
81
+
82
+ for (let i = 0; i < N; i++) {
83
+ const x = gangX(i);
84
+ const g = cfg.gangs[i];
85
+ const t = gangType(g);
86
+ const sc = g.screws !== false; // place screws on this gang?
87
+ if (t === "decora") {
88
+ cutters.push(rrect(d.openW, d.openH, d.openR).extrude(DEPTH + 2).translate([x, 0, -1]));
89
+ if (sc) screwsPair(x, d.screwSpacing);
90
+ } else if (t === "duplex") {
91
+ const cs = CrossSection.circle(d.duplexW / 2, SEG).intersect(CrossSection.square([d.duplexW, d.duplexH], true));
92
+ for (const s of [-1, 1]) cutters.push(cs.extrude(DEPTH + 2).translate([x, s * d.duplexSpacing / 2, -1]));
93
+ if (sc) cutters.push(screw(x, 0)); // single centre screw
94
+ } else if (cfg.blankScrews && sc) { // blank: box-ear line, or Decora line if a filler plate
95
+ screwsPair(x, g.filler ? d.screwSpacing : d.blankScrewSpacing);
96
+ }
97
+ }
98
+
99
+ // ----- labels: collect every accent-colour text placement -----
100
+ // gang legend (below), per-gang header (above), and per-rocker side labels
101
+ // all share the accent colour, so they fold into one "legend" object.
102
+ interface Place { txt: string; size: number; halign: HAlign; x: number; y: number; }
103
+ const openHalf = d.openW / 2, openTop = d.openH / 2;
104
+ const accent: Place[] = [];
105
+ for (let i = 0; i < N; i++) {
106
+ const g = cfg.gangs[i];
107
+ const gx = gangX(i);
108
+ if ((g.legend ?? "") !== "")
109
+ accent.push({ txt: g.legend, size: cfg.legendSize, halign: "center", x: gx, y: cfg.legendY });
110
+ if ((g.header ?? "") !== "")
111
+ accent.push({ txt: g.header, size: cfg.headerSize, halign: "center", x: gx, y: openTop + cfg.headerGap });
112
+ const rk = g.rockers ?? [];
113
+ for (let j = 0; j < rk.length; j++) {
114
+ const yr = openTop - (j + 0.5) * (d.openH / rk.length); // rockers top -> bottom
115
+ if ((rk[j].left ?? "") !== "") // right-aligned, snug to opening
116
+ accent.push({ txt: rk[j].left, size: cfg.rockerSize, halign: "right", x: gx - openHalf - cfg.rockerGap, y: yr });
117
+ if ((rk[j].right ?? "") !== "") // left-aligned, snug to opening
118
+ accent.push({ txt: rk[j].right, size: cfg.rockerSize, halign: "left", x: gx + openHalf + cfg.rockerGap, y: yr });
119
+ }
120
+ }
121
+ const hasAccent = accent.length > 0;
122
+ const hasBreaker = cfg.breaker.switch !== "";
123
+ const font = (hasAccent || hasBreaker) ? loadFont(cfg.fontFile) : null;
124
+
125
+ const textManifold = (txt: string, size: number, halign: HAlign, height: number): Manifold | null => {
126
+ const contours: Contours = textToContours(font, txt, size, halign);
127
+ if (contours.length === 0) return null;
128
+ return new CrossSection(contours, "NonZero").extrude(height);
129
+ };
130
+ const accentManifold = (height: number): Manifold | null => {
131
+ let acc: Manifold | null = null;
132
+ for (const p of accent) {
133
+ const m = textManifold(p.txt, p.size, p.halign, height);
134
+ if (!m) continue;
135
+ const placed = m.translate([p.x, p.y, DEPTH - LABEL_DEPTH]);
136
+ acc = acc ? acc.add(placed) : placed;
137
+ }
138
+ return acc;
139
+ };
140
+ const breakerManifold = (height: number): Manifold | null => {
141
+ const m = textManifold(cfg.breaker.switch, cfg.breaker.size, "right", height);
142
+ if (!m) return null;
143
+ return m.translate([PLATE_W / 2 - cfg.breaker.inset, -PLATE_H / 2 + cfg.breaker.inset, DEPTH - LABEL_DEPTH]);
144
+ };
145
+
146
+ // ----- subtract cutters + label pockets from the plate -----
147
+ let cutAll: Manifold | null = cutters.length ? Manifold.union(cutters) : null;
148
+ for (const p of [accentManifold(LABEL_DEPTH + 0.02), breakerManifold(LABEL_DEPTH + 0.02)]) {
149
+ if (p) cutAll = cutAll ? cutAll.add(p) : p;
150
+ }
151
+ const plate = cutAll ? body.subtract(cutAll) : body;
152
+
153
+ // print-orientation: flip front-face-down (rotation, not mirror) so left stays left
154
+ const orient = (m: Manifold): Manifold =>
155
+ cfg.printReady ? m.rotate([180, 0, 0]).translate([0, 0, DEPTH]) : m;
156
+
157
+ const out: LabeledMesh[] = [{ label: "plate", mesh: toMesh(orient(plate)) }];
158
+ if (hasAccent) { const m = accentManifold(LABEL_DEPTH); if (m) out.push({ label: "legend", mesh: toMesh(orient(m)) }); }
159
+ if (hasBreaker) { const m = breakerManifold(LABEL_DEPTH); if (m) out.push({ label: "breaker", mesh: toMesh(orient(m)) }); }
160
+ return out;
161
+ }
package/index.ts ADDED
@@ -0,0 +1,94 @@
1
+ #!/usr/bin/env node
2
+ /*
3
+ * wallplater — JSON-driven wall-plate generator (Decora / duplex / blank).
4
+ *
5
+ * Reads a small JSON config (deep-merged over defaults.json) and emits an
6
+ * STL/3MF. Two engines:
7
+ * - manifold (default): builds geometry directly via manifold-3d. No OpenSCAD.
8
+ * - scad (-scad or engine:"scad"): writes a .scad and shells out to OpenSCAD.
9
+ *
10
+ * The 3MF is multi-object (plate + labels as separate, individually colourable
11
+ * objects). Run (Node 24+, native TS):
12
+ * node index.ts ../KitchenBack.json
13
+ * node index.ts ../KitchenBack.json -scad
14
+ */
15
+
16
+ import { writeFileSync, unlinkSync } from "fs";
17
+ import { basename, dirname, join } from "path";
18
+ import { loadConfig, type Resolved } from "./config.ts";
19
+ import { generateScad, runOpenscad, readStl } from "./scad.ts";
20
+ import { buildMeshes } from "./geometry.ts";
21
+ import { build3mf, writeBinaryStl, type Mesh } from "./threemf.ts";
22
+
23
+ interface ModePart { mode: string; label: string; }
24
+
25
+ function partList(cfg: Resolved): ModePart[] {
26
+ const parts: ModePart[] = [{ mode: "plate", label: "plate" }];
27
+ if (cfg.gangs.some((g) => (g.legend ?? "") !== "")) parts.push({ mode: "legend", label: "legend" });
28
+ if (cfg.breaker.switch !== "") parts.push({ mode: "breaker", label: "breaker" });
29
+ return parts;
30
+ }
31
+
32
+ function runScadEngine(cfg: Resolved, dir: string): void {
33
+ if (cfg.gangs.some((g) => (g.rockers?.length ?? 0) > 0 || (g.header ?? "") !== ""))
34
+ console.warn(" ! rocker/header labels are only rendered by the manifold engine; the -scad engine ignores them.");
35
+ const scadPath = join(dir, `${cfg.name}.scad`);
36
+ writeFileSync(scadPath, generateScad(cfg));
37
+ console.log("wrote", basename(scadPath));
38
+ if (cfg.output === "scad") return;
39
+
40
+ const parts = partList(cfg);
41
+ if (cfg.output === "stl") {
42
+ for (const p of parts) runOpenscad(cfg.openscad, scadPath, join(dir, `${cfg.name}_${p.label}.stl`), p.mode);
43
+ } else {
44
+ const tmp: string[] = [];
45
+ const meshes: Mesh[] = parts.map((p) => {
46
+ const stl = join(dir, `_${cfg.name}_${p.label}.stl`);
47
+ runOpenscad(cfg.openscad, scadPath, stl, p.mode);
48
+ tmp.push(stl);
49
+ return readStl(stl);
50
+ });
51
+ writeFileSync(join(dir, `${cfg.name}.3mf`), build3mf(meshes));
52
+ for (const t of tmp) unlinkSync(t);
53
+ console.log(" wrote", `${cfg.name}.3mf`, `(${meshes.length} objects: ${parts.map((p) => p.label).join(", ")})`);
54
+ }
55
+ }
56
+
57
+ async function runManifoldEngine(cfg: Resolved, dir: string): Promise<void> {
58
+ if (cfg.output === "scad") { // scad output always needs the scad engine
59
+ runScadEngine(cfg, dir);
60
+ return;
61
+ }
62
+ const meshes = await buildMeshes(cfg);
63
+ for (const m of meshes) console.log(" built", m.label, `(${m.mesh.tris.length} tris)`);
64
+ if (cfg.output === "stl") {
65
+ for (const m of meshes) writeFileSync(join(dir, `${cfg.name}_${m.label}.stl`), writeBinaryStl(m.mesh));
66
+ } else {
67
+ writeFileSync(join(dir, `${cfg.name}.3mf`), build3mf(meshes.map((m) => m.mesh)));
68
+ console.log(" wrote", `${cfg.name}.3mf`, `(${meshes.length} objects: ${meshes.map((m) => m.label).join(", ")})`);
69
+ }
70
+ }
71
+
72
+ async function main(): Promise<void> {
73
+ const args = process.argv.slice(2);
74
+ const flags = args.filter((a) => a.startsWith("-"));
75
+ const positional = args.filter((a) => !a.startsWith("-"));
76
+ const isScad = (f: string): boolean => f.toLowerCase() === "-scad" || f.toLowerCase() === "--scad";
77
+ const unknown = flags.filter((f) => !isScad(f));
78
+ if (unknown.length) {
79
+ console.error(`unknown option(s): ${unknown.join(" ")}\nusage: node index.ts <config.json> [-scad]`);
80
+ process.exit(2);
81
+ }
82
+ const useScad = flags.some(isScad);
83
+ const cfgPath = positional[0] ?? "wallplate.json";
84
+ const cfg = loadConfig(cfgPath);
85
+ const dir = dirname(cfgPath);
86
+ const engine = useScad || cfg.engine === "scad" ? "scad" : "manifold";
87
+ console.log(`engine: ${engine}, output: ${cfg.output}`);
88
+
89
+ if (engine === "scad") runScadEngine(cfg, dir);
90
+ else await runManifoldEngine(cfg, dir);
91
+ console.log("done");
92
+ }
93
+
94
+ main().catch((e) => { console.error(e); process.exit(1); });
package/package.json ADDED
@@ -0,0 +1,29 @@
1
+ {
2
+ "name": "@bobfrankston/wallplater",
3
+ "version": "1.0.1",
4
+ "type": "module",
5
+ "description": "JSON-driven wall-plate generator (Decora / duplex / blank). Direct-to-3MF via manifold-3d, with an optional OpenSCAD engine.",
6
+ "bin": {
7
+ "wallplater": "index.ts"
8
+ },
9
+ "engines": {
10
+ "node": ">=24"
11
+ },
12
+ "scripts": {
13
+ "gen": "node index.ts",
14
+ "typecheck": "tsc --noEmit",
15
+ "release": "npmglobalize"
16
+ },
17
+ "dependencies": {
18
+ "manifold-3d": "^3.0.0",
19
+ "opentype.js": "^1.3.4"
20
+ },
21
+ "devDependencies": {
22
+ "@types/node": "^25.9.2",
23
+ "@types/opentype.js": "^1.3.8"
24
+ },
25
+ "repository": {
26
+ "type": "git",
27
+ "url": "git@github.com:BobFrankston/wallplater.git"
28
+ }
29
+ }
package/scad.ts ADDED
@@ -0,0 +1,177 @@
1
+ /*
2
+ * scad.ts — the legacy OpenSCAD engine (kept as the `-scad` / engine:"scad"
3
+ * option). Writes a complete parametric .scad, shells out to OpenSCAD to export
4
+ * one STL per colour part, and reads STLs back into the common Mesh shape.
5
+ * Note: rocker/header labels are manifold-engine only and are not emitted here.
6
+ */
7
+
8
+ import { readFileSync } from "fs";
9
+ import { execFileSync } from "child_process";
10
+ import { basename } from "path";
11
+ import { type Resolved, gangType } from "./config.ts";
12
+ import type { Mesh } from "./threemf.ts";
13
+
14
+ function num(n: number): string {
15
+ return Number.isInteger(n) ? String(n) : n.toFixed(4).replace(/0+$/, "").replace(/\.$/, "");
16
+ }
17
+
18
+ export function generateScad(cfg: Resolved): string {
19
+ const d = cfg.dims;
20
+ const gangs = "[" + cfg.gangs.map((g) => `"${gangType(g)}"`).join(", ") + "]";
21
+ const legend = "[" + cfg.gangs.map((g) => `"${g.legend ?? ""}"`).join(", ") + "]";
22
+ const filler = "[" + cfg.gangs.map((g) => (g.filler ? "true" : "false")).join(", ") + "]";
23
+ const screws = "[" + cfg.gangs.map((g) => (g.screws === false ? "false" : "true")).join(", ") + "]";
24
+ const params = `// ===== Auto-generated by wallplater from ${cfg.name}.json — do not hand-edit =====
25
+ GANGS = ${gangs}; // per gang: "blank" | "decora" | "duplex"
26
+ LEGEND = ${legend};
27
+ FILLER = ${filler}; // blank gang has a filler/insert plate -> screws on Decora line
28
+ SCREWS = ${screws}; // per gang: place screw holes? (false = omit)
29
+ GANG_PITCH = ${num(d.gangPitch)};
30
+ ONE_GANG_W = ${num(d.oneGangWidth)};
31
+ PLATE_H = ${num(d.height)};
32
+ DEPTH = ${num(d.depth)};
33
+ FACE_T = ${num(d.faceT)};
34
+ RIM_W = ${num(d.rimW)};
35
+ CORNER_R = ${num(d.cornerR)};
36
+ BEVEL = ${num(cfg.bevel)};
37
+ OPEN_W = ${num(d.openW)};
38
+ OPEN_H = ${num(d.openH)};
39
+ OPEN_R = ${num(d.openR)};
40
+ DUPLEX_W = ${num(d.duplexW)};
41
+ DUPLEX_H = ${num(d.duplexH)};
42
+ DUPLEX_SPACING= ${num(d.duplexSpacing)};
43
+ SCREW_SPACING = ${num(d.screwSpacing)};
44
+ BLANK_SCREW_SPACING = ${num(d.blankScrewSpacing)};
45
+ BLANK_SCREWS = ${cfg.blankScrews};
46
+ SCREW_CLEAR_D = ${num(d.screwClearD)};
47
+ SCREW_HEAD_D = ${num(d.screwHeadD)};
48
+ CSK_ANGLE = ${num(d.cskAngle)};
49
+ LEGEND_SIZE = ${num(cfg.legendSize)};
50
+ LEGEND_Y = ${num(cfg.legendY)};
51
+ BREAKER = "${cfg.breaker.switch}";
52
+ BREAKER_SIZE = ${num(cfg.breaker.size)};
53
+ BREAKER_INSET = [${num(cfg.breaker.inset)}, ${num(cfg.breaker.inset)}];
54
+ LABEL_DEPTH = ${num(cfg.labelDepth)};
55
+ LABEL_FONT = "${cfg.font}";
56
+ PLATE_COLOR = "${cfg.plateColor}";
57
+ ACCENT_COLOR = "${cfg.accentColor}";
58
+ MODE = "preview"; // overridden via -D by wallplater
59
+ PRINT_READY = ${cfg.printReady};
60
+ `;
61
+ return params + SCAD_BODY;
62
+ }
63
+
64
+ const SCAD_BODY = `
65
+ $fn = 96;
66
+ CSK_DEPTH = (SCREW_HEAD_D - SCREW_CLEAR_D) / 2 / tan(CSK_ANGLE / 2);
67
+ N = len(GANGS);
68
+ PLATE_W = ONE_GANG_W + (N - 1) * GANG_PITCH;
69
+ function gang_x(i) = (i - (N - 1) / 2) * GANG_PITCH;
70
+
71
+ module rrect(w, h, r)
72
+ hull() for (sx = [-1, 1], sy = [-1, 1])
73
+ translate([sx * (w/2 - r), sy * (h/2 - r)]) circle(r = max(0.1, r));
74
+
75
+ module screw(x, y) {
76
+ translate([x, y, -1]) cylinder(d = SCREW_CLEAR_D, h = DEPTH + 2);
77
+ translate([x, y, DEPTH - CSK_DEPTH])
78
+ cylinder(d1 = SCREW_CLEAR_D, d2 = SCREW_HEAD_D, h = CSK_DEPTH + 0.01);
79
+ }
80
+ module screws_pair(x, sp) { screw(x, sp/2); screw(x, -sp/2); }
81
+
82
+ module decora_opening(x)
83
+ translate([x, 0, -1]) linear_extrude(DEPTH + 2) rrect(OPEN_W, OPEN_H, OPEN_R);
84
+
85
+ module duplex_opening(x)
86
+ for (s = [-1, 1])
87
+ translate([x, s * DUPLEX_SPACING/2, -1]) linear_extrude(DEPTH + 2)
88
+ intersection() { circle(d = DUPLEX_W); square([DUPLEX_W, DUPLEX_H], center = true); }
89
+
90
+ module outline() rrect(PLATE_W, PLATE_H, CORNER_R);
91
+
92
+ module plate_body() {
93
+ linear_extrude(DEPTH - FACE_T) difference() {
94
+ outline();
95
+ rrect(PLATE_W - 2*RIM_W, PLATE_H - 2*RIM_W, CORNER_R - RIM_W);
96
+ }
97
+ translate([0, 0, DEPTH - FACE_T]) linear_extrude(FACE_T - BEVEL + 0.001) outline();
98
+ if (BEVEL > 0)
99
+ hull() {
100
+ translate([0, 0, DEPTH - BEVEL]) linear_extrude(0.001) outline();
101
+ translate([0, 0, DEPTH - 0.001]) linear_extrude(0.001) offset(r = -BEVEL) outline();
102
+ }
103
+ }
104
+
105
+ module label_text(txt, sz, h, halign = "center")
106
+ if (txt != "")
107
+ linear_extrude(h)
108
+ text(txt, size = sz, font = LABEL_FONT, halign = halign, valign = "center");
109
+
110
+ module legend_solids(h)
111
+ for (i = [0 : N - 1])
112
+ translate([gang_x(i), LEGEND_Y, DEPTH - LABEL_DEPTH])
113
+ label_text((i < len(LEGEND)) ? LEGEND[i] : "", LEGEND_SIZE, h);
114
+
115
+ module breaker_solid(h)
116
+ translate([PLATE_W/2 - BREAKER_INSET[0], -PLATE_H/2 + BREAKER_INSET[1], DEPTH - LABEL_DEPTH])
117
+ label_text(BREAKER, BREAKER_SIZE, h, halign = "right");
118
+
119
+ module plate() {
120
+ difference() {
121
+ plate_body();
122
+ for (i = [0 : N - 1]) {
123
+ x = gang_x(i);
124
+ t = GANGS[i];
125
+ sc = (i >= len(SCREWS)) || SCREWS[i]; // place screws on this gang?
126
+ if (t == "decora") { decora_opening(x); if (sc) screws_pair(x, SCREW_SPACING); }
127
+ else if (t == "duplex") { duplex_opening(x); if (sc) screw(x, 0); } // single centre screw
128
+ else if (BLANK_SCREWS && sc) // blank: box-ear line, or Decora line if a filler plate
129
+ screws_pair(x, (i < len(FILLER) && FILLER[i]) ? SCREW_SPACING : BLANK_SCREW_SPACING);
130
+ }
131
+ legend_solids(LABEL_DEPTH + 0.02);
132
+ breaker_solid(LABEL_DEPTH + 0.02);
133
+ }
134
+ }
135
+
136
+ module oriented()
137
+ if (PRINT_READY) translate([0, 0, DEPTH]) rotate([180, 0, 0]) children();
138
+ else children();
139
+
140
+ if (MODE == "preview") {
141
+ color(PLATE_COLOR) plate();
142
+ color(ACCENT_COLOR) legend_solids(LABEL_DEPTH);
143
+ color(ACCENT_COLOR) breaker_solid(LABEL_DEPTH);
144
+ } else if (MODE == "plate") oriented() plate();
145
+ else if (MODE == "legend") oriented() legend_solids(LABEL_DEPTH);
146
+ else if (MODE == "breaker") oriented() breaker_solid(LABEL_DEPTH);
147
+ `;
148
+
149
+ export function runOpenscad(exe: string, scadPath: string, outPath: string, mode: string): void {
150
+ const fmt: string = outPath.endsWith(".3mf") ? "3mf" : outPath.endsWith(".png") ? "png" : "binstl";
151
+ execFileSync(exe, [`-D`, `MODE="${mode}"`, "--export-format", fmt, "-o", outPath, scadPath],
152
+ { stdio: ["ignore", "ignore", "inherit"] });
153
+ console.log(" wrote", basename(outPath));
154
+ }
155
+
156
+ export function readStl(path: string): Mesh {
157
+ const buf: Buffer = readFileSync(path);
158
+ const n: number = buf.readUInt32LE(80);
159
+ const map = new Map<string, number>();
160
+ const verts: number[][] = [];
161
+ const tris: number[][] = [];
162
+ const vid = (x: number, y: number, z: number): number => {
163
+ const k = `${x.toFixed(4)},${y.toFixed(4)},${z.toFixed(4)}`;
164
+ let i = map.get(k);
165
+ if (i === undefined) { i = verts.length; verts.push([x, y, z]); map.set(k, i); }
166
+ return i;
167
+ };
168
+ let o = 84;
169
+ for (let t = 0; t < n; t++) {
170
+ const a = vid(buf.readFloatLE(o + 12), buf.readFloatLE(o + 16), buf.readFloatLE(o + 20));
171
+ const b = vid(buf.readFloatLE(o + 24), buf.readFloatLE(o + 28), buf.readFloatLE(o + 32));
172
+ const c = vid(buf.readFloatLE(o + 36), buf.readFloatLE(o + 40), buf.readFloatLE(o + 44));
173
+ if (a !== b && b !== c && a !== c) tris.push([a, b, c]);
174
+ o += 50;
175
+ }
176
+ return { verts, tris };
177
+ }
package/text.ts ADDED
@@ -0,0 +1,97 @@
1
+ /*
2
+ * text.ts — glyph outlines -> filled 2D contours for the manifold engine.
3
+ *
4
+ * opentype.js gives us vector glyph outlines (TrueType quadratics / CFF
5
+ * cubics). We flatten the curves to polylines, flip to model-space (y-up),
6
+ * and align. The resulting contours (outer + holes) are handed to manifold's
7
+ * CrossSection with a NonZero fill rule, which resolves the holes — so no
8
+ * separate triangulation step is needed.
9
+ */
10
+
11
+ import opentype from "opentype.js";
12
+
13
+ export type Contours = [number, number][][];
14
+ export type HAlign = "left" | "center" | "right";
15
+
16
+ const CURVE_SEGS = 12; // polyline segments per bezier
17
+
18
+ let cachedPath = "";
19
+ let cachedFont: opentype.Font | null = null;
20
+
21
+ export function loadFont(path: string): opentype.Font {
22
+ if (cachedFont && cachedPath === path) return cachedFont;
23
+ cachedFont = opentype.loadSync(path);
24
+ cachedPath = path;
25
+ return cachedFont;
26
+ }
27
+
28
+ function quad(p0: [number, number], c: [number, number], p1: [number, number], out: [number, number][]): void {
29
+ for (let i = 1; i <= CURVE_SEGS; i++) {
30
+ const t = i / CURVE_SEGS, u = 1 - t;
31
+ out.push([
32
+ u * u * p0[0] + 2 * u * t * c[0] + t * t * p1[0],
33
+ u * u * p0[1] + 2 * u * t * c[1] + t * t * p1[1],
34
+ ]);
35
+ }
36
+ }
37
+
38
+ function cubic(p0: [number, number], c1: [number, number], c2: [number, number], p1: [number, number], out: [number, number][]): void {
39
+ for (let i = 1; i <= CURVE_SEGS; i++) {
40
+ const t = i / CURVE_SEGS, u = 1 - t;
41
+ out.push([
42
+ u * u * u * p0[0] + 3 * u * u * t * c1[0] + 3 * u * t * t * c2[0] + t * t * t * p1[0],
43
+ u * u * u * p0[1] + 3 * u * u * t * c1[1] + 3 * u * t * t * c2[1] + t * t * t * p1[1],
44
+ ]);
45
+ }
46
+ }
47
+
48
+ /**
49
+ * Build aligned, model-space (y-up) filled contours for `text`.
50
+ * halign positions the bounding box horizontally relative to the origin;
51
+ * vertically it is always centred. Returns [] for empty text.
52
+ */
53
+ export function textToContours(font: opentype.Font, text: string, sizeMm: number, halign: HAlign): Contours {
54
+ if (text === "") return [];
55
+ const path = font.getPath(text, 0, 0, sizeMm); // opentype y points DOWN
56
+ const contours: Contours = [];
57
+ let cur: [number, number][] = [];
58
+ let px = 0, py = 0; // current point (opentype space)
59
+ const flip = (x: number, y: number): [number, number] => [x, -y]; // -> model y-up
60
+
61
+ for (const cmd of path.commands) {
62
+ if (cmd.type === "M") {
63
+ if (cur.length) contours.push(cur);
64
+ cur = [flip(cmd.x, cmd.y)];
65
+ px = cmd.x; py = cmd.y;
66
+ } else if (cmd.type === "L") {
67
+ cur.push(flip(cmd.x, cmd.y));
68
+ px = cmd.x; py = cmd.y;
69
+ } else if (cmd.type === "Q") {
70
+ const pts: [number, number][] = [];
71
+ quad([px, py], [cmd.x1, cmd.y1], [cmd.x, cmd.y], pts);
72
+ for (const p of pts) cur.push(flip(p[0], p[1]));
73
+ px = cmd.x; py = cmd.y;
74
+ } else if (cmd.type === "C") {
75
+ const pts: [number, number][] = [];
76
+ cubic([px, py], [cmd.x1, cmd.y1], [cmd.x2, cmd.y2], [cmd.x, cmd.y], pts);
77
+ for (const p of pts) cur.push(flip(p[0], p[1]));
78
+ px = cmd.x; py = cmd.y;
79
+ } else if (cmd.type === "Z") {
80
+ if (cur.length) contours.push(cur);
81
+ cur = [];
82
+ }
83
+ }
84
+ if (cur.length) contours.push(cur);
85
+ if (contours.length === 0) return [];
86
+
87
+ // bounding box over all (already flipped) points
88
+ let minX = Infinity, maxX = -Infinity, minY = Infinity, maxY = -Infinity;
89
+ for (const c of contours) for (const [x, y] of c) {
90
+ if (x < minX) minX = x; if (x > maxX) maxX = x;
91
+ if (y < minY) minY = y; if (y > maxY) maxY = y;
92
+ }
93
+ const dx = halign === "center" ? -(minX + maxX) / 2 : halign === "right" ? -maxX : -minX;
94
+ const dy = -(minY + maxY) / 2;
95
+ for (const c of contours) for (const p of c) { p[0] += dx; p[1] += dy; }
96
+ return contours;
97
+ }
package/threemf.ts ADDED
@@ -0,0 +1,117 @@
1
+ /*
2
+ * threemf.ts — minimal, dependency-free writers for output geometry:
3
+ * - Mesh: the common interchange shape used by every engine.
4
+ * - build3mf(): a multi-object core-spec 3MF (plate + labels as separate,
5
+ * individually colourable objects), packed into the OPC zip by hand.
6
+ * - writeBinaryStl(): a single binary STL (used for the manifold STL path).
7
+ */
8
+
9
+ import { deflateRawSync } from "zlib";
10
+
11
+ export interface Mesh { verts: number[][]; tris: number[][]; }
12
+
13
+ // ---------- multi-object 3MF (core spec, no slicer settings) ----------
14
+ export function build3mf(meshes: Mesh[]): Buffer {
15
+ // separate top-level objects (each individually colourable in Bambu), all
16
+ // at the same transform so the labels stay seated in the plate's pockets.
17
+ let objs = "";
18
+ let items = "";
19
+ meshes.forEach((m, i) => {
20
+ const id = i + 1;
21
+ const vs = m.verts.map((v) => `<vertex x="${v[0]}" y="${v[1]}" z="${v[2]}"/>`).join("");
22
+ const ts = m.tris.map((t) => `<triangle v1="${t[0]}" v2="${t[1]}" v3="${t[2]}"/>`).join("");
23
+ objs += ` <object id="${id}" type="model"><mesh><vertices>${vs}</vertices><triangles>${ts}</triangles></mesh></object>\n`;
24
+ items += ` <item objectid="${id}" transform="1 0 0 0 1 0 0 0 1 128 128 0"/>\n`;
25
+ });
26
+ const model = `<?xml version="1.0" encoding="UTF-8"?>
27
+ <model unit="millimeter" xml:lang="en-US" xmlns="http://schemas.microsoft.com/3dmanufacturing/core/2015/02">
28
+ <resources>
29
+ ${objs} </resources>
30
+ <build>
31
+ ${items} </build>
32
+ </model>
33
+ `;
34
+ const ct = `<?xml version="1.0" encoding="UTF-8"?>
35
+ <Types xmlns="http://schemas.openxmlformats.org/package/2006/content-types">
36
+ <Default Extension="rels" ContentType="application/vnd.openxmlformats-package.relationships+xml"/>
37
+ <Default Extension="model" ContentType="application/vnd.ms-package.3dmanufacturing-3dmodel+xml"/>
38
+ </Types>
39
+ `;
40
+ const rels = `<?xml version="1.0" encoding="UTF-8"?>
41
+ <Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">
42
+ <Relationship Target="/3D/3dmodel.model" Id="rel0" Type="http://schemas.microsoft.com/3dmanufacturing/2013/01/3dmodel"/>
43
+ </Relationships>
44
+ `;
45
+ return zip([
46
+ { name: "[Content_Types].xml", data: Buffer.from(ct) },
47
+ { name: "_rels/.rels", data: Buffer.from(rels) },
48
+ { name: "3D/3dmodel.model", data: Buffer.from(model) },
49
+ ]);
50
+ }
51
+
52
+ // ---------- binary STL (single object) ----------
53
+ export function writeBinaryStl(mesh: Mesh): Buffer {
54
+ const buf = Buffer.alloc(84 + mesh.tris.length * 50);
55
+ buf.writeUInt32LE(mesh.tris.length, 80);
56
+ let o = 84;
57
+ for (const t of mesh.tris) {
58
+ const a = mesh.verts[t[0]], b = mesh.verts[t[1]], c = mesh.verts[t[2]];
59
+ const ux = b[0] - a[0], uy = b[1] - a[1], uz = b[2] - a[2];
60
+ const vx = c[0] - a[0], vy = c[1] - a[1], vz = c[2] - a[2];
61
+ let nx = uy * vz - uz * vy, ny = uz * vx - ux * vz, nz = ux * vy - uy * vx;
62
+ const len = Math.hypot(nx, ny, nz) || 1;
63
+ nx /= len; ny /= len; nz /= len;
64
+ buf.writeFloatLE(nx, o); buf.writeFloatLE(ny, o + 4); buf.writeFloatLE(nz, o + 8);
65
+ buf.writeFloatLE(a[0], o + 12); buf.writeFloatLE(a[1], o + 16); buf.writeFloatLE(a[2], o + 20);
66
+ buf.writeFloatLE(b[0], o + 24); buf.writeFloatLE(b[1], o + 28); buf.writeFloatLE(b[2], o + 32);
67
+ buf.writeFloatLE(c[0], o + 36); buf.writeFloatLE(c[1], o + 40); buf.writeFloatLE(c[2], o + 44);
68
+ o += 50;
69
+ }
70
+ return buf;
71
+ }
72
+
73
+ const CRC_TABLE: number[] = (() => {
74
+ const t: number[] = [];
75
+ for (let n = 0; n < 256; n++) {
76
+ let c = n;
77
+ for (let k = 0; k < 8; k++) c = (c & 1) ? (0xedb88320 ^ (c >>> 1)) : (c >>> 1);
78
+ t[n] = c >>> 0;
79
+ }
80
+ return t;
81
+ })();
82
+ function crc32(buf: Buffer): number {
83
+ let c = 0xffffffff;
84
+ for (let i = 0; i < buf.length; i++) c = CRC_TABLE[(c ^ buf[i]) & 0xff] ^ (c >>> 8);
85
+ return (c ^ 0xffffffff) >>> 0;
86
+ }
87
+
88
+ function zip(files: { name: string; data: Buffer }[]): Buffer {
89
+ const parts: Buffer[] = [];
90
+ const central: Buffer[] = [];
91
+ let offset = 0;
92
+ for (const f of files) {
93
+ const name = Buffer.from(f.name);
94
+ const comp = deflateRawSync(f.data);
95
+ const crc = crc32(f.data);
96
+ const lh = Buffer.alloc(30);
97
+ lh.writeUInt32LE(0x04034b50, 0); lh.writeUInt16LE(20, 4); lh.writeUInt16LE(0, 6);
98
+ lh.writeUInt16LE(8, 8); lh.writeUInt32LE(0, 10); lh.writeUInt32LE(crc, 14);
99
+ lh.writeUInt32LE(comp.length, 18); lh.writeUInt32LE(f.data.length, 22);
100
+ lh.writeUInt16LE(name.length, 26); lh.writeUInt16LE(0, 28);
101
+ parts.push(lh, name, comp);
102
+ const ch = Buffer.alloc(46);
103
+ ch.writeUInt32LE(0x02014b50, 0); ch.writeUInt16LE(20, 4); ch.writeUInt16LE(20, 6);
104
+ ch.writeUInt16LE(0, 8); ch.writeUInt16LE(8, 10); ch.writeUInt32LE(0, 12);
105
+ ch.writeUInt32LE(crc, 16); ch.writeUInt32LE(comp.length, 20); ch.writeUInt32LE(f.data.length, 24);
106
+ ch.writeUInt16LE(name.length, 28); ch.writeUInt32LE(0, 30); ch.writeUInt32LE(0, 34);
107
+ ch.writeUInt32LE(0, 38); ch.writeUInt32LE(offset, 42);
108
+ central.push(ch, name);
109
+ offset += lh.length + name.length + comp.length;
110
+ }
111
+ const cd = Buffer.concat(central);
112
+ const eocd = Buffer.alloc(22);
113
+ eocd.writeUInt32LE(0x06054b50, 0); eocd.writeUInt16LE(files.length, 8);
114
+ eocd.writeUInt16LE(files.length, 10); eocd.writeUInt32LE(cd.length, 12);
115
+ eocd.writeUInt32LE(offset, 16);
116
+ return Buffer.concat([...parts, cd, eocd]);
117
+ }
package/tsconfig.json ADDED
@@ -0,0 +1,20 @@
1
+ {
2
+ "compilerOptions": {
3
+ "target": "esnext",
4
+ "module": "NodeNext",
5
+ "moduleResolution": "nodenext",
6
+ "allowImportingTsExtensions": true,
7
+ "noEmit": true,
8
+ "esModuleInterop": true,
9
+ "allowSyntheticDefaultImports": true,
10
+ "strict": true,
11
+ "strictNullChecks": false,
12
+ "noImplicitAny": true,
13
+ "skipLibCheck": true,
14
+ "forceConsistentCasingInFileNames": true,
15
+ "types": ["node"],
16
+ "newLine": "lf"
17
+ },
18
+ "include": ["*.ts"],
19
+ "exclude": ["node_modules", "cruft", ".git", "tests", "prev"]
20
+ }