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 · BillOfMaterials.Item · BillOfMaterials.Column · Sheet Extension, BOM Rendering · RayHit · Shape Extension, Ray Casting · Shape Extension, Face Index Access · Selector · Selector.SelectionMode · Selector.SubShapeType · Selector.PickResult
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 ofItemrows; rendered top-down from the header.title: optional label for the table (not rendered byrender(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: theDXFWriterreceiving 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 perColumn.allCases; ifnilusesColumn.defaultWidthfor each column.
- Returns: The top-right corner of the rendered table (useful for chaining further annotations above the BOM). Returns
originifcolumnWidths.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);nilrenders as empty.description: human-readable description (DESCRIPTION column).quantity: part count (QTY column); default 1.material: material specification (MAT column);nilrenders as empty.mass: mass in model units (MASS column); formatted"%.2f";nilrenders as empty.notes: freeform notes (NOTES column);nilrenders 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
}
header
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: theBillOfMaterialsto render.into writer: theDXFWriterreceiving the geometry.at origin: explicit bottom-right anchor;niluses the automatic frame-relative placement.rowHeight: height of each row in model units (default 6).columnWidths: per-column widths;nilusesColumn.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 toBRepLProp_SLPropsas the local-property resolution, where it is a dimensionless sine tolerance: any value at or above 1 rejected every hit normal, soraycast(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 into0...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
RayHitsorted by ascendingdistance; empty if no intersection. - OCCT:
IntCurvesFace_ShapeIntersector::Load/Perform/NbPnt/Pnt/WParameter/Face/UParameter/VParameter; normals viaBRepAdaptor_Surface+BRepLProp_SLPropsat the sharedocctLocalPropsResolution(). - 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, ornilif the ray does not intersect the shape. - OCCT:
IntCurvesFace_ShapeIntersector(viaraycast). - 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_Facesub-shapes; 0 on error or if the shape has none. - OCCT:
TopExp::MapShapes(shape, TopAbs_FACE, faceMap)→faceMap.Extent(). - Former second spelling:
Shape.nbFacesasked this same question, backed by a bareTopExp_Exploreroccurrence 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.facesis a different number, it counts occurrences and is not an index bound. SeeShape.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, in0..<faceCount. - Returns:
Faceat the given index, ornilifindexis 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()
The selector is headless by construction. OCCTHeadlessSelector, the bridge-private SelectMgr_ViewerSelector subclass it creates, exists to expose that base class’s protected TraverseSensitives and GetManager, so a pick can run against a camera alone where OCCT’s own entry points all require a view object. Each shape handed to add(shape:id:) is wrapped in a second bridge-private class, a selectable object whose Compute is empty because nothing is ever drawn; see that method for its own attribution.
- OCCT:
SelectMgr_SelectionManager+OCCTHeadlessSelector. - 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. Cases 0…7 mirror ShapeType’s ordinals and casing exactly (compSolid, renamed from compsolid in #844, which found this, Shape.ShapeFilterType, and a local Shape.TopAbs_ShapeEnum had all drifted from ShapeType’s spelling independently). .shape (TopAbs_SHAPE = 8, “the whole shape was selected”) has no ShapeType counterpart, ShapeType only ever names a concrete sub-shape kind a shape’s own root topology can be, so this stays its own type rather than becoming a ShapeType typealias.
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 viaadd(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 withface(at:)orsubShape(type:index:);-1when the whole shape is selected (mode 0). This was 1-based with0as 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: theShapeto register.id: unique integer identifier; used to correlatePickResult.shapeIdback to the shape.
- Returns:
trueif 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:
trueif the shape was found and removed;falseif 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::Removecalled 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: theSelectionModeto 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: theSelectionModeto 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:
trueif the mode is active. - OCCT: Queries
SelectMgr_Selectionactivation state viaSelectMgr_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 into0...Sampling.maximumSampleCount(10,000,000); 0 or less returns empty (#622).
-
Returns: Array of
PickResultsorted by ascending depth; empty if nothing was hit. This entry named the kernel’s ownPickentry point until #1399. It is not on this path, and cannot be: every one of its four overloads takes a view object, which is exactly what a headless selector does not have. What runs instead is the volume-manager sequence below. - OCCT:
OCCTHeadlessSelector::PickPointbuilds a point selecting volume throughSelectMgr_SelectingVolumeManager::InitPointSelectingVolume/SetCamera/SetWindowSize/SetPixelTolerance/BuildSelectingVolume, then runs the protectedSelectMgr_ViewerSelector::TraverseSensitives. Depth ordering comes fromSelectMgr_SortCriterion, and each hit is read back throughSelectMgr_EntityOwner. - 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 into0...Sampling.maximumSampleCount(10,000,000); 0 or less returns empty (#622).
-
Returns: Array of
PickResultfor all shapes intersecting the rectangle. This entry called the C++ methodPickRectuntil #1399, borrowing the name of theOCCTSelectorPickRectbridge function that reaches it; the method isPickBox. - OCCT:
OCCTHeadlessSelector::PickBoxbuilds a box selecting volume throughSelectMgr_SelectingVolumeManager::InitBoxSelectingVolume, then runsTraverseSensitives. - 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 into0...Sampling.maximumSampleCount(10,000,000); 0 or less returns empty (#622).
- Returns: Array of
PickResultfor all shapes whose sensitive primitives fall inside the polygon. - OCCT:
OCCTHeadlessSelector::PickPolybuilds a polyline selecting volume throughSelectMgr_SelectingVolumeManager::InitPolylineSelectingVolume, then runsTraverseSensitives; pixel XY pairs are passed as an interleaveddoublearray. - 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")