@bobfrankston/wallplater 1.0.2 → 1.0.4

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,20 +14,39 @@ 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
+ ## Build / install
18
+
19
+ TypeScript compiled to co-located `.js` / `.d.ts` / `.map` (no `src`/`dist`
20
+ split). `tsc` is the global install.
21
+
22
+ ```sh
23
+ npm install
24
+ npm run build # tsc (or: npm run watch for tsc -w)
25
+ ```
26
+
27
+ Installed globally (`npm i -g @bobfrankston/wallplater`) it exposes a
28
+ `wallplater` command. `defaults.json` is resolved relative to the module, so it
29
+ runs correctly from any working directory.
30
+
17
31
  ## Usage
18
32
 
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`).
33
+ Requires Node 24+ (ESM). Run the compiled entry:
22
34
 
23
35
  ```sh
24
- node index.ts ../KitchenBack.json # manifold engine -> KitchenBack.3mf
25
- node index.ts ../KitchenBack.json -scad # OpenSCAD engine (same result)
36
+ node index.js ../KitchenBack.json # manifold engine -> KitchenBack.3mf
37
+ node index.js ../KitchenBack.json -scad # OpenSCAD engine (same result)
38
+ wallplater ../KitchenBack.json # if installed globally
26
39
  ```
27
40
 
28
41
  - Outputs are written next to the **config file**, named after the config's `name`.
29
42
  - Unknown flags are rejected. `-scad` (or `--scad`) selects the OpenSCAD engine.
30
43
 
44
+ The 3MF holds up to three named, individually-selectable objects — `<name> plate`,
45
+ `<name> labels` (all accent text), `<name> breaker` — so each is easy to find in
46
+ Bambu Studio's object list and assign to a filament. The label objects sit flush
47
+ in the plate's front-face pockets (the plate prints front-face-down), so they
48
+ look hidden until you give them a second colour.
49
+
31
50
  ## Config
32
51
 
33
52
  Each specific config is **deep-merged over `defaults.json`** (in this
@@ -54,9 +73,13 @@ Lengths are written **CSS-style with a unit suffix**: `"0.375in"`, `"5mm"`,
54
73
  - `gangs[].type`: `"decora"` | `"duplex"` | `"blank"`.
55
74
  - `gangs[].screws`: `false` to omit that gang's screw holes (default true).
56
75
  - `gangs[].legend`: single label centred below the gang.
76
+ - `gangs[].breaker`: per-gang breaker/circuit label, centred just below that
77
+ gang's legend (size/position from `gangBreakerSize` / `gangBreakerY`).
57
78
  - `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`.
79
+ panel-wide breaker label in the lower-right corner. `switch` is the text;
80
+ `size`/`inset` come from `defaults.json`, so a config usually sets only
81
+ `breaker.switch`. Per-gang (`gangs[].breaker`) and panel-wide (`breaker`)
82
+ labels are independent — use either or both.
60
83
 
61
84
  ### Stacked rocker labels (Leviton 1755 triple rocker, etc.)
62
85
 
@@ -76,8 +99,8 @@ opening is unchanged — only the labelling differs. Per gang:
76
99
  ```
77
100
 
78
101
  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:
102
+ to the opening, `right` labels left-aligned. `legend` / `header` / `rockers` /
103
+ per-gang `breaker` all print in the accent colour (the **labels** object). Relevant defaults:
81
104
  `rockerSize` (3.5mm), `rockerGap` (1.5mm), `headerSize` (4mm), `headerGap`
82
105
  (5mm). Keep side labels short on multi-gang plates — the side margin is ~18 mm
83
106
  on an end/single gang but only ~6 mm between interior gangs; long labels can
package/config.d.ts ADDED
@@ -0,0 +1,127 @@
1
+ export declare const IN = 25.4;
2
+ export type GangType = "blank" | "decora" | "duplex";
3
+ export type Engine = "manifold" | "scad";
4
+ /** A length: CSS-style unit string ("0.375in" / "5mm") or a bare number (mm). */
5
+ export type Len = string | number;
6
+ /** Parse a length to millimetres. */
7
+ export declare function mm(v: Len): number;
8
+ export interface RockerCfg {
9
+ left?: string;
10
+ right?: string;
11
+ }
12
+ export interface GangCfg {
13
+ type?: GangType;
14
+ opening?: boolean;
15
+ legend?: string;
16
+ filler?: boolean;
17
+ screws?: boolean;
18
+ rockers?: RockerCfg[];
19
+ header?: string;
20
+ breaker?: string;
21
+ }
22
+ export interface BreakerCfg {
23
+ switch?: string;
24
+ size?: Len;
25
+ inset?: Len;
26
+ }
27
+ export interface DimsCfg {
28
+ gangPitch?: Len;
29
+ oneGangWidth?: Len;
30
+ height?: Len;
31
+ depth?: Len;
32
+ faceT?: Len;
33
+ rimW?: Len;
34
+ cornerR?: Len;
35
+ openW?: Len;
36
+ openH?: Len;
37
+ openR?: Len;
38
+ duplexW?: Len;
39
+ duplexH?: Len;
40
+ duplexSpacing?: Len;
41
+ screwSpacing?: Len;
42
+ blankScrewSpacing?: Len;
43
+ screwClearD?: Len;
44
+ screwHeadD?: Len;
45
+ cskAngle?: number;
46
+ }
47
+ export interface Config {
48
+ name?: string;
49
+ gangs: GangCfg[];
50
+ breaker?: BreakerCfg;
51
+ legendSize?: Len;
52
+ legendY?: Len;
53
+ gangBreakerSize?: Len;
54
+ gangBreakerY?: Len;
55
+ rockerSize?: Len;
56
+ rockerGap?: Len;
57
+ headerSize?: Len;
58
+ headerGap?: Len;
59
+ bevel?: Len;
60
+ labelDepth?: Len;
61
+ blankScrews?: boolean;
62
+ font?: string;
63
+ fontFile?: string;
64
+ plateColor?: string;
65
+ accentColor?: string;
66
+ printReady?: boolean;
67
+ dims?: DimsCfg;
68
+ output?: "scad" | "stl" | "3mf";
69
+ engine?: Engine;
70
+ openscad?: string;
71
+ }
72
+ /** Resolved breaker: lengths reduced to millimetres. */
73
+ export interface Breaker {
74
+ switch: string;
75
+ size: number;
76
+ inset: number;
77
+ }
78
+ /** Resolved dimensions, all in millimetres (cskAngle in degrees). */
79
+ export interface Dims {
80
+ gangPitch: number;
81
+ oneGangWidth: number;
82
+ height: number;
83
+ depth: number;
84
+ faceT: number;
85
+ rimW: number;
86
+ cornerR: number;
87
+ openW: number;
88
+ openH: number;
89
+ openR: number;
90
+ duplexW: number;
91
+ duplexH: number;
92
+ duplexSpacing: number;
93
+ screwSpacing: number;
94
+ blankScrewSpacing: number;
95
+ screwClearD: number;
96
+ screwHeadD: number;
97
+ cskAngle: number;
98
+ }
99
+ /** Fully resolved config: every length reduced to millimetres. */
100
+ export interface Resolved {
101
+ name: string;
102
+ gangs: GangCfg[];
103
+ breaker: Breaker;
104
+ legendSize: number;
105
+ legendY: number;
106
+ gangBreakerSize: number;
107
+ gangBreakerY: number;
108
+ rockerSize: number;
109
+ rockerGap: number;
110
+ headerSize: number;
111
+ headerGap: number;
112
+ bevel: number;
113
+ labelDepth: number;
114
+ blankScrews: boolean;
115
+ font: string;
116
+ fontFile: string;
117
+ plateColor: string;
118
+ accentColor: string;
119
+ printReady: boolean;
120
+ dims: Dims;
121
+ output: "scad" | "stl" | "3mf";
122
+ engine: Engine;
123
+ openscad: string;
124
+ }
125
+ export declare function loadConfig(path: string): Resolved;
126
+ export declare function gangType(g: GangCfg): GangType;
127
+ //# sourceMappingURL=config.d.ts.map
package/config.js ADDED
@@ -0,0 +1,81 @@
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
+ import { readFileSync } from "fs";
12
+ import { join } from "path";
13
+ export const IN = 25.4;
14
+ /** Parse a length to millimetres. */
15
+ export function mm(v) {
16
+ if (typeof v === "number")
17
+ return v;
18
+ const m = /^\s*(-?[\d.]+)\s*(mm|in)?\s*$/i.exec(v);
19
+ if (!m)
20
+ throw new Error(`invalid length "${v}" (use e.g. "0.375in" or "5mm")`);
21
+ const n = parseFloat(m[1]);
22
+ return m[2] && m[2].toLowerCase() === "in" ? n * IN : n;
23
+ }
24
+ // JSON.parse returns dynamic shape; merged then validated below.
25
+ function readJson(path) {
26
+ return JSON.parse(readFileSync(path, "utf8"));
27
+ }
28
+ /** Deep-merge plain objects; arrays and scalars from `over` replace `base`. */
29
+ function deepMerge(base, over) {
30
+ if (over === null || typeof over !== "object" || Array.isArray(over))
31
+ return over;
32
+ const isObj = (v) => v !== null && typeof v === "object" && !Array.isArray(v);
33
+ const out = { ...base };
34
+ for (const k of Object.keys(over))
35
+ out[k] = isObj(base?.[k]) && isObj(over[k]) ? deepMerge(base[k], over[k]) : over[k];
36
+ return out;
37
+ }
38
+ export function loadConfig(path) {
39
+ const defaults = readJson(join(import.meta.dirname, "defaults.json"));
40
+ const raw = readJson(path);
41
+ if (raw.code !== undefined) // renamed 2026-06-08; don't silently ignore old configs
42
+ console.warn(" ! config field 'code' is now 'breaker': { switch }. The 'code' value is ignored.");
43
+ // every default lives in defaults.json; the merge guarantees presence
44
+ const c = deepMerge(defaults, raw);
45
+ if (!c.gangs || c.gangs.length === 0)
46
+ throw new Error("config needs a non-empty 'gangs' array");
47
+ const d = c.dims;
48
+ const dims = {
49
+ gangPitch: mm(d.gangPitch), oneGangWidth: mm(d.oneGangWidth), height: mm(d.height), depth: mm(d.depth),
50
+ faceT: mm(d.faceT), rimW: mm(d.rimW), cornerR: mm(d.cornerR),
51
+ openW: mm(d.openW), openH: mm(d.openH), openR: mm(d.openR),
52
+ duplexW: mm(d.duplexW), duplexH: mm(d.duplexH), duplexSpacing: mm(d.duplexSpacing),
53
+ screwSpacing: mm(d.screwSpacing), blankScrewSpacing: mm(d.blankScrewSpacing),
54
+ screwClearD: mm(d.screwClearD), screwHeadD: mm(d.screwHeadD), cskAngle: d.cskAngle,
55
+ };
56
+ const b = c.breaker;
57
+ return {
58
+ name: c.name,
59
+ gangs: c.gangs,
60
+ breaker: { switch: b.switch ?? "", size: mm(b.size), inset: mm(b.inset) },
61
+ legendSize: mm(c.legendSize), legendY: mm(c.legendY),
62
+ gangBreakerSize: mm(c.gangBreakerSize), gangBreakerY: mm(c.gangBreakerY),
63
+ rockerSize: mm(c.rockerSize), rockerGap: mm(c.rockerGap),
64
+ headerSize: mm(c.headerSize), headerGap: mm(c.headerGap),
65
+ bevel: mm(c.bevel), labelDepth: mm(c.labelDepth),
66
+ blankScrews: c.blankScrews,
67
+ font: c.font, fontFile: c.fontFile,
68
+ plateColor: c.plateColor, accentColor: c.accentColor,
69
+ printReady: c.printReady,
70
+ dims,
71
+ output: c.output, engine: c.engine, openscad: c.openscad,
72
+ };
73
+ }
74
+ export function gangType(g) {
75
+ if (g.type)
76
+ return g.type;
77
+ if (g.opening === true)
78
+ return "decora";
79
+ return "blank";
80
+ }
81
+ //# sourceMappingURL=config.js.map
package/defaults.json CHANGED
@@ -3,6 +3,8 @@
3
3
  "breaker": { "switch": "", "size": "0.375in", "inset": "0.25in" },
4
4
  "legendSize": "5mm",
5
5
  "legendY": "-42mm",
6
+ "gangBreakerSize": "4mm",
7
+ "gangBreakerY": "-50mm",
6
8
  "rockerSize": "3.5mm",
7
9
  "rockerGap": "1.5mm",
8
10
  "headerSize": "4mm",
package/geometry.d.ts ADDED
@@ -0,0 +1,8 @@
1
+ import { type Resolved } from "./config.js";
2
+ import type { Mesh } from "./threemf.js";
3
+ export interface LabeledMesh {
4
+ label: string;
5
+ mesh: Mesh;
6
+ }
7
+ export declare function buildMeshes(cfg: Resolved): Promise<LabeledMesh[]>;
8
+ //# sourceMappingURL=geometry.d.ts.map
@@ -5,17 +5,12 @@
5
5
  * 3D). Produces one Mesh per colour part (plate / legend / code), already
6
6
  * print-oriented, ready for build3mf() or writeBinaryStl().
7
7
  */
8
-
9
8
  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> {
9
+ import { gangType } from "./config.js";
10
+ import { loadFont, textToContours } from "./text.js";
11
+ const SEG = 96; // circular segments (matches the SCAD $fn = 96)
12
+ let wasm = null;
13
+ async function getWasm() {
19
14
  if (!wasm) {
20
15
  wasm = await Module();
21
16
  wasm.setup();
@@ -23,40 +18,34 @@ async function getWasm(): Promise<ManifoldToplevel> {
23
18
  }
24
19
  return wasm;
25
20
  }
26
-
27
- export interface LabeledMesh { label: string; mesh: Mesh; }
28
-
29
- function toMesh(m: Manifold): Mesh {
21
+ function toMesh(m) {
30
22
  const g = m.getMesh();
31
23
  const np = g.numProp;
32
24
  const vp = g.vertProperties;
33
25
  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]];
26
+ const verts = new Array(nv);
27
+ for (let i = 0; i < nv; i++)
28
+ verts[i] = [vp[i * np], vp[i * np + 1], vp[i * np + 2]];
36
29
  const tv = g.triVerts;
37
30
  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]];
31
+ const tris = new Array(nt);
32
+ for (let t = 0; t < nt; t++)
33
+ tris[t] = [tv[t * 3], tv[t * 3 + 1], tv[t * 3 + 2]];
40
34
  return { verts, tris };
41
35
  }
42
-
43
- export async function buildMeshes(cfg: Resolved): Promise<LabeledMesh[]> {
36
+ export async function buildMeshes(cfg) {
44
37
  const w = await getWasm();
45
38
  const { Manifold, CrossSection } = w;
46
39
  const d = cfg.dims;
47
-
48
40
  const CSK_DEPTH = (d.screwHeadD - d.screwClearD) / 2 / Math.tan((d.cskAngle / 2) * Math.PI / 180);
49
41
  const N = cfg.gangs.length;
50
42
  const PLATE_W = d.oneGangWidth + (N - 1) * d.gangPitch;
51
43
  const PLATE_H = d.height;
52
44
  const DEPTH = d.depth, FACE_T = d.faceT, RIM_W = d.rimW, CORNER_R = d.cornerR, BEVEL = cfg.bevel;
53
45
  const LABEL_DEPTH = cfg.labelDepth;
54
- const gangX = (i: number): number => (i - (N - 1) / 2) * d.gangPitch;
55
-
46
+ const gangX = (i) => (i - (N - 1) / 2) * d.gangPitch;
56
47
  // 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
-
48
+ const rrect = (wd, ht, r) => CrossSection.square([wd - 2 * r, ht - 2 * r], true).offset(r, "Round", 2, SEG);
60
49
  // ----- plate body: rim frame + face slab + front bevel -----
61
50
  const outline = () => rrect(PLATE_W, PLATE_H, CORNER_R);
62
51
  const frame = rrect(PLATE_W, PLATE_H, CORNER_R)
@@ -66,96 +55,104 @@ export async function buildMeshes(cfg: Resolved): Promise<LabeledMesh[]> {
66
55
  if (BEVEL > 0) {
67
56
  // SCAD does a hull(offset(-BEVEL)); a top-scale extrude is the close,
68
57
  // 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];
58
+ const topScale = [(PLATE_W - 2 * BEVEL) / PLATE_W, (PLATE_H - 2 * BEVEL) / PLATE_H];
70
59
  body = body.add(outline().extrude(BEVEL, 0, 0, topScale).translate([0, 0, DEPTH - BEVEL]));
71
60
  }
72
-
73
61
  // ----- cutters: openings + screw holes -----
74
- const cutters: Manifold[] = [];
75
- const screw = (x: number, y: number): Manifold => {
62
+ const cutters = [];
63
+ const screw = (x, y) => {
76
64
  const shaft = Manifold.cylinder(DEPTH + 2, d.screwClearD / 2, d.screwClearD / 2, SEG).translate([x, y, -1]);
77
65
  const csk = Manifold.cylinder(CSK_DEPTH + 0.01, d.screwClearD / 2, d.screwHeadD / 2, SEG).translate([x, y, DEPTH - CSK_DEPTH]);
78
66
  return shaft.add(csk);
79
67
  };
80
- const screwsPair = (x: number, sp: number): void => { cutters.push(screw(x, sp / 2), screw(x, -sp / 2)); };
81
-
68
+ const screwsPair = (x, sp) => { cutters.push(screw(x, sp / 2), screw(x, -sp / 2)); };
82
69
  for (let i = 0; i < N; i++) {
83
70
  const x = gangX(i);
84
71
  const g = cfg.gangs[i];
85
72
  const t = gangType(g);
86
- const sc = g.screws !== false; // place screws on this gang?
73
+ const sc = g.screws !== false; // place screws on this gang?
87
74
  if (t === "decora") {
88
75
  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") {
76
+ if (sc)
77
+ screwsPair(x, d.screwSpacing);
78
+ }
79
+ else if (t === "duplex") {
91
80
  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
81
+ for (const s of [-1, 1])
82
+ cutters.push(cs.extrude(DEPTH + 2).translate([x, s * d.duplexSpacing / 2, -1]));
83
+ if (sc)
84
+ cutters.push(screw(x, 0)); // single centre screw
85
+ }
86
+ else if (cfg.blankScrews && sc) { // blank: box-ear line, or Decora line if a filler plate
95
87
  screwsPair(x, g.filler ? d.screwSpacing : d.blankScrewSpacing);
96
88
  }
97
89
  }
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
90
  const openHalf = d.openW / 2, openTop = d.openH / 2;
104
- const accent: Place[] = [];
91
+ const accent = [];
105
92
  for (let i = 0; i < N; i++) {
106
93
  const g = cfg.gangs[i];
107
94
  const gx = gangX(i);
108
95
  if ((g.legend ?? "") !== "")
109
96
  accent.push({ txt: g.legend, size: cfg.legendSize, halign: "center", x: gx, y: cfg.legendY });
97
+ if ((g.breaker ?? "") !== "") // per-gang breaker, below the legend
98
+ accent.push({ txt: g.breaker, size: cfg.gangBreakerSize, halign: "center", x: gx, y: cfg.gangBreakerY });
110
99
  if ((g.header ?? "") !== "")
111
100
  accent.push({ txt: g.header, size: cfg.headerSize, halign: "center", x: gx, y: openTop + cfg.headerGap });
112
101
  const rk = g.rockers ?? [];
113
102
  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
103
+ const yr = openTop - (j + 0.5) * (d.openH / rk.length); // rockers top -> bottom
104
+ if ((rk[j].left ?? "") !== "") // right-aligned, snug to opening
116
105
  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
106
+ if ((rk[j].right ?? "") !== "") // left-aligned, snug to opening
118
107
  accent.push({ txt: rk[j].right, size: cfg.rockerSize, halign: "left", x: gx + openHalf + cfg.rockerGap, y: yr });
119
108
  }
120
109
  }
121
110
  const hasAccent = accent.length > 0;
122
111
  const hasBreaker = cfg.breaker.switch !== "";
123
112
  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;
113
+ const textManifold = (txt, size, halign, height) => {
114
+ const contours = textToContours(font, txt, size, halign);
115
+ if (contours.length === 0)
116
+ return null;
128
117
  return new CrossSection(contours, "NonZero").extrude(height);
129
118
  };
130
- const accentManifold = (height: number): Manifold | null => {
131
- let acc: Manifold | null = null;
119
+ const accentManifold = (height) => {
120
+ let acc = null;
132
121
  for (const p of accent) {
133
122
  const m = textManifold(p.txt, p.size, p.halign, height);
134
- if (!m) continue;
123
+ if (!m)
124
+ continue;
135
125
  const placed = m.translate([p.x, p.y, DEPTH - LABEL_DEPTH]);
136
126
  acc = acc ? acc.add(placed) : placed;
137
127
  }
138
128
  return acc;
139
129
  };
140
- const breakerManifold = (height: number): Manifold | null => {
130
+ const breakerManifold = (height) => {
141
131
  const m = textManifold(cfg.breaker.switch, cfg.breaker.size, "right", height);
142
- if (!m) return null;
132
+ if (!m)
133
+ return null;
143
134
  return m.translate([PLATE_W / 2 - cfg.breaker.inset, -PLATE_H / 2 + cfg.breaker.inset, DEPTH - LABEL_DEPTH]);
144
135
  };
145
-
146
136
  // ----- subtract cutters + label pockets from the plate -----
147
- let cutAll: Manifold | null = cutters.length ? Manifold.union(cutters) : null;
137
+ let cutAll = cutters.length ? Manifold.union(cutters) : null;
148
138
  for (const p of [accentManifold(LABEL_DEPTH + 0.02), breakerManifold(LABEL_DEPTH + 0.02)]) {
149
- if (p) cutAll = cutAll ? cutAll.add(p) : p;
139
+ if (p)
140
+ cutAll = cutAll ? cutAll.add(p) : p;
150
141
  }
151
142
  const plate = cutAll ? body.subtract(cutAll) : body;
152
-
153
143
  // 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)) }); }
144
+ const orient = (m) => cfg.printReady ? m.rotate([180, 0, 0]).translate([0, 0, DEPTH]) : m;
145
+ const out = [{ label: "plate", mesh: toMesh(orient(plate)) }];
146
+ if (hasAccent) {
147
+ const m = accentManifold(LABEL_DEPTH);
148
+ if (m)
149
+ out.push({ label: "legend", mesh: toMesh(orient(m)) });
150
+ }
151
+ if (hasBreaker) {
152
+ const m = breakerManifold(LABEL_DEPTH);
153
+ if (m)
154
+ out.push({ label: "breaker", mesh: toMesh(orient(m)) });
155
+ }
160
156
  return out;
161
157
  }
158
+ //# sourceMappingURL=geometry.js.map
package/index.d.ts ADDED
@@ -0,0 +1,3 @@
1
+ #!/usr/bin/env node
2
+ export {};
3
+ //# sourceMappingURL=index.d.ts.map
package/index.js ADDED
@@ -0,0 +1,101 @@
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
+ import { writeFileSync, unlinkSync } from "fs";
16
+ import { basename, dirname, join } from "path";
17
+ import { loadConfig } from "./config.js";
18
+ import { generateScad, runOpenscad, readStl } from "./scad.js";
19
+ import { buildMeshes } from "./geometry.js";
20
+ import { build3mf, writeBinaryStl } from "./threemf.js";
21
+ // Bambu object-list name: "<config> <part>"; the accent text object reads "labels".
22
+ function partName(cfgName, label) {
23
+ return `${cfgName} ${label === "legend" ? "labels" : label}`;
24
+ }
25
+ function partList(cfg) {
26
+ const parts = [{ mode: "plate", label: "plate" }];
27
+ if (cfg.gangs.some((g) => (g.legend ?? "") !== ""))
28
+ parts.push({ mode: "legend", label: "legend" });
29
+ if (cfg.breaker.switch !== "")
30
+ parts.push({ mode: "breaker", label: "breaker" });
31
+ return parts;
32
+ }
33
+ function runScadEngine(cfg, dir) {
34
+ if (cfg.gangs.some((g) => (g.rockers?.length ?? 0) > 0 || (g.header ?? "") !== "" || (g.breaker ?? "") !== ""))
35
+ console.warn(" ! per-gang rocker/header/breaker labels are only rendered by the manifold engine; the -scad engine ignores them.");
36
+ const scadPath = join(dir, `${cfg.name}.scad`);
37
+ writeFileSync(scadPath, generateScad(cfg));
38
+ console.log("wrote", basename(scadPath));
39
+ if (cfg.output === "scad")
40
+ return;
41
+ const parts = partList(cfg);
42
+ if (cfg.output === "stl") {
43
+ for (const p of parts)
44
+ runOpenscad(cfg.openscad, scadPath, join(dir, `${cfg.name}_${p.label}.stl`), p.mode);
45
+ }
46
+ else {
47
+ const tmp = [];
48
+ const objects = parts.map((p) => {
49
+ const stl = join(dir, `_${cfg.name}_${p.label}.stl`);
50
+ runOpenscad(cfg.openscad, scadPath, stl, p.mode);
51
+ tmp.push(stl);
52
+ return { name: partName(cfg.name, p.label), mesh: readStl(stl) };
53
+ });
54
+ writeFileSync(join(dir, `${cfg.name}.3mf`), build3mf(objects));
55
+ for (const t of tmp)
56
+ unlinkSync(t);
57
+ console.log(" wrote", `${cfg.name}.3mf`, `(${objects.length} objects: ${objects.map((o) => o.name).join(", ")})`);
58
+ }
59
+ }
60
+ async function runManifoldEngine(cfg, dir) {
61
+ if (cfg.output === "scad") { // scad output always needs the scad engine
62
+ runScadEngine(cfg, dir);
63
+ return;
64
+ }
65
+ const meshes = await buildMeshes(cfg);
66
+ for (const m of meshes)
67
+ console.log(" built", m.label, `(${m.mesh.tris.length} tris)`);
68
+ if (cfg.output === "stl") {
69
+ for (const m of meshes)
70
+ writeFileSync(join(dir, `${cfg.name}_${m.label}.stl`), writeBinaryStl(m.mesh));
71
+ }
72
+ else {
73
+ const objects = meshes.map((m) => ({ name: partName(cfg.name, m.label), mesh: m.mesh }));
74
+ writeFileSync(join(dir, `${cfg.name}.3mf`), build3mf(objects));
75
+ console.log(" wrote", `${cfg.name}.3mf`, `(${objects.length} objects: ${objects.map((o) => o.name).join(", ")})`);
76
+ }
77
+ }
78
+ async function main() {
79
+ const args = process.argv.slice(2);
80
+ const flags = args.filter((a) => a.startsWith("-"));
81
+ const positional = args.filter((a) => !a.startsWith("-"));
82
+ const isScad = (f) => f.toLowerCase() === "-scad" || f.toLowerCase() === "--scad";
83
+ const unknown = flags.filter((f) => !isScad(f));
84
+ if (unknown.length) {
85
+ console.error(`unknown option(s): ${unknown.join(" ")}\nusage: node index.ts <config.json> [-scad]`);
86
+ process.exit(2);
87
+ }
88
+ const useScad = flags.some(isScad);
89
+ const cfgPath = positional[0] ?? "wallplate.json";
90
+ const cfg = loadConfig(cfgPath);
91
+ const dir = dirname(cfgPath);
92
+ const engine = useScad || cfg.engine === "scad" ? "scad" : "manifold";
93
+ console.log(`engine: ${engine}, output: ${cfg.output}`);
94
+ if (engine === "scad")
95
+ runScadEngine(cfg, dir);
96
+ else
97
+ await runManifoldEngine(cfg, dir);
98
+ console.log("done");
99
+ }
100
+ main().catch((e) => { console.error(e); process.exit(1); });
101
+ //# sourceMappingURL=index.js.map
package/package.json CHANGED
@@ -1,16 +1,20 @@
1
1
  {
2
2
  "name": "@bobfrankston/wallplater",
3
- "version": "1.0.2",
3
+ "version": "1.0.4",
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
+ "main": "index.js",
7
+ "types": "index.d.ts",
6
8
  "bin": {
7
- "wallplater": "index.ts"
9
+ "wallplater": "index.js"
8
10
  },
9
11
  "engines": {
10
12
  "node": ">=24"
11
13
  },
12
14
  "scripts": {
13
- "gen": "node index.ts",
15
+ "build": "tsc",
16
+ "watch": "tsc -w",
17
+ "gen": "node index.js",
14
18
  "typecheck": "tsc --noEmit",
15
19
  "release": "npmglobalize"
16
20
  },
@@ -25,5 +29,8 @@
25
29
  "repository": {
26
30
  "type": "git",
27
31
  "url": "git@github.com:BobFrankston/wallplater.git"
32
+ },
33
+ "publishConfig": {
34
+ "access": "public"
28
35
  }
29
36
  }
package/scad.d.ts ADDED
@@ -0,0 +1,6 @@
1
+ import { type Resolved } from "./config.js";
2
+ import type { Mesh } from "./threemf.js";
3
+ export declare function generateScad(cfg: Resolved): string;
4
+ export declare function runOpenscad(exe: string, scadPath: string, outPath: string, mode: string): void;
5
+ export declare function readStl(path: string): Mesh;
6
+ //# sourceMappingURL=scad.d.ts.map