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 +15 -6
- package/dist/brepjs-sheetmetal.cjs +267 -84
- package/dist/brepjs-sheetmetal.js +268 -85
- package/package.json +1 -1
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.
|
|
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
|
-
|
|
309
|
-
|
|
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,
|
|
324
|
-
U-channel
|
|
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
|
-
|
|
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
|
-
]
|
|
1349
|
-
const
|
|
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
|
|
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(
|
|
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
|
|
1614
|
-
*
|
|
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
|
|
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
|
|
1621
|
-
const
|
|
1622
|
-
if (
|
|
1623
|
-
const
|
|
1624
|
-
|
|
1625
|
-
|
|
1626
|
-
|
|
1627
|
-
|
|
1628
|
-
|
|
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
|
-
|
|
1632
|
-
|
|
1633
|
-
return (0, brepjs.
|
|
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
|
|
1637
|
-
* `thickness` apart
|
|
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
|
|
1645
|
-
if (
|
|
1646
|
-
const
|
|
1647
|
-
const
|
|
1648
|
-
|
|
1649
|
-
|
|
1650
|
-
|
|
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 (${
|
|
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
|
|
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:
|
|
1702
|
-
center: (0, brepjs.vecScale)((0, brepjs.vecAdd)(
|
|
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:
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
/**
|
|
2153
|
-
function
|
|
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
|
|
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
|
-
|
|
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
|
-
]
|
|
1348
|
-
const
|
|
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
|
|
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(
|
|
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
|
|
1613
|
-
*
|
|
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
|
|
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
|
|
1620
|
-
const
|
|
1621
|
-
if (
|
|
1622
|
-
const
|
|
1623
|
-
|
|
1624
|
-
|
|
1625
|
-
|
|
1626
|
-
|
|
1627
|
-
|
|
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
|
-
|
|
1631
|
-
|
|
1632
|
-
|
|
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
|
|
1636
|
-
* `thickness` apart
|
|
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
|
|
1644
|
-
if (
|
|
1645
|
-
const
|
|
1646
|
-
const
|
|
1647
|
-
|
|
1648
|
-
|
|
1649
|
-
|
|
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 (${
|
|
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
|
|
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:
|
|
1701
|
-
center: vecScale(vecAdd(
|
|
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:
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
/**
|
|
2152
|
-
function
|
|
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
|
|
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
|
/**
|