brepjs-sheetmetal 0.4.1 → 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
@@ -307,9 +307,13 @@ authored path. It reads only the B-rep:
307
307
  - **Fit bends**: `fitCylinder(face)` recovers a bend's axis + radius + swept angle from sampled points and
308
308
  normals. The axis direction is the common perpendicular of the sampled normals (every cylinder normal is
309
309
  ⟂ the axis), recovered as the sign-aligned average of cross products of non-parallel normal pairs; the
310
- axis line and radius come from a least-squares circle fit of the points projected onto the plane ⟂ axis;
311
- the radius is the mean point-to-axis distance. A fit whose residual exceeds tolerance is rejected (warned).
312
- 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.
313
317
  - **Build the bend graph** (flats = nodes; a bend connects the two flats it shares edges with), take a
314
318
  spanning tree from a root flat, and turn non-tree bends into seam cuts (`SEAM_CUT`, warned).
315
319
  - **Unfold** by walking the tree, replacing each cylindrical region with a developed strip of length
@@ -324,8 +328,9 @@ fit a cylinder within tolerance all surface as warnings inside the `Ok` payload
324
328
  sheet-metal structure is detected at all, e.g. a bare sphere). The detection is validated by an oracle:
325
329
  author a part, take only its `.solid`, run `unfoldSolid`, and confirm the detected unfold reproduces the
326
330
  authored unfold's developed area, bend count, flat count, and total flat bbox with no warnings (single bend,
327
- plus the L-bracket, U-channel, and four-sided tray at thicknesses 1, 1.5, 2, and 3 against a 2 mm inner
328
- radius), reading only the solid, never the feature tree.
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.
329
334
 
330
335
  ## Bend tables
331
336
 
@@ -1393,15 +1393,20 @@ function fitCylinder(face) {
1393
1393
  const axisDir = axisFromNormals(samples.map((s) => s.normal));
1394
1394
  if (axisDir === null) return null;
1395
1395
  const basis = perpBasis(axisDir);
1396
- const pts2 = samples.map((s) => [(0, brepjs.vecDot)(s.point, basis.e1), (0, brepjs.vecDot)(s.point, basis.e2)]);
1397
- const circle = fitCircle2(pts2);
1398
- if (circle === null) return null;
1399
- const axial = (0, brepjs.vecDot)(samples[0]?.point ?? [
1396
+ let centroid = [
1400
1397
  0,
1401
1398
  0,
1402
1399
  0
1403
- ], axisDir);
1404
- 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)));
1405
1410
  let sumR = 0;
1406
1411
  for (const s of samples) sumR += distanceToAxis(s.point, axisOrigin, axisDir);
1407
1412
  const radius = sumR / samples.length;
@@ -1657,7 +1662,7 @@ function unfoldForeignSolid(solid, opts) {
1657
1662
  const thickness = thicknessResult.value;
1658
1663
  const flats = pairFlats(planar, thickness, warnings);
1659
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"));
1660
- 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);
1661
1666
  if (!layout.ok) return layout;
1662
1667
  return (0, brepjs.ok)({
1663
1668
  pattern: layout.value.pattern,
@@ -1765,16 +1770,10 @@ function pairFlats(planar, thickness, warnings) {
1765
1770
  const fa = pa.face;
1766
1771
  const best = bestOpposedPartner(planar, i, thickness, used);
1767
1772
  if (best === null) {
1768
- const db = (0, brepjs.getBounds)(fa);
1769
- const ext = [
1770
- db.xMax - db.xMin,
1771
- db.yMax - db.yMin,
1772
- db.zMax - db.zMin
1773
- ].sort((p, q) => q - p);
1774
- const inPlaneMin = ext[1] ?? 0;
1775
- if (inPlaneMin > thickness * 4) warnings.push({
1773
+ const size = inPlaneSize(fa);
1774
+ if (size.short > thickness * 4) warnings.push({
1776
1775
  code: "DETECTION_INCOMPLETE",
1777
- 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)`
1778
1777
  });
1779
1778
  continue;
1780
1779
  }
@@ -1815,10 +1814,61 @@ function pairFlats(planar, thickness, warnings) {
1815
1814
  return flats;
1816
1815
  }
1817
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
+ /**
1818
1868
  * Pair cylindrical faces into bends: each bend has an inner + outer coaxial face
1819
1869
  * (same axis, radii a thickness apart) or, failing a pair, a single fitted face.
1820
1870
  */
1821
- function pairBends(cyl, warnings) {
1871
+ function pairBends(cyl, thickness, warnings) {
1822
1872
  const fitted = [];
1823
1873
  for (const face of cyl) {
1824
1874
  const fit = fitCylinder(face);
@@ -1844,9 +1894,7 @@ function pairBends(cyl, warnings) {
1844
1894
  for (let j = i + 1; j < fitted.length; j += 1) {
1845
1895
  if (used.has(j)) continue;
1846
1896
  const b = fitted[j];
1847
- if (b === void 0) continue;
1848
- if (Math.abs((0, brepjs.vecDot)(a.fit.axisDir, b.fit.axisDir)) < .99) continue;
1849
- 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;
1850
1898
  mate = j;
1851
1899
  break;
1852
1900
  }
@@ -1991,20 +2039,21 @@ function layoutForeign(flats, graph, thickness, kFactor, warnings) {
1991
2039
  const axis = bend.fit.axisDir;
1992
2040
  const span = bendSpan.get(bend) ?? bendAxialExtent(bend);
1993
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
+ }
1994
2051
  placed.set(tb.child, placement.child);
1995
2052
  rects.push(placement.strip);
1996
2053
  rects.push(flatRect2(placement.child));
1997
2054
  bendLines.push({
1998
2055
  id: bend.id,
1999
- line: (0, brepjs.line)([
2000
- placement.bendA[0],
2001
- placement.bendA[1],
2002
- 0
2003
- ], [
2004
- placement.bendB[0],
2005
- placement.bendB[1],
2006
- 0
2007
- ]),
2056
+ line: bendLine,
2008
2057
  angleDeg: bend.angleDeg,
2009
2058
  direction,
2010
2059
  inward: [-placement.outDir2[0], -placement.outDir2[1]]
@@ -2021,7 +2070,8 @@ function layoutForeign(flats, graph, thickness, kFactor, warnings) {
2021
2070
  }
2022
2071
  const bbox = aabb2(rects);
2023
2072
  const outline = buildRectOutline2(bbox);
2024
- const outlineWire = (0, brepjs.wireLoop)(outline);
2073
+ if (!outline.ok) return outline;
2074
+ const outlineWire = (0, brepjs.wireLoop)(outline.value);
2025
2075
  if (!outlineWire.ok) return outlineWire;
2026
2076
  const pattern = {
2027
2077
  outline: outlineWire.value,
@@ -2214,8 +2264,30 @@ function aabb2(rects) {
2214
2264
  y1
2215
2265
  };
2216
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
+ }
2217
2281
  function buildRectOutline2(b) {
2218
- 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)([
2219
2291
  (0, brepjs.line)([
2220
2292
  b.x0,
2221
2293
  b.y0,
@@ -2252,21 +2324,27 @@ function buildRectOutline2(b) {
2252
2324
  b.y0,
2253
2325
  0
2254
2326
  ])
2255
- ];
2327
+ ]);
2256
2328
  }
2257
- /** Axial extent of a bend (its cylindrical face span along the fitted axis). */
2258
- function bendAxialExtent(bend) {
2259
- const face = bend.faces[0];
2260
- if (face === void 0) return 0;
2261
- const axis = bend.fit.axisDir;
2262
- const samples = sampleFace(face, 6);
2329
+ /** Range of a face's sampled points along `axis`. */
2330
+ function axialRange(face, axis) {
2263
2331
  let min = Infinity;
2264
2332
  let max = -Infinity;
2265
- for (const s of samples) {
2333
+ for (const s of sampleFace(face, 6)) {
2266
2334
  const a = (0, brepjs.vecDot)(s.point, axis);
2267
2335
  min = Math.min(min, a);
2268
2336
  max = Math.max(max, a);
2269
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);
2270
2348
  return Number.isFinite(max - min) ? max - min : 0;
2271
2349
  }
2272
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, measureDistance, measureVolume, normalAt, ok, pointOnSurface, rotate, sharedEdges, translate, validationError, vecAdd, vecCross, vecDot, vecLength, vecNormalize, vecScale, vecSub, vertex, 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) {
@@ -1392,15 +1392,20 @@ function fitCylinder(face) {
1392
1392
  const axisDir = axisFromNormals(samples.map((s) => s.normal));
1393
1393
  if (axisDir === null) return null;
1394
1394
  const basis = perpBasis(axisDir);
1395
- const pts2 = samples.map((s) => [vecDot(s.point, basis.e1), vecDot(s.point, basis.e2)]);
1396
- const circle = fitCircle2(pts2);
1397
- if (circle === null) return null;
1398
- const axial = vecDot(samples[0]?.point ?? [
1395
+ let centroid = [
1399
1396
  0,
1400
1397
  0,
1401
1398
  0
1402
- ], axisDir);
1403
- 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)));
1404
1409
  let sumR = 0;
1405
1410
  for (const s of samples) sumR += distanceToAxis(s.point, axisOrigin, axisDir);
1406
1411
  const radius = sumR / samples.length;
@@ -1656,7 +1661,7 @@ function unfoldForeignSolid(solid, opts) {
1656
1661
  const thickness = thicknessResult.value;
1657
1662
  const flats = pairFlats(planar, thickness, warnings);
1658
1663
  if (flats.length === 0) return err(validationError("NO_FLATS", "no planar panel pairs detected; not a recognizable sheet-metal solid"));
1659
- 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);
1660
1665
  if (!layout.ok) return layout;
1661
1666
  return ok({
1662
1667
  pattern: layout.value.pattern,
@@ -1764,16 +1769,10 @@ function pairFlats(planar, thickness, warnings) {
1764
1769
  const fa = pa.face;
1765
1770
  const best = bestOpposedPartner(planar, i, thickness, used);
1766
1771
  if (best === null) {
1767
- const db = getBounds(fa);
1768
- const ext = [
1769
- db.xMax - db.xMin,
1770
- db.yMax - db.yMin,
1771
- db.zMax - db.zMin
1772
- ].sort((p, q) => q - p);
1773
- const inPlaneMin = ext[1] ?? 0;
1774
- if (inPlaneMin > thickness * 4) warnings.push({
1772
+ const size = inPlaneSize(fa);
1773
+ if (size.short > thickness * 4) warnings.push({
1775
1774
  code: "DETECTION_INCOMPLETE",
1776
- 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)`
1777
1776
  });
1778
1777
  continue;
1779
1778
  }
@@ -1814,10 +1813,61 @@ function pairFlats(planar, thickness, warnings) {
1814
1813
  return flats;
1815
1814
  }
1816
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
+ /**
1817
1867
  * Pair cylindrical faces into bends: each bend has an inner + outer coaxial face
1818
1868
  * (same axis, radii a thickness apart) or, failing a pair, a single fitted face.
1819
1869
  */
1820
- function pairBends(cyl, warnings) {
1870
+ function pairBends(cyl, thickness, warnings) {
1821
1871
  const fitted = [];
1822
1872
  for (const face of cyl) {
1823
1873
  const fit = fitCylinder(face);
@@ -1843,9 +1893,7 @@ function pairBends(cyl, warnings) {
1843
1893
  for (let j = i + 1; j < fitted.length; j += 1) {
1844
1894
  if (used.has(j)) continue;
1845
1895
  const b = fitted[j];
1846
- if (b === void 0) continue;
1847
- if (Math.abs(vecDot(a.fit.axisDir, b.fit.axisDir)) < .99) continue;
1848
- 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;
1849
1897
  mate = j;
1850
1898
  break;
1851
1899
  }
@@ -1990,20 +2038,21 @@ function layoutForeign(flats, graph, thickness, kFactor, warnings) {
1990
2038
  const axis = bend.fit.axisDir;
1991
2039
  const span = bendSpan.get(bend) ?? bendAxialExtent(bend);
1992
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
+ }
1993
2050
  placed.set(tb.child, placement.child);
1994
2051
  rects.push(placement.strip);
1995
2052
  rects.push(flatRect2(placement.child));
1996
2053
  bendLines.push({
1997
2054
  id: bend.id,
1998
- line: line([
1999
- placement.bendA[0],
2000
- placement.bendA[1],
2001
- 0
2002
- ], [
2003
- placement.bendB[0],
2004
- placement.bendB[1],
2005
- 0
2006
- ]),
2055
+ line: bendLine,
2007
2056
  angleDeg: bend.angleDeg,
2008
2057
  direction,
2009
2058
  inward: [-placement.outDir2[0], -placement.outDir2[1]]
@@ -2020,7 +2069,8 @@ function layoutForeign(flats, graph, thickness, kFactor, warnings) {
2020
2069
  }
2021
2070
  const bbox = aabb2(rects);
2022
2071
  const outline = buildRectOutline2(bbox);
2023
- const outlineWire = wireLoop(outline);
2072
+ if (!outline.ok) return outline;
2073
+ const outlineWire = wireLoop(outline.value);
2024
2074
  if (!outlineWire.ok) return outlineWire;
2025
2075
  const pattern = {
2026
2076
  outline: outlineWire.value,
@@ -2213,8 +2263,30 @@ function aabb2(rects) {
2213
2263
  y1
2214
2264
  };
2215
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
+ }
2216
2280
  function buildRectOutline2(b) {
2217
- 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([
2218
2290
  line([
2219
2291
  b.x0,
2220
2292
  b.y0,
@@ -2251,21 +2323,27 @@ function buildRectOutline2(b) {
2251
2323
  b.y0,
2252
2324
  0
2253
2325
  ])
2254
- ];
2326
+ ]);
2255
2327
  }
2256
- /** Axial extent of a bend (its cylindrical face span along the fitted axis). */
2257
- function bendAxialExtent(bend) {
2258
- const face = bend.faces[0];
2259
- if (face === void 0) return 0;
2260
- const axis = bend.fit.axisDir;
2261
- const samples = sampleFace(face, 6);
2328
+ /** Range of a face's sampled points along `axis`. */
2329
+ function axialRange(face, axis) {
2262
2330
  let min = Infinity;
2263
2331
  let max = -Infinity;
2264
- for (const s of samples) {
2332
+ for (const s of sampleFace(face, 6)) {
2265
2333
  const a = vecDot(s.point, axis);
2266
2334
  min = Math.min(min, a);
2267
2335
  max = Math.max(max, a);
2268
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);
2269
2347
  return Number.isFinite(max - min) ? max - min : 0;
2270
2348
  }
2271
2349
  /**
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "brepjs-sheetmetal",
3
- "version": "0.4.1",
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",