Link Search Menu Expand Document

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, and Drawing view factories.

Topics


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.uAxis

Local U axis of the profile plane, in world space.

Flange.vAxis

Local V axis of the profile plane, in world space; derived as cross(normal, uAxis) when not given explicitly at init.


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 by Bend.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 from normal × uAxis if 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. A seam diagonal to the flange’s own axes needs no split at all, so it works on any profile (#3033).
  • 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: if the owning Bend.angle is non-nil and non-zero, its sign decides directly (positive = concave, negative = convex), overriding geometric inference. Otherwise direction is inferred from flange-body positions: concave when flange B’s centroid sits on flange A’s +normal side.

SheetMetal.BendDirection.convex

The metal folds back on the opposite side (reflex dihedral > 180°).


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; nil means infer from flange placements. 0 = flat continuation; ±π = fully closed. Positive = concave; negative = convex. When direction is .auto (the default), a non-nil, non-zero angle decides concave vs. convex by this sign, overriding geometric inference; an explicit direction always wins over angle.
  • insideRadius: concave (inner) bend radius. 0 for a sharp inside corner.
  • outsideRadius: convex (outer) bend radius; documented default is insideRadius + thickness when nil, but not yet read by Builder.build(), so setting it has no effect on the built shape today (#1565).
  • materialThicknessAtBend: material thickness through the bend zone; documented default is the builder’s global thickness, but not yet read by Builder.build(), so setting it has no effect on the built shape today (#1565).
  • direction: explicit override; defaults to .auto.

Bend.fromFlangeID

ID of the flange the bend originates from (matches a Flange.id).

Bend.toFlangeID

ID of the flange the bend connects to (matches a Flange.id).

Bend.insideRadius

Concave (inner) bend radius. 0 for a sharp inside corner.

Bend.outsideRadius

Convex (outer) bend radius; documented default is insideRadius + thickness when nil. Not yet read by Builder.build(): neither the concave fillet path (which always uses insideRadius alone) nor the convex bend-material path (whose prism radius is always the builder’s thickness) consult this field. Setting it has no effect on the built shape today (#1565).

Bend.materialThicknessAtBend

Material thickness through the bend zone; documented default is the builder’s global thickness. Not yet read by Builder.build(): same gap as outsideRadius above. Set to a fraction for etched/thinned bend lines is the intended, not-yet-implemented, use (#1565).


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 to radius + 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 = global thickness.
    • 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, inside the run of the seam both occupy.
.filletFailed The concave bend’s rounding could not be built as asked: the radius reaches past either flange’s face, the two faces are too nearly parallel to leave a wedge to round, or the fused result was not a valid solid. Also a convex bend whose material could not be built, and a zero-radius concave bend whose Shape.filleted(edges:radius:) returned nil.
.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.

BuildError.invalidThickness

thickness is <= 0.

BuildError.noFlanges

The flanges array is empty.

BuildError.duplicateFlangeID

Two flanges share the same id.

BuildError.unknownFlangeID

A Bend references a flange id that is not in flanges.

BuildError.invalidFlangeProfile

A flange profile has fewer than 3 points.

BuildError.flangeExtrusionFailed

Shape.extrude returned nil for this flange.

BuildError.unionFailed

The boolean union of extruded pieces failed.

BuildError.parallelFlangesHaveNoSeam

The two flanges are parallel: their normals’ cross-product is zero, so there is no seam line.

BuildError.noSeamEdgeFound

Union succeeded but no shared seam edge was found between the two flanges, inside the run of the seam both occupy.

BuildError.filletFailed

The bend’s rounding could not be built as asked, and nothing is returned in its place. For a concave bend of positive radius that is a radius reaching past either flange’s face (the tangent line would leave the metal), faces too nearly parallel to leave a wedge between them, or a fused result that is not a valid solid (#3045). A convex bend raises it when its material cannot be built. A concave bend of radius 0 reaches it when Shape.filleted(edges:radius:) returns nil.

BuildError.seamsDoNotOverlap

The two flanges’ seam-direction extents have no overlap; they cannot meet.

BuildError.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 rounds each concave bend by fusing in the fillet’s material as a prism, and each convex bend by fusing in its bend material.

  • OCCT: Internally delegates to BRepPrimAPI_MakePrism (via Shape.extrude), BRepAlgoAPI_Fuse (via Shape.union), and BRepFilletAPI_MakeFillet (via Shape.filleted).

Builder.thickness

Uniform sheet thickness in model units, set at init and used for every flange’s extrusion length and every bend’s default outside radius.

  • OCCT: Internally delegates to BRepPrimAPI_MakePrism (via Shape.extrude), BRepAlgoAPI_Fuse (via Shape.union), and BRepFilletAPI_MakeFillet (via Shape.filleted).

Private and fileprivate implementation helpers

Not part of the public API; documented for completeness since they carry the actual geometry logic behind build(flanges:bends:)’s five steps above.


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 or build throws .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:

  1. Validate thickness > 0 and flanges non-empty; check all Bend IDs exist.
  2. 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 pieces outside the bend remain flat.
  3. Extrude every piece via Wire.polygon3D + Shape.extrude(profile:direction:length:).
  4. Fuse all pieces with sequential Shape.union.
  5. For each concave bend: locate seam edges between the two flanges you declared, restricted to the run of the seam line the two share, and fuse in a prism of the fillet’s material over that run: the corner of the free wedge between the two faces, less the circle a ball of insideRadius rolls, built from the tangent points and extruded along the seam (#3045). It adds r^2 (1 - pi/4) per unit of seam at a right angle. An insideRadius of 0 has no material to add and still goes through 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, cut to that same run) and fuse it in.

On a stepped seam, the outer pieces keep their sharp edges. The two plane tests that find the seam edge hold along the whole seam line, not just along the bend, so until #2972 the outer piece’s free edge was filleted too. That edge is convex, so the fillet removed material there: all four stepped fixtures in the suite came out below their flange volumes, by exactly r^2 (1 - pi/4) times the surplus length. The selection is now bounded by the bend’s own intersection range. Measurement and derivations: Scripts/repro/2972-sheetmetal-volumes/.

A bend reads the flanges you declared and its own run of the seam, never a flange piece. A flange another bend split is cut into pieces, and until #3019 a bend was resolved to the one piece whose range on the seam axis equalled its own, or to the first piece when none did. A concave bend was spared, because its run comes from the bend’s own intersection. A convex bend cut its prism to that piece’s edge, so the prism came out as long as a sliver of the flange, or in the wrong place, or, for a flange split along its other profile axis, inside the flange body where it added nothing. The volume missed (pi/4) t^2 times the length not built, to within the fillet run-out (at most 0.08). The run is now read off the bend and the prism is cut to it.

A seam diagonal to a flange’s own axes gets the same run (#3033). There is no profile axis to project the bend’s intersection onto, so the run is the part of the seam line that both flanges’ profile edges cover. That needs no flange split, because the fused solid already holds the seam as separate edges at the contact boundary. An upright narrower than the diagonal edge it stood on used to throw BuildError.filletFailed for a concave bend, and for a convex one built silently with the prism cut to the from flange’s whole edge whatever the other flange covered, so the two declaration orders disagreed. Both now build over the shared run. Measurement and derivations: Scripts/repro/3019-3033-sheetmetal-seam-extent/.

A stepped concave bend is exact at every radius (#3045). It used to come back with isValid == false once the radius reached the thickness, where the two flange bodies interpenetrate, and where the narrower flange butts the wider one, and from a radius past the thickness the volume was wrong as well (+7.7 at r = 2.5 and +16.2 at r = 3.0 on a foot under a wider web at thickness 2). The solid the fillet started from was valid. BRepFilletAPI_MakeFillet has to close the fillet off where the seam stops short of a flange’s own edge, on a side face one thickness tall, and once the radius passes that it runs through it. The bend is now the prism described in step 5 and ends flush with its run. A bend whose tangent line would leave a flange’s face, or that does not give a valid solid, throws BuildError.filletFailed and is never returned.

// A foot (y in [10, 35]) under a wider web (y in [0, 45]), at a radius past the thickness.
let foot = SheetMetal.Flange(
    id: "foot",
    profile: [SIMD2(10, 0), SIMD2(35, 0), SIMD2(35, 30), SIMD2(10, 30)],
    origin: SIMD3(0, 0, 0), normal: SIMD3(0, 0, -1),
    uAxis: SIMD3(0, 1, 0), vAxis: SIMD3(1, 0, 0))
let web = SheetMetal.Flange(
    id: "web",
    profile: [SIMD2(0, 0), SIMD2(20, 0), SIMD2(20, 45), SIMD2(0, 45)],
    origin: SIMD3(30, 0, 0), normal: SIMD3(-1, 0, 0),
    uAxis: SIMD3(0, 0, 1), vAxis: SIMD3(0, 1, 0))
let part = try SheetMetal.Builder(thickness: 2).build(
    flanges: [foot, web],
    bends: [SheetMetal.Bend(from: "web", to: "foot", radius: 3)])
// part.isValid is true, and part.volume is 3300 + 25 * 3^2 * (1 - pi/4) = 3348.285...

A bend’s volume is predictable, to 1e-9. Flange body volumes, less any volume where two bodies interpenetrate, plus r^2 (1 - pi/4) * L for each concave bend over its matched seam length and (pi/4) * t^2 * L for each convex one, for a right angle. Before #3045 a seam that ran on into a flat neighbour cost a little more, the fillet closing off over about 0.1 past the step, which was why the stepped fixtures were not exact.

  • 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: The fused, bent Shape, a valid solid: a result that is not one is refused as filletFailed, never returned.
  • Throws: BuildError on validation failure, extrusion failure, union failure, or a bend that could not be built.
  • OCCT: BRepPrimAPI_MakePrism (extrude) · BRepAlgoAPI_Fuse (union) · GC_MakeArcOfCircle / BRepBuilderAPI_MakeWire (the concave and convex bend arcs) · GC_MakeSegment (the bend lines) · BRepFilletAPI_MakeFillet (a concave bend of radius 0 only).
  • 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 (.convex or 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, PDFWriter, or SVGWriter via its scaled/translated transform.

public func render(into writer: DXFWriter)
public func render(into writer: PDFWriter)
public func render(into writer: SVGWriter)

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 output bytes.

Three overloads, one per writer type (#1180) – previously DXFWriter-only, so a standardLayout(of:) result could not be rendered onto a PDF or SVG sheet at all; a caller had to re-derive this loop inline against layout.placed instead of calling render(into:).

  • Parameters: writer, DXFWriter, PDFWriter, or SVGWriter to receive the drawing entities.
  • Example:
    let writer = DXFWriter()
    layout.render(into: writer)
    try writer.write(to: URL(fileURLWithPath: "/tmp/layout.dxf"))
    

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 unannotated Drawing. Mutate this (add dimensions, centrelines) before calling render(into:).
  • offset: translation applied to the drawing’s coordinate system: apply(p) = scale * p + offset.
  • scale: uniform scale factor. Computed as min(caller's scale, fit-to-cell scale) so no view overflows its cell.

StandardLayout.PlacedView.drawing

The original unannotated Drawing, mutable before calling render(into:).


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:

  1. Generate Drawing.frontView, topView, sideView (and optionally isometricView) via the Drawing projection API.
  2. Compute the sheet’s inner frame, subtract margin on each outer edge and margin/2 between cells to get four equal cells.
  3. Choose a uniform appliedScale = min(callerScale, fit-to-cell scale) that prevents any view from overflowing its cell.
  4. Assign views to the 2×2 grid slots per the sheet’s projection setting (.first or .third).
  5. Compute each view’s offset so 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: when false, the isometric cell is left empty; StandardLayout.iso is nil.
  • Returns: StandardLayout with four placed views, or nil if any of the front/top/side projections fail.
  • Note: The isometric view failure is non-fatal, if Drawing.isometricView returns nil, iso is simply nil.
  • 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: .reduction(2), margin: 15) {
        let writer = DXFWriter()
        layout.render(into: writer)
        try writer.write(to: URL(fileURLWithPath: "/tmp/bracket.dxf"))
    }