A SwiftUI viewer
A macOS app in Swift with two panes: the same app as the Qt viewer, in SwiftUI's terms. On the left, a sidebar with the file's node tree: a tick shows or hides a part, and a selection highlights it. On the right, the file drawn by cadaclysm_view with Metal, from its exact surfaces and with its edges: drag to orbit, drag with the right button or two fingers to pan, scroll the wheel or pinch to zoom, and click a part to select it in the sidebar. The viewer owns the GPU, the frame and the camera; the app hands it an NSView and the mouse. Swift calls both C libraries directly, with no wrapper in between: the two headers are imported as one Clang module.
What you need
- macOS 14 (Sonoma) or later, on Apple silicon or Intel.
- Xcode 15 or later, or its command-line tools: Swift 5.9 and SwiftPM.
- The macOS cadaclysm release archive, unpacked (
cadaclysm-<version>-macos-universal, see Getting started). It holdslibcadaclysm_capi.dylib, which reads the file (cadaclysm.h, the C API), andlibcadaclysm_view.dylib, which draws it (cadaclysm_view.h, its section of the C API).
The view loads nothing itself. It finds the cadaclysm_capi that the process has already loaded and reads the scene through it. The app is a Swift package with one executable and one module map:
cadaclysm-<version>-macos-universal/
├── include/ cadaclysm.h, cadaclysm_view.h
└── lib/ libcadaclysm_capi.dylib, libcadaclysm_view.dylib
swiftui_viewer/
├── Package.swift the build
└── Sources/
├── CCadaclysmView/ the two C headers as one Clang module
│ ├── module.modulemap
│ └── shim.h
└── swiftui_viewer/
├── Model.swift the open file: its scene, its node tree, what both panes share
├── Sidebar.swift the node tree
├── Viewport.swift the NSView the view draws into, the frames and the mouse
└── App.swift the app: the window, the open panel, the split view
The build
Package.swift finds the unpacked archive through CADACLYSM_DIR, or a folder (or a symlink) named cadaclysm beside the package. Its include/ goes on the C header search path. Its lib/ goes on the link line and into the executable's rpath, so the program finds both libraries when it runs (the release's dylibs are @rpath/ libraries for exactly this).
// swift-tools-version:5.9
import Foundation
import PackageDescription
// An unpacked cadaclysm release archive: CADACLYSM_DIR=<its folder>, or a folder named
// `cadaclysm` beside this file. Its include/ has the two headers, its lib/ the two libraries.
let here = URL(fileURLWithPath: #filePath).deletingLastPathComponent()
let sdk = ProcessInfo.processInfo.environment["CADACLYSM_DIR"].flatMap { $0.isEmpty ? nil : $0 }
?? here.appendingPathComponent("cadaclysm").path
let package = Package(
name: "swiftui_viewer",
platforms: [.macOS(.v14)],
targets: [
// The two C headers, imported as one Clang module.
.systemLibrary(name: "CCadaclysmView", path: "Sources/CCadaclysmView"),
.executableTarget(
name: "swiftui_viewer",
dependencies: ["CCadaclysmView"],
swiftSettings: [.unsafeFlags(["-Xcc", "-I\(sdk)/include"])],
linkerSettings: [
// The reader, and the viewer that draws what it read.
.linkedLibrary("cadaclysm_capi"),
.linkedLibrary("cadaclysm_view"),
// Found at run time where they were found at link time.
.unsafeFlags(["-L\(sdk)/lib", "-Xlinker", "-rpath", "-Xlinker", "\(sdk)/lib"]),
]),
]
)
The module map makes the two headers one module, CCadaclysmView. Here is Sources/CCadaclysmView/module.modulemap:
// cadaclysm.h (the reader) and cadaclysm_view.h (the viewer), from the archive's include/,
// which Package.swift puts on the header search path.
module CCadaclysmView [system] {
header "shim.h"
export *
}
and the shim.h it names:
#include <cadaclysm.h>
#include <cadaclysm_view.h>
Swift sees the C API as it is. cadaclysm_open takes a Swift String, a scene or a view is an OpaquePointer, and the structs come with a zeroing init(). A #defined constant is a number to convert, as in UInt32(CADACLYSM_VIEW_WINDOW_APPKIT).
The model
Model.swift opens the file and walks its node tree once, into plain Node values for the sidebar. A node's id is its index in the scene. That index is also what the view's node calls and picks take, so there is no mapping between the two. The model holds what the two panes share: which nodes are ticked, which one is selected and which are expanded. It closes the scene when it goes, after the viewport has destroyed its view (Step 5).
import CCadaclysmView
import Combine
import Foundation
/// `CADACLYSM_NONE`: no node.
let noNode = UInt32.max
/// One node of the file's tree, as the sidebar lists it. Its id is the node's index, which is
/// also what the view's node calls and picks take, so there is no mapping between the two.
struct Node: Identifiable {
let id: UInt32
let label: String
let children: [Node]
}
/// The open file: its scene and its node tree, and what the sidebar and the viewport share --
/// which nodes are ticked, which is selected, which are expanded.
final class Model: ObservableObject {
let scene: OpaquePointer // CadaclysmScene *
let path: String
private(set) var roots: [Node] = []
private var parents: [UInt32: UInt32] = [:]
@Published var visible: [UInt32: Bool] = [:]
@Published var selection: UInt32?
@Published var expanded: Set<UInt32> = []
/// A tick changed: the node, and whether it is now shown.
let visibilityChanged = PassthroughSubject<(UInt32, Bool), Never>()
init(path: String) throws {
guard let scene = cadaclysm_open(path, nil) else {
throw NSError(domain: "cadaclysm", code: 1,
userInfo: [NSLocalizedDescriptionKey: String(cString: cadaclysm_last_error())])
}
self.scene = scene
self.path = path
roots = (0..<cadaclysm_root_count(scene)).map { addNode(cadaclysm_root(scene, $0), parent: noNode) }
expanded = Set(roots.map(\.id)) // the roots open, as Qt's expandToDepth(0)
}
// The viewport holds the model, and destroys its view first: the scene goes after it.
deinit { cadaclysm_close(scene) }
// The node and its children's, recursively.
private func addNode(_ node: UInt32, parent: UInt32) -> Node {
var label = String(cString: cadaclysm_node_name(scene, node))
if label.isEmpty { label = String(cString: cadaclysm_node_kind(scene, node)) }
if label.isEmpty { label = "#\(node)" }
parents[node] = parent
visible[node] = cadaclysm_node_visible(scene, node)
let children = (0..<cadaclysm_node_child_count(scene, node)).map {
addNode(cadaclysm_node_child(scene, node, $0), parent: node)
}
return Node(id: node, label: label, children: children)
}
func setVisible(_ node: UInt32, _ on: Bool) {
visible[node] = on
visibilityChanged.send((node, on))
}
/// Selects a node the viewport picked, opening every node above it so its row is there.
func reveal(_ node: UInt32?) {
var up = node.flatMap { parents[$0] } ?? noNode
while up != noNode {
expanded.insert(up)
up = parents[up] ?? noNode
}
selection = node
}
}
reveal is for a click in the viewport. The picked node may be deep in a collapsed branch, so every node above it is opened before it is selected; in the Qt version, a QTreeWidget does this on its own.
The sidebar
Sidebar.swift is a List bound to the model's selection, with one row per node. A node with children is a DisclosureGroup, and whether it is open lives in the model's expanded set, so reveal can open it. Each row is a checkbox Toggle tagged with the node's id: the list selects by the tag, and the checkbox is the tick.
import SwiftUI
/// The node tree on the left: a tick shows or hides a node, a selection highlights it.
struct Sidebar: View {
@ObservedObject var model: Model
var body: some View {
ScrollViewReader { scroller in
List(selection: $model.selection) {
ForEach(model.roots) { NodeRow(node: $0, model: model) }
}
// A click in the viewport selects a node: bring its row into sight.
.onChange(of: model.selection) { _, node in
if let node { withAnimation { scroller.scrollTo(node) } }
}
}
}
}
/// One node's row, and its children's under a disclosure triangle.
struct NodeRow: View {
let node: Node
@ObservedObject var model: Model
var body: some View {
if node.children.isEmpty {
label
} else {
DisclosureGroup(isExpanded: expanded) {
ForEach(node.children) { NodeRow(node: $0, model: model) }
} label: {
label
}
}
}
private var label: some View {
Toggle(node.label, isOn: visible)
.toggleStyle(.checkbox)
.tag(node.id)
.id(node.id)
}
private var visible: Binding<Bool> {
Binding(get: { model.visible[node.id] ?? true }, set: { model.setVisible(node.id, $0) })
}
private var expanded: Binding<Bool> {
Binding(get: { model.expanded.contains(node.id) },
set: { if $0 { model.expanded.insert(node.id) } else { model.expanded.remove(node.id) } })
}
}
The viewport
Here is the whole of Viewport.swift: an NSViewRepresentable for SwiftUI's layout, and the NSView under it.
import AppKit
import CCadaclysmView
import Combine
import QuartzCore
import SwiftUI
/// The right-hand pane, as SwiftUI places it.
struct Viewport: NSViewRepresentable {
let model: Model
func makeNSView(context: Context) -> ViewportView { ViewportView(model: model) }
func updateNSView(_ nsView: ViewportView, context: Context) {}
}
/// An NSView backed by a CAMetalLayer that the view draws into. The view owns the device, the
/// frame and the camera; this NSView hands it the window and the mouse.
final class ViewportView: NSView {
private let model: Model
private var view: OpaquePointer? // CadaclysmView *, made once the NSView is in a window, at size
private var viewSize = CGSize.zero // the size last given to the view
private var item: UInt32 = 0 // the scene, as the view shows it
private var highlighted = noNode
private var displayLink: CADisplayLink?
private var watching: Set<AnyCancellable> = []
private var pressed = CGPoint.zero, last = CGPoint.zero
init(model: Model) {
self.model = model
super.init(frame: .zero)
wantsLayer = true
// What the sidebar asks for: each call covers the node and everything under it.
model.visibilityChanged.sink { [weak self] node, on in self?.showNode(node, on) }.store(in: &watching)
model.$selection.removeDuplicates().sink { [weak self] node in self?.highlight(node ?? noNode) }
.store(in: &watching)
}
required init?(coder: NSCoder) { fatalError("not from a nib") }
override func makeBackingLayer() -> CALayer { CAMetalLayer() }
override var isFlipped: Bool { true } // y down, as the view counts pixels
override var acceptsFirstResponder: Bool { true }
private var scale: CGFloat { window?.backingScaleFactor ?? 1 }
private var pixels: CGSize { convertToBacking(bounds).size } // the view counts physical pixels
// ---- the view's lifetime: made at the first layout in a window, gone with the window
override func viewDidMoveToWindow() {
super.viewDidMoveToWindow()
guard window != nil, displayLink == nil else { return }
// Frames are drawn on demand: the link runs while the view owes one, then pauses.
let link = displayLink(target: self, selector: #selector(step))
link.add(to: .main, forMode: .common)
displayLink = link
needsLayout = true
}
// The view goes before its window. The link holds this NSView, so it goes too.
override func viewWillMove(toWindow newWindow: NSWindow?) {
super.viewWillMove(toWindow: newWindow)
guard newWindow == nil else { return }
displayLink?.invalidate()
displayLink = nil
cadaclysm_view_destroy(view)
view = nil
}
override func layout() {
super.layout()
let size = pixels
guard window != nil, size.width >= 1, size.height >= 1 else { return }
layer?.contentsScale = scale
if view == nil {
var host = CadaclysmViewWindow()
host.size = UInt32(MemoryLayout<CadaclysmViewWindow>.size)
host.kind = UInt32(CADACLYSM_VIEW_WINDOW_APPKIT)
host.window = Unmanaged.passUnretained(self).toOpaque() // the NSView
host.width = UInt32(size.width)
host.height = UInt32(size.height)
view = cadaclysm_view_create(&host, nil) // exact surfaces, Metal, Z up
guard let view else { fatalError("cadaclysm_view_create: \(String(cString: cadaclysm_view_last_error()))") }
item = cadaclysm_view_show(view, model.scene)
if item == 0 { print("cadaclysm_view_show:", String(cString: cadaclysm_view_last_error())) }
for (node, on) in model.visible where !on { cadaclysm_view_set_node_visible(view, item, node, false) }
highlighted = noNode
highlight(model.selection ?? noNode)
} else if size != viewSize {
cadaclysm_view_resize(view, UInt32(size.width), UInt32(size.height))
}
viewSize = size
requestDraw()
}
// Moved to a screen of another scale: the same points are another count of pixels.
override func viewDidChangeBackingProperties() {
super.viewDidChangeBackingProperties()
needsLayout = true
}
private func requestDraw() { displayLink?.isPaused = false }
// One frame: draw, answer a pick if one landed, and keep going while the view says a
// frame is still owed (parts uploading, exact surfaces refining, a pick in flight).
@objc private func step(_ link: CADisplayLink) {
guard let view else { link.isPaused = true; return }
if !cadaclysm_view_draw(view) { print("cadaclysm_view_draw:", String(cString: cadaclysm_view_last_error())) }
var pick = CadaclysmViewPick()
pick.size = UInt32(MemoryLayout<CadaclysmViewPick>.size)
if cadaclysm_view_pick_result(view, &pick) { model.reveal(pick.item != 0 ? pick.node : nil) }
link.isPaused = !cadaclysm_view_needs_draw(view)
}
// ---- the mouse and the trackpad: drag orbits, right drag or two fingers pan, the wheel
// ---- or a pinch zooms, a click picks
override func mouseDown(with event: NSEvent) { press(event) }
override func rightMouseDown(with event: NSEvent) { press(event) }
override func otherMouseDown(with event: NSEvent) { press(event) }
override func mouseDragged(with event: NSEvent) { drag(event, cadaclysm_view_orbit) }
override func rightMouseDragged(with event: NSEvent) { drag(event, cadaclysm_view_pan) }
override func otherMouseDragged(with event: NSEvent) { drag(event, cadaclysm_view_pan) }
private func press(_ event: NSEvent) {
pressed = convert(event.locationInWindow, from: nil)
last = pressed
}
private func drag(_ event: NSEvent, _ move: (OpaquePointer?, Float, Float) -> Void) {
let at = convert(event.locationInWindow, from: nil)
let dx = Float((at.x - last.x) * scale), dy = Float((at.y - last.y) * scale)
last = at
guard view != nil else { return }
move(view, dx, dy)
requestDraw()
}
override func mouseUp(with event: NSEvent) {
// A click, not a drag: ask what is under the cursor. The answer comes on a later frame.
let at = convert(event.locationInWindow, from: nil)
guard let view, abs(at.x - pressed.x) + abs(at.y - pressed.y) <= 3 else { return }
cadaclysm_view_request_pick(view, UInt32(at.x * scale), UInt32(at.y * scale))
requestDraw()
}
override func scrollWheel(with event: NSEvent) {
guard let view else { return }
if event.hasPreciseScrollingDeltas {
// Two fingers on a trackpad: the model follows them.
cadaclysm_view_pan(view, Float(event.scrollingDeltaX * scale), Float(event.scrollingDeltaY * scale))
} else {
cadaclysm_view_zoom(view, Float(event.scrollingDeltaY)) // a mouse wheel, in notches
}
requestDraw()
}
override func magnify(with event: NSEvent) {
guard let view else { return }
cadaclysm_view_zoom(view, Float(event.magnification * 10)) // a pinch
requestDraw()
}
// ---- what the sidebar asks for
private func showNode(_ node: UInt32, _ on: Bool) {
if let view, cadaclysm_view_set_node_visible(view, item, node, on) { requestDraw() }
}
private func highlight(_ node: UInt32) {
guard let view else { return }
if highlighted != noNode { cadaclysm_view_set_node_highlight(view, item, highlighted, nil) }
highlighted = node
let orange: [Float] = [1.0, 0.55, 0.1, 0.6] // alpha: how strongly it tints
if node != noNode { cadaclysm_view_set_node_highlight(view, item, node, orange) }
requestDraw()
}
}
The window handle. The view takes the NSView itself, as CADACLYSM_VIEW_WINDOW_APPKIT. The NSView's backing layer is a CAMetalLayer (makeBackingLayer), and the view draws into it with Metal. The view must be made, drawn and destroyed on the main thread, which is where AppKit makes all of these calls.
Made at the first layout. SwiftUI gives the NSView its size in layout(), once the NSView is in a window, so the view is made there at the size it really has. Later layouts resize it. It is destroyed in viewWillMove(toWindow: nil), before its window goes and before the model can close the scene.
Frames are drawn on demand. A CADisplayLink (from NSView.displayLink, macOS 14) runs step once per display refresh while it is unpaused. Each step draws, answers a pick if one landed, and pauses the link once cadaclysm_view_needs_draw says no frame is owed (one is owed while parts upload, while exact surfaces refine and while a pick is in flight). Anything that changes the picture unpauses it.
Sizes and pick coordinates are physical pixels, hence convertToBacking and backingScaleFactor: on a Retina screen, a pane 1000 points wide is 2000 pixels wide. isFlipped makes the NSView's y run downward, as the view counts it. The camera takes pixels dragged and wheel notches. On a trackpad, a two-finger scroll pans and a pinch zooms.
The app
App.swift opens the file named on the command line, or asks for one with an open panel, and puts the sidebar and the viewport in a NavigationSplitView. File › Open… (⌘O) replaces the file. .id gives the new model a new tree and a new viewport, so the old view is destroyed before the old scene is closed.
import AppKit
import SwiftUI
@main
struct SwiftUIViewerApp: App {
@NSApplicationDelegateAdaptor private var delegate: AppDelegate
@State private var model: Model?
var body: some Scene {
Window("swiftui_viewer", id: "main") {
Group {
if let model {
// The node tree on the left, the file drawn on the right.
NavigationSplitView {
Sidebar(model: model).navigationSplitViewColumnWidth(min: 200, ideal: 300)
} detail: {
Viewport(model: model)
}
.navigationTitle(URL(fileURLWithPath: model.path).lastPathComponent)
.id(ObjectIdentifier(model)) // another file: a new tree and a new viewport
} else {
Button("Open a CAD file…", action: open).padding(40)
}
}
.frame(minWidth: 800, minHeight: 500)
.onAppear {
// The file named on the command line, or ask for one.
if let path = CommandLine.arguments.dropFirst().first { load(path) } else { open() }
}
}
.defaultSize(width: 1280, height: 800)
.commands {
CommandGroup(replacing: .newItem) {
Button("Open…", action: open).keyboardShortcut("o")
}
}
}
private func open() {
let panel = NSOpenPanel()
panel.message = "Open a CAD file"
if panel.runModal() == .OK, let url = panel.url { load(url.path) }
}
private func load(_ path: String) {
do {
model = try Model(path: path)
} catch {
NSAlert(error: error).runModal()
}
}
}
final class AppDelegate: NSObject, NSApplicationDelegate {
func applicationDidFinishLaunching(_ notification: Notification) {
// Run from `swift run`, the executable is not in an app bundle: make it a regular
// app, with a Dock icon and a menu bar, and bring it to the front.
NSApp.setActivationPolicy(.regular)
NSApp.activate(ignoringOtherApps: true)
}
func applicationShouldTerminateAfterLastWindowClosed(_ sender: NSApplication) -> Bool { true }
}
Run with swift run, the program is a bare executable, not an app bundle, and macOS would give it no Dock icon, no menu bar and no focus. The app delegate asks for the place a regular app has. An Xcode app project, which has an Info.plist, does not need it.
Build it and run it
export CADACLYSM_DIR=~/cadaclysm-<version>-macos-universal
swift run swiftui_viewer part.step
With no file named, the open panel asks for one. The file can be STEP, IGES, SAT, 3dm, IFC, BREP or anything else the reader opens. The sidebar fills first. Then the parts appear on the right as they upload, with their edges drawn, coloured as the file colours them and grey where the file gives no colour. Drag to orbit. Drag with the right button, or with two fingers on a trackpad, to pan. Use the wheel or a pinch to zoom. Click a part to select it in the sidebar, and untick a node to hide it and everything under it.
To work on it in Xcode, open the package (xed .). Xcode does not pass a terminal's environment to the package build, so put a cadaclysm symlink to the archive beside Package.swift.
Without a license everything works, but cadaclysm_capi prints a notice for every file opened. To remove it, call cadaclysm_license_set("cadaclysm.lic") before the open, or put a cadaclysm.lic beside the executable.
Change it
Each change is one line on the view. To switch a layer on or off (surfaces, edges, curves, isocurves):
cadaclysm_view_set_layer(view, UInt32(CADACLYSM_VIEW_LAYER_ISOCURVES), true)
To draw triangles instead of exact surfaces:
cadaclysm_view_set_surfaces(view, UInt32(CADACLYSM_VIEW_SURFACES_TRIANGLES))
To give a node a colour of its own, over the file's (nil clears it):
let blue: [Float] = [0.2, 0.5, 0.9, 1.0]
cadaclysm_view_set_node_color(view, item, node, blue)
To switch to a standard view, then frame the camera on everything shown:
cadaclysm_view_standard(view, UInt32(CADACLYSM_VIEW_STANDARD_ISO))
cadaclysm_view_frame(view, 0)
Each of these changes the picture, so follow it with requestDraw(). To show more files in one view, call cadaclysm_view_show once per open scene, each with its own item, and cadaclysm_view_remove to drop one.
Where next
The reference is the view's section of the C API: every call, its errors, and the rule for the size field its structs share. The Swift API wraps the reader and the kernel in Swift classes, for an app that also reads attributes and placements or builds parts. The C API page covers the reader: nodes, attributes, placements and the rest of what the sidebar could show.