brepjs-sheetmetal 0.4.3 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -94,6 +94,12 @@ feature in that region's **local** frame: `(0, 0)` is the region's near corner,
94
94
  profile is built in region-local coords, then placed via the region's world frame (origin/u/v/n), so a
95
95
  hole authored on a folded flange lands on the flange face, not the base plane. Rejects a feature outside
96
96
  the region (`CUTOUT_OUT_OF_BOUNDS`) and guards a valid, single-bodied result (`CUTOUT_SEVERED_SOLID`).
97
+ - `addCutouts(part, specs)` punches a list of cutouts in **one** boolean cut. Each `addCutout` call is a cut
98
+ on the solid that the call before it made larger, so a chain of calls slows down as it goes. Use
99
+ `addCutouts` for a vent pattern or a row of holes: the result is the same part, with the features recorded
100
+ in `specs` order. A spec that fails validation fails the whole call.
101
+ - A round hole is a true cylinder in the solid, and an obround slot has true arcs. Their developed loops are
102
+ polylines of 48 segments for a full circle.
97
103
  - `unfold` emits the matching developed loop at the cutout's flat-pattern location (mapped through the same
98
104
  region's developed frame) and drops `developedArea` by the cutout areas.
99
105
 
package/dist/api.d.ts CHANGED
@@ -33,6 +33,8 @@ export declare function autoReliefs(part: SheetMetalPart, spec?: ReliefSpec): Re
33
33
  export declare function relieveCorner(part: SheetMetalPart, flangeIdA: string, flangeIdB: string, spec?: ReliefSpec): Result<SheetMetalPart>;
34
34
  /** Punch a cutout (hole / slot / polygon) through a named flat region's thickness. */
35
35
  export declare function addCutout(part: SheetMetalPart, spec: CutoutSpec): Result<SheetMetalPart>;
36
+ /** Punch many cutouts in one boolean cut. Use this for a pattern of cutouts. */
37
+ export declare function addCutouts(part: SheetMetalPart, specs: readonly CutoutSpec[]): Result<SheetMetalPart>;
36
38
  /** Punch a circular hole of `diameter` centred at region-local `(x, y)`. */
37
39
  export declare function addHole(part: SheetMetalPart, region: string, x: number, y: number, diameter: number): Result<SheetMetalPart>;
38
40
  /** Punch a slot (rectangular or obround) centred at region-local `(x, y)`. */
@@ -1686,14 +1686,14 @@ function describePlanar(face) {
1686
1686
  /** True if `point` (already on the face's surface) lies within the face's trimmed boundary. */
1687
1687
  function liesOnFace(face, point) {
1688
1688
  try {
1689
- var _usingCtx$1 = _usingCtx();
1690
- const probe = _usingCtx$1.u((0, brepjs.vertex)(point));
1689
+ var _usingCtx$2 = _usingCtx();
1690
+ const probe = _usingCtx$2.u((0, brepjs.vertex)(point));
1691
1691
  const d = (0, brepjs.measureDistance)(probe, face);
1692
1692
  return d.ok && d.value < GEOM_TOL;
1693
1693
  } catch (_) {
1694
- _usingCtx$1.e = _;
1694
+ _usingCtx$2.e = _;
1695
1695
  } finally {
1696
- _usingCtx$1.d();
1696
+ _usingCtx$2.d();
1697
1697
  }
1698
1698
  }
1699
1699
  /** Normal-direction gap between two planar faces, or `null` unless they are antiparallel. */
@@ -1935,6 +1935,9 @@ function buildBendGraph(flats, bends, warnings) {
1935
1935
  if (bendTouchesFlat(bend, flat)) touching.push(fi);
1936
1936
  }
1937
1937
  if (touching.length < 2) {
1938
+ const [only] = touching;
1939
+ const flat = only !== void 0 ? flats[only] : void 0;
1940
+ if (flat !== void 0 && goesThroughFlat(bend, flat)) continue;
1938
1941
  warnings.push({
1939
1942
  code: "DETECTION_INCOMPLETE",
1940
1943
  message: `bend '${bend.id}' connects ${touching.length} flats (expected 2); ignored`,
@@ -1954,11 +1957,28 @@ function buildBendGraph(flats, bends, warnings) {
1954
1957
  }
1955
1958
  return { edges };
1956
1959
  }
1960
+ /**
1961
+ * True for a cylinder that goes through the sheet: a round hole, the end of an
1962
+ * obround slot or a rounded corner of the outline. A bend axis lies in the plane
1963
+ * of its flats; this axis is along the flat normal, and the cylinder meets both
1964
+ * faces of the panel. A boss or a blind recess meets one face only, so it keeps
1965
+ * its warning.
1966
+ */
1967
+ function goesThroughFlat(bend, flat) {
1968
+ if (Math.abs((0, brepjs.vecDot)(bend.fit.axisDir, flat.normal)) <= .99) return false;
1969
+ return flat.faces.every((ff) => bend.faces.some((bf) => facesShareEdge(bf, ff)));
1970
+ }
1957
1971
  /** True if any of the bend's faces shares an edge with either face of the flat. */
1958
1972
  function bendTouchesFlat(bend, flat) {
1959
- for (const bf of bend.faces) for (const ff of flat.faces) if ((0, brepjs.sharedEdges)(bf, ff).length > 0) return true;
1973
+ for (const bf of bend.faces) for (const ff of flat.faces) if (facesShareEdge(bf, ff)) return true;
1960
1974
  return false;
1961
1975
  }
1976
+ /** True if two faces share an edge. `sharedEdges` returns handles that the caller owns. */
1977
+ function facesShareEdge(a, b) {
1978
+ const edges = (0, brepjs.sharedEdges)(a, b);
1979
+ for (const edge of edges) edge.delete();
1980
+ return edges.length > 0;
1981
+ }
1962
1982
  /**
1963
1983
  * Lay out detected flats into the developed plane. A spanning tree over the bend
1964
1984
  * graph (BFS from flat 0) places each flat as a rectangle and each tree bend as a
@@ -2944,29 +2964,13 @@ function buildWedge$1(axisOrigin, thetaDeg, outerR, width, direction) {
2944
2964
  }
2945
2965
  /** The flange flat, built lying along +X past the bend, then folded to angle θ. */
2946
2966
  function buildFlangeFlat(axisOrigin, thetaDeg, width, thickness, length, direction) {
2947
- if (direction === "up") {
2948
- const flat = (0, brepjs.box)(length, width, thickness);
2949
- const positioned = (0, brepjs.translate)(flat, [
2950
- axisOrigin[0],
2951
- axisOrigin[1],
2952
- 0
2953
- ]);
2954
- return (0, brepjs.rotate)(positioned, -thetaDeg, {
2955
- at: axisOrigin,
2956
- axis: [
2957
- 0,
2958
- 1,
2959
- 0
2960
- ]
2961
- });
2962
- }
2963
2967
  const flat = (0, brepjs.box)(length, width, thickness);
2964
2968
  const positioned = (0, brepjs.translate)(flat, [
2965
2969
  axisOrigin[0],
2966
2970
  axisOrigin[1],
2967
- -thickness
2971
+ 0
2968
2972
  ]);
2969
- return (0, brepjs.rotate)(positioned, thetaDeg, {
2973
+ return (0, brepjs.rotate)(positioned, (direction === "up" ? -1 : 1) * thetaDeg, {
2970
2974
  at: axisOrigin,
2971
2975
  axis: [
2972
2976
  0,
@@ -3343,7 +3347,10 @@ function dist2(a, b) {
3343
3347
  var EPS$5 = 1e-6;
3344
3348
  /** A hair of over-cut so the tool pokes through both faces cleanly. */
3345
3349
  var HAIR$1 = .05;
3346
- /** Polyline segments approximating a circular boundary (hole / obround end cap). */
3350
+ /**
3351
+ * Polyline segments approximating a circular boundary (hole / obround end cap) in
3352
+ * the developed loop. The 3D cut tool keeps the true circle and arcs.
3353
+ */
3347
3354
  var CIRCLE_SEGMENTS$1 = 48;
3348
3355
  /**
3349
3356
  * Punch a cutout (hole / slot / polygon) through a named flat region's thickness.
@@ -3354,42 +3361,78 @@ var CIRCLE_SEGMENTS$1 = 48;
3354
3361
  * mapped through the region's developed {@link Frame2} and recorded as a
3355
3362
  * {@link CutoutFeature}, so {@link unfold} emits the matching loop in the flat
3356
3363
  * pattern and {@link fold} can replay it. Guards a valid, single-bodied solid.
3364
+ *
3365
+ * Each call is one boolean cut on a solid that the cut before it made larger. For
3366
+ * a pattern of cutouts, {@link addCutouts} makes one cut for all of them.
3357
3367
  */
3358
3368
  function addCutout(part, spec) {
3359
- if (part.solid === void 0) return (0, brepjs.err)((0, brepjs.validationError)("NO_SOLID", "addCutout: part has no folded solid to cut"));
3360
- const regionId = resolveRegionId(spec.region);
3361
- const treeResult = featureTree(part);
3362
- if (!treeResult.ok) return treeResult;
3363
- const tree = treeResult.value;
3364
- const framesResult = worldFrames(part, tree);
3365
- if (!framesResult.ok) return framesResult;
3366
- const worldFrame = framesResult.value.get(regionId);
3367
- if (worldFrame === void 0) return (0, brepjs.err)((0, brepjs.validationError)("UNKNOWN_REGION", `addCutout: region '${spec.region}' not found`));
3368
- const localResult = localLoop(spec);
3369
- if (!localResult.ok) return localResult;
3370
- const local = localResult.value;
3371
- const oob = checkInBounds(local, regionExtent(part, regionId, worldFrame));
3372
- if (oob !== void 0) return (0, brepjs.err)((0, brepjs.validationError)("CUTOUT_OUT_OF_BOUNDS", oob));
3373
- const dev = developedFrame(part, regionId, tree);
3374
- if (!dev.ok) return dev;
3375
- const toolResult = buildTool(local, worldFrame, part.thickness);
3376
- if (!toolResult.ok) return toolResult;
3377
- const cutResult = (0, brepjs.cut)(part.solid, toolResult.value);
3378
- if (!cutResult.ok) return cutResult;
3379
- const solid = normalizeSolid(cutResult.value);
3380
- if (!(0, brepjs.isValid)(solid) || (0, brepjs.getSolids)(solid).length > 1) return (0, brepjs.err)((0, brepjs.validationError)("CUTOUT_SEVERED_SOLID", `addCutout: ${spec.kind} on region '${spec.region}' severs the part into multiple bodies`));
3381
- const loop = local.map((p) => mapToFrame2(p, dev.value));
3382
- const feature = {
3383
- spec,
3384
- region: regionId,
3385
- loop,
3386
- area: polygonArea(loop)
3387
- };
3388
- return (0, brepjs.ok)({
3389
- ...part,
3390
- solid,
3391
- cutouts: [...part.cutouts ?? [], feature]
3392
- });
3369
+ return punch(part, [spec], "addCutout");
3370
+ }
3371
+ /**
3372
+ * Punch many cutouts in one boolean cut. The result is the same part that a chain
3373
+ * of {@link addCutout} calls gives, with the features recorded in `specs` order,
3374
+ * but the cost does not grow with the square of the count. A spec that fails
3375
+ * validation fails the whole call, and no cutout is made.
3376
+ */
3377
+ function addCutouts(part, specs) {
3378
+ return punch(part, specs, "addCutouts");
3379
+ }
3380
+ function punch(part, specs, label) {
3381
+ try {
3382
+ var _usingCtx$1 = _usingCtx();
3383
+ if (part.solid === void 0) return (0, brepjs.err)((0, brepjs.validationError)("NO_SOLID", `${label}: part has no folded solid to cut`));
3384
+ if (specs.length === 0) return (0, brepjs.ok)(part);
3385
+ const treeResult = featureTree(part);
3386
+ if (!treeResult.ok) return treeResult;
3387
+ const tree = treeResult.value;
3388
+ const framesResult = worldFrames(part, tree);
3389
+ if (!framesResult.ok) return framesResult;
3390
+ const layout = layoutTree(part, tree, part.baseLength, part.width);
3391
+ if (!layout.ok) return layout;
3392
+ const scope = _usingCtx$1.u(new brepjs.DisposalScope());
3393
+ const tools = [];
3394
+ const features = [];
3395
+ for (const spec of specs) {
3396
+ const regionId = resolveRegionId(spec.region);
3397
+ const worldFrame = framesResult.value.get(regionId);
3398
+ if (worldFrame === void 0) return (0, brepjs.err)((0, brepjs.validationError)("UNKNOWN_REGION", `${label}: region '${spec.region}' not found`));
3399
+ const localResult = localLoop(spec);
3400
+ if (!localResult.ok) return localResult;
3401
+ const local = localResult.value;
3402
+ const oob = checkInBounds(outlineBounds(spec, local), regionExtent(part, regionId, worldFrame));
3403
+ if (oob !== void 0) return (0, brepjs.err)((0, brepjs.validationError)("CUTOUT_OUT_OF_BOUNDS", oob));
3404
+ const dev = developedFrame(layout.value, regionId);
3405
+ if (!dev.ok) return dev;
3406
+ const toolResult = buildTool(spec, local, worldFrame, part.thickness, scope);
3407
+ if (!toolResult.ok) return toolResult;
3408
+ tools.push(scope.register(toolResult.value));
3409
+ const loop = local.map((p) => mapToFrame2(p, dev.value));
3410
+ features.push({
3411
+ spec,
3412
+ region: regionId,
3413
+ loop,
3414
+ area: polygonArea(loop)
3415
+ });
3416
+ }
3417
+ const [only] = tools;
3418
+ const cutResult = tools.length === 1 && only !== void 0 ? (0, brepjs.cut)(part.solid, only) : (0, brepjs.cutAll)(part.solid, tools);
3419
+ if (!cutResult.ok) return cutResult;
3420
+ const solid = normalizeSolid(cutResult.value);
3421
+ if (!(0, brepjs.isValid)(solid) || (0, brepjs.getSolids)(solid).length > 1) {
3422
+ const [first] = specs;
3423
+ const what = specs.length === 1 && first !== void 0 ? `${first.kind} on region '${first.region}' severs` : `the ${specs.length} cutouts sever`;
3424
+ return (0, brepjs.err)((0, brepjs.validationError)("CUTOUT_SEVERED_SOLID", `${label}: ${what} the part into multiple bodies`));
3425
+ }
3426
+ return (0, brepjs.ok)({
3427
+ ...part,
3428
+ solid,
3429
+ cutouts: [...part.cutouts ?? [], ...features]
3430
+ });
3431
+ } catch (_) {
3432
+ _usingCtx$1.e = _;
3433
+ } finally {
3434
+ _usingCtx$1.d();
3435
+ }
3393
3436
  }
3394
3437
  /** Punch a circular hole of `diameter` centred at region-local `(x, y)`. */
3395
3438
  function addHole(part, region, x, y, diameter) {
@@ -3436,7 +3479,9 @@ function regionFrames(part, region) {
3436
3479
  if (!framesResult.ok) return framesResult;
3437
3480
  const world = framesResult.value.get(regionId);
3438
3481
  if (world === void 0) return (0, brepjs.err)((0, brepjs.validationError)("UNKNOWN_REGION", `region '${region}' not found`));
3439
- const dev = developedFrame(part, regionId, tree);
3482
+ const layout = layoutTree(part, tree, part.baseLength, part.width);
3483
+ if (!layout.ok) return layout;
3484
+ const dev = developedFrame(layout.value, regionId);
3440
3485
  if (!dev.ok) return dev;
3441
3486
  return (0, brepjs.ok)({
3442
3487
  regionId,
@@ -3510,45 +3555,107 @@ function regionExtent(part, regionId, worldFrame) {
3510
3555
  vMax: worldFrame.vLen
3511
3556
  };
3512
3557
  }
3513
- /** First out-of-bounds message, or undefined if the whole loop lies inside the region. */
3514
- function checkInBounds(local, ext) {
3515
- for (const [x, y] of local) if (x < -1e-6 || x > ext.uMax + EPS$5 || y < -1e-6 || y > ext.vMax + EPS$5) return `cutout point (${x.toFixed(3)}, ${y.toFixed(3)}) lies outside region extent [0,${ext.uMax}]×[0,${ext.vMax}]`;
3558
+ /**
3559
+ * Local-frame bounding box of the outline that the 3D tool cuts. A hole and the
3560
+ * ends of an obround slot are true circles, which reach past the vertices of
3561
+ * their developed polyline, so their boxes come from the circle centres.
3562
+ */
3563
+ function outlineBounds(spec, local) {
3564
+ if (spec.kind === "hole") {
3565
+ const r = spec.diameter / 2;
3566
+ return {
3567
+ xMin: spec.x - r,
3568
+ xMax: spec.x + r,
3569
+ yMin: spec.y - r,
3570
+ yMax: spec.y + r
3571
+ };
3572
+ }
3573
+ if (spec.kind === "slot" && spec.round === true) {
3574
+ const hw = spec.width / 2;
3575
+ const straight = Math.max(spec.length / 2 - hw, 0);
3576
+ const rad = (spec.angleDeg ?? 0) * Math.PI / 180;
3577
+ const dx = Math.abs(straight * Math.cos(rad)) + hw;
3578
+ const dy = Math.abs(straight * Math.sin(rad)) + hw;
3579
+ return {
3580
+ xMin: spec.x - dx,
3581
+ xMax: spec.x + dx,
3582
+ yMin: spec.y - dy,
3583
+ yMax: spec.y + dy
3584
+ };
3585
+ }
3586
+ const xs = local.map((p) => p[0]);
3587
+ const ys = local.map((p) => p[1]);
3588
+ return {
3589
+ xMin: Math.min(...xs),
3590
+ xMax: Math.max(...xs),
3591
+ yMin: Math.min(...ys),
3592
+ yMax: Math.max(...ys)
3593
+ };
3594
+ }
3595
+ /** Out-of-bounds message, or undefined if the whole outline lies inside the region. */
3596
+ function checkInBounds(b, ext) {
3597
+ if (b.xMin < -1e-6 || b.xMax > ext.uMax + EPS$5 || b.yMin < -1e-6 || b.yMax > ext.vMax + EPS$5) return `cutout extent [${b.xMin.toFixed(3)}, ${b.xMax.toFixed(3)}]×[${b.yMin.toFixed(3)}, ${b.yMax.toFixed(3)}] lies outside region extent [0,${ext.uMax}]×[0,${ext.vMax}]`;
3516
3598
  }
3517
3599
  /**
3518
- * Build the 3D cut tool: the local loop placed onto the region's bottom surface
3519
- * (`origin + x·u + y·v − HAIR·n`), turned into a face, and extruded through the
3520
- * sheet by `thickness + 2·HAIR` along +n. Using the world frame's `u`/`v`/`n` is
3521
- * what lands the cut on the correct folded face.
3600
+ * Build the 3D cut tool on the region's bottom surface, a hair below it
3601
+ * (`origin + x·u + y·v − HAIR·n`), through the sheet by `thickness + 2·HAIR` along
3602
+ * +n. Using the world frame's `u`/`v`/`n` is what lands the cut on the correct
3603
+ * folded face. A hole is a true cylinder and an obround slot has true arcs, so a
3604
+ * round cutout adds one or two curved faces to the solid, not one planar face for
3605
+ * each segment of its developed loop. Intermediate shapes go to `scope`.
3522
3606
  */
3523
- function buildTool(local, f, thickness) {
3607
+ function buildTool(spec, local, f, thickness, scope) {
3524
3608
  const base = (0, brepjs.vecAdd)(f.origin, (0, brepjs.vecScale)(f.n, -.05));
3525
- const worldPts = local.map(([x, y]) => (0, brepjs.vecAdd)((0, brepjs.vecAdd)(base, (0, brepjs.vecScale)(f.u, x)), (0, brepjs.vecScale)(f.v, y)));
3526
- const edges = [];
3527
- for (let i = 0; i < worldPts.length; i += 1) {
3528
- const a = worldPts[i];
3529
- const b = worldPts[(i + 1) % worldPts.length];
3530
- if (a === void 0 || b === void 0) return (0, brepjs.err)((0, brepjs.validationError)("CUTOUT_TOOL_FAILED", "failed to index cutout loop points"));
3531
- edges.push((0, brepjs.line)(a, b));
3532
- }
3609
+ const toWorld = ([x, y]) => (0, brepjs.vecAdd)((0, brepjs.vecAdd)(base, (0, brepjs.vecScale)(f.u, x)), (0, brepjs.vecScale)(f.v, y));
3610
+ const depth = thickness + 2 * HAIR$1;
3611
+ if (spec.kind === "hole") return (0, brepjs.ok)((0, brepjs.cylinder)(spec.diameter / 2, depth, {
3612
+ at: toWorld([spec.x, spec.y]),
3613
+ axis: f.n
3614
+ }));
3615
+ const edges = spec.kind === "slot" && spec.round === true ? obroundEdges(spec, toWorld) : polylineEdges(local, toWorld);
3616
+ for (const e of edges) scope.register(e);
3533
3617
  const wire = (0, brepjs.wireLoop)(edges);
3534
3618
  if (!wire.ok) return wire;
3619
+ scope.register(wire.value);
3535
3620
  if (!(0, brepjs.isPlanarWire)(wire.value)) return (0, brepjs.err)((0, brepjs.validationError)("CUTOUT_TOOL_FAILED", "cutout profile wire is not planar"));
3536
3621
  const profile = (0, brepjs.face)(wire.value);
3537
3622
  if (!profile.ok) return profile;
3538
- const depth = thickness + 2 * HAIR$1;
3623
+ scope.register(profile.value);
3539
3624
  return (0, brepjs.extrude)(profile.value, [
3540
3625
  f.n[0] * depth,
3541
3626
  f.n[1] * depth,
3542
3627
  f.n[2] * depth
3543
3628
  ]);
3544
3629
  }
3545
- /** Developed-plane {@link Frame2} of a region (base or flange) via the unfold layout. */
3546
- function developedFrame(part, regionId, tree) {
3547
- const treeResult = tree !== void 0 ? (0, brepjs.ok)(tree) : featureTree(part);
3548
- if (!treeResult.ok) return treeResult;
3549
- const layout = layoutTree(part, treeResult.value, part.baseLength, part.width);
3550
- if (!layout.ok) return layout;
3551
- const placed = layout.value.flats.find((p) => p.id === regionId);
3630
+ function polylineEdges(local, toWorld) {
3631
+ const pts = local.map(toWorld);
3632
+ return pts.map((a, i) => (0, brepjs.line)(a, pts[(i + 1) % pts.length] ?? a));
3633
+ }
3634
+ /**
3635
+ * Edges of an obround slot: two straight sides joined by two semicircles of radius
3636
+ * `width/2`. A slot as wide as it is long has no straight side, so it is two
3637
+ * semicircles (a circle).
3638
+ */
3639
+ function obroundEdges(spec, toWorld) {
3640
+ const hw = spec.width / 2;
3641
+ const straight = Math.max(spec.length / 2 - hw, 0);
3642
+ const rad = (spec.angleDeg ?? 0) * Math.PI / 180;
3643
+ const c = Math.cos(rad);
3644
+ const s = Math.sin(rad);
3645
+ const at = (x, y) => toWorld([spec.x + x * c - y * s, spec.y + x * s + y * c]);
3646
+ const rightBottom = at(straight, -hw);
3647
+ const rightTop = at(straight, hw);
3648
+ const leftTop = at(-straight, hw);
3649
+ const leftBottom = at(-straight, -hw);
3650
+ const edges = [(0, brepjs.threePointArc)(rightBottom, at(straight + hw, 0), rightTop)];
3651
+ if (straight > EPS$5) edges.push((0, brepjs.line)(rightTop, leftTop));
3652
+ edges.push((0, brepjs.threePointArc)(leftTop, at(-straight - hw, 0), leftBottom));
3653
+ if (straight > EPS$5) edges.push((0, brepjs.line)(leftBottom, rightBottom));
3654
+ return edges;
3655
+ }
3656
+ /** Developed-plane {@link Frame2} of a region (base or flange) in the unfold layout. */
3657
+ function developedFrame(layout, regionId) {
3658
+ const placed = layout.flats.find((p) => p.id === regionId);
3552
3659
  if (placed === void 0) return (0, brepjs.err)((0, brepjs.validationError)("UNKNOWN_REGION", `developedFrame: region '${regionId}' missing from layout`));
3553
3660
  return (0, brepjs.ok)(placed.frame);
3554
3661
  }
@@ -4228,22 +4335,17 @@ function foldWithWarnings(input) {
4228
4335
  if (!relieved.ok) return relieved;
4229
4336
  part = relieved.value;
4230
4337
  }
4231
- for (const spec of input.baseCutouts ?? []) {
4232
- const cutResult = addCutout(part, {
4233
- ...spec,
4234
- region: ROOT_FLAT_ID
4235
- });
4236
- if (!cutResult.ok) return cutResult;
4237
- part = cutResult.value;
4238
- }
4239
- for (const region of input.regions) for (const spec of region.cutouts ?? []) {
4240
- const cutResult = addCutout(part, {
4241
- ...spec,
4242
- region: region.id
4243
- });
4244
- if (!cutResult.ok) return cutResult;
4245
- part = cutResult.value;
4246
- }
4338
+ const cutoutSpecs = (input.baseCutouts ?? []).map((spec) => ({
4339
+ ...spec,
4340
+ region: ROOT_FLAT_ID
4341
+ }));
4342
+ for (const region of input.regions) for (const spec of region.cutouts ?? []) cutoutSpecs.push({
4343
+ ...spec,
4344
+ region: region.id
4345
+ });
4346
+ const cutResult = addCutouts(part, cutoutSpecs);
4347
+ if (!cutResult.ok) return cutResult;
4348
+ part = cutResult.value;
4247
4349
  for (const spec of input.baseTabs ?? []) {
4248
4350
  const tabbed = addTab(part, {
4249
4351
  ...spec,
@@ -7120,6 +7222,10 @@ function relieveCorner(part, flangeIdA, flangeIdB, spec) {
7120
7222
  function addCutout$1(part, spec) {
7121
7223
  return addCutout(part, spec);
7122
7224
  }
7225
+ /** Punch many cutouts in one boolean cut. Use this for a pattern of cutouts. */
7226
+ function addCutouts$1(part, specs) {
7227
+ return addCutouts(part, specs);
7228
+ }
7123
7229
  /** Punch a circular hole of `diameter` centred at region-local `(x, y)`. */
7124
7230
  function addHole$1(part, region, x, y, diameter) {
7125
7231
  return addHole(part, region, x, y, diameter);
@@ -7282,6 +7388,10 @@ var SheetMetalPartHandle = class SheetMetalPartHandle {
7282
7388
  cutout(spec) {
7283
7389
  return new SheetMetalPartHandle(unwrapOrThrow(addCutout$1(this.part, spec)));
7284
7390
  }
7391
+ /** Punch many cutouts in one boolean cut. Use this for a pattern of cutouts. */
7392
+ cutouts(specs) {
7393
+ return new SheetMetalPartHandle(unwrapOrThrow(addCutouts$1(this.part, specs)));
7394
+ }
7285
7395
  /** Punch a circular hole of `diameter` centred at region-local `(x, y)`. */
7286
7396
  hole(region, x, y, diameter) {
7287
7397
  return new SheetMetalPartHandle(unwrapOrThrow(addHole$1(this.part, region, x, y, diameter)));
@@ -7437,6 +7547,9 @@ var SheetMetalBuilder = class SheetMetalBuilder {
7437
7547
  cutout(spec) {
7438
7548
  return this.build().cutout(spec);
7439
7549
  }
7550
+ cutouts(specs) {
7551
+ return this.build().cutouts(specs);
7552
+ }
7440
7553
  hole(region, x, y, diameter) {
7441
7554
  return this.build().hole(region, x, y, diameter);
7442
7555
  }
@@ -7518,6 +7631,7 @@ exports.SheetMetalPartHandle = SheetMetalPartHandle;
7518
7631
  exports.addBendRelief = addBendRelief;
7519
7632
  exports.addBendTable = addBendTable;
7520
7633
  exports.addCutout = addCutout;
7634
+ exports.addCutouts = addCutouts;
7521
7635
  exports.addForm = addForm;
7522
7636
  exports.addHole = addHole;
7523
7637
  exports.addPolygonCutout = addPolygonCutout;
@@ -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, measureLength, measureVolume, normalAt, ok, pointOnSurface, rotate, sharedEdges, translate, validationError, vecAdd, vecCross, vecDot, vecLength, vecNormalize, vecScale, vecSub, vertex, wire, wireLoop } from "brepjs";
1
+ import { DisposalScope, box, curveEndPoint, curveStartPoint, cut, cutAll, 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, threePointArc, 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) {
@@ -1685,14 +1685,14 @@ function describePlanar(face) {
1685
1685
  /** True if `point` (already on the face's surface) lies within the face's trimmed boundary. */
1686
1686
  function liesOnFace(face, point) {
1687
1687
  try {
1688
- var _usingCtx$1 = _usingCtx();
1689
- const probe = _usingCtx$1.u(vertex(point));
1688
+ var _usingCtx$2 = _usingCtx();
1689
+ const probe = _usingCtx$2.u(vertex(point));
1690
1690
  const d = measureDistance(probe, face);
1691
1691
  return d.ok && d.value < GEOM_TOL;
1692
1692
  } catch (_) {
1693
- _usingCtx$1.e = _;
1693
+ _usingCtx$2.e = _;
1694
1694
  } finally {
1695
- _usingCtx$1.d();
1695
+ _usingCtx$2.d();
1696
1696
  }
1697
1697
  }
1698
1698
  /** Normal-direction gap between two planar faces, or `null` unless they are antiparallel. */
@@ -1934,6 +1934,9 @@ function buildBendGraph(flats, bends, warnings) {
1934
1934
  if (bendTouchesFlat(bend, flat)) touching.push(fi);
1935
1935
  }
1936
1936
  if (touching.length < 2) {
1937
+ const [only] = touching;
1938
+ const flat = only !== void 0 ? flats[only] : void 0;
1939
+ if (flat !== void 0 && goesThroughFlat(bend, flat)) continue;
1937
1940
  warnings.push({
1938
1941
  code: "DETECTION_INCOMPLETE",
1939
1942
  message: `bend '${bend.id}' connects ${touching.length} flats (expected 2); ignored`,
@@ -1953,11 +1956,28 @@ function buildBendGraph(flats, bends, warnings) {
1953
1956
  }
1954
1957
  return { edges };
1955
1958
  }
1959
+ /**
1960
+ * True for a cylinder that goes through the sheet: a round hole, the end of an
1961
+ * obround slot or a rounded corner of the outline. A bend axis lies in the plane
1962
+ * of its flats; this axis is along the flat normal, and the cylinder meets both
1963
+ * faces of the panel. A boss or a blind recess meets one face only, so it keeps
1964
+ * its warning.
1965
+ */
1966
+ function goesThroughFlat(bend, flat) {
1967
+ if (Math.abs(vecDot(bend.fit.axisDir, flat.normal)) <= .99) return false;
1968
+ return flat.faces.every((ff) => bend.faces.some((bf) => facesShareEdge(bf, ff)));
1969
+ }
1956
1970
  /** True if any of the bend's faces shares an edge with either face of the flat. */
1957
1971
  function bendTouchesFlat(bend, flat) {
1958
- for (const bf of bend.faces) for (const ff of flat.faces) if (sharedEdges(bf, ff).length > 0) return true;
1972
+ for (const bf of bend.faces) for (const ff of flat.faces) if (facesShareEdge(bf, ff)) return true;
1959
1973
  return false;
1960
1974
  }
1975
+ /** True if two faces share an edge. `sharedEdges` returns handles that the caller owns. */
1976
+ function facesShareEdge(a, b) {
1977
+ const edges = sharedEdges(a, b);
1978
+ for (const edge of edges) edge.delete();
1979
+ return edges.length > 0;
1980
+ }
1961
1981
  /**
1962
1982
  * Lay out detected flats into the developed plane. A spanning tree over the bend
1963
1983
  * graph (BFS from flat 0) places each flat as a rectangle and each tree bend as a
@@ -2943,29 +2963,13 @@ function buildWedge$1(axisOrigin, thetaDeg, outerR, width, direction) {
2943
2963
  }
2944
2964
  /** The flange flat, built lying along +X past the bend, then folded to angle θ. */
2945
2965
  function buildFlangeFlat(axisOrigin, thetaDeg, width, thickness, length, direction) {
2946
- if (direction === "up") {
2947
- const flat = box(length, width, thickness);
2948
- const positioned = translate(flat, [
2949
- axisOrigin[0],
2950
- axisOrigin[1],
2951
- 0
2952
- ]);
2953
- return rotate(positioned, -thetaDeg, {
2954
- at: axisOrigin,
2955
- axis: [
2956
- 0,
2957
- 1,
2958
- 0
2959
- ]
2960
- });
2961
- }
2962
2966
  const flat = box(length, width, thickness);
2963
2967
  const positioned = translate(flat, [
2964
2968
  axisOrigin[0],
2965
2969
  axisOrigin[1],
2966
- -thickness
2970
+ 0
2967
2971
  ]);
2968
- return rotate(positioned, thetaDeg, {
2972
+ return rotate(positioned, (direction === "up" ? -1 : 1) * thetaDeg, {
2969
2973
  at: axisOrigin,
2970
2974
  axis: [
2971
2975
  0,
@@ -3342,7 +3346,10 @@ function dist2(a, b) {
3342
3346
  var EPS$5 = 1e-6;
3343
3347
  /** A hair of over-cut so the tool pokes through both faces cleanly. */
3344
3348
  var HAIR$1 = .05;
3345
- /** Polyline segments approximating a circular boundary (hole / obround end cap). */
3349
+ /**
3350
+ * Polyline segments approximating a circular boundary (hole / obround end cap) in
3351
+ * the developed loop. The 3D cut tool keeps the true circle and arcs.
3352
+ */
3346
3353
  var CIRCLE_SEGMENTS$1 = 48;
3347
3354
  /**
3348
3355
  * Punch a cutout (hole / slot / polygon) through a named flat region's thickness.
@@ -3353,42 +3360,78 @@ var CIRCLE_SEGMENTS$1 = 48;
3353
3360
  * mapped through the region's developed {@link Frame2} and recorded as a
3354
3361
  * {@link CutoutFeature}, so {@link unfold} emits the matching loop in the flat
3355
3362
  * pattern and {@link fold} can replay it. Guards a valid, single-bodied solid.
3363
+ *
3364
+ * Each call is one boolean cut on a solid that the cut before it made larger. For
3365
+ * a pattern of cutouts, {@link addCutouts} makes one cut for all of them.
3356
3366
  */
3357
3367
  function addCutout(part, spec) {
3358
- if (part.solid === void 0) return err(validationError("NO_SOLID", "addCutout: part has no folded solid to cut"));
3359
- const regionId = resolveRegionId(spec.region);
3360
- const treeResult = featureTree(part);
3361
- if (!treeResult.ok) return treeResult;
3362
- const tree = treeResult.value;
3363
- const framesResult = worldFrames(part, tree);
3364
- if (!framesResult.ok) return framesResult;
3365
- const worldFrame = framesResult.value.get(regionId);
3366
- if (worldFrame === void 0) return err(validationError("UNKNOWN_REGION", `addCutout: region '${spec.region}' not found`));
3367
- const localResult = localLoop(spec);
3368
- if (!localResult.ok) return localResult;
3369
- const local = localResult.value;
3370
- const oob = checkInBounds(local, regionExtent(part, regionId, worldFrame));
3371
- if (oob !== void 0) return err(validationError("CUTOUT_OUT_OF_BOUNDS", oob));
3372
- const dev = developedFrame(part, regionId, tree);
3373
- if (!dev.ok) return dev;
3374
- const toolResult = buildTool(local, worldFrame, part.thickness);
3375
- if (!toolResult.ok) return toolResult;
3376
- const cutResult = cut(part.solid, toolResult.value);
3377
- if (!cutResult.ok) return cutResult;
3378
- const solid = normalizeSolid(cutResult.value);
3379
- if (!isValid(solid) || getSolids(solid).length > 1) return err(validationError("CUTOUT_SEVERED_SOLID", `addCutout: ${spec.kind} on region '${spec.region}' severs the part into multiple bodies`));
3380
- const loop = local.map((p) => mapToFrame2(p, dev.value));
3381
- const feature = {
3382
- spec,
3383
- region: regionId,
3384
- loop,
3385
- area: polygonArea(loop)
3386
- };
3387
- return ok({
3388
- ...part,
3389
- solid,
3390
- cutouts: [...part.cutouts ?? [], feature]
3391
- });
3368
+ return punch(part, [spec], "addCutout");
3369
+ }
3370
+ /**
3371
+ * Punch many cutouts in one boolean cut. The result is the same part that a chain
3372
+ * of {@link addCutout} calls gives, with the features recorded in `specs` order,
3373
+ * but the cost does not grow with the square of the count. A spec that fails
3374
+ * validation fails the whole call, and no cutout is made.
3375
+ */
3376
+ function addCutouts(part, specs) {
3377
+ return punch(part, specs, "addCutouts");
3378
+ }
3379
+ function punch(part, specs, label) {
3380
+ try {
3381
+ var _usingCtx$1 = _usingCtx();
3382
+ if (part.solid === void 0) return err(validationError("NO_SOLID", `${label}: part has no folded solid to cut`));
3383
+ if (specs.length === 0) return ok(part);
3384
+ const treeResult = featureTree(part);
3385
+ if (!treeResult.ok) return treeResult;
3386
+ const tree = treeResult.value;
3387
+ const framesResult = worldFrames(part, tree);
3388
+ if (!framesResult.ok) return framesResult;
3389
+ const layout = layoutTree(part, tree, part.baseLength, part.width);
3390
+ if (!layout.ok) return layout;
3391
+ const scope = _usingCtx$1.u(new DisposalScope());
3392
+ const tools = [];
3393
+ const features = [];
3394
+ for (const spec of specs) {
3395
+ const regionId = resolveRegionId(spec.region);
3396
+ const worldFrame = framesResult.value.get(regionId);
3397
+ if (worldFrame === void 0) return err(validationError("UNKNOWN_REGION", `${label}: region '${spec.region}' not found`));
3398
+ const localResult = localLoop(spec);
3399
+ if (!localResult.ok) return localResult;
3400
+ const local = localResult.value;
3401
+ const oob = checkInBounds(outlineBounds(spec, local), regionExtent(part, regionId, worldFrame));
3402
+ if (oob !== void 0) return err(validationError("CUTOUT_OUT_OF_BOUNDS", oob));
3403
+ const dev = developedFrame(layout.value, regionId);
3404
+ if (!dev.ok) return dev;
3405
+ const toolResult = buildTool(spec, local, worldFrame, part.thickness, scope);
3406
+ if (!toolResult.ok) return toolResult;
3407
+ tools.push(scope.register(toolResult.value));
3408
+ const loop = local.map((p) => mapToFrame2(p, dev.value));
3409
+ features.push({
3410
+ spec,
3411
+ region: regionId,
3412
+ loop,
3413
+ area: polygonArea(loop)
3414
+ });
3415
+ }
3416
+ const [only] = tools;
3417
+ const cutResult = tools.length === 1 && only !== void 0 ? cut(part.solid, only) : cutAll(part.solid, tools);
3418
+ if (!cutResult.ok) return cutResult;
3419
+ const solid = normalizeSolid(cutResult.value);
3420
+ if (!isValid(solid) || getSolids(solid).length > 1) {
3421
+ const [first] = specs;
3422
+ const what = specs.length === 1 && first !== void 0 ? `${first.kind} on region '${first.region}' severs` : `the ${specs.length} cutouts sever`;
3423
+ return err(validationError("CUTOUT_SEVERED_SOLID", `${label}: ${what} the part into multiple bodies`));
3424
+ }
3425
+ return ok({
3426
+ ...part,
3427
+ solid,
3428
+ cutouts: [...part.cutouts ?? [], ...features]
3429
+ });
3430
+ } catch (_) {
3431
+ _usingCtx$1.e = _;
3432
+ } finally {
3433
+ _usingCtx$1.d();
3434
+ }
3392
3435
  }
3393
3436
  /** Punch a circular hole of `diameter` centred at region-local `(x, y)`. */
3394
3437
  function addHole(part, region, x, y, diameter) {
@@ -3435,7 +3478,9 @@ function regionFrames(part, region) {
3435
3478
  if (!framesResult.ok) return framesResult;
3436
3479
  const world = framesResult.value.get(regionId);
3437
3480
  if (world === void 0) return err(validationError("UNKNOWN_REGION", `region '${region}' not found`));
3438
- const dev = developedFrame(part, regionId, tree);
3481
+ const layout = layoutTree(part, tree, part.baseLength, part.width);
3482
+ if (!layout.ok) return layout;
3483
+ const dev = developedFrame(layout.value, regionId);
3439
3484
  if (!dev.ok) return dev;
3440
3485
  return ok({
3441
3486
  regionId,
@@ -3509,45 +3554,107 @@ function regionExtent(part, regionId, worldFrame) {
3509
3554
  vMax: worldFrame.vLen
3510
3555
  };
3511
3556
  }
3512
- /** First out-of-bounds message, or undefined if the whole loop lies inside the region. */
3513
- function checkInBounds(local, ext) {
3514
- for (const [x, y] of local) if (x < -1e-6 || x > ext.uMax + EPS$5 || y < -1e-6 || y > ext.vMax + EPS$5) return `cutout point (${x.toFixed(3)}, ${y.toFixed(3)}) lies outside region extent [0,${ext.uMax}]×[0,${ext.vMax}]`;
3557
+ /**
3558
+ * Local-frame bounding box of the outline that the 3D tool cuts. A hole and the
3559
+ * ends of an obround slot are true circles, which reach past the vertices of
3560
+ * their developed polyline, so their boxes come from the circle centres.
3561
+ */
3562
+ function outlineBounds(spec, local) {
3563
+ if (spec.kind === "hole") {
3564
+ const r = spec.diameter / 2;
3565
+ return {
3566
+ xMin: spec.x - r,
3567
+ xMax: spec.x + r,
3568
+ yMin: spec.y - r,
3569
+ yMax: spec.y + r
3570
+ };
3571
+ }
3572
+ if (spec.kind === "slot" && spec.round === true) {
3573
+ const hw = spec.width / 2;
3574
+ const straight = Math.max(spec.length / 2 - hw, 0);
3575
+ const rad = (spec.angleDeg ?? 0) * Math.PI / 180;
3576
+ const dx = Math.abs(straight * Math.cos(rad)) + hw;
3577
+ const dy = Math.abs(straight * Math.sin(rad)) + hw;
3578
+ return {
3579
+ xMin: spec.x - dx,
3580
+ xMax: spec.x + dx,
3581
+ yMin: spec.y - dy,
3582
+ yMax: spec.y + dy
3583
+ };
3584
+ }
3585
+ const xs = local.map((p) => p[0]);
3586
+ const ys = local.map((p) => p[1]);
3587
+ return {
3588
+ xMin: Math.min(...xs),
3589
+ xMax: Math.max(...xs),
3590
+ yMin: Math.min(...ys),
3591
+ yMax: Math.max(...ys)
3592
+ };
3593
+ }
3594
+ /** Out-of-bounds message, or undefined if the whole outline lies inside the region. */
3595
+ function checkInBounds(b, ext) {
3596
+ if (b.xMin < -1e-6 || b.xMax > ext.uMax + EPS$5 || b.yMin < -1e-6 || b.yMax > ext.vMax + EPS$5) return `cutout extent [${b.xMin.toFixed(3)}, ${b.xMax.toFixed(3)}]×[${b.yMin.toFixed(3)}, ${b.yMax.toFixed(3)}] lies outside region extent [0,${ext.uMax}]×[0,${ext.vMax}]`;
3515
3597
  }
3516
3598
  /**
3517
- * Build the 3D cut tool: the local loop placed onto the region's bottom surface
3518
- * (`origin + x·u + y·v − HAIR·n`), turned into a face, and extruded through the
3519
- * sheet by `thickness + 2·HAIR` along +n. Using the world frame's `u`/`v`/`n` is
3520
- * what lands the cut on the correct folded face.
3599
+ * Build the 3D cut tool on the region's bottom surface, a hair below it
3600
+ * (`origin + x·u + y·v − HAIR·n`), through the sheet by `thickness + 2·HAIR` along
3601
+ * +n. Using the world frame's `u`/`v`/`n` is what lands the cut on the correct
3602
+ * folded face. A hole is a true cylinder and an obround slot has true arcs, so a
3603
+ * round cutout adds one or two curved faces to the solid, not one planar face for
3604
+ * each segment of its developed loop. Intermediate shapes go to `scope`.
3521
3605
  */
3522
- function buildTool(local, f, thickness) {
3606
+ function buildTool(spec, local, f, thickness, scope) {
3523
3607
  const base = vecAdd(f.origin, vecScale(f.n, -.05));
3524
- const worldPts = local.map(([x, y]) => vecAdd(vecAdd(base, vecScale(f.u, x)), vecScale(f.v, y)));
3525
- const edges = [];
3526
- for (let i = 0; i < worldPts.length; i += 1) {
3527
- const a = worldPts[i];
3528
- const b = worldPts[(i + 1) % worldPts.length];
3529
- if (a === void 0 || b === void 0) return err(validationError("CUTOUT_TOOL_FAILED", "failed to index cutout loop points"));
3530
- edges.push(line(a, b));
3531
- }
3608
+ const toWorld = ([x, y]) => vecAdd(vecAdd(base, vecScale(f.u, x)), vecScale(f.v, y));
3609
+ const depth = thickness + 2 * HAIR$1;
3610
+ if (spec.kind === "hole") return ok(cylinder(spec.diameter / 2, depth, {
3611
+ at: toWorld([spec.x, spec.y]),
3612
+ axis: f.n
3613
+ }));
3614
+ const edges = spec.kind === "slot" && spec.round === true ? obroundEdges(spec, toWorld) : polylineEdges(local, toWorld);
3615
+ for (const e of edges) scope.register(e);
3532
3616
  const wire = wireLoop(edges);
3533
3617
  if (!wire.ok) return wire;
3618
+ scope.register(wire.value);
3534
3619
  if (!isPlanarWire(wire.value)) return err(validationError("CUTOUT_TOOL_FAILED", "cutout profile wire is not planar"));
3535
3620
  const profile = face(wire.value);
3536
3621
  if (!profile.ok) return profile;
3537
- const depth = thickness + 2 * HAIR$1;
3622
+ scope.register(profile.value);
3538
3623
  return extrude(profile.value, [
3539
3624
  f.n[0] * depth,
3540
3625
  f.n[1] * depth,
3541
3626
  f.n[2] * depth
3542
3627
  ]);
3543
3628
  }
3544
- /** Developed-plane {@link Frame2} of a region (base or flange) via the unfold layout. */
3545
- function developedFrame(part, regionId, tree) {
3546
- const treeResult = tree !== void 0 ? ok(tree) : featureTree(part);
3547
- if (!treeResult.ok) return treeResult;
3548
- const layout = layoutTree(part, treeResult.value, part.baseLength, part.width);
3549
- if (!layout.ok) return layout;
3550
- const placed = layout.value.flats.find((p) => p.id === regionId);
3629
+ function polylineEdges(local, toWorld) {
3630
+ const pts = local.map(toWorld);
3631
+ return pts.map((a, i) => line(a, pts[(i + 1) % pts.length] ?? a));
3632
+ }
3633
+ /**
3634
+ * Edges of an obround slot: two straight sides joined by two semicircles of radius
3635
+ * `width/2`. A slot as wide as it is long has no straight side, so it is two
3636
+ * semicircles (a circle).
3637
+ */
3638
+ function obroundEdges(spec, toWorld) {
3639
+ const hw = spec.width / 2;
3640
+ const straight = Math.max(spec.length / 2 - hw, 0);
3641
+ const rad = (spec.angleDeg ?? 0) * Math.PI / 180;
3642
+ const c = Math.cos(rad);
3643
+ const s = Math.sin(rad);
3644
+ const at = (x, y) => toWorld([spec.x + x * c - y * s, spec.y + x * s + y * c]);
3645
+ const rightBottom = at(straight, -hw);
3646
+ const rightTop = at(straight, hw);
3647
+ const leftTop = at(-straight, hw);
3648
+ const leftBottom = at(-straight, -hw);
3649
+ const edges = [threePointArc(rightBottom, at(straight + hw, 0), rightTop)];
3650
+ if (straight > EPS$5) edges.push(line(rightTop, leftTop));
3651
+ edges.push(threePointArc(leftTop, at(-straight - hw, 0), leftBottom));
3652
+ if (straight > EPS$5) edges.push(line(leftBottom, rightBottom));
3653
+ return edges;
3654
+ }
3655
+ /** Developed-plane {@link Frame2} of a region (base or flange) in the unfold layout. */
3656
+ function developedFrame(layout, regionId) {
3657
+ const placed = layout.flats.find((p) => p.id === regionId);
3551
3658
  if (placed === void 0) return err(validationError("UNKNOWN_REGION", `developedFrame: region '${regionId}' missing from layout`));
3552
3659
  return ok(placed.frame);
3553
3660
  }
@@ -4227,22 +4334,17 @@ function foldWithWarnings(input) {
4227
4334
  if (!relieved.ok) return relieved;
4228
4335
  part = relieved.value;
4229
4336
  }
4230
- for (const spec of input.baseCutouts ?? []) {
4231
- const cutResult = addCutout(part, {
4232
- ...spec,
4233
- region: ROOT_FLAT_ID
4234
- });
4235
- if (!cutResult.ok) return cutResult;
4236
- part = cutResult.value;
4237
- }
4238
- for (const region of input.regions) for (const spec of region.cutouts ?? []) {
4239
- const cutResult = addCutout(part, {
4240
- ...spec,
4241
- region: region.id
4242
- });
4243
- if (!cutResult.ok) return cutResult;
4244
- part = cutResult.value;
4245
- }
4337
+ const cutoutSpecs = (input.baseCutouts ?? []).map((spec) => ({
4338
+ ...spec,
4339
+ region: ROOT_FLAT_ID
4340
+ }));
4341
+ for (const region of input.regions) for (const spec of region.cutouts ?? []) cutoutSpecs.push({
4342
+ ...spec,
4343
+ region: region.id
4344
+ });
4345
+ const cutResult = addCutouts(part, cutoutSpecs);
4346
+ if (!cutResult.ok) return cutResult;
4347
+ part = cutResult.value;
4246
4348
  for (const spec of input.baseTabs ?? []) {
4247
4349
  const tabbed = addTab(part, {
4248
4350
  ...spec,
@@ -7119,6 +7221,10 @@ function relieveCorner(part, flangeIdA, flangeIdB, spec) {
7119
7221
  function addCutout$1(part, spec) {
7120
7222
  return addCutout(part, spec);
7121
7223
  }
7224
+ /** Punch many cutouts in one boolean cut. Use this for a pattern of cutouts. */
7225
+ function addCutouts$1(part, specs) {
7226
+ return addCutouts(part, specs);
7227
+ }
7122
7228
  /** Punch a circular hole of `diameter` centred at region-local `(x, y)`. */
7123
7229
  function addHole$1(part, region, x, y, diameter) {
7124
7230
  return addHole(part, region, x, y, diameter);
@@ -7281,6 +7387,10 @@ var SheetMetalPartHandle = class SheetMetalPartHandle {
7281
7387
  cutout(spec) {
7282
7388
  return new SheetMetalPartHandle(unwrapOrThrow(addCutout$1(this.part, spec)));
7283
7389
  }
7390
+ /** Punch many cutouts in one boolean cut. Use this for a pattern of cutouts. */
7391
+ cutouts(specs) {
7392
+ return new SheetMetalPartHandle(unwrapOrThrow(addCutouts$1(this.part, specs)));
7393
+ }
7284
7394
  /** Punch a circular hole of `diameter` centred at region-local `(x, y)`. */
7285
7395
  hole(region, x, y, diameter) {
7286
7396
  return new SheetMetalPartHandle(unwrapOrThrow(addHole$1(this.part, region, x, y, diameter)));
@@ -7436,6 +7546,9 @@ var SheetMetalBuilder = class SheetMetalBuilder {
7436
7546
  cutout(spec) {
7437
7547
  return this.build().cutout(spec);
7438
7548
  }
7549
+ cutouts(specs) {
7550
+ return this.build().cutouts(specs);
7551
+ }
7439
7552
  hole(region, x, y, diameter) {
7440
7553
  return this.build().hole(region, x, y, diameter);
7441
7554
  }
@@ -7505,4 +7618,4 @@ function foldFlat(input) {
7505
7618
  function fromSolid(solid, opts) {
7506
7619
  return new ForeignSolidHandle(solid, opts);
7507
7620
  }
7508
- export { ALUMINUM_GAUGES, DEFAULT_K_FACTOR, ForeignSolidHandle, MATERIALS, ROOT_FLAT_ID, STEEL_GAUGES, SheetMetalBuilder, SheetMetalError, SheetMetalPartHandle, addBendRelief, addBendTable, addCutout, addForm, addHole, addPolygonCutout, addSlot, addTab, allowance, author, authorContourFlange, authorLoftedFlange, authorPart, autoBendReliefs, autoMiterCorner, autoReliefs, baseEdgeRef, bendAllowance, bendRelief, bendTable, buildFeatureGraph, buildFeatureTree, buildReport, contourFlange, cornerRelief, developed, developedLength, emboss, featureTree, fitCylinder, flatPatternToDXF, flatPatternToPolylines, fold, foldFlat, fold$1 as foldPart, foldWithWarnings, fromPart, fromSolid, getBendTable, getMaterial, hem, jog, loftedFlange, louver, miter, miterCorner, miterCut, multiPatternToDXF, nest, nestToDXF, neutralRadius, partToFlatInput, patternBbox, patternToFlatInput, pointInPolygon, polygonBounds, polygonsOverlap, polygonsOverlapWithClearance, registerBendTable, relieveCorner, report, reportFrom, reportFromUnfold, reportJSON, reportToJSON, resolveAllowance, resolveBendAllowance, segmentsIntersect, sheetMetal, signedArea, tabAndSlot, toDXF, transformPolygon, unfold, unfoldForeignSolid, unfold$1 as unfoldPart, unfoldSolid, validate, validatePart, wireToPolygon };
7621
+ export { ALUMINUM_GAUGES, DEFAULT_K_FACTOR, ForeignSolidHandle, MATERIALS, ROOT_FLAT_ID, STEEL_GAUGES, SheetMetalBuilder, SheetMetalError, SheetMetalPartHandle, addBendRelief, addBendTable, addCutout, addCutouts, addForm, addHole, addPolygonCutout, addSlot, addTab, allowance, author, authorContourFlange, authorLoftedFlange, authorPart, autoBendReliefs, autoMiterCorner, autoReliefs, baseEdgeRef, bendAllowance, bendRelief, bendTable, buildFeatureGraph, buildFeatureTree, buildReport, contourFlange, cornerRelief, developed, developedLength, emboss, featureTree, fitCylinder, flatPatternToDXF, flatPatternToPolylines, fold, foldFlat, fold$1 as foldPart, foldWithWarnings, fromPart, fromSolid, getBendTable, getMaterial, hem, jog, loftedFlange, louver, miter, miterCorner, miterCut, multiPatternToDXF, nest, nestToDXF, neutralRadius, partToFlatInput, patternBbox, patternToFlatInput, pointInPolygon, polygonBounds, polygonsOverlap, polygonsOverlapWithClearance, registerBendTable, relieveCorner, report, reportFrom, reportFromUnfold, reportJSON, reportToJSON, resolveAllowance, resolveBendAllowance, segmentsIntersect, sheetMetal, signedArea, tabAndSlot, toDXF, transformPolygon, unfold, unfoldForeignSolid, unfold$1 as unfoldPart, unfoldSolid, validate, validatePart, wireToPolygon };
@@ -12,8 +12,18 @@ type Pt2 = [number, number];
12
12
  * mapped through the region's developed {@link Frame2} and recorded as a
13
13
  * {@link CutoutFeature}, so {@link unfold} emits the matching loop in the flat
14
14
  * pattern and {@link fold} can replay it. Guards a valid, single-bodied solid.
15
+ *
16
+ * Each call is one boolean cut on a solid that the cut before it made larger. For
17
+ * a pattern of cutouts, {@link addCutouts} makes one cut for all of them.
15
18
  */
16
19
  export declare function addCutout(part: SheetMetalPart, spec: CutoutSpec): Result<SheetMetalPart>;
20
+ /**
21
+ * Punch many cutouts in one boolean cut. The result is the same part that a chain
22
+ * of {@link addCutout} calls gives, with the features recorded in `specs` order,
23
+ * but the cost does not grow with the square of the count. A spec that fails
24
+ * validation fails the whole call, and no cutout is made.
25
+ */
26
+ export declare function addCutouts(part: SheetMetalPart, specs: readonly CutoutSpec[]): Result<SheetMetalPart>;
17
27
  /** Punch a circular hole of `diameter` centred at region-local `(x, y)`. */
18
28
  export declare function addHole(part: SheetMetalPart, region: string, x: number, y: number, diameter: number): Result<SheetMetalPart>;
19
29
  /** Punch a slot centred at `(x, y)`: `length` along the slot axis by `width` across. */
package/dist/facade.d.ts CHANGED
@@ -24,6 +24,8 @@ declare class SheetMetalPartHandle {
24
24
  cornerRelief(flangeIdA: string, flangeIdB: string, spec?: ReliefSpec): SheetMetalPartHandle;
25
25
  /** Punch a cutout (hole / slot / polygon) through a named flat region. */
26
26
  cutout(spec: CutoutSpec): SheetMetalPartHandle;
27
+ /** Punch many cutouts in one boolean cut. Use this for a pattern of cutouts. */
28
+ cutouts(specs: readonly CutoutSpec[]): SheetMetalPartHandle;
27
29
  /** Punch a circular hole of `diameter` centred at region-local `(x, y)`. */
28
30
  hole(region: string, x: number, y: number, diameter: number): SheetMetalPartHandle;
29
31
  /** Punch a slot (rectangular or obround) centred at region-local `(x, y)`. */
@@ -123,6 +125,7 @@ declare class SheetMetalBuilder {
123
125
  autoReliefs(spec?: ReliefSpec): SheetMetalPartHandle;
124
126
  cornerRelief(flangeIdA: string, flangeIdB: string, spec?: ReliefSpec): SheetMetalPartHandle;
125
127
  cutout(spec: CutoutSpec): SheetMetalPartHandle;
128
+ cutouts(specs: readonly CutoutSpec[]): SheetMetalPartHandle;
126
129
  hole(region: string, x: number, y: number, diameter: number): SheetMetalPartHandle;
127
130
  slot(region: string, opts: {
128
131
  x: number;
package/dist/index.d.ts CHANGED
@@ -14,7 +14,7 @@ export type { AuthorSpec, BaseFlatSpec, FlangeSpec, FlangeSide, SeamSpec, } from
14
14
  export { miterCut, autoMiterCorner } from './miterFns.js';
15
15
  export type { MiterPlane } from './miterFns.js';
16
16
  export { addBendRelief, autoBendReliefs, cornerRelief } from './reliefFns.js';
17
- export { addCutout, addHole, addSlot, addPolygonCutout } from './cutoutFns.js';
17
+ export { addCutout, addCutouts, addHole, addSlot, addPolygonCutout } from './cutoutFns.js';
18
18
  export { addTab, tabAndSlot } from './tabFns.js';
19
19
  export type { SlotPlacement } from './tabFns.js';
20
20
  export { addForm, louver, emboss } from './formFns.js';
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "brepjs-sheetmetal",
3
- "version": "0.4.3",
3
+ "version": "0.5.0",
4
4
  "description": "Sheet-metal CAD domain for brepjs — flange authoring, auto-miter, fold/unfold flat patterns",
5
5
  "keywords": [
6
6
  "sheet-metal",