brepjs-sheetmetal 0.4.1 → 0.4.3
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 +10 -5
- package/dist/brepjs-sheetmetal.cjs +143 -57
- package/dist/brepjs-sheetmetal.js +144 -58
- package/dist/miterFns.d.ts +4 -4
- package/package.json +2 -2
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
|
-
|
|
312
|
-
|
|
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
|
-
|
|
328
|
-
|
|
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
|
-
|
|
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
|
-
]
|
|
1404
|
-
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)));
|
|
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
|
|
1769
|
-
|
|
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 (${
|
|
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:
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
/**
|
|
2258
|
-
function
|
|
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
|
|
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
|
/**
|
|
@@ -4791,10 +4869,10 @@ function miterCut(part, plane) {
|
|
|
4791
4869
|
});
|
|
4792
4870
|
}
|
|
4793
4871
|
/**
|
|
4794
|
-
* Auto corner-miter between two adjacent flanges. The miter plane
|
|
4795
|
-
*
|
|
4796
|
-
*
|
|
4797
|
-
*
|
|
4872
|
+
* Auto corner-miter between two adjacent flanges. The miter plane is vertical at
|
|
4873
|
+
* their shared corner, bisects the two directions that point out of the part, and
|
|
4874
|
+
* is offset outward by half the gap. The single cut trims the ends of both flanges
|
|
4875
|
+
* to a clean mitered corner and leaves the base flat and the bend lines whole.
|
|
4798
4876
|
*/
|
|
4799
4877
|
function autoMiterCorner(part, flangeIdA, flangeIdB, gap = 0) {
|
|
4800
4878
|
if (part.solid === void 0) return (0, brepjs.err)((0, brepjs.validationError)("NO_SOLID", "autoMiterCorner: part has no folded solid"));
|
|
@@ -4805,7 +4883,7 @@ function autoMiterCorner(part, flangeIdA, flangeIdB, gap = 0) {
|
|
|
4805
4883
|
const axisA = (0, brepjs.vecNormalize)(bendA.axisDir);
|
|
4806
4884
|
const axisB = (0, brepjs.vecNormalize)(bendB.axisDir);
|
|
4807
4885
|
const corner = cornerPoint(bendA.axisOrigin, axisA, bendB.axisOrigin, axisB);
|
|
4808
|
-
const planeNormal = miterNormal(axisA, axisB);
|
|
4886
|
+
const planeNormal = miterNormal(axisA, bendLineMid(part, bendA, axisA), axisB, bendLineMid(part, bendB, axisB), corner);
|
|
4809
4887
|
if (planeNormal === void 0) return (0, brepjs.err)((0, brepjs.validationError)("PARALLEL_FLANGES", "autoMiterCorner: flange bend axes are parallel; no corner to miter"));
|
|
4810
4888
|
const cutResult = miterCut(part, {
|
|
4811
4889
|
origin: (0, brepjs.vecAdd)(corner, (0, brepjs.vecScale)(planeNormal, gap * .5)),
|
|
@@ -4834,19 +4912,27 @@ function cornerPoint(originA, dirA, originB, dirB) {
|
|
|
4834
4912
|
const sc = (b * e - c * d) / denom;
|
|
4835
4913
|
return (0, brepjs.vecAdd)(originA, (0, brepjs.vecScale)(dirA, sc));
|
|
4836
4914
|
}
|
|
4837
|
-
/**
|
|
4838
|
-
|
|
4839
|
-
|
|
4840
|
-
|
|
4841
|
-
|
|
4842
|
-
|
|
4843
|
-
|
|
4844
|
-
|
|
4845
|
-
|
|
4846
|
-
|
|
4847
|
-
|
|
4848
|
-
|
|
4849
|
-
|
|
4915
|
+
/** Midpoint of a bend line. The line starts at the axis origin and runs the span of its flange. */
|
|
4916
|
+
function bendLineMid(part, bend, axis) {
|
|
4917
|
+
const span = part.flanges.find((f) => f.id === bend.id)?.span ?? 0;
|
|
4918
|
+
return (0, brepjs.vecAdd)(bend.axisOrigin, (0, brepjs.vecScale)(axis, span / 2));
|
|
4919
|
+
}
|
|
4920
|
+
/**
|
|
4921
|
+
* Normal of the miter plane between two flanges whose bend axes are `dirA`/`dirB`:
|
|
4922
|
+
* the bisector of the two directions that point out of the part. The outward
|
|
4923
|
+
* direction of a flange is perpendicular to its bend axis in the plane of the two
|
|
4924
|
+
* axes, and points away from the bend line of the other flange (`midA`/`midB` are
|
|
4925
|
+
* the midpoints of the two bend lines). The bend axes do not give this direction:
|
|
4926
|
+
* their sum lies along the diagonal of the part, and a cut there removes half of it.
|
|
4927
|
+
*/
|
|
4928
|
+
function miterNormal(dirA, midA, dirB, midB, corner) {
|
|
4929
|
+
const up = (0, brepjs.vecCross)(dirA, dirB);
|
|
4930
|
+
if (Math.hypot(up[0], up[1], up[2]) < 1e-9) return void 0;
|
|
4931
|
+
const outward = (dir, otherMid) => {
|
|
4932
|
+
const run = (0, brepjs.vecNormalize)((0, brepjs.vecCross)(dir, up));
|
|
4933
|
+
return (0, brepjs.vecDot)(run, (0, brepjs.vecSub)(otherMid, corner)) > 0 ? (0, brepjs.vecScale)(run, -1) : run;
|
|
4934
|
+
};
|
|
4935
|
+
return (0, brepjs.vecNormalize)((0, brepjs.vecAdd)(outward(dirA, midB), outward(dirB, midA)));
|
|
4850
4936
|
}
|
|
4851
4937
|
var Z_AXIS = [
|
|
4852
4938
|
0,
|
|
@@ -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
|
-
|
|
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
|
-
]
|
|
1403
|
-
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)));
|
|
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
|
|
1768
|
-
|
|
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 (${
|
|
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:
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
/**
|
|
2257
|
-
function
|
|
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
|
|
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
|
/**
|
|
@@ -4790,10 +4868,10 @@ function miterCut(part, plane) {
|
|
|
4790
4868
|
});
|
|
4791
4869
|
}
|
|
4792
4870
|
/**
|
|
4793
|
-
* Auto corner-miter between two adjacent flanges. The miter plane
|
|
4794
|
-
*
|
|
4795
|
-
*
|
|
4796
|
-
*
|
|
4871
|
+
* Auto corner-miter between two adjacent flanges. The miter plane is vertical at
|
|
4872
|
+
* their shared corner, bisects the two directions that point out of the part, and
|
|
4873
|
+
* is offset outward by half the gap. The single cut trims the ends of both flanges
|
|
4874
|
+
* to a clean mitered corner and leaves the base flat and the bend lines whole.
|
|
4797
4875
|
*/
|
|
4798
4876
|
function autoMiterCorner(part, flangeIdA, flangeIdB, gap = 0) {
|
|
4799
4877
|
if (part.solid === void 0) return err(validationError("NO_SOLID", "autoMiterCorner: part has no folded solid"));
|
|
@@ -4804,7 +4882,7 @@ function autoMiterCorner(part, flangeIdA, flangeIdB, gap = 0) {
|
|
|
4804
4882
|
const axisA = vecNormalize(bendA.axisDir);
|
|
4805
4883
|
const axisB = vecNormalize(bendB.axisDir);
|
|
4806
4884
|
const corner = cornerPoint(bendA.axisOrigin, axisA, bendB.axisOrigin, axisB);
|
|
4807
|
-
const planeNormal = miterNormal(axisA, axisB);
|
|
4885
|
+
const planeNormal = miterNormal(axisA, bendLineMid(part, bendA, axisA), axisB, bendLineMid(part, bendB, axisB), corner);
|
|
4808
4886
|
if (planeNormal === void 0) return err(validationError("PARALLEL_FLANGES", "autoMiterCorner: flange bend axes are parallel; no corner to miter"));
|
|
4809
4887
|
const cutResult = miterCut(part, {
|
|
4810
4888
|
origin: vecAdd(corner, vecScale(planeNormal, gap * .5)),
|
|
@@ -4833,19 +4911,27 @@ function cornerPoint(originA, dirA, originB, dirB) {
|
|
|
4833
4911
|
const sc = (b * e - c * d) / denom;
|
|
4834
4912
|
return vecAdd(originA, vecScale(dirA, sc));
|
|
4835
4913
|
}
|
|
4836
|
-
/**
|
|
4837
|
-
|
|
4838
|
-
|
|
4839
|
-
|
|
4840
|
-
|
|
4841
|
-
|
|
4842
|
-
|
|
4843
|
-
|
|
4844
|
-
|
|
4845
|
-
|
|
4846
|
-
|
|
4847
|
-
|
|
4848
|
-
|
|
4914
|
+
/** Midpoint of a bend line. The line starts at the axis origin and runs the span of its flange. */
|
|
4915
|
+
function bendLineMid(part, bend, axis) {
|
|
4916
|
+
const span = part.flanges.find((f) => f.id === bend.id)?.span ?? 0;
|
|
4917
|
+
return vecAdd(bend.axisOrigin, vecScale(axis, span / 2));
|
|
4918
|
+
}
|
|
4919
|
+
/**
|
|
4920
|
+
* Normal of the miter plane between two flanges whose bend axes are `dirA`/`dirB`:
|
|
4921
|
+
* the bisector of the two directions that point out of the part. The outward
|
|
4922
|
+
* direction of a flange is perpendicular to its bend axis in the plane of the two
|
|
4923
|
+
* axes, and points away from the bend line of the other flange (`midA`/`midB` are
|
|
4924
|
+
* the midpoints of the two bend lines). The bend axes do not give this direction:
|
|
4925
|
+
* their sum lies along the diagonal of the part, and a cut there removes half of it.
|
|
4926
|
+
*/
|
|
4927
|
+
function miterNormal(dirA, midA, dirB, midB, corner) {
|
|
4928
|
+
const up = vecCross(dirA, dirB);
|
|
4929
|
+
if (Math.hypot(up[0], up[1], up[2]) < 1e-9) return void 0;
|
|
4930
|
+
const outward = (dir, otherMid) => {
|
|
4931
|
+
const run = vecNormalize(vecCross(dir, up));
|
|
4932
|
+
return vecDot(run, vecSub(otherMid, corner)) > 0 ? vecScale(run, -1) : run;
|
|
4933
|
+
};
|
|
4934
|
+
return vecNormalize(vecAdd(outward(dirA, midB), outward(dirB, midA)));
|
|
4849
4935
|
}
|
|
4850
4936
|
var Z_AXIS = [
|
|
4851
4937
|
0,
|
package/dist/miterFns.d.ts
CHANGED
|
@@ -12,9 +12,9 @@ export interface MiterPlane {
|
|
|
12
12
|
*/
|
|
13
13
|
export declare function miterCut(part: SheetMetalPart, plane: MiterPlane): Result<SheetMetalPart>;
|
|
14
14
|
/**
|
|
15
|
-
* Auto corner-miter between two adjacent flanges. The miter plane
|
|
16
|
-
*
|
|
17
|
-
*
|
|
18
|
-
*
|
|
15
|
+
* Auto corner-miter between two adjacent flanges. The miter plane is vertical at
|
|
16
|
+
* their shared corner, bisects the two directions that point out of the part, and
|
|
17
|
+
* is offset outward by half the gap. The single cut trims the ends of both flanges
|
|
18
|
+
* to a clean mitered corner and leaves the base flat and the bend lines whole.
|
|
19
19
|
*/
|
|
20
20
|
export declare function autoMiterCorner(part: SheetMetalPart, flangeIdA: string, flangeIdB: string, gap?: number): Result<SheetMetalPart>;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "brepjs-sheetmetal",
|
|
3
|
-
"version": "0.4.
|
|
3
|
+
"version": "0.4.3",
|
|
4
4
|
"description": "Sheet-metal CAD domain for brepjs — flange authoring, auto-miter, fold/unfold flat patterns",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"sheet-metal",
|
|
@@ -53,7 +53,7 @@
|
|
|
53
53
|
},
|
|
54
54
|
"devDependencies": {
|
|
55
55
|
"@eslint/js": "*",
|
|
56
|
-
"@types/node": "26.6.
|
|
56
|
+
"@types/node": "26.6.6",
|
|
57
57
|
"eslint": "*",
|
|
58
58
|
"tsx": "4.23.15",
|
|
59
59
|
"typescript": "*",
|