Feature Recognition & Medial Axis
FeatureRecognition.swift provides an Attributed Adjacency Graph (AAG) for B-Rep feature recognition — classifying faces and their shared-edge convexity to detect pockets and holes. MedialAxis.swift computes the Voronoi skeleton (medial axis transform) of a planar face, producing a graph of bisector arcs annotated with inscribed-circle radii for thin-wall detection and tool-path planning.
Topics
- EdgeConvexity · AAGNode · AAGEdge · AAG · PocketFeature · Feature Recognition Extensions (AAG) · Shape Extension — Feature Recognition · MedialAxisNode · MedialAxisArc · MedialAxis
EdgeConvexity
Classification of the dihedral angle at a shared edge between two adjacent faces.
public enum EdgeConvexity: Int32, Sendable {
case concave = -1 // Interior angle > 180° (pocket-like, going inward)
case smooth = 0 // Tangent faces (~180°)
case convex = 1 // Interior angle < 180° (fillet-like, going outward)
}
Maps to OCCTEdgeConvexity from the bridge. The classification is computed by OCCTEdgeGetConvexity using BRepAdaptor_Surface surface normals and edge tangents — specifically the sign of (tangent × n1) · n2 at the edge midpoint.
concave— the two face normals “open inward”; typical of pocket walls meeting a floor.smooth— faces are tangent (within ~0.5°); typical of filleted edges.convex— the two face normals “open outward”; typical of external edges.
AAGNode
A node in the Attributed Adjacency Graph, representing a single B-Rep face with precomputed analysis results.
public struct AAGNode: Sendable {
public let faceIndex: Int
public let normal: SIMD3<Double>?
public let isPlanar: Bool
public let isHorizontal: Bool
public let isUpward: Bool
public let isDownward: Bool
public let isVertical: Bool
public let zLevel: Double?
public let bounds: (min: SIMD3<Double>, max: SIMD3<Double>)
}
All fields are populated once during AAG.buildGraph() by querying the corresponding Face properties. normal is nil for degenerate faces; zLevel is nil for non-planar or non-horizontal faces.
AAGEdge
An edge in the Attributed Adjacency Graph, representing the adjacency relationship between two faces that share at least one B-Rep edge.
public struct AAGEdge: Sendable {
public let face1Index: Int
public let face2Index: Int
public let convexity: EdgeConvexity
public let sharedEdgeCount: Int
}
convexity is taken from the first shared edge between the two faces. sharedEdgeCount is the total number of B-Rep edges shared by the pair.
AAG
Attributed Adjacency Graph for feature recognition. Nodes are faces; graph edges connect pairs of adjacent faces and carry convexity attributes.
AAG.init(shape:)
Constructs the AAG by traversing all face pairs in the shape.
public init(shape: Shape)
Calls buildGraph() which iterates all (i, j) face pairs, tests adjacency via OCCTFacesAreAdjacent (backed by TopExp::MapShapes + TopoDS_Edge::IsSame), retrieves shared edges via OCCTFaceGetSharedEdges, and classifies each shared edge via OCCTEdgeGetConvexity (BRepAdaptor_Surface + BRep_Tool::CurveOnSurface).
- Parameters:
shape— the solid to analyse. Works best on closed solids. - OCCT:
TopExp::MapShapes/TopoDS_Edge::IsSame(adjacency);BRepAdaptor_Surface+BRep_Tool::CurveOnSurface(convexity). - Example:
let box = Shape.box(width: 10, height: 10, depth: 5)! let aag = AAG(shape: box) print(aag.nodes.count) // 6 print(aag.edges.count) // 12
shape
The shape this graph was built from.
public let shape: Shape
nodes
All nodes (one per face) in the graph, in face traversal order.
public private(set) var nodes: [AAGNode]
edges
All adjacency edges in the graph.
public private(set) var edges: [AAGEdge]
adjacencyList
Bidirectional adjacency map: adjacencyList[faceIndex][neighborIndex] gives the index into edges for that pair.
public private(set) var adjacencyList: [[Int: Int]]
neighbors(of:)
Returns the face indices of all faces adjacent to the given face.
public func neighbors(of faceIndex: Int) -> [Int]
- Parameters:
faceIndex— 0-based index intonodes. - Returns: Array of neighbor face indices; empty if
faceIndexis out of range. - Example:
let aag = Shape.box(width: 10, height: 10, depth: 5)!.buildAAG() let neighbors = aag.neighbors(of: 0) // A box face has 4 adjacent neighbors
edge(between:and:)
Returns the AAGEdge between two faces, if they are adjacent.
public func edge(between face1: Int, and face2: Int) -> AAGEdge?
- Parameters:
face1— first face index;face2— second face index. - Returns: The
AAGEdgedescribing their shared boundary, ornilif they are not adjacent or the index is out of range. - Example:
if let e = aag.edge(between: 0, and: 1) { print(e.convexity) // .convex for an external box edge }
concaveNeighbors(of:)
Returns the face indices of all neighbors connected to this face via concave edges.
public func concaveNeighbors(of faceIndex: Int) -> [Int]
Pocket floors are typically surrounded by concave-edge neighbors (vertical walls). Returns empty if faceIndex is out of range.
- Parameters:
faceIndex— 0-based face index. - Returns: Indices of neighbor faces where the shared edge is classified
.concave. - Example:
let pocketFloor = 2 let walls = aag.concaveNeighbors(of: pocketFloor)
convexNeighbors(of:)
Returns the face indices of all neighbors connected to this face via convex edges.
public func convexNeighbors(of faceIndex: Int) -> [Int]
- Parameters:
faceIndex— 0-based face index. - Returns: Indices of neighbor faces where the shared edge is classified
.convex.
PocketFeature
A recognized pocket feature detected from the AAG.
public struct PocketFeature: Sendable {
public let floorFaceIndex: Int
public let wallFaceIndices: [Int]
public let zLevel: Double
public let bounds: (min: SIMD3<Double>, max: SIMD3<Double>)
public let isOpen: Bool
public var depth: Double
}
floorFaceIndex
0-based index of the upward-facing horizontal face identified as the pocket floor.
public let floorFaceIndex: Int
wallFaceIndices
0-based indices of the vertical faces that form the pocket walls.
public let wallFaceIndices: [Int]
zLevel
Z coordinate of the pocket floor plane.
public let zLevel: Double
bounds
Axis-aligned bounding box of the pocket (floor + walls combined).
public let bounds: (min: SIMD3<Double>, max: SIMD3<Double>)
bounds.min.z equals zLevel; bounds.max.z is the height of the tallest wall.
isOpen
Whether the pocket is considered open (fewer than 3 walls).
public let isOpen: Bool
A pocket with fewer than 3 wall faces does not form a closed loop and is treated as open (e.g. a slot that opens at the side of a part).
depth
Approximate depth of the pocket: bounds.max.z - zLevel.
public var depth: Double
Pure-Swift computed property; no bridge call.
Feature Recognition Extensions (AAG)
These methods extend AAG with higher-level feature detection.
AAG.detectPockets()
Detects pocket features in the shape by AAG analysis.
public func detectPockets() -> [PocketFeature]
A pocket is identified when: (1) an upward-facing, horizontal, planar face exists as the floor; (2) that floor has at least one concave-edge neighbor; (3) the concave neighbors are vertical faces (walls). Results are sorted by ascending zLevel (deepest pocket first).
- Returns: Array of
PocketFeaturevalues, sorted deepest-first. - Example:
let part = Shape.box(width: 20, height: 20, depth: 10)! let aag = part.buildAAG() let pockets = aag.detectPockets() for p in pockets { print("floor \(p.floorFaceIndex), depth \(p.depth), open: \(p.isOpen)") }
AAG.detectHoles()
Detects candidate hole features (cylindrical or conical faces with all-concave adjacency).
public func detectHoles() -> [(faceIndex: Int, radius: Double, depth: Double)]
Identifies faces where every adjacent face is connected via a concave edge and the face’s XY bounding box has an aspect ratio under 1.2 (roughly circular) while being non-planar. radius and depth are estimated from the bounding box.
- Returns: Array of
(faceIndex, radius, depth)tuples;radiusis(width + height) / 4,depthisbounds.max.z - bounds.min.z. - Note: This is a heuristic approximation — it does not inspect the surface type. For precise cylindrical detection, check
Face.surfaceType == .cylinder. - Example:
let holes = aag.detectHoles() for h in holes { print("face \(h.faceIndex), r≈\(h.radius), d≈\(h.depth)") }
Shape Extension — Feature Recognition
Shape.buildAAG()
Constructs an Attributed Adjacency Graph for this shape.
public func buildAAG() -> AAG
Convenience wrapper around AAG(shape: self).
- Returns: A fully built
AAGfor the receiver. - Example:
let aag = Shape.box(width: 10, height: 10, depth: 5)!.buildAAG()
Shape.detectPocketsAAG()
Detects pockets using AAG-based feature recognition.
public func detectPocketsAAG() -> [PocketFeature]
Equivalent to buildAAG().detectPockets().
- Returns: Array of
PocketFeaturevalues, sorted deepest-first. - Example:
let pockets = myPart.detectPocketsAAG() for pocket in pockets { print("Z=\(pocket.zLevel), depth=\(pocket.depth)") }
MedialAxisNode
A node in the medial axis graph, representing a point on the skeleton with an associated inscribed-circle radius.
public struct MedialAxisNode: Sendable {
public let index: Int32
public let position: SIMD2<Double>
public let distance: Double
public let isPending: Bool
public let isOnBoundary: Bool
}
index— 1-based index within theMAT_Graph.position— 2D coordinates of the node in the plane of the face.distance— distance to the nearest boundary curve (inscribed-circle radius at this node). Half of local wall thickness.isPending—trueif the node has only one linked arc (a skeleton endpoint). WrapsMAT_Node::PendingNode.isOnBoundary—trueif the node lies on the shape boundary. WrapsMAT_Node::OnBasicElt.
MedialAxisArc
An arc in the medial axis graph, connecting two nodes along a bisector curve.
public struct MedialAxisArc: Sendable {
public let index: Int32
public let geomIndex: Int32
public let firstNodeIndex: Int32
public let secondNodeIndex: Int32
public let firstElementIndex: Int32
public let secondElementIndex: Int32
}
index— 1-based index within theMAT_Graph.geomIndex— geometry index referencing the bisector curve in theBRepMAT2d_BisectingLocus.firstNodeIndex/secondNodeIndex— 1-based indices of the endpoint nodes.firstElementIndex/secondElementIndex— 1-based indices of the boundary elements (input edges) the arc bisects.
MedialAxis
Medial axis (Voronoi skeleton) of a planar face. Computes the locus of centers of maximal inscribed circles within a 2D profile using BRepMAT2d_BisectingLocus.
MedialAxis.init?(of:tolerance:)
Computes the medial axis of the first planar face found in shape.
public init?(of shape: Shape, tolerance: Double = 1e-4)
Extracts the first face via TopExp_Explorer, runs BRepMAT2d_Explorer::Perform on it, then calls BRepMAT2d_BisectingLocus::Compute with MAT_Left join type and GeomAbs_Arc bisector type. Returns nil if no face is found, the computation does not complete, or the resulting graph has no arcs.
- Parameters:
shape— a shape containing at least one face;tolerance— computation tolerance (default 1e-4). - Returns:
nilif computation fails or the shape has no faces. - OCCT:
BRepMAT2d_Explorer::Perform+BRepMAT2d_BisectingLocus::Compute+MAT_Graph. - Example:
let rect = Shape.makeFace( wire: Shape.makePolygon([ SIMD3(0, 0, 0), SIMD3(10, 0, 0), SIMD3(10, 4, 0), SIMD3(0, 4, 0) ], closed: true)! )! if let ma = MedialAxis(of: rect) { print("Arcs: \(ma.arcCount), nodes: \(ma.nodeCount)") }
Graph Counts
arcCount
Number of bisector arcs in the medial axis graph.
public var arcCount: Int { get }
- OCCT:
MAT_Graph::NumberOfArcs.
nodeCount
Number of nodes (arc endpoints) in the medial axis graph.
public var nodeCount: Int { get }
- OCCT:
MAT_Graph::NumberOfNodes.
basicElementCount
Number of boundary elements (input edges) used in the computation.
public var basicElementCount: Int { get }
- OCCT:
BRepMAT2d_BisectingLocusbasic element count viaMAT_Graph.
Node Access
MedialAxis.node(at:)
Returns a node by its 1-based index.
public func node(at index: Int) -> MedialAxisNode?
- Parameters:
index— 1-based node index (1…nodeCount). - Returns:
MedialAxisNode, ornilif the index is out of range or the graph is null. - OCCT:
MAT_Graph::Node+BRepMAT2d_BisectingLocus::GeomElt(node). - Example:
if let ma = MedialAxis(of: face), let n = ma.node(at: 1) { print(n.position, n.distance) }
nodes
All nodes in the graph.
public var nodes: [MedialAxisNode] { get }
Iterates 1-based indices 1…nodeCount via node(at:).
- Returns: Array of all
MedialAxisNodevalues; empty if the graph has no nodes. - Example:
if let ma = MedialAxis(of: face) { let minDist = ma.nodes.map { $0.distance }.min() ?? 0 print("Min inscribed radius: \(minDist)") }
Arc Access
MedialAxis.arc(at:)
Returns an arc by its 1-based index.
public func arc(at index: Int) -> MedialAxisArc?
- Parameters:
index— 1-based arc index (1…arcCount). - Returns:
MedialAxisArc, ornilif the index is out of range or the graph is null. - OCCT:
MAT_Graph::Arc. - Example:
if let ma = MedialAxis(of: face), let a = ma.arc(at: 1) { print(a.firstNodeIndex, a.secondNodeIndex) }
arcs
All arcs in the graph.
public var arcs: [MedialAxisArc] { get }
Iterates 1-based indices 1…arcCount via arc(at:).
- Returns: Array of all
MedialAxisArcvalues; empty if the graph has no arcs.
Distance / Thickness
minThickness
Minimum inscribed circle radius across all nodes. Represents half of the minimum wall thickness.
public var minThickness: Double { get }
- Returns: Minimum
distancevalue across all nodes, or-1if the computation fails. - OCCT:
OCCTMedialAxisMinThickness— iterates allMAT_Nodepositions, computes distance to nearest boundary curve viaGeom2dAPI_ProjectPointOnCurve, returns the minimum. - Example:
if let ma = MedialAxis(of: thinWallPart) { let halfThickness = ma.minThickness print("Min wall thickness ≈ \(halfThickness * 2)") }
distanceToBoundary(arcIndex:parameter:)
Interpolated inscribed-circle radius along an arc at a given parameter.
public func distanceToBoundary(arcIndex: Int, parameter t: Double) -> Double
Samples a point on the arc’s bisector curve at parameter t and computes the distance to the nearest boundary element via Geom2dAPI_ProjectPointOnCurve.
- Parameters:
arcIndex— 1-based arc index;t— parameter in [0, 1] (0 = first node, 1 = second node). - Returns: Inscribed circle radius at the sampled point, or
-1on error. - OCCT:
BRepMAT2d_BisectingLocus::GeomBis+Geom2d_TrimmedCurve::Value+Geom2dAPI_ProjectPointOnCurve. - Example:
if let ma = MedialAxis(of: face) { let radiusMid = ma.distanceToBoundary(arcIndex: 1, parameter: 0.5) }
Drawing
drawArc(at:maxPoints:)
Samples points along a single bisector arc for visualization.
public func drawArc(at index: Int, maxPoints: Int = 32) -> [SIMD2<Double>]
Evaluates the arc’s bisector curve (BRepMAT2d_BisectingLocus::GeomBis) at uniformly spaced parameters. Infinite curve parameters are clamped to ±1000.
- Parameters:
index— 1-based arc index;maxPoints— maximum number of sample points (default 32). - Returns: Array of 2D points along the arc; empty on error.
- OCCT:
MAT_Graph::Arc+BRepMAT2d_BisectingLocus::GeomBis+Geom2d_TrimmedCurve::Value. - Example:
if let ma = MedialAxis(of: face) { let pts = ma.drawArc(at: 1, maxPoints: 20) for pt in pts { print(pt) } }
drawAll(maxPointsPerArc:)
Samples points along all bisector arcs, returning one polyline per arc.
public func drawAll(maxPointsPerArc: Int = 32) -> [[SIMD2<Double>]]
Calls OCCTMedialAxisDrawAll which fills a flat XY buffer and per-arc start/length arrays in one pass. More efficient than calling drawArc(at:) in a loop.
- Parameters:
maxPointsPerArc— maximum sample points per arc (default 32). - Returns: Array of polylines, one per arc; empty if
arcCount == 0. - OCCT:
BRepMAT2d_BisectingLocus::GeomBis(per arc) +Geom2d_TrimmedCurve::Value. - Example:
if let ma = MedialAxis(of: face) { let skeleton = ma.drawAll() for polyline in skeleton { // render each bisector arc as a 2D polyline print(polyline.count, "points") } }