@bobfrankston/wallplater 1.0.1 → 1.0.3
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 +6 -2
- package/config.d.ts +127 -0
- package/config.js +81 -0
- package/defaults.json +2 -0
- package/geometry.d.ts +8 -0
- package/{geometry.ts → geometry.js} +63 -66
- package/index.d.ts +3 -0
- package/{index.ts → index.js} +38 -36
- package/package.json +10 -3
- package/scad.d.ts +6 -0
- package/{scad.ts → scad.js} +21 -23
- package/text.d.ts +11 -0
- package/{text.ts → text.js} +62 -46
- package/threemf.d.ts +7 -0
- package/{threemf.ts → threemf.js} +56 -33
- package/config.ts +0 -171
- package/tsconfig.json +0 -20
package/README.md
CHANGED
|
@@ -54,9 +54,13 @@ Lengths are written **CSS-style with a unit suffix**: `"0.375in"`, `"5mm"`,
|
|
|
54
54
|
- `gangs[].type`: `"decora"` | `"duplex"` | `"blank"`.
|
|
55
55
|
- `gangs[].screws`: `false` to omit that gang's screw holes (default true).
|
|
56
56
|
- `gangs[].legend`: single label centred below the gang.
|
|
57
|
+
- `gangs[].breaker`: per-gang breaker/circuit label, centred just below that
|
|
58
|
+
gang's legend (size/position from `gangBreakerSize` / `gangBreakerY`).
|
|
57
59
|
- `breaker`: `{ "switch": "A8", "size": "0.375in", "inset": "0.25in" }` — the
|
|
58
|
-
|
|
59
|
-
from `defaults.json`, so a config usually sets only
|
|
60
|
+
panel-wide breaker label in the lower-right corner. `switch` is the text;
|
|
61
|
+
`size`/`inset` come from `defaults.json`, so a config usually sets only
|
|
62
|
+
`breaker.switch`. Per-gang (`gangs[].breaker`) and panel-wide (`breaker`)
|
|
63
|
+
labels are independent — use either or both.
|
|
60
64
|
|
|
61
65
|
### Stacked rocker labels (Leviton 1755 triple rocker, etc.)
|
|
62
66
|
|
package/config.d.ts
ADDED
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
export declare const IN = 25.4;
|
|
2
|
+
export type GangType = "blank" | "decora" | "duplex";
|
|
3
|
+
export type Engine = "manifold" | "scad";
|
|
4
|
+
/** A length: CSS-style unit string ("0.375in" / "5mm") or a bare number (mm). */
|
|
5
|
+
export type Len = string | number;
|
|
6
|
+
/** Parse a length to millimetres. */
|
|
7
|
+
export declare function mm(v: Len): number;
|
|
8
|
+
export interface RockerCfg {
|
|
9
|
+
left?: string;
|
|
10
|
+
right?: string;
|
|
11
|
+
}
|
|
12
|
+
export interface GangCfg {
|
|
13
|
+
type?: GangType;
|
|
14
|
+
opening?: boolean;
|
|
15
|
+
legend?: string;
|
|
16
|
+
filler?: boolean;
|
|
17
|
+
screws?: boolean;
|
|
18
|
+
rockers?: RockerCfg[];
|
|
19
|
+
header?: string;
|
|
20
|
+
breaker?: string;
|
|
21
|
+
}
|
|
22
|
+
export interface BreakerCfg {
|
|
23
|
+
switch?: string;
|
|
24
|
+
size?: Len;
|
|
25
|
+
inset?: Len;
|
|
26
|
+
}
|
|
27
|
+
export interface DimsCfg {
|
|
28
|
+
gangPitch?: Len;
|
|
29
|
+
oneGangWidth?: Len;
|
|
30
|
+
height?: Len;
|
|
31
|
+
depth?: Len;
|
|
32
|
+
faceT?: Len;
|
|
33
|
+
rimW?: Len;
|
|
34
|
+
cornerR?: Len;
|
|
35
|
+
openW?: Len;
|
|
36
|
+
openH?: Len;
|
|
37
|
+
openR?: Len;
|
|
38
|
+
duplexW?: Len;
|
|
39
|
+
duplexH?: Len;
|
|
40
|
+
duplexSpacing?: Len;
|
|
41
|
+
screwSpacing?: Len;
|
|
42
|
+
blankScrewSpacing?: Len;
|
|
43
|
+
screwClearD?: Len;
|
|
44
|
+
screwHeadD?: Len;
|
|
45
|
+
cskAngle?: number;
|
|
46
|
+
}
|
|
47
|
+
export interface Config {
|
|
48
|
+
name?: string;
|
|
49
|
+
gangs: GangCfg[];
|
|
50
|
+
breaker?: BreakerCfg;
|
|
51
|
+
legendSize?: Len;
|
|
52
|
+
legendY?: Len;
|
|
53
|
+
gangBreakerSize?: Len;
|
|
54
|
+
gangBreakerY?: Len;
|
|
55
|
+
rockerSize?: Len;
|
|
56
|
+
rockerGap?: Len;
|
|
57
|
+
headerSize?: Len;
|
|
58
|
+
headerGap?: Len;
|
|
59
|
+
bevel?: Len;
|
|
60
|
+
labelDepth?: Len;
|
|
61
|
+
blankScrews?: boolean;
|
|
62
|
+
font?: string;
|
|
63
|
+
fontFile?: string;
|
|
64
|
+
plateColor?: string;
|
|
65
|
+
accentColor?: string;
|
|
66
|
+
printReady?: boolean;
|
|
67
|
+
dims?: DimsCfg;
|
|
68
|
+
output?: "scad" | "stl" | "3mf";
|
|
69
|
+
engine?: Engine;
|
|
70
|
+
openscad?: string;
|
|
71
|
+
}
|
|
72
|
+
/** Resolved breaker: lengths reduced to millimetres. */
|
|
73
|
+
export interface Breaker {
|
|
74
|
+
switch: string;
|
|
75
|
+
size: number;
|
|
76
|
+
inset: number;
|
|
77
|
+
}
|
|
78
|
+
/** Resolved dimensions, all in millimetres (cskAngle in degrees). */
|
|
79
|
+
export interface Dims {
|
|
80
|
+
gangPitch: number;
|
|
81
|
+
oneGangWidth: number;
|
|
82
|
+
height: number;
|
|
83
|
+
depth: number;
|
|
84
|
+
faceT: number;
|
|
85
|
+
rimW: number;
|
|
86
|
+
cornerR: number;
|
|
87
|
+
openW: number;
|
|
88
|
+
openH: number;
|
|
89
|
+
openR: number;
|
|
90
|
+
duplexW: number;
|
|
91
|
+
duplexH: number;
|
|
92
|
+
duplexSpacing: number;
|
|
93
|
+
screwSpacing: number;
|
|
94
|
+
blankScrewSpacing: number;
|
|
95
|
+
screwClearD: number;
|
|
96
|
+
screwHeadD: number;
|
|
97
|
+
cskAngle: number;
|
|
98
|
+
}
|
|
99
|
+
/** Fully resolved config: every length reduced to millimetres. */
|
|
100
|
+
export interface Resolved {
|
|
101
|
+
name: string;
|
|
102
|
+
gangs: GangCfg[];
|
|
103
|
+
breaker: Breaker;
|
|
104
|
+
legendSize: number;
|
|
105
|
+
legendY: number;
|
|
106
|
+
gangBreakerSize: number;
|
|
107
|
+
gangBreakerY: number;
|
|
108
|
+
rockerSize: number;
|
|
109
|
+
rockerGap: number;
|
|
110
|
+
headerSize: number;
|
|
111
|
+
headerGap: number;
|
|
112
|
+
bevel: number;
|
|
113
|
+
labelDepth: number;
|
|
114
|
+
blankScrews: boolean;
|
|
115
|
+
font: string;
|
|
116
|
+
fontFile: string;
|
|
117
|
+
plateColor: string;
|
|
118
|
+
accentColor: string;
|
|
119
|
+
printReady: boolean;
|
|
120
|
+
dims: Dims;
|
|
121
|
+
output: "scad" | "stl" | "3mf";
|
|
122
|
+
engine: Engine;
|
|
123
|
+
openscad: string;
|
|
124
|
+
}
|
|
125
|
+
export declare function loadConfig(path: string): Resolved;
|
|
126
|
+
export declare function gangType(g: GangCfg): GangType;
|
|
127
|
+
//# sourceMappingURL=config.d.ts.map
|
package/config.js
ADDED
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* config.ts — config schema, defaults, and loading.
|
|
3
|
+
*
|
|
4
|
+
* A specific config (e.g. KitchenBack.json) is deep-merged over defaults.json
|
|
5
|
+
* (in this directory), so a specific file need only carry what differs — often
|
|
6
|
+
* just `name`, `gangs`, and a `breaker.switch`.
|
|
7
|
+
*
|
|
8
|
+
* Lengths are written CSS-style with a unit suffix: "0.375in", "5mm", "-42mm".
|
|
9
|
+
* A bare number is treated as millimetres. Angles are plain numbers (degrees).
|
|
10
|
+
*/
|
|
11
|
+
import { readFileSync } from "fs";
|
|
12
|
+
import { join } from "path";
|
|
13
|
+
export const IN = 25.4;
|
|
14
|
+
/** Parse a length to millimetres. */
|
|
15
|
+
export function mm(v) {
|
|
16
|
+
if (typeof v === "number")
|
|
17
|
+
return v;
|
|
18
|
+
const m = /^\s*(-?[\d.]+)\s*(mm|in)?\s*$/i.exec(v);
|
|
19
|
+
if (!m)
|
|
20
|
+
throw new Error(`invalid length "${v}" (use e.g. "0.375in" or "5mm")`);
|
|
21
|
+
const n = parseFloat(m[1]);
|
|
22
|
+
return m[2] && m[2].toLowerCase() === "in" ? n * IN : n;
|
|
23
|
+
}
|
|
24
|
+
// JSON.parse returns dynamic shape; merged then validated below.
|
|
25
|
+
function readJson(path) {
|
|
26
|
+
return JSON.parse(readFileSync(path, "utf8"));
|
|
27
|
+
}
|
|
28
|
+
/** Deep-merge plain objects; arrays and scalars from `over` replace `base`. */
|
|
29
|
+
function deepMerge(base, over) {
|
|
30
|
+
if (over === null || typeof over !== "object" || Array.isArray(over))
|
|
31
|
+
return over;
|
|
32
|
+
const isObj = (v) => v !== null && typeof v === "object" && !Array.isArray(v);
|
|
33
|
+
const out = { ...base };
|
|
34
|
+
for (const k of Object.keys(over))
|
|
35
|
+
out[k] = isObj(base?.[k]) && isObj(over[k]) ? deepMerge(base[k], over[k]) : over[k];
|
|
36
|
+
return out;
|
|
37
|
+
}
|
|
38
|
+
export function loadConfig(path) {
|
|
39
|
+
const defaults = readJson(join(import.meta.dirname, "defaults.json"));
|
|
40
|
+
const raw = readJson(path);
|
|
41
|
+
if (raw.code !== undefined) // renamed 2026-06-08; don't silently ignore old configs
|
|
42
|
+
console.warn(" ! config field 'code' is now 'breaker': { switch }. The 'code' value is ignored.");
|
|
43
|
+
// every default lives in defaults.json; the merge guarantees presence
|
|
44
|
+
const c = deepMerge(defaults, raw);
|
|
45
|
+
if (!c.gangs || c.gangs.length === 0)
|
|
46
|
+
throw new Error("config needs a non-empty 'gangs' array");
|
|
47
|
+
const d = c.dims;
|
|
48
|
+
const dims = {
|
|
49
|
+
gangPitch: mm(d.gangPitch), oneGangWidth: mm(d.oneGangWidth), height: mm(d.height), depth: mm(d.depth),
|
|
50
|
+
faceT: mm(d.faceT), rimW: mm(d.rimW), cornerR: mm(d.cornerR),
|
|
51
|
+
openW: mm(d.openW), openH: mm(d.openH), openR: mm(d.openR),
|
|
52
|
+
duplexW: mm(d.duplexW), duplexH: mm(d.duplexH), duplexSpacing: mm(d.duplexSpacing),
|
|
53
|
+
screwSpacing: mm(d.screwSpacing), blankScrewSpacing: mm(d.blankScrewSpacing),
|
|
54
|
+
screwClearD: mm(d.screwClearD), screwHeadD: mm(d.screwHeadD), cskAngle: d.cskAngle,
|
|
55
|
+
};
|
|
56
|
+
const b = c.breaker;
|
|
57
|
+
return {
|
|
58
|
+
name: c.name,
|
|
59
|
+
gangs: c.gangs,
|
|
60
|
+
breaker: { switch: b.switch ?? "", size: mm(b.size), inset: mm(b.inset) },
|
|
61
|
+
legendSize: mm(c.legendSize), legendY: mm(c.legendY),
|
|
62
|
+
gangBreakerSize: mm(c.gangBreakerSize), gangBreakerY: mm(c.gangBreakerY),
|
|
63
|
+
rockerSize: mm(c.rockerSize), rockerGap: mm(c.rockerGap),
|
|
64
|
+
headerSize: mm(c.headerSize), headerGap: mm(c.headerGap),
|
|
65
|
+
bevel: mm(c.bevel), labelDepth: mm(c.labelDepth),
|
|
66
|
+
blankScrews: c.blankScrews,
|
|
67
|
+
font: c.font, fontFile: c.fontFile,
|
|
68
|
+
plateColor: c.plateColor, accentColor: c.accentColor,
|
|
69
|
+
printReady: c.printReady,
|
|
70
|
+
dims,
|
|
71
|
+
output: c.output, engine: c.engine, openscad: c.openscad,
|
|
72
|
+
};
|
|
73
|
+
}
|
|
74
|
+
export function gangType(g) {
|
|
75
|
+
if (g.type)
|
|
76
|
+
return g.type;
|
|
77
|
+
if (g.opening === true)
|
|
78
|
+
return "decora";
|
|
79
|
+
return "blank";
|
|
80
|
+
}
|
|
81
|
+
//# sourceMappingURL=config.js.map
|
package/defaults.json
CHANGED
package/geometry.d.ts
ADDED
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import { type Resolved } from "./config.js";
|
|
2
|
+
import type { Mesh } from "./threemf.js";
|
|
3
|
+
export interface LabeledMesh {
|
|
4
|
+
label: string;
|
|
5
|
+
mesh: Mesh;
|
|
6
|
+
}
|
|
7
|
+
export declare function buildMeshes(cfg: Resolved): Promise<LabeledMesh[]>;
|
|
8
|
+
//# sourceMappingURL=geometry.d.ts.map
|
|
@@ -5,17 +5,12 @@
|
|
|
5
5
|
* 3D). Produces one Mesh per colour part (plate / legend / code), already
|
|
6
6
|
* print-oriented, ready for build3mf() or writeBinaryStl().
|
|
7
7
|
*/
|
|
8
|
-
|
|
9
8
|
import Module from "manifold-3d";
|
|
10
|
-
import
|
|
11
|
-
import {
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
const SEG = 96; // circular segments (matches the SCAD $fn = 96)
|
|
16
|
-
|
|
17
|
-
let wasm: ManifoldToplevel | null = null;
|
|
18
|
-
async function getWasm(): Promise<ManifoldToplevel> {
|
|
9
|
+
import { gangType } from "./config.js";
|
|
10
|
+
import { loadFont, textToContours } from "./text.js";
|
|
11
|
+
const SEG = 96; // circular segments (matches the SCAD $fn = 96)
|
|
12
|
+
let wasm = null;
|
|
13
|
+
async function getWasm() {
|
|
19
14
|
if (!wasm) {
|
|
20
15
|
wasm = await Module();
|
|
21
16
|
wasm.setup();
|
|
@@ -23,40 +18,34 @@ async function getWasm(): Promise<ManifoldToplevel> {
|
|
|
23
18
|
}
|
|
24
19
|
return wasm;
|
|
25
20
|
}
|
|
26
|
-
|
|
27
|
-
export interface LabeledMesh { label: string; mesh: Mesh; }
|
|
28
|
-
|
|
29
|
-
function toMesh(m: Manifold): Mesh {
|
|
21
|
+
function toMesh(m) {
|
|
30
22
|
const g = m.getMesh();
|
|
31
23
|
const np = g.numProp;
|
|
32
24
|
const vp = g.vertProperties;
|
|
33
25
|
const nv = vp.length / np;
|
|
34
|
-
const verts
|
|
35
|
-
for (let i = 0; i < nv; i++)
|
|
26
|
+
const verts = new Array(nv);
|
|
27
|
+
for (let i = 0; i < nv; i++)
|
|
28
|
+
verts[i] = [vp[i * np], vp[i * np + 1], vp[i * np + 2]];
|
|
36
29
|
const tv = g.triVerts;
|
|
37
30
|
const nt = tv.length / 3;
|
|
38
|
-
const tris
|
|
39
|
-
for (let t = 0; t < nt; t++)
|
|
31
|
+
const tris = new Array(nt);
|
|
32
|
+
for (let t = 0; t < nt; t++)
|
|
33
|
+
tris[t] = [tv[t * 3], tv[t * 3 + 1], tv[t * 3 + 2]];
|
|
40
34
|
return { verts, tris };
|
|
41
35
|
}
|
|
42
|
-
|
|
43
|
-
export async function buildMeshes(cfg: Resolved): Promise<LabeledMesh[]> {
|
|
36
|
+
export async function buildMeshes(cfg) {
|
|
44
37
|
const w = await getWasm();
|
|
45
38
|
const { Manifold, CrossSection } = w;
|
|
46
39
|
const d = cfg.dims;
|
|
47
|
-
|
|
48
40
|
const CSK_DEPTH = (d.screwHeadD - d.screwClearD) / 2 / Math.tan((d.cskAngle / 2) * Math.PI / 180);
|
|
49
41
|
const N = cfg.gangs.length;
|
|
50
42
|
const PLATE_W = d.oneGangWidth + (N - 1) * d.gangPitch;
|
|
51
43
|
const PLATE_H = d.height;
|
|
52
44
|
const DEPTH = d.depth, FACE_T = d.faceT, RIM_W = d.rimW, CORNER_R = d.cornerR, BEVEL = cfg.bevel;
|
|
53
45
|
const LABEL_DEPTH = cfg.labelDepth;
|
|
54
|
-
const gangX = (i
|
|
55
|
-
|
|
46
|
+
const gangX = (i) => (i - (N - 1) / 2) * d.gangPitch;
|
|
56
47
|
// rounded rectangle: square inset by r, inflated by r with round corners
|
|
57
|
-
const rrect = (wd
|
|
58
|
-
CrossSection.square([wd - 2 * r, ht - 2 * r], true).offset(r, "Round", 2, SEG);
|
|
59
|
-
|
|
48
|
+
const rrect = (wd, ht, r) => CrossSection.square([wd - 2 * r, ht - 2 * r], true).offset(r, "Round", 2, SEG);
|
|
60
49
|
// ----- plate body: rim frame + face slab + front bevel -----
|
|
61
50
|
const outline = () => rrect(PLATE_W, PLATE_H, CORNER_R);
|
|
62
51
|
const frame = rrect(PLATE_W, PLATE_H, CORNER_R)
|
|
@@ -66,96 +55,104 @@ export async function buildMeshes(cfg: Resolved): Promise<LabeledMesh[]> {
|
|
|
66
55
|
if (BEVEL > 0) {
|
|
67
56
|
// SCAD does a hull(offset(-BEVEL)); a top-scale extrude is the close,
|
|
68
57
|
// robust manifold equivalent for a thin perimeter chamfer.
|
|
69
|
-
const topScale
|
|
58
|
+
const topScale = [(PLATE_W - 2 * BEVEL) / PLATE_W, (PLATE_H - 2 * BEVEL) / PLATE_H];
|
|
70
59
|
body = body.add(outline().extrude(BEVEL, 0, 0, topScale).translate([0, 0, DEPTH - BEVEL]));
|
|
71
60
|
}
|
|
72
|
-
|
|
73
61
|
// ----- cutters: openings + screw holes -----
|
|
74
|
-
const cutters
|
|
75
|
-
const screw = (x
|
|
62
|
+
const cutters = [];
|
|
63
|
+
const screw = (x, y) => {
|
|
76
64
|
const shaft = Manifold.cylinder(DEPTH + 2, d.screwClearD / 2, d.screwClearD / 2, SEG).translate([x, y, -1]);
|
|
77
65
|
const csk = Manifold.cylinder(CSK_DEPTH + 0.01, d.screwClearD / 2, d.screwHeadD / 2, SEG).translate([x, y, DEPTH - CSK_DEPTH]);
|
|
78
66
|
return shaft.add(csk);
|
|
79
67
|
};
|
|
80
|
-
const screwsPair = (x
|
|
81
|
-
|
|
68
|
+
const screwsPair = (x, sp) => { cutters.push(screw(x, sp / 2), screw(x, -sp / 2)); };
|
|
82
69
|
for (let i = 0; i < N; i++) {
|
|
83
70
|
const x = gangX(i);
|
|
84
71
|
const g = cfg.gangs[i];
|
|
85
72
|
const t = gangType(g);
|
|
86
|
-
const sc = g.screws !== false;
|
|
73
|
+
const sc = g.screws !== false; // place screws on this gang?
|
|
87
74
|
if (t === "decora") {
|
|
88
75
|
cutters.push(rrect(d.openW, d.openH, d.openR).extrude(DEPTH + 2).translate([x, 0, -1]));
|
|
89
|
-
if (sc)
|
|
90
|
-
|
|
76
|
+
if (sc)
|
|
77
|
+
screwsPair(x, d.screwSpacing);
|
|
78
|
+
}
|
|
79
|
+
else if (t === "duplex") {
|
|
91
80
|
const cs = CrossSection.circle(d.duplexW / 2, SEG).intersect(CrossSection.square([d.duplexW, d.duplexH], true));
|
|
92
|
-
for (const s of [-1, 1])
|
|
93
|
-
|
|
94
|
-
|
|
81
|
+
for (const s of [-1, 1])
|
|
82
|
+
cutters.push(cs.extrude(DEPTH + 2).translate([x, s * d.duplexSpacing / 2, -1]));
|
|
83
|
+
if (sc)
|
|
84
|
+
cutters.push(screw(x, 0)); // single centre screw
|
|
85
|
+
}
|
|
86
|
+
else if (cfg.blankScrews && sc) { // blank: box-ear line, or Decora line if a filler plate
|
|
95
87
|
screwsPair(x, g.filler ? d.screwSpacing : d.blankScrewSpacing);
|
|
96
88
|
}
|
|
97
89
|
}
|
|
98
|
-
|
|
99
|
-
// ----- labels: collect every accent-colour text placement -----
|
|
100
|
-
// gang legend (below), per-gang header (above), and per-rocker side labels
|
|
101
|
-
// all share the accent colour, so they fold into one "legend" object.
|
|
102
|
-
interface Place { txt: string; size: number; halign: HAlign; x: number; y: number; }
|
|
103
90
|
const openHalf = d.openW / 2, openTop = d.openH / 2;
|
|
104
|
-
const accent
|
|
91
|
+
const accent = [];
|
|
105
92
|
for (let i = 0; i < N; i++) {
|
|
106
93
|
const g = cfg.gangs[i];
|
|
107
94
|
const gx = gangX(i);
|
|
108
95
|
if ((g.legend ?? "") !== "")
|
|
109
96
|
accent.push({ txt: g.legend, size: cfg.legendSize, halign: "center", x: gx, y: cfg.legendY });
|
|
97
|
+
if ((g.breaker ?? "") !== "") // per-gang breaker, below the legend
|
|
98
|
+
accent.push({ txt: g.breaker, size: cfg.gangBreakerSize, halign: "center", x: gx, y: cfg.gangBreakerY });
|
|
110
99
|
if ((g.header ?? "") !== "")
|
|
111
100
|
accent.push({ txt: g.header, size: cfg.headerSize, halign: "center", x: gx, y: openTop + cfg.headerGap });
|
|
112
101
|
const rk = g.rockers ?? [];
|
|
113
102
|
for (let j = 0; j < rk.length; j++) {
|
|
114
|
-
const yr = openTop - (j + 0.5) * (d.openH / rk.length);
|
|
115
|
-
if ((rk[j].left ?? "") !== "")
|
|
103
|
+
const yr = openTop - (j + 0.5) * (d.openH / rk.length); // rockers top -> bottom
|
|
104
|
+
if ((rk[j].left ?? "") !== "") // right-aligned, snug to opening
|
|
116
105
|
accent.push({ txt: rk[j].left, size: cfg.rockerSize, halign: "right", x: gx - openHalf - cfg.rockerGap, y: yr });
|
|
117
|
-
if ((rk[j].right ?? "") !== "")
|
|
106
|
+
if ((rk[j].right ?? "") !== "") // left-aligned, snug to opening
|
|
118
107
|
accent.push({ txt: rk[j].right, size: cfg.rockerSize, halign: "left", x: gx + openHalf + cfg.rockerGap, y: yr });
|
|
119
108
|
}
|
|
120
109
|
}
|
|
121
110
|
const hasAccent = accent.length > 0;
|
|
122
111
|
const hasBreaker = cfg.breaker.switch !== "";
|
|
123
112
|
const font = (hasAccent || hasBreaker) ? loadFont(cfg.fontFile) : null;
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
113
|
+
const textManifold = (txt, size, halign, height) => {
|
|
114
|
+
const contours = textToContours(font, txt, size, halign);
|
|
115
|
+
if (contours.length === 0)
|
|
116
|
+
return null;
|
|
128
117
|
return new CrossSection(contours, "NonZero").extrude(height);
|
|
129
118
|
};
|
|
130
|
-
const accentManifold = (height
|
|
131
|
-
let acc
|
|
119
|
+
const accentManifold = (height) => {
|
|
120
|
+
let acc = null;
|
|
132
121
|
for (const p of accent) {
|
|
133
122
|
const m = textManifold(p.txt, p.size, p.halign, height);
|
|
134
|
-
if (!m)
|
|
123
|
+
if (!m)
|
|
124
|
+
continue;
|
|
135
125
|
const placed = m.translate([p.x, p.y, DEPTH - LABEL_DEPTH]);
|
|
136
126
|
acc = acc ? acc.add(placed) : placed;
|
|
137
127
|
}
|
|
138
128
|
return acc;
|
|
139
129
|
};
|
|
140
|
-
const breakerManifold = (height
|
|
130
|
+
const breakerManifold = (height) => {
|
|
141
131
|
const m = textManifold(cfg.breaker.switch, cfg.breaker.size, "right", height);
|
|
142
|
-
if (!m)
|
|
132
|
+
if (!m)
|
|
133
|
+
return null;
|
|
143
134
|
return m.translate([PLATE_W / 2 - cfg.breaker.inset, -PLATE_H / 2 + cfg.breaker.inset, DEPTH - LABEL_DEPTH]);
|
|
144
135
|
};
|
|
145
|
-
|
|
146
136
|
// ----- subtract cutters + label pockets from the plate -----
|
|
147
|
-
let cutAll
|
|
137
|
+
let cutAll = cutters.length ? Manifold.union(cutters) : null;
|
|
148
138
|
for (const p of [accentManifold(LABEL_DEPTH + 0.02), breakerManifold(LABEL_DEPTH + 0.02)]) {
|
|
149
|
-
if (p)
|
|
139
|
+
if (p)
|
|
140
|
+
cutAll = cutAll ? cutAll.add(p) : p;
|
|
150
141
|
}
|
|
151
142
|
const plate = cutAll ? body.subtract(cutAll) : body;
|
|
152
|
-
|
|
153
143
|
// print-orientation: flip front-face-down (rotation, not mirror) so left stays left
|
|
154
|
-
const orient = (m
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
144
|
+
const orient = (m) => cfg.printReady ? m.rotate([180, 0, 0]).translate([0, 0, DEPTH]) : m;
|
|
145
|
+
const out = [{ label: "plate", mesh: toMesh(orient(plate)) }];
|
|
146
|
+
if (hasAccent) {
|
|
147
|
+
const m = accentManifold(LABEL_DEPTH);
|
|
148
|
+
if (m)
|
|
149
|
+
out.push({ label: "legend", mesh: toMesh(orient(m)) });
|
|
150
|
+
}
|
|
151
|
+
if (hasBreaker) {
|
|
152
|
+
const m = breakerManifold(LABEL_DEPTH);
|
|
153
|
+
if (m)
|
|
154
|
+
out.push({ label: "breaker", mesh: toMesh(orient(m)) });
|
|
155
|
+
}
|
|
160
156
|
return out;
|
|
161
157
|
}
|
|
158
|
+
//# sourceMappingURL=geometry.js.map
|
package/index.d.ts
ADDED
package/{index.ts → index.js}
RENAMED
|
@@ -12,68 +12,69 @@
|
|
|
12
12
|
* node index.ts ../KitchenBack.json
|
|
13
13
|
* node index.ts ../KitchenBack.json -scad
|
|
14
14
|
*/
|
|
15
|
-
|
|
16
15
|
import { writeFileSync, unlinkSync } from "fs";
|
|
17
16
|
import { basename, dirname, join } from "path";
|
|
18
|
-
import { loadConfig
|
|
19
|
-
import { generateScad, runOpenscad, readStl } from "./scad.
|
|
20
|
-
import { buildMeshes } from "./geometry.
|
|
21
|
-
import { build3mf, writeBinaryStl
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
if (cfg.breaker.switch !== "") parts.push({ mode: "breaker", label: "breaker" });
|
|
17
|
+
import { loadConfig } from "./config.js";
|
|
18
|
+
import { generateScad, runOpenscad, readStl } from "./scad.js";
|
|
19
|
+
import { buildMeshes } from "./geometry.js";
|
|
20
|
+
import { build3mf, writeBinaryStl } from "./threemf.js";
|
|
21
|
+
function partList(cfg) {
|
|
22
|
+
const parts = [{ mode: "plate", label: "plate" }];
|
|
23
|
+
if (cfg.gangs.some((g) => (g.legend ?? "") !== ""))
|
|
24
|
+
parts.push({ mode: "legend", label: "legend" });
|
|
25
|
+
if (cfg.breaker.switch !== "")
|
|
26
|
+
parts.push({ mode: "breaker", label: "breaker" });
|
|
29
27
|
return parts;
|
|
30
28
|
}
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
console.warn(" ! rocker/header labels are only rendered by the manifold engine; the -scad engine ignores them.");
|
|
29
|
+
function runScadEngine(cfg, dir) {
|
|
30
|
+
if (cfg.gangs.some((g) => (g.rockers?.length ?? 0) > 0 || (g.header ?? "") !== "" || (g.breaker ?? "") !== ""))
|
|
31
|
+
console.warn(" ! per-gang rocker/header/breaker labels are only rendered by the manifold engine; the -scad engine ignores them.");
|
|
35
32
|
const scadPath = join(dir, `${cfg.name}.scad`);
|
|
36
33
|
writeFileSync(scadPath, generateScad(cfg));
|
|
37
34
|
console.log("wrote", basename(scadPath));
|
|
38
|
-
if (cfg.output === "scad")
|
|
39
|
-
|
|
35
|
+
if (cfg.output === "scad")
|
|
36
|
+
return;
|
|
40
37
|
const parts = partList(cfg);
|
|
41
38
|
if (cfg.output === "stl") {
|
|
42
|
-
for (const p of parts)
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
39
|
+
for (const p of parts)
|
|
40
|
+
runOpenscad(cfg.openscad, scadPath, join(dir, `${cfg.name}_${p.label}.stl`), p.mode);
|
|
41
|
+
}
|
|
42
|
+
else {
|
|
43
|
+
const tmp = [];
|
|
44
|
+
const meshes = parts.map((p) => {
|
|
46
45
|
const stl = join(dir, `_${cfg.name}_${p.label}.stl`);
|
|
47
46
|
runOpenscad(cfg.openscad, scadPath, stl, p.mode);
|
|
48
47
|
tmp.push(stl);
|
|
49
48
|
return readStl(stl);
|
|
50
49
|
});
|
|
51
50
|
writeFileSync(join(dir, `${cfg.name}.3mf`), build3mf(meshes));
|
|
52
|
-
for (const t of tmp)
|
|
51
|
+
for (const t of tmp)
|
|
52
|
+
unlinkSync(t);
|
|
53
53
|
console.log(" wrote", `${cfg.name}.3mf`, `(${meshes.length} objects: ${parts.map((p) => p.label).join(", ")})`);
|
|
54
54
|
}
|
|
55
55
|
}
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
if (cfg.output === "scad") { // scad output always needs the scad engine
|
|
56
|
+
async function runManifoldEngine(cfg, dir) {
|
|
57
|
+
if (cfg.output === "scad") { // scad output always needs the scad engine
|
|
59
58
|
runScadEngine(cfg, dir);
|
|
60
59
|
return;
|
|
61
60
|
}
|
|
62
61
|
const meshes = await buildMeshes(cfg);
|
|
63
|
-
for (const m of meshes)
|
|
62
|
+
for (const m of meshes)
|
|
63
|
+
console.log(" built", m.label, `(${m.mesh.tris.length} tris)`);
|
|
64
64
|
if (cfg.output === "stl") {
|
|
65
|
-
for (const m of meshes)
|
|
66
|
-
|
|
65
|
+
for (const m of meshes)
|
|
66
|
+
writeFileSync(join(dir, `${cfg.name}_${m.label}.stl`), writeBinaryStl(m.mesh));
|
|
67
|
+
}
|
|
68
|
+
else {
|
|
67
69
|
writeFileSync(join(dir, `${cfg.name}.3mf`), build3mf(meshes.map((m) => m.mesh)));
|
|
68
70
|
console.log(" wrote", `${cfg.name}.3mf`, `(${meshes.length} objects: ${meshes.map((m) => m.label).join(", ")})`);
|
|
69
71
|
}
|
|
70
72
|
}
|
|
71
|
-
|
|
72
|
-
async function main(): Promise<void> {
|
|
73
|
+
async function main() {
|
|
73
74
|
const args = process.argv.slice(2);
|
|
74
75
|
const flags = args.filter((a) => a.startsWith("-"));
|
|
75
76
|
const positional = args.filter((a) => !a.startsWith("-"));
|
|
76
|
-
const isScad = (f
|
|
77
|
+
const isScad = (f) => f.toLowerCase() === "-scad" || f.toLowerCase() === "--scad";
|
|
77
78
|
const unknown = flags.filter((f) => !isScad(f));
|
|
78
79
|
if (unknown.length) {
|
|
79
80
|
console.error(`unknown option(s): ${unknown.join(" ")}\nusage: node index.ts <config.json> [-scad]`);
|
|
@@ -85,10 +86,11 @@ async function main(): Promise<void> {
|
|
|
85
86
|
const dir = dirname(cfgPath);
|
|
86
87
|
const engine = useScad || cfg.engine === "scad" ? "scad" : "manifold";
|
|
87
88
|
console.log(`engine: ${engine}, output: ${cfg.output}`);
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
else
|
|
89
|
+
if (engine === "scad")
|
|
90
|
+
runScadEngine(cfg, dir);
|
|
91
|
+
else
|
|
92
|
+
await runManifoldEngine(cfg, dir);
|
|
91
93
|
console.log("done");
|
|
92
94
|
}
|
|
93
|
-
|
|
94
95
|
main().catch((e) => { console.error(e); process.exit(1); });
|
|
96
|
+
//# sourceMappingURL=index.js.map
|
package/package.json
CHANGED
|
@@ -1,16 +1,20 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@bobfrankston/wallplater",
|
|
3
|
-
"version": "1.0.
|
|
3
|
+
"version": "1.0.3",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "JSON-driven wall-plate generator (Decora / duplex / blank). Direct-to-3MF via manifold-3d, with an optional OpenSCAD engine.",
|
|
6
|
+
"main": "index.js",
|
|
7
|
+
"types": "index.d.ts",
|
|
6
8
|
"bin": {
|
|
7
|
-
"wallplater": "index.
|
|
9
|
+
"wallplater": "index.js"
|
|
8
10
|
},
|
|
9
11
|
"engines": {
|
|
10
12
|
"node": ">=24"
|
|
11
13
|
},
|
|
12
14
|
"scripts": {
|
|
13
|
-
"
|
|
15
|
+
"build": "tsc",
|
|
16
|
+
"watch": "tsc -w",
|
|
17
|
+
"gen": "node index.js",
|
|
14
18
|
"typecheck": "tsc --noEmit",
|
|
15
19
|
"release": "npmglobalize"
|
|
16
20
|
},
|
|
@@ -25,5 +29,8 @@
|
|
|
25
29
|
"repository": {
|
|
26
30
|
"type": "git",
|
|
27
31
|
"url": "git@github.com:BobFrankston/wallplater.git"
|
|
32
|
+
},
|
|
33
|
+
"publishConfig": {
|
|
34
|
+
"access": "public"
|
|
28
35
|
}
|
|
29
36
|
}
|
package/scad.d.ts
ADDED
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
import { type Resolved } from "./config.js";
|
|
2
|
+
import type { Mesh } from "./threemf.js";
|
|
3
|
+
export declare function generateScad(cfg: Resolved): string;
|
|
4
|
+
export declare function runOpenscad(exe: string, scadPath: string, outPath: string, mode: string): void;
|
|
5
|
+
export declare function readStl(path: string): Mesh;
|
|
6
|
+
//# sourceMappingURL=scad.d.ts.map
|
package/{scad.ts → scad.js}
RENAMED
|
@@ -4,18 +4,14 @@
|
|
|
4
4
|
* one STL per colour part, and reads STLs back into the common Mesh shape.
|
|
5
5
|
* Note: rocker/header labels are manifold-engine only and are not emitted here.
|
|
6
6
|
*/
|
|
7
|
-
|
|
8
7
|
import { readFileSync } from "fs";
|
|
9
8
|
import { execFileSync } from "child_process";
|
|
10
9
|
import { basename } from "path";
|
|
11
|
-
import {
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
function num(n: number): string {
|
|
10
|
+
import { gangType } from "./config.js";
|
|
11
|
+
function num(n) {
|
|
15
12
|
return Number.isInteger(n) ? String(n) : n.toFixed(4).replace(/0+$/, "").replace(/\.$/, "");
|
|
16
13
|
}
|
|
17
|
-
|
|
18
|
-
export function generateScad(cfg: Resolved): string {
|
|
14
|
+
export function generateScad(cfg) {
|
|
19
15
|
const d = cfg.dims;
|
|
20
16
|
const gangs = "[" + cfg.gangs.map((g) => `"${gangType(g)}"`).join(", ") + "]";
|
|
21
17
|
const legend = "[" + cfg.gangs.map((g) => `"${g.legend ?? ""}"`).join(", ") + "]";
|
|
@@ -60,7 +56,6 @@ PRINT_READY = ${cfg.printReady};
|
|
|
60
56
|
`;
|
|
61
57
|
return params + SCAD_BODY;
|
|
62
58
|
}
|
|
63
|
-
|
|
64
59
|
const SCAD_BODY = `
|
|
65
60
|
$fn = 96;
|
|
66
61
|
CSK_DEPTH = (SCREW_HEAD_D - SCREW_CLEAR_D) / 2 / tan(CSK_ANGLE / 2);
|
|
@@ -145,24 +140,25 @@ if (MODE == "preview") {
|
|
|
145
140
|
else if (MODE == "legend") oriented() legend_solids(LABEL_DEPTH);
|
|
146
141
|
else if (MODE == "breaker") oriented() breaker_solid(LABEL_DEPTH);
|
|
147
142
|
`;
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
execFileSync(exe, [`-D`, `MODE="${mode}"`, "--export-format", fmt, "-o", outPath, scadPath],
|
|
152
|
-
{ stdio: ["ignore", "ignore", "inherit"] });
|
|
143
|
+
export function runOpenscad(exe, scadPath, outPath, mode) {
|
|
144
|
+
const fmt = outPath.endsWith(".3mf") ? "3mf" : outPath.endsWith(".png") ? "png" : "binstl";
|
|
145
|
+
execFileSync(exe, [`-D`, `MODE="${mode}"`, "--export-format", fmt, "-o", outPath, scadPath], { stdio: ["ignore", "ignore", "inherit"] });
|
|
153
146
|
console.log(" wrote", basename(outPath));
|
|
154
147
|
}
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
const
|
|
158
|
-
const
|
|
159
|
-
const
|
|
160
|
-
const
|
|
161
|
-
const
|
|
162
|
-
const vid = (x: number, y: number, z: number): number => {
|
|
148
|
+
export function readStl(path) {
|
|
149
|
+
const buf = readFileSync(path);
|
|
150
|
+
const n = buf.readUInt32LE(80);
|
|
151
|
+
const map = new Map();
|
|
152
|
+
const verts = [];
|
|
153
|
+
const tris = [];
|
|
154
|
+
const vid = (x, y, z) => {
|
|
163
155
|
const k = `${x.toFixed(4)},${y.toFixed(4)},${z.toFixed(4)}`;
|
|
164
156
|
let i = map.get(k);
|
|
165
|
-
if (i === undefined) {
|
|
157
|
+
if (i === undefined) {
|
|
158
|
+
i = verts.length;
|
|
159
|
+
verts.push([x, y, z]);
|
|
160
|
+
map.set(k, i);
|
|
161
|
+
}
|
|
166
162
|
return i;
|
|
167
163
|
};
|
|
168
164
|
let o = 84;
|
|
@@ -170,8 +166,10 @@ export function readStl(path: string): Mesh {
|
|
|
170
166
|
const a = vid(buf.readFloatLE(o + 12), buf.readFloatLE(o + 16), buf.readFloatLE(o + 20));
|
|
171
167
|
const b = vid(buf.readFloatLE(o + 24), buf.readFloatLE(o + 28), buf.readFloatLE(o + 32));
|
|
172
168
|
const c = vid(buf.readFloatLE(o + 36), buf.readFloatLE(o + 40), buf.readFloatLE(o + 44));
|
|
173
|
-
if (a !== b && b !== c && a !== c)
|
|
169
|
+
if (a !== b && b !== c && a !== c)
|
|
170
|
+
tris.push([a, b, c]);
|
|
174
171
|
o += 50;
|
|
175
172
|
}
|
|
176
173
|
return { verts, tris };
|
|
177
174
|
}
|
|
175
|
+
//# sourceMappingURL=scad.js.map
|
package/text.d.ts
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import opentype from "opentype.js";
|
|
2
|
+
export type Contours = [number, number][][];
|
|
3
|
+
export type HAlign = "left" | "center" | "right";
|
|
4
|
+
export declare function loadFont(path: string): opentype.Font;
|
|
5
|
+
/**
|
|
6
|
+
* Build aligned, model-space (y-up) filled contours for `text`.
|
|
7
|
+
* halign positions the bounding box horizontally relative to the origin;
|
|
8
|
+
* vertically it is always centred. Returns [] for empty text.
|
|
9
|
+
*/
|
|
10
|
+
export declare function textToContours(font: opentype.Font, text: string, sizeMm: number, halign: HAlign): Contours;
|
|
11
|
+
//# sourceMappingURL=text.d.ts.map
|
package/{text.ts → text.js}
RENAMED
|
@@ -7,25 +7,18 @@
|
|
|
7
7
|
* CrossSection with a NonZero fill rule, which resolves the holes — so no
|
|
8
8
|
* separate triangulation step is needed.
|
|
9
9
|
*/
|
|
10
|
-
|
|
11
10
|
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
|
-
|
|
11
|
+
const CURVE_SEGS = 12; // polyline segments per bezier
|
|
18
12
|
let cachedPath = "";
|
|
19
|
-
let cachedFont
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
13
|
+
let cachedFont = null;
|
|
14
|
+
export function loadFont(path) {
|
|
15
|
+
if (cachedFont && cachedPath === path)
|
|
16
|
+
return cachedFont;
|
|
23
17
|
cachedFont = opentype.loadSync(path);
|
|
24
18
|
cachedPath = path;
|
|
25
19
|
return cachedFont;
|
|
26
20
|
}
|
|
27
|
-
|
|
28
|
-
function quad(p0: [number, number], c: [number, number], p1: [number, number], out: [number, number][]): void {
|
|
21
|
+
function quad(p0, c, p1, out) {
|
|
29
22
|
for (let i = 1; i <= CURVE_SEGS; i++) {
|
|
30
23
|
const t = i / CURVE_SEGS, u = 1 - t;
|
|
31
24
|
out.push([
|
|
@@ -34,8 +27,7 @@ function quad(p0: [number, number], c: [number, number], p1: [number, number], o
|
|
|
34
27
|
]);
|
|
35
28
|
}
|
|
36
29
|
}
|
|
37
|
-
|
|
38
|
-
function cubic(p0: [number, number], c1: [number, number], c2: [number, number], p1: [number, number], out: [number, number][]): void {
|
|
30
|
+
function cubic(p0, c1, c2, p1, out) {
|
|
39
31
|
for (let i = 1; i <= CURVE_SEGS; i++) {
|
|
40
32
|
const t = i / CURVE_SEGS, u = 1 - t;
|
|
41
33
|
out.push([
|
|
@@ -44,54 +36,78 @@ function cubic(p0: [number, number], c1: [number, number], c2: [number, number],
|
|
|
44
36
|
]);
|
|
45
37
|
}
|
|
46
38
|
}
|
|
47
|
-
|
|
48
39
|
/**
|
|
49
40
|
* Build aligned, model-space (y-up) filled contours for `text`.
|
|
50
41
|
* halign positions the bounding box horizontally relative to the origin;
|
|
51
42
|
* vertically it is always centred. Returns [] for empty text.
|
|
52
43
|
*/
|
|
53
|
-
export function textToContours(font
|
|
54
|
-
if (text === "")
|
|
55
|
-
|
|
56
|
-
const
|
|
57
|
-
|
|
58
|
-
let
|
|
59
|
-
|
|
60
|
-
|
|
44
|
+
export function textToContours(font, text, sizeMm, halign) {
|
|
45
|
+
if (text === "")
|
|
46
|
+
return [];
|
|
47
|
+
const path = font.getPath(text, 0, 0, sizeMm); // opentype y points DOWN
|
|
48
|
+
const contours = [];
|
|
49
|
+
let cur = [];
|
|
50
|
+
let px = 0, py = 0; // current point (opentype space)
|
|
51
|
+
const flip = (x, y) => [x, -y]; // -> model y-up
|
|
61
52
|
for (const cmd of path.commands) {
|
|
62
53
|
if (cmd.type === "M") {
|
|
63
|
-
if (cur.length)
|
|
54
|
+
if (cur.length)
|
|
55
|
+
contours.push(cur);
|
|
64
56
|
cur = [flip(cmd.x, cmd.y)];
|
|
65
|
-
px = cmd.x;
|
|
66
|
-
|
|
57
|
+
px = cmd.x;
|
|
58
|
+
py = cmd.y;
|
|
59
|
+
}
|
|
60
|
+
else if (cmd.type === "L") {
|
|
67
61
|
cur.push(flip(cmd.x, cmd.y));
|
|
68
|
-
px = cmd.x;
|
|
69
|
-
|
|
70
|
-
|
|
62
|
+
px = cmd.x;
|
|
63
|
+
py = cmd.y;
|
|
64
|
+
}
|
|
65
|
+
else if (cmd.type === "Q") {
|
|
66
|
+
const pts = [];
|
|
71
67
|
quad([px, py], [cmd.x1, cmd.y1], [cmd.x, cmd.y], pts);
|
|
72
|
-
for (const p of pts)
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
68
|
+
for (const p of pts)
|
|
69
|
+
cur.push(flip(p[0], p[1]));
|
|
70
|
+
px = cmd.x;
|
|
71
|
+
py = cmd.y;
|
|
72
|
+
}
|
|
73
|
+
else if (cmd.type === "C") {
|
|
74
|
+
const pts = [];
|
|
76
75
|
cubic([px, py], [cmd.x1, cmd.y1], [cmd.x2, cmd.y2], [cmd.x, cmd.y], pts);
|
|
77
|
-
for (const p of pts)
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
76
|
+
for (const p of pts)
|
|
77
|
+
cur.push(flip(p[0], p[1]));
|
|
78
|
+
px = cmd.x;
|
|
79
|
+
py = cmd.y;
|
|
80
|
+
}
|
|
81
|
+
else if (cmd.type === "Z") {
|
|
82
|
+
if (cur.length)
|
|
83
|
+
contours.push(cur);
|
|
81
84
|
cur = [];
|
|
82
85
|
}
|
|
83
86
|
}
|
|
84
|
-
if (cur.length)
|
|
85
|
-
|
|
86
|
-
|
|
87
|
+
if (cur.length)
|
|
88
|
+
contours.push(cur);
|
|
89
|
+
if (contours.length === 0)
|
|
90
|
+
return [];
|
|
87
91
|
// bounding box over all (already flipped) points
|
|
88
92
|
let minX = Infinity, maxX = -Infinity, minY = Infinity, maxY = -Infinity;
|
|
89
|
-
for (const c of contours)
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
+
for (const c of contours)
|
|
94
|
+
for (const [x, y] of c) {
|
|
95
|
+
if (x < minX)
|
|
96
|
+
minX = x;
|
|
97
|
+
if (x > maxX)
|
|
98
|
+
maxX = x;
|
|
99
|
+
if (y < minY)
|
|
100
|
+
minY = y;
|
|
101
|
+
if (y > maxY)
|
|
102
|
+
maxY = y;
|
|
103
|
+
}
|
|
93
104
|
const dx = halign === "center" ? -(minX + maxX) / 2 : halign === "right" ? -maxX : -minX;
|
|
94
105
|
const dy = -(minY + maxY) / 2;
|
|
95
|
-
for (const c of contours)
|
|
106
|
+
for (const c of contours)
|
|
107
|
+
for (const p of c) {
|
|
108
|
+
p[0] += dx;
|
|
109
|
+
p[1] += dy;
|
|
110
|
+
}
|
|
96
111
|
return contours;
|
|
97
112
|
}
|
|
113
|
+
//# sourceMappingURL=text.js.map
|
package/threemf.d.ts
ADDED
|
@@ -5,13 +5,9 @@
|
|
|
5
5
|
* individually colourable objects), packed into the OPC zip by hand.
|
|
6
6
|
* - writeBinaryStl(): a single binary STL (used for the manifold STL path).
|
|
7
7
|
*/
|
|
8
|
-
|
|
9
8
|
import { deflateRawSync } from "zlib";
|
|
10
|
-
|
|
11
|
-
export interface Mesh { verts: number[][]; tris: number[][]; }
|
|
12
|
-
|
|
13
9
|
// ---------- multi-object 3MF (core spec, no slicer settings) ----------
|
|
14
|
-
export function build3mf(meshes
|
|
10
|
+
export function build3mf(meshes) {
|
|
15
11
|
// separate top-level objects (each individually colourable in Bambu), all
|
|
16
12
|
// at the same transform so the labels stay seated in the plate's pockets.
|
|
17
13
|
let objs = "";
|
|
@@ -48,9 +44,8 @@ ${items} </build>
|
|
|
48
44
|
{ name: "3D/3dmodel.model", data: Buffer.from(model) },
|
|
49
45
|
]);
|
|
50
46
|
}
|
|
51
|
-
|
|
52
47
|
// ---------- binary STL (single object) ----------
|
|
53
|
-
export function writeBinaryStl(mesh
|
|
48
|
+
export function writeBinaryStl(mesh) {
|
|
54
49
|
const buf = Buffer.alloc(84 + mesh.tris.length * 50);
|
|
55
50
|
buf.writeUInt32LE(mesh.tris.length, 80);
|
|
56
51
|
let o = 84;
|
|
@@ -60,58 +55,86 @@ export function writeBinaryStl(mesh: Mesh): Buffer {
|
|
|
60
55
|
const vx = c[0] - a[0], vy = c[1] - a[1], vz = c[2] - a[2];
|
|
61
56
|
let nx = uy * vz - uz * vy, ny = uz * vx - ux * vz, nz = ux * vy - uy * vx;
|
|
62
57
|
const len = Math.hypot(nx, ny, nz) || 1;
|
|
63
|
-
nx /= len;
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
buf.writeFloatLE(
|
|
67
|
-
buf.writeFloatLE(
|
|
58
|
+
nx /= len;
|
|
59
|
+
ny /= len;
|
|
60
|
+
nz /= len;
|
|
61
|
+
buf.writeFloatLE(nx, o);
|
|
62
|
+
buf.writeFloatLE(ny, o + 4);
|
|
63
|
+
buf.writeFloatLE(nz, o + 8);
|
|
64
|
+
buf.writeFloatLE(a[0], o + 12);
|
|
65
|
+
buf.writeFloatLE(a[1], o + 16);
|
|
66
|
+
buf.writeFloatLE(a[2], o + 20);
|
|
67
|
+
buf.writeFloatLE(b[0], o + 24);
|
|
68
|
+
buf.writeFloatLE(b[1], o + 28);
|
|
69
|
+
buf.writeFloatLE(b[2], o + 32);
|
|
70
|
+
buf.writeFloatLE(c[0], o + 36);
|
|
71
|
+
buf.writeFloatLE(c[1], o + 40);
|
|
72
|
+
buf.writeFloatLE(c[2], o + 44);
|
|
68
73
|
o += 50;
|
|
69
74
|
}
|
|
70
75
|
return buf;
|
|
71
76
|
}
|
|
72
|
-
|
|
73
|
-
const
|
|
74
|
-
const t: number[] = [];
|
|
77
|
+
const CRC_TABLE = (() => {
|
|
78
|
+
const t = [];
|
|
75
79
|
for (let n = 0; n < 256; n++) {
|
|
76
80
|
let c = n;
|
|
77
|
-
for (let k = 0; k < 8; k++)
|
|
81
|
+
for (let k = 0; k < 8; k++)
|
|
82
|
+
c = (c & 1) ? (0xedb88320 ^ (c >>> 1)) : (c >>> 1);
|
|
78
83
|
t[n] = c >>> 0;
|
|
79
84
|
}
|
|
80
85
|
return t;
|
|
81
86
|
})();
|
|
82
|
-
function crc32(buf
|
|
87
|
+
function crc32(buf) {
|
|
83
88
|
let c = 0xffffffff;
|
|
84
|
-
for (let i = 0; i < buf.length; i++)
|
|
89
|
+
for (let i = 0; i < buf.length; i++)
|
|
90
|
+
c = CRC_TABLE[(c ^ buf[i]) & 0xff] ^ (c >>> 8);
|
|
85
91
|
return (c ^ 0xffffffff) >>> 0;
|
|
86
92
|
}
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
const
|
|
90
|
-
const central: Buffer[] = [];
|
|
93
|
+
function zip(files) {
|
|
94
|
+
const parts = [];
|
|
95
|
+
const central = [];
|
|
91
96
|
let offset = 0;
|
|
92
97
|
for (const f of files) {
|
|
93
98
|
const name = Buffer.from(f.name);
|
|
94
99
|
const comp = deflateRawSync(f.data);
|
|
95
100
|
const crc = crc32(f.data);
|
|
96
101
|
const lh = Buffer.alloc(30);
|
|
97
|
-
lh.writeUInt32LE(0x04034b50, 0);
|
|
98
|
-
lh.writeUInt16LE(
|
|
99
|
-
lh.
|
|
100
|
-
lh.writeUInt16LE(
|
|
102
|
+
lh.writeUInt32LE(0x04034b50, 0);
|
|
103
|
+
lh.writeUInt16LE(20, 4);
|
|
104
|
+
lh.writeUInt16LE(0, 6);
|
|
105
|
+
lh.writeUInt16LE(8, 8);
|
|
106
|
+
lh.writeUInt32LE(0, 10);
|
|
107
|
+
lh.writeUInt32LE(crc, 14);
|
|
108
|
+
lh.writeUInt32LE(comp.length, 18);
|
|
109
|
+
lh.writeUInt32LE(f.data.length, 22);
|
|
110
|
+
lh.writeUInt16LE(name.length, 26);
|
|
111
|
+
lh.writeUInt16LE(0, 28);
|
|
101
112
|
parts.push(lh, name, comp);
|
|
102
113
|
const ch = Buffer.alloc(46);
|
|
103
|
-
ch.writeUInt32LE(0x02014b50, 0);
|
|
104
|
-
ch.writeUInt16LE(
|
|
105
|
-
ch.
|
|
106
|
-
ch.writeUInt16LE(
|
|
107
|
-
ch.
|
|
114
|
+
ch.writeUInt32LE(0x02014b50, 0);
|
|
115
|
+
ch.writeUInt16LE(20, 4);
|
|
116
|
+
ch.writeUInt16LE(20, 6);
|
|
117
|
+
ch.writeUInt16LE(0, 8);
|
|
118
|
+
ch.writeUInt16LE(8, 10);
|
|
119
|
+
ch.writeUInt32LE(0, 12);
|
|
120
|
+
ch.writeUInt32LE(crc, 16);
|
|
121
|
+
ch.writeUInt32LE(comp.length, 20);
|
|
122
|
+
ch.writeUInt32LE(f.data.length, 24);
|
|
123
|
+
ch.writeUInt16LE(name.length, 28);
|
|
124
|
+
ch.writeUInt32LE(0, 30);
|
|
125
|
+
ch.writeUInt32LE(0, 34);
|
|
126
|
+
ch.writeUInt32LE(0, 38);
|
|
127
|
+
ch.writeUInt32LE(offset, 42);
|
|
108
128
|
central.push(ch, name);
|
|
109
129
|
offset += lh.length + name.length + comp.length;
|
|
110
130
|
}
|
|
111
131
|
const cd = Buffer.concat(central);
|
|
112
132
|
const eocd = Buffer.alloc(22);
|
|
113
|
-
eocd.writeUInt32LE(0x06054b50, 0);
|
|
114
|
-
eocd.writeUInt16LE(files.length,
|
|
133
|
+
eocd.writeUInt32LE(0x06054b50, 0);
|
|
134
|
+
eocd.writeUInt16LE(files.length, 8);
|
|
135
|
+
eocd.writeUInt16LE(files.length, 10);
|
|
136
|
+
eocd.writeUInt32LE(cd.length, 12);
|
|
115
137
|
eocd.writeUInt32LE(offset, 16);
|
|
116
138
|
return Buffer.concat([...parts, cd, eocd]);
|
|
117
139
|
}
|
|
140
|
+
//# sourceMappingURL=threemf.js.map
|
package/config.ts
DELETED
|
@@ -1,171 +0,0 @@
|
|
|
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/tsconfig.json
DELETED
|
@@ -1,20 +0,0 @@
|
|
|
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
|
-
}
|