@bobfrankston/wallplater 1.0.5 → 1.0.7
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +90 -7
- package/config.d.ts +20 -3
- package/config.js +39 -9
- package/defaults.json +4 -0
- package/geometry.js +21 -1
- package/index.js +20 -7
- package/package.json +2 -1
- package/scad.d.ts +1 -0
- package/scad.js +87 -2
- package/threemf.d.ts +9 -1
- package/threemf.js +137 -2
package/README.md
CHANGED
|
@@ -1,19 +1,24 @@
|
|
|
1
1
|
# @bobfrankston/wallplater
|
|
2
2
|
|
|
3
|
-
JSON-driven wall-plate generator (Decora / duplex / blank gangs). Reads
|
|
4
|
-
JSON config and emits a multi-object 3MF (plate + labels as
|
|
5
|
-
individually colourable objects) or per-part STLs.
|
|
3
|
+
JSON-driven wall-plate generator (Decora / duplex / blank / button gangs). Reads
|
|
4
|
+
a small JSON (JSON5) config and emits a multi-object 3MF (plate + labels as
|
|
5
|
+
separate, individually colourable objects) or per-part STLs.
|
|
6
6
|
|
|
7
7
|
## Engines
|
|
8
8
|
|
|
9
9
|
| Engine | Flag | Needs OpenSCAD? | Speed | Notes |
|
|
10
10
|
|---|---|---|---|---|
|
|
11
11
|
| **manifold** (default) | — | no | ~1 s | Direct geometry via [manifold-3d](https://github.com/elalish/manifold) (Clipper2 + robust CSG). Text via [opentype.js](https://github.com/opentypejs/opentype.js). |
|
|
12
|
-
| **scad** (legacy) | `-scad` or `"engine":"scad"` | yes | ~30 s | Writes a `.scad` and shells out to OpenSCAD. Reference / fallback.
|
|
12
|
+
| **scad** (legacy) | `-scad` or `"engine":"scad"` | yes | ~30 s | Writes a `.scad` and shells out to OpenSCAD. Reference / fallback. Renders all hole geometry (incl. button pips) but **not** rocker/header/per-button labels. |
|
|
13
13
|
|
|
14
14
|
The manifold engine has no system dependency beyond Node and is the path
|
|
15
15
|
forward; `-scad` is kept for parity/verification.
|
|
16
16
|
|
|
17
|
+
If the `-scad` engine can't find OpenSCAD (neither the `openscad` config path nor
|
|
18
|
+
`openscad` on `PATH`), it offers to install it for you — `winget` on Windows,
|
|
19
|
+
`brew --cask` on macOS, `apt-get` on Linux — then re-checks. Decline (or a
|
|
20
|
+
non-interactive stdin) exits with a hint to install manually or drop `-scad`.
|
|
21
|
+
|
|
17
22
|
## Build / install
|
|
18
23
|
|
|
19
24
|
TypeScript compiled to co-located `.js` / `.d.ts` / `.map` (no `src`/`dist`
|
|
@@ -49,6 +54,13 @@ without manual assignment — provided the project has ≥2 filaments. The label
|
|
|
49
54
|
objects sit flush in the plate's front-face pockets (printed front-face-down), so
|
|
50
55
|
they look hidden in the viewport until coloured.
|
|
51
56
|
|
|
57
|
+
The 3MF also embeds a **preview thumbnail** (`Metadata/thumbnail.png`, wired via
|
|
58
|
+
the OPC thumbnail relationship) so Windows Explorer and slicers show an image of
|
|
59
|
+
the plate. It's a pure-JS orthographic render of the front face in the configured
|
|
60
|
+
`plateColor` / `accentColor` — no extra dependency, no STL/mesh rendering by the
|
|
61
|
+
shell. (Without it, the file previews blank: the shell never renders the geometry
|
|
62
|
+
itself, it only displays this embedded PNG.)
|
|
63
|
+
|
|
52
64
|
## Config
|
|
53
65
|
|
|
54
66
|
Each specific config is **deep-merged over `defaults.json`** (in this
|
|
@@ -56,8 +68,21 @@ directory), so a specific file carries only what differs — often just `name`,
|
|
|
56
68
|
`gangs`, and a `breaker.switch`. Every default lives in `defaults.json`, not in
|
|
57
69
|
code.
|
|
58
70
|
|
|
59
|
-
|
|
60
|
-
|
|
71
|
+
Configs are parsed with **JSON5** (the most lenient reader), so comments
|
|
72
|
+
(`//`, `/* */`), trailing commas, and unquoted keys are all allowed — plain JSON
|
|
73
|
+
still works unchanged.
|
|
74
|
+
|
|
75
|
+
Lengths are written **CSS-style with a unit suffix**. Supported units:
|
|
76
|
+
|
|
77
|
+
| Suffix | Meaning | mm |
|
|
78
|
+
|---|---|---|
|
|
79
|
+
| `in` | inch | 25.4 |
|
|
80
|
+
| `ft` | foot | 304.8 |
|
|
81
|
+
| `cm` | centimetre | 10 |
|
|
82
|
+
| `mm` | millimetre | 1 |
|
|
83
|
+
|
|
84
|
+
e.g. `"0.375in"`, `"5mm"`, `"2cm"`, `"0.5ft"`, `"-42mm"`. A bare number (no
|
|
85
|
+
suffix) is treated as millimetres. Angles (e.g. `cskAngle`) are plain numbers.
|
|
61
86
|
|
|
62
87
|
```jsonc
|
|
63
88
|
// KitchenBack.json — only what differs from defaults.json
|
|
@@ -72,8 +97,11 @@ Lengths are written **CSS-style with a unit suffix**: `"0.375in"`, `"5mm"`,
|
|
|
72
97
|
}
|
|
73
98
|
```
|
|
74
99
|
|
|
75
|
-
- `gangs[].type`: `"decora"` | `"duplex"` | `"blank"`.
|
|
100
|
+
- `gangs[].type`: `"decora"` | `"duplex"` | `"blank"` | `"button"`.
|
|
76
101
|
- `gangs[].screws`: `false` to omit that gang's screw holes (default true).
|
|
102
|
+
- `gangs[].filler`: on a **blank** gang, marks it as backed by a filler/insert
|
|
103
|
+
plate, so its cover screws sit on the Decora line (wider spacing) instead of
|
|
104
|
+
the box-ear line.
|
|
77
105
|
- `gangs[].legend`: single label centred above the gang's opening (positioned
|
|
78
106
|
clear of the screw holes; `legendY` / `legendSize`).
|
|
79
107
|
- `gangs[].breaker`: per-gang breaker/circuit label, lower-right of the opening
|
|
@@ -112,10 +140,65 @@ overflow. See `../triple_demo.json` for a worked single-gang example.
|
|
|
112
140
|
> Rocker/header labels are rendered by the **manifold engine only**. The
|
|
113
141
|
> `-scad` engine ignores them (it warns).
|
|
114
142
|
|
|
143
|
+
### Button gangs (round holes in a pip layout)
|
|
144
|
+
|
|
145
|
+
A `"button"` gang punches a set of round button holes (default **0.5 in**
|
|
146
|
+
diameter) arranged symmetrically like the pips on a die / playing card — odd
|
|
147
|
+
counts put a single button in the centre. Set the count with `buttons` (1–9):
|
|
148
|
+
|
|
149
|
+
```jsonc
|
|
150
|
+
{
|
|
151
|
+
"type": "button",
|
|
152
|
+
"legend": "FAN", // the legend "on top" (above the buttons)
|
|
153
|
+
"buttons": 3, // 1-9; pip layout, single = centre
|
|
154
|
+
"buttonD": "0.5in", // optional per-gang diameter (default dims.buttonD)
|
|
155
|
+
"buttonLegends": ["HI", "MED", "LO"] // optional small per-button labels
|
|
156
|
+
}
|
|
157
|
+
```
|
|
158
|
+
|
|
159
|
+
- `buttons`: number of holes (1–9). Setting it implies `type:"button"`. The pip
|
|
160
|
+
pattern is the standard die/domino layout, read **top → bottom, left → right**:
|
|
161
|
+
`1`=centre, `2`/`3`=diagonal, `4`/`5`=corners (+centre for 5), `6`/`7`=two
|
|
162
|
+
columns (+centre for 7), `8`/`9`=full ring/grid. Counts above 9 are an error.
|
|
163
|
+
- `buttonD`: per-gang hole diameter; defaults to `dims.buttonD` (`0.5in`).
|
|
164
|
+
- `buttonLegends`: small labels, one per button in pip reading order (`""` or a
|
|
165
|
+
short array skips some). They are **in addition to** the gang `legend` on top,
|
|
166
|
+
and print in the accent colour. Sizing/placement: `buttonLegendSize` (3 mm),
|
|
167
|
+
`buttonLegendGap` (1 mm, below each hole).
|
|
168
|
+
- Pip spacing is `dims.buttonPitch` (`0.85in` centre-to-centre). A button gang
|
|
169
|
+
mounts like a device — it gets a screw pair on the Decora line unless
|
|
170
|
+
`"screws": false`.
|
|
171
|
+
|
|
172
|
+
See `../buttons_demo.json` for a worked Decora-plus-buttons example.
|
|
173
|
+
|
|
174
|
+
> Per-button legends are rendered by the **manifold engine only** (the `-scad`
|
|
175
|
+
> engine emits the button holes but skips the small labels, and warns).
|
|
176
|
+
|
|
115
177
|
See `../decora_wallplate_spec.md` for the dimensional reference and print notes
|
|
116
178
|
(print front-face-down; PETG/ASA recommended for heat near devices; keep plate
|
|
117
179
|
and labels the same material family — see the chat note on ABS+PLA).
|
|
118
180
|
|
|
181
|
+
### Appearance, output & engine
|
|
182
|
+
|
|
183
|
+
Top-level keys (all have defaults in `defaults.json`):
|
|
184
|
+
|
|
185
|
+
| Key | Default | Meaning |
|
|
186
|
+
|---|---|---|
|
|
187
|
+
| `output` | `"3mf"` | `"3mf"` (multi-object + thumbnail), `"stl"` (one binary STL per part), or `"scad"` (write the `.scad` only, no render — implies the scad engine). |
|
|
188
|
+
| `engine` | `"manifold"` | `"manifold"` (direct) or `"scad"` (OpenSCAD). `-scad` on the CLI forces scad. |
|
|
189
|
+
| `plateColor` | `"#35589f"` | Plate (filament 1) colour — used for the embedded preview and the scad-engine preview. |
|
|
190
|
+
| `accentColor` | `"#101010"` | Label (filament 2) colour, same uses. |
|
|
191
|
+
| `printReady` | `true` | Flip the model front-face-down (rotate 180° about X) for printing. The preview camera follows the flip, so labels stay upright either way. |
|
|
192
|
+
| `fontFile` | Arial Bold | Path to a `.ttf`/`.otf` for the **manifold** engine's text. |
|
|
193
|
+
| `font` | Liberation Sans Bold | OpenSCAD fontconfig name for the **scad** engine's text. |
|
|
194
|
+
| `openscad` | Program Files path | OpenSCAD executable; only used by the scad engine (auto-install offered if missing). |
|
|
195
|
+
| `bevel` | `1.2mm` | Front-edge chamfer width. |
|
|
196
|
+
| `labelDepth` | `0.6mm` | Recess depth of the label pockets / height of the label solids. |
|
|
197
|
+
|
|
198
|
+
Plate geometry, opening sizes, screw spacing, etc. live under the `dims` object
|
|
199
|
+
(see `defaults.json` for the full list, all unit-suffixed lengths). Override any
|
|
200
|
+
single dim by deep-merge, e.g. `"dims": { "depth": "5mm" }`.
|
|
201
|
+
|
|
119
202
|
## Files
|
|
120
203
|
|
|
121
204
|
- `index.ts` — CLI entry / engine dispatch / arg validation
|
package/config.d.ts
CHANGED
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
export declare const IN = 25.4;
|
|
2
|
-
export type GangType = "blank" | "decora" | "duplex";
|
|
2
|
+
export type GangType = "blank" | "decora" | "duplex" | "button";
|
|
3
3
|
export type Engine = "manifold" | "scad";
|
|
4
|
-
/** A length: CSS-style unit string ("0.375in" / "5mm") or a bare number (mm). */
|
|
4
|
+
/** A length: CSS-style unit string ("0.375in" / "5mm" / "2cm" / "0.5ft") or a bare number (mm). */
|
|
5
5
|
export type Len = string | number;
|
|
6
|
-
/** Parse a length to millimetres. */
|
|
6
|
+
/** Parse a length to millimetres. A bare number (no suffix) is millimetres. */
|
|
7
7
|
export declare function mm(v: Len): number;
|
|
8
8
|
export interface RockerCfg {
|
|
9
9
|
left?: string;
|
|
@@ -13,6 +13,9 @@ export interface GangCfg {
|
|
|
13
13
|
type?: GangType;
|
|
14
14
|
opening?: boolean;
|
|
15
15
|
legend?: string;
|
|
16
|
+
buttons?: number;
|
|
17
|
+
buttonD?: Len;
|
|
18
|
+
buttonLegends?: string[];
|
|
16
19
|
filler?: boolean;
|
|
17
20
|
screws?: boolean;
|
|
18
21
|
rockers?: RockerCfg[];
|
|
@@ -38,6 +41,8 @@ export interface DimsCfg {
|
|
|
38
41
|
duplexW?: Len;
|
|
39
42
|
duplexH?: Len;
|
|
40
43
|
duplexSpacing?: Len;
|
|
44
|
+
buttonD?: Len;
|
|
45
|
+
buttonPitch?: Len;
|
|
41
46
|
screwSpacing?: Len;
|
|
42
47
|
blankScrewSpacing?: Len;
|
|
43
48
|
screwClearD?: Len;
|
|
@@ -56,6 +61,8 @@ export interface Config {
|
|
|
56
61
|
rockerGap?: Len;
|
|
57
62
|
headerSize?: Len;
|
|
58
63
|
headerGap?: Len;
|
|
64
|
+
buttonLegendSize?: Len;
|
|
65
|
+
buttonLegendGap?: Len;
|
|
59
66
|
bevel?: Len;
|
|
60
67
|
labelDepth?: Len;
|
|
61
68
|
blankScrews?: boolean;
|
|
@@ -90,6 +97,8 @@ export interface Dims {
|
|
|
90
97
|
duplexW: number;
|
|
91
98
|
duplexH: number;
|
|
92
99
|
duplexSpacing: number;
|
|
100
|
+
buttonD: number;
|
|
101
|
+
buttonPitch: number;
|
|
93
102
|
screwSpacing: number;
|
|
94
103
|
blankScrewSpacing: number;
|
|
95
104
|
screwClearD: number;
|
|
@@ -109,6 +118,8 @@ export interface Resolved {
|
|
|
109
118
|
rockerGap: number;
|
|
110
119
|
headerSize: number;
|
|
111
120
|
headerGap: number;
|
|
121
|
+
buttonLegendSize: number;
|
|
122
|
+
buttonLegendGap: number;
|
|
112
123
|
bevel: number;
|
|
113
124
|
labelDepth: number;
|
|
114
125
|
blankScrews: boolean;
|
|
@@ -124,4 +135,10 @@ export interface Resolved {
|
|
|
124
135
|
}
|
|
125
136
|
export declare function loadConfig(path: string): Resolved;
|
|
126
137
|
export declare function gangType(g: GangCfg): GangType;
|
|
138
|
+
/**
|
|
139
|
+
* Pip layout for a button gang: grid coordinates (gx, gy) each in {-1, 0, 1}
|
|
140
|
+
* (gy up), die/domino style, in reading order (top -> bottom, left -> right).
|
|
141
|
+
* Odd counts include the centre (0, 0). Supports 1-9.
|
|
142
|
+
*/
|
|
143
|
+
export declare function pipGrid(n: number): [number, number][];
|
|
127
144
|
//# sourceMappingURL=config.d.ts.map
|
package/config.js
CHANGED
|
@@ -5,25 +5,29 @@
|
|
|
5
5
|
* (in this directory), so a specific file need only carry what differs — often
|
|
6
6
|
* just `name`, `gangs`, and a `breaker.switch`.
|
|
7
7
|
*
|
|
8
|
-
* Lengths are written CSS-style with a unit suffix: "0.375in", "5mm", "
|
|
9
|
-
* A bare number is treated as millimetres. Angles are plain
|
|
8
|
+
* Lengths are written CSS-style with a unit suffix: "0.375in", "5mm", "2cm",
|
|
9
|
+
* "0.5ft", "-42mm". A bare number is treated as millimetres. Angles are plain
|
|
10
|
+
* numbers (degrees).
|
|
10
11
|
*/
|
|
11
12
|
import { readFileSync } from "fs";
|
|
12
13
|
import { join } from "path";
|
|
14
|
+
import JSON5 from "json5";
|
|
13
15
|
export const IN = 25.4;
|
|
14
|
-
/**
|
|
16
|
+
/** Millimetres per supported length unit. */
|
|
17
|
+
const UNIT = { mm: 1, cm: 10, in: IN, ft: 12 * IN };
|
|
18
|
+
/** Parse a length to millimetres. A bare number (no suffix) is millimetres. */
|
|
15
19
|
export function mm(v) {
|
|
16
20
|
if (typeof v === "number")
|
|
17
21
|
return v;
|
|
18
|
-
const m = /^\s*(-?[\d.]+)\s*(mm|in)?\s*$/i.exec(v);
|
|
22
|
+
const m = /^\s*(-?[\d.]+)\s*(mm|cm|in|ft)?\s*$/i.exec(v);
|
|
19
23
|
if (!m)
|
|
20
|
-
throw new Error(`invalid length "${v}" (use e.g. "0.375in"
|
|
21
|
-
|
|
22
|
-
return m[2] && m[2].toLowerCase() === "in" ? n * IN : n;
|
|
24
|
+
throw new Error(`invalid length "${v}" (use e.g. "0.375in", "5mm", "2cm", "0.5ft")`);
|
|
25
|
+
return parseFloat(m[1]) * UNIT[(m[2] ?? "mm").toLowerCase()];
|
|
23
26
|
}
|
|
24
|
-
//
|
|
27
|
+
// JSON5 (lenient: comments, trailing commas, unquoted keys) returns dynamic
|
|
28
|
+
// shape; merged then validated below.
|
|
25
29
|
function readJson(path) {
|
|
26
|
-
return
|
|
30
|
+
return JSON5.parse(readFileSync(path, "utf8"));
|
|
27
31
|
}
|
|
28
32
|
/** Deep-merge plain objects; arrays and scalars from `over` replace `base`. */
|
|
29
33
|
function deepMerge(base, over) {
|
|
@@ -50,6 +54,7 @@ export function loadConfig(path) {
|
|
|
50
54
|
faceT: mm(d.faceT), rimW: mm(d.rimW), cornerR: mm(d.cornerR),
|
|
51
55
|
openW: mm(d.openW), openH: mm(d.openH), openR: mm(d.openR),
|
|
52
56
|
duplexW: mm(d.duplexW), duplexH: mm(d.duplexH), duplexSpacing: mm(d.duplexSpacing),
|
|
57
|
+
buttonD: mm(d.buttonD), buttonPitch: mm(d.buttonPitch),
|
|
53
58
|
screwSpacing: mm(d.screwSpacing), blankScrewSpacing: mm(d.blankScrewSpacing),
|
|
54
59
|
screwClearD: mm(d.screwClearD), screwHeadD: mm(d.screwHeadD), cskAngle: d.cskAngle,
|
|
55
60
|
};
|
|
@@ -62,6 +67,7 @@ export function loadConfig(path) {
|
|
|
62
67
|
gangBreakerSize: mm(c.gangBreakerSize), gangBreakerY: mm(c.gangBreakerY),
|
|
63
68
|
rockerSize: mm(c.rockerSize), rockerGap: mm(c.rockerGap),
|
|
64
69
|
headerSize: mm(c.headerSize), headerGap: mm(c.headerGap),
|
|
70
|
+
buttonLegendSize: mm(c.buttonLegendSize), buttonLegendGap: mm(c.buttonLegendGap),
|
|
65
71
|
bevel: mm(c.bevel), labelDepth: mm(c.labelDepth),
|
|
66
72
|
blankScrews: c.blankScrews,
|
|
67
73
|
font: c.font, fontFile: c.fontFile,
|
|
@@ -74,8 +80,32 @@ export function loadConfig(path) {
|
|
|
74
80
|
export function gangType(g) {
|
|
75
81
|
if (g.type)
|
|
76
82
|
return g.type;
|
|
83
|
+
if (g.buttons && g.buttons > 0)
|
|
84
|
+
return "button"; // count implies a button gang
|
|
77
85
|
if (g.opening === true)
|
|
78
86
|
return "decora";
|
|
79
87
|
return "blank";
|
|
80
88
|
}
|
|
89
|
+
/**
|
|
90
|
+
* Pip layout for a button gang: grid coordinates (gx, gy) each in {-1, 0, 1}
|
|
91
|
+
* (gy up), die/domino style, in reading order (top -> bottom, left -> right).
|
|
92
|
+
* Odd counts include the centre (0, 0). Supports 1-9.
|
|
93
|
+
*/
|
|
94
|
+
export function pipGrid(n) {
|
|
95
|
+
const grid = {
|
|
96
|
+
1: [[0, 0]],
|
|
97
|
+
2: [[-1, 1], [1, -1]],
|
|
98
|
+
3: [[-1, 1], [0, 0], [1, -1]],
|
|
99
|
+
4: [[-1, 1], [1, 1], [-1, -1], [1, -1]],
|
|
100
|
+
5: [[-1, 1], [1, 1], [0, 0], [-1, -1], [1, -1]],
|
|
101
|
+
6: [[-1, 1], [1, 1], [-1, 0], [1, 0], [-1, -1], [1, -1]],
|
|
102
|
+
7: [[-1, 1], [1, 1], [-1, 0], [0, 0], [1, 0], [-1, -1], [1, -1]],
|
|
103
|
+
8: [[-1, 1], [0, 1], [1, 1], [-1, 0], [1, 0], [-1, -1], [0, -1], [1, -1]],
|
|
104
|
+
9: [[-1, 1], [0, 1], [1, 1], [-1, 0], [0, 0], [1, 0], [-1, -1], [0, -1], [1, -1]],
|
|
105
|
+
};
|
|
106
|
+
const p = grid[n];
|
|
107
|
+
if (!p)
|
|
108
|
+
throw new Error(`buttons: count ${n} unsupported (pip layout is 1-9)`);
|
|
109
|
+
return p;
|
|
110
|
+
}
|
|
81
111
|
//# sourceMappingURL=config.js.map
|
package/defaults.json
CHANGED
|
@@ -9,6 +9,8 @@
|
|
|
9
9
|
"rockerGap": "1.5mm",
|
|
10
10
|
"headerSize": "4mm",
|
|
11
11
|
"headerGap": "5mm",
|
|
12
|
+
"buttonLegendSize": "3mm",
|
|
13
|
+
"buttonLegendGap": "1mm",
|
|
12
14
|
"bevel": "1.2mm",
|
|
13
15
|
"labelDepth": "0.6mm",
|
|
14
16
|
"blankScrews": true,
|
|
@@ -34,6 +36,8 @@
|
|
|
34
36
|
"duplexW": "1.34375in",
|
|
35
37
|
"duplexH": "1.125in",
|
|
36
38
|
"duplexSpacing": "1.5in",
|
|
39
|
+
"buttonD": "0.5in",
|
|
40
|
+
"buttonPitch": "0.85in",
|
|
37
41
|
"screwSpacing": "3.8125in",
|
|
38
42
|
"blankScrewSpacing": "3.281in",
|
|
39
43
|
"screwClearD": "4mm",
|
package/geometry.js
CHANGED
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
* print-oriented, ready for build3mf() or writeBinaryStl().
|
|
7
7
|
*/
|
|
8
8
|
import Module from "manifold-3d";
|
|
9
|
-
import { gangType } from "./config.js";
|
|
9
|
+
import { gangType, pipGrid, mm } from "./config.js";
|
|
10
10
|
import { loadFont, textToContours } from "./text.js";
|
|
11
11
|
const SEG = 96; // circular segments (matches the SCAD $fn = 96)
|
|
12
12
|
let wasm = null;
|
|
@@ -83,6 +83,14 @@ export async function buildMeshes(cfg) {
|
|
|
83
83
|
if (sc)
|
|
84
84
|
cutters.push(screw(x, 0)); // single centre screw
|
|
85
85
|
}
|
|
86
|
+
else if (t === "button") { // round button holes in a pip layout
|
|
87
|
+
const bd = g.buttonD !== undefined ? mm(g.buttonD) : d.buttonD;
|
|
88
|
+
for (const [gx, gy] of pipGrid(g.buttons))
|
|
89
|
+
cutters.push(Manifold.cylinder(DEPTH + 2, bd / 2, bd / 2, SEG)
|
|
90
|
+
.translate([x + gx * d.buttonPitch, gy * d.buttonPitch, -1]));
|
|
91
|
+
if (sc)
|
|
92
|
+
screwsPair(x, d.screwSpacing); // mounts like a device, on the Decora line
|
|
93
|
+
}
|
|
86
94
|
else if (cfg.blankScrews && sc) { // blank: box-ear line, or Decora line if a filler plate
|
|
87
95
|
screwsPair(x, g.filler ? d.screwSpacing : d.blankScrewSpacing);
|
|
88
96
|
}
|
|
@@ -101,6 +109,18 @@ export async function buildMeshes(cfg) {
|
|
|
101
109
|
accent.push({ txt: g.breaker, size: cfg.gangBreakerSize, halign: "right", x: gx + openHalf, y: cfg.gangBreakerY });
|
|
102
110
|
if ((g.header ?? "") !== "")
|
|
103
111
|
accent.push({ txt: g.header, size: cfg.headerSize, halign: "center", x: gx, y: openTop + cfg.headerGap });
|
|
112
|
+
if (gangType(g) === "button") { // per-button legends, small, below each hole
|
|
113
|
+
const bd = g.buttonD !== undefined ? mm(g.buttonD) : d.buttonD;
|
|
114
|
+
const bl = g.buttonLegends ?? [];
|
|
115
|
+
const pips = pipGrid(g.buttons);
|
|
116
|
+
for (let j = 0; j < pips.length; j++) {
|
|
117
|
+
if ((bl[j] ?? "") === "")
|
|
118
|
+
continue;
|
|
119
|
+
const [px, py] = pips[j];
|
|
120
|
+
accent.push({ txt: bl[j], size: cfg.buttonLegendSize, halign: "center",
|
|
121
|
+
x: gx + px * d.buttonPitch, y: py * d.buttonPitch - bd / 2 - cfg.buttonLegendGap });
|
|
122
|
+
}
|
|
123
|
+
}
|
|
104
124
|
const rk = g.rockers ?? [];
|
|
105
125
|
for (let j = 0; j < rk.length; j++) {
|
|
106
126
|
const yr = openTop - (j + 0.5) * (d.openH / rk.length); // rockers top -> bottom
|
package/index.js
CHANGED
|
@@ -15,9 +15,21 @@
|
|
|
15
15
|
import { writeFileSync, unlinkSync } from "fs";
|
|
16
16
|
import { basename, dirname, join } from "path";
|
|
17
17
|
import { loadConfig } from "./config.js";
|
|
18
|
-
import { generateScad, runOpenscad, readStl } from "./scad.js";
|
|
18
|
+
import { generateScad, runOpenscad, readStl, ensureOpenscad } from "./scad.js";
|
|
19
19
|
import { buildMeshes } from "./geometry.js";
|
|
20
20
|
import { build3mf, writeBinaryStl } from "./threemf.js";
|
|
21
|
+
// Preview-PNG view: both engines flip the model front-face-down when printReady
|
|
22
|
+
// (rotate 180 about X -> front normal -Z, world-up -Y), so aim the thumbnail
|
|
23
|
+
// camera at the readable front either way.
|
|
24
|
+
function thumbView(cfg) {
|
|
25
|
+
const f = cfg.printReady;
|
|
26
|
+
return {
|
|
27
|
+
front: [0, 0, f ? -1 : 1],
|
|
28
|
+
up: [0, f ? -1 : 1, 0],
|
|
29
|
+
plateColor: cfg.plateColor,
|
|
30
|
+
accentColor: cfg.accentColor,
|
|
31
|
+
};
|
|
32
|
+
}
|
|
21
33
|
// Bambu object-list name: "<config> <part>"; the accent text object reads "labels".
|
|
22
34
|
function partName(cfgName, label) {
|
|
23
35
|
return `${cfgName} ${label === "legend" ? "labels" : label}`;
|
|
@@ -35,27 +47,28 @@ function partList(cfg) {
|
|
|
35
47
|
return parts;
|
|
36
48
|
}
|
|
37
49
|
function runScadEngine(cfg, dir) {
|
|
38
|
-
if (cfg.gangs.some((g) => (g.rockers?.length ?? 0) > 0 || (g.header ?? "") !== "" || (g.breaker ?? "") !== ""))
|
|
39
|
-
console.warn(" ! per-gang rocker/header/breaker labels are only rendered by the manifold engine; the -scad engine ignores them.");
|
|
50
|
+
if (cfg.gangs.some((g) => (g.rockers?.length ?? 0) > 0 || (g.header ?? "") !== "" || (g.breaker ?? "") !== "" || (g.buttonLegends?.length ?? 0) > 0))
|
|
51
|
+
console.warn(" ! per-gang rocker/header/breaker labels and per-button legends are only rendered by the manifold engine; the -scad engine ignores them (button holes themselves are emitted).");
|
|
40
52
|
const scadPath = join(dir, `${cfg.name}.scad`);
|
|
41
53
|
writeFileSync(scadPath, generateScad(cfg));
|
|
42
54
|
console.log("wrote", basename(scadPath));
|
|
43
55
|
if (cfg.output === "scad")
|
|
44
56
|
return;
|
|
57
|
+
const exe = ensureOpenscad(cfg.openscad); // verify/install OpenSCAD before any export
|
|
45
58
|
const parts = partList(cfg);
|
|
46
59
|
if (cfg.output === "stl") {
|
|
47
60
|
for (const p of parts)
|
|
48
|
-
runOpenscad(
|
|
61
|
+
runOpenscad(exe, scadPath, join(dir, `${cfg.name}_${p.label}.stl`), p.mode);
|
|
49
62
|
}
|
|
50
63
|
else {
|
|
51
64
|
const tmp = [];
|
|
52
65
|
const objects = parts.map((p) => {
|
|
53
66
|
const stl = join(dir, `_${cfg.name}_${p.label}.stl`);
|
|
54
|
-
runOpenscad(
|
|
67
|
+
runOpenscad(exe, scadPath, stl, p.mode);
|
|
55
68
|
tmp.push(stl);
|
|
56
69
|
return { name: partName(cfg.name, p.label), mesh: readStl(stl), extruder: partExtruder(p.label) };
|
|
57
70
|
});
|
|
58
|
-
writeFileSync(join(dir, `${cfg.name}.3mf`), build3mf(objects));
|
|
71
|
+
writeFileSync(join(dir, `${cfg.name}.3mf`), build3mf(objects, thumbView(cfg)));
|
|
59
72
|
for (const t of tmp)
|
|
60
73
|
unlinkSync(t);
|
|
61
74
|
console.log(" wrote", `${cfg.name}.3mf`, `(${objects.length} objects: ${objects.map((o) => o.name).join(", ")})`);
|
|
@@ -75,7 +88,7 @@ async function runManifoldEngine(cfg, dir) {
|
|
|
75
88
|
}
|
|
76
89
|
else {
|
|
77
90
|
const objects = meshes.map((m) => ({ name: partName(cfg.name, m.label), mesh: m.mesh, extruder: partExtruder(m.label) }));
|
|
78
|
-
writeFileSync(join(dir, `${cfg.name}.3mf`), build3mf(objects));
|
|
91
|
+
writeFileSync(join(dir, `${cfg.name}.3mf`), build3mf(objects, thumbView(cfg)));
|
|
79
92
|
console.log(" wrote", `${cfg.name}.3mf`, `(${objects.length} objects: ${objects.map((o) => o.name).join(", ")})`);
|
|
80
93
|
}
|
|
81
94
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@bobfrankston/wallplater",
|
|
3
|
-
"version": "1.0.
|
|
3
|
+
"version": "1.0.7",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "JSON-driven wall-plate generator (Decora / duplex / blank). Direct-to-3MF via manifold-3d, with an optional OpenSCAD engine.",
|
|
6
6
|
"main": "index.js",
|
|
@@ -19,6 +19,7 @@
|
|
|
19
19
|
"release": "npmglobalize"
|
|
20
20
|
},
|
|
21
21
|
"dependencies": {
|
|
22
|
+
"json5": "^2.2.3",
|
|
22
23
|
"manifold-3d": "^3.0.0",
|
|
23
24
|
"opentype.js": "^1.3.4"
|
|
24
25
|
},
|
package/scad.d.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { type Resolved } from "./config.js";
|
|
2
2
|
import type { Mesh } from "./threemf.js";
|
|
3
3
|
export declare function generateScad(cfg: Resolved): string;
|
|
4
|
+
export declare function ensureOpenscad(exe: string): string;
|
|
4
5
|
export declare function runOpenscad(exe: string, scadPath: string, outPath: string, mode: string): void;
|
|
5
6
|
export declare function readStl(path: string): Mesh;
|
|
6
7
|
//# sourceMappingURL=scad.d.ts.map
|
package/scad.js
CHANGED
|
@@ -4,8 +4,8 @@
|
|
|
4
4
|
* one STL per colour part, and reads STLs back into the common Mesh shape.
|
|
5
5
|
* Note: rocker/header labels are manifold-engine only and are not emitted here.
|
|
6
6
|
*/
|
|
7
|
-
import { readFileSync } from "fs";
|
|
8
|
-
import { execFileSync } from "child_process";
|
|
7
|
+
import { readFileSync, existsSync, readSync } from "fs";
|
|
8
|
+
import { execFileSync, spawnSync } from "child_process";
|
|
9
9
|
import { basename } from "path";
|
|
10
10
|
import { gangType } from "./config.js";
|
|
11
11
|
function num(n) {
|
|
@@ -17,11 +17,13 @@ export function generateScad(cfg) {
|
|
|
17
17
|
const legend = "[" + cfg.gangs.map((g) => `"${g.legend ?? ""}"`).join(", ") + "]";
|
|
18
18
|
const filler = "[" + cfg.gangs.map((g) => (g.filler ? "true" : "false")).join(", ") + "]";
|
|
19
19
|
const screws = "[" + cfg.gangs.map((g) => (g.screws === false ? "false" : "true")).join(", ") + "]";
|
|
20
|
+
const buttons = "[" + cfg.gangs.map((g) => num(g.buttons ?? 0)).join(", ") + "]";
|
|
20
21
|
const params = `// ===== Auto-generated by wallplater from ${cfg.name}.json — do not hand-edit =====
|
|
21
22
|
GANGS = ${gangs}; // per gang: "blank" | "decora" | "duplex"
|
|
22
23
|
LEGEND = ${legend};
|
|
23
24
|
FILLER = ${filler}; // blank gang has a filler/insert plate -> screws on Decora line
|
|
24
25
|
SCREWS = ${screws}; // per gang: place screw holes? (false = omit)
|
|
26
|
+
BUTTONS = ${buttons}; // per gang: button-hole count (0 = not a button gang)
|
|
25
27
|
GANG_PITCH = ${num(d.gangPitch)};
|
|
26
28
|
ONE_GANG_W = ${num(d.oneGangWidth)};
|
|
27
29
|
PLATE_H = ${num(d.height)};
|
|
@@ -36,6 +38,8 @@ OPEN_R = ${num(d.openR)};
|
|
|
36
38
|
DUPLEX_W = ${num(d.duplexW)};
|
|
37
39
|
DUPLEX_H = ${num(d.duplexH)};
|
|
38
40
|
DUPLEX_SPACING= ${num(d.duplexSpacing)};
|
|
41
|
+
BUTTON_D = ${num(d.buttonD)};
|
|
42
|
+
BUTTON_PITCH = ${num(d.buttonPitch)};
|
|
39
43
|
SCREW_SPACING = ${num(d.screwSpacing)};
|
|
40
44
|
BLANK_SCREW_SPACING = ${num(d.blankScrewSpacing)};
|
|
41
45
|
BLANK_SCREWS = ${cfg.blankScrews};
|
|
@@ -82,6 +86,24 @@ module duplex_opening(x)
|
|
|
82
86
|
translate([x, s * DUPLEX_SPACING/2, -1]) linear_extrude(DEPTH + 2)
|
|
83
87
|
intersection() { circle(d = DUPLEX_W); square([DUPLEX_W, DUPLEX_H], center = true); }
|
|
84
88
|
|
|
89
|
+
// pip grid: [gx, gy] in {-1,0,1} (gy up), die/domino style, centre for odd counts (1-9)
|
|
90
|
+
function pip_grid(n) =
|
|
91
|
+
n == 1 ? [[0,0]] :
|
|
92
|
+
n == 2 ? [[-1,1],[1,-1]] :
|
|
93
|
+
n == 3 ? [[-1,1],[0,0],[1,-1]] :
|
|
94
|
+
n == 4 ? [[-1,1],[1,1],[-1,-1],[1,-1]] :
|
|
95
|
+
n == 5 ? [[-1,1],[1,1],[0,0],[-1,-1],[1,-1]] :
|
|
96
|
+
n == 6 ? [[-1,1],[1,1],[-1,0],[1,0],[-1,-1],[1,-1]] :
|
|
97
|
+
n == 7 ? [[-1,1],[1,1],[-1,0],[0,0],[1,0],[-1,-1],[1,-1]] :
|
|
98
|
+
n == 8 ? [[-1,1],[0,1],[1,1],[-1,0],[1,0],[-1,-1],[0,-1],[1,-1]] :
|
|
99
|
+
n == 9 ? [[-1,1],[0,1],[1,1],[-1,0],[0,0],[1,0],[-1,-1],[0,-1],[1,-1]] :
|
|
100
|
+
[];
|
|
101
|
+
|
|
102
|
+
module button_holes(x, n)
|
|
103
|
+
for (p = pip_grid(n))
|
|
104
|
+
translate([x + p[0]*BUTTON_PITCH, p[1]*BUTTON_PITCH, -1])
|
|
105
|
+
cylinder(d = BUTTON_D, h = DEPTH + 2);
|
|
106
|
+
|
|
85
107
|
module outline() rrect(PLATE_W, PLATE_H, CORNER_R);
|
|
86
108
|
|
|
87
109
|
module plate_body() {
|
|
@@ -120,6 +142,7 @@ module plate() {
|
|
|
120
142
|
sc = (i >= len(SCREWS)) || SCREWS[i]; // place screws on this gang?
|
|
121
143
|
if (t == "decora") { decora_opening(x); if (sc) screws_pair(x, SCREW_SPACING); }
|
|
122
144
|
else if (t == "duplex") { duplex_opening(x); if (sc) screw(x, 0); } // single centre screw
|
|
145
|
+
else if (t == "button") { button_holes(x, BUTTONS[i]); if (sc) screws_pair(x, SCREW_SPACING); }
|
|
123
146
|
else if (BLANK_SCREWS && sc) // blank: box-ear line, or Decora line if a filler plate
|
|
124
147
|
screws_pair(x, (i < len(FILLER) && FILLER[i]) ? SCREW_SPACING : BLANK_SCREW_SPACING);
|
|
125
148
|
}
|
|
@@ -140,6 +163,68 @@ if (MODE == "preview") {
|
|
|
140
163
|
else if (MODE == "legend") oriented() legend_solids(LABEL_DEPTH);
|
|
141
164
|
else if (MODE == "breaker") oriented() breaker_solid(LABEL_DEPTH);
|
|
142
165
|
`;
|
|
166
|
+
// ----- OpenSCAD discovery + optional install -----
|
|
167
|
+
// Return a runnable OpenSCAD command: the configured path if it exists, else a
|
|
168
|
+
// bare "openscad(.exe)" if that's on PATH, else null.
|
|
169
|
+
function findOpenscad(exe) {
|
|
170
|
+
if (exe && existsSync(exe))
|
|
171
|
+
return exe;
|
|
172
|
+
const probe = process.platform === "win32" ? "openscad.exe" : "openscad";
|
|
173
|
+
const r = spawnSync(probe, ["--version"], { stdio: "ignore" });
|
|
174
|
+
if (!r.error && r.status === 0)
|
|
175
|
+
return probe;
|
|
176
|
+
return null;
|
|
177
|
+
}
|
|
178
|
+
// Synchronous y/N prompt (CLI is otherwise synchronous). Non-TTY/EOF -> no.
|
|
179
|
+
function promptYesNo(q) {
|
|
180
|
+
process.stdout.write(q);
|
|
181
|
+
const buf = Buffer.alloc(64);
|
|
182
|
+
try {
|
|
183
|
+
const n = readSync(0, buf, 0, buf.length, null);
|
|
184
|
+
const ans = buf.toString("utf8", 0, n).trim().toLowerCase();
|
|
185
|
+
return ans === "y" || ans === "yes";
|
|
186
|
+
}
|
|
187
|
+
catch {
|
|
188
|
+
return false; // stdin not readable (piped/CI) -> decline
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
// Platform install command for OpenSCAD: [exe, args].
|
|
192
|
+
function installCmd() {
|
|
193
|
+
switch (process.platform) {
|
|
194
|
+
case "win32": return ["winget", ["install", "--id", "OpenSCAD.OpenSCAD", "-e",
|
|
195
|
+
"--accept-package-agreements", "--accept-source-agreements"]];
|
|
196
|
+
case "darwin": return ["brew", ["install", "--cask", "openscad"]];
|
|
197
|
+
default: return ["sudo", ["apt-get", "install", "-y", "openscad"]];
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
// Ensure OpenSCAD is available; offer to install it if not. Returns the command
|
|
201
|
+
// to invoke, or exits the process if it can't be found/installed.
|
|
202
|
+
export function ensureOpenscad(exe) {
|
|
203
|
+
const found = findOpenscad(exe);
|
|
204
|
+
if (found)
|
|
205
|
+
return found;
|
|
206
|
+
const [cmd, args] = installCmd();
|
|
207
|
+
const shown = `${cmd} ${args.join(" ")}`;
|
|
208
|
+
console.error(`OpenSCAD not found (looked for "${exe}" and "openscad" on PATH).`);
|
|
209
|
+
if (!promptYesNo(`Install it now with:\n ${shown}\n? [y/N] `)) {
|
|
210
|
+
console.error("The -scad engine needs OpenSCAD. Install it, or drop -scad to use the default manifold engine.");
|
|
211
|
+
process.exit(1);
|
|
212
|
+
}
|
|
213
|
+
try {
|
|
214
|
+
execFileSync(cmd, args, { stdio: "inherit" });
|
|
215
|
+
}
|
|
216
|
+
catch {
|
|
217
|
+
console.error("Install failed. Install OpenSCAD manually: https://openscad.org/downloads.html");
|
|
218
|
+
process.exit(1);
|
|
219
|
+
}
|
|
220
|
+
const after = findOpenscad(exe);
|
|
221
|
+
if (!after) {
|
|
222
|
+
console.error('OpenSCAD installed but not yet found — open a new terminal (so PATH refreshes), or set "openscad" in your config to the full exe path.');
|
|
223
|
+
process.exit(1);
|
|
224
|
+
}
|
|
225
|
+
console.log("OpenSCAD installed.");
|
|
226
|
+
return after;
|
|
227
|
+
}
|
|
143
228
|
export function runOpenscad(exe, scadPath, outPath, mode) {
|
|
144
229
|
const fmt = outPath.endsWith(".3mf") ? "3mf" : outPath.endsWith(".png") ? "png" : "binstl";
|
|
145
230
|
execFileSync(exe, [`-D`, `MODE="${mode}"`, "--export-format", fmt, "-o", outPath, scadPath], { stdio: ["ignore", "ignore", "inherit"] });
|
package/threemf.d.ts
CHANGED
|
@@ -7,6 +7,14 @@ export interface Object3mf {
|
|
|
7
7
|
mesh: Mesh;
|
|
8
8
|
extruder?: number;
|
|
9
9
|
}
|
|
10
|
-
export
|
|
10
|
+
export interface ViewOpts {
|
|
11
|
+
front?: number[];
|
|
12
|
+
up?: number[];
|
|
13
|
+
plateColor?: string;
|
|
14
|
+
accentColor?: string;
|
|
15
|
+
size?: number;
|
|
16
|
+
}
|
|
17
|
+
export declare function build3mf(objects: Object3mf[], view?: ViewOpts): Buffer;
|
|
18
|
+
export declare function renderThumbnail(objects: Object3mf[], opts?: ViewOpts): Buffer;
|
|
11
19
|
export declare function writeBinaryStl(mesh: Mesh): Buffer;
|
|
12
20
|
//# sourceMappingURL=threemf.d.ts.map
|
package/threemf.js
CHANGED
|
@@ -5,12 +5,12 @@
|
|
|
5
5
|
* individually colourable objects), packed into the OPC zip by hand.
|
|
6
6
|
* - writeBinaryStl(): a single binary STL (used for the manifold STL path).
|
|
7
7
|
*/
|
|
8
|
-
import { deflateRawSync } from "zlib";
|
|
8
|
+
import { deflateRawSync, deflateSync } from "zlib";
|
|
9
9
|
function xmlEscape(s) {
|
|
10
10
|
return s.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """);
|
|
11
11
|
}
|
|
12
12
|
// ---------- multi-object 3MF (core spec, no slicer settings) ----------
|
|
13
|
-
export function build3mf(objects) {
|
|
13
|
+
export function build3mf(objects, view = {}) {
|
|
14
14
|
// separate, named top-level objects (each individually selectable/colourable
|
|
15
15
|
// in Bambu), all at the same transform so the labels stay seated in the
|
|
16
16
|
// plate's pockets.
|
|
@@ -43,15 +43,21 @@ ${objects.map((o, i) => ` <object id="${i + 1}">
|
|
|
43
43
|
</object>`).join("\n")}
|
|
44
44
|
</config>
|
|
45
45
|
`;
|
|
46
|
+
// Package thumbnail (PNG). Windows Explorer / slicers display this embedded
|
|
47
|
+
// image as the file's preview; they do NOT render the mesh themselves. It is
|
|
48
|
+
// wired up via the OPC thumbnail relationship below + the png content type.
|
|
49
|
+
const thumb = renderThumbnail(objects, view);
|
|
46
50
|
const ct = `<?xml version="1.0" encoding="UTF-8"?>
|
|
47
51
|
<Types xmlns="http://schemas.openxmlformats.org/package/2006/content-types">
|
|
48
52
|
<Default Extension="rels" ContentType="application/vnd.openxmlformats-package.relationships+xml"/>
|
|
49
53
|
<Default Extension="model" ContentType="application/vnd.ms-package.3dmanufacturing-3dmodel+xml"/>
|
|
54
|
+
<Default Extension="png" ContentType="image/png"/>
|
|
50
55
|
</Types>
|
|
51
56
|
`;
|
|
52
57
|
const rels = `<?xml version="1.0" encoding="UTF-8"?>
|
|
53
58
|
<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">
|
|
54
59
|
<Relationship Target="/3D/3dmodel.model" Id="rel0" Type="http://schemas.microsoft.com/3dmanufacturing/2013/01/3dmodel"/>
|
|
60
|
+
<Relationship Target="/Metadata/thumbnail.png" Id="rel-thumb" Type="http://schemas.openxmlformats.org/package/2006/relationships/metadata/thumbnail"/>
|
|
55
61
|
</Relationships>
|
|
56
62
|
`;
|
|
57
63
|
return zip([
|
|
@@ -59,8 +65,137 @@ ${objects.map((o, i) => ` <object id="${i + 1}">
|
|
|
59
65
|
{ name: "_rels/.rels", data: Buffer.from(rels) },
|
|
60
66
|
{ name: "3D/3dmodel.model", data: Buffer.from(model) },
|
|
61
67
|
{ name: "Metadata/model_settings.config", data: Buffer.from(settings) },
|
|
68
|
+
{ name: "Metadata/thumbnail.png", data: thumb },
|
|
62
69
|
]);
|
|
63
70
|
}
|
|
71
|
+
// ---------- preview thumbnail (orthographic, pure-JS, no deps) ----------
|
|
72
|
+
function hexRgb(s, fallback) {
|
|
73
|
+
const m = /^#?([0-9a-fA-F]{6})$/.exec(s ?? "");
|
|
74
|
+
if (!m)
|
|
75
|
+
return fallback;
|
|
76
|
+
const n = parseInt(m[1], 16);
|
|
77
|
+
return [(n >> 16) & 255, (n >> 8) & 255, n & 255];
|
|
78
|
+
}
|
|
79
|
+
function vcross(a, b) {
|
|
80
|
+
return [a[1] * b[2] - a[2] * b[1], a[2] * b[0] - a[0] * b[2], a[0] * b[1] - a[1] * b[0]];
|
|
81
|
+
}
|
|
82
|
+
function vdot(a, b) { return a[0] * b[0] + a[1] * b[1] + a[2] * b[2]; }
|
|
83
|
+
function vnorm(a) { const l = Math.hypot(a[0], a[1], a[2]) || 1; return [a[0] / l, a[1] / l, a[2] / l]; }
|
|
84
|
+
// Render a top-down (front-facing) orthographic preview of all objects to a PNG.
|
|
85
|
+
// Plate-extruder parts take plateColor, accent parts accentColor; a fixed light
|
|
86
|
+
// gives the flat face a little form. Background is transparent.
|
|
87
|
+
export function renderThumbnail(objects, opts = {}) {
|
|
88
|
+
const S = opts.size ?? 320;
|
|
89
|
+
const front = vnorm(opts.front ?? [0, 0, 1]);
|
|
90
|
+
const up = vnorm(opts.up ?? [0, 1, 0]);
|
|
91
|
+
const right = vnorm(vcross(up, front)); // right,up,front right-handed -> non-mirrored
|
|
92
|
+
const camUp = up;
|
|
93
|
+
const plateC = hexRgb(opts.plateColor, [120, 140, 200]);
|
|
94
|
+
const accentC = hexRgb(opts.accentColor, [20, 20, 24]);
|
|
95
|
+
// light from upper-left-front in screen space, expressed in world coords
|
|
96
|
+
const light = vnorm([
|
|
97
|
+
-0.4 * right[0] + 0.5 * camUp[0] + 0.9 * front[0],
|
|
98
|
+
-0.4 * right[1] + 0.5 * camUp[1] + 0.9 * front[1],
|
|
99
|
+
-0.4 * right[2] + 0.5 * camUp[2] + 0.9 * front[2],
|
|
100
|
+
]);
|
|
101
|
+
const AMBIENT = 0.45;
|
|
102
|
+
const tris = [];
|
|
103
|
+
let uMin = Infinity, uMax = -Infinity, vMin = Infinity, vMax = -Infinity;
|
|
104
|
+
for (const o of objects) {
|
|
105
|
+
const col = (o.extruder ?? 1) === 1 ? plateC : accentC;
|
|
106
|
+
const pv = o.mesh.verts.map((p) => [vdot(p, right), vdot(p, camUp), vdot(p, front)]);
|
|
107
|
+
for (const p of pv) {
|
|
108
|
+
if (p[0] < uMin)
|
|
109
|
+
uMin = p[0];
|
|
110
|
+
if (p[0] > uMax)
|
|
111
|
+
uMax = p[0];
|
|
112
|
+
if (p[1] < vMin)
|
|
113
|
+
vMin = p[1];
|
|
114
|
+
if (p[1] > vMax)
|
|
115
|
+
vMax = p[1];
|
|
116
|
+
}
|
|
117
|
+
for (const t of o.mesh.tris) {
|
|
118
|
+
const a = o.mesh.verts[t[0]], b = o.mesh.verts[t[1]], c = o.mesh.verts[t[2]];
|
|
119
|
+
const n = vnorm(vcross([b[0] - a[0], b[1] - a[1], b[2] - a[2]], [c[0] - a[0], c[1] - a[1], c[2] - a[2]]));
|
|
120
|
+
if (vdot(n, front) <= 0)
|
|
121
|
+
continue; // cull faces pointing away from viewer
|
|
122
|
+
const inten = AMBIENT + (1 - AMBIENT) * Math.max(0, vdot(n, light));
|
|
123
|
+
const shade = [
|
|
124
|
+
Math.min(255, col[0] * inten), Math.min(255, col[1] * inten), Math.min(255, col[2] * inten),
|
|
125
|
+
];
|
|
126
|
+
const A = pv[t[0]], B = pv[t[1]], C = pv[t[2]];
|
|
127
|
+
tris.push({ x: [A[0], B[0], C[0]], y: [A[1], B[1], C[1]], z: [A[2], B[2], C[2]], col: shade });
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
const pad = Math.round(S * 0.08);
|
|
131
|
+
const span = Math.max(uMax - uMin, vMax - vMin, 1e-6);
|
|
132
|
+
const scale = (S - 2 * pad) / span;
|
|
133
|
+
const ox = pad + (S - 2 * pad - (uMax - uMin) * scale) / 2;
|
|
134
|
+
const oy = pad + (S - 2 * pad - (vMax - vMin) * scale) / 2;
|
|
135
|
+
const sx = (u) => ox + (u - uMin) * scale;
|
|
136
|
+
const sy = (v) => S - (oy + (v - vMin) * scale); // flip: image row 0 is top
|
|
137
|
+
const rgba = Buffer.alloc(S * S * 4); // transparent background
|
|
138
|
+
const zbuf = new Float32Array(S * S).fill(-Infinity);
|
|
139
|
+
for (const tr of tris) {
|
|
140
|
+
const px = [sx(tr.x[0]), sx(tr.x[1]), sx(tr.x[2])];
|
|
141
|
+
const py = [sy(tr.y[0]), sy(tr.y[1]), sy(tr.y[2])];
|
|
142
|
+
const area = (px[1] - px[0]) * (py[2] - py[0]) - (px[2] - px[0]) * (py[1] - py[0]);
|
|
143
|
+
if (Math.abs(area) < 1e-9)
|
|
144
|
+
continue;
|
|
145
|
+
const minX = Math.max(0, Math.floor(Math.min(px[0], px[1], px[2])));
|
|
146
|
+
const maxX = Math.min(S - 1, Math.ceil(Math.max(px[0], px[1], px[2])));
|
|
147
|
+
const minY = Math.max(0, Math.floor(Math.min(py[0], py[1], py[2])));
|
|
148
|
+
const maxY = Math.min(S - 1, Math.ceil(Math.max(py[0], py[1], py[2])));
|
|
149
|
+
for (let yy = minY; yy <= maxY; yy++) {
|
|
150
|
+
for (let xx = minX; xx <= maxX; xx++) {
|
|
151
|
+
const cx = xx + 0.5, cy = yy + 0.5;
|
|
152
|
+
const w0 = ((px[1] - cx) * (py[2] - cy) - (px[2] - cx) * (py[1] - cy)) / area;
|
|
153
|
+
const w1 = ((px[2] - cx) * (py[0] - cy) - (px[0] - cx) * (py[2] - cy)) / area;
|
|
154
|
+
const w2 = 1 - w0 - w1;
|
|
155
|
+
if (w0 < 0 || w1 < 0 || w2 < 0)
|
|
156
|
+
continue;
|
|
157
|
+
const z = w0 * tr.z[0] + w1 * tr.z[1] + w2 * tr.z[2];
|
|
158
|
+
const idx = yy * S + xx;
|
|
159
|
+
if (z <= zbuf[idx])
|
|
160
|
+
continue;
|
|
161
|
+
zbuf[idx] = z;
|
|
162
|
+
const o4 = idx * 4;
|
|
163
|
+
rgba[o4] = tr.col[0];
|
|
164
|
+
rgba[o4 + 1] = tr.col[1];
|
|
165
|
+
rgba[o4 + 2] = tr.col[2];
|
|
166
|
+
rgba[o4 + 3] = 255;
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
return encodePng(S, S, rgba);
|
|
171
|
+
}
|
|
172
|
+
// minimal 8-bit RGBA PNG (single IDAT, no filtering)
|
|
173
|
+
function encodePng(width, height, rgba) {
|
|
174
|
+
const chunk = (type, data) => {
|
|
175
|
+
const t = Buffer.from(type, "latin1");
|
|
176
|
+
const len = Buffer.alloc(4);
|
|
177
|
+
len.writeUInt32BE(data.length, 0);
|
|
178
|
+
const crc = Buffer.alloc(4);
|
|
179
|
+
crc.writeUInt32BE(crc32(Buffer.concat([t, data])), 0);
|
|
180
|
+
return Buffer.concat([len, t, data, crc]);
|
|
181
|
+
};
|
|
182
|
+
const ihdr = Buffer.alloc(13);
|
|
183
|
+
ihdr.writeUInt32BE(width, 0);
|
|
184
|
+
ihdr.writeUInt32BE(height, 4);
|
|
185
|
+
ihdr[8] = 8;
|
|
186
|
+
ihdr[9] = 6;
|
|
187
|
+
ihdr[10] = 0;
|
|
188
|
+
ihdr[11] = 0;
|
|
189
|
+
ihdr[12] = 0; // 8-bit, RGBA
|
|
190
|
+
const row = width * 4;
|
|
191
|
+
const raw = Buffer.alloc(height * (1 + row));
|
|
192
|
+
for (let y = 0; y < height; y++) {
|
|
193
|
+
raw[y * (1 + row)] = 0; // filter: none
|
|
194
|
+
rgba.copy(raw, y * (1 + row) + 1, y * row, (y + 1) * row);
|
|
195
|
+
}
|
|
196
|
+
const sig = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]);
|
|
197
|
+
return Buffer.concat([sig, chunk("IHDR", ihdr), chunk("IDAT", deflateSync(raw)), chunk("IEND", Buffer.alloc(0))]);
|
|
198
|
+
}
|
|
64
199
|
// ---------- binary STL (single object) ----------
|
|
65
200
|
export function writeBinaryStl(mesh) {
|
|
66
201
|
const buf = Buffer.alloc(84 + mesh.tris.length * 50);
|