@faicad/sheetmetal 0.13.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.
Files changed (113) hide show
  1. package/dist/allowanceFns.d.ts +42 -0
  2. package/dist/allowanceFns.d.ts.map +1 -0
  3. package/dist/allowanceFns.js +63 -0
  4. package/dist/allowanceFns.js.map +1 -0
  5. package/dist/api.d.ts +312 -0
  6. package/dist/api.d.ts.map +1 -0
  7. package/dist/api.js +367 -0
  8. package/dist/api.js.map +1 -0
  9. package/dist/authorFns.d.ts +118 -0
  10. package/dist/authorFns.d.ts.map +1 -0
  11. package/dist/authorFns.js +562 -0
  12. package/dist/authorFns.js.map +1 -0
  13. package/dist/bendTableFns.d.ts +87 -0
  14. package/dist/bendTableFns.d.ts.map +1 -0
  15. package/dist/bendTableFns.js +345 -0
  16. package/dist/bendTableFns.js.map +1 -0
  17. package/dist/compat.d.ts +90 -0
  18. package/dist/compat.d.ts.map +1 -0
  19. package/dist/compat.js +99 -0
  20. package/dist/compat.js.map +1 -0
  21. package/dist/contourFlangeFns.d.ts +122 -0
  22. package/dist/contourFlangeFns.d.ts.map +1 -0
  23. package/dist/contourFlangeFns.js +403 -0
  24. package/dist/contourFlangeFns.js.map +1 -0
  25. package/dist/cutoutFns.d.ts +95 -0
  26. package/dist/cutoutFns.d.ts.map +1 -0
  27. package/dist/cutoutFns.js +300 -0
  28. package/dist/cutoutFns.js.map +1 -0
  29. package/dist/dxfFns.d.ts +48 -0
  30. package/dist/dxfFns.d.ts.map +1 -0
  31. package/dist/dxfFns.js +236 -0
  32. package/dist/dxfFns.js.map +1 -0
  33. package/dist/facade.d.ts +449 -0
  34. package/dist/facade.d.ts.map +1 -0
  35. package/dist/facade.js +535 -0
  36. package/dist/facade.js.map +1 -0
  37. package/dist/featureTreeFns.d.ts +76 -0
  38. package/dist/featureTreeFns.d.ts.map +1 -0
  39. package/dist/featureTreeFns.js +190 -0
  40. package/dist/featureTreeFns.js.map +1 -0
  41. package/dist/foldFns.d.ts +83 -0
  42. package/dist/foldFns.d.ts.map +1 -0
  43. package/dist/foldFns.js +744 -0
  44. package/dist/foldFns.js.map +1 -0
  45. package/dist/foreignUnfoldFns.d.ts +62 -0
  46. package/dist/foreignUnfoldFns.d.ts.map +1 -0
  47. package/dist/foreignUnfoldFns.js +854 -0
  48. package/dist/foreignUnfoldFns.js.map +1 -0
  49. package/dist/formFns.d.ts +58 -0
  50. package/dist/formFns.d.ts.map +1 -0
  51. package/dist/formFns.js +300 -0
  52. package/dist/formFns.js.map +1 -0
  53. package/dist/hemFns.d.ts +17 -0
  54. package/dist/hemFns.d.ts.map +1 -0
  55. package/dist/hemFns.js +212 -0
  56. package/dist/hemFns.js.map +1 -0
  57. package/dist/index.d.ts +43 -0
  58. package/dist/index.d.ts.map +1 -0
  59. package/dist/index.js +32 -0
  60. package/dist/index.js.map +1 -0
  61. package/dist/internal.d.ts +27 -0
  62. package/dist/internal.d.ts.map +1 -0
  63. package/dist/internal.js +38 -0
  64. package/dist/internal.js.map +1 -0
  65. package/dist/jogFns.d.ts +18 -0
  66. package/dist/jogFns.d.ts.map +1 -0
  67. package/dist/jogFns.js +180 -0
  68. package/dist/jogFns.js.map +1 -0
  69. package/dist/loftedFlangeFns.d.ts +23 -0
  70. package/dist/loftedFlangeFns.d.ts.map +1 -0
  71. package/dist/loftedFlangeFns.js +306 -0
  72. package/dist/loftedFlangeFns.js.map +1 -0
  73. package/dist/materials.d.ts +16 -0
  74. package/dist/materials.d.ts.map +1 -0
  75. package/dist/materials.js +44 -0
  76. package/dist/materials.js.map +1 -0
  77. package/dist/miterFns.d.ts +31 -0
  78. package/dist/miterFns.d.ts.map +1 -0
  79. package/dist/miterFns.js +128 -0
  80. package/dist/miterFns.js.map +1 -0
  81. package/dist/nestFns.d.ts +116 -0
  82. package/dist/nestFns.d.ts.map +1 -0
  83. package/dist/nestFns.js +517 -0
  84. package/dist/nestFns.js.map +1 -0
  85. package/dist/polygonFns.d.ts +144 -0
  86. package/dist/polygonFns.d.ts.map +1 -0
  87. package/dist/polygonFns.js +324 -0
  88. package/dist/polygonFns.js.map +1 -0
  89. package/dist/reliefFns.d.ts +44 -0
  90. package/dist/reliefFns.d.ts.map +1 -0
  91. package/dist/reliefFns.js +367 -0
  92. package/dist/reliefFns.js.map +1 -0
  93. package/dist/reportFns.d.ts +33 -0
  94. package/dist/reportFns.d.ts.map +1 -0
  95. package/dist/reportFns.js +71 -0
  96. package/dist/reportFns.js.map +1 -0
  97. package/dist/tabFns.d.ts +43 -0
  98. package/dist/tabFns.d.ts.map +1 -0
  99. package/dist/tabFns.js +179 -0
  100. package/dist/tabFns.js.map +1 -0
  101. package/dist/types.d.ts +629 -0
  102. package/dist/types.d.ts.map +1 -0
  103. package/dist/types.js +2 -0
  104. package/dist/types.js.map +1 -0
  105. package/dist/unfoldFns.d.ts +120 -0
  106. package/dist/unfoldFns.d.ts.map +1 -0
  107. package/dist/unfoldFns.js +766 -0
  108. package/dist/unfoldFns.js.map +1 -0
  109. package/dist/validateFns.d.ts +18 -0
  110. package/dist/validateFns.d.ts.map +1 -0
  111. package/dist/validateFns.js +196 -0
  112. package/dist/validateFns.js.map +1 -0
  113. package/package.json +45 -0
@@ -0,0 +1,854 @@
1
+ import { ok, err, validationError, getFaces, getSurfaceType, pointOnSurface, normalAt, faceCenter, sharedEdges, measureArea, getBounds, isValid, vecAdd, vecSub, vecScale, vecDot, vecCross, vecLength, vecNormalize, line, wireLoop, } from '@faicad/faijs/brepjs-compat';
2
+ import { developedLength } from './allowanceFns.js';
3
+ /** Default neutral-axis K-factor for a purely geometric unfold (mid-surface). */
4
+ const DEFAULT_FOREIGN_K = 0.5;
5
+ /** Relative tolerance for the cylinder-fit residual (fraction of the radius). */
6
+ const FIT_RESIDUAL_TOL = 1e-2;
7
+ /** Absolute tolerance for matching parallel planes / coincident geometry. */
8
+ const GEOM_TOL = 1e-4;
9
+ /**
10
+ * Sample a regular grid of surface points and their normals across the face's UV
11
+ * bounds. `pointOnSurface` takes normalized (0-1) UV; `normalAt` re-projects the
12
+ * sampled point to UV internally, so the pair holds for true cylinders/planes but
13
+ * is not an exact-UV guarantee for degenerate parameterizations.
14
+ */
15
+ function sampleFace(face, n) {
16
+ const samples = [];
17
+ for (let i = 0; i < n; i += 1) {
18
+ const u = n === 1 ? 0.5 : i / (n - 1);
19
+ for (let j = 0; j < n; j += 1) {
20
+ const v = n === 1 ? 0.5 : j / (n - 1);
21
+ const point = pointOnSurface(face, u, v);
22
+ const normal = normalAt(face, point);
23
+ const len = vecLength(normal);
24
+ if (len < GEOM_TOL)
25
+ continue;
26
+ samples.push({ point, normal: vecScale(normal, 1 / len) });
27
+ }
28
+ }
29
+ return samples;
30
+ }
31
+ /**
32
+ * Numerically fit a cylinder to a face.
33
+ *
34
+ * Every normal of a cylinder is perpendicular to the axis, so the axis direction
35
+ * is the common perpendicular of the sampled normals: it is recovered as the
36
+ * sign-aligned average of the cross products of pairs of non-parallel sampled
37
+ * normals (each such cross product lies along ±axis). The axis LINE and RADIUS
38
+ * are then found by projecting the sample points onto the plane ⟂ axis and
39
+ * fitting a circle (algebraic least squares); the radius is the mean distance
40
+ * from the axis line to the points. The angular span is the extent of the
41
+ * projected points about the fitted centre. Returns `null` when the surface
42
+ * does not fit a cylinder within {@link FIT_RESIDUAL_TOL}.
43
+ *
44
+ * @param face - the B-rep face to fit.
45
+ * @returns the fitted cylinder, or null if the face is not cylindrical.
46
+ */
47
+ export function fitCylinder(face) {
48
+ const surf = getSurfaceType(face);
49
+ if (!surf.ok || surf.value !== 'CYLINDRE')
50
+ return null;
51
+ const samples = sampleFace(face, 6);
52
+ if (samples.length < 4)
53
+ return null;
54
+ const axisDir = axisFromNormals(samples.map((s) => s.normal));
55
+ if (axisDir === null)
56
+ return null;
57
+ // Project points onto the plane ⟂ axis (in a local 2D basis) and fit a circle.
58
+ const basis = perpBasis(axisDir);
59
+ const pts2 = samples.map((s) => [
60
+ vecDot(s.point, basis.e1),
61
+ vecDot(s.point, basis.e2),
62
+ ]);
63
+ const circle = fitCircle2(pts2);
64
+ if (circle === null)
65
+ return null;
66
+ // Lift the 2D centre back to a 3D point on the axis: use the axial component of
67
+ // an arbitrary sample so the origin sits at a real location on the face.
68
+ const axial = vecDot(samples[0]?.point ?? [0, 0, 0], axisDir);
69
+ const axisOrigin = vecAdd(vecAdd(vecScale(basis.e1, circle.cx), vecScale(basis.e2, circle.cy)), vecScale(axisDir, axial));
70
+ // Radius = mean distance from the axis line to the sampled points; residual is
71
+ // the max relative deviation from that radius (a true cylinder is near zero).
72
+ let sumR = 0;
73
+ for (const s of samples)
74
+ sumR += distanceToAxis(s.point, axisOrigin, axisDir);
75
+ const radius = sumR / samples.length;
76
+ if (radius < GEOM_TOL)
77
+ return null;
78
+ let maxDev = 0;
79
+ for (const s of samples) {
80
+ const d = distanceToAxis(s.point, axisOrigin, axisDir);
81
+ maxDev = Math.max(maxDev, Math.abs(d - radius));
82
+ }
83
+ const residual = maxDev / radius;
84
+ if (residual > FIT_RESIDUAL_TOL)
85
+ return null;
86
+ const angleSpan = angularExtent(pts2, circle.cx, circle.cy);
87
+ return { axisOrigin, axisDir, radius, angleSpan, residual };
88
+ }
89
+ /**
90
+ * Axis direction as the common perpendicular of the cylinder normals: average the
91
+ * cross products of pairs of sufficiently non-parallel normals, aligning each to a
92
+ * consistent hemisphere before summing so they don't cancel. Returns `null` if no
93
+ * pair is non-parallel (a degenerate/near-planar sample).
94
+ */
95
+ function axisFromNormals(normals) {
96
+ let acc = [0, 0, 0];
97
+ let ref = null;
98
+ for (let i = 0; i < normals.length; i += 1) {
99
+ for (let j = i + 1; j < normals.length; j += 1) {
100
+ const a = normals[i];
101
+ const b = normals[j];
102
+ if (a === undefined || b === undefined)
103
+ continue;
104
+ const c = vecCross(a, b);
105
+ const len = vecLength(c);
106
+ if (len < 1e-3)
107
+ continue;
108
+ let unit = vecScale(c, 1 / len);
109
+ if (ref === null)
110
+ ref = unit;
111
+ else if (vecDot(unit, ref) < 0)
112
+ unit = vecScale(unit, -1);
113
+ acc = vecAdd(acc, unit);
114
+ }
115
+ }
116
+ if (vecLength(acc) < GEOM_TOL)
117
+ return null;
118
+ return vecNormalize(acc);
119
+ }
120
+ /** An orthonormal pair spanning the plane perpendicular to `axis`. */
121
+ function perpBasis(axis) {
122
+ const seed = Math.abs(axis[0]) < 0.9 ? [1, 0, 0] : [0, 1, 0];
123
+ const e1 = vecNormalize(vecCross(axis, seed));
124
+ const e2 = vecNormalize(vecCross(axis, e1));
125
+ return { e1, e2 };
126
+ }
127
+ /** Perpendicular distance from `p` to the line through `o` with unit direction `d`. */
128
+ function distanceToAxis(p, o, d) {
129
+ const w = vecSub(p, o);
130
+ const along = vecDot(w, d);
131
+ const perp = vecSub(w, vecScale(d, along));
132
+ return vecLength(perp);
133
+ }
134
+ /** Algebraic (Kåsa) least-squares circle fit to 2D points. */
135
+ function fitCircle2(pts) {
136
+ const n = pts.length;
137
+ if (n < 3)
138
+ return null;
139
+ let sx = 0;
140
+ let sy = 0;
141
+ let sxx = 0;
142
+ let syy = 0;
143
+ let sxy = 0;
144
+ let sxz = 0;
145
+ let syz = 0;
146
+ let sz = 0;
147
+ for (const [x, y] of pts) {
148
+ const z = x * x + y * y;
149
+ sx += x;
150
+ sy += y;
151
+ sxx += x * x;
152
+ syy += y * y;
153
+ sxy += x * y;
154
+ sxz += x * z;
155
+ syz += y * z;
156
+ sz += z;
157
+ }
158
+ // Solve the normal equations for [A, B, C] in A·x + B·y + C = z (= x²+y²).
159
+ const m = [
160
+ [sxx, sxy, sx],
161
+ [sxy, syy, sy],
162
+ [sx, sy, n],
163
+ ];
164
+ const rhs = [sxz, syz, sz];
165
+ const sol = solve3(m, rhs);
166
+ if (sol === null)
167
+ return null;
168
+ const [a, b, c] = sol;
169
+ const cx = a / 2;
170
+ const cy = b / 2;
171
+ const r2 = c + cx * cx + cy * cy;
172
+ if (r2 <= 0)
173
+ return null;
174
+ return { cx, cy, r: Math.sqrt(r2) };
175
+ }
176
+ /** Solve a 3×3 linear system by Cramer's rule; `null` if (near-)singular. */
177
+ function solve3(m, rhs) {
178
+ const det = det3(m);
179
+ if (Math.abs(det) < 1e-12)
180
+ return null;
181
+ const mx = [
182
+ [rhs[0] ?? 0, m[0]?.[1] ?? 0, m[0]?.[2] ?? 0],
183
+ [rhs[1] ?? 0, m[1]?.[1] ?? 0, m[1]?.[2] ?? 0],
184
+ [rhs[2] ?? 0, m[2]?.[1] ?? 0, m[2]?.[2] ?? 0],
185
+ ];
186
+ const my = [
187
+ [m[0]?.[0] ?? 0, rhs[0] ?? 0, m[0]?.[2] ?? 0],
188
+ [m[1]?.[0] ?? 0, rhs[1] ?? 0, m[1]?.[2] ?? 0],
189
+ [m[2]?.[0] ?? 0, rhs[2] ?? 0, m[2]?.[2] ?? 0],
190
+ ];
191
+ const mz = [
192
+ [m[0]?.[0] ?? 0, m[0]?.[1] ?? 0, rhs[0] ?? 0],
193
+ [m[1]?.[0] ?? 0, m[1]?.[1] ?? 0, rhs[1] ?? 0],
194
+ [m[2]?.[0] ?? 0, m[2]?.[1] ?? 0, rhs[2] ?? 0],
195
+ ];
196
+ return [det3(mx) / det, det3(my) / det, det3(mz) / det];
197
+ }
198
+ function det3(m) {
199
+ const a = m[0] ?? [];
200
+ const b = m[1] ?? [];
201
+ const c = m[2] ?? [];
202
+ return ((a[0] ?? 0) * ((b[1] ?? 0) * (c[2] ?? 0) - (b[2] ?? 0) * (c[1] ?? 0)) -
203
+ (a[1] ?? 0) * ((b[0] ?? 0) * (c[2] ?? 0) - (b[2] ?? 0) * (c[0] ?? 0)) +
204
+ (a[2] ?? 0) * ((b[0] ?? 0) * (c[1] ?? 0) - (b[1] ?? 0) * (c[0] ?? 0)));
205
+ }
206
+ /** Angular extent (radians) spanned by 2D points about a centre. */
207
+ function angularExtent(pts, cx, cy) {
208
+ const angles = pts.map(([x, y]) => Math.atan2(y - cy, x - cx)).sort((a, b) => a - b);
209
+ if (angles.length < 2)
210
+ return 0;
211
+ // Largest gap between consecutive sorted angles (wrapping); the span is 2π minus it.
212
+ let maxGap = (angles[0] ?? 0) + 2 * Math.PI - (angles[angles.length - 1] ?? 0);
213
+ for (let i = 1; i < angles.length; i += 1) {
214
+ maxGap = Math.max(maxGap, (angles[i] ?? 0) - (angles[i - 1] ?? 0));
215
+ }
216
+ return 2 * Math.PI - maxGap;
217
+ }
218
+ /**
219
+ * Unfold an ARBITRARY imported sheet-metal solid with no feature tree, by
220
+ * detecting its geometry numerically. The supported class is roughly-uniform-
221
+ * thickness solids whose panels are planar and whose bends are cylindrical:
222
+ *
223
+ * 1. Classify faces by surface type — planar faces are panel faces, cylindrical
224
+ * faces are bend faces. Any other surface type is reported `UNSUPPORTED_FACE`.
225
+ * 2. Pair the two large parallel planar faces of each flat panel (a thickness
226
+ * apart); pair the inner/outer cylindrical faces of each bend and fit its
227
+ * axis + inner radius + swept angle via {@link fitCylinder}.
228
+ * 3. Build a bend graph (flats = nodes; a bend connects the two flats it shares
229
+ * edges with), take a spanning tree from a root flat, and turn any non-tree
230
+ * bend into a seam cut (warned).
231
+ * 4. Walk the tree replacing each cylindrical bend region with a developed strip
232
+ * of length `developedLength(angle, thickness, { innerRadius, kFactor })`
233
+ * (`kFactor` defaults to the {@link DEFAULT_FOREIGN_K} mid-surface), laying
234
+ * each flat out into the plane. Bend direction (up/down) is read from the
235
+ * cylinder centre relative to the parent flat.
236
+ *
237
+ * Non-fatal warnings ride inside the Ok payload; the function fails only when the
238
+ * input is not a recognizable sheet-metal solid at all.
239
+ *
240
+ * @param solid - the foreign solid to unfold.
241
+ * @param opts - optional k-factor override.
242
+ * @returns the unfold result, or an error.
243
+ */
244
+ export function unfoldForeignSolid(solid, opts) {
245
+ if (!isValid(solid)) {
246
+ return err(validationError('INVALID_SOLID', 'foreign solid is not a valid B-rep'));
247
+ }
248
+ const kFactor = opts?.kFactor ?? DEFAULT_FOREIGN_K;
249
+ if (!Number.isFinite(kFactor) || kFactor < 0 || kFactor > 1) {
250
+ return err(validationError('INVALID_K_FACTOR', `kFactor must be in [0, 1], got ${kFactor}`));
251
+ }
252
+ const warnings = [];
253
+ const faces = getFaces(solid);
254
+ if (faces.length === 0) {
255
+ return err(validationError('EMPTY_SOLID', 'foreign solid has no faces'));
256
+ }
257
+ const planarFaces = [];
258
+ const cylFaces = [];
259
+ for (const face of faces) {
260
+ const t = getSurfaceType(face);
261
+ if (!t.ok) {
262
+ warnings.push({ code: 'UNSUPPORTED_FACE', message: 'failed to read a face surface type; skipping' });
263
+ continue;
264
+ }
265
+ if (t.value === 'PLANE')
266
+ planarFaces.push(face);
267
+ else if (t.value === 'CYLINDRE')
268
+ cylFaces.push(face);
269
+ else {
270
+ warnings.push({
271
+ code: 'UNSUPPORTED_FACE',
272
+ message: `UNSUPPORTED_FACE: surface type '${t.value}' is not a planar panel or cylindrical bend; the unfold ignores it and may be incomplete`,
273
+ });
274
+ }
275
+ }
276
+ const thicknessResult = detectThickness(planarFaces);
277
+ if (!thicknessResult.ok)
278
+ return thicknessResult;
279
+ const thickness = thicknessResult.value;
280
+ const flats = pairFlats(planarFaces, thickness, warnings);
281
+ if (flats.length === 0) {
282
+ return err(validationError('NO_FLATS', 'no planar panel pairs detected; not a recognizable sheet-metal solid'));
283
+ }
284
+ const bends = pairBends(cylFaces, warnings);
285
+ const graph = buildBendGraph(flats, bends, warnings);
286
+ const layout = layoutForeign(flats, graph, thickness, kFactor, warnings);
287
+ if (!layout.ok)
288
+ return layout;
289
+ return ok({ pattern: layout.value.pattern, report: layout.value.report, warnings });
290
+ }
291
+ /**
292
+ * Detect the sheet thickness as the smallest gap between an antiparallel pair of
293
+ * planar faces (the two faces of one flat panel). For a roughly-uniform-thickness
294
+ * part this gap is the same across panels; a spread beyond tolerance warns.
295
+ */
296
+ function detectThickness(planar) {
297
+ const gaps = [];
298
+ for (let i = 0; i < planar.length; i += 1) {
299
+ for (let j = i + 1; j < planar.length; j += 1) {
300
+ const fa = planar[i];
301
+ const fb = planar[j];
302
+ if (fa === undefined || fb === undefined)
303
+ continue;
304
+ const na = faceNormal(fa);
305
+ const nb = faceNormal(fb);
306
+ if (vecDot(na, nb) > -0.99)
307
+ continue; // need antiparallel (opposite faces of a panel)
308
+ const ca = faceCenter(fa);
309
+ const cb = faceCenter(fb);
310
+ const gap = Math.abs(vecDot(vecSub(cb, ca), na));
311
+ if (gap > GEOM_TOL)
312
+ gaps.push(gap);
313
+ }
314
+ }
315
+ if (gaps.length === 0) {
316
+ return err(validationError('NO_THICKNESS', 'could not detect a sheet thickness (no opposed planar pair)'));
317
+ }
318
+ gaps.sort((a, b) => a - b);
319
+ return ok(gaps[0]);
320
+ }
321
+ /**
322
+ * Pair planar faces into flat panels: each panel is two parallel faces a
323
+ * `thickness` apart with overlapping footprints. Greedy nearest-match by gap.
324
+ */
325
+ function pairFlats(planar, thickness, warnings) {
326
+ const used = new Set();
327
+ const flats = [];
328
+ for (let i = 0; i < planar.length; i += 1) {
329
+ if (used.has(i))
330
+ continue;
331
+ const fa = planar[i];
332
+ if (fa === undefined)
333
+ continue;
334
+ const na = faceNormal(fa);
335
+ const ca = faceCenter(fa);
336
+ let best = -1;
337
+ let bestErr = Infinity;
338
+ for (let j = 0; j < planar.length; j += 1) {
339
+ if (j === i || used.has(j))
340
+ continue;
341
+ const fb = planar[j];
342
+ if (fb === undefined)
343
+ continue;
344
+ const nb = faceNormal(fb);
345
+ if (vecDot(na, nb) > -0.99)
346
+ continue;
347
+ const cb = faceCenter(fb);
348
+ const gap = Math.abs(vecDot(vecSub(cb, ca), na));
349
+ const e = Math.abs(gap - thickness);
350
+ if (e < bestErr) {
351
+ bestErr = e;
352
+ best = j;
353
+ }
354
+ }
355
+ if (best < 0 || bestErr > thickness * 0.5 + GEOM_TOL) {
356
+ // No usable antiparallel partner at the sheet thickness. A bend-edge cap or
357
+ // rim band is plate-thin in one in-plane direction (≈ thickness) — expected,
358
+ // skip quietly. A face that is wide in BOTH in-plane directions is a real
359
+ // panel; dropping it means non-uniform thickness or unrecognised topology, so
360
+ // flag it. (Area alone can't tell them apart — a long rim is large by area.)
361
+ const db = getBounds(fa);
362
+ const ext = [db.xMax - db.xMin, db.yMax - db.yMin, db.zMax - db.zMin].sort((p, q) => q - p);
363
+ const inPlaneMin = ext[1] ?? 0;
364
+ if (inPlaneMin > thickness * 4) {
365
+ warnings.push({
366
+ code: 'DETECTION_INCOMPLETE',
367
+ message: `a panel-sized planar face (${(ext[0] ?? 0).toFixed(1)}×${inPlaneMin.toFixed(1)}) has no opposite face at the sheet thickness ${thickness.toFixed(3)}; omitted (possible non-uniform thickness)`,
368
+ });
369
+ }
370
+ continue;
371
+ }
372
+ const fb = planar[best];
373
+ used.add(i);
374
+ used.add(best);
375
+ const areaA = measureArea(fa);
376
+ const areaB = measureArea(fb);
377
+ let area;
378
+ if (areaA.ok && areaB.ok)
379
+ area = (areaA.value + areaB.value) / 2;
380
+ else if (areaA.ok)
381
+ area = areaA.value;
382
+ else if (areaB.ok)
383
+ area = areaB.value;
384
+ else {
385
+ // Neither face area is measurable: fall back to the panel's bounding-box
386
+ // footprint so developedArea isn't silently zero, and flag the estimate.
387
+ const b = getBounds(fa);
388
+ const dx = b.xMax - b.xMin;
389
+ const dy = b.yMax - b.yMin;
390
+ const dz = b.zMax - b.zMin;
391
+ area = Math.max(dx * dy, dy * dz, dx * dz);
392
+ warnings.push({
393
+ code: 'DETECTION_INCOMPLETE',
394
+ message: `flat-${flats.length}: face area unmeasurable; using a bounding-box estimate (${area.toFixed(2)})`,
395
+ });
396
+ }
397
+ flats.push({
398
+ id: `flat-${flats.length}`,
399
+ faces: [fa, fb],
400
+ normal: na,
401
+ center: vecScale(vecAdd(ca, faceCenter(fb)), 0.5),
402
+ area,
403
+ });
404
+ }
405
+ if (flats.length === 0 && planar.length > 0) {
406
+ warnings.push({
407
+ code: 'DETECTION_INCOMPLETE',
408
+ message: 'no parallel planar face pairs matched the detected thickness; the part may have non-uniform thickness',
409
+ });
410
+ }
411
+ return flats;
412
+ }
413
+ /**
414
+ * Pair cylindrical faces into bends: each bend has an inner + outer coaxial face
415
+ * (same axis, radii a thickness apart) or, failing a pair, a single fitted face.
416
+ */
417
+ function pairBends(cyl, warnings) {
418
+ const fitted = [];
419
+ for (const face of cyl) {
420
+ const fit = fitCylinder(face);
421
+ if (fit === null) {
422
+ warnings.push({
423
+ code: 'UNSUPPORTED_FACE',
424
+ message: 'a cylindrical face did not fit a cylinder within tolerance; the bend is ignored',
425
+ });
426
+ continue;
427
+ }
428
+ fitted.push({ face, fit });
429
+ }
430
+ const used = new Set();
431
+ const bends = [];
432
+ for (let i = 0; i < fitted.length; i += 1) {
433
+ if (used.has(i))
434
+ continue;
435
+ const a = fitted[i];
436
+ if (a === undefined)
437
+ continue;
438
+ let mate = -1;
439
+ for (let j = i + 1; j < fitted.length; j += 1) {
440
+ if (used.has(j))
441
+ continue;
442
+ const b = fitted[j];
443
+ if (b === undefined)
444
+ continue;
445
+ if (Math.abs(vecDot(a.fit.axisDir, b.fit.axisDir)) < 0.99)
446
+ continue;
447
+ if (distanceToAxis(b.fit.axisOrigin, a.fit.axisOrigin, a.fit.axisDir) > GEOM_TOL * 10)
448
+ continue;
449
+ mate = j;
450
+ break;
451
+ }
452
+ const faces = [a.face];
453
+ let innerRadius = a.fit.radius;
454
+ let fit = a.fit;
455
+ if (mate >= 0) {
456
+ const b = fitted[mate];
457
+ faces.push(b.face);
458
+ innerRadius = Math.min(a.fit.radius, b.fit.radius);
459
+ fit = a.fit.radius <= b.fit.radius ? a.fit : b.fit;
460
+ used.add(mate);
461
+ }
462
+ used.add(i);
463
+ bends.push({
464
+ id: `bend-${bends.length}`,
465
+ faces,
466
+ fit,
467
+ innerRadius,
468
+ angleDeg: (fit.angleSpan * 180) / Math.PI,
469
+ });
470
+ }
471
+ return bends;
472
+ }
473
+ /**
474
+ * Connect bends to the flats they touch: a bend's cylindrical face shares edges
475
+ * with exactly the two flats tangent to it. A bend that touches fewer than two
476
+ * detected flats is dropped (it is a free rim); more than two is ambiguous and
477
+ * warned. Spanning-tree / seam handling happens in {@link layoutForeign}.
478
+ */
479
+ function buildBendGraph(flats, bends, warnings) {
480
+ const edges = [];
481
+ for (const bend of bends) {
482
+ const touching = [];
483
+ for (let fi = 0; fi < flats.length; fi += 1) {
484
+ const flat = flats[fi];
485
+ if (flat === undefined)
486
+ continue;
487
+ if (bendTouchesFlat(bend, flat))
488
+ touching.push(fi);
489
+ }
490
+ if (touching.length < 2) {
491
+ warnings.push({
492
+ code: 'DETECTION_INCOMPLETE',
493
+ message: `bend '${bend.id}' connects ${touching.length} flats (expected 2); ignored`,
494
+ featureId: bend.id,
495
+ });
496
+ continue;
497
+ }
498
+ if (touching.length > 2) {
499
+ warnings.push({
500
+ code: 'DETECTION_INCOMPLETE',
501
+ message: `bend '${bend.id}' touches ${touching.length} flats; using the first two`,
502
+ featureId: bend.id,
503
+ });
504
+ }
505
+ edges.push({ bend, flats: [touching[0], touching[1]] });
506
+ }
507
+ return { edges };
508
+ }
509
+ /** True if any of the bend's faces shares an edge with either face of the flat. */
510
+ function bendTouchesFlat(bend, flat) {
511
+ for (const bf of bend.faces) {
512
+ for (const ff of flat.faces) {
513
+ if (sharedEdges(bf, ff).length > 0)
514
+ return true;
515
+ }
516
+ }
517
+ return false;
518
+ }
519
+ /**
520
+ * Lay out detected flats into the developed plane. A spanning tree over the bend
521
+ * graph (BFS from flat 0) places each flat as a rectangle and each tree bend as a
522
+ * developed strip; non-tree bends become seam cuts (warned). The developed area
523
+ * is the sum of flat mid-surface areas plus each developed-strip area, computed
524
+ * directly from detected geometry. Bend direction is read from the cylinder
525
+ * centre relative to the parent flat's outward normal.
526
+ */
527
+ function layoutForeign(flats, graph, thickness, kFactor, warnings) {
528
+ const adjacency = new Map();
529
+ for (let i = 0; i < flats.length; i += 1)
530
+ adjacency.set(i, []);
531
+ for (const e of graph.edges) {
532
+ adjacency.get(e.flats[0])?.push(e);
533
+ adjacency.get(e.flats[1])?.push(e);
534
+ }
535
+ const visited = new Set([0]);
536
+ const treeEdges = new Set();
537
+ const treeBends = [];
538
+ const queue = [0];
539
+ while (queue.length > 0) {
540
+ const cur = queue.shift();
541
+ if (cur === undefined)
542
+ break;
543
+ for (const e of adjacency.get(cur) ?? []) {
544
+ if (treeEdges.has(e))
545
+ continue;
546
+ const next = e.flats[0] === cur ? e.flats[1] : e.flats[0];
547
+ if (visited.has(next))
548
+ continue;
549
+ visited.add(next);
550
+ treeEdges.add(e);
551
+ treeBends.push({ edge: e, parent: cur, child: next });
552
+ queue.push(next);
553
+ }
554
+ }
555
+ for (const e of graph.edges) {
556
+ if (treeEdges.has(e))
557
+ continue;
558
+ warnings.push({
559
+ code: 'SEAM_CUT',
560
+ message: `closed profile: bend '${e.bend.id}' becomes a seam cut`,
561
+ featureId: e.bend.id,
562
+ });
563
+ }
564
+ // Each bend's strip width = the bend cylinder's axial extent (its span).
565
+ const bendSpan = new Map();
566
+ for (const e of graph.edges)
567
+ bendSpan.set(e.bend, bendAxialExtent(e.bend));
568
+ // 2D placement: develop each flat into the plane preserving its real fold
569
+ // direction (perpendicular to the bend axis), so an L-bracket lays out L-shaped,
570
+ // not collapsed into a straight chain. Each placed flat carries a consistent
571
+ // 3D→2D affine map (`m2`): a 3D point on the flat's plane maps to 2D via its
572
+ // in-plane basis, so the bend line, edges and develop-out direction are all
573
+ // derived in 3D then mapped to 2D — keeping signs/edges consistent. The root is
574
+ // laid at the origin; a child is placed past its developed strip, out from the
575
+ // shared bend line. The developed AREA is geometry-exact (Σ flat mid-surface
576
+ // areas + Σ developed-strip areas).
577
+ const placed = new Map();
578
+ const root = flats[0];
579
+ const rootAxis = treeBends[0]?.edge.bend.fit.axisDir ?? perpBasis(root.normal).e1;
580
+ const rootBasis = planeBasis(root.normal, rootAxis);
581
+ placed.set(0, {
582
+ flat: root,
583
+ a: rootBasis.along,
584
+ b: rootBasis.out,
585
+ o3: root.center,
586
+ o2: [0, 0],
587
+ a2: [1, 0],
588
+ b2: [0, 1],
589
+ });
590
+ let developedArea = 0;
591
+ for (const f of flats)
592
+ developedArea += f.area;
593
+ const bendReportRows = [];
594
+ const rects = [];
595
+ const bendLines = [];
596
+ rects.push(flatRect2(placed.get(0)));
597
+ for (const tb of treeBends) {
598
+ const bend = tb.edge.bend;
599
+ const dev = developedLength(bend.angleDeg, thickness, { innerRadius: bend.innerRadius, kFactor }, (w) => warnings.push(w));
600
+ if (!dev.ok)
601
+ return dev;
602
+ const parentPlace = placed.get(tb.parent);
603
+ const parent = flats[tb.parent];
604
+ const child = flats[tb.child];
605
+ if (parentPlace === undefined)
606
+ continue;
607
+ const direction = bendDirection(bend, parent);
608
+ const axis = bend.fit.axisDir;
609
+ const span = bendSpan.get(bend) ?? bendAxialExtent(bend);
610
+ const placement = placeChildFlat(parentPlace, child, bend, axis, span, dev.value);
611
+ placed.set(tb.child, placement.child);
612
+ rects.push(placement.strip);
613
+ rects.push(flatRect2(placement.child));
614
+ bendLines.push({
615
+ id: bend.id,
616
+ line: line([placement.bendA[0], placement.bendA[1], 0], [placement.bendB[0], placement.bendB[1], 0]),
617
+ angleDeg: bend.angleDeg,
618
+ direction,
619
+ inward: [-placement.outDir2[0], -placement.outDir2[1]],
620
+ });
621
+ developedArea += dev.value * span;
622
+ bendReportRows.push({
623
+ id: bend.id,
624
+ angleDeg: bend.angleDeg,
625
+ radius: bend.innerRadius,
626
+ allowance: dev.value,
627
+ flatLength: placement.runLen,
628
+ direction,
629
+ });
630
+ }
631
+ const bbox = aabb2(rects);
632
+ const outline = buildRectOutline2(bbox);
633
+ const outlineWire = wireLoop(outline);
634
+ if (!outlineWire.ok)
635
+ return outlineWire;
636
+ const pattern = {
637
+ outline: outlineWire.value,
638
+ bendLines,
639
+ holes: [],
640
+ formCuts: [],
641
+ formMarkers: [],
642
+ formHinges: [],
643
+ loftedDevelopments: [],
644
+ developedArea,
645
+ };
646
+ const report = {
647
+ bends: bendReportRows,
648
+ totalFlatSize: [bbox.x1 - bbox.x0, bbox.y1 - bbox.y0],
649
+ };
650
+ return ok({ pattern, report });
651
+ }
652
+ /** Map a 3D in-plane point through a placed flat's affine map. */
653
+ function mapPoint(p, point) {
654
+ const da = vecDot(vecSub(point, p.o3), p.a);
655
+ const db = vecDot(vecSub(point, p.o3), p.b);
656
+ return [p.o2[0] + da * p.a2[0] + db * p.b2[0], p.o2[1] + da * p.a2[1] + db * p.b2[1]];
657
+ }
658
+ /** 3D in-plane basis of a flat: `along` parallel to `axis`, `out` perpendicular. */
659
+ function planeBasis(normal, axis) {
660
+ const proj = vecSub(axis, vecScale(normal, vecDot(axis, normal)));
661
+ const along = vecLength(proj) > GEOM_TOL ? vecNormalize(proj) : perpBasis(normal).e1;
662
+ const out = vecNormalize(vecCross(normal, along));
663
+ return { along, out };
664
+ }
665
+ /** AABB rectangle of a placed flat in 2D (mapping its mid-surface sample corners). */
666
+ function flatRect2(p) {
667
+ const samples = sampleFace(p.flat.faces[0], 4);
668
+ const pts2 = samples.map((s) => mapPoint(p, s.point));
669
+ return rectFrom(pts2);
670
+ }
671
+ /**
672
+ * Place a child flat past its developed bend strip. The shared bend line is the
673
+ * bend axis projected into the parent's plane; the child develops `out` from it on
674
+ * the side away from the parent. Both the bend line and the develop-out direction
675
+ * are computed in 3D then mapped to 2D through the parent's affine map, so the 2D
676
+ * placement stays consistent with the real geometry regardless of basis sign.
677
+ */
678
+ function placeChildFlat(parent, childFlat, bend, axis, span, dev) {
679
+ // In-plane bend axis direction (the strip-width direction).
680
+ const axisInPlane = vecNormalize(vecSub(axis, vecScale(parent.flat.normal, vecDot(axis, parent.flat.normal))));
681
+ // The shared bend line is where the bend meets the parent: the bend axis line
682
+ // projected onto the parent plane, positioned over the parent/child along-axis
683
+ // overlap (the fit's axial origin is arbitrary, so the line is placed by the
684
+ // parent's footprint, not the fit point). Its mid-point along the axis is the
685
+ // centre of the parent footprint along the axis; it spans `span` (the bend's
686
+ // axial extent).
687
+ const axisLineMid = projectToPlane(bend.fit.axisOrigin, parent.flat.normal, parent.o3);
688
+ const lineMid = parentFootprintCentreAlong(parent, axisInPlane, axisLineMid);
689
+ const toBend = vecSub(axisLineMid, parent.flat.center);
690
+ let outDir3 = vecSub(toBend, vecScale(axisInPlane, vecDot(toBend, axisInPlane)));
691
+ if (vecLength(outDir3) < GEOM_TOL)
692
+ outDir3 = vecNormalize(vecCross(parent.flat.normal, axisInPlane));
693
+ else
694
+ outDir3 = vecNormalize(outDir3);
695
+ const bend3A = vecSub(lineMid, vecScale(axisInPlane, span / 2));
696
+ const bend3B = vecAdd(lineMid, vecScale(axisInPlane, span / 2));
697
+ const bendA = mapPoint(parent, bend3A);
698
+ const bendB = mapPoint(parent, bend3B);
699
+ // 2D develop-out direction = image of `outDir3`; 2D along = image of axisInPlane.
700
+ const outBase2 = mapPoint(parent, vecAdd(lineMid, outDir3));
701
+ const lineMid2 = mapPoint(parent, lineMid);
702
+ const outDir2 = unit2([outBase2[0] - lineMid2[0], outBase2[1] - lineMid2[1]]);
703
+ const along2 = unit2([bendB[0] - bendA[0], bendB[1] - bendA[1]]);
704
+ const stripFarA = [bendA[0] + outDir2[0] * dev, bendA[1] + outDir2[1] * dev];
705
+ const childBasis = planeBasis(childFlat.normal, axis);
706
+ const runLen = inPlaneRun(childFlat, childBasis.out);
707
+ // The child's 2D frame: origin maps the child corner that develops onto
708
+ // `stripFarA` — the corner at minimum bend-axis position (the bend-line start)
709
+ // and minimum run (nearest the bend). `a2`=along2, `b2`=outDir2. Its 3D basis
710
+ // `a`=axisInPlane (shared bend axis, oriented to match `along2`), `b`=child run
711
+ // direction pointing away from the bend.
712
+ const childRunDir = runDirectionAway(childFlat, childBasis.out, bend.fit.axisOrigin);
713
+ const child = {
714
+ flat: childFlat,
715
+ a: axisInPlane,
716
+ b: childRunDir,
717
+ o3: childNearCorner(childFlat, axisInPlane, childRunDir),
718
+ o2: stripFarA,
719
+ a2: along2,
720
+ b2: outDir2,
721
+ };
722
+ const stripFarB = [bendB[0] + outDir2[0] * dev, bendB[1] + outDir2[1] * dev];
723
+ const strip = rectFrom([bendA, bendB, stripFarA, stripFarB]);
724
+ return { child, strip, bendA, bendB, outDir2, runLen };
725
+ }
726
+ /** Project a point onto a plane (point `o`, unit normal `n`). */
727
+ function projectToPlane(p, n, o) {
728
+ return vecSub(p, vecScale(n, vecDot(vecSub(p, o), n)));
729
+ }
730
+ /**
731
+ * A point on the parent plane at the centre of the parent's footprint along the
732
+ * bend axis, keeping the bend's out-position (so the bend line sits on the parent's
733
+ * edge but is centred over the parent's along-extent). This pins the bend line to
734
+ * the parent footprint rather than the fit's arbitrary axial origin.
735
+ */
736
+ function parentFootprintCentreAlong(parent, axisInPlane, axisRef) {
737
+ const samples = sampleFace(parent.flat.faces[0], 4);
738
+ let min = Infinity;
739
+ let max = -Infinity;
740
+ for (const s of samples) {
741
+ const v = vecDot(s.point, axisInPlane);
742
+ min = Math.min(min, v);
743
+ max = Math.max(max, v);
744
+ }
745
+ const mid = (min + max) / 2;
746
+ const refAlong = vecDot(axisRef, axisInPlane);
747
+ return vecAdd(axisRef, vecScale(axisInPlane, mid - refAlong));
748
+ }
749
+ /** In-plane run extent of a flat along `out` (perpendicular to the bend axis). */
750
+ function inPlaneRun(flat, out) {
751
+ const samples = sampleFace(flat.faces[0], 4);
752
+ let min = Infinity;
753
+ let max = -Infinity;
754
+ for (const s of samples) {
755
+ const v = vecDot(s.point, out);
756
+ min = Math.min(min, v);
757
+ max = Math.max(max, v);
758
+ }
759
+ return Number.isFinite(max - min) ? max - min : 0;
760
+ }
761
+ /** Run direction of a child flat pointing away from the bend axis. */
762
+ function runDirectionAway(flat, out, axisOrigin) {
763
+ const toCenter = vecSub(flat.center, axisOrigin);
764
+ return vecDot(toCenter, out) >= 0 ? out : vecScale(out, -1);
765
+ }
766
+ /**
767
+ * The child corner that develops onto the strip-far near point: minimum along the
768
+ * (oriented) bend axis and minimum along the run (nearest the bend). Searched over
769
+ * the in-plane bounds so `(P−o3)·a ∈ [0, span]` and `(P−o3)·b ∈ [0, runLen]`.
770
+ */
771
+ function childNearCorner(flat, axis, run) {
772
+ const samples = sampleFace(flat.faces[0], 4);
773
+ let minAxis = Infinity;
774
+ let minRun = Infinity;
775
+ for (const s of samples) {
776
+ minAxis = Math.min(minAxis, vecDot(s.point, axis));
777
+ minRun = Math.min(minRun, vecDot(s.point, run));
778
+ }
779
+ // Reconstruct the corner in the plane: shift a reference sample to the
780
+ // (minAxis, minRun) coordinates so `(P−corner)·axis ∈ [0, span]` and
781
+ // `(P−corner)·run ∈ [0, runLen]` over the flat.
782
+ const ref = samples[0]?.point ?? flat.center;
783
+ const da = minAxis - vecDot(ref, axis);
784
+ const db = minRun - vecDot(ref, run);
785
+ return vecAdd(vecAdd(ref, vecScale(axis, da)), vecScale(run, db));
786
+ }
787
+ function unit2(v) {
788
+ const len = Math.hypot(v[0], v[1]);
789
+ return len < 1e-12 ? [1, 0] : [v[0] / len, v[1] / len];
790
+ }
791
+ function rectFrom(pts) {
792
+ let x0 = Infinity;
793
+ let y0 = Infinity;
794
+ let x1 = -Infinity;
795
+ let y1 = -Infinity;
796
+ for (const [x, y] of pts) {
797
+ x0 = Math.min(x0, x);
798
+ y0 = Math.min(y0, y);
799
+ x1 = Math.max(x1, x);
800
+ y1 = Math.max(y1, y);
801
+ }
802
+ return { x0, y0, x1, y1 };
803
+ }
804
+ function aabb2(rects) {
805
+ let x0 = Infinity;
806
+ let y0 = Infinity;
807
+ let x1 = -Infinity;
808
+ let y1 = -Infinity;
809
+ for (const r of rects) {
810
+ x0 = Math.min(x0, r.x0);
811
+ y0 = Math.min(y0, r.y0);
812
+ x1 = Math.max(x1, r.x1);
813
+ y1 = Math.max(y1, r.y1);
814
+ }
815
+ if (!Number.isFinite(x0))
816
+ return { x0: 0, y0: 0, x1: 0, y1: 0 };
817
+ return { x0, y0, x1, y1 };
818
+ }
819
+ function buildRectOutline2(b) {
820
+ return [
821
+ line([b.x0, b.y0, 0], [b.x1, b.y0, 0]),
822
+ line([b.x1, b.y0, 0], [b.x1, b.y1, 0]),
823
+ line([b.x1, b.y1, 0], [b.x0, b.y1, 0]),
824
+ line([b.x0, b.y1, 0], [b.x0, b.y0, 0]),
825
+ ];
826
+ }
827
+ /** Axial extent of a bend (its cylindrical face span along the fitted axis). */
828
+ function bendAxialExtent(bend) {
829
+ const face = bend.faces[0];
830
+ if (face === undefined)
831
+ return 0;
832
+ const axis = bend.fit.axisDir;
833
+ const samples = sampleFace(face, 6);
834
+ let min = Infinity;
835
+ let max = -Infinity;
836
+ for (const s of samples) {
837
+ const a = vecDot(s.point, axis);
838
+ min = Math.min(min, a);
839
+ max = Math.max(max, a);
840
+ }
841
+ return Number.isFinite(max - min) ? max - min : 0;
842
+ }
843
+ /**
844
+ * Bend direction: 'up' if the cylinder axis sits on the +normal side of the
845
+ * parent flat's mid-surface, 'down' otherwise.
846
+ */
847
+ function bendDirection(bend, parent) {
848
+ const toAxis = vecSub(bend.fit.axisOrigin, parent.center);
849
+ return vecDot(toAxis, parent.normal) >= 0 ? 'up' : 'down';
850
+ }
851
+ function faceNormal(face) {
852
+ return vecNormalize(normalAt(face, faceCenter(face)));
853
+ }
854
+ //# sourceMappingURL=foreignUnfoldFns.js.map