TopologyGraph — Attributes, Snapshots & References
These are the pure-Swift value types that sit alongside TopologyGraph: a typed attribute store (NodeAttributeStore) that attaches arbitrary metadata to graph nodes, a GraphSnapshot that serializes attributes and the source shape for round-trip persistence, and TopologyRef — a recipe-based identity scheme that survives graph mutations. No C++ bridge code is involved in these types. See the main TopologyGraph page (coming) for the graph structure, node counts, adjacency queries, and history primitives (NodeRef, HistoryRecord, NodeKind) that these types build on.
Topics
- AttrValue · NodeAttributeStore · NodeAttributeStore — Codable · GraphSnapshot · GraphSnapshotError · Snapshot / Restore on TopologyGraph · TopologyRef · NodeRef.sentinel · TopologyResolutionError · resolve on TopologyGraph · currentForms on TopologyGraph
AttrValue
TopologyGraph.AttrValue is a closed, Codable union of the scalar and array types you can attach to a node. The closed set keeps snapshot round-trips lossless — no open extension point means no unknown cases when deserializing.
public enum AttrValue: Codable, Hashable, Sendable {
case bool(Bool)
case int(Int)
case double(Double)
case string(String)
case ints([Int]) // e.g. a mesh-region triangle index set
case doubles([Double]) // e.g. fitted-surface parameters
}
AttrValue.boolValue
Convenience unwrap — returns the wrapped Bool, or nil on type mismatch.
public var boolValue: Bool? { get }
AttrValue.intValue
Convenience unwrap — returns the wrapped Int, or nil on type mismatch.
public var intValue: Int? { get }
AttrValue.doubleValue
Convenience unwrap — returns the wrapped Double, or nil on type mismatch.
public var doubleValue: Double? { get }
AttrValue.stringValue
Convenience unwrap — returns the wrapped String, or nil on type mismatch.
public var stringValue: String? { get }
AttrValue.intsValue
Convenience unwrap — returns the wrapped [Int], or nil on type mismatch.
public var intsValue: [Int]? { get }
AttrValue.doublesValue
Convenience unwrap — returns the wrapped [Double], or nil on type mismatch.
public var doublesValue: [Double]? { get }
- Example:
let attr: TopologyGraph.AttrValue = .doubles([0.12, 0.34, 0.56]) if let params = attr.doublesValue { print("params:", params) }
NodeAttributeStore
NodeAttributeStore is a per-node attribute bag keyed by TopologyGraph.NodeRef. Keys are caller-namespaced strings (e.g. "reconstruct.residualRMS"). The store is Codable, Sendable, and Equatable; its Codable encoding is a sorted array of entries — see NodeAttributeStore — Codable.
public struct NodeAttributeStore: Codable, Sendable, Equatable
NodeAttributeStore.init(storage:)
Create a store, optionally pre-populated.
public init(storage: [TopologyGraph.NodeRef: [String: TopologyGraph.AttrValue]] = [:])
- Parameters:
storage— initial contents; defaults to empty. - Example:
var store = NodeAttributeStore()
NodeAttributeStore.storage
The raw dictionary backing the store.
public private(set) var storage: [TopologyGraph.NodeRef: [String: TopologyGraph.AttrValue]]
Direct mutation is not exposed; use the subscript and the mutating helpers below.
NodeAttributeStore.subscript(_:)
All attributes on a node — get returns an empty dictionary when no attributes are set; set with an empty dictionary removes the node entry entirely.
public subscript(node: TopologyGraph.NodeRef) -> [String: TopologyGraph.AttrValue] { get set }
- Parameters:
node— the node whose attribute dictionary to read or replace. - Example:
var store = NodeAttributeStore() let ref = TopologyGraph.NodeRef(kind: .face, index: 0) store[ref] = ["region": .int(3)] print(store[ref]["region"]?.intValue ?? -1) // 3
NodeAttributeStore.value(_:for:)
Read one attribute by key, or nil if unset.
public func value(_ key: String, for node: TopologyGraph.NodeRef) -> TopologyGraph.AttrValue?
- Parameters:
key— attribute name;node— the node to query. - Returns: The stored value, or
nilif the key is absent.
NodeAttributeStore.set(_:_:for:)
Set one attribute on a node.
public mutating func set(_ key: String, _ value: TopologyGraph.AttrValue, for node: TopologyGraph.NodeRef)
- Parameters:
key— attribute name;value— value to store;node— the target node.
NodeAttributeStore.clear(_:for:)
Remove one attribute. Drops the node entry entirely once its last attribute is cleared.
public mutating func clear(_ key: String, for node: TopologyGraph.NodeRef)
- Parameters:
key— attribute name to remove;node— the target node.
NodeAttributeStore.removeAll(for:)
Remove every attribute on a node.
public mutating func removeAll(for node: TopologyGraph.NodeRef)
- Parameters:
node— the node whose entire attribute dictionary should be dropped.
NodeAttributeStore.annotatedNodeCount
Number of nodes carrying at least one attribute.
public var annotatedNodeCount: Int { get }
- Example:
var store = NodeAttributeStore() let face0 = TopologyGraph.NodeRef(kind: .face, index: 0) let face1 = TopologyGraph.NodeRef(kind: .face, index: 1) store.set("tag", .string("critical"), for: face0) store.set("rms", .double(0.002), for: face1) print(store.annotatedNodeCount) // 2
NodeAttributeStore — Codable
The store uses a custom Codable implementation so that JSON output is deterministic and diffable. Attributes are serialized as a sorted array of {node, attrs} entries — attributes within each entry sorted by key; entries sorted by (kind.rawValue, index). Pairing this with GraphSnapshot.canonicalEncoder() (which adds .sortedKeys) makes the whole JSON byte-stable across runs.
NodeAttributeStore.init(from:)
Decode from a sorted-array encoding.
public init(from decoder: Decoder) throws
NodeAttributeStore.encode(to:)
Encode as a deterministically-sorted array.
public func encode(to encoder: Encoder) throws
GraphSnapshot
GraphSnapshot bundles everything needed to persist a TopologyGraph session: the source shape as a BREP string (which is sufficient to re-derive the graph structure), plus the attribute store. The graph topology is NOT stored — it is reconstructed from brep on TopologyGraph.init(snapshot:), relying on the fact that TopologyGraph.init(shape:) produces identical node indexing for the same BREP.
public struct GraphSnapshot: Codable, Sendable, Equatable
GraphSnapshot.currentFormatVersion
Current on-disk format version. Increment on any breaking schema change.
public static let currentFormatVersion = 1
GraphSnapshot.brep
BREP serialization of the source shape, used to re-derive the graph structure on load.
public var brep: String
GraphSnapshot.attributes
The per-node attribute store.
public var attributes: NodeAttributeStore
GraphSnapshot.formatVersion
The format version this snapshot was written with.
public var formatVersion: Int
GraphSnapshot.init(brep:attributes:formatVersion:)
Create a snapshot directly.
public init(brep: String, attributes: NodeAttributeStore, formatVersion: Int = GraphSnapshot.currentFormatVersion)
- Parameters:
brep— BREP string of the source shape.attributes— the attribute store.formatVersion— defaults tocurrentFormatVersion.
GraphSnapshot.canonicalEncoder()
Returns a JSONEncoder configured for byte-stable, diffable output.
public static func canonicalEncoder() -> JSONEncoder
Sets outputFormatting to [.sortedKeys]. Combined with NodeAttributeStore’s sorted-array encoding, the full snapshot JSON is reproducible byte-for-byte across runs — suitable for versioned sessions and golden-file tests.
- Returns: A configured
JSONEncoder. - Example:
guard let graph = TopologyGraph(shape: myShape) else { return } let snap = try graph.snapshot() let data = try GraphSnapshot.canonicalEncoder().encode(snap) try data.write(to: snapshotURL)
GraphSnapshotError
Errors raised while snapshotting or rebuilding a TopologyGraph.
public enum GraphSnapshotError: Error, Equatable, Sendable
GraphSnapshotError.noSourceShape
The graph has no captured source shape to serialize (e.g. built from a handle directly, not from a Shape).
case noSourceShape
GraphSnapshotError.invalidBREP
The snapshot’s BREP string could not be deserialized back into a Shape.
case invalidBREP
GraphSnapshotError.graphBuildFailed
The graph could not be rebuilt from the deserialized shape.
case graphBuildFailed
GraphSnapshotError.unsupportedFormatVersion(_:)
The snapshot was written by a newer, unsupported format version.
case unsupportedFormatVersion(Int)
- Associated value: The version number found in the snapshot.
Snapshot / Restore on TopologyGraph
TopologyGraph.attribute(_:for:)
Read one attribute on a node, or nil if unset.
public func attribute(_ key: String, for node: NodeRef) -> AttrValue?
- Parameters:
key— attribute name;node— the node to query. - Returns: The stored value, or
nil. - Note: Pure Swift — no bridge call.
- Example:
if let rms = graph.attribute("fit.residualRMS", for: faceRef) { print("RMS:", rms.doubleValue ?? 0) }
TopologyGraph.setAttribute(_:_:for:)
Set one attribute on a node.
public func setAttribute(_ key: String, _ value: AttrValue, for node: NodeRef)
- Parameters:
key— attribute name;value— value to store;node— the target node. - Note: Mutates
self.attributesin-place. Despiteselfbeing a class, callers don’t needmutating. - Example:
graph.setAttribute("region.id", .int(7), for: faceRef)
TopologyGraph.snapshot()
Export the attribute store and source shape for persistence or transport.
public func snapshot() throws -> GraphSnapshot
- Returns: A
GraphSnapshotcontaining the BREP string and the attribute store. - Throws:
GraphSnapshotError.noSourceShapeif the graph was not built from aShape(e.g. constructed from a raw handle). - Note: Pure Swift — no bridge call.
TopologyGraph.init(snapshot:)
Rebuild a graph from a snapshot: deserialize the BREP, rebuild the graph (non-parallel for deterministic node indexing), and reattach the attributes.
public convenience init(snapshot: GraphSnapshot) throws
- Parameters:
snapshot— the previously saved snapshot. - Throws:
GraphSnapshotError.unsupportedFormatVersionifsnapshot.formatVersion > currentFormatVersion.GraphSnapshotError.invalidBREPif the BREP string cannot be parsed.GraphSnapshotError.graphBuildFailedif graph construction fails.
- OCCT:
Shape.fromBREPString+OCCTBRepGraphCreatewithparallel: false. - Note: Attribute keys are
NodeRef(kind+index). The non-parallel rebuild ensures identical node indexing for the same BREP across runs — this is the contract that makes the round-trip safe. - Example:
// Round-trip guard let graph = TopologyGraph(shape: myShape) else { return } graph.setAttribute("quality", .string("high"), for: someRef) let snap = try graph.snapshot() let data = try GraphSnapshot.canonicalEncoder().encode(snap) // Later... let snap2 = try JSONDecoder().decode(GraphSnapshot.self, from: data) let graph2 = try TopologyGraph(snapshot: snap2) let quality = graph2.attribute("quality", for: someRef) // quality == .string("high")
TopologyRef
TopologyRef is a recipe-based topology identity (OCCTSwift #72, Phase 1). OCCT node indices (BRepGraph NodeId) are unstable across mutations — after a fillet, split, or Boolean operation, the same index may point to a different entity or nothing at all. TopologyRef encodes how to find an entity rather than where it is now, and TopologyGraph.resolve(_:) evaluates the recipe against the current graph state on demand.
The design follows Onshape’s FeatureScript query system (qCreatedBy, qContainedIn, etc.) and the Shapr3D / Onshape consensus: when a recipe can’t resolve, return an error rather than silently guessing.
public indirect enum TopologyRef: Sendable, Hashable
indirect enables recursive nesting (e.g. containedIn(parent: .createdBy(…), …)).
TopologyRef.literal(_:)
Direct reference by current (kind, index) — an escape hatch that bypasses recipe resolution.
case literal(TopologyGraph.NodeRef)
Use sparingly. A literal ref breaks the moment any mutation changes node indexing. Prefer .createdBy or .containedIn for any ref that must survive mutations.
TopologyRef.createdBy(operationName:kind:occurrence:leafOccurrence:)
The Nth node of kind that appears as a replacement in a history record tagged with operationName.
case createdBy(operationName: String,
kind: TopologyGraph.NodeKind,
occurrence: Int = 0,
leafOccurrence: Int? = 0)
- Parameters:
operationName— the tag recorded in the history log by the creating operation.kind— theNodeKindto look for in the replacement set.occurrence— which candidate to pick when the operation produced multiple nodes ofkind(default0= first, in deterministic sort order:sequenceNumber, then(kind.rawValue, index), then position in replacements vector).leafOccurrence— after the seed node is found, walk history forward to its current live form and pick the Nth leaf.nildisables the forward-walk and returns the node exactly as created (useful for history inspection). Default0.
- Example:
// Pick the first face created by an extrude operation let extrudeFace = TopologyRef.createdBy( operationName: "extrude_base", kind: .face, occurrence: 0 )
TopologyRef.containedIn(parent:kind:occurrence:)
The Nth descendant of kind contained within parent.
case containedIn(parent: TopologyRef,
kind: TopologyGraph.NodeKind,
occurrence: Int = 0)
- Parameters:
parent— a recipe resolving to the containing node (e.g. a solid or shell).kind— theNodeKindto collect from the parent’s children in the graph.occurrence— zero-based index into the children of that kind (order is stable across mutations for unmodified parents).
- Example:
// The second face of a solid created by a named operation let secondFace = TopologyRef.containedIn( parent: .createdBy(operationName: "make_box", kind: .solid), kind: .face, occurrence: 1 )
TopologyRef.splitOf(original:occurrence:)
The Nth replacement produced by the operation that split original into multiple nodes.
case splitOf(original: TopologyRef, occurrence: Int)
Typical use: picking one of two halves after an edge or face split.
- Parameters:
original— recipe for the node before the split.occurrence— index into the replacement list produced by the split.
- Example:
// Second half of an edge that was split let halfEdge = TopologyRef.splitOf( original: .literal(TopologyGraph.NodeRef(kind: .edge, index: 5)), occurrence: 1 )
NodeRef.sentinel
A sentinel NodeRef for recording pure creations that have no meaningful ancestor.
public static let sentinel = TopologyGraph.NodeRef(kind: .solid, index: -1)
Matches OCCT’s default-constructed BRepGraph_NodeId (kind .solid, index -1). isValid is false on the sentinel.
TopologyResolutionError
Errors returned from TopologyGraph.resolve(_:) when a recipe cannot be evaluated.
public enum TopologyResolutionError: Error, Sendable, Hashable
TopologyResolutionError.ancestorMissing(_:)
The parent ref in a .containedIn or .splitOf recipe could not itself be resolved.
case ancestorMissing(TopologyRef)
TopologyResolutionError.kindMismatch(expected:found:)
A resolved node has a different NodeKind than expected.
case kindMismatch(expected: TopologyGraph.NodeKind, found: TopologyGraph.NodeKind)
TopologyResolutionError.occurrenceOutOfRange(_:available:requested:)
The requested occurrence index exceeds the number of matching candidates.
case occurrenceOutOfRange(TopologyRef, available: Int, requested: Int)
- Associated values: The failing ref, how many candidates exist, what was asked for.
TopologyResolutionError.operationNotFound(_:)
No history record with the given operationName was found.
case operationNotFound(String)
TopologyResolutionError.noCurrentDescendant(_:)
The original node in a .splitOf recipe was found in history but no history record shows it as an original with multiple replacements.
case noCurrentDescendant(TopologyRef)
TopologyResolutionError.invalid(_:)
The ref is structurally invalid (e.g. a .literal wrapping a NodeRef with index < 0).
case invalid(TopologyRef)
resolve on TopologyGraph
TopologyGraph.resolve(_:)
Resolve a TopologyRef recipe against the graph’s current state.
public func resolve(_ ref: TopologyRef) -> Result<NodeRef, TopologyResolutionError>
Recipes are evaluated lazily — resolve performs the full lookup on every call, walking history records as needed. For hot paths, cache the resolved NodeRef and invalidate on any mutation.
- Parameters:
ref— the recipe to evaluate. - Returns:
.success(NodeRef)when the entity can be found;.failure(TopologyResolutionError)when it cannot. - Note: Pure Swift — no bridge call. The evaluation walks
historyRecordsandchildIndices(a public helper onTopologyGraphdefined inConstructionEntity.swift). - Example:
guard let graph = TopologyGraph(shape: myShape) else { return } let ref = TopologyRef.containedIn( parent: .literal(TopologyGraph.NodeRef(kind: .solid, index: 0)), kind: .face, occurrence: 0 ) switch graph.resolve(ref) { case .success(let node): print("Resolved to face index:", node.index) case .failure(let err): print("Resolution failed:", err) }
currentForms on TopologyGraph
TopologyGraph.currentForms(of:)
All current (live-leaf) descendants of node, in deterministic order.
public func currentForms(of node: NodeRef) -> [NodeRef]
A descendant is “live” when it does not appear as an original in any subsequent history record — i.e. it is the final form of that branch. Returns an empty array when node has no derived descendants at all (it may itself still be live; use findDerivedOrSelf from the main graph API for that case).
- Parameters:
node— the node to walk forward from. - Returns: Live leaf descendants sorted by
(kind.rawValue, index), or[]if there are none. - Note: Used internally by
.createdByresolution whenleafOccurrenceis non-nil. Callers can use it directly to enumerate all current forms of a node that may have been split by subsequent operations. - Example:
let seed = TopologyGraph.NodeRef(kind: .face, index: 2) let leaves = graph.currentForms(of: seed) if leaves.isEmpty { print("Face 2 has not been split") } else { print("Face 2 split into", leaves.count, "live faces") }