brepjs-sheetmetal 0.1.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/LICENSE ADDED
@@ -0,0 +1,191 @@
1
+
2
+ Apache License
3
+ Version 2.0, January 2004
4
+ http://www.apache.org/licenses/
5
+
6
+ TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
7
+
8
+ 1. Definitions.
9
+
10
+ "License" shall mean the terms and conditions for use, reproduction,
11
+ and distribution as defined by Sections 1 through 9 of this document.
12
+
13
+ "Licensor" shall mean the copyright owner or entity authorized by
14
+ the copyright owner that is granting the License.
15
+
16
+ "Legal Entity" shall mean the union of the acting entity and all
17
+ other entities that control, are controlled by, or are under common
18
+ control with that entity. For the purposes of this definition,
19
+ "control" means (i) the power, direct or indirect, to cause the
20
+ direction or management of such entity, whether by contract or
21
+ otherwise, or (ii) ownership of fifty percent (50%) or more of the
22
+ outstanding shares, or (iii) beneficial ownership of such entity.
23
+
24
+ "You" (or "Your") shall mean an individual or Legal Entity
25
+ exercising permissions granted by this License.
26
+
27
+ "Source" form shall mean the preferred form for making modifications,
28
+ including but not limited to software source code, documentation
29
+ source, and configuration files.
30
+
31
+ "Object" form shall mean any form resulting from mechanical
32
+ transformation or translation of a Source form, including but
33
+ not limited to compiled object code, generated documentation,
34
+ and conversions to other media types.
35
+
36
+ "Work" shall mean the work of authorship, whether in Source or
37
+ Object form, made available under the License, as indicated by a
38
+ copyright notice that is included in or attached to the work
39
+ (an example is provided in the Appendix below).
40
+
41
+ "Derivative Works" shall mean any work, whether in Source or Object
42
+ form, that is based on (or derived from) the Work and for which the
43
+ editorial revisions, annotations, elaborations, or other modifications
44
+ represent, as a whole, an original work of authorship. For the purposes
45
+ of this License, Derivative Works shall not include works that remain
46
+ separable from, or merely link (or bind by name) to the interfaces of,
47
+ the Work and Derivative Works thereof.
48
+
49
+ "Contribution" shall mean any work of authorship, including
50
+ the original version of the Work and any modifications or additions
51
+ to that Work or Derivative Works thereof, that is intentionally
52
+ submitted to the Licensor for inclusion in the Work by the copyright owner
53
+ or by an individual or Legal Entity authorized to submit on behalf of
54
+ the copyright owner. For the purposes of this definition, "submitted"
55
+ means any form of electronic, verbal, or written communication sent
56
+ to the Licensor or its representatives, including but not limited to
57
+ communication on electronic mailing lists, source code control systems,
58
+ and issue tracking systems that are managed by, or on behalf of, the
59
+ Licensor for the purpose of discussing and improving the Work, but
60
+ excluding communication that is conspicuously marked or otherwise
61
+ designated in writing by the copyright owner as "Not a Contribution."
62
+
63
+ "Contributor" shall mean Licensor and any individual or Legal Entity
64
+ on behalf of whom a Contribution has been received by the Licensor and
65
+ subsequently incorporated within the Work.
66
+
67
+ 2. Grant of Copyright License. Subject to the terms and conditions of
68
+ this License, each Contributor hereby grants to You a perpetual,
69
+ worldwide, non-exclusive, no-charge, royalty-free, irrevocable
70
+ copyright license to reproduce, prepare Derivative Works of,
71
+ publicly display, publicly perform, sublicense, and distribute the
72
+ Work and such Derivative Works in Source or Object form.
73
+
74
+ 3. Grant of Patent License. Subject to the terms and conditions of
75
+ this License, each Contributor hereby grants to You a perpetual,
76
+ worldwide, non-exclusive, no-charge, royalty-free, irrevocable
77
+ (except as stated in this section) patent license to make, have made,
78
+ use, offer to sell, sell, import, and otherwise transfer the Work,
79
+ where such license applies only to those patent claims licensable
80
+ by such Contributor that are necessarily infringed by their
81
+ Contribution(s) alone or by combination of their Contribution(s)
82
+ with the Work to which such Contribution(s) was submitted. If You
83
+ institute patent litigation against any entity (including a
84
+ cross-claim or counterclaim in a lawsuit) alleging that the Work
85
+ or a Contribution incorporated within the Work constitutes direct
86
+ or contributory patent infringement, then any patent licenses
87
+ granted to You under this License for that Work shall terminate
88
+ as of the date such litigation is filed.
89
+
90
+ 4. Redistribution. You may reproduce and distribute copies of the
91
+ Work or Derivative Works thereof in any medium, with or without
92
+ modifications, and in Source or Object form, provided that You
93
+ meet the following conditions:
94
+
95
+ (a) You must give any other recipients of the Work or
96
+ Derivative Works a copy of this License; and
97
+
98
+ (b) You must cause any modified files to carry prominent notices
99
+ stating that You changed the files; and
100
+
101
+ (c) You must retain, in the Source form of any Derivative Works
102
+ that You distribute, all copyright, patent, trademark, and
103
+ attribution notices from the Source form of the Work,
104
+ excluding those notices that do not pertain to any part of
105
+ the Derivative Works; and
106
+
107
+ (d) If the Work includes a "NOTICE" text file as part of its
108
+ distribution, then any Derivative Works that You distribute must
109
+ include a readable copy of the attribution notices contained
110
+ within such NOTICE file, excluding any notices that do not
111
+ pertain to any part of the Derivative Works, in at least one
112
+ of the following places: within a NOTICE text file distributed
113
+ as part of the Derivative Works; within the Source form or
114
+ documentation, if provided along with the Derivative Works; or,
115
+ within a display generated by the Derivative Works, if and
116
+ wherever such third-party notices normally appear. The contents
117
+ of the NOTICE file are for informational purposes only and
118
+ do not modify the License. You may add Your own attribution
119
+ notices within Derivative Works that You distribute, alongside
120
+ or as an addendum to the NOTICE text from the Work, provided
121
+ that such additional attribution notices cannot be construed
122
+ as modifying the License.
123
+
124
+ You may add Your own copyright statement to Your modifications and
125
+ may provide additional or different license terms and conditions
126
+ for use, reproduction, or distribution of Your modifications, or
127
+ for any such Derivative Works as a whole, provided Your use,
128
+ reproduction, and distribution of the Work otherwise complies with
129
+ the conditions stated in this License.
130
+
131
+ 5. Submission of Contributions. Unless You explicitly state otherwise,
132
+ any Contribution intentionally submitted for inclusion in the Work
133
+ by You to the Licensor shall be under the terms and conditions of
134
+ this License, without any additional terms or conditions.
135
+ Notwithstanding the above, nothing herein shall supersede or modify
136
+ the terms of any separate license agreement you may have executed
137
+ with Licensor regarding such Contributions.
138
+
139
+ 6. Trademarks. This License does not grant permission to use the trade
140
+ names, trademarks, service marks, or product names of the Licensor,
141
+ except as required for reasonable and customary use in describing the
142
+ origin of the Work and reproducing the content of the NOTICE file.
143
+
144
+ 7. Disclaimer of Warranty. Unless required by applicable law or
145
+ agreed to in writing, Licensor provides the Work (and each
146
+ Contributor provides its Contributions) on an "AS IS" BASIS,
147
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
148
+ implied, including, without limitation, any warranties or conditions
149
+ of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
150
+ PARTICULAR PURPOSE. You are solely responsible for determining the
151
+ appropriateness of using or redistributing the Work and assume any
152
+ risks associated with Your exercise of permissions under this License.
153
+
154
+ 8. Limitation of Liability. In no event and under no legal theory,
155
+ whether in tort (including negligence), contract, or otherwise,
156
+ unless required by applicable law (such as deliberate and grossly
157
+ negligent acts) or agreed to in writing, shall any Contributor be
158
+ liable to You for damages, including any direct, indirect, special,
159
+ incidental, or consequential damages of any character arising as a
160
+ result of this License or out of the use or inability to use the
161
+ Work (including but not limited to damages for loss of goodwill,
162
+ work stoppage, computer failure or malfunction, or any and all
163
+ other commercial damages or losses), even if such Contributor
164
+ has been advised of the possibility of such damages.
165
+
166
+ 9. Accepting Warranty or Additional Liability. While redistributing
167
+ the Work or Derivative Works thereof, You may choose to offer,
168
+ and charge a fee for, acceptance of support, warranty, indemnity,
169
+ or other liability obligations and/or rights consistent with this
170
+ License. However, in accepting such obligations, You may act only
171
+ on Your own behalf and on Your sole responsibility, not on behalf
172
+ of any other Contributor, and only if You agree to indemnify,
173
+ defend, and hold each Contributor harmless for any liability
174
+ incurred by, or claims asserted against, such Contributor by reason
175
+ of your accepting any such warranty or additional liability.
176
+
177
+ END OF TERMS AND CONDITIONS
178
+
179
+ Copyright 2025-2026 Andy Aragon
180
+
181
+ Licensed under the Apache License, Version 2.0 (the "License");
182
+ you may not use this file except in compliance with the License.
183
+ You may obtain a copy of the License at
184
+
185
+ http://www.apache.org/licenses/LICENSE-2.0
186
+
187
+ Unless required by applicable law or agreed to in writing, software
188
+ distributed under the License is distributed on an "AS IS" BASIS,
189
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
190
+ See the License for the specific language governing permissions and
191
+ limitations under the License.
package/README.md ADDED
@@ -0,0 +1,415 @@
1
+ # brepjs-sheetmetal
2
+
3
+ > Experimental, unpublished satellite package.
4
+
5
+ A sheet-metal CAD domain for [brepjs](https://github.com/andymai/brepjs). It authors parametric
6
+ sheet-metal parts (flange/bend features), auto-miters corners, folds to 3D, and unfolds to a flat
7
+ pattern for fabrication.
8
+
9
+ Pipeline: **author part → auto-miter corners → fold to 3D → unfold to flat pattern → export DXF/STEP/GLB + bend report.**
10
+
11
+ ## Scope
12
+
13
+ Straight (cylindrical) bends, authoring our own parts (not unfolding foreign solids). The bend model is
14
+ K-factor based by default, with optional **shop bend tables** (see below) for parts that develop per a
15
+ press-brake's measured allowance/deduction values.
16
+
17
+ - Bend allowance: `BA = (π/180)·|angle|·(R + K·T)`, or interpolated from a referenced bend table.
18
+ - Defaults: units are mm; K-factor `0.44`; inner radius `= thickness`.
19
+
20
+ Authoring supports arbitrary bend trees: flanges off any of the four base edges, **chained
21
+ flange-off-flange** bends (U-channels, Z-profiles, box walls), **up/down** fold direction, and
22
+ **partial/offset** flanges (more than one flange per edge). Closed profiles (tubes/boxes) author a seam
23
+ edge that the unfold leaves uncut as a `SEAM_CUT`, flattening into a valid connected pattern.
24
+
25
+ ## Status
26
+
27
+ | Area | State |
28
+ | --------------- | -------------------------------------------------------------------------------------------- |
29
+ | Authoring | 4-edge flanges, chained bends, up/down, partial/offset flanges, closed-box seams |
30
+ | Unfold | recursive BFS tree-walk → rectilinear-union flat pattern + bend lines + developed area |
31
+ | Fold | `FlatInput` region-tree → 3D part (inverse of unfold); round-trips `unfold(fold)` |
32
+ | Reliefs | bend reliefs (slots at partial-flange bend-line ends), corner reliefs (notch at a corner) |
33
+ | Cutouts | holes / slots (rect + obround) / polygons punched on the base or a folded flange |
34
+ | Tabs / joints | additive edge tabs, self-fixturing tab-and-slot joints (tab + matching mating slot) |
35
+ | Form features | louvers (3-side cut + formed flap), embosses / dimples (round raised / recessed forms) |
36
+ | Contour flange | open line/arc profile swept along a base edge (multi-bend section); EXACT development |
37
+ | Lofted flange | ruled transition between two open profiles; triangulated development (exact if developable) |
38
+ | Hems | edge folded back ~180°+ (closed / open / teardrop / rolled); EXACT development |
39
+ | Jogs | two opposite bends stepping a flat by a Z-offset (joggle); EXACT development |
40
+ | Bend tables | shop allowance/deduction tables, angle-linear + thickness×radius-bilinear interp, clamp-warn |
41
+ | Miter / outputs | auto corner-miter, multi-layer DXF (incl. FORM layer), JSON bend report, warnings |
42
+ | API | functional `*Fns` → short-named `api.ts` → fluent `sheetMetal()` facade |
43
+
44
+ `fold(input: FlatInput)` folds a flat pattern back up into a 3D part, the inverse of `unfold`. A
45
+ `FlatInput` is a region-tree (a base rectangle plus child fold regions, each with a fold line, angle,
46
+ direction, and bend rule), the inverse of the unfold layout. `patternToFlatInput(pattern, { thickness,
47
+ ruleFor })` recovers that region-tree from the **2D flat-pattern geometry alone** (the developed outline
48
+ wire and bend-line edges), reading real coordinates back out with the public `getEdges` / `curveStartPoint`
49
+ / `curveEndPoint` readers; only the bend rule (which a flat pattern cannot encode) is a supplied input.
50
+ `partToFlatInput(part)` chains `unfold` → `patternToFlatInput`, so `fold(partToFlatInput(part))` round-trips
51
+ volume, validity, and bend/flange counts through the 2D geometry, making the round-trip a genuine,
52
+ non-circular oracle. Fold reuses the forward authoring bend geometry wholesale (no duplicated bend math),
53
+ and rides SEAM_CUT / MIN_RADIUS warnings inside the Ok payload.
54
+
55
+ ## Reliefs
56
+
57
+ Multi-bend parts tear or collide at corners unless material is relieved. Reliefs are recorded features
58
+ (like corner miters): cut from the folded 3D solid and replayed by `unfold` as 2D notches subtracted from
59
+ the developed outline (so they ride in the DXF `OUTLINE` layer and shrink `developedArea`).
60
+
61
+ - `addBendRelief(part, flangeId, spec?)` cuts a slot at each **mid-edge end** of a partial/offset flange's
62
+ bend line, the ends that don't reach the parent-edge endpoint, where the parent material would tear.
63
+ `autoBendReliefs(part, spec?)` adds one to every partial-span flange (full-span flanges are skipped).
64
+ - `cornerRelief(part, flangeIdA, flangeIdB, spec?)` cuts a square notch centred on the shared corner of two
65
+ adjacent flanges, the notch alternative to a 45° miter. The square side is `spec.width` when given, else
66
+ the depth clearance. It records the corner as resolved, so the `COLLISION` warning the un-relieved corner
67
+ raised goes away.
68
+
69
+ A `ReliefSpec` is `{ shape: 'rectangular' | 'obround'; width?; depth? }`. Defaults: `width ≈ thickness`,
70
+ `depth ≈ developedLength(bend) + thickness`. `obround` records the rounded-slot intent; the developed notch
71
+ and 3D cut are rectangular for both shapes in this version. Reliefs round-trip through `fold` via a
72
+ `FoldRegion.bendRelief` field; the strict geometric round-trip oracle (`partToFlatInput → fold`) skips
73
+ relief'd parts because a notched outline is not a plain rectangle the parser can re-derive (same as mitered
74
+ parts).
75
+
76
+ ## Cutouts
77
+
78
+ Holes, slots, and polygon cutouts are 2D features punched through a flat region's thickness. They survive
79
+ fold/unfold: each appears in the 3D solid, in the flat pattern (`FlatPattern.holes`, one closed wire per
80
+ cutout), and in the DXF (a `CUTOUT` layer). The outer outline is unchanged; cutouts are interior loops.
81
+
82
+ A `CutoutSpec` names the `region` (a flange id, or `'base'`/`'root'` for the base flat) and places the
83
+ feature in that region's **local** frame: `(0, 0)` is the region's near corner, `+x` runs along the region's
84
+ `u` axis and `+y` along its `v` axis. The discriminated union is `hole` (`{ x, y, diameter }`), `slot`
85
+ (`{ x, y, length, width, angleDeg?, round? }`, where `round` makes the ends semicircular/obround), or `polygon`
86
+ (`{ points: [x, y][] }`).
87
+
88
+ - `addCutout(part, spec)` / `addHole(part, region, x, y, diameter)` / `addSlot(part, region, opts)` /
89
+ `addPolygonCutout(part, region, points)` cut the feature through the sheet on the **correct face**: the 2D
90
+ profile is built in region-local coords, then placed via the region's world frame (origin/u/v/n), so a
91
+ hole authored on a folded flange lands on the flange face, not the base plane. Rejects a feature outside
92
+ the region (`CUTOUT_OUT_OF_BOUNDS`) and guards a valid, single-bodied result (`CUTOUT_SEVERED_SOLID`).
93
+ - `unfold` emits the matching developed loop at the cutout's flat-pattern location (mapped through the same
94
+ region's developed frame) and drops `developedArea` by the cutout areas.
95
+
96
+ Cutouts round-trip through `fold` via `FoldRegion.cutouts` (and `FlatInput.baseCutouts` for the base):
97
+ `partToFlatInput(part)` carries the region-local specs across, so `fold(partToFlatInput(part))` reproduces a
98
+ cutout'd part's volume. Unlike notched reliefs, cutouts don't change the outer outline, so a cutout'd part
99
+ still round-trips through the strict outline oracle.
100
+
101
+ ## Tabs and tab-and-slot joints
102
+
103
+ A **tab** is the additive counterpart of a cutout: a rectangular protrusion of material extending
104
+ **outward** from a flat region's edge. `addTab(part, { region, side, offset, width, length })` builds the
105
+ tab as a region-local rectangle just past the chosen `side`, places it on the correct folded face via the
106
+ region's world frame, extrudes it through the sheet thickness and **fuses** it on, so the volume rises by
107
+ `width · length · thickness`. The same rectangle is mapped through the region's developed frame and recorded
108
+ as a `TabFeature`, so `unfold` **extends the outer outline** by the protrusion and grows `developedArea` by
109
+ the tab area. Guards a valid, single-bodied result (`TAB_INVALID_SOLID`) and rejects a tab overhanging its
110
+ edge (`TAB_OUT_OF_BOUNDS`).
111
+
112
+ `tabAndSlot(part, tab, { region, x, y, clearance? })` is the headline self-locating joint: it fuses a tab on
113
+ one region **and** punches a matching **slot cutout** on the mating region, sized so the tab's cross-section
114
+ (`width × thickness`) inserts into the slot. The slot is `tab.width + clearance` long by `thickness +
115
+ clearance` wide, always strictly larger than the tab cross-section, so the joint mates (clearance defaults
116
+ to `0.1` mm). Use it to make boxes/enclosures that self-fixture before welding.
117
+
118
+ Tabs ride through `fold` via `FoldRegion.tabs` / `FlatInput.baseTabs`, so `fold` re-fuses them and a tab'd
119
+ part folds back into the same volume. Like cutouts and forms, a tab carries its region-local spec, so
120
+ `partToFlatInput` re-attaches it onto the recovered region and `fold(partToFlatInput(part))` round-trips a
121
+ tab'd part's volume through the strict oracle. Although a tab protrudes the outer outline, the parser
122
+ recovers the base/flange rectangles from the bend lines and the unprotruded edges, so the protrusion does
123
+ not corrupt region recovery (a tab on an unmappable region fails loudly with `TAB_REGION_UNMAPPED` rather
124
+ than being silently dropped).
125
+
126
+ ## Form features (louvers, embosses, dimples)
127
+
128
+ Form features are **locally formed**: they neither remove nor add net material, so the developed **outline
129
+ and area are unchanged**; the flat pattern instead carries the fabrication markers on a `FORM` DXF layer.
130
+
131
+ - `louver(part, { region, x, y, length, width, height, direction? })`: a vent cut on three sides with the
132
+ flap formed up along the hinge. In the **flat pattern** it emits the three cut sides as an **open** cut
133
+ path in `FlatPattern.formCuts` (the fabricator cuts three of the footprint's four sides, all but the
134
+ hinge) plus the hinge fold line in `FlatPattern.formHinges`, so a fabricator cuts the U and folds the flap
135
+ about the uncut hinge. The cut path is emitted as an open LWPOLYLINE on the DXF `FORM` layer (not a closed
136
+ rectangle, which would drop the flap out).
137
+ - `emboss(part, { region, x, y, diameter, height, kind: 'emboss' | 'dimple' })`: a round local form, raised
138
+ (`emboss`) or recessed (`dimple`). The footprint circle is emitted as a marker (`FlatPattern.formMarkers`).
139
+
140
+ **3D fidelity (simplified, documented).** The public CSG-only API makes true sheet forming (bending the flap
141
+ continuous with the parent, or stretch-forming a spherical cap) impractical, so the 3D representations are
142
+ deliberately simplified while keeping a **valid single solid**; the flat pattern is the fabrication-critical
143
+ output:
144
+
145
+ - _Louver_: the vent opening (`length × width`) is cut fully through the sheet, and the formed flap is
146
+ represented as a thin plate hinged on one side and tilted up to `height`, fused at the hinge so the result
147
+ stays one body. (True forming keeps the flap material continuous with the parent.)
148
+ - _Emboss_: a short cylinder fused onto the formed face (raised by `height`). _Dimple_: a shallow cylinder
149
+ recess cut into the formed face (recessed by `height`, never through; rejected if `height ≥ thickness`).
150
+ The flat-topped round approximates a true spherical/conical form so the solid stays valid.
151
+
152
+ Forms ride through `fold` via `FoldRegion.forms` / `FlatInput.baseForms`. Because they are material-neutral
153
+ they don't change the outline, so a **formed part still round-trips** through the strict outline oracle
154
+ (`partToFlatInput` carries the region-local form specs across, exactly as it does for cutouts).
155
+
156
+ ## Contour & lofted/ruled transition flanges
157
+
158
+ Two profile-driven flanges extend authoring beyond single straight bends:
159
+
160
+ - `contourFlange(part, { id, side, profile, rule?, offset?, width? })`: an **open 2D profile** (alternating
161
+ `{ kind: 'line', length }` flats and `{ kind: 'arc', radius, angleDeg, direction }` bends) swept along a
162
+ straight base edge into a multi-bend cross-section (a return, a hat/top-hat, a J). Each arc becomes a
163
+ recorded `BendFeature` (id `contour::<flange>::<n>`) and chains frame-to-frame exactly as flange-off-flange
164
+ bends do. The **development is EXACT**: the developed strip length is the sum of each segment's developed
165
+ length (lines: `length`; arcs: the canonical `developedLength` = `(π/180)·|angle|·(R + K·T)`). The unfold
166
+ lays the strip out straight along the base edge with one bend line per arc at its exact cumulative
167
+ arc-length offset.
168
+ - `loftedFlange(part, { id, profileA, profileB, height, thickness? })`: a **ruled transition** between two
169
+ parallel open profiles (`profileA` at z=0, `profileB` at z=`height`, equal vertex counts), thickened to a
170
+ valid solid by triangulating each quad of the ruled surface and extruding each triangle. The
171
+ **development is by triangulation**, the standard sheet-metal transition development: each quad is split
172
+ along a diagonal into two triangles laid out flat preserving their true 3D edge lengths. For a genuinely
173
+ **developable** transition (planar quads / single-curvature) this is **exact to tolerance**; for a
174
+ non-developable (twisted) transition it is an **approximation**, and the unfold emits a
175
+ `DEVELOPMENT_APPROXIMATE` warning (inside the `Ok` payload). The triangulated boundary is emitted as a
176
+ valid closed wire in `FlatPattern.loftedDevelopments`.
177
+
178
+ Both flanges have **non-rectilinear** developments, so, like miters and tabs, they sit outside the strict
179
+ `patternToFlatInput` outline oracle: verification leans on developed-length/area invariants and solid
180
+ validity rather than a fold round-trip. The contour flange's developed strip still joins the rectilinear
181
+ outline union; the lofted flange's triangulated blank is a separate developed boundary.
182
+
183
+ ## Hems & jogs
184
+
185
+ Hems and jogs are multi-bend edge features built on the same frame-to-frame profile chainer as the contour
186
+ flange, so their developments are **EXACT** (Σ segment developed lengths, via the table-aware
187
+ `developedLength`). Both attach to any flat **region**, the base (`'base'`/`'root'`) or a named flange,
188
+ edge, record their curl/step sub-bends as `hem::<id>::<n>` / `jog::<id>::<n>` `BendFeature`s (skipped by the
189
+ feature-tree spanning walk, like `contour::` bends), and lay their developed strip out straight along the
190
+ region edge with one bend line per sub-bend.
191
+
192
+ - `hem(part, { region, side, type, length?, radius?, gap?, offset?, width?, rule? })` folds a region edge
193
+ back onto its parent. Four `type`s set the curl angle and the gap:
194
+ - **`closed`**: a tight ~180° fold, the return flat against the parent. The curl radius is inflated by a
195
+ small **HAIR** clearance (0.05 mm) so the fused solid stays valid (a true zero-gap fold makes the curl's
196
+ inner cylinder coincide with the parent face → a self-touching, non-manifold solid). `length` is the
197
+ return-leg length (must be ≥ one thickness).
198
+ - **`open`**: a ~180° fold with a clear `gap` (default = `radius`) between the return and the parent; the
199
+ curl radius is sized from the gap, so its developed curl length is the exact 180° allowance at that radius.
200
+ - **`teardrop`**: a >180° (~210°) curl leaving a small teardrop opening, then a short return.
201
+ - **`rolled`**: a full ~270° circular roll (a curled / safe edge); no flat return.
202
+
203
+ The curl is split into ≤120° sub-arcs (the bend-patch wedge degenerates at ≥180°), each a recorded
204
+ sub-bend; the bend allowance is linear in angle, so Σ sub-arc allowances == the single-curl allowance. The
205
+ **developed length** is Σ curl allowances + the return length, laid out straight past the edge.
206
+
207
+ - `jog(part, { region, side, position, offsetHeight, angle?, runOut?, radius?, offset?, width?, rule? })`
208
+ steps a flat by `offsetHeight` perpendicular to its plane with **two opposite bends** (`+θ` then `−θ`,
209
+ `angle` default 45°), then continues parallel. The connecting step run is solved so the two arcs' rise plus
210
+ the step rise equals `offsetHeight` exactly; the resulting run-out bottom face sits exactly `offsetHeight`
211
+ above the base bottom face (verified by measurement, not just construction). The flat carries the **two
212
+ bend lines** (one `up`, one `down`); the **developed length** is `position + 2·allowance + stepRun +
213
+ runOut`. `offsetHeight` must exceed the bends' own rise `(T + 2R)(1 − cos θ)`, else the jog is rejected.
214
+
215
+ Like the contour/lofted flanges, hems and jogs have non-rectilinear developments and so sit outside the
216
+ strict `partToFlatInput → fold` round-trip oracle; verification leans on developed-length invariants, the
217
+ measured jog offset, and solid validity.
218
+
219
+ ## Nesting
220
+
221
+ `nest(patterns, { sheet, margin?, spacing?, allowRotation?, strategy? })` arranges a set of developed flat
222
+ patterns onto stock sheets to reduce waste, returning `Result<NestResult>` (`{ sheets, unplaced, warnings }`).
223
+ Each sheet carries its `placements` (`{ patternIndex, x, y, rotationDeg }`) and a `utilization`.
224
+ `nestToDXF(result, patterns, sheetIndex)` then emits one fabrication-ready, multi-layer DXF per sheet,
225
+ translating and rotating every placed pattern (outline + bend lines + holes + forms) onto the sheet.
226
+
227
+ Two strategies are available via `strategy`:
228
+
229
+ ### `strategy: "bbox"` (default): bounding-box nesting
230
+
231
+ Packs each part as its axis-aligned **outline bounding box**, so parts do **not** interlock: a concave or
232
+ L-shaped part reserves its full rectangular footprint and leaves the in-bbox waste unused. `rotationDeg` is
233
+ `0` or `90`. This remains the default so existing callers are unchanged.
234
+
235
+ - **Algorithm.** A shelf (level) packing heuristic. Parts are sorted largest-first (by bbox height, then area)
236
+ and placed left-to-right into horizontal shelves; a new shelf opens below when the current row fills, and a
237
+ **new sheet** opens when the next shelf would overflow the usable height.
238
+ - **Rotation.** With `allowRotation`, each part is tried at `0°` and `90°` and the orientation that fits (or
239
+ packs shorter) is kept.
240
+ - **Utilization.** Σ placed part **bounding-box** areas ÷ usable-sheet area, a conservative lower bound that
241
+ does not credit inter-part gaps or intra-bbox waste.
242
+
243
+ ### `strategy: "nfp"`: true-shape (no-fit-polygon) nesting
244
+
245
+ Packs the actual **outline polygons** so concave parts interlock; an L-shaped part nests a rotated copy into
246
+ its notch, fitting more material per sheet than bbox packing ever can. `rotationDeg` may be `0/90/180/270`.
247
+
248
+ > **Heuristic, not optimal.** The nfp packer is a **bottom-left-fill** heuristic (largest-first, with a
249
+ > discretised raster scan over each part's orientations). It finds good interlocks but is **not** a provably
250
+ > optimal solver; a different part order or finer rotation set may pack tighter. What it guarantees: placed
251
+ > outline polygons never overlap (within `spacing`), all placements stay inside `[margin, sheet − margin]`, an
252
+ > oversized part goes to `unplaced` with a `PART_TOO_LARGE` warning (never dropped, never an infinite loop).
253
+
254
+ - **Correctness backbone.** Overlap is tested at the **polygon** level: two outlines overlap iff any edges
255
+ cross **or** one contains a vertex of the other (full containment), with a configurable `spacing` clearance.
256
+ A bbox-only check would let a part collide with a concave neighbour, so the true outline is always used.
257
+ - **Utilization.** Σ placed part **outline-polygon** areas ÷ usable-sheet area, so an L-shaped part credits
258
+ only its true material; the two strategies' utilizations are directly comparable on the same parts.
259
+ - **Geometry assumption.** Outlines are read as straight-edged polylines (one vertex per edge start point);
260
+ arc edges are approximated by their endpoints, the same assumption the DXF/SVG writers and the bbox nester
261
+ make.
262
+
263
+ On a set of identical L-shaped parts, `"nfp"` interlocks them and typically uses roughly **half the sheets** of
264
+ `"bbox"` (see the snapshot harness's `nfp vs bbox` demo).
265
+
266
+ ```ts
267
+ const patterns = parts.map((p) => unfold(p).value.pattern);
268
+
269
+ // Default bounding-box nesting:
270
+ const bbox = nest(patterns, {
271
+ sheet: { width: 1250, height: 2500 },
272
+ margin: 5,
273
+ spacing: 3,
274
+ allowRotation: true,
275
+ });
276
+
277
+ // True-shape nesting - interlocks concave parts for higher utilization:
278
+ const nfp = nest(patterns, {
279
+ sheet: { width: 1250, height: 2500 },
280
+ margin: 5,
281
+ spacing: 3,
282
+ allowRotation: true,
283
+ strategy: 'nfp',
284
+ });
285
+
286
+ const dxf = nestToDXF(nfp.value, patterns, 0); // fabrication-ready DXF for sheet 0
287
+ ```
288
+
289
+ ## Foreign-solid import & unfold
290
+
291
+ `unfoldSolid(solid, { kFactor? })` (fluent: `fromSolid(solid).unfold()`) flattens an **arbitrary imported
292
+ sheet-metal solid that has no feature tree** by detecting its geometry numerically, the reverse of the
293
+ authored path. It reads only the B-rep:
294
+
295
+ - **Classify faces** by surface type: planar faces are panel faces, cylindrical faces are bend faces. Any
296
+ other surface type (cone, sphere, spline, …) is reported `UNSUPPORTED_FACE` and skipped (the unfold never
297
+ silently invents a flat for an unrecognised face).
298
+ - **Pair flats**: the two large parallel planar faces of a panel, a sheet thickness apart, are the
299
+ mid-surface flat. Thickness is detected as the smallest opposed-planar-pair gap.
300
+ - **Fit bends**: `fitCylinder(face)` recovers a bend's axis + radius + swept angle from sampled points and
301
+ normals. The axis direction is the common perpendicular of the sampled normals (every cylinder normal is
302
+ ⟂ the axis), recovered as the sign-aligned average of cross products of non-parallel normal pairs; the
303
+ axis line and radius come from a least-squares circle fit of the points projected onto the plane ⟂ axis;
304
+ the radius is the mean point-to-axis distance. A fit whose residual exceeds tolerance is rejected (warned).
305
+ Recovery is high-precision: radius and axis of a known `cylinder()` primitive are recovered to ~1e-6.
306
+ - **Build the bend graph** (flats = nodes; a bend connects the two flats it shares edges with), take a
307
+ spanning tree from a root flat, and turn non-tree bends into seam cuts (`SEAM_CUT`, warned).
308
+ - **Unfold** by walking the tree, replacing each cylindrical region with a developed strip of length
309
+ `developedLength(angle, thickness, { innerRadius, kFactor })`. Because a foreign solid carries no material
310
+ K-factor, the default is the **mid-surface neutral axis (K = 0.5)**; pass `{ kFactor }` to match a known
311
+ material. Bend direction (up/down) is read from the cylinder centre relative to the parent flat.
312
+
313
+ **Supported class**: roughly-uniform-thickness solids whose panels are **planar** and whose bends are
314
+ **cylindrical** (straight bends). Outside this class the unfold **warns rather than mis-unfolding**:
315
+ non-uniform thickness, conical/spline bend faces, non-manifold / non-sheet topology, and faces that don't
316
+ fit a cylinder within tolerance all surface as warnings inside the `Ok` payload (or a clear `Err` when no
317
+ sheet-metal structure is detected at all, e.g. a bare sphere). The detection is validated by an oracle:
318
+ author a part, take only its `.solid`, run `unfoldSolid`, and confirm the detected unfold reproduces the
319
+ authored unfold's developed area, bend count, flat count, and total flat bbox (single bend, L-bracket,
320
+ U-channel), reading only the solid, never the feature tree.
321
+
322
+ ## Bend tables
323
+
324
+ By default a bend develops by the K-factor formula `BA = (π/180)·|angle|·(R + K·T)`. A `BendRule` may
325
+ instead reference a **shop bend table** by id (`rule.bendTableRef`), in which case the developed length is
326
+ **interpolated from the table**: linear in bend angle, bilinear across thickness × inner radius (with
327
+ nearest-radius substitution for sparse one-radius-per-gauge tables), clamping to the nearest tabulated
328
+ entry outside the table's range (and emitting a `MIN_RADIUS` unfold warning rather than extrapolating).
329
+
330
+ A table is `{ id, kind: 'allowance' | 'deduction', rows: { thickness, radius, angleDeg, value }[] }`. An
331
+ `allowance` table tabulates the developed arc length directly; a `deduction` table tabulates the bend
332
+ deduction and is converted on lookup via the outside-setback relation `OSSB = (R+T)·tan(θ/2)`,
333
+ `BD = 2·OSSB − BA` ⇒ `BA = 2·OSSB − BD` (for a 90° bend, `OSSB = R+T`).
334
+
335
+ Resolution precedence at the single resolution point (`resolveBendAllowance`, which `developedLength`
336
+ delegates to): **(1) `bendTableRef` table → (2) explicit `rule.allowance` override → (3) K-factor formula**.
337
+ Parts with no `bendTableRef` are byte-for-byte unchanged. Two starter air-bend tables ship auto-registered
338
+ on first access (`steel-airbend` (mild steel) and `aluminum-airbend` (5052-H32)) with published 90°
339
+ allowances scaled across {30, 60, 90, 120}° (sources: SheetMetal.Me charts, Machinery's Handbook 29th ed.).
340
+
341
+ ```ts
342
+ import { registerBendTable, author, unfold } from 'brepjs-sheetmetal';
343
+
344
+ registerBendTable({
345
+ id: 'my-shop-steel',
346
+ kind: 'allowance',
347
+ rows: [{ thickness: 1.52, radius: 1.5, angleDeg: 90, value: 3.63 }],
348
+ });
349
+
350
+ const part = author({
351
+ thickness: 1.52,
352
+ base: { length: 40, width: 40 },
353
+ flanges: [
354
+ {
355
+ id: 'fx',
356
+ length: 18,
357
+ angleDeg: 90,
358
+ side: 'xmax',
359
+ rule: { innerRadius: 1.5, kFactor: 0.44, bendTableRef: 'my-shop-steel' },
360
+ },
361
+ ],
362
+ });
363
+ // unfold(part.value) now develops the bend at BA = 3.63 mm (the table), not (π/2)·(R + K·T).
364
+ ```
365
+
366
+ ## Design
367
+
368
+ All geometry for **authored** parts is computed analytically from the feature tree; bend axis, radius, and
369
+ angle are known inputs recorded on each `BendFeature`, so the unfold reads the tree rather than
370
+ reverse-engineering the B-rep. The **foreign-solid** path (`unfoldSolid`) is the exception: it detects bend
371
+ geometry numerically from the public surface queries (no recorded tree). No kernel/WASM changes are required
372
+ for either path.
373
+
374
+ ## Usage
375
+
376
+ ```ts
377
+ import { MATERIALS, getMaterial } from 'brepjs-sheetmetal';
378
+
379
+ const steel = getMaterial('steel-16ga');
380
+ ```
381
+
382
+ All public operations return `Result<T>` (from `brepjs`); non-fatal warnings travel inside the `Ok` payload.
383
+
384
+ ## Snapshot harness
385
+
386
+ A standalone visual harness lives in `harness/snapshot.ts`. It imports
387
+ `brepjs-sheetmetal` directly (no playground dependency), builds a set of demos
388
+ (the headline L-bracket with a mitered corner, a tray, reliefs, a cutout panel, a
389
+ **tab-and-slot box** (self-locating corner joint), and a **louvered panel** (vent
390
+ flaps + emboss/dimple)), then renders each folded 3D part next to its developed
391
+ flat pattern as a single side-by-side SVG and also writes the folded solid as STEP.
392
+ It also runs a **nesting** demo (a handful of parts bbox-packed onto one sheet),
393
+ printing the sheet count + utilization and writing the nested sheet as DXF + SVG,
394
+ plus an **nfp vs bbox** comparison nesting identical L-shaped parts with both
395
+ strategies and printing the true-shape utilization gain.
396
+
397
+ ```bash
398
+ npm run snapshot --workspace=brepjs-sheetmetal
399
+ # writes harness/out/bracket.svg (folded isometric wireframe + flat pattern)
400
+ # harness/out/bracket.step (folded solid)
401
+ ```
402
+
403
+ The folded view is an isometric edge-wireframe projection of the kernel mesh, so
404
+ the harness runs as a plain `tsx` script wherever the WASM kernel runs, with no
405
+ headless browser or GPU required. Output lands in `harness/out/` (gitignored).
406
+
407
+ ## Development
408
+
409
+ ```bash
410
+ npm run typecheck --workspace=brepjs-sheetmetal
411
+ npm run lint --workspace=brepjs-sheetmetal
412
+ npm run build --workspace=brepjs-sheetmetal
413
+ npm run test --workspace=brepjs-sheetmetal
414
+ npx tsc -p packages/brepjs-sheetmetal/harness/tsconfig.json # typecheck the harness
415
+ ```
@@ -0,0 +1,21 @@
1
+ import { Result } from 'brepjs';
2
+ import { BendRule, SheetMetalWarning } from './types.js';
3
+ /**
4
+ * Bend allowance — the developed (flat) length of material consumed by a bend,
5
+ * measured along the neutral axis. Resolution precedence (see
6
+ * {@link resolveBendAllowance}): a referenced bend table, then an explicit
7
+ * `rule.allowance` override, then the K-factor formula
8
+ * BA = (π/180)·|angle|·(R + K·T). An optional `onWarning` receives the clamp
9
+ * warning when a table query falls outside its tabulated range.
10
+ */
11
+ export declare function bendAllowance(angleDeg: number, thickness: number, rule: BendRule, onWarning?: (warning: SheetMetalWarning) => void): Result<number>;
12
+ /**
13
+ * Developed length of the bend region when flattened — the neutral-axis arc
14
+ * length that replaces the curved patch in the flat pattern. Identical to the
15
+ * bend allowance (kept distinct in name for the unfold call sites that consume
16
+ * it as a strip width), and routed through the same {@link resolveBendAllowance}
17
+ * resolution (table → explicit allowance → K-factor).
18
+ */
19
+ export declare function developedLength(angleDeg: number, thickness: number, rule: BendRule, onWarning?: (warning: SheetMetalWarning) => void): Result<number>;
20
+ /** Neutral-axis radius R + K·T (the radius the developed arc length is measured at). */
21
+ export declare function neutralRadius(thickness: number, rule: BendRule): Result<number>;