@bobfrankston/wallplater 1.0.6 → 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 +36 -0
- package/index.js +18 -5
- package/package.json +1 -1
- package/scad.d.ts +1 -0
- package/scad.js +64 -2
- package/threemf.d.ts +9 -1
- package/threemf.js +137 -2
package/README.md
CHANGED
|
@@ -14,6 +14,11 @@ separate, individually colourable objects) or per-part STLs.
|
|
|
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
|
|
@@ -87,6 +99,9 @@ suffix) is treated as millimetres. Angles (e.g. `cskAngle`) are plain numbers.
|
|
|
87
99
|
|
|
88
100
|
- `gangs[].type`: `"decora"` | `"duplex"` | `"blank"` | `"button"`.
|
|
89
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.
|
|
90
105
|
- `gangs[].legend`: single label centred above the gang's opening (positioned
|
|
91
106
|
clear of the screw holes; `legendY` / `legendSize`).
|
|
92
107
|
- `gangs[].breaker`: per-gang breaker/circuit label, lower-right of the opening
|
|
@@ -163,6 +178,27 @@ See `../decora_wallplate_spec.md` for the dimensional reference and print notes
|
|
|
163
178
|
(print front-face-down; PETG/ASA recommended for heat near devices; keep plate
|
|
164
179
|
and labels the same material family — see the chat note on ABS+PLA).
|
|
165
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
|
+
|
|
166
202
|
## Files
|
|
167
203
|
|
|
168
204
|
- `index.ts` — CLI entry / engine dispatch / arg validation
|
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}`;
|
|
@@ -42,20 +54,21 @@ function runScadEngine(cfg, dir) {
|
|
|
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",
|
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) {
|
|
@@ -163,6 +163,68 @@ if (MODE == "preview") {
|
|
|
163
163
|
else if (MODE == "legend") oriented() legend_solids(LABEL_DEPTH);
|
|
164
164
|
else if (MODE == "breaker") oriented() breaker_solid(LABEL_DEPTH);
|
|
165
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
|
+
}
|
|
166
228
|
export function runOpenscad(exe, scadPath, outPath, mode) {
|
|
167
229
|
const fmt = outPath.endsWith(".3mf") ? "3mf" : outPath.endsWith(".png") ? "png" : "binstl";
|
|
168
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);
|