brepjs-sheetmetal 0.4.0 → 0.4.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -300,13 +300,20 @@ authored path. It reads only the B-rep:
300
300
  other surface type (cone, sphere, spline, …) is reported `UNSUPPORTED_FACE` and skipped (the unfold never
301
301
  silently invents a flat for an unrecognised face).
302
302
  - **Pair flats**: the two large parallel planar faces of a panel, a sheet thickness apart, are the
303
- mid-surface flat. Thickness is detected as the smallest opposed-planar-pair gap.
303
+ mid-surface flat. Two antiparallel faces count as opposed only when they face each other (an interior
304
+ point of one projects inside the other), so a flange's end cap and the adjacent flange's inner face, a
305
+ bend radius apart across a corner, never pair. Thickness is detected as the smallest opposed-planar-pair
306
+ gap.
304
307
  - **Fit bends**: `fitCylinder(face)` recovers a bend's axis + radius + swept angle from sampled points and
305
308
  normals. The axis direction is the common perpendicular of the sampled normals (every cylinder normal is
306
309
  ⟂ the axis), recovered as the sign-aligned average of cross products of non-parallel normal pairs; the
307
- axis line and radius come from a least-squares circle fit of the points projected onto the plane ⟂ axis;
308
- the radius is the mean point-to-axis distance. A fit whose residual exceeds tolerance is rejected (warned).
309
- Recovery is high-precision: radius and axis of a known `cylinder()` primitive are recovered to ~1e-6.
310
+ axis line and radius come from a least-squares circle fit of the points projected onto the plane ⟂ axis,
311
+ taken relative to their centroid so a part a metre from the origin fits as precisely as one at it; the
312
+ radius is the mean point-to-axis distance. A fit whose residual exceeds tolerance is rejected (warned).
313
+ Recovery is high-precision: radius and axis of a known `cylinder()` primitive are recovered to ~1e-6. A
314
+ bend's inner and outer faces pair only when they share an axis line, differ in radius by the sheet
315
+ thickness, and overlap along the axis, so matching corner rounds or holes on two parallel walls stay
316
+ apart.
310
317
  - **Build the bend graph** (flats = nodes; a bend connects the two flats it shares edges with), take a
311
318
  spanning tree from a root flat, and turn non-tree bends into seam cuts (`SEAM_CUT`, warned).
312
319
  - **Unfold** by walking the tree, replacing each cylindrical region with a developed strip of length
@@ -320,8 +327,10 @@ non-uniform thickness, conical/spline bend faces, non-manifold / non-sheet topol
320
327
  fit a cylinder within tolerance all surface as warnings inside the `Ok` payload (or a clear `Err` when no
321
328
  sheet-metal structure is detected at all, e.g. a bare sphere). The detection is validated by an oracle:
322
329
  author a part, take only its `.solid`, run `unfoldSolid`, and confirm the detected unfold reproduces the
323
- authored unfold's developed area, bend count, flat count, and total flat bbox (single bend, L-bracket,
324
- U-channel), reading only the solid, never the feature tree.
330
+ authored unfold's developed area, bend count, flat count, and total flat bbox with no warnings (single bend,
331
+ L-bracket, U-channel, and four-sided tray at thicknesses 1, 1.5, 2, and 3 against a 2 mm inner radius: as
332
+ authored, with each side of the bent profile merged into one face as most CAD exports carry it, and placed a
333
+ metre from the origin as parts in an assembly export are), reading only the solid, never the feature tree.
325
334
 
326
335
  ## Bend tables
327
336
 
@@ -1286,6 +1286,61 @@ function reportFromLayout(layout) {
1286
1286
  totalFlatSize: [maxX - minX, maxY - minY]
1287
1287
  };
1288
1288
  }
1289
+ // \0@oxc-project+runtime@0.155.0/helpers/esm/usingCtx.js
1290
+ function _usingCtx() {
1291
+ var r = "function" == typeof SuppressedError ? SuppressedError : function(r, e) {
1292
+ var n = Error();
1293
+ return n.name = "SuppressedError", n.error = r, n.suppressed = e, n;
1294
+ }, e = {}, n = [];
1295
+ function using(r, e) {
1296
+ if (null != e) {
1297
+ if (Object(e) !== e) throw new TypeError("using declarations can only be used with objects, functions, null, or undefined.");
1298
+ if (r) var o = e[Symbol.asyncDispose || Symbol["for"]("Symbol.asyncDispose")];
1299
+ if (void 0 === o && (o = e[Symbol.dispose || Symbol["for"]("Symbol.dispose")], r)) var t = o;
1300
+ if ("function" != typeof o) throw new TypeError("Object is not disposable.");
1301
+ t && (o = function o() {
1302
+ try {
1303
+ t.call(e);
1304
+ } catch (r) {
1305
+ return Promise.reject(r);
1306
+ }
1307
+ }), n.push({
1308
+ v: e,
1309
+ d: o,
1310
+ a: r
1311
+ });
1312
+ } else r && n.push({
1313
+ d: e,
1314
+ a: r
1315
+ });
1316
+ return e;
1317
+ }
1318
+ return {
1319
+ e,
1320
+ u: using.bind(null, !1),
1321
+ a: using.bind(null, !0),
1322
+ d: function d() {
1323
+ var o, t = this.e, s = 0;
1324
+ function next() {
1325
+ for (; o = n.pop();) try {
1326
+ if (!o.a && 1 === s) return s = 0, n.push(o), Promise.resolve().then(next);
1327
+ if (o.d) {
1328
+ var r = o.d.call(o.v);
1329
+ if (o.a) return s |= 2, Promise.resolve(r).then(next, err);
1330
+ } else s |= 1;
1331
+ } catch (r) {
1332
+ return err(r);
1333
+ }
1334
+ if (1 === s) return t !== e ? Promise.reject(t) : Promise.resolve();
1335
+ if (t !== e) throw t;
1336
+ }
1337
+ function err(n) {
1338
+ return t = t !== e ? new r(n, t) : n, next();
1339
+ }
1340
+ return next();
1341
+ }
1342
+ };
1343
+ }
1289
1344
  // src/foreignUnfoldFns.ts
1290
1345
  /** Default neutral-axis K-factor for a purely geometric unfold (mid-surface). */
1291
1346
  var DEFAULT_FOREIGN_K = .5;
@@ -1338,15 +1393,20 @@ function fitCylinder(face) {
1338
1393
  const axisDir = axisFromNormals(samples.map((s) => s.normal));
1339
1394
  if (axisDir === null) return null;
1340
1395
  const basis = perpBasis(axisDir);
1341
- const pts2 = samples.map((s) => [(0, brepjs.vecDot)(s.point, basis.e1), (0, brepjs.vecDot)(s.point, basis.e2)]);
1342
- const circle = fitCircle2(pts2);
1343
- if (circle === null) return null;
1344
- const axial = (0, brepjs.vecDot)(samples[0]?.point ?? [
1396
+ let centroid = [
1345
1397
  0,
1346
1398
  0,
1347
1399
  0
1348
- ], axisDir);
1349
- const axisOrigin = (0, brepjs.vecAdd)((0, brepjs.vecAdd)((0, brepjs.vecScale)(basis.e1, circle.cx), (0, brepjs.vecScale)(basis.e2, circle.cy)), (0, brepjs.vecScale)(axisDir, axial));
1400
+ ];
1401
+ for (const s of samples) centroid = (0, brepjs.vecAdd)(centroid, s.point);
1402
+ centroid = (0, brepjs.vecScale)(centroid, 1 / samples.length);
1403
+ const pts2 = samples.map((s) => {
1404
+ const d = (0, brepjs.vecSub)(s.point, centroid);
1405
+ return [(0, brepjs.vecDot)(d, basis.e1), (0, brepjs.vecDot)(d, basis.e2)];
1406
+ });
1407
+ const circle = fitCircle2(pts2);
1408
+ if (circle === null) return null;
1409
+ const axisOrigin = (0, brepjs.vecAdd)(centroid, (0, brepjs.vecAdd)((0, brepjs.vecScale)(basis.e1, circle.cx), (0, brepjs.vecScale)(basis.e2, circle.cy)));
1350
1410
  let sumR = 0;
1351
1411
  for (const s of samples) sumR += distanceToAxis(s.point, axisOrigin, axisDir);
1352
1412
  const radius = sumR / samples.length;
@@ -1596,12 +1656,13 @@ function unfoldForeignSolid(solid, opts) {
1596
1656
  message: `UNSUPPORTED_FACE: surface type '${t.value}' is not a planar panel or cylindrical bend; the unfold ignores it and may be incomplete`
1597
1657
  });
1598
1658
  }
1599
- const thicknessResult = detectThickness(planarFaces);
1659
+ const planar = planarFaces.map(describePlanar);
1660
+ const thicknessResult = detectThickness(planar);
1600
1661
  if (!thicknessResult.ok) return thicknessResult;
1601
1662
  const thickness = thicknessResult.value;
1602
- const flats = pairFlats(planarFaces, thickness, warnings);
1663
+ const flats = pairFlats(planar, thickness, warnings);
1603
1664
  if (flats.length === 0) return (0, brepjs.err)((0, brepjs.validationError)("NO_FLATS", "no planar panel pairs detected; not a recognizable sheet-metal solid"));
1604
- const layout = layoutForeign(flats, buildBendGraph(flats, pairBends(cylFaces, warnings), warnings), thickness, kFactor, warnings);
1665
+ const layout = layoutForeign(flats, buildBendGraph(flats, pairBends(cylFaces, thickness, warnings), warnings), thickness, kFactor, warnings);
1605
1666
  if (!layout.ok) return layout;
1606
1667
  return (0, brepjs.ok)({
1607
1668
  pattern: layout.value.pattern,
@@ -1609,73 +1670,116 @@ function unfoldForeignSolid(solid, opts) {
1609
1670
  warnings
1610
1671
  });
1611
1672
  }
1673
+ function describePlanar(face) {
1674
+ const center = (0, brepjs.faceCenter)(face);
1675
+ const n = 4;
1676
+ const candidates = [center];
1677
+ for (let i = 0; i < n; i += 1) for (let j = 0; j < n; j += 1) candidates.push((0, brepjs.pointOnSurface)(face, (i + .5) / n, (j + .5) / n));
1678
+ const interior = candidates.filter((p) => liesOnFace(face, p));
1679
+ return {
1680
+ face,
1681
+ normal: faceNormal(face),
1682
+ center,
1683
+ interior
1684
+ };
1685
+ }
1686
+ /** True if `point` (already on the face's surface) lies within the face's trimmed boundary. */
1687
+ function liesOnFace(face, point) {
1688
+ try {
1689
+ var _usingCtx$1 = _usingCtx();
1690
+ const probe = _usingCtx$1.u((0, brepjs.vertex)(point));
1691
+ const d = (0, brepjs.measureDistance)(probe, face);
1692
+ return d.ok && d.value < GEOM_TOL;
1693
+ } catch (_) {
1694
+ _usingCtx$1.e = _;
1695
+ } finally {
1696
+ _usingCtx$1.d();
1697
+ }
1698
+ }
1699
+ /** Normal-direction gap between two planar faces, or `null` unless they are antiparallel. */
1700
+ function antiparallelGap(a, b) {
1701
+ if ((0, brepjs.vecDot)(a.normal, b.normal) > -.99) return null;
1702
+ return Math.abs((0, brepjs.vecDot)((0, brepjs.vecSub)(b.center, a.center), a.normal));
1703
+ }
1704
+ /**
1705
+ * Antiparallel faces are the two sides of one panel only if they face each other:
1706
+ * an interior point of one projects inside the other. The normal-direction gap
1707
+ * alone also matches faces that merely sit on parallel planes, e.g. a flange's end
1708
+ * cap and the adjacent flange's inner face across a corner. Their gap is the bend's
1709
+ * inner radius, which passes for the sheet thickness as T approaches or exceeds R.
1710
+ */
1711
+ function facesOppose(a, b) {
1712
+ return b.interior.some((p) => liesOnFace(a.face, projectToPlane(p, a.normal, a.center))) || a.interior.some((p) => liesOnFace(b.face, projectToPlane(p, b.normal, b.center)));
1713
+ }
1612
1714
  /**
1613
- * Detect the sheet thickness as the smallest gap between an antiparallel pair of
1614
- * planar faces (the two faces of one flat panel). For a roughly-uniform-thickness
1615
- * part this gap is the same across panels; a spread beyond tolerance warns.
1715
+ * Detect the sheet thickness as the smallest gap between two antiparallel planar
1716
+ * faces that oppose each other (the two faces of one flat panel).
1616
1717
  */
1617
1718
  function detectThickness(planar) {
1618
- const gaps = [];
1719
+ const pairs = [];
1619
1720
  for (let i = 0; i < planar.length; i += 1) for (let j = i + 1; j < planar.length; j += 1) {
1620
- const fa = planar[i];
1621
- const fb = planar[j];
1622
- if (fa === void 0 || fb === void 0) continue;
1623
- const na = faceNormal(fa);
1624
- const nb = faceNormal(fb);
1625
- if ((0, brepjs.vecDot)(na, nb) > -.99) continue;
1626
- const ca = (0, brepjs.faceCenter)(fa);
1627
- const cb = (0, brepjs.faceCenter)(fb);
1628
- const gap = Math.abs((0, brepjs.vecDot)((0, brepjs.vecSub)(cb, ca), na));
1629
- if (gap > GEOM_TOL) gaps.push(gap);
1721
+ const a = planar[i];
1722
+ const b = planar[j];
1723
+ if (a === void 0 || b === void 0) continue;
1724
+ const gap = antiparallelGap(a, b);
1725
+ if (gap !== null && gap > GEOM_TOL) pairs.push({
1726
+ gap,
1727
+ a,
1728
+ b
1729
+ });
1630
1730
  }
1631
- if (gaps.length === 0) return (0, brepjs.err)((0, brepjs.validationError)("NO_THICKNESS", "could not detect a sheet thickness (no opposed planar pair)"));
1632
- gaps.sort((a, b) => a - b);
1633
- return (0, brepjs.ok)(gaps[0]);
1731
+ pairs.sort((p, q) => p.gap - q.gap);
1732
+ const thinnest = pairs.find((p) => facesOppose(p.a, p.b));
1733
+ if (thinnest === void 0) return (0, brepjs.err)((0, brepjs.validationError)("NO_THICKNESS", "could not detect a sheet thickness (no opposed planar pair)"));
1734
+ return (0, brepjs.ok)(thinnest.gap);
1735
+ }
1736
+ /** Unused face opposing `a` whose gap is nearest the sheet thickness, within tolerance. */
1737
+ function bestOpposedPartner(planar, i, thickness, used) {
1738
+ const a = planar[i];
1739
+ if (a === void 0) return null;
1740
+ const candidates = [];
1741
+ for (let j = 0; j < planar.length; j += 1) {
1742
+ const b = planar[j];
1743
+ if (j === i || used.has(j) || b === void 0) continue;
1744
+ const gap = antiparallelGap(a, b);
1745
+ if (gap === null) continue;
1746
+ const e = Math.abs(gap - thickness);
1747
+ if (e <= thickness * .5 + GEOM_TOL) candidates.push({
1748
+ j,
1749
+ e
1750
+ });
1751
+ }
1752
+ candidates.sort((p, q) => p.e - q.e);
1753
+ const match = candidates.find((c) => {
1754
+ const b = planar[c.j];
1755
+ return b !== void 0 && facesOppose(a, b);
1756
+ });
1757
+ return match === void 0 ? null : match.j;
1634
1758
  }
1635
1759
  /**
1636
- * Pair planar faces into flat panels: each panel is two parallel faces a
1637
- * `thickness` apart with overlapping footprints. Greedy nearest-match by gap.
1760
+ * Pair planar faces into flat panels: each panel is two antiparallel faces a
1761
+ * `thickness` apart that oppose each other. Greedy nearest-match by gap.
1638
1762
  */
1639
1763
  function pairFlats(planar, thickness, warnings) {
1640
1764
  const used = /* @__PURE__ */ new Set();
1641
1765
  const flats = [];
1642
1766
  for (let i = 0; i < planar.length; i += 1) {
1643
1767
  if (used.has(i)) continue;
1644
- const fa = planar[i];
1645
- if (fa === void 0) continue;
1646
- const na = faceNormal(fa);
1647
- const ca = (0, brepjs.faceCenter)(fa);
1648
- let best = -1;
1649
- let bestErr = Infinity;
1650
- for (let j = 0; j < planar.length; j += 1) {
1651
- if (j === i || used.has(j)) continue;
1652
- const fb = planar[j];
1653
- if (fb === void 0) continue;
1654
- const nb = faceNormal(fb);
1655
- if ((0, brepjs.vecDot)(na, nb) > -.99) continue;
1656
- const cb = (0, brepjs.faceCenter)(fb);
1657
- const gap = Math.abs((0, brepjs.vecDot)((0, brepjs.vecSub)(cb, ca), na));
1658
- const e = Math.abs(gap - thickness);
1659
- if (e < bestErr) {
1660
- bestErr = e;
1661
- best = j;
1662
- }
1663
- }
1664
- if (best < 0 || bestErr > thickness * .5 + GEOM_TOL) {
1665
- const db = (0, brepjs.getBounds)(fa);
1666
- const ext = [
1667
- db.xMax - db.xMin,
1668
- db.yMax - db.yMin,
1669
- db.zMax - db.zMin
1670
- ].sort((p, q) => q - p);
1671
- const inPlaneMin = ext[1] ?? 0;
1672
- if (inPlaneMin > thickness * 4) warnings.push({
1768
+ const pa = planar[i];
1769
+ if (pa === void 0) continue;
1770
+ const fa = pa.face;
1771
+ const best = bestOpposedPartner(planar, i, thickness, used);
1772
+ if (best === null) {
1773
+ const size = inPlaneSize(fa);
1774
+ if (size.short > thickness * 4) warnings.push({
1673
1775
  code: "DETECTION_INCOMPLETE",
1674
- 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)`
1776
+ message: `a panel-sized planar face (${size.long.toFixed(1)}×${size.short.toFixed(1)}) has no opposite face at the sheet thickness ${thickness.toFixed(3)}; omitted (possible non-uniform thickness)`
1675
1777
  });
1676
1778
  continue;
1677
1779
  }
1678
- const fb = planar[best];
1780
+ const pb = planar[best];
1781
+ if (pb === void 0) continue;
1782
+ const fb = pb.face;
1679
1783
  used.add(i);
1680
1784
  used.add(best);
1681
1785
  const areaA = (0, brepjs.measureArea)(fa);
@@ -1698,8 +1802,8 @@ function pairFlats(planar, thickness, warnings) {
1698
1802
  flats.push({
1699
1803
  id: `flat-${flats.length}`,
1700
1804
  faces: [fa, fb],
1701
- normal: na,
1702
- center: (0, brepjs.vecScale)((0, brepjs.vecAdd)(ca, (0, brepjs.faceCenter)(fb)), .5),
1805
+ normal: pa.normal,
1806
+ center: (0, brepjs.vecScale)((0, brepjs.vecAdd)(pa.center, pb.center), .5),
1703
1807
  area
1704
1808
  });
1705
1809
  }
@@ -1710,10 +1814,61 @@ function pairFlats(planar, thickness, warnings) {
1710
1814
  return flats;
1711
1815
  }
1712
1816
  /**
1817
+ * In-plane size of a planar face: the sides of the rectangle with the same area
1818
+ * and perimeter. Unlike a world-axis bounding box it ignores orientation, and a
1819
+ * strip of width w along any path (a merged side face tracing the whole bent
1820
+ * profile) measures w wide.
1821
+ */
1822
+ function inPlaneSize(face) {
1823
+ const area = (0, brepjs.measureArea)(face);
1824
+ let perimeter = 0;
1825
+ let measured = area.ok;
1826
+ for (const edge of (0, brepjs.getEdges)(face)) {
1827
+ const len = (0, brepjs.measureLength)(edge);
1828
+ if (len.ok) perimeter += len.value;
1829
+ else measured = false;
1830
+ }
1831
+ if (!measured || !area.ok) {
1832
+ const b = (0, brepjs.getBounds)(face);
1833
+ const ext = [
1834
+ b.xMax - b.xMin,
1835
+ b.yMax - b.yMin,
1836
+ b.zMax - b.zMin
1837
+ ].sort((p, q) => q - p);
1838
+ return {
1839
+ long: ext[0] ?? 0,
1840
+ short: ext[1] ?? 0
1841
+ };
1842
+ }
1843
+ const half = perimeter / 2;
1844
+ const disc = half * half - 4 * area.value;
1845
+ const short = disc > 0 ? (half - Math.sqrt(disc)) / 2 : half / 2;
1846
+ return {
1847
+ long: half - short,
1848
+ short
1849
+ };
1850
+ }
1851
+ /**
1852
+ * The inner and outer faces of one bend share an axis line, differ in radius by
1853
+ * the sheet thickness, and cover the same stretch of the axis. A shared axis line
1854
+ * alone also matches separate features that happen to line up, such as matching
1855
+ * corner rounds or holes on two parallel walls.
1856
+ */
1857
+ function isBendMate(a, b, thickness) {
1858
+ const axis = a.fit.axisDir;
1859
+ if (Math.abs((0, brepjs.vecDot)(axis, b.fit.axisDir)) < .99) return false;
1860
+ if (distanceToAxis(b.fit.axisOrigin, a.fit.axisOrigin, axis) > GEOM_TOL * 10) return false;
1861
+ const radiusGap = Math.abs(a.fit.radius - b.fit.radius);
1862
+ if (Math.abs(radiusGap - thickness) > thickness * .5 + GEOM_TOL) return false;
1863
+ const ra = axialRange(a.face, axis);
1864
+ const rb = axialRange(b.face, axis);
1865
+ return Math.min(ra.max, rb.max) - Math.max(ra.min, rb.min) > GEOM_TOL;
1866
+ }
1867
+ /**
1713
1868
  * Pair cylindrical faces into bends: each bend has an inner + outer coaxial face
1714
1869
  * (same axis, radii a thickness apart) or, failing a pair, a single fitted face.
1715
1870
  */
1716
- function pairBends(cyl, warnings) {
1871
+ function pairBends(cyl, thickness, warnings) {
1717
1872
  const fitted = [];
1718
1873
  for (const face of cyl) {
1719
1874
  const fit = fitCylinder(face);
@@ -1739,9 +1894,7 @@ function pairBends(cyl, warnings) {
1739
1894
  for (let j = i + 1; j < fitted.length; j += 1) {
1740
1895
  if (used.has(j)) continue;
1741
1896
  const b = fitted[j];
1742
- if (b === void 0) continue;
1743
- if (Math.abs((0, brepjs.vecDot)(a.fit.axisDir, b.fit.axisDir)) < .99) continue;
1744
- if (distanceToAxis(b.fit.axisOrigin, a.fit.axisOrigin, a.fit.axisDir) > GEOM_TOL * 10) continue;
1897
+ if (b === void 0 || !isBendMate(a, b, thickness)) continue;
1745
1898
  mate = j;
1746
1899
  break;
1747
1900
  }
@@ -1886,20 +2039,21 @@ function layoutForeign(flats, graph, thickness, kFactor, warnings) {
1886
2039
  const axis = bend.fit.axisDir;
1887
2040
  const span = bendSpan.get(bend) ?? bendAxialExtent(bend);
1888
2041
  const placement = placeChildFlat(parentPlace, child, bend, axis, span, dev.value);
2042
+ const bendLine = segment2(placement.bendA, placement.bendB);
2043
+ if (bendLine === null) {
2044
+ warnings.push({
2045
+ code: "DETECTION_INCOMPLETE",
2046
+ message: `bend '${bend.id}' has no length along ${parent.id} (zero span, or an axis that crosses the flat instead of lying in it); ${child.id} and the flats beyond it are left out of the layout`,
2047
+ featureId: bend.id
2048
+ });
2049
+ continue;
2050
+ }
1889
2051
  placed.set(tb.child, placement.child);
1890
2052
  rects.push(placement.strip);
1891
2053
  rects.push(flatRect2(placement.child));
1892
2054
  bendLines.push({
1893
2055
  id: bend.id,
1894
- line: (0, brepjs.line)([
1895
- placement.bendA[0],
1896
- placement.bendA[1],
1897
- 0
1898
- ], [
1899
- placement.bendB[0],
1900
- placement.bendB[1],
1901
- 0
1902
- ]),
2056
+ line: bendLine,
1903
2057
  angleDeg: bend.angleDeg,
1904
2058
  direction,
1905
2059
  inward: [-placement.outDir2[0], -placement.outDir2[1]]
@@ -1916,7 +2070,8 @@ function layoutForeign(flats, graph, thickness, kFactor, warnings) {
1916
2070
  }
1917
2071
  const bbox = aabb2(rects);
1918
2072
  const outline = buildRectOutline2(bbox);
1919
- const outlineWire = (0, brepjs.wireLoop)(outline);
2073
+ if (!outline.ok) return outline;
2074
+ const outlineWire = (0, brepjs.wireLoop)(outline.value);
1920
2075
  if (!outlineWire.ok) return outlineWire;
1921
2076
  const pattern = {
1922
2077
  outline: outlineWire.value,
@@ -2109,8 +2264,30 @@ function aabb2(rects) {
2109
2264
  y1
2110
2265
  };
2111
2266
  }
2267
+ /** A 2D segment as an edge, or `null` when it is too short or not finite to build one. */
2268
+ function segment2(a, b) {
2269
+ const len = Math.hypot(b[0] - a[0], b[1] - a[1]);
2270
+ if (!Number.isFinite(len) || len < GEOM_TOL) return null;
2271
+ return (0, brepjs.line)([
2272
+ a[0],
2273
+ a[1],
2274
+ 0
2275
+ ], [
2276
+ b[0],
2277
+ b[1],
2278
+ 0
2279
+ ]);
2280
+ }
2112
2281
  function buildRectOutline2(b) {
2113
- return [
2282
+ const w = b.x1 - b.x0;
2283
+ const h = b.y1 - b.y0;
2284
+ if (![
2285
+ b.x0,
2286
+ b.y0,
2287
+ b.x1,
2288
+ b.y1
2289
+ ].every(Number.isFinite) || w < GEOM_TOL || h < GEOM_TOL) return (0, brepjs.err)((0, brepjs.validationError)("DEGENERATE_LAYOUT", `the developed outline is degenerate (${w}×${h})`));
2290
+ return (0, brepjs.ok)([
2114
2291
  (0, brepjs.line)([
2115
2292
  b.x0,
2116
2293
  b.y0,
@@ -2147,21 +2324,27 @@ function buildRectOutline2(b) {
2147
2324
  b.y0,
2148
2325
  0
2149
2326
  ])
2150
- ];
2327
+ ]);
2151
2328
  }
2152
- /** Axial extent of a bend (its cylindrical face span along the fitted axis). */
2153
- function bendAxialExtent(bend) {
2154
- const face = bend.faces[0];
2155
- if (face === void 0) return 0;
2156
- const axis = bend.fit.axisDir;
2157
- const samples = sampleFace(face, 6);
2329
+ /** Range of a face's sampled points along `axis`. */
2330
+ function axialRange(face, axis) {
2158
2331
  let min = Infinity;
2159
2332
  let max = -Infinity;
2160
- for (const s of samples) {
2333
+ for (const s of sampleFace(face, 6)) {
2161
2334
  const a = (0, brepjs.vecDot)(s.point, axis);
2162
2335
  min = Math.min(min, a);
2163
2336
  max = Math.max(max, a);
2164
2337
  }
2338
+ return {
2339
+ min,
2340
+ max
2341
+ };
2342
+ }
2343
+ /** Axial extent of a bend (its cylindrical face span along the fitted axis). */
2344
+ function bendAxialExtent(bend) {
2345
+ const face = bend.faces[0];
2346
+ if (face === void 0) return 0;
2347
+ const { min, max } = axialRange(face, bend.fit.axisDir);
2165
2348
  return Number.isFinite(max - min) ? max - min : 0;
2166
2349
  }
2167
2350
  /**
@@ -1,4 +1,4 @@
1
- import { box, curveEndPoint, curveStartPoint, cut, cylinder, err, extrude, face, faceCenter, fuse, getBounds, getEdges, getFaces, getSolids, getSurfaceType, intersect, isErr, isOk, isPlanarWire, isSolid, isValid, line, measureArea, measureVolume, normalAt, ok, pointOnSurface, rotate, sharedEdges, translate, validationError, vecAdd, vecCross, vecDot, vecLength, vecNormalize, vecScale, vecSub, wire, wireLoop } from "brepjs";
1
+ import { box, curveEndPoint, curveStartPoint, cut, cylinder, err, extrude, face, faceCenter, fuse, getBounds, getEdges, getFaces, getSolids, getSurfaceType, intersect, isErr, isOk, isPlanarWire, isSolid, isValid, line, measureArea, measureDistance, measureLength, measureVolume, normalAt, ok, pointOnSurface, rotate, sharedEdges, translate, validationError, vecAdd, vecCross, vecDot, vecLength, vecNormalize, vecScale, vecSub, vertex, wire, wireLoop } from "brepjs";
2
2
  // src/materials.ts
3
3
  var DEFAULT_K_FACTOR = .44;
4
4
  function gauge(name, thickness, kFactor = DEFAULT_K_FACTOR) {
@@ -1285,6 +1285,61 @@ function reportFromLayout(layout) {
1285
1285
  totalFlatSize: [maxX - minX, maxY - minY]
1286
1286
  };
1287
1287
  }
1288
+ // \0@oxc-project+runtime@0.155.0/helpers/esm/usingCtx.js
1289
+ function _usingCtx() {
1290
+ var r = "function" == typeof SuppressedError ? SuppressedError : function(r, e) {
1291
+ var n = Error();
1292
+ return n.name = "SuppressedError", n.error = r, n.suppressed = e, n;
1293
+ }, e = {}, n = [];
1294
+ function using(r, e) {
1295
+ if (null != e) {
1296
+ if (Object(e) !== e) throw new TypeError("using declarations can only be used with objects, functions, null, or undefined.");
1297
+ if (r) var o = e[Symbol.asyncDispose || Symbol["for"]("Symbol.asyncDispose")];
1298
+ if (void 0 === o && (o = e[Symbol.dispose || Symbol["for"]("Symbol.dispose")], r)) var t = o;
1299
+ if ("function" != typeof o) throw new TypeError("Object is not disposable.");
1300
+ t && (o = function o() {
1301
+ try {
1302
+ t.call(e);
1303
+ } catch (r) {
1304
+ return Promise.reject(r);
1305
+ }
1306
+ }), n.push({
1307
+ v: e,
1308
+ d: o,
1309
+ a: r
1310
+ });
1311
+ } else r && n.push({
1312
+ d: e,
1313
+ a: r
1314
+ });
1315
+ return e;
1316
+ }
1317
+ return {
1318
+ e,
1319
+ u: using.bind(null, !1),
1320
+ a: using.bind(null, !0),
1321
+ d: function d() {
1322
+ var o, t = this.e, s = 0;
1323
+ function next() {
1324
+ for (; o = n.pop();) try {
1325
+ if (!o.a && 1 === s) return s = 0, n.push(o), Promise.resolve().then(next);
1326
+ if (o.d) {
1327
+ var r = o.d.call(o.v);
1328
+ if (o.a) return s |= 2, Promise.resolve(r).then(next, err);
1329
+ } else s |= 1;
1330
+ } catch (r) {
1331
+ return err(r);
1332
+ }
1333
+ if (1 === s) return t !== e ? Promise.reject(t) : Promise.resolve();
1334
+ if (t !== e) throw t;
1335
+ }
1336
+ function err(n) {
1337
+ return t = t !== e ? new r(n, t) : n, next();
1338
+ }
1339
+ return next();
1340
+ }
1341
+ };
1342
+ }
1288
1343
  // src/foreignUnfoldFns.ts
1289
1344
  /** Default neutral-axis K-factor for a purely geometric unfold (mid-surface). */
1290
1345
  var DEFAULT_FOREIGN_K = .5;
@@ -1337,15 +1392,20 @@ function fitCylinder(face) {
1337
1392
  const axisDir = axisFromNormals(samples.map((s) => s.normal));
1338
1393
  if (axisDir === null) return null;
1339
1394
  const basis = perpBasis(axisDir);
1340
- const pts2 = samples.map((s) => [vecDot(s.point, basis.e1), vecDot(s.point, basis.e2)]);
1341
- const circle = fitCircle2(pts2);
1342
- if (circle === null) return null;
1343
- const axial = vecDot(samples[0]?.point ?? [
1395
+ let centroid = [
1344
1396
  0,
1345
1397
  0,
1346
1398
  0
1347
- ], axisDir);
1348
- const axisOrigin = vecAdd(vecAdd(vecScale(basis.e1, circle.cx), vecScale(basis.e2, circle.cy)), vecScale(axisDir, axial));
1399
+ ];
1400
+ for (const s of samples) centroid = vecAdd(centroid, s.point);
1401
+ centroid = vecScale(centroid, 1 / samples.length);
1402
+ const pts2 = samples.map((s) => {
1403
+ const d = vecSub(s.point, centroid);
1404
+ return [vecDot(d, basis.e1), vecDot(d, basis.e2)];
1405
+ });
1406
+ const circle = fitCircle2(pts2);
1407
+ if (circle === null) return null;
1408
+ const axisOrigin = vecAdd(centroid, vecAdd(vecScale(basis.e1, circle.cx), vecScale(basis.e2, circle.cy)));
1349
1409
  let sumR = 0;
1350
1410
  for (const s of samples) sumR += distanceToAxis(s.point, axisOrigin, axisDir);
1351
1411
  const radius = sumR / samples.length;
@@ -1595,12 +1655,13 @@ function unfoldForeignSolid(solid, opts) {
1595
1655
  message: `UNSUPPORTED_FACE: surface type '${t.value}' is not a planar panel or cylindrical bend; the unfold ignores it and may be incomplete`
1596
1656
  });
1597
1657
  }
1598
- const thicknessResult = detectThickness(planarFaces);
1658
+ const planar = planarFaces.map(describePlanar);
1659
+ const thicknessResult = detectThickness(planar);
1599
1660
  if (!thicknessResult.ok) return thicknessResult;
1600
1661
  const thickness = thicknessResult.value;
1601
- const flats = pairFlats(planarFaces, thickness, warnings);
1662
+ const flats = pairFlats(planar, thickness, warnings);
1602
1663
  if (flats.length === 0) return err(validationError("NO_FLATS", "no planar panel pairs detected; not a recognizable sheet-metal solid"));
1603
- const layout = layoutForeign(flats, buildBendGraph(flats, pairBends(cylFaces, warnings), warnings), thickness, kFactor, warnings);
1664
+ const layout = layoutForeign(flats, buildBendGraph(flats, pairBends(cylFaces, thickness, warnings), warnings), thickness, kFactor, warnings);
1604
1665
  if (!layout.ok) return layout;
1605
1666
  return ok({
1606
1667
  pattern: layout.value.pattern,
@@ -1608,73 +1669,116 @@ function unfoldForeignSolid(solid, opts) {
1608
1669
  warnings
1609
1670
  });
1610
1671
  }
1672
+ function describePlanar(face) {
1673
+ const center = faceCenter(face);
1674
+ const n = 4;
1675
+ const candidates = [center];
1676
+ for (let i = 0; i < n; i += 1) for (let j = 0; j < n; j += 1) candidates.push(pointOnSurface(face, (i + .5) / n, (j + .5) / n));
1677
+ const interior = candidates.filter((p) => liesOnFace(face, p));
1678
+ return {
1679
+ face,
1680
+ normal: faceNormal(face),
1681
+ center,
1682
+ interior
1683
+ };
1684
+ }
1685
+ /** True if `point` (already on the face's surface) lies within the face's trimmed boundary. */
1686
+ function liesOnFace(face, point) {
1687
+ try {
1688
+ var _usingCtx$1 = _usingCtx();
1689
+ const probe = _usingCtx$1.u(vertex(point));
1690
+ const d = measureDistance(probe, face);
1691
+ return d.ok && d.value < GEOM_TOL;
1692
+ } catch (_) {
1693
+ _usingCtx$1.e = _;
1694
+ } finally {
1695
+ _usingCtx$1.d();
1696
+ }
1697
+ }
1698
+ /** Normal-direction gap between two planar faces, or `null` unless they are antiparallel. */
1699
+ function antiparallelGap(a, b) {
1700
+ if (vecDot(a.normal, b.normal) > -.99) return null;
1701
+ return Math.abs(vecDot(vecSub(b.center, a.center), a.normal));
1702
+ }
1703
+ /**
1704
+ * Antiparallel faces are the two sides of one panel only if they face each other:
1705
+ * an interior point of one projects inside the other. The normal-direction gap
1706
+ * alone also matches faces that merely sit on parallel planes, e.g. a flange's end
1707
+ * cap and the adjacent flange's inner face across a corner. Their gap is the bend's
1708
+ * inner radius, which passes for the sheet thickness as T approaches or exceeds R.
1709
+ */
1710
+ function facesOppose(a, b) {
1711
+ return b.interior.some((p) => liesOnFace(a.face, projectToPlane(p, a.normal, a.center))) || a.interior.some((p) => liesOnFace(b.face, projectToPlane(p, b.normal, b.center)));
1712
+ }
1611
1713
  /**
1612
- * Detect the sheet thickness as the smallest gap between an antiparallel pair of
1613
- * planar faces (the two faces of one flat panel). For a roughly-uniform-thickness
1614
- * part this gap is the same across panels; a spread beyond tolerance warns.
1714
+ * Detect the sheet thickness as the smallest gap between two antiparallel planar
1715
+ * faces that oppose each other (the two faces of one flat panel).
1615
1716
  */
1616
1717
  function detectThickness(planar) {
1617
- const gaps = [];
1718
+ const pairs = [];
1618
1719
  for (let i = 0; i < planar.length; i += 1) for (let j = i + 1; j < planar.length; j += 1) {
1619
- const fa = planar[i];
1620
- const fb = planar[j];
1621
- if (fa === void 0 || fb === void 0) continue;
1622
- const na = faceNormal(fa);
1623
- const nb = faceNormal(fb);
1624
- if (vecDot(na, nb) > -.99) continue;
1625
- const ca = faceCenter(fa);
1626
- const cb = faceCenter(fb);
1627
- const gap = Math.abs(vecDot(vecSub(cb, ca), na));
1628
- if (gap > GEOM_TOL) gaps.push(gap);
1720
+ const a = planar[i];
1721
+ const b = planar[j];
1722
+ if (a === void 0 || b === void 0) continue;
1723
+ const gap = antiparallelGap(a, b);
1724
+ if (gap !== null && gap > GEOM_TOL) pairs.push({
1725
+ gap,
1726
+ a,
1727
+ b
1728
+ });
1629
1729
  }
1630
- if (gaps.length === 0) return err(validationError("NO_THICKNESS", "could not detect a sheet thickness (no opposed planar pair)"));
1631
- gaps.sort((a, b) => a - b);
1632
- return ok(gaps[0]);
1730
+ pairs.sort((p, q) => p.gap - q.gap);
1731
+ const thinnest = pairs.find((p) => facesOppose(p.a, p.b));
1732
+ if (thinnest === void 0) return err(validationError("NO_THICKNESS", "could not detect a sheet thickness (no opposed planar pair)"));
1733
+ return ok(thinnest.gap);
1734
+ }
1735
+ /** Unused face opposing `a` whose gap is nearest the sheet thickness, within tolerance. */
1736
+ function bestOpposedPartner(planar, i, thickness, used) {
1737
+ const a = planar[i];
1738
+ if (a === void 0) return null;
1739
+ const candidates = [];
1740
+ for (let j = 0; j < planar.length; j += 1) {
1741
+ const b = planar[j];
1742
+ if (j === i || used.has(j) || b === void 0) continue;
1743
+ const gap = antiparallelGap(a, b);
1744
+ if (gap === null) continue;
1745
+ const e = Math.abs(gap - thickness);
1746
+ if (e <= thickness * .5 + GEOM_TOL) candidates.push({
1747
+ j,
1748
+ e
1749
+ });
1750
+ }
1751
+ candidates.sort((p, q) => p.e - q.e);
1752
+ const match = candidates.find((c) => {
1753
+ const b = planar[c.j];
1754
+ return b !== void 0 && facesOppose(a, b);
1755
+ });
1756
+ return match === void 0 ? null : match.j;
1633
1757
  }
1634
1758
  /**
1635
- * Pair planar faces into flat panels: each panel is two parallel faces a
1636
- * `thickness` apart with overlapping footprints. Greedy nearest-match by gap.
1759
+ * Pair planar faces into flat panels: each panel is two antiparallel faces a
1760
+ * `thickness` apart that oppose each other. Greedy nearest-match by gap.
1637
1761
  */
1638
1762
  function pairFlats(planar, thickness, warnings) {
1639
1763
  const used = /* @__PURE__ */ new Set();
1640
1764
  const flats = [];
1641
1765
  for (let i = 0; i < planar.length; i += 1) {
1642
1766
  if (used.has(i)) continue;
1643
- const fa = planar[i];
1644
- if (fa === void 0) continue;
1645
- const na = faceNormal(fa);
1646
- const ca = faceCenter(fa);
1647
- let best = -1;
1648
- let bestErr = Infinity;
1649
- for (let j = 0; j < planar.length; j += 1) {
1650
- if (j === i || used.has(j)) continue;
1651
- const fb = planar[j];
1652
- if (fb === void 0) continue;
1653
- const nb = faceNormal(fb);
1654
- if (vecDot(na, nb) > -.99) continue;
1655
- const cb = faceCenter(fb);
1656
- const gap = Math.abs(vecDot(vecSub(cb, ca), na));
1657
- const e = Math.abs(gap - thickness);
1658
- if (e < bestErr) {
1659
- bestErr = e;
1660
- best = j;
1661
- }
1662
- }
1663
- if (best < 0 || bestErr > thickness * .5 + GEOM_TOL) {
1664
- const db = getBounds(fa);
1665
- const ext = [
1666
- db.xMax - db.xMin,
1667
- db.yMax - db.yMin,
1668
- db.zMax - db.zMin
1669
- ].sort((p, q) => q - p);
1670
- const inPlaneMin = ext[1] ?? 0;
1671
- if (inPlaneMin > thickness * 4) warnings.push({
1767
+ const pa = planar[i];
1768
+ if (pa === void 0) continue;
1769
+ const fa = pa.face;
1770
+ const best = bestOpposedPartner(planar, i, thickness, used);
1771
+ if (best === null) {
1772
+ const size = inPlaneSize(fa);
1773
+ if (size.short > thickness * 4) warnings.push({
1672
1774
  code: "DETECTION_INCOMPLETE",
1673
- 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)`
1775
+ message: `a panel-sized planar face (${size.long.toFixed(1)}×${size.short.toFixed(1)}) has no opposite face at the sheet thickness ${thickness.toFixed(3)}; omitted (possible non-uniform thickness)`
1674
1776
  });
1675
1777
  continue;
1676
1778
  }
1677
- const fb = planar[best];
1779
+ const pb = planar[best];
1780
+ if (pb === void 0) continue;
1781
+ const fb = pb.face;
1678
1782
  used.add(i);
1679
1783
  used.add(best);
1680
1784
  const areaA = measureArea(fa);
@@ -1697,8 +1801,8 @@ function pairFlats(planar, thickness, warnings) {
1697
1801
  flats.push({
1698
1802
  id: `flat-${flats.length}`,
1699
1803
  faces: [fa, fb],
1700
- normal: na,
1701
- center: vecScale(vecAdd(ca, faceCenter(fb)), .5),
1804
+ normal: pa.normal,
1805
+ center: vecScale(vecAdd(pa.center, pb.center), .5),
1702
1806
  area
1703
1807
  });
1704
1808
  }
@@ -1709,10 +1813,61 @@ function pairFlats(planar, thickness, warnings) {
1709
1813
  return flats;
1710
1814
  }
1711
1815
  /**
1816
+ * In-plane size of a planar face: the sides of the rectangle with the same area
1817
+ * and perimeter. Unlike a world-axis bounding box it ignores orientation, and a
1818
+ * strip of width w along any path (a merged side face tracing the whole bent
1819
+ * profile) measures w wide.
1820
+ */
1821
+ function inPlaneSize(face) {
1822
+ const area = measureArea(face);
1823
+ let perimeter = 0;
1824
+ let measured = area.ok;
1825
+ for (const edge of getEdges(face)) {
1826
+ const len = measureLength(edge);
1827
+ if (len.ok) perimeter += len.value;
1828
+ else measured = false;
1829
+ }
1830
+ if (!measured || !area.ok) {
1831
+ const b = getBounds(face);
1832
+ const ext = [
1833
+ b.xMax - b.xMin,
1834
+ b.yMax - b.yMin,
1835
+ b.zMax - b.zMin
1836
+ ].sort((p, q) => q - p);
1837
+ return {
1838
+ long: ext[0] ?? 0,
1839
+ short: ext[1] ?? 0
1840
+ };
1841
+ }
1842
+ const half = perimeter / 2;
1843
+ const disc = half * half - 4 * area.value;
1844
+ const short = disc > 0 ? (half - Math.sqrt(disc)) / 2 : half / 2;
1845
+ return {
1846
+ long: half - short,
1847
+ short
1848
+ };
1849
+ }
1850
+ /**
1851
+ * The inner and outer faces of one bend share an axis line, differ in radius by
1852
+ * the sheet thickness, and cover the same stretch of the axis. A shared axis line
1853
+ * alone also matches separate features that happen to line up, such as matching
1854
+ * corner rounds or holes on two parallel walls.
1855
+ */
1856
+ function isBendMate(a, b, thickness) {
1857
+ const axis = a.fit.axisDir;
1858
+ if (Math.abs(vecDot(axis, b.fit.axisDir)) < .99) return false;
1859
+ if (distanceToAxis(b.fit.axisOrigin, a.fit.axisOrigin, axis) > GEOM_TOL * 10) return false;
1860
+ const radiusGap = Math.abs(a.fit.radius - b.fit.radius);
1861
+ if (Math.abs(radiusGap - thickness) > thickness * .5 + GEOM_TOL) return false;
1862
+ const ra = axialRange(a.face, axis);
1863
+ const rb = axialRange(b.face, axis);
1864
+ return Math.min(ra.max, rb.max) - Math.max(ra.min, rb.min) > GEOM_TOL;
1865
+ }
1866
+ /**
1712
1867
  * Pair cylindrical faces into bends: each bend has an inner + outer coaxial face
1713
1868
  * (same axis, radii a thickness apart) or, failing a pair, a single fitted face.
1714
1869
  */
1715
- function pairBends(cyl, warnings) {
1870
+ function pairBends(cyl, thickness, warnings) {
1716
1871
  const fitted = [];
1717
1872
  for (const face of cyl) {
1718
1873
  const fit = fitCylinder(face);
@@ -1738,9 +1893,7 @@ function pairBends(cyl, warnings) {
1738
1893
  for (let j = i + 1; j < fitted.length; j += 1) {
1739
1894
  if (used.has(j)) continue;
1740
1895
  const b = fitted[j];
1741
- if (b === void 0) continue;
1742
- if (Math.abs(vecDot(a.fit.axisDir, b.fit.axisDir)) < .99) continue;
1743
- if (distanceToAxis(b.fit.axisOrigin, a.fit.axisOrigin, a.fit.axisDir) > GEOM_TOL * 10) continue;
1896
+ if (b === void 0 || !isBendMate(a, b, thickness)) continue;
1744
1897
  mate = j;
1745
1898
  break;
1746
1899
  }
@@ -1885,20 +2038,21 @@ function layoutForeign(flats, graph, thickness, kFactor, warnings) {
1885
2038
  const axis = bend.fit.axisDir;
1886
2039
  const span = bendSpan.get(bend) ?? bendAxialExtent(bend);
1887
2040
  const placement = placeChildFlat(parentPlace, child, bend, axis, span, dev.value);
2041
+ const bendLine = segment2(placement.bendA, placement.bendB);
2042
+ if (bendLine === null) {
2043
+ warnings.push({
2044
+ code: "DETECTION_INCOMPLETE",
2045
+ message: `bend '${bend.id}' has no length along ${parent.id} (zero span, or an axis that crosses the flat instead of lying in it); ${child.id} and the flats beyond it are left out of the layout`,
2046
+ featureId: bend.id
2047
+ });
2048
+ continue;
2049
+ }
1888
2050
  placed.set(tb.child, placement.child);
1889
2051
  rects.push(placement.strip);
1890
2052
  rects.push(flatRect2(placement.child));
1891
2053
  bendLines.push({
1892
2054
  id: bend.id,
1893
- line: line([
1894
- placement.bendA[0],
1895
- placement.bendA[1],
1896
- 0
1897
- ], [
1898
- placement.bendB[0],
1899
- placement.bendB[1],
1900
- 0
1901
- ]),
2055
+ line: bendLine,
1902
2056
  angleDeg: bend.angleDeg,
1903
2057
  direction,
1904
2058
  inward: [-placement.outDir2[0], -placement.outDir2[1]]
@@ -1915,7 +2069,8 @@ function layoutForeign(flats, graph, thickness, kFactor, warnings) {
1915
2069
  }
1916
2070
  const bbox = aabb2(rects);
1917
2071
  const outline = buildRectOutline2(bbox);
1918
- const outlineWire = wireLoop(outline);
2072
+ if (!outline.ok) return outline;
2073
+ const outlineWire = wireLoop(outline.value);
1919
2074
  if (!outlineWire.ok) return outlineWire;
1920
2075
  const pattern = {
1921
2076
  outline: outlineWire.value,
@@ -2108,8 +2263,30 @@ function aabb2(rects) {
2108
2263
  y1
2109
2264
  };
2110
2265
  }
2266
+ /** A 2D segment as an edge, or `null` when it is too short or not finite to build one. */
2267
+ function segment2(a, b) {
2268
+ const len = Math.hypot(b[0] - a[0], b[1] - a[1]);
2269
+ if (!Number.isFinite(len) || len < GEOM_TOL) return null;
2270
+ return line([
2271
+ a[0],
2272
+ a[1],
2273
+ 0
2274
+ ], [
2275
+ b[0],
2276
+ b[1],
2277
+ 0
2278
+ ]);
2279
+ }
2111
2280
  function buildRectOutline2(b) {
2112
- return [
2281
+ const w = b.x1 - b.x0;
2282
+ const h = b.y1 - b.y0;
2283
+ if (![
2284
+ b.x0,
2285
+ b.y0,
2286
+ b.x1,
2287
+ b.y1
2288
+ ].every(Number.isFinite) || w < GEOM_TOL || h < GEOM_TOL) return err(validationError("DEGENERATE_LAYOUT", `the developed outline is degenerate (${w}×${h})`));
2289
+ return ok([
2113
2290
  line([
2114
2291
  b.x0,
2115
2292
  b.y0,
@@ -2146,21 +2323,27 @@ function buildRectOutline2(b) {
2146
2323
  b.y0,
2147
2324
  0
2148
2325
  ])
2149
- ];
2326
+ ]);
2150
2327
  }
2151
- /** Axial extent of a bend (its cylindrical face span along the fitted axis). */
2152
- function bendAxialExtent(bend) {
2153
- const face = bend.faces[0];
2154
- if (face === void 0) return 0;
2155
- const axis = bend.fit.axisDir;
2156
- const samples = sampleFace(face, 6);
2328
+ /** Range of a face's sampled points along `axis`. */
2329
+ function axialRange(face, axis) {
2157
2330
  let min = Infinity;
2158
2331
  let max = -Infinity;
2159
- for (const s of samples) {
2332
+ for (const s of sampleFace(face, 6)) {
2160
2333
  const a = vecDot(s.point, axis);
2161
2334
  min = Math.min(min, a);
2162
2335
  max = Math.max(max, a);
2163
2336
  }
2337
+ return {
2338
+ min,
2339
+ max
2340
+ };
2341
+ }
2342
+ /** Axial extent of a bend (its cylindrical face span along the fitted axis). */
2343
+ function bendAxialExtent(bend) {
2344
+ const face = bend.faces[0];
2345
+ if (face === void 0) return 0;
2346
+ const { min, max } = axialRange(face, bend.fit.axisDir);
2164
2347
  return Number.isFinite(max - min) ? max - min : 0;
2165
2348
  }
2166
2349
  /**
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "brepjs-sheetmetal",
3
- "version": "0.4.0",
3
+ "version": "0.4.2",
4
4
  "description": "Sheet-metal CAD domain for brepjs — flange authoring, auto-miter, fold/unfold flat patterns",
5
5
  "keywords": [
6
6
  "sheet-metal",