Picking & Selection
OCCTSwiftViewport resolves a tap or click through two complementary paths: a GPU pick pass that reads a single R32Uint texel to identify which body and primitive was hit, and a CPU raycast (SceneRaycast) that computes a precise world-space intersection point. Both paths are controlled from ViewportController.
Enabling the GPU pick pass
Picking is off by default. Turn it on when you create your ViewportConfiguration:
var config = ViewportConfiguration.cad
config.pickingConfiguration = PickingConfiguration(isEnabled: true)
let controller = ViewportController(configuration: config)
The renderer allocates a second R32Uint color attachment only when picking is enabled, keeping the default render path lean.
Reading a pick result
After a tap, the renderer reads the pick texture and populates ViewportController.pickResult (a @Published property). Subscribe with Combine or read it directly in SwiftUI:
// SwiftUI — react inside .onChange
MetalViewportView(controller: controller, bodies: bodies)
.onChange(of: controller.pickResult) { _, result in
guard let result else {
// tap landed on background
return
}
print("hit body:", result.bodyID)
print("kind:", result.kind) // .face / .edge / .vertex
print("primitive index:", result.triangleIndex)
}
PickResult fields:
| Property | Type | Meaning |
|---|---|---|
bodyID | String | The id string of the picked ViewportBody |
bodyIndex | Int | Zero-based draw-order index |
kind | PrimitiveKind | .face, .edge, or .vertex |
triangleIndex | Int | Primitive index within the body (triangle for face picks, segment for edge picks, point for vertex picks) |
pickLayer | PickLayer | .userGeometry or .widget |
rawValue | UInt32 | Raw R32Uint value (bits 0–15: objectIndex; 16–29: primitiveID; 30–31: kind) |
pickResult carries .userGeometry picks. Widget-layer picks (manipulator handles, etc.) land separately in widgetPickResult and are never mixed into the selection stream.
Callback alternative
If you prefer a callback over Combine, assign onPick:
controller.onPick = { result in
if let result {
highlightBody(id: result.bodyID)
}
}
Body-level selection
ViewportController maintains selectedBodyIDs: Set<String>, which the renderer uses to draw a highlight outline around selected bodies. Select programmatically or mirror a pick result:
// Single select
controller.selectBody("bracket", toggle: false)
// Multi-select (toggle mode)
controller.selectBody("bracket", toggle: true)
// Mirror GPU pick → body selection
controller.onPick = { result in
guard let result else { return }
controller.selectBody(result.bodyID, toggle: false)
}
// Clear
controller.deselectAll()
// or
controller.clearSelection() // also clears pickResult
SelectionFilter chains
A SelectionFilter is a composable predicate that runs on a decoded PickResult after the GPU pass. A result that fails the filter is treated as a miss — since the GPU resolves exactly one primitive per pixel, there is no fallback candidate.
Assign one to ViewportController.selectionFilter:
// Faces only
controller.selectionFilter = .faces
// Edges or vertices
controller.selectionFilter = .edges.or(.vertices)
// Faces, excluding a construction body
controller.selectionFilter = .faces.and(.excludingBodyIDs(["ground_plane"]))
// Custom predicate
controller.selectionFilter = SelectionFilter { result in
result.kind == .face && result.bodyID.hasPrefix("solid_")
}
// Remove the filter (accept everything)
controller.selectionFilter = nil
Built-in factory filters:
| Filter | Accepts |
|---|---|
.all | Everything |
.nothing | Nothing |
.faces | .face primitives |
.edges | .edge primitives |
.vertices | .vertex primitives |
.kind(_:) | Exact PrimitiveKind |
.kinds(_:) | Set of PrimitiveKind |
.bodyIDs(_:) | Allow-listed body IDs |
.excludingBodyIDs(_:) | Deny-listed body IDs |
.bodyIndices(_:) | Allow-listed draw-order indices |
.layer(_:) | Specific PickLayer |
Compose with .and(_:), .or(_:), .negated, SelectionFilter.all(of:), and SelectionFilter.any(of:).
Excluding bodies from picks with isPickable
Set ViewportBody.isPickable = false to keep a body visible but invisible to the GPU pick pass. This is the right way to handle datum planes, grid meshes, or any decorative geometry that should never steal a tap from real parts:
let groundPlane = ViewportBody(
id: "ground",
vertexData: planeVerts,
indices: planeIndices,
isPickable: false // drawn, but excluded from pick texture
)
The body’s objectIndex still advances in the draw order so other indices remain stable. You do not need a SelectionFilter as well — isPickable: false is cheaper because it skips the pick sub-passes entirely.
CPU raycast with SceneRaycast
The GPU pick pass identifies which body and primitive was hit but carries no world-space position. Use SceneRaycast when you need the actual hit point in 3D (for measurements, snapping, or distance-aware filtering).
Constructing a ray from a tap
Convert a screen-space tap location to NDC, then use Ray.fromCamera:
// screenPoint: CGPoint in the viewport's coordinate system (origin top-left)
// viewportSize: CGSize of the MetalViewportView
func makeRay(from screenPoint: CGPoint,
viewportSize: CGSize,
controller: ViewportController) -> Ray {
let aspectRatio = Float(viewportSize.width / viewportSize.height)
// NDC: x in [-1, 1] right, y in [-1, 1] up
let ndcX = Float(screenPoint.x / viewportSize.width) * 2.0 - 1.0
let ndcY = 1.0 - Float(screenPoint.y / viewportSize.height) * 2.0
let ndc = SIMD2<Float>(ndcX, ndcY)
return Ray.fromCamera(
ndc: ndc,
cameraState: controller.cameraState,
aspectRatio: aspectRatio
)
}
Ray.fromCamera handles both perspective and orthographic projections automatically.
Casting the ray
Pass the ray, your body array, and a bounding-box cache. SceneRaycast.cast runs AABB broadphase (slab method) then Möller–Trumbore narrowphase on survivors, returning the nearest hit:
func castRay(_ ray: Ray,
bodies: [ViewportBody],
controller: ViewportController) -> RaycastHit? {
// Build a simple AABB cache from each body's bounding box
var bbCache: [String: BoundingBox] = [:]
for body in bodies {
bbCache[body.id] = body.boundingBox
}
return SceneRaycast.cast(
ray: ray,
bodies: bodies,
boundingBoxCache: bbCache
)
}
RaycastHit gives you bodyID: String, point: SIMD3<Float> (world-space), and distance: Float from the ray origin.
Full tap-to-world-point example
func onTap(at screenPoint: CGPoint,
viewportSize: CGSize,
controller: ViewportController,
bodies: [ViewportBody]) {
let aspectRatio = Float(viewportSize.width / viewportSize.height)
let ndcX = Float(screenPoint.x / viewportSize.width) * 2.0 - 1.0
let ndcY = 1.0 - Float(screenPoint.y / viewportSize.height) * 2.0
let ray = Ray.fromCamera(
ndc: SIMD2<Float>(ndcX, ndcY),
cameraState: controller.cameraState,
aspectRatio: aspectRatio
)
var bbCache: [String: BoundingBox] = [:]
for body in bodies { bbCache[body.id] = body.boundingBox }
if let hit = SceneRaycast.cast(ray: ray, bodies: bodies, boundingBoxCache: bbCache) {
print("hit \(hit.bodyID) at \(hit.point), distance \(hit.distance)")
}
}
Direct ray construction
You can also build a Ray directly for programmatic use (e.g. automated testing):
let ray = Ray(
origin: SIMD3<Float>(0, 10, 0),
direction: SIMD3<Float>(0, -1, 0) // normalized automatically
)
Projecting world points back to screen
Use ProjectionUtility to go the other direction — world position to screen coordinates, for annotation placement or custom overlays:
let vpMatrix = controller.cameraState.viewProjectionMatrix(aspectRatio: aspectRatio)
if let screenPt = ProjectionUtility.worldToScreen(
point: hit.point,
vpMatrix: vpMatrix,
viewportSize: viewportSize
) {
// screenPt is a CGPoint with origin at top-left, Y-down
drawAnnotation(at: screenPt)
}
worldToScreen returns nil when the point is behind the camera. worldToNDC gives the same result in NDC space when you need to stay in normalized coordinates.
Two-stream pick routing
Bodies tagged with pickLayer: .widget bypass the selectionFilter and land in widgetPickResult instead of pickResult. This lets manipulator widgets (e.g. from OCCTSwiftAIS) have their own pick stream without colliding with user geometry selection:
let handle = ViewportBody(
id: "translate_x",
vertexData: arrowVerts,
indices: arrowIndices,
pickLayer: .widget // routed to controller.widgetPickResult
)
faceIndices — mapping triangles to BREP faces
When your geometry comes from a B-Rep tessellation, supply faceIndices to map each triangle back to its source face ID. This lets you identify which BREP face was tapped from a .face PickResult:
let body = ViewportBody(
id: "solid",
vertexData: meshVerts,
indices: meshIndices,
faceIndices: perTriangleFaceIDs // [Int32], one entry per triangle
)
// In the pick handler:
controller.onPick = { result in
guard let result, result.kind == .face else { return }
let faceID = body.faceIndices[result.triangleIndex]
print("tapped BREP face", faceID)
}
faceIndices is optional (defaults to []). When empty, triangleIndex is still valid as a raw triangle index within the body’s indices buffer.