brepjs-sheetmetal 0.4.4 → 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
@@ -3327,7 +3347,10 @@ function dist2(a, b) {
3327
3347
  var EPS$5 = 1e-6;
3328
3348
  /** A hair of over-cut so the tool pokes through both faces cleanly. */
3329
3349
  var HAIR$1 = .05;
3330
- /** 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
+ */
3331
3354
  var CIRCLE_SEGMENTS$1 = 48;
3332
3355
  /**
3333
3356
  * Punch a cutout (hole / slot / polygon) through a named flat region's thickness.
@@ -3338,42 +3361,78 @@ var CIRCLE_SEGMENTS$1 = 48;
3338
3361
  * mapped through the region's developed {@link Frame2} and recorded as a
3339
3362
  * {@link CutoutFeature}, so {@link unfold} emits the matching loop in the flat
3340
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.
3341
3367
  */
3342
3368
  function addCutout(part, spec) {
3343
- if (part.solid === void 0) return (0, brepjs.err)((0, brepjs.validationError)("NO_SOLID", "addCutout: part has no folded solid to cut"));
3344
- const regionId = resolveRegionId(spec.region);
3345
- const treeResult = featureTree(part);
3346
- if (!treeResult.ok) return treeResult;
3347
- const tree = treeResult.value;
3348
- const framesResult = worldFrames(part, tree);
3349
- if (!framesResult.ok) return framesResult;
3350
- const worldFrame = framesResult.value.get(regionId);
3351
- if (worldFrame === void 0) return (0, brepjs.err)((0, brepjs.validationError)("UNKNOWN_REGION", `addCutout: region '${spec.region}' not found`));
3352
- const localResult = localLoop(spec);
3353
- if (!localResult.ok) return localResult;
3354
- const local = localResult.value;
3355
- const oob = checkInBounds(local, regionExtent(part, regionId, worldFrame));
3356
- if (oob !== void 0) return (0, brepjs.err)((0, brepjs.validationError)("CUTOUT_OUT_OF_BOUNDS", oob));
3357
- const dev = developedFrame(part, regionId, tree);
3358
- if (!dev.ok) return dev;
3359
- const toolResult = buildTool(local, worldFrame, part.thickness);
3360
- if (!toolResult.ok) return toolResult;
3361
- const cutResult = (0, brepjs.cut)(part.solid, toolResult.value);
3362
- if (!cutResult.ok) return cutResult;
3363
- const solid = normalizeSolid(cutResult.value);
3364
- 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`));
3365
- const loop = local.map((p) => mapToFrame2(p, dev.value));
3366
- const feature = {
3367
- spec,
3368
- region: regionId,
3369
- loop,
3370
- area: polygonArea(loop)
3371
- };
3372
- return (0, brepjs.ok)({
3373
- ...part,
3374
- solid,
3375
- cutouts: [...part.cutouts ?? [], feature]
3376
- });
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
+ }
3377
3436
  }
3378
3437
  /** Punch a circular hole of `diameter` centred at region-local `(x, y)`. */
3379
3438
  function addHole(part, region, x, y, diameter) {
@@ -3420,7 +3479,9 @@ function regionFrames(part, region) {
3420
3479
  if (!framesResult.ok) return framesResult;
3421
3480
  const world = framesResult.value.get(regionId);
3422
3481
  if (world === void 0) return (0, brepjs.err)((0, brepjs.validationError)("UNKNOWN_REGION", `region '${region}' not found`));
3423
- 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);
3424
3485
  if (!dev.ok) return dev;
3425
3486
  return (0, brepjs.ok)({
3426
3487
  regionId,
@@ -3494,45 +3555,107 @@ function regionExtent(part, regionId, worldFrame) {
3494
3555
  vMax: worldFrame.vLen
3495
3556
  };
3496
3557
  }
3497
- /** First out-of-bounds message, or undefined if the whole loop lies inside the region. */
3498
- function checkInBounds(local, ext) {
3499
- 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}]`;
3500
3598
  }
3501
3599
  /**
3502
- * Build the 3D cut tool: the local loop placed onto the region's bottom surface
3503
- * (`origin + x·u + y·v − HAIR·n`), turned into a face, and extruded through the
3504
- * sheet by `thickness + 2·HAIR` along +n. Using the world frame's `u`/`v`/`n` is
3505
- * 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`.
3506
3606
  */
3507
- function buildTool(local, f, thickness) {
3607
+ function buildTool(spec, local, f, thickness, scope) {
3508
3608
  const base = (0, brepjs.vecAdd)(f.origin, (0, brepjs.vecScale)(f.n, -.05));
3509
- 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)));
3510
- const edges = [];
3511
- for (let i = 0; i < worldPts.length; i += 1) {
3512
- const a = worldPts[i];
3513
- const b = worldPts[(i + 1) % worldPts.length];
3514
- if (a === void 0 || b === void 0) return (0, brepjs.err)((0, brepjs.validationError)("CUTOUT_TOOL_FAILED", "failed to index cutout loop points"));
3515
- edges.push((0, brepjs.line)(a, b));
3516
- }
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);
3517
3617
  const wire = (0, brepjs.wireLoop)(edges);
3518
3618
  if (!wire.ok) return wire;
3619
+ scope.register(wire.value);
3519
3620
  if (!(0, brepjs.isPlanarWire)(wire.value)) return (0, brepjs.err)((0, brepjs.validationError)("CUTOUT_TOOL_FAILED", "cutout profile wire is not planar"));
3520
3621
  const profile = (0, brepjs.face)(wire.value);
3521
3622
  if (!profile.ok) return profile;
3522
- const depth = thickness + 2 * HAIR$1;
3623
+ scope.register(profile.value);
3523
3624
  return (0, brepjs.extrude)(profile.value, [
3524
3625
  f.n[0] * depth,
3525
3626
  f.n[1] * depth,
3526
3627
  f.n[2] * depth
3527
3628
  ]);
3528
3629
  }
3529
- /** Developed-plane {@link Frame2} of a region (base or flange) via the unfold layout. */
3530
- function developedFrame(part, regionId, tree) {
3531
- const treeResult = tree !== void 0 ? (0, brepjs.ok)(tree) : featureTree(part);
3532
- if (!treeResult.ok) return treeResult;
3533
- const layout = layoutTree(part, treeResult.value, part.baseLength, part.width);
3534
- if (!layout.ok) return layout;
3535
- 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);
3536
3659
  if (placed === void 0) return (0, brepjs.err)((0, brepjs.validationError)("UNKNOWN_REGION", `developedFrame: region '${regionId}' missing from layout`));
3537
3660
  return (0, brepjs.ok)(placed.frame);
3538
3661
  }
@@ -4212,22 +4335,17 @@ function foldWithWarnings(input) {
4212
4335
  if (!relieved.ok) return relieved;
4213
4336
  part = relieved.value;
4214
4337
  }
4215
- for (const spec of input.baseCutouts ?? []) {
4216
- const cutResult = addCutout(part, {
4217
- ...spec,
4218
- region: ROOT_FLAT_ID
4219
- });
4220
- if (!cutResult.ok) return cutResult;
4221
- part = cutResult.value;
4222
- }
4223
- for (const region of input.regions) for (const spec of region.cutouts ?? []) {
4224
- const cutResult = addCutout(part, {
4225
- ...spec,
4226
- region: region.id
4227
- });
4228
- if (!cutResult.ok) return cutResult;
4229
- part = cutResult.value;
4230
- }
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;
4231
4349
  for (const spec of input.baseTabs ?? []) {
4232
4350
  const tabbed = addTab(part, {
4233
4351
  ...spec,
@@ -7104,6 +7222,10 @@ function relieveCorner(part, flangeIdA, flangeIdB, spec) {
7104
7222
  function addCutout$1(part, spec) {
7105
7223
  return addCutout(part, spec);
7106
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
+ }
7107
7229
  /** Punch a circular hole of `diameter` centred at region-local `(x, y)`. */
7108
7230
  function addHole$1(part, region, x, y, diameter) {
7109
7231
  return addHole(part, region, x, y, diameter);
@@ -7266,6 +7388,10 @@ var SheetMetalPartHandle = class SheetMetalPartHandle {
7266
7388
  cutout(spec) {
7267
7389
  return new SheetMetalPartHandle(unwrapOrThrow(addCutout$1(this.part, spec)));
7268
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
+ }
7269
7395
  /** Punch a circular hole of `diameter` centred at region-local `(x, y)`. */
7270
7396
  hole(region, x, y, diameter) {
7271
7397
  return new SheetMetalPartHandle(unwrapOrThrow(addHole$1(this.part, region, x, y, diameter)));
@@ -7421,6 +7547,9 @@ var SheetMetalBuilder = class SheetMetalBuilder {
7421
7547
  cutout(spec) {
7422
7548
  return this.build().cutout(spec);
7423
7549
  }
7550
+ cutouts(specs) {
7551
+ return this.build().cutouts(specs);
7552
+ }
7424
7553
  hole(region, x, y, diameter) {
7425
7554
  return this.build().hole(region, x, y, diameter);
7426
7555
  }
@@ -7502,6 +7631,7 @@ exports.SheetMetalPartHandle = SheetMetalPartHandle;
7502
7631
  exports.addBendRelief = addBendRelief;
7503
7632
  exports.addBendTable = addBendTable;
7504
7633
  exports.addCutout = addCutout;
7634
+ exports.addCutouts = addCutouts;
7505
7635
  exports.addForm = addForm;
7506
7636
  exports.addHole = addHole;
7507
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
@@ -3326,7 +3346,10 @@ function dist2(a, b) {
3326
3346
  var EPS$5 = 1e-6;
3327
3347
  /** A hair of over-cut so the tool pokes through both faces cleanly. */
3328
3348
  var HAIR$1 = .05;
3329
- /** 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
+ */
3330
3353
  var CIRCLE_SEGMENTS$1 = 48;
3331
3354
  /**
3332
3355
  * Punch a cutout (hole / slot / polygon) through a named flat region's thickness.
@@ -3337,42 +3360,78 @@ var CIRCLE_SEGMENTS$1 = 48;
3337
3360
  * mapped through the region's developed {@link Frame2} and recorded as a
3338
3361
  * {@link CutoutFeature}, so {@link unfold} emits the matching loop in the flat
3339
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.
3340
3366
  */
3341
3367
  function addCutout(part, spec) {
3342
- if (part.solid === void 0) return err(validationError("NO_SOLID", "addCutout: part has no folded solid to cut"));
3343
- const regionId = resolveRegionId(spec.region);
3344
- const treeResult = featureTree(part);
3345
- if (!treeResult.ok) return treeResult;
3346
- const tree = treeResult.value;
3347
- const framesResult = worldFrames(part, tree);
3348
- if (!framesResult.ok) return framesResult;
3349
- const worldFrame = framesResult.value.get(regionId);
3350
- if (worldFrame === void 0) return err(validationError("UNKNOWN_REGION", `addCutout: region '${spec.region}' not found`));
3351
- const localResult = localLoop(spec);
3352
- if (!localResult.ok) return localResult;
3353
- const local = localResult.value;
3354
- const oob = checkInBounds(local, regionExtent(part, regionId, worldFrame));
3355
- if (oob !== void 0) return err(validationError("CUTOUT_OUT_OF_BOUNDS", oob));
3356
- const dev = developedFrame(part, regionId, tree);
3357
- if (!dev.ok) return dev;
3358
- const toolResult = buildTool(local, worldFrame, part.thickness);
3359
- if (!toolResult.ok) return toolResult;
3360
- const cutResult = cut(part.solid, toolResult.value);
3361
- if (!cutResult.ok) return cutResult;
3362
- const solid = normalizeSolid(cutResult.value);
3363
- 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`));
3364
- const loop = local.map((p) => mapToFrame2(p, dev.value));
3365
- const feature = {
3366
- spec,
3367
- region: regionId,
3368
- loop,
3369
- area: polygonArea(loop)
3370
- };
3371
- return ok({
3372
- ...part,
3373
- solid,
3374
- cutouts: [...part.cutouts ?? [], feature]
3375
- });
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
+ }
3376
3435
  }
3377
3436
  /** Punch a circular hole of `diameter` centred at region-local `(x, y)`. */
3378
3437
  function addHole(part, region, x, y, diameter) {
@@ -3419,7 +3478,9 @@ function regionFrames(part, region) {
3419
3478
  if (!framesResult.ok) return framesResult;
3420
3479
  const world = framesResult.value.get(regionId);
3421
3480
  if (world === void 0) return err(validationError("UNKNOWN_REGION", `region '${region}' not found`));
3422
- 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);
3423
3484
  if (!dev.ok) return dev;
3424
3485
  return ok({
3425
3486
  regionId,
@@ -3493,45 +3554,107 @@ function regionExtent(part, regionId, worldFrame) {
3493
3554
  vMax: worldFrame.vLen
3494
3555
  };
3495
3556
  }
3496
- /** First out-of-bounds message, or undefined if the whole loop lies inside the region. */
3497
- function checkInBounds(local, ext) {
3498
- 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}]`;
3499
3597
  }
3500
3598
  /**
3501
- * Build the 3D cut tool: the local loop placed onto the region's bottom surface
3502
- * (`origin + x·u + y·v − HAIR·n`), turned into a face, and extruded through the
3503
- * sheet by `thickness + 2·HAIR` along +n. Using the world frame's `u`/`v`/`n` is
3504
- * 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`.
3505
3605
  */
3506
- function buildTool(local, f, thickness) {
3606
+ function buildTool(spec, local, f, thickness, scope) {
3507
3607
  const base = vecAdd(f.origin, vecScale(f.n, -.05));
3508
- const worldPts = local.map(([x, y]) => vecAdd(vecAdd(base, vecScale(f.u, x)), vecScale(f.v, y)));
3509
- const edges = [];
3510
- for (let i = 0; i < worldPts.length; i += 1) {
3511
- const a = worldPts[i];
3512
- const b = worldPts[(i + 1) % worldPts.length];
3513
- if (a === void 0 || b === void 0) return err(validationError("CUTOUT_TOOL_FAILED", "failed to index cutout loop points"));
3514
- edges.push(line(a, b));
3515
- }
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);
3516
3616
  const wire = wireLoop(edges);
3517
3617
  if (!wire.ok) return wire;
3618
+ scope.register(wire.value);
3518
3619
  if (!isPlanarWire(wire.value)) return err(validationError("CUTOUT_TOOL_FAILED", "cutout profile wire is not planar"));
3519
3620
  const profile = face(wire.value);
3520
3621
  if (!profile.ok) return profile;
3521
- const depth = thickness + 2 * HAIR$1;
3622
+ scope.register(profile.value);
3522
3623
  return extrude(profile.value, [
3523
3624
  f.n[0] * depth,
3524
3625
  f.n[1] * depth,
3525
3626
  f.n[2] * depth
3526
3627
  ]);
3527
3628
  }
3528
- /** Developed-plane {@link Frame2} of a region (base or flange) via the unfold layout. */
3529
- function developedFrame(part, regionId, tree) {
3530
- const treeResult = tree !== void 0 ? ok(tree) : featureTree(part);
3531
- if (!treeResult.ok) return treeResult;
3532
- const layout = layoutTree(part, treeResult.value, part.baseLength, part.width);
3533
- if (!layout.ok) return layout;
3534
- 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);
3535
3658
  if (placed === void 0) return err(validationError("UNKNOWN_REGION", `developedFrame: region '${regionId}' missing from layout`));
3536
3659
  return ok(placed.frame);
3537
3660
  }
@@ -4211,22 +4334,17 @@ function foldWithWarnings(input) {
4211
4334
  if (!relieved.ok) return relieved;
4212
4335
  part = relieved.value;
4213
4336
  }
4214
- for (const spec of input.baseCutouts ?? []) {
4215
- const cutResult = addCutout(part, {
4216
- ...spec,
4217
- region: ROOT_FLAT_ID
4218
- });
4219
- if (!cutResult.ok) return cutResult;
4220
- part = cutResult.value;
4221
- }
4222
- for (const region of input.regions) for (const spec of region.cutouts ?? []) {
4223
- const cutResult = addCutout(part, {
4224
- ...spec,
4225
- region: region.id
4226
- });
4227
- if (!cutResult.ok) return cutResult;
4228
- part = cutResult.value;
4229
- }
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;
4230
4348
  for (const spec of input.baseTabs ?? []) {
4231
4349
  const tabbed = addTab(part, {
4232
4350
  ...spec,
@@ -7103,6 +7221,10 @@ function relieveCorner(part, flangeIdA, flangeIdB, spec) {
7103
7221
  function addCutout$1(part, spec) {
7104
7222
  return addCutout(part, spec);
7105
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
+ }
7106
7228
  /** Punch a circular hole of `diameter` centred at region-local `(x, y)`. */
7107
7229
  function addHole$1(part, region, x, y, diameter) {
7108
7230
  return addHole(part, region, x, y, diameter);
@@ -7265,6 +7387,10 @@ var SheetMetalPartHandle = class SheetMetalPartHandle {
7265
7387
  cutout(spec) {
7266
7388
  return new SheetMetalPartHandle(unwrapOrThrow(addCutout$1(this.part, spec)));
7267
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
+ }
7268
7394
  /** Punch a circular hole of `diameter` centred at region-local `(x, y)`. */
7269
7395
  hole(region, x, y, diameter) {
7270
7396
  return new SheetMetalPartHandle(unwrapOrThrow(addHole$1(this.part, region, x, y, diameter)));
@@ -7420,6 +7546,9 @@ var SheetMetalBuilder = class SheetMetalBuilder {
7420
7546
  cutout(spec) {
7421
7547
  return this.build().cutout(spec);
7422
7548
  }
7549
+ cutouts(specs) {
7550
+ return this.build().cutouts(specs);
7551
+ }
7423
7552
  hole(region, x, y, diameter) {
7424
7553
  return this.build().hole(region, x, y, diameter);
7425
7554
  }
@@ -7489,4 +7618,4 @@ function foldFlat(input) {
7489
7618
  function fromSolid(solid, opts) {
7490
7619
  return new ForeignSolidHandle(solid, opts);
7491
7620
  }
7492
- 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.4",
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",