Measurement
OCCTSwift’s measurement layer adds ergonomic, one-liner accessors for the most common spatial queries: angles between edges, faces, axes, and planes; circle/arc geometry extraction; revolution-surface properties; and a snapshot type (ShapeMeasurements) that pre-computes per-face areas/centroids/perimeters and per-edge arc lengths for the whole shape in a single call. These are convenience wrappers over OCCTSwift’s existing geometry coverage (no new OCCT calls are introduced for angle computation; bridge calls are confined to circleProperties geometry recovery and Shape.measure(linearTolerance:), which produces a ShapeMeasurements).
Topics
- Angles, Edge · Angles, Face · Angles, ConstructionAxis · Angles, ConstructionPlane · Utility, unsignedAngle · Circle Properties, Edge · Revolution Properties, Face · ShapeMeasurements · Shape Extension, measure
Angles. Edge
Extension on Edge (defined in MeasurementHelpers.swift).
Edge.angle(to:atParameter:)
Angle between this edge’s tangent direction and another edge’s tangent direction.
public func angle(to other: Edge, atParameter t: Double = 0.5) -> Double?
Samples each edge’s tangent at the normalised parameter t (0 = start, 1 = end, default 0.5 = mid). For straight edges the result is the line-line angle. For curved edges it is a point estimate; the angle varies along the curve.
- Parameters:
other: the edge to measure against.atParameter: normalised parameter in[0, 1]specifying where to sample the tangent on each edge (default0.5, i.e. mid-curve). Clamped to[0, 1].
- Returns: Angle in radians in
[0, π], ornilif either edge has noparameterBoundsor the tangent cannot be evaluated. - OCCT: Pure-Swift over
Edge.tangent(at:)+Edge.parameterBounds. Tangent evaluation delegates toBRepAdaptor_Curve::DN. - Example:
let box = Shape.box(width: 10, height: 10, depth: 10)! let edges = box.edges() if edges.count >= 2, let a = edges[0].angle(to: edges[1]) { print(a * 180 / .pi) // degrees }
Edge.isParallel(to:toleranceRadians:)
Whether this edge is parallel to another edge at their mid-tangents.
public func isParallel(to other: Edge, toleranceRadians: Double = 1e-4) -> Bool?
Convenience wrapper over angle(to:). Returns true when the angle is within toleranceRadians of 0 or π (anti-parallel counts as parallel).
- Parameters:
other: edge to compare.toleranceRadians: angular tolerance (default1e-4rad ≈ 0.006°).
- Returns:
trueif parallel,falseif not,nilif the angle cannot be computed. - Example:
let edges = Shape.box(width: 10, height: 10, depth: 10)!.edges() if let parallel = edges[0].isParallel(to: edges[1]) { print(parallel) }
Edge.isPerpendicular(to:toleranceRadians:)
Whether this edge is perpendicular to another edge at their mid-tangents.
public func isPerpendicular(to other: Edge, toleranceRadians: Double = 1e-4) -> Bool?
Returns true when |angle - π/2| < toleranceRadians.
- Parameters:
other: edge to compare.toleranceRadians: angular tolerance (default1e-4rad).
- Returns:
trueif perpendicular,falseif not,nilif the angle cannot be computed. - Example:
let edges = Shape.box(width: 10, height: 5, depth: 2)!.edges() if let perp = edges[0].isPerpendicular(to: edges[3]) { print(perp) // true for adjacent box edges }
Angles. Face
Extension on Face (defined in MeasurementHelpers.swift).
Face.angle(to:)
Angle between the normals of two faces, evaluated at the UV midpoint of each.
public func angle(to other: Face) -> Double?
For two planar faces this equals the dihedral angle between the planes (after the normal-space mapping). For curved faces it is a point estimate at each face centre.
- Parameters:
other, the face to measure against. - Returns: Angle between the normals in radians in
[0, π], ornilif either face has nouvBoundsor the normal cannot be evaluated. - OCCT: Pure-Swift over
Face.normal(atU:v:)+Face.uvBounds. Normal evaluation delegates toGeomLProp_SLProps::Normal. - Example:
let box = Shape.box(width: 10, height: 10, depth: 10)! let faces = box.faces() if faces.count >= 2, let a = faces[0].angle(to: faces[1]) { print(a * 180 / .pi) // 90° for adjacent box faces }
Face.isParallel(to:toleranceRadians:)
Whether this face’s normal is parallel (or anti-parallel) to another face’s normal.
public func isParallel(to other: Face, toleranceRadians: Double = 1e-4) -> Bool?
Returns true when the normal-to-normal angle is within toleranceRadians of 0 or π.
- Parameters:
other: face to compare.toleranceRadians: angular tolerance (default1e-4rad).
- Returns:
trueif parallel,falseif not,nilif the angle cannot be computed. - Example:
let faces = Shape.box(width: 10, height: 10, depth: 5)!.faces() // Top and bottom faces of a box are parallel if let par = faces[0].isParallel(to: faces[5]) { print(par) // true }
Face.isPerpendicular(to:toleranceRadians:)
Whether this face is perpendicular to another (normals at 90°).
public func isPerpendicular(to other: Face, toleranceRadians: Double = 1e-4) -> Bool?
Returns true when |angle - π/2| < toleranceRadians.
- Parameters:
other: face to compare.toleranceRadians: angular tolerance (default1e-4rad).
- Returns:
trueif perpendicular,falseif not,nilif the angle cannot be computed. - Example:
let faces = Shape.box(width: 10, height: 10, depth: 5)!.faces() if let perp = faces[0].isPerpendicular(to: faces[2]) { print(perp) // true for a top face vs a side face }
Face.isCoplanar(with:tolerance:)
Whether this face is coplanar with another, normals are parallel AND their centre points lie on the same plane.
public func isCoplanar(with other: Face, tolerance: Double = 1e-6) -> Bool?
Two conditions must both hold: (1) normals are parallel within 1e-4 radians, (2) the signed distance from this face’s UV-midpoint to the other face’s plane is less than tolerance.
- Parameters:
other: face to compare.tolerance: point-to-plane distance tolerance (default1e-6).
- Returns:
trueif coplanar,falseif not,nilif normals or points cannot be evaluated. - Note: Returns
nil(notfalse) if the faces are not parallel, callers can distinguish “non-parallel” from “parallel but offset”. - Example:
let faces = Shape.box(width: 10, height: 10, depth: 5)!.faces() // Top and bottom are parallel but NOT coplanar if let cp = faces[0].isCoplanar(with: faces[5]) { print(cp) // false }
Angles. ConstructionAxis
Extension on ConstructionAxis (defined in MeasurementHelpers.swift).
ConstructionAxis.angle(to:in:)
Angle between two construction axes, resolved against the given topology graph.
public func angle(to other: ConstructionAxis, in graph: BRepGraph) -> Double?
Resolves each axis via BRepGraph.resolve(_:) to obtain its direction vector, then computes the unsigned angle between the directions.
- Parameters:
other: the axis to compare.graph: theBRepGraphused to resolve both axis handles.
- Returns: Angle in radians in
[0, π], ornilif either axis fails to resolve. - OCCT: Pure-Swift over
BRepGraph.resolve+unsignedAngle(between:and:). - Example:
let graph = BRepGraph(shape: shape)! let axisA = ConstructionAxis.absolute(origin: SIMD3(0, 0, 0), direction: SIMD3(1, 0, 0)) let axisB = ConstructionAxis.absolute(origin: SIMD3(0, 0, 0), direction: SIMD3(0, 1, 0)) if let a = axisA.angle(to: axisB, in: graph) { print(a * 180 / .pi) // 90 }
Angles. ConstructionPlane
Extension on ConstructionPlane (defined in MeasurementHelpers.swift).
ConstructionPlane.angle(to:in:)
Angle between two construction planes (angle between their Z-axis normals).
public func angle(to other: ConstructionPlane, in graph: BRepGraph) -> Double?
Resolves each plane via BRepGraph.resolve(_:) to obtain its zAxis vector, then computes the unsigned angle between them.
- Parameters:
other: the plane to compare.graph: theBRepGraphused to resolve both plane handles.
- Returns: Angle in radians in
[0, π], ornilif either plane fails to resolve. - OCCT: Pure-Swift over
BRepGraph.resolve+unsignedAngle(between:and:). - Example:
let graph = BRepGraph(shape: shape)! let planeA = ConstructionPlane.absolute(origin: SIMD3(0, 0, 0), normal: SIMD3(0, 0, 1)) let planeB = ConstructionPlane.absolute(origin: SIMD3(0, 0, 0), normal: SIMD3(1, 0, 0)) if let a = planeA.angle(to: planeB, in: graph) { print(a * 180 / .pi) // 90 }
Utility, unsignedAngle
Free function (defined in MeasurementHelpers.swift).
unsignedAngle(between:and:)
Unsigned angle in [0, π] between two 3D vectors.
public func unsignedAngle(between a: SIMD3<Double>, and b: SIMD3<Double>) -> Double?
Uses the clamped dot-product formula acos(dot(a,b) / (|a| * |b|)). Returns nil for degenerate (near-zero length) input, rather than reporting a degenerate/near-singular vector as parallel (angle 0) to everything (PR #897 review, finding 5).
- Parameters:
a: first vector (need not be unit length).b: second vector (need not be unit length).
- Returns: Angle in radians in
[0, π], ornilif either vector has length ≤1e-12. - Example:
let a = SIMD3<Double>(1, 0, 0) let b = SIMD3<Double>(0, 1, 0) if let angle = unsignedAngle(between: a, and: b) { print(angle) // π/2 }
Circle Properties. Edge
Extension on Edge, plus the nested CircleProperties struct (defined in MeasurementHelpers.swift).
Edge.CircleProperties
Extracted circle or arc geometry for a circular edge.
public struct CircleProperties: Sendable, Hashable {
public let center: SIMD3<Double>
public let radius: Double
public let axis: SIMD3<Double> // unit normal to the circle's plane
public let isFullCircle: Bool
public let startAngle: Double // radians; 0 for a full circle
public let endAngle: Double // radians; 2π for a full circle
}
center: 3D centre of the circle.radius: circle radius in model units.axis: unit normal to the circle’s plane (right-hand rule relative to the curve’s direction).isFullCircle:truewhenendAngle - startAngle ≈ 2π.startAngle/endAngle, parameter range in radians (equal toparameterBoundsfor aGeom_Circle).
Edge.CircleProperties.endAngle
End of the parameter range in radians; 2π for a full circle.
Edge.circleProperties
Circle or arc properties if this edge’s underlying curve is a circle. Returns nil for lines, ellipses, B-splines, etc.
public var circleProperties: CircleProperties? { get }
Checks curveType == .circle, then samples three points along the parameter range and fits a circle through them via circleThroughThreePoints. The axis direction is derived from the cross product of the chord vectors.
- Returns:
CircleProperties, ornilif the edge is not circular or parameter bounds are unavailable. - OCCT: Pure-Swift over
Edge.point(at:)+Edge.parameterBounds+ internalcircleThroughThreePoints. Curve type check usesBRepAdaptor_Curve::GetType. - Example:
let cyl = Shape.cylinder(radius: 5, height: 10)! for edge in cyl.edges() { if let cp = edge.circleProperties { print("r=\(cp.radius) full=\(cp.isFullCircle) axis=\(cp.axis)") } }
Revolution Properties. Face
Extension on Face, plus the nested RevolutionProperties struct (defined in MeasurementHelpers.swift).
Face.RevolutionProperties
Axis and representative radius for a revolved surface face.
public struct RevolutionProperties: Sendable, Hashable {
public let axis: ShapeAxis
public let radius: Double
}
axis: the primary revolution axis (aShapeAxiscarryingoriginanddirection).radius: distance to a representative surface point, in model units. For cylindrical faces this is the exact cylinder radius. For spherical faces it is the exact, constant sphere radius, every surface point is equidistant from the center regardless of the (arbitrary) poleprimaryAxisreports for a sphere, so this doesn’t depend onaxisthe way the other kinds below do (PR #897 review, finding 1). For cones, tori, and surfaces of revolution it is a representative radial distance from the axis at the UV midpoint, genuinely ambiguous since the true radius varies by position; useSurfacededicated properties for major/minor radii.
Face.revolutionProperties
Axis and representative radius if this face’s underlying surface is cylindrical, conical, toroidal, spherical, or a surface of revolution.
public var revolutionProperties: RevolutionProperties? { get }
Returns nil for planar faces or free-form (B-spline) surfaces. For spherical faces the radius is the exact distance from the UV-midpoint sample to the axis origin (the sphere’s center), not an axis-relative radial component, since a sphere’s primaryAxis is only an arbitrary construction-frame pole, not a real axis (PR #897 review, finding 1). For every other supported type the radius is computed as the distance from the axis line to the UV-midpoint of the face.
- Returns:
RevolutionProperties, ornilifprimaryAxisis unavailable or itskindis not one of.cylinder,.cone,.sphere,.torus,.revolution(ShapeAxis.Kind, notFace.surfaceType, switched from the latter to the former in the #914 review, third pass, so this predicate andresolveFaceAxisDirection’s identical one inConstructionEntity.swiftcan’t drift apart across two separately-maintained enums). - OCCT: Pure-Swift over
Face.primaryAxis+Face.uvBounds+Face.point(atU:v:).primaryAxisdelegates toBRepAdaptor_Surfaceaxis extraction. - Note: For surfaces where “radius” is ambiguous (e.g. a torus has major and minor radius), this returns only a single representative value. Use
Surfacefor full parametric detail. - Example:
let cyl = Shape.cylinder(radius: 5, height: 10)! for face in cyl.faces() { if let rp = face.revolutionProperties { print("r=\(rp.radius) axis=\(rp.axis.direction)") } }
ShapeMeasurements
Defined in ShapeMeasurements.swift. A snapshot of per-face and per-edge scalar measurements for a Shape, indexed parallel to Shape.faces() and Shape.edge(at:).
ShapeMeasurements (struct)
public struct ShapeMeasurements: Sendable
All four stored arrays are parallel to the shape’s face/edge enumeration order and are computed together by Shape.measure(linearTolerance:).
ShapeMeasurements.faceAreas
Per-face surface areas, indexed parallel to shape.faces().
public let faceAreas: [Double]
faceAreas[i] is the area of shape.faces()[i]. Computed via BRepGProp::SurfaceProperties.
- OCCT:
BRepGProp::SurfaceProperties+GProp_GProps::Mass. - Example:
let m = Shape.box(width: 10, height: 10, depth: 5)!.measure() for (i, area) in m.faceAreas.enumerated() { print("face \(i): area = \(area)") }
ShapeMeasurements.edgeLengths
Per-edge arc lengths, indexed parallel to 0..<shape.edgeCount.
public let edgeLengths: [Double]
edgeLengths[i] is the arc length of shape.edge(at: i). A missing edge (nil from Shape.edge(at:)) contributes 0.0.
- OCCT:
Edge.length, delegates toBRepGProp::LinearProperties+GProp_GProps::Mass. - Example:
let m = Shape.box(width: 10, height: 10, depth: 5)!.measure() print(m.edgeLengths) // 12 values for a box
ShapeMeasurements.faceCentroids
Per-face surface centres of mass, indexed parallel to shape.faces().
public let faceCentroids: [SIMD3<Double>?]
faceCentroids[i] is the surface centre-of-mass of shape.faces()[i], computed via BRepGProp_Sinert (surface inertia), or nil when that face has no area to have a centroid. Empty array if the struct was constructed without centroids.
Optional since #609, for the same reason facePerimeters always was: a zero-area face reported (0,0,0), which a dimension widget cannot tell apart from a face genuinely centred on the origin. The entry is nil rather than dropped, so the array stays index-parallel with faces().
- OCCT:
OCCTBRepGPropSinert→BRepGProp_Sinert::CentreOfMass. - Example:
let m = Shape.box(width: 10, height: 10, depth: 5)!.measure() for (i, c) in m.faceCentroids.enumerated() { guard let c else { print("face \(i): no area, so no centroid"); continue } print("face \(i) centroid: \(c)") }
ShapeMeasurements.facePerimeters
Per-face outer-boundary lengths, indexed parallel to shape.faces().
public let facePerimeters: [Double?]
facePerimeters[i] is the arc length of the outer wire of shape.faces()[i], or nil if the face has no outer wire or wire length is unavailable. Inner-wire (hole) perimeters are excluded.
- OCCT:
Face.outerWire?.length→BRepTools::OuterWire+BRepGProp::LinearProperties. - Example:
let m = Shape.box(width: 10, height: 10, depth: 5)!.measure() for p in m.facePerimeters { print(p.map { "\($0)" } ?? "no outer wire") }
ShapeMeasurements.init(faceAreas:edgeLengths:faceCentroids:facePerimeters:)
Memberwise initialiser for constructing ShapeMeasurements directly.
public init(
faceAreas: [Double],
edgeLengths: [Double],
faceCentroids: [SIMD3<Double>?] = [],
facePerimeters: [Double?] = []
)
Useful when building measurement snapshots programmatically (e.g. from cached data). faceCentroids and facePerimeters default to empty arrays for back-compat with callers that only need areas and lengths.
- Parameters:
faceAreas: per-face areas parallel toshape.faces().edgeLengths: per-edge lengths parallel to0..<shape.edgeCount.faceCentroids: per-face centroids; defaults to[].facePerimeters: per-face outer-wire lengths; defaults to[].
- Example:
let m = ShapeMeasurements(faceAreas: [50, 50, 100, 100, 50, 50], edgeLengths: Array(repeating: 10, count: 12)) print(m.totalFaceArea) // 400
ShapeMeasurements.totalFaceArea
Sum of all face areas.
public var totalFaceArea: Double { get }
Convenience over faceAreas.reduce(0, +): N independently-toleranced per-face integrals (Face.area(tolerance:), tunable via Shape.measure(linearTolerance:)).
Not the same computation as Shape.surfaceArea (#885): see Shape-Features.md for why Shape.surfaceArea, Shape.surfaceInertiaProperties().mass and Shape.surfaceInertia.area can disagree with this total, and Shape.measure(linearTolerance:) below for the measured gap and which total to reach for.
- Example:
let m = Shape.box(width: 10, height: 10, depth: 10)!.measure() print(m.totalFaceArea) // 600.0
ShapeMeasurements.totalEdgeLength
Sum of all edge arc lengths.
public var totalEdgeLength: Double { get }
Convenience over edgeLengths.reduce(0, +).
- Example:
let m = Shape.box(width: 10, height: 10, depth: 10)!.measure() print(m.totalEdgeLength) // 120.0 (12 edges × 10)
ShapeMeasurements.totalFacePerimeter
Sum of all available face outer-wire lengths (nil entries are treated as zero).
public var totalFacePerimeter: Double { get }
Convenience over facePerimeters.reduce(0) { acc, p in acc + (p ?? 0) }.
- Example:
let m = Shape.box(width: 10, height: 10, depth: 10)!.measure() print(m.totalFacePerimeter) // 240.0 (6 faces × 4 × 10)
Shape Extension, measure
Shape.measure(linearTolerance:)
Compute per-face area / centroid / perimeter and per-edge arc length for this shape in one call.
public func measure(linearTolerance: Double = 1e-6) -> ShapeMeasurements
Iterates faces() and edge(at:), computing all four measurement arrays. The faceCentroids array is populated from Face.surfaceInertia (which calls BRepGProp_Sinert); facePerimeters uses Face.outerWire?.length.
linearTolerance only ever moves faceAreas/totalFaceArea, never Shape.surfaceArea and its two siblings (see Shape-Features.md for why, #885). At the default 1e-6 the two agree to ~12 significant figures on ordinary shapes (measured on a radius-10 sphere: 1256.637061435917 vs 1256.6370614359175), so tightening linearTolerance to “fix” a mismatch just makes totalFaceArea diverge further from the other, unmoved, one. Past linearTolerance = 0.001, BRepGProp::SurfaceProperties’s own adaptive integration gives way to a non-adaptive mode (documented on the OCCT call itself), and the gap can become real: the same sphere measured 1216.31... at linearTolerance: 0.5 against the other three’s fixed 1256.64..., a ~3.2% difference, not floating-point noise.
- Parameters:
linearTolerance, numerical integration tolerance forwarded toFace.area(tolerance:)(default1e-6). Tighten only if you observe precision issues, this does not convergetotalFaceAreatowardsurfaceAreapast a point, see above. - Returns: A
ShapeMeasurementssnapshot with all four arrays populated and indexed parallel to the shape’s face/edge enumeration. - OCCT:
BRepGProp::SurfaceProperties(face areas),BRepGProp_Sinert(centroids),BRepGProp::LinearProperties(edge lengths + outer-wire lengths),BRepTools::OuterWire(outer wire lookup). - Example:
let part = Shape.box(width: 100, height: 50, depth: 20)! let m = part.measure() print("surface area:", m.totalFaceArea) // 22000 print("total edge length:", m.totalEdgeLength) // 1440 for (i, (area, centroid)) in zip(m.faceAreas, m.faceCentroids).enumerated() { guard let centroid else { continue } // nil when the face has no area (#609) print("face \(i): area=\(area) centroid=\(centroid)") }