@bobfrankston/wallplater 1.0.5 → 1.0.6

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,15 +1,15 @@
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.
@@ -56,8 +56,21 @@ directory), so a specific file carries only what differs — often just `name`,
56
56
  `gangs`, and a `breaker.switch`. Every default lives in `defaults.json`, not in
57
57
  code.
58
58
 
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.
59
+ Configs are parsed with **JSON5** (the most lenient reader), so comments
60
+ (`//`, `/* */`), trailing commas, and unquoted keys are all allowed — plain JSON
61
+ still works unchanged.
62
+
63
+ Lengths are written **CSS-style with a unit suffix**. Supported units:
64
+
65
+ | Suffix | Meaning | mm |
66
+ |---|---|---|
67
+ | `in` | inch | 25.4 |
68
+ | `ft` | foot | 304.8 |
69
+ | `cm` | centimetre | 10 |
70
+ | `mm` | millimetre | 1 |
71
+
72
+ e.g. `"0.375in"`, `"5mm"`, `"2cm"`, `"0.5ft"`, `"-42mm"`. A bare number (no
73
+ suffix) is treated as millimetres. Angles (e.g. `cskAngle`) are plain numbers.
61
74
 
62
75
  ```jsonc
63
76
  // KitchenBack.json — only what differs from defaults.json
@@ -72,7 +85,7 @@ Lengths are written **CSS-style with a unit suffix**: `"0.375in"`, `"5mm"`,
72
85
  }
73
86
  ```
74
87
 
75
- - `gangs[].type`: `"decora"` | `"duplex"` | `"blank"`.
88
+ - `gangs[].type`: `"decora"` | `"duplex"` | `"blank"` | `"button"`.
76
89
  - `gangs[].screws`: `false` to omit that gang's screw holes (default true).
77
90
  - `gangs[].legend`: single label centred above the gang's opening (positioned
78
91
  clear of the screw holes; `legendY` / `legendSize`).
@@ -112,6 +125,40 @@ overflow. See `../triple_demo.json` for a worked single-gang example.
112
125
  > Rocker/header labels are rendered by the **manifold engine only**. The
113
126
  > `-scad` engine ignores them (it warns).
114
127
 
128
+ ### Button gangs (round holes in a pip layout)
129
+
130
+ A `"button"` gang punches a set of round button holes (default **0.5 in**
131
+ diameter) arranged symmetrically like the pips on a die / playing card — odd
132
+ counts put a single button in the centre. Set the count with `buttons` (1–9):
133
+
134
+ ```jsonc
135
+ {
136
+ "type": "button",
137
+ "legend": "FAN", // the legend "on top" (above the buttons)
138
+ "buttons": 3, // 1-9; pip layout, single = centre
139
+ "buttonD": "0.5in", // optional per-gang diameter (default dims.buttonD)
140
+ "buttonLegends": ["HI", "MED", "LO"] // optional small per-button labels
141
+ }
142
+ ```
143
+
144
+ - `buttons`: number of holes (1–9). Setting it implies `type:"button"`. The pip
145
+ pattern is the standard die/domino layout, read **top → bottom, left → right**:
146
+ `1`=centre, `2`/`3`=diagonal, `4`/`5`=corners (+centre for 5), `6`/`7`=two
147
+ columns (+centre for 7), `8`/`9`=full ring/grid. Counts above 9 are an error.
148
+ - `buttonD`: per-gang hole diameter; defaults to `dims.buttonD` (`0.5in`).
149
+ - `buttonLegends`: small labels, one per button in pip reading order (`""` or a
150
+ short array skips some). They are **in addition to** the gang `legend` on top,
151
+ and print in the accent colour. Sizing/placement: `buttonLegendSize` (3 mm),
152
+ `buttonLegendGap` (1 mm, below each hole).
153
+ - Pip spacing is `dims.buttonPitch` (`0.85in` centre-to-centre). A button gang
154
+ mounts like a device — it gets a screw pair on the Decora line unless
155
+ `"screws": false`.
156
+
157
+ See `../buttons_demo.json` for a worked Decora-plus-buttons example.
158
+
159
+ > Per-button legends are rendered by the **manifold engine only** (the `-scad`
160
+ > engine emits the button holes but skips the small labels, and warns).
161
+
115
162
  See `../decora_wallplate_spec.md` for the dimensional reference and print notes
116
163
  (print front-face-down; PETG/ASA recommended for heat near devices; keep plate
117
164
  and labels the same material family — see the chat note on ABS+PLA).
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
@@ -35,8 +35,8 @@ function partList(cfg) {
35
35
  return parts;
36
36
  }
37
37
  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.");
38
+ if (cfg.gangs.some((g) => (g.rockers?.length ?? 0) > 0 || (g.header ?? "") !== "" || (g.breaker ?? "") !== "" || (g.buttonLegends?.length ?? 0) > 0))
39
+ 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
40
  const scadPath = join(dir, `${cfg.name}.scad`);
41
41
  writeFileSync(scadPath, generateScad(cfg));
42
42
  console.log("wrote", basename(scadPath));
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bobfrankston/wallplater",
3
- "version": "1.0.5",
3
+ "version": "1.0.6",
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.js CHANGED
@@ -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
  }