@bobfrankston/wallplater 1.0.4 → 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 +65 -15
- package/config.d.ts +20 -3
- package/config.js +39 -9
- package/defaults.json +7 -3
- package/geometry.js +26 -3
- package/index.js +8 -4
- package/package.json +2 -1
- package/scad.js +23 -0
- package/threemf.d.ts +1 -0
- package/threemf.js +12 -0
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
|
|
4
|
-
JSON config and emits a multi-object 3MF (plate + labels as
|
|
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.
|
|
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.
|
|
@@ -41,11 +41,13 @@ wallplater ../KitchenBack.json # if installed globally
|
|
|
41
41
|
- Outputs are written next to the **config file**, named after the config's `name`.
|
|
42
42
|
- Unknown flags are rejected. `-scad` (or `--scad`) selects the OpenSCAD engine.
|
|
43
43
|
|
|
44
|
-
The 3MF holds up to three named, individually-selectable objects — `<name> plate
|
|
45
|
-
`<name> labels` (all accent text), `<name> breaker`
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
44
|
+
The 3MF holds up to three named, individually-selectable objects — `<name> plate`
|
|
45
|
+
(filament 1), `<name> labels` (all accent text, filament 2), `<name> breaker`
|
|
46
|
+
(filament 2). The filament assignment is baked in via `Metadata/model_settings.config`
|
|
47
|
+
(Bambu/Orca per-object `extruder`), so the text slices in your second colour
|
|
48
|
+
without manual assignment — provided the project has ≥2 filaments. The label
|
|
49
|
+
objects sit flush in the plate's front-face pockets (printed front-face-down), so
|
|
50
|
+
they look hidden in the viewport until coloured.
|
|
49
51
|
|
|
50
52
|
## Config
|
|
51
53
|
|
|
@@ -54,8 +56,21 @@ directory), so a specific file carries only what differs — often just `name`,
|
|
|
54
56
|
`gangs`, and a `breaker.switch`. Every default lives in `defaults.json`, not in
|
|
55
57
|
code.
|
|
56
58
|
|
|
57
|
-
|
|
58
|
-
|
|
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.
|
|
59
74
|
|
|
60
75
|
```jsonc
|
|
61
76
|
// KitchenBack.json — only what differs from defaults.json
|
|
@@ -70,11 +85,12 @@ Lengths are written **CSS-style with a unit suffix**: `"0.375in"`, `"5mm"`,
|
|
|
70
85
|
}
|
|
71
86
|
```
|
|
72
87
|
|
|
73
|
-
- `gangs[].type`: `"decora"` | `"duplex"` | `"blank"`.
|
|
88
|
+
- `gangs[].type`: `"decora"` | `"duplex"` | `"blank"` | `"button"`.
|
|
74
89
|
- `gangs[].screws`: `false` to omit that gang's screw holes (default true).
|
|
75
|
-
- `gangs[].legend`: single label centred
|
|
76
|
-
|
|
77
|
-
|
|
90
|
+
- `gangs[].legend`: single label centred above the gang's opening (positioned
|
|
91
|
+
clear of the screw holes; `legendY` / `legendSize`).
|
|
92
|
+
- `gangs[].breaker`: per-gang breaker/circuit label, lower-right of the opening
|
|
93
|
+
and clear of the screws (`gangBreakerY` / `gangBreakerSize`).
|
|
78
94
|
- `breaker`: `{ "switch": "A8", "size": "0.375in", "inset": "0.25in" }` — the
|
|
79
95
|
panel-wide breaker label in the lower-right corner. `switch` is the text;
|
|
80
96
|
`size`/`inset` come from `defaults.json`, so a config usually sets only
|
|
@@ -109,6 +125,40 @@ overflow. See `../triple_demo.json` for a worked single-gang example.
|
|
|
109
125
|
> Rocker/header labels are rendered by the **manifold engine only**. The
|
|
110
126
|
> `-scad` engine ignores them (it warns).
|
|
111
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
|
+
|
|
112
162
|
See `../decora_wallplate_spec.md` for the dimensional reference and print notes
|
|
113
163
|
(print front-face-down; PETG/ASA recommended for heat near devices; keep plate
|
|
114
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", "
|
|
9
|
-
* A bare number is treated as millimetres. Angles are plain
|
|
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
|
-
/**
|
|
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"
|
|
21
|
-
|
|
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
|
-
//
|
|
27
|
+
// JSON5 (lenient: comments, trailing commas, unquoted keys) returns dynamic
|
|
28
|
+
// shape; merged then validated below.
|
|
25
29
|
function readJson(path) {
|
|
26
|
-
return
|
|
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
|
@@ -2,13 +2,15 @@
|
|
|
2
2
|
"name": "wallplate",
|
|
3
3
|
"breaker": { "switch": "", "size": "0.375in", "inset": "0.25in" },
|
|
4
4
|
"legendSize": "5mm",
|
|
5
|
-
"legendY": "
|
|
6
|
-
"gangBreakerSize": "
|
|
7
|
-
"gangBreakerY": "-
|
|
5
|
+
"legendY": "39mm",
|
|
6
|
+
"gangBreakerSize": "5mm",
|
|
7
|
+
"gangBreakerY": "-39mm",
|
|
8
8
|
"rockerSize": "3.5mm",
|
|
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
|
}
|
|
@@ -92,12 +100,27 @@ export async function buildMeshes(cfg) {
|
|
|
92
100
|
for (let i = 0; i < N; i++) {
|
|
93
101
|
const g = cfg.gangs[i];
|
|
94
102
|
const gx = gangX(i);
|
|
103
|
+
// Screw holes sit on the gang centreline at y = +/- screwSpacing/2, so
|
|
104
|
+
// labels are kept off-centreline / out of those bands: legend above the
|
|
105
|
+
// opening (legendY > 0), per-gang breaker below-right of the opening.
|
|
95
106
|
if ((g.legend ?? "") !== "")
|
|
96
107
|
accent.push({ txt: g.legend, size: cfg.legendSize, halign: "center", x: gx, y: cfg.legendY });
|
|
97
|
-
if ((g.breaker ?? "") !== "") // per-gang breaker
|
|
98
|
-
accent.push({ txt: g.breaker, size: cfg.gangBreakerSize, halign: "
|
|
108
|
+
if ((g.breaker ?? "") !== "") // per-gang breaker: lower-right of the opening
|
|
109
|
+
accent.push({ txt: g.breaker, size: cfg.gangBreakerSize, halign: "right", x: gx + openHalf, y: cfg.gangBreakerY });
|
|
99
110
|
if ((g.header ?? "") !== "")
|
|
100
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
|
+
}
|
|
101
124
|
const rk = g.rockers ?? [];
|
|
102
125
|
for (let j = 0; j < rk.length; j++) {
|
|
103
126
|
const yr = openTop - (j + 0.5) * (d.openH / rk.length); // rockers top -> bottom
|
package/index.js
CHANGED
|
@@ -22,6 +22,10 @@ import { build3mf, writeBinaryStl } from "./threemf.js";
|
|
|
22
22
|
function partName(cfgName, label) {
|
|
23
23
|
return `${cfgName} ${label === "legend" ? "labels" : label}`;
|
|
24
24
|
}
|
|
25
|
+
// Filament/extruder per part: plate -> 1, all accent text -> 2.
|
|
26
|
+
function partExtruder(label) {
|
|
27
|
+
return label === "plate" ? 1 : 2;
|
|
28
|
+
}
|
|
25
29
|
function partList(cfg) {
|
|
26
30
|
const parts = [{ mode: "plate", label: "plate" }];
|
|
27
31
|
if (cfg.gangs.some((g) => (g.legend ?? "") !== ""))
|
|
@@ -31,8 +35,8 @@ function partList(cfg) {
|
|
|
31
35
|
return parts;
|
|
32
36
|
}
|
|
33
37
|
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.");
|
|
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).");
|
|
36
40
|
const scadPath = join(dir, `${cfg.name}.scad`);
|
|
37
41
|
writeFileSync(scadPath, generateScad(cfg));
|
|
38
42
|
console.log("wrote", basename(scadPath));
|
|
@@ -49,7 +53,7 @@ function runScadEngine(cfg, dir) {
|
|
|
49
53
|
const stl = join(dir, `_${cfg.name}_${p.label}.stl`);
|
|
50
54
|
runOpenscad(cfg.openscad, scadPath, stl, p.mode);
|
|
51
55
|
tmp.push(stl);
|
|
52
|
-
return { name: partName(cfg.name, p.label), mesh: readStl(stl) };
|
|
56
|
+
return { name: partName(cfg.name, p.label), mesh: readStl(stl), extruder: partExtruder(p.label) };
|
|
53
57
|
});
|
|
54
58
|
writeFileSync(join(dir, `${cfg.name}.3mf`), build3mf(objects));
|
|
55
59
|
for (const t of tmp)
|
|
@@ -70,7 +74,7 @@ async function runManifoldEngine(cfg, dir) {
|
|
|
70
74
|
writeFileSync(join(dir, `${cfg.name}_${m.label}.stl`), writeBinaryStl(m.mesh));
|
|
71
75
|
}
|
|
72
76
|
else {
|
|
73
|
-
const objects = meshes.map((m) => ({ name: partName(cfg.name, m.label), mesh: m.mesh }));
|
|
77
|
+
const objects = meshes.map((m) => ({ name: partName(cfg.name, m.label), mesh: m.mesh, extruder: partExtruder(m.label) }));
|
|
74
78
|
writeFileSync(join(dir, `${cfg.name}.3mf`), build3mf(objects));
|
|
75
79
|
console.log(" wrote", `${cfg.name}.3mf`, `(${objects.length} objects: ${objects.map((o) => o.name).join(", ")})`);
|
|
76
80
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@bobfrankston/wallplater",
|
|
3
|
-
"version": "1.0.
|
|
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
|
}
|
package/threemf.d.ts
CHANGED
package/threemf.js
CHANGED
|
@@ -26,11 +26,22 @@ export function build3mf(objects) {
|
|
|
26
26
|
});
|
|
27
27
|
const model = `<?xml version="1.0" encoding="UTF-8"?>
|
|
28
28
|
<model unit="millimeter" xml:lang="en-US" xmlns="http://schemas.microsoft.com/3dmanufacturing/core/2015/02">
|
|
29
|
+
<metadata name="Application">wallplater</metadata>
|
|
29
30
|
<resources>
|
|
30
31
|
${objs} </resources>
|
|
31
32
|
<build>
|
|
32
33
|
${items} </build>
|
|
33
34
|
</model>
|
|
35
|
+
`;
|
|
36
|
+
// Bambu/Orca per-object filament assignment lives here (read by path; not
|
|
37
|
+
// declared in [Content_Types].xml, matching how Bambu itself packages it).
|
|
38
|
+
const settings = `<?xml version="1.0" encoding="UTF-8"?>
|
|
39
|
+
<config>
|
|
40
|
+
${objects.map((o, i) => ` <object id="${i + 1}">
|
|
41
|
+
<metadata key="name" value="${xmlEscape(o.name)}"/>
|
|
42
|
+
<metadata key="extruder" value="${o.extruder ?? 1}"/>
|
|
43
|
+
</object>`).join("\n")}
|
|
44
|
+
</config>
|
|
34
45
|
`;
|
|
35
46
|
const ct = `<?xml version="1.0" encoding="UTF-8"?>
|
|
36
47
|
<Types xmlns="http://schemas.openxmlformats.org/package/2006/content-types">
|
|
@@ -47,6 +58,7 @@ ${items} </build>
|
|
|
47
58
|
{ name: "[Content_Types].xml", data: Buffer.from(ct) },
|
|
48
59
|
{ name: "_rels/.rels", data: Buffer.from(rels) },
|
|
49
60
|
{ name: "3D/3dmodel.model", data: Buffer.from(model) },
|
|
61
|
+
{ name: "Metadata/model_settings.config", data: Buffer.from(settings) },
|
|
50
62
|
]);
|
|
51
63
|
}
|
|
52
64
|
// ---------- binary STL (single object) ----------
|