@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,744 @@
1
+ import { ok, err, validationError, getEdges, curveStartPoint, curveEndPoint, } from '@faicad/faijs/brepjs-compat';
2
+ import { authorPart } from './authorFns.js';
3
+ import { addBendRelief } from './reliefFns.js';
4
+ import { addCutout } from './cutoutFns.js';
5
+ import { addTab } from './tabFns.js';
6
+ import { addForm } from './formFns.js';
7
+ import { featureTree, ROOT_FLAT_ID } from './featureTreeFns.js';
8
+ import { developedLength } from './allowanceFns.js';
9
+ import { unfold, edgeBasis2 } from './unfoldFns.js';
10
+ import { validatePart } from './validateFns.js';
11
+ /**
12
+ * Fold a flat pattern up into a 3D part. Each {@link FoldRegion} folds about its
13
+ * fold line off its parent region, exactly inverting {@link unfold}: folding by an
14
+ * angle is the same rigid construction the forward authoring path performs, so this
15
+ * reuses {@link authorPart}'s bend geometry wholesale rather than re-deriving it.
16
+ *
17
+ * The result carries a fully-populated {@link SheetMetalPart} (valid solid + the
18
+ * BendFeature/FlangeFeature tree consistent with `authorPart`), so
19
+ * `unfold(fold(input))` round-trips. SEAM_CUT-style and min-radius warnings ride
20
+ * inside the Ok payload.
21
+ *
22
+ * @param input - the flat pattern input to fold.
23
+ * @returns the folded sheet metal part, or an error.
24
+ */
25
+ export function fold(input) {
26
+ const result = foldWithWarnings(input);
27
+ if (!result.ok)
28
+ return result;
29
+ return ok(result.value.part);
30
+ }
31
+ /**
32
+ * {@link fold} that also surfaces the fold warnings alongside the part.
33
+ * @param input - the flat pattern input to fold.
34
+ * @returns the fold result with warnings, or an error.
35
+ */
36
+ export function foldWithWarnings(input) {
37
+ const spec = {
38
+ thickness: input.thickness,
39
+ base: { length: input.baseLength, width: input.width },
40
+ flanges: input.regions.map(regionToFlange),
41
+ ...(input.material !== undefined ? { material: input.material } : {}),
42
+ };
43
+ const authored = authorPart(spec);
44
+ if (!authored.ok)
45
+ return authored;
46
+ let part = authored.value;
47
+ // Re-apply any bend reliefs recorded on the regions, so fold reproduces a
48
+ // relief'd part's solid + recorded feature (reliefs are cut after the solid is
49
+ // built, exactly as the explicit `addBendRelief` API does).
50
+ for (const region of input.regions) {
51
+ if (region.bendRelief === undefined)
52
+ continue;
53
+ const relieved = addBendRelief(part, region.id, region.bendRelief);
54
+ if (!relieved.ok)
55
+ return relieved;
56
+ part = relieved.value;
57
+ }
58
+ // Re-apply recorded cutouts (in region-local coords) so a cutout'd part folds
59
+ // back into the same solid + recorded feature, exactly as `addCutout` produces.
60
+ for (const spec of input.baseCutouts ?? []) {
61
+ const cutResult = addCutout(part, { ...spec, region: ROOT_FLAT_ID });
62
+ if (!cutResult.ok)
63
+ return cutResult;
64
+ part = cutResult.value;
65
+ }
66
+ for (const region of input.regions) {
67
+ for (const spec of region.cutouts ?? []) {
68
+ const cutResult = addCutout(part, { ...spec, region: region.id });
69
+ if (!cutResult.ok)
70
+ return cutResult;
71
+ part = cutResult.value;
72
+ }
73
+ }
74
+ // Re-apply recorded tabs (additive protrusions) and form features so a tab'd /
75
+ // formed part folds back into the same solid + recorded features.
76
+ for (const spec of input.baseTabs ?? []) {
77
+ const tabbed = addTab(part, { ...spec, region: ROOT_FLAT_ID });
78
+ if (!tabbed.ok)
79
+ return tabbed;
80
+ part = tabbed.value;
81
+ }
82
+ for (const region of input.regions) {
83
+ for (const spec of region.tabs ?? []) {
84
+ const tabbed = addTab(part, { ...spec, region: region.id });
85
+ if (!tabbed.ok)
86
+ return tabbed;
87
+ part = tabbed.value;
88
+ }
89
+ }
90
+ for (const spec of input.baseForms ?? []) {
91
+ const formed = addForm(part, { ...spec, region: ROOT_FLAT_ID });
92
+ if (!formed.ok)
93
+ return formed;
94
+ part = formed.value;
95
+ }
96
+ for (const region of input.regions) {
97
+ for (const spec of region.forms ?? []) {
98
+ const formed = addForm(part, { ...spec, region: region.id });
99
+ if (!formed.ok)
100
+ return formed;
101
+ part = formed.value;
102
+ }
103
+ }
104
+ // SEAM_CUT comes from the feature-tree walk; INVALID_SOLID / COLLISION / MIN_RADIUS
105
+ // come from the canonical validator, so fold inherits the same checks (and message
106
+ // text) as the rest of the package instead of re-deriving min-radius here.
107
+ const warnings = [];
108
+ const tree = featureTree(part);
109
+ if (tree.ok)
110
+ warnings.push(...tree.value.warnings);
111
+ warnings.push(...validatePart(part));
112
+ return ok({ part, warnings });
113
+ }
114
+ function regionToFlange(region) {
115
+ return {
116
+ id: region.id,
117
+ length: region.length,
118
+ angleDeg: region.angleDeg,
119
+ rule: region.rule,
120
+ direction: region.direction,
121
+ ...(region.side !== undefined ? { side: region.side } : {}),
122
+ ...(region.parent !== undefined ? { parent: region.parent } : {}),
123
+ ...(region.offset !== undefined ? { offset: region.offset } : {}),
124
+ ...(region.width !== undefined ? { width: region.width } : {}),
125
+ ...(region.miter !== undefined ? { miter: region.miter } : {}),
126
+ };
127
+ }
128
+ /**
129
+ * Reconstruct a {@link FlatInput} region-tree from the *2D flat-pattern geometry*
130
+ * alone — `pattern.outline` (a closed 2D wire) and `pattern.bendLines` (each a 2D
131
+ * segment + fold angle/direction). Nothing is read from a feature tree or a 3D
132
+ * solid: the regions, sides, offsets, spans and flat lengths are all recovered by
133
+ * reading real 2D coordinates back out of the wire/edges via the public morph
134
+ * geometry readers (`getEdges`, `curveStartPoint`, `curveEndPoint`).
135
+ *
136
+ * This is the non-circular round-trip bridge: feeding `unfold(part).pattern` through
137
+ * here and into {@link fold} exercises unfold's 2D placement, this parser, and the
138
+ * forward fold geometry — a bug in any of them breaks the round-trip.
139
+ *
140
+ * Algorithm (rectilinear families — PR1 develops only axis-aligned rectangles):
141
+ * every bend line is an axis-aligned segment lying on the shared edge between a
142
+ * parent region and a child's developed strip. The developed strip width is
143
+ * `developedLength(angle, thickness, ruleFor(i))` (the supplied rule). BFS outward
144
+ * from the base region (the one anchored at the origin): a bend line on a known
145
+ * region's edge spawns a child region whose far edge is the next outward bend line
146
+ * (a grandchild) or the outline boundary; `length = far-extent − dev`, `span` =
147
+ * bend-line length, `offset`/`side` are the bend line's position on the parent edge.
148
+ *
149
+ * @param pattern - the flat pattern to convert.
150
+ * @param opts - options (thickness, ruleFor, material).
151
+ * @returns the flat input, or an error.
152
+ */
153
+ export function patternToFlatInput(pattern, opts) {
154
+ const { thickness, ruleFor, material } = opts;
155
+ if (!Number.isFinite(thickness) || thickness <= 0) {
156
+ return err(validationError('INVALID_THICKNESS', `thickness must be positive, got ${thickness}`));
157
+ }
158
+ const segsResult = parseBendLines(pattern, thickness, ruleFor);
159
+ if (!segsResult.ok)
160
+ return segsResult;
161
+ const bends = segsResult.value;
162
+ const outline = parseOutlineExtents(pattern);
163
+ if (!outline.ok)
164
+ return outline;
165
+ const { xs, ys, contains } = outline.value;
166
+ const base = baseRect(xs, ys, contains, bends);
167
+ if (!base.ok)
168
+ return base;
169
+ const baseRectVal = base.value;
170
+ const buildResult = buildRegions(baseRectVal, bends, contains);
171
+ if (!buildResult.ok)
172
+ return buildResult;
173
+ return ok({
174
+ thickness,
175
+ baseLength: baseRectVal.x1 - baseRectVal.x0,
176
+ width: baseRectVal.y1 - baseRectVal.y0,
177
+ ...(material !== undefined ? { material } : {}),
178
+ regions: buildResult.value,
179
+ });
180
+ }
181
+ /**
182
+ * Convert an authored {@link SheetMetalPart} into the {@link FlatInput} that folds
183
+ * back into it, going strictly through the 2D flat pattern: `unfold(part)` produces
184
+ * the developed wire + bend lines, and {@link patternToFlatInput} recovers the
185
+ * region tree from that 2D geometry. Only the bend *rule* is read off the part (by
186
+ * matching bend id), which a flat pattern legitimately cannot encode; every
187
+ * geometric attribute is parsed from the wire/edges. This makes the round-trip
188
+ * oracle non-circular — a bug in unfold's 2D placement or in the parser breaks it.
189
+ *
190
+ * Seam bends (closed profiles) are left unfolded by `unfold`, so the recovered part
191
+ * is the open spanning-tree shape, not the re-closed box.
192
+ *
193
+ * @param part - the sheet metal part to recover a flat input from.
194
+ * @returns the flat input, or an error.
195
+ */
196
+ export function partToFlatInput(part) {
197
+ const unfolded = unfold(part);
198
+ if (!unfolded.ok)
199
+ return unfolded;
200
+ const pattern = unfolded.value.pattern;
201
+ // The rule is allowed as an input (a flat pattern cannot encode K-factor). Match
202
+ // each bend line back to its authored bend by id, in unfold's bend-line order
203
+ // (which is the spanning-tree order featureTree/layoutTree emit), falling back to
204
+ // a default rule if a bend can't be matched.
205
+ const ruleByIndex = bendRulesInOrder(part);
206
+ const fallback = part.material?.defaultRule ?? { innerRadius: part.thickness, kFactor: 0.44 };
207
+ const recovered = patternToFlatInput(pattern, {
208
+ thickness: part.thickness,
209
+ ruleFor: (i) => ruleByIndex[i] ?? fallback,
210
+ ...(part.material !== undefined ? { material: part.material } : {}),
211
+ });
212
+ if (!recovered.ok)
213
+ return recovered;
214
+ const withCutouts = attachCutouts(part, recovered.value);
215
+ if (!withCutouts.ok)
216
+ return withCutouts;
217
+ const withTabs = attachTabs(part, withCutouts.value);
218
+ if (!withTabs.ok)
219
+ return withTabs;
220
+ return attachForms(part, withTabs.value);
221
+ }
222
+ /**
223
+ * Re-attach a part's recorded cutouts onto a recovered {@link FlatInput}. Cutouts
224
+ * ride as interior loops, so {@link patternToFlatInput} (which reads only the outer
225
+ * outline) cannot recover them; we carry the region-local specs across instead.
226
+ * The recovered region for the i-th feature-tree bend is `b${i}` (the parser's id
227
+ * scheme), and the unfold emits bend lines in that same BFS order, so a flange's
228
+ * recovered region id is its position in `featureTree(part).bends`. A cutout whose
229
+ * region can't be mapped is failed loudly rather than dropped: silently shedding it
230
+ * would change the refolded volume with no diagnostic, a correctness violation.
231
+ */
232
+ function attachCutouts(part, input) {
233
+ if (part.cutouts === undefined || part.cutouts.length === 0)
234
+ return ok(input);
235
+ const recoveredIdByFlange = new Map();
236
+ const tree = featureTree(part);
237
+ if (tree.ok) {
238
+ tree.value.bends.forEach((tb, i) => recoveredIdByFlange.set(tb.child, `b${i}`));
239
+ }
240
+ const baseCutouts = [];
241
+ const byRegion = new Map();
242
+ for (const c of part.cutouts) {
243
+ if (c.region === ROOT_FLAT_ID) {
244
+ baseCutouts.push(c.spec);
245
+ continue;
246
+ }
247
+ const recoveredId = recoveredIdByFlange.get(c.region);
248
+ if (recoveredId === undefined) {
249
+ return err(validationError('CUTOUT_REGION_UNMAPPED', `partToFlatInput: cutout on region '${c.region}' has no recovered flat region; round-trip would drop it`));
250
+ }
251
+ const list = byRegion.get(recoveredId) ?? [];
252
+ list.push(c.spec);
253
+ byRegion.set(recoveredId, list);
254
+ }
255
+ const regions = input.regions.map((r) => {
256
+ const specs = byRegion.get(r.id);
257
+ return specs === undefined ? r : { ...r, cutouts: specs };
258
+ });
259
+ return ok({
260
+ ...input,
261
+ regions,
262
+ ...(baseCutouts.length > 0 ? { baseCutouts } : {}),
263
+ });
264
+ }
265
+ /**
266
+ * Re-attach a part's recorded tabs onto a recovered {@link FlatInput}, mirroring
267
+ * {@link attachCutouts}. A tab carries its region-local {@link TabSpec}, so it
268
+ * replays through the recorded-feature path exactly as cutouts/forms do — the spec,
269
+ * not the outline, drives the re-fuse. (The tab does extend the outer outline, but
270
+ * the outline parser only needs to recover the base/flange rectangles, which it does
271
+ * from the bend lines and the unprotruded edges; see {@link partToFlatInput}.) An
272
+ * unmappable region fails loudly rather than silently dropping the tab — silently
273
+ * shedding it would change the refolded volume with no diagnostic.
274
+ */
275
+ function attachTabs(part, input) {
276
+ if (part.tabs === undefined || part.tabs.length === 0)
277
+ return ok(input);
278
+ const recoveredIdByFlange = new Map();
279
+ const tree = featureTree(part);
280
+ if (tree.ok) {
281
+ tree.value.bends.forEach((tb, i) => recoveredIdByFlange.set(tb.child, `b${i}`));
282
+ }
283
+ const baseTabs = [];
284
+ const byRegion = new Map();
285
+ for (const t of part.tabs) {
286
+ if (t.region === ROOT_FLAT_ID) {
287
+ baseTabs.push(t.spec);
288
+ continue;
289
+ }
290
+ const recoveredId = recoveredIdByFlange.get(t.region);
291
+ if (recoveredId === undefined) {
292
+ return err(validationError('TAB_REGION_UNMAPPED', `partToFlatInput: tab on region '${t.region}' has no recovered flat region; round-trip would drop it`));
293
+ }
294
+ const list = byRegion.get(recoveredId) ?? [];
295
+ list.push(t.spec);
296
+ byRegion.set(recoveredId, list);
297
+ }
298
+ const regions = input.regions.map((r) => {
299
+ const specs = byRegion.get(r.id);
300
+ return specs === undefined ? r : { ...r, tabs: specs };
301
+ });
302
+ return ok({
303
+ ...input,
304
+ regions,
305
+ ...(baseTabs.length > 0 ? { baseTabs } : {}),
306
+ });
307
+ }
308
+ /**
309
+ * Re-attach a part's recorded form features (louvers / embosses) onto a recovered
310
+ * {@link FlatInput}, mirroring {@link attachCutouts}. Forms are net-material-neutral
311
+ * (they don't change the outer outline), so they round-trip cleanly through the
312
+ * outline oracle; only their region-local specs need carrying across. An unmappable
313
+ * region fails loudly rather than silently dropping the form.
314
+ */
315
+ function attachForms(part, input) {
316
+ if (part.forms === undefined || part.forms.length === 0)
317
+ return ok(input);
318
+ const recoveredIdByFlange = new Map();
319
+ const tree = featureTree(part);
320
+ if (tree.ok) {
321
+ tree.value.bends.forEach((tb, i) => recoveredIdByFlange.set(tb.child, `b${i}`));
322
+ }
323
+ const baseForms = [];
324
+ const byRegion = new Map();
325
+ for (const f of part.forms) {
326
+ if (f.region === ROOT_FLAT_ID) {
327
+ baseForms.push(f.spec);
328
+ continue;
329
+ }
330
+ const recoveredId = recoveredIdByFlange.get(f.region);
331
+ if (recoveredId === undefined) {
332
+ return err(validationError('FORM_REGION_UNMAPPED', `partToFlatInput: form on region '${f.region}' has no recovered flat region; round-trip would drop it`));
333
+ }
334
+ const list = byRegion.get(recoveredId) ?? [];
335
+ list.push(f.spec);
336
+ byRegion.set(recoveredId, list);
337
+ }
338
+ const regions = input.regions.map((r) => {
339
+ const specs = byRegion.get(r.id);
340
+ return specs === undefined ? r : { ...r, forms: specs };
341
+ });
342
+ return ok({
343
+ ...input,
344
+ regions,
345
+ ...(baseForms.length > 0 ? { baseForms } : {}),
346
+ });
347
+ }
348
+ /**
349
+ * Bend rules in {@link FlatPattern.bendLines} order. `unfold` emits bend lines in
350
+ * the layout walk's order, which is the feature tree's BFS (spanning-tree) order;
351
+ * the same walk drives the rules here so index `i` lines up with bend line `i`.
352
+ */
353
+ function bendRulesInOrder(part) {
354
+ const tree = featureTree(part);
355
+ if (!tree.ok)
356
+ return part.flanges.map((f) => f.rule);
357
+ const ruleById = new Map();
358
+ for (const f of part.flanges)
359
+ ruleById.set(f.id, f.rule);
360
+ for (const b of part.bends)
361
+ if (!ruleById.has(b.id))
362
+ ruleById.set(b.id, b.rule);
363
+ const rules = [];
364
+ for (const tb of tree.value.bends) {
365
+ rules.push(ruleById.get(tb.child) ?? tb.bend.rule);
366
+ }
367
+ return rules;
368
+ }
369
+ const EPS = 1e-6;
370
+ function parseBendLines(pattern, thickness, ruleFor) {
371
+ const out = [];
372
+ for (let i = 0; i < pattern.bendLines.length; i += 1) {
373
+ const bl = pattern.bendLines[i];
374
+ if (bl === undefined)
375
+ continue;
376
+ const a = curveStartPoint(bl.line);
377
+ const b = curveEndPoint(bl.line);
378
+ const seg = axisAlignedSeg(a, b);
379
+ if (seg === undefined) {
380
+ return err(validationError('NON_AXIS_BEND', `bend line ${i} is not axis-aligned: (${a[0]},${a[1]})→(${b[0]},${b[1]})`));
381
+ }
382
+ const rule = ruleFor(i);
383
+ const devResult = developedLength(bl.angleDeg, thickness, rule);
384
+ if (!devResult.ok)
385
+ return devResult;
386
+ out.push({
387
+ index: i,
388
+ axis: seg.axis,
389
+ fixed: seg.fixed,
390
+ lo: seg.lo,
391
+ hi: seg.hi,
392
+ span: seg.hi - seg.lo,
393
+ angleDeg: bl.angleDeg,
394
+ direction: bl.direction,
395
+ rule,
396
+ dev: devResult.value,
397
+ });
398
+ }
399
+ return ok(out);
400
+ }
401
+ function axisAlignedSeg(a, b) {
402
+ if (Math.abs(a[0] - b[0]) < EPS) {
403
+ return { axis: 'v', fixed: (a[0] + b[0]) / 2, lo: Math.min(a[1], b[1]), hi: Math.max(a[1], b[1]) };
404
+ }
405
+ if (Math.abs(a[1] - b[1]) < EPS) {
406
+ return { axis: 'h', fixed: (a[1] + b[1]) / 2, lo: Math.min(a[0], b[0]), hi: Math.max(a[0], b[0]) };
407
+ }
408
+ return undefined;
409
+ }
410
+ /**
411
+ * Read the outline wire's vertices back out via `getEdges` + curve endpoint readers
412
+ * and build a point-in-region test over the axis-aligned arrangement they induce.
413
+ * The developed pattern is a simply-connected rectilinear polygon, so a winding-free
414
+ * cell test (is this cell's centre inside the polygon) suffices.
415
+ */
416
+ function parseOutlineExtents(pattern) {
417
+ const edges = getEdges(pattern.outline);
418
+ if (edges.length < 3) {
419
+ return err(validationError('OUTLINE_TOO_SMALL', `outline has ${edges.length} edges, need ≥ 3`));
420
+ }
421
+ const segs = [];
422
+ const xsRaw = [];
423
+ const ysRaw = [];
424
+ for (const e of edges) {
425
+ const s = curveStartPoint(e);
426
+ const t = curveEndPoint(e);
427
+ segs.push({ a: [s[0], s[1]], b: [t[0], t[1]] });
428
+ xsRaw.push(s[0], t[0]);
429
+ ysRaw.push(s[1], t[1]);
430
+ }
431
+ const poly = orderedLoop(segs);
432
+ if (poly === undefined) {
433
+ return err(validationError('OUTLINE_NOT_CLOSED', 'outline edges do not form a single closed loop'));
434
+ }
435
+ const xs = uniqueSorted(xsRaw);
436
+ const ys = uniqueSorted(ysRaw);
437
+ const contains = (x, y) => pointInPolygon(poly, x, y);
438
+ return ok({ xs, ys, contains });
439
+ }
440
+ /** Chain segments into one ordered vertex loop, or undefined if they don't close. */
441
+ function orderedLoop(segs) {
442
+ if (segs.length === 0)
443
+ return undefined;
444
+ const key = (p) => `${round(p[0])}|${round(p[1])}`;
445
+ const from = new Map();
446
+ // Two segments sharing a start-vertex key means the loop isn't a simple chain
447
+ // (floating-point drift can also collapse two near vertices into one bucket);
448
+ // bail rather than silently overwrite and trace a wrong polygon.
449
+ for (const s of segs) {
450
+ const k = key(s.a);
451
+ if (from.has(k))
452
+ return undefined;
453
+ from.set(k, s.b);
454
+ }
455
+ const start = segs[0]?.a;
456
+ if (start === undefined)
457
+ return undefined;
458
+ const loop = [start];
459
+ let cur = from.get(key(start));
460
+ let guard = 0;
461
+ while (cur !== undefined && key(cur) !== key(start) && guard < segs.length + 2) {
462
+ loop.push(cur);
463
+ cur = from.get(key(cur));
464
+ guard += 1;
465
+ }
466
+ if (cur === undefined || key(cur) !== key(start))
467
+ return undefined;
468
+ return loop;
469
+ }
470
+ function round(n) {
471
+ return Math.round(n / EPS) * EPS;
472
+ }
473
+ /** Standard ray-cast point-in-polygon for the (axis-aligned) outline loop. */
474
+ function pointInPolygon(poly, x, y) {
475
+ let inside = false;
476
+ for (let i = 0, j = poly.length - 1; i < poly.length; j = i, i += 1) {
477
+ const pi = poly[i];
478
+ const pj = poly[j];
479
+ if (pi === undefined || pj === undefined)
480
+ continue;
481
+ const intersect = pi[1] > y !== pj[1] > y &&
482
+ x < ((pj[0] - pi[0]) * (y - pi[1])) / (pj[1] - pi[1]) + pi[0];
483
+ if (intersect)
484
+ inside = !inside;
485
+ }
486
+ return inside;
487
+ }
488
+ /**
489
+ * Base region: the rectangle anchored at the origin, `[0, baseLength] × [0, width]`
490
+ * (the unfold layout fixes the base frame there). Each base edge is bounded by the
491
+ * bend line lying on it (a tray folds off all four edges) or, when no flange folds
492
+ * off that edge, by the outline boundary. The extents are therefore the nearest
493
+ * base-bounding bend line — a bend line perpendicular to the run that crosses the
494
+ * origin row/column — clamped to the outline reach.
495
+ */
496
+ function baseRect(xs, ys, contains, bends) {
497
+ if (xs.length < 2 || ys.length < 2) {
498
+ return err(validationError('BASE_NOT_FOUND', 'cannot locate base region at origin'));
499
+ }
500
+ // Probe halfway between the origin and the first positive x grid line — a
501
+ // scale-invariant point inside the base's undivided +X span (the base is one
502
+ // rectangle in X, so the first gridline > 0 is its right edge). Avoids assuming
503
+ // any absolute unit and stays correct when xmin/ymin flanges add negative coords.
504
+ const xFirstPositive = xs.find((v) => v > EPS);
505
+ if (xFirstPositive === undefined) {
506
+ return err(validationError('BASE_NOT_FOUND', 'no positive x extent to locate the base region'));
507
+ }
508
+ const xProbeBase = xFirstPositive / 2;
509
+ const yProbe = firstFilledCellMid(ys.filter((v) => v > -EPS), (mid) => contains(xProbeBase, mid));
510
+ if (yProbe === undefined) {
511
+ return err(validationError('BASE_NOT_FOUND', 'origin cell is not inside the developed region'));
512
+ }
513
+ const x0 = 0;
514
+ const y0 = 0;
515
+ // +X base edge: nearest vertical bend line right of origin crossing the base row,
516
+ // else the outline reach.
517
+ const xBendBound = nearestBend(bends, 'v', yProbe, x0, 1);
518
+ const x1 = xBendBound ?? marchOutline(x0, 1, (c) => contains(c, yProbe));
519
+ const xProbe = (x0 + x1) / 2;
520
+ const yBendBound = nearestBend(bends, 'h', xProbe, y0, 1);
521
+ const y1 = yBendBound ?? marchOutline(y0, 1, (c) => contains(xProbe, c));
522
+ if (x1 <= x0 + EPS || y1 <= y0 + EPS) {
523
+ return err(validationError('BASE_NOT_FOUND', 'base region has zero extent'));
524
+ }
525
+ return ok({ x0, y0, x1, y1 });
526
+ }
527
+ /**
528
+ * Nearest bend line of the given orientation, on the `dir` side of `from` along its
529
+ * perpendicular axis, whose span covers the base `cross` coordinate — i.e. a bend
530
+ * line lying on the base's far edge in that direction. Returns its fixed coordinate.
531
+ */
532
+ function nearestBend(bends, axis, cross, from, dir) {
533
+ let best;
534
+ for (const b of bends) {
535
+ if (b.axis !== axis)
536
+ continue;
537
+ if (cross < b.lo - EPS || cross > b.hi + EPS)
538
+ continue;
539
+ const beyond = dir > 0 ? b.fixed > from + EPS : b.fixed < from - EPS;
540
+ if (!beyond)
541
+ continue;
542
+ if (best === undefined || Math.abs(b.fixed - from) < Math.abs(best - from))
543
+ best = b.fixed;
544
+ }
545
+ return best;
546
+ }
547
+ /** First arrangement cell whose midpoint passes `probe`; locates an interior coord. */
548
+ function firstFilledCellMid(coords, probe) {
549
+ for (let j = 0; j + 1 < coords.length; j += 1) {
550
+ const lo = coords[j];
551
+ const hi = coords[j + 1];
552
+ if (lo === undefined || hi === undefined)
553
+ continue;
554
+ const mid = (lo + hi) / 2;
555
+ if (probe(mid))
556
+ return mid;
557
+ }
558
+ return undefined;
559
+ }
560
+ /** March from `c0` in `dir` until the probe leaves the region; bisect to the boundary. */
561
+ function marchOutline(c0, dir, probe) {
562
+ let inside = c0 + dir * EPS * 4;
563
+ let step = 1;
564
+ let guard = 0;
565
+ while (probe(inside + dir * step) && guard < 100000) {
566
+ inside += dir * step;
567
+ step *= 2;
568
+ guard += 1;
569
+ }
570
+ let lo = inside;
571
+ let hi = inside + dir * step;
572
+ for (let i = 0; i < 60; i += 1) {
573
+ const m = (lo + hi) / 2;
574
+ if (probe(m))
575
+ lo = m;
576
+ else
577
+ hi = m;
578
+ }
579
+ return lo;
580
+ }
581
+ /**
582
+ * BFS outward from the base, attributing each bend line to the region whose local
583
+ * edge it lies on (the parent) and spawning the child region beyond it. Each region
584
+ * carries its 2D frame; a bend line collinear with one of the parent's four local
585
+ * edges, within that edge's span, folds a child whose far edge is the nearest
586
+ * outward grandchild bend line or the outline boundary. `side`/`offset` are reported
587
+ * in the parent's local frame so the re-fold reproduces the authored attachment.
588
+ */
589
+ function buildRegions(base, bends, contains) {
590
+ const regions = [];
591
+ const used = new Set();
592
+ const baseFrame = {
593
+ origin: [base.x0, base.y0],
594
+ u: [1, 0],
595
+ v: [0, 1],
596
+ uLen: base.x1 - base.x0,
597
+ vLen: base.y1 - base.y0,
598
+ };
599
+ const queue = [{ frame: baseFrame, id: undefined }];
600
+ const idForBend = (b) => `b${b.index}`;
601
+ let guard = 0;
602
+ while (queue.length > 0 && guard < bends.length + 4) {
603
+ guard += 1;
604
+ const node = queue.shift();
605
+ if (node === undefined)
606
+ break;
607
+ for (const b of bends) {
608
+ if (used.has(b.index))
609
+ continue;
610
+ const attached = bendOnFrameEdge(b, node.frame);
611
+ if (attached === undefined)
612
+ continue;
613
+ used.add(b.index);
614
+ const far = farExtent(b, attached.outSign, bends, contains);
615
+ const length = far - b.dev;
616
+ if (length <= EPS) {
617
+ return err(validationError('BAD_FLANGE_LENGTH', `recovered flange length for bend ${b.index} is non-positive (${length})`));
618
+ }
619
+ const id = idForBend(b);
620
+ const region = {
621
+ id,
622
+ length,
623
+ angleDeg: b.angleDeg,
624
+ direction: b.direction,
625
+ rule: b.rule,
626
+ side: attached.side,
627
+ offset: attached.offset,
628
+ width: b.span,
629
+ ...(node.id !== undefined ? { parent: node.id } : {}),
630
+ };
631
+ regions.push(region);
632
+ queue.push({ frame: childFrame(attached, b, length), id });
633
+ }
634
+ }
635
+ if (used.size !== bends.length) {
636
+ return err(validationError('UNATTACHED_BEND', `${bends.length - used.size} bend line(s) could not be attached to a region`));
637
+ }
638
+ return ok(regions);
639
+ }
640
+ /**
641
+ * If bend `b` lies on one of `frame`'s four local edges (within that edge's span),
642
+ * return the parent-local `side`, the `offset` along that edge, and the edge basis.
643
+ * The bend line is collinear with the edge `edgeOrigin + along·t`, `t ∈ [0, edgeLen]`;
644
+ * the offset is the projection of the bend's near endpoint onto `along`.
645
+ */
646
+ function bendOnFrameEdge(b, frame) {
647
+ const segMid = bendMidpoint(b);
648
+ const segDir = b.axis === 'v' ? [0, 1] : [1, 0];
649
+ for (const side of ['xmax', 'xmin', 'ymax', 'ymin']) {
650
+ const basis = edgeBasis2(frame, side);
651
+ // Edge must be parallel to the bend (along ∥ segDir) and the bend must sit on it.
652
+ if (Math.abs(cross2(basis.along, segDir)) > EPS)
653
+ continue;
654
+ const rel = [segMid[0] - basis.edgeOrigin[0], segMid[1] - basis.edgeOrigin[1]];
655
+ const perp = Math.abs(dot2(rel, basis.out));
656
+ if (perp > EPS)
657
+ continue;
658
+ const t = dot2(rel, basis.along);
659
+ const edgeLen = edgeLengthOf(frame, side);
660
+ const half = b.span / 2;
661
+ if (t < half - EPS || t > edgeLen - half + EPS)
662
+ continue;
663
+ const offset = t - half;
664
+ // Outward sign on the bend's perpendicular axis (X for a vertical bend line,
665
+ // Y for a horizontal one), so `farExtent` marches the right way.
666
+ const outComp = b.axis === 'v' ? basis.out[0] : basis.out[1];
667
+ const outSign = outComp >= 0 ? 1 : -1;
668
+ return { side, offset: Math.max(0, offset), along: basis.along, out: basis.out, edgeOrigin: basis.edgeOrigin, outSign };
669
+ }
670
+ return undefined;
671
+ }
672
+ /**
673
+ * Child region frame, mirroring unfold's `placeChild`: origin past the developed
674
+ * strip (`edgeOrigin + along·offset + out·dev`), u along the bend axis (span), v
675
+ * outward (length). This keeps recovered frames identical to the layout's so a
676
+ * grandchild attaches to the right local edge.
677
+ */
678
+ function childFrame(attached, b, length) {
679
+ const stripFar = [
680
+ attached.edgeOrigin[0] + attached.along[0] * attached.offset + attached.out[0] * b.dev,
681
+ attached.edgeOrigin[1] + attached.along[1] * attached.offset + attached.out[1] * b.dev,
682
+ ];
683
+ return { origin: stripFar, u: attached.along, v: attached.out, uLen: b.span, vLen: length };
684
+ }
685
+ function edgeLengthOf(f, side) {
686
+ return side === 'xmax' || side === 'xmin' ? f.vLen : f.uLen;
687
+ }
688
+ function bendMidpoint(b) {
689
+ const mid = (b.lo + b.hi) / 2;
690
+ return b.axis === 'v' ? [b.fixed, mid] : [mid, b.fixed];
691
+ }
692
+ function cross2(a, b) {
693
+ return a[0] * b[1] - a[1] * b[0];
694
+ }
695
+ function dot2(a, b) {
696
+ return a[0] * b[0] + a[1] * b[1];
697
+ }
698
+ /**
699
+ * Far perpendicular extent (strip + flat) of a child region from its bend line: the
700
+ * nearest grandchild bend line beyond `b` (parallel, overlapping the span, same
701
+ * outward side) or, failing that, the outline boundary, measured as a distance from
702
+ * the bend line outward. Because bend lines are axis-aligned, the outward direction
703
+ * is along the bend's perpendicular axis with `outSign` (from the edge basis).
704
+ */
705
+ function farExtent(b, outSign, bends, contains) {
706
+ let nearest;
707
+ for (const g of bends) {
708
+ if (g.index === b.index)
709
+ continue;
710
+ if (g.axis !== b.axis)
711
+ continue;
712
+ const overlaps = g.lo < b.hi - EPS && b.lo < g.hi - EPS;
713
+ if (!overlaps)
714
+ continue;
715
+ const beyond = outSign > 0 ? g.fixed > b.fixed + EPS : g.fixed < b.fixed - EPS;
716
+ if (!beyond)
717
+ continue;
718
+ if (nearest === undefined || Math.abs(g.fixed - b.fixed) < Math.abs(nearest - b.fixed)) {
719
+ nearest = g.fixed;
720
+ }
721
+ }
722
+ const boundary = nearest ?? outlineFar(b, outSign, contains);
723
+ return Math.abs(boundary - b.fixed);
724
+ }
725
+ /**
726
+ * March perpendicular outward from the bend line until the region stops being filled
727
+ * (the outline boundary), probing at the span midpoint, then bisect to the boundary.
728
+ */
729
+ function outlineFar(b, outSign, contains) {
730
+ const mid = (b.lo + b.hi) / 2;
731
+ const probe = (perp) => b.axis === 'v' ? contains(perp, mid) : contains(mid, perp);
732
+ return marchOutline(b.fixed, outSign, probe);
733
+ }
734
+ function uniqueSorted(values) {
735
+ const sorted = [...values].sort((a, b) => a - b);
736
+ const out = [];
737
+ for (const v of sorted) {
738
+ const last = out[out.length - 1];
739
+ if (last === undefined || Math.abs(v - last) > EPS)
740
+ out.push(v);
741
+ }
742
+ return out;
743
+ }
744
+ //# sourceMappingURL=foldFns.js.map