Link Search Menu Expand Document

Bill of Materials & Selection

BillOfMaterials is a pure-Swift value type for assembling and rendering tabular parts lists onto DXF drawings. Selector is a BVH-accelerated headless hit-testing engine for interactive picking (point, rectangle, or lasso) without a display context. The Selection.swift extensions add ray casting and index-based face access to Shape.

Topics


BillOfMaterials

A pure-Swift, Sendable, Hashable, Codable value type that holds a list of parts and renders them as a bordered table into a DXFWriter.


BillOfMaterials.init(items:title:)

Creates a BillOfMaterials with an explicit item list and optional title string.

public init(items: [Item], title: String? = nil)
  • Parameters:
    • items — ordered array of Item rows; rendered top-down from the header.
    • title — optional label for the table (not rendered by render(into:at:) itself; reserved for caller use).
  • Example:
    let bom = BillOfMaterials(
        items: [
            .init(number: 1, partNumber: "P-001", description: "Base Plate",
                  quantity: 1, material: "6061-T6", mass: 0.48),
            .init(number: 2, description: "M5 Socket Screw", quantity: 8)
        ],
        title: "Assembly BOM"
    )
    

items

The ordered array of Item rows in the bill of materials.

public var items: [Item]
  • Example:
    bom.items.append(.init(number: 3, description: "Washer", quantity: 16))
    

title

An optional title string for the BOM table.

public var title: String?

render(into:at:rowHeight:columnWidths:)

Renders the BOM as a bordered table into a DXFWriter, growing upward and leftward from the given origin.

@discardableResult
public func render(into writer: DXFWriter,
                    at origin: SIMD2<Double>,
                    rowHeight: Double = 6,
                    columnWidths: [Double]? = nil) -> SIMD2<Double>

The origin is the bottom-right corner of the table; the table expands upward and to the left, placing it naturally above a title block. All cells are written on the "TEXT" layer; border lines on the "BORDER" layer.

  • Parameters:
    • into writer — the DXFWriter receiving the geometry.
    • at origin — bottom-right anchor point of the table in model coordinates.
    • rowHeight — height of each row in model units (default 6).
    • columnWidths — per-column widths in model units, one per Column.allCases; if nil uses Column.defaultWidth for each column.
  • Returns: The top-right corner of the rendered table (useful for chaining further annotations above the BOM). Returns origin if columnWidths.count != Column.allCases.count.
  • Note: Pure-Swift; no OCCT bridge involved.
  • Example:
    var writer = DXFWriter()
    let topRight = bom.render(into: writer, at: SIMD2(190, 30))
    // topRight.y is the Y coordinate above which the next annotation can be placed
    

(Internal, not public API: renderRow(textsIn:widths:leftX:baselineY:into:) is a private func on BillOfMaterials: the shared row-drawing helper render(into:at:rowHeight:columnWidths:) above calls once for the header row and once per item row, writing each column’s text at its cell’s left edge.)


BillOfMaterials.Item

A single row in the bill of materials. All fields except number, description, and quantity are optional.


BillOfMaterials.Item.init(number:partNumber:description:quantity:material:mass:notes:)

Creates a BOM row.

public init(number: Int,
            partNumber: String? = nil,
            description: String,
            quantity: Int = 1,
            material: String? = nil,
            mass: Double? = nil,
            notes: String? = nil)
  • Parameters:
    • number — balloon/callout number (ITEM column).
    • partNumber — drawing part number (PART NO column); nil renders as empty.
    • description — human-readable description (DESCRIPTION column).
    • quantity — part count (QTY column); default 1.
    • material — material specification (MAT column); nil renders as empty.
    • mass — mass in model units (MASS column); formatted "%.2f"; nil renders as empty.
    • notes — freeform notes (NOTES column); nil renders as empty.
  • Example:
    let row = BillOfMaterials.Item(
        number: 1,
        partNumber: "SH-42",
        description: "Shaft",
        quantity: 2,
        material: "4140 Steel",
        mass: 1.35,
        notes: "Heat treat to 40 HRC"
    )
    

number

The item/balloon number for this row (ITEM column).

public var number: Int

partNumber

The part number string (PART NO column). nil renders as blank.

public var partNumber: String?

description

Human-readable description of the part (DESCRIPTION column).

public var description: String

quantity

Number of instances of this part (QTY column).

public var quantity: Int

material

Material specification string (MAT column). nil renders as blank.

public var material: String?

mass

Mass value in model units (MASS column), formatted as "%.2f". nil renders as blank.

public var mass: Double?

notes

Freeform annotation text (NOTES column). nil renders as blank.

public var notes: String?

BillOfMaterials.Column

Enumerates the fixed set of columns rendered by BillOfMaterials.render(into:at:), in source order.

public enum Column: String, Sendable, CaseIterable {
    case item, partNumber, description, quantity, material, mass, notes
}

The column header label as rendered in the top row of the table.

public var header: String { get }

Returns "ITEM", "PART NO", "DESCRIPTION", "QTY", "MAT", "MASS", or "NOTES" respectively.

  • Example:
    let headers = BillOfMaterials.Column.allCases.map(\.header)
    // ["ITEM", "PART NO", "DESCRIPTION", "QTY", "MAT", "MASS", "NOTES"]
    

defaultWidth

The default column width in model units used when columnWidths is nil in render(into:at:).

public var defaultWidth: Double { get }

Values: .item → 12, .partNumber → 25, .description → 60, .quantity → 10, .material → 25, .mass → 15, .notes → 30.


Sheet Extension — BOM Rendering

Sheet.renderBOM(_:into:at:rowHeight:columnWidths:)

Renders a BillOfMaterials onto the sheet at a position automatically aligned to the inner frame’s top-right corner.

@discardableResult
public func renderBOM(_ bom: BillOfMaterials,
                       into writer: DXFWriter,
                       at origin: SIMD2<Double>? = nil,
                       rowHeight: Double = 6,
                       columnWidths: [Double]? = nil) -> SIMD2<Double>

When origin is nil, the anchor is computed as (frame.max.x, frame.max.y − totalTableHeight), placing the table flush with the inner frame’s right edge and dropping it so the bottom lands at the top of the title block area.

  • Parameters:
    • bom — the BillOfMaterials to render.
    • into writer — the DXFWriter receiving the geometry.
    • at origin — explicit bottom-right anchor; nil uses the automatic frame-relative placement.
    • rowHeight — height of each row in model units (default 6).
    • columnWidths — per-column widths; nil uses Column.defaultWidth.
  • Returns: Top-right corner of the rendered table (forwarded from BillOfMaterials.render).
  • Note: Pure-Swift; no OCCT bridge involved.
  • Example:
    var writer = DXFWriter()
    let sheet = Sheet(size: .a3, orientation: .landscape)
    sheet.render(into: &writer)
    sheet.renderBOM(bom, into: writer)
    

RayHit

Result of a single ray-surface intersection from Shape.raycast(origin:direction:tolerance:maxHits:).

public struct RayHit: Sendable {
    public let point: SIMD3<Double>
    public let normal: SIMD3<Double>
    public let faceIndex: Int
    public let distance: Double
    public let uv: SIMD2<Double>
    public let normalDefined: Bool
}
Field Meaning
point 3D world-space intersection point on the surface.
normal Unit outward normal at the intersection; respects face orientation (TopAbs_REVERSED). Falls back to (0, 0, 1) if the normal is undefined at the hit point.
faceIndex 0-based index of the intersected face within the shape’s TopTools_IndexedMapOfShape.
distance Signed ray parameter (distance from origin along the ray direction).
uv UV surface parameters at the intersection point.
normalDefined Whether normal is the surface’s own normal or the (0, 0, 1) fallback, which is otherwise indistinguishable from a real upward normal (added by #529).

RayHit.normalDefined

Whether normal is the surface’s own normal, or the (0, 0, 1) fallback.


Shape Extension — Ray Casting

Shape.raycast(origin:direction:tolerance:maxHits:)

Casts a ray against all faces of the shape and returns every intersection, sorted nearest-first.

public func raycast(
    origin: SIMD3<Double>,
    direction: SIMD3<Double>,
    tolerance: Double = 0.001,
    maxHits: Int = 100
) -> [RayHit]

The direction vector is automatically normalised by gp_Dir. Intersections beyond maxHits are discarded. Returns an empty array if the ray does not intersect the shape or an error occurs.

  • Parameters:
    • origin — ray start point in world space.
    • direction — ray direction (normalised internally).
    • tolerance — intersection tolerance (default 0.001). Bounds the intersection only. Until #529 it was also passed to BRepLProp_SLProps as the local-property resolution, where it is a dimensionless sine tolerance: any value at or above 1 rejected every hit normal, so raycast(tolerance: 1.0) on a sphere reported (0, 0, 1) for both hits, and at 5.0 a box’s downward face came back pointing up.
    • maxHits — output capacity (default 100), clamped into 0...Sampling.maximumSampleCount (10,000,000); 0 or less returns empty (#622). The ray decides how many surfaces it crosses, so the capacity only truncates: clamping an unservable one returns the same hits.
  • Returns: Array of RayHit sorted by ascending distance; empty if no intersection.
  • OCCT: IntCurvesFace_ShapeIntersector::Load / Perform / NbPnt / Pnt / WParameter / Face / UParameter / VParameter; normals via BRepAdaptor_Surface + BRepLProp_SLProps at the shared occtLocalPropsResolution().
  • Example:
    let box = Shape.box(width: 10, height: 10, depth: 10)!
    let hits = box.raycast(
        origin: SIMD3(5, 5, -20),
        direction: SIMD3(0, 0, 1)
    )
    if let first = hits.first {
        print(first.point, first.normal, first.faceIndex)
    }
    

Shape.raycastNearest(origin:direction:tolerance:)

Convenience wrapper that returns only the nearest ray intersection.

public func raycastNearest(
    origin: SIMD3<Double>,
    direction: SIMD3<Double>,
    tolerance: Double = 0.001
) -> RayHit?

Equivalent to raycast(origin:direction:tolerance:maxHits:100).first.

  • Parameters:
    • origin — ray start point.
    • direction — ray direction (normalised internally).
    • tolerance — intersection tolerance (default 0.001).
  • Returns: The nearest RayHit, or nil if the ray does not intersect the shape.
  • OCCT: IntCurvesFace_ShapeIntersector (via raycast).
  • Example:
    let sphere = Shape.sphere(radius: 5)!
    if let hit = sphere.raycastNearest(
        origin: SIMD3(0, 0, -20),
        direction: SIMD3(0, 0, 1)
    ) {
        print(hit.distance)  // ≈ 15.0 (front of sphere)
    }
    

Shape Extension — Face Index Access

Shape.faceCount

Total number of face sub-shapes in the shape.

public var faceCount: Int { get }

This is the same enumeration Shape.faces() walks and Shape.face(at:) indexes: one entry per distinct face (TopoDS_Shape::IsSame — same TShape and location, orientation ignored), in TopExp_Explorer order, addressed from 0. A face reachable from two parents counts once.

  • Returns: Count of distinct TopoDS_Face sub-shapes; 0 on error or if the shape has none.
  • OCCT: TopExp::MapShapes(shape, TopAbs_FACE, faceMap)faceMap.Extent().
  • Former second spelling: Shape.nbFaces asked this same question, backed by a bare TopExp_Explorer occurrence count instead of this deduplicated one; deprecated as a forward here in #651 and removed at v2.0.0 (#784).
  • Example:
    let box = Shape.box(width: 10, height: 10, depth: 10)!
    print(box.faceCount)                     // 6
    print(box.faces().count)                 // 6, the same enumeration
    print(box.subShapeCount(ofType: .face))  // 6, the generic spelling
    
  • Note: contents.faces is a different number — it counts occurrences and is not an index bound. See Shape.contents. (#541)

Shape.face(at:)

Returns the face at a 0-based index within the shape’s indexed face map.

public func face(at index: Int) -> Face?

The index is the one Face.index carries, the faceIndex field RayHit returns, and the one every face-index-taking method on Shape expects. It is meaningful only against the shape it came from: an index taken from one shape and used on another names an unrelated face, or nothing at all. (#541)

  • Parameters: index — 0-based face index, in 0..<faceCount.
  • Returns: Face at the given index, or nil if index is out of bounds or the shape is null.
  • OCCT: TopExp::MapShapes + TopoDS::Face(faceMap(index + 1)) (OCCT maps are 1-based internally).
  • Example:
    let box = Shape.box(width: 10, height: 10, depth: 10)!
    let hits = box.raycast(origin: SIMD3(5, 5, -20), direction: SIMD3(0, 0, 1))
    if let hit = hits.first, let face = box.face(at: hit.faceIndex) {
        print(face.surfaceType)  // .plane
    }
    

Selector

BVH-accelerated headless hit-testing for point, rectangle, and lasso picking against registered Shape objects, without requiring an OpenGL or Metal display context.

public final class Selector: @unchecked Sendable

Internally wraps an OCCTHeadlessSelector — a subclass of OCCT’s SelectMgr_ViewerSelector — paired with a SelectMgr_SelectionManager. Shapes are decomposed into SelectMgr_Selection sensitive primitives by StdSelect_BRepSelectionTool.

(Internal, not public API: Selector.handle is an internal let handle: OCCTSelectorRef wrapping the underlying bridge object, released in deinit. See Memory Management.)


Selector.init()

Creates an empty Selector with no registered shapes.

public init()
  • OCCT: SelectMgr_SelectionManager + OCCTHeadlessSelector (custom SelectMgr_ViewerSelector subclass).
  • Example:
    let selector = Selector()
    

Selector.SelectionMode

Controls which level of sub-shape topology is made selectable for a given shape.

public enum SelectionMode: Int32, Sendable {
    case shape  = 0
    case vertex = 1
    case edge   = 2
    case wire   = 3
    case face   = 4
}

Maps to TopAbs_ShapeEnum decomposition as used by StdSelect_BRepSelectionTool. Mode .shape (0) is activated automatically when a shape is added. Multiple modes can be active simultaneously.


Selector.SubShapeType

Identifies the topology type of the sub-shape that was hit in a pick result.

public enum SubShapeType: Int32, Sendable {
    case compound  = 0
    case compsolid = 1
    case solid     = 2
    case shell     = 3
    case face      = 4
    case wire      = 5
    case edge      = 6
    case vertex    = 7
    case shape     = 8
}

Maps directly to OCCT’s TopAbs_ShapeEnum integer values.


Selector.PickResult

Result of a single hit from a pick operation.

public struct PickResult: Sendable {
    public let shapeId: Int32
    public let depth: Double
    public let point: SIMD3<Double>
    public let subShapeType: SubShapeType
    public let subShapeIndex: Int32
}
  • shapeId — the integer ID assigned when the shape was added via add(shape:id:).
  • depth — distance from the camera to the hit.
  • point — 3D world-space point where the pick ray intersected the sensitive primitive.
  • subShapeType — topology type of the sub-shape hit (e.g. .face, .edge).
  • subShapeIndex — 0-based index of the hit sub-shape within its parent shape, addressable with face(at:) or subShape(type:index:); -1 when the whole shape is selected (mode 0). This was 1-based with 0 as that sentinel until #541, which named the sub-shape before the one hit and made the sentinel indistinguishable from a hit on sub-shape 0.

(Per-field anchors below, for cross-reference; the list above has the actual meaning of each.)

Selector.PickResult.subShapeType


Shape Management

Selector.add(shape:id:)

Registers a shape with a unique integer ID and activates whole-shape selection (mode 0).

@discardableResult
public func add(shape: Shape, id: Int32) -> Bool

If a shape with the same id is already registered, it is replaced. Use activateMode(_:for:) after adding to enable sub-shape picking modes.

  • Parameters:
    • shape — the Shape to register.
    • id — unique integer identifier; used to correlate PickResult.shapeId back to the shape.
  • Returns: true if the shape was added successfully.
  • OCCT: SelectMgr_SelectionManager::Load + StdSelect_BRepSelectionTool::Load.
  • Example:
    let box = Shape.box(width: 10, height: 10, depth: 10)!
    selector.add(shape: box, id: 1)
    

Selector.remove(id:)

Removes the shape registered under the given ID.

@discardableResult
public func remove(id: Int32) -> Bool
  • Parameters: id — the shape ID to remove.
  • Returns: true if the shape was found and removed; false if no shape with that ID exists.
  • OCCT: SelectMgr_SelectionManager::Remove.
  • Example:
    selector.remove(id: 1)
    

Selector.clearAll()

Removes all registered shapes and their selection owners.

public func clearAll()
  • OCCT: SelectMgr_SelectionManager::Remove called for each registered shape.
  • Example:
    selector.clearAll()
    

Selection Modes

Selector.activateMode(_:for:)

Activates a selection mode for a registered shape, making that sub-shape level pickable.

public func activateMode(_ mode: SelectionMode, for shapeId: Int32)

Multiple modes can be active simultaneously. Calling this with .face enables face picking without disabling the whole-shape mode already active.

  • Parameters:
    • mode — the SelectionMode to activate.
    • shapeId — the ID of the shape to configure.
  • OCCT: SelectMgr_SelectionManager::Activate(selectable, mode.rawValue) + StdSelect_BRepSelectionTool::Load.
  • Example:
    selector.add(shape: box, id: 1)
    selector.activateMode(.face, for: 1)
    selector.activateMode(.edge, for: 1)
    

Selector.deactivateMode(_:for:)

Deactivates a selection mode for a registered shape.

public func deactivateMode(_ mode: SelectionMode, for shapeId: Int32)
  • Parameters:
    • mode — the SelectionMode to deactivate.
    • shapeId — the ID of the shape to configure.
  • OCCT: SelectMgr_SelectionManager::Deactivate(selectable, mode.rawValue).
  • Example:
    selector.deactivateMode(.edge, for: 1)
    

Selector.isModeActive(_:for:)

Checks whether a given selection mode is currently active for a shape.

public func isModeActive(_ mode: SelectionMode, for shapeId: Int32) -> Bool
  • Parameters:
    • mode — the mode to query.
    • shapeId — the shape ID.
  • Returns: true if the mode is active.
  • OCCT: Queries SelectMgr_Selection activation state via SelectMgr_SelectionManager.
  • Example:
    if selector.isModeActive(.face, for: 1) {
        print("face picking is on")
    }
    

Pixel Tolerance

Selector.pixelTolerance

Pixel radius used to detect picks near thin geometry such as edges and vertices.

public var pixelTolerance: Int32 { get set }

Higher values increase the catchment area around edges and vertices, making them easier to pick in densely packed scenes. Default is 2 pixels.

  • OCCT: SelectMgr_ViewerSelector::SetPixelTolerance / PixelTolerance.
  • Example:
    selector.pixelTolerance = 5  // easier to pick edges
    

Picking

Selector.pick(at:camera:viewSize:maxResults:)

Picks shapes at a single pixel coordinate.

public func pick(at pixel: SIMD2<Double>,
                 camera: Camera,
                 viewSize: SIMD2<Double>,
                 maxResults: Int = 32) -> [PickResult]

Results are sorted by depth (nearest first). Only shapes and sub-shape modes that have been activated are returned.

  • Parameters:
    • pixel — pixel coordinate in the viewport (origin at top-left).
    • camera — camera providing the projection and view transforms.
    • viewSize — viewport dimensions in pixels (width, height).
    • maxResults — output capacity (default 32), clamped into 0...Sampling.maximumSampleCount (10,000,000); 0 or less returns empty (#622).
  • Returns: Array of PickResult sorted by ascending depth; empty if nothing was hit.
  • OCCT: OCCTHeadlessSelector::PickPointSelectMgr_ViewerSelector::Pick (point volume) + SelectMgr_SortCriterion for depth ordering.
  • Example:
    let results = selector.pick(at: SIMD2(320, 240),
                                 camera: camera,
                                 viewSize: SIMD2(640, 480))
    if let hit = results.first {
        print(hit.shapeId, hit.subShapeType, hit.point)
    }
    

Selector.pick(rect:camera:viewSize:maxResults:)

Picks all shapes that intersect a rectangular pixel region (rubber-band selection).

public func pick(rect: (min: SIMD2<Double>, max: SIMD2<Double>),
                 camera: Camera,
                 viewSize: SIMD2<Double>,
                 maxResults: Int = 32) -> [PickResult]
  • Parameters:
    • rect — rectangle defined by (min, max) pixel corners.
    • camera — camera providing the projection and view transforms.
    • viewSize — viewport dimensions in pixels.
    • maxResults — output capacity (default 32), clamped into 0...Sampling.maximumSampleCount (10,000,000); 0 or less returns empty (#622).
  • Returns: Array of PickResult for all shapes intersecting the rectangle.
  • OCCT: OCCTHeadlessSelector::PickRectSelectMgr_ViewerSelector::Pick (box volume).
  • Example:
    let selected = selector.pick(
        rect: (min: SIMD2(100, 100), max: SIMD2(400, 300)),
        camera: camera,
        viewSize: SIMD2(640, 480)
    )
    print("\(selected.count) shapes in rectangle")
    

Selector.pick(polygon:camera:viewSize:maxResults:)

Picks all shapes inside a closed polygon (lasso selection).

public func pick(polygon: [SIMD2<Double>],
                 camera: Camera,
                 viewSize: SIMD2<Double>,
                 maxResults: Int = 32) -> [PickResult]

The polygon must have at least 3 points. The last point is automatically connected back to the first. Returns an empty array immediately if polygon.count < 3.

  • Parameters:
    • polygon — array of pixel coordinates defining the polygon vertices (minimum 3 points).
    • camera — camera providing the projection and view transforms.
    • viewSize — viewport dimensions in pixels.
    • maxResults — output capacity (default 32), clamped into 0...Sampling.maximumSampleCount (10,000,000); 0 or less returns empty (#622).
  • Returns: Array of PickResult for all shapes whose sensitive primitives fall inside the polygon.
  • OCCT: OCCTHeadlessSelector::PickPolySelectMgr_ViewerSelector::Pick (polyline volume); pixel XY pairs passed as interleaved double array.
  • Example:
    let lasso: [SIMD2<Double>] = [
        SIMD2(100, 100), SIMD2(400, 80),
        SIMD2(420, 350), SIMD2(80,  330)
    ]
    let inside = selector.pick(polygon: lasso,
                                camera: camera,
                                viewSize: SIMD2(640, 480))
    print("\(inside.count) shapes inside lasso")