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