Sheet Metal
SheetMetal is a declarative namespace for composing bent sheet-metal parts from planar flanges and bend specifications. StandardLayout (in SheetLayout.swift) is a companion Sheet extension that auto-arranges front/top/side/iso engineering views on a drawing sheet. Neither type wraps OCCT sheet-metal primitives directly; both build on Shape.extrude, Shape.union, Shape.filleted, and Drawing view factories.
Topics
- SheetMetal.Flange · SheetMetal.BendDirection · SheetMetal.Bend · SheetMetal.BuildError · SheetMetal.Builder · StandardLayout · StandardLayout.PlacedView · Sheet Extension — standardLayout
SheetMetal.Flange
A single sheet-metal flange: a closed 2D profile positioned in world space via (origin, uAxis, vAxis), extruded along normal by the builder’s thickness.
public struct Flange: Sendable {
public let id: String
public let profile: [SIMD2<Double>]
public let origin: SIMD3<Double>
public let uAxis: SIMD3<Double>
public let vAxis: SIMD3<Double>
public let normal: SIMD3<Double>
}
All three axes are stored explicitly to avoid handedness surprises when flanges are placed in arbitrary world orientations. If vAxis is omitted at init, it is derived as cross(normal, uAxis).
Flange.init(id:profile:origin:normal:uAxis:vAxis:)
Constructs a positioned flange from a 2D profile and world-space axes.
public init(
id: String,
profile: [SIMD2<Double>],
origin: SIMD3<Double>,
normal: SIMD3<Double>,
uAxis: SIMD3<Double>,
vAxis: SIMD3<Double>? = nil
)
The normal is normalised at init time; vAxis defaults to cross(normal, uAxis) if nil.
- Parameters:
id— unique string identifier referenced byBend.fromFlangeID/Bend.toFlangeID.profile— ordered 2D polygon vertices (at least 3 points) in the flange’s local(u, v)space.origin— world-space origin of the profile plane.normal— extrusion direction; normalised automatically.uAxis— local U axis in world space.vAxis— local V axis; computed fromnormal × uAxisif omitted.
- Note: Stepped-seam bends (issue #86, v0.153) require rectangular profiles for split-flange support; non-rectangular profiles still work when no step split is needed.
- Example:
let base = SheetMetal.Flange( id: "base", profile: [SIMD2(0, 0), SIMD2(100, 0), SIMD2(100, 50), SIMD2(0, 50)], origin: .zero, normal: SIMD3(0, 0, 1), uAxis: SIMD3(1, 0, 0) )
SheetMetal.BendDirection
Direction of a bend, measured from the metal’s perspective.
public enum BendDirection: Sendable, Equatable {
case auto
case concave
case convex
}
.concave— the metal folds toward itself (interior dihedral < 180°), as in an L-bracket..convex— the metal folds back on the opposite side (interior dihedral > 180°, reflex), as in the middle bend of a Z-section..auto— direction inferred from flange-body positions: concave when flange B’s centroid sits on flange A’s+normalside.
SheetMetal.Bend
A bend between two flanges, with inside/outside radius, optional angle, material thickness override, and direction control.
public struct Bend: Sendable {
public let fromFlangeID: String
public let toFlangeID: String
public let angle: Double?
public let insideRadius: Double
public let outsideRadius: Double?
public let materialThicknessAtBend: Double?
public let direction: BendDirection
}
angle— bend angle in radians;nilmeans infer from flange placements.0= flat continuation;±π= fully closed. Positive = concave; negative = convex.insideRadius— concave (inner) bend radius.0for a sharp inside corner.outsideRadius— convex (outer) bend radius; defaults toinsideRadius + thicknesswhennil.materialThicknessAtBend— material thickness through the bend zone; defaults to the builder’s globalthickness. Set to a fraction for etched/thinned bend lines.direction— explicit override; defaults to.auto.
Bend.init(from:to:radius:)
Backward-compatible convenience init: radius becomes the inside bend radius; direction is inferred.
public init(from fromID: String, to toID: String, radius: Double)
- Parameters:
fromID— ID of the originating flange.toID— ID of the target flange.radius— inside bend radius. Outside radius defaults toradius + thickness.
- Example:
let bend = SheetMetal.Bend(from: "base", to: "upright", radius: 2.0)
Bend.init(from:to:angle:insideRadius:outsideRadius:materialThicknessAtBend:direction:)
Full init exposing all bend controls.
public init(
from fromID: String,
to toID: String,
angle: Double? = nil,
insideRadius: Double,
outsideRadius: Double? = nil,
materialThicknessAtBend: Double? = nil,
direction: BendDirection = .auto
)
- Parameters:
fromID— ID of the originating flange.toID— ID of the target flange.angle— bend angle in radians (nil= infer from geometry).insideRadius— concave radius.outsideRadius— convex radius;nil=insideRadius + thickness.materialThicknessAtBend— local material thickness override;nil= globalthickness.direction—.auto,.concave, or.convex.
- Example:
let bend = SheetMetal.Bend( from: "base", to: "upright", insideRadius: 2.0, outsideRadius: 3.0, direction: .concave )
Bend.radius
Legacy alias for insideRadius.
public var radius: Double { insideRadius }
Retained for backward compatibility with pre-v0.155 call sites that used the single-radius init. New code should use insideRadius directly.
SheetMetal.BuildError
Errors thrown by SheetMetal.Builder.build(flanges:bends:).
public enum BuildError: Error, CustomStringConvertible {
case invalidThickness(Double)
case noFlanges
case duplicateFlangeID(String)
case unknownFlangeID(String)
case invalidFlangeProfile(id: String)
case flangeExtrusionFailed(id: String)
case unionFailed
case parallelFlangesHaveNoSeam(fromID: String, toID: String)
case noSeamEdgeFound(fromID: String, toID: String)
case filletFailed(fromID: String, toID: String, radius: Double)
case seamsDoNotOverlap(fromID: String, toID: String)
case nonRectangularStepFlange(id: String)
}
| Case | Meaning |
|---|---|
.invalidThickness | thickness ≤ 0. |
.noFlanges | flanges array is empty. |
.duplicateFlangeID | Two flanges share the same id. |
.unknownFlangeID | A Bend references a flange id not in flanges. |
.invalidFlangeProfile | Flange profile has fewer than 3 points. |
.flangeExtrusionFailed | Shape.extrude returned nil for this flange. |
.unionFailed | Boolean union of extruded pieces failed. |
.parallelFlangesHaveNoSeam | The two flanges are parallel — their normals cross-product is zero, so there is no seam line. |
.noSeamEdgeFound | Union succeeded but no shared seam edge was found between the two flanges’ matched-extent pieces. |
.filletFailed | Shape.filleted(edges:radius:) returned nil for the seam edge(s). |
.seamsDoNotOverlap | The two flanges’ seam-direction extents have no overlap — they cannot meet. |
.nonRectangularStepFlange | A stepped-seam bend targets a non-rectangular flange profile; v0.153 split logic requires rectangles. |
SheetMetal.Builder
Composes a list of flanges and bends into a single bent Shape.
public struct Builder: Sendable {
public let thickness: Double
}
The builder validates inputs, optionally splits flanges at stepped-seam intersections, extrudes each piece along its normal, fuses all pieces with Shape.union, then fillets each bend seam with Shape.filleted(edges:radius:).
- OCCT: Internally delegates to
BRepPrimAPI_MakePrism(viaShape.extrude),BRepAlgoAPI_Fuse(viaShape.union), andBRepFilletAPI_MakeFillet(viaShape.filleted).
Builder.init(thickness:)
Creates a builder for sheet metal of the given uniform thickness.
public init(thickness: Double)
- Parameters:
thickness— sheet thickness in model units; must be > 0 orbuildthrows.invalidThickness. - Example:
let builder = SheetMetal.Builder(thickness: 2.0)
Builder.build(flanges:bends:)
Build the bent sheet-metal part from the supplied flanges and bend specifications.
public func build(flanges: [Flange], bends: [Bend] = []) throws -> Shape
Build sequence:
- Validate
thickness > 0andflangesnon-empty; check allBendIDs exist. - For each bend, compute the seam direction (
cross(a.normal, b.normal)) and the overlap range along the seam. If a flange extends past the intersection (a stepped seam), split that flange’s profile at the intersection endpoints — the matched-extent middle piece carries the bend; outer pieces remain flat. - Extrude every piece via
Wire.polygon3D+Shape.extrude(profile:direction:length:). - Fuse all pieces with sequential
Shape.union. - For each concave bend: locate seam edges between the matched-extent pieces and call
Shape.filleted(edges:radius:). For each convex bend: build a curved-triangle prism of bend material (three-point arc cross-section extruded along the seam) and fuse it in.
- Parameters:
flanges— ordered list of flanges; IDs must be unique; each profile needs ≥ 3 points.bends— list of bend connections; defaults to[](no bends = simple multi-flange union).
- Returns: Fused and filleted
Shape. - Throws:
BuildErroron validation failure, extrusion failure, union failure, or fillet failure. - OCCT:
BRepPrimAPI_MakePrism(extrude) ·BRepAlgoAPI_Fuse(union) ·BRepFilletAPI_MakeFillet(fillet) ·GC_MakeArcOfCircle/BRepBuilderAPI_MakeWire(convex bend arc) ·GC_MakeSegment(convex bend lines). - Example:
let base = SheetMetal.Flange( id: "base", profile: [SIMD2(0,0), SIMD2(80,0), SIMD2(80,50), SIMD2(0,50)], origin: .zero, normal: SIMD3(0, 0, 1), uAxis: SIMD3(1, 0, 0) ) let upright = SheetMetal.Flange( id: "upright", profile: [SIMD2(0,0), SIMD2(80,0), SIMD2(80,40), SIMD2(0,40)], origin: SIMD3(0, 50, 0), normal: SIMD3(0, 1, 0), uAxis: SIMD3(1, 0, 0) ) let bend = SheetMetal.Bend(from: "base", to: "upright", radius: 3.0) let builder = SheetMetal.Builder(thickness: 2.0) if let bracket = try? builder.build(flanges: [base, upright], bends: [bend]) { // bracket is a filleted L-shape } - Note: Convex bends (
.convexor auto-inferred) add bend material rather than filleting an existing edge — the inside corner stays sharp at the kiss line. For a fully-rounded inside, position flanges to leave room for the inner cylinder.
StandardLayout
Result of Sheet.standardLayout(of:scale:margin:includeIso:). Holds four placed views in ISO 5456-2 projection-angle order (first-angle or third-angle, following the sheet’s projection setting).
public struct StandardLayout: Sendable {
public let front: PlacedView
public let top: PlacedView
public let side: PlacedView
public let iso: PlacedView?
}
Each PlacedView carries the original unannotated Drawing (so callers can attach dimensions or centrelines to a specific view) together with the offset and scale that render(into:) applies.
StandardLayout.front
The front-view placed drawing.
public let front: PlacedView
Position within the 2×2 grid follows ISO 5456-2: lower-left cell for first-angle; lower-left cell for third-angle as well (both conventions place the front view at the primary position).
StandardLayout.top
The top-view placed drawing.
public let top: PlacedView
First-angle: lower-left cell (below front). Third-angle: upper-left cell (above front).
StandardLayout.side
The right-side-view placed drawing.
public let side: PlacedView
First-angle: upper-right cell (beside front). Third-angle: lower-right cell.
StandardLayout.iso
The isometric-view placed drawing, or nil if includeIso: false was passed.
public let iso: PlacedView?
Always placed in the remaining corner: upper-right for third-angle; lower-right for first-angle.
StandardLayout.placed
Every placed view in draw order: front, top, side, then iso (if present).
public var placed: [PlacedView] { get }
Pure-Swift. Useful for iterating all views uniformly.
- Example:
for p in layout.placed { print(p.scale, p.offset) }
StandardLayout.render(into:)
Emits every placed view onto a DXFWriter via its scaled/translated transform.
public func render(into writer: DXFWriter)
Calls writer.collectFromDrawing(_:translate:scale:) for each view in placed order. The writer accumulates all geometry; call its output method after render to produce the DXF bytes.
- Parameters:
writer—DXFWriterto receive the drawing entities. - Example:
let writer = DXFWriter() layout.render(into: writer) let dxf = writer.dxfString()
StandardLayout.PlacedView
A single view with its position and scale within the layout.
public struct PlacedView: Sendable {
public let drawing: Drawing
public let offset: SIMD2<Double>
public let scale: Double
}
drawing— the original unannotatedDrawing. Mutate this (add dimensions, centrelines) before callingrender(into:).offset— translation applied to the drawing’s coordinate system:apply(p) = scale * p + offset.scale— uniform scale factor. Computed asmin(caller's scale, fit-to-cell scale)so no view overflows its cell.
Sheet Extension — standardLayout
Sheet.standardLayout(of:scale:margin:includeIso:)
Auto-composes front / top / side / optional isometric views of shape onto this sheet at the supplied scale, arranged in a 2×2 grid following ISO 5456-2.
public func standardLayout(of shape: Shape,
scale: DrawingScale = .one,
margin: Double = 20,
includeIso: Bool = true) -> StandardLayout?
Algorithm:
- Generate
Drawing.frontView,topView,sideView(and optionallyisometricView) via theDrawingprojection API. - Compute the sheet’s inner frame, subtract
marginon each outer edge andmargin/2between cells to get four equal cells. - Choose a uniform
appliedScale = min(callerScale, fit-to-cell scale)that prevents any view from overflowing its cell. - Assign views to the 2×2 grid slots per the sheet’s
projectionsetting (.firstor.third). - Compute each view’s
offsetso the view’s bounding-box centre aligns with its cell centre.
- Parameters:
shape— the solid to project.scale— caller’s preferred uniform scale (default.one= 1:1). Applied only if smaller than the fit-to-cell scale.margin— outer and inter-cell margin in sheet units (default 20).includeIso— whenfalse, the isometric cell is left empty;StandardLayout.isoisnil.
- Returns:
StandardLayoutwith four placed views, ornilif any of the front/top/side projections fail. - Note: The isometric view failure is non-fatal — if
Drawing.isometricViewreturnsnil,isois simplynil. - Example:
let sheet = Sheet(size: .a3, projection: .first) let box = Shape.box(width: 80, height: 50, depth: 30)! if let layout = sheet.standardLayout(of: box, scale: .oneToTwo, margin: 15) { let writer = DXFWriter() layout.render(into: writer) try writer.dxfString().write(toFile: "/tmp/bracket.dxf", atomically: true, encoding: .utf8) }