@bobfrankston/wallplater 1.0.5 → 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
@@ -1,19 +1,24 @@
1
1
  # @bobfrankston/wallplater
2
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.
3
+ JSON-driven wall-plate generator (Decora / duplex / blank / button gangs). Reads
4
+ a small JSON (JSON5) config and emits a multi-object 3MF (plate + labels as
5
+ separate, individually colourable objects) or per-part STLs.
6
6
 
7
7
  ## Engines
8
8
 
9
9
  | Engine | Flag | Needs OpenSCAD? | Speed | Notes |
10
10
  |---|---|---|---|---|
11
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. |
12
+ | **scad** (legacy) | `-scad` or `"engine":"scad"` | yes | ~30 s | Writes a `.scad` and shells out to OpenSCAD. Reference / fallback. Renders all hole geometry (incl. button pips) but **not** rocker/header/per-button labels. |
13
13
 
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
@@ -56,8 +68,21 @@ directory), so a specific file carries only what differs — often just `name`,
56
68
  `gangs`, and a `breaker.switch`. Every default lives in `defaults.json`, not in
57
69
  code.
58
70
 
59
- Lengths are written **CSS-style with a unit suffix**: `"0.375in"`, `"5mm"`,
60
- `"-42mm"`. A bare number is treated as millimetres. Angles are plain numbers.
71
+ Configs are parsed with **JSON5** (the most lenient reader), so comments
72
+ (`//`, `/* */`), trailing commas, and unquoted keys are all allowed — plain JSON
73
+ still works unchanged.
74
+
75
+ Lengths are written **CSS-style with a unit suffix**. Supported units:
76
+
77
+ | Suffix | Meaning | mm |
78
+ |---|---|---|
79
+ | `in` | inch | 25.4 |
80
+ | `ft` | foot | 304.8 |
81
+ | `cm` | centimetre | 10 |
82
+ | `mm` | millimetre | 1 |
83
+
84
+ e.g. `"0.375in"`, `"5mm"`, `"2cm"`, `"0.5ft"`, `"-42mm"`. A bare number (no
85
+ suffix) is treated as millimetres. Angles (e.g. `cskAngle`) are plain numbers.
61
86
 
62
87
  ```jsonc
63
88
  // KitchenBack.json — only what differs from defaults.json
@@ -72,8 +97,11 @@ Lengths are written **CSS-style with a unit suffix**: `"0.375in"`, `"5mm"`,
72
97
  }
73
98
  ```
74
99
 
75
- - `gangs[].type`: `"decora"` | `"duplex"` | `"blank"`.
100
+ - `gangs[].type`: `"decora"` | `"duplex"` | `"blank"` | `"button"`.
76
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.
77
105
  - `gangs[].legend`: single label centred above the gang's opening (positioned
78
106
  clear of the screw holes; `legendY` / `legendSize`).
79
107
  - `gangs[].breaker`: per-gang breaker/circuit label, lower-right of the opening
@@ -112,10 +140,65 @@ overflow. See `../triple_demo.json` for a worked single-gang example.
112
140
  > Rocker/header labels are rendered by the **manifold engine only**. The
113
141
  > `-scad` engine ignores them (it warns).
114
142
 
143
+ ### Button gangs (round holes in a pip layout)
144
+
145
+ A `"button"` gang punches a set of round button holes (default **0.5 in**
146
+ diameter) arranged symmetrically like the pips on a die / playing card — odd
147
+ counts put a single button in the centre. Set the count with `buttons` (1–9):
148
+
149
+ ```jsonc
150
+ {
151
+ "type": "button",
152
+ "legend": "FAN", // the legend "on top" (above the buttons)
153
+ "buttons": 3, // 1-9; pip layout, single = centre
154
+ "buttonD": "0.5in", // optional per-gang diameter (default dims.buttonD)
155
+ "buttonLegends": ["HI", "MED", "LO"] // optional small per-button labels
156
+ }
157
+ ```
158
+
159
+ - `buttons`: number of holes (1–9). Setting it implies `type:"button"`. The pip
160
+ pattern is the standard die/domino layout, read **top → bottom, left → right**:
161
+ `1`=centre, `2`/`3`=diagonal, `4`/`5`=corners (+centre for 5), `6`/`7`=two
162
+ columns (+centre for 7), `8`/`9`=full ring/grid. Counts above 9 are an error.
163
+ - `buttonD`: per-gang hole diameter; defaults to `dims.buttonD` (`0.5in`).
164
+ - `buttonLegends`: small labels, one per button in pip reading order (`""` or a
165
+ short array skips some). They are **in addition to** the gang `legend` on top,
166
+ and print in the accent colour. Sizing/placement: `buttonLegendSize` (3 mm),
167
+ `buttonLegendGap` (1 mm, below each hole).
168
+ - Pip spacing is `dims.buttonPitch` (`0.85in` centre-to-centre). A button gang
169
+ mounts like a device — it gets a screw pair on the Decora line unless
170
+ `"screws": false`.
171
+
172
+ See `../buttons_demo.json` for a worked Decora-plus-buttons example.
173
+
174
+ > Per-button legends are rendered by the **manifold engine only** (the `-scad`
175
+ > engine emits the button holes but skips the small labels, and warns).
176
+
115
177
  See `../decora_wallplate_spec.md` for the dimensional reference and print notes
116
178
  (print front-face-down; PETG/ASA recommended for heat near devices; keep plate
117
179
  and labels the same material family — see the chat note on ABS+PLA).
118
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
+
119
202
  ## Files
120
203
 
121
204
  - `index.ts` — CLI entry / engine dispatch / arg validation
package/config.d.ts CHANGED
@@ -1,9 +1,9 @@
1
1
  export declare const IN = 25.4;
2
- export type GangType = "blank" | "decora" | "duplex";
2
+ export type GangType = "blank" | "decora" | "duplex" | "button";
3
3
  export type Engine = "manifold" | "scad";
4
- /** A length: CSS-style unit string ("0.375in" / "5mm") or a bare number (mm). */
4
+ /** A length: CSS-style unit string ("0.375in" / "5mm" / "2cm" / "0.5ft") or a bare number (mm). */
5
5
  export type Len = string | number;
6
- /** Parse a length to millimetres. */
6
+ /** Parse a length to millimetres. A bare number (no suffix) is millimetres. */
7
7
  export declare function mm(v: Len): number;
8
8
  export interface RockerCfg {
9
9
  left?: string;
@@ -13,6 +13,9 @@ export interface GangCfg {
13
13
  type?: GangType;
14
14
  opening?: boolean;
15
15
  legend?: string;
16
+ buttons?: number;
17
+ buttonD?: Len;
18
+ buttonLegends?: string[];
16
19
  filler?: boolean;
17
20
  screws?: boolean;
18
21
  rockers?: RockerCfg[];
@@ -38,6 +41,8 @@ export interface DimsCfg {
38
41
  duplexW?: Len;
39
42
  duplexH?: Len;
40
43
  duplexSpacing?: Len;
44
+ buttonD?: Len;
45
+ buttonPitch?: Len;
41
46
  screwSpacing?: Len;
42
47
  blankScrewSpacing?: Len;
43
48
  screwClearD?: Len;
@@ -56,6 +61,8 @@ export interface Config {
56
61
  rockerGap?: Len;
57
62
  headerSize?: Len;
58
63
  headerGap?: Len;
64
+ buttonLegendSize?: Len;
65
+ buttonLegendGap?: Len;
59
66
  bevel?: Len;
60
67
  labelDepth?: Len;
61
68
  blankScrews?: boolean;
@@ -90,6 +97,8 @@ export interface Dims {
90
97
  duplexW: number;
91
98
  duplexH: number;
92
99
  duplexSpacing: number;
100
+ buttonD: number;
101
+ buttonPitch: number;
93
102
  screwSpacing: number;
94
103
  blankScrewSpacing: number;
95
104
  screwClearD: number;
@@ -109,6 +118,8 @@ export interface Resolved {
109
118
  rockerGap: number;
110
119
  headerSize: number;
111
120
  headerGap: number;
121
+ buttonLegendSize: number;
122
+ buttonLegendGap: number;
112
123
  bevel: number;
113
124
  labelDepth: number;
114
125
  blankScrews: boolean;
@@ -124,4 +135,10 @@ export interface Resolved {
124
135
  }
125
136
  export declare function loadConfig(path: string): Resolved;
126
137
  export declare function gangType(g: GangCfg): GangType;
138
+ /**
139
+ * Pip layout for a button gang: grid coordinates (gx, gy) each in {-1, 0, 1}
140
+ * (gy up), die/domino style, in reading order (top -> bottom, left -> right).
141
+ * Odd counts include the centre (0, 0). Supports 1-9.
142
+ */
143
+ export declare function pipGrid(n: number): [number, number][];
127
144
  //# sourceMappingURL=config.d.ts.map
package/config.js CHANGED
@@ -5,25 +5,29 @@
5
5
  * (in this directory), so a specific file need only carry what differs — often
6
6
  * just `name`, `gangs`, and a `breaker.switch`.
7
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).
8
+ * Lengths are written CSS-style with a unit suffix: "0.375in", "5mm", "2cm",
9
+ * "0.5ft", "-42mm". A bare number is treated as millimetres. Angles are plain
10
+ * numbers (degrees).
10
11
  */
11
12
  import { readFileSync } from "fs";
12
13
  import { join } from "path";
14
+ import JSON5 from "json5";
13
15
  export const IN = 25.4;
14
- /** Parse a length to millimetres. */
16
+ /** Millimetres per supported length unit. */
17
+ const UNIT = { mm: 1, cm: 10, in: IN, ft: 12 * IN };
18
+ /** Parse a length to millimetres. A bare number (no suffix) is millimetres. */
15
19
  export function mm(v) {
16
20
  if (typeof v === "number")
17
21
  return v;
18
- const m = /^\s*(-?[\d.]+)\s*(mm|in)?\s*$/i.exec(v);
22
+ const m = /^\s*(-?[\d.]+)\s*(mm|cm|in|ft)?\s*$/i.exec(v);
19
23
  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;
24
+ throw new Error(`invalid length "${v}" (use e.g. "0.375in", "5mm", "2cm", "0.5ft")`);
25
+ return parseFloat(m[1]) * UNIT[(m[2] ?? "mm").toLowerCase()];
23
26
  }
24
- // JSON.parse returns dynamic shape; merged then validated below.
27
+ // JSON5 (lenient: comments, trailing commas, unquoted keys) returns dynamic
28
+ // shape; merged then validated below.
25
29
  function readJson(path) {
26
- return JSON.parse(readFileSync(path, "utf8"));
30
+ return JSON5.parse(readFileSync(path, "utf8"));
27
31
  }
28
32
  /** Deep-merge plain objects; arrays and scalars from `over` replace `base`. */
29
33
  function deepMerge(base, over) {
@@ -50,6 +54,7 @@ export function loadConfig(path) {
50
54
  faceT: mm(d.faceT), rimW: mm(d.rimW), cornerR: mm(d.cornerR),
51
55
  openW: mm(d.openW), openH: mm(d.openH), openR: mm(d.openR),
52
56
  duplexW: mm(d.duplexW), duplexH: mm(d.duplexH), duplexSpacing: mm(d.duplexSpacing),
57
+ buttonD: mm(d.buttonD), buttonPitch: mm(d.buttonPitch),
53
58
  screwSpacing: mm(d.screwSpacing), blankScrewSpacing: mm(d.blankScrewSpacing),
54
59
  screwClearD: mm(d.screwClearD), screwHeadD: mm(d.screwHeadD), cskAngle: d.cskAngle,
55
60
  };
@@ -62,6 +67,7 @@ export function loadConfig(path) {
62
67
  gangBreakerSize: mm(c.gangBreakerSize), gangBreakerY: mm(c.gangBreakerY),
63
68
  rockerSize: mm(c.rockerSize), rockerGap: mm(c.rockerGap),
64
69
  headerSize: mm(c.headerSize), headerGap: mm(c.headerGap),
70
+ buttonLegendSize: mm(c.buttonLegendSize), buttonLegendGap: mm(c.buttonLegendGap),
65
71
  bevel: mm(c.bevel), labelDepth: mm(c.labelDepth),
66
72
  blankScrews: c.blankScrews,
67
73
  font: c.font, fontFile: c.fontFile,
@@ -74,8 +80,32 @@ export function loadConfig(path) {
74
80
  export function gangType(g) {
75
81
  if (g.type)
76
82
  return g.type;
83
+ if (g.buttons && g.buttons > 0)
84
+ return "button"; // count implies a button gang
77
85
  if (g.opening === true)
78
86
  return "decora";
79
87
  return "blank";
80
88
  }
89
+ /**
90
+ * Pip layout for a button gang: grid coordinates (gx, gy) each in {-1, 0, 1}
91
+ * (gy up), die/domino style, in reading order (top -> bottom, left -> right).
92
+ * Odd counts include the centre (0, 0). Supports 1-9.
93
+ */
94
+ export function pipGrid(n) {
95
+ const grid = {
96
+ 1: [[0, 0]],
97
+ 2: [[-1, 1], [1, -1]],
98
+ 3: [[-1, 1], [0, 0], [1, -1]],
99
+ 4: [[-1, 1], [1, 1], [-1, -1], [1, -1]],
100
+ 5: [[-1, 1], [1, 1], [0, 0], [-1, -1], [1, -1]],
101
+ 6: [[-1, 1], [1, 1], [-1, 0], [1, 0], [-1, -1], [1, -1]],
102
+ 7: [[-1, 1], [1, 1], [-1, 0], [0, 0], [1, 0], [-1, -1], [1, -1]],
103
+ 8: [[-1, 1], [0, 1], [1, 1], [-1, 0], [1, 0], [-1, -1], [0, -1], [1, -1]],
104
+ 9: [[-1, 1], [0, 1], [1, 1], [-1, 0], [0, 0], [1, 0], [-1, -1], [0, -1], [1, -1]],
105
+ };
106
+ const p = grid[n];
107
+ if (!p)
108
+ throw new Error(`buttons: count ${n} unsupported (pip layout is 1-9)`);
109
+ return p;
110
+ }
81
111
  //# sourceMappingURL=config.js.map
package/defaults.json CHANGED
@@ -9,6 +9,8 @@
9
9
  "rockerGap": "1.5mm",
10
10
  "headerSize": "4mm",
11
11
  "headerGap": "5mm",
12
+ "buttonLegendSize": "3mm",
13
+ "buttonLegendGap": "1mm",
12
14
  "bevel": "1.2mm",
13
15
  "labelDepth": "0.6mm",
14
16
  "blankScrews": true,
@@ -34,6 +36,8 @@
34
36
  "duplexW": "1.34375in",
35
37
  "duplexH": "1.125in",
36
38
  "duplexSpacing": "1.5in",
39
+ "buttonD": "0.5in",
40
+ "buttonPitch": "0.85in",
37
41
  "screwSpacing": "3.8125in",
38
42
  "blankScrewSpacing": "3.281in",
39
43
  "screwClearD": "4mm",
package/geometry.js CHANGED
@@ -6,7 +6,7 @@
6
6
  * print-oriented, ready for build3mf() or writeBinaryStl().
7
7
  */
8
8
  import Module from "manifold-3d";
9
- import { gangType } from "./config.js";
9
+ import { gangType, pipGrid, mm } from "./config.js";
10
10
  import { loadFont, textToContours } from "./text.js";
11
11
  const SEG = 96; // circular segments (matches the SCAD $fn = 96)
12
12
  let wasm = null;
@@ -83,6 +83,14 @@ export async function buildMeshes(cfg) {
83
83
  if (sc)
84
84
  cutters.push(screw(x, 0)); // single centre screw
85
85
  }
86
+ else if (t === "button") { // round button holes in a pip layout
87
+ const bd = g.buttonD !== undefined ? mm(g.buttonD) : d.buttonD;
88
+ for (const [gx, gy] of pipGrid(g.buttons))
89
+ cutters.push(Manifold.cylinder(DEPTH + 2, bd / 2, bd / 2, SEG)
90
+ .translate([x + gx * d.buttonPitch, gy * d.buttonPitch, -1]));
91
+ if (sc)
92
+ screwsPair(x, d.screwSpacing); // mounts like a device, on the Decora line
93
+ }
86
94
  else if (cfg.blankScrews && sc) { // blank: box-ear line, or Decora line if a filler plate
87
95
  screwsPair(x, g.filler ? d.screwSpacing : d.blankScrewSpacing);
88
96
  }
@@ -101,6 +109,18 @@ export async function buildMeshes(cfg) {
101
109
  accent.push({ txt: g.breaker, size: cfg.gangBreakerSize, halign: "right", x: gx + openHalf, y: cfg.gangBreakerY });
102
110
  if ((g.header ?? "") !== "")
103
111
  accent.push({ txt: g.header, size: cfg.headerSize, halign: "center", x: gx, y: openTop + cfg.headerGap });
112
+ if (gangType(g) === "button") { // per-button legends, small, below each hole
113
+ const bd = g.buttonD !== undefined ? mm(g.buttonD) : d.buttonD;
114
+ const bl = g.buttonLegends ?? [];
115
+ const pips = pipGrid(g.buttons);
116
+ for (let j = 0; j < pips.length; j++) {
117
+ if ((bl[j] ?? "") === "")
118
+ continue;
119
+ const [px, py] = pips[j];
120
+ accent.push({ txt: bl[j], size: cfg.buttonLegendSize, halign: "center",
121
+ x: gx + px * d.buttonPitch, y: py * d.buttonPitch - bd / 2 - cfg.buttonLegendGap });
122
+ }
123
+ }
104
124
  const rk = g.rockers ?? [];
105
125
  for (let j = 0; j < rk.length; j++) {
106
126
  const yr = openTop - (j + 0.5) * (d.openH / rk.length); // rockers top -> bottom
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}`;
@@ -35,27 +47,28 @@ function partList(cfg) {
35
47
  return parts;
36
48
  }
37
49
  function runScadEngine(cfg, dir) {
38
- if (cfg.gangs.some((g) => (g.rockers?.length ?? 0) > 0 || (g.header ?? "") !== "" || (g.breaker ?? "") !== ""))
39
- console.warn(" ! per-gang rocker/header/breaker labels are only rendered by the manifold engine; the -scad engine ignores them.");
50
+ if (cfg.gangs.some((g) => (g.rockers?.length ?? 0) > 0 || (g.header ?? "") !== "" || (g.breaker ?? "") !== "" || (g.buttonLegends?.length ?? 0) > 0))
51
+ console.warn(" ! per-gang rocker/header/breaker labels and per-button legends are only rendered by the manifold engine; the -scad engine ignores them (button holes themselves are emitted).");
40
52
  const scadPath = join(dir, `${cfg.name}.scad`);
41
53
  writeFileSync(scadPath, generateScad(cfg));
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.5",
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",
@@ -19,6 +19,7 @@
19
19
  "release": "npmglobalize"
20
20
  },
21
21
  "dependencies": {
22
+ "json5": "^2.2.3",
22
23
  "manifold-3d": "^3.0.0",
23
24
  "opentype.js": "^1.3.4"
24
25
  },
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) {
@@ -17,11 +17,13 @@ export function generateScad(cfg) {
17
17
  const legend = "[" + cfg.gangs.map((g) => `"${g.legend ?? ""}"`).join(", ") + "]";
18
18
  const filler = "[" + cfg.gangs.map((g) => (g.filler ? "true" : "false")).join(", ") + "]";
19
19
  const screws = "[" + cfg.gangs.map((g) => (g.screws === false ? "false" : "true")).join(", ") + "]";
20
+ const buttons = "[" + cfg.gangs.map((g) => num(g.buttons ?? 0)).join(", ") + "]";
20
21
  const params = `// ===== Auto-generated by wallplater from ${cfg.name}.json — do not hand-edit =====
21
22
  GANGS = ${gangs}; // per gang: "blank" | "decora" | "duplex"
22
23
  LEGEND = ${legend};
23
24
  FILLER = ${filler}; // blank gang has a filler/insert plate -> screws on Decora line
24
25
  SCREWS = ${screws}; // per gang: place screw holes? (false = omit)
26
+ BUTTONS = ${buttons}; // per gang: button-hole count (0 = not a button gang)
25
27
  GANG_PITCH = ${num(d.gangPitch)};
26
28
  ONE_GANG_W = ${num(d.oneGangWidth)};
27
29
  PLATE_H = ${num(d.height)};
@@ -36,6 +38,8 @@ OPEN_R = ${num(d.openR)};
36
38
  DUPLEX_W = ${num(d.duplexW)};
37
39
  DUPLEX_H = ${num(d.duplexH)};
38
40
  DUPLEX_SPACING= ${num(d.duplexSpacing)};
41
+ BUTTON_D = ${num(d.buttonD)};
42
+ BUTTON_PITCH = ${num(d.buttonPitch)};
39
43
  SCREW_SPACING = ${num(d.screwSpacing)};
40
44
  BLANK_SCREW_SPACING = ${num(d.blankScrewSpacing)};
41
45
  BLANK_SCREWS = ${cfg.blankScrews};
@@ -82,6 +86,24 @@ module duplex_opening(x)
82
86
  translate([x, s * DUPLEX_SPACING/2, -1]) linear_extrude(DEPTH + 2)
83
87
  intersection() { circle(d = DUPLEX_W); square([DUPLEX_W, DUPLEX_H], center = true); }
84
88
 
89
+ // pip grid: [gx, gy] in {-1,0,1} (gy up), die/domino style, centre for odd counts (1-9)
90
+ function pip_grid(n) =
91
+ n == 1 ? [[0,0]] :
92
+ n == 2 ? [[-1,1],[1,-1]] :
93
+ n == 3 ? [[-1,1],[0,0],[1,-1]] :
94
+ n == 4 ? [[-1,1],[1,1],[-1,-1],[1,-1]] :
95
+ n == 5 ? [[-1,1],[1,1],[0,0],[-1,-1],[1,-1]] :
96
+ n == 6 ? [[-1,1],[1,1],[-1,0],[1,0],[-1,-1],[1,-1]] :
97
+ n == 7 ? [[-1,1],[1,1],[-1,0],[0,0],[1,0],[-1,-1],[1,-1]] :
98
+ n == 8 ? [[-1,1],[0,1],[1,1],[-1,0],[1,0],[-1,-1],[0,-1],[1,-1]] :
99
+ n == 9 ? [[-1,1],[0,1],[1,1],[-1,0],[0,0],[1,0],[-1,-1],[0,-1],[1,-1]] :
100
+ [];
101
+
102
+ module button_holes(x, n)
103
+ for (p = pip_grid(n))
104
+ translate([x + p[0]*BUTTON_PITCH, p[1]*BUTTON_PITCH, -1])
105
+ cylinder(d = BUTTON_D, h = DEPTH + 2);
106
+
85
107
  module outline() rrect(PLATE_W, PLATE_H, CORNER_R);
86
108
 
87
109
  module plate_body() {
@@ -120,6 +142,7 @@ module plate() {
120
142
  sc = (i >= len(SCREWS)) || SCREWS[i]; // place screws on this gang?
121
143
  if (t == "decora") { decora_opening(x); if (sc) screws_pair(x, SCREW_SPACING); }
122
144
  else if (t == "duplex") { duplex_opening(x); if (sc) screw(x, 0); } // single centre screw
145
+ else if (t == "button") { button_holes(x, BUTTONS[i]); if (sc) screws_pair(x, SCREW_SPACING); }
123
146
  else if (BLANK_SCREWS && sc) // blank: box-ear line, or Decora line if a filler plate
124
147
  screws_pair(x, (i < len(FILLER) && FILLER[i]) ? SCREW_SPACING : BLANK_SCREW_SPACING);
125
148
  }
@@ -140,6 +163,68 @@ if (MODE == "preview") {
140
163
  else if (MODE == "legend") oriented() legend_solids(LABEL_DEPTH);
141
164
  else if (MODE == "breaker") oriented() breaker_solid(LABEL_DEPTH);
142
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
+ }
143
228
  export function runOpenscad(exe, scadPath, outPath, mode) {
144
229
  const fmt = outPath.endsWith(".3mf") ? "3mf" : outPath.endsWith(".png") ? "png" : "binstl";
145
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);