Viewport Body & Geometry
These types are the complete input interface for the Metal rendering pipeline. ViewportBody is the geometry-source-agnostic container passed to ViewportController; ViewportArc describes analytic circular/arc edges that the renderer re-samples adaptively each frame; PBRMaterial carries physically based material parameters; the supporting enums (BodyPrimitiveKind, RenderLayer, PickLayer) and the TriangleStyle struct control how a body is drawn and picked; and ArcSampling provides the pure utility that the renderer uses to decide how many line segments to allocate per arc.
All types are Sendable and value types (structs or enums), safe to pass across actor boundaries.
Topics
- ViewportBody · Vertex data layout · Primitive factories
- ViewportArc · ArcSampling
- PBRMaterial
- TriangleStyle
- BodyPrimitiveKind · RenderLayer · PickLayer
ViewportBody
public struct ViewportBody: Identifiable, Sendable
A renderable body for the Metal viewport. Holds interleaved vertex/normal data, triangle indices, optional polyline edges, analytic arcs, point-cloud vertices, per-triangle highlight styles, a PBR material, and rendering/picking metadata. The renderer reads all of these; the consumer creates and mutates ViewportBody values and hands them to ViewportController.
Every call to init increments a global generation counter. The renderer compares generation between frames to know whether GPU buffers must be re-uploaded, avoiding expensive vertex-data diffing.
Vertex data layout
vertexData is a flat [Float] of interleaved position + normal, stride 6 floats (24 bytes):
index 0 1 2 3 4 5 6 7 8 9 10 11 ...
px py pz nx ny nz | px py pz nx ny nz | …
Accessing vertex i: base = i * 6; position = vertexData[base..<base+3]; normal = vertexData[base+3..<base+6].
init
public init(
id: String,
vertexData: [Float],
indices: [UInt32],
edges: [[SIMD3<Float>]],
arcs: [ViewportArc] = [],
faceIndices: [Int32] = [],
edgeIndices: [Int32] = [],
vertices: [SIMD3<Float>] = [],
vertexIndices: [Int32] = [],
vertexColors: [SIMD4<Float>] = [],
triangleStyles: [TriangleStyle] = [],
color: SIMD4<Float>,
roughness: Float = 0.5,
metallic: Float = 0.0,
material: PBRMaterial? = nil,
pointRadius: Float = 0.05,
primitiveKind: BodyPrimitiveKind = .mesh,
isVisible: Bool = true,
isPickable: Bool = true,
renderLayer: RenderLayer = .geometry,
pickLayer: PickLayer = .userGeometry,
transform: simd_float4x4 = matrix_identity_float4x4
)
Creates a body and assigns it the next unique generation value. The two required non-defaulted parameters are id, color, and the three geometry arrays (vertexData, indices, edges).
| Parameter | Description |
|---|---|
id | Stable string identifier; used as the dictionary key in ViewportController. |
vertexData | Interleaved [px, py, pz, nx, ny, nz, …], stride 6. See vertex data layout. |
indices | Triangle index list. indices.count must be divisible by 3. |
edges | Polylines for wireframe rendering. Each inner array is one connected polyline. |
arcs | Analytic circular/arc edges in body-local space. Renderer re-samples adaptively per frame. Default []. |
faceIndices | Per-triangle B-Rep face index, parallel to triangle count (indices.count / 3). Used for face pick mapping. Default []. |
edgeIndices | Per-segment source-edge index, parallel to the flattened edge segment list across all edges polylines. Enables edge-pick mapping. Default [] — body is not edge-pickable. |
vertices | Point list for vertex-pick sprites. Default [] — body is not vertex-pickable. |
vertexIndices | Per-point source-vertex index, parallel to vertices. Default [] — pick reports the raw vertex array index. |
vertexColors | Per-point RGBA colors, parallel to vertices. Only consumed by the .point point-cloud pass. Default []. |
triangleStyles | Per-triangle highlight overrides, parallel to triangle count. Default [] (no highlight pass). |
color | Base RGBA color. Used as effectiveMaterial.baseColor + opacity when material is nil. |
roughness | PBR roughness 0–1. Default 0.5. Ignored when material is set. |
metallic | PBR metallic factor 0–1. Default 0.0. Ignored when material is set. |
material | Full PBRMaterial. When set, overrides color/roughness/metallic. Default nil. |
pointRadius | World-space point-sprite radius for .point bodies. Projected and clamped to [1, 64] px. Default 0.05. |
primitiveKind | .mesh (default), .point, or .wire. See BodyPrimitiveKind. |
isVisible | false skips the body entirely. Default true. |
isPickable | false excludes the body from the GPU pick buffer while still drawing it. Default true. |
renderLayer | .geometry (normal depth test) or .overlay (always-on-top). Default .geometry. |
pickLayer | .userGeometry routes hits to ViewportController.pickResult; .widget routes to widgetPickResult. Default .userGeometry. |
transform | Per-body model matrix applied on top of the scene model matrix in the vertex shader. Default matrix_identity_float4x4. |
Example — shaded mesh from tessellated BREP data:
// triangles and edgePolylines come from OCCTSwift tessellation
let body = ViewportBody(
id: "bracket",
vertexData: triangles.interleavedVertexNormals, // [Float], stride 6
indices: triangles.indices,
edges: edgePolylines,
faceIndices: triangles.faceIndices,
color: SIMD4<Float>(0.72, 0.74, 0.76, 1.0),
roughness: 0.4,
metallic: 0.8
)
viewportController.bodies["bracket"] = body
Properties
id: String
Unique identifier for this body. Used as the dictionary key in ViewportController.bodies.
generation: UInt64
Monotonically increasing tag assigned at init time. The renderer compares this between frames; a changed value triggers a full GPU buffer re-upload. Read-only (let).
vertexData: [Float]
Interleaved position + normal, stride 6. See vertex data layout.
indices: [UInt32]
Triangle index buffer. indices.count / 3 equals the triangle count.
edges: [[SIMD3<Float>]]
Polylines for wireframe rendering. Each sub-array is one continuous polyline — for a closed loop, repeat the first point at the end.
arcs: [ViewportArc]
Analytic arc/circle feature edges in body-local space. Renderer samples these to line segments adaptively per frame using ArcSampling.segmentCount. Empty by default.
faceIndices: [Int32]
Per-triangle source face index, parallel to indices.count / 3. Maps GPU face picks back to B-Rep face IDs for sub-body selection. Empty if not applicable.
edgeIndices: [Int32]
Per-segment source-edge index, parallel to the flattened segments across all edges polylines. Empty if not applicable — in which case the body is not edge-pickable.
vertices: [SIMD3<Float>]
Point list rendered as pick sprites for vertex picking. Empty if not applicable — body is not vertex-pickable.
vertexIndices: [Int32]
Per-point source-vertex index, parallel to vertices. Empty defaults to identity mapping.
vertexColors: [SIMD4<Float>]
Per-point RGBA colors, parallel to vertices. Only used by the .point point-cloud render pass. Empty means all points use color.
pointRadius: Float
World-space radius for point sprites. Projected through the MVP matrix and clamped to [1, 64] px by the shader (Apple Metal [[point_size]] limit). Default 0.05.
primitiveKind: BodyPrimitiveKind
Selects the render pass: .mesh (shaded + wireframe), .point (point-cloud sprites), or .wire (edge-only). See BodyPrimitiveKind.
triangleStyles: [TriangleStyle]
Per-triangle highlight overrides. When non-empty, count must equal indices.count / 3. Entries with color.w == 0 are skipped. The renderer composites non-zero-alpha styles over the shaded pass with .lessEqual depth, preventing silhouette flicker. Mutating this field triggers a GPU style-buffer re-upload without disturbing vertex/index/edge buffers.
color: SIMD4<Float>
Base RGBA color. Used by effectiveMaterial when material is nil. The alpha channel drives opacity.
roughness: Float
PBR perceptual roughness 0 (mirror) – 1 (fully rough). Default 0.5. Ignored when material is set.
metallic: Float
PBR metallic factor 0 (dielectric) – 1 (metal). Default 0.0. Ignored when material is set.
material: PBRMaterial?
Full PBR material. When set, overrides color, roughness, and metallic. Use effectiveMaterial to read the resolved value regardless of which source is active.
isVisible: Bool
When false, the body is completely skipped by the renderer (not drawn, not picked).
isPickable: Bool
When false, the body is drawn normally but excluded from the GPU pick buffer. Useful for datum planes, ground planes, and always-on-top reference geometry that should not steal picks from real geometry behind them.
renderLayer: RenderLayer
.geometry — normal depth test (default). .overlay — body is drawn after the selection outline pass with an always-pass depth state, visible even when occluded. Used by manipulator widgets.
pickLayer: PickLayer
.userGeometry — hit results flow into ViewportController.pickResult (default). .widget — hit results flow into ViewportController.widgetPickResult, keeping manipulator picks out of the user selection stream.
transform: simd_float4x4
Per-body model matrix. Applied in the vertex shader on top of the scene model matrix. Lets the renderer reposition a body (e.g., during a manipulator drag) without re-uploading vertex data. Default matrix_identity_float4x4.
Computed properties
var boundingBox: BoundingBox?
public var boundingBox: BoundingBox? { get }
Returns the axis-aligned bounding box of all vertex positions in vertexData (stride 6, positions at offsets 0-2). Falls back to vertices when vertexData is empty, so .point bodies report a usable extent for shadow framing, pick culling, and CameraState.fit(to:). Returns nil if both sources are empty.
var effectiveMaterial: PBRMaterial
public var effectiveMaterial: PBRMaterial { get }
Returns material if set; otherwise synthesizes a PBRMaterial from the legacy color/roughness/metallic fields. The renderer always reads this rather than either source directly.
Example:
var body = ViewportBody.box(id: "b", color: SIMD4<Float>(0.8, 0.8, 0.8, 1))
// Color-only body — effectiveMaterial synthesizes from color/roughness/metallic.
print(body.effectiveMaterial.roughness) // 0.5
// Switch to a named PBR preset:
body.material = .steel
print(body.effectiveMaterial.metallic) // 1.0
worldHitPoint(ray:triangleIndex:)
public func worldHitPoint(ray: Ray, triangleIndex: Int) -> SIMD3<Float>?
Returns the world-space point where ray intersects the triangle at triangleIndex, accounting for this body’s transform.
Looks up the three vertex positions from indices/vertexData (stride 6), transforms each into world space via transform, and intersects with the ray using Möller-Trumbore. Returns nil if triangleIndex is out of range or the ray misses the (transformed) triangle.
triangleIndex matches PickResult.triangleIndex for a .face pick result — pass the two together to find where the user tapped on a surface.
| Parameter | Description |
|---|---|
ray | World-space ray (typically from Ray.fromCamera via ProjectionUtility). |
triangleIndex | Zero-based triangle index; matches PickResult.triangleIndex. |
Example — tap-to-measure world point:
// result from ViewportController.pickResult, ray from tap NDC coords
if result.kind == .face,
let point = body.worldHitPoint(ray: tapRay, triangleIndex: result.triangleIndex) {
viewportController.addMeasurementPoint(point)
}
Primitive factories
Convenience static methods on ViewportBody that generate common CAD primitives with interleaved vertex/normal data, triangle indices, closed polyline edges, and faceIndices. All are defined in Primitives.swift.
box(id:width:height:depth:color:)
public static func box(
id: String,
width: Float = 1,
height: Float = 1,
depth: Float = 1,
color: SIMD4<Float> = SIMD4<Float>(0.8, 0.8, 0.8, 1.0)
) -> ViewportBody
Creates a flat-shaded box centered at the origin. Six faces, four vertices each (24 vertices total, unique per face for flat normals). Twelve polyline edges (four per axis-aligned loop plus four connecting edges). faceIndices maps each triangle pair to its face (0 = front +Z, 1 = back −Z, 2 = right +X, 3 = left −X, 4 = top +Y, 5 = bottom −Y).
Example:
let crate = ViewportBody.box(id: "crate", width: 2, height: 1, depth: 1.5,
color: SIMD4<Float>(0.6, 0.4, 0.2, 1))
cylinder(id:radius:height:segments:color:)
public static func cylinder(
id: String,
radius: Float = 0.5,
height: Float = 1,
segments: Int = 64,
color: SIMD4<Float> = SIMD4<Float>(0.8, 0.8, 0.8, 1.0)
) -> ViewportBody
Creates a cylinder aligned along the Y axis, centered at the origin. Side, top cap, and bottom cap are separate geometry regions with their own normals. Polyline edges: top ring, bottom ring, and up to eight evenly-spaced vertical lines. faceIndices: face 0 = side, face 1 = top cap, face 2 = bottom cap.
Example:
let pin = ViewportBody.cylinder(id: "pin", radius: 0.05, height: 0.8)
sphere(id:radius:segments:rings:color:)
public static func sphere(
id: String,
radius: Float = 0.5,
segments: Int = 48,
rings: Int = 32,
color: SIMD4<Float> = SIMD4<Float>(0.8, 0.8, 0.8, 1.0)
) -> ViewportBody
Creates a UV sphere centered at the origin. Smooth per-vertex normals. Polyline edges: one equatorial ring plus four meridians (at 0°, 90°, 180°, 270°). All triangles belong to faceIndices face 0 (single continuous surface).
Example:
let ball = ViewportBody.sphere(id: "ball", radius: 0.25, segments: 64, rings: 48)
ViewportArc
public struct ViewportArc: Sendable, Hashable
An analytic circular arc in body-local space. Unlike polyline edges (pre-sampled), ViewportArc is re-tessellated by the renderer adaptively to the arc’s projected screen size each frame, so circular features stay smooth at any zoom level without the consumer choosing a segment count.
A point at angle θ on the arc is computed as:
center + radius * (cos(θ) · xAxis + sin(θ) · yAxis)
Picking: arcs are pickable. A hit reports PickResult.kind == .edge with triangleIndex equal to the arc’s index in ViewportBody.arcs. A body that mixes both edges and arcs cannot distinguish them from kind alone — prefer one representation per body.
Properties
var center: SIMD3<Float>
Arc center in body-local space.
var radius: Float
Arc radius.
var xAxis: SIMD3<Float>
Unit in-plane axis at angle 0. Must be orthogonal to yAxis.
var yAxis: SIMD3<Float>
Unit in-plane axis at angle π/2. The arc lies in the plane spanned by xAxis and yAxis; their cross product is the arc’s normal.
var startAngle: Float
Start angle in radians. Default 0.
var endAngle: Float
End angle in radians. endAngle > startAngle sweeps counter-clockwise in the xAxis→yAxis plane. Default 2 * .pi (full circle).
var sweep: Float
The total swept angle: abs(endAngle - startAngle). Read-only computed.
init(center:radius:xAxis:yAxis:startAngle:endAngle:)
public init(
center: SIMD3<Float>,
radius: Float,
xAxis: SIMD3<Float>,
yAxis: SIMD3<Float>,
startAngle: Float = 0,
endAngle: Float = 2 * .pi
)
Example — arc on a cylindrical hole edge:
// A 60° arc on a horizontal circle at y = 0.5, radius 0.3
let arc = ViewportArc(
center: SIMD3<Float>(0, 0.5, 0),
radius: 0.3,
xAxis: SIMD3<Float>(1, 0, 0),
yAxis: SIMD3<Float>(0, 0, 1),
startAngle: 0,
endAngle: .pi / 3
)
body.arcs.append(arc)
circle(center:radius:xAxis:yAxis:)
public static func circle(
center: SIMD3<Float>,
radius: Float,
xAxis: SIMD3<Float>,
yAxis: SIMD3<Float>
) -> ViewportArc
Convenience factory for a full circle (startAngle = 0, endAngle = 2π). xAxis and yAxis must be unit-length and orthogonal; their cross product is the circle’s normal.
Example — circular edge on a bore:
// XY-plane circle at the top face of a cylinder, z = 1.0
let topEdge = ViewportArc.circle(
center: SIMD3<Float>(0, 0, 1),
radius: 0.5,
xAxis: SIMD3<Float>(1, 0, 0),
yAxis: SIMD3<Float>(0, 1, 0)
)
body.arcs = [topEdge]
point(at:)
public func point(at t: Float) -> SIMD3<Float>
Returns the body-local position at parameter t ∈ [0, 1], mapping linearly from startAngle to endAngle.
Example:
let mid = arc.point(at: 0.5) // midpoint of the arc
ArcSampling
public enum ArcSampling
Namespace for the adaptive segment-count algorithm used by the renderer when tessellating ViewportArc values to line segments. Exposed publicly so host apps or custom renderers can use the same heuristic.
segmentCount(arc:mvp:viewportSize:targetPixels:minSegments:maxSegments:)
public static func segmentCount(
arc: ViewportArc,
mvp: simd_float4x4,
viewportSize: SIMD2<Float>,
targetPixels: Float = 6,
minSegments: Int = 6,
maxSegments: Int = 512
) -> Int
Returns the number of line segments to use when rendering arc, chosen so that each segment is roughly targetPixels pixels long on screen. The algorithm:
- Coarsely samples 8 evenly-spaced arc points, projects through
mvp, and sums the on-screen pixel-length. - Divides by
targetPixelsto get a pixel-driven segment count. - Applies an angular floor of 1 segment per ~12° (
Float.pi / 15) so small on-screen circles remain round. - Clamps to
[minSegments, maxSegments]. - Falls back to
max(minSegments, maxSegments / 4)if any sample projects behind the camera (w ≤ 0).
| Parameter | Default | Description |
|---|---|---|
arc | — | The arc to measure. |
mvp | — | Model-view-projection matrix for the current frame. |
viewportSize | — | Viewport dimensions in pixels, as SIMD2<Float>. |
targetPixels | 6 | Desired pixel length per segment. |
minSegments | 6 | Lower clamp. |
maxSegments | 512 | Upper clamp. |
Example — custom renderer:
let n = ArcSampling.segmentCount(
arc: arc,
mvp: uniforms.modelViewProjectionMatrix,
viewportSize: SIMD2<Float>(Float(drawableSize.width), Float(drawableSize.height))
)
// Emit n+1 vertices from arc.point(at:) calls
for i in 0...n {
let p = arc.point(at: Float(i) / Float(n))
// ... add to line buffer
}
PBRMaterial
public struct PBRMaterial: Sendable, Codable, Hashable
Physically based material parameters following the glTF 2.0 metallic-roughness model, extended with a clearcoat layer (KHR_materials_clearcoat), IOR-driven F0 (KHR_materials_ior), and HDR emissive strength (KHR_materials_emissive_strength). When clearcoat == 0 the material reduces to standard glTF 2.0 metallic-roughness.
Assign to ViewportBody.material to override the legacy color/roughness/metallic fields.
init
public init(
baseColor: SIMD3<Float> = SIMD3<Float>(0.8, 0.8, 0.8),
metallic: Float = 0,
roughness: Float = 0.5,
ior: Float = 1.5,
clearcoat: Float = 0,
clearcoatRoughness: Float = 0.03,
emissive: SIMD3<Float> = SIMD3<Float>(0, 0, 0),
emissiveStrength: Float = 1,
opacity: Float = 1
)
Properties
var baseColor: SIMD3<Float>
Linear RGB albedo for dielectrics; F0 tint for metals.
var metallic: Float
0 = dielectric, 1 = metal. Intermediate values are not physically meaningful but useful for blending.
var roughness: Float
Perceptual roughness 0 (mirror) – 1 (fully rough). Squared internally for GGX microfacet distribution.
var ior: Float
Index of refraction for dielectrics. Default 1.5 (plastic, glass). Drives F0 = ((ior-1)/(ior+1))² for non-metals. Ignored when metallic >= 1.
var clearcoat: Float
Clearcoat layer strength. 0 = no coat, 1 = full polyurethane-like coat. Enables a second specular lobe.
var clearcoatRoughness: Float
Roughness of the clearcoat layer, independent of base roughness. Default 0.03 (sharp coat).
var emissive: SIMD3<Float>
Linear RGB emissive color. Multiplied by emissiveStrength before tonemapping.
var emissiveStrength: Float
Emissive intensity multiplier. Values > 1 produce true HDR bloom-ready emission.
var opacity: Float
Surface opacity. 1 = opaque. Values < 1 alpha-blend the body against the background; not a transmission model.
Presets
public static let presets: [String: PBRMaterial]
Built-in materials for the common engineering visualization palette, keyed by stable lowercase identifiers. Available as convenience static properties:
| Property | Key | Notes |
|---|---|---|
PBRMaterial.steel | "steel" | Metallic, roughness 0.35 |
PBRMaterial.brushedAluminum | "brushedAluminum" | Metallic, roughness 0.55 |
PBRMaterial.brass | "brass" | Metallic, roughness 0.30 |
PBRMaterial.copper | "copper" | Metallic, roughness 0.30 |
PBRMaterial.chromedSteel | "chromedSteel" | Metallic, roughness 0.05 |
PBRMaterial.gold | "gold" | Metallic, roughness 0.20 |
PBRMaterial.titanium | "titanium" | Metallic, roughness 0.45 |
PBRMaterial.plasticGlossy | "plasticGlossy" | Dielectric, ior 1.5, roughness 0.25 |
PBRMaterial.plasticMatte | "plasticMatte" | Dielectric, ior 1.5, roughness 0.85 |
PBRMaterial.paintedAutomotive | "paintedAutomotive" | Dielectric + clearcoat 1.0 |
PBRMaterial.rubber | "rubber" | Dielectric, roughness 0.95 |
PBRMaterial.glass | "glass" | Dielectric, opacity 0.3 |
Example:
var body = ViewportBody.box(id: "housing", color: .zero /* overridden */)
body.material = .paintedAutomotive
Custom material example:
let anodizedBlue = PBRMaterial(
baseColor: SIMD3<Float>(0.1, 0.2, 0.6),
metallic: 0.9,
roughness: 0.3,
ior: 1.5
)
body.material = anodizedBlue
TriangleStyle
public struct TriangleStyle: Hashable, Sendable
Per-triangle highlight color. Alpha 0 means no highlight (renderer skips); alpha > 0 composites the color over the base shading at that triangle using a .lessEqual depth pass, preventing silhouette flicker on coplanar geometry.
Populate ViewportBody.triangleStyles (parallel to triangle count) to highlight specific triangles — for example, the triangles of a selected B-Rep face.
init(color:)
public init(color: SIMD4<Float> = .zero)
Properties
var color: SIMD4<Float>
RGBA highlight color. Alpha 0 = no highlight; alpha > 0 composites over base shading.
static let none: TriangleStyle
Pre-built no-highlight style (color = .zero). Use to clear individual entries without allocating.
Example — highlight triangles belonging to a selected face:
guard var body = viewportController.bodies["bracket"] else { return }
let triangleCount = body.indices.count / 3
var styles = Array(repeating: TriangleStyle.none, count: triangleCount)
// faceIndices maps triangle → face ID; highlight face 3
for i in 0..<triangleCount where body.faceIndices[i] == 3 {
styles[i] = TriangleStyle(color: SIMD4<Float>(0.2, 0.6, 1.0, 0.5))
}
body.triangleStyles = styles
viewportController.bodies["bracket"] = body
BodyPrimitiveKind
public enum BodyPrimitiveKind: Sendable, Hashable
Selects which renderer pass draws the body.
| Case | Behavior |
|---|---|
.mesh | Default. Shaded triangles from vertexData/indices + wireframe edges from edges/arcs. |
.point | Point-cloud sprites from vertices. Ignores vertexData/indices/edges. vertexColors and pointRadius apply. |
.wire | Edge-only intent. Currently rendered identically to .mesh with empty vertexData — use an empty indices array and populate edges/arcs. |
Example — point cloud:
let pts: [SIMD3<Float>] = scanPoints
let body = ViewportBody(
id: "scan",
vertexData: [],
indices: [],
edges: [],
vertices: pts,
color: SIMD4<Float>(0.9, 0.7, 0.2, 1),
pointRadius: 0.02,
primitiveKind: .point
)
RenderLayer
public enum RenderLayer: Hashable, Sendable
Controls when the body is drawn relative to depth testing.
| Case | Behavior |
|---|---|
.geometry | Normal depth test. Default for all bodies. |
.overlay | Drawn after the selection outline pass with an always-pass depth state. The body is visible even when fully occluded by other geometry. Used by manipulator widgets and similar always-on-top affordances. |
PickLayer
public enum PickLayer: Hashable, Sendable
Determines which pick result stream receives hits on this body.
| Case | Destination |
|---|---|
.userGeometry | ViewportController.pickResult — the main user-selection stream. Default. |
.widget | ViewportController.widgetPickResult — a separate stream for manipulator/widget hits, so OCCTSwiftAIS or custom gizmos can handle their own picks without polluting the user selection stream. |