makeables 0.9.0 → 0.11.0
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 +14 -1
- package/dist/badge-package-CMFI3BIR.js +53 -0
- package/dist/brand-NMENEWIZ.js +37 -0
- package/dist/brand-preview-W4FTJJM3.js +48 -0
- package/dist/chunk-4WLCMPRH.js +74 -0
- package/dist/chunk-6RBOBRL4.js +391 -0
- package/dist/chunk-BYXBJQAS.js +0 -0
- package/dist/chunk-DLTVENEP.js +3295 -0
- package/dist/{chunk-RFIQNWNF.js → chunk-YB2YJCBS.js} +475 -2928
- package/dist/chunk-ZZA6CR6X.js +700 -0
- package/dist/cli.js +2777 -480
- package/dist/kit-IEWHMBS2.js +18 -0
- package/dist/{mcp-HS2XZXSO.js → mcp-YBRUVIWG.js} +14 -19
- package/dist/submission-ATVS5YTK.js +76 -0
- package/package.json +3 -2
package/dist/cli.js
CHANGED
|
@@ -1,77 +1,1558 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
+
import "./chunk-BYXBJQAS.js";
|
|
3
|
+
import {
|
|
4
|
+
addBrandKit,
|
|
5
|
+
addBrandLogo,
|
|
6
|
+
brandFromUrl,
|
|
7
|
+
brandView,
|
|
8
|
+
checkKitAgainstBrand,
|
|
9
|
+
newBrand,
|
|
10
|
+
prepareBrand,
|
|
11
|
+
readBrand,
|
|
12
|
+
writeBrand
|
|
13
|
+
} from "./chunk-ZZA6CR6X.js";
|
|
14
|
+
import {
|
|
15
|
+
addKitMember,
|
|
16
|
+
addToSheet,
|
|
17
|
+
checkSheet,
|
|
18
|
+
exportKit,
|
|
19
|
+
exportSheetPdf,
|
|
20
|
+
importKit,
|
|
21
|
+
kitCover,
|
|
22
|
+
newSheet,
|
|
23
|
+
packStickerSheet,
|
|
24
|
+
readKit,
|
|
25
|
+
readSheet,
|
|
26
|
+
sheetPreviewPng,
|
|
27
|
+
sheetReport
|
|
28
|
+
} from "./chunk-6RBOBRL4.js";
|
|
29
|
+
import {
|
|
30
|
+
getSkill,
|
|
31
|
+
skills,
|
|
32
|
+
version
|
|
33
|
+
} from "./chunk-4WLCMPRH.js";
|
|
2
34
|
import {
|
|
3
35
|
CloudError,
|
|
4
36
|
InputError,
|
|
5
|
-
PACKAGE_LIMIT,
|
|
6
37
|
addBadgeSticker,
|
|
7
|
-
badgeDesignObjectSchema,
|
|
8
|
-
badgeDesignSchema,
|
|
9
38
|
cardStyles,
|
|
10
|
-
checkSubmittedSvg,
|
|
11
39
|
connectAccount,
|
|
12
|
-
|
|
13
|
-
defaultStickerMaterial,
|
|
40
|
+
createStickerDesign,
|
|
14
41
|
designCatalog,
|
|
15
|
-
designProfile,
|
|
16
|
-
draftFromPackage,
|
|
17
42
|
findCardStyle,
|
|
18
43
|
findDesign,
|
|
19
|
-
getSkill,
|
|
20
|
-
hashBytes,
|
|
21
|
-
jsonBytes,
|
|
22
44
|
listCardDesigns,
|
|
23
45
|
logout,
|
|
24
46
|
newBadge,
|
|
25
47
|
newCard,
|
|
26
48
|
openBrowser,
|
|
27
|
-
packSheet,
|
|
28
|
-
packageJson,
|
|
29
|
-
placeNear,
|
|
30
|
-
prepareSheetPrint,
|
|
31
49
|
readBadge,
|
|
32
|
-
readPackageJson,
|
|
33
|
-
readPackageZip,
|
|
34
50
|
readSubmission,
|
|
35
51
|
removeBadgeLayer,
|
|
36
52
|
renderBadge,
|
|
53
|
+
renderDraftFace,
|
|
37
54
|
setBadgeLayer,
|
|
38
|
-
sheetPrintPdf,
|
|
39
|
-
sheetSizes,
|
|
40
|
-
sheetSpec,
|
|
41
|
-
sheetTypeIds,
|
|
42
|
-
sheetTypes,
|
|
43
55
|
side,
|
|
44
|
-
skills,
|
|
45
56
|
startStudio,
|
|
46
57
|
stickerCut,
|
|
47
58
|
stickerCutSvg,
|
|
48
|
-
stickerMaterialSchema,
|
|
49
|
-
stickerSheetSchema,
|
|
50
59
|
stickerStyles,
|
|
51
60
|
studioRequest,
|
|
52
61
|
submitDesign,
|
|
53
62
|
summarizeBadgeLayers,
|
|
54
|
-
svgNodeSchema,
|
|
55
|
-
svgNodeXml,
|
|
56
|
-
version,
|
|
57
63
|
writeAtomic
|
|
58
|
-
} from "./chunk-
|
|
64
|
+
} from "./chunk-YB2YJCBS.js";
|
|
65
|
+
import {
|
|
66
|
+
PACKAGE_LIMIT,
|
|
67
|
+
badgeDesignObjectSchema,
|
|
68
|
+
badgeDesignSchema,
|
|
69
|
+
checkSubmittedSvg,
|
|
70
|
+
defaultStickerMaterial,
|
|
71
|
+
designProfile,
|
|
72
|
+
draftFromPackage,
|
|
73
|
+
hashBytes,
|
|
74
|
+
jsonBytes,
|
|
75
|
+
kitDocumentSchema,
|
|
76
|
+
packageJson,
|
|
77
|
+
readPackageJson,
|
|
78
|
+
readPackageZip,
|
|
79
|
+
stickerMaterialSchema,
|
|
80
|
+
svgNodeSchema,
|
|
81
|
+
svgNodeXml
|
|
82
|
+
} from "./chunk-DLTVENEP.js";
|
|
59
83
|
|
|
60
84
|
// src/cli.ts
|
|
61
|
-
import { spawnSync as
|
|
62
|
-
import {
|
|
63
|
-
import {
|
|
64
|
-
import {
|
|
85
|
+
import { spawnSync as spawnSync3 } from "child_process";
|
|
86
|
+
import { randomUUID as randomUUID3 } from "crypto";
|
|
87
|
+
import { constants as constants4 } from "fs";
|
|
88
|
+
import { access, lstat as lstat4, open as open4, readFile as readFile8 } from "fs/promises";
|
|
89
|
+
import { dirname as dirname5, extname as extname4, resolve as resolve8 } from "path";
|
|
65
90
|
import { parseArgs } from "util";
|
|
66
|
-
import
|
|
91
|
+
import sharp9 from "sharp";
|
|
92
|
+
import { ZodError as ZodError2 } from "zod";
|
|
67
93
|
import { zodToJsonSchema } from "zod-to-json-schema";
|
|
68
94
|
|
|
95
|
+
// src/brick.ts
|
|
96
|
+
import { existsSync, readFileSync } from "fs";
|
|
97
|
+
import { readFile } from "fs/promises";
|
|
98
|
+
import { join, resolve } from "path";
|
|
99
|
+
|
|
100
|
+
// ../bricks/dist/index.js
|
|
101
|
+
var GRID_MM = 42;
|
|
102
|
+
var GRIDFINITY_UNIT_MM = 10;
|
|
103
|
+
var CELL_UNITS = GRID_MM / GRIDFINITY_UNIT_MM;
|
|
104
|
+
var HEIGHT_UNIT_MM = 7;
|
|
105
|
+
var BASE_PROFILE = [
|
|
106
|
+
[2.95, 0],
|
|
107
|
+
[2.15, 0.8],
|
|
108
|
+
[2.15, 2.6],
|
|
109
|
+
[0, 4.75]
|
|
110
|
+
];
|
|
111
|
+
var BASE_TOP_MM = 41.5;
|
|
112
|
+
var BASE_TOP_RADIUS_MM = 3.75;
|
|
113
|
+
var GAP_MM = 0.25;
|
|
114
|
+
var WALL_MM = 0.95;
|
|
115
|
+
var FUSE_MM = 0.5;
|
|
116
|
+
var CORNER_SEGMENTS = 6;
|
|
117
|
+
function rounded(cx, cz, w, d, r) {
|
|
118
|
+
const out = [];
|
|
119
|
+
const corners = [
|
|
120
|
+
[cx + w / 2 - r, cz + d / 2 - r, 0],
|
|
121
|
+
[cx - w / 2 + r, cz + d / 2 - r, 90],
|
|
122
|
+
[cx - w / 2 + r, cz - d / 2 + r, 180],
|
|
123
|
+
[cx + w / 2 - r, cz - d / 2 + r, 270]
|
|
124
|
+
];
|
|
125
|
+
for (const [x, z5, start] of corners)
|
|
126
|
+
for (let k = 0; k <= CORNER_SEGMENTS; k++) {
|
|
127
|
+
const t = (start + 90 * k / CORNER_SEGMENTS) * Math.PI / 180;
|
|
128
|
+
out.push([x + r * Math.cos(t), z5 + r * Math.sin(t)]);
|
|
129
|
+
}
|
|
130
|
+
return out;
|
|
131
|
+
}
|
|
132
|
+
function squareFor(ring, cx, cz, half) {
|
|
133
|
+
return ring.map(([x, z5]) => {
|
|
134
|
+
const dx = x - cx;
|
|
135
|
+
const dz = z5 - cz;
|
|
136
|
+
const s = half / Math.max(Math.abs(dx), Math.abs(dz));
|
|
137
|
+
return [cx + dx * s, cz + dz * s];
|
|
138
|
+
});
|
|
139
|
+
}
|
|
140
|
+
var Mesh = class {
|
|
141
|
+
triangles = [];
|
|
142
|
+
/**
|
|
143
|
+
* Counter-clockwise seen from outside. Zero-area triangles along the knife
|
|
144
|
+
* edge between baseplate cells are kept: they stitch the seam closed.
|
|
145
|
+
*/
|
|
146
|
+
tri(a, b, c) {
|
|
147
|
+
this.triangles.push(...a, ...b, ...c);
|
|
148
|
+
}
|
|
149
|
+
/** Side wall between two rings; `outward` faces away from the ring centre. */
|
|
150
|
+
wall(lower, yl, upper, yu, outward, keep = () => true) {
|
|
151
|
+
for (let i = 0; i < lower.length; i++) {
|
|
152
|
+
if (!keep(i)) continue;
|
|
153
|
+
const j = (i + 1) % lower.length;
|
|
154
|
+
const a = [lower[i][0], yl, lower[i][1]];
|
|
155
|
+
const b = [lower[j][0], yl, lower[j][1]];
|
|
156
|
+
const c = [upper[j][0], yu, upper[j][1]];
|
|
157
|
+
const d = [upper[i][0], yu, upper[i][1]];
|
|
158
|
+
if (outward) {
|
|
159
|
+
this.tri(a, c, b);
|
|
160
|
+
this.tri(a, d, c);
|
|
161
|
+
} else {
|
|
162
|
+
this.tri(a, b, c);
|
|
163
|
+
this.tri(a, c, d);
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
/** Flat convex cap; `up` faces +Y. */
|
|
168
|
+
cap(ring, y, up) {
|
|
169
|
+
const [cx, cz] = ring.reduce(
|
|
170
|
+
([sx, sz], [x, z5]) => [sx + x / ring.length, sz + z5 / ring.length],
|
|
171
|
+
[0, 0]
|
|
172
|
+
);
|
|
173
|
+
for (let i = 0; i < ring.length; i++) {
|
|
174
|
+
const j = (i + 1) % ring.length;
|
|
175
|
+
const c = [cx, y, cz];
|
|
176
|
+
const a = [ring[i][0], y, ring[i][1]];
|
|
177
|
+
const b = [ring[j][0], y, ring[j][1]];
|
|
178
|
+
if (up) this.tri(c, b, a);
|
|
179
|
+
else this.tri(c, a, b);
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
/** Flat annulus between two stitched rings; `up` faces +Y. */
|
|
183
|
+
annulus(outer, inner, y, up) {
|
|
184
|
+
for (let i = 0; i < outer.length; i++) {
|
|
185
|
+
const j = (i + 1) % outer.length;
|
|
186
|
+
const a = [outer[i][0], y, outer[i][1]];
|
|
187
|
+
const b = [outer[j][0], y, outer[j][1]];
|
|
188
|
+
const c = [inner[j][0], y, inner[j][1]];
|
|
189
|
+
const d = [inner[i][0], y, inner[i][1]];
|
|
190
|
+
if (up) {
|
|
191
|
+
this.tri(a, c, b);
|
|
192
|
+
this.tri(a, d, c);
|
|
193
|
+
} else {
|
|
194
|
+
this.tri(a, b, c);
|
|
195
|
+
this.tri(a, c, d);
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
};
|
|
200
|
+
var profileRing = (cx, cz, size, radius, inset) => rounded(cx, cz, size - 2 * inset, size - 2 * inset, Math.max(0.01, radius - inset));
|
|
201
|
+
var toUnits = (mm) => mm.map((v) => v / GRIDFINITY_UNIT_MM);
|
|
202
|
+
function part(id, name, mesh, connectors, height, w, d) {
|
|
203
|
+
return {
|
|
204
|
+
id,
|
|
205
|
+
name,
|
|
206
|
+
connectors,
|
|
207
|
+
triangles: toUnits(mesh.triangles),
|
|
208
|
+
colors: new Array(mesh.triangles.length / 9).fill(16),
|
|
209
|
+
body: {
|
|
210
|
+
min: [-w / 2 * CELL_UNITS, 0, -d / 2 * CELL_UNITS],
|
|
211
|
+
max: [w / 2 * CELL_UNITS, height / GRIDFINITY_UNIT_MM, d / 2 * CELL_UNITS]
|
|
212
|
+
}
|
|
213
|
+
};
|
|
214
|
+
}
|
|
215
|
+
var cellCentres = (w, d) => Array.from({ length: w * d }, (_, n) => [n % w - (w - 1) / 2, Math.floor(n / w) - (d - 1) / 2]);
|
|
216
|
+
function gridfinityBin(w, d, h) {
|
|
217
|
+
if (![w, d, h].every((n) => Number.isInteger(n) && n >= 1))
|
|
218
|
+
throw new Error("Bin size must be whole cells and height units, at least 1.");
|
|
219
|
+
const mesh = new Mesh();
|
|
220
|
+
const top = h * HEIGHT_UNIT_MM;
|
|
221
|
+
for (const [ux, uz] of cellCentres(w, d)) {
|
|
222
|
+
const [cx, cz] = [ux * GRID_MM, uz * GRID_MM];
|
|
223
|
+
const rings = BASE_PROFILE.map(
|
|
224
|
+
([inset]) => profileRing(cx, cz, BASE_TOP_MM, BASE_TOP_RADIUS_MM, inset)
|
|
225
|
+
);
|
|
226
|
+
mesh.cap(rings[0], 0, false);
|
|
227
|
+
for (let k = 0; k + 1 < rings.length; k++)
|
|
228
|
+
mesh.wall(rings[k], BASE_PROFILE[k][1], rings[k + 1], BASE_PROFILE[k + 1][1], true);
|
|
229
|
+
const crown = BASE_PROFILE[BASE_PROFILE.length - 1][1];
|
|
230
|
+
const last = rings[rings.length - 1];
|
|
231
|
+
mesh.wall(last, crown, last, crown + FUSE_MM, true);
|
|
232
|
+
mesh.cap(last, crown + FUSE_MM, true);
|
|
233
|
+
}
|
|
234
|
+
const outerW = w * GRID_MM - 2 * GAP_MM;
|
|
235
|
+
const outerD = d * GRID_MM - 2 * GAP_MM;
|
|
236
|
+
const outer = rounded(0, 0, outerW, outerD, BASE_TOP_RADIUS_MM);
|
|
237
|
+
const inner = rounded(
|
|
238
|
+
0,
|
|
239
|
+
0,
|
|
240
|
+
outerW - 2 * WALL_MM,
|
|
241
|
+
outerD - 2 * WALL_MM,
|
|
242
|
+
BASE_TOP_RADIUS_MM - WALL_MM
|
|
243
|
+
);
|
|
244
|
+
const base = BASE_PROFILE[BASE_PROFILE.length - 1][1];
|
|
245
|
+
mesh.cap(outer, base, false);
|
|
246
|
+
mesh.wall(outer, base, outer, top, true);
|
|
247
|
+
mesh.annulus(outer, inner, top, true);
|
|
248
|
+
mesh.wall(inner, HEIGHT_UNIT_MM, inner, top, false);
|
|
249
|
+
mesh.cap(inner, HEIGHT_UNIT_MM, true);
|
|
250
|
+
const feet = cellCentres(w, d).map(
|
|
251
|
+
([x, z5]) => ({
|
|
252
|
+
kind: "foot",
|
|
253
|
+
p: [x * CELL_UNITS, 0, z5 * CELL_UNITS],
|
|
254
|
+
a: [0, -1, 0]
|
|
255
|
+
})
|
|
256
|
+
);
|
|
257
|
+
return part(`bin-${w}x${d}x${h}`, `Gridfinity bin ${w} x ${d} x ${h}`, mesh, feet, top, w, d);
|
|
258
|
+
}
|
|
259
|
+
function gridfinityBaseplate(w, d) {
|
|
260
|
+
if (![w, d].every((n) => Number.isInteger(n) && n >= 1))
|
|
261
|
+
throw new Error("Baseplate size must be whole cells, at least 1.");
|
|
262
|
+
const mesh = new Mesh();
|
|
263
|
+
const height = BASE_PROFILE[BASE_PROFILE.length - 1][1];
|
|
264
|
+
for (const [ux, uz] of cellCentres(w, d)) {
|
|
265
|
+
const [cx, cz] = [ux * GRID_MM, uz * GRID_MM];
|
|
266
|
+
const pocket = BASE_PROFILE.map(
|
|
267
|
+
([inset]) => profileRing(cx, cz, BASE_TOP_MM + 2 * GAP_MM, BASE_TOP_RADIUS_MM + GAP_MM, inset)
|
|
268
|
+
);
|
|
269
|
+
const square = squareFor(pocket[0], cx, cz, GRID_MM / 2);
|
|
270
|
+
const edge = (i) => {
|
|
271
|
+
const j = (i + 1) % square.length;
|
|
272
|
+
const mx = (square[i][0] + square[j][0]) / 2;
|
|
273
|
+
const mz = (square[i][1] + square[j][1]) / 2;
|
|
274
|
+
return Math.abs(Math.abs(mx) - w * GRID_MM / 2) < 1e-6 || Math.abs(Math.abs(mz) - d * GRID_MM / 2) < 1e-6;
|
|
275
|
+
};
|
|
276
|
+
mesh.wall(square, 0, square, height, true, edge);
|
|
277
|
+
mesh.annulus(square, pocket[pocket.length - 1], height, true);
|
|
278
|
+
mesh.annulus(square, pocket[0], 0, false);
|
|
279
|
+
for (let k = 0; k + 1 < pocket.length; k++)
|
|
280
|
+
mesh.wall(pocket[k], BASE_PROFILE[k][1], pocket[k + 1], BASE_PROFILE[k + 1][1], false);
|
|
281
|
+
}
|
|
282
|
+
const pockets = cellCentres(w, d).map(
|
|
283
|
+
([x, z5]) => ({
|
|
284
|
+
kind: "pocket",
|
|
285
|
+
p: [x * CELL_UNITS, 0, z5 * CELL_UNITS],
|
|
286
|
+
a: [0, 1, 0]
|
|
287
|
+
})
|
|
288
|
+
);
|
|
289
|
+
return part(
|
|
290
|
+
`baseplate-${w}x${d}`,
|
|
291
|
+
`Gridfinity baseplate ${w} x ${d}`,
|
|
292
|
+
mesh,
|
|
293
|
+
pockets,
|
|
294
|
+
height,
|
|
295
|
+
w,
|
|
296
|
+
d
|
|
297
|
+
);
|
|
298
|
+
}
|
|
299
|
+
var IDENTITY = [1, 0, 0, 0, 1, 0, 0, 0, 1];
|
|
300
|
+
var add = (a, b) => [a[0] + b[0], a[1] + b[1], a[2] + b[2]];
|
|
301
|
+
var sub = (a, b) => [a[0] - b[0], a[1] - b[1], a[2] - b[2]];
|
|
302
|
+
var scale = (a, s) => [a[0] * s, a[1] * s, a[2] * s];
|
|
303
|
+
var dot = (a, b) => a[0] * b[0] + a[1] * b[1] + a[2] * b[2];
|
|
304
|
+
var cross = (a, b) => [
|
|
305
|
+
a[1] * b[2] - a[2] * b[1],
|
|
306
|
+
a[2] * b[0] - a[0] * b[2],
|
|
307
|
+
a[0] * b[1] - a[1] * b[0]
|
|
308
|
+
];
|
|
309
|
+
var length = (a) => Math.hypot(a[0], a[1], a[2]);
|
|
310
|
+
function apply(m, v) {
|
|
311
|
+
return [
|
|
312
|
+
m[0] * v[0] + m[1] * v[1] + m[2] * v[2],
|
|
313
|
+
m[3] * v[0] + m[4] * v[1] + m[5] * v[2],
|
|
314
|
+
m[6] * v[0] + m[7] * v[1] + m[8] * v[2]
|
|
315
|
+
];
|
|
316
|
+
}
|
|
317
|
+
function mul(a, b) {
|
|
318
|
+
const r = new Array(9).fill(0);
|
|
319
|
+
for (let i = 0; i < 3; i++)
|
|
320
|
+
for (let j = 0; j < 3; j++)
|
|
321
|
+
for (let k = 0; k < 3; k++) r[i * 3 + j] += a[i * 3 + k] * b[k * 3 + j];
|
|
322
|
+
return r;
|
|
323
|
+
}
|
|
324
|
+
var transpose = (m) => [m[0], m[3], m[6], m[1], m[4], m[7], m[2], m[5], m[8]];
|
|
325
|
+
var AXIS_ROTATIONS = (() => {
|
|
326
|
+
const axes = [
|
|
327
|
+
[1, 0, 0],
|
|
328
|
+
[-1, 0, 0],
|
|
329
|
+
[0, 1, 0],
|
|
330
|
+
[0, -1, 0],
|
|
331
|
+
[0, 0, 1],
|
|
332
|
+
[0, 0, -1]
|
|
333
|
+
];
|
|
334
|
+
const out = [];
|
|
335
|
+
for (const x of axes)
|
|
336
|
+
for (const y of axes) {
|
|
337
|
+
if (dot(x, y) !== 0) continue;
|
|
338
|
+
const z5 = cross(x, y);
|
|
339
|
+
out.push([x[0], y[0], z5[0], x[1], y[1], z5[1], x[2], y[2], z5[2]]);
|
|
340
|
+
}
|
|
341
|
+
return out;
|
|
342
|
+
})();
|
|
343
|
+
var CELL = 0.1;
|
|
344
|
+
var index = (v, i, j, k) => i < 0 || j < 0 || k < 0 || i >= v.dims[0] || j >= v.dims[1] || k >= v.dims[2] ? -1 : (k * v.dims[1] + j) * v.dims[0] + i;
|
|
345
|
+
function cellAt(v, p, field = "solid") {
|
|
346
|
+
const n = index(
|
|
347
|
+
v,
|
|
348
|
+
Math.floor((p[0] - v.origin[0]) / CELL),
|
|
349
|
+
Math.floor((p[1] - v.origin[1]) / CELL),
|
|
350
|
+
Math.floor((p[2] - v.origin[2]) / CELL)
|
|
351
|
+
);
|
|
352
|
+
return n >= 0 && v[field][n] === 1;
|
|
353
|
+
}
|
|
354
|
+
function voxelize(triangles, min, max) {
|
|
355
|
+
const origin = [min[0] - CELL, min[1] - CELL, min[2] - CELL];
|
|
356
|
+
const dims = [0, 1, 2].map(
|
|
357
|
+
(k) => Math.max(1, Math.ceil((max[k] - origin[k]) / CELL) + 1)
|
|
358
|
+
);
|
|
359
|
+
const total = dims[0] * dims[1] * dims[2];
|
|
360
|
+
const votes = new Uint8Array(total);
|
|
361
|
+
const v = { origin, dims, solid: new Uint8Array(total), interior: new Uint8Array(total) };
|
|
362
|
+
for (let axis = 0; axis < 3; axis++) {
|
|
363
|
+
const u = (axis + 1) % 3;
|
|
364
|
+
const w = (axis + 2) % 3;
|
|
365
|
+
const hits = /* @__PURE__ */ new Map();
|
|
366
|
+
for (let t = 0; t < triangles.length; t += 9) {
|
|
367
|
+
const a = [triangles[t], triangles[t + 1], triangles[t + 2]];
|
|
368
|
+
const b = [triangles[t + 3], triangles[t + 4], triangles[t + 5]];
|
|
369
|
+
const c = [triangles[t + 6], triangles[t + 7], triangles[t + 8]];
|
|
370
|
+
const area = (b[u] - a[u]) * (c[w] - a[w]) - (c[u] - a[u]) * (b[w] - a[w]);
|
|
371
|
+
if (Math.abs(area) < 1e-12) continue;
|
|
372
|
+
const lo = (k) => Math.min(a[k], b[k], c[k]);
|
|
373
|
+
const hi = (k) => Math.max(a[k], b[k], c[k]);
|
|
374
|
+
const iu0 = Math.max(0, Math.floor((lo(u) - origin[u]) / CELL - 0.5));
|
|
375
|
+
const iu1 = Math.min(dims[u] - 1, Math.ceil((hi(u) - origin[u]) / CELL - 0.5));
|
|
376
|
+
const iw0 = Math.max(0, Math.floor((lo(w) - origin[w]) / CELL - 0.5));
|
|
377
|
+
const iw1 = Math.min(dims[w] - 1, Math.ceil((hi(w) - origin[w]) / CELL - 0.5));
|
|
378
|
+
for (let iu = iu0; iu <= iu1; iu++)
|
|
379
|
+
for (let iw = iw0; iw <= iw1; iw++) {
|
|
380
|
+
const pu = origin[u] + (iu + 0.5) * CELL + 13e-6;
|
|
381
|
+
const pw = origin[w] + (iw + 0.5) * CELL + 29e-6;
|
|
382
|
+
const l0 = ((b[u] - pu) * (c[w] - pw) - (c[u] - pu) * (b[w] - pw)) / area;
|
|
383
|
+
const l1 = ((c[u] - pu) * (a[w] - pw) - (a[u] - pu) * (c[w] - pw)) / area;
|
|
384
|
+
const l2 = 1 - l0 - l1;
|
|
385
|
+
if (l0 < 0 || l1 < 0 || l2 < 0) continue;
|
|
386
|
+
const key2 = iu * dims[w] + iw;
|
|
387
|
+
const list = hits.get(key2) ?? [];
|
|
388
|
+
list.push(l0 * a[axis] + l1 * b[axis] + l2 * c[axis]);
|
|
389
|
+
hits.set(key2, list);
|
|
390
|
+
}
|
|
391
|
+
}
|
|
392
|
+
for (const [key2, list] of hits) {
|
|
393
|
+
list.sort((x, y) => x - y);
|
|
394
|
+
const iu = Math.floor(key2 / dims[w]);
|
|
395
|
+
const iw = key2 % dims[w];
|
|
396
|
+
const ijk = [0, 0, 0];
|
|
397
|
+
ijk[u] = iu;
|
|
398
|
+
ijk[w] = iw;
|
|
399
|
+
let before = 0;
|
|
400
|
+
for (let ia = 0; ia < dims[axis]; ia++) {
|
|
401
|
+
const c = origin[axis] + (ia + 0.5) * CELL;
|
|
402
|
+
while (before < list.length && list[before] < c) before++;
|
|
403
|
+
ijk[axis] = ia;
|
|
404
|
+
const n = index(v, ijk[0], ijk[1], ijk[2]);
|
|
405
|
+
votes[n] += before % 2 + (list.length - before) % 2;
|
|
406
|
+
}
|
|
407
|
+
}
|
|
408
|
+
}
|
|
409
|
+
for (let n = 0; n < total; n++) v.solid[n] = votes[n] >= 4 ? 1 : 0;
|
|
410
|
+
for (let k = 0; k < dims[2]; k++)
|
|
411
|
+
for (let j = 0; j < dims[1]; j++)
|
|
412
|
+
for (let i = 0; i < dims[0]; i++) {
|
|
413
|
+
const n = index(v, i, j, k);
|
|
414
|
+
if (!v.solid[n]) continue;
|
|
415
|
+
const around = [
|
|
416
|
+
[1, 0, 0],
|
|
417
|
+
[-1, 0, 0],
|
|
418
|
+
[0, 1, 0],
|
|
419
|
+
[0, -1, 0],
|
|
420
|
+
[0, 0, 1],
|
|
421
|
+
[0, 0, -1]
|
|
422
|
+
].every(([di, dj, dk]) => v.solid[index(v, i + di, j + dj, k + dk)] === 1);
|
|
423
|
+
v.interior[n] = around ? 1 : 0;
|
|
424
|
+
}
|
|
425
|
+
return v;
|
|
426
|
+
}
|
|
427
|
+
function partOf(lib, item) {
|
|
428
|
+
const part2 = lib[item.part];
|
|
429
|
+
if (!part2) throw new Error(`Unknown part: ${item.part}`);
|
|
430
|
+
return part2;
|
|
431
|
+
}
|
|
432
|
+
function worldConnectors(lib, item) {
|
|
433
|
+
return partOf(lib, item).connectors.map((c) => ({
|
|
434
|
+
kind: c.kind,
|
|
435
|
+
p: add(apply(item.rotation, c.p), item.position),
|
|
436
|
+
a: apply(item.rotation, c.a),
|
|
437
|
+
len: c.len
|
|
438
|
+
}));
|
|
439
|
+
}
|
|
440
|
+
function worldBody(lib, item) {
|
|
441
|
+
const { min, max } = partOf(lib, item).body;
|
|
442
|
+
const lo = [Infinity, Infinity, Infinity];
|
|
443
|
+
const hi = [-Infinity, -Infinity, -Infinity];
|
|
444
|
+
for (const x of [min[0], max[0]])
|
|
445
|
+
for (const y of [min[1], max[1]])
|
|
446
|
+
for (const z5 of [min[2], max[2]]) {
|
|
447
|
+
const p = add(apply(item.rotation, [x, y, z5]), item.position);
|
|
448
|
+
for (let k = 0; k < 3; k++) {
|
|
449
|
+
lo[k] = Math.min(lo[k], p[k]);
|
|
450
|
+
hi[k] = Math.max(hi[k], p[k]);
|
|
451
|
+
}
|
|
452
|
+
}
|
|
453
|
+
return { min: lo, max: hi };
|
|
454
|
+
}
|
|
455
|
+
function pointMates(x, y, adapter) {
|
|
456
|
+
return length(sub(x.p, y.p)) <= adapter.tolerance && dot(x.a, y.a) <= -0.99;
|
|
457
|
+
}
|
|
458
|
+
function axialMates(x, y, adapter) {
|
|
459
|
+
if (length(cross(x.a, y.a)) > 0.01) return false;
|
|
460
|
+
const d = sub(y.p, x.p);
|
|
461
|
+
const along = dot(d, x.a);
|
|
462
|
+
if (length(sub(d, scale(x.a, along))) > adapter.tolerance) return false;
|
|
463
|
+
const hx = (x.len ?? 0) / 2;
|
|
464
|
+
const hy = (y.len ?? 0) / 2;
|
|
465
|
+
return Math.min(hx, along + hy) - Math.max(-hx, along - hy) >= adapter.minEngagement;
|
|
466
|
+
}
|
|
467
|
+
function contacts(model, lib, adapter) {
|
|
468
|
+
const world = model.items.map((it) => worldConnectors(lib, it));
|
|
469
|
+
const out = [];
|
|
470
|
+
for (let a = 0; a < world.length; a++)
|
|
471
|
+
for (let b = a + 1; b < world.length; b++) {
|
|
472
|
+
const kinds = /* @__PURE__ */ new Map();
|
|
473
|
+
for (const x of world[a])
|
|
474
|
+
for (const y of world[b]) {
|
|
475
|
+
if (!adapter.fits[x.kind]?.includes(y.kind)) continue;
|
|
476
|
+
const axial = x.len !== void 0 && y.len !== void 0;
|
|
477
|
+
if (axial ? !axialMates(x, y, adapter) : !pointMates(x, y, adapter)) continue;
|
|
478
|
+
const kind = [x.kind, y.kind].sort().join("-");
|
|
479
|
+
kinds.set(kind, {
|
|
480
|
+
count: (kinds.get(kind)?.count ?? 0) + 1,
|
|
481
|
+
axis: axial ? x.a : void 0,
|
|
482
|
+
release: axial ? void 0 : x.a
|
|
483
|
+
});
|
|
484
|
+
}
|
|
485
|
+
for (const [kind, v] of kinds) out.push({ a, b, kind, ...v });
|
|
486
|
+
}
|
|
487
|
+
return out;
|
|
488
|
+
}
|
|
489
|
+
function overlapVolume(x, y, epsilon = 0.02) {
|
|
490
|
+
let v = 1;
|
|
491
|
+
for (let k = 0; k < 3; k++) {
|
|
492
|
+
const d = Math.min(x.max[k], y.max[k]) - Math.max(x.min[k], y.min[k]) - 2 * epsilon;
|
|
493
|
+
if (d <= 0) return 0;
|
|
494
|
+
v *= d;
|
|
495
|
+
}
|
|
496
|
+
return v;
|
|
497
|
+
}
|
|
498
|
+
function meshBounds(triangles) {
|
|
499
|
+
const min = [Infinity, Infinity, Infinity];
|
|
500
|
+
const max = [-Infinity, -Infinity, -Infinity];
|
|
501
|
+
for (let i = 0; i < triangles.length; i++) {
|
|
502
|
+
min[i % 3] = Math.min(min[i % 3], triangles[i]);
|
|
503
|
+
max[i % 3] = Math.max(max[i % 3], triangles[i]);
|
|
504
|
+
}
|
|
505
|
+
return { min, max };
|
|
506
|
+
}
|
|
507
|
+
function partVoxels(part2) {
|
|
508
|
+
if (!part2.voxels) {
|
|
509
|
+
const { min, max } = meshBounds(part2.triangles);
|
|
510
|
+
part2.voxels = voxelize(part2.triangles, min, max);
|
|
511
|
+
}
|
|
512
|
+
return part2.voxels;
|
|
513
|
+
}
|
|
514
|
+
function collisionCells(lib, x, y) {
|
|
515
|
+
let hits = 0;
|
|
516
|
+
for (const [a, b] of [
|
|
517
|
+
[x, y],
|
|
518
|
+
[y, x]
|
|
519
|
+
]) {
|
|
520
|
+
const va = partVoxels(partOf(lib, a));
|
|
521
|
+
const vb = partVoxels(partOf(lib, b));
|
|
522
|
+
for (let k = 0; k < va.dims[2]; k++)
|
|
523
|
+
for (let j = 0; j < va.dims[1]; j++)
|
|
524
|
+
for (let i = 0; i < va.dims[0]; i++) {
|
|
525
|
+
if (!va.interior[(k * va.dims[1] + j) * va.dims[0] + i]) continue;
|
|
526
|
+
const local = [
|
|
527
|
+
va.origin[0] + (i + 0.5) * CELL,
|
|
528
|
+
va.origin[1] + (j + 0.5) * CELL,
|
|
529
|
+
va.origin[2] + (k + 0.5) * CELL
|
|
530
|
+
];
|
|
531
|
+
const world = add(apply(a.rotation, local), a.position);
|
|
532
|
+
const r = b.rotation;
|
|
533
|
+
const d = sub(world, b.position);
|
|
534
|
+
const inB = [
|
|
535
|
+
r[0] * d[0] + r[3] * d[1] + r[6] * d[2],
|
|
536
|
+
r[1] * d[0] + r[4] * d[1] + r[7] * d[2],
|
|
537
|
+
r[2] * d[0] + r[5] * d[1] + r[8] * d[2]
|
|
538
|
+
];
|
|
539
|
+
if (cellAt(vb, inB)) hits++;
|
|
540
|
+
}
|
|
541
|
+
}
|
|
542
|
+
return hits;
|
|
543
|
+
}
|
|
544
|
+
var LDU = 20;
|
|
545
|
+
var FLIP = [1, 0, 0, 0, -1, 0, 0, 0, 1];
|
|
546
|
+
var normalizeName = (name) => name.trim().replace(/\\/g, "/").toLowerCase();
|
|
547
|
+
function toStuds(rotation, position) {
|
|
548
|
+
return {
|
|
549
|
+
rotation: mul(mul(FLIP, rotation), FLIP),
|
|
550
|
+
position: [position[0] / LDU, -position[1] / LDU, position[2] / LDU]
|
|
551
|
+
};
|
|
552
|
+
}
|
|
553
|
+
function readRef(f) {
|
|
554
|
+
const n = f.slice(2, 14).map(Number);
|
|
555
|
+
return {
|
|
556
|
+
color: Number(f[1]),
|
|
557
|
+
position: [n[0], n[1], n[2]],
|
|
558
|
+
rotation: [n[3], n[4], n[5], n[6], n[7], n[8], n[9], n[10], n[11]],
|
|
559
|
+
name: normalizeName(f.slice(14).join(" "))
|
|
560
|
+
};
|
|
561
|
+
}
|
|
562
|
+
function parsePart(name, resolve9, isJoint) {
|
|
563
|
+
const triangles = [];
|
|
564
|
+
const colors = [];
|
|
565
|
+
const edges = [];
|
|
566
|
+
const joints = [];
|
|
567
|
+
const min = [Infinity, Infinity, Infinity];
|
|
568
|
+
const max = [-Infinity, -Infinity, -Infinity];
|
|
569
|
+
const walk = (file, m, t, inJoint, depth, color) => {
|
|
570
|
+
const text = resolve9(file);
|
|
571
|
+
if (text === void 0) throw new Error(`LDraw file not found: ${file}`);
|
|
572
|
+
if (depth > 32) throw new Error(`LDraw nesting too deep at ${file}`);
|
|
573
|
+
for (const line of text.split(/\r?\n/)) {
|
|
574
|
+
const f = line.trim().split(/\s+/);
|
|
575
|
+
const type = f[0];
|
|
576
|
+
if (type === "1" && f.length >= 15) {
|
|
577
|
+
const ref = readRef(f);
|
|
578
|
+
const rotation = mul(m, ref.rotation);
|
|
579
|
+
const position = add(apply(m, ref.position), t);
|
|
580
|
+
const joint = !inJoint && isJoint(ref.name);
|
|
581
|
+
if (joint) joints.push({ name: ref.name, ...toStuds(rotation, position) });
|
|
582
|
+
const inherited = ref.color === 16 ? color : ref.color;
|
|
583
|
+
walk(ref.name, rotation, position, inJoint || joint, depth + 1, inherited);
|
|
584
|
+
} else if (type === "2" && f.length >= 8) {
|
|
585
|
+
const n = f.slice(2, 8).map(Number);
|
|
586
|
+
for (const k of [0, 3]) {
|
|
587
|
+
const w = add(apply(m, [n[k], n[k + 1], n[k + 2]]), t);
|
|
588
|
+
edges.push(w[0] / LDU, -w[1] / LDU, w[2] / LDU);
|
|
589
|
+
}
|
|
590
|
+
} else if (type === "3" || type === "4") {
|
|
591
|
+
const n = f.slice(2).map(Number);
|
|
592
|
+
const pts = [];
|
|
593
|
+
for (let i = 0; i < (type === "3" ? 3 : 4); i++) {
|
|
594
|
+
const w = add(apply(m, [n[i * 3], n[i * 3 + 1], n[i * 3 + 2]]), t);
|
|
595
|
+
pts.push([w[0] / LDU, -w[1] / LDU, w[2] / LDU]);
|
|
596
|
+
}
|
|
597
|
+
const tris = type === "3" ? [pts] : [
|
|
598
|
+
[pts[0], pts[1], pts[2]],
|
|
599
|
+
[pts[0], pts[2], pts[3]]
|
|
600
|
+
];
|
|
601
|
+
const c = Number(f[1]);
|
|
602
|
+
for (const tri of tris) {
|
|
603
|
+
for (const p of tri) triangles.push(p[0], p[1], p[2]);
|
|
604
|
+
colors.push(c === 16 ? color : c);
|
|
605
|
+
}
|
|
606
|
+
if (!inJoint)
|
|
607
|
+
for (const p of pts)
|
|
608
|
+
for (let k = 0; k < 3; k++) {
|
|
609
|
+
min[k] = Math.min(min[k], p[k]);
|
|
610
|
+
max[k] = Math.max(max[k], p[k]);
|
|
611
|
+
}
|
|
612
|
+
}
|
|
613
|
+
}
|
|
614
|
+
};
|
|
615
|
+
walk(normalizeName(name), IDENTITY, [0, 0, 0], false, 0, 16);
|
|
616
|
+
return { triangles, colors, edges, body: { min, max }, joints };
|
|
617
|
+
}
|
|
618
|
+
function parseModel(text) {
|
|
619
|
+
const items = [];
|
|
620
|
+
let step = 1;
|
|
621
|
+
for (const line of text.split(/\r?\n/)) {
|
|
622
|
+
const f = line.trim().split(/\s+/);
|
|
623
|
+
if (f[0] === "0" && f[1]?.toUpperCase() === "STEP") step++;
|
|
624
|
+
else if (f[0] === "1" && f.length >= 15) {
|
|
625
|
+
const ref = readRef(f);
|
|
626
|
+
items.push({
|
|
627
|
+
part: ref.name.replace(/\.dat$/, ""),
|
|
628
|
+
color: ref.color,
|
|
629
|
+
step,
|
|
630
|
+
...toStuds(ref.rotation, ref.position)
|
|
631
|
+
});
|
|
632
|
+
}
|
|
633
|
+
}
|
|
634
|
+
return { items };
|
|
635
|
+
}
|
|
636
|
+
function writeModel(model, title) {
|
|
637
|
+
const fmt = (n) => String(Math.round(n * 1e4) / 1e4 || 0);
|
|
638
|
+
const lines = [
|
|
639
|
+
`0 ${title}`,
|
|
640
|
+
`0 Name: ${title.toLowerCase().replace(/\W+/g, "-")}.ldr`,
|
|
641
|
+
"0 Author: Makeables"
|
|
642
|
+
];
|
|
643
|
+
let step;
|
|
644
|
+
for (const it of model.items) {
|
|
645
|
+
if (step !== void 0 && it.step !== step) lines.push("0 STEP");
|
|
646
|
+
step = it.step;
|
|
647
|
+
const r = mul(mul(FLIP, it.rotation), FLIP);
|
|
648
|
+
const p = [it.position[0] * LDU, -it.position[1] * LDU, it.position[2] * LDU];
|
|
649
|
+
lines.push(`1 ${it.color} ${[...p, ...r].map(fmt).join(" ")} ${it.part}.dat`);
|
|
650
|
+
}
|
|
651
|
+
return `${lines.join("\n")}
|
|
652
|
+
`;
|
|
653
|
+
}
|
|
654
|
+
var REACH = 0.45;
|
|
655
|
+
var DIAGONAL = 0.2;
|
|
656
|
+
var MIN_RUN = 0.4;
|
|
657
|
+
var SAMPLE = 0.05;
|
|
658
|
+
var ring8 = [0, 1, 2, 3, 4, 5, 6, 7].map((k) => [
|
|
659
|
+
Math.cos(k * Math.PI / 4),
|
|
660
|
+
Math.sin(k * Math.PI / 4)
|
|
661
|
+
]);
|
|
662
|
+
function technicHoles(voxels, body) {
|
|
663
|
+
const found = [];
|
|
664
|
+
for (let axis = 0; axis < 3; axis++) {
|
|
665
|
+
const u = (axis + 1) % 3;
|
|
666
|
+
const w = (axis + 2) % 3;
|
|
667
|
+
const at = (cu, cw, t, du = 0, dw = 0) => {
|
|
668
|
+
const p = [0, 0, 0];
|
|
669
|
+
p[axis] = t;
|
|
670
|
+
p[u] = cu + du;
|
|
671
|
+
p[w] = cw + dw;
|
|
672
|
+
return cellAt(voxels, p);
|
|
673
|
+
};
|
|
674
|
+
const from = (k) => Math.ceil((body.min[k] + 0.3) / SAMPLE - 1e-9) * SAMPLE;
|
|
675
|
+
for (let cu = from(u); cu <= body.max[u] - 0.3 + 1e-9; cu += SAMPLE)
|
|
676
|
+
for (let cw = from(w); cw <= body.max[w] - 0.3 + 1e-9; cw += SAMPLE) {
|
|
677
|
+
let run2 = 0;
|
|
678
|
+
let best = { start: 0, length: 0, cross: 0, open: false };
|
|
679
|
+
let start = 0;
|
|
680
|
+
let cross2 = 0;
|
|
681
|
+
for (let t = body.min[axis] + SAMPLE; t < body.max[axis]; t += 0.1) {
|
|
682
|
+
const hollow = [0, 0.12].every(
|
|
683
|
+
(r) => [
|
|
684
|
+
[1, 0],
|
|
685
|
+
[-1, 0],
|
|
686
|
+
[0, 1],
|
|
687
|
+
[0, -1]
|
|
688
|
+
].every(([a2, b]) => !at(cu, cw, t, a2 * r, b * r))
|
|
689
|
+
);
|
|
690
|
+
const walled = ring8.filter(([a2, b]) => {
|
|
691
|
+
for (let r = 0.15; r <= REACH + 1e-9; r += 0.05)
|
|
692
|
+
if (at(cu, cw, t, a2 * r, b * r)) return true;
|
|
693
|
+
return false;
|
|
694
|
+
}).length;
|
|
695
|
+
if (hollow && walled === 8) {
|
|
696
|
+
if (!run2) {
|
|
697
|
+
start = t;
|
|
698
|
+
cross2 = 0;
|
|
699
|
+
}
|
|
700
|
+
run2 += 0.1;
|
|
701
|
+
cross2 += ring8.filter((_, k) => k % 2).filter(([a2, b]) => at(cu, cw, t, a2 * DIAGONAL, b * DIAGONAL)).length;
|
|
702
|
+
if (run2 > best.length) best = { start, length: run2, cross: cross2, open: false };
|
|
703
|
+
} else run2 = 0;
|
|
704
|
+
}
|
|
705
|
+
if (best.length + 1e-9 < MIN_RUN) continue;
|
|
706
|
+
const clear = (t0, dt) => {
|
|
707
|
+
for (let t = t0; t > body.min[axis] - 0.1 && t < body.max[axis] + 0.1; t += dt)
|
|
708
|
+
if (at(cu, cw, t)) return false;
|
|
709
|
+
return true;
|
|
710
|
+
};
|
|
711
|
+
if (!clear(best.start - 0.1, -0.1) || !clear(best.start + best.length, 0.1)) continue;
|
|
712
|
+
const p = [0, 0, 0];
|
|
713
|
+
p[axis] = best.start - 0.05 + best.length / 2;
|
|
714
|
+
p[u] = cu;
|
|
715
|
+
p[w] = cw;
|
|
716
|
+
const a = [0, 0, 0];
|
|
717
|
+
a[axis] = 1;
|
|
718
|
+
const steps = best.length / 0.1;
|
|
719
|
+
found.push({
|
|
720
|
+
kind: best.cross / steps >= 3 ? "axlehole" : "pinhole",
|
|
721
|
+
p,
|
|
722
|
+
a,
|
|
723
|
+
len: best.length,
|
|
724
|
+
score: best.length
|
|
725
|
+
});
|
|
726
|
+
}
|
|
727
|
+
}
|
|
728
|
+
const holes = [];
|
|
729
|
+
const left = new Set(found);
|
|
730
|
+
const near = (x, y) => x.a.join() === y.a.join() && x.p.every((v, k) => x.a[k] === 1 || Math.abs(v - y.p[k]) <= SAMPLE + 1e-6);
|
|
731
|
+
for (const seed of found) {
|
|
732
|
+
if (!left.has(seed)) continue;
|
|
733
|
+
const group = [seed];
|
|
734
|
+
left.delete(seed);
|
|
735
|
+
for (let i = 0; i < group.length; i++)
|
|
736
|
+
for (const h of left)
|
|
737
|
+
if (near(group[i], h)) {
|
|
738
|
+
group.push(h);
|
|
739
|
+
left.delete(h);
|
|
740
|
+
}
|
|
741
|
+
const mean = [0, 1, 2].map((k) => group.reduce((s, h) => s + h.p[k], 0) / group.length);
|
|
742
|
+
const longest = group.reduce((x, y) => y.score > x.score ? y : x);
|
|
743
|
+
const crosses = group.filter((h) => h.kind === "axlehole").length;
|
|
744
|
+
holes.push({
|
|
745
|
+
kind: crosses * 2 > group.length ? "axlehole" : "pinhole",
|
|
746
|
+
p: mean.map((v) => Math.round(v / SAMPLE) * SAMPLE || 0),
|
|
747
|
+
a: seed.a,
|
|
748
|
+
len: longest.len
|
|
749
|
+
});
|
|
750
|
+
}
|
|
751
|
+
return holes;
|
|
752
|
+
}
|
|
753
|
+
var SHAFT = /^Technic (Axle\s+\d+(\.\d+)?$|Axle\s+\d+ with Stop|Pin\b|Friction Pin|Axle Pin)/i;
|
|
754
|
+
function technicShafts(name, triangles) {
|
|
755
|
+
if (!SHAFT.test(name) || /connector|joiner|hole|beam/i.test(name)) return [];
|
|
756
|
+
const { min, max } = meshBounds(triangles);
|
|
757
|
+
const size = [0, 1, 2].map((k) => max[k] - min[k]);
|
|
758
|
+
const axis = size.indexOf(Math.max(...size));
|
|
759
|
+
const a = [0, 0, 0];
|
|
760
|
+
a[axis] = 1;
|
|
761
|
+
const p = [0, 1, 2].map((k) => (min[k] + max[k]) / 2);
|
|
762
|
+
return [
|
|
763
|
+
{ kind: /pin/i.test(name) && !/axle pin/i.test(name) ? "pin" : "axle", p, a, len: size[axis] }
|
|
764
|
+
];
|
|
765
|
+
}
|
|
766
|
+
var STUD = /^(stud|stud2|stud2a|studa|stud26|stud6a?|stud9|stud10|stud13|stud15|stud17a?|studel|studh[lr]?|studp01|studxa?|stud2?-logo\d?)\.dat$/;
|
|
767
|
+
var FAMILY = {
|
|
768
|
+
"3001": "brick",
|
|
769
|
+
"3003": "brick",
|
|
770
|
+
"3004": "brick",
|
|
771
|
+
"3005": "brick",
|
|
772
|
+
"3010": "brick",
|
|
773
|
+
"3020": "plate",
|
|
774
|
+
"3021": "plate",
|
|
775
|
+
"3022": "plate",
|
|
776
|
+
"3023": "plate",
|
|
777
|
+
"3024": "plate",
|
|
778
|
+
"3710": "plate",
|
|
779
|
+
"3666": "plate",
|
|
780
|
+
"3460": "plate",
|
|
781
|
+
"3068b": "tile",
|
|
782
|
+
"3069b": "tile",
|
|
783
|
+
"3070b": "tile"
|
|
784
|
+
};
|
|
785
|
+
var lego = {
|
|
786
|
+
name: "lego",
|
|
787
|
+
fits: {
|
|
788
|
+
stud: ["socket"],
|
|
789
|
+
socket: ["stud"],
|
|
790
|
+
axle: ["axlehole", "pinhole"],
|
|
791
|
+
axlehole: ["axle"],
|
|
792
|
+
pin: ["pinhole"],
|
|
793
|
+
pinhole: ["pin", "axle"],
|
|
794
|
+
wheelpin: ["wheelhole"],
|
|
795
|
+
wheelhole: ["wheelpin"],
|
|
796
|
+
rim: ["tyre"],
|
|
797
|
+
tyre: ["rim"],
|
|
798
|
+
hinge: ["hinge"],
|
|
799
|
+
bar: ["clip", "barhole"],
|
|
800
|
+
clip: ["bar"],
|
|
801
|
+
barhole: ["bar"],
|
|
802
|
+
neck: ["neckhole"],
|
|
803
|
+
neckhole: ["neck"],
|
|
804
|
+
hatstud: ["hat"],
|
|
805
|
+
hat: ["hatstud"],
|
|
806
|
+
waist: ["waisthole"],
|
|
807
|
+
waisthole: ["waist"],
|
|
808
|
+
hip: ["leg"],
|
|
809
|
+
leg: ["hip"],
|
|
810
|
+
shoulder: ["shoulderhole"],
|
|
811
|
+
shoulderhole: ["shoulder"],
|
|
812
|
+
wrist: ["hand"],
|
|
813
|
+
hand: ["wrist"]
|
|
814
|
+
},
|
|
815
|
+
tolerance: 0.06,
|
|
816
|
+
minEngagement: 0.24,
|
|
817
|
+
family: (part2) => FAMILY[part2] ?? part2
|
|
818
|
+
};
|
|
819
|
+
var WRIST_RIGHT = [-0.25, -0.875, -0.5];
|
|
820
|
+
var WRIST_LEFT = [0.25, -0.875, -0.5];
|
|
821
|
+
var LEGO_CONNECTORS = {
|
|
822
|
+
// Plate 2 x 2 with 2 Wheel Pins; the pins hang below the plate, so its sockets are listed too.
|
|
823
|
+
"4600": [
|
|
824
|
+
...[-0.5, 0.5].flatMap(
|
|
825
|
+
(x) => [-0.5, 0.5].map((z5) => ({ kind: "socket", p: [x, -0.4, z5], a: [0, -1, 0] }))
|
|
826
|
+
),
|
|
827
|
+
{ kind: "wheelpin", p: [1.4, -0.25, 0], a: [1, 0, 0], len: 0.6 },
|
|
828
|
+
{ kind: "wheelpin", p: [-1.4, -0.25, 0], a: [1, 0, 0], len: 0.6 }
|
|
829
|
+
],
|
|
830
|
+
// Wheel Rim 6.4 x 8
|
|
831
|
+
"4624": [
|
|
832
|
+
{ kind: "wheelhole", p: [0, 0, 0], a: [0, 0, 1], len: 0.8 },
|
|
833
|
+
{ kind: "rim", p: [0, 0, 0], a: [0, 0, 1], len: 0.8 }
|
|
834
|
+
],
|
|
835
|
+
// Tyre 6/ 50 x 8 Offset Tread
|
|
836
|
+
"3641": [{ kind: "tyre", p: [0, 0, 0], a: [0, 0, 1], len: 0.8 }],
|
|
837
|
+
// Hinge 1 x 2 Base and Top: both pivot on the cylinder along x at 10 LDU below the origin.
|
|
838
|
+
"3937": [{ kind: "hinge", p: [0, -0.5, 0], a: [1, 0, 0], len: 2 }],
|
|
839
|
+
"3938": [{ kind: "hinge", p: [0, -0.5, 0], a: [1, 0, 0], len: 2 }],
|
|
840
|
+
// Plate 1 x 3 with 2 Studs Offset: the teeth underneath hold a stud at every half step,
|
|
841
|
+
// so a jumper also sits straight on another jumper's offset studs.
|
|
842
|
+
"34103": [-1, -0.5, 0, 0.5, 1].map(
|
|
843
|
+
(x) => ({ kind: "socket", p: [x, -0.4, 0], a: [0, -1, 0] })
|
|
844
|
+
),
|
|
845
|
+
// Bar 6L with Thick Stop: the bar runs the full height of the part.
|
|
846
|
+
"63965a": [{ kind: "bar", p: [0, 2.2, 0], a: [0, 1, 0], len: 6 }],
|
|
847
|
+
// Cone 1 x 1: hollow along its axis, so a bar goes in from the top.
|
|
848
|
+
"4589": [
|
|
849
|
+
{ kind: "barhole", p: [0, -0.5, 0], a: [0, 1, 0], len: 1.4 },
|
|
850
|
+
{ kind: "socket", p: [0, -1.2, 0], a: [0, -1, 0] }
|
|
851
|
+
],
|
|
852
|
+
// Tile 2 x 3 with Clips Horizontal: both C-clips share an axis along x.
|
|
853
|
+
"30350b": [{ kind: "clip", p: [0, -0.05, 2], a: [1, 0, 0], len: 2 }],
|
|
854
|
+
// Minifig, in the frame of each part as LDraw places a standard figure.
|
|
855
|
+
"973": [
|
|
856
|
+
{ kind: "neck", p: [0, 0, 0], a: [0, 1, 0] },
|
|
857
|
+
{ kind: "waist", p: [0, -1.6, 0], a: [0, -1, 0] },
|
|
858
|
+
{ kind: "shoulder", p: [-0.7776, -0.45, 0], a: [-1, 0, 0] },
|
|
859
|
+
{ kind: "shoulder", p: [0.7776, -0.45, 0], a: [1, 0, 0] }
|
|
860
|
+
],
|
|
861
|
+
"3626c": [
|
|
862
|
+
{ kind: "neckhole", p: [0, -1.2, 0], a: [0, -1, 0] },
|
|
863
|
+
{ kind: "hatstud", p: [0, 0, 0], a: [0, 1, 0] }
|
|
864
|
+
],
|
|
865
|
+
"3626cp7g": [
|
|
866
|
+
{ kind: "neckhole", p: [0, -1.2, 0], a: [0, -1, 0] },
|
|
867
|
+
{ kind: "hatstud", p: [0, 0, 0], a: [0, 1, 0] }
|
|
868
|
+
],
|
|
869
|
+
"86035": [{ kind: "hat", p: [0, 0, 0], a: [0, -1, 0] }],
|
|
870
|
+
"3815": [
|
|
871
|
+
{ kind: "waisthole", p: [0, 0, 0], a: [0, 1, 0] },
|
|
872
|
+
{ kind: "hip", p: [0, -0.6, 0], a: [0, -1, 0] },
|
|
873
|
+
{ kind: "hip", p: [0, -0.6, 0], a: [0, -1, 0] }
|
|
874
|
+
],
|
|
875
|
+
"3816": [
|
|
876
|
+
{ kind: "leg", p: [0, 0, 0], a: [0, 1, 0] },
|
|
877
|
+
{ kind: "socket", p: [-0.5, -1.4, 0], a: [0, -1, 0] }
|
|
878
|
+
],
|
|
879
|
+
"3817": [
|
|
880
|
+
{ kind: "leg", p: [0, 0, 0], a: [0, 1, 0] },
|
|
881
|
+
{ kind: "socket", p: [0.5, -1.4, 0], a: [0, -1, 0] }
|
|
882
|
+
],
|
|
883
|
+
"3818": [
|
|
884
|
+
{ kind: "shoulderhole", p: [0, 0, 0], a: [1, 0, 0] },
|
|
885
|
+
{ kind: "wrist", p: WRIST_RIGHT, a: [0, 0, -1] }
|
|
886
|
+
],
|
|
887
|
+
"3819": [
|
|
888
|
+
{ kind: "shoulderhole", p: [0, 0, 0], a: [-1, 0, 0] },
|
|
889
|
+
{ kind: "wrist", p: WRIST_LEFT, a: [0, 0, -1] }
|
|
890
|
+
],
|
|
891
|
+
// The hand's peg goes into the sleeve from the front.
|
|
892
|
+
"3820": [{ kind: "hand", p: [0, 0, 0], a: [0, 0, 1] }]
|
|
893
|
+
};
|
|
894
|
+
function technicConnectors(name, parsed, voxels) {
|
|
895
|
+
const shafts = technicShafts(name, parsed.triangles);
|
|
896
|
+
return shafts.length ? shafts : technicHoles(voxels, parsed.body);
|
|
897
|
+
}
|
|
898
|
+
var FLAT_TOP = 0.5;
|
|
899
|
+
function flatTop({ triangles, body }) {
|
|
900
|
+
let area = 0;
|
|
901
|
+
for (let t = 0; t < triangles.length; t += 9) {
|
|
902
|
+
const ys = [triangles[t + 1], triangles[t + 4], triangles[t + 7]];
|
|
903
|
+
if (ys.some((y) => Math.abs(y - body.max[1]) > 1e-3)) continue;
|
|
904
|
+
const ux = triangles[t + 3] - triangles[t];
|
|
905
|
+
const uz = triangles[t + 5] - triangles[t + 2];
|
|
906
|
+
const vx = triangles[t + 6] - triangles[t];
|
|
907
|
+
const vz = triangles[t + 8] - triangles[t + 2];
|
|
908
|
+
area += Math.abs(ux * vz - uz * vx) / 2;
|
|
909
|
+
}
|
|
910
|
+
const footprint = (body.max[0] - body.min[0]) * (body.max[2] - body.min[2]);
|
|
911
|
+
return footprint > 0 ? area / footprint : 0;
|
|
912
|
+
}
|
|
913
|
+
var WALLED_BOTTOM = 0.35;
|
|
914
|
+
var STUD_DEPTH = 0.3;
|
|
915
|
+
var lattice = (lo, hi, phase) => {
|
|
916
|
+
const out = [];
|
|
917
|
+
for (let x = Math.ceil(lo - phase) + phase; x < hi; x++)
|
|
918
|
+
if (x - lo > 0.25 && hi - x > 0.25) out.push(x);
|
|
919
|
+
return out;
|
|
920
|
+
};
|
|
921
|
+
function legoConnectors(parsed, voxels) {
|
|
922
|
+
const out = [];
|
|
923
|
+
for (const j of parsed.joints)
|
|
924
|
+
if (STUD.test(j.name))
|
|
925
|
+
out.push({ kind: "stud", p: j.position, a: apply(j.rotation, [0, 1, 0]) });
|
|
926
|
+
const { min, max } = parsed.body;
|
|
927
|
+
const up = out.find((c) => dot(c.a, [0, 1, 0]) > 0.99);
|
|
928
|
+
const phase = (k) => {
|
|
929
|
+
const v = (min[k] + 0.5) % 1;
|
|
930
|
+
return v < 0 ? v + 1 : v;
|
|
931
|
+
};
|
|
932
|
+
const y = min[1];
|
|
933
|
+
const rim = [];
|
|
934
|
+
for (let x = min[0] + CELL / 2; x < max[0]; x += CELL)
|
|
935
|
+
rim.push([x, y + CELL / 2, min[2] + CELL / 2], [x, y + CELL / 2, max[2] - CELL / 2]);
|
|
936
|
+
for (let z5 = min[2] + CELL / 2; z5 < max[2]; z5 += CELL)
|
|
937
|
+
rim.push([min[0] + CELL / 2, y + CELL / 2, z5], [max[0] - CELL / 2, y + CELL / 2, z5]);
|
|
938
|
+
const walled = rim.filter((p) => cellAt(voxels, p)).length >= WALLED_BOTTOM * rim.length;
|
|
939
|
+
if (!up && !walled && flatTop(parsed) < FLAT_TOP) return out;
|
|
940
|
+
for (const x of lattice(min[0], max[0], phase(0)))
|
|
941
|
+
for (const z5 of lattice(min[2], max[2], phase(2))) {
|
|
942
|
+
let hollow;
|
|
943
|
+
for (let h = y + CELL / 2; h < y + STUD_DEPTH && hollow === void 0; h += CELL)
|
|
944
|
+
if (!cellAt(voxels, [x, h, z5])) hollow = h;
|
|
945
|
+
if (hollow === void 0) continue;
|
|
946
|
+
let ceiling = false;
|
|
947
|
+
for (let h = hollow + CELL; h < max[1] && !ceiling; h += CELL)
|
|
948
|
+
ceiling = cellAt(voxels, [x, h, z5]);
|
|
949
|
+
if (ceiling) out.push({ kind: "socket", p: [x, y, z5], a: [0, -1, 0] });
|
|
950
|
+
}
|
|
951
|
+
return out;
|
|
952
|
+
}
|
|
953
|
+
function loadLegoPart(id, resolve9, opts = {}) {
|
|
954
|
+
const parsed = parsePart(`${id}.dat`, resolve9, (p) => STUD.test(p));
|
|
955
|
+
const bounds = meshBounds(parsed.triangles);
|
|
956
|
+
const voxels = voxelize(parsed.triangles, bounds.min, bounds.max);
|
|
957
|
+
return {
|
|
958
|
+
id,
|
|
959
|
+
name: opts.name ?? id,
|
|
960
|
+
voxels,
|
|
961
|
+
connectors: [
|
|
962
|
+
// Listed sockets replace inferred ones, for parts whose underside the solid misreads.
|
|
963
|
+
...legoConnectors(parsed, voxels).filter(
|
|
964
|
+
(c) => c.kind !== "socket" || !LEGO_CONNECTORS[id]?.some((l) => l.kind === "socket")
|
|
965
|
+
),
|
|
966
|
+
...opts.connectors ?? LEGO_CONNECTORS[id] ?? technicConnectors(opts.name ?? "", parsed, voxels)
|
|
967
|
+
],
|
|
968
|
+
body: parsed.body,
|
|
969
|
+
triangles: parsed.triangles,
|
|
970
|
+
colors: parsed.colors,
|
|
971
|
+
edges: parsed.edges
|
|
972
|
+
};
|
|
973
|
+
}
|
|
974
|
+
function libraryResolver(read, root) {
|
|
975
|
+
return (name) => {
|
|
976
|
+
for (const dir of ["parts", "p", "parts/s"]) {
|
|
977
|
+
const text = read(`${root}/${dir}/${name}`);
|
|
978
|
+
if (text !== void 0) return text;
|
|
979
|
+
}
|
|
980
|
+
return void 0;
|
|
981
|
+
};
|
|
982
|
+
}
|
|
983
|
+
var DEFAULT_PALETTE = {
|
|
984
|
+
0: "#1B2A34",
|
|
985
|
+
1: "#1E5AA8",
|
|
986
|
+
4: "#B40000",
|
|
987
|
+
14: "#FAC80A",
|
|
988
|
+
15: "#F4F4F4",
|
|
989
|
+
71: "#969696",
|
|
990
|
+
72: "#646464"
|
|
991
|
+
};
|
|
992
|
+
function parsePalette(ldconfig) {
|
|
993
|
+
const out = {};
|
|
994
|
+
for (const m of ldconfig.matchAll(/^0 !COLOUR \S+\s+CODE\s+(\d+)\s+VALUE\s+(#[0-9A-Fa-f]{6})/gm))
|
|
995
|
+
out[Number(m[1])] = m[2];
|
|
996
|
+
return out;
|
|
997
|
+
}
|
|
998
|
+
var LIGHT = (() => {
|
|
999
|
+
const v = [0.4, 0.85, 0.35];
|
|
1000
|
+
return scale(v, 1 / length(v));
|
|
1001
|
+
})();
|
|
1002
|
+
var rgb = (hex) => {
|
|
1003
|
+
const n = Number.parseInt(hex.slice(1), 16);
|
|
1004
|
+
return [n >> 16 & 255, n >> 8 & 255, n & 255];
|
|
1005
|
+
};
|
|
1006
|
+
function rasterize(model, lib, opts = {}) {
|
|
1007
|
+
const ss = opts.supersample ?? 2;
|
|
1008
|
+
const W2 = (opts.width ?? 800) * ss;
|
|
1009
|
+
const H2 = (opts.height ?? 600) * ss;
|
|
1010
|
+
const yaw = opts.yaw ?? Math.PI / 4;
|
|
1011
|
+
const pitch = opts.pitch ?? Math.PI / 6;
|
|
1012
|
+
const palette = { ...DEFAULT_PALETTE, ...opts.palette };
|
|
1013
|
+
const highlight = new Set(opts.highlight ?? []);
|
|
1014
|
+
const background = rgb(opts.background ?? "#ffffff");
|
|
1015
|
+
const view = [
|
|
1016
|
+
Math.sin(yaw) * Math.cos(pitch),
|
|
1017
|
+
Math.sin(pitch),
|
|
1018
|
+
Math.cos(yaw) * Math.cos(pitch)
|
|
1019
|
+
];
|
|
1020
|
+
const right = [Math.cos(yaw), 0, -Math.sin(yaw)];
|
|
1021
|
+
const up = cross(view, right);
|
|
1022
|
+
const shown = model.items.map((item, i) => ({ item, i })).filter(({ item }) => opts.maxStep === void 0 || (item.step ?? 0) <= opts.maxStep);
|
|
1023
|
+
const bodies = shown.map(({ item }) => worldBody(lib, item));
|
|
1024
|
+
const center = [0, 0, 0];
|
|
1025
|
+
for (const b of bodies) for (let k = 0; k < 3; k++) center[k] += (b.min[k] + b.max[k]) / 2;
|
|
1026
|
+
if (bodies.length) for (let k = 0; k < 3; k++) center[k] /= bodies.length;
|
|
1027
|
+
const tris = [];
|
|
1028
|
+
const lines = [];
|
|
1029
|
+
let minX = Infinity;
|
|
1030
|
+
let minY = Infinity;
|
|
1031
|
+
let maxX = -Infinity;
|
|
1032
|
+
let maxY = -Infinity;
|
|
1033
|
+
const toView = (p) => {
|
|
1034
|
+
const v = [dot(p, right), -dot(p, up), dot(p, view)];
|
|
1035
|
+
minX = Math.min(minX, v[0]);
|
|
1036
|
+
maxX = Math.max(maxX, v[0]);
|
|
1037
|
+
minY = Math.min(minY, v[1]);
|
|
1038
|
+
maxY = Math.max(maxY, v[1]);
|
|
1039
|
+
return v;
|
|
1040
|
+
};
|
|
1041
|
+
shown.forEach(({ item, i }, n) => {
|
|
1042
|
+
const part2 = lib[item.part];
|
|
1043
|
+
if (!part2) throw new Error(`Unknown part: ${item.part}`);
|
|
1044
|
+
const b = bodies[n];
|
|
1045
|
+
const mid = [
|
|
1046
|
+
(b.min[0] + b.max[0]) / 2,
|
|
1047
|
+
(b.min[1] + b.max[1]) / 2,
|
|
1048
|
+
(b.min[2] + b.max[2]) / 2
|
|
1049
|
+
];
|
|
1050
|
+
const offset = opts.explode ? scale(sub(mid, center), opts.explode) : [0, 0, 0];
|
|
1051
|
+
const fade = highlight.size && !highlight.has(i) ? 0.72 : 0;
|
|
1052
|
+
const world = (x, y, z5) => add(add(apply(item.rotation, [x, y, z5]), item.position), offset);
|
|
1053
|
+
const t = part2.triangles;
|
|
1054
|
+
for (let f = 0; f < t.length / 9; f++) {
|
|
1055
|
+
const w = [0, 1, 2].map(
|
|
1056
|
+
(v) => world(t[f * 9 + v * 3], t[f * 9 + v * 3 + 1], t[f * 9 + v * 3 + 2])
|
|
1057
|
+
);
|
|
1058
|
+
const normal = cross(sub(w[1], w[0]), sub(w[2], w[0]));
|
|
1059
|
+
const len = length(normal);
|
|
1060
|
+
if (len < 1e-9) continue;
|
|
1061
|
+
const nn = scale(normal, 1 / len);
|
|
1062
|
+
const facing = dot(nn, view) < 0 ? scale(nn, -1) : nn;
|
|
1063
|
+
const code = part2.colors[f] === 16 ? item.color : part2.colors[f];
|
|
1064
|
+
const k = 0.55 + 0.45 * Math.max(0, dot(facing, LIGHT));
|
|
1065
|
+
const base2 = rgb(palette[code] ?? "#888888");
|
|
1066
|
+
tris.push({
|
|
1067
|
+
p: w.map(toView),
|
|
1068
|
+
color: base2.map((c) => {
|
|
1069
|
+
const v = Math.min(255, c * k);
|
|
1070
|
+
return v + (255 - v) * fade;
|
|
1071
|
+
})
|
|
1072
|
+
});
|
|
1073
|
+
}
|
|
1074
|
+
const e = part2.edges ?? [];
|
|
1075
|
+
const base = rgb(palette[item.color] ?? "#888888");
|
|
1076
|
+
const dark = base[0] + base[1] + base[2] < 115;
|
|
1077
|
+
const edge = (dark ? [110, 110, 110] : base.map((c) => c * 0.45)).map(
|
|
1078
|
+
(c) => c + (255 - c) * fade
|
|
1079
|
+
);
|
|
1080
|
+
for (let s2 = 0; s2 < e.length; s2 += 6)
|
|
1081
|
+
lines.push({
|
|
1082
|
+
a: toView(world(e[s2], e[s2 + 1], e[s2 + 2])),
|
|
1083
|
+
b: toView(world(e[s2 + 3], e[s2 + 4], e[s2 + 5])),
|
|
1084
|
+
color: edge
|
|
1085
|
+
});
|
|
1086
|
+
});
|
|
1087
|
+
const s = tris.length ? 0.9 * Math.min(W2 / (maxX - minX || 1), H2 / (maxY - minY || 1)) : 1;
|
|
1088
|
+
const ox = W2 / 2 - s * (minX + maxX) / 2;
|
|
1089
|
+
const oy = H2 / 2 - s * (minY + maxY) / 2;
|
|
1090
|
+
const sx = (v) => [ox + s * v[0], oy + s * v[1], v[2]];
|
|
1091
|
+
const data = new Uint8ClampedArray(W2 * H2 * 4);
|
|
1092
|
+
const depth = new Float32Array(W2 * H2).fill(-Infinity);
|
|
1093
|
+
for (let p = 0; p < W2 * H2; p++) {
|
|
1094
|
+
data[p * 4] = background[0];
|
|
1095
|
+
data[p * 4 + 1] = background[1];
|
|
1096
|
+
data[p * 4 + 2] = background[2];
|
|
1097
|
+
data[p * 4 + 3] = 255;
|
|
1098
|
+
}
|
|
1099
|
+
const put = (x, y, z5, c) => {
|
|
1100
|
+
const p = y * W2 + x;
|
|
1101
|
+
if (z5 <= depth[p]) return;
|
|
1102
|
+
depth[p] = z5;
|
|
1103
|
+
data[p * 4] = c[0];
|
|
1104
|
+
data[p * 4 + 1] = c[1];
|
|
1105
|
+
data[p * 4 + 2] = c[2];
|
|
1106
|
+
};
|
|
1107
|
+
for (const tri of tris) {
|
|
1108
|
+
const [a, b, c] = tri.p.map(sx);
|
|
1109
|
+
const area = (b[0] - a[0]) * (c[1] - a[1]) - (c[0] - a[0]) * (b[1] - a[1]);
|
|
1110
|
+
if (Math.abs(area) < 1e-9) continue;
|
|
1111
|
+
const x0 = Math.max(0, Math.floor(Math.min(a[0], b[0], c[0])));
|
|
1112
|
+
const x1 = Math.min(W2 - 1, Math.ceil(Math.max(a[0], b[0], c[0])));
|
|
1113
|
+
const y0 = Math.max(0, Math.floor(Math.min(a[1], b[1], c[1])));
|
|
1114
|
+
const y1 = Math.min(H2 - 1, Math.ceil(Math.max(a[1], b[1], c[1])));
|
|
1115
|
+
for (let y = y0; y <= y1; y++)
|
|
1116
|
+
for (let x = x0; x <= x1; x++) {
|
|
1117
|
+
const px = x + 0.5;
|
|
1118
|
+
const py = y + 0.5;
|
|
1119
|
+
const l0 = ((b[0] - px) * (c[1] - py) - (c[0] - px) * (b[1] - py)) / area;
|
|
1120
|
+
const l1 = ((c[0] - px) * (a[1] - py) - (a[0] - px) * (c[1] - py)) / area;
|
|
1121
|
+
const l2 = 1 - l0 - l1;
|
|
1122
|
+
if (l0 < 0 || l1 < 0 || l2 < 0) continue;
|
|
1123
|
+
put(x, y, l0 * a[2] + l1 * b[2] + l2 * c[2], tri.color);
|
|
1124
|
+
}
|
|
1125
|
+
}
|
|
1126
|
+
const bias = 0.02;
|
|
1127
|
+
for (const line of lines) {
|
|
1128
|
+
const a = sx(line.a);
|
|
1129
|
+
const b = sx(line.b);
|
|
1130
|
+
const steps = Math.max(1, Math.ceil(Math.max(Math.abs(b[0] - a[0]), Math.abs(b[1] - a[1]))));
|
|
1131
|
+
for (let k = 0; k <= steps; k++) {
|
|
1132
|
+
const t = k / steps;
|
|
1133
|
+
const x = Math.round(a[0] + (b[0] - a[0]) * t);
|
|
1134
|
+
const y = Math.round(a[1] + (b[1] - a[1]) * t);
|
|
1135
|
+
if (x < 0 || y < 0 || x >= W2 || y >= H2) continue;
|
|
1136
|
+
for (const [dx, dy] of ss > 1 ? [
|
|
1137
|
+
[0, 0],
|
|
1138
|
+
[1, 0],
|
|
1139
|
+
[0, 1],
|
|
1140
|
+
[1, 1]
|
|
1141
|
+
] : [[0, 0]]) {
|
|
1142
|
+
const X = Math.min(W2 - 1, x + dx);
|
|
1143
|
+
const Y = Math.min(H2 - 1, y + dy);
|
|
1144
|
+
const p = Y * W2 + X;
|
|
1145
|
+
const z5 = a[2] + (b[2] - a[2]) * t;
|
|
1146
|
+
if (z5 + bias < depth[p]) continue;
|
|
1147
|
+
data[p * 4] = line.color[0];
|
|
1148
|
+
data[p * 4 + 1] = line.color[1];
|
|
1149
|
+
data[p * 4 + 2] = line.color[2];
|
|
1150
|
+
}
|
|
1151
|
+
}
|
|
1152
|
+
}
|
|
1153
|
+
return { width: W2, height: H2, data };
|
|
1154
|
+
}
|
|
1155
|
+
var key = (a, b, kind) => `${a < b ? `${a}~${b}` : `${b}~${a}`}:${kind}`;
|
|
1156
|
+
function learnGrammar(model, joints, adapter) {
|
|
1157
|
+
const exact = /* @__PURE__ */ new Set();
|
|
1158
|
+
const family = /* @__PURE__ */ new Set();
|
|
1159
|
+
for (const c of joints) {
|
|
1160
|
+
const a = model.items[c.a].part;
|
|
1161
|
+
const b = model.items[c.b].part;
|
|
1162
|
+
exact.add(key(a, b, c.kind));
|
|
1163
|
+
family.add(key(adapter.family(a), adapter.family(b), c.kind));
|
|
1164
|
+
}
|
|
1165
|
+
return { exact, family };
|
|
1166
|
+
}
|
|
1167
|
+
function jointAllowed(grammar, adapter, a, b, kind, mode) {
|
|
1168
|
+
if (mode === "free" || grammar.exact.has(key(a, b, kind))) return true;
|
|
1169
|
+
return mode === "family" && grammar.family.has(key(adapter.family(a), adapter.family(b), kind));
|
|
1170
|
+
}
|
|
1171
|
+
var DIRECTIONS = [
|
|
1172
|
+
[0, 1, 0],
|
|
1173
|
+
[1, 0, 0],
|
|
1174
|
+
[-1, 0, 0],
|
|
1175
|
+
[0, 0, 1],
|
|
1176
|
+
[0, 0, -1],
|
|
1177
|
+
[0, -1, 0]
|
|
1178
|
+
];
|
|
1179
|
+
var STEP = 0.15;
|
|
1180
|
+
var BLOCKING_CELLS = 4;
|
|
1181
|
+
var moved = (it, d, s) => ({
|
|
1182
|
+
...it,
|
|
1183
|
+
position: add(it.position, scale(d, s))
|
|
1184
|
+
});
|
|
1185
|
+
var shifted = (b, d, s) => ({
|
|
1186
|
+
min: add(b.min, scale(d, s)),
|
|
1187
|
+
max: add(b.max, scale(d, s))
|
|
1188
|
+
});
|
|
1189
|
+
function planAssembly(model, lib, adapter) {
|
|
1190
|
+
const items = model.items;
|
|
1191
|
+
const bodies = items.map((it) => worldBody(lib, it));
|
|
1192
|
+
const axes = /* @__PURE__ */ new Map();
|
|
1193
|
+
const releases = /* @__PURE__ */ new Map();
|
|
1194
|
+
for (const c of contacts(model, lib, adapter)) {
|
|
1195
|
+
if (c.axis) {
|
|
1196
|
+
axes.set(`${c.a},${c.b}`, c.axis);
|
|
1197
|
+
axes.set(`${c.b},${c.a}`, c.axis);
|
|
1198
|
+
}
|
|
1199
|
+
if (c.release) {
|
|
1200
|
+
releases.set(`${c.b},${c.a}`, c.release);
|
|
1201
|
+
releases.set(`${c.a},${c.b}`, scale(c.release, -1));
|
|
1202
|
+
}
|
|
1203
|
+
}
|
|
1204
|
+
const allowed = (i, d, around) => {
|
|
1205
|
+
for (const j of around) {
|
|
1206
|
+
if (j === i) continue;
|
|
1207
|
+
const axis = axes.get(`${i},${j}`);
|
|
1208
|
+
if (axis && !slides(i, j, d)) return false;
|
|
1209
|
+
const release = releases.get(`${i},${j}`);
|
|
1210
|
+
if (release && dot(release, d) < 0.99) return false;
|
|
1211
|
+
}
|
|
1212
|
+
return true;
|
|
1213
|
+
};
|
|
1214
|
+
const centre = (b) => scale(add(b.min, b.max), 0.5);
|
|
1215
|
+
const slides = (i, j, d) => {
|
|
1216
|
+
const axis = axes.get(`${i},${j}`);
|
|
1217
|
+
if (!axis || Math.abs(dot(axis, d)) <= 0.99) return false;
|
|
1218
|
+
return dot(d, sub(centre(bodies[i]), centre(bodies[j]))) >= -1e-6;
|
|
1219
|
+
};
|
|
1220
|
+
const left = new Set(items.map((_, i) => i));
|
|
1221
|
+
const freeWay = (i, around) => DIRECTIONS.find((d) => {
|
|
1222
|
+
if (!allowed(i, d, around)) return false;
|
|
1223
|
+
const others = [...around].filter((j) => j !== i && !slides(i, j, d));
|
|
1224
|
+
for (let s = STEP; ; s += STEP) {
|
|
1225
|
+
const box2 = shifted(bodies[i], d, s);
|
|
1226
|
+
const near = others.filter((j) => overlapVolume(box2, bodies[j], -0.4) > 0);
|
|
1227
|
+
if (!near.length) return true;
|
|
1228
|
+
const it = moved(items[i], d, s);
|
|
1229
|
+
if (near.some((j) => collisionCells(lib, it, items[j]) > BLOCKING_CELLS)) return false;
|
|
1230
|
+
}
|
|
1231
|
+
});
|
|
1232
|
+
const removed = [];
|
|
1233
|
+
while (left.size) {
|
|
1234
|
+
const order2 = [...left].sort((a, b) => bodies[b].max[1] - bodies[a].max[1] || b - a);
|
|
1235
|
+
let next;
|
|
1236
|
+
for (const i of order2) {
|
|
1237
|
+
const d = freeWay(i, left);
|
|
1238
|
+
if (d) {
|
|
1239
|
+
next = { item: i, insert: [0 - d[0], 0 - d[1], 0 - d[2]] };
|
|
1240
|
+
break;
|
|
1241
|
+
}
|
|
1242
|
+
}
|
|
1243
|
+
if (!next) break;
|
|
1244
|
+
removed.push(next);
|
|
1245
|
+
left.delete(next.item);
|
|
1246
|
+
}
|
|
1247
|
+
if (!items.length || items.some((it) => it.step === void 0))
|
|
1248
|
+
return { steps: removed.reverse(), locked: [...left] };
|
|
1249
|
+
const step = (i) => items[i].step ?? 0;
|
|
1250
|
+
const order = items.map((_, i) => i).sort((a, b) => step(a) - step(b) || a - b);
|
|
1251
|
+
const built = /* @__PURE__ */ new Set();
|
|
1252
|
+
const steps = [];
|
|
1253
|
+
const locked = [];
|
|
1254
|
+
for (const i of order) {
|
|
1255
|
+
const d = freeWay(i, /* @__PURE__ */ new Set([...built, i]));
|
|
1256
|
+
if (d) steps.push({ item: i, insert: [0 - d[0], 0 - d[1], 0 - d[2]] });
|
|
1257
|
+
else locked.push(i);
|
|
1258
|
+
built.add(i);
|
|
1259
|
+
}
|
|
1260
|
+
return { steps, locked };
|
|
1261
|
+
}
|
|
1262
|
+
function seededRandom(seed) {
|
|
1263
|
+
let a = seed >>> 0;
|
|
1264
|
+
return () => {
|
|
1265
|
+
a = a + 1831565813 >>> 0;
|
|
1266
|
+
let t = a;
|
|
1267
|
+
t = Math.imul(t ^ t >>> 15, t | 1);
|
|
1268
|
+
t ^= t + Math.imul(t ^ t >>> 7, t | 61);
|
|
1269
|
+
return ((t ^ t >>> 14) >>> 0) / 4294967296;
|
|
1270
|
+
};
|
|
1271
|
+
}
|
|
1272
|
+
function mates(adapter, free, placed) {
|
|
1273
|
+
if (!adapter.fits[free.kind]?.includes(placed.kind)) return false;
|
|
1274
|
+
const axial = free.len !== void 0 && placed.len !== void 0;
|
|
1275
|
+
return axial ? length(cross(free.a, placed.a)) < 0.01 : dot(free.a, placed.a) < -0.99;
|
|
1276
|
+
}
|
|
1277
|
+
function remix(exemplar, lib, adapter, opts) {
|
|
1278
|
+
const random = seededRandom(opts.seed);
|
|
1279
|
+
const mode = opts.mode ?? "family";
|
|
1280
|
+
const joints = contacts(exemplar, lib, adapter);
|
|
1281
|
+
const grammar = learnGrammar(exemplar, joints, adapter);
|
|
1282
|
+
const parts = [...new Set(exemplar.items.map((it) => it.part))];
|
|
1283
|
+
const owners = /* @__PURE__ */ new Map();
|
|
1284
|
+
for (const part2 of parts)
|
|
1285
|
+
for (const c of lib[part2].connectors)
|
|
1286
|
+
owners.set(c.kind, (owners.get(c.kind) ?? /* @__PURE__ */ new Set()).add(part2));
|
|
1287
|
+
const specialised = (kind) => kind.split("-").every((k) => owners.get(k)?.size === 1);
|
|
1288
|
+
const group = exemplar.items.map((_, i) => i);
|
|
1289
|
+
const find = (i) => {
|
|
1290
|
+
while (group[i] !== i) i = group[i];
|
|
1291
|
+
return i;
|
|
1292
|
+
};
|
|
1293
|
+
for (const c of joints) if (specialised(c.kind)) group[find(c.a)] = find(c.b);
|
|
1294
|
+
const members = /* @__PURE__ */ new Map();
|
|
1295
|
+
exemplar.items.forEach((_, i) => {
|
|
1296
|
+
members.set(find(i), [...members.get(find(i)) ?? [], i]);
|
|
1297
|
+
});
|
|
1298
|
+
const units = [...members.values()].map((ids) => {
|
|
1299
|
+
const r = exemplar.items[ids[0]];
|
|
1300
|
+
const inverse = transpose(r.rotation);
|
|
1301
|
+
return {
|
|
1302
|
+
first: Math.min(...ids),
|
|
1303
|
+
root: r,
|
|
1304
|
+
items: ids.map((i) => {
|
|
1305
|
+
const it = exemplar.items[i];
|
|
1306
|
+
return {
|
|
1307
|
+
...it,
|
|
1308
|
+
rotation: mul(inverse, it.rotation),
|
|
1309
|
+
position: apply(inverse, sub(it.position, r.position))
|
|
1310
|
+
};
|
|
1311
|
+
})
|
|
1312
|
+
};
|
|
1313
|
+
});
|
|
1314
|
+
units.sort((x, y) => x.first - y.first);
|
|
1315
|
+
const place = (unit, { rotation, position }) => unit.items.map((it) => ({
|
|
1316
|
+
...it,
|
|
1317
|
+
rotation: mul(rotation, it.rotation),
|
|
1318
|
+
position: add(apply(rotation, it.position), position)
|
|
1319
|
+
}));
|
|
1320
|
+
const freeConnectors = (items2) => {
|
|
1321
|
+
const world = items2.map((it) => worldConnectors(lib, it));
|
|
1322
|
+
const used = /* @__PURE__ */ new Set();
|
|
1323
|
+
for (const c of contacts({ items: items2 }, lib, adapter))
|
|
1324
|
+
for (const [x, y] of [
|
|
1325
|
+
[c.a, c.b],
|
|
1326
|
+
[c.b, c.a]
|
|
1327
|
+
])
|
|
1328
|
+
world[x].forEach((cx, n) => {
|
|
1329
|
+
if (world[y].some(
|
|
1330
|
+
(cy) => mates(adapter, cx, cy) && length(sub(cx.p, cy.p)) <= adapter.tolerance
|
|
1331
|
+
))
|
|
1332
|
+
used.add(`${x}:${n}`);
|
|
1333
|
+
});
|
|
1334
|
+
return world.flatMap((list, x) => list.filter((_, n) => !used.has(`${x}:${n}`)));
|
|
1335
|
+
};
|
|
1336
|
+
const poses = (unit, free) => {
|
|
1337
|
+
const own = freeConnectors(unit.items);
|
|
1338
|
+
const out = [];
|
|
1339
|
+
for (const rotation of AXIS_ROTATIONS)
|
|
1340
|
+
for (const c of own) {
|
|
1341
|
+
const turned = { ...c, p: apply(rotation, c.p), a: apply(rotation, c.a) };
|
|
1342
|
+
for (const f of free)
|
|
1343
|
+
if (mates(adapter, f, turned)) out.push({ rotation, position: sub(f.p, turned.p) });
|
|
1344
|
+
}
|
|
1345
|
+
for (let i = out.length - 1; i > 0; i--) {
|
|
1346
|
+
const j = Math.floor(random() * (i + 1));
|
|
1347
|
+
[out[i], out[j]] = [out[j], out[i]];
|
|
1348
|
+
}
|
|
1349
|
+
return out;
|
|
1350
|
+
};
|
|
1351
|
+
const legal = (build, added) => {
|
|
1352
|
+
const all = [...build, ...added];
|
|
1353
|
+
const fresh = contacts({ items: all }, lib, adapter).filter(
|
|
1354
|
+
(c) => c.a < build.length && c.b >= build.length
|
|
1355
|
+
);
|
|
1356
|
+
if (!fresh.length || !fresh.every(
|
|
1357
|
+
(c) => jointAllowed(grammar, adapter, all[c.a].part, all[c.b].part, c.kind, mode)
|
|
1358
|
+
))
|
|
1359
|
+
return false;
|
|
1360
|
+
const through = new Set(fresh.filter((c) => c.axis).map((c) => `${c.a},${c.b}`));
|
|
1361
|
+
return added.every((item, k) => {
|
|
1362
|
+
const body = worldBody(lib, item);
|
|
1363
|
+
return build.every(
|
|
1364
|
+
(other, j) => through.has(`${j},${build.length + k}`) || overlapVolume(body, worldBody(lib, other), -0.4) <= 0 || collisionCells(lib, item, other) <= 4
|
|
1365
|
+
);
|
|
1366
|
+
});
|
|
1367
|
+
};
|
|
1368
|
+
const [seed, ...rest] = units;
|
|
1369
|
+
const items = place(seed, seed.root).map((it) => ({ ...it, step: 1 }));
|
|
1370
|
+
const pending = [...rest];
|
|
1371
|
+
for (let step = 2; pending.length; step++) {
|
|
1372
|
+
const free = freeConnectors(items);
|
|
1373
|
+
let chosen;
|
|
1374
|
+
for (const unit of pending) {
|
|
1375
|
+
for (const pose of poses(unit, free)) {
|
|
1376
|
+
const added = place(unit, pose).map((it) => ({ ...it, step }));
|
|
1377
|
+
if (legal(items, added) && !planAssembly({ items: [...items, ...added] }, lib, adapter).locked.length) {
|
|
1378
|
+
chosen = { unit, added };
|
|
1379
|
+
break;
|
|
1380
|
+
}
|
|
1381
|
+
}
|
|
1382
|
+
if (chosen) break;
|
|
1383
|
+
}
|
|
1384
|
+
if (!chosen) break;
|
|
1385
|
+
pending.splice(pending.indexOf(chosen.unit), 1);
|
|
1386
|
+
items.push(...chosen.added);
|
|
1387
|
+
}
|
|
1388
|
+
return { model: { items }, left: pending.flatMap((u) => u.items.map((it) => it.part)) };
|
|
1389
|
+
}
|
|
1390
|
+
function toStl(model, lib, unit) {
|
|
1391
|
+
const tris = [];
|
|
1392
|
+
for (const item of model.items) {
|
|
1393
|
+
const part2 = lib[item.part];
|
|
1394
|
+
if (!part2) throw new Error(`Unknown part: ${item.part}`);
|
|
1395
|
+
const t = part2.triangles;
|
|
1396
|
+
for (let i = 0; i < t.length; i += 3) {
|
|
1397
|
+
const p = add(apply(item.rotation, [t[i], t[i + 1], t[i + 2]]), item.position);
|
|
1398
|
+
tris.push(p[0] * unit, -p[2] * unit, p[1] * unit);
|
|
1399
|
+
}
|
|
1400
|
+
}
|
|
1401
|
+
const count = tris.length / 9;
|
|
1402
|
+
const out = new DataView(new ArrayBuffer(84 + count * 50));
|
|
1403
|
+
out.setUint32(80, count, true);
|
|
1404
|
+
for (let f = 0; f < count; f++) {
|
|
1405
|
+
const o = 84 + f * 50;
|
|
1406
|
+
const v = tris.slice(f * 9, f * 9 + 9);
|
|
1407
|
+
const u = [v[3] - v[0], v[4] - v[1], v[5] - v[2]];
|
|
1408
|
+
const w = [v[6] - v[0], v[7] - v[1], v[8] - v[2]];
|
|
1409
|
+
const n = [u[1] * w[2] - u[2] * w[1], u[2] * w[0] - u[0] * w[2], u[0] * w[1] - u[1] * w[0]];
|
|
1410
|
+
const len = Math.hypot(n[0], n[1], n[2]) || 1;
|
|
1411
|
+
for (let k = 0; k < 3; k++) out.setFloat32(o + k * 4, n[k] / len, true);
|
|
1412
|
+
for (let k = 0; k < 9; k++) out.setFloat32(o + 12 + k * 4, v[k], true);
|
|
1413
|
+
}
|
|
1414
|
+
return new Uint8Array(out.buffer);
|
|
1415
|
+
}
|
|
1416
|
+
var COLLISION_CELLS = 4;
|
|
1417
|
+
function validate(model, lib, adapter, opts = {}) {
|
|
1418
|
+
const issues = [];
|
|
1419
|
+
const n = model.items.length;
|
|
1420
|
+
const joints = contacts(model, lib, adapter);
|
|
1421
|
+
const bodies = model.items.map((it) => worldBody(lib, it));
|
|
1422
|
+
const through = new Set(joints.filter((c) => c.axis).map((c) => `${c.a},${c.b}`));
|
|
1423
|
+
for (let a = 0; a < n; a++)
|
|
1424
|
+
for (let b = a + 1; b < n; b++)
|
|
1425
|
+
if (!through.has(`${a},${b}`) && overlapVolume(bodies[a], bodies[b], -0.4) > 0 && collisionCells(lib, model.items[a], model.items[b]) > COLLISION_CELLS)
|
|
1426
|
+
issues.push({ rule: "overlap", items: [a, b], message: "two parts occupy the same space" });
|
|
1427
|
+
const parent = model.items.map((_, i) => i);
|
|
1428
|
+
const find = (x) => {
|
|
1429
|
+
while (parent[x] !== x) x = parent[x];
|
|
1430
|
+
return x;
|
|
1431
|
+
};
|
|
1432
|
+
for (const c of joints) parent[find(c.a)] = find(c.b);
|
|
1433
|
+
const groups = /* @__PURE__ */ new Map();
|
|
1434
|
+
for (let i = 0; i < n; i++) groups.set(find(i), [...groups.get(find(i)) ?? [], i]);
|
|
1435
|
+
const sorted = [...groups.values()].sort((x, y) => y.length - x.length);
|
|
1436
|
+
for (const g of sorted.slice(1))
|
|
1437
|
+
issues.push({ rule: "disconnected", items: g, message: "parts not joined to the main build" });
|
|
1438
|
+
if (opts.grammar)
|
|
1439
|
+
for (const c of joints) {
|
|
1440
|
+
const [a, b] = [model.items[c.a].part, model.items[c.b].part];
|
|
1441
|
+
if (!jointAllowed(opts.grammar, adapter, a, b, c.kind, opts.mode ?? "strict"))
|
|
1442
|
+
issues.push({
|
|
1443
|
+
rule: "unseen-joint",
|
|
1444
|
+
items: [c.a, c.b],
|
|
1445
|
+
message: "a joint the exemplar never makes"
|
|
1446
|
+
});
|
|
1447
|
+
}
|
|
1448
|
+
return { ok: issues.length === 0, issues, contacts: joints };
|
|
1449
|
+
}
|
|
1450
|
+
|
|
1451
|
+
// src/brick.ts
|
|
1452
|
+
import sharp from "sharp";
|
|
1453
|
+
function ldrawLibrary(flag) {
|
|
1454
|
+
const dir = typeof flag === "string" ? flag : process.env.LDRAW_DIR;
|
|
1455
|
+
if (!dir)
|
|
1456
|
+
throw new InputError(
|
|
1457
|
+
"Pass --library <ldraw-dir> or set LDRAW_DIR to an unpacked LDraw library (https://library.ldraw.org)."
|
|
1458
|
+
);
|
|
1459
|
+
const root = resolve(dir);
|
|
1460
|
+
if (!existsSync(join(root, "parts")) || !existsSync(join(root, "p")))
|
|
1461
|
+
throw new InputError(`${root} is not an LDraw library: expected parts/ and p/ inside.`);
|
|
1462
|
+
return root;
|
|
1463
|
+
}
|
|
1464
|
+
async function readBrickModel(file, library) {
|
|
1465
|
+
const text = await readFile(resolve(file), "utf8").catch((error) => {
|
|
1466
|
+
if (error.code === "ENOENT") throw new InputError(`No model at ${resolve(file)}.`);
|
|
1467
|
+
throw error;
|
|
1468
|
+
});
|
|
1469
|
+
const model = parseModel(text);
|
|
1470
|
+
if (!model.items.length) throw new InputError(`${file} places no parts.`);
|
|
1471
|
+
const read = (path) => existsSync(path) ? readFileSync(path, "utf8") : void 0;
|
|
1472
|
+
const resolver = libraryResolver(read, library);
|
|
1473
|
+
const lib = {};
|
|
1474
|
+
const names = {};
|
|
1475
|
+
for (const item of model.items) {
|
|
1476
|
+
if (lib[item.part]) continue;
|
|
1477
|
+
const text2 = resolver(`${item.part}.dat`);
|
|
1478
|
+
if (text2 === void 0) throw new InputError(`Part ${item.part} is not in the LDraw library.`);
|
|
1479
|
+
names[item.part] = text2.split(/\r?\n/, 1)[0].replace(/^0\s*/, "").trim();
|
|
1480
|
+
lib[item.part] = loadLegoPart(item.part, resolver, { name: names[item.part] });
|
|
1481
|
+
}
|
|
1482
|
+
const config = read(join(library, "LDConfig.ldr"));
|
|
1483
|
+
return { model, lib, names, palette: config ? parsePalette(config) : {} };
|
|
1484
|
+
}
|
|
1485
|
+
function brickCheck({ model, lib, names }) {
|
|
1486
|
+
const result = validate(model, lib, lego);
|
|
1487
|
+
const label2 = (i) => `${i}:${model.items[i].part} ${names[model.items[i].part]}`;
|
|
1488
|
+
return {
|
|
1489
|
+
ok: result.ok,
|
|
1490
|
+
parts: model.items.length,
|
|
1491
|
+
joints: result.contacts.length,
|
|
1492
|
+
issues: result.issues.map((issue) => ({ ...issue, parts: issue.items.map(label2) }))
|
|
1493
|
+
};
|
|
1494
|
+
}
|
|
1495
|
+
function brickPlan({ model, lib, names }) {
|
|
1496
|
+
const plan = planAssembly(model, lib, lego);
|
|
1497
|
+
const axis = (d) => ["+x", "-x", "+y", "-y", "+z", "-z"][[
|
|
1498
|
+
[1, 0, 0],
|
|
1499
|
+
[-1, 0, 0],
|
|
1500
|
+
[0, 1, 0],
|
|
1501
|
+
[0, -1, 0],
|
|
1502
|
+
[0, 0, 1],
|
|
1503
|
+
[0, 0, -1]
|
|
1504
|
+
].findIndex((v) => v.every((x, k) => Math.abs(x - d[k]) < 1e-6))] ?? d.join(",");
|
|
1505
|
+
return {
|
|
1506
|
+
...plan,
|
|
1507
|
+
rows: plan.steps.map((s) => ({
|
|
1508
|
+
step: model.items[s.item].step ?? 0,
|
|
1509
|
+
part: model.items[s.item].part,
|
|
1510
|
+
name: names[model.items[s.item].part],
|
|
1511
|
+
insert: axis(s.insert)
|
|
1512
|
+
}))
|
|
1513
|
+
};
|
|
1514
|
+
}
|
|
1515
|
+
async function brickRender({ model, lib, palette }, opts) {
|
|
1516
|
+
const highlight = opts.step === void 0 ? void 0 : model.items.map((it, i) => it.step === opts.step ? i : -1).filter((i) => i >= 0);
|
|
1517
|
+
if (opts.step !== void 0 && !highlight?.length)
|
|
1518
|
+
throw new InputError(`The model has no step ${opts.step}.`);
|
|
1519
|
+
const image = rasterize(model, lib, {
|
|
1520
|
+
width: 1200,
|
|
1521
|
+
height: 900,
|
|
1522
|
+
palette,
|
|
1523
|
+
background: "#ecebe7",
|
|
1524
|
+
maxStep: opts.step,
|
|
1525
|
+
highlight,
|
|
1526
|
+
explode: opts.explode,
|
|
1527
|
+
yaw: opts.yaw === void 0 ? void 0 : opts.yaw * Math.PI / 180,
|
|
1528
|
+
pitch: opts.pitch === void 0 ? void 0 : opts.pitch * Math.PI / 180
|
|
1529
|
+
});
|
|
1530
|
+
return sharp(Buffer.from(image.data.buffer), {
|
|
1531
|
+
raw: { width: image.width, height: image.height, channels: 4 }
|
|
1532
|
+
}).resize(1200, 900).png().toBuffer();
|
|
1533
|
+
}
|
|
1534
|
+
var MODES = ["strict", "family", "free"];
|
|
1535
|
+
function brickRemix(exemplar, seed, mode) {
|
|
1536
|
+
if (mode !== void 0 && !MODES.includes(mode))
|
|
1537
|
+
throw new InputError(`--mode must be one of ${MODES.join(", ")}.`);
|
|
1538
|
+
const result = remix(exemplar.model, exemplar.lib, lego, {
|
|
1539
|
+
seed,
|
|
1540
|
+
mode
|
|
1541
|
+
});
|
|
1542
|
+
const grown = { ...exemplar, model: result.model };
|
|
1543
|
+
return {
|
|
1544
|
+
ldr: writeModel(result.model, `Remix ${seed}`),
|
|
1545
|
+
left: result.left,
|
|
1546
|
+
check: brickCheck(grown)
|
|
1547
|
+
};
|
|
1548
|
+
}
|
|
1549
|
+
|
|
69
1550
|
// src/card-import.ts
|
|
70
1551
|
import { createHash } from "crypto";
|
|
71
|
-
import { readFile, stat } from "fs/promises";
|
|
1552
|
+
import { readFile as readFile2, stat } from "fs/promises";
|
|
72
1553
|
import { basename, extname } from "path";
|
|
73
1554
|
import { DOMParser } from "@xmldom/xmldom";
|
|
74
|
-
import
|
|
1555
|
+
import sharp2 from "sharp";
|
|
75
1556
|
var box = { x: 0, y: 0, w: 1200, h: 756 };
|
|
76
1557
|
function elements(node) {
|
|
77
1558
|
return Array.from(node.childNodes).filter((child) => child.nodeType === 1);
|
|
@@ -104,7 +1585,7 @@ async function importCard(file, name) {
|
|
|
104
1585
|
const info = await stat(file);
|
|
105
1586
|
if (!info.isFile() || info.size > 2e6)
|
|
106
1587
|
throw new InputError("Import a local SVG or raster file below 2 MB.");
|
|
107
|
-
const bytes = await
|
|
1588
|
+
const bytes = await readFile2(file);
|
|
108
1589
|
const title = (name ?? basename(file, extname(file))).slice(0, 60);
|
|
109
1590
|
const design = {
|
|
110
1591
|
version: 1,
|
|
@@ -118,7 +1599,7 @@ async function importCard(file, name) {
|
|
|
118
1599
|
back: { background: "#111111", layers: [] }
|
|
119
1600
|
};
|
|
120
1601
|
if (extname(file).toLowerCase() !== ".svg") {
|
|
121
|
-
const meta = await
|
|
1602
|
+
const meta = await sharp2(bytes, { limitInputPixels: 24e6 }).metadata();
|
|
122
1603
|
if (!meta.format || !["png", "jpeg", "webp"].includes(meta.format))
|
|
123
1604
|
throw new InputError("Import PNG, JPEG, WebP or a self-contained SVG.");
|
|
124
1605
|
design.assets = {
|
|
@@ -181,11 +1662,11 @@ async function importCard(file, name) {
|
|
|
181
1662
|
`This SVG uses elements outside the editable format: ${result.error.issues.map((issue) => issue.message).join("; ")}. Keep and edit the original SVG; it was not changed.`
|
|
182
1663
|
);
|
|
183
1664
|
}
|
|
184
|
-
design.front.layers = groups.map((children,
|
|
1665
|
+
design.front.layers = groups.map((children, index2) => {
|
|
185
1666
|
const first = children[0];
|
|
186
1667
|
return {
|
|
187
|
-
id: `source-${String(
|
|
188
|
-
name: (grouped ? "Original composition" : first?.attrs?.["aria-label"] ?? first?.attrs?.id ?? first?.text?.trim() ?? `Original ${first?.tag} ${
|
|
1668
|
+
id: `source-${String(index2 + 1).padStart(2, "0")}`,
|
|
1669
|
+
name: (grouped ? "Original composition" : first?.attrs?.["aria-label"] ?? first?.attrs?.id ?? first?.text?.trim() ?? `Original ${first?.tag} ${index2 + 1}`).slice(0, 80),
|
|
189
1670
|
kind: "svg",
|
|
190
1671
|
nodes: Object.keys(inherited).length ? [{ tag: "g", attrs: inherited, children }] : children,
|
|
191
1672
|
...box,
|
|
@@ -198,7 +1679,7 @@ async function importCard(file, name) {
|
|
|
198
1679
|
for (const layer of design.front.layers) {
|
|
199
1680
|
if (layer.kind !== "svg") continue;
|
|
200
1681
|
const markup = `<svg xmlns="http://www.w3.org/2000/svg" width="1200" height="756" viewBox="0 0 1200 756"><defs>${defs}</defs>${layer.nodes.map((node) => svgNodeXml(node)).join("")}</svg>`;
|
|
201
|
-
const { data, info: pixels } = await
|
|
1682
|
+
const { data, info: pixels } = await sharp2(Buffer.from(markup)).ensureAlpha().raw().toBuffer({ resolveWithObject: true });
|
|
202
1683
|
let left = 1200, top = 756, right = -1, bottom = -1;
|
|
203
1684
|
for (let y = 0; y < pixels.height; y++) {
|
|
204
1685
|
for (let x = 0; x < pixels.width; x++) {
|
|
@@ -213,65 +1694,527 @@ async function importCard(file, name) {
|
|
|
213
1694
|
const measured = { x: left, y: top, w: right - left + 1, h: bottom - top + 1 };
|
|
214
1695
|
Object.assign(layer, measured, { sourceBox: { ...measured } });
|
|
215
1696
|
}
|
|
216
|
-
}
|
|
217
|
-
return badgeDesignSchema.parse(design);
|
|
1697
|
+
}
|
|
1698
|
+
return badgeDesignSchema.parse(design);
|
|
1699
|
+
}
|
|
1700
|
+
|
|
1701
|
+
// src/grid.ts
|
|
1702
|
+
var whole = (name, raw) => {
|
|
1703
|
+
const n = Number(raw);
|
|
1704
|
+
if (raw === void 0 || !Number.isInteger(n) || n < 1)
|
|
1705
|
+
throw new InputError(`--${name} must be a whole number of at least 1.`);
|
|
1706
|
+
return n;
|
|
1707
|
+
};
|
|
1708
|
+
function gridStl(kind, values) {
|
|
1709
|
+
const part2 = kind === "bin" ? gridfinityBin(
|
|
1710
|
+
whole("width", values.width),
|
|
1711
|
+
whole("depth", values.depth),
|
|
1712
|
+
whole("height", values.height)
|
|
1713
|
+
) : gridfinityBaseplate(whole("width", values.width), whole("depth", values.depth));
|
|
1714
|
+
const stl = toStl(
|
|
1715
|
+
{ items: [{ part: part2.id, rotation: IDENTITY, position: [0, 0, 0], color: 16 }] },
|
|
1716
|
+
{ [part2.id]: part2 },
|
|
1717
|
+
GRIDFINITY_UNIT_MM
|
|
1718
|
+
);
|
|
1719
|
+
const { min, max } = meshBounds(part2.triangles);
|
|
1720
|
+
const mm = (k) => +((max[k] - min[k]) * GRIDFINITY_UNIT_MM).toFixed(2);
|
|
1721
|
+
return {
|
|
1722
|
+
stl,
|
|
1723
|
+
part: part2.id,
|
|
1724
|
+
name: part2.name,
|
|
1725
|
+
triangles: part2.triangles.length / 9,
|
|
1726
|
+
sizeMm: { x: mm(0), y: mm(2), z: mm(1) }
|
|
1727
|
+
};
|
|
1728
|
+
}
|
|
1729
|
+
|
|
1730
|
+
// src/images.ts
|
|
1731
|
+
import { readFile as readFile3, stat as stat2, writeFile } from "fs/promises";
|
|
1732
|
+
import { extname as extname2, resolve as resolve2 } from "path";
|
|
1733
|
+
async function readToolResult(path) {
|
|
1734
|
+
const info = await stat2(path);
|
|
1735
|
+
if (!info.isFile() || info.size > 12e6)
|
|
1736
|
+
throw new InputError("Expected a result file below 12 MB.");
|
|
1737
|
+
let value = JSON.parse(await readFile3(path, "utf8"));
|
|
1738
|
+
if (typeof value === "string") value = JSON.parse(value);
|
|
1739
|
+
if (value && typeof value === "object" && "success" in value) {
|
|
1740
|
+
if (!value.success) throw new InputError("The browser command failed.");
|
|
1741
|
+
value = value.data.output;
|
|
1742
|
+
}
|
|
1743
|
+
if (typeof value === "string") value = JSON.parse(value);
|
|
1744
|
+
if (!value?.ok || !value.data || typeof value.data !== "object")
|
|
1745
|
+
throw new InputError(value?.error?.message || "The tool returned an invalid result.");
|
|
1746
|
+
return value.data;
|
|
1747
|
+
}
|
|
1748
|
+
async function imageParams(file, state, target = "portrait") {
|
|
1749
|
+
const types = {
|
|
1750
|
+
".png": "image/png",
|
|
1751
|
+
".jpg": "image/jpeg",
|
|
1752
|
+
".jpeg": "image/jpeg",
|
|
1753
|
+
".webp": "image/webp"
|
|
1754
|
+
};
|
|
1755
|
+
const mime = types[extname2(file).toLowerCase()];
|
|
1756
|
+
if (!mime || !["portrait", "artwork"].includes(target))
|
|
1757
|
+
throw new InputError("Use a PNG/JPEG/WebP file and portrait or artwork target.");
|
|
1758
|
+
const info = await stat2(file);
|
|
1759
|
+
if (!info.isFile() || info.size > 4e6)
|
|
1760
|
+
throw new InputError("Image must be smaller than 4 MB.");
|
|
1761
|
+
const current = await readToolResult(state);
|
|
1762
|
+
if (typeof current.revision !== "string")
|
|
1763
|
+
throw new InputError("Inspect the editor to obtain its revision.");
|
|
1764
|
+
const bytes = await readFile3(file);
|
|
1765
|
+
if (bytes.length > 4e6) throw new InputError("Image must be smaller than 4 MB.");
|
|
1766
|
+
return {
|
|
1767
|
+
action: "import",
|
|
1768
|
+
target,
|
|
1769
|
+
expectedRevision: current.revision,
|
|
1770
|
+
dataUrl: `data:${mime};base64,${bytes.toString("base64")}`
|
|
1771
|
+
};
|
|
1772
|
+
}
|
|
1773
|
+
async function extractImage(file, out) {
|
|
1774
|
+
const image = await readToolResult(file);
|
|
1775
|
+
const match = /^data:image\/(?:png|jpeg|webp);base64,([A-Za-z0-9+/]+={0,2})$/.exec(
|
|
1776
|
+
image.dataUrl ?? ""
|
|
1777
|
+
);
|
|
1778
|
+
if (!match || image.dataUrl.length > 56e5)
|
|
1779
|
+
throw new InputError("The tool did not return a supported image.");
|
|
1780
|
+
const bytes = Buffer.from(match[1], "base64");
|
|
1781
|
+
if (bytes.length > 4e6) throw new InputError("Image is larger than 4 MB.");
|
|
1782
|
+
const path = resolve2(out);
|
|
1783
|
+
await writeFile(path, bytes, { flag: "wx", mode: 384 });
|
|
1784
|
+
return { path, bytes: bytes.length };
|
|
1785
|
+
}
|
|
1786
|
+
|
|
1787
|
+
// src/kit-extract.ts
|
|
1788
|
+
import { mkdir, mkdtemp, rename, rm, writeFile as writeFile2 } from "fs/promises";
|
|
1789
|
+
import { dirname, join as join2, resolve as resolve3 } from "path";
|
|
1790
|
+
import sharp3 from "sharp";
|
|
1791
|
+
import { z } from "zod";
|
|
1792
|
+
var regionSchema = z.object({
|
|
1793
|
+
x: z.number().int().nonnegative(),
|
|
1794
|
+
y: z.number().int().nonnegative(),
|
|
1795
|
+
w: z.number().int().positive(),
|
|
1796
|
+
h: z.number().int().positive(),
|
|
1797
|
+
name: z.string().trim().min(1).max(60).optional()
|
|
1798
|
+
}).strict();
|
|
1799
|
+
var optionsSchema = z.object({
|
|
1800
|
+
name: z.string().trim().min(1).max(60).default("Artwork kit"),
|
|
1801
|
+
gap: z.number().int().min(0).max(40).default(12),
|
|
1802
|
+
tolerance: z.number().int().min(0).max(150).default(40),
|
|
1803
|
+
minArea: z.number().int().min(1).max(1e5).default(64),
|
|
1804
|
+
regions: z.array(regionSchema).min(1).max(24).optional()
|
|
1805
|
+
}).strict();
|
|
1806
|
+
function dilate(mask, w, h, radius) {
|
|
1807
|
+
const horizontal = new Uint8Array(mask.length), out = new Uint8Array(mask.length);
|
|
1808
|
+
for (let y = 0; y < h; y++) {
|
|
1809
|
+
let count = 0;
|
|
1810
|
+
for (let x = -radius; x < w; x++) {
|
|
1811
|
+
if (x + radius < w) count += mask[y * w + x + radius];
|
|
1812
|
+
if (x - radius - 1 >= 0) count -= mask[y * w + x - radius - 1];
|
|
1813
|
+
if (x >= 0) horizontal[y * w + x] = count > 0 ? 1 : 0;
|
|
1814
|
+
}
|
|
1815
|
+
}
|
|
1816
|
+
for (let x = 0; x < w; x++) {
|
|
1817
|
+
let count = 0;
|
|
1818
|
+
for (let y = -radius; y < h; y++) {
|
|
1819
|
+
if (y + radius < h) count += horizontal[(y + radius) * w + x];
|
|
1820
|
+
if (y - radius - 1 >= 0) count -= horizontal[(y - radius - 1) * w + x];
|
|
1821
|
+
if (y >= 0) out[y * w + x] = count > 0 ? 1 : 0;
|
|
1822
|
+
}
|
|
1823
|
+
}
|
|
1824
|
+
return out;
|
|
1825
|
+
}
|
|
1826
|
+
function artworkRegions(foreground, w, h, gap, minArea) {
|
|
1827
|
+
const mask = dilate(foreground, w, h, gap), queue = new Int32Array(mask.length);
|
|
1828
|
+
const regions = [];
|
|
1829
|
+
for (let start = 0; start < mask.length; start++) {
|
|
1830
|
+
if (!mask[start]) continue;
|
|
1831
|
+
let head = 0, tail = 1, x0 = w, y0 = h, x1 = -1, y1 = -1, area = 0;
|
|
1832
|
+
queue[0] = start;
|
|
1833
|
+
mask[start] = 0;
|
|
1834
|
+
while (head < tail) {
|
|
1835
|
+
const p = queue[head++], x = p % w, y = Math.floor(p / w);
|
|
1836
|
+
if (foreground[p]) {
|
|
1837
|
+
x0 = Math.min(x0, x);
|
|
1838
|
+
x1 = Math.max(x1, x);
|
|
1839
|
+
y0 = Math.min(y0, y);
|
|
1840
|
+
y1 = Math.max(y1, y);
|
|
1841
|
+
area++;
|
|
1842
|
+
}
|
|
1843
|
+
for (const n of [
|
|
1844
|
+
x > 0 ? p - 1 : -1,
|
|
1845
|
+
x < w - 1 ? p + 1 : -1,
|
|
1846
|
+
y > 0 ? p - w : -1,
|
|
1847
|
+
y < h - 1 ? p + w : -1
|
|
1848
|
+
])
|
|
1849
|
+
if (n >= 0 && mask[n]) {
|
|
1850
|
+
mask[n] = 0;
|
|
1851
|
+
queue[tail++] = n;
|
|
1852
|
+
}
|
|
1853
|
+
}
|
|
1854
|
+
if (area >= minArea)
|
|
1855
|
+
regions.push({
|
|
1856
|
+
x: Math.max(0, x0 - 2),
|
|
1857
|
+
y: Math.max(0, y0 - 2),
|
|
1858
|
+
w: Math.min(w - 1, x1 + 2) - Math.max(0, x0 - 2) + 1,
|
|
1859
|
+
h: Math.min(h - 1, y1 + 2) - Math.max(0, y0 - 2) + 1
|
|
1860
|
+
});
|
|
1861
|
+
}
|
|
1862
|
+
return regions.sort((a, b) => a.y - b.y || a.x - b.x);
|
|
1863
|
+
}
|
|
1864
|
+
function removeOuterBackground(rgba, w, h, background, tolerance) {
|
|
1865
|
+
const result = new Uint8Array(rgba), seen = new Uint8Array(w * h), queue = new Int32Array(w * h);
|
|
1866
|
+
let head = 0, tail = 0;
|
|
1867
|
+
const add2 = (p) => {
|
|
1868
|
+
if (seen[p]) return;
|
|
1869
|
+
seen[p] = 1;
|
|
1870
|
+
if (rgba[p * 4 + 3] < 16 || [0, 1, 2].reduce((n, c) => n + Math.abs(rgba[p * 4 + c] - background[c]), 0) <= tolerance)
|
|
1871
|
+
queue[tail++] = p;
|
|
1872
|
+
};
|
|
1873
|
+
for (let x = 0; x < w; x++) {
|
|
1874
|
+
add2(x);
|
|
1875
|
+
add2((h - 1) * w + x);
|
|
1876
|
+
}
|
|
1877
|
+
for (let y = 0; y < h; y++) {
|
|
1878
|
+
add2(y * w);
|
|
1879
|
+
add2(y * w + w - 1);
|
|
1880
|
+
}
|
|
1881
|
+
while (head < tail) {
|
|
1882
|
+
const p = queue[head++], x = p % w, y = Math.floor(p / w);
|
|
1883
|
+
result[p * 4 + 3] = 0;
|
|
1884
|
+
if (x) add2(p - 1);
|
|
1885
|
+
if (x < w - 1) add2(p + 1);
|
|
1886
|
+
if (y) add2(p - w);
|
|
1887
|
+
if (y < h - 1) add2(p + w);
|
|
1888
|
+
}
|
|
1889
|
+
return result;
|
|
1890
|
+
}
|
|
1891
|
+
async function extractKit(file, out, input) {
|
|
1892
|
+
const options = optionsSchema.parse(input);
|
|
1893
|
+
const { data, info } = await sharp3(resolve3(file), { limitInputPixels: 24e6 }).rotate().ensureAlpha().toColourspace("srgb").raw().toBuffer({ resolveWithObject: true });
|
|
1894
|
+
const w = info.width, h = info.height;
|
|
1895
|
+
const corners = [0, w - 1, (h - 1) * w, w * h - 1];
|
|
1896
|
+
const transparentBackground = corners.every((p) => data[p * 4 + 3] < 16);
|
|
1897
|
+
const background = [0, 1, 2].map(
|
|
1898
|
+
(c) => Math.round(corners.reduce((n, p) => n + data[p * 4 + c], 0) / 4)
|
|
1899
|
+
);
|
|
1900
|
+
const foreground = new Uint8Array(w * h);
|
|
1901
|
+
for (let p = 0; p < foreground.length; p++)
|
|
1902
|
+
foreground[p] = data[p * 4 + 3] >= 16 && (transparentBackground || [0, 1, 2].reduce((n, c) => n + Math.abs(data[p * 4 + c] - background[c]), 0) > options.tolerance) ? 1 : 0;
|
|
1903
|
+
const regions = options.regions ?? artworkRegions(foreground, w, h, options.gap, options.minArea);
|
|
1904
|
+
if (!regions.length || regions.length > 24)
|
|
1905
|
+
throw new InputError("Choose 1\u201324 regions using --params, or adjust gap/minArea.");
|
|
1906
|
+
if (regions.some((r) => r.x + r.w > w || r.y + r.h > h))
|
|
1907
|
+
throw new InputError("Every region must fit inside the image.");
|
|
1908
|
+
const target = resolve3(out), temporary = await mkdtemp(join2(dirname(target), ".makeables-extract-"));
|
|
1909
|
+
try {
|
|
1910
|
+
await mkdir(join2(temporary, "pieces"));
|
|
1911
|
+
const kit = {
|
|
1912
|
+
format: "makeables-kit",
|
|
1913
|
+
version: 1,
|
|
1914
|
+
name: options.name,
|
|
1915
|
+
items: [],
|
|
1916
|
+
sheets: []
|
|
1917
|
+
};
|
|
1918
|
+
const views = [];
|
|
1919
|
+
for (const [index2, region] of regions.entries()) {
|
|
1920
|
+
const id = `piece-${String(index2 + 1).padStart(2, "0")}`, name = region.name ?? `Piece ${index2 + 1}`;
|
|
1921
|
+
const crop = await sharp3(data, { raw: { width: w, height: h, channels: 4 } }).extract({ left: region.x, top: region.y, width: region.w, height: region.h }).raw().toBuffer();
|
|
1922
|
+
const pixels = transparentBackground ? crop : removeOuterBackground(crop, region.w, region.h, background, options.tolerance);
|
|
1923
|
+
const png = await sharp3(pixels, { raw: { width: region.w, height: region.h, channels: 4 } }).png().toBuffer();
|
|
1924
|
+
const design = createStickerDesign();
|
|
1925
|
+
design.sticker = { ...defaultStickerMaterial, finish: "vinyl", sparkle: 0, holography: 0 };
|
|
1926
|
+
design.name = name;
|
|
1927
|
+
design.source = id;
|
|
1928
|
+
design.assets = {
|
|
1929
|
+
artwork: {
|
|
1930
|
+
source: "image",
|
|
1931
|
+
name,
|
|
1932
|
+
data: `data:image/png;base64,${png.toString("base64")}`,
|
|
1933
|
+
sourcePixels: { width: region.w, height: region.h }
|
|
1934
|
+
}
|
|
1935
|
+
};
|
|
1936
|
+
design.front = {
|
|
1937
|
+
background: "#00000000",
|
|
1938
|
+
layers: [
|
|
1939
|
+
{
|
|
1940
|
+
id: "artwork",
|
|
1941
|
+
kind: "image",
|
|
1942
|
+
asset: "artwork",
|
|
1943
|
+
name,
|
|
1944
|
+
x: 64,
|
|
1945
|
+
y: 64,
|
|
1946
|
+
w: 896,
|
|
1947
|
+
h: 896,
|
|
1948
|
+
fit: "contain",
|
|
1949
|
+
opacity: 1,
|
|
1950
|
+
radius: 0
|
|
1951
|
+
}
|
|
1952
|
+
]
|
|
1953
|
+
};
|
|
1954
|
+
design.back = { background: "#00000000", layers: [] };
|
|
1955
|
+
await writeFile2(
|
|
1956
|
+
join2(temporary, `pieces/${id}.json`),
|
|
1957
|
+
JSON.stringify(badgeDesignSchema.parse(design), null, 2)
|
|
1958
|
+
);
|
|
1959
|
+
kit.items.push({ id, file: `pieces/${id}.json` });
|
|
1960
|
+
views.push({ name, bytes: png });
|
|
1961
|
+
}
|
|
1962
|
+
await writeFile2(join2(temporary, "kit.json"), JSON.stringify(kit, null, 2));
|
|
1963
|
+
await writeFile2(join2(temporary, "contact.png"), await kitCover(options.name, views));
|
|
1964
|
+
await mkdir(target);
|
|
1965
|
+
await rename(temporary, target);
|
|
1966
|
+
return {
|
|
1967
|
+
path: join2(target, "kit.json"),
|
|
1968
|
+
preview: join2(target, "contact.png"),
|
|
1969
|
+
items: kit.items.length,
|
|
1970
|
+
regions,
|
|
1971
|
+
source: { width: w, height: h },
|
|
1972
|
+
notice: "Review the contact proof. Raster lettering remains part of each image; preserve new text as native layers."
|
|
1973
|
+
};
|
|
1974
|
+
} catch (error) {
|
|
1975
|
+
await rm(temporary, { recursive: true, force: true });
|
|
1976
|
+
throw error;
|
|
1977
|
+
}
|
|
1978
|
+
}
|
|
1979
|
+
|
|
1980
|
+
// src/layers.ts
|
|
1981
|
+
import { randomBytes } from "crypto";
|
|
1982
|
+
import { ZodError } from "zod";
|
|
1983
|
+
var layerKinds = ["text", "shape", "graphic", "qr"];
|
|
1984
|
+
function defaults(kind, width, height) {
|
|
1985
|
+
const centered = (w, h) => ({
|
|
1986
|
+
x: Math.round((width - w) / 2),
|
|
1987
|
+
y: Math.round((height - h) / 2),
|
|
1988
|
+
w,
|
|
1989
|
+
h
|
|
1990
|
+
});
|
|
1991
|
+
if (kind === "text")
|
|
1992
|
+
return {
|
|
1993
|
+
...centered(Math.round(width * 0.8), 160),
|
|
1994
|
+
text: "Text",
|
|
1995
|
+
binding: "none",
|
|
1996
|
+
color: "#141414",
|
|
1997
|
+
font: "sans",
|
|
1998
|
+
size: 72,
|
|
1999
|
+
weight: "700",
|
|
2000
|
+
align: "center",
|
|
2001
|
+
fit: "shrink"
|
|
2002
|
+
};
|
|
2003
|
+
if (kind === "shape")
|
|
2004
|
+
return {
|
|
2005
|
+
...centered(Math.round(width * 0.6), Math.round(height * 0.3)),
|
|
2006
|
+
shape: "rectangle",
|
|
2007
|
+
color: "#141414",
|
|
2008
|
+
radius: 0,
|
|
2009
|
+
stroke: 0,
|
|
2010
|
+
opacity: 1
|
|
2011
|
+
};
|
|
2012
|
+
if (kind === "qr") return centered(320, 320);
|
|
2013
|
+
return {
|
|
2014
|
+
...centered(Math.round(width * 0.6), Math.round(width * 0.6)),
|
|
2015
|
+
pattern: "path",
|
|
2016
|
+
color: "#141414",
|
|
2017
|
+
accent: "#141414",
|
|
2018
|
+
density: 1,
|
|
2019
|
+
seed: 1
|
|
2020
|
+
};
|
|
2021
|
+
}
|
|
2022
|
+
function addLayer(design, face, kind, params = {}) {
|
|
2023
|
+
if (!layerKinds.includes(kind))
|
|
2024
|
+
throw new InputError(
|
|
2025
|
+
`--kind must be one of ${layerKinds.join(", ")}. Add images with --sticker <path>.`
|
|
2026
|
+
);
|
|
2027
|
+
const profile = designProfile(design);
|
|
2028
|
+
const id = typeof params.id === "string" ? params.id : `${kind}-${randomBytes(3).toString("hex")}`;
|
|
2029
|
+
if (design[face].layers.some((layer2) => layer2.id === id))
|
|
2030
|
+
throw new InputError(`Layer ${id} already exists on ${face}. Use layer set to change it.`);
|
|
2031
|
+
const layer = {
|
|
2032
|
+
...defaults(kind, profile.width, profile.height),
|
|
2033
|
+
...params,
|
|
2034
|
+
id,
|
|
2035
|
+
kind
|
|
2036
|
+
};
|
|
2037
|
+
if (kind === "graphic" && layer.pattern === "path" && !layer.path)
|
|
2038
|
+
throw new InputError('A path graphic needs "path" (SVG path data) and "viewBox" in --params.');
|
|
2039
|
+
try {
|
|
2040
|
+
const next = badgeDesignSchema.parse({
|
|
2041
|
+
...design,
|
|
2042
|
+
[face]: { ...design[face], layers: [...design[face].layers, layer] }
|
|
2043
|
+
});
|
|
2044
|
+
const text = typeof layer.text === "string" ? layer.text : "";
|
|
2045
|
+
const unsupported = [
|
|
2046
|
+
...new Set(
|
|
2047
|
+
[...text].filter((c) => {
|
|
2048
|
+
const code = c.codePointAt(0) ?? 0;
|
|
2049
|
+
return code > 591 && !(code >= 8208 && code <= 8231) && code !== 8364;
|
|
2050
|
+
})
|
|
2051
|
+
)
|
|
2052
|
+
];
|
|
2053
|
+
const warnings = unsupported.length ? [
|
|
2054
|
+
`The bundled fonts may not draw ${unsupported.join(" ")}. Draw symbols as a graphic path or image layer.`
|
|
2055
|
+
] : [];
|
|
2056
|
+
return { design: next, id, warnings };
|
|
2057
|
+
} catch (error) {
|
|
2058
|
+
if (error instanceof ZodError)
|
|
2059
|
+
throw new InputError(
|
|
2060
|
+
`Invalid ${kind} layer: ${error.issues.map((issue) => `${issue.path.slice(3).join(".") || "layer"}: ${issue.message}`).join("; ")}. Run makeables schema for every field.`
|
|
2061
|
+
);
|
|
2062
|
+
throw error;
|
|
2063
|
+
}
|
|
2064
|
+
}
|
|
2065
|
+
function blankDesign(product, name) {
|
|
2066
|
+
const title = typeof name === "string" && name.trim() ? name.trim().slice(0, 60) : `Untitled ${product}`;
|
|
2067
|
+
const preset = structuredClone(
|
|
2068
|
+
product === "card" ? findCardStyle("pearl") : findDesign("herbario-azul")
|
|
2069
|
+
);
|
|
2070
|
+
if (!preset) throw new Error(`Missing base ${product} style.`);
|
|
2071
|
+
const base = {
|
|
2072
|
+
...preset,
|
|
2073
|
+
product,
|
|
2074
|
+
name: title,
|
|
2075
|
+
event: title,
|
|
2076
|
+
description: "",
|
|
2077
|
+
artPrompt: "",
|
|
2078
|
+
source: void 0,
|
|
2079
|
+
artwork: void 0,
|
|
2080
|
+
assets: {}
|
|
2081
|
+
};
|
|
2082
|
+
if (product === "card")
|
|
2083
|
+
return badgeDesignSchema.parse({
|
|
2084
|
+
...base,
|
|
2085
|
+
front: {
|
|
2086
|
+
background: "#f4f1eb",
|
|
2087
|
+
layers: preset.front.layers.filter((layer) => layer.kind === "card-mark")
|
|
2088
|
+
},
|
|
2089
|
+
back: { background: "#f4f1eb", layers: [] }
|
|
2090
|
+
});
|
|
2091
|
+
const portrait = preset.front.layers.find((layer) => layer.kind === "portrait");
|
|
2092
|
+
const text = (id, binding, y, size, weight = "700") => ({
|
|
2093
|
+
id,
|
|
2094
|
+
kind: "text",
|
|
2095
|
+
x: 96,
|
|
2096
|
+
y,
|
|
2097
|
+
w: 832,
|
|
2098
|
+
h: Math.round(size * 1.3),
|
|
2099
|
+
text: "",
|
|
2100
|
+
binding,
|
|
2101
|
+
color: "#141414",
|
|
2102
|
+
font: "sans",
|
|
2103
|
+
size,
|
|
2104
|
+
weight,
|
|
2105
|
+
align: "center",
|
|
2106
|
+
fit: "shrink"
|
|
2107
|
+
});
|
|
2108
|
+
return badgeDesignSchema.parse({
|
|
2109
|
+
...base,
|
|
2110
|
+
front: {
|
|
2111
|
+
background: "#f4f1eb",
|
|
2112
|
+
layers: [
|
|
2113
|
+
...portrait ? [{ ...portrait, x: 192, y: 240, w: 640, h: 640, filter: "original", fade: void 0 }] : [],
|
|
2114
|
+
text("participant-name", "name", 960, 96),
|
|
2115
|
+
text("participant-role", "role", 1100, 48, "500")
|
|
2116
|
+
]
|
|
2117
|
+
},
|
|
2118
|
+
back: {
|
|
2119
|
+
background: "#f4f1eb",
|
|
2120
|
+
// The back repeats the holder: badges need name and role on both faces.
|
|
2121
|
+
layers: [
|
|
2122
|
+
text("back-name", "name", 200, 72),
|
|
2123
|
+
text("back-role", "role", 310, 40, "500"),
|
|
2124
|
+
{ id: "access-qr", kind: "qr", x: 312, y: 520, w: 400, h: 400 },
|
|
2125
|
+
text("event-name", "event", 1e3, 56)
|
|
2126
|
+
]
|
|
2127
|
+
}
|
|
2128
|
+
});
|
|
218
2129
|
}
|
|
219
2130
|
|
|
220
|
-
// src/
|
|
221
|
-
import {
|
|
222
|
-
import {
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
2131
|
+
// src/preflight.ts
|
|
2132
|
+
import { spawnSync } from "child_process";
|
|
2133
|
+
import { mkdtemp as mkdtemp2, readdir, rm as rm2 } from "fs/promises";
|
|
2134
|
+
import { tmpdir } from "os";
|
|
2135
|
+
import { join as join3 } from "path";
|
|
2136
|
+
var generationKeys = [
|
|
2137
|
+
"AI_GATEWAY_API_KEY",
|
|
2138
|
+
"VERCEL_OIDC_TOKEN",
|
|
2139
|
+
"OPENAI_API_KEY",
|
|
2140
|
+
"GOOGLE_GENERATIVE_AI_API_KEY"
|
|
2141
|
+
];
|
|
2142
|
+
var defaultRun = (command, args) => {
|
|
2143
|
+
const result = spawnSync(command, args, { encoding: "utf8", shell: false, timeout: 12e4 });
|
|
2144
|
+
return {
|
|
2145
|
+
status: result.status,
|
|
2146
|
+
stdout: result.stdout ?? "",
|
|
2147
|
+
stderr: result.stderr ?? "",
|
|
2148
|
+
error: result.error
|
|
2149
|
+
};
|
|
2150
|
+
};
|
|
2151
|
+
function redact(text, env) {
|
|
2152
|
+
let out = text;
|
|
2153
|
+
for (const key2 of generationKeys) {
|
|
2154
|
+
const value = env[key2];
|
|
2155
|
+
if (value && value.length > 4) out = out.split(value).join("[redacted]");
|
|
232
2156
|
}
|
|
233
|
-
|
|
234
|
-
if (!value?.ok || !value.data || typeof value.data !== "object")
|
|
235
|
-
throw new InputError(value?.error?.message || "The tool returned an invalid result.");
|
|
236
|
-
return value.data;
|
|
2157
|
+
return out.trim().slice(0, 400);
|
|
237
2158
|
}
|
|
238
|
-
async function
|
|
239
|
-
const
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
2159
|
+
async function preflight(options = {}) {
|
|
2160
|
+
const env = options.env ?? process.env;
|
|
2161
|
+
const run2 = options.run ?? defaultRun;
|
|
2162
|
+
const version2 = run2("ai", ["--version"]);
|
|
2163
|
+
const aiCli = {
|
|
2164
|
+
installed: !version2.error && version2.status === 0,
|
|
2165
|
+
version: version2.status === 0 ? version2.stdout.trim().slice(0, 40) : null,
|
|
2166
|
+
install: "npm install --global ai-cli"
|
|
244
2167
|
};
|
|
245
|
-
const
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
2168
|
+
const keys = generationKeys.filter((key2) => Boolean(env[key2]));
|
|
2169
|
+
let probe = null;
|
|
2170
|
+
if (options.probe) {
|
|
2171
|
+
if (!aiCli.installed) probe = { ok: false, error: "ai-cli is not installed." };
|
|
2172
|
+
else if (!keys.length) probe = { ok: false, error: "No generation key in the environment." };
|
|
2173
|
+
else {
|
|
2174
|
+
const dir = await mkdtemp2(join3(tmpdir(), "makeables-probe-"));
|
|
2175
|
+
try {
|
|
2176
|
+
const result = run2("ai", [
|
|
2177
|
+
"image",
|
|
2178
|
+
"--quiet",
|
|
2179
|
+
"--json",
|
|
2180
|
+
"--no-preview",
|
|
2181
|
+
"--output",
|
|
2182
|
+
dir,
|
|
2183
|
+
"a small flat orange circle on a white background"
|
|
2184
|
+
]);
|
|
2185
|
+
const made = (await readdir(dir)).length > 0;
|
|
2186
|
+
probe = result.status === 0 && made ? { ok: true } : {
|
|
2187
|
+
ok: false,
|
|
2188
|
+
error: redact(result.stderr || result.stdout || "No image was produced.", env)
|
|
2189
|
+
};
|
|
2190
|
+
} finally {
|
|
2191
|
+
await rm2(dir, { recursive: true, force: true });
|
|
2192
|
+
}
|
|
2193
|
+
}
|
|
2194
|
+
}
|
|
2195
|
+
const cliReady = probe ? probe.ok : aiCli.installed && keys.length > 0;
|
|
2196
|
+
const tracing = run2("potrace", ["--version"]);
|
|
256
2197
|
return {
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
2198
|
+
imageGeneration: {
|
|
2199
|
+
aiCli,
|
|
2200
|
+
keys,
|
|
2201
|
+
probe,
|
|
2202
|
+
ready: cliReady,
|
|
2203
|
+
recommendation: cliReady ? "generate" : "host-or-install"
|
|
2204
|
+
},
|
|
2205
|
+
potrace: { installed: !tracing.error && tracing.status === 0 },
|
|
2206
|
+
ask: {
|
|
2207
|
+
source: ["official", "generated", "mixed"],
|
|
2208
|
+
defaultSource: "mixed",
|
|
2209
|
+
stickers: { min: 6, max: 10 }
|
|
2210
|
+
}
|
|
261
2211
|
};
|
|
262
2212
|
}
|
|
263
|
-
|
|
264
|
-
const
|
|
265
|
-
const
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
if (!match || image.dataUrl.length > 56e5)
|
|
269
|
-
throw new InputError("The tool did not return a supported image.");
|
|
270
|
-
const bytes = Buffer.from(match[1], "base64");
|
|
271
|
-
if (bytes.length > 4e6) throw new InputError("Image is larger than 4 MB.");
|
|
272
|
-
const path = resolve(out);
|
|
273
|
-
await writeFile(path, bytes, { flag: "wx", mode: 384 });
|
|
274
|
-
return { path, bytes: bytes.length };
|
|
2213
|
+
function preflightSummary(result) {
|
|
2214
|
+
const generation = result.imageGeneration;
|
|
2215
|
+
const line = generation.ready ? `Image generation: ready (ai-cli${generation.probe?.ok ? ", probe passed" : ""}).` : generation.probe && !generation.probe.ok ? `Image generation: failed (${generation.probe.error}).` : generation.aiCli.installed ? "Image generation: ai-cli installed, no key. Use the host's image tool or set AI_GATEWAY_API_KEY." : "Image generation: not installed. Use the host's image tool or ask to install ai-cli.";
|
|
2216
|
+
return `${line}
|
|
2217
|
+
Ask the user: art source (official, generated or mixed) and how many stickers (6-10).`;
|
|
275
2218
|
}
|
|
276
2219
|
|
|
277
2220
|
// src/presentation.ts
|
|
@@ -298,11 +2241,11 @@ function banner(version2) {
|
|
|
298
2241
|
// src/publish.ts
|
|
299
2242
|
import { createHash as createHash2, randomUUID } from "crypto";
|
|
300
2243
|
import { constants } from "fs";
|
|
301
|
-
import { appendFile, link, mkdir, open, readFile as
|
|
2244
|
+
import { appendFile, link, mkdir as mkdir2, open, readFile as readFile4, rename as rename2, rm as rm3 } from "fs/promises";
|
|
302
2245
|
import { homedir } from "os";
|
|
303
|
-
import { dirname, join, resolve as
|
|
304
|
-
import
|
|
305
|
-
import { z } from "zod";
|
|
2246
|
+
import { dirname as dirname2, join as join4, resolve as resolve4 } from "path";
|
|
2247
|
+
import sharp4 from "sharp";
|
|
2248
|
+
import { z as z2 } from "zod";
|
|
306
2249
|
async function saveBundle(url, out) {
|
|
307
2250
|
const state = await studioRequest(url, "/call", {
|
|
308
2251
|
name: "badge_inspect",
|
|
@@ -323,7 +2266,7 @@ async function saveBundle(url, out) {
|
|
|
323
2266
|
result.error?.message ?? "The editor could not export a bundle. Refresh it after saving the current draft."
|
|
324
2267
|
);
|
|
325
2268
|
const pkg = await readPackageJson(result.data);
|
|
326
|
-
const path =
|
|
2269
|
+
const path = resolve4(out);
|
|
327
2270
|
const file = await open(path, "wx", 384);
|
|
328
2271
|
try {
|
|
329
2272
|
await file.writeFile(`${JSON.stringify(packageJson(pkg))}
|
|
@@ -332,7 +2275,7 @@ async function saveBundle(url, out) {
|
|
|
332
2275
|
} finally {
|
|
333
2276
|
await file.close();
|
|
334
2277
|
}
|
|
335
|
-
const saved = await
|
|
2278
|
+
const saved = await readFile4(path);
|
|
336
2279
|
return {
|
|
337
2280
|
path,
|
|
338
2281
|
bytes: saved.length,
|
|
@@ -344,55 +2287,55 @@ async function saveBundle(url, out) {
|
|
|
344
2287
|
published: false
|
|
345
2288
|
};
|
|
346
2289
|
}
|
|
347
|
-
var receiptSchema =
|
|
348
|
-
id:
|
|
349
|
-
version:
|
|
350
|
-
state:
|
|
351
|
-
url:
|
|
2290
|
+
var receiptSchema = z2.object({
|
|
2291
|
+
id: z2.string().uuid(),
|
|
2292
|
+
version: z2.number().int().positive(),
|
|
2293
|
+
state: z2.enum(["published", "withdrawn"]),
|
|
2294
|
+
url: z2.string()
|
|
352
2295
|
});
|
|
353
2296
|
|
|
354
2297
|
// src/reference-image.ts
|
|
355
|
-
import { spawnSync } from "child_process";
|
|
356
|
-
import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "fs";
|
|
357
|
-
import { tmpdir } from "os";
|
|
358
|
-
import { join as
|
|
2298
|
+
import { spawnSync as spawnSync2 } from "child_process";
|
|
2299
|
+
import { existsSync as existsSync2, mkdirSync, mkdtempSync, readFileSync as readFileSync2, rmSync, writeFileSync } from "fs";
|
|
2300
|
+
import { tmpdir as tmpdir2 } from "os";
|
|
2301
|
+
import { join as join5, resolve as resolve5 } from "path";
|
|
359
2302
|
import { DOMParser as DOMParser2, XMLSerializer } from "@xmldom/xmldom";
|
|
360
|
-
import
|
|
361
|
-
import { z as
|
|
2303
|
+
import sharp5 from "sharp";
|
|
2304
|
+
import { z as z3 } from "zod";
|
|
362
2305
|
var W = 1200;
|
|
363
2306
|
var H = 756;
|
|
364
2307
|
var N = W * H;
|
|
365
|
-
var optionSchema =
|
|
366
|
-
crop:
|
|
367
|
-
patch:
|
|
368
|
-
"edge-band":
|
|
369
|
-
"bg-tol":
|
|
370
|
-
title:
|
|
371
|
-
desc:
|
|
372
|
-
credit:
|
|
373
|
-
tones:
|
|
374
|
-
ink:
|
|
375
|
-
raster:
|
|
376
|
-
points:
|
|
377
|
-
box:
|
|
378
|
-
rows:
|
|
379
|
-
cols:
|
|
380
|
-
dark:
|
|
381
|
-
colors:
|
|
382
|
-
at:
|
|
383
|
-
up:
|
|
384
|
-
k:
|
|
385
|
-
fill:
|
|
386
|
-
top:
|
|
387
|
-
upright:
|
|
388
|
-
"ref-box":
|
|
2308
|
+
var optionSchema = z3.object({
|
|
2309
|
+
crop: z3.string().optional(),
|
|
2310
|
+
patch: z3.array(z3.string()).default([]),
|
|
2311
|
+
"edge-band": z3.coerce.number().min(0).max(1e3).optional(),
|
|
2312
|
+
"bg-tol": z3.coerce.number().min(0).max(765).optional(),
|
|
2313
|
+
title: z3.string().min(1).max(200).optional(),
|
|
2314
|
+
desc: z3.string().max(5e3).optional(),
|
|
2315
|
+
credit: z3.string().max(5e3).optional(),
|
|
2316
|
+
tones: z3.coerce.number().int().min(2).max(12).optional(),
|
|
2317
|
+
ink: z3.coerce.number().min(0).max(1).optional(),
|
|
2318
|
+
raster: z3.boolean().optional(),
|
|
2319
|
+
points: z3.array(z3.string()).max(100).optional(),
|
|
2320
|
+
box: z3.string().optional(),
|
|
2321
|
+
rows: z3.string().optional(),
|
|
2322
|
+
cols: z3.string().optional(),
|
|
2323
|
+
dark: z3.coerce.number().min(0).max(765).optional(),
|
|
2324
|
+
colors: z3.coerce.number().int().min(1).max(12).optional(),
|
|
2325
|
+
at: z3.string().optional(),
|
|
2326
|
+
up: z3.coerce.number().int().min(1).max(8).optional(),
|
|
2327
|
+
k: z3.coerce.number().min(0).max(1).optional(),
|
|
2328
|
+
fill: z3.string().regex(/^#[a-fA-F0-9]{6}$/).optional(),
|
|
2329
|
+
top: z3.enum(["left", "right"]).optional(),
|
|
2330
|
+
upright: z3.enum(["left", "right"]).optional(),
|
|
2331
|
+
"ref-box": z3.string().optional()
|
|
389
2332
|
}).strict();
|
|
390
2333
|
function fail(message) {
|
|
391
2334
|
throw new InputError(message);
|
|
392
2335
|
}
|
|
393
2336
|
var nums = (value, count, name) => {
|
|
394
2337
|
const parts = value.split(",").map(Number);
|
|
395
|
-
if (value.split(",").some((
|
|
2338
|
+
if (value.split(",").some((part2) => !part2.trim())) fail(`${name} contains an empty coordinate.`);
|
|
396
2339
|
if (parts.length < count || parts.some((v) => !Number.isFinite(v)))
|
|
397
2340
|
fail(`${name} needs ${count} comma-separated numbers.`);
|
|
398
2341
|
return parts;
|
|
@@ -404,7 +2347,7 @@ function validBox(values, width, height) {
|
|
|
404
2347
|
}
|
|
405
2348
|
var lumOf = (r, g, b) => 0.299 * r + 0.587 * g + 0.114 * b;
|
|
406
2349
|
async function detect(file) {
|
|
407
|
-
const { data, info } = await
|
|
2350
|
+
const { data, info } = await sharp5(file, { limitInputPixels: 24e6 }).toColourspace("srgb").removeAlpha().raw().toBuffer({ resolveWithObject: true });
|
|
408
2351
|
const { width: w, height: h } = info;
|
|
409
2352
|
const px = (x, y) => {
|
|
410
2353
|
const i = (y * w + x) * 3;
|
|
@@ -480,7 +2423,7 @@ async function detect(file) {
|
|
|
480
2423
|
async function prepare(file, o) {
|
|
481
2424
|
if (!o.crop || !o.out) fail('prepare needs --out <dir> and --params with {"crop":"x,y,w,h"}.');
|
|
482
2425
|
const [cx, cy, cw, ch] = nums(o.crop, 4, "crop");
|
|
483
|
-
const meta = await
|
|
2426
|
+
const meta = await sharp5(file, { limitInputPixels: 24e6 }).metadata();
|
|
484
2427
|
validBox([cx, cy, cw, ch], meta.width ?? 0, meta.height ?? 0);
|
|
485
2428
|
if (Math.abs(cw / ch / (W / H) - 1) > 0.03)
|
|
486
2429
|
fail(
|
|
@@ -493,12 +2436,12 @@ async function prepare(file, o) {
|
|
|
493
2436
|
if (feather < 0 || ![0, 1].includes(flop))
|
|
494
2437
|
fail("Patch feather must be nonnegative and flop must be 0 or 1.");
|
|
495
2438
|
}
|
|
496
|
-
const out =
|
|
2439
|
+
const out = resolve5(o.out);
|
|
497
2440
|
mkdirSync(out);
|
|
498
2441
|
const layers = [];
|
|
499
2442
|
for (const spec of o.patch) {
|
|
500
2443
|
const [dx, dy, pw, ph, sx, sy, feather = 6, flop = 0] = nums(spec, 6, "patch");
|
|
501
|
-
const patch = await
|
|
2444
|
+
const patch = await sharp5(file).extract({ left: sx, top: sy, width: pw, height: ph }).flop(!!flop).toColourspace("srgb").removeAlpha().raw().toBuffer();
|
|
502
2445
|
const rgba = Buffer.alloc(pw * ph * 4);
|
|
503
2446
|
for (let y = 0; y < ph; y++)
|
|
504
2447
|
for (let x = 0; x < pw; x++) {
|
|
@@ -510,10 +2453,10 @@ async function prepare(file, o) {
|
|
|
510
2453
|
}
|
|
511
2454
|
layers.push({ input: rgba, raw: { width: pw, height: ph, channels: 4 }, left: dx, top: dy });
|
|
512
2455
|
}
|
|
513
|
-
const patched = await
|
|
514
|
-
const card = await
|
|
2456
|
+
const patched = await sharp5(file).removeAlpha().composite(layers).png().toBuffer();
|
|
2457
|
+
const card = await sharp5(patched).extract({ left: cx, top: cy, width: cw, height: ch }).toColourspace("srgb").removeAlpha().raw().toBuffer({ resolveWithObject: true });
|
|
515
2458
|
const px = card.data, CW = card.info.width, CH = card.info.height;
|
|
516
|
-
const src = await
|
|
2459
|
+
const src = await sharp5(file, { limitInputPixels: 24e6 }).toColourspace("srgb").removeAlpha().raw().toBuffer({ resolveWithObject: true });
|
|
517
2460
|
const sp = (x, y) => {
|
|
518
2461
|
const i = (y * src.info.width + x) * 3;
|
|
519
2462
|
return [src.data[i], src.data[i + 1], src.data[i + 2]];
|
|
@@ -553,14 +2496,14 @@ async function prepare(file, o) {
|
|
|
553
2496
|
if (y > 0) queue.push(i - CW);
|
|
554
2497
|
if (y < CH - 1) queue.push(i + CW);
|
|
555
2498
|
}
|
|
556
|
-
const cardPng = await
|
|
557
|
-
const resized =
|
|
558
|
-
await resized.clone().png().toFile(
|
|
559
|
-
await resized.clone().jpeg({ quality: 84, mozjpeg: true }).toFile(
|
|
560
|
-
const
|
|
2499
|
+
const cardPng = await sharp5(px, { raw: card.info }).png().toBuffer();
|
|
2500
|
+
const resized = sharp5(cardPng).resize(W, H, { fit: "fill", kernel: "lanczos3" });
|
|
2501
|
+
await resized.clone().png().toFile(join5(out, "card.png"));
|
|
2502
|
+
await resized.clone().jpeg({ quality: 84, mozjpeg: true }).toFile(join5(out, "card.jpg"));
|
|
2503
|
+
const rgb2 = await resized.clone().toColourspace("srgb").raw().toBuffer();
|
|
561
2504
|
const g = await resized.clone().toColourspace("b-w").raw().toBuffer();
|
|
562
|
-
writeFileSync(
|
|
563
|
-
writeFileSync(
|
|
2505
|
+
writeFileSync(join5(out, "rgb.raw"), rgb2);
|
|
2506
|
+
writeFileSync(join5(out, "lum.raw"), g);
|
|
564
2507
|
const GW = W * 2, GH = H * 2, big = Buffer.alloc(GW * GH);
|
|
565
2508
|
for (let y = 0; y < GH; y++) {
|
|
566
2509
|
const fy = Math.min(H - 1, Math.max(0, (y + 0.5) / 2 - 0.5)), y0 = Math.floor(fy), y1 = Math.min(H - 1, y0 + 1), ty = fy - y0;
|
|
@@ -572,14 +2515,14 @@ async function prepare(file, o) {
|
|
|
572
2515
|
);
|
|
573
2516
|
}
|
|
574
2517
|
}
|
|
575
|
-
writeFileSync(
|
|
2518
|
+
writeFileSync(join5(out, "ink.pgm"), Buffer.concat([Buffer.from(`P5
|
|
576
2519
|
${GW} ${GH}
|
|
577
2520
|
255
|
|
578
2521
|
`), big]));
|
|
579
2522
|
const hex = paper.map((v) => Math.round(v).toString(16).padStart(2, "0")).join("");
|
|
580
2523
|
return {
|
|
581
2524
|
out,
|
|
582
|
-
preview:
|
|
2525
|
+
preview: join5(out, "card.png"),
|
|
583
2526
|
paper: `#${hex}`,
|
|
584
2527
|
patches: o.patch.length,
|
|
585
2528
|
filledEdgePixels: filled,
|
|
@@ -587,7 +2530,7 @@ ${GW} ${GH}
|
|
|
587
2530
|
};
|
|
588
2531
|
}
|
|
589
2532
|
function potrace(input, extra) {
|
|
590
|
-
const r =
|
|
2533
|
+
const r = spawnSync2(
|
|
591
2534
|
"potrace",
|
|
592
2535
|
[input, "-b", "svg", "-o", "-", "-W", `${W}pt`, "-H", `${H}pt`, ...extra],
|
|
593
2536
|
{ maxBuffer: 64 << 20 }
|
|
@@ -604,11 +2547,11 @@ function potrace(input, extra) {
|
|
|
604
2547
|
}
|
|
605
2548
|
var esc = (s) => String(s).replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """);
|
|
606
2549
|
function vectorize(dir, o) {
|
|
607
|
-
dir =
|
|
2550
|
+
dir = resolve5(dir);
|
|
608
2551
|
if (!o.out || !o.title) fail("vectorize needs --out <file.svg> and --title");
|
|
609
2552
|
const K = Number(o.tones ?? 7);
|
|
610
|
-
const lum =
|
|
611
|
-
if (lum.length !== N ||
|
|
2553
|
+
const lum = readFileSync2(join5(dir, "lum.raw")), rgb2 = readFileSync2(join5(dir, "rgb.raw"));
|
|
2554
|
+
if (lum.length !== N || rgb2.length !== N * 3)
|
|
612
2555
|
fail("Expected an intact directory created by image prepare.");
|
|
613
2556
|
let c = [...Array(K)].map((_, i) => 30 + i * 200 / (K - 1));
|
|
614
2557
|
const band = new Uint8Array(N);
|
|
@@ -646,9 +2589,9 @@ function vectorize(dir, o) {
|
|
|
646
2589
|
let r = 0, g = 0, b = 0, n = 0;
|
|
647
2590
|
for (let i = 0; i < N; i++)
|
|
648
2591
|
if (band[i] === k) {
|
|
649
|
-
r +=
|
|
650
|
-
g +=
|
|
651
|
-
b +=
|
|
2592
|
+
r += rgb2[i * 3];
|
|
2593
|
+
g += rgb2[i * 3 + 1];
|
|
2594
|
+
b += rgb2[i * 3 + 2];
|
|
652
2595
|
n++;
|
|
653
2596
|
}
|
|
654
2597
|
const h = (v) => Math.round(v / Math.max(n, 1)).toString(16).padStart(2, "0");
|
|
@@ -657,11 +2600,11 @@ function vectorize(dir, o) {
|
|
|
657
2600
|
const L = used.length;
|
|
658
2601
|
const inkK = o.ink !== void 0 ? Number(o.ink) : L > 1 ? (c[used[0]] + c[used[1]]) / 2 / 255 : 0.25;
|
|
659
2602
|
const groups = [];
|
|
660
|
-
const maskFile =
|
|
2603
|
+
const maskFile = join5(dir, "mask.pbm");
|
|
661
2604
|
for (let j = o.raster ? 0 : L - 2; j >= 0; j--) {
|
|
662
2605
|
let traced;
|
|
663
2606
|
if (j === 0) {
|
|
664
|
-
traced = potrace(
|
|
2607
|
+
traced = potrace(join5(dir, "ink.pgm"), [
|
|
665
2608
|
"-k",
|
|
666
2609
|
inkK.toFixed(4),
|
|
667
2610
|
"-t",
|
|
@@ -684,7 +2627,7 @@ ${W} ${H}
|
|
|
684
2627
|
if (traced) groups.push(`<g transform="${traced.tr}" fill="${colors[j]}">${traced.paths}</g>`);
|
|
685
2628
|
}
|
|
686
2629
|
const base = o.raster ? `<rect width="${W}" height="${H}" fill="${colors[L - 1]}"/>
|
|
687
|
-
<image width="${W}" height="${H}" preserveAspectRatio="none" href="data:image/jpeg;base64,${
|
|
2630
|
+
<image width="${W}" height="${H}" preserveAspectRatio="none" href="data:image/jpeg;base64,${readFileSync2(join5(dir, "card.jpg")).toString("base64")}"/>` : `<rect width="${W}" height="${H}" fill="${colors[L - 1]}"/>`;
|
|
688
2631
|
const title = esc(o.title), desc = esc(o.desc ?? `${o.title} card artwork.`);
|
|
689
2632
|
const meta = esc(o.credit ?? "Original artwork traced from a reference image.");
|
|
690
2633
|
const svg = `<svg xmlns="http://www.w3.org/2000/svg" width="${W}" height="${H}" viewBox="0 0 ${W} ${H}" role="img" aria-label="${desc}">
|
|
@@ -697,9 +2640,9 @@ ${groups.join("\n")}
|
|
|
697
2640
|
`;
|
|
698
2641
|
const checked = checkSubmittedSvg(svg);
|
|
699
2642
|
if (!checked.ok) fail(checked.error);
|
|
700
|
-
writeFileSync(
|
|
2643
|
+
writeFileSync(resolve5(o.out), svg, { flag: "wx" });
|
|
701
2644
|
return {
|
|
702
|
-
out:
|
|
2645
|
+
out: resolve5(o.out),
|
|
703
2646
|
bytes: Buffer.byteLength(svg),
|
|
704
2647
|
mode: o.raster ? "raster background + vector ink" : "pure vector",
|
|
705
2648
|
tones: colors,
|
|
@@ -707,7 +2650,7 @@ ${groups.join("\n")}
|
|
|
707
2650
|
};
|
|
708
2651
|
}
|
|
709
2652
|
async function rawImage(file) {
|
|
710
|
-
const { data, info } = await
|
|
2653
|
+
const { data, info } = await sharp5(file, { limitInputPixels: 24e6 }).toColourspace("srgb").removeAlpha().raw().toBuffer({ resolveWithObject: true });
|
|
711
2654
|
const px = (x, y) => {
|
|
712
2655
|
if (!Number.isInteger(x) || !Number.isInteger(y) || x < 0 || y < 0 || x >= info.width || y >= info.height)
|
|
713
2656
|
fail("Pixel coordinates are outside the image.");
|
|
@@ -806,19 +2749,19 @@ async function glyphs(file, o) {
|
|
|
806
2749
|
if (!o.box || !o.at) fail("glyphs needs --box x,y,w,h and --at X,Y");
|
|
807
2750
|
const [bx, by, bw, bh] = nums(o.box, 4, "box");
|
|
808
2751
|
const [ax, ay] = nums(o.at, 2, "at");
|
|
809
|
-
const meta = await
|
|
2752
|
+
const meta = await sharp5(file, { limitInputPixels: 24e6 }).metadata();
|
|
810
2753
|
validBox([bx, by, bw, bh], meta.width ?? 0, meta.height ?? 0);
|
|
811
2754
|
const up = Number(o.up ?? 4);
|
|
812
2755
|
if (bw * bh * up * up > 24e6)
|
|
813
2756
|
fail("Trace region is too large; lower up or choose a smaller box.");
|
|
814
|
-
const { data, info } = await
|
|
815
|
-
const temp = mkdtempSync(
|
|
816
|
-
const pgm =
|
|
2757
|
+
const { data, info } = await sharp5(file).extract({ left: bx, top: by, width: bw, height: bh }).greyscale().removeAlpha().resize({ width: bw * up, kernel: "lanczos3" }).raw().toBuffer({ resolveWithObject: true });
|
|
2758
|
+
const temp = mkdtempSync(join5(tmpdir2(), "makeables-glyphs-"));
|
|
2759
|
+
const pgm = join5(temp, "glyphs.pgm");
|
|
817
2760
|
writeFileSync(pgm, Buffer.concat([Buffer.from(`P5
|
|
818
2761
|
${info.width} ${info.height}
|
|
819
2762
|
255
|
|
820
2763
|
`), data]));
|
|
821
|
-
const r =
|
|
2764
|
+
const r = spawnSync2(
|
|
822
2765
|
"potrace",
|
|
823
2766
|
[pgm, "-b", "svg", "-o", "-", "-k", String(o.k ?? 0.5), "-t", "2", "-a", "1", "-O", "0.3"],
|
|
824
2767
|
{ maxBuffer: 64 << 20 }
|
|
@@ -832,18 +2775,18 @@ ${info.width} ${info.height}
|
|
|
832
2775
|
const paths = [...svg.matchAll(/ d="([^"]+)"/g)].map((m) => m[1].replace(/\s+/g, " ")).join(" ");
|
|
833
2776
|
if (!tr || !paths) fail("nothing dark enough to trace in that box; lower --k or check the box");
|
|
834
2777
|
const snippet = `<g transform="translate(${ax} ${ay}) scale(${+(1 / up).toFixed(6)})"><path transform="${tr}" fill="${o.fill ?? "#0a0a0a"}" fill-rule="evenodd" d="${paths}"/></g>`;
|
|
835
|
-
if (o.out) writeFileSync(
|
|
2778
|
+
if (o.out) writeFileSync(resolve5(o.out), snippet, { flag: "wx" });
|
|
836
2779
|
return {
|
|
837
2780
|
box: { x: bx, y: by, w: bw, h: bh },
|
|
838
2781
|
at: { x: ax, y: ay },
|
|
839
2782
|
bytes: snippet.length,
|
|
840
|
-
out: o.out ?
|
|
2783
|
+
out: o.out ? resolve5(o.out) : null,
|
|
841
2784
|
snippet: o.out ? void 0 : snippet
|
|
842
2785
|
};
|
|
843
2786
|
}
|
|
844
2787
|
function rotate(file, o) {
|
|
845
2788
|
if (!o.out) fail("rotate needs --out <card.svg>");
|
|
846
|
-
const text =
|
|
2789
|
+
const text = readFileSync2(file, "utf8");
|
|
847
2790
|
const parser = new DOMParser2({ onError: (_level, message) => fail(`Malformed SVG: ${message}`) });
|
|
848
2791
|
if (/<!DOCTYPE|<!ENTITY/i.test(text)) fail("SVG declarations are not supported.");
|
|
849
2792
|
const root = parser.parseFromString(text, "image/svg+xml").documentElement;
|
|
@@ -859,28 +2802,28 @@ function rotate(file, o) {
|
|
|
859
2802
|
const svg = `<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="${W}" height="${H}" viewBox="0 0 ${W} ${H}"><g transform="${transform}"><g${inherited}>${body}</g></g></svg>`;
|
|
860
2803
|
const checked = checkSubmittedSvg(svg);
|
|
861
2804
|
if (!checked.ok) fail(checked.error);
|
|
862
|
-
writeFileSync(
|
|
863
|
-
return { out:
|
|
2805
|
+
writeFileSync(resolve5(o.out), svg, { flag: "wx" });
|
|
2806
|
+
return { out: resolve5(o.out), transform, bytes: Buffer.byteLength(svg) };
|
|
864
2807
|
}
|
|
865
2808
|
async function compare(file, ref, o) {
|
|
866
2809
|
if (!o.out) fail("compare needs --out <file.png>");
|
|
867
|
-
let card =
|
|
2810
|
+
let card = sharp5(readFileSync2(file));
|
|
868
2811
|
if (o.upright) card = card.rotate(o.upright === "left" ? 90 : -90);
|
|
869
2812
|
const cardPng = await card.png().toBuffer();
|
|
870
|
-
let reference =
|
|
2813
|
+
let reference = sharp5(ref);
|
|
871
2814
|
if (o["ref-box"]) {
|
|
872
2815
|
const [x, y, w, h] = nums(o["ref-box"], 4, "ref-box");
|
|
873
2816
|
reference = reference.extract({ left: x, top: y, width: w, height: h });
|
|
874
2817
|
}
|
|
875
2818
|
const height = 900;
|
|
876
|
-
const a = await
|
|
877
|
-
const b = await
|
|
2819
|
+
const a = await sharp5(cardPng).resize({ height }).png().toBuffer({ resolveWithObject: true });
|
|
2820
|
+
const b = await sharp5(await reference.png().toBuffer()).resize({ height }).png().toBuffer({ resolveWithObject: true });
|
|
878
2821
|
const gap = 24, width = a.info.width + b.info.width + gap * 3;
|
|
879
|
-
await
|
|
2822
|
+
await sharp5({ create: { width, height: height + gap * 2, channels: 3, background: "#ffffff" } }).composite([
|
|
880
2823
|
{ input: a.data, left: gap, top: gap },
|
|
881
2824
|
{ input: b.data, left: a.info.width + gap * 2, top: gap }
|
|
882
|
-
]).png().toFile(
|
|
883
|
-
return { out:
|
|
2825
|
+
]).png().toFile(resolve5(o.out));
|
|
2826
|
+
return { out: resolve5(o.out), left: "card", right: "reference" };
|
|
884
2827
|
}
|
|
885
2828
|
var imageTools = [
|
|
886
2829
|
"detect",
|
|
@@ -900,8 +2843,8 @@ async function referenceImage(command, file, options, out, reference) {
|
|
|
900
2843
|
parsed.error.issues.map((issue) => `${issue.path.join(".")}: ${issue.message}`).join("; ")
|
|
901
2844
|
);
|
|
902
2845
|
const o = { ...parsed.data, out };
|
|
903
|
-
if (out &&
|
|
904
|
-
if (!
|
|
2846
|
+
if (out && existsSync2(resolve5(out))) fail("Output already exists. Choose a new --out path.");
|
|
2847
|
+
if (!existsSync2(file)) fail("Input file does not exist.");
|
|
905
2848
|
switch (command) {
|
|
906
2849
|
case "detect":
|
|
907
2850
|
return detect(file);
|
|
@@ -928,180 +2871,11 @@ async function referenceImage(command, file, options, out, reference) {
|
|
|
928
2871
|
}
|
|
929
2872
|
}
|
|
930
2873
|
|
|
931
|
-
// src/sheet.ts
|
|
932
|
-
import { readFile as readFile4 } from "fs/promises";
|
|
933
|
-
import { resolve as resolve4 } from "path";
|
|
934
|
-
import { deflateSync } from "zlib";
|
|
935
|
-
import { createCanvas } from "@napi-rs/canvas";
|
|
936
|
-
import sharp4 from "sharp";
|
|
937
|
-
async function readSheet(file) {
|
|
938
|
-
const value = JSON.parse(await readFile4(resolve4(file), "utf8"));
|
|
939
|
-
const parsed = stickerSheetSchema.safeParse(value);
|
|
940
|
-
if (!parsed.success)
|
|
941
|
-
throw new InputError(
|
|
942
|
-
`Invalid sheet: ${parsed.error.issues.map((issue) => `${issue.path.join(".")}: ${issue.message}`).join("; ")}`
|
|
943
|
-
);
|
|
944
|
-
return parsed.data;
|
|
945
|
-
}
|
|
946
|
-
function newSheet(type, size, name) {
|
|
947
|
-
if (typeof type !== "string" || !sheetTypeIds.includes(type))
|
|
948
|
-
throw new InputError(`--type must be one of ${sheetTypeIds.join(", ")}.`);
|
|
949
|
-
const sheet = createStickerSheet(
|
|
950
|
-
type,
|
|
951
|
-
typeof name === "string" ? name : void 0
|
|
952
|
-
);
|
|
953
|
-
if (size !== void 0) {
|
|
954
|
-
if (typeof size !== "string" || !(size in sheetSizes))
|
|
955
|
-
throw new InputError(`--size must be one of ${Object.keys(sheetSizes).join(", ")}.`);
|
|
956
|
-
sheet.size = size;
|
|
957
|
-
}
|
|
958
|
-
return parse(sheet);
|
|
959
|
-
}
|
|
960
|
-
function parse(sheet) {
|
|
961
|
-
const parsed = stickerSheetSchema.safeParse(sheet);
|
|
962
|
-
if (!parsed.success)
|
|
963
|
-
throw new InputError(parsed.error.issues.map((issue) => issue.message).join("; "));
|
|
964
|
-
return parsed.data;
|
|
965
|
-
}
|
|
966
|
-
async function readSticker(source) {
|
|
967
|
-
const style2 = stickerStyles.find((entry) => entry.source === source);
|
|
968
|
-
if (style2) return badgeDesignSchema.parse(structuredClone(style2));
|
|
969
|
-
const design = badgeDesignSchema.parse(JSON.parse(await readFile4(resolve4(source), "utf8")));
|
|
970
|
-
if (design.product !== "sticker") throw new InputError(`${source} is not a sticker design.`);
|
|
971
|
-
return design;
|
|
972
|
-
}
|
|
973
|
-
var artCache = /* @__PURE__ */ new Map();
|
|
974
|
-
function flatArt(design) {
|
|
975
|
-
const key = JSON.stringify(design);
|
|
976
|
-
let art = artCache.get(key);
|
|
977
|
-
if (!art) {
|
|
978
|
-
art = renderBadge(design, { face: "front", person: {} }).then(async ({ png }) => {
|
|
979
|
-
const { data, info } = await sharp4(png).ensureAlpha().raw().toBuffer({ resolveWithObject: true });
|
|
980
|
-
return {
|
|
981
|
-
width: info.width,
|
|
982
|
-
height: info.height,
|
|
983
|
-
rgba: new Uint8ClampedArray(data.buffer, data.byteOffset, data.length)
|
|
984
|
-
};
|
|
985
|
-
});
|
|
986
|
-
artCache.set(key, art);
|
|
987
|
-
}
|
|
988
|
-
return art;
|
|
989
|
-
}
|
|
990
|
-
async function sheetArt(sheet) {
|
|
991
|
-
return new Map(
|
|
992
|
-
await Promise.all(
|
|
993
|
-
sheet.items.map(async (item) => [item.id, await flatArt(item.design)])
|
|
994
|
-
)
|
|
995
|
-
);
|
|
996
|
-
}
|
|
997
|
-
function bounds(sheet) {
|
|
998
|
-
const spec = sheetSpec(sheet);
|
|
999
|
-
return { widthMm: spec.widthMm ?? 0, heightMm: spec.heightMm ?? 0 };
|
|
1000
|
-
}
|
|
1001
|
-
async function addToSheet(sheet, source, widthMm) {
|
|
1002
|
-
const type = sheetTypes[sheet.type];
|
|
1003
|
-
if (type.cut === "die-cut" && sheet.items.length)
|
|
1004
|
-
throw new InputError("A single sticker file holds one sticker. Start another sheet.");
|
|
1005
|
-
const width = widthMm === void 0 ? type.stickerWidthMm : Number(widthMm);
|
|
1006
|
-
if (!Number.isFinite(width) || width < 5 || width > 300)
|
|
1007
|
-
throw new InputError("--width-mm must be a number from 5 to 300.");
|
|
1008
|
-
const design = await readSticker(source);
|
|
1009
|
-
const base = (design.source ?? "sticker").replace(/[^a-z0-9-]/g, "").slice(0, 40) || "sticker";
|
|
1010
|
-
let id = base;
|
|
1011
|
-
for (let n = 2; sheet.items.some((item2) => item2.id === id); n++) id = `${base}-${n}`;
|
|
1012
|
-
const spec = sheetSpec(sheet);
|
|
1013
|
-
const item = {
|
|
1014
|
-
id,
|
|
1015
|
-
design,
|
|
1016
|
-
x: (spec.widthMm ?? width) / 2,
|
|
1017
|
-
y: (spec.heightMm ?? width) / 2,
|
|
1018
|
-
rotation: 0,
|
|
1019
|
-
widthMm: width
|
|
1020
|
-
};
|
|
1021
|
-
const next = parse({ ...sheet, items: [...sheet.items, item] });
|
|
1022
|
-
if (type.cut === "die-cut") return { sheet: next, item, placed: true };
|
|
1023
|
-
const print = prepareSheetPrint(next, await sheetArt(next));
|
|
1024
|
-
const moved = placeNear(
|
|
1025
|
-
spec,
|
|
1026
|
-
bounds(next),
|
|
1027
|
-
next.items.map((entry) => ({ item: entry, shape: print.shapes.get(entry.id) })),
|
|
1028
|
-
id,
|
|
1029
|
-
[item.x, item.y]
|
|
1030
|
-
);
|
|
1031
|
-
if (moved) next.items[next.items.length - 1] = moved;
|
|
1032
|
-
return { sheet: next, item: moved ?? item, placed: Boolean(moved) };
|
|
1033
|
-
}
|
|
1034
|
-
async function packStickerSheet(sheet) {
|
|
1035
|
-
if (sheetTypes[sheet.type].cut === "die-cut") return { sheet, missing: [] };
|
|
1036
|
-
const print = prepareSheetPrint(sheet, await sheetArt(sheet));
|
|
1037
|
-
const packed = packSheet(
|
|
1038
|
-
sheetSpec(sheet),
|
|
1039
|
-
bounds(sheet),
|
|
1040
|
-
sheet.items.map((item) => ({ item, shape: print.shapes.get(item.id) })),
|
|
1041
|
-
sheet.hero
|
|
1042
|
-
);
|
|
1043
|
-
return { sheet: parse({ ...sheet, items: packed.items }), missing: packed.missing };
|
|
1044
|
-
}
|
|
1045
|
-
async function checkSheet(sheet) {
|
|
1046
|
-
return prepareSheetPrint(sheet, await sheetArt(sheet));
|
|
1047
|
-
}
|
|
1048
|
-
function sheetReport(print) {
|
|
1049
|
-
return {
|
|
1050
|
-
printable: print.printable,
|
|
1051
|
-
widthMm: Number(print.widthMm.toFixed(2)),
|
|
1052
|
-
heightMm: Number(print.heightMm.toFixed(2)),
|
|
1053
|
-
stickers: print.items.length,
|
|
1054
|
-
violations: print.violations,
|
|
1055
|
-
issues: print.issues.map(({ id, issue }) => ({ id, ...issue }))
|
|
1056
|
-
};
|
|
1057
|
-
}
|
|
1058
|
-
async function exportSheetPdf(sheet, print) {
|
|
1059
|
-
return sheetPrintPdf(sheet, print, async (bytes) => new Uint8Array(deflateSync(bytes)));
|
|
1060
|
-
}
|
|
1061
|
-
function sheetPreviewPng(print, dpi = 150) {
|
|
1062
|
-
const scale = dpi / 25.4;
|
|
1063
|
-
const canvas = createCanvas(
|
|
1064
|
-
Math.round(print.widthMm * scale),
|
|
1065
|
-
Math.round(print.heightMm * scale)
|
|
1066
|
-
);
|
|
1067
|
-
const context = canvas.getContext("2d");
|
|
1068
|
-
context.fillStyle = "#f4f3ef";
|
|
1069
|
-
context.fillRect(0, 0, canvas.width, canvas.height);
|
|
1070
|
-
for (const item of print.pdfItems) {
|
|
1071
|
-
const art = createCanvas(item.art.width, item.art.height);
|
|
1072
|
-
const data = art.getContext("2d").createImageData(item.art.width, item.art.height);
|
|
1073
|
-
data.data.set(item.art.rgba);
|
|
1074
|
-
art.getContext("2d").putImageData(data, 0, 0);
|
|
1075
|
-
context.save();
|
|
1076
|
-
context.translate(item.x * scale, item.y * scale);
|
|
1077
|
-
context.rotate(item.rotation * Math.PI / 180);
|
|
1078
|
-
context.drawImage(
|
|
1079
|
-
art,
|
|
1080
|
-
-item.widthMm / 2 * scale,
|
|
1081
|
-
-item.heightMm / 2 * scale,
|
|
1082
|
-
item.widthMm * scale,
|
|
1083
|
-
item.heightMm * scale
|
|
1084
|
-
);
|
|
1085
|
-
context.restore();
|
|
1086
|
-
context.strokeStyle = "#ff00ff";
|
|
1087
|
-
context.lineWidth = Math.max(1, 0.2 * scale);
|
|
1088
|
-
context.beginPath();
|
|
1089
|
-
for (const loop of item.cut) {
|
|
1090
|
-
for (const [i, [x, y]] of loop.entries())
|
|
1091
|
-
if (i) context.lineTo(x * scale, y * scale);
|
|
1092
|
-
else context.moveTo(x * scale, y * scale);
|
|
1093
|
-
context.closePath();
|
|
1094
|
-
}
|
|
1095
|
-
context.stroke();
|
|
1096
|
-
}
|
|
1097
|
-
return canvas.toBuffer("image/png");
|
|
1098
|
-
}
|
|
1099
|
-
|
|
1100
2874
|
// src/sticker-cut.ts
|
|
1101
|
-
import
|
|
2875
|
+
import sharp6 from "sharp";
|
|
1102
2876
|
async function stickerPrintCut(design) {
|
|
1103
2877
|
const { png } = await renderBadge(design, { face: "front", person: {} });
|
|
1104
|
-
const { data, info } = await
|
|
2878
|
+
const { data, info } = await sharp6(png).ensureAlpha().raw().toBuffer({ resolveWithObject: true });
|
|
1105
2879
|
const material = design.sticker ?? defaultStickerMaterial;
|
|
1106
2880
|
const cut = stickerCut(
|
|
1107
2881
|
new Uint8ClampedArray(data.buffer, data.byteOffset, data.length),
|
|
@@ -1116,7 +2890,7 @@ async function stickerPrintCut(design) {
|
|
|
1116
2890
|
// src/sticker-material.ts
|
|
1117
2891
|
import { readFile as readFile5, stat as stat3 } from "fs/promises";
|
|
1118
2892
|
import { basename as basename2 } from "path";
|
|
1119
|
-
import
|
|
2893
|
+
import sharp7 from "sharp";
|
|
1120
2894
|
async function bindStickerMap(design, layerId, path) {
|
|
1121
2895
|
if (design.product !== "sticker") throw new InputError("Optical maps require a sticker.");
|
|
1122
2896
|
const layer = design.front.layers.find((layer2) => layer2.id === layerId);
|
|
@@ -1125,7 +2899,7 @@ async function bindStickerMap(design, layerId, path) {
|
|
|
1125
2899
|
if (!info.isFile() || info.size > 4e6)
|
|
1126
2900
|
throw new InputError("Use an optical image below 4 MB.");
|
|
1127
2901
|
const bytes = await readFile5(path);
|
|
1128
|
-
const meta = await
|
|
2902
|
+
const meta = await sharp7(bytes, { limitInputPixels: 16777216 }).metadata();
|
|
1129
2903
|
if (!meta.width || !meta.height || meta.width > 4096 || meta.height > 4096 || !["png", "webp"].includes(meta.format ?? ""))
|
|
1130
2904
|
throw new InputError("Use an RGB PNG or WebP optical map up to 4096 \xD7 4096.");
|
|
1131
2905
|
const asset = design.sticker?.effectMap?.asset ?? "optical-map";
|
|
@@ -1150,14 +2924,49 @@ async function bindStickerMap(design, layerId, path) {
|
|
|
1150
2924
|
});
|
|
1151
2925
|
}
|
|
1152
2926
|
|
|
2927
|
+
// src/trace.ts
|
|
2928
|
+
import { constants as constants2 } from "fs";
|
|
2929
|
+
import { open as open2 } from "fs/promises";
|
|
2930
|
+
async function appendTrace(path, event) {
|
|
2931
|
+
const file = await open2(
|
|
2932
|
+
path,
|
|
2933
|
+
constants2.O_APPEND | constants2.O_CREAT | constants2.O_WRONLY | constants2.O_NOFOLLOW,
|
|
2934
|
+
384
|
|
2935
|
+
);
|
|
2936
|
+
try {
|
|
2937
|
+
if (!(await file.stat()).isFile()) throw new Error("Choose a regular trace file.");
|
|
2938
|
+
await file.chmod(384);
|
|
2939
|
+
await file.writeFile(`${JSON.stringify(event)}
|
|
2940
|
+
`);
|
|
2941
|
+
} finally {
|
|
2942
|
+
await file.close();
|
|
2943
|
+
}
|
|
2944
|
+
}
|
|
2945
|
+
function compactReceipt(value) {
|
|
2946
|
+
if (Array.isArray(value)) return value.map(compactReceipt);
|
|
2947
|
+
if (value && typeof value === "object")
|
|
2948
|
+
return Object.fromEntries(
|
|
2949
|
+
Object.entries(value).map(([key2, entry]) => [
|
|
2950
|
+
key2,
|
|
2951
|
+
key2 === "design" && entry && typeof entry === "object" ? {
|
|
2952
|
+
name: "name" in entry ? entry.name : void 0,
|
|
2953
|
+
product: "product" in entry ? entry.product : "badge",
|
|
2954
|
+
omitted: true
|
|
2955
|
+
} : compactReceipt(entry)
|
|
2956
|
+
])
|
|
2957
|
+
);
|
|
2958
|
+
if (typeof value === "string" && value.length > 2e3) return "[omitted; inspect the saved file]";
|
|
2959
|
+
return value;
|
|
2960
|
+
}
|
|
2961
|
+
|
|
1153
2962
|
// src/wallet.ts
|
|
1154
2963
|
import { spawn } from "child_process";
|
|
1155
2964
|
import { createHash as createHash3 } from "crypto";
|
|
1156
|
-
import { existsSync as
|
|
1157
|
-
import { chmod as chmod2, lstat as lstat3, mkdir as
|
|
1158
|
-
import { homedir as homedir3, tmpdir as
|
|
1159
|
-
import { dirname as
|
|
1160
|
-
import
|
|
2965
|
+
import { existsSync as existsSync3 } from "fs";
|
|
2966
|
+
import { chmod as chmod2, lstat as lstat3, mkdir as mkdir4, mkdtemp as mkdtemp4, readFile as readFile7, rename as rename4, rm as rm6, writeFile as writeFile4 } from "fs/promises";
|
|
2967
|
+
import { homedir as homedir3, tmpdir as tmpdir3 } from "os";
|
|
2968
|
+
import { dirname as dirname4, extname as extname3, join as join9, resolve as resolve7 } from "path";
|
|
2969
|
+
import sharp8 from "sharp";
|
|
1161
2970
|
|
|
1162
2971
|
// src/wallet-release.json
|
|
1163
2972
|
var wallet_release_default = {
|
|
@@ -1178,18 +2987,18 @@ var wallet_release_default = {
|
|
|
1178
2987
|
|
|
1179
2988
|
// src/wallet-storage.ts
|
|
1180
2989
|
import { randomUUID as randomUUID2 } from "crypto";
|
|
1181
|
-
import { constants as
|
|
1182
|
-
import { chmod, lstat, mkdir as
|
|
2990
|
+
import { constants as constants3 } from "fs";
|
|
2991
|
+
import { chmod, lstat, mkdir as mkdir3, open as open3, rename as rename3, rm as rm4 } from "fs/promises";
|
|
1183
2992
|
import { homedir as homedir2 } from "os";
|
|
1184
|
-
import { dirname as
|
|
2993
|
+
import { dirname as dirname3, join as join6, resolve as resolve6 } from "path";
|
|
1185
2994
|
function walletHome() {
|
|
1186
|
-
return
|
|
1187
|
-
process.env.MAKEABLES_WALLET_HOME ??
|
|
2995
|
+
return resolve6(
|
|
2996
|
+
process.env.MAKEABLES_WALLET_HOME ?? join6(homedir2(), "Library", "Application Support", "Makeables", "Wallet")
|
|
1188
2997
|
);
|
|
1189
2998
|
}
|
|
1190
2999
|
async function privateDirectory(path) {
|
|
1191
|
-
const absolute =
|
|
1192
|
-
const parent =
|
|
3000
|
+
const absolute = resolve6(path);
|
|
3001
|
+
const parent = dirname3(absolute);
|
|
1193
3002
|
if (parent !== absolute) {
|
|
1194
3003
|
try {
|
|
1195
3004
|
const info = await lstat(parent);
|
|
@@ -1200,14 +3009,14 @@ async function privateDirectory(path) {
|
|
|
1200
3009
|
await privateDirectory(parent);
|
|
1201
3010
|
}
|
|
1202
3011
|
}
|
|
1203
|
-
await
|
|
3012
|
+
await mkdir3(absolute, { recursive: true, mode: 448 });
|
|
1204
3013
|
if (!(await lstat(absolute)).isDirectory())
|
|
1205
3014
|
throw new InputError("Wallet storage must be a directory, not a symbolic link.");
|
|
1206
3015
|
await chmod(absolute, 448);
|
|
1207
3016
|
return absolute;
|
|
1208
3017
|
}
|
|
1209
3018
|
async function readPrivateJson(path) {
|
|
1210
|
-
const handle = await
|
|
3019
|
+
const handle = await open3(path, constants3.O_RDONLY | constants3.O_NOFOLLOW);
|
|
1211
3020
|
try {
|
|
1212
3021
|
const info = await handle.stat();
|
|
1213
3022
|
if (!info.isFile() || info.size > 1e6)
|
|
@@ -1218,9 +3027,9 @@ async function readPrivateJson(path) {
|
|
|
1218
3027
|
}
|
|
1219
3028
|
}
|
|
1220
3029
|
async function writePrivateJson(path, value) {
|
|
1221
|
-
const temporary =
|
|
3030
|
+
const temporary = join6(dirname3(path), `.${randomUUID2()}.json`);
|
|
1222
3031
|
try {
|
|
1223
|
-
const handle = await
|
|
3032
|
+
const handle = await open3(temporary, "wx", 384);
|
|
1224
3033
|
try {
|
|
1225
3034
|
await handle.writeFile(`${JSON.stringify(value)}
|
|
1226
3035
|
`);
|
|
@@ -1228,15 +3037,15 @@ async function writePrivateJson(path, value) {
|
|
|
1228
3037
|
} finally {
|
|
1229
3038
|
await handle.close();
|
|
1230
3039
|
}
|
|
1231
|
-
await
|
|
3040
|
+
await rename3(temporary, path);
|
|
1232
3041
|
} finally {
|
|
1233
|
-
await
|
|
3042
|
+
await rm4(temporary, { force: true });
|
|
1234
3043
|
}
|
|
1235
3044
|
}
|
|
1236
3045
|
|
|
1237
3046
|
// src/wallet-submission.ts
|
|
1238
|
-
import { mkdtemp, readFile as readFile6, rm as
|
|
1239
|
-
import { join as
|
|
3047
|
+
import { mkdtemp as mkdtemp3, readFile as readFile6, rm as rm5, writeFile as writeFile3 } from "fs/promises";
|
|
3048
|
+
import { join as join7 } from "path";
|
|
1240
3049
|
var uuid = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
|
1241
3050
|
function submissionId(input) {
|
|
1242
3051
|
if (uuid.test(input)) return input.toLowerCase();
|
|
@@ -1254,7 +3063,7 @@ function submissionId(input) {
|
|
|
1254
3063
|
return id.toLowerCase();
|
|
1255
3064
|
}
|
|
1256
3065
|
async function downloadWalletSubmission(input) {
|
|
1257
|
-
const
|
|
3066
|
+
const started2 = performance.now();
|
|
1258
3067
|
const id = submissionId(input);
|
|
1259
3068
|
const response = await fetch(`https://makeables.dev/api/submissions/${id}/files?download=1`, {
|
|
1260
3069
|
redirect: "error",
|
|
@@ -1284,15 +3093,15 @@ async function downloadWalletSubmission(input) {
|
|
|
1284
3093
|
const pkg = await readPackageZip(bytes);
|
|
1285
3094
|
if (pkg.manifest.product !== "card")
|
|
1286
3095
|
throw new InputError("Wallet artwork requires a card submission.");
|
|
1287
|
-
const directory = await
|
|
1288
|
-
|
|
1289
|
-
await privateDirectory(
|
|
3096
|
+
const directory = await mkdtemp3(
|
|
3097
|
+
join7(
|
|
3098
|
+
await privateDirectory(join7(await privateDirectory(walletHome()), "submissions")),
|
|
1290
3099
|
"download-"
|
|
1291
3100
|
)
|
|
1292
3101
|
);
|
|
1293
3102
|
try {
|
|
1294
|
-
const path =
|
|
1295
|
-
await
|
|
3103
|
+
const path = join7(directory, `${id}.makeables.zip`);
|
|
3104
|
+
await writeFile3(path, bytes, { mode: 384, flag: "wx" });
|
|
1296
3105
|
const saved = await readFile6(path);
|
|
1297
3106
|
const sha256 = await hashBytes(saved);
|
|
1298
3107
|
if (saved.length !== bytes.length || sha256 !== await hashBytes(bytes))
|
|
@@ -1304,53 +3113,53 @@ async function downloadWalletSubmission(input) {
|
|
|
1304
3113
|
submissionId: id,
|
|
1305
3114
|
title: pkg.manifest.title,
|
|
1306
3115
|
downloadVerified: true,
|
|
1307
|
-
elapsedMs: Math.round(performance.now() -
|
|
3116
|
+
elapsedMs: Math.round(performance.now() - started2)
|
|
1308
3117
|
};
|
|
1309
3118
|
} catch (error) {
|
|
1310
|
-
await
|
|
3119
|
+
await rm5(directory, { recursive: true, force: true });
|
|
1311
3120
|
throw error;
|
|
1312
3121
|
}
|
|
1313
3122
|
}
|
|
1314
3123
|
|
|
1315
3124
|
// src/wallet-targets.ts
|
|
1316
3125
|
import { lstat as lstat2 } from "fs/promises";
|
|
1317
|
-
import { join as
|
|
3126
|
+
import { join as join8 } from "path";
|
|
1318
3127
|
import lockfile from "proper-lockfile";
|
|
1319
|
-
import { z as
|
|
1320
|
-
var alias =
|
|
1321
|
-
var label =
|
|
3128
|
+
import { z as z4 } from "zod";
|
|
3129
|
+
var alias = z4.string().regex(/^[a-z][a-z0-9-]{0,47}$/).refine((value) => !/\d{5}/.test(value));
|
|
3130
|
+
var label = z4.string().trim().min(1).max(60).refine(
|
|
1322
3131
|
(value) => !/\d{5}/.test(value) && [...value].every(
|
|
1323
3132
|
(character) => character.charCodeAt(0) >= 32 && character.charCodeAt(0) !== 127
|
|
1324
3133
|
)
|
|
1325
3134
|
);
|
|
1326
|
-
var targetSchema =
|
|
3135
|
+
var targetSchema = z4.object({
|
|
1327
3136
|
target: alias,
|
|
1328
3137
|
label,
|
|
1329
|
-
last4:
|
|
1330
|
-
identitySource:
|
|
1331
|
-
backup:
|
|
1332
|
-
createdAt:
|
|
3138
|
+
last4: z4.string().regex(/^\d{4}$/).optional(),
|
|
3139
|
+
identitySource: z4.literal("user-confirmed"),
|
|
3140
|
+
backup: z4.string().min(1),
|
|
3141
|
+
createdAt: z4.string().datetime()
|
|
1333
3142
|
}).strict();
|
|
1334
|
-
var registrySchema =
|
|
1335
|
-
function targetIdentity(values,
|
|
3143
|
+
var registrySchema = z4.object({ version: z4.literal(1), targets: z4.array(targetSchema).max(100) }).strict();
|
|
3144
|
+
function targetIdentity(values, key2 = "remember") {
|
|
1336
3145
|
const result = targetSchema.pick({ target: true, label: true, last4: true }).safeParse({
|
|
1337
|
-
target: values[
|
|
3146
|
+
target: values[key2],
|
|
1338
3147
|
label: values.label,
|
|
1339
3148
|
last4: values.last4
|
|
1340
3149
|
});
|
|
1341
3150
|
if (!result.success)
|
|
1342
3151
|
throw new InputError(
|
|
1343
|
-
`Supply --${
|
|
3152
|
+
`Supply --${key2} with a lowercase alias and --label with the user-confirmed card name. Optional --last4 must be exactly four digits; never supply a full card number.`
|
|
1344
3153
|
);
|
|
1345
3154
|
return result.data;
|
|
1346
3155
|
}
|
|
1347
3156
|
function registryPath() {
|
|
1348
|
-
return
|
|
3157
|
+
return join8(walletHome(), "targets", "registry.json");
|
|
1349
3158
|
}
|
|
1350
3159
|
async function walletTargets() {
|
|
1351
3160
|
const path = registryPath();
|
|
1352
3161
|
try {
|
|
1353
|
-
for (const parent of [walletHome(),
|
|
3162
|
+
for (const parent of [walletHome(), join8(walletHome(), "targets")]) {
|
|
1354
3163
|
if (!(await lstat2(parent)).isDirectory())
|
|
1355
3164
|
throw new InputError("Invalid Wallet target storage.");
|
|
1356
3165
|
}
|
|
@@ -1373,7 +3182,7 @@ function targetChoices(targets) {
|
|
|
1373
3182
|
}));
|
|
1374
3183
|
}
|
|
1375
3184
|
async function withWalletTargets(action) {
|
|
1376
|
-
const directory = await privateDirectory(
|
|
3185
|
+
const directory = await privateDirectory(join8(await privateDirectory(walletHome()), "targets"));
|
|
1377
3186
|
const release = await lockfile.lock(directory, { realpath: false, retries: 0 });
|
|
1378
3187
|
try {
|
|
1379
3188
|
return await action(await walletTargets(), async (targets) => {
|
|
@@ -1402,9 +3211,9 @@ var WalletError = class extends Error {
|
|
|
1402
3211
|
};
|
|
1403
3212
|
function installation() {
|
|
1404
3213
|
const platform = `${process.platform}-${process.arch}`;
|
|
1405
|
-
const cache = process.env.MAKEABLES_WALLET_CACHE ??
|
|
1406
|
-
const root =
|
|
1407
|
-
return { platform, root, executable:
|
|
3214
|
+
const cache = process.env.MAKEABLES_WALLET_CACHE ?? join9(homedir3(), "Library", "Caches", "Makeables", "wallet");
|
|
3215
|
+
const root = resolve7(cache, `${wallet_release_default.version}-${platform}`);
|
|
3216
|
+
return { platform, root, executable: join9(root, "makeables-wallet", "makeables-wallet") };
|
|
1408
3217
|
}
|
|
1409
3218
|
async function execute(command, args, progress) {
|
|
1410
3219
|
return new Promise((accept, reject) => {
|
|
@@ -1471,7 +3280,7 @@ async function walletDoctor() {
|
|
|
1471
3280
|
supported: false,
|
|
1472
3281
|
reason: "Wallet artwork requires a Mac and an iPhone connected by USB."
|
|
1473
3282
|
};
|
|
1474
|
-
if (!
|
|
3283
|
+
if (!existsSync3(installed))
|
|
1475
3284
|
return {
|
|
1476
3285
|
ready: false,
|
|
1477
3286
|
supported: true,
|
|
@@ -1501,7 +3310,7 @@ async function setupWallet(progress) {
|
|
|
1501
3310
|
return info;
|
|
1502
3311
|
}
|
|
1503
3312
|
const { platform, root, executable } = installation();
|
|
1504
|
-
if (
|
|
3313
|
+
if (existsSync3(executable)) {
|
|
1505
3314
|
const info = await walletDoctor();
|
|
1506
3315
|
if (!info.ready)
|
|
1507
3316
|
throw new InputError("The installed Wallet companion is incomplete or incompatible.");
|
|
@@ -1535,30 +3344,30 @@ async function setupWallet(progress) {
|
|
|
1535
3344
|
const bytes = Buffer.concat(chunks);
|
|
1536
3345
|
if (createHash3("sha256").update(bytes).digest("hex") !== artifact.sha256)
|
|
1537
3346
|
throw new Error("Wallet companion checksum did not match. Nothing was installed.");
|
|
1538
|
-
await
|
|
1539
|
-
const stage = await
|
|
3347
|
+
await mkdir4(dirname4(root), { recursive: true, mode: 448 });
|
|
3348
|
+
const stage = await mkdtemp4(join9(dirname4(root), ".install-"));
|
|
1540
3349
|
try {
|
|
1541
|
-
const archive =
|
|
1542
|
-
await
|
|
3350
|
+
const archive = join9(stage, "companion.tar.gz");
|
|
3351
|
+
await writeFile4(archive, bytes, { mode: 384, flag: "wx" });
|
|
1543
3352
|
const members = (await execute("tar", ["-tzf", archive])).trim().split("\n");
|
|
1544
3353
|
if (members.some(
|
|
1545
3354
|
(path) => !path.startsWith("makeables-wallet/") || path.split("/").includes("..")
|
|
1546
3355
|
))
|
|
1547
3356
|
throw new Error("Unexpected companion archive contents.");
|
|
1548
3357
|
await execute("tar", ["-xzf", archive, "-C", stage]);
|
|
1549
|
-
await
|
|
1550
|
-
await chmod2(
|
|
1551
|
-
const info = await invoke(
|
|
3358
|
+
await rm6(archive);
|
|
3359
|
+
await chmod2(join9(stage, "makeables-wallet", "makeables-wallet"), 448);
|
|
3360
|
+
const info = await invoke(join9(stage, "makeables-wallet", "makeables-wallet"), ["version"]);
|
|
1552
3361
|
if (info.version !== wallet_release_default.version || info.protocol !== wallet_release_default.protocol)
|
|
1553
3362
|
throw new Error("Companion version does not match the installed CLI.");
|
|
1554
|
-
await
|
|
3363
|
+
await rename4(stage, root);
|
|
1555
3364
|
} finally {
|
|
1556
|
-
await
|
|
3365
|
+
await rm6(stage, { recursive: true, force: true });
|
|
1557
3366
|
}
|
|
1558
3367
|
return walletDoctor();
|
|
1559
3368
|
}
|
|
1560
3369
|
async function walletArtwork(file, side2 = "front") {
|
|
1561
|
-
const input =
|
|
3370
|
+
const input = resolve7(file);
|
|
1562
3371
|
const info = await lstat3(input);
|
|
1563
3372
|
if (!info.isFile() || info.size > 336e5)
|
|
1564
3373
|
throw new InputError("Expected artwork or a complete Makeables package below 32 MB.");
|
|
@@ -1577,10 +3386,10 @@ async function walletArtwork(file, side2 = "front") {
|
|
|
1577
3386
|
if (!result.ok) throw new InputError(result.error);
|
|
1578
3387
|
}
|
|
1579
3388
|
}
|
|
1580
|
-
return
|
|
3389
|
+
return sharp8(source, { limitInputPixels: 24e6, density: 144 }).rotate().resize(1536, 969, { fit: "contain", background: "#000000" }).flatten({ background: "#000000" }).toColourspace("srgb").png().toBuffer();
|
|
1581
3390
|
}
|
|
1582
3391
|
async function preparedArtwork(directory) {
|
|
1583
|
-
const path =
|
|
3392
|
+
const path = resolve7(directory);
|
|
1584
3393
|
if (!(await lstat3(path)).isDirectory())
|
|
1585
3394
|
throw new InputError("--assets must be a prepared artwork directory.");
|
|
1586
3395
|
for (const name of [
|
|
@@ -1588,7 +3397,7 @@ async function preparedArtwork(directory) {
|
|
|
1588
3397
|
"cardBackgroundCombined@2x.png",
|
|
1589
3398
|
"cardBackgroundCombined.pdf"
|
|
1590
3399
|
]) {
|
|
1591
|
-
const file =
|
|
3400
|
+
const file = join9(path, name);
|
|
1592
3401
|
const info = await lstat3(file);
|
|
1593
3402
|
if (!info.isFile() || info.size === 0 || info.size > 16 * 1024 * 1024)
|
|
1594
3403
|
throw new InputError(`Invalid prepared asset: ${name}. Run wallet prep again.`);
|
|
@@ -1596,7 +3405,7 @@ async function preparedArtwork(directory) {
|
|
|
1596
3405
|
if (!(await readFile7(file)).subarray(0, signature.length).equals(signature))
|
|
1597
3406
|
throw new InputError(`Invalid prepared asset: ${name}. Run wallet prep again.`);
|
|
1598
3407
|
}
|
|
1599
|
-
return { path, preview:
|
|
3408
|
+
return { path, preview: join9(path, "cardBackgroundCombined@3x.png") };
|
|
1600
3409
|
}
|
|
1601
3410
|
async function walletCommand(command, values, progress) {
|
|
1602
3411
|
if (values.remember) {
|
|
@@ -1630,8 +3439,8 @@ async function walletCommand(command, values, progress) {
|
|
|
1630
3439
|
return runWalletCommand(command, values, progress);
|
|
1631
3440
|
}
|
|
1632
3441
|
async function runWalletCommand(command, values, progress) {
|
|
1633
|
-
const
|
|
1634
|
-
const elapsed = () => Math.round(performance.now() -
|
|
3442
|
+
const started2 = performance.now();
|
|
3443
|
+
const elapsed = () => Math.round(performance.now() - started2);
|
|
1635
3444
|
let latestPhase;
|
|
1636
3445
|
const report = (phase, message) => {
|
|
1637
3446
|
latestPhase = phase;
|
|
@@ -1806,7 +3615,7 @@ async function runWalletCommand(command, values, progress) {
|
|
|
1806
3615
|
}
|
|
1807
3616
|
}
|
|
1808
3617
|
if (command === "wallet prep" || applying || installing) {
|
|
1809
|
-
const work = await
|
|
3618
|
+
const work = await mkdtemp4(join9(tmpdir3(), "makeables-wallet-"));
|
|
1810
3619
|
let phase = "prepare";
|
|
1811
3620
|
let deviceWriteAttempted = false;
|
|
1812
3621
|
let persistent;
|
|
@@ -1814,23 +3623,23 @@ async function runWalletCommand(command, values, progress) {
|
|
|
1814
3623
|
try {
|
|
1815
3624
|
let target;
|
|
1816
3625
|
if (existing) {
|
|
1817
|
-
target =
|
|
3626
|
+
target = resolve7(need("assets"));
|
|
1818
3627
|
prepared = existing;
|
|
1819
3628
|
} else {
|
|
1820
3629
|
report("prepare", "Preparing the complete card artwork.");
|
|
1821
3630
|
const png = await walletArtwork(need("file"), values.side === "back" ? "back" : "front");
|
|
1822
|
-
const raster =
|
|
1823
|
-
await
|
|
3631
|
+
const raster = join9(work, "artwork.png");
|
|
3632
|
+
await writeFile4(raster, png, { mode: 384 });
|
|
1824
3633
|
if (command === "wallet prep") {
|
|
1825
|
-
target =
|
|
3634
|
+
target = resolve7(need("out"));
|
|
1826
3635
|
} else if (installing && !applying) {
|
|
1827
3636
|
const root = await privateDirectory(
|
|
1828
|
-
|
|
3637
|
+
join9(await privateDirectory(walletHome()), "prepared")
|
|
1829
3638
|
);
|
|
1830
|
-
persistent = await
|
|
1831
|
-
target =
|
|
3639
|
+
persistent = await mkdtemp4(join9(root, "install-"));
|
|
3640
|
+
target = join9(persistent, "assets");
|
|
1832
3641
|
} else {
|
|
1833
|
-
target =
|
|
3642
|
+
target = join9(work, "assets");
|
|
1834
3643
|
}
|
|
1835
3644
|
prepared = await invoke(
|
|
1836
3645
|
executable,
|
|
@@ -1916,8 +3725,8 @@ async function runWalletCommand(command, values, progress) {
|
|
|
1916
3725
|
}
|
|
1917
3726
|
throw error;
|
|
1918
3727
|
} finally {
|
|
1919
|
-
await
|
|
1920
|
-
if (persistent && phase === "prepare") await
|
|
3728
|
+
await rm6(work, { recursive: true, force: true });
|
|
3729
|
+
if (persistent && phase === "prepare") await rm6(persistent, { recursive: true, force: true });
|
|
1921
3730
|
}
|
|
1922
3731
|
}
|
|
1923
3732
|
throw new InputError("Unknown Wallet command.");
|
|
@@ -1968,14 +3777,33 @@ function walletSummary(result) {
|
|
|
1968
3777
|
|
|
1969
3778
|
// src/cli.ts
|
|
1970
3779
|
var commands = [
|
|
3780
|
+
"preflight [--probe]",
|
|
3781
|
+
"badge package --file <badge.json> --photo <portrait> --name <name> --out <badge.makeables.json> [--role] [--organization] [--number] [--event] [--url <https://...>]",
|
|
3782
|
+
"brand new --name <brand> --out <brand.json> [--slug <slug>]",
|
|
3783
|
+
"brand from-url --url <https://event-or-site> (--out <new-directory> | --file <brand.json>) [--name <brand>] [--slug <slug>]",
|
|
3784
|
+
"brand add-kit --file <brand.json> --ref <kit.json|kit.makeables.zip> --id <kit-id> [--family <family-id>]",
|
|
3785
|
+
"brand add-logo --file <brand.json> --ref <logo.svg|logo.png> --id <logo-id> [--name <label>]",
|
|
3786
|
+
"brand inspect --file <brand.json>",
|
|
3787
|
+
"brand export --file <brand.json> --out <brand.makeables.zip> [--overwrite]",
|
|
3788
|
+
"brand preview --file <brand.json> [--site <origin>] [--no-open]",
|
|
3789
|
+
"kit check --file <kit.json> --brand <brand.json>",
|
|
3790
|
+
"kit new --name <title> --out <kit.json>",
|
|
3791
|
+
"kit extract --file <artwork-sheet.png> --out <new-directory> [--params <json|@file>]",
|
|
3792
|
+
"kit add --file <kit.json> --ref <design|package> --id <piece-id>",
|
|
3793
|
+
"kit sheet --file <kit.json> --ref <sheet.json> --id <sheet-id>",
|
|
3794
|
+
"kit inspect --file <kit.json|kit.makeables.zip>",
|
|
3795
|
+
"kit render --file <kit.json|kit.makeables.zip> --out <mockup.png> [--overwrite]",
|
|
3796
|
+
"kit export --file <kit.json|kit.makeables.zip> --out <kit.makeables.zip> [--overwrite]",
|
|
3797
|
+
"kit import --file <kit.makeables.zip> --out <new-directory>",
|
|
1971
3798
|
"designs list",
|
|
1972
|
-
"new card --design <slug> | --style <id> --out <new-file> [--dry-run]",
|
|
3799
|
+
"new card --design <slug> | --style <id> | --blank [--name <title>] --out <new-file> [--dry-run]",
|
|
1973
3800
|
"material set --file <design.json> --params <json|@file>",
|
|
1974
3801
|
"material map --file <sticker.json> --id <image-layer> --ref <optical.png|optical.webp>",
|
|
1975
|
-
"new badge --style <id> --out <new-file> [--dry-run]",
|
|
1976
|
-
"new sticker --style <id> --out <new-file> [--dry-run]",
|
|
3802
|
+
"new badge (--style <id> | --blank [--name <title>]) --out <new-file> [--dry-run]",
|
|
3803
|
+
"new sticker (--style <id> | --blank [--name <title>]) --out <new-file> [--dry-run]",
|
|
1977
3804
|
"layer list --file <design.json>",
|
|
1978
|
-
"layer add --file <design.json> --sticker <petdex:slug|path> [--side front|back]",
|
|
3805
|
+
"layer add --file <design.json> --sticker <petdex:slug|path> [--side front|back] [--relative-to document|cwd]",
|
|
3806
|
+
"layer add --file <design.json> --kind text|shape|graphic|qr [--params <json|@file>] [--side front|back]",
|
|
1979
3807
|
"layer set --file <design.json> --id <layer> [--side front|back] [--x --y --rotation] [--params <json|@file>]",
|
|
1980
3808
|
"layer remove --file <design.json> --id <layer> [--side front|back]",
|
|
1981
3809
|
"cut --file <sticker.json> [--out <cut.svg>] [--overwrite]",
|
|
@@ -1984,7 +3812,13 @@ var commands = [
|
|
|
1984
3812
|
"sheet pack --file <sheet.json>",
|
|
1985
3813
|
"sheet check --file <sheet.json>",
|
|
1986
3814
|
"sheet export --file <sheet.json> --out <sheet.pdf> [--preview <proof.png>] [--overwrite]",
|
|
1987
|
-
"
|
|
3815
|
+
"brick check --file <model.ldr> [--library <ldraw-dir>]",
|
|
3816
|
+
"brick plan --file <model.ldr> [--library <ldraw-dir>]",
|
|
3817
|
+
"brick remix --file <exemplar.ldr> --seed <n> --out <new.ldr> [--mode strict|family|free] [--library <ldraw-dir>]",
|
|
3818
|
+
"grid bin --width <cells> --depth <cells> --height <units> --out <file.stl> [--overwrite]",
|
|
3819
|
+
"grid plate --width <cells> --depth <cells> --out <file.stl> [--overwrite]",
|
|
3820
|
+
"brick render --file <model.ldr> --out <png> [--step <n>] [--explode <factor>] [--yaw=<deg>] [--pitch=<deg>] [--library <ldraw-dir>] [--overwrite]",
|
|
3821
|
+
"render --file <design.json|design.makeables.json> --out <png> [--appearance flat|material] [--side front|back] [--photo <image>] [--name --role --number] [--overwrite]",
|
|
1988
3822
|
"validate --file <design.json|artwork.svg> [--out <preview.png>]",
|
|
1989
3823
|
"new card --file <artwork.svg|image.png> --out <card.json> [--name <title>]",
|
|
1990
3824
|
...imageTools.map(
|
|
@@ -2027,22 +3861,35 @@ var commands = [
|
|
|
2027
3861
|
"schema"
|
|
2028
3862
|
];
|
|
2029
3863
|
var nextSteps = ["makeables skills get core", "makeables doctor"];
|
|
3864
|
+
var compact = false;
|
|
3865
|
+
var stdoutClosed = false;
|
|
3866
|
+
var traceFile;
|
|
3867
|
+
var traceCommand = "invalid";
|
|
3868
|
+
var traceCode;
|
|
3869
|
+
var elapsedStartup = process.uptime() * 1e3;
|
|
3870
|
+
var startedAt = new Date(Date.now() - elapsedStartup).toISOString();
|
|
3871
|
+
var started = performance.now() - elapsedStartup;
|
|
3872
|
+
process.stdout.on("error", (error) => {
|
|
3873
|
+
if (error.code === "EPIPE") stdoutClosed = true;
|
|
3874
|
+
else throw error;
|
|
3875
|
+
});
|
|
2030
3876
|
function emit(data, next, human, json) {
|
|
3877
|
+
if (stdoutClosed) return;
|
|
2031
3878
|
process.stdout.write(
|
|
2032
|
-
json ? `${JSON.stringify({ ok: true, version, data, nextSteps: next })}
|
|
3879
|
+
json ? `${JSON.stringify({ ok: true, version, data: compact ? compactReceipt(data) : data, nextSteps: next })}
|
|
2033
3880
|
` : `${human}
|
|
2034
3881
|
`
|
|
2035
3882
|
);
|
|
2036
3883
|
}
|
|
2037
3884
|
async function newPath(file) {
|
|
2038
|
-
const path =
|
|
3885
|
+
const path = resolve8(file);
|
|
2039
3886
|
try {
|
|
2040
3887
|
await lstat4(path);
|
|
2041
3888
|
throw new InputError(`File already exists: ${path}. Choose a new --out path.`);
|
|
2042
3889
|
} catch (error) {
|
|
2043
3890
|
if (error.code !== "ENOENT") throw error;
|
|
2044
3891
|
}
|
|
2045
|
-
await access(
|
|
3892
|
+
await access(dirname5(path), constants4.W_OK);
|
|
2046
3893
|
return path;
|
|
2047
3894
|
}
|
|
2048
3895
|
async function run() {
|
|
@@ -2100,10 +3947,34 @@ async function run() {
|
|
|
2100
3947
|
type: { type: "string" },
|
|
2101
3948
|
size: { type: "string" },
|
|
2102
3949
|
"width-mm": { type: "string" },
|
|
2103
|
-
preview: { type: "string" }
|
|
3950
|
+
preview: { type: "string" },
|
|
3951
|
+
library: { type: "string" },
|
|
3952
|
+
step: { type: "string" },
|
|
3953
|
+
explode: { type: "string" },
|
|
3954
|
+
yaw: { type: "string" },
|
|
3955
|
+
pitch: { type: "string" },
|
|
3956
|
+
seed: { type: "string" },
|
|
3957
|
+
width: { type: "string" },
|
|
3958
|
+
depth: { type: "string" },
|
|
3959
|
+
height: { type: "string" },
|
|
3960
|
+
mode: { type: "string" },
|
|
3961
|
+
appearance: { type: "string" },
|
|
3962
|
+
"relative-to": { type: "string" },
|
|
3963
|
+
slug: { type: "string" },
|
|
3964
|
+
family: { type: "string" },
|
|
3965
|
+
brand: { type: "string" },
|
|
3966
|
+
probe: { type: "boolean" },
|
|
3967
|
+
blank: { type: "boolean" },
|
|
3968
|
+
kind: { type: "string" },
|
|
3969
|
+
organization: { type: "string" },
|
|
3970
|
+
event: { type: "string" },
|
|
3971
|
+
compact: { type: "boolean" },
|
|
3972
|
+
trace: { type: "string" }
|
|
2104
3973
|
}
|
|
2105
3974
|
});
|
|
2106
3975
|
const json = machineOutput(Boolean(values.json));
|
|
3976
|
+
compact = Boolean(values.compact);
|
|
3977
|
+
traceFile = values.trace;
|
|
2107
3978
|
if (values.version) {
|
|
2108
3979
|
emit({ version }, nextSteps, version, json);
|
|
2109
3980
|
return;
|
|
@@ -2116,7 +3987,7 @@ async function run() {
|
|
|
2116
3987
|
emit(
|
|
2117
3988
|
{
|
|
2118
3989
|
commands: helpCommands,
|
|
2119
|
-
flags: ["--json", "--help", "--version"],
|
|
3990
|
+
flags: ["--json", "--help", "--version", "--compact", "--trace <local.jsonl>"],
|
|
2120
3991
|
exitCodes: { success: 0, invalidInput: 2, systemFailure: 1 }
|
|
2121
3992
|
},
|
|
2122
3993
|
nextSteps,
|
|
@@ -2132,6 +4003,34 @@ From a built checkout: npm run studio -- <command>`,
|
|
|
2132
4003
|
}
|
|
2133
4004
|
const command = ["skills get", "studio call"].includes(positionals.slice(0, 2).join(" ")) ? positionals.slice(0, 2).join(" ") : positionals.join(" ");
|
|
2134
4005
|
const allowed = {
|
|
4006
|
+
"kit new": ["name", "out"],
|
|
4007
|
+
"kit extract": ["file", "out", "params"],
|
|
4008
|
+
"kit add": ["file", "ref", "id"],
|
|
4009
|
+
"kit sheet": ["file", "ref", "id"],
|
|
4010
|
+
"kit check": ["file", "brand"],
|
|
4011
|
+
preflight: ["probe"],
|
|
4012
|
+
"badge package": [
|
|
4013
|
+
"file",
|
|
4014
|
+
"photo",
|
|
4015
|
+
"name",
|
|
4016
|
+
"role",
|
|
4017
|
+
"organization",
|
|
4018
|
+
"number",
|
|
4019
|
+
"event",
|
|
4020
|
+
"url",
|
|
4021
|
+
"out"
|
|
4022
|
+
],
|
|
4023
|
+
"brand new": ["name", "slug", "out"],
|
|
4024
|
+
"brand from-url": ["url", "out", "file", "name", "slug"],
|
|
4025
|
+
"brand add-kit": ["file", "ref", "id", "family"],
|
|
4026
|
+
"brand add-logo": ["file", "ref", "id", "name"],
|
|
4027
|
+
"brand inspect": ["file"],
|
|
4028
|
+
"brand export": ["file", "out", "overwrite"],
|
|
4029
|
+
"brand preview": ["file", "site", "no-open"],
|
|
4030
|
+
"kit inspect": ["file"],
|
|
4031
|
+
"kit render": ["file", "out", "overwrite"],
|
|
4032
|
+
"kit export": ["file", "out", "overwrite"],
|
|
4033
|
+
"kit import": ["file", "out"],
|
|
2135
4034
|
...Object.fromEntries(
|
|
2136
4035
|
imageTools.map((tool) => [`image ${tool}`, ["file", "out", "params", "ref"]])
|
|
2137
4036
|
),
|
|
@@ -2204,13 +4103,13 @@ From a built checkout: npm run studio -- <command>`,
|
|
|
2204
4103
|
"design create": ["style", "out", "dry-run"],
|
|
2205
4104
|
"design validate": ["file"],
|
|
2206
4105
|
"designs list": [],
|
|
2207
|
-
"new card": ["design", "style", "file", "name", "out", "dry-run"],
|
|
2208
|
-
"new sticker": ["style", "out", "dry-run"],
|
|
4106
|
+
"new card": ["design", "style", "file", "blank", "name", "out", "dry-run"],
|
|
4107
|
+
"new sticker": ["style", "blank", "name", "out", "dry-run"],
|
|
2209
4108
|
"material set": ["file", "params"],
|
|
2210
4109
|
"material map": ["file", "id", "ref"],
|
|
2211
|
-
"new badge": ["style", "out", "dry-run"],
|
|
4110
|
+
"new badge": ["style", "blank", "name", "out", "dry-run"],
|
|
2212
4111
|
"layer list": ["file"],
|
|
2213
|
-
"layer add": ["file", "sticker", "side"],
|
|
4112
|
+
"layer add": ["file", "sticker", "kind", "params", "side", "relative-to"],
|
|
2214
4113
|
"layer set": ["file", "id", "x", "y", "rotation", "side", "params"],
|
|
2215
4114
|
"layer remove": ["file", "id", "side"],
|
|
2216
4115
|
cut: ["file", "out", "overwrite"],
|
|
@@ -2219,17 +4118,240 @@ From a built checkout: npm run studio -- <command>`,
|
|
|
2219
4118
|
"sheet pack": ["file"],
|
|
2220
4119
|
"sheet check": ["file"],
|
|
2221
4120
|
"sheet export": ["file", "out", "preview", "overwrite"],
|
|
2222
|
-
|
|
4121
|
+
"brick check": ["file", "library"],
|
|
4122
|
+
"brick plan": ["file", "library"],
|
|
4123
|
+
"brick render": ["file", "out", "step", "explode", "yaw", "pitch", "library", "overwrite"],
|
|
4124
|
+
"brick remix": ["file", "seed", "out", "mode", "library"],
|
|
4125
|
+
"grid bin": ["width", "depth", "height", "out", "overwrite"],
|
|
4126
|
+
"grid plate": ["width", "depth", "out", "overwrite"],
|
|
4127
|
+
render: ["file", "out", "overwrite", "side", "photo", "name", "role", "number", "appearance"],
|
|
2223
4128
|
validate: ["file", "out"]
|
|
2224
4129
|
};
|
|
2225
4130
|
if (!(command in allowed)) throw new InputError(`Unknown command: ${command}`);
|
|
4131
|
+
traceCommand = command;
|
|
2226
4132
|
for (const flag of Object.keys(values))
|
|
2227
|
-
if (!["json", "help", "version", ...allowed[command]].includes(flag))
|
|
4133
|
+
if (!["json", "help", "version", "compact", "trace", ...allowed[command]].includes(flag))
|
|
2228
4134
|
throw new InputError(`--${flag} is not supported by ${command}`);
|
|
2229
4135
|
const progress = (data) => process.stderr.write(`${JSON.stringify(data)}
|
|
2230
4136
|
`);
|
|
2231
4137
|
if (values.product && !["card", "badge", "sticker"].includes(values.product))
|
|
2232
4138
|
throw new InputError("--product must be card, badge or sticker.");
|
|
4139
|
+
if (command === "badge package") {
|
|
4140
|
+
const need = (value, flag) => {
|
|
4141
|
+
if (!value) throw new InputError(`Supply --${flag}.`);
|
|
4142
|
+
return value;
|
|
4143
|
+
};
|
|
4144
|
+
const out = await newPath(need(values.out, "out"));
|
|
4145
|
+
if (!out.endsWith(".makeables.json"))
|
|
4146
|
+
throw new InputError("--out must end in .makeables.json.");
|
|
4147
|
+
const { badgePackage } = await import("./badge-package-CMFI3BIR.js");
|
|
4148
|
+
const { json: body, participant } = await badgePackage(
|
|
4149
|
+
need(values.file, "file"),
|
|
4150
|
+
need(values.photo, "photo"),
|
|
4151
|
+
{
|
|
4152
|
+
name: need(values.name, "name"),
|
|
4153
|
+
role: values.role,
|
|
4154
|
+
organization: values.organization,
|
|
4155
|
+
number: values.number ? Number(values.number) : void 0,
|
|
4156
|
+
event: values.event,
|
|
4157
|
+
url: values.url
|
|
4158
|
+
}
|
|
4159
|
+
);
|
|
4160
|
+
await writeAtomic(out, JSON.stringify(body), true);
|
|
4161
|
+
emit(
|
|
4162
|
+
{
|
|
4163
|
+
path: out,
|
|
4164
|
+
participant: {
|
|
4165
|
+
name: participant.name,
|
|
4166
|
+
role: participant.role,
|
|
4167
|
+
publicUrl: participant.publicUrl
|
|
4168
|
+
}
|
|
4169
|
+
},
|
|
4170
|
+
[`makeables kit add --file <kit.json> --ref ${JSON.stringify(out)} --id badge`],
|
|
4171
|
+
`Badge package ready: ${out}`,
|
|
4172
|
+
json
|
|
4173
|
+
);
|
|
4174
|
+
return;
|
|
4175
|
+
}
|
|
4176
|
+
if (command === "preflight") {
|
|
4177
|
+
const result2 = await preflight({ probe: Boolean(values.probe) });
|
|
4178
|
+
emit(result2, ["makeables skills get kits"], preflightSummary(result2), json);
|
|
4179
|
+
return;
|
|
4180
|
+
}
|
|
4181
|
+
if (command.startsWith("brand ")) {
|
|
4182
|
+
const need = (value, flag) => {
|
|
4183
|
+
if (!value) throw new InputError(`Supply --${flag}.`);
|
|
4184
|
+
return value;
|
|
4185
|
+
};
|
|
4186
|
+
let result2;
|
|
4187
|
+
let human = "Brand ready.";
|
|
4188
|
+
if (command === "brand new") {
|
|
4189
|
+
const path2 = await newPath(need(values.out, "out"));
|
|
4190
|
+
const brand = newBrand(need(values.name, "name"), values.slug);
|
|
4191
|
+
await writeBrand(path2, brand);
|
|
4192
|
+
result2 = { path: path2, brand };
|
|
4193
|
+
} else if (command === "brand from-url") {
|
|
4194
|
+
const out = resolve8(values.file ? dirname5(values.file) : need(values.out, "out"));
|
|
4195
|
+
if (!values.file)
|
|
4196
|
+
try {
|
|
4197
|
+
await lstat4(out);
|
|
4198
|
+
throw new InputError(`Directory already exists: ${out}. Choose a new --out path.`);
|
|
4199
|
+
} catch (error) {
|
|
4200
|
+
if (error.code !== "ENOENT") throw error;
|
|
4201
|
+
}
|
|
4202
|
+
result2 = await brandFromUrl(need(values.url, "url"), out, {
|
|
4203
|
+
name: values.name,
|
|
4204
|
+
slug: values.slug,
|
|
4205
|
+
into: values.file
|
|
4206
|
+
});
|
|
4207
|
+
human = `Brand refs ready. Open ${result2.proof} and confirm the assets with the user.`;
|
|
4208
|
+
} else if (command === "brand add-kit") {
|
|
4209
|
+
const path2 = need(values.file, "file");
|
|
4210
|
+
const brand = await addBrandKit(
|
|
4211
|
+
path2,
|
|
4212
|
+
need(values.ref, "ref"),
|
|
4213
|
+
need(values.id, "id"),
|
|
4214
|
+
values.family
|
|
4215
|
+
);
|
|
4216
|
+
await writeBrand(path2, brand);
|
|
4217
|
+
result2 = { path: resolve8(path2), kits: brand.kits.length };
|
|
4218
|
+
} else if (command === "brand add-logo") {
|
|
4219
|
+
const path2 = need(values.file, "file");
|
|
4220
|
+
const brand = await addBrandLogo(
|
|
4221
|
+
path2,
|
|
4222
|
+
need(values.ref, "ref"),
|
|
4223
|
+
need(values.id, "id"),
|
|
4224
|
+
values.name
|
|
4225
|
+
);
|
|
4226
|
+
await writeBrand(path2, brand);
|
|
4227
|
+
result2 = { path: resolve8(path2), logos: brand.logos.map((logo) => logo.id) };
|
|
4228
|
+
} else if (command === "brand inspect") {
|
|
4229
|
+
const brand = await readBrand(need(values.file, "file"));
|
|
4230
|
+
const view = await brandView(need(values.file, "file"));
|
|
4231
|
+
result2 = {
|
|
4232
|
+
brand,
|
|
4233
|
+
pieces: view.pieces.length,
|
|
4234
|
+
products: Object.fromEntries(
|
|
4235
|
+
["card", "badge", "sticker"].map((product) => [
|
|
4236
|
+
product,
|
|
4237
|
+
view.pieces.filter((piece) => piece.product === product).length
|
|
4238
|
+
])
|
|
4239
|
+
),
|
|
4240
|
+
unsubmitted: brand.kits.filter((kit) => !kit.submission).map((kit) => kit.id)
|
|
4241
|
+
};
|
|
4242
|
+
} else if (command === "brand export") {
|
|
4243
|
+
const out = resolve8(need(values.out, "out"));
|
|
4244
|
+
if (!out.endsWith(".zip")) throw new InputError("--out must end in .zip.");
|
|
4245
|
+
const { packageZip } = await import("./submission-ATVS5YTK.js");
|
|
4246
|
+
const pkg = await prepareBrand(need(values.file, "file"));
|
|
4247
|
+
const bytes = await packageZip(pkg);
|
|
4248
|
+
const handle2 = await open4(
|
|
4249
|
+
values.overwrite ? out : await newPath(out),
|
|
4250
|
+
values.overwrite ? "w" : "wx"
|
|
4251
|
+
);
|
|
4252
|
+
try {
|
|
4253
|
+
await handle2.writeFile(bytes);
|
|
4254
|
+
} finally {
|
|
4255
|
+
await handle2.close();
|
|
4256
|
+
}
|
|
4257
|
+
result2 = {
|
|
4258
|
+
path: out,
|
|
4259
|
+
product: "brand",
|
|
4260
|
+
kits: pkg.manifest.product === "brand" ? pkg.manifest.kits.length : 0,
|
|
4261
|
+
bytes: bytes.length
|
|
4262
|
+
};
|
|
4263
|
+
} else {
|
|
4264
|
+
const { serveBrandPreview } = await import("./brand-preview-W4FTJJM3.js");
|
|
4265
|
+
result2 = await serveBrandPreview(need(values.file, "file"), {
|
|
4266
|
+
site: values.site,
|
|
4267
|
+
open: !values["no-open"]
|
|
4268
|
+
});
|
|
4269
|
+
human = `Brand preview at ${result2.url}. Press Ctrl+C to stop.`;
|
|
4270
|
+
}
|
|
4271
|
+
emit(result2, ["makeables skills get kits"], human, json);
|
|
4272
|
+
return;
|
|
4273
|
+
}
|
|
4274
|
+
if (command.startsWith("kit ")) {
|
|
4275
|
+
const required = (value, flag) => {
|
|
4276
|
+
if (!value) throw new InputError(`Supply --${flag}.`);
|
|
4277
|
+
return value;
|
|
4278
|
+
};
|
|
4279
|
+
let result2;
|
|
4280
|
+
if (command === "kit check") {
|
|
4281
|
+
result2 = await checkKitAgainstBrand(
|
|
4282
|
+
required(values.file, "file"),
|
|
4283
|
+
required(values.brand, "brand")
|
|
4284
|
+
);
|
|
4285
|
+
emit(result2, ["makeables skills get kits"], JSON.stringify(result2, null, 2), json);
|
|
4286
|
+
if (!result2.ok) process.exitCode = 2;
|
|
4287
|
+
return;
|
|
4288
|
+
}
|
|
4289
|
+
if (command === "kit extract") {
|
|
4290
|
+
result2 = await extractKit(
|
|
4291
|
+
required(values.file, "file"),
|
|
4292
|
+
required(values.out, "out"),
|
|
4293
|
+
values.params ? await readParams(values.params) : {}
|
|
4294
|
+
);
|
|
4295
|
+
} else if (command === "kit new") {
|
|
4296
|
+
const path2 = await newPath(required(values.out, "out"));
|
|
4297
|
+
const kit = kitDocumentSchema.parse({
|
|
4298
|
+
format: "makeables-kit",
|
|
4299
|
+
version: 1,
|
|
4300
|
+
name: required(values.name, "name"),
|
|
4301
|
+
items: [],
|
|
4302
|
+
sheets: []
|
|
4303
|
+
});
|
|
4304
|
+
await writeAtomic(path2, JSON.stringify(kit, null, 2), true);
|
|
4305
|
+
result2 = { path: path2, kit };
|
|
4306
|
+
} else if (command === "kit add" || command === "kit sheet") {
|
|
4307
|
+
const path2 = required(values.file, "file");
|
|
4308
|
+
const kit = await addKitMember(
|
|
4309
|
+
path2,
|
|
4310
|
+
required(values.ref, "ref"),
|
|
4311
|
+
required(values.id, "id"),
|
|
4312
|
+
command === "kit add" ? "items" : "sheets"
|
|
4313
|
+
);
|
|
4314
|
+
await writeAtomic(path2, JSON.stringify(kit, null, 2));
|
|
4315
|
+
result2 = { path: resolve8(path2), items: kit.items.length, sheets: kit.sheets.length };
|
|
4316
|
+
} else if (command === "kit import") {
|
|
4317
|
+
result2 = await importKit(required(values.file, "file"), required(values.out, "out"));
|
|
4318
|
+
} else if (command === "kit inspect" && !required(values.file, "file").toLowerCase().endsWith(".zip")) {
|
|
4319
|
+
const value = JSON.parse(await readFile8(required(values.file, "file"), "utf8"));
|
|
4320
|
+
if (value.format === "makeables-kit") result2 = await readKit(required(values.file, "file"));
|
|
4321
|
+
else {
|
|
4322
|
+
const pkg = await readSubmission(required(values.file, "file"));
|
|
4323
|
+
if (pkg.manifest.product !== "kit")
|
|
4324
|
+
throw new InputError("Choose a kit document or package.");
|
|
4325
|
+
result2 = pkg.manifest;
|
|
4326
|
+
}
|
|
4327
|
+
} else {
|
|
4328
|
+
const { pkg, bytes } = await exportKit(required(values.file, "file"));
|
|
4329
|
+
if (command === "kit inspect") result2 = pkg.manifest;
|
|
4330
|
+
else {
|
|
4331
|
+
const out = resolve8(required(values.out, "out"));
|
|
4332
|
+
const rendering = command === "kit render";
|
|
4333
|
+
if (!out.endsWith(rendering ? ".png" : ".zip"))
|
|
4334
|
+
throw new InputError(rendering ? "--out must end in .png." : "--out must end in .zip.");
|
|
4335
|
+
const handle2 = await open4(
|
|
4336
|
+
values.overwrite ? out : await newPath(out),
|
|
4337
|
+
values.overwrite ? "w" : "wx"
|
|
4338
|
+
);
|
|
4339
|
+
try {
|
|
4340
|
+
await handle2.writeFile(rendering ? pkg.files[pkg.manifest.front] : bytes);
|
|
4341
|
+
} finally {
|
|
4342
|
+
await handle2.close();
|
|
4343
|
+
}
|
|
4344
|
+
result2 = {
|
|
4345
|
+
path: out,
|
|
4346
|
+
product: "kit",
|
|
4347
|
+
title: pkg.manifest.title,
|
|
4348
|
+
bytes: rendering ? pkg.files[pkg.manifest.front].length : bytes.length
|
|
4349
|
+
};
|
|
4350
|
+
}
|
|
4351
|
+
}
|
|
4352
|
+
emit(result2, ["makeables skills get kits"], "Kit ready.", json);
|
|
4353
|
+
return;
|
|
4354
|
+
}
|
|
2233
4355
|
if (command.startsWith("wallet ")) {
|
|
2234
4356
|
const walletProgress = (data) => {
|
|
2235
4357
|
if (json) return progress(data);
|
|
@@ -2289,7 +4411,12 @@ From a built checkout: npm run studio -- <command>`,
|
|
|
2289
4411
|
throw new InputError(
|
|
2290
4412
|
"Supply --file with editable JSON, an original SVG or a Makeables package."
|
|
2291
4413
|
);
|
|
2292
|
-
const
|
|
4414
|
+
const isBrand = values.file.endsWith(".json") && await readFile8(values.file, "utf8").then(
|
|
4415
|
+
(text) => text.includes('"makeables-brand"'),
|
|
4416
|
+
() => false
|
|
4417
|
+
);
|
|
4418
|
+
const submit = isBrand ? (await import("./brand-NMENEWIZ.js")).submitBrand : submitDesign;
|
|
4419
|
+
const result2 = await submit({
|
|
2293
4420
|
title: values.title,
|
|
2294
4421
|
credit: values.credit,
|
|
2295
4422
|
creditUrl: values["credit-url"],
|
|
@@ -2339,7 +4466,7 @@ From a built checkout: npm run studio -- <command>`,
|
|
|
2339
4466
|
}
|
|
2340
4467
|
if (command === "doctor") {
|
|
2341
4468
|
const check = (command2) => {
|
|
2342
|
-
const result2 =
|
|
4469
|
+
const result2 = spawnSync3(command2, ["--version"], {
|
|
2343
4470
|
encoding: "utf8",
|
|
2344
4471
|
timeout: 5e3,
|
|
2345
4472
|
stdio: ["ignore", "pipe", "pipe"]
|
|
@@ -2395,7 +4522,7 @@ Run makeables skills get core for setup.`,
|
|
|
2395
4522
|
}
|
|
2396
4523
|
if (command === "mcp") {
|
|
2397
4524
|
try {
|
|
2398
|
-
const { runMcp } = await import("./mcp-
|
|
4525
|
+
const { runMcp } = await import("./mcp-YBRUVIWG.js");
|
|
2399
4526
|
await runMcp(values.root ?? process.cwd());
|
|
2400
4527
|
} catch (error) {
|
|
2401
4528
|
process.stderr.write(
|
|
@@ -2537,7 +4664,7 @@ ${styles.map((entry) => `${column(entry.id ?? "", 25)}${entry.name}`).join("\n")
|
|
|
2537
4664
|
const path2 = values.out ? await newPath(values.out) : null;
|
|
2538
4665
|
const dryRun = Boolean(values["dry-run"]);
|
|
2539
4666
|
if (path2 && !dryRun) {
|
|
2540
|
-
const handle2 = await
|
|
4667
|
+
const handle2 = await open4(path2, "wx", 384);
|
|
2541
4668
|
try {
|
|
2542
4669
|
await handle2.writeFile(`${JSON.stringify(design, null, 2)}
|
|
2543
4670
|
`);
|
|
@@ -2560,8 +4687,8 @@ ${dryRun ? "Dry run. No files changed." : "2 faces \xB7 editable layers"}`,
|
|
|
2560
4687
|
return;
|
|
2561
4688
|
}
|
|
2562
4689
|
if (!values.file) throw new InputError("Supply --file to validate a document.");
|
|
2563
|
-
const path =
|
|
2564
|
-
const handle = await
|
|
4690
|
+
const path = resolve8(values.file);
|
|
4691
|
+
const handle = await open4(path, "r");
|
|
2565
4692
|
let input;
|
|
2566
4693
|
try {
|
|
2567
4694
|
const info = await handle.stat();
|
|
@@ -2586,7 +4713,7 @@ Schema and layout constraints passed.`,
|
|
|
2586
4713
|
}
|
|
2587
4714
|
async function readParams(value) {
|
|
2588
4715
|
return JSON.parse(
|
|
2589
|
-
value.startsWith("@") ? await readFile8(
|
|
4716
|
+
value.startsWith("@") ? await readFile8(resolve8(value.slice(1)), "utf8") : value
|
|
2590
4717
|
);
|
|
2591
4718
|
}
|
|
2592
4719
|
async function cardCommand(command, values, json) {
|
|
@@ -2597,12 +4724,26 @@ async function cardCommand(command, values, json) {
|
|
|
2597
4724
|
};
|
|
2598
4725
|
const renderHint = (path, face, design) => `npx makeables render --file ${JSON.stringify(path)} --side ${face} --out ${design.product ?? "badge"}.png --overwrite`;
|
|
2599
4726
|
if (command === "new sticker") {
|
|
4727
|
+
if (values.blank && values.style) throw new InputError("Choose --blank or --style, not both.");
|
|
2600
4728
|
const preset = stickerStyles.find(
|
|
2601
4729
|
(entry) => entry.source === (values.style ?? "typingmind-absolutely-right")
|
|
2602
4730
|
);
|
|
2603
4731
|
if (!preset)
|
|
2604
4732
|
throw new InputError("Unknown sticker style. Run makeables styles list --product sticker.");
|
|
2605
|
-
const design = badgeDesignSchema.parse(
|
|
4733
|
+
const design = badgeDesignSchema.parse(
|
|
4734
|
+
values.blank ? {
|
|
4735
|
+
...structuredClone(preset),
|
|
4736
|
+
name: typeof values.name === "string" ? values.name : "Untitled sticker",
|
|
4737
|
+
source: void 0,
|
|
4738
|
+
event: typeof values.name === "string" ? values.name : "Makeables",
|
|
4739
|
+
description: "",
|
|
4740
|
+
artPrompt: "",
|
|
4741
|
+
assets: {},
|
|
4742
|
+
front: { background: "#00000000", layers: [] },
|
|
4743
|
+
back: { background: "#00000000", layers: [] },
|
|
4744
|
+
sticker: { ...defaultStickerMaterial }
|
|
4745
|
+
} : structuredClone(preset)
|
|
4746
|
+
);
|
|
2606
4747
|
const path = values.out ? await newPath(String(values.out)) : null;
|
|
2607
4748
|
const dryRun = Boolean(values["dry-run"]);
|
|
2608
4749
|
if (path && !dryRun) await writeAtomic(path, `${JSON.stringify(design, null, 2)}
|
|
@@ -2619,10 +4760,29 @@ ${path ?? "No file written. Add --out sticker.json."}`,
|
|
|
2619
4760
|
);
|
|
2620
4761
|
return true;
|
|
2621
4762
|
}
|
|
4763
|
+
if ((command === "new badge" || command === "new card") && values.blank) {
|
|
4764
|
+
if (values.style || values.design || values.file)
|
|
4765
|
+
throw new InputError("Choose --blank or a --style, --design or --file, not both.");
|
|
4766
|
+
const design = blankDesign(command === "new card" ? "card" : "badge", values.name);
|
|
4767
|
+
const path = values.out ? await newPath(String(values.out)) : null;
|
|
4768
|
+
const dryRun = Boolean(values["dry-run"]);
|
|
4769
|
+
if (path && !dryRun) await writeAtomic(path, `${JSON.stringify(design, null, 2)}
|
|
4770
|
+
`, true);
|
|
4771
|
+
emit(
|
|
4772
|
+
{ design, path, written: Boolean(path && !dryRun), dryRun },
|
|
4773
|
+
path ? [
|
|
4774
|
+
`makeables layer add --file ${JSON.stringify(path)} --kind text --params '{"text":"..."}'`
|
|
4775
|
+
] : [],
|
|
4776
|
+
`${design.product === "card" ? "Card" : "Badge"} ready \xB7 ${design.name}
|
|
4777
|
+
${path ?? "No file written. Add --out."}`,
|
|
4778
|
+
json
|
|
4779
|
+
);
|
|
4780
|
+
return true;
|
|
4781
|
+
}
|
|
2622
4782
|
if (command === "new badge" || command === "new card") {
|
|
2623
4783
|
const card = command === "new card";
|
|
2624
4784
|
if (card && [values.design, values.style, values.file].filter(Boolean).length !== 1)
|
|
2625
|
-
throw new InputError("Choose exactly one --design, --style or --
|
|
4785
|
+
throw new InputError("Choose exactly one --design, --style, --file or --blank.");
|
|
2626
4786
|
const preset = card && values.style ? findCardStyle(need(values.style, "style")) : void 0;
|
|
2627
4787
|
if (card && values.style && !preset)
|
|
2628
4788
|
throw new InputError("Unknown card style. Run makeables styles list --product card.");
|
|
@@ -2648,7 +4808,7 @@ ${path ?? `No file written. Add --out ${kind}.json.`}`,
|
|
|
2648
4808
|
return true;
|
|
2649
4809
|
}
|
|
2650
4810
|
if (command === "material map") {
|
|
2651
|
-
const path =
|
|
4811
|
+
const path = resolve8(need(file, "file"));
|
|
2652
4812
|
const next = await bindStickerMap(
|
|
2653
4813
|
await readBadge(path),
|
|
2654
4814
|
need(values.id, "id"),
|
|
@@ -2664,7 +4824,7 @@ ${path ?? `No file written. Add --out ${kind}.json.`}`,
|
|
|
2664
4824
|
return true;
|
|
2665
4825
|
}
|
|
2666
4826
|
if (command === "material set") {
|
|
2667
|
-
const path =
|
|
4827
|
+
const path = resolve8(need(file, "file"));
|
|
2668
4828
|
const design = await readBadge(path);
|
|
2669
4829
|
const raw = need(values.params, "params");
|
|
2670
4830
|
const input = JSON.parse(raw.startsWith("@") ? await readFile8(raw.slice(1), "utf8") : raw);
|
|
@@ -2726,11 +4886,11 @@ ${path ?? `No file written. Add --out ${kind}.json.`}`,
|
|
|
2726
4886
|
const changes = values.params ? await readParams(String(values.params)) : {};
|
|
2727
4887
|
if (typeof changes !== "object" || changes === null || Array.isArray(changes))
|
|
2728
4888
|
throw new InputError("--params must be a JSON object of layer fields.");
|
|
2729
|
-
for (const
|
|
2730
|
-
if (values[
|
|
2731
|
-
const value = Number(values[
|
|
2732
|
-
if (!Number.isFinite(value)) throw new InputError(`--${
|
|
2733
|
-
changes[
|
|
4889
|
+
for (const key2 of ["x", "y", "rotation"])
|
|
4890
|
+
if (values[key2] !== void 0) {
|
|
4891
|
+
const value = Number(values[key2]);
|
|
4892
|
+
if (!Number.isFinite(value)) throw new InputError(`--${key2} must be a number.`);
|
|
4893
|
+
changes[key2] = value;
|
|
2734
4894
|
}
|
|
2735
4895
|
design = setBadgeLayer(design, face, id, changes);
|
|
2736
4896
|
}
|
|
@@ -2744,14 +4904,37 @@ ${path ?? `No file written. Add --out ${kind}.json.`}`,
|
|
|
2744
4904
|
);
|
|
2745
4905
|
return true;
|
|
2746
4906
|
}
|
|
4907
|
+
if (command === "layer add" && values.kind) {
|
|
4908
|
+
if (values.sticker) throw new InputError("Choose --kind or --sticker, not both.");
|
|
4909
|
+
const path = need(file, "file");
|
|
4910
|
+
const face = side(values.side);
|
|
4911
|
+
const { design, id, warnings } = addLayer(
|
|
4912
|
+
await readBadge(path),
|
|
4913
|
+
face,
|
|
4914
|
+
String(values.kind),
|
|
4915
|
+
values.params ? await readParams(String(values.params)) : {}
|
|
4916
|
+
);
|
|
4917
|
+
await writeAtomic(path, `${JSON.stringify(design, null, 2)}
|
|
4918
|
+
`);
|
|
4919
|
+
const layer = design[face].layers.find((item) => item.id === id);
|
|
4920
|
+
emit(
|
|
4921
|
+
{ layer, warnings },
|
|
4922
|
+
[renderHint(path, face, design)],
|
|
4923
|
+
[`${style("Added", "1", !json)} ${id} on ${face}`, ...warnings].join("\n"),
|
|
4924
|
+
json
|
|
4925
|
+
);
|
|
4926
|
+
return true;
|
|
4927
|
+
}
|
|
2747
4928
|
if (command === "layer add") {
|
|
2748
4929
|
const path = need(file, "file");
|
|
2749
4930
|
const face = side(values.side);
|
|
4931
|
+
if (values["relative-to"] && !["cwd", "document"].includes(String(values["relative-to"])))
|
|
4932
|
+
throw new InputError("--relative-to must be document or cwd.");
|
|
2750
4933
|
const design = await addBadgeSticker(
|
|
2751
4934
|
await readBadge(path),
|
|
2752
4935
|
face,
|
|
2753
4936
|
need(values.sticker, "sticker"),
|
|
2754
|
-
|
|
4937
|
+
values["relative-to"] === "cwd" ? process.cwd() : dirname5(resolve8(path))
|
|
2755
4938
|
);
|
|
2756
4939
|
await writeAtomic(path, `${JSON.stringify(design, null, 2)}
|
|
2757
4940
|
`);
|
|
@@ -2764,9 +4947,100 @@ ${path ?? `No file written. Add --out ${kind}.json.`}`,
|
|
|
2764
4947
|
);
|
|
2765
4948
|
return true;
|
|
2766
4949
|
}
|
|
4950
|
+
if (command === "grid bin" || command === "grid plate") {
|
|
4951
|
+
const out = resolve8(need(values.out, "out"));
|
|
4952
|
+
const result = gridStl(command === "grid bin" ? "bin" : "plate", values);
|
|
4953
|
+
const handle = await open4(out, values.overwrite ? "w" : "wx", 420);
|
|
4954
|
+
try {
|
|
4955
|
+
await handle.writeFile(result.stl);
|
|
4956
|
+
} finally {
|
|
4957
|
+
await handle.close();
|
|
4958
|
+
}
|
|
4959
|
+
const { x, y, z: z5 } = result.sizeMm;
|
|
4960
|
+
emit(
|
|
4961
|
+
{ out, part: result.part, triangles: result.triangles, sizeMm: result.sizeMm },
|
|
4962
|
+
[`Open ${out} in your slicer.`],
|
|
4963
|
+
`${style("Written", "1", !json)} ${result.name}, ${x} x ${y} x ${z5} mm, ${out}`,
|
|
4964
|
+
json
|
|
4965
|
+
);
|
|
4966
|
+
return true;
|
|
4967
|
+
}
|
|
4968
|
+
if (command.startsWith("brick ")) {
|
|
4969
|
+
const path = need(file, "file");
|
|
4970
|
+
const model = await readBrickModel(path, ldrawLibrary(values.library));
|
|
4971
|
+
if (command === "brick check") {
|
|
4972
|
+
const report = brickCheck(model);
|
|
4973
|
+
emit(
|
|
4974
|
+
report,
|
|
4975
|
+
report.ok ? [`makeables brick plan --file ${JSON.stringify(path)}`] : ["Fix the parts listed, or add connectors for joints the checker cannot see."],
|
|
4976
|
+
[
|
|
4977
|
+
`${style(report.ok ? "Buildable" : "Not buildable", "1", !json)} ${report.parts} parts, ${report.joints} joints`,
|
|
4978
|
+
...report.issues.map((i) => ` ${i.rule}: ${i.message}: ${i.parts.join(", ")}`)
|
|
4979
|
+
].join("\n"),
|
|
4980
|
+
json
|
|
4981
|
+
);
|
|
4982
|
+
if (!report.ok) process.exitCode = 2;
|
|
4983
|
+
return true;
|
|
4984
|
+
}
|
|
4985
|
+
if (command === "brick plan") {
|
|
4986
|
+
const plan = brickPlan(model);
|
|
4987
|
+
emit(
|
|
4988
|
+
{ steps: plan.rows, locked: plan.locked },
|
|
4989
|
+
[`makeables brick render --file ${JSON.stringify(path)} --out step.png --step 1`],
|
|
4990
|
+
[
|
|
4991
|
+
...plan.rows.map(
|
|
4992
|
+
(r) => ` ${String(r.step).padStart(3)} ${r.part.padEnd(9)} ${r.insert} ${r.name}`
|
|
4993
|
+
),
|
|
4994
|
+
...plan.locked.length ? [` locked: ${plan.locked.join(", ")}`] : []
|
|
4995
|
+
].join("\n"),
|
|
4996
|
+
json
|
|
4997
|
+
);
|
|
4998
|
+
return true;
|
|
4999
|
+
}
|
|
5000
|
+
const number = (name) => {
|
|
5001
|
+
const raw = values[name];
|
|
5002
|
+
if (raw === void 0) return void 0;
|
|
5003
|
+
const n = Number(raw);
|
|
5004
|
+
if (!Number.isFinite(n)) throw new InputError(`--${name} must be a number.`);
|
|
5005
|
+
return n;
|
|
5006
|
+
};
|
|
5007
|
+
if (command === "brick remix") {
|
|
5008
|
+
const seed = number("seed");
|
|
5009
|
+
if (seed === void 0 || !Number.isInteger(seed))
|
|
5010
|
+
throw new InputError("--seed must be an integer.");
|
|
5011
|
+
const out2 = await newPath(need(values.out, "out"));
|
|
5012
|
+
const result = brickRemix(model, seed, values.mode);
|
|
5013
|
+
await writeAtomic(out2, result.ldr, true);
|
|
5014
|
+
emit(
|
|
5015
|
+
{ out: out2, parts: result.check.parts, left: result.left, check: result.check },
|
|
5016
|
+
[
|
|
5017
|
+
`makeables brick plan --file ${JSON.stringify(out2)}`,
|
|
5018
|
+
`makeables brick render --file ${JSON.stringify(out2)} --out remix.png`
|
|
5019
|
+
],
|
|
5020
|
+
`${style(result.check.ok ? "Remixed" : "Remixed, not buildable", "1", !json)} ${result.check.parts} parts${result.left.length ? `, ${result.left.length} left in the bag` : ""} ${out2}`,
|
|
5021
|
+
json
|
|
5022
|
+
);
|
|
5023
|
+
return true;
|
|
5024
|
+
}
|
|
5025
|
+
const out = resolve8(need(values.out, "out"));
|
|
5026
|
+
const png = await brickRender(model, {
|
|
5027
|
+
step: number("step"),
|
|
5028
|
+
explode: number("explode"),
|
|
5029
|
+
yaw: number("yaw"),
|
|
5030
|
+
pitch: number("pitch")
|
|
5031
|
+
});
|
|
5032
|
+
const handle = await open4(out, values.overwrite ? "w" : "wx", 420);
|
|
5033
|
+
try {
|
|
5034
|
+
await handle.writeFile(png);
|
|
5035
|
+
} finally {
|
|
5036
|
+
await handle.close();
|
|
5037
|
+
}
|
|
5038
|
+
emit({ out }, [`Open ${out} and look at it.`], `${style("Rendered", "1", !json)} ${out}`, json);
|
|
5039
|
+
return true;
|
|
5040
|
+
}
|
|
2767
5041
|
if (command.startsWith("sheet ")) {
|
|
2768
5042
|
const write = async (target, bytes) => {
|
|
2769
|
-
const handle = await
|
|
5043
|
+
const handle = await open4(target, values.overwrite ? "w" : "wx", 420);
|
|
2770
5044
|
try {
|
|
2771
5045
|
await handle.writeFile(bytes);
|
|
2772
5046
|
} finally {
|
|
@@ -2845,9 +5119,9 @@ ${path ?? `No file written. Add --out ${kind}.json.`}`,
|
|
|
2845
5119
|
throw new InputError(
|
|
2846
5120
|
`The sheet is not printable. Run makeables sheet check --file ${JSON.stringify(path)}.`
|
|
2847
5121
|
);
|
|
2848
|
-
const out =
|
|
5122
|
+
const out = resolve8(need(values.out, "out"));
|
|
2849
5123
|
if (!out.endsWith(".pdf")) throw new InputError("--out must end in .pdf.");
|
|
2850
|
-
const preview = typeof values.preview === "string" ?
|
|
5124
|
+
const preview = typeof values.preview === "string" ? resolve8(values.preview) : void 0;
|
|
2851
5125
|
if (preview && !preview.endsWith(".png")) throw new InputError("--preview must end in .png.");
|
|
2852
5126
|
const targets = [out, ...preview ? [preview] : []];
|
|
2853
5127
|
if (!values.overwrite) for (const target of targets) await newPath(target);
|
|
@@ -2865,12 +5139,12 @@ ${path ?? `No file written. Add --out ${kind}.json.`}`,
|
|
|
2865
5139
|
if (command === "cut") {
|
|
2866
5140
|
const design = await readBadge(need(file, "file"));
|
|
2867
5141
|
if (design.product !== "sticker") throw new InputError("cut applies to sticker designs.");
|
|
2868
|
-
const out = typeof values.out === "string" ?
|
|
5142
|
+
const out = typeof values.out === "string" ? resolve8(values.out) : void 0;
|
|
2869
5143
|
if (out && !out.endsWith(".svg")) throw new InputError("--out must end in .svg.");
|
|
2870
5144
|
const target = out && (values.overwrite ? out : await newPath(out));
|
|
2871
5145
|
const { cut, svg } = await stickerPrintCut(design);
|
|
2872
5146
|
if (target) {
|
|
2873
|
-
const handle = await
|
|
5147
|
+
const handle = await open4(target, values.overwrite ? "w" : "wx", 420);
|
|
2874
5148
|
try {
|
|
2875
5149
|
await handle.writeFile(svg);
|
|
2876
5150
|
} finally {
|
|
@@ -2902,17 +5176,20 @@ ${path ?? `No file written. Add --out ${kind}.json.`}`,
|
|
|
2902
5176
|
return true;
|
|
2903
5177
|
}
|
|
2904
5178
|
if (command === "render") {
|
|
5179
|
+
if (values.appearance && !["flat", "material"].includes(String(values.appearance)))
|
|
5180
|
+
throw new InputError("--appearance must be flat or material.");
|
|
5181
|
+
const appearance = values.appearance;
|
|
2905
5182
|
const path = need(file, "file");
|
|
2906
|
-
const out =
|
|
5183
|
+
const out = resolve8(need(values.out, "out"));
|
|
2907
5184
|
if (!out.endsWith(".png")) throw new InputError("--out must end in .png.");
|
|
2908
5185
|
const target = values.overwrite ? out : await newPath(out);
|
|
2909
5186
|
const number = values.number === void 0 ? void 0 : Number(values.number);
|
|
2910
5187
|
if (number !== void 0 && !Number.isInteger(number))
|
|
2911
5188
|
throw new InputError("--number must be an integer.");
|
|
2912
5189
|
const face = side(values.side);
|
|
2913
|
-
const value = JSON.parse(await readFile8(
|
|
5190
|
+
const value = JSON.parse(await readFile8(resolve8(path), "utf8"));
|
|
2914
5191
|
const pkg = value?.format === "makeables-package" ? await readPackageJson(value) : void 0;
|
|
2915
|
-
if (pkg && ["photo", "name", "role", "number"].some((
|
|
5192
|
+
if (pkg && ["photo", "name", "role", "number"].some((key2) => values[key2] !== void 0))
|
|
2916
5193
|
throw new InputError(
|
|
2917
5194
|
"Portable packages include their participant and images. Edit the package before rendering, or use these flags with a plain design JSON."
|
|
2918
5195
|
);
|
|
@@ -2922,11 +5199,12 @@ ${path ?? `No file written. Add --out ${kind}.json.`}`,
|
|
|
2922
5199
|
if (pkg && !previewPath) throw new InputError("This package has no back preview.");
|
|
2923
5200
|
const card = design.product === "card" || design.product === "sticker";
|
|
2924
5201
|
const { png, person, samplePortrait } = pkg && previewPath ? {
|
|
2925
|
-
png: Buffer.from(pkg.files[previewPath]),
|
|
5202
|
+
png: appearance && draft ? await renderDraftFace(draft, face, appearance) : Buffer.from(pkg.files[previewPath]),
|
|
2926
5203
|
person: draft?.participant,
|
|
2927
5204
|
samplePortrait: false
|
|
2928
5205
|
} : await renderBadge(design, {
|
|
2929
5206
|
face,
|
|
5207
|
+
appearance,
|
|
2930
5208
|
photo: typeof values.photo === "string" ? values.photo : void 0,
|
|
2931
5209
|
person: {
|
|
2932
5210
|
...typeof values.name === "string" ? { name: values.name } : {},
|
|
@@ -2934,7 +5212,7 @@ ${path ?? `No file written. Add --out ${kind}.json.`}`,
|
|
|
2934
5212
|
...number === void 0 ? {} : { number }
|
|
2935
5213
|
}
|
|
2936
5214
|
});
|
|
2937
|
-
const handle = await
|
|
5215
|
+
const handle = await open4(target, values.overwrite ? "w" : "wx", 420);
|
|
2938
5216
|
try {
|
|
2939
5217
|
await handle.writeFile(png);
|
|
2940
5218
|
} finally {
|
|
@@ -2949,12 +5227,13 @@ ${path ?? `No file written. Add --out ${kind}.json.`}`,
|
|
|
2949
5227
|
side: face,
|
|
2950
5228
|
width,
|
|
2951
5229
|
height,
|
|
5230
|
+
appearance: appearance ?? (pkg && design.product === "sticker" ? "material" : "flat"),
|
|
2952
5231
|
...card ? {} : { person, samplePortrait }
|
|
2953
5232
|
},
|
|
2954
5233
|
[
|
|
2955
5234
|
`Open ${target} to review.`,
|
|
2956
5235
|
...portrait ? ["Pass --photo <image> to use the person's portrait."] : [],
|
|
2957
|
-
...pkg && design.product === "sticker" ? ["The transparent PNG includes the sticker material."] : ["This is flat artwork. Use an available integrated preview for live materials."]
|
|
5236
|
+
...(appearance === "material" || pkg && !appearance) && design.product === "sticker" ? ["The transparent PNG includes the sticker material."] : ["This is flat artwork. Use an available integrated preview for live materials."]
|
|
2958
5237
|
],
|
|
2959
5238
|
`${style("Rendered", "1", !json)} ${face} ${target}${portrait ? "\n note: sample portrait; pass --photo" : ""}`,
|
|
2960
5239
|
json
|
|
@@ -2972,13 +5251,13 @@ ${path ?? `No file written. Add --out ${kind}.json.`}`,
|
|
|
2972
5251
|
return true;
|
|
2973
5252
|
}
|
|
2974
5253
|
if (command === "validate") {
|
|
2975
|
-
const path =
|
|
5254
|
+
const path = resolve8(need(file, "file"));
|
|
2976
5255
|
if (extname4(path).toLowerCase() === ".svg") {
|
|
2977
5256
|
const source = await readFile8(path, "utf8");
|
|
2978
5257
|
const result2 = checkSubmittedSvg(source);
|
|
2979
5258
|
if (!result2.ok) throw new InputError(result2.error);
|
|
2980
5259
|
const preview = values.out ? await newPath(need(values.out, "out")) : void 0;
|
|
2981
|
-
if (preview) await
|
|
5260
|
+
if (preview) await sharp9(Buffer.from(result2.svg)).png().toFile(preview);
|
|
2982
5261
|
emit(
|
|
2983
5262
|
{
|
|
2984
5263
|
valid: true,
|
|
@@ -3013,7 +5292,7 @@ ${path ?? `No file written. Add --out ${kind}.json.`}`,
|
|
|
3013
5292
|
return false;
|
|
3014
5293
|
}
|
|
3015
5294
|
run().catch((error) => {
|
|
3016
|
-
const userError = error instanceof InputError || error instanceof WalletError && error.details.code === "INVALID_INPUT" || error instanceof SyntaxError || [
|
|
5295
|
+
const userError = error instanceof InputError || error instanceof ZodError2 || error instanceof WalletError && error.details.code === "INVALID_INPUT" || error instanceof SyntaxError || [
|
|
3017
5296
|
"ERR_PARSE_ARGS_UNKNOWN_OPTION",
|
|
3018
5297
|
"ERR_PARSE_ARGS_INVALID_OPTION_VALUE",
|
|
3019
5298
|
"ERR_PARSE_ARGS_UNEXPECTED_POSITIONAL",
|
|
@@ -3032,10 +5311,28 @@ run().catch((error) => {
|
|
|
3032
5311
|
},
|
|
3033
5312
|
nextSteps
|
|
3034
5313
|
};
|
|
3035
|
-
|
|
5314
|
+
traceCode = payload.error.code;
|
|
5315
|
+
if (!stdoutClosed && machineOutput(process.argv.includes("--json")))
|
|
3036
5316
|
process.stdout.write(`${JSON.stringify(payload)}
|
|
3037
5317
|
`);
|
|
3038
5318
|
process.stderr.write(`makeables: ${error.message}
|
|
3039
5319
|
`);
|
|
3040
5320
|
process.exitCode = exitCode;
|
|
5321
|
+
}).finally(async () => {
|
|
5322
|
+
if (!traceFile) return;
|
|
5323
|
+
try {
|
|
5324
|
+
const supplied = process.env.MAKEABLES_TRACE_SESSION;
|
|
5325
|
+
await appendTrace(resolve8(traceFile), {
|
|
5326
|
+
sessionId: supplied && /^[a-zA-Z0-9_-]{1,80}$/.test(supplied) ? supplied : randomUUID3(),
|
|
5327
|
+
version,
|
|
5328
|
+
command: traceCommand,
|
|
5329
|
+
startedAt,
|
|
5330
|
+
endedAt: (/* @__PURE__ */ new Date()).toISOString(),
|
|
5331
|
+
durationMs: Math.round(performance.now() - started),
|
|
5332
|
+
ok: !traceCode,
|
|
5333
|
+
...traceCode ? { code: traceCode } : {}
|
|
5334
|
+
});
|
|
5335
|
+
} catch {
|
|
5336
|
+
process.stderr.write("makeables: Could not write the optional local trace.\n");
|
|
5337
|
+
}
|
|
3041
5338
|
});
|