makeables 0.11.0 → 0.12.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/dist/cli.js CHANGED
@@ -10,7 +10,7 @@ import {
10
10
  prepareBrand,
11
11
  readBrand,
12
12
  writeBrand
13
- } from "./chunk-ZZA6CR6X.js";
13
+ } from "./chunk-YOGNQAYJ.js";
14
14
  import {
15
15
  addKitMember,
16
16
  addToSheet,
@@ -25,12 +25,12 @@ import {
25
25
  readSheet,
26
26
  sheetPreviewPng,
27
27
  sheetReport
28
- } from "./chunk-6RBOBRL4.js";
28
+ } from "./chunk-7A4TRTVE.js";
29
29
  import {
30
30
  getSkill,
31
31
  skills,
32
32
  version
33
- } from "./chunk-4WLCMPRH.js";
33
+ } from "./chunk-WEPC5LJ7.js";
34
34
  import {
35
35
  CloudError,
36
36
  InputError,
@@ -61,7 +61,7 @@ import {
61
61
  submitDesign,
62
62
  summarizeBadgeLayers,
63
63
  writeAtomic
64
- } from "./chunk-YB2YJCBS.js";
64
+ } from "./chunk-MXT43NAQ.js";
65
65
  import {
66
66
  PACKAGE_LIMIT,
67
67
  badgeDesignObjectSchema,
@@ -79,7 +79,7 @@ import {
79
79
  stickerMaterialSchema,
80
80
  svgNodeSchema,
81
81
  svgNodeXml
82
- } from "./chunk-DLTVENEP.js";
82
+ } from "./chunk-KLJRRHMY.js";
83
83
 
84
84
  // src/cli.ts
85
85
  import { spawnSync as spawnSync3 } from "child_process";
@@ -453,8 +453,10 @@ function worldBody(lib, item) {
453
453
  return { min: lo, max: hi };
454
454
  }
455
455
  function pointMates(x, y, adapter) {
456
- return length(sub(x.p, y.p)) <= adapter.tolerance && dot(x.a, y.a) <= -0.99;
456
+ if (length(sub(x.p, y.p)) > adapter.tolerance) return false;
457
+ return isSpherical(adapter, x.kind) || dot(x.a, y.a) <= -0.99;
457
458
  }
459
+ var isSpherical = (adapter, kind) => adapter.spherical?.includes(kind) ?? false;
458
460
  function axialMates(x, y, adapter) {
459
461
  if (length(cross(x.a, y.a)) > 0.01) return false;
460
462
  const d = sub(y.p, x.p);
@@ -464,26 +466,77 @@ function axialMates(x, y, adapter) {
464
466
  const hy = (y.len ?? 0) / 2;
465
467
  return Math.min(hx, along + hy) - Math.max(-hx, along - hy) >= adapter.minEngagement;
466
468
  }
469
+ var GRID = 2;
470
+ function nearPairs(boxes, margin) {
471
+ const cells = /* @__PURE__ */ new Map();
472
+ const span = (lo, hi) => [
473
+ Math.floor((lo - margin) / GRID),
474
+ Math.floor((hi + margin) / GRID)
475
+ ];
476
+ boxes.forEach((b, i) => {
477
+ const [x0, x1] = span(b.min[0], b.max[0]);
478
+ const [y0, y1] = span(b.min[1], b.max[1]);
479
+ const [z0, z1] = span(b.min[2], b.max[2]);
480
+ for (let x = x0; x <= x1; x++)
481
+ for (let y = y0; y <= y1; y++)
482
+ for (let z5 = z0; z5 <= z1; z5++) {
483
+ const key2 = `${x},${y},${z5}`;
484
+ const list = cells.get(key2);
485
+ if (list) list.push(i);
486
+ else cells.set(key2, [i]);
487
+ }
488
+ });
489
+ const seen = /* @__PURE__ */ new Set();
490
+ const out = [];
491
+ const n = boxes.length;
492
+ for (const list of cells.values())
493
+ for (let p = 0; p < list.length; p++)
494
+ for (let q = p + 1; q < list.length; q++) {
495
+ const [a, b] = list[p] < list[q] ? [list[p], list[q]] : [list[q], list[p]];
496
+ if (seen.has(a * n + b)) continue;
497
+ seen.add(a * n + b);
498
+ if (touches(boxes[a], boxes[b], margin)) out.push([a, b]);
499
+ }
500
+ return out.sort((x, y) => x[0] - y[0] || x[1] - y[1]);
501
+ }
502
+ var touches = (x, y, margin) => [0, 1, 2].every((k) => x.min[k] - margin <= y.max[k] && y.min[k] - margin <= x.max[k]);
503
+ function reachBox(lib, item, connectors) {
504
+ const { min, max } = worldBody(lib, item);
505
+ const lo = [...min];
506
+ const hi = [...max];
507
+ for (const c of connectors) {
508
+ const h = (c.len ?? 0) / 2;
509
+ for (const p of [add(c.p, scale(c.a, h)), add(c.p, scale(c.a, -h))])
510
+ for (let k = 0; k < 3; k++) {
511
+ lo[k] = Math.min(lo[k], p[k]);
512
+ hi[k] = Math.max(hi[k], p[k]);
513
+ }
514
+ }
515
+ return { min: lo, max: hi };
516
+ }
467
517
  function contacts(model, lib, adapter) {
468
518
  const world = model.items.map((it) => worldConnectors(lib, it));
519
+ const reach = model.items.map((it, i) => reachBox(lib, it, world[i]));
469
520
  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
- }
521
+ for (const [a, b] of nearPairs(reach, adapter.tolerance)) {
522
+ const kinds = /* @__PURE__ */ new Map();
523
+ for (const x of world[a])
524
+ for (const y of world[b]) {
525
+ if (!adapter.fits[x.kind]?.includes(y.kind)) continue;
526
+ const axial = x.len !== void 0 && y.len !== void 0;
527
+ if (axial ? !axialMates(x, y, adapter) : !pointMates(x, y, adapter)) continue;
528
+ const kind = [x.kind, y.kind].sort().join("-");
529
+ const seen = kinds.get(kind);
530
+ kinds.set(kind, {
531
+ count: (seen?.count ?? 0) + 1,
532
+ axis: axial ? x.a : void 0,
533
+ release: axial || isSpherical(adapter, x.kind) ? void 0 : x.a,
534
+ sum: add(seen?.sum ?? [0, 0, 0], x.p)
535
+ });
536
+ }
537
+ for (const [kind, { sum, ...v }] of kinds)
538
+ out.push({ a, b, kind, ...v, at: scale(sum, 1 / v.count) });
539
+ }
487
540
  return out;
488
541
  }
489
542
  function overlapVolume(x, y, epsilon = 0.02) {
@@ -511,7 +564,7 @@ function partVoxels(part2) {
511
564
  }
512
565
  return part2.voxels;
513
566
  }
514
- function collisionCells(lib, x, y) {
567
+ function collisionCells(lib, x, y, ignore) {
515
568
  let hits = 0;
516
569
  for (const [a, b] of [
517
570
  [x, y],
@@ -529,6 +582,7 @@ function collisionCells(lib, x, y) {
529
582
  va.origin[2] + (k + 0.5) * CELL
530
583
  ];
531
584
  const world = add(apply(a.rotation, local), a.position);
585
+ if (ignore && length(sub(world, ignore.centre)) < ignore.radius) continue;
532
586
  const r = b.rotation;
533
587
  const d = sub(world, b.position);
534
588
  const inB = [
@@ -559,7 +613,7 @@ function readRef(f) {
559
613
  name: normalizeName(f.slice(14).join(" "))
560
614
  };
561
615
  }
562
- function parsePart(name, resolve9, isJoint) {
616
+ function parsePart(name, resolve9, isJoint2) {
563
617
  const triangles = [];
564
618
  const colors = [];
565
619
  const edges = [];
@@ -577,7 +631,7 @@ function parsePart(name, resolve9, isJoint) {
577
631
  const ref = readRef(f);
578
632
  const rotation = mul(m, ref.rotation);
579
633
  const position = add(apply(m, ref.position), t);
580
- const joint = !inJoint && isJoint(ref.name);
634
+ const joint = !inJoint && isJoint2(ref.name);
581
635
  if (joint) joints.push({ name: ref.name, ...toStuds(rotation, position) });
582
636
  const inherited = ref.color === 16 ? color : ref.color;
583
637
  walk(ref.name, rotation, position, inJoint || joint, depth + 1, inherited);
@@ -764,6 +818,9 @@ function technicShafts(name, triangles) {
764
818
  ];
765
819
  }
766
820
  var STUD = /^(stud|stud2|stud2a|studa|stud26|stud6a?|stud9|stud10|stud13|stud15|stud17a?|studel|studh[lr]?|studp01|studxa?|stud2?-logo\d?)\.dat$/;
821
+ var BALL = /^joint8ball\.dat$/;
822
+ var BALL_SOCKET = /^joint8socket\d\.dat$/;
823
+ var isJoint = (p) => STUD.test(p) || BALL.test(p) || BALL_SOCKET.test(p);
767
824
  var FAMILY = {
768
825
  "3001": "brick",
769
826
  "3003": "brick",
@@ -810,8 +867,11 @@ var lego = {
810
867
  shoulder: ["shoulderhole"],
811
868
  shoulderhole: ["shoulder"],
812
869
  wrist: ["hand"],
813
- hand: ["wrist"]
870
+ hand: ["wrist"],
871
+ ball: ["ballsocket"],
872
+ ballsocket: ["ball"]
814
873
  },
874
+ spherical: ["ball", "ballsocket"],
815
875
  tolerance: 0.06,
816
876
  minEngagement: 0.24,
817
877
  family: (part2) => FAMILY[part2] ?? part2
@@ -834,6 +894,12 @@ var LEGO_CONNECTORS = {
834
894
  ],
835
895
  // Tyre 6/ 50 x 8 Offset Tread
836
896
  "3641": [{ kind: "tyre", p: [0, 0, 0], a: [0, 0, 1], len: 0.8 }],
897
+ // Plate 1 x 2 with Socket Joint-8 and Ball Joint-8: its ball is drawn as a plain sphere,
898
+ // which also hangs below the plate and pulls the inferred sockets down with it.
899
+ "14419": [
900
+ { kind: "ball", p: [-1.5, -0.2, 0], a: [-1, 0, 0] },
901
+ ...[-0.5, 0.5].map((x) => ({ kind: "socket", p: [x, -0.4, 0], a: [0, -1, 0] }))
902
+ ],
837
903
  // Hinge 1 x 2 Base and Top: both pivot on the cylinder along x at 10 LDU below the origin.
838
904
  "3937": [{ kind: "hinge", p: [0, -0.5, 0], a: [1, 0, 0], len: 2 }],
839
905
  "3938": [{ kind: "hinge", p: [0, -0.5, 0], a: [1, 0, 0], len: 2 }],
@@ -918,17 +984,37 @@ var lattice = (lo, hi, phase) => {
918
984
  if (x - lo > 0.25 && hi - x > 0.25) out.push(x);
919
985
  return out;
920
986
  };
987
+ var BORE = 0.25;
988
+ function takesStud(voxels, x, y, z5, top) {
989
+ let hollow;
990
+ for (let h = y + CELL / 2; h < y + STUD_DEPTH && hollow === void 0; h += CELL)
991
+ if (!cellAt(voxels, [x, h, z5])) hollow = h;
992
+ if (hollow === void 0) return false;
993
+ const capped = (dx, dz) => {
994
+ for (let h = hollow + CELL; h < top; h += CELL)
995
+ if (cellAt(voxels, [x + dx, h, z5 + dz])) return true;
996
+ return false;
997
+ };
998
+ return capped(0, 0) || [
999
+ [BORE, 0],
1000
+ [-BORE, 0],
1001
+ [0, BORE],
1002
+ [0, -BORE]
1003
+ ].every(([dx, dz]) => capped(dx, dz));
1004
+ }
921
1005
  function legoConnectors(parsed, voxels) {
922
1006
  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]) });
1007
+ for (const j of parsed.joints) {
1008
+ const a = apply(j.rotation, [0, 1, 0]);
1009
+ if (STUD.test(j.name)) out.push({ kind: "stud", p: j.position, a });
1010
+ else if (BALL.test(j.name)) out.push({ kind: "ball", p: j.position, a });
1011
+ else if (BALL_SOCKET.test(j.name)) out.push({ kind: "ballsocket", p: j.position, a });
1012
+ }
926
1013
  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
- };
1014
+ const up = out.find((c) => c.kind === "stud" && dot(c.a, [0, 1, 0]) > 0.99);
1015
+ const wrap = (v) => (v % 1 + 1) % 1;
1016
+ const edgePhase = (k) => wrap(min[k] + 0.5);
1017
+ const studPhase = (k) => up ? wrap(up.p[k]) : edgePhase(k);
932
1018
  const y = min[1];
933
1019
  const rim = [];
934
1020
  for (let x = min[0] + CELL / 2; x < max[0]; x += CELL)
@@ -937,21 +1023,21 @@ function legoConnectors(parsed, voxels) {
937
1023
  rim.push([min[0] + CELL / 2, y + CELL / 2, z5], [max[0] - CELL / 2, y + CELL / 2, z5]);
938
1024
  const walled = rim.filter((p) => cellAt(voxels, p)).length >= WALLED_BOTTOM * rim.length;
939
1025
  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
- }
1026
+ const sockets = (px, pz) => {
1027
+ const found = [];
1028
+ for (const x of lattice(min[0], max[0], px))
1029
+ for (const z5 of lattice(min[2], max[2], pz))
1030
+ if (takesStud(voxels, x, y, z5, max[1]))
1031
+ found.push({ kind: "socket", p: [x, y, z5], a: [0, -1, 0] });
1032
+ return found;
1033
+ };
1034
+ const byStuds = sockets(studPhase(0), studPhase(2));
1035
+ const byEdges = sockets(edgePhase(0), edgePhase(2));
1036
+ out.push(...byEdges.length > byStuds.length ? byEdges : byStuds);
951
1037
  return out;
952
1038
  }
953
1039
  function loadLegoPart(id, resolve9, opts = {}) {
954
- const parsed = parsePart(`${id}.dat`, resolve9, (p) => STUD.test(p));
1040
+ const parsed = parsePart(`${id}.dat`, resolve9, isJoint);
955
1041
  const bounds = meshBounds(parsed.triangles);
956
1042
  const voxels = voxelize(parsed.triangles, bounds.min, bounds.max);
957
1043
  return {
@@ -980,6 +1066,37 @@ function libraryResolver(read, root) {
980
1066
  return void 0;
981
1067
  };
982
1068
  }
1069
+ function turn(x, y) {
1070
+ const r = AXIS_ROTATIONS.find(
1071
+ (m) => [0, 1, 2].every((k) => m[k * 3] === x[k] && m[k * 3 + 1] === y[k])
1072
+ );
1073
+ if (!r) throw new Error(`No axis rotation sends x to ${x} and y to ${y}.`);
1074
+ return r;
1075
+ }
1076
+ var round = (v) => v.map((x) => Math.round(x * 1e3) / 1e3 + 0);
1077
+ function describePart(part2) {
1078
+ const { min, max } = part2.body;
1079
+ const near = (a, b) => Math.abs(a - b) < 0.05;
1080
+ const connectors = {};
1081
+ for (const c of part2.connectors) {
1082
+ connectors[c.kind] ??= [];
1083
+ const list = connectors[c.kind];
1084
+ list.push({
1085
+ index: list.length,
1086
+ p: round(c.p),
1087
+ a: round(c.a),
1088
+ ...c.len === void 0 ? {} : { len: c.len }
1089
+ });
1090
+ }
1091
+ return {
1092
+ id: part2.id,
1093
+ name: part2.name,
1094
+ size: round([max[0] - min[0], max[1] - min[1], max[2] - min[2]]),
1095
+ body: { min: round(min), max: round(max) },
1096
+ origin: near(max[1], 0) ? "top" : near(min[1], 0) ? "bottom" : "inside",
1097
+ connectors
1098
+ };
1099
+ }
983
1100
  var DEFAULT_PALETTE = {
984
1101
  0: "#1B2A34",
985
1102
  1: "#1E5AA8",
@@ -1024,20 +1141,37 @@ function rasterize(model, lib, opts = {}) {
1024
1141
  const center = [0, 0, 0];
1025
1142
  for (const b of bodies) for (let k = 0; k < 3; k++) center[k] += (b.min[k] + b.max[k]) / 2;
1026
1143
  if (bodies.length) for (let k = 0; k < 3; k++) center[k] /= bodies.length;
1027
- const tris = [];
1028
- const lines = [];
1144
+ let tri = new Float64Array(9 * 4096);
1145
+ let triColor = new Float64Array(3 * 4096);
1146
+ let tris = 0;
1147
+ let seg = new Float64Array(6 * 4096);
1148
+ let segColor = new Float64Array(3 * 4096);
1149
+ let segs = 0;
1150
+ const grow = (buf, need) => {
1151
+ if (need <= buf.length) return buf;
1152
+ const next = new Float64Array(Math.max(need, buf.length * 2));
1153
+ next.set(buf);
1154
+ return next;
1155
+ };
1029
1156
  let minX = Infinity;
1030
1157
  let minY = Infinity;
1031
1158
  let maxX = -Infinity;
1032
1159
  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;
1160
+ const [r0, r1, r2] = right;
1161
+ const [u0, u1, u2] = up;
1162
+ const [v0, v1, v2] = view;
1163
+ const project = (out, o, x, y, z5) => {
1164
+ const vx = x * r0 + y * r1 + z5 * r2;
1165
+ const vy = -(x * u0 + y * u1 + z5 * u2);
1166
+ out[o] = vx;
1167
+ out[o + 1] = vy;
1168
+ out[o + 2] = x * v0 + y * v1 + z5 * v2;
1169
+ if (vx < minX) minX = vx;
1170
+ if (vx > maxX) maxX = vx;
1171
+ if (vy < minY) minY = vy;
1172
+ if (vy > maxY) maxY = vy;
1040
1173
  };
1174
+ const w = new Float64Array(9);
1041
1175
  shown.forEach(({ item, i }, n) => {
1042
1176
  const part2 = lib[item.part];
1043
1177
  if (!part2) throw new Error(`Unknown part: ${item.part}`);
@@ -1049,27 +1183,43 @@ function rasterize(model, lib, opts = {}) {
1049
1183
  ];
1050
1184
  const offset = opts.explode ? scale(sub(mid, center), opts.explode) : [0, 0, 0];
1051
1185
  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);
1186
+ const m = item.rotation;
1187
+ const [p0, p1, p2] = item.position;
1188
+ const [o0, o1, o2] = offset;
1189
+ const world = (k, x, y, z5) => {
1190
+ w[k] = m[0] * x + m[1] * y + m[2] * z5 + p0 + o0;
1191
+ w[k + 1] = m[3] * x + m[4] * y + m[5] * z5 + p1 + o1;
1192
+ w[k + 2] = m[6] * x + m[7] * y + m[8] * z5 + p2 + o2;
1193
+ };
1194
+ const shade = /* @__PURE__ */ new Map();
1053
1195
  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]));
1196
+ const faces = t.length / 9;
1197
+ tri = grow(tri, (tris + faces) * 9);
1198
+ triColor = grow(triColor, (tris + faces) * 3);
1199
+ for (let f = 0; f < faces; f++) {
1200
+ for (let v = 0; v < 3; v++)
1201
+ world(v * 3, t[f * 9 + v * 3], t[f * 9 + v * 3 + 1], t[f * 9 + v * 3 + 2]);
1202
+ const e1 = [w[3] - w[0], w[4] - w[1], w[5] - w[2]];
1203
+ const e2 = [w[6] - w[0], w[7] - w[1], w[8] - w[2]];
1204
+ const normal = cross(e1, e2);
1059
1205
  const len = length(normal);
1060
1206
  if (len < 1e-9) continue;
1061
1207
  const nn = scale(normal, 1 / len);
1062
1208
  const facing = dot(nn, view) < 0 ? scale(nn, -1) : nn;
1063
1209
  const code = part2.colors[f] === 16 ? item.color : part2.colors[f];
1064
1210
  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
- });
1211
+ let base2 = shade.get(code);
1212
+ if (!base2) {
1213
+ base2 = rgb(palette[code] ?? "#888888");
1214
+ shade.set(code, base2);
1215
+ }
1216
+ for (let v = 0; v < 3; v++)
1217
+ project(tri, tris * 9 + v * 3, w[v * 3], w[v * 3 + 1], w[v * 3 + 2]);
1218
+ for (let c = 0; c < 3; c++) {
1219
+ const value = Math.min(255, base2[c] * k);
1220
+ triColor[tris * 3 + c] = value + (255 - value) * fade;
1221
+ }
1222
+ tris++;
1073
1223
  }
1074
1224
  const e = part2.edges ?? [];
1075
1225
  const base = rgb(palette[item.color] ?? "#888888");
@@ -1077,77 +1227,103 @@ function rasterize(model, lib, opts = {}) {
1077
1227
  const edge = (dark ? [110, 110, 110] : base.map((c) => c * 0.45)).map(
1078
1228
  (c) => c + (255 - c) * fade
1079
1229
  );
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
- });
1230
+ seg = grow(seg, (segs + e.length / 6) * 6);
1231
+ segColor = grow(segColor, (segs + e.length / 6) * 3);
1232
+ for (let q = 0; q < e.length; q += 6) {
1233
+ world(0, e[q], e[q + 1], e[q + 2]);
1234
+ world(3, e[q + 3], e[q + 4], e[q + 5]);
1235
+ project(seg, segs * 6, w[0], w[1], w[2]);
1236
+ project(seg, segs * 6 + 3, w[3], w[4], w[5]);
1237
+ segColor.set(edge, segs * 3);
1238
+ segs++;
1239
+ }
1086
1240
  });
1087
- const s = tris.length ? 0.9 * Math.min(W2 / (maxX - minX || 1), H2 / (maxY - minY || 1)) : 1;
1241
+ const s = tris ? 0.9 * Math.min(W2 / (maxX - minX || 1), H2 / (maxY - minY || 1)) : 1;
1088
1242
  const ox = W2 / 2 - s * (minX + maxX) / 2;
1089
1243
  const oy = H2 / 2 - s * (minY + maxY) / 2;
1090
- const sx = (v) => [ox + s * v[0], oy + s * v[1], v[2]];
1091
1244
  const data = new Uint8ClampedArray(W2 * H2 * 4);
1092
1245
  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]);
1246
+ const pixels = new Uint32Array(data.buffer);
1247
+ pixels.fill(255 << 24 | background[2] << 16 | background[1] << 8 | background[0]);
1248
+ for (let f = 0; f < tris; f++) {
1249
+ const q = f * 9;
1250
+ const ax = ox + s * tri[q];
1251
+ const ay = oy + s * tri[q + 1];
1252
+ const az = tri[q + 2];
1253
+ const bx = ox + s * tri[q + 3];
1254
+ const by = oy + s * tri[q + 4];
1255
+ const bz = tri[q + 5];
1256
+ const cx = ox + s * tri[q + 6];
1257
+ const cy = oy + s * tri[q + 7];
1258
+ const cz = tri[q + 8];
1259
+ const area = (bx - ax) * (cy - ay) - (cx - ax) * (by - ay);
1110
1260
  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++) {
1261
+ const red = triColor[f * 3];
1262
+ const green = triColor[f * 3 + 1];
1263
+ const blue = triColor[f * 3 + 2];
1264
+ const x0 = Math.max(0, Math.floor(Math.min(ax, bx, cx)));
1265
+ const x1 = Math.min(W2 - 1, Math.ceil(Math.max(ax, bx, cx)));
1266
+ const y0 = Math.max(0, Math.floor(Math.min(ay, by, cy)));
1267
+ const y1 = Math.min(H2 - 1, Math.ceil(Math.max(ay, by, cy)));
1268
+ for (let y = y0; y <= y1; y++) {
1269
+ const py = y + 0.5;
1270
+ let lo = Infinity;
1271
+ let hi = -Infinity;
1272
+ for (const [sx, sy, ex, ey] of [
1273
+ [ax, ay, bx, by],
1274
+ [bx, by, cx, cy],
1275
+ [cx, cy, ax, ay]
1276
+ ]) {
1277
+ if ((sy - py) * (ey - py) > 0 || sy === ey) continue;
1278
+ const cross2 = sx + (py - sy) * (ex - sx) / (ey - sy);
1279
+ if (cross2 < lo) lo = cross2;
1280
+ if (cross2 > hi) hi = cross2;
1281
+ }
1282
+ if (lo > hi) continue;
1283
+ const from = Math.max(x0, Math.floor(lo) - 1);
1284
+ const to = Math.min(x1, Math.ceil(hi) + 1);
1285
+ for (let x = from; x <= to; x++) {
1117
1286
  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;
1287
+ const l0 = ((bx - px) * (cy - py) - (cx - px) * (by - py)) / area;
1288
+ if (l0 < 0) continue;
1289
+ const l1 = ((cx - px) * (ay - py) - (ax - px) * (cy - py)) / area;
1121
1290
  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);
1291
+ if (l1 < 0 || l2 < 0) continue;
1292
+ const z5 = l0 * az + l1 * bz + l2 * cz;
1293
+ const p = y * W2 + x;
1294
+ if (z5 <= depth[p]) continue;
1295
+ depth[p] = z5;
1296
+ data[p * 4] = red;
1297
+ data[p * 4 + 1] = green;
1298
+ data[p * 4 + 2] = blue;
1124
1299
  }
1300
+ }
1125
1301
  }
1126
1302
  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]))));
1303
+ const thick = ss > 1 ? 2 : 1;
1304
+ for (let l = 0; l < segs; l++) {
1305
+ const q = l * 6;
1306
+ const ax = ox + s * seg[q];
1307
+ const ay = oy + s * seg[q + 1];
1308
+ const az = seg[q + 2];
1309
+ const bx = ox + s * seg[q + 3];
1310
+ const by = oy + s * seg[q + 4];
1311
+ const bz = seg[q + 5];
1312
+ const steps = Math.max(1, Math.ceil(Math.max(Math.abs(bx - ax), Math.abs(by - ay))));
1131
1313
  for (let k = 0; k <= steps; k++) {
1132
1314
  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);
1315
+ const x = Math.round(ax + (bx - ax) * t);
1316
+ const y = Math.round(ay + (by - ay) * t);
1135
1317
  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
- }
1318
+ const z5 = az + (bz - az) * t;
1319
+ for (let dy = 0; dy < thick; dy++)
1320
+ for (let dx = 0; dx < thick; dx++) {
1321
+ const p = Math.min(H2 - 1, y + dy) * W2 + Math.min(W2 - 1, x + dx);
1322
+ if (z5 + bias < depth[p]) continue;
1323
+ data[p * 4] = segColor[l * 3];
1324
+ data[p * 4 + 1] = segColor[l * 3 + 1];
1325
+ data[p * 4 + 2] = segColor[l * 3 + 2];
1326
+ }
1151
1327
  }
1152
1328
  }
1153
1329
  return { width: W2, height: H2, data };
@@ -1178,6 +1354,11 @@ var DIRECTIONS = [
1178
1354
  ];
1179
1355
  var STEP = 0.15;
1180
1356
  var BLOCKING_CELLS = 4;
1357
+ var SWEEP = 4;
1358
+ var union = (x, y) => ({
1359
+ min: [0, 1, 2].map((k) => Math.min(x.min[k], y.min[k])),
1360
+ max: [0, 1, 2].map((k) => Math.max(x.max[k], y.max[k]))
1361
+ });
1181
1362
  var moved = (it, d, s) => ({
1182
1363
  ...it,
1183
1364
  position: add(it.position, scale(d, s))
@@ -1191,7 +1372,16 @@ function planAssembly(model, lib, adapter) {
1191
1372
  const bodies = items.map((it) => worldBody(lib, it));
1192
1373
  const axes = /* @__PURE__ */ new Map();
1193
1374
  const releases = /* @__PURE__ */ new Map();
1375
+ const snaps = /* @__PURE__ */ new Set();
1376
+ const ways = items.map(() => [...DIRECTIONS]);
1377
+ const tryWay = (i, d) => {
1378
+ if (!ways[i].some((w) => dot(w, d) > 0.999)) ways[i].push(d);
1379
+ };
1194
1380
  for (const c of contacts(model, lib, adapter)) {
1381
+ if (c.kind.split("-").every((k) => isSpherical(adapter, k))) {
1382
+ snaps.add(`${c.a},${c.b}`);
1383
+ snaps.add(`${c.b},${c.a}`);
1384
+ }
1195
1385
  if (c.axis) {
1196
1386
  axes.set(`${c.a},${c.b}`, c.axis);
1197
1387
  axes.set(`${c.b},${c.a}`, c.axis);
@@ -1199,6 +1389,8 @@ function planAssembly(model, lib, adapter) {
1199
1389
  if (c.release) {
1200
1390
  releases.set(`${c.b},${c.a}`, c.release);
1201
1391
  releases.set(`${c.a},${c.b}`, scale(c.release, -1));
1392
+ tryWay(c.b, c.release);
1393
+ tryWay(c.a, scale(c.release, -1));
1202
1394
  }
1203
1395
  }
1204
1396
  const allowed = (i, d, around) => {
@@ -1218,12 +1410,20 @@ function planAssembly(model, lib, adapter) {
1218
1410
  return dot(d, sub(centre(bodies[i]), centre(bodies[j]))) >= -1e-6;
1219
1411
  };
1220
1412
  const left = new Set(items.map((_, i) => i));
1221
- const freeWay = (i, around) => DIRECTIONS.find((d) => {
1413
+ const freeWay = (i, around) => ways[i].find((d) => {
1222
1414
  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) {
1415
+ const others = [...around].filter(
1416
+ (j) => j !== i && !slides(i, j, d) && !snaps.has(`${i},${j}`)
1417
+ );
1418
+ let nearby = [];
1419
+ for (let s = STEP, until = 0; ; s += STEP) {
1420
+ if (s > until) {
1421
+ const stretch = union(shifted(bodies[i], d, s), shifted(bodies[i], d, s + SWEEP));
1422
+ nearby = others.filter((j) => overlapVolume(stretch, bodies[j], -0.4) > 0);
1423
+ until = s + SWEEP;
1424
+ }
1225
1425
  const box2 = shifted(bodies[i], d, s);
1226
- const near = others.filter((j) => overlapVolume(box2, bodies[j], -0.4) > 0);
1426
+ const near = nearby.filter((j) => overlapVolume(box2, bodies[j], -0.4) > 0);
1227
1427
  if (!near.length) return true;
1228
1428
  const it = moved(items[i], d, s);
1229
1429
  if (near.some((j) => collisionCells(lib, it, items[j]) > BLOCKING_CELLS)) return false;
@@ -1387,6 +1587,134 @@ function remix(exemplar, lib, adapter, opts) {
1387
1587
  }
1388
1588
  return { model: { items }, left: pending.flatMap((u) => u.items.map((it) => it.part)) };
1389
1589
  }
1590
+ var BRICKS = [
1591
+ ["3007", 8, 2],
1592
+ ["2456", 6, 2],
1593
+ ["3001", 4, 2],
1594
+ ["3002", 3, 2],
1595
+ ["3008", 8, 1],
1596
+ ["3009", 6, 1],
1597
+ ["3003", 2, 2],
1598
+ ["3010", 4, 1],
1599
+ ["3622", 3, 1],
1600
+ ["3004", 2, 1],
1601
+ ["3005", 1, 1]
1602
+ ].sort((a, b) => b[1] * b[2] - a[1] * a[2] || b[1] - a[1]);
1603
+ var PLATES = [
1604
+ ["3034", 8, 2],
1605
+ ["3795", 6, 2],
1606
+ ["3020", 4, 2],
1607
+ ["3021", 3, 2],
1608
+ ["3460", 8, 1],
1609
+ ["3666", 6, 1],
1610
+ ["3022", 2, 2],
1611
+ ["3710", 4, 1],
1612
+ ["3623", 3, 1],
1613
+ ["3023b", 2, 1],
1614
+ ["3024", 1, 1]
1615
+ ].sort((a, b) => b[1] * b[2] - a[1] * a[2] || b[1] - a[1]);
1616
+ var TILES = [
1617
+ ["87079", 4, 2],
1618
+ ["26603", 3, 2],
1619
+ ["4162", 8, 1],
1620
+ ["6636", 6, 1],
1621
+ ["3068b", 2, 2],
1622
+ ["2431", 4, 1],
1623
+ ["63864", 3, 1],
1624
+ ["3069b", 2, 1],
1625
+ ["3070b", 1, 1]
1626
+ ].sort((a, b) => b[1] * b[2] - a[1] * a[2] || b[1] - a[1]);
1627
+ var ALONG_Z = turn([0, 0, 1], [0, 1, 0]);
1628
+ function stress(model, lib, adapter) {
1629
+ const n = model.items.length;
1630
+ const joints = contacts(model, lib, adapter);
1631
+ const mass = model.items.map((it) => {
1632
+ const v = partVoxels(lib[it.part]);
1633
+ let cells = 0;
1634
+ for (const c of v.interior) if (c) cells++;
1635
+ return cells * CELL ** 3;
1636
+ });
1637
+ const centre = model.items.map((it) => {
1638
+ const { min, max } = worldBody(lib, it);
1639
+ return scale(add(min, max), 0.5);
1640
+ });
1641
+ const floor = Math.min(...model.items.map((it) => worldBody(lib, it).min[1]));
1642
+ const grounded = model.items.map((it, i) => worldBody(lib, it).min[1] - floor < 0.05 ? i : -1).filter((i) => i >= 0);
1643
+ const edges = model.items.map(() => []);
1644
+ joints.forEach((c, k) => {
1645
+ edges[c.a].push([c.b, k]);
1646
+ edges[c.b].push([c.a, k]);
1647
+ });
1648
+ const reach = (start, skip) => {
1649
+ const seen = /* @__PURE__ */ new Set([start]);
1650
+ const queue = [start];
1651
+ while (queue.length) {
1652
+ const x = queue.pop();
1653
+ for (const [y, k] of edges[x])
1654
+ if (k !== skip && !seen.has(y)) {
1655
+ seen.add(y);
1656
+ queue.push(y);
1657
+ }
1658
+ }
1659
+ return seen;
1660
+ };
1661
+ const cut = bridges(n, edges);
1662
+ const out = [];
1663
+ joints.forEach((c, k) => {
1664
+ if (!cut.has(k)) return;
1665
+ const side2 = reach(c.a, k);
1666
+ const held = grounded.some((g) => side2.has(g)) ? [...reach(c.b, k)] : [...side2];
1667
+ if (held.some((i) => grounded.includes(i))) return;
1668
+ const load = held.reduce((s, i) => s + mass[i], 0);
1669
+ const com = scale(
1670
+ held.reduce((s, i) => add(s, scale(centre[i], mass[i])), [0, 0, 0]),
1671
+ 1 / load
1672
+ );
1673
+ const lever = Math.hypot(com[0] - c.at[0], com[2] - c.at[2]);
1674
+ out.push({
1675
+ a: c.a,
1676
+ b: c.b,
1677
+ kind: c.kind,
1678
+ count: c.count,
1679
+ holds: held.sort((x, y) => x - y),
1680
+ load: round3(load),
1681
+ lever: round3(lever),
1682
+ moment: round3(load * lever / c.count)
1683
+ });
1684
+ });
1685
+ return out.sort((x, y) => y.moment - x.moment);
1686
+ }
1687
+ var round3 = (v) => Math.round(v * 1e3) / 1e3;
1688
+ function bridges(n, edges) {
1689
+ const order = new Array(n).fill(-1);
1690
+ const low = new Array(n).fill(0);
1691
+ const out = /* @__PURE__ */ new Set();
1692
+ let time = 0;
1693
+ for (let root = 0; root < n; root++) {
1694
+ if (order[root] >= 0) continue;
1695
+ const stack = [{ v: root, via: -1, next: 0 }];
1696
+ order[root] = low[root] = time++;
1697
+ while (stack.length) {
1698
+ const top = stack[stack.length - 1];
1699
+ if (top.next < edges[top.v].length) {
1700
+ const [w, k] = edges[top.v][top.next++];
1701
+ if (k === top.via) continue;
1702
+ if (order[w] < 0) {
1703
+ order[w] = low[w] = time++;
1704
+ stack.push({ v: w, via: k, next: 0 });
1705
+ } else low[top.v] = Math.min(low[top.v], order[w]);
1706
+ } else {
1707
+ stack.pop();
1708
+ const parent = stack[stack.length - 1];
1709
+ if (parent) {
1710
+ low[parent.v] = Math.min(low[parent.v], low[top.v]);
1711
+ if (low[top.v] > order[parent.v]) out.add(top.via);
1712
+ }
1713
+ }
1714
+ }
1715
+ }
1716
+ return out;
1717
+ }
1390
1718
  function toStl(model, lib, unit) {
1391
1719
  const tris = [];
1392
1720
  for (const item of model.items) {
@@ -1398,6 +1726,9 @@ function toStl(model, lib, unit) {
1398
1726
  tris.push(p[0] * unit, -p[2] * unit, p[1] * unit);
1399
1727
  }
1400
1728
  }
1729
+ return encodeStl(tris);
1730
+ }
1731
+ function encodeStl(tris) {
1401
1732
  const count = tris.length / 9;
1402
1733
  const out = new DataView(new ArrayBuffer(84 + count * 50));
1403
1734
  out.setUint32(80, count, true);
@@ -1413,17 +1744,253 @@ function toStl(model, lib, unit) {
1413
1744
  }
1414
1745
  return new Uint8Array(out.buffer);
1415
1746
  }
1747
+ function toSolidStl(model, lib, unit, contacts2 = []) {
1748
+ const placed = model.items.map((item) => {
1749
+ const part2 = lib[item.part];
1750
+ if (!part2) throw new Error(`Unknown part: ${item.part}`);
1751
+ const { min: min2, max: max2 } = meshBounds(part2.triangles);
1752
+ const lo = [Infinity, Infinity, Infinity];
1753
+ const hi = [-Infinity, -Infinity, -Infinity];
1754
+ for (let c = 0; c < 8; c++) {
1755
+ const p = add(
1756
+ apply(item.rotation, [
1757
+ c & 1 ? max2[0] : min2[0],
1758
+ c & 2 ? max2[1] : min2[1],
1759
+ c & 4 ? max2[2] : min2[2]
1760
+ ]),
1761
+ item.position
1762
+ );
1763
+ for (let k = 0; k < 3; k++) {
1764
+ lo[k] = Math.min(lo[k], p[k]);
1765
+ hi[k] = Math.max(hi[k], p[k]);
1766
+ }
1767
+ }
1768
+ return { item, voxels: partVoxels(part2), lo, hi };
1769
+ });
1770
+ const min = [0, 1, 2].map((k) => Math.min(...placed.map((p) => p.lo[k])) - CELL);
1771
+ const max = [0, 1, 2].map((k) => Math.max(...placed.map((p) => p.hi[k])) + CELL);
1772
+ const dims = [0, 1, 2].map((k) => Math.ceil((max[k] - min[k]) / CELL));
1773
+ const solid = new Uint8Array(dims[0] * dims[1] * dims[2]);
1774
+ const owner = new Int32Array(solid.length).fill(-1);
1775
+ const at = (i, j, k) => (k * dims[1] + j) * dims[0] + i;
1776
+ placed.forEach(({ item, voxels, lo, hi }, index2) => {
1777
+ const r = item.rotation;
1778
+ const i0 = [0, 1, 2].map((k) => Math.max(0, Math.floor((lo[k] - min[k]) / CELL)));
1779
+ const i1 = [0, 1, 2].map((k) => Math.min(dims[k] - 1, Math.ceil((hi[k] - min[k]) / CELL)));
1780
+ for (let k = i0[2]; k <= i1[2]; k++)
1781
+ for (let j = i0[1]; j <= i1[1]; j++)
1782
+ for (let i = i0[0]; i <= i1[0]; i++) {
1783
+ const n = at(i, j, k);
1784
+ if (solid[n]) continue;
1785
+ const d = [
1786
+ min[0] + (i + 0.5) * CELL - item.position[0],
1787
+ min[1] + (j + 0.5) * CELL - item.position[1],
1788
+ min[2] + (k + 0.5) * CELL - item.position[2]
1789
+ ];
1790
+ const local = [
1791
+ r[0] * d[0] + r[3] * d[1] + r[6] * d[2],
1792
+ r[1] * d[0] + r[4] * d[1] + r[7] * d[2],
1793
+ r[2] * d[0] + r[5] * d[1] + r[8] * d[2]
1794
+ ];
1795
+ if (cellAt(voxels, local)) {
1796
+ solid[n] = 1;
1797
+ owner[n] = index2;
1798
+ }
1799
+ }
1800
+ });
1801
+ weld(contacts2, placed, solid, owner, dims, min, at);
1802
+ unpinch(solid, dims);
1803
+ const tris = [];
1804
+ const corner = (i, j, k) => {
1805
+ const x = min[0] + i * CELL;
1806
+ const y = min[1] + j * CELL;
1807
+ const z5 = min[2] + k * CELL;
1808
+ tris.push(x * unit, -z5 * unit, y * unit);
1809
+ };
1810
+ const filled = (i, j, k) => i >= 0 && j >= 0 && k >= 0 && i < dims[0] && j < dims[1] && k < dims[2] && solid[at(i, j, k)] === 1;
1811
+ const faces = [
1812
+ [
1813
+ [1, 0, 0],
1814
+ [
1815
+ [1, 0, 0],
1816
+ [1, 1, 0],
1817
+ [1, 1, 1],
1818
+ [1, 0, 1]
1819
+ ]
1820
+ ],
1821
+ [
1822
+ [-1, 0, 0],
1823
+ [
1824
+ [0, 0, 0],
1825
+ [0, 0, 1],
1826
+ [0, 1, 1],
1827
+ [0, 1, 0]
1828
+ ]
1829
+ ],
1830
+ [
1831
+ [0, 1, 0],
1832
+ [
1833
+ [0, 1, 0],
1834
+ [0, 1, 1],
1835
+ [1, 1, 1],
1836
+ [1, 1, 0]
1837
+ ]
1838
+ ],
1839
+ [
1840
+ [0, -1, 0],
1841
+ [
1842
+ [0, 0, 0],
1843
+ [1, 0, 0],
1844
+ [1, 0, 1],
1845
+ [0, 0, 1]
1846
+ ]
1847
+ ],
1848
+ [
1849
+ [0, 0, 1],
1850
+ [
1851
+ [0, 0, 1],
1852
+ [1, 0, 1],
1853
+ [1, 1, 1],
1854
+ [0, 1, 1]
1855
+ ]
1856
+ ],
1857
+ [
1858
+ [0, 0, -1],
1859
+ [
1860
+ [0, 0, 0],
1861
+ [0, 1, 0],
1862
+ [1, 1, 0],
1863
+ [1, 0, 0]
1864
+ ]
1865
+ ]
1866
+ ];
1867
+ for (let k = 0; k < dims[2]; k++)
1868
+ for (let j = 0; j < dims[1]; j++)
1869
+ for (let i = 0; i < dims[0]; i++) {
1870
+ if (!solid[at(i, j, k)]) continue;
1871
+ for (const [n, q] of faces) {
1872
+ if (filled(i + n[0], j + n[1], k + n[2])) continue;
1873
+ for (const v of [q[0], q[1], q[2], q[0], q[2], q[3]])
1874
+ corner(i + v[0], j + v[1], k + v[2]);
1875
+ }
1876
+ }
1877
+ return encodeStl(tris);
1878
+ }
1879
+ function components(solid, dims) {
1880
+ const label2 = new Int32Array(solid.length);
1881
+ const stack = [];
1882
+ const plane = dims[0] * dims[1];
1883
+ let next = 0;
1884
+ for (let start = 0; start < solid.length; start++) {
1885
+ if (!solid[start] || label2[start]) continue;
1886
+ label2[start] = ++next;
1887
+ stack.push(start);
1888
+ while (stack.length) {
1889
+ const n = stack.pop();
1890
+ const i = n % dims[0];
1891
+ const j = Math.floor(n / dims[0]) % dims[1];
1892
+ const k = Math.floor(n / plane);
1893
+ const near = [
1894
+ i > 0 ? n - 1 : -1,
1895
+ i < dims[0] - 1 ? n + 1 : -1,
1896
+ j > 0 ? n - dims[0] : -1,
1897
+ j < dims[1] - 1 ? n + dims[0] : -1,
1898
+ k > 0 ? n - plane : -1,
1899
+ k < dims[2] - 1 ? n + plane : -1
1900
+ ];
1901
+ for (const m of near)
1902
+ if (m >= 0 && solid[m] && !label2[m]) {
1903
+ label2[m] = next;
1904
+ stack.push(m);
1905
+ }
1906
+ }
1907
+ }
1908
+ return label2;
1909
+ }
1910
+ function weld(contacts2, placed, solid, owner, dims, min, at) {
1911
+ const cellOf = (item) => owner.indexOf(item);
1912
+ let label2 = components(solid, dims);
1913
+ for (const c of contacts2) {
1914
+ const a = cellOf(c.a);
1915
+ const b = cellOf(c.b);
1916
+ if (a < 0 || b < 0) continue;
1917
+ const boxes = [placed[c.a], placed[c.b]];
1918
+ const reach = Math.max(
1919
+ ...boxes.flatMap(
1920
+ (box2) => [0, 1, 2].map((k) => Math.max(c.at[k] - box2.lo[k], box2.hi[k] - c.at[k]))
1921
+ )
1922
+ );
1923
+ for (let r = CELL; label2[a] !== label2[b] && r <= reach + CELL; r += CELL) {
1924
+ const i0 = [0, 1, 2].map((k) => Math.max(0, Math.floor((c.at[k] - r - min[k]) / CELL)));
1925
+ const i1 = [0, 1, 2].map(
1926
+ (k) => Math.min(dims[k] - 1, Math.ceil((c.at[k] + r - min[k]) / CELL))
1927
+ );
1928
+ for (let k = i0[2]; k <= i1[2]; k++)
1929
+ for (let j = i0[1]; j <= i1[1]; j++)
1930
+ for (let i = i0[0]; i <= i1[0]; i++) {
1931
+ const p = [
1932
+ min[0] + (i + 0.5) * CELL,
1933
+ min[1] + (j + 0.5) * CELL,
1934
+ min[2] + (k + 0.5) * CELL
1935
+ ];
1936
+ if (Math.hypot(p[0] - c.at[0], p[1] - c.at[1], p[2] - c.at[2]) > r) continue;
1937
+ if (!boxes.some((box2) => p.every((v, q) => v >= box2.lo[q] && v <= box2.hi[q]))) continue;
1938
+ solid[at(i, j, k)] = 1;
1939
+ }
1940
+ label2 = components(solid, dims);
1941
+ }
1942
+ }
1943
+ }
1944
+ function unpinch(solid, dims) {
1945
+ const step = [1, dims[0], dims[0] * dims[1]];
1946
+ const coord = (n) => [
1947
+ n % dims[0],
1948
+ Math.floor(n / dims[0]) % dims[1],
1949
+ Math.floor(n / step[2])
1950
+ ];
1951
+ for (let changed = true; changed; ) {
1952
+ changed = false;
1953
+ for (let n = 0; n < solid.length; n++) {
1954
+ const c = coord(n);
1955
+ for (const [u, w] of [
1956
+ [0, 1],
1957
+ [1, 2],
1958
+ [0, 2]
1959
+ ]) {
1960
+ if (c[u] + 1 >= dims[u] || c[w] + 1 >= dims[w]) continue;
1961
+ const a = n;
1962
+ const b = n + step[u];
1963
+ const d = n + step[w];
1964
+ const e = n + step[u] + step[w];
1965
+ if (solid[a] && solid[e] && !solid[b] && !solid[d]) {
1966
+ solid[b] = 1;
1967
+ changed = true;
1968
+ } else if (solid[b] && solid[d] && !solid[a] && !solid[e]) {
1969
+ solid[a] = 1;
1970
+ changed = true;
1971
+ }
1972
+ }
1973
+ }
1974
+ }
1975
+ }
1416
1976
  var COLLISION_CELLS = 4;
1977
+ var SOCKET_REACH = 0.5;
1417
1978
  function validate(model, lib, adapter, opts = {}) {
1418
1979
  const issues = [];
1419
1980
  const n = model.items.length;
1420
1981
  const joints = contacts(model, lib, adapter);
1421
1982
  const bodies = model.items.map((it) => worldBody(lib, it));
1422
1983
  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" });
1984
+ const balls = new Map(
1985
+ joints.filter((c) => c.kind.split("-").every((k) => isSpherical(adapter, k))).map((c) => [`${c.a},${c.b}`, c.at])
1986
+ );
1987
+ for (const [a, b] of nearPairs(bodies, 0.8)) {
1988
+ if (through.has(`${a},${b}`) || overlapVolume(bodies[a], bodies[b], -0.4) <= 0) continue;
1989
+ const centre = balls.get(`${a},${b}`);
1990
+ const ignore = centre && { centre, radius: SOCKET_REACH };
1991
+ if (collisionCells(lib, model.items[a], model.items[b], ignore) > COLLISION_CELLS)
1992
+ issues.push({ rule: "overlap", items: [a, b], message: "two parts occupy the same space" });
1993
+ }
1427
1994
  const parent = model.items.map((_, i) => i);
1428
1995
  const find = (x) => {
1429
1996
  while (parent[x] !== x) x = parent[x];
@@ -1482,6 +2049,16 @@ async function readBrickModel(file, library) {
1482
2049
  const config = read(join(library, "LDConfig.ldr"));
1483
2050
  return { model, lib, names, palette: config ? parsePalette(config) : {} };
1484
2051
  }
2052
+ function brickPart(id, library) {
2053
+ const read = (path) => existsSync(path) ? readFileSync(path, "utf8") : void 0;
2054
+ const resolver = libraryResolver(read, library);
2055
+ const text = resolver(`${id}.dat`);
2056
+ if (text === void 0) throw new InputError(`Part ${id} is not in the LDraw library.`);
2057
+ const name = text.split(/\r?\n/, 1)[0].replace(/^0\s*/, "").trim();
2058
+ const moved2 = name.match(/^~Moved to (\S+)/);
2059
+ if (moved2) throw new InputError(`Part ${id} moved to ${moved2[1]}; use that id.`);
2060
+ return describePart(loadLegoPart(id, resolver, { name }));
2061
+ }
1485
2062
  function brickCheck({ model, lib, names }) {
1486
2063
  const result = validate(model, lib, lego);
1487
2064
  const label2 = (i) => `${i}:${model.items[i].part} ${names[model.items[i].part]}`;
@@ -1492,6 +2069,10 @@ function brickCheck({ model, lib, names }) {
1492
2069
  issues: result.issues.map((issue) => ({ ...issue, parts: issue.items.map(label2) }))
1493
2070
  };
1494
2071
  }
2072
+ function brickStress({ model, lib, names }) {
2073
+ const label2 = (i) => `${i}:${model.items[i].part} ${names[model.items[i].part]}`;
2074
+ return stress(model, lib, lego).map((j) => ({ ...j, between: [label2(j.a), label2(j.b)] }));
2075
+ }
1495
2076
  function brickPlan({ model, lib, names }) {
1496
2077
  const plan = planAssembly(model, lib, lego);
1497
2078
  const axis = (d) => ["+x", "-x", "+y", "-y", "+z", "-z"][[
@@ -1546,6 +2127,29 @@ function brickRemix(exemplar, seed, mode) {
1546
2127
  check: brickCheck(grown)
1547
2128
  };
1548
2129
  }
2130
+ var STUD_MM = 8;
2131
+ function brickStl({ model, lib }, scale2 = 1) {
2132
+ if (!Number.isFinite(scale2) || scale2 <= 0)
2133
+ throw new InputError("--scale must be a positive number.");
2134
+ const stl = toSolidStl(model, lib, STUD_MM * scale2, validate(model, lib, lego).contacts);
2135
+ const view = new DataView(stl.buffer);
2136
+ const count = view.getUint32(80, true);
2137
+ const min = [Infinity, Infinity, Infinity];
2138
+ const max = [-Infinity, -Infinity, -Infinity];
2139
+ for (let f = 0; f < count; f++)
2140
+ for (let k = 0; k < 9; k++) {
2141
+ const v = view.getFloat32(84 + f * 50 + 12 + k * 4, true);
2142
+ min[k % 3] = Math.min(min[k % 3], v);
2143
+ max[k % 3] = Math.max(max[k % 3], v);
2144
+ }
2145
+ const mm = (k) => +(max[k] - min[k]).toFixed(2);
2146
+ return {
2147
+ stl,
2148
+ parts: model.items.length,
2149
+ triangles: count,
2150
+ sizeMm: { x: mm(0), y: mm(1), z: mm(2) }
2151
+ };
2152
+ }
1549
2153
 
1550
2154
  // src/card-import.ts
1551
2155
  import { createHash } from "crypto";
@@ -3812,9 +4416,14 @@ var commands = [
3812
4416
  "sheet pack --file <sheet.json>",
3813
4417
  "sheet check --file <sheet.json>",
3814
4418
  "sheet export --file <sheet.json> --out <sheet.pdf> [--preview <proof.png>] [--overwrite]",
4419
+ "brick part --id <ldraw-id> [--library <ldraw-dir>]",
3815
4420
  "brick check --file <model.ldr> [--library <ldraw-dir>]",
3816
4421
  "brick plan --file <model.ldr> [--library <ldraw-dir>]",
4422
+ "brick stress --file <model.ldr> [--library <ldraw-dir>]",
3817
4423
  "brick remix --file <exemplar.ldr> --seed <n> --out <new.ldr> [--mode strict|family|free] [--library <ldraw-dir>]",
4424
+ "brick stl --file <model.ldr> --out <file.stl> [--scale <factor>] [--library <ldraw-dir>] [--overwrite]",
4425
+ "print list",
4426
+ "print <object> [--param <name=value>]... [--brand <slug|brand.json>] --out <file.stl|file.3mf> [--colors <body,label>] [--preview <views.png>] [--overwrite]",
3818
4427
  "grid bin --width <cells> --depth <cells> --height <units> --out <file.stl> [--overwrite]",
3819
4428
  "grid plate --width <cells> --depth <cells> --out <file.stl> [--overwrite]",
3820
4429
  "brick render --file <model.ldr> --out <png> [--step <n>] [--explode <factor>] [--yaw=<deg>] [--pitch=<deg>] [--library <ldraw-dir>] [--overwrite]",
@@ -3893,7 +4502,10 @@ async function newPath(file) {
3893
4502
  return path;
3894
4503
  }
3895
4504
  async function run() {
3896
- const { values, positionals } = parseArgs({
4505
+ const {
4506
+ values: { param: printParams, ...values },
4507
+ positionals
4508
+ } = parseArgs({
3897
4509
  allowPositionals: true,
3898
4510
  strict: true,
3899
4511
  options: {
@@ -3917,6 +4529,7 @@ async function run() {
3917
4529
  port: { type: "string" },
3918
4530
  url: { type: "string" },
3919
4531
  params: { type: "string" },
4532
+ param: { type: "string", multiple: true },
3920
4533
  state: { type: "string" },
3921
4534
  target: { type: "string" },
3922
4535
  yes: { type: "boolean" },
@@ -3954,6 +4567,7 @@ async function run() {
3954
4567
  yaw: { type: "string" },
3955
4568
  pitch: { type: "string" },
3956
4569
  seed: { type: "string" },
4570
+ scale: { type: "string" },
3957
4571
  width: { type: "string" },
3958
4572
  depth: { type: "string" },
3959
4573
  height: { type: "string" },
@@ -3963,6 +4577,7 @@ async function run() {
3963
4577
  slug: { type: "string" },
3964
4578
  family: { type: "string" },
3965
4579
  brand: { type: "string" },
4580
+ colors: { type: "string" },
3966
4581
  probe: { type: "boolean" },
3967
4582
  blank: { type: "boolean" },
3968
4583
  kind: { type: "string" },
@@ -4001,7 +4616,8 @@ From a built checkout: npm run studio -- <command>`,
4001
4616
  );
4002
4617
  return;
4003
4618
  }
4004
- const command = ["skills get", "studio call"].includes(positionals.slice(0, 2).join(" ")) ? positionals.slice(0, 2).join(" ") : positionals.join(" ");
4619
+ const printTarget = positionals[0] === "print" && positionals.length === 2 && positionals[1] !== "list" ? positionals[1] : void 0;
4620
+ const command = printTarget ? "print <object>" : ["skills get", "studio call"].includes(positionals.slice(0, 2).join(" ")) ? positionals.slice(0, 2).join(" ") : positionals.join(" ");
4005
4621
  const allowed = {
4006
4622
  "kit new": ["name", "out"],
4007
4623
  "kit extract": ["file", "out", "params"],
@@ -4118,10 +4734,15 @@ From a built checkout: npm run studio -- <command>`,
4118
4734
  "sheet pack": ["file"],
4119
4735
  "sheet check": ["file"],
4120
4736
  "sheet export": ["file", "out", "preview", "overwrite"],
4737
+ "brick part": ["id", "library"],
4121
4738
  "brick check": ["file", "library"],
4122
4739
  "brick plan": ["file", "library"],
4740
+ "brick stress": ["file", "library"],
4123
4741
  "brick render": ["file", "out", "step", "explode", "yaw", "pitch", "library", "overwrite"],
4124
4742
  "brick remix": ["file", "seed", "out", "mode", "library"],
4743
+ "brick stl": ["file", "out", "scale", "library", "overwrite"],
4744
+ "print list": [],
4745
+ "print <object>": ["out", "brand", "colors", "preview", "overwrite"],
4125
4746
  "grid bin": ["width", "depth", "height", "out", "overwrite"],
4126
4747
  "grid plate": ["width", "depth", "out", "overwrite"],
4127
4748
  render: ["file", "out", "overwrite", "side", "photo", "name", "role", "number", "appearance"],
@@ -4132,6 +4753,8 @@ From a built checkout: npm run studio -- <command>`,
4132
4753
  for (const flag of Object.keys(values))
4133
4754
  if (!["json", "help", "version", "compact", "trace", ...allowed[command]].includes(flag))
4134
4755
  throw new InputError(`--${flag} is not supported by ${command}`);
4756
+ if (printParams && command !== "print <object>")
4757
+ throw new InputError(`--param is not supported by ${command}`);
4135
4758
  const progress = (data) => process.stderr.write(`${JSON.stringify(data)}
4136
4759
  `);
4137
4760
  if (values.product && !["card", "badge", "sticker"].includes(values.product))
@@ -4144,7 +4767,7 @@ From a built checkout: npm run studio -- <command>`,
4144
4767
  const out = await newPath(need(values.out, "out"));
4145
4768
  if (!out.endsWith(".makeables.json"))
4146
4769
  throw new InputError("--out must end in .makeables.json.");
4147
- const { badgePackage } = await import("./badge-package-CMFI3BIR.js");
4770
+ const { badgePackage } = await import("./badge-package-RBQ43DQX.js");
4148
4771
  const { json: body, participant } = await badgePackage(
4149
4772
  need(values.file, "file"),
4150
4773
  need(values.photo, "photo"),
@@ -4242,7 +4865,7 @@ From a built checkout: npm run studio -- <command>`,
4242
4865
  } else if (command === "brand export") {
4243
4866
  const out = resolve8(need(values.out, "out"));
4244
4867
  if (!out.endsWith(".zip")) throw new InputError("--out must end in .zip.");
4245
- const { packageZip } = await import("./submission-ATVS5YTK.js");
4868
+ const { packageZip } = await import("./submission-H3RXUH7T.js");
4246
4869
  const pkg = await prepareBrand(need(values.file, "file"));
4247
4870
  const bytes = await packageZip(pkg);
4248
4871
  const handle2 = await open4(
@@ -4261,7 +4884,7 @@ From a built checkout: npm run studio -- <command>`,
4261
4884
  bytes: bytes.length
4262
4885
  };
4263
4886
  } else {
4264
- const { serveBrandPreview } = await import("./brand-preview-W4FTJJM3.js");
4887
+ const { serveBrandPreview } = await import("./brand-preview-C6HBXUXH.js");
4265
4888
  result2 = await serveBrandPreview(need(values.file, "file"), {
4266
4889
  site: values.site,
4267
4890
  open: !values["no-open"]
@@ -4377,6 +5000,74 @@ From a built checkout: npm run studio -- <command>`,
4377
5000
  emit(result2, ["makeables skills get cards"], JSON.stringify(result2, null, 2), json);
4378
5001
  return;
4379
5002
  }
5003
+ if (command === "print list") {
5004
+ const { printList } = await import("./print-NGPUZPVN.js");
5005
+ const objects = printList();
5006
+ emit(
5007
+ { objects },
5008
+ ["Build one with makeables print <object> --param name=value --out <file.stl>."],
5009
+ objects.map(
5010
+ (o) => `${style(o.id, "1", !json)} ${o.description}
5011
+ ${[
5012
+ ...o.params.map(
5013
+ (p) => ` --param ${p.name}=${p.options ? p.options.join("|") : p.type === "number" ? `${p.min}..${p.max}` : "<text>"} (default ${JSON.stringify(p.default)})`
5014
+ ),
5015
+ ...Object.entries(o.assets).map(
5016
+ ([slot, a]) => ` --param ${slot}=<file.${a.kind}> ${a.description}`
5017
+ )
5018
+ ].join("\n")}`
5019
+ ).join("\n"),
5020
+ json
5021
+ );
5022
+ return;
5023
+ }
5024
+ if (command === "print <object>" && printTarget) {
5025
+ if (!values.out) throw new InputError("Supply --out.");
5026
+ const out = resolve8(values.out);
5027
+ const preview = values.preview ? resolve8(values.preview) : void 0;
5028
+ const { printObject, printPreview } = await import("./print-NGPUZPVN.js");
5029
+ const multicolor = out.toLowerCase().endsWith(".3mf");
5030
+ if (!multicolor && !out.toLowerCase().endsWith(".stl"))
5031
+ throw new InputError("--out must end in .stl, or .3mf for two colours.");
5032
+ if (values.colors && !multicolor)
5033
+ throw new InputError("--colors needs a .3mf --out; an STL has one colour.");
5034
+ const result2 = await printObject(printTarget, printParams ?? [], values.brand, {
5035
+ multicolor,
5036
+ colors: values.colors
5037
+ });
5038
+ const files = [[out, result2.threeMf ?? result2.stl]];
5039
+ if (preview) files.push([preview, await printPreview(result2)]);
5040
+ for (const [path2, bytes] of files) {
5041
+ const handle2 = await open4(path2, values.overwrite ? "w" : "wx", 420);
5042
+ try {
5043
+ await handle2.writeFile(bytes);
5044
+ } finally {
5045
+ await handle2.close();
5046
+ }
5047
+ }
5048
+ const { x, y, z: z5 } = result2.sizeMm;
5049
+ emit(
5050
+ {
5051
+ out,
5052
+ object: result2.object,
5053
+ brand: result2.brand,
5054
+ params: result2.params,
5055
+ triangles: result2.triangles,
5056
+ sizeMm: result2.sizeMm,
5057
+ check: result2.check,
5058
+ printability: result2.printability,
5059
+ ...result2.colors && { colors: result2.colors },
5060
+ ...preview && { preview }
5061
+ },
5062
+ [
5063
+ preview ? `Look at ${preview} (top, three-quarter, front) before printing.` : `Add --preview <views.png> to look at it before printing.`,
5064
+ `Open ${out} in your slicer; it is one closed solid with no edges to repair.`
5065
+ ],
5066
+ `${style("Written", "1", !json)} ${result2.name}, ${x} x ${y} x ${z5} mm, ${out}`,
5067
+ json
5068
+ );
5069
+ return;
5070
+ }
4380
5071
  const card = await cardCommand(command, values, json);
4381
5072
  if (card) return;
4382
5073
  if (command === "login" || command === "logout") {
@@ -4415,7 +5106,7 @@ From a built checkout: npm run studio -- <command>`,
4415
5106
  (text) => text.includes('"makeables-brand"'),
4416
5107
  () => false
4417
5108
  );
4418
- const submit = isBrand ? (await import("./brand-NMENEWIZ.js")).submitBrand : submitDesign;
5109
+ const submit = isBrand ? (await import("./brand-UNVFUN4H.js")).submitBrand : submitDesign;
4419
5110
  const result2 = await submit({
4420
5111
  title: values.title,
4421
5112
  credit: values.credit,
@@ -4522,7 +5213,7 @@ Run makeables skills get core for setup.`,
4522
5213
  }
4523
5214
  if (command === "mcp") {
4524
5215
  try {
4525
- const { runMcp } = await import("./mcp-YBRUVIWG.js");
5216
+ const { runMcp } = await import("./mcp-2VMGYAUZ.js");
4526
5217
  await runMcp(values.root ?? process.cwd());
4527
5218
  } catch (error) {
4528
5219
  process.stderr.write(
@@ -4965,6 +5656,23 @@ ${path ?? `No file written. Add --out ${kind}.json.`}`,
4965
5656
  );
4966
5657
  return true;
4967
5658
  }
5659
+ if (command === "brick part") {
5660
+ const id = need(values.id, "id");
5661
+ const sheet = brickPart(id, ldrawLibrary(values.library));
5662
+ emit(
5663
+ sheet,
5664
+ [`Place ${id} with Assembly.seat, which counts sockets as listed here.`],
5665
+ [
5666
+ `${style(sheet.id, "1", !json)} ${sheet.name}`,
5667
+ ` size ${sheet.size.join(" x ")} studs, origin at ${sheet.origin}`,
5668
+ ...Object.entries(sheet.connectors).map(
5669
+ ([kind, list]) => ` ${kind}: ${list.map((c) => `#${c.index} (${c.p.join(", ")})`).join(" ")}`
5670
+ )
5671
+ ].join("\n"),
5672
+ json
5673
+ );
5674
+ return true;
5675
+ }
4968
5676
  if (command.startsWith("brick ")) {
4969
5677
  const path = need(file, "file");
4970
5678
  const model = await readBrickModel(path, ldrawLibrary(values.library));
@@ -4982,6 +5690,20 @@ ${path ?? `No file written. Add --out ${kind}.json.`}`,
4982
5690
  if (!report.ok) process.exitCode = 2;
4983
5691
  return true;
4984
5692
  }
5693
+ if (command === "brick stress") {
5694
+ const joints = brickStress(model);
5695
+ emit(
5696
+ { joints },
5697
+ joints.length ? [
5698
+ "Brace the joints at the top: add a second connection, shorten the lever, or lighten the load."
5699
+ ] : ["Every joint sits in a loop; nothing hangs on a single joint."],
5700
+ joints.length ? joints.map(
5701
+ (j) => ` ${j.moment.toFixed(2).padStart(8)} ${j.kind.padEnd(16)} x${j.count} holds ${j.holds.length} parts, lever ${j.lever} studs ${j.between.join(" -> ")}`
5702
+ ).join("\n") : "No joint holds part of the build alone.",
5703
+ json
5704
+ );
5705
+ return true;
5706
+ }
4985
5707
  if (command === "brick plan") {
4986
5708
  const plan = brickPlan(model);
4987
5709
  emit(
@@ -5004,6 +5726,25 @@ ${path ?? `No file written. Add --out ${kind}.json.`}`,
5004
5726
  if (!Number.isFinite(n)) throw new InputError(`--${name} must be a number.`);
5005
5727
  return n;
5006
5728
  };
5729
+ if (command === "brick stl") {
5730
+ const out2 = resolve8(need(values.out, "out"));
5731
+ const scale2 = number("scale");
5732
+ const result = brickStl(model, scale2);
5733
+ const handle2 = await open4(out2, values.overwrite ? "w" : "wx", 420);
5734
+ try {
5735
+ await handle2.writeFile(result.stl);
5736
+ } finally {
5737
+ await handle2.close();
5738
+ }
5739
+ const { x, y, z: z5 } = result.sizeMm;
5740
+ emit(
5741
+ { out: out2, parts: result.parts, triangles: result.triangles, sizeMm: result.sizeMm },
5742
+ [`Open ${out2} in your slicer; the parts are already fused into one closed solid.`],
5743
+ `${style("Written", "1", !json)} ${result.parts} parts, ${x} x ${y} x ${z5} mm, ${out2}`,
5744
+ json
5745
+ );
5746
+ return true;
5747
+ }
5007
5748
  if (command === "brick remix") {
5008
5749
  const seed = number("seed");
5009
5750
  if (seed === void 0 || !Number.isInteger(seed))